ARM 代码移植指南
本文系统梳理了向 ARMv7 平台移植代码时最常见的跨架构问题:端序差异如何导致静默数据错误、ARMv4 与 ARMv7 非对齐访问行为的不兼容、C 语言实现定义行为在不同编译器间的分歧,以及 ARM 汇编与 Thumb-2 互操作的关键约束。
1. 端序与跨平台一致性
在计算机体系结构中,”端序”指多字节数据在内存中的字节排列顺序:
| 端序 | 规则 | 实例:int i = 0x44332211 在地址 0x1000 的内存布局 |
|---|---|---|
| Little-endian | 最低有效字节存在最低地址 | 0x1000: 0x11, 0x1001: 0x22, 0x1002: 0x33, 0x1003: 0x44 |
| Big-endian | 最高有效字节存在最低地址 | 0x1000: 0x44, 0x1001: 0x33, 0x1002: 0x22, 0x1003: 0x11 |
ARM 核心同时支持两种端序,但默认运行于 little-endian。大多数 ARM Linux 发行版仅支持 little-endian。一个值得记住的对照:x86 是 little-endian;PowerPC 和 68K 通常是 big-endian;TCP/IP 是大端,USB 和 PCI 是小端。
1.1 端序敏感的代码模式
最容易因端序搬迁而崩溃的代码模式:
Unions — 联合体在不同时刻以不同数据类型解释同一块内存。当 halfword/word 数据被当作 byte 数组访问时,不同端序下的字节顺序完全相反。
指针强制转换 — char *buf = (char*)&i; 然后将 buf[0] 到 buf[3] 分别赋给 i0 到 i3。这四段代码在 big-endian 和 little-endian 系统上产生的 i0..i3 值排列顺序恰好相反——“inherently non-portable”。
Bitfields — 位域在不同编译器、不同端序下的排列方式没有任何标准保证:”code that defines bitfields or performs bit operations must not be used in code that is intended to be portable.”
网络代码 (Networking code) — 与网络协议栈或 I/O 设备的数据交互受端序影响最直接。TCP/IP 头部字段均为大端(htonl/ntohl 等函数用于主机序与网络序的转换),PCI/USB 为小端,而设备 DMA 缓冲区的端序取决于控制器设计。端口测试如果不覆盖不同端序的对端,静默的字节反转错误容易遗漏。
数据共享 — 任何跨核、跨 DSP、跨外设或跨网络的数据交换都必须确认双方的端序约定是否一致。不一致则必须在外设接口处做字节反转。
1.2 ARM 的端序控制机制
Cortex-A 系列通过 CPSR 的 E 位支持运行时切换数据访问的字节序。软件可使用 SETEND LE 或 SETEND BE 在 Little Endian 与 Big Endian 之间切换,但指令取指始终采用 Little Endian 编码,不会受 E 位影响。因此,程序切换端序时只影响数据读写,不影响代码执行。
CP15 SCTLR 的 EE 位(Exception Endian,bit[25])则决定异常处理期间的数据访问端序,同时也决定 MMU 页表遍历(Page Table Walk)的端序。这意味着应用程序与异常处理代码在理论上可以使用不同的数据端序。不过,主流编译器和 ABI 通常假设整个程序采用统一端序,很难针对系统中的一部分代码单独生成不同端序的访问,因此这种混合端序(Mixed Endian)系统在实际中很少使用。
现代 ARM 处理器采用 BE8(Byte-Invariant Big Endian) 模式实现大端支持:只有数据访问使用 Big Endian,指令取指始终保持 Little Endian。早期 ARM 曾支持 BE-32(指令和数据均采用 Big Endian)的模式,但已在 ARMv7 中废弃。因此,在 ARMv7 及之后的处理器上,无论系统采用大端还是小端,CPU 都始终以 Little Endian 格式读取指令,只是数据访问的字节顺序会根据配置发生变化。对于需要在软件中进行大小端转换的场景,可以使用 REV 指令高效地完成寄存器内的字节交换。
2. 内存对齐:ARMv4 与 ARMv7 的行为差异
2.1 旧 ARM 的 unaligned 行为与迁移风险
从 ARMv4/ARMv5 向 ARMv7 迁移的对齐问题值得单独讨论。在 ARM7 和 ARM9 处理器上,一条对一个非对齐地址的 LDR 不会触发异常——硬件仍然执行对齐的内存访问,但将加载的数据旋转使得请求地址处的字节出现在目标寄存器的最低有效字节位置。
“一些老式编译器和操作系统曾利用这一行为进行取巧的优化”。问题在于:当这段代码搬到 ARMv7 时,unaligned access 的行为完全不同——ARMv7 会从非对齐地址加载正确的连续字节(从该地址开始拉四个字节),而非旋转旧的对齐数据。结果:代码静默产生完全不同但看起来”语法正确”的输出——不会有 crash,不会有 warning,只有错误的计算结果。
ARMv7 的 MMU 可以配置为检测非对齐访问并触发 Data Abort——通过 CP15 SCTLR 的 A bit(alignment check enable)。对于调试来说,这是比静默错误好得多的行为。
2.2 ARMv7 的非对齐支持
Cortex-A 系列确实支持 unaligned access,但需要通过 CP15 SCTLR 的 U bit 显式启用。启用后,LDR/STR(word 和 halfword)可以处理非对齐地址。但以下指令类型始终要求对齐:
| 指令类型 | 最低对齐要求 | 后果 |
|---|---|---|
LDM / STM |
Word 对齐(4 字节) | 未定义行为或 Data Abort |
LDRD / STRD(双字) |
Word 对齐 | 同上 |
| VFP/NEON Load/Store | 元素大小对齐 | 同上,且可能触发 UNDEF |
| ABI 额外要求 | 可能强于架构定义 | 取决于编译器 |
非对齐访问不保证原子性——一个外部代理(如另一个核心)可能看到部分旧字节 + 部分新字节的混合状态。这是多核并发代码最隐蔽的竞态之一。
memcpy() 的例子展示了性能影响的量级:小批量、word-aligned 的拷贝编译为 LDM/STM 指令;大块对齐拷贝调用优化的库函数;起止边界不在 word 对齐上的拷贝则回退到通用 memcpy()——可能慢数倍。但若源和目标”同向不对齐”(source 和 destination 以相同偏移错位),只有首尾碎片是非优化的,中间段仍可全速 LDM/STM。
3. C 语言移植注意事项
3.1 char 的 signed/unsigned 歧义与编译器差异
一个极简例子展示了 ARM 与 x86 之间最隐蔽的差异:
1 | char c = -1; |
在 x86 上输出 “c is negative”,在 ARM 编译器上输出 “c is positive”(且伴随 warning)。原因是 ANSI C 标准没有规定 plain char 是 signed 还是 unsigned——这是编译器实现决定的。ARM 的最早版本没有 LDRSB(加载有符号字节并符号扩展),因此早期 ARM 编译器选择 char 默认为 unsigned 以匹配硬件能力。x86 则历来默认为 signed。
更致命的死循环例子:
1 | char c; |
在 ARM 上,getchar() 返回 int 类型的 -1(EOF),赋值给 unsigned char 的 c 后变成 255。循环条件 255 != -1 永远为真——死循环。修正方法是将 c 声明为 int:这正是 stdio.h 中函数的定义方式。同样的问题也出现在 getopt() 和 getc() 上——两者定义为返回 int,用 char 接收返回值会引发同样的 EOF 比较失败。
最佳实践:
unsigned char— 用于内存字节访问和小的无符号整数signed char— 用于小的有符号整数plain char— 仅用于 ASCII 字符和字符串
补充性能建议:在实际的 ARM 平台上,即便是小的值也通常优先用
int而非char——32-bit 寄存器的算术运算无需额外的符号扩展或截断指令。
3.2 结构体填充与 __packed__ 的代价
编译器不能重排结构体成员,且必须遵循核心的对齐约束。这意味着不同架构对同一 struct 可能插入不同的填充字节:
1 | struct test { |
使用 __attribute__((__packed__))(GCC)或 __packed(ARM Compiler)移除所有填充后,sizeof(struct test) 降为 7 bytes——但访问 i 和 s 变成了非对齐访问,需要额外的指令周期。权衡建议很明确:packed 对 Cortex-A 系列”相对高效”,但仍会损失性能并增加代码体积。用于跨硬件传递的数据结构时是可接受的;用于频繁访问的内部数据结构时需要权衡。
3.3 变参函数与栈假设
使用 <stdarg.h> 宏(va_start/va_arg/va_end)的代码是跨架构可移植的——它们封装了栈帧遍历。标准库的 va_* 宏会处理 ABI 相关的细节:参数是通过寄存器还是栈传递、栈的对齐要求、每个参数的类型提升规则(如 float 自动提升为 double、小整数提升为 int)。这些规则在不同架构和编译器之间完全不同。
但任何直接操作栈指针或用自定义宏遍历栈来获取变参的代码,在不同 ABI 下必然崩溃。典型的危险代码模式是:
1 | // 危险 —— 不可移植的变参访问 |
ARM AAPCS 规定前 4 个 32-bit 参数通过 R0-R3 寄存器传递,其余溢出的才入栈。做一个简单的指针算术 &count + 1 完全无法得到正确的变参位置——寄存器中的值根本不在栈上。唯一的可移植方案是 va_list + va_start + va_arg。
3.4 enum的大小与ABI兼容性
很多人认为 enum 一定占 4 字节,但实际上 C 标准并未规定枚举的底层类型,只要求能够表示所有枚举值。编译器可以根据实现或编译选项选择合适的整数类型。例如,GCC 默认通常将枚举实现为 int(4 字节),而启用 -fshort-enums 后,可能将仅包含少量枚举值的类型压缩为 1 字节。
这种差异在单个编译单元中通常没有问题,但跨模块或库接口可能导致 ABI 不兼容。例如:
1 | // library.h |
如果库按默认方式编译(sizeof(Color) == 4),而应用程序启用了 -fshort-enums(sizeof(Color) == 1),双方编译出的 Widget 布局将不同。由于链接器无法检查类型布局,这类问题通常不会在链接阶段报错,而是在运行时表现为成员访问错误或数据损坏。
最佳实践:
- 整个工程保持一致的编译选项,避免混用
-fshort-enums。 - 公共 API 应避免依赖
enum的具体大小;如需稳定 ABI,可使用固定宽度整数类型(如int32_t)配合常量定义,或在支持 C23 的环境下显式指定枚举底层类型。
3.5 整数位宽假设
从 8-bit 或 16-bit 核心移植的代码可能假设 int 是 16-bit,这类代码常见于 AVR、MSP430、PIC 等嵌入式平台的固件。但 ARMv7-A 上 int 永远是 32-bit——这不仅是 ARM 的惯例,也是 AAPCS 和 Linux ARM EABI 的强制要求。
C 标准仅规定了各整数类型的最小宽度,而非实际宽度:
| 类型 | C 标准最小位宽 | ARMv7-A 实际位宽 |
|---|---|---|
char |
8-bit | 8-bit |
short |
16-bit | 16-bit |
int |
16-bit | 32-bit |
long |
32-bit | 32-bit |
long long |
64-bit | 64-bit |
依赖 16-bit 溢出行为的代码在 ARMv7 上会产生完全不同的结果:
1 | uint16_t counter = 65535; |
1 | unsigned int counter = 65535; // 被 16-bit 开发者误认为是 16-bit 量 |
解决方案是始终使用 <stdint.h> 中的精确宽度类型(uint16_t、int32_t 等)。不要假设 int 或 long 的位宽——这些类型在不同架构上的实际宽度不同,且都符合 C 标准的合法实现。移植遗留嵌入式代码时,第一步应该将所有非固定宽度的整数声明替换为 stdint.h 类型,这是性能代价最低的预防措施。
3.6 函数原型缺失与声明不匹配
不正确声明的函数原型在不同编译器之间行为可能完全不同。原型必须在定义和使用它的所有编译单元之间精确匹配——参数类型、返回类型、变参标识(...)的任何不一致,在不同 ABI 下可能导致栈帧错位、参数传递错误或返回值的解释偏差。在跨架构跨编译器迁移时,确保所有共享头文件中的函数声明与实现一致是防止静默错误的底线。
4. ARM 汇编代码的架构迁移
从早期 ARM 处理器移植汇编代码到 Cortex-A 系列时,最大的风险并不是语法变化,而是部分指令和寄存器的语义已经发生变化。下面列举几类最常见的问题。
4.1 CP15 寄存器并非都能直接沿用
CP15 寄存器可分为两类:
- 架构定义寄存器:如
SCTLR、TTBR,不同 ARMv7-A 处理器的含义基本一致。 - 实现特定寄存器:如
ACTLR(Auxiliary Control Register),不同 Cortex-A 内核的位定义可能完全不同。
因此,移植旧代码时,应重点检查所有访问实现特定寄存器的代码。这些寄存器在不同处理器上的功能可能发生变化,甚至不存在,需要根据目标 CPU 重新确认。
例如,下面的代码用于开启某处理器的预取优化:
1 | MRC p15, 0, r0, c1, c0, 1 ; 读取 ACTLR |
这段代码在另一款 Cortex-A 处理器上,bit[2] 可能表示完全不同的功能,甚至该寄存器不存在,因此不能直接移植。
4.2 用 LDREX/STREX 替代 SWP
SWP 曾用于实现原子交换操作,但在 ARMv7 中已不推荐使用,Cortex-A 系列复位后默认也不会识别该指令(可通过 SCTLR 配置重新启用)。
例如,早期代码常见写法:
1 | SWP r1, r0, [r2] ; 原子交换 |
现代 ARM 推荐改为 LDREX/STREX:
1 | retry: |
LDREX(Load Exclusive)会读取指定地址的数据,并建立一个排他监视器(Exclusive Monitor);随后执行 STREX(Store Exclusive)时,只有当该地址在此期间未被其他 CPU 或总线 Master 修改过,写入才会成功(返回 0),否则写入失败(返回非 0),软件需要重新执行整个过程。
保护同一共享对象的代码也应统一采用 LDREX/STREX,不要与 SWP 混用。
实际开发中,更推荐使用操作系统提供的 spinlock、mutex 等同步原语,或编译器提供的原子操作,而不是直接编写汇编。
4.3 检查旧代码中的内存顺序假设
ARMv7 采用弱内存排序模型,处理器和总线可能对内存访问顺序进行优化。因此,与 DMA、其他 CPU 或外设共享数据时,应检查是否需要插入内存屏障。
例如,CPU 向 DMA 描述符写入数据:
1 | desc->addr = buf; |
如果没有内存屏障,处理器可能先写入 valid,再写入 addr 或 len。DMA 此时读取到 valid == 1,却可能看到尚未更新完成的地址或长度,从而导致数据错误。
因此,在通知 DMA 前通常需要插入 DMB 或 DSB,确保前面的数据写入已经对外可见。
5. ARM 汇编到 Thumb-2 的迁移
将 ARM 32-bit 汇编代码移植到 Thumb-2,通常不是简单地重新汇编即可。由于 Thumb-2 的指令编码和执行模型与 ARM 状态存在差异,一些常见写法需要修改,才能保证代码的正确性和可移植性。
5.1 避免手工计算 PC 相对地址
ARM 汇编中,经常需要获取一个常量或变量的地址。早期代码常采用 PC 相对寻址,例如:
1 | ADD r0, pc, #offset ; 手工计算目标地址 |
这种写法依赖于 PC 的具体取值,而 ARM 与 Thumb 状态下 PC 的行为并不完全一致,因此这类代码移植到 Thumb-2 后容易出错,也不便于维护。
更推荐使用汇编器提供的 LDR Rd, =expr 伪指令:
1 | LDR r0, =value ; 加载常量 value |
这里的 =expr 不是 ARM 指令语法,而是汇编器提供的伪指令。汇编器会根据实际情况自动选择最合适的实现方式:
- 如果
expr可以直接编码,生成MOV等立即数指令; - 如果不能直接编码,则自动建立字面池(Literal Pool),并生成正确的 PC 相对加载指令。
因此,开发者无需关心 ARM 与 Thumb 状态下 PC 的差异,也不用手工计算偏移量。
如果汇编时出现 “literal pool out of range” 错误,说明字面池距离当前代码过远,可使用:
1 | .ltorg |
主动生成新的字面池。
迁移建议: 将所有依赖
ADD PC, #offset等手工计算地址的代码,改为LDR Rd, =expr,既能提高代码的可移植性,也能避免 ARM 与 Thumb 状态下 PC 行为差异带来的问题。
5.2 所有涉及 PC 的跳转应改用 BX/BLX
ARM 状态下,很多代码直接修改 PC 完成跳转:
| ARM 写法 | Thumb-2 推荐写法 |
|---|---|
MOV PC, LR |
BX LR |
MOV PC, r0 |
BX r0 |
MOV LR, PC``MOV PC, r0 |
BLX r0 |
原因是 BX 和 BLX 会根据目标地址 bit[0] 自动切换 ARM/Thumb 状态,而 MOV PC, ... 不具备这种能力,容易导致返回到错误的指令集状态。
例如:
1 | ; ARM 写法 |
需要注意的是,POP {..., PC} 和 LDMFD SP!, {..., PC} 仍然可以正常使用,ARMv7 硬件会自动根据装载到 PC 的地址切换执行状态,因此无需修改。
5.3 Thumb 指令限制更多
为了提高代码密度,Thumb-2 对部分指令的编码进行了压缩,因此某些 ARM 中合法的写法需要调整。
(1)条件执行改为 IT 块
ARM 中,大多数指令都支持条件后缀:
1 | ADDNE r0, r1, r2 |
Thumb 中,除分支外,其他指令需要放在 IT(If-Then)块中:
1 | IT NE |
如果不希望手工维护 IT 指令,可使用汇编器选项:
1 | -mimplicit-it=thumb |
由汇编器自动生成。
(2)立即数和寻址范围更受限制
部分 ARM 可以直接编码的大偏移,在 Thumb 中可能无法表示。
例如:
1 | ; ARM |
可能需要改写为:
1 | ADD r1, r1, #4092 |
(3)部分 ARM 指令没有 Thumb 编码
例如:
1 | RSC |
Thumb-2 不支持,需要改写为其他等价指令(如 RSB + SBC),或保留 ARM 状态执行。
5.4 链接器可能自动插入 Veneer
当分支目标超出指令可编码范围,或需要完成 ARM/Thumb 状态切换时,链接器可能自动生成一段中间跳转代码,称为 Veneer(跳板)。
例如:
1 | BL foo |
大多数情况下,Veneer 完全由链接器处理,程序员无需关心。
但如果热点循环频繁经过 Veneer,每次跳转都会刷新流水线,可能带来一定的性能损失。可通过调整代码布局,减少远距离调用,或使用 ARM 链接器的 --info veneers 选项查看 Veneer 的生成情况并进行优化。
6. 学习要点总结
- 端序相关代码应重点审查。 使用
union、指针强制类型转换(cast)或位域(bitfield)直接访问数据布局时,都可能依赖字节序。跨大小端平台移植时,应逐一确认其行为是否符合预期;需要进行字节交换时,优先使用REV等专用指令。 - 注意不同 ARM 架构对非对齐访问的处理方式。 ARMv4 与 ARMv7 对未对齐数据的访问行为不同,移植旧代码时应检查是否存在依赖旧行为的代码,否则可能产生难以发现的数据错误。
- 不要依赖
char的默认符号属性。char是signed还是unsigned属于实现定义,不同编译器和 ABI 可能不同。涉及数值运算时,应显式使用signed char或unsigned char;读取getchar()、getc()等函数返回值时,应使用int接收,以正确处理EOF。 - ARM/Thumb 混合代码应统一使用
BX/BLX完成跳转。 返回或间接跳转时,不要使用MOV PC, ...,而应使用BX或BLX,确保处理器能够正确切换执行状态。 - 使用现代原子操作替代
SWP。 在 ARMv7 中,SWP已不推荐使用,应采用LDREX/STREX配合必要的内存屏障实现原子操作,并确保同一共享对象采用统一的同步机制。 - 合理使用
__packed__。__packed__可以消除结构体填充,减小存储空间,但可能导致未对齐访问,增加访问开销。是否使用应根据数据传输需求和访问频率综合权衡。 - 关注链接器生成的 Veneer。 Veneer 能自动解决远距离跳转和 ARM/Thumb 状态切换问题,但在性能敏感路径中可能带来额外开销。必要时可借助
--info veneers分析 Veneer 的生成情况,并通过调整代码布局减少其影响。