Appearance
设备与磁盘
这一章我把device/下面的驱动实现和disk相关的实现细节集中写在一起。
前面在“内核整体概述”里我已经用比较宏观的方式提过这些模块的职责,这里我就不再重复概念性描述了。我更想做的是:把每条设备链路从“硬件触发/端口读写”一路写到“我在内核里暴露出来的接口”,让你能拿着代码一段一段对照。
本章范围与读法
我在写设备相关代码的时候,最大的感受是:光知道“有个键盘中断”“有个硬盘读写函数”其实没啥用,真正能把系统跑稳的,是把每条链路的边界画清楚。
这章我刻意把叙述方式定成一套固定套路:我先说清楚“这个模块在内核里提供的最小接口是什么”,然后把它背后的数据结构、端口号/寄存器、以及中断/同步约束串起来。最后我会写一段“我怎么验证它真的在工作”的经验。
我想避免的重复
中断这块在第五章我已经把 CPU 侧的流程、IDT 的搭建、以及 PIC 的初始化讲得比较细了。所以这一章里,我只保留驱动作者绕不开的三件事:
(1) 驱动如何把自己的处理函数挂到某个向量号上。
(2) 硬件中断来临时,驱动在 handler 里必须做什么(比如读端口清中断、或者发 EOI)。
(3) 在“中断可能打断线程执行”的前提下,我的驱动和上层代码怎么做同步。
代码目录对照
这一章主要对应以下目录(我在正文里会反复引用这些文件名):
device/console/:VGA 文本模式输出与内核打印。device/keyboard/:键盘 IRQ1、扫描码状态机、以及写入输入缓冲。device/time/:PIT 编程、时钟中断、ticks更新。device/ioqueue/:一个“中断安全”的环形缓冲,键盘输入会用到它。device/ide/:IDE/ATA PIO 读写、硬盘中断同步、分区扫描。kernel/module/filesystem/:文件系统直接用ide_read/ide_write做扇区 I/O。
我用的启动环境是 Bochs,所以你也会看到我在 bochsrc-gdb.disk 里开了串口文件输出(尽管当前代码还没真正写串口驱动)。

设备与中断的交界面
驱动写起来最容易“表面能跑、偶尔死掉”的点,基本都发生在中断交界面:要么 handler 没挂对向量号,要么中断处理完没把硬件状态清掉导致后续不再触发,要么在 handler 里做了会睡眠的事情把系统卡死。
这一节我不复盘中断的底层原理,而是站在“写驱动的人”的角度,把我在这个项目里固定下来的接口和约束讲清楚。
向量号与 register_handler
我的驱动侧不需要直接碰 IDT 表本身,它只需要调用 register_handler 把某个向量号对应的处理函数注册进去。
时钟、键盘这两个外设的向量号我直接写死成:
时钟 IRQ0:
0x20键盘 IRQ1:
0x21
对应代码里就是:
c
register_handler(0x20, intr_time_handler);
register_handler(0x21, intr_keyboard_handler);硬盘稍微不一样:IDE 初始化时我会根据通道号算出 IRQ 向量并注册(主通道 0x2e,从通道 0x2f)。这条链路在 device/ide/init.cpp 里很清晰。
EOI 与“清中断源”
对 8259A PIC 来说,中断处理结束要发 EOI;对具体外设来说,还要“清中断源”,否则 PIC 虽然放行了,外设那边还保持着中断请求,下一次就不会再触发。
我在硬盘中断里专门做了一件很朴素但很关键的事情:在释放信号量以后,再读一次 status 寄存器。
c++
// device/ide/intr.cpp
channel->disk_done.release();
inb(reg::status(channel));这行 inb(reg::status(channel)) 在当时不加就会出现“第一次读写正常,第二次永远等不到中断”的情况。
中断上下文里能做什么
我的一个硬规则是:中断处理函数里只做“很短的、不会阻塞的工作”,把真正可能睡眠的部分放到线程上下文里。
在这个项目里,键盘和硬盘都遵守了这个约束:
键盘中断 handler 只负责把扫描码翻译成字符,然后调用 solve(c) 把字符塞进缓冲区。它不会在中断里做阻塞等待。
硬盘中断 handler 更短:它只做 disk_done.release(),把等待 I/O 的线程唤醒。
这里有一个很容易忽略的点:我的 ioqueue 把“中断状态”为前置条件写成了断言(ASSERT(intr_get_status() == INTR_OFF))。
这意味着只要我在某个路径上调用 ioqbuf.get() 或 ioqbuf.put(),那条路径必须保证是在关中断的情况下执行,否则会直接触发断言。
这也是为什么我把 stdin 的 read() 放在系统调用里做:系统调用入口的汇编路径会统一关中断,这样 ioqueue 才能用一个非常简单的并发模型跑起来。
| 外设 | 向量号 | 说明 |
|---|---|---|
| PIT 时钟 | 0x20 | 在 device/time/time.c 里注册到 intr_time_handler。 |
| 键盘 8042 | 0x21 | 在 device/keyboard/keyboard.c 里注册到 intr_keyboard_handler。 |
| IDE 通道 0 | 0x2e | 在 device/ide/init.cpp 动态注册到 intr_hd_handler。 |
| IDE 通道 1 | 0x2f | 同上,对应第二个通道。 |
本项目里与设备相关的常用向量号(实现侧)
这些向量号默认假设 PIC 已经 remap 到 0x20 开始的范围;PIC remap 的过程我不在本章重复。
控制台输出:从 print 到显存
我在调内核的时候,最依赖的不是 gdb,而是“能不能稳定输出日志”。所以控制台输出我希望它足够简单、足够早可用、也足够不容易把系统搞崩。
这个项目里的输出路径非常直白:所有输出最终都会落到 putchar,它直接写 VGA 文本模式显存,然后更新硬件光标寄存器。
两套 print:内核 console 与用户态 std::print
我实际上有两套“格式化打印”:
内核态我用 device/console/console.cpp 里的 console::print/println,它用 std::format_to 把参数格式化到一个栈上缓冲区,然后调用 puts 输出。
c++
// device/console/console.cpp
template<typename... Args>
auto print(char const* format,Args&&... args) -> u32
{
char buf[BUFSZ]{};
auto ret = std::format_to(buf,format,std::forward<Args>(args)...);
buf[ret] = '\0';
write(buf);
return ret;
}用户态我用 kernel/module/user/printf.cpp 导出的 std::print/std::println,它调用 std::write(stdout, ...)。
这两套东西的区别在于:内核态的 puts/putchar 直接写 VGA;用户态的 write 会走系统调用进内核,最后还是落到 console::write。
输出序列化:mutex + lock_guard
控制台输出属于“全局共享资源”,只要我开了多线程或中断打印,不做保护就会把字符打乱。
所以在 device/console/console.cpp 里我给 write/putc/puth 都加了同一把 mutex。
这把锁的效果很朴素:一次 println 的整段字符串会连在一起,不会被别的线程的输出插进来。
putchar 的硬件细节
真正的硬件操作在 kernel/src/stdio.asm 的 putchar 里。它做的事可以拆成三步:
(1) 读出当前光标位置。VGA 文本模式的光标寄存器在端口 0x03D4/0x03D5。
(2) 根据字符类型决定怎么写:普通字符就把 (字符, 属性) 两个字节写到显存;\n、\r 会触发换行;\b 会回退并覆盖成空格。
(3) 更新光标,必要时滚屏。滚屏实际上就是把第 1 行到最后一行的显存整体向上搬一行。
VGA 文本模式下本项目用到的关键端口

putcharBochs 的视频输出很稳定,但我有时候更想把日志留在宿主机文件里方便搜索。这就引出了“串口输出”,我下一节会写我现在的配置和缺失的代码。
串口输出:我现在能写什么、缺什么
我在 Bochs 的配置里把 com1 打开了,并且让它把 guest 的串口输出写到宿主机文件 serial.txt。
配置在 bochsrc-gdb.disk 里:
text
com1: enabled=1, mode=file, dev="serial.txt"这意味着只要我在内核里往 COM1 的数据端口写字节,宿主机的 serial.txt 就会出现对应内容。
当前代码的真实情况:没有 serial driver
需要强调的是:当前这个仓库里我并没有实现真正的 UART/串口驱动。
我之所以还能“正常看到输出”,完全是因为我的 putchar/puts 是走 VGA 文本模式显存,而不是串口。
所以这一节我能写的内容分两块:一块是“为什么我现在看不到 serial.txt”,另一块是“如果要加串口,我会怎么把它接到现有 print 体系里”。
为什么 serial.txt 为空
原因很简单:我没有任何代码往 COM1 端口写数据。
VGA 输出:
putchar写的是gs指向的视频段。串口输出:需要
outb(0x3F8, byte)之类的端口写。
我当前项目里只有第一条。
如果要实现:最小 COM1 驱动应该长什么样
我倾向于做一个“非常小”的串口模块,只实现两件事:初始化和输出一个字节。输入、波特率自适应、FIFO 这些都可以后面再扩。
在 PC 兼容机上,COM1 的经典端口基址是 0x3F8,几个常用寄存器偏移如下:
COM1(16550 UART)常用寄存器(本项目建议)
这张表是“我建议这样写”,不是当前代码已有的实现。
我大概会写成下面这种(这段是伪代码,用来说明思路):
c
#define COM1_BASE 0x3F8
static inline void serial_putc(char c) {
while ((inb(COM1_BASE + 5) & 0x20) == 0) { }
outb(COM1_BASE + 0, c);
}怎么接到现有的 print
接法我会选“并行输出”,也就是:console::putc 在写 VGA 的同时,再写一份到串口。这样 VGA 仍然能当作“实时屏幕”,串口文件则更适合拿来做 grep。
但是我不会在串口输出上加太多聪明逻辑,因为这东西本质是调试工具,稳定比功能重要。

键盘输入:扫描码到 stdin 的完整链路
键盘这条链路我写得比较“实用主义”:我只支持最常见的 US 键盘扫描码集合,把它翻成 ASCII 字符,然后塞进一个环形缓冲。Shell 或用户程序通过读 stdin 把字符拿走。
硬件入口:端口 0x60 与 IRQ1
键盘控制器(8042/键盘接口)在有键按下或抬起时会产生 IRQ1,中断到来后我必须读一次输出缓冲寄存器,否则 8042 不会继续发下一次中断。
在 device/keyboard/keyboard.c 里,这一行就是核心:
c
auto scancode = (u16)inb(0x60);键盘初始化则是把 handler 注册到 0x21:
c
register_handler(0x21, intr_keyboard_handler);扫描码状态机:扩展码、断码、修饰键
我把键盘处理写成一个小状态机,状态主要由这几个布尔量组成:
ext_scancode:上一字节是不是0xE0。ctrl_status/shift_status/alt_status:修饰键是否按下。caps_lock_status:CapsLock 是切换式,所以只在通码里取反。
断码的判断我用了一个很典型的写法:扫描码最高位为 1 就是 break。
c
auto break_code = (bool)(scancode & 0x0080);如果是 break,我只更新修饰键状态并返回;如果是 make,我会根据扫描码所在区域判断要不要受到 Shift/Caps 的影响,然后用 keymap[scan][shift] 得到字符。
这一段我当时踩的坑是:符号键和字母键的 Shift 规则不一样。符号键就是“Shift 直接换另一个字符”,字母键是 Shift 和 Caps 之间有异或关系。
从字符到缓冲:solve(c)
翻译出来的 ASCII 字符如果不是 0,我就调用 solve(c)。
这个函数在 device/keyboard/iobuf.cpp 里,作用非常单一:往全局的 ioqbuf 里塞一个字节。
c++
// device/keyboard/iobuf.cpp
if (not ioqbuf.full()) {
ioqbuf.put(c);
}这里我刻意不做“键盘回显”。你会看到我把 putchar(c) 注释掉了,因为我想让“回显策略”留给上层(比如 Shell 控制是否回显、是否支持退格编辑)。
stdin 的消费方:系统调用 read
输入缓冲写进去了,还需要一个地方把它读出来。
我选择把键盘输入直接接到 stdin,也就是文件描述符 0。在 的 read 里,有一个非常明确的分支:
c++
if (fd == stdin) {
auto it = (char*)buf;
for (auto bytes_read : std::iota[count]) {
*it++ = ioqbuf.get();
}
return count == 0 ? -1 : count;
}这就把“键盘输入”变成了“对 stdin 的阻塞读”。从用户程序角度看,它只是在读 fd=0;从内核角度看,它读的是 ioqbuf。

stdin 的路径如果你调键盘时遇到“按键只触发一次”,优先怀疑自己是不是忘了在 handler 里读 0x60。8042 的输出缓冲不被读取时,会直接把后续中断憋住。
时钟:PIT 编程与 tick
时钟我用的是 8253/8254 PIT,代码目标不是做一个高精度计时器,而是提供一个稳定的“节拍源”,让线程调度和 sleep 有依据。
PIT 初始化:frequency_set
PIT 的控制端口是 0x43,计数器 0 的数据端口是 0x40。
在 device/time/time.h 里我把频率写死成 100Hz:
c
auto constexpr IRQ0_FREQUENCY = 100;
auto constexpr COUNTER0_VALUE = INPUT_FREQUENCY / IRQ0_FREQUENCY;然后在 device/time/time.c 里通过 frequency_set 写控制字和初值:
c
outb(0x43, counter_no << 6 | rwl << 4 | counter_mode << 1);
outb(0x40, counter_value);
outb(0x40, counter_value >> 8);这里我当时专门把“先低后高”写成注释,是因为这类细节出错会非常隐蔽:表面上系统还能跑,但 tick 的频率会完全不对。
注册中断处理函数
timer 初始化最后会注册时钟中断处理函数到 0x20:
c
register_handler(0x20, intr_time_handler);真正的 handler 在 device/time/time.cpp 里,它做两件事:
(1) 维护全局 ticks(内核自中断开启以来的 tick 数)。
(2) 维护当前线程的时间片计数,时间片用完就调用 schedule()。
c++
++cur_thread->elapsed_ticks;
++ticks;
if (cur_thread->ticks == 0) {
schedule();
} else {
--cur_thread->ticks;
}我不在这里展开调度器实现,因为那会和第六章重复。我只强调一个“设备侧”的事实:时钟中断 handler 是调度器能按时运行的前提。
和 sleep 的交界
我在 IDE 读写里有一个 busy_wait,它会用 sleep(10ms) 这种粒度去轮询硬盘状态。
这件事能成立的前提就是:sleep 最终是依赖 ticks 推进的。如果时钟中断不工作,所有基于 sleep 的逻辑都会表现成“永远卡住”。

ioqueue:一个“中断安全”的环形缓冲
ioqueue 是我写设备时很关键的一个小工具:键盘中断是生产者,系统调用 read 是消费者。两边运行上下文不同,所以我希望这个缓冲结构的并发模型足够简单。
我的选择是:把“关中断”当成 ioqueue 的基本使用前置条件。
数据结构:head/tail 与环绕迭代器
实现文件是 device/ioqueue/ioqueue.cpp。它本质是一个固定长度数组 buf,再加两个游标:
head:写入位置。tail:读出位置。
我写了一个很小的 iterator,让“游标自增时自动环绕”这件事变成类型内部的语义。
满/空判定与 INTR_OFF 约束
满的判定是 ++end() == begin(),空的判定是 begin() == end()。
这两个函数都带了同一条断言:ASSERT(intr_get_status() == INTR_OFF)。
写这条断言不是为了装样子,而是为了把并发模型钉死:只要中断是开的,生产者(键盘中断)随时可能把 head/tail 改掉,我就必须引入更复杂的原子操作或自旋锁。对一个课程 OS 来说我觉得没必要。
阻塞与唤醒:wait/wakeup
ioqueue 的阻塞/唤醒我没有搞条件变量,而是直接存两个 task*:一个 producer,一个 consumer。
当队列空时,消费者会把自己记录到 consumer 并阻塞;当生产者写入一个字节后发现 consumer 不为空,就把它唤醒。
代码长这样:
c++
while (empty()) {
auto lcg = lock_guard{ mtx };
wait(consumer);
}
...
if (producer) {
wakeup(producer);
}你会注意到:我在 while 循环里还套了一个 mutex。
这把锁的作用是避免两个消费者/两个生产者同时在这里“抢占 waiter 指针”。我不希望出现 A 把 consumer 指针写成自己,B 又覆盖成 B,最后唤醒错人这种情况。
在这个实现里,thread_block 发生在关中断的前提下。它依赖我在调度器里做了对应的保证:即阻塞当前线程时,能在正确的时机恢复中断状态。
如果你把 ioqueue 拿去别的地方复用,一定要重新确认这条假设仍然成立。

IDE/ATA PIO:端口、命令与同步
磁盘这块我选的是最经典、也最“粗暴可靠”的做法:IDE/ATA 的 PIO 模式 + LBA28。
我不追求性能,只要能稳定读写扇区、能在多线程下不乱序、能在中断同步上不死锁就够了。
相关实现主要集中在三个文件:
device/ide/ide.cpp:寄存器封装、PIO 读写、identify。device/ide/init.cpp:通道枚举、端口基址、中断注册。device/ide/intr.cpp:硬盘中断 handler,负责唤醒等待线程。
通道与硬盘结构
我把“一个 IDE 通道”抽象成 ide_channel,里面有端口基址、IRQ 向量、互斥锁和一个信号量。
c++
struct ide_channel {
u16 port_base;
u8 irq_no;
mutex mtx;
bool expecting_intr;
semaphore disk_done;
disk devices[2];
};这几个字段里,我最依赖的是 mtx 和 disk_done。
mtx 保证同一时刻只有一个线程在操作某个通道(因为 PIO 本质上是“我写寄存器 + 我搬数据”,并不支持并发重入)。
disk_done 用来把“等待硬盘中断”这件事做成阻塞等待,而不是忙等。
寄存器与端口封装
我在 ide.cpp 里做了一个小技巧:用 namespace reg 把所有寄存器地址计算封装成函数,这样上层读写看起来更像“在访问某个寄存器”,而不是手写一堆 magic number。
比如 reg::data(channel) 返回的是 port_base + 0,reg::status(channel) 返回的是 port_base + 7。
IDE PIO(本项目)常用寄存器偏移
一次读扇区在代码里怎么走
ide_read(hd, lba, buf, sec_cnt) 里最核心的是这条循环:
(1) select_disk(hd):选择主盘/从盘。
(2) select_sector(hd, lba+k, op):写入 LBA 与扇区数。
(3) cmd_out(channel, CMD_READ_SECTOR):写命令并把 expecting_intr 置为 true。
(4) disk_done.acquire():阻塞等待中断。
(5) busy_wait(hd):中断到来后再轮询确认 DRQ 就绪。
(6) insw(DATA,...):把数据搬到内存。
这条链路里有两个我认为“不能省”的点。
一个是 expecting_intr。硬盘中断可能在任何时机到来,如果我不区分“这是我期待的中断”还是“无关中断”,很容易把信号量释放乱套。
另一个是中断 handler 里“读 status 清中断”的那行 inb(reg::status(channel)),不加就会出现后续中断不来。
busy_wait 的设计取舍
我这里有点保守:即使已经等到了中断,我还是会在 busy_wait 里再确认一次硬盘的状态位,确保 DRQ 真的就绪。
这会让代码慢一点,但对我这种以稳定为目标的实现来说,它能换来“出错时更早发现”。
c++
while (wait > 0) {
if (!(inb(reg::status(channel)) & STAT_BSY)) {
return inb(reg::status(channel)) & STAT_DRQ;
}
sleep(10ms);
wait -= 10;
}
分区扫描:MBR/EBR 递归链
IDE 能读写扇区以后,下一步我需要知道“哪些扇区属于哪个分区”。
我在这个项目里实现了最经典的 MBR 分区表 + 扩展分区 EBR 链式结构扫描。
实现集中在 device/ide/part.cpp。
分区表项与引导扇区结构
我把分区表项写成了一个强制 16 字节的 packed 结构:
c++
struct [[gnu::packed]] partition_table_entry {
u8 bootable;
...
u32 start_lba;
u32 sec_cnt;
};引导扇区 boot_sector 则是 446 字节引导代码 + 4 个分区表项 + 结尾魔数 0xAA55。
我在扫描时不会去解释那 446 字节代码,它在这里完全是占位;我只关心分区表和魔数。
扫描算法:ext_lba_base + 递归
扩展分区的坑点在于:EBR 里的第二个分区表项往往指向“下一个 EBR”,它的 start_lba 是相对总扩展分区起点的。
所以我在代码里留了一个全局变量 ext_lba_base,第一次遇到扩展分区时把它记下来,后续扫描就用它做偏移基准。
c++
if (p.fs_type == 0x5) {
if (ext_lba_base != 0) {
self(hd, p.start_lba + ext_lba_base);
} else {
ext_lba_base = p.start_lba;
self(hd, p.start_lba);
}
}非扩展分区则会被记录到 prim_parts 或 logic_parts,并且统一挂到一个 partition_list 链表上。
分区命名与列表
我给硬盘命名成 sda/sdb/...,分区命名则按惯例在后面接编号。
主分区编号从 1 开始,逻辑分区编号从 5 开始,所以你会看到这两段格式化:
c++
std::format_to(part.name, "{}{}", hd->name, ++p_no);
std::format_to(part.name, "{}{}", hd->name, l_no++ + 5);我这样做的好处是:后面文件系统 mount 默认分区时可以直接写 sdb1 这种字符串,而不需要再做映射表。

文件系统与块设备的边界:扇区读写是谁的事
我在做文件系统时很刻意地保持了一条清晰边界:
IDE 驱动只负责“按 LBA 读写扇区”。
文件系统负责“解释这些扇区里的结构(超级块、位图、inode、目录项)”。
这条边界在代码里非常明显:文件系统所有落盘行为,最终都落到 ide_read/ide_write。
format_partition:文件系统第一次落盘怎么写
格式化逻辑在 kernel/module/filesystem/fs.cpp 的 format_partition。
它做的事情可以理解为“把一套最小的文件系统元数据写到分区开头”:
写超级块(
start_lba + 1)。写块位图、inode 位图、inode 表。
写根目录数据块(
data_start_lba),里面只有.和..。
每一步写盘都直接调用 ide_write(hd, lba, buf, sec_cnt)。
我当时觉得最好理解的地方,是超级块里的各个 _lba 字段:它们把“文件系统结构”全部转换成了“扇区地址”。
mount:把硬盘元信息搬进内存
挂载逻辑在同一个文件里的 mount。
它的核心就是读超级块,然后把块位图和 inode 位图读进内存,变成 partition::block 和 partition::inode 两个 bitmap。
这一步以后,文件系统分配 inode/数据块就不需要每次都扫盘了,只要操作内存位图,再用 bitmap_sync 把某个扇区的位图写回硬盘。
stdin/stdout:设备与系统调用的交界
我在系统调用的 read/write 里做了两个“设备特判”。
stdin:读取来自键盘缓冲ioqbuf.get()。stdout:写入走console::write输出到屏幕。
它们的实现都在 kernel/module/filesystem/sysfunc.cpp。
这样做的效果是:对用户程序来说,键盘和屏幕仍然是“文件”,它只需要遵守 read/write 的统一接口。

设备初始化顺序
设备驱动有一个很现实的问题:它不是“你把代码写完就能用”,它强依赖初始化顺序。顺序错了,经常不是报错,而是直接卡死。
init_all 的顺序:我为什么这么排
初始化入口在 kernel/init.cpp 的 init_all()。
它的顺序我当时反复改过几次,最后定成下面这样:
先清 BSS,再调用全局构造函数(否则 C++ 全局对象可能踩到脏内存)。
初始化中断、内存、线程环境,然后再初始化 PIT 和键盘。
初始化 TSS 和系统调用。
开中断,再初始化 IDE。
最后初始化文件系统。
其中“IDE 初始化前必须开中断”是硬约束,因为 identify_disk 和后续读写都在等 disk_done 信号量,如果中断关着,硬盘中断 handler 不会执行,系统就会永远阻塞。
代码里我写得很直白:
c++
intr_enable();
ide_init();
filesystem_init();