如果您是想尝试 Cinux并对一些驱动、前沿细节的实现感兴趣的朋友请移步到下面的仓库 https://github.com/Awesome-Embedded-Learning-Studio/Cinux如果您对手写一个现代 C 操作系统感兴趣的朋友请到这里 https://github.com/Awesome-Embedded-Learning-Studio/Cinux-Book或者直接访问文档站开始阅读Cinux如果上面的内容对您的学习和实际的开发哪怕有一丝帮助都是笔者极大的荣幸喜欢的话麻烦小小的赏一个 ⭐QAQ。自己的知识仍不精湛文章必然还有很多错误还请各位大佬批评斧正004 我们让内核跑起来了,可它只会往 debugcon 吐单字符(P、L、CPP),既不能打印一个数字,也没法把 BootInfo 里那张内存图好好 dump 出来。更要命的是——我们改一行代码,除了重跑看它崩不崩之外没有任何验证手段。这一章,我们要给内核装上真正的输出(串口 kprintf)和真正的测试(host 单测 QEMU 内核测试双轨)。从这以后,内核才算会说话,我们也才算有底气继续往上堆功能。这一章我们要点亮什么三件事,一件比一件实在。第一,写一个串口驱动:让内核能往 COM1(端口0x3F8)一个字节一个字节地输出文本,QEMU 的-serial stdio直接接住,终端里就能看到内核在说什么。第二,写一个kprintf——内核版的printf。给它一个格式串和几个参数,它能把%d、%x、%p这些格式化好地打到串口上(顺带还支持%b二进制,这是 Cinux 自己加的)。第三,搭一套双轨测试:一边是 host 上跑的单元测试(用宿主机 g 编,CTest 驱动),另一边是 QEMU 里跑的内核测试(和真内核同样的编译选项,跑完自动退出)。两边共享同一份纯算法代码。做完之后,make test会先在 host 上跑格式化的边界测试,再在 QEMU 里跑一遍内核,串口打印出 All tests completed 后干净退出。整个内核第一次有了改完能自动验证的能力。为什么现在需要它004 的内核有个很尴尬的处境:它已经能跑 C 了,可一旦想确认我的 BootInfo 读对了没、那张 E820 内存图有几条、各多大,你毫无办法。debugcon 只能吐单字符,想打个42都得自己拆位。没有结构化输出,后面的内存管理、进程这些东西根本没法调试——你连分配了多少都打印不出来。但更深的问题不是输出,是测试。到目前为止,我们验证内核的方式只有一种:重编、重跑、看它崩不崩。这种验证粗糙得可怕——一个边界 bug(比如format_decimal遇到INT64_MIN直接溢出)可能要等到很久以后某个偶然的场景才暴露。我们需要的,是把内核里那些和硬件无关的纯算法(比如把一个整数转成字符串)单独拎出来,在 host 上用正常的测试框架去磨它。这正好串起了这一章的核心设计。串口和 kprintf 解决怎么输出;而 kprintf 之所以能做到 host 可测,是因为我们把格式化算法(format.cpp)和输出到哪(serial还是debugcon)解耦了——那个算法是纯函数,既能编进内核,也能编进 host 测试。这就是这一章最值钱的一个架构决定。外部依据:OSDev 的 Serial Ports 页描述了 16550 UART 的寄存器布局与 LSR 状态位;PC 的 COM 端口标准(COM1 基址0x3F8)是 IBM PC 定下的约定。设计图先看串口这一层。一个 UART(NS16550A)挂在一段连续的 I/O 端口上,基址0x3F8,各寄存器按偏移区分:偏移 寄存器 读/写 用途 0 RBR/THR 读/写 收/发缓冲(同一个偏移,靠读/写区分) 1 IER 写 中断使能(我们关掉,轮询) 2 FCR 写 FIFO 控制 3 LCR 写 线路控制(8N1 0x03) 4 MCR 写 Modem 控制(RTSDTR 0x03) 5 LSR 读 线路状态(bit5可发, bit0可收)发一个字符的流程就是死循环查 LSR 的 bit5(发送保持寄存器空了没),空了就往 THR(offset 0)写字节。收字符类似,查 bit0。再看 kprintf 怎么把格式化和输出解耦。关键是模板加一个输出函数对象:vkprintf_implOutputFn(putc, format, args) ├─ 遍历 format 串,遇 % 走格式化分支 ├─ 数字/指针 → 调 format.cpp 的纯函数算出字符串 └─ 每个字符 → 调 putc(c) ← 输出门户在这里抽象掉 ├─ kprintf: putc serial.putc (打到 COM1) └─ kdebugf: putc debugcon_putc (打到 0xE9)OutputFn是个抽象:你给它一个怎么吐一个字符的函数,vkprintf_impl只管把格式化好的字符逐个喂给它。于是同一套格式化逻辑,串口、debugcon、甚至以后接帧缓冲,都只是换个putc。而双轨测试的纽带,就是中间那个format.cpp:format.cpp(纯算法:format_decimal/hex/binary) ┌────────────────────┴────────────────────┐ 编进内核 编进 host 测试 kprintf.cpp 调它 test_kprintf_format.cpp 调它 (走 serial 输出) (走 ASSERT_EQ 比对字符串) │ │ mini_kernel(QEMU 跑) test_host(CTest 跑)同一份format.cpp,两个编译上下文:内核里它被 kprintf 调用输出到串口;host 上它被单元测试调用、结果拿去和期望字符串比对。算法只有一份,内核和测试不会各写各的。代码路线1. 串口驱动:轮询式 UART最底层的 I/O 原语是两条内联汇编——读/写一个字节到指定端口(io.h):inline uint8_t inb(uint16_t port) { uint8_t value; __asm__ volatile(inb %1, %0 : a(value) : Nd(port)); return value; } inline void outb(uint16_t port, uint8_t value) { __asm__ volatile(outb %0, %1 : : a(value), Nd(port)); }x86 用in/out指令访问 I/O 端口空间(这和内存是两套地址空间,不能拿指针解引用去碰)。a把结果放进al,Nd让端口用立即数或dx传。serial.cpp 的Serial类把 UART 包起来。构造时先init配成 8N1:void Serial::init() { io::outb(base_port IER, 0x00); // 关中断:我们轮询,不要 UART 中断 io::outb(base_port LCR, 0x03); // 8 数据位、无校验、1 停止位 io::outb(base_port FCR, 0xC7); // 开 FIFO、清缓冲、14 字节阈值 io::outb(base_port MCR, 0x03); // RTS DTR }发字符是轮询的精髓——putc先死等 LSR 的 bit5(TX_READY)置位,表示发送保持寄存器空了,再把字节塞进 THR:void Serial::putc(char c) { while (!is_tx_ready()) __asm__ volatile(pause); // 自旋等 io::outb(base_port THR, static_castuint8_t(c)); }pause是给 CPU 的提示:我在自旋等,你稍微省点电、也别让乱序拖累。这里故意不开中断(IER0):这一章的串口是我说你听的单向输出通道,中断驱动的收发是后面 007 的事。puts还做一件小事:遇到\n先补一个\r——串口终端把\n当换行不回车,不补\r的话每行会逐行往右错位(经典阶梯状输出)。构造函数里还埋了一串 debugcon 面包屑:init前打\、init各步打[1 2 3 4、init后打。这些是给串口本身还没通时的调试串口的调试——万一串口初始化卡在某一步,debugcon 上能看到卡在哪个数字,比黑屏强。2. kprintf:把格式化和输出目的地解耦kprintf.cpp 的核心是一个模板函数,接受一个吐一个字符的函数对象:template typename OutputFn void vkprintf_impl(OutputFn putc, const char* format, va_list args) { char buffer[64]; while (*format) { if (*format %) { // 解析 % [0] [width] type,调 format_* 算出字符串,putc 逐字输出 } else { putc(*format); } } }然后两个对外接口,区别只在吐到哪:void kprintf(const char* fmt, ...) { // → 串口 va_list args; va_start(args, fmt); auto serial serial::get_initial_serial(); vkprintf_impl([](char c){ serial.putc(c); }, fmt, args); va_end(args); } void kdebugf(const char* fmt, ...) { // → debugcon 0xE9 va_list args; va_start(args, fmt); vkprintf_impl([](char c){ debugcon_putc(c); }, fmt, args); va_end(args); }为什么费这个劲搞模板,而不是直接写两个几乎一样的函数?因为格式化的逻辑(%d怎么转、宽度怎么补)很复杂且容易出错,我们绝对不想写两份。模板让格式化只存在一份,输出到哪作为一个参数注入。以后想加帧缓冲输出,也是再加一个kprintf变体、传个写像素的putc进去,格式化那几十行一个字不用动。支持的格式是 Cinux 自己挑的一套精简版:%%、%c、%s、%d、%u、%x/%X、%p(带0x前缀)、还有个%b(二进制,调试位掩码时很顺手),外加%N/%0N的宽度填充。它不是完整 printf——没有浮点、没有精度、没有%l长度修饰。够用就好,内核不需要printf(%f, 3.14)。3. format.cpp 为什么单独抽出来vkprintf_impl里真正把数字变成字符串的那几个函数——format_decimal、format_hex、format_binary——被放在单独的 format.cpp,还单独编成一个静态库(kprintf_private)。这看似多余,实则是整个可测性设计的命门。看看format_decimal里一个真实的坑就懂了:int format_decimal(int64_t value, char* buffer, int buffer_size) { bool is_neg value 0; if (is_neg) { if (value INT64_MIN) { // ★ 特判 // 直接拷 -9223372036854775808 ... } value -value; // 否则这里溢出! } ... }INT64_MIN是-9223372036854775808,它的绝对值比INT64_MAX大 1,-value会溢出成它自己(还是负数),后面整个转换就乱了。这种边界,你要是只在 QEMU 里跑、只在恰好打印INT64_MIN时才触发,可能永远发现不了。但因为format.cpp是纯函数(输入一个数、输出一串字符,不碰任何硬件、不调任何 I/O),我们完全可以把它编进 host 测试,直接ASSERT_EQ(format_decimal(INT64_MIN, ...), -9223372036854775808)——一条测试就把这个坑钉死。这就是纯逻辑单独抽库的全部回报:凡是和硬件无关的算法,都值得让它能脱离内核、在 host 上被磨。format_hex去前导零、format_binary跳过高位 0,这些也都是同类的纯逻辑,一并放进 host 测试覆盖。4. 双轨测试:host CTest QEMU 内核测试两条测试轨道,各管一摊。host 轨道(test_kprintf_format.cpp)测的是上一节那些纯函数。它直接#include mini/lib/private/format.h,把format.cpp跟测试一起用宿主机 g 编(加-DCINUX_HOST_TEST告诉代码现在跑在 host 上),用一套自研的轻量宏(TEST(...)、ASSERT_EQ、RUN_ALL_TESTS)断言。测试覆盖正负零、INT64_MIN/INT64_MAX、hex 全数字、binary 去前导零这些边界。跑法是 CTest:cmake --build build --target test_host。这条轨道快、能在 CI 里跑、不依赖 QEMU,是日常改格式化代码的第一道闸。QEMU 轨道(test)测的是真内核里能不能跑。它构造一个mini_kernel_test目标,和量产内核用完全一样的编译/链接选项,只是把main.cpp换成测试专用的main_test.cpp,再加上test_cpp_basic.cpp。后者用另一套自研宏(TEST_ASSERT、RUN_TEST)测 C 运行时本身:类的构造/析构计数对不对、虚函数派发对不对、全局对象构造函数跑没跑、多重继承的this调整对不对。这些必须在真内核里跑(它们依赖 vtable、.init_array、链接脚本),host 测不了。内核测试怎么知道跑完了并报告结果?靠 QEMU 的 isa-debug-exit 设备:测完往端口0xf4写一个双字,QEMU 就用那个值当退出码直接退出。于是 CI 能拿到退出码判断过没过,不用人去盯串口。两条轨道最后被make test串起来:先 host 后 kernel。这套双轨,本质上是按能不能脱离硬件把测试劈成两半——能脱离的(host)、必须真硬件的(kernel),各走最快的路。调试现场这一章值得记的真实坑,都和边界或工具链有关。第一个是上面讲的INT64_MIN。format_decimal不特判它,-value溢出,打印INT64_MIN会得到一串错的数字。这种 bug 在内核里极难触发(谁会专门打印INT64_MIN?),但 host 单测一条就抓出来。这也是为什么纯算法要能 host 测——它不是为了好看,是真抓 bug。第二个是串口的 Baud / 配置。init里设的LCR0x03(8N1)必须和 QEMU-serial默认的 115200 8N1 对上。配错一位,终端收到的就是满屏乱码——能看出有东西在发,但全是垃圾。判断方法:先发一个固定字符(比如A),终端看到A就说明线路配置对,看到乱码就是 LCR/Baud 问题。第三个是puts不转\r\n。漏了\n前putc(\r)的话,终端每换行不回首列,输出会呈阶梯状斜着走。这是串口输出的经典初见坑,一眼能认。第四个(也是 006 要再次踩的)是对象库与全局构造。format.cpp被单独编成静态库再链进内核,如果链接/构造函数表没处理好,全局对象的构造可能不被调用。这一章的linker.ld特意用KEEP(*(.init_array))防止.init_array被链接器当垃圾裁掉——裁掉了,_init_global_ctors遍历到的就是空,全局对象构造全跳过。test_cpp_basic里那个全局对象构造测试,就是专门盯这个的。验证这是本系列第一次有真正的自动化测试,验证也第一次分两条路。host 单测(快,CI 友好):cmake --build build --target test_host它会跑 CTest,测format_*的各种边界。全过的话终端会报告kprintf_format等 test 通过。QEMU 内核测试:cmake --build build --target run-kernel-test # 跑 mini_kernel_test,自动退出串口上会依次看到 kprintf Test (各种格式化样例)、 C Runtime Tests (四个[RUN]/[PASS]),最后 All tests completed ,然后 QEMU 靠0xf4退出。退出码 0 就是全过。一次跑全套:cmake --build build --target test # 先 host,后 kernel想看生产内核(非测试版)长什么样,make run会跑量产mini_kernel.bin,串口打印Cinux Mini Kernel v0.1.0、BootInfo、还有那张 E820 内存图的逐条 dump——这条 dump 正是下一章内存管理的原料。下一站内核现在会说话了:能往串口打格式化文本,改完代码还有双轨测试兜底。可你看量产内核 dump 出的那张 E820 内存图——它只是打印出来了,内核根本还没用它。operator new调一下还是原地hlt,因为我们既没有物理内存管理,也没有堆。
Cinux会说话了:串口、kprintf 与双轨测试
如果您是想尝试 Cinux并对一些驱动、前沿细节的实现感兴趣的朋友请移步到下面的仓库 https://github.com/Awesome-Embedded-Learning-Studio/Cinux如果您对手写一个现代 C 操作系统感兴趣的朋友请到这里 https://github.com/Awesome-Embedded-Learning-Studio/Cinux-Book或者直接访问文档站开始阅读Cinux如果上面的内容对您的学习和实际的开发哪怕有一丝帮助都是笔者极大的荣幸喜欢的话麻烦小小的赏一个 ⭐QAQ。自己的知识仍不精湛文章必然还有很多错误还请各位大佬批评斧正004 我们让内核跑起来了,可它只会往 debugcon 吐单字符(P、L、CPP),既不能打印一个数字,也没法把 BootInfo 里那张内存图好好 dump 出来。更要命的是——我们改一行代码,除了重跑看它崩不崩之外没有任何验证手段。这一章,我们要给内核装上真正的输出(串口 kprintf)和真正的测试(host 单测 QEMU 内核测试双轨)。从这以后,内核才算会说话,我们也才算有底气继续往上堆功能。这一章我们要点亮什么三件事,一件比一件实在。第一,写一个串口驱动:让内核能往 COM1(端口0x3F8)一个字节一个字节地输出文本,QEMU 的-serial stdio直接接住,终端里就能看到内核在说什么。第二,写一个kprintf——内核版的printf。给它一个格式串和几个参数,它能把%d、%x、%p这些格式化好地打到串口上(顺带还支持%b二进制,这是 Cinux 自己加的)。第三,搭一套双轨测试:一边是 host 上跑的单元测试(用宿主机 g 编,CTest 驱动),另一边是 QEMU 里跑的内核测试(和真内核同样的编译选项,跑完自动退出)。两边共享同一份纯算法代码。做完之后,make test会先在 host 上跑格式化的边界测试,再在 QEMU 里跑一遍内核,串口打印出 All tests completed 后干净退出。整个内核第一次有了改完能自动验证的能力。为什么现在需要它004 的内核有个很尴尬的处境:它已经能跑 C 了,可一旦想确认我的 BootInfo 读对了没、那张 E820 内存图有几条、各多大,你毫无办法。debugcon 只能吐单字符,想打个42都得自己拆位。没有结构化输出,后面的内存管理、进程这些东西根本没法调试——你连分配了多少都打印不出来。但更深的问题不是输出,是测试。到目前为止,我们验证内核的方式只有一种:重编、重跑、看它崩不崩。这种验证粗糙得可怕——一个边界 bug(比如format_decimal遇到INT64_MIN直接溢出)可能要等到很久以后某个偶然的场景才暴露。我们需要的,是把内核里那些和硬件无关的纯算法(比如把一个整数转成字符串)单独拎出来,在 host 上用正常的测试框架去磨它。这正好串起了这一章的核心设计。串口和 kprintf 解决怎么输出;而 kprintf 之所以能做到 host 可测,是因为我们把格式化算法(format.cpp)和输出到哪(serial还是debugcon)解耦了——那个算法是纯函数,既能编进内核,也能编进 host 测试。这就是这一章最值钱的一个架构决定。外部依据:OSDev 的 Serial Ports 页描述了 16550 UART 的寄存器布局与 LSR 状态位;PC 的 COM 端口标准(COM1 基址0x3F8)是 IBM PC 定下的约定。设计图先看串口这一层。一个 UART(NS16550A)挂在一段连续的 I/O 端口上,基址0x3F8,各寄存器按偏移区分:偏移 寄存器 读/写 用途 0 RBR/THR 读/写 收/发缓冲(同一个偏移,靠读/写区分) 1 IER 写 中断使能(我们关掉,轮询) 2 FCR 写 FIFO 控制 3 LCR 写 线路控制(8N1 0x03) 4 MCR 写 Modem 控制(RTSDTR 0x03) 5 LSR 读 线路状态(bit5可发, bit0可收)发一个字符的流程就是死循环查 LSR 的 bit5(发送保持寄存器空了没),空了就往 THR(offset 0)写字节。收字符类似,查 bit0。再看 kprintf 怎么把格式化和输出解耦。关键是模板加一个输出函数对象:vkprintf_implOutputFn(putc, format, args) ├─ 遍历 format 串,遇 % 走格式化分支 ├─ 数字/指针 → 调 format.cpp 的纯函数算出字符串 └─ 每个字符 → 调 putc(c) ← 输出门户在这里抽象掉 ├─ kprintf: putc serial.putc (打到 COM1) └─ kdebugf: putc debugcon_putc (打到 0xE9)OutputFn是个抽象:你给它一个怎么吐一个字符的函数,vkprintf_impl只管把格式化好的字符逐个喂给它。于是同一套格式化逻辑,串口、debugcon、甚至以后接帧缓冲,都只是换个putc。而双轨测试的纽带,就是中间那个format.cpp:format.cpp(纯算法:format_decimal/hex/binary) ┌────────────────────┴────────────────────┐ 编进内核 编进 host 测试 kprintf.cpp 调它 test_kprintf_format.cpp 调它 (走 serial 输出) (走 ASSERT_EQ 比对字符串) │ │ mini_kernel(QEMU 跑) test_host(CTest 跑)同一份format.cpp,两个编译上下文:内核里它被 kprintf 调用输出到串口;host 上它被单元测试调用、结果拿去和期望字符串比对。算法只有一份,内核和测试不会各写各的。代码路线1. 串口驱动:轮询式 UART最底层的 I/O 原语是两条内联汇编——读/写一个字节到指定端口(io.h):inline uint8_t inb(uint16_t port) { uint8_t value; __asm__ volatile(inb %1, %0 : a(value) : Nd(port)); return value; } inline void outb(uint16_t port, uint8_t value) { __asm__ volatile(outb %0, %1 : : a(value), Nd(port)); }x86 用in/out指令访问 I/O 端口空间(这和内存是两套地址空间,不能拿指针解引用去碰)。a把结果放进al,Nd让端口用立即数或dx传。serial.cpp 的Serial类把 UART 包起来。构造时先init配成 8N1:void Serial::init() { io::outb(base_port IER, 0x00); // 关中断:我们轮询,不要 UART 中断 io::outb(base_port LCR, 0x03); // 8 数据位、无校验、1 停止位 io::outb(base_port FCR, 0xC7); // 开 FIFO、清缓冲、14 字节阈值 io::outb(base_port MCR, 0x03); // RTS DTR }发字符是轮询的精髓——putc先死等 LSR 的 bit5(TX_READY)置位,表示发送保持寄存器空了,再把字节塞进 THR:void Serial::putc(char c) { while (!is_tx_ready()) __asm__ volatile(pause); // 自旋等 io::outb(base_port THR, static_castuint8_t(c)); }pause是给 CPU 的提示:我在自旋等,你稍微省点电、也别让乱序拖累。这里故意不开中断(IER0):这一章的串口是我说你听的单向输出通道,中断驱动的收发是后面 007 的事。puts还做一件小事:遇到\n先补一个\r——串口终端把\n当换行不回车,不补\r的话每行会逐行往右错位(经典阶梯状输出)。构造函数里还埋了一串 debugcon 面包屑:init前打\、init各步打[1 2 3 4、init后打。这些是给串口本身还没通时的调试串口的调试——万一串口初始化卡在某一步,debugcon 上能看到卡在哪个数字,比黑屏强。2. kprintf:把格式化和输出目的地解耦kprintf.cpp 的核心是一个模板函数,接受一个吐一个字符的函数对象:template typename OutputFn void vkprintf_impl(OutputFn putc, const char* format, va_list args) { char buffer[64]; while (*format) { if (*format %) { // 解析 % [0] [width] type,调 format_* 算出字符串,putc 逐字输出 } else { putc(*format); } } }然后两个对外接口,区别只在吐到哪:void kprintf(const char* fmt, ...) { // → 串口 va_list args; va_start(args, fmt); auto serial serial::get_initial_serial(); vkprintf_impl([](char c){ serial.putc(c); }, fmt, args); va_end(args); } void kdebugf(const char* fmt, ...) { // → debugcon 0xE9 va_list args; va_start(args, fmt); vkprintf_impl([](char c){ debugcon_putc(c); }, fmt, args); va_end(args); }为什么费这个劲搞模板,而不是直接写两个几乎一样的函数?因为格式化的逻辑(%d怎么转、宽度怎么补)很复杂且容易出错,我们绝对不想写两份。模板让格式化只存在一份,输出到哪作为一个参数注入。以后想加帧缓冲输出,也是再加一个kprintf变体、传个写像素的putc进去,格式化那几十行一个字不用动。支持的格式是 Cinux 自己挑的一套精简版:%%、%c、%s、%d、%u、%x/%X、%p(带0x前缀)、还有个%b(二进制,调试位掩码时很顺手),外加%N/%0N的宽度填充。它不是完整 printf——没有浮点、没有精度、没有%l长度修饰。够用就好,内核不需要printf(%f, 3.14)。3. format.cpp 为什么单独抽出来vkprintf_impl里真正把数字变成字符串的那几个函数——format_decimal、format_hex、format_binary——被放在单独的 format.cpp,还单独编成一个静态库(kprintf_private)。这看似多余,实则是整个可测性设计的命门。看看format_decimal里一个真实的坑就懂了:int format_decimal(int64_t value, char* buffer, int buffer_size) { bool is_neg value 0; if (is_neg) { if (value INT64_MIN) { // ★ 特判 // 直接拷 -9223372036854775808 ... } value -value; // 否则这里溢出! } ... }INT64_MIN是-9223372036854775808,它的绝对值比INT64_MAX大 1,-value会溢出成它自己(还是负数),后面整个转换就乱了。这种边界,你要是只在 QEMU 里跑、只在恰好打印INT64_MIN时才触发,可能永远发现不了。但因为format.cpp是纯函数(输入一个数、输出一串字符,不碰任何硬件、不调任何 I/O),我们完全可以把它编进 host 测试,直接ASSERT_EQ(format_decimal(INT64_MIN, ...), -9223372036854775808)——一条测试就把这个坑钉死。这就是纯逻辑单独抽库的全部回报:凡是和硬件无关的算法,都值得让它能脱离内核、在 host 上被磨。format_hex去前导零、format_binary跳过高位 0,这些也都是同类的纯逻辑,一并放进 host 测试覆盖。4. 双轨测试:host CTest QEMU 内核测试两条测试轨道,各管一摊。host 轨道(test_kprintf_format.cpp)测的是上一节那些纯函数。它直接#include mini/lib/private/format.h,把format.cpp跟测试一起用宿主机 g 编(加-DCINUX_HOST_TEST告诉代码现在跑在 host 上),用一套自研的轻量宏(TEST(...)、ASSERT_EQ、RUN_ALL_TESTS)断言。测试覆盖正负零、INT64_MIN/INT64_MAX、hex 全数字、binary 去前导零这些边界。跑法是 CTest:cmake --build build --target test_host。这条轨道快、能在 CI 里跑、不依赖 QEMU,是日常改格式化代码的第一道闸。QEMU 轨道(test)测的是真内核里能不能跑。它构造一个mini_kernel_test目标,和量产内核用完全一样的编译/链接选项,只是把main.cpp换成测试专用的main_test.cpp,再加上test_cpp_basic.cpp。后者用另一套自研宏(TEST_ASSERT、RUN_TEST)测 C 运行时本身:类的构造/析构计数对不对、虚函数派发对不对、全局对象构造函数跑没跑、多重继承的this调整对不对。这些必须在真内核里跑(它们依赖 vtable、.init_array、链接脚本),host 测不了。内核测试怎么知道跑完了并报告结果?靠 QEMU 的 isa-debug-exit 设备:测完往端口0xf4写一个双字,QEMU 就用那个值当退出码直接退出。于是 CI 能拿到退出码判断过没过,不用人去盯串口。两条轨道最后被make test串起来:先 host 后 kernel。这套双轨,本质上是按能不能脱离硬件把测试劈成两半——能脱离的(host)、必须真硬件的(kernel),各走最快的路。调试现场这一章值得记的真实坑,都和边界或工具链有关。第一个是上面讲的INT64_MIN。format_decimal不特判它,-value溢出,打印INT64_MIN会得到一串错的数字。这种 bug 在内核里极难触发(谁会专门打印INT64_MIN?),但 host 单测一条就抓出来。这也是为什么纯算法要能 host 测——它不是为了好看,是真抓 bug。第二个是串口的 Baud / 配置。init里设的LCR0x03(8N1)必须和 QEMU-serial默认的 115200 8N1 对上。配错一位,终端收到的就是满屏乱码——能看出有东西在发,但全是垃圾。判断方法:先发一个固定字符(比如A),终端看到A就说明线路配置对,看到乱码就是 LCR/Baud 问题。第三个是puts不转\r\n。漏了\n前putc(\r)的话,终端每换行不回首列,输出会呈阶梯状斜着走。这是串口输出的经典初见坑,一眼能认。第四个(也是 006 要再次踩的)是对象库与全局构造。format.cpp被单独编成静态库再链进内核,如果链接/构造函数表没处理好,全局对象的构造可能不被调用。这一章的linker.ld特意用KEEP(*(.init_array))防止.init_array被链接器当垃圾裁掉——裁掉了,_init_global_ctors遍历到的就是空,全局对象构造全跳过。test_cpp_basic里那个全局对象构造测试,就是专门盯这个的。验证这是本系列第一次有真正的自动化测试,验证也第一次分两条路。host 单测(快,CI 友好):cmake --build build --target test_host它会跑 CTest,测format_*的各种边界。全过的话终端会报告kprintf_format等 test 通过。QEMU 内核测试:cmake --build build --target run-kernel-test # 跑 mini_kernel_test,自动退出串口上会依次看到 kprintf Test (各种格式化样例)、 C Runtime Tests (四个[RUN]/[PASS]),最后 All tests completed ,然后 QEMU 靠0xf4退出。退出码 0 就是全过。一次跑全套:cmake --build build --target test # 先 host,后 kernel想看生产内核(非测试版)长什么样,make run会跑量产mini_kernel.bin,串口打印Cinux Mini Kernel v0.1.0、BootInfo、还有那张 E820 内存图的逐条 dump——这条 dump 正是下一章内存管理的原料。下一站内核现在会说话了:能往串口打格式化文本,改完代码还有双轨测试兜底。可你看量产内核 dump 出的那张 E820 内存图——它只是打印出来了,内核根本还没用它。operator new调一下还是原地hlt,因为我们既没有物理内存管理,也没有堆。