前 24 篇一直把 0xB8000 当作屏幕:一个字符占两个字节,内核写入字符码和颜色,VGA 文本模式负责把它显示出来。窗口系统需要另一种输出契约。内核必须能描述一个像素的位置,并且知道这段内存的宽、高、步长和颜色格式。

本篇增加独立的图形启动镜像。第二阶段引导器在实模式设置 BIOS Mode 13h,回读确认后发布 BootInfo v2;内核验证 0xA0000、320×200、每行 320 字节和 8 位索引色,再通过同一个有界像素函数绘制整帧、矩形与边框。历史文本镜像继续使用 BootInfo v1,所以已有 Shell、管道和早期启动实验不需要假装理解新的字段语义。

专项实验同时读取串口、GDB 符号、物理页位图和 64,001 个显存字节,并保存 QEMU 截图。强制失败镜像则证明模式设置未通过时不会进入图形内核。

BIOS 调用必须留在实模式

Mode 13h 是一个适合教学的最小图形入口:320×200 个像素,每个像素占一个字节,一帧共 64,000 字节。标准 VGA 图形窗口从物理地址 0xA0000 开始。像素 (x, y) 的地址为:

1
0xA0000 + y * 320 + x

每个字节保存调色板索引。值 14 和 15 在 BIOS 默认调色板中通常显示为黄色和白色,但它们不是压缩后的 RGB 值。改变 DAC 调色板后,同一份显存字节可以呈现不同颜色。

设置模式依赖 INT 10h,而当前内核进入保护模式后没有虚拟 8086 模式或 BIOS thunk。调用位置因此固定在 stage2 尚未设置 CR0.PE 的阶段:

1
2
3
4
5
6
7
8
mov ax, 0x0013
int 0x10

mov ah, 0x0f
int 0x10
and al, 0x7f
cmp al, 0x13
jne video_error

AH=00h 请求设置模式,AH=0Fh 回读当前模式。SeaBIOS 的设模式路径不会把内部失败转换成一套通用返回码,所以本项目只能在固定环境中检查最终模式是否为 0x13。这项回读能保护当前启动契约,不能扩张为“所有 BIOS 视频失败都可被完整诊断”。

显示参数是一份带版本的启动契约

Day 04 定义的 BootInfo v1 恰好是 40 字节,末尾字段描述文本屏幕:

偏移 v1 语义 文本镜像的值
28 video 0xB8000
32 columns 80
34 rows 25
36 pitch 160 字节
38 mode 3
39 cell bytes 2

若只把 video 改成 0xA0000,内核仍会把 320 解释成列数、把最后一个字节解释成字符单元大小。内存布局没有变化,并不表示数据含义没有变化。

图形镜像发布 BootInfo v2,复用相同偏移和总长度,但字段按版本解释:

偏移 v2 语义 图形镜像的值
28 framebuffer base 0xA0000
32 width 320 像素
34 height 200 像素
36 pitch 320 字节
38 BIOS mode 0x13
39 bits per pixel 8

stage2 负责生产这份数据,C 结构负责消费。两边保持同一组偏移,并用 _Static_assert(sizeof(struct bootinfo) == 40) 锁住总长度。图形内核还会逐项核对固定值:

1
2
3
4
5
KASSERT(info && info->magic == 0x31495442 && info->version == 2 &&
info->size == sizeof(*info));
KASSERT(info->video == 0xa0000 && info->display_width == 320 &&
info->display_height == 200 && info->display_pitch == 320 &&
info->display_mode == 0x13 && info->display_depth == 8);

文本镜像仍要求 version == 1mbr.img、Shell 镜像和管道镜像没有被切换到图形模式,旧回归继续观察 0xB8000 与原来的保护模式标记。新增能力通过 mbr-graphics.img 单独进入,避免一次字段升级重写前 24 篇的测试前提。

可迁移的规则是:共享结构的字节数相同,不代表新旧消费者可以互换。字段语义变化时,生产者、消费者和版本检查必须一起更新;无法理解新版本的一方应在使用数据前拒绝它。

所有像素先经过坐标边界

当前像素原语只接受无符号坐标,先检查宽高,再计算地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
static int put_pixel(const struct bootinfo *info, unsigned x, unsigned y,
uint8_t color)
{
if (x >= info->display_width || y >= info->display_height) {
++graphics_rejected;
return 0;
}
volatile uint8_t *frame =
(volatile uint8_t *)(uintptr_t)info->video;
frame[y * info->display_pitch + x] = color;
++graphics_pixels;
return 1;
}

合法坐标是半开区间 [0, 320) × [0, 200)。右下角 (319, 199) 对应偏移 63,999;(320, 0)(0, 200) 都必须在地址计算前返回失败。

实验在偏移 64,000 保存一个哨兵,完成所有绘制后再次读取。两次越界调用使 rejected 变成 2,哨兵仍为 0。GDB 还直接检查四个边角、内部黄色像素和中心白色像素。截图证明宿主显示面看到了画面,显存字节与哨兵则证明画面来自预期地址范围;两种证据各自覆盖不同问题。

矩形函数目前假定调用者传入的矩形完整落在屏幕内。Day 26 会加入有符号坐标与裁剪,并在计算 x + width 之前处理整数溢出。本篇没有把只适用于已知常量矩形的循环包装成通用绘图库。

显存不能成为普通物理页

Mode 13h 的显存占据 0xA0000..0xAFFFF 的前 64,000 字节。当前页表恒等映射前 64 MiB,因此内核可以直接用物理地址访问它;物理页分配器则只把 1 MiB 以上的可用页加入候选集合。

这两个条件缺一不可。只有映射而没有保留,分配器可能把显存交给用户页或内核栈;只有保留而没有映射,C 代码会在第一次像素写入时触发页故障。

图形内核先执行:

1
KASSERT(!page_free(info->video));

专项 GDB 检查进一步读取 eligible 位图,确认第 0xA0 个物理页没有 eligible 位。它不依赖一次 page_free 返回值来替代内部状态观察。随后 64,000 字节绘制与哈希又证明该范围在当前页表中确实可读写。

设备内存保护可以概括为“映射与所有权分开验证”:页表回答 CPU 能否访问,分配器回答普通内存使用者能否取得。遇到 framebuffer、MMIO 或固件保留区时,两张表都要检查。

图形模式后串口承担诊断

原来的 console_init() 同时初始化 VGA 文本和 COM1。切换到 Mode 13h 后,继续写 0xB8000 不能作为可见文本证据,图形内核的诊断全部以串口为准:

1
2
3
4
D25 VIDEO MODE 13h
D04 PM OK - BootInfo v2
D25 GRAPHICS base=0xa0000 320x200 pitch=320 bpp=8 pixels=185120 rejected=2 hash=4130a99d
GRAPHICS OK framebuffer reserved and bounds checked

最终哈希 0x4130a99d 覆盖整帧 64,000 字节。pixels=185120 是绘制操作次数,同一位置可能被背景、矩形和边框多次覆盖,不能把它当作不同像素的数量。

强制失败镜像把回读结果改为失败值。stage2 在保护模式之前恢复 mode 3,经文本显存和 COM1 输出:

1
D25 VIDEO FAIL

日志中不存在保护模式成功标记,也不存在内核图形统计。失败路径先恢复可读诊断面,再停机;它没有带着一份无效 BootInfo 继续启动。

截图与内存证据需要同时保留

QEMU 的 screendump 得到一张 640×400 PNG:

Mode 13h 色块、矩形与边界

客体分辨率仍是 320×200。QEMU 对传统模式的导出结果进行了 2 倍缩放,检查脚本因此分别验证 BootInfo 中的 320×200和 PNG 头中的 640×400,没有用宿主图片尺寸反推客体 pitch。

完整串口记录GDB 显存与分配位图现场强制失败串口记录累计回归记录保存了本次运行结果。专项检查之外,保护模式、管道、Shell、文件描述符、FAT16、ATA、进程、系统调用、ELF、内存、容量边界和真实 IRQ1 键盘检查均再次退出 0。

当前边界

当前图形输出固定为 BIOS Mode 13h、320×200、8 位索引色和默认调色板。没有 VBE、线性高分辨率 framebuffer、DAC 编程、双缓冲、字体、滚动、鼠标、窗口或用户态图形协议。

像素与矩形绘制发生在内核启动演示中。矩形只接受已知合法的无符号参数,不支持负坐标和任意裁剪。全帧写入结果在 QEMU 8.2.2、SeaBIOS 1.16.3、pc-i440fx-7.2、TCG、qemu32、1 CPU 和 64 MiB 中得到验证;没有据此声称真实 VGA 硬件、其他 BIOS 或其他模拟器已经兼容。

机制速查

遇到的问题 可迁移做法 本篇落点
固件能力只在早期环境可用 在切换执行模式前完成调用并发布结果 实模式 INT 10h 后进入保护模式
结构尺寸相同但字段含义变化 版本号与生产/消费两端同步升级 BootInfo v1/v2
线性缓冲区容易越界 坐标验证先于地址计算 [0,width) × [0,height)
设备地址不能交给普通分配器 分别验证映射可达与资源保留 恒等映射 + eligible 位图
主显示面可能失去诊断能力 保留独立的带外输出 COM1 串口日志
截图只能证明看起来正确 组合界面、内存和状态证据 PNG + GDB + 串口 + 失败镜像

下载与复现

下载第 25 篇完整源码。附件 SHA-256:

1
57e512a88ef1490d47fb21c82f388670e0d1f684a696bfb9ef18411ed18b53a5

解压后执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
unzip os-day-25.zip
cd os-day-25
make build
make check-graphics
make check-pm
make check-pipe
make check-shell
make check-fd
make check-fat
make check-block
make check-process
make check-syscall
make check-elf
make check-memory
make check-runtime-limit
make check-keyboard

附件包含第 01–25 篇的累计工程和打包前形成的 docs/day25-* 证据,不含 build/。冻结后会在新的空目录中重建并重跑专项及十二项累计检查;复验收据验证已冻结的附件,因此不反向写入 ZIP 自身。

练习

  1. put_pixel 的边界检查暂时移到地址计算之后,用 (UINT_MAX, 1) 说明为何后续通用裁剪必须防止整数溢出,再恢复正确实现。
  2. 改写绘制图案,但保持同一组关键像素断言,观察截图仍然可见而整帧哈希发生变化;据此区分局部断言和全帧证据。
  3. 让图形内核错误地接受 BootInfo v1,记录它会怎样解释 80、25、160 和 2,再恢复按版本拒绝的边界。

上一篇:24 - 管道

下一篇:26 - 字体与缓冲。

参考资料