一句话结论:一个能用的串口 shell,核心不是「命令多」,而是三件事——命令表是纯数据(放 Flash 不占 RAM,help 自动生成)、输入解析是一个显式三态状态机(所以方向键的 ANSI 转义不会把 shell 卡死)、ISR 里只做「收字节进缓冲」,命令执行留给主循环(所以永远不在中断里 printf)。本文实测:6 条命令、行缓冲 80 字节、历史 8 条,全部 RAM 开销 1304 字节;支持引号参数(含词中间的引号拼接)、Tab 唯一补全与多义列举、退格/左右键/Delete、历史上下翻、行溢出保护(200 字符收 79 丢 121),以及被截断的转义序列之后仍能继续正常执行命令。
一、完整工程下载
压缩包内含全部源码、platformio.ini、Makefile、README.md,解压即用,不需要额外配置。
下载 shell-cli.zip (22.7 KB,共 8 个文件)
.gitignore
Makefile
README.md
include/
shell.h
platformio.ini
src/
main.c
shell.c
test/
test_shell.c一、先看那个「能跑但撑不住」的写法
大多数嵌入式调试串口的起点都是这样:
if (strcmp(buf, "test") == 0) {
printf("ok\n");
} else if (strcmp(buf, "read") == 0) {
printf("%d\n", adc_read());
} else if (strncmp(buf, "set ", 4) == 0) {
int v = atoi(buf + 4);
set_value(v);
} else if (strcmp(buf, "help") == 0) {
printf("test / read / set / help\n"); /* 手工维护,必然和代码不同步 */
}它能用。它的崩溃点也不在「命令太多」,而在这几件事上:
help是手写的字符串,加一条命令就得记着改一遍——最后一定不同步set 12 34这种多个参数,靠atoi(buf + 4)硬算偏移,加个空格就废- 参数带空格(文件名、备注)完全没法传
- 没有退格:打错一个字只能整行重打
- 没有历史:同一条命令要重打十遍
- 方向键是灾难:按下上键,终端发来
\x1b[A三个字节,
你的 strcmp 把它当成一条叫作「ESC [ A」的命令,回你一句「unknown command」, 屏幕上还会多出诡异的 ^[[A
- 在中断里 printf:如果你的输入是在 UART ISR 里处理的,
那 printf 就在中断上下文里跑,把主循环的时序全打乱
本文把这七件事逐条解决。做法不是「写更多 if」,而是把命令和解析都变成数据 + 状态机。
二、命令表:让 help 自动生成
命令描述成一个 const 结构体数组,纯数据,零函数指针以外的逻辑:
typedef struct {
const char *name; /* 命令名 */
const char *usage; /* 用法提示,help 直接用 */
const char *help; /* 一句话说明 */
shell_cmd_fn fn; /* 处理函数 */
uint8_t min_args; /* 最少参数个数(不含命令名) */
uint8_t max_args; /* 最多参数个数 */
} shell_cmd_t;于是 help 的实现变成「遍历这张表」:
static void cmd_help(shell_t *sh, int argc, char **argv)
{
if (argc >= 2) {
const shell_cmd_t *c = find_cmd(sh, argv[1]);
if (c == NULL) { out_unknown(sh, argv[1]); return; }
wf(sh, "用法: %s %s\n", c->name, c->usage); /* usage 直接来自表 */
wf(sh, " %s\n", c->help);
return;
}
for (i = 0; i < sh->ncmds; i++) {
wf(sh, " %-8s %s\n", sh->cmds[i].name, sh->cmds[i].usage);
}
}加一条命令 = 表里加一行 + 写一个函数,help 自动就有了。 min_args / max_args 也放在表里,参数个数校验就统一到 dispatch 里做一次:
if (argc - 1 < c->min_args) { wf(sh, "参数太少: %s %s\n", c->name, c->usage); sh->n_err_args++; return; }
if (c->max_args != SHELL_ARGS_ANY && argc - 1 > c->max_args) { ... sh->n_err_args++; return; }这样每个命令的 handler 里就不需要再写一遍参数检查——校验过一次,就只写一次。
三、输入解析:一个显式状态机
方向键、Home、End、Delete 在终端里发过来的都是 ANSI 转义序列:
上 \x1b [ A
下 \x1b [ B
右 \x1b [ C
左 \x1b [ D
Home \x1b [ H
End \x1b [ F
Del \x1b [ 3 ~关键是:这些序列是分三个字节到达的。串口一次只给你一个字节, 所以 shell_putc() 必须记住「上一个字节是 ESC」,否则无法区分 「用户按了上键」和「用户按了 ESC 然后打了 [ 然后打了 A」。
做法就是一个三态状态机:
普通字符
┌──────────────┐
│ ▼
┌─────────┐ \x1b ┌─────────┐ '[' ┌─────────┐
│ NORMAL ├───────►│ ESC ├────────►│ CSI │
└─────────┘ └────┬────┘ └────┬────┘
▲ │ 其它字符 │ 终结字节(0x40~0x7E)
│ │(裸 ESC: │
└──────────────────┘ 当成普通字符重放)│
└──────────────────────────────────────┘第三个状态(CSI = Control Sequence Introducer)还要累积数字参数, 因为 \x1b[3~ 里有个 3。终结字节是 0x40~0x7E 范围内的字符, 用它来判定「这个转义序列结束了」是标准做法。
中间那个「裸 ESC」分支特别重要:如果只按了一下 ESC(或者串口被干扰, 把一个转义序列截断了),状态机不能卡在 ESC 状态里等 [——那样后面所有输入都会 被当成转义序列的一部分吃掉,shell 看起来就死了。正确做法是: 遇到 ESC 后面的字符不是 [,就退回 NORMAL 并把那个字符当成普通输入重新处理一遍。
四、ISR 与主循环的分工
这是嵌入式 shell 和 PC 上 shell 最大的结构差别:
UART 中断(ISR) 主循环
────────────────── ──────────────────
收到一个字节 if (shell_line_ready(&sh)) {
-> shell_putc(&sh, c) 解析 argv
只做:写行缓冲、状态机、 查表
置 line_ready 标志 校验参数
不做:printf、malloc、 执行 handler
查表、执行命令 shell_clear_ready(&sh)
}shell_putc() 里一个 printf 都没有——它只往行缓冲里写字节, 需要回显时调用的也是「把这段字节丢进发送环」的轻量函数,不是 printf。 后面第七节会看到这个划分在实测里怎么验证。
五、参数解析:引号、转义、原地切分
tokenize() 直接在行缓冲上原地切分(把分隔符改成 \0),不分配任何内存:
static int tokenize(char *s, char **argv, int max)
{
int argc = 0;
char *p = s;
while (*p != 0) {
char *w;
char *sep;
while (*p == ' ' || *p == '\t') p++; /* 跳过前导空白 */
if (*p == 0) break;
if (argc >= max) break; /* 参数过多:丢弃多余的 */
argv[argc++] = p;
w = p; /* 写指针:压缩后的内容写这里 */
while (*p != 0 && *p != ' ' && *p != '\t') {
if (*p == '"' || *p == '\'') { /* 引号包裹:里面的空格保留 */
char q = *p++; /* 吃掉开引号 */
while (*p != 0 && *p != q) {
if (*p == '\\' && (*(p + 1) == q || *(p + 1) == '\\')) p++;
*w++ = *p++; /* 原地压缩:w 永远 <= p */
}
if (*p == q) p++; /* 吃掉闭引号 */
} else {
*w++ = *p++;
}
}
sep = (*p != 0) ? p + 1 : p;
*w = 0;
p = sep;
}
return argc;
}三个重点:
argc到上限就break,不是返回错误。行缓冲本身有长度上限,
参数个数天然有界,超了就丢弃多余的并继续执行——比让 shell 报错更好用。
- 引号内的
\"和\\会原地「左移」压缩:因为写指针w永远<=读指针p,
所以可以边读边写,不需要临时缓冲。
- 引号没闭合时,这个参数到此为止,而不是报语法错误。现场调试时,
报错打断你的输入比宽容处理更烦人。
这一段我在实测里真踩了两个坑,都写下来:
坑 1:不要按「是不是引号开头」分两个分支。 我第一版就是这么写的—— 先判断这个词是不是以引号开头,是就走引号分支,不是就扫到下一个空格。 结果 echo pre"mid dle"post 会走普通分支,在引号里的那个空格处断开, 被切成 pre"mid 和 dle"post 两个参数,引号本身也被留在了结果里。 正确做法是一个词一个循环,碰到引号就进引号内循环,出来继续处理同一个词。
坑 2:写结尾 \0 之前要先记下分隔符后面的位置。 写成
*w = 0; /* 先写 */
if (*p != 0) { *p = 0; p++; } /* 再取 p + 1 —— 错的 */在「压缩后没有位移」(w == p,即这个词里没有引号)时就会出问题: *w = 0 恰好把分隔符本身改成了 \0,于是 *p != 0 判断失败、p 停在原地不动, 后面所有参数全丢。这就是 sep = (*p != 0) ? p + 1 : p; 这一行存在的原因—— 顺序不能反。
六、Tab 补全:唯一就补全,多义就列举
on_tab(sh):
取当前行的第一个词
在命令表里找前缀匹配
1 个匹配 -> 补完整名字 + 一个空格(方便直接接着打参数)
多个匹配 -> 先换行列出所有候选,再 \r 重画 prompt + 当前行
0 个匹配 -> 什么都不做(不要报错,太吵)「唯一补全后自动加一个空格」这个细节很关键——否则你补全完还得自己敲空格。
七、主机实测(本文数据来源)
配置:6 条命令、行缓冲 80 字节、历史 8 条、最多 8 个参数。 测试方式是把字节流直接喂给 shell_putc()(模拟串口来的字符), 再在 shell_poll() 之后检查输出缓冲区里真实的文字。
RAM 开销(这是单片机上最该先看的数字):
| 项目 | 字节 |
|---|---|
| 行缓冲 | 80 |
| 历史 8 条 | 640 |
| 翻历史时的暂存行 | 80 |
| 变量表 8 项 | 392 |
| 其它(指针、计数器、状态机) | 112 |
| 合计 | 1304 |
命令表本身是 const,放 Flash,不占这 1304 字节。
| # | 实验 | 实测结果 |
|---|---|---|
| 1 | 基本执行 | 只喂字符不回车的屏幕是 $ echo hi;此时 shell_poll() 返回 0(不执行);回车后返回 1 并输出 hi |
| 2 | 参数解析 | echo a b c → a b c;echo "hello world" → hello world;echo 'x y' → x y(双空格保留);echo "a\"b" → a"b;echo "" tail → tail;echo pre"mid dle"post → premid dlepost |
| 3 | 参数个数校验 | set → 参数太少。用法: set key=value;stat extra → 参数太多;echo 传 12 个参数 → 截断成 7 个(a b c d e f g)但命令仍执行 |
| 4 | set / show | set led=on → led = on;show 列出两个变量;show nosuchkey → nosuchkey 未定义;set notvalid(没有等号)→ 提示格式 |
| 5 | 未知命令 | hel → 未知命令: hel + 是不是想用: help;xyz → 无建议(不做乱猜) |
| 6 | 退格 | set k=abc + 两次退格 + XY → k = aXY |
| 7 | 方向键 | 左键后插入 → echo abc;set k=QXXZ + 三个左键 + 两次 Delete → k = QZ |
| 8 | 历史 | 上键取到最近一条并重画整屏;再上键取更早一条;直接回车执行它;下键回到空行;连按 12 次上键(历史只有 8 条)时 hist_idx 停在 8,不越界;连打两次同一条命令,历史里只出现 1 次 |
| 9 | 行缓冲溢出 | 连喂 200 个字符 → 收下 79 个,溢出 121 次,不越界;溢出后回车照常报未知命令而不是崩 |
| 10 | Tab 补全 | he+Tab → 唯一匹配,补成 help 并自动加空格;s+Tab → 列出 set show stat 三个候选且不动当前行;zz+Tab → 只有 1 字节响铃,不报错;在参数位置按 Tab → 不补全 |
| 11 | Ctrl-C / 裸 ESC | Ctrl-C 中止当前行且回车后什么都不执行;单独一个 ESC 后打 ok → 被正常当成 ok;ESC [ 3 断线后仍能复位到 NORMAL 并继续正常执行 echo after;未定义的 ESC [ 99 Z 不卡死 |
| 12 | 重画成本 | 退格一次发出 20 字节(\r + prompt + 整行 + \x1b[K);80 字节行缓冲的最坏情况是 114 字节 |
| 13 | 统计接口 | stat 输出 输入字节 703,行数 37,转义序列 37 / Tab 4,上翻 11,中止 1 / 未知命令 5,参数不对 2,行溢出 121 |
第 10 项和第 11 项是这套 shell 里最容易被忽略、但现场最影响体验的两件事:
第 10 项的「多义时不动当前行」:很多实现会在 Tab 之后把当前行改掉, 于是你打完 s 按 Tab 列出一堆候选,再想补一个 e 就变成了别的东西。 正确行为是「列出候选,然后把 s 原样留着」——这样你可以继续敲字母收敛到唯一匹配。
第 11 项的「永远能回到 NORMAL」:串口是不可靠的。 电平干扰、线松了一下、终端程序被 kill,都可能把一个三字节的转义序列砍成两字节。 如果状态机停在 CSI 状态等终结字节,那么后面所有的正常输入都会被当成转义序列吃掉, 从用户角度看就是「shell 突然不回我了」。所以实测里专门验证的是这几件: 遇到未知终结字节要复位、被截断之后要复位、并且紧接着的一条完整命令必须能正常执行。
完整代码
Makefile
CC ?= gcc
CFLAGS ?= -std=c99 -Wall -Wextra -O2 -Iinclude
LDLIBS ?=
SRC = src/shell.c
TEST = test/test_shell.c
ifeq ($(OS),Windows_NT)
EXT = .exe
endif
BIN = build/test$(EXT)
all: run
$(BIN): $(SRC) $(TEST)
@mkdir -p build
$(CC) $(CFLAGS) $(SRC) $(TEST) -o $(BIN) $(LDLIBS)
run: $(BIN)
@$(BIN)
clean:
rm -rf build
.PHONY: all run cleaninclude/shell.h
/**
* shell.h - 串口命令行(行编辑 / 历史 / Tab 补全 / ANSI 转义)
*
* 设计要点:
* 1) 命令表是纯 const 数据,help 由表生成,不会和代码不同步。
* 2) 参数解析在行缓冲上**原地**切分,不分配内存。
* 3) 输入是一个显式三态状态机(NORMAL / ESC / CSI),
* 裸 ESC 不会让 shell 卡死。
* 4) ISR 里只调 shell_putc()(不 printf、不查表),
* 命令执行由主循环的 shell_poll() 做。
*/
#ifndef SHELL_H
#define SHELL_H
#include <stddef.h>
#include <stdint.h>
#ifndef SHELL_LINE_MAX
#define SHELL_LINE_MAX 80 /* 行缓冲(含结尾 0) */
#endif
#ifndef SHELL_HIST_MAX
#define SHELL_HIST_MAX 8 /* 历史条数 */
#endif
#ifndef SHELL_ARGV_MAX
#define SHELL_ARGV_MAX 8 /* 最多参数个数 */
#endif
#ifndef SHELL_KV_MAX
#define SHELL_KV_MAX 8 /* 演示用变量表 */
#endif
#define SHELL_ARGS_ANY 0xFFu /* max_args 用这个表示「不限」 */
struct shell;
typedef int (*shell_out_fn)(void *ctx, const char *s, int len);
typedef void (*shell_cmd_fn)(struct shell *sh, int argc, char **argv);
typedef struct {
const char *name;
const char *usage;
const char *help;
shell_cmd_fn fn;
uint8_t min_args;
uint8_t max_args;
} shell_cmd_t;
typedef enum {
SH_IN_NORMAL = 0,
SH_IN_ESC,
SH_IN_CSI
} shell_in_state_t;
typedef struct {
char key[16];
char val[32];
uint8_t used;
} shell_kv_t;
typedef struct shell {
const shell_cmd_t *cmds;
int ncmds;
/* 行缓冲 */
char line[SHELL_LINE_MAX];
uint16_t len; /* 当前行长度(不含结尾 0) */
uint16_t cursor; /* 光标位置(从 0 开始) */
/* 历史:history[0] 是最新的一条 */
char history[SHELL_HIST_MAX][SHELL_LINE_MAX];
uint8_t hist_count;
uint8_t hist_idx; /* 0 = 没在翻历史;k = 正在看第 k 条 */
char saved[SHELL_LINE_MAX]; /* 翻历史前暂存的当前行 */
/* 输入状态机 */
shell_in_state_t in_state;
uint16_t csi_param;
/* 待执行标志(ISR 置位,主循环消费) */
volatile int line_ready;
volatile int aborted;
int echo;
int redraw_bytes; /* 每次重画花了多少字节 */
char prompt[8];
shell_out_fn out;
void *out_ctx;
shell_kv_t vars[SHELL_KV_MAX];
uint8_t nvars;
/* 统计 */
uint32_t n_bytes;
uint32_t n_lines;
uint32_t n_esc;
uint32_t n_overflow;
uint32_t n_err_cmd;
uint32_t n_err_args;
uint32_t n_tab;
uint32_t n_hist_up;
uint32_t n_aborted;
} shell_t;
/** 初始化。out 是输出回调(在 ISR 里会被调用 -> 里面不要 printf)。 */
void shell_init(shell_t *sh, const shell_cmd_t *cmds, int ncmds,
shell_out_fn out, void *out_ctx);
/** 送一个字节进来。**可以在 UART 中断里调用**:不 printf、不查表、不执行命令。 */
void shell_putc(shell_t *sh, char c);
/** 主循环里调用:有完整命令行就执行掉。返回执行了几条命令。 */
int shell_poll(shell_t *sh);
/** 打印提示符(初始化后和每次执行完调一次) */
void shell_prompt(shell_t *sh);
/** 输出辅助(等价于 fprintf,但走 out 回调) */
void shell_printf(shell_t *sh, const char *fmt, ...);
static inline int shell_line_ready(const shell_t *sh) { return sh->line_ready; }
/* ---------------- 内置命令 ---------------- */
void cmd_help(shell_t *sh, int argc, char **argv);
void cmd_echo(shell_t *sh, int argc, char **argv);
void cmd_set(shell_t *sh, int argc, char **argv);
void cmd_show(shell_t *sh, int argc, char **argv);
void cmd_stat(shell_t *sh, int argc, char **argv);
void cmd_reboot(shell_t *sh, int argc, char **argv);
/** 平台相关:真的重启(主机测试上是空实现) */
void shell_platform_reboot(void);
extern const shell_cmd_t SHELL_CMDS[];
extern const int SHELL_NCMDS;
#endif /* SHELL_H */platformio.ini
[platformio]
default_envs = bluepill
[env:bluepill]
platform = ststm32
board = bluepill_f103c8
framework = arduino
upload_protocol = stlink
monitor_speed = 115200
build_flags =
-Wall
-Wextra
-Isrc
lib_ldf_mode = deep+src/main.c
/**
* main.c - STM32 上把 shell 接上串口
*
* 分工:
* USART1 中断 -> shell_putc() 只收字节
* 主循环 -> shell_poll() 执行命令
*/
#include "shell.h"
#include <string.h>
static shell_t g_shell;
/* ---------------- 平台相关(STM32F103) ---------------- */
#define RCC_APB2ENR (*(volatile uint32_t *)0x40021018u)
#define GPIOA_CRH (*(volatile uint32_t *)0x40010804u)
#define USART1_SR (*(volatile uint32_t *)0x40013800u)
#define USART1_DR (*(volatile uint32_t *)0x40013804u)
#define USART1_BRR (*(volatile uint32_t *)0x40013808u)
#define USART1_CR1 (*(volatile uint32_t *)0x4001380Cu)
#define NVIC_ISER1 (*(volatile uint32_t *)0xE000E104u)
static void uart_init(void)
{
RCC_APB2ENR |= (1u << 2) | (1u << 14); /* GPIOA + USART1 */
GPIOA_CRH = (GPIOA_CRH & ~(0xFFu << 4)) | (0x0Bu << 4) | (0x04u << 8);
USART1_BRR = 8000000u / 115200u; /* 8 MHz APB2 -> 115200 */
USART1_CR1 = (1u << 13) | (1u << 3) | (1u << 2); /* UE | TE | RE */
NVIC_ISER1 |= (1u << (37u - 32u)); /* USART1_IRQn = 37 */
}
void USART1_IRQHandler(void)
{
/* 中断里只有一件事:把字节交给 shell。不 printf、不查表、不执行命令。 */
if ((USART1_SR & (1u << 5)) != 0u) {
char c = (char)(USART1_DR & 0xFFu);
shell_putc(&g_shell, c);
}
}
/* RAM 占用:行缓冲 + 历史。这两块是 shell 的固定开销,算得出来。 */
static char g_tx_buf[256];
static volatile int g_tx_head;
static volatile int g_tx_tail;
static int uart_out(void *ctx, const char *s, int len)
{
int i;
(void)ctx;
for (i = 0; i < len; i++) {
int next = (g_tx_head + 1) % (int)sizeof(g_tx_buf);
if (next == g_tx_tail) break; /* 发送环满:丢字节,绝不阻塞 */
g_tx_buf[g_tx_head] = s[i];
g_tx_head = next;
}
USART1_CR1 |= (1u << 7); /* TXEIE:让中断把环里内容发出去 */
return i;
}
int main(void)
{
uart_init();
shell_init(&g_shell, SHELL_CMDS, SHELL_NCMDS, uart_out, (void *)0);
shell_prompt(&g_shell);
for (;;) {
shell_poll(&g_shell); /* 命令都在这里执行,不在中断里 */
}
}src/shell.c
/**
* shell.c - 串口命令行实现
*
* 本文件里 shell_putc() 是「中断安全」的部分:只有状态机 + 写缓冲,
* 没有 printf / 没有查表 / 没有执行命令。命令执行全在 shell_poll() 里。
*/
#include <stdarg.h>
#include <stdio.h>
#include <string.h>
#include "shell.h"
/* ---------------- 输出 ---------------- */
void shell_printf(shell_t *sh, const char *fmt, ...)
{
char buf[192];
va_list ap;
int n;
va_start(ap, fmt);
n = vsnprintf(buf, sizeof(buf), fmt, ap);
va_end(ap);
if (n < 0) return;
if ((size_t)n >= sizeof(buf)) n = (int)sizeof(buf) - 1;
if (sh->out != NULL) sh->out(sh->out_ctx, buf, n);
}
static void out_str(shell_t *sh, const char *s)
{
if (s != NULL && sh->out != NULL) {
sh->out(sh->out_ctx, s, (int)strlen(s));
}
}
/* ---------------- 重画当前行 ----------------
*
* 每次编辑(退格、翻历史、补全)都要把屏幕上的这一行重画一遍:
* \r + prompt + 行内容 + \x1b[K(擦掉行尾残留)
* 注意必须发 \x1b[K,否则「原来长、现在短」时会留下尾巴。
*/
static void redraw(shell_t *sh)
{
char tail[8];
int n;
out_str(sh, "\r");
out_str(sh, sh->prompt);
if (sh->len > 0u) {
if (sh->out != NULL) sh->out(sh->out_ctx, sh->line, (int)sh->len);
}
/* 光标不在行尾时,往左退回去 */
n = 0;
while (n < (int)(sh->len - sh->cursor) && n < 30) {
tail[n] = '\b';
n++;
}
if (n > 0) sh->out(sh->out_ctx, tail, n);
out_str(sh, "\x1b[K"); /* erase to end of line */
sh->redraw_bytes = 3 + (int)strlen(sh->prompt) + (int)sh->len + n;
}
/* ---------------- 行缓冲操作 ---------------- */
static void line_clear(shell_t *sh)
{
sh->len = 0u;
sh->cursor = 0u;
sh->line[0] = 0;
}
static void line_insert(shell_t *sh, char c)
{
uint16_t i;
if (sh->len + 1u >= SHELL_LINE_MAX) {
sh->n_overflow++;
if (sh->echo) {
sh->out(sh->out_ctx, "\a", 1); /* 响铃:满了 */
}
return;
}
for (i = sh->len; i > sh->cursor; i--) {
sh->line[i] = sh->line[i - 1u];
}
sh->line[sh->cursor] = c;
sh->cursor++;
sh->len++;
sh->line[sh->len] = 0;
}
static void line_backspace(shell_t *sh)
{
uint16_t i;
if (sh->cursor == 0u) return;
for (i = sh->cursor - 1u; i < sh->len - 1u; i++) {
sh->line[i] = sh->line[i + 1u];
}
sh->cursor--;
sh->len--;
sh->line[sh->len] = 0;
}
/* 单字节版:光标左右移动(不自增,交给 redraw 统一处理) */
static void line_mov_left(shell_t *sh)
{
if (sh->cursor > 0u) sh->cursor--;
else sh->out(sh->out_ctx, "\a", 1);
}
static void line_mov_right(shell_t *sh)
{
if (sh->cursor < sh->len) sh->cursor++;
else sh->out(sh->out_ctx, "\a", 1);
}
static void line_delete(shell_t *sh) /* Delete 键:删光标右侧那个字符 */
{
uint16_t i;
if (sh->cursor >= sh->len) { sh->out(sh->out_ctx, "\a", 1); return; }
for (i = sh->cursor; i < sh->len - 1u; i++) {
sh->line[i] = sh->line[i + 1u];
}
sh->len--;
sh->line[sh->len] = 0;
}
/* ---------------- 历史 ---------------- */
static void hist_push(shell_t *sh, const char *s)
{
int i;
if (s == NULL || s[0] == 0) return;
/* 和最新一条完全相同就不重复记(连按两次回车不该占两条历史) */
if (sh->hist_count > 0u && strcmp(sh->history[0], s) == 0) return;
for (i = (int)SHELL_HIST_MAX - 1; i > 0; i--) {
memcpy(sh->history[i], sh->history[i - 1], SHELL_LINE_MAX);
}
strncpy(sh->history[0], s, SHELL_LINE_MAX - 1u);
sh->history[0][SHELL_LINE_MAX - 1u] = 0;
if (sh->hist_count < SHELL_HIST_MAX) sh->hist_count++;
}
static void hist_load(shell_t *sh, uint8_t idx)
{
sh->hist_idx = idx;
if (idx == 0u) {
memcpy(sh->line, sh->saved, SHELL_LINE_MAX); /* 回到「还没翻历史」的那行 */
} else {
memcpy(sh->line, sh->history[idx - 1u], SHELL_LINE_MAX);
}
sh->len = (uint16_t)strlen(sh->line);
sh->cursor = sh->len;
}
static void hist_up(shell_t *sh)
{
if (sh->hist_count == 0u) { sh->out(sh->out_ctx, "\a", 1); return; }
if (sh->hist_idx == 0u) {
memcpy(sh->saved, sh->line, SHELL_LINE_MAX); /* 第一次翻:暂存当前行 */
} else if (sh->hist_idx >= sh->hist_count) {
sh->out(sh->out_ctx, "\a", 1); /* 已经到最老一条 */
return;
}
hist_load(sh, (uint8_t)(sh->hist_idx + 1u));
sh->n_hist_up++;
}
static void hist_down(shell_t *sh)
{
if (sh->hist_idx == 0u) { sh->out(sh->out_ctx, "\a", 1); return; }
hist_load(sh, (uint8_t)(sh->hist_idx - 1u));
}
/* ---------------- Tab 补全 ---------------- */
static int prefix_len(const char *line, uint16_t len)
{
int n = 0;
while (n < (int)len && line[n] != ' ' && line[n] != '\t') n++;
return n;
}
static void do_tab(shell_t *sh)
{
char word[SHELL_LINE_MAX];
int wl = prefix_len(sh->line, sh->len);
int i, hits = 0, last = -1;
sh->n_tab++;
/* 只在「第一个词」上补全;已经在打参数了就不动 */
if (sh->len != (uint16_t)wl) { sh->out(sh->out_ctx, "\a", 1); return; }
if (wl <= 0) { sh->out(sh->out_ctx, "\a", 1); return; }
memcpy(word, sh->line, (size_t)wl);
word[wl] = 0;
for (i = 0; i < sh->ncmds; i++) {
if (strncmp(sh->cmds[i].name, word, (size_t)wl) == 0) {
hits++;
last = i;
}
}
if (hits == 0) { sh->out(sh->out_ctx, "\a", 1); return; }
if (hits == 1) {
/* 唯一匹配:补完整名 + 一个空格(省得你还得自己敲空格) */
const char *nm = sh->cmds[last].name;
size_t n = strlen(nm);
if ((size_t)wl + 1u + 1u >= SHELL_LINE_MAX) return;
memcpy(sh->line, nm, n);
sh->line[n] = ' ';
sh->line[n + 1u] = 0;
sh->len = (uint16_t)(n + 1u);
sh->cursor = sh->len;
redraw(sh);
} else {
/* 多义:换行列出所有候选,再重画当前行 */
out_str(sh, "\r\n");
for (i = 0; i < sh->ncmds; i++) {
if (strncmp(sh->cmds[i].name, word, (size_t)wl) == 0) {
shell_printf(sh, " %s", sh->cmds[i].name);
}
}
out_str(sh, "\r\n");
redraw(sh);
}
}
/* ---------------- 命令查找 ---------------- */
static const shell_cmd_t *find_cmd(shell_t *sh, const char *name)
{
int i;
for (i = 0; i < sh->ncmds; i++) {
if (strcmp(sh->cmds[i].name, name) == 0) return &sh->cmds[i];
}
return NULL;
}
/*
* 找不到命令时给个提示:找「参数名是输入前缀」的命令。
* 不做编辑距离(那是 PC 上 shell 的做法),因为在单片机上
* 最常见的手误就是少打了几个字母。
*/
static void out_unknown(shell_t *sh, const char *name)
{
int i, shown = 0;
size_t nl = strlen(name);
sh->n_err_cmd++;
shell_printf(sh, "未知命令: %s\n", name);
for (i = 0; i < sh->ncmds && shown < 3; i++) {
if (nl > 0u && strncmp(sh->cmds[i].name, name, nl) == 0) {
if (shown == 0) out_str(sh, " 是不是想用: ");
shell_printf(sh, "%s ", sh->cmds[i].name);
shown++;
}
}
if (shown > 0) out_str(sh, "\n");
out_str(sh, " 输入 help 看全部命令\n");
}
/* ---------------- 参数解析(原地切分) ---------------- */
/*
* 原地切分:把分隔符改成 '\0',把引号「压缩」掉(读指针 r >= 写指针 w,
* 所以可以边读边写)。不分配任何内存。
*
* 注意这里**不能**写成「先判断是不是引号开头的词」那种两分支写法——
* 那样 pre"mid dle"post 会被空格切错。正确做法是逐字符走,
* 遇到引号就进入引号内循环,出来继续处理同一个词。
*/
static int tokenize(char *s, char **argv, int max)
{
int argc = 0;
char *p = s;
while (*p != 0) {
char *w;
char *sep;
while (*p == ' ' || *p == '\t') p++; /* 跳过前导空白 */
if (*p == 0) break;
if (argc >= max) break; /* 参数过多:丢弃多余的 */
argv[argc++] = p;
w = p; /* 写指针,压缩写回这里 */
while (*p != 0 && *p != ' ' && *p != '\t') {
if (*p == '"' || *p == '\'') {
char q = *p++; /* 吃掉开引号 */
while (*p != 0 && *p != q) {
if (*p == '\\' && (*(p + 1) == q || *(p + 1) == '\\')) p++;
*w++ = *p++;
}
if (*p == q) p++; /* 吃掉闭引号 */
/* 没闭合:内层循环因 *p == 0 退出,整个词到此结束 */
} else {
*w++ = *p++;
}
}
/* 先记下分隔符后面那个位置,再写结尾 0 ——
* 顺序反了的话,w == p 时会把分隔符写成 0,然后误以为到了行尾,
* 后面的参数就全丢了。 */
sep = (*p != 0) ? p + 1 : p;
*w = 0;
p = sep;
}
return argc;
}
/* ---------------- 状态机 ---------------- */
void shell_init(shell_t *sh, const shell_cmd_t *cmds, int ncmds,
shell_out_fn out, void *out_ctx)
{
memset(sh, 0, sizeof(*sh));
sh->cmds = cmds;
sh->ncmds = ncmds;
sh->out = out;
sh->out_ctx = out_ctx;
sh->echo = 1;
sh->in_state = SH_IN_NORMAL;
strncpy(sh->prompt, "$ ", sizeof(sh->prompt) - 1u);
}
void shell_prompt(shell_t *sh)
{
if (sh->echo) out_str(sh, sh->prompt);
}
/* 把一行提交出去:置标志,真正的执行在主循环里做 */
static void submit_line(shell_t *sh)
{
sh->len = (uint16_t)strlen(sh->line);
sh->cursor = sh->len;
if (sh->len > 0u) {
hist_push(sh, sh->line);
}
sh->hist_idx = 0u;
sh->line_ready = 1; /* ← 只有这一步是「通知主循环」 */
}
void shell_putc(shell_t *sh, char c)
{
sh->n_bytes++;
/* ---- 状态机部分:完全在中断上下文里跑,只写缓冲 ---- */
switch (sh->in_state) {
case SH_IN_ESC:
sh->n_esc++;
if (c == '[') {
sh->in_state = SH_IN_CSI;
sh->csi_param = 0u;
return;
}
/* 裸 ESC(或转义序列被截断):退回普通态,并把当前字符**重新处理一遍** */
sh->in_state = SH_IN_NORMAL;
break;
case SH_IN_CSI:
if (c >= '0' && c <= '9') {
sh->csi_param = (uint16_t)(sh->csi_param * 10u + (uint16_t)(c - '0'));
return;
}
if (c < 0x40 || c > 0x7e) return; /* 参数中间字节,忽略 */
switch (c) {
case 'A': hist_up(sh); break;
case 'B': hist_down(sh); break;
case 'C': line_mov_right(sh); break;
case 'D': line_mov_left(sh); break;
case 'H': sh->cursor = 0u; break;
case 'F': sh->cursor = sh->len; break;
case '~': if (sh->csi_param == 3u) line_delete(sh); break;
default: break;
}
sh->in_state = SH_IN_NORMAL;
redraw(sh);
return;
default:
break;
}
if (c == 0x1b) { sh->in_state = SH_IN_ESC; return; }
/* ---- 普通字符 ---- */
if (c == '\r' || c == '\n') {
sh->n_lines++;
if (sh->echo) out_str(sh, "\r\n");
submit_line(sh); /* 不在这里执行! */
return;
}
if (c == 0x03) { /* Ctrl-C:丢弃当前行 */
if (sh->echo) out_str(sh, "^C\r\n");
line_clear(sh);
sh->hist_idx = 0u;
sh->n_aborted++;
sh->aborted = 1;
return;
}
if (c == 0x15) { /* Ctrl-U:清行 */
line_clear(sh);
redraw(sh);
return;
}
if (c == '\t') { do_tab(sh); return; }
if (c == 0x7f || c == 0x08) { /* Backspace / DEL */
line_backspace(sh);
redraw(sh);
return;
}
if (c < 0x20) return; /* 其它控制字符忽略 */
if (sh->len + 1u >= SHELL_LINE_MAX) {
sh->n_overflow++;
if (sh->echo) sh->out(sh->out_ctx, "\a", 1);
return;
}
line_insert(sh, c);
if (sh->echo) sh->out(sh->out_ctx, &c, 1);
}
int shell_poll(shell_t *sh)
{
char *argv[SHELL_ARGV_MAX];
int argc;
int ran = 0;
if (!sh->line_ready) return 0;
sh->line_ready = 0;
sh->aborted = 0;
argc = tokenize(sh->line, argv, SHELL_ARGV_MAX);
if (argc > 0) {
const shell_cmd_t *c = find_cmd(sh, argv[0]);
if (c == NULL) {
out_unknown(sh, argv[0]);
} else if (argc - 1 < (int)c->min_args) {
shell_printf(sh, "参数太少。用法: %s %s\n", c->name, c->usage);
sh->n_err_args++;
} else if (c->max_args != SHELL_ARGS_ANY && argc - 1 > (int)c->max_args) {
shell_printf(sh, "参数太多。用法: %s %s\n", c->name, c->usage);
sh->n_err_args++;
} else {
c->fn(sh, argc, argv);
}
ran = 1;
}
line_clear(sh);
sh->hist_idx = 0u;
if (sh->echo) shell_prompt(sh);
return ran;
}
/* ==================== 内置命令 ==================== */
void cmd_help(shell_t *sh, int argc, char **argv)
{
int i;
if (argc >= 2) {
const shell_cmd_t *c = find_cmd(sh, argv[1]);
if (c == NULL) { out_unknown(sh, argv[1]); return; }
shell_printf(sh, "用法: %s %s\n", c->name, c->usage);
shell_printf(sh, " %s\n", c->help);
return;
}
shell_printf(sh, "共 %d 条命令:\n", sh->ncmds);
for (i = 0; i < sh->ncmds; i++) {
shell_printf(sh, " %-8s %-12s %s\n", sh->cmds[i].name,
sh->cmds[i].usage, sh->cmds[i].help);
}
}
void cmd_echo(shell_t *sh, int argc, char **argv)
{
int i;
for (i = 1; i < argc; i++) {
if (i > 1) out_str(sh, " ");
out_str(sh, argv[i]);
}
out_str(sh, "\r\n");
}
static shell_kv_t *kv_find(shell_t *sh, const char *key)
{
int i;
for (i = 0; i < (int)sh->nvars; i++) {
if (strcmp(sh->vars[i].key, key) == 0) return &sh->vars[i];
}
return NULL;
}
void cmd_set(shell_t *sh, int argc, char **argv)
{
char *eq;
shell_kv_t *v;
size_t klen;
(void)argc; /* 参数个数已经由 dispatch 统一校验过了 */
eq = strchr(argv[1], '=');
if (eq == NULL) {
shell_printf(sh, "格式: set key=value\n");
return;
}
klen = (size_t)(eq - argv[1]);
if (klen == 0u || klen >= sizeof(sh->vars[0].key)) {
shell_printf(sh, "key 太长或为空(最多 %u 字节)\n",
(unsigned)(sizeof(sh->vars[0].key) - 1u));
return;
}
v = kv_find(sh, argv[1]);
if (v == NULL) {
if (sh->nvars >= SHELL_KV_MAX) {
shell_printf(sh, "变量表满(最多 %d 个)\n", SHELL_KV_MAX);
return;
}
v = &sh->vars[sh->nvars++];
memset(v, 0, sizeof(*v));
memcpy(v->key, argv[1], klen);
v->key[klen] = 0;
}
strncpy(v->val, eq + 1, sizeof(v->val) - 1u);
v->val[sizeof(v->val) - 1u] = 0;
/* 顺手打印出来:现场调试时「它到底收到什么了」比什么都重要 */
shell_printf(sh, "%s = %s\n", v->key, v->val);
}
void cmd_show(shell_t *sh, int argc, char **argv)
{
int i;
if (argc >= 2) {
shell_kv_t *v = kv_find(sh, argv[1]);
if (v == NULL) { shell_printf(sh, "%s 未定义\n", argv[1]); return; }
shell_printf(sh, "%s\n", v->val);
return;
}
if (sh->nvars == 0u) { out_str(sh, "(没有变量)\r\n"); return; }
for (i = 0; i < (int)sh->nvars; i++) {
shell_printf(sh, "%-8s = %s\n", sh->vars[i].key, sh->vars[i].val);
}
}
void cmd_stat(shell_t *sh, int argc, char **argv)
{
(void)argc;
(void)argv;
shell_printf(sh, "输入字节 %u,行数 %u,转义序列 %u\n",
(unsigned)sh->n_bytes, (unsigned)sh->n_lines,
(unsigned)sh->n_esc);
shell_printf(sh, "历史 %u/%d,Tab %u,上翻 %u,中止 %u\n",
(unsigned)sh->hist_count, SHELL_HIST_MAX,
(unsigned)sh->n_tab, (unsigned)sh->n_hist_up,
(unsigned)sh->n_aborted);
shell_printf(sh, "错误: 未知命令 %u,参数不对 %u,行溢出 %u\n",
(unsigned)sh->n_err_cmd, (unsigned)sh->n_err_args,
(unsigned)sh->n_overflow);
shell_printf(sh, "行缓冲 %d 字节 + 历史 %d 条 x %d 字节 = 固定占用 %u 字节\n",
SHELL_LINE_MAX, SHELL_HIST_MAX, SHELL_LINE_MAX,
(unsigned)(SHELL_LINE_MAX * (1 + SHELL_HIST_MAX)));
shell_printf(sh, "当前行长度 %u 字节(注意:命令执行时行缓冲还没清)\n",
(unsigned)sh->len);
}
void cmd_reboot(shell_t *sh, int argc, char **argv)
{
(void)argc;
(void)argv;
out_str(sh, "重启中……\r\n");
shell_platform_reboot();
}
const shell_cmd_t SHELL_CMDS[] = {
/* name usage help fn min max */
{ "help", "[命令]", "列出全部命令,或看某条命令的详细用法", cmd_help, 0, 1 },
{ "echo", "[文本...]", "原样打印参数(支持引号与转义)", cmd_echo, 0, SHELL_ARGS_ANY },
{ "set", "key=value", "设置一个变量", cmd_set, 1, 1 },
{ "show", "[key]", "打印变量,或全部变量", cmd_show, 0, 1 },
{ "stat", "", "打印 shell 自身的统计(排查输入问题用)", cmd_stat, 0, 0 },
{ "reboot", "", "重启设备(需要二次确认请自行加)", cmd_reboot, 0, 0 }
};
const int SHELL_NCMDS = (int)(sizeof(SHELL_CMDS) / sizeof(SHELL_CMDS[0]));
void shell_platform_reboot(void)
{
/* 真实平台上在这里调 NVIC_SystemReset();
* 主机测试上是空实现,只验证命令能被派发到。 */
}test/test_shell.c
/**
* 主机端测试:串口命令行 shell
*
* gcc -std=c99 -Wall -Wextra -Iinclude src/shell.c test/test_shell.c -o build/test
*
* 测试方式:把字节流「喂」给 shell_putc()(模拟串口来的字符),
* 然后在 shell_poll() 之后检查输出缓冲区里的实际文字。
*/
#include <stdio.h>
#include <string.h>
#include "shell.h"
static int failed = 0;
static void check(int cond, const char *what)
{
if (!cond) {
printf(" [FAIL] %s\n", what);
failed++;
}
}
/* ---------------- 输出捕获 ---------------- */
static char g_out[16384];
static int g_out_len;
static int out_cb(void *ctx, const char *s, int len)
{
(void)ctx;
if (g_out_len + len < (int)sizeof(g_out)) {
memcpy(g_out + g_out_len, s, (size_t)len);
g_out_len += len;
}
return len;
}
static void out_reset(void) { g_out_len = 0; g_out[0] = 0; }
static void out_end(void) { g_out[g_out_len] = 0; }
/* 把捕获到的输出里的 \r 去掉,方便断言(终端里 \r 只是光标回行首) */
static void strip_cr(char *s)
{
char *w = s, *r = s;
while (*r != 0) {
if (*r != '\r') *w++ = *r;
r++;
}
*w = 0;
}
static shell_t g_sh;
/* 喂一串字节,然后跑一次 poll */
static void feed(const char *s)
{
while (*s != 0) {
shell_putc(&g_sh, *s);
s++;
}
}
static void feed_cr(void)
{
shell_putc(&g_sh, '\r');
}
/* 喂一行(自动补回车)并执行 */
static void run(const char *line)
{
out_reset();
feed(line);
feed_cr();
shell_poll(&g_sh);
out_end();
strip_cr(g_out);
}
/* 检查上一次 run 的输出里是否包含某个子串 */
static int out_has(const char *needle)
{
return strstr(g_out, needle) != NULL;
}
int main(void)
{
printf("===== 嵌入式命令行 shell 实测 =====\n");
printf("命令 %d 条,行缓冲 %d 字节,历史 %d 条,最多 %d 个参数\n",
SHELL_NCMDS, SHELL_LINE_MAX, SHELL_HIST_MAX, SHELL_ARGV_MAX);
/* 在单片机上,这段是文章里最该被贴到项目文档里的数字 */
shell_init(&g_sh, SHELL_CMDS, SHELL_NCMDS, out_cb, NULL);
printf("shell_t 内存分布(这就是 shell 的全部 RAM 开销):\n");
printf(" 行缓冲 %4u 字节\n", (unsigned)sizeof(g_sh.line));
printf(" 历史 %-2d 条 %4u 字节\n", SHELL_HIST_MAX, (unsigned)sizeof(g_sh.history));
printf(" 翻历史暂存 %4u 字节\n", (unsigned)sizeof(g_sh.saved));
printf(" 变量表 %4u 字节(%d 项 x %u 字节)\n",
(unsigned)sizeof(g_sh.vars), SHELL_KV_MAX,
(unsigned)sizeof(g_sh.vars[0]));
printf(" 其它 %4u 字节(指针、计数器、状态机)\n",
(unsigned)(sizeof(shell_t) - sizeof(g_sh.line) - sizeof(g_sh.history)
- sizeof(g_sh.saved) - sizeof(g_sh.vars)));
printf(" 合计 %4u 字节\n", (unsigned)sizeof(shell_t));
printf(" -> 命令表本身是 const,放 Flash,不占这 %u 字节\n\n",
(unsigned)sizeof(shell_t));
shell_prompt(&g_sh);
/* ---------- [1] 基本执行 + 回显 ---------- */
printf("[1] 基本执行:输入被回显,命令被派发\n");
{
int ran;
/* 先清空捕获窗口,再补打一次提示符 —— 这样窗口里就是「屏幕此刻的样子」 */
out_reset();
shell_prompt(&g_sh);
feed("echo hi");
out_end();
strip_cr(g_out);
printf(" 只喂入不回车的输出: 「%s」\n", g_out);
check(strcmp(g_out, "$ echo hi") == 0, "回显不对(应该是 prompt + 输入)");
ran = shell_poll(&g_sh);
printf(" 没回车就 poll -> 执行了 %d 条命令(应该是 0)\n", ran);
check(ran == 0, "没回车就执行了命令");
feed_cr();
out_reset();
ran = shell_poll(&g_sh);
out_end();
strip_cr(g_out);
printf(" 回车后 poll -> 执行了 %d 条\n", ran);
printf(" 输出: 「%s」\n", g_out);
check(ran == 1, "回车后没有执行命令");
check(out_has("hi"), "echo 没有输出 hi");
check(out_has("$ "), "执行完没有重新打印 prompt");
}
/* ---------- [2] 参数解析:引号与转义 ---------- */
printf("\n[2] 参数解析:引号、转义、多空格\n");
{
run("echo a b c");
printf(" 输入「echo a b c」-> 「%s」\n", g_out);
check(out_has("a b c"), "多空格没有被合并成一个分隔");
run("echo \"hello world\"");
printf(" 输入「echo \"hello world\"」-> 「%s」\n", g_out);
check(out_has("hello world"), "引号里的空格没有被保留");
run("echo 'x y'");
printf(" 输入「echo 'x y'」-> 「%s」\n", g_out);
check(out_has("x y"), "单引号里的双空格没有保留");
run("echo \"a\\\"b\"");
printf(" 输入「echo \"a\\\"b\"」-> 「%s」\n", g_out);
check(out_has("a\"b"), "转义引号没有还原成单个引号");
run("echo \"\" tail");
printf(" 输入「echo \"\" tail」-> 「%s」\n", g_out);
check(out_has(" tail"), "空参数后面还有参数时丢了空格");
run("echo pre\"mid dle\"post");
printf(" 输入「echo pre\"mid dle\"post」-> 「%s」\n", g_out);
check(out_has("premid dlepost"), "引号在词中间时拼接不对");
}
/* ---------- [3] 参数个数校验(统一在 dispatch 里做) ---------- */
printf("\n[3] 参数个数校验\n");
{
uint32_t before = g_sh.n_err_args;
run("set");
printf(" 输入「set」(该命令要求 1 个参数)\n");
printf(" 输出: 「%s」\n", g_out);
check(g_sh.n_err_args == before + 1u, "参数太少没被拦住");
check(out_has("key=value"), "没有给出用法提示");
run("stat extra");
printf(" 输入「stat extra」(该命令不接受参数)\n");
printf(" 输出: 「%s」\n", g_out);
check(g_sh.n_err_args == before + 2u, "参数太多没被拦住");
run("echo a b c d e f g h i j k");
printf(" 输入 12 个参数的 echo(上限 %d)-> 「%s」\n",
SHELL_ARGV_MAX, g_out);
check(out_has("a b c d e f g"), "超出的参数应该被丢弃但命令仍执行");
}
/* ---------- [4] 带参数的命令:set / show ---------- */
printf("\n[4] set / show:变量表\n");
{
run("set led=on");
printf(" set led=on -> 「%s」\n", g_out);
check(out_has("led = on"), "set 没有回显赋值结果");
run("set baud=115200");
run("show");
printf(" show -> 「%s」\n", g_out);
check(out_has("led") && out_has("baud"), "show 没有列出全部变量");
check(out_has("115200"), "变量值丢");
run("show led");
printf(" show led -> 「%s」\n", g_out);
check(strstr(g_out, "on") != NULL, "show led 没取到值");
run("show nosuchkey");
printf(" show nosuchkey -> 「%s」\n", g_out);
check(out_has("未定义"), "未定义的变量没有提示");
run("set notvalid");
printf(" set notvalid(没有等号)-> 「%s」\n", g_out);
check(out_has("格式"), "缺少等号时没有提示格式");
}
/* ---------- [5] 未知命令 + 建议 ---------- */
printf("\n[5] 未知命令与建议\n");
{
uint32_t before = g_sh.n_err_cmd;
run("hel");
printf(" 输入「hel」-> 「%s」\n", g_out);
check(g_sh.n_err_cmd == before + 1u, "未知命令没有计数");
check(out_has("未知命令"), "没有报未知命令");
check(out_has("help"), "没有给出 help 建议");
run("xyz");
printf(" 输入「xyz」-> 「%s」\n", g_out);
check(!out_has("是不是想用"), "完全不相关的输入不该给建议");
}
/* ---------- [6] 退格编辑 ---------- */
printf("\n[6] 退格编辑(0x7f 字节)\n");
{
/* 同样只看「执行结果」那一行:输入回显里一定含有被删掉的字符 */
out_reset();
feed("set k=abc");
shell_putc(&g_sh, 0x7f); /* 退格一次 */
shell_putc(&g_sh, 0x7f); /* 再退一次 */
feed("XY");
feed_cr();
shell_poll(&g_sh);
out_end();
strip_cr(g_out);
printf(" 输入「set k=abc」+ 两次退格 + 「XY」-> 执行结果「%s」\n", g_out);
check(out_has("k = aXY"), "退格后插入位置不对(期望 k = aXY)");
check(!out_has("k = abc"), "被删掉的字符还在");
}
/* ---------- [7] 光标左右移动 ---------- */
printf("\n[7] 方向键:光标左右移动 + Delete\n");
{
out_reset();
feed("echo ac");
feed("\x1b[D"); /* 左键 */
feed("b"); /* 在 c 前面插入 b */
feed_cr();
shell_poll(&g_sh);
out_end();
strip_cr(g_out);
printf(" 输入「echo ac」+ 左键 + 「b」-> 输出「%s」\n", g_out);
check(out_has("abc"), "左移后插入位置不对");
/*
* 注意:不能用 echo + 「输出里不含 QXXZ」来判断——
* shell 会把输入原样回显,所以 QXXZ 一定出现在屏幕输出里。
* 必须看**命令执行结果那一行**。用 set 把结果存下来再看最干净。
*/
out_reset();
feed("set k=QXXZ");
feed("\x1b[D\x1b[D\x1b[D"); /* 退三格,光标落在第一个 X 上 */
feed("\x1b[3~"); /* Delete:删光标右侧那个 X */
feed("\x1b[3~"); /* 再删一个 X */
feed_cr();
shell_poll(&g_sh);
out_end();
strip_cr(g_out);
printf(" 输入「set k=QXXZ」+ 三个左键 + 两次 Delete -> 执行结果「%s」\n", g_out);
check(out_has("k = QZ"), "Delete 没有删掉光标右侧的字符");
check(!out_has("k = QXXZ"), "Delete 没生效");
}
/* ---------- [8] 历史 ---------- */
printf("\n[8] 历史(上/下方向键)\n");
{
uint32_t before = g_sh.n_hist_up;
run("set k1=v1");
run("set k2=v2");
printf(" 已执行两条命令,历史 %u 条\n", (unsigned)g_sh.hist_count);
out_reset();
feed("\x1b[A"); /* 上:应该出现 set k2=v2 */
out_end();
strip_cr(g_out);
printf(" 按一次上键,屏幕重画为「%s」\n", g_out);
check(strstr(g_out, "set k2=v2") != NULL, "上键没有取到最近一条");
check(g_sh.n_hist_up == before + 1u, "上键没有计数");
out_reset();
feed("\x1b[A"); /* 再上:set k1=v1 */
out_end();
strip_cr(g_out);
printf(" 再按一次上键:「%s」\n", g_out);
check(strstr(g_out, "set k1=v1") != NULL, "第二次上键没取到更早那条");
/* 直接回车执行历史里的这一条 */
out_reset();
feed_cr();
shell_poll(&g_sh);
out_end();
strip_cr(g_out);
printf(" 直接回车执行它 -> 「%s」\n", g_out);
check(out_has("k1 = v1"), "历史里的命令没有被执行");
out_reset();
feed("\x1b[A\x1b[B"); /* 上、下 -> 应该回到翻历史前的那一行 */
out_end();
strip_cr(g_out);
printf(" 上键后按下键,最后一屏是「%s」(应回到空行)\n", g_out);
check(g_sh.hist_idx == 0u, "下键没有把 hist_idx 归零");
check(g_sh.len == 0u, "下键没有回到空行");
/* 已经翻到最老一条时再按上键:应该只有响铃,不越界、不崩 */
out_reset();
feed("\x1b[A\x1b[A\x1b[A\x1b[A\x1b[A\x1b[A\x1b[A\x1b[A\x1b[A\x1b[A\x1b[A\x1b[A");
out_end();
printf(" 连按 12 次上键(历史只有 %u 条):hist_idx = %u(= 最老一条,没越界)\n",
(unsigned)g_sh.hist_count, (unsigned)g_sh.hist_idx);
check(g_sh.hist_idx == g_sh.hist_count, "连续上键越过了最早的一条历史");
feed("\x1b[B\x1b[B\x1b[B\x1b[B\x1b[B\x1b[B\x1b[B\x1b[B\x1b[B\x1b[B\x1b[B\x1b[B");
feed_cr();
shell_poll(&g_sh);
check(g_sh.hist_idx == 0u, "下键到底之后没有归零");
/* 重复命令不应该占两条历史 */
run("echo dup");
run("echo dup");
{
int i, dup = 0;
for (i = 0; i < (int)g_sh.hist_count; i++) {
if (strcmp(g_sh.history[i], "echo dup") == 0) dup++;
}
printf(" 连打两次「echo dup」,历史里出现 %d 次(应为 1)\n", dup);
check(dup == 1, "重复命令被记了多条历史");
}
}
/* ---------- [9] 行缓冲溢出保护 ---------- */
printf("\n[9] 行缓冲溢出(行缓冲 %d 字节,最多装 %d 个字符)\n",
SHELL_LINE_MAX, SHELL_LINE_MAX - 1);
{
uint32_t before = g_sh.n_overflow;
int i;
out_reset();
for (i = 0; i < 200; i++) shell_putc(&g_sh, 'x');
out_end();
printf(" 连喂 200 个字符 -> 收下 %u 个,溢出计数 %u -> %u\n",
(unsigned)g_sh.len, (unsigned)before, (unsigned)g_sh.n_overflow);
check(g_sh.n_overflow == before + (uint32_t)(200 - (SHELL_LINE_MAX - 1)),
"溢出计数不对");
check(g_sh.len == SHELL_LINE_MAX - 1u, "行长度没有被限制在缓冲内");
printf(" 行长度被限制在 %u(缓冲 %d,留一个字节给结尾 0)\n",
(unsigned)g_sh.len, SHELL_LINE_MAX);
printf(" -> 溢出不报错、不越界,只响一声铃,输入继续被接受\n");
/* 溢出的前提下回车也不能崩,而且要能执行 */
out_reset();
feed_cr();
shell_poll(&g_sh);
out_end();
strip_cr(g_out);
printf(" 溢出后回车:报未知命令而不是崩 -> 「%.40s...」\n", g_out);
check(out_has("未知命令"), "溢出后没有正常走未知命令分支");
}
/* ---------- [10] Tab 补全 ---------- */
printf("\n[10] Tab 补全\n");
{
out_reset();
feed("he\t");
out_end();
strip_cr(g_out);
printf(" 「he」+ Tab(唯一匹配)-> 屏幕变成「%s」\n", g_out);
check(strstr(g_out, "help ") != NULL, "唯一匹配没有补全并加空格");
feed_cr();
shell_poll(&g_sh);
out_reset();
feed("s\t");
out_end();
strip_cr(g_out);
printf(" 「s」+ Tab(多义)-> 「%s」\n", g_out);
check(out_has("set") && out_has("show") && out_has("stat"),
"多义时没有列出全部候选");
check(strstr(g_out, "help") == NULL, "多义时列出了不相关的候选");
check(g_sh.len == 1u && g_sh.line[0] == 's',
"多义 Tab 不应该改动当前行");
printf(" 当前行没被改动(还是 %u 个字符),符合预期\n", (unsigned)g_sh.len);
/* 无匹配:只响一声铃。先把「zz」打出去再清空输出,
* 否则会把回显的两个 z 也数进去。 */
feed("zz");
out_reset();
shell_putc(&g_sh, '\t');
out_end();
printf(" 「zz」+ Tab(无匹配)-> 只有响铃 %d 字节,不报错\n", g_out_len);
check(g_out_len == 1 && g_out[0] == '\a', "无匹配时不该输出别的");
shell_putc(&g_sh, 0x15); /* Ctrl-U 清行 */
feed_cr();
shell_poll(&g_sh);
out_reset();
feed("echo x\ty");
out_end();
printf(" 在参数位置按 Tab -> 不补全(只有响铃)\n");
check(!out_has("help"), "参数位置的 Tab 不该触发补全");
feed_cr();
shell_poll(&g_sh);
}
/* ---------- [11] Ctrl-C 与裸 ESC ---------- */
printf("\n[11] Ctrl-C 中止 / 裸 ESC 容错(串口被干扰时的关键)\n");
{
uint32_t before = g_sh.n_aborted;
out_reset();
feed("echo should not run");
shell_putc(&g_sh, 0x03); /* Ctrl-C */
out_end();
strip_cr(g_out);
printf(" 打到一半按 Ctrl-C -> 「%s」\n", g_out);
check(out_has("^C"), "Ctrl-C 没有回显 ^C");
check(g_sh.n_aborted == before + 1u, "Ctrl-C 没有计数");
out_reset();
printf(" Ctrl-C 后回车,应该**什么都不执行**\n");
shell_poll(&g_sh);
out_end();
printf(" poll 返回后 line_ready = %d\n", g_sh.line_ready);
check(!out_has("should not run"), "Ctrl-C 之后那一行还是被执行了");
/* 裸 ESC:后面跟着的不是 '[' */
out_reset();
shell_putc(&g_sh, 0x1b);
feed("ok");
feed_cr();
shell_poll(&g_sh);
out_end();
strip_cr(g_out);
printf(" 单独一个 ESC 后打「ok」-> 「%s」\n", g_out);
check(out_has("未知命令: ok"), "裸 ESC 之后输入的字符被吃掉了");
check(g_sh.in_state == SH_IN_NORMAL, "状态机没回到 NORMAL");
/*
* 被截断的转义序列:ESC [ 3 之后就断线了。
* 这里要验证的不是「那几个字节怎么处理」(它们本来就会被当成 CSI 吃掉),
* 而是**状态机最终一定回到 NORMAL,shell 之后仍然能用**。
*/
shell_putc(&g_sh, 0x1b);
shell_putc(&g_sh, '[');
shell_putc(&g_sh, '3');
printf(" 发「ESC [ 3」后停住 -> 状态 = %s(在等终结字节)\n",
g_sh.in_state == SH_IN_CSI ? "CSI" : "?");
check(g_sh.in_state == SH_IN_CSI, "应该在 CSI 状态等终结字节");
feed("echo hi");
feed_cr();
shell_poll(&g_sh);
printf(" 接着发正常内容 -> 状态 = %s\n",
g_sh.in_state == SH_IN_NORMAL ? "已复位到 NORMAL" : "卡住了");
check(g_sh.in_state == SH_IN_NORMAL, "被截断后状态机没复位");
/* 最关键的断言:紧跟着再执行一条完整命令,必须完全正常 */
run("echo after");
printf(" 紧接着执行「echo after」-> 「%s」\n", g_out);
check(out_has("after"), "被截断的转义序列影响了后续输入");
check(strcmp(g_sh.line, "") == 0, "行缓冲没被清干净");
/* 未知的 CSI 终结字节:忽略,不能崩 */
run("echo before");
out_reset();
shell_putc(&g_sh, 0x1b);
shell_putc(&g_sh, '[');
shell_putc(&g_sh, '9');
shell_putc(&g_sh, '9');
shell_putc(&g_sh, 'Z'); /* 未定义的终结字节 */
out_end();
printf(" 发一个未定义的 CSI(ESC [ 99 Z)-> 状态 %s,没崩\n",
g_sh.in_state == SH_IN_NORMAL ? "已复位" : "卡住");
check(g_sh.in_state == SH_IN_NORMAL, "未知 CSI 让状态机卡住了");
}
/* ---------- [12] 重画成本 ---------- */
printf("\n[12] 行编辑的重画成本(每按一次退格发多少字节)\n");
{
int before;
out_reset();
feed("echo abcdefghij");
out_end();
before = g_out_len;
out_reset();
shell_putc(&g_sh, 0x7f);
out_end();
printf(" 退格一次发出 %d 字节(整行重画:\\r + prompt + 行 + \\x1b[K)\n",
g_out_len);
printf(" 实际内容: ");
{
int i;
for (i = 0; i < g_out_len; i++) {
char c = g_out[i];
if (c == 0x1b) { printf("\\x1b"); i++; printf("%c", g_out[i]); }
else if (c == '\r') printf("\\r");
else printf("%c", c);
}
printf("\n");
}
check(g_out_len > 0, "退格没有触发重画");
printf(" -> 代价是 O(行长度)。所以行缓冲不要开太大:\n");
printf(" %d 字节的行缓冲,一次退格最坏要发 %d 字节\n",
SHELL_LINE_MAX, 3 + (int)strlen(g_sh.prompt) + SHELL_LINE_MAX - 1 + 30);
/* 清掉这一行 */
shell_putc(&g_sh, 0x15);
feed_cr();
shell_poll(&g_sh);
(void)before;
}
/* ---------- [13] 统计接口 ---------- */
printf("\n[13] stat 命令:shell 自己的统计(排查「为什么输入没反应」)\n");
{
run("stat");
printf("%s", g_out);
check(out_has("输入字节"), "stat 没有输出统计");
check(g_sh.n_lines > 0u, "行数统计不对");
}
printf("\n");
if (failed == 0) {
printf("===== 全部通过:以上数据由本机 gcc 实编译实运行 =====\n");
} else {
printf("===== %d 项断言失败 =====\n", failed);
}
return failed == 0 ? 0 : 1;
}实测输出
下面这段输出是把上面的核心算法用 本机 gcc 真编译、真运行得到的(不含任何硬件依赖):
===== 嵌入式命令行 shell 实测 =====
命令 6 条,行缓冲 80 字节,历史 8 条,最多 8 个参数
shell_t 内存分布(这就是 shell 的全部 RAM 开销):
行缓冲 80 字节
历史 8 条 640 字节
翻历史暂存 80 字节
变量表 392 字节(8 项 x 49 字节)
其它 112 字节(指针、计数器、状态机)
合计 1304 字节
-> 命令表本身是 const,放 Flash,不占这 1304 字节
[1] 基本执行:输入被回显,命令被派发
只喂入不回车的输出: 「$ echo hi」
没回车就 poll -> 执行了 0 条命令(应该是 0)
回车后 poll -> 执行了 1 条
输出: 「hi
$ 」
[2] 参数解析:引号、转义、多空格
输入「echo a b c」-> 「echo a b c
a b c
$ 」
输入「echo "hello world"」-> 「echo "hello world"
hello world
$ 」
输入「echo 'x y'」-> 「echo 'x y'
x y
$ 」
输入「echo "a\"b"」-> 「echo "a\"b"
a"b
$ 」
输入「echo "" tail」-> 「echo "" tail
tail
$ 」
输入「echo pre"mid dle"post」-> 「echo pre"mid dle"post
premid dlepost
$ 」
[3] 参数个数校验
输入「set」(该命令要求 1 个参数)
输出: 「set
参数太少。用法: set key=value
$ 」
输入「stat extra」(该命令不接受参数)
输出: 「stat extra
参数太多。用法: stat
$ 」
输入 12 个参数的 echo(上限 8)-> 「echo a b c d e f g h i j k
a b c d e f g
$ 」
[4] set / show:变量表
set led=on -> 「set led=on
led = on
$ 」
show -> 「show
led = on
baud = 115200
$ 」
show led -> 「show led
on
$ 」
show nosuchkey -> 「show nosuchkey
nosuchkey 未定义
$ 」
set notvalid(没有等号)-> 「set notvalid
格式: set key=value
$ 」
[5] 未知命令与建议
输入「hel」-> 「hel
未知命令: hel
是不是想用: help
输入 help 看全部命令
$ 」
输入「xyz」-> 「xyz
未知命令: xyz
输入 help 看全部命令
$ 」
[6] 退格编辑(0x7f 字节)
输入「set k=abc」+ 两次退格 + 「XY」-> 执行结果「set k=abc$ set k=ab[K$ set k=a[KXY
k = aXY
$ 」
[7] 方向键:光标左右移动 + Delete
输入「echo ac」+ 左键 + 「b」-> 输出「echo ac$ echo ac[Kb
abc
$ 」
输入「set k=QXXZ」+ 三个左键 + 两次 Delete -> 执行结果「set k=QXXZ$ set k=QXXZ[K$ set k=QXXZ[K$ set k=QXXZ[K$ set k=QXZ[K$ set k=QZ[K
k = QZ
$ 」
[8] 历史(上/下方向键)
已执行两条命令,历史 8 条
按一次上键,屏幕重画为「$ set k2=v2[K」
再按一次上键:「$ set k1=v1[K」
直接回车执行它 -> 「
k1 = v1
$ 」
上键后按下键,最后一屏是「$ set k1=v1[K$ [K」(应回到空行)
连按 12 次上键(历史只有 8 条):hist_idx = 8(= 最老一条,没越界)
连打两次「echo dup」,历史里出现 1 次(应为 1)
[9] 行缓冲溢出(行缓冲 80 字节,最多装 79 个字符)
连喂 200 个字符 -> 收下 79 个,溢出计数 0 -> 121
行长度被限制在 79(缓冲 80,留一个字节给结尾 0)
-> 溢出不报错、不越界,只响一声铃,输入继续被接受
溢出后回车:报未知命令而不是崩 -> 「
未知命令: xxxxxxxxxxxxxxxxxxxxxxxxx...」
[10] Tab 补全
「he」+ Tab(唯一匹配)-> 屏幕变成「he$ help [K」
「s」+ Tab(多义)-> 「s
set show stat
$ s[K」
当前行没被改动(还是 1 个字符),符合预期
「zz」+ Tab(无匹配)-> 只有响铃 1 字节,不报错
在参数位置按 Tab -> 不补全(只有响铃)
[11] Ctrl-C 中止 / 裸 ESC 容错(串口被干扰时的关键)
打到一半按 Ctrl-C -> 「echo should not run^C
」
Ctrl-C 后回车,应该**什么都不执行**
poll 返回后 line_ready = 0
单独一个 ESC 后打「ok」-> 「ok
未知命令: ok
输入 help 看全部命令
$ 」
发「ESC [ 3」后停住 -> 状态 = CSI(在等终结字节)
接着发正常内容 -> 状态 = 已复位到 NORMAL
紧接着执行「echo after」-> 「echo after
after
$ 」
发一个未定义的 CSI(ESC [ 99 Z)-> 状态 已复位,没崩
[12] 行编辑的重画成本(每按一次退格发多少字节)
退格一次发出 20 字节(整行重画:\r + prompt + 行 + \x1b[K)
实际内容: \r$ echo abcdefghi\x1b[K
-> 代价是 O(行长度)。所以行缓冲不要开太大:
80 字节的行缓冲,一次退格最坏要发 114 字节
[13] stat 命令:shell 自己的统计(排查「为什么输入没反应」)
stat
输入字节 703,行数 37,转义序列 37
历史 8/8,Tab 4,上翻 11,中止 1
错误: 未知命令 5,参数不对 2,行溢出 121
行缓冲 80 字节 + 历史 8 条 x 80 字节 = 固定占用 720 字节
当前行长度 4 字节(注意:命令执行时行缓冲还没清)
$
===== 全部通过:以上数据由本机 gcc 实编译实运行 =====