ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

FreeRTOS-Plus-CLI实战:嵌入式串口调试的命令行利器

FreeRTOS-Plus-CLI实战:嵌入式串口调试的命令行利器 产品正在现场跑着突然偶发死机手里只有一个串口调试助手和一台不能随便停机的设备。这时候你没法加打印重新烧录也没法把调试器捅到板子上复现问题唯一能做的就是对着串口发呆。这种场景我经历过不止一次后来把 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注意占 RAMconfigCOMMAND_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 deviceCLI 会把引号去掉并让 “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-heapFreeRTOS-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, paramLen); 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那种“无线调试”的体验会让人上瘾。
返回列表