1. 字符设备驱动基础:从分类到设备号管理

大家好,我是你们这门课的老朋友。今天咱们正式开篇,聊聊字符设备驱动的基础。说实话,我做了十几年嵌入式,接触最多的就是字符设备。它简单、直接,是理解 Linux 驱动模型的绝佳入口。

你想想看,一个驱动工程师如果连字符设备都搞不定,那后面的网络设备、块设备就更别想了。所以这一章,咱们把地基打牢。

1.1 Linux 设备驱动分类:三大门派

Linux 下的设备驱动,说白了就分三类:字符设备、块设备和网络设备。我习惯用一张表来对比它们,清晰明了。

类型 数据访问方式 典型代表 关键特征
字符设备 顺序字节流,无缓冲 串口、GPIO、I2C、SPI 按字节读写,open/read/write/close
块设备 随机块访问,有缓冲 硬盘、eMMC、U盘 以块为单位,支持挂载文件系统
网络设备 数据包收发 以太网、WiFi、蓝牙 通过 socket 接口,不映射到文件节点

嗯,这里要注意:字符设备虽然叫“字符”,但传输的未必是 ASCII 字符。它只是强调数据流是顺序的、没有缓冲的。我在项目中遇到过有人把 SPI Flash 当成字符设备来操作,结果因为没处理页对齐,数据写进去读出来全是乱的。这就是没理解“字符设备无缓冲”这个本质。

1.2 字符设备的核心数据结构:struct cdev

每个字符设备在内核里都有一个“身份证”——struct cdev。这个结构体定义在 <linux/cdev.h> 里。我刚开始学驱动时,总觉得它很神秘,后来发现它其实就干三件事:

  • 记录设备号(主设备号 + 次设备号)
  • 绑定文件操作函数集(struct file_operations)
  • 把自己挂到内核的设备链表上

咱们直接看代码,比空讲强得多:

struct cdev {
    struct kobject kobj;          // 内核对象,用于 sysfs
    struct module *owner;         // 所属模块,通常设为 THIS_MODULE
    const struct file_operations *ops;  // 文件操作函数指针
    struct list_head list;        // 内核链表节点
    dev_t dev;                    // 设备号(主+次)
    unsigned int count;           // 次设备号数量
};

我个人习惯在写驱动时,把 struct cdev 嵌入到自己的设备结构体里,而不是单独分配。这样内存管理更干净,不容易泄漏。比如:

struct my_device {
    struct cdev cdev;
    unsigned long base_addr;
    int irq_num;
    /* 其他私有数据 */
};
小技巧: 用 container_of() 宏可以从 struct cdev 指针反推出你的设备结构体。这是内核编程的经典手法,后面实战中会反复用到。

1.3 设备号:主设备号与次设备号

设备号是内核识别设备的“门牌号”。dev_t 类型是一个 32 位无符号整数,高 12 位是主设备号,低 20 位是次设备号。主设备号对应驱动,次设备号对应具体设备实例。

举个例子:你电脑上可能有两个串口 /dev/ttyS0 和 /dev/ttyS1,它们的主设备号都是 4(串口驱动),但次设备号分别是 64 和 65。

我当年犯过一个低级错误:手动指定了主设备号,结果跟系统里已有的驱动冲突了,模块加载直接报 register_chrdev_region: -16(设备或资源忙)。从那以后,我学乖了——能用动态分配就别用静态。

1.4 设备号注册与注销:两种方式

内核提供了两套 API 来管理设备号。我整理成表格,方便你对比记忆:

方式 函数 适用场景 注意事项
静态分配 register_chrdev_region() 已知主设备号(如 240-255 预留范围) 可能冲突,需查 /proc/devices
动态分配 alloc_chrdev_region() 不关心具体主设备号 内核自动分配,推荐使用

动态分配的代码模板是这样的:

dev_t dev_num;
int major, minor;

/* 动态分配设备号 */
ret = alloc_chrdev_region(&dev_num, 0, 1, "my_driver");
if (ret < 0) {
    printk(KERN_ERR "Failed to allocate device number\n");
    return ret;
}

major = MAJOR(dev_num);
minor = MINOR(dev_num);
printk("Allocated major=%d, minor=%d\n", major, minor);

注销时一定要成对释放:

unregister_chrdev_region(dev_num, 1);
避坑指南: 我曾经在卸载模块时忘了调用 unregister_chrdev_region(),结果再次加载时动态分配拿到了同一个主设备号,但 cdev_add() 却失败了。内核不会自动帮你清理,必须手动释放。记住:注册和注销要成对出现。

1.5 知识体系总览:一张图看懂

说了这么多,咱们用一张 SVG 图把整个字符设备驱动的框架串起来。这张图我画了很多遍,每次给团队新人培训都用它。

字符设备驱动核心框架 用户空间 (User Space) 应用程序 → open/read/write/close → /dev/my_device 系统调用层 (VFS) 内核空间 (Kernel Space) — 字符设备驱动 struct cdev 设备对象 file_operations 操作函数集 dev_t 设备号 注册流程:alloc_chrdev_region → cdev_init → cdev_add 硬件层 (Hardware) — 串口/GPIO/SPI/I2C

这张图从左到右展示了用户空间到硬件层的完整路径。你注意看中间那三个红色、紫色、绿色的方块——它们就是字符设备驱动的三要素:struct cdev、file_operations 和 dev_t。少了任何一个,驱动都跑不起来。

1.6 本章小结

咱们这一章把字符设备驱动的基础骨架搭起来了。你记住三件事:

  • 分类:字符设备、块设备、网络设备,各有各的脾气
  • 数据结构:struct cdev 是核心,file_operations 是灵魂
  • 设备号:动态分配是王道,注册注销要成对