前言
sizeof是函数,是关键字,还是预定义标识符?
使用64位GCC编译器编译生成64位可执行文件,运行在32位环境下,结果如何?
代码中宏定义、零长度数组、位运算、结构体变量初始化的作用。[插图]
1.2 程序编译工具:make
- 一个Makefile通常是由一个个规则构成的,规则是构成Makefile的基本单元。一个规则通常由目标、目标依赖和命令3部分构成。
- 目标一般指我们要生成的可执行文件
- 或各个源文件对应的目标文件
- 命令可以是编译命令,可以是链接命令,也可以是一个Shell命令,命令必须以Tab键开头。
2.1 一颗芯片是怎样诞生的
- CSP可以让芯片面积减小到DIP的四分之一,同时具备信号传输延迟短、寄生参数小、电热性能更好的优势,更适合高频电路的封装
- 层叠封装(Package-on-Package,PoP)技术此时就应运而生了。PoP可以将多个芯片元器件分层堆叠、互连,封装在一个芯片内,从而让整个芯片更薄、体积更小。
2.2 一颗CPU是怎么设计出来的
- 任何复杂的运算都可以分解为有限个基本运算指令。
- ● 一个读写头Head:相当于程序计数器PC。
- 每一种CPU只能支持有限个指令,任何复杂的运算最终都可以分解成有限个基本指令来完成:加、减、乘、除、与、或、非、移位等算术运算或逻辑运算。
- CPU内部构造很简单,只包含基本的算术逻辑运算单元、控制单元、寄存器等,仅支持有限个指令。
- 为了效率考虑,运算结果一般会先保存到寄存器中
- 任何复杂的运算都可以分解为指令集中的基本指令
2.3 计算机体系结构
- 这类存储器支持随机访问,CPU可以随机到它的任意地址去读写数据,访问非常方便,但缺点是断电后数据会立即消失,无法永久保存
- 采用冯·诺依曼架构的计算机,其特点是程序中的指令和数据混合存储,存储在同一块存储器上
- 哈弗架构的特点是:指令和数据被分开独立存储,它们分别被存放到程序存储器和数据存储器。
- 为了减少内存瓶颈带来的影响,CPU引入了Cache机制:指令Cache和数据Cache,用来缓存数据和指令,提升计算机的运行效率。
- 当CPU到RAM中读数据时,内存RAM不是一次只传输要读取的指定字节,而是一次缓存一批数据到Cache中,等下次CPU再去取指令和数据时,可以先到这两个Cache中看看要读取的数据是不是已经缓存到这里了,如果没有缓存命中,再到内存中读取。
- Cache缓存机制大大提高了CPU的访问效率
2.4 CPU性能提升:Cache机制
- Cache在物理实现上其实就是静态随机访问存储器(Static Random Access Memory,SRAM),Cache的运行速度介于CPU和内存DRAM之间,是在CPU和内存之间插入的一组高速缓冲存储器,用来解决两者速度不匹配带来的瓶颈问题。
- 此时Cache和内存RAM的数据就不一致了,缓存的每块空间里一般会有一个特殊的标记位,叫“Dirty Bit”,用来记录这种变化
- 一个CPU芯片内部会集成多个Core,每个Core都会有自己独立的L1 Cache,包括D-Cache和I-Cache。
一是这些处理器都是低功耗、低成本处理器,在CPU内集成Cache会增加芯片的面积和发热量,不仅功耗增加,芯片的成本也会增加不
这些处理器本来工作频率就不高(从几十兆赫到几百兆赫不等),和RAM之间不存在带宽问题
三是使用Cache无法保证实时性。当缓存未命中时,CPU从RAM中读取数据的时间是不确定的,这是嵌入式实时控制场景无法接受的
2.5 CPU性能提升:流水线
- 一条指令的执行一般要经过取指令、翻译指令、执行指令3个基本流程
- 流水线的本质其实就是拿空间换时间
- 每一道工序都称为流水线中的一级,流水线越深,每一道工序的执行时间就会变得越小,处理器的时钟周期就可以更短,CPU的工作频率就可以更高,进而可以提升CPU的性能,提高工作效率
- 我们把5级以上的流水线称为超流水线结构。为了提升CPU主频,高性能的处理器一般都会采用这种超流水线结构。
- 流水线的本质是拿空间换时间,流水线越深,电路会越复杂,就需要更多的组合逻辑电路和寄存器,芯片面积也就越大,功耗也就随之上升了
- 流水线越深,一旦预取指令失败,浪费和损失就会越严重,因为流水线中预取的几十条指令可能都要丢弃掉,此时流水线就发生了停顿,无法按照预期继续执行,这种情况我们一般称为流水线冒险(hazard)。
- ● 结构冒险:所需的硬件正在为前面的指令工作。● 数据冒险:当前指令需要前面指令的运算数据才能执行。● 控制冒险:需根据之前指令的执行结果决定下一步的行为。
- 现在的经典CPU流水线一般分为5级:取指、译码、执行、访问内存、写回。
- 现在的CPU流水线在取指和译码时,都要对跳转指令进行分析,预测可能执行的分支和路径,防止预取错误的分支路径指令给流水线带来停顿。
- 分支预测可分为静态预测和动态预测。静态预测在程序编译时通过编译器进行分支预测,这种预测方式对于循环程序最有效,它可以根据你的循环边界反复取指令
我们在编写有跳转分支的程序时,要记得把大概率执行的代码分支放在前面,这样可以明显提高代码的执行效率。
- 一旦预测失败,就会浪费很多时钟周期去冲刷前面预取错误的流水线,
将大概率执行的分支放到前面
- 造成流水线冲突的根源在于指令之间存在相关性:前后指令之间要么产生数据冒险,要么产生结构冒险
- 双发射处理器可以在一个时钟周期内同时分发(dispatch)多条指令到不同的执行单元运行,让CPU同时执行不同的计算(加法、乘法、浮点运算等),从而达到指令级的并行。
- VLIW实现简单,不需要额外的硬件,通过编译器在编译阶段就可以完成指令的并行。
- 采用SuperScalar结构的处理器又叫超标量处理器,如图2-43所示,这种处理器在多发射的实现过程中会增加额外的取指单元、译码单元、逻辑控制单元等硬件电路
- 乱序执行是串行执行指令,只不过调整了指令的执行顺序而已,而SuperScalar则是并行执行多条指令。
2.6 多核CPU
- 半导体工艺和架构是提升CPU性能的双驾马车。CPU的发展史,其实就是处理器架构和半导体工艺交互升级、协同演进的发展史。
- 在相同的工艺制程下,通过不断优化CPU架构,从Cache、流水线、乱序执行、SIMD、多发射、指令预测等方面不断更新迭代,就可以设计出比别家公司性能更高、功耗更低的处理器。
- 总线型连接也有缺陷,在某一个时刻只允许一对设备进行通信
- 当处理器的Core很多时,我们不再使用总线型连接,而是使用网络节点的方式连接。每个节点包括计算单元、通信单元及其附属电路。计算和通信实现了分离,每一个节点中的处理单元可以是一个Core,也可以是一个小规模的SoC。Core与Core之间的通信基于通信协议进行,数据包在网络中按照设定的路由算法传输,通过网络通信的分布化来避免总线的竞争。
- 将网络路径中每一条线的首尾路由节点相连,就变成了二维的Ring Bus结构,即Torus网络,可以进一步减少路由路径较远时带来的通信延迟。
- 超线程技术通过增加一定的控制逻辑电路,使用特殊指令可以将一个物理处理器当两个逻辑处理器使用,每个逻辑处理器都可以分配一个线程运行,从而最大限度地提升CPU的资源利用率
- 我们通过增加一些控制逻辑电路,保存各个线程的状态,共享ALU、Cache等共享资源,就可以在一个物理Core上实现两个逻辑Core,操作系统可以给每个逻辑Core都分配1个线程运行。
- 但是两个线程之间可以互相协助运行,一般处理器上的两个线程上下文切换需要20 000个时钟周期,而超线程处理器上的两个线程切换只需要一个时钟周期就可以了,上下文切换的时间开销大大减少。
- 超线程技术其实就是“欺骗”操作系统,让操作系统认为它有更多的Core,给它分配更多的任务执行,通过减少CPU的空闲时间来提高CPU的利用率。
- 截至目前,市面上还没有发现使用超线程技术的ARM处理器
2.7 后摩尔时代:异构计算的崛起
- 显卡将数字图像信号转换为模拟信号,并输出到屏幕上
- GPU也是一种SIMD结构,但和CPU不同的是,它没有复杂的控制单元和Cache,却集成了几千个,甚至上万个计算核心
- 相比CPU,DSP有三个优势:一是DSP采用哈弗架构,指令和数据独立存储,并行存取,执行效率更高
- DSP有专门的硬件乘法器,可以在一个时钟周期内完成乘法运算。
- SP的缺陷是只适合做大量重复运算,无法像CPU那样提供一个通用的平台,DSP处理器虽然有自己的指令集和C语言编译器,但对操作系统的支持一般。
- FPGA不依赖冯·诺依曼体系结构,也不要编译器编译指令,它直接将硬件描述语言翻译为晶体管门电路的组合,实现特定的算法和功能
- ,在数据并行处理方面最具优势
- 在同构处理器时代,我们一般使用主频来衡量一个处理器的性能
- 异构处理器时代,
- 一般使用浮点运算能力来衡量一个处理器的性能。
- 每秒浮点运算次数(Floating Point Operations Per Second,FLOPS),又称为每秒峰值速度。
- 浮点运算能力=处理器核数×每周期浮点运算次数×处理器主频
- NPU(Neural Network Processing Unit,神经网络处理器)是面向人工智能领域,基于神经网络算法,进行硬件加速的处理器统称。
- NPU使用电路来模拟人类的神经元和突触结构,用自己指令集中的专有指令直接处理大规模的神经元和突触。
- 边缘计算指在靠近物或数据源头的一侧,采用网络、计算、存储、应用为一体的开发平台,就近提供最近端服务。
- NPU一条指令就可以完成一组神经元的处理,并对神经元和突触数据在芯片上的传输提供一系列专门的优化和支持,从而在算力性能上比CPU提高成百上千倍。
2.8 总线与地址
- 在一个计算机系统中,CPU内部的寄存器是没有地址的,可直接通过寄存器名访问。而内存和外部设备控制器中的寄存器都需要有一个地址,然后CPU才能通过地址去读写这些外部设备控制器的寄存器,控制外部设备的运行,或者根据地址去读写指定的内存单元。
- 当CPU想访问其中一个存储单元时,可通过CPU管脚发出一组信号,经过译码器译码,选中与这个信号对应的存储单元,然后就可以直接读写这块内存了。CPU管脚发出的这组信号,也就是存储单元对应的编号,即地址。
- 地址的本质其实就是由CPU管脚发出的一组地址控制信号。因为这些信号是由CPU管脚直接发出的,因此也被称为物理地址
- 地址信号线的位数决定了寻址空间的大小,
- 寻址空间和一个计算机系统实际的内存大小并不是一回事
- 在带有MMU的CPU平台下,程序运行一般使用的是虚拟地址,MMU会把虚拟地址转换为物理地址,然后通过CPU管脚发送出去,地址信号通过译码,选中指定的内存存储单元,再进行读写操作。
- 总线其实就是各种数字信号的集合,包括地址信号、数据信号、控制信号等
- 不同的总线之间通过桥(bridge)来连接。桥一般是一个芯片组电路,用来将总线的电子信号翻译成另一种总线的电子信号。
- 计算机一般采用两种编址方式:统一编址和独立编址。统一编址,顾名思义,就是内存RAM和外部设备共享CPU的寻址空间
- 和统一编址相对应的是独立编址。在独立编址模式下,内存RAM和外部设备的寄存器独立编址,分别占用不同的地址空间。
2.9 指令集与微架构
- 图灵原型机的基本思想是:任何复杂的运算都可以分解为有限个基本指令的组合来完成。
- CPU支持的有限个指令的集合,我们称之为指令集。
- 指令集最终的实现就是微架构,就是CPU内部的各种译码和执行电路。
- 为什么我们编写的高级程序经过编译后,可以直接在CPU上运行呢?就是因为CPU设计者和编译器开发者遵循的是同一个指令集标准,编译器最终编译生成的指令,都是CPU硬件电路支持运行的指令。每一种不同架构的CPU一般都需要配套一个对应的编译器。
- 指令集作为CPU和编译器的设计规范和参考标准,主要用来定义指令的格式、操作数的类型、寄存器的分配、地址的格式等,指令集主要由以下内容组成。
- 例如ARM指令集,从最初的ARM V1发展到目前的ARM V8,一直在不断地发展,不断添加新的指令。
- 在一个ARM SoC芯片上,我们把CPU内核和各种外设IP通过AMBA总线连接起来,构成一个片上系统,即System On Chip,简称SoC。
- ARM公司有多种灵活的授权方式,目前主要有以下三种。● 指令集/架构授权。● 内核授权。● 使用授权。
- 一个指令通常由操作码和操作数组成
- 为了方便编程,我们给这些二进制指令定义了各种助记符,这种助记符其实就是汇编指令
- 我们将可执行文件通过反编译生成汇编代码进行分析,就可以很直观地看到高级语言的这些过程在底层到底是怎么实现的。
第3章 ARM体系结构与汇编语言
- 通过反汇编分析,我们可以从体系结构和底层汇编这样一个新视角去窥探程序的运行机制,如函数调用、参数传递、内存中堆栈的动态变化等,会对C语言有一个更深的理解
3.1 ARM体系结构
- 计算机的指令集一般可分为4种:复杂指令集(CISC)、精简指令集(RISC)、显式并行指令集(EPIC)和超长指令字指令集(VLIW)。
RISC指令集相对于CISC指令集,主要有以下特点
- Load/Store架构
- ,CPU不能直接处理内存中的数据,要先将内存中的数据Load(加载)到寄存器中才能操作,然后将处理结果Store(存储)到内存中。
- 固定的指令长度、单周期指令
- 倾向于使用更多的寄存器来存储数据,而不是使用内存中的堆栈,效率更高。
ARM指令集虽然属于RISC,但是和原汁原味的RISC相比,还是有一些差异的
- ARM有桶型移位寄存器,单周期内可以完成数据的各种移位操作
- 并不是所有的ARM指令都是单周期的。
- ARM有16位的Thumb指令集,是32位ARM指令集的压缩形式,提高了代码密度。
- 条件执行:通过指令组合,减少了分支指令数目,提高了代码密度。
- 增加了DSP、SIMD/NEON等指令。
ARM处理器有多种工作模式
- 应用程序正常运行时,ARM处理器工作在用户模式(User mode),当程序运行出错或有中断发生时,ARM处理器就会切换到对应的特权工作模式。
表3-1 ARM处理器的不同工作模式
- 处理器处于普通模式,没有权限对内存和底层硬件进行操作
- 首先通过系统调用或软中断进入处理器特权模式,运行操作系统内核或硬件驱动代码,才能对底层的硬件设备进行读写操作。
- 在ARM处理器内部,除了基本的算术运算单元、逻辑运算单元、浮点运算单元和控制单元,还有一系列寄存器,包括各种通用寄存器、状态寄存器、控制寄存器,用来控制处理器的运行,保存程序运行时的各种状态和临时结果
ARM处理器中的寄存器可分为通用寄存器和专用寄存器两种
- R0~R12属于通用寄存器
- 除了FIQ工作模式,在其他工作模式下这些寄存器都是共用、共享的
- R0~R3通常用来传递函数参数,R4~R11用来保存程序运算的中间结果或函数的局部变量等,R12常用来作为函数调用过程中的临时寄存器。
- ARM处理器有多种工作模式,除了这些在各个模式下通用的寄存器,还有一些寄存器在各自的工作模式下是独立存在的,如R13、R14、R15、CPSP、SPSR寄存器,在每个工作模式下都有自己单独的寄存器
- R13寄存器又称为堆栈指针寄存器(Stack Pointer,SP),用来维护和管理函数调用过程中的栈帧变化,R13总是指向当前正在运行的函数的栈帧,一般不能再用作其他用途
- R14寄存器又称为链接寄存器(Link Register,LR),在函数调用过程中主要用来保存上一级函数调用者的返回地址
- 寄存器R15又称为程序计数器(Program Counter,PC),CPU从内存取指令执行,就是默认从PC保存的地址中取的,每取一次指令,PC寄存器的地址值自动增加
- 在ARM三级流水线中,PC指针的值等于当前正在运行的指令地址+8
- 当前处理器状态寄存器(Current Processor State Register,CPSR)主要用来表征当前处理器的运行状态。
- 在每种工作模式下,都有一个单独的程序状态保存寄存器(Saved Processor State Register,SPSR)
ARM处理器切换工作模式或发生异常
- SPSR用来保存当前工作模式下的处理器现场,即将CPSR寄存器的值保存到当前工作模式下的SPSR寄存器。
从异常返回时
- 从SPSR寄存器中恢复原先的处理器状态,切换到原来的工作模式继续运行
- FIQ模式。为了快速响应中断,减少中断现场保护带来的时间开销,在FIQ工作模式下,ARM处理器有自己独享的R8~R12寄存器。
3.2 ARM汇编指令
- 一个完整的ARM指令通常由操作码+操作数组成
ARM指令集属于RISC指令集
- RISC处理器采用典型的加载/存储体系结构,CPU无法对内存里的数据直接操作,只能通过Load/Store指令来实现
- ARM处理器属于冯·诺依曼架构,程序和数据都存储在同一存储器上,内存空间和I/O空间统一编址,ARM处理器对程序指令、数据、I/O空间中外设寄存器的访问都要通过Load/Store指令来完成。
RM处理器中经常使用的Load/Store指令的使用方法如下。
表3-2 不同类型的堆栈
- 在一个堆栈内存结构中,如果堆栈指针SP总是指向栈顶元素,那么这个栈就是满栈;如果堆栈指针SP指向的是栈顶元素的下一个空闲的存储单元,那么这个栈就是空栈。
- 如果栈指针SP从高地址往低地址移动,那么这个栈就是递减栈;
- ARM处理器使用的一般都是满递减堆栈
- 这里需要注意的一个细节是,在入栈和出栈过程中要留意栈中各个元素的入栈出栈顺序。
- 从左往右
- LDR/STR指令用来在寄存器和内存之间输送数据
- 在寄存器之间传送数据,则可以使用MOV指令
MOV指令的格式如下。
- 比较指令用来比较两个数的大小,或比较两个数是否相等
- 较指令的运算结果会影响CPSR寄存器的N、Z、C、V标志位
- 条件执行经常出现在跳转或循环的程序结构中
BL跳转指令表示带链接的跳转
- 在跳转之前,BL指令会先将当前指令的下一条指令地址(即返回地址)保存到LR寄存器中,然后跳转到label处执行。
- 子函数执行结束后,LR寄存器中的地址被赋值给PC,处理器就可以返回到原来的主函数中继续运行
BX表示带状态切换的跳转
- Rm寄存器中保存的是跳转地址,要跳转的目标地址处可能是ARM指令,也可能是Thumb指令
- 处理器根据Rm[0]位决定是切换到ARM状态还是切换到Thumb状态。
- BLX指令是BL指令和BX指令的综合,表示带链接和状态切换的跳转
3.3 ARM寻址方式
- ARM属于RISC体系架构,一个ARM汇编程序中的大部分汇编指令,基本上都和数据传输有关:在内存-寄存器、内存-内存、寄存器-寄存器之间来回传输数据。
寻址方式
- 比较常见的寻址方式有寄存器寻址、立即寻址、寄存器偏移寻址、寄存器间接寻址、基址寻址、多寄存器寻址、相对寻址等。
寄存器寻址
- 操作数保存在寄存器中,通过寄存器名就可以直接对寄存器中的数据进行读写
- 在立即数寻址中,ARM指令中的操作数为一个常数。立即数以#为前缀,0x前缀表示该立即数为十六进制,不加前缀默认是十进制
- 寄存器偏移寻址可以看作寄存器寻址的一种特例
常见的移位操作有逻辑移位和算术移位
- 逻辑移位无论是左移还是右移,空缺位一律补0;而算术移位则不同,左移时空缺位补0,右移时空缺位使用符号位填充。
- 寄存器间接寻址主要用来在内存和寄存器之间传输数据
- 寄存器中保存的是数据在内存中的存储地址,我们通过这个地址就可以在寄存器和内存之间传输数据
- C语言中的指针操作,在汇编层次其实就是使用寄存器间接寻址实现的
说明如下所示。
- 基址寻址其实也属于寄存器间接寻址。两者的不同之处在于,基址寻址将寄存器中的地址与一个偏移量相加,生成一个新地址,然后基于这个新地址去访问内存。
- 基址寻址一般用在查表、数组访问、函数的栈帧管理等场合
- STM/LDM指令就属于多寄存器寻址,一次可以传输多个寄存器的值
。
连续的寄存器,还可以使用连接符-连接
- 栈是程序运行过程中非常重要的一段内存空间,栈是C语言运行的基础,函数内的局部变量、函数调用过程中要传递的参数、函数的返回值一般都是保存在栈中的
- 在嵌入式系统的一些启动代码中,你会看到,在运行C语言程序之前,必须要先运行一段汇编代码初始化内存和栈指针SP,然后才能跳到C语言程序中运行。
ARM没有专门的入栈和出栈指令,ARM中的栈操作其实就是通过上面所讲的STM/LDM指令和栈指针SP配合操作完成的
- ARM默认使用满递减堆栈
- STMFD/LDMFD指令配对使用,完成堆栈的入栈和出栈操作
- ARM中的PUSH和POP指令其实就是LDM/STM的同义词,是LDMFD和STMFD组合指令的助记符。
- 相对寻址其实也属于基址寻址,只不过它是基址寻址的一种特殊情况。特殊在什么地方呢?它是以PC指针作为基地址进行寻址的
- ,以指令中的地址差作为偏移,两者相加后得到的就是一个新地址
- 很多与位置无关的代码,如动态链接共享库,其在汇编代码层次的实现其实也是采用相对寻址的
- 程序中使用相对寻址访问的好处是不需要重定位,将代码加载到内存中的任何地址都可以直接运行。
3.4 ARM伪指令
- RM伪指令并不是ARM指令集中定义的标准指令,而是为了编程方便,各家编译器厂商自定义的一些辅助指令
- 伪指令有点类似C语言中的预处理命令,在程序编译时,这些伪指令会被翻译为一条或多条ARM标准指令
- 常见的ARM伪指令主要有4个:ADR、ADRL、LDR、NOP
使用示例如下。
- NOP伪指令比较简单,其实就相当于MOV R0,R0。
- ARM属于RISC架构,不能对内存中的数据直接操作,ARM通常会使用LDR/STR这对加载/存储指令,先将内存中的数据加载到寄存器,然后才能对寄存器中的数据进行操作,最后把寄存器中的处理结果存储到内存中
- LDR伪指令的主要用途是将一个32位的内存地址保存到寄存器中。
- RISC指令的特点是单周期指令,指令的长度一般都是固定的。在一个32位的系统中,一条指令通常是32位的,指令中包括操作码和操作数,如图3-5所示。[插图]图3-5 ARM指令的编码格式指令中的操作码和操作数共享32位的存储空间:一般前面的操作码要占据几个比特位,剩下来的留给操作数的编码空间就小于32位了。
- 为了与ARM指令集中的加载指令LDR区别开来,LDR伪指令中的操作数前一般会有一个等于号=,用来表示该指令是个伪指令。通过LDR伪指令,编译器就解决了向一个寄存器传送32位的立即数时指令无法编码的难题
- 在程序编译期间,这些伪指令会被标准的ARM指令替代。编译器在处理伪指令时,根据伪指令中的操作数大小,会使用不同的ARM标准指令替代
- 存放这些32位地址常量的文字池一般紧挨着当前指令的代码段,直接放置在当前代码段的后面。
阅读障碍和拦路虎。
- ADR伪指令的功能与LDR伪指令类似,将基于PC相对偏移的地址值读取到寄存器中。
- ADR为小范围的地址读取伪指令,底层使用相对寻址来实现,因此可以做到代码与位置无关
示例代码如下。
ADR伪指令和LDR伪指令
- 两者都是为了加载一个地址到指定的寄存器中
- LDR伪指令通常被翻译为ARM指令集中的LDR或MOV指令
- ADR伪指令则通常会被ADD或SUB指令代替。
用途上
- LDR伪指令主要用来操作外部设备的寄存器
- DR伪指令主要用来通过相对寻址,生成与位置无关的代码
- LDR使用绝对地址,而ADR则使用相对地址,LDR和ADR伪指令的地址适用范围也不同,LDR伪指令适用的地址范围为[0,32GB],而ADR伪指令则要求当前指令和标号必须在同一个段中,地址偏移范围也较小,地址对齐时偏移范围为[0,1020],地址未对齐时偏移范围为[0,4096]。
3.5 ARM汇编程序设计
- ARM汇编程序是以段(section)为单位进行组织的
- 在一个汇编文件中,可以有不同的section,分为代码段、数据段等,各个段之间相互独立
- 由AREA伪操作来标识一个段的起始、段名、段的属性(CODE、DATA)和读写权限(READONLY、READWRITE)。
- ARM汇编程序通过ENTRY这个伪操作来标识汇编程序的运行入口,使用伪操作END来标识汇编程序的结束。
- 在ARM汇编程序中可以使用标号。像C语言一样,在汇编语言中,标号代表的指令地址
- 在汇编程序中
- 使用分号;来注释代码
- 。在一个空行的行首或者一条指令语句的末尾添加一个分号,然后就可以在分号后面添加注释,以增加程序的可读性。
- 使用符号来标识一个地址、变量或数字常量。当用符号来标识一个地址时,这个符号通常又被称为标号。
- 符号的命名规则和C语言的标识符命名规则一样:由字母、数字和下画线组成,符号的开头不能使用数字,但标号除外。
- 符号的命名在其作用域内必须唯一,不能与系统内部或系统预定义的符号同名,不能与指令助记符、伪指令同名
- 作用域是整个汇编源文件
- 直接通过数字[0,99]而不是使用字符来进行地址引用,我们称这种数字为局部标号。局部标号的作用域为当前段,
- 在汇编语言中,为了编程方便,汇编器也定义了一些特殊的指令助记符,以方便对汇编程序做各种处理。如使用AREA来定义一个段(section),使用GBLA来定义一个数据,使用ENTRY来指定汇编程序的执行入口等,这些指令助记符统称为伪指令或伪操作
用的伪操作如下。
- 关于数据定义,常用的伪操作有DCD、DCB、SPACE、DATA
如下所示。
表3-4 伪操作
3.6 C语言和汇编语言混合编程
- 如在ARM启动代码中,系统一上电首先运行的是汇编代码,等初始化好内存堆栈环境后,才会跳到C程序中执行。
- ATPCS的全称是ARM-Thumb Procedure Call Standard,其核心内容就是定义了ARM子程序调用的基本规则及堆栈的使用约定等。
- TPCS规定了ARM程序要使用满递减堆栈,入栈/出栈操作要使用STMFD/LDMFD指令
ATPCS最重要的内容是定义了子程序调用的具体规则
- 子程序间要通过寄存器R0~R3(可记作a0~a3)传递参数,当参数个数大于4时,剩余的参数使用堆栈来传递。
- 子程序通过R0~R1返回结果。
- 子程序中使用R4~R11(可记作v1~v8)来保存局部变量
- R12作为调用过程中的临时寄存器,一般用来保存函数的栈帧基址,记作FP。
- R13作为堆栈指针寄存器,一般记作SP
- R14作为链接寄存器,用来保存函数调用者的返回地址,记作LR
- R15作为程序计数器,总是指向当前正在运行的指令,记作PC。
- 为了能在C程序中内嵌汇编代码,ARM编译器在ANSI C标准的基础上扩展了一个关键字__asm。通过这个关键字,我们就可以在C程序中内嵌ARM汇编代码
格式如下。
- 如果你想在内嵌的汇编代码中添加注释,记得要使用C语言的/**/注释符,而不是汇编语言的分号注释符。
- 不同的编译器基于ANSI C标准扩展了不同的关键字,使用的汇编格式可能也不太一样
- GNU ARM编译器提供了一个__asm__关键字
3.7 GNU ARM汇编语言
- 编译器:用来将C源文件编译成汇编文件。● 汇编器:用来将汇编文件汇编成目标文件。● 链接器:用来将目标文件组装成可执行文件。● 二进制转化工具:objdump、objcopy、strip等。● 库打包工具:ar。● 调试工具:gdb、nm。
- ● 库/头文件:根据C语言标准定义的API实现的C标准库及对应的头文件。
不同编译器的伪操作对比
- 常用的GNU ARM伪指令操作
- GNU ARM汇编语言中的标识符可以由字母、数字、下画线和“.”构成,局部标号可以由纯数字构成
- GNU ARM汇编语言使用标号_start作为汇编程序的入口
- 在GNU ARM汇编语言中,用户可以使用.section伪操作自定义一个段
式如下。
- 在使用伪操作.section定义一个段时,每个段以段名开始,以下一个段名或文件结尾作为结束标记
- 二进制数据通常以0B或0b开头,八进制数据以0开头,十六进制数据以0x开头,十进制数据则以非0数字开头。负数前面加“-”,取补用“~”,不相等用“<>”,其他运算符号如+、-、*、%、<、<<、>、>>、|、&、^、!、==、>=、&&与C语言语法相似。
- 字符串常量要用双引号""括起来。使用.ascii定义字符串时要自行在结尾加’\0’,.string伪操作可以定义多个字符串,使用.asciz伪操作可以定义一个以NULL字符结尾的字符串,使用.rept伪操作可以重复定义数据。
在GNU ARM汇编程序中经常使用小圆点.表示当前指令的地址
- 在GNU ARM汇编程序中,如果我们想定义一个浮点数
定义。
- 使用.float伪操作定义一个浮点数f,并初始化为3.14。如果你想将这个浮点数重新赋值为3.1415,则可以通过.equ伪操作来完成
- .equ伪操作除了给数据赋值,还可以把常量定义在代码段中,然后在代码中直接引用。这一点有点类似C语言中的#define宏定义。
4.1 从源程序到二进制文件
从源程序到二进制文件
- 程序的编译过程,其实就是将我们编写的C源程序翻译成CPU能够识别和运行的二进制机器指令的过程
- token
- 编译器在编译程序时会根据这些函数声明对我们的源程序进行语法检查:检查实参类型、返回结果类型和函数声明的类型是否匹配。
- ARM交叉编译器成功地将C源程序翻译为可执行文件
- readelf-h命令主要用来获取可执行文件的头部信息,主要包括可执行文件运行的平台、软件版本、程序入口地址,以及program headers、section header等信息。
- [插图]
- section headers是干什么用的呢?它主要用来描述可执行文件的section信息。
- 一个可执行文件通常由不同的段(section)构成:代码段、数据段、BSS段、只读数据段等
- 每个section用一个section header来描述,包括段名、段的类型、段的起始地址、段的偏移和段的大小等
- 将这些section headers集中放到一起,就是section header table
- readelf-S命令来查看一个可执行文件的节头表
- 一个可执行文件由一系列section组成,section header table自身也是以一个section的形式存储在可执行文件中
- 可执行文件还会有一个文件头ELF header,用来描述文件类型、要运行的处理器平台、入口地址等信息。当程序运行时,加载器会根据此文件头来获取可执行文件的一些信息。
- 函数翻译成二进制指令放在代码段
- 初始化的全局变量和静态局部变量放在数据段中。
BSS段比较特殊
- 未初始化的全局变量和静态变量会放置在BSS段中,但是因为它们未初始化,默认值全部是0,其实没有必要再单独开辟空间存储,为了节省存储空间,所以在可执行文件中BSS段是不占用空间的。但是BSS段的大小、起始地址和各个变量的地址信息会分别保存在节头表section header table和符号表.symtab里,当程序运行时,加载器会根据这些信息在内存中紧挨着数据段的后面为BSS段开辟一片存储空间,为各个变量分配存储单元。
- 程序中定义的一些字符串、printf函数打印的字符串常量则放置在只读数据段.rodata中
- 如果程序在编译时设置为debug模式,则可执行文件中还会有一个专门的.debug section,用来保存可执行文件中每一条二进制指令对应的源码位置信息。
- 在最后环节,编译器还会在可执行文件中添加一些其他section,如.init section,这些代码来自C语言运行库的一些汇编代码,用来初始化C程序运行所依赖的环境,如内存堆栈的初始化等。
- [插图]
- 程序的整个编译流程主要分为以下几个阶段:预处理、编译、汇编、链接。
- [插图]
- 在一个多文件的C项目中,编译器是以C源文件为单位进行编译
- 在编译器安装路径的bin目录下,你会看到各种各样的编译工具,gcc在程序编译过程中会分别调用它们,常见的工具有预处理器、编译器、汇编器、链接器。
- 预处理器:将源文件main.c经过预处理变为main.i。● 编译器:将预处理后的main.i编译为汇编文件main.s。● 汇编器:将汇编文件main.s编译为目标文件main.o。● 链接器:将各个目标文件main.o、sub.o链接成可执行文件a.out。
- 生成的可执行文件a.out其实也是目标文件(object file)
- 目标文件一般可以分为3种。● 可重定位的目标文件(relocatable files)。● 可执行的目标文件(executable files)。● 可被共享的目标文件(shared object files)。
- 汇编器生成的目标文件是可重定位的目标文件,是不可执行的,需要链接器经过链接、重定位之后才能运行
- 。可被共享的目标文件一般以共享库的形式存在,在程序运行时需要动态加载到内存,跟应用程序一起运行
4.2 预处理过程
- 预处理过程
- 编译控制:#pragma。
- ;通过条件编译可以让代码兼容不同的处理器架构和平台,以最大限度地复用公用代码
- 通过#pragma预处理命令可以设定编译器的状态,指示编译器完成一些特定的动作。
- ● #pragma pack([n]):指示结构体和联合成员的对齐方式。
- #pragma once:在头文件中添加这条指令,可以防止头文件多次编译。
- ● 头文件展开:将#include包含的头文件内容展开到当前位置。● 宏展开:展开所有的宏定义,并删除#define。● 条件编译:根据宏定义条件,选择要参与编译的分支代码,其余的分支丢弃。● 删除注释。● 添加行号和文件名标识:编译过程中根据需要可以显示这些信息。● 保留#pragma命令:该命令会在程序编译时指示编译器执行一些特定行为。
- ,经过预处理后,#pragma保留,指示编译器在后续的编译阶段执行一些特定的操作
4.3 程序的编译
编译阶段主要分两步
- 第一步,编译器调用一系列解析工具,去分析这些C代码,将C源文件编译为汇编文件
- 第二步,通过汇编器将汇编文件汇编成可重定位的目标文件。
从C文件到汇编文件
- 一个汇编文件是以段为单位来组织程序的:代码段、数据段、BSS段等,各个段之间相互独立。我们可以使用AREA或.section伪操作来定义一个段。
- 汇编指令就是二进制指令的助记符,唯一的差异就是汇编语言的程序结构需要使用各种伪操作来组织。汇编文件经过汇编器汇编后,处理掉各种伪操作命令,就是二进制目标文件了。
- (1)词法分析。(2)语法分析。(3)语义分析。(4)中间代码生成。(5)汇编代码生成。(6)目标代码生成。
- 通过有限状态机解析并识别这些字符流,将源程序分解为一系列不能再分解的记号单元——token。
- token是字符流解析过程中有意义的最小记号单元
- 经过词法扫描器扫描分析后,就分解成了8个token:“sum”“=”“a”“+”“b”“/”“c”“;”,很多C语言初学者在编写程序时,不小心输入了中文符号、圆角/半角字符导致编译出错,其实就发生在这个阶段。
- 语法分析工具在对token序列分析过程中,如果发现不能构建语法上正确的语句或表达式,就会报语法错误:syntax error。如果程序语句后面少了一个语句结束符分号或者在for循环中少了一个分号,报的错误都属于这种语法错误。大家在调试程序时,再遇到syntax error的字眼,应该知道问题出在什么地方了吧。
- 中间码一般和平台是无关的
- 使用arm-linux-gnueabi-gcc-S命令或反汇编可执行文件,即可看到汇编代码的具体实现
- [插图]
汇编过程
- 汇编过程是使用汇编器将前一阶段生成的汇编文件翻译成目标文件。汇编器的主要工作就是参考ISA指令集,将汇编代码翻译成对应的二进制指令,同时生成一些必要的信息,以section的形式组装到目标文件中,后面的链接过程会用到这些信息。如图4-5所示,汇编的流程主要包括词法分析、语法分析、指令生成等过程。
- main.o和sub.o是不可执行的,属于可重定位的目标文件,它们要经过链接器重定位、链接之后,才能组装成一个可执行的目标文件a.out。
- 通过编译生成的可重定位目标文件,都是以零地址为链接起始地址进行链接的
- 在每个可重定位目标文件中,函数或变量的地址其实就是它们在文件中相对于零地址的偏移
- 链接器将各个目标文件组装在一起后,我们需要重新修改各个目标文件中的变量或函数的地址,这个过程一般称为重定位。
- 我们把需要重定位的符号收集起来,生成一个重定位表,以section的形式保存到每个可重定位目标文件中
- 一个文件中的所有符号,无论是函数名还是变量名,无论其是否需要重定位,我们一般也会收集起来,生成一个符号表,以section的形式添加到每一个可重定位目标文件中
符号表与重定位表
- 符号表和重定位表是非常重要的两个表,这两个表为链接过程提供各种必要的信息
- 在整个编译过程中,符号表主要用来保存源程序中各种符号的信息,包括符号的地址、类型、占用空间的大小等。
符号的类型
- OBJECT:对象类型,一般用来表示我们在程序中定义的变量。
- FUNC:关联的是函数名或其他可引用的可执行代码。
- FILE:该符号关联的是当前目标文件的名称。
- SECTION:表明该符号关联的是一个section,主要用来重定位
- COMMON:表明该符号是一个公用块数据对象,是一个全局弱符号,在当前文件中未分配空间。
- TLS:表明该符号对应的变量存储在线程局部存储中。
- NOTYPE:未指定类型,或者目前还不知道该符号类型。
- 编译器就会认为你引用的这个全局变量或函数可能在其他文件、库中定义,在编译阶段暂时不会报错
- 在后面的链接过程中,链接器会尝试在其他文件或库中查找你引用的这个符号的定义,如果真的找不到才会报错,此时的错误类型是链接错误
- 编译器在给每个目标文件生成符号表的过程中,如果在当前文件中没有找到符号的定义,也会将这些符号搜集在一起并保存到一个单独的符号表中,以待后续填充,这个符号表就是重定位符号表。
- 中会使用一个重定位表.rel.text来记录这些需要重定位的符号
4.4 链接过程
- 在一个C项目的编译中,编译器以C源文件为单位,将一个个C文件翻译成对应的目标文件
- 生成的每一个目标文件都是由代码段、数据段、BSS段、符号表等section组成的。这些section从目标文件的零偏移地址开始按照顺序依次排放,每个段中的符号相对于零地址的偏移,其实就是每个符号的地址,这样程序中定义的变量、函数名等,都有了一个暂时的地址。
- 在后续的链接过程中,这些目标文件中的各个section会重新拆分组装,每个section的起始参考地址都会发生变化,导致每个section中定义的函数、全局变量等符号的地址也要随之发生变化,需要重新修改,即重定位
- 链接主要分为3个过程:分段组装、符号决议和重定位。
分段组装
- 链接过程的第一步,就是将各个目标文件分段组装
- 程序在链接程序时需要指定一个链接起始地址,链接开始地址一般也就是程序要加载到内存中的地址
- 如何指定程序的链接地址和各个段的组装顺序呢?很简单,通过链接脚本就可以了。
- 链接脚本本质上是一个脚本文件。在这个脚本文件里,不仅规定了各个段的组装顺序、起始地址、位置对齐等信息,同时对输出的可执行文件格式、运行平台、入口地址等信息做了详细的描述
- ,并最终将这些信息以section的形式保存到可执行文件的ELF Header中
- 程序运行时,加载器首先会解析可执行文件中的ELF Header头部信息,验证程序的运行平台和加载地址信息,然后将可执行文件加载到内存中对应的地址,程序就可以正常运行了。
- GCC编译器的默认链接脚本在/usr/lib/scripts目录下
- 在一个由带有MMU的CPU搭建的嵌入式系统中,程序的链接起始地址往往都是一个虚拟地址,程序运行过程中还需要地址转换,通过MMU将虚拟地址转换为物理地址,然后才能访问内存
,链接器早就料到会有这种情况,它有专门的符号决议规则来解决这种符号冲突
- 一山不容二虎。
- 强弱可以共存。
- 体积大者胜出。
- 函数名、初始化的全局变量是强符号,而未初始化的全局变量则是弱符号。
- 当强弱符号共存时,强符号会覆盖掉弱符号,链接器会选择强符号作为可执行文件中的最终符号。
在程序编译期间,编译器在分析每个文件中未初始化的全局变量时,并不知道该符号在链接阶段是被采用还是被丢弃,
因此在程序编译期间,未初始化的全局变量并没有被直接放置在BSS段中,而是将这些弱符号放到一个叫作COMMON的临时块中,在符号表中使用一个未定义的COMMON来标记,在目标文件中也没有给它们分配存储空间。
- 在链接期间,链接器会比较多个文件中的弱符号,选择占用空间最大的那一个,作为可执行文件中的最终符号,此时弱符号的大小已经确定,并被直接放到了可执行文件的BSS段中
- GNU C编译器在ANSI C语法标准的基础上扩展了一系列C语言语法,如提供了一个__attribute__关键字用来声明符号的属性。
- [插图]
强引用、弱引用
- 在一个程序中,我们可以定义多个函数和变量,变量名和函数名都是符号,这些符号的本质,或者说这些符号值,其实就是地址
- 我们通过符号去调用一个函数或访问一个变量,通常称之为引用(reference),强符号对应强引用,弱符号对应弱引用。
重定位
- 经过符号决议,我们解决了链接过程中多文件符号冲突的问题
- 符号表中的每个符号值,也就是每个函数、全局变量的地址,还是原来各个目标文件中的值,还都是基于零地址的偏移。链接器将各个目标文件重新分解组装后,各个段的起始地址都发生了变化。
- 链接器怎么知道哪些符号需要重定位呢?不要忘了,在各个目标文件中还有一个重定位表,专门记录各个文件中需要重定位的符号。
- 重定位的核心工作就是修正指令中的符号地址,是链接过程中的最后一步,也是最核心、最重要的一步
- 重定位表中有一个信息比较重要:需要重定位的符号在指令代码中的偏移地址offset,链接器修正指令代码中各个符号的值时要根据这个地址信息才能从茫茫的二级制代码中找到它们。
- [插图]
4.5 程序的安装
- 程序的运行过程,其实就是处理器根据PC寄存器中的地址,从内存中不断取指令、翻译指令和执行指令的过程
- 内存RAM的优点是支持随机读写,因此可以支持CPU随机读取指令
- ROM中存储的数据断电后不会消失,常用来保存程序的指令和数据,但ROM不支持随机存取,因此程序运行时,会首先将指令和数据从ROM加载到RAM,然后CPU到RAM中取指令
程序安装的本质
- 软件安装的过程其实就是将一个可执行文件安装到ROM的过程。
- 在Linux环境下,我们一般将可执行文件直接复制到系统的官方路径/bin、/sbin、/usr/bin下,程序运行时直接从这些系统默认的路径下去查找可执行文件,将其加载到内存运行。
- 在Linux下制作软件安装包
- Linux操作系统一般可分为两派:Redhat系和Debian系。Redhat系使用RPM包管理机制,而Debian系,像Debian、Ubuntu等操作系统则使用deb包管理机制。
- 在Debian和Ubuntu环境下,软件压缩包一般为deb格式。
4.6 程序的运行
- 程序的运行分两种情况:一种是在有操作系统的环境下执行一个应用程序;另一种是在无操作系统的环境下执行一个裸机程序。
- ,如在Linux环境下,可执行文件是ELF格式,而在裸机环境下执行的程序一般是BIN/HEX格式。
- BIN/HEX文件是纯指令文件
- ELF文件除了基本的代码段、数据段,还有文件头、符号表、program header table等用来辅助程序运行的信息。
操作系统环境下的程序运行
- 一个装有操作系统的计算机系统,当执行一个应用程序时,首先会运行一个叫作加载器的程序。
- 段头表中记录的是如何将可执行文件加载到内存的相关信息,包括可执行文件中要加载到内存中的段、入口地址等信息。
- 可重定位目标文件因为是不可执行的,不需要加载到内存中,所以段头表这个section在目标文件中不是必须存在的,是可选的
- 而在一个可执行文件中,加载器要加载程序到内存,要依赖段头表提供的信息,因此段头表是必需的。
- 在Linux环境下运行的程序一般都会被封装成进程,参与操作系统的统一调度和运行
- Shell环境下运行一个程序,Shell终端程序一般会先fork一个子进程,创建一个独立的虚拟进程地址空间
- 接着调用execve函数将要运行的程序加载到进程空间:通过可执行文件的文件头,找到程序的入口地址,建立进程虚拟地址空间与可执行文件的映射关系,将PC指针设置为可执行文件的入口地址,即可启动运行。
- 程序的入口地址=编译时的链接地址+一定偏移(程序头等会占用一部分空间)
- 不同的编译器有不同的链接起始地址。在Linux环境下,GCC链接时一般以0x08040000为起始地址开始存放代码段,
- ARM GCC交叉编译器一般以0x10000为链接起始地址。紧挨着代码段,从一个4KB边界对齐的地址处开始存放数据段。紧挨着数据段,就是BSS段。BSS段后面的第一个4KB地址对齐处,就是我们在程序中使用malloc()/free()申请的堆空间
- :程序链接时的链接地址其实都是虚拟地址。
- 对于每一个运行的进程,Linux内核都会使用一个task_struct结构体来表示,多个结构体通过指针构成链表。操作系统基于该链表就可以对这些进程进行管理、调度和运行。
- 裸机环境下的程序运行
- 在操作系统环境下,我们可以通过加载器将程序的指令加载到内存中,然后CPU到内存中取指运行。在一个裸机平台下,系统上电后,没有程序运行的环境,我们需要借助第三方工具将程序加载到内存,然后才能正常运行
- 在一个嵌入式Linux系统中,Linux内核镜像的运行其实就是裸机环境下的程序运行。Linux内核镜像一般会借助U-boot这个加载工具将其从Flash存储分区加载到内存中运行,U-boot在Linux启动过程中扮演了“加载器”的角色。当然U-boot的功能绝不仅限于此,现在的U-boot功能已经很强大了,实现了各种各样的功能,这里不再赘述。
- 的。U-boot在Linux启动过程中,充当了“加载器”的角色,但是其自身也和Linux内核镜像一样,存储在NAND/NOR分区上。
- U-boot启动过程中,不仅要完成本身代码的“自复制”:将自身代码从存储分区复制到内存中,还要完成自身代码的重定位,一般具备这种功能的代码我们称之为“自举”
程序入口main()函数分析
- 。编译器在编译一个工程时,默认的程序入口是_start符号,而不是main。符号main是一个约定符号,它用来告诉编译器在一个项目中哪里是程序的入口点
- C语言函数调用过程中的参数传递、函数内部的局部变量都是保存在栈中的
在运行main()函数之前必须先运行一段汇编代码来初始化堆栈环境
- 。设置好堆栈指针后,这部分代码还要继续初始化一些环境,如初始化data段的内容,初始化static静态变量和global全局变量,并给BSS段的变量赋初值:未初始化的全局变量中,int类型的全部初始化为0,布尔型的变量初始化为FALSE,指针型的变量初始化为NULL
- 这部分代码属于C运行库(C Running Time,CRT)中的代码
进入main()函数之前的一系列初始化操作。
- C语言运行的基本堆栈环境、进程环境。
- 动态库的加载、释放、初始化、清理等工作。
- 向main()函数传参argc、argv,调用main()函数执行。
- 在main()函数退出后,调用exit()函数,结束进程的运行。
- 你会看到一个叫作crt1.o的目标文件,这个文件其实就是由汇编初始化代码编译生成的,是CRT的一部分。在链接过程中,链接器会将crt1.o这个目标文件和项目中的目标文件组装在一起,生成最终的可执行文件。
- 初始化C语言运行依赖的栈环境,并设置栈指针
- 在嵌入式系统裸机环境下,系统上电后要初始化时钟、内存,然后设置堆栈指针
- 保存一些上下文环境后就可以直接跳到第一个C语言入口函数:__libc_start_main
- __libc_start_main函数的代码很长,我们简化分析后的大致流程如下:首先设置程序运行的进程环境,加载共享库,解析用户输入的参数,将参数传递给main()函数,最后调用main()函数运行。main()函数运行结束后,再调用exit函数结束整个进程。
- main只是编译器和程序员约定好的默认入口点,并不是一成不变
- 对于未初始化的全局变量和静态局部变量,编译器将其放置在BSS段中
- BSS段是不占用可执行文件存储空间的
- 设置BSS段的目的主要就是减少可执行文件的体积,节省磁盘空间
- 当程序加载到内存运行时,加载器会在内存中给BSS段开辟一段存储空间。
- 在数据段的后面分配指定大小的内存空间并清零
- [插图]
- :BSS段设计的初衷就是为了减少文件体积,节省磁盘资源。
编译器对数据段和BSS段符号的处理流程是相同的,唯一的差异在于
- 在可执行文件内不给BSS段分配存储空间,在程序运行内存时再分配存储空间和地址。
4.7 链接静态库
库分为静态库和动态库
- 如果我们在项目中引用了库函数,则在编译时,链接器会将我们引用的函数代码或变量,链接到可执行文件里,和可执行程序组装在一起,这种库被称为静态库,即在编译阶段链接的库
- 动态库在编译阶段不参与链接,不会和可执行文件组装在一起,而是在程序运行时才被加载到内存参与链接,因此又叫作动态链接库
- 静态库的本质其实就是可重定位目标文件的归档文件
- 使用AR命令就可以将多个目标文件打包为一个静态库。
- 编译参数大写的L表示要链接的库的路径,小写的l表示要链接的库名字。链接时库的名字要去掉前后缀,如libtest.a,链接时要指定的库名字为test。
- 用ar命令制作静态库
- ● -c:禁止在创建库时产生的正常消息。● -r:如果指定的文件已经在库中存在,则替换它。● -s:无论库是否更新都强制重新生成新的符号表。● -d:从库中删除指定的文件。● -o:对压缩文档成员进行排序。● -q:向库中追加指定文件。● -t:打印库中的目标文件。● -x:解压库中的目标文件
- 编译器是以源文件为单位编译程序的,链接器在链接过程中逐个对目标文件进行分解组装
这样很容易产生一个问题
- 如果在一个源文件中我们定义了100个函数,而只使用了其中的1个,那么链接器在链接时也会把这100个函数的代码指令全部组装到可执行文件中,这会让最终生成的可执行文件体积大大增加。
- 解决这个问题其实很简单:我们在封装函数库时,将每个函数都单独使用一个源文件实现,然后将多个目标文件打包即可。
- ,每一个库函数都是单独使用一个同名的源文件实现的。printf()函数单独定义在printf.c文件中,scanf()函数单独定义在scanf.c文件中,如果你调用了一个printf()函数,则链接器只是将printf()函数的目标文件链接到你的可执行文件中
静态链接还会产生另外一个问题
- 多个程序都调用了它,链接器在链接时就要将printf的指令添加到多个可执行文件中。在一个多任务环境中,当多个进程并发运行时,你会发现内存中有大量重复的printf指令代码,很浪费内存资源
- 动态链接这时候就开始低调登场
4.8 动态链接
静态链接的缺点
- 生成的可执行文件体积较大,当多个程序引用相同的公共代码时,这些公共代码会多次加载到内存,浪费内存资源
动态链接对静态链接做了一些优化
- 对一些公用的代码,如库,在链接期间暂不链接,而是推迟到程序运行时再进行链接。这些在程序运行时才参与链接的库被称为动态链接库。程序运行时,除了可执行文件,这些动态链接库也要跟着一起加载到内存,参与链接和重定位过程,否则程序可能就会报未定义错误,无法运行
动态链接的好处
- 节省了内存资源:加载到内存的动态链接库可以被多个运行的程序共享,使用动态链接可以运行更大的程序、更多的程序,升级也更加简单方便。
- .
- dll
- 这些文件其实就是动态链接库,需要和可执行文件一起安装到系统中。程序运行前会首先把它们加载到内存,链接成功后程序才能运行。
- Linux环境下
- 以
- .so
- 一个软件采用动态链接,版本升级时主程序的业务逻辑或框架不需要改变,只需要更新对应的.dll或.so文件就可以了,简单方便,也避免了用户重复安装卸载软件
- 在Linux环境下,当我们运行一个程序时
- 操作系统首先会给程序fork一个子进程,接着动态链接器被加载到内存,操作系统将控制权交给动态链接器,让动态链接器完成动态库的加载和重定位操作,最后跳转到要运行的程序
- 动态链接器本身也是一个动态库,即/lib/ld-linux.so文件。动态链接器被加载到内存后,会首先给自己重定位,然后才能运行。像这种自己给自己重定位然后自动运行的行为,我们一般称为自举
- U-boot也有自举功能
- 在系统上电启动后会完成代码的自我复制和重定位操作,然后加载Linux内核镜像运行。
- 动态链接器解析可执行文件中未确定的符号及需要链接的动态库信息,将对应的动态库加载到内存,并进行重定位操作
- [插图]
- 库动态链接需要考虑的一个重要问题是加载地址。一个静态链接的可执行文件在运行时,一般加载地址等于链接地址,而且这个地址是固定的。
- 是装载时重定位
- 静态链接过程中,每个目标文件中的代码段都被分解组装,起始地址发生了变化,要进行重定位,然后程序才可以运行。类似静态链接的重定位,动态链接库被加载到内存后,目标文件的起始地址也发生了变化,需要重定位。一个可执行文件对动态链接库的符号引用,要等动态链接库加载到内存后地址才能确定,然后对可执行文件中的这些符号修改即可
与地址无关的代码
- 如果想让我们的动态库放到内存的任何位置都可以运行,都可以被多个进程共享,一种比较好的方法是将我们的动态库设计成与地址无关的代码。
- 需要被修改的指令(符号)和数据在每个进程中都有一个副本,互不影响各自的运行。
- 与地址无关的代码实现也很简单,编译代码时加上-fPIC参数即可。PIC是Position-Independent Code的简写,即与地址无关的代码。加上-fPIC参数生成的指令,实现了代码与地址无关,放到哪里都可以执行。
- ,对函数和全局变量的引用要避免使用绝对地址,一般可以使用相对跳转代替
- 以ARM平台为例,可以采用相对寻址来实现。ARM有多种寻址方式,其中有一种叫相对寻址,以PC为基址,以当前指令和目标地址的差作为偏移量,两者相加的地址即操作数的有效地址。ARM汇编中的B、BL、ADR、ADRL等指令都是采用相对寻址实现的。
全局偏移表
- 当动态库作为第三方模块被不同的应用程序引用时,库中的一些绝对地址符号(如函数名)将不可避免地被多次调用,需要重定位
- 解决
- 每个应用程序将引用的动态库(绝对地址)符号收集起来,保存到一个表中,这个表用来记录各个引用符号的地址。当程序在运行过程中需要引用这些符号时,可以通过这个表查询各个符号的地址。这个表被称为全局偏移表(Global Offset Table,GOT)。
- ,GOT表以section的形式保存在可执行文件中
- 编译阶段就已经确定
- 运行需要引用动态库中的函数时
- 更新GOT表中的各个符号(函数)的地址
好处
- 在内存中只需要加载一份动态库,当不同的程序运行时,只要修改各自的GOT表,它们引用的符号都可以指向同一份动态库,就可以达到不同程序共享同一个动态库的目标
延迟绑定
- 与地址无关”这一技术在ARM平台可以使用相对寻址来实现
- ARM相对寻址的本质其实就是寄存器间接寻址
,可执行文件一般都采用延迟绑定
- 程序在运行时,并不急着把所有的动态库都加载到内存中并进行重定位。当动态库中的函数第一次被调用到时,才会把用到的动态库加载到内存中并进行重定位
- 既节省了内存,又可以提高程序的运行速度
- GOT表中每一项都是相同的值:0x10490。在0x10490地址处是一个跳转指令,跳转到动态链接器去执行,动态链接器的入口地址保存在GOT表的0x21008~0x2100b处。
- 动态链接器的主要工作就是加载动态库到内存中并进行重定位操作
- [插图]
- 指令代码中每一个使用动态链接的符号<x@plt>,都被保存在过程链接表(Procedure Linkage Table,PLT,以.plt为后缀)中。过程链接表其实就是一个跳转指令,它无法单独工作,要和GOT表相关联,协同工作
- 过程链接表PLT本质上是一个数组,每一个在程序中被引用的动态链接库函数,都在数组中对应其中一项,跳转到GOT表中的对应项。PLT表中有两个特殊项,PLT[0]会关联到动态链接器的入口地址,而PLT[1]则会关联到初始化函数:__libc_start_main(),该函数会初始化C语言运行的进本环境;
- C标准库其实就是以动态共享库的封装形式保存在Linux系统中的
- 各个应用程序在引用printf这个符号时,就会启动动态链接器,将这份代码映射到各自进程的地址空间,更新各自GOT表中printf()函数的实际地址,然后通过查询GOT表找到printf()函数在内存中的实际地址,就可通过间接访问跳转执行。
- 共享库
- 现在大多数软件都是采用动态链接的方式开发的,不仅可以节省内存空间,升级维护也比较方便。
- 加载到内存中并进行动态链接
- 动态链接器在查找共享库的过程中,除了到系统默认的路径(/lib、/usr/lib)下查找,也会到用户指定的一些路径下去查找,用户可以在/etc/ld.so.conf文件中添加自己的共享库路径
- 有时候我们也可以使用LD_LIBRARY_PATH环境变量临时改变共享库的查找路径
- 多个共享库的路径添加到这个环境变量中,各个路径用冒号隔开。
4.9 插件的工作原理
很多软件为了扩展方便,具备通用性,普遍都支持插件机制
- 主程序的逻辑功能框架不变,各个具体的功能和业务以动态链接库的形式加载进来。这样做的好处是软件发布以后不用重新编译,可以直接通过插件的形式来更新功能,实现软件升值。
插件的本质其实就是共享动态库
- 主程序框架引用的外部模块符号,运行时以动态链接库的形式加载进来并进行重定位,就可以直接调用
Linux提供了专门的系统调用接口,支持显式加载和引用动态链接库
- (1)加载动态链接库
- [插图]
- dlopen() 函数返回的是一个void*类型的操作句柄,我们通过这个句柄就可以操作显式加载到内存中的动态库
,第二个参数是打开标志位,经常使用的标记位
- RTLD_LAZY:解析动态库遇到未定义符号不退出,仍继续使用
- RTLD_NOW:遇到未定义符号,立即退出。
- RTLD_GLOBAL:允许导出符号,在后面其他动态库中可以引用
- (2)获取动态对象的地址
- [插图]
- dlsym() 函数根据动态链接库句柄和要引用的符号,返回符号对应的地址
- (3)关闭动态链接库
- [插图]
- 该函数会将加载到内存的共享库的引用计数减一,当引用计数为0时,该动态共享库便会从系统中被卸载
- (4)动态库错误函数
- [插图]
- 当动态链接库操作函数失败时,dlerror将返回出错信息。若没有出错,则dlerror的返回值为NULL
4.10 Linux内核模块运行机制
Linux内核实现
支持模块的动态加载和运行
- 如果你实现了一个内核模块并打算运行它,你并不需要重启系统,直接使用insmod命令加载即可
- hello.ko内核模块的运行原理其实和共享库的运行机制一样,都是在运行期间加载到内存,然后进行一系列空间分配、符号解析、重定位等操作。
- hello.ko文件本质上和静态库、动态库一样,是一个可重定位的目标文件。
- hello.ko和动态库的不同之处在于:一个运行在内核空间,一个运行在用户空间。
insmod命令加载一个内核模块时,基本流程如下。
- (1)kernel/module.c/init_module.(2)复制到内核:copy_module_from_user。(3)地址空间分配:layout_and_allocate。(4)符号解析:simplify_symbols。(5)重定位:apply_relocations。(6)执行:complete_formation。
4.11 Linux内核编译和启动分析
- 操作系统为应用程序提供了运行的进程环境和调度管理
操作系统自身是如何运行和启动的
- 通过U-boot加载Linux内核镜像uImage到内存的不同位置,观察Linux内核启动流程
- Linux内核镜像uImage的编译流程
- Linux内核是在裸机环境下启动的,在启动过程中并没有ELF文件的执行环境,需要将ELF文件转换为BIN/HEX格式的纯二进制指令文件。编译器会调用objcopy命令删除vmlinux可执行文件中不必要的section,只保留代码段、数据段等必要的section,将ELF格式的vmlinux文件转换为原始的二进制内核镜像Image
- [插图]
- 程Image是纯指令文件,可以在裸机环境下运行,但自身体积比较大(一般几十兆以上)
- BootLoader
- 使用U-boot引导内核的嵌入式平台通常会对zImage进一步转换,给它添加一个64字节的数据头,用来记录镜像文件的加载地址、入口地址、文件大小、CPU架构等信息
- 我们可以使用U-boot提供的mkimage工具将zImage镜像转换为uImage
- [插图]
- mkimage常用的一些参数说明如下。● -A:指定CPU架构类型。● -O:指定操作系统类型。● -T:指定image类型。● -C:采用的压缩方式有none、gzip、bzip2等。● -a:内核加载地址。● -e:内核镜像入口地址。
- U-boot提供了bootm机制来启动内核的运行。bootm会解析uImage文件中64(0x40)字节的数据头,解析出指定的加载地址
并和自己的启动参数进行对比
- 发现bootm参数地址和编译时-a指定的加载地址0x60003000相同,就会直接跳过数据头
- 如果bootm发现自己的参数地址和-a指定的加载地址0x60003000不同,它会把去掉64字节数据头的内核镜像zImage复制到编译时-a指定的加载地址处,然后跳到该地址执行
因为Image镜像链接时使用的是虚拟地址,所以在运行Linux内核的C语言函数之前,首先会运行一段汇编代码来初始化堆栈环境,使能MMU
- 运行入口:arch/arm/kernel/head.S。
- 能MMU:__create_page_tables()。
- 跳入C语言函数:__mmap_switched/start_kernel()。
4.12 U-boot重定位分析
- 。U-boot比较有意思,不仅充当“加载器”的角色,引导Linux内核镜像运行,还充当了“链接器”的角色,完成自身代码的复制及重定位。
现在的ARM SoC一般会在芯片内部集成一块ROM,在ROM上会固化一段启动代码
系统上电后,会首先运行固化在芯片内部的ROMCODE代码
- 初始化存储接口、建立存储映射
- 根据CPU外部管脚或eFuse值来判断系统的启动方式
- 如果我们设置系统从NOR Flash启动,那么这段代码就会将NOR Flash映射到零地址,然后系统复位,CPU跳到U-boot中断向量表中的第一行代码,即NOR Flash中的第一行代码去执行。
- 除了SDRAM和NOR Flash支持随机读写,可以直接运行代码,其他Flash存储器是不支持直接运行代码的,只能将代码复制到内存中执行。
- 为此时系统刚上电,内存还没有初始化,所以系统一般会先将NAND Flash或SD卡中的一部分代码(前4KB)复制到芯片内部的SRAM中去执行,映射SRAM到零地址,然后在这4KB代码中进行各种初始化、代码复制、重定位等工作,最后PC指针才跳到SDRAM内存中去执行代码
- 在一个嵌入式系统中,无论采用哪种启动方式,为提高运行效率,U-boot在启动过程中,都会将存储在ROM上的自身代码复制到内存中重定位,然后跳转到内存SDRAM中去执行。
- 系统上电复位,ARM首先会跳到中断向量表执行复位程序,reset复位程序定义在start.S汇编文件中,PC指针会跳转到start.S文件执行该程序
。系统上电复位程序主要执行
- 设置CPU为SVC模式。
- 关闭Cache,关闭MMU。
- 设置看门狗、屏蔽中断、设置时钟、初始化SDRAM。
- reset复位程序会调用不同的子程序完成各种初始化,
- reset最后会跳到crt0.S中的_main汇编子程序执行
- [插图]
在_main中主要执行
- 初始化C语言运行环境、堆栈设置。
- 各种板级设备初始化、初始化NAND Flash、SDRAM。
- 初始化全局结构体变量GD,在GD里有U-boot实际加载地址。
- 调用relocate_code,将U-boot镜像从Flash复制到RAM
- 从Flash跳到内存RAM中继续执行程序。
- BSS段清零,跳入bootcmd或main_loop交互模式。
调用relocate_code实现代码的复制与重定位操作
- U-boot是如何将自身代码从Flash复制到RAM中的?U-boot自身是如何从Flash跳到RAM中的?
- relocate_code这段汇编代码慢慢分析。relocate_code在relocate.S汇编文件中定义,它会首先将U-boot自身的代码段、数据段从Flash复制到RAM,然后根据重定向符号表,对内存中的代码进行重定位。
U-boot会被内核镜像复制到内存中的什么地址
- U-boot可以根据硬件平台实际RAM的大小灵活设置加载地址,并保存在全局数据gd→relocaddr中
- 更大程度地适配不同大小的内存配置、不同的启动方式和不同的链接地址
内核镜像一般会加载到内存的低端地址,U-boot一般被加载到内存的高端地址
- 一是防止U-boot在复制内核镜像到内存时覆盖掉自己,二是U-boot可以一直驻留在内存中,当我们使用reboot软重启Linux系统时,还可以回跳到U-boot执行
在relocate_code中
复制镜像的核心代码。
- [插图]
U-boot分别使用两个零长度数组__image_copy_start和__image_copy_end来标记U-boot中要复制到内存中的指令代码段
- 在复制之前,要判断链接地址__image_copy_start和保存在R0中的实际加载地址gd→relocaddr是否相等,如果相等,则跳过复制过程
__image_copy_start在链接脚本U-boot.lds中的位置
- [插图]
- U-boot复制到内存后,还需要对其重定位,然后才能跳到RAM中运行
- 动态链接库为了让多个进程共享,使用了-fpic参数编译,生成了与位置无关的代码+GOT表的形式
- 与位置无关的代码采用相对寻址,无论加载到内存中的任何地方都可以运行
- GOT表放到数据段中,位置是固定不变的,当程序要访问动态库中的绝地地址符号时,可先通过相对寻址跳到GOT表中查找该符号的真实地址,然后跳过去执行即可
U-boot的重定位操作和动态链接库类似,采用与地址无关代码+符号表的形式来完成重定位操作
- 符号表中保存的是代码中引用的绝对符号地址,如全局变量的地址、函数的地址等
- 符号表紧挨着代码段,位置在编译时就已经固定死了,程序访问全局变量时,可先通过相对寻址跳到符号表,在符号表中找到变量的真实地址,然后就可以直接访问变量
- U-boot在启动过程中,调用relocate_code将自身镜像复制到内存的0x3000地址处
- 定位后,符号表中全局变量i的地址就更新为在内存中的真实地址0x3500了,PC指针跳到内存执行后就可以根据符号表中的地址正常访问变量i
4.13 常用的binutils工具集
- tools。GNU工具集主要用来协助程序的编译、链接、调试过程,支持不同格式的文件相互转换,以及针对特定的处理器做优化等。
- 表4-1 常用的binutils工具
- [插图]
readelf是我们比较常用的命令,主要用来查看二进制文件的各个section信息。
- 表4-2 readelf命令的参数和说明[插图]
objdump主要用来反汇编,将可执行文件的二进制指令反汇编成汇编文件。
- 表4-3 objdump命令的参数和说明[插图]续表[插图]
objcopy命令主要用来将一个文件的内容复制到另一个目标文件中,对目标文件实行格式转换
- 表4-4 objcopy命令的参数和说明[插图]
- 如果我们想将一个ELF文件转换为BIN文件,则可以使用下面的命令。[插图]各个参数的说明如下。
- O binary:输出为原始的二进制文件。
- -R.comment:删除section.comment。
- -S:重定位、符号表等信息不要输出到目标文件U-boot.bin中。
第5章 内存堆栈管理
- 当程序运行时,可执行文件首先被加载到内存中,各个section分别加载到内存中对应的代码段、数据段和BSS段中。
- 需要动态链接的动态库也被加载到内存中,完成代码的链接和重定位操作,以保证程序的正常运行
5.1 程序运行的“马甲”:进程
- Shell虚拟终端bash本身也是以进程的形式运行
- 当我们在Shell交互环境下运行./hello时,bash会解析我们的命令和参数,调用fork创建一个子进程,接着调用exec()函数将hello可执行文件的代码段、数据段加载到内存,替换掉子进程的代码段和数据段。
- 然后bash会解析我们在交互环境下输入的参数,将解析的参数列表argv传递给main,最后跳到main()函数执行。
- 在Linux系统中,每个进程都使用一个task_struct结构体表示,各个task_struct构成一个链表,由操作系统的调度器管理和维护,每一个进程都会接受操作系统的任务调度,轮流占用CPU去运行
- 程序是安装在磁盘上某个路径下的二进制文件,而进程则是一个程序运行的实例
- 操作系统会从磁盘上加载这个程序到内存,分配相应的资源、初始化相关的环境,然后调度运行
- 一个进程实例
包括汇编指令代码、数据
- 包括进程上下文环境、CPU寄存器状态、打开的文件描述符、信号、分配的物理内存等相关资源
- 在一个进程的地址空间中,代码段、数据段、BSS段在程序加载运行后,地址就已经固定了,在整个程序运行期间不再发生变化,这部分内存一般也称为静态内存。
- 使用malloc申请的内存、函数调用过程中的栈在程序运行期间则是不断变化
动态内存
- 用户使用malloc申请的内存一般被称为堆内存(heap),函数调用过程中使用的内存一般被称为栈内存(stack)。
5.2 Linux环境下的内存管理
- 在Linux环境下运行的程序,在编译时链接的起始地址都是相同的,而且是一个虚拟地址。
- Linux内核通过页表和MMU硬件来管理内存,完成虚拟地址到物理地址的转换、内存读写权限管理等功能
- 每一个应用程序进程都有4GB大小的虚拟地址空间。为了系统的安全稳定,0~4GB的虚拟地址空间一般分为两部分:用户空间和内核空间。0~3GB地址空间给应用程序使用,而操作系统一般运行在3~4GB内核空间。
应用程序没有权限访问内核空间
- ,只能通过中断或系统调用来访问内核空间
- 在Linux环境下,虽然所有的程序编译时使用相同的链接地址,但在程序运行时,相同的虚拟地址会通过MMU转换,映射到不同的物理内存区域,各个可执行文件被加载到内存不同的物理页上
- 每个进程都有各自的页表,用来记录各自进程中虚拟地址到物理地址的映射关系。
- 通过这种地址管理,每个进程都可以独享一份独立的、私有的3GB用户空间
- 。堆内存一般在BSS段的后面,随着用户使用malloc申请的内存越来越多,堆空间不断往高地址增长
- 栈空间则紧挨着内核空间,ARM使用的是满递减堆栈,栈指针会从用户空间的高地址往低地址不断增长
- 堆栈之间的一片茫茫空间中,还有一块区域叫作MMAP区域,我们上一章学习的动态共享库就是使用这片地址空间的
5.3 栈的管理
- 入栈和出栈都靠栈指针(Stack Pointer,SP)来维护,SP会随着入栈和出栈在栈顶上下移动。
栈是C语言运行的基础
- C语言函数中的局部变量、传递的实参、返回的结果、编译器生成的临时变量都是保存在栈中的,离开了栈,C语言就无法运行
- 在很多嵌入式系统的启动代码中,你会看到:系统一上电开始运行的都是汇编代码,在跳到第一个C语言函数运行之前,都要先初始化栈空间。
栈的初始化
栈的初始化其实就是栈指针SP的初始化
- 在系统启动过程中,内存初始化后,将栈指针指向内存中的一段空间,就完成了栈的初始化
- 栈指针指向的这片内存空间被称为栈空间。
- ARM处理器则使用R13寄存器(SP)和R11寄存器(FP)来管理堆栈。
- ARM处理器使用的是满递减栈,在Linux环境下,栈的起始地址一般就是进程用户空间的最高地址,紧挨着内核空间,栈指针从高地址往低地址增长。
在Linux环境下,我们可以通过下面的命令来查看和设置栈的大小。
- [插图]
- Linux默认给每一个用户进程栈分配8MB大小的空间
防止栈溢出,可以参考下面的一些原则
- 尽量不要在函数内使用大数组,如果确实需要大块内存,则可以使用malloc申请动态内存。
● 函数的嵌套层数不宜过深。
● 递归的层数不宜太深。
5.3.2 函数调用
栈是C语言运行的基础,一个函数内定义的局部变量、传递的实参都是保存在栈中的。每一个函数都会有自己专门的栈空间来保存这些数据,每个函数的栈空间都被称为栈帧(Frame Pointer,FP)。每一个栈帧都使用两个寄存器FP和SP来维护,FP指向栈帧的底部,SP指向栈帧的顶部。
函数的栈帧除了保存局部变量和实参,还用来保存函数的上下文。如图5-10所示,我们在main()函数中调用了f()函数,main()函数的栈帧基址FP、main()函数的返回地址LR,都需要保存在f()函数的栈帧中。当f()函数运行结束退出时就可以根据栈中保存的地址返回函数的上一级继续执行。
- 函数的嵌套层数不宜过深。
- 递归的层数不宜太深。
函数调用
- 每个函数的栈空间都被称为栈帧(Frame Pointer,FP)
- 每一个栈帧都使用两个寄存器FP和SP来维护,FP指向栈帧的底部,SP指向栈帧的顶部
- 函数的栈帧除了保存局部变量和实参,还用来保存函数的上下文。
- 我们在main()函数中调用了f()函数,main()函数的栈帧基址FP、main()函数的返回地址LR,都需要保存在f()函数的栈帧中。
- SP总是指向当前正在运行函数栈帧的栈顶
- FP总是指向当前运行函数的栈底。
每一个函数栈帧中
- 要保存局部变量、函数实参、函数调用者的返回地址
- 有时候编译过程中的一些临时变量也会保存到函数的栈帧中
- 上一级函数栈帧的起始地址,即栈底也会保存到当前函数的栈帧中
- 多个栈帧通过FP构成一个链,这个链就是某个进程的函数调用栈
- 每个函数栈帧中都保存着上一级函数的返回地址LR和它的栈帧空间起始地址FP,当函数运行结束时,可根据这些信息返回上一级函数继续运行
- 前4个参数使用寄存器传递,剩余的参数则压入堆栈保存
- 表5-1 常用的调用惯例
- [插图]
- C语言默认使用cdecl调用惯例。参数传递时按照从右到左的顺序依次压入堆栈,栈的清理方则由函数调用者caller管理。
- 使用cdecl调用惯例的好处是可以预先知道参数和返回值大小,而且可以支持变参函数的调用,如printf()函数。
- FP寄存器不仅可以向前偏移访问本函数栈帧的内存单元,还可以向后偏移,到上一级函数的栈帧中获取要传递的实参。
形参与实参
- 形参只是在函数被调用时才会在栈中分配临时的存储单元,用来保存传递过来的实参值
- 变量作为实参传递时,只是将变量值复制给了形参,形参和实参在栈中位于不同的存储单元
- 最后我们做个小结:形参只有在函数被调用时才会在函数栈帧内分配存储单元,用来接收传进来的实参值。函数运行结束后,形参单元随着栈帧的销毁而被释放
栈与作用域
- 函数只有在被调用的时候才会在内存中开辟一个栈帧空间,在这个栈空间里存储局部变量及传进来的函数实参等。
全局变量的作用域
- 全局变量的作用域由文件来限定。
- 可使用extern进行扩展,被其他文件引用。
- 可以使用static进行限制,只能在本文件中被引用
局部变量的作用域
- 局部变量的作用域由{}限定
- 可以使用static修饰局部变量来改变它们的存储属性(生命周期),但不能改变其作用域。
- 以使用static修饰局部变量来改变它们的存储属性(生命周期),但不能改变其作用域。
栈溢出攻击原理
GCC编译器为了防止数组越界访问
- 一般会在用户定义的数组末尾放入一个保护变量,并根据此变量是否被修改来判断数组是否越界访问。
5.4 堆内存管理
- 堆是Linux进程空间中一片可动态扩展或缩减的内存区域,一般位于BSS段的后面。
内存申请相关的其他函数
- [插图]
- 当申请的内存不够用时,我们可以使用realloc()函数动态调整内存块的大小。
- 使用realloc()函数可以调整内存的大小,可以在原来malloc()申请的内存块的后面直接扩展
- 我们要将内存块大小调整到200字节,realloc()函数会新申请一块大小为200字节的空间,并将原来内存上的数据复制过来,返回给用户新申请空间的指针。
堆内存与栈相比,有相同点,也有区别
- 堆内存是匿名的,不能像变量那样使用名字直接访问,一般通过指针间接访问。
- 在函数运行期间,对函数栈帧内的内存访问也不能像变量那样通过变量名直接访问,一般通过栈指针FP或SP相对寻址访问。
- 堆内存由程序员自己申请和释放,函数退出时,如果程序员没有主动释放,就会造成内存泄漏。
- 栈内存由编译器维护,函数运行时开辟一个栈帧空间,函数运行结束,栈帧空间随之销毁释放
裸机环境下的堆内存管理
uC/OS的堆内存管理
Linux堆内存管理
- malloc()/free()函数的底层实现,其实就是通过系统调用brk向内核的内存管理系统申请内存。
- 当用户要申请的内存比较大时,如大于128KB,一般会通过mmap系统调用直接映射一片内存,使用结束后再通过ummap系统调用归还这块内存
- mmap区域是Linux进程中比较特殊的一块区域,主要用于程序运行时动态共享库的加载和mmap文件映射
- mmap区域则紧挨着stack,mmap区域包括进程动态链接时加载到内存的动态链接器ld-2.23.so、动态共享库、使用mmap申请的动态内存。
- heap区和mmap区的起始地址和stack一样,也不是固定不变的。为了防止黑客攻击,每次程序运行时,它们都会以一个随机偏移作为起始地址。
- mm_struct结构体中的start_brk成员表示堆区的起始地址
- 用户使用malloc()申请的内存大小大于当前的堆区时,malloc()就会通过brk()系统调用,修改mm_struct中的成员变量brk来扩展堆区的大小
- brk()系统调用的核心操作其实就是通过扩展数据段的边界来改变数据段的大小的。
- 大量的系统调用会让处理器和操作系统在不同的工作模式之间来回切换:操作系统要在用户态和内核态之间来回切换,CPU要在普通模式和特权模式之间来回切换,每一次切换都意味着各种上下文环境的保存和恢复,频繁地系统调用会降低系统的性能
- glibc中实现的内存分配器(allocator)可以直接对堆内存进行维护和管理。
- 当用户使用free()释放内存时,释放的内存并不会立即返回给内核,而是被内存分配器接收,缓存在用户空间
- 内存分配器将这些内存块通过链表收集起来,等下次有用户再去申请内存时,可以直接从链表上查找合适大小的内存块给用户使用,如果缓存的内存不够用再通过brk()系统调用去内核“批发”内存
- 内存分配器相当于一个内存池缓存,通过这种操作方式,大大减少了系统调用的次数,从而提升了程序申请内存的效率,提高了系统的整体性能。
- Linux环境下的C标准库glibc使用ptmalloc/ptmalloc2作为默认的内存分配器,
- 对于每一个用户申请的内存块,ptmalloc都使用一个malloc_chunk结构体来表示,每一个内存块被称为chunk
- [插图]
- 用户程序调用free()释放掉的内存块并不会立即归还给操作系统,而是被用户空间的ptmalloc接收并添加到一个空闲链表中
- malloc_chunk结构体中的fd和bk指针成员将每个内存块链成一个双链表,不同大小的内存块链接在不同的链表上,每个链表都被我们称作bin,ptmalloc内存分配器共有128个bin,使用一个数组来保存这些bin的起始地址。
- 每一个bin都是由不同大小的内存块链接而成的链表,根据内存块大小的不同,我们可以对这些bins进行分类。
- [插图]
- 用户释放掉的内存块不会立即放到bins中,而是先放到unsorted bin中
- 等用户下次申请内存时,会首先到unsorted bin中查看有没有合适的内存块,若没有找到,则再到small bins或large bins中查找。small bins中一共包括62个bin,相邻两个bin上的内存块大小相差8字节,内存数据块的大小范围为[16,504],大于504字节的大内存块要放到large bins对应的链表中
- 除了数组中的这些bins,还有一些特殊的bins,如fast bins。用户释放掉的小于M_MXFAST(32位系统下默认是64字节)的内存块会首先被放到fast bins中
- fast bins由单链表构成,FILO栈式操作,运行效率高,相当于small bins的缓存。
- 堆内存的分配流程
- 当用户申请一块内存时,内存分配器就根据申请的内存大小从bins查找合适的内存块
- 当申请的内存块小于M_MXFAST时,ptmalloc分配器会首先到fast bins中去看看有没有合适的内存块,如果没有找到,则再到small bins中查找。如果要申请的内存块大于512字节,则直接跳过small bins,直接到unsorted bin中查找。
- 在适当的时机,fast bins会将物理相邻的空闲内存块合并,存放到unsorted bin中
- 内存分配器如果在unsorted bin中没有找到合适大小的内存块,则会将unsorted bins中物理相邻的内存块合并,根据合并后的内存块大小再迁移到small bins或large bins中
- ptmalloc接着会到large bins中寻找合适大小的内存块。假设没有找到大小正好合适的内存块,一些大的内存块将会被分割成两部分:一部分返回给用户使用,剩余部分则放到unsorted bin中。
- 如果在large bins中还没有找到合适的内存块,这时候就要到top chunk上去分配内存
- top chunk是堆内存区顶部的一个独立chunk,它比较特殊,不属于任何bins
- 若用户申请的内存小于top chunk,则top chunk会被分割成两部分:一部分返回给用户使用,剩余部分则作为新的top chunk
- 若用户申请的内存大于top chunk,则内存分配器会通过系统调用sbrk()/mmap()扩展top chunk的大小
- 用户第一次调用malloc()申请内存时,ptmalloc会申请一块比较大的内存,切割一部分给用户使用,剩下部分作为top chunk。
- 当用户申请的内存大于M_MMAP_THRESHOLD(默认128KB)时,内存分配器会通过系统调用mmap()申请内存
- 使用mmap映射的内存区域是一种特殊的chunk,这种chunk叫作mmap chunk。当用户通过free()函数释放掉这块内存时,内存分配器再通过munmap()系统调用将其归还给操作系统,而不是将其放到bin中。
堆内存测试程序
- 当堆内存中相邻的两个内存块都被释放且处于空闲状态时,ptmalloc在合适的时机,会将这两块内存合并成一块大内存,并在bins上更新它们的维护信息
实现自己的堆管理器
当函数一级一级地调用又退出
- 栈中的函数栈帧是如何创建和销毁的
- FP和SP指针是如何移动的
当函数内使用malloc()/free() 申请释放内存时
- 堆区的内存是如何变化的
- brk指针是如何移动的
- glibc中的内存分配器ptmalloc又是如何工作的
5.5 mmap映射区域探秘
- 当用户使用malloc申请大于128KB的堆内存时,内存分配器会通过mmap系统调用,在Linux进程虚拟空间中直接映射一片内存给用户使用
- 无论是动态链接器、动态共享库的加载,还是大于128KB的堆内存申请,都和这个区域息息相关
- 既然已经有堆区和栈区了,为什么还要使用这片映射区域?
- 这片映射区域的内存有什么特点?
- 怎么使用它?
- 操作系统是如何管理和维护的?
。当我们运行一个程序时
- 需要从磁盘上将该可执行文件加载到内存
将文件加载到内存有两种常用的操作方法
- 一种是通过常规的文件I/O操作,如read/write等系统调用接口
- 一种是使用mmap系统调用将文件映射到进程的虚拟空间,然后直接对这片映射区域读写即可。
- 为了提高读写效率,减少I/O读盘次数以保护磁盘,Linux内核基于程序的局部原理提供了一种磁盘缓冲机制
当应用程序读磁盘文件时
- 会先到缓存中看数据是否存在,若数据存在就直接读取并复制到用户空间
- 若不存在,则先将磁盘数据读取到页缓存(page cache)中,然后从页缓存中复制数据到用户空间的buf中
。当应用程序写数据到磁盘文件时
- 会先将用户空间buf中的数据写入page cache
- ,当page cache中缓存的数据达到设定的阈值或者刷新时间超时,Linux内核会将这些数据回写到磁盘中
- 为了减少系统调用的次数
- glibc决定进一步优化
- ,在用户空间开辟一个I/O缓冲区,并将系统调用read()/write() 进一步封装成fread()/fwrite()库函数
- 用户可以通过这个FILE类型的文件指针,调用fread()/fwrite() C标准库函数来读写文件
- 当应用程序通过fread()函数读磁盘文件时,数据从内核的页缓存复制到I/O缓冲区,然后复制到用户的buf2中
- 当fread第二次读写磁盘文件时会先到I/O缓冲区里查看是否有要读写的数据,如果有就直接读取
- 如果没有就重复上面的流程,重新缓存
- I/O缓冲区通过减少系统调用的次数来降低系统调用的开销,但也增加了数据在不同缓冲区复制的次数:一次读写流程要完成两次数据的复制操作。当程序要读写的数据很大时,这种文件I/O的开销也是很大的,得不偿失
- 我们可以通过mmap系统调用将文件直接映射到进程的虚拟地址空间中,地址与文件数据一一对应,对这片内存映射区域进行读写操作相当于对磁盘上的文件进行读写操作
- 这种映射方式减少了内存复制和系统调用的次数,可以进一步提高系统性能。
将文件映射到内存
mmap()的函数原型如下。
mmap映射实现机制分析
- task_struct结构体的mm_struct成员用来描述当前进程的内存布局信息
- 一个进程的虚拟地址空间分为不同的区域,如代码段、数据段、mmap区域等,每一个区域都使用vm_area_struct结构体对象来描述。
- [插图]
- 当用户程序开始读写进程虚拟空间中的这片映射区域时,发现这片映射区域还没有分配物理内存,就会产生一个请页异常,Linux内存管理子系统就会给该片映射内存分配物理内存,将要读写的文件内容读取到这片内存,最后将虚拟地址和物理地址之间的映射关系写入该进程的页表
- 一块普通的内存,显卡、Frambuffer都是一个文件,都可以映射到内存,既减少了系统调用的次数,又减少了数据复制的次数,性能相比文件I/O显著提高
把设备映射到内存
- 在嵌入式ARM平台上,LCD控制器通常以IP的形式集成到SoC芯片
- 似,也要占用一部分内存空间作为显示内存
- Linux内核在驱动层对不同的LCD硬件设备进行抽象,屏蔽底层的各种硬件差异和操作细节,抽象出一个帧缓存设备——Framebuffer。Framebuffer是Linux对显存抽象的一种虚拟设备,对应的设备文件为/dev/fb,它为Linux的显示提供了统一的接口
多进程共享动态库
- 一个被加载到内存的动态库是如何被多个进程共享的
- 动态库libtest.so只加载到物理内存一次,后面的进程如果需要链接这个动态库,直接将该库文件映射到自身进程的虚拟空间即可,
5.6 内存泄漏与防范
预防内存泄漏
预防内存泄漏最好的方法就是
- 内存申请后及时地释放,两者要配对使用,内存释放后要及时将指针设置为NULL,使用内存指针前要进行非空判断
- 为了预防这种错误的发生,在编程时,如果我们在一个函数内申请了内存,则要在申请处添加注释,说明这块内存应该在哪里释放。
内存泄漏检测:MTrace
- MTrace是Linux系统自带的一个工具,它通过跟踪内存的使用记录来动态定位用户代码中内存泄漏的位置
- 广义上的内存泄漏
- 广义上的内存泄漏指系统频繁地进行内存申请和释放,导致内存碎片越来越多,无法再申请一片连续的大块内存。
5.7 常见的内存错误及检测
常见的内存错误一般主要分为以下几种类型:
- 内存越界、内存踩踏、多次释放、非法指针。
- 发生段错误的根本原因在于非法访问内存,即访问了权限未许可的内存空间。
使用core dump调试段错误
内存踩踏
内存踩踏监测:mprotect
- 页(page)是Linux内存管理的基本单元,在32位系统中,一个页通常是4096字节,mprotect()要保护的内存单元通常要以页地址对齐,我们可以使用memalign()函数申请一个以页地址对齐的一片内存。
内存检测神器:Valgrind
- Valgrind包含一套工具集,其中一个内存检测工具Memcheck可以对我们的内存进行内存覆盖、内存泄漏、内存越界检测。Valgrind的安装及使用步骤如下。
8.2 面向对象编程基础
- 面向过程编程侧重于解决问题的步骤过程,一般适用于简单功能的实现场合。
- 每一步我们都可以使用一个函数完成特定的功能,然后在主程序中分别调用即可。
8.3 Linux内核中的OOP思想:封装
- 在C语言中,内嵌结构体或内嵌指向结构体的指针,都可以看作对“继承”的模拟。
- Linux使用sysfs文件系统来显示设备的信息,在/sys目录下,你会看到有devices的目录
- [插图]
们所有设备在系统中的树结构雏形
- kobject结构体用来表示Linux系统中的一个设备,相同类型的kobject通过其内嵌的list_head链成一个链表,然后使用另外一个结构体kset来指向和管理这个列表。
- 结构体kobject也定义了很多方法,用来支持设备热插拔等事件的管理。当用户插入一个设备或拔出一个设备时,系统中的设备信息也会随之发生更新
- 在结构体kobject中内嵌了一个kobj_type结构体,该结构体内封装了很多关于设备插拔、添加、删除的方法。
- 与字符设备cdev类似,在结构体类型device的定义里,也通过内嵌kobject结构体来完成对基类kobject的继承。但其与字符设备不同之处在于,device结构体内部还内嵌了bus_type和device_driver,用来表示其挂载的总线和与其匹配的设备驱动。
- device结构体可以看成一个抽象类,我们无法使用它去创建一个具体的设备。其他具体的总线型设备,如USB设备、I2C设备等可以通过内嵌device结构体来完成对device类属性和方法的继承。
- USB网卡比较特殊,虽然它实现了网卡的功能,但是其底层通信是USB协议,底层接口是USB接口,而不是普通的以太网接口,所以这里涉及多重继承的问题。USB网卡是一个子类,usb_device和net_device都是它的基类。
8.4 Linux内核中的OOP思想:继承
- 抽象类的作用,主要就是实现分层:实现抽象层。
- 抽象类主要用来管理父类和子类的继承关系,通过分层来提高代码的复用性。如上面设备模型中的device类,位于kobj类和usb_device类之间,通过分层,可以更好地实现代码复用。
- 什么是接口呢?一个类支持的行为和方法就是接口。
- 接口与抽象类相比,两者有很多相似的地方。如两者都不能实例化对象,都是为了实现多态。不同点在于接口是对一些方法的封装,在类中不允许有数据成员,而抽象类中则允许有数据成员存在。除此之外,抽象类一般被子类继承,而接口一般要被类实现。
- 我们可以把接口看作一个退化了的多重继承。接口简化了继承关系,解决了多重继承的冲突,可以将两个不相关的类建立关联。
8.5 Linux内核中的OOP思想:多态
- 在子类继承父类的过程中,一个接口可以有多种实现,在不同的子类中有不同的实现,我们通过基类指针去调用子类中的不同实现,就叫作多态。
9.1 模块的编译和链接
- 在项目编译过程中,编译器是以C源文件为单位进行编译的,每一个C源文件都会被编译器翻译成对应的一个目标文件
- 接下来链接器对每一个目标文件进行解析,将文件中的代码段、数据段分别组装,生成一个可执行的目标文件。如果程序调用了库函数,则链接器也会找到对应的库文件,将程序中引用的库代码一同链接到可执行文件中
- 在链接过程中,如果多个目标文件定义了重名的函数或全局变量,就会发生符号冲突,报重定义错误。这时候链接器就要对这些重复定义的符号做符号决议,决定哪些留下,哪些丢弃
符号决议
- 在一个多文件项目中,不允许有多个强符号。● 若存在一个强符号和多个弱符号,则选择强符号。● 若存在多个弱符号,则选择占用空间最大的那一个。
- 初始化的全局变量和函数是强符号,未初始化的全局变量默认属于弱符号。程序员也可以通过__attribute__属性声明显式更改符号的属性,将一个强符号显式转换为弱符号。
- 自动化编译工具make来编译项目,make自动编译工具依赖项目的Makefile文件
- akefile文件主要用来描述各个模块文件的依赖关系,要生成的可执行文件,需要编译哪些源文件,如何编译,先编译哪个,后编译哪个,Makefile里都有描述
- make在编译项目时,会首先解析Makefile,分析需要编译哪些源文件,构建一个完整的依赖关系树,然后调用具体的命令一步步去生成各个目标文件和最终的可执行文件
9.2 系统模块划分
- 通过分层设计,可以使整个系统层次更加分明,结构更加清晰,管理和维护起来更加方便
- 置,基本上不会对系统中的其他模块有多大的改动。分层设计的另一个好处就是使系统资源的初始化和释放顺序清晰明了:可以根据模块间的依赖关系,按照顺序去初始化或释放各个模块资源
9.4 头文件深度剖析
- 一个变量的声明和一个变量的定义不是一回事,大家不要弄混了:是否分配内存是区分定义和声明的唯一标准
- 一个变量只能定义一次,即只能分配一次存储空间,但是可以多次声明
- 在C语言中,如果我们在程序中调用了在其他文件中定义的函数,但没有在本文件中声明,编译器在编译时并不会报错,而是会给我们一个警告信息并自动添加一个默认的函数声明。
- C语言编程中对一个符号“先声明后引用”的重要性
- 对外部文件的符号进行声明呢?C语言提供了extern关键字
- 从C语言语法的角度看,使用extern关键字可以扩展一个全局变量或函数的作用域
- 如果省略了extern且具有初始化语句,则为定义语句。如int i=10;。● 如果使用了extern,无初始化语句,则为声明语句。如extern int i;。● 如果省略了extern且无初始化语句,则为试探性定义。如int i;。
- 定义的本质就是为对象分配存储空间,而声明则将一个标识符与某个C语言对象相关联(函数、变量等)。我
- ● void.● an array type of unknown size:int a[];.● a structure or union type of unknown content.
- 直接在结构体内使用其类型定义了一个指针成员next,这也算前向引用
- 当我们对一个不完全类型进行前向引用时,我们只能使用该标识符的部分属性:类型
9.6 被误解的关键字:goto
- goto在使用的过程中,也有一些需要注意的地方,如只能往前跳,不能往回跳
- 使用goto只能在同一函数内跳转,函数内goto标签的位置也有一定的讲究,goto标签一般在函数体内两段不同逻辑功能代码的交界处,用来区分函数内的模块化设计和逻辑关系
9.7 模块间通信
- 一个全局变量具有文件作用域,但是我们可以通过extern关键字将全局变量的作用域扩展到不同的文件中,然后各个模块就可以通过全局变量进行通信
- 一个系统的不同模块还可以通过数据耦合、标记耦合的方式进行通信,即通过函数调用过程中的参数传递、返回值来实现模块间通信。
- 依赖倒置原则:上层模块不应该依赖底层模块,它们共同依赖某一个抽象。抽象不能依赖具象,具象依赖抽象
- 可以在两个模块之间定义一个抽象接口。[插图][插图]
- 模块间通信无论是通过模块接口,还是通过回调函数,其实都属于阻塞式同步调用,会占用CPU资源
- 常用的异步通信如下。● 消息机制:具体实现与平台相关。● 事件驱动机制:状态机、GUI、前端编程等。● 中断。● 异步回调。
- Linux内核模块之间可以使用notify机制进行通信;内核和用户之间可以通过AIO、netlink进行通信;
10.2 操作系统基本原理
任务栈的作用主要有两个
- 任务执行期间,函数调用需要的函数栈帧。
- ● 保存被打断的任务现场:CPU的各种寄存器、状态寄存器、被打断地址等。
- 在一个多任务环境中,一个函数如果可以被多次重复调用,或者被多个任务并发调用,函数在运行过程中可以随时随地被打断,并不影响该函数的运行结果,我们称这样的函数为可重入函数
函数就是不可重入函数。
- 函数内部使用了全局变量或静态局部变量。● 函数返回值是一个全局变量或静态变量。● 函数内部调用了malloc()/free()函数。● 函数内部使用了标准I/O函数。● 函数内部调用了其他不可重入函数。
- 在裸机环境下面,我们不需要考虑函数的可重入问题,
- 临界区实现方式可以有多种:可以直接关中断,也可以通过互斥访问实现,如信号量、互斥量、自旋锁等,
10.3 中断
- 中断的用途不仅仅用在任务切换中,操作系统中的系统调用、内存管理等各种机制其实都是基于中断实现的。
- 这里就涉及中断号或中断线的概念了,如图10-10所示。
- SoC芯片内部通常会集成一个专门管理中断的模块:中断控制器。中断控制器通常通过一根或两根中断信号线与CPU相连。
- 每个中断的中断处理例程(Interrupt Service Routines,ISR)都有对应的中断处理函数。在中断处理函数中需要做什么操作,需要工程师根据自己的业务需要或功能需求自己编写
- :当函数调用返回时,一般返回的是当前调用指令的下一条指令;
- 中断返回时,一般返回到当前指令处继续执行
- CPU对栈内数据的访问是通过FP/SP+相对偏移实现的。因为是相对寻址,所以栈是与位置无关的
- 每个任务都有自己的任务栈,当CPU运行不同的任务时,我们让SP栈指针分别指向每个任务各自的任务栈
- ARM处理器属于RISC架构,不能直接处理内存中的数据,要先通过LDR指令将内存中的数据加载到寄存器,处理完毕后再通过STR指令回写到内存中,每一次数据的处理都需要加载/存储操作来辅助完成。如果在CPU处理数据的过程中任务被打断,保存现场时这些寄存器的值也要保存起来,再加上ARM处理器中的状态寄存器CPSR等,它们和PC、SP寄存器一起构成了程序运行的现场,即任务上下文环境。
保存当前任务的现场
- 任务上下文,包括PC指针、SP指针、各种寄存器等。这些现场一般会保存到各个任务的任务栈中(SP指针一般会保存在各个任务的结构体中
编写中断函数时要注意
- 中断函数被调用的时间不固定:中断函数要自己保护现场。● 中断函数被调用的地点不固定:当前的任务无法给中断函数传参。● 中断函数的返回地点不固定:中断函数不能有返回值。
编写一个中断处理函数,一般要遵循以下基本流程。
- 保存中断现场:状态寄存器、返回地址入栈、中断ISR中要用到的寄存器入栈。(2)清中断:关中断,保护现场。有些硬件会自动清除,重开中断前记得要清除。(3)执行用户编写的中断处理函数。(4)恢复现场:将栈中保存的数据弹到CPU的各个寄存器中,恢复被中断的现场,从栈中弹出返回地址到PC寄存器,CPU从被打断的程序处继续执行。
- 中断函数不能有返回值。● 不能向中断函数传递参数。● 不能调用不可重入函数,如printf()。● 不能调用引起睡眠的函数。● 中断函数应短小精悍,快速执行、快速返回。
- 在一个多任务环境中,中断处理函数还涉及任务调度的问题。当中断处理完毕要退出时,不一定会返回到原先被打断的任务继续执行,它会找出当前优先级最高的就绪任务,然后开始执行它
在Linux环境下编写中断处理函数
- 如在中断上下文中,要禁止任何进程切换。在中断处理函数中不能调用可能会引起任务调度的函数,如一些可能会引起CPU睡眠的函数、引起阻塞的函数,或者其他一些导致调度器介入执行,发生任务切换的函数。
10.5 揭开文件系统的神秘面纱
- 所谓格式化,其实就是让文件系统去管理这块存储空间,文件系统可能会把这块原始存储空间像耕地一样划分为大小相同的块,建立文件名、目录名到实际物理存储地址的映射关系,并将这些映射关系存储在某块物理存储单元内
- block是文件系统存储数据的基本管理单元。文件系统将U盘的存储空间分为两部分:纯数据区和元数据区。
- 纯数据区是文件真正的数据存储区,而元数据区则用来存储文件的相关属性:该文件在磁盘中的存储位置、文件的长度、时间戳、读写权限、所属组、链接数等文件信息
- 文件系统中的每一个文件都用一个inode结构体来描述,用来存储文件的元数据信息。
- 将一个存储设备mount到一个目录上的本质,其实就是改变该目录到具体物理存储的映射关系,让该存储设备与要挂载的某个目录建立关联,加入全局文件系统目录树中
- 你可以把目录看成一个指针,它可以指向不同的物理存储设备,你可以将多个设备挂载到同一个目录下,但该目录只指向最后一次挂载的存储设备,当挂载的设备卸载时,该指针会重新指向原来的物理存储空间。
- Linux内核在初始化过程中,首先会创建一个根目录“/”,然后mount第一个文件系统到这个根目录下,这个文件系统就被称为根文件系统
- 其他存储分区、磁盘、SD卡、U盘接着就可以mount到根文件系统的某个目录下,然后用户就可以通过文件接口访问各个不同的存储设备。
- 在Linux环境下,一个根文件系统会包含Linux运行所需要的完整目录和相关的启动脚本、配置文件、库、头文件等。它经常会包含以下目录。● /bin、/sbin:存放Linux常用的命令,以二进制可执行文件形式存储在该目录下。● /lib:用来存放Linux常用的一些库,如C标准库。● /include:头文件的存放目录。● /etc:用来存放系统配置文件、启动脚本。● /mnt:常用来作为挂载目录。
10.6 存储器接口与映射
- ROM用来存储程序和数据,当程序运行时,这些程序和数据会从ROM加载到RAM,RAM支持随机读写,CPU可以直接从RAM中取指运行。
- ● NOR Flash:数据线、地址线分开,具有随机寻址功能。● NAND Flash:数据线、地址线复用,不支持随机寻址,要按页读取。
- eMMC:将NAND Flash和读写控制器封装在一起,使用BGA封装。对外引出MMC接口,用户可以通过MMC协议读写NAND Flash,简化了读写方式。
- DRAM。SRAM是Static Random Access Memory的英文缩写,即静态随机存取存储器,每1bit的数据存储使用6个晶体管来实现,读写速度快,但是存储成本较高,一般作为CPU内部的寄存器、Cache、片内SRAM使用
- DRAM(Dynamic Random Access Memory,动态随机存取存储器),每1bit的数据存储使用一个晶体管和一个电容实现,电容充电和放电时分别代表1和0。DRAM存储成本比较低,但是因为电容漏电缘故,需要每隔一段时间定时刷新,为电容补充电荷。DRAM读写速度相比SRAM会慢很多,而且DRAM读写还需要控制器的支持
- SDRAM(Synchronous DRAM,同步动态随机存取内存)对DRAM作了一些改进,省去了电容充电时间,并改用流水线操作,将DRAM的读写速度提高了不少,再加上其存储成本低、容量大等优势,因此在嵌入式系统、计算机中被广泛使用。计算机上目前使用的内存条、智能手机使用的内存颗粒,其实都是SDRAM
- 不同的存储器使用不同的接口与CPU相连,存储器接口按访问方式一般分为SRAM接口、DRAM接口和串行接口3种
- SRAM接口是一种全地址、全数据线的总线接口,地址和存储单元是一一对应的,支持随机寻址,CPU可以直接访问,
- 随机读写。SRAM和NOR Flash一般都采用这种接口与CPU相连
- DRAM接口没有采用全地址线方式,而是采用行地址选择(RAS)+列地址选择(CAS)的地址形式,地址线是复用的,一个地址需要多个周期发送。因此CPU不能通过地址线直接访问DRAM,要通过DRAM控制器按照规定的时序去访问DRAM的存储单元。DRAM、SDRAM一般都是采用DRAM接口与CPU处理器相连。目前计算机中的各种DDR SDRAM内存条、智能手机中的内存颗粒都采用这种连接方案
- ARM处理器上电复位后,PC寄存器为0,CPU默认是从零地址去读取指令执行的。我们可以通过存储映射,将不同的存储器映射到零地址,那么CPU复位后,就可以到不同的存储器取指令运行,从而实现多种启动方式。
- 所谓存储映射,其实就是为SRAM中的存储单元分配逻辑地址的过程
- 无论采用何种映射方式,存储器的映射一般都会在复位之前由CPU自动完成,复位之后的CPU默认会从零地址开始执行代码,这是所有处理器都要遵守的规则
- CPU上电后会首先运行固化在CPU芯片内部的一小段代码,这片代码通常被称为ROMCODE
- 部分代码的主要功能就是初始化存储器接口,建立存储映射
- 。如果我们将U-boot代码“烧写”在NOR Flash上,设置系统从NOR Flash启动,这段ROMCODE代码就会将NOR Flash映射到零地址,然后系统复位,CPU默认从零地址取代码执行,即从NOR Flash上开始执行U-boot指令。
- 除了SRAM和NOR Flash支持随机读写,可以直接运行代码,其他Flash设备是不支持代码直接运行的,因此我们只能将这些代码从NAND Flash或SD卡复制到内存执行。
- 因为此时DDR SDRAM内存还没有被初始化,所以我们一般会先将NAND Flash或SD卡中的一部分代码(通常为前4KB)复制到芯片内部集成的SRAM中去执行,然后在这4KB代码中完成各种初始化、代码复制、重定位等工作,最后PC指针才会跳到DDR SDRAM内存中去运行
10.7 内存与外部设备
- 内存一般又称为主存,是CPU可以直接寻址的存储空间,存取速度快,常见的内存包括RAM、ROM、NOR Flash等
- 外存一般又称为辅存,是除CPU缓存和内存外的存储器,包括磁盘、NAND Flash、SD卡、EEPROM等
- 内存具有随机读写的特点,CPU的PC指针可以随机存取数据,可以直接运行代码
- 指令和数据存储在外存上,当程序运行时,指令和数据加载到内存,然后CPU直接从内存取指令和数据运行
- CPU与外部设备进行通信,常见的有3种方式:轮询、中断和DMA。
- ARM架构的处理器一般会将外部设备控制器的这些寄存器、缓冲区、FIFO和内存统一编址
- 外部设备控制器的寄存器和内存一起共享地址空间,因此也被称为I/O内存,CPU可以按照内存读写的方式,直接读写这些寄存器来管理和操作外部设备。
10.8 寄存器操作
- 让一个数据的高低位互换,则直接使用移位操作就可以实现。
- 我们有时候会看到类似mask&(mask-1)的程序语句,这个表达式可以用来判断一个数是否为2的整数次幂。
- 个数对另一个数做2次异或运算,还等于其本身。利用这个特性,我们可以实现数据的加密。
- 利用异或的这种特性,我们还可以实现一个函数,不需要借助第三方变量,实现两个变量无参交换
- 读写寄存器除了使用“位掩码+位运算”的组合方式,还有另外一种比较直接的方法:使用位域直接操作寄存器。
- 位域不仅可以和结构体结合使用,还可以和联合体结合使用
- C语言允许在结构体中使用匿名位域。
10.9 内存管理单元MMU
- 任何一个技术的出现,都有其存在的意义,都是为了解决相关问题出现的
- 每个App编译时都以虚拟地址为链接地址,甚至使用相同的链接地址都可以。当各个App运行时,CPU会通过MMU将相同的虚拟地址映射到不同的物理地址,各个App都有各自的物理内存空间,互不影响各自的运行
- 我们可以将内存分隔成4KB大小相同的内存单元,每个内存单元都被称为页或页帧。我们以页为单位进行映射,地址转换表中只保存每个页的虚拟起始地址到物理起始地址的转换关系
- 页映射的设计大大节省了内存空间,此时的地址转换表一般也被称为页表。
- 一个页表中有很多页表项,每一个页表项里只有每个页的虚拟起始地址到物理起始地址的转换信息
- 一般CPU会把这个虚拟地址分解成页帧号+页内偏移的形式
从虚拟地址到物理地址的整个转换过程实际上是由硬件和软件协作完成的
- CPU内部集成的MMU器件,通过页表内每一个页表项的转换关系,将虚拟地址转换为不同的物理地址。而页表则是由操作系统维护的,由Linux内存管理子系统负责管理和维护,当地址完成转换后,会同步更新到用户空间的每一个进程内。
- 为了提高转换效率,在CPU内部一般会集成一个缓存——TLB,用来缓存部分页表。
- MMU和页表还可以对不同的内存区域设置不同的权限,防止内存被践踏,从而保障系统的安全运行。
10.10 进程、线程和协程
- 对于一个不可重入函数来说,如果我们在它访问全局变量的时候,通过锁、关中断等机制实现互斥访问,那么这个不可重入函数也就变得安全了。此时,我们就说这个函数是线程安全的。
- 一个可重入函数肯定是线程安全的,一个不可重入函数如果对临界资源实现了互斥访问,那么它就变成了线程安全的。
- Linux环境下,因为每个进程在物理内存上都是相互隔离的,所以我们在多进程编程时,无论一个函数是否是可重入的,无论这个函数是否是线程安全的,我们在一个进程中都可以调用它们。
- 程序调用加锁函数时,操作系统会从用户态切换到内核态,并阻塞在内核态;当程序调用解锁函数时,操作系统同样会经历从用户态到内核态,再从内核态到用户态的转换。
- 协程一般适用在彼此熟悉的合作式多任务中,上下文切换成本低,更适合高并发请求的应用场景。