RT-Thread 内核对象与 FinSH 进阶实战:客户现场敲 `list_thread` 一片空白,我把对象容器、命令注册段和 rt_kprintf 的边界从头翻了一遍
去年给一家做配电终端的客户做了一套基于 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();
}
三个必须注意的点:
rt_list_entry(node, struct rt_object, list)—— 链表节点是obj->list,要通过container_of反推回对象本身。忘了这一步会拿到错的地址。- 遍历不是原子的。链表增删本身在对象层是关中断保护的,但”你遍历到一半,别的线程删掉了一个节点”依然可能发生。诊断命令要么
rt_enter_critical()包住(对象不多、耗时可接受),要么接受”快照可能不一致”并在文档里写明。别在中断频繁、对象高速增删的系统里裸遍历。 - 先
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, charargv) 调用的。**
所以如果你用
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 风格即可。换平台移植后命令消失,第一件事就是查这两个宏在本工程里展开成了什么。
排查三步(值得抄在工位上)
- 看
rtconfig.h:RT_USING_FINSH和FINSH_USING_SYMTAB是不是都开了; - 看 map 文件:搜
FSymTab,确认段存在、__fsymtab_start与__fsymtab_end不相等,并且里面有你那条命令的名字字符串; - 段大小是 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 的采集线程也会被拖到丢样。
三条纪律:
- 中断里绝不
rt_kprintf。 只置标志位、填一个无锁环形缓冲,让低优先级日志线程去输出。 - 控制路径上的输出全部走”可丢”通道,满了就丢弃并计数(丢弃计数本身要能被读出来,否则”日志没打出来”会被误读成”代码没跑到”)。
- 高频调试输出换物理通道: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_ */
- 编译期宏隔离(首选):危险命令用
#ifdef DEBUG_BUILD包住,release 固件里连代码都不存在,不存在”误敲”的可能。 FINSH_USING_AUTH+ 口令:较新的 RT-Thread 支持 shell 登录口令(FINSH_DEFAULT_PASSWORD),能挡住”随手插个 USB 转串口就乱敲”的人。- 二次确认:对确实需要在现场执行的写操作,
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 固件已用编译宏屏蔽所有写入类危险命令,调试口已做基本的电气保护
相关阅读
- RT-Thread 设备框架与驱动移植实战:从 FreeRTOS”各写各的 SPI”搬过来,我把 rt_device 这层统一套路补齐了
- TencentOS tiny / RT-Thread / FreeRTOS 怎么选:一次表计项目的选型对比实录 —— 优先级方向相反的三个同名不同义坑
- Zephyr 与 FreeRTOS 迁移实战:把跑了三年的 FreeRTOS 项目搬到 Zephyr,我把两个 RTOS 的底层差异从头对了一遍
- FreeRTOS 栈溢出检测实战:从”莫名其妙死机”到高水位线监控,一次内存踩踏的排查记
- ITM/SWO 打印实战:printf 把 100μs 的控制中断拖到超时,我才明白调试输出也是一种”外设” —— 不占业务串口的高频调试输出通道
- 电池管理与 SOC 估算实战:磷酸铁锂显示还剩 40%,三分钟后直接关机 —— 64 位整数积分打印踩坑的那一次
本文配套脚本:_tools/rtt_fmt.py —— 定点格式化(替代 %f)、输出长度 vs RT_CONSOLEBUF_SIZE 截断检查、内核对象内存账、MSH 命令符号表 Flash 开销、rt_kprintf 阻塞耗时估算。全部为离线整数/浮点估算,不联网。
下次打算写:USB Type-C / PD 电源协商里的坑(CC 线上的 BMC 编码、Source/Sink 角色翻转、PDO 协商失败的分诊),或者充电 IC 外围设计(截止电压精度、充电电流与温升、NTC 位置)。哪个先写,看这周的调试口先出什么问题。