Skip to content

C语言预处理、编译、汇编、链接

具体知识在这里:Gcc 编译的背后 - C语言编程透视GCC and Make Compiling, Linking and Building C/C++ Applications

本文只记录具体操作,并查看解析生成的内容。

操作命令工具
预处理gcc -Ecpp
编译gcc -Scc1
汇编gcc -cas
链接gcc -Old

准备文件my_program.c

c
#include <stdio.h>

int main(){
	printf("Hello World!\n");
}

预处理

bash
[root@centos-llvm test] % gcc -E my_program.c -o my_program.i

生成的my_program.i中除了一般的C语言代码,如typedef等等;还有类似

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

的行,这种行被称为linemarkers,其格式表现为:

c
# linenum filename flags

更多信息可以查看Preprocessor Output (The C Preprocessor)

预处理就是对一些宏定义进行替换、增加或删减,即使程序不能通过编译,也可以通过预处理的。

这里的linemarkers我没搞懂怎么看,有什么作用,搞懂的可以联系我讨论。

编译

拿到my_program.i后,即可对其进行编译:

bash
[root@centos-llvm test] % gcc -S my_program.i -o my_program.s

拿到的就是汇编代码了:

assembly
	.file	"my_program.c" 			    # 指定当前汇编文件的原始源文件名为 "my_program.c"
	.text                                       # 表示接下来的指令都是在代码段中,也就是程序执行的部分。
	.section	.rodata		            # 定义一个名为 .rodata 的只读数据段。
.LC0:					            # 定义一个本地标号,表示后面的字符串是 .LC0。
	.string	"Hello World!"			    # 将字符串 "Hello World!" 存放在 .rodata 段中。
	.text					    # 回到代码段。
	.globl	main				    # 声明 main 函数为全局符号,表示这是程序的入口函数。
	.type	main, @function			    # 声明 main 是一个函数。
main:						    # 定义一个本地标号,表示 main 函数的开始。
.LFB0:						    # 定义一个本地标号,表示当前函数块的开始。
	.cfi_startproc				    # 表示开始一个过程。
	pushq	%rbp				    # 将 rbp 寄存器的值压入栈。
	.cfi_def_cfa_offset 16			    # 定义当前的栈偏移量。
	.cfi_offset 6, -16			    # 定义 rbp 寄存器在栈中的偏移。
	movq	%rsp, %rbp			    # 将栈指针 rsp 的值复制给基址指针 rbp。
	.cfi_def_cfa_register 6			    # 定义 rbp 为当前的栈帧指针。
	movl	$.LC0, %edi			    # 将字符串 .LC0 的地址作为参数传递给 puts 函数。
	call	puts				    # 调用 C 库函数 puts 来输出字符串。
	movl	$0, %eax			    # 将返回值设为0。
	popq	%rbp				    # 从栈中弹出 rbp 寄存器的值。
	.cfi_def_cfa 7, 8			    # 定义当前栈帧的寄存器。
	ret				            # 返回到调用方。
	.cfi_endproc			            # 表示结束一个过程。
.LFE0:					            # 定义一个本地标号,表示当前函数块的结束。
	.size	main, .-main		            # 定义 main 函数的大小。
	.ident	"GCC: (GNU) 12.1.0"		    # 用于标识当前汇编代码是由 GCC 版本 12.1.0 编译生成的。
	.section	.note.GNU-stack,"",@progbits # 定义一个段用于控制栈的保护,通常为空。

这段代码实现了一个简单的C程序,调用了C库函数 puts 来输出字符串 "Hello World!"。

和课堂里学的 intel 的汇编语法不太一样,这里用的是 AT&T 语法格式。

汇编

汇编代码翻译成了机器代码,即目标代码,不过它还不可以运行。

bash
[root@centos-llvm test] % gcc -c my_program.s -o my_program.o

可以查看my的类型:

bash
[root@centos-llvm test] % file my_program.o 
my_program.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
  • ELF: Executable and Linkable Format,可执行与可链接格式,指的是一种可执行文件和共享库的格式。
  • 64-bit: 表示这是一个64位的文件格式,适用于64位的系统架构。
  • LSB: 表示该文件遵循 Linux 标准库的规范。
  • relocatable: 表示这是一个可重定位文件,即该文件中的代码和数据是相对地址,并且可以在加载时进行重定位,适用于编译生成静态库或共享库。
  • x86-64: 表示该文件是为 x86-64 架构(即 64 位的 x86 架构)生成的。
  • version 1 (SYSV): 表示该文件遵循 SYSV 版本 1 的规范。
  • not stripped: 表示该文件没有经过剥离(stripping),即还包含有调试信息等。

该描述信息说明了这是一个 64 位的可重定位文件,遵循 Linux 标准库规范,适用于 x86-64 架构的系统。同时,该文件中包含有调试信息等,没有进行剥离操作。

查看一下my_program.o中都有哪些内容:

bash
[root@centos-llvm test] % readelf -S my_program.o 
共有 13 个节头,从偏移量 0x280 开始:

节头:
  [号] 名称               类型             地址               偏移量
       大小              全体大小          旗标   链接   信息   对齐
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .text             PROGBITS         0000000000000000  00000040
       0000000000000015  0000000000000000  AX       0     0     1
  [ 2] .rela.text        RELA             0000000000000000  000001d0
       0000000000000030  0000000000000018   I      10     1     8
  [ 3] .data             PROGBITS         0000000000000000  00000055
       0000000000000000  0000000000000000  WA       0     0     1
  [ 4] .bss              NOBITS           0000000000000000  00000055
       0000000000000000  0000000000000000  WA       0     0     1
  [ 5] .rodata           PROGBITS         0000000000000000  00000055
       000000000000000d  0000000000000000   A       0     0     1
  [ 6] .comment          PROGBITS         0000000000000000  00000062
       0000000000000013  0000000000000001  MS       0     0     1
  [ 7] .note.GNU-stack   PROGBITS         0000000000000000  00000075
       0000000000000000  0000000000000000           0     0     1
  [ 8] .eh_frame         PROGBITS         0000000000000000  00000078
       0000000000000038  0000000000000000   A       0     0     8
  [ 9] .rela.eh_frame    RELA             0000000000000000  00000200
       0000000000000018  0000000000000018   I      10     8     8
  [10] .symtab           SYMTAB           0000000000000000  000000b0
       0000000000000108  0000000000000018          11     9     8
  [11] .strtab           STRTAB           0000000000000000  000001b8
       0000000000000018  0000000000000000           0     0     1
  [12] .shstrtab         STRTAB           0000000000000000  00000218
       0000000000000061  0000000000000000           0     0     1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
  L (link order), O (extra OS processing required), G (group), T (TLS),
  C (compressed), x (unknown), o (OS specific), E (exclude),
  l (large), p (processor specific)

可以看到,一共有13个节。

例如号为1的节:

bash
[号] 名称               类型                地址                偏移量
     大小               全体大小            旗标   链接   信息    对齐
[ 1] .text             PROGBITS           0000000000000000    00000040
     0000000000000015  0000000000000000   AX    0      0      1

可以看到:

  • 类型为progbits,即Program Bits。
  • 大小为0x15个字节。
  • 旗标(Flag)为AX,A (alloc), X (execute),表示该节占用了内存空间、包含可执行代码。
  • 对齐方式为1,表示不需要对齐。(2表示对齐到2字节边界,为4表示对齐到4字节边界,依此类推。)

在现代计算机体系结构中,许多硬件要求数据的存储地址必须是特定字节的整数倍。例如,x86-64体系结构通常要求数据在内存中的地址是8字节的倍数,而ARM体系结构可能要求数据是4字节或8字节的倍数。

把这13个节按照其大小和偏移量排序,可以看出其分布如下图(忽略编号为0的节):

1

objdump -d 可看反编译结果,用 -j 选项可指定需要查看的节区:

bash
[root@centos-llvm test] % objdump -d my_program.o -j .text

输出:

bash
[root@centos-llvm test] % objdump -d my_program.o -j .text

my_program.o:     文件格式 elf64-x86-64


Disassembly of section .text:

0000000000000000 <main>:
   0:	55               push   %rbp	  # 将寄存器 %rbp 中的值压入栈中,保存调用函数的栈帧基指针。
   1:	48 89 e5         mov    %rsp,%rbp # 将栈指针 %rsp 的值赋给基指针 %rbp,建立新的栈帧。
   4:	bf 00 00 00 00   mov    $0x0,%edi # 将立即数 0 赋值给寄存器 %edi,这里用作参数传递给puts函数。
   9:	e8 00 00 00 00   callq  e <main+0xe> # 调用函数 puts。
   e:	b8 00 00 00 00   mov    $0x0,%eax  # 将立即数 0 赋值给寄存器 %eax,这里用作返回值。
  13:	5d               pop    %rbp	   # 将栈顶元素弹出并存放到寄存器 %rbp 中,恢复调用函数的栈帧基指针。
  14:	c3               retq  			# 返回指令,从函数 main 返回。

可以看到确实有0x15个字节,实现了一个简单的C语言程序,该程序调用puts函数输出字符串(该字符串在代码的其他部分定义,未在这里显示),然后返回0作为退出状态码。

这里每一行的十六进制数,都是具体的指令。如48 89 e5 是 x86-64 架构汇编指令的十六进制表示,它实际上包含两个指令:

48 89 e5 是 x86-64 架构汇编指令的十六进制表示,它实际上包含两个指令:

  1. 48: 这是 REX 前缀字节,用于扩展寄存器的位宽,以支持 64 位寻址模式。在 x86-64 架构中,几乎所有的指令都使用这个前缀。
  2. 89 e5: 这是 mov %rsp, %rbp 指令,用于将栈指针(Stack Pointer,%rsp)的值赋给基指针(Base Pointer,%rbp)。它用于建立新的栈帧,将当前函数的栈帧基地址保存在 %rbp 中,以便在函数调用过程中正确访问局部变量和参数。

刚才读取的.text区,因为其Flag标志位包含X,所以反汇编可以看到具体的汇编指令。

而其他区并不包含X,可以直接读取这个区的数据,以16进制形式呈现:

bash
[root@centos-llvm test] % readelf -x .rela.text my_program.o

“.rela.text”节的十六进制输出:
  0x00000000 05000000 00000000 0a000000 05000000 ................
  0x00000010 00000000 00000000 0a000000 00000000 ................
  0x00000020 02000000 0a000000 fcffffff ffffffff ................
[root@centos-llvm test] % readelf -x .rodata my_program.o

“.rodata”节的十六进制输出:
  0x00000000 48656c6c 6f20576f 726c6421 00       Hello World!.
[root@centos-llvm test] % readelf -x .comment my_program.o

“.comment”节的十六进制输出:
  0x00000000 00474343 3a202847 4e552920 31322e31 .GCC: (GNU) 12.1
  0x00000010 2e3000                              .0.

[root@centos-llvm test] % readelf -x .strtab my_program.o

“.strtab”节的十六进制输出:
  0x00000000 006d795f 70726f67 72616d2e 63006d61 .my_program.c.ma
  0x00000010 696e0070 75747300                   in.puts.
  
[root@centos-llvm test] % readelf -x .shstrtab my_program.o

“.shstrtab”节的十六进制输出:
  0x00000000 002e7379 6d746162 002e7374 72746162 ..symtab..strtab
  0x00000010 002e7368 73747274 6162002e 72656c61 ..shstrtab..rela
  0x00000020 2e746578 74002e64 61746100 2e627373 .text..data..bss
  0x00000030 002e726f 64617461 002e636f 6d6d656e ..rodata..commen
  0x00000040 74002e6e 6f74652e 474e552d 73746163 t..note.GNU-stac
  0x00000050 6b002e72 656c612e 65685f66 72616d65 k..rela.eh_frame
  0x00000060 00

查看文件的重定位信息:

bash
[root@centos-llvm test] % readelf -r my_program.o

重定位节 '.rela.text' 位于偏移量 0x1d0 含有 2 个条目:
  偏移量          信息           类型           符号值        符号名称 + 加数
000000000005  00050000000a R_X86_64_32       0000000000000000 .rodata + 0
00000000000a  000a00000002 R_X86_64_PC32     0000000000000000 puts - 4

重定位节 '.rela.eh_frame' 位于偏移量 0x200 含有 1 个条目:
  偏移量          信息           类型           符号值        符号名称 + 加数
000000000020  000200000002 R_X86_64_PC32     0000000000000000 .text + 0

符号表 .symtab 包括所有用到的相关符号信息,如函数名、变量名,可用 readelf 查看:

bash
[root@centos-llvm test] % readelf my_program.o --symbols

Symbol table '.symtab' contains 11 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS my_program.c
     2: 0000000000000000     0 SECTION LOCAL  DEFAULT    1 
     3: 0000000000000000     0 SECTION LOCAL  DEFAULT    3 
     4: 0000000000000000     0 SECTION LOCAL  DEFAULT    4 
     5: 0000000000000000     0 SECTION LOCAL  DEFAULT    5 
     6: 0000000000000000     0 SECTION LOCAL  DEFAULT    7 
     7: 0000000000000000     0 SECTION LOCAL  DEFAULT    8 
     8: 0000000000000000     0 SECTION LOCAL  DEFAULT    6 
     9: 0000000000000000    21 FUNC    GLOBAL DEFAULT    1 main
    10: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND puts

对比汇编代码和所有的节区内容,可以看到有一个可重定位的节区,即 .rel.text,它标记了两个需要重定位的项,.rodataputs。这个节区将告诉编译器这两个信息在链接或者动态链接的过程中需要重定位。

链接

动态链接

执行

bash
[root@centos-llvm test] % gcc -O my_program.o -o my_program

即可生成可执行文件my_program

重定位是将符号引用与符号定义进行链接的过程。因此链接是处理可重定位文件,把它们的各种符号引用和符号定义转换为可执行文件中的合适信息(一般是虚拟内存地址)的过程。

链接又分为静态链接和动态链接,前者是程序开发阶段程序员用 ldgcc 实际上在后台调用了 ld)静态链接器手动链接的过程,而动态链接则是程序运行期间系统调用动态链接器(ld-linux.so)自动链接的过程。

比如,如果链接到可执行文件中的是静态链接库 libmyprintf.a,那么 .rodata 节区在链接后需要被重定位到一个绝对的虚拟内存地址,以便程序运行时能够正确访问该节区中的字符串信息。而对于 puts 函数,因为它是动态链接库 libc.so 中定义的函数,所以会在程序运行时通过动态符号链接找出 puts 函数在内存中的地址,以便程序调用该函数。在这里主要讨论静态链接过程,动态链接过程见《动态符号链接的细节》

静态链接过程主要是把可重定位文件依次读入,分析各个文件的文件头,进而依次读入各个文件的节区,并计算各个节区的虚拟内存位置,对一些需要重定位的符号进行处理,设定它们的虚拟内存地址等,并最终产生一个可执行文件或者是动态链接库。这个链接过程是通过 ld 来完成的,ld 在链接时使用了一个链接脚本(linker script),该链接脚本处理链接的具体细节。

由于静态符号链接过程非常复杂,特别是计算符号地址的过程,考虑到时间关系,相关细节请参考 ELF 手册。这里主要介绍可重定位文件中的节区(节区表描述的)和可执行文件中段(程序头描述的)的对应关系以及 gcc 编译时采用的一些默认链接选项。

查看可执行文件my_program的程序头(段表):

bash
[root@centos-llvm test] % readelf -l my_program

Elf 文件类型为 EXEC (可执行文件)
入口点 0x400430
共有 9 个程序头,开始于偏移量64

程序头:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  PHDR           0x0000000000000040 0x0000000000400040 0x0000000000400040
                 0x00000000000001f8 0x00000000000001f8  R E    8
  INTERP         0x0000000000000238 0x0000000000400238 0x0000000000400238
                 0x000000000000001c 0x000000000000001c  R      1
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD           0x0000000000000000 0x0000000000400000 0x0000000000400000
                 0x00000000000006dc 0x00000000000006dc  R E    200000
  LOAD           0x0000000000000e18 0x0000000000600e18 0x0000000000600e18
                 0x0000000000000228 0x0000000000000230  RW     200000
  DYNAMIC        0x0000000000000e28 0x0000000000600e28 0x0000000000600e28
                 0x00000000000001d0 0x00000000000001d0  RW     8
  NOTE           0x0000000000000254 0x0000000000400254 0x0000000000400254
                 0x0000000000000020 0x0000000000000020  R      4
  GNU_EH_FRAME   0x00000000000005b4 0x00000000004005b4 0x00000000004005b4
                 0x0000000000000034 0x0000000000000034  R      4
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RW     10
  GNU_RELRO      0x0000000000000e18 0x0000000000600e18 0x0000000000600e18
                 0x00000000000001e8 0x00000000000001e8  R      1

 Section to Segment mapping:
  段节...
   00     
   01     .interp 
   02     .interp .note.ABI-tag .hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt .init .plt .text .fini .rodata .eh_frame_hdr .eh_frame 
   03     .init_array .fini_array .dynamic .got .got.plt .data .bss 
   04     .dynamic 
   05     .note.ABI-tag 
   06     .eh_frame_hdr 
   07     
   08     .init_array .fini_array .dynamic .got

下面是对程序头表中各个字段的解释:

  1. Type:段类型,表示段的作用和属性。例如,PHDR 表示程序头表自身,INTERP 表示解释器段,LOAD 表示可加载段,DYNAMIC 表示动态链接信息段,NOTE 表示附加信息段等。
  2. Offset:段在文件中的偏移量,表示该段在 ELF 文件中的起始位置距离文件开头的字节偏移量。
  3. VirtAddr:段在内存中的虚拟地址,表示该段在程序运行时在虚拟内存中的起始地址。
  4. PhysAddr:段在内存中的物理地址,一般情况下为 0,表示该段在内存中的物理地址是由操作系统动态分配的。
  5. FileSiz:段在文件中的大小,表示该段在 ELF 文件中占用的字节数。
  6. MemSiz:段在内存中的大小,表示该段在程序运行时占用的虚拟内存空间大小。
  7. Flags:段的标志,描述段的属性。例如,R 表示段可读,W 表示段可写,E 表示段可执行。
  8. Align:段在内存中的对齐方式,表示该段在内存中的起始地址在内存中的对齐方式,一般以 2 的幂次进行对齐。

程序头种类如下:

  • PHDR: 给出了程序表自身的大小和位置,不能出现一次以上。
  • INTERP: 因为程序中调用了 puts(在动态链接库中定义),使用了动态链接库,因此需要动态装载器/链接器(ld-linux.so
  • LOAD: 包括程序的指令,.text 等节区都映射在该段,只读(R)
  • LOAD: 包括程序的数据,.data,.bss 等节区都映射在该段,可读写(RW)
  • DYNAMIC: 动态链接相关的信息,比如包含有引用的动态链接库名字等信息
  • NOTE: 给出一些附加信息的位置和大小
  • GNU_STACK: 这里为空,应该是和GNU相关的一些信息

这个程序的执行需要动态链接器/lib64/ld-linux-x86-64.so.2的支持。当执行一个可执行文件时,操作系统会查找该文件的程序头表中的 INTERP 段,该段指定了运行该可执行文件所需的解释器的路径。然后,操作系统会加载并运行这个解释器,并将可执行文件作为解释器的参数传递给它。如果电脑上缺少 /lib64/ld-linux-x86-64.so.2 这个解释器,那么执行可执行文件时就无法找到解释器,导致程序无法运行。

静态链接

如果希望程序包含所有运行时所需的依赖库,以便于其能够在定制的、精简的或自定义的 Linux 系统中运行,那么可以在链接时加上-static参数:

bash
[root@centos-llvm test] % gcc -static -O my_program.o -o my_program

如果报错

bash
/usr/bin/ld: 找不到 -lc
collect2: 错误:ld 返回 1

则是因为使用了 -static 选项,编译器会查找静态版本的标准 C 库,即 libc.a。出现这个错误可能是因为你的系统上缺少静态版本的标准 C 库。在某些 Linux 发行版中,默认情况下可能只安装了动态版本的标准 C 库,没有安装静态版本。因此,编译器无法找到所需的静态库而报错。

可以通过安装

bash
# Ubuntu
sudo apt-get install libc6-dev

# CentOS
sudo yum install glibc-devel glibc-static

经过测试,相同代码经过动态链接得到的可执行文件大小为8.0KB,而静态链接得到的可执行文件大小为948KB

查看静态链接得到的可执行文件的程序头(段表):

bash
[root@centos-llvm test] % readelf -l my_program

Elf 文件类型为 EXEC (可执行文件)
入口点 0x400f3d
共有 6 个程序头,开始于偏移量64

程序头:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  LOAD           0x0000000000000000 0x0000000000400000 0x0000000000400000
                 0x00000000000bcbd9 0x00000000000bcbd9  R E    200000
  LOAD           0x00000000000bcec0 0x00000000006bcec0 0x00000000006bcec0
                 0x0000000000001850 0x00000000000039e8  RW     200000
  NOTE           0x0000000000000190 0x0000000000400190 0x0000000000400190
                 0x0000000000000020 0x0000000000000020  R      4
  TLS            0x00000000000bcec0 0x00000000006bcec0 0x00000000006bcec0
                 0x0000000000000020 0x0000000000000058  R      10
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RW     10
  GNU_RELRO      0x00000000000bcec0 0x00000000006bcec0 0x00000000006bcec0
                 0x0000000000000140 0x0000000000000140  R      1

 Section to Segment mapping:
  段节...
   00     .note.ABI-tag .rela.plt .init .plt .text __libc_freeres_fn __libc_thread_freeres_fn .fini .rodata __libc_atexit __libc_subfreeres .stapsdt.base __libc_thread_subfreeres __libc_IO_vtables .eh_frame .gcc_except_table 
   01     .tdata .init_array .fini_array .data.rel.ro .got .got.plt .data .bss __libc_freeres_ptrs 
   02     .note.ABI-tag 
   03     .tdata .tbss 
   04     
   05     .tdata .init_array .fini_array .data.rel.ro .got

可以看到其不再需要动态链接器。