如果你的 STM32F429 板子上已经跑起了 FreeRTOS,但每次想确认一个任务当前是阻塞、就绪还是被卡死,都要重新编译下载、到处加打印,还要靠逻辑分析仪慢慢猜,这个感觉我太熟悉了。后来我在工程里集成了 FreeRTOS-Plus-CLI,用串口敲几条命令就能看任务状态、运行时间统计,甚至直接改业务参数,整个调试节奏完全不一样。这篇文章就是我多次在 STM32F429 上移植 FreeRTOS-Plus-CLI 的完整记录,从源码集成、串口收发、命令注册,到运行时统计和各类高频问题排查,都会讲到。适合有一定 FreeRTOS 基础、想给嵌入式系统加一个可交互调试入口的工程师,照着做基本能少踩一半的坑。
1. 为什么要在 STM32F429 上给 FreeRTOS 加一个命令行
1.1 调试裸机和简单 RTOS 工程的老大难问题
在只跑裸机或者简单定时器轮询的项目里,调试通常靠 LED 闪烁、串口 printf、以及断点。断点这招在逻辑复杂的状态机里其实很难用,因为打断的位置不对,现场早就不一样了。而走到多任务阶段后,问题更明显:任务优先级配错、栈溢出、信号量拿不到,这些都不是靠 printf 插桩能快速定位的,尤其当问题只在跑了几分钟甚至几小时之后才出现,你根本没法定点断下来。
我印象最深的一次,是有个任务周期性采集传感器数据,跑一会儿就停在那里。我怀疑是某个优先级更高的任务占用 CPU 太久,但又没办法实时看到每个任务的运行时间。当时手里只有一个串口调试助手,还是只能用 printf 打印日志。我打了整整两天的日志,最后才怀疑到调度配置上。后来我想明白:如果系统里有一个命令行,能随时查看任务状态和运行时间统计,这类问题五分钟就能确认。
1.2 FreeRTOS-Plus-CLI 到底能做什么
FreeRTOS-Plus-CLI 是 FreeRTOS 官方提供的一个命令行解析组件,它本身不依赖特定板卡,只需要一个能输入字符、能输出字符的通道,最常见的载体就是串口。它可以让你在运行中的嵌入式系统里,像 SSH 登录服务器一样敲命令,然后立刻看结果。
它的核心能力可以归为三类:
- 查看类命令:任务运行状态、运行时统计、系统版本、运行时长、内存使用情况。
- 操作类命令:改变全局参数、触发某个自检流程、复位某个外设、开关注册表里的开关。
- 测试类命令:模拟传感器数据、注入异常、批量跑压力测试。
这也是为什么很多人把它叫做“嵌入式系统的控制台”。在 FreeRTOS 官方的演示工程里,CLI 组件通常是和 TCP 服务器配套的,通过网络远程敲命令。但我们做 STM32 这类 MCU 项目,完全可以直接挂到 UART 上,实现成本更低,调试效率提升却很直接。
1.3 自己写命令解析器,还是直接上现成组件
你可能会想:一个命令解析器而已,我自己写也就一百多行,为什么非要用 FreeRTOS-Plus-CLI?确实,最简单的那种“收到一个字符串,用 strcmp 比较一下再执行”,在命令数量少的时候完全够用。
但一旦命令多起来,问题就来了:参数怎么分割?带引号的字符串怎么处理?帮助信息怎么统一维护?输出太长怎么分页?这些看着不起眼的小设计,自己写起来非常琐碎,还容易出边界 bug。FreeRTOS-Plus-CLI 的好处是命令注册、参数提取、输出续传这些机制都已经做好了,代码量不大,但结构完整,你只需要实现每个命令的回调函数。
从资源占用上看,这个组件也很适合 MCU。它本身不动态申请内存,运行时的核心数据结构都来自用户提供的缓冲区,代码体积大概几 KB,RAM 消耗主要看你的命令输出缓冲区大小。在 STM32F429 这种有 256KB SRAM、主频 180MHz 的芯片上,跑它一点也不吃力。
2. 移植前的资源盘点与关键概念
2.1 硬件、工具链和源码准备
在开始写代码之前,最好先把手里的东西理清楚。硬件方面,STM32F429 本身有多个串口,选一个不用和调试器冲突的 UART 就行。比如常用 USART1 或者 UART4,注意确认引脚是否被其他外设占用。硬件上只需要三根线:TXD、RXD、GND,接一个 USB-TTL 转换器到电脑,再用串口助手或 MobaXterm 之类的工具连接即可。
工具链方面,Keil MDK 和 IAR 都可以,你只要保证代码按 C99 标准编译就行。FreeRTOS 内核版本建议 10.x 以上,现在绝大多数新工程也都是这个版本。FreeRTOS-Plus-CLI 可以从官方仓库下载,也可以直接从某个官方示例工程里把 FreeRTOS_CLI.c 和 FreeRTOS_CLI.h 两个文件拷出来,这两个文件就是组件的全部源码,没有其他花哨依赖。
需要注意一点:这个组件通过 FreeRTOS 内核的 API 工作,所以它必须放在已经能正常调度、能创建任务、能使用队列或信号量的工程里。如果你当前连一个最简单的 LED 任务都还没跑起来,先不要着急移植 CLI,否则出了问题很难判断是内核问题还是组件问题。
2.2 FreeRTOS 内核要满足的前提条件
FreeRTOS-Plus-CLI 对内核的依赖不算多,但有几个前提最好提前检查。
第一,堆内存要够用。如果你用动态创建任务的方式,CLI 任务需要一块 TCB 和任务栈;如果你用静态创建,也需要用户自己提供这些内存。官方组件本身不会额外申请内存,主要是任务本身的开销。
第二,要使能任务通知。CLI 任务推荐用任务通知来唤醒,configUSE_TASK_NOTIFICATIONS要保持默认的 1。
第三,如果你打算用系统自带的vTaskList和vTaskGetRunTimeStats,必须在 FreeRTOSConfig.h 里打开两个宏:configUSE_TRACE_FACILITY和configUSE_STATS_FORMATTING_FUNCTIONS。后面被格式化输出函数依赖,漏了这些宏,编译直接报错或者生成空输出。
第四,关注中断优先级。FreeRTOS 在 Cortex-M 系列上对中断优先级很敏感。串口中断里如果调用了vTaskNotifyGiveFromISR,中断优先级数值必须在configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY允许的范围内,否则会出现中断里调内核 API 导致系统崩溃的问题。对 STM32F429 来说,一般把中断优先级设置成 5 或更高数值即可。
2.3 FreeRTOS-Plus-CLI 的核心工作方式
理解了这个组件的运行机制,后面写代码会顺利很多。它本质上是一个“注册 + 分发 + 输出”的结构。
命令先注册到一个内部命令表里。每个命令用一个结构体描述:
typedef struct CLI_Command_Definition_t { const char *pcCommand; const char *pcHelpString; BaseType_t xCommandHasParameters; pdCommandInterpreter_t pxCommandInterpreter; } CLI_Command_Definition_t;pcCommand是命令名称,比如 "task-stats"。pcHelpString是帮助文本,会被 help 命令拿来展示。xCommandHasParameters表示该命令是否需要接收参数。pxCommandInterpreter是真正的回调函数,也就是命令执行函数。
收到一行命令字符串后,调用FreeRTOS_CLIProcessCommand来解析。这个函数会把输入的命令和命令表里的条目匹配,匹配成功就调用对应的回调。回调的固定格式是:
static BaseType_t prvCommandHandler(char *pcWriteBuffer, size_t xWriteBufferLen, const char *pcCommandString);回调把自己要显示的内容写进pcWriteBuffer,返回值pdFALSE表示输出完毕,返回pdTRUE表示还有更多内容要输出,CLI 会继续调用回调来填充剩余内容。这个“分页输出”机制是 CLI 组件一个非常容易踩坑的地方,后面章节我会单独讲。
3. 在 STM32F429 上一步步完成移植
3.1 把 CLI 源码加进工程
这一步没有什么技术含量,但决定了后面排查问题的难度。我习惯在工程里单独建一个目录,比如Middlewares/Third_Party/FreeRTOS-Plus/CLI,把FreeRTOS_CLI.c和FreeRTOS_CLI.h放进去,而不是跟其他 BSP 驱动文件混在一起。
在 Keil 里,把FreeRTOS_CLI.c添加到某个分组下,然后在 C/C++ 编译器选项的 Include Paths 里加上这个目录。IAR 的操作类似。这里有个容易忽略的小地方:FreeRTOS_CLI.c会包含FreeRTOS.h,所以 include 路径里必须能找到 FreeRTOS 内核的头文件目录,否则编译直接失败。
测试编译前,建议在FreeRTOS_CLI.h同级目录里再放一个FreeRTOS_CLI_IO.h之类的头文件,用来声明你自己实现的字符收发接口吗?其实不一定需要。最简单的做法是直接在 CLI 相关代码里调用你已有的串口发送函数,只要在适当地方extern声明一下即可。我自己更倾向封装一层cli_puts(const char *s),里面调用串口发送,这样后面想改成 DMA 或者加缓存都可以只改一个函数。
3.2 串口收发层的设计与实现
CLI 组件不帮你做串口收发,这部分要自己实现。我的建议是:发送用简单轮询,接收用中断 + 环形缓冲。这样做的好处是中断里只做拷贝和唤醒,不解析、不格式化,逻辑简单也不容易出错。
信号量、消息队列在 CLI 场景尽管可用,但会让代码绕一圈,性价比不高。任务通知就够用了。
先定义一个环形缓冲,接收和处理之间靠它解耦:
#define RX_RING_SIZE 512 static volatile uint8_t rx_ring[RX_RING_SIZE]; static volatile uint16_t rx_head = 0; static volatile uint16_t rx_tail = 0;串口中断里,只管把新到的字节放进 ring,同时在遇到\r或\n时通知 CLI 任务。这里用任务通知,比每次发信号量更轻量:
void UART4_IRQHandler(void) { BaseType_t xHigherPriorityTaskWoken = pdFALSE; uint32_t sr = UART4->SR; if (sr & USART_SR_RXNE) { uint8_t c = (uint8_t)(UART4->DR & 0xFF); uint16_t next = (rx_head + 1) % RX_RING_SIZE; if (next != rx_tail) { rx_ring[rx_head] = c; rx_head = next; } if ((c == '\r') || (c == '\n')) { vTaskNotifyGiveFromISR(xCLITaskHandle, &xHigherPriorityTaskWoken); } } portYIELD_FROM_ISR(xHigherPriorityTaskWoken); }这里有几个关键点:
rx_ring、rx_head、rx_tail加上 volatile,是为了防止编译器优化掉对共享变量的重复读取。- 判断
next != rx_tail是为了防止环形缓冲溢出覆盖还没处理的数据。数据满了就丢弃新字节,对终端输入来说,能接受。 - 遇到回车或换行就给任务发一次通知,CLI 任务醒了之后再统一从 ring 里取数据,这样即便一次传了多行也不怕丢。
发送函数我建议用一个简单的字符循环:
void cli_puts(const char *s) { while (*s) { while (!(UART4->SR & USART_SR_TXE)); UART4->DR = (uint8_t)(*s++); } }如果你对波特率有要求,或者担心发送阻塞会影响其他任务,可以改成 DMA 发送,但 CLI 的输出量通常不大,轮询发送在 115200 波特率下完全够用。
3.3 CLI 任务创建、命令注册和输出分页
在 main 函数创建一个 CLI 任务。推荐用静态创建方式,因为 CLI 任务栈大小可以预先规划,而且不依赖堆碎片:
#define CLI_TASK_STACK_SIZE 512 static StackType_t uxCLITaskStack[CLI_TASK_STACK_SIZE]; static StaticTask_t xCLITaskTCB; TaskHandle_t xCLITaskHandle;创建任务的代码:
xCLITaskHandle = xTaskCreateStatic(vCLITask, "CLI", CLI_TASK_STACK_SIZE, NULL, tskIDLE_PRIORITY + 2, uxCLITaskStack, &xCLITaskTCB);优先级我不建议设太高。CLI 是人在终端前手动敲命令,实时性要求并不高。设成tskIDLE_PRIORITY + 2的较低优先级,就不会抢占真正的控制逻辑。
任务主体负责三件事:等待通知、从 ring 里取出一整行、调用解析器并输出结果:
static void vCLITask(void *pvParameters) { static char cLine[CLI_INPUT_LEN]; static char cOut[CLI_OUTPUT_LEN]; uint16_t usLineLen = 0; (void)pvParameters; vRegisterCLICommands(); for (;;) { ulTaskNotifyTake(pdTRUE, portMAX_DELAY); while (rx_tail != rx_head) { uint8_t c = rx_ring[rx_tail]; rx_tail = (rx_tail + 1) % RX_RING_SIZE; if ((c == '\r') || (c == '\n')) { if (usLineLen > 0) { cLine[usLineLen] = '\0'; vCliExecute(cLine); usLineLen = 0; } } else if ((c == '\b') || (c == 0x7F)) { if (usLineLen > 0) usLineLen--; } else if (usLineLen < CLI_INPUT_LEN - 1) { cLine[usLineLen++] = c; } } } }这里没有把FreeRTOS_CLIProcessCommand直接放在等待循环里,是因为中断里可能一次性唤醒多次,我们把 ring 里的所有字节都处理完,这样不容易漏命令。
真正执行命令的函数要处理 CLI 输出“续传”机制。第一次调用传入命令字符串,如果返回pdTRUE,说明输出还没完,后面要传NULL继续拿后续输出:
static void vCliExecute(char *pcCmd) { static char cOut[CLI_OUTPUT_LEN]; BaseType_t bMore = pdTRUE; bMore = FreeRTOS_CLIProcessCommand(pcCmd, cOut, sizeof(cOut)); cli_puts(cOut); while (bMore == pdTRUE) { memset(cOut, 0, sizeof(cOut)); bMore = FreeRTOS_CLIProcessCommand(NULL, cOut, sizeof(cOut)); cli_puts(cOut); } }cOut数组要放在这个函数内部用 static 申请,避免在中断或任务栈上大量占用栈空间。CLI_OUTPUT_LEN我一般设成 512,因为vTaskList这类系统命令打印一个表头加多个任务行,256 字节往往不够。
命令注册函数长这样:
static void vRegisterCLICommands(void) { FreeRTOS_CLIRegisterCommand(&xCmdHelp); FreeRTOS_CLIRegisterCommand(&xCmdTaskStats); FreeRTOS_CLIRegisterCommand(&xCmdRunTimeStats); FreeRTOS_CLIRegisterCommand(&xCmdUptime); }3.4 常用系统命令的实现
命令定义都是类似的。拿查看任务状态的命令举例:
static BaseType_t prvCmdTaskStats(char *pcWriteBuffer, size_t xWriteBufferLen, const char *pcCommandString) { (void)xWriteBufferLen; vTaskList(pcWriteBuffer); return pdFALSE; } static const CLI_Command_Definition_t xCmdTaskStats = { "task-stats", "task-stats: show task state/list\n", 0, prvCmdTaskStats };这就能用task-stats命令打印出 FreeRTOS 所有任务的状态表了。
再做一个uptime命令,显示系统跑了多少 tick 和多少秒。FreeRTOS 内核里有一个全局变量xTickCount,但更规范的做法是用xTaskGetTickCount():
static BaseType_t prvCmdUptime(char *pcWriteBuffer, size_t xWriteBufferLen, const char *pcCommandString) { TickType_t now = xTaskGetTickCount(); snprintf(pcWriteBuffer, xWriteBufferLen, "tick=%lu, seconds=%lu\n", (unsigned long)now, (unsigned long)(now / configTICK_RATE_HZ)); return pdFALSE; }help命令如果想列出所有命令,最直接的方式是遍历自己的命令表。不过这里有个小麻烦,CLI 组件内部会保存一份注册表,但它没有提供遍历全部命令的公开接口。我一般会在自己的代码里维护一个命令指针数组,然后在 help 里一个个打印:
static const CLI_Command_Definition_t *pxAllCommands[] = { &xCmdHelp, &xCmdTaskStats, &xCmdRunTimeStats, &xCmdUptime, }; static BaseType_t prvCmdHelp(char *pcWriteBuffer, size_t xWriteBufferLen, const char *pcCommandString) { size_t used = 0; for (uint32_t i = 0; i < (sizeof(pxAllCommands) / sizeof(pxAllCommands[0])); i++) { used += snprintf(pcWriteBuffer + used, xWriteBufferLen - used, "%s", pxAllCommands[i]->pcHelpString); if (used >= xWriteBufferLen) break; } return pdFALSE; }这种写法虽然维护两处,但胜在可控。命令多了之后,help 内容对不对一目了然。
3.5 打开运行时统计的配置与实现
想看每个任务占用 CPU 的百分比,就要用到vTaskGetRunTimeStats。这个函数依赖一个高分辨率时间基准,推荐用 STM32F429 内核里的 DWT 周期计数器,因为它不需要额外占用定时器,代码也简单。
在 FreeRTOSConfig.h 中增加或修改这些宏:
#define configGENERATE_RUN_TIME_STATS 1 #define configUSE_TRACE_FACILITY 1 #define configUSE_STATS_FORMATTING_FUNCTIONS 1 #define portCONFIGURE_TIMER_FOR_RUN_TIME_STATS() prvConfigureRunTimeTimer() #define portGET_RUN_TIME_COUNTER_VALUE() prvGetRunTimeCounterValue()然后在某个 C 文件里实现这两个函数:
static void prvConfigureRunTimeTimer(void) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; } static uint32_t prvGetRunTimeCounterValue(void) { return DWT->CYCCNT; }DWT 计数器会跟着 CPU 时钟一直增长,频率是 180MHz,大约 23 秒翻转一次。对查看任务占用率这种场景来说足够了,因为vTaskGetRunTimeStats内部会计算百分比,它关心的是两次调用之间的计数值差,只有两次差值超过 4294967295 才会有问题,这在正常情况下不会发生。
配置好之后,新增命令:
static const CLI_Command_Definition_t xCmdRunTimeStats = { "run-time", "run-time: show task CPU usage\n", 0, prvCmdRunTimeStats }; static BaseType_t prvCmdRunTimeStats(char *pcWriteBuffer, size_t xWriteBufferLen, const char *pcCommandString) { (void)xWriteBufferLen; vTaskGetRunTimeStats(pcWriteBuffer); return pdFALSE; }到这里,一个包含 help、task-stats、run-time、uptime 的基础命令行已经能用了。
4. 用起来之后才发现的调试技巧
4.1 任务状态表的正确读法
task-stats命令输出的第一行是字段名,第二行开始就是每个任务的信息。我最常看的是最后一列,也就是栈剩余空间。很多莫名的死机,其实就是某个任务栈溢出后把邻近内存踩坏了,但如果一直没注意栈余量,排查方向很容易跑到别处去。
FreeRTOS 里每个状态用简写表示:
- R 表示 Running,当前正在运行。
- B 表示 Blocked,阻塞中,比如等信号量、等延时。
- S 表示 Suspended,被挂起。
- D 表示 Deleted,任务被删除,但内核还没释放资源。
调试卡死问题时,重点看你想运行的那个任务是不是成了 S。如果任务莫名其妙停在 Suspended,多半是有人对它调用了vTaskSuspend,或者任务自己把自己挂起了。
要注意,vTaskList为了统计数据,会短暂挂起调度器,所以不要把这条命令做得太高频。人工调试时敲一下,完全没问题。
4.2 自定义业务命令时的高效姿势
系统命令只是开胃菜,真正好用的是把业务参数暴露给命令行。
比如你的系统里有一个电机目标速度变量g_u16MotorSpeed,想临时修改它来测试不同速度下的表现,就可以做一个命令:
static BaseType_t prvCmdSetSpeed(char *pcWriteBuffer, size_t xWriteBufferLen, const char *pcCommandString) { const char *pcParam; uint32_t xParamLen; unsigned long speed = 0; pcParam = FreeRTOS_CLIGetParameter(pcCommandString, 1, &xParamLen); if (pcParam == NULL) { snprintf(pcWriteBuffer, xWriteBufferLen, "usage: set-speed <0-3000>\n"); return pdFALSE; } speed = strtoul(pcParam, NULL, 10); if (speed > 3000) { snprintf(pcWriteBuffer, xWriteBufferLen, "invalid speed, max 3000\n"); return pdFALSE; } g_u16MotorSpeed = (uint16_t)speed; snprintf(pcWriteBuffer, xWriteBufferLen, "speed set to %lu\n", speed); return pdFALSE; }这里有两个重要的坑。
第一,FreeRTOS_CLIGetParameter返回的指针是指向命令字符串内部的,不是独立缓冲区。它只适合拿来读,千万不要往里写。如果你要把参数保存起来,尽量先把内容复制到自己的局部数组里。
第二,strtoul不是 FreeRTOS 专用函数,但标准 C 库在 Keil 和 IAR 里都能正常使用,只是会增加一点代码体积。如果你很在意编译尺寸,也可以自己写个简单的十进制字符串转数值函数。
业务命令修改的全局变量,如果同时被其他任务访问,必须有保护。最简单的方式是加临界区:
taskENTER_CRITICAL(); g_u16MotorSpeed = (uint16_t)speed; taskEXIT_CRITICAL();如果数据更复杂,建议用互斥信号量保护,但临界区在修改单变量时开销最小,不会导致调度延迟,够用。
4.3 怎么把命令行变成安全的调试后门
命令行功能是越用越方便,越方便就越容易失控。我见过有人把所有内部寄存器都暴露出去,结果生产环境误操作把设备搞得不可运行。所以我的习惯是给命令分等级。
调试代码里可以注册所有命令,到了发布版本,通过预编译宏把危险命令排除掉:
#if (CLI_ENABLE_WRITE_COMMANDS == 1) FreeRTOS_CLIRegisterCommand(&xCmdSetSpeed); #endif甚至更极端一点,生产固件里连 CLI 任务都不创建。这个思路本质上就是“表面一样,底层分环境编译”,对维护不同版本非常友好。
如果你必须在生产环境保留命令行,建议至少做一层简单的口令校验。比如在 CLI 任务启动后,要求输入特定字符串才开放写命令入口,否则只允许 help、task-stats 这类只读命令。口令校验别做得太复杂,不要引入加密库,一个简单的字符串比较配合编译宏常量就够了,因为它的作用是防止误操作,不是防黑客。
5. 高频问题与排查经验
5.1 问题速查表
下面这些是我在移植和使用 FreeRTOS-Plus-CLI 时遇到过的真实问题,整理成表,方便你对照排查。
| 症状 | 可能原因 | 处理办法 |
|---|---|---|
| 敲回车后完全没有输出 | CLI 任务没创建,或串口发送路径不对 | 先确认xCLITaskHandle不为 NULL,再在 CLI 任务里加一条启动打印 |
| 输入命令后提示 unknown command | 命令还没注册,或注册表是在任务启动前已经初始化但没有调用注册函数 | 在 CLI 任务开始处调用vRegisterCLICommands(),并检查命令字符串大小写 |
| 输出乱码 | 波特率不匹配,或串口时钟配置错误 | 用逻辑分析仪/示波器看波形,或先写一个纯串口回环测试排除问题 |
| 命令能被识别,但任务状态列表内容截断 | 输出缓冲区太小 | 把CLI_OUTPUT_LEN调到 512 或更大,再试一次 |
| 一执行 task-stats 就进 HardFault | 输出缓冲区不够,或任务栈太小 | 加大 CLI 任务栈到 512 字以上,并调大cOut缓冲区 |
| 运行时间统计输出全为 0 | portGET_RUN_TIME_COUNTER_VALUE没有正确替换 | 检查宏名是否拼写正确,确认portGET_RUN_TIME_COUNTER_VALUE()能拿到非零计数器值 |
| CLI 任务优先级太高导致业务任务抖动 | 优先级设置不合理 | 把 CLI 任务优先级降到tskIDLE_PRIORITY + 2左右 |
| 串口中断里调用通知后系统随机死机 | 中断优先级超出 FreeRTOS 允许范围 | 将串口中断优先级设置为大于等于configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY的数值 |
5.2 实际踩坑记录:分页输出和栈资源
第一次集成的时候,我以为调用一次FreeRTOS_CLIProcessCommand就能输出所有内容,结果执行run-time命令时,只看到前面部分任务行,后面的任务统计不见了。翻源码才发现 CLI 使用“调用一次、返回 pdTRUE 表示还有内容”的续传机制。如果没有 while 循环继续传 NULL,长输出就会被截断。这个坑不看实现很难发现。
第二个坑是栈资源。刚开始 CLI 任务栈我按常规任务给了 128 字,跑 help 和简单命令都没问题,但一执行task-stats就死机。定位过程很典型:先看到系统进入 HardFault,检查所有数组越界都没问题,后来把 CLI 任务栈加到 512 字,问题消失。原因就是vTaskList内部调用格式化函数,对栈的需求超出普通任务。后来我把所有系统相关命令(task-stats、run-time)的pcWriteBuffer输出缓冲放到函数内部的 static 区,任务栈压力进一步降低,这样才稳妥。
第三个坑是串口中断优先级。我最初把 UART4 中断优先级设置得比较高,比如 2,结果跑着跑着一进串口发送日志就死机,而且现象不固定。后来想到 FreeRTOS 的configMAX_SYSCALL_INTERRUPT_PRIORITY限制,把串口优先级降到了 5,问题再没出现过。这一点在 Cortex-M4 上尤其重要,因为中断优先级数字越小优先级越高,而 FreeRTOS 只允许在特定优先级之上调用带 FromISR 后缀的 API。
5.3 几个可以少走弯路的配置建议
如果你现在正准备在自己的 STM32 工程里移植这个组件,我建议一开始就把下面几件事做对,能省很多事。
- 串口接收 ring 缓冲区至少 256 字节。你永远不知道用户在终端里粘贴了一段多长的命令。
- CLI 任务栈不要小于 512 字。如果你后面还要加网络、文件系统相关命令,栈要相应再加大。
- 每次执行完命令,把
cOut缓冲区 memset 一下,防止上次残留内容混入。 - 帮助信息里统一带命令说明和用法,不要只写命令名。
- 不要在中断里注册命令。命令注册必须在普通任务上下文里完成。
6. 最后分享一点我的配置倾向
如果你问我个人项目里最习惯怎么搭这套东西,我的答案是:CLI 任务用低优先级,串口发送用 DMA,接收用中断加环形缓冲,业务参数的写命令全部用互斥保护,并且出厂时默认关闭写命令。这套组合在 STM32F429 上已经稳定跑了几个项目,给我省下大量重复烧录的时间。选型时如果资源允许,不要吝啬给 CLI 分配输出缓冲,一个 1KB 的 static 缓冲区对 F429 来说根本不是事,却能避免很多诡异的截断问题。后续如果你想扩展,可以往命令行里加 flash 读写命令、日志导出命令、自定义协议触发命令,把嵌入式设备的调试体验真正做成服务端开发那样顺手。