这篇能做出什么
做完这篇教程,你会得到一个能启动的最小操作系统镜像:它在 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 有多强,而在于你亲手把“机器启动”这件事拆开看过一遍。以后遇到任何“系统起不来”的问题,你的排查顺序会比别人清楚得多。
