4. Zynq中断控制器与GIC驱动实践
字符设备驱动,说白了就是Linux世界里最基础、最常用的一种驱动形态。你想想看,串口、GPIO、I2C、SPI,这些在Zynq上天天打交道的硬件,本质上都是字符设备。我刚开始做Zynq开发时,第一个正经写的驱动就是字符设备驱动——一个简单的LED控制。嗯,从那以后,我对这套机制算是彻底摸透了。
4.1 Linux设备驱动模型:别被它吓到
很多初学者一听到“设备驱动模型”就觉得高大上,其实没那么玄乎。Linux内核从2.6开始引入了一套统一的设备驱动模型,核心就三个东西:总线(Bus)、设备(Device)、驱动(Driver)。
我个人习惯把这套模型理解成“相亲平台”:
- 总线就是婚介所,负责牵线搭桥
- 设备是征婚方,把自己的硬件资源(寄存器地址、中断号等)挂在总线上
- 驱动是应征方,声明自己能支持哪些设备(通过id_table匹配)
当设备和驱动在总线上“看对眼”了,内核就会调用驱动的probe函数,驱动正式接管设备。这套机制的好处是——驱动代码和设备信息解耦了。你在Zynq上写驱动,设备信息通常放在设备树里,驱动只关心怎么操作硬件,两者通过platform总线匹配。
核心要点:在Zynq平台上,字符设备驱动通常挂载在platform总线上。设备树描述硬件资源,驱动通过platform_driver结构体注册,probe函数里完成字符设备的初始化。
4.2 字符设备注册与注销:从申请到释放
字符设备驱动的生命周期,说白了就是“申请设备号 → 注册设备 → 提供操作接口 → 注销设备 → 释放设备号”。我在项目中见过不少新手直接跳过设备号申请这一步,结果驱动加载时跟其他模块冲突,系统直接panic。
4.2.1 设备号管理
Linux用dev_t类型表示设备号,高12位是主设备号,低20位是次设备号。主设备号标识驱动类型,次设备号标识同一个驱动管理的不同设备。
| 函数 | 作用 | 适用场景 |
|---|---|---|
register_chrdev_region() |
静态申请指定范围的设备号 | 你知道要用的主设备号(比如从/proc/devices查到的) |
alloc_chrdev_region() |
动态分配设备号 | 不关心主设备号,让内核帮你分配(推荐) |
unregister_chrdev_region() |
释放设备号 | 驱动卸载时必须调用 |
我的建议:在Zynq开发中,除非你有特殊需求(比如要跟旧驱动保持兼容),否则一律用alloc_chrdev_region()动态分配。我曾经在一个项目里硬编码了主设备号,结果跟另一个模块冲突,排查了半天才发现是设备号撞车了。
4.2.2 cdev结构体与注册
设备号申请好了,接下来就是初始化cdev结构体,然后把它添加到内核中。这一步很多人容易漏掉cdev_init()和cdev_add()的调用顺序——必须先init再add,否则内核访问到未初始化的函数指针,直接oops给你看。
static int my_driver_probe(struct platform_device *pdev)
{
int ret;
dev_t devno;
struct cdev *cdev;
// 1. 动态分配设备号
ret = alloc_chrdev_region(&devno, 0, 1, "my_char_dev");
if (ret < 0) {
pr_err("Failed to alloc chrdev region\n");
return ret;
}
// 2. 初始化cdev
cdev = cdev_alloc();
cdev_init(cdev, &my_fops);
cdev->owner = THIS_MODULE;
// 3. 添加cdev到内核
ret = cdev_add(cdev, devno, 1);
if (ret) {
pr_err("Failed to add cdev\n");
unregister_chrdev_region(devno, 1);
return ret;
}
platform_set_drvdata(pdev, cdev);
return 0;
}
注意:cdev_add()一旦成功,内核立刻就能通过设备号访问到你的驱动。所以在这之前,必须确保所有硬件初始化都已完成,否则用户空间程序打开设备时可能触发空指针访问。我曾经在probe函数里先调了cdev_add,再去初始化硬件,结果用户程序抢先打开了设备,直接崩溃。
4.2.3 注销流程
驱动卸载时,顺序跟注册相反:先删除cdev,再释放设备号。这个顺序搞反了,设备号虽然释放了,但cdev还在内核里挂着,万一有进程正持有文件描述符,就会访问到野指针。
static int my_driver_remove(struct platform_device *pdev)
{
struct cdev *cdev = platform_get_drvdata(pdev);
dev_t devno = cdev->dev;
// 1. 删除cdev
cdev_del(cdev);
// 2. 释放设备号
unregister_chrdev_region(devno, 1);
return 0;
}
4.3 file_operations结构体:驱动与用户空间的桥梁
这个结构体,说白了就是驱动暴露给用户空间的操作接口表。用户程序调用open()、read()、write()、ioctl()时,内核通过这个结构体找到对应的驱动函数。
4.3.1 核心成员
| 成员 | 函数原型 | 说明 |
|---|---|---|
open |
int (*open)(struct inode *, struct file *) |
设备打开时调用,通常做硬件初始化或权限检查 |
release |
int (*release)(struct inode *, struct file *) |
设备关闭时调用,释放资源 |
read |
ssize_t (*read)(struct file *, char __user *, size_t, loff_t *) |
从设备读取数据,注意要用copy_to_user() |
write |
ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *) |
向设备写入数据,注意要用copy_from_user() |
unlocked_ioctl |
long (*unlocked_ioctl)(struct file *, unsigned int, unsigned long) |
设备控制命令,Zynq上常用它来配置硬件寄存器 |
llseek |
loff_t (*llseek)(struct file *, loff_t, int) |
文件定位操作,如果设备不支持,直接返回-ESPIPE |
关键点:用户空间的指针(__user标记的)绝对不能在内核态直接解引用。必须用copy_to_user()和copy_from_user()来安全传递数据。我见过有人图省事直接memcpy,结果在内核态访问了用户空间地址,系统直接挂掉。
4.3.2 一个完整的file_operations实现示例
static int my_dev_open(struct inode *inode, struct file *filp)
{
pr_info("Device opened\n");
return 0;
}
static int my_dev_release(struct inode *inode, struct file *filp)
{
pr_info("Device closed\n");
return 0;
}
static ssize_t my_dev_read(struct file *filp, char __user *buf,
size_t count, loff_t *f_pos)
{
char kernel_buf[64];
size_t len;
// 从硬件读取数据到内核缓冲区
len = min(count, sizeof(kernel_buf));
// 假设从硬件寄存器读取
// ...
if (copy_to_user(buf, kernel_buf, len)) {
return -EFAULT;
}
return len;
}
static ssize_t my_dev_write(struct file *filp, const char __user *buf,
size_t count, loff_t *f_pos)
{
char kernel_buf[64];
size_t len;
len = min(count, sizeof(kernel_buf));
if (copy_from_user(kernel_buf, buf, len)) {
return -EFAULT;
}
// 将数据写入硬件
// ...
return len;
}
static long my_dev_ioctl(struct file *filp, unsigned int cmd,
unsigned long arg)
{
switch (cmd) {
case MY_IOCTL_SET_CONFIG:
// 配置硬件寄存器
break;
case MY_IOCTL_GET_STATUS:
// 读取硬件状态
break;
default:
return -ENOTTY;
}
return 0;
}
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_dev_open,
.release = my_dev_release,
.read = my_dev_read,
.write = my_dev_write,
.unlocked_ioctl = my_dev_ioctl,
};
4.4 知识体系总览
下面这张图把字符设备驱动的核心流程串起来了,从设备树描述到用户空间操作,每一步都离不开前面讲的内容。
经验之谈:在Zynq上调试字符设备驱动时,我习惯先在/proc/devices里确认设备号是否注册成功,再用mknod手动创建设备节点。等驱动稳定了,再让udev自动管理设备节点。这样排查问题更清晰,不会把驱动问题和设备节点问题混在一起。
4.5 避坑指南
- 设备号冲突:我曾经在一个项目里同时加载了两个驱动,都用了同一个主设备号,结果第二个驱动加载时直接报错。从那以后,我统一用
alloc_chrdev_region()动态分配,再也没出过问题。 - copy_to_user/copy_from_user返回值检查:这两个函数返回未拷贝成功的字节数,不是错误码。很多人直接判断返回值是否小于0,结果数据拷贝失败却没发现。正确的做法是判断返回值是否等于0,不为0说明拷贝不完整。
- ioctl命令号冲突:Zynq上多个驱动可能共用同一个ioctl命令号,导致误操作。建议用
_IO()、_IOR()、_IOW()宏生成唯一的命令号,避免冲突。 - 并发访问:字符设备驱动默认不支持并发。如果多个进程同时打开设备,read/write操作会互相干扰。我建议在驱动里加一个原子操作或者互斥锁,确保同一时间只有一个进程在操作硬件。