1. 为什么 MCU 项目需要一个趁手的 shell
第一次把 LetterShell 拉进工程时,我的诉求很朴素:板子跑起来之后,能有个地方敲命令、看变量、改参数,而不是每次改一个阈值就重新编译烧录。嵌入式开发里这种场景太常见了——调 PID 要看实时误差,测传感器要临时读寄存器,排查通信问题要手动发一帧数据。如果没有命令行交互,这些动作全都要靠「改代码 + 重新下载 + 复位」三件套,效率低得让人抓狂。
LetterShell 就是解决这个问题的。它是一个用纯 C 写的嵌入式 shell 组件,不依赖操作系统,资源占用小,可以跑在裸机、RTOS 甚至 Linux 上。你给它一个字符输入接口和一个字符输出接口,它就能把命令行交互跑起来。支持命令注册、参数解析、历史记录、Tab 补全、快捷键,还能挂载到串口、USB CDC、RTT 等各种通道上。
它适合谁?适合所有需要在 MCU 上做调试交互的开发者。不管你是用 STM32、GD32、ESP32 还是国产 RISC-V 芯片,只要能把串口收发打通,就能把 LetterShell 接进去。这篇内容聚焦初次上手:给出可复制的配置骨架,讲清楚串口对接和命令表的关键项,然后跑通第一条 shell 命令,确认整条交互链路是通的。
我试过在几个不同平台上接 LetterShell,踩过的坑主要集中在串口中断接收和 shell 任务调度这两块。下面按「先跑通、再优化」的思路来写,你可以直接跟着操作。
2. 把 LetterShell 源码放进工程的前置准备
2.1 获取源码与目录结构
LetterShell 的源码托管在 GitHub 上,直接 clone 或者下载 zip 都行。核心文件不多,初次上手只需要关注这几个:
letter-shell/ ├── src/ │ ├── shell.c # shell 核心逻辑 │ ├── shell.h # 对外接口 │ └── shell_port.c # 移植层,需要你自己实现 ├── ext/ # 扩展功能(可选) │ ├── shell_fs.c # 文件系统命令 │ ├── shell_log.c # 日志命令 │ └── ... └── examples/ # 各平台示例初次接入,把src/shell.c、src/shell.h和src/shell_port.c三个文件加入工程即可。shell_port.c是移植层,里面有几个函数需要你根据实际硬件去填。
2.2 配置项决定资源占用
LetterShell 的行为由shell.h里的一组宏控制。初次上手建议先关注这几个:
| 宏 | 作用 | 建议初值 |
|---|---|---|
SHELL_USING_CMD_EXPORT | 是否用段属性自动注册命令 | 1(方便) |
SHELL_MAX_NUMBER | 最大命令数 | 20 |
SHELL_BUFFER_SIZE | 命令行缓冲区大小 | 128 |
SHELL_HISTORY_MAX_NUMBER | 历史命令条数 | 5 |
SHELL_USING_TAB | 是否启用 Tab 补全 | 1 |
SHELL_USING_FUNC_SIGNATURE | 是否显示函数签名 | 0(省空间) |
这些宏在shell_cfg.h或直接在shell.h里改。初次跑通,用默认值就行,等确认链路正常再按需裁剪。
注意:
SHELL_USING_CMD_EXPORT依赖链接器把命令结构体放到特定段里。如果你用的是 IAR 或 Keil,需要在链接脚本里保留这个段,否则命令注册会失败。GCC 下一般不用额外处理。
2.3 串口收发接口是移植的核心
shell_port.c里需要你实现两个方向的接口:一个是 shell 往外写字符,一个是 shell 从外部读字符。写字符通常是阻塞发送,读字符则有两种模式——查询和中断。
初次上手推荐用中断接收。串口每收到一个字节就丢给 shell,shell 内部自己组包。这样不会丢数据,也不占用主循环。
/* shell_port.c 中的写接口示例 */ void userShellWrite(char *data, unsigned short len) { /* 假设你有一个串口发送函数 uart_send_bytes */ uart_send_bytes(UART_DEBUG, (uint8_t *)data, len); }读接口在中断里调用:
/* 串口中断服务函数里 */ void UART_DEBUG_IRQHandler(void) { if (uart_rx_ready(UART_DEBUG)) { char ch = uart_read_byte(UART_DEBUG); shellHandler(&shell, ch); /* 把字节喂给 shell */ } }shellHandler是 shell 的核心入口,每收到一个字符就调一次。它内部会处理回车、退格、Tab 等控制字符,组好一条完整命令后去命令表里查找并执行。
3. 可复制的配置骨架与命令表写法
3.1 shell 对象定义与初始化
在shell_port.c里定义一个全局 shell 对象,然后在初始化函数里把它挂起来:
#include "shell.h" Shell shell; /* 全局 shell 对象 */ char shellBuffer[512]; /* 命令行缓冲区 */ /* 写接口,shell 通过它输出 */ void userShellWrite(char *data, unsigned short len) { uart_send_bytes(UART_DEBUG, (uint8_t *)data, len); } /* 初始化函数,在 main 里调用 */ void userShellInit(void) { shell.write = userShellWrite; /* 绑定写接口 */ shellInit(&shell, shellBuffer, sizeof(shellBuffer)); }shellInit会把 shell 对象和缓冲区关联起来,并初始化内部状态。缓冲区大小决定了单条命令的最大长度,512 字节对大多数场景够用了。
3.2 命令注册的两种方式
第一种是手动注册,适合命令少、想精确控制顺序的场景:
void shellCmdLedOn(void) { led_on(); shellPrint(&shell, "LED ON\r\n"); } void shellCmdLedOff(void) { led_off(); shellPrint(&shell, "LED OFF\r\n"); } /* 在初始化里注册 */ shellSetCmd(&shell, "ledon", shellCmdLedOn, "turn on led"); shellSetCmd(&shell, "ledoff", shellCmdLedOff, "turn off led");第二种是自动注册,用宏把命令结构体放到特定段里,链接时自动收集:
#include "shell.h" void shellCmdLedOn(void) { led_on(); shellPrint(&shell, "LED ON\r\n"); } SHELL_EXPORT_CMD(SHELL_CMD_PERMISSION(0)|SHELL_CMD_TYPE(SHELL_TYPE_CMD_FUNC), ledon, shellCmdLedOn, turn on led);SHELL_EXPORT_CMD的第一个参数是权限和类型,第二个是命令名,第三个是函数指针,第四个是帮助信息。自动注册的好处是命令和实现放在一起,增删命令不用改初始化代码。
3.3 带参数的命令怎么写
实际调试中,命令往往需要带参数。LetterShell 支持把参数解析成整数、字符串等类型:
void shellCmdSetPid(int kp, int ki, int kd) { pid_set_params(kp / 100.0f, ki / 100.0f, kd / 100.0f); shellPrint(&shell, "PID set: kp=%d ki=%d kd=%d\r\n", kp, ki, kd); } SHELL_EXPORT_CMD(SHELL_CMD_PERMISSION(0)|SHELL_CMD_TYPE(SHELL_TYPE_CMD_FUNC), setpid, shellCmdSetPid, set pid params);在终端里输入setpid 100 20 5,shell 会自动把三个参数解析成整数传给函数。参数类型由函数签名决定,shell 内部用shellExtractParam做转换。
3.4 主循环里要做什么
如果用的是中断接收模式,主循环里其实不需要为 shell 做太多事。但如果你用的是查询模式,或者需要在 shell 之外处理其他任务,可以这样组织:
int main(void) { board_init(); uart_init(UART_DEBUG, 115200); userShellInit(); while (1) { /* 其他任务 */ task_sensor_poll(); task_control_loop(); /* 如果串口是查询模式,在这里喂数据 */ /* if (uart_rx_ready(UART_DEBUG)) { */ /* shellHandler(&shell, uart_read_byte(UART_DEBUG)); */ /* } */ } }中断模式下,shellHandler在中断里被调用,主循环不用管。但要注意:如果 shell 命令执行时间较长,不要在中断里直接执行命令逻辑,否则会阻塞其他中断。LetterShell 默认是在shellHandler里同步执行命令的,所以命令函数要尽量短小,或者把耗时操作丢到主循环里做。
4. 验证请求与成功结果确认
4.1 最小验证动作
烧录之后,打开串口终端(波特率和你初始化的一致,比如 115200,8N1)。复位板子,你应该能看到 shell 的提示符:
LetterShell v3.x.x shell>在提示符后面输入help回车,如果命令表注册成功,会列出所有可用命令:
shell> help Command list: ledon - turn on led ledoff - turn off led setpid - set pid params help - show command list再输入ledon回车,如果 LED 亮了,并且终端打印LED ON,说明整条链路是通的:串口接收 → shell 解析 → 命令执行 → 串口输出。
4.2 用 shellPrint 确认输出通道
shellPrint是 shell 提供的格式化输出函数,用法和printf类似。它内部会调用你绑定的shell.write接口,把数据发出去。如果你在命令函数里调了shellPrint但终端没显示,先检查shell.write有没有正确绑定。
void shellCmdTest(void) { shellPrint(&shell, "test ok, tick=%d\r\n", get_tick()); } SHELL_EXPORT_CMD(SHELL_CMD_PERMISSION(0)|SHELL_CMD_TYPE(SHELL_TYPE_CMD_FUNC), test, shellCmdTest, test command);输入test,终端应该打印test ok, tick=12345。如果只看到命令回显但没有输出,多半是写接口的问题。
4.3 验证 Tab 补全和历史记录
输入led然后按 Tab 键,如果补全功能开启,会自动补成ledon或列出ledon、ledoff两个候选。按上下方向键可以翻历史命令。这两个功能能正常工作,说明 shell 的缓冲区管理和按键解析都没问题。
5. 本篇常见错排查
5.1 终端没有任何输出
先确认串口参数:波特率、数据位、停止位、校验位。LetterShell 本身不关心这些,它只负责字符流,但串口配置错了就什么都收不到。用示波器或者逻辑分析仪看 TX 引脚有没有波形,是最直接的判断方法。
如果 TX 有波形但终端是乱码,检查波特率是否匹配。如果 TX 没波形,检查shell.write有没有被调用,以及串口发送函数本身是否正常。
5.2 输入字符没有回显
LetterShell 默认会回显输入的字符。如果没有回显,说明shellHandler没有被调用,或者串口接收中断没进。检查中断服务函数里有没有调用shellHandler,以及串口接收中断是否使能。
另一个可能:你用的是查询模式,但主循环里没有喂数据。确认主循环里有shellHandler(&shell, uart_read_byte(...))这样的调用。
5.3 命令注册了但 help 里看不到
如果用SHELL_EXPORT_CMD自动注册,检查链接脚本有没有保留命令段。GCC 下通常用__attribute__((section("shellCommand"))),如果链接器把未引用的段优化掉了,命令就会丢失。可以在链接脚本里加KEEP(*(shellCommand))。
如果用shellSetCmd手动注册,检查注册代码有没有被执行到。有时候初始化顺序不对,shell 还没初始化就注册命令,会导致注册失败。
5.4 命令执行到一半卡死
多半是命令函数里做了阻塞操作,比如死循环等待某个标志位。LetterShell 在中断里同步执行命令,如果命令不返回,中断就出不来,整个系统都会卡住。解决办法是把耗时操作拆成状态机,或者用 shell 的异步命令机制(如果版本支持)。
另外检查栈空间:shell 命令执行时用的是当前栈,如果命令函数里局部变量太大,可能栈溢出。增大任务栈或者减少局部变量。
5.5 中文或特殊字符显示异常
LetterShell 默认按字节处理,不关心编码。如果你的终端是 UTF-8,而代码里发的是 GBK,就会乱码。统一用 ASCII 或者确保两端编码一致。shellPrint本身不做编码转换,发什么字节就显示什么。
6. 把 shell 接入你的调试工作流
跑通第一条命令之后,LetterShell 的价值才真正开始体现。你可以把常用的调试动作都做成命令:读寄存器、改参数、触发校准、打印状态。配合 Tab 补全和历史记录,调试效率会比反复烧录高很多。
如果你在多个项目里都用 LetterShell,可以考虑把shell_port.c做成一个可复用的模块,串口收发用弱符号或者回调注册,这样换平台时只需要改硬件相关的几行代码。
对于需要长期做嵌入式编码和 Agent 调试的场景,可以把 shell 命令和上层工具链结合起来,用 Coding Plan 管理你的调试脚本和命令集,减少重复配置。接入文档里有具体的接口说明和示例,API Keys 页面可以拿到调用凭证。模型对话入口适合快速验证命令逻辑,不用每次都烧到板子上。
先把help、ledon、test这三条命令跑通,确认串口收发、命令解析、输出回显都正常,剩下的就是按你的项目需求往里加命令了。