作者:Allen B. Downey
原文:Chapter 1 Compilation
译者:飞龙
协议:CC BY-NC-SA 4.0
人们通常把编程语言描述为编译语言或者解释语言。前者的意思是程序被翻译成机器语言,之后由硬件执行;而后者的意思是程序被软件解释器读取并执行。例如,C被认为是编译语言,而Python被认为是解释语言。但是二者之间的界限并不总是那么明显。
首先,许多语言既可以编译执行也可以解释执行。例如,存在C的解释器,和Python的编译器。其次,类似Java的语言混合了这两种方法,它先把程序编译成中间语言,之后在解释器中执行转换后的程序。Java使用了一种叫做“Java 字节码”的中间语言,它类似于机器语言,但是由软件解释器执行,即Java虚拟机(JVM)。
所以,编译执行或解释执行并不是语言的内在特征。尽管如此,在编译语言和解释语言之间有一些普遍的差异。
许多解释语言都支持动态类型,但是编译语言通常限制为静态类型。在静态类型的语言中,你可以通过观察程序,来分辨出每个变量都指向哪种类型。在动态类型的语言中,直到运行起来,你才能知道变量的类型。通常,“静态”指那些在编译时发生的事情,而“动态”指在运行时发生的事情。
例如,在Python中你可以像这样编写函数:
def add(x, y): return x + y
观察这段代码,你不能分辨出x和y所指向的类型。这个函数在运行时可能会调用数次,每次都接受不同类型的值。任何支持加法操作的值都是有效的,任何其它类型的值都会引发异常,或者“运行时错误”。
x
y
C中你可以像这样编写同样的函数:
int add(int x, int y) { return x + y; }
函数的第一行包含了参数及返回值的“类型声明”:x和y都声明为整数,这意味着我们可以在编译时检查加法操作对该类型是否合法(是的)。返回值也声明为整数。
由于这些类型声明,当函数在程序其它位置调用时,编译器就可以检查所提供的参数是否具有正确类型,以及返回值是否使用正确。
这些检查在程序开始运行之前发生,所以可以更快地找到错误。更重要的是,程序永远不会运行的一部分中也可以找到错误。而且,这些检查不必发生于运行期间,这也是编译语言通常快于解释语言的原因之一。
编译时的类型声明也会节省空间。在动态语言中,变量的名称在程序运行时储存在内存中,并且它们通常可由程序访问。例如,在Python中,内建的locals函数返回含有变量名称和值的字典。下面是Python解释器中的一个示例:
locals
>>> x = 5 >>> print locals() {'x': 5, '__builtins__': <module '__builtin__' (built-in)>, '__name__': '__main__', '__doc__': None, '__package__': None}
这段代码表明,变量的名称在程序运行期间储存在内存中(以及其它作为默认运行时环境一部分的值)。
在编译语言中,变量的名称只存在于编译时,而不是运行时。编译器为每个变量选择一个位置,并记录这些位置作为所编译程序的一部分[1]。变量的位置被称为“地址”。在运行期间,每个变量的值都储存在它的地址处,但是变量的名称完全不会储存(除非它们由于调试目的被编译器添加)。
[1] 这只是一个简述,之后我们会深入了解更多细节。
作为程序员,你应该对编译期间发生的事情有所认识。如果你理解了这个过程,它会帮助你解释错误信息,调试你的代码,以及避免常见的陷阱。
下面是编译的步骤:
#include
通常当你运行gcc时,它会执行上述所有步骤,并且生成一份可执行文件。例如,下面是一个小型的C语言程序:
gcc
#include <stdio.h> int main() { printf("Hello World\n"); }
如果你把它保存在名为hello.c的文件中,你可以像这样编译并运行它:
hello.c
$ gcc hello.c $ ./a.out
通常,gcc将可执行代码储存在名为a.out的文件中(它原本代表汇编器的输出,即“assembler output”)。第二行运行了这个可执行文件。./前缀告诉shell在当前目录中寻找它。
a.out
./
使用-o选项来为可执行文件提供一个更好的名字,通常是个不错的主意。
-o
$ gcc hello.c -o hello $ ./hello
-c选项告诉gcc编译程序并生成机器码,但是不链接它们或生成可执行文件:
-c
$ gcc hello.c -c
执行结果是名为hello.o的文件,其中o代表“目标代码”(object code),它就是编译后的程序。目标代码并不是可执行代码,但是它可以链接到可执行文件中。
hello.o
o
nm UNIX命令可以读取目标文件并生成关于它所定义和所使用的名称的信息。例如:
nm
$ nm hello.o 0000000000000000 T main U puts
输出显示,hello.o定义了main名称,并使用了puts函数,它代表“输出字符串”(put string)。在这个例子中,gcc通过将printf替换掉执行了优化,它是一个复杂的大型函数。而puts相对来说比较简单。
main
puts
printf
你可以使用-O选项来控制gcc优化的程度。通常,它执行非常细微的优化,可以使调试更加容易。-O1选项会开启最为普通和安全的优化。更高的数值开启需要长时间编译的高级优化。
-O
-O1
理论上,优化除了加速运行之外,不应改变程序的行为。但是如果你的程序中有微妙的bug,你可能会发现,优化会使bug出现或消失。在开发新的代码时,关闭优化通常是一个不错的主意。一旦程序正常运行并通过了适当的测试,你可以开启优化,并确保测试仍然能够通过。
和-c选项类似。-S告诉gcc编译程序并生成汇编代码,它通常为机器代码的可读形式。
-S
$ gcc hello.c -S
执行结果是名为hello.s的文件,它可能看起来是这样:
hello.s
.file "hello.c" .section .rodata .LC0: .string "Hello World" .text .globl main .type main, @function main: .LFB0: .cfi_startproc pushq %rbp .cfi_def_cfa_offset 16 .cfi_offset 6, -16 movq %rsp, %rbp .cfi_def_cfa_register 6 movl $.LC0, %edi call puts movl $0, %eax popq %rbp .cfi_def_cfa 7, 8 ret .cfi_endproc .LFE0: .size main, .-main .ident "GCC: (Ubuntu/Linaro 4.7.3-1ubuntu1) 4.7.3" .section .note.GNU-stack,"",@progbits
gcc通常为你所运行的机器生成代码,所以对我来说它生成x86汇编代码,运行在Intel、AMD和许多其它处理器上面。如果你运行在不同的架构上,你会看到不同的代码。
在编译过程中再往前退一步,你可以使用-E选项来只运行预处理器:
-E
$ gcc hello.c -E
执行结果就是预处理器的输出。这个例子中,它含有来自stdio.h的被包含代码,和stdio.h所包含的所有文件,还有这些文件所包含的所有文件,以及其它。在我的机器上,共计800行代码。因为几乎每个C语言程序都会包含stdio.h,这800行代码经常会被编译。如果你像大多数C程序那样也包含了stdlib.h,结果会变成多于1800行代码。
stdio.h
stdlib.h
既然我们知道了编译过程的步骤,理解错误消息就变得十分容易。例如,如果#include指令中出现了一个错误,你会从预处理器处得到一个错误:
hello.c:1:20: fatal error: stdioo.h: No such file or directory compilation terminated.
如果有语法错误,你会从编译器处得到一个错误:
hello.c: In function 'main': hello.c:6:1: error: expected ';' before '}' token
如果你使用了没有在任何标准库中定义的函数,你会从链接器处得到一个错误:
/tmp/cc7iAUbN.o: In function `main': hello.c:(.text+0xf): undefined reference to `printff' collect2: error: ld returned 1 exit status
ld是UNIX链接器的名称,这样命名是因为“装载”(loading)是编译过程中的另一个步骤,它和链接关系密切。
ld
一旦程序运行起来,C会执行非常少的运行时检测,所以你会看到极少的运行时错误。如果你发生了除零错误,或者执行了其它非法的浮点操作,你会得到“浮点数异常”。而且,如果你尝试读写内存的不正确位置,你会得到“段错误”。
Copyright© 2013-2020
All Rights Reserved 京ICP备2023019179号-8