第 05 篇已经完成从保护模式到 kernel_main 的交接,栈、参数、已初始化数据和 BSS 都有检查。内核还需要把检查结果输出成可保存的日志,并在无法继续时留下调用位置。

第 06 篇增加 COM1 串口、VGA 文本控制台和 panic。正常镜像会演示换行、滚屏与格式化;独立故障镜像触发断言,同时输出文件名、行号和表达式,随后循环执行 hlt。这些功能仍运行在单核、未开启 IRQ、没有分页的早期内核里。

输出模块在自检之前初始化

启动器仍按第 05 篇的契约装载内核。stage2 完成 E820、A20 和保护模式切换,把内核复制到 0x100000;入口汇编清 BSS、建立栈,再把 BootInfo 指针传给 C。新增的控制台没有改变这条装载路径。

kernel_main 的第一步是 console_init(),之后才检查 BootInfo、data、BSS 和最小运行时函数。这个顺序让自检失败能输出具体原因。若先验证 BSS,输出模块却依赖尚未初始化的状态,坏 BSS 实验就可能连错误信息也无法显示。

因此,console_init() 显式把 rowcolumn 归零,把 serial_available 置为 1,不只依赖入口清零。随后配置 UART,隐藏 BIOS 留下的硬件光标,并把文本显存清成白字黑底的空格。

1
2
3
4
5
entry:清 BSS、建立栈、传入 BootInfo
→ console_init:串口配置、VGA 清屏
→ BootInfo / data / BSS / runtime 自检
→ 换行、滚屏、格式化演示
→ 正常返回,或断言失败进入 panic

格式化后的字符都经过 console_putc():先尝试写串口,再更新 VGA。串口失效后,VGA 仍沿原路径处理同一个字符。两端共享文本内容,但控制字符的显示行为不完全相同。

这里没有通用设备发现。COM1 使用固定端口,VGA 使用固定文本地址;BootInfo 的检查也不代表任意指针都可以安全解引用。当前实验依赖第 05 篇已经验证的入口契约。

COM1:端口含义取决于 DLAB

本实验使用传统 PC 的 COM1 基址 0x3f8。UART 通过端口 I/O 访问,outb 写寄存器,inb 读取状态;它与后面直接写内存的 VGA 是两条不同路径。

初始化代码中的端口写入如下,数值与附件里的 kernel/console.c 一致:

1
2
3
4
5
6
7
outb(0x3f9, 0);
outb(0x3fb, 0x80);
outb(0x3f8, 1);
outb(0x3f9, 0);
outb(0x3fb, 3);
outb(0x3fa, 0xc7);
outb(0x3fc, 3);

0x3fb 是 Line Control Register,简称 LCR。它的 bit 7 是 DLAB,决定基址偏移 0、1 当前访问普通寄存器还是波特率除数。DLAB 为 0 时,写 0x3f8 是发送字符,0x3f9 是中断使能寄存器;DLAB 为 1 时,两处分别是除数的低、高字节。TI PC16550D 数据手册,DigiKey 托管 的寄存器表列出了这种复用关系。

本系列启动器已经清除 DLAB,代码据此先关闭 UART 中断,再写 0x80 打开 DLAB,设置除数为 1。按传统 PC 的 1.8432 MHz UART 输入时钟,波特率等于 1843200 / (16 × 1),即 115200。这个结果依赖时钟,不能把除数 1 当成所有 UART 上通用的波特率定义。

随后写 LCR 为 0x03:低两位选择 8 个数据位,停止位选择 1 位,关闭奇偶校验,合起来就是 8N1。这次写入也清掉 DLAB,后续向 0x3f8 写字节才会走发送路径。最后的 0xc7 启用并清理 FIFO,0x03 设置 DTR、RTS;当前驱动不使用 UART 中断。

THRE 与有界轮询

字符不能不检查状态就连续写入。0x3fd 是 Line Status Register,bit 5 的 0x20 对应 THRE。在 FIFO 模式下,它表示发送 FIFO 为空,可以提交新的数据;它不保证最后一个字节已全部移出线路,后者还涉及 TEMT 和发送移位寄存器。TI PC16550D 数据手册 区分了 LSR 的这两个状态位。

附件中的发送函数保留了等待上限:

1
2
3
4
5
6
7
8
9
10
11
12
static void serial_putc(char c)
{
if (!serial_available) return;
for (unsigned retry = 0; retry < 100000; ++retry) {
uint8_t status = inb(0x3fd);
if (status != 0xff && (status & 0x20)) {
outb(0x3f8, (uint8_t)c);
return;
}
}
serial_available = 0;
}

除了测试 THRE,代码还拒绝 0xff,避免把这种全 1 读值直接当成发送就绪。到达上限后,本次字符不再发送,后续字符也跳过串口;控制台继续写 VGA,不会每个字符都重新空转 100000 次。

100000 是状态读取次数,不是毫秒期限。CPU 执行速度、模拟器调度和端口访问成本都会影响耗时。当前没有定时器可供计时,这个限制只证明本函数不会无限等待 UART,不能给出固定时长保证。

VGA:字符、属性与位置

当前 VGA 文本缓冲区位于物理地址 0xb8000,大小固定为 80 列、25 行。每格占两个字节,低字节保存字符,高字节保存属性。代码把缓冲区声明为 volatile uint16_t *,写入设备可见的内存,不把它当成可省略写入的普通数组。

1
vga[row * 80 + column++] = 0x0f00 | (uint8_t)c;

属性 0x0f 在当前文本模式下表示亮白字、黑背景;清屏值 0x0f20 则是同样属性的空格。普通字符写入后推进列号,到第 80 列便归零列号并进入下一行。屏幕位置由软件变量维护,初始化时通过 0x3d40x3d5 隐藏了 BIOS 光标,没有实现硬件光标跟随。

换行 \n 把列号归零并增加行号,回车 \r 只把列号归零。退格 \b 先回到前一格,再写入空格;位于一行开头时可以退到上一行末尾。在屏幕原点退格不会产生负索引,代码会清除当前格。

1
2
3
4
5
输入:abc\rR\bB\n
abc 写入 a、b、c
Rbc 回车后以 R 覆盖第 0 列
bc 退格擦除第 0 列
Bbc 写入 B,再换行

串口把 \n 转为 CRLF,回车与退格字节则原样发送。串口终端怎样解释 BS 由终端决定,单独发送 BS 不等于执行 VGA 的“回退并擦除”。Bbc 是本实现 VGA 缓冲区的确定结果,不能据此断言所有串口终端都显示相同过程。

行号达到 25 时,控制台把后 24 行向上复制一行,清空最后一行,再把行号设回 24。源位置总在目标位置后面,因此从低地址向高地址逐格复制不会覆盖尚未读取的内容。这里的复制量固定为 1920 格,不需要额外分配缓冲区。

最小 kprintf 的类型边界

内核没有链接目标 libc。kprintf 使用编译器提供的可变参数支持,自行解析格式串,把最终字符交给控制台。它仅支持 %s%c%d%u%x%p%%,没有宽度、精度、长度修饰或浮点转换。

%s 输出 C 字符串,空指针显示 (null)%c 按可变参数的默认提升规则读取 int%u%x 读取 unsigned,分别按十进制和小写十六进制输出。%p 读取 void *,在当前 32 位目标下输出带 0x 前缀、不补零的地址,因此空指针显示 0x0

十进制和十六进制共用 number():反复求余,把低位数字存入局部数组,最后倒序输出。循环采用 do ... while,零也会产生一位 0。这条路径不需要堆分配,也不需要把整数转换委托给标准库。

有符号十进制必须处理 INT_MIN。32 位 int 的最小值为 -2147483648,相反数不能用同类型表示;直接对这个有符号值取负会发生溢出。源码先转为无符号数,再做无符号减法:

1
2
3
4
5
6
7
int value = va_arg(args, int);
uint32_t magnitude = (uint32_t)value;
if (value < 0) {
console_putc('-');
magnitude = 0u - magnitude;
}
number(magnitude, 10);

在当前 32 位类型契约下,无符号运算按模 2^32 进行,能得到幅值 2147483648。这也是正常演示选择 INT_MINUINT_MAX 的原因:普通小整数通过,并不能证明边界值正确。

未知转换按原字符输出,不消费参数;末尾孤立的 % 也原样保留。%q 不会报错,但 %08x 同样不会得到补零效果。调用方必须遵守已支持的语法,不能把它当作标准 printf 的完整替代品。

空字符串指针的特殊处理也只覆盖空指针。格式串、非空字符串指针必须有效,传入参数的类型必须与转换匹配;这一阶段没有用户内存验证,不能拿 %s 安全地探测任意地址。

panic 输出断言位置并保留栈

console.h 的断言宏只在条件失败时调用 panic,把编译器提供的文件名、行号和字符串化表达式传进去:

1
#define KASSERT(condition) do { if (!(condition)) panic(__FILE__, __LINE__, #condition); } while (0)

panic 声明为 _Noreturn。函数先执行 cli,再调用 kprintf 输出诊断,最后进入名为 panic_halt 的汇编循环。CLI 在这里清除 IF,屏蔽可屏蔽的外部中断;它不屏蔽 NMI,也不阻止 CPU 异常。Intel 指令手册 对 CLI 的作用范围有明确说明。

1
2
3
4
5
6
panic(file, line, reason)
cli
输出 PANIC、文件、行号、原因
panic_halt:
hlt
jmp panic_halt

HLT 暂停当前逻辑处理器的执行,NMI、SMI 等事件仍可能让处理器离开 halt 状态;后面的跳转使正常内核流程不会继续执行。它不是整台硬件不可恢复的永久关闭操作。Intel 指令手册 的 HLT 条目列出了恢复执行的条件。

这条路径保留了当前调用栈,方便 GDB 查看 panic 的参数和调用者。不过它没有在 CPU 异常发生瞬间统一保存通用寄存器、错误码或异常返回位置。到达 panic 时已经执行过 C 调用和打印,不能把此时寄存器当作任意故障的原始快照。

当前实现也没有可重入保护或多核输出锁。单核启动阶段尚未开放 IRQ,这个范围内可以直接顺序输出;异常入口、并发和更复杂的故障处理需要各自建立契约。

下载并运行冻结版本

下载第 06 篇源码包,解压得到 os-day-06 目录。源码对应提交 6c70cce3618ee01a7a0226db0f2643e812a4bea9,附件可独立运行,不需要检出随后增加其他功能的分支。

宿主复用第 01 篇的 Podman Linux 环境与工具链镜像。在解压目录外执行:

1
2
cd os-day-06/examples/build-an-os
podman run --rm -it -v "$PWD:/work" -w /work localhost/build-an-os:day01 sh

进入容器后执行:

1
2
make check-console
make run

check-console 会构建并检查正常输出、边界行为、UART 不就绪和独立 panic 镜像。make run 打开 curses 文本显示,配置为 -serial none,因此它用于看屏幕;串口证据由检查脚本写入 build/console-normal-serial.txt 等文件。不要把交互窗口里没有串口日志误判为串口驱动失效。

已有 localhost/build-an-os:day01 时不用重建。首次缺少镜像才按第 01 篇流程运行 podman build -t localhost/build-an-os:day01 .。完整累计回归使用 make check,生成正常截图使用 make screenshot;所有产物仍放在忽略的 build/ 下。

检查共用容器内 GDB 的 1234 端口,运行期间不要同时启动另一个调试实例。复现步骤和原始证据文件名也保存在附件的 docs/day06-evidence.md 中。

正常输出与屏幕检查

冻结版本的实测环境是 QEMU 7.2.22、pc-i440fx-7.2、TCG、qemu32、单核、64 MiB 和 SeaBIOS。内核二进制为 2804 字节,占 6 个扇区;这些地址和大小属于该版本,修改代码后应重新读取构建结果。

正常 COM1 日志保留早期的 D04 PM OK - BootInfo v1KERNEL_MAIN OK。28 行滚屏演示后,两端都有以下格式化结果:

1
2
3
4
FMT ok Z -2147483648 4294967295 deadbeef 0x1234 %
ZERO 0 0 0 0x0 NULL (null)
unknown=%q trailing=%
CONSOLE OK

第 06 篇正常控制台:滚屏与格式化结果

检查脚本在第 80 个 W 输出后读取真实 VGA 内存,确认整行正好 80 个 W,软件位置为 row=2,column=0。滚屏和诊断输出全部完成后,屏幕首行精确为 scroll 9,仍含 scroll 27,最后一行空白。

控制字符演示对应的行精确为 Bbc,全部 2000 个字符格的属性字节均为 0x0f。这些检查覆盖实际内存布局和位置变化,单凭一张看起来正确的截图无法验证所有格子的属性或第 80 列边界。

独立断言镜像与实际 HLT

make check-console 另外构建 build/mbr-panic.img。它使用单独的内核对象、ELF 和镜像头,启用 TEST_PANIC,执行 KASSERT(2 + 2 == 5);正常镜像不包含这个主动失败路径。两端实测输出为:

1
PANIC kernel/main.c:60: 2 + 2 == 5

第 06 篇主动断言失败后的控制台

GDB 在 panic_halt 指令前停止时,能读取以下现场:

1
2
3
4
5
6
STOP EIP=0x100992 ESP=0x104b48 EFLAGS=0x6 CR0=0x11
#0 panic (file="kernel/main.c", line=60, reason="2 + 2 == 5")
#1 kernel_main (info=0x5000)
#2 _start
0x100992: hlt
0x100993: jmp 0x100992

这说明控制流抵达了停机指令,调用栈仍可展开。断点发生在 HLT 之前,仅凭这次暂停还不能证明处理器已经执行 HLT。

另一次运行不设断点,让镜像实际执行后,再读取 QEMU monitor 的寄存器状态:

1
2
EIP=00100993 EFL=00000006 CPL=0 II=0 A20=1 SMM=0 HLT=1
CR0=00000011 CR2=00000000 CR3=00000000 CR4=00000000

此时 EIP 指向 HLT 后面的跳转,HLT=1 表示 QEMU 中的 CPU 已进入 halt。EFLAGS 的 IF 为 0,CR0 表明保护模式开启、分页关闭。断点记录证明停机入口和调用栈,后一次记录证明实际 halt,两者回答的是不同问题。

串口失效时还剩下什么

检查脚本还有一条软件故障注入路径:在正常镜像进入 C 时,把本次运行内存中的 inb 指令改为返回 0,使 LSR 持续不就绪。磁盘镜像和源码不变,这不是实际拔除串口设备的实验。

注入后,内核消耗 100000 次轮询,将 serial_available 置为 0,仍完成同样的 VGA 检查并到达正常停机地址;串口没有 CONSOLE OK。这验证了新控制台不会因 UART 持续不就绪而无限等待,也验证了停用串口后 VGA 路径继续执行。

证明范围只覆盖本篇 C 控制台的等待逻辑。启动器里此前的串口例程没有因此获得相同保证;若故障发生得更早,仍需按对应阶段的代码定位。

练习与导航

  1. 将普通字符演示改为 81 个字符,检查换行后第 0 列的内容。
  2. 给格式化演示加入 -1INT_MAX,比较有符号幅值转换的两条路径。
  3. 将断言条件改为成立,再执行 make check-console,观察故障镜像验收为何拒绝没有 panic 的结果;恢复条件后复验。

上一篇:05 - 跳进 C。下一篇:07 - 异常入口