4. 字符设备驱动框架
字符设备是Linux驱动开发中最基础、最常见的一类设备。它按字节流方式访问,不经过块层缓存,典型代表有串口、按键、LED、ADC、GPIO扩展芯片等。理解字符设备驱动框架,核心是抓住四条主线:设备号标识“我是谁”,cdev把驱动注册进内核,file_operations定义“我能做什么”,用户空间系统调用决定“怎么找到我”。这四者串起来,才构成一个可被应用程序打开的完整字符设备。
一、设备号:dev_t与主次设备号
内核用dev_t(32位)表示一个设备号,其中高12位是主设备号(major),低20位是次设备号(minor)。主设备号对应“哪一类驱动”,次设备号对应“这一类里的第几个设备实例”。例如两个LED共用主设备号,但次设备号分别为0和1。
# 查看已注册字符设备主设备号
cat /proc/devices | grep -i mychar
# 手动创建设备节点:mknod 名称 类型 主设备号 次设备号
sudo mknod /dev/mychar0 c 240 0
sudo chmod 666 /dev/mychar0
设备号分配有两种方式:静态指定使用register_chrdev_region(),需要开发者自己保证主设备号不冲突;动态分配使用alloc_chrdev_region(),由内核返回可用主设备号,是推荐做法。释放时统一用unregister_chrdev_region()。
class_create()与device_create()自动生成/dev节点,避免手工mknod。二、cdev注册:把驱动挂到内核字符设备表
拿到设备号后,需要把驱动与设备号绑定。内核用struct cdev描述一个字符设备,关键步骤是:定义cdev、用cdev_init()绑定file_operations、用cdev_add()加入内核。卸载时先cdev_del(),再释放设备号。
static dev_t devno;
static struct cdev my_cdev;
static struct class *my_class;
static int __init mychar_init(void)
{
int ret;
ret = alloc_chrdev_region(&devno, 0, 1, "mychar");
if (ret < 0)
return ret;
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
ret = cdev_add(&my_cdev, devno, 1);
if (ret < 0) {
unregister_chrdev_region(devno, 1);
return ret;
}
my_class = class_create(THIS_MODULE, "mychar");
device_create(my_class, NULL, devno, NULL, "mychar0");
return 0;
}
这里cdev_add()的第三个参数是设备数量。若一次注册多个次设备号,内核会把它们都关联到同一个cdev,此时需要在open中通过inode->i_rdev区分具体是哪一个次设备。
三、file_operations:驱动能力的函数表
struct file_operations是字符设备驱动的“能力清单”。用户空间每调用一次open/read/write/ioctl,内核就通过它找到对应函数。常用成员如下表:
| 成员 | 触发时机 | 典型用途 |
|---|---|---|
| open | 应用调用open() | 初始化硬件、申请资源、保存私有数据 |
| release | 应用调用close() | 释放资源、关闭时钟、复位设备 |
| read | 应用调用read() | 从设备读取数据到用户缓冲区 |
| write | 应用调用write() | 把用户数据写入设备 |
| unlocked_ioctl | 应用调用ioctl() | 执行非读写类控制命令 |
| llseek | 应用调用lseek() | 调整文件偏移,字符设备常可省略 |
实现时要注意:read/write的返回值是实际传输字节数,返回负值表示错误;copy_to_user()和copy_from_user()是用户空间与内核空间之间安全拷贝数据的唯一正确方式,不能直接解引用用户指针。
static ssize_t my_read(struct file *filp, char __user *buf,
size_t count, loff_t *ppos)
{
char kbuf[64] = "hello from kernel";
if (count > sizeof(kbuf))
count = sizeof(kbuf);
if (copy_to_user(buf, kbuf, count))
return -EFAULT;
return count;
}
static long my_ioctl(struct file *filp, unsigned int cmd,
unsigned long arg)
{
switch (cmd) {
case MYCHAR_RESET:
/* 执行复位 */
break;
default:
return -ENOTTY;
}
return 0;
}
四、用户空间调用流程与联调要点
应用程序执行open("/dev/mychar0", O_RDWR)时,内核经历:VFS查找inode → 根据设备号找到cdev → 取出file_operations → 调用驱动的open。后续read/write/ioctl都复用这条路径。可以用下面这张图理解整个调用链:
联调时最常见的现象是:insmod成功,但open返回No such device。这通常不是驱动逻辑错,而是设备节点没建、主次设备号不匹配,或cdev_add失败未检查返回值。另一个高频问题是read返回-EFAULT,原因多为直接对用户指针赋值,而没有使用copy_to_user()。
open里做耗时初始化,也不要在中断上下文调用可能睡眠的copy_to_user()。字符设备驱动的open/read/write通常运行在进程上下文,可以睡眠,但ioctl中的命令语义要清晰,未知命令应返回-ENOTTY。五、复盘:一个最小可用的字符设备驱动骨架
- 用
alloc_chrdev_region()动态申请设备号,记录返回的dev_t。 - 定义
file_operations,至少实现open、release、read、write、unlocked_ioctl。 - 用
cdev_init()绑定fops,用cdev_add()注册,并检查返回值。 - 用
class_create()和device_create()自动生成/dev节点。 - 卸载时逆序执行:
device_destroy→class_destroy→cdev_del→unregister_chrdev_region。 - 用
dmesg观察printk日志,用cat /proc/devices确认主设备号,用ls -l /dev/mychar0确认节点类型。
掌握这套框架后,后续的platform驱动、misc设备、sysfs属性、中断处理,都是在字符设备骨架上叠加总线匹配、资源管理和并发控制。把设备号、cdev、file_operations、系统调用这四层关系理清,字符设备驱动就不再是“背模板”,而是可以按需裁剪和扩展的工程结构。