p27 ~ p30(约 47 分钟)
用厕所理解同步和互斥
同一时间只能一个人用厕所,这叫互斥。「我等你用完再用」,这是同步操作。互斥通常是用同步实现的。
换成办公室的场景:同事 A 写完报表,经理 B 才能拿去汇报——B 有依赖,必须放慢脚步等,这是同步。A 已经在用会议室了,B 就算是领导也得等,于是 B 说「你用完提醒我一下」——这就是用同步实现互斥。
代码里的模型是这样:
A 先抢到函数,用厕所B 再调用,发现有人用 → 进入阻塞A 用完 → 唤醒 BB 从断点继续被保护的「厕所」叫临界资源。
第一个缺陷:忙等浪费 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 00000x70 = 0111 00001 点亮,0 熄灭,12 列拼出挡球板球是 0x03 = 0000 0011汽车更复杂一点,0x40、0x02、0x01 这些,一行数据对应 8 行像素,整组数据搬到 OLED 显存就显示出来了。
game1 的主循环是这样跑的:先隐藏球(在球的 XY 处写全零改显存),再通过 I2C 把显存刷到 OLED;移动球之后判断有没有撞砖块(撞了就把砖块位置清零并刷新)、撞墙壁(X 方向加速度反向)、撞挡球板,然后在新的位置画球。
改造目标(第 34 个程序)要接的模块:
红外接收 遥控器左右键控制挡球板无源蜂鸣器 外壳没有白纸的是无源,有白纸的是有源,只有无源才能发游戏音温湿度模块 接它就要拔掉 DS18B20,两者只能同时接一个陀螺仪 姿态控制旋转编码器 左旋/右旋控制挡球板接好之后,晃动板子、遥控器、旋转编码器都能控制挡球板,球撞边会发声。
[待确认] 红外接收头和旋转编码器的具体型号与接线,字幕里没有给出。
坑
- 全局变量做同步,等的一方如果在死循环里轮询,会白占 CPU,把对方拖慢一倍。
- 编译器可能把变量缓存进寄存器,轮询标志位时要加
volatile。 - 「判断 + 设置」两个动作不是原子的,中间随时可能被切换打断。
- 减一那种写法也救不了,因为
--本身就是读、改、写三条汇编。 - 关中断能解决问题,但等待方会反复失败重试,效率很低。真正需要的是阻塞和唤醒。
请输入编辑凭据,只有站点所有者可以修改文章。