理论讲解:加载C内核

1. 从汇编到C

到目前为止我们一直在写汇编。汇编适合底层操作(模式切换、硬件初始化),但用它写整个OS既痛苦又低效。

解决方案: 用汇编完成最小的初始化工作,然后跳转到C代码。

汇编代码(Bootloader)          C代码(内核)
┌────────────────┐            ┌────────────────┐
│ 加载内核到内存  │            │ void kernel_main() │
│ 设置GDT        │   call     │ {               │
│ 切换保护模式    │ ────────→ │   // 内核逻辑   │
│ 设置栈          │            │ }               │
│ 调用C函数      │            │                 │
└────────────────┘            └────────────────┘

2. C运行时环境

C代码需要几个基本条件:

  1. 栈 — 函数调用和局部变量(已在保护模式切换时设置)
  2. BSS段清零 — 未初始化全局变量必须为0
  3. 入口点 — 一个汇编桩来call kernel_main

内核入口(kernel_entry.asm)

[BITS 32]
[EXTERN kernel_main]    ; 声明C函数
global kernel_entry     ; 导出给链接器
 
kernel_entry:
    call kernel_main    ; 调用C入口
    jmp $               ; 如果返回则死循环

3. 最简单的C内核

void kernel_main(void) {
    char *video_memory = (char*)0xB8000;
    
    const char *msg = "Hello from C kernel!";
    for (int i = 0; msg[i] != '\0'; i++) {
        video_memory[i * 2] = msg[i];
        video_memory[i * 2 + 1] = 0x0F;
    }
    
    while (1) {
        __asm__ volatile("hlt");
    }
}

要点:

  • 没有 #include <stdio.h> —— freestanding环境,没有标准库
  • 直接写 0xB8000 输出到屏幕
  • while(1) hlt 防止CPU继续执行随机内存

4. 链接脚本

链接器需要知道如何安排各个段,以及内核在内存中的加载地址:

ENTRY(kernel_entry)     /* 入口点 */
 
SECTIONS {
    . = 0x1000;         /* 内核加载地址 */
    
    .text : { *(.text) }
    .rodata : { *(.rodata) }
    .data : { *(.data) }
    .bss : { *(COMMON) *(.bss) }
}

. = 0x1000 必须与Bootloader中的 KERNEL_OFFSET 一致。

5. 编译和链接过程

kernel_entry.asm ──→ nasm ──→ kernel_entry.o ─┐
                                                ├─→ ld ──→ kernel.bin
kernel.c ──→ gcc -m32 -ffreestanding ──→ kernel.o ─┘
                                          │
                                    使用 linker.ld

boot.asm ──→ nasm ──→ boot.bin

boot.bin + kernel.bin ──→ cat ──→ os-image.bin

关键编译选项:

  • -m32 — 生成32位代码
  • -ffreestanding — 独立环境,不依赖标准库
  • -fno-pie — 禁用位置无关代码
  • -c — 只编译不链接
  • --oformat binary — 输出原始二进制(不是ELF)