1. Linux内核并发与竞争:自旋锁、信号量与互斥体的工程实践

在嵌入式Linux驱动开发中,“并发”与“竞争”并非抽象概念,而是直接影响系统稳定性与实时性的核心工程问题。当多个执行上下文(如进程、线程、中断服务程序)试图同时访问同一块共享资源(例如寄存器映射区、全局数据结构、硬件设备状态寄存器)时,若缺乏同步机制,极大概率导致数据错乱、设备行为异常甚至内核崩溃。本节将从工程实现角度,系统剖析Linux内核提供的三类关键同步原语:自旋锁(spinlock)、信号量(semaphore)与互斥体(mutex),明确其设计目标、适用边界、使用陷阱及真实驱动中的选型逻辑。

1.1 并发场景的本质:为什么原子操作不够用?

初学者常误以为“原子操作”足以解决所有并发问题。原子操作(atomic_t、atomic_bit)确实在单条指令层面保证了读-改-写(如 atomic_inc() 、 test_and_set_bit() )的不可分割性,适用于对整型计数器或单个比特位的简单操作。然而, 真实驱动中的临界区远非单指令可覆盖 。

以一个典型的字符设备驱动为例,其设备结构体 struct mycdev 可能包含:

struct mycdev {
    struct list_head queue;      // 等待队列链表头
    unsigned int data_ready;     // 数据就绪标志
    char *buffer;                // 指向DMA缓冲区的指针
    size_t buf_len;              // 缓冲区长度
    spinlock_t lock;             // 保护上述字段的自旋锁
};

当用户空间调用 read() 系统调用时,内核需执行一连串不可分割的操作:
1. 检查 data_ready 是否为1;
2. 若为1,则从 buffer 中拷贝数据到用户空间;
3. 将 data_ready 置0;
4. 从 queue 链表中移除该请求节点;
5. 更新 buf_len 等状态。

这一系列操作跨越多条指令,涉及内存读写、链表遍历、条件判断。若此时另一个CPU核心上的中断服务程序(ISR)恰好触发,也试图修改 data_ready 并操作 queue ,而无同步保护,便会出现经典的“竞态条件”(Race Condition): read() 可能读取到部分更新的数据,链表指针被破坏,最终导致内核Oops。

关键结论 :原子操作仅保障单个变量的单次操作原子性;而临界区是 一段逻辑上必须整体执行、不可被其他上下文穿插的代码序列 。保护临界区,需要更高层次的同步机制——锁。

1.2 自旋锁(spinlock):轻量级、短时、SMP安全的CPU级锁

1.2.1 设计哲学与核心特性

自旋锁的设计初衷非常明确: 为极短时间(微秒级)的临界区提供低开销、无睡眠的同步 。其名称“spin”(旋转)形象地描述了其等待行为——当一个执行流(task)尝试获取已被持有的自旋锁时,它不会让出CPU,而是进入一个紧密的循环(busy-wait),持续检查锁的状态,直至锁被释放。

这种设计在对延迟极度敏感的场景下具有不可替代的优势:
- 中断上下文安全 :中断服务程序(ISR)绝对禁止睡眠,自旋锁是其唯一可用的同步原语。
- SMP(对称多处理)安全 :在多核SoC(如ARM Cortex-A7/A53双核/四核)上,自旋锁通过底层汇编指令(如ARM的 LDREX/STREX 或x86的 LOCK 前缀)确保跨CPU核心的原子性。
- 零调度开销 :避免了进程切换(context switch)带来的TLB刷新、寄存器保存/恢复等数十微秒级开销。

1.2.2 API详解与工程化使用

自旋锁的核心数据结构为 spinlock_t ,定义于 <linux/spinlock.h> 。其使用遵循“声明→初始化→加锁→临界区→解锁”的严格流程:

#include <linux/spinlock.h>

// 1. 声明与初始化(两种等效方式)
static spinlock_t my_lock;
// 方式一:声明后显式初始化
spin_lock_init(&my_lock);

// 方式二:声明并初始化(推荐,更简洁)
static DEFINE_SPINLOCK(my_lock);

加锁与解锁API :
- spin_lock(&my_lock) :获取锁。若锁已被占用,则CPU在此处“自旋”,直到获得锁。 此函数会自动禁用本地CPU的内核抢占(preemption) ,防止当前持有锁的task被更高优先级task抢占,从而避免死锁。
- spin_unlock(&my_lock) :释放锁。释放后,会自动恢复本地CPU的抢占状态。

中断安全变体(重中之重) :
当临界区可能被中断服务程序(ISR)和普通进程(task)共同访问时,必须考虑中断打断问题。若进程A持有锁后被中断,而中断服务程序B也试图获取同一把锁,则B将无限自旋,因为A被中断打断后无法继续执行至 spin_unlock() 。解决方案是 在获取锁的同时关闭本地CPU的中断 :

unsigned long flags;
// 获取锁并禁用本地中断(保存中断状态到flags)
spin_lock_irqsave(&my_lock, flags);

// ... 临界区代码 ...

// 解锁并恢复之前的中断状态
spin_unlock_irqrestore(&my_lock, flags);

spin_lock_irqsave() 是驱动开发中最常用的自旋锁API。它比 spin_lock_irq() 更安全,因为后者直接关闭中断,不保存状态,若在已关中断的上下文中再次调用,可能导致中断被意外开启。 flags 变量精确记录了加锁前的中断使能状态, _irqrestore 确保状态被精准还原。

1.2.3 致命陷阱:自旋锁的“禁区”

自旋锁的高效是以严格约束为代价的。违反以下任一规则,都将引发灾难性后果:

  1. 临界区内严禁任何可能导致睡眠的操作 :

    • 绝对禁止调用 msleep() , wait_event_interruptible() , kmalloc(GFP_KERNEL) 等会阻塞的函数。
    • 原因:自旋锁持有期间已禁用抢占。若task A在持锁状态下睡眠,CPU将被调度给其他task B。B尝试获取同一把锁时,将陷入无限自旋(因为A永远无法醒来释放锁),即 死锁(Deadlock) 。
  2. 临界区必须足够短 :

    • 工程经验法则:临界区执行时间应远小于一次进程切换的开销(通常<100μs)。若临界区涉及复杂计算、大量内存拷贝或等待外部硬件响应,应果断弃用自旋锁。
  3. 禁止递归获取 :

    • 同一task不能对同一把自旋锁重复调用 spin_lock() 。这会导致task在第二次加锁时自旋等待自己,形成死锁。Linux内核自旋锁本身不提供递归支持(与某些RTOS的递归互斥量不同)。
  4. 锁粒度需精细 :

    • 避免用一把大锁保护整个设备驱动的所有数据。应按数据访问模式拆分,例如为 queue 链表单独一把锁,为 data_ready 标志单独一把锁,以提高并发度。

1.3 信号量(semaphore):面向长时间临界区的睡眠式同步

当临界区的执行时间较长(毫秒级及以上),自旋锁的“忙等”策略变得极其低效——CPU空转消耗算力,却无实际工作产出。此时,信号量(semaphore)成为更优选择。其核心思想是: 让等待的task主动进入休眠状态,将CPU让渡给其他可运行的task,待资源可用时再被唤醒 。

1.3.1 信号量的类型与语义

Linux信号量分为两类,由 struct semaphore 表示:

  • 计数信号量(Counting Semaphore) :其值( count 字段)可为任意非负整数。 down() 操作使 count 减1;若减后 count < 0 ,则调用者进入不可中断休眠(D状态); up() 操作使 count 加1,并唤醒一个等待者。初始 count=10 ,意味着最多允许10个task同时进入临界区。

  • 二值信号量(Binary Semaphore) :是计数信号量的特例,初始 count=1 。其行为与互斥体(mutex)高度相似,但语义上更偏向于“资源计数”,而非严格的“互斥访问”。

1.3.2 API详解与典型用法

信号量的使用流程与自旋锁类似,但增加了初始化参数:

#include <linux/semaphore.h>

// 1. 声明与初始化(count=1,即二值信号量)
static struct semaphore my_sem;
sema_init(&my_sem, 1); // 初始化为1

// 或使用宏(更常用)
static DEFINE_SEMAPHORE(my_sem); // 等价于 sema_init(&my_sem, 1)

核心API :
- down(&my_sem) :申请信号量。若 count > 0 ,则 count-- 并立即返回;否则,当前task进入 不可中断休眠 ,直至被唤醒。
- up(&my_sem) :释放信号量。 count++ ,并唤醒一个在 down() 上等待的task。
- down_interruptible(&my_sem) :可中断版本。若task在等待时收到信号(如 SIGKILL ),则 down_interruptible() 返回 -ERESTARTSYS ,允许上层处理信号。这是用户空间系统调用中更安全的选择。
- down_trylock(&my_sem) :非阻塞尝试获取。成功返回0,失败返回非零值(通常为1),不休眠。

典型驱动场景示例 :

static ssize_t mycdev_read(struct file *filp, char __user *buf,
                          size_t count, loff_t *f_pos) {
    struct mycdev *dev = filp->private_data;

    // 1. 尝试获取信号量(可中断,以便用户能Ctrl+C退出)
    if (down_interruptible(&dev->sem))
        return -ERESTARTSYS;

    // 2. 临界区:安全访问共享数据
    if (!dev->data_ready) {
        up(&dev->sem); // 必须先释放,否则死锁
        return -EAGAIN; // 或使用 wait_event_interruptible()
    }
    copy_to_user(buf, dev->buffer, count);
    dev->data_ready = 0;

    // 3. 释放信号量
    up(&dev->sem);
    return count;
}
1.3.3 信号量的关键约束与选型考量

信号量虽强大,但有其固有局限:

  • 中断上下文禁用 : down() / up() 函数内部会进行进程调度( schedule() ),而中断上下文(ISR)绝对禁止调度。因此, 信号量绝不能在中断服务程序中使用 。若共享资源需被ISR和task共同访问,必须回退到自旋锁( spin_lock_irqsave )。

  • 休眠开销权衡 :每次 down() 休眠和 up() 唤醒都涉及一次完整的进程切换,开销约为数十微秒。若临界区平均执行时间远小于此(例如<50μs),使用信号量反而比自旋锁更慢。此时应重新审视临界区设计,或考虑其他方案。

  • 所有权非强制 :信号量没有严格的“持有者”概念。理论上,task A获取信号量后,task B可以调用 up() 将其释放。这在某些IPC场景有用,但在驱动中易导致逻辑混乱,应避免。

1.4 互斥体(mutex):专为互斥访问优化的现代同步原语

随着Linux内核发展, mutex (互斥体)作为 semaphore 的现代化替代品被引入,专为解决“一次仅允许一个task访问临界区”这一最常见需求而设计。它在语义清晰性、调试能力与性能上均优于二值信号量。

1.4.1 mutex的核心优势
  • 严格的互斥语义 : mutex 明确表示“互斥”(mutual exclusion),其API( mutex_lock() / mutex_unlock() )比 down() / up() 更具可读性。
  • 所有权跟踪 :内核可精确追踪哪个task持有 mutex 。若发生死锁(如task A持有mutex后又试图获取),内核能通过 CONFIG_DEBUG_MUTEXES 配置项捕获并打印详细调用栈,极大简化调试。
  • 优化的休眠路径 : mutex 的实现针对单任务持有场景进行了深度优化,在大多数情况下,其休眠/唤醒开销略低于 semaphore 。
  • 支持中断休眠 : mutex_lock_interruptible() 提供了标准的可中断获取接口。
1.4.2 API与最佳实践

mutex 的使用最为直观:

#include <linux/mutex.h>

// 1. 声明与初始化
static struct mutex my_mutex;
mutex_init(&my_mutex);

// 或使用宏(强烈推荐)
static DEFINE_MUTEX(my_mutex);

核心API :
- mutex_lock(&my_mutex) :获取互斥体。若已被占用,则当前task休眠等待。
- mutex_unlock(&my_mutex) :释放互斥体。 必须由持有者调用 ,否则内核会发出警告( WARNING: bad unlock balance detected! )。
- mutex_lock_interruptible(&my_mutex) :可中断版本,用于可能被信号中断的上下文(如 read() / write() )。
- mutex_trylock(&my_mutex) :非阻塞尝试获取。

工程最佳实践 :
- 首选 mutex :对于纯粹的“一次一访问”场景, mutex 是Linux内核官方推荐的首选。绝大多数新驱动代码都应使用 mutex 而非二值 semaphore 。
- 禁止在中断中使用 :与 semaphore 相同, mutex 依赖进程调度,故 严禁在ISR中调用 mutex_lock() 。
- 临界区内可睡眠 : mutex 保护的临界区允许调用 msleep() 、 wait_event() 等睡眠函数,这是其区别于 spinlock 的关键优势。
- 递归锁定是错误 : mutex 不支持递归锁定。同一task重复调用 mutex_lock() 会导致死锁。若逻辑上需要递归,应重构代码,或使用 semaphore (但需谨慎评估必要性)。

1.5 同步原语选型决策树:工程师的实战指南

面对一个具体的共享资源,如何在 spinlock 、 semaphore 、 mutex 间做出正确选择?以下是基于多年嵌入式Linux驱动开发经验总结的决策流程:

决策节点 选项 行动
Q1:该资源是否会被中断服务程序(ISR)访问? 是 → 必须使用 spinlock (具体为 spin_lock_irqsave / spin_unlock_irqrestore )。信号量与mutex在中断中非法。
否 → 进入Q2
Q2:临界区的预期执行时间是多少? 极短(< 50μs),且仅涉及简单内存操作 → 首选 spinlock 。避免调度开销,保证实时性。
较长(> 100μs),或涉及I/O、DMA、复杂计算、等待硬件就绪 → 进入Q3
Q3:该临界区是否要求严格的“一次一访问”语义? 是(99%的驱动场景) → 首选 mutex 。语义清晰、调试友好、性能优异。
否(需允许多个task并发访问,如资源池计数) → 使用 semaphore ,并设置合适的初始 count 值。

一个反直觉但至关重要的经验 :即使你的SoC是单核(如ARM Cortex-A7单核), 也应默认按SMP环境编写驱动 。原因有二:
1. 可移植性 :今日的单核驱动,明日可能被部署到双核平台上。 spinlock 在单核上同样有效(其禁用抢占的特性在单核上等价于禁用抢占),而 mutex / semaphore 在SMP上也完全可用。
2. 内核一致性 :Linux内核的 sched 子系统、 mm 子系统等核心组件均按SMP模型设计。遵循SMP规则编写驱动,能最大程度避免与内核其他部分产生隐晦冲突。

1.6 实际驱动中的混合使用模式

在复杂的设备驱动中,单一同步原语往往不足以覆盖所有场景。工程师需根据数据访问模式,组合使用多种原语。一个典型的混合模式如下:

struct my_complex_dev {
    // 1. 高频、超短临界区:仅保护几个标志位或计数器
    atomic_t irq_count;       // 原子操作即可
    spinlock_t reg_lock;      // 保护对硬件寄存器的读-改-写操作

    // 2. 中等长度临界区:保护链表、缓冲区状态
    struct mutex data_mutex;  // 保护queue, buffer, data_ready等

    // 3. 长时间操作:等待DMA完成、等待硬件事件
    struct completion dma_done; // 使用completion而非mutex,更轻量
};
  • 对 reg_lock 的使用:在 mycdev_irq_handler() 中,仅用几条指令读取状态寄存器、清除中断标志、更新 irq_count ,全程使用 spin_lock_irqsave(&dev->reg_lock, flags) 。
  • 对 data_mutex 的使用:在 mycdev_read() 中,执行完整的数据拷贝、链表操作、状态更新,使用 mutex_lock(&dev->data_mutex) 。
  • 对 completion 的使用:在启动DMA传输后,调用 wait_for_completion(&dev->dma_done) 等待中断通知,比用 mutex + wait_event 更高效。

这种分层、精细化的同步策略,是构建高性能、高可靠性嵌入式Linux驱动的基石。它要求工程师深入理解每种原语的底层机制与开销,而非机械套用模板。

我在实际项目中曾遇到一个案例:一款工业相机驱动,其图像采集线程需频繁更新帧缓冲区索引。最初使用 mutex 保护索引,但在高帧率(60fps)下, mutex_lock() / unlock() 的开销累积,导致采集线程偶尔错过DMA完成中断。将索引保护改为 atomic_t 操作后,问题彻底消失。这印证了一个朴素真理: 最简单的工具,往往是解决特定问题最有效的工具。工程师的价值,正在于为每个问题精准匹配最恰当的工具。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐