FreeRTOS消息队列实验中的按键“失灵”谜案:一次调用引发的后果
写在前面
最近在学习FreeRTOS的消息队列,自己写了一个小实验:创建两个任务,Send_Task负责检测按键(KEY1和KEY2),一旦有按键按下,就通过消息队列发送对应的数值(1或2);Receive_Task则阻塞等待队列消息,收到后通过串口打印出来。
实验运行起来,却发现一个诡异的现象:当我把Send_Task中两个按键检测之间的vTaskDelay(50);注释掉后,KEY2就彻底“罢工”了,无论怎么按都不响应;而一旦加上这50ms的延时,一切又恢复正常。
起初我怀疑是消息队列长度不够导致发送失败,但检查代码后发现队列长度已经设为4,完全够用。经过一番排查,最终把目光锁定在了按键扫描函数Key_GetNum()的实现上。下面我详细还原这个问题从出现到解决的全过程,希望能给遇到类似问题的朋友一些启发。
一、实验环境与代码框架
- 硬件:STM32F103 开发板
- RTOS:FreeRTOS V10.4.1
- 外设:两个独立按键(KEY1 – PB1,KEY2 – PB11),串口1用于打印
- 源代码 https://github.com/wzdffffff/Freerots/tree/queue
1.1 消息队列的创建
#define QUEUE_LEN 4 #define QUEUE_SIZE 4 QueueHandle_t Test_Queue; // 在 AppTaskCreate 中创建 Test_Queue = xQueueCreate((UBaseType_t)QUEUE_LEN, (UBaseType_t)QUEUE_SIZE);1.2 接收任务
static void Receive_Task(void* parameter) { BaseType_t xReturn; uint32_t r_queue; while (1) { xReturn = xQueueReceive(Test_Queue, &r_queue, portMAX_DELAY); if(pdTRUE == xReturn) printf("本次接收到的数据是%d\r\n", r_queue); else printf("数据接收出错,错误代码0x%lx\r\n", xReturn); } }1.3 发送任务(问题版本!!!)
static void Send_Task(void* parameter) { BaseType_t xReturn; uint32_t send_data1 = 1; uint32_t send_data2 = 2; while (1) { if(Key_GetNum() == 1) { printf("发送消息send_data1!\r\n"); xReturn = xQueueSend(Test_Queue, &send_data1, 0); if(pdPASS == xReturn) printf("消息send_data1发送成功!\r\n"); } // vTaskDelay(50); // 注释掉这一行后 KEY2 失效 if(Key_GetNum() == 2) { printf("发送消息send_data2!\r\n"); xReturn = xQueueSend(Test_Queue, &send_data2, 0); if(pdPASS == xReturn) printf("消息send_data2发送成功!\r\n"); } vTaskDelay(20); } }1.4 按键扫描函数(经典阻塞式消抖)
uint8_t Key_GetNum(void) { uint8_t KeyNum = 0; if (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_1) == 0) { vTaskDelay(20); // 延时消抖 while (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_1) == 0); // 等待松手 vTaskDelay(20); KeyNum = 1; } if (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_11) == 0) { vTaskDelay(20); while (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_11) == 0); vTaskDelay(20); KeyNum = 2; } return KeyNum; }二、现象复现与分析
2.1 诡异的现象
有vTaskDelay(50)时:先按KEY1,串口打印“发送消息send_data1!成功”;再按KEY2,同样正常打印“发送消息send_data2!成功”。一切完美。
注释掉vTaskDelay(50)后:KEY1依然工作,但KEY2无论怎么按都毫无反应,串口没有任何关于KEY2的打印。仿佛KEY2被系统遗忘了。
2.2 第一反应:队列满了?
按照常规思维,发送失败最常见的原因就是队列已满。于是我检查了队列长度(4个元素),两个消息才占2个位置,不可能满。而且代码里也没有其他任务往队列里写数据。退一步说,即便队列真的满了,xQueueSend会返回errQUEUE_FULL,但我的代码里只判断了pdPASS并打印成功,没有处理失败情况。不过关键现象是:连printf("发送消息send_data2!\r\n");都没有执行,说明程序根本没有进入if(Key_GetNum()==2)分支。因此问题不在队列,而在按键检测本身。
2.3 深入分析:Key_GetNum()的阻塞特性
观察Key_GetNum()的实现,发现它是一个“阻塞式消抖”函数:
当检测到引脚为低电平时,立即调用
vTaskDelay(20)等待20个tick(约200ms,假设tick=10ms)。然后死循环
while等待按键松开,在此期间任务一直占用CPU(虽然会因vTaskDelay而被挂起,但逻辑上是阻塞的)。最后再次延时20tick后返回键值。
也就是说,一次Key_GetNum()调用从检测到按键按下到函数返回,至少需要经过消抖延时 + 等待松手 + 再次消抖的时间,实际耗时通常大于200ms。在这段漫长的时间内,当前任务(Send_Task)是处于阻塞或运行状态的(等待松手时是运行状态不断读GPIO,浪费CPU)。
2.4 连续调用两次的后果
现在回到Send_Task:
if(Key_GetNum()==1) { ... } // 第一次调用 // 没有延时 if(Key_GetNum()==2) { ... } // 第二次调用假设你按下了KEY1:
第一次调用
Key_GetNum()被触发,由于KEY1引脚为低,进入内部逻辑:延时20tick → 等待KEY1松开 → 再延时20tick → 返回1。此时的你,手指还按在KEY1上吗?大概率是的,因为函数会一直等到你松开才返回。所以在你松开KEY1之前,这个函数不会返回。等你终于松开KEY1,
Key_GetNum()返回1,于是第一个if成立,发送消息1。紧接着,第二个
Key_GetNum()被调用。但此刻KEY1已经松开,KEY2也没有被按下(你还没来得及按),那么Key_GetNum()会从上到下扫描GPIO:PB1为高、PB11为高,两个if都不满足,直接返回0。因此第二个if永远不成立。
如果你试图直接按KEY2(不按KEY1):
第一次
Key_GetNum()首先检查PB1,是高的,不进入第一个if;接着检查PB11,此时你按下了KEY2,PB11为低,于是进入第二个if内部,延时、等待松手、延时后返回2。返回2后,
if(Key_GetNum()==1)比较的是2 == 1吗?显然不成立,所以第一个if不会执行。但奇怪的是,第二个if(Key_GetNum()==2)会被再次调用!因为这是两个独立的调用,第二次调用Key_GetNum()时,KEY2已经被你松开(经过第一次调用的等待松手),所以返回0,依然进不了第二个if。
结论:无论你按哪个键,第二个if(Key_GetNum()==2)永远拿不到有效的键值2。这就是KEY2“失效”的根本原因。
2.5 为什么加上vTaskDelay(50)就正常了?
当我们在两个if之间插入vTaskDelay(50):
if(Key_GetNum()==1) { ... } vTaskDelay(50); if(Key_GetNum()==2) { ... }此时,如果你先按KEY1然后迅速松开,第一次调用返回1并发送消息。然后任务延时50tick。在这50tick内,你可以从容地按下KEY2。等延时结束,第二次调用Key_GetNum()时,KEY2正处于按下状态(或者刚被松开但仍在消抖窗口内),函数会检测到PB11低电平,进入内部逻辑,最终返回2,从而进入第二个if。
但是这里有一个隐含条件:你必须在50tick内完成按键2的按下并松开(实际上只需要按下一瞬间,因为函数会等待松手)。如果按得太慢,延时结束后你还没按,第二次调用依然返回0。所以这个方案并不稳定,并不能真正解决问题。
三、正确的解决方案
明白了问题根源在于连续调用了两次会阻塞的按键检测函数,解决方案自然就清晰了:只调用一次Key_GetNum(),将返回值保存下来,然后分别判断。
3.1 修改后的Send_Task代码
static void Send_Task(void* parameter) { BaseType_t xReturn; uint32_t send_data1 = 1; uint32_t send_data2 = 2; uint8_t key; // 保存按键值的变量 while (1) { key = Key_GetNum(); // 只调用一次,获取当前按键值 if(key == 1) { printf("发送消息send_data1!\r\n"); xReturn = xQueueSend(Test_Queue, &send_data1, 0); if(pdPASS == xReturn) printf("消息send_data1发送成功!\r\n"); } else if(key == 2) { printf("发送消息send_data2!\r\n"); xReturn = xQueueSend(Test_Queue, &send_data2, 0); if(pdPASS == xReturn) printf("消息send_data2发送成功!\r\n"); } vTaskDelay(20); // 周期性延时,避免任务空跑 } }3.2 为什么这样就能解决问题?
现在整个循环只调用一次
Key_GetNum(),该调用会完整地处理一次按键:等待按键按下(如果有)、消抖、等待松手、返回键值。返回的键值被保存在局部变量
key中,后续所有的判断都基于这个值。无论按键是1还是2,都能被正确识别并进入对应的分支。
即使你不加中间的
vTaskDelay(50),KEY2也能正常工作。因为当你按下KEY2时,key得到的就是2,第二个else if会匹配成功。
四、总结与反思
| 问题现象 | 错误原因 | 解决方案 |
|---|---|---|
注释掉vTaskDelay(50)后 KEY2 无响应 | 连续两次调用阻塞式按键检测函数,第二次调用永远拿不到有效键值 | 只调用一次Key_GetNum(),将返回值保存后判断 |
加上vTaskDelay(50)后看似正常,实际不稳定 | 延时给了用户切换按键的时间,但依赖运气 | 同上,从根本上消除问题 |
几点重要启示:
不要在短时间内多次调用带有阻塞或清除状态的函数。尤其是按键读取、传感器数据读取等函数,它们往往只提供“一次性”的结果。
理解
Key_GetNum()的内部实现至关重要。如果它是非破坏性读取(如仅返回当前电平,不等待松手),连续调用不一定出问题;但阻塞式消抖函数天然不适合被连续调用。调试时不要急于归咎于队列、信号量等高级组件。先确认程序是否进入了你想让它进入的分支。最简单的方法就是加打印语句,观察逻辑流程。
在RTOS中,尽量避免长时间阻塞或死循环等待外部事件。使用状态机或事件标志组,让任务能够及时让出CPU。
希望这篇博客能帮助你理解Key_GetNum()调用次数带来的微妙差异,以及如何设计更健壮的按键处理逻辑。如果你也遇到过类似的“幽灵”bug,欢迎在评论区分享你的经历!
