产品正在现场跑着,突然偶发死机,手里只有一个串口调试助手和一台不能随便停机的设备。这时候你没法加打印重新烧录,也没法把调试器捅到板子上复现问题,唯一能做的就是对着串口发呆。这种场景我经历过不止一次,后来把 FreeRTOS-Plus-CLI 加进固件,才算是给自己留了一扇窗户。
FreeRTOS-Plus-CLI 是 FreeRTOS 官方仓库里自带的一个命令行组件,核心功能就一句话:在嵌入式设备上实现一个可扩展的命令行解释器。你通过串口敲一行命令,它负责解析字符串、匹配命令表、调用你注册的回调函数,最后把结果输出到控制台。它不依赖任何第三方库,资源开销可控,适合从 Cortex-M0 到应用处理器的各种 FreeRTOS 项目。这篇文章我把自己从下载源码、集成进工程、踩坑到最终跑通的经验完整记录下来,适合正在调试任务栈、堆碎片、设备参数这类问题的开发者参考。
1. 在资源紧张的MCU里,CLI组件到底值不值得加
1.1 一个现场问题的启示:没有命令行的裸奔调试有多痛苦
先讲个真实场景。有一回设备在现场偶发重启,我怀疑是某个任务栈溢出或者堆被写穿,但现场没法复现,只能拿回样机。传统做法是打开vTaskList打印任务状态,加一批printf,烧录、复现、抓日志,一次循环至少半小时。运气好一天能定位,运气不好折腾一周都不一定见到问题。
如果固件里提前内置了 FreeRTOS-Plus-CLI,整个过程会变得非常直接:通过串口敲一条task-stats,任务名、状态、优先级、栈高水位全部列出来;再敲一条free-heap,看堆剩余字节数和碎片情况。如果怀疑某个全局变量被改坏,自定义一条读内存命令,直接看指定地址的内容。这种能力不是“锦上添花”,在关键时候就是排查效率的十倍差距。
1.2 组件定位:它不是终端模拟器,而是命令解析中枢
我第一次接触 FreeRTOS-Plus-CLI 的时候,以为它是一个完整的终端程序,带tab补全、历史记录、终端控制序列。实际上它比这薄得多。它只做三件事:把一行字符串拆成命令和参数、在命令注册表里匹配对应的回调函数、把回调产生的输出写到一块内存缓冲区里。
换句话说,UART驱动、字符接收、输出发送这些外围活它一概不管。它把嵌入式命令行里最麻烦的“字符串解析状态机”封装好了,你只需要注册命令、处理底层收发。这种设计让它非常容易移植,不管你的控制台是UART、以太网telnet、BLE透传还是USB CDC,只要能把字节流接进来就能用。
1.3 什么情况下不建议上CLI
不是所有项目都适合加这个组件。如果你的MCU RAM总量只有几KB,输出缓冲区占掉几百字节就很肉疼;如果产品有严格的信息安全要求,不允许固件里留任何“后门式”交互通道,那也不建议上线。如果只是联调阶段需要,我建议用一个编译开关把整个CLI代码和命令表包起来,量产固件直接关掉,既不影响调试,也不留隐患。
2. 拿到组件后的第一轮改造:目录裁剪与最小工程集成
2.1 下载路径与文件清单
FreeRTOS-Plus-CLI 就在 FreeRTOS 官方 GitHub 仓库里,路径是FreeRTOS-Plus/Source/CLI/。核心文件只有两个:FreeRTOS_CLI.h和FreeRTOS_CLI.c。这两个文件不依赖网络协议栈,只依赖 FreeRTOS 内核头文件和标准 C 库的字符串函数。
把这两个文件直接丢进你的工程,加上头文件路径,编译一遍,通常不会报错。如果报错,大多是配置宏没定义的问题,后面会讲。有一点要注意:这个组件内部用到list.h维护命令注册表,所以你的 FreeRTOS 内核需要开启通用的链表支持,正常移植的 FreeRTOS 工程默认都有。
2.2 组件不替你做的那三件事,恰恰是最容易卡住人的地方
源码拿到手只是开始,你还需要自己实现三件事:从UART接收字符、把字符按回车拼成一行、把CLI输出缓冲区的数据发回串口。
接收字符最常用的做法是在UART中断里把单个字节丢进 ring buffer,然后在 FreeRTOS 任务里阻塞等待。另一种方案是用 DMA 加空闲中断,整帧接收,效率更高但代码复杂度也上去。我自己的习惯是:调试阶段先用中断加 ring buffer 的方式跑通,稳定后再优化成 DMA。
输出方向就简单得多:CLI 执行完命令后,通过FreeRTOS_CLIGetOutputBuffer()拿到一块内存缓冲区,里面是格式化好的文本,你只需要调用自己的 UART 发送函数把它发出去。
2.3 一个最小可跑通的集成骨架
下面这个骨架是我在多个项目里反复用过的结构,精简掉硬件相关代码后大致长这样:
#define CLI_TASK_STACK_SIZE 1024 #define CLI_RX_QUEUE_LEN 128 static QueueHandle_t xCliRxQueue; static char cLineBuffer[configCOMMAND_LINE_MAX_LENGTH]; static size_t xLineLength = 0; void UART_ISR_Handler(void) { uint8_t byte; while (UART_RX_NOT_EMPTY()) { byte = UART_READ_BYTE(); xQueueSendFromISR(xCliRxQueue, &byte, NULL); } } static void vCLITask(void *pvParameters) { uint8_t byte; for (;;) { if (xQueueReceive(xCliRxQueue, &byte, portMAX_DELAY) == pdPASS) { if ((byte == '\n') || (byte == '\r')) { if (xLineLength > 0) { cLineBuffer[xLineLength] = '\0'; if (FreeRTOS_CLIProcessCommand(cLineBuffer, xCliWriteBuffer, configCOMMAND_INT_MAX_OUTPUT_SIZE) != pdFALSE) { /* 还有更多输出,继续调用直到完成 */ } UART_SendString(xCliWriteBuffer); xLineLength = 0; } } else { if (xLineLength < sizeof(cLineBuffer) - 1) { cLineBuffer[xLineLength++] = (char)byte; } } } } }这段代码我故意把输出处理写得比较粗略,因为FreeRTOS_CLIProcessCommand的返回值处理非常关键,后面专门用一节来讲。队列接收方式的好处是天然做了任务同步,UART中断里不涉及任何阻塞操作,rx队列溢出时也能统计丢包。
2.4 配置宏清单:从老版本到新版本的变化
FreeRTOS-Plus-CLI 的配置宏在FreeRTOS_CLI.h里用#ifndef定义了默认值,你也可以在FreeRTOSConfig.h里覆盖。常用的有这么几个:
| 宏名 | 作用 | 默认值 | 建议 |
|---|---|---|---|
configCOMMAND_LINE_MAX_LENGTH | 单条命令行的最大字符数 | 100 | 按需调大,超长输入会被截断 |
configCOMMAND_INT_MAX_OUTPUT_SIZE | 输出缓冲区的字节数 | 1000 | 命令多时建议 1500~2000,注意占 RAM |
configCOMMAND_MAX_NESTED_COMMANDS | 嵌套命令最大深度 | 10 | 一般保持默认 |
这里有个坑:老版本(FreeRTOS Labs 时期)的宏名可能叫FreeRTOS_CLI_MAX_OUTPUT_BUFFER_SIZE,新版本(集成到主仓库后)改成了configCOMMAND_INT_MAX_OUTPUT_SIZE这类以config开头的命名。不同小版本之间可能还有差异,集成时第一件事就是打开头文件搜MAX_OUTPUT和MAX_LINE,以你拿到的源码实际定义为准,别背网上的老配置。
3. 从RegisterCommand到ProcessCommand:把执行链路拆开看
3.1 命令注册:CommandDefinition_t 与回调函数签名
CLI 的命令表通过一个CommandDefinition_t结构体来描述,注册命令就是填充这个结构体并调用FreeRTOS_CLIRegisterCommand()。结构体各字段含义如下:
typedef struct xCOMMAND_DEFINITION { const char *pcCommand; /* 命令名字符串 */ const char *pcHelpString; /* help命令显示的帮助文本 */ const BaseType_t xCommandNeedsTask; /* 是否需要任务上下文执行 */ const int32_t lExpectedNumberOfParameters; /* 期望参数个数 */ const CommandCallback_t pxCommandInterpreter; /* 回调函数指针 */ } CommandDefinition_t;xCommandNeedsTask这个字段值得单独说。它告诉 CLI 这个回调函数是必须在任务上下文调用,还是允许在中断上下文调用。像vTaskList这种依赖调度器状态查询的接口,必须在任务上下文跑,这个字段就要置为pdTRUE。如果你拿不准,一律置pdTRUE最安全。
回调函数的签名是固定格式:
static BaseType_t prvTaskStatsCommand(char *pcWriteBuffer, size_t xWriteBufferLen, const char *pcCommandString);pcWriteBuffer指向 CLI 的输出缓冲区,你把结果写进去;xWriteBufferLen是这个缓冲区的长度,写之前一定要检查边界;pcCommandString是完整的原始命令字符串,后续的参数解析全靠它。
3.2 参数解析:FreeRTOS_CLIGetParameter 是怎么工作的
命令回调拿到的是一整行字符串,要拿到某个参数需要调用FreeRTOS_CLIGetParameter():
const char *pcParameter; BaseType_t xParameterNumber = 1; /* 0 是命令本身,参数从 1 开始 */ uint32_t xParameterLength; pcParameter = FreeRTOS_CLIGetParameter(pcCommandString, xParameterNumber, &xParameterLength);这个接口会把第 N 个参数以字符串形式返回,同时通过第三个参数告诉你这个参数的长度。有个细节很实用:如果参数被双引号包裹,比如设置设备名称时输入set-name "my device",CLI 会把引号去掉,并让 “my device” 作为一个参数整体返回,中间的空格不会被拆开。这个特性在处理带空格的设备名、SSID、文件路径时非常有用。
CLI 还会根据lExpectedNumberOfParameters做参数个数校验。我实测下来,如果输入参数个数对不上,CLI 根本不会调用你的回调,而是在输出缓冲区写入类似 “Incorrect number of parameters” 的错误信息。所以回调里其实不用反复校验参数个数,但还是要处理参数内容本身非法的情况。
3.3 输出契约:FREE_RTOS_CLIProcessCommand 的返回值是什么意思
这一节是整篇文章里最重要的一段,我见过太多人在这个地方栽跟头。
FreeRTOS_CLIProcessCommand()的返回值不是“命令执行成功与否”,而是“还有没有更多输出需要读取”。具体来说:如果返回pdPASS,说明命令还没结束,输出缓冲区里还有后续内容,你需要再次调用这个函数,它会继续生成下一段输出;如果返回pdFALSE,说明命令已经执行完,这是最后一段输出。
为什么设计成这样?因为 CLI 的输出缓冲区是有限大小的。命令产生的输出可能远超缓冲区长度,比如 help 命令列出几十条命令、task-stats打印几十个任务的信息。CLI 会在缓冲区写满时停下来,告诉调用方“你先把我这段输出发走,再回来找我继续”。
正确的处理方式是一个循环:
do { FreeRTOS_CLIProcessCommand(cLineBuffer, xCliWriteBuffer, configCOMMAND_INT_MAX_OUTPUT_SIZE); UART_SendString(xCliWriteBuffer); } while (FreeRTOS_CLIProcessCommand(cLineBuffer, xCliWriteBuffer, configCOMMAND_INT_MAX_OUTPUT_SIZE) != pdFALSE);或者更规范一点,用一个 do-while 在发送完当前段后询问是否还有下一段。很多人只调用一次FreeRTOS_CLIProcessCommand,然后发送 buffer,结果 help 输出只显示一半,还以为是 buffer 不够大,其实真正问题是没把剩余输出取完。
3.4 三个原生命令:help、task-stats、free-heap
FreeRTOS-Plus-CLI 自带 help 命令的基础实现,它会遍历命令注册表,生成所有命令名和帮助文本。你每注册一条新命令,help 输出里就自动多一行,不需要额外维护。
真正实用的是自己注册task-stats和free-heap。task-stats直接包装vTaskList()就能用:
static BaseType_t prvTaskStatsCommand(char *pcWriteBuffer, size_t xWriteBufferLen, const char *pcCommandString) { (void)xWriteBufferLen; (void)pcCommandString; vTaskList(pcWriteBuffer); return pdFALSE; }不过要注意,vTaskList依赖configUSE_TRACE_FACILITY和configUSE_STATS_FORMATTING_FUNCTIONS这两个配置宏,没开启的话编译直接报错。free-heap则包装vPortGetHeapStats(),能输出堆总大小、剩余大小、最大空闲块、碎片数等信息,对排查堆溢出和碎片化特别有用。
4. 实测中踩过的坑:从“命令没反应”到“输出被截断”的完整排查链路
4.1 坑一:help 输出只显示一半,修复后的完整循环
我在一个 STM32F4 项目里第一次集成 CLI,帮朋友加调试命令,注册了大概 15 条命令。烧进去以后敲help,终端上只显示了前 7 条命令,后面的凭空消失。第一反应是输出缓冲区太小,把configCOMMAND_INT_MAX_OUTPUT_SIZE从 1000 改到 2000,重新编译烧录,结果前 10 条出来了,但还有几条看不到。
后来翻源码才发现问题不在缓冲区大小,而在调用方式。我把FreeRTOS_CLIProcessCommand写成了只调用一次。help 命令的输出超过缓冲区后,第一次调用只能生成一部分,返回pdPASS表示“还有下一段”,而我的代码直接把这次的 buffer 发出去了,剩下的内容永远没机会生成。
正确做法就是我上面给的那个循环。改完之后 help 完整显示,所有命令正常。这里分享一个小技巧:如果某条命令的输出就是会超过 buffer,调大 buffer 确实能减少分段次数,但根本上还是要支持循环取输出,因为命令输出长度不可控。
4.2 坑二:长命令丢字符,UART 接收中断的设计缺陷
另一个项目里,用户反馈串口输入free-heap时,偶尔变成fee-heap或者free-hea,字符被吞了。我一开始怀疑终端工具配置,换了几种串口助手都一样,基本排除上位机问题。
为了定位是在哪个环节丢的字符,我在 CLI 任务收到原始字节的地方加了一个调试计数器,同时把收到的字符以十六进制方式回显。跑了半天抓到一个规律:丢字符总是发生在 UART 中断里执行了打印操作之后。
原因很典型:我的 UART 中断服务函数里,为了调试方便,直接调用了printf往调试串口打日志。printf是阻塞式的,在中断里会占用大量时间,第二个字符到达时触发不了中断,硬件 FIFO 直接丢弃。修复方案是把所有调试打印移出中断,中断里只做xQueueSendFromISR,打印逻辑放到任务上下文。如果 MCU 的 UART 支持 FIFO,可以适当加大中断触发的阈值,减少中断频率。
这里也给个排查链路建议:遇到丢字符,先别急着看缓冲区大小和中断优先级,先把收到的原始字节流打出来,确认是在哪个环节开始丢的。是中断没进,还是队列溢出,还是任务处理不及时,通过加计数器能快速区分。
4.3 坑三:敲了命令之后系统直接卡死
有一次我在一个低优先级任务里挂了个 CLI,敲了一条自定义命令,结果系统直接卡死,看门狗复位。当时第一反应是回调里写了死循环,检查代码却发现只是遍历了一个大数组打印数据,大约几百毫秒的耗时。
问题出在上下文。CLI 的回调是在“调用FreeRTOS_CLIProcessCommand的那个任务”的上下文里执行的。我把 CLI 放在了一个优先级很低的任务里,这个任务平时闲着,但一旦执行耗时长的命令,低优先级任务占着 CPU 不放,直接导致高优先级实时任务无法被调度。几百毫秒对实时系统来说已经是不可接受的卡顿。
更隐蔽的一种情况是:回调里调用了vTaskDelay或者等待一个信号量,而这个信号量恰好由更高优先级的任务持有,形成优先级反转甚至死锁。
排查链路是:先确认FreeRTOS_CLIProcessCommand是从哪个任务调用的,这个任务的优先级是多少,再看回调里有没有阻塞 API。我的最终方案是把 CLI 做成一个独立任务,优先级设为 idle 之上、实时任务之下,并且严格要求命令回调函数不能调用任何阻塞 API。如果确实有耗时操作,就把命令拆成“启动 + 查询结果”两步,执行完先返回,后续通过事件标志通知完成状态。
4.4 坑四:多任务同时打印,输出缓冲区被踩烂
这个坑最隐蔽,也是我花时间最久的一次。现象是:CLI 命令输出偶尔出现半行乱码,而且是随机性的,有时任务状态表格里混进来一段日志,有时日志中间插进半行命令输出。
排查到后面发现根源是共享资源没加锁。FreeRTOS-Plus-CLI 的输出缓冲区是全局静态数组,我的项目里有一个日志任务和一个 CLI 任务。日志任务偶尔也调用FreeRTOS_CLIGetOutputBuffer()来获取临时缓冲区拼接日志,两个任务同时操作同一块内存,自然互相覆盖。
还有一种更隐蔽的叠加场景:即使缓冲区写入没有冲突,UART 发送也是异步的。CLI 把输出缓冲区内容交给 DMA 发送后,DMA 还没传完,下一个命令又开始往同一块缓冲区写数据,DMA 读到一半的数据就变成了新命令的内容。
修复方案是给 CLI 调用加互斥锁,锁的粒度要覆盖“调用FreeRTOS_CLIProcessCommand+ 发送输出”的全过程,不能只锁其中一段。日志任务如果要借用输出缓冲区,也必须走同一把锁。更好的做法是让 UART 驱动实现 DMA 拷贝,或者用双缓冲,但最简单的还是保证同一时刻只有一个任务碰这块缓冲区。
5. 让CLI真正好用起来:格式化输出、长参数与自定义调试命令的进阶方案
5.1 在CLI回调里安全地使用printf类输出
CLI 回调拿到的pcWriteBuffer指向一块固定大小的内存,它不是标准stdio的FILE*。很多人在回调里直接写sprintf(pcWriteBuffer, ...),在小工程里能用,但我建议封装一层自定义格式化函数,原因有两个:一是不同工具链的vsnprintf体积和栈占用差异很大,在 Cortex-M0 这类小核上可能引入大量代码;二是标准库的格式化函数在缓冲区写满时的行为并不总是符合预期。
我的做法是封装一个极简的格式化函数,只支持%d、%u、%x、%s、%c这几个调试高频格式:
static void cli_format(char *buf, size_t buf_size, const char *fmt, ...) { va_list args; va_start(args, fmt); vsnprintf(buf, buf_size, fmt, args); va_end(args); }如果你的板子 RAM 和 Flash 都比较紧张,可以自己实现一个只包含必要格式转换的简化版。这里的关键不是格式化本身,而是要意识到:CLI 输出缓冲区是有限的,格式化之前要先预估长度,别等写完了才发现超长被截断。
5.2 带参数命令:解析数值、字符串、十六进制
CLI 最大的价值在于让命令带参数。我常用的一个命令是修改日志级别:
static BaseType_t prvSetLogLevelCommand(char *pcWriteBuffer, size_t xWriteBufferLen, const char *pcCommandString) { const char *pcLevelStr; uint32_t level; BaseType_t paramLen; pcLevelStr = FreeRTOS_CLIGetParameter(pcCommandString, 1, ¶mLen); if (pcLevelStr == NULL) { snprintf(pcWriteBuffer, xWriteBufferLen, "usage: set-log-level <0-4>\r\n"); return pdFALSE; } level = strtoul(pcLevelStr, NULL, 10); if (level > 4) { snprintf(pcWriteBuffer, xWriteBufferLen, "invalid level: %lu\r\n", (unsigned long)level); return pdFALSE; } g_log_level = level; snprintf(pcWriteBuffer, xWriteBufferLen, "log level set to %lu\r\n", (unsigned long)level); return pdFALSE; }strtoul的第三个参数传 0 时,输入可以是十进制、八进制、十六进制,也就是说你敲0x1A也能正确解析。对参数做范围校验是必要的,CLI 只保证参数个数匹配,不保证参数内容合法。
5.3 内存/变量查看命令:现场排查的利器
栈溢出、野指针这类问题,靠黑盒测试很难定位。我给自己做了个dumpmem命令,用来查看指定内存地址的内容:
static BaseType_t prvMemDumpCommand(char *pcWriteBuffer, size_t xWriteBufferLen, const char *pcCommandString) { const char *pcAddrStr = FreeRTOS_CLIGetParameter(pcCommandString, 1, NULL); const char *pcLenStr = FreeRTOS_CLIGetParameter(pcCommandString, 2, NULL); uint32_t addr, len, i; if (pcAddrStr == NULL || pcLenStr == NULL) { snprintf(pcWriteBuffer, xWriteBufferLen, "usage: dumpmem <addr> <len>\r\n"); return pdFALSE; } addr = strtoul(pcAddrStr, NULL, 0); len = strtoul(pcLenStr, NULL, 0); if (len > 256) { snprintf(pcWriteBuffer, xWriteBufferLen, "max len is 256\r\n"); return pdFALSE; } for (i = 0; i < len; i++) { if ((i % 16) == 0) { snprintf(pcWriteBuffer + strlen(pcWriteBuffer), xWriteBufferLen - strlen(pcWriteBuffer), "\r\n%08X: ", (unsigned int)(addr + i)); } snprintf(pcWriteBuffer + strlen(pcWriteBuffer), xWriteBufferLen - strlen(pcWriteBuffer), "%02X ", *(unsigned char *)(addr + i)); } snprintf(pcWriteBuffer + strlen(pcWriteBuffer), xWriteBufferLen - strlen(pcWriteBuffer), "\r\n"); return pdFALSE; }注意这里有个细节:如果输出超过 buffer 长度,这个写法会被截断。实际项目里我用的是分段输出加pdPASS返回的方式,每次最多生成一行,然后循环续传。上面这段代码适合输出长度可控的场景,真正的大容量 dump 命令要配合 3.3 节的返回值循环来处理。
我用这个命令排查过好几次可疑内存区域。比如怀疑一个全局结构体被写坏,先查它的地址,再 dump 出 64 字节,对比发给我的预期数据,几秒钟就能确认哪个字节被改了。这比在代码里到处加断点快得多。
5.4 给CLI加保护:非法输入不崩溃、超长输入有兜底
CLI 组件本身对超长输入有截断机制,但我建议在接收端也做一层保护。比如 UART 任务里维护的行缓冲区长度要大于configCOMMAND_LINE_MAX_LENGTH,一旦超过就把当前行丢弃,重新等待回车。这样即使终端工具发了一整屏的二进制数据,也不会把 CLI 的内部状态搞乱。
非法参数防护方面,养成三个习惯:第一,所有FreeRTOS_CLIGetParameter的返回值都要判空;第二,所有数字参数都要做范围校验,CLI 只保证类型是字符串,不保证内容是合法数字;第三,回调里写输出缓冲区时,每写一次都要计算剩余空间,不要用sprintf无脑往里怼。做到这三点,CLI 命令几乎不会把系统搞崩。
我在实际项目中还有一个习惯:可写命令都做操作记录。比如set-log-level执行时,把新值和执行者的命令行原样存到一个环形日志里。现场如果出了网络问题,先把配置变更历史拉出来,能快速判断是不是有人误操作改了参数。这个思路不复杂,但非常实用。
再分享一个最后的小技巧:CLI 通道可以不走 UART,我在几个项目里把它接到了 BLE UART 透传和以太网 telnet 上。底层传输变了,上层命令完全不用改,因为 FreeRTOS-Plus-CLI 把命令解析和传输彻底解耦了。如果你手头有蓝牙模块,可以试试手机连上去敲task-stats,那种“无线调试”的体验会让人上瘾。