2. 临界区与保护机制:临界区概念、保护原则、原子操作与锁的引入
2.1 临界区概念
在并发编程中,临界区(Critical Section)是指访问共享资源的代码片段,该片段在同一时刻只能被一个执行单元(进程、线程或CPU核心)执行,否则会导致数据不一致或系统崩溃。
临界区的本质: 一段代码,而非一段数据。保护的是对共享资源的访问路径,而非资源本身。
考虑一个简单的驱动场景:两个CPU核心同时执行驱动中的 write() 回调,更新同一个设备寄存器映射的内存变量 dev->count。如果没有保护,最终结果取决于两个核心的指令交错顺序,这就是典型的竞态条件(Race Condition)。
2.2 保护原则
临界区保护遵循以下核心原则,这些原则是设计任何锁机制的基础:
| 原则 | 描述 | 违反后果 |
|---|---|---|
| 互斥 | 同一时刻最多只有一个执行单元进入临界区 | 数据竞争、内存损坏 |
| 前进 | 没有执行单元在临界区时,请求进入的单元必须能立即进入 | 活锁、饥饿 |
| 有限等待 | 等待进入临界区的单元不会无限期阻塞 | 死锁、优先级反转 |
| 可重入性 | 同一执行单元可安全地多次进入同一临界区(取决于锁类型) | 自旋锁递归死锁 |
驱动开发黄金法则: 临界区越小越好。只在真正访问共享资源的那几行代码周围加锁,不要在锁内执行耗时的I/O操作或内存分配。
2.3 原子操作:最轻量的保护
原子操作(Atomic Operation)是不可分割的操作,要么全部执行完成,要么完全不执行。在单指令周期内完成对内存的读-改-写,无需加锁。
Linux 内核提供两类原子操作接口:
- 原子整数操作:
atomic_t类型及atomic_inc()、atomic_dec()、atomic_add_return()等 - 原子位操作:
set_bit()、clear_bit()、test_and_set_bit()等
// 使用原子操作保护计数器,无需锁
#include <linux/atomic.h>
struct device_data {
atomic_t usage_count;
void __iomem *reg_base;
};
// 在中断上下文或任意CPU上安全调用
void increment_usage(struct device_data *dev) {
atomic_inc(&dev->usage_count);
}
// 读取当前值
int get_usage(struct device_data *dev) {
return atomic_read(&dev->usage_count);
}
原子操作的局限性: 原子操作只能保护单个整数或单个位。当临界区包含多条指令、复杂数据结构(链表、树)或需要条件判断时,原子操作无能为力,必须使用锁。
2.4 锁的引入:从原子操作到通用保护
当临界区无法用单个原子操作表达时,就需要引入锁(Lock)机制。锁是一种高级抽象,它利用底层的原子操作(如 test_and_set)来实现互斥访问。
Linux 内核提供两大类锁:
| 锁类型 | 等待行为 | 适用上下文 | 典型场景 |
|---|---|---|---|
| 自旋锁(spinlock) | 忙等待(spin) | 中断上下文、SMP | 保护短临界区,不可睡眠 |
| 互斥锁(mutex) | 睡眠等待 | 进程上下文 | 保护长临界区,允许睡眠 |
| 读写锁(rwlock/rwsem) | 读共享/写互斥 | 读多写少场景 | 路由表、配置数据 |
选择锁的核心依据: 临界区是否允许睡眠?如果允许,使用互斥锁;如果不允许(如中断处理程序),必须使用自旋锁。
2.5 临界区保护实战原则
在编写驱动代码时,遵循以下原则可以避免大多数并发问题:
- 识别共享数据:明确哪些变量、队列、描述符被多个执行单元访问
- 划定临界区边界:只包裹真正访问共享数据的代码行
- 选择正确的锁:根据上下文(中断/进程)和临界区长度选择自旋锁或互斥锁
- 保持锁层次一致:多个锁同时使用时,所有路径必须按相同顺序获取
- 避免锁内睡眠:持有自旋锁时绝对不能睡眠,持有互斥锁时避免长时间睡眠
调试提示: 在开发初期,可以使用
lockdep 内核锁依赖检测工具自动发现潜在的死锁路径。在 .config 中启用 CONFIG_PROVE_LOCKING 即可。