
RTOS设计与开发(10):panic、断言与栈诊断,先把错误路径收口
RTOS设计与开发(10):panic、断言与栈诊断,先把错误路径收口
内核一旦开始有 mutex、消息队列、软件定时器,错误路径的复杂度就会迅速上升。
这个阶段最值得写的一点,不是“又多了一个模块”,而是 RTOS 明确开始建设自己的诊断体系了。它不再满足于“出了错就 while(1)”这种裸停机,而是试图把错误统一分类、统一上报、统一收口。
这版代码的诊断体系主要由四部分组成:
panic原因枚举和 hook。OS_ASSERT宏。- 任务栈填充、高水位和哨兵检查。
- 端口层对
HardFault/UsageFault/ 非法切换链路的统一收口。
为什么 panic 要先变成一个明确的数据结构
先看 os_diag.h:
typedef enum {
OS_PANIC_ASSERT = 0,
OS_PANIC_TASK_STATE,
OS_PANIC_STACK_POINTER_RANGE,
OS_PANIC_STACK_SENTINEL,
OS_PANIC_PORT_FAILURE,
OS_PANIC_HARDFAULT,
OS_PANIC_USAGEFAULT
} os_panic_reason_t;
typedef struct os_panic_info {
os_panic_reason_t reason;
const char *file;
uint32_t line;
const struct tcb *current_task;
} os_panic_info_t;
这份设计很重要,因为它把“出错”从一个单纯的停机动作,提升成了一份可传递的诊断信息。
也就是说,内核不只是说“我崩了”,而是在说:
- 我为什么崩。
- 在哪一行崩。
- 当时谁在运行。
这比简单打一个错误码要强得多,因为 RTOS 的很多错误不是功能性返回值,而是系统模型被破坏。对于这种错误,继续运行往往没有意义,但可诊断性非常有意义。
panic hook 为什么比直接打印更通用
os_diag.c 里提供的不是一套固定输出,而是一个 hook 机制:
os_panic_hook_t os_panic_hook_set(os_panic_hook_t hook)
{
os_panic_hook_t old_hook = NULL;
uint32_t primask = 0U;
primask = os_port_enter_critical();
old_hook = g_os_panic_hook;
g_os_panic_hook = hook;
os_port_exit_critical(primask);
return old_hook;
}
这样做的价值在于:内核只负责定义 panic contract,不预设应用必须怎么观察它。
不同项目可以把 hook 接到不同渠道上:
- 串口打印。
- SWO/ITM。
- LED 错误码。
- RAM 中保留一份 crash snapshot。
当前实现没有把这些策略硬编码进 RTOS 本体,而是保持了内核层的克制。
os_panic() 为什么一旦进入就不允许重入
panic 主路径很短,但逻辑很紧:
void os_panic(os_panic_reason_t reason, const char *file, uint32_t line)
{
os_panic_info_t info = {
.reason = reason,
.file = file,
.line = line,
.current_task = NULL,
};
os_panic_hook_t hook = NULL;
(void)os_port_enter_critical();
if (g_os_panic_active != 0U)
{
while (1)
{
}
}
g_os_panic_active = 1U;
info.current_task = task_get_current();
hook = g_os_panic_hook;
if (hook != NULL)
{
hook(&info);
}
while (1)
{
}
}
这段代码体现了一个很成熟的判断:
进入 panic 以后,系统最重要的不是“尽量往前跑”,而是“不要再把现场二次破坏”。
所以它先关中断、再防重入、再调用 hook、最后永久停机。换句话说,这一版 panic 的设计目标不是恢复系统,而是稳定地把错误现场收住。
OS_ASSERT 为什么直接接 panic,而不是返回错误码
OS_ASSERT 的实现很直接:
#if (OS_ASSERT_ENABLE != 0U)
#define OS_ASSERT(expr) \
do \
{ \
if ((expr) == 0) \
{ \
os_panic(OS_PANIC_ASSERT, __FILE__, (uint32_t)__LINE__); \
} \
} while (0)
#endif
这背后是一个很明确的分类:
- 参数错误、对象状态不允许执行,属于普通 API 返回值问题。
- “本不应该发生”的内部不变量破坏,属于 assert/panic 问题。
当前 RTOS 已经开始认真区分这两类错误了。这样做的好处是,调用方不会把真正的内核一致性损坏误当成一个还能继续处理的普通返回值。
为什么任务创建时就要先填满栈
任务层现在在创建任务时会先预填整段栈:
static void task_fill_stack_pattern(uint32_t *stack_base, uint32_t stack_size)
{
uint32_t index = 0U;
if (stack_base == NULL)
{
return;
}
for (index = 0U; index < stack_size; index++)
{
stack_base[index] = OS_TASK_STACK_FILL_PATTERN;
}
}
配置宏里也明确给了默认填充值:
#define OS_TASK_STACK_FILL_PATTERN 0xA5A5A5A5UL
这一步不是为了好看,而是同时服务两个诊断目标:
- 栈底哨兵检查。
- high water mark 统计。
一旦整段栈都先被填成固定 pattern,后面就可以从低地址开始数还有多少 word 没被真正使用过。
为什么当前版本只检查“栈底哨兵字”
当前的栈越界检查是非常克制的:
static uint8_t task_stack_sentinel_is_intact(const tcb_t *task)
{
if ((task_is_valid(task) == 0U) || (task->stack_base == NULL) || (task->stack_size == 0U))
{
return 0U;
}
return (uint8_t)(task->stack_base[0] == OS_TASK_STACK_FILL_PATTERN);
}
也就是说,这版代码暂时只把栈底第一个 word 当作哨兵字来检查。
这不是最强的检测方式,但它足够便宜,而且对于 Cortex-M 这种栈向低地址增长的场景来说,已经能相当有效地发现“栈溢出把底部打穿”的情况。更重要的是,它把诊断接进了运行期主路径,而不是停留在离线估算。
为什么哨兵检查放在 task_system_tick() 里
当前哨兵检查是在 tick 路径上做的:
#if (OS_TASK_STACK_CHECK_ENABLE != 0U)
if ((g_current_task != NULL) && (task_stack_sentinel_is_intact(g_current_task) == 0U))
{
os_port_exit_critical(primask);
os_panic(OS_PANIC_STACK_SENTINEL, __FILE__, (uint32_t)__LINE__);
}
#endif
这个位置选得很稳。
因为 SysTick 本来就会周期性进入任务层,成本上最容易承载“顺手做一次运行期栈检查”。这样不需要额外的后台任务,也不需要每个 API 都插一遍检查点。
代价是它不是“立刻发现”,而是“最迟在下一个 tick 发现”。但对于当前这个阶段的内核,这个 tradeoff 很合理。
high water mark 为什么比“估算剩余栈”更靠谱
对外公开的查询接口是:
os_status_t task_stack_high_water_mark_get(const tcb_t *task, uint32_t *unused_words)
{
uint32_t count = 0U;
...
while ((count < task->stack_size) && (task->stack_base[count] == OS_TASK_STACK_FILL_PATTERN))
{
count++;
}
*unused_words = count;
return OS_STATUS_OK;
}
它统计的不是“此刻 SP 离栈底还有多远”,而是“从任务创建到现在,栈底连续有多少 word 从未被动过”。
这就是 high water mark 的意义。它比瞬时 SP 更适合做调优,因为瞬时值只代表“现在”,而 high water mark 代表“历史上最深用到哪儿”。
端口层为什么还要检查 PSP 合法范围
除了任务层的哨兵检查,port 层也在真正切换上下文时检查 PSP 是否还在合法栈区间里:
current_task = task_get_current();
if (current_task != NULL)
{
current_task->sp = stack_pointer;
#if (OS_TASK_STACK_CHECK_ENABLE != 0U)
if (os_port_task_sp_is_valid(current_task, current_task->sp) == 0U)
{
os_panic(OS_PANIC_STACK_POINTER_RANGE, __FILE__, (uint32_t)__LINE__);
}
#endif
}
...
if (os_port_task_sp_is_valid(next_task, next_task->sp) == 0U)
{
os_panic(OS_PANIC_STACK_POINTER_RANGE, __FILE__, (uint32_t)__LINE__);
}
这和哨兵检查解决的是不同的问题。
- 哨兵检查偏向发现“栈已经压过头了”。
- PSP 范围检查偏向发现“当前要保存/恢复的栈指针本身就已经离谱了”。
两个检查叠在一起,能覆盖掉不少切换链路上的坏上下文情况。
为什么 HardFault 和 UsageFault 也统一收进 panic
port 层还做了一件非常对的事情:把硬 fault 路径也接到了同一套诊断模型里。
void HardFault_Handler(void)
{
os_panic(OS_PANIC_HARDFAULT, __FILE__, (uint32_t)__LINE__);
}
void UsageFault_Handler(void)
{
os_panic(OS_PANIC_USAGEFAULT, __FILE__, (uint32_t)__LINE__);
}
同时,初始化异常优先级时还显式打开了 UsageFault:
SCB->SHCSR |= SCB_SHCSR_USGFAULTENA_Msk;
这意味着当前 RTOS 的诊断链路已经不再只覆盖“代码自己主动检查出来的错”,也开始覆盖 CPU 直接抛出来的故障。
小结
这一阶段的诊断体系有一个非常明显的特点:
它没有追求“花哨的调试框架”,而是优先把最关键的几条错误路径统一起来。
具体来说,它已经把这些事情做成了:
panic有统一原因枚举和现场信息。OS_ASSERT能直接接到 panic 路径。- 任务栈支持 fill pattern、栈底哨兵和 high water mark。
- 上下文切换时会检查 PSP 是否仍在合法范围。
HardFault/UsageFault/ port 失败都会走同一套 panic 入口。
对于一个还在快速演进的 RTOS,这种“先把错误暴露得一致”比一开始就做复杂的调试 UI 更重要。
下一篇会进入这轮新增里更偏服务化的一块:软件定时器,看看 active list、expired FIFO 和 daemon task 是怎么拼起来的。