返回文章列表
技术

嵌入式shell

·11 分钟阅读

shell简易版

一、 核心数据结构:命令字典与函数指针

思路归纳: 将“用户输入的字符串”与“底层执行的代码”进行解耦。利用结构体数组建立一个“命令字典(Command Table)”,通过查表法实现指令的分发,彻底抛弃臃肿的 if-elseswitch-case 字符串匹配。

命令字典结构体(Command Table) 用于绑定触发命令的单词、对应的函数入口以及帮助说明。

  • 函数指针: 统一使用 void (*func)(int argc, char *argv[]) 签名,模仿 Linux C 程序的 main 函数入参,实现参数的动态传递。
  • 极致省内存: 将整个命令表用 const 修饰,强制编译进 Flash(ROM)中,不占用 RAM。
hljs c
// 命令字典条目定义
typedef struct {
   const char *name;                      // 触发命令的字符串 (如 "led")
   void (*func)(int argc, char *argv[]);  // 绑定的执行函数指针
   const char *desc;                      // 帮助说明文本
} shell_cmd_st;

// 静态实例化的命令表 (全 const,存入 ROM)
static const shell_cmd_st cmd_table[] = {
   {"hello", cmd_hello, "Print hello message"},
   {"led",   cmd_led,   "Control LED: led on/off"},
   {"help",  cmd_help,  "Show help"},
   {NULL, NULL, NULL}   // 查表结束的哨兵标志
};

二、 运行时缓存区:单片机的“命令行大脑”(RAM区)

思路归纳: 终端的交互是一个持续的字符流输入过程,系统需要极少量的 RAM 来记住用户“当前敲了什么”、“光标在哪”以及“上次敲了什么”。

  • line_buf (行缓存): 存放用户当前正在输入的字符串。
  • last_cmd (历史记录): 极简的历史记录机制,缓存上一次成功执行的命令,用于上下键调用。
  • line_len & cursor_pos (长度与光标): line_len 记录当前字符串总长度;cursor_pos 记录真正的物理光标位置(用于支持在句子中间插入/删除字符)。
  • esc_state (ANSI 状态机): 用于捕捉并解析键盘特殊按键(上下左右方向键)产生的 ESC 转义序列,防止屏幕出现乱码。
hljs c
#define SHELL_LINE_MAX 128U
static char line_buf[SHELL_LINE_MAX];
static char last_cmd[SHELL_LINE_MAX] = {0};
static uint16_t line_len = 0U;
static uint16_t cursor_pos = 0U;
static uint8_t esc_state = 0U;

三、 核心神技:ANSI 转义序列与内存平移

思路归纳: 在单片机串口上实现类似 Linux 终端的“中间插入/删除”,绝对不能用清屏重绘(极度卡顿)。必须利用标准终端软件(如 Xshell、Putty)自带的 ANSI 控制码 配合 C 语言的 memmove 实现零闪烁渲染。

1. 局部刷新渲染器 (shell_refresh_tail) 当用户在句子中间插入或删除字符时,只重绘光标后面的“尾巴”。

  • \x1B[K (清除到行尾): 这是 ANSI 神技,一秒擦除光标右侧的所有残影。
  • 退回光标: 打印完尾巴后,用 \b 把物理光标稳稳拉回原来插入/删除的位置。
hljs c
static void shell_refresh_tail(void) {
   shell_send("\x1B[K"); // ANSI指令:清除光标到行尾的内容
   if (cursor_pos < line_len) {
       uint16_t tail_len = (uint16_t)(line_len - cursor_pos);
       // 把被挤到后面的字符重新打印出来
       HAL_UART_Transmit(&huart1, (uint8_t *)&line_buf[cursor_pos], tail_len, 10U);
       // 把物理光标退回到插入点
       for (uint16_t i = 0U; i < tail_len; i++) {
           shell_send("\b");
       }
   }
}

四、 逻辑控制:一镜到底的字符流处理器

思路归纳: 串口每收到一个字符,就喂给 app_shell_process。该函数通过扁平化的 if 分支,将字符分流到“方向键处理”、“回车执行”、“退格删除”或“字符插入”四大逻辑块中。

1. 过滤与拦截:ANSI 方向键状态机 防止按下方向键时屏幕出现 [D [C 乱码,将其转化为实际的光标移动或历史命令调用。

hljs c
void app_shell_process(char ch) {
   // 状态 1: 捕捉起始符 ESC (0x1B)
   if ((uint8_t)ch == 0x1BU) { esc_state = 1U; return; }
   // 状态 2: 捕捉括号 [
   if ((esc_state == 1U) && (ch == '[')) { esc_state = 2U; return; }
   
   // 状态 3: 解析方向键 (A上, B下, C右, D左)
   if (esc_state == 2U) {
       if ((ch == 'D') && (cursor_pos > 0U)) {
           cursor_pos--; shell_send("\b");       // 左键
       } else if ((ch == 'C') && (cursor_pos < line_len)) {
           cursor_pos++; shell_send("\x1B[C");   // 右键
       } else if (((ch == 'A') || (ch == 'B')) && (last_cmd[0] != '\0')) {
           // 上下键:调出历史命令
           while (cursor_pos > 0U) { shell_send("\b"); cursor_pos--; } // 退回行首
           shell_send("\x1B[K");                                       // 清空当前行
           strcpy(line_buf, last_cmd);                                 // 覆盖缓存
           line_len = cursor_pos = (uint16_t)strlen(line_buf);
           shell_send(line_buf);
       }
       esc_state = 0U; return; // 吞噬结束,复位状态机
   }
   esc_state = 0U; // 遇到杂乱字符强制复位
   
   // ... 后续常规字符处理 ...

2. 插入与删除:memmove 内存平移 利用 C 语言标准库极其高效的内存平移操作,为插入的字符腾出空间,或覆盖被删除的字符。

hljs c
   // --- 退格删除逻辑 ---
   if (ch == '\b' || ch == 0x7F) {
       if (cursor_pos > 0U) {
           cursor_pos--; line_len--;
           // 把后面的数据整体往前拉一格,覆盖掉要删除的字符
           memmove(&line_buf[cursor_pos], &line_buf[cursor_pos + 1U], (size_t)(line_len - cursor_pos));
           shell_send("\b");       // 物理光标退一格
           shell_refresh_tail();   // 重绘尾巴
       }
       return;
   }

   // --- 字符插入逻辑 ---
   if (((uint8_t)ch >= 0x20U) && ((uint8_t)ch <= 0x7EU)) {
       if (line_len < (SHELL_LINE_MAX - 1U)) {
           // 把后面的数据整体往后推一格,腾出空位
           memmove(&line_buf[cursor_pos + 1U], &line_buf[cursor_pos], (size_t)(line_len - cursor_pos));
           line_buf[cursor_pos] = ch; // 填入新字符
           cursor_pos++; line_len++;
           HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1U, 10U); // 回显
           shell_refresh_tail();                                // 重绘尾巴
       } else {
           shell_send("\a"); // 触达界限,发出警告音
       }
   }
}

五、 命令解析与执行:词法切割 (strtok)

思路归纳: 当检测到回车键(\r\n)时,说明一条完整的指令输入完毕。利用 strtok 函数以空格为界限,将完整的字符串切割成单词数组(argv),并统计数量(argc),随后在“命令字典”中查表执行。

hljs c
static void shell_execute(char *cmd_line) {
   char *argv[SHELL_ARGV_MAX];
   int argc = 0;
   
   // 1. 词法切割:将 "led on" 切割为 argv[0]="led", argv[1]="on"
   char *token = strtok(cmd_line, " \t");
   while ((token != NULL) && (argc < SHELL_ARGV_MAX)) {
       argv[argc++] = token;
       token = strtok(NULL, " \t");
   }

   if (argc == 0) return; // 空回车直接退出

   // 2. 查表匹配:遍历 cmd_table 寻找同名命令
   for (int i = 0; cmd_table[i].name != NULL; i++) {
       if (strcmp(argv[0], cmd_table[i].name) == 0) {
           // 找到命令,直接调用绑定的函数指针,把切割好的参数传进去
           cmd_table[i].func(argc, argv);
           return;
       }
   }

   // 3. 兜底处理:未找到命令
   my_printf(&huart1, "Unknown command: %s\r\n", argv[0]);
}

相关文章