Administrator
发布于 2024-03-11 / 11 阅读
0
0

编译过程<1>(编译和链接)

编译过程<1>(编译和链接)

前言

一个c语言文件是怎么转换成 可执行文件的呢? 中间有什么过程呢? 编译器 起着什么作用呢?

如何转换可执行文件?

我们编译的时候要么用 ide 帮我编译程序,要么自己敲命令,我们后者为例,使用gcc 编译一个c文件


hrp@ubuntu:~$ cat hello.c
#include <stdio.h>
int main() {
	  printf("你好,世界!\n");
	  //这是注释!!!
	  return 0;

}

hrp@ubuntu:~$ gcc hello.c -o hello
hrp@ubuntu:~$ ./hello
你好,世界!
hrp@ubuntu:~$

一个很简单的代码,使用 gcc 编译就可以到 二进制可执行文件,并执行;那这之间发生了什么呢?

整个编译流程可以分为4个部分

1. 预处理 (Preprocessing) ") 1\. 预处理 (Preprocessing)

在预处理阶段,预处理器会处理源代码文件,执行诸如宏展开、头文件包含等操作,并生成一个经过预处理的中间文件

比如代码里面用到头文件 stdio.h #include <stdio.h> ,预编译的时候则会找到对应的 这个文件所在的目录; 如果有用到宏定义,会将代码中使用到的宏替换成对应的字符

预编译可以单独执行, gcc -E 即可以看到,原本的c语言文件 预处理后是什么样子的


hrp@ubuntu:~$ cat hello.c
#include <stdio.h>
#define PI 3.14159

int main() {
	int i = PI * PI;
	printf("你好,世界!\n");
    return 0;
}
hrp@ubuntu:~$
hrp@ubuntu:~$ gcc -E hello.c -o hello.i

  • \\查看 hello.i 发现 #include <stdio.h> 已经变成了 绝对路径 “/usr/include/stdio.h” \\
  • 删除了define PI的宏 也替换成了3.14159
  • 删除了注释,c源码中的注释也没有

#....
...
# 868 "/usr/include/stdio.h" 3 4
# 2 "hello.c" 2
# 5 "hello.c"
int main() {
 int 10i = 3.14159 * 3.14159;
 printf("你好,世界!\n");
         return 0;
}

2 编译 (Compiling) ") 2 编译 (Compiling)

编译器接收预处理后的文件,并将其转换为汇编语言,这个过程也是最为复杂一环,需要编译器的来做一些 词法分析词法分析 等相关工作(后面会分析);


hrp@ubuntu:~$  gcc -S hello.i -o hello.s && cat hello.s
	.file	"hello.c"
	.text
	.section	.rodata
.LC0:
	.string	"\344\275\240\345\245\275\357\274\214\344\270\226\347\225\214\357\274\201"
	.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	$9, -4(%rbp)
	leaq	.LC0(%rip), %rdi
	call	puts@PLT
	movl	$0, %eax
	leave
	.cfi_def_cfa 7, 8
	ret
	.cfi_endproc
.LFE0:
	.size	main, .-main
	.ident	"GCC: (Ubuntu 7.5.0-3ubuntu1~18.04) 7.5.0"
	.section	.note.GNU-stack,"",@progbits
hrp@ubuntu:~$

预编译和编译这两个操作可以合并,是用gcc 自带的程序 ( cc1)生成 汇编文件 , cc1 会输出编译器执行过程中不同阶段的时间统计:


hrp@ubuntu:~$ /usr/lib/gcc/x86_64-linux-gnu/7/cc1 hello.c
 main
Analyzing compilation unit
Performing interprocedural optimizations
 <*free_lang_data> <visibility> <build_ssa_passes> <opt_local_passes> <targetclone> <free-inline-summary> <whole-program> <inline>Assembling functions:
 <materialize-all-clones> <simdclone> main
Execution times (seconds)
 phase setup             :   0.00 ( 0%) usr   0.00 ( 0%) sys   0.00 ( 0%) wall    1179 kB (68%) ggc
 phase parsing           :   0.00 ( 0%) usr   0.04 (100%) sys   0.05 (83%) wall     488 kB (28%) ggc
 phase opt and generate  :   0.00 ( 0%) usr   0.00 ( 0%) sys   0.01 (17%) wall      56 kB ( 3%) ggc
 dump files              :   0.00 ( 0%) usr   0.00 ( 0%) sys   0.01 (17%) wall       0 kB ( 0%) ggc
 lexical analysis        :   0.00 ( 0%) usr   0.03 (75%) sys   0.05 (83%) wall       0 kB ( 0%) ggc
 parser (global)         :   0.00 ( 0%) usr   0.01 (25%) sys   0.00 ( 0%) wall     320 kB (18%) ggc
 TOTAL                 :   0.00             0.04             0.06               1733 kB

3 汇编 (Assembling) ") 3 汇编 (Assembling)

汇编器接收汇编语言代码,并将其转换为机器码,生成目标文件(xxx.o);每条汇编命令都有相对于的机器码,只需逐条翻译即可;

可以使用 as 命令,也可以使用 gcc -c 命令


hrp@ubuntu:~$
hrp@ubuntu:~$ as hello.s -o hello.o
// 对于 目标文件可以用 objdump 反汇编查看
hrp@ubuntu:~$ objdump -d hello.o
hello.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:	c7 45 fc 09 00 00 00 	movl   $0x9,-0x4(%rbp)
   f:	48 8d 3d 00 00 00 00 	lea    0x0(%rip),%rdi        # 16 <main+0x16>
  16:	e8 00 00 00 00       	callq  1b <main+0x1b>
  1b:	b8 00 00 00 00       	mov    $0x0,%eax
  20:	c9                   	leaveq
  21:	c3                   	retq
hrp@ubuntu:~$
hrp@ubuntu:~$ gcc -c hello.s  -o hello.o
hrp@ubuntu:~$ objdump -d hello.o

hello.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:	c7 45 fc 09 00 00 00 	movl   $0x9,-0x4(%rbp)
   f:	48 8d 3d 00 00 00 00 	lea    0x0(%rip),%rdi        # 16 <main+0x16>
  16:	e8 00 00 00 00       	callq  1b <main+0x1b>
  1b:	b8 00 00 00 00       	mov    $0x0,%eax
  20:	c9                   	leaveq
  21:	c3                   	retq
hrp@ubuntu:~$

4 链接 (Linking) ") 4 链接 (Linking)

链接器接收一个或多个目标文件,将它们合并在一起,并解析它们之间的符号引用, 生成可执行文件


hrp@ubuntu:~$ gcc hello.o -o hello
hrp@ubuntu:~$ ./hello
你好,世界!
hrp@ubuntu:~$

经历了这4个步骤就完成了从c语言文本到可执行文件的转换

编译器具体做了什么?

简单的来说,编译器的工作就是把自然语言转换为机器语言,可以理解就是一个 翻译官,而且这个翻译官做了翻译的被背后边很多优化! 翻译的过程可以分为以下六个步骤

词法分析(Lexical Analysis) 词法分析(Lexical Analysis)

词法分析器会跟据不同语言的此法规则,将源代码分解成一个个的词法单元(tokens),如关键字、标识符、运算符;

从左往右逐个字符地扫描源程序,产生一个个的单词符号。也就是说,它会对输入的字符流进行处理,再输出单词流。执行词法分析的程序即 词法分析器,或者说扫描器。

语法分析(Syntax Analysis) 语法分析(Syntax Analysis)

语法分析器根据词法分析器得到的词法单元,构建出抽象语法树(Abstract Syntax Tree,AST),表示程序的结构。

语义分析(Semantic Analysis) 语义分析(Semantic Analysis)

语义分析器检查代码的语义正确性,包括类型匹配、变量声明、函数调用等(检查你的代码有咩有按照规则来写)

优化(Optimization) 优化(Optimization)

优化器对生成的抽象语法树进行各种优化操作,如优化一些汇编指令、循环优化等,以提高程序的执行效率

代码生成(Code Generation) 代码生成(Code Generation)

代码生成器将优化后的抽象语法树转换成目标代码,通常是汇编代码或者机器代码。

链接(Linking) 链接(Linking)

如果编译器生成的是目标文件而不是可执行文件,那么链接器会将多个目标文件链接成一个可执行文件。链接器还负责解析外部函数和变量的引用,将其与其他文件中的定义进行连接。

前面为五个步骤是生成了 目标文件,目标文件只要语法正确都可以生成

比如 hello.c 没有main 函数,但是也可以生成目标文件


hrp@ubuntu:~$ cat hello.c
#include <stdio.h>

void print_hello_1() {
	    printf("print_hello_1  function!\n");
}

void print_hello_2() {
	    printf("print_hello_1  function!\n");
}
hrp@ubuntu:~$
hrp@ubuntu:~$ gcc -c hello.c -o hello.o
hrp@ubuntu:~$ objdump -d hello.o
hello.o:     file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <print_hello_1>:
   0:	55                   	push   %rbp
   1:	48 89 e5             	mov    %rsp,%rbp
   4:	48 8d 3d 00 00 00 00 	lea    0x0(%rip),%rdi        # b <print_hello_1+0xb>
   b:	e8 00 00 00 00       	callq  10 <print_hello_1+0x10>
  10:	90                   	nop
  11:	5d                   	pop    %rbp
  12:	c3                   	retq
0000000000000013 <print_hello_2>:
  13:	55                   	push   %rbp
  14:	48 89 e5             	mov    %rsp,%rbp
  17:	48 8d 3d 00 00 00 00 	lea    0x0(%rip),%rdi        # 1e <print_hello_2+0xb>
  1e:	e8 00 00 00 00       	callq  23 <print_hello_2+0x10>
  23:	90                   	nop
  24:	5d                   	pop    %rbp
  25:	c3                   	retq

我需要调用 hello.c 里面的 print_hello_1 函数应该怎么做呢?(其实这种场景很常见,一个项目中可能会分为很多功能模块,主函数会调用其他模块的函数)

我在 main.c 文件里面调用了 print\_hello\_1 函数,这main.c 文件也是可以生成目标文件,可以可能到有 cllaq 跳转指令 , callq 指令的作用是跳转到目标函数的地址,从反汇编文件看, 是跳转到了 目标地址为 e 的函数,目前只是单独编译 main.c 的文件,还没有涉及到 hello.c 文件;

最后 gcc -c 生成可执行文件,执行结果符合预期,调用了 print_hello_1 , 那问题来了,main.o 中 callq e <main+0xe> ,是怎么跳转到 hello.o 中的print\_hello\_1呢?

为了解决这个问题,编译器最后会把 多个模块根据一定的规则,以及主函数需要调用哪些模块的代码,将其组合到一起,最后形成可执行文件,这做法也就是 链接


hrp@ubuntu:~$ cat main.c
#include <stdio.h>
// 声明外部函数
 extern void print_hello();

 int main() {
      print_hello_1();
      return 0;
      }

hrp@ubuntu:~$ gcc -c main.c -o main.o -w
hrp@ubuntu:~$ 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:	b8 00 00 00 00       	mov    $0x0,%eax
   // 在 callq
   9:	e8 00 00 00 00       	callq  e <main+0xe>
   e:	b8 00 00 00 00       	mov    $0x0,%eax
  13:	5d                   	pop    %rbp
  14:	c3                   	retq
hrp@ubuntu:~$
hrp@ubuntu:~$ gcc -c main.o hello.o -o main
hrp@ubuntu:~$ ./main
print_hello_1  function!

链接

背景

在汇编语言出现之前,当时没有内存之类的存储介质,程序是以打孔的形式存放在纸带上,计算机在运行程序时会直接从纸带的开头开始读取,每读取到一条指令就执行一条指令。

!image-20240311002322477

然而,程序经常需要进行修改。例如,插入几条指令在第四条指令和第五条指令之间,这会导致第五条指令后面的指令位置发生变化,整个程序的地址都需要重新计算。 工程师必须重新计算并修改纸带上的指令位置,这个过程称为重定位。 如果程序很大,涉及到多个纸带,那么即使是一个简单的修改也会导致大量的工作量。

!image-20240311002259982

后来人们发明了汇编语言,它更易读,开发人员不再需要记住每个机器码。

此外,汇编语言还允许使用符号来表示地址。例如, 可以用符号“foo”代表地址0x001,这样可以通过写” jump foo” 来跳转到地址0x0001的位置,有了这个 语法糖 之后,执行函数,只要知道函数代码的地址,直接用 jump 跳转过去在执行,原本在第五条指令前插入指令,会影响后面的代码,假如第五条指令后的内容,使用 jump XXX 的方式来执行,只要 foo 地址不变化,那我无论在前面加入多少条都不会影响后面的寻址结果,foo 只是符号,就算 foo 目标地址发生了改动,只需要 把foo 指令修正到新的地址即可,这样就拜托了手动跳转地址的工作。

!image-20240311005739608

有了汇编语言之后,编程变得更加容易;一个项目可以被切割成许多模块,这些模块之间既相互依赖又相互独立。那么,模块之间如何组合?如何相互调用?模块间的变量如何访问?这时候链接器就发挥了重要作用,接下来我们将介绍它的功能和作用。

链接的定义

链接(linking)是将各种代码和数据部分收集起来并组合成为一个单一文件的过程,这个文件可被加载(或被拷贝)到存储器并执行; 此外外 链接可以执行于编译时(compile time),也就是在源代码被翻译成机器代码时;也可以执行于加载时,也就是在程序被加载器(loader)加载到存储器并执行时;甚至执行于运行时(run time),这也就是校招面试经常问到的静态编译和动态编译;

!image-20240405092321892

链接 解决的问题是什么呢?

  • 解决了符号引用问题

在编写程序时,经常会引用其他文件中定义的函数、变量或者常量。链接的一个重要作用是将这些引用与其定义进行关联,以确保程序能够正确地调用这些函数、访问这些变量和常量。

  • 合并多个目标文件

当程序由多个源文件编译而成时,每个源文件都会生成一个目标文件。链接器负责将这些目标文件合并成一个可执行文件或者共享库。一些大型项目几十万行代码,总不能每个功能都写在同一个文件中吧?

  • 解决外部依赖问题

在编写程序时,可能会依赖于第三方库或者系统提供的标准库。链接器会将这些外部依赖项与程序的目标文件进行链接,以确保程序能够正确地调用这些外部函数和访问这些外部变量。

  • 优化代码大小和性能

链接器还可以对目标文件进行优化,例如去除未使用的代码、合并相似的代码块等,以减小程序的体积(动态编译)并提高执行效率。

如何链接的(先简单介绍下)

链接的本质是拼接各个模块;比如 main 函数中调用了 hello.c 中的hello() 函数, main 函数必须知道 hello() 的地址,而模块是各自编译的,main 并不知 hello函数地址,于是 就先随便置一个地址,等链接的时候再替换,举个例子:

先直接将main单独 编译成 obj 文件,反汇编,此时 callq 调用的函数只是个 偏移量


hrp@ubuntu:~$ cat main.c
// main.c
#include "hello.h"
int main() {
    hello();
    return 0;
}

hrp@ubuntu:~$ gcc -c main.c
hrp@ubuntu:~$ objdump -d main.o
0000000000000000 <main>:
   0:	55                   	push   %rbp
   1:	48 89 e5             	mov    %rsp,%rbp
   4:	b8 00 00 00 00       	mov    $0x0,%eax
   9:	e8 00 00 00 00       	callq  e <main+0xe>
   e:	b8 00 00 00 00       	mov    $0x0,%eax
  13:	5d                   	pop    %rbp
  14:	c3                   	retq
hrp@ubuntu:~$

生成 hello.o 文件


hrp@ubuntu:~$ gcc -c hello.c

hrp@ubuntu:~$ objdump -d hello.o
0000000000000000 <hello>:
   0:	55                   	push   %rbp
   1:	48 89 e5             	mov    %rsp,%rbp
   4:	48 8d 3d 00 00 00 00 	lea    0x0(%rip),%rdi        # b <hello+0xb>
   b:	e8 00 00 00 00       	callq  10 <  +0x10>
  10:	90                   	nop
hrp@ubuntu:~$

main.o 和 hello.o 链接起来(gcc 背后会调用ld 命令 );反汇编可以可能到是,两个 obj 文件的函数拼接成一个文件,并且更新了 main中 callq 的地址,此时的地址才是真正调用的地址,这个过程也叫重定位;


hrp@ubuntu:~$ gcc hello.o main.o -o main
hrp@ubuntu:~$ ./main
ddHello, world!

hrp@ubuntu:~$ objdump -d main
.....
000000000000068a <hello>:
 68a:	55                   	push   %rbp
 68b:	48 89 e5             	mov    %rsp,%rbp
 68e:	48 8d 3d bf 00 00 00 	lea    0xbf(%rip),%rdi        # 754 <_IO_stdin_used+0x4>
 695:	e8 b6 fe ff ff       	callq  550 <puts@plt>
 69a:	90                   	nop
 69b:	5d                   	pop    %rbp
 69c:	c3                   	retq

000000000000069d <main>:
 69d:	55                   	push   %rbp
 69e:	48 89 e5             	mov    %rsp,%rbp
 6a1:	48 8d 3d ba 00 00 00 	lea    0xba(%rip),%rdi        # 762 <_IO_stdin_used+0x12>
 6b7:	e8 ce ff ff ff       	callq  68a <hello>
 6bc:	b8 00 00 00 00       	mov    $0x0,%eax
 6c1:	5d                   	pop    %rbp
 6c2:	c3                   	retq


评论