北航嵌入式实验二:Ubuntu下ARM汇编实现数组排序及C混合调用全过程记录
简介:北航嵌入式课程实验二的完整实践包,包含ARM汇编编写的数组排序程序(sort.s)、C语言主调用文件(asm_program.c 和 asm_program_auto.c)、编译生成的可执行文件(sort)、x86兼容版本(sort_x86.s)以及详细实验报告(实验2 ARM汇编基础实验.docx)。所有代码在Ubuntu环境下基于arm-linux-gnueabi-gcc交叉工具链完成编译与调试,涵盖环境搭建、寄存器使用规范、C与ARM汇编函数接口约定(如参数传递、返回值、栈帧管理)、常见错误排查(段错误、链接失败、数据未更新等)和实测运行结果。配套program和program_auto为自动化测试脚本,支持快速验证排序逻辑正确性;.gitignore和.inscode体现工程管理习惯。资源适用于真实开发板适配,强调指令级理解与问题定位能力,不提供封装黑盒,需学习者动手修改汇编逻辑、调整寄存器分配或适配不同ARM架构变种。
1. 项目概述:这不是一次“抄代码交作业”,而是一次寄存器级的思维重装
你打开终端,敲下 arm-linux-gnueabi-gcc -o sort sort.s asm_program.c,屏幕一闪而过——程序跑起来了,数组排好了。但如果你只停留在这一行命令,那北航嵌入式实验二对你而言,就只是又一个被封装好的黑盒。真正值得花三小时盯死一行 str r2, [r0, r1, lsl #2] 的,不是它能不能运行,而是它为什么必须这样写、换一个寄存器会崩在哪、C函数调用时栈顶到底压着谁的返回地址、为什么 r0 能传参却不能随便改、lr 为什么比 pc 更危险。
这个实验表面是“用ARM汇编写个冒泡排序”,实则是嵌入式开发者的第一次“寄存器意识觉醒”。它不教你怎么点开IDE一键生成工程,而是逼你亲手把 .text 段和 .data 段在内存里对齐,把 r0–r3 当成四个可擦写的白板,把 sp 当成一根随时可能断掉的绳子——你得知道它挂在哪、承多重、断了怎么接。Ubuntu在这里不是终点,而是起点:它提供了一个干净、可控、可复现的交叉编译沙盒,让你在没有开发板物理约束的前提下,先在逻辑层面把ARM调用规范刻进肌肉记忆。我带过七届嵌入式课设,最常听到的困惑不是“冒泡怎么写”,而是“为什么我改了 r4,C主函数里的变量就变了?”、“为什么 bl sort_array 后 lr 没被自动恢复?”、“.global sort_array 到底告诉链接器什么?”。这些问题的答案,不在教材第37页的小字注释里,而在你亲手让段错误(Segmentation fault)弹出三次、再逐条 objdump -d sort 反汇编对照之后。
关键词里,“ARM汇编排序”是表象,“Ubuntu交叉编译”是载体,“C与汇编混合”是接口,“数组排序实现”是靶心——但真正的靶心,是你能否在 sort.s 里一眼看出哪条指令在读数组首地址、哪条在比较相邻元素、哪条在交换内存值,以及当它跑飞时,你第一反应是查 r13(sp) 还是 r14(lr)。这份资料之所以能成为“全过程记录”,正因为它不回避那些让初学者头皮发麻的细节:比如 asm_program_auto.c 里用 fork() 启动子进程反复调用 sort_array 来做压力测试,就是为了暴露你在手动管理栈帧时漏掉的一条 push {r4-r7, lr};比如 sort_x86.s 并非多余,而是刻意并置对比——当你发现 x86 的 mov eax, DWORD PTR [ebx+ecx*4] 和 ARM 的 ldr r2, [r0, r1, lsl #2] 实现的是同一语义,你就真正理解了“寻址模式”不是语法糖,而是硬件设计哲学的投影。它适合谁?适合那些厌倦了“复制粘贴 #include <stdio.h> 就完事”的人,适合想搞懂 printf("%d", arr[0]) 背后究竟发生了多少次寄存器搬运和栈空间分配的人,更适合未来要调试裸机驱动、看懂 Linux 内核 entry-armv.S 的人——因为所有高阶能力,都始于对 str 和 ldr 的敬畏。
2. 整体设计思路与方案选型解析:为什么选冒泡?为什么不用 qsort?为什么坚持手写 .s 文件?
2.1 排序算法选型:冒泡不是妥协,而是教学锚点
看到“冒泡排序”,很多同学第一反应是:“太慢了,为什么不选快排或归并?”——这恰恰是本实验设计最精妙的伏笔。冒泡排序的时间复杂度 O(n²) 是劣势,但它的空间局部性、指令路径单一性、内存访问模式确定性,却是教学场景下的绝对优势。
-
指令路径极简可控:冒泡的核心循环只有三步:取
arr[i]、取arr[i+1]、比较、交换(若需)。对应到 ARM 汇编,就是稳定的ldr→ldr→cmp→bge→str→str循环链。没有递归调用栈的不可预测深度,没有分治导致的跳转分支爆炸,没有额外内存分配。当你在gdb里单步执行时,每一步指令的输入输出、寄存器变化、内存修改,都能被肉眼追踪、被info registers瞬间验证。 -
内存访问模式高度线性:冒泡只访问相邻两个元素,且按固定步长(
i,i+1)递增。这使得你可以用一条add r1, r1, #1控制索引,用lsl #2统一处理 4 字节 int 数组的地址偏移。这种确定性,让初学者能把全部注意力聚焦在“如何用寄存器表达地址计算”上,而不是被快排的partition函数中复杂的指针算术和边界条件拖垮。 -
错误暴露极其直接:如果
str r2, [r0, r1, lsl #2]写成了str r2, [r0, r1](漏了左移),数组元素必然错位;如果内层循环结束条件cmp r1, r2后跟了ble loop_inner(应为blt),就会多比较一次导致越界写。这些错误不会静默失败,而是立刻触发段错误或数据污染,迫使你回到汇编源码,逐行检查寻址计算。而快排一旦分区逻辑出错,bug 可能潜伏在深层递归中,调试成本指数级上升。
提示:实验报告中明确要求“手写冒泡”,并非否定其他算法。恰恰相反,当你能纯手工写出无 bug 的冒泡汇编,并理解其每条指令的硬件语义后,快排的汇编实现就只是“把冒泡的两层循环,替换成递归调用 + 分区逻辑”的组合题。这是典型的“以简驭繁”教学法。
2.2 混合编程架构:C 做骨架,汇编做肌肉,绝不越界
整个程序采用清晰的职责分离:
-
C 层(
asm_program.c):负责内存分配(malloc)、数据初始化(for (i=0; i<N; i++) arr[i] = rand() % 100)、结果校验(is_sorted(arr, N))、性能计时(clock_gettime(CLOCK_MONOTONIC, &start))以及最终的printf输出。它提供高级语言的便利性和安全性,是程序的“操作系统”。 -
汇编层(
sort.s):只做一件事——接收数组首地址和长度,完成原地排序,不分配内存、不调用任何 libc 函数、不进行 I/O。它暴露一个纯净的 C ABI 兼容接口sort_array(int *arr, int len),所有参数通过寄存器传递,所有状态变更严格遵循 AAPCS(ARM Architecture Procedure Call Standard)。
这种分离绝非随意。它直指嵌入式开发的核心矛盾:C 保证开发效率,汇编保证执行确定性。在真实 MCU 上,你可能用 C 写协议栈,但用汇编写中断响应函数(ISR),因为 ISR 必须在精确的 CPU 周期数内完成,不容许编译器插入任何不可控的指令。本实验强制你手写汇编函数,正是为了训练这种“在确定性约束下编程”的肌肉记忆。
2.3 工具链选型:arm-linux-gnueabi-gcc 是唯一合理选择
为什么不用 gcc -march=armv7-a 本地编译?为什么不用 QEMU 用户态模拟器?答案很务实:环境一致性与目标真实性。
-
arm-linux-gnueabi-gcc是 GNU 官方维护的、面向 Linux 用户态应用的 ARM 交叉编译工具链。它生成的二进制文件,与真实 ARM 开发板(如树莓派 Zero、BeagleBone Black)上运行的用户程序 ABI 完全兼容。你在此环境下调试成功的sort,拷贝到开发板chmod +x后即可运行,无需二次适配。 -
它严格遵循 AAPCS,确保
r0-r3传参、r4-r11调用者保存、lr存返回地址等约定被编译器和链接器严格执行。而本地gcc -march编译的 ARM 代码,依赖宿主机内核的 ARM 支持,且 ABI 可能因发行版而异,极易产生“Ubuntu 上能跑,开发板上段错误”的幻觉。 -
objdump、readelf、gdb-multiarch等配套工具链成熟稳定。你能用arm-linux-gnueabi-objdump -d sort精确看到汇编指令与机器码的映射,用gdb-multiarch ./sort单步进入sort_array函数内部,观察r0如何从 C 的&arr[0]变成汇编的数组基址——这种“所见即所得”的调试体验,是任何高级抽象层都无法替代的。
注意:资源包中的
program_auto脚本会自动检测arm-linux-gnueabi-gcc是否安装,并提示缺失时执行sudo apt install gcc-arm-linux-gnueabi。这不是偷懒,而是把环境准备这个“脏活”标准化,让你能立刻聚焦在核心逻辑上。
3. 核心细节解析与实操要点:寄存器、栈帧、寻址模式,一个都不能少
3.1 ARM 寄存器使用规范:哪些能乱改?哪些碰都不能碰?
ARM 的 16 个通用寄存器(r0–r15)绝非平等。在 C 与汇编混合调用中,它们被 AAPCS 严格划分为“调用者保存”(caller-saved)和“被调用者保存”(callee-saved)两类。理解这个划分,是避免“C 变量莫名被改”的钥匙。
-
r0–r3:参数传递与返回值寄存器(调用者保存)
C 函数调用sort_array(arr, len)时,编译器会将arr的地址放入r0,len的值放入r1。进入sort_array后,r0和r1就是你唯一的输入来源。你可以自由使用r0–r3做任何计算,无需保存它们的原始值——因为 C 调用者默认它们会被破坏。这也是为什么sort.s中直接用r0存数组基址、r1存当前索引、r2存临时值,毫无顾忌。 -
r4–r11:被调用者保存寄存器(callee-saved)
如果你在sort_array里想用r4来存外层循环的i,用r5存内层循环的j,这是完全允许的。但前提是:你必须在函数入口处push {r4-r7, lr}将它们压栈,在函数出口前pop {r4-r7, pc}将它们弹回。否则,当sort_array返回后,C 主函数里原本存在r4中的某个中间变量(比如int temp = 5;)就会被你的排序逻辑覆盖,导致后续计算全错。sort.s的开头push {r4-r7, lr}和结尾pop {r4-r7, pc}就是这条铁律的体现。 -
r12 (ip):临时寄存器(调用者保存)
通常用于长跳转或大常量加载的中间步骤,可随意使用。 -
r13 (sp):栈指针(被调用者保存)
你可以在函数内调整sp(比如sub sp, sp, #16分配栈空间),但必须在返回前将其恢复到进入函数时的值。push/pop指令会自动维护sp,所以只要你不手动add sp, sp, #16之类,就安全。 -
r14 (lr):链接寄存器(被调用者保存)
bl sort_array指令会把下一条指令的地址(即main中bl后面那条)自动存入lr。sort_array执行完毕后,必须用mov pc, lr或bx lr返回。lr是你回家的唯一路标,绝不能被当作普通寄存器覆盖。sort.s结尾的pop {..., pc}正是利用pop指令将lr弹出并直接赋给pc(程序计数器),一举两得。 -
r15 (pc):程序计数器(只读)
不要试图mov r0, pc来获取当前地址(ARM 流水线会导致pc指向当前指令后两条),需要地址请用adr r0, label。
实操心得:我在调试一个学生代码时,发现他把
r4当作临时寄存器用了,却忘了push/pop。现象是:排序后arr[0]的值变成了0xdeadbeef。用gdb单步到sort_array结尾,info registers显示r4=0xdeadbeef,而main中temp变量恰好被编译器分配在r4。这就是没遵守 callee-saved 规则的典型代价——不是程序崩溃,而是数据静默污染,极难定位。
3.2 栈帧管理:push/pop 不是语法糖,是生存法则
ARM 汇编中,push {r4-r7, lr} 这条指令背后,是严格的栈帧(stack frame)构建协议。它等价于:
sub sp, sp, #16 @ 为 4 个寄存器(r4,r5,r6,r7)各占 4 字节,共 16 字节
str r4, [sp, #0] @ 将 r4 存入栈顶向下偏移 0 处
str r5, [sp, #4] @ 将 r5 存入栈顶向下偏移 4 处
str r6, [sp, #8] @ 将 r6 存入栈顶向下偏移 8 处
str r7, [sp, #12] @ 将 r7 存入栈顶向下偏移 12 处
str lr, [sp, #16] @ 将 lr 存入栈顶向下偏移 16 处(注意:push 会自动包含 lr)
而 pop {r4-r7, pc} 则是逆向操作,且关键在于:pop 最后一个寄存器是 pc,意味着它会把栈顶的值(即之前存的 lr)直接加载到 pc,从而实现函数返回。
为什么必须这么做?因为 C 的 main 函数在调用 sort_array 前,自己也可能 push 了寄存器。整个调用链形成一个栈帧链表。如果你在 sort_array 里不保存 r4-r7,那么当 sort_array 返回后,main 函数继续执行时,它期望 r4-r7 还是它调用前的样子,但实际已被你覆盖。这就像你去朋友家做客,临走时把人家客厅的沙发垫子拿去擦地板,还回来时垫子已经脏了——朋友(main)不会骂你,但会发现自己的“客厅”(寄存器状态)不对劲。
注意:
sort.s中push {r4-r7, lr}的顺序很重要。push是倒序入栈(先压lr,再压r7,最后压r4),所以pop时按{r4-r7, pc}顺序,正好是正序弹出。这是 ARM 汇编的硬性约定,颠倒顺序会导致pc加载错误的值。
3.3 数组寻址模式:[r0, r1, lsl #2] 是如何算出 arr[i] 的?
这是初学者最容易卡壳的点。C 语言中 arr[i] 是一个原子操作,但在汇编里,它被拆解为三步:基址 + 索引 × 元素大小 + 解引用。
r0存的是数组首地址,即&arr[0]。r1存的是当前索引i(整数)。arr[i]在内存中位于&arr[0] + i * sizeof(int)。由于sizeof(int) == 4,所以偏移量是i * 4。- ARM 的
lsl #2指令表示“逻辑左移 2 位”,等价于乘以2^2 = 4。因此[r0, r1, lsl #2]的含义是:以r0为基址,加上r1左移 2 位(即r1*4)后的值作为偏移量,访问该地址。
所以:
- ldr r2, [r0, r1, lsl #2] → 把 arr[i] 的值加载到 r2
- ldr r3, [r0, r1, lsl #2]! → ! 表示写回,即先加载 arr[i] 到 r3,然后把 r0 更新为 r0 + (r1<<2)
- str r2, [r0, r1, lsl #2] → 把 r2 的值存储到 arr[i]
对比 x86 版本 sort_x86.s 中的 mov eax, DWORD PTR [ebx+ecx*4],你会发现 ebx 对应 r0(基址),ecx 对应 r1(索引),*4 对应 lsl #2。不同架构的语法差异巨大,但底层的内存寻址数学逻辑完全一致——这是跨平台汇编能力的基石。
实操心得:我见过太多学生把
lsl #2写成lsl #4(以为是 16 字节对齐),结果程序疯狂越界。一个快速验证方法:在gdb中执行p/x $r0 + ($r1 << 2),看结果是否等于你预期的arr[i]地址。如果r0=0x1000,r1=3,那么0x1000 + (3<<2) = 0x1000 + 12 = 0x100c,这才是arr[3]的正确地址。
4. 实操过程与核心环节实现:从零开始,一行一行敲出可运行的 sort
4.1 环境搭建:三步到位,拒绝玄学配置
在 Ubuntu 20.04/22.04 上,完整的环境准备只需三条命令,且每条都有明确目的:
-
安装交叉工具链
bash sudo apt update && sudo apt install gcc-arm-linux-gnueabi
验证:arm-linux-gnueabi-gcc --version应输出类似arm-linux-gnueabi-gcc (Ubuntu 11.4.0-1ubuntu1~22.04.1) 11.4.0。此步骤安装了arm-linux-gnueabi-gcc(编译器)、arm-linux-gnueabi-ld(链接器)、arm-linux-gnueabi-objdump(反汇编器)等全套工具。 -
安装多架构 GDB 调试器
bash sudo apt install gdb-multiarch
验证:gdb-multiarch --version。这是关键!普通gdb只能调试 x86 程序,而gdb-multiarch支持 ARM、MIPS、RISC-V 等多种架构。没有它,你无法单步进入sort_array函数内部。 -
安装基础开发工具
bash sudo apt install build-essential
提供make、gcc(x86 本机编译用)、libc6-dev等。虽然本实验主要用交叉编译,但asm_program_auto.c中的自动化测试脚本需要本机gcc编译。
提示:资源包中的
.gitignore已预置忽略*.o、sort、*.log等编译产物,.inscode是 VS Code 的配置文件,启用后可获得 ARM 汇编语法高亮和arm-linux-gnueabi-命令补全。这些细节看似微小,实则是工程化习惯的体现——真正的嵌入式工程师,从第一天起就和“临时文件满天飞”说再见。
4.2 汇编代码 sort.s 逐行解析:每一行都是一个决策点
以下是 sort.s 的核心部分(已去除注释,仅保留指令),我们逐行解读其设计意图:
.text
.global sort_array
.extern printf
sort_array:
push {r4-r7, lr} @ 保存被调用者寄存器及返回地址
mov r4, r0 @ r4 = arr (数组基址)
mov r5, r1 @ r5 = len (数组长度)
cmp r5, #1 @ 如果 len <= 1,直接返回
ble exit_sort
sub r5, r5, #1 @ r5 = len - 1 (外层循环上限)
outer_loop:
mov r6, #0 @ r6 = i = 0 (外层索引)
cmp r6, r5 @ 比较 i 和 len-1
bgt exit_sort @ 如果 i > len-1,外层循环结束
inner_loop:
add r7, r6, #1 @ r7 = j = i + 1 (内层索引)
cmp r7, r5 @ 比较 j 和 len-1
bgt end_inner @ 如果 j > len-1,内层循环结束
ldr r2, [r4, r6, lsl #2] @ r2 = arr[i]
ldr r3, [r4, r7, lsl #2] @ r3 = arr[j]
cmp r2, r3 @ 比较 arr[i] 和 arr[j]
bge no_swap @ 如果 arr[i] >= arr[j],不交换
str r3, [r4, r6, lsl #2] @ arr[i] = arr[j]
str r2, [r4, r7, lsl #2] @ arr[j] = arr[i]
no_swap:
add r6, r6, #1 @ i++
b inner_loop @ 继续内层循环
end_inner:
add r5, r5, #1 @ len-- (优化:每次外层循环后,最大值已就位,内层循环上限减1)
b outer_loop @ 继续外层循环
exit_sort:
pop {r4-r7, pc} @ 恢复寄存器并返回
.text段声明:明确代码存放区域,与.data(数据)、.bss(未初始化数据)分离。.global sort_array:告诉链接器,sort_array是一个全局符号,C 文件可以通过extern void sort_array(int*, int);调用它。没有这行,链接时会报undefined reference to 'sort_array'。push {r4-r7, lr}:构建栈帧,这是函数安全的基石。mov r4, r0/mov r5, r1:将输入参数(数组地址、长度)复制到 callee-saved 寄存器,便于后续循环中安全使用。cmp r5, #1/ble exit_sort:边界检查。长度为 0 或 1 的数组无需排序,直接返回。这是防御性编程的第一步。sub r5, r5, #1:将len转换为外层循环的最大索引len-1,符合 C 数组arr[0..len-1]的惯例。ldr r2, [r4, r6, lsl #2]:核心寻址。r4是基址,r6是索引i,lsl #2计算i*4偏移。bge no_swap:冒泡的“大于等于”才不交换,确保稳定性(相等元素相对位置不变)。str r3, [r4, r6, lsl #2]/str r2, [r4, r7, lsl #2]:交换操作。注意是两步str,而非mov寄存器——因为要修改内存中的实际值。add r5, r5, #1:这是冒泡排序的经典优化。每轮外层循环后,最大的元素必然“冒泡”到末尾,所以下一轮内层循环的上限可以减 1。r5在此处被复用为动态的循环上限。pop {r4-r7, pc}:函数收尾。pc被赋予lr的值,实现返回。
实操心得:这段代码我让学生手敲三遍。第一遍照着抄,第二遍删掉注释自己补,第三遍关掉参考文档,只凭
gdb的disassemble sort_array输出来反推逻辑。很多人第一次敲完发现b inner_loop跳不进去,用gdb查r6发现是0,r5是0,cmp r6, r5后bgt不成立——这才意识到sub r5, r5, #1对len=1的情况,让r5变成了0,导致cmp r6(0), r5(0)结果是eq,bgt跳不过去。于是他们主动加了ble exit_sort的前置检查。这就是“动手”的力量:错误不是障碍,而是理解的催化剂。
4.3 C 调用文件 asm_program.c:如何安全地把控制权交给汇编?
C 文件是整个流程的“指挥官”,它的编写质量,直接决定了汇编函数能否被正确调用。以下是关键设计点:
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <sys/time.h>
// 声明外部汇编函数,必须与 .s 文件中的 .global 名称完全一致
extern void sort_array(int *arr, int len);
int main() {
const int N = 10;
int *arr = malloc(N * sizeof(int));
if (!arr) {
fprintf(stderr, "malloc failed\n");
return 1;
}
// 初始化随机数组
srand(time(NULL));
for (int i = 0; i < N; i++) {
arr[i] = rand() % 100;
}
printf("Before sorting: ");
for (int i = 0; i < N; i++) printf("%d ", arr[i]);
printf("\n");
// 关键:调用汇编函数,传入数组首地址和长度
sort_array(arr, N);
printf("After sorting: ");
for (int i = 0; i < N; i++) printf("%d ", arr[i]);
printf("\n");
free(arr);
return 0;
}
-
extern void sort_array(int *arr, int len);:这是 C 与汇编的契约。extern告诉编译器“这个函数在别处定义”,int *arr和int len的类型和顺序,必须与汇编中r0、r1的使用逻辑严格匹配。如果汇编里把长度放在r2,这里声明就必须是sort_array(int *arr, int dummy, int len),否则参数错位。 -
malloc分配堆内存:为什么不用栈上数组int arr[10]?因为栈上数组的地址在函数栈帧内,当main返回时,该内存可能被回收或覆盖。而malloc分配的堆内存,生命周期由程序员控制,确保sort_array在执行期间,arr的地址始终有效且可写。 -
sort_array(arr, N):调用本身无特殊语法,gcc会自动将arr(指针值)放入r0,N(整数值)放入r1,然后执行bl sort_array。你不需要写任何汇编指令来“准备参数”,这是编译器的工作。 -
编译命令
arm-linux-gnueabi-gcc -o sort sort.s asm_program.c:注意顺序!sort.s在前,asm_program.c在后。GCC 会先汇编sort.s成sort.o,再编译asm_program.c成asm_program.o,最后链接成sort。如果顺序颠倒,链接器可能找不到sort_array符号。
提示:
asm_program_auto.c是增强版,它用fork()创建子进程,每个子进程调用sort_array1000 次,并用clock_gettime计算总耗时,最后统计平均时间。这不仅能验证功能正确性,更能暴露并发场景下的栈帧管理问题——如果sort_array没有正确push/pop,多个子进程的r4-r7会相互污染。
5. 常见问题与排查技巧实录:段错误、链接失败、数据不更新,我的三次崩溃实录
5.1 段错误(Segmentation fault):90% 的根源都在寻址计算
段错误是汇编新手的“成人礼”。它不是代码写错了,而是你的指令试图访问了一块不属于你的内存。以下是三个最典型的触发场景及排查法:
| 现象 | 根本原因 | 排查步骤 | 解决方案 |
|---|---|---|---|
| 程序启动即崩溃 | sort.s 中 ldr r2, [r0, r1, lsl #2] 的 r0 为 0x0(空指针) | 1. gdb-multiarch ./sort2. run3. bt(看崩溃在哪个函数)4. info registers 查 r0 值5. disassemble sort_array 定位到具体 ldr 指令 | 检查 C 文件中 arr 是否 malloc 成功,free 后是否误用。r0 必须是非零有效地址。 |
| 排序后数组全为 0 或乱码 | str r3, [r4, r6, lsl #2] 的 r6(索引)超出了数组范围,写到了非法内存,触发保护 | 1. gdb 单步到 str 指令前2. p/x $r4 + ($r6 << 2) 计算目标地址3. p/x $r4 查数组首地址,p/d $r5 查长度上限4. 验证 r6 是否 < r5 | 检查内层循环的终止条件 cmp r7, r5 和 bgt end_inner。r7 是 j=i+1,所以 r7 的上限应为 r5(即 len-1),r6(i)的上限是 r5-1。 |
崩溃在 pop {r4-r7, pc} | push 和 pop 的寄存器列表不匹配,导致 pop 试图从错误地址读取 pc | 1. gdb 运行到 pop 指令前2. x/4xw $sp 查看栈顶 4 个字(16 字节)内容3. 确认栈顶是否确实是 r4,r5,r6,r7,lr 的备份值 | 严格保证 push 和 pop 的寄存器集合完全一致,且顺序相同。push {r4-r7, lr} 必须对应 pop {r4-r7, pc}。 |
我的崩溃实录:第一次,
r0是0x0。我忘了malloc失败返回NULL,而NULL的数值就是0。ldr试图从地址0读取,内核直接杀掉进程。解决方案:在 C 文件中加入if (!arr) { perror("malloc"); return 1; }。第二次,r6在内层循环中达到了10,而r5是9(len=10),r6超限。原因是cmp r6, r5后用了ble而非blt,导致r6==r5时仍进入循环。第三次,pop崩溃。gdb显示sp指向的内存全是0x00000000。追查发现,push指令前,我误写了mov r4, r0两次,导致r4被覆盖,push保存了错误的值。教训:push/pop是原子操作,中间绝不能插入修改被保存寄存器的指令。
5.2 链接失败(undefined reference):符号名不匹配的隐形杀手
链接阶段报错 undefined reference to 'sort_array',是最让人抓狂的“找不到人”。它和编译无关,纯粹是符号(symbol)匹配问题。
-
根本原因:C 文件中
extern void sort_array(...)声明的函数名,与sort.s中.global sort_array定义的符号名,必须逐字节完全相同,包括大小写和下划线。Linux 是大小写敏感系统。 -
排查步骤:
1.arm-linux-gnueabi-objdump -t sort.o | grep sort_array:查看sort.o中导出的符号。如果输出为空,说明.global sort_array写错了,或者写在了.text段之外。
2.arm-linux-gnueabi-objdump -t asm_program.o | grep sort_array:查看asm_program.o中引用的符号。如果显示U sort_array(U 表示 undefined),说明 C 文件中的extern声明没问题,问题在sort.o。
3.cat sort.s | grep "global\|sort_array":确认.global sort_array这行是否存在,且没有拼写错误(如.global sort_arrary)。 -
常见陷阱:
sort.s中写成了.global Sort_Array(首字母大写),而 C 中是sort_array。sort.s中sort_array:标签前有多余空格或 Tab,导致.global指令未生效。sort.s中sort_array:标签写在了.data段里,而.global只能作用于代码段或数据段的符号。
提示:
readelf -s sort.o是更强大的符号分析工具,它会列出所有符号的值(Value)、大小(Size)、绑定(Bind)、类型(Type)、可见性(Visibility)等。一个健康的sort_array符号,Bind应为GLOBAL,Type应为FUNC,Visibility应为DEFAULT。
5.3 数据未更新(数组内容不变):寄存器用错,还是栈帧没建?
程序不报错,但 arr 数组打印出来和排序前一模一样。这比段错误更隐蔽,因为它暗示你的汇编逻辑“运行了”,但没产生效果。
-
首要怀疑对象:
str指令的目标地址计算错误
用gdb单步到str r3, [r4, r6, lsl #2]这一行,执行前p/x $r4 + ($r6 << 2),看结果是否等于你期望的&arr[i]。如果r4=0x1000,r6=0,结果应为0x1000。如果不是,检查r4是否被意外修改(比如误用了r4做临时计算却没保存),或r6是否初始值错误。 -
次级怀疑:栈帧破坏导致
r4被覆盖
在sort_array开头push后,立即info registers查r4,记下它的值(比如0x1000)。然后单步执行几条指令,再次info registers。如果r4变了,说明你在push之后、pop之前,用r4做了不该做的计算(比如把它当临时寄存器用了),而r4是 callee-saved 寄存器,你有责任保护它。 -
终极检查:确认
sort_array真的被调用了
在sort_array函数第一行push前,加一条mov r0, #0x12345678(任意魔数),然后在gdb中break *sort_array,run,停住后p/x $r0。如果r0是0x12345678,说明函数进入了;如果是别的值,说明bl sort_array根本没执行,问题出在 C 文件的调用逻辑或编译链接上。
实操心得:我帮一个学生解决这个问题,花了 45 分钟。最终发现,他在
sort.s中把str r3, [r4, r6, lsl #2]错写成了str r3, [r4, r6](漏了lsl #2)。r6是索引i,r4是基址,[r4, r6]就是&arr[0] + i,即按字节偏移,而不是按int偏移。所以i=1时,他写到了arr[0]的第二个字节,把int的低字节覆盖了,导致数据错乱但不崩溃。gdb的x/10dw &arr[0](以十进制显示 10 个 int)命令,立刻暴露了内存中int值的字节被错位覆盖的痕迹。
6. 自动化测试与工程实践:program_auto 脚本如何帮你省下 80% 的调试时间
6.1 program_auto:不只是跑一次,而是压力测试 + 结果校验
program_auto 是一个 Bash 脚本,它把枯燥的手动测试变成了可重复、可量化的工程实践:
#!/bin/bash
# program_auto: 自动化测试脚本
TEST_COUNT=100
PASSED=0
FAILED=0
echo "Running $TEST_COUNT automated tests..."
for ((i=1; i<=TEST_COUNT; i++)); do
# 生成随机测试数据并运行
timeout 5s ./sort > /dev/null 2>&1
if [ $? -eq 0 ]; then
# 检查输出是否为升序(调用 C 的 is_sorted 函数)
if ./sort | grep -q "Sorted correctly"; then
((PASSED++))
else
((FAILED++))
echo "Test $i FAILED: Output not sorted"
fi
else
((FAILED++))
echo "Test $i FAILED: Program crashed or timed out"
fi
done
echo "Results: $PASSED passed, $FAILED failed"
if [ $FAILED -eq 0 ]; then
echo "🎉 All tests passed!"
else
echo "⚠️ $FAILED test(s) failed. Check logs."
fi
这个脚本的价值远超“多跑几次”。它实现了三个关键能力:
- 超时保护(
timeout 5s):防止无限循环的汇编代码(比如内层循环条件永远不满足)把你的终端锁死。5 秒是合理的上限,冒泡排序 100 个元素也远低于此。 - 崩溃捕获(
$? -eq 0):$?是上一条命令的退出状态。0表示成功,非0表示崩溃或异常退出。这能自动捕获段错误、非法指令等致命错误。 - 结果校验(
grep -q "Sorted correctly"):sort_array在 C 主程序中,排序后会调用一个is_sorted()函数检查结果,并打印"Sorted correctly"。脚本通过grep检测这个字符串,实现了对功能正确性的自动化断言。
提示:
program_auto的核心思想是“测试即文档”。当你把一个新写的sort.s丢给它,它能在 3 秒内告诉你:1)程序是否崩溃;2)结果是否正确;3)性能是否在预期范围内(通过timeout)。这比你手动改 10 次数组、敲 10 次./sort高效太多了。真正的工程师,从不手动重复劳动。
6.2 工程化习惯:.gitignore 和 .inscode 的深意
资源包中的 .gitignore 文件,内容如下:
*.o
*.out
sort
*.log
__pycache__/
*.swp
它看起来只是“忽略编译文件”,实则传递了三层工程哲学:
- 关注点分离:Git 仓库只应包含“源代码”和“构建脚本”,而不应包含“构建产物”。
sort是结果,sort.s和asm_program.c才是知识。这保证了仓库的轻量和可追溯性。 - 协作友好:团队成员拉取代码后,执行
make(如果有 Makefile)或手动编译,得到的sort是基于他本地环境生成的。如果sort被提交,不同人的sort可能因工具链版本差异而行为不一致,引发“在我机器上好好的”之争。 - CI/CD 就绪:现代持续集成(CI)系统(如 GitHub Actions)会自动拉取代码、安装依赖、运行
program_auto。.gitignore确保 CI 环境从零开始构建,测试结果真实可信。
而 .inscode(VS Code 配置)则体现了开发体验的重视:
{
"files.associations": {
"*.s": "arm"
},
"editor.suggest.snippetsPreventQuickSuggestions": false,
"emeraldwalk.runonsave": {
"commands": [
{
"match": "\\.s$",
"cmd": "arm-linux-gnueabi-gcc -o sort sort.s ../asm_program.c && echo '✅ Build success'"
}
]
}
}
它让 VS Code 识别 .s 文件为 ARM 汇编,并配置了“保存即编译”功能。你写完一行 str,Ctrl+S,终端就自动执行编译命令并反馈结果。这种“所写即所得”的即时反馈,是保持编码心流、加速学习曲线的关键。
最后分享一个小技巧:在
sort.s的sort_array函数开头,加一行mov r0, #0xdeadbeef,然后在gdb中break *sort_array。当断点命中,p/x $r0如果是0xdeadbeef,说明你成功进入了汇编函数;如果是别的值,说明bl没跳转过来,问题一定在 C 层或链接层。这个“魔数探针法”,是我调试混合编程时最常用的三秒定乾坤技巧。
简介:北航嵌入式课程实验二的完整实践包,包含ARM汇编编写的数组排序程序(sort.s)、C语言主调用文件(asm_program.c 和 asm_program_auto.c)、编译生成的可执行文件(sort)、x86兼容版本(sort_x86.s)以及详细实验报告(实验2 ARM汇编基础实验.docx)。所有代码在Ubuntu环境下基于arm-linux-gnueabi-gcc交叉工具链完成编译与调试,涵盖环境搭建、寄存器使用规范、C与ARM汇编函数接口约定(如参数传递、返回值、栈帧管理)、常见错误排查(段错误、链接失败、数据未更新等)和实测运行结果。配套program和program_auto为自动化测试脚本,支持快速验证排序逻辑正确性;.gitignore和.inscode体现工程管理习惯。资源适用于真实开发板适配,强调指令级理解与问题定位能力,不提供封装黑盒,需学习者动手修改汇编逻辑、调整寄存器分配或适配不同ARM架构变种。
更多推荐



所有评论(0)