在嵌入式 Linux 驱动开发中,并发控制与中断处于极其重要的核心地位。本文,我将结合 CPU 的行为与操作系统的调度,深入分析 spinlock 和 mutex 的本质区别,以及 Linux 中断上下半部。
1. 上下文的概念
在深入探究锁和中断之前,我们必须先了解 Linux 内核的两种核心执行流,这里先简单概括一下:
- 进程上下文: 代表着某个具体的进程在执行,有对应的
task_struct。进程上下文中允许休眠,因为调度器知道休眠后该去唤醒谁,上下文切换是安全的。 - 中断上下文: 当发生硬件中断时会强行打断当前进程的执行。中断上下文其实是 借用了被中断的进程的内核栈,并没有自己独立的进程实体,也就是
task_struct,因此中断上下文中是绝对禁止休眠的,如果引发休眠,调度器将永远 无法重新唤醒 它,直接导致系统崩溃。
下面我们逐个深入解析。
1.1 进程上下文
要学习进程上下文我们必须知道 current 宏,current 是 Linux 内核中用于获取当前正在运行进程的 task_struct 结构体指针的宏。
current 可以用于定位当前进程,返回 struct task_struct * 指针,指向当前 CPU 上 正在执行的进程描述符。在内核中通常用于读取或修改进程状态、PID、权限、信号、调度属性等信息。此外,每个 CPU 都 独立 维护自己的 current,无锁,开销低。
我们通常都知道进程上下文中允许休眠,但是这里面其实有着很多的细节:
- 当 进程上下文 中调用
malloc或者mutex_lock,若资源不足,内核就会调用schedule。 - 然后,调度器会将当前 CPU 的寄存器,还有程序计数器、栈指针 压入该进程的内核栈,这是为了在找回该进程的时候能接着上次休眠的地方继续执行,并将进程状态设为
TASK_INTERRUPTIBLE或TASK_UNINTERRUPTIBLE,然后放入等待队列。 schedule()会找到下一个可运行的进程,切换页表,并恢复其寄存器。- 当资源可用时,唤醒函数会把原先休眠的进程 移回运行队列,调度器下次运行时,能从之前休眠的代码位置继续执行。
关键点就在于内核有 current 指针,在进程上下文切换时,current 就从旧进程的 task_struct 切换到新的进程的 task_struct。
1.2 中断上下文
上面我们提到,如果在中断上下文中引发休眠,调度器将无法重新唤醒它,这是为什么呢?
我们从调度器的视角来看:
- 中断上下文并没有
task_struct,当它调用schedule时,调度器并 不知道要把哪个进程切出去,更不知道这个执行流是谁。它找不到对应的task_struct来保存状态。因此,从逻辑上讲,调度器在这种情况下是无法进行调度的。 - 即使强行切换了,中断返回时,CPU 需要恢复现场,但由于没有
task_struct记录中断的栈帧,CPU 不知道应该回到哪里去。
因此,Linux 内核对于这种尝试在不能休眠的位置进行可能触发休眠的操作提出了应对的方法:
Linux 内核中,如果中断上下文中调用了可能休眠的函数,比如 mutex_lock,内核的锁校验机制 CONFIG_DEBUG_ATOMIC_SLEEP 会在编译时或运行时检测到 in_atomic() 为真,直接触发 BUG: scheduling while atomic。这个错误的本质是:你试图在一个不能进行进程切换的环境里,执行一个可能触发进程切换的操作。
1.3 让内核崩一次
为了验证在中断中睡眠是否会导致程序崩溃,我进行了下面实验:
我用我前两天写的按键 input 子系统驱动进行测试,在按键按下或抬起时会进入中断,我在中断处理函数中加入 msleep,如下图:
我原以为加载驱动后,按下按键,程序会崩溃,内核打印错误日志,因此我特意开了两个终端,一个用来加载模块,一个用来查看实时内核日志,但是结果令我大吃一惊,请看吧:
如图所示,在按下按键之后,内核也是如愿以偿的崩了,我尝试了好几次,前面几次甚至连内核日志都没来的及打印,SSH 就断开了,随后板上象征着系统正常运行的闪烁着的绿灯也彻底熄灭了。
我试到第四次,才成功看到了内核日志打印的错误信息。
2. 并发与同步机制
在 Linux 内核中,保护共享资源的两种最基本锁机制是 自旋锁 和 互斥锁。它们的核心差异不仅在于名字,更在于获取不到锁时的行为和对系统造成的开销。
2.1 行为对比
对于自旋锁:
当拿不到锁时,CPU 会进入死循环,原地空转不断尝试获取,它会严重消耗 CPU 资源,但并不产生上下文切换的开销。
此外,拿到自旋锁之后,还会关闭当前 CPU 的内核抢占功能,导致该 CPU 无法响应其他的中断,若等待时间过长,会严重拖慢系统的反应速度。
对于互斥锁:
当拿不到锁时,当前进程会被放入等待队列,主动调用 schedule 让出 CPU 进入休眠,它会引发上下文切换,从而导致操作系统调度别的任务。
上下文切换的开销很大,涉及到寄存器的保护和恢复,Cache 的失效等等。
2.2 适用场景总结
基于上面两种锁机制的特性,可以归纳一下他们各自的适用场景:
自旋锁:
适用于临界区代码极短,锁很快就能被释放的场景。
适用于中断上下文,由于中断上下文禁止睡眠,所以只能使用自旋锁。
互斥锁:
适用于临界区较长,或者临界区内包含可能引起休眠的操作,比如复制大量数据到用户空间、分配内存等。
互斥锁只能用于进程上下文。
2.3 死锁问题简述
如果在中断中不仅要加锁,还涉及到与普通进程共享资源,那么就不能使用普通的自旋锁,必须使用 spin_lock_irqsave。
spin_lock_irqsave 在获取锁的同时,会 关闭本地 CPU 的硬件中断 并保存中断状态。
如果只用普通的 spin_lock,进程拿到锁还没释放时, CPU 突然来了中断,中断处理函数中又去申请同一把锁,就会导致死锁。
3. 中断的上下半部机制
中断处理的原则是 越快越好,因为在中断处理期间,硬件中断是被屏蔽的。但是此时如果网卡收到大量数据需要处理,或者读取传感器需要一定的时间,怎么办?
为了能够解决 既要响应快,又要及时处理耗时任务 的矛盾,内核引入了中断上下半部机制。
其中 上半部 在 中断上下文 中执行,只做最紧急的事情,比如读取硬件寄存器,清除中断标志位,将数据存入内存,并触发下半部,然后立刻返回。
而 下半部 中,负责完成中断相关的剩余耗时工作,执行环境也相对宽松一点。
常见的下半部实现方式有 Tasklet 和 Workqueue,他们的对比如下表:
4. 现代 Linux 内核的演进方向
在早期的内核代码中,Tasklet 使用非常广泛。但在现代 Linux 内核中 Tasklet 正在被逐步废弃,这是由于其设计缺陷容易导致 软中断延迟不可控,影响系统的实时性。
此外,现代内核优化了 Workqueue,性能已足够强了,所以以后习惯上还是改用 Workqueue 比较好。
内核还引入了 request_threaded_irq 接口,也就是中断线程化,直接将下半部放入一个独立的、可调度的内核线程中去执行。这种方式既解决了休眠问题,又能让操作系统统一管理调度优先级,极大地提升了系统的实时性。
最后,大家可以尝试思考两个简单的问题:
- 在硬件中断处理函数中,如果我要等待一个 I2C 传感器回应,可以调用
msleep(10)吗? - Spinlock 既可以在中断上下文使用,又可以在进程上下文使用,那为什么还要有 Mutex?
知道答案的可以打在评论区哦~