前言
经学长推荐最近在学习《程序员的自我修养》这本书,是清华大学出版社出版的作品。这本书的内容非常多,所以写篇读书笔记
我们将学到什么?–链接、装载与库
- WIN和Linux的操作系统下的可执行文件和目标文件格式
- C/C++程序代码如何被编译成目标文件和在其中的存储
- 目标文件的链接和执行、符号处理、重定向和地址分配
- 什么是动态链接,为何动态链接,WIN和Linux如何动态链接和动态链接的相关问题
- 可执行文件如何被装载并执行,与进程的虚拟空间之间如何映射
- 堆与栈
- 函数调用惯例,运行库,Glibc和MSVC CRT的实现分析
基础要求:x86汇编语言基础、C/C++、操作系统基本概念和基本编程技巧、计算机系统结构基本概念
1. 温故而知新
从Hello World说起
一个"Hello World"程序,带领无数人进入了程序的世界,而 简单的背后往往有很多复杂的机制,因此到了某些细节会
模糊
| |
带着这些思考进入之后的学习:
- 程序为什么要被编译器编译了之后才可以运行?
- 编译器在把C语言程序转换成可以执行的机器码的过程中做了什么, 怎么做的?
- 最后编译出来的可执行文件里面是什么? 除了机器码还有什么? 它们怎么存放的, 怎 么组织的?
- #include <stdio.h>是什么意思? 把stdio.h包含进来意味着什么?C语言库又是什么? 它 怎么实现的?
- 不同的编译器(Microsoft VC、GCC)和不同的硬件平台(x86、SPARC、MIPS、ARM), 以及不同的操作系统(Windows、Linux、UNIX、Solaris), 最终编译出来的结果一样 吗? 为什么?
- Hello World 程序是怎么运行起来的? 操作系统是怎么装载它的? 它从哪儿开始执行, 到哪儿结束? main 函数之前发生了什么?main 函数结束以后又发生了什么?
- 如果没有操作系统,Hello World 可以运行吗? 如果要在一台没有操作系统的机器上运 行 Hello World 需要什么? 应该怎么实现?
- printf是怎么实现的? 它为什么可以有不定数量的参数? 为什么它能够在终端上输出字 符串?
- Hello World 程序在运行时, 它在内存中是什么样子的?
软件、系统
软件
- 系统软件: 传统意义上指用于管理计算机本身的软件
- 可分为两类:
- 平台性: 如操作系统内核、驱动程序、运行库、数以千计的系统工具
- 程序开发: 编译器、汇编器、链接器等开发工具和开发库
- 可分为两类:
计算机系统软件体系结构采用一种层的结构, 有人说过一句名言:
“计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决”
“Any problem in computer science can be solved by another layer of indirection.”
计算机的整个体系结构从上到下都是按照严格的层次结构设计的,每个层次之间进行通信的协议就是接口(interface), 层次之间遵循这个接口的话任何一个层都能被修改或被替换。
除硬件和应用程序外,其他的都是中间层, 而每个中间层都是对下面层的包装和扩展,由此应用程序和硬件保持相对独立(兼容性)虚拟机则是在硬件和操作系统之间增加了一层虚拟层,由此一个计算机上可以同时运行多个操作系统
- 应用程序(最上层):网络浏览器、多媒体播放器、文本编辑器、游戏等
- 应用程序编程接口:开发工具和应用程序使用的接口
- 接口提供者:运行库——WIN32 API、Glibc
系统
操作系统: 管理硬件资源,使得硬件资源能充分发挥作用
- CPU:
计算机发展早期,CPU 资源十分昂贵, 如果一个CPU只能运行一个程序, 那么当程序读写磁盘(当时可能是磁带)时,CPU 就空闲下来了, 这在当时简直就是暴珍天物
为了协调CPU、内存和高速的图形设备, 人们专门设计了一个高速的北桥芯片, 以便它们之间能够高速地交换数据。而南桥是连接低频设备的, 最初CPU频率甚至和内存差不多
有了监控系统之后, 当某个程序暂时无须使用 CPU时, 监控程序就把另外的正在等待 CPU 资源的程序启动, 但是最大的问题是程序之间的调度策略太粗糙。对于多道程序来说, 程序之间不分轻重缓急, 如果有些程序急需使用 CPU 来完成一些任务(比如用户交互的任务), 那么很有可能很长时间后才有机会分配到CPU。
经过稍微改进, 程序运行模式变成了一种协作的模式, 即每个程序运行一段时间以后都主动让出 CPU 给其他程序, 使得一段时间内每个程序都有机会运行一小段时间。这对于一
些交互式的任务尤为重要, 比如点击一下鼠标或按下一个键盘按键后, 程序所要处理的任务
可能并不多, 但是它需要尽快地被处理, 使得用户能够立即看到效果。这种程序协作模式叫
做分时系统(Time-Sharing System)(但如果一个程序霸占了CPU就会类似while(1), 直接死循环。。)
程序员肯定不想天天跟硬件打交道(然而早期程序员又不得不这么做😂), 所以操作系统逐步发展,在成熟之后硬件被抽象成一系列概念(NIX 中, 硬件设备的访问形式跟访问普通的文件形式一样; 在 Windows系统中, 图形硬件被抽象成了 GDI, 声音和多媒体设备被抽象成了DirectX 对象; 磁盘被抽象成了普通文件系统, 等等)
文件系统: 文件系统保存了这些文件的存储结构, 负责维护这些数据结构并且保证磁盘中的扇区能 够有效地组织和利用。
内存: 一个重要问题:如何将计算机上有限的物理内存分配给多个程序使用。
假设我们的计算机有128 MB 内存, 程序A运行需要10MB, 程序B需要100MB, 很容易想到的一种解决方案是:A分配前10MB,B分配10~110MB,但是很明显这样有问题
- 内存利用率低,假设有个C程序需要20MB, 这里就需要将B先换出到磁盘, 然后再分配给C,有大量的数据换入换出了
- 地址空间不隔离: 直接访问物理地址, 恶意程序很容易直接改写进行破坏整个内存空间中的程序(
有联想到pwn中未开启ASLR吗)- 程序运行地址不确定, 每次程序装入分配的空间位置不确定 因此我们需要的是把程序给出的地址看作是一种
虚拟地址, 通过映射再进行转化,从而程序物理空间互不重叠。
关于隔离
地址空间分为两种:虚拟地址空间(Virtual Address Space)和物理地址空间(Physical Address Space)
物理地址空间是实实在在存在的, 存在于计算机中, 而且对于每台计算机只有唯一一个, 把物理空间想象成物理内存, 比如一台Intel的Pentium4的处理器,它是32位的机器, 物理空间有4GB,但是计算机上如果只装了512MB的内存,实际上物理地址的有效部分就是0x00000000 ~ 0x1FFFFFFF
虚拟地址空间则比较抽象,是指虚拟的、人们想象出来的地址空间, 而实际上并不存在,每个进程都有自己独立的虚拟空间, 而且每个进程只能访问自己的地址空间,由此有效地做到了进程隔离
每个进程有自己的虚拟地址空间
每个进程在操作系统中都会被分配一个独立的虚拟地址空间。这个虚拟空间对于每个进程来说是连续的,且相互之间不会干扰。例如:
- 进程 A 可能会把自己的虚拟地址空间分配在
0x00000000到0x7FFFFFFF之间。 - 进程 B 则会有一个完全不同的虚拟地址空间,比如
0x00000000到0x7FFFFFFF,虽然它和进程 A 的虚拟地址空间在地址范围上重叠,但操作系统确保它们彼此隔离,不会互相访问。
进程只能访问自己的虚拟空间 尽管所有进程的虚拟地址空间可能存在重叠,但操作系统通过内存管理单元(MMU)和页表来确保:
分段和分页
- 分段:把虚拟地址空间分成若干段,每段有独立的属性,如可读、可写、可执行等
我们可以将虚拟空间映射到屋里空间,操作系统中设置一个映射函数,然后实际地址由硬件完成- 由此我们便可以实现
1.地址空间隔离(超出范围判断访问出错)
- 物理地址空间不必继续关心(可以不连续),这使得程序不需再次重定位
- 由此我们便可以实现
但是如何解决内存利用效率问题?
- 分页:把虚拟地址空间分成若干页,操作系统决定页大小 Intel Pentium系列处理器支持4KB或4MB页大小,操作系统可以根据此进行选择 页会在我们需要的时候才从磁盘取出来使其真正有效可用
线程
什么是线程? 线程:有时被称为轻量级进程,是程序执行流的最小单元,可见下图
线程拥有自己的私有存储空间,一般包括- 栈(尽管并非完全无法被其他线程访问, 但一般情况下仍然可以认为是私有的数据)。
- 线程局部存储(Thread Local Storage TLS) 线程私有空间,但是很有限
- 寄存器
不过我们也可以从另一个角度来看,从程序员角度来看的话
线程私有 线程之间共享 局部变量 全局变量 函数参数 堆上数据 TLS数据 函数里的静态变量 程序代码 打开的文件,例如A打开的由B读写
关于线程调度和优先级的话,这里不多叙述,简单来讲线程调度分为三种状态:
- 就绪态:可以运行,但是没有被调度
- 运行态:正在运行
- 等待态:无法运行,例如等待I/O完成
主流的调度方式尽管各不相同,但都带有优先级调度和轮转法。
我们一般把频繁等待的
线程称之为IO密集型线程(IO Bound Thread),而把很少等待的线程称为 CPU 密集型线程
(CPU Bound Thread)
而在优先级调度环境下,线程优先级改变一般有三种:
- 用户指定优先级
- 根据进入状态频繁程度进行修改
- 长时间得不到执行会被提高优先级(防止饿死)
Linux的多线程
Windows 对进程和线程的实现如同教科书一般标准,Windows 内核有明确的线程和进 程的概念。在Windows API中, 可以使用明确的API: Create Process 和 Create Thread 来创建 进程和线程, 并且有一系列的API来操纵它们。但对于Linux 来说,线程并不是一个通用的 概念。
Linux将所有执行实体称之为任务(Task),不过Linux下不同任务可以选择共享内存空间。
我们来看看如何创建新任务
| 系统调用 | 作用 |
|---|---|
| fork | 复制当前进程 |
| exec | 使用新的可执行映像覆盖当前可执行映像 |
| clone | 创建子进程并从指定位置开始执行 |
线程安全
多线程程序处于一个多变的环境当中,可访问的全局变量和堆数据随时都可能被其他的 线程改变。因此多线程程序在并发时数据的一致性变得非常重要。
我们常使用锁来实现线程的访问同步。
锁是一种非强制机制, 每一个线程在访问数据或 资源之前首先试图获取(Acquire) 锁, 并在访问结束之后释放(Release)锁。在锁已经被 占用的时候试图获取锁时, 线程会等待, 直到锁重新可用。
静态链接
关于目标文件
程序运行四大过程:预处理、编译、汇编、链接
- 预处理:处理预编译指令(递归插入文件)删除注释,添加行号以及文件名标识
- 编译:进行一系列词法分析、语法分析等后优化生成的汇编代码文件
- 汇编:将汇编代码转化成机器可执行命令
- 链接:将文件链接起来获得最终的可执行文件 按需要组装源代码模块,是为 链接
- 地址和空间分配
- 符号决议
- 重定位等等步骤 下面是一个最基本的静态链接过程
链接的接口——符号
不难想到,目标文件之间的必须有固定的规则才能进行恰当的链接才行
如果我们要生成一个目标文件B, 并且用到A中的函数foo 则A 定义 foo。B 引用 foo。
我们将函数和变量统称为 符号(symbol),而函数名或变量名就是符号名(Symbol Name)。
不难想到每个文件中都应该有一个符号表的存在,并且有对应的值
| |
编译运行得到
符号修饰与函数签名
为了防止符号名和c库相关文件冲突,UNIX下的C语言规定,C语言源代码文件中所有全局变量、函数经过编译后,会在对应符号名前加上下划线_,比如foo->_foo
关于符号修饰,我们可以拿出经典的C++进行参考,C++拥有类、继承、虚机制、重载等特性,因此符号管理也会更复杂,就比如函数重载仅仅是参数不同就可以实现一个不同功能的同名函数
| |
看上面,有6个同名func函数,但返回类型以及参数的名称空间是不同的。
这里引入一个术语函数签名(Function Signature)
顾名思义,就是编译器以及链接器会给函数签名后一个修饰后的名称,在这种情况下,即使我们表面上给函数一个一样的名称,实际上对编译器来说还是不同的! (注意修饰后名称会根据不同的编译器厂商有不同的方法)
extern “C”(选学)
C++为了与C兼容,有一个用来声明、定义C的符号的extern "C"关键字用法,在其后面的符号都为修饰后符号
弱符号与强符号
编程中会在多个可能在多个目标文件中有相同名字全局符号的定义,那么在目标文件链接的时候会出现符号重复定义的错误,链接器就可能报错 ,多个目标文件中定义同名符号,导致出现重复定义错误,一般为 强符号
对于C/C++来说,编译器默认函数和初始化了的全局变量为强符号,未初始化的则为弱符号。也可以通过__attribute__((weak))来定义任何一个强符号为弱符号
weak和weak2是弱符号;strong和main是强符号,ext则非强非弱,因为是外部变量引用,下面是一些重要的规则- 强符号不可多次定义,会报错
- 若在一个文件中为强符号,而其他文件中都是弱符号,那么链接时当作强符号
- 在所有文件都是弱符号,则选择占用空间最大的一个。如A和B都有global,其中一个为int 4字节,一个为double 8字节。那么最后会选择8个字节的global 弱引用和强引用则根据会不会因为不存在而报错来进行判断,弱引用会被强引用覆盖,那么程序可以使用自定义版本的库函数(有点“虚”的概念在?)通过弱引用我们就有办法判断当前Linux程序线程是否指向单线程亦或是多线程
调试信息
在gcc编译的时候加上g参数,编译器就会在目标文件里加上调试信息(合格的程序员还是必须学会调试啊,拿上面的special.c举例吧
现在的ELF文件采用一个叫做DWARF(Debug With Arbitrary Record Format)的标准调试信息格式
详解静态链接
我们要将ab链接在一起,形成可执行文件ab,注意c99开始就不支持使用未声明的的函数了。所以还要写一行void swap(int *,int *);第一个问题:链接器如何合并段到输出文件?
自然想到一个简单的方法就是按照次序叠加
所以我们想到,诶那我们将性质类似的段合并不久简单多了?
.bss段在目标文件和可执行文件中不占用文件的空间,但是它在装载时占用地址空间。💀嗯?那.bss的空间分配到底是什么空间嗯。。。细读一下 链接器为目标文件分配地址和空间 这句话,地址和空间?
- 可执行文件的空间
- 装载后的虚拟地址的虚拟地址空间 事实上空间分配就是关注虚拟地址空间分配,这与可执行文件本身的空间分配关系几乎没有(一个是“需要的空间”,一个是运行所分配的使用空间,这样应该好理解)那么就可以引出二步链接法
- 空间与地址分配
- 符号解析与重定位(核心)
gcc a.o b.o -o ab进行链接
VMA就是虚拟地址,而LMA就是加载地址了,在部分嵌入式系统中两者才会不一样,一般就关注VMA
注意到!在链接后VMA才分配到了相应的虚拟地址
符号地址会根据.text段.data段等的地址来确认,比如main符号在a.o中是代码段最开始,那么在输出文件中其偏移也会是0
第二个问题:接下来如何重定位?
空间地址分好后,我们要想想符号解析与重定位的步骤
反汇编,canary不用看这是分析的过程,e8为操作码(近址相对位移调用指令, 在其后面会跟 调用指令的下一条指令 的偏移量)call这里的一系列地址都是一个临时的假地址,而在编译的时候无论是a还是main的地址实际上都不是真正知道的的,而这些工作就交给了链接器——确定所有符号在分配完地址空间后的虚拟地址,从而修正。那么我们来看看ab?
- ABI和API以及C++
- 为什么静态运行库中一个目标文件包含一个函数?-减少浪费
- 最小的程序


















