main.c
从编译到执行到底经历了什么编写程序add.c
和main.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;
}
预处理阶段,所完成的操作有:
#include
预处理指令,将包含的头文件内容插入到相应的位置。#define
预处理指令,将宏定义替换为其对应的文本。#ifdef
、#ifndef
、#if
等条件编译指令,根据条件判断是否保留或移除特定的代码块。\
结尾的行与下一行连接在一起。执行如下命令,将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 9月 23 13:10 add.c
-rw-rw-r-- 1 lee lee 137 9月 21 22:21 main.c
预处理后:
-rw-rw-r-- 1 lee lee 262 9月 23 13:10 add.i
-rw-rw-r-- 1 lee lee 16425 9月 23 13:11 main.i
可以看到,预处理后的文件都比源文件要大,其中main.i
比源文件大了一百倍
将预处理得到的main.i
和add.i
文件,进行一系列的词法分析,语法分析、语义优化生成对应的汇编文件.s
。
编译阶段完成的工作有:
在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
# 这两行指令将10和20分别存储到堆栈帧上的相对位置。
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:
首先将.s
文件生成可重定位目标文件.o
。命令如下:
gcc -c add.s -o add.o
gcc -c main.s -o main.o
可以使用Linux
的objdump
命令来查看目标文件(.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.o
和add.o
组合起来,生成一个可执行目标文件app.out
gcc -o app.out tmp/main.o tmp/add.o
链接器需要完成两个任务:
什么是ELF
文件。ELF
是一种文件格式,是Linux
和类Unix
系统定义的可执行文件的文件格式。
在Linux
系统中,属于ELF
文件类型的有:
.o
可重定向目标文件.out
可执行文件.so
动态库文件在shell
中,可以使用readelf
指令读取.o
, .out
文件获取其中的文件信息。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++
和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.版本已经能够识别该错误了