硬核透视一个C++函数调用
Image (题图截取自cmatrix命令行程序运行效果)
1
话不多说,直接上代码吧。下图左边是C++代码,右边是对应生成的汇编指令(生成方法为:g++ -S foo.cpp):
Image
接下来,就深度剖析下这段汇编指令。
2
首先介绍些关于汇编语言的基础知识。
与 C/C++ 等高级语言不同,汇编语言之所以不高级,是因为它严重依赖于底层 CPU 体系架构,它的每一行代码,都能直接对应到一条机器指令,通常翻译成一个或几个字节的机器代码,扔给 CPU 就能执行。
gcc 生成的汇编代码,为 GNU 汇编(The GNU Assembler)语法。这里不详细展开其语法规则,只针对读懂上述代码,做一些简单说明:
行首未缩进的,都是用于指代该位置的标号,用半角冒号(:)结束。如“_Z3fooi:”、“.LFB0:”、“main:”、“0:”。这些标号,可能是变量名、函数名或跳转语句的目标,最终都会被汇编器将其更换成为对应的内存地址。
行首缩进,且起始字符不是半角点号(.)的,是真正的汇编语句。如“movq %rsp, %rbp”、“subq $16, %rsp”、“call _Z3fooi”、“leave”。这些语句都可以明确地被翻译成对应的机器指令。
行首缩进,且起始字符是半角点号(.)的,是汇编伪指令。如“.text”、“.globl main”、“.align 8”、“.long 0x3”。它们通常并不生成机器指令,而是告诉编译器做些特别的处理。
3
接下来,我把上面的 64 行汇编代码,拆分为四段,分别进行详细解读:
1-2 行:整体描述信息
3-22 行:int foo(int x) 函数
23-46 行:int main() 函数
47-64 行:全局数据段
第一段和第四段相对简单,先进行解读。
第一段:
Image
第一行“.file”仅用于记录其源代码文件,即当前“foo.s”是由“foo.cpp”编译得到。
第二行“.text”则标记接下去生成的汇编代码(或者也可以理解为对应的机器指令),需要放入代码段。是的,在汇编语言中,“代码”是用“text”表示,而非“code”表示。与“.text”相对应的,还有“.data”和“.bss”等,分别表示不同类型的数据段。
代码段和数据段等概念,与编译原理和可执行程序加载运行有关,这里不做展开(以后有机会再深挖)。简单理解为,高级语言的源代码,经过编译链接成为可执行文件后,其实可执行文件是由几个块组成的,在加载运行时,不同块会分别以不同形式加载。
第四段:
Image
由于本程序(foo.cpp)中未定义全局变量,所以这里生成的“预定义数据”,其实都是编译器额外编排的,诸如 GNU 版本、C/C++ 运行时库可能用到的内置全局变量等内容。
“.ident”用于标记编译器版本号,用于后续帮助链接器进行版本检查,避免把版本不匹配的机器指令整合到一起,而引起错误。
“.section”用于定义接下去的内容,所属段名,以及相关属性。
“.align”表示字节对齐,后面的数字 8,表示按能够被 8 整除的位置进行对齐。如果前面的字符串、数字或代码,在逐个进行字节编码后,到此处的地址,并不能被 8 整除,则编译器会在此空出几个字节,确保接下去的内存地址,是能被 8 整除的。之所以做这样的调整,是为了让内存寻址和使用更加高效。
“.long”和“.string”是分别用于分配长整数内存空间与字符串内存空间,并在该空间内预存入初始值。
上述数据内容对于理解本篇代码几乎无影响,就不进一步展开解释了。
4
第二段:
Image
先看函数名,原本在 C++ 代码中定义的“int foo(int x)”,到了这里,被转化成了“_Z3fooi”,这是 C++ 编译器为了实现函数重载(即同样的函数名,但参数类型或个数不同,可以同时存在于一个程序中)。重载的函数,本质上会在汇编语言层面(也即机器语言层面,下同,不再赘述),并列成为不同的代码。这个特性对于理解 C++ 模板的展开也很关键。
第 3 行的“.globl”,用于标识该“_Z3fooi”标号为全局,也即最终可以被链接器看到的“外部标识”,用于在将不同模块进行整合成一个可执行文件时,互相识别,从而确保其他模块也能使用这个标识。具体到这里,相当于提供了其他模块(即其他 .cpp 文件)可以调用这个“int foo(int x)”的基础。
第 4 行的“.type _Z3fooi, @function”,则进一步说明,这个标识是一个函数,这有助于后续正确使用该标识。如前所述,函数、变量、跳转目标等,在汇编语言中都“降维”拍平成了单个地址。确保每个地址被正确使用,成了编译器为提高代码质量,需要尽量严格检查的内容。
第 5 行的“.Z3fooi:”是标号定义,其名称来自原 C++ 函数名,并通过增加前缀与后缀,确保与其他重载函数不会冲突。第 6 行则定义了另一个标号“.LFB0:”,与之对应的还有第 21 行的“.LFE0:”,盲猜它们应该表示的是“local function begin”和“local function end”,仅仅为了让编译器可以更方便地定位到函数代码的起始和终止位置。
第 7 行的“.cfi_startproc”和第 20 行的“.cfi_endproc”也类似,不过并非以标号的方式,而是以伪指令的方式,定义了函数的起始和终止。猜测可能是帮助编译器确定当前生成代码状态,避免出现诸如函数内再嵌套函数等混乱发生。(插句题外话:即使这样的混乱发生,从机器指令的层面而言,也都是“合法”的,只不过其执行结果就未必是我们预期的了;我们通常编写的程序代码,都是做了很多秩序约定后,在广阔潜在可编码空间中,限定的一个狭小集合空间中进行的,这仅仅是为了使我们能够“轻松”驾驭代码而已)。
第 22 行的“.size _Z3fooi, .-_Z3fooi”,其中的“.”表示当前地址,它减去“_Z3fooi”地址,正好就是该函数代码的大小。所以,这里就是帮助编译器进行“_Z3fooi”大小定义的。
此外,第 10、11、13、18 行,“.cfi_XXX”的伪指令,都是用于帮助编译器识别并优化代码的,我们可以直接忽略,而仅仅看其他汇编代码。于是,我们可以对上面的代码做下简化,得到:
Image
它对应原C++代码:
Image
第 2 行汇编代码“endbr64”是一条现代CPU的新指令,与CPU指令流水线控制有关,这里简单将其理解为一条无任何动作的空指令(NOP)即可。
第 3 行“pushq %rbp”,将寄存器 rbp 存入堆栈,以便后续恢复。第 4 行“movq %rsp, %rbp”紧接着将堆栈指针赋给寄存器 rbp。这两行是函数调用在机器指令水平的常规套路,它们(与调用者代码配合)会在进程空间的堆栈段中,划出存放参数传递的内存空间,同时也为局部变量划出空间,并由此建立起一个新的堆栈空间。这个内存结构设计非常精巧,能够确保对参数和局部变量的访问,都局限在能够被快速访问的内存区域,且通过特定方式迭代地追溯,是能够找到函数的每一层调用者及相关信息的。调试器正是通过这个结构,将运行时的函数调用关系(调用堆栈),展示给程序员的。
第 8 行“popq %rbp”是从堆栈还原了寄存器 rbp 的值,从而使堆栈空间还原到函数调用前。第 9 行“ret”则将指令指针寄存器还原到调用者(call语句的下一条指令),实现函数返回功能。这与上面的第 3、4 行语句配合,形成了通常的函数框架的套路实现。真正的函数体(原C++代码的第 3 行),对应到汇编语言中,仅仅是上图的第 5-7 行。
Image
第 5 行“movl %edi, -4(%rbp)”,将寄存器 edi 的值,存入堆栈区域预留空间,该空间通过寄存器 rbp 来寻址,加上一个偏移量 -4,在这条汇编语句里写成了“-4(%rbp)”的形式。该预留空间,可以理解为“形参”(C++函数中看到的 int x),而寄存器本身的传递,可以相应理解为“实参”(C++ 函数调用者,main 中的 int a)。
第 6 行“movl -4(%rbp), %eax”,将该“形参”从内存读出,存入寄存器 eax。之所以这样做,是因为在机器指令中,所有四则运算都需要放到寄存器中才能进行。
第 7 行“addl $1, %eax”,则将寄存器 eax 的值进行加 1 操作,结果仍存入 eax 中。
至此,第 5-7 行汇编代码,实现了C++中的“return x+1;”,始于传入的寄存器 edi,终于返回值传出的寄存器 eax。
5
第三段:
Image
这是 main 函数的实现,与上述 foo 函数实现非常类似,相关的伪指令基本都完全一致,这里不做赘述,直接简化该代码成为:
Image
这里,第 2-5、12-13 行,与上述 foo 函数中的堆栈空间分配及还原的套路基本是一样的。唯一差别在于,main 函数中有两个局部变量(int a 与 int b),因此多了第 5 行“subq $16, %rsp”,将寄存器 rsp(表示堆栈底部指针)减去16,留出局部变量的空间。而第 12 行“leave”语句,这也是一个在80386 CPU中引入的“新语句”,其功能上相当于“movl %ebp, %esp”和“popl %ebp”两个语句,用于还原第 2-5 行造成的影响,使堆栈恢复函数调用前的状态。
真正的 main 函数内容:
Image
其 8-9 行源码,所翻译的汇编语句仅为这几行:
Image
第 6 行,“movl $1, -8(%rbp)”,将 1 赋值给局部变量 int a,即堆栈中预留出的空间“-8(%rbp)”。紧接着,第 7 行,“movl -8(%rbp), %eax”,又将该局部变量的值,重新读入到寄存器 eax 中,以便后续计算或操作。
然而其实并没有其他计算操作,有的仅仅是需要把该数值传入函数 foo 中。前面提到函数调用的传入参数与返回值,“始于 edi,终于 eax”,所以,这里将 eax 的值赋给 edi,用于传入,即第 8 行“movl %eax, %edi”。
之后的第 9 行,“call _Z3fooi”,即调用函数,它相当于把当前 ecs 和 eip(代码段地址与代码指针地址)都存入堆栈,然后做了一个长跳转(long jump)。与之对应的,子程序中的 ret 指令,其实是将 ecs 与 eip 从堆栈中恢复出来,于是程序就能继续从调用者的 call 语句之后,接着执行。
第 10-11 行,函数返回值由寄存器 eax 返回,将该数值存入局部变量 int b 中,即堆栈中分配出来的“-4(%rbp)”空间。
6
至此,我们解析完成了整篇汇编代码。这个堆栈构造与还原的套路,是所有计算机语言的底层通用实现。编译器在看到函数调用时,会自动帮我们去完成这些机器指令的编写。
当然,这里仅仅是一个最简单场景的开始,还不涉及到多个参数,非内建类型的参数,甚至多个返回值等情况。但万变不离其宗,理解最基本的套路后,其它的无非是做了延伸扩展。
想要扎实掌握 C++ 这门语言,甚至融会贯通地把握住任何其他语言,上述略显枯燥的解析过程,还是需要仔细吃透的。不愿意下这份功夫的,不妨及早放弃,选择更合适的钻研方向。毕竟,大多数码农,在 IDE 和 AI 的助力下,可能一辈子都不会需要接触到这些底层“屠龙技”的。
参考链接:
[1] https://debrouxl.github.io/gcc4ti/gnuasm.html [2] https://ftp.gnu.org/old-gnu/Manuals/gas-2.9.1/as.html [3] http://web.mit.edu/rhel-doc/3/rhel-as-en-3/index.html
注:本文首发表于“不靠谱颜论”公众号,并同步至本站。