目录
注:
本笔记参考:B站up 鹏哥C语言
推荐书籍
- 《程序员的自我修养》
程序的翻译环境和执行环境
在ANSI C的任何一种实现中,存在两个不同的环境:
- 翻译环境:在这个环境中源代码被转换成可执行的机器指令。
- 执行环境:这个环境被用于实际执行代码。
编译和链接
翻译环境
- 每一个源文件都会 单独 经过编译器的处理,各自写成形成一个目标文件(.obj)。
- 这些目标文件会一起经过链接器,连接器会把这些 目标文件 还有一些 链接库 全部链接在一起,生成一个可执行程序。
链接库(Libraries)
以fread函数的链接库为例
LIBC.LIB Single thread static library, retail version LIBCMT.LIB Multithread static library, retail version MSVCRT.LIB Import library for MSVCRT.DLL, retail version 这些链接库内包含的是fread函数的一些相关信息。
翻译环境包含了两大步骤:
- 编译 - 把每个源文件进行编辑处理,形成目标文件,编译依赖的是 编译器 。(VS 作为一种IDE,本身自带了编译器,一般是 cl.exe )
- 链接 - 依赖的是 链接器 。(VS 同样有链接器,一般是 link.exe )
在Linux系统下的编译过程展示(test.c):
gcc test.c — 默认生成一个 a.out(可执行程序),这个可执行程序(a.out)可以通过输入 .\a.out 的方式执行它。
编译
预处理
接下来首先解析预处理步骤,在预处理阶段,编译器完成的工作有:
- 头文件的包含;
- 对#include定义的符号和宏的替换;
- 注释的删除。
例如:
#include<stdio.h> int g_val = 2022; int ADD(int x, int y) { return x + y; } int main() { int a = 10; int b = 20; int ret = ADD(a, b); printf("%d\n", ret); return 0; }
接下来,在Linux系统下编译该代码。
第一步(只执行预处理步骤):
gcc test.c - E //直接在控制台上进行输出 gcc test.c -E > test.i //把输出结果重定向到test.i中
此时如果打开 test.i ,会发现,文件内部存在大量之前没有见过的内容,截取一部分:
注:在写程序引用头文件时,需要使用 include ,如 #include<stdio.h> 这种代码的工作原理就是把 stdio.h 里面的代码拷贝到 源文件 里面。
会发现
- flockfile
- ftrylockfile
- funlockfile
这三个符号,那如何证明这三个符号也出现在 stdio.h 这个头文件下面呢?现在让我们打开头文件所在目录:/usr/include/
发现该目录下面存在大量头文件。接下来打开 stdio.h ,vim stdio.h
同样,在 stdio.h 中,我们找到了这三个字符。这就可以证明源文件在引用头文件时,会拷贝头文件的内容。
通过上面的例子我们可以发现,在预处理阶段,完成了头文件的包含。
接下来再在源代码 test.c 中增加 宏 和 定义:
再次编译:gcc test.c -E > test.i
再次打开 test.i ,观察:
发现
- include指令已经不见了;
- 原本的 M 已经变成了 1000 了;
- define定义的 宏 也替换成了 ((100)>(200)?(100):(200)) 。
所以预处理阶段完成的第二件事情就是对#define定义的符号和宏的替换。
而如果再在 test.c 中写入注释:
再次编译至预处理步骤,会发现
原本注释所在的地方,注释不见了。所以预处理阶段完成的第三件事情就是删除注释。
编译
在编译阶段,完成的工作有:
- 语法分析
- 词法分析
- 语义分析
- 符号汇总
也就是把C语言代码转换成汇编代码。(涉及 编译原理)
在Linux系统下,编译 test.c 对应的命令是
gcc -S test.c //编译 test.i 也可以
gcc -S test.c > test.s
(接下来的说明使用的例子也是 test.c )
在执行完编译后,写入 test.s 内的是汇编代码:
汇编
在Linux系统下对 test.c 进行汇编的命令是
gcc -c test.c
gcc -c test.c > test.o //(在Windows平台下是 test.obj )
生成的是目标文件 test.o 。
打开文件
发现出现的是无法看懂的乱码,其实这些乱码就是二进制信息。
从上面可以得出,在汇编中进行的工作是:
- 把汇编代码转换成机器指令(二进制指令);
- 另外,在汇编中还完成了 生成符号表。
注意:test.o 这个文件是有格式的,这个格式就是 elf格式 。在这种格式下, test.o 被划分成了一个一个的“段”,每个段内存放的内容是不同的。(注:可执行文件 .out 也是elf格式的)
既然有格式,那么理所当然就可以使用工具看懂这个文件,比如:readelf 。接下来就使用 readelf 打开 test.o 。
使用命令:
readelf test.o -s
生成了:
其中,红框内的符号可以和 test.c 内的全局变量、ADD函数、主函数和printf函数对应起来。
注意:编译阶段,符号汇总也是对上述这些变量和函数进行的汇总。
为了更好地说明编译器进行工作的过程,我们把 主函数 和 ADD函数 拆成两部分(使用VS 2022)。
![]()
接下来在Linux内进行预处理:
gcc test.c -c 生成 test.o
gcc add.c -c 生成 add.o
接下来查看 add.o
在这里可以看到 ADD 对应的值是 1 。
再观察 test.o
这就是编译阶段进行的符号汇总,之后在汇编阶段生成的符号表。
那么生成的符号表有什么用吗?这就要进入下一个阶段 —— 链接 了。
链接
链接 — 把多个目标文件和链接库进行链接。
在该阶段完成的任务是:
- 合并段表;(将不同目标文件elf格式下的相同段合并起来)
- 符号表的合并和符号表的重定位。
假如删除拥有有效地址的ADD②,最后生成的表就会是这样:
ADD拥有的就是无效的地址,这时候如果继续编译,会发现无法通过。
这个报错就是因为ADD没有意义导致的。
注:只有当地址有效时,链接器才可以通过地址找到函数。
所以编译阶段的符号汇总、汇编阶段的生成符号表和链接阶段的合并符号表和重定位都是在为链接时跨文件链接做准备。
运行环境/执行环境
程序运行的过程:
- 程序必须载入内存中。这个操作如果是在有操作系统的环境中,一般是由操作系统完成;如果是在独立的系统中,则需要手工进行安排,或者通过可执行代码置入只读内存的方式完成。
- 程序的执行就是开始,接着就是调用main函数。
- 开始执行程序代码。这个时候程序将运行时堆栈(stack)(或者函数栈帧),存储函数的局部变量和返回地址。同时,程序也可以使用静态(static)内存,存储在静态内存中的变量在程序的整个执行过程中会一直保留他们的值。
- 终止程序。正常终止于main函数,也可能是以外终止。