从0写自己的操作系统(2) loader->kernel加载操作系统内核

发布于:2025-07-04 ⋅ 阅读:(12) ⋅ 点赞:(0)

内联汇编

为什么要使用内联汇编?
内联汇编(inline assembly),主要是因为它提供了一种在 C/C++ 语言中直接操控底层硬件与 CPU 指令的能力,这在操作系统、驱动、嵌入式开发中是不可或缺的手段。

比如:

1. C 无法完成的任务必须靠汇编

  • 访问 I/O 端口:inb, outb
  • 设置中断标志:cli(), sti()
  • 操作控制寄存器:mov %%cr0, eax
  • 加载 GDT、IDT、TSS:lgdt, lidt, ltr
  • 执行长跳转进入保护模式:ljmp

👉 这些都是 CPU 的“特权指令”,C 语言没有语法支持,只能通过汇编实现,而你用了内联汇编封装它们。

2. 相比裸汇编,内联汇编更可控、更灵活

  • 在 C 里调用更方便,可读性高
  • 不必单独写 .s 文件(.s 文件是 汇编语言源代码文件),减少管理成本
  • 变量和寄存器约束清晰,和 C 变量直接交互

3. 系统编程/OS开发必备技能

你写的是 Bootloader、Loader、Kernel,涉及:

模式切换(实模式 → 保护模式)

分页机制启用

启动中断系统

TSS 和任务切换

BIOS / I/O 交互

👉 这些全部都需要你直接与 CPU 架构打交道,掌握内联汇编是基础技能

本项目:
代码定义了一个名为 cpu_instr.h 的头文件,用于在x86架构的操作系统中提供与CPU指令直接交互的功能。代码中包含了多个静态内联函数,这些函数通过内联汇编指令与CPU寄存器和控制寄存器进行操作。以下是对代码的逐步分解和详细解释:

  1. 端口读写函数

    • inb(uint16_t port):从指定的16位端口读取一个字节的数据。
    • inw(uint16_t port):从指定的16位端口读取两个字节的数据。
    • outb(uint16_t port, uint8_t data):向指定的16位端口写入一个字节的数据。
    • outw(uint16_t port, uint16_t data):向指定的16位端口写入两个字节的数据。

    这些函数主要用于与外部设备进行通信,通过端口读写数据是硬件交互的基础操作。inbinw 分别使用内联汇编指令 inbin,而 outboutw 分别使用内联汇编指令 outbout

  2. 中断控制函数

    • cli():清除中断标志位,禁止中断。
    • sti():设置中断标志位,允许中断。

    clisti 是用于控制CPU中断的函数,cli 使用内联汇编指令 cli,而 sti 使用内联汇编指令 sti。它们在需要禁止或恢复中断的情况下使用,例如在执行某些需要独占CPU的操作期间。

  3. 全局描述符表(GDT)操作函数

    • lgdt(uint32_t start, uint32_t size):加载全局描述符表。GDT用于定义内存中的段,包括代码段、数据段和堆栈段等。这个函数用来设置GDT的起始地址和大小,以便CPU能够正确地访问内存中的段。
  4. 控制寄存器读写函数

    • read_cr0()write_cr0(uint32_t v):读写控制寄存器CR0。CR0寄存器包含了CPU的一些基本控制位,如分页机制的开关等。
    • read_cr2():读取CR2寄存器的值。CR2寄存器保存的是最近一次产生保护性错误的线性地址。
    • write_cr3(uint32_t v)read_cr3():读写控制寄存器CR3。CR3寄存器保存的是当前页目录表的物理地址,对于分页机制至关重要。
    • read_cr4()write_cr4(uint32_t v):读写控制寄存器CR4。CR4寄存器包含了一些更高级的控制位,可能会影响CPU的性能和功能,如虚拟8086模式的支持等。
  5. 远程跳转函数

    • far_jump(uint32_t selector, uint32_t offset):执行一个远跳转,到指定的选择子和偏移地址。这个函数在需要切换到不同的代码段时使用,例如在实现系统调用或中断处理时。
  6. 中断描述符表(IDT)操作函数

    • lidt(uint32_t start, uint32_t size):加载中断描述符表。IDT用于定义中断和异常处理程序的入口点,这个函数用来设置IDT的起始地址和大小,以便CPU能够正确地调用中断处理程序。
  7. 低功耗状态控制函数

    • hlt(void):使CPU进入低功耗状态,直到收到中断信号。hlt 指令常用于在空闲循环中节省能源。
  8. 任务状态段(TSS)操作函数

    • write_tr(uint32_t tss_selector):加载任务状态段选择子到TR寄存器。TSS用于保存任务的上下文信息,包括堆栈指针等。
  9. EFLAGS寄存器读写函数

    • read_eflags(void):读取EFLAGS寄存器的值。EFLAGS寄存器包含了一些标志位,用于表示CPU的状态和控制信息。
    • write_eflags(uint32_t eflags):写入EFLAGS寄存器的值。通过修改EFLAGS寄存器的值,可以改变CPU的一些状态和行为,例如设置或清除中断标志位。

总结

这段代码的主要功能是通过内联汇编指令提供对x86 CPU的直接控制,包括端口读写、中断控制、全局描述符表和中断描述符表的加载,以及对控制寄存器和任务状态段的操作。这些功能对于操作系统内核的实现尤为关键,因为它们直接与硬件交互,管理内存分段、中断处理和CPU状态等。


#ifndef CPU_INSTR_H
#define CPU_INSTR_H

#include "types.h"

// 从指定端口读取一个字节的数据
static inline uint8_t inb(uint16_t port) {
    uint8_t rv;
    // 使用内联汇编指令 "inb" 从端口 port 读取数据到寄存器 al
    __asm__ __volatile__("inb %[p], %[v]" : [v]"=a" (rv) : [p]"d"(port));
    return rv;
}

// 从指定端口读取两个字节的数据
static inline uint16_t inw(uint16_t port) {
    uint16_t rv;
    // 使用内联汇编指令 "in" 从端口 port 读取数据到寄存器 eax
    __asm__ __volatile__("in %1, %0" : "=a" (rv) : "dN" (port));
    return rv;
}

// 向指定端口写入一个字节的数据
static inline void outb(uint16_t port, uint8_t data) {
    // 使用内联汇编指令 "outb" 将数据 data 写入端口 port
    __asm__ __volatile__("outb %[v], %[p]" : : [p]"d" (port), [v]"a" (data));
}

// 向指定端口写入两个字节的数据
static inline void outw(uint16_t port, uint16_t data) {
    // 使用内联汇编指令 "out" 将数据 data 写入端口 port
    __asm__ __volatile__("out %[v], %[p]" : : [p]"d" (port), [v]"a" (data));
}

// 清除中断标志位,禁止中断
static inline void cli() {
    // 使用内联汇编指令 "cli" 清除中断标志位
    __asm__ __volatile__("cli");
}

// 设置中断标志位,允许中断
static inline void sti() {
    // 使用内联汇编指令 "sti" 设置中断标志位
    __asm__ __volatile__("sti");
}

// 加载全局描述符表(GDT)
static inline void lgdt(uint32_t start, uint32_t size) {
    struct {
        uint16_t limit;        // GDT 表限长
        uint16_t start15_0;    // GDT 表起始地址(低16位)
        uint16_t start31_16;   // GDT 表起始地址(高16位)
    } gdt;

    // 将起始地址拆分为两部分存储
    gdt.start31_16 = start >> 16;
    gdt.start15_0 = start & 0xFFFF;
    gdt.limit = size - 1;

    // 使用内联汇编指令 "lgdt" 加载 GDT
    __asm__ __volatile__("lgdt %[g]"::[g]"m"(gdt));
}

// 读取 CR0 寄存器的值
static inline uint32_t read_cr0() {
    uint32_t cr0;
    // 使用内联汇编指令 "mov" 读取 CR0 寄存器的值到变量 cr0
    __asm__ __volatile__("mov %%cr0, %[v]":[v]"=r"(cr0));
    return cr0;
}

// 写入 CR0 寄存器的值
static inline void write_cr0(uint32_t v) {
    // 使用内联汇编指令 "mov" 将变量 v 的值写入 CR0 寄存器
    __asm__ __volatile__("mov %[v], %%cr0"::[v]"r"(v));
}

// 读取 CR2 寄存器的值,通常保存的是最近一次保护性错误的线性地址
static inline uint32_t read_cr2() {
    uint32_t cr2;
    // 使用内联汇编指令 "mov" 读取 CR2 寄存器的值到变量 cr2
    __asm__ __volatile__("mov %%cr2, %[v]":[v]"=r"(cr2));
    return cr2;
}

// 写入 CR3 寄存器的值
static inline void write_cr3(uint32_t v) {
    // 使用内联汇编指令 "mov" 将变量 v 的值写入 CR3 寄存器
    __asm__ __volatile__("mov %[v], %%cr3"::[v]"r"(v));
}

// 读取 CR3 寄存器的值,通常保存的是当前页目录表的物理地址
static inline uint32_t read_cr3() {
    uint32_t cr3;
    // 使用内联汇编指令 "mov" 读取 CR3 寄存器的值到变量 cr3
    __asm__ __volatile__("mov %%cr3, %[v]":[v]"=r"(cr3));
    return cr3;
}

// 读取 CR4 寄存器的值
static inline uint32_t read_cr4() {
    uint32_t cr4;
    // 使用内联汇编指令 "mov" 读取 CR4 寄存器的值到变量 cr4
    __asm__ __volatile__("mov %%cr4, %[v]":[v]"=r"(cr4));
    return cr4;
}

// 写入 CR4 寄存器的值
static inline void write_cr4(uint32_t v) {
    // 使用内联汇编指令 "mov" 将变量 v 的值写入 CR4 寄存器
    __asm__ __volatile__("mov %[v], %%cr4"::[v]"r"(v));
}

// 远程跳转到指定的选择子和偏移地址
static inline void far_jump(uint32_t selector, uint32_t offset) {
    uint32_t addr[] = {offset, selector }; // 构建选择子和偏移地址数组
    // 使用内联汇编指令 "ljmpl" 进行远程跳转
    __asm__ __volatile__("ljmpl *(%[a])"::[a]"r"(addr));
}

// 加载中断描述符表(IDT)
static inline void lidt(uint32_t start, uint32_t size) {
    struct {
        uint16_t limit;        // IDT 表限长
        uint16_t start15_0;    // IDT 表起始地址(低16位)
        uint16_t start31_16;   // IDT 表起始地址(高16位)
    } idt;

    // 将起始地址拆分为两部分存储
    idt.start31_16 = start >> 16;
    idt.start15_0 = start & 0xFFFF;
    idt.limit = size - 1;

    // 使用内联汇编指令 "lidt" 加载 IDT
    __asm__ __volatile__("lidt %0"::"m"(idt));
}

// 使 CPU 进入低功耗状态,直到收到中断信号
static inline void hlt(void) {
    // 使用内联汇编指令 "hlt" 使 CPU 进入低功耗状态
    __asm__ __volatile__("hlt");
}

// 加载任务状态段选择子到 TR 寄存器
static inline void write_tr(uint32_t tss_selector) {
    // 使用内联汇编指令 "ltr" 加载任务状态段选择子
    __asm__ __volatile__("ltr %%ax"::"a"(tss_selector));
}

// 读取 EFLAGS 寄存器的值
static inline uint32_t read_eflags(void) {
    uint32_t eflags;

    // 使用内联汇编指令 "pushfl" 将 EFLAGS 寄存器值压栈,然后 "popl %%eax" 弹栈到 eax
    __asm__ __volatile__("pushfl\n\tpopl %%eax":"=a"(eflags));
    return eflags;
}

// 写入 EFLAGS 寄存器的值
static inline void write_eflags(uint32_t eflags) {
    // 使用内联汇编指令 "pushl %%eax" 将 eflags 值压栈,然后 "popfl" 弹栈到 EFLAGS 寄存器
    __asm__ __volatile__("pushl %%eax\n\tpopfl"::"a"(eflags));
}

检测内存容量

在 x86 架构中,检测内存容量常用的方法是通过 BIOS 中断 int 0x15 调用功能号 0xE820,这是最常见、最可靠的内存检测方式之一,适用于 保护模式之前(实模式) 执行的 bootloader 阶段。

使用LBA读取磁盘

/**
* 使用LBA48位模式读取磁盘
*/
static void read_disk(int sector, int sector_count, uint8_t * buf) {
    outb(0x1F6, (uint8_t) (0xE0));

	outb(0x1F2, (uint8_t) (sector_count >> 8));
    outb(0x1F3, (uint8_t) (sector >> 24));		// LBA参数的24~31位
    outb(0x1F4, (uint8_t) (0));					// LBA参数的32~39位
    outb(0x1F5, (uint8_t) (0));					// LBA参数的40~47位

    outb(0x1F2, (uint8_t) (sector_count));
	outb(0x1F3, (uint8_t) (sector));			// LBA参数的0~7位
	outb(0x1F4, (uint8_t) (sector >> 8));		// LBA参数的8~15位
	outb(0x1F5, (uint8_t) (sector >> 16));		// LBA参数的16~23位

	outb(0x1F7, (uint8_t) 0x24);

	// 读取数据
	uint16_t *data_buf = (uint16_t*) buf;
	while (sector_count-- > 0) {
		// 每次扇区读之前都要检查,等待数据就绪
		while ((inb(0x1F7) & 0x88) != 0x8) {}

		// 读取并将数据写入到缓存中
		for (int i = 0; i < SECTOR_SIZE / 2; i++) {
			*data_buf++ = inw(0x1F0);
		}
	}
}

在 x86 实模式或早期保护模式中,使用 LBA(Logical Block Addressing)方式读取磁盘,可以通过 BIOS 中断 int 0x13, ah=0x42h 实现。这种方式比传统的 CHS(Cylinder-Head-Sector)方式更现代、更方便。

我们使用 LBA 读取磁盘扇区,对从实模式进入保护模式 和 加载 OS 内核 有着关键作用。

使用 LBA 是关键的过渡工具:

🔹 一、在实模式下读取大于 1 个扇区的数据

因为实模式下的 bootloader 只有 512 字节,它没法容纳复杂的 loader 或 OS 内核,我们必须通过 LBA 读取更多数据到内存中,将控制权交给更强大的 loader。

LBA 帮我们实现:

  • 读取 loader 第二阶段程序(通常位于 LBA 1 开始)
  • 读取 kernel.elf 映像文件(几十 KB 甚至 MB)

#🔹 二、构建“从 boot 到 OS”桥梁的关键步骤

启动流程分阶段如下:

text
复制代码
[BIOS/实模式]
   ↓
int 0x13 (LBA)  ← 读取扇区
   ↓
[Loader]       ← 构建GDT,切换保护模式
   ↓
int 0x13 (LBA)  ← 再次读取内核 ELF
   ↓
[Kernel/OS]


✅ 和保护模式的关系

  1. 读取 loader 前:LBA 让 bootloader 能加载更复杂的 loader(因为 bootloader 太小,只有 512 字节,功能有限)。
  2. 在 loader 中:还在实模式,但已经能使用 LBA 来读取内核,把 kernel.elf 读入内存
  3. 读取内核后切换到保护模式:loader 读完 kernel 后,才设置 GDT、打开 CR0.PE,进入保护模式。
  4. OS 在保护模式中运行:最终,加载完内核并切换模式后,操作系统以 32 位方式执行。

✅ 举个真实场景:

假设你操作系统内核大小是 128KB,那就需要:

  • 读取:128KB / 512B = 256 个扇区
  • bootloader 通过 int 0x13, ah=0x42(LBA)读取 LBA 1~256 到内存 0x100000 处
  • 再跳转到 kernel_maine_entry

✅ 总结LAB一句话:

LBA 是 bootloader 和 loader 从磁盘读取更大程序(如 loader/kernel)的唯一方法,它为后续切换保护模式和加载内核打下了物理基础,是从实模式向现代操作系统过渡的关键一步。

再总结一下读取磁盘的过程吧

首先上电自检,检查磁盘内存硬件是否完好POST检查
然后将MBR第一块磁盘读入内存(chs方式 0x13h),检查尾部是否是0x55aa,如果是的话说明是有效的MBR
然后加载MBR的loader引导程序(LAB方式读取磁盘)然后加载内核最后运行操作系统

面试叙述版本:

在 x86 架构下,操作系统启动时最初是由 BIOS 主导的。BIOS 会读取启动设备的第一个扇区(即 Boot Sector),并将这 512 字节加载到内存地址 0x7C00。如果该扇区末尾是 0x55AA,就会跳转执行这段代码,也就是我们编写的 Bootloader。

Bootloader 是我写的第一段运行在内存中的程序,主要负责初始化 CPU 模式、加载更多的数据,比如第二阶段 Bootloader 或直接解析并加载内核文件(如 kernel.elf)。由于 CPU 只能执行内存中的指令,所以 Bootloader 的核心任务就是将后续代码从磁盘读入内存并跳转执行。

在我设计的系统中,Bootloader 会从磁盘读取一个 ELF 格式的内核镜像,并解析它的 Program Header,将其中的代码段和数据段加载到内存的合适地址,比如 0x100000。最后它会跳转到 ELF 的入口地址,正式开始执行内核。

整个过程是一个从“磁盘存储”到“内存运行”的转换,体现了系统启动过程中从 BIOS 到 Bootloader,再到操作系统内核的完整链路。


🎯 加分建议:

  • 如果你能提到 “BIOS 使用 INT 13h 来读取磁盘” 或者 “LBA 和 CHS 的差别”,面试官会觉得你对底层非常熟悉。

从实模式->保护模式

在这里插入图片描述
第一步 关中断(内联函数封装)cli()

第二步 打开A20地址线

第三步 加载GDT表

第四步 设置CR0

第五步 远跳转

static void  enter_protect_mode() {
    // 关中断
    cli();

    // 开启A20地址线,使得可访问1M以上空间
    // 使用的是Fast A20 Gate方式,见https://wiki.osdev.org/A20#Fast_A20_Gate
    uint8_t v = inb(0x92);
    outb(0x92, v | 0x2);

    // 加载GDT。由于中断已经关掉,IDT不需要加载
    lgdt((uint32_t)gdt_table, sizeof(gdt_table));

    // 打开CR0的保护模式位,进入保持模式
    uint32_t cr0 = read_cr0();
    write_cr0(cr0 | (1 << 0));

    // 长跳转进入到保护模式
    // 使用长跳转,以便清空流水线,将里面的16位代码给清空
    far_jump(8, (uint32_t)protect_mode_entry);
}

cmake中反汇编之前是16位的现在也要改成32位的

向内核传递启动信息

这一小节主要讲解了如何利用栈去传递参数和变量

在 x86 中,栈是函数调用、参数传递和局部变量分配的核心机制。通过 push 参数、保存返回地址、设置新的 ebp 栈帧,函数可以有序访问参数和变量。操作系统也通过栈完成系统调用处理、中断响应和上下文切换。
在这里插入图片描述

代码数据段链接脚本

使用kernel.lds
将不同的段指定到固定的地址
在链接脚本中,可以做更为复杂和灵活的设置。其语法结构为:

section {

. = 虚拟地址;

.text/.rodata/.data/.bss : {

目标文件(.text/.data/.bss/.data) /可使用通配符*/

}

在这里插入图片描述

加载内核映像文件

在这里插入图片描述
如果是elf格式的文件,有两个优势 1.更小 2.权限设置

内核使用 ELF 格式作为映像,是因为 ELF 拥有良好的段结构、入口信息、符号表、调试支持等优点,不仅方便 bootloader 加载,也极大提升了开发、调试、模块化支持的能力。相比 .bin 文件,ELF 更加系统化和可扩展,是现代操作系统开发的标准选择。

ELF 之所以“可扩展”,是因为它的结构允许你添加自定义段、符号、重定位信息、调试数据等;同时也支持动态加载、跨平台、多架构,适合内核模块系统、动态库、调试器等各种需求,是现代操作系统通用的映像格式标准。

加载kernel.elf

你的代码中加载 kernel.elf 的过程是非常标准的 自制 bootloader/loader 加载 ELF 内核的方式。下面我为你完整梳理你这段 loader 加载 kernel.elf 的关键步骤和逻辑:


✅ 你是怎么加载 kernel.elf 的?(逐步讲解)

🔹 1. 从磁盘读取 kernel.elf 到内存

你通过 BIOS 磁盘函数(LBA 或 CHS)将内核文件从磁盘的某个扇区读入内存中:


read_disk(100, 500, (uint8_t *)SYS_KERNEL_LOAD_ADDR);

这行代码含义是:

  • 从硬盘读取 第 100 个扇区开始,共 500 个扇区
  • 把读取到的数据存到内存的地址 SYS_KERNEL_LOAD_ADDR(比如 0x100000)

✅ 也就是说,kernel.elf 文件已经被你从硬盘完整读进了内存。


🔹 2. 手动解析 ELF 文件格式

你调用 reload_elf_file() 函数,把 ELF 文件中有用的部分复制到真正的目标地址:

c
复制代码
uint32_t kernel_entry = reload_elf_file((uint8_t *)SYS_KERNEL_LOAD_ADDR);

这个函数做了什么?

  • 检查文件前 4 字节是否是魔数 0x7F 'E' 'L' 'F'
  • 遍历 ELF 文件的 Program Header Table
    • 找到类型是 PT_LOAD 的段

    • 把段内容从文件偏移 phdr->p_offset 拷贝到目标内存地址 phdr->p_paddr

    • 如果有 .bss(未初始化数据段),则额外清 0:

      c
      复制代码
      for (...) *dest++ = 0;
      
      

✅ 最终把 .text、.data、.rodata、.bss 等段完整加载到内存它们各自应该在的位置。


🔹 3. 返回 kernel 的入口地址
c
复制代码
return elf_hdr->e_entry;

这是 ELF 文件头中 e_entry 字段,表示内核入口地址。


🔹 4. 开启分页
c
复制代码
enable_page_mode();

你设置了一个简单的页表:

  • 把低 4MB 映射到 0x00000000(保证 loader 能继续执行)
  • 同时映射 0xC0000000(或 SYS_KERNEL_BASE_ADDR)到物理地址 0x00000000(内核高地址映射)

🔹 5. 跳转到 kernel_main
c
复制代码
((void (*)(boot_info_t *))kernel_entry)(&boot_info);

你强制把入口地址转换为函数指针,并调用它:

  • 也就是执行 kernel_main(boot_info)(或者 _start()
  • 此时正式进入内核世界

✅ 图示理解


[bootloader]
   ↓
读取 loader 到内存 (0x8000)
   ↓
[loader]
→ 实模式 → 开启保护模式
→ read_disk → 读 kernel.elf → 加载到 0x100000
→ reload_elf_file → 加载段 → 获取入口地址
→ 开启分页
→ ((void(*)(boot_info*))entry)(&boot_info)
   ↓
[kernel_main()]


✅ 总结一句话

你使用 read_disk 把 kernel.elf 加载进内存,然后通过 reload_elf_file() 解析 ELF 结构,把 .text/.data/.bss 段分别复制到 p_paddr 指定的位置,返回入口地址,开启分页后跳转到内核执行。

最后的总结

面试叙述版本1:从 Bootloader 到 Kernel 的加载过程

在我自研的操作系统中,整个启动流程遵循 x86 的典型引导架构,分为 BIOS → Bootloader → Loader → Kernel 四个阶段。

启动初期由 BIOS 主导,它通过 CHS 方式调用 INT 13h 将启动设备第一个扇区(MBR)加载到内存地址 0x7C00,并检查其尾部签名 0x55AA 以判断是否为有效引导扇区。我的 Bootloader 正是从这里开始执行。

Bootloader 的职责是切换 CPU 至保护模式,并使用 LBA 模式从磁盘中读取更复杂的 Loader 或内核映像。我在 Bootloader 中使用了内联汇编,直接操作 I/O 端口、控制寄存器、加载 GDT 并进行远跳转(ljmp)进入 32 位保护模式。

进入保护模式后,我的第二阶段 Loader 通过自定义的 read_disk() 函数使用 LBA48 位协议,从磁盘的第 100 个扇区起读取内核 kernel.elf 到内存中的 0x100000

加载完成后,我手动解析 ELF 文件格式,通过 Program Header Table 遍历段信息,将每个段(如 .text.data.bss)复制到其目标物理地址 p_paddr,并最终获取 ELF 的入口地址 e_entry

在分页机制上,我设置了一套简单的页表,同时映射低 1MB 和内核高地址空间 0xC0000000,以便后续进入高地址内核执行。

最后,通过函数指针跳转至内核入口,并传递 boot_info 结构体完成启动信息的传递,系统正式进入内核主函数 kernel_main()

整个过程展现了我对 Boot 流程、实模式与保护模式切换、内联汇编操作、磁盘 LBA 协议、ELF 文件解析与加载、分页机制配置等核心系统能力的掌握。


✅ 可选补充(应对深入提问):

  • 为什么用 ELF?

    ELF 是标准的可执行文件格式,支持段划分、入口地址、调试信息,非常适合内核加载与调试,相比裸 bin 文件更专业、更系统化。

  • 为什么用内联汇编?

    因为操作系统开发必须直接访问硬件和控制 CPU 状态,如端口操作、加载 GDT/IDT、读写 CR0~CR4、进入保护模式等,C 语言无法胜任,需要通过内联汇编直接封装。

  • 如何检测内存?

    在 Bootloader 阶段我使用了 BIOS 中断 INT 0x15, EAX=0xE820 的方式进行内存检测,获取有效内存区间,供分页和物理内存管理模块使用。

面试叙述版本2(自制 Bootloader → 加载 kernel.elf)

在我的操作系统中,我实现了自定义的 Bootloader 和 Loader,不依赖 GRUB。系统启动时,BIOS 会通过 INT 13h 将磁盘的第一个扇区加载到内存 0x7C00 并执行。我的 Bootloader 进入后,通过封装好的 read_disk() 函数使用 LBA48 模式,从磁盘第 100 扇区开始读取大约 500 个扇区,目标是将 kernel.elf 完整地加载到内存中的 0x100000 处(即 SYS_KERNEL_LOAD_ADDR)。

LBA 读取过程通过一系列向 IDE 控制器端口(0x1F0~0x1F7)发出的 outb/inw 指令完成,依赖硬件的状态位轮询以确保数据准备就绪。这段代码使用内联汇编封装端口读写指令,实现了和设备层的直接交互,体现了底层 I/O 能力。

接下来进入 Loader 阶段。我手动解析 ELF 文件格式,通过 reload_elf_file() 函数读取 ELF 头、遍历 Program Header Table,对每个 PT_LOAD 段将 p_filesz 对应的内容从 ELF 文件中复制到 p_paddr 所指定的物理地址,同时对 p_memsz 多余的部分进行清零。这相当于把 .text.data.bss 等段分别加载到了内核期望的位置,确保内核启动时能正确运行。

加载完成后,我启用了分页机制。由于此时页表还未建立,我用的是临时页表,通过启用 CR4 的 PSE 位构造 4MB 大页,仅设置一个页目录项,把物理地址 0 映射到 0x00000000,同时也让内核可以通过高地址访问相同物理内存(例如映射到 0xC0000000)。通过写入 CR3 和打开 CR0 的 PG 位,正式进入分页模式。

最后,我将 ELF 文件头中的 e_entry 入口地址转换为函数指针,传入 boot_info 结构体,跳转执行,进入 kernel_main(),系统开始进入真正的内核阶段。

这一整套启动逻辑由我独立实现,涵盖了 LBA 磁盘读取、ELF 加载、内存段映射、分页开启、CPU 控制寄存器操作等操作系统启动的核心知识点,充分锻炼了我对 x86 启动过程的理解和裸机开发的能力。


✅ 技术关键词(适合你面试时适当穿插)

  • LBA48 磁盘读取
  • 内联汇编访问端口(inb/outb/inw)
  • ELF Program Header Table 加载
  • p_filesz / p_memsz 对比处理
  • 4MB 页(CR4.PSE)
  • CR0 / CR3 / CR4 设置
  • boot_info 参数传递
  • 高地址内核映射


🔧 面试官追问应对建议

问题类型 回答建议
Q:你为什么不用 GRUB? 为了完全掌控启动过程,练习自己写磁盘读取、ELF 解析、分页等能力,同时也更贴合嵌入式/最小系统开发需求。
Q:ELF 的好处是什么? 支持段加载、调试信息、入口地址等标准结构,适合可扩展的模块化内核。比裸 bin 文件更专业、可维护。
Q:为什么要用分页? 一方面是内核地址空间隔离,另一方面通过高地址映射,内核地址统一,避免与 loader 重叠,提高安全性和一致性。
Q:reload_elf_file 是怎么处理 bss 段的? 如果 p_memsz > p_filesz,我会将剩下的空间手动清零,模拟 .bss 段未初始化行为,符合 C/C++ 语言运行时的预期。

网站公告

今日签到

点亮在社区的每一天
去签到