Day 28 的窗口能接收字符,却还只是把字符追加到内核数组。Day 23 的 Shell 则已经在 Ring 3 中运行,能从 FAT16 加载命令、等待子进程和连接管道,但输出只能到文本控制台。把两者接起来,关键不是再写一套命令解释器,而是保持 Shell 只认识 fd 0/1/2,让窗口成为终端字节流的生产者和消费者。

本篇新增一个固定的图形终端窗口。键盘字符进入 64 字节输入队列,Shell 输出进入另一条 64 字节队列,UI 每轮只消费一小批字符,再处理鼠标和重绘。这样既能复用原来的用户态程序,也能用真实的满队列阻塞检查长输出期间的调度与界面响应。

复用 Shell,先固定身份证据

mbr-window-shell.img 使用图形版 stage2,但 FAT16 分区直接来自 Day 23 的 fat16-shell.bin。启动 Shell 的路径没有绕过文件系统:

1
2
3
4
5
6
FAT16 /SHELL.EXE
→ file_open + file_pread
→ elf_load_source
→ user_space_build_stack
→ process_create
→ IRET 到 CPL3

窗口代码不解析 echocatwcexit。它只在终端队列与字符网格之间搬运字节。命令解析、spawn_filewait 和管道仍在同一份 user/day23.c 中。

GDB 在 Shell 发布和第一个提示符处核对了磁盘 ELF 的大小、哈希与用户返回现场:

1
2
3
LOADED size=3792
sha256=b44b54cef5743aaa2c8423492cc5d15e96f567802256cbcd25186a7b493bc8b9
cs=1b eip=400000c7 prompts=1 refs=3

CS=0x1b 是已有用户代码段,EIP 位于 ELF 用户地址窗口。提示符出现时只有一个 Shell 进程、两个 open-file 对象和三个 fd 引用。文件哈希、装载路径和 CPL 一起约束“复用”这件事:仅仅在窗口里画出 # ,不能证明运行的是原 Shell。

fd 不需要知道窗口长什么样

原终端文件对象有输入和输出两种类型。Day 29 没有增加新的用户态 ABI,而是在创建终端对象时记录它是否连接图形会话:

1
2
3
4
5
6
7
8
9
struct open_file {
enum file_type type;
uint32_t refs;
uint32_t offset;
unsigned readable, writable;
struct fat_file fat;
struct pipe *pipe;
unsigned graphics_terminal;
};

旧 Shell 镜像仍令输入端调用 keyboard_read_char(),输出端调用 console_putc()。只有 Day 29 的独立镜像先开启图形终端会话,随后创建的 stdin/stdout/stderr 才走队列。旧 check-shellcheck-fd 因此仍测试原路径,不会被新界面悄悄替换。

图形终端保存两条互不混用的环形队列:

1
2
3
4
5
6
7
8
9
struct graphics_terminal {
unsigned active;
unsigned input_used, input_read, input_write;
unsigned output_used, output_read, output_write;
unsigned input_endpoints, output_endpoints;
struct wait_queue input_waiters, output_waiters;
uint8_t input[64];
uint8_t output[64];
};

输入方向上,UI 是生产者,Shell 是消费者;输出方向正好相反。两个方向分别维护游标、占用量和等待队列,不能因为它们都叫“终端”就共享一组状态。

空输入要睡眠,满输出也要睡眠

Shell 读取空输入队列时,把当前进程登记到输入等待队列:

1
2
3
4
while (!terminal.input_used && terminal.active) {
++terminal_read_blocks;
task_block_locked(&terminal.input_waiters);
}

UI 从 PS/2 队列取得完整字符后再写入终端输入,并唤醒一个 reader。按键中断本身仍只读取 0x60 和放入原始队列,不在 IRQ 上下文中修改窗口或绘制字形。

输出方向不能用“队列满就丢字符”掩盖速度差。writer 在 64 字节队列已满时阻塞;UI 释放空间后唤醒它:

1
2
3
4
5
6
7
8
while (terminal.output_used == 64 && terminal.active) {
++terminal_write_blocks;
task_block_locked(&terminal.output_waiters);
}

unsigned count = length;
if (count > 64 - terminal.output_used)
count = 64 - terminal.output_used;

一次系统调用可以短写。用户态 write_all 会继续提交剩余字节,因此终端后端既保持有界,也不丢失长输出。这个处理沿用了管道的基本模式:共享条件在关中断的短临界区内检查,登记阻塞与条件检查不可分开,唤醒后再循环复查。

UI 必须成为可运行的消费者

只有有界队列还不够。如果负责清空输出的 UI 和被阻塞的 writer 是同一个执行流,队列一满就会死锁。Day 29 让 PID 0 的 bootstrap 承担 UI 循环,用户 Shell 和命令进程由原抢占调度器运行。

每轮按下面的顺序推进:

1
2
3
4
5
6
7
最多消费 16 个终端输出字节
→ 更新 38×21 字符网格
→ 搬运可接受的键盘字符
→ 处理全部已到达的鼠标包
→ 状态变化时重建并刷新画面
→ 回收孤儿进程
→ 让出 CPU,或在无任务可运行时 HLT

16 字节配额不是性能结论,只是一个明确的公平边界。它让 1479 字节的 cat README.TXT 必然经历多轮消费,同时每轮都有机会处理鼠标。若一轮无上限地排空输出,队列虽然有界,界面仍可能被持续写入者长期占用。

这里还暴露了初始化顺序的约束。timer_start() 会重新初始化 PIC 并先只开放 IRQ0,所以鼠标控制器不能在它之前完成最后的 IRQ1/IRQ12 解屏蔽。正确顺序是先启动 PIT,再配置共享 8042 和级联 PIC;否则 HMP 已注入按键,客体的 keyboard_irqs 仍会保持 0。

字符网格才是终端状态

窗口客户区保存 38×21 个字符单元、光标位置和折行/滚动计数。UI 消费输出字节时先修改网格,绘制时再展开固定 8×8 字形:

1
2
3
4
5
for (uint32_t row = 0; row < 21; ++row)
for (uint32_t column = 0; column < 38; ++column)
surface_draw_glyph(surface,
8 + column * 8, 22 + row * 8,
terminal.cells[row][column], 15, 0);

长输出造成折行和滚动后,旧像素不再是事实源。每次重绘都从字符网格生成窗口,再叠加鼠标并 flush 到 0xA0000。本次最终画面经历 28 次折行和 41 次滚动:

窗口中的用户态 Shell 完成命令与管道

用于自动检查的 2048 字节保留流记录了本轮所有终端输出。它是诊断数据,不参与 Shell 语义;测试同时读取真实 framebuffer 截图,避免拿日志代替可见结果。

长输出、管道和鼠标在同一轮运行

专项测试依次注入:

1
2
3
4
echo hello
cat readme.txt
cat readme.txt | wc
exit

第二条命令直接输出 1479 字节文件。输出期间又注入六个鼠标移动包,随后才运行管道。最终状态为:

1
2
FINAL input=51 output=1552 high=2/64 block=49/89
mouse=6 redraws=146 pipe=1/1 pblock=21/3 log=1552

输出队列高水位正好为 64,writer 阻塞 89 次,1552 个输出字节全部被 UI 消费;鼠标包计数为 6,指针最终位于 (260,93)。这说明背压发生时 UI 仍获得执行机会,不代表测得了某个刷新率。

cat README.TXT | wc 输出 27 1479。管道创建与释放都是 1,reader 阻塞 21 次、writer 阻塞 3 次。窗口只是显示结果,真正的数据仍从 FAT 文件经过 producer、64 字节管道和 consumer:

1
README.TXT → CAT.EXE stdout → pipe → WC.EXE stdin → terminal output

退出时沿所有权反向释放

Shell 执行 exit 后,进程退出路径先关闭 fd。stdout 与 stderr 是同一个输出 open-file 的两个引用,所以只有最后一个引用消失时才释放输出端点;stdin 同理释放输入端点。PID 0 回收 Shell 地址空间与内核栈后,UI 排空仍在队列中的输出,再释放 16 页 surface。

串口最终记录为:

1
2
3
4
D29 UI mouse=6 at=260,93 redraws=146 chars=1552 wraps=28 scrolls=41
D29 TERMINAL input=51 output=1552 consumed=1552 high=2/64 block=49/89 endpoints=2
D29 ELF size=3792 reads=15 prompts=4 pipe=1/1 rblock=21 wblock=3
WINDOW SHELL OK FAT ELF, bounded terminal and responsive UI

最终断言要求 open-file 数量和引用数为 0、管道创建数等于释放数、终端输出入队数等于消费数、两个端点都已释放,Shell 页数回到启动基线。surface 释放后再与 UI 初始化前的页数比较。

实验记录与累计回归

专项检查结果ELF 与用户态证据队列/管道/资源终态串口记录完整证据说明保存了本篇运行数据。

专项之后,Day 28 窗口、Day 27 鼠标、图形文字、Mode 13h、保护模式、管道、原 Shell、文件描述符、FAT16、ATA、进程、系统调用、ELF、物理页、装载容量和 IRQ1 键盘共十六项检查全部退出 0,合并记录见累计回归日志

当前边界

当前只有一个固定全屏终端窗口,没有动态创建、关闭、隐藏或缩放。验证了 Shell 正常退出时的端点释放,没有实现“进程正阻塞于读/写时关闭窗口”的取消协议,也没有反复打开和关闭终端窗口。这个差异必须保留,不能把退出后的清理扩大成完整的关窗竞态处理。

输入队列满时 UI 暂停从键盘原始队列取字符;如果生产速度继续超过既有 PS/2 队列容量,原始层仍可能溢出并复位。本次输入高水位为 2、丢弃为 0。没有 canonical line discipline、Ctrl-D、Unicode 或多终端焦点路由。

UI 仍在内核 bootstrap 中,不是用户态显示服务器;每次变化复制完整 64,000 字节,没有脏矩形、垂直同步或多核锁。测试限定于 QEMU 8.2.2、SeaBIOS 1.16.3、pc-i440fx-7.2、TCG、qemu32、1 CPU 和 64 MiB。

机制速查

遇到的问题 可迁移做法 本篇落点
给程序更换显示界面 保持字节流 ABI,替换端点后端 fd 0/1/2 不变,终端改接窗口队列
生产者快于消费者 有界缓冲、短写、阻塞与唤醒 64 字节输出环与 write_all
长输出压住交互 每轮限制消费配额并回到事件循环 每轮最多处理 16 个输出字节
证明复用而非重写 同时核对输入文件、装载路径与权限级 ELF 哈希、FAT 读取、CS/EIP
像素无法表达终端历史 字符网格作为状态,像素作为派生结果 38×21 单元、折行、滚动、重绘
关闭共享输出对象 以最终引用消失作为端点释放时刻 stdout/stderr 共用 open-file 引用计数

下载与复现

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

1
ecbdd9cd4fbd740c7ad968094c1f5d995ac1b599b1ac54f0fbd5bd699c441fdc

解压后执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
unzip os-day-29.zip
cd os-day-29
make build
make check-window-shell
make check-window
make check-mouse
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–29 篇累计工程和打包前形成的 docs/day29-* 证据,不含 build/。冻结后已在新空目录重新构建并复跑专项与累计检查;独立附件复验是 ZIP 外的收据,不写回归档。

练习

  1. 把 UI 每轮的输出消费配额改为 1、8、32 和 64,记录阻塞与重绘计数;不要把计数差异直接解释为墙钟性能。
  2. 为输入端增加显式 EOF,让阻塞在 read 的 Shell 能因关闭窗口退出,并检查等待队列中没有遗留任务。
  3. 建立两个终端对象,把键盘字符只送给焦点终端;分别运行命令,确认两个输出网格和 fd 生命周期互不混用。
  4. 把全帧刷新替换为脏区域,先列出光标、滚动、窗口边框和鼠标旧位置会使哪些区域失效,再补对应像素断言。

上一篇:28 - 窗口管理

下一篇:30 - 综合演示与交付验证

参考资料

  • xv6 pipe.c:有界管道、端点状态及关闭唤醒的生命周期对照;本篇不移植其 RISC-V 锁与 copyin/out。
  • QEMU 7.2 QAPI UI schemasendkey/输入事件和 screendump 自动化入口。
  • 23 - 用户态 Shell:本篇复用的 FAT-backed ELF、参数栈与命令循环。
  • 24 - 两段管道:终端输出与管道共同复用的 open-file、等待和引用计数模型。
  • 28 - 窗口管理:窗口绘制、鼠标事件和全帧恢复路径。