2. 自旋锁API详解

自旋锁(Spinlock)是Linux内核中最基础的同步原语之一,特别适用于保护短临界区。其核心行为是:如果锁已被持有,申请者会原地“自旋”等待,直到锁被释放。这种忙等机制避免了上下文切换的开销,但要求临界区必须极短且执行路径不可睡眠。

Linux内核提供了三组自旋锁API,分别对应不同的中断上下文场景。选择错误的API是导致死锁和性能问题的常见根源。

2.1 三组API的核心差异

下表从中断使能状态、适用上下文和性能开销三个维度对比三组API:

API 组 获取锁时对本地中断的影响 适用上下文 保存/恢复中断状态 典型使用场景
spin_lock / spin_unlock 不操作中断使能位 进程上下文(无中断竞争) 否 纯进程上下文保护,或已确保中断不会访问共享数据
spin_lock_irq / spin_unlock_irq 获取锁时关闭本地中断,释放锁时无条件开启 进程上下文 + 中断上下文(硬中断) 否(硬开关) 明确知道进入临界区前中断是开启的,且退出后可以无条件开启
spin_lock_irqsave / spin_unlock_irqrestore 获取锁时保存当前中断状态并关闭本地中断;释放锁时恢复原状态 任何上下文(包括中断嵌套、软中断、NMI) 是(通过 unsigned long flags) 不确定中断状态、中断嵌套、或需要保持调用者中断状态不变的场景
关键原则: 在中断上下文中访问被自旋锁保护的共享数据时,必须使用 spin_lock_irqsave 或 spin_lock_irq,否则当前进程持有锁时被中断打断,中断处理程序尝试获取同一把锁,将导致自我死锁。

2.2 API 原型与行为详解

2.2.1 spin_lock / spin_unlock

#include <linux/spinlock.h>

spinlock_t my_lock;
spin_lock_init(&my_lock);

spin_lock(&my_lock);
/* 临界区:仅限进程上下文,不可睡眠 */
spin_unlock(&my_lock);
  • 行为: 仅执行原子性的“测试并设置”(TAS)或“加载链接/条件存储”(LL/SC)操作,不修改中断标志。
  • 风险: 如果临界区被中断处理程序访问,且中断处理程序也调用 spin_lock 获取同一把锁,则发生死锁。
  • 性能: 开销最小,仅涉及内存屏障和原子操作。

2.2.2 spin_lock_irq / spin_unlock_irq

spin_lock_irq(&my_lock);
/* 临界区:本地中断已关闭,可安全被中断上下文访问 */
spin_unlock_irq(&my_lock);
  • 行为: spin_lock_irq 在获取锁之前执行 local_irq_disable(),spin_unlock_irq 在释放锁之后执行 local_irq_enable()。
  • 风险: 如果调用 spin_lock_irq 之前中断已经是关闭状态,那么 spin_unlock_irq 会错误地开启中断,破坏调用者的中断状态预期。这是常见的隐蔽 bug。
  • 适用: 仅在明确知道调用时本地中断处于开启状态时使用。

2.2.3 spin_lock_irqsave / spin_unlock_irqrestore

unsigned long flags;

spin_lock_irqsave(&my_lock, flags);
/* 临界区:中断状态被保存,无论之前中断是开还是关,退出后都能正确恢复 */
spin_unlock_irqrestore(&my_lock, flags);
  • 行为: spin_lock_irqsave 将当前处理器的中断使能状态保存到 flags 变量中,然后关闭本地中断并获取锁。spin_unlock_irqrestore 释放锁后,从 flags 恢复中断状态。
  • 安全性: 这是最安全的变体,适用于所有上下文,包括中断嵌套、软中断(softirq)和 NMI 上下文。
  • 性能: 比 spin_lock_irq 多一次寄存器保存/恢复操作,但避免了因错误开启中断导致的潜在死锁。
常见陷阱: 在中断处理函数中,如果已经处于中断上下文(本地中断默认关闭),此时使用 spin_lock_irq 而非 spin_lock_irqsave,虽然不会立即死锁,但 spin_unlock_irq 会重新开启中断,可能导致中断嵌套失控。始终优先使用 spin_lock_irqsave 是一种安全的编码习惯。

2.3 中断上下文死锁场景:SVG 流程图

下图展示了使用 spin_lock 在进程上下文与中断上下文之间引发死锁的典型路径:

自旋锁死锁场景:spin_lock 在中断上下文中 进程上下文 spin_lock(&lock) 持有锁,进入临界区 硬件中断 中断处理程序 spin_lock(&lock) 锁已被持有,自旋等待 死锁:中断永不返回,进程无法释放锁 解决方案:使用 spin_lock_irqsave 在获取锁前关闭本地中断,防止中断处理程序抢占 spin_lock_irqsave(&lock, flags) → 临界区 → spin_unlock_irqrestore(&lock, flags)

2.4 API 选择决策树

在实际驱动开发中,可以依据以下决策树快速选择正确的API:

  1. 共享数据是否会被中断上下文访问?
    • 否 → 使用 spin_lock / spin_unlock(最低开销)
    • 是 → 进入第2步
  2. 调用者是否明确知道当前中断处于开启状态?
    • 是,且无嵌套中断风险 → 使用 spin_lock_irq / spin_unlock_irq
    • 否,或不确定,或存在中断嵌套 → 使用 spin_lock_irqsave / spin_unlock_irqrestore
  3. 临界区是否包含可能睡眠的操作?
    • 是 → 不能使用自旋锁,应改用互斥锁(mutex)或信号量
    • 否 → 继续使用自旋锁

2.5 三组API的指令流对比:SVG 时序图

下图从CPU指令序列的角度,对比三组API在获取/释放锁时的行为差异:

三组自旋锁API的指令流对比 spin_lock / spin_unlock spin_lock_irq / spin_unlock_irq spin_lock_irqsave / spin_unlock_irqrestore 获取锁 原子 TAS/LLSC 操作 local_irq_disable() + 原子 TAS/LLSC 操作 保存 flags = 中断状态 + local_irq_disable() + TAS 临界区 中断可能抢占 本地中断已关闭,安全 本地中断已关闭,安全 释放锁 原子清除锁标志 原子清除锁标志 + local_irq_enable() 原子清除锁标志 + local_irq_restore(flags) 关键区别 spin_unlock_irq 无条件开启中断,可能破坏调用者状态; spin_unlock_irqrestore 恢复原始状态,适用于所有嵌套场景。

2.6 最佳实践与避坑指南

推荐策略: 在驱动开发中,除非你100%确定中断状态且无嵌套风险,否则始终使用 spin_lock_irqsave / spin_unlock_irqrestore。这多出的几条指令开销在绝大多数场景下可以忽略,但能避免一类极其隐蔽的死锁 bug。
  • 禁止在持有自旋锁时睡眠: 包括 msleep()、copy_from_user()、kmalloc(GFP_KERNEL) 等可能导致调度的操作。这会导致系统崩溃或死锁。
  • 临界区应极短: 自旋锁的忙等特性决定了临界区应控制在几十条指令以内。超过100微秒的临界区应考虑其他同步机制。
  • 注意 flags 变量的作用域: flags 必须是局部变量,不能是全局或静态变量,否则在中断嵌套场景下会被覆盖。
  • 对称使用: 确保 spin_lock_irqsave 和 spin_unlock_irqrestore 使用同一个 flags 变量,且成对出现。