Bootstrap

【嵌入式C语言破壁指南系列——编译器的那些事】


前言

我们在运行.c文件时,做的第一步就是编译程序,在点击编译后,编译器会在背后进行很多操作。此时,如果程序没有问题就会正常运行,否则就会报错。要想解决报错,就得明确编译器在这背后到底为我们做了什么。本文首先介绍程序在运行过程中的内存分布,然后介绍编译器的编译过程以及使用动态库和静态库进行编译。

通过学习本文,你将掌握以下知识:

  1. 程序运行过程中所有存储对象的内存分区
  2. 明晰(静态)局部变量、(静态)全局变量、全局函数和静态函数的存储区域、生命周期以及作用域
  3. gcc编译器预处理、编译、汇编以及链接的过程
  4. 静态库和动态库的制作以及分别使用它们编译

一、运行中的内存分区

在一个进程中,系统会为每个进程分配虚拟的4G内存(32位系统),而这些空间又跟据不同的功能被分为了代码区、文字常量区、全局区、栈区和堆区5个区域。

图片转自:https://blog.csdn.net/zw_whusgg/article/details/126288914
其中代码区存放代码的二进制指令;文字常量区存放常量、字符常量、字符串常量,如‘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.alib为前缀,真正的库名为mylib!!!)
在这里插入图片描述

(2)静态库的使用

这里根据静态库的位置不同可分为三种情况:(1)静态库在项目目录(2)静态库在其他目录(3)静态库在系统指定目录
(1)静态库在项目目录
我们直接使用gcc main.c libmylib.a -o main进行编译
在这里插入图片描述

(2)静态库在其他目录

有时后会将头文件和库放到其他具有某一共性的文件夹下,比如我们现在将fun.hlibmylib.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

;