返回上一级
2207 字
11 分钟
同步、互斥与通信总览

p27 ~ p30(约 47 分钟)

用厕所理解同步和互斥#

同一时间只能一个人用厕所,这叫互斥。「我等你用完再用」,这是同步操作。互斥通常是用同步实现的。

换成办公室的场景:同事 A 写完报表,经理 B 才能拿去汇报——B 有依赖,必须放慢脚步等,这是同步。A 已经在用会议室了,B 就算是领导也得等,于是 B 说「你用完提醒我一下」——这就是用同步实现互斥。

代码里的模型是这样:

A 先抢到函数,用厕所
B 再调用,发现有人用 → 进入阻塞
A 用完 → 唤醒 B
B 从断点继续

被保护的「厕所」叫临界资源。

第一个缺陷:忙等浪费 CPU#

把第 6 个程序改成第 12 个程序。任务一算一个大数:

int32_t i;
for (i = 0; i < 10000000; i++) {
sum += i;
}
计算结束 = 1;
vTaskDelete(NULL);

算完把全局标志「计算结束」置 1,然后自杀(本来想用 delay,改成了 delete)。任务二用 while (计算结束 == 0); 死等,等到之后打印 sum 和耗时。

现象是任务二一直卡死。单步看汇编,发现它就在两条指令上循环:编译器第一次把变量读进 CPU 寄存器之后,循环里一直判断那个寄存器,读的是旧值,从来没重新读内存。加 volatile 就好了。

数值太大变成负数,改用十六进制(0x 前缀)打印。耗时按纳秒算出来约 2 秒,改成毫秒(除以 10^6)得到 2500ms。

真正的发现在这里:任务 B 的死等本身也在耗 CPU。两个任务同优先级、各跑 1ms 交替,B 大部分时间都在跑那个没意义的死循环。把 B 去掉、或者让它阻塞,A 只需要大约一半时间,1.2 秒。

验证一下:让 B 先 vTaskDelay(3000)(3 秒 = 3000 tick),A 果然约 1.2 秒完成。

所以全局变量做同步有效率问题。正确做法是 A 算完之后唤醒 B,让等待的任务真正阻塞、不参与调度。

第二个缺陷:判断和设置不是原子操作#

第 6 个程序创建三个任务,都调用同一个函数往屏幕上显示信息。屏幕是临界资源,同一时间只能一个任务访问。

原因是这个函数通过 I2C 访问硬件,一次 I2C 操作要发 start 信号、发设备地址、读写数据、发停止信号。不加保护的话:任务 A 刚发完 start 就被切出去,切到 B,B 发了 start 和设备地址之后又被切走,于是 I2C 上出现「两个 start 之后才发设备地址」,时序全乱,数据错乱。

有缺陷的写法是用一个全局变量保护:

static int b_can_user = 1;

假设 A 运行到第 108 行(此时变量 = 1)被切换,还没把变量清零;B 运行也成功通过了判断。B 用 LCD 之前确实把变量清零了,但没用,因为 A 下次是从断点继续的。再假设 B 用到一半被切,轮到 A,A 又清一次变量,A 也能用 LCD——A、B 同时用了 LCD。

这个切换点必须落在「判断变量」和「设置变量」之间,恰好发生 tick 中断的概率极低,所以绝大多数情况不出问题,但程序跑成千上万次就可能踩中。本质是:判断与设置不是原子操作,两个动作之间的时间太长。

文档里有个改进版:一进来先把变量减一。初始值 1,减到 0 表示之前没人用,可以用 LCD;B 再执行发现已经等于 0,减一下变成 1,条件不成立,用不了。

但 b_can_user-- 会被编译成三条汇编:

① 读变量到寄存器
② 寄存器减一
③ 写回内存

A 执行到 ① 之后被切(r0 = 1),B 读到变量 = 1、减到 0 后开始用 LCD,用到一半被切;A 恢复时现场里 r0 还是 1,减一得 0 写回,变量又变成 0,A 的判断也成立——A 也用上 LCD 了。所以减一也杜绝不了,根因是「读—改—写」这个过程可以被打断。

两个解法:

① 关中断
判断 + 修改变量之后再开中断。关中断期间不会发生任务切换。
如果关中断之后发现 LCD 已被占用,就开中断、返回失败。
② 用 FreeRTOS 提供的函数

关中断的不足在于:A 在打印的时候,B 不断尝试、不断失败(每次返回 −1)又变回死等,CPU 利用率很低。期望的效果是 B 用不上 LCD 就阻塞,A 用完再唤醒 B——这要靠后面的信号量和互斥量。

FreeRTOS 提供了什么#

通信本身很简单:第 12 个程序就是 A 把结果放全局变量、B 去读。问题在于 A 写一半没写完 B 就来读,会读到错误的值。

所以「怎么通信」不重要,重要的是保证通信结果正确,这需要互斥;除此之外还要高效率,比如写一个很大的数组时,读方应该阻塞等待。

FreeRTOS 的思路是:先用互斥保证正确性,再提供阻塞和唤醒机制提高效率。四个工具先混个脸熟:

队列 传送带/流水线。生产者往上放产品,消费者从上面取,
先进先出,先生产先取。一个对多个、多个对一个都行。
事件组 一个整数,每一位代表一种事件。生产者把某位置 1 表示事件完成;
消费者可以等某一个、某几个事件,或者「若干事件中任意一个」。多对多。
信号量 里面放的不是商品,是一个计数值。手工作坊做饺子:产出一个 +1,
买走一个 −1。计数值限制成只有 1 或 0,信号量就变成互斥量,
用来保护临界资源。用互斥量会引入优先级反转,
于是又有优先级继承来解决。
任务通知 多对一。多个任务通知某一个任务,可以传一个数字,
也可以只通知「某事件发生了」。

游戏机项目#

游戏来自开源的 STM32 手表项目 Nwatch [待确认],里面有三个游戏,官方源码已经移植到 DShanMCU-F103。

开发板资料的「程序源码/freertos」下面有三个程序:

① 裸机程序,只有一个游戏
② FreeRTOS 程序
③ 完整的 Nwatch(裸机)

完整的那个按中间三角切菜单、左右键移动菜单、遥控器控制挡球板。第二个程序功能简陋、只支持遥控器、没有音效,后面就在它基础上改。

游戏的核心是画位图和刷位图。OLED 是 128×64 点阵,位图数据按「一个字节等于 8 行像素,低位在上、高位在下」组织:

0x60 = 0110 0000
0x70 = 0111 0000
1 点亮,0 熄灭,12 列拼出挡球板
球是 0x03 = 0000 0011

汽车更复杂一点,0x40、0x02、0x01 这些,一行数据对应 8 行像素,整组数据搬到 OLED 显存就显示出来了。

game1 的主循环是这样跑的:先隐藏球(在球的 XY 处写全零改显存),再通过 I2C 把显存刷到 OLED;移动球之后判断有没有撞砖块(撞了就把砖块位置清零并刷新)、撞墙壁(X 方向加速度反向)、撞挡球板,然后在新的位置画球。

改造目标(第 34 个程序)要接的模块:

红外接收 遥控器左右键控制挡球板
无源蜂鸣器 外壳没有白纸的是无源,有白纸的是有源,只有无源才能发游戏音
温湿度模块 接它就要拔掉 DS18B20,两者只能同时接一个
陀螺仪 姿态控制
旋转编码器 左旋/右旋控制挡球板

接好之后,晃动板子、遥控器、旋转编码器都能控制挡球板,球撞边会发声。

[待确认] 红外接收头和旋转编码器的具体型号与接线,字幕里没有给出。

坑#

  • 全局变量做同步,等的一方如果在死循环里轮询,会白占 CPU,把对方拖慢一倍。
  • 编译器可能把变量缓存进寄存器,轮询标志位时要加 volatile。
  • 「判断 + 设置」两个动作不是原子的,中间随时可能被切换打断。
  • 减一那种写法也救不了,因为 -- 本身就是读、改、写三条汇编。
  • 关中断能解决问题,但等待方会反复失败重试,效率很低。真正需要的是阻塞和唤醒。
同步、互斥与通信总览
https://me.622168.xyz/posts/freertos/06-sync-mutex-communication/
作者
Shaw 的小屋
发布于
2026-09-29
许可协议
CC BY-NC-SA 4.0