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] 分别赋给 i0i3。这四段代码在 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 LESETEND 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
2
3
char c = -1;
if (c > 0) printf("c is positive\n");
else printf("c is negative\n");

在 x86 上输出 “c is negative”,在 ARM 编译器上输出 “c is positive”(且伴随 warning)。原因是 ANSI C 标准没有规定 plain char 是 signed 还是 unsigned——这是编译器实现决定的。ARM 的最早版本没有 LDRSB(加载有符号字节并符号扩展),因此早期 ARM 编译器选择 char 默认为 unsigned 以匹配硬件能力。x86 则历来默认为 signed。

更致命的死循环例子:

1
2
3
char c;
while ((c = getchar()) != EOF) // EOF = -1
putchar(c);

在 ARM 上,getchar() 返回 int 类型的 -1(EOF),赋值给 unsigned charc 后变成 255。循环条件 255 != -1 永远为真——死循环。修正方法是将 c 声明为 int:这正是 stdio.h 中函数的定义方式。同样的问题也出现在 getopt()getc() 上——两者定义为返回 int,用 char 接收返回值会引发同样的 EOF 比较失败。

最佳实践:

  1. unsigned char — 用于内存字节访问和小的无符号整数
  2. signed char — 用于小的有符号整数
  3. plain char仅用于 ASCII 字符和字符串

补充性能建议:在实际的 ARM 平台上,即便是小的值也通常优先用 int 而非 char——32-bit 寄存器的算术运算无需额外的符号扩展或截断指令。

3.2 结构体填充与 __packed__ 的代价

编译器不能重排结构体成员,且必须遵循核心的对齐约束。这意味着不同架构对同一 struct 可能插入不同的填充字节:

1
2
3
4
5
6
struct test {
unsigned char c; // 1 byte
unsigned int i; // 4 bytes — 需要 word 对齐
unsigned short s; // 2 bytes
};
// ARM 未压缩布局(默认):c @ 0, padding[3], i @ 4-7, s @ 8-9, padding[2], sizeof=12

使用 __attribute__((__packed__))(GCC)或 __packed(ARM Compiler)移除所有填充后,sizeof(struct test) 降为 7 bytes——但访问 is 变成了非对齐访问,需要额外的指令周期。权衡建议很明确:packed 对 Cortex-A 系列”相对高效”,但仍会损失性能并增加代码体积。用于跨硬件传递的数据结构时是可接受的;用于频繁访问的内部数据结构时需要权衡。

3.3 变参函数与栈假设

使用 <stdarg.h> 宏(va_start/va_arg/va_end)的代码是跨架构可移植的——它们封装了栈帧遍历。标准库的 va_* 宏会处理 ABI 相关的细节:参数是通过寄存器还是栈传递、栈的对齐要求、每个参数的类型提升规则(如 float 自动提升为 double、小整数提升为 int)。这些规则在不同架构和编译器之间完全不同。

但任何直接操作栈指针或用自定义宏遍历栈来获取变参的代码,在不同 ABI 下必然崩溃。典型的危险代码模式是:

1
2
3
4
5
6
// 危险 —— 不可移植的变参访问
void print_ints(int count, ...) {
int *p = &count + 1; // 假设所有参数都在 count 之后的栈上
for (int i = 0; i < count; i++)
printf("%d\n", p[i]); // ARM AAPCS 下前 4 个参数走 R0-R3,栈偏移完全错误
}

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
2
3
4
5
6
7
8
// library.h
typedef enum { RED, GREEN, BLUE } Color;

typedef struct {
Color c1;
Color c2;
Color c3;
} Widget;

如果库按默认方式编译(sizeof(Color) == 4),而应用程序启用了 -fshort-enumssizeof(Color) == 1),双方编译出的 Widget 布局将不同。由于链接器无法检查类型布局,这类问题通常不会在链接阶段报错,而是在运行时表现为成员访问错误或数据损坏。

最佳实践:

  1. 整个工程保持一致的编译选项,避免混用 -fshort-enums
  2. 公共 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
2
uint16_t counter = 65535;
counter++; // 归零为 0,正确(显式 16-bit)
1
2
unsigned int counter = 65535; // 被 16-bit 开发者误认为是 16-bit 量
counter++; // 等于 65536,不归零——算法逻辑完全错误

解决方案是始终使用 <stdint.h> 中的精确宽度类型uint16_tint32_t 等)。不要假设 intlong 的位宽——这些类型在不同架构上的实际宽度不同,且都符合 C 标准的合法实现。移植遗留嵌入式代码时,第一步应该将所有非固定宽度的整数声明替换为 stdint.h 类型,这是性能代价最低的预防措施。

3.6 函数原型缺失与声明不匹配

不正确声明的函数原型在不同编译器之间行为可能完全不同。原型必须在定义和使用它的所有编译单元之间精确匹配——参数类型、返回类型、变参标识(...)的任何不一致,在不同 ABI 下可能导致栈帧错位、参数传递错误或返回值的解释偏差。在跨架构跨编译器迁移时,确保所有共享头文件中的函数声明与实现一致是防止静默错误的底线。


4. ARM 汇编代码的架构迁移

从早期 ARM 处理器移植汇编代码到 Cortex-A 系列时,最大的风险并不是语法变化,而是部分指令和寄存器的语义已经发生变化。下面列举几类最常见的问题。

4.1 CP15 寄存器并非都能直接沿用

CP15 寄存器可分为两类:

  • 架构定义寄存器:如 SCTLRTTBR,不同 ARMv7-A 处理器的含义基本一致。
  • 实现特定寄存器:如 ACTLR(Auxiliary Control Register),不同 Cortex-A 内核的位定义可能完全不同。

因此,移植旧代码时,应重点检查所有访问实现特定寄存器的代码。这些寄存器在不同处理器上的功能可能发生变化,甚至不存在,需要根据目标 CPU 重新确认。

例如,下面的代码用于开启某处理器的预取优化:

1
2
3
MRC p15, 0, r0, c1, c0, 1    ; 读取 ACTLR
ORR r0, r0, #(1 << 2) ; 设置某个优化位
MCR p15, 0, r0, c1, c0, 1

这段代码在另一款 Cortex-A 处理器上,bit[2] 可能表示完全不同的功能,甚至该寄存器不存在,因此不能直接移植。


4.2 用 LDREX/STREX 替代 SWP

SWP 曾用于实现原子交换操作,但在 ARMv7 中已不推荐使用,Cortex-A 系列复位后默认也不会识别该指令(可通过 SCTLR 配置重新启用)。

例如,早期代码常见写法:

1
SWP r1, r0, [r2]     ; 原子交换

现代 ARM 推荐改为 LDREX/STREX

1
2
3
4
5
6
7
8
retry:
LDREX r1, [r2] ; 从 [r2] 读取数据到 r1,并建立 Exclusive Monitor
; 修改 r1 ...
STREX r3, r1, [r2] ; 尝试将 r1 写回 [r2]
; 成功:r3 = 0;失败:r3 = 1
CMP r3, #0 ; 判断写回是否成功
BNE retry ; 若失败,重新读取并重试
DMB ; 数据内存屏障,确保写入顺序对其他 CPU/设备可见

LDREXLoad Exclusive)会读取指定地址的数据,并建立一个排他监视器(Exclusive Monitor);随后执行 STREXStore Exclusive)时,只有当该地址在此期间未被其他 CPU 或总线 Master 修改过,写入才会成功(返回 0),否则写入失败(返回非 0),软件需要重新执行整个过程。

保护同一共享对象的代码也应统一采用 LDREX/STREX,不要与 SWP 混用。

实际开发中,更推荐使用操作系统提供的 spinlock、mutex 等同步原语,或编译器提供的原子操作,而不是直接编写汇编。


4.3 检查旧代码中的内存顺序假设

ARMv7 采用弱内存排序模型,处理器和总线可能对内存访问顺序进行优化。因此,与 DMA、其他 CPU 或外设共享数据时,应检查是否需要插入内存屏障。

例如,CPU 向 DMA 描述符写入数据:

1
2
3
desc->addr = buf;
desc->len = len;
desc->valid = 1; // 通知 DMA 可以读取

如果没有内存屏障,处理器可能先写入 valid,再写入 addrlen。DMA 此时读取到 valid == 1,却可能看到尚未更新完成的地址或长度,从而导致数据错误。

因此,在通知 DMA 前通常需要插入 DMBDSB,确保前面的数据写入已经对外可见。


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
2
LDR r0, =value         ; 加载常量 value
LDR r1, =buffer ; 加载变量 buffer 的地址

这里的 =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

原因是 BXBLX 会根据目标地址 bit[0] 自动切换 ARM/Thumb 状态,而 MOV PC, ... 不具备这种能力,容易导致返回到错误的指令集状态。

例如:

1
2
3
4
5
; ARM 写法
MOV PC, LR

; Thumb-2 推荐
BX LR

需要注意的是,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
2
IT   NE
ADDNE r0, r1, r2

如果不希望手工维护 IT 指令,可使用汇编器选项:

1
-mimplicit-it=thumb

由汇编器自动生成。

(2)立即数和寻址范围更受限制

部分 ARM 可以直接编码的大偏移,在 Thumb 中可能无法表示。

例如:

1
2
; ARM
STR r0, [r1, #4092]

可能需要改写为:

1
2
ADD r1, r1, #4092
STR r0, [r1]

(3)部分 ARM 指令没有 Thumb 编码

例如:

1
RSC

Thumb-2 不支持,需要改写为其他等价指令(如 RSB + SBC),或保留 ARM 状态执行。


5.4 链接器可能自动插入 Veneer

当分支目标超出指令可编码范围,或需要完成 ARM/Thumb 状态切换时,链接器可能自动生成一段中间跳转代码,称为 Veneer(跳板)

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
BL foo


(距离过远)


BL veneer


veneer:
LDR PC, ...


foo

大多数情况下,Veneer 完全由链接器处理,程序员无需关心。

但如果热点循环频繁经过 Veneer,每次跳转都会刷新流水线,可能带来一定的性能损失。可通过调整代码布局,减少远距离调用,或使用 ARM 链接器的 --info veneers 选项查看 Veneer 的生成情况并进行优化。

6. 学习要点总结

  1. 端序相关代码应重点审查。 使用 union、指针强制类型转换(cast)或位域(bitfield)直接访问数据布局时,都可能依赖字节序。跨大小端平台移植时,应逐一确认其行为是否符合预期;需要进行字节交换时,优先使用 REV 等专用指令。
  2. 注意不同 ARM 架构对非对齐访问的处理方式。 ARMv4 与 ARMv7 对未对齐数据的访问行为不同,移植旧代码时应检查是否存在依赖旧行为的代码,否则可能产生难以发现的数据错误。
  3. 不要依赖 char 的默认符号属性。 charsigned 还是 unsigned 属于实现定义,不同编译器和 ABI 可能不同。涉及数值运算时,应显式使用 signed charunsigned char;读取 getchar()getc() 等函数返回值时,应使用 int 接收,以正确处理 EOF
  4. ARM/Thumb 混合代码应统一使用 BX/BLX 完成跳转。 返回或间接跳转时,不要使用 MOV PC, ...,而应使用 BXBLX,确保处理器能够正确切换执行状态。
  5. 使用现代原子操作替代 SWP 在 ARMv7 中,SWP 已不推荐使用,应采用 LDREX/STREX 配合必要的内存屏障实现原子操作,并确保同一共享对象采用统一的同步机制。
  6. 合理使用 __packed__ __packed__ 可以消除结构体填充,减小存储空间,但可能导致未对齐访问,增加访问开销。是否使用应根据数据传输需求和访问频率综合权衡。
  7. 关注链接器生成的 Veneer。 Veneer 能自动解决远距离跳转和 ARM/Thumb 状态切换问题,但在性能敏感路径中可能带来额外开销。必要时可借助 --info veneers 分析 Veneer 的生成情况,并通过调整代码布局减少其影响。