# clix **Repository Path**: logeexpluoqi/clix ## Basic Information - **Project Name**: clix - **Description**: 小型命令行CLI交互库,可以方便集成在MCU中以及其他工程中 - **Primary Language**: C/C++ - **License**: MIT - **Default Branch**: main - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-04-09 - **Last Updated**: 2026-09-15 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # CliX > 树形结构的零分配命令行框架,C11,为 STM32 / Cortex-M 这类资源受限平台设计。 命令组织成**任意深度的树**,并在其上实现逐层 TAB 补全、`to`/`here` 层级导航、命令历史 与完整行编辑,而**不需要 malloc、不需要 libc、不需要文件系统、不需要 RTOS**。 ```text clix/> ? / ---------------------------- ? list the commands of this level [path] to go to a level of the command tree [path] gpio GPIO 控制 (pin 0..15) uart 串口控制 ... clix/> to gpio clix/gpio> re clix/gpio> read 7 P7 = 0 (in) ``` `?` 只列**当前层**的命令;`to gpio` 换层后提示符从 `clix/>` 变成 `clix/gpio>`;`TAB` 只补 本层命令,所以 `re` 补成 `read `。层级任意深,补全跟着层级走。 | | | | --- | --- | | **树形命令** | 任意深度,组/叶子可混;每条命令 32 字节(32 位平台) | | **零分配** | 无 `malloc`/`free`/`realloc`,内存全部由调用者持有 | | **零 libc** | 默认用自带字符串函数,`-ffreestanding` 下无未定义符号 | | **逐层补全** | TAB 跟随当前层级;支持 `/` 路径与 `to`/`?` 的参数补全 | | **可裁剪** | 11 个编译期开关;最小配置 128 字节 RAM + 5.5 KB 代码 | | **可嵌入** | 只要一个 printf 风格的输出函数 | --- ## 使用 ```c #include "clix.h" #include /* 1. 回调:self 是命中的节点,self->priv 是你的 */ static int hello_cb(CliXCmd *self, int argc, char **argv) { (void)self; printf(" hello %s\r\n", (argc > 0) ? argv[0] : "world"); return CLIX_ERR_NONE; } /* 2. 静态定义节点:普通结构体,库不拷贝也不分配 */ static CliXCmd c_hello = CLIX_CMD("hello", hello_cb, "打个招呼 [name]"); int main(void) { static CliX cli; /* 全部内存在这里 */ clix_init(&cli, printf); /* 装入 7 条内置命令 */ clix_add(clix_root(&cli), &c_hello); clix_prompt(&cli); for(int c; (c = getchar()) != EOF; ) { clix_xchar(&cli, (char)c); /* 逐字节喂进去 */ } } ``` 三条要点:节点生命周期由你负责(通常 `static`);`self->priv` 让一个回调服务多条命令; 回调返回负数时框架负责打印。 不用交互的场景(bootloader、协议帧、测试)直接执行一行: ```c clix_xline(&cli, "gpio write 13 1"); ``` ### 接上宿主:STM32 / Linux / Windows 库只要两样东西:一个 printf 风格的 sink,和把收到的字节交给 `clix_xchar()`。 `clix.h` + `clix.c` 就是全部,其余都是宿主的事。 #### STM32 / 裸串口 ```c #include "clix.h" static CliX cli; /* 放 .bss,别放栈上 */ /* sink:往 UART 写。也可以换成 ITM / DMA */ static int uart_puts(const char *fmt, ...) { char buf[128]; va_list ap; int n; va_start(ap, fmt); n = vsnprintf(buf, sizeof(buf), fmt, ap); va_end(ap); HAL_UART_Transmit(&huart1, (uint8_t *)buf, (uint16_t)n, HAL_MAX_DELAY); return n; } void console_init(void) { clix_init(&cli, uart_puts); clix_add(clix_root(&cli), &c_gpio_read); clix_prompt(&cli); } /* 中断里只入队:库会回调 sink 回显,而那是一次阻塞的 UART 发送 */ void USART1_IRQHandler(void) { if(USART1->SR & USART_SR_RXNE) { rx_push((char)USART1->DR); } } /* 主循环里喂字节:回显、补全、执行都发生在这里 */ void console_poll(void) { char c; while(rx_pop(&c)) { clix_xchar(&cli, c); } } ``` * 只需这两个文件,`example/` 整个目录留在 PC 上;不需要 RTOS,库内无全局可变状态。 * 上面把"喂字节"放在主循环是因为 sink 会阻塞。如果 sink 是非阻塞且可重入的(写 DMA、ITM), 可以省掉环形缓冲,直接在中断里调 `clix_xchar()`。 * 不要的内置命令用 `clix_del(clix_child(clix_root(&cli), "tree"))` 摘掉,或整组 `CLIX_USE_BUILTIN=0`;省 RAM/Flash 的组合见《资源占用》。 * 上位机用 PuTTY / Tera Term / minicom,**关掉本地行编辑与本地回显**,方向键和 TAB 就都能用。 * 要在串口上编辑中文,打开 `CLIX_USE_WIDE`(否则退格按字节删),并建议关掉 `CLIX_CFG_ANSI` —— 非 ANSI 路径靠重打字符移动光标,对宽字符反而更准。 #### Linux / macOS ```c #include #include #include #include #include #include "clix.h" static struct termios g_saved; static void tty_restore(void) { tcsetattr(STDIN_FILENO, TCSANOW, &g_saved); } static bool tty_init(void) { struct termios raw; if(!isatty(STDIN_FILENO) || tcgetattr(STDIN_FILENO, &g_saved) != 0) { return false; /* 不是终端:按行读就行 */ } raw = g_saved; raw.c_lflag &= ~(ICANON | ECHO | ISIG | IEXTEN); /* 原始字节;Ctrl-C 不再是信号 */ raw.c_cc[VMIN] = 1; raw.c_cc[VTIME] = 0; return tcsetattr(STDIN_FILENO, TCSANOW, &raw) == 0; } int main(void) { static CliX cli; clix_init(&cli, printf); if(tty_init()) { atexit(tty_restore); } clix_prompt(&cli); for(;;) { unsigned char c; if(read(STDIN_FILENO, &c, 1) != 1) { break; /* EOF */ } if(c == 0x04 && cli.len == 0) { break; /* 空行上的 Ctrl-D 用来退出 */ } clix_xchar(&cli, (char)c); } return 0; } ``` * 输出直接 `printf`,不需要额外处理。 * `ISIG` 必须关:否则 Ctrl-C 变成 SIGINT,库就收不到 0x03,"放弃当前行"这个功能随之失效。 关 `ICANON`/`ECHO` 是为了拿到逐字节输入并自己负责回显。 * `tty_restore()` 记得在 `atexit` 或信号处理里调用,否则退出后终端仍是原始模式。 * stdin 不是终端时(管道、脚本)跳过原始模式即可,`clix_xline()` 也照样能用。 #### Windows ```c #define WIN32_LEAN_AND_MEAN #include #include #include #include "clix.h" static void win_init(void) { HANDLE in = GetStdHandle(STD_INPUT_HANDLE); HANDLE out = GetStdHandle(STD_OUTPUT_HANDLE); DWORD mode; if(GetConsoleMode(in, &mode)) { /* 原始输入:不加 ENABLE_LINE_INPUT / ENABLE_ECHO_INPUT, 也不加 ENABLE_PROCESSED_INPUT —— 这样 Ctrl-C 以 0x03 送到库里 */ SetConsoleMode(in, ENABLE_EXTENDED_FLAGS); } if(GetConsoleMode(out, &mode)) { /* 让控制台认得库输出的 ANSI 转义(颜色、光标定位) */ SetConsoleMode(out, mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING); } } int main(void) { static CliX cli; win_init(); clix_init(&cli, printf); clix_prompt(&cli); for(;;) { int c; if(!_kbhit()) { Sleep(1); /* 空转时可以做别的活 */ continue; } c = _getch(); /* 方向键:0xE0 或 0x00 + 扫描码 */ if(c == 0x04 && cli.len == 0) { break; } clix_xchar(&cli, (char)c); } return 0; } ``` * 与 Linux 同理:`ENABLE_PROCESSED_INPUT`(Ctrl-C 变信号)与行输入/回显都要清掉, 再打开 `ENABLE_VIRTUAL_TERMINAL_PROCESSING` 让控制台理解 ANSI 输出。 * `_getch()` 对方向键返回 `0xE0` + 扫描码,这恰好是 `_WIN32` 目标下 `CLIX_CFG_KEY_WIN` 的默认状态,**不需要额外配置**。 * 另一种做法是开 `ENABLE_VIRTUAL_TERMINAL_INPUT` 并用 `ReadFile()` 读字节流,此时控制台 发的是 ANSI 序列,库同样能解 —— 两条路都可以,见《特殊按键与平台》。 ### 建树 任何节点都可以是**组**(`cb == NULL`)、**叶子**,或两者兼具(能匹配子命令就下沉, 否则剩下的词当参数)。 ```c static CliXCmd c_gpio = CLIX_CMD("gpio", NULL, "GPIO 控制"); static CliXCmd c_mode = CLIX_CMD("mode", mode_cb, "设置模式 "); static CliXCmd c_bank = CLIX_CMD("bank", NULL, "端口分组"); static CliXCmd c_bank0 = CLIX_CMD("bank0", NULL, "BANK0"); static CliXCmd c_set = CLIX_CMD("set", bank_set_cb, "设置整口 "); clix_add(clix_root(&cli), &c_gpio); /* 挂接顺序决定 `?` 里的顺序 */ clix_add(&c_gpio, &c_mode); clix_add(&c_gpio, &c_bank); clix_add(&c_bank, &c_bank0); clix_add(&c_bank0, &c_set); /* 第四层 */ ``` 重名、重复挂接、成环都会被拒(`CLIX_ERR_EXIST` / `CLIX_ERR_PARAM`)且**不改动树**, 细节见《[树结构原理](#树结构原理)》。 ### 回调 ```c static int gpio_mode_cb(CliXCmd *self, int argc, char **argv) { if(argc != 2) { return CLIX_ERR_ARGS; /* 框架打印错误码 + 本命令的 desc */ } clix_print_self(self, " P%s -> %s\r\n", argv[0], argv[1]); return CLIX_ERR_NONE; } ``` `argc`/`argv` 是**命令路径之后**剩下的词,`argv` 以 `NULL` 结尾。参数是原地切分的, `argv[i]` 指向解析缓冲区,不要保存。 ### priv:一个回调服务多条命令 ```c struct led { const char *name; int pin; }; static struct led led_red = { "red", 13 }; static int led_cb(CliXCmd *self, int argc, char **argv) { struct led *l = self->priv; /* ← 区别只有这一行 */ clix_print_self(self, " %s(pin %d)\r\n", l->name, l->pin); return CLIX_ERR_NONE; } static CliXCmd c_red = CLIX_CMD_EX("red", led_cb, "红灯 [on|off]", &led_red, CLIX_FLAG_NONE); ``` ### 运行期增删 ```c static CliXCmd c_tmp = CLIX_CMD("tmp", tmp_cb, "临时命令"); c_tmp.parent = c_tmp.next = NULL; /* 重新挂接前断开旧链 */ clix_add(clix_root(&cli), &c_tmp); /* 注册 */ clix_del(&c_tmp); /* 注销,节点及其子树仍归你 */ clix_del(clix_child(clix_root(&cli), "tree")); /* 摘掉某个内置命令 */ ``` `clix_del()` 只断开兄弟链,子树原封不动地跟着节点进出。 ### 嵌套执行、多实例、运行期配置 ```c /* 回调里可以再执行一整行;参数互不干扰 */ static int script_cb(CliXCmd *self, int argc, char **argv) { (void)argc; (void)argv; clix_xline(clix_shell(self), "sys info"); return clix_xline(clix_shell(self), "gpio dump"); } /* 没有全局可变状态,多个实例并存 */ static CliX uart1, uart2; clix_init(&uart1, uart1_puts); clix_init(&uart2, uart2_puts); clix_set_prompt(&uart2, "slave"); /* slave> 或 slave/gpio> */ /* 运行期开关 */ clix_set_flags(&cli, CLIX_CFG_ANSI, false); /* 关颜色 */ clix_set_flags(&cli, CLIX_CFG_DISPLAY, false);/* 完全不输出,只留返回值 */ cli.width = 16; /* 候选列表按 16 列排版 */ ``` `CLIX_CFG_*` 全关时,库退化成"纯解析 + 派发",适合在 ISR 或协议帧里执行命令。 --- ## 命令树 ### 解析规则 | 写法 | 含义 | | --- | --- | | `gpio read 13` | 逐词下沉,第一个匹配不上的词起全部作为参数 | | `gpio/read 13` | 层级路径,一次解析到位 | | `/sys/info` | 前导 `/` 表示从根开始 | 优先级:**当前层子命令 → 根层的全局命令 → 当成参数**。全局回退**只对第一个词生效**: `clear` 在任何层级都能用,而 `gpio clear` 打印的是 `gpio` 的帮助。TAB 的候选遵循同一 规则。背后的数据结构见《[树结构原理](#树结构原理)》。 ### 当前层级 CliX 没有"目录",`here` 打印的是你站在命令树的哪个节点上。`to` 移动它。 | 写法 | 含义 | | --- | --- | | `to gpio` | 进入 `gpio`,提示符变成 `clix/gpio>` | | `to /` 或 `to` | 回到根层 | | `to ..` | 回到上一级 | | `to net/if` | 多段路径一次进入 | 命令名特意用 `to`/`here` 而不是 `cd`/`pwd`:这里既没有文件也没有目录。整组功能可用 `CLIX_USE_NAV=0` 编译掉(省约 0.8 KB),平铺命令表用不上它。 #### 当前层本身就是命令时 `to math/div` 之后,若整行没有任何词能解析成命令名,就当作**该层命令的参数**: ```text clix/math/div> 10 20 10 / 20 = 0 ... 10 clix/math/div> clear ← 真命令优先,不会当参数 ``` 只回退**一层**:`/math` 这种组节点(`cb == NULL`)不吞参数。 ### 键盘按键 | 按键 | 行为 | | --- | --- | | `TAB` | 唯一匹配 → 补全并追加空格;多匹配 → 先补公共前缀,再按列出候选 | | `↑` / `↓` | 历史上下翻,翻到底还原未提交的半行 | | `←` / `→` | `ESC[C/D` 与 `ESC OC/D` 两种序列都支持 | | `Home` / `End` | `ESC[H`、`ESC[F`、`ESC[1~`、`ESC[4~` | | `Ctrl-A` / `Ctrl-E` | 行首 / 行尾 | | `Ctrl-B` / `Ctrl-F` | 左 / 右 | | `Ctrl-K` / `Ctrl-U` / `Ctrl-W` | 删到行尾 / 删到行首 / 删前一个词 | | `Ctrl-L` | 清屏并重绘 | | `Ctrl-C` | 放弃当前行,**不退出**;永远返回 `CLIX_ERR_NONE` | | `Ctrl-D` | 行非空 → 删除光标处字符;行为空 → 返回 `CLIX_ERR_NOENT` | | `Backspace` / `DEL` | 删除光标左侧字符 | | `Delete` | `ESC[3~` | 方向键、`Home`/`End`/`Delete` 这些「不是字符」的键靠**字节序列**识别。库同时听懂 ANSI (`ESC [ A` / `ESC O A`)与 Win32 扫描码(`0xE0` / `0x00` + 扫描码),不需要宿主翻译。 两个容易踩的地方: * `CLIX_USE_ANSI=0` **只是不再输出** ANSI 转义(颜色、光标定位),**方向键照样能解**; * `Ctrl-C` 要真的送到库手上,宿主必须关掉终端的信号/加工模式:POSIX 侧清 `ISIG`, Windows 侧清 `ENABLE_PROCESSED_INPUT`,否则它会变成信号,进不了输入流。 细节(包括 `0xE0` 为什么受一个开关控制)见《[特殊按键与平台](#特殊按键与平台)》。 宿主想拿 `Ctrl-D` 当 EOF 时**不能只看返回值**(回调也可能返回 `NOENT`),应在喂进去之前 判断: ```c if(c == 0x04 && cli.len == 0) { break; /* demo 就是这么退出的 */ } clix_xchar(&cli, (char)c); ``` ### 补全行为 * **匹配是全局的**:`clix/gpio> he` → `here`,内置命令不用打全名; * **列举是分层的**:`clix/gpio> ` 只列 `gpio` 自己的子命令; * **参数位不补命令名**:`gpio read `、`tree ` 什么都不做; * **全局命令只在第一个词位置补**:`gpio c` 不动(它会变成 `gpio clear`)。 参数位唯二能补的是 `to` 和 `?`(标了 `CLIX_FLAG_PATHARG`): ```text clix/> to gp → clix/> to gpio clix/> to /gpio/mo → clix/> to /gpio/mode clix/> ? gpio mo → clix/> ? gpio mode ``` --- ## 内置命令 `clix_init()` 装载下面 7 条,库除此之外不带任何命令。不想要某一条用 `clix_del()` 摘掉; 整组不需要就定义 `CLIX_USE_BUILTIN=0`;`to`/`here` 还额外受 `CLIX_USE_NAV` 控制。 | 命令 | 说明 | | --- | --- | | `? [path]` | 列出当前(或指定)层的子命令 | | `to [path]` | 切换当前命令层级 | | `here` | 打印当前命令层级,如 `/gpio/mode` | | `tree [depth]` | 从当前层开始打印命令树 | | `history [n]` | 显示最近 n 条历史(`CLIX_HISTORY_MAX > 0` 时才有) | | `clear` | 清屏(需要终端支持 ANSI) | | `disp on\|off\|echo\|quiet` | 控制框架输出与字符回显 | --- ## API 命名:类型 PascalCase(`CliX`、`CliXCmd`),函数 `clix_*`,常量 `CLIX_*`。 15 个函数(`CLIX_USE_NAV=0` 时 14 个)+ 4 个宏(`CLIX_CMD`、`CLIX_CMD_EX`、 `clix_print`、`clix_print_self`)。补全、帮助、命令树、历史、行编辑都由 `TAB`、`?`、 `tree`、`↑/↓` 和 `clix_xchar()` 驱动,没有额外 API。 ### 启动 | 函数 | 说明 | | --- | --- | | `int clix_init(CliX *, CliXPrint)` | 初始化并装载内置命令 | | `CliXCmd *clix_root(CliX *)` | 根节点,`clix_add()` 的父节点 | | `int clix_add(CliXCmd *parent, CliXCmd *cmd)` | 追加子节点(重名返回 `CLIX_ERR_EXIST`) | ### 运行 | 函数 | 说明 | | --- | --- | | `int clix_prompt(CliX *)` | 打印提示符 | | `int clix_xchar(CliX *, char ch)` | 字节泵:行编辑 + 补全 + 执行;返回回车那次的执行结果 | | `int clix_xline(CliX *, const char *line)` | 执行一整行(可在回调里嵌套调用) | | `clix_print(cli, fmt, ...)` | 宏,转发到输出 sink | | `clix_print_self(self, fmt, ...)` | 宏,回调里最常用(内部自动 `clix_shell(self)`) | ### 树操作与探查 | 函数 | 说明 | | --- | --- | | `CliX *clix_shell(CliXCmd *)` | 由任意节点反查 shell | | `CliXCmd *clix_child(CliXCmd *, const char *)` | 按名查找直接子节点 | | `int clix_del(CliXCmd *cmd)` | 摘除节点及其子树,节点本身仍归你所有 | | `int clix_path(CliXCmd *, char *buf, size_t)` | 生成层级路径,如 `/gpio/read` | | 宏 `CLIX_CMD` / `CLIX_CMD_EX` | 静态定义节点 | ### 层级导航(`CLIX_USE_NAV`) | 函数 | 说明 | | --- | --- | | `CliXCmd *clix_level(CliX *, const char *path)` | `path == NULL` 读当前层;否则移动过去并返回新层,失败返回 `NULL` | | `CliXCmd *clix_find(CliX *, const char *path)` | 只解析不移动 | `CLIX_USE_NAV=0` 时 `clix_find()` 仍可用,路径退化成单个命令名。 ### 配置与诊断 | 函数 | 说明 | | --- | --- | | `int clix_set_prompt(CliX *, const char *)` | 设置 `>` 之前那段文本 | | `int clix_set_flags(CliX *, uint32_t, bool)` | 开关 `CliXCfg` 位 | | `const char *clix_strerror(int)` | 错误码文案 | 只有这两个 setter,因为最容易写错(有界拷贝、位掩码);其余字段公开,直接写 `cli.width = 40;`。 ### 错误码 | 错误码 | 含义 | | --- | --- | | `CLIX_ERR_NONE` | 成功 | | `CLIX_ERR_FAIL` | 未指定的失败 | | `CLIX_ERR_PARAM` | 参数非法 | | `CLIX_ERR_NOMEM` | 缓冲区满 | | `CLIX_ERR_NOENT` | 找不到 | | `CLIX_ERR_EXIST` | 已存在 | | `CLIX_ERR_SYNTAX` | 输入格式错误 | | `CLIX_ERR_ARGS` | 命令参数不合法 | | `CLIX_ERR_BUSY` | 资源忙 | | `CLIX_ERR_DISABLED` | 节点被禁用 | **失败由发现它的那一层打印,永远只有一行**: * 框架打印命令找不到、参数不合语法、行太长、节点被禁用这四种; * 回调只 `return CLIX_ERR_*;`,**不要自己打印**; * 返回 `ARGS`/`PARAM` 时框架会再补一行该节点的 `desc` 作为用法提示 —— 所以把用法写进 `desc` 是有用的,`?` 会列出来,参数出错时也会显示: ```text clix/> uart format xxxz #! invalid arguments ! 帧格式 <7|8> <1|2> ← 这行就是 desc ``` `CLIX_ERR_NOENT` 在**只有一个参数**时会带上那个名字(`#! no such cmd: foo !`),多参数 时框架不知道错的是哪个,就用通用文案。 ### 两种位掩码 | | `CliXFlag`(节点属性) | `CliXCfg`(实例开关) | | --- | --- | --- | | 属于谁 | 一条命令 | 一个 shell 实例 | | 何时定 | 编译期,写在 `CLIX_CMD_EX` 里 | 运行期 `clix_set_flags()` | | 存在哪 | `CliXCmd::flags` | `CliX::flags` | | 例子 | `CLIX_FLAG_HIDDEN` | `CLIX_CFG_ANSI` | `CLIX_FLAG_GLOBAL` 只在**根的子节点**上有意义(全局回退只扫根的一层)。`CliXCfg` 里 `ECHO`/`DISPLAY`/`ANSI`/`PATH`/`KEY_WIN` 五个是开关位,另外两个是掩码: `CLIX_CFG_KEY_DEFAULT`(本平台默认认哪种按键编码)和 `CLIX_CFG_DEFAULT`(`clix_init()` 设的全部默认值)。`KEY_WIN` 是唯一与平台相关的位,见 《[特殊按键与平台](#特殊按键与平台)》。 --- ## 编译期配置 在包含 `clix.h` 之前定义,或直接用 CMake 同名选项。 | 宏 | 默认 | 说明 | | --- | --- | --- | | `CLIX_USE_STDLIBC` | `0` | 用 `` 而不是自带实现 | | `CLIX_USE_QUOTE` | `1` | 支持 `"引号"`、`'引号'`、`\转义` | | `CLIX_USE_WIDE` | `0` | 按**字符**而不是字节编辑:方向键/退格/删除不再切开一个 UTF-8 字符 | | `CLIX_USE_ANSI` | `1` | 支持 ANSI 光标控制/颜色;关掉只能退格。**只影响输出**,不影响方向键的识别 | | `CLIX_USE_BUILTIN` | `1` | 编译内置命令(约 1.5 KB Flash、224 B RAM) | | `CLIX_USE_NAV` | `1` | 编译层级导航(约 0.8 KB Flash) | | `CLIX_LINE_MAX` | `64` | 单行最大长度(**字节**,见 `CLIX_USE_WIDE`) | | `CLIX_ARGC_MAX` | `8` | 整行(含命令路径)最多几个词 | | `CLIX_HISTORY_MAX` | `8` | 历史条数,`0` 完全编译掉(省 594 B RAM) | | `CLIX_PROMPT_MAX` | `24` | 提示符前缀长度 | | `CLIX_WIDTH` | `80` | 补全候选排版宽度 | ```bash cmake -S . -B build -DCMAKE_BUILD_TYPE=MinSizeRel \ -DCLIX_HISTORY_MAX=0 -DCLIX_USE_BUILTIN=0 -DCLIX_LINE_MAX=48 ``` ### 中文与多字节字符 默认(`CLIX_USE_WIDE=0`)行编辑的最小单位是**字节**:`CLIX_LINE_MAX` 也是字节数,光标 左右、退格、删除都按一个字节算。ASCII 下这没问题,但中文这种多字节字符会被从中间切开, 行变成非法 UTF-8。打开: ```bash cmake -S . -B build -DCLIX_USE_WIDE=1 ``` 之后: * `←`/`→` 按**字符**走,永远不会停在字符中间; * `退格` / `Delete` / `Ctrl-D` 删掉整个字符; * `Ctrl-W` 不会把词尾的字符切一半; * 光标重定位按**显示列**算(一个汉字两列),而不是字节数。 代价约 **+619 B** `.text`(32 位,含列宽判断),且列宽是**近似的** —— 库里没有 Unicode 宽度表,规则只有两条(见 `cx_utf8_cols_()`):组合符占 0 列,U+1100 及以上占 2 列。 ASCII 和 CJK 都是对的;U+2000..U+2FFF 这类窄字符和 ambiguous 宽度字符会差一列, 只影响终端光标回退的位置,不影响文本本身。`CLIX_LINE_MAX` 依旧是字节数,所以改了模式 不必改缓冲大小。 --- ## 资源占用 `gcc 13`,`-Os -ffreestanding -fno-asynchronous-unwind-tables`,实测。32 位那列用 `gcc -m32`(指针 4 字节),作为 Cortex-M 的代理。 ### 结构体 `CliXCmd` = 7 指针 + 4 字节标志: | | 32 位 | 64 位 | | --- | --- | --- | | `sizeof(CliXCmd)` | **32 B** | **64 B** | `sizeof(CliX)`: | 配置 | 32 位 | 64 位 | | --- | --- | --- | | 默认 | **1024 B** | 1288 B | | `CLIX_USE_BUILTIN=0` | **800 B** | 840 B | | `CLIX_HISTORY_MAX=0` | **432 B** | 696 B | | `BUILTIN=0` + `HISTORY_MAX=0` | **208 B** | 248 B | | 极限精简(见下) | **128 B** | 168 B | 默认配置(32 位)的构成:`root` 32 B + 提示符/标志 27 B + 行缓冲两个 130 B + 历史 594 B + 内置命令节点 224 B + 其余指针与计数器。 ### 代码体积 极限精简 = `NAV=0 BUILTIN=0 HISTORY_MAX=0 QUOTE=0 ANSI=0 LINE_MAX=32 ARGC_MAX=4 PROMPT_MAX=8`。`.rodata` 默认 257 B,关掉历史后 225 B(其中 33 B 是 `?` 的横线)。 | 配置 | `.text` (32 位) | `.text` (64 位) | | --- | --- | --- | | 默认 | 8697 B | 8585 B | | `CLIX_USE_WIDE=1` | 9301 B | 9138 B | | `CLIX_USE_NAV=0` | 7965 B | 7790 B | | `CLIX_USE_BUILTIN=0` | 7154 B | 7128 B | | `CLIX_HISTORY_MAX=0` | 8055 B | 7845 B | | `CLIX_USE_QUOTE=0` | 8441 B | 8395 B | | `CLIX_USE_ANSI=0` | 8377 B | 8306 B | | `BUILTIN=0` + `HISTORY_MAX=0` | 6646 B | 6550 B | | 极限精简 | 5364 B | 5482 B | | 极限精简 + `WIDE=1` | 5895 B | 5985 B | | `CLIX_USE_STDLIBC=1` | — | 8501 B | 最贵的是行编辑器(约 3 KB,`clix_xchar` 含光标移动、Home/End/Delete、 `Ctrl-K/U/W/L`、历史、TAB 补全),其次补全(约 1.3 KB)、内置命令(约 1.0 KB), 宽字符编辑约 0.6 KB。其中约 54 B 代码 + 32 B `.rodata` 是“三种按键编码共用一个动作表” 的代价:多一层 `cx_key_()` 派发,换来的是不可能再出现“表与起始字节不匹配”那类 bug。 ### 裁剪顺序 ```bash -DCLIX_HISTORY_MAX=0 # 省 594 B RAM + 642 B Flash -DCLIX_USE_BUILTIN=0 # 省 224 B RAM + 1543 B Flash -DCLIX_USE_NAV=0 # 省 732 B Flash -DCLIX_USE_ANSI=0 # 省 320 B Flash -DCLIX_USE_QUOTE=0 # 省 256 B Flash ``` F103C8(64 KB Flash / 20 KB RAM)用默认配置就够;F030F4(16 KB / 4 KB)建议至少关掉 历史和内置命令。 ### 自己量 ```bash printf '#include "clix.h"\nconst char a[sizeof(CliX)];\n' > /tmp/sz.c gcc -m32 -ffreestanding -I. -c /tmp/sz.c -o /tmp/sz.o && nm --print-size /tmp/sz.o gcc -m32 -ffreestanding -Os -c clix.c -o /tmp/clix.o && size -A /tmp/clix.o gcc -m32 -ffreestanding -fno-pic -Os -c clix.c -o /tmp/clix.o && nm -u /tmp/clix.o # 应为空 ``` --- ## 特殊按键与平台 方向键、`Home`/`End`/`Delete`、`F1` 这些键没有对应字符,靠一串字节表示。库把这些字节 解成动作,所以**宿主不需要认识方向键**: ```c void USART1_IRQHandler(void) { if(USART1->SR & USART_SR_RXNE) { clix_xchar(&cli, (char)USART1->DR); /* 方向键原样进来就行 */ } } ``` 三种编码都认,且**同时生效**(不是编译期二选一): | 控制台 | 上键 | 状态 | | --- | --- | --- | | Linux / macOS、PuTTY、任何 VT100 | `ESC [ A` | `1B` → `[` → 数字 → 终结字节 | | xterm application cursor mode | `ESC O A` | `1B` → `O` → 终结字节 | | Win32 `_getch()`(方向键、Home/End/Del) | `E0 48` | `E0` → 扫描码 | | Win32 `_getch()`(F1..F10) | `00 3B` | `00` → 扫描码,无动作则丢弃 | CSI 里的 `;` 用来吃掉修饰键参数,所以 `ESC [ 1 ; 5 C` 仍落到 `C`(Ctrl-右)。 **两张表必须分开**:扫描码不是字符,而且和 ASCII 字母撞值 —— `0x48` 是上键,`'H'` 是 `Home`。混用会让上键变成 Home、下/左/右被丢弃(旧版正是这个 bug)。 `0xE0` / `0x00` 这两种起始字节受 `CLIX_CFG_KEY_WIN` 控制,因为 `0xE0` 是 UTF-8 / GBK 的 **首字节**,在 Linux 和裸机上当前缀会吃掉中文的第一个字节。默认值按目标给: | 目标 | 默认 | 结果 | | --- | --- | --- | | Linux / macOS / 裸机 | 0 | `ESC` 序列可用;`0xE0` / `0x00` 是普通字节 | | 编译目标为 Windows | 1 | `ESC` 序列**和**扫描码都可用 | ```c clix_set_flags(&cli, CLIX_CFG_KEY_WIN, false); /* 送 ANSI,或要输入中文 */ ``` ANSI 那条路永远打开,所以同一个二进制能吃下 Linux 终端、PuTTY 接 UART、Windows 上开了 `ENABLE_VIRTUAL_TERMINAL_INPUT` 的控制台。平台判断只有 `clix.h` 里 `CLIX_CFG_KEY_DEFAULT` 一处,`clix.c` 里没有任何 `#ifdef`。 已知限制:未知序列(`F1`、`PgUp`)整段吞掉,不会变成乱码;孤立的 `ESC` 后跟普通字符时 两个字节都丢(状态机没有超时,分不出“按了 ESC”和“序列开头”)。 三个平台的完整接入示例(含终端模式设置)见 《[使用 → 接上宿主](#接上宿主stm32--linux--windows)》。 --- ## 使用注意与已知边界 * **`clix_init()` 是初始化,不是复位。** 再调一次会从空根开始,之前挂上的用户节点变成 「游离」状态(`parent` 还指着已丢弃的链),`clix_add()` 会返回 `CLIX_ERR_EXIST`;把 `parent`、`next` 清成 `NULL` 即可重新挂接。 * **`CLIX_FLAG_GLOBAL` 只对根的子节点有意义**,全局回退只扫根的一层。 * **数字参数有界**:`tree `、`history ` 超出 `INT_MAX` 返回 `CLIX_ERR_ARGS`, 不回绕(回绕成负数在 `tree` 里正好意味着「全部层级」)。 * **`clix_add()` 拒绝成环**(返回 `CLIX_ERR_PARAM`,树不变)。 * **`clix_set_flags()` 只取低 16 位**,`CliX::flags` 是 `uint16_t`。 * **一行上限**是 `CLIX_ARGC_MAX` 个词、`CLIX_LINE_MAX` 个字节,超了返回 `CLIX_ERR_NOMEM`(即「缓冲区满」)。 * **失败只打印一次**:回调只 `return CLIX_ERR_*`,不要自己打印。 * **`argv[i]` 指向解析缓冲区**(`cli->line` 或 `clix_xline()` 的栈帧),回调里不要保存。 * **默认按字节编辑**:多字节字符会被切开,要按字符编辑就打开 `CLIX_USE_WIDE` (见《[中文与多字节字符](#中文与多字节字符)》)。 --- ## 树结构原理 ### 节点:左孩子、右兄弟 `CliXCmd` 是**侵入式**的:库不拥有节点,只借用三个指针。 | 字段 | 含义 | | --- | --- | | `parent` | 唯一父节点,根的子节点指向虚拟根 | | `child` | **第一个**子节点,是表头不是列表 | | `next` | **同层下一个**兄弟 | ```text root (内嵌在 CliX 里, name == "") │ child ▼ "?" ──next──> "clear" ──next──> "gpio" ──next──> ... │ child ▼ read ──next──> write ──next──> ... ``` 于是遍历就两行,任意深度、无数组、无容量上限、无 malloc: ```c for(c = node->child; c; c = c->next) { /* 本层所有子命令 */ } ``` `name`/`desc`/`cb`/`priv` 是你的,`parent`/`child`/`next`/`reserved` 是库的。 ### 虚拟根 `CliX` 内嵌一个 `CliXCmd root`(名字 `""`、`parent == NULL`),让**每个节点都有父节点**, 上下行走的代码都不必为根写分支:`level` 初值指向它,所以根上 `..` 原地不动;`clix_path()` 递归到它自然停下(空名字不入串,根就打平成 `/`)。 `clix_shell(cmd)` 也靠它:沿 `parent` 爬到顶,确认是 `clix_init()` 建的根,再用 `container_of` 从 `&cli->root` 反算 `CliX *`。节点里因此不用存 shell 指针 —— 每条命令 32 字节就是这么省出来的。 ### 四条不变式 由 `clix_add()` / `clix_del()` 守,读树的代码(解析、补全、路径、帮助)因此不用写防御 分支: | 不变式 | 违反时 | | --- | --- | | 同层名字唯一 | `CLIX_ERR_EXIST` | | 一个节点至多一个父 | `CLIX_ERR_EXIST` | | 无环(`cmd` 不是 `parent` 的祖先) | `CLIX_ERR_PARAM` | | 摘除只动兄弟链,子树原样保留 | `CLIX_ERR_NOENT` | 第三条必须有:`clix_shell()`、`clix_path()`、`clix_del()` 都沿 `parent` 走,一个环就让 它们永不返回。代价是 O(深度),只在建树时付。 ### 节点的三种形态 | `cb` | `child` | 行为 | | --- | --- | --- | | 有 | 无 | 叶子:剩下的词全是参数 | | 无 | 有 | 组:`?`/`to` 能走到,单独执行时打印帮助 | | 有 | 有 | 兼有:匹配得上子命令就下沉,否则剩下的词当参数 | 第三种是「当前层即命令」的基础,也是解析器唯一需要回退的地方。 ### 一行字怎么变成一次回调 ```text clix_xline("gpio/bank0 set 1") │ 拷到栈上 buf[] ← 切词会破坏缓冲区 ▼ cx_parse_() 一趟扫描:空格 + 引号 + 反斜杠 │ argv = ["gpio/bank0", "set", "1"] ▼ cx_resolve_() 逐词下沉,返回第一个「参数」的下标 │ 首词含 '/' → cx_locate_();之后每词 clix_child() 下一层 ▼ cx_dispatch_() 禁用检查 → 调 cb → 负数统一打印 ``` 两条规则决定了外部行为: * **全局回退只对第一个词生效**。`clear` 在任何层级都能用,而 `gpio clear` 里它是 `gpio` 的参数。TAB 也遵守同一规则,否则补出来的行会执行成别的东西。 * **唯一的回退**:首词一个命令都没匹配上时,若当前层自己有 `cb`,整行当它的参数 (`to math/div` 后直接敲 `10 20`)。查找在前、回退在后,真命令不会被吞。 ### 遍历:补全与帮助是两套规则 帮助只列本层,沿 `child`/`next` 走、跳过 `CLIX_FLAG_HIDDEN` 即可。补全要「本层优先、 全局兜底」,所以用一个小迭代器 `cx_iter_t`:先走本层可见子节点,本层一个候选都没有时才 走 `root` 的全局命令,并按名字去重。`to`/`?` 的路径参数走同一个迭代器,但过滤掉内置 命令 —— 它们是命令,不是导航目标。 ### 复杂度 `d` 深度、`m` 某一层命令数、`w` 一行词数: | 操作 | 复杂度 | | --- | --- | | 解析并定位一行 / TAB 补全 | O(w · m) | | `clix_add()` | O(m + d) | | `clix_del()` | O(m) | | `clix_path()` / 提示符 / `clix_shell()` | O(d) | 关键在 `m` 是**某一层**的命令数而不是全树节点数:命令涨到几百条,查找仍只看当前层。 而一层只有十几个节点时线性扫描比任何索引都快,所以库里没有哈希也没有排序,`?` 的顺序 就是 `clix_add()` 的顺序。 --- ## 设计要点 树的组织、不变式与执行流程在上一节《[树结构原理](#树结构原理)》,这里只列几个取舍。 * **解析结果放栈上**。`clix_xline()` 用局部 `buf[]` 和 `argv[]`,多余的拷贝一次都不做, 也不存在「嵌套执行覆盖外层参数」的坑(早期把 `argv` 放进 `CliX`,每次执行都要存/恢复, 还多占 44 字节)。 * **原地切词**。`cx_parse_()` 直接在缓冲区上把分隔符改写成 `'\0'`,一趟里处理引号和转义, 没有 token 缓冲;代价是缓冲区被破坏,所以 `clix_xline()` 先拷一份。 * **名字比较只要一次有界比较**:`cx_strncmp_(name, s, len) == 0 && name[len] == '\0'`, 前一半通常第一个字节就返回,后一半顺手确认长度,省掉 `strlen()`。 * **层级就是上下文**。`level` 只是一个树节点指针:提示符沿 `parent` 拼路径,TAB 从 `level` 列候选,`?` 遍历 `level->child`。整组导航可以编译掉。 * **编译期裁剪而不是运行期判断**。用 `#if` 而不是 `if`:关掉历史、引号、ANSI、导航, 代码根本不进二进制,结构体字段也一起消失(`CLIX_HISTORY_MAX=0` 省 594 B RAM)。 * **按键翻译在库里,平台差异只在运行期**。见《[特殊按键与平台](#特殊按键与平台)》。 * **字节是默认单位,字符可选**。`CLIX_USE_WIDE` 只换编辑动作的单位,解析与派发不动, 四个接缝函数(`cx_snap_`/`cx_prev_char_`/`cx_next_char_`/`cx_cols_`)各自带 `#if` 实现。 --- ## 构建 ```bash cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release cmake --build build ctest --test-dir build --output-on-failure # 库的测试 ./build/clix-demo # demo,Ctrl-D 退出(见[文末](#demo)) ``` 只要库(嵌入式常用): ```bash cmake -S . -B build -DCLIX_BUILD_EXAMPLES=OFF -DCLIX_BUILD_TESTS=OFF ``` 产物是 `build/libclix.a`;也可以直接把 `clix.h` 和 `clix.c` 拷进工程,没有任何依赖。 --- ## 测试 测试目标随上面的构建一起编译,用 `ctest` 跑。**测试只覆盖库**,demo 不参与,换个 demo 不影响回归检查。 | 目标 | 配置 | 结果 | | --- | --- | --- | | `clix_test` | 默认 | 88 用例 / 861 断言 | | `clix_test_std` | `CLIX_USE_STDLIBC=1` | 同上(验证 libc 路径) | | `clix_test_wide` | `CLIX_USE_WIDE=1` | 88 用例 / 872 断言(宽模式下多几条断言) | | `clix_test_min` | `NAV/BUILTIN/QUOTE/ANSI=0`, `HISTORY=0`, `LINE=32`, `ARGC=4` | 13 断言(编译与冒烟) | | 文件 | 覆盖 | | --- | --- | | `test_tree.c` | 节点增删、重名与重复挂接的拒绝、环的拒绝、重复 `clix_init()` 后的重新挂接、子节点查找、`clix_shell` 反查、路径解析与生成 | | `test_exec.c` | 一到四层派发、参数透传、组节点帮助、未知命令、禁用与隐藏、全局命令、引号与转义、各类溢出、错误码上报、`priv`、嵌套执行、当前层即命令时回退参数 | | `test_complete.c` | 唯一匹配、逐层、`/` 路径、路径参数、公共前缀、候选分栏、隐藏命令排除、参数位不补命令名、全局命令只在第一个词位置补、内置命令不作路径目标 | | `test_history.c` | 环形缓冲、去重、空行、溢出淘汰、输入路径入栈、上下翻页、还原未提交输入、`history ` 的数字边界 | | `test_edit.c` | 插入/删除/退格、光标移动、Home/End 与方向键、三种按键编码(CSI / SS3 / `0xE0`+`0x00` 扫描码)、宽字符编辑与列宽(两种模式的契约都固定)、`Ctrl-A/B/E/F/K/U/W/L`、行长上限、转义状态机、`Ctrl-C`/`Ctrl-D` 的返回约定 | | `test_builtin.c` | `?` 的分层列举与路径参数、`to`/`here` 的层级移动、`tree` 的深度与数字边界、`clear`、`disp`、逐条摘除内置命令、提示符与开关配置、错误码文案 | | `test_util.c` | 输出捕获、字符串断言、夹具树 | 测试框架是自带的(`test/test.h`,不到 200 行)。历史、补全、行编辑这些内部实现也全部 通过 `clix_xchar()` 逐字节驱动来验证,不依赖内部符号,因此四种构建配置共用同一套用例 (宽模式那套多几条断言,断言两套契约)。 --- ## 性能 `demo bench ` 关掉所有输出,循环执行 `gpio read 3`,测的是库的「切词 + 定位 + 派发 + 回调」纯开销。 ```text clix/> demo bench 1000000 1000000 次 "gpio read 3" 耗时 49.2 ms, 约 20342155 次/秒 (sink=0) ``` 约 **20 M 次/秒**、49 ns 一次(x86-64 / gcc 13 / `-O3`)。同一负载独立计时:`-O2` 26.5 M/s,`-Os` 19.7 M/s。 为什么快:无 malloc/free,参数**原地切分**(把分隔符改成 `'\0'`);解析向量放栈上而不是 结构体里(省 44 B,也免掉嵌套执行的保存/恢复);名字比较用一次有界比较 + 读一个字节判 结尾,不做 `strlen()`;历史环形缓冲用条件减法代替 `%`;重绘只在必要时整行重画。 ### 内联策略 `clix.c` 里有个 `CX_INLINE_` 宏(`always_inline`,无该属性则退化为普通 `inline`), 只标在每次解析/补全都要走的**小函数**上。实测(`-m32 -Os`,交替测量取最小值;体积列 为当前版本复测,时间为该实验当时的实测值): | | `.text` | exec | path | 补全 | 列表 | 编辑 | 帮助 | | --- | --- | --- | --- | --- | --- | --- | --- | | 不标 | 8693 B | 52.5 ns | 89.4 ns | 80.6 ns | 81.5 ns | 39.1 ns | 64.1 ns | | **采用** | **8697 B** | **41.9** | **70.1** | **64.7** | **66.6** | **35.5** | **63.3** | 体积只差 4 字节,换来 exec 与路径解析快 20% 上下(`-Os` 档;`-O2` 下 gcc 自己会内联, 差别不大)。三条不标的理由: * `cx_strlen_`/`cx_memcpy_` 是**循环体**,复制进每个调用点会让行编辑慢 14%; * 补全迭代器标了能让 TAB 再快 8%,但要 +397 B,不值; * `-O2` 下 gcc 自己就会内联这些函数,标与不标只差 16 字节 —— 所以这项优化只对 `-Os` (嵌入式那一档)有意义。普通 `inline` 关键字在 `-Os` 下完全无效,故用了 `always_inline`。 --- ## 与扁平命令表相比 最简单的设备控制台就是一张单层命令表加一个空格切分。要不要用 CliX,先看多出来的这些 是否用得上: | | 扁平命令表 | CliX | | --- | --- | --- | | 命令组织 | 单层,命令一多就不好找 | 任意深度树,组/叶子可混 | | 命令寻址 | 只有命令名 | 层级路径、`to` 当前层级、全局命令 | | TAB 补全 | 列全部命令名 | 逐层跟随上下文,支持 `/` 路径与路径参数 | | 回调签名 | `cb(int argc, char **argv)` | `cb(CliXCmd *self, int argc, char **argv)`,节点带 `priv` | | 参数解析 | 空格切分 | 引号 + 反斜杠转义 | | 按键解码 | 通常不管,只读整行 | 方向键 / Home / End / Delete / `Ctrl-*` 全解 | | 错误上报 | 回调自己打印 | `CliXError` + `clix_strerror()`,框架保证只打一行 | | 配置粒度 | 编译期宏 | 编译期宏 + 每实例运行期 flags | 命令只有十几条、都在同一层时,扁平表更省事;一旦按外设或模块分成了两三层,逐层补全和 层级路径省下的输入与误操作通常更值钱。开销见《[资源占用](#资源占用)》。 --- ## Demo `example/` 是一个 PC 上的完整 demo,**不属于库**:`example.c` 是 60+ 条演示命令, `term.c` 是裸终端读写。本文档的终端输出都截自它。 | | 库 | demo | | --- | --- | --- | | 文件 | `clix.h` + `clix.c` | `example/example.c` + `example/term.c` | | 产物 | `build/libclix.a` | `build/clix-demo` | | 命令 | **只有 7 条内置命令** | 内置命令 + 60+ 条演示命令 | | 测试 | `test/` 全覆盖 | 不测 | ```bash cmake -S . -B build -G Ninja && cmake --build build ./build/clix-demo # 交互,Ctrl-D 退出 echo 'gpio read 13' | ./build/clix-demo # 非交互,逐行执行 stdin ``` 命令按 `/sys` `/gpio` `/led` `/uart` `/net` `/math` `/str` `/demo` 分组,演示四层嵌套、 参数校验、引号与转义、隐藏与禁用命令、运行期增删、嵌套 `clix_xline()`、多实例,以及 `demo bench` 性能基准。 `term.c` 也演示了「按键翻译不归宿主管」:POSIX 侧只做 `read()`,Windows 侧两条路由 CMake 选项 `CLIX_DEMO_WIN_GETCH`(默认 `ON`)选择 —— * **开**:`_getch()` 直接返回 `0xE0` + 扫描码,交给库自己的扫描码表解,demo 一行翻译都不写; * **关**:`ReadConsoleInputA` 的记录转成 ANSI 序列再喂进去,走库的 `ESC` 路径。 两条路都能用,区别只是谁来翻译,库本身两条都认。移到自己的工程时只要 `clix.h` 和 `clix.c`,`example/` 整个目录留下。 --- ## License 见 [LICENSE](LICENSE)。