4. 并发与竞态控制:自旋锁、互斥体、原子操作、RCU机制
并发,是驱动开发里绕不开的坎儿。说白了,就是多个执行路径同时访问同一份数据。我刚开始做驱动时,总觉得只要代码逻辑对就行,结果有一次在 SMP 板子上跑压力测试,系统随机崩溃,查了三天才发现是全局变量没加锁。嗯,从那以后,我对并发就再也不敢马虎了。
嵌入式 Linux 里的并发源很多:中断上下文、软中断、多核 CPU、进程抢占……任何一个都可能把你的数据撕成碎片。今天我们就来把这四种武器讲透:自旋锁、互斥体、原子操作、RCU。
4.1 自旋锁:轻量级的忙等待
自旋锁,顾名思义——拿不到锁就原地打转。它不会让出 CPU,而是不断循环检查锁状态。你想想看,如果临界区只有几条指令,上下文切换的开销可能比忙等待还大。这时候自旋锁就派上用场了。
我在项目中遇到过一个问题:一个 GPIO 中断服务程序里要更新一个状态寄存器,用了 mutex,结果中断上下文不能睡眠,直接 panic。后来换成自旋锁,问题解决。记住,中断上下文只能用自旋锁。
// 自旋锁基本用法
spinlock_t my_lock;
spin_lock_init(&my_lock);
spin_lock(&my_lock);
// 临界区:极短的操作,比如更新一个标志位
shared_flag = 1;
spin_unlock(&my_lock);
// 中断上下文版本
unsigned long flags;
spin_lock_irqsave(&my_lock, flags);
// 临界区
spin_unlock_irqrestore(&my_lock, flags);
4.2 互斥体:让出 CPU 的绅士
互斥体(mutex)和自旋锁最大的区别:拿不到锁时,它会主动睡眠,让出 CPU。等锁释放了,内核再把等待的进程唤醒。说白了,这是一种「绅士风度」——我不跟你抢 CPU,我先歇着。
什么时候用互斥体?临界区比较长的时候。比如你要操作一个硬件,需要等待几十微秒甚至几毫秒,用自旋锁就是浪费 CPU。我习惯这样选:临界区超过 100 条指令,或者可能调用阻塞函数,就用 mutex。
// 互斥体基本用法
struct mutex my_mutex;
mutex_init(&my_mutex);
mutex_lock(&my_mutex);
// 临界区:可以睡眠,可以调用阻塞函数
// 比如:等待硬件操作完成
hardware_operation();
mutex_unlock(&my_mutex);
| 特性 | 自旋锁 | 互斥体 |
|---|---|---|
| 等待方式 | 忙等待(循环检查) | 睡眠(上下文切换) |
| 临界区长度 | 极短(< 100 条指令) | 可长可短 |
| 中断上下文 | 可用 | 不可用 |
| 持有期间睡眠 | 禁止 | 允许 |
| 性能开销 | 低(无调度) | 较高(有调度) |
4.3 原子操作:硬件级的终极保障
原子操作,是 CPU 指令级别的「不可分割」。比如对一个 32 位整数做加 1,普通代码会被编译成多条指令:读、加、写。多核同时执行时,数据就乱了。原子操作保证这三步一气呵成,中间不会被任何中断或别的核打断。
我经常用原子操作来维护驱动里的引用计数、状态标志。它比锁更轻量,没有上下文切换,没有忙等待。你想想看,如果只是对一个计数器加 1,用自旋锁是不是有点杀鸡用牛刀?
// 原子操作示例
atomic_t ref_count = ATOMIC_INIT(0);
// 原子加 1
atomic_inc(&ref_count);
// 原子减 1 并检查是否为 0
if (atomic_dec_and_test(&ref_count)) {
// 引用计数归零,执行清理
cleanup_resource();
}
// 原子位操作
set_bit(3, &flags);
clear_bit(3, &flags);
test_bit(3, &flags);
4.4 RCU 机制:读多写少场景的利器
RCU(Read-Copy-Update),读-复制-更新。这个名字已经说明了它的核心思想:读操作不加锁,写操作先复制一份,修改完再原子替换指针,最后延迟释放旧数据。
为什么读操作可以不加锁?因为 RCU 保证:读操作看到的指针要么是旧的完整数据,要么是新的完整数据,永远不会看到中间状态。这依赖内存屏障和宽限期(grace period)机制。
我在项目中用 RCU 优化过一个路由表查询驱动。原来的实现用读写锁,每秒 10 万次查询,CPU 占用率 30%。换成 RCU 后,查询路径完全无锁,CPU 占用降到 5%。写操作虽然复杂一点,但路由表更新频率很低,完全能接受。
// RCU 读端(无锁!)
rcu_read_lock();
struct route_entry *entry = rcu_dereference(route_table);
// 使用 entry,此时不会阻塞任何写操作
if (entry)
process_route(entry);
rcu_read_unlock();
// RCU 写端
struct route_entry *new_entry = kmalloc(sizeof(*new_entry), GFP_KERNEL);
// 复制旧数据并修改
*new_entry = *old_entry;
new_entry->nexthop = new_nexthop;
// 原子替换指针
rcu_assign_pointer(route_table, new_entry);
// 等待所有读端完成,然后释放旧数据
synchronize_rcu();
kfree(old_entry);
4.5 如何选择?一张决策流程图
实际开发中,我一般按这个思路选型:
- 先看上下文: 中断上下文?只能用自旋锁或原子操作。
- 再看临界区长度: 只有几条指令?原子操作优先;几十条指令?自旋锁;几百条以上?互斥体。
- 最后看读写比例: 读多写少(99:1)?RCU 是首选。
并发控制没有银弹。每种机制都有它的适用场景,也有它的代价。理解它们的原理和取舍,比记住 API 更重要。嗯,这四种武器用好了,你的驱动就能在多核、多中断的复杂环境下稳稳运行。