Skip to content

设备与磁盘

这一章我把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 时钟0x20device/time/time.c 里注册到 intr_time_handler
键盘 80420x21device/keyboard/keyboard.c 里注册到 intr_keyboard_handler
IDE 通道 00x2edevice/ide/init.cpp 动态注册到 intr_hd_handler
IDE 通道 10x2f同上,对应第二个通道。

本项目里与设备相关的常用向量号(实现侧)

这些向量号默认假设 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.asmputchar 里。它做的事可以拆成三步:

(1) 读出当前光标位置。VGA 文本模式的光标寄存器在端口 0x03D4/0x03D5

(2) 根据字符类型决定怎么写:普通字符就把 (字符, 属性) 两个字节写到显存;\n\r 会触发换行;\b 会回退并覆盖成空格。

(3) 更新光标,必要时滚屏。滚屏实际上就是把第 1 行到最后一行的显存整体向上搬一行。

VGA 文本模式下本项目用到的关键端口

打印链路:从格式化到 putchar

Bochs 的视频输出很稳定,但我有时候更想把日志留在宿主机文件里方便搜索。这就引出了“串口输出”,我下一节会写我现在的配置和缺失的代码。

串口输出:我现在能写什么、缺什么

我在 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

键盘输入从 IRQ1 流向 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 的逻辑都会表现成“永远卡住”。

PIT 节拍如何被调度与 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 拿去别的地方复用,一定要重新确认这条假设仍然成立。

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];
};

这几个字段里,我最依赖的是 mtxdisk_done

mtx 保证同一时刻只有一个线程在操作某个通道(因为 PIO 本质上是“我写寄存器 + 我搬数据”,并不支持并发重入)。

disk_done 用来把“等待硬盘中断”这件事做成阻塞等待,而不是忙等。

寄存器与端口封装

我在 ide.cpp 里做了一个小技巧:用 namespace reg 把所有寄存器地址计算封装成函数,这样上层读写看起来更像“在访问某个寄存器”,而不是手写一堆 magic number。

比如 reg::data(channel) 返回的是 port_base + 0reg::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;
}
IDE PIO 读扇区的线程/中断协作时序

分区扫描: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_partslogic_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 这种字符串,而不需要再做映射表。

扩展分区的 EBR 链式结构与扫描偏移

文件系统与块设备的边界:扇区读写是谁的事

我在做文件系统时很刻意地保持了一条清晰边界:

  • IDE 驱动只负责“按 LBA 读写扇区”。

  • 文件系统负责“解释这些扇区里的结构(超级块、位图、inode、目录项)”。

这条边界在代码里非常明显:文件系统所有落盘行为,最终都落到 ide_read/ide_write

format_partition:文件系统第一次落盘怎么写

格式化逻辑在 kernel/module/filesystem/fs.cppformat_partition

它做的事情可以理解为“把一套最小的文件系统元数据写到分区开头”:

  • 写超级块(start_lba + 1)。

  • 写块位图、inode 位图、inode 表。

  • 写根目录数据块(data_start_lba),里面只有 ...

每一步写盘都直接调用 ide_write(hd, lba, buf, sec_cnt)

我当时觉得最好理解的地方,是超级块里的各个 _lba 字段:它们把“文件系统结构”全部转换成了“扇区地址”。

mount:把硬盘元信息搬进内存

挂载逻辑在同一个文件里的 mount

它的核心就是读超级块,然后把块位图和 inode 位图读进内存,变成 partition::blockpartition::inode 两个 bitmap。

这一步以后,文件系统分配 inode/数据块就不需要每次都扫盘了,只要操作内存位图,再用 bitmap_sync 把某个扇区的位图写回硬盘。

stdin/stdout:设备与系统调用的交界

我在系统调用的 read/write 里做了两个“设备特判”。

  • stdin:读取来自键盘缓冲 ioqbuf.get()

  • stdout:写入走 console::write 输出到屏幕。

它们的实现都在 kernel/module/filesystem/sysfunc.cpp

这样做的效果是:对用户程序来说,键盘和屏幕仍然是“文件”,它只需要遵守 read/write 的统一接口。

文件系统与块设备的边界,以及 stdin/stdout 的设备特判

设备初始化顺序

设备驱动有一个很现实的问题:它不是“你把代码写完就能用”,它强依赖初始化顺序。顺序错了,经常不是报错,而是直接卡死。

init_all 的顺序:我为什么这么排

初始化入口在 kernel/init.cppinit_all()

它的顺序我当时反复改过几次,最后定成下面这样:

  • 先清 BSS,再调用全局构造函数(否则 C++ 全局对象可能踩到脏内存)。

  • 初始化中断、内存、线程环境,然后再初始化 PIT 和键盘。

  • 初始化 TSS 和系统调用。

  • 开中断,再初始化 IDE。

  • 最后初始化文件系统。

其中“IDE 初始化前必须开中断”是硬约束,因为 identify_disk 和后续读写都在等 disk_done 信号量,如果中断关着,硬盘中断 handler 不会执行,系统就会永远阻塞。

代码里我写得很直白:

c++
intr_enable();
ide_init();
filesystem_init();
从内核启动到设备与文件系统初始化的顺序