3. 字符设备驱动开发:设备号注册与注销、file_operations结构体实现、用户态与内核态数据交互、ioctl接口实现
字符设备驱动,说白了就是 Linux 下最基础、最常用的一类驱动。你想想看,键盘、鼠标、串口,这些按字节流读写数据的设备,都属于字符设备。我刚开始学驱动时,觉得这玩意儿挺玄乎,后来发现核心就四个事:设备号怎么管、操作函数怎么挂、数据怎么在用户态和内核态之间倒腾、以及怎么通过 ioctl 搞点特殊控制。
嗯,咱们一个一个来拆解。
3.1 设备号注册与注销
每个字符设备在内核里都有一个身份证,就是设备号。它分两部分:主设备号和次设备号。主设备号标识驱动类型,次设备号标识具体设备。我习惯用 MAJOR(dev) 和 MINOR(dev) 这两个宏来拆解。
注册设备号有两种方式:静态申请和动态分配。
- 静态申请:用
register_chrdev_region(),你得自己指定主设备号。我在项目中遇到过,如果主设备号被别的驱动占了,注册就会失败。所以除非你知道自己在干什么,否则别用这个。 - 动态分配:用
alloc_chrdev_region(),内核自动给你分配一个没被占用的主设备号。我个人强烈推荐这种方式,省心。
注销时调用 unregister_chrdev_region(),把设备号还回去。我曾经见过有人忘了注销,导致模块卸载后设备号还被占用,下次加载就报错。嗯,这种坑踩过一次就记住了。
核心函数一览:
| 函数 | 作用 |
|---|---|
register_chrdev_region() | 静态注册设备号 |
alloc_chrdev_region() | 动态分配设备号 |
unregister_chrdev_region() | 注销设备号 |
// 动态分配示例
dev_t dev_num;
int ret = alloc_chrdev_region(&dev_num, 0, 1, "my_char_dev");
if (ret < 0) {
printk(KERN_ERR "设备号分配失败\n");
return ret;
}
printk("主设备号: %d, 次设备号: %d\n", MAJOR(dev_num), MINOR(dev_num));
// 注销
unregister_chrdev_region(dev_num, 1);
3.2 file_operations 结构体实现
设备号只是身份证,真正干活的是 file_operations 结构体。它就像一张函数表,告诉内核:当用户程序打开、读、写这个设备时,该调用驱动里的哪个函数。
我一般至少实现这几个成员:
.open:打开设备时调用。我习惯在这里做初始化,比如分配缓冲区。.release:关闭设备时调用。记得释放资源,否则内存泄漏。.read:从设备读数据。注意要处理copy_to_user()。.write:向设备写数据。注意要处理copy_from_user()。.unlocked_ioctl:后面会细讲。
避坑指南: 我曾经在 .open 里忘了检查设备是否已被占用,结果两个进程同时打开同一个设备,数据全乱了。后来加了个原子变量做互斥,才搞定。
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
.write = my_write,
.unlocked_ioctl = my_ioctl,
};
3.3 用户态与内核态数据交互
这是驱动开发里最容易出问题的地方。用户程序跑在用户态,驱动跑在内核态,两个地址空间是隔离的。你不能直接传指针,必须用内核提供的专用函数。
核心就两个函数:
copy_to_user(void __user *to, const void *from, unsigned long n):从内核往用户空间拷数据。copy_from_user(void *to, const void __user *from, unsigned long n):从用户空间往内核拷数据。
你想想看,如果直接 memcpy,轻则段错误,重则内核崩溃。我刚开始写驱动时就犯过这个错,结果系统直接挂了,只能重启。嗯,从那以后我再也不敢偷懒了。
注意: 这两个函数会返回未拷贝成功的字节数。如果返回值不为0,说明拷贝失败,要返回 -EFAULT 给用户程序。
static ssize_t my_read(struct file *filp, char __user *buf, size_t len, loff_t *off)
{
char kernel_buf[128] = "Hello from kernel!";
if (copy_to_user(buf, kernel_buf, strlen(kernel_buf))) {
return -EFAULT;
}
return strlen(kernel_buf);
}
static ssize_t my_write(struct file *filp, const char __user *buf, size_t len, loff_t *off)
{
char kernel_buf[128];
if (copy_from_user(kernel_buf, buf, len)) {
return -EFAULT;
}
printk("收到用户数据: %s\n", kernel_buf);
return len;
}
3.4 ioctl 接口实现
有时候光读和写不够用,比如你想设置串口的波特率、调整音量大小。这时候就需要 ioctl 了。它本质上是一个万能接口,通过命令字来区分不同的操作。
实现 ioctl 时,我建议遵循以下步骤:
- 定义命令字。用
_IO、_IOR、_IOW、_IOWR宏来生成,确保唯一性。 - 在
unlocked_ioctl函数里用switch处理不同命令。 - 如果命令涉及数据交换,用
copy_from_user或copy_to_user。
命令字宏说明:
| 宏 | 含义 |
|---|---|
_IO(type, nr) | 无数据传递的命令 |
_IOR(type, nr, size) | 从设备读数据 |
_IOW(type, nr, size) | 向设备写数据 |
_IOWR(type, nr, size) | 双向数据传递 |
#define MY_IOCTL_MAGIC 'k'
#define MY_IOCTL_SET _IOW(MY_IOCTL_MAGIC, 1, int)
#define MY_IOCTL_GET _IOR(MY_IOCTL_MAGIC, 2, int)
static long my_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
{
int val;
switch (cmd) {
case MY_IOCTL_SET:
if (copy_from_user(&val, (int __user *)arg, sizeof(val)))
return -EFAULT;
printk("设置值为: %d\n", val);
break;
case MY_IOCTL_GET:
val = 42;
if (copy_to_user((int __user *)arg, &val, sizeof(val)))
return -EFAULT;
break;
default:
return -ENOTTY;
}
return 0;
}
用户态调用时,用 ioctl(fd, MY_IOCTL_SET, &value) 就行。我曾经在定义命令字时忘了用宏,直接写数字,结果跟别的驱动冲突了。后来老老实实用 _IO 宏,再也没出过问题。
3.5 知识体系结构图
下面这张图把本章的核心逻辑串起来了。你一看就明白:设备号是身份,file_operations 是行为,数据交互是桥梁,ioctl 是扩展能力。
好了,以上就是字符设备驱动开发的核心内容。设备号注册与注销是基础,file_operations 是骨架,数据交互是血脉,ioctl 是灵活扩展的手段。把这四个点吃透了,大部分字符设备驱动你都能搞定。
个人经验: 我建议你在写驱动时,先把 open 和 release 实现好,确保设备能正常打开关闭。然后再加 read 和 write,最后再搞 ioctl。一步步来,出问题了也好定位。
想要更多资料,联系可可