资讯动态

FreeRTOS+串口CLI:嵌入式开发调试效率倍增的实战方案

发布时间:2026/9/20 19:49:57 来源:尧图企业网站定制
做嵌入式开发这么多年我最大的体会之一就是调试手段决定开发效率。早年间调一块板子上的参数改一个延时、翻一个IO都得重新编译、烧录、断电重启一天下来大半时间浪费在烧录器上。后来在项目里接上了命令行终端直接在串口工具里敲命令改参数、看状态效率直接翻倍。这篇博文就围绕一个非常实用的组合展开FreeRTOS CLI GD32串口终端。我会用GD32F103这颗芯片做载体带你从零把一个串口命令行CLI跑起来再用它控制LED实现亮、灭、翻转、闪烁这类基本操作。整个移植思路不仅适用于GD32也适用于STM32、HK32这类Cortex-M内核的芯片。文末会分享我在实际调试中踩过的坑和排查技巧。如果你正准备给嵌入式项目加一个调试入口或者正在学FreeRTOS的队列、任务、信号量怎么用在真实项目中这篇文章值得仔细看一遍。1. 为什么我建议在GD32上折腾一个串口CLI1.1 CLI解决的核心痛点在没有CLI之前MCU产品调试通常是这个流程发现问题 - 修改代码 - 重新编译 - 烧录 - 复现问题 - 再改。如果一个参数需要反复试这个过程会极度消耗耐心。比如你要调一个呼吸灯的周期每次都要改宏定义重新烧录十分钟能完成一次试验就算快的。有了CLI之后调试变成了这样串口终端 - 敲一条命令 - 立即看到效果。参数不对就再敲一条整个过程不碰编译器、不碰烧录器。这不是锦上添花而是能实打实缩短调试周期的事情。我在一些量产产品上用过这个方案比如带RS485通讯的采集设备、不带屏的传感器节点现场工程师插上USB转串口就能查看固件版本、修改设备地址、调整采样周期不需要拆机、不需要额外的调试工具。除了调参CLI还能用来做系统体检查看当前任务状态、剩余堆栈、内存使用率、内核运行时间。这些都是FreeRTOS移植完成后非常关键的验证手段。你要知道系统里哪个任务快爆栈了、哪个队列经常满靠打断点不如直接在命令行里敲一条代码来得直观。1.2 方案选型自研解析器还是移植现成组件做CLI方案社区里其实已有不少现成轮子比如letter-shell、nr_micro_shell还有专门面向Arm Cortex-M的shell组件。它们功能完整自带tab补全、历史记录甚至还能和文件系统联动。但在GD32FreeRTOS这个组合下我个人的建议是如果是为了学习或做小型产品先自己写一个精简的解析器更靠谱。原因有三个。第一第三方shell为了通用性往往引入大量抽象层对接时需要阅读较多源码反而拉长移植周期。第二嵌入式CLI的核心需求其实非常简单接收字符串、按空格拆解参数、查表执行函数、回显结果。这些用一两百行C代码就能实现自己写反而更容易控制资源和可维护性。第三自研解析器的排错路径非常短一旦出问题你清楚每一行代码在干什么。等理解透彻之后再去看那些开源组件你会发现它们的设计思路也很容易吸收。我用一个表格对比一下自研和现成组件的取舍对比维度自研精简解析器现成开源shell组件代码量引入约150-250行通常1000行起部分带依赖功能扩展性按需增加结构清晰tab补全、历史记录等开箱即用学习价值高能理解命令分发本质中等更多是学习组件用法调试排查成本低逻辑完全掌握中高需先理解组件内部机制Flash/RAM占用极低相对更高所以这篇博文会以自研CLI为主线走完整流程。2. 移植前的基础准备与FreeRTOS关键概念梳理2.1 硬件环境与工程结构我这次用的是GD32F103RET6主频72MHz板上带一颗LED接到PC13这跟比较常见的STM32F103C8T6核心板布局接近方便大家复现。串口使用USART0PA9发送、PA10接收通过USB转TTL模块连到电脑。调试环境是Keil MDK 5 GD32标准固件库FreeRTOS版本是V10.4.6内核源码直接放入工程。如果你的板子是GD32F303或STM32F103移植步骤基本一致只需要把引脚和RCU使能的时钟改成你自己的配置。千万不要觉得芯片型号差一点就得重来Cortex-M3内核外设的编程模型高度一致这恰恰是嵌入式开发的友好之处。工程结构我建议按模块拆分不要所有代码都堆在一个main.c里。一个可参考的目录组织app/main.c系统初始化、任务创建app/cli/cli.c、cli.h命令行解析核心app/cli/cli_cmd.c命令表及具体命令实现bsp/uart.c、gpio.c串口、LED驱动kernel/FreeRTOS源码startup/GD32启动文件与系统时钟配置这样的好处是命令扩展时只改cli_cmd.c不用动主逻辑。2.2 FreeRTOS里必须搞懂的三件事写CLI之前有三个FreeRTOS概念值得先理清后面代码全靠它们支撑。第一是FreeRTOS堆管理。FreeRTOS的任务栈、队列、信号量等内核对象内存都从它自己管理的堆中分配。在FreeRTOSConfig.h里通过configTOTAL_HEAP_SIZE配置总大小。GD32F103内部SRAM常见的是8KB、10KB、20KB等取决于具体型号如果heap配太大任务栈就会被挤压容易启动失败。我之前在8KB SRAM的芯片上调试时configTOTAL_HEAP_SIZE配成4KB加上两个任务各256字栈系统能跑但再创建队列就开始返回NULL。合理做法是先用xPortGetFreeHeapSize()函数打印剩余堆根据实际情况回推配置。第二是队列机制。命令行从串口接收数据本质上是生产者-消费者模型串口中断是生产者CLI任务是消费者。中断里把字节放入队列任务里取出并解析。FreeRTOS队列天然支持任务与中断之间的数据传递效率高且不会丢数据。中断中使用xQueueSendFromISR任务中使用xQueueReceive这一对API就是整个CLI串口链路的核心。第三是任务优先级设计。CLI任务本质上是人机交互对实时性要求不高但也不能被饿死。在FreeRTOS同优先级任务时间片轮转下建议把CLI任务放在中间优先级比如优先级3到5LED控制任务如果是周期性的闪烁优先级可以稍低如果有高实时性的采集任务再给更高优先级。切忌把所有任务都设成同一优先级又不加阻塞延时那样会导致低优先级任务彻底抢不到CPU。3. 串口接收链路与命令行解析器一步步写出来3.1 串口中断队列保证字节一个不漏串口接收要在中断函数中处理。常规做法是每次收到一个字节就存入缓冲区这个缓冲区如果由任务读取就存在并发访问问题。更好的做法是收到字节后立即放入FreeRTOS队列由CLI任务自己去取。初始化部分的重点有三个使能串口时钟、配置GPIO复用、配置USART0中断并设置抢占优先级。这里尤其要注意中断优先级设置必须遵循FreeRTOS的要求。在Cortex-M3上FreeRTOS把PendSV和SysTick设置为最低优先级而外设中断优先级必须高于它们即优先级数值比PendSV/SysTick小否则调用xQueueSendFromISR时可能产生断言错误。GD32固件库中一般这样配置nvic_irq_enable(USART0_IRQn, 0, 0);注意NVIC_PriorityGroup_2这样的分组设置需要和FreeRTOS的configPRIO_BITS保持一致。GD32F103为4位优先级位即configPRIO_BITS定义为4。中断服务函数里先把接收到的字节取出来然后通过xQueueSendFromISR放入队列void USART0_IRQHandler(void) { BaseType_t xHigherPriorityTaskWoken pdFALSE; uint8_t ch; if (usart_interrupt_flag_get(USART0, USART_INT_FLAG_RBNE) ! RESET) { ch (uint8_t)usart_data_receive(USART0); xQueueSendFromISR(s_xUartQueue, ch, xHigherPriorityTaskWoken); usart_interrupt_flag_clear(USART0, USART_INT_FLAG_RBNE); } portYIELD_FROM_ISR(xHigherPriorityTaskWoken); }为什么要用xQueueSendFromISR而不是任务内的发送函数因为中断上下文不能阻塞不能调用会挂起当前任务的API。这个队列的大小我一般配置为256字节能满足快速输入一个较长命令的需求。3.2 命令行解析器命令表驱动加命令只需添一行自研解析器的核心是一个命令表。利用C语言的函数指针把命令字符串、帮助文本、执行函数绑定在一起。定义这样一个结构体typedef struct { const char *name; const char *help; int (*func)(int argc, char **argv); } cli_cmd_t;命令表可以是一个cli_cmd_t数组所有命令集中放在一块static const cli_cmd_t s_cmd_table[] { { led, led on|off|toggle|blink [ms] - control led, cmd_led }, { help, help - show all commands, cmd_help }, { version,version - show firmware version, cmd_version }, { sysinfo,sysinfo - show system info, cmd_sysinfo }, };解析流程是CLI任务从队列中逐个接收字符累计到回车符就认为一行命令输入完成然后把整行交给解析函数解析函数用strtok_r按空格拆出命令名和参数最后遍历命令表用strcmp匹配到对应函数并调用。这里有一个细节回车换行要统一处理。串口终端发送回车时很多上位机会同时发送\r\n有的只发\r。稳妥的做法是解析时去掉\n遇到\r或\n都视为一行结束。如果处理不当会看到命令明明敲对了却一直报未知命令。我自己的处理是while (xQueueReceive(s_xUartQueue, ch, portMAX_DELAY) pdTRUE) { if ((ch \r) || (ch \n)) { if (s_line_len 0) { s_line_buf[s_line_len] \0; cli_execute(s_line_buf); s_line_len 0; } } else if (s_line_len (CLI_LINE_BUF_SIZE - 1)) { s_line_buf[s_line_len] ch; } }在CLI任务里用portMAX_DELAY阻塞等待队列配合FreeRTOS的实时调度CPU利用率极低没有输入时任务就挂起不占用系统资源。这比用轮询扫描串口寄存器要优雅得多。4. LED控制源码实战4.1 从命令字符串到LED动作的命令设计串口CLI跑通后接下来就是最直观的验证方式LED控制。我设计了一组子命令覆盖了最常见的点灯场景命令功能示例led on点亮LEDled onled off熄灭LEDled offled toggle翻转LED状态led toggleled blink 500以500ms周期闪烁led blink 500led stop停止闪烁led stop这里的关键设计是led on/off/toggle操作的是LED任务共享的状态变量而led blink 500则涉及一个专门的闪烁任务。整个实现过程中会用到FreeRTOS的signal量或事件标志。我这里选择用二值信号量来通知LED任务去翻转状态用全局变量记录LED目标状态。4.2 完整源码实现与讲解先看LED驱动部分。GD32固件库的GPIO初始化如下void led_gpio_init(void) { rcu_periph_clock_enable(RCU_GPIOC); gpio_init(GPIOC, GPIO_MODE_OUT_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_13); gpio_bit_set(GPIOC, GPIO_PIN_13); // 初始灭 }然后是命令函数static int cmd_led(int argc, char **argv) { if (argc 2) { cli_print(usage: led on|off|toggle|blink [ms]|stop); return -1; } if (strcmp(argv[1], on) 0) { xSemaphoreTake(s_xLedSem, portMAX_DELAY); g_led_state LED_ON; xSemaphoreGive(s_xLedSem); gpio_bit_reset(GPIOC, GPIO_PIN_13); cli_print(led on); } else if (strcmp(argv[1], off) 0) { xSemaphoreTake(s_xLedSem, portMAX_DELAY); g_led_state LED_OFF; xSemaphoreGive(s_xLedSem); gpio_bit_set(GPIOC, GPIO_PIN_13); cli_print(led off); } else if (strcmp(argv[1], toggle) 0) { xSemaphoreTake(s_xLedSem, portMAX_DELAY); g_led_state (g_led_state LED_ON) ? LED_OFF : LED_ON; xSemaphoreGive(s_xLedSem); gpio_bit_write(GPIOC, GPIO_PIN_13, (g_led_state LED_ON) ? RESET : SET); cli_print(led toggle); } else if (strcmp(argv[1], blink) 0) { int period 500; if (argc 3) { period atoi(argv[2]); if (period 50) period 50; if (period 5000) period 5000; } g_blink_period period; g_blink_enable 1; cli_print(led blink start); } else if (strcmp(argv[1], stop) 0) { g_blink_enable 0; gpio_bit_set(GPIOC, GPIO_PIN_13); cli_print(led blink stopped); } else { cli_print(unknown led subcommand); return -1; } return 0; }为了让g_blink_enable的变化可靠地被另一个任务看到这里使用了volatile修饰和信号量保护避免编译器优化导致状态不一致。LED闪烁任务实现如下static void led_blink_task(void *param) { for (;;) { if (g_blink_enable) { gpio_bit_write(GPIOC, GPIO_PIN_13, RESET); vTaskDelay(pdMS_TO_TICKS(g_blink_period / 2)); gpio_bit_write(GPIOC, GPIO_PIN_13, SET); vTaskDelay(pdMS_TO_TICKS(g_blink_period / 2)); } else { vTaskDelay(pdMS_TO_TICKS(50)); } } }在main函数里把任务和信号量创建起来int main(void) { systick_config(); led_gpio_init(); uart0_init(115200); s_xUartQueue xQueueCreate(256, sizeof(uint8_t)); s_xLedSem xSemaphoreCreateBinary(); xTaskCreate(cli_task, cli, 512, NULL, 4, NULL); xTaskCreate(led_blink_task, led_blk, 256, NULL, 3, NULL); vTaskStartScheduler(); for (;;); }任务栈大小按字计算。CLI任务里包含了串口接收、命令解析、字符串格式化用512字比较稳妥LED闪烁任务逻辑简单256字足够。如果你在后面测试中发现任务栈溢出可以先用uxTaskGetStackHighWaterMark()打印历史最小剩余栈再决定是否扩栈。不要盲目把栈开得很大可能挤占其他内核对象的内存。5. 踩坑记录与排查速查表5.1 实测最容易踩的五个坑这套方案看起来简单但实际移植时有几个位置非常容易出问题我逐一说明。第一坑串口输出乱码。我最初把USART0波特率设为115200但GD32的串口时钟源是APB2如果系统时钟配置和固件库默认不一致波特率会偏差。GD32F103的系统时钟默认是108MHz还是72MHz取决于system_gd32f10x.c中的宏定义你要确保__SYSTEM_CLOCK_72M_PLL_HXTAL这类宏与你板载晶振一致。如果晶振是8MHz就对应72MHz主频配置如果晶振是25MHz则要选择对应配置。不然不但串口波特率乱vTaskDelay的毫秒换算也会不准。第二坑命令输完回车没反应。多半是没正确识别回车符。我在调试早期用的上位机是某串口助手默认只发\r而我的解析器只认\n导致命令永远不被触发。后来统一对\r和\n都做结束处理问题消失。另外一个隐蔽点如果你用了scanf或类似带缓冲的库函数回车符会被吞掉所以解析器最好自己处理原始字节流。第三坑输入长命令时卡死或乱回显。这是队列溢出或行缓冲区不足的典型症状。串口中断是很快的如果你是粘贴一大段内容几百个字节瞬间进入队列如果队列只有64字节就可能丢数据。我把队列改成256字节行缓冲区设为128字节后粘贴一整行几十个字符完全无压力。当然如果你需要支持超长命令行configUSE_TRACE_FACILITY和行缓冲要适当加大。第四坑输出被其他任务打断显示错乱。CLI的输出走串口而如果高优先级任务也往串口打印调试信息两个任务并发写串口会出现字符交错。解决方法是给串口发送加互斥锁互斥信号量或者干脆只在CLI任务中统一输出调试信息。我自己的习惯是调试打印全部走CLI命令触发不让任务主动乱打串口这样交互才干净。第五坑系统启动即进HardFault。大部分情况是FreeRTOS的堆分配失败任务创建返回NULL后系统不知道该干什么随后内核启动调度就崩了。排查时在xTaskCreate之后判断返回值一旦创建失败就把configTOTAL_HEAP_SIZE调大或减少任务栈。另外也要检查中断优先级分组GD32上如果NVIC_PRIGROUP和FreeRTOS期望不一致configASSERT会直接帮你指出来。5.2 推荐加的一个系统状态命令sysinfo当你把CLI跑通之后我强烈建议再加一个查看系统状态的命令。这个命令只干两件事打印剩余堆、打印各任务的历史最小栈余量。别小看这两个信息它几乎是所有FreeRTOS运行异常的破案线索。static int cmd_sysinfo(int argc, char **argv) { UBaseType_t i; TaskStatus_t xTaskDetails; cli_print(free heap: %d bytes, (int)xPortGetFreeHeapSize()); for (i 0; i uxTaskGetNumberOfTasks(); i) { vTaskGetInfo(NULL, xTaskDetails, pdTRUE, eInvalid); cli_print(task: %s stack_highwater: %d, xTaskDetails.pcTaskName, (int)xTaskDetails.usStackHighWaterMark); } return 0; }这里用vTaskGetInfo枚举有点绕更简单直接的用法是uxTaskGetStackHighWaterMark(NULL)加任务句柄去查询。但不管哪种方式你都能快速看到哪个任务栈快见底了。有一次我调试一个通信协议栈任务跑一段时间就死机用这个命令一查发现该任务历史最小栈余量只剩几十字节典型的栈溢出问题。把栈从256字加到384字后问题消失。这种问题如果没有CLI你得接调试器打断点慢慢查耗时完全不在一个量级。实操总结与建议做完整个移植后我自己的最大收益其实是形成了“系统可观测”的开发习惯。GD32FreeRTOS不再是一块盲盒串口命令行把系统的内部状态透明地暴露出来。后续我再加传感器驱动、通讯协议、状态机逻辑时第一件事一定是给每个模块注册一组CLI命令用命令行去触发和观察而不是反复烧录。几个后续扩展方向供你参考在命令表中加入flash读写命令用来保存配置参数加入I2C/SPI总线扫描命令调试外设时直接访问寄存器或者把命令结果不回显到串口而是通过RS485/UDP发送到上位机做成远程运维入口。这套CLI骨架不会白搭它会一点点变成你手头最趁手的调试工具。最后再分享一个小技巧如果你在串口助手里输入命令时希望像Linux终端那样有历史记录不要急着在MCU端实现可以先在上位机端手动用脚本处理。很多串口工具本身支持发送历史记录利用好这个功能MCU端解析器保持精简开发和调试体验都不会差。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价