Administrator
发布于 2026-09-25 / 0 阅读
0
0

给单片机写个能用的命令行:行编辑、历史、Tab 补全、ANSI 转义全都要

一句话结论:一个能用的串口 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)但命令仍执行
4set / showset 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 次,不越界;溢出后回车照常报未知命令而不是崩
10Tab 补全he+Tab → 唯一匹配,补成 help 并自动加空格;s+Tab → 列出 set show stat 三个候选且不动当前行;zz+Tab → 只有 1 字节响铃,不报错;在参数位置按 Tab → 不补全
11Ctrl-C / 裸 ESCCtrl-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 clean

include/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$ set k=aXY
k = aXY
$ 」

[7] 方向键:光标左右移动 + Delete
      输入「echo ac」+ 左键 + 「b」-> 输出「echo ac$ echo acb
abc
$ 」
      输入「set k=QXXZ」+ 三个左键 + 两次 Delete -> 执行结果「set k=QXXZ$ set k=QXXZ$ set k=QXXZ$ set k=QXXZ$ set k=QXZ$ set k=QZ
k = QZ
$ 」

[8] 历史(上/下方向键)
      已执行两条命令,历史 8 条
      按一次上键,屏幕重画为「$ set k2=v2」
      再按一次上键:「$ set k1=v1」
      直接回车执行它 -> 「
k1 = v1
$ 」
      上键后按下键,最后一屏是「$ set k1=v1$ 」(应回到空行)
      连按 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 」
      「s」+ Tab(多义)-> 「s
  set  show  stat
$ s」
        当前行没被改动(还是 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 实编译实运行 =====

评论