程序的编译、链接、安装和运行

程序的编译、链接、安装和运行

1.1从源程序到二进制文件

程序的编译过程,其实就是将我们编写好的C源程序翻译成CPU能识别和运行的二进制机器指令的过程。

一个可执行文件通常由不同的的段构(section)成:代码段、数据段、BBS段、只读数据段等。每个section有一个section header来描述,包括段名、段的类型、段的起始地址、段的偏移地址和段的大小。一个可执行文件中的每一个section都有一个section header,将这些section headers集中放到一起,就是section header table,也就是节头表。C程序中定义的函数、变量、未初始化的全局变量经过编译后会放置在不同的段中:函数翻译成二进制指令放在代码段,初始化的全局变量和静态局部变量放在数据段,BSS段比较特殊,一般来讲,未初始化的全局变量和静态变量会放置在BSS段中,但是因为它们未初始化,默认值都为0,其实没有必要再单独开辟空间存储,为了节省存储空间,所以在可执行文件中BSS段是不占用空间的。但是BSS段的大小、起始地址和各个变量的地址信息会分别保存在节头表section header table 和符号表中,当程序运行时,加载器会根据这些信息在内存中紧挨着数据段的后面为BSS段开辟一片存储空间,为各个变量分配存储单元。

从C程序到可执行文件,整个编译过程并不是一气呵成、一步完成的,而是步步执行的,整个编译流程主要有以下几个阶段:预处理、编译、汇编、链接,每个阶段都需要调用不同的工具完成。

过程如下:

  1. 预处理器:将源文件 main.c 经过预处理变为 main.i
  2. 编译器:将预处理后的main.i 编译为汇编文件 main.s
  3. 汇编器:将汇编文件 main.s 编译为目标文件 main.o
  4. 链接器:将各个目标文件 main.o、sub.o链接成可执行文件 a.out
    在这里插入图片描述

1.2预处理过程

为了方便编程,编译器一般会给开发者提供一些预处理指令,使用#标识。常见的如头文件包含、定义宏、条件编译、编译控制等。

通过头文件包含可以实现模块化编程;使用宏定义可以定义一个常量,提高程序可读性;通过条件编译(#if、#else、#endif)可以让代码兼容不同的处理器架构和平台,以最大限度复用公共代码;通过#pragma 预处理命令可以设定编译器的状态,指示编译器完成一些特定的动作。

预处理主要有以下操作:

  1. 头文件展开:将#include包含的头文件内容展开到当前位置
  2. 宏展开:展开所有的宏定义,并删除#define
  3. 条件编译:根据宏定义条件,选择要参与编译的分支代码,其余的分支丢弃
  4. 删除注释
  5. 添加行号和文件名标识:编译过程根据需要可以显示这些信息
  6. 保留#pragma命令:该命令会在程序编译时指示编译器执行一些特定行为

1.3程序的编译

经过预处理后的源文件,剩下的就是原汁原味的C代码,接下来就是编译阶段。编译阶段主要分为两步:第一步,编译器调用一些列解析工具,去分析这些C代码,将C源文件编译为汇编文件;第二步,通过汇编器将汇编文件汇编成可重定位的目标文件。

1.3.1从C文件到汇编文件

这个过程其实就是从高级语言到低级语言的转换。也就是将C文件中的程序代码块、函数转换为汇编程序中的代码段,将C程序中的全局变量、静态变量、常量转换为汇编程序中的数据段、只读数据段。具体有以下六步:

  1. 词法分析
  2. 语法分析
  3. 语义分析
  4. 中间代码生成
  5. 汇编代码生成
  6. 目标代码生成

语法分析是第一步,主要用来解析C程序语句。语法分析一般会通过词法扫描器从左到右,一个字符一个字符的读入源程序,通过有限状态机解析并识别这些字符流,将源程序分解为一系列不能再分解的几号单元–token。常见token包括:

  1. C语言的各种关键字:int、float、for等
  2. 用户定义的各种标识符:函数名、变量名、标号等
  3. 字面量:数字、字符串等
  4. 运算符
  5. 分隔符

词法分析结束后,就是语法分析,主要是对前一阶段产生的token序列进行解析,看是否能构建出一个语法上正确的语法短语,语法短语用语法树来表述,是一种树型结构,不再是线性序列。

语法分析完成后,就是语义分析。语法分析仅仅对程序做语法检查,对程序、语句的真正意义并不了解,而语义分析主要是对语法分析输出的各种表达式、语句进行检查,比如说,如果你传递给函数的实参于函数声明的形参类型不一致,或者你使用了一个未声明的变量,或者除数为0等。

语义分析分析通过后,就是编译的第四个阶段,生成中间代码。是编译过程中的一种临时代码,常见的有三地址码等。中间代码是一维线性序列结构,类似伪代码,编译器很容易将中间代码翻译成目标代码。

1.3.2汇编过程

汇编过程就是使用汇编器将前一阶段生产的汇编文件翻译成目标文件。汇编器的主要工作就是参考ISA指令集,将汇编代码翻译成对应的二进制指令,同时生成一些必要的信息,以section的形式组装到目标文件中,后面的连接过程会用到这些信息。

汇编的流程主要包括:词法分析、语法分析、指令生成

通过编译生成的可重定位目标文件,都是以零地址为链接起始地址进行链接的。也就是说,编译器再将源文件翻译成可重定位目标文件的过程中,将不同的函数编译成二进制指令后,是从零地址开始依次将每一个函数的指令序列存放到代码段中,每个函数的入口地址也就从零地址开始依次往后偏移。

链接器将各个目标文件组装在一起后,我们需要重新修改各个目标文件中的变量或函数的地址,这个过程一般称为重定位。

在这里插入图片描述

1.3.3符号表与重定位表

在汇编阶段,汇编器会分析汇编语言中各个section的信息,收集各种符号,生成符号表,将各个符号在section内的偏移地址也填充到符号表内。

在整个编译过程中,符号表主要用来保存源程序中各种符号的信息,包括符号的地址、类型、占用空间的大小等。这些信息一方面可以辅助编译器作语义检查,看源程序是否有语义错误;令一方面也可以辅助编译器编译代码的生成,包括地址与空间的分配、符号决议、重定位等。

1.4链接过程

在链接过程中,这戏目标文件中的各个section会重新拆分组装,每个section的起始参数地址都会发生变化,导致每个section中定义的函数、全局变量等符号的地址,也会发生变化,需要重新修改,即重定位。这些函数、全局变量等符号同时被编译工具收集起来,放到一个符号表里,符号表以section的形式被放置在目标文件中,这些目标文件是不可执行的,它们需要经过链接器链接,重定位后才能运行。

链接主要有三个过程:分段组装、符号决议和重定位。

1.4.1分段组装

过程如下:

在这里插入图片描述

链接器根据脚本定义的规则来组装可执行文件的。

1.4.2符号决议

在团队开发时,位于不同的模块或不同的文件中的全局变量、函数可能存在重名冲突。

当这些全局变量在多个文件中定义时,链接器在链接过程中会发现:各个文件中定义了相同的全局变量或函数名,发生了符号冲突,这时,编译器引入了强符号和弱符号的概念。函数名、初始化的全局变量是强符号,而未初始化的全局变量则是弱符号。在一个工程文件中,强符号不允许多次定义,否则就会发生重定义错误。强符号和弱符号可以在一个项目中共存,强符号会覆盖掉弱符号,链接器会选择强符号作为可执行文件中的最终符号。

1.4.3重定位

经过符号决议,解决了链接过程中多文件符号冲突的问题,但是还有一个问题,符号表中的每个符号值,也就是每个函数、全局变量的地址,还是原来各个目标文件中的值,还都是基于零地址的偏移。链接器将各个目标文件重新分解组装后,各个段的起始地址都发生了变化。重定位的核心就是修正指令中的符号地址,是链接过程中的最后一步,重定位表中有一个信息很重要:需要重定位的符号在指令代码中的偏移地址offset,链接器修正指令代码中各个符号的值时根据这个地址信息才能从茫茫的二进制代码中找到它们,链接器读取各个目标文件中的重定位表,根据这些符号在可执行文件中的新地址,进行符号重定位,修正指令代码中引用这些符号的地址,并生成新的符号表。重定位过程中的地址修正其实很简单,即重定位新地址=新的段基址+段内偏移。

1.5程序的安装

程序的运行过程,就是处理器根据pc寄存器中的地址,从内存不断取指令、翻译指令、执行指令的过程,内存RAM的优点是支持随机读写,因此可以支持CPU随机读取指令;内存的缺陷是RAM属于易失性存储器,一旦断电,内存中原先保存的数据都会消失。现代计算机的存储一般采用ROM+RAM的组合形式,ROM中存储的数据断电后不会消失,常用来保存程序的指令和数据,因此,程序运行时,会首先将指令和数据从ROM加载到RAM,然后CPU到RAM中取指令就行了。

软件安装的过程就是将一个可执行文件安装到ROM的过程。

1.6程序的运行

程序运行有两种,一是在有操作系统的环境下执行一个应用程序;另一种是在无操作系统的环境下执行一个裸机程序。在不同的环境下执行程序,文件的格式一般也不会相同。在Linux下一般是ELF,裸机下一般是BIN/HEX。

1.6.1操作系统下的程序运行

当执行一个程序时,首先会运行一个叫做加载器的程序,加载器会根据软件的安装路径信息,将可执行文件从ROM中加载到内存,然后进行一些与初始化、动态库重定位相关的操作,最后才跳转到程序的入口运行。

在Linux环境下运行的程序一般都会被封装成进程,参与操作系统的统一调度和运行,在shell环境下运行一个程序,shell终端程序一般会先fork一个子进程,创建一个独立的虚拟进程地址空间,接着调用execve 函数将要运行的程序加载到进程空间:通过可执行文件的文件头,找到程序的入口地址,建立进程虚拟地址空间与可执行文件的映射关系,将pc指针设置为可执行文件的入口地址,即可启动运行。

1.6.2裸机下的程序运行

在一个裸机平台,系统上电后,没有程序运行的环境,需要借助第三方工具将程序加载到内存,才能正常运行。

例如,通过JTAG接口和开发板通信,将我们在pc上编译好的BIN/HEX格式的可执行文件下载到开发板的内存中运行。

在一个嵌入式Linux系统中,Linux内核镜像的运行其实就是裸机环境下的程序运行,Linux内核镜像一般会借助U-boot这个加载工具将其从Flash存储分区加载到内存中运行,U-boot在Linux启动过程中扮演了加载器的角色。

1.6.3程序入口main()函数分析

在main函数运行前,已经有代码提前运行了。它们主要负责完成运行初始化工作,比如初始化堆栈指针等。栈是C语言运行的必备环境,C语言函数调用过程中的参数传递、函数内部的局部变量都是保存在栈中,没有栈,c语言就无法运行,因此在运行main函数之前,必须先运行一段汇编代码来初始化堆栈环境。设置好指针后,还要初始化一些环境,例如初始化data段的内容,初始化static静态变量和global全局变量,并给BSS段的变量赋初值:未初始化的全局变量中,int类型全部初始化为0,布尔类型初始为false,指针类型初始化为NULL。最后,再将用户传入的参数传递给main,跳入main函数运行。

这部分初始化代码是在程序编译阶段,由编译器自动添加到可执行文件中。

1.6.4BSS段的小秘密

对于未初始化的全局变量和静态局部变量,编译器将其放置在BSS段。BSS段不占用可执行文件存储空间,在但是当程序加载到内存运行时,加载器会在内存中给BSS段开辟一段存储空间。

1.7链接静态库

库分为静态库和动态库两种,如果在项目中引用了库函数,则在编译的时候,链接器会将我们引用的函数代码或变量,链接到可执行文件里,和可执行程序组装到一起,这种库被称为静态库,即在编译阶段链接的库。动态库在编译阶段不参与链接,不会与可执行文件组装在一起,而是在程序运行时才被加载到内存参与链接,因此又叫动态链接库。

静态库的本质就是可重定位目标文件的归档文件。

编译器是以源文件为单位编译程序的,链接器在连接过程中逐个对目标文件进行分解组装,会产生一个问题,如果在一个源文件中定义一百个函数,而只使用一个,那么链接器会把这100个函数的代码指令全部组装到可执行文件中,会造成最终可执行文件提价大大提高。所以,在封装函数库时,将每个函数都单独使用一个源文件实现,然后将多个目标文件打包即可。

静态链接还会产生另一个问题,如C标准库里的printf函数,可能多个程序都调用了他,链接器在链接时就要将printf的指令添加到多个可执行文件中,在一个多任务环境中,当多个进程并发运行时,内存会有大量财富的printf指令代码,浪费内存资源。所以需要动态链接。

1.8动态链接

静态链接的缺点:生成的可执行文件体积较大,当多个程序引用相同的公告代码时,多次加载到内存,会造成内存浪费。所以,动态链接对静态链接做了一些优化,对一些公共的代码,如库,在链接期间暂不链接,而是推迟到程序运行时再进行链接。这些在查询运行时才参与链接的库被称为动态链接库。

发运行时,内存会有大量财富的printf指令代码,浪费内存资源。所以需要动态链接。

1.8动态链接

静态链接的缺点:生成的可执行文件体积较大,当多个程序引用相同的公告代码时,多次加载到内存,会造成内存浪费。所以,动态链接对静态链接做了一些优化,对一些公共的代码,如库,在链接期间暂不链接,而是推迟到程序运行时再进行链接。这些在查询运行时才参与链接的库被称为动态链接库。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值