刚学习编程时,是从C语言的HelloWorld开始的,那么此处就也从HelloWorld开始分析吧,其中引出了C语言中的调用约定的学习。

还是那个Hello World

使用C编写一个输出HelloWorld的简单程序:

1
2
3
4
5
#include<stdio.h>

int main() {
printf("Hello, World!\n");
}

将其打包为一个可执行程序,并使用IDA打开:

image-20241206133107979

可以看到IDA将程序中的机器码转换为了汇编语言的形式。

在IDA显示的内容中,发现除了接触过的汇编语言以外,还存在:

1
; int __fastcall main(int argc, const char **argv, const char **envp)

递归学习一下

C语言的调用约定(Calling Convention)

在C语言中,有不同的调用约定,用来定义函数如何调用、参数如何传递以及栈的清理方式。它们的主要区别如下:

__cdecl(C Declaration)

特点:

  1. C的默认约定。
  2. 参数从右到左依次压入栈。
  3. 返回值通常存储在寄存器(x86:EAX;ARM64: X0)。
  4. 支持可变参数(如printf,参数数量不固定的函数)。
  5. 调用者负责清理栈(由调用函数的一方(Caller)负责将函数参数在栈上的空间清理掉,而不是由被调用函数(Callee)完成)。

__stdcall(Standard Call)

特点:

  1. 参数由右到左依次压入栈。
  2. 被调用者负责清理栈(Callee负责清空栈上的参数)。
  3. 常用于Windows API函数。
  4. 不支持可变参数(函数参数需固定)。

__fastcall(Fast Call)

特点:

  1. 尽量使用寄存器传递函数参数,未用完的参数通过栈传递(前几个参数使用寄存器传递,剩余参数从右到左压栈)。
  2. 被调用者负责清理栈(Callee负责清空栈上的参数)。
  3. 适用于性能敏感的场景,因为寄存器比内存(栈)访问速度快。
  4. 在x86平台中,ECX和EDX寄存器用于传递前两个参数,其余参数依旧压栈;在ARM64架构下,通常前几个参数使用寄存器(如X0、X1等),后续参数用栈传递。

压栈顺序示例:

1
2
int sum(int a, int b);
sum(3, 4);

从右到左堆栈调用流程:

  1. b 压栈;
  2. a 压栈;
  3. 函数返回后,调用者/被调用者清理栈。

从左到右堆栈调用流程:

  1. a 压栈;
  2. b 压栈;
  3. 函数返回后,调用者/被调用者清理栈。

总结

调用约定 参数传递方式 栈清理责任 支持变长参数 用途
__cdecl 参数从右到左压栈 调用者 支持 适合跨平台通用性强的C语言代码
__stdcall 参数从右到左压栈 被调用者 不支持 用于特定平台(Windows)上的调用规范
__fastcall 参数通过寄存器+栈传递 被调用者 不支持 优化性能,适合性能敏感的场景

回到HelloWorld

基础知识

在ARM64中,函数调用涉及两大关键组件:

  1. 寄存器:用来存储数据、传递参数或保存返回值。

​ •X0~X30:通用寄存器,主要用来存储整数、指针、返回值等。

​ •SP:栈指针,指向当前栈的顶端。

​ •X29:帧指针,标记栈帧的基址,便于访问局部变量和返回地址。

​ •X30:链接寄存器,用来保存函数调用的返回地址。

  1. :用于存储函数的局部变量和寄存器的备份。栈的操作遵循 “向下增长”(ARM64栈的地址递减)。

程序分析

以下代码展示了一个简单的函数调用过程,其中 main 函数负责调用 printf 输出 Hello, World!,并返回 0。

首先从寄存器特征和起始的AREA __test, CODE可以看出来,我的主机是arm架构的,ORG 0x100003F6C表示的是指定段的起始位置。

IDA反编译出来的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
; Segment type: Pure code
AREA __text, CODE
; ORG 0x100003F6C
CODE64


; Attributes: bp-based frame

; int __fastcall main(int argc, const char **argv, const char **envp)
EXPORT _main
_main

var_s0= 0
var_s8= 8

STP X29, X30, [SP,#-0x10+var_s0]!
MOV X29, SP
ADRL X0, aHelloWorld ; "Hello, World!\n"
BL _printf
MOV W0, #0
LDP X29, X30, [SP+var_s0],#0x10
RET
; End of function _main

; __text ends

函数声明

1
; int __fastcall main(int argc, const char **argv, const char **envp)

此处表明main函数使用了__fastcall调用约定。

内容分解

1.栈帧的创建

1
STP             X29, X30, [SP,#-0x10+var_s0]!

分解说明

(1)STP 指令:同时存储两个寄存器的值到内存。

(2)将 X29(帧指针)和 X30(链接寄存器,保存返回地址)压入栈中。

(3)栈指针 SP 递减 0x10(16字节)以分配新的栈空间,同时更新 SP 的值。

作用

(1)保护当前函数的调用环境,避免寄存器被覆盖。

(2)创建栈帧,用于存储局部变量和保存上下文。

示意图(执行后栈的变化):

1
2
3
4
5
低地址 ┌───────────┐
│ X30 │ <- 保存的返回地址
├───────────┤
│ X29 │ <- 保存的帧指针
高地址 └───────────┘ <- SP(栈指针)
1
MOV             X29, SP

分解说明

(1)MOV 指令:将一个值复制到寄存器。

(2)将栈指针 SP 的值复制到帧指针 X29。

作用:更新帧指针 X29,使其指向当前函数的栈帧。现在 X29 指向当前栈帧的顶部,方便访问局部变量和返回地址。

2. 函数调用

1
ADRL            X0, aHelloWorld ; "Hello, World!\n"

分解说明

(1)ADRL 指令:加载一个全局变量的地址。

(2)将字符串 “Hello, World!\n” 的地址加载到寄存器 X0 中。

作用:ARM64调用约定规定,第一个参数通过 X0 寄存器传递。此时,printf 函数将接收 X0 作为它的第一个参数。

1
BL              _printf

分解说明

(1)BL 指令:跳转到 _printf 的地址,并保存当前返回地址到 X30。

(2)执行 _printf 函数,输出字符串。

作用:将控制权交给 printf 函数,同时保存返回地址以便后续恢复。

过程:printf 会根据寄存器的值(X0),找到 “Hello, World!\n” 并打印。

3. 返回值处理

1
MOV             W0, #0

分解说明

(1)MOV 指令:将立即数 0 加载到 W0。

(2)ARM64调用约定规定,整数返回值存储在 X0(或低32位的 W0)。

作用:准备返回值 0,表示程序成功执行。

4. 栈帧的销毁与返回

1
LDP             X29, X30, [SP+var_s0],#0x10

分解说明

(1)LDP 指令:从栈中加载两个寄存器的值。

(2)恢复 X29(帧指针)和 X30(返回地址)。

(3)同时释放栈空间,更新 SP。

1
RET

分解说明:从 X30 中取出返回地址,并跳转到该地址继续执行。

作用:恢复调用者环境,返回到调用 main 的位置。

[!NOTE]

  1. 在ARM64平台中函数的前几个参数通过寄存器(X0~X7)传递,之后的再通过栈传递,所以此处printf函数的第一个参数也就是X0(此时加载字符串”Hello…”的地址)。
  2. 在ARM64平台中,当执行跳转指令(如BL、BLR)时,CPU会自动将跳转后应返回的位置(即当前指令的下一条指令地址)保存到X30。
  3. 在ARM64平台的调用约定中,函数的返回值为整数时默认存储在X0或W0寄存器(取决于返回值是64位(X0)还是32位(W0)),返回值为浮点类型时存储在V0(浮点寄存器),如果返回值体积较大,超出了寄存器的存储能力时,会通过栈传递。
  4. 当全部内容执行完毕后,使用RET指令,从X30中取出返回地址,恢复到调用main的位置继续执行。
  5. 开头为什么偏移 -0x10?ARM64 的栈通常以16字节对齐,因此这里分配16字节(0x10),确保对齐的同时提供足够的空间存储两个64位寄存器(X29 和 X30 每个占8字节)。

总结

自从在大学上完汇编的课之后,好像确实没有什么实际的使用和研究,虽然学到了汇编的基本语法和指令集,也大致理解了计算机如何与硬件互动,但随着学业的推进,更多的课程开始专注于高级语言的应用,汇编似乎逐渐被遗忘了。回想起来,曾经有过一段时间觉得这些底层知识不太重要,可能是由于一直在倾向web方面内容的学习,没有太过于注重底层的原理,重新学习后发现,汇编不仅是工具,更是一种深入理解计算机运行的思维方式。

确实这次会感觉重新学习这些知识比较难,不仅是回忆之前学过的,也有一些课程没有接触过的东西,虽然只是过了个最简单的HelloWorld的程序,但那种重新理解底层运作的感觉还是挺震撼的。