p37 ~ p40(约 136 分钟)
第 14 个程序的毛病
红外遥控的中断函数解析出按键值之后,直接把按键翻译成「游戏左移/右移」再写队列——硬件驱动里掺进了游戏业务。以后不做游戏机、改成电视机遥控,这段代码就不能用了,得重写。
对比一下旋转编码器的驱动:它只把「硬件相关的数据」写进自己的队列,由上层任务决定怎么用,这才纯粹。
但「一个硬件一个任务」也有代价:每加一个设备(比如 MPU6050)就多一个任务、多一份任务栈,内存浪费很大。
一个任务读多个队列
改进后的框架是这样:底层三个硬件驱动各自在中断里把硬件数据写进自己的队列(队列 1/2/3,跟业务无关),上面只留一个 InputTask,负责读多个队列、解析、转成业务数据(游戏控制)、再写挡球板队列。以后加硬件,只需要在这一层加一个判断分支。
一个任务读多个队列有两种办法。
轮询:依次 xQueueReceive 队列 1、队列 2、队列 3。读队列 1 的时候超时只能设 0——一旦设长超时(比如 30 分钟),队列 2、3 就会被推迟处理。结果就是任务永远不阻塞、空转烧 CPU。理论上能及时读到三个队列,效果不好。
队列集:队列集本身也是个队列,只不过里面装的是队列句柄。若 A 队列深 2、B 队列深 3,队列集大小就是 2 + 3 = 5(最坏情况两个队列都写满,集里就有 5 个句柄)。用起来四步:
① 创建队列 A、B② 创建队列集 S③ 把 A、B 加入 S xQueueAddToSet④ 任务里读 S内部机制值得记一下:往队列 A 写数据的时候,写队列函数会判断 A 是不是属于某个队列集,属于就顺便把 A 的句柄也写进队列集。所以 InputTask 读队列集就能立刻拿到「有数据的那个队列的句柄」,再拿句柄去读该队列拿到真正的数据——读两次,先读句柄,再读数据。不管有多少个队列,InputTask 只读队列集就够了。
[待确认] 这一集字幕里的「169」「平板队列」识别不清。
把框架改干净
第 15 个程序,复制第 14 个改。
驱动层要改三处:
红外驱动不再把键值翻译成游戏键,原原本本上报硬件数据 原来的 input_data 改名成红外数据结构体,放到红外驱动的头文件里 键值用 IR_KEY_POWER 这样的宏代替裸数字 中断里得到重复码就上报重复码,解析出数据就上报 data0 / Value 等原始值, 不再记录「上一次的值」——那是上层的事
在红外驱动的初始化函数里创建自己的队列 static,长度用宏 IR_Q_LEN = 10,元素大小就是结构体大小 用静态创建 xQueueCreateStatic,buffer 一并搬过来
驱动不暴露 static 变量,改成提供 Get 函数返回句柄 game 里定义全局变量 xq_ir、xq_encoder,调这些函数取得句柄旋转编码器同理:把原来放在游戏文件里的结构体挪回编码器头文件,队列改在编码器驱动里创建。
上层(game 文件)做四件事:
创建队列集 xQueueCreateSet(长度) 长度必须 ≥ N + M(两个队列都写满时的句柄总数) = IR_Q_LEN + 编码器长度 = 20
创建输入任务 InputTask
把两个队列 xQueueAddToSet 加入队列集
InputTask 循环: xQueueSelectFromSet(超时用 portMAX_DELAY,因为没数据就没法处理) → 得到句柄 → 等于红外句柄就调处理红外函数,否则如果等于编码器句柄就调处理编码器函数 → 处理函数把硬件数据转成游戏控制数据,写挡球板队列 → 处理函数里 xQueueReceive 超时用 0(「肯定会读到」)红外处理里按键等于左就是 left、等于右就是 right,其他无效;重复按键沿用上一次的值(用 static 记)。编码器处理按速度决定 for 循环上报多少个数据。
编译和调试踩了四个坑:
队列集函数未定义 → 要配 configUSE_QUEUE_SETS = 1 → CubeMX 里没有队列集配置项,直接改 FreeRTOS 源文件在重新生成后会被恢复成 0 → 要放在配置文件的 USER CODE 区加 define
CubeMX 重新生成会恢复被注释掉的默认任务 → 改成 vTaskDelete(NULL) 让它自杀
堆不够,挡球板不显示 → 从默认值调大到 8000(多了一个队列和一个队列集)
遥控器不生效 → 调试发现上报的是原始数据,应该上报转换后的游戏数据现象和第 14 个程序一致,只是框架更漂亮。
加上姿态控制
第 16 个程序,用 MPU6050 加姿态控制:摇晃板子控制挡球板,遥控器和旋转编码器仍然可用。这一集专注 FreeRTOS,MPU6050 怎么取数、怎么算角度不细讲。
MPU6050 的接口是 I2C,读出 XYZ 加速度和角速度之后解析成结构体,这里只解析 X 方向的角度。
先做个实验验证角度基准:只留默认任务,在默认任务的死循环里每 100ms(vTaskDelay)读一次,用 LCD 打印 X 方向的角度。结果是:
水平放置 ≈ 90°左边高 < 90°左边低 > 90°据此就能判断左右倾斜了。
思路分三步:
① MPU6050 没有接中断 I2C 很慢,即使将来有中断,中断里也不该读 I2C 应该只唤醒任务,由任务去读 I2C
② 任务里 while 循环: 读 I2C → 解析 → 写自己的队列(写队列时会自动把句柄写进队列集) → vTaskDelay,延迟 50ms (不能老是读,会影响别的程序)
③ 把这个队列加进队列集;InputTask 里增加「句柄等于 MPU6050 队列」的分支代码细节:驱动注册函数里创建 MPU6050 队列(宏 MPU6050_Q_LEN = 10),并提供函数返回句柄。硬件初始化不放注册函数里,而是放在 FreeRTOS 入口直接初始化——因为注册函数什么时候运行不确定,而 game 任务需要马上拿到队列。
任务里读数据的代码照搬测试代码,只传 X 方向变量,其余传 NULL;解析后用 xQueueSend 写队列(任务里写,超时 0)。
game 里取句柄、加入队列集(队列集长度相应加大)、创建 MPU6050Task、InputTask 增加分支;处理函数读队列得到角度:大于 90° 挡球板左移(value = left),小于 90° 右移,等于 90° 不动。
这一节有两个 bug 值得记。
摇不动。数据源确实在写队列,但没唤醒 InputTask。原因是任务创建顺序:先建 game 任务、紧接着建 MPU6050Task;6050 任务每 50ms 写一次,此时队列还没被加入队列集,很快就写满了;等 game 任务运行到 xQueueAddToSet 的时候队列已经满了,之后再也写不进队列,也就不会再写队列集。修正办法是先让 game 任务把队列加进队列集,再创建 MPU6050Task。
玩久了出现两个挡球板(残影)。MPU6050 和屏幕都走 I2C,没有互斥。临时办法是把屏幕刷新函数用的静态标志变量提为全局,MPU6050 那边用 extern 引用,等于 1 表示别人正在用 I2C 就死循环等待,用完置 0。这里只是用全局变量凑合,并不可靠,讲互斥量之前先这么用着。
赛车游戏:把数据分发给多个任务
第 17 个程序,基于第 16 个改,是个简陋版赛车游戏,也是后面讲信号量、互斥量的基础。
效果是遥控器 1/2/3 分别控制三辆小车右移,移到最右就不再动。
素材来自开发板配套资料:05 程序源码 / 02 FreeRTOS 参考源码 / DShanMCU-F103,解压最后一个程序,取里面 watch 目录的 game2 加进工程,只保留汽车位图和路标位图,其余删掉。
显示上的细节:
汽车位图 car image 每行 15 字节,宽 15、高 16(2 行) 清除一辆汽车用 30 个 0 覆盖,所以 clean_image 取 30、初值 0路标位图 宽 8、高 1(只用一行数据)LCD 分辨率 64×128绘制用 draw bitmap (XY 坐标、位图、宽、高、是否反转、offsetY)车道 路标 Y 坐标 16、33、50(间隔 17) 每行路标画 8 段(128÷8=16 段会首尾相连不好看,只画 8 段) X = 16 × Z,Z < 8先写个测试程序 car test:初始化显存 → 画一辆车(0,0,15×16)→ 画一个路标(X=0, Y=16)→ 死循环。验证之后再画三辆车(X 都从 0 起,Y 累加)。
分发数据是这一节的核心。红外中断解析出键值之后只写一个队列,但有三个 car task。普通队列一个消费者取走就没了,另外两个任务拿不到,所以不能用「一个队列多消费者」。
方案是让驱动层分发,同一个键值写三次队列(三辆车各一条队列)。
半成品的写法是分发函数 dispatch_key 里直接写 car1、car2、car3 三个队列,代码丑陋、和汽车业务强耦合,加一条队列就要改代码。
改进版是提供注册函数 Register:任务把自己的队列句柄传进来,驱动用一个数组(xq[10])记录,配一个 static int count;分发的时候 for 循环遍历 count 逐条写队列。驱动自己也创建一个默认队列并注册进去。
任务那边:先创建自己的队列(长度 10)→ 注册 → 读队列(永久等待)→ 按按键值控制小车。
汽车任务的结构体:
struct car { int x; int y; int key_control_key; /* 队列 */};三辆全局变量 cars,键用 IRK1/IRK2/IRK3,坐标 (0,0)/(0,17)/(0,34)。手工创建三个任务而不用 for 循环,因为以后各任务的优先级要不一样。
移动的逻辑:先隐藏旧车(同位置画 clean_image)→ 调整位置(X 每次加 20,按五六次就能到最右;越界就钳到 128 − 15 = 113)→ 重新显示。条件是位置小于 X 分辨率(128) − 车宽(15) 才移动。
一开始就要把三辆车显示出来,否则按下按键才出现,很别扭。
完成后把 car test 改名 car game,main 里去掉测试代码,改调创建任务的函数。
坑
- 驱动里不要掺业务逻辑,硬件数据原样上报,翻译交给上层任务。
- 轮询读多个队列时超时只能设 0,设长了后面的队列会被推迟。
- 队列集长度要 ≥ 各成员队列长度之和,两个深 10 的队列就要 20。
configUSE_QUEUE_SETS要放在 USER CODE 区,直接改源文件会被 CubeMX 重新生成覆盖。- 队列要先加入队列集,再创建往它写数据的任务,否则队列先写满就再也进不了集合。
- 堆不够的表现是画面不显示,不是报错,多一个队列和队列集就要把堆调大。
请输入编辑凭据,只有站点所有者可以修改文章。