4.6.1 断言机制
4.6.1 断言机制
📚 本节导读
学习时长: 约 25 分钟
难度级别: ⭐⭐☆☆☆
前置知识: C 语言宏、宏定义 do { ... } while(0)、位运算基础
🎯 学习目标
- 理解断言在嵌入式开发中的作用与适用边界
- 掌握
OS_ASSERT和OS_ASSERT_EX两个宏的完整定义与行为 - 理解断言触发后的安全处理流程(
os_safety_assert_process()) - 掌握断言钩子(Hook)的注册与调用机制
- 能够区分断言和错误码的适用场景,并做出正确选型
一、断言概念
1.1 什么是断言
断言(Assertion)是一种运行时条件检查机制,用于验证程序运行中"不应该发生"的情况。当断言条件为假时,表示程序出现了不应该出现的错误状态,系统将打印错误信息并进入安全处理流程。
断言的核心思想是:在开发阶段尽早暴露问题——如果某个条件在逻辑上不可能为假,就用断言来验证它。一旦断言失败,就能迅速定位到问题的根源。
为什么需要断言(讲"为什么"):
在嵌入式系统中,很多 bug 并不会立刻显现。比如一个空指针被传给函数,如果不加检查,可能直到几毫秒后程序随机崩溃才暴露出来,此时现场已被破坏,极难定位。而断言把"本不该发生"的检查前置到错误发生的第一现场:
- 缩短定位时间:断言失败时能立刻打印出"哪个条件、哪个函数、哪一行",直接把问题锚定到源码位置,而不是靠猜。
- 明确假设:断言实际上是在代码中写下对函数输入/状态的假设。如果假设不成立,就说明调用方或上层逻辑写错了,而不是本函数的问题。
- 零成本于发布版:断言是可配置编译期开关的,发布版本可完全剔除,不残留任何判断代码与运行时开销(见"二、配置")。
1.2 断言 vs 错误码
| 特性 | 断言 | 错误码 |
|---|---|---|
| 处理方式 | 打印信息 + 进入安全处理 | 函数返回错误码 |
| 适用场景 | 不应发生的逻辑错误(编程错误) | 可预见的运行时错误 |
| 可恢复性 | 通常不可恢复(停机/挂起) | 调用者可以处理 |
| 性能影响 | 可配置关闭(零开销) | 始终存在 |
| 典型用例 | 空指针、参数越界、状态不一致 | 资源不足、超时、权限拒绝 |
| 检查对象 | 调用方/自身逻辑的 bug | 外部环境/用户输入的异常 |
原则:用断言检查"不可能发生"的条件(编程错误),用错误码处理"可能发生但可恢复"的错误(运行时错误)。
举个反例帮助理解:
- ❌ 错误:用断言去检查"内存分配失败"。因为内存不足是可能发生的运行时状况,用断言会导致系统直接停机,用户没有挽回余地。应返回
OS_ENOMEM让调用方决定。 - ✅ 正确:用断言去检查"枚举值非法"。因为
state只能取LED_ON/LED_OFF是代码层面的不变量,如果出现第三个值,说明写代码的人错了,直接暴露出来是最好的结果。
1.3 断言在 OneOS 错误处理体系中的位置
OneOS 的错误处理是一套分层防护体系:
错误处理体系
│
├─ ① 断言机制(本小节) ──► 编程错误,开发期暴露
├─ ② 堆栈溢出检测(4.6.2) ──► 栈空间被突破,运行时检测
├─ ③ 异常处理(HardFault 等)──► CPU 硬件异常
└─ ④ 安全机制(SAFETY_MECHANISM)──► 提供统一钩子入口其中断言是第一层、最常用的防护,用于在开发调试阶段捕获"不应该发生"的条件。断言触发后会调用安全处理流程 os_safety_assert_process(),后者可以根据是否启用安全机制,选择调用用户钩子或直接停机。
二、断言配置
断言相关的行为完全由两个编译期宏开关决定:OS_USING_ASSERT 和 OS_USING_SAFETY_MECHANISM。它们定义在工程的 oneos_config.h 配置文件中(一般由构建系统根据 .config 生成,也可以手工编辑)。这些宏在 kernel/include/os_assert.h、kernel/include/os_safety.h 中被 #ifdef 引用,决定宏展开成完整实现还是空语句。
2.1 配置文件与宏定义位置
来源:projects/stm32f103zet6-atk-elite/oneos_config.h
/* Check */
#define OS_USING_OVERFLOW_CHECK
#define OS_USING_ASSERT
#define OS_USING_KERNEL_LOCK_CHECK
/* end of Check */在 oneos_config.h 的 /* Check */ 段,通过 #define OS_USING_ASSERT 定义断言开关:
| 宏 | 定义时 | 未定义时 |
|---|---|---|
OS_USING_ASSERT | 断言生效,OS_ASSERT / OS_ASSERT_EX 为完整实现 | 断言为空语句,零开销 |
OS_USING_SAFETY_MECHANISM | os_safety_* 为真实函数,可注册钩子 | os_safety_assert_process 为"关中断 + 死循环"宏 |
说明:
OS_USING_SAFETY_MECHANISM在大多数默认工程中未定义(即不启用安全机制)。只有需要钩子接管断言/栈溢出/异常处理时才定义它。
2.2 如何启用断言
在 oneos_config.h 中,启用断言只需保留 #define OS_USING_ASSERT(默认工程已启用):
#define OS_USING_ASSERT /* 启用断言机制 */启用后:
OS_ASSERT(condition)和OS_ASSERT_EX(condition, fmt, ...)会展开成完整逻辑(判断 + 打印 + 调处理函数)。- 需要同时保证
OS_DEBUG已定义(在配置文件的/* Debug */段),因为断言属于调试构建的一部分。
数值演算(宏展开效果):启用时,OS_ASSERT(ptr != OS_NULL) 会被预处理器展开为(以下为展开后的等效结构):
| 原始写法 | 预处理展开结果 | 执行行为 |
|---|---|---|
OS_ASSERT(ptr != OS_NULL) | do { if (!(ptr != OS_NULL)) { os_kprintf(...); os_safety_assert_process(); } } while (0) | ptr == OS_NULL 时打印并进入安全处理 |
2.3 如何禁用断言(发布版)
在 oneos_config.h 中注释掉 OS_USING_ASSERT 宏:
// #define OS_USING_ASSERT /* 禁用断言,生产版本中推荐 */禁用后:
OS_ASSERT和OS_ASSERT_EX均被替换为空语句,condition表达式不会参与编译、不会被执行。- 不产生任何判断指令与打印调用,固件体积更小、运行时零开销。
易错点:因为禁用后
condition不参与编译,所以不要在断言条件里写有副作用的操作(如OS_ASSERT(++i < 10))——否则启用与禁用断言时程序行为会不一致。
2.4 如何启用安全机制(钩子)
若希望断言失败时执行自定义处理(而非裸死循环),需在 oneos_config.h 中定义 OS_USING_SAFETY_MECHANISM:
#define OS_USING_SAFETY_MECHANISM /* 启用功能安全机制,可使用钩子 */定义后:
os_safety_assert_process()变为真实函数(kernel/source/os_safety.c)。- 可在代码中通过
os_safety_assert_set_hook()注册断言钩子(详见 4.2 节)。 - 未注册钩子时,仍会走"关中断 + 死循环"的默认停机逻辑。
两种配置的完整对照:
| 配置组合 | OS_ASSERT 行为 | os_safety_assert_process() 行为 |
|---|---|---|
OS_USING_ASSERT 未定义 | 空语句(零开销) | (无调用发生) |
OS_USING_ASSERT 已定义,OS_USING_SAFETY_MECHANISM 未定义 | 打印 + 调用处理函数 | 宏:关中断 + 死循环停机 |
OS_USING_ASSERT 已定义,OS_USING_SAFETY_MECHANISM 已定义 | 打印 + 调用处理函数 | 函数:有钩子则调钩子,无钩子则关中断 + 死循环 |
三、断言 API
3.1 OS_ASSERT — 基本断言
函数签名
OS_ASSERT(condition)参数说明
| 参数 | 类型 | 说明 |
|---|---|---|
condition | 布尔表达式 | 需要验证的条件。为 真 时什么都不做;为 假 时触发断言失败处理 |
返回值:
OS_ASSERT是宏(void 类型),无返回值。它在OS_USING_ASSERT未开启时展开为空语句。
完整源码
来源:kernel/include/os_assert.h
#ifdef OS_USING_ASSERT
#define OS_ASSERT(condition) \
do \
{ \
if (!(condition)) \
{ \
os_kprintf("Assert failed. Condition(%s). [%s][%d]\r\n", #condition, __FUNCTION__, __LINE__); \
os_safety_assert_process(); \
} \
} while (0)
#else
#define OS_ASSERT(condition)
#endif /* OS_USING_ASSERT */分步详解
第一步:编译期分支 —— 检查是否启用断言(条件 OS_USING_ASSERT)
#ifdef OS_USING_ASSERT
#define OS_ASSERT(condition) \
do \
{ \
if (!(condition)) \
{ \
os_kprintf("Assert failed. Condition(%s). [%s][%d]\r\n", #condition, __FUNCTION__, __LINE__); \
os_safety_assert_process(); \
} \
} while (0)
#else
#define OS_ASSERT(condition)
#endif /* OS_USING_ASSERT */- 当
OS_USING_ASSERT未定义时,OS_ASSERT(condition)被预处理器替换成一个空语句,所有传入condition的表达式在编译期就被丢弃,不生成任何机器码。 - 这带来两个好处:① 发布版零开销;② 传参表达式不会被求值,因此没有副作用残留。
- 易错点:正因为禁用时表达式不参与编译,若
condition里含未定义标识符,只有启用断言才会报编译错。所以不要在condition里写"有副作用的操作"(如OS_ASSERT(++i < 10)),否则启用/禁用断言时行为不一致。
第二步:do { ... } while(0) 封装(无条件执行)
do \
{ \
if (!(condition)) \
{ \
os_kprintf("Assert failed. Condition(%s). [%s][%d]\r\n", #condition, __FUNCTION__, __LINE__); \
os_safety_assert_process(); \
} \
} while (0)do { ... } while(0)是 C 语言宏的标准写法,作用是让多行宏像一条普通语句一样使用。- 为什么用
do { ... } while(0)(讲"为什么"):宏在预处理时是纯文本替换。如果不用这个封装,当用户写if (x) OS_ASSERT(ptr);时,替换后可能变成:
if (x)
{
if (!(ptr))
{
os_kprintf("Assert failed. Condition(%s). [%s][%d]\r\n", #condition, __FUNCTION__, __LINE__);
os_safety_assert_process();
}
}
os_safety_assert_process(); /* 如果没封装,这里会脱离 if,无条件执行 */实际上,由于没有 do { } while(0) 包裹,宏体中的最后一条语句 os_safety_assert_process(); 会脱离 if (x) 的控制,无条件执行,导致即使条件满足也错误地进入安全处理。while(0) 之后的分号由调用方补上,且整个宏被包成一个语句块,因此无论放在 if、for、还是普通语句中,都能保持语义正确。
while(0)本身只执行一次(条件恒为假),循环体退出即结束,没有额外开销。
第三步:判断条件为假(条件 !(condition) 为真时进入)
if (!(condition))- 这是断言的核心判断。逻辑上等价于
if (condition == 0)。 - 为假(
0)时进入断言失败分支;为真(非0)时整个if不进入,断言通过,继续执行后续代码。 - 注意这里用的是
!(逻辑非),不是按位取反~。condition会被当作"真/假"来判定,任何非零值都视为真。
第四步:打印断言失败信息(条件 !(condition) 为真时执行)
os_kprintf("Assert failed. Condition(%s). [%s][%d]\r\n", #condition, __FUNCTION__, __LINE__);| 打印项 | 含义 | 来源 |
|---|---|---|
"Assert failed. Condition(%s)." | 固定的前缀,提示断言失败 | 固定字符串 |
#condition | 条件表达式的原文(字符串化) | # 运算符把表达式转成字符串 |
[%s] → __FUNCTION__ | 当前函数名 | 编译器预定义宏 |
[%d] → __LINE__ | 断言所在源码行号 | 编译器预定义宏 |
\r\n | 回车换行 | 固定 |
#condition是字符串化(stringification)运算符:把传入的表达式文本原样转为字符串。例如OS_ASSERT(ptr != OS_NULL)中#condition展开为"ptr != OS_NULL"。__FUNCTION__和__LINE__是编译器提供的预定义宏,分别在编译期被替换为当前函数名和所在行号,无需手动维护。os_kprintf声明于kernel/include/os_util.h:extern void os_kprintf(const char *fmt, ...);,是 OneOS 的格式化打印接口(底层可对接串口/日志)。
输出示例(演算):
假如在 my_task.c 第 128 行写了 OS_ASSERT(ptr != OS_NULL); 且 ptr 为 NULL:
| 打印占位符 | 代入内容 | 结果 |
|---|---|---|
%s(condition) | #condition → "ptr != OS_NULL" | ptr != OS_NULL |
%s(function) | __FUNCTION__ | my_task(示例) |
%d(line) | __LINE__ | 128 |
最终串口输出:
Assert failed. Condition(ptr != OS_NULL). [my_task][128]第五步:进入安全处理流程(条件 !(condition) 为真时执行)
os_safety_assert_process();- 打印完信息后,调用
os_safety_assert_process()进入安全处理流程。该函数的行为取决于OS_USING_SAFETY_MECHANISM(详见 4.1 节)。 - 它可能调用用户注册的断言钩子,也可能直接关中断并死循环。因此断言失败后一般不会返回到这里。
OS_ASSERT 条件走向总结
OS_ASSERT(condition) 条件走向总结:
├── OS_USING_ASSERT 未定义
│ └── 宏展开为空语句(零开销,不编译 condition)
└── OS_USING_ASSERT 已定义
├── condition 为真(非 0)→ if(!(condition)) 不进入 → 断言通过,继续执行
└── condition 为假(0)→ if(!(condition)) 进入
├── os_kprintf() 打印 条件原文 + 函数名 + 行号
└── os_safety_assert_process() 进入安全处理(见 4.1)3.2 OS_ASSERT_EX — 带自定义消息的断言
函数签名
OS_ASSERT_EX(condition, fmt, ...)参数说明
| 参数 | 类型 | 说明 |
|---|---|---|
condition | 布尔表达式 | 需要验证的条件,为假时触发断言失败 |
fmt | 格式化字符串 | 自定义消息的格式串,遵循 printf 格式 |
... | 变参 | 与 fmt 中占位符对应的实际值 |
返回值:宏(void 类型),无返回值;
OS_USING_ASSERT未开启时展开为空语句。
完整源码
来源:kernel/include/os_assert.h
#define OS_ASSERT_EX(condition, fmt, ...) \
do \
{ \
if (!(condition)) \
{ \
os_kprintf("Assert failed. " fmt " [%s][%d]\r\n", ##__VA_ARGS__, __FUNCTION__, __LINE__); \
os_safety_assert_process(); \
} \
} while (0)
#else
#define OS_ASSERT_EX(condition, fmt, ...)
#endif /* OS_USING_ASSERT */分步详解
OS_ASSERT_EX 与 OS_ASSERT 的绝大部分逻辑相同(#ifdef 开关、do { } while(0) 封装、!(condition) 判断、os_safety_assert_process() 调用),区别仅在打印部分。此处重点讲不同之处。
第一步:编译期分支(条件 OS_USING_ASSERT)
#ifdef OS_USING_ASSERT
#define OS_ASSERT_EX(condition, fmt, ...) \
do \
{ \
if (!(condition)) \
{ \
os_kprintf("Assert failed. " fmt " [%s][%d]\r\n", ##__VA_ARGS__, __FUNCTION__, __LINE__); \
os_safety_assert_process(); \
} \
} while (0)
#else
#define OS_ASSERT_EX(condition, fmt, ...)
#endif /* OS_USING_ASSERT */- 与
OS_ASSERT完全一致:未启用时展开为空语句,启用时才是完整宏。
第二步:do { ... } while(0) 封装 + 条件判断(无条件执行)
do \
{ \
if (!(condition)) \
{ \
os_kprintf("Assert failed. " fmt " [%s][%d]\r\n", ##__VA_ARGS__, __FUNCTION__, __LINE__); \
os_safety_assert_process(); \
} \
} while (0)- 判断逻辑与
OS_ASSERT相同:condition为假才进入失败分支。这里不重复展开,可回看 3.1 的第二步、第三步。
第三步:打印自定义消息(条件 !(condition) 为真时执行)
os_kprintf("Assert failed. " fmt " [%s][%d]\r\n", ##__VA_ARGS__, __FUNCTION__, __LINE__);- 与
OS_ASSERT相比,差异点在于格式串:
| 差异 | OS_ASSERT | OS_ASSERT_EX |
|---|---|---|
| 条件打印 | 用 #condition 打印条件原文 | 不打印条件原文,改用用户提供的 fmt 消息 |
| 附加信息 | 仅函数名 + 行号 | 用户自定义消息 + 函数名 + 行号 |
| 变参 | 无 | ##__VA_ARGS__ 支持任意数量格式化参数 |
"Assert failed. " fmt " [%s][%d]\r\n"中的fmt是宏参数,在预处理时被拼接进格式串。所以如果你传fmt为"Timer ID invalid: 0x%08X",实际格式串就是"Assert failed. Timer ID invalid: 0x%08X [%s][%d]\r\n"。##__VA_ARGS__是 GNU/OneOS 支持的变参宏扩展:##的作用是当变参为空时,把前面紧邻的逗号也一并删除,避免编译错。例如:OS_ASSERT_EX(ptr != OS_NULL, "ptr is NULL")—— 没有变参,##吃掉逗号,格式串无多余占位符。OS_ASSERT_EX(prio < MAX, "prio=%d", prio)—— 有变参prio,正常填入%d。
输出示例(演算):
在 os_timer.c 第 56 行执行 OS_ASSERT_EX(timer != OS_NULL, "Timer ID invalid: 0x%08X", (os_ubase_t)timer);,且 timer 为 0x20001234:
| 格式串 | 占位符 | 代入 | 结果 |
|---|---|---|---|
Assert failed. | 固定 | — | Assert failed. |
fmt | — | 宏展开 | Timer ID invalid: 0x%08X |
%08X | ##__VA_ARGS__ | (os_ubase_t)timer = 0x20001234 | 20001234 |
[%s] | __FUNCTION__ | — | os_timer(示例) |
[%d] | __LINE__ | — | 56 |
最终串口输出:
Assert failed. Timer ID invalid: 0x20001234 [os_timer][56]第四步:进入安全处理流程(条件 !(condition) 为真时执行)
os_safety_assert_process();- 与
OS_ASSERT完全相同,见 3.1 第五步与 4.1 节。
OS_ASSERT_EX 条件走向总结
OS_ASSERT_EX(condition, fmt, ...) 条件走向总结:
├── OS_USING_ASSERT 未定义 → 宏展开为空语句
└── OS_USING_ASSERT 已定义
├── condition 为真 → 断言通过,继续执行
└── condition 为假
├── os_kprintf() 打印 "Assert failed. " + 自定义 fmt 消息 + 函数名 + 行号
└── os_safety_assert_process() 进入安全处理3.3 OS_ASSERT vs OS_ASSERT_EX 对比
| 对比项 | OS_ASSERT | OS_ASSERT_EX |
|---|---|---|
| 打印内容 | 条件表达式原文 + 函数名 + 行号 | 自定义消息 + 函数名 + 行号 |
| 是否支持变参 | 否 | 是(##__VA_ARGS__) |
| 使用成本 | 低,无额外参数 | 需写 fmt 与对应参数 |
| 适用场景 | 简单的"条件不满足"检查 | 需要输出变量具体值、定位更精准的检查 |
| 禁用行为 | 空语句 | 空语句 |
建议:检查"是否为 NULL"、"越界"等简单条件用
OS_ASSERT;当需要把失败现场的关键变量值打出来辅助排查时,用OS_ASSERT_EX。
四、断言的安全处理流程
4.1 os_safety_assert_process —— 断言失败后的处理
os_safety_assert_process() 是断言失败后的统一出口。它的实现有两种形态,取决于 OS_USING_SAFETY_MECHANISM。
形态一:未启用安全机制(os_safety.h 中的宏定义)
来源:kernel/include/os_safety.h
#define os_safety_assert_process() \
do \
{ \
os_irq_disable(); \
while (1) \
{ \
; \
} \
} while (0)形态二:启用安全机制(os_safety.c 中的真实函数)
来源:kernel/source/os_safety.c
static void (*gs_os_safety_assert_hook)(void) = OS_NULL;
void os_safety_assert_process(void)
{
if (OS_NULL != gs_os_safety_assert_hook)
{
gs_os_safety_assert_hook();
}
else
{
os_irq_disable();
while (1)
;
}
return;
}分步详解(以"形态二"真实函数为例)
第一步:检查是否已注册断言钩子(条件 gs_os_safety_assert_hook != OS_NULL)
if (OS_NULL != gs_os_safety_assert_hook)
{
gs_os_safety_assert_hook();
}- 读取全局函数指针
gs_os_safety_assert_hook(定义于os_safety.c顶部,初始为OS_NULL)。 - 若已通过
os_safety_assert_set_hook()注册了钩子,则调用该钩子,把断言失败的处理权交给用户自定义逻辑(如保存现场、复位、进入安全模式等)。
第二步:未注册钩子则停机(条件 gs_os_safety_assert_hook == OS_NULL)
else
{
os_irq_disable();
while (1)
;
}- 没有钩子时,采取最保守策略:
os_irq_disable()关闭全局中断,防止在死循环等待期间被中断打扰、掩盖现场。while(1) ;无限循环,让系统停在此处,不再继续执行可能已损坏的逻辑。
- 为什么这样处理(讲"为什么"):断言失败意味着系统已经进入"本不该发生"的状态,继续运行很可能导致不可预测的破坏(越界写、状态错乱)。此时停住是最安全的选择——既便于调试器挂住现场分析,也避免把故障扩散。
第三步:返回(无条件执行)
return;- 正常情况下不会执行到这里(因为要么钩子内部自行处理,要么死循环停机)。
return;只是语法兜底,保证函数有明确出口。
os_safety_assert_process 条件走向总结
os_safety_assert_process() 条件走向总结:
├── OS_USING_SAFETY_MECHANISM 未启用(宏形态)
│ └── os_irq_disable() + while(1) 死循环停机
└── OS_USING_SAFETY_MECHANISM 已启用(函数形态)
├── gs_os_safety_assert_hook != OS_NULL(已注册钩子)
│ └── 调用钩子 gs_os_safety_assert_hook()
└── gs_os_safety_assert_hook == OS_NULL(未注册钩子)
└── os_irq_disable() + while(1) 死循环停机4.2 断言钩子(Hook)机制
启用安全机制后,可以在系统启动早期注册断言钩子,把"断言失败"事件接管过来。
os_safety_assert_set_hook —— 注册断言钩子
来源:kernel/source/os_safety.c
void os_safety_assert_set_hook(void (*hook)(void))
{
gs_os_safety_assert_hook = hook;
}| 参数 | 类型 | 说明 |
|---|---|---|
hook | void (*)(void) | 断言失败时将被调用的回调函数指针 |
分步详解:
第一步:参数为函数指针(无条件执行)
hook 是一个 void (*hook)(void) 类型的函数指针,指向用户提供的、无参无返回值的回调函数。这个回调就是"断言失败时我要做的自定义处理"。
第二步:写入全局钩子指针(无条件执行)
gs_os_safety_assert_hook = hook;- 将
hook保存到全局静态变量gs_os_safety_assert_hook。 - 此后任何
os_safety_assert_process()被调用时,都会发现该指针非空并调用它。
易错点:这个钩子全局唯一(系统只有一个
gs_os_safety_assert_hook)。多次调用os_safety_assert_set_hook()会覆盖上一次注册的钩子,后注册的生效。这与其他安全钩子(栈溢出、异常)是各自独立的三个指针,互不影响。
三个安全钩子的对应关系
os_safety.c 中定义了三个独立的全局函数指针与对应的注册函数:
| 事件 | 钩子函数指针 | 注册函数 | 处理函数 |
|---|---|---|---|
| 断言失败 | gs_os_safety_assert_hook | os_safety_assert_set_hook() | os_safety_assert_process() |
| 栈溢出 | gs_os_safety_task_stack_overflow_hook | os_safety_task_stack_overflow_set_hook() | os_safety_task_stack_overflow_process() |
| 异常(HardFault) | gs_os_safety_exception_hook | os_safety_exception_set_hook() | os_safety_exception_process() |
三个指针各自独立、互不干扰,注册哪个,对应事件发生时才会调用哪个。栈溢出检测详见 4.6.2。
4.3 断言失败完整时序图
调用处 OS_ASSERT(ptr != OS_NULL)
│ condition 为假(ptr 为 NULL)
▼
os_kprintf("Assert failed. Condition(ptr != OS_NULL). [func][line]") ← 打印定位信息
│
▼
os_safety_assert_process()
│
├── OS_USING_SAFETY_MECHANISM 已启用?
│ ├── 是:有钩子吗?
│ │ ├── 是 → 调用 gs_os_safety_assert_hook()(用户接管)
│ │ └── 否 → os_irq_disable() + while(1) 停机
│ └── 否(宏形态)→ os_irq_disable() + while(1) 停机
└──(一般不再返回调用处)五、使用场景
5.1 一句话核心
断言适用于检查"本不该发生"的编程错误(空指针、非法枚举、状态不一致、内部不变量被破坏)——这些错误代表写代码的人错了,应当在开发期直接暴露。
5.2 断言 vs 错误码选型对比
| 场景 | 该用什么 | 为什么 |
|---|---|---|
| 检查函数入参为非法枚举值 | 断言 | 这是调用方 bug,属编程错误 |
| 内存分配可能失败 | 错误码 | 资源不足是运行时状况,应可恢复 |
| 队列发送超时 | 错误码 | 超时是预期内可处理事件 |
| 检查指针是否在 ISR 上下文被错误使用 | 断言 | 使用不当属编程错误 |
| 外设返回忙(BUSY) | 错误码 | 硬件状态可恢复,应重试/等待 |
通用规则:凡是"理论上不可能为假,若为假则说明有 bug"的 → 断言;凡是"环境/用户可能导致,且希望程序能继续处理"的 → 错误码。
5.3 结合源码的使用注意点
结合 os_assert.h / os_safety.h 的实际实现,使用时有以下几点约束:
- 不要在断言条件里写有副作用的操作:
OS_ASSERT(++i < 10)在禁用断言时++i根本不会执行,启用/禁用行为不一致,且调试时副作用会干扰现场。正确做法是先算好再断言:i++; OS_ASSERT(i < 10);。 OS_ASSERT不打印变量值,只打印条件原文:如果要在失败信息里看到具体值,用OS_ASSERT_EX并把变量作为变参传入。OS_ASSERT_EX的fmt是宏拼接,不是运行时格式串:格式占位符必须与后面传入的变参个数、类型严格对应,否则断言失败时os_kprintf会打印出乱码甚至引发二次异常。- 钩子全局唯一,会被覆盖:
os_safety_assert_set_hook()反复注册会覆盖前一个,确认只有一个地方注册断言钩子。 - 安全机制未启用时没有钩子可用:此时
os_safety_assert_process是"关中断 + 死循环"的宏,os_safety_assert_set_hook这个函数根本不存在(编译报错)。要使用钩子,必须开启OS_USING_SAFETY_MECHANISM。 - 发布版若想保留某些关键检查:可考虑用"错误码 + 日志"替代断言,而不是直接依赖
OS_ASSERT(它会在发布版被整体剔除)。
六、代码示例
6.1 基本使用 —— 参数合法性检查
以下示例演示用 OS_ASSERT 检查函数入参的合法性(这些属于"调用方 bug",适合断言):
#include <os_assert.h>
#define MAX_LED_COUNT 8
typedef enum
{
LED_OFF = 0,
LED_ON
} led_state_t;
void set_led_state(int led_id, led_state_t state)
{
/* 步骤 1:断言 led_id 在有效范围内 —— 若越界说明调用方 bug */
OS_ASSERT((led_id >= 0) && (led_id < MAX_LED_COUNT));
/* 步骤 2:断言 state 是合法枚举 —— 若非法说明枚举被破坏 */
OS_ASSERT((state == LED_ON) || (state == LED_OFF));
/* 步骤 3:正常业务逻辑(上述断言全部通过才会执行到这里) */
gpio_write(led_pins[led_id], state);
}实际效果:当 set_led_state(9, LED_ON) 被误调用时,串口会输出:
Assert failed. Condition((led_id >= 0) && (led_id < MAX_LED_COUNT)). [set_led_state][18]开发者在串口日志里立刻看到第 18 行条件不满足,从而定位到是某处把 led_id 传成了 9。
6.2 基本使用 —— 分配结果检查
#include <os_assert.h>
void *allocate_buffer(os_size_t size)
{
void *buf = os_malloc(size);
/* 步骤 1:分配失败 —— 注意:这里用断言仅示意"不可恢复场景" */
OS_ASSERT(buf != OS_NULL);
return buf;
}注意(结合 5.2 选型):真正的产品代码里,若内存分配失败是可恢复的,应返回
OS_NULL/错误码让调用方处理,而不是用断言直接停机。这里仅用于展示OS_ASSERT的写法。
6.3 带自定义消息 —— 提供变量值上下文
#include <os_assert.h>
void start_timer_with_check(os_timer_id timer)
{
/* 步骤 1:带自定义消息的断言,打印出具体的 timer 地址 */
OS_ASSERT_EX(timer != OS_NULL, "Timer is NULL, cannot start");
/* 步骤 2:带变参的断言,把不符合的 timer ID 打出来 */
OS_ASSERT_EX(os_timer_check_exist(timer),
"Timer ID 0x%08X does not exist", (os_ubase_t)timer);
/* 步骤 3:断言通过后正常启动 */
os_timer_start(timer);
}实际效果:若 timer 指向不存在的定时器,串口输出:
Assert failed. Timer ID 0x20001234 does not exist [start_timer_with_check][12]相比 OS_ASSERT(只能看到条件原文),这里直接把出问题的 0x20001234 打印出来,省去手动对照。
6.4 注册断言钩子(需开启 OS_USING_SAFETY_MECHANISM)
以下示例演示如何注册断言钩子,让断言失败时执行自定义处理(如记录错误状态):
#include <os_assert.h>
#include <os_safety.h>
/* 记录断言是否发生 */
static volatile os_bool_t g_assert_occurred = OS_FALSE;
static void on_assert_failure(void)
{
/* 自定义处理:标记现场,避免死循环,便于主循环检测后进入安全模式 */
g_assert_occurred = OS_TRUE;
/* 可在此保存关键寄存器、上传日志、点亮故障灯等 */
}
void system_init(void)
{
/* 步骤 1:注册断言钩子(在系统初始化早期调用) */
os_safety_assert_set_hook(on_assert_failure);
/* 步骤 2:完成其他初始化工作后启动调度器 */
/* 此后若某处断言失败,将回调 on_assert_failure 而不是死循环 */
os_start_system();
}实际效果:当系统某处触发 OS_ASSERT 时,由于已注册钩子,os_safety_assert_process() 会调用 on_assert_failure() 而不是死循环。主循环可以轮询 g_assert_occurred,一旦为真就进入安全兜底流程(如重启外设、降级运行、上报错误)。
编译前提:
on_assert_failure无参无返回值,签名与钩子类型void (*)(void)一致;且必须开启OS_USING_SAFETY_MECHANISM,否则os_safety_assert_set_hook不存在,代码无法编译。
七、使用建议
- 在开发阶段始终启用断言:
OS_USING_ASSERT默认在调试构建下开启,帮助开发过程中快速发现和定位 bug。 - 断言用于检查内部逻辑一致性:不要在断言中执行有副作用的操作(如
OS_ASSERT(++i < 10))。 - 断言条件应该简洁:复杂的条件表达式会降低断言失败时的可读性(因为
OS_ASSERT只打印条件原文)。 - 需要变量值时用
OS_ASSERT_EX:当失败后希望立刻看到具体数值,用带变参的版本,调试效率更高。 - 生产版本可考虑禁用断言:禁用后
OS_ASSERT/OS_ASSERT_EX不产生任何代码,有助于减小固件体积、消除判断开销。 - 不要用断言替代错误码:对于可恢复的运行时错误(资源不足、超时、外设忙),应使用错误码机制让调用方处理。
- 关键安全场景可用钩子接管:开启
OS_USING_SAFETY_MECHANISM后,用os_safety_assert_set_hook()把断言失败转为自定义安全兜底,而不是裸死循环。
📝 本节小结
核心要点回顾
- 断言用于检查编程错误("本不该发生"的条件),在开发期尽早暴露;错误码用于处理可恢复的运行时错误。
OS_ASSERT(condition):为假时打印条件原文 + 函数名 + 行号,再调用os_safety_assert_process();由#condition(字符串化)、__FUNCTION__、__LINE__提供定位信息。OS_ASSERT_EX(condition, fmt, ...):与OS_ASSERT类似,但用fmt + ##__VA_ARGS__打印自定义消息和变量值,定位更精准。- 两个宏都用
do { ... } while(0)封装,保证能像普通语句一样使用;OS_USING_ASSERT未开启时均展开为空语句(零开销)。 - 断言配置在
oneos_config.h中通过宏定义完成:#define OS_USING_ASSERT启用断言(默认工程已启用)、注释掉即禁用;#define OS_USING_SAFETY_MECHANISM启用安全机制与钩子(默认未启用)。 os_safety_assert_process():未开启安全机制时是"关中断 + 死循环"的宏;开启后是真实函数,已注册钩子则调钩子,未注册则停机。os_safety_assert_set_hook():注册断言失败回调,钩子全局唯一、多次注册会覆盖;必须开启OS_USING_SAFETY_MECHANISM才存在。
关键易错点速查
| 易错点 | 正确做法 |
|---|---|
在断言条件里写副作用操作(OS_ASSERT(++i<10)) | 先算好再断言,或避免副作用,否则禁用断言时行为不一致 |
| 用断言处理可恢复运行时错误(分配失败/超时) | 改用错误码 + 返回值检查,让调用方决定 |
想在断言里看到变量值却用 OS_ASSERT | 改用 OS_ASSERT_EX,把变量作为变参传入 |
OS_ASSERT_EX 的 fmt 占位符与变参不匹配 | 确保 %d/%s/%08X 等与后面变参数量、类型严格对应 |
想注册断言钩子但没开 OS_USING_SAFETY_MECHANISM | 先开启该配置,否则 os_safety_assert_set_hook 不存在(编译报错) |
| 多处注册断言钩子 | 记住钩子全局唯一,后注册覆盖先注册,只保留一个注册点 |
未开启断言却以为 OS_ASSERT 在干活 | 确认 OS_USING_ASSERT 已定义(调试构建默认开) |
下一步
- 4.6.2 堆栈溢出检测:了解 OneOS 如何检测任务栈溢出、栈水印原理与
OS_USING_OVERFLOW_CHECK配置,并与本节的os_safety_*钩子机制形成完整的错误处理闭环。