4. 开发环境搭建
字符设备驱动,说白了就是Linux驱动里最基础、最常用的一种模型。你想想看,键盘、鼠标、串口、LED灯——这些按字节流读写的外设,几乎都用字符设备驱动来管理。我刚开始学驱动时,就是从字符设备入手的。今天咱们就把它的核心流程拆开揉碎了讲清楚。
设备号:驱动的身份证
每个字符设备在内核里都有一个唯一的编号,叫设备号。它分两部分:主设备号和次设备号。主设备号标识驱动类型,次设备号标识具体设备实例。
设备号结构:dev_t 类型,高12位是主设备号,低20位是次设备号。
申请设备号有两种方式:
- 静态申请:你自己指定主设备号。适合你知道这个号没被占用的场景。
- 动态申请:让内核帮你分配。我建议你优先用这种方式,省心。
代码示例:
#include <linux/fs.h>
#include <linux/device.h>
dev_t dev_num;
int major;
/* 动态申请:让内核分配 */
alloc_chrdev_region(&dev_num, 0, 1, "my_device");
major = MAJOR(dev_num);
/* 静态申请:自己指定主设备号 */
register_chrdev_region(MKDEV(240, 0), 1, "my_device");
我的习惯:开发阶段用动态申请,调试时通过 cat /proc/devices 查看分配到的号。等产品定型了,再改成静态申请固定号。
注销设备号很简单:
unregister_chrdev_region(dev_num, 1);
我曾经踩过的坑:在模块卸载函数里忘了注销设备号,结果第二次 insmod 时直接报错“Device or resource busy”。后来我养成了习惯——每次在 exit 函数里第一行就写 unregister,确保不会漏。
file_operations:驱动与用户态的桥梁
用户程序调用 open、read、write、close 时,内核怎么知道该执行你驱动的哪段代码?答案就是 file_operations 结构体。它是一张函数指针表,把系统调用映射到你的驱动函数。
常用的操作:
| 成员 | 系统调用 | 说明 |
|---|---|---|
| .open | open() | 打开设备,做初始化 |
| .release | close() | 关闭设备,释放资源 |
| .read | read() | 从设备读取数据 |
| .write | write() | 向设备写入数据 |
| .unlocked_ioctl | ioctl() | 设备控制命令 |
实现示例:
static int my_open(struct inode *inode, struct file *filp) {
printk(KERN_INFO "Device opened\n");
return 0;
}
static ssize_t my_read(struct file *filp, char __user *buf,
size_t count, loff_t *f_pos) {
/* 从内核空间拷贝数据到用户空间 */
if (copy_to_user(buf, kernel_buf, count))
return -EFAULT;
return count;
}
static ssize_t my_write(struct file *filp, const char __user *buf,
size_t count, loff_t *f_pos) {
/* 从用户空间拷贝数据到内核空间 */
if (copy_from_user(kernel_buf, buf, count))
return -EFAULT;
return count;
}
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
.write = my_write,
};
关键点:read/write 里一定要用 copy_to_user 和 copy_from_user,不能直接解引用用户空间指针。内核态和用户态的地址空间是隔离的,直接访问会引发段错误。
cdev:字符设备的内核对象
设备号有了,操作函数表也有了,怎么把它们注册到内核里?靠 cdev 结构体。它是字符设备在内核中的核心表示。
注册流程分三步:
- 分配 cdev:用
cdev_alloc()或静态定义。 - 初始化 cdev:调用
cdev_init(),绑定 file_operations。 - 添加 cdev:调用
cdev_add(),告诉内核这个设备号归你管。
代码示例:
static struct cdev my_cdev;
static int __init my_init(void) {
dev_t dev;
int ret;
/* 1. 申请设备号 */
alloc_chrdev_region(&dev, 0, 1, "my_device");
/* 2. 初始化 cdev */
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
/* 3. 注册到内核 */
ret = cdev_add(&my_cdev, dev, 1);
if (ret) {
printk(KERN_ERR "cdev_add failed\n");
unregister_chrdev_region(dev, 1);
return ret;
}
printk(KERN_INFO "Device registered, major=%d\n", MAJOR(dev));
return 0;
}
static void __exit my_exit(void) {
/* 先注销 cdev,再释放设备号 */
cdev_del(&my_cdev);
unregister_chrdev_region(my_cdev.dev, 1);
printk(KERN_INFO "Device unregistered\n");
}
顺序很重要:卸载时先 cdev_del 再 unregister_chrdev_region。反过来会导致设备号释放了但 cdev 还在,内核访问野指针直接 panic。我调试时遇到过,屏幕一黑,重启吧。
用户态与内核态交互:数据怎么传
用户程序通过系统调用进入内核,驱动在 file_operations 里处理请求。数据传递靠两个关键函数:
copy_from_user(to, from, n):用户空间 → 内核空间copy_to_user(to, from, n):内核空间 → 用户空间
用户态程序示例:
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
int main() {
int fd;
char buf[64] = "Hello from userspace!";
char read_buf[64] = {0};
fd = open("/dev/my_device", O_RDWR);
if (fd < 0) {
perror("open");
return -1;
}
write(fd, buf, sizeof(buf));
read(fd, read_buf, sizeof(read_buf));
printf("Read from kernel: %s\n", read_buf);
close(fd);
return 0;
}
调试小技巧:用 printk 打印 current->pid 和 current->comm,可以知道是哪个用户进程在操作你的设备。我在排查多进程并发访问时经常用这招。
完整流程:一张图看懂
我把整个字符设备驱动的注册、交互、注销流程画成了一张图。你对照着看,思路会更清晰。
避坑指南:我踩过的几个雷
- 设备号冲突:静态申请时用了已被占用的主设备号,insmod 直接失败。用
cat /proc/devices先查一下。 - cdev_add 失败不清理:如果 cdev_add 返回错误,别忘了 unregister_chrdev_region。否则设备号就泄漏了。
- copy_to_user 返回值不检查:如果拷贝失败,要返回 -EFAULT。用户态会收到错误码,而不是读到乱数据。
- 并发访问:多个进程同时 open 同一个设备,你的 open/release 里有没有计数保护?我早期没加,结果 release 里释放了还在用的资源,系统直接 hang 住。
我的建议:刚开始写字符驱动时,先搭一个最小框架——只有 open/release,打印点日志。确认注册注销流程没问题了,再往里加 read/write 逻辑。这样出问题了容易定位。
字符设备驱动是 Linux 驱动开发的基石。设备号、file_operations、cdev 这三样东西,你搞明白了,后面学块设备、网络设备都会轻松很多。嗯,今天就先聊到这儿,你先把代码跑起来试试。