简介:北航小操作系统实验代码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 -mabi=32 -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 寄存器配置为 8N1 |
3. 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; // 8N1,8 数据位无校验 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_to
Lab2 的目标是让两个任务轮流跑,核心数据结构是 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 的普通异常入口在0x80000180,TLB 重填异常在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是 syscall,2是 TLB 不命中,3是 TLB 非法访问。跳进 C 之前,现场必须完整保存在内核栈上,否则 C 函数的任意一次函数调用都会覆盖现场。
返回现场用的是eret而不是jr。eret会从 EPC 寄存器恢复跳转地址,同时恢复 Status 的中断使能状态。不少人是把返回写成jr $ra,结果所有异常处理都能跑但一返回就死循环,最后发现是返回方式错了。
4.2 系统调用:syscall 指令与 EPC+4 的约定
用户态调用系统调用时,遵循 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 本身。如果不加 4,eret之后 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 是 PFN,bit5: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] | VPN2 |
| EntryHi[7:0] | ASID |
| EntryLo[25:6] | PFN |
| EntryLo[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执行;那里如果还是空的 0,CPU 执行 0 地址的指令,自然崩。第二个原因是中断现场保存不完整,中断处理函数返回后寄存器已经被改坏。
解决:先确认.text.exc段真的被链接到了0x80000180附近,readelf -S可以看段地址。写一个只做eret的空处理函数先跑通流程,再逐步往里面加保存、分发的逻辑。顺序一定是"先有异常入口,再开中断",不要反着来。
5.3 syscall 的返回地址永远是同一行
现象:用户态调用sys_write之后,程序像被粘在 syscall 指令上一样,重复进入内核,打印日志看到 EPC 始终不变。
原因:内核处理 syscall 后没有把 EPC 加 4。syscall 指令触发异常,EPC 指向 syscall 本身;返回时如果直接用原 EPC,eret 会再次执行同一条 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 完整跑起来。
本文还有配套的精品资源,点击获取