新字符设备驱动开发
新字符设备驱动开发
struct cdev
就是“字符设备注册登记表”。
struct cdev {
struct kobject kobj;
struct module *owner;
const struct file_operations *ops;
struct list_head list;
dev_t dev;
unsigned int count;
};1. struct kobject kobj; —— “基础档案”
- 是啥:Linux 内核里万物皆对象,
kobject就是最底层的“对象基类”。 - 干嘛用:它负责这个设备在
/sys/目录下的引用计数和父子关系。暂时不用管它,填好后面的项,内核会自动帮你维护这个。 - 比喻:就像你的身份证号,你不用自己编,系统给你生成。
2. struct module *owner; —— “谁是老板”
- 是啥:指向这个设备属于哪个驱动模块。
- 干嘛用:防止你在设备还在用的时候,就把驱动模块给拔了。这里必须填
THIS_MODULE。 - 比喻:营业执照上的“法人代表”。
3. const struct file_operations *ops; —— “你会干啥活”
- 是啥:这就是你之前费劲填的
file_operations结构体。 - 干嘛用:告诉内核,我这个设备支持
open、read、write等操作,对应的函数是哪些。 - 比喻:公司的“经营范围”(是开餐馆还是修电脑)。
4. struct list_head list; —— “公司名录里的一页”
- 是啥:一个链表节点。
- 干嘛用:内核把所有注册好的字符设备串成一个链表(就像一本公司名录)。你的设备通过这个
list挂在链表上,方便内核查找。 - 比喻:工商局档案室里,把你公司的档案页和别的公司档案串在一起的装订线。
5. dev_t dev; —— “你的门牌号”
- 是啥:设备号(主设备号 + 次设备号)。
- 干嘛用:记录你分配到的具体门牌号。
- 比喻:注册下来的公司地址(比如 200 栋 0 号)。
6. unsigned int count; —— “占了几间房”
- 是啥:你申请了几个连续的设备号。
- 干嘛用:如果是 LED 灯,有 3 个灯,count 就是 3。内核就知道你是从
dev开始,占了连续 3 个号。 - 比喻:你的公司租了同一层楼的 0 号、1 号、2 号共 3 间办公室。
不必手动去碰 kobj 和 list,内核有专门的函数 cdev_init 帮填
struct cdev led_cdev; // 拿一张空白的“登记表”
cdev_init(&led_cdev, &led_fops);// 1. 先把“经营范围(fops)”填上
led_cdev.owner = THIS_MODULE; // 2. 签字画押,证明这表是你的
// 3. 把设备号(门牌号)和数量告诉内核,正式注册
cdev_add(&led_cdev, devid, 1);cdev_init()干了啥
为什么只初始化ops函数指针字段,不初始化dev设备号字段?
cdev_init 的职责只是 “绑定操作函数” ,它不关心设备号。cdev_add 的职责是 “正式注册,绑定设备号并上线”。
这个 ,只有你这个设备时,。所以内核把这个赋值动作,而不是更早的 cdev_init 里。
void cdev_init(struct cdev *cdev, const struct file_operations *fops)
{
//把整个结构体先清零,`dev` 暂时是 `0`。真正的设备号,等 `cdev_add` 时才填进去。
memset(cdev, 0, sizeof *cdev); // 清零,此时 dev 被置为 0
INIT_LIST_HEAD(&cdev->list); // 初始化链表
kobject_init(&cdev->kobj, ...); // 初始化内核对象
cdev->ops = fops; // 只绑定了 ops,不管 dev 核心
}cdev_add 内部干了啥
传进去的 devid,就是在这个时候被写入了 led_cdev.dev
nt cdev_add(struct cdev *p, dev_t dev, unsigned count)
{
p->dev = dev; // ← 就是这里!把你传进来的设备号填进去
p->count = count; // 把设备数量也填进去
// 然后把 p 插入内核的字符设备哈希表,让系统能查到它
return kobj_map(cdev_map, dev, count, NULL, exact_match, exact_lock, p);
}class_create和device_create
1. 它们到底创建了什么?
led_class = class_create(THIS_MODULE, "led_class");
led_device = device_create(led_class, NULL, devid, NULL, "led0");这两行代码实际上创建了这些路径:
| 操作 | 创建的实际路径 | 作用 |
|---|---|---|
class_create | /sys/class/led_class/ | 创建一个“类”目录,表示这一类设备 |
device_create | /sys/class/led_class/led0/ | 在类目录下创建具体设备目录,包含设备属性信息 |
device_create | /dev/led0 | 自动创建用户可用的设备节点 |
所以 device_create 一行代码做了件事:
- 在
/sys/class/led_class/下创建led0目录。 - 同时触发
udev或mdev,自动在/dev/下创建led0设备节点。
/sys/class/ 和 /dev/ 不是同一个东西的两个备份,而是统一设备模型的两个视角:
/sys/class/是给和系统服务看的“”/dev/是给程序看的“”
2. 为什么非要先创建“类”?
“类(class)”是用来归类的。
假设你的系统里有:
- 3 个 LED 灯
- 2 个串口
- 1 个加速度传感器
/sys/class/ 下的结构就是:
/sys/class/
├── leds/ ← 所有 LED 都在这
│ ├── led0/
│ ├── led1/
│ └── led2/
├── tty/ ← 所有串口都在这
│ ├── ttySAC0/
│ └── ttySAC1/
└── misc/ ← 杂项设备
└── accel/不创建类,设备就没法分类。 系统管理工具(如 udev)就是通过 /sys/class/ 下的目录结构,知道该在 /dev/ 下创建什么名字的设备节点、设置什么权限。
3.如果没有这一步会怎样?
如果不写这两行,驱动虽然通过 cdev_add 注册了,但:
/dev/下不会自动出现设备节点,必须手动mknod。/sys/class/下没有设备信息,系统工具(如udev)无法管理设备。
所以这两个目录必须创建:
/sys/class/是为了内核统一管理/dev/是为了用户程序方便使用
一句话总结:class_create 是告诉内核“我这设备属于哪一类”,device_create 是告诉内核“我这个具体设备叫啥名字,并在 /dev/ 下给我开个入口
使用流程
1. 申请设备号
dev_t devid;
alloc_chrdev_region(&devid, 0, 1, "led");devid:输出参数,存放申请到的设备号(主+次)。0:希望从次设备号 0 开始。1:申请 1 个设备号。"led":名字,/proc/devices会显示。
也可手动指定:
register_chrdev_region(MKDEV(200,0), 1, "led"),但容易冲突。
2. 分配并初始化 cdev
struct cdev led_cdev;
cdev_init(&led_cdev, &led_fops);
led_cdev.owner = THIS_MODULE;cdev_init把led_cdev和file_operations绑定。- 手动设置
.owner(如果不设,cdev_add后可能会有警告)。
3. 注册 cdev
cdev_add(&led_cdev, devid, 1);- 参数:cdev 结构体、起始设备号、设备数量。
- 注册后,应用层才能通过设备号找到
led_fops。
4. 创建类并自动生成设备节点
struct class *led_class;
struct device *led_device;
led_class = class_create(THIS_MODULE, "led_class");
led_device = device_create(led_class, NULL, devid, NULL, "led0");class_create在/sys/class/下创建类目录。device_create自动在/sys/class/类目录/ 和/dev/下创建设备节点led0,不需要手动mknod。
卸载流程(与注册相反)
device_destroy(led_class, devid);
class_destroy(led_class);
cdev_del(&led_cdev);
unregister_chrdev_region(devid, 1);- 顺序:先删设备,再删类,然后删除 cdev,最后释放设备号。
加载驱动
和老字符设备相比,少了创建设备节点文件这一步
新老方法对比
| 项目 | 老方法 register_chrdev | 新方法 cdev 系列 |
|---|---|---|
| 设备号占用 | 一次占满主设备号下所有次设备号 | 按需申请,只占用需要的个数 |
| 设备节点 | 需手动 mknod | 自动生成 /dev/led0 |
| 灵活性 | 一个主设备号只能给一个驱动 | 同一个主设备号可分配给不同驱动 |
| 复杂度 | 一行搞定 | 需要 4~5 步,但流程清晰 |
总结一句话:新流程就是用 alloc_chrdev_region 精确申请设备号,用 cdev 绑定 file_operations,然后用 class_create 和 device_create 自动创建设备节点,避免了老方法的浪费和手动操作。
复杂点的
#define NEWCHRLED_CNT 1 /* 设备号个数 */
#define NEWCHRLED_NAME "newchrled" /* 名字 */
/* newchrled设备结构体 */
struct newchrled_dev{
dev_t devid; /* 设备号 */
struct cdev cdev; /* cdev */
struct class *class; /* 类 接收class_create 的返回 */
struct device *device; /* 设备 接收device_create的返回 */
int major; /* 主设备号 */
int minor; /* 次设备号 */
};
struct newchrled_dev newchrled; /* 一个led设备 */
/* 注册字符设备驱动 */
/* 1、创建设备号 */
if (newchrled.major) { /* 定义了设备号 */
newchrled.devid = MKDEV(newchrled.major, 0);
register_chrdev_region(newchrled.devid, NEWCHRLED_CNT, NEWCHRLED_NAME);
} else { /* 没有定义设备号 */
alloc_chrdev_region(&newchrled.devid, 0, NEWCHRLED_CNT, NEWCHRLED_NAME); /* 申请设备号 */
newchrled.major = MAJOR(newchrled.devid); /* 获取分配号的主设备号 */
newchrled.minor = MINOR(newchrled.devid); /* 获取分配号的次设备号 */
}
printk("newcheled major=%d,minor=%d\r\n",newchrled.major, newchrled.minor);
/* 2、初始化cdev */
newchrled.cdev.owner = THIS_MODULE;
cdev_init(&newchrled.cdev, &newchrled_fops);
/* 3、添加一个cdev */
cdev_add(&newchrled.cdev, newchrled.devid, NEWCHRLED_CNT);
/* 4、创建类 */
newchrled.class = class_create(THIS_MODULE, NEWCHRLED_NAME);
if (IS_ERR(newchrled.class)) {
return PTR_ERR(newchrled.class);
}
/* 5、创建设备 */
newchrled.device = device_create(newchrled.class, NULL, newchrled.devid, NULL, NEWCHRLED_NAME);
if (IS_ERR(newchrled.device)) {
return PTR_ERR(newchrled.device);
}问题
/*
* @description : 打开设备
* @param - inode : 传递给驱动的inode
* @param - filp : 设备文件,file结构体有个叫做private_data的成员变量
* 一般在open的时候将private_data指向设备结构体。
* @return : 0 成功;其他 失败
*/
static int led_open(struct inode *inode, struct file *filp)
{
filp->private_data = &newchrled; /* 设置私有数据 */
return 0;
}1.这个为什么要给这个指针指向?
让驱动内部的其它函数(比如读/写)能拿到属于这个设备的私有数据。
2.如果不写这行会发生什么?
newchrled 是一个全局的结构体变量,驱动里所有的函数本来就能直接访问它。
但如果不把它存进 filp->private_data,以后驱动如果想扩展功能、支持同时操作多个 LED,或者在不同的进程里用不同的配置,代码逻辑就会乱套
3.为什么要多此一举?意义在哪?
- 面向对象:把
filp->private_data想象成“设备对象”的指针。谁打开设备,内核就把这个专属指针传给它。以后在led_write、led_read里,你只要一行代码取出来,就能立刻知道“正在操作的是哪盏灯”。 - 支持多设备:假如你要控制 LED0 和 LED1 两盏灯,把它们各自的私有数据分别存进各自的
filp->private_data。当led_write被调用时,它不用去猜“我该写哪盏灯”,直接从指针就能精准定位,不会把 LED0 的数据写到 LED1 上。
4.为什么上次的LED程序没这么写?
上次写的很可能是早期简单教程里的驱动,那种驱动为了省事,直接用了一个固定的全局变量。
因为只控制一盏灯,而且代码很简单,所有函数都能直接访问那个全局变量,所以看不出问题。但那种写法无法管理多盏灯,也不是 Linux 驱动标准的实现方式。现在加上这一行,就是用标准的方式管理设备。
多设备例子进阶理解为什么要这样
先看代码
两盏灯的写法
#define LED_NUM 2
struct newchrled_dev {
dev_t devid;
struct cdev cdev;
struct class *class; // 注意:类通常还是共用同一个
struct device *device;
int major;
int minor;
unsigned int led_pin; // 新增:这盏灯对应的 GPIO 引脚编号
};
struct newchrled_dev *led_dev[LED_NUM]; // 指针数组,每盏灯各一份open 函数:让每盏灯知道自己是谁
static int led_open(struct inode *inode, struct file *filp)
{
// 从 inode 的 i_cdev 反推出是哪个 led_dev 结构体
struct newchrled_dev *dev = container_of(inode->i_cdev, struct newchrled_dev, cdev);
filp->private_data = dev; // 把当前这盏灯的数据存进去
return 0;
}write 函数:精确控制对应的那盏灯,实现
static ssize_t led_write(struct file *filp, const char __user *buf, size_t cnt, loff_t *offt)
{
struct newchrled_dev *dev = filp->private_data; // 取出私有数据
unsigned char databuf[1];
copy_from_user(databuf, buf, 1);
if (databuf[0] == LEDON)
gpio_set_value(dev->led_pin, 1); // 控制这盏灯对应的引脚
else
gpio_set_value(dev->led_pin, 0);
return 0;
}初始化函数:循环创建两盏灯
static int __init led_init(void)
{
int i;
// 1. 申请 2 个连续设备号
alloc_chrdev_region(&devid_base, 0, LED_NUM, "led");
// 2. 创建类(所有灯共用一个类)
led_class = class_create(THIS_MODULE, "leds");
for (i = 0; i < LED_NUM; i++) {
led_dev[i] = kzalloc(sizeof(struct newchrled_dev), GFP_KERNEL);
led_dev[i]->devid = MKDEV(MAJOR(devid_base), i); // 次设备号 0, 1
cdev_init(&led_dev[i]->cdev, &led_fops);
led_dev[i]->cdev.owner = THIS_MODULE;
cdev_add(&led_dev[i]->cdev, led_dev[i]->devid, 1);
// 创建设备节点:/dev/led0, /dev/led1
led_dev[i]->device = device_create(led_class, NULL, led_dev[i]->devid, NULL,
"led%d", i);
}
return 0;
}总流程
应用层 open("/dev/led0")
│
▼
内核 VFS 层
┌─ 解析 inode,获取设备号 (200, 0)
├─ 查 cdev_map 哈希表 → 找到 cdev 地址 0xA000000C
├─ inode->i_cdev = 0xA000000C
└─ 调用驱动 open: led_open(inode, filp)
│
▼
驱动 led_open
┌─ container_of(inode->i_cdev, struct newchrled_dev, cdev)
│ │
│ ├─ offsetof(struct newchrled_dev, cdev) = 12 字节
│ │
│ └─ 计算: 0xA000000C - 12 = 0xA0000000 → led0 首地址
│
└─ filp->private_data = led0 (0xA0000000)
│
▼
应用层 write(fd, &buf, 1)
│
▼
驱动 led_write(filp, ...)
┌─ dev = filp->private_data // 取出 led0
└─ gpio_set_value(dev->led_pin, 1) // 控制正确的引脚详解
偏移量
当你定义结构体时:
struct newchrled_dev {
dev_t devid; // 0~3 字节
int major; // 4~7 字节
int minor; // 8~11 字节
struct cdev cdev; // 12 字节开始(假设前面占了 12 字节)
};编译器在编译时会精确计算每个成员相对于结构体开头的偏移字节数。
例如,如果 devid、major、minor 一共占 12 字节,那么 cdev 成员的偏移量就是 12。
这个偏移量是编译时确定的常量,不会在运行时改变。
container_of 怎么利用偏移量?
container_of 是一个宏,它的核心公式是:
#define container_of(ptr, type, member) \
((type *)((char *)(ptr) - offsetof(type, member)))ptr:成员指针,这里是inode->i_cdev,,在结构体的后方offsetof(type, member):另一个宏,用来获取成员在结构体中的偏移量。(char *)(ptr):把成员指针转成字节地址。- 减去偏移量:得到结构体首地址的字节地址。
- 强制转换成
type *:得到结构体指针。
offsetof 宏又是怎么算的?
offsetof 的标准实现:
#define offsetof(type, member) ((size_t) &((type *)0)->member)它的原理是:
- 假设在地址 0 处有一个
type类型的结构体。 - 取出该结构体中
member成员的地址。 - 由于基地址是 0,这个成员的地址数值就等于它的偏移量。
完全由编译器在编译时计算,运行时只是引用一个立即数。
总结
- 一套驱动代码,多个设备实例:
led_open和led_write都是同一份代码,却能分别控制 LED0 和 LED1。 cdev是钥匙,container_of是锁:内核用cdev定位到设备在哈希表中的条目,驱动用container_of反推出包含该cdev的完整设备结构体。private_data是传送带:open时把反推出的结构体指针存入,write时直接取出,全程无需全局变量,避免了设备间数据混淆。- 偏移量是编译期常量:整个反推过程只做一次减法,没有任何运行时查找开销,效率极高。

