写Linux驱动,写到后面你会发现一个很有意思的现象:明明学的是“设备驱动”,但真正决定你代码稳不稳定的,往往不是设备本身的寄存器操作,而是你对待进程的方式。驱动代码从不主动运行,它要么在某个进程的系统调用里被动执行,要么在中断里被硬件强行拉起来跑一小段。这两种状态下的规矩,是完全相反的:在进程上下文里你随便睡,睡多久都行;在中断上下文里,你敢睡一下整个系统都跟着出事。
所以为什么要专门聊“Linux驱动开发之进程”?不夸张地说,你对进程相关机制的熟悉程度,决定了驱动代码从“能跑”到“稳定运行”之间的路要走多久。尤其是做字符设备、块设备、传感器驱动,每天打交道的都是进程调度、唤醒阻塞、并发保护。这篇文章我就按自己多年写驱动和内核模块的经验,把驱动视角下的进程机制掰开揉碎讲一遍,重点是告诉你每一步为什么这么做,以及哪些坑是我真踩过的。
1. 驱动代码的运行上下文:先搞清楚代码是谁在执行
1.1 进程上下文和中断上下文的边界
驱动里的每段代码,运行时都有“身份”。绝大多数驱动函数,比如read、write、ioctl、open,都是进程调用系统调用后进入内核,然后通过file_operations回调到驱动里的。这时候整条路径都叫进程上下文。
进程上下文里你有三样东西可以放心用:可以睡眠等待、可以访问当前进程的信息、可以通过内存描述符访问用户态地址空间。这也是为什么驱动的读写函数里可以用wait_event_interruptible、msleep、mutex_lock,这些都是会导致进程睡一觉的操作。
另一种身份是中断上下文,包括硬中断处理函数和底半部(softirq/tasklet)。中断上下文里没有进程的概念,当前进程是谁跟你没关系,这块代码是被硬件抢占进来的。这里不能睡眠、不能拿普通互斥锁、不能用会阻塞的函数。注意,printk也得悠着点,虽然它本身不会睡,但打印太多会挤占中断路径的时间。
一个很实用的判断技巧:in_interrupt()返回非零,说明你正在中断上下文里,那所有可能睡觉的API一概不要碰。更粗的判断是,看到hardirq、softirq、nmi这些字样,就按“不能睡”处理。
这里有一个最常见的初学者问题:中断处理里想做耗时的事情怎么办?答案是压到底半部或内核线程里做。但底半部还是软中断上下文,依然不能睡。真正想舒舒服服睡觉,得用内核线程或者工作队列。我见过不少模块,为了省事在tasklet里做互斥锁保护,结果锁竞争的时候直接死锁或内核panic,就是这个原因。
1.2 current指针与用户空间内存访问
在进程上下文里,内核全局指针current指向当前正在执行的进程的task_struct。这个指针很有用:调试时打印current->pid、current->comm,就能知道是哪个进程闯进你的驱动了。在并发问题排查时,这个信息比看堆栈还直观。
需要注意,current只能在进程上下文使用。中断处理里current是中断打断时那个进程的task_struct,拿它做任何判断都不可靠。内核线程里current指向内核线程自身,进程管理上的很多字段要小心使用。
用户空间内存的访问同样受上下文限制。驱动收到用户态指针时,不能直接memcpy或*ptr赋值,因为用户态页面可能不在内存里,强行访问会造成缺页,如果恰好发生在原子上下文,会引发死锁。正确姿势是用copy_from_user、copy_to_user这类安全拷贝函数,它们会处理缺页,并且可以睡眠等待页面调入。
我早期做过一个调试接口,图省事直接用了copy_from_user,结果某次被一个高频调用的路径调用,导致拦截信号和调度延迟,现象是系统卡顿几秒。后来换了带_user后缀的接入标准方式,并减少了调用频率才解决。写驱动时千万别贪图少写那两行代码。
2. 进程与设备接口之间的生命周期绑定
2.1 file_operations是驱动的用户面孔
用户进程“打开设备”这件事,听起来简单,在内核里却是一套完整的生命周期:进程调用open(),内核按设备号找到驱动,执行驱动的open回调,返回一个struct file给进程。之后进程的所有read/write/ioctl操作,都围绕这个struct file进行,直到进程关闭文件描述符,驱动执行release清理。
所以驱动开发中有一件事很核心:每个进程打开设备,都得有一个独立的“会话上下文”。这个上下文一般放在struct file的private_data里:
static int my_open(struct inode *inode, struct file *filp) { struct my_dev *dev = container_of(inode->i_cdev, struct my_dev, cdev); struct my_open_ctx *ctx; ctx = kzalloc(sizeof(*ctx), GFP_KERNEL); if (!ctx) return -ENOMEM; ctx->dev = dev; filp->private_data = ctx; return 0; }private_data的存在,让同一个驱动可以同时服务几十个进程,互不干扰。每次open都有独立的上下文,每个进程操作的偏移量、状态都有独立副本。这个设计我建议所有驱动都遵守,即使只有一个进程用,也保留上下文结构体,方便以后扩展。
2.2 open/release里最容易被忘掉的细节
release里做资源回收,看起来简单,但漏掉任何一样都会出问题。核心工作是释放private_data、关闭中断、卸载定时器、销毁工作队列等。特别要注意并发场景:release执行时,可能还有另一个进程正阻塞在read等待队列里。如果release直接把共享状态销毁了,阻塞中的进程会突然醒来访问空指针。
一个实用的手段是引用计数。设备结构体里放一个struct kref,read/open/ioctl都先拿引用,用完后放掉。release只做“最后一次引用释放”时的资源回收,这样就不会出现某个路径还在用设备、驱动已经把状态清掉了的情况。引用计数是驱动里最容易漏但最值得花时间设计的机制,尤其是设备和进程生命周期不一致的场合。
2.3 多进程打开同一设备时的私有数据
多个进程打开同一个设备,一般分两种情况:设备本身允许并发访问,或者同一时刻只允许一个进程独占。对于独占型设备(比如很多串口设备),通常用互斥锁或原子变量做一个“正在使用”标志,第二次open直接返回-EBUSY。很多成熟的驱动就是这么实现的,比如传统的串口驱动。
对于允许并发访问的设备,则需要保证每个open下来的私有数据互相隔离。尤其要注意“设备级状态”和“会话级状态”的区分。设备级状态(比如硬件寄存器、全局配置)要加锁保护;会话级状态(比如当前读写指针、本次配置)放在private_data里,天然是线程安全的。把两类状态混在一起,是驱动并发bug的常见来源。
3. 阻塞、唤醒与等待队列:驱动和进程互相等人的正确姿势
3.1 为什么不能循环等待
驱动里最常见的一种场景:进程read数据,但硬件还没把数据准备好。初学者最容易写出的代码是:
while (!(readl(reg) & DATA_READY)) ;这在单核、无并发、简单的Demo里能跑,线上设备这么写就是灾难。忙等待会让CPU空转,在高优先级下可能把整个系统拖死,功耗也高得离谱。正确的做法是让进程睡一会,等到条件满足再被叫醒。
内核为此提供了完整的等待机制:wait_event系列宏 +wake_up系列函数。进程在等待期间让出CPU,由调度器安排别的任务执行,条件满足时由驱动主动唤醒。
3.2 wait_event系列宏的用法
标准姿势是在设备结构体里定义一个等待队列头:
struct my_dev { struct cdev cdev; wait_queue_head_t wq; int data_ready; spinlock_t lock; };初始化用init_waitqueue_head(&dev->wq)。读取时:
static ssize_t my_read(struct file *filp, char __user *buf, size_t count, loff_t *off) { int ret; struct my_open_ctx *ctx = filp->private_data; struct my_dev *dev = ctx->dev; if (wait_event_interruptible(dev->wq, READ_ONCE(dev->data_ready))) return -ERESTARTSYS; // 取数据、拷贝到用户空间 return ret; }这里的每一个细节都有讲究:
wait_event_interruptible和wait_event的区别是能不能被信号打断。用户态程序Ctrl+C时,内核会试图唤醒等待中的进程。不可中断的wait_event会导致进程既睡不着也叫不醒,按Ctrl+C半天没反应。所以驱动里我几乎都用interruptible版本,配合返回值-ERESTARTSYS让上层系统调用重来。条件表达式
data_ready要用READ_ONCE或配合锁读取,避免编译器过度优化成只读一次寄存器,尤其是共享标志在中断里被修改的情况下。条件满足的瞬间,等待队列会自动把进程从队列摘除并继续执行。注意,参加唤醒等待时,条件可能已经被其他进程消费掉了,所以条件必须是“真正可获取的状态”,而不是一个一次性的瞬时信号。
3.3 唤醒时机和丢失唤醒的竞态
有等待就有唤醒,wake_up通常放在数据就绪的位置。如果是硬件中断通知数据到达,就在中断处理函数里唤醒:
static irqreturn_t my_irq_handler(int irq, void *arg) { struct my_dev *dev = arg; unsigned long flags; spin_lock_irqsave(&dev->lock, flags); dev->data_ready = 1; spin_unlock_irqrestore(&dev->lock, flags); wake_up_interruptible(&dev->wq); return IRQ_HANDLED; }这里有一个经典的“丢失唤醒”问题:进程检查条件发现不满足,准备睡眠,但此时中断已经发生且准备唤醒,可进程还没挂进等待队列。如果唤醒发生在挂队列之前,唤醒就丢了,进程彻底睡死。
内核的wait_event宏之所以安全,是因为它内部在持有等待队列锁的前提下重新检查条件,条件不满足才把进程挂入队列。所以要实现安全的等待,不要手写“先判断再睡眠”的代码,直接用宏。这也是为什么内核文档反复强调“使用wait_event而不是自己写等待逻辑”。
另外一个教训:唤醒的操作要放在临界区外执行。上锁改标志,解锁后立刻wake_up。如果在持锁时唤醒,唤醒路径要抢占锁来检查等待队列,可能增加锁竞争,甚至在某些锁类型下引起死锁。保持“临界区小于等于改标志,唤醒在外边”习惯。
3.4 完成量completion的使用场景
wait_event适合等待一个持续有效条件。如果等待的是一个“完成后就失效”的单次事件,比如异步操作结束、固件加载完成,更适合用completion:
struct completion done; init_completion(&done); /* 后台线程或中断里 */ complete(&done); /* 等待方 */ wait_for_completion(&done);注意:complete唤醒所有等待者还是只唤醒一个,取决于调用方式。默认complete唤醒一个等待任务,但如果你调用了两次complete而没有相应的等待者,计数会累积,下次等待立即返回——这在某些设计里是好事,在另一些设计里会让逻辑错乱。所以使用前想清楚这个事件的“一次性”属性。
completion比较适合一对一场景:一个线程发起操作,一个中断/内核线程完成操作。不像wait_event那样需要自己维护状态标志,少了一半逻辑,不容易出错。
4. 并发访问与锁的选择:调度随时发生
4.1 竞态来源:进程切换留了一个空档
驱动代码并发访问的根源,正是进程调度。进程中执行的驱动代码,可能在任意一条指令后被”抢走”,换另一个进程进来执行。比如两个进程同时调用ioctl修改同一个寄存器,如果不加保护,寄存器值就可能在某个中间状态上被覆盖,导致硬件状态错乱。
驱动里并发保护的基础思路和普通多线程编程一致:识别共享资源,划定临界区,选择合适的锁。区别在于驱动里共享资源的访问可能来自多个上下文:多个进程、硬中断、软中断、内核线程。每种上下文的锁规则不一样,这比纯用户态编程麻烦得多。
4.2 自旋锁与互斥锁:能否睡眠决定选择
锁选择的铁律就一条:临界区里会不会睡眠。会睡眠就用mutex,不会睡眠就用spinlock。
mutex_lock可能导致进程睡眠,因此只能在进程上下文使用。中断处理里不能拿互斥锁,否则可能死锁。spinlock则可以在中断里安全地使用(配上_irqsave/_irqrestore),但临界区不能睡眠,因为自旋锁获取期间会关闭抢占,拿着锁睡觉等于让其他CPU都等着你醒。
如何在两者之间选择,我有个非常实用的经验:保护寄存器配置、队列指针、状态标志这类只有几十条指令就能搞定的临界区,用spinlock;而保护需要操作大块缓冲区、等待硬件完成、需要分配内存这种可能阻塞的场景,用mutex。
还有一个隐蔽问题:同一个锁可能在进程上下文和中断上下文都被用到。比如设备状态标志既会被进程ioctl访问,又会在中断里被修改。这时候必须用spin_lock_irqsave,它会在当前CPU上关闭中断再拿锁,否则进程正拿着锁,中断来了又要拿同一把锁,就死锁了。我在刚学驱动时踩过这个坑:用了普通spin_lock,只要中断一来,系统马上卡死。换_irqsave后问题消失,这个教训我到现在记忆犹新。
4.3 用引用计数管理设备实例生命周期
设备可能被进程打开使用,也可能在某个时刻被卸载。如何保证卸载时没有进程还在访问设备?答案就是引用计数。
内核提供kref专门干这个事。拿引用用kref_get,释放用kref_put。最后一个引用被释放时,调用释放回调,这时才能安全释放设备结构体内存。考虑一个场景:进程A正在read一个设备,此时用户执行rmmod卸载模块。卸载函数等不到A退出——因为A可能阻塞在等待队列里。如果没有引用计数,卸载函数把设备结构体释放了,A随后醒来访问已释放内存,内核直接崩。
所以驱动在open里就应该给设备增加引用,release里减少引用,read/write/ioctl里也要先拿引用再操作。卸载流程里不要主动释放结构体,只要无法获取设备节点、然后等最后引用被放掉后自动释放即可。这个机制需要提前设计,我建议所有模块一开始就加上,省得后头补得苦不堪言。
5. 内核线程:驱动主动完成工作的常见手段
5.1 创建内核线程的正确流程
有时候驱动需要主动干一些活儿,比如周期轮询硬件状态、处理耗时的数据整理。最常见方式是用kthread_run创建内核线程:
struct task_struct *task; task = kthread_run(thread_fn, dev, "mydev_thread"); if (IS_ERR(task)) return PTR_ERR(task);回调函数需要检查退出信号:
static int thread_fn(void *data) { struct my_dev *dev = data; while (!kthread_should_stop()) { /* 做工作 */ set_current_state(TASK_INTERRUPTIBLE); schedule_timeout(msecs_to_jiffies(100)); } return 0; }停止时调用kthread_stop(task)。注意,kthread_stop和kthread_run的配对关系,以及线程函数里要检查kthread_should_stop。不要在卸载函数里直接force_sig或类似手段去杀线程,容易制造竞态。
5.2 内核线程与用户进程的区别
内核线程本质上是没有用户地址空间(current->mm为NULL)的特殊进程,运行在内核态,优先级上跟普通进程一样参与调度。它能睡眠,能干重活儿,但不能访问用户态内存,不经过系统调用接口。
驱动里创建内核线程的最大价值是:把耗时的、不能放入中断的活儿延后执行,并且可以主动选择睡眠时长来轮询硬件。但要注意,内核线程数量不是越多越好。每个线程都有栈空间和调度负担,过多的线程甚至可能导致系统响应变慢。能用工作队列解决的,就不要开线程;能用延迟工作解决的,也不要轻易开线程。
5.3 内核线程还是工作队列取决于任务属性
工作队列(workqueue)是把“一小段函数”丢给一个共享的内核线程池执行,灵活性比自建线程高,也不用管理线程生命周期。它适合“攒一批然后后台处理”的场合。自建内核线程适合“需要长期轮询、持续处理”,比如轮询采样的驱动。
我的一个经验是:如果任务是离散的、事件驱动的,优先考虑schedule_work或queue_work;如果任务本身有循环特性、需要周期巡检,才考虑内核线程。选错会导致代码里多了大量维护线程状态、退出同步的逻辑,真实工作中完全可以用workqueue简化掉。
6. 实操中的排查与避坑
6.1 怎么看一个驱动当前跑在哪个上下文
调试时最基础的问题是:这段代码当前能不能睡眠?我一般用三招鉴别:
看函数调用路径:从
read进系统调用进驱动,基本是进程上下文;从request_irq回调进入,多半是硬中断。看打印:在调用
dump_stack()后,查看调用栈里有没有interrupt、softirq等标记。使用内核提供的宏:
in_atomic()、in_interrupt()、in_irq()可以程序化判断。虽然它们在某些情况下不完全准确,但配合堆栈信息足够定位九成问题。
6.2 常见进程相关报错与处理
我整理了驱动调试中最高频的几个进程相关报错类型和经验处理方式,方便查表:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 驱动read一直不返回 | 等待队列条件永远不会满足 | 检查中断是否注册、条件标志是否被置位、是否丢失唤醒 |
| 按Ctrl+C没反应 | 用了不可中断的等待 | 换用wait_event_interruptible,处理-ERESTARTSYS |
| 卸载模块时系统卡死 | 内核线程未退出或引用计数未归零 | 检查kthread_stop配对、release时引用释放 |
| 中断上下文调用msleep导致死锁 | 锁选择错误 | 使用spinlock保护中断共享数据;把耗时工作移到线程 |
| 偶发死锁,加锁后重启确认 | 锁顺序不一致或中断打断持锁路径 | 使用spin_lock_irqsave;统一锁顺序 |
用copy_from_user后进程崩溃 | 指针无效或地址空间不匹配 | 确认用户指针合法性,使用access_ok预检 |
| 调度器大范围延迟 | 驱动临界区过长或频繁关中断 | 精简临界区,避免在锁内做循环 |
还有一个很刁钻的问题:进程在某些路径下被标记成TASK_UNINTERRUPTIBLE,然后驱动等待一个永远不会来的唤醒条件。这种情况下进程D状态卡死,既不能响应信号也不能被调度踢走。排查重点就是要确保每个进入睡眠的进程都有对应的唤醒路径。
6.3 写阻塞式读取驱动的一些经验
最后给一点真正实用的小经验。写带等待队列的阻塞read,我后面几次写得顺畅,主要靠三条:
第一,状态标志的改动必须在锁保护下进行,而唤醒放锁外。标志和队列头在结构体里紧挨着放,一目了然。
第二,read函数在拿到数据后,判断数据是否已被消费,如果数据被其他进程抢先读走,要重新进入等待。这类逻辑用wait_event条件写成函数判断,可读性远比裸循环好。
第三,用户态测试时,先写一个简单的状态机脚本:一个线程阻塞读,另一个线程触发数据。确认设备能正确唤醒,再把驱动程序移植到真实中断上。先软件后硬件的顺序,能省掉大量中断尚不稳定的调试时间。
我个人在真实项目里踩过的最大一个坑,就是在一个共享设备状态上同时用了自旋锁和互斥锁,两把锁保护同一份数据,导致偶发乱序和死锁。后来统一为一把锁并把访问路径画出来,问题立刻消失。所以锁定策略必须在设计初期明确,不要写了一半再调整。