main.c从编译到运行到底发生了什么

一个程序main.c从编译到执行到底经历了什么

-E
-S
-c
-o
main.c
main.i
main.s
main.o
app

1. 编写程序

编写程序add.cmain.c

/**** add.c ***/
#include "add.h"
#define FLAG 1
#define NEG 1

/*
加法函数。这里添加了宏和#if,目的是为了演示预处理对此的处理结果
*/

int add(int a, int b)
{
#ifdef NEG
#if (NEG == 0)
	return a + b;
#else 
	return -(a + b + FLAG);
#endif
#endif
}
/*** main.c ***/
#include 
#include "add.h"

int main()
{
    int a = 10;
    int b = 20;
    int c = add(a, b);
    printf("a + b = %d\n", c);
    return 0;
}

2.预处理

预处理阶段,所完成的操作有:

  1. 头文件包含:处理#include预处理指令,将包含的头文件内容插入到相应的位置。
  2. 宏替换:处理#define预处理指令,将宏定义替换为其对应的文本。
  3. 条件编译:处理#ifdef#ifndef#if等条件编译指令,根据条件判断是否保留或移除特定的代码块。
  4. 删除注释:将注释从代码中删除,因为注释在编译过程中是不需要的。
  5. 去除多余空格:删除多余的空格、制表符和换行符,使代码更紧凑。
  6. 连接行:将以反斜杠\结尾的行与下一行连接在一起。

执行如下命令,将main.c , add.c进行预处理。

gcc -E main.c -o tmp/main.i
gcc -E add.c -o tmp/add.i

main.c被预处理后得到的main.i文件会非常大,这是因为#include 头文件所依赖的组件比较多,文件内容替换也多。

add.c中的头文件只有add.h,预处理得到的add.i文件如下:

# 1 "add.c"
# 1 ""
# 1 ""
# 31 ""
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 32 "" 2
# 1 "add.c"
# 1 "add.h" 1



int add(int a, int b);
# 2 "add.c" 2







int add(int a, int b)
{




 return -(a + b + 1);


}

我们可以使用ll查看预处理前、预处理后文件大小对比

预处理前:

-rw-rw-r-- 1 lee lee  261 923 13:10 add.c
-rw-rw-r-- 1 lee lee  137 921 22:21 main.c

预处理后:

-rw-rw-r-- 1 lee lee   262 923 13:10 add.i
-rw-rw-r-- 1 lee lee 16425 923 13:11 main.i

可以看到,预处理后的文件都比源文件要大,其中main.i比源文件大了一百倍

3.编译成汇编代码

将预处理得到的main.iadd.i文件,进行一系列的词法分析,语法分析、语义优化生成对应的汇编文件.s

编译阶段完成的工作有:

  1. 词法分析;
  2. 语法分析
  3. 语义分析
  4. 优化
  5. 目标代码生成:生成汇编代码
  6. 目标代码优化:寻找合适的寻址方式、使用位移运算替代乘法运算、删除多余的指令

shell中输入如下指令:

gcc -S add.i -o add.s
gcc -S main.i -o main.s

生成的add.s

# 这行指令指定了源文件的名称,即"add.c"
	.file	"add.c"
# 这几行指令定义了全局可见的符号"add",表示这是一个函数。后续的代码将是该函数的实现。
	.text
	.globl	add
	.type	add, @function
add:
# 这几行指令建立了函数的堆栈帧,保存了之前的栈指针和帧指针,用于函数调用和局部变量的访问。
.LFB0:
	.cfi_startproc
	endbr64
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register 6
# 这段代码将传入的两个参数 a 和 b 分别存储到堆栈帧上的相对位置上。然后使用addl指令将 a 和 b 相加,并将结果存储在寄存器 %eax 中。接着使用notl指令对 %eax 的值进行取反操作。
    movl	%edi, -4(%rbp)
	movl	%esi, -8(%rbp)
	movl	-4(%rbp), %edx
	movl	-8(%rbp), %eax
	addl	%edx, %eax
	notl	%eax
# 这几行指令恢复了函数的堆栈帧,并将栈指针和帧指针重置为之前保存的值。
    popq	%rbp
	.cfi_def_cfa 7, 8
# 这行指令表示函数的结束,并通过 ret 指令返回。
    ret
	.cfi_endproc
.LFE0:
	.size	add, .-add
	.ident	"GCC: (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.4.0"
	.section	.note.GNU-stack,"",@progbits
	.section	.note.gnu.property,"a"
	.align 8
	.long	 1f - 0f
	.long	 4f - 1f
	.long	 5
0:
	.string	 "GNU"
1:
	.align 8
	.long	 0xc0000002
	.long	 3f - 2f
2:
	.long	 0x3
3:
	.align 8
4:

生成的main.s

# 这行指令指定了源文件的名称,即"main.c"	
	.file	"main.c"
# 这几行指令定义了只读数据段(.rodata),并将字符串"a + b = %d\n"存储在标签.LC0中。
    .text
	.section	.rodata
.LC0:
	.string	"a + b = %d\n"
# 这几行指令定义了只读数据段(.rodata),并将字符串"a + b = %d\n"存储在标签.LC0中。
    .text
	.globl	main
	.type	main, @function
main:
# 这几行指令建立了函数的堆栈帧,保存了之前的栈指针和帧指针,用于函数调用和局部变量的访问。
.LFB0:
	.cfi_startproc
	endbr64
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register 6
	subq	$16, %rsp
# 这两行指令将1020分别存储到堆栈帧上的相对位置。
    movl	$10, -12(%rbp)
	movl	$20, -8(%rbp)
# 这两行指令将从堆栈帧上的相对位置读取值并分别存储到寄存器%edx和%eax中。
    movl	-8(%rbp), %edx
	movl	-12(%rbp), %eax
# 这三行指令将%edx和%eax中的值分别传递给%esi和%edi寄存器,并调用add函数(通过add@PLT符号)。
    movl	%edx, %esi
	movl	%eax, %edi
	call	add@PLT
# 这几行指令将add函数的返回值存储到堆栈帧上的相对位置,并将该值作为参数传递给printf函数(通过printf@PLT符号),同时将.LC0字符串的地址传递给%rdi寄存器进行格式化输出。
    movl	%eax, -4(%rbp)
	movl	-4(%rbp), %eax
	movl	%eax, %esi
	leaq	.LC0(%rip), %rdi
	movl	$0, %eax
	call	printf@PLT
# 这几行指令将0存储到%eax寄存器,并通过leave指令恢复堆栈帧,并使用ret指令返回。
    movl	$0, %eax
	leave
	.cfi_def_cfa 7, 8
	ret
	.cfi_endproc
.LFE0:
	.size	main, .-main
	.ident	"GCC: (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.4.0"
	.section	.note.GNU-stack,"",@progbits
	.section	.note.gnu.property,"a"
	.align 8
	.long	 1f - 0f
	.long	 4f - 1f
	.long	 5
0:
	.string	 "GNU"
1:
	.align 8
	.long	 0xc0000002
	.long	 3f - 2f
2:
	.long	 0x3
3:
	.align 8
4:

4.链接

首先将.s文件生成可重定位目标文件.o。命令如下:

gcc -c add.s -o add.o
gcc -c main.s -o main.o

objdump

可以使用Linuxobjdump命令来查看目标文件(.o文件)的内容和信息。objdump是一个功能强大的二进制文件分析工具,可以显示目标文件的汇编代码、符号表、段信息等。

下面是objdump命令的基本用法:

objdump [options] 

其中,options是可选的参数,file...是要查看的目标文件列表。

以下是一些常用的objdump选项:

  • -d:显示目标文件的汇编代码。
  • -t:显示目标文件的符号表。
  • -h:显示目标文件的段(section)信息。
  • -s:以十六进制形式显示目标文件的内容。
  • -r:显示目标文件的重定位信息。
  • -x:显示目标文件的全部信息。

使用objdump查看main.o的汇编代码

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


Disassembly of section .text:

0000000000000000 <main>:
   0:   f3 0f 1e fa             endbr64 
   4:   55                      push   %rbp
   5:   48 89 e5                mov    %rsp,%rbp
   8:   48 83 ec 10             sub    $0x10,%rsp
   c:   c7 45 f4 0a 00 00 00    movl   $0xa,-0xc(%rbp)
  13:   c7 45 f8 14 00 00 00    movl   $0x14,-0x8(%rbp)
  1a:   8b 55 f8                mov    -0x8(%rbp),%edx
  1d:   8b 45 f4                mov    -0xc(%rbp),%eax
  20:   89 d6                   mov    %edx,%esi
  22:   89 c7                   mov    %eax,%edi
  24:   e8 00 00 00 00          callq  29 <main+0x29>
  29:   89 45 fc                mov    %eax,-0x4(%rbp)
  2c:   8b 45 fc                mov    -0x4(%rbp),%eax
  2f:   89 c6                   mov    %eax,%esi
  31:   48 8d 3d 00 00 00 00    lea    0x0(%rip),%rdi        # 38 
  38:   b8 00 00 00 00          mov    $0x0,%eax
  3d:   e8 00 00 00 00          callq  42 <main+0x42>
  42:   b8 00 00 00 00          mov    $0x0,%eax
  47:   c9                      leaveq 
  48:   c3                      retq  

使用objdump查看main.o的段信息

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

节:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         00000049  0000000000000000  0000000000000000  00000040  2**0
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
  1 .data         00000000  0000000000000000  0000000000000000  00000089  2**0
                  CONTENTS, ALLOC, LOAD, DATA
  2 .bss          00000000  0000000000000000  0000000000000000  00000089  2**0
                  ALLOC
  3 .rodata       0000000c  0000000000000000  0000000000000000  00000089  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  4 .comment      0000002c  0000000000000000  0000000000000000  00000095  2**0
                  CONTENTS, READONLY
  5 .note.GNU-stack 00000000  0000000000000000  0000000000000000  000000c1  2**0
                  CONTENTS, READONLY
  6 .note.gnu.property 00000020  0000000000000000  0000000000000000  000000c8  2**3
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  7 .eh_frame     00000038  0000000000000000  0000000000000000  000000e8  2**3
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA

使用objdump查看main.o的符号表

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

SYMBOL TABLE:
0000000000000000 l    df *ABS*  0000000000000000 main.c
0000000000000000 l    d  .text  0000000000000000 .text
0000000000000000 l    d  .data  0000000000000000 .data
0000000000000000 l    d  .bss   0000000000000000 .bss
0000000000000000 l    d  .rodata        0000000000000000 .rodata
0000000000000000 l    d  .note.GNU-stack        0000000000000000 .note.GNU-stack
0000000000000000 l    d  .note.gnu.property     0000000000000000 .note.gnu.property
0000000000000000 l    d  .eh_frame      0000000000000000 .eh_frame
0000000000000000 l    d  .comment       0000000000000000 .comment
0000000000000000 g     F .text  0000000000000049 main
0000000000000000         *UND*  0000000000000000 _GLOBAL_OFFSET_TABLE_
0000000000000000         *UND*  0000000000000000 add
0000000000000000         *UND*  0000000000000000 printf

然后运行链接器,将main.oadd.o组合起来,生成一个可执行目标文件app.out

gcc -o app.out tmp/main.o tmp/add.o

链接过程发生了什么???

链接器需要完成两个任务:

  • 符号解析
  • 重定位
ELF文件

什么是ELF文件。ELF是一种文件格式,是Linux和类Unix系统定义的可执行文件的文件格式。

Linux系统中,属于ELF文件类型的有:

  • .o可重定向目标文件
  • .out可执行文件
  • .so动态库文件

shell 中,可以使用readelf指令读取.o, .out文件获取其中的文件信息。ELF文件的格式一般如下表示:

  • ELF文件头(ELF Header):存储有关ELF文件的基本信息,如文件类型、架构、入口点地址等。
  • 程序头表(Program Header Table):描述ELF文件中各个程序段的位置、大小和访问权限等信息,用于在内存中加载和映射程序段。
  • 节头表(Section Header Table):描述ELF文件中各个节(Section)的位置、大小和属性等信息,用于定位和链接代码、数据和其他符号。
  • 节(Section):包含代码、数据和其他符号等信息。常见的节包括.text(代码段)、.data(数据段)、.rodata(只读数据段)等。
  • 符号表(Symbol Table):记录了ELF文件中定义和使用的各种符号(如函数、变量)的信息,以及它们在内存中的地址和大小等。
  • 字符串表(String Table):存储了ELF文件中使用的字符串,用于描述符号表、节名等。
符号表

符号表,由ELF文件的.symtab表示。每一个.o.out文件都有自己的符号表。符号表保存了程序中定义和引用的函数和全局变量的信息。

使用readelf获取main.o文件的符号表信息:readelf -s main.o。最后四行内容:

    10: 0000000000000000    73 FUNC    GLOBAL DEFAULT    1 main
    11: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND _GLOBAL_OFFSET_TABLE_
    12: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND add
    13: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND printf

在这个例子中,可以看到main函数是一个位于索引序号为1 --> text也即.text段,偏移量为0的大小为73字节的FUNC函数

我们自己定义的add函数在main函数中被引用,但是由于main.o没有add函数的定义信息,所以此处标识为UND未定义。

readelf用一个整数索引表示每一个节,数字索引对应的段如下:

[] 名称              类型             地址              偏移量
       大小              全体大小          旗标   链接   信息   对齐
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .text             PROGBITS         0000000000000000  00000040
       0000000000000049  0000000000000000  AX       0     0     1
  [ 2] .rela.text        RELA             0000000000000000  000002a0
       0000000000000048  0000000000000018   I      11     1     8
  [ 3] .data             PROGBITS         0000000000000000  00000089
       0000000000000000  0000000000000000  WA       0     0     1
  [ 4] .bss              NOBITS           0000000000000000  00000089
       0000000000000000  0000000000000000  WA       0     0     1
  [ 5] .rodata           PROGBITS         0000000000000000  00000089
       000000000000000c  0000000000000000   A       0     0     1
  [ 6] .comment          PROGBITS         0000000000000000  00000095
       000000000000002c  0000000000000001  MS       0     0     1
  [ 7] .note.GNU-stack   PROGBITS         0000000000000000  000000c1
       0000000000000000  0000000000000000           0     0     1
  [ 8] .note.gnu.propert NOTE             0000000000000000  000000c8
       0000000000000020  0000000000000000   A       0     0     8
  [ 9] .eh_frame         PROGBITS         0000000000000000  000000e8
       0000000000000038  0000000000000000   A       0     0     8
  [10] .rela.eh_frame    RELA             0000000000000000  000002e8
       0000000000000018  0000000000000018   I      11     9     8
  [11] .symtab           SYMTAB           0000000000000000  00000120
       0000000000000150  0000000000000018          12    10     8
  [12] .strtab           STRTAB           0000000000000000  00000270
       000000000000002e  0000000000000000           0     0     1
  [13] .shstrtab         STRTAB           0000000000000000  00000300
       0000000000000074  0000000000000000           0     0     1
符号解析
未定义的符号

链接器之所以能够解析不同文件中的函数、变量等信息,是因为程序源文件被编译的过程中,会在符号表中生成用于引用的符号。

如果在所有的目标文件中,都找不到你引用的变量或者函数,编译器会报错,比如:

void foo();
int main()
{
    foo();
    return 0;
}

由于函数foo没有定义,直接调用foo肯定是找不到相应的实现的,因此链接器会报错如下:

/usr/bin/ld: /tmp/ccxDeQck.o: in function `main':
undef.c:(.text+0xe): undefined reference to `foo'
C++的重载函数

另外,对于C++Java来说,他们支持函数重载,函数重载是指函数名相同,但是参数列表不同,比如:

void func(int a, int b);
void func(int a, float b);

对于具有相同名字的函数,链接器会采用重整的编码方式,将函数名和参数列表组合得到一个唯一的符号,从而找到正确的函数。

具有相同定义的全局符号

如果在多个源文件中,定义了具有相同名字的符号,链接器该作如何选择呢?

对于Linux系统,在面对此种情况,分三种情况讨论:

  • 不允许有相同的强符号,比如两个模块中都有main函数,此时会报错;
  • 如果有一个强符号和多个弱符号,选择强符号;
  • 如果有多个弱符号,任意选择一个;

case2和3这种将决定权交给链接器来选择的方式,会给程序带来潜在的风险。

例如,在两个源文件中,都同时声明了全局变量int x

/* code1.c */
int x;
void f()
{
    x = -1;
}

/* code2.c */
void f();
int x = 100;

如果根据规则,强符号是在code2.c中定义的x,但是在code1.c中却对x的值进行了修改,最后得到变量x = -1

此外,还有一种更严重的情况,重定义的全局变量,具有不同的数据类型。由于数据类型不同,所占字节大小不同,变量字节变大后将会影响正常的其他变量的值,进而产生更多隐藏的bug。。。gcc 9.4.0.版本已经能够识别该错误了

你可能感兴趣的:(c语言,算法,开发语言)