Appearance
内核整体概述
本章将从宏观角度介绍内核的整体架构,帮助读者建立对整个系统的全局理解。我们将依次探讨启动流程、内存管理、中断系统、进程与线程、设备驱动、磁盘结构、文件系统以及系统调用与Shell的实现。
内核整体架构
在动手写代码之前,我先规划了整个内核的目录结构和模块划分。
目录结构与模块划分
我把代码按功能拆成了几个主要目录。boot/ 放启动相关的汇编代码,包括 MBR 和 Loader。kernel/ 是内核主体,里面又细分了 module/ 子目录来存放各个功能模块——内存管理、线程调度、文件系统这些都在里面。device/ 专门放设备驱动,时钟、键盘、硬盘各一个子目录。
bash
os/
├── boot/ # 启动相关
│ ├── mbr.asm # MBR引导扇区
│ └── loader.asm # 加载器
├── kernel/ # 内核主体
│ ├── main.cpp # 内核入口
│ └── module/ # 功能模块
│ ├── memory/ # 内存管理
│ ├── thread/ # 进程与线程
│ ├── filesystem/ # 文件系统
│ └── shell/ # Shell实现
└── device/ # 设备驱动
├── time/ # 时钟
├── keyboard/ # 键盘
└── ide/ # IDE硬盘整体上我采用了分层架构。最底层是硬件抽象层,封装时钟、键盘、磁盘这些设备的操作。往上是内核核心层,处理中断、内存分配、进程调度。再往上是系统服务层,提供文件系统和系统调用接口。最顶层就是用户交互层,也就是 Shell。

技术栈
语言方面我选了 C++26,主要是想用 Modules 特性来组织代码,编译速度快而且模块化更清晰。底层的启动代码和中断处理用 NASM 汇编写。目标架构是 x86 32位保护模式,用 GCC 的 -m32 选项编译。构建工具用 CMake,调试环境是 Bochs 虚拟机配合 GDB。
启动流程
这一节我来讲讲系统从上电到进入内核的整个过程。
MBR引导扇区
机器通电后,BIOS 会把磁盘第一个扇区(也就是 MBR)加载到内存 0x7C00,然后跳过去执行。我的 MBR 做的事情很简单:初始化段寄存器,在屏幕左上角显示 "KKKZBH" 证明自己活着,然后从磁盘把 Loader 读到 0x900,最后跳过去执行 Loader。
text
; mbr.asm 关键代码
org 0x7c00
mov eax, LOADER_START_SECTOR ; Loader所在扇区
mov bx, LOADER_BASE_ADDR ; 目标地址 0x900
mov cx, 4 ; 读取4个扇区
call rd_disk_m_16 ; 读取磁盘
jmp LOADER_BASE_ADDR + 0x300 ; 跳转执行Loader加载器
Loader 是启动过程中最复杂的部分。它得先通过 BIOS 中断探测物理内存大小,然后打开 A20 地址线、加载 GDT、设置 CR0 的 PE 位来进入 32 位保护模式。接着从磁盘把内核 ELF 文件读到 0x70000,建立页目录和页表开启分页,解析 ELF 把各个段复制到正确位置,最后跳到内核入口。

内核入口
Loader 把控制权交给内核后,我按顺序初始化各个子系统。先是中断描述符表,然后是内存管理、线程调度、控制台、键盘、时钟、硬盘驱动、文件系统,最后初始化系统调用。全部搞定后,内核会创建 init 进程,启动 Shell 等待用户输入。
c++
// kernel.cpp 初始化顺序
idt_init(); // 中断描述符表
mem_init(); // 内存管理
thread_init(); // 线程调度
console_init(); // 控制台
keyboard_init(); // 键盘驱动
timer_init(); // 时钟
ide_init(); // 硬盘驱动
filesystem_init(); // 文件系统
syscall_init(); // 系统调用内存管理
这一节讲内存管理的实现,包括分页机制、物理内存池和堆分配器。
分页机制
我用的是二级分页,页大小 4KB。虚拟地址空间的划分方式是:低 3GB(0x00000000 到 0xBFFFFFFF)给用户进程用,高 1GB(0xC0000000 到 0xFFFFFFFF)留给内核。内核空间会映射到所有进程的页表里,这样内核代码在任意进程上下文中都能访问。
页目录表放在物理地址 0x100000(1MB)。Loader 建立初始映射时,把第 0 项和第 768 项都指向同一个页表(映射低端 1MB),这样开启分页前后地址都是通的。第 1023 项指向页目录自身,方便后续动态修改页表。
物理内存池与虚拟地址管理
物理内存我用位图来管理,分成内核物理内存池和用户物理内存池两个。每个进程还有自己的虚拟地址位图,记录哪些虚拟页已经用了。分配页面的核心函数长这样:
c++
void* get_kernel_pages(u32 pg_cnt); // 分配内核页
void* get_user_pages(u32 pg_cnt); // 分配用户页
void* get_a_page(pool_flags pf, u32 vaddr); // 指定虚地址分配堆分配器
小于一页的内存分配用 Arena 分配器。思路是把一页内存切成多个固定大小的块(16B、32B 一直到 1024B),用空闲链表管理。如果申请的内存大于 1024B,就直接分配整页。这样既能减少内存碎片,又能快速分配回收。
c++
void* malloc(size_t size); // 分配内存
void free(void* ptr); // 释放内存中断系统
这一节讲中断描述符表、可编程中断控制器和异常处理。
IDT与中断门
x86 用中断描述符表(IDT)把中断向量号和处理函数关联起来。我定义了 129 个中断描述符(0x00 到 0x80)。0x00 到 0x1F 是 CPU 异常,0x20 到 0x2F 是外部硬件中断(通过 PIC 传进来),0x80 留给系统调用。
c
// 中断门描述符结构
struct gate_desc {
u16 func_offset_low; // 处理函数偏移低16位
u16 selector; // 代码段选择子
u8 dcount;
u8 attribute; // 门类型与权限
u16 func_offset_high; // 处理函数偏移高16位
};8259A PIC初始化
我用两片级联的 8259A 芯片管理 15 个外部中断。主片(端口 0x20-0x21)处理 IRQ0 到 IRQ7,中断向量是 0x20 到 0x27。从片(端口 0xA0-0xA1)处理 IRQ8 到 IRQ15,中断向量是 0x28 到 0x2F。
目前开启了三个中断:IRQ0 时钟中断用来做进程调度,IRQ1 键盘中断接收用户输入,IRQ14 硬盘中断处理磁盘读写完成通知。
异常处理
CPU 异常我注册了通用处理函数,遇到异常就打印异常名称和相关信息然后挂起。比如除零错误是 #DE,无效操作码是 #UD,缺页异常是 #PF。缺页异常还会额外打印 CR2 寄存器里保存的出错地址,方便调试。
进程与线程
这一节讲进程控制块、线程创建、调度算法和同步机制。
PCB结构
进程控制块用 task 结构体表示,占一页内存(4KB)。页底部放 PCB 信息,页顶部当内核栈用。这样设计的好处是通过栈指针就能快速定位到 PCB——直接把 ESP 按页对齐就行。
c++
struct task {
u32* self_kstack; // 内核栈指针
pid_t pid; // 进程ID
thread_status stu; // 运行状态
char name[16]; // 进程名
u8 priority; // 优先级
u8 ticks; // 时间片剩余
u32* pgdir; // 页目录表(用户进程)
bitmap userprog_vaddr; // 用户虚地址位图
i32 fd_table[8]; // 文件描述符表
};线程创建与调度
创建线程的过程是:先分配一页内存当 PCB,初始化各个字段,构造初始线程栈,然后加入就绪队列。内核线程的 pgdir 是空指针,共用内核页表;用户进程有自己的页目录,有独立地址空间。
调度算法用的是时间片轮转。每次时钟中断把当前进程的 ticks 减 1,减到 0 就触发调度。新选中的进程会把 ticks 重置成它的 priority,所以优先级越高,单次获得的时间片越长。
c++
auto schedule() -> void {
auto cur = running_thread();
if(cur->stu == running) {
thread_ready_list.push_back(&cur->general_tag);
cur->ticks = cur->priority;
cur->stu = ready;
}
auto next = find_next_thread();
next->stu = running;
process_activate(next);
switch_to(cur, next);
}同步机制
我实现了信号量和互斥锁两种同步原语。信号量用于等待/通知模式,互斥锁保护临界区。还写了个 lock_guard 做 RAII 风格的锁管理,避免忘记释放锁。
用户进程
用户进程相关的系统调用有 fork()、exec()、wait() 和 exit()。fork() 复制当前进程,子进程返回 0,父进程返回子进程 PID。exec() 加载新程序替换当前进程映像。wait() 等子进程结束并获取退出状态。exit() 结束进程并把状态码传给父进程。
设备驱动
这一节讲时钟、键盘和 IDE 硬盘驱动的实现。
时钟驱动
时钟用的是 8253 PIT(可编程间隔定时器),我把它设成 100Hz,也就是每 10ms 产生一次中断。时钟中断处理函数做两件事:给当前进程的运行时间计数加 1,然后检查时间片是否用完。用完就调用 schedule() 切换进程。
c
void intr_timer_handler() {
auto cur = running_thread();
++cur->elapsed_ticks;
++ticks;
if(cur->ticks == 0) {
schedule();
} else {
--cur->ticks;
}
}键盘驱动
键盘驱动处理 PS/2 键盘中断(IRQ1)。收到中断后,从端口 0x60 读取扫描码,转成 ASCII 字符(要处理 Shift、Caps Lock 这些修饰键),然后写进环形缓冲区。Shell 从缓冲区读数据,如果缓冲区空就阻塞等待。这就是典型的生产者-消费者模式。
IDE硬盘驱动
IDE 驱动用的是 PIO(Programmed I/O)模式,寻址方式是 LBA28。主通道端口是 0x1F0-0x1F7,中断号 IRQ14。读写流程是:先选择硬盘,写入扇区地址和数量,发送读/写命令,等中断或轮询状态,然后通过数据端口传输数据。
c++
auto ide_read(disk* hd, u32 lba, void* buf, u32 sec_cnt) -> void;
auto ide_write(disk* hd, u32 lba, void* buf, u32 sec_cnt) -> void;磁盘结构
这一节讲项目用的两个磁盘镜像的结构。
hd64M.img(裸盘)
hd64M.img 是 64MB 的裸盘镜像,不分区,直接存放启动代码和内核。第 0 扇区是 MBR,第 2-5 扇区是 Loader(2KB),从第 9 扇区开始存内核 ELF 文件(大约 180KB)。构建时用 dd 命令把各部分写入对应位置。
hd80M.img(分区盘)
hd80M.img 是 80MB 的分区磁盘,带 MBR 分区表,有个 sdb1 主分区装着我实现的文件系统。这个磁盘用来存用户程序和数据。
sdb1 分区的布局从前往后依次是:引导块(保留没用)、超级块(文件系统元数据)、块位图(管理空闲块)、inode 位图(管理空闲 inode)、inode 表、数据区(存文件内容和目录)。
文件系统
这一节讲我实现的文件系统设计。
超级块与分区布局
超级块存放文件系统的全局元数据,包括总扇区数、inode 数量、各区域起始地址等。它固定占一个扇区(512字节),里面有个魔数 0x19590318 用来识别文件系统类型。
c++
struct super_block {
u32 magic; // 魔数 0x19590318
u32 sec_cnt; // 总扇区数
u32 inode_cnt; // inode数量
u32 lba_base; // 分区起始LBA
u32 block_bitmap_lba; // 块位图起始扇区
u32 inode_bitmap_lba; // inode位图起始扇区
u32 inode_table_lba; // inode表起始扇区
u32 data_start_lba; // 数据区起始扇区
u32 root_inode_no; // 根目录inode号
u32 dir_entry_size; // 目录项大小
};inode结构与空闲块管理
每个文件或目录对应一个 inode,记录文件大小和数据块位置。我用 12 个直接块加 1 个一级间接块,最大文件大小是 $(12 + 128) \times 512 = 71680$ 字节。
c++
struct inode {
u32 no; // inode编号
u32 size; // 文件大小
u32 open_cnts; // 打开次数
u32 sectors[13]; // 数据块索引
};空闲块和空闲 inode 都用位图管理。位为 0 表示空闲,为 1 表示已用。分配时扫描位图找第一个 0 位,释放时把对应位置 0。
目录结构与文件操作
目录本质是特殊文件,内容是一串目录项。每个目录项包含文件名、inode 号和类型。每个目录都有 "." 和 ".." 两个特殊条目。
c++
struct dir_entry {
char filename[16]; // 文件名
u32 inode_no; // 对应inode号
file_type type; // 类型
};文件操作方面,我实现了 open、close、read、write、lseek、unlink 这些基本接口,还有 mkdir、rmdir、opendir、readdir、closedir 来操作目录。
系统调用与Shell
这一节讲系统调用机制和 Shell 的实现。
系统调用机制
用户程序通过 int 0x80 软中断进入内核。系统调用号放 eax,参数放 ebx、ecx、edx。内核根据调用号查表执行对应处理函数,返回值通过 eax 传回用户程序。
c++
template<typename... Args>
auto syscall(int num, Args... args) -> int {
int ret;
asm volatile(
"int $0x80"
: "=a"(ret)
: "a"(num), "b"(args...[0]), ...
: "memory"
);
return ret;
}syscall表
我实现了二十多个系统调用,覆盖进程管理(getpid、fork、exec、wait、exit)、内存管理(malloc、free)、文件操作(open、close、read、write、lseek、unlink)、目录操作(mkdir、rmdir、opendir、readdir、chdir)、控制台(putchar、clear)等。
Shell实现
Shell 是个运行在用户态的命令解释器。它读取用户输入,解析命令行,然后执行。内置命令(cd、pwd、ls、mkdir、rmdir、rm、ps、clear)直接在 Shell 进程里处理。外部程序则通过 fork() 创建子进程,子进程调用 exec() 加载执行,父进程调用 wait() 等子进程结束。
c++
// Shell主循环
while(true) {
prompt();
readline(cmd_line);
argc = cmd_parse(cmd_line, argv, ' ');
if(is_builtin(argv[0])) {
run_builtin(argc, argv);
} else {
if(fork() == 0) {
exec(argv[0], argv);
} else {
wait(status);
}
}
}