返回上一级
3740 字
19 分钟
队列、环形缓冲与多设备输入

p31 ~ p36(约 103 分钟)

环形 buffer#

任务 A、B 都是函数,和裸机不一样,可以「同时」执行。传数据有三种方法,先看环形缓冲区。

用全局变量或结构体传数据的毛病:一个变量只传一个数据,结构体也只传一个结构体本身,数量只有 1;没有保护措施,也没有阻塞唤醒,效率低;还可能传错。

传错的例子很典型:结构体 {int x; int y;} 作为全局变量,A 把 X 改成 1、Y 改成 2。如果 A 改完 X 就被切出去,B 拿到的是新 X 配旧 Y,数据就是错的。

环形缓冲区的本质就是一个数组:

int buffer[8];
读位置 R = 0;
写位置 W = 0;

判空很简单:R == W。

写的时候先判未满,buffer[W] = 值,W 累加,溢出到 8 就归 0。读的时候 R != W 表示有数据,value = buffer[R],R 累加,越界归 0。

麻烦的是判满。写位置追上读位置的时候是满,但这和「空」的状态撞了,所以牺牲一个空位——W 指向 7 的时候就不写这一格,认为已经满了,也就是 8 格最多只写 7 个。

next_w = W + 1;
if (next_w == 8) next_w = 0;
判满 next_w == R
未满 next_w != R

写函数里就是「下一个写位置不等于读位置就可以写」。

那为什么不干脆加一个计数变量 int number?可以,但没有保护会出问题。

有缺陷的写法是这样:除了 buffer 和读写位置再加一个 number。写的时候 number != 8 表示未满,填数据、number 累加;读的时候 number != 0 表示有数据,读数据、number 减减。

问题在于 number 是全局变量,生产者只管累加,消费者只管减减,两个人都要改它。number++ 会被拆成三条:

① 读入寄存器
② 寄存器累加
③ 写回内存

假设 number = 10,A 想累加成 11。A 执行到 ① 被切走(r0 = 10),B 读到数据把 number 从 10 减到 9(buffer 里剩 9 个),切回 A,r0 还是 10、累加得 11 写回。本该是 10 → 11 → 10,结果停在 11,个数错了。

结论很明确:不要加这种被双方共改的计数值。改用读写位置判断——生产者只改写位置,消费者只改读位置,没有变量被同时访问,就不会出这个问题。

如果只有两个任务,也不关心阻塞唤醒和效率,可以用这种环形缓冲区。切记别加计数值。

队列的本质#

队列内部读写数据用的就是环形缓冲区,只是在它上面加了保护措施和阻塞唤醒机制。正因为有保护,内部才敢同时用「读位置、写位置、数据个数」三个量。

写队列 把数据拷进去,计数累加
读队列 把数据取出来,计数减一

如果队列只改计数、不传数据,就成了信号量;把计数上限限制为 1,就成了互斥量。三者本质都是队列。

操作队列三步:创建、写、读。阻塞和超时可以用流水线来理解:工人 A 往流水线上放产品,工人 B 从上面取。

流水线上没产品 B 眯一会儿(阻塞),但定了闹钟(超时)
A 放入产品 敲一下流水线把 B 震醒(唤醒)
A 一直不放 闹钟响(tick 中断超时唤醒)

对应到程序里,读队列函数是带 timeout 的。有产品立即返回;没产品就阻塞,比如阻塞 30 分钟,第 15 分钟 A 写队列并唤醒它;如果 A 一直不写,tick 中断不断产生、判断时间到了把它唤醒。写队列也一样,队列可能已经满了(它就是个环形缓冲区),满了可以立即返回错误,也可以阻塞睡一会儿,由取走产品的同事敲流水线(有空位了)或者 tick 超时来唤醒。

队列的本质是三样东西:

① 一个环形 buffer
② send list 想写但队列满而阻塞的发送者
③ receiver list 想读但队列空而阻塞的接收者

A 写队列的时候并不知道该唤醒谁,它只是「敲流水线」,链表里记着 B 呢。

以任务 B 为例看状态流转:B 创建后处于就绪态,挂在优先级 10 的那条就绪链表上。B 运行到读队列,队列没数据而且它愿意等,就把自己从就绪链表移除,放进队列的 receiver list;同时因为指定了超时时间,还被挂到延时链表上。

情况一 A 写队列 → 数据存进环形 buffer → 去队列的链表看,非空就取第一个任务唤醒
(把 B 移出 receiver list,放回就绪链表)→ B 拿到数据继续处理
情况二 B 一直阻塞,没人写 → tick 中断反复判断延时链表里的任务是否超时
→ 超时就把它从链表删除、移入就绪链表
→ B 运行后从函数返回一个错误值(没取到数据,是超时被唤醒的)

知道内部是怎么实现的,用起来心里更有底。

改成队列:红外遥控#

改成第 13 个程序。整个框架里用户任务只有两个:球任务(game1task)和挡球板任务。球任务的主循环每次让球移到下一个位置,判断撞墙、撞砖块、撞挡球板——这部分基本不用改。要改的是挡球板任务。

挡球板任务通过一个函数取红外键值,这个函数读的是一个环形缓冲区(驱动在 driver/103 的红外遥控器部分)。谁往这个 buffer 里写?红外接收中断服务程序,解析出数据之后写进去。

环形 buffer 没有阻塞唤醒,任务只能一直循环尝试读,读不到再读,无阻塞、低效率。

改造思路:把这个函数改成读队列 A。红外中断解析出数据后不再写环形缓冲区,改写队列 A。

为什么还要加旋转编码器?红外遥控器一个按键的最长数据是「引导码 + 数据」,耗时 85ms(接近 100ms),反应快的人会觉得非常迟钝,玩游戏很难受,经常要长按左右键挡球板才快速移动。旋转编码器快得多。

旋转编码器的驱动也是在中断里解析的(判断逆时针还是顺时针)。可以让它也写同一个队列,但这里刻意让它先写队列 B,再由一个「旋转编码器任务」读队列 B、处理之后写队列 A。

为什么要多这么一个任务?简单的事可以在中断里做,复杂的事必须放到任务里——中断里执行太久会卡住其他中断,拖低系统性能。

编码器这块要处理的是:转动产生中断,即使只中断一次,信息量也不一样。驱动在中断里记录了 count(转一下 +1 或 −1)和速度(数值变化的速度),速度才是关键。目标是转得慢就慢慢调整挡球板,转得快即使只中断一次也要让挡球板多移几格。

所以要做两件事:分辨速度;按速度决定往队列 A 写多少个数据——快就多写,慢就少写,数据个数决定挡球板移动速度。程序里每次调整 X 都是加 3 或减 3,值固定;队列里数据多,就能每次循环都取到,移动更快。

把处理放到任务里只是个例子,真正要引出的是「中断里写队列」和「任务里写队列」用的函数不一样。改用队列之后,读不到数据会阻塞,阻塞时其他任务能跑,CPU 效率提高了。

红外改造的实现#

第 13 个程序,用队列传数据。效果是左右两键控制挡球板,插上蜂鸣器还能一边听音乐一边玩。

三步:创建队列、红外中断写队列、挡球板任务读队列(原来是读环形缓冲区)。

创建队列可以用动态分配 xQueueCreate,也可以用静态分配——静态版要提供两个 buffer:环形缓冲区本身加队列结构体。在 game1 里创建,定义全局的 QueueHandle_t。

xQueueCreate(队列元素个数, 每个元素大小)
xQueueCreate(10, sizeof(struct input_data))

元素用结构体,在 game_type.h 里定义,包含 device 和 value 两项,都是 32 位。为精简代码没判返回值。

读用 xQueueReceive,返回 pdPASS 表示读到了。定义一个 struct input_data idata 传地址进去,timeout 用「永远等待」(portMAX_DELAY),一直等到有数据为止。为了不改动后面的代码,取 data = idata.value。

写在红外接收中断回调里,把原来写环形缓冲区改成写队列。队列在 game1 里创建,中断文件里要 extern 声明。xQueueSend 传入数据地址,不等待,队列满就直接返回错误——中断里不允许等待。解析出有用数据的地方:d.device = data[0]、d.value = data[2],然后写队列。

调试过程踩了两个坑:

编译报错「不认识 Q」 → 要包含 queue.h
input_data 未定义 → 它在 game_type.h 里,复制过来并添加头文件路径

烧写后一按键就卡死、单步跑飞。原因是写队列用了 xQueueSend,但这句在中断里,必须改成 xQueueSendFromISR(另一个写队列的地方也要加这个后缀)。改完就能用遥控器控制挡球板了。

最后验证音乐:main 里原先没有启动默认任务,而音乐播放任务是在默认任务里创建的。现在把 music 任务打开,播放果然很流畅,同时还能用遥控器控制,实验目的达到。

旋转编码器#

在第 13 个程序基础上改出第 14 个程序,加入旋转编码器控制,比红外灵敏得多。

结构是:中断写队列 B → 旋转编码器任务读队列 B、处理之后写队列 A。为什么不像红外那样直接写队列 A?因为原始数据要先运算才能变成控制挡球板的动作,这里假设这个转换很耗时(不一定,但为了引出新技术),所以放到任务里做。

队列 B 刻意改用静态创建 xQueueCreateStatic,长度取 10,要提供两个 buffer:

static ... rotary_buf[10]; 存放 10 个结构体的 data buffer
static struct ... ; 队列结构体 buffer

数据结构体 rotary_data 有两项:int count(当前计数,变大还是变小)和 speed(速度)。

写源头在旋转编码器驱动里(仿照红外接收驱动写,要包含三个头文件)。中断回调里用 extern 取到队列,构造 rotary_data(count = 计数值、speed = 速度)写进队列 B。

任务里循环做三件事:读队列 B(无限等待,只有读到才处理)、处理数据、写队列 A。写队列 A 用 xQueueSend、不阻塞,而且应该放在循环里——转得快就写多次,慢就写一两次。

处理逻辑看 rdata.speed 判断方向:负数向左转,正数向右转。小于 0 就把 left 置 1,否则置 0,速度取绝对值。速度分级一开始写的是 if (速度 > 100) count = 3; else count = 1;,又试过 count = 速度 / 10,如果算出来是 0 至少上报一次。然后 for (i = 0; i < count; i++) 往队列 A 写,idata.device 随便写 1,idata.value 按左/右取相应键值(这里暂时借用红外键值,有点别扭,先调通)。

现象是编码器能移动、比红外快,红外也还能用,目的达到。但程序很怪异——处理结果跟红外挂钩,因为队列 A 沿用的还是红外的数据格式。

于是改造队列 A:value 只关心「左/右」,不关心来自红外的哪个键。在 game 里定义宏(value 取 无/left/right),让红外和编码器都转换到这套值,跟红外解耦。

红外中断里 左键 = 0x10,右键 = 0x90
等于 0x10 则 value = left,等于 0x90 则 value = right
编码器任务里 左取 left,否则取 right
挡球板任务 把原来按键函数设置的变量改成直接取 data.value

红外还有重复码:重复码表示左还是右,要看上一次的值。用 static int32_t last_value 记录上次的 value,重复码时上报上一次的按键。

编译时括号和逗号有错,改掉之后遥控器又能用了。这时编码器「太飘忽」,时快时慢不好控制。把 count = 速度 / 10 改成分级限制:

速度 > 100 → count = 4
速度 > 50 → count = 2
否则 → count = 1

不让 count 太大。再烧写,控制很漂亮——想快就快转,想准就慢下来,既快又准。

勘误:旋转编码器不好用#

很多同学反馈旋转编码器不好用,现象是一直想往左滑,它就是不动。这一集是录完全部课程之后补录的,后续源码按它改。

问题出在旋转编码器代码里的延时,它根本消除不了抖动。用波形看:如果跳变时间小于 2ms,后面确实会稳定;但如果这段跳变时间大于 2ms,你只是在那里等,等够 2ms 之后,后面那些抖动跳变的波形仍然会被当成有效脉冲。

改法是不用延时,改用时间差判抖:

当前中断时间 − 上次中断时间 < 2ms → 判为抖动,直接返回不处理
≥ 2ms → 往下处理
同时把当前中断时间记录下来,更新上次中断时间戳

2ms = 2000 微秒 = 2 000 000 纳秒。

第二处:算速度的时候要做除法,如果时间足够大,除出来的值可能是 0。结果为 0 时给一个较小的速度值(比如 1),避免出现 0 速度。

改进就这两点:防抖,以及速度为 0 时给最小值。烧写后滑动顺畅,不再出现怎么滑都不动。

坑#

  • 环形缓冲区不要加被生产者和消费者共改的计数值,number++ 的读—改—写会被打断。
  • 判满只能靠牺牲一个空位:8 格的 buffer 最多写 7 个。
  • 中断里写队列必须用 xQueueSendFromISR,用 xQueueSend 会卡死跑飞。
  • 中断里不允许等待,队列满就直接返回错误。
  • 耗时的数据转换放任务里做,中断里执行太久会卡住其他中断。
  • 用时间差防抖比用延时靠谱,延时挡不住比延时更长的抖动。
  • 算速度要防除零,结果为 0 时给个最小值。
队列、环形缓冲与多设备输入
https://me.622168.xyz/posts/freertos/07-queue-game-input/
作者
Shaw 的小屋
发布于
2026-09-29
许可协议
CC BY-NC-SA 4.0