最近看到了这样一句话:如果操作 GPIO 可能导致休眠,那么同步机制绝不能采用 spinlock。要想彻底领悟这句话的含义,我们要将它拆分成三个部分,分别是 spinlock 的本质,休眠的本质以及 GPIO 操作为什么会导致休眠。最后我们再将三者结合,分析出如果违背了这句话,最终会导致怎么样的后果。
1. spinlock的底层逻辑
spinlock 也就是 自旋锁 是为了多核处理器环境设计的一种 极轻量级、极短时间 的同步机制。
自旋锁有以下几个特点:
- 当线程 A 获取了某个自旋锁,线程 B 再去尝试获取同一个自旋锁时,线程 B 不会去休眠,而是占用 CPU 执行循环,也就是原地打转,疯狂检查这个自旋锁有没有被释放。
- 在 Linux 内核中,一旦一个线程获取了自旋锁,内核就会关闭当前 CPU 的抢占功能。这也就是说,只要这个线程还持有这个自旋锁,操作系统的调度器就不能把这个线程从 CPU 上踢走,换成别的线程。除非发生了更高优先级的中断,但是这也取决于自旋锁的变体,如果使用的是
spin_lock_irqsave,连中断也会被关闭。 - 被自旋锁保护的代码区域称为 原子上下文,也就是说代码必须像原子一样不可分割,绝不能出现任何可能让出 CPU 控制权的操作。
通过上面几个自旋锁的特点,我们基本能够看出来:如果一个线程拿到了自旋锁,那么基本上可以等同于它霸占了当前 CPU 的绝对使用权,站在系统管理层面来看,这就意味着该线程必须尽快执行完毕并释放自旋锁。
2. 休眠的底层逻辑
在操作系统中,休眠通常意味着 线程主动或者被动的交出 CPU 的使用权。
当一个线程调用了可能导致休眠的函数,它实际上是在告诉操作系统的调度器,自己现在已经无法使用 CPU 了,然后调度器就会把它放到 等待队列 中,换成别的已经 就绪 线程继续使用 CPU。
可以说,休眠的本质就是触发系统调度,从而把 CPU 让给别的线程。
3. 为什么操作GPIO可能导致休眠
有不少人对 CPU 的印象还停留在下面的场景:他们一般认为,控制 GPIO 不就是往寄存器里写个 0 或 1 吗?这样速度是极快的,怎么会导致休眠呢?
在 SoC 芯片 内部原生 的 GPIO 上确实如此,这种操作的确是不会导致休眠的。
但是,现代硬件系统中往往存在着 GPIO 扩展芯片。
比如,主控芯片的 GPIO 不够用了,硬件工程师加了一片 I2C 接口或 SPI 接口的 GPIO 扩展芯片。
当你通过代码去设置这种 GPIO 扩展芯片上的 GPIO 引脚时,问题就出现了:
- 为了控制扩展芯片上的引脚,CPU 必须通过 I2C 或者 SPI 总线向扩展芯片发送控制命令。
- 但是 I2C 和 SPI 总线的通信速度相对于 CPU 来说是极其缓慢的。
- 为了不浪费 CPU 资源,I2C 或者 SPI 控制器的底层驱动会在发起总线传输之后,让当前线程休眠,等待硬件中断的唤醒。
所以,如果你操作的 GPIO 是通过低速总线扩展出来的,底层驱动必然会调用导致休眠的函数。
4. 违反规则的后果
现在,我们把上面的逻辑拼接起来,看看如果你用 spinlock 保护一段可能休眠的 GPIO 操作,会发生什么样的后果。
假设我们有两个线程:线程 A 和线程 B。系统有两个 CPU 核:CPU 0 和 CPU 1。线程 A 运行在 CPU 0,线程 B 运行在 CPU 1。
下面开始分析:
- 线程 A 调用
spin_lock获取了自旋锁,CPU 0 的抢占被关闭,调度器在 CPU 0 上失效。 - 线程 A 开始操作一个 I2C 总线上的 GPIO。
- 线程 A 在等待 I2C 总线传输时,底层驱动让它休眠了。此时矛盾爆发了:一来线程 A 持自旋锁告诉内核关闭了 CPU 0 的抢占,二来又主动调用调度器要求休眠,切换成别的线程。在未开启内核调试检查的情况下,调度器可能被迫强行切换上下文,把 CPU 0 让给了其他线程,但此时,自旋锁依然被线程 A 持有着。
- 线程 B 此时也要操作这个设备,于是调用
spin_lock尝试获取同一把自旋锁。 - 这时,线程 B 发现锁被占用了,于是开始在 CPU 1 上自旋,等待锁被释放。
- 重点来了:持有锁的线程 A 在休眠,他要等 I2C 中断到来,并且调度器重新让它使用 CPU 时,他才能释放锁。如果在这个时间段中恰好触发了一个复杂的调度情况,系统的整体状态将陷入僵死。即便线程 A 能够醒来,这种 带着自旋锁休眠 的行为也会导致其他等待该锁的 CPU 核,比如运行线程 B 的 CPU1,浪费大量的时钟周期,完全违背了自旋锁设计的初衷。
因为这种错误极其致命,Linux 内核设计了严格的防范机制,如果你在编译内核时开启了 CONFIG_DEBUG_SPINLOCK_SLEEP 选项,内核会在每次发生休眠时 检查当前线程是否持有自旋锁。
如果发现持有自旋锁,内核会打印出崩溃信息:BUG: scheduling while atomic (错误:在原子上下文中进行了调度) 。
随后,系统通常会直接崩溃,必须修复代码才能重新运行。
5. 正确处理方法
既然不能使用 spinlock ,那到底该怎么做呢?
如果你的代码在 进程上下文 中运行,并且需要保护一段 可能休眠 的代码,应该使用 mutex。当线程 B 尝试获取已被线程 A 持有的 mutex 时,线程 B 不会自旋,而是会 主动去休眠等待。这样不仅不浪费 CPU,而且完全符合 允许休眠 的逻辑。
此外,为了提醒开发者,Linux 内核特意提供了两套 GPIO 操作接口:
gpio_set_value:用于操作原生的、绝对不会休眠 的 GPIO。如果你传入了一个会休眠的扩展 GPIO,内核会报警告。gpiod_set_value_cansleep:明确告诉内核或开发者这个 GPIO 可能导致休眠。看到这个函数,任何有经验的内核开发者都会立刻明白:外层绝对不能用 spinlock,必须用 mutex。
如果是在中断处理函数中怎么办? 要知道中断上下文中是 绝对不允许休眠 的,更不用说加 mutex 了。如果你必须在中断里操作 I2C GPIO,必须使用 Threaded IRQ(中断线程化) 或 Workqueue(工作队列) ,将这部分可能休眠的操作放到进程上下文中去执行。