Funpack5-1 - 用NXP FRDM-MCXA346 开发板设计一款简易shell模块
该项目使用了NXP FRDM-MCXA346,实现了简易shell的设计,它的主要功能为:一个通用的嵌入式shell模块。
标签
串口
NXP FRDM-MCXA346
Shell
LGX
更新2026-03-13
27

一、 项目介绍

本项目基于NXP FRDM-MCXA346主控板,设计一款轻量级Shell组件,核心目标是为MCU设备提供低成本、高兼容的命令交互能力,同时支持灵活拓展以适配不同业务场景。


1.1 硬件介绍

FRDM-MCXA346 是一款紧凑且可扩展的开发板,可让你快速基于FRDM-MCXA346微控制器单元(MCU)开展原型设计。它们提供行业标准的接口,可轻松访问MCU的I/O,配备集成的开放标准串行接口、外部闪存和板载MCU-Link调试器。通过 MCUXpresso Developer Experience提供其他工具,如面向附加板的扩展板中心和面向软件示例的应用代码中心

1.2 项目功能概述

  • 核心功能实现
    • 1. 串口数据收发与缓冲管理,基于环形缓冲区保障数据稳定传输;
    • 2. 基础交互控制,支持退格删除、光标移动等终端常用操作;
    • 3. 命令解析与执行,支持自定义命令注册、命令参数分词解析,具备命令匹配容错与错误提示;
    • 4. 进阶交互优化,实现Tab键命令自动补全、上下键历史命令回溯,提升操作效率;
    • 5. 多实例支持,可绑定不同串口实现独立Shell交互,互不干扰。
  • 可拓展性设计
    • 1. 预留功能键拓展入口,支持新增F1-F10等功能键的自定义处理逻辑;
    • 2. 命令体系可灵活拓展,通过统一注册接口新增命令,无需修改核心框架;
    • 3. 可快速对接AT指令等文本类交互协议,仅需调整命令匹配规则;
    • 4. 硬件适配拓展,采用IO抽象设计,剥离底层硬件依赖,可轻松移植到不同MCU平台。


二、 功能实现

2.1 目标功能

  • 接收终端输入的字符(含普通字符、控制字符、ESC控制序列)
  • 解析并响应控制操作(如回车执行命令、退格删除字符、上下键翻历史)
  • 执行用户预定义的命令列表
  • 将io层抽象出来,令shell不强制绑定串口这种接收数据的方式
  • 后续编写新的逻辑判断时不需要修改主要逻辑
  • 编写自定义命令时只需要关注命令名字,参数,和回调函数
  • 可以开多个shell且不相互影响
  • 不依赖于指定平台的函数或者外设

2.2 软件架构

mermaid-diagram (1).png

2.3 关键函数实现

2.3.1 宏定义

#define SHELL_BUF_MAX   	128		  // shell缓冲区长度
#define SHELL_HISTORY_MAX 8 // 历史数据最大存储数量
#define SHELL_LINE_MAX 128 // 历史数据最大长度
#define RETRY_CNT 50 // ESC序列重试次数
#define SHELL_MAX_CMDS 32 // 最大命令数量
#define SHELL_NAME_LEN 16 // 最大命令名字长度

2.3.2 结构体设计

  • 错误类型枚举
typedef enum {

SHELL_OK = 0,
SHELL_ERR_NULL = -1, // 空指针
SHELL_ERR_PARAM = -2, // 参数错误
SHELL_ERR_NOMEM = -3, // 内存不足
SHELL_ERR_INDEX = -4, // 索引错误
SHELL_EMPTY = -5, //空
SHELL_ERR_IO = -10, //IO错误
SHELL_ERR_CMD_NOT_FOUND = -21,//解析错误
} myshell_err_t;
  • Shell控制字符映射表结构体
typedef struct {
uint8_t key[3]; // 控制字符
uint8_t len; // 长度
myshell_err_t (*handler)(myshell_t *sh, uint8_t ch); // 处理函数
} shell_ctrl_map_t;
  • 自定义命令结构体
typedef struct {
const char *name; // 命令名,例如 "led"
void (*handler)(int argc, char **argv, void *data); // 回调函数
void *data; // 用户自定义数据
const char *help; // 命令帮助
} myshell_cmd_t;
  • IO结构体

核心作用是将 Shell 的输入输出逻辑与底层硬件完全解耦:不过这里实现的还是不够好

typedef struct {
void *ctx; //硬件地址
myfifo_t rx_fifo; //fifo缓冲区
int (*write)(void *ctx, const char *buf, int len);//写函数指针成员
} myshell_io_t;
  • shell结构体
typedef struct {
char name[SHELL_NAME_LEN];// shell名称
myshell_io_t io; //IO
uint8_t rx_buf[SHELL_BUF_MAX];//缓冲区数组
int rx_index; //光标位置索引
int line_len; //当前缓冲区长度
uint8_t esc_retry; // ESC序列重试次数
char history[SHELL_HISTORY_MAX][SHELL_LINE_MAX];// 历史记录存储数组
int hist_write; // 写入索引
int hist_head; // 最旧历史
int hist_tail; // 最新历史写入位置
int cmd_count; // 当前命令数量
myshell_cmd_t cmd_list[SHELL_MAX_CMDS];//自定义命令表
void *user; //预留数据指针
} myshell_t;

2.3.3 函数实现

  • 初始化函数
myshell_err_t myshell_init(myshell_t *sh, char *name, myshell_io_t *io, uint8_t *io_fifo, int fifo_size, fifo_mode_t fifo_flags);
myshell_err_t myshell_init(myshell_t *sh,
char *name,
myshell_io_t *io,
uint8_t *io_fifo,
int fifo_size,
fifo_mode_t fifo_flags)
{
sh->io = *io;
myfifo_init(&(sh->io.rx_fifo), io_fifo, fifo_size, FIFO_MODE_OVERWRITE);
sh->user = NULL;
sh->rx_index = 0;
sh->line_len = 0;
sh->hist_write = 0;
sh->hist_head = 0;
sh->hist_tail = 0;
sh->esc_retry = RETRY_CNT;
sh->cmd_count = 0;
int len = (strlen(name) > SHELL_NAME_LEN)? SHELL_NAME_LEN : strlen(name);
memmove(sh->name, name, len);
sh->name[len] = '\0'; // 保证结尾
myshell_prompt(sh); // 显示提示符

for (int i = 0; i < SHELL_HISTORY_MAX; i++)
{
sh->history[i][0] = '\0';
}
return SHELL_OK;
}
  • 提示符输出
static void myshell_prompt(myshell_t *sh)
{
char buf[SHELL_LINE_MAX];
int n = snprintf(buf, sizeof(buf), "%s > ", sh->name);
sh->io.write(sh->io.ctx, buf, n);
}
  • 自定义命令注册函数
myshell_err_t myshell_register_cmd(myshell_t *sh,  const char *name,  void (*handler)(int argc, char **argv,  void *data),void *data,  const char *help);
myshell_err_t myshell_register_cmd(	myshell_t *sh,
const char *name,
void (*handler)(int argc, char **argv, void *data),
void *data,
const char *help)
{
if (sh->cmd_count >= SHELL_MAX_CMDS) return SHELL_ERR_INDEX;

myshell_cmd_t *cmd = &sh->cmd_list[sh->cmd_count++];
cmd->name = name;
cmd->handler = handler;
cmd->data = data;
cmd->help = help;

return SHELL_OK;
}
    • 使用示例
void cmd_hello(int argc, char **argv, void *data)
{
UART_SendString(&shellUart, "world");
for (int i = 0; i < argc; i++)
{
UART_SendString(&shellUart, argv[i]);
}
UART_SendString(&shellUart, "\r\n");
}
myshell_register_cmd(&shell, "hello", cmd_hello, NULL, "hello command");
  • 核心轮询函数

通过查询资料,可以将把输入的数据大致分为3类:可打印字符,不可打印字符,ESC控制序列。

且为了保证方便后续添加更多的逻辑,采用将字符处理分类处理,维护俩个函数表shell_crtl_table[]shell_esc_table[],本项目只实现了退格,tab补全,上下箭头访问历史,左右箭头移动光标以及CTRL+C/U/A/E,后续如果需要添加其他字符只需要编写对应处理函数并添加到对应的函数表即可

将可打印字符回显,需要注意的是光标的位置,假如在中间的话就需要剪切缓冲区->打印缓冲区->把光标重新移动到原位置

将ESC序列(如上箭头为:ESC [ A/0x1b 0x5b 0x41)检查到后进行查表,查询匹配的命令并调用对应的处理函数,同时加入次数机制,当esc_retry 归0后或者匹配成功后再处理掉,避免因为串口读取速度导致读取到0x1b的时候后续字节还没到达导致误处理;还有一种解决方案是在接收到0x1b后稍微延时一会,但是考虑到后续如果需要移植的话需要适配对应的延时函数,于是乎这里采用次数的判断

剩下的就是单字节控制字符了,例如退格,tab等,直接查表使用对应的处理函数就行

void myshell_poll(myshell_t *sh)
void myshell_poll(myshell_t *sh)
{
uint8_t ch; // 临时存储从FIFO读取的字符
myfifo_err_t err; // FIFO操作返回的错误码
// 1. 快速检测:若FIFO缓冲区为空,直接返回,减少空轮询消耗
if (FIFO_ERR_EMPTY == myfifo_is_empty( &(sh->io.rx_fifo) ) )
return;
// 2. 预读取FIFO首个字符(peek不弹出,仅查看),失败则返回
if ( FIFO_OK != myfifo_peek( &(sh->io.rx_fifo), &ch) )
return;
// 3. 处理可打印字符(0x20~0x7E,即空格到~的可见ASCII字符)
if (ch >= 0x20 && ch <= 0x7E)
{
myfifo_pop( &(sh->io.rx_fifo), &ch); // 弹出FIFO中的该字符(正式消费)
// 防缓冲区溢出:输入长度达到上限则拒绝接收
if (sh->line_len >= SHELL_BUF_MAX - 1)
return;
// 字符插入逻辑:若光标不在行尾,将光标后字符后移,实现中间插入
if (sh->rx_index < sh->line_len)
memmove(&sh->rx_buf[sh->rx_index + 1], &sh->rx_buf[sh->rx_index], sh->line_len - sh->rx_index);
sh->rx_buf[sh->rx_index] = ch; // 插入当前字符到光标位置
sh->rx_index++; // 光标后移一位
sh->line_len++; // 输入行总长度+1
// 回显逻辑:保证终端显示与输入内容同步
sh->io.write(sh->io.ctx, &ch, 1); // 先回显当前输入的字符
// 若光标不在行尾,回显光标后剩余字符,再通过ANSI转义序列将光标移回原位置
if (sh->rx_index < sh->line_len)
{
// 回显光标后的字符
sh->io.write(sh->io.ctx, (char *)&sh->rx_buf[sh->rx_index], sh->line_len - sh->rx_index);
// 构造ANSI光标左移序列:\x1B[%dD 表示左移N位
char seq[16] ;
int n = snprintf(seq, sizeof(seq), "\x1B[%dD", sh->line_len - sh->rx_index);
sh->io.write(sh->io.ctx, seq, n); // 光标移回插入位置
}
}
// 4. 处理ESC控制序列
else if (ch == 0x1B)
{
bool esc_matched = false; // 标记是否匹配到有效的ESC序列
// 遍历ESC控制序列映射表,匹配对应的控制序列
for (size_t idx = 0; idx < sizeof(shell_esc_table) / sizeof(shell_esc_table[0]); idx++)
{
const shell_ctrl_map_t *esc_entry = &shell_esc_table[idx];
if (esc_entry->len == 0) // 无效表项跳过
continue;
// 预读取FIFO中ESC后续的N个字节(N=序列长度),用于匹配
uint8_t peek_buf[4] = {0};
int peek_len = myfifo_peek_n(&(sh->io.rx_fifo), peek_buf, esc_entry->len + 1);
if ( peek_len < esc_entry->len + 1) // 字节数不足,跳过当前表项
continue;
// 对比预读取的字节与映射表中的序列是否匹配
int match_count = 0;
for (int j = 1; j <= esc_entry->len; j++)
{
if (peek_buf[j] == esc_entry->key[j - 1])
match_count++;
}
// 匹配成功:调用对应处理函数,并弹出FIFO中已匹配的后续字节
if (match_count == esc_entry->len)
{
esc_matched = true;
esc_entry->handler(sh, 0); // 调用序列对应的处理函数
// 弹出FIFO中已匹配的ESC后续字节
uint8_t tmp;
for (int k = 0; k < esc_entry->len; k++)
myfifo_pop(&(sh->io.rx_fifo), &tmp);
break; // 匹配成功后退出遍历
}
}
// 消费ESC字符:匹配成功 或 重试次数耗尽时,弹出FIFO中的ESC字节
if ( esc_matched || sh->esc_retry-- == 0)
{
myfifo_pop(&(sh->io.rx_fifo), &ch); // 消费ESC字符
sh->esc_retry = RETRY_CNT; // 重置ESC重试次数
}
}
// 5. 处理单字节控制符
else
{
myfifo_pop( &(sh->io.rx_fifo), &ch); // 弹出并消费该控制字符
// 遍历单字节控制符映射表,调用对应处理函数
for (size_t i = 0; i < sizeof(shell_ctrl_table) / sizeof(shell_ctrl_table[0]); i++)
{
if (shell_ctrl_table[i].key[0] == ch)
{
shell_ctrl_table[i].handler(sh, ch); // 调用控制符处理函数
return; // 处理完成后直接返回
}
}
}
}

image.png

  • shell_ctrl_table[] ,shell_ctrl_table[]

详细函数实现就不贴了,都放在附件

static const shell_ctrl_map_t shell_ctrl_table[] = {
{ {'\r'}, 1, ctrl_enter },
{ {'\n'}, 1, ctrl_enter },

{ {0x08}, 1, ctrl_backspace },
{ {0x7F}, 1, ctrl_backspace },

{ {0x03}, 1, ctrl_cancel }, // Ctrl+C
{ {0x15}, 1, ctrl_clear_line }, // Ctrl+U
{ {0x01}, 1, ctrl_home }, // Ctrl+A
{ {0x05}, 1, ctrl_end }, // Ctrl+E
{ {0x09}, 1, ctrl_tab }
};
static const shell_ctrl_map_t shell_ctrl_table[] = {
{ {'\r'}, 1, ctrl_enter },
{ {'\n'}, 1, ctrl_enter },

{ {0x08}, 1, ctrl_backspace },
{ {0x7F}, 1, ctrl_backspace },

{ {0x03}, 1, ctrl_cancel }, // Ctrl+C
{ {0x15}, 1, ctrl_clear_line }, // Ctrl+U
{ {0x01}, 1, ctrl_home }, // Ctrl+A
{ {0x05}, 1, ctrl_end }, // Ctrl+E
{ {0x09}, 1, ctrl_tab }
};


三、功能展示

  • 输入,插入,删除,光标的移动等(详情看演示视频)

image.png

  • 编写了俩个自定命令,用途分别是输入hell哦,打印出world,以及led的开关
void cmd_led(int argc, char **argv, myshell_t *sh, void *data)
{
// 1. 参数校验:必须传入子命令(argc需等于2,否则提示用法)
if (argc != 1)
{
char arg[] = "Usage: led <on/off/toggle>\r\n";
sh->io.write(sh->io.ctx, arg, strlen(arg));
return;
}

if (strcmp(argv[0], "on") == 0 || strcmp(argv[0], "ON") == 0)
{
GPIO_PinWrite(BOARD_LED_GPIO, BOARD_LED_GPIO_PIN, 0);
// 输出执行结果提示
char res[] = "LED turned on\r\n";
sh->io.write(sh->io.ctx, res, strlen(res));
}
// 匹配 "off":熄灭LED(清零GPIO)
else if (strcmp(argv[0], "off") == 0 || strcmp(argv[0], "OFF") == 0)
{
GPIO_PinWrite(BOARD_LED_GPIO, BOARD_LED_GPIO_PIN, 1);
// 输出执行结果提示
char res[] = "LED turned off\r\n";
sh->io.write(sh->io.ctx, res, strlen(res));
}
// 匹配 "toggle":翻转LED状态(保留原逻辑)
else if (strcmp(argv[0], "toggle") == 0 || strcmp(argv[0], "TOGGLE") == 0)
{
GPIO_PortToggle(BOARD_LED_GPIO, 1u << BOARD_LED_GPIO_PIN);
// 输出执行结果提示
char res[] = "LED toggled\r\n";
sh->io.write(sh->io.ctx, res, strlen(res));
}
// 3. 非法子命令:提示错误
else
{
char err[] = "Error: invalid argument! Usage: led <on/off/toggle>\r\n";
sh->io.write(sh->io.ctx, err, strlen(err));
}

}
void cmd_hello(int argc, char **argv, myshell_t *sh, void *data)
{

char arg[] = "world";
sh->io.write(sh->io.ctx, arg, strlen(arg));
for (int i = 0; i < argc; i++)
{
sh->io.write(sh->io.ctx, argv[i], strlen(argv[i]));
}
UART_SendString(&shellUart, "\r\n");
}

并在添加到shell中

myshell_register_cmd(&shell, "hello", cmd_hello, NULL, "hello command");
myshell_register_cmd(&shell, "heel", cmd_hello, NULL, "hello command");
myshell_register_cmd(&shell, "hool", cmd_hello, NULL, "hello command");
myshell_register_cmd(&shell, "led", cmd_led, NULL, "led");

在中断中输入led on后板载红色led灯亮起,输入led off后板载led灯熄

输入hello后正常打印出world以及后续的参数

当有多个匹配命令时,按下tab键打印出所有匹配的命令,一个匹配时可以直接补全命令

image.png

8e063f56874d3c629c760d2e2b307502.jpgbef2f85a43a3da151d59f75c7cdad6b5.jpg


四、总结

写完这次的代码感觉整个人更抽象了呢(,好吧其实是感觉对于解耦设计的能力提高了不少,一个好的架构设计真的是能节省很多后续开发的时间(虽然前面会很花时间)。也深刻体会到自己的能力还是有些不足,当前只能设计到这种地步,代码中还有很多地方其实不怎么合理,虽然定义了错误类型枚举量,但没有好好的使用起来,对于内存管理这部分也是忽略了很多检查,只能是用于一些个人小项目使用(不过反正也有很多开源的其实不用自己写)。

也感谢电子森林与得捷电子联合推出的 《Funpack》 系列活动,这对我来说是一个很好的学习动力,希望后续可以推出更多有趣的板卡和题目。

附件下载
myshell.zip
团队介绍
个人
团队成员
LGX
评论
0 / 100
查看更多
硬禾服务号
关注最新动态
0512-67862536
info@eetree.cn
江苏省苏州市苏州工业园区新平街388号腾飞创新园A2幢815室
苏州硬禾信息科技有限公司
Copyright © 2024 苏州硬禾信息科技有限公司 All Rights Reserved 苏ICP备19040198号