Category Archives: Linux

那些和语言有关的坛坛罐罐(一)

从这一篇文章开始,我们来关注在用 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 函数不返回任何值,虽然并不规范,但确实无伤大雅,节省了一行汇编代码。同时,也不要担心写入了形式参数会增大生成的程序体积的问题——编译器强大的优化在很多时候比人类刻意去写的所谓“优化”后的汇编代码更优,这种简单的“死代码消除”对它来说实在是小菜一碟,它会在运行速度和代码体积之间进行合理的折衷(也可以通过用户传递的选项手动设置)。