从零手写操作系统:开发环境搭建与内核架构设计实战指南

发布时间:2026/10/20 7:01:31

从零手写操作系统:开发环境搭建与内核架构设计实战指南 1. 从裸机到内核为什么开发环境与架构设计是操作系统的第一道坎很多人学操作系统一上来就抱着《操作系统概念》啃或者直接去读Linux源码结果看了几百行汇编就放弃了。我当初也是这么过来的后来才想明白一件事写操作系统不是读书读出来的是动手搭出来的。你连一个能跑起来的最小系统都没有谈什么进程调度、内存管理所以这个系列第三篇我专门把“开发环境建立”和“内核架构设计”放在一起讲因为这两件事本质上是一体的——环境决定了你能用什么工具链架构决定了你后面要写多少汇编、踩多少坑。这篇文章面向的是已经了解C语言和基本计算机组成原理但还没真正动手写过操作系统的朋友。我会从零开始把交叉编译工具链怎么选、引导加载程序怎么写、内核入口怎么设计、目录结构怎么规划全部拆开揉碎讲清楚。你跟着走一遍至少能得到一个能打印字符、能响应中断、能管理物理内存的最小内核骨架。这个骨架不依赖任何现成操作系统纯裸机运行烧到U盘或者用虚拟机加载都能跑。核心关键词我提前点一下交叉编译器、引导扇区、保护模式、内核入口、链接脚本、GDT、IDT、物理内存管理器。这些词你后面会反复遇到现在混个脸熟就行。2. 开发环境搭建工具链选型与虚拟机配置2.1 为什么必须用交叉编译器而不是本机编译器这是新手最容易犯的错误。你在x86_64的Linux上写操作系统目标平台也是x86看起来用系统自带的gcc就行了不行。原因有三个第一系统gcc默认链接的是用户态运行时库会插入大量你根本不需要的启动代码和系统调用第二你无法控制生成代码的指令集和内存模型比如你想生成纯32位代码系统gcc可能默认给你搞出64位指令第三链接脚本、目标文件格式这些细节你没法完全掌控。所以必须用交叉编译器也就是专门为目标平台构建的编译器。对于x86架构的操作系统开发我推荐用i686-elf-gcc或者x86_64-elf-gcc。这两个工具链在大多数Linux发行版上都能通过包管理器直接装比如在Debian/Ubuntu上sudo apt install gcc-i686-linux-gnu binutils-i686-linux-gnu但注意i686-linux-gnu是给Linux用户态程序用的虽然比系统gcc好一些但严格来说还是不够“裸”。最干净的做法是自己编译一套i686-elf工具链或者直接下载预编译好的。自己编译大概需要半小时到一小时步骤不复杂但比较繁琐我后面会单独讲。如果你只是想快速上手先用i686-linux-gnu也能跑通大部分流程等遇到链接错误再换。提示不要用gcc -m32来糊弄。-m32只是让编译器生成32位代码但链接器还是系统的那一套最终生成的可执行文件格式和运行时依赖都不对加载到裸机上必崩。2.2 虚拟机选型QEMU还是Bochs开发阶段强烈建议用QEMU因为它启动快、支持硬件加速、调试接口完善。Bochs虽然调试信息更详细但速度慢得让人抓狂只适合在QEMU里定位不到问题时作为补充。QEMU的基本用法很简单qemu-system-i386 -fda os.img -boot a这行命令把os.img当作软盘镜像启动。你也可以用硬盘镜像qemu-system-i386 -hda os.img -boot c调试的时候加上-s -S参数QEMU会在1234端口等待GDB连接qemu-system-i386 -fda os.img -s -S然后另开一个终端gdb (gdb) target remote localhost:1234 (gdb) break *0x7c00 (gdb) continue这样你就能在引导扇区第一条指令处下断点单步跟踪每一条汇编。我实测下来QEMU的GDB stub非常稳定比用Bochs的调试器舒服得多。2.3 镜像制作与烧写工具你需要一个工具把编译出来的二进制文件写成可启动的磁盘镜像。最常用的是dd命令但手动计算偏移量很容易出错。我推荐写一个简单的Makefile来自动化这个过程。另外nasm是必须的汇编器语法比GAS友好太多写引导扇区首选。sudo apt install nasm qemu-system-x86 gdb如果你在Windows上开发可以用WSL2装这些工具或者用MSYS2。但我不建议在纯Windows环境下搞各种路径和权限问题会让你怀疑人生。3. 引导加载程序从BIOS到保护模式的惊险一跃3.1 引导扇区的512字节限制与突破方法x86上电后BIOS会把磁盘的第一个扇区512字节加载到内存0x7C00处然后跳过去执行。这512字节里你要完成的事情包括初始化段寄存器、加载GDT、进入保护模式、把内核从磁盘读到内存、跳转到内核入口。听起来就不可能对吧512字节确实不够所以通常的做法是两级引导第一级引导扇区只负责把第二级引导程序从磁盘读到内存第二级再去做保护模式和加载内核。第一级引导的代码大概长这样[bits 16] [org 0x7c00] start: cli xor ax, ax mov ds, ax mov es, ax mov ss, ax mov sp, 0x7c00 mov [boot_drive], dl ; 读取第二级引导程序到0x8000 mov ah, 0x02 mov al, 4 ; 读4个扇区 mov ch, 0 ; 柱面0 mov cl, 2 ; 从第2个扇区开始 mov dh, 0 ; 磁头0 mov dl, [boot_drive] mov bx, 0x8000 int 0x13 jc disk_error jmp 0x8000 disk_error: mov si, msg call print jmp $ print: lodsb or al, al jz .done mov ah, 0x0e int 0x10 jmp print .done: ret msg db Disk error!, 0 boot_drive db 0 times 510-($-$$) db 0 dw 0xaa55这段代码的关键点org 0x7c00告诉汇编器代码会被加载到哪个地址int 0x13是BIOS磁盘读取中断ah0x02表示读扇区最后两字节0xaa55是引导扇区的魔数没有它BIOS不认。注意int 0x13读取的扇区数不能太大有些BIOS实现最多一次读128个扇区。而且现代BIOS对软盘模拟的支持越来越差建议直接用硬盘镜像用int 0x13的扩展功能ah0x42来读兼容性更好。3.2 进入保护模式GDT设置与远跳转保护模式是32位操作系统的运行基础。进入保护模式的核心步骤是关中断、加载GDT、设置CR0的PE位、远跳转刷新CS。GDT全局描述符表里至少要有三个描述符空描述符、代码段描述符、数据段描述符。gdt_start: dd 0x0 dd 0x0 gdt_code: dw 0xffff dw 0x0 db 0x0 db 10011010b db 11001111b db 0x0 gdt_data: dw 0xffff dw 0x0 db 0x0 db 10010010b db 11001111b db 0x0 gdt_end: gdt_descriptor: dw gdt_end - gdt_start - 1 dd gdt_start CODE_SEG equ gdt_code - gdt_start DATA_SEG equ gdt_data - gdt_start加载GDT并进入保护模式cli lgdt [gdt_descriptor] mov eax, cr0 or eax, 0x1 mov cr0, eax jmp CODE_SEG:init_pm [bits 32] init_pm: mov ax, DATA_SEG mov ds, ax mov ss, ax mov es, ax mov fs, ax mov gs, ax mov ebp, 0x90000 mov esp, ebp call kernel_main这里有个细节jmp CODE_SEG:init_pm必须是远跳转因为只有远跳转才能刷新CS寄存器里的段选择子。近跳转不行CS还是实模式的值后面所有代码都会跑飞。3.3 把内核加载到内存磁盘读取与ELF解析第二级引导程序进入保护模式后下一步是把内核镜像从磁盘读到内存。内核通常编译成ELF格式你需要解析ELF头找到各个段把它们复制到正确的虚拟地址。如果不想写ELF解析器也可以直接把内核编译成纯二进制用objcopy转换objcopy -O binary kernel.elf kernel.bin然后引导程序直接把kernel.bin读到某个固定地址比如0x1000001MB处再跳过去执行。这种方法简单粗暴适合早期开发。缺点是失去了ELF的段权限信息和符号表调试不太方便。我个人的做法是早期用纯二进制快速跑通等内核功能复杂了再换成ELF加载。这样前期迭代快后期也不缺调试信息。4. 内核架构设计目录结构、链接脚本与入口函数4.1 内核目录结构规划一个清晰的项目结构能让你少走很多弯路。我推荐这样组织os/ ├── boot/ │ ├── boot.asm │ └── stage2.asm ├── kernel/ │ ├── arch/ │ │ └── x86/ │ │ ├── gdt.c │ │ ├── idt.c │ │ └── isr.asm │ ├── mm/ │ │ └── pmm.c │ ├── drivers/ │ │ └── vga.c │ ├── lib/ │ │ └── string.c │ └── main.c ├── include/ │ └── kernel/ │ ├── types.h │ └── vga.h ├── linker.ld └── Makefileboot/放引导相关汇编kernel/arch/放体系结构相关代码方便以后移植到ARM或RISC-Vkernel/mm/放内存管理kernel/drivers/放设备驱动kernel/lib/放自己实现的字符串、内存操作函数因为裸机环境没有标准库include/放头文件linker.ld是链接脚本Makefile统一管理编译流程。这种结构的好处是关注点分离。你写VGA驱动的时候不用关心GDT怎么设置写内存管理的时候不用管中断怎么处理。后面要加文件系统、进程调度直接新建目录就行不会把代码搅成一团。4.2 链接脚本控制内核在内存中的布局链接脚本决定了各个段被放到哪个地址。对于x86内核通常从1MB处开始加载因为低地址被BIOS和显存占用了。ENTRY(_start) SECTIONS { . 1M; .text BLOCK(4K) : ALIGN(4K) { *(.multiboot) *(.text) } .rodata BLOCK(4K) : ALIGN(4K) { *(.rodata) } .data BLOCK(4K) : ALIGN(4K) { *(.data) } .bss BLOCK(4K) : ALIGN(4K) { *(COMMON) *(.bss) } }ENTRY(_start)指定入口符号. 1M把当前位置计数器设为1MB每个段都按4KB对齐方便后面做分页。.multiboot段是如果你用GRUB引导的话需要放的自己写引导程序就不需要。注意.bss段在ELF文件里不占空间但加载到内存后必须清零。引导程序如果直接复制二进制需要手动把.bss对应的内存区域清零否则全局变量初值会是随机垃圾。4.3 内核入口从汇编到C的交接内核入口通常是一小段汇编负责设置栈指针、清零.bss、调用C语言的kernel_main。[bits 32] global _start extern kernel_main section .text _start: mov esp, stack_top mov ebp, esp ; 清零bss extern bss_start extern bss_end mov edi, bss_start mov ecx, bss_end sub ecx, edi xor eax, eax rep stosb call kernel_main jmp $ section .bss align 16 stack_bottom: resb 16384 stack_top:这段代码把栈设在16KB的静态数组上然后清零.bss最后调用kernel_main。kernel_main返回后执行jmp $死循环防止跑飞。C语言的kernel_main可以很简单#include vga.h void kernel_main(void) { vga_clear(); vga_write(Hello, OS!\n); while (1); }VGA驱动直接操作显存0xB8000每个字符占两个字节低字节是ASCII码高字节是颜色属性。#define VGA_BUFFER ((volatile unsigned short*)0xB8000) #define VGA_WIDTH 80 #define VGA_HEIGHT 25 static int cursor_x 0; static int cursor_y 0; void vga_clear(void) { for (int i 0; i VGA_WIDTH * VGA_HEIGHT; i) { VGA_BUFFER[i] 0x0F20; } cursor_x 0; cursor_y 0; } void vga_putchar(char c) { if (c \n) { cursor_x 0; cursor_y; } else { VGA_BUFFER[cursor_y * VGA_WIDTH cursor_x] (0x0F 8) | c; cursor_x; } if (cursor_x VGA_WIDTH) { cursor_x 0; cursor_y; } if (cursor_y VGA_HEIGHT) { cursor_y 0; } } void vga_write(const char* str) { while (*str) { vga_putchar(*str); } }这段代码跑起来你就能在QEMU窗口里看到白字黑底的“Hello, OS!”。虽然简单但这是从零到一的关键一步。5. 中断与异常处理IDT设置与ISR编写5.1 IDT结构与中断门描述符中断描述符表IDT是保护模式下处理中断和异常的核心数据结构。每个表项16字节包含中断处理函数的地址和属性。x86有256个中断向量前32个是CPU异常后面是硬件中断和软件中断。struct idt_entry { unsigned short base_low; unsigned short selector; unsigned char zero; unsigned char flags; unsigned short base_high; } __attribute__((packed)); struct idt_ptr { unsigned short limit; unsigned int base; } __attribute__((packed)); struct idt_entry idt[256]; struct idt_ptr idtp; void idt_set_gate(int n, unsigned int handler, unsigned short sel, unsigned char flags) { idt[n].base_low handler 0xFFFF; idt[n].base_high (handler 16) 0xFFFF; idt[n].selector sel; idt[n].zero 0; idt[n].flags flags; } void idt_init(void) { idtp.limit sizeof(struct idt_entry) * 256 - 1; idtp.base (unsigned int)idt; // 清零 for (int i 0; i 256; i) { idt_set_gate(i, 0, 0, 0); } // 设置前32个异常处理 idt_set_gate(0, (unsigned int)isr0, 0x08, 0x8E); idt_set_gate(1, (unsigned int)isr1, 0x08, 0x8E); // ... 其他异常 __asm__ volatile(lidt %0 : : m(idtp)); }0x08是代码段选择子0x8E表示存在、特权级0、32位中断门。5.2 用汇编存根统一处理中断每个中断处理函数都需要保存现场、调用C函数、恢复现场。如果每个都手写汇编太累可以用宏批量生成。%macro ISR_NOERRCODE 1 global isr%1 isr%1: cli push byte 0 push byte %1 jmp isr_common_stub %endmacro %macro ISR_ERRCODE 1 global isr%1 isr%1: cli push byte %1 jmp isr_common_stub %endmacro ISR_NOERRCODE 0 ISR_NOERRCODE 1 ; ... 其他异常 extern isr_handler isr_common_stub: pusha mov ax, ds push eax mov ax, 0x10 mov ds, ax mov es, ax mov fs, ax mov gs, ax push esp call isr_handler add esp, 4 pop eax mov ds, ax mov es, ax mov fs, ax mov gs, ax popa add esp, 8 iretC语言的isr_handler接收一个指向寄存器结构的指针可以打印异常信息。void isr_handler(struct regs* r) { vga_write(Exception: ); vga_write(exception_messages[r-int_no]); vga_write(\n); while (1); }这样任何异常触发后你都能在屏幕上看到异常名称而不是莫名其妙重启。6. 物理内存管理位图分配器实现6.1 为什么需要物理内存管理器内核跑起来之后你迟早要动态分配内存。裸机环境没有malloc必须自己实现。物理内存管理器负责跟踪哪些物理页被占用、哪些空闲。最简单的实现是位图每一位对应一个物理页通常4KB0表示空闲1表示占用。假设你有128MB内存那就是32768个页位图大小是32768/84096字节也就是4KB。完全可以接受。6.2 位图分配器的实现#include types.h #define PAGE_SIZE 4096 #define MEMORY_SIZE (128 * 1024 * 1024) #define TOTAL_PAGES (MEMORY_SIZE / PAGE_SIZE) #define BITMAP_SIZE (TOTAL_PAGES / 8) static unsigned char bitmap[BITMAP_SIZE]; static unsigned int used_pages 0; void pmm_init(void) { for (int i 0; i BITMAP_SIZE; i) { bitmap[i] 0; } // 标记低1MB为已占用BIOS、显存等 for (int i 0; i 256; i) { bitmap_set(i); } used_pages 256; } void bitmap_set(int page) { bitmap[page / 8] | (1 (page % 8)); } void bitmap_clear(int page) { bitmap[page / 8] ~(1 (page % 8)); } int bitmap_test(int page) { return bitmap[page / 8] (1 (page % 8)); } void* pmm_alloc_page(void) { for (int i 0; i TOTAL_PAGES; i) { if (!bitmap_test(i)) { bitmap_set(i); used_pages; return (void*)(i * PAGE_SIZE); } } return 0; } void pmm_free_page(void* addr) { int page (unsigned int)addr / PAGE_SIZE; if (bitmap_test(page)) { bitmap_clear(page); used_pages--; } }这个分配器每次从头扫描效率不高但胜在简单可靠。等内核功能多了可以改成记录last_alloc位置从上次分配的地方继续找减少扫描次数。注意pmm_alloc_page返回的是物理地址不是虚拟地址。在开启分页之前物理地址和虚拟地址是一样的。开启分页之后你需要把物理地址映射到虚拟地址才能访问。7. 常见问题与排查技巧实录7.1 引导失败QEMU黑屏或反复重启这是最常见的问题。排查步骤确认引导扇区最后两字节是0xaa55。用xxd os.img | head看一下偏移0x1FE处是不是55 aa。确认org地址和实际加载地址一致。BIOS加载到0x7C00你的汇编里必须写org 0x7c00。用QEMU的-d int参数打印中断日志看看是不是触发了异常。在GDB里break *0x7c00单步执行看哪条指令跑飞。7.2 进入保护模式后崩溃通常是GDT设置错误或远跳转没写对。检查GDT描述符的基址和界限是否正确。lgdt加载的是GDT描述符的地址不是GDT本身的地址。远跳转的段选择子必须是GDT中代码段的偏移。CR0的PE位设置后必须立即远跳转中间不能插入其他指令。7.3 中断触发后死机检查IDT表项是否设置正确特别是flags字段。0x8E表示中断门0x8F表示陷阱门。如果flags的P位第7位是0CPU会认为该表项不存在触发双重故障。另外isr_common_stub里保存和恢复寄存器的顺序必须严格对称。pusha之后是push eax保存ds恢复时先pop eax再popa顺序反了栈就乱了。7.4 链接错误undefined reference to__udivdi3这是因为你在64位系统上用32位交叉编译器但链接时混入了64位库。解决办法是确保所有编译和链接都用同一套工具链并且在链接时加上-nostdlib -lgcc。-lgcc提供编译器生成代码所需的底层函数比如64位除法。7.5 常见问题速查表现象可能原因排查方法QEMU黑屏无输出引导扇区魔数错误检查0x1FE处是否为55 aa反复重启三重故障QEMU加-d int看异常号保护模式崩溃GDT或远跳转错误GDB单步跟踪中断死机IDT表项错误检查flags和handler地址链接报错工具链混用统一用i686-elf-*全局变量初值乱码.bss未清零入口处手动清零8. 实操心得与后续扩展方向踩了这么多坑我最大的体会是不要一开始就追求完美架构。我见过太多人花两周设计目录结构、画架构图结果一行代码没写。正确的做法是先让最小系统跑起来哪怕所有代码都堆在一个文件里。等你能打印字符了再慢慢重构。重构的成本远低于从零设计一个“完美”架构然后发现根本跑不通。另一个心得是善用GDB和QEMU的调试功能。info registers看寄存器x/10i $eip看指令x/10x $esp看栈。这些命令比printf不知道高到哪里去了。我调试中断的时候就是靠GDB发现栈指针在异常处理时偏移了8字节原因是错误码压栈和普通中断压栈的差异没处理好。后续你可以从这个骨架继续扩展加分页机制实现虚拟内存加堆分配器实现malloc/free加键盘驱动实现输入加简单文件系统实现持久化存储。每一步都是在现有基础上叠加不会推翻重来。这就是良好架构设计的价值——它让你后面的每一行代码都有地方放而不是到处打补丁。最后分享一个小技巧在kernel_main里加一个简单的命令行解析器支持help、clear、meminfo几个命令。这样你每次加新功能都可以通过命令行验证比反复重启看打印信息高效得多。我当初就是靠这个命令行在半小时内定位到了位图分配器的越界写入问题。
延伸阅读

更多相关文章

2026/10/20 7:01:31

2026年景区文旅行业电话AI客服厂商推荐,文旅数字化转型热度上升

本文核心要点摘要本文围绕景区文旅行业电话AI客服的选型需求展开,梳理文旅数字化转型背景下热线服务的核心挑战,结合合力亿捷热线语音客服的产品能力与应用实践,从方言识别、话务承接、业务流程匹配、系统协同等维度提供选型参考,…

2026/10/20 7:01:31

FANUC机器人控制柜内部电缆连接全解析:从接线原理到故障排查

简介:基于实际FANUC机器人改造项目整理的实操型技术文档,面向维护、调试与电气改造工程师,系统总结控制柜内部电缆连接的关键步骤与注意事项。内容涵盖A型、B型柜与Mate柜的电源规格差异,底板固定线缆的拆装顺序,附加轴…

2026/10/20 7:56:34

基于SpringBoot的保险保单分析系统开题答辩全流程指南

上个月我走完了一场开题答辩,题目是《基于SpringBoot的保险保单分析系统设计与实现》。从收到开题通知到答辩结束,前后准备了小一个月,中间踩了不少坑。今天把完整过程整理出来——标题里说的“全过程”不只是现场那十几分钟,而是…

2026/10/20 7:56:34

PHP+Excel通用课表查询系统v3.8:部署、查询逻辑与性能优化

简介:这份资源是面向Web开发初学者、计算机专业学生及毕业设计实践者的PHP课表查询系统源码包,基于PHP与Excel文件格式构建,用于高效管理和查询课程表信息,适用于学校、培训机构或个人学习计划管理场景。压缩包共19个文件&#xf…

2026/10/20 7:56:34

Oracle ASM命令行添加聚合磁盘实战:从语法到重平衡验证

干这行十来年,ASM磁盘组的管理几乎每周都会碰到。今天想聊聊“Oracle ASM 命令行方式添加聚合磁盘”这件事。很多人看到“聚合磁盘”会愣一下,因为Oracle官方文档里其实没有这个标准名词,它更多是实践里的说法:把多块物理裸盘交给…

2026/10/20 7:56:34

单目结构光三维重建:Matlab实现互补格雷码与多频外差相位展开

做结构光三维重建的,手里基本都攒过一套“投影仪相机”的实验台。这套代码就是把台上最核心的几个环节——条纹编码、图像采集、相位解算、相位展开、三角化重建——全部用Matlab串起来。项目同时实现了互补格雷码加四步相移、多频外差加四步相移两条相位展开路线&a…

2026/10/20 7:51:34

机器学习复习地图:从损失函数到评估指标的系统梳理

简介:《唐宇迪-机器学习课程资料》是一套面向机器学习初学者与进阶者的综合学习包,内容覆盖Python编程基础、常用库实战与主流算法理论,可帮助学习者从零搭建知识体系并掌握实际建模能力。整个资源以RAR压缩包形式发布,大小66.11M…

2026/10/19 18:06:35

金融AI落地指南:从调研报告到技术选型与工程实践

简介:这份PDF报告聚焦2025年金融服务业中国AI现状与趋势,面向金融科技从业者、AI战略规划人员及关注行业数字化转型的研究者,帮助读者把握生成式AI在银行、支付、交易等场景中的落地路径与投资方向。资源包内含1个PDF文件,大小约1…

2026/10/20 1:05:37

单片机C运行时从启动到多任务:中断安全与共享资源冲突

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:05:37

GCC编译四步详解:预处理、编译、汇编、链接实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:05:37

FIW线是什么?FEP注塑线与普通氟塑料线的区别及应用指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑