1. 为什么这三个机制要放在一起讲做嵌入式Linux驱动做的久了你会发现中断底半部 ioctl 原子操作与锁这三个东西几乎总是同时出现在同一个驱动里。与其在项目里东拼西凑地查资料不如专门用一篇文章把这三者的配合关系彻底讲透。我自己的体会是这三个机制对应的是一个驱动里最经典的一条数据链路——硬件中断触发事件驱动在受限的中断上下文中快速响应把耗时工作放到底半部处理然后通过设备节点的接口ioctl把数据交到用户态而整个过程中共享变量的修改必须用原子操作和锁来保护。举一个很常见的实际场景某ARM开发板上接了一个按键按下时产生GPIO中断驱动需要记录按下的次数、时间戳还要把最新状态反馈给用户态程序。此时你就必须面对几个问题按键按下瞬间中断处理函数顶半部不适合做太多事情因为中断上下文里不能睡眠也不能长时间占用CPU否则整个系统都会卡顿。中断服务函数里修改的计数变量、状态标志用户态随时可能通过ioctl来读取两边并发访问不加保护就会出现读一半被改掉之类的脏数据问题。中断上下文和进程上下文对锁的要求完全不同进程上下文可以睡中断上下文绝对不能睡选错锁轻则性能暴跌重则直接死锁系统崩溃。这三个需求叠在一起正好就是标题里说的中断底半部 ioctl 原子操作与锁的完整组合。1.1 打断一下中断上下文为什么这么特殊理解这三个机制之前先搞清楚一个基础概念CPU在响应中断的时候会进入一个叫做中断上下文的状态。这时候系统不会调度别的进程中断处理函数里一旦调用会睡眠的函数比如mutex_lock、copy_to_user、kmalloc加GFP_KERNEL标志内核直接报错严重时系统挂死。所以中断处理函数必须短小精悍。可是按键去抖、数据累积、事件上报这些工作又不可能在两三行代码里全干完。于是Linux内核提供了一个经典的拆解思路把紧急的工作放在顶半部硬中断处理函数把不紧急的工作推后到底半部执行。顶半部负责立刻要做的事比如读硬件寄存器、清中断标志位、唤醒底半部。底半部负责可以等一会儿再做的事比如处理数据、更新计数、唤醒等待队列上的进程。1.2 用户态和内核态之间的门卫ioctl设备驱动最终要替用户态程序办事。用户态程序需要读取按键计数、清空计数、设置上报模式这些操作都通过系统调用下发到内核。read/write是通用的数据流接口但很多设备的行为更像命令控制而不是读一串字节。这时候用ioctl最自然它本质上就是一个命令分发器用户态传进来一个命令号驱动根据命令号决定是读数据、写配置还是执行某个动作。1.3 并发从哪里来锁到底在防谁很多新手想不明白一个问题我就是个按键驱动怎么会并发实际上并发来源比你想象的要多用户态两个线程同时调用ioctl读取同一个变量中断顶半部正在修改计数用户态的read/ioctl正好也在读这个计数底半部在跑用户态又发来一个写命令修改配置多核ARM平台上两个核同时执行驱动代码那就更是正儿八经的并行这些场景下不加锁就会出现读到半个数据、计数丢失更新、状态错乱的问题。原子操作和锁就是干这个用的。2. 中断底半部不能什么都往顶半部里塞2.1 顶半部到底该干多少活先达成一个共识顶半部里的代码越少越好。我给自己定的标准是顶半部里只放三样东西读取硬件状态寄存器确认中断源清除硬件中断标志否则中断会一直触发形成中断风暴触发底半部调度然后立刻返回拿按键中断举例顶半部大概长这样static irqreturn_t key_irq_handler(int irq, void *dev_id) { struct key_device *dev (struct key_device *)dev_id; // 读硬件寄存器确认中断确实来自按键 if (!(readl(dev-regs GPIO_ISR) KEY_BIT_MASK)) return IRQ_NONE; // 清中断标志 writel(KEY_BIT_MASK, dev-regs GPIO_ISR); // 调度底半部 tasklet_schedule(dev-key_tasklet); return IRQ_HANDLED; }为什么不能在这里面直接更新计数和时间戳从表面看更新一个atomic_t计数变量也就几行代码时间上完全来得及。但你还要考虑中断嵌套、多核并行、后续扩展逻辑比如上报netlink消息、唤醒进程这些代码一旦多了就会让整个系统的实时性变差。中断处理函数占用CPU的时间越长系统能响应的其他中断就越少。所以规矩点顶半部只管调度干活到底半部去。2.2 tasklet 和 workqueue 怎么选底半部在Linux内核里主要有两种实现方式tasklet和workqueue老内核里还有softirq直接操作但驱动开发者一般不用碰那么底层。两者的核心区别可以总结成一张表特性taskletworkqueue运行上下文软中断上下文进程上下文是否允许睡眠不允许允许调度粒度轻量开销小较重内核线程调度适合场景中断频繁且任务短任务耗时较长可能做阻塞操作并发性同一tasklet在任意时刻只能在一个CPU上执行工作队列可并发取决于配置我这次的按键Demo用的是tasklet。理由很直接按键中断触发后底半部只做计数累加、记录时间戳、更新状态标志这些操作没有任何阻塞睡眠的可能tasklet的开销又比workqueue小响应也更快。如果你在驱动里后续要加入网络上报、写入大块数据、获取互斥锁时可能睡眠这种操作那就要把代码从tasklet迁移到workqueue了。判断标准就一句话底半部里有没有睡觉的需求有就workqueue没有就tasklet。2.3 tasklet的创建、调度与销毁tasklet的完整生命周期涉及三个动作初始化、调度、销毁。// 结构体里定义 struct key_device { struct tasklet_struct key_tasklet; atomic_t press_count; unsigned long last_jiffies; unsigned int event_flags; }; // 初始化通常在probe里做 tasklet_init(dev-key_tasklet, key_bottom_half, (unsigned long)dev); // 调度在中断顶半部里调用 tasklet_schedule(dev-key_tasklet); // 销毁在remove里做注意要确保不会再被调度 tasklet_kill(dev-key_tasklet);这里有几个细节值得强调第一tasklet_schedule之后底半部不一定立刻执行它只是被挂到了CPU的软中断队列上等内核在合适的时间点去执行。所以不要用时序严格同步的思路去理解顶半部和底半部的关系。第二tasklet_schedule多次调用同一个tasklet时底半部只会执行一次。但是如果你希望每次中断都处理一次数据这个特性就要注意后面我会在第六节讲这个坑。第三同一个tasklet在多核系统上同一个时刻只能在一个CPU核上运行所以两个CPU不会同时执行同一个tasklet。但多核上不同tasklet之间还是可能并行如果你有多个tasklet共享数据锁仍然不能少。2.4 底半部里的数据到底还要不要锁答案是要。tasklet虽然与顶半部之间是串行执行的同一tasklet不会自己打断自己但用户态的ioctl可能在另一个CPU核上同时访问共享变量。底半部更新press_count和last_jiffies用户态通过ioctl读取这些变量如果只有写没有读或者只有读没有写可能没事。但判断并发安全不能赌必须加原子操作或锁。具体怎么加我放在第四节统一讲这里先把底半部的代码写出来static void key_bottom_half(unsigned long data) { struct key_device *dev (struct key_device *)data; // 累加计数值原子操作 atomic_inc(dev-press_count); // 更新最后一次按下时间这个变量用自旋锁保护 spin_lock(dev-state_lock); dev-last_jiffies jiffies; dev-event_flags | KEY_FLAG_PRESSED; spin_unlock(dev-state_lock); // 唤醒等待数据的进程 wake_up_interruptible(dev-wait_queue); }3. ioctl接口给用户态开一扇命令窗口3.1 ioctl命令码是怎么拼出来的ioctl在Linux里的核心是命令码。用户态传入一个整数内核驱动通过这个整数决定执行什么操作。Linux内核里定义了四个宏来生成命令码#include linux/ioctl.h #define KEY_MAGIC K #define KEY_IO_GET_COUNT _IOR(KEY_MAGIC, 1, unsigned int) #define KEY_IO_GET_STATUS _IOR(KEY_MAGIC, 2, struct key_status) #define KEY_IO_CLEAR_COUNT _IOW(KEY_MAGIC, 3, unsigned int) #define KEY_IO_SET_MODE _IOW(KEY_MAGIC, 4, unsigned int)命令码一共32位分成几个部分type幻数占8位用来区分不同的设备驱动随便选一个字母或数字别和系统里其他驱动冲突nr编号占8位同一个驱动内部区分不同命令direction方向占2位_IOR表示内核要读用户态往内核传数据方向是写反直觉记从用户视角看方向就好使了size数据大小占14位编译器通过宏自动算宏的名字就是最直白的记忆点_IOR表示用户态要读数据_IOW表示用户态要写入数据。如果你既写又读用_IOWR。3.2 为什么不能用内核指针直接访问用户态的内存用户态程序调用ioctl时传入的往往是一个用户空间指针。驱动代码运行在内核态不能直接解引用用户空间的指针。原因有几个用户态地址在内核态可能有完全不同的映射含义用户传进来的指针可能指向非法地址内核直接访问也没法安全地返回错误直接解引用容易引入安全问题所以copy_to_user和copy_from_user就是专门干这个的桥梁static int key_ioctl_get_count(struct key_device *dev, unsigned long arg) { unsigned int count; count atomic_read(dev-press_count); // 从内核拷贝数据到用户空间 if (copy_to_user((void __user *)arg, count, sizeof(count))) return -EFAULT; return 0; }这里有个非常容易犯的错误把copy_to_user的结果忽略。它返回0表示成功非0表示还有多少字节没拷完。判断条件写成if (copy_to_user(...))return -EFAULT是标准写法但这只能说明拷贝失败到底拷了多少字节用户态的缓冲区是否被部分写入你不一定清楚。对于要求严格的驱动还需要在拷贝前用access_ok检查用户缓冲区的合法性不过copy_to_user内部已经做了不用重复写。3.3 结构体版的数据交互一次传多个字段按键驱动除了计数用户态往往还需要知道按键状态、最近一次按下的时间。用一个结构体来交互比多次ioctl调用清爽得多struct key_status { unsigned int press_count; unsigned long last_jiffies; unsigned int event_flags; }; #define KEY_IO_GET_STATUS _IOR(KEY_MAGIC, 2, struct key_status)驱动侧static int key_ioctl_get_status(struct key_device *dev, unsigned long arg) { struct key_status status; spin_lock(dev-state_lock); status.press_count atomic_read(dev-press_count); status.last_jiffies dev-last_jiffies; status.event_flags dev-event_flags; spin_unlock(dev-state_lock); if (copy_to_user((void __user *)arg, status, sizeof(status))) return -EFAULT; return 0; }注意驱动先把数据拷贝到栈上的临时结构体status再统一copy_to_user。为什么不能直接把dev-last_jiffies这些字段拷给用户因为dev里的数据在内核地址空间直接拿那个指针操作没法保证数据的当前一致性而且结构体字段在用户态和内核态可能有对齐差异。先拷到本地再整体拷贝是驱动开发里最稳妥的写法。3.4 ioctl的统一分发逻辑ioctl处理函数本身只是一个分发器用switch或case匹配命令码static long key_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { struct key_device *dev filp-private_data; switch (cmd) { case KEY_IO_GET_COUNT: return key_ioctl_get_count(dev, arg); case KEY_IO_GET_STATUS: return key_ioctl_get_status(dev, arg); case KEY_IO_CLEAR_COUNT: atomic_set(dev-press_count, 0); return 0; case KEY_IO_SET_MODE: return key_ioctl_set_mode(dev, arg); default: return -ENOTTY; } }几个返回值别搞混-ENOTTY命令码不识别的标准返回值用户态ioctl就会拿到错误码ENOTTY-EFAULT用户缓冲区拷贝失败-EINVAL参数不合法在file_operations里记住要挂.unlocked_ioctl而不是.ioctl。新内核Linux 2.6.36以后已经把.ioctl移除了.unlocked_ioctl就是标准入口它的原型不带inode参数只带file指针。3.5 file-private_data一个必用的关联魔法在open函数里把设备的私有数据结构指针存到filp-private_data这样后续的read/write/ioctl/release里直接取出来用不用靠一堆全局变量。这是驱动开发里最标准的做法static int key_open(struct inode *inode, struct file *filp) { struct key_device *dev container_of(inode-i_cdev, struct key_device, cdev); filp-private_data dev; return 0; }container_of的用法inode-i_cdev指向字符设备的内核对象cdev而这个cdev嵌在key_device结构体里通过它反推出整个key_device结构体的首地址。4. 原子操作与锁不是所有数据都要上锁4.1 原子变量对付计数器最优雅的方案计数器是并发环境下最容易出问题的一类数据。两个CPU同时执行count在汇编层面至少是三步操作读内存到寄存器、寄存器加1、写回内存。两步之间CPU可能被抢占值就错了。原子变量通过硬件指令ARM架构上是LDREX/STREX或者ATOMIC_*指令保证读-改-写一气呵成。#include linux/atomic.h struct key_device { atomic_t press_count; }; // 初始化 atomic_set(dev-press_count, 0); // 累加 atomic_inc(dev-press_count); // 读取 unsigned int count atomic_read(dev-press_count); // 清空没竞争可以简单写回 atomic_set(dev-press_count, 0);为什么计数这种简单的变量适合原子操作而不是自旋锁因为开销小。自旋锁在临界区即使只有一条指令也要经历加锁解锁的完整流程原子变量用一条指令就干完了。再说了代码也更简洁。所以有一个经验法则如果你的临界区只有一条简单的读改写操作优先考虑原子变量。4.2 自旋锁和互斥锁一个死等一个睡觉这是并发保护里最容易让人迷糊的地方。简单粗暴地区分一下锁类型等待者行为能否在中断/底半部中使用临界区要求spinlock自旋忙等不睡觉可以必须短不能睡眠mutex睡眠等待让出CPU绝对不可以没有硬性时间限制选择的逻辑很简单在中断上下文和底半部里只用自旋锁在进程上下文且临界区可能较长时用互斥锁。如果你在中断处理函数里调用了mutex_lock系统会输出BUG: sleeping function called from invalid context然后直接把你挂掉。这个坑我在调试阶段踩过一次教训深刻。反过来在进程上下文用自旋锁也不是不行只要临界区足够短但如果临界区里有耗时的内存分配、文件操作、或者可能睡眠的调用那用自旋锁就是在浪费CPU时间。4.3 自旋锁的两种初始化方式和关中断变种静态初始化直接用DEFINE_SPINLOCKstatic DEFINE_SPINLOCK(state_lock);动态初始化用spin_lock_initspin_lock_init(dev-state_lock);按键驱动的底半部更新last_jiffies和event_flags时用的就是这个自旋锁。但这里还有一个更关键的变种spin_lock_irqsave和spin_unlock_irqrestore。为什么有时候普通的spin_lock不够用因为如果驱动的锁在进程上下文比如ioctl里持有同时中断顶半部或底半部也要拿同一把锁就可能出现死锁进程拿着锁来了中断中断想拿同一把锁但进程永远无法释放因为被中断打断了直到中断处理完才继续。解决方法是中断处理里把中断关掉再锁锁完再恢复static void key_bottom_half(unsigned long data) { struct key_device *dev (struct key_device *)data; unsigned long flags; atomic_inc(dev-press_count); spin_lock_irqsave(dev-state_lock, flags); dev-last_jiffies jiffies; dev-event_flags | KEY_FLAG_PRESSED; spin_unlock_irqrestore(dev-state_lock, flags); wake_up_interruptible(dev-wait_queue); }flags保存了中断开/关的原始状态unlock_restore会把它恢复原样。在ioctl里访问同样字段时我用了spin_lock_irqsave虽然进程上下文看起来没必要关中断但为了对称和安全统一用irqsave版本最保险反正多几条指令的代价可忽略。4.4 锁的粒度宁可临界区短不可不加锁新手容易走两个极端要么什么都上锁临街区巨大要么觉得我这个数据就几行代码不会有问题。关键原则是临界区要短数据要保护。比如复制整个key_status结构体给用户态你应该在临界区里只做数据拷贝临界区外再处理copy_to_user。因为copy_to_user可能会睡眠缺页异常这在自旋锁的临界区里妥妥出事。反过来如果你有10个字段都在多处被并发改写别一个个原子化直接丢进自旋锁里更省心。原子操作只适合一行代码级别的简单修改字段与字段之间有关联一致性时就该用锁。5. 实操把三个机制拼成完整驱动把前四节的内容串起来就是一个可以在某ARM开发板上实际跑起来的按键驱动Demo。下面我给一个完整的参考结构和关键代码。5.1 设备结构体和文件操作集合#include linux/module.h #include linux/platform_device.h #include linux/interrupt.h #include linux/io.h #include linux/cdev.h #include linux/device.h #include linux/fs.h #include linux/slab.h #include linux/uaccess.h #include linux/wait.h #include linux/spinlock.h #include linux/atomic.h #include linux/of.h #include linux/of_irq.h #define KEY_MAGIC K #define KEY_IO_GET_COUNT _IOR(KEY_MAGIC, 1, unsigned int) #define KEY_IO_GET_STATUS _IOR(KEY_MAGIC, 2, struct key_status) struct key_status { unsigned int press_count; unsigned long last_jiffies; unsigned int event_flags; }; #define KEY_FLAG_PRESSED (1 0) struct key_device { struct cdev cdev; struct device *dev; int irq; void __iomem *regs; struct tasklet_struct key_tasklet; atomic_t press_count; spinlock_t state_lock; unsigned long last_jiffies; unsigned int event_flags; wait_queue_head_t wait_queue; };file_operations的结构体我直接写全static int key_open(struct inode *inode, struct file *filp); static int key_release(struct inode *inode, struct file *filp); static long key_ioctl(struct file *filp, unsigned int cmd, unsigned long arg); static const struct file_operations key_fops { .owner THIS_MODULE, .open key_open, .release key_release, .unlocked_ioctl key_ioctl, };5.2 probe函数里的中断注册与tasklet初始化ARM平台上的GPIO中断注册一般用devm_request_irq它会随设备remove自动释放省心static int key_probe(struct platform_device *pdev) { struct key_device *kdev; int ret; kdev kzalloc(sizeof(*kdev), GFP_KERNEL); if (!kdev) return -ENOMEM; kdev-irq platform_get_irq(pdev, 0); if (kdev-irq 0) { ret kdev-irq; goto err_free; } atomic_set(kdev-press_count, 0); spin_lock_init(kdev-state_lock); init_waitqueue_head(kdev-wait_queue); tasklet_init(kdev-key_tasklet, key_bottom_half, (unsigned long)kdev); // 注册中断IRQF_TRIGGER_FALLING 表示下降沿触发 ret devm_request_irq(pdev-dev, kdev-irq, key_irq_handler, IRQF_TRIGGER_FALLING, key_interrupt, kdev); if (ret) { dev_err(pdev-dev, request_irq failed: %d\n, ret); goto err_free; } // cdev 注册和 device_create 等代码省略... platform_set_drvdata(pdev, kdev); return 0; err_free: kfree(kdev); return ret; }特别注意request_irq的最后一个参数dev_id传的是kdev指针不是NULL。使用共享中断线时必须有不同标识即使非共享这个指针也用来在中断处理函数里安全地取回设备结构。5.3 应用层怎么调用这个驱动用户态程序同样要定义命令码宏注意宏必须和内核侧保持一致类型和编号都不能错#include stdio.h #include fcntl.h #include sys/ioctl.h #define KEY_MAGIC K #define KEY_IO_GET_COUNT _IOR(KEY_MAGIC, 1, unsigned int) #define KEY_IO_GET_STATUS _IOR(KEY_MAGIC, 2, struct key_status) struct key_status { unsigned int press_count; unsigned long last_jiffies; unsigned int event_flags; }; int main(void) { int fd open(/dev/key_demo, O_RDWR); if (fd 0) { perror(open); return 1; } unsigned int count 0; if (ioctl(fd, KEY_IO_GET_COUNT, count) 0) { perror(ioctl get count); return 1; } struct key_status st; if (ioctl(fd, KEY_IO_GET_STATUS, st) 0) { perror(ioctl get status); return 1; } printf(press_count %u, last_jiffies %lu, flags 0x%x\n, st.press_count, st.last_jiffies, st.event_flags); close(fd); return 0; }5.4 编译与验证流程内核模块的Makefile基本是固定套路obj-m key_drv.o KERNEL_DIR ? /lib/modules/$(shell uname -r)/build all: $(MAKE) -C $(KERNEL_DIR) M$(PWD) modules clean: $(MAKE) -C $(KERNEL_DIR) M$(PWD) clean编译完成后加载卸载的顺序如下insmod key_drv.ko mknod /dev/key_demo c 240 0 # 主设备号以实际注册为准 ./key_test rmmod key_drv调试时多观察dmesg输出中断有没有触发、tasklet有没有执行都靠它确认。我一般习惯在水潭里加dev_dbg或者在需要跟踪的函数里临时加printk生产环境记得拆掉。6. 常见问题与排查技巧实录这部分是我自己调试时真实踩过的坑。整理成表格方便大家直接对照。现象原因定位思路中断处理函数里用了mutex系统挂死中断上下文睡眠dmesg里搜sleeping function called from invalid contextioctl返回-EFAULTcopy_to_user/from_user失败检查用户传入指针是否为NULL、是否有效驱动加载成功但中断不触发中断号错误或触发方式不对cat /proc/interrupts 看注册情况检查硬件状态寄存器tasklet只执行了一次多次schedule合并每次中断都要重新schedule或用tasklet_hi_schedulermmod提示Device or resource busy设备节点被占用lsof或fuser查占用进程检查release是否执行计数总是不对中断和读并发没有原子保护换用atomic_inc或者给临界区加锁6.1 中断上下文睡下去的惨痛教训第一次写带锁的驱动时我在中断处理函数里用了mutex_lock保护共享变量加载模块按下按键的瞬间开发板直接死机。dmesg什么输出都没有因为系统已经挂死。后来用串口接上才看到一行泛红的警告指向mutex_lock。从此之后我给自己立了个规矩任何写进中断处理函数和tasklet的代码先过一遍会不会睡眠的排查单。睡眠的常见来源包括mutex_lock、kzalloc加GFP_KERNEL、copy_to_user/copy_from_user、msleep、printk某些调试变体等。必须在中断上下文时改成自旋锁、原子操作、GFP_ATOMIC、或者把耗时逻辑扔到workqueue。6.2 tasklet只跑一次的坑按键快速连按两次时第一次中断触发tasklet_scheduletasklet还在队列里没执行第二次中断又来了再次tasklet_schedule。因为同一个tasklet已经在挂起状态第二次调度会被忽略。如果底半部里只累加一次计数实际只收到了一次按键。理解这个机制以后就知道tasklet不是事件队列它只是一个待执行工作标记。如果你的需求是不可丢失的事件计数可以采用两种思路顶半部里用原子变量/锁直接累加计数底半部只做同步到用户态的事或者做成每中断都唤醒等待队列让用户态主动读取最新计数我在Demo里采用的是第一种顶半部只调度不计数底半部才计数——但因为tasklet会合并严格来说在快速连按场景有丢失事件的风险。所以对于按键这种场景更稳妥的做法是把atomic_inc放到顶半部里底半部只负责通知。6.3 ioctl返回-EFAULT的排查经历应用层传了一个栈上的unsigned int count进去ioctl却返回EFAULT。我当时费了很大劲才发现应用层的头文件src居然写成了_IOR(KEY_MAGIC, 1, unsigned int)但内核侧定义成了_IOR(KEY_MAGIC, 1, int)类型不同导致命令码的size字段对不上。_IOR/_IOW里嵌入数据大小是为了让内核预检查数据长度但这个长度是编译期算出来的。如果你应用层和内核层头文件里的结构体一个带packed一个不带或者类型不一致命令码就不一致驱动switch匹配不到直接走default分支返回-ENOTTY。排查方法也用命令码不一致来解释用户态ioctl返回-1并且errno是ENOTTY对应内核返回-ENOTTY基本就是命令码对不上如果是EFAULT则是命令码匹配上了但数据拷贝失败优先查指针和缓冲区。6.4 锁顺序不一致导致死锁我有一次在驱动里用两把锁保护两组数据一把锁管state_lock一把锁管data_lock。有一个路径是先锁data_lock再锁state_lock另一个路径顺序反了结果在并发时出现了死锁。排查死锁的标准思路是查看内核日志的possible circular locking dependency detected提示然后按提示调整两个加锁点的顺序。我自己总结的经验是一个驱动内尽量只用一把锁如果确实需要多把锁一定要定义明确的加锁顺序并写注释提醒后来的人。6.5 卸载模块时设备忙调试时经常遇到rmmod报Device or resource busy。大部分情况是应用层进程没关闭设备节点就强制退出了或者功能测试程序异常崩溃release没有被调用。常规排查lsof /dev/key_demo看哪个进程占用直接kill掉。如果确认没有进程占用了还有一层可能是cdev没正确删除或者device_destroy没调用。确保remove里的清理顺序正确先device_destroy、再cdev_del、最后释放中断和tasklet。6.6 中断触发频率过快导致系统反应迟钝如果按键硬件没有消抖或者GPIO配置的触发条件太灵敏中断可能一下触发几百次。这时候顶半部反复调度tasklet软中断把CPU占满系统响应变慢。这种情况有两个层面的解法硬件层面按键并联电容或者调整GPIO的滤波软件层面顶半部里记录最后一次中断的jiffies如果在短时间窗口内多次触发只处理第一次或合并处理标志位代码示意static irqreturn_t key_irq_handler(int irq, void *dev_id) { struct key_device *dev dev_id; if (!(readl(dev-regs GPIO_ISR) KEY_BIT_MASK)) return IRQ_NONE; writel(KEY_BIT_MASK, dev-regs GPIO_ISR); // 简单消抖如果距上次不足20ms则忽略 if (time_is_before_jiffies(dev-last_jiffies msecs_to_jiffies(20))) { tasklet_schedule(dev-key_tasklet); } return IRQ_HANDLED; }注意这个消抖逻辑里读last_jiffies可能有并发严谨做法还是加锁或原子读取此处代码只作为示意。结束前说点实际的这三个机制组合起来覆盖了驱动开发里最核心的中断响应-数据处理-用户态交互-并发保护链路特别是ARM平台上的真实项目中这套组合几乎天天都要写。我在实操中最深刻的体会是代码本身都不复杂真正坑人的是上下文边界和锁的选择。你在内核里写的每一行代码都要问自己我现在在什么上下文能不能睡眠这个数据有没有可能被并发访问。实际做项目的时候不要一上来就追求把所有机制写得花样百出。先跑通最简路径顶半部调度tasklettasklet更新原子变量ioctl读原子变量。等基础链路验证OK了再根据需求往里面加自旋锁保护多字段结构体、加wait_queue实现阻塞读、加workqueue处理复杂逻辑。一层一层往上叠出了问题定位也快。如果真在调试里碰到了系统挂死第一反应别慌先串口连上开发板打开dmesg看内核的最后几行输出。大部分上下文睡眠和锁死锁的问题内核都会在日志里明确提示。读懂了内核提示比在网上乱找答案高效得多。
阅读完成 · 觉得有帮助?