Skip to content

内核整体概述

本章将从宏观角度介绍内核的整体架构,帮助读者建立对整个系统的全局理解。我们将依次探讨启动流程、内存管理、中断系统、进程与线程、设备驱动、磁盘结构、文件系统以及系统调用与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(0x000000000xBFFFFFFF)给用户进程用,高 1GB(0xC00000000xFFFFFFFF)留给内核。内核空间会映射到所有进程的页表里,这样内核代码在任意进程上下文中都能访问。

页目录表放在物理地址 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;       // 类型
};

文件操作方面,我实现了 openclosereadwritelseekunlink 这些基本接口,还有 mkdirrmdiropendirreaddirclosedir 来操作目录。

系统调用与Shell

这一节讲系统调用机制和 Shell 的实现。

系统调用机制

用户程序通过 int 0x80 软中断进入内核。系统调用号放 eax,参数放 ebxecxedx。内核根据调用号查表执行对应处理函数,返回值通过 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);
        }
    }
}