内核开发是最残酷也最纯粹的 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) |
| 自定义的 panic | std.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 裸机开发的十条铁律
- 一切 MMIO 访问都要
volatile,防止编译器优化掉硬件交互。 - 在编译期断言布局:
@sizeOf(IdtEntry) == 16、@sizeOf(Mb2Header) == 16。 - 尽早切到自己管理的栈,不要在 bootloader 的栈上运行复杂逻辑。
- 先串口后显存:串口输出在任何显示模式下都可用,是最可靠的生命线。
- Debug 模式开发,安全检查(数组越界、未定义值)能抓出大量早期 bug。
- 中断处理器保持极短,只记录状态、唤醒事件循环,不做重活。
- 每次只改一个子系统:GDT 通了再碰 IDT,IDT 通了再碰分页。
- QEMU + GDB 是标配,真机调试是最后手段。
- 用编译期特性生成分发表、异常消息表,避免运行时 switch 的样板代码。
- 保持 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 交叉编译世界。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。