AI 技术博客
Android全栈11 分钟阅读6677

【全栈第14课】字符设备驱动 /dev/hello

Android 全栈工程师进阶教程 第14课。字符设备驱动 /dev/hello。基于公开 AOSP 知识整理,含原理、可编译示例、常见问题定位。

> 本文是《Android 全栈工程师进阶教程》系列第 14 课。完整 26 课见 GitHub 仓库

【全栈第14课】字符设备驱动 /dev/hello


0. 这节课你将做出什么

写一个真实的 Linux 字符设备驱动:编成 .ko,insmod 后自动创建 /dev/hello,支持 open/read/write/ioctl。 学完你能回答:一个驱动怎么变成 /dev/xxx 让用户空间访问?用户 read(fd) 后内核驱动里发生了什么?触控/传感器/指纹这些驱动的最基础形态长什么样?

1. 背景:Linux"一切皆文件",驱动也是

Linux 把设备抽象成文件:用户空间通过 /dev/xxx 这个"文件"来操作硬件——open 打开、read/write 读写、ioctl 发控制命令。

驱动的工作,就是实现这些文件操作背后的真正逻辑。字符设备(character device)是最基础的一类——按字节流访问(对比块设备按块),触控、传感器、串口、指纹大多是字符设备。

学会字符驱动 = 掌握"用户空间 ↔ 内核驱动 ↔ 硬件"这条路的内核端入口。

2. 全局图:字符驱动六要素

用户空间: open("/dev/hello") / read / write / ioctl
              │ VFS(虚拟文件系统)根据设备号路由
              ▼
内核: file_operations 回调表 ── 你实现的 hello_open/read/write/ioctl
              │
              ├─ alloc_chrdev_region  申请设备号(主+次)
              ├─ cdev_add             注册字符设备
              ├─ class_create+device_create  自动建 /dev/hello 节点
              └─ copy_to/from_user    内核↔用户空间安全拷贝数据

3. 内核 API 印证(基于 6.17 真编译)

本课在 Linux 6.17 真编译时验证了几个新内核 API(老教程常写错):

  • class_create(name) —— 6.4+ 是单参数(老教程是 class_create(owner, name) 两参数,新内核编不过)
  • file_operations.unlocked_ioctl —— 现代用这个,不是老的 .ioctl

4. 前置准备

  • 依赖 L13(知道 .ko 怎么编、怎么加载)。
  • 真编译环境:有 kernel headers 的 Linux(本课用本机 6.17;某真实机型 GKI prebuilt 编不了,见 L13)。
  • 文件:hello_chardev.cMakefiletest_hello.c(用户态测试)。

5. 动手:写字符驱动(逐段)

5.1 文件操作回调实现

#include <linux/module.h>
#include <linux/fs.h>          // file_operations
#include <linux/cdev.h>        // cdev
#include <linux/device.h>      // class_create/device_create
#include <linux/uaccess.h>     // copy_to_user/copy_from_user
#include <linux/slab.h>        // kzalloc

#define DEV_NAME "hello"
#define BUF_SIZE 256
#define HELLO_IOC_CLEAR _IO('H', 0)   // ioctl 命令:用 _IO 宏定义,'H' 是幻数

static char *kbuf;        // 内核缓冲区
static int data_len;

// open:用户 open("/dev/hello") 时调用
static int hello_open(struct inode *i, struct file *f) {
    pr_info("hello: open by pid=%d\n", current->pid);  // pr_info 打到 dmesg
    return 0;
}

// read:用户 read(fd) → copy_to_user 把内核数据拷给用户
static ssize_t hello_read(struct file *f, char __user *ubuf, size_t len, loff_t *off) {
    if (*off >= data_len) return 0;                 // 读到末尾返回 0(EOF)
    if (len > data_len - *off) len = data_len - *off;
    if (copy_to_user(ubuf, kbuf + *off, len)) return -EFAULT;  // 内核→用户,失败返 -EFAULT
    *off += len;
    return len;     // 返回实际读了多少字节
}

// write:用户 write(fd) → copy_from_user 把用户数据拷进内核
static ssize_t hello_write(struct file *f, const char __user *ubuf, size_t len, loff_t *off) {
    if (len > BUF_SIZE) len = BUF_SIZE;
    if (copy_from_user(kbuf, ubuf, len)) return -EFAULT;  // 用户→内核
    data_len = len;
    return len;
}

// ioctl:控制命令(这里实现清空缓冲)
static long hello_ioctl(struct file *f, unsigned int cmd, unsigned long arg) {
    if (cmd == HELLO_IOC_CLEAR) { memset(kbuf, 0, BUF_SIZE); data_len = 0; return 0; }
    return -ENOTTY;   // 不支持的命令返回 -ENOTTY(约定)
}

> 为什么不能直接用用户传进来的指针? 用户空间的指针在内核里不能直接解引用(地址空间不同、可能非法/恶意)。必须用 copy_to_user/copy_from_user 安全拷贝,它们会检查地址合法性。这是内核安全的基本功。

5.2 file_operations 表 —— 把回调挂上

static const struct file_operations hello_fops = {
    .owner          = THIS_MODULE,
    .open           = hello_open,
    .read           = hello_read,
    .write          = hello_write,
    .unlocked_ioctl = hello_ioctl,   // 现代用 unlocked_ioctl
};

这张表就是"用户对 /dev/hello 做什么操作 → 调哪个函数"的映射。VFS 拿到你的操作,查这张表分发。

5.3 模块加载:申请设备号 + 注册 + 建节点

static dev_t dev_num;
static struct cdev hello_cdev;
static struct class *hello_class;

static int __init hello_init(void) {
    int ret;
    kbuf = kzalloc(BUF_SIZE, GFP_KERNEL);             // 分配内核缓冲
    if (!kbuf) return -ENOMEM;

    ret = alloc_chrdev_region(&dev_num, 0, 1, DEV_NAME);  // ① 动态申请设备号(主+次)
    if (ret) goto e1;

    cdev_init(&hello_cdev, &hello_fops);              // ② 初始化 cdev,挂上 fops
    ret = cdev_add(&hello_cdev, dev_num, 1);          //    注册字符设备
    if (ret) goto e2;

    hello_class = class_create(DEV_NAME);             // ③ 创建 class(6.4+ 单参数!)
    if (IS_ERR(hello_class)) { ret = PTR_ERR(hello_class); goto e3; }
    device_create(hello_class, NULL, dev_num, NULL, DEV_NAME);  // udev 自动建 /dev/hello

    pr_info("hello: loaded major=%d\n", MAJOR(dev_num));
    return 0;
e3: cdev_del(&hello_cdev);                            // 错误回滚:逆序释放
e2: unregister_chrdev_region(dev_num, 1);
e1: kfree(kbuf);
    return ret;
}

static void __exit hello_exit(void) {                 // rmmod 时逆序拆
    device_destroy(hello_class, dev_num);
    class_destroy(hello_class);
    cdev_del(&hello_cdev);
    unregister_chrdev_region(dev_num, 1);
    kfree(kbuf);
}
module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("Lesson14 hello char device driver");   // ← 不写会 warning

> 错误回滚(goto e1/e2/e3)是内核驱动的标准写法:init 里每一步可能失败,失败时要把前面成功的步骤逆序撤销,否则资源泄漏。这不是可选项,是规范。

5.4 Makefile

obj-m += hello_chardev.o
KDIR := /lib/modules/$(shell uname -r)/build
all:
	$(MAKE) -C $(KDIR) M=$(shell pwd) modules

6. 看底层:read 一次,内核走了什么

用户: read(fd, buf, 64)
  │ 系统调用 sys_read
  ▼ VFS 根据 fd 找到 /dev/hello 对应的 file_operations
  ▼ 调用 hello_read(file, buf, 64, &offset)
  │   copy_to_user(buf, kbuf+offset, n)  ← 内核缓冲 → 用户缓冲
  ▼ 返回实际字节数 n
用户: read 返回 n

open/write/ioctl 同理,VFS 都按 file_operations 表分发到你的函数。

7. 编译 & 运行(✅ 真编译过)

make                       # 真编译,产出 hello_chardev.ko
sudo insmod hello_chardev.ko
dmesg | tail               # 看 "hello: loaded major=xxx"
ls -l /dev/hello           # 自动建的节点
echo "test" | sudo tee /dev/hello && sudo cat /dev/hello   # 写入再读回
sudo rmmod hello_chardev

真编译结果(本机 6.17):

CC [M]  hello_chardev.o
LD [M]  hello_chardev.ko    ← .ko 真的编出来了

> 验证状态(诚实):本课驱动在本机 Linux 6.17 make modules 编出 .ko(class_create 单参数等新 API 经真编译确认)。insmod 跑需 root,你本人执行(命令如上)。

8. 踩坑提醒(真编译踩到的)

  1. class_create 参数变了:6.4+ 是单参数 class_create(name),老教程 class_create(owner, name) 在新内核编不过
  2. MODULE_DESCRIPTION:新内核会 warning(missing MODULE_DESCRIPTION()),加上消除。
  3. 直接用用户指针:必须 copy_to/from_user,直接解引用用户指针会崩或被安全检查拦。
  4. 忘了错误回滚:init 中途失败不逆序释放 → 资源泄漏,rmmod 后再 insmod 可能失败。
  5. .ko 内核版本不匹配:给 A 内核编的 .ko 装到 B 内核 → version magic 错(L13)。

9. 常见问题分析与定位(字符驱动实战)

9.1 常见问题清单

  1. insmod 成功但 /dev/hello 没出现
  2. open /dev/hello 报 Permission denied
  3. read/write 数据不对/截断
  4. rmmod 报 module is in use 卸不掉
  5. 驱动崩导致 kernel panic

9.2 分析与定位

① /dev/hello 没出现

  • 原因:device_create 没调/失败,或 udev/ueventd 没建节点。
  • 定位:
dmesg | grep hello              # init 走到哪了,有没有报错
cat /proc/devices | grep hello  # 设备号注册了吗
ls -l /sys/class/hello_class/   # class 建了吗
  • 修复:确认 class_create + device_create 都成功;手动 mknod 临时建节点验证。

② Permission denied

  • 原因:/dev/hello 节点权限/SELinux 不允许当前进程访问。
  • 定位:ls -lZ /dev/hello(权限 + SELinux 标签);dmesg | grep avc(Android 上 SELinux 拦)。
  • 修复:Android 上要配 sepolicy 给节点打标签 + allow(L6/L11)。

③ 数据不对/截断

  • 原因:read/write 里 len/offset 处理错,或 copy_to/from_user 返回值没判。
  • 定位:在 hello_read/write 里 pr_info 打印 len/off/data_len,看哪不对。

④ module is in use 卸不掉

  • 原因:还有进程 open 着 /dev/hello(引用计数 >0)。
  • 定位:lsof /dev/hellofuser /dev/hello 看谁占着;关掉那个进程再 rmmod。

⑤ 驱动崩 panic

  • 原因:空指针(kbuf 没分配就用)、越界、用户指针直接解引用。
  • 定位:dmesg/ramdump 看 panic 栈顶在你驱动哪一行(L24);常见是 copy_to_user 地址错、kbuf NULL。

9.3 定位决策树

字符驱动出问题
├─ /dev/hello 没出现 → dmesg + cat /proc/devices + ls /sys/class → class/device_create 失败
├─ Permission denied → ls -lZ + dmesg avc → 节点权限/SELinux(L6/L11)
├─ 数据不对 → hello_read/write 里 pr_info 打 len/off → 边界/copy 返回值
├─ 卸不掉 → lsof/fuser /dev/hello → 关占用进程
└─ panic → dmesg/ramdump 栈顶(L24) → 空指针/越界/用户指针

10. 小结

  1. Linux 一切皆文件:驱动通过 file_operations 实现 /dev/xxx 的 open/read/write/ioctl。
  2. 六要素:file_operations 表 + 设备号(alloc_chrdev_region)+ cdev_add + class/device_create(建节点)+ copy_to/from_user(安全拷贝)+ 错误回滚。
  3. 新内核 API:class_create 单参数、.unlocked_ioctl(真编译确认)。
  4. 绝不直接用用户指针,必须 copy_to/from_user。
  5. 排查口诀:没节点查 device_create+/proc/devices;权限查 SELinux;卸不掉查 lsof;崩看 dmesg 栈顶。

下节预告

L15:platform driver + Device Tree —— 真实 ARM SoC 驱动不靠手动 insmod,而是 DTS 描述硬件、驱动 compatible 匹配自动 probe。

评论