跳转到内容

编程语言简介/编译程序

来自维基教科书,开放的书籍,开放的世界

我们有不同的方法来执行程序。 编译器解释器虚拟机 是我们用来完成此任务的一些工具。所有这些工具都提供了一种在硬件中模拟程序语义的方法。虽然这些不同的技术存在于相同的核心目的 - 执行程序 - 但它们以非常不同的方式执行。它们都具有优点和缺点,在本节中,我们将更仔细地研究这些权衡。在我们继续之前,必须说明一个重要的一点:原则上,任何编程语言都可以编译或解释。但是,一些执行策略在某些语言中比在其他语言中更自然。

编译程序

[编辑 | 编辑源代码]

编译器是将高级编程语言翻译成 低级编程语言 的计算机程序。编译器的产出是一个 可执行 文件,它由以特定 机器代码 编码的指令组成。因此,可执行程序是特定于某种类型的 计算机体系结构 的。为不同编程语言设计的编译器可能大不相同;然而,它们都倾向于具有下图所示的整体宏观架构

编译器有一个 前端,它负责将用高级源语言编写的程序转换为编译器将在后续阶段处理的中间表示。我们在前端进行输入程序的 解析,正如我们在前两章中所见。一些编译器,如 gcc 可以解析几种不同的输入语言。在这种情况下,编译器为它可以处理的每种语言都有一个不同的前端。编译器也有一个 后端,它执行代码生成。如果编译器可以针对许多不同的 计算机体系结构,那么它将为每个体系结构提供不同的后端。最后,编译器通常会做一些 代码优化。换句话说,它们试图在特定效率标准下(例如速度、空间或能耗)改进程序。通常,优化器不允许更改输入程序的语义。

通过编译执行的主要优势是速度。因为源程序直接翻译成机器代码,所以该程序很可能比解释它更快。然而,正如我们将在下一节中看到的,一个解释程序运行得比它的机器代码等效程序更快仍然是可能的,尽管不太可能。通过编译执行的主要缺点是可移植性。编译程序针对的是特定类型的计算机体系结构,无法在不同的硬件上运行。

编译程序的生命周期

[编辑 | 编辑源代码]

一个典型的 C 程序,例如由 gcc 编译,在硬件中执行之前将经历许多转换。这个过程类似于一个 生产线,其中一个阶段的输出成为下一个阶段的输入。最终,生成了最终产品,即可执行程序。这条长链通常对程序员来说是不可见的。如今,集成开发环境 (IDE) 将编译过程中的多个工具组合成一个单一的执行环境。但是,为了演示编译器的运行方式,我们将展示使用 gcc 编译标准 C 文件执行时存在的阶段。这些阶段、它们的产出以及一些工具示例在下图中进行了说明。

上述步骤的目的是将源文件翻译成计算机可以运行的代码。首先,程序员使用 文本编辑器 创建一个源文件,其中包含用高级编程语言编写的程序。在本例中,我们假设是 C。这里可以使用各种文本编辑器。其中一些提供支持的形式,例如 语法高亮集成调试器。假设我们刚刚编辑了以下文件,我们想要编译它

#define CUBE(x) (x)*(x)*(x)
int main() {
  int i = 0;
  int x = 2;
  int sum = 0;
  while (i++ < 100) {
    sum += CUBE(x);
  }
  printf("The sum is %d\n", sum);
}

在编辑 C 文件之后,使用 预处理器 来扩展源代码中存在的 。宏展开在 C 中是一个相对简单的任务,但在 lisp 等语言中可能非常复杂,例如,它们负责避免宏展开中常见的诸如 变量捕获 等问题。在展开阶段,宏的代码体替换程序源代码中每次出现宏名称的地方。我们可以通过类似 gcc -E f0.c -o f1.c 的命令来调用 gcc 的预处理器。预处理我们示例程序的结果是下面的代码。请注意,调用 CUBE(x) 已被表达式 (x)*(x)*(x) 替换。

int main() {
  int i = 0;
  int x = 2;
  int sum = 0;
  while (i++ < 100) {
    sum += (x)*(x)*(x);
  }
  printf("The sum is %d\n", sum);
}

在下一阶段,我们将源程序转换为 汇编 代码。这个阶段是我们通常所说的编译:用 C 语法编写的文本将被转换为用 x86 汇编语法编写的程序。在这个步骤中,我们执行 C 程序的解析。在 Linux 中,我们可以通过命令 cc1 f1.c -o f2.s 将源文件(例如 f1.c)翻译成汇编,假设 cc1 是系统的编译器。此命令等效于调用 gcc -S f1.c -o f2.s。汇编程序可以在下图左侧看到。该程序是用 x86 体系结构中使用的汇编语言编写的。存在许多不同的计算机体系结构,例如 ARMPowerPCAlpha。为其中任何一个生成的汇编语言将与下面的程序大不相同。为了比较,我们在图的右侧打印了同一程序的 ARM 版本。这两种汇编语言遵循非常不同的设计理念:x86 使用 CISC 指令集,而 ARM 更紧密地遵循 RISC 方法。然而,这两个文件,x86 的和 ARM 的,都有类似的语法骨架。汇编语言具有线性结构:程序是一个类似列表的指令序列。另一方面,C 语言具有更像树的语法结构,正如我们在之前的 章节 中所见。由于这种语法差异,这个阶段包含程序在其生命周期中将经历的最复杂的转换步骤。

# Assembly of x86                           # Assembly of ARM
  .cstring                                  _main:
LC0:                                        @ BB#0:
  .ascii "The sum is %d\12\0"                 push	{r7, lr}
  .text                                       mov	r7, sp
.globl _main                                  sub	sp, sp, #16
_main:                                        mov	r1, #2
  pushl   %ebp                                mov	r0, #0
  movl    %esp, %ebp                          str	r0, [r7, #-4]
  subl    $40, %esp                           str	r0, [sp, #8]
  movl    $0, -20(%ebp)                       stm	sp, {r0, r1}
  movl    $2, -16(%ebp)                       b	LBB0_2
  movl    $0, -12(%ebp)                     LBB0_1:
  jmp     L2                                  ldr	r0, [sp, #4]
L3:                                           ldr	r3, [sp]
  movl    -16(%ebp), %eax                     mul	r1, r0, r0
  imull   -16(%ebp), %eax                     mla	r2, r1, r0, r3
  imull   -16(%ebp), %eax                     str	r2, [sp]
  addl    %eax, -12(%ebp)                   LBB0_2:
L2:                                           ldr	r0, [sp, #8]
  cmpl    $99, -20(%ebp)                      add	r1, r0, #1
  setle   %al                                 cmp	r0, #99
  addl    $1, -20(%ebp)                       str	r1, [sp, #8]
  testb   %al, %al                            ble	LBB0_1
  jne     L3                                @ BB#3:
  movl    -12(%ebp), %eax                     ldr	r0, LCPI0_0
  movl    %eax, 4(%esp)                       ldr	r1, [sp]
  movl    $LC0, (%esp)                      LPC0_0:
  call    _printf                             add	r0, pc, r0
  leave                                       bl	_printf
  ret                                         ldr	r0, [r7, #-4]
                                              mov	sp, r7
                                              pop	{r7, lr}
                                              mov	pc, lr

在从高级语言到汇编语言的翻译过程中,编译器可能会应用代码优化。这些优化必须遵守源程序的语义。优化的程序应该与原始版本做相同的事情。如今,编译器非常擅长以提高效率的方式更改程序。例如,两个众所周知的优化(循环展开和常量传播)的组合可以优化我们的示例程序,以至于循环被完全删除。例如,假设 `cc1` 是 gcc 使用的默认编译器,我们可以使用以下命令运行优化器:`cc1 -O1 f1.c -o f2.opt.s`。我们这次生成的最终程序 `f2.opt.s` 出乎意料地简洁。

  .cstring
LC0:
  .ascii "The sum is %d\12\0"
  .text
.globl _main
_main:
  pushl	%ebp
  movl	%esp, %ebp
  subl	$24, %esp
  movl	$800, 4(%esp)
  movl	$LC0, (%esp)
  call	_printf
  leave
  ret

编译链的下一步是将汇编语言翻译成二进制代码。汇编程序仍然可以被人阅读。二进制程序,也称为目标文件,当然也可以被人阅读,但如今很少有人能胜任这项工作。从汇编语言到二进制代码的翻译是一项相当简单的任务,因为这两种语言具有相同的句法结构。只有它们的词法结构不同。汇编文件是用 ASCII [助记符] 编写的,而二进制文件包含硬件处理器识别的零和一的序列。此阶段使用的典型工具是 `as` 汇编器。我们可以使用以下命令生成目标文件:`as f2.s -o f3.o`。

目标文件目前还不可执行。它不包含足够的信息来指定在哪里可以找到 `printf` 函数的实现,例如。在编译过程的下一步中,我们更改此文件,以便外部库中定义的函数的地址可见。每个操作系统都为程序员提供一些可以与他们创建的代码一起使用的库。一种特殊的软件,称为链接器,可以找到这些库中函数的地址,从而修复目标文件中的空白地址。不同的操作系统使用不同的链接器。在这种情况下,一个典型的工具是 `ld` 或 `collect2`。例如,为了在运行 Leopard 的 Mac OS 上生成可执行程序,我们可以使用以下命令:`collect2 -o f4.exe -lcrt1.10.5.o f3.o -lSystem`。

此时,我们几乎拥有了一个可执行文件,但我们的链接的二进制程序在我们可以看到其输出之前必须经过最后一次转换。二进制代码中的所有地址都是相对的。我们必须用绝对值替换这些地址,这些地址正确地指向函数调用的目标和其他程序对象。最后一步由一个名为加载器的程序负责。加载器将程序映像转储到内存并运行它。

语法制导类型检查 · 解释性程序

华夏公益教科书