广义上的编译过程包括预处理、狭义上的编译、汇编三个过程。
在linux用xshell时,程序员自己给出的外部实现,需要手动链接外部的.o文件。但在windows中VS(IDE)不用,因为IDE自动完成了全过程。
预处理
作用:处理源代码中的预处理指令(以 #
开头的命令),生成纯C/C++代码。
关键操作:
-
展开头文件(
#include
) -
宏替换(
#define
) -
条件编译(
#ifdef
、#endif
) -
删除注释
*.h头文件
下面是stdio.h中的前面一些内容
#pragma once //#pragma once非标准但广泛支持,更简洁的头文件保护
#ifndef _INC_STDIO // include guard for 3rd party interop
#define _INC_STDIO //头文件守卫(Header Guard):通过宏定义避免重复包含导致的编译错误。
#include <corecrt.h>
#include <corecrt_wstdio.h>
#pragma warning(push)
#pragma warning(disable: _UCRT_DISABLED_WARNINGS)
_UCRT_DISABLE_CLANG_WARNINGS
_CRT_BEGIN_C_HEADER
#define BUFSIZ 512
#define _NFILE _NSTREAM_
#define _NSTREAM_ 512
#define _IOB_ENTRIES 3
#define EOF (-1)
#define _IOFBF 0x0000
#define _IOLBF 0x0040
#define _IONBF 0x0004
#define L_tmpnam 260 // _MAX_PATH
#if __STDC_WANT_SECURE_LIB__
#define L_tmpnam_s L_tmpnam
#endif
....
由此看出头文件中仅只包含函数或变量的声明, 如函数声明、类/结构体定义、宏定义、类型别名等,但不包含函数实现(内联函数和模板例外)。也就是说头文件中的内容不会分配内存空间
*.c源文件
#include <stdio.h>
#define N 10
#define ADD(a,b) ((a)+(b))
int main()
{
printf("Hello world\n");
printf("N=%d",N);
int result = ADD(1,1);
printf("result=%d\n",result);
return 0;
}
*.i文件
gcc -E test.c -o test.i //这是linux中xshell的指令,下面同理
.......
# 885 "/usr/include/stdio.h" 3 4
extern int __uflow (FILE *);
extern int __overflow (FILE *, int);
# 902 "/usr/include/stdio.h" 3 4
int main()
{
printf("Hello world\n");
printf("N=%d",10);
int result = ((1)+(1));
printf("result=%d\n",result);
return 0;
}
编译
作用:将预处理后的代码翻译成汇编代码(.s
文件)。值得注意的是汇编代码中没有变量名,变量名被地址取代,可以从上面的result,再结合下面的-4(%rbp)可看出。
关键操作:
-
语法和语义分析
-
生成与平台相关的汇编指令
-
优化代码(printf优化为puts)
*.s文件
gcc -S test.c -o test.s
.LC0:
.string "Hello world" # 定义字符串常量 "Hello world",标签为 .LC0
.LC1:
.string "N=%d" # 定义格式化字符串 "N=%d",标签为 .LC1
.LC2:
.string "result=%d\n" # 定义格式化字符串 "result=%d\n",标签为 .LC2
.text # 代码段开始
.globl main # 声明 main 函数为全局符号(可被链接器访问)
.type main, @function # 定义 main 的类型为函数
main: # main 函数入口
.LFB0: # 函数开始标签(Local Function Begin)
.cfi_startproc # 调用帧信息(用于栈展开调试)
endbr64 # 对抗控制流攻击的安全指令(Intel CET 特性)
# === 函数序言(Prologue)=== #
pushq %rbp # 保存调用者的基址指针(栈帧链)
.cfi_def_cfa_offset 16 # 调试信息:当前栈帧偏移量为 16 字节
.cfi_offset 6, -16 # 调试信息:寄存器 %rbp 保存在 -16(%rsp) 处
movq %rsp, %rbp # 设置当前栈指针为新的基址指针
.cfi_def_cfa_register 6 # 调试信息:现在用 %rbp 计算栈帧地址
subq $16, %rsp # 在栈上分配 16 字节空间(局部变量/对齐)
# === 调用 puts("Hello world") === #
leaq .LC0(%rip), %rax # 将字符串 "Hello world" 地址加载到 %rax(RIP 相对寻址)
movq %rax, %rdi # 第一个参数存入 %rdi(x86-64 调用约定)
call puts@PLT # 调用动态链接的 puts 函数
# === 调用 printf("N=%d", 10) === #
movl $10, %esi # 第二个参数(10)存入 %esi
leaq .LC1(%rip), %rax # 加载格式化字符串 "N=%d" 地址到 %rax
movq %rax, %rdi # 第一个参数(字符串地址)存入 %rdi
movl $0, %eax # 清空 %eax(表示没有浮点参数)
call printf@PLT # 调用 printf
# === 局部变量操作 === #
movl $2, -4(%rbp) # 将常量 2 存入栈上的局部变量(地址 %rbp-4)
movl -4(%rbp), %eax # 将局部变量的值加载到 %eax
movl %eax, %esi # 将值移动到 %esi 作为 printf 的第二个参数
# === 调用 printf("result=%d\n", 2) === #
leaq .LC2(%rip), %rax # 加载格式化字符串 "result=%d\n" 地址
movq %rax, %rdi # 第一个参数存入 %rdi
movl $0, %eax # 清空 %eax
call printf@PLT # 调用 printf
# === 函数收尾(Epilogue)=== #
movl $0, %eax # 返回值 0 存入 %eax(main 函数的返回码)
leave # 等同于 movq %rbp, %rsp + popq %rbp(恢复栈帧)
.cfi_def_cfa 7, 8 # 调试信息:栈帧恢复为调用者状态
ret # 返回调用者
.cfi_endproc # 调用帧信息结束
汇编
作用:将汇编代码转换为机器码(目标文件 .o
),生成二进制指令。
关键操作:
-
解析汇编指令为操作码(Opcode)
-
生成可重定位目标文件(Relocatable Object File)
可重定位性:代码中的地址是相对的,链接器可将其调整为最终内存地址。
重定向可以将这些标准流指向其他设备或文件,使得程序从不同的输入源读取数据或将输出发送到不同的位置。
*.o文件
gcc -c test.c -o test.o
实际上.o文件就是一堆二进制 ,这里我们使用指令objdump -d test.o反汇编代码段(机器码 → 汇编)
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: 48 8d 05 00 00 00 00 lea 0x0(%rip),%rax # 13 <main+0x13>
13: 48 89 c7 mov %rax,%rdi
16: e8 00 00 00 00 call 1b <main+0x1b>
1b: be 0a 00 00 00 mov $0xa,%esi
20: 48 8d 05 00 00 00 00 lea 0x0(%rip),%rax # 27 <main+0x27>
27: 48 89 c7 mov %rax,%rdi
2a: b8 00 00 00 00 mov $0x0,%eax
2f: e8 00 00 00 00 call 34 <main+0x34>
34: c7 45 fc 02 00 00 00 movl $0x2,-0x4(%rbp)
3b: 8b 45 fc mov -0x4(%rbp),%eax
3e: 89 c6 mov %eax,%esi
40: 48 8d 05 00 00 00 00 lea 0x0(%rip),%rax # 47 <main+0x47>
47: 48 89 c7 mov %rax,%rdi
4a: b8 00 00 00 00 mov $0x0,%eax
4f: e8 00 00 00 00 call 54 <main+0x54>
54: b8 00 00 00 00 mov $0x0,%eax
59: c9 leave
5a: c3 ret
//左列:机器码的十六进制表示(如 55 对应 push %rbp)
由此可知汇编语言其实是二进制的助记符
nm (全称 names 或 name mangler )是一个用于分析目标文件(如可执行文件、静态库、动态库等)符号表的命令行工具。它能够列出文件中定义的或引用的符号(如函数、全局变量等),帮助开发者调试程序、分析链接问题或检查二进制文件的符号信息 。
nm test.o
0000000000000000 T main //T:已定义的函数(如 main)。
U printf
U puts //U:未定义的符号(需链接时解析)。
gcc test.c -o test 注意:在linux中可执行程序通常没有固定的文件扩展名,而是通过文件的 权限属性 和 内容格式 来标识是否为可执行程序。所以在这里test就是可执行程序,但是如果是Windows的话它就是test.exe格式
nm test
000000000000038c r __abi_tag // 只读的ABI兼容性标签(GCC 11+引入)
0000000000004010 B __bss_start // 未初始化数据段(.bss)的起始地址
0000000000004010 b completed.0 // 编译器生成的静态变量,标记初始化状态
w __cxa_finalize@GLIBC_2.2.5 // 弱引用:GLIBC的全局析构函数(程序退出时调用)
0000000000004000 D __data_start // 已初始化数据段(.data)的起始地址
0000000000004000 W data_start // 弱符号,等同于__data_start(历史遗留)
00000000000010b0 t deregister_tm_clones // 文本段(.text)局部符号:TM(线程本地)克隆注销逻辑
0000000000001120 t __do_global_dtors_aux // 文本段局部符号:全局析构函数的辅助例程
0000000000003db8 d __do_global_dtors_aux_fini_array_entry // 数据段:存储全局析构函数的指针数组
0000000000004008 D __dso_handle // 已初始化数据:动态共享对象(DSO)句柄
0000000000003dc0 d _DYNAMIC // 数据段:动态链接信息表(用于运行时链接)
0000000000004010 D _edata // 数据段结束地址(等价于.bss起始)
0000000000004018 B _end // 程序内存布局的结束地址
00000000000011c4 T _fini // 文本段:程序终止代码(析构逻辑入口)
0000000000001160 t frame_dummy // 文本段局部符号:栈帧初始化占位函数
0000000000003db0 d __frame_dummy_init_array_entry // 数据段:初始化帧信息的指针数组
0000000000002100 r __FRAME_END__ // 只读段:EH(异常处理)帧数据的结束标记
0000000000003fb0 d _GLOBAL_OFFSET_TABLE_ // 数据段:全局偏移表(GOT),用于动态链接
w __gmon_start__ // 弱引用:gprof性能分析工具的初始化钩子
0000000000002020 r __GNU_EH_FRAME_HDR // 只读段:异常处理帧头信息
0000000000001000 T _init // 文本段:程序初始化代码(构造逻辑入口)
0000000000002000 R _IO_stdin_used // 只读段:标记GLIBC中stdin的使用(兼容性占位)
w _ITM_deregisterTMCloneTable // 弱引用:事务内存(TM)克隆表注销函数
w _ITM_registerTMCloneTable // 弱引用:事务内存(TM)克隆表注册函数
U __libc_start_main@GLIBC_2.34 // 未定义符号:GLIBC的入口函数(由动态链接器解析)
0000000000001169 T main // 文本段:用户定义的main函数
U printf@GLIBC_2.2.5 // 未定义符号:动态链接的printf函数
U puts@GLIBC_2.2.5 // 未定义符号:动态链接的puts函数
00000000000010e0 t register_tm_clones // 文本段局部符号:TM克隆注册逻辑
0000000000001080 T _start // 文本段:程序入口点(由内核调用,跳转到main)
0000000000004010 D __TMC_END__ // 已初始化数据:事务内存(TM)相关结构的结束标记
链接
作用:合并多个目标文件和库,解析符号引用,生成可执行文件。
关键操作:
-
地址重定位:确定函数和变量的最终内存地址
-
符号解析:解决跨文件的函数/变量引用(如
printf
)
链接分为动态链接和静态链接
特性 | 静态链接(Static Linking) | 动态链接(Dynamic Linking) |
---|---|---|
链接时机 | 编译时 将库代码直接嵌入可执行文件 | 运行时 由系统动态加载共享库(.so /.dll ) |
文件内容 | 可执行文件包含所有依赖的库代码,独立性强 | 可执行文件仅保留库的引用,依赖外部共享库文件 |
库文件示例 | Windows: .lib ,Linux: .a (如 libmath.a ) | Windows: .dll ,Linux: .so (如 libmath.so ) |
strace -e openat ./program 2>&1 | grep '\.so' //查看程序尝试打开哪些库文件。
由/lib可知它会动态链接里面的库文件
Linux常见目录的功能:
目录名 | 目录的功能作用 |
---|---|
/bin(binary) | 存放可执行程序或脚本文件 |
/sys(system) | 存放和系统相关的文件 |
/dev(device) | 存放设备文件 |
/etc | 一般用来存放配置文件和启动脚本 |
/lib(library) | 存放系统库文件 |
/var(variable) | 存放变化很快的文件,比如日志文件 |
/proc(process) | 存放进程相关的数据 |
/root | root用户的家目录 |
/home/{username} | 普通用户的家目录 |
/usr | 用于存放用户可执行程序、库、文档等资源。 |
gcc -static hello.c -o hello_static # 强制静态链接
当使用了强制静态链接后, 我们发现文件的大小一下子变大了不少, 由此可见静态链接虽然导致速度变快了, 但是也引发了一个问题, 可执行程序的体积体积会变得很大
方面 | 静态链接 | 动态链接 |
---|---|---|
文件大小 | 大(包含库代码) | 小(仅保留引用) |
内存占用 | 高(库代码重复加载) | 低(多进程共享库) |
启动速度 | 快(无库加载开销) | 稍慢(需加载库) |
维护性 | 差(更新需重新编译) | 好(替换库文件即可) |
兼容性 | 强(不依赖系统库) | 弱(依赖库版本) |
适用场景 | 嵌入式、独立分发、无外置库环境 | 通用软件、系统库、频繁更新的模块 |