0%

从CSapp中收获的知识(持续更新)

从CSapp中收获的知识

CSapp是一本很好的书,它像极了一本“技术字典”,知识十分全面但又不深入,对学习计算机有着引领性的作用

我学习CSapp已经有一段时间了,有些章节可以调起我的兴趣,使我在网络上进行了知识扩充,但有些章节用大段大段的文字来劝退我,甚至还有一些章节玩起了“高数”,搞得我很懵逼

CSapp上的知识太杂,太广,我感觉好多知识我都没法利用上(或许以后用得上),我害怕搞忘了这些知识,于是我也做过章节总结,但是我发现这样做的效率并不高,所以我打算不以章节为单位进行总结,而是把知识拆分为小块,逐一记录


栈帧

程序执行流程

文字版本如下:

编译器驱动程序

编译器驱动程序的工作:调用语言预处理器,编译器,汇编器,链接器

详细过程:

  • 预处理阶段:预处理器(cpp)根据以字符 “#” 开头的命令,修改原始的 C 程序(比如 hello.c 中第 1 行的#include命令告诉预处理器读取系统头文件 stdio.h 的内容)并把它直接插入程序文本中,结果就得到了另一个 C 程序
    • 通常是以 .i 作为文件扩展名
    • 所谓的头文件,里面装的其实就是函数声明(libc库中的函数:scanf,printf 等)
  • 编译阶段:编译器(ccl)将文本文件 hello.i 翻译成文本文件 hello.s,它包含一个汇编语言程序
    • .s 文件其实就是装有汇编语言的文件
  • 汇编阶段:汇编器(as)将 hello.s 翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序(relocatable object program)的格式,并将结果保存在目标文件 hello.o 中
    • hello.o 文件是一个二进制文件,它包含的 17 个字节是函数 main 的指令编码
    • 如果我们在文本编辑器中打开 hello.o文件,将看到一堆乱码(二进制)
  • 链接阶段:链接器(ld)就负责处理合并各个 hello.o 文件,结果就得到 hello 文件,它是一个可执行目标文件(或者简称为可执行文件),可以被加载到内存中,由系统执行
    • 请注意,hello 程序调用了 printf 函数,它是每个 C 编译器都提供的标准 C 库中的一个函数
    • printf 函数存在于一个名为 printf.o 的单独的预编译好了的目标文件中,而这个文件必须以某种方式合并到我们的 hello.o 程序中
    • 通过修改 hello.o 可以影响最终文件 hello 的效果

抽象

抽象是从众多的事物中抽取出共同的、本质性的特征

指令集架构是对 实际处理器硬件 的抽象

文件是对 I/O设备 的抽象

虚拟内存是对 程序存储器 的抽象

虚拟机是对 整个计算机 的抽象

缓存

缓存(cache),原始意义是指访问速度比一般随机存取存储器(RAM)快的一种高速存储器,通常它不像系统主存那样使用 [DRAM]技术 ,而使用昂贵但较快速的 [SRAM]技术 ,缓存的设置是所有现代计算机系统发挥高性能的重要因素之一

缓存的工作原理是当CPU要读取一个数据时,首先从CPU缓存中查找,找到就立即读取并送给CPU处理,没有找到,就从速率相对较慢的内存中读取并送给CPU处理

缓存命中

但程序需要在第 n 层中查找数据时:它会首先在第 n-1 层中查找, 如果数据刚好就在第 n-1 层中,就直接使用第 n-1 层中的数据,称为缓存命中

从第 n-1 层中读取,要比从第 n 层中读取更快

缓存不命中

另一方面,如果程序没有在第 n-1 层中查找到数据,那么它便会在第 n 层中查找,称为缓存不命中,同时会把第 n 层的数据写入第 n-1

内存阶层

速度快的存储器往往容量小,容量大的储存器往往速度慢

所以综合存储器的优劣,内存阶层的机制出现了

核心思想为:上一层(更快更小)为下一层(更大更慢)的缓存

内存阶层是在电脑架构下储存系统阶层的排列顺序,每一层于下一层相比都拥有较高的速度和较低延迟性,以及较小的容量(也有少量例外)

类型 位于哪里 存储容量 访问时间
CPU寄存器 位于CPU执行单元中 CPU寄存器通常只有几个到几十个,每个寄存器的容量取决于CPU的字长,所以一共只有几十到几百字节 寄存器是访问速度最快的存储器,典型的访问时间是几纳秒
Cache 和MMU一样位于CPU核中 Cache通常分为几级,最典型的是如上图所示的两级Cache,一级Cache更靠近CPU执行单元,二级Cache更靠近物理内存,通常一级Cache有几十到几百KB,二级Cache有几百KB到几MB 典型的访问时间是几十纳秒
内存 位于CPU外的芯片,与CPU通过地址和数据总线相连 典型的存储容量是几百MB到几GB 典型的访问时间是几百纳秒
硬盘 位于设备总线上,并不直接和CPU相连,CPU通过设备总线的控制器访问硬盘 典型的存储容量是几百GB 典型的访问时间是几毫秒,是寄存器的“10的6次方”倍

管理单元

计算机常用“扇区”,“簇”,“块”,“页”等概念,这些都是管理单元

扇区:(Sector)

扇区,顾名思义,每个磁盘有多条同心圆似的磁道,磁道被分割成多个部分,每部分的弧长加上到圆心的两个半径,恰好形成一个扇形,所以叫做扇区

扇区是磁盘中最小的物理存储单位(每个扇区的大小是512字节,通常4096字节)

块:(Block)

块是操作系统中最小的逻辑存储单元(例如内存块的基本组成单元:chunk)

簇:

簇是微软操作系统(DOS、WINDOWS等)中磁盘文件存储管理的最小单位

块和簇的关系:

在Windows下如NTFS等文件系统中叫做簇

在Linux下如Ext4等文件系统中叫做块

每个簇或者块可以包括2、4、8、16、32、64… “2的n次方” 个扇区

页:(page)

页是内存的最小存储单位,页的大小通常为磁盘块大小的 “2的n次方” 倍,是内存操作的基本单位

操作系统

操作系统有两目的:

1.防止硬件被失控的应用滥用

2.向应用程序提供简单一致的机制来控制复杂的低级硬件

操作系统通过几个基本的抽象概念来实现这两个功能:

1.文件是对 I/O设备 的抽象

2.虚拟内存是对 程序存储器 的抽象

3.进程是对 处理器,内存,I/O设备 的抽象

程序计数器(PC)

程序计数器(PC)是CPU控制部件中的一种,用于存放指令的地址

程序计数器是一个概念上的说法,不同的机型把不同的存储器当成程序计数器

  • 8086:IP寄存器
  • i386:EIP寄存器
  • amd64:RIP寄存器

上下文切换

内核可以决定抢占当前进程,并重新开始一个被抢占的进程,这种决策叫做调度,是由内核中被称为“调度器”的代码处理的

上下文切换机制用于:保存“被调度进程”的数据

进程

当程序在系统上运行时,操作系统会提供一种 假象 ,就好像系统上只有这个程序在运行一样,程序看上去是在 独占 处理器(该程序是系统资源中唯一的对象)

这些 假象 是通过 进程 这个概念来实现的

​ //在系统中,各种程序交替执行,而 进程 把不同功能的程序 区分 开来

在单处理器系统中:

程序想要“并发处理多个进程”时,必须要先保存当前进程的 上下文 ,然后创建新进程的 上下文

比如“hello”执行的过程就涉及到两个进程:1.shell 2.hello

系统中的每个程序都运行在某个进程的上下文中,上下文就是程序正确运行所需要的状态(包括:存放在内存中的代码和数据,栈和通用寄存器中的内容,程序计数器,环境变量,打开文件描述符的集合)

进程将会提供给应用程序两个关键的抽象:

  • 逻辑控制流
  • 私有地址空间

逻辑控制流

实际上:系统中的进程是交错运行的,每个进程执行它流的一部分,然后被抢占(暂时挂起),接着轮到其他进程

进程提供的假象:好像每个程序都在单独地使用处理器

这种假象就是:逻辑控制流,它仿佛可以控制程序的逻辑行为,一步一步的流向,可以使用流程图来表现这种行为的流动,方便了调试人员对程序执行流程的把控

并发流

系统中的逻辑流有多种不同的形式:异常处理,进程,信号处理,线程,java进程……

并发流:在时间上,一个逻辑流和另一个逻辑流冲突,这两个流并发运行

并发:多条流在同时执行的一般现象

多任务(时间分片):多个进程流量执行的现象

私有地址空间

私有地址空间也是进程提供的假象,好像程序在单独地使用系统地址空间一样

​ // 私有地址空间是虚拟内存的子集

每个进程都会为所对应的程序提供一份“私有地址空间”,每个“私有地址空间”完全一致,并且只能被所对应的程序访问

进程控制

Unix提供了大量从C程序中操作进程的系统调用

僵死进程

当一个进程因为某种原因终止后,内核并不会马上把它清除,而是等待它的父进程把它回收

一个终止了但是还没有被回收的进程被称为僵死进程

如果一个进程的父进程终止了,内核会安排“init进程”成为它的“养父”(代替父进程回收子进程)

​ // “init进程”的PID为“1”,“调度进程”(系统进程)的PID为“0”

PID算法

PID,就是“比例(proportional)、积分(integral)、微分(derivative)”

PID控制应该算是应用非常广泛的控制算法,小到控制一个元件的温度,大到控制无人机的飞行姿态和飞行速度等等,都可以使用PID控制。这里我们从原理上来理解PID控制

线程

线程(英语:thread)是操作系统能够进行运算调度的最小单位

线程被包含在进程之中,是进程中的实际运作单位

​ // 多线程多进程 跟高效

虚拟内存

虚拟内存为每个进程提供了一个假象

就好像每个进程在单独占用内存空间一样,每个进程中看到的内存是一样的(虚拟地址空间)

即:该进程是系统资源中唯一的对象

物理内存是不连续的,但是操作系统完成了“使内存看起来是连续的”这样一份抽象,创造了 虚拟内存 ,并且为虚拟内存中的各个空间进行了编号

  • 虚拟内存用户空间每个进程各一份
  • 虚拟内存内核空间所有进程共享一份
  • 虚拟内存 mmap 段中的动态链接库仅在物理内存中装载一份

虚拟内存的作用

硬件只能识别“物理地址”,而“物理地址”是不连续的并且不方便显示,这就给调试程序带来了巨大的影响

所以操作系统抽象出了“虚拟内存”,把硬件中的数据映射到“虚拟内存”中,方便了调试

虚拟内存作为缓存工具

VM系统(虚拟程序系统)将虚拟内存分割成块,这些块被称为虚拟页

每个虚拟页的大小为“P = 2的p次方”字节,类似地,物理内存被分割为物理页,大小也为P字节(物理页也被称为“页帧”)

在任意时刻,虚拟页面的集合都分为3个不相交的子集:

  • 未分配的:VM系统还未分配的页,没有数据和它们相关,不占用磁盘空间
  • 缓存的:当前已缓存在物理内存中的已分配页
  • 未缓存的:当前未缓存在物理内存中的已分配页

Proc文件系统

Proc文件系统是一个伪文件系统,它只存在于内存中(不占用外存空间),允许“用户模式进程”访问“内核数据结构”的内容

Proc文件系统将许多“内核数据结构”的内容输出为一个用户程序可以读的“文本文件”

1
$ /proc/cpuinfo				# 输出CPU属性

Unix I/O

一个 Linux 文件就是一个 m 个字节的序列,所有的 I/O 设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行

这种将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单、低级的应用接口,称为 Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行:

  • 打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个 I/O 设备,内核返回一个小的非负整数,叫做 描述符 ,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息,应用程序只需记住这个描述符
  • 改变当前的文件位置:对于 每个打开的文件 ,内核保持着一个 文件位置(符号为 k),初始为 0,这个文件位置是 从文件开头起始的字节偏移量(相当于“指向”内存中文件末尾的一个标记,也可以用来记录该文件在内存中的大小),应用程序能够通过执行 seek 操作,显式地设置文件的当前位置为 k
  • 读写文件:一个读操作就是 从文件复制 n > 0 个字节到内存(从当前文件位置 k 开始,然后将 k 增加到 k+n) ,假设给定一个大小为 m 字节的文件,当 k>=m 时(文件被完全读入内存)会触发一个称为 end-of-file(EOF)的条件,应用程序能检测到这个条件,在文件结尾处并没有明确的“EOF符号”,类似地,写操作就是 从内存复制 n > 0 个字节到一个文件)从当前文件位置 k 开始,然后更新 k)
  • 关闭文件:当应用完成了对文件的访问之后,它就通知内核关闭这个文件,作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中,无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源

Linux shell 创建的每个进程开始时都有三个打开的文件:标准输入(描述符为 0)、标准输出(描述符为 1)和 标准错误(描述符为 2),头文件 定义了常量 STDIN_FILENO、STDOUT_FILENO 和 STDERR_FILENO,它们可用来代替显式的描述符值

文件

文件就是 字节序列

系统中所有的输入输出都是通过使用 Unix I/O系统函数 来实现的,文件向应用程序提供了一个 统一的视图 ,来看待系统中可能含有的各种形式的 I/O设备,在linux操作系统中有一个思想“一切皆文件”,即使是内存中的进程都可以被当成文件输出

每个 Linux 文件都有一个 类型(type)来表明它在系统中的角色:

  • 普通文件(regular file):包含任意数据,应用程序常常要区分 文本文件二进制文件 ,文本文件是只含有 ASCII 或 Unicode 字符的普通文件,二进制文件是所有其他的文件
    • 对内核而言,文本文件和二进制文件没有区别
    • Linux 文本文件包含了一个 文本行(text line)序列,其中每一行都是一个字符序列,以一个新行符(“\n”)结束
    • 新行符与 ASCII 的换行符(LF)是一样的,其数字值为 0x0a
  • 目录(directory):是包含一组 链接 的文件,其中每个链接都将一个 文件名 映射到一个文件,这个文件可能是另一个目录
    • 每个目录至少含有两个条目:是到该目录自身的链接,以及是到目录层次结构中 父目录 的链接
    • 你可以用 mkdir 命令创建一个目录,用 Is 查看其内容,用 rmdir 删除该目录
  • 套接字(socket):是用来与另一个进程进行跨网络通信的文件

其他文件类型包含 命名通道 (named pipe)、 符号链接 (symbolic link),以及 字符和块设备 (character and block device)

RIO-健壮地读写

我们会讲述一个 I/O 包,称为 RIO(Robust I/O,健壮的 I/O)包,它会自动为你处理上文中所述的不足值,在像网络程序这样容易出现不足值的应用中,RIO 包提供了方便、健壮和高效的 I/O

RIO 提供了两类不同的函数:

  • 无缓冲的输入输出函数:这些函数直接在内存和文件之间传送数据,没有应用级缓冲,它们对将二进制数据读写到网络和从网络读写二进制数据尤其有用
  • 带缓冲的输入函数:这些函数允许你高效地从文件中读取文本行和二进制数据,这些文件的内容缓存在应用级缓冲区内,类似于为 printf 这样的标准 I/O 函数提供的缓冲区
    • 与【110】中讲述的带缓冲的 I/O 例程不同,带缓冲的 RIO 输入函数是线程安全的,它在同一个描述符上可以被交错地调用
    • 例如,你可以从一个描述符中读一些文本行,然后读取一些二进制数据,接着再多读取一些文本行

共享文件

可以用许多不同的方式来共享 Linux 文件,内核用三个相关的数据结构来表示打开的文件:

  • 描述符表(descriptor table):每个进程都有它独立的描述符表,它的表项是由进程打开的文件描述符来索引的。每个打开的描述符表项指向文件表中的一个表项
  • 文件表(file table):打开文件的集合是由一张文件表来表示的,所有的进程共享这张表,每个文件表的表项组成:
    • 包括当前的 文件位置引用计数(reference count)(即当前指向该表项的描述符表项数),以及一个 指向 v-node 表 中对应表项的指针
    • 关闭一个描述符会减少相应的文件表表项中的引用计数
    • 内核不会删除这个文件表表项,直到它的引用计数为零
  • v-node 表(v-node table):同文件表一样,所有的进程共享这张 v-node 表,每个表项包含 stat 结构中的大多数信息,包括 st_mode 和 st_size 成员

网络

系统并不是由 孤立的 硬件和软件组成的结合体,而是可以通过 网络 相互通信

对于一个单独的系统而言,网络可以认为是一个I/O设备(输入或输出数据到本机)

并发和并行

计算机的发展始终围绕两个目的:1.更多 2.更快

并发:指一个同时具有多活动的系统

并行:指使用 并发 来使一个系统运行得更快

并行可以在计算机系统的多个 抽象层次 中运用,其中最主要的有3个:

1.线程级并发

构建在进程这个抽象之上,我们可以设计出有多个 “程序执行工具” 的系统,这导致了 并行

这个“程序执行工具”就是线程,使用线程,我们甚至可以在一个进程中执行多个 控制流

2.指令级并行

构建在较低的抽象层次上,使处理器可以同时执行多条指令

3.单指令,多数据并行

构建在较最的抽象层次上,允许一条指令可以产生多个“并行执行”的操作

​ //即SIMD并行

指令集架构

指令集架构(InstructionSetArchitecture)

描述:指的是CPU机器码所使用的指令的集合以及其背后的寄存器体系、总线设计等逻辑框架 ,也是规定的一系列CPU与其他硬件电路相配合的指令系统

作用:定义 机器级程序 的格式和行为,它定义了 处理器状态指令的格式 ,以及 每条指令的影响

行为:每条指令顺序执行,一条指令完成后,另一条指令开始

​ //不同进程中的指令会被CPU并发处理,但是就一个进程而言,它的指令是“顺序执行”

字节序

大端序:符合人类的认知(低地址存低位,高地址存高位)

小端序:利用机器的运算(低地址存高位,高地址存低位)

为了方便阅读程序,我们通常把低地址写在上面,把高地址写在下面

​ // 这样小端序就“符合人类的认知”

不同的系统采用不同的字节序

1
2
3
4
5
6
7
8
9
void test_show_bytes(int val)
{
int ival = val;
float fval = (float) ival;
int *pval = &ival;
show_int(ival);
show_fload(fval);
show_pointer(pval);
}

布尔代数

将逻辑值True和False用二进制值“1”和“0”表示,设计出了一种代数

基础逻辑运算:“ ~ ”(非),“ & ”(或),“ | ”(与),“ ^ ”(异或)

过程

过程是软件中一种很重要的抽象,它提供了一种 封装 代码的方式,用一组 指定的参数 和一个 可选择的返回值 实现了某种功能,而程序可以在任何一个位置引用这种功能

设计良好的软件用 过程 作为抽象机制,隐藏某个行为的具体表现,同时又提供清晰简洁的 接口定义 ,说明需要计算的是哪些值,过程会对程序状态产生什么影响

过程在不同的编译语言中有不同的名称:函数(Function),方法(method),子例程,处理函数

嵌套数组&变长数组

嵌套数组:(多维数组)

1
2
3
4
int A[5][3]
------------------------
typedef int row3_[3];
row3_t A[5];

两者是等价的

变长数组:

变长数组既数组大小待定的数组, C语言中结构体的最后一个元素可以是大小未知的数组,也就是所谓的0长度,所以我们可以用结构体来创建变长数组

1
2
3
4
typedef struct {
int len;
int array[];
}SoftArray;

异质的数据结构

c语言提供了两种不同类型的对象组合到一起创建数据类型的机制:

1.结构(structure) 2.联合(union)

structure可以将多个对象集合到一个单位中

union可以用几种不同的类型来引用一个对象

缓存区保护

1.栈随机化

使栈在每一次加载时都会发生变化

对抗:空操作雪橇,在shellcode前面插入相等长度的“nop”,只要有一个地址命中“nop”就行

​ //汇编指令“nop”让IP指针+1,没有其他作用

2.栈破坏机制

Canary(金丝雀)是系统生成的一个随机数,如果它被破坏,系统就会强行终止程序

3.限制可执行权限

伪指令

以“ . ”开头的指令就是 伪指令 ,它们告诉汇编器调整地址,以便在那里产生代码或插入一些数据

例如:伪指令“.pos 0”告诉汇编器应该从地址“0”开始产生代码

控制语言(HCL)

大多数现代的电路技术都是用信号线上的高低电压来表示不同的值

​ //逻辑1用1.0伏特的高电压,逻辑0用0.0伏特的低电压

为了方便编程,硬件描述语言HDL(Hardware Description Language)诞生了

HDL是一种文本表示,和编程语言类似,但是它是用来描述 硬件结构 而不是 程序行为

HCL语言只表达硬件设计的控制逻辑部分,只有有限的操作集合(控制逻辑是处理器中最困难的)

逻辑门

逻辑门是数字电路的基本计算单元,它们的产生和输出,等于它们输入位数的某个布尔函数

组合电路

将很多的逻辑门组合成一个网,就可以构建出一个计算块(computational block),称为组合电路

优化编译器的能力

这两个函数有相同的功能,但CPU读写效率却有不同:

第一个函数需要:2次读 xp,2次读 yp,2次写 *xp

第二个函数需要:1次读 xp,1次读 yp,1次写 *xp

程序性能的表示

我们引入度量标准:每元素的周期数(Cycles Per Element),作为一种表示程序性能并指引我们改进代码的方法

处理器活动的顺序是由时钟控制的,时钟提供了某个频率的规律信号,通常用千兆赫兹,十亿周期每秒来表示

例如:一个系统有“4GHz”处理器,表示这个系统的处理器时钟运行频率为4 * 10的9次方 /每秒

存储器系统

存储器系统是一个具有不同容量,成本,访问时间的存储设备层次结构

靠近CPU的,小的,快速的,称为高速缓存存储器

这个思想围绕着计算机程序的一种称为 局部性 的基本属性,具有良好局部性的程序倾向于一次又一次地访问相同的 数据项集合 ,或是倾向访问邻近的 数据项集合 ,并且更多的倾向于从存储器层次结构中较高层次处访问 数据项集合 ,这些操作都可以使程序运行得更快

局部性

简述

原指处理器在访问某些数据时短时间内存在重复访问,某些数据或者位置访问的 概率极大 ,大多数时间只访问局部的数据(随着优化技术的提升,局部性的概念也得到了扩充)

其实就是概率的不均等,这个宇宙中,很多东西都不是平均分布的,平均分布是概率论中几何分布的一种特殊形式,非常简单,但世界就是没这么简单。我们更长听到的发布叫做高斯发布,同时也被称为正态分布,因为它就是正常状态下的概率发布,起概率图如下 :

时间局部性(Temporal locality):

如果某个信息这次被访问,那它有可能在不久的未来被多次访问

时间局部性是空间局部性访问地址一样时的一种特殊情况,这种情况下,可以把常用的数据加cache(缓存)来优化访存

空间局部性(Spatial locality):

如果某个位置的信息被访问,那和它相邻的信息也很有可能被访问到

这个也很好理解,我们大部分情况下代码都是顺序执行,数据也是顺序访问的

内存局部性(Memory locality):

访问内存时,大概率会访问连续的块,而不是单一的内存地址,其实就是 空间局部性在内存上 的体现

目前计算机设计中,都是以块/页为单位管理调度存储,其实就是在利用空间局部性来优化性能

分支局部性(Branch locality)

这个又被称为顺序局部性,计算机中大部分指令是顺序执行,即便有if这种选择分支,其实大多数情况下某个分支都是被大概率选中的

于是就有了CPU的分支预测优化,设计CPU优先选择 概率较大 的if分支

等距局部性(Equidistant locality)

等距局部性是指如果某个位置被访问,那和它 相邻等距离的连续地址 极有可能会被访问到,它位于空间局部性和分支局部性之间

举个例子,比如多个相同格式的数据数组,你只取其中每个数据的一部分字段,那么他们可能在内存中地址距离是等距的,这个可以通过简单的线性预测就预测是未来访问的位置

步长对局部性的影响

步长:连续序列号的差值

两个函数只是交互了循环的次序,但程序的性能却截然不同

第一个程序:步长为“4”(int类型) [ 4 - 0 ]

第二个程序:步长为“12”(int类型 * 3)[ 12 - 0 ]

第一个程序的性能远高于第二个程序(步长越长,性能越差)

存储技术

静态RAM(SRAM)

SRAM将每个存储在一个 双稳态 的存储器单元中(每个单元通常是用一个 6晶体管电路 来实现的)

这个电路有这样一个属性:它可以无限期地保持 “两个不同的电压配置” 中的一个

这个双稳态的存储器单元就像图中的钟摆一样,除了左右稳态以外的任何区域都是不稳定的

只要有电,它就会永远保持它的值,即使有外界干扰也会马上回复

动态RAM(DRAM)

DRAM将每个存储为一个 电容的充电(每个单元由一个 电容 和一个 访问晶体管 组成)

和SRAM有着较强的稳定性不同,DRAM对干扰非常敏感,电容电压被扰乱以后就不会恢复了

DRAM芯片被封装在内存模块中

内存模块 (Memory Module)是指一个 印刷电路板 表面上有镶嵌数个 记忆体芯片chips(碎片),而这 内存芯片 通常是 DRAM芯片

增强型DRAM

快页模式DRAM(FPM DRAM)

扩展数据DRAM(EDO DRAM)

同步DRAM(SDRAM)

双倍数据速率同步DRAM(DDR SDRAM)

视频RAM(VRAM)

只读存储器ROM(ROM是一种非易失性存储器)

如果计算机突然断电,DRAM和SRAM都会失去它们的数据,所以它们是易失的

在失去电源的情况下,数据也不会丢失的存储器就是非易失性存储器

最开始的ROM是真的“只读”的,但是随着技术的发展,ROM也慢慢“可读”了起来

可编程ROM(PROM)

PROM只能每编程一次(PROM的每个存储器单元都有一种熔丝(fuse),只能被高温熔断一次)

可擦写可编程ROM(EPROM)

EPROM有一个透明的石英窗口,允许光线到达存储单元,当紫外线射过窗口时,EPROM单元会被清除为“0”,对EPROM的编程需要通过特殊设备(把“1”写入EPROM),EPROM总共可以擦除重编程的次数为:10的3次方

电子可擦除PROM(EEPROM)

EEPROM和EPROM类似,只不过不用特殊设备就可以对它进行编程,可以直接在印制电路卡上进行编程,EEPROM总共可以被编程的次数为:10的5次方

闪存(flash memory)

闪存基于EEPROM,是一种重要的存储技术(U盘就是利用Flash实现的)

与前面的PROM,EPROM,EEPROM以 为单位不同,Flash以 为单位(128kb,256kb)

Flash的基本构成单位为浮栅场效应管(这是一个三端器件,分别为:源极,漏极,栅极)

磁盘

磁盘是用于大量存储数据的存储设备,存储数据的数量级可以达到几千千兆字节,但从磁盘上读取数据的时间为毫秒级,比DRAM慢了10万倍,比SRAM慢了100万倍

磁盘由多个重叠在一起的盘片组成,它们被封装在一个密封的包装中

内存结构

在内存调试时,经常需要用到rank、bank等参数,可以看到电脑的内存条中,有很多一片一片的芯片,这就是内存芯片,也是叫内存颗粒

内存的基本单元称为 cell ,cell按行( row )、列( column )分布组成一个 bank

一颗内存颗粒由多个 bank 组成,现在的内存颗粒一般是8个bank,每个bank对于内存来说是平等的关系,因为内存控制器的原因,每个时钟周期只能对一个bank进行操作

所有在内存容量关系中:颗粒 > bank > rowcolumn > cell

IO桥接器

数据通过总线进行流通,每次CPU和主存之间的数据传输都是通过一系列步骤完成的,这些步骤被称为总线事务

读事务:把数据从主存传输到CPU

写事务:把数据从CPU传输到主存

而现代计算机中IO是通过 共享一条总线 的方式来实现的,这就是 IO总线(I/O bus)

IO桥接器则是一组芯片组(其中包括内存控制器)

CPU芯片,IO桥接器,DRAM内存模块

系统总线(system bus)连接了CPU和IO桥接器

内存总线(memory bus)连接IO桥接器和主存

系统总线和内存总线都通过IO桥接器连接到IO总线上,这就实现了各个硬件之间的交互

在这个过程中:CPU上被称为总线接口的电路会在总线上发起“读事务”

​ //交换“A”和“%rax”位置,则会发起“写事务”

CPU会把A放到系统总线上,IO桥接器将信号传递到内存总线,传输到主存

接下来主存会识别内存总线上的地址信号,从内存总线中读取地址,从DRAM中取出数据字,并将数据写入内存总线通过IO桥又写入系统总线,传输到CPU

最后CPU会读取系统总线上的数据,赋值给寄存器rax

链接器

链接(linking)是将 各种代码数据片段 收集并组合成一个单一文件的过程

负责链接工作的程序被称为“链接器”

链接器的出现使我们不用将一个应用组织为一个庞大的文件,而是可以分割为更小的模块

静态链接

静态链接的链接器必须完成两个任务:符号解析,重定位

符号解析:解析目标文件的符号,把 符号引用符号定义 关联起来

重定位:通过把 符号定义内存位置 关联起来,来重定位这些节片段,然后修改对这些符号的引用,使它们指向对应的内存位置

动态链接

在创建可执行文件时,静态执行一些链接

在程序加载时,动态完成链接

动态链接器通过执行下面的重定位来完成链接任务:

  • 重定位 libc.so 的文本和数据到某个内存段
  • 重定位 libvector.so 的文本和数据到另一个内存段
  • 重定位 prog21 中所有的 “由 libc.so 和 libvector.so(存档文件) 定义的” 符号的引用

存档文件

存档文件(libvector.so)是一种文件格式,用于存储一组文件以及与这些文件有关的信息(元数据),创建存档文件的目的是将多个文件存储在一起,通常采用压缩格式,这样可以提高可移植性,同时节省磁盘上的存储空间

目标文件

目标文件分为3种:

1.可重定位目标文件:包含二进制代码和数据,可以在编译时与其他可重定位目标文件合并,创建一个可执行目标文件

2.可执行目标文件:包含二进制代码和数据,可以直接放入内存执行(就是可执行文件)

3.共享目标文件:一种特殊的可重定位目标文件,可以在加载或者运行时被 动态 地加载进内存并进行连接

编译器和汇编器生成:可重定位目标文件

链接器生成:可执行目标文件

各个系统目标文件的格式不同:Unix系统,采用a.out格式;Windows系统,采用PE格式;MacOS系统,采用Mach-O格式

可重定位目标文件

这个就是可重定位目标文件的格式,ELF头描述了产生该文件的系统的 字的大小字节序

ELF头剩下的部分包含:帮助链接器进行 语法分析解释目标文件 的信息

不同节的位置大小是由 “节头部” 决定的,其中目标文件的每个节都有一个固定大小的条目(entry),夹在“ELF头”和“节头部表”之间的都是节

一个经典的ELF可重定位目标文件包含下面几个节:

.text:已经编译的程序机器代码

.rodata:只读数据

.data:已经初始化的 全局变量 和 静态变量(局部变量保存在栈中)

.bss:未初始化的 全局变量 和 静态变量(被初始化为“0”的变量也会保存在这里)

.symtab:符号表,用于存放程序中定义或引用的 “函数和全局变量” 的信息

.rel.text:“.text节”的重定位信息,用于重新修改代码段的指令中的地址信息

.debug:调试符号表

.line:原始C源程序中的行号 和 “.text节”中机器指令之间的映射

.strtab:字符串表(包含“.symtab”和“.debug”中的符号名和节名)

符号及其相关

符号(symbol)

函数名,变量名,数组名,结构体名都可以称之为符号

在链接器的上下文中一共有3中不同的符号:

  • Global symbols(模块内部定义的全局符号)
    • 由模块m定义并能被其他模块引用的符号(非static函数,非static全局变量)
  • External symbols(外部定义的全局符号)
    • 由其他模块定义并被模块m引用的全局符号
  • Local symbols(本模块的局部符号)
    • 仅由模块m定义和引用的本地符号(带有static的函数和全局变量)
    • 注意:局部变量不会在过程外被引用(分配在栈中),因此不是符号定义

符号表(symtab)

符号表是由汇编器构造的,包含一个条目的数组,每个条目都是一个结构体

1
2
3
4
5
6
7
8
9
typedef struct {
Elf32_Word st_name; /* 符号对应字符串在strtab节中的偏移量 */
Elf32_Word st_value; /* 在对应节中的偏移量,可执行文件中是虚拟地址 */
Elf32_Word st_size; /* 符号对应目标所占字节数 */
unsigned char type: 4, /* 符号对应目标的类型:数据、函数、源文件、节 */
binding: 4; /* 符号类别:全局符号、局部符号、弱符号 */
unsigned char st_other;
Elf32_Section st_shndx; /* 符号对应目标所在的节,或其他情况 */
}Elf32_Sym;
1
2
3
4
5
6
7
8
9
typedef struct  
{
Elf64_Word st_name; /* Symbol name (string tbl index) */
unsigned char st_info; /* Symbol type and binding */
// 高4字节为type,低4字节为binding
unsigned char st_other; /* Symbol visibility */
Elf64_Addr st_value; /* Symbol value */
Elf64_Xword st_size; /* Symbol size */
}Elf64_Sym;

st_name:对应字符串表的偏移

st_value:距离目标符号地址的偏移

st_size:目标的大小

st_info:高字节为type,低字节为binding

type:表示符号类型(要么是数据,要么是函数)

binding:表示符号是本地的,还是全局的

符号和节

每个符号都会被分配到目标文件的某个节,由 section 字段表示,改字段也是一个到节头部表的索引,但是存在3个 伪节 ,它们在节头部表中是没有条目的

ABS:代表不应该被重定位的符号

UNDEF:代表未定义的符号,就是在本目标模块中被引用,但是却在其他地方定义的符号

COMMON:代表未被分配位置的未初始化数据的目录

​ // 只有在可重定位目标文件中才有这些伪节,可执行目标文件是没有的

COMMON和bss节的区别很细微:

符号解析

链接器从左往右按照它们在编译器驱动程序命令行上出现的顺序,来扫描可重定位目标文件和存档文件,在这次扫描中,链接器会维护:

一个 可重定位目标文件的集合E (将会被合并为可执行目标文件)

一个 未解析符号集合U (引用了但是未定义)

一个 在前面输入文件中已经定义的符号集合D(自己定义的符号也会被装入)

符号解析的工作流程如下:

  1. 在命令行上输入文件F,链接器会判断文件F是一个目标文件,还是一个存档文件
  2. 如果是目标文件,则添加到E,并且修改U和D来反应F中的 符号定义引用
  3. 如果F是一个存档文件,那么链接会尝试匹配 未解析符号集合U存档文件成员定义的符号 ,假设存档文件中有个成员 m,定义了一个符号来解析U中的一个引用,那么就将 m 添加到E中,并且链接器修改U和D来反映 m 中的 符号定义引用

扫描完成以后,集合U非空,链接器就会输出一个错误并终止,以表明有符号未定义

扫描完成以后,集合U为空,那么链接器就会合并集合E中的目标文件,构建可执行文件

​ // 当D被新填入时,对应的U会减少

符号变量

自动变量(动态局部变量):auto

  • 离开函数,值就消失
  • 不写 static 就默认是 auto

静态局部变量:static

  • 离开函数,值任然保留
  • 变量的值只在函数内部生效
  • 带有 static 的变量只会初始化一次(数据存储在 data 段)
  • 当上一级函数多次调用本函数时,带有 static 的变量数值不变(并且不会进行初始化)

寄存器变量:register

  • 离开函数,值就消失
  • 变量的值只在函数内部生效

全局变量:在 main 之外

  • 允许 main 中所有函数访问
  • 允许外部其他文件访问

静态全局变量:在 main 之外,static

  • 允许 main 中所有函数访问
  • 变量的值只在文件内部生效

使用目标文件的节头表,可以定位文件的所有节,节头表是 Elf32_ShdrElf64_Shdr 结构的数组

节头

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
typedef struct {
elf32_Word sh_name; /* 节的名称,此成员值是节头字符串表节的索引,用于指定以空字符结尾的字符串的位置 */
Elf32_Word sh_type; /* 用于将节的内容和语义分类 */
Elf32_Word sh_flags; /* 节可支持用于说明杂项属性的1位标志 */
Elf32_Addr sh_addr; /* 如果节显示在进程的内存映像中,则此成员会指定节的第一个字节所在的地址 */
Elf32_Off sh_offset; /* 从文件的起始位置到节中第一个字节的字节偏移 */
Elf32_Word sh_size; /* 节的大小 */
Elf32_Word sh_link; /* 节头表索引链接,其解释依赖于节类型 */
Elf32_Word sh_info; /* 额外信息,其解释依赖于节类型 */
Elf32_Word sh_addralign; /* 一些节具有地址对齐约束 */
Elf32_Word sh_entsize; /* 指定每一项的大小(一些节包含固定大小的项的表) */
} Elf32_Shdr;

typedef struct {
Elf64_Word sh_name;
Elf64_Word sh_type;
Elf64_Xword sh_flags;
Elf64_Addr sh_addr;
Elf64_Off sh_offset;
Elf64_Xword sh_size;
Elf64_Word sh_link;
Elf64_Word sh_info;
Elf64_Xword sh_addralign;
Elf64_Xword sh_entsize;
} Elf64_Shdr;

节分配

节简述

  • ELF头:包括16字节的标识信息,文件类型(.o,exec,.so),机器类型(如Intel 80386),节头表的偏移,节头表的表项大小及表项个数
  • .text节:编译后的代码部分
  • .rodata节:只读数据,如 printf 用到的格式串,switch 跳转表等
  • .data节:已初始化的全局变量和静态变量
  • .bss节:未初始化全局变量和静态变量,仅是占位符,不占据任何磁盘空间,区分初始化和非初始化是为了空间效率
  • .symtab节:存放函数和全局变量(符号表)的信息,它不包括局部变量
  • .rel.text节:.text节的重定位信息,用于重新修改代码段的指令中的地址信息
  • .debug节:调试用的符号表(gcc -g)
  • .strtab节:包含 .symtab节和 .debug节 中的符号及节名

示例(可能会有不同,比如:在我的电脑上 .data 为第4节)

运行&链接&加载&存储地址

运行地址 ~~ 链接地址

链接地址:在程序编译的时候,每个目标文件都是由源代码编译得到,最终多个目标文件链接生成一个最终的可执行文件,而链接地址就是指示链接器,各个目标文件的在可执行程序中的位置

  • 链接地址是静态的,在进行程序编译的时候指定的

运行地址: 程序实际在内存中运行时候的地址

  • 运行地址是动态的,如果你将程序加载到内存中时,改变存放在内存的地址,那么运行地址也就随之改变了
1
例如上图所示,指令ldr r0, =func就是一条位置相关指令,在编译的时候,编译器根据链接地址(链接地址入口是 0x40008000 )将其翻译成:ldr r0, [pc, #0x80],也就是将func标号等价于地址 0x40008080 ,然后将 0x40008080 这个地址数值放在a.out文件中链接地址 0x50008000 的位置。当程序运行时,a.out会被加载到内存中运行,如果程序运行的地址和链接的地址都是 0x40008000 ,那么程序运行时,没有任何问题,因为读取的func的地址是 0x40008080 ,实际跳转的时候,跳转到 0x40008080 中存放的也是func对应的代码。但是如果运行的地址和链接地址不一样(运行地址是 0x20008000 ),这时候,func的地址还是编译的时候计算的地址 0x40008080 ,但是实际在内存中,func的地址是 0x20008080 ,那么当你跳转执行func的时候,取出来的是 0x40008080 ,跳转的地址也是 0x40008080 ,而 0x40008080 中存放的是什么代码我们不确定,但是一定不是func的代码(func存放在 0x20008080 中)。这个就是位置相关的概念

加载地址 ~~ 存储地址

加载地址:每一个程序一开始都是存放在flash中的,而运行是在内存中,这个时候就需要从flash中将指令读取到内存中(运行地址),flash的地址就是加载地址

存储地址:指令在flash中存放的存储地址,就是存储地址

参考:链接地址、运行地址、加载地址、存储地址

重定位

但链接器完成符号解析这一步时,就会把代码中的 每个符号引用一个符号定义 关联起来

此时,链接器就知道它输入目标模块的 “代码节” 和 “数据节” 的切确大小

重定位的工作流程如下:

  • 重定位“节”和“符号定义”:链接器会把所有同类型的节合并起来,然后将“运行地址”赋值给 “新的聚合节”“输入模块中定义的每个节” ,以及 “输入模块中定义的每个符号” ,这一步完成之后,程序中的每条指令和全局变量都有唯一的“运行地址”
  • 重定位“节”中的“符号引用”:链接器会修改 代码节 和 数据节 中对应 每个符号的引用 ,使得它们指向 正确的运行地址 (链接器需要依赖“重定位条目relocation entry”完成此操作)

重定位条目

代码的重定位条目存放于“.rel.text”中

已初始化数据的重定位条目存放于“.rel.data”中

1
2
3
4
typedef struct {
Elf32_Addr r_offset;
Elf32_Word r_info;
} Elf32_Rel;
1
2
3
4
5
6
typedef struct {
Elf64_Addr r_offset;
Elf64_Word r_info;
Elf64_Word r_addend;
} Elf64_Rel;

r_offset:需要 “被修改引用” 的节偏移

r_info:高字节为type,低字节为symbol

type:告知链接器如何修改新的引用

symbol:标识 “被修改引用” 应该指向的符号

r_addend:一个符号常数,一些类型的重定位要使用它进行调整

ELF文件定义了32种不同的重定位类型,其中最基本的两种为:

  • R_X86_64_PC32:使用32位 PC相对地址 的引用(PC相对地址:距离程序计数器(PC)的当前运行值的偏移量),类似于 “jmp xxxx” 等汇编指令,“xxxx” 加上 程序的“SP指针”得到 有效地址 ,PC值通常是下一条指令在内存中的地址
  • R_X86_64_32:使用32位 绝对地址 ,CPU直接获取 有效地址

文件中的代码和数据总体大小“小于2GB”,使用“R_X86_64_PC32”(小型代码模型)

文件中的代码和数据总体大小“大于2GB”,使用“R_X86_64_32”(中型代码模型,大型代码模型)

GCC默认使用“R_X86_64_PC32”

可执行目标文件

段和节

段视图:是用来描述ELF加载到进程中后,来划分“读,写,执行”权限划分的视图

节视图:是ELF存放在磁盘中时,进行不同功能区域划分的视图

在汇编源码中,通常用语法关键字section或segment来表示一段区域,它们是编译器提供的伪指令,作用是相同的,都是在程序中”逻辑地”规划一段区域,此区域便是节

注意,此时所说的section或segment都是汇编语法中的关键字,它们在语法中都表示”节”,不是段,只是不同编译器的关键字不同而已,关键字segment在语法中也被认为与section意义相同

只有ELF文件加载到内存成为进程过后,才有“段”的概念

ELF文件到虚拟内存的映射

​ // 左边为“节”,右边为“段”

1
2
objdump -s elf	#查看elf文件结构
cat /proc/pid/maps #输出进程对应的‘虚拟内存’结构

加载

当linux系统加载某个文件时,会通过调用某个驻留在存储器中的 加载器(loader)来运行它,加载器本质上也是一段“操作系统代码”(通过execve函数来调用加载器)

加载器把目标文件的“代码”和“数据”,从磁盘复制到内存中的过程就叫做加载

位置无关代码(PIC)

无需“重定位”就可以加装的代码就是 位置无关代码 (PIC)

位置无关代码无论被加载到哪个地址上都可以正常执行

PIC的数据引用

无论我们在内存何处加装一个目标模块,它的“数据段”和“代码段”的距离总是保持不变的,因此,代码段中 任何指令任何变量 之间的距离都是常量

生成全局变量PIC的偏移器利用了这个事实,它在数据段开始的地方创建了一个表,叫做全局偏移量表(GOT表),在GOT表中,每个被此模块引用的“全局数据”,“过程”,“全局变量”,都有一个8字节条目

加载时,动态链接会重定位GOT表中的每个条目,使其包含目标的绝对地址

PIC的函数调用

编译器没法直接获取“共享库函数”的绝对地址,所以这里采用了延迟绑定的方式

延迟绑定技术是通过GOT表(全局偏移量表)和PLT表(过程链接表)的交互完成的

  • 过程链接表(plt):plt是一个数组,每个条目都是16字节的代码,每一个条目负责一个具体的函数,plt[0]是一个特殊条目(它可以跳转 动态链接器 ),plt[1]调用系统启动函数“libc_start_main”,从plt[2]开始的条目依次调用“用户代码中的函数”
  • 全局偏移量表(got):got是一个数组,每个条目都是8字节地址,got[0]和got[1]会包含 动态链接器 在解析函数地址时需要的信息,got[2]是 动态链接器 在“ld-linux.so”模块中的入口点,其余的每一个got表条目都对应一个被调用的函数,每一个条目都有一个对应的plt[n]
  • 第1步:程序进入plt[n](对应被调用函数的plt条目),跳转got[n]
  • 第2步:got[n]没有对应的libc地址,程序跳转回plt[n]
  • 第3步:把偏移压栈,接着跳转plt[0]
  • 第4步:把got[1](link_map)压栈,跳转got[2]
  • 第5步:got[2]中存放的动态链接器会根据“偏移”和“got[1]”查找libc库中的函数
  • 第6步:找到对应函数后,进入函数,并且把got[n]对应位置写入libc函数的地址

库打桩机制

linux链接器允许用户截获程序对共享库函数的调用,取而代之自己的代码,这种技术被称为“库打桩”

利用“库打桩”,用户可以追踪某个函数的调用次数,验证和追踪它的输入值和输出值,或者把它替换为一个完全不同的实现

基本思想

先给定一个需要打桩的目标函数,创建一个包装函数(它的原型和目标函数一样),使用特殊的打桩机制,可以欺骗程序程序调用包装函数而不是目标函数

假设有程序:

1
2
3
4
5
6
7
8
#include<stdio.h>
#include"malloc.h"
int main()
{
char *p = malloc(64);
free(p);
return 0;
}

编译时打桩

1
2
#define malloc(size) mymalloc(size) //把malloc全替换为mymalloc
void *mymalloc(size_t size)
1
2
3
4
5
6
7
8
9
10
11
#ifdef MYMOCK //只有MYMOCK编译选项是,这段代码才会编译进去
#include<stdio.h>
#include<stdlib.h>
/*打桩函数*/
void *mymalloc(size_t size)
{
void *ptr = malloc(size);
printf("ptr is %p\n",ptr);
return ptr;
}
#endif

编译时打桩:主要利用了C预处理机制

1
2
$ gcc -MYMOCK -c mymalloc.c	
$ gcc -I. -o test test.c mymalloc.o //test就是目标文件

参数 “- I.” 会告诉C预处理器,在搜索通常的系统目录之前,先在当前目录中查找“malloc.h”

1
2
$ ./main
ptr is 0xdbd010

可以发现:“malloc.h”被“mymalloc.o”调包了

链接时打桩

1
2
#define malloc(size) mymalloc(size) //把malloc全替换为mymalloc
void *mymalloc(size_t size)
1
2
3
4
5
6
7
8
9
10
11
12
#ifdef MYMOCK //只有MYMOCK编译选项是,这段代码才会编译进去
#include<stdio.h>
#include<stdlib.h>
void *__real_malloc(size_t size);//注意声明
/*打桩函数*/
void *__wrap_malloc(size_t size)
{
void *ptr = __real_malloc(size);//最后会被解析成malloc
printf("ptr is %p\n",ptr);
return ptr;
}
#endif

链接时打桩:linux静态链接器支持用 “—wrap,function”进行打桩

1
2
3
$ gcc -DMYMOCK mymalloc.c
$ gcc -c main.c
$ gcc -Wl,--wrap,malloc -o test test.o mymalloc.o //test就是目标文件

“-Wl option”表示把“option”传递给链接器

“—wrap,malloc”告诉静态链接器:

把“malloc”解析为“ __wrap_malloc ”

把“ __real_malloc ”解析为“真正的malloc”

1
2
$ ./main
ptr is 0x95f010

可以发现:“malloc”被解析为了“ __wrap_malloc ”

运行时打桩

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#ifdef MYMOCK //只有MYMOCK编译选项是,这段代码才会编译进去
#define _GNU_SOURCE //这行特别注意加上
#include<stdio.h>
#include<stdlib.h>
#include<dlfcn.h>
extern FILE *stdout;
/*打桩的malloc函数*/
void *malloc(size_t size)
{
static int calltimes;
calltimes++;
/*函数指针*/
void *(*realMalloc)(size_t size) = NULL;
char *error;

realMalloc = dlsym(RTLD_NEXT,"malloc");//RTLD_NEXT
if(NULL == realMalloc)
{
error = dlerror();
fputs(error,stdout);
return NULL;
}

void *ptr = realMalloc(size);
if(1 == calltimes)
{
printf("ptr is %p\n",ptr);
}
calltimes = 0;
return ptr;
}
#endif

运行时打桩:可以通过设置LD_PRELOAD环境变量,达到在你加载一个动态库或者解析一个符号时,先从LD_PRELOAD指定的目录下的库去寻找需要的符号,然后再去其他库中寻找

1
2
3
$ gcc -DMYMOCK -shared -fPIC -o libmymalloc.so mymalloc.c -ldl
//将mymalloc.c制作成动态库 >> libmymalloc.so
$ LD_PRELOAD="./libmymalloc.so" //设置LD_PRELOAD环境变量
1
2
$ ./main
ptr is 0x95f010

可以发现:程序优先从”./libmymalloc.so”找到了“打桩malloc”

异常控制流(ECF)

程序可以使控制流发生“突变”来处理异常情况,这些“突变”就是异常控制流

应用程序可以使用“陷阱(trap)”或者“系统调用(syscall)”的ECF形式,向操作系统请求服务:向磁盘中写数据,从网络中读数据,创建一个新进程,终止当前进程

同步&异步

同步,是所有的操作都做完,才返回给用户结果,即写完数据库之后,在相应用户

异步,不用等所有操作等做完,就相应用户请求,即先相应用户请求,然后慢慢去写数据库

异常

异常是控制流中的一种形式,它一部分由硬件实现,一部分由操作系统实现

  • CPU检测到flag寄存器中的异常数据
  • CPU控制IP指针,通过“异常表的跳转表”跳转对应的处理程序
  • 处理完成后,进行相对应的操作(终止进程,继续进程,输出报错信息……)

系统把每种类型的异常都分配了一个唯一非负的异常号

异常表的基址放在一个叫异常表基址寄存器的特殊CPU寄存器中

异常的类型

异常分为4种:中断(interrupt),陷阱(trap),故障(fault),终止(abort)

类型 原因 异步/同步 返回行为
中断 来自“I/O”设备的信号 同步&异步 总是返回到下一条指令
陷阱 有意的异常 同步 总是返回到下一条指令
故障 潜在可恢复的错误 同步 可能返回到当前指令
终止 不可恢复的错误 同步 不会返回

中断

中断程序信号处理的一种机制

中断分为 内中断外中断

内中断概述

任何一个CPU都有一种能力:

可以在执行完当前指令后检测到从CPU内部产生的一种特殊信息,并立刻进行处理

这种特殊信息就是中断信息

​ //中断信息要求CPU立马进行处理,并携带了必备的参数

内中断可以使计算机可以处理紧急情况

内中断在程序中有意地产生,所以是主动的,也是“同步”

内中断产生

1.除法错误(TF=0)

2.单步执行(TF=1)

3.执行”into”指令(TF=4)

4.执行”int n”指令(TF=n)

对于8086CPU,这4种情况可以产生中断信息

外中断概述

CPU除了进行运算以外,还要有对 I/O 能力(输入/输出),但是外设的输入输出 随时 可能发生,CPU必须要拥有可以 及时处理 这些信息的能力,这就引入了外中断的思想

这种中断发生完全是“异步”的,根本无法预测到此类中断会在什么时候发生

外中断产生

外设的输入将被存放在端口中,而其输入随时可能到达

信息到达时,外设的相关芯片会给CPU发出相应的中断信息,当CPU执行完当前的指令后,一旦检测到该中断信息,就会触发外中断,行为上和内中断相似

在PC系统中,外中断源一般有以下两类:

1.可屏蔽中断:

可屏蔽中断是CPU可以不响应的中断

其到底响不响应,主要是看IF寄存器(“IF = 1” ——> 响应,“IF = 0” ——> 不响应)

在CPU执行某个中断时,会把IF设置为“0”,可以暂时屏蔽其他中断

​ //指令sti:设置“IF=1”,指令cli:设置“IF=0”

2.不可屏蔽中断:

不可屏蔽中断是CPU必须执行的外中断,不可屏蔽中断不需要中断类型码,立即引发响应

中断向量表

中断向量表中保存了256个中断处理程序的入口

CPU接收到中断类型码后,就会根据中断类型表找到中断处理程序的入口

中断过程

1.CPU获取中断类型码

2.flag寄存器入栈(中断过程会改变flag寄存器)

3.设置flag寄存器的TFIF为“0”

4.CS寄存器入栈

5.IP寄存器入栈

6.从中断向量表中读取中断处理程序的入口地址

中断检查

中断信息会被存储在flag寄存器的TF位中(TF = n:中断类型码为“n”)

CPU读取到此信息后就会开始中断过程

陷阱

陷阱是有意的异常,是执行一条指令的结果

陷阱最重要的用途:在“用户程序”和“内核”之间提供一个一样的接口,称为系统调用

处理器提供了一条特殊的指令:syscall,用于用户请求系统调用

有些基于读写的操作必须要在 “内核模式” 中完成,而系统调用为了实现这些功能而诞生的,它通过提供 有权限限制“内核模式” ,来实现一些 “用户模式” 无法办到的操作

用户程序通过系统调用从用户态(user mode)切换到核心态( kernel mode ),从而可以访问相应的资源。这样做的好处是:

  • 为用户空间提供了一种硬件的抽象接口,使编程更加容易
  • 有利于系统安全
  • 有利于每个进程度运行在虚拟系统中,接口统一有利于移植

当然,中断也可以实现系统调用(“int 80”)

故障

故障是由错误引起的,但是它可能可以被故障处理程序修正

当发生故障时,处理器会将控制转移给故障处理程序:如果可以修正故障,那么程序将返回并继续执行,否则,程序将返回内核中的“abort例程”并终止该程序

终止

终止是不可恢复的致命错误的结果,通常是一些硬件错误

终止处理程序将直接终止目标程序,不会返回任何信息

内核模式

处理器通常是用某个控制寄存器中的模式位来表示“当前进程的特权”

共两种特权:“用户模式”,“内核模式”

套接字接口

套接字接口(socket interface)是一组函数,它们和 Unix I/O 函数结合起来,用以创建网络应用

大多数现代系统上都实现套接字接口,包括所有的 Unix 变种、Windows 和 Macintosh 系统

​ // 从 Linux 内核的角度来看,一个套接字就是 通信的一个端点 ,从 Linux 程序的角度来看,套接字就是一个 有相应描述符的打开文件

因特网的套接字地址存放在所示的类型为 sockaddr_in 的 16 字节结构中(IP 地址和端口号总是以网络字节顺序(大端法)存放的)

下面将介绍套接字接口中的部分函数:

1
2
3
4
int socket(int domain, int type, int protocol);
// domain:即协议域,又称为协议族(family)
// type:指定socket类型
// protocol:指定协议

​ // 协议族决定了 socket 的地址类型,在通信中必须采用对应的地址

socket 函数 用于来返回一个 套接字描述符 (clientfd)

  • 套接字描述符:用来标定系统为当前的进程划分的一块缓冲空间,类似于文件描述符
  • 文件描述符:是内核为了高效管理已被打开的文件所创建的索引,用于指代被打开的文件,对文件所有 I/O 操作相关的系统调用都需要通过文件描述符(open的返回值fd)
1
2
3
4
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
// sockfd:需要绑定的socket
// *addr:存放了服务端用于通信的地址和端口
// addrlen: sizeof(sockaddr_in)

bind 函数 告诉内核将 addr 中的服务器套接字地址和套接字描述符 sockfd 联系起来

​ // bind函数把一个本地协议地址赋予一个套接字

1
2
3
int connect(int clientfd, const struct sockaddr *addr, socklen_t addrlen);
// clientfd:套接字描述符的一种
// addrlen:sizeof(sockaddr_in)

connect 函数 试图与 “套接字地址为 addr 的服务器” 建立一个因特网连接

如果成功,clientfd 描述符现在就准备好可以读写了(最好用 getaddrinfo 来为 connect 提供参数)

1
2
3
int listen(int sockfd, int backlog);
// sockfd:需要绑定的socket
// backlog:暗示了内核在开始拒绝连接请求之前,队列中要排队的未完成的连接请求的数量

listen 函数 将 sockfd 从一个 主动套接字 转化为一个 监听套接字 (listening socket),该套接字可以接受来自客户端的连接请求

1
2
3
4
int accept(int listenfd, struct sockaddr *addr, int *addrlen);
// listenfd:服务器的socket描述符
// *addr:指向struct sockaddr *的指针
// *addrlen:协议地址的长度

accept 函数 等待来自客户端的连接请求到达侦听描述符 listenfd,然后在 addr 中填写客户端的套接字地址,并返回一个 已连接描述符

一个服务器通常通常仅仅只创建一个监听socket描述字,内核为每个由服务器进程接受的客户连接创建了一个已连接socket描述字,当服务器完成了对某个客户的服务,相应的已连接socket描述字就被关闭

1
2
3
4
5
6
7
8
9
int getaddrinfo(const char *host, const char *service,
const struct addrinfo *hints,
struct addrinfo **result);
// host & service:套接字地址的两个组成部分
// 可选的参数 hints 是一个 addrinfo 结构,它提供对 getaddrinfo 返回的套接字地址列表的更好的控制
// getaddrinfo 返回 result,result 一个指向 addrinfo 结构的链表

void freeaddrinfo(struct addrinfo *result);
const char *gai_strerror(int errcode); // 返回:错误消息

getaddrinfo 函数 将主机名、主机地址、服务名和端口号的字符串表示转化成套接字地址结构,它是已弃用的 gethostbyname 和 getservbyname 函数的新的替代品

在客户端调用了 getaddrinfo 之后,会遍历这个列表,依次尝试每个套接字地址,直到调用 socket 和 connect 成功,建立起连接,类似地,服务器会尝试遍历列表中的每个套接字地址,直到调用 socket 和 bind 成功,描述符会被绑定到一个合法的套接字地址,

  • 为了避免内存泄漏,应用程序必须在最后调用 freeaddrinfo,释放该链表
  • 如果 getaddrinfo 返回非零的错误代码,应用程序可以调用 gai_streeror,将该代码转换成消息字符串
1
2
3
4
5
6
7
8
9
10
struct addrinfo {
int ai_flags; /* Hints argument flags */
int ai_family; /* First arg to socket function */
int ai_socktype; /* Second arg to socket function */
int ai_protocol; /* Third arg to socket function */
char *ai_canonname; /* Canonical hostname */
size_t ai_addrlen; /* Size of ai_addr struct */
struct sockaddr *ai_addr; /* Ptr to socket address structure */
struct addrinfo *ai_next; /* Ptr to next item in linked list */
};
1
2
3
4
5
6
7
int getnameinfo(const struct sockaddr *sa, socklen_t salen,
char *host, size_t hostlen,
char *service, size_t servlen, int flags);
// *sa:指向大小为 salen 字节的套接字地址结构
// *host 指向大小为 hostlen 字节的缓冲区
// *service 指向大小为 servlen 字节的缓冲区
// 参数 flags 是一个位掩码,能够修改默认的行为

getnameinfo 函数 和 getaddrinfo 是相反的,将一个套接字地址结构转换成相应的主机和服务名字符串,它是已弃用的 gethostbyaddr 和 getservbyport 函数的新的替代品

1
2
3
int open_clientfd(char *hostname, char *port);
// *hostname:服务器运行的地址
// *port:指向端口

客户端调用 open_clientfd 建立与服务器的连接

open_clientfd 函数 建立与服务器的连接,该服务器运行在主机 hostname 上,并在端口号 port 上监听连接请求

1
2
int open_listenfd(char *port);
// *port:指向端口号

open_listenfd 函数 打开和返回一个监听描述符,这个描述符准备好在端口 port_h 接收连接请求

网络编程中的信号

进程组

进程组就是一系列相互关联的进程集合,系统中的每一个进程也必须从属于某一个进程组,每个进程组中都会有一个唯一的 ID(process group id),简称 PGID,PGID 一般等同于进程组的创建进程的 Process ID,而这个进进程一般也会被称为进程组先导(process group leader),同一进程组中除了进程组先导外的其他进程都是其子进程

进程组的存在,方便了系统对多个相关进程执行某些统一的操作,例如:我们可以一次性发送一个信号量给同一进程组中的所有进程

会话

会话(session)是一个若干进程组的集合,同样的,系统中每一个进程组也都必须从属于某一个会话

  • 一个会话只拥有最多一个控制终端(也可以没有),该终端为会话中所有进程组中的进程所共用
  • 一个会话中前台进程组只会有一个,只有其中的进程才可以和控制终端进行交互,除了前台进程组外的进程组,都是后台进程组

和进程组先导类似,会话中也有会话先导(session leader)的概念,用来表示建立起到控制终端连接的进程,在拥有控制终端的会话中,session leader 也被称为控制进程(controlling process),一般来说控制进程也就是登入系统的 shell 进程(login shell)

带外数据

带外数据用于迅速告知对方本端发生的重要的事件,它比普通的数据(带内数据)拥有更高的优先级, 不论发送缓冲区中是否有排队等待发送的数据,它总是被立即发送 ,带外数据的传输可以使用一条独立的传输层连接,也可以映射到传输普通数据的连接中,

​ // 实际应用中,带外数据是使用很少见,有 telnet 和 ftp 等远程非活跃程序

UDP没有没有实现带外数据传输,TCP也没有真正的带外数据,不过TCP利用头部的紧急指针标志和紧急指针,为应用程序提供了一种紧急方式,含义和带外数据类似,TCP的紧急方式利用传输普通数据的连接来传输紧急数据

SIGHUP信号(关闭进程)

SIGHUP 信号在 用户终端连接(正常或非正常)结束 时发出, 通常是在终端的控制进程结束时, 通知同一session内的各个作业(任务),这时它们与控制终端不再关联

系统对SIGHUP信号的默认处理是:终止收到该信号的进程 ,所以若程序中没有捕捉该信号,当收到该信号时,进程就会退出

SIGHUP会在以下3种情况下被发送给相应的进程:

  • 终端关闭时,该信号被发送到 session 首进程以及作为 job 提交的进程(即用 & 符号提交的进程)
  • session 首进程退出时,该信号被发送到该 session 中的前台进程组中的每一个进程
  • 若父进程退出导致进程组成为孤儿进程组,且该进程组中有进程处于停止状态(收到SIGSTOP或SIGTSTP信号),该信号会被发送到该进程组中的每一个进程

例如:在我们登录Linux时,系统会分配给登录用户一个终端(Session),在这个终端运行的所有程序,包括前台进程组和后台进程组,一般都属于这个 Session,当用户退出Linux登录时,前台进程组和后台有对终端输出的进程将会收到SIGHUP信号,这个信号的默认操作为终止进程,因此前台进程组和后台有终端输出的进程就会中止

​ // 晦涩难懂,需要在实例中理解分析

SIGPIPE信号(告知中断)

往一个写端关闭的管道或 socket 连接中连续写入数据时会引发 SIGPIPE 信号(引发 SIGPIPE 信号的写操作将设置 errno 为EPIPE)

在TCP通信中,当通信的双方中的一方close一个连接时,若另一方接着发数据,根据TCP协议的规定,会收到一个RST(Reset the connection)响应报文,若再往这个服务器发送数据时,系统会发出一个SIGPIPE信号给进程,告诉进程这个连接已经断开了,不能再写入数据

  • 即使断开还可以进行一次通信,第二次发送数据时才触发SIGPIPE
  • 可以用相应的 handle 进行处理SIGPIPE,完成想要的操作

网络编程结构体

通用结构体:struct sockaddr,16个字节

1
2
3
4
5
/* Generic socket address structure (for connect, bind, and accept) */
struct sockaddr {
uint16_t sa_family; /* Protocol family */
char sa_data[14]; /* Address data */
};

struct sockaddr 是一个通用地址结构,这是为了统一地址结构的表示方法,统一接口函数,使不同的地址结构可以被bind() , connect() 等函数调用

sockaddr的缺陷:sa_data 把目标地址和端口信息混在一起了

通用结构体:struct sockaddr_storage,128个字节

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* Structure large enough to hold any socket address 
(with the historical exception of AF_UNIX). 128 bytes reserved. */

#if ULONG_MAX > 0xffffffff
# define __ss_aligntype __uint64_t
#else
# define __ss_aligntype __uint32_t
#endif
#define _SS_SIZE 128
#define _SS_PADSIZE (_SS_SIZE - (2 * sizeof (__ss_aligntype)))

struct sockaddr_storage
{
uint16_t ss_family; /* Address family */
__ss_aligntype __ss_align; /* Force desired alignment. */
char __ss_padding[_SS_PADSIZE];
};

struct sockaddr_storage 被设计为同时适合 struct sockaddr_in 和 struct sockaddr_in6

为了避免试图知道要使用的IP版本,可以使用 struct sockaddr_storage,该版本可以保存其中任何一个,后将通过 connect(),bind() 等函数将其类型转换为 struct sockaddr 并以这种方式进行访问

IPv4:struct sockaddr_in,16个字节

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* IP socket address structure */
struct sockaddr_in {
uint16_t sin_family; /* Protocol family (always AF_INET) */
uint16_t sin_port; /* 16位的端口号 */
struct in_addr sin_addr; /* 32位的IP地址 */
unsigned char sin_zero[sizeof (struct sockaddr) -
sizeof (sa_family_t) -
sizeof (in_port_t) -
sizeof (struct in_addr)]; // sin_zero[8]
/* Pad to sizeof(struct sockaddr) */
};

typedef uint32_t in_addr_t;
struct in_addr {
in_addr_t s_addr; /* IPv4 address */
};

该结构体解决了 sockaddr 的缺陷,把 port 和 addr 分开储存在两个变量中

IPv6:struct sockaddr_in6,28个字节

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct sockaddr_in6 {
uint16_t sin6_family; /* AF_INET6 */
uint16_t sin6_port; /* Transport layer port # */
uint32_t sin6_flowinfo; /* IPv6 flow information */
struct in6_addr sin6_addr; /* IPv6 address */
uint32_t sin6_scope_id; /* IPv6 scope-id */
};
struct in6_addr {
union {
uint8_t u6_addr8[16];
uint16_t u6_addr16[8];
uint32_t u6_addr32[4];
} in6_u;

#define s6_addr in6_u.u6_addr8
#define s6_addr16 in6_u.u6_addr16
#define s6_addr32 in6_u.u6_addr32
};

服务器简析

每个网络应用都是基于客户端—服务器模型的,釆用这个模型,一个应用是由 一个服务器进程 和一个或者多个 客户端 进程组成

个客户端—服务器事务由以下四步组成:

  1. 当一个客户端需要服务时,它向服务器发送一个请求,发起一个事务,例如,当 Web 浏览器需要一个文件时,它就发送一个请求给 Web 服务器
  2. 服务器收到请求后,解释它,并以适当的方式操作它的资源,例如,当 Web 服务器收到浏览器发出的请求后,它就读一个磁盘文
  3. 服务器给客户端发送一个响应,并等待下一个请求,例如,Web 服务器将文件发送回客户端
  4. 客户端收到响应并处理它,例如,当 Web 浏览器收到来自服务器的一页后,就在屏幕上显示此页

服务器请求

一般的请求消息如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
GET /home.html HTTP/1.0 <!-- 请求消息行 -->
Accept: */* <!-- 请求消息头 -->
Host: localhost:GET /home.html HTTP/1.0
Accept: */*
Host: localhost:GET /home.html HTTP/1.0
Accept: */*
Host: localhost:
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:10.0.3) Gecko/20120305 Firefox/10.0.3
Connection: close
Proxy-Connection: close

<html> <!-- 消息正文 -->
<head><title>test</title></head>
<body>
<img align="middle" src="godzilla.gif">
Dave O'Hallaron
</body>
</html>
  • 请求消息行:请求消息的第一行为请求消息行

    • 例如:GET /test/test.html HTTP/1.1
    • GET 为请求方式,请求方式分为:Get(默认)、POST、DELETE、HEAD等
      • GET:明文传输 不安全,数据量有限,不超过1kb
      • POST:暗文传输,安全,数据量没有限制
    • /test/test.html 为URI,统一资源标识符
    • HTTP/1.1 为协议版本
  • 请求消息头:从第二行开始到空白行统称为请求消息头

    • Accept:浏览器可接受的MIME类型告诉服务器客户端能接收什么样类型的文件
    • Accept-Charset:浏览器通过这个头告诉服务器,它支持哪种字符集
    • Accept-Encoding:浏览器能够进行解码的数据编码方式,比如 gzip
    • Accept-Language:浏览器所希望的语言种类,当服务器能够提供一种以上的语言版本时要用到,可以在浏览器中进行设置
    • Host:初始URL中的主机和端口
    • Referrer:包含一个URL,用户从该URL代表的页面出发访问当前请求的页面
    • Content-Type:内容类型告诉服务器浏览器传输数据的MIME类型,文件传输的类型
    • If-Modified-Since:利用这个头与服务器的文件进行比对,如果一致,则从缓存中直接读取文件
    • User-Agent:浏览器类型
    • Content-Length:表示请求消息正文的长度
    • Connection:表示是否需要持久连接。如果服务器看到这里的值为“Keep -Alive”,或者看到请求使用的是HTTP 1.1(HTTP 1.1默认进行持久连接)
    • Cookie:用于分辨两个请求是否来自同一个浏览器,以及保存一些状态信息
    • Date:请求时间GMT
  • 消息正文:当请求方式是[POST]方式时,才能看见消息正文,消息正文就是要传输的一些数据,如果没有数据需要传输时,消息正文为空

服务器响应

一般的响应如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
HTTP/1.0 200 OK <!-- 响应消息行 -->
Server: Tiny Web Server <!-- 响应消息头 -->
Content-length: 120
Content-type: text/html

<html> <!-- 响应正文 -->
<head><title>test</title></head>
<body>
<img align="middle" src="godzilla.gif">
Dave O'Hallaron
</body>
</html>
  • 响应消息行:第一行响应消息为响应消息行
    • 例如:HTTP/1.0 200 OK
    • HTTP/1.0 为协议版本
    • 200 为响应状态码,常用的响应状态码有40余种,这里我们仅列出几种,详细请看:
      • 200:一切正常
      • 302/307:临时重定向
      • 304:未修改,客户端可以从缓存中读取数据,无需从服务器读取
      • 404:服务器上不存在客户端所请求的资源
      • 500:服务器内部错误
    • OK 为状态码描述
  • 响应消息头:
    • Location:指示新的资源的位置通常和302/307一起使用,完成请求重定向
    • Server:指示服务器的类型
    • Content-Encoding:服务器发送的数据采用的编码类型
    • Content-Length:告诉浏览器正文的长度
    • Content-Language:服务发送的文本的语言
    • Content-Type:服务器发送的内容的MIME类型
    • Last-Modified:文件的最后修改时间
    • Refresh:指示客户端刷新频率,单位是秒
    • Content-Disposition:指示客户端下载文件
    • Set-Cookie:服务器端发送的Cookie
    • Expires:-1
    • Cache-Control:no-cache (1.1)
    • Pragma:no-cache (1.0) 表示告诉客户端不要使用缓存
    • Connection:close/Keep-Alive
    • Date:请求时间
  • 响应正文:即网页的源代码(F12可查看)