介绍
APCS,ARM 过程调用标准(ARM Procedure Call Standard),提供了紧凑的编写例程的一种机制,定义的例程能够与其它例程交织在一起。最显著的一点是对这些例程来自哪里没有明白的限制。它们能够编译自 C、 Pascal、也能够是用汇编语言写成的。
APCS 定义了:
- 对寄存器使用的限制。
- 使用栈的惯例。
- 在函数调用之间传递/返回參数。
- 能够被‘回溯’的基于栈的结构的格式,用来提供从失败点到程序入口的函数(和给予的參数)的列表。
APCS 不一个单一的给定标准,而是一系列类似但在特定条件下有所差别的标准。比如,APCS-R (用于 RISC OS)规定在函数进入时设置的标志必须在函数退出时复位。在 32 位标准下,并非总能知道进入标志的(没有 USR_CPSR),所以你不须要恢复它们。如你所预料的那样,在不同版本号间没有相容性。希望恢复标志的代码在它们未被恢复的时候可能会表现失常...
假设你开发一个基于 ARM 的系统,不要求你去实现 APCS。但建议你实现它,由于它不难实现,且能够使你获得各种利益。可是,假设要写用来与编译后的 C 连接的汇编代码(C与汇编混着编写),则必须使用 APCS。编译器期望特定的条件,在你的增加(add-in)代码中必须得到满足。一个好样例是 APCS 定义 a1 到 a4 能够被破坏,而 v1 到 v6 必须被保护。如今我确信你正在挠头并自言自语“a 是什么? v 是什么?”。所以首先介绍 APCS-R 寄存器定义...
寄存器命名
APCS 对我们通常称为 R0 到 R14 的寄存器起了不同的名字。使用汇编器预处理器的功能,你能够定义 R0 等名字,但在你改动其它人写的代码的时候,不妨学习使用 APCS 名字。
寄存器名字 | ||
---|---|---|
Reg# | APCS | 意义 |
R0 | a1 | 工作寄存器 |
R1 | a2 | " |
R2 | a3 | " |
R3 | a4 | " |
R4 | v1 | 必须保护 |
R5 | v2 | " |
R6 | v3 | " |
R7 | v4 | " |
R8 | v5 | " |
R9 | v6 | " |
R10 | sl | 栈限制 |
R11 | fp | 桢指针 |
R12 | ip | 内部过程调用寄存器 |
R13 | sp | 栈指针 |
R14 | lr | 连接寄存器 |
R15 | pc | 程序计数器 |
译注:ip 是指令指针的简写。
这些名字不是由标准的 Acorn 的 objasm(版本号 2.00)所定义的,可是 objasm 的后来版本号,和其它汇编器(比方 Nick Robert 的 ASM)定义了它们。要定义一个寄存器名字,典型的,你要在程序最開始的地方使用 RN
宏指令(directive):
a1 RN 0 a2 RN 1 a3 RN 2 ...等... r13 RN 13 sp RN 13 r14 RN 14 lr RN r14 pc RN 15
这个样例展示了一些重要的东西:
- 寄存器能够定义多个名字 - 你能够定义‘r13’和‘sp’二者。
- 寄存器能够定义自前面定义的寄存器 - ‘lr’定义自叫做‘r14’的寄存器。
(对于 objasm 是正确的,其它汇编器可能不是这样)
设计关键
- 函数调用应当快、小、和易于(由编译器来)优化。
- 函数应当能够妥善处理多个栈。
- 函数应当易于写可重入和可重定位的代码;主要通过把可写的数据与代码分离来实现。
- 可是最重要的是,它应当简单。这样汇编编程者能够很easy的使用它的设施,而调试者能够很easy的跟踪程序。
一致性
程序的遵循 APCS 的部分在调用外部函数时被称为“一致”。在程序运行期间的全部时候都遵循 APCS (典型的,由编译器生成的程序)被称为“严格一致”。协议指出,假如你遵守正确的进入和退出參数,你能够在你自己的函数范围内做你须要的不论什么事情,而仍然保持一致。这在有些时候是必须的,比方在写 SWI 伪装(veneers)的时候使用了很多给实际的 SWI 调用的寄存器。
栈
栈是链接起来的‘桢’的一个列表,通过一个叫做‘回溯结构’的东西来链接它们。这个结构存储在每一个桢的高端。按递减地址次序分配栈的每一块。寄存器 sp
总是指向在最当前桢中最低的使用的地址。这符合传统上的满降序栈。在 APCS-R 中,寄存器 sl
持有一个栈限制,你递减 sp
不能低于它。在当前栈指针和当前栈之间,不应该有不论什么其它 APCS 函数所依赖的东西,在被调用的时候,函数能够为自己设置一个栈块。
能够有多个栈区(chunk)。它们能够位于内存中的不论什么地址,这里没有提供规范。典型的,在可重入方式下运行的时候,这将被用于为同样的代码提供多个栈;一个类比是 FileCore,它通过简单的设置‘状态’信息和并按要求调用同样部分的代码,来向当前可获得的 FileCore 文件系统(ADFS、RAMFS、IDEFS、SCSIFS 等)提供服务。
回溯结构
寄存器 fp
(桢指针)应当是零或者是指向栈回溯结构的列表中的最后一个结构,提供了一种追溯程序的方式,来反向跟踪调用的函数。
回溯结构是:
地址高端 保存代码指针 [fp] fp 指向这里 返回 lr 值 [fp, #-4] 返回 sp 值 [fp, #-8] 返回 fp 值 [fp, #-12] 指向下一个结构 [保存的 sl] [保存的 v6] [保存的 v5] [保存的 v4] [保存的 v3] [保存的 v2] [保存的 v1] [保存的 a4] [保存的 a3] [保存的 a2] [保存的 a1] [保存的 f7] 三个字 [保存的 f6] 三个字 [保存的 f5] 三个字 [保存的 f4] 三个字 地址低端
这个结构包括 4 至 27 个字,在方括号里的是可选的值。假设它们存在,则必须按给定的次序存在(比如,在内存中保存的 a3 以下能够是保存的 f4,但 a2-f5 则不能存在)。浮点值按‘内部格式’存储并占用三个字(12 字节)。
fp 寄存器指向当前运行的函数的栈回溯结构。返回 fp 值应当是零,或者是指向由调用了这个当前函数的函数建立的栈回溯结构的一个指针。而这个结构中的返回 fp 值是指向调用了调用了这个当前函数的函数的函数的栈回溯结构的一个指针;并以此类推直到第一个函数。
在函数退出的时候,把返回连接值、返回 sp 值、和返回 fp 值装载到 pc、sp、和 fp 中。
#include <stdio.h> void one(void); void two(void); void zero(void); int main(void) { one(); return 0; } void one(void) { zero(); two(); return; } void two(void) { printf("main...one...two\n"); return; } void zero(void) { return; } 当它在屏幕上输出消息的时候, APCS 回溯结构将是: fp ----> two_structure return link return sp return fp ----> one_structure ... return link return sp return fp ----> main_structure ... return link return sp return fp ----> 0 ...
所以,我们能够检查 fp 并參看给函数‘two’的结构,它指向给函数‘one’的结构,它指向给‘main’的结构,它指向零来终结。在这样的方式下,我们能够反向追溯整个程序并确定我们是怎样到达当前的崩溃点的。值得指出‘zero’函数,由于它已经被运行并退出了,此时我们正在做它后面的打印,所以它以前在回溯结构中,但如今不在了。值得指出的还有对于给定代码不太可能总是生成象上面那样的一个 APCS 结构。原因是不调用不论什么其它函数的函数不要求全然的 APCS 头部。
为了更仔细的理解,以下是代码是 Norcroft C v4.00 为上述代码生成的...
AREA |C$$code|, CODE, READONLY IMPORT |__main| |x$codeseg| B |__main| DCB &6d,&61,&69,&6e DCB &00,&00,&00,&00 DCD &ff000008 IMPORT |x$stack_overflow| EXPORT one EXPORT main main MOV ip, sp STMFD sp!, {fp,ip,lr,pc} SUB fp, ip, #4 CMPS sp, sl BLLT |x$stack_overflow| BL one MOV a1, #0 LDMEA fp, {fp,sp,pc}^ DCB &6f,&6e,&65,&00 DCD &ff000004 EXPORT zero EXPORT two one MOV ip, sp STMFD sp!, {fp,ip,lr,pc} SUB fp, ip, #4 CMPS sp, sl BLLT |x$stack_overflow| BL zero LDMEA fp, {fp,sp,lr} B two IMPORT |_printf| two ADD a1, pc, #L000060-.-8 B |_printf| L000060 DCB &6d,&61,&69,&6e DCB &2e,&2e,&2e,&6f DCB &6e,&65,&2e,&2e DCB &2e,&74,&77,&6f DCB &0a,&00,&00,&00 zero MOVS pc, lr AREA |C$$data| |x$dataseg| END
这个样例不遵从 32 为体系。APCS-32 规定仅仅是简单的说明了标志不须要被保存。所以删除 LDM 的‘^’后缀,并在函数 zero 中删除 MOVS 的‘S’后缀。则代码就与遵从 32-bit 的编译器生成的一样了。
保存代码指针包括这条设置回溯结构的指令(STMFD ...)的地址再加上 12 字节。记住,对于 26-bit 代码,你须要去除当中的 PSR 来得到实际的代码地址。
如今我们查看刚进入函数的时候:
- pc总是包括下一个要被运行的指令的位置。
- lr (总是)包括着退出时要装载到 pc中的值。在 26-bit 位代码中它还包括着 PSR。
- sp指向当前的栈块(chunk)限制,或它的上面。这是用于复制暂时数据、寄存器和类似的东西到当中的地方。在 RISC OS 下,你有可选择的至少 256 字节来扩展它。
- fp要么是零,要么指向回溯结构的最当前的部分。
- 函数实參布置成(以下)描写叙述的那样。
实际參数
APCS 未定义记录、数组、和类似的格局。这样语言能够自由的定义怎样进行这些活动。可是,假设你自己的实现实际上不符合 APCS 的精神,那么将不同意来自你的编译器的代码与来自其它编译器的代码连接在一起。典型的,使用 C 语言的惯例。
- 前 4 个整数实參(或者更少!)被装载到 a1 - a4。
- 前 4 个浮点实參(或者更少!)被装载到 f0 - f3。
- 其它不论什么实參(假设有的话)存储在内存中,用进入函数时紧接在 sp 的值上面的字来指向。换句话说,其余的參数被压入栈顶。所以要想简单。最好定义接受 4 个或更少的參数的函数。
函数退出
通过把返回连接值传送到程序计数器中来退出函数,而且:
- 假设函数返回一个小于等于一个字大小的值,则把这个值放置到 a1 中。
- 假设函数返回一个浮点值,则把它放入 f0 中。
- sp、fp、sl、v1-v6、和 f4-f7 应当被恢复(假设被修改了)为包括在进入函数时它所持有的值。
我測试了有益的破坏寄存器,而结果是(常常在程序全然不同的部分)出现不希望的和神秘的故障。 - ip、lr、a2-a4、f1-f3 和入栈的这些实參能够被破坏。
在 32 位模式下,不须要对 PSR 标志进行跨越函数调用的保护。在 26 位模式下必须这样,并通过传送 lr 到 pc 中(MOVS、或 LDMFD xxx^)来暗中恢复。必须从 lr 又一次装载 N、Z、C 和 V,跨越函数保护这些标志不是足够的。
建立栈回溯结构
对于一个简单函数(固定个数的參数,不可重入),你能够用下列指令建立一个栈回溯结构:
function_name_label MOV ip, sp STMFD sp!, {fp,ip,lr,pc} SUB fp, ip, #4
这个片段(来自上述编译后的程序)是最主要的形式。假设你要破坏其它不可破坏的寄存器,则你应该在这个 STMFD 指令中包括它们。
下一个任务是检查栈空间。假设不须要非常多空间(小于 256 字节)则你能够使用:
CMPS sp, sl BLLT |x$stack_overflow| 这是 C 版本号 4.00 处理溢出的方式。在以后的版本号中,你要调用 |__rt_stkovf_split_small|。
接着做你自己的事情...
通过以下的指令完毕退出:
LDMEA fp, {fp,sp,pc}^
还有,假设你入栈了其它寄存器,则也在这里又一次装载它们。选择这个简单的 LDM 退出机制的原因是它比分支到一个特殊的函数退出处理器(handler)更easy和更合理。
用在回溯中的对这个协议的一个扩展是把函数名字嵌入到代码中。紧靠在函数(和 MOV ip, sp
)的前面的应该是:
DCD &ff0000xx
这里的‘xx’是函数名字符串的长度(包含填充和终结符)。这个字符串是字对齐、尾部填充的,而且应当被直接放置在 DCD &ff....的前面。
所以一个完整的栈回溯代码应当是:
DCB "my_function_name", 0, 0, 0, 0 DCD &ff000010 my_function_name MOV ip, sp STMFD sp!, {fp, ip, lr, pc} SUB fp, ip, #4 CMPS sp, sl ; 假设你不使用栈 BLLT |x$stack_overflow| ; 则能够省略 ...处理... LDMEA fp, {fp, sp, pc}^
要使它遵从 32-bit 体系,仅仅须简单的省略最后一个指令的‘^’。注意你不能在一个编译的 26-bit 代码中使用这个代码。实际上,你能够去除它,但这不是我愿意打赌的事情。
假设你不使用栈,而且你不须要保存不论什么寄存器,而且你不调用不论什么东西,则没有必要设置 APCS 块(但在调试阶段对跟踪问题仍是实用的)。在这样的情况下你能够:
my_simple_function ...处理... MOVS pc, lr
(再次,对 32 位 APCS 使用 MOV 而不是 MOVS,可是不要冒险与 26 位代码连接)。
APCS 标准
总的来说,有多个版本号的 APCS (实际上是 16 个)。我们仅仅关心在 RISC OS 上可能遇到的。
APCS-A
就是 APCS-Arthur;由早期的 Arthur 所定义。它已经被废弃,原因是它有不同的寄存器定义(对于熟练的 RISC OS 程序猿它是某种异类)。它用于在 USR 模式下执行的 Arthur 应用程序。不应该使用它。
sl = R13, fp = R10, ip = R11, sp = R12, lr = R14, pc = R15。
- PRM (p4-411) 中说“用
r12
作为sp
,而不是在体系上更自然的r13
,是历史性的并先于 Arthur 和 RISC OS 二者。” - 栈是分段的并可按须要来扩展。
- 26-bit 程序计数器。
- 不在 FP 寄存器中传递浮点实參。
- 不可重入。标志必须被恢复。
APCS-R
就是 APCS-RISC OS。用于 RISC OS 应用程序在 USR 模式下进行操作;或在 SVC 模式下的模块/处理程序。
sl = R10, fp = R11, ip = R12, sp = R13, lr = R14, pc = R15。
- 它是唯一的最通用的 APCS 版本号。由于全部编译的 C 程序都使用 APCS-R。
- 显式的栈限制检查。
- 26-bit 程序计数器。
- 不在 FP 寄存器中传递浮点实參。
- 不可重入。标志必须被恢复。
APCS-U
就是 APCS-Unix,Acorn 的 RISCiX 使用它。它用于 RISCiX 应用程序(USR 模式)或内核(SVC 模式)。
sl = R10, fp = R11, ip = R12, sp = R13, lr = R14, pc = R15。
- 隐式的栈限制检查(使用 sl)。
- 26-bit 程序计数器。
- 不在 FP 寄存器中传递浮点实參。
- 不可重入。标志必须被恢复。
APCS-32
它是 APCS-2(-R 和 -U)的一个扩展,同意 32-bit 程序计数器,而且从运行在 USR 模式下的一个函数中退出时,同意标志不被恢复。其它事情同于 APCS-R。
Acorn C 版本号 5 支持生成 32-bit 代码;在用于广域调试的 32 位工具中,它是最完整的开发发行。一个简单的測试是要求你的编译器导出汇编源代码(而不是制作目标代码)。你不应该找到:MOVS PC, R14
或者LDMFD R13!, {Rx-x, PC}^
对编码实用的东西
首先要考虑的是该死的 26/32 位问题。 简单的说,不转弯抹角绝对没有方法为两个版本号的 APCS 汇编同一个通用代码。可是幸运的这不是问题。APCS 标准不会突然改变。RISC OS 的 32 位版本号也不会立马变异。所以利用这些,我们能够设计一种支持两种版本号的方案。这将远远超出 APCS,对于 RISC OS 的 32 位版本号你须要使用 MSR 来处理状态和模式位,而不是使用 TEQP。很多现存的 API 实际上不须要保护标志位。所以在我们的 32 版本号中能够通过把 MOVS PC,...
变成 MOV PC,...
,和把 LDM {...}^
变成 LDM {...}
,并又一次建造来解决。objasm 汇编器(v3.00 和以后)有一个 {CONFIG}
变量能够是 26
或 32
。能够使用它建造宏...
my_function_name MOV ip, sp STMFD sp!, {fp, ip, lr, pc} SUB fp, ip, #4 ...处理... [ {CONFIG} = 26 LDMEA fp, {fp, sp, pc}^ | LDMEA fp, {fp, sp, pc} ]
我未測试这个代码。它(或类似的东西)好象是保持与两个版本号的 APCS 相兼容的最佳方式,也是对 RISC OS 的不同版本号,26 位版本号和将来的 32 位版本号的最佳方法。
測试是否处于 32 位? 假设你要求你的代码有适应性,有一个最简单的方法来确定处理器的 PC 状态:
TEQ PC, PC ; 对于 32 位是 EQ;对于 26 位是 NE
使用它你能够确定:
- 26 位 PC,可能是 APCS-R 或 APCS-32。
- 32 位 PC,不能 APCS-R。全部 26-bit 代码(TEQP 等)面临着失败!