Zig 裸机开发:从零编写最小内核

本文用 Zig 从零编写一个 x86-64 最小内核:freestanding 编译环境、链接脚本、无 std 的 VGA/串口输出、GDT/IDT 与中断处理、Multiboot2 启动,以及 QEMU 调试工作流。

内核开发是最残酷也最纯粹的 Zig 训练场:没有 libc、没有操作系统帮忙、甚至没有 printf。你要亲自设置 CPU 寄存器、布置内存段、登记中断,然后一点点让屏幕亮起来。Zig 的显式哲学在这里发挥到极致——你写的每一行汇编都精确对应一段硬件行为。

本文以 x86-64 为例,从零构建一个可在 QEMU 中启动的最小内核,覆盖 freestanding 编译、链接脚本、无 std 的 I/O、GDT/IDT、中断处理与 Multiboot2 启动。

1. freestanding 环境

1.1 什么是 freestanding

普通程序运行在操作系统之上:启动时由 loader 布置好栈和参数,运行时随时调用 libc。内核没有这些——它的代码由 bootloader 直接加载到内存,从第一条指令起就要自己管理一切:

资源普通程序内核
栈系统布置自己分配并设置 RSP
内存虚拟地址 + 分页直接面对物理内存
标准输出printf/stdout自己写显存或串口寄存器
异常处理libc + OS自己写 IDT 并登记处理器
时钟/驱动OS 管理自己初始化 PIC/APIC

1.2 Zig 的 freestanding 目标

Zig 原生支持 os_tag = .freestanding 的目标三元组——没有 libc、没有启动文件、没有任何隐式依赖:

// build.zig
const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.resolveTargetQuery(.{
        .cpu_arch = .x86_64,
        .os_tag = .freestanding,   // 无操作系统
        .abi = .none,
    });
    const optimize = b.standardOptimizeOption(.{});

    const kernel = b.addExecutable(.{
        .name = "plumeos",
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    });
    kernel.setLinkerScript(b.path("src/linker.ld"));
    kernel.entry = .{ .symbol = "_start" }; // Zig 0.14+;0.13 为 entry_symbol_name

    // 安装 ELF(QEMU -kernel / GRUB 需要)
    b.installArtifact(kernel);

    // 同时生成裸二进制(自定义 bootloader 或直接烧写用)
    const raw = kernel.addObjCopy(.{ .format = .bin });
    const install_raw = b.addInstallFileWithDir(raw.getOutput(), .bin, "plumeos.bin");
    b.getInstallStep().dependOn(&install_raw.step);
}

1.3 无 std 时能用什么

在 freestanding 目标上,@import("std") 依然可用,但必须避开依赖操作系统的部分:

可用不可用
std.mem、std.builtin、std.fmt(comptime 部分)std.fs、std.net、std.Thread
comptime 与泛型全套能力std.debug.print(需 OS 写 fd)
std.math、纯函数工具page_allocator(底层是 mmap)
自定义的 panicstd.heap.GeneralPurposeAllocator(依赖系统分配)

最简单可靠的做法:内核代码尽量不 import std,需要什么(memcpy、字符串)自己实现或用 comptime 展开。

2. 链接脚本与启动流程

2.1 内存布局

linker.ld 告诉链接器把各个段放在物理内存的什么位置。Multiboot 约定内核从 1MB 处加载:

ENTRY(_start)

SECTIONS {
    . = 1M;                 /* 内核加载基址,1MB 以上 */

    .text : ALIGN(4K) {
        *(.multiboot)       /* 多引导头部必须最先 */
        *(.text*)           /* 代码段 */
    }

    .rodata : ALIGN(4K) { *(.rodata*) }
    .data   : ALIGN(4K) { *(.data*) }
    .bss    : ALIGN(4K) { *(COMMON) *(.bss*) }

    . = ALIGN(4K);
    .stack : { . = . + 16K; }   /* 预留 16KB 内核栈 */
    stack_top = .;              /* 栈顶符号,供 _start 引用 */
}

2.2 入口与栈设置

_start 是内核的第一行代码:bootloader 跳到此处时,CPU 已处于保护/长模式,但栈还是 bootloader 的——内核必须立刻切换到自己的栈:

// src/main.zig
extern const stack_top: u8;   // 来自链接脚本的符号

export fn _start() noreturn {
    // 把 RSP 指向内核栈顶部(栈向下增长)
    asm volatile ("mov %[rsp], %%rsp" :: [rsp] "r" (@intFromPtr(&stack_top)) : "memory");

    // 显式清零 EFLAGS 的方向位等(可选但稳妥)
    asm volatile ("cld");

    kmain();
    hlt();
}

fn hlt() noreturn {
    while (true) asm volatile ("hlt");
}

@intFromPtr(&stack_top) 取的是链接脚本符号的地址——extern const u8 只是一个"地址占位符",读它的地址就能拿到符号值。

3. 无 std 的最小内核

3.1 VGA 文本模式输出

x86 兼容机从实模式起就保留了 0xB8000 处的文本帧缓冲:每 2 字节一个字符(字符 + 属性)。把它声明为 volatile 数组,写入即可显示:

const VGA = struct {
    const WIDTH = 80;
    const HEIGHT = 25;
    const buffer: *volatile [WIDTH * HEIGHT * 2]u8 = @as(*volatile [WIDTH * HEIGHT * 2]u8, @ptrFromInt(0xB8000));

    const WHITE_ON_BLACK = 0x0F;
    var cursor: usize = 0;

    fn clear() void {
        var i: usize = 0;
        while (i < WIDTH * HEIGHT * 2) : (i += 2) {
            buffer[i] = ' ';
            buffer[i + 1] = WHITE_ON_BLACK;
        }
        cursor = 0;
    }

    fn put(c: u8) void {
        if (c == '\n') {
            cursor += WIDTH - (cursor % WIDTH);
        } else {
            buffer[cursor * 2] = c;
            buffer[cursor * 2 + 1] = WHITE_ON_BLACK;
            cursor += 1;
        }
        if (cursor >= WIDTH * HEIGHT) cursor = 0; // 简易回卷
    }

    fn print(msg: []const u8) void {
        for (msg) |c| put(c);
    }
};

fn kmain() void {
    VGA.clear();
    VGA.print("Hello from PlumeOS!\n");
    VGA.print("Zig bare-metal kernel is running.\n");
}

*volatile 是必需的——没有它,编译器可能把"看起来没被读取"的显存写入优化掉,或批量重排写入。

3.2 串口输出(UART/COM1)

文本模式在图形界面上不可见,但串口是内核调试的黄金通道。COM1 基址 0x3F8,通过 16550 UART 的寄存器协议发送字节:

const COM1 = 0x3F8;

fn serial_init() void {
    const lcr: *volatile u8 = @ptrFromInt(COM1 + 3);
    lcr.* = 0x80;          // 允许写分频寄存器
    const dll: *volatile u8 = @ptrFromInt(COM1 + 0);
    const dlm: *volatile u8 = @ptrFromInt(COM1 + 1);
    dll.* = 0x01;          // 115200 波特率(1.8432MHz / 16 / 1)
    dlm.* = 0x00;
    lcr.* = 0x03;          // 8 数据位,无校验,1 停止位
    const ier: *volatile u8 = @ptrFromInt(COM1 + 1);
    ier.* = 0x00;          // 关闭中断
}

fn serial_put(c: u8) void {
    // 等待发送保持寄存器空(THR Empty = bit5 of LSR)
    const lsr: *volatile u8 = @ptrFromInt(COM1 + 5);
    while ((lsr.* & 0x20) == 0) {}
    const thr: *volatile u8 = @ptrFromInt(COM1 + 0);
    thr.* = c;
}

QEMU 用 -serial stdio 把 COM1 重定向到终端,即可看到内核日志。

3.3 自定义 panic

内核代码不可能没有 bug,panic 是最后的安全网。Zig 允许在根文件定义 panic 覆盖默认行为:

pub fn panic(msg: []const u8, error_return_trace: ?*std.builtin.StackTrace, ret_addr: ?usize) noreturn {
    _ = error_return_trace;
    _ = ret_addr;
    VGA.print("KERNEL PANIC: ");
    VGA.print(msg);
    VGA.print("\n");
    hlt();
}

定义它之后,任何 unreachable、数组越界(Debug 模式)、@panic 都会走到这里,而不是依赖不存在的 OS。

4. GDT 与中断处理

4.1 GDT:平坦内存模型

x86-64 在长模式下段选择子大多退居幕后,但 CPU 仍要求 GDT 有效且 CS 指向一个合法的代码段。定义一个"平坦模型"(基址 0,限长全部)的 GDT:

const GdtEntry = packed struct {
    limit_lo: u16,
    base_lo: u16,
    base_mid: u8,
    access: u8,      // P=1, DPL=0, S=1, 代码/数据位等
    granularity: u8, // G=1(4K 粒度), D/B, L=1(64 位)
    base_hi: u8,
};

// null | 代码段(0x08) | 数据段(0x10)
const gdt = [_]GdtEntry{
    .{ .limit_lo = 0, .base_lo = 0, .base_mid = 0, .access = 0, .granularity = 0, .base_hi = 0 },
    .{ .limit_lo = 0xFFFF, .base_lo = 0, .base_mid = 0, .access = 0x9A, .granularity = 0xAF, .base_hi = 0 },
    .{ .limit_lo = 0xFFFF, .base_lo = 0, .base_mid = 0, .access = 0x92, .granularity = 0xAF, .base_hi = 0 },
};

const GdtPtr = packed struct { limit: u16, base: u64 };

fn gdt_init() void {
    var ptr: GdtPtr = .{
        .limit = @sizeOf(GdtEntry) * gdt.len - 1,
        .base = @intFromPtr(&gdt),
    };
    asm volatile ("lgdt %[ptr]" :: [ptr] "m" (ptr) : "memory");
    // 重载数据段寄存器
    asm volatile (
        \\movw $0x10, %ax
        \\movw %ax, %ds
        \\movw %ax, %es
        \\movw %ax, %ss
    );
    // 提示:重载 CS 需要一条 far jump,通常由汇编 stub 完成;
    // 平坦模型下旧的 0x08 选择子依然指向兼容的代码段,QEMU 下可正常工作。
}

注意:lgdt 的输入必须是内存操作数("m"),不能是寄存器——LGDT 指令操作数就在内存里。

4.2 IDT:登记中断

IDT 每个表项 16 字节,描述中断处理函数的地址、段选择子和类型属性:

const IdtEntry = packed struct {
    offset_lo: u16,
    selector: u16,
    ist: u3,
    _reserved0: u5,
    type_attr: u8,     // 0x8E = present, 32-bit 中断门
    offset_mid: u16,
    offset_hi: u32,
    _reserved1: u32,
};

var idt: [256]IdtEntry = undefined;
const IdtPtr = packed struct { limit: u16, base: u64 };

fn set_idt_entry(num: u16, handler: usize, selector: u16, flags: u8) void {
    idt[num] = .{
        .offset_lo = @truncate(handler),
        .selector = selector,
        .ist = 0,
        ._reserved0 = 0,
        .type_attr = flags,
        .offset_mid = @truncate(handler >> 16),
        .offset_hi = @truncate(handler >> 32),
        ._reserved1 = 0,
    };
}

fn idt_init() void {
    // 除法错误(#DE)与一般保护错误(#GP)各挂一个处理器
    set_idt_entry(0,  @intFromPtr(&exc_div0), 0x08, 0x8E);
    set_idt_entry(13, @intFromPtr(&exc_gpf),  0x08, 0x8E);

    var ptr: IdtPtr = .{ .limit = @sizeOf(IdtEntry) * idt.len - 1, .base = @intFromPtr(&idt) };
    asm volatile ("lidt %[ptr]" :: [ptr] "m" (ptr) : "memory");
}

4.3 中断处理器

Zig 对 x86 提供 callconv(.interrupt)——编译器自动生成中断门的进出序列(压寄存器、iret 返回):

fn exc_div0() callconv(.interrupt) void {
    VGA.print("EXCEPTION: Divide by zero\n");
    hlt();
}

fn exc_gpf() callconv(.interrupt) void {
    VGA.print("EXCEPTION: General Protection Fault\n");
    hlt();
}

主动触发验证:

fn kmain() void {
    VGA.clear();
    VGA.print("PlumeOS: setting up GDT & IDT...\n");
    gdt_init();
    idt_init();

    // 故意除以零,验证 IDT 生效
    const zero: u32 = 0;
    const x: u32 = 1 / zero;
    _ = x;
}

QEMU 下应看到 EXCEPTION: Divide by zero 然后停机。完整的中断调度(保存错误码、打印 CPU 状态、区分可恢复/致命)需要数百行,真实内核会把它做成一张"中断号 → 处理函数指针"的分发表——正是 https://plumephp.com/zig-comptime-programming/ 中编译期派发的典型应用。

5. Bootloader 与 Multiboot2 启动

5.1 多引导头部

要让 GRUB 或 QEMU 识别我们的内核,文件开头必须有一个多引导头部。Multiboot2 头部格式:

const MULTIBOOT2_MAGIC = 0xE85250D6;

const Mb2Header = extern struct {
    magic: u32,
    architecture: u32,     // 3 = x86-64(0 = i386)
    header_length: u32,
    checksum: u32,
};

// 放在 .multiboot 段,链接脚本保证它位于文件最前
export var mb2_header linksection(".multiboot") = Mb2Header{
    .magic = MULTIBOOT2_MAGIC,
    .architecture = 3,
    .header_length = @sizeOf(Mb2Header),
    .checksum = -(MULTIBOOT2_MAGIC + 3 + @sizeOf(Mb2Header)),
};

checksum 必须满足 magic + architecture + header_length + checksum == 0,否则 bootloader 拒绝加载。(Zig 0.14+ 把 linksection 改名为 @section。)

5.2 启动流程

CPU 上电
  └─ BIOS/UEFI 固件初始化硬件
       └─ 引导介质(GRUB / QEMU -kernel)
            └─ 扫描内核 ELF 的 Multiboot 头,校验 checksum
                 └─ 加载段到物理内存(入口 1MB)
                      └─ 切换 CPU 到长模式并跳转 _start
                           └─ 内核接管:栈 → kmain → 基础设施

5.3 用 QEMU 验证

zig build
# 方法一:QEMU 直接加载 Multiboot ELF(QEMU ≥ 2.9 支持 Multiboot2)
qemu-system-x86_64 -kernel zig-out/bin/plumeos -m 512M -serial stdio

# 方法二:用 GRUB 生成可启动 ISO
# 准备 zig-out/boot/grub/grub.cfg:
#   menuentry "plumeos" { multiboot2 /boot/plumeos }
# grub-mkrescue -o plumeos.iso zig-out/
# qemu-system-x86_64 -cdrom plumeos.iso -serial stdio

-serial stdio 把 COM1 接到终端;再配合 -d int,cpu_reset 可以观察每次中断,是内核调试的好帮手。

6. 调试与演进路线

6.1 QEMU 调试工作流

# 等待 GDB 连接
qemu-system-x86_64 -kernel zig-out/bin/plumeos -s -S &

# 另一个终端
gdb zig-out/bin/plumeos
(gdb) target remote :1234
(gdb) b kmain
(gdb) c

Debug 优化级别下,Zig 生成带符号的 ELF,GDB 可以直接断在 Zig 源码行。

6.2 从最小内核到可用内核

里程碑需要的技术
屏幕/串口输出VGA/串口寄存器(已完成)
中断分发表中断号 → 处理器指针表 + 错误码解析
分页与虚拟内存CR3、页表构建、invlpg
内存分配器物理页帧分配器 + 内核堆 bump/伙伴分配器
用户态与系统调用特权级切换、syscall/sysret、TSS
多核与调度APIC、每 CPU 栈、上下文切换

每一步都可对照 https://plumephp.com/posts/os/ 专题的操作系统原理理解"为什么",再回到 Zig 里实现"怎么做"。

7. 最佳实践与总结

7.1 裸机开发的十条铁律

  1. 一切 MMIO 访问都要 volatile,防止编译器优化掉硬件交互。
  2. 在编译期断言布局:@sizeOf(IdtEntry) == 16、@sizeOf(Mb2Header) == 16。
  3. 尽早切到自己管理的栈,不要在 bootloader 的栈上运行复杂逻辑。
  4. 先串口后显存:串口输出在任何显示模式下都可用,是最可靠的生命线。
  5. Debug 模式开发,安全检查(数组越界、未定义值)能抓出大量早期 bug。
  6. 中断处理器保持极短,只记录状态、唤醒事件循环,不做重活。
  7. 每次只改一个子系统:GDT 通了再碰 IDT,IDT 通了再碰分页。
  8. QEMU + GDB 是标配,真机调试是最后手段。
  9. 用编译期特性生成分发表、异常消息表,避免运行时 switch 的样板代码。
  10. 保持 panic 路径可用,它会在你最需要的时候告诉你哪里崩了。

7.2 总结

Zig 是少数能让你"从第一条指令到完整内核"都用同一种语言推进的现代语言:comptime 生成硬件表、packed struct 精确描述寄存器、callconv(.interrupt) 原生支持中断门、编译期断言锁定 ABI。裸机开发把 https://plumephp.com/zig-memory-management/ 的分配器哲学和 https://plumephp.com/zig-comptime-programming/ 的编译期能力推向极限——没有任何运行时兜底,一切显式,一切可验证。下一步,本文的 freestanding 技术将无缝衔接到 https://plumephp.com/zig-embedded-cross-compile/ 的 ARM/RISC-V 交叉编译世界。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「系统编程」更多文章

  1. Zig 嵌入式开发:交叉编译与 MCU 裸机实践
  2. Zig 高级 FFI:动态库、回调、内存布局与 C++ ABI
  3. Zig 并发与原子操作:线程、同步与消息传递