
FreeRTOS-Plus-CLI 这个组件我最早接触是两三年前做一个小型车载网关设备的时候。那会儿固件里堆了一堆调试用的全局变量、临时开关宏、断点打桩代码改一次配置就得重新编译烧录一天下来三分之一的开发时间都耗在反复拆壳连线上了。后来在一次项目评审里被问“你的运行状态怎么实时看”我憋了半天答案只有“串口打印一堆日志”。那次之后我花了两个晚上把 FreeRTOS-Plus-CLI 移植进去从此调试效率直接上了一个台阶。这篇文章不是翻译文档也不是源码注释的搬运。我把从移植到实际使用中真正踩过的坑、验证过的写法、以及几个可以直接抄作业的命令示例整理出来分享给正准备上手或者已经移植了一半的同行。整篇内容围绕一个核心思路CLI 不是一个“炫技”组件而是一个帮你把运行时信息主动权抓回手里的调试基础设施。1. 我为什么会用上 FreeRTOS-Plus-CLI1.1 没有命令行的时候调试到底有多别扭很多基于 FreeRTOS 的项目早期调试手段基本是三件套串口打印、按键轮询、Debugger 断点。串口打印的问题是改日志开关要改代码重新编译打印多了时序会变打印少了现场信息不够用。按键轮询看起来灵活一点但要自己维护一套状态机按键硬编码在 GPIO 上后期加功能非常痛苦。断点调试在开发板上有用到了样机阶段基本废掉因为板子可能装在密封壳子里调试器根本接不进去。我当时最缺的不是“更多日志”而是一个可以在固件运行过程中动态查看任务栈余量、 heap 使用情况、以及手动触发某些硬件动作的入口。也就是说我需要一个能随时敲命令、马上看到结果的交互通道。这个通道用得上位机小工具加私有协议也能做但协议设计、解析、封装、容错一套下来工作量并不小而且每个项目都要重复一遍。1.2 FreeRTOS-Plus-CLI 解决的核心问题FreeRTOS-Plus-CLI 是 FreeRTOS 官方提供的一个轻量命令行解释器组件思路非常简单把一串字符串命令注册进去组件负责解析命令名和参数命令对应的回调函数负责执行逻辑输出通过你挂接的发送函数发出去。所有代码加起来就几个文件核心文件只有 FreeRTOS_CLI.c 和 FreeRTOS_CLI.h移植代价很小。它能解决的并不仅仅是“串口敲命令”这一个场景。你可以把命令接收端放在 UART、蓝牙透传、TCP Socket、甚至是一块自定义按键矩阵上只要最终能拿到以换行符结尾的字符串CLI 就能把它解释成命令并执行。所以在实际项目里它更像一个“运行时控制总线”上位机、手机 App、远程调试工具都可以通过各自的通信通道接进来统一使用同一套命令体系。2. 移植接入的完整流程与文件构成2.1 需要哪些源文件别多拿从 FreeRTOS 官方仓库的 FreeRTOS-Plus 目录下可以找到 CLI 组件源码核心文件并不多我一般只拷贝这几个FreeRTOS-Plus-CLI/ include/ FreeRTOS_CLI.h FreeRTOS_CLI.c如果你用 IDE 工程需要把这两个文件加入编译路径。有几个文件比如 FreeRTOS_CLI_IO.h 在旧版本里会出现新版本已经合并或者不再需要具体要以你拉取版本里的实际文件为准。我的习惯是优先使用官方仓库中与 FreeRTOS Kernel 同级的版本避免从第三方拷贝来路不明的修改版。一个容易忽略的头文件依赖是 FreeRTOS.h 和 task.h这是基础CLI 组件内部用到了 FreeRTOS 的任务锁和内存管理宏。所以移植前请确保 FreeRTOS 内核本体的编译路径已经配置正确CLI 的头文件搜索路径里要能同时找到 FreeRTOS_CLI.h 和 FreeRTOS.h。2.2 初始化与命令注册CLI 组件的核心数据结构是命令表每个命令通过 FreeRTOS_CLIRegisterCommand 注册进去。官方建议在系统启动阶段调用但实际在任务的初始化函数里注册也没有问题只要保证在用户输入命令之前完成注册即可。基本初始化流程如下#include FreeRTOS.h #include FreeRTOS_CLI.h void vRegisterCLICommands(void) { FreeRTOS_CLIRegisterCommand(xResetCommand); FreeRTOS_CLIRegisterCommand(xVersionCommand); FreeRTOS_CLIRegisterCommand(xTaskInfoCommand); FreeRTOS_CLIRegisterCommand(xMemInfoCommand); }每个命令定义为一个静态常量结构体字段说明如下static const CLI_Command_Definition_t xVersionCommand { version, /* 命令名 */ version\r\n, /* 帮助信息注意换行转义 */ vVersionCommand, /* 命令处理函数 */ 0 /* 参数个数0 表示不带参数 */ };这里有一个小细节需要特别注意命令名字符串和帮助信息字符串建议用静态常量字符串不要用局部字符数组否则在命令注册后如果局部缓冲区被复用命令表指向的内容就变了会导致诡异的解析失败。2.3 把命令输出接到调试串口命令处理函数里的输出并不是直接调用 printf而是向一个输出缓冲写入内容。FreeRTOS-Plus-CLI 的理念是“命令逻辑与物理输出解耦”组件会调用一个由你提供的发送回调函数把缓冲区数据真正发到 UART。我惯用的写法是封装一个串口发送函数void vCLIOutputCallback(const char *pcBuffer, size_t uxLength) { /* 假设底层串口驱动是中断发送或阻塞发送 */ UART_Send(pcBuffer, uxLength); }然后把它挂在 CLI 的处理循环里。在这里有个重要的设计取舍CLI 命令处理循环是放在一个独立任务里还是在空闲任务里还是由串口接收中断驱动我的建议是单独建一个 CLI 任务优先级不要太高。任务主体就是一个 while 循环接收一行完整字符串交给 FreeRTOS_CLIProcessCommand 处理然后把输出回调结果送出去。这样既能阻塞等待串口数据又不会阻塞其他高优先级业务任务。3. 命令编写与参数解析的核心细节3.1 命令定义的标准写法FreeRTOS-Plus-CLI 的命令定义有几个固定字段但实际使用中不少人对参数个数和帮助信息的格式处理得不够严谨。命令定义的结构体如下typedef struct CLI_Command_Definition { const char *pcCommand; const char *pcHelpString; uint32_t uxTotalParameters; int32_t (*pxCommandInterpreter)(char *pcWriteBuffer, size_t xWriteBufferLen, const char *pcCommandString); } CLI_Command_Definition_t;注意命令回调函数的返回值有两个约定返回 0 表示命令处理完成返回非 0 表示“这条命令还需要继续处理”。如果一个命令需要分多次输出大量数据你可以返回 1CLI 会保留上下文在下次命令输入时继续调用同一个回调。官方示例中 help 命令就是这样做的一次性把所有命令的帮助信息都列出来可能超长就分批输出。我在实际使用中很少依赖这个分批次特性因为串口缓冲开到 512 字节后一次输出 20 条左右的简短帮助信息完全够用。但如果是通过低带宽通道比如 BLE 传输分批输出反而更安全不会因为一次打包太多数据导致缓冲区溢出。3.2 长短命令与参数解析CLI 的字符串解析有一个很实用的特性命令名支持“唯一前缀匹配”。也就是说如果注册了 version、verify、value 三条命令用户输入 ver 时组件可以确认指向 version输入 v 时如果仍有歧义就会返回“命令不明确”之类的提示。这个机制在命令行交互场景下体验很好但在脚本化调用场景下要小心脚本里最好写完整命令名避免因后续新增命令前缀冲突导致行为变化。参数解析使用 FreeRTOS_CLIGetParameter 宏或者函数获取参数从 1 开始编号注意不是从 0 开始int32_t vMemCommand(char *pcWriteBuffer, size_t xWriteBufferLen, const char *pcCommandString) { char *pcParameter; BaseType_t xParameterNumber 1; pcParameter (char *) FreeRTOS_CLIGetParameter(pcCommandString, xParameterNumber); if (pcParameter ! NULL) { uint32_t ulValue (uint32_t) strtoul(pcParameter, NULL, 0); snprintf(pcWriteBuffer, xWriteBufferLen, Param value: %lu\r\n, ulValue); } else { snprintf(pcWriteBuffer, xWriteBufferLen, Missing parameter\r\n); } return 0; }这里有个高频坑FreeRTOS_CLIGetParameter 返回的指针指向的是 CLI 内部解析缓冲中的一段不是独立分配的字符串。如果你在命令回调里把参数指针保存到全局变量等回调返回之后再去读内容可能已经变了。正确做法是立即拷贝到自己的缓冲区。3.3 多任务安全与输出保护CLI 命令回调函数在哪个任务上下文里执行取决于你把 FreeRTOS_CLIProcessCommand 放在谁里面调用。在我的设计里它是 CLI 任务的上下文所以所有命令回调默认都在 CLI 任务中执行。但问题来了一些命令回调里会访问其他任务共享的数据结构如果这些结构同时在业务任务中被修改就会发生数据竞争。处理思路有两条。一是尽量在命令回调里只做查询和展示不做修改二是如果确实需要修改用 FreeRTOS 的队列或者二值信号量把请求转发给真正的业务任务而不是直接在 CLI 上下文里操作共享资源。输出保护这个问题很多人忽略。串口、蓝牙等外设通常是被多个任务共用的如果 CLI 任务发送日志的同时另一个任务也在打印调试日志两条数据会交错命令输出就花了。我的做法是给 CLI 输出挂一把递归互斥锁任何任务往串口写数据前都要先拿锁。CLI 命令回调内部的 snprintf 到缓冲区阶段不需要上锁真正调用底层 UART 发送函数之前再统一上锁这样锁的时间窗口很短不容易拖慢其他任务的发送流程。4. 实际项目中的几个典型命令实现4.1 查看任务运行状态这是所有项目里我用得最频繁的一条命令。基于 FreeRTOS 官方提供的 uxTaskGetSystemState 函数可以拿到所有任务的名字、状态、栈高水位、运行计数等数据。示例代码如下static int32_t vTaskInfoCommand(char *pcWriteBuffer, size_t xWriteBufferLen, const char *pcCommandString) { TaskStatus_t xTaskStatusArray[16]; UBaseType_t uxArraySize, uxIndex; uint32_t ulTotalRunTime 0, ulStatsAsPercentage; uxArraySize uxTaskGetNumberOfTasks(); if (uxArraySize 16) { uxArraySize 16; } uxArraySize uxTaskGetSystemState(xTaskStatusArray, uxArraySize, ulTotalRunTime); snprintf(pcWriteBuffer, xWriteBufferLen, Name State Stack RunTime%%\r\n); vCLIOutputCallback(pcWriteBuffer, strlen(pcWriteBuffer)); for (uxIndex 0; uxIndex uxArraySize; uxIndex) { ulStatsAsPercentage 0; if (ulTotalRunTime 0) { ulStatsAsPercentage (xTaskStatusArray[uxIndex].ulRunTimeCounter * 100UL) / ulTotalRunTime; } snprintf(pcWriteBuffer, xWriteBufferLen, %-12s %4u %6u %3u%%\r\n, xTaskStatusArray[uxIndex].pcTaskName, xTaskStatusArray[uxIndex].eCurrentState, (unsigned int) xTaskStatusArray[uxIndex].usStackHighWaterMark, (unsigned int) ulStatsAsPercentage); vCLIOutputCallback(pcWriteBuffer, strlen(pcWriteBuffer)); } return 0; }注意使用 uxTaskGetSystemState 需要将 configUSE_TRACE_FACILITY 置 1。运行时间统计如果想要准确还需要把 configGENERATE_RUN_TIME_STATS 打开同时提供时间基准函数否则 ulRunTimeCounter 不递增。没开这两个宏的时候命令也能跑只是运行时间占比那一列永远是 0% 容易给人错误结论。4.2 查看堆内存使用情况对于 heap_4 或 heap_5可以通过 xPortGetFreeHeapSize 拿到当前空闲总字节数通过 xPortGetMinimumEverFreeHeapSize 拿到系统启动以来的最小空闲值。这两个 API 加进去一条 mem 命令就出来了static int32_t vMemCommand(char *pcWriteBuffer, size_t xWriteBufferLen, const char *pcCommandString) { snprintf(pcWriteBuffer, xWriteBufferLen, Free heap: %u, Min free: %u\r\n, (unsigned int) xPortGetFreeHeapSize(), (unsigned int) xPortGetMinimumEverFreeHeapSize()); vCLIOutputCallback(pcWriteBuffer, strlen(pcWriteBuffer)); return 0; }这里建议再加一层把 heap 使用率也计算出来百分比比绝对值直观得多。计算前提是知道堆总大小也就是 configTOTAL_HEAP_SIZE 的值。可以在编译期通过宏把总大小带进去命令输出像这样heap: 3856 free, min 2904, usage 91%一条命令就能在测试现场快速判断是否存在内存泄漏趋势。4.3 手动触发硬件自检命令命令行不只是用来看状态还能主动控制硬件。我在一个电源管理项目里写了一条 selftest 命令用来逐个使能电源轨并读取电压 ADC 值static int32_t vSelftestCommand(char *pcWriteBuffer, size_t xWriteBufferLen, const char *pcCommandString) { int i; static const uint8_t railPins[] { RAIL_1V8_PIN, RAIL_3V3_PIN, RAIL_5V_PIN }; static const uint8_t adcChannels[] { ADC_CH_1V8, ADC_CH_3V3, ADC_CH_5V }; uint16_t adcVal; float voltage; snprintf(pcWriteBuffer, xWriteBufferLen, Power rail selftest...\r\n); vCLIOutputCallback(pcWriteBuffer, strlen(pcWriteBuffer)); for (i 0; i 3; i) { GPIO_SetPin(railPins[i], 1); vTaskDelay(pdMS_TO_TICKS(50)); adcVal ADC_ReadChannel(adcChannels[i]); voltage adcVal * 3.3f / 4096.0f; snprintf(pcWriteBuffer, xWriteBufferLen, rail %d: %d mV\r\n, i 1, (int)(voltage * 1000)); vCLIOutputCallback(pcWriteBuffer, strlen(pcWriteBuffer)); } return 0; }这类命令非常适合产线测试。产测上位机通过串口发送 selftest固件自动执行硬件动作并回传结果比原来用专用测试固件维护两套代码成本低很多。4.4 注册一个带密码保护的命令有些命令比如“恢复出厂设置”“写 EEPROM”风险级别较高我不希望任何人拿串口敲一下就执行。处理办法是做一个简易的密码确认机制在命令处理函数里读取参数如果参数不等于预设值就拒绝执行if (pcParameter NULL || strncmp(pcParameter, doit, 4) ! 0) { snprintf(pcWriteBuffer, xWriteBufferLen, Usage: factory_reset token\r\n); return 0; } /* 再执行真正的恢复出厂逻辑 */这种方案谈不上绝对安全串口抓包的人能看到明文密码但对于产品调试链路上的日常防护已经足够。真正涉及安全的操作建议走双向认证通道命令行只做辅助。5. 踩坑记录与排查技巧5.1 常见问题速查表我在多个项目里积累了一些典型的 CLI 异常现象整理成表格方便同行快速定位现象可能原因排查方向输入命令没任何反应CLI 任务没跑起来或串口中断没触发整行接收检查 CLI 任务是否阻塞在队列等待命令提示符出现但命令都不识别命令注册时机太晚或命令表被意外修改在注册后打印命令表起始地址输出乱码或顺序错乱多任务共用 UART 没有加锁给所有串口写操作加互斥锁长命令被截断接收缓冲长度不够检查配置的输入缓冲长度是否不小于最大长度参数解析拿到的字符串带多余字符行尾没有去除 \r \n在进入 ProcessCommand 前做字符串裁切命令回调访问了非法地址参数指针被保存后复用立即拷贝参数到局部变量其中“多任务共用 UART 没有加锁”是我遇到最多的问题。如果你只用 CLI 输出不和日志系统混用基本不会出事。一旦日志打印、断言输出、CLI 输出都走同一个串口乱码就几乎不可避。加锁是最直接的解决方案代价是同一时刻只能有一个任务在写串口日志吞吐量会受一点影响但对大多数产品来说可接受。5.2 交互优化实现简单历史记录与 Tab 提示很多从 Linux 过来的工程师一进 CLI 就习惯按 Tab 自动补全但 FreeRTOS-Plus-CLI 默认并没有做完整的按键级交互处理。它只负责解析已经形成的完整命令行上游的按键扫描、退格、历史记录都要你自己实现。我在一个带全键盘调试面板的设备上实现过一套简单的行编辑逻辑核心要点有三个。第一个是行缓冲管理用户按下一个字符就把它追加到当前行缓冲同时回显到屏幕按下退格键删除末尾字符并回显删除。第二个是历史记录用环形数组保存最近 20 条输入上下键切换时从历史数组里取内容回填到行缓冲。第三个是 Tab 补全每次用户按 Tab遍历命令表匹配当前已输入前缀的命令名。如果匹配唯一自动补全并追加一个空格如果不唯一把所有匹配项输出到屏幕。这套逻辑大概增加了 200 行左右代码换来的是调试体验的显著提升。如果你只是用串口工具打字输入不建议做 Tab 补全因为大多数串口助手本身就带了本地补全或快捷键功能没必要在固件里重复造轮子。5.3 一个提高 CLI 扩展性的小技巧命令多了之后很多人都遇到过“想加一条命令但定义结构体和注册代码散落在一堆文件里”的问题。我的做法是统一建一个 cli_commands.h 头文件所有的命令导出函数声明都放里面每个业务模块只负责实现自己的命令回调然后在一个集中的源文件里统一注册。这样加一条命令就只需要两步在对应模块实现回调函数再在注册函数里加一行。版控管理也更方便命令表集中审查谁新增了什么命令一目了然。如果你希望命令自动生成帮助信息可以考虑写一个命令行辅助生成脚本从 cli_commands.h 里解析出命令名和参数数自动生成命令定义数组。这个技巧在命令数量超过 30 条时能省不少事但项目初期不建议引入额外工具链先把手工流程跑通才是关键。5.4 CLI 任务优先级与栈大小的选择CLI 任务优先级我通常设为比业务主任务低、比空闲任务高比如 1 或者 2。优先级太低会导致命令响应迟钝太高又可能干扰实时任务的时序。一个容易踩的坑是如果某条命令回调里执行了长时间的循环或者阻塞延时CLI 任务就会占用 CPU可能阻塞低优先级任务。所以命令回调里尽量避免长时间运算和无限循环必要时把耗时操作拆成多步状态机每次命令处理只执行一步。栈大小方面CLI 任务栈主要消耗在 FreeRTOS_CLIProcessCommand 内部解析和你的命令回调中的局部变量。一个包含 snprintf 格式化操作为主的回调512 字节能跑得动大部分场景。如果你在回调里声明了大数组比如 1024 字节的局部缓冲区任务栈就要相应加大否则进任务就跑飞。我一般先给 1024 字节稳定运行后再用 uxTaskGetStackHighWaterMark 看实际峰值调到一个稍有余量的值。6. 从设备调试接口到产品功能的一部分CLI 在很多人印象里只是调试工具但我认为把自定义命令做得足够顺滑之后它能直接变成产品功能的一部分。例如我之前做的一台小型测试仪器用户需要配置报警阈值、查看当前采样频率、手动触发校准这些功能在原本设计里打算做一套带菜单的 LCD 界面。后来评估工时后决定用串口 CLI 加简单上位机界面替代固件端只增加十几条命令上位机用 Python 写了一个简单的串口控制台几天就交付了。这比让固件工程师在裸机菜单状态机上挣扎两三周性价比高得多。把 CLI 当成产品功能的一部分时命令命名和帮助信息就要考虑最终用户的可读性了。你在内部用“mem”没问题给客户用的时候最好改成“show_memory、set_threshold”帮助信息写清楚参数范围和单位。折中做法是系统保留两组命令一组是内部调试命令一组是用户可见命令通过编译宏控制是否注册。如果你正在做一个需要远程运维的设备还可以把 CLI 接到已经存在的网络通道上比如通过 MQTT 下发命令文本设备端解析后执行同样的命令表。由于 CLI 层的字符串解析和命令逻辑完全复用上层通信协议只需要保证可靠传输就行不需要为每一种远程操作单独设计协议字段。这也是 FreeRTOS-Plus-CLI 这类组件真正的价值所在一次实现多渠道复用。最后再分享一个我后来一直坚持的习惯。每注册一条新命令我都会在帮助信息里把参数的取值范围和示例写全哪怕多写几个字节也无所谓。因为两三周后你自己都不一定记得住哪些命令需要哪些参数如果帮助信息只能显示一行“usage: xxx”排查现场还得翻代码那这个帮助功能就名存实亡了。命令行工具的体验往往就是藏在细节里的。