4. 自旋锁的使用与陷阱:spin_lock/spin_unlock、中断上下文中的自旋锁、IRQ与BH锁
自旋锁(Spinlock)是 Linux 内核中最基础的锁机制之一,设计用于保护短临界区。与互斥锁不同,自旋锁在锁被持有时,等待线程不会睡眠,而是持续“自旋”检查锁状态。这种忙等行为在单核或抢占式内核中会引发严重问题,尤其是在中断上下文中使用时,稍有不慎就会导致死锁或系统挂起。
核心警告:自旋锁的持有时间必须极短(通常 < 几十微秒)。在持有自旋锁期间,绝对不能调用可能导致睡眠的函数(如
copy_from_user()、kmalloc(GFP_KERNEL)、mutex_lock()),否则内核会触发 BUG 或死锁。
4.1 spin_lock / spin_unlock 基础用法
自旋锁的核心 API 围绕 spinlock_t 类型展开。标准使用模式如下:
#include <linux/spinlock.h>
spinlock_t my_lock;
// 静态初始化
DEFINE_SPINLOCK(my_lock);
// 动态初始化
void init_module(void) {
spin_lock_init(&my_lock);
}
// 临界区
void my_function(void) {
unsigned long flags;
spin_lock_irqsave(&my_lock, flags); // 保存中断状态并获取锁
// ... 受保护的共享数据操作 ...
spin_unlock_irqrestore(&my_lock, flags); // 恢复中断状态并释放锁
}
| API 函数 | 上下文要求 | 行为说明 |
|---|---|---|
spin_lock() |
进程上下文(可睡眠前) | 获取锁,若锁被持有则忙等 |
spin_unlock() |
与 lock 配对 | 释放锁,唤醒等待者 |
spin_lock_irq() |
进程/软中断上下文 | 获取锁 + 关闭本地 CPU 中断 |
spin_unlock_irq() |
与 lock_irq 配对 | 释放锁 + 开启本地 CPU 中断 |
spin_lock_irqsave() |
任何上下文(推荐) | 保存中断标志 + 关闭中断 + 获取锁 |
spin_unlock_irqrestore() |
与 lock_irqsave 配对 | 恢复中断标志 + 释放锁 |
spin_lock_bh() |
进程/硬中断上下文 | 获取锁 + 禁止软中断(BH) |
spin_unlock_bh() |
与 lock_bh 配对 | 释放锁 + 开启软中断 |
最佳实践:在不确定当前中断状态时,始终使用
spin_lock_irqsave() / spin_unlock_irqrestore() 配对。它们会保存并恢复中断标志,避免因中断状态改变导致的隐蔽死锁。
4.2 中断上下文中的自旋锁陷阱
当中断处理程序(ISR)与进程上下文共享自旋锁时,必须防止“中断处理程序在锁被持有时抢占进程上下文”。下图展示了经典死锁场景:
解决方案:在中断上下文中访问共享锁时,必须使用 spin_lock_irqsave() 在进程上下文中关闭本地 CPU 中断,确保中断处理程序不会在锁持有期间被调度。
// 正确的做法:进程上下文中关闭中断
void process_context_func(void) {
unsigned long flags;
spin_lock_irqsave(&my_lock, flags);
// 此时本地 CPU 中断被关闭,ISR 不会抢占
shared_data++;
spin_unlock_irqrestore(&my_lock, flags);
}
// 中断处理程序:只需普通 spin_lock(因为中断已关闭)
irqreturn_t my_isr(int irq, void *dev_id) {
spin_lock(&my_lock);
// 操作共享数据
spin_unlock(&my_lock);
return IRQ_HANDLED;
}
4.3 IRQ 锁与 BH 锁的区别
Linux 内核将中断处理分为上半部(Hard IRQ)和下半部(Soft IRQ / Tasklet / Workqueue)。自旋锁 API 针对不同中断层级提供了细粒度控制:
| 场景 | 推荐的锁 API | 说明 |
|---|---|---|
| 仅进程上下文 | spin_lock() |
无中断竞争,性能最优 |
| 进程 + 软中断 | spin_lock_bh() |
禁止软中断,允许硬中断 |
| 进程 + 硬中断 | spin_lock_irqsave() |
关闭本地 CPU 所有中断 |
| 进程 + 软中断 + 硬中断 | spin_lock_irqsave() |
最安全,覆盖所有抢占路径 |
| 仅软中断 | spin_lock() |
软中断不会互相抢占(同一 CPU) |
| 仅硬中断 | spin_lock() |
硬中断不会互相嵌套(同一 CPU) |
关键区别:
spin_lock_irq()关闭本地 CPU 的 所有中断(硬中断 + 软中断)。spin_lock_bh()仅关闭 软中断(BH),硬中断仍然可以响应。这允许硬中断处理程序在软中断持有锁时仍然执行。- 如果硬中断处理程序也访问同一锁,使用
spin_lock_bh()是不够的——必须升级到spin_lock_irqsave()。
4.4 自旋锁使用陷阱清单
以下是驱动开发中常见的自旋锁错误,每个都可能导致死锁或数据损坏:
| 陷阱 | 错误示例 | 后果 | 正确做法 |
|---|---|---|---|
| 在锁内睡眠 | spin_lock(); kmalloc(..., GFP_KERNEL); |
内核 BUG / 死锁 | 使用 GFP_ATOMIC 或提前分配 |
中断上下文使用 spin_lock() |
ISR 中调用 spin_lock(&lock) |
死锁(若进程已持有锁) | 使用 spin_lock_irqsave() |
| 忘记保存/恢复中断标志 | spin_lock_irq(); ...; spin_unlock(); |
中断意外开启/关闭 | 配对使用 _irqsave/_irqrestore |
| 递归获取自旋锁 | 同一函数内两次 spin_lock() |
自旋死锁 | 重新设计锁粒度或使用 spin_lock_nested() |
| 锁顺序不一致 | 线程 A: lock1→lock2; 线程 B: lock2→lock1 | ABBA 死锁 | 固定全局锁顺序 |
在 preempt_disable() 内使用 spin_lock() |
抢占已关闭,自旋锁冗余 | 性能下降 | 直接使用 preempt_disable() 即可 |
调试提示:如果系统在加载驱动后随机挂起,且挂起时 CPU 占用 100%(通过串口或 netconsole 观察),极有可能是自旋锁死锁。此时立即启用
CONFIG_DEBUG_SPINLOCK 和 CONFIG_DEBUG_LOCK_ALLOC 重新编译内核,lockdep 会输出详细的死锁检测报告。
4.5 自旋锁与抢占的关系
自旋锁的实现与内核抢占密切相关。在抢占式内核(CONFIG_PREEMPT)中,spin_lock() 会隐式调用 preempt_disable(),防止当前任务被抢占。这意味着:
- 持有自旋锁时,当前 CPU 不会发生进程切换。这是自旋锁能保护数据的关键——锁持有者不会被其他进程抢占。
- 中断仍然可以发生。即使抢占被禁用,硬件中断仍会触发。这就是为什么中断上下文需要额外的
irqsave保护。 - 在单核 CPU 上,
spin_lock()退化为preempt_disable()+ 内存屏障,没有真正的自旋等待。
// spin_lock() 在抢占式内核中的简化实现(概念性)
static inline void spin_lock(spinlock_t *lock) {
preempt_disable(); // 禁止抢占
if (unlikely(!raw_spin_trylock(lock))) {
// 自旋等待直到获取锁
while (!raw_spin_trylock(lock)) {
cpu_relax(); // 告诉 CPU 我们在自旋等待
}
}
}
性能建议:自旋锁的自旋等待会消耗 CPU 周期。如果临界区较长(> 几百条指令),考虑使用
mutex 或 completion 替代。使用 perf top 或 ftrace 可以识别出高自旋开销的锁。
4.6 实战:编写安全的自旋锁驱动代码
以下是一个完整的中断共享自旋锁示例,展示了正确的上下文处理:
#include <linux/module.h>
#include <linux/spinlock.h>
#include <linux/interrupt.h>
static spinlock_t my_lock;
static int shared_counter = 0;
// 硬中断处理程序
static irqreturn_t my_irq_handler(int irq, void *dev_id) {
// 中断上下文:使用 spin_lock(因为进程上下文已关闭中断)
spin_lock(&my_lock);
shared_counter++;
spin_unlock(&my_lock);
return IRQ_HANDLED;
}
// 进程上下文函数
static void process_work(void) {
unsigned long flags;
// 保存中断状态并获取锁
spin_lock_irqsave(&my_lock, flags);
shared_counter += 10;
// 此时本地 CPU 中断关闭,ISR 不会执行
spin_unlock_irqrestore(&my_lock, flags);
}
// 软中断(Tasklet)上下文
static void my_tasklet_func(unsigned long data) {
// 软中断上下文:使用 spin_lock_bh 防止其他软中断抢占
spin_lock_bh(&my_lock);
shared_counter += 5;
spin_unlock_bh(&my_lock);
}
static int __init my_driver_init(void) {
spin_lock_init(&my_lock);
// 注册中断、初始化 tasklet 等
return 0;
}
module_init(my_driver_init);
上述代码遵循了以下安全原则:
- 进程上下文使用
spin_lock_irqsave(),确保硬中断不会在锁持有期间抢占。 - 硬中断上下文使用
spin_lock(),因为进程上下文已经关闭了中断,不会发生竞争。 - 软中断上下文使用
spin_lock_bh(),防止其他软中断实例同时执行。 - 所有 API 严格配对,没有混用
_irqsave和_irqrestore以外的组合。
总结:自旋锁是 Linux 驱动中最容易出错但性能最高的锁机制。记住三条黄金法则:
1. 持有时间极短,绝不睡眠。
2. 锁 API 必须覆盖所有可能抢占该锁的上下文层级。
3. 始终使用
1. 持有时间极短,绝不睡眠。
2. 锁 API 必须覆盖所有可能抢占该锁的上下文层级。
3. 始终使用
spin_lock_irqsave() 作为默认选择,除非你明确知道中断状态。