从零编写操作系统 29 - 窗口终端:把用户态 Shell 接进图形界面
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 | |
窗口代码不解析 echo、cat、wc 或 exit。它只在终端队列与字符网格之间搬运字节。命令解析、spawn_file、wait 和管道仍在同一份 user/day23.c 中。
GDB 在 Shell 发布和第一个提示符处核对了磁盘 ELF 的大小、哈希与用户返回现场:
1 | |
CS=0x1b 是已有用户代码段,EIP 位于 ELF 用户地址窗口。提示符出现时只有一个 Shell 进程、两个 open-file 对象和三个 fd 引用。文件哈希、装载路径和 CPL 一起约束“复用”这件事:仅仅在窗口里画出 # ,不能证明运行的是原 Shell。
fd 不需要知道窗口长什么样
原终端文件对象有输入和输出两种类型。Day 29 没有增加新的用户态 ABI,而是在创建终端对象时记录它是否连接图形会话:
1 | |
旧 Shell 镜像仍令输入端调用 keyboard_read_char(),输出端调用 console_putc()。只有 Day 29 的独立镜像先开启图形终端会话,随后创建的 stdin/stdout/stderr 才走队列。旧 check-shell 和 check-fd 因此仍测试原路径,不会被新界面悄悄替换。
图形终端保存两条互不混用的环形队列:
1 | |
输入方向上,UI 是生产者,Shell 是消费者;输出方向正好相反。两个方向分别维护游标、占用量和等待队列,不能因为它们都叫“终端”就共享一组状态。
空输入要睡眠,满输出也要睡眠
Shell 读取空输入队列时,把当前进程登记到输入等待队列:
1 | |
UI 从 PS/2 队列取得完整字符后再写入终端输入,并唤醒一个 reader。按键中断本身仍只读取 0x60 和放入原始队列,不在 IRQ 上下文中修改窗口或绘制字形。
输出方向不能用“队列满就丢字符”掩盖速度差。writer 在 64 字节队列已满时阻塞;UI 释放空间后唤醒它:
1 | |
一次系统调用可以短写。用户态 write_all 会继续提交剩余字节,因此终端后端既保持有界,也不丢失长输出。这个处理沿用了管道的基本模式:共享条件在关中断的短临界区内检查,登记阻塞与条件检查不可分开,唤醒后再循环复查。
UI 必须成为可运行的消费者
只有有界队列还不够。如果负责清空输出的 UI 和被阻塞的 writer 是同一个执行流,队列一满就会死锁。Day 29 让 PID 0 的 bootstrap 承担 UI 循环,用户 Shell 和命令进程由原抢占调度器运行。
每轮按下面的顺序推进:
1 | |
16 字节配额不是性能结论,只是一个明确的公平边界。它让 1479 字节的 cat README.TXT 必然经历多轮消费,同时每轮都有机会处理鼠标。若一轮无上限地排空输出,队列虽然有界,界面仍可能被持续写入者长期占用。
这里还暴露了初始化顺序的约束。timer_start() 会重新初始化 PIC 并先只开放 IRQ0,所以鼠标控制器不能在它之前完成最后的 IRQ1/IRQ12 解屏蔽。正确顺序是先启动 PIT,再配置共享 8042 和级联 PIC;否则 HMP 已注入按键,客体的 keyboard_irqs 仍会保持 0。
字符网格才是终端状态
窗口客户区保存 38×21 个字符单元、光标位置和折行/滚动计数。UI 消费输出字节时先修改网格,绘制时再展开固定 8×8 字形:
1 | |
长输出造成折行和滚动后,旧像素不再是事实源。每次重绘都从字符网格生成窗口,再叠加鼠标并 flush 到 0xA0000。本次最终画面经历 28 次折行和 41 次滚动:
用于自动检查的 2048 字节保留流记录了本轮所有终端输出。它是诊断数据,不参与 Shell 语义;测试同时读取真实 framebuffer 截图,避免拿日志代替可见结果。
长输出、管道和鼠标在同一轮运行
专项测试依次注入:
1 | |
第二条命令直接输出 1479 字节文件。输出期间又注入六个鼠标移动包,随后才运行管道。最终状态为:
1 | |
输出队列高水位正好为 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 | |
退出时沿所有权反向释放
Shell 执行 exit 后,进程退出路径先关闭 fd。stdout 与 stderr 是同一个输出 open-file 的两个引用,所以只有最后一个引用消失时才释放输出端点;stdin 同理释放输入端点。PID 0 回收 Shell 地址空间与内核栈后,UI 排空仍在队列中的输出,再释放 16 页 surface。
串口最终记录为:
1 | |
最终断言要求 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 | |
解压后执行:
1 | |
附件包含第 01–29 篇累计工程和打包前形成的 docs/day29-* 证据,不含 build/。冻结后已在新空目录重新构建并复跑专项与累计检查;独立附件复验是 ZIP 外的收据,不写回归档。
练习
- 把 UI 每轮的输出消费配额改为 1、8、32 和 64,记录阻塞与重绘计数;不要把计数差异直接解释为墙钟性能。
- 为输入端增加显式 EOF,让阻塞在
read的 Shell 能因关闭窗口退出,并检查等待队列中没有遗留任务。 - 建立两个终端对象,把键盘字符只送给焦点终端;分别运行命令,确认两个输出网格和 fd 生命周期互不混用。
- 把全帧刷新替换为脏区域,先列出光标、滚动、窗口边框和鼠标旧位置会使哪些区域失效,再补对应像素断言。
上一篇:28 - 窗口管理。
下一篇:30 - 综合演示与交付验证。
参考资料
- xv6
pipe.c:有界管道、端点状态及关闭唤醒的生命周期对照;本篇不移植其 RISC-V 锁与 copyin/out。 - QEMU 7.2 QAPI UI schema:
sendkey/输入事件和screendump自动化入口。 - 23 - 用户态 Shell:本篇复用的 FAT-backed ELF、参数栈与命令循环。
- 24 - 两段管道:终端输出与管道共同复用的 open-file、等待和引用计数模型。
- 28 - 窗口管理:窗口绘制、鼠标事件和全帧恢复路径。

