先问一个问题:你在高级语言里写排序时,一条sort(arr, arr+n)完事,背后到底发生了什么?如果你能回答“不知道”,那这篇文章就是写给你的。如果你是因为课程作业、计组实验或者单纯好奇,不得不面对MIPS汇编里的SORT实现——尤其还要排结构体——那恭喜你,点进了一篇能把这件事讲透的文章。
array: .word 3, 10, 8, 2, 5, 2, 3这种数据段定义,配合sort子程序,是MARS模拟器教学里最常见的题目形态。看似只是把一段C代码“翻译”成汇编,实际动手时你会发现:一个for(int i=0;i<n;i++)的循环结构,在MIPS里要拆成寄存器初始化、条件判断、跳转标签三部分,而一旦加入结构体数组,事情就变成了“比较结构体字段”和“交换整个结构体”两层逻辑的纠缠。
这篇文章我把完整思路拆开讲:先在MARS的数据段布局上想清楚结构体数组的内存形态,再讨论为什么课堂作业普遍选选择排序而不是快排,然后给出可直接运行的整数排序和结构体排序完整代码,最后分享几个我调试时踩过的坑和MARS里的排查套路。
1. 先把这条经典的MIPS数据段读懂:从3,10,8,2,5,2,3说起
MARS里定义一个数组,几乎每个教程都会出现这样一行:
.data array: .word 3,10,8,2,5,2,3如果你只在C语言里见过int a[] = {3,10,8,2,5,2,3},第一次看到这行汇编时容易忽略一件事:.word不是“给变量赋值”,而是“在当前地址按4字节一块,连续存放这些立即数”。array:是一个地址标签,它表示的是这一段连续内存的起始地址,而不是一个“数组变量”的内存槽。这是一个很基础但很关键的认知转换。
MIPS是RISC架构,所有运算都发生在寄存器里,内存中的数据必须先用lw(load word)取到寄存器,再参与比较或交换;算完后要写回内存用sw(store word)。所以数组访问在汇编里不是arr[i]这样一个表达式,而是两步:
- 用索引算出偏移量:
i * 4 - 用基址加偏移算出内存地址:
array + (i * 4)
这也意味着,你在高级语言里写的arr[i] < arr[j],在MIPS的sort子程序里会变成大约三条指令:sll乘4得到偏移,add加基址,lw取数据。等你要排序结构体时,这个4还要换成结构体占用的字节数,偏移量还要加上字段在该结构体中的位置——这是后面第4节的核心矛盾点。
再回头看看这个数组:3, 10, 8, 2, 5, 2, 3,长度7,里面有重复元素3和2。题目出这个例子是有讲究的:它逼你考虑“相等元素如何处理”。如果排序算法里比较条件写成大于等于或小于等于的边界搞错,重复元素容易导致死循环或者错误交换;不稳定的排序算法在处理相等元素时,结果虽然数值顺序对,但会掩盖你比较条件写反的问题,导致程序在更复杂数据上翻车。我自己的建议是:验证时一定要带重复元素用例,别只用全不相同的数组测一遍就以为通过了。
2. 结构体数组在MIPS内存里的布局:一个Student占几个.word
说“排序结构体”之前,得先把结构体数组在汇编里的形态钉死,不然后面写代码全是糊涂账。
先假设一个最典型的场景:按学生成绩排序,每个学生有学号(word)和成绩(word),那么一个结构体占8字节,相当于2个.word。在MIPS里没有结构体声明语法,我们直接用布局说话:
.data # 每个学生占8字节:学号(word) + 成绩(word) students: .word 1001 # 学号 .word 85 # 成绩 .word 1003 .word 92 .word 1002 .word 78 .word 1005 .word 88 .word 1004 .word 95 end_students:这个end_students:标签很关键。在MARS里,标签可以当成一个“地址计算工具”,我可以用(end_students - students) / 8算出结构体个数,而不用自己去数。这是汇编程序员的心智习惯:用地址差而不是手写常量,改数据后不用同步改长度。若数组长度写死在la $a1, 5里,以后增删记录就会忘改,这种问题在实验报告里很常见。
画个内存布局图,students这个地址上连续排布是这样的:
| 偏移地址 | 内容 | 含义 |
|---|---|---|
| students+0 | 1001 | 第0个学生学号 |
| students+4 | 85 | 第0个学生成绩 |
| students+8 | 1003 | 第1个学生学号 |
| students+12 | 92 | 第1个学生成绩 |
| ... | ... | ... |
现在你看到了:第k个学生是从students + k * 8开始的,学号在这个地址偏移0的位置,成绩在偏移4的位置。也就是说,若$t0里存的是第k个学生的首地址,那么lw $t1, 0($t0)取学号,lw $t2, 4($t0)取成绩。
结构体还可以更复杂,比如包含姓名(字符串),姓名长度不固定就更麻烦,得用.space预留固定长度缓冲区。但课堂作业最常考的还是定长字段结构体,所以这里我们先吃透8字节结构体。如果你以后要排多个字段,方法不变:记住两条定律——定位元素用“索引乘结构体大小”,定位字段用“基址加字段偏移量”。
3. 为什么课堂版SORT几乎都选选择排序,而不碰快排
你可能会想:既然写出一个能跑的排序,快排不也就几十行?为什么MIPS作业十有八九是选择排序或冒泡排序?
先看一个事实:MIPS里没有一条指令能直接“比较两个内存变量然后交换”,一切都要拆成“取数→比较→跳转→存数”。高级语言里一次看起来很轻巧的数组元素比较,在汇编里至少对应3条指令;内层循环每跑一次,光lw就要执行两次。冒泡或者选择排序的循环次数是O(n^2),当n=7甚至20时,MARS模拟器都能秒出结果,但换成快排的递归结构,代价就完全不一样了。
快排在MIPS里的两个“额外成本”是很多人没有提前想到的:
- 递归调用需要自己用栈保存现场。每进入一层递归,都要
addi $sp, $sp, -N,把$ra和要保留的寄存器逐个sw进栈,返回前再逐个lw恢复。这一大段进出栈代码,比C语言编译器自动生成的啰嗦得多。 - 快排里有个“取数组中间值做基准”(median-of-three)的常见优化,这个优化在C里只是一行表达式,在MIPS里涉及到数组索引乘4、加法、两次
lw、比较和可能的分支交换,很容易把初学者的寄存器分配搅成一锅粥。
选择排序的优点是状态量少。外层循环维护一个“当前排好的前缀长度i”,内层循环维护一个“当前找到的最小元素下标min”,算法本身只需要两个循环变量加一个交换过程,对应的寄存器和临时变量非常清晰:
$t0= i(外层)$t1= min(当前最小下标)$t2= j(内层)
寄存器变量一少,出错概率就断崖式下降。对课程作业来说,“今天一定能调通”比“理论上更快”重要得多。当然这次也给你留个进阶题:如果数据量真到了万级,用MARS跑O(n^2)能等到怀疑人生,那时你就知道为什么工业级实现里再慢也得想别的办法。但先学会走再学跑,一堂课的作业别过度设计。
选择排序的另一个隐藏优势是交换次数少。每一轮外层循环最多交换一次,最坏情况也就交换n次。如果你在后面结构体排序里用“整块结构体搬移”的交换策略,这个优势更值钱——每次交换要拷贝2个word甚至更多,冒泡排序那种频繁交换的写法遇到大结构体会带来巨大的内存访问开销。
4. 整数版SORT完整实现:MARS里可直接跑的骨架代码
说了那么多原理,直接上代码。下面这个版本我刻意写成能直接复制到MARS里运行的完整程序,用它当“最小的可运行骨架”再逐步扩展成结构体版。
.data array: .word 3,10,8,2,5,2,3 N: .word 7 .text .globl main main: la $a0, array # $a0 = 数组基址 lw $a1, N # $a1 = 数组长度 jal sort # 调用 sort 子程序 # 打印排序结果(验证用) la $t0, array lw $t1, N li $t2, 0 print_loop: bge $t2, $t1, exit lw $a0, 0($t0) li $v0, 1 syscall la $a0, space li $v0, 4 syscall addi $t0, $t0, 4 addi $t2, $t2, 1 j print_loop exit: li $v0, 10 syscall .data space: .asciiz " " .text # sort 子程序 # 参数: # $a0 = 数组起始地址 # $a1 = 元素个数 # 说明:使用选择排序,对 word 数组升序排序 sort: addi $sp, $sp, -16 sw $ra, 0($sp) sw $s0, 4($sp) # 保存调用者现场的 $s0 sw $s1, 8($sp) sw $s2, 12($sp) move $s0, $a0 # $s0 = 数组基址 move $s1, $a1 # $s1 = 元素个数 li $s2, 0 # i = 0 outer_loop: bge $s2, $s1, sort_done move $t0, $s2 # min = i addi $t1, $s2, 1 # j = i + 1 inner_loop: bge $t1, $s1, after_inner # 取 arr[j] sll $t2, $t1, 2 # $t2 = j * 4 add $t2, $s0, $t2 # 地址 = 基址 + j*4 lw $t3, 0($t2) # $t3 = arr[j] # 取 arr[min] sll $t4, $t0, 2 # $t4 = min * 4 add $t4, $s0, $t4 lw $t5, 0($t4) # $t5 = arr[min] bge $t3, $t5, skip_update # 如果 arr[j] >= arr[min],不更新 move $t0, $t1 # min = j skip_update: addi $t1, $t1, 1 j inner_loop after_inner: # 如果 min != i 则交换 arr[i] 与 arr[min] beq $t0, $s2, no_swap sll $t4, $t0, 2 add $t4, $s0, $t4 lw $t3, 0($t4) # 保存 arr[min] 的值 sll $t5, $s2, 2 add $t5, $s0, $t5 lw $t6, 0($t5) # 保存 arr[i] 的值 sw $t3, 0($t5) # arr[i] = arr[min] sw $t6, 0($t4) # arr[min] = arr[i] no_swap: addi $s2, $s2, 1 j outer_loop sort_done: lw $ra, 0($sp) lw $s0, 4($sp) lw $s1, 8($sp) lw $s2, 12($sp) addi $sp, $sp, 16 jr $ra这段代码里最值得你研究的是进入sort后先做的“现场保护”。MIPS调用约定里,寄存器分两类:
$t0-$t9是临时寄存器,子程序可以不保留,随便用;$s0-$s7是保留寄存器,如果子程序要用它,必须先保存原值,返回前恢复;$ra保存返回地址,一旦你在这个子程序里再次jal,原来的$ra就丢了,所以也要保存。
我当时第一次写时忘了保存$s寄存器,结果sort调用完,回到main时$s已经被sort内部改掉,导致后面打印循环用了坏地址,找了半小时bug。你最好现在就养成习惯:每个子程序开头addi $sp, $sp, -N,把所有要用的$s寄存器连同$ra一起压栈,收尾时逆序弹栈。这个模板一次写对,后面所有实验都能套用。
还有一处我用了bge $t3, $t5, skip_update。选“>=就跳过”而不是“>才更新”,是为了让重复元素保持位置相对稳定,虽然选择排序本来就不稳定,但这个写法至少避免了无意义的交换。反过来,如果你写成了bgt $t3, $t5, skip_update,效果是遇到相等元素时min会更新到后面那个相等元素,然后交换,代码一样能跑但白白多一次写内存。性能差异在这个规模下无感,但会让你的数据在调试断点里看起来“跳来跳去”。
5. 结构体排序的完整改造:按成绩字段比较,按整块记录交换
整数排序跑通后,结构体排序就是它的“换皮升级”。关键变化有两点:访问步长从4字节变成8字节,比较的是某个字段而不是整个元素。但如果你只是把sll $t1, 1, 2里的2改成3来“乘8”,还是不对,因为你还要决定拿哪个字段去比、交换时怎么把8字节整体搬过去。
我直接给你一个结构体排序主程序,代码里保留了上一节的骨架,但把访问逻辑替换为“结构体步长 + 字段偏移”的版本。数组用上一节定义的学生结构体,按成绩升序排列。
.data students: .word 1001, 85 .word 1003, 92 .word 1002, 78 .word 1005, 88 .word 1004, 95 end_students: .text .globl main main: la $a0, students la $t0, end_students sub $a1, $t0, $a0 srl $a1, $a1, 3 # 除以8,得到学生个数 jal sort_students # 打印每个学生的学号和成绩,验证排序结果 la $t0, students la $t1, end_students print_students: bge $t0, $t1, done lw $a0, 0($t0) # 学号 li $v0, 1 syscall la $a0, colon li $v0, 4 syscall lw $a0, 4($t0) # 成绩 li $v0, 1 syscall la $a0, newline li $v0, 4 syscall addi $t0, $t0, 8 j print_students done: li $v0, 10 syscall .data colon: .asciiz ": " newline: .asciiz "\n" .text # sort_students # 参数:$a0 = 结构体数组起始地址,$a1 = 结构体个数 # 按成绩字段升序排列,每个结构体 8 字节 sort_students: addi $sp, $sp, -20 sw $ra, 0($sp) sw $s0, 4($sp) sw $s1, 8($sp) sw $s2, 12($sp) sw $s3, 16($sp) move $s0, $a0 move $s1, $a1 li $s2, 0 # i = 0 outer: bge $s2, $s1, finish_sort move $s3, $s2 # min = i addi $t0, $s2, 1 # j = i + 1 inner: bge $t0, $s1, after_inner # 计算第 j 个学生的首地址 move $t1, $t0 sll $t1, $t1, 3 # j * 8 add $t1, $s0, $t1 # &students[j] lw $t2, 4($t1) # students[j].score # 计算第 min 个学生的首地址 move $t3, $s3 sll $t3, $t3, 3 # min * 8 add $t3, $s0, $t3 # &students[min] lw $t4, 4($t3) # students[min].score bge $t2, $t4, no_update move $s3, $t0 # min = j no_update: addi $t0, $t0, 1 j inner after_inner: beq $s3, $s2, no_swap_struct # 计算两个结构体首地址 move $t5, $s3 sll $t5, $t5, 3 add $t5, $s0, $t5 # $t5 = &students[min] move $t6, $s2 sll $t6, $t6, 3 add $t6, $s0, $t6 # $t6 = &students[i] # 交换 8 字节,即两个 word lw $t7, 0($t5) # 学号1 lw $t8, 4($t5) # 成绩1 lw $t9, 0($t6) # 学号2 sw $t7, 0($t6) # 覆盖 lw $t0, 4($t6) # 这里注意 $t0 还能用吗?——能,因为内层循环已结束 sw $t8, 4($t6) sw $t9, 0($t5) sw $t0, 4($t5) no_swap_struct: addi $s2, $s2, 1 j outer finish_sort: lw $ra, 0($sp) lw $s0, 4($sp) lw $s1, 8($sp) lw $s2, 12($sp) lw $s3, 16($sp) addi $sp, $sp, 20 jr $ra这里有个很隐蔽的坑点我必须单独说。交换代码里我临时用了$t0作为中转寄存器。但是,外层循环的上一次迭代已经结束了,此时$t0不是循环控制变量,内层循环也不会再进入,所以可以安全使用。但如果你把这段交换逻辑封装成单独的函数swap_students,在函数内部用$t0当然没问题,只是不要忘了swap返回后外层循环的$t0又被重新赋值为j,所以两种写法其实都可以。真正危险的是:如果在交换时顺手jal调用别的子程序,那个子程序会破坏$t7、$t8、$t9等临时寄存器,导致你从返回后取到的值全都不是刚才存的了。遇到多寄存器交换时,我的建议是宁可多用几个$s寄存器并压栈保护,也别硬复用临时寄存器,否则调试时会怀疑人生。
上面交换结构体用了四个lw加四个sw,本质上就是C语言里这个操作的展开:
Student tmp = students[i]; students[i] = students[min]; students[min] = tmp;因为结构体是8字节,所以搬一次tmp要两个word,整体交换就是4个word。如果结构体更大,比如10个字段,这个交换操作会非常长。这时候就需要考虑第6节讲的“索引排序”优化了。
6. 两种工程化思路:只交换4字节的指针/索引,而不是搬整条记录
结构体排序最容易忽略的性能问题是:比较只需要读一个字段,但交换却要搬整个结构体。成绩从78分搬到95分,学号也一起搬,也许还有姓名、班级、宿舍号……每交换一次就是一次大块内存拷贝,代价远大于一次比较。
我在处理这种问题时的经验是:如果结构体大小超过16字节,就不直接交换结构体本体,而是另开一个索引数组或者指针数组,每次只交换4字节的索引值。这样比较时先取索引,再通过索引导向原结构体取字段;交换时只交换索引。排序完成后,原数组没有被改动,输出的顺序则由索引数组决定。
在MIPS里实现索引排序,思路非常直接。假设我们已经有学生结构体数组students和等长整数数组index,初始化让index[i] = i:
.data index: .word 0, 1, 2, 3, 4 # 初始时索引[i] = i排序过程中,比较两个“虚拟元素”index[j]和index[min]时,不直接取students里的成绩,而是:
- 取物理编号
index[j]的值,设为$t0; - 用
$t0作为“第几个学生”的下标,乘以8,加上students基址; - 再在偏移4处取成绩字段。
代码逻辑比直接排序结构体多了一层间接跳转,换来的是交换操作从拷贝8字节缩减为拷贝4字节。结构体越大,越划算。这种技巧的本质,和你在数据库里对大数据行建索引排序是一样的,在汇编层面见识一次,等你回去写C的qsort时,对这些细节会有完全不同的敏感度。
如果不想开索引数组,也可以维护一个“指针数组”,每个元素存结构体首地址。la $t0, students后截断为仅存储各记录首地址……但作为MARS课程作业,索引数组已经足够,指针数组多了初始化指针的麻烦,反而不推荐。
顺带再说一个从高级语言迁移过来的理解陷阱。在C语言里,qsort要传一个比较函数cmp,它的参数是“指向元素的指针”。如果你之前理解了这个设计,再看MIPS的结构体排序,会立刻意识到:手动对结构体排序时,比较代码写死在排序函数里,没有扩展性。如果你有“通用”需求,可以在MIPS里约定用一个回调函数指针,比如$a3存放一个compare_proc的地址,排序过程中需要比较时jalr $a3调用它,让函数负责返回-1/0/1。这其实就是在MIPS里仿qsort。缺点是递归/回调栈管理变复杂,课堂作业一般不会要求到这层,但你可以把它作为后续挑战。
7. 调试实录:MARS里三种你一定会遇到的症状与定位方法
调试汇编比调试C语言更让人头疼的地方在于:高级语言编译器会提示“数组越界”“段错误”,而MARS汇编只在运行到非法访问时才给出含糊的错误信号,很多情况下程序不会立刻崩,而是“结果错得莫名其妙”。下面按我实际踩过的顺序,给你排出最常见的三类症状和定位方法。
7.1 死循环:卡在内层循环里不动
如果你在MARS里点单步执行,发现程序反复跳回某个标签,先把目光放到循环变量上。最典型的原因是忘记addi自增,或者把自增指令放在了j inner_loop之后,导致永远不会执行到。记住MIPS没有“for循环自动++”这回事,每一层循环的计数器自增都必须显式写出来,而且一定放在跳转语句之前。
还有种特殊死循环只出现在结构体排序里:你把步长算错了。比如每个结构体是8字节,但你在第j个学生的地址计算时用了j * 4,那j每加1实际只前进半个结构体,后面的字段读取全错,比较永远不满足更新条件,内层循环能一直转下去。这时检查乘法因子是否是结构体大小对应的字节数。
7.2 结果排序不对,但程序能正常跑完
这个症状最迷惑。程序能跑通,打印出来却是乱的,多半出现在比较方向或交换标签上。我建议你第一时间用“最小用例”二分定位:把数组缩短到3个元素,甚至2个元素,然后单步追踪看比较跳转是否正确。如果2个元素排序都对,就到3个元素,多半能找到问题。我在MARS里还遇到过一眼看过去很离谱的bug——排序结果是1,2,2,3,4这种大小穿插、部分有序的形态,后来发现是交换时用了两个元素分别存同一个寄存器,覆盖顺序错乱。这种问题最好的排查方式是在纸上列出寄存器变量:
swap前: arr[i] 在 $t6 arr[min] 在 $t3 $t6 和 $t3 都是从这个内存位置读出来的 写回的时候,我先写了arr[i]=$t3,没问题; 接着我想写arr[min]=$t6,但是发现 $t6 已经在第一步被覆盖了……如果你在MARS的“Execute”面板里能看到register窗口,可以在每轮交换前记录下$t3、$t6、$t7等值,用纸笔或Excel列个表追踪一轮完整循环,比盲目改代码快得多。
7.3 地址错乱:从系统区读到垃圾值
出现这种情况,先查代码是不是误用了lw的偏移量。MIPS的lw $t0, 4($t0)里的4是“字节偏移”,不是“数组下标偏移”。所以当你取第k个学生的成绩时,写4($t0)能取到成绩没错,但如果你想取第k+1个学生的学号,不该写8($t0)又写12($t0),而是要先让$t0加上8字节跳到下一条记录首地址,再取偏移0或4。如果你的代码里到处都是魔法数字0、4、8,出错的概率会显著提升。
调试时还有个大杀器:在MARS菜单栏Settings里勾选“Show Labels Window”,并在Memory Contents窗口用十六进制地址追踪students数组的内存。判断一个字段有没有被正确写入,直接在数据段视图里看十六进制值比脑内推演可靠多了。我调试结构体排序时,一半的bug都是靠盯内存窗口抓出来的。
8. 顺带把“sort函数”的含义讲全:从MIPS到C再到Python的抽象落差
很多读者是搜“sort函数”或者“sort函数排序结构体”进来的,可能期待的是sort(arr.begin(), arr.end())一行代码的用法解析,结果看到这里发现是汇编。这两者并不矛盾——我反而觉得,理解MIPS里排序如何展开,能反哺你对高级语言sort函数的理解。
看一张对照表,体会一下同样的操作在不同抽象层的表达差异:
| 逻辑操作 | Python/C++ | MIPS汇编 |
|---|---|---|
| 数组第i个元素 | arr[i] | lw $t0, 4*i($base) |
| 交换两个元素 | swap(arr[i], arr[j]) | lw+lw+sw+sw(4条指令起) |
| 循环n次 | for i in range(n) | li $t0,0+bge $t0,n,done+ 末尾addi $t0,1+j loop |
| 结构体字段比较 | a.score < b.score | sll+add+lw,再lw另一条,再bge/bgt/ble/blt |
这就是为什么老工程师常说“C语言是最接近汇编的高级语言”,而Python又在这之上包了一层。你用Python写students.sort(key=lambda s: s.score)时,不需要考虑8字节对齐,不需要管$t0有没有被交换代码污染,也不需要担心循环自增忘写——解释器替你把“结构体排序”的每一步都做完了。
但从学习角度讲,正因为在MIPS里每一步都必须显式地做,你才会真正看透“排序”的本质:它只有两件事——按照一定规则比较两个元素,以及在顺序不对时交换它们。比较规则是你定义的(成绩升序、成绩降序、学号升序),交换策略是可选的(直接搬结构体,或者只搬索引)。一旦你抓住这两点,任何排序题都只是参数不同而已。
我自己带人做这类实验时,有个屡试不爽的收尾方法:让他在完成结构体按成绩升序排序后,原地改成按学号降序排序,然后再改成“成绩相同按学号升序”。绝大多数人卡在最后一题,因为要先比成绩,若相等再比学号,这个分支在汇编里就是多一层嵌套判断:
# 先取两个比较字段到 $t2, $t4 bne $t2, $t4, decide # 成绩不等,直接按成绩判断 lw $t2, 0($t1) # 成绩相等时改取学号 lw $t4, 0($t3) decide: bge $t2, $t4, no_update这段代码简单吧?但你直接把它们拼进原来的sort_students里,十有八九会忘记处理“成绩相等”字段切换后,$t1和$t3还是结构体首地址,所以学号在偏移0处需要重新lw。这就是做结构体排序最典型的一种坑,也是助教最爱设的“改一行让你想半小时”的关卡。
回到最初那个问题:sort(arr, arr+n)背后到底发生了什么?现在你能回答出一层一层展开的细节了——比较、修改变量、交换、循环跳转,直到内存里呈现有序的字节序列。学MIPS的意义不在于你以后真去写汇编,而在于它逼你亲手撕开高级语言包在外面的那层糖衣,看清底层逻辑的骨架长什么样。如果你能把这段sort_students的代码自己动手敲一遍、调通、再扩展成多字段排序,那你对“数据+操作=程序”的理解,会比只背API的人扎实得多。