WangYu::Space

Study, think, create, and grow. Teach yourself and teach others.

C 语言编译流程

分类:编译与链接创建时间:2020-07-19 00:00:00

我们写的 C/C++ 程序,是如何经过编译器的处理并得到可执行程序的呢?预处理、编译、链接具体做了些什么呢?动态链接、静态链接又有什么区别呢?本文将尝试对这些问题做一个解答。

0. 编译流程

通常我们所说的“编译”是指由 C/C++ 源文件到可执行程序的过程,实际上这个过程包含了多个步骤:

下面以这个最简单的 hello world 程序为例,来剖析编译链接的全过程。

// main.c
#include <stdio.h>

int main(int argc, char **argv) {
    printf("hello world\n");
    return 0;
}

我们通常可以使用一行命令完成上图中的全部过程:

$ gcc main.c
$ ./a.out
hello world

gcc 实际上是一组工具的驱动程序,它调用多个其他工具协同完成整个编译过程。接下来我将分步执行,以此来观察每一步都做了些什么。

1. 预处理

预处理过程中,预处理器会处理 #include、#define、#if 等等预处理指令。比如会将 #include 指定的头文件包含进来,对 #define 的宏做替换。

$ cpp main.c -o main.i
# 或者
$ gcc -E main.c -o main.i

预处理后,stdio.h 中的内容被包含在了 main.i 中,整个文件包含 800 多行。因为内容过多,这里只贴部分内容:

# 1 "main.c"
# 1 "<built-in>"
# 1 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 1 "<command-line>" 2
# 1 "main.c"
# 1 "/usr/include/stdio.h" 1 3 4
# 27 "/usr/include/stdio.h" 3 4
# 1 "/usr/include/features.h" 1 3 4

...

# 1 "/usr/include/bits/types.h" 1 3 4
# 27 "/usr/include/bits/types.h" 3 4
# 1 "/usr/include/bits/wordsize.h" 1 3 4
# 28 "/usr/include/bits/types.h" 2 3 4


typedef unsigned char __u_char;
typedef unsigned short int __u_short;
typedef unsigned int __u_int;
typedef unsigned long int __u_long;

...

# 2 "main.c" 2

int main(int argc, char **argv) {
    printf("hello world\n");
    return 0;
}

可以看到,预处理后头文件中的内容被插入到单个文件中,另外还多了一些以 # 开头的行,这些行的格式如下:

# linenum filename flags

filename 文件的路径,可以看到头文件被替换成了绝对路径。linenum 是指下面的一行出现在 filename 中的第几行。举个例子:

# 1 "main.c"
# 1 "/usr/include/stdio.h" 1 3 4

这意思是指 /usr/include/stdio.h 中的内容出现在 main.c 的第一行。

# 27 "/usr/include/stdio.h" 3 4
# 1 "/usr/include/features.h" 1 3 4

这是指 /usr/include/features.h 的内容出现在 stdio.h 的第 27 行,打开 stdio.h 可以看到如下内容:

/*
 *	ISO C99 Standard: 7.19 Input/output	<stdio.h>
 */

#ifndef _STDIO_H

#if !defined __need_FILE && !defined __need___FILE
# define _STDIO_H	1
# include <features.h>   // <- 第 27 行

文件路径后面的数字是什么意思呢?

预处理器处理了 #include、#define、#if 等预处理指令,它为什么要插入这些以 # 开头的内容呢?其中一个作用是确定预处理后的每一行代码最初位于那个文件中。另外 flags 是给后续的编译提供一些参数,比如对于系统头文件,默认就不打印编译警告。

关于预处理的输出格式,可以阅读此文档了解详情。

2. 编译

编译是将 C 源文件编译为汇编指令,可以使用如下命令来执行编译:

$ cc1 main.i -o main.s
# 或
$ gcc -S main.i -o main.s

编译后的结果如下:

	.file	"main.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
	subq	$16, %rsp
	movl	%edi, -4(%rbp)
	movq	%rsi, -16(%rbp)
	movl	$.LC0, %edi
	call	puts
	movl	$0, %eax
	leave
	.cfi_def_cfa 7, 8
	ret
	.cfi_endproc
.LFE0:
	.size	main, .-main
	.ident	"GCC: (GNU) 4.8.3"
	.section	.note.GNU-stack,"",@progbits

观察上面的编译结果,其中 printf 函数调用被编译为 call puts(因为前面调用 printf 只有一个参数,因此编译器做了些优化),但编译后的结果中看不到 puts 函数的实现细节。实际上,puts 函数的内容在 glibc 的动态库或静态库中。

在编译的过程中,不需要被调用函数的实现细节,仅仅需要函数的声明即可,这是为了确定在调用时候如何传参。如果使用了结构体,则需要结构体完整的定义,这是为了得到结构体的大小以及各个字段的偏移量。但如果只使用了结构体指针,且未访问结构体中的字段,则可以不用提供结构体的定义,只需要有个声明即可。

编译结果中包含一些类似 .cfi_def_* 的指令,其中 cfi 是 Call Frame Information 的缩写,用来说明调用栈帧的一些属性,这里我们忽略他们。

3. 汇编

汇编过程则是将汇编指令翻译为机器指令,得到的 *.o 文件我们称之为目标文件。

$ as main.s -o main.o
# 或
$ gcc -c main.s -o main.o

因为目标文件中包含机器指令,不再是文本格式,可以使用 objdump 观察其中内容:

$ objdump -d main.o

main.o:     file format elf64-x86-64


Disassembly of section .text:

0000000000000000 <main>:
   0:   55                      push   %rbp
   1:   48 89 e5                mov    %rsp,%rbp
   4:   48 83 ec 10             sub    $0x10,%rsp
   8:   89 7d fc                mov    %edi,-0x4(%rbp)
   b:   48 89 75 f0             mov    %rsi,-0x10(%rbp)
   f:   bf 00 00 00 00          mov    $0x0,%edi
  14:   e8 00 00 00 00          callq  19 <main+0x19>
  19:   b8 00 00 00 00          mov    $0x0,%eax
  1e:   c9                      leaveq 
  1f:   c3                      retq

这里使用 -d 选项对 .text 段做了反汇编。.data 或者 .rodata 段中因为存储的并不是指令,因此使用反汇编观察是无意义的。

可以看到,源文件中的函数 puts 调用在目标文件中被翻译为指令 e8 00 00 00 00,其中 e8 是 callq 的 opcode 指令,其后的四个字节,是要调用的函数地址。此处地址是 0,这是怎么回事?因为以上过程参与编译与汇编的仅仅只有一个文件而已,其中调用的库函数在标准库的动态库或静态库中,在生成目标文件时候,不知道 puts 的地址,这里将地址设为 0,仅仅是占位,这个地址会在链接的时候被链接器填上 puts 函数真正的位置。

可以这里并没有 puts 函数的任何信息,链接器如何知道该把 e8 后面的四个字节替换为那个函数的地址呢?请继续往后看。

4. 链接

可以使用如下命令执行链接操作,但实际上链接是使用 ld 命令完成的。

$ gcc main.o -o main

目标文件是数据和函数的集合,一个目标文件可能引用其他目标文件中的数据和函数。链接是将多个目标文件中的数据和函数合并起来,构成可执行程序的过程。

一个函数可能调用其他 .c 文件中的函数,在编译、汇编阶段,参与的单个文件,此时自然不能得知被函数的函数的细节,我们仅仅通过头文件中的声明得知函数的参数返回值等信息。

参与链接的是所有的目标文件和动态库、静态库,此时可以将各目标文件中的函数和数据合并起来,这样函数的地址、变量的地址就都确定了,此时就可以把函数调用的地址修改为真实的地址了。

关于链接,我可以举个例子来说明其原理。假如我正在写一本书,在某些页面我引用了前文后即将产生的后文中的内容,但此时因为此时书籍尚未成型,我不知道引用的内容最终会出现在书中哪一页,这个时候我该怎么办呢?

这个问题很好办,给待引用的内容加个标签,引用的时候不引用其页码,而是引用这个标签。当全书写完后,把所有页面装订在一起,此时各个页面的页码就确定了,此时将引用的标签替换为实际的页面即可。

在编译时候,发现调用了尚未定义的函数,这就相当于我们引用了其他页面的内容。编译的时候只需要记下一个标签即可,即记下函数名即可。而链接,就像是将书中页面装订成册,然后将引用的标签替换为实际页码。可见整个编译过程似乎没什么神秘的,它就像把散落的书稿装订成册一样。

总结

本文从宏观上讲了编译链接的大致原理,其中关于链接的部分其实并不完全准确,因为链接涉及到动态链接和静态链接,它们的原理是不同的。链接需要较大的篇幅来讲,我将新写文章介绍。

评论 (评论内容仅博主可见,不会公开显示)