第5章 内核层核心实现

操作系统内核是整个系统的大脑和心脏。在上一章中,BootLoader已经完成了从实模式到64位长模式的切换,并将内核代码加载到了内存中的指定位置。现在,舞台交给了内核。

从这一刻起,所有的底层硬件管理、资源调度、异常处理、设备驱动,全部由内核代码来接管。没有BIOS可以依赖,没有任何外部库可以调用。你所拥有的只有CPU、内存和一堆等待被驱动的硬件设备。内核必须从零开始,一砖一瓦地搭建起整个操作系统运行的基础设施。

本章将从内核启动的第一条指令开始,依次讲解屏幕显示、异常处理、内存管理、中断处理、键盘驱动和进程管理等核心模块的设计与实现。每一个模块都是操作系统不可缺少的组成部分,它们相互配合,共同构成了一个可以真正运行程序的操作系统内核。


5.1 内核执行头部程序

5.1.1 内核执行头部程序的定义与作用

当Loader完成模式切换并跳转到内核入口时,CPU已经处于64位长模式。但此时的运行环境仍然非常原始——Loader虽然建立了一个最基本的页表让CPU能够正常运行,但这个页表映射的范围很小,只覆盖了低端几MB的物理内存。内核如果被链接到高地址空间(绝大多数操作系统内核都是这样做的),那么当前的页表根本无法支撑内核的正常运行。

这就是内核执行头部程序存在的理由。它是内核被加载后最先执行的一段代码,通常用汇编语言编写,主要任务是为内核的C语言主体代码准备好运行环境。你可以把它理解为内核的"前奏曲"——在正式的交响乐开始之前,需要先把乐器调好音。

内核执行头部程序的具体职责包括:

第一,重新设置页表。Loader建立的临时页表通常比较粗糙,只是为了让CPU能够在长模式下运行而做的最小化映射。内核执行头部程序需要建立一套更完善的页表,通常包括将内核虚拟地址映射到正确的物理地址、建立高地址空间到物理内存的映射等。很多操作系统内核被链接到虚拟地址空间的高半部分(比如Linux的内核虚拟地址从0xFFFF800000000000开始,Windows的内核空间也在高地址),这就要求页表中有对应的映射条目。

第二,重新加载GDT和IDT(中断描述符表)。Loader阶段设置的GDT可能不够完善,内核需要自己的GDT来定义所需的段描述符。IDT在这个阶段通常先设置为空或者只包含最基本的异常处理入口,后续在内核初始化过程中再逐步完善。

第三,初始化栈。C语言代码的运行离不开栈——函数调用、局部变量、参数传递都依赖栈。内核执行头部程序需要设置RSP寄存器,指向一块预先分配好的内核栈空间。

第四,清零BSS段。C语言规定未初始化的全局变量和静态变量存放在BSS段中,它们的初始值应该为零。在ELF文件中,BSS段不占用文件空间(因为内容全是零,没必要存储),但在内存中需要分配空间并清零。这个清零工作由内核执行头部程序来完成。

第五,调用内核主函数。当所有准备工作完成后,内核执行头部程序通过一条CALL指令跳转到用C语言编写的内核主函数(通常叫做kernel_main或者start_kernel),正式开始内核的初始化流程。

Linux内核中对应的文件是 arch/x86/kernel/head_64.S。这个文件包含了64位Linux内核启动时最先执行的汇编代码。它的工作内容与上面描述的基本一致——设置页表、加载GDT、初始化栈,最终跳转到C语言的 x86_64_start_kernel() 函数。Windows内核的启动过程虽然不开源,但从公开的资料来看,ntoskrnl.exe的入口点也是一段汇编代码,完成类似的初始化工作后跳转到 KiSystemStartup() 这个C语言函数。

FreeBSD的内核启动也有类似的头部代码,位于 sys/amd64/amd64/locore.S。可以看到,无论是哪个操作系统,内核执行头部程序的角色都是一样的——充当汇编世界和C语言世界之间的桥梁。

5.1.2 动手编写内核执行头部程序

下面我们来实际编写这段代码。首先要明确几个前提:内核以ELF格式编译,被加载到物理内存的某个位置(比如0x100000,即1MB处);内核的链接地址(虚拟地址)可能与加载地址(物理地址)不同。

一个基本的内核执行头部程序框架如下:

nasm

[bits 64]

section .text
global _start

extern kernel_main      ; 声明外部的C语言入口函数

_start:
    ; 加载新的GDT
    lgdt [gdt64_pointer]

    ; 重新加载段寄存器
    mov ax, 0x10         ; 数据段选择子
    mov ds, ax
    mov es, ax
    mov fs, ax
    mov gs, ax
    mov ss, ax

    ; 设置内核栈
    mov rsp, kernel_stack_top

    ; 清零BSS段
    mov rdi, bss_start
    mov rcx, bss_size
    xor al, al
    rep stosb

    ; 设置页表(建立完善的内存映射)
    call setup_page_table

    ; 重新加载CR3
    mov rax, pml4_table
    mov cr3, rax

    ; 调用内核主函数
    call kernel_main

    ; 如果kernel_main返回了(正常情况下不应该返回),进入死循环
.halt:
    hlt
    jmp .halt

其中几个关键部分需要详细说明。

GDT的重新设置。 虽然Loader已经设置了一个GDT,但那个GDT是属于Loader的。内核应该使用自己的GDT,因为后续可能需要添加TSS(任务状态段)描述符、调用门描述符等。内核的GDT通常定义在内核的数据段中:

nasm

section .data

align 16
gdt64_table:
    dq 0                            ; 空描述符
    ; 内核代码段 (选择子 0x08)
    dq 0x0020980000000000           ; L=1, P=1, DPL=0, Type=Code Execute/Read
    ; 内核数据段 (选择子 0x10)
    dq 0x0000920000000000           ; P=1, DPL=0, Type=Data Read/Write
    ; 用户代码段 (选择子 0x18,后续用户态进程需要)
    dq 0x0020F80000000000           ; L=1, P=1, DPL=3, Type=Code Execute/Read
    ; 用户数据段 (选择子 0x20)
    dq 0x0000F20000000000           ; P=1, DPL=3, Type=Data Read/Write

gdt64_pointer:
    dw $ - gdt64_table - 1          ; GDT大小
    dq gdt64_table                  ; GDT基地址

注意用户代码段和用户数据段的DPL(描述符特权级)是3,这是为后续实现用户态进程做准备的。在64位长模式下,段描述符的基地址和限长大部分被忽略,但DPL仍然有效,CPU在进行特权级检查时会用到它。

栈的设置。 内核栈可以在BSS段中预留一块空间:

nasm

section .bss
align 16
kernel_stack:
    resb 32768          ; 32KB的内核栈空间
kernel_stack_top:       ; 栈从高地址向低地址增长,所以栈顶在高地址端

32KB对于内核的初始栈来说通常足够了。Linux内核的默认栈大小是16KB(可以配置为8KB),Windows内核线程的默认栈大小是12KB。内核栈不宜太大,因为每个线程或进程都需要自己的内核栈,过大的栈会浪费宝贵的内核内存。

BSS段的清零。 在链接脚本中,我们可以定义BSS段的起始地址和大小,然后在头部代码中用REP STOSB指令快速清零。链接脚本的相关部分:

ld

SECTIONS {
    .text 0xFFFF800000100000 : AT(0x100000) {
        *(.text)
    }
    .data : {
        *(.data)
    }
    .bss : {
        bss_start = .;
        *(.bss)
        *(COMMON)
        bss_end = .;
    }
    bss_size = bss_end - bss_start;
}

这个链接脚本中有一个重要的细节——.text 段的虚拟地址是 0xFFFF800000100000,但AT指令指定了加载地址(物理地址)是 0x100000。这就是所谓的"高半核"(Higher Half Kernel)布局:内核的代码在虚拟地址空间中位于高地址,但实际上被加载到物理内存的低地址。页表中的映射负责把虚拟地址翻译到正确的物理地址。

页表的设置。 这是头部程序中最复杂的部分。我们需要建立的映射至少包括:

  1. 恒等映射(Identity Mapping):虚拟地址 = 物理地址。这在页表切换的过渡期间是必要的——如果当前执行的代码所在的虚拟地址在新页表中没有映射,CPU会立刻触发Page Fault。
  2. 高地址映射:将内核的虚拟地址(如0xFFFF800000000000开始的地址)映射到对应的物理地址。
  3. 显存映射:VGA文本模式的显存在物理地址0xB8000,在新的页表中也需要能够访问到。

nasm

setup_page_table:
    ; 清零页表区域
    mov rdi, pml4_table
    xor rax, rax
    mov rcx, 512 * 4        ; 4个页表,每个512个条目
    rep stosq

    ; PML4[0] -> PDPT_low  (恒等映射)
    mov qword [pml4_table], pdpt_low | 3

    ; PML4[256] -> PDPT_high  (高地址映射,0xFFFF800000000000对应PML4索引256)
    mov qword [pml4_table + 256*8], pdpt_high | 3

    ; PDPT_low[0] -> PD_low
    mov qword [pdpt_low], pd_low | 3

    ; PDPT_high[0] -> PD_high
    mov qword [pdpt_high], pd_high | 3

    ; PD中用2MB大页建立映射
    mov rcx, 0              ; 计数器
    mov rdi, pd_low
    mov rsi, pd_high
.map_pages:
    mov rax, rcx
    shl rax, 21             ; rax = rcx * 2MB
    or rax, 0x83            ; Present + Read/Write + Page Size (2MB)
    mov [rdi + rcx*8], rax
    mov [rsi + rcx*8], rax
    inc rcx
    cmp rcx, 512            ; 映射512个2MB页 = 1GB
    jne .map_pages

    ret

这段代码映射了低端1GB的物理内存,既建立了恒等映射(通过PML4[0]),也建立了从0xFFFF800000000000开始的高地址映射(通过PML4[256])。映射完成后,无论使用低地址还是高地址都能访问到同一块物理内存。

为什么选择PML4[256]?在64位长模式下,虚拟地址是48位有效的(实际上是48位,高16位必须是第47位的符号扩展)。PML4表有512个条目(索引0到511),每个条目覆盖512GB的虚拟地址空间。条目0覆盖0x0000000000000000到0x0000007FFFFFFFFF,条目256覆盖0xFFFF800000000000到0xFFFF807FFFFFFFFF(注意高16位的符号扩展)。所以PML4[256]正是高半核地址空间的起始位置。

页表设置完成并重新加载CR3后,内核代码就可以使用高地址来运行了。此时可以安全地移除恒等映射(PML4[0]),把低端虚拟地址空间留给用户态进程使用。但在初始化阶段保留恒等映射通常是个好主意,等内核完全初始化完毕后再做清理。


5.2 内核主体程序

内核执行头部程序完成环境准备后,通过CALL指令跳转到C语言编写的内核主函数。这是内核的"大脑中枢"——所有子系统的初始化都在这里统一调度。

c

void kernel_main(void)
{
    // 初始化屏幕显示
    console_init();
    printk("Kernel starting...\n");

    // 初始化GDT(可能需要更完善的GDT,包含TSS等)
    gdt_init();
    printk("GDT initialized.\n");

    // 初始化IDT和异常处理
    idt_init();
    printk("IDT initialized.\n");

    // 初始化内存管理
    memory_init();
    printk("Memory manager initialized.\n");

    // 初始化中断控制器
    pic_init();
    printk("Interrupt controller initialized.\n");

    // 初始化键盘驱动
    keyboard_init();
    printk("Keyboard driver initialized.\n");

    // 初始化进程管理
    process_init();
    printk("Process manager initialized.\n");

    // 启用中断
    sti();

    // 进入idle循环
    while (1) {
        hlt();
    }
}

这个函数的结构反映了操作系统内核初始化的一般顺序。初始化顺序不是随意的——存在严格的依赖关系。比如,在IDT初始化完成之前不能启用中断,否则任何硬件中断都会导致CPU找不到处理程序而触发双重故障甚至三重故障。内存管理初始化需要先获取硬件信息(这些信息由Loader在实模式下通过BIOS中断收集),所以要在console初始化之后(方便输出调试信息)但在需要动态内存分配的模块之前完成。

Linux内核的主函数叫做 start_kernel(),定义在 init/main.c 中。打开这个文件,你会看到一长串初始化函数的调用——setup_arch()mm_init()sched_init()init_IRQ()time_init() 等等,有几十个之多。每个函数都负责初始化内核的一个子系统。整个初始化流程走完后,start_kernel() 最后会调用 rest_init(),创建init进程(PID 1)和kthreadd进程(PID 2),然后当前执行流变成idle进程(PID 0)。

Windows内核的 KiSystemStartup() 做的事情也是类似的:初始化HAL(硬件抽象层)、初始化内存管理器、初始化对象管理器、初始化进程管理器、初始化I/O管理器等。最终创建smss.exe(Session Manager Subsystem)进程,由它来继续启动系统的其他部分。

我们自己的内核主函数虽然简单得多,但骨架是一样的。接下来的各节将依次实现上面列出的各个子系统。


5.3 屏幕输出功能

操作系统内核在初始化过程中需要向开发者报告各种状态信息——哪些硬件被检测到了,内存有多大,各个子系统的初始化是否成功。在图形界面启动之前,这些信息都需要通过文本模式显示在屏幕上。

5.3.1 在屏幕上呈现色彩

在引导过程中,我们已经用过VGA文本模式的显存来显示字符。现在需要把这个功能封装成一个正式的屏幕输出模块。

VGA文本模式的显存起始于物理地址0xB8000。如果内核使用高地址映射,那么在内核代码中访问显存时需要加上虚拟地址偏移。假设物理地址0xB8000被映射到虚拟地址0xFFFF8000000B8000,那么内核代码中就要通过这个虚拟地址来访问显存。

VGA文本模式的显存布局非常直观:每个字符占2个字节,第一个字节是ASCII字符码,第二个字节是属性字节。属性字节的格式如下:

位 7      : 闪烁位(或者高亮背景色的高位,取决于VGA寄存器的设置)
位 6-4    : 背景色(3位,8种颜色)
位 3-0    : 前景色(4位,16种颜色)

16种前景色的编码如下:

ini

0x0 = 黑色       0x8 = 深灰色
0x1 = 蓝色       0x9 = 亮蓝色
0x2 = 绿色       0xA = 亮绿色
0x3 = 青色       0xB = 亮青色
0x4 = 红色       0xC = 亮红色
0x5 = 洋红色     0xD = 亮洋红色
0x6 = 棕色       0xE = 黄色
0x7 = 浅灰色     0xF = 白色

背景色只有8种(0到7,与前景色的低4种相同)。

知道了显存的格式,就可以编写在指定位置显示指定颜色字符的函数了:

c

#define SCREEN_WIDTH  80
#define SCREEN_HEIGHT 25
#define VRAM_BASE     0xFFFF8000000B8000UL

// 属性字节的构造宏
#define MAKE_COLOR(bg, fg) (((bg) << 4) | (fg))

// 在指定位置显示字符
void put_char(int x, int y, char ch, unsigned char color)
{
    unsigned short *vram = (unsigned short *)VRAM_BASE;
    int offset = y * SCREEN_WIDTH + x;
    vram[offset] = (unsigned short)((color << 8) | (unsigned char)ch);
}

// 清屏
void clear_screen(unsigned char color)
{
    unsigned short *vram = (unsigned short *)VRAM_BASE;
    unsigned short blank = (unsigned short)((color << 8) | ' ');
    for (int i = 0; i < SCREEN_WIDTH * SCREEN_HEIGHT; i++) {
        vram[i] = blank;
    }
}

这里有一个技巧——显存中每个位置存储的是一个16位(unsigned short)的值,低字节是字符码,高字节是属性。直接把属性左移8位再与字符码进行或运算,一次赋值就能同时设置字符和颜色。

在真实的操作系统中,VGA文本模式只是最基础的显示方式。现代操作系统在启动后很快就会切换到图形模式——Linux通常在内核启动阶段就通过framebuffer驱动切换到图形模式,Windows更是在启动过程的早期就开始显示图形化的启动动画。但对于学习操作系统开发来说,VGA文本模式已经完全够用了,而且它的简单性让我们可以把注意力集中在操作系统的核心概念上。

值得一提的是,Bochs和QEMU都很好地模拟了VGA文本模式,但如果你想在真实硬件上运行自己的操作系统,可能会遇到问题——有些现代显卡已经不再支持VGA兼容模式。在这种情况下,你需要使用UEFI提供的GOP(Graphics Output Protocol)或者通过VBE(VESA BIOS Extensions)来获取一个framebuffer,然后自己在framebuffer上绘制字符。这比VGA文本模式复杂得多,但原理是类似的。

5.3.2 在屏幕上输出日志信息

有了基本的字符显示功能后,接下来要实现一个更高级的日志输出系统。内核需要一个类似于C标准库中 printf 函数的功能来格式化输出各种信息。在Linux内核中,这个函数叫做 printk

实现一个完整的 printf 是相当复杂的工作——需要解析格式化字符串,处理各种转换说明符(%d、%x、%s、%p等),处理宽度、精度、对齐等格式选项。对于内核的初始阶段,我们可以先实现一个简化版本,只支持最常用的几种格式:

c

#include <stdarg.h>

// 当前光标位置
static int cursor_x = 0;
static int cursor_y = 0;
static unsigned char default_color = MAKE_COLOR(0x0, 0x7);  // 黑底灰字

// 屏幕滚动
static void scroll_screen(void)
{
    unsigned short *vram = (unsigned short *)VRAM_BASE;

    // 将第1行到第24行的内容上移一行
    for (int i = 0; i < (SCREEN_HEIGHT - 1) * SCREEN_WIDTH; i++) {
        vram[i] = vram[i + SCREEN_WIDTH];
    }

    // 清除最后一行
    unsigned short blank = (unsigned short)((default_color << 8) | ' ');
    for (int i = (SCREEN_HEIGHT - 1) * SCREEN_WIDTH;
         i < SCREEN_HEIGHT * SCREEN_WIDTH; i++) {
        vram[i] = blank;
    }

    cursor_y = SCREEN_HEIGHT - 1;
}

// 输出单个字符(带光标管理)
void console_putchar(char ch)
{
    if (ch == '\n') {
        cursor_x = 0;
        cursor_y++;
    } else if (ch == '\r') {
        cursor_x = 0;
    } else if (ch == '\t') {
        cursor_x = (cursor_x + 8) & ~7;  // 对齐到8的倍数
    } else {
        put_char(cursor_x, cursor_y, ch, default_color);
        cursor_x++;
    }

    // 自动换行
    if (cursor_x >= SCREEN_WIDTH) {
        cursor_x = 0;
        cursor_y++;
    }

    // 屏幕滚动
    if (cursor_y >= SCREEN_HEIGHT) {
        scroll_screen();
    }

    // 更新硬件光标(可选)
    update_cursor(cursor_x, cursor_y);
}

// 输出字符串
void console_puts(const char *str)
{
    while (*str) {
        console_putchar(*str++);
    }
}

屏幕滚动的实现是将显存中第1行到最后一行的内容整体向上移动一行,然后清空最后一行。这在VGA文本模式下非常高效——只需要移动4000字节的数据(80 * 25 * 2 = 4000,减去最后一行的160字节,实际移动3840字节)。

更新硬件光标需要通过VGA的I/O端口来操作。VGA光标位置由两个8位寄存器组成(高8位和低8位),通过端口0x3D4(索引端口)和0x3D5(数据端口)来访问:

c

void update_cursor(int x, int y)
{
    unsigned short pos = y * SCREEN_WIDTH + x;
    outb(0x3D4, 0x0F);           // 选择光标位置低8位寄存器
    outb(0x3D5, pos & 0xFF);
    outb(0x3D4, 0x0E);           // 选择光标位置高8位寄存器
    outb(0x3D5, (pos >> 8) & 0xFF);
}

有了字符输出功能,就可以实现 printk 了。以下是一个简化版本:

c

// 将无符号整数转换为字符串
static void itoa(unsigned long value, char *buf, int base, int is_signed)
{
    char digits[] = "0123456789abcdef";
    char tmp[64];
    int i = 0;
    int negative = 0;

    if (is_signed && (long)value < 0) {
        negative = 1;
        value = -(long)value;
    }

    if (value == 0) {
        tmp[i++] = '0';
    } else {
        while (value > 0) {
            tmp[i++] = digits[value % base];
            value /= base;
        }
    }

    int j = 0;
    if (negative) buf[j++] = '-';
    while (i > 0) buf[j++] = tmp[--i];
    buf[j] = '\0';
}

void printk(const char *fmt, ...)
{
    va_list args;
    va_start(args, fmt);

    char buf[256];

    while (*fmt) {
        if (*fmt != '%') {
            console_putchar(*fmt++);
            continue;
        }
        fmt++;  // 跳过 '%'

        switch (*fmt) {
            case 'd': {
                long val = va_arg(args, long);
                itoa(val, buf, 10, 1);
                console_puts(buf);
                break;
            }
            case 'u': {
                unsigned long val = va_arg(args, unsigned long);
                itoa(val, buf, 10, 0);
                console_puts(buf);
                break;
            }
            case 'x': {
                unsigned long val = va_arg(args, unsigned long);
                console_puts("0x");
                itoa(val, buf, 16, 0);
                console_puts(buf);
                break;
            }
            case 's': {
                char *str = va_arg(args, char *);
                console_puts(str ? str : "(null)");
                break;
            }
            case 'c': {
                char ch = (char)va_arg(args, int);
                console_putchar(ch);
                break;
            }
            case '%': {
                console_putchar('%');
                break;
            }
            default:
                console_putchar('%');
                console_putchar(*fmt);
                break;
        }
        fmt++;
    }

    va_end(args);
}

printk 是内核开发中最重要的调试工具。Linux内核的 printk 比上面的版本复杂得多——它支持日志级别(KERN_EMERG、KERN_ALERT、KERN_CRIT等8个级别),支持环形缓冲区(dmesg命令显示的就是这个缓冲区的内容),支持格式化输出内核特有的数据类型(比如 %pK 输出内核指针、%pI4 输出IPv4地址等)。

Linux的 printk 还有一个著名的性能问题——在某些情况下,printk 会尝试将日志同步输出到串口或控制台,这可能会阻塞调用者相当长的时间。在高频调用 printk 的场景下(比如网络数据包处理中的调试输出),这会严重影响系统性能。为了解决这个问题,Linux内核后来引入了 printk_deferred 等变体。

对于我们自己的内核来说,暂时不需要考虑这些高级特性。一个能正确格式化输出字符串和数字的 printk 就足以支撑内核开发的需要了。

除了 printk,很多操作系统内核还支持通过串口输出日志。串口输出的好处是可以在另一台电脑上接收日志信息,即使显示器出了问题或者系统崩溃了,串口日志可能仍然是完整的。Bochs和QEMU都支持将虚拟串口重定向到宿主机的终端或文件中,这在调试内核时非常有用。Linux内核启动参数中的 console=ttyS0,115200 就是把控制台输出重定向到串口的意思。


5.4 系统异常机制

异常(Exception)是CPU在执行指令时遇到特殊情况时产生的同步事件。与中断(Interrupt)不同,异常是由CPU内部产生的,而中断是由外部硬件设备产生的。正确处理异常对于操作系统的稳定性至关重要——一个未被处理的异常可能导致整个系统崩溃。

5.4.1 异常的类型划分

x86-64架构定义了256个中断和异常向量(编号0到255)。其中前32个(0到31)被Intel保留用于CPU异常,其余的(32到255)可以用于外部中断和软件中断。

CPU异常按照严重程度和处理方式分为三类:

故障(Fault) 是一种可以被修正的异常。当故障发生时,CPU会保存产生故障的那条指令的地址(而不是下一条指令),然后跳转到异常处理程序。如果处理程序能够修正故障的原因(比如缺页故障时分配物理页面并建立映射),那么从异常处理程序返回后,CPU会重新执行那条导致故障的指令。

最典型的故障是Page Fault(#PF,向量号14)。当程序访问一个在页表中没有映射的虚拟地址时,CPU产生缺页故障。操作系统的缺页处理程序检查这个访问是否合法(比如是否在进程的虚拟地址空间范围内),如果合法就分配物理页面、建立映射、然后返回让CPU重试。如果不合法,就向进程发送信号(Linux中是SIGSEGV,也就是著名的"段错误")。

Windows中的虚拟内存管理也严重依赖缺页故障。Windows的内存管理器在分配虚拟内存时(VirtualAlloc),通常不会立即分配物理页面,而是标记这些虚拟地址为"已提交但未分配物理页"。当程序首次访问这些地址时触发缺页故障,内存管理器才真正分配物理页面。这种"按需分配"(demand paging)策略大大节省了物理内存。

陷阱(Trap) 也是一种异常,但它与故障的区别是:CPU保存的是产生陷阱的指令的下一条指令的地址。从陷阱处理程序返回后,CPU会继续执行下一条指令,而不是重新执行导致陷阱的指令。

最常见的陷阱是INT 3(断点,向量号3)和INTO(溢出检查,向量号4)。调试器使用INT 3来实现断点功能——调试器把目标指令的第一个字节替换为0xCC(INT 3的机器码),当CPU执行到这里时产生陷阱,调试器获得控制权,可以检查程序状态,然后把原来的指令恢复回去,让程序继续执行。这就是为什么GDB中 break 命令能让程序在指定位置停下来。

中止(Abort) 是最严重的异常,表示遇到了无法恢复的错误。最典型的中止是Double Fault(#DF,向量号8)——当CPU在处理一个异常时又遇到了另一个异常,而且这两个异常的组合无法被串行处理时,就会产生双重故障。如果在处理双重故障时再次发生异常,CPU就会触发Triple Fault(三重故障),此时CPU会直接复位——也就是电脑重启。这就是为什么内核中的严重错误可能导致系统突然重启而不是显示错误信息。

以下是x86-64中最重要的几个异常向量:

basic

向量号  名称                  类型    助记符
0       除法错误              故障    #DE
1       调试异常              故障/陷阱 #DB
2       NMI中断              -       -
3       断点                  陷阱    #BP
4       溢出                  陷阱    #OF
5       边界检查超出          故障    #BR
6       无效操作码            故障    #UD
7       设备不可用            故障    #NM
8       双重故障              中止    #DF
10      无效TSS               故障    #TS
11      段不存在              故障    #NP
12      栈段故障              故障    #SS
13      一般保护故障          故障    #GP
14      缺页故障              故障    #PF
16      x87浮点异常           故障    #MF
17      对齐检查              故障    #AC
18      机器检查              中止    #MC
19      SIMD浮点异常          故障    #XM

General Protection Fault(#GP,向量号13)是一个"万能"的故障——当CPU检测到各种保护性违规时都会产生#GP。比如访问不存在的I/O端口、在用户态执行特权指令、加载无效的段选择子等。在调试内核代码时,#GP是最常遇到的异常之一,因为它的触发条件太多了,有时候很难判断到底是什么原因导致的。好在CPU在产生#GP时会在栈上压入一个错误码(error code),错误码中包含了导致故障的段选择子信息,可以帮助定位问题。

5.4.2 系统异常处理机制(上)

要处理CPU异常,首先需要设置IDT(Interrupt Descriptor Table,中断描述符表)。IDT的作用与GDT类似,但它定义的是中断和异常的处理程序入口。

在64位长模式下,IDT中的每个描述符占16字节(比32位保护模式的8字节描述符大了一倍),格式如下:

apache

字节 0-1   : 处理程序偏移量 (位0-15)
字节 2-3   : 代码段选择子
字节 4     : IST (Interrupt Stack Table) 索引 (低3位)
字节 5     : 类型和属性
             位 7    : Present
             位 6-5  : DPL
             位 4    : 0 (固定)
             位 3-0  : 类型 (0xE=中断门, 0xF=陷阱门)
字节 6-7   : 处理程序偏移量 (位16-31)
字节 8-11  : 处理程序偏移量 (位32-63)
字节 12-15 : 保留 (必须为0)

中断门和陷阱门的区别:当CPU通过中断门进入处理程序时,会自动清除RFLAGS中的IF标志(禁用中断);通过陷阱门进入时,IF标志保持不变。对于大多数异常处理程序,使用中断门更安全,因为它防止了在处理异常时被另一个中断打断。

下面是IDT的设置代码:

c

// IDT描述符结构(16字节)
struct idt_entry {
    unsigned short offset_low;      // 偏移量低16位
    unsigned short selector;        // 代码段选择子
    unsigned char ist;              // IST索引
    unsigned char type_attr;        // 类型和属性
    unsigned short offset_mid;      // 偏移量中16位
    unsigned int offset_high;       // 偏移量高32位
    unsigned int reserved;          // 保留
} __attribute__((packed));

// IDT指针结构
struct idt_ptr {
    unsigned short limit;
    unsigned long base;
} __attribute__((packed));

// IDT表,256个条目
static struct idt_entry idt[256];
static struct idt_ptr idtr;

// 设置IDT条目
void set_idt_entry(int vector, void *handler, unsigned short selector,
                   unsigned char ist, unsigned char type_attr)
{
    unsigned long addr = (unsigned long)handler;
    idt[vector].offset_low  = addr & 0xFFFF;
    idt[vector].selector    = selector;
    idt[vector].ist         = ist;
    idt[vector].type_attr   = type_attr;
    idt[vector].offset_mid  = (addr >> 16) & 0xFFFF;
    idt[vector].offset_high = (addr >> 32) & 0xFFFFFFFF;
    idt[vector].reserved    = 0;
}

// 初始化IDT
void idt_init(void)
{
    // 注册异常处理程序
    set_idt_entry(0,  divide_error_handler,  0x08, 0, 0x8E);  // 中断门, DPL=0
    set_idt_entry(1,  debug_handler,         0x08, 0, 0x8E);
    set_idt_entry(2,  nmi_handler,           0x08, 0, 0x8E);
    set_idt_entry(3,  breakpoint_handler,    0x08, 0, 0xEE);  // 陷阱门, DPL=3
    set_idt_entry(6,  invalid_opcode_handler,0x08, 0, 0x8E);
    set_idt_entry(8,  double_fault_handler,  0x08, 1, 0x8E);  // 使用IST 1
    set_idt_entry(13, general_protection_handler, 0x08, 0, 0x8E);
    set_idt_entry(14, page_fault_handler,    0x08, 0, 0x8E);
    // ... 其他异常处理程序

    // 加载IDT
    idtr.limit = sizeof(idt) - 1;
    idtr.base  = (unsigned long)&idt;
    __asm__ volatile ("lidt %0" :: "m"(idtr));
}

注意几个细节:

断点异常(向量3)的DPL设置为3。这意味着用户态程序也可以触发这个异常(通过INT 3指令)。如果DPL设置为0,那么用户态程序执行INT 3时会触发#GP而不是#BP,调试器就无法正常工作了。

双重故障处理程序使用了IST 1。IST(Interrupt Stack Table)是x86-64新增的机制,允许某些中断/异常使用独立的栈。这对于双重故障特别重要——双重故障通常是因为栈出了问题(比如栈溢出导致栈空间不足),如果处理程序还使用相同的栈,那么它也会失败,导致三重故障。使用IST可以让双重故障处理程序使用一个预先分配好的独立栈,避免这个问题。IST的栈地址在TSS(任务状态段)中设置。

0x8E表示中断门(类型0xE),Present=1,DPL=0。0xEE表示陷阱门(类型0xE... 等一下,其实0xEE = 1110 1110,Present=1,DPL=3,类型=0xE=中断门)。如果要用陷阱门,类型字段应该是0xF,即0xEF或0x8F。这里的具体值取决于你是否希望在进入处理程序时自动禁用中断。

5.4.3 系统异常处理机制(下)

IDT只定义了处理程序的入口地址,但处理程序本身还需要编写。异常处理程序通常分为两部分:一个汇编语言的"桩"(stub)负责保存CPU上下文并调用C语言的处理函数,处理函数完成后汇编桩恢复上下文并返回。

为什么需要汇编桩?因为当CPU跳转到异常处理程序时,它只保存了CS、RIP、RFLAGS、SS和RSP(对于从用户态到内核态的切换还会切换栈),以及可选的错误码。通用寄存器(RAX、RBX等)、段寄存器等都没有保存。如果C语言处理函数使用了这些寄存器(编译器肯定会使用),原来的值就丢失了。所以汇编桩需要在调用C函数之前把所有寄存器压栈保存,在C函数返回后再恢复。

nasm

; 异常处理桩的模板
; 对于有错误码的异常
%macro EXCEPTION_HANDLER_WITH_ERRCODE 1
global exception_%1_stub
exception_%1_stub:
    ; CPU已经压入了错误码
    push rax
    push rbx
    push rcx
    push rdx
    push rsi
    push rdi
    push rbp
    push r8
    push r9
    push r10
    push r11
    push r12
    push r13
    push r14
    push r15

    mov rdi, rsp            ; 第一个参数:指向保存的寄存器结构体
    mov rsi, %1             ; 第二个参数:异常向量号
    call exception_handler  ; 调用C语言处理函数

    pop r15
    pop r14
    pop r13
    pop r12
    pop r11
    pop r10
    pop r9
    pop r8
    pop rbp
    pop rdi
    pop rsi
    pop rdx
    pop rcx
    pop rbx
    pop rax
    add rsp, 8              ; 弹出错误码
    iretq                   ; 中断返回
%endmacro

; 对于没有错误码的异常,手动压入一个0作为占位
%macro EXCEPTION_HANDLER_WITHOUT_ERRCODE 1
global exception_%1_stub
exception_%1_stub:
    push 0                  ; 假的错误码(占位)
    push rax
    ; ... 同上
%endmacro

; 生成各个异常的处理桩
EXCEPTION_HANDLER_WITHOUT_ERRCODE 0     ; #DE 除法错误
EXCEPTION_HANDLER_WITHOUT_ERRCODE 1     ; #DB 调试
EXCEPTION_HANDLER_WITHOUT_ERRCODE 6     ; #UD 无效操作码
EXCEPTION_HANDLER_WITH_ERRCODE    8     ; #DF 双重故障
EXCEPTION_HANDLER_WITH_ERRCODE    13    ; #GP 一般保护故障
EXCEPTION_HANDLER_WITH_ERRCODE    14    ; #PF 缺页故障

有些异常会让CPU自动压入错误码(如#DF、#GP、#PF等),有些不会(如#DE、#UD等)。为了让所有异常处理程序的栈布局统一(方便C语言处理函数以统一的方式访问保存的寄存器),对于没有错误码的异常,我们手动压入一个0作为占位符。

C语言的异常处理函数可以这样编写:

c

// 保存的CPU上下文
struct exception_frame {
    unsigned long r15, r14, r13, r12, r11, r10, r9, r8;
    unsigned long rbp, rdi, rsi, rdx, rcx, rbx, rax;
    unsigned long error_code;
    unsigned long rip;
    unsigned long cs;
    unsigned long rflags;
    unsigned long rsp;
    unsigned long ss;
};

void exception_handler(struct exception_frame *frame, unsigned long vector)
{
    printk("\n!!! Exception %d occurred !!!\n", vector);
    printk("Error code: %x\n", frame->error_code);
    printk("RIP: %x\n", frame->rip);
    printk("CS:  %x\n", frame->cs);
    printk("RFLAGS: %x\n", frame->rflags);
    printk("RSP: %x\n", frame->rsp);
    printk("RAX: %x  RBX: %x\n", frame->rax, frame->rbx);
    printk("RCX: %x  RDX: %x\n", frame->rcx, frame->rdx);

    if (vector == 14) {
        // 对于缺页故障,CR2寄存器包含导致故障的虚拟地址
        unsigned long cr2;
        __asm__ volatile ("mov %%cr2, %0" : "=r"(cr2));
        printk("Page fault address (CR2): %x\n", cr2);
    }

    // 在当前的简单内核中,异常通常意味着不可恢复的错误
    printk("System halted.\n");
    while (1) {
        __asm__ volatile ("hlt");
    }
}

这个异常处理函数目前只是打印异常信息然后停机。在一个成熟的操作系统中,异常处理会复杂得多。比如Linux的缺页故障处理程序(do_page_fault 函数,位于 arch/x86/mm/fault.c)长达几百行,需要判断各种情况——是内核态还是用户态发生的缺页?访问的地址是否在进程的VMA(虚拟内存区域)范围内?是读访问还是写访问?写访问是否触发了COW(写时复制)?是否需要从交换分区读回页面?是否是栈的自动增长?每种情况的处理方式都不同。

Linux中当内核遇到无法恢复的异常时,会调用 panic() 函数。这个函数会打印错误信息和调用栈跟踪(backtrace),然后根据配置选择停机或者在一段时间后自动重启。用户态进程遇到异常(比如段错误)时,内核不会停机,而是向进程发送信号,默认行为是终止进程并生成core dump文件供事后分析。

Windows中等价的机制是蓝屏(BSOD,Blue Screen of Death)。当Windows内核遇到无法恢复的错误时,会调用 KeBugCheckEx() 函数,显示蓝色的错误界面,包含错误代码(如 IRQL_NOT_LESS_OR_EQUALPAGE_FAULT_IN_NONPAGED_AREA 等)和相关参数。这些错误代码对应着不同的异常情况,可以帮助工程师定位问题。Windows 10之后的蓝屏还会显示一个二维码,扫描后可以链接到微软的技术支持页面。

macOS中内核异常会导致kernel panic,表现为屏幕变暗并显示多语言的重启提示信息。macOS的kernel panic日志存储在NVRAM中,重启后可以通过系统偏好设置中的"系统报告"查看。


5.5 基础内存管理模块

内存管理是操作系统内核最基础也最重要的功能之一。没有内存管理,内核中的其他模块就无法动态分配内存来创建数据结构。一个好的内存管理器需要高效地跟踪哪些物理内存已经被使用、哪些是空闲的,并且能够快速地分配和释放内存页。

5.5.1 获取物理内存布局信息

在实现内存管理之前,首先需要知道系统中到底有多少物理内存以及这些内存的分布情况。这个信息是由Loader在实模式下通过BIOS中断INT 0x15 EAX=0xE820获取的,并存储在内存中的某个约定位置,供内核读取。

回顾一下,INT 0x15 EAX=0xE820返回的每个内存区域描述符包含以下信息:

c

struct e820_entry {
    unsigned long base_addr;    // 区域的物理起始地址(8字节)
    unsigned long length;       // 区域的长度(8字节)
    unsigned int type;          // 区域的类型(4字节)
    // 可选的扩展属性(4字节),不一定存在
};

type字段的取值及含义:

  • 1 = 可用内存(AddressRangeMemory)。操作系统可以自由使用的物理内存。
  • 2 = 保留内存(AddressRangeReserved)。系统保留,不可使用。可能被BIOS、ACPI表或其他固件占用。
  • 3 = ACPI可回收内存(AddressRangeACPI)。存放ACPI表的内存,操作系统读取完ACPI表后可以回收。
  • 4 = ACPI NVS内存(AddressRangeNVS)。ACPI使用的非易失性存储区域,不可使用。
  • 5 = 包含坏内存的区域(BadMemory)。硬件已知损坏的内存区域。

一个典型的物理内存映射可能如下所示:

apache

区域 0: 0x0000000000000000 - 0x000000000009FBFF  类型1 (可用, ~640KB)
区域 1: 0x000000000009FC00 - 0x000000000009FFFF  类型2 (保留, EBDA)
区域 2: 0x00000000000E0000 - 0x00000000000FFFFF  类型2 (保留, BIOS ROM)
区域 3: 0x0000000000100000 - 0x000000001FFFFFFF  类型1 (可用, ~511MB)
区域 4: 0x00000000FEC00000 - 0x00000000FEFFFFFF  类型2 (保留, APIC/IOAPIC)
区域 5: 0x00000000FFB00000 - 0x00000000FFFFFFFF  类型2 (保留, 高位BIOS ROM映射)

可以看到,物理内存并不是一段连续的可用空间。低端640KB是可用的(但前几KB被中断向量表、BIOS数据区等占用),640KB到1MB之间是一个"内存空洞"(memory hole),被显存、BIOS ROM等占据。1MB以上通常是大块的可用内存,但中间可能穿插着保留区域。在4GB附近,APIC寄存器和BIOS ROM被映射到物理地址空间中,占据了一部分地址。

内核在初始化内存管理器时,需要解析这个内存映射表,找出所有类型为1的可用内存区域,排除掉已经被内核代码和数据占据的部分,剩下的才是可以被内存管理器管理的空闲物理内存。

c

// 解析E820内存映射
void parse_memory_map(struct e820_entry *map, int count)
{
    printk("Physical memory map:\n");
    unsigned long total_memory = 0;
    unsigned long usable_memory = 0;

    for (int i = 0; i < count; i++) {
        printk("  %x - %x  type %d",
               map[i].base_addr,
               map[i].base_addr + map[i].length - 1,
               map[i].type);

        switch (map[i].type) {
            case 1: printk(" (usable)\n"); usable_memory += map[i].length; break;
            case 2: printk(" (reserved)\n"); break;
            case 3: printk(" (ACPI reclaimable)\n"); break;
            case 4: printk(" (ACPI NVS)\n"); break;
            default: printk(" (unknown)\n"); break;
        }
        total_memory = map[i].base_addr + map[i].length;
    }

    printk("Total address space: %d MB\n", total_memory / 1024 / 1024);
    printk("Usable memory: %d MB\n", usable_memory / 1024 / 1024);
}

5.5.2 统计可用物理内存页面数量

操作系统通常以页(Page)为单位管理物理内存。在x86-64架构上,标准页面大小是4KB(4096字节)。也支持2MB和1GB的大页,但基本的内存管理是以4KB页为粒度的。

统计可用的物理内存页面数量需要遍历E820内存映射,将所有可用内存区域的大小累加起来,然后除以页面大小:

c

#define PAGE_SIZE 4096

unsigned long count_available_pages(struct e820_entry *map, int count)
{
    unsigned long available_pages = 0;

    for (int i = 0; i < count; i++) {
        if (map[i].type == 1) {  // 可用内存
            // 将起始地址上对齐到页边界
            unsigned long start = (map[i].base_addr + PAGE_SIZE - 1) & ~(PAGE_SIZE - 1);
            // 将结束地址下对齐到页边界
            unsigned long end = (map[i].base_addr + map[i].length) & ~(PAGE_SIZE - 1);

            if (end > start) {
                available_pages += (end - start) / PAGE_SIZE;
            }
        }
    }

    return available_pages;
}

这里需要注意地址对齐的问题。内存区域的起始地址和长度不一定是页对齐的,所以在计算页面数量时需要进行对齐处理——起始地址向上对齐(确保不会包含区域外的地址),结束地址向下对齐(确保不会超出区域范围)。

统计出的页面数量只是一个初始值。在内核运行的过程中,还需要排除掉已经被使用的页面——内核代码和数据占据的页面、页表本身占据的页面、引导信息占据的页面等。这些页面虽然在E820报告中属于"可用内存",但实际上已经被使用了,不能再分配出去。

5.5.3 实现物理内存页面的分配

有了可用内存页面的信息后,就可以实现物理内存分配器了。最简单的分配方式是使用位图(Bitmap)。

位图方案的思想很简单:为每个物理页面分配一个位(bit),0表示空闲,1表示已使用。分配页面时在位图中找到一个为0的位,把它设置为1,然后返回对应的物理地址;释放页面时把对应的位设置为0。

c

// 位图
static unsigned char *page_bitmap;
static unsigned long total_pages;
static unsigned long bitmap_size;  // 位图占用的字节数

// 初始化位图
void page_allocator_init(unsigned long max_physical_addr)
{
    total_pages = max_physical_addr / PAGE_SIZE;
    bitmap_size = (total_pages + 7) / 8;  // 每个字节8个位

    // 位图需要存放在某个已知的位置
    // 这里假设放在内核BSS段之后
    page_bitmap = (unsigned char *)bitmap_start_addr;

    // 先把所有页面标记为已使用
    memset(page_bitmap, 0xFF, bitmap_size);

    // 然后根据E820信息,把可用内存区域中的页面标记为空闲
    // (但要跳过已被内核占用的页面)
    mark_available_pages();
}

// 标记某个页面为已使用
static void set_page_used(unsigned long page_index)
{
    page_bitmap[page_index / 8] |= (1 << (page_index % 8));
}

// 标记某个页面为空闲
static void set_page_free(unsigned long page_index)
{
    page_bitmap[page_index / 8] &= ~(1 << (page_index % 8));
}

// 检查某个页面是否空闲
static int is_page_free(unsigned long page_index)
{
    return !(page_bitmap[page_index / 8] & (1 << (page_index % 8)));
}

// 分配一个物理页面
unsigned long alloc_page(void)
{
    for (unsigned long i = 0; i < total_pages; i++) {
        if (is_page_free(i)) {
            set_page_used(i);
            return i * PAGE_SIZE;
        }
    }
    return 0;  // 没有可用页面
}

// 释放一个物理页面
void free_page(unsigned long physical_addr)
{
    unsigned long page_index = physical_addr / PAGE_SIZE;
    if (page_index < total_pages) {
        set_page_free(page_index);
    }
}

位图方案的优点是实现简单,内存开销小(每个4KB页面只需要1位来记录状态,1GB内存只需要32KB的位图)。缺点是分配速度慢——找到一个空闲页面的最坏时间复杂度是O(n),n是总页面数。对于拥有几GB甚至几十GB内存的现代系统,线性扫描位图的效率是不可接受的。

实际的操作系统使用更高效的数据结构来管理物理页面。Linux使用了Buddy System(伙伴系统)。伙伴系统的核心思想是把空闲页面按照大小分组管理——0阶(1页)、1阶(2页连续)、2阶(4页连续)、...、最大阶(通常是10阶,1024页=4MB)。每个阶维护一个空闲块的链表。分配n页连续的内存时,先计算需要的阶(最小的k使得2^k >= n),然后从该阶的链表中取一个空闲块。如果该阶没有空闲块,就从更高阶取一个块并分裂为两个"伙伴"块,一个使用一个放回低阶链表。释放时检查该块的伙伴是否也空闲,如果是就合并成更大的块,递归地尝试向上合并。

伙伴系统的分配和释放时间复杂度都是O(log n),而且天然支持分配连续的物理页面(某些DMA操作和大页映射需要连续的物理页面),这是位图方案做不到的。

Linux在伙伴系统之上还有一层slab分配器(后来演化为SLUB分配器),用于高效地分配小于一个页面的内核对象。很多内核数据结构(如进程描述符、文件描述符、inode等)的大小远小于4KB,如果每个对象都分配一整个页面就太浪费了。Slab分配器预先分配好一些页面,把每个页面划分成固定大小的对象槽,分配和释放一个对象只需要从对应的slab中取出或放回一个槽,速度非常快。

Windows的内存管理器使用PFN数据库(Page Frame Number Database)来管理物理页面。每个物理页面对应PFN数据库中的一个条目,条目中记录了页面的状态(空闲、在用、等待中、修改过等)和各种引用信息。Windows把空闲页面组织成多个链表——空闲列表、清零列表、备用列表、修改列表等,每个列表有不同的用途和优先级。

FreeBSD的物理内存管理也使用了类似于伙伴系统的方案,但细节上有所不同。Solaris使用了一种叫做"页面着色"(Page Coloring)的技术来优化CPU缓存的使用效率。

对于我们自己的操作系统来说,从位图方案开始是合理的。它虽然不够高效,但足够简单且正确。等内核的其他部分稳定运行后,再考虑升级到伙伴系统也不迟。很多操作系统教学项目(如xv6)也使用了简单的空闲链表来管理物理页面——每个空闲页面的前几个字节存储下一个空闲页面的地址,形成一个单链表。分配时取链表头,释放时插入链表头,时间复杂度O(1),比位图更快,但不支持连续页面的分配。


5.6 中断处理机制

中断(Interrupt)是计算机系统中最重要的机制之一。没有中断,CPU就只能用轮询(Polling)的方式来检查外部设备的状态,这会浪费大量的CPU时间。有了中断,设备可以在需要CPU关注时主动通知CPU,CPU在其他时间可以专心执行程序。

5.6.1 8259A可编程中断控制器

8259A PIC(Programmable Interrupt Controller,可编程中断控制器)是PC架构中管理硬件中断的经典芯片。虽然现代的PC已经使用APIC(Advanced PIC)来取代8259A,但理解8259A对于学习中断机制仍然很有价值。而且在Bochs和QEMU中,8259A通常是默认的中断控制器配置。

一个标准的PC使用两片8259A级联:主PIC(Master PIC)和从PIC(Slave PIC)。每片PIC有8个中断输入引脚(IR0到IR7),级联后总共可以管理15个中断源(主PIC的IR2连接到从PIC的输出,所以主PIC只剩7个可用输入,加上从PIC的8个,总共15个)。

标准PC中各个IRQ的分配如下:

apache

主PIC (I/O端口: 0x20, 0x21)
  IRQ 0  : 定时器(PIT, Programmable Interval Timer)
  IRQ 1  : 键盘
  IRQ 2  : 级联到从PIC
  IRQ 3  : 串口2 (COM2)
  IRQ 4  : 串口1 (COM1)
  IRQ 5  : 并口2 (LPT2) / 声卡
  IRQ 6  : 软盘控制器
  IRQ 7  : 并口1 (LPT1)

从PIC (I/O端口: 0xA0, 0xA1)
  IRQ 8  : 实时时钟 (RTC)
  IRQ 9  : ACPI / 可用
  IRQ 10 : 可用 / 网卡
  IRQ 11 : 可用 / USB
  IRQ 12 : PS/2鼠标
  IRQ 13 : 数学协处理器
  IRQ 14 : 主IDE通道
  IRQ 15 : 从IDE通道

8259A需要经过初始化才能正常工作。初始化过程是向PIC的I/O端口发送一系列ICW(Initialization Command Word,初始化命令字)。8259A的初始化序列比较固定:

c

// 初始化8259A PIC
void pic_init(void)
{
    // ICW1: 开始初始化序列
    outb(0x20, 0x11);    // 主PIC: 需要ICW4, 级联模式
    outb(0xA0, 0x11);    // 从PIC: 需要ICW4, 级联模式

    // ICW2: 设置中断向量偏移
    outb(0x21, 0x20);    // 主PIC: IRQ 0-7 映射到向量 0x20-0x27
    outb(0xA1, 0x28);    // 从PIC: IRQ 8-15 映射到向量 0x28-0x2F

    // ICW3: 设置级联关系
    outb(0x21, 0x04);    // 主PIC: IR2连接从PIC (位2置1)
    outb(0xA1, 0x02);    // 从PIC: 连接到主PIC的IR2 (值为2)

    // ICW4: 设置工作模式
    outb(0x21, 0x01);    // 主PIC: 8086模式
    outb(0xA1, 0x01);    // 从PIC: 8086模式

    // 设置中断屏蔽寄存器(OCW1)
    // 先屏蔽所有中断,后续按需开启
    outb(0x21, 0xFF);    // 屏蔽主PIC所有中断
    outb(0xA1, 0xFF);    // 屏蔽从PIC所有中断
}

ICW2设置的中断向量偏移非常重要。默认情况下,BIOS可能把IRQ 0-7映射到中断向量0-7,但这与CPU的异常向量(0-31)冲突了。比如IRQ 0(定时器)会使用向量0,但向量0是除法错误异常。如果不重新映射,操作系统就无法区分定时器中断和除法错误异常。所以我们把主PIC的IRQ映射到向量0x20(32)开始,从PIC的IRQ映射到向量0x28(40)开始,避开了前32个保留给CPU异常的向量。

中断屏蔽寄存器(IMR,Interrupt Mask Register)控制哪些IRQ被允许、哪些被禁止。每个位对应一个IRQ,1表示屏蔽(禁止),0表示允许。初始化后我们先屏蔽所有中断,等后续各个设备驱动初始化完成后再逐个开启对应的IRQ。

c

// 开启指定的IRQ
void enable_irq(unsigned char irq)
{
    unsigned short port;
    unsigned char mask;

    if (irq < 8) {
        port = 0x21;          // 主PIC的IMR端口
    } else {
        port = 0xA1;          // 从PIC的IMR端口
        irq -= 8;
    }

    mask = inb(port) & ~(1 << irq);
    outb(port, mask);
}

// 禁止指定的IRQ
void disable_irq(unsigned char irq)
{
    unsigned short port;
    unsigned char mask;

    if (irq < 8) {
        port = 0x21;
    } else {
        port = 0xA1;
        irq -= 8;
    }

    mask = inb(port) | (1 << irq);
    outb(port, mask);
}

当中断处理完成后,需要向PIC发送EOI(End of Interrupt)信号,告知PIC可以接受下一个中断了。如果不发送EOI,PIC会认为当前中断还在处理中,不会传递后续的中断:

c

// 发送EOI
void send_eoi(unsigned char irq)
{
    if (irq >= 8) {
        outb(0xA0, 0x20);    // 先向从PIC发送EOI
    }
    outb(0x20, 0x20);        // 向主PIC发送EOI
}

关于8259A PIC,还有一个需要注意的问题——伪中断(Spurious Interrupt)。在某些竞态条件下,PIC可能会产生一个并不对应真实硬件中断的"假"中断。特别是IRQ 7(主PIC的最后一个IRQ)和IRQ 15(从PIC的最后一个IRQ)容易出现这种情况。处理方法是在中断处理程序中检查PIC的ISR(In-Service Register),如果对应的位没有被设置,说明是伪中断,不需要处理也不需要发送EOI。

在现代操作系统中,8259A通常被APIC(Advanced Programmable Interrupt Controller)取代。APIC分为Local APIC(每个CPU核心一个)和I/O APIC(通常一个或多个)。Local APIC处理CPU内部的中断(如定时器中断、IPI——处理器间中断),I/O APIC处理外部设备的中断并将其路由到合适的CPU核心。APIC支持更多的中断源、更灵活的中断路由和优先级管理,是多处理器系统必不可少的组件。Linux和Windows在现代硬件上都使用APIC而不是8259A。

5.6.2 中断的触发与响应

PIC初始化完成后,还需要在IDT中注册各个IRQ的中断处理程序,并在设备驱动就绪后开启对应的IRQ。

中断处理程序的结构与异常处理程序类似——汇编桩保存上下文、调用C语言处理函数、恢复上下文、发送EOI、执行IRETQ返回。但中断处理程序有一个重要的区别:中断是异步的,可能在任何时刻打断正在执行的程序。所以中断处理程序需要尽量快速地完成工作,避免长时间占用CPU。

nasm

; IRQ处理桩的模板
%macro IRQ_HANDLER 2   ; 参数1: IRQ号, 参数2: 中断向量号
global irq_%1_stub
irq_%1_stub:
    push 0              ; 假错误码(保持栈布局一致)
    ; 保存所有通用寄存器
    push rax
    push rbx
    push rcx
    push rdx
    push rsi
    push rdi
    push rbp
    push r8
    push r9
    push r10
    push r11
    push r12
    push r13
    push r14
    push r15

    mov rdi, %1         ; 第一个参数:IRQ号
    mov rsi, rsp        ; 第二个参数:寄存器上下文指针
    call irq_handler    ; 调用C语言处理函数

    ; 恢复所有通用寄存器
    pop r15
    pop r14
    pop r13
    pop r12
    pop r11
    pop r10
    pop r9
    pop r8
    pop rbp
    pop rdi
    pop rsi
    pop rdx
    pop rcx
    pop rbx
    pop rax
    add rsp, 8          ; 弹出假错误码
    iretq
%endmacro

IRQ_HANDLER 0, 0x20     ; IRQ 0 (定时器)
IRQ_HANDLER 1, 0x21     ; IRQ 1 (键盘)
; ... 其他IRQ

C语言的IRQ处理函数:

c

// IRQ处理函数表
typedef void (*irq_handler_t)(unsigned long irq, struct interrupt_frame *frame);
static irq_handler_t irq_handlers[16] = {0};

// 注册IRQ处理函数
void register_irq_handler(unsigned char irq, irq_handler_t handler)
{
    irq_handlers[irq] = handler;
}

// 统一的IRQ分发函数
void irq_handler(unsigned long irq, struct interrupt_frame *frame)
{
    // 调用对应的处理函数
    if (irq_handlers[irq]) {
        irq_handlers[irq](irq, frame);
    } else {
        printk("Unhandled IRQ %d\n", irq);
    }

    // 发送EOI
    send_eoi(irq);
}

这种设计把IRQ的分发逻辑和具体的设备处理逻辑分开,每个设备驱动只需要注册自己的处理函数即可,不需要了解PIC的细节。

在Linux中,中断处理被分为"上半部"(Top Half)和"下半部"(Bottom Half)。上半部是中断处理程序本身,在禁止中断的状态下执行,只做最紧急的事情(比如从设备读取数据到缓冲区、应答中断)。下半部在允许中断的状态下执行,可以做比较耗时的处理工作(比如协议栈解析、数据拷贝到用户缓冲区等)。Linux提供了多种下半部机制——softirq、tasklet、workqueue等。这种分离机制让系统在保持中断响应速度的同时,也能正确处理复杂的中断事件。

Windows使用类似的分层机制,叫做DPC(Deferred Procedure Call,延迟过程调用)。ISR(Interrupt Service Routine)完成紧急处理后,排队一个DPC来完成后续工作。DPC在较低的IRQL(Interrupt Request Level)上执行,允许被更高优先级的中断打断。


5.7 键盘驱动程序

键盘是最基本的输入设备之一。实现键盘驱动是让操作系统具有交互能力的第一步——没有键盘输入,操作系统就只能单方面地输出信息,用户无法与之交互。

5.7.1 键盘的工作原理概述

PC键盘的工作原理可以分为几个层次。物理层面上,当你按下或释放一个键时,键盘内部的控制器(通常是一个8048或兼容芯片)会检测到按键状态的变化,生成一个扫描码(Scan Code),通过PS/2接口或USB接口发送给主机。

扫描码不是ASCII码。扫描码是键盘控制器根据按键的物理位置生成的编码,与键帽上印的字符无关。比如在美式键盘上,字母A的按下(Make)扫描码是0x1E,释放(Break)扫描码是0x9E(按下码加上0x80)。同一个物理位置的键,在不同的键盘布局下对应不同的字符,但扫描码是一样的。从扫描码到字符的转换是由操作系统的键盘驱动来完成的。

PC使用的扫描码有三套标准——Scan Code Set 1、2、3。IBM PC/XT使用Set 1,IBM PC/AT使用Set 2,PS/2键盘默认使用Set 2但8042键盘控制器会自动将Set 2翻译为Set 1。所以大多数操作系统处理的都是Set 1扫描码。

8042键盘控制器(或其兼容芯片)通过两个I/O端口与CPU通信:

  • 端口0x60:数据端口。读取时获取扫描码或键盘控制器的响应,写入时向键盘发送命令。
  • 端口0x64:状态/命令端口。读取时获取控制器状态,写入时向控制器发送命令。

状态端口(0x64)的各位含义:

  • 位0(Output Buffer Full):1表示数据端口有数据可读
  • 位1(Input Buffer Full):1表示控制器还在处理上一个命令
  • 位2(System Flag):系统标志
  • 位3(Command/Data):0表示数据端口的数据来自键盘,1表示来自控制器命令的响应
  • 其他位用途各异

当键盘有数据发送时,8042控制器会触发IRQ 1中断。中断处理程序通过读取端口0x60来获取扫描码。

现代PC虽然大多使用USB键盘,但为了兼容性,通常通过USB Legacy Support把USB键盘模拟为PS/2键盘,所以操作系统在引导阶段仍然可以通过8042控制器来接收键盘输入。等USB驱动加载完成后,才会切换到原生的USB HID协议。

5.7.2 编写键盘中断捕获处理函数

下面实现一个基本的键盘驱动,能够接收按键事件并在屏幕上显示对应的字符。

首先是扫描码到ASCII字符的映射表。这只是一个简化版本,只处理了主键盘区的按键,没有处理小键盘、功能键、多媒体键等:

c

// Scan Code Set 1 到 ASCII 的映射表(未按Shift键时)
static const char scancode_to_ascii[128] = {
    0,    27,  '1', '2', '3', '4', '5', '6',   // 0x00 - 0x07
    '7', '8', '9', '0', '-', '=', '\b', '\t',  // 0x08 - 0x0F
    'q', 'w', 'e', 'r', 't', 'y', 'u', 'i',   // 0x10 - 0x17
    'o', 'p', '[', ']', '\n', 0,   'a', 's',   // 0x18 - 0x1F (0x1D=Left Ctrl)
    'd', 'f', 'g', 'h', 'j', 'k', 'l', ';',   // 0x20 - 0x27
    '\'', '`', 0,  '\\', 'z', 'x', 'c', 'v',  // 0x28 - 0x2F (0x2A=Left Shift)
    'b', 'n', 'm', ',', '.', '/', 0,   '*',    // 0x30 - 0x37 (0x36=Right Shift)
    0,   ' ', 0,   0,   0,   0,   0,   0,      // 0x38 - 0x3F (0x38=Left Alt)
    // ... 后续是功能键等,暂时不处理
};

// 按Shift时的映射表
static const char scancode_to_ascii_shift[128] = {
    0,    27,  '!', '@', '#', '$', '%', '^',
    '&', '*', '(', ')', '_', '+', '\b', '\t',
    'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I',
    'O', 'P', '{', '}', '\n', 0,   'A', 'S',
    'D', 'F', 'G', 'H', 'J', 'K', 'L', ':',
    '"', '~', 0,   '|', 'Z', 'X', 'C', 'V',
    'B', 'N', 'M', '<', '>', '?', 0,   '*',
    0,   ' ', 0,   0,   0,   0,   0,   0,
};

// 修饰键状态
static int shift_pressed = 0;
static int ctrl_pressed = 0;
static int alt_pressed = 0;

// 键盘中断处理函数
void keyboard_handler(unsigned long irq, struct interrupt_frame *frame)
{
    unsigned char scancode = inb(0x60);

    // 判断是按下还是释放
    if (scancode & 0x80) {
        // 释放事件(Break code)
        unsigned char key = scancode & 0x7F;
        if (key == 0x2A || key == 0x36) shift_pressed = 0;
        if (key == 0x1D) ctrl_pressed = 0;
        if (key == 0x38) alt_pressed = 0;
    } else {
        // 按下事件(Make code)
        if (scancode == 0x2A || scancode == 0x36) {
            shift_pressed = 1;
            return;
        }
        if (scancode == 0x1D) {
            ctrl_pressed = 1;
            return;
        }
        if (scancode == 0x38) {
            alt_pressed = 1;
            return;
        }

        // 转换为ASCII字符
        char ch;
        if (shift_pressed) {
            ch = scancode_to_ascii_shift[scancode];
        } else {
            ch = scancode_to_ascii[scancode];
        }

        if (ch) {
            // 在屏幕上显示字符
            console_putchar(ch);
        }
    }
}

// 键盘驱动初始化
void keyboard_init(void)
{
    // 注册键盘中断处理函数
    register_irq_handler(1, keyboard_handler);

    // 开启IRQ 1
    enable_irq(1);

    printk("Keyboard driver initialized.\n");
}

这个驱动虽然简单,但已经能够处理大部分常见的按键输入了。它跟踪Shift、Ctrl和Alt修饰键的状态,根据Shift的状态选择不同的映射表来转换扫描码。

在实际的操作系统中,键盘驱动要复杂得多。Linux的键盘驱动(drivers/input/keyboard/atkbd.c)处理了完整的Scan Code Set 2(包括扩展扫描码0xE0前缀的多字节序列),支持键盘LED控制(Num Lock、Caps Lock、Scroll Lock),支持多种键盘布局的映射,支持组合键(如Ctrl+Alt+Del),还提供了一个输入事件接口让用户态程序可以接收原始的按键事件。

Windows的键盘驱动栈更加复杂——从底层的i8042prt.sys(8042端口驱动)到kbdclass.sys(键盘类驱动),再到更高层的键盘布局DLL,层次分明。Windows还支持"钩子"(Hook)机制,允许应用程序截获键盘输入(用于输入法、快捷键管理等),这也是一些恶意软件(键盘记录器)的常用手法。


5.8 进程管理机制

进程是操作系统中最核心的抽象概念之一。一个进程代表一个正在执行的程序实例,包括程序代码、数据、栈、打开的文件、内存映射等资源。进程管理是操作系统实现多任务的基础。

5.8.1 进程管理模块概述

在单处理器系统中,同一时刻只有一个进程在CPU上运行。但操作系统通过快速地在多个进程之间切换(上下文切换),制造出多个进程同时运行的假象。这就是"多任务"或"并发执行"的本质。

进程管理模块的核心功能包括:

进程创建。 创建一个新的进程,为它分配必要的资源(内存、PCB、栈等),初始化执行上下文,加入调度队列。在UNIX/Linux中,进程创建通过fork()系统调用来完成——fork()创建一个与父进程几乎完全相同的子进程副本(利用COW机制,实际上不会立即复制所有内存)。在Windows中,进程创建通过CreateProcess() API完成,它同时完成进程创建和程序加载。

进程调度。 决定哪个进程获得CPU的使用权以及使用多长时间。调度算法的设计直接影响系统的响应速度和吞吐量。经典的调度算法包括先来先服务(FCFS)、短作业优先(SJF)、时间片轮转(Round Robin)、优先级调度、多级反馈队列等。

Linux目前使用的调度器叫做CFS(Completely Fair Scheduler,完全公平调度器),由Ingo Molnar在2007年引入。CFS的核心思想是让每个进程获得公平的CPU时间份额——它维护一个按虚拟运行时间(vruntime)排序的红黑树,每次调度时选择vruntime最小的进程运行。vruntime的增长速度与进程的权重成反比,优先级高的进程vruntime增长慢,所以能获得更多的CPU时间。

Windows使用多级反馈队列调度算法。线程被分为32个优先级级别(0-31),每个级别一个队列。高优先级的线程可以抢占低优先级的线程。在同一优先级内使用时间片轮转。Windows还会根据线程的行为动态调整优先级——比如等待I/O完成的线程会被临时提升优先级,以改善交互响应速度。

进程终止。 释放进程占用的所有资源,从调度队列中移除。进程终止可能是自愿的(调用exit()),也可能是非自愿的(被信号杀死或异常终止)。

上下文切换。 保存当前进程的CPU上下文(寄存器值、程序计数器、栈指针等),恢复另一个进程的CPU上下文,切换地址空间(通过更换CR3寄存器来切换页表)。上下文切换是有开销的——不仅是保存和恢复寄存器的时间,更重要的是TLB(Translation Lookaside Buffer,页表缓存)失效导致的性能损失。每次切换地址空间时,TLB中缓存的页表映射全部失效,新进程的前几次内存访问都需要查询页表(page walk),这比TLB命中要慢几十倍。

5.8.2 进程控制块(PCB)

PCB(Process Control Block,进程控制块)是操作系统用来管理进程的核心数据结构。每个进程有且只有一个PCB,它包含了操作系统管理这个进程所需的全部信息。

在不同的操作系统中,PCB的名称和具体结构不同,但包含的信息类型是类似的。Linux中PCB叫做 task_struct(定义在 include/linux/sched.h 中),是一个巨大的结构体,包含几百个字段。Windows中对应的是 EPROCESS(Executive Process Block)和 KPROCESS(Kernel Process Block)结构体。

对于我们自己的内核,可以定义一个相对简洁的PCB结构:

c

// 进程状态枚举
enum process_state {
    PROCESS_RUNNING,        // 正在CPU上执行
    PROCESS_READY,          // 就绪,等待被调度
    PROCESS_BLOCKED,        // 阻塞,等待某个事件
    PROCESS_ZOMBIE,         // 已终止,等待父进程回收
};

// CPU上下文(保存在上下文切换时)
struct cpu_context {
    unsigned long rax, rbx, rcx, rdx;
    unsigned long rsi, rdi, rbp, rsp;
    unsigned long r8, r9, r10, r11;
    unsigned long r12, r13, r14, r15;
    unsigned long rip;
    unsigned long rflags;
    unsigned long cr3;              // 页表基地址
    // 浮点/SIMD状态可以后续添加
};

// 进程控制块
struct pcb {
    long pid;                       // 进程ID
    enum process_state state;       // 进程状态
    long priority;                  // 优先级
    long counter;                   // 时间片计数器

    struct cpu_context context;     // CPU上下文

    unsigned long kernel_stack;     // 内核栈的顶部地址
    unsigned long cr3;              // 进程的页表

    struct pcb *parent;             // 父进程
    struct pcb *next;               // 进程链表中的下一个

    char name[32];                  // 进程名称(调试用)
};

每个字段的作用:

pid 是进程的唯一标识符。在UNIX/Linux中,PID从1开始递增分配。PID 0通常保留给内核的idle进程(也叫swapper),PID 1是init进程。PID的范围在Linux中默认是0到32767(可以通过 /proc/sys/kernel/pid_max 修改),用完后会回绕重用。

state 记录进程的当前状态。经典的进程状态模型包括五种状态:新建(New)、就绪(Ready)、运行(Running)、阻塞/等待(Blocked/Waiting)、终止(Terminated)。进程在这些状态之间转换:新建->就绪(初始化完成后)、就绪->运行(被调度器选中)、运行->就绪(时间片用完被抢占)、运行->阻塞(等待I/O或其他事件)、阻塞->就绪(等待的事件发生了)、运行->终止(进程结束)。

Linux在此基础上增加了更多细分的状态:TASK_RUNNING(就绪或运行)、TASK_INTERRUPTIBLE(可中断的阻塞,收到信号会被唤醒)、TASK_UNINTERRUPTIBLE(不可中断的阻塞,用于关键的I/O操作)、TASK_STOPPED(被信号停止,如SIGSTOP)、TASK_TRACED(被调试器跟踪)等。其中TASK_UNINTERRUPTIBLE状态的进程在 ps 命令中显示为D状态,这种进程既不能被信号杀死也不能被中断,只能等待它自己完成——这就是为什么有时候你会看到无法杀死的"僵尸"进程。

prioritycounter 用于调度。最简单的时间片轮转调度可以这样实现:每个进程有一个时间片计数器(counter),每次定时器中断时当前进程的counter减1,当counter减到0时切换到下一个进程。priority可以影响初始的counter值——优先级高的进程获得更多的时间片。

context 保存进程的CPU上下文。在上下文切换时,当前进程的寄存器值保存到它的PCB中,然后从目标进程的PCB中恢复寄存器值。上下文切换的核心函数通常用汇编语言编写:

nasm

; 上下文切换函数
; void switch_to(struct pcb *prev, struct pcb *next)
global switch_to
switch_to:
    ; 保存prev进程的上下文
    ; rdi = prev (第一个参数)
    ; rsi = next (第二个参数)

    mov [rdi + PCB_CONTEXT_RAX], rax
    mov [rdi + PCB_CONTEXT_RBX], rbx
    mov [rdi + PCB_CONTEXT_RCX], rcx
    mov [rdi + PCB_CONTEXT_RDX], rdx
    mov [rdi + PCB_CONTEXT_RSI], rsi
    mov [rdi + PCB_CONTEXT_RDI], rdi
    mov [rdi + PCB_CONTEXT_RBP], rbp
    mov [rdi + PCB_CONTEXT_RSP], rsp
    mov [rdi + PCB_CONTEXT_R8],  r8
    mov [rdi + PCB_CONTEXT_R9],  r9
    mov [rdi + PCB_CONTEXT_R10], r10
    mov [rdi + PCB_CONTEXT_R11], r11
    mov [rdi + PCB_CONTEXT_R12], r12
    mov [rdi + PCB_CONTEXT_R13], r13
    mov [rdi + PCB_CONTEXT_R14], r14
    mov [rdi + PCB_CONTEXT_R15], r15

    ; 保存RIP(使用返回地址)
    mov rax, [rsp]              ; 栈顶是call指令压入的返回地址
    mov [rdi + PCB_CONTEXT_RIP], rax

    ; 保存RFLAGS
    pushfq
    pop rax
    mov [rdi + PCB_CONTEXT_RFLAGS], rax

    ; 恢复next进程的上下文
    ; 切换页表
    mov rax, [rsi + PCB_CR3]
    mov cr3, rax

    ; 切换内核栈
    mov rsp, [rsi + PCB_CONTEXT_RSP]

    ; 恢复RFLAGS
    mov rax, [rsi + PCB_CONTEXT_RFLAGS]
    push rax
    popfq

    ; 恢复通用寄存器
    mov rax, [rsi + PCB_CONTEXT_RAX]
    mov rbx, [rsi + PCB_CONTEXT_RBX]
    mov rcx, [rsi + PCB_CONTEXT_RCX]
    mov rdx, [rsi + PCB_CONTEXT_RDX]
    mov rbp, [rsi + PCB_CONTEXT_RBP]
    mov r8,  [rsi + PCB_CONTEXT_R8]
    mov r9,  [rsi + PCB_CONTEXT_R9]
    mov r10, [rsi + PCB_CONTEXT_R10]
    mov r11, [rsi + PCB_CONTEXT_R11]
    mov r12, [rsi + PCB_CONTEXT_R12]
    mov r13, [rsi + PCB_CONTEXT_R13]
    mov r14, [rsi + PCB_CONTEXT_R14]
    mov r15, [rsi + PCB_CONTEXT_R15]

    ; 跳转到next进程的执行点
    mov rdi, [rsi + PCB_CONTEXT_RDI]    ; 恢复RDI(最后恢复,因为之前在用)
    mov rsi, [rsi + PCB_CONTEXT_RSI]    ; 恢复RSI
    push qword [rsi + PCB_CONTEXT_RIP]  ; 不对,RSI已经被覆盖了...
    ; 这里有个问题,需要更仔细地处理

实际上,上下文切换的实现比看起来要微妙得多。上面的代码只是一个示意,实际实现中通常使用更巧妙的方式。一种常见的做法是利用C语言的调用约定——在x86-64的System V ABI中,只有RBX、RBP、R12-R15、RSP是"被调用者保存"(callee-saved)的寄存器,其他寄存器由调用者保存。所以上下文切换函数只需要保存和恢复这几个寄存器以及RIP(通过RET指令自然实现),就足够了:

nasm

; 简化的上下文切换
; void switch_to(struct context **old, struct context *new)
switch_to:
    ; 保存callee-saved寄存器
    push rbx
    push rbp
    push r12
    push r13
    push r14
    push r15

    ; 保存当前RSP到old指向的位置
    mov [rdi], rsp

    ; 切换到new的RSP
    mov rsp, rsi

    ; 恢复callee-saved寄存器
    pop r15
    pop r14
    pop r13
    pop r12
    pop rbp
    pop rbx

    ; RET自动从栈上弹出返回地址到RIP
    ret

这个版本精简了很多。它的工作原理是:当进程A调用switch_to时,A的返回地址和callee-saved寄存器被压入A的栈中,然后RSP被切换到进程B的栈。进程B的栈上存放着上次B被切换出去时压入的寄存器和返回地址,pop和ret就自然地恢复了B的执行环境。这个技巧非常优雅,Linux内核中的 __switch_to_asm 函数就是类似的实现。

cr3 字段存储进程的页表基地址。每个进程有自己独立的虚拟地址空间,这是通过每个进程使用不同的页表来实现的。上下文切换时需要更新CR3寄存器来切换地址空间。但内核地址空间通常是所有进程共享的——每个进程的页表中,高地址部分(内核空间)的映射是相同的,只有低地址部分(用户空间)的映射不同。这样在内核态运行时,无论当前的CR3指向哪个进程的页表,内核代码和数据都能被正确访问。

5.8.3 init初始进程

init进程是操作系统启动后创建的第一个用户态进程。在UNIX/Linux系统中,init进程的PID永远是1。它是所有其他用户进程的祖先——所有用户进程要么是init直接创建的,要么是init的子进程创建的后代。

在我们的内核中,init进程的创建是一个特殊的过程,因为此时还没有fork()系统调用可用(fork需要一个已存在的进程来调用)。所以第一个进程需要手动构造:

c

// 第一个进程的PCB(静态分配)
static struct pcb init_pcb;
static unsigned char init_kernel_stack[KERNEL_STACK_SIZE] __attribute__((aligned(16)));

// 第一个进程的执行体
void init_process(void)
{
    printk("Init process started (PID %d)\n", current->pid);

    // 在一个真正的操作系统中,init进程会:
    // 1. 挂载根文件系统
    // 2. 读取配置文件
    // 3. 启动系统服务
    // 4. 启动登录终端

    // 目前我们的init进程只是做一些简单的测试
    while (1) {
        printk("init: tick\n");
        // 这里应该有某种延迟机制
    }
}

// 创建init进程
void create_init_process(void)
{
    // 初始化PCB
    init_pcb.pid = 1;
    init_pcb.state = PROCESS_READY;
    init_pcb.priority = 15;
    init_pcb.counter = 15;
    init_pcb.parent = NULL;    // init进程没有父进程

    // 设置内核栈
    init_pcb.kernel_stack = (unsigned long)init_kernel_stack + KERNEL_STACK_SIZE;

    // 设置初始CPU上下文
    // 让进程从init_process函数开始执行
    init_pcb.context.rip = (unsigned long)init_process;
    init_pcb.context.rsp = init_pcb.kernel_stack;
    init_pcb.context.rflags = 0x200;    // IF=1,允许中断
    init_pcb.context.cr3 = get_current_cr3();  // 使用内核页表

    // 初始化进程名
    strncpy(init_pcb.name, "init", sizeof(init_pcb.name));

    // 加入进程列表和就绪队列
    add_to_process_list(&init_pcb);
    add_to_ready_queue(&init_pcb);

    printk("Init process created (PID 1)\n");
}

但是,init进程目前还只是在内核态运行。一个真正的init进程应该运行在用户态(Ring 3)。要让进程从内核态切换到用户态,需要构造一个特殊的栈帧,然后执行IRETQ指令——IRETQ会从栈上弹出RIP、CS、RFLAGS、RSP和SS,如果CS中的DPL(RPL)指示的是用户态(Ring 3),CPU就会自动切换到用户态。

c

// 从内核态跳转到用户态执行
// user_rip: 用户态程序的入口地址
// user_rsp: 用户态栈的栈顶地址
void switch_to_user_mode(unsigned long user_rip, unsigned long user_rsp)
{
    __asm__ volatile (
        "push %0 \n\t"      // SS (用户数据段选择子, DPL=3)
        "push %1 \n\t"      // RSP (用户态栈指针)
        "pushfq  \n\t"      // RFLAGS
        "push %2 \n\t"      // CS (用户代码段选择子, DPL=3)
        "push %3 \n\t"      // RIP (用户态入口地址)
        "iretq   \n\t"      // 中断返回,切换到用户态
        :
        : "r"((unsigned long)0x23),       // 用户数据段 (GDT索引4, RPL=3)
          "r"(user_rsp),
          "r"((unsigned long)0x1B),       // 用户代码段 (GDT索引3, RPL=3)
          "r"(user_rip)
    );
}

在Linux系统中,init进程有着举足轻重的地位。传统的UNIX系统使用SysV init,它根据运行级别(runlevel)来启动不同的服务。后来出现了Upstart(Ubuntu曾经使用)和systemd(目前绝大多数Linux发行版使用)等更现代的init系统。systemd不仅是PID 1的进程,它还承担了服务管理、日志管理(journald)、设备管理(udevd)、登录管理(logind)等众多职责,这也是它饱受争议的原因之一——有人认为它违反了UNIX的"做好一件事"的哲学。

在macOS中,PID 1的进程是launchd,它同时扮演了init和服务管理器的角色。Windows中虽然没有"init进程"的概念,但smss.exe(Session Manager Subsystem)扮演了类似的角色——它是内核创建的第一个用户态进程,负责创建环境子系统进程(csrss.exe)和登录进程(winlogon.exe)。

一个有趣的事实是:如果init进程意外终止了,内核会陷入panic。这是因为init进程是孤儿进程的最终收养者——当一个进程的父进程终止后,这个进程会被reparent到init进程。如果init不存在了,就没有人来回收这些孤儿进程的资源了,系统就无法正常运行了。在Linux内核中,当检测到PID 1退出时,do_exit() 函数会直接调用 panic("Attempted to kill init!")


回顾本章的全部内容,我们从内核的第一条指令出发,依次构建了屏幕输出、异常处理、内存管理、中断处理、键盘驱动和进程管理这六大核心模块。这些模块相互依赖、相互配合:屏幕输出为调试提供了基础;异常处理保证了系统的稳定性;内存管理为所有数据结构提供了空间;中断处理让系统能够响应外部事件;键盘驱动让用户能够与系统交互;进程管理让系统能够运行多个任务。

这六大模块构成了一个最小但完整的操作系统内核的骨架。在此基础上,后续可以继续扩展——添加文件系统让程序能够读写磁盘数据,添加系统调用让用户程序能够安全地请求内核服务,添加网络协议栈让系统能够联网通信,添加图形界面让系统更加友好易用。

操作系统的开发是一个不断积累的过程。每完成一个模块,系统就多了一份能力。而每一个模块的实现,都需要对计算机硬件有深入的理解。这也是操作系统开发独特的魅力所在——它让你站在软件和硬件的交界处,真正理解计算机系统是如何运转的。当你的内核第一次成功地响应了键盘中断,在屏幕上显示出你敲击的字符时,那种成就感是其他软件开发很难带来的。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐