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(&lock) 中断处理程序 (ISR) 尝试 spin_lock(&lock) 中断发生 ⚠ 死锁发生 进程持有锁 → 中断抢占同一 CPU → 中断自旋等待锁 → 永远无法释放 结果:系统挂起 / 看门狗复位

解决方案:在中断上下文中访问共享锁时,必须使用 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_irq() / spin_lock_irqsave() / spin_lock_bh() 软中断 / Tasklet / BH 必须使用 spin_lock_bh() 或 spin_lock_irqsave(),禁止使用 spin_lock() 硬中断 (IRQ) 必须使用 spin_lock_irqsave() 或 spin_lock()(若已知中断已关闭) 规则:锁 API 必须覆盖所有可能抢占该锁的上下文 如果锁被进程 + 软中断 + 硬中断共享,必须使用 spin_lock_irqsave()
场景 推荐的锁 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);

上述代码遵循了以下安全原则:

  1. 进程上下文使用 spin_lock_irqsave(),确保硬中断不会在锁持有期间抢占。
  2. 硬中断上下文使用 spin_lock(),因为进程上下文已经关闭了中断,不会发生竞争。
  3. 软中断上下文使用 spin_lock_bh(),防止其他软中断实例同时执行。
  4. 所有 API 严格配对,没有混用 _irqsave 和 _irqrestore 以外的组合。
总结:自旋锁是 Linux 驱动中最容易出错但性能最高的锁机制。记住三条黄金法则:
1. 持有时间极短,绝不睡眠。
2. 锁 API 必须覆盖所有可能抢占该锁的上下文层级。
3. 始终使用 spin_lock_irqsave() 作为默认选择,除非你明确知道中断状态。