重点思考两个问题
一段源代码是怎么变成最后可执行的程序的
一个进程,在内存中是什么样的
一.预备知识
-
为了协调CPU、内存和高速的图形设备 -> 北桥 -> 有相对低速的设备连在北桥上 -> 南桥,专门处理低速设备
CPU频率被4GHz天花板限制,增加CPU数量 -> 对称多处理器 -> 成本高,多处理机之间共享昂贵缓存,只多个核 ->多核处理器 -
将用于管理计算机本身的软件成为系统软件
计算机软件体系结构
- 平台性的:操作系统内核 驱动程序 运行库 系统工具
- 程序开发性的:编译器、汇编器、链接器等开发工具和开发库
- 运行库
- 操作系统内核
- 硬件
-
将计算机上有限的物理内存分配给多个程序使用,但问题是地址空间不隔离、内存使用效率低、程序运行地址不确定。
加中间层的方法可以避免问题,把程序给出的地址看作是虚拟地址,通过映射,将虚拟地址转换为实际物理地址。
内存 -> 物理地址
分段将程序所需的内存空间大小的虚拟地址映射到某个物理地址,程序A、B被映射到两块不同物理空间区域且无重叠,解决了地址空间不隔离、程序运行地址不确定的问题
根据局部性原理,程序在运行时,在某个时间段内,只是频繁用到一小部分数据,分页将地址空间人为地等分成固定大小的页共程序使用,提高内存使用效率 -
进程:所有应用程序以进程的方式运行在比操作系统权限更低的级别,有独立的地址空间,进程之间地址空间相互隔离,CPU分配资源的最小单位
线程:cpu执行任务的最小单元,线程ID + 当前指令指针PC + 寄存器集合 + 堆栈
一个进程由一个或多个线程组成,每个线程都运行在进程的上下文中,各个线程之间共享程序的内存空间(代码段、数据段、堆)及一些进程级资源(打开文件和信号)
线程状态:运行 就绪 等待
线程调度:
- 轮转法:让各个线程轮流执行一小段时间的方法
- 优先级调度:改变优先级的方法:指定优先级、根据进入等待状态的频繁程度提升或降级优先级、长时间得不到执行而提升优先级
- 线程安全
同步:在一个线程访问数据未结束时,其他线程不得对同一个数据进行访问,将数据的访问原子化
同步最常见方法:锁,线程访问数据或资源前先获取锁,并在访问结束之后释放锁,锁被占用时,获取锁的线程等待,直到锁重新可用
- 信号量 标志资源占用/非占用
- 互斥量 必须由同一个线程释放、获取的信号量
- 临界区 仅限于本进程,其他进程无法获取的互斥量
编译器优化时,可能为了效率而交换毫不相干的两条相邻指令的执行顺序,使用volatile关键字阻止过度优化
1.阻止编译器为了提高速度将一个变量缓存到寄存器内而不写回
2.阻止编译器调整操作volatile变量的指令顺序
二.编译和链接
编译 + 链接 = 构建
- 预编译
处理以‘#’开始的预编译指令,展开宏定义 #define #if #endif
删除注释 添加行号标示 - 编译
- 词法分析 将源代码字符序列分割成一系列记号
- 语法分析
用语法分析器将产生的记号进行语法分析,产生语法树 - 语义分析
将语法树上的类型不符的插入相应结点,做隐式转换 -
生成中间代码
编译器前端 Clang 优化部分代码
编译过程.png
- 汇编
编译器后端 LLVM GCC 将汇编代码转变为机器可执行指令 - 链接
将源代码模块独立地编译,然后将其组装起来,将目标文件链接形成可执行文件- 地址和空间分配
- 符号决议、地址绑定
- 重定向,确定全局变量和函数最终运行时的绝对地址
三.目标文件
目标文件内存布局.text/.data 它们在文件中和虚拟地址中分配空间
.bss仅分配虚拟地址空间
每个可重定位目标模块m都有一个符号表,包含m所定义和引用的符号的信息,共有三种不同符号:
由m定义并能被其他模块引用的全局符号:非静态的C函数和全局变量
由其他模块定义并被模块m引用的全局符号:其他模块中定义的非静态C函数和全局变量
只被模块m定义和引用的局部符号:带static属性的C函数和全局变量,
这些符号在模块m中全局可见,但其他模块不可见
其他非本地静态变量由栈管理,链接器对此类不感兴趣
利用 static 属性隐藏变量和函数名字
段表:数组中每个元素都是结构体,包括段名、类型、加载地址、相对于文件头的偏移量、段大小、链接信息
重定位表:需要重定位的信息
函数、变量需要独特的符号名,防止类似的符号名冲突,C++采用命名空间的方法解决符号冲突,Objective-C 采用加前缀方式。
函数签名:包含了一个函数的信息,包括函数名、参数类型、所属类、名称空间及其他信息
符号分为强符号,和弱符号。强符号不可名称重复,弱符号(未初始化的全局变量)可以符号名相同。
对符号名的引用分为强引用和弱引用,强引用表示如果找不到符号定义会报错,弱引用不报错,默认为0或某个特殊值。
目标文件里面还有可能保存调试信息
可以进行设置断点、监视变量变化、单步行进等调试是因为编译器将源代码的行、函数和变量类型、结构体的定义、字符串保存在目标文件里
GCC编译时加上 -g 参数,编译器就会加上调试信息
四.静态链接
链接:将几个输入目标文件加工后合并成一个输出文件,这个文件可被加载到内存并执行。
两步链接:空间与地址分配 符号解析与链接时重定位
一种语言的开发环境会附带语言库,语言库是对操作系统API的包装、常用函数
静态库可看做一组目标文件的集合,参与编译
链接控制脚本控制链接器的运行,将目标文件和库文件转化为可执行文件
五.可执行文件的装载与进程
程序:静态 预先编译好的指令和数据集合的文件
进程:动态 程序运行时的过程
CPU位数决定了虚拟地址空间的大小 —> 硬件寻址空间大小页映射函数将虚拟空间的各个页映射至相应的物理空间
程序执行时所需要的指令和数据必须在内存中才能够正常运行,又根据局部性原理,可将程序最常用的部分驻留在内存中,不常用的存放在磁盘里,需要时,动态装入
动态装载的方法:
- 覆盖装入
手工将程序分成若干块,在编写覆盖管理器管理模块何时应该驻留内存、何时应被替换掉 - 页映射
将内存和磁盘中的数据及指令按照“页”为单位划分,需要时装入,超出范围后,用FIFO或最少使用算法替换新页
进程的建立:
创建一个独立的虚拟地址空间
读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系
将CPU的指令寄存器设置成可执行文件的入口地址,启动运行
进程结束后,将相关资源(进程地址空间、物理内存、打开文件、网络链接)都被操作系统关闭或收回
页错误:当执行到某个地址的指令时,发现页为空(未被装入)
段地址对齐
进程启动前,将系统环境变量和程序运行参数保存到进程的虚拟空间栈中
程序库部分会把堆栈里的初始化信息中的参数信息传递给main函数的argc、argv参数argc命令行参数数量、argv命令行参数字符串指针数组
六.动态链接
静态链接把所有程序模块都链接成一个单独的可执行文件,可能会带来这些问题:
- 对计算机内存和磁盘空间浪费严重,每个程序内部都保留着公用库函数
- 更新库函数时,程序需重新链接
动态链接:把程序按照模块拆分为各个相对独立的部分,运行时才将他们链接在一起,在运行和加载时,可以被加载到任意的内存地址,并和一个在内存中的程序链接起来。
共享对象会被多个程序调用,导致其在虚拟地址空间中的位置难以确定,所以共享对象需要在装载时重定位
但装载时重定位会导致无法在多个进程间共享,采用地址无关代码
将共享对象模块中的地址引用划分为模块内部引用和模块外部引用、指令引用和数据访问
- 对于模块内部的指令和数据引用,采用相对偏移调用的方法
- 对于模块外部的指令和数据引用,采用GOT全局偏移表间接访问
延迟绑定 Lazy Binding 当函数第一次被用到的时候才重定位,提供程序运行速度
动态链接器是一个特殊共享对象,不依赖与任何动态共享文件,且自己的重定位工作由自己完成
自举:不用到任何全局和静态变量,自己完成重定位工作
七.内存
内核空间(内核使用,应用程序无法访问) + 用户空间
用户空间:
-
栈 从高位向低地址增长,向下增长
i386中,esp寄存器指向栈顶,ebp寄存器指向了函数活动记录的一个固定位置
-堆栈帧 保存了一个函数调用所需要的维护信息
函数的返回地址和参数
临时变量 函数的非静态局部变量 编译器自动生成的其他临时变量
保存的上下文 在函数调用前后需要保持不变的寄存器
调用惯例:函数的调用方和被调用方对于函数如何调用的明确约定
栈上的数据在函数返回时会被释放掉,无法将数据传递至函数外部 -
堆 从低位向高地址增长(不总是向上增长)
程序可申请一段内存,释放申请的这个内存
堆上的内存管理由堆自己进行,若由操作系统进行,会频繁进行系统调用,性能开销较大
若一次性分给进程的空间不够,可能出现系统调用,再申请一部分空间
-
代码区:存放程序的二进制代码
-
常量区:所有常量
-
全局数据区:全局变量和静态数据
-
可执行文件映像:由装载器将可执行文件的内存读取或映射在这里
-
保留区:对内存中收到保护而禁止访问的内存区域总称
Debug模式中,将未初始化区域都初始化为0xCC,有助于判断一个变量是否没有初始化,0xCCCC被当做文本就是烫,0xCDCD是屯
HotPatch 可替换函数,实现Hook,允许用户在某些时刻截获特定函数的调用
八.运行库
入口函数:运行库的一部分,一个程序的初始化和结束部分,准备好了main函数执行所需要的环境,并且负责调用main函数,这样在main函数中才能:申请内存、使用系统调用、触发异常、访问I/O
程序运行步骤:
1.操作系统创建进程后,把控制权交给程序入口
2.入口函数对运行库和程序运行环境进行初始化,包括堆、I/O、线程、全局变量构造等
3.入口函数完成初始化后,调用main函数,进行程序主体部分
4.main函数执行完毕后,返回入口函数,入口函数进行清理工作,包括全局变量析构、堆销毁、关闭I/O,然后系统调用结束进程
环境变量:存在于系统中的一些公用数据,如系统搜索路径,当前OS版本
I/O 指代了程序与外界的交互,包括文件、管道、网络、命令行、信号等FILE
运行库:启动与退出、标准函数、I/O、堆、语言实现、调试
九.系统调用
系统调用:为了让应用程序 (运行库) 有能力访问系统资源,让程序借助操作系统做一些必须由操作系统支持的行为,操作系统提供的接口
现代CPU可在多种截然不同的特权级别下执行指令,分为用户模式、内核模式
接口的调用通过中断实现从用户模式到内核模式的切换
上下文:操作系统保持跟踪进程运行所需的所有状态信息,包括 PC 和寄存器文件的当前值,以及主存的内容。
在任何时刻,单处理器系统都只能执行一个进程的代码,当操作系统决定要把控制权从当前进程转移到某个新进程时,就会进行上下文切换,保存当前进程的上下文,恢复新进程的上下文,然后将控制权传递到新进程。
课后问题及答案
-
源代码是怎么变成可执行文件的,每一步的作用是什么?
(预编译,词法分析,语法分析,语义分析,中间语言生成目标代码生成,汇编,链接)
预编译:展开宏,删除注释,标注行号
词法分析:将代码解析成一个个记号
语法分析:生成语法树
语义分析:将语法树上的类型不符的插入相应结点,做隐式转换
中间代码生成汇编:生成汇编语言
链接:将源代码模块独立地编译,然后将其组装起来,将目标文件链接形成可执行文件 -
应用层、API、运行库、系统调用、操作系统内核之间的关系是什么?
应用层通过API调用运行库的接口,运行库通过系统调用调用操作系统内核 -
虚拟内存空间是什么,为什么要有虚拟内存空间?
虚拟内存空间使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间),而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。 -
静态链接和动态链接分别表示什么,大概是怎么实现的?
静态链接:把所有程序模块都链接成一个单独的可执行文件
动态链接:把程序按照模块拆分为各个相对独立的部分,运行时才将他们链接在一起 -
可执行文件的结构如何?分为哪些段?
文件头、代码段、数据段、bss段(未初始化的全局变量) -
进程的内存格局是怎样的?
堆、栈、全局/静态区,代码区,常量区 -
堆和栈的区别,函数调用和栈的关系
-
进程和线程的区别
-
异步和同步,串行,并发,并行的区别
- 异步 并不按顺序执行,开启新线程
- 同步 按顺序执行,不开启新线程
- 串行 串行是指多个任务,各个任务按顺序执行,完成一个之后才能进行下一个
- 并行 同一时刻的肯定有多个任务在执行
- 并发 同一时间间隔有多个任务在发生
-
多并发任务,仅多线程能加快速度么?
(不能,会变慢,有线程切换的开销) -
多个线程之间可以共享那些数据?
全局变量、堆上的数据、函数里的静态变量、程序代码打开的文件 -
进程之间如何通信管道?
在父子进程中单向的流动有名管道:可在无亲缘关系的进程中通信信号量:控制多个进程对共享资源的访问
消息队列:由消息的链表,存放在内核中并由消息队列标示
共享内存:映射一段能被其他进程所访问的内存
套接字:进程间通信机制 -
介绍几种锁,他们的用途和区别?
//todo
我们在使用多线程的时候多个线程可能会访问同一块资源,这样就很容易引发数据错乱和数据安全等问题,这时候就需要我们保证每次只有一个线程访问这一块资源,锁 应运而生。
网友评论