从这一篇文章开始,我们来关注在用 C 语言编程时的一些和算法无关、语言有关的、一寸一地的得失问题。这些问题大多数无伤大雅,并不对程序真实效率有多大影响,但可当作学习汇编思想的一种题材。大多数情况下,这些概念和理论能应用于所有语言,但由于编译器和汇编器的实现千姿万态,严谨起见,我们在这里只考虑 ANSI C(C89)标准,并且使用 GNU Compiler Collection(GCC) 4.4.3——一个优化性能极高的编译器,测试环境是 x86-64 Linux(Ubuntu)。
在这一章中,我们来看看每一个 C 程序都会有的内容——程序入口点有什么值得注意的细节。首先我们来编译一个空的源文件:
$ cat > 1.c
$ gcc -O3 -S -masm=intel 1.c
-O3 选项告诉 GCC 我们要打开所有优化模式;-S 选项告诉 GCC 我们要生成汇编代码,而不是可执行文件;-masm=intel 告诉 GCC 生成的汇编语言要使用 Intel 语法,而不是默认的 AT&T 语法;1.c 是我们的源文件名。输出如下:
.file "1.c"
.intel_syntax noprefix
.ident "GCC: (Ubuntu 4.4.3-4ubuntu5) 4.4.3"
.section .note.GNU-stack,"",@progbits
我们可以看到,一个空的 C 源文件经过 GCC 编译后,会生成这样 4 行汇编代码。这 4 行会以不同的形式出现在所有经 GCC 编译的程序代码中,前两行是初始代码,后两行是结束代码。
我们给源文件添加一个 main 函数:
void main() {
}
编译器输出:
.file "1.c"
.intel_syntax noprefix
.text
.p2align 4,,15
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
rep
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (Ubuntu 4.4.3-4ubuntu5) 4.4.3"
.section .note.GNU-stack,"",@progbits
可以看到在初始代码和结束代码之间,又增加了不少代码。我们关心的是 Main 函数内部的代码:
.cfi_startproc
rep
ret
.cfi_endproc
.cfi_startproc 和 .cfi_endproc 分别是 dwarf2 CFI 的初始过程和结束过程指令,它们隐藏了一些 CFI 有关的操作。rep 之所以出现在这里是因为 GCC 尝试避免分歧跳转跟着 ret 指令时发生 CPU 预测器无法得知 ret 的目标地址的问题(这个问题实际只存在于 x86-64)。ret 是从当前过程中返回的指令。这就是一个最简单的 main 函数内部的三个步骤:CFI 初始操作 – 返回 – CFI 结束操作。由于第一个和最后一个步骤永远伴随着函数,我们大可将注意力集中在这两个步骤之间的代码,也就是 main 函数的实际内容。
现在我们让 main 函数变成更规范、我们更熟悉的形式:
int main() {
return 0;
}
这时,我们之前提到的第一个步骤和第二个步骤之间的代码就变成了:
xor eax, eax
ret
我们可以看到,生成的代码比之前多了一行。eax 是一个通用的寄存器,根据 cdesl 调用约定(即 C 语言调用约定),在函数返回时,返回值必须保存在 eax 寄存器中,交给调用者处理。xor 是异或运算指令,自己异或自己并保存结果到自己,自己自然就成了 0,这是在汇编层把一个寄存器设为 0 的最快的方法(它的操作码在很多平台上都比 mov eax, 0 指令短)。所以,当 main 函数有返回值时,程序的代码是比没有时长的。
我们再给 main 函数加上常见的参数列表:
int main(int argc, char **argv, char **envp) {
return 0;
}
输出后你会发现汇编代码与之前相比没有改变。如果我们把 return 0 改为:
return argc;
输出就改变了:
mov eax, edi
ret
edi 是保存了 argc 的寄存器,mov 使 edi 的值传送到了 eax,实现了 return argc 的功能。由此可见,main 参数的传递是由调用者完成的(在这里 main 函数的调用者是装载器),并没有影响程序代码长度。
我们现在去掉 GCC 的 -O3 选项,即不进行任何优化,编译带三个参数、返回 argc 的 main 函数代码,发现输出为:
push rbp
.cfi_def_cfa_offset 16
mov rbp, rsp
.cfi_offset 6, -16
.cfi_def_cfa_register 6
mov DWORD PTR [rbp-4], edi
mov QWORD PTR [rbp-16], rsi
mov QWORD PTR [rbp-24], rdx
mov eax, DWORD PTR [rbp-4]
leave
ret
你一定震撼了。多出来的这些代码,是函数调用和返回时的常规代码,进行栈指针偏移、局部变量入栈等操作。由于我们这段代码根本没有必要使用栈内存(没有调用其它函数),GCC 在优化的时候就把我们的程序当作了一串没有非局部跳转的指令,省略了这些操作,直接返回,达到了未优化时的同样效果。这是一种最常见的编译器优化技术——死代码消除法(dead code elimination),根据上下文排除了不必要、不会影响程序运行的代码。这里只是死代码消除法的冰山一角而已。
好了,关于程序入口点,差不多就是这些内容。我们可以在不需要告诉操作系统程序运行的结果时让 main 函数不返回任何值,虽然并不规范,但确实无伤大雅,节省了一行汇编代码。同时,也不要担心写入了形式参数会增大生成的程序体积的问题——编译器强大的优化在很多时候比人类刻意去写的所谓“优化”后的汇编代码更优,这种简单的“死代码消除”对它来说实在是小菜一碟,它会在运行速度和代码体积之间进行合理的折衷(也可以通过用户传递的选项手动设置)。