做《30天自制操作系统》做到第5天应该会有一个很明显的分水岭前四天你还在靠写内存、刷像素来“画”出一个系统到了第5天系统开始真正长出神经元——GDT、IDT、结构体描述符、中断入口。这一天我以为是整个教程里最值得反复啃的一节因为从这一天起CPU不再是黑盒你得按它的规矩交一张又一张表它才肯把控制权给你。如果你和我一样之前只写过应用层程序第5天会觉得有点“硬件味”扑面而来但这恰恰是自制操作系统最迷人的地方没有抽象层兜底每个字节都要你亲手摆好。1. 第5天的任务拆解结构体、文字显示、GDT/IDT1.1 为什么这一天开始引入结构体前4天写代码大多是“往某个地址写数据”这种操作。比如往显存地址写几个字节屏幕就会多一个点或一条线。第5天突然出现一堆结构体我第一次看到时愣了一下C语言里用结构体不是常规操作吗值得单开一节讲后来才明白这里用一种很教科书的方式把CPU定义的“裸数据结构”翻译成C语言的结构体。CPU要求你在内存里按固定的字节顺序填好段描述符和中断门描述符。你不能写“段基址0x00280000”这种语句因为段描述符并不是一个简单的整数它是一个64位的数据块里面混杂了基址低16位、基址中间8位、基址高8位、段界限、权限位、是否开启位等等。你要把这些乱七八糟的位按CPU规定的布局塞进8个字节里。如果用纯位运算每填一个描述符都要写几十行又长又容易错的代码。但如果定义一个结构体让每个字段对应CPU描述符里的每个区域那就直观多了。我习惯把段描述符结构体写成这样typedef struct { short limit_low; // 段界限低16位 short base_low; // 基址低16位 char base_mid; // 基址中间8位 char access_right; // 权限与类型字段 char limit_high; // 段界限高4位 其他标志 char base_high; // 基址高8位 } SEGMENT_DESCRIPTOR;这个结构体总共8字节正好对应CPU期望的内存布局。只要按字段赋值再把它烧到内存里CPU就能看懂。同样IDT的门描述符也是类似思路。第5天最大的心智负担不是结构体语法本身而是你要时刻提醒自己这个结构体不只是给C语言看的更是给CPU看的一张“内存图纸”。1.2 从字符显示到验证系统“活着”第5天的另一个表面任务是“文字显示”但我们先别急着把它想成printf。在自制操作系统的前五天我们根本没有标准库字符显示的本质依然是往VRAM里贴点阵图。一个字符可以理解成一个方格里的黑白点阵比如16x16或8x16的矩阵里哪些点涂黑哪些点留白。系统里准备好一堆字模数据每个字符对应一段数组数组里的每个字节表示一行的8个点。显示字符的过程就是把字模数组里的字节逐个复制到显存的对应位置。那为什么第5天要专门讲这个我理解是为了验证前面写的GDT和IDT是否真的生效。GDT设置好了CPU才能在保护模式下正常访问代码段和数据段IDT设好了系统才能安全地处理中断。但你如何验证这些表设对了直接看寄存器太不直观。最直接的验证就是往屏幕上输出一串字符比如显示“OS is running”看到字符不再花屏、不再黑屏说明段描述符里的权限位大概率是对的IDT至少没有一上来就触发三重错误。所以字符显示在这个阶段更像一块试金石。你写好了两套描述符表再靠字符显示来证明它们不是一张废纸。2. GDT与段机制CPU凭什么知道代码在哪2.1 实模式与保护模式的差距第3天刚进入32位模式时系统还活在实模式的记忆里。在实模式下CPU用段寄存器加偏移量来访问内存具体叫“段基址*16 偏移地址”。这种方式简单粗暴但带来的问题是程序可以直接访问任何内存地址没有权限边界没有保护机制。放现在看就像一间公寓里所有房间都不锁门谁都可以串门。进入保护模式后段寄存器不再直接存放段基址而是变成一个索引这个索引指向一张表里的某一行这张表就是GDT。CPU拿到这个索引后去GDT里查出真正的段基址、段界限、访问权限再配合偏移量计算最终地址。为什么搞这么复杂主要目的有三个隔离不同程序可以放在不同段里互相不干扰。权限可以设置段可读、可写、可执行避免恶意代码随意修改别的段。安全检查访问超出段界限时CPU能发出异常不会傻乎乎地继续读下去。第5天的GDT初始化就是在保护模式正式运行前把这张“门禁表”准备好并告诉CPU这张表在哪、表有多大。2.2 GDT初始化的实操要点我按自己的理解捋一遍GDT初始化流程第一步在内存里找一块空闲区域比如0x270000附近把结构体数组放进去。第二步向数组里填充段描述符。至少在传统设置里系统要准备几个段一个代码段权限要设置成可执行一个数据段权限要设置成可读写。这两者是保护模式运行的“底裤”。第5天通常还会准备一些特殊的段用于内核各区域但核心还是代码段和数据段。第三步把GDT的地址和长度边界告诉CPU。这一步必须用汇编里的LGDT指令完成。C语言里没法直接写这个指令所以我们需要写一个汇编函数封装供C语言调用。第四步设置GDTR寄存器之后CPU就知道该去哪找GDT了。我当时封装汇编函数时一度对参数传递很懵。C语言调用这个函数时会把GDT的长度和首地址压到栈上汇编函数要按调用约定去取。我简化过的代码如下load_gdtr: MOV AX, [ESP4] ; 取GDT界限长度 MOV [ESP6], AX ; 写入临时内存 LGDT [ESP6] ; 加载GDTR RET这里的关键在于理解栈调用者压入第一个参数长度再压入第二个参数首地址返回地址在栈顶。所以ESP4是长度ESP8是首地址。上面这段汇编只取第一个参数做LGDT的界限字段然后把地址也写入。实际代码会更严格但核心逻辑就是这个。再回头看结构体里的access_right那真是个“一门心思找存在感”的字段。低4位决定段的类型比如0x9a是可执行的代码段0x92是可读写的数据段。高4位里包含是否在内存中、是否特权级0等标志。写错一位轻则屏幕黑掉重则CPU直接异常。我第一次写反了类型位启动后什么都显示不出来后来逐字节比对内存转储才发现是access_right里第4位和第3位的问题。3. IDT与中断让CPU知道中断来了该去哪3.1 中断与IDT的关系GDT管的是“内存段长什么样”IDT管的则是“中断和异常来了该怎么办”。操作系统里键盘按键、定时器、磁盘IO这些事件都会触发中断。CPU收到中断信号后会停下来手上的活转去执行一段特殊代码。可问题是CPU怎么知道这段特殊代码在哪答案是查IDT。IDT是一张中断描述符表最多有256个条目0到255。每个条目标明“当中断向量N发生时跳转到哪个地址、以什么权限跳转”。第5天设置IDT的目的很简单先把这张表造出来给各个中断向量挂上处理入口哪怕处理函数暂时只是个空壳也能避免系统因为“中断来了找不到入口”而重启。这个设计让我想起职场里的“值班表”。你不一定知道今晚会发生什么突发事件但你必须提前安排好值班人员的电话。IDT的256条记录就是256个电话号码CPU遇到突发事件就按号码拨过去。3.2 IDT项怎么填代码怎么标IDT的门描述符同样是64位里面主要包含几个关键部分处理程序入口地址的偏移量拆成低16位和高16位分成两截放在描述符的不同位置所使用的段选择子告诉CPU去哪个段里找处理程序类型属性是中断门还是陷阱门权限是多少Present位这个门是否有效我实现时习惯用一个set_idt_desc函数来填描述符void set_idt_desc(struct IDT_DESCRIPTOR *idt, int vector, int handler, int selector, int type) { idt[vector].offset_low handler 0xffff; idt[vector].selector selector; idt[vector].access_right type; idt[vector].offset_high (handler 16) 0xffff; }因为中断处理函数的地址可能大于16位所以才要拆成上下两截。CPU拿到门描述符时会自己把这两截拼起来得到真正的入口地址。初始化第5天代码时我给0到255号向量全部填了同一个空处理函数或者一个简单的保留函数这样即使还没有真正实现鼠标键盘驱动一旦意外来了IRQCPU也不会因为跳到一个未初始化的地址而崩溃。我见过有人只初始化了21号向量结果一按键盘系统直接重启就是这个原因。4. 实操记录从头构建第5天环境4.1 构建工具与Makefile我用的开发环境不算特别讲究一台装了Linux的电脑装好GCC、ld、objcopy再准备一个模拟器用于运行。整个工程分成几个模块启动初始化的汇编代码负责把软盘/硬盘里的引导扇区加载到内存切换到32位模式再把C语言编译出来的二进制调到既定地址。主入口的C文件里面包含boot启动后的main函数。汇编小工具文件封装LGDT、LIDT、关中断、开中断等CPU指令。字模数据文件用于显示字符。Makefile的大致结构是这样bootpack.bin : bootpack.c naskfunc.nas gcc -m32 -c bootpack.c nasm naskfunc.nas -f elf ld -m elf_i386 -e main -o bootpack.elf bootpack.o naskfunc.o objcopy -O binary bootpack.elf bootpack.bin第5天的代码量比前几天大了不少如果不写Makefile靠手动编译很容易乱。实际编译时我遇到了C语言生成的目标文件里有一些额外的符号或段最后用objcopy剥掉后才得到干净的二进制。一个容易忽略的点是链接地址也就是要把代码安排到内存的哪个位置。第3天、第4天已经约定好主程序会从某个固定地址执行链接脚本或ld -Ttext参数必须和这个约定一致。否则你会看到C代码里写的全局变量地址全是错的程序运行起来完全像癫痫发作。4.2 运行结果与内存布局一切搞定后我执行make run模拟器弹出窗口屏幕上显示出预设的字符串。那一刻踏实了说明GDT和IDT不是白设的。我做第5天练习时把内存布局大致安排成下面这样仅供参考区域起始地址说明CPU相关数据0x00260000一些临时变量与栈IDT0x0026c000中断描述符表GDT0x00270000全局描述符表内核主体0x00280000bootpack等程序的加载区显存等硬件区0x000a0000VRAM和IO设备区域这并非固定答案只要不跟代码冲突就行。重点是你必须让GDT和IDT在CPU真正使用前被填好并且加载进去。我把GDT长度设成大约3个描述符乘8字节IDT默认256个描述符乘8字节。这些地址之所以选在2MB以上是为了避开实模式下的内存区域、显存区以及BIOS保留区。5. 我踩过的坑与几点经验5.1 见到的三个典型翻车现场第一个坑LGDT传入的地址不对。看似小事但CPU加载GDTR时只要读到一个非法地址就会直接抛异常。调试时用模拟器的日志看寄存器发现GDTR的值完全不是我预期的那块地址一查发现汇编封装函数里我把参数偏移算错了。记住32位C代码进入汇编函数时栈顶是返回地址ESP4才是第一个参数。第二个坑段描述符的access_right被我写反。我为了图省事把所有段都设置成了相同权限结果代码段不可执行CPU一跳转就死。表现是屏幕上什么都没有模拟器提示“Triple Fault”。当时我脑子里第一反应是“代码有问题”但代码逻辑很简单查来查去最后对着内存转储看才发现权限字段的位排列不对把0x9a写成了0x98或0x92之类。对于数据段0x92是对的对于代码段必须用0x9a或者类似带可执行位的值。要像记身份证号一样记这两种段类型。第三个坑IDT里每一个向量都挂处理函数不是只挂几个。我一开始只初始化了键盘相关的中断向量觉得其他用不到。结果一个随机中断进来IDT那个条目还是全零CPU直接重启。后来我索性写了个空处理函数把所有IDT项统统一遍不再纠结哪个用得到。这个思路值得记在系统初始阶段宁可“占位”也不能“留空”。5.2 给新手的几点建议如果你正在做第5天建议不要跳着看。第3天的实模式和保护模式切换是理解GDT的基础第4天的C语言与画面绘制能帮你习惯与硬件打交道。第5天最大的难度不是代码量而是你得同时理解“内存中的表”和“CPU如何引用表”这两件事。我个人的调试方法特别笨但很有效启动到第5天代码后把内存里GDT和IDT的内容用十六进制方式往屏幕上一行一行打出来对照结构体定义逐字段检查。虽然慢但能排除80%的初始化问题。如果实在看不到输出优先检查两条GDT是否正常加载IDT的present位是否置1。我很多次黑屏都绕不开这两个原因。再有不要急着追求“一次成功”。第5天不像是写业务代码能跑就行它要求你把CPU手册里的位定义当成刚需出错的容忍度很低。多失败几次反而能把那些位字段都记牢。做到第5天结束时我再回头看前四天那些“画画”代码才意识到那不是在浪费时间而是在为今天这种表结构操作培养手感。当你亲手把一个描述符填进内存然后看到CPU按你的安排跳转、执行、返回那种感觉是普通应用开发给不了的。建议你做完第5天的例程后自己试着往屏幕上打印GDT基址和IDT基址再手动增加一个段描述符折腾一番。第5天之后的中断处理、鼠标键盘驱动都会轻松不少。