0%

【MCU】嵌入式开发之命令行调试-Shell全剖析

前言

本人初次接触命令行调试,还是在大学时初学正点原子 STM32 教程,其提供了一个软件组件用于调试:(只要你在串口输入相应的函数名,单片机就能执行相应的函数),当时真的惊为天人,觉得这种调试方法很神奇也很有意思。

那时不了解什么是命令行,什么是Shell,也没有能力或者精力去了解其原理实现,即使那软件组件并不具备一个 Shell 的完整功能,但也足以震惊初学STM32点灯的我

随着开发经历多起来,日常Linux Shell、PowerShell、嵌入式Shell 等等各种什么 Shell 的字眼充斥耳边,本人也对 Shell 这个词有了新的理解,因此也就写下这篇文章,主要为总结和升华个人对嵌入式 Shell 的应用及理解,当然也包括其它的一些方面,或者说在研读源码过程中,能够学习到一些编程方式/方法/思想,那就更好不过了

命令行概述

官方百科:Shell,即命令行解释器,是计算机操作系统的一种用户界面,用户可以通过Shell与操作系统进行交互,输入命令来执行各种操作、控制系统的各种功能。Bash、Windows CMD、PowerShell等都是属于命令行解释器

那么嵌入式Shell也是同理,只不过用户是使用上位机串口软件输入命令与嵌入式设备进行交互而已(通常是通过UART串口与外界交互,但其实也可以将输入输出流设置到其它外设上,如蓝牙BLE透传、TCP/IP等)

比如在Linux Shell中,用户在命令行界面输入如ls cd touch gcc make等这些命令,使得Shell解释器会调用相对应名称的可执行程序,当然了通常输入命令还可以附带参数,这就跟C函数也有入口参数一样。

换句话说,命令就是可执行程序,其就是一个被封装好的功能函数。


另外,命令行能够直接调用这些可执行程序,是因为它们的绝对路径被声明到环境变量路径中了,这样使得Shell解释器能够找到它们

所以,不管是在Linux,还是Windows中,环境变量都是个重要概念



下面主要以 Letter-Shell、RT-Thread FinSH 控制台为例,讲述嵌入式Shell的应用及原理分析

引出命令行函数原理

从通俗的角度来说,命令行调用就相当于:用户向设备发送一个指令,这个指令约定好跟一个指定操作有着映射关系,于是设备收到该指令后,去查询并执行其对应映射的操作。

就 Letter-Shell 为例(其它嵌入式Shell同理),通常移植完成后,其引出命令行的代码如下:

1
2
3
4
void cmd_test(void) {
printf("Hello world ! \\n");
}
SHELL_EXPORT_CMD(SHELL_CMD_PERMISSION(0)|SHELL_CMD_TYPE(SHELL_TYPE_CMD_FUNC), cmdTest, cmd_test, test cmd);

如上,当在Shell中输入cmdTest时,程序则会调用执行cmd_test()函数,而test cmd则是命令的说明部分。

其主要是通过定义SHELL_EXPORT_CMD宏,将函数添加到shell的可执行命令列表中,该宏涉及预处理和链接特性,其实现如下:

1
2
3
4
5
6
7
8
9
10
11
#define SHELL_EXPORT_CMD(_attr, _name, _func, _desc) \\
const char shellCmd##_name[] = #_name; \\
const char shellDesc##_name[] = #_desc; \\
SHELL_USED const ShellCommand \\
shellCommand##_name SHELL_SECTION(\"shellCommand\") = \\
{ \\
.attr.value = _attr, \\
.data.cmd.name = shellCmd##_name, \\
.data.cmd.function = (int (*)())_func, \\
.data.cmd.desc = shellDesc##_name \\
}

这个宏接受四个参数:_attr、_name、_func和_desc,分别代表命令的属性、命令名、函数和描述。这个宏的作用是生成一个 ShellCommand 类型的全局变量,这个变量会被放在一个特殊的段(shellCommand)中。

上述代码详细解释如下:

  • SHELL_USED__attribute__((used)),指示编译器不要优化这个变量
  • SHELL_USED const ShellCommand \\中的ShellCommand是一个结构体(联合体)变量类型,其元素包含命令的属性、命令名、函数指针、或者存放按键键值、用户名、用户密码等等信息
  • SHELL_SECTION(\"shellCommand\")__attribute__((section(shellCommand))),指示将定义的这个结构体变量声明到这个段中

另外,值得注意的是,其声明的ShellCommand结构体变量类型节选如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
typedef struct shell_command
{
union
{
struct
{
unsigned char permission : 8; /**< command权限 */
ShellCommandType type : 4; /**< command类型 */
unsigned char enableUnchecked : 1; /**< 在未校验密码的情况下可用 */
unsigned char disableReturn : 1; /**< 禁用返回值输出 */
unsigned char readOnly : 1; /**< 只读 */
unsigned char reserve : 1; /**< 保留 */
unsigned char paramNum : 4; /**< 参数数量 */
} attrs;
int value;
} attr; /**< 属性 */
union
{
struct
{
const char *name; /**< 命令名 */
int (*function)(); /**< 命令执行函数 */
const char *desc; /**< 命令描述 */
} cmd; /**< 命令定义 */
// ......
} data;
} ShellCommand;

在上述代码中,在声明宏的时候会执行变量初始化有.data.cmd.function = (int (*)())_func,这里以及变量定义中的int (*function)();都是指声明一个函数指针,此函数接受任意数量和类型的参数,并返回一个整数

但是从 C99 开始,该种声明方式已经被弃用,其有可能会导致未定义的行为。因此,在使用 Letter_Shell 时,建议确保编译器的C语言标准为 C89/C99/GNU89/GNU99 均可。如在 GCC 开发环境中添加编译选项-std=gnu99


在链接阶段,链接器会将同一段中的所有变量放在一起,形成一个数组。这样,Shell就可以通过遍历这个数组来获取所有的命令,如下:

不同的编译器特性不一样,但程序都是通过获取这个段的首末地址,然后在输入命令时,轮询这个段并执行相应的命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 以下为针对不同编译器,声明外部链接器中给段定义好的 段 的首末地址变量
#if defined(__CC_ARM) || (defined(__ARMCC_VERSION) && __ARMCC_VERSION >= 6000000)
extern const unsigned int shellCommand$$Base;
extern const unsigned int shellCommand$$Limit;
#elif defined(__ICCARM__) || defined(__ICCRX__)
#pragma section="shellCommand"
#elif defined(__GNUC__)
extern const unsigned int _shell_command_start;
extern const unsigned int _shell_command_end;
#endif


// 以下为记录 shellCommand 段的首末地址,并计算出有多少个命令
#if defined(__CC_ARM) || (defined(__ARMCC_VERSION) && __ARMCC_VERSION >= 6000000)
shell->commandList.base = (ShellCommand *)(&shellCommand$$Base);
shell->commandList.count = ((unsigned int)(&shellCommand$$Limit)
- (unsigned int)(&shellCommand$$Base))
/ sizeof(ShellCommand);

#elif defined(__ICCARM__) || defined(__ICCRX__)
shell->commandList.base = (ShellCommand *)(__section_begin("shellCommand"));
shell->commandList.count = ((unsigned int)(__section_end("shellCommand"))
- (unsigned int)(__section_begin("shellCommand")))
/ sizeof(ShellCommand);
#elif defined(__GNUC__)
shell->commandList.base = (ShellCommand *)(&_shell_command_start);
shell->commandList.count = ((unsigned int)(&_shell_command_end)
- (unsigned int)(&_shell_command_start))
/ sizeof(ShellCommand);

其中,如果是在GCC编译环境下,那么需要在链接脚本中加入以下代码,以使得C源程序能够获取ShellCommand的首末地址,不然程序编译会报错

1
2
3
4
5
6
7
8
9
10
11
.rodata :
{
. = ALIGN(4);
*(.rodata) /* .rodata sections (constants, strings, etc.) */
*(.rodata*) /* .rodata* sections (constants, strings, etc.) */
. = ALIGN(4);

_shell_command_start = .;
KEEP (*(shellCommand))
_shell_command_end = .;
} >FLASH

综上所述,引出命令行的原理是:通过一个宏,定义一个全局结构体变量(其包含要引出的命令函数及其相关属性信息),并将该变量声明到一个指定的段中。Shell在接受外界命令时,会轮询这个段内的所有结构体变量,找到对应的命令函数指针并执行调用。

命令行解析

1
2
3
4
void shellTaskPoll(void)
{
shellTask((void *)&shell);
}

上述为在移植 Letter_Shell 组件,需要添加的核心轮询函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void shellTask(void *param)
{
Shell *shell = (Shell *)param;
char data;
#if SHELL_TASK_WHILE == 1
while(1)
{
#endif
if (shell->read && shell->read(&data, 1) == 1)
{
shellHandler(shell, data);
}
#if SHELL_TASK_WHILE == 1
}
#endif
}

上述代码在每次读取到终端的一个字节数据,而后都会调用到shellHandler函数,shellHandler函数的主要执行如下:

  • 首先将字节与键值表查看是否匹配,如果匹配,则执行相应的键值处理函数。比如检测匹配收到了一个回车键,则会执行相应的回车处理函数shellEnter
  • 如果字节与键值都不匹配,则认为是正常输入字符,从而调用shellNormalInput函数,将字符数据记录到 buffer 中,并调用shell->writeuserShellWrite函数将输入的字符回显到终端上
  • 其中也插有各种附带的功能,比如权限检查的条件判断等等,此处不讲述

在 Letter_Shell 中,其对所有的命令行函数解析,都会记录下argcargv[],最终在调用时,才会根据先前宏定义声明的函数类型进行 函数指针 + 参数 的强制指向调用

命令表

Letter_Shell在轮询收到回车键时,会调用回车键值处理函数shellEnter -> 调用shellExec()运行命令函数(先检查密码是否已验证) -> 调用shellSeekCommand匹配命令 -> 匹配非空,且权限检查通过 -> 则调用shellRunCommand()根据不同的类型执行相应的函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/**
* @brief shell匹配命令
*
* @param shell shell对象
* @param cmd 命令
* @param base 匹配命令表基址
* @param compareLength 匹配字符串长度
* @return ShellCommand* 匹配到的命令
*/
ShellCommand* shellSeekCommand(Shell *shell,
const char *cmd,
ShellCommand *base,
unsigned short compareLength)
{
const char *name;
unsigned short count = shell->commandList.count -
((int)base - (int)shell->commandList.base) / sizeof(ShellCommand);
for (unsigned short i = 0; i < count; i++)
{
if (base[i].attr.attrs.type == SHELL_TYPE_KEY
|| shellCheckPermission(shell, &base[i]) != 0)
{
continue;
}
name = shellGetCommandName(&base[i]);
if (!compareLength)
{
if (strcmp(cmd, name) == 0)
{
return &base[i];
}
}
else
{
if (strncmp(cmd, name, compareLength) == 0)
{
return &base[i];
}
}
}
return NULL;
}

main 类型函数

用户在终端输入命令执行时,shell通过识别空格键回车键,累计计算得出函数的入参个数,存放至shell->parser.paramCount,将入参以字符串的形式存放于二级指针数组中,最终调用main类型函数时将参数个数以及参数二级指针作为入参。

如下代码:

1
2
3
4
5
6
7
8
9
10
if (command->attr.attrs.type == SHELL_TYPE_CMD_MAIN)
{
shellRemoveParamQuotes(shell);
returnValue = command->data.cmd.function(shell->parser.paramCount,
shell->parser.param);
if (!command->attr.attrs.disableReturn)
{
shellWriteReturnValue(shell, returnValue);
}
}

普通函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
int shellExtRun(Shell *shell, ShellCommand *command, int argc, char *argv[])
{
unsigned int params[SHELL_PARAMETER_MAX_NUMBER] = {0};
int paramNum = command->attr.attrs.paramNum > (argc - 1) ?
command->attr.attrs.paramNum : (argc - 1);
for (int i = 0; i < argc - 1; i++)
{
params[i] = shellExtParsePara(shell, argv[i + 1]);
}
switch (paramNum)
{
#if SHELL_PARAMETER_MAX_NUMBER >= 1
case 0:
return command->data.cmd.function();
// break;
#endif /** SHELL_PARAMETER_MAX_NUMBER >= 1 */
#if SHELL_PARAMETER_MAX_NUMBER >= 2
case 1:
return command->data.cmd.function(params[0]);
// break;
#endif /** SHELL_PARAMETER_MAX_NUMBER >= 2 */

// ......
}
}

键值的匹配与引出

通过宏定义SHELL_EXPORT_KEY引出

其中一些按键如:退格、回车、左右方向,是声明了不用权限检查的,即是不用通过密码通过或者等级检查等等,也能直接调用相关键值执行函数。(因为输入密码时也需要用到这些键值,,这里扯多了)

而一些按键如:Tab,是需要权限检查的;当密码尚未验证时,输入这个键,会提示请输入密码相关信息

Shell 附加功能

密码设置

用户通过宏定义设置:

1
2
3
4
5
/**
* @brief shell默认用户密码
* 若默认用户不需要密码,设为""
*/
#define SHELL_DEFAULT_USER_PASSWORD ""

程序在初始化时会设置值shell->status.isChecked,如果定义了密码字符,则定义其值为 1。执行流为:shellInit -> shellSetUser -> shell->status.isChecked

而后,Shell在每次的字符输入和命令执行时,都会检查这个shell->status.isChecked

实时操作系统下获取当前线程运行状态

这个没啥好说的,实时系统自己针对Shell内置的一些命令罢了。

每个线程都有自己的句柄,其记录下线程状态的各个标记位,或者各种信息,可以通过命令行函数展示打印出来

线程栈最大使用率

主要原理为:在线程初始化时,将线程栈(即一个静态数组)全部初始化为一个特定的字节,比如 1。在查看最大使用率时,从栈底往栈顶方向计数,算出字节 1 的数量占整个栈大小的比例,将 100% 减去这个比例即是最大使用率

CPU空闲率

需要上实时操作系统才能支持,通过计算单元时间内 空闲任务的运行时间占比即可

日志输出

颜色的标准配置

日志等级

Shell 移植适配

此处不过多赘述

注意项及疑惑解答

实际应用中避免串口回环,导致函数陷入死循环

如使用串口作为终端,则要考虑到串口 tx、rx 可能的短接导致的问题

声明的命令行函数接受任何类型的参数并返回int型

在 Letter_Shell 中,其使用的仍然是旧式声明,(如int foo();

此旧式声明只是阻止编译器检查参数类型和数量,但假如传入的参数不符合函数定义中的要求,则导致未定义行为

旧式的声明/定义已经在新的 C2x 标准草案中被移除了。此后,int foo(); 等价于 int foo(void);

按键键值匹配设置

换行,tab等标准功能 参照Shell

串口软件之按键键值映射,参照SecureCRT

参考站点