资讯动态

Linux驱动开发中的进程管理:上下文、等待队列与并发保护

发布时间:2026/10/20 7:24:35 来源:尧图企业网站定制
写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 krefread/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的区别是能不能被信号打断。用户态程序CtrlC时内核会试图唤醒等待中的进程。不可中断的wait_event会导致进程既睡不着也叫不醒按CtrlC半天没反应。所以驱动里我几乎都用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适合等待一个持续有效条件。如果等待的是一个“完成后就失效”的单次事件比如异步操作结束、固件加载完成更适合用completionstruct 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一直不返回等待队列条件永远不会满足检查中断是否注册、条件标志是否被置位、是否丢失唤醒按CtrlC没反应用了不可中断的等待换用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条件写成函数判断可读性远比裸循环好。第三用户态测试时先写一个简单的状态机脚本一个线程阻塞读另一个线程触发数据。确认设备能正确唤醒再把驱动程序移植到真实中断上。先软件后硬件的顺序能省掉大量中断尚不稳定的调试时间。我个人在真实项目里踩过的最大一个坑就是在一个共享设备状态上同时用了自旋锁和互斥锁两把锁保护同一份数据导致偶发乱序和死锁。后来统一为一把锁并把访问路径画出来问题立刻消失。所以锁定策略必须在设计初期明确不要写了一半再调整。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑