根据ARM过程调用标准,程序的执行总是从main函数开始返回值通常保存在R0中,若返回值为64位的,用来保存返回值的是什么?

C/C++ 函数调用方式与栈原理是 C/C++ 开发必须要掌握的基础知识,也是高级技术岗位面试中高频题。我真的真的真的建议无论是使用 C/C++ 的学生还是广大 C/C++ 开发者,都该掌握此回答中所介绍的知识。

如果你看不懂接下来第二部分在说什么,但是仍然希望有朝一日能掌握第二部分的内容,可以直接跳到第三部分《从哪里可以系统地学习到这些知识?》。

二、你一定要搞明白的 C/C++ 函数调用方式与栈原理

这篇回答试图讲明当一个 C/C++ 函数被调用时,一个栈帧(stack frame)是如何被建立,又是如何被销毁的。

这些细节跟操作系统平台及编译器的实现有关,下面的描述是针对运行在 Intel 奔腾芯片上 Linux 的 gcc 编译器而言。C/C++ 标准并没有描述实现的方式,所以,不同的编译器、处理器、操作系统都可能有自己的建立栈帧的方式。

2.1 一个典型的栈帧

图 1 是一个典型的栈帧,图中,栈顶在上,地址空间往下增长。 这是如下一个函数调用时的栈的内容:

并且,foo 有两个局部的 int 变量(4 个字节)。

在这个简化的场景中,main 调用 foo,而程序的控制仍在 foo 中。这里,main 是调用者(caller),foo 是被调用者(callee)。

ESP 寄存器被 foo 使用来指示栈顶,EBP 寄存器存储“基准指针”

从 main 传递到 foo 的参数以及 foo 本身的局部变量都可以以基准指针 EBP 为参考,加上偏移量找到。由于被调用者允许使用 EAX、ECX 和 EDX 寄存器,所以如果调用者希望保存这些寄存器的值,就必须在调用子函数之前显式地把它们保存在栈中。

另一方面,如果除了上面提到的几个寄存器,被调用者还想使用别的寄存器,比如 EBX、ESI 和 EDI,那么,被调用者就必须在栈中保存这些被额外使用的寄存器,并在调用返回前恢复它们。也就是说,如果被调用者只使用约定的 EAX、ECX 和 EDX 寄存器,它们由调用者负责保存并恢复,但如果被调用这还额外使用了别的寄存器,则必须由它们自己保存并回复这些寄存器的值。

传递给 foo 的参数被压到栈中,最后一个参数先进栈,所以第一个参数是位于栈顶的。

foo 中声明的局部变量以及函数执行过程中需要用到的一些临时变量也都存在栈中。 小于等于 4 个字节的返回值会被保存到 EAX 中,如果大于 4 字节,小于 8 字节,那么 EDX 也会被用来保存返回值。如果返回值占用的空间还要大,那么调用者会向被调用者传递一个额外的参数,这个额外的参数指向将要保存返回值的地址。用 C/C++ 语言来描述这个过程就是函数调用:

由于 x 的内容占用的空间过大被转化为:

注意,这仅仅在返回值占用大于 8 个字节时才发生。有的编译器不用 EDX 保存返回值,所以当返回值大于 4 个字节时,就用这种转换。 当然,并不是所有函数调用都直接赋值给一个变量,还可能是直接参与到某个表达式的计算中,例如:

或者作为另外一个函数的参数, 例如:

这些情况下,foo 的返回值会被保存在一个临时变量中参加后续的运算。

让我们一步步地看一下在 C/C++ 函数调用过程中,一个栈帧是如何建立及销毁的。

2.2 函数调用前调用者的动作

在我们的例子中,调用者是 main,它准备调用函数 foo。在函数调用前,main 正在用 ESP 和 EBP 寄存器指示它自己的栈帧。

首先,main 把 EAX、ECX 和 EDX 压栈。这是一个可选的步骤,如果这三个寄存器即将被用到,但当前存储的内容需要保存下来以备将来恢复,则执行此步骤。

接着,main 把传递给 foo 的参数一一进栈,最后的参数最先进栈。例如,假设我们的函数调用是:

相应的汇编语言指令是(这里 12、15 和 18 都是立即数):

当 call 指令执行的时候,EIP 指令指针寄存器的内容被压入栈中。因为 EIP 寄存器是指向 main 中的下一条指令,所以现在返回地址就在栈顶了。在 call 指令执行完之后,下一个执行周期将从名为 foo 的标记处开始。

图 2 展示了 call 指令完成后栈的内容。图 2 及后续图中的双虚线指示了函数调用前栈顶的位置。我们将会看到,当整个函数调用过程结束后,栈顶又回到了这个位置。

2.3 被调用者在函数调用后的动作

当函数 foo,也就是被调用者取得程序的控制权,它必须做 3 件事

  1. 建立它自己的栈帧,为局部变量分配空间(如果需要,保存 EBX、ESI 和 EDI 等寄存器的值)。

首先,foo 必须建立它自己的栈帧。EBP 寄存器现在正指向 main 的栈帧中的某个位置,这个值必须被保留,因此,EBP 进栈保存当前值;然后 ESP 的内容赋值给了 EBP,这使得函数的参数可以通过对 EBP 附加一个偏移量得到,而栈寄存器 ESP 便可以空出来做其他事情。如此一来,几乎所有的 C/C++ 函数调用都从如下两个指令开始:

此时的栈如图 3 所示,在这个场景中,第一个参数的地址是 EBP 加 8,因为 main 的 EBP 和返回地址各在栈中占了 4 个字节。

  1. 下一步,foo 必须为它的局部变量分配空间,同时,也必须为它可能用到的一些临时变量分配空间。

例如,foo 中的一些 C/C++ 语句可能包括复杂的表达式,其子表达式的中间值就必须得有地方存放,因为它们可能被下一个复杂表达式所复用。为说明方便,我们假设 foo 中有两个 int 类型(每个占 4 字节)的局部变量,另外还需要额外的 12 字节作为临时存储空间。简单地把栈指针减去 20 便是为这 20 个字节分配了空间:

现在,局部变量和临时存储空间都可以通过基准指针 EBP 加偏移量找到了。 最后,如果 foo 用到 EBX、ESI 和 EDI 寄存器,则必须使用栈来保存这些寄存器的当前值。因此,现在的栈如图 4 所示。

foo 的函数体现在就可以执行了。这其中也许有进栈、出栈的动作,栈指针 ESP 也会上下移动,但 EBP 是保持不变的。这意味着我们可以一直用 [EBP+8] 找到第一个参数,而不管在函数中有多少进出栈的动作。 函数 foo 的执行也许还会调用别的函数,甚至递归地调用 foo 本身。然而,只要 EBP 寄存器在这些子调用返回时被恢复,就可以继续用 EBP 加上偏移量的方式访问实际参数、局部变量和临时存储空间。

2.4 被调用者返回前的动作

在把程序控制权返还给调用者前,被调用者 foo 必须先把返回值保存在 EAX 寄存器中。

我们前面已经讨论过,当返回值占用多于 4 个或 8 个字节时,接收返回值的变量地址会作为一个额外的指针参数被传到函数中,而函数本身就不需要返回值了。这种情况下,被调用者直接通过内存拷贝把返回值直接拷贝到接收地址,从而省去了一次通过栈的中转拷贝。 其次,foo 必须恢复 EBX、ESI 和 EDI 寄存器的值。如果这些寄存器被修改,正如我们前面所说,我们会在 foo 执行开始时把它们的原始值压入栈中。如果 ESP 寄存器指向如图 4 所示的正确位置,寄存器的原始值就可以出栈并恢复。可见,在 foo 函数的执行过程中正确地跟踪 ESP 是多么的重要——也就是说,进栈和出栈操作的次数必须保持平衡。

这两步之后,我们不再需要 foo 的局部变量和临时存储空间了,我们可以通过下面的指令销毁当前栈帧:

其结果就是现在栈里的内容跟图 2 中所示的栈完全一样。现在可以执行返回指令了。从栈里弹出返回地址,赋值给 EIP 寄存器。栈如图 5 所示:

i386 指令集有一条 “leave” 指令,它与上面提到的 mov 和 pop 指令所做的动作完全相同。所以,C/C++ 函数通常以这样的指令结束:

2.5 调用者在返回后的动作

在程序控制权返回到调用者(也就是我们例子中的 main 函数)后,栈如图 5 所示。这时,传递给 foo 的参数通常已经不需要了。我们可以把这 3 个参数一起弹出栈,这可以通过把栈指针加 12(3 个 4 字节)实现:

如果在函数调用前,保存过 EAX、ECX 和 EDX 寄存器的值,调用者 main 函数现在可以把它们弹出以恢复它们当时的值。这个动作之后,栈顶就回到了开始整个函数调用过程前的位置,也就是图 5 中双虚线的位置。

这段代码反汇编后,代码是什么呢?

ebp 的值入栈,保存现场(调用现场,从 test 函数看,如红线所示,即保存的 0x12FF80 用于从 test 函数堆栈返回到 main 函数):

此时 ebp=0x12FF80 此时 ebp 就是“当前函数堆栈”的基址,以便访问堆栈中的信息;还有就是从当前函数栈顶返回到栈底:

函数使用的堆栈,默认 64 个字节,堆栈上就是 16 个横条(密集线部分)此时 esp=0x12FF40。 在上图中,上面密集线是 test 函数堆栈空间,下面是Main的堆栈空间(补充,其实这个就叫做 Stack Frame):

函数调用,转向 eip 。

即转向被调函数 test:

因为 Win32 汇编一般用 eax 返回结果 所以如果最终结果不是在 eax 里面的话,还要把它放到 eax。

注意,从被调函数返回时,是弹出 EBP 恢复堆栈到函数调用前的地址,弹出返回地址到 EIP 以继续执行程序。 从 test 函数返回,执行:

清栈,清除两个压栈的参数10 和 90,由调用者 main 负责。 (默认函数调用方式是 __cdecl,该函数调用方式由调用者负责恢复栈,调用者负责清理的只是入栈的参数,test 函数自己的堆栈空间自己返回时自己已经清除)

 push eax ;入栈,计算结果108入栈,即printf函数的参数之一入栈
 

EBP=0x12FFC0 尘归尘 土归土 一切都恢复最初的样子了。

  1. 运行过程中 0x12FF28 保存了指令地址 是怎么保存的?栈每个空间保存 4 个字节(粒度 4 字节) 例如下一个栈空间 0x12FF2C 保存参数 10,因此:

Little-Endian 认为其读的第一个字节为最小的那位上的数。

  1. char a[] = "abcde" 对局部字符数组变量(栈变量)赋值,是利用寄存器从全局数据内存区把字符串“abcde”拷贝到栈内存中的。

可以看出来是从右边开始入栈,所以是 5 4 3 2 1 入栈,

如果上面是指针算术,那这里就是地址算术,只是首地址 + 1个字节的 offset,即 ebp-13h 给指针。实际保存是这样的:

注意,是 int* 类型的,最后获得的是 00 00 00 02,由于使用的是 Little-Endian, 实际上逻辑数是 ,转换为十进制数就为 ,最后输出 。

三、从哪里可以系统地学习到这些知识?

不知道你看了上文是否觉得特别吃力,如果觉得吃力,我推荐几本书可以让你系统地掌握这些知识。

学习汇编不是一定要用汇编来写代码,就像我们学习 C/C++ 也不一定单纯为了面试和找工作。

对于 C/C++ 的同学来说,汇编是建议一定要掌握的,只有这样,你才能在书写 C++ 代码的时候,清楚地知道你的每一行C++代码背后对应着什么样的机器指令,if/for/while 等基本程序结构如何实现的,函数的返回值如何返回的,为什么整型变量的数学运算不是原子的,最终你知道如何书写代码才能做到效率最高。掌握了汇编,你可以明白,在 C++ 中,一个栈对象从构造到析构,其整个生命周期里,开发者的代码、编译器和操作系统分别做了什么。掌握了汇编,你可以理解函数调用是如何实现的,你可以理解函数的几种调用方法,为什么 printf 这样的函数其调用方式不能是 __stdcall,而必须是 __cdecl。掌握了汇编,你就能明白为什么一个类对象增加一个方法不会增加其实际占的内存空间。

汇编的书籍首推王爽老师的《汇编语言(第三版)》,这本书比较薄,读起来轻松舒适,可以作为汇编入门的书籍。

当然,如果你想系统且严谨地学习下汇编且用于对计算机系统原理的理解(这本书不仅仅介绍汇编知识,还有其他内容),我强烈推荐一本经典著作,学计算机的同学基本没有不知道这本书的——《深入理解计算机系统》,这本书英文叫《Computer Systems:A Programmer’s Perspective》(程序员视角下的计算机系统,所以又称为 CSAPP)。

2. 第二个基础知识是编译、链接与运行时体系知识

作为一个开发者,要清楚地知道我们写的 C/C++ 程序是如何通过预处理、编译与链接等步骤最终变成可执行的二进制文件,操作系统如何识别一个文件为可执行文件,一个可执行文件包含什么内容,执行时如何加载到进程的地址空间,程序的每一个变量和数据位于进程地址空间的什么位置,如何引用到。一个进程的地址空间有些什么内容,各段地址分布着什么内容,为什么读写空指针或者野指针会有内存问题。一个进程如何装在各个 so 或 dll 文件的,这些文件被加载到进程地址空间的什么位置,如何被执行,数据如何被交换。

这里强烈推荐下《程序员的自我修养》这本书,搞 C/C++ 开发,读了一百本 C/C++ 相关的书籍,不如好好读一下这本书。

当然,还有另外一本可用于实战的书《老码识途 从机器码到框架的系统观逆向修炼之路》,这本通过实践行动告诉你,你写的每一行 C/C++ 代码是如何与相应的机器码对应起来的。

此书以逆向反汇编为线索,自底向上,从探索者的角度,原生态地刻画了对系统机制的学习,以及相关问题的猜测、追踪和解决过程,展现了系统级思维方式的淬炼方法。该思维方式是架构师应具备的一种重要素质。此书内容涉及反汇编、底层调试、链接、加载、钩子、异常处理、测试驱动开发、对象模型和机制、线程类封装、跨平台技术、插件框架、设计模式、GUI框架设计等。

你可以跟着这本书的内容在调试器中一步步看你的 C/C++ 代码在系统层级是如何运行的。我第一次知道有此书时如获至宝,连夜下单购买。

当然,还有一本关于 C++ 反汇编的书叫《C++反汇编与逆向分析技术揭秘》,这本书也是非常不错的书,可以学习汇编和反汇编,学习 C++ 代码如何与编译后的机器码相对应。

这本书最后的案例是逆向分析大名鼎鼎的熊猫烧香病毒。

图书来源于网络,喜欢请购买正版,侵删。

原创不易,觉得有帮助,请点赞和关注 ~

更多的术语将在第一次出现时进行定义.

AAPCS定义了单独编译与汇编的程序如何组合工作. 在这些程序间有一个外部可见的接口. 通常情况下, 不是所有的外部可见的软件接口都是公开可见或任意使用的.
事实上, (由目标代码格式严格定义的)机器层面的外部可见的概念与更高层面上, (系统定义或应用程序定义的)面向应用程序的外部可见的概念是不同的.
(1). 在任何时候栈空间限制与栈对齐规则都必须遵守(见5.1.1. 通用栈限制).
(2). 在静态链接阶段, 每个使用BL类型的控制转移指令的调用都必须遵守使用IP寄存器的规范(见5.3.1.1. 链接器对IP的使用).

从软件角度看, 内存是一组可寻址的字节数组. ABI支持两种由硬件实现的内存视图.
(1). 在小端内存视图下一个数据对象的最低有效字节是该数据对象所占内存的最低字节地址.
(2). 在小端内存视图下一个数据对象的最低有效字节是该数据对象所占内存的最高字节地址.
一个对象的最低有效位永远是位0.
所有对象都是统一字节序, 即映射可以对应更大或更小的对象. 一个双字在内存中的映射如下所示:
大端字节序数据对象在内存中布局
小端字节序数据对象在内存中布局

5. 基本程序调用标准
基本标准定义了(ARM指令集与Thumb指令集通用的)机器层面的, 只与核心寄存器相关的调用标准. 它应被应用于无浮点硬件或高度需要Tbumb代码相关的系统中.

对于没有这些特殊需求的虚拟平台可将r9设计为额外的参数寄存器v6.
CPSR是一个全局寄存器, 它有如下属性:
(2). 在ARM 6架构上, 位E可被(在小端字节序或big-endian-8模式下执行的)应用程序用作暂时的改变访问内存时数据的字节序. 应用程序必须有指定的字节序且在进入公共接口(public interface)或从接口返回时设置位E必须遵守应用程序指定的字节序.
(3). 位T(5位)与位J(24位)是程序执行状态位. 只有被用来修改这些位的指令才能修改它们.
(4). 位A, I, F与M[4:0](0-7位)是特权位且仅能被设计在特权模式下显式操作的程序修改.
(5). 所有其它位均为保留位且不能被修改. 不论它们为0或为1或在公共接口(public interface)调用时是否保持其含义均是未定义的.
大于32位的基本类型可以在函数调用时作为参数传入或作为结果返回. 当这些类型保存在核心寄存器中时必须遵守以下规则:
(1). 一个双字大小的类型通过两个连续的寄存器传递(i.e. r0与r1, r2与r3). 值通过单个LDM指令将从内存中加载到寄存器中.
(2). 一个四字大小的矩阵向量(Containerized vector)通过四个连续的寄存器传递. 值通过单个LDM指令将从内存中加载到寄存器中.
机器寄存器组可通过附加寄存器扩展, 这些附加寄存器在协处理器指令空间中使用指令访问. 使用这些不用来传递参数或返回结果的协处理器寄存器是兼容基本标准的. 每个协处理器可能有额外的管理自身的寄存器的规则.
注意: 即使协处理器不用来传递参数, 有些语言运行时支持的特性要求了解程序中全部协处理器的状态以保证正常运行(i.e. C语言中setjmp()与C++异常机制).
VFP-v2协处理器有32个单精度寄存器s0-s31, 同时支持以16个双精度寄存器d0-d15形式访问(d0包括s0, s1, d1包括s2, s3, 依次类推). 此外根据不同实现还有3个及以上的系统寄存器.
VFP-v3增加额外16个双精度定时器d16-d31, 但它们不支持以单精度方式访问.
高级SMID扩展使用VFP寄存器组, 使用双精度寄存器作为64位矢量, 以及定义四字寄存器作为128位矢量(q0包括d0, d1, q1包括d2, d3, 依次类推).
FPSCR是唯一可被符合标准的代码(conforming code)访问的寄存器. 它是一个有以下特性的全局寄存器:
(2). 异常控制位(8-12位), 取整模式位(22-23位)与flush-to-zero位(24位)的修改可通过调用特定的支持函数且会影响到应用程序的全局状态.
(3). 长度位(16-18位)与跨距位(20-21位)必须在进入与返回接口时保持为零.
(4). 所有其它位均为保留位且不能被修改. 不论它们为0或为1或在公共接口(public interface)调用时是否保持其含义均是未定义的.

AAPCS应用于单一线程的程序(以下简称进程). 一个进程有一个由底层的机器寄存器定义的程序状态与它可访问的内存内容. 一个进程的内存通常可以分为五类:
(1). 代码(被执行的程序), 对进程而言它必须是可读的但不需要可写.
可写静态数据又可进一步细分为初始化数据, 清零初始化数据, 未初始化数据. 除栈外其它类型不需要占用单一连续内存区域. 一个进程必须有代码与栈, 但可以没有其它类型内存.
堆是一块(或若干)由进程自己管理(i.e. 使用C malloc函数)的内存区域, 通常用于创建动态数据对象.
符合标准的程序必须只能执行被设计用来存放代码的内存区域中的指令.
栈是一块用来存储局部变量与当参数寄存器不足时传递额外参数给子程序的连续内存区域. 栈的实现是满降栈(full-descending)且当前地址保存在寄存器SP中. 通常情况下栈有一个基址与长度限制, 但程序在实际运行时无法确定两者的值. 栈可以是固定大小也可以是动态扩展的(通过向下调整栈长度限制). 栈维护的规则有两条: 一组任何时候都必须遵守的约束与一条额外的在公共接口(public

ARM指令集与Thumb指令集均包含一个基本的子程序调用指令BL, 用于执行带链接的跳转操作. 执行BL指令的结果是将程序计数器(PC)的下一个值(即返回地址)传入链接寄存器(LR)并将目的地址传入程序计数器(PC). 链接寄存器的位0置位代表BL指令从Thumb状态执行的, 置零代表BL指令从ARM状态执行的. 指令执行结果是将控制转移到目的地址, 将LR中的返回地址作为额外的参数传给被调用的子程序. 当返回地址重新装载回PC时控制返回到BL的下一条指令(见5.6. 相互转化).
一个子程序调用可以由任何具有该作用的指令序列组成, 举例而言, 在ARM模式下若想调用一个以寄存器r4寻址的子程序并(在子程序结束后)控制返回到接下来的指令, 仅需:
注意: 以上序列将不在Thumb状态起作用因为设置LR的指令不会将代表Thunb状态的位传递到LR[0].
在ARM v5架构下ARM状态与Thumb状态均提供BLX指令, 它可以调用一个以寄存器寻址的子程序并正确的将返回地址设置为程序计数器的下一个值为程序计数器的下一个值为程序计数器的下一个值为程序计数器的下一个值为程序计数器的下一个值为程序计数器的下一个值为程序计数器的下一个值为程序计数器的下一个值为程序计数器的下一个值.
ARM状态与Thumb状态下BL指令均无法寻址32位空间, 所以链接器在调用程序与被调用子程序间插入胶合代码(veneer)是必要的. 胶合代码(veneer)也可以用来支持ARM-Thumb状态相互转换或动态链接. 任何插入的胶合代码(veneer)必须保护除IP(r12)以外的所有寄存器及状态码标记位.

函数返回的结果需要遵守的规则是由结果的类型决定的, 对于基本标准:
(1). 类型是半精度浮点的返回值存储在r0的低16位中返回.
(2). 类型是长度小于4字节的基本数据类型的返回值以零扩展或符号扩展为单字后存储在r0中返回.
(6). 类型是长度不大于4字节的复合数据类型存储在r0中返回. 返回值的格式是假设结果存储在以单字对齐的内存地址上并通过一个一个LDR指令加载到r0中, 任何r0中超过结果边界的位都是未指定的值.
(7). 类型是长度大于4字节或(无论调用程序还是被调用程序)都不能静态的决定长度的复合数据类型, 其返回值存储在函数被调用时传递的额外参数所指向的内存地址上, 用于保存结果的内存在函数调用期间任意时间点均可能被修改.

基本标准允许通过核心寄存器r0-r3及栈传递参数. 对于只需少量参数的子程序使用寄存器传递参数可以极大减少调用开销.
参数传递可以用两级抽象模型:
(1). 从源语言参数到机器类型的映射.
(2). 机器类型的集合来产生最终的参数列表.
从源语言参数到机器类型的映射是由每种语言分开描述并指定的(C与C++语言相关内容见7 ARM C与C++映射), 其结果是一个将要传递给子程序的有序的参数列表.
接下来的描述中会假设有许多协处理器可以用来传递或接收参数. 协处理器寄存器分为许多类型. 一个参数至多是一类协处理器寄存器的候选对象. 一个参数如适合分配在一个协处理器寄存器上则被称为协处理器寄存器候选对象(CPRC).
在基本标准中没有参数是协处理器寄存器的候选对象.
变参函数必须总是整理(marshaling)已使其符合基本标准.
对于调用者而言, 在整理(marshaling)之前需先保证足够的栈空间(已被分配)来保存存储在栈上的参数: 实际应用中所需要的栈空间在参数整理(marshaling)完以前是未知的. 被调用者可修改任何用来传递调用者参数的栈空间.
当一个复合类型参数(部分或全部)被分配给核心寄存器, 其表现相当于参数先被存储到以字对齐的内存地址上然后使用合适的复数加载指令将其加载到连续的寄存器中.
本阶段仅在参数处理之前出现一次.
A.2.cp. 开始协处理器参数寄存器初始化.
A.3. 下一个存储参数栈地址(NSAA)被设置为当前栈指针值(SP).
A.4. 如果子程序是将返回值保存在内存中的函数则返回值的地址将放在r0中且NCRN被设置为r1.
阶段B. 预填充与扩展参数
对于每个在列表中的参数都需遵守以下原则中的第一条.
B.1. 如果参数是复合类型且其长度不能被(调用者或被调用者)静态决定, 那么需将参数拷贝至内存并将参数本身用指向该拷贝的指针替换.
B.2. 如果参数是整数的基本数据类型且小于单字, 那么需将参数以零扩展或符号扩展至一个字并将其长度设置为4字节. 如果参数是半精度浮点类型, 则将它的长度设置为4字节并将其当做拷贝至32位寄存器的低位且其余位填充未指定数据.
B.3.cp. 如果参数是CPRC那么任何对协处理器寄存器类的准备规则都需应用.
B.4. 如果参数是复合类型且其大小不是4字节的倍数, 那么它的大小需要向上对齐到最近的4的倍数.
B.5. 如果参数是调整过对齐的类型那么它以实际参数的拷贝传递参数, 其拷贝的对齐按如下规则. 对于基本数据类型, 其对齐以该基本类型的自然对齐为准. 对于复合数据类型, 拷贝的对齐需要以4字节对齐(如果其自然对齐不大于4字节)或8字节对齐(如果其自然对齐大于等于8字节). 拷贝的对齐适用于整理(marshaling)规则.
阶段C. 参数分配给寄存器与栈
对于列表中每个参数依次应用以下规则直至参数被分配.
C.1.cp. 如果参数是CPRC且有足够的对应类型的未分配的协处理器寄存器, 则参数被分配给协处理器寄存器.
C.2.cp. 如果参数是CPRC且任何对应类型的未分配的协处理器寄存器均被标记为不能使用, 则向上调整NSAA直至其地址与参数对齐然后将参数拷贝至调整后的NSAA, NSAA进一步增长参数长度的大小.
C.3. 如果参数需要以双字对齐, NCRN需向上对齐到偶数个寄存器编号.
C.4. 如果参数以字为单位的大小不超过r4减去NCRN, 参数将被拷贝至以NCRN开始的核心寄存器, NCRN增加被使用的寄存器个数的大小. 如果使用一个LDM命令将值从内存装载到寄存器中, 则连续的寄存器保存参数的各个部分. 至此参数完成分配.
C.5. 如果NCRN小于r4且NSAA等于SP参数将被分开存储在核心寄存器与栈上. 参数的第一部分被拷贝到以NCRN开始直至r3的核心寄存器中, 其余的部分被拷贝至以NSAA开始的栈上. 然后NCRN被设置为r4且NSAA增长参数长度减去传参寄存器个数的大小. 至此参数完成分配.
C.7. 如果参数要求双字对齐则NSAA向上对齐到双字.
需要注意的是以上算法对除C与C++以外的语言做出了规定因为它提供了传递数组与动态长度的值. 规则是让调用者总是能够静态决定用于存放不通过寄存器传递的参数的栈空间的大小, 即便函数是变参的.
进一步可以观察到以下几点:
(1). 初始栈地址是传递给子程序的栈指针的值. 因此编译过程中可能需要运行两次以上算法, 第一次确定所需栈空间大小, 第二次确定最终的栈地址.
(2). 一个双字对齐的类型总是存储在以偶数起始的核心寄存器内, 或双字对齐的栈地址, 即使它不是结构体(Aggregate)的第一个参数.
(3). 参数首先被分配在寄存器中且仅(有参数寄存器不够用时)额外的参数才保存在栈上.
(4). 基本数据类型的参数总是全部保存在寄存器中或全部保存在栈上.
(5). 根据C.5.规则至多只有一个参数可被分开保存在寄存器与栈上.
(6). CPRC对象可被保存在协处理器寄存器中或栈上, 但不能保存在核心处理器中.
(7). 因一个参数至多只能是一类协处理器寄存器的候选者, 该规则对多个协处理器在不影响其使用情况下同样适用.
(8). 一个参数只能被分配在核心寄存器与栈上如果它之前的CPRC已被分配在协处理器寄存器中.

AAPCS要求所有子程序调用与返回均支持ARM状态与Thumb状态相互转化. 这对编译各类ARM架构影响如下:

通过函数指针的调用应使用以下方式之一:
使用bl<cond>或b<cond>的函数调用需要链接器生成的胶合代码(veneer), 如果产生了状态转换. 因此有时使用一个带有无条件bl指令的指令序列会更高效.
返回序列可能会使用载入多个操作来直接装载PC或合适的bx指令.
以下传统的返回方式在有状态转换时无法使用:

涉及状态改变且使用bl的调用同样需要链接器生成的代码.
通过函数指针的调用必须使用对应ARM状态的指令序列.
但是该指令序列不适用于Thumb状态, 所以通常使用一个bl指令外加胶合代码(veneer)来替代bx的作用.
返回的指令序列必须恢复被保存的寄存器且使用bx指令来返回调用者.

ARMv4架构既不支持Thumb状态又不支持bx指令, 因此它不完全兼容AAPCS.
推荐对ARMv4的代码使用ARMv4T相互转化的序列进行编译, 但因所有bx指令均服从R_ARM_V4BX重定位[AAELF], 一个链接器链接的代码可能会将所有bx Rm类指令转变为mov PC, Rm. 但可重定位文件仍与此标准兼容.

本节内容仅适用于非变参函数, 对于变参函数总是使用基本标准来参数传递与结果返回.

本变型(variant)改变了浮点值在调用程序与子程序间传递的方式且提升了性能, 当存在VFP协处理器或高级SIMD扩展时.
6.1.1. 寄存器与内存格式之间的映射
使用VFP在接口调用时传递参数如下所示:
(1). 半精度浮点类型以将其从内存拷贝至单精度寄存器的低16位方式传递.
(2). 单精度浮点类型以将其从内存拷贝至单精度寄存器(使用VLDR指令)的方式传递.
(3). 双精度浮点类型以将其从内存拷贝至双精度寄存器(使用VLDR指令)的方式传递.
调用存储寄存器的集合与基本标准相同(5.1.2.1. VFP寄存器使用规则).
一个类型满足VFP CPRC条件的结果会从以最小的寄存器号起始的恰当的连续VFP寄存器中返回. 所有其它类型按基本标准返回.
有一类VFP协处理器寄存器类使用s0-s15(d0-d7)来传递参数. 以下是VFP协处理器守则:
A.2.vfp 浮点参数寄存器被标记为未分配.
C.1.vfp 如果参数是一个VFP CPRC且存在足够的未分配的对应类型的连续VFP寄存器则参数被分配到最低编码起始的寄存器.
C.2.vfp 如果参数是一个VFP CPRC且任何未分配的VFP寄存器均被标记为不可使用, 则NSAA向上调整直到它正确的以参数大小对齐再将参数拷贝至调整后NSAA起始的栈上. NSAA再增加参数的大小. 至此参数完成分配.
注意规则要求回填因前面参数的对齐约束导致跳过的未使用的寄存器, 只要没有VFP CPRC被分配到栈上回填就会一直持续.

代码可被编译为使用可选格式(alternative format)的半精度浮点值的程序. 传递参数与返回结果的守则可以按基本标准或VFP与高级SIMD守则.

为要求读写位置无关(i.e. 单一地址空间类DLL模型)的执行环境而编译与汇编的代码使用静态基地址来寻址可写数据. 核心寄存器r9重命名为SB并用于保存静态基地址: 因此该寄存器任何时候都不能用于保存其它数据(注1).

注1: 尽管未被标准授权, 编译器通常通过加载SB偏移再加上SB基址来得到静态数据的地址. 偏移通常是一个32位的值, 从文字池(literal pool)中装载的PC相关的值. 在静态链接时字面值通常受R_ARM_SBREL32类型重定位. 数据地址相对于SB的偏移是可执行文件布局的一项属性, 它是在静态链接时固定的. 它不依赖数据在何处载入, 它是在运行时由SB捕获的.

本章描述了ARM编译器如何将C特性映射到机器层面标准. C++作为C扩展的超集它同样描述了映射C++的特性.

本ABI将枚举类型的表示方法的选择交由平台ABI(无论是标准定义的还是实际使用的)或交由接口协议如果没有定义平台ABI.
(2). 枚举类型的存储容器的类型是以能包含该枚举全部枚举值的最小整型.
C与C++语言标准对枚举类型的定义没有涉及二进制接口的定义且遗留以下问题:
(1). 枚举类型的容器是否有固定大小(正如大多数系统环境所希望的)或其大小不超过所保存的枚举值的大小(正如大多数嵌入式用户所希望的)?
(3). 枚举类型的值(在C/C++要求的转换后)是有符号还是无符号的?
关于最后一个问题C与C++语言标准声明:
(1). [C]每个枚举类型都需要兼容整型类型. 类型的选择是实现定义的, 但它必须能够表示所有枚举成员的值.
基于本ABI, 声明允许定义了可移植二进制包接口的头文件以一种严格遵循的可移植的方式强迫其客户采用一个32位有符号(int或long)表示一个枚举类型(通过定义一个负枚举值一个正枚举值并确保枚举数的范围超过16位且不超过32位). 否则必须通过调用平台ABI或分离的接口定义来建立对二进制表示的通用解释.
C与C++均要求系统提供基于基本类型的额外类型定义. 通常这些类型是通过包含适当的头文件来定义的. 然而在C++中基础类型size_t()可以无需使用任何头文件而简单使用::operator new()来暴露它, 而va_list的定义由编译器内部实现. 一个遵守AAPCS的对象必须遵守以下定义.
数据类型的声明可以用volatile描述符描述. 编译器不会移除任何对volatile数据类型的访问除非它能证明包含该访问的代码永远不会执行; 然而编译器会忽略自动变量的volatile描述符除非函数调用setjmp(). 结构体或联合体的volatile描述符被解释为递归的应用到组成结构的每个基本数据类型的描述符. 访问带volatile描述符的基本数据类型必须总是访问整个类型. 指定结构体或联合体的成员是volatile的的行为是未定义的. 同样的, 改变描述符或其大小的类型转换也是未定义的行为.
不是所有ARM架构都提供以各种宽度访问类型; 举例而言, 早于ARMv4的架构没有以16位访问的指令, 同样也没有64位访问的指令. 因此处理器中内存系统对部分或全部内存有严格的总线宽度限制. 这种情况下对volatile类型的唯一保证是类型的每一个字节都会精确访问一次且任何包含volatile数据且不属于该类型的字节都不做访问. 然而, 如果编译器有一条可用指令恰好能访问该类型,

7.2. 参数传递转换
子程序调用的参数列表是通过获取用户参数的被指定的顺序来形成的.
(1). 对C而言每个参数都是通过源代码中指定值形成的, 除了那些通过第一个元素的地址传递的数组.
(2). 对C++而言一个隐式this参数作为一个额外参数在第一个用户参数之前传递, 其它编组C++参数的规则见CPPABI.
然后按程序调用标准守则(见5.5. 参数传递)或对应变型(variant)处理参数列表.

附录A. 高级SIMD扩展的支持

C语言编译后,在可执行文件中会有 函数名信息。如果想要动态调用一个C函数,首先需要 根据函数名找到这个函数地址 ,然后根据函数地址进行调用。

动态链接器已经提供一个 API:dlsym(),可以通过函数名字拿到函数地址:

从上面代码中可以看出,test方法是没有返回值和参数的。所以funcPointer只能在指向参数和返回值都是空的函数时才能正确调用到。
对于有返回值和有参数的C函数,需要指明参数和返回值类型才能使用。

不同的函数都有不同的参数和返回值类型,也就没办法通过一个万能的函数指针去支持所有函数的动态调用,必须要让函数的参数/返回值类型都对应上才能调用。因为函数的调用方和被调用方会遵循一种约定:调用惯例(Calling Convention)。

当然我们也不是没有应对方法,我们可以将所有参数设置为void* 指针,然后强行
将所有类型参数转化为void* 调用,就像这样:

但是检测的调用也是会出现很多问题的。

高级编程语言的函数在调用时,需要约定好参数的传递顺序、传递方式,栈维护的方式,名字修饰。这种函数调用者和被调用者对函数如何调用的约定,就叫作调用惯例(Calling Convention)。高级语言编译时,会生成遵循调用惯例的汇编代码。

调用函数时,参数可以选择使用栈或者使用寄存器进行传递
参数压栈的顺序可以从左到右也可以从右到左
函数调用后参数从栈弹出可以由调用方完成,也可以由被调用方完成

在日常工作中,比较少接触到这个概念。因为编译器已经帮我们完成了这一工作,我们只需要遵循正确的语法规则即可,编译器会根据不同的架构生成对应的汇编代码,从而确保函数调用约定的正确性。

函数调用者和被调用者需要遵循这同一套约定,上述②,就是函数本身遵循了这个约定,而调用者没有遵守,导致调用出错。

以上面例子简单分析下,如果按①那样正确的定义方式定义funcPointer,然后调用它,这里编译成汇编后,在调用处会有相应指令把参数 n,m 的值 1 和 2 入栈(这里是举例),然后跳过去 testFunc()函数实体执行,这个函数执行时,按约定它知道n,m两个参数值已经在栈上,就可以取出来使用了:

EI_PAD 呃…就是一堆全是00的用来补全大小的数组

  • e_entry 可执行程序入口点地址。

  • e_flags 保存与文件相关的,特定于处理器的标志。(不知道有什么用,看了几个arm都是00 00 00 05,x86都是0)。

  • e_phentsize 程序头部表的单个表项的大小

  • e_phnum 程序头部表的表项数

  • e_shstrndx String Table Index,在节区表中有一个存储各节区名称的节区(通常是最后一个),这里表示名称表在第几个节区。

  • p_type 声明此段的作用类型

    此数组元素未用。结构中其他成员都是未定义的。
    此数组元素给出一个可加载的段,段的大小由 p_filesz 和 p_memsz 描述。文件中的字节被映射到内存段开始处。如果 p_memsz 大于 p_filesz,“剩余”的字节要清零。p_filesz 不能大于 p_memsz。可加载的段在程序头部表格中根据 p_vaddr 成员按升序排列。
    数组元素给出动态链接信息。
    数组元素给出一个 NULL 结尾的字符串的位置和长度,该字符串将被当作解释器调用。这种段类型仅对与可执行文件有意义(尽管也可能在共享目标文件上发生)。在一个文件中不能出现一次以上。如果存在这种类型的段,它必须在所有可加载段项目的前面。
    此数组元素给出附加信息的位置和大小。
    此段类型被保留,不过语义未指定。包含这种类型的段的程序与 ABI不符。
    此类型的数组元素如果存在,则给出了程序头部表自身的大小和位置,既包括在文件中也包括在内存中的信息。此类型的段在文件中不能出现一次以上。并且只有程序头部表是程序的内存映像的一部分时才起作用。如果存在此类型段,则必须在所有可加载段项目的前面。
    此范围的类型保留给处理器专用语义。
    此范围的类型保留给处理器专用语义。

    还有一些编译器或者处理器标识的段类型,有待补充。

  • p_offset 段相对于文件的索引地址

  • p_vaddr 段在内存中的虚拟地址

  • p_filesz 段在文件中所占的长度

  • p_memsz 段在内存中所占的长度

sh_name 节区名称,此处是一个在名称节区的索引。

0 此值标志节区头部是非活动的,没有对应的节区。此节区头部中的其他成员取值无意义。
此节区包含程序定义的信息,其格式和含义都由程序来解释。
此节区包含一个符号表。目前目标文件对每种类型的节区都只能包含一个,不过这个限制将来可能发生变化。一般,SHT_SYMTAB 节区提供用于链接编辑(指 ld 而言)的符号,尽管也可用来实现动态链接。
此节区包含字符串表。目标文件可能包含多个字符串表节区。
此节区包含重定位表项,其中可能会有补齐内容(addend),例如 32 位目标文件中的 Elf32_Rela 类型。目标文件可能拥有多个重定位节区。
此节区包含符号哈希表。所有参与动态链接的目标都必须包含一个符号哈希表。目前,一个目标文件只能包含一个哈希表,不过此限制将来可能会解除。
此节区包含动态链接的信息。目前一个目标文件中只能包含一个动态节区,将来可能会取消这一限制。
此节区包含以某种方式来标记文件的信息。
这种类型的节区不占用文件中的空间,其他方面和 SHT_PROGBITS 相似。尽管此节区不包含任何字节,成员sh_offset 中还是会包含概念性的文件偏移
此节区包含重定位表项,其中没有补齐(addends),例如 32 位目标文件中的 Elf32_rel 类型。目标文件中可以拥有多个重定位节区。
此节区被保留,不过其语义是未规定的。包含此类型节区的程序与 ABI 不兼容。
作为一个完整的符号表,它可能包含很多对动态链接而言不必要的符号。因此,目标文件也可以包含一个 SHT_DYNSYM 节区,其中保存动态链接符号的一个最小集合,以节省空间。
这一段(包括两个边界),是保留给处理器专用语义的。
这一段(包括两个边界),是保留给处理器专用语义的。
此值给出保留给应用程序的索引下界。
此值给出保留给应用程序的索引上界。

sh_offset 节区相对于文件的偏移地址

sh_link 此成员给出节区头部表索引链接。

sh_info 此成员给出附加信息。

此节区中条目所用到的字符串表格的节区头部索引 0
此哈希表所适用的符号表的节区头部索引 0
相关符号表的节区头部索引 重定位所适用的节区的节区头部索引
相关联的字符串表的节区头部索引 最后一个局部符号(绑定 STB_LOCAL)的符号表索引值加一
0

对于字符串的定义,是以NULL(\0)开头,以NULL结尾。

从这里可以得到3个字符串即:

符号: 指函数或者数据对象等。
既然叫做表,那么也分为一个一个表项,其表项也有自己的结构定义:


 
 
 
 
 
 
 

代码段就是 存放指令的节区(.text) ,符号表中的 st_value 指向代码段中具体的函数地址,以其地址的指令为函数开头。

指.got节区,.got内的值均为 Elf32_Addr。其为全局符号提供偏移地址(指向过程链接表)。

.plt节区,其每个表项都是一段代码,作用是跳转至真实的函数地址

  • .data 存放已初始化的全局变量、常量。
  • .bss 存放未初始化的全局变量,所以此段数据均为0,仅作占位。
  • .rodata 是只读数据段,此段的数据不可修改,存放常量。

获取ELF文件格式的符号表

基于以上的知识,我们就可以通过将elf文件映射成内存,然后通过结构体指针,解析出ELF文件的符号表


 

在C语言中函数是如何调用的?我们通过下方这个简单的代码来进行深入研究。

每一次函数调用都是一个过程,这个过程要为函数开辟栈空间,用于本次函数的调用中临时变量的保存,现场保护。我们称这块栈空间为函数栈帧。
为了维护栈帧,在函数调用的过程中esp寄存器存放了维护这个栈的栈顶指针,ebp寄存器存放了维护这个栈的栈底指针。
当我们要详细研究函数的调用过程,我们需要研究其对应的汇编代码。
具体过程分为以下四步:

通过对于这种压栈的过程的分析 我们不难发现,直接使用万能指针是有其先天缺陷的,因为我们将所有参数都假想成

void* 指针进行压栈,所以可能会出现很多问题,比如我们有一个在32位机器上有一个 int64_t 的数据作为参数,在将int64_t

转化成void* 的过程中必然伴随着数据的丢失,出现不太可知的错误。此外万能指针的方式并不支持 浮点类型。

为了对于不同的参数都只压入与其类型对应的大小,需要在

“FFI” 的全名是 Foreign Function Interface(外部函数接口),通过外部函数接口允许用一种语言编写的代码调用用另一种语言编写的代码。libffi提供了最底层的接口,在不确定参数个数和类型的情况下,根据相应规则,完成所需数据的准备,生成相应汇编指令的代码来完成函数调用。

libffi还提供了可移植的高级语言接口,可以不使用函数签名间接调用C函数。比如,脚本语言Python在运行时会使用libffi高级语言的接口去调用C函数。

libffi的作用类似于一个动态的编译器,在运行时就能够完成编译时所做的调用惯例函数调用代码生成。

ffi_cif由参数类型(ffi_type)和参数个数生成,定义如下:

准备好函数模板之后,就可以使用ffi_call调用指定函数了,简单看个例子,结合了模板生成和函数调用步骤:

先定义一个C函数 使用libffi调用这个C函数:

在可以调用函数后,我们发现在函数调用之前必须事先知道每个函数的输入参数和返回值,在一个ELF文件中本身是不带有这些信息的,

但是当你使用了-g 参数之后便会在elf文件中产生许多debugxx节,分析这些节的信息就可以知道函数名称,函数入参这类的信息。

DWARF格式中,高级语言的源文件、函数、变量、类型等调试信息在.debug_info节区中存储。.debug_info节区中,调试信息以节点的形式存在。节点可以存储一个源文件的调试信息、一个变量的调试信息、一个函数的调试信息等等。节点之间存在兄弟或父子的关系,一个源文件的调试信息节点形成一个调试信息树。

.debug_info节区中的节点有不同的类型和格式,但大多数节点的类型和格式是相同的,为了节省存储空间,DWARF在.debug_abbrev节区中定义了所有节点的类型和格式。.debug_info节区中存储节点调试信息时,只需要引用.debug_abbrev节区中存储的节点类型格式等信息即可,然后只存储节点的取值即可。.debug_info节区和.debug_abbrev节区结构及对应关系如下图所示:

每个节点有若干组属性,这些属性描述该节点的特点。例如DW_TAG_ compile_unit节点的属性以及属性的格式如图所示。该节点描述源文件调试信息,每个源文件都有一个该类型的节点描述。

对于 函数名称的获取方式可以通过寻找TAG号为46 的name 属性

想要获取函数的输入参数 则需要寻找TAG 号为5 的 type属性

通过对dwarf的信息进行分析就可以结合libffi 进行更为可靠的函数运行中调用。

我要回帖

更多关于 程序的执行总是从main函数开始 的文章

 

随机推荐