4、Shell调试子系统:Shell配置与启用、自定义命令开发、内存查看命令、线程信息查看

Shell调试子系统,说白了就是Zephyr的「人机交互窗口」。你想想看,嵌入式开发最痛苦的是什么?是看不到系统内部的状态。代码跑起来了,但到底跑得对不对?内存有没有泄漏?线程有没有死锁?全靠猜。有了Shell,这些问题就迎刃而解了。

我个人习惯,拿到一个新板子,第一件事就是把Shell打开。为什么?因为后面所有的调试工作,几乎都离不开它。今天我们就来聊聊,怎么用好这个强大的工具。

4.1 Shell配置与启用

启用Shell其实很简单,但有几个坑,我踩过好几次。先看最基础的配置:

# prj.conf 中的配置
CONFIG_SHELL=y
CONFIG_SHELL_BACKEND_SERIAL=y
CONFIG_SHELL_CMD_BUFFER_SIZE=256
CONFIG_SHELL_HISTORY=10

嗯,这里要注意。第一行是总开关,第二行是选择串口作为交互通道。后面两个参数,我建议你根据实际需求调整。

我的经验:命令缓冲区大小,默认128字节。如果你要传长参数,比如写一大段内存数据,128字节根本不够用。我一般设到512。历史记录设10条就够了,太多反而占内存。

还有一个容易被忽略的配置:

CONFIG_SHELL_STATS=y

这个打开后,你可以看到Shell命令的执行时间统计。我在做性能调优时,经常用它来快速定位哪个命令响应慢。

配置完成后,编译烧录。用串口工具连上,按回车,你应该能看到Shell提示符:

uart:~$

如果没出来,别慌。我曾经遇到过一个问题:串口波特率不匹配。Zephyr默认用115200,但有些开发板的bootloader改了波特率。检查一下你的终端设置。

4.2 自定义命令开发

内置命令虽然好用,但真正体现Shell威力的,是自定义命令。你可以把调试用的函数,直接注册成Shell命令,随时调用。

先看一个最简单的例子:

#include <shell/shell.h>

static int cmd_hello(const struct shell *shell, size_t argc, char **argv)
{
    shell_print(shell, "Hello from Zephyr!");
    return 0;
}

SHELL_CMD_REGISTER(hello, NULL, "Say hello", cmd_hello);

编译烧录后,在Shell里输入hello,就能看到输出。就这么简单。

但实际项目中,命令往往需要参数。比如我想查看某个内存地址的值:

static int cmd_peek(const struct shell *shell, size_t argc, char **argv)
{
    uint32_t addr;
    uint32_t val;

    if (argc < 2) {
        shell_error(shell, "Usage: peek <address>");
        return -EINVAL;
    }

    addr = strtoul(argv[1], NULL, 16);
    val = *(volatile uint32_t *)addr;
    shell_print(shell, "0x%08x: 0x%08x", addr, val);

    return 0;
}

SHELL_CMD_REGISTER(peek, NULL, "Read 32-bit value from address", cmd_peek);
避坑指南:我曾经在自定义命令里直接访问了未对齐的地址,结果触发了HardFault。所以,如果你要访问外设寄存器,一定要确认地址对齐。另外,参数解析用strtoulatoi更安全,因为它能处理十六进制。

命令多了之后,建议用子命令来组织。比如:

SHELL_STATIC_SUBCMD_SET_CREATE(sub_memory,
    SHELL_CMD(read, NULL, "Read memory", cmd_mem_read),
    SHELL_CMD(write, NULL, "Write memory", cmd_mem_write),
    SHELL_SUBCMD_SET_END
);

SHELL_CMD_REGISTER(mem, &sub_memory, "Memory operations", NULL);

这样,你就可以用mem read 0x20000000mem write 0x20000000 0x12345678来操作内存了。结构清晰,也方便扩展。

4.3 内存查看命令

Zephyr内置了一个非常好用的内存查看命令——devmem。但说实话,它只能看物理地址。对于调试来说,我们更关心的是堆内存的使用情况。

我写了一个小工具,用来查看堆内存的分配情况:

static int cmd_heap_info(const struct shell *shell, size_t argc, char **argv)
{
    struct k_mem_slab *slab;
    struct k_heap *heap;

    heap = k_heap_get(&_system_heap);
    if (heap) {
        shell_print(shell, "System heap:");
        shell_print(shell, "  Total: %zu bytes", heap->size);
        shell_print(shell, "  Free:  %zu bytes", k_heap_free_size(heap));
        shell_print(shell, "  Used:  %zu bytes", heap->size - k_heap_free_size(heap));
    }

    return 0;
}

SHELL_CMD_REGISTER(heapinfo, NULL, "Show heap memory info", cmd_heap_info);

这个命令能帮你快速判断内存是否泄漏。我在一次项目中,发现系统运行几天后突然卡死。用heapinfo一看,空闲内存从32KB降到了2KB。嗯,明显有地方在持续分配但不释放。

另外,mem命令也很有用。你可以直接查看任意地址的内容:

uart:~$ mem read 0x20000000 64
0x20000000: 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
0x20000010: 10 11 12 13 14 15 16 17 18 19 1A 1B 1C 1D 1E 1F
...

这个命令在调试DMA传输、验证数据缓冲区内容时,简直是神器。

4.4 线程信息查看

多线程系统里,最怕的就是线程死锁或者优先级反转。Zephyr的kernel threads命令,能帮你把当前所有线程的状态扒个底朝天。

输入命令:

uart:~$ kernel threads

输出大概长这样:

线程名 优先级 状态 栈使用 运行时间
idle -1 就绪 12% 12345ms
main 0 运行 45% 6789ms
uart_rx 2 挂起 78% 234ms

这里有几个关键信息:

  • 栈使用:如果超过80%,就要小心栈溢出了。我一般会留30%的余量。
  • 状态:如果某个线程长期处于「挂起」状态,可能是信号量没等到,或者消息队列满了。
  • 运行时间:可以帮你发现哪个线程占用了过多的CPU。
实战案例:有一次,我发现一个传感器采集线程的栈使用率高达95%。按理说,这个线程只做简单的I2C读取,不应该用这么多栈。后来仔细看代码,发现它在中断上下文里调用了printk,导致栈空间被大量占用。改成用消息队列传递数据后,栈使用率降到了30%。

如果你想看更详细的线程信息,可以用:

uart:~$ kernel threads verbose

这个命令会显示每个线程的寄存器状态、等待的内核对象等。在调试死锁时特别有用。比如,你可以看到线程A在等待信号量S,而线程B持有信号量S但被线程C阻塞了。一眼就能看出问题所在。

嗯,最后提醒一句。Shell虽然好用,但别在生产固件里留太多调试命令。我一般会在发布前,通过Kconfig把调试相关的命令全部关掉。毕竟,安全第一。