Day 25 已经能把颜色值写入 0xA0000,但那些矩形没有可恢复的内容模型。窗口被遮住再移开时,单靠显存无法知道原位置应该重画什么;文本发生滚动时,逐像素搬运也会把终端语义和显示细节绑在一起。

本篇把两层状态拆开。64,000 字节离屏画面由 16 个独立物理页持有,绘图函数通过 surface 访问它;终端另外保存 40×25 个字符单元、颜色和光标。折行与滚动先修改字符网格,渲染阶段再用固定 8×8 字体生成像素,最后把完整画面复制到 Mode 13h 显存。

专项实验覆盖第 8 页分配前失败、INT32_MIN 坐标、UINT32_MAX 尺寸、完全屏外和右下跨界矩形。GDB 对照字形 A 的八行数据,重新计算 64,000 字节帧哈希,并确认 surface 释放后空闲页数恢复。

一帧大于现有堆的单次上限

Mode 13h 的一帧需要:

1
320 × 200 × 1 byte = 64,000 bytes

Day 12 的 kmalloc 以单个 4 KiB 物理页为 arena,不跨页拼接对象。扣除块头和尾部哨兵后,单次请求上限为 4,076 字节。直接写 kmalloc(64000) 只会稳定失败;把 64,000 字节数组放进 BSS,又会把运行内存增长藏进静态镜像,并重新碰到加载器容量边界。

Day 26 的 surface 显式持有 16 个页地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#define SURFACE_PAGES 16u

struct surface {
uint32_t pages[SURFACE_PAGES];
uint32_t page_count;
uint32_t width;
uint32_t height;
uint32_t pitch;
};

static uint8_t *surface_byte(struct surface *surface, uint32_t offset)
{
return (uint8_t *)(uintptr_t)surface->pages[offset / 4096] +
offset % 4096;
}

这些页不要求物理连续。低 64 MiB 恒等映射已经让每个物理地址可被内核访问,因此当前阶段也不需要建立新的连续虚拟区。代价是每次访问都要做一次页号和页内偏移计算。这个实现适合固定 320×200 的教学画面;更高分辨率或高频合成需要连续虚拟映射、批量行访问或脏区更新。

16 页共有 65,536 字节。前 64,000 字节属于可见画面,剩余 1,536 字节全部填入 0xA5,绘制结束后逐字节复查。这个尾部区域给最后一页提供了越界探针,但无法代替对负坐标和前向越界的逻辑检查。

可迁移的做法是“连续接口、离散所有权”:上层按一个线性 surface 使用,下层保留每个页的独立所有权。物理连续性不是图形算法的前提,也不应为了一个线性坐标公式强迫分配器提供大块连续内存。

分配失败必须撤销已经取得的页

surface 的创建过程要么取得全部 16 页,要么一页不留。循环在第 i 次失败时,从 page_count 记录的末尾逆序释放:

1
2
3
4
5
6
7
8
9
10
11
12
for (uint32_t i = 0; i < SURFACE_PAGES; ++i) {
uint32_t page = i == fail_after ? 0 : page_alloc();
if (!page) {
while (surface->page_count) {
--surface->page_count;
page_free(surface->pages[surface->page_count]);
surface->pages[surface->page_count] = 0;
}
return 0;
}
surface->pages[surface->page_count++] = page;
}

专项镜像先令 fail_after=7。空闲页数在调用前为 16,046,七页已经分配时强制返回失败,回滚后仍为 16,046。正常创建随后把计数降到 16,030,flush 完成并销毁 surface 后又恢复到 16,046:

1
D26 BACKBUFFER pages=16 rollback=7 free=16046/16030/16046

单看最终页数相等,还不能说明失败分支真的走过。rollback=7 与三个时间点的页数放在一起,才能区分“根本没分配”“分配后泄漏”和“分配后完整撤销”。

裁剪发生在地址计算之前

Day 25 的像素函数只接收无符号坐标,矩形调用者还要保证整个区域在屏幕内。通用绘图入口会收到窗口拖动、文本边缘和鼠标指针产生的负坐标,也可能收到接近整数上限的尺寸。

Day 26 用半开矩形 [x, x+width) × [y, y+height)xy 是 32 位有符号数,宽高是 32 位无符号数;边界先提升到 64 位再相加:

1
2
3
4
5
6
7
8
9
10
11
12
13
int64_t left = x;
int64_t top = y;
int64_t right = left + (int64_t)width;
int64_t bottom = top + (int64_t)height;

if (!width || !height || right <= 0 || bottom <= 0 ||
left >= surface->width || top >= surface->height)
return 0;

if (left < 0) left = 0;
if (top < 0) top = 0;
if (right > surface->width) right = surface->width;
if (bottom > surface->height) bottom = surface->height;

求交之前不能先把负坐标转成无符号数,也不能先在 32 位里计算 x + width。前者会把 -1 变成一个巨大的正数,后者会在极端输入下回绕。只有求交后的行列才参与 row * pitch + column

四个探针经过同一个生产函数:

输入 求交结果 写入像素
x=INT32_MIN, w=16 完全在左侧 0
x=300, y=190, w=h=UINT32_MAX [300,320) × [190,200) 200
x=400, y=300, w=h=16 完全在右下 0
x=318, y=198, w=h=8 [318,320) × [198,200) 4

运行结果为 clipped=2 rejected=2 pixels=204 guard=0。测试既检查返回计数,也检查最后 1,536 个字节的哨兵,因此“函数声称拒绝”和“内存确实没被越界改写”得到两份不同层面的证据。

字形是一组固定的行位图

字体取自 dhepper/font8x8font8x8_basic.h,固定到提交 8e279d2d864e79128e96188a6b9526cfa3fbfef9。附件保留可见 ASCII U+0020–U+007E,并记录上游的 Public Domain 声明、作者、URL 和提交号。该声明按原样保存,不被扩张成独立法律意见。

每个字符占八个字节,每个字节是一行,最低位对应最左侧像素。大写 A 的八行是:

1
0c 1e 33 33 3f 33 33 00

绘制时不需要把字体复制到新格式:

1
2
3
4
for (int row = 0; row < 8; ++row)
for (int column = 0; column < 8; ++column)
put_pixel(x + column, y + row,
glyph[row] & (1u << column) ? foreground : background);

字形仍通过同一套裁剪入口,没有因为尺寸固定而增加一个无边界检查的快路径。输入不在可见 ASCII 范围时统一替换为 ?。这份表不能处理 UTF-8、中文、组合字符、双宽字符或字体 fallback。

字体文件的可迁移规则是“数据也要锁版本”。只写“使用一个 8×8 字体”无法复现实验;字节表、位序、覆盖范围、来源和授权声明都属于渲染契约。

字符网格保存终端语义

终端状态没有从显存反读。它保存 40×25 个字符、每格颜色、光标行列以及折行和滚动计数:

1
2
3
4
5
6
7
struct text_terminal {
uint8_t cells[25][40];
uint8_t colors[25][40];
uint32_t column, row;
uint32_t wraps, scrolls;
uint8_t foreground, background;
};

普通字符写入当前格并右移光标。第 40 列写完后自动换到下一行;换行将列归零并增加行号。行号越过第 24 行时,网格的第 1–24 行整体前移,最后一行填空格,光标停在新的末行。退格只修改网格中的字符。

专项阶段先写入 45 个字符的长行,再连续加入 30 行,得到一次折行和八次滚动。随后清空可见网格,但保留计数,绘制标题、全部可见 ASCII 和光标。这样截图保持可读,故障路径仍由计数证明:

1
D26 TEXT wraps=1 scrolls=8 cursor=6,10 A=c,1e,33,33,3f,33,33,0

字符网格带来的收益会在窗口阶段体现。遮挡区域恢复时只需重新渲染对应单元,不必猜测显存中的像素原来属于哪个字符;更换前景色或移动终端窗口,也不需要从旧画面反向解析文本。

全帧 flush 的证明边界

完成字符网格渲染后,内核按线性偏移读取 16 个页,把 64,000 字节逐一复制到 0xA0000。复制前的 surface 与复制后的显存分别计算 FNV-1a,结果相同:

1
D26 FRAME hash=d84d1c49

GDB 又从 0xA0000 读取完整一帧,在宿主侧重新得到 0xd84d1c49。它还核对画面左上角标题中 A 的前景和背景像素。QEMU screendump 给出实际显示结果:

8×8 字体、ASCII 字符和光标

后备缓冲保证一帧先在普通内存中完成,避免显存长期停留在“上半部分已更新、下半部分仍是旧内容”的构造状态。最后的 64,000 字节复制仍然需要时间,当前代码没有等待 VGA 垂直回扫,也没有页翻转协议,因此不能声称绝对无撕裂。脏矩形同样没有提前加入;Day 28 会先观察窗口合成与输入响应,再决定是否值得增加复杂度。

实验记录与累计回归

专项串口记录GDB 现场专项检查结果完整证据说明保存了本次运行数据。

专项通过后,Day 25 图形、保护模式、管道、Shell、文件描述符、FAT16、ATA、进程、系统调用、ELF、物理页、加载容量和真实 IRQ1 键盘检查全部再次退出 0,合并记录见累计回归日志。冻结 ZIP 又在无 build/ 的空目录完成同一轮复验,收据见独立附件复验。普通文本内核仍为 83,424 字节;新增字体与图形文本路径的独立内核为 92,128 字节,没有扩大 128 KiB 文件上限或 256 KiB 运行内存上限。

当前边界

当前终端固定为 320×200、40×25 单元、8×8 等宽字体和 8 位索引色。只有可见 ASCII;没有 UTF-8、中文、滚动历史、选择复制、光标闪烁或用户态终端协议。

后备 surface 是 16 个页组成的逻辑线性区域,不是物理连续内存或连续虚拟映射。每帧完整复制,没有脏区、页翻转、垂直同步或性能测量。结果限定在 QEMU 8.2.2、SeaBIOS 1.16.3、pc-i440fx-7.2、TCG、qemu32、1 CPU 和 64 MiB 环境。

机制速查

遇到的问题 可迁移做法 本篇落点
大对象超过小块堆上限 显式持有多个页并提供统一访问层 16 页 surface
多步分配可能半途失败 发布前完成全部资源,失败时逆序回滚 第 8 页前故障注入
坐标和尺寸可能溢出 在宽整数中求半开区间交集,再算地址 4 类边界探针
像素不能表达文本语义 字符网格作为源状态,像素作为派生状态 折行、滚动与光标
外部字体数据会漂移 固定字节表、位序、提交和来源声明 可见 ASCII 8×8 表
截图无法证明内存安全 组合截图、整帧哈希、哨兵和页计数 QEMU + GDB + 串口

下载与复现

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

1
32addbe3f40b5435114d9d0c233b40f760e61534320712f1f59d044762713ad2

解压后执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
unzip os-day-26.zip
cd os-day-26
make build
make check-graphics-text
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–26 篇的累计工程和打包前形成的 docs/day26-* 证据,不含 build/。冻结后会在新的空目录中重建并复跑专项及累计检查;复验收据验证冻结附件,不写回 ZIP 自身。

练习

  1. 把 16 个页临时改为 15 个,观察最后 2,560 个可见字节为什么不能被 surface 表示,再恢复常量和静态边界。
  2. 将字形绘制改成透明背景,分别在纯色和已有文字上重绘同一字符,比较它与当前覆盖式终端单元的语义差异。
  3. 在不改变字符网格的前提下只重绘一个 8×8 单元,记录写入字节数;不要在测量输入响应前直接把它推广为完整脏矩形系统。

上一篇:25 - 从字符到像素

下一篇:27 - 鼠标输入。

参考资料