文章目录
前言
我们在运行.c文件时,做的第一步就是编译程序,在点击编译后,编译器会在背后进行很多操作。此时,如果程序没有问题就会正常运行,否则就会报错。要想解决报错,就得明确编译器在这背后到底为我们做了什么。本文首先介绍程序在运行过程中的内存分布,然后介绍编译器的编译过程以及使用动态库和静态库进行编译。
通过学习本文,你将掌握以下知识:
- 程序运行过程中所有存储对象的内存分区
- 明晰(静态)局部变量、(静态)全局变量、全局函数和静态函数的存储区域、生命周期以及作用域
- gcc编译器预处理、编译、汇编以及链接的过程
- 静态库和动态库的制作以及分别使用它们编译
一、运行中的内存分区
在一个进程中,系统会为每个进程分配虚拟的4G内存(32位系统),而这些空间又跟据不同的功能被分为了代码区、文字常量区、全局区、栈区和堆区5个区域。
其中代码区存放代码的二进制指令;文字常量区存放常量、字符常量、字符串常量,如‘2’,’a‘,“1234abc”
;全局区存放全局变量、静态局部变量、静态全局变量(后文细讲);栈区存放局部变量、函数的形参、大于4字节的函数返回值(小于4字节的在寄存器中);堆区用户自己管理,即通过malloc、realloc、calloc
申请内存和free
释放内存。
1.内存中的各类对象
让我们首先通过以下代码了解什么是局部变量、全局变量、静态局部变量、静态全局变量、全局函数和静态函数。
#include <stdio.h>
// 全局变量
int global_var = 10;
// 静态全局变量
static int static_global_var = 20;
// 全局函数声明
void global_function(void);
// 静态函数声明
static void static_function(void);
int main() {
// 局部变量
int local_var = 30;
// 静态局部变量
static int static_local_var = 40;
printf("Global variable: %d\n", global_var);
printf("Static global variable: %d\n", static_global_var);
printf("Local variable: %d\n", local_var);
printf("Static local variable: %d\n", static_local_var);
global_function();
static_function();
return 0;
}
// 全局函数定义
void global_function(void) {
printf("This is a global function.\n");
}
// 静态函数定义
static void static_function(void) {
printf("This is a static function.\n");
}
2.各类对象对比
项目 | 定义形式 | 作用范围 | 生命周期 | 内存区域 |
---|---|---|---|---|
局部变量 | {}内部定义的变量 | {}内 | {}内 | 栈区 |
全局变量 | {}外部定义的变量 | 整个程序(其他源文件使用需要extern声明) | 整个进程 | 全局区 |
静态局部变量 | 在{}里面用static修饰 | {}内 | 整个进程 | 全局区 |
静态全局变量 | 在{}外面用static修饰 | 当前源文件 | 整个进程 | 全局区 |
全局函数 | 普通定义的函数 | 当前源文件(其他需声明) | - | 代码区 |
静态函数 | 使用static修饰 | 当前源文件(不可被其他源文件使用) | - | 代码区 |
注意事项:
- 局部变量不初始化内容不确定,而其他三种默认为0
- 全局变量想要在其他源文件使用,必须使用
extern
声明
作用范围:在上表中我们能够看到只要是局部变量,不管是普通局部变量还是静态局部变量,作用范围都只限{}内,但是他们的生命周期却不同。我们来看下面程序:
#include <stdio.h>
void localVariable() {
int localVar = 0;
localVar++;
printf("Local Variable: %d\n", localVar);
}
void staticLocalVariable() {
static int staticLocalVar = 0;
staticLocalVar++;
printf("Static Local Variable: %d\n", staticLocalVar);
}
int main() {
printf("First call:\n");
localVariable(); // 1
staticLocalVariable(); // 1
printf("Second call:\n");
localVariable(); // 1
staticLocalVariable(); // 2
return 0;
}
以上定义了两个函数,localVariable
函数包含普通局部变量localVar
,每次调用都会重新初始化;staticLocalVariable
函数包含静态局部变量staticLocalVar
,仅初始化一次。main函数两次调用这两个函数并打印结果,体现出普通局部变量每次调用结果相同,静态局部变量值会累加,反应了静态局部变量的生命周期为整个进程,只有当进程结束,全局区的内存才会释放。
对于静态全局变量,他的作用域为当前源文件,对于这一点可能有的小伙伴会说了,为什么我全局变量和静态全局变量的生命周期都为整个进程,在运行期间对内存的占用都是相同的,为什么要搞个静态全局变量呢?大家请思考这样一个场景,在同一个公司中,大家合作开发同一个产品,不同的人实现不同的功能,张三在他的源文件中定义了num这个变量,但是这个变量又在这个源文件的多个地方使用,因此他将这个变量定义为全局变量,这时李四并不知道张三定义了这样一个变量名,也要定义这样一个全局变量,那么当项目整合之后必然会出现重定义的问题。因此,为了避免这种现象,使用静态全局变量是一个很好的选择,它的作用域为当前源文件,这样就起到了一个源文件隔离的作用。静态函数和全局函数同理。
二、编译过程
编译器编译分为预处理、编译、汇编和链接四个部分,接下来分别介绍各个部分。
1.预处理
在预处理阶段,编译器主要会进行①头文件包含,②宏替换,③条件编译以及④删除注释,此过程不做语法检查,只是简单的文本级别操作。我们从以下例子来看编译器的编译过程:
首先我们在mian函数导入“fun.h
"头文件,然后定了一个int
变量num
,他的值为PI的宏定义。
在fun.h
中,我们声明了两个全局函数,并定义了PI宏。
接着使用gcc -E main.c -o main.i
预处理指令,输出.i文件。
在mian.i中,橙色的字符串是预处理指令,用于指示下一步编译阶段需要执行的操作;接下来是fun.h头文件中的内容,可以看到①头文件包含操作是将.h文件中的内容照搬过来,而②定义的宏PI此时已经替换成3.14159,并且③删除了.c源文件中第5行的注释。
2.编译
在编译阶段,编译器会将.i文件转换成.s汇编文件,此过程会做语法检查,包括词法分析、语法分析、语义分析等。我们使用gcc -S main.i -o main.s
进行编译操作:
得到如下.s文件:
3.汇编
在汇编阶段,编译器会将.s汇编文件转换成二进制.o文件,即将人类可读的汇编代码转换成机器能够执行的二进制代码。我们使用gcc -c main.s -o main.o
执行汇编指令,将汇编文件转换成二进制.o文件:
mian.o
文件为二进制文件,这里不再展示。
4.链接
这是生成可执行文件的最后一步,此阶段会将各个独立的二进制文件、库函数加上启动代码一起生成可执行文件。我们使用gcc main.o -o main
将上一步得到的二进制文件通过链接生成最后的可执行文件:
以上过程展示了编译的各个阶段,在日常使用中我们使用gcc 源文件.c -o 可执行文件名
编译,这时编译器会按照下图所示例子工作:
三、多文件编译
我们在开发某一项目时往往不止一个源文件,通常是一个源文件实现某一功能,服务于这一功能的所有函数在这个源文件中实现。这样一个项目就会有好多源文件共同支持,我们在main.c
中进行调用即可。那么这种涉及到多个源文件的程序应该怎么编译呢?
在前面的全局函数和静态函数讲解中我们知道,要在全局函数源文件外部使用全局函数就必须在使用前用extern
声明,这就是跨文件使用函数的桥梁。为了统一管理这些桥梁,源文件通常还有一个同名的.h文件,也就是头文件。我们一般会将函数、变量的声明,宏定义等集中写在头文件中,这样我们在使用其他源文件的函数时,只需要引入相应的头文件即可,我们知道在预处理阶段,编译器会进行头文件替换,也就是帮我们声明好这些函数。我们来看这样一个例子:
在主函数中,我们想使用fun.c
源文件中的greet()
函数,我们只需要导入fun.h
头文件即可,这样编译器在预处理阶段会将头文件中的内容替换。在主函数中,还使用了printf()
函数,因此我们要在使用前声明,也就是引入它的头文件stdio.h
。
这里需要强调一点的是,在引入stdio.h
头文件时,我们使用的是<#include stdio.h>
,而在引入fun.h
时,我们使用的是"#include fun.h"
,这是因为#include ""
:从当前目录查找头文件,如果找不到 才从系统指定目录找头文件(包用户定义的头文件),而我们的fun.h
就在当前主函数路径下;#include<>
:只从系统指定目录去找头文件(包含系统头文件)。
之后我们使用gcc main.c fun.c编译。(编译过程中需要将使用到的所有源文件都加在gcc后)
需要说明的是printf()
函数是系统级函数,我们直接使用就可以,那么这些系统级头文件和源文件在哪呢?在linux系统中头文件通常在/usr/lib
中,源文件通常会被封装为库,其中库分为静态库和动态库,接下来我们详细介绍静态库和动态库的制作与使用。
四、静态库和动态库
1. 静态库和动态库的区别
静态库和动态库主要是在编译器的链接阶段,静态库以.a
结尾,链接会将静态库中所有的函数都链接到可执行文件中;而动态库以.so
结尾,在编译器链接阶段仅仅建立和动态库的链接关系,只有在运行阶段才会将库函数包含在可执行文件中。
我们将上述代码分别使用gcc main.c fun.c -o main
动态编译,gcc main.c fun.c -o main_static -static
静态编译,从上述图中可以看到静态编译所占的内存为888K个字节,而使用动态库编译仅占8.6K个字节。
项目 | 静态库 | 动态库 |
---|---|---|
优点 | 对库的依赖不大 | 生成的可执行文件小 |
缺点 | 可执行文件大、如果修改库函数需要重新链接 | 对库的环境依赖大 |
2. 静态库的制作与使用
(1)静态库的制作
首先使用gcc -c fun.c -o fun.o
将源文件生成.o文件,然后使用ar rc libmylib.a fun.o
生成静态库。(注意:这里libmylib.a
中lib
为前缀,真正的库名为mylib
!!!)
(2)静态库的使用
这里根据静态库的位置不同可分为三种情况:(1)静态库在项目目录(2)静态库在其他目录(3)静态库在系统指定目录
(1)静态库在项目目录
我们直接使用gcc main.c libmylib.a -o main
进行编译
(2)静态库在其他目录
有时后会将头文件和库放到其他具有某一共性的文件夹下,比如我们现在将fun.h
和libmylib.a
放到项目的fun
文件夹下,这时在编译时需要使用-I
指定头文件路径-L
指定库路径,同时-l
指定库名称,如:gcc main.c -I./fun -L./fun -lmylib
(这里没有-o指定可执行文件的名称,默认为a.out
(3)静态库在系统指定目录
- 系统默认的头文件路径:
/usr/include
- 系统默认的库的路径:
/usr/lib
我们前面讲到#include ""
:从当前目录查找头文件,如果找不到 才从系统指定目录找头文件(包用户定义的头文件),而我们的fun.h
就在当前主函数路径下;#include<>
:只从系统指定目录去找头文件(包含系统头文件)。但是经常用#include<>
导入系统头文件,#include ""
导入用户头文件。当静态库放入系统指定的目录时只需要指定库名称即可,即gcc main.c -lmylib
3. 动态库的制作与使用
(1) 动态库的制作
直接使用gcc -shared main.c -o libmylib.so
生成.so
结尾的动态库,注意动态库的名称为mylib
,lib为前缀!!!
(2) 动态库的使用
同静态库的使用方法,值得注意的是,当静态库和动态库在统一位置时优先使用动态库,如果要使用静态库要加-static