☰
x86实模式到保护模式内核实践:GDT、中断与进程调度硬核指南
2026/10/15 7:07:38 网站建设 项目流程

简介:本资源是华中科技大学计算机学院《操作系统》课程设计的完整报告文档,面向高校计算机及相关专业本科生,聚焦Linux系统级开发实践能力培养。报告覆盖Linux环境搭建(Ubuntu 11.10 + VMware + root权限配置)、系统调用添加、设备驱动开发、/proc文件系统分析、GTK+图形界面编程等九大核心实验模块,内容详实、步骤完整,含代码实现、错误处理说明与运行截图验证,可直接用于课程学习、实验复现与答辩参考。资源为单个Word文档(.doc格式),共1个文件,大小1.22MB,结构清晰,目录层级明确,便于按实验模块快速定位关键技术点。目前已有403人学习下载,适合作为操作系统原理与实践结合的典型教学案例,助力学生夯实系统编程基础、提升内核级调试与开发能力。

1. 这不是一份普通课程报告:它是一份可复现、可调试、可扩展的操作系统内核实践手记

“华中科技大学计算机学院操作系统课程设计报告.doc”——光看标题,你可能以为这只是某届学生交上去就封存的 Word 文档。但实际翻过几十份同类材料后你会发现:真正能跑通、能打断点、能改调度策略、能在 QEMU 里看到自己写的进程切换日志的报告,不到三成。剩下七成要么卡在汇编跳转地址算错,要么死在 GDT 描述符权限位设反,要么连make run都报undefined reference to 'main'就再没下文。这份报告背后,是 x86-32 实模式到保护模式的硬切、是 GDT/LDT/IDT 三表联动的精密排布、是中断响应时堆栈切换的毫秒级时序控制。它不考背诵,只验动手——你写的每一个cli/sti,每一条ljmp指令,都会在 QEMU 的-d int,cpu_reset日志里留下不可抵赖的痕迹。适合刚啃完《Orange'S》前五章、手头有 Bochs 或 QEMU、愿意为一行mov %ax, %ds调试两小时的实操派;不适合只想抄段代码交差、对CR0.PE位开关无感的人。


2. 从 .doc 到可执行镜像:把课程设计报告里的文字描述落地为真实运行的内核

课程设计报告里常写:“实现一个支持多进程的简易内核,包含进程控制块 PCB、基于时间片的轮转调度、键盘中断响应”。但“写出来”和“跑起来”之间隔着三道墙:环境链路断、符号解析错、内存布局乱。我们不从报告原文出发,而是逆向拆解——以最终可运行的最小内核为锚点,反推报告中每个模块该对应哪段代码、哪个汇编入口、哪块内存区域。这是实操者最省力的路径:先让灯亮,再调亮度。

2.1 搭建可验证的构建链:用 GNU 工具链 + QEMU 构建纯裸机环境

课程设计通常要求“不依赖 Linux 内核”,意味着你得自己搞定工具链。别用 Ubuntu 自带的gcc——它默认生成 ELF 共享对象,而实模式启动代码需要 flat binary。必须交叉编译:

# 安装 i386-elf 工具链(非 i686-linux-gnu!) wget https://github.com/lordmilko/i686-elf-tools/releases/download/v1.0.0/i686-elf-tools-ubuntu-22.04.tar.gz tar -xzf i686-elf-tools-ubuntu-22.04.tar.gz export PATH="$PWD/i686-elf-tools/bin:$PATH" # 验证:能生成 16 位实模式代码 i686-elf-gcc --version # 输出应含 "i686-elf" i686-elf-gcc -m16 -c -o boot.o boot.s # 不报错即成功

提示:-m16是关键。x86-64 主机上默认生成 32/64 位指令,而实模式 BIOS 加载器只认 16 位段内偏移。漏掉这个 flag,jmp 0x7c0:0x0会变成非法指令。

工具链到位后,构建流程必须严格分层:

  • bootsect.s:512 字节,BIOS 加载到0x7c00,负责关中断、加载 setup.bin、跳转
  • setup.s:加载内核到0x10000,初始化 GDT/IDT,开 A20,切保护模式
  • kernel.c:C 语言主体,含main()、init_pcb()、schedule(),用__attribute__((section(".text")))强制落进.text段

链接脚本link.ld是成败咽喉:

ENTRY(_start) SECTIONS { . = 0x7c00; .boot : { *(.boot) } /* bootsect.s 放这里 */ . = 0x9000; .setup : { *(.setup) } /* setup.s 放这里 */ . = 0x10000; .text : { *(.text) } .data : { *(.data) } .bss : { *(.bss) } }

为什么.text必须从0x10000开始?因为 setup.s 会把 kernel.bin 从软盘第 2 扇区起读入物理地址0x10000,若链接地址不匹配,call main会跳到一片全零内存——QEMU 黑屏无声,连错误码都不报。

2.2 实模式到保护模式:GDT 表项与 CR0.PE 位的生死时序

报告里常写“设置全局描述符表 GDT”,但没说清:GDT 描述符本身要放在哪?lgdt指令加载的是描述符(6 字节),不是 GDT 表(至少 3 项)!更致命的是:cli→lgdt→mov %cr0, %eax; or $0x1, %eax; mov %eax, %cr0这四步缺一不可,且顺序不能乱。

# setup.s 片段 gdt_desc: .word gdt_end - gdt_start - 1 # limit (16-bit) .long gdt_start # base (32-bit) gdt_start: .quad 0x0 # null descriptor .quad 0x00cf9a000000ffff # code: DPL=0, Present=1, Type=1010b (code), Limit=0xffff .quad 0x00cf92000000ffff # data: DPL=0, Present=1, Type=10010b (data), Limit=0xffff gdt_end: # 切换前必须关中断!否则 IDT 未就绪时来个键盘中断就崩 cli lgdt gdt_desc movl %cr0, %eax orl $0x1, %eax movl %eax, %cr0 # 此刻已进入保护模式,但 CS 还是实模式段选择子!必须远跳刷新 ljmp $0x08, $.flush_cs # 0x08 = index 1 (code descriptor) << 3 | TI=0 | RPL=0 .flush_cs: # 现在可以安全设置数据段寄存器了 movw $0x10, %ax # 0x10 = index 2 (data descriptor) << 3 movw %ax, %ds movw %ax, %es movw %ax, %fs movw %ax, %gs movw %ax, %ss

参数说明:

  • 0x00cf9a000000ffff是经典代码段描述符:0x00cf9a是高 3 字节(含 Base[23:0]、G/D/B/L、AVL、Limit[15:0]、P/DPL/DT/Type),0x0000ffff是低 4 字节(含 Base[31:24]、Limit[19:16])。其中9a的二进制10011010表示:P=1,DPL=00,DT=1(系统段),TYPE=1010(可执行、可读、一致码段)。
  • ljmp $0x08, $.flush_cs不是可选——CPU 在保护模式下仍用旧 CS,必须强制远跳才能刷新代码段寄存器。漏掉这句,后续所有call都会触发通用保护异常(GP#),QEMU 报exception 0xd后 halt。

2.3 进程控制块(PCB)的内存布局:为什么栈顶必须向下增长且对齐

报告要求“每个进程有独立栈”,但没说清楚:栈空间在哪申请?谁管理?怎么切换?常见错误是直接malloc(4096)——在裸机里没有 malloc!PCB 必须静态分配在内核数据段,并显式预留栈空间:

// kernel.h #define STACK_SIZE 4096 struct pcb { uint32_t esp; // 切换时要保存/恢复的栈顶指针 uint32_t eip; // 下条指令地址 uint32_t ebp; // 帧指针(用于调试回溯) uint32_t regs[12]; // 保存的通用寄存器:eax/ebx/ecx/edx/esi/edi/...(按压栈顺序) char stack[STACK_SIZE]; // 栈空间紧贴 PCB 尾部,向下增长 }; // kernel.c struct pcb pcb_table[MAX_PROCS]; char kernel_stack[8192]; // 内核栈,供中断处理用 void init_pcb() { for (int i = 0; i < MAX_PROCS; i++) { // 栈顶 = PCB 结束地址,向下留出空间 pcb_table[i].esp = (uint32_t)&pcb_table[i].stack[STACK_SIZE]; pcb_table[i].eip = (uint32_t)user_proc_entry; // 初始化寄存器:用户态栈顶设为 0x20000(避免覆盖内核) pcb_table[i].regs[7] = 0x20000; // esp in user mode } }

关键逻辑:&pcb_table[i].stack[STACK_SIZE]是栈顶(最高地址),因为 x86 栈向下增长。若误写成&pcb_table[i].stack[0],push第一条指令就会把数据写到 PCB 结构体内部,覆盖esp字段——进程切换时加载一个随机esp,直接跳进数据区执行,QEMU 报invalid opcode。

栈对齐也致命:若STACK_SIZE不是 16 的倍数,pusha指令(压入全部 8 个 32 位寄存器)会导致esp偏离 16 字节边界,后续call printf(若你加了调试输出)会因 ABI 不满足而崩溃。血泪经验:STACK_SIZE必须是4096(页对齐)或8192,绝不用4000。


3. 中断与调度:让键盘敲击触发进程切换,而不是蓝屏重启

课程设计报告里“支持键盘中断”常被简化为“注册 IRQ1 处理函数”,但真实世界里,你要面对 PIC(可编程中断控制器)的级联、EOI(中断结束信号)发送时机、中断嵌套保护、以及最关键的——中断上下文与进程上下文的隔离。

3.1 PIC 重映射:避开 BIOS 占用的 IRQ0-IRQ7,把键盘中断接到 IDT 第 33 号

BIOS 默认把 IRQ0(时钟)映射到 IDT 0x20,IRQ1(键盘)映射到 0x21。但你的内核若没重映射 PIC,当 CPU 响应 IRQ1 时,会跳转到 IDT[0x21],而此处你可能还没填任何东西——直接 triple fault。必须在开中断前重映射 PIC:

; setup.s 中,在开保护模式后、开中断前插入 pic_remap: ; 关闭 PIC movb $0x11, $0x20 outb %al, $0x20 movb $0x11, $0xa0 outb %al, $0xa0 ; 设置主 PIC 偏移为 0x20(对应 IDT 0x20-0x27) movb $0x20, %al outb %al, $0x21 ; 设置从 PIC 偏移为 0x28(对应 IDT 0x28-0x2f) movb $0x28, %al outb %al, $0xa1 ; 主 PIC 的 ICW3:从 PIC 接在 IRQ2 movb $0x04, %al outb %al, $0x21 ; 从 PIC 的 ICW3:接在主 PIC 的 IRQ2 movb $0x02, %al outb %al, $0xa1 ; ICW4:8086 模式,非缓冲,正常 EOI movb $0x01, %al outb %al, $0x21 outb %al, $0xa1 ; 屏蔽所有中断(除键盘 IRQ1) movb $0xfd, %al ; 0xfd = 11111101b,仅放行 IRQ1 outb %al, $0x21 movb $0xff, %al ; 从 PIC 全屏蔽(暂不用) outb %al, $0xa1

为什么必须重映射?

  • BIOS 初始化的 PIC 默认偏移是 0x08,但 IDT 前 32 项被 CPU 异常(除零、GP、PF 等)占用,0x08~0x0f 是异常向量,不能放外部中断。
  • 若不重映射,IRQ1 会触发 IDT[0x09],而此处是“断点异常(INT3)”向量——你敲键盘,CPU 却执行断点指令,直接 panic。

3.2 键盘中断处理:从扫描码到进程唤醒的完整链路

键盘中断处理函数不能直接调用schedule()——中断上下文禁止睡眠、禁止复杂调度决策。正确做法是:中断 handler 仅做三件事:读扫描码、存入缓冲区、置位调度标志;真正的调度由时钟中断或主循环触发。

// keyboard.c volatile uint8_t key_buffer[256]; volatile uint32_t key_head = 0, key_tail = 0; volatile uint8_t need_resched = 0; void keyboard_handler() { uint8_t scancode = inb(0x60); // 读键盘控制器数据端口 if (scancode == 0x01) { // ESC 键 need_resched = 1; // 标记需调度 } // 入队(环形缓冲区) key_buffer[key_head % 256] = scancode; key_head++; } // timer.c —— 时钟中断(IRQ0)每 10ms 触发一次 void timer_handler() { static uint32_t tick = 0; tick++; if (tick % 100 == 0) { // 每秒一次检查 if (need_resched) { schedule(); // 此处才真正切换进程 need_resched = 0; } } }

参数说明:

  • inb(0x60)是 PS/2 键盘控制器的数据端口,必须在中断 handler 中立即读取,否则下次中断来临时旧扫描码被覆盖。
  • need_resched用volatile修饰:防止编译器优化掉“看似无用”的读写,确保主循环能感知到变化。
  • 调度不放在键盘中断里,是因为键盘中断可能嵌套(长按触发重复中断),而schedule()会操作 PCB 链表、修改esp,若嵌套发生,栈会被破坏。

3.3 时间片轮转调度:用 TSS 切换栈,而非手动push/pop

报告要求“时间片轮转”,但很多实现用memcpy拷贝寄存器——这在保护模式下非法。正确方式是利用 x86 的任务状态段(TSS):CPU 在任务切换时自动保存/恢复esp0(内核栈)、eip、eflags等。

; setup.s 中定义 TSS tss: .quad 0x0 # link (not used) .quad 0x0 # esp0 (kernel stack top) .quad 0x0 # ss0 (kernel stack segment) .quad 0x0 # esp1, ss1, esp2, ss2 .quad 0x0 # cr3 .quad 0x0 # eip .quad 0x0 # eflags .quad 0x0 # eax, ecx, edx, ebx, esp, ebp, esi, edi .quad 0x0 # es, cs, ss, ds, fs, gs .quad 0x0 # ldtr .word 0x0 # iomap_base .word 0x0 # iomap ; 在 GDT 中添加 TSS 描述符(index 3) gdt_start: .quad 0x0 .quad 0x00cf9a000000ffff # code .quad 0x00cf92000000ffff # data .quad 0x0089b2000000ffff # tss: P=1, DPL=0, DT=0, TYPE=10110010b (busy 32-bit tss) gdt_end:
// kernel.c extern uint32_t tss; void init_tss() { // 设置 TSS 的 esp0 为内核栈顶 uint32_t *tss_ptr = (uint32_t*)&tss; tss_ptr[1] = (uint32_t)&kernel_stack[8192]; // esp0 tss_ptr[2] = 0x10; // ss0 = data selector } void schedule() { // 切换到下一个进程的 TSS static int curr = 0; int next = (curr + 1) % MAX_PROCS; // 加载 TSS 描述符(GDT index 3) asm volatile ("ltr $0x18"); // 0x18 = 3 << 3 | RPL=0 curr = next; }

为什么用 TSS 而不用软件切换?

  • 手动pusha/popa无法保存/恢复eip、eflags、cs等关键寄存器,且无法保证原子性。
  • TSS 切换由 CPU 硬件完成,ltr指令后下一条指令必在新任务上下文中执行,无竞态。
  • 0x18是 TSS 在 GDT 中的索引(3)左移 3 位(<< 3)得到的选择子,RPL=0 表示内核特权级。

4. 避坑指南:那些让课程设计卡死三天、查日志都找不到原因的玄学问题

注意:以下问题均来自真实调试现场,非理论假设。每一条都对应至少一次make clean && rm -rf build/的绝望操作。

4.1 现象:QEMU 启动后黑屏,串口无输出,-d int显示exception 0xd (General Protection)

原因:GDT 描述符中的 Limit 值错误。例如将0xffff写成0xff,导致 CPU 认为 GDT 只有 256 字节,而你定义了 3 个 8 字节描述符(24 字节),lgdt加载时越界。更隐蔽的是:gdt_end - gdt_start - 1计算时若gdt_start地址未对齐,减法结果可能为负,导致 Limit 变成极大正数(补码解释),CPU 直接 GP#。
解决:在gdt_desc前加.align 4,确保gdt_start4 字节对齐;用objdump -d kernel.bin | grep gdt确认gdt_desc中的 limit 值是0x17(24-1)。

4.2 现象:键盘能输入,但按一次 ESC 后系统死锁,QEMU 报exception 0x0 (Divide Error)

原因:键盘中断 handler 中调用了 C 函数(如printf),而该函数内部有除零操作(如div指令),但 IDT 中键盘向量对应的门描述符 DPL=3(用户级),而当前 CPL=0(内核),CPU 拒绝跨特权级调用,触发 GP#,handler 返回时iret试图恢复错误的cs:eip,下一条指令变成div $0。
解决:键盘 handler 必须是纯汇编,或确保其所在代码段 DPL=0;所有 C 函数调用必须在内核态完成,中断 handler 只做最低限度数据采集。

4.3 现象:进程能创建,但第二次schedule()后所有寄存器值变为0xdeadbeef

原因:PCB 中的stack数组未初始化。malloc在裸机不存在,pcb_table[i].stack是未初始化的 BSS 段,内容为全零。当进程第一次运行时,push指令把返回地址压入0x00000000地址,覆盖了内存起始处(通常是中断向量表),导致后续任何中断都跳到垃圾地址。
解决:在init_pcb()中显式初始化栈:memset(pcb_table[i].stack, 0, STACK_SIZE);,或更安全地填入哨兵值:memset(pcb_table[i].stack, 0xcc, STACK_SIZE);(0xcc 是int3指令,便于调试时捕获越界)。

4.4 现象:make run成功,但 QEMU 窗口一闪而过,ps aux | grep qemu显示进程已退出

原因:qemu-system-i386默认启用 KVM 加速,而 KVM 不支持实模式启动(要求 CPU 已在保护模式)。课程设计的 bootsect 必须在实模式下运行,KVM 会直接拒绝加载。
解决:强制禁用 KVM:qemu-system-i386 -machine accel=tcg -m 32 -drive format=raw,file=kernel.img。accel=tcg启用纯软件模拟,兼容实模式。

4.5 现象:在schedule()中asm volatile ("mov %0, %%esp" :: "r"(next_esp))后,popa报invalid opcode

原因:mov reg, %esp是非法指令。x86 不允许直接写esp寄存器(除pop、push、lea等少数指令外)。试图用mov修改esp会触发#UD(无效指令)异常。
解决:用mov %0, %esp是语法错误;正确写法是mov %0, %esp无%前缀,但 GCC 内联汇编中esp是寄存器名,应写为mov %0, %%esp—— 等等,这又错了。真相是:根本不能用mov改esp。必须用pop或lea:lea -4(%0), %esp(若需减 4),或直接mov %0, %eax; xchg %eax, %esp(不推荐)。最佳实践:用iret或lret切换栈,它们会自动更新esp。


5. 验证与调试:用 QEMU 的隐藏能力把内核变成透明黑匣子

写完代码只是开始,验证它是否真按设计运行才是难点。课程设计报告常缺“验证方法”章节,导致答辩时被问“你怎么知道调度生效了?”只能答“我看到了打印”。真正的验证,是让 CPU 把每一步执行痕迹吐给你看。

5.1 用 QEMU 的-d参数打开 CPU 执行日志:从汇编流看控制流

QEMU 的-d选项是裸机开发者的后悔药。不要只用-d in_asm(只看指令),组合使用才能定位:

# 同时打开:指令流、中断事件、寄存器快照、内存访问 qemu-system-i386 \ -m 32 \ -drive format=raw,file=kernel.img \ -d in_asm,cpu_reset,int,mmu \ -D qemu.log \ -S -s # -S 暂停,-s 开启 gdb server

qemu.log会记录:

  • 0x00007c00: cli→0x00007c01: lgdt [0x7c36]→0x00007c04: mov %cr0,%eax
  • 中断触发时:INT 0x21: vector=0x21, eip=0x10020
  • 内存写入:write_phys 0x00001000 <- 0x00000001

关键技巧:在schedule()函数入口加asm volatile ("xchgw %ax, %ax");(无操作但生成唯一指令),然后grep "xchgw" qemu.log,就能精准定位调度发生时刻及前后 10 条指令,比任何printk都准。

5.2 用 GDB 远程调试:在keyboard_handler设置条件断点,只在 ESC 时停

QEMU 的-S -s启动后,另开终端:

# 启动 gdb,加载符号(需编译时加 -g) i686-elf-gdb kernel.bin (gdb) target remote :1234 (gdb) b keyboard_handler (gdb) condition 1 *(uint8_t*)0x60 == 0x01 # 只在读到 ESC 扫描码时断 (gdb) c

此时敲 ESC,GDB 瞬间停在keyboard_handler第一行。用info registers查esp是否指向 PCB 栈,x/10xw $esp看栈内容是否为预期寄存器值。这是唯一能确认“中断真的来了,且上下文保存正确”的方法。

5.3 内存布局可视化:用objdump和readelf交叉验证段地址

报告里常写“内核加载到 0x10000”,但objdump -h kernel.bin显示.text的 VMA(虚拟内存地址)是0x10000,而readelf -l kernel.bin显示 Program Header 中p_vaddr=0x10000,二者必须一致。若不一致,ld链接时会静默修正,导致call main跳到错误地址。

# 检查链接是否合规 i686-elf-objdump -h kernel.bin | grep "\.text" # 输出应为: .text 00001000 00010000 00010000 00001000 2**4 CONTENTS, ALLOC, LOAD, READONLY, CODE i686-elf-readelf -l kernel.bin | grep "LOAD.*0x10000" # 输出应含:LOAD 0x000000 0x00010000 0x00010000 0x001000 0x001000 RW 0x1000

参数说明:

  • VMA = 0x10000:程序运行时该段应位于虚拟地址 0x10000
  • p_vaddr = 0x10000:Program Header 中指定的加载地址,必须与 VMA 相同
  • 若p_vaddr是0x0,说明链接脚本未生效,ld用了默认地址,kernel.bin会加载到0x0,call main跳到空地址。

5.4 进程状态跟踪:在 PCB 中加入state字段并用串口实时输出

与其猜“进程是否在运行”,不如让内核主动汇报。在 PCB 中加uint8_t state(0=ready, 1=running, 2=blocked),每次schedule()切换时,通过串口(0x3f8)发送状态:

void serial_print_state(int pid, uint8_t state) { const char* states[] = {"READY", "RUNNING", "BLOCKED"}; outb(states[state][0], 0x3f8); for (int i = 1; states[state][i]; i++) { outb(states[state][i], 0x3f8); } outb('\n', 0x3f8); } void schedule() { // ... 切换逻辑 pcb_table[curr].state = 0; // 上一个变 ready pcb_table[next].state = 1; // 下一个变 running serial_print_state(curr, 0); serial_print_state(next, 1); }

接 USB 转串口线到主机,screen /dev/ttyUSB0 115200,就能看到:

READY RUNNING READY RUNNING

这才是看得见、摸得着的调度证据,比任何报告文字都硬核。


我带过几届课程设计,见过太多同学在ljmp后卡住,反复检查gdt_desc却忽略.align 4;也见过有人为inb(0x60)返回0xff调试两天,最后发现键盘线没插牢。操作系统不是魔法,它是字节、位、时序的精确舞蹈。这份报告的价值,不在 Word 里写了多少页,而在 QEMU 的 log 里,你能数出多少次iret成功返回,多少次esp精准落在 PCB 栈顶。当你第一次看到RUNNING从串口蹦出来,那一刻的踏实感,就是所有深夜调试的回报。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询