从裸机到多任务:MIPS操作系统实验核心难点解析

发布时间:2026/10/14 7:35:22
从裸机到多任务:MIPS操作系统实验核心难点解析
简介北航小操作系统实验代码BUAA_MIPS_OS是一套面向计算机专业学生的MIPS架构操作系统课程配套资料覆盖lab1至lab6六个递进阶段从bootloader编写、中断与异常处理到内存管理、进程调度、文件系统及设备驱动完整呈现一个小型OS的内核构建脉络。资源包共123个文件以C语言源码49个与头文件26个为核心辅以汇编.s文件、makefile构建脚本、链接脚本.lds及编译生成的.out、img镜像等压缩包仅1.3MB便于直接下载对照学习。已有2036人浏览学习。通过逐lab研读与运行学习者可深入理解硬件与软件的交互方式掌握页表、上下文切换、信号量等关键机制并提升系统级编程与调试能力。开源形式也便于二次修改与扩展适合作为操作系统课程设计、考研复试或自学MIPS底层开发的参考。1. 不要被名字吓到BUAA_MIPS_OS 到底在做什么BUAA_MIPS_OS 是某高校操作系统课程里的 MIPS 小操作系统实验lab1 到 lab6 串成一条从裸机到多任务系统的完整路线。第一周你还在折腾串口能不能吐出一个字符第三周就要让两个任务抢占式轮流跑到 lab6 往往是一个带键盘交互、多任务、信号量同步的综合 demo。这个方向最反直觉的地方在于整个实验的代码量没有多大真正消耗你时间的全在 MIPS 的寄存器约定、异常现场、延迟槽这些底层细节上。换句话说难点不是写操作系统而是读懂硬件给的规则。适合正在做同类课程实验的学生也适合想用真实模拟器补体系结构基础的工程师。这个投入很值得因为这套链路一旦跑通你再去看其他架构的调度器实现会发现只是同一副骨架换了张皮。2. 先把最小内核跑起来交叉编译、QEMU 与 Makefile 的一次打通2.1 工具链为什么不能用本机 gcc本机 gcc 编出来的是 x86 机器码MIPS 模拟器一条都执行不了。MIPS 实验的第一步就是装交叉编译工具链。常见名字是mipsel-linux-gnu-gcc注意中间那个el代表小端。QEMU 的 malta 板默认跑小端工具链选错加载内核后第一条指令就会异常。sudo apt install gcc-mipsel-linux-gnu qemu-system-mips mipsel-linux-gnu-gcc --version qemu-system-mips -M malta -cpu 24Kf -m 64M -kernel vmlinux -nographic安装完成后先用--version确认工具链可用。QEMU 命令里-M malta指定机器-cpu 24Kf选择带 TLB 和中断控制器的 CPU-m 64M给 64MB 内存-kernel加载你编出来的 ELF-nographic把串口直接接到当前终端。如果你是做自己学校实验机器和 CPU 型号以实验框架为准只要确定用的是小端 MIPS工具链就选mipsel前缀。还要留意一个很容易忽略的点交叉编译器包在部分发行版里叫gcc-mips-linux-gnu这是大端版本和默认的 QEMU 配置对不上。我一般先用file vmlinux看 ELF 的字节序再决定要不要换工具链。这一步错了后面全是白搭这也是 Lab1 最常见的卡点。2.2 启动代码 entry.S从 reset 到 main 的三件事所有 lab 的起点都是一段极短的启动汇编。它只需要做三件事关中断、设置栈顶、清 bss然后跳进 C 语言的 main。.set noreorder .globl _start _start: mtc0 $zero, $12 # CP0 Status 写 0彻底关中断 la $sp, boot_stack_top la $gp, _gp jal main nop 1: j 1b nop.set noreorder告诉汇编器别给延迟槽自动填指令这样每一句汇编的实际执行顺序完全由你控制。MIPS 的jal后面那条nop一定会执行这叫延迟槽如果这里随手放一条有副作用的指令程序一开始就会翻车。mtc0 $zero, $12是把 CP0 的 Status 寄存器清零处理器处于全关中断状态等调度器初始化好再统一开。栈顶必须提前准备好否则第一个 C 函数调用连返回地址都没地方放。bss 段的清零同样不能省否则全局变量初始值全是垃圾后续所有判断都会变得像玄学。栈空间一般这样预留__attribute__((section(.bss))) char boot_stack[4096];这段代码放入.bss.boot或.bss段都行重点是它不占 ELF 文件体积只在加载时占用内存。栈大小 4KB 在早期 lab 足够到 lab3 后面跑多任务每个任务的内核栈建议单独开到 8KB。2.3 Makefile 与链接脚本怎么确认内核真的起来了交叉编译内核和编普通应用不一样必须用-nostdlib和-nostdinc把所有标准库依赖去掉链接脚本也需要自己写。CROSS mipsel-linux-gnu- CC $(CROSS)gcc LD $(CROSS)ld CFLAGS -mips32r2 -G0 -mabi32 -fno-pic -fno-builtin \ -nostdinc -nostdlib -fno-stack-protector OBJS entry.o main.o uart.o vmlinux: $(OBJS) $(LD) -T link.ld -o $ $^ qemu: vmlinux qemu-system-mips -M malta -cpu 24Kf -m 64M -kernel $ -nographic-G0最关键它禁止汇编器把变量放进 gp 相对寻址的小数据区。实验代码规模不大但链接后地址一旦超出 gp 的 16 位偏移范围访问全局变量就会异常。-fno-pic关闭位置无关代码避免生成额外的 GOT 跳转。-mips32r2指定指令集版本QEMU 的 24Kf 完全支持。链接脚本至少保证两点入口符号_start在最前面.text段放到 0x80010000或实验框架定义的加载地址。readelf -h vmlinux和readelf -l vmlinux可以快速确认入口地址和段布局。QEMU 启动后如果终端马上出现一个字符或乱码说明内核已经跑到了串口初始化附近如果完全没输出先不要查调度器回头检查链接地址和启动汇编。现象原因对策QEMU 启动即非法指令工具链字节序与机器不匹配换 mipsel / mips 工具链完全没有串口输出链接地址不对或 bss 未清零用readelf检查入口检查启动汇编输出乱码串口初始化未做或波特率配置错误确认 16550 寄存器配置为 8N13. Lab1-Lab2从串口打印到抢占式调度难点都藏在寄存器里3.1 Lab1 串口输出一个 volatile 指针就够了QEMU 的 malta 板把 16550 串口映射在物理地址 0x1f000000 附近内核里通常用 kseg1 的虚拟地址0xbfe003f8访问。kseg1 的特点是不走缓存读写直接落到设备上正适合操作 UART。#define UART_BASE 0xbfe003f8 #define UART_THR (*(volatile unsigned char *)(UART_BASE 0)) #define UART_LSR (*(volatile unsigned char *)(UART_BASE 5)) void uart_init(void) { *(volatile unsigned char *)(UART_BASE 3) 0x03; // 8N18 数据位无校验 1 停止位 } void putc(char c) { while ((UART_LSR 0x20) 0); // bit5 为 1 表示发送保持寄存器空 UART_THR c; }UART_LSR 0x20是标准轮询写法设备还没把上一个字符送出去之前发送寄存器不能写。Lab1 做完putc之后我一般会立刻做一个精简版printf只支持%c、%s、%x后面所有 lab 的调试信息输出都靠它。这个非常值得后续上下文切换和异常调试时没有格式化打印会痛苦十倍。注意寄存器偏移不要死记。不同实验框架可能把串口基址放在0xbfe00000加上标准 COM1 偏移0x3f8最后就是0xbfe003f8。只要确认三个事实基址对不对、偏移对不对、有没有加volatile串口就稳定了。3.2 调度器核心PCB、任务栈与 switch_toLab2 的目标是让两个任务轮流跑核心数据结构是 PCB核心函数是switch_to。#define STACK_SIZE 8192 struct task { uint32_t *ksp; // 内核栈指针切换现场就保存在这里 uint32_t *kra; // 冗余保存 ra便于调试 int pid; int state; // READY / RUNNING / BLOCKED char kernel_stack[STACK_SIZE]; };很多第一次实现的人会把现场全部压栈再换栈其实 MIPS 约定下 PCB 里只需要保存一个ksp。切换发生时当前任务的通用寄存器、返回地址已经由编译器按 C 调用约定压在当前栈上了调度器只需要把当前sp存下来再把下一个任务的sp恢复回去。.globl switch_to switch_to: # a0 prev 的 task-ksp, a1 next 的 task-ksp sw $ra, 0($a0) sw $sp, 4($a0) sw $s0, 8($a0) sw $s1, 12($a0) sw $s2, 16($a0) sw $s3, 20($a0) sw $s4, 24($a0) sw $s5, 28($a0) sw $s6, 32($a0) sw $s7, 36($a0) lw $ra, 0($a1) lw $sp, 4($a1) lw $s0, 8($a1) lw $s1, 12($a1) lw $s2, 16($a1) lw $s3, 20($a1) lw $s4, 24($a1) lw $s5, 28($a1) lw $s6, 32($a1) lw $s7, 36($a1) jr $ra nop这里有个关键认知t0-t9是调用者保存寄存器switch_to返回后上层代码本来就会重新加载它们所以不需要保存。真正必须保存的是ra、sp和s0-s7。switch_to结尾的jr $ra用的是新任务的ra所以它会恰好回到新任务上次被切走的地方这就是调度器最核心的 trick。创建第一个任务时没有上次的现场常见做法是伪造一个栈帧把预留栈的栈顶指针减去一个固定大小把task_entry的地址写进ra的位置然后让switch_to恢复它。看起来像魔法实际上只是把栈帧填好。3.3 时钟中断让调度器自己动起来Lab2 最后一步是加时钟中断让调度不再靠手动调用而是每过一个时间片自动触发。void timer_init(void) { uint32_t status read_cp0_status(); write_cp0_status(status | (1 15)); // 使能时钟对应的中断位 write_cp0_compare(read_cp0_count() TICKS_PER_TICK); } void do_timer_irq(void) { write_cp0_compare(read_cp0_count() TICKS_PER_TICK); // 重装比较器 if (ticks TIME_SLICE) { ticks 0; schedule(); // 时间片用完切换任务 } }MIPS 的时钟中断由 CP0 的 Count 和 Compare 寄存器配合产生Count 每周期递增当它等于 Compare 时Cause 寄存器的对应中断位被置 1。注意中断使能有两道开关Cause 那边是有没有发生Status 的 IM 位是允不允许进来最顶层的 IE 位是总开关。调试中断时三个位都要看少一个都会让人怀疑人生。这里有一个容易踩的细节do_timer_irq里要先重装 Compare再决定要不要调度。如果先调度、后重装等切换回来的任务刚恢复现场同一次中断条件可能又立即触发造成无限中断。QEMU 上表现是任务切换速度奇快、根本停不下来真机上会更隐蔽。4. Lab3-Lab4异常、系统调用与 TLB三条最容易翻车的链路4.1 异常入口0x80000180 这个地方只有两件事MIPS 的普通异常入口在0x80000180TLB 重填异常在0x80000000。写入口代码的时候脑子里要清楚这里只做两件事保存现场、跳 C 函数剩下的都交给 C。.section .text.exc .align 7 ex_entry: addiu $sp, $sp, -TF_SIZE sw $ra, TF_RA($sp) # ... 按固定偏移保存通用寄存器 ... mfc0 $a0, $13 # Cause mfc0 $a1, $14 # EPC move $a2, $sp # trapframe 指针 jal exception_handler nop # ... 从 trapframe 恢复现场 ... eret nopexception_handler根据 Cause 寄存器的 ExcCode 字段第 6 到 2 位判断异常类型。0是中断8是 syscall2是 TLB 不命中3是 TLB 非法访问。跳进 C 之前现场必须完整保存在内核栈上否则 C 函数的任意一次函数调用都会覆盖现场。返回现场用的是eret而不是jr。eret会从 EPC 寄存器恢复跳转地址同时恢复 Status 的中断使能状态。不少人是把返回写成jr $ra结果所有异常处理都能跑但一返回就死循环最后发现是返回方式错了。4.2 系统调用syscall 指令与 EPC4 的约定用户态调用系统调用时遵循 O32 ABI编号放v0参数放a0-a2结果仍返回到v0。内核做分发时有一句代码是整个实验里最容易被忽略的。void do_syscall(struct trapframe *tf) { int num tf-regs[2]; // v0 tf-epc 4; // 关键跳过 syscall 指令本身 switch (num) { case SYS_WRITE: tf-regs[2] sys_write(tf-regs[4], tf-regs[5], tf-regs[6]); break; case SYS_EXIT: do_exit(tf-regs[4]); break; default: tf-regs[2] -ENOSYS; break; } }为什么一定要tf-epc 4因为 syscall 指令触发异常时EPC 指向的是 syscall 本身。如果不加 4eret之后 CPU 会再次执行同一条 syscall再次进异常形成死循环。这是 Lab3 最常见的死机现场没有之一。用户态那侧同样有讲究static inline int sys_write(int fd, const void *buf, int len) { register int v0 asm(v0) SYS_WRITE; register int a0 asm(a0) fd; register void *a1 asm(a1) (void *)buf; register int a2 asm(a2) len; asm volatile(syscall); return v0; }register ... asm(v0)是 GCC 扩展直接要求编译器把某个变量固定放在指定寄存器里。这里不建议用int $2这种裸汇编写完整调用因为编译器可能把局部变量调度到别的寄存器后续引用就对不上了。4.3 TLB 与页表为什么默认映射能跑开了磁盘交换就崩MIPS 的 TLB 完全由软件管理这意味着每次 TLB 不命中CPU 都会进异常入口由内核决定是查页表填 TLB还是报段错误。写 TLB 的代码不复杂复杂的是字段布局。void tlb_insert_one(int idx, uint32_t vaddr, uint32_t paddr) { uint32_t entryhi (vaddr 0xffffe000); // 高 19 位是 VPN2 uint32_t entrylo ((paddr 12) 6) // PFN 放在 bit25:6 | (2 3) // C 字段cache 属性 | (1 2) // D 位可写 | (1 1); // V 位有效 write_cp0_entryhi(entryhi); write_cp0_entrylo0(entrylo); write_cp0_entrylo1(entrylo); write_cp0_index(idx); asm volatile(tlbwi); // 写入指定索引槽 }EntryHi 的高 19 位是 VPN2低 8 位是 ASID。EntryLo 的 bit25:6 是 PFNbit5:3 是 cache 属性bit2 是 D 位bit1 是 V 位bit0 是 Global 位。tlbwi写入CP0_Index指定的槽位tlbp则按 EntryHi 搜 TLB。页表这块建议按两步拆开验证。第一步做固定映射把用户虚拟地址和物理地址一一对应先把 TLB 填表的流程跑通第二步再引入真正的页表查表逻辑。一上来就做分级页表错误会叠加很难定位是查表算法的问题还是 TLB 字段的问题。ASID 也要尽早意识多个进程共用 TLB 时如果 EntryHi 里 ASID 不做区分进程 A 的页会命中进程 B 的 TLB 项表现是一模一样的逻辑代码在不同进程上下文里跑出奇怪数据。位段含义EntryHi[31:13]VPN2EntryHi[7:0]ASIDEntryLo[25:6]PFNEntryLo[5:3]Cache 属性EntryLo[2]D 可写位EntryLo[1]V 有效位EntryLo[0]G 全局位5. 避坑指南MIPS 实验里 5 个让人通宵的现场5.1 串口打印只有第一行后面全丢现象内核启动后终端打出第一行信息再往后一片空白程序没有死但没有任何输出。原因通常是串口初始化只做了一半。很多人的uart_init只设置了线路控制寄存器没有管 FIFO 控制寄存器或者发送时轮询了错误的标志位。16550 的 LSR 有两位需要注意bit5 是 THR 空bit6 是 TEMT发送移位寄存器也空。只轮询 bit6在 QEMU 上可能永远等不到表现成卡死只轮询 bit5数据其实还没完全送出就写下一个字符慢速终端下会丢字符。解决初始化时明确写UART_FCR 0x01开启 FIFO发送统一轮询LSR 0x20。打印密度高的时候可以在putc里对\n做一次小延时避免 QEMU 的串口背压把字符吞掉。5.2 开中断就死机现象启动汇编里关中断一切正常一旦在 C 里调用write_cp0_status(status | 0x8000)程序立刻跑飞QEMU 显示非法指令或直接无响应。原因多数是开了中断却没写异常入口。MIPS 的 CPU 只要中断使能任何中断信号都会跳到0x80000180执行那里如果还是空的 0CPU 执行 0 地址的指令自然崩。第二个原因是中断现场保存不完整中断处理函数返回后寄存器已经被改坏。解决先确认.text.exc段真的被链接到了0x80000180附近readelf -S可以看段地址。写一个只做eret的空处理函数先跑通流程再逐步往里面加保存、分发的逻辑。顺序一定是先有异常入口再开中断不要反着来。5.3 syscall 的返回地址永远是同一行现象用户态调用sys_write之后程序像被粘在 syscall 指令上一样重复进入内核打印日志看到 EPC 始终不变。原因内核处理 syscall 后没有把 EPC 加 4。syscall 指令触发异常EPC 指向 syscall 本身返回时如果直接用原 EPCeret 会再次执行同一条 syscall于是变成无限循环。解决在do_syscall入口处第一件事就是tf-epc 4。调试时可以把这个值打出来确认它是用户态 syscall 后面那条指令的地址。还有一个隐藏点如果 GCC 内联汇编把 syscall 放在分支延迟槽里EPC 会指向分支指令而不是 syscall 本身这种代码即教程写法也要避免。5.4 换页之后旧数据还在新地址上出现现象进程访问 A 页却读到了很久以前 B 页的数据页表和物理内存都检查过了看起来逻辑完全正确。原因TLB 没有在切页后失效。MIPS 的 TLB 是硬件自动命中的软件填进去之后不会自己失效。你更新了页表但 TLB 里还留着旧映射CPU 直接命中旧项根本不查新页表。解决更新页表后主动tlbp找到对应槽位再用tlbwi覆盖或者一次性tlbr全部失效。切换进程地址空间时同样要刷新 TLB。在 QEMU 上这个问题表现为玄学——有时正常有时数据错乱因为 TLB 是否被替换取决于运气。5.5 上下文切换后返回地址不对现象switch_to执行完新任务没有从上次暂停的地方继续而是跳到了一个看起来毫无关系的地址有时是复位向量有时是 0。原因PCB 里保存的ra不对。检查现场会发现保存的是switch_to内部的返回地址而不是任务真正被打断时那条指令的地址。根源是创建任务时伪造的初始栈帧没有做对或者保存现场的时机不对ra已经被内部函数调用改掉了。解决把创建任务的栈帧初始化独立成一个函数专门负责往栈里填初始ra、初始sp、初始s0-s7。调试时先在 PCB 里冗余保存一个kra字段两个值对照着看能省掉大量排查时间。6. Lab5-Lab6 收尾信号量、综合任务与一条压测命令6.1 信号量关中断的 P/V 操作Lab5 的同步互斥在 MIPS 内核里最常见的实现是基于关中断的 P/V。P 操作等资源时如果count 0当前任务要阻塞并主动调度出去这里有一个细节比其他架构更容易漏——回到本任务继续执行时要重新关中断。void sem_p(sem_t *s) { uint32_t saved disable_irq(); // 关中断 while (s-count 0) { enqueue(s-wait_queue, current); schedule(); // 切换现场被保存 } s-count--; restore_irq(saved); }schedule()切到别的任务时那个任务的现场恢复会把中断状态一起恢复。等本任务再次被调度回来它恢复的是自己进schedule之前的状态所以restore_irq(saved)能正确复位。千万不要在schedule返回后无条件开中断那样会把之前的中断状态覆盖掉。6.2 综合任务把 lab1-lab5 串成一条可演示的链路Lab6 不需要新机制它考的是缝合能力。我的做法是把 lab1 的串口、lab2 的调度、lab3 的 syscall、lab4 的页表、lab5 的信号量全部编译进同一个内核然后跑三个任务一个任务循环打印系统信息一个任务通过 syscall 从串口读键盘输入一个任务做生产者消费者。这样任何一个环节坏了都会在一个 demo 里暴露出来。6.3 验证用一条命令反复压测综合实验跑通后建议写一个内核自检函数连续创建 10 个任务每个任务循环 1000 次在串口输出各自的计数。正常结果是计数严格递增、任务切换顺序稳定、没有重复打印或乱序。如果计数跳变先去查 TLB 是否残留了旧映射如果任务卡死优先怀疑信号量的等待队列操作。MIPS 实验做到最后我发现真正值钱的不是那一份能跑的内核而是对着 CP0 寄存器反复试错养成的底层直觉。这套东西放到其他 RISC 架构上内核栈、现场保存、异常分发仍然是一个套路。希望这篇能让你少熬几个夜尽早把属于自己的 BUAA_MIPS_OS 完整跑起来。本文还有配套的精品资源点击获取