☰
嵌入式Day11:FreeRTOS任务调度实战,LED呼吸灯与按键响应互不干扰
2026/10/17 5:13:15 网站建设 项目流程

第10天晚上,我盯着满屏的串口日志反复调一个程序,真的快要崩溃。明明只是想做一个LED呼吸的同时响应按键切换模式,结果一跑起来,按键的响应完全看运气——有时候按下去没反应,有时候又像连发一样一次触发好几回。按老思路继续在while(1)主循环里“分段轮询”各个模块,代码越写越乱,逻辑全挤在一起,任何一个环节稍微卡顿,整块板子都跟着遭殃。于是到“学习嵌入式Day11”这天,我决定不再绕路:正式把实时操作系统(RTOS)引入开发板,先从理解任务调度开始,把LED、按键、串口拆成三个互不干扰的独立任务。这篇就记录我这一天的完整经过,从裸机代码哪里出了问题、RTOS的核心概念,到实际移植和踩坑修复,全程用可复现的代码说话。

1. 裸机轮询捅出的娄子:一个按键能让整块板子“停摆”

1.1 还原那个让人抓狂的现场

Day10的代码大概是下面这种“非阻塞时间片轮询”,我当时觉得已经比HAL_Delay这种傻等好很多了:

uint32_t lastToggle = 0; uint32_t lastPrint = 0; uint32_t lastKeyScan = 0; while (1) { uint32_t tick = HAL_GetTick(); if (tick - lastToggle >= 500) { HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); lastToggle = tick; } if (tick - lastPrint >= 1000) { printf("[%lu] system alive\r\n", tick); lastPrint = tick; } if (tick - lastKeyScan >= 20) { scan_key_and_handle(); lastKeyScan = tick; } }

表面看,三件事被分成了3个时间判断,互不阻塞。问题在于:模块之间的耦合全靠“上次执行时间”和“差了多少毫秒”这类状态变量来撬动,一旦模块数量从3个变成8个,你会发现自己写的不是业务逻辑,而是一堆时间片管理代码。更麻烦的是,scan_key_and_handle()内部往往是一个带消抖、带边沿检测、甚至带长按短按判断的状态机,稍微复杂一点,这个函数的执行时间就不稳定。只要它在本次轮询里多磨蹭了几毫秒,LED的闪烁和串口的打印节奏全都会被连带推迟。这不是哪一行代码写错了,而是“单核CPU顺序执行 + 手动切分时间片”这种模式的天然瓶颈。

1.2 单核CPU的真相:一次只能跑一条指令

很多人一听“操作系统”、“多任务”,就觉得单片机同时干了好几件事,其实这是错觉。Cortex-M3无论主频多高,单核在同一时刻只能执行一条指令。所谓的“多任务”,本质是CPU把自己按时间切成很多片,飞快地在不同代码片段之间切换,造成“都在跑”的观感。

我在学RTOS时找了一个特别容易理解的生活类比:一个食堂只有一个打饭窗口,厨师一次只能炒一道菜。但他完全可以一边让炖锅在那咕嘟着,一边去切下一道菜的配菜,等炖锅好了再回来调味。这里的“炖锅等待”就是任务进入阻塞状态,厨师去“切配菜”就是切换到了另一个任务。CPU也一样,所谓并发,指的是逻辑上同时推进;并行才是物理上真的多个核心同时跑。单片机上的RTOS,做的就是让厨师高效安排“等待”与“干活”这两件事的调度。

1.3 为什么不是“中断解决一切”

刚学中断的时候,很多人会觉得:既然按键按下会触发外部中断,那我直接在中断里写处理逻辑不就行了吗?确实,中断能解决“及时响应硬件事件”的问题,但它不适合承载复杂的业务链。

几个原因:首先,中断服务函数(ISR)会打断主程序的任意位置,如果在ISR里跑耗时很长的逻辑,等它出来的期间,其他中断会被阻塞,整个系统实时性反而变差。其次,ISR的编写约束很多,不能轻易使用带有阻塞性质的操作,也很难维护一整套业务状态机。最后,中断和RTOS并不是对立关系,而是配合关系——中断负责“第一时间通知”,真正的业务处理放到任务里慢慢做。比如按键沿触发中断时,ISR只置一个标志位,然后由按键任务负责消抖、识别长按短按、更新模式。这样既保证了响应速度,又能把逻辑写得清晰、可维护。

2. 先掰扯清楚:RTOS不是并行,而是更聪明的排队

2.1 调度器按什么规矩分配CPU

FreeRTOS是我选用的一个开源、轻量、资料极多的实时内核。它最核心的组件就是调度器,调度器按一套明确的规则决定“这一刻CPU归谁”:

  • 就绪状态下,数值更大的优先级任务先运行;
  • 优先级相同的任务,按时间片轮流转(configUSE_TIME_SLICING);
  • 高优先级任务可以随时抢占正在运行的低优先级任务;
  • 任务可以主动“阻塞”自己(比如调用vTaskDelay),把CPU让给低优先级任务。

注意FreeRTOS的优先级是“数字越大优先级越高”,这一点和很多国产RTOS正好相反,刚切换过来时很容易搞反。为了记住它,我一直用一句话:xTaskCreate第4个参数越低,代表“越没脾气”的路人甲任务;数字越高,代表“插队特权”任务。

调度器做决策的时机主要有两个:一个是SysTick定时器周期性触发tick中断时,一个是任务主动阻塞或调用taskYIELD()主动让出CPU时。tick中断就好比闹钟,到了设定的时间点,调度器会看看有没有更急的任务需要上场。这也是为什么我后面专门踩了一个SysTick优先级的坑,优先级配错,这个“闹钟”就会瞎响或者干脆不响。

2.2 任务状态的流转:就绪、运行、阻塞、挂起

任务在FreeRTOS里不是只有“运行”和“不运行”两种状态,它会在这几种状态之间流转:

状态含义典型进入方式
运行(Running)正在占用CPU执行代码优先级最高的就绪任务被调度选中
就绪(Ready)能跑,但CPU此刻没轮到自己高优先级任务正在运行,自己排队等待
阻塞(Blocked)等待某事件或延时结束,不占CPU调用vTaskDelay,或等待队列/信号量
挂起(Suspended)被主动暂停,需要外部唤醒调用vTaskSuspend主动挂起

刚学的时候,我总把“阻塞”和“挂起”混在一起。后来想通了:阻塞是任务“自己等水烧开”,时间到了或者外部事件来了就能自动醒;挂起是任务被人为按了暂停键,必须别人调vTaskResume才能恢复。两者都让出了CPU,但有本质区别。

还有一件事特别重要:任务函数必须是一个永远不会正常返回的for(;;)循环。如果任务函数从入口跑到出口,一旦还在运行状态却走到了return,FreeRTOS会因为“任务函数不能返回”这个断言直接卡死。所以写任务函数,第一行开始就要有“无限循环”的概念。

2.3 任务上下文和中断上下文是两套世界

进入Day11之前,我一直以为任务和中断只是“运行的位置不同”,实际并非如此。中断有自己的上下文环境,ISR里执行的是特殊模式下的内核代码路径;任务则运行在普通线程上下文里。FreeRTOS提供的很多API分成两个版本:任务里能用普通版,比如xQueueSend;但中断里不能用,必须用带FromISR后缀的版本,比如xQueueSendFromISR。如果混用,轻则数据错乱,重则触发断言或HardFault。

这个知识点虽然Day11还没有深入用到,但我是严格按照规矩写的:ISR里只做标志位翻转和极短的数据拷贝,所有业务逻辑都放回任务里。很多网上复制的代码在ISR里直接调普通API,看运气能跑通,实际上是在埋雷。

3. 把调度器搬进单片机:移植过程中的两个大坑

3.1 从哪搞到FreeRTOS源码

我第一次移植时,最纠结的就是“源码从哪里来”。这里有三种常见途径:

  1. 用IDE的图形化配置直接生成:很多嵌入式IDE的中间件里集成了FreeRTOS,勾选就可以生成带好配置的工程。这是最省力的方式,适合第一次接触,先把环境跑通。
  2. 从开源仓库下载源码包:FreeRTOS官方源码在各大开源社区都能拿到。下载后会看到tasks.c、queue.c、list.c、timers.c、event_groups.c等核心文件,以及portable目录下针对不同编译器和芯片架构的移植层。
  3. 拿现成的精简工程模板改:找一个和你的开发板型号、编译器完全一致的开源模板,直接改业务代码。

我个人建议Day11这天不要纠结“我必须完全手动移植”,先让IDE帮你生成能编译能下载的最小工程,然后把精力集中在“调度器的代码长什么样、我的任务怎么往里塞”。

3.2 工程里多出来的核心文件是干什么的

移植之后,工程里会多出下面这些文件,它们各管一摊:

  • tasks.c:任务创建、调度器启动、延时、任务切换的主战场;
  • queue.c:队列和信号量机制的底层实现,后面做任务间通信要用;
  • list.c:内核链表,是就绪列表、延时列表的数据结构基础;
  • timers.c:软件定时器,可以理解成跑在后台的“闹钟服务”;
  • event_groups.c:事件组,用来实现多个事件位组合触发的同步;
  • portable/MemMang/heap_4.c:内存分配器,最常用的一个堆管理方案。

重点是heap_4.c,它负责给任务栈和数据结构分配内存,支持合并相邻空闲内存块。第一次接触时,我还闹过把任务栈大小写成0、创建任务仍然“成功”的笑话——其实是内存分配失败后返回NULL,我没检查返回值,后面一调用任务函数就死机。任何创建API,只要返回值和pdPASS不相等,必须停下来排查内存配置。

3.3 大坑一:中断优先级分组没设置对,一调用API就死机

这是我Day11遇到的第一道坎。FreeRTOS要求NVIC中断优先级分组必须设置为“全部抢占优先级、无子优先级”,也就是抢占优先级占4位、子优先级占0位那种分组方式。为什么?因为FreeRTOS在进入临界区时,会操作BASEPRI寄存器来屏蔽“优先级低于某个阈值”的中断。如果优先级分组不对,BASEPRI对高16位优先级和低4位子优先级的处理语义就全变了,结果要么是应该被屏蔽的中断没被屏蔽,导致内核数据结构被破坏,要么是临界区一开,系统直接进HardFault。

我当时的现象是:任务创建成功,调用vTaskStartScheduler()之后,板子像睡着了一样毫无反应。排查了半天,在初始化函数里补上这样一段就活了:

void setup_nvic(void) { // 必须设置为全抢占优先级分组 NVIC_PriorityGroupConfig(NVIC_PriorityGroup_4); }

这个代码要在任何中断开启之前执行,最好放到main函数最前面。只学过裸机开发的人很容易忽略这里,因为裸机程序根本不关心你用的是哪个优先级分组,随便写都能跑。但到了RTOS,这就是一条硬约束。

3.4 大坑二:SysTick和PendSV的优先级必须是当前最低

第二个坑更隐蔽。FreeRTOS靠SysTick产生周期性tick中断,靠PendSV中断执行上下文切换。这两个中断的优先级必须设置为当前系统里最低的,也就是数值最大的那个优先级。原因很直白:如果在任务切换的瞬间,来了一个更高优先级的串口中断,串口ISR会被PendSV“插上一脚”,导致ISR执行被拖延,整个系统的实时响应就会错乱。反过来,SysTick优先级如果太高,那它会频繁抢占其他中断,系统的硬件事件响应就全乱了。

很多自动生成的工程已经帮你配好了,但如果你是从网上抄的模板,一定要主动检查。我当时就是手贱把SysTick优先级设成了0(最高优先级),结果串口打印经常丢字节,LED闪烁节奏还会莫名其妙地跳变。设成最低优先级之后,一切恢复正常。

3.5 跑通第一个任务:让LED自己闪烁

移植完成后,我用最简代码验证调度器是否工作:

static void vLEDTask(void *param) { for (;;) { HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); vTaskDelay(pdMS_TO_TICKS(500)); } } int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); setup_nvic(); BaseType_t ret = xTaskCreate(vLEDTask, "led", 128, NULL, 1, NULL); configASSERT(ret == pdPASS); vTaskStartScheduler(); // 正常情况下代码永远执行不到这里 for (;;) { } }

这个Demo能亮,才算迈出了第一步。这里有三个理解要点:xTaskCreate的栈大小是以“字”为单位的,32位MCU上1个字等于4字节,128字等于512字节;任务函数最前面那个for(;;)是必需品;vTaskStartScheduler()一旦调用,就不会正常返回,main里后面的代码等于废弃。只有明白了这三点,后面排查才不至于晕头转向。

4. 第一个真正的多任务项目:LED呼吸灯+按键切模式+串口日志

4.1 把需求拆成3个独立任务

跑通单个LED任务后,我把Day10那个翻车需求重新做了一遍:呼吸灯正常闪烁,按键可以切换闪烁模式,串口每秒打印一次当前状态。拆成3个任务后,每个任务只关心自己的事,不再有“互相挤时间”的尴尬。我分配的优先级如下:

任务名功能触发方式优先级
vLEDTask根据模式切换LED闪烁间隔周期500ms/200ms/80ms2
vKeyTask扫描按键,更新模式值每20ms扫描一次3
vLogTask串口打印当前模式与运行时长每1000ms打印一次1

按键优先级最高,因为用户交互最需要及时响应;LED中等;串口日志最低,哪怕它偶尔慢一点也不会影响核心功能。这个优先级划分思路,和裸机时“代码里谁写在前面谁优先”是完全不同的两套逻辑。

4.2 三个任务的完整代码

三个任务共用一个全局模式变量g_mode,这里暂时用volatile修饰保证编译器不会过度优化,真正安全的做法是队列或互斥量,我在Day12再升级。先看代码:

static volatile uint8_t g_mode = 0; static void vLEDTask(void *param) { GPIO_PinState state = GPIO_PIN_RESET; uint16_t delay_ms = 200; for (;;) { uint8_t mode = g_mode; if (mode == 0) delay_ms = 200; else if (mode == 1) delay_ms = 500; else delay_ms = 80; state = (state == GPIO_PIN_SET) ? GPIO_PIN_RESET : GPIO_PIN_SET; HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, state); vTaskDelay(pdMS_TO_TICKS(delay_ms)); } }

按键任务用了一个30ms延时来消抖,再用最近1秒的时间差做防连击。注意,这里的vTaskDelay本身就是“让出CPU的等待”,所以LED和日志任务完全不会被按键消抖时间拖住:

static void vKeyTask(void *param) { uint32_t lastPressSec = 0; for (;;) { if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == GPIO_PIN_RESET) { vTaskDelay(pdMS_TO_TICKS(30)); // 消抖等待 if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == GPIO_PIN_RESET) { uint32_t nowSec = HAL_GetTick() / 1000; if (nowSec - lastPressSec >= 1) { g_mode++; if (g_mode > 2) g_mode = 0; lastPressSec = nowSec; } } } vTaskDelay(pdMS_TO_TICKS(20)); // 没有按键也定期让出CPU } }

日志任务最粗暴,每个1秒打印一次:

static void vLogTask(void *param) { for (;;) { printf("[tick=%lu] mode=%u\r\n", HAL_GetTick(), (unsigned)g_mode); vTaskDelay(pdMS_TO_TICKS(1000)); } }

最后在main里创建这三个任务,并把各自的栈大小开好:

xTaskCreate(vLEDTask, "led", 128, NULL, 2, NULL); xTaskCreate(vKeyTask, "key", 128, NULL, 3, NULL); xTaskCreate(vLogTask, "log", 256, NULL, 1, NULL);

4.3 为什么任务里不能直接用HAL_Delay

这是新手最容易翻车的地方,我自己的教训相当深刻。HAL_Delay的实现本质是“不断读取当前tick直到目标时间到达”,这是一个典型的忙等待循环,它不会把CPU让给其他任务。如果你在LED任务里调了HAL_Delay(500),那么这500ms里,CPU全被LED任务占着,按键任务和日志任务全都冻住了。表面上没死机,但用户体验和裸机一样卡。

换成vTaskDelay就不一样:它会把当前任务放入延时列表,然后立刻触发一次调度,让就绪列表里优先级最高的任务出来运行。等延时时间到了,tick中断会再次把这个任务挪回就绪列表。这句话是整个多任务编程的命门——任何需要“等待”的地方,都必须使用能够“让出CPU”的等待原语,否则多任务就是纸面文章。

4.4 实测对比:同一个需求,裸机和RTOS的差距

我把Day10的裸机版和Day11的RTOS版放在同一块板上跑,结果非常直观:

对比项裸机轮询版RTOS任务版
按键响应偶尔迟钝,受其他模块影响稳定,20ms一扫描
串口打印卡顿会影响LED节奏独立,不影响业务
增加新模块需要重新设计时间片直接新增任务即可
代码耦合度状态变量散落四处每个任务封装性好
内存开销小额外消耗任务栈和内核内存

这段对比不是想说明裸机“一无是处”,而是要说明:当业务需求超过“两三件事且互不干扰”这个规模时,自己手搓时间片轮询的维护成本会快速超过引入RTOS带来的额外内存开销。对于一块RAM紧张的MCU,怎么在“裸机精简”和“RTOS好维护”之间取舍,本身就是工程决策的一部分。

5. 任务栈到底开多大:改个数值就死机的尴尬

5.1 一个让人崩溃的现象

Day11下午我做了一个“自以为很聪明”的优化:把LED任务栈从256字改成64字,心想这个任务才十几行代码,64字绰绰有余吧。结果程序能编译、能下载,刚开始一切正常,运行了几分钟后串口开始偶尔乱码,再过一会儿按键失灵,最后干脆HardFault。把栈改回128字,问题消失。

如果栈太小,任务运行时局部变量、函数调用参数、嵌套的中断现场都会往这个任务栈里压。一旦超出边界,最先被踩坏的往往是紧邻栈空间的其他内存区域,可能是其他任务的控制块、堆管理器的元数据,也可能是另一个任务的栈。所以栈溢出的后果是“随机延迟爆发”,并不一定当场崩。这也是嵌入式开发里特别阴险的一类问题:它不报错,只在某个随机时刻反咬你一口。

5.2 FreeRTOS的两种栈溢出检测机制

FreeRTOS提供了configCHECK_FOR_STACK_OVERFLOW配置项,设置成1或2会开启不同强度的检测:

  • 设为1:在每次上下文切换时检查当前任务栈指针是否越界,开销较小,但只能发现“当前任务”的栈溢出。
  • 设为2:除了上述检查,还会把任务创建时写入栈空间的水印值做对比,能发现更多栈被破坏的情况,但开销稍大。

我在工程里直接设成2,并钩住了溢出回调函数,方便第一时间抓到线索:

#define configCHECK_FOR_STACK_OVERFLOW 2 void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) { printf("stack overflow task: %s\r\n", (char *)pcTaskName); configASSERT(0); }

注意,这个检测机制是“事后诸葛亮”,它只能告诉你溢出了,不能帮你自动扩容。想做到心里有数,还得主动给任务栈做体检。

5.3 用HighWaterMark给任务栈做“体检”

调优栈大小的实用手段是uxTaskGetStackHighWaterMark。它可以返回“任务创建以来,栈剩余量的历史最低值”,单位是字。注意是“历史最低剩余”,不是当前剩余,所以最好在任务稳定运行一段时间之后再打印。

我把这个体检代码放进了日志任务,每隔几秒打印一次各任务的剩余栈,观察数值是否接近0:

UBaseType_t ledLeft = uxTaskGetStackHighWaterMark(hTaskLED); UBaseType_t keyLeft = uxTaskGetStackHighWaterMark(hTaskKey); UBaseType_t logLeft = uxTaskGetStackHighWaterMark(hTaskLog); printf("[stack] led=%u key=%u log=%u\r\n", (unsigned)ledLeft, (unsigned)keyLeft, (unsigned)logLeft);

如果某个任务的剩余值只剩几十字,说明栈开小了,赶紧加。如果剩余值高达一两百字,说明栈开得很大,可以适当收一点给别的任务用。这个“反馈式调参”远比拍脑袋准。

5.4 栈大小的经验参考表

综合Day11的实测和学习,我总结出一个粗放但实用的栈大小参考:

任务特点参考栈大小
纯逻辑处理,无打印、无大数组128字以内
含printf格式化输出256字起步
含浮点运算或复杂库函数512字起步
含较大局部数组(128字节以上)在基础需求上叠加字节数对应的字数量

还有一个最容易看走眼的地方:第4个参数的单位是“字”,不是“字节”。32位MCU上一个字等于4字节,这是第一次接触RTOS的人最常见的错位。你要是把一个任务栈写成128,以为是128字节,实际上分配了512字节,不要奇怪为什么内存占用比预估大。

5.5 内存规划:多任务不是免费午餐

最后一个提醒和内存规划有关。每个任务在创建时都会马上从堆里分走自己的栈,即使它还没开始跑。假设MCU只有20KB RAM,三个任务各占128字(共1536字节),日志任务占256字(1024字节),再加上内核的任务控制块、空闲任务栈、定时器任务栈,可用内存会迅速缩水。特别是heap_4.c这种堆分配方式,内存碎片虽然会合并,但如果总RAM不足,xTaskCreate可能直接返回内存分配失败。

我实测的开机自检方法是:创建完所有任务后,打印一份xPortGetFreeHeapSize()的值,看堆还剩多少。如果剩余量很小,就说明任务栈定义得太大,或者总RAM本来就紧张。Day11能跑通这个Demo,不代表TaskA的栈就能直接套用到TaskB上,一切都是“具体任务、具体调优、具体验证”。

Day11这天的实验结束后,我最真实的体会是:学RTOS不能先从原理书那种“从头啃到尾”的方式入手,一定要先有一个能跑的环境,再把真实需求往里塞。让LED、按键、串口从互相抢时间变成各干各的,那种从混乱到秩序的感觉,比任何书里的调度图都更能帮你建立底层认知。Day12我打算把所有跨任务共享的变量全部升级成队列和互斥信号量,把g_mode这种简易共享手法替换掉,争取彻底解决“多个任务同时读写同一个变量”的隐患。多看、多写、多崩几次,嵌入式这东西,真的要靠“亲手把板子调崩溃”来学。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询