多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

MIPS结构体排序终极指南:从整数数组到选择排序实现与汇编调试

MIPS结构体排序终极指南:从整数数组到选择排序实现与汇编调试 先问一个问题你在高级语言里写排序时一条sort(arr, arrn)完事背后到底发生了什么如果你能回答“不知道”那这篇文章就是写给你的。如果你是因为课程作业、计组实验或者单纯好奇不得不面对MIPS汇编里的SORT实现——尤其还要排结构体——那恭喜你点进了一篇能把这件事讲透的文章。array: .word 3, 10, 8, 2, 5, 2, 3这种数据段定义配合sort子程序是MARS模拟器教学里最常见的题目形态。看似只是把一段C代码“翻译”成汇编实际动手时你会发现一个for(int i0;in;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架构所有运算都发生在寄存器里内存中的数据必须先用lwload word取到寄存器再参与比较或交换算完后要写回内存用swstore 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这个地址上连续排布是这样的偏移地址内容含义students01001第0个学生学号students485第0个学生成绩students81003第1个学生学号students1292第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)当n7甚至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)能取到成绩没错但如果你想取第k1个学生的学号不该写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/CMIPS汇编数组第i个元素arr[i]lw $t0, 4*i($base)交换两个元素swap(arr[i], arr[j])lwlwswsw4条指令起循环n次for i in range(n)li $t0,0bge $t0,n,done 末尾addi $t0,1j loop结构体字段比较a.score b.scoreslladdlw再lw另一条再bge/bgt/ble/blt这就是为什么老工程师常说“C语言是最接近汇编的高级语言”而Python又在这之上包了一层。你用Python写students.sort(keylambda 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, arrn)背后到底发生了什么现在你能回答出一层一层展开的细节了——比较、修改变量、交换、循环跳转直到内存里呈现有序的字节序列。学MIPS的意义不在于你以后真去写汇编而在于它逼你亲手撕开高级语言包在外面的那层糖衣看清底层逻辑的骨架长什么样。如果你能把这段sort_students的代码自己动手敲一遍、调通、再扩展成多字段排序那你对“数据操作程序”的理解会比只背API的人扎实得多。
返回列表