跳到主内容
快讯直播
AI智模界
教程

用 Fable 5.2 从零造最小操作系统:四段实战

这篇能做出什么

做完这篇教程,你会得到一个能启动的最小操作系统镜像:它在 QEMU 里开机后先显示一句引导信息,接着进入 32 位内核,把文字写到屏幕上,同时通过串口往外打日志,能用键盘输入命令,最后出现一个能响应 help、echo、clear 的 Shell。

代码量不大,四个文件加一份构建脚本,但它是一条完整的链路:从 BIOS 交出控制权,到自己接管屏幕和键盘。中途每一次“改完就能看到变化”,都是可验证的,不需要一口气写几千行再祈祷它能跑。

整篇的组织方式是:把“造 OS”拆成引导、内核、驱动、Shell 四段,每段给出一个可以直接粘给 AI 编程助手(本文以 Fable 5.2 为例,具体能力与用法以官方文档当前版本为准)的提示词、一段可照抄的代码、以及明确的验收标准。四段全部通过,再合起来跑。

为什么强调“分阶段提示”?因为一次性让助手生成一个完整 OS,几乎必然得到一堆看似合理、实际跑不起来的代码。分段之后,每一段的产出小到你能自己读一遍,出错也容易定位。

前置条件清单

在动手前,把下面几样装上并确认可用:

  • Linux 或 macOS 终端(Windows 建议用 WSL2,路径处理会顺一些)
  • NASM:汇编器,负责把 .asm 变成二进制
  • GCC(能产出 32 位目标):gcc -m32 可用即可;如果报缺少 32 位库,装 gcc-multilib。更稳妥的做法是按 OSDev Wiki 的 “GCC Cross-Compiler” 条目自建一个 i686-elf-gcc 交叉编译器,版本以你所用发行版与官方文档为准
  • GNU ld:链接器,随 binutils 一起
  • GRUB 工具:grub-mkrescue、grub-file,以及它依赖的 xorriso(部分发行版还需 mtools)
  • QEMU:qemu-system-i386
  • GDB:调试用

一条命令检查大部分工具是否就位:

```bash

for t in nasm gcc ld grub-mkrescue grub-file qemu-system-i386 gdb objdump; do

printf '%-20s' "$t"; command -v $t >/dev/null && echo OK || echo MISSING

done

```

准备工作是第一步:能稳定复现,比能写出来更重要。 后面所有排错都依赖“我知道上一次能跑”。

阶段一:引导(Boot)

这段要做什么

先不碰 C,也不碰保护模式。只写一个 512 字节的引导扇区,让 BIOS 把它加载到内存 0x7C00 并执行,它用 BIOS 中断在屏幕上打一行字,然后死循环。

提示词

```text

你是操作系统教学助手。请写一个 512 字节的 x86 引导扇区,要求:

1. NASM 语法,16 位实模式,org 0x7C00;

2. 用 BIOS 中断 int 0x10、ah=0x0E 逐字符输出字符串 "Boot OK";

3. 输出后 cli + hlt 死循环;

4. 结尾用 times 补齐到 510 字节,再写 0xAA55;

5. 每一行加中文注释,特别是 org 的作用;

6. 只输出一个 boot.asm 文件,不要引入任何外部依赖。

```

代码

```asm

[bits 16]

[org 0x7c00] ; BIOS 把引导扇区加载到 0x7C00,所有标号按这个基址计算

start:

mov si, msg ; si 指向字符串首地址

.print:

lodsb ; al = [si], si++

or al, al ; 遇到 0 结束

jz .hang

mov ah, 0x0e ; BIOS 电传打字输出

mov bh, 0x00 ; 页号

int 0x10

jmp .print

.hang:

cli

hlt ; 停机,等中断(这里不会再醒)

jmp .hang

msg: db "Boot OK", 13, 10, 0

times 510 - ($ - $$) db 0

dw 0xAA55 ; 引导扇区签名,缺了 BIOS 不认

```

编译与运行

```bash

nasm -f bin boot.asm -o boot.bin

ls -l boot.bin # 必须正好 512 字节

qemu-system-i386 -drive format=raw,file=boot.bin

```

验收标准

  • boot.bin 大小恰好 512 字节
  • QEMU 窗口里出现 Boot OK,然后光标停住不再滚动
  • 反复运行结果一致

常见问题

  • 屏幕上什么都不显示:多半是忘了 org 0x7c00。没有它,mov si, msg 拿到的是按 0 基址算的地址,读到的就是垃圾。
  • QEMU 报 no bootable device:签名没写对,或者文件不是 512 字节。用 xxd boot.bin | tail -2 看最后两字节是不是 aa55。
  • 输出了乱码或不停刷屏:字符串少了结尾的 0,lodsb 会一直往后读。

一个必须做的决定

引导扇区只有 512 字节,放不下内核。下一步有两条路:自己写二级加载器(读磁盘、开 A20、建 GDT、进保护模式),或者把这件事交给 GRUB,用 Multiboot 规范让 GRUB 直接把内核加载进 32 位环境。

教学项目建议走第二条。自写加载器很有教育意义,但会把教程的一半篇幅耗在调试 A20 和磁盘读取上,而这两件事跟“理解操作系统”关系不大。前面那个引导扇区已经让你看清了 BIOS 交权的那一刻,够了。

从这一阶段起,向助手提出新提示词:

```text

把引导方式改为 Multiboot 1 + GRUB。请生成:

1. boot.asm:.multiboot 段放 magic 0x1BADB002、flags 0x00000003、两者之和的负值校验;

2. 定义一个 16KB 的内核栈(.bss 段),_start 里设置 esp 后 call kmain,返回后 cli/hlt 死循环;

3. 说明为什么 multiboot 头必须落在文件前 8192 字节内。

```

阶段二:内核(Kernel)

这段要做什么

让内核拿到 CPU 控制权,用一个不依赖任何库的方式往屏幕写字符。VGA 文本模式显存在 0xB8000,每个字符占两字节:低字节是 ASCII,高字节是属性(前景色、背景色)。

代码

kernel.c:

```c

/* 没有 libc,全部自己来 */

static volatile unsigned short *const VGA = (unsigned short *)0xB8000;

static int cursor = 0;

static void putc_vga(char c) {

if (c == '\n') {

cursor += 80 - (cursor % 80);

return;

}

VGA[cursor++] = (unsigned short)(unsigned char)c | (0x0F << 8); /* 白字黑底 */

if (cursor >= 80 * 25) cursor = 0;

}

void kprint(const char *s) {

while (*s) putc_vga(*s++);

}

void kmain(void) {

kprint("Kernel entered\n");

kprint("Stage 2 OK\n");

for (;;) __asm__ volatile("hlt");

}

```

linker.ld:

```ld

ENTRY(_start)

SECTIONS

{

. = 1M; /* 内核加载到 1MB,避开低端内存 */

.text : { *(.multiboot) *(.text) }

.rodata : { *(.rodata*) }

.data : { *(.data) }

.bss : { *(COMMON) *(.bss) }

}

```

boot.asm 的 32 位部分(片段):

```asm

[bits 32]

section .multiboot

align 4

dd 0x1BADB002

dd 0x00000003

dd -(0x1BADB002 + 0x00000003)

section .bss

align 16

stack_bottom:

resb 16384

stack_top:

section .text

global _start

extern kmain

_start:

mov esp, stack_top

call kmain

.hang:

cli

hlt

jmp .hang

```

编译与打包成 ISO

```bash

nasm -f elf32 boot.asm -o boot.o

gcc -m32 -c -ffreestanding -fno-pie -fno-stack-protector \

-fno-asynchronous-unwind-tables -O0 -Wall kernel.c -o kernel.o

ld -m elf_i386 -T linker.ld -o kernel.elf boot.o kernel.o

grub-file --is-x86-multiboot kernel.elf && echo "multiboot OK"

mkdir -p iso/boot/grub

cp kernel.elf iso/boot/

cat > iso/boot/grub/grub.cfg <<'EOF'

set timeout=0

set default=0

menuentry "mini-os" {

multiboot /boot/kernel.elf

boot

}

EOF

grub-mkrescue -o mini.iso iso

qemu-system-i386 -cdrom mini.iso

```

验收标准

  • grub-file --is-x86-multiboot kernel.elf 返回成功
  • QEMU 里跳过 GRUB 菜单,屏幕左上角出现两行字
  • objdump -h kernel.elf 能看到 .multiboot 在靠前的段里

常见问题

  • GRUB 报 “no multiboot header found”:multiboot 头被链接到了文件很靠后的位置,超过了前 8192 字节。把 .multiboot 放进 .text 的第一段,或者单独放最前面。
  • 链接报 relocation / PIE 相关错误:编译时补 -fno-pie,链接用 ld 而不是 gcc。用 gcc 直链会带上启动文件,跟自定义 _start 冲突。
  • 进去了但立刻重启:栈没设。call kmain 之前 esp 指向未知区域,第一次压栈就可能踩坏东西。
  • grub-mkrescue 失败:缺 xorriso 或 grub-pc-bin,按发行版官方文档装齐。

阶段三:驱动(Driver)

这段要做什么

两个最小外设:串口(COM1)和键盘(PS/2)。串口的意义不只是输出,它让你能在纯文本终端里看日志,也让后面接 GDB 调试更顺。

提示词

```text

在已有 32 位 freestanding 内核上增加两个驱动,不要引入 libc:

1. io.h:内联汇编实现 inb/outb;

2. serial.c:初始化 COM1(0x3F8) 为 8N1、关闭中断、轮询发送,提供 serial_init 和 serial_write;

3. keyboard.c:轮询 0x64 状态端口,确认输出缓冲区满且不是鼠标数据后读 0x60,返回扫描码;

4. 给出扫描码集 1 到 ASCII 的映射表(至少字母、数字、回车、退格);

5. 说明 0x64 状态寄存器各标志位的含义。

```

关键代码:

```c

/* io.h */

static inline void outb(unsigned short port, unsigned char v) {

__asm__ volatile("outb %0, %1" :: "a"(v), "Nd"(port));

}

static inline unsigned char inb(unsigned short port) {

unsigned char r;

__asm__ volatile("inb %1, %0" : "=a"(r) : "Nd"(port));

return r;

}

```

```c

/* serial.c */

#define COM1 0x3F8

void serial_init(void) {

outb(COM1 + 1, 0x00); /* 关中断 */

outb(COM1 + 3, 0x80); /* 打开 DLAB */

outb(COM1 + 0, 0x03); /* 分频低字节 */

outb(COM1 + 1, 0x00); /* 分频高字节 */

outb(COM1 + 3, 0x03); /* 8 位、无校验、1 停止位 */

outb(COM1 + 2, 0xC7); /* 使能 FIFO */

outb(COM1 + 4, 0x0B); /* 置 DTR/RTS,允许发送 */

}

void serial_write(char c) {

while (!(inb(COM1 + 5) & 0x20)) { } /* 等发送保持寄存器空 */

outb(COM1, (unsigned char)c);

}

void serial_print(const char *s) { while (*s) serial_write(*s++); }

```

```c

/* keyboard.c */

static unsigned char keyboard_poll(void) {

unsigned char status = inb(0x64);

if (!(status & 0x01)) return 0; /* 输出缓冲区空 */

if (status & 0x20) return 0; /* 这是鼠标数据,丢掉 */

return inb(0x60);

}

```

验收标准

  • QEMU 加 -serial stdio 启动后,终端里能看到内核打的串口日志
  • 按键后能在屏幕上看到对应字符(把 keyboard_poll 接到 putc_vga 上做临时验证)
  • 按下的键不会“吞掉”或重复两次

常见问题

  • 串口终端一片空白:-serial stdio 与 -nographic 同时用会打架,二选一;另外检查 serial_init 里 DLAB 的开关顺序,顺序错了分频值会写进错误寄存器。
  • 键盘读到的全是 0 或乱码:只判断了 0x01 没判断 0x20,把鼠标字节当键盘吃了。
  • 按键出现两个字符:松键时还会发一个“断开码”(扫描码 + 0x80),要么过滤最高位为 1 的字节,要么单独处理。

阶段四:Shell

这段要做什么

一个行编辑器加几个内置命令。功能很小,但它是第一个“你的 OS 在跟你对话”的时刻。

提示词

```text

在已有内核上实现一个最小 Shell:

1. 维护 128 字节行缓冲和长度,支持可打印字符、退格删除、回车提交;

2. 提示符 "> ",提交后按空格切分第一个词作为命令名;

3. 内置命令:help 列出命令,echo 原样回显参数,clear 清屏(直接写 0xB8000 填满空格),halt 停机;

4. 未知命令输出 "unknown: <cmd>";

5. 不要用任何 libc 函数,字符串比较自己写。

```

命令分发核心:

```c

static int streq(const char *a, const char *b) {

while (*a && *a == *b) { a++; b++; }

return *a == *b;

}

static void run(char *line) {

char *cmd = line;

while (*cmd == ' ') cmd++;

char *arg = cmd;

while (*arg && *arg != ' ') arg++;

if (*arg) { *arg++ = 0; while (*arg == ' ') arg++; }

if (streq(cmd, "help")) kprint("help echo clear halt\n");

else if (streq(cmd, "echo")) { kprint(arg); kprint("\n"); }

else if (streq(cmd, "clear")) clear_screen();

else if (streq(cmd, "halt")) { kprint("halted\n"); for (;;) __asm__ volatile("hlt"); }

else if (*cmd) { kprint("unknown: "); kprint(cmd); kprint("\n"); }

}

```

验收标准

  • 输入 help 回车,列出四条命令
  • 输入 echo hello world,回显 hello world
  • 退格能删掉刚打的字,且屏幕上同步擦除
  • 输入 clear 后屏幕干净、提示符回到左上角
  • 空行回车不报错

常见问题

  • 退格只删数据不删屏幕:光标位置得自己维护,退格时既要 len--,也要把显存对应位置写成空格并把光标移回来。
  • 输入一串字符后突然不响应:行缓冲越界写坏了相邻变量。加长度上限判断,这是最容易忽略的一行。
  • echo 把命令名也回显了:切分时忘了在命令名后写 0 终止符。

合起来在 QEMU 里跑

四段都过了验收,用一份 Makefile 串起来:

```makefile

CFLAGS = -m32 -ffreestanding -fno-pie -fno-stack-protector \

-fno-asynchronous-unwind-tables -O0 -Wall

OBJS = boot.o kernel.o serial.o keyboard.o shell.o

kernel.elf: $(OBJS) linker.ld

ld -m elf_i386 -T linker.ld -o $@ $(OBJS)

%.o: %.c

gcc $(CFLAGS) -c $< -o $@

%.o: %.asm

nasm -f elf32 $< -o $@

mini.iso: kernel.elf

mkdir -p iso/boot/grub

cp kernel.elf iso/boot/

grub-mkrescue -o $@ iso

run: mini.iso

qemu-system-i386 -cdrom mini.iso -serial stdio

clean:

rm -f *.o kernel.elf mini.iso

```

```bash

make clean && make run

```

调试三件套

1. 看 CPU 异常。 内核一启动就重启,通常是三重故障。用这行抓现场:

```bash

qemu-system-i386 -cdrom mini.iso -no-reboot -no-shutdown \

-d int,cpu_reset -D qemu.log

```

打开 qemu.log,找最后一次异常向量号和当时的 EIP,再 objdump -d kernel.elf 对照地址,基本能定位到函数。

2. 用 GDB 单步。 加 -s -S 让 QEMU 停在起点等调试器:

```bash

qemu-system-i386 -cdrom mini.iso -s -S

```

另开一个终端:

```bash

gdb

(gdb) set architecture i386

(gdb) file kernel.elf

(gdb) target remote localhost:1234

(gdb) break kmain

(gdb) continue

```

命中断点后 layout src 或 layout asm,单步看寄存器。注意在 GRUB 阶段是没有内核符号的,断点要设在 kmain 这类内核符号上,等 GRUB 跳转过去才会命中。

3. 用串口当日志。 在没有成熟调试器的环境里,serial_print 往往比 GDB 更快定位问题。养成在关键分支前后各打一行的习惯。

常见坑与排错速查

现象优先怀疑
QEMU 直接重启栈没设、三重故障、缺 hlt 死循环
GRUB 找不到 multiboot 头头被链接到 8KB 之后
链接报 relocation 错误缺 -fno-pie,或误用 gcc 直接链接
屏幕有字但立刻被刷掉属性字节写反,或者写越界回绕到 0
串口无输出DLAB 顺序、-serial 参数、FIFO 未使能
键盘时好时坏没过滤鼠标字节和断开码
改一行就全崩改完没做单段验收,一次动了太多东西

一条经验:每次只改一个阶段,改完立刻跑那一段的验收。 造 OS 的痛苦几乎全部来自“同时改了三处,不知道是哪一处坏的”。

下一步建议

四段跑通之后,可以按兴趣挑一个方向往下走,每个方向都仍然适合用分阶段提示推进:

  • 内存管理:先做物理页帧分配器(用 GRUB 给的 memory map),再做分页。验收标准是“能分配、能释放、重复分配不泄漏”。
  • 中断:搭 IDT,接 PIC,把键盘从轮询改成中断驱动。验收标准是“按键时中断计数递增,且系统不崩”。
  • 定时器:用 PIT 产生周期性中断,做一个 sleep。这是第一个“和时间有关”的内核功能。
  • 文件系统:先做一个只读的、内存里的简单结构,再考虑落到磁盘上。

给 AI 助手的提示词模板可以固定成四段式,长期有效:

```text

背景:一个 32 位 freestanding 内核,已有 <列出当前能力>。

任务:<一句话说清要加什么>。

约束:不用 libc;不引入外部库;给出完整可编译文件;关键处加中文注释。

验收:<写出你能亲手验证的现象,例如“执行 X 后屏幕上出现 Y”>。

排错:列出这一步最可能出的三个问题和判断方法。

```

最后提醒一句:这个项目的价值不在于最终那个 Shell 有多强,而在于你亲手把“机器启动”这件事拆开看过一遍。以后遇到任何“系统起不来”的问题,你的排查顺序会比别人清楚得多。

AI 生成本文由 AI 基于公开信息自动生成,仅供参考。