RT-Thread 内核对象与 FinSH 进阶实战:客户现场敲 `list_thread` 一片空白,我把对象容器、命令注册段和 rt_kprintf 的边界从头翻了一遍

2026年10月7日 0 By admin

去年给一家做配电终端的客户做了一套基于 RT-Thread 的采集板。板子跑了大半年,现场没有任何屏幕,只有一路 RS232 调试口和几条自己写的 shell 命令 —— 换句话说,调试口就是我们唯一的眼睛。

一直到那批货到了客户配电房,我远程指导现场同事接线,问题来了:

  • 敲 list_thread,回了一行 Unknown command;
  • 敲回车,屏幕上什么都没有,像是终端死了;
  • 好不容易有一条 stat 命令有输出,但打出来的 JSON 是半截的,服务端直接判格式错误。

三条命令,三种完全不同的”没反应”。当时我在电话里对着屏幕猜了两个小时,回来之后把 RT-Thread 的对象容器、FinSH 的段注册机制和 rt_kprintf 的边界从头翻了一遍。这篇文章就是那次复盘的完整记录 —— 内核对象那一套机制其实不难,难的是“命令敲不出来”这件事至少有六个层次的可能原因,而它们给出的现象长得几乎一样。


一、先分诊:三种”没反应”分属三个层次

这是我最想先给的一张表。现场遇到 shell 不工作,按这张表往下走,比乱试快得多。

现象 出问题的层次 第一动作
Unknown command / 命令名打出来但”未找到” 链接期:FSymTab 段被回收,或符号表没开 翻 map 文件搜 FSymTab;查 FINSH_USING_SYMTAB
敲回车没反应,或要等十几秒才回 线程调度:tshell 被高优先级任务饿死 查 FINSH_THREAD_PRIORITY;看系统里有多少高优先级常驻任务
命令能执行,但输出被砍成半截 输出缓冲:RT_CONSOLEBUF_SIZE 截断 数一下这条输出多少字节,跟 128 比
命令跑到一半系统就死 栈:tshell 的 4KB 栈被命令的局部变量吃穿 list_thread 看 tshell 的 max used
数值位置是空的 / 打成 0 格式化:rt_kprintf 没有 %f 分支 直接放弃 %f,改定点格式化
现场一开始能用,跑几天就不响应 资源:动态对象把堆啃碎,或日志把串口占死 list_mem / list_memheap,以及日志量统计

一句话结论:这三条命令的失败没有共同根因。它们的共同点只是”都通过同一个 shell 表现出来”,而 shell 只是最外面那层皮。

下面按内核对象 → 命令注册 → 输出通道这三段,把机制讲透。


二、rt_object:RT-Thread 里所有对象的”基类”

RT-Thread 的设计里,线程、信号量、互斥量、事件、邮箱、消息队列、内存堆/池、设备、定时器 …… 这些东西全部继承自同一个基类结构体 struct rt_object。这个设计是整个”对象容器”体系的地基。

struct rt_object
{
    char       name[RT_NAME_MAX];   /* 内嵌的定长名字数组,不是指针! */
    rt_uint8_t type;                /* 低 7 位是类型,最高位是静态标志 */
    rt_uint8_t flag;                /* 各类型的私有标志位 */
    rt_list_t  list;                /* 挂到对象容器链表上的钩子 */
};

拆开看几个关键点:

type 的低 7 位是对象类型,比如 RT_Object_Class_Thread / RT_Object_Class_Semaphore / RT_Object_Class_Device / RT_Object_Class_Timer 等等;最高位 RT_Object_Class_Static 用来区分这个对象是静态定义还是堆上动态创建的。这一位很重要,rt_object_delete() 对静态对象是拒绝执行的 —— 因为静态对象根本不归堆管,删它毫无意义。(具体枚举值各版本略有出入,以工程内 rtdef.h 为准。)

name 是内嵌数组而不是指针,这是后面 RT_NAME_MAX 那一节所有坑的源头。

对象容器是一个全局数组,每个对象类型对应一个双向链表:

/* components/finsh、kernel/object.c 之外的静态定义,简化示意 */
static struct rt_object_information rt_object_container[RT_Object_Info_Unknown] =
{
    { RT_Object_Class_Thread,   _OBJ_CONTAINER_LIST_INIT(...), sizeof(struct rt_thread) },
    { RT_Object_Class_Semaphore,_OBJ_CONTAINER_LIST_INIT(...), sizeof(struct rt_semaphore) },
    /* ... 以及其他类型 ... */
};

struct rt_object_information 里三个字段:type(类型)、object_list(该类型的对象链表头)、object_size(该类型对象的结构体大小)。

所以内核里任何时刻都能回答”现在系统中有哪些线程、哪些信号量、各占多大”——这正是 list_thread / list_sem 这些命令的实现基础,它们不是内核里的特殊功能,就是一次链表遍历。


三、静态对象 vs 动态对象:内存从哪儿来,决定你该用哪个

两套 API 长得几乎一样,但内存来源完全不同:

静态对象 动态对象
创建 rt_sem_init(&sem, "name", ...) rt_sem_create("name", ...)
内存来源 你自己定义的全局变量(.bss) 堆(RT_KERNEL_MALLOC)
释放 rt_sem_detach() rt_sem_delete()
type 的最高位 置 RT_Object_Class_Static 不置
失败表现 编译期就知道有没有 运行期返回 RT_NULL
碎片风险 无 反复 create/delete 会累积

现场设备上我的建议很明确:能静态就静态。原因不是性能,是可预测性 —— 动态对象在 create 失败的那一刻是运行期,而量产固件的堆里躺着多少碎片,你没法在办公室预测。静态定义从 .bss 里拿空间,链接期就定死了,出问题是在烧写之前而不是在现场。

真要动态创建的场合也有:比如按配置项数量临时建信号量。这种情况下至少做到 “创建一次、用完不 delete、进程生命周期内复用”,别让 create/delete 进热路径。


四、RT_NAME_MAX 默认 8:一个名字长度引发的三个坑

rtconfig.h 里:

#define RT_NAME_MAX 8

名字最长只有 7 个字符(留一个 '\0')。因为 rt_object_init() 里是这么干的:

rt_strncpy(obj->name, name, RT_NAME_MAX - 1);
obj->name[RT_NAME_MAX - 1] = '\0';

短名字是静默截断的,不报错、不断言。 由此引出三个真实踩过的坑:

坑一:list_device 里一堆设备分不清谁是谁

我把两个传感器设备命名成 sen_inlet_a、sen_inlet_b,截断之后两个都变成 sen_inl。查设备列表时看到两行一模一样的名字,只能靠注册顺序猜。后来统一改成短前缀规范:ia、ib、mq_pwr、s1。

坑二:截断后重名 —— 查找返回”错的那个”

这条最阴。sen_inlet_a 和 sen_inlet_b 截断后同名,插入容器时不会报错(容器只是个链表,没有唯一性校验)。之后 rt_device_find("sen_inl") 返回哪一个,取决于链表顺序 —— 我拿到的一直是 A,但代码以为自己拿到的是 B。

症状是”某一路采集偶尔读到另一路的数值”,而代码逻辑看上去毫无问题。定位方法很简单:在查找成功后把对象的 name 打出来对一遍,别只看自己传进去的字符串。

坑三:改 RT_NAME_MAX 要全工程重编

因为 name 是内嵌数组,RT_NAME_MAX 一变,struct rt_object 以及所有派生结构体的大小、成员偏移全变。如果工程里有 RT_NAME_MAX 不同时编译的目标文件(比如某个预编译的 .a 库、或者 CubeIDE / Keil 的增量编译缓存没清干净),就会出现”我这边按 16 字节读 name,那边按 8 字节写”的 ABI 错位 —— 现象极其诡异:结构体字段错位、type 读出来是乱码。

所以:改这个宏之后一定 clean 全编;如果引用了第三方预编译库,这个宏值就是 ABI 的一部分,不能单独改。


五、对象容器 API:自己写诊断命令就靠这三个函数

想自己写”把系统里所有对象列出来”的命令,就三个 API:

struct rt_object_information *rt_object_get_information(enum rt_object_class_type type);
int rt_object_get_length(enum rt_object_class_type type);
int rt_object_get_pointers(enum rt_object_class_type type, rt_object_t *pointers, int maxlen);
  • rt_object_get_information():拿到某类型的容器信息(含链表头和 object_size);
  • rt_object_get_length():先问总数,这个习惯很重要(下面有坑);
  • rt_object_get_pointers():把对象指针填到你的数组里,最多填 maxlen 个,返回实际填入数量。

一个完整的遍历实现(输出机器可读的一行一条,现场好抄):

#include <rtthread.h>
#include <rtservice.h>

#define LOG_TAG "diag"
#include <ulog.h>

static const char *_cls_name(rt_uint8_t type)
{
    /* 去掉静态标志位后比对 */
    switch (type & ~RT_Object_Class_Static)
    {
    case RT_Object_Class_Thread:    return "thread";
    case RT_Object_Class_Semaphore: return "sem";
    case RT_Object_Class_Mutex:     return "mutex";
    case RT_Object_Class_Event:     return "event";
    case RT_Object_Class_Device:    return "device";
    case RT_Object_Class_Timer:     return "timer";
    default:                        return "other";
    }
}

static void dump_objects(enum rt_object_class_type cls)
{
    struct rt_object_information *info = rt_object_get_information(cls);
    struct rt_object *obj;
    rt_list_t *node;
    int n;

    if (info == RT_NULL) { rt_kprintf("ERR class=%d\n", cls); return; }

    n = rt_object_get_length(cls);
    rt_kprintf("OBJ cls=%s total=%d size=%d\n",
               _cls_name((rt_uint8_t)cls), n, info->object_size);

    /* 遍历期间不允许别的线程增删对象 */
    rt_enter_critical();
    rt_list_for_each(node, &info->object_list)
    {
        obj = rt_list_entry(node, struct rt_object, list);
        rt_kprintf("  NAME=%-8s TYPE=%02x FLAG=%02x\n",
                   obj->name, obj->type, obj->flag);
    }
    rt_exit_critical();
}

三个必须注意的点:

  1. rt_list_entry(node, struct rt_object, list) —— 链表节点是 obj->list,要通过 container_of 反推回对象本身。忘了这一步会拿到错的地址。
  2. 遍历不是原子的。链表增删本身在对象层是关中断保护的,但”你遍历到一半,别的线程删掉了一个节点”依然可能发生。诊断命令要么 rt_enter_critical() 包住(对象不多、耗时可接受),要么接受”快照可能不一致”并在文档里写明。别在中断频繁、对象高速增删的系统里裸遍历。
  3. 先 get_length 再决定批大小。老版本 msh.c 里的 list_* 用的是本地定长数组(常见是 10 个元素)来装对象指针,对象数超过这个数就只列前面几个,而且不告诉你被截断了。我自己写的诊断命令一律先问总数,超过阈值就分批取,并在输出里带 total=,避免现场误以为”系统里只有 8 个线程”。

顺带一句:rt_object_find(name, type) 这个按名字查对象的函数通常只在开启调试选项时才编进固件,量产 release 里可能根本不存在。要用就自己用遍历实现,别依赖它。


六、FinSH 的两副面孔:函数式导出 与 MSH 命令

FinSH 其实有两套用法混在一起,先分清楚:

宏 导出到 用法 现在的建议
FINSH_FUNCTION_EXPORT(name, desc) FSymTab 直接敲函数名 老代码遗留,别新增
FINSH_FUNCTION_EXPORT_ALIAS(name, alias, desc) FSymTab 敲别名 同上
FINSH_VAR_EXPORT(var, type, desc) VSymTab 读写全局变量 MSH 模式基本用不到
MSH_CMD_EXPORT(cmd, desc) FSymTab 敲命令名 新代码统一用这个
MSH_CMD_EXPORT_ALIAS(func, cmd, desc) FSymTab 敲命令名,函数名另起 函数名带模块前缀时用

rtconfig.h 里对应的开关:

#define RT_USING_FINSH
#define FINSH_USING_SYMTAB          /* 必须开,否则符号表不生成 */
#define FINSH_USING_DESCRIPTION     /* 关掉可省 Flash,代价是 help 没说明 */
#define FINSH_USING_MSH             /* 开 MSH 模式 */
#define FINSH_USING_MSH_ONLY        /* 关掉传统 C 风格 shell,只留 MSH */

强烈建议开 FINSH_USING_MSH_ONLY。 传统 C 风格 shell 会把整个解释器(变量表、表达式求值)编进来,占几百字节到 1KB 的 Flash 加栈开销,而现场真正用的是 MSH。

这里有个必须知道的行为差异:

在 MSH 模式下,FSymTab 里的每一个条目都是按 int func(int argc, char argv) 调用的。**

所以如果你用 FINSH_FUNCTION_EXPORT 导出一个老式的 int foo(int mode),在 MSH 里敲 foo 3,传进去的是 argc=2, argv=...,函数却按 int mode 去读 —— mode 会变成 2,而不是 3。参数对不上,还不报错。

这就是为什么新代码一律用 MSH_CMD_EXPORT 配 (int argc, char **argv) 签名。(各版本解释器细节有差异,但”统一用 argc/argv 签名”是唯一安全的做法。)


七、命令是怎么”贴”进固件的:两个段体系必须分清

MSH 命令不是靠注册函数调进去的,是靠链接期的段(section)机制。这里面有两套段,很容易混:

体系一:自动初始化段 .rti_fn.*

RT-Thread 用这一套做”模块自动初始化”,按级别分 6 档:

宏 段 执行时机
INIT_BOARD_EXPORT(fn) .rti_fn.1 板级初始化
INIT_PREV_EXPORT(fn) .rti_fn.2 纯软件前置初始化
INIT_DEVICE_EXPORT(fn) .rti_fn.3 设备驱动初始化
INIT_COMPONENT_EXPORT(fn) .rti_fn.4 组件初始化
INIT_ENV_EXPORT(fn) .rti_fn.5 环境初始化(文件系统等)
INIT_APP_EXPORT(fn) .rti_fn.6 应用初始化(最后)

rt_components_init() 会沿这个段从头扫到尾,依次执行。你写传感器驱动、文件系统挂载、应用任务创建,都是往这里塞。

体系二:FinSH 符号表段 FSymTab / VSymTab

MSH 命令走的是这一套,和上面的初始化段没有任何关系:

/* 简化后的宏展开示意,各版本写法略有差异,以工程内 finsh 头文件为准 */
struct finsh_syscall
{
    const char *name;
#if defined(FINSH_USING_DESCRIPTION) && defined(FINSH_USING_SYMTAB)
    const char *desc;
#endif
    syscall_func func;
};

#define FINSH_FUNCTION_EXPORT_CMD(name, cmd, desc)                       \
    const char __fsym_##cmd##_name[] SECTION(".rodata.name") = #cmd;     \
    const char __fsym_##cmd##_desc[] SECTION(".rodata.name") = #desc;    \
    RT_USED const struct finsh_syscall __fsym_##cmd SECTION("FSymTab") = \
    { __fsym_##cmd##_name, __fsym_##cmd##_desc, (syscall_func)&name };

#define MSH_CMD_EXPORT(command, desc)  FINSH_FUNCTION_EXPORT_CMD(command, command, desc)

shell 启动时把 FSymTab 段首尾地址一夹,就得到一张完整的命令表 —— 这就是为什么命令不需要任何注册调用。

于是有了最容易踩的那个坑

一旦工程开了:

-ffunction-sections -fdata-sections -Wl,--gc-sections

链接器会做垃圾回收:任何”没有被任何地方引用”的函数和数据段,全部丢掉。而 FSymTab 段里的这些 finsh_syscall 结构体,没有任何 C 代码引用它们(只有 shell 通过段的起止符号访问)。链接器不知道段边界符号的语义,于是整张命令表连同命令函数体一起被回收。

现象就是本文开头那个:代码里明明写了 MSH_CMD_EXPORT(stat, ...),编译链接全过,固件里就是敲不出来。

解决办法是在链接脚本里 KEEP 这些段:

/* GCC / GNU ld 的链接脚本片段 */
.rti_fn :
{
    . = ALIGN(4);
    __rt_init_start = .;
    KEEP(*(SORT(.rti_fn*)))
    __rt_init_end = .;
} > ROM

FSymTab :
{
    . = ALIGN(4);
    __fsymtab_start = .;
    KEEP(*(FSymTab))
    __fsymtab_end = .;
} > ROM

VSymTab :
{
    . = ALIGN(4);
    __vsymtab_start = .;
    KEEP(*(VSymTab))
    __vsymtab_end = .;
} > ROM

不同工具链的 SECTION() / RT_USED 定义不一样:GCC 是 __attribute__((section(x))) + __attribute__((used));IAR 是 @ "FSymTab" + __root;MDK/armclang 用 GCC 风格即可。换平台移植后命令消失,第一件事就是查这两个宏在本工程里展开成了什么。

排查三步(值得抄在工位上)

  1. 看 rtconfig.h:RT_USING_FINSH 和 FINSH_USING_SYMTAB 是不是都开了;
  2. 看 map 文件:搜 FSymTab,确认段存在、__fsymtab_start 与 __fsymtab_end 不相等,并且里面有你那条命令的名字字符串;
  3. 段大小是 0 或者被合并进”已丢弃”区 → 回链接脚本加 KEEP。

八、MSH_CMD_EXPORT 的规范用法与参数解析

命令函数只有两种可用签名:

/* 无参数命令 */
static void cmd_reset(void)
{
    rt_kprintf("system reboot...\n");
    rt_hw_cpu_reset();
}
MSH_CMD_EXPORT(cmd_reset, reboot the board);
/* 敲:reset(注意:命令名是 cmd_reset,因为 MSH_CMD_EXPORT 用函数名当命令名) */

/* 带参数命令 —— 推荐形式 */
static int cmd_echo(int argc, char **argv)
{
    int i;
    for (i = 1; i < argc; i++)
        rt_kprintf("%s%s", argv[i], (i + 1 < argc) ? " " : "\n");
    return 0;
}
MSH_CMD_EXPORT(cmd_echo, echo args);

四条实践规则:

规则一:argv 全是字符串,数字要自己转。

/* 正确:显式转,并检查非法输入 */
static int cmd_cal(int argc, char **argv)
{
    char *end = RT_NULL;
    long idx;

    if (argc < 2) { rt_kprintf("usage: cal <index>\n"); return -RT_EINVAL; }

    idx = strtol(argv[1], &end, 0);           /* 0 = 自动识别 0x/0 前缀 */
    if (end == argv[1] || *end != '\0')
    {
        rt_kprintf("ERR bad number: %s\n", argv[1]);
        return -RT_EINVAL;
    }
    rt_kprintf("index=%ld\n", idx);
    return 0;
}

规则二:返回值不会被自动打印。 现场版本的 msh 不检查命令返回值,最后那个 int 只是给你自己用的(也可以在命令里自己 LOG_I("RC=%d"))。任何错误都必须自己 rt_kprintf 出来,否则现场看到的是”命令执行了但什么也没说”。

规则三:注意两个长度宏。

宏 默认值 含义
FINSH_ARG_MAX 10 一条命令行最多接受 10 个参数(即 argv[0..9])
FINSH_CMD_SIZE 80 输入行缓冲长度,超过就被截断(现场最容易犯:一条长命令后半截没了,参数静默丢失)

现在固定写命令名 + 子命令 + 长参数很容易超 80 字符,建议提到 128~256。

规则四:命令名加项目前缀。 因为导出的符号名由命令名派生,跟组件自带的例程命令(各种 test、list、version)撞名时,多数版本会直接在链接期报重复定义。统一用 xxx_ 前缀(我是 diag_),一次就把这类问题消掉。


九、把诊断做成一条命令:子命令分发器

现场真正好用的做法不是导出二十条命令,而是一条命令 + 子命令分发。好处:help 干净、命令表省 Flash、现场同事只需要记住一个入口。

/* diag.c —— 现场自助诊断入口 */
#include <rtthread.h>
#include <stdlib.h>
#include <string.h>

static void _diag_usage(void)
{
    rt_kprintf("usage: diag <sub>\n");
    rt_kprintf("  diag thr      dump thread objects (name/type/flag)\n");
    rt_kprintf("  diag obj <t>  dump objects: thr|sem|mutex|dev|timer\n");
    rt_kprintf("  diag mem      heap summary\n");
    rt_kprintf("  diag stat     one-line health status\n");
}

static int _diag_mem(void)
{
    /* rt_memory_info() 是系统堆统计接口(签名随版本可能是 rt_uint32_t *,以本工程为准) */
    rt_size_t total = 0, used = 0, max_used = 0;
    rt_memory_info(&total, &used, &max_used);
    rt_kprintf("MEM total=%u used=%u max_used=%u free=%u\n",
               (unsigned)total, (unsigned)used, (unsigned)max_used,
               (unsigned)(total - used));
    return 0;
}

static int _diag_stat(void)
{
    /* 一行 KEY=VAL,现场抄回来就能分析 */
    rt_kprintf("STAT uptime_s=%u tick=%u thr=%d\n",
               (unsigned)(rt_tick_get() / RT_TICK_PER_SECOND),
               (unsigned)rt_tick_get(),
               rt_object_get_length(RT_Object_Class_Thread));
    return 0;
}

static int cmd_diag(int argc, char **argv)
{
    if (argc < 2) { _diag_usage(); return -RT_EINVAL; }

    if (!strcmp(argv[1], "thr"))  { dump_objects(RT_Object_Class_Thread); return 0; }
    if (!strcmp(argv[1], "mem"))  { return _diag_mem();                   }
    if (!strcmp(argv[1], "stat")) { return _diag_stat();                  }
    if (!strcmp(argv[1], "obj"))
    {
        if (argc < 3) { _diag_usage(); return -RT_EINVAL; }
        if (!strcmp(argv[2], "thr"))   { dump_objects(RT_Object_Class_Thread);    return 0; }
        if (!strcmp(argv[2], "sem"))   { dump_objects(RT_Object_Class_Semaphore); return 0; }
        if (!strcmp(argv[2], "mutex")) { dump_objects(RT_Object_Class_Mutex);     return 0; }
        if (!strcmp(argv[2], "dev"))   { dump_objects(RT_Object_Class_Device);    return 0; }
        if (!strcmp(argv[2], "timer")) { dump_objects(RT_Object_Class_Timer);     return 0; }
        rt_kprintf("ERR unknown obj type: %s\n", argv[2]);
        return -RT_EINVAL;
    }
    _diag_usage();
    return -RT_EINVAL;
}
MSH_CMD_EXPORT_ALIAS(cmd_diag, diag, on-site self diagnostic);

几点经验:

  • 输出格式固定成 KEY=VAL 或固定列宽。现场同事在电话里念回来的那一行,要能直接跟历史记录做 diff。花里胡哨的表格在 80 列终端里全是换行,反而读不了。
  • 子命令解析用 argv[1]/argv[2] 手工分发,MSH 本身不支持嵌套子命令,但手写一层分发就够用。
  • usage 是给人看的,不是给代码看的。把常用子命令和参数格式列全,现场才不用打电话问。
  • 危险子命令(擦 Flash、改校准、强制输出)不要放进这一条,单独用编译宏隔离,见第十三节。

十、rt_kprintf 的三条硬边界

这一节是本文最”值钱”的部分,因为三个边界我都真实被咬过。

10.1 边界一:RT_CONSOLEBUF_SIZE 默认 128,超长输出静默截断

#define RT_CONSOLEBUF_SIZE 128

任何一次 rt_kprintf 的格式化结果都先生成在栈上一个 128 字节的缓冲区里,超出的部分被直接砍掉,不报错、不返回错误码。

我在现场打一个七字段的 JSON 状态帧:

{"ua":231.4,"ub":229.8,"ia":1.32,"soc":87,"temp":26.5,"pf":0.98,"freq":50.01}

一共 77 字节(UTF-8),加上结尾 '\0' 需要 78 字节,128 的缓冲下余量 50 字节 —— 这次是运气好。但加上设备号、时间戳、CRC 之后轻松过 130 字节,打出去的就是半截 JSON,服务端解析失败,而板子这边看不到任何异常。

用一个简单的长度账就能提前发现:

python rtt_fmt.py fit '{"ua":231.4,"ub":229.8,"ia":1.32,"soc":87,"temp":26.5,"pf":0.98,"freq":50.01}' 128
# 字符串 UTF-8 字节数  = 77
# 含结尾 '\0' 需要     = 78 字节
# 可用                = 127 字节
# 结论:装得下,余量 50 字节。

对策三条:① 先估长度再打;② 长输出分段打(每段自己加前缀,接收端能拼);③ 提高 RT_CONSOLEBUF_SIZE —— 但记住这个缓冲在栈上,rt_kprintf 又是被各线程和中断调用的,这个宏实际上是在给每一个调用点的栈加预算,改大了要重新评估各线程栈(尤其 tshell 和中断栈)。

10.2 边界二:rt_kprintf 不支持 %f —— 根本没有浮点分支

RT-Thread 的 rt_vsnprintf 实现里支持 %c %s %d %i %u %x %X %p %%,开了 RT_KPRINTF_USING_LONGLONG 才支持 %lld 一类。浮点 %f 没有分支。

不同版本遇到 %f 的兜底行为不一样(有的把它当未知格式符丢掉,有的输出成字面内容),但有一点是确定的:它一定不是你想要的数值。而且这种错编译期零告警,只在运行时表现为数值位置为空 —— 这跟我在裸机上被 newlib-nano 的 %f 坑过的那个症状一模一样(那个还更狠:一开 nanoprintffloat 关闭连 _dtoa_r 都不用,直接输出空字符串,而且只在 Release 版暴露)。

所以我的 MCU 工程有两条硬约定,RT-Thread 项目同样执行:

① 业务路径禁用 %f/%g/%e,一律自己做定点格式化。

② 业务代码零 malloc(RT-Thread 里对应”不动态创建内核对象、不用 rt_malloc 做业务缓冲”)。

定点格式化就是整数运算,一段 20 行的函数解决战斗:

/* 把 q = value * 10^dp 打成定点字符串,buf 至少 8+dp 字节 */
static void fmt_fixed(char *buf, int32_t q, uint8_t dp)
{
    char tmp[13]; int n = 0, i, k = 0;
    uint8_t neg = (q < 0);
    int32_t m = neg ? -q : q;
    int32_t sc = 1, ip, fp;

    for (i = 0; i < dp; i++) sc *= 10;
    ip = m / sc; fp = m % sc;

    if (ip == 0) tmp[n++] = '0';
    while (ip) { tmp[n++] = (char)('0' + ip % 10); ip /= 10; }
    if (neg) buf[k++] = '-';
    while (n) buf[k++] = tmp[--n];
    if (dp) {
        buf[k++] = '.';
        for (i = dp - 1; i >= 0; i--) { buf[k + i] = (char)('0' + fp % 10); fp /= 10; }
        k += dp;
    }
    buf[k] = '\0';
}

/* 用法:把 float 值先放大成整数,四舍五入再打 */
static void diag_voltage(float ua)
{
    char s[16];
    fmt_fixed(s, (int32_t)(ua * 100.0f + (ua >= 0 ? 0.5f : -0.5f)), 2);
    rt_kprintf("UA=%s\n", s);           /* 输出 UA=231.40 */
}

一个附带提醒:%lld 打印 64 位整数也要看宏。 我之前用 64 位整数做电量积分(int64_t,见《电池管理与 SOC 估算》那篇),打印时忘了开 RT_KPRINTF_USING_LONGLONG,结果高位被截断,打印出来的累计电量比实际小了一大截 —— 差点被误判成积分算法有 bug。打印前先确认这个宏。

10.3 边界三:rt_kprintf 是同步阻塞的

默认情况下 rt_kprintf 走的是 rt_device_write() 到控制台设备,而串口驱动默认是轮询模式,也就是说它是一个忙等发送:把数据一个字节一个字节塞进数据寄存器,等发送完成。

算一笔账(8N1,每字节 10 位):

波特率 每字节耗时 一行 40 字节 一行 80 字节
9600 1041.7 μs 41.7 ms 83.3 ms
115200 86.8 μs 3.47 ms 6.94 ms
460800 21.7 μs 0.87 ms 1.74 ms
921600 10.9 μs 0.43 ms 0.87 ms
python rtt_fmt.py kprint --bytes 40 --baud 115200
# 发送耗时 = 40×10/115200 = 3.472 ms
#       中断周期        该行占用        结论
#       100 μs        超 >1000%        禁止
#      1000 μs    超 100% → 必丢样        禁止

在 100 μs 周期的控制中断里打一行 40 字节日志,就等于中断永远出不来了 —— 系统直接死。 就算放在线程里,115200 下打一行 3.5 ms,一个 1 kHz 的采集线程也会被拖到丢样。

三条纪律:

  1. 中断里绝不 rt_kprintf。 只置标志位、填一个无锁环形缓冲,让低优先级日志线程去输出。
  2. 控制路径上的输出全部走”可丢”通道,满了就丢弃并计数(丢弃计数本身要能被读出来,否则”日志没打出来”会被误读成”代码没跑到”)。
  3. 高频调试输出换物理通道:ITM/SWO、RTT、或者单独的 debug UART。这些不影响业务串口,也不需要忙等(见《ITM/SWO 打印实战》那篇的完整配置)。

顺便说,rt_kprintf 还有个隐性的坑:它会和控制台设备互斥。如果你的控制台设备和业务通信用的是同一个 UART 实例,rt_kprintf 打日志就会插进 Modbus/自定义协议的帧中间 —— 对端收到一个 CRC 错的帧。调试口和业务口,物理上分开。


十一、ulog:把 rt_kprintf 换成分级、可过滤、可异步的日志

写到这里结论已经很清楚了:业务路径不该用 rt_kprintf。RT-Thread 带的 ulog 就是干这个的。

#define LOG_TAG "pwr"          /* 每个 .c 文件顶部定义自己的 tag */
#include <ulog.h>

void pwr_task(void *arg)
{
    LOG_I("start, tick=%u", (unsigned)rt_tick_get());
    LOG_D("adc raw=%d volt=%s", raw, s_volt_str);       /* 详细,默认不打 */
    LOG_W("bus timeout cnt=%d", to_cnt);
    LOG_E("sensor offline addr=0x%02x", addr);
    LOG_HEX("pwr", 16, buf, len);                        /* 十六进制 dump */
}

最大的价值在现场可维护性:日志级别是运行时可调的。产线发的固件把全局级别设在 INFO,某台设备出问题时,现场通过 shell 一句话就能把某个 tag 调到 DEBUG,不用重新烧固件:

msh /> ulog_lvl 7              /* 全局输出级别调到 DEBUG */
msh /> ulog_tag_lvl pwr 7      /* 只放开 pwr 这个 tag */
msh /> ulog_tag                /* 列出系统里所有 tag 及当前级别 */
msh /> ulog_be_lvl console 6   /* 单独调某个后端的级别 */

(命令集随 ulog 组件版本略有差异,敲 help 或看 ulog.c 里导出的命令确认本工程实际有哪些。)

其他要点:

  • 后端(backend)可插:console、文件系统、自定义。自定义后端的典型用途是把告警级日志写进 Flash 循环区,现场返修时先读日志再上电踩问题。
  • 异步输出:把耗时最长的格式化搬到专门的日志线程,中断/业务线程只把原始参数压进 ringbuffer。中断里有日志的场合,异步几乎是唯一可行方案(配置宏名各版本写法不同,以工程内 ulog_cfg.h 为准)。
  • ULOG_LINE_BUF_SIZE 和 RT_CONSOLEBUF_SIZE 是两个不同的缓冲,各自有截断风险,别以为改了一个另一个也跟着变。
  • ulog 的格式化同样不支持 %f —— 它复用的还是那套实现。前面定点格式化的约定,在 ulog 里一样适用。
  • 丢日志要计数。 缓冲不够时 ulog 会丢,如果丢的静默的,那你就会得出”这段代码没走到”的错误结论 —— 我在现场为这个结论浪费过整整一个下午。带上丢弃计数,或者至少在响应里体现日志水位。

现场实践里我把业务日志全部改成 ulog,只在启动横幅、断言、以及”必须在任何环境下都能打出来”的几条保留 rt_kprintf。两者混用要注意:rt_kprintf 不受 ulog 级别过滤,永远输出。


十二、tshell 线程:4KB 栈 + 优先级 20,两个都会被现场打脸

FinSH 的命令是跑在一个名叫 tshell 的独立线程里的。它的默认配置:

宏 默认值 现场会怎么咬你
FINSH_THREAD_NAME "tshell" —
FINSH_THREAD_STACK_SIZE 4096 命令里放大数组/递归 → tshell 栈溢出
FINSH_THREAD_PRIORITY 20 数值大 = 优先级低 → 被高优先级业务任务饿死
FINSH_THREAD_TICK 20 时间片

12.1 优先级 20 的坑:敲回车没人应

RT-Thread 的优先级规则是数值越小优先级越高(这一点跟 FreeRTOS 相反,我在《TencentOS tiny / RT-Thread / FreeRTOS 怎么选》和《Zephyr 与 FreeRTOS 迁移》两篇里都专门强调过,因为它太容易记反)。

FINSH_THREAD_PRIORITY 默认 20,属于比较低的优先级。如果你的系统里有几个优先级 8~15 的采集/控制/通信任务持续处于就绪态(比如各种 while(1) 里不带延时、或者延时很短的任务),tshell 永远排不上队 —— 现场的表现就是”敲回车屏幕上什么都没有”。

请注意这个现象和”命令不存在”完全是两回事,但现场同事的描述可能都是”shell 没反应”。区分方法:如果系统本身还在正常工作(业务没停),只是 shell 不响应,那就是饿死,而不是命令没编进去。

对策:把现场固件的 FINSH_THREAD_PRIORITY 提到比最关键的实时任务略低、但高于普通业务任务的位置(我的做法是实时任务 5~8、tshell 12、业务任务 16~24),并且检查有没有不带阻塞点的死循环任务 —— 那才是根因,tshell 只是受害者。

12.2 4KB 栈的坑:敲命令就死,系统还活着

命令函数跑在 tshell 的栈上。如果在命令里定义一个大数组:

static int cmd_bad(int argc, char **argv)
{
    char buf[4096];      /* 光这一个数组就吃光了整个 tshell 栈 */
    rt_kprintf("buf=%p\n", buf);   /* 死在这附近 */
    ...
}

结果是 tshell 栈溢出,栈上数据踩到相邻内存。典型表现是”一敲这个命令就死机/复位,其他功能正常” —— 因为 tshell 挂了但内核还在跑。

排查方法:list_thread 看 tshell 那一行的 max used:

thread   pri  status      sp     stack size max used left tick  error
-------- ---  ------- ---------- ----------  ------  ---------- ---
tshell    20  ready   0x000000b0 0x00001000      92%  0x0000000a 000
                                      ^^^^^^^^^^^^^
                                      4096 字节的栈已经用了 92%

92% 就是红线,不是”还能跑”。 现场命令的栈用量跟参数长度强相关,一次超长的输入就可能把 8% 余量吃光。

规范做法:

  • 命令里不放大数组,需要缓冲就 static(单线程执行,tshell 是唯一的调用者)或用 rt_malloc(但按我们的零 malloc 约定,用 static 更好);
  • 需要大缓冲的诊断命令(比如 dump 一屏数据),用一个模块级 static 缓冲 + 互斥保护,别放栈上;
  • 把 list_thread(或者我自己的 diag thr)当作日常巡检项,而不是出事才看。任何线程的 max used 长期高于 80% 就说明它迟早会出事。

十三、命令的安全边界:哪些命令不该出现在量产固件里

shell 是个双向接口:它既能让你在客户现场救活一台设备,也能让一个乱插串口的人把设备变砖。现场设备上我按这个清单做分级:

危险等级 命令类型 处置
高 擦写 Flash / 改校准参数 / 清 EEPROM / 强制继电器输出 / 改通信地址 release 固件里用宏整体屏蔽
中 改日志级别 / 强制重启 / 手动触发上报 保留,但要有回显确认
低 查询类(diag stat / diag thr / diag obj) 保留,现场自助全靠它

三种屏蔽手段:

#ifdef RT_USING_FINSH
#ifdef DIAG_SAFE_MODE          /* release: 只有查询类命令 */
#define MSH_CMD_EXPORT_SAFE(cmd, desc)  MSH_CMD_EXPORT(cmd, desc)
#else                          /* 调试: 全部命令都在 */
#define MSH_CMD_EXPORT_SAFE(cmd, desc)  MSH_CMD_EXPORT(cmd, desc)
#endif
#endif

/* 命令名冲突:统一加前缀 diag_ / xxx_ */
  1. 编译期宏隔离(首选):危险命令用 #ifdef DEBUG_BUILD 包住,release 固件里连代码都不存在,不存在”误敲”的可能。
  2. FINSH_USING_AUTH + 口令:较新的 RT-Thread 支持 shell 登录口令(FINSH_DEFAULT_PASSWORD),能挡住”随手插个 USB 转串口就乱敲”的人。
  3. 二次确认:对确实需要在现场执行的写操作,cmd_xxx confirm 这种显式第二参数,比 cmd_xxx 直接动手安全得多。

另外提醒一句:shell 的串口线对外就是攻击面。如果设备的调试口是引到外部连接器的,那它至少应该和业务口做电气隔离(哪怕只是串个电阻),别让现场误接 24V 把 MCU 带走。


十四、内存与 Flash 的账:别让诊断功能吃掉预算

诊断功能要留,但要按总量给预算,不能”用着用着就发现 Flash 满了”。

内核对象的内存账(以 RT_NAME_MAX=8,32 位目标为例):

python rtt_fmt.py objmem --threads 8 --sems 12 --mutex 4 --devices 9 --name-max 8
RT_NAME_MAX = 8  →  struct rt_object 基类 ≈ 20 字节

对象类型              数量       基类区      额外数据        小计
----------------------------------------------------
thread             8       160       736       896
semaphore         12       240       144       384
mutex              4        80        96       176
device             9       180       144       324
----------------------------------------------------
合计                33                          1780

对象名字节账:33 个对象 × 8 字节 = 264 字节(内嵌在基类里,无法复用)

(这个表里 thread 的”额外数据”含 rt_thread 结构体其余字段,不含线程栈 —— 栈才是大头,别漏了。)

MSH 命令符号表的 Flash 账:

python rtt_fmt.py cmdtab --cmds 60 --avg-name 12 --avg-desc 28
单条命令符号开销      ≈ 54 字节
60 条命令            ≈ 3240 字节 (3.2 KB)

若关掉 FINSH_USING_DESCRIPTION:单条 25 字节,60 条 ≈ 1500 字节 (1.5 KB),省 1740 字节 (54%)

三点结论:

  • 命令符号表本身不贵(几十条命令才 3KB 左右,还不含函数体),别为了省这点空间把调试能力砍掉 —— 现场一次远程救活的成本远超 3KB Flash。
  • 描述字符串是符号表里最”虚”的一半开销。release 固件可以关 FINSH_USING_DESCRIPTION(省约 54%),代价是 help 里没说明,现场自助性下降。我自己的做法:调试固件保留,release 关掉,但把 diag 的用法写在设备贴纸上。
  • 真正占空间的是对象名和日志缓冲:对象数 × RT_NAME_MAX 是硬开销;日志缓冲(ULOG_LINE_BUF_SIZE)和 console 缓冲(RT_CONSOLEBUF_SIZE)是每个调用点都要的栈预算。

十五、七条实战坑速查

# 坑 现象 根因 处置
1 FSymTab 被 --gc-sections 回收 编译链接全过,命令敲不出来 链接脚本没有 KEEP(*(FSymTab)) 加 KEEP,或先关 gc-sections 验证
2 RT_NAME_MAX=8 静默截断 list_device 里名字一样;查找返回错对象 名字数组定长 + rt_strncpy(n-1) 短名规范;改宏后 clean 全编
3 FINSH_CMD_SIZE=80 长命令行后半截丢了,参数静默缺失 输入行缓冲太小 提到 128/256
4 FINSH_FUNCTION_EXPORT 导出老式函数 MSH 下参数变成 argc 的值 MSH 一律按 (argc, argv) 调用 改成 MSH_CMD_EXPORT + (argc, argv)
5 RT_CONSOLEBUF_SIZE=128 输出被砍成半截,JSON 被服务端判错 格式化缓冲在栈上且静默截断 先估长度、分段打、或调大并重评栈
6 rt_kprintf("%f") 数值位置为空或 0,编译零告警 rt_vsnprintf 没有浮点分支 定点格式化;同时确认 RT_KPRINTF_USING_LONGLONG 是否开
7 tshell 优先级 20 + 4KB 栈 敲命令无响应 / 一敲就死 被高优先级任务饿死 / 命令局部变量爆栈 调优先级;命令里不放大数组;list_thread 盯 max used

十六、落地自查清单

☐ 1. rtconfig.h 中 RT_USING_FINSH 与 FINSH_USING_SYMTAB 都已开启,且开了 FINSH_USING_MSH_ONLY

☐ 2. 链接脚本中存在 KEEP((FSymTab)) / KEEP((VSymTab)) / KEEP((SORT(.rti_fn))),已在 map 文件里确认段非空

☐ 3. 所有自研命令都用 MSH_CMD_EXPORT 或 MSH_CMD_EXPORT_ALIAS 导出,签名为 int f(int argc, char **argv),并统一带项目前缀

☐ 4. 命令里的数字参数全部用 strtol/strtoul 显式转换,并检查非法输入(end == argv[1] || *end != '\0')

☐ 5. FINSH_CMD_SIZE 已按最长命令调整(建议 ≥128),FINSH_ARG_MAX 已确认够用

☐ 6. RT_NAME_MAX 下所有对象名均 ≤ RT_NAME_MAX-1 字符,且全局唯一(已用脚本或人工核对)

☐ 7. 量产固件的 FINSH_THREAD_PRIORITY 高于普通业务任务、低于实时任务,且系统中不存在无阻塞点的死循环任务

☐ 8. list_thread(或 diag thr)日常巡检,所有线程 max used 低于 80%,tshell 尤其关注

☐ 9. 所有 rt_kprintf 输出长度已按 RT_CONSOLEBUF_SIZE 核算过,长输出已分段;打 64 位整数前已确认 RT_KPRINTF_USING_LONGLONG

☐ 10. 业务路径不存在 %f/%g/%e,浮点全部走定点格式化;业务代码零 rt_malloc/动态对象创建

☐ 11. 中断上下文不调用 rt_kprintf;日志走 ulog(必要时异步 + 丢弃计数),且日志串口与业务串口物理分开

☐ 12. release 固件已用编译宏屏蔽所有写入类危险命令,调试口已做基本的电气保护


相关阅读


本文配套脚本:_tools/rtt_fmt.py —— 定点格式化(替代 %f)、输出长度 vs RT_CONSOLEBUF_SIZE 截断检查、内核对象内存账、MSH 命令符号表 Flash 开销、rt_kprintf 阻塞耗时估算。全部为离线整数/浮点估算,不联网。

下次打算写:USB Type-C / PD 电源协商里的坑(CC 线上的 BMC 编码、Source/Sink 角色翻转、PDO 协商失败的分诊),或者充电 IC 外围设计(截止电压精度、充电电流与温升、NTC 位置)。哪个先写,看这周的调试口先出什么问题。