断言:一个名字,两份实现
格式引擎验完了,咱们造第三件武器:断言。断言想说的话,咱们一句就能摆平:这个条件必须永远成立。条件成立呢,大家装作无事发生。条件不成立呢,咱们当场把事闹大。
最省事的写法, 咱们谁都会:包含标准库的 <cassert>、写一句 assert(x != nullptr)、一行完事。咱们眼下人在正常电脑上,这么写当然编得过、跑得欢、一切正常。
链接器递回来的名字
咱们把时间拨回第一遍。当年笔者做这个内核,断言借的就是 <cassert> 的壳,图省事嘛:头文件一包含、该停的地方轻飘飘写一句 assert(...),正常电脑上的编译、运行全都正常,绿绿的测试一路给笔者涨着信心。
坏就坏在咱们把代码编进内核的时候。链接器呢,冷笑一声、递回来一个从没实现过的名字:__assert_fail。明明编译过了,凭什么它就不认呢?咱们顺着名字往回找,事情一下子就清楚了。
咱们在独立环境里照样编得过 <cassert>,咱们在七个头,五面旗里说过呢:"标准不承诺提供"不等于"编译器禁止包含"。可 assert 其实是个宏。咱们一展开它、失败分支要调的就是 __assert_fail,人家是住在标准库运行时里的函数。正常电脑上有整条运行时替咱们伺候它,咱们平时根本察觉不到它的存在。可到了裸机上呢,咱们没有谁会提供它,链接这一关呢,当场把咱们卡死。
当年拦住笔者的正是这个没人提供的名字, 虽然吧,笔者最后手工把符号补上,才把它救活了。咱们现在回头看,当时的处境其实就两条路。当年笔者选的就是它:当场把缺的名字补上。另一条呢,咱们要把借来的壳整个换掉:全项目搜索替换、每一处 assert 改名字、改语义。咱们只要改漏一处,埋下的真的是一颗雷:漏掉的位置还是 assert 宏、一展开、失败分支要调的还是没人提供的 __assert_fail,咱们下一次链接内核、当场又被卡死。
那要是断言从第一天就是咱们自己的呢?真到咱们把代码编进内核,咱们要做的事就剩一件:给内核世界补一份实现、所有调用点原样留着。所以咱们重走一遍,断言就不借了。
第三个参数,一个字没写
说来 Check 呢,您已经见它上过班了。Result 一节里 value() 的第一行、实验 D 的那声报告,用的都是它。
自己的断言长什么样呢?咱们拢共就写两个函数、都住在 cinux::base::safety 名下:平时打交道的是 Check,断言失败后真正接手的钩子,咱们叫它 AssertionFailed。
[[noreturn]] void AssertionFailed(const char* k_message,
std::source_location loc = std::source_location::current());
inline void Check(bool condition_result, const char* k_message,
std::source_location loc = std::source_location::current()) {
if (condition_result) {
return;
}
AssertionFailed(k_message, loc);
}Check 的本体您一眼就能看完:条件成立、return 走人。轮到条件不成立呢,咱们把消息和位置交给钩子、一整句就是个 inline 转发。声明顶上的双方括号 [[noreturn]],咱们把它押后,一会儿再还给您。咱们要找的讲头几乎全藏在参数表里。
咱们来认一认 std::source_location:标准库的"源位置"设施。打开一看呢:文件名、行号、列号、函数名四样。它的默认值 std::source_location::current() 呢,读出来就是一句人话:"把此刻的源位置装进来"。咱们要琢磨的就是"此刻"到底是哪一刻。
咱们拿一行真的排一遍。您在 AllocPage 里写下:
Check(ptr != nullptr, "分配器返回了空指针");失败真发生的时候呢,咱们要报告指向哪一行?当然该指着您刚刚落笔的位置。可这里特别容易想歪:把 current() 挪进 Check 的函数体、每回进来取一次,看起来还挺直白呢。您也别急着点头:这么取,拿到的永远是 Check 自己躺在 assert.hpp 里的行号。咱们整个工程不管哪儿出错、看到的都只会是同一句:"错在 assert.hpp 第六十几行"。这情报咱们谁也用不上,咱们扭头就把这个写法丢掉。
默认参数把求值搬到了咱们写下调用的地方。您回头看 AllocPage 里的调用:第三个参数,您一个字都没写、编译器就地补上默认值。current() 呢,就在咱们落笔的位置当场求值:文件、行号、AllocPage 的函数名全抓齐,咱们装进 loc、跟消息一起送进 Check。
Result 一节实验 D 的报告您还记得吧:文件、行号、函数名、一样不少,全是默认参数给咱们带上门的。咱们调用的人两手空空,真的什么都不用多写。
咱们回头再看声明,跟您说定一件事:它是函数、不是宏。咱们要是传错了东西,编译器当场就把咱们拦下。咱们还能拿调试器对付它:断点照打、单步照踩。咱们也找不到宏那一套往作用域里漏名字的毛病。笔者现在就把规范立下:产品代码、非极端必要、不用宏、断言自己头一个照办。
咱们把押后的 [[noreturn]] 拿回来交代:它告诉编译器,AssertionFailed 一去就不回来了。咱们把 Check 的控制流看得清清楚楚:条件不成立、走进去就是终点。编译器也不必替咱们追问"失败处理返回之后呢"。
只欠一个名字
咱们把代码读顺了,可 std::source_location 它自己过不过得了独立环境呢?咱们不猜——直接编一遍。
咱们编一个翻译单元:只包含 cinux/assert.hpp、只调一次 Check、按内核姿势编译。四面旗咱们在七个头,五面旗里用过、咱们原样搬来。新添的呢:只有一面 -fno-stack-protector、把栈保护也关上。咱们只编不链、回头用 nm -u 数它欠了谁。您在仓库根目录跑:
printf '#include <cinux/assert.hpp>\nint Probe() { cinux::base::safety::Check(true, "probe"); return 0; }\n' \
| g++ -x c++ -std=c++23 -ffreestanding -fno-exceptions -fno-rtti -nostdlib -fno-stack-protector \
-Ibase/include -c -o /tmp/probe_assert.o -
nm -u /tmp/probe_assert.o | c++filt您一敲回车,欠条就出来了:
U cinux::base::safety::AssertionFailed(char const*, std::source_location)您数完整份目标文件欠着的名字、就 AssertionFailed 一个。咱们再看 std::source_location:连一个未定义符号都没看到,编译器在编译期就把它要的东西全处理完了。
您还记得吗:七个头,五面旗里咱们只往 vector 装了三个整数、欠条拉出八行。咱们这边一行就完事了。
您再瞄一眼欠条,上面的名字咱们认得:正是设计里说好"按世界各交一份"的钩子,整张欠条咱们再找不出第二个债主。
咱们单独交代一下新添的 -fno-stack-protector:它摘掉栈保护、顺带摘掉对 __stack_chk_fail 的引用。这个名字您在Result 一节实验 C 里已经照过面。埋金丝雀这件事呢,咱们得看触发条件:函数里有本地数组、或局部变量被取了地址,咱们手头这个 GCC 默认就埋。咱们刚才的翻译单元两样都不沾,所以旗子咱们关不关、nm 的输出都一字不差。您要是不信呢,您把旗子摘了再跑一遍,您看到的还是同一行,笔者刚替您试过。咱们还是让探针带着旗子跑:防的就是哪天换一个复杂些的翻译单元、栈保护真生效、输出里凭空多出 __stack_chk_fail 一行,咱们"只欠一个名字"的局面就破了。
链接期说了算
Result 一节的实验 C 呢,咱们停在了"只编不链"。咱们把"链"补上:请链接器把目标文件拼成一个完整程序,哪一份实现被拼进来呢,咱们说的选择就发生在这时候。
咱们给 AssertionFailed 在头文件里只留一份声明、两个世界看到的都是同一份。正常电脑世界链接的一份呢,咱们放在 test/framework/assert_host.cpp:
void AssertionFailed(const char* k_message, std::source_location loc) {
std::println(stderr, "[assert] {}:{}:{} in {}: {}",
loc.file_name(), loc.line(), loc.column(),
loc.function_name(), k_message);
std::abort();
}请您留心一下,这里的 std::println 可不在允许清单上。您也别误会:允许清单管的只是将来要进内核的代码。正常电脑世界的实现咱们可不让它管(要不然咱们没法写代码了QAQ)。
头文件只许诺一个稳定入口,最终链接哪个实现由目标决定。两条路径共享调用点,却在失败之后走向完全不同的出口:
您看函数体,咱们一眼看穿:往不缓冲的标准错误流打一行、再 std::abort() 终止。为什么死前非走不缓冲的路不可呢?咱们在Result 一节实验 B 里亲眼验过:程序一终止,攒在缓冲里的输出跟着一起蒸发。至于内核世界呢,咱们将来给它的实现,是把失败接进内核的崩溃报告,咱们到后面站再讲。到时候咱们换的只是函数体、调用点一个不动。
结构上咱们还白赚一道守门:真把两个世界的实现误链进同一个程序呢,咱们撞上的就是一个名字、两份定义。咱们试它一下:把正常电脑这边的实现编两遍、一起交给链接器,当场就是一声 multiple definition 呢,咱们听见喊出来的名字正是 AssertionFailed,咱们想悄悄混过去都不行。咱们读起来也落个干净:随便打开哪一份源文件、看到的永远只有一个世界。
那咱们为什么不用更"常见"的 #ifdef 平台分支呢?咱们要是用 #ifdef 写平台分支,咱们就得把分支塞进头文件。塞完头文件呢,咱们等于把两个世界塞进了同一个文件。咱们改一行要想两遍、漏看一个分支就漏改一个世界。更憋屈的呢,是分支到底选没选对、咱们得等程序跑起来才见分晓。咱们用文件级分离、把选择提前到链接期。真选错了呢,咱们听到的就是刚才的 multiple definition,构建当场就停了,咱们不用等程序跑起来才见分晓。
咱们在七个头,五面旗里交代过呢,咱们让构建目标带上了编译开关。轮到断言呢,换成链接结构接手了。下一节咱们收尾,造咱们的最后一件武器:测试框架。