七个头,五面旗
武器嘛,咱们下一节就正式开造。不过动工以前笔者有几句话得交代给大伙:句句都是逆耳的忠言,可咱们非赶在前头说清不可。
咱们把处境交代在最前头,因为后面的限制全是从这里来的。注定要过两种日子的,正是咱们手头的这批 C++。今天的它们,在您眼前的正常电脑(也就是咱们开发它的电脑)上被测试翻来覆去地折磨,标准库也是随叫随到的。可到了明天呢,同一批代码就要被咱们收进内核里去,而那边呢,连一个标准库运行时都是没有的。要提防的恰恰是随手占来的便宜:哪些东西是这边真有的,哪些只是运行时暂时替咱们扛着的?咱们不亲手跑上一遍,您我都分不清。本节咱们就挨个数一遍。
所以嘛,接下来咱们一行武器代码都不写,只把约束摆出来给大伙看。约束也不靠嘴说:能当场验的,咱们当场验,已经落成文件的,就把文件翻开给您看。
拿 vector 做个实验
就从标准库说起吧。有的朋友可能不太理解:标准库咱们用不了,异常咱们也用不了,您说代码还能怎么写?咱们不着急,直接做个十秒钟的小实验,它是比什么大道理都清楚的。
程序呢,咱们要它越小越好:只包含一个 <vector>,咱们直接往里头塞三个整数,就算收工了。咱们连文件都省得建,抄起趁手的 printf 管道,把咱们的源码直接喂给 g++,您自个享受去:
printf '#include <vector>\nint Probe() { std::vector<int> v{1, 2, 3}; return v.back(); }\n' \
| g++ -x c++ -std=c++23 -ffreestanding -fno-exceptions -fno-rtti -nostdlib -c -o /tmp/probe_vec.o -这一长串可能会吓到您,等笔者安抚好您受惊的小心灵,还请您聚精会神地看看咱们递给编译器的 flags,说不定您在前置卷就已经见过它们了!它们的真身分别是 -ffreestanding、-fno-exceptions、-fno-rtti、-nostdlib,四面的旗子到齐。笔者可没骗您,这的确是咱们将来把代码编进内核时所采纳的姿势。至于它们各管什么?您要是出门以前跟笔者说一声"私密马赛,我忘了",那么您这边请:工具链课里给咱们一面一面地讲过。要是您还是不理解,就请您大胆当一回 CV 工程师,复制过来就完事了!
您一敲回车,妈呀,报错糊了您一脸。
咱们实际看到的报错比下面这句还要热闹得多:等头文件被拒绝之后,里面的模板就找不到各自的依赖了,跟在后面又凑热闹地报了一整轮。咱们看了个热闹,可根子上的其实就一句:
bits/requires_hosted.h: error: #error "This header is not available in freestanding mode."就是说 std::vector 这个咱们天天打交道的家伙,到了独立环境(freestanding)里,人家是根本不接待的。还有一层咱们得说准:这句 #error 是新一些的 GCC 自己加上的检查,《标准》里可没有这样的规定。它拦的是那些离了操作系统就活不了的头文件,咱们一包含它们,它当场就喊停了。
那咱们换个玩法,请您把 -ffreestanding 摘掉、其余三面原样留着、再编一回,顺便瞧瞧编出来的目标文件带了什么出门。工具都是咱们的老熟人:nm 负责翻看目标文件,-u 让它只列出未定义的符号,也就是本文件用到、却不归本文件定义的那些名字,c++filt 再把 C++ 编码过的名字翻回人话:
printf '#include <vector>\nint Probe() { std::vector<int> v{1, 2, 3}; return v.back(); }\n' \
| g++ -x c++ -std=c++23 -fno-exceptions -fno-rtti -nostdlib -c -o /tmp/probe_vec.o -
nm -u /tmp/probe_vec.o | c++filt欸嘿,这回咱们编过去了!您也别急着欢呼,欠着的名字都排在下面呢:
U memcpy
U __stack_chk_fail
U operator delete(void*, unsigned long)
U operator new(unsigned long)
U std::__throw_bad_alloc()
U std::__throw_length_error(char const*)
U std::__glibcxx_assert_fail(char const*, int, char const*, char const*)
U std::__throw_bad_array_new_length()一个只往 vector 里放了三个整数的程序,欠下的名字居然有整整八个。咱们照着单子从上往下过一遍。
打头的是 memcpy,咱们的老熟人,来自 <cstring> 的它待会儿还有单独的一段,咱们把话记在这儿。
排第二的是 __stack_chk_fail,归它管的是栈保护。开了栈保护的编译器,会在一部分函数的栈帧里放进一个校验值、函数返回以前再核对一遍,对不上的时候它就把程序终止掉。请您注意,它抓的是已经发生的越界写入:有人把栈帧写花了,它都是事后才发现、当场叫停的。至于越界本身嘛,它是拦不住的,咱们也从没听谁说过它能拦。
咱们再来看 operator new 和 operator delete,它们是堆分配的本体。vector 的本事您是知道的:装不下了就去找内存,找的就是它们俩。而它们背后站着的,其实是标准库运行时和操作系统。咱们到了内核那边,情形可是倒过来的:内存分配器恰恰是咱们要亲手造出来的头一批家当,它们还没出生呢,这俩名字喊破喉咙也是不会有人应的。
咱们还得把 std::__throw_bad_alloc 这一类函数单独拎出来说和它捎上的 __glibcxx_assert_fail,它们是标准库自家的报障机器。怪不怪?咱们明明用 -fno-exceptions 把异常关掉了,它们怎么还挂在这儿?因为落笔的时候,库代码并不知道您将来关不关异常,这些名字呢,它可是照常引用着的。真被调用到的那天,咱们现在这套姿势能给它的结局也只有终止。可欠符号是硬邦邦的事实:链接器才不管您打算怎么死,它唯一过问的就是名字有没有人认。
欠的名字过完了,咱们退一步再看。正常电脑这边呢,八个名字由标准库的运行时一并承包,所以您平时用 vector 才会从来没见过它们。内核世界里呢,可是没有谁承包的。再回头看开头那声 #error——它只是 GCC 一家安的门卫,咱们换一个头、换一家编译器,它可就未必拦得住了。真正拦在路上的,是八个没人认的名字。
家底就七个头
实验做完了,欠的名字也数完了,咱们把问题掉个头:您在独立环境里写 C++,手头到底还能用上什么呢?
笔者把答案定成一张允许清单。清单管的地界叫 base,也就是正常电脑和内核两个世界共享的那层公共代码。清单本身呢,咱们一共只放七个头、一个都不多:
<cstddef>:size_t、ptrdiff_t,量尺寸用的。<cstdint>:uint8_t、uintptr_t这些定宽整数。<cstdarg>:变参,格式引擎吃...参数全靠它。<type_traits>:编译期给类型问诊,std::is_nothrow_move_constructible_v就住这儿。<utility>:std::move、std::to_underlying这些小工具。<cstring>:memcpy 这一类函数。<source_location>:文件名、行号、函数名,断言报位置全靠它。
您把七个头摆在一块儿,一眼就能挑出它们的共同点:要么是安分的纯类型和常量,要么是编译期就干完活的小工具。那些要靠运行时伺候的大家伙,一个都不在名单上:会找堆的容器、iostream、异常、RTTI。它们各自欠的名字长什么样,您刚才在 nm 的输出里见过样本了。不过名单里有一个头,欠的名字也没躲开,现在咱们说它。
咱们看 <cstring> 里头,memcpy、memset、memcmp、memmove、strlen 这几个是货真价实的函数:用上了,咱们的目标文件里就欠这几个名字,欠法和刚才 vector 欠 operator new 的一模一样。GCC 的文档把话说得直白:独立环境必须自己提供的只有四个:memcpy、memmove、memset、memcmp。strlen 不在四人的班底里,可它同样是真函数嘛,欠下的照样算欠。这几位呢,等进内核那天要咱们亲手写,眼下咱们只把它们记成待办——格式引擎的实现今天就在调 strlen,算 %s 的长度用的就是它。
清单上还另有一格正式的例外栏:<new>。例外栏里住的是 placement new,在一块现成的内存上就地构造对象的写法,它的声明只住在这个头里,别处可是没有的。Result 的判别联合装复杂对象全靠它,所以咱们为它单开一格。用法呢,等咱们走到 Result 的时候细讲,这里咱们只把它记下来。
跟清单配套的,还有几条边界咱们得画清楚。笔者让整个 base 不做堆分配,会分配内存的 new、malloc 一概不碰,而 placement new 只是在已有内存上构造、不向系统要新内存,咱们不算它破戒。单个的头文件也不超过一千行,大了咱们就拆,省得您在里头迷路。
另有一条界线咱们得掰清楚,不掰的话,您越往后越拧:库的规范和语言标准,是彻头彻尾的两码事。语言这边呢,咱们把它全开,C++23 有什么用什么——本节的代码里它们全在岗:指定初始化器、条件 noexcept、[[nodiscard]],咱们一个都没省。库这边呢,只认手里的清单。咱们最容易在这里栽跟头:std::source_location 和 std::to_underlying,听着像是语言的新特性,其实都是库设施。咱们分辨它们,不靠名字的新旧,靠的是它们住哪个头——<source_location>、<utility> 都在清单上,当然就进得来,跟语言的新旧没有关系。
不过呢,freestanding 拿走的其实是承诺,而不是文件本身。《标准》说的是"这些设施我不承诺独立环境里有",可没说"编译器禁止您包含"。您猜哪些头直接编不过、哪些照样编得过?标准可是留了白的,全看各家编译器自己的意思,您撞见的那声 #error,只是 GCC 一家的检查。
落到正常电脑这边呢,眼下咱们就没有机器替清单把关了:测试代码里偷用一个 <vector>,照样编得过的,照样跑得通的。
您要偷懒,理由当然永远现成:就一个
vector而已,进内核前再换嘛。至于代价,那就是等半年后真要换的时候,满树的std::,咱们就得一个一个找回来,再一个一个地还回去。
把地图摊开
清单划出了 base 这块地界,可它住在硬盘的哪个角落,咱们手头得有张图。本节过后,咱们仓库里会多出两个顶层的目录:base 和 test。往后几节咱们会一遍一遍报里面的路径,咱们这就挨家挨户认一遍。
咱们头一户就认 base/include/cinux/。assert.hpp、format.hpp、result.hpp 三个头的声明都住在这儿,正常电脑和内核两个世界看到的都是同一份声明。这也是 base 最要紧的一层意思:接口只写一遍,另抄一份的口子咱们不留。
咱们再往 base/src/ 看一眼,里面放的是实现,编出来的库叫 cinux_base。正常电脑上的测试今天链的就是它,将来咱们进了内核,用的还是它,格式引擎的内脏呢,两个世界共用的一副。
可能有朋友吐槽:为什么叫做 base? 笔者是做跟Chromium内核有关系的东西,所以的话被里面的base组件深刻影响,base这个词我也很喜欢,咱们的地基嘛!

咱们再认一户 test/。咱们把测试框架本体连同断言的宿主实现安顿在 test/framework/,首批的三件用例安顿在 test/unit/。断言又是不一样的,它的实现按世界各备一组,内核那一组将来靠的是我们的"链接期选择"接进构建。
什么叫链接期选择呢:简单的说就是在程序链接的时候,才选择具体的实现。这个机制可以使用构建系统构建不同的目标的时候提供具体的实现。专业的说就是提供独立的Translate Unit,翻译单元。
根上还有三份老住户呢,本章后头咱们都会走到:根 CMakeLists 立的是版本检查,.clang-tidy 装的是命名规范,而 cmake/compilers/warning_standard.cmake,是五面警告旗的家。
我去!这个警告会下崽
另一句话更短:编译器提醒的事,咱们一律当成已经出事。落到开关上呢,咱们一共上五个:
-Wall -Wextra -Wpedantic -Wshadow -Werror前面的三个呢,是您多半眼熟的老三样:-Wall 收常见问题,-Wextra 再给咱们补一批,-Wpedantic 专管编译器自作主张的方言扩展。真正让这套开关长牙的是 -Werror:警告一冒头,编译当场就判没过——压根没有所谓"过关但带提醒"的中间档。
所以,牢记前辈言:编译器的警告等级开到最高,而且不允许放过任何的错误。是重中之重的!
至于咱们为什么要这么凶——警告这东西是会下崽的。第一天您放过一条"应该没事",它没事呢,它的亲戚也就都成了"应该没事"。一个月过后呢,一百条"其实没事"里头,真危险的一条正安安静静埋着,咱们自己也不会再多看它一眼。然后内核爆炸了,起因是数字悄悄的转换到了完全相反的意思,或者是堆栈访问的时候编译器向您的大吼大叫您压根也不理睬。哦吼,炸了。
五面编译器的 flags 里,-Wshadow 值得咱们单独讲一讲。它拦的是变量遮蔽:内层作用域声明了一个和外层同名的变量,外层那个就被悄悄地盖住。您以为自己在改 A,程序里改的其实是 B。这样的错,编译器默认一声不吭地放行,全靠这个开关替咱们喊一嗓子。
还有一类呢,咱们在测试代码里最常见到的就是它:循环变量是 int,拿去比 size_t 的长度。一边是有符号的,一边是无符号的,咱们让两家直接比大小,-Wsign-compare 会提醒咱们(它住在 -Wall 里),咱们又开着 -Werror,提醒一来呢,那可就是编译失败了。对付它的习惯就一条:动手比较以前,显式地定宽——该转 size_t 就转过去,要按 int 比呢,就明白地写出来,不让编译器替咱们悄悄做主。
开关能拦得住的前提是每一次编译它都在场。咱们要是把开关散着写、每个目录抄一份,那可就靠不住了:咱们谁少抄一行、谁改松一档,没有任何机器会替你报错,咱们抄着抄着开关就漂了。所以咱们不抄,把整套开关封装成一个可链接的构建目标。文件的原文住在 cmake/compilers/warning_standard.cmake 里,整个文件也就一屏的量。根 CMakeLists 里的一行 include() 把它接进来:
# Just the CMake Warning Requests!
add_library(cinux_warnings INTERFACE)
# Export the Cinux Warning Flags
target_compile_options(cinux_warnings INTERFACE
-Wall
-Wextra
-Wpedantic
-Wshadow
-Werror
)INTERFACE 目标下,我们一行代码都是不编译的,它携带的只有编译选项:谁链接它,谁的编译命令就自动带上全套。将来哪个文件真的需要豁免了,咱们也只精准地豁免单条。起码咱们这样可以做到记录当初为什么我们这样决策了!
轮到咱们消费它呢,一行代码就够用:咱们在 test/CMakeLists.txt 里每登记一个新测试,写的都是这么一句:
target_link_libraries(test_${name} PRIVATE cinux_base cinux_warnings)咱们看 cinux_base 自己也链着它,您翻 base/CMakeLists.txt,同样是一行 PRIVATE 的链接。所以在这棵树上呢,咱们没有任何一条编译命令能绕开这套开关。PRIVATE 的意思是,选项只进本目标自己的编译动作,往下传的事儿是没有的。
开关到底进没进咱们的编译命令?不用信笔者,您自己数。根 CMakeLists 里的 set(CMAKE_EXPORT_COMPILE_COMMANDS ON),会给咱们在 build/ 下生成一份 compile_commands.json,也就是咱们的编译数据库:哪个文件、用什么命令编的,一条一条都给咱们记成 JSON。咱们的编辑器和代码分析器都认它,照它给的跳转和补全,跟编译器用的是同一个脑子,不会跟它各说各话的。咱们拿它数一数 -Werror:
grep -c -- -Werror build/compile_commands.json这个数要怎么读呢,咱们得交代清楚:它数的是上面地图里那批文件全就位之后的树,不是您此刻的。等咱们把文件凑齐、configure 也跑过了,上面的命令就会数到 11——每个测试目标咱们各编三份:自己的一份 test_*.cpp,加上 test/framework/ 的 framework.cpp 和 assert_host.cpp。三个目标给咱们凑成九份,再加 cinux_base 自己的两份实现,一条都不会差的。本节咱们一行代码都不写,您现在去跑,多半连 build/ 的影子都还没有。等后面几节咱们把武器一件件造齐,它就该长到 11 了。往后哪个新目录忘了链呢,这个数当场就少给你看,您一眼就能看见它现形。
咱们还在同一份根 CMakeLists 里,让 CMake 专门检查编译器的版本:本站咱们要 GCC 14 往上。最硬的需求来自正常电脑这边——测试框架和断言的宿主实现,打印咱们全用 std::println,而它呢,GCC 14 才有的设施。更早的更不行:C++23 的支持本身就残缺,GCC 12 那一代的编译器,本站要用的设施凑不齐,报出来的错还不着边际,炸的位置离病根隔着十万八千里。所以咱们把检查放在配置阶段,CMake 一跑咱们就能见分晓,不满足的话直接退出,不让人沉到编译半路、看不懂的错误里:
if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
if(CMAKE_CXX_COMPILER_VERSION VERSION_LESS 14)
message(FATAL_ERROR
"Cinux requires GCC >= 14 (C++23 freestanding + strict warnings); "
"found GCC ${CMAKE_CXX_COMPILER_VERSION}.")
endif()
endif()现在咱们的构建只专门检查 GCC 一种编译器,版本低了咱们就拦截。您要是用 clang,这个检查不会拦您,可那不是承诺——书里的命令实录都跑在 GCC 16.2.1 上,clang 跟不跟得上呢,风险您自担。
名字各有长相
最后咱们说名字。话呢不长,直接给大伙亮出来:
- 自由函数,大驼峰:
VformatToBuf,格式引擎那一节的主角。 - 成员方法,小写下划线:
Sink身上的put。 - 类型,大驼峰:
KernelError、BufferSink。 - 枚举值,
k打头:KernelError::kNotFound。 - 私有成员,结尾一条下划线:
is_ok_。 - 宏,全大写:
TEST、ASSERT_EQ,测试框架里的宏正是这样。
这套分法最实在的红利,恰恰落在教程里:正文的代码块没有语法高亮,咱们看一个名字是自由函数还是成员方法,肉眼认的全是形态。VformatToBuf 一露面呢,您就知道它在外面,谁都是能调的。put 一露面呢,它长在类型的身上,咱们得拿对象去调。
把关的是根目录的 .clang-tidy 配置文件,clang-tidy 就按它说的执法。咱们信它不信文档,因为文档是会漂的:代码改了,文档忘了跟上的话,谁也帮不上咱们的忙。配置是跟着代码一起躺在仓库里的,它写的是什么,工具查的就是什么。test/ 目录下另有小小的一份覆盖,给咱们的测试代码豁免了一条,咱们这里不去管它。
它对名字的计较是真计较,咱们拿 const char* 就能领教一回。这个名字的确切读法是"指向常量的指针":指向的字符不能通过它改,而指针自己呢,却是个想指向哪就指向哪的变量,而不是常量。clang-tidy 呢,判断一个名字"是不是常量"的时候,押的只有最右边那个 const。const char* 少了它的时候,工具就按变量的命名告警。冷不丁吃一串莫名其妙的名字警告,您别急着怪工具发疯,看看您手里的 const char*:想让它真是常量呢,您在右边再补一个,写成 const char* const 的样子,咱们从右往左地读——指针自己是 const、指向的字符也是 const。
开关和命名都有机器替咱们守着,一套住在构建的目标里,另一套的家安在 .clang-tidy。只有允许清单呢,眼下还只是躺在纸面上的一页字,守它的,是评审的人眼。
下一节咱们开工,造咱们的第一件武器:Result。