操作系统
目录基本知识
用户态与内核态
- 系统态(也称为管态/核心态/内核态)——运行操作系统程序
- 当进程运行在内核态时,CPU堆栈指针寄存器指向的是内核堆栈地址,使用的是内核堆栈
- 用户态(也称为目态)——运行用户程序
- 当进程运行在用户态时。CPU堆栈指针寄存器指向的是用户堆栈地址,使用的是用户堆栈
在实际运行过程中,处理机会在系统态和用户态间切换。相应地,现代多数操作系统将 CPU 的指令集分为
- 特权指令
- 在系统态时运行的指令
- 能访问所有的内存空间和对象,不仅能访问用户存储空间,也能访问系统存储空间,
- 其所占有的处理机是不可被抢占的
- 特权指令只允许操作系统使用,不允许应用程序使用,否则会引起系统混乱。
- 包括启动I/O,内存清零,允许禁止中断,设置时钟,停机等
- 非特权指令
- 一般应用程序所使用的都是非特权指令,它只能完成一般性的操作和任务
- 访问的内存空间和对象受到限制,其所占有的处理机是可被抢占的
- 不能对系统中的硬件和软件直接进行访问,其对内存的访问范围也局限于用户空间。
两个状态的切换
- 用户态切换到内核态的唯一途径——>中断/异常/陷入
- 内核态切换到用户态的途径——>设置程序状态字
陷入指令(又称为访管指令,因为内核态也被称为管理态,访管就是访问管理态)
该指令给用户提供接口,用于调用操作系统的服务。
同步异步
并发进程/线程在一些关键点上可能需要互相等待与互通消息,这种相互制约的等待与互通信息称为进程/线程同步,指需要阻塞等待事件的完成才能进行下一步操作
异步是指不需要等待事件完成,可以在这期间做点别的事件,即非阻塞
并发并行
多个程序、交替执行的思想,就有 CPU 管理多个进程的初步想法;虽然单核的 CPU 在某一个瞬间,只能运行一个进程。但在 1 秒钟期间,它可能会运行多个进程,这样就产生并行的错觉,实际上这是并发。
死锁
线程死锁是指由于两个或者多个线程互相持有对方所需要的资源,于是两者都不能执行而处于永远等待状态
产生死锁的的四个条件如下:
- 互斥:一个资源一次只能被一个进程所使用
- 请求与保持:一个进程因请求资源被阻塞时,对已获得的资源保持不放
- 不可剥夺:进程已获得的资源,在未使用完之间不可被强行剥夺
- 循环等待:多个进程之间形成一种互相循环等待资源的关系。
解决死锁
预防死锁
- 资源一次性分配:一次性分配所有资源,这样就不会再有请求了:(破坏请求条件)
- 只要有一个资源得不到分配,也不给这个进程分配其他的资源:(破坏请保持条件)
- 可剥夺资源:即当某进程获得了部分资源,但得不到其它资源,则释放已占有的资源(破坏不可剥夺条件)
- 资源有序分配法:系统给每类资源赋予一个编号,每一个进程按编号递增的顺序请求资源,释放则相反(破坏环路等待条件)
避免死锁
加锁顺序:当多个线程需要相同的一些锁,但是按照不同的顺序加锁,死锁就很容易发生。如果能确保所有的线程都是按照相同的顺序获得锁,那么死锁就不会发生。
加锁时限:加上一个超时时间,若一个线程没有在给定的时限内成功获得所有需要的锁,则会进行回退并释放所有已经获得的锁,然后等待一段随机的时间再重试。
死锁检测
- 首先为每个进程和每个资源指定一个唯一的号码;
- 然后建立资源分配表和进程等待表。
银行家算法:
- 银行家算法是从当前状态出发,按照系统各类资源剩余量逐个检查各进程需要申请的资源量,找到一个各类资源申请量均小于等于系统剩余资源量的进程P1。
- 然后分配给该P1进程所请求的资源,假定P1完成工作后归还其占有的所有资源,更新系统剩余资源状态并且移除进程列表中的P1,进而检查下一个能完成工作的客户,......。
- 如果所有客户都能完成工作,则找到一个安全序列,银行家才是安全的。若找不到这样的安全序列,则当前状态不安全。
进程
我们编写的代码只是一个存储在硬盘的静态文件,通过编译后就会生成二进制可执行文件,当我们运行这个可执行文件后,它会被装载到内存中,接着 CPU 会执行程序中的每一条指令,那么这个运行中的程序,就被称为「进程」。
进程是计算机中的程序关于某数据集合上的一次运行活动
一个进程的活动期间至少具备三种基本状态,即运行状态、就绪状态、阻塞状态。
- 运行状态(Runing):该时刻进程占用 CPU;
- 就绪状态(Ready):可运行,但因为其他进程正在运行而暂停停止;
- 阻塞状态(Blocked):该进程正在等待某一事件发生(如等待输入/输出操作的完成)而暂时停止运行,这时,即使给它CPU控制权,它也无法运行;
进程控制块PCB
在操作系统中,是用进程控制块(process control block,PCB)数据结构来描述进程的。
PCB是进程存在的唯一标志!
PCB内容
进程描述信息:
- 进程标识符:标识各个进程,每个进程都有一个并且唯一的标识符;
- 用户标识符:进程归属的用户,用户标识符主要为共享和保护服务;
进程控制和管理信息:
- 进程当前状态,如 new、ready、running、waiting 或 blocked 等;
- 进程优先级:进程抢占 CPU 时的优先级;
资源分配清单:
- 有关内存地址空间或虚拟地址空间的信息,所打开文件的列表和所使用的 I/O 设备信息。
CPU 相关信息:
- CPU 中各个寄存器的值,当进程被切换时,CPU 的状态信息都会被保存在相应的 PCB 中,以便进程重新执行时,能从断点处继续执行。
底层结构
通常是通过链表的方式进行组织,把具有相同状态的进程链在一起,组成各种队列。比如:
- 将所有处于就绪状态的进程链在一起,称为就绪队列;
- 把所有因等待某事件而处于等待状态的进程链在一起就组成各种阻塞队列;
- 另外,对于运行队列在单核 CPU 系统中则只有一个运行指针了,因为单核 CPU 在某个时间,只能运行一个程序。
上下文切换
进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。
同一进程的线程的上下文切换只需要切换线程的独立一套的寄存器和栈不共享的数据
线程Thread
线程是进程当中的一条执行流程,同一个进程内多个线程之间可以共享代码段、数据段、打开的文件等资源,但每个线程都有独立一套的寄存器和栈,这样可以确保线程的控制流是相对独立的。
线程的优点:
- 一个进程中可以同时存在多个线程;
- 各个线程之间可以并发执行;
- 各个线程之间可以共享地址空间和文件等资源;
线程的缺点:
- 当进程中的一个线程奔溃时,会导致其所属进程的所有线程奔溃。
线程与进程
线程与进程最大的区别在于:进程是资源拥有的基本单位,线程是调度的基本单位。
- 进程是资源(包括内存、打开的文件等)分配的单位,线程是 CPU 调度的单位;
- 进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如寄存器和栈;
- 所以线程创建时间,终止时间快
- 线程同样具有就绪、阻塞、执行三种基本状态,同样具有状态之间的转换关系;
- 线程能减少并发执行的时间和空间开销;
- 同一个进程内的线程切换比进程切换快,因为线程具有相同的地址空间(虚拟内存共享),这意味着同一个进程的线程都具有同一个页表,那么在切换的时候不需要切换页表。
- 由于同一进程的各线程间共享内存和文件资源,那么在线程之间数据传递的时候,就不需要经过内核了,这就使得线程之间的数据交互效率更高了;
线程的实现
主要有三种线程的实现方式:
- 用户线程(User Thread):在用户空间实现的线程,不是由内核管理的线程,是由用户态的线程库来完成线程的管理;
- 内核线程(Kernel Thread):在内核中实现的线程,是由内核管理的线程;
- 轻量级进程(LightWeight Process):在内核中来支持用户线程;
线程上下文切换
- 当两个线程不是属于同一个进程,则切换的过程就跟进程上下文切换一样;
- 当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据;
所以,线程的上下文切换相比进程,开销要小很多。
资源竞争
线程是非独立的,同一个进程里线程是数据共享的,当各个线程访问数据资源时会出现竞争状态即:
数据几乎同步会被多个线程占用,造成数据混乱
线程安全
在拥有共享数据的多条线程并行执行的程序中,线程安全的代码会通过同步机制保证各个线程都可以正常且正确的执行,不会出现数据污染等意外情况。
如何保证线程安全?
-
给共享的资源加把锁,保证每个资源变量每时每刻至多被一个线程占用。
-
让线程也拥有资源,不用去共享进程中的资源。如: 使用threadlocal可以为每个线程的维护一个私有的本地变量。
-
让共享资源只能看,不能改
线程不安全就是两条都满足:共享和可变,只要打破其中一条即可。
单例模式
单例模式指在整个系统生命周期里,保证一个类只能产生一个实例,确保该类的唯一性。
单例类特点
- 构造函数和析构函数为private类型,目的禁止外部构造和析构。
- 拷贝构造和赋值构造函数为private类型,目的是禁止外部拷贝和赋值,确保实例的唯一性。
- 唯一单例对象指针为private类型,用函数获取
- 类里有个获取实例的静态函数,可以全局访问。
单例模式分为两种
在C++11内部静态变量的方式里是线程安全的,只会创建了一次实例
-
懒汉式是以时间换空间,适应于访问量较小时;推荐使用内部静态变量的懒汉单例,代码量少。
- 懒汉模式即是等到访问的时候再创建实例
-
饿汉式是以空间换时间,适应于访问量较大时,或者线程比较多的的情况。
- 饿汉模式是代码一运行就初始化并创建实例 ,因为使用static,本身就线程安全
一般new会经过三个步骤
- malloc分配内存
- 强制类型转换
- 调用构造函数
由于编译器的优化以及运行时优化等等原因,使得instance虽然已经不是nullptr但是其所指对象还没有完成构造函数,这种情况下,另一个线程如果调用getInstance()就有可能使用到一个不完全初始化的对象。
C++11没有出来的时候,只能靠插入两个memory barrier(内存屏障)来解决这个错误,但是C++11引进了memory model,提供了atomic实现内存的同步访问,即不同线程总是获取对象修改前或修改后的值,无法在对象修改期间获得该对象。
三种解决方法
- 内存栅栏memory barrier
- atomic
- volatile,不优雅
//----------1.懒汉非线程安全----------
class SingleInstance
{
private:
// 唯一单实例对象指针
static SingleInstance *m_SingleInstance;
private:
Singleton();
~Singleton();
Singleton(const Singleton&);
Singleton& operator=(const Singleton&);
void SingleInstance::deleteInstance();
public:
SingleInstance* SingleInstance::GetInstance();
};
//初始化静态成员变量
SingleInstance *SingleInstance::m_SingleInstance = NULL;
SingleInstance* SingleInstance::GetInstance()
{
if (m_SingleInstance == NULL)
{
m_SingleInstance = new (std::nothrow) SingleInstance; // 没有加锁是线程不安全的,当线程并发时会创建多个实例
}
return m_SingleInstance;
}
void SingleInstance::deleteInstance()
{
if (m_SingleInstance)
{
delete m_SingleInstance;
m_SingleInstance = NULL;
}
}
//---------2.加锁懒汉线程安全---------
。。。类内
private:
// 唯一单实例对象指针
static SingleInstance *m_SingleInstance;
static std::mutex m_Mutex;
};
//初始化静态成员变量
SingleInstance *SingleInstance::m_SingleInstance = NULL;
std::mutex SingleInstance::m_Mutex;
SingleInstance *&SingleInstance::GetInstance()
{
// 这里使用了两个 if判断语句的技术称为双检锁;
// 避免每次调用 GetInstance的方法都加锁(如果加锁放在外面的话),锁的开销毕竟还是有点大的。
if (m_SingleInstance == NULL)
{
//如果不双重加锁,线程A到这被中断,线程B进来加锁,实例化;此时线程A恢复现场,重新执行
//若没有再次判断,则线程A也加锁,实例化,那么就有两个单例了;
//若有再次判断,则线程A直接跳出这里;
std::unique_lock<std::mutex> lock(m_Mutex); // 加锁
if (m_SingleInstance == NULL)
{
m_SingleInstance = new (std::nothrow) SingleInstance;
}
}
return m_SingleInstance;
}
void SingleInstance::deleteInstance()
{
std::unique_lock<std::mutex> lock(m_Mutex); // 加锁
if (m_SingleInstance)
{
delete m_SingleInstance;
m_SingleInstance = NULL;
}
}
//--------3.atomic懒汉线程安全-----------
atomic<Widget*> Widget::pInstance{ nullptr };
Widget* Widget::Instance() {
Widget* p = pInstance;//用临时指针减少了atomic的开销
if (p == nullptr) {
lock_guard<mutex> lock{ mutW };
if ((p = pInstance) == nullptr) {
pInstance = p = new Widget();
}
}
return p;
}
//--------4.内部局部静态懒汉线程安全-----------只能在C++11后使用
Single &Single::GetInstance()
{
// 局部静态特性的方式实现单实例
static Single signal;
return signal;
}
//----------5.饿汉式单例线程安全------------
private:
// 唯一单实例对象指针
static Singleton *g_pSingleton;
};
// 代码一运行就初始化创建实例 ,本身就线程安全
Singleton* Singleton::g_pSingleton = new (std::nothrow) Singleton;
Singleton* Singleton::GetInstance()
{
return g_pSingleton;
}
void Singleton::deleteInstance()
{
if (g_pSingleton)
{
delete g_pSingleton;
g_pSingleton = NULL;
}
}
STL容器线程安全
STL 语义上不提供任何强度的线程安全保证,以vector为例
多线程访问vector分为两种情况
- 不改变vector的元素数量, 则多线程访问无冲突
- 可以通过固定vector的大小,避免动态扩容(无push_back)来做到lock-free!
- 即在开始并发读写之前(比如初始化)的时候,给vector设置好大小,然后多线程不允许push
- 改变vector的元素数量(比如push_back, erase), 则多线程访问有冲突,需要加锁。
- 每次写入前都锁住,执行完毕本次写入后,其他线程需要写入才能进去。这样就不会出现内存被重新分配同时也在写入导致内存无效了。
调度
进程调度
-
非抢占式:当进程正在运行时,它就会一直运行,直到该进程完成或发生某个事件而被阻塞时,才会把 CPU 让给其他进程。
- 先来先服务调度算法FCFS,对短作业不利(等前面长作业)
- 短作业优先SJF,对长作业不利(等短作业)
- 高响应比优先 (Highest Response Ratio Next, HRRN)
- 优先权=(等待时间+要求服务时间)/要求服务时间
- 兼顾到了长短作业
-
抢占式:进程正在运行的时,可以被打断,使其把 CPU 让给其他进程
- 时间片轮转(Round Robin, RR)
- 如果时间片用完,进程还在运行,那么将会把此进程从 CPU 释放出来,并把 CPU 分配另外一个进程;
- 如果该进程在时间片结束前阻塞或结束,则 CPU 立即进行切换;
- 最高优先级(Highest Priority First,HPF)
- 静态优先级:创建进程时候,就已经确定了优先级了,然后整个运行时间优先级都不会变化;
- 动态优先级:根据进程的动态变化调整优先级
- 时间片轮转(Round Robin, RR)
页面调度
缺页异常(缺页中断)
当 CPU 访问的页面不在物理内存时,便会产生一个缺页中断,请求操作系统将所缺页调入到物理内存。那它与一般中断的主要区别在于:
- 缺页中断在指令执行「期间」产生和处理中断信号,而一般中断在一条指令执行「完成」后检查和处理中断信号。
- 缺页中断返回到该指令的开始重新执行「该指令」,而一般中断返回回到该指令的「下一个指令」执行。
页面置换算法的功能是,当出现缺页异常,需调入新页面而内存已满时,选择被置换的物理页面,也就是说选择一个物理页面换出到磁盘,然后把需要访问的页面换入到物理页。
算法目标则是,尽可能减少页面的换入换出的次数
-
最佳页面置换算法(OPT)
- 置换在「未来」最长时间不访问的页面。
- 实际系统中无法预知每个页面在「下一次」访问前的等待时间。
- 最佳页面置换算法作用是为了衡量你的算法的效率,你的算法效率越接近该算法的效率,那么说明你的算法是高效的。
-
先进先出置换算法(FIFO)
- 选择在内存驻留时间最长的页面进行中置换,即使最近被访问过
-
最近最久未使用的置换算法(LRU)
- 发生缺页时,选择最长时间没有被访问的页面进行置换
- 需要在内存中维护一个所有页面的链表,最近最多使用的页面在表头,最近最少使用的页面在表尾。
- 每次访问内存时都必须要更新「整个链表」
-
时钟页面置换算法(Lock)
-
最不常用置换算法(LFU)
- 当发生缺页中断时,选择「访问次数」最少的那个页面,并将其淘汰。
磁盘置换
磁盘调度算法的目的就是为了提高磁盘的访问性能
- 先来先服务算法
- 按请求序列进行访问
- 最短寻道时间优先算法
- 优先选择从当前磁头位置所需寻道时间最短的请求
- 扫描算法算法
- 最短寻道时间优先算法会产生饥饿的原因在于:磁头有可能再一个小区域内来回得移动。
- 磁头在一个方向上移动,访问所有未完成的请求,直到磁头到达该方向上的最后的磁道,才调换方向
- 中间部分相比其他部分响应的频率会比较多,也就是说每个磁道的响应频率存在差异。
- 循环扫描算法
- 磁道只响应一个方向上的请求
- 到达末端直接复位,返回中途不处理任何请求
- LOOK 与 C-LOOK 算法
- 磁头在移动到「最远的请求」位置,然后立即反向移动。
- LOOK:反向移动的途中会响应请求
- C-LOOK :反向移动的途中不会响应请求
进程间通信
由于每个进程的用户空间都是独立的,不能相互访问,这时就需要借助内核空间来实现进程间通信,原因很简单,每个进程都是共享一个内核空间。
同一台主机
管道
特点:写入与获取的数据都是缓存在内核,同时通信数据都遵循先进先出原则,不支持 lseek 之类的文件定位操作。
缺点:管道的通信方式是效率低的,因此管道不适合进程间频繁地交换数据。
- 匿名管道
- 特殊文件只存在于内存,没有存在于文件系统中
- 通信的数据是无格式的流并且大小受限
- 通信的方式是单向的,数据只能在一个方向上流动,如果要双向通信,需要创建两个管道
- 匿名管道是只能用于存在父子关系的进程间通信,匿名管道的生命周期随着进程创建而建立,随着进程终止而消失
- 命名管道
- 需要在文件系统创建一个类型为 p 的设备文件,那么毫无关系的进程就可以通过这个设备文件进行通信
消息队列
特点:解决管道不适合频繁沟通的问题
缺点:一是通信不及时,二是附件也有大小限制
- 消息队列实际上是保存在内核的「消息链表」
- 消息队列的消息体是可以用户自定义的数据类型,发送数据时,会被分成一个一个独立的消息体
- 消息队列通信的速度不是最及时的,因为每次数据的写入和读取都需要经过用户态与内核态之间的拷贝过程。
- 消息队列生命周期随内核,如果没有释放消息队列或者没有关闭操作系统,消息队列会一直存在
共享内存
特点:可以解决消息队列通信中用户态与内核态之间数据拷贝过程带来的开销,大大提高了通信的速度,享有最快的进程间通信方式之名
缺点:多进程竞争同个共享资源会造成数据的错乱
- 直接分配一个共享空间,每个进程都可以直接访问,就像访问进程自己的空间一样快捷方便,不需要陷入内核态或者系统调用,从而提高通信速度
信号量
特点:保护共享资源,以确保任何时刻只能有一个进程访问共享资源,这种方式就是互斥访问
- 信号量不仅可以实现访问的互斥性,还可以实现进程间的同步
- 本质是一个计数器,表示的是资源个数,其值可以通过两个原子操作来控制,分别是 P 操作和 V 操作
信号
特点:进程间通信机制中唯一的异步通信机制,可以在应用进程和内核之间直接交互,内核也可以利用信号来通知用户空间的进程发生了哪些系统事件
- 信号事件的来源主要有硬件来源(如键盘 Cltr+C )和软件来源(如 kill 命令)
- 一旦有信号发生,进程有三种方式响应信号 1. 执行默认操作、2. 捕捉信号、3. 忽略信号
捕捉信号:当信号发生时,我们就执行相应的信号处理函数。
不同主机
socket
特点:Socket 实际上不仅用于不同的主机进程间通信,还可以用于本地主机进程间通信
根据创建 Socket 的类型不同,分为三种常见的通信方式,
- 基于 TCP 协议的通信方式
- 基于 UDP 协议的通信方式
- 本地进程间通信方式。
线程间通信
同个进程下的线程之间都是共享进程的资源,只要是共享变量都可以做到线程间通信
所以对于线程间关注的不是通信方式,而是关注多线程竞争共享资源的问题,信号量也同样可以在线程间实现互斥与同步:
- 互斥的方式,可保证任意时刻只有一个线程访问共享资源;
- 同步的方式,可保证线程 A 应在线程 B 之前执行;
内存管理
早期,程序直接运行在物理内存上,直接操作物理内存,导致三个问题
- 地址空间不隔离:程序操作相同地址空间会造成互相影响甚至崩溃,而且安全性也得不到保证;
- 使用效率低:没有特别好的策略保证多个进程对超过物理内存大小的内存需求的满足;
- 程序运行地址不确定:程序运行时,都需要分配空闲区域,而空闲位置不确定,会带来一些重定位问题;
虚拟内存
计算机系统里任何问题都可以靠引入一个中间层来解决,内存管理就在程序和物理内存之间引入了虚拟内存
的概念;对进程地址和物理地址进行隔离;
- 我们程序所使用的内存地址叫做虚拟内存地址(Virtual Memory Address)
- 实际存在硬件里面的空间地址叫物理内存地址(Physical Memory Address)。
进程持有的虚拟地址会通过 CPU 芯片中的内存管理单元(MMU)的映射关系,来转换变成物理地址,然后再通过物理地址访问内存
虚拟地址空间
物理地址空间是有限的,虚拟地址空间可以是任意大小;
每个进程都有自己的虚拟空间,而物理内存只有一个,所以当启用了大量的进程,物理内存必然会很紧张,于是操作系统会通过内存交换技术,把不常使用的内存暂时存放到硬盘(换出),在需要的时候再装载回物理内存(换入)。
程序可以通过操作虚拟地址,把虚拟地址空间映射到物理地址空间; Linux通过缺页中断和调度机制,实现虚拟地址映射;
虚拟地址优点:
- 避免直接访问绝对物理内存地址,使得在多进程环境下,进程之间的内存地址不受影响,相互隔离
- 每个进程都被分配4GB虚拟内存;可使用比实际物理内存更大的地址空间。
分页和分段
虚拟地址和物理地址,主要通过分段
和分页
技术,进行映射;
程序地址:段号+页号+页内偏移;
段和页的区别:
- 段是信息的逻辑单位,根据用户的需要划分,段对用户是可见的; 页时信息的物理单位,为管理内存方便和划分的,对用户透明的。
- 段的大小不固定,根据功能觉得;页的大小固定,由系统觉得;
- 段向用户提供二维地址空间;页向用户提供一维地址空间;
- 段便于存储保护和信息共享;页的保护和共享受到限制;
- 分段和分页:分页的粒度更小;
分段:将程序分为代码段、数据段、堆栈段等;
- 分段机制下的虚拟地址由两部分组成,段选择子和段内偏移量。
- 段选择子里面最重要的是段号,用作段表的索引。段表里面保存的是这个段的基地址、段的界限和特权等级等。
- 物理地址=段基地址+ 偏移量
分页:将段分成均匀的小块,通过页表映射物理内存;
- 把虚拟内存地址,切分成页号和偏移量;
- 根据页号,从页表里面,查询对应的物理页号;
- 直接拿物理页号,加上前面的偏移量,就得到了物理内存地址。
演变过程
- 先出现分段,根据程序的逻辑角度,分成了栈段、堆段、数据段、代码段等
- 每个段的大小都不是统一的,这就会导致内存碎片和内存交换效率低的问题。于是引入分页,把虚拟空间和物理空间分成大小固定的页
- 为了解决简单分页产生的页表过大的问题,就有了多级页表,它解决了空间上的问题,但这就会导致 CPU 在寻址的过程中,需要有很多层表参与,加大了时间上的开销。
- 于是根据程序的局部性原理,在 CPU 芯片中加入了 TLB,负责缓存最近常被访问的页表项,大大提高了地址的转换速度。
页表实际上存储在 CPU 的内存管理单元 (MMU) 中
当进程访问的虚拟地址在页表中查不到时,系统会产生一个缺页异常,进入系统内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行。
分页的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理内存中。我们完全可以在进行虚拟内存和物理内存的页之间的映射之后,并不真的把页加载到物理内存里,而是只有在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去。
内存碎片
内存碎片:回收内存时,将内存块放入空闲链表中; 因内存越分越小,内存块小而多;当需要一块大内存时,尽管此时空闲内存综合可能满足需求,但过于零散,没有一个合适的内存块
内存碎片产生原因:分配内存时,不能将相邻内存合并;
解决内存碎片的方法:
- 小内存单独分配(内存池)、大内存由操作系统分配
- 外部碎片避免:伙伴系统算法
- 内部碎片避免:slab 算法
大端模式和小端模式
"大端"和"小端"表示多字节值的哪一端存储在该值的起始地址处
//大端模式:高字节存储在低地址中,低字节存放在高地址中。符合人正常逻辑
低地址 -----> 高地址
0x12 | 0x34 | 0x56
//小端模式:与大端相反,符合计算机逻辑
低地址 -----> 高地址
0x56 | 0x34 | 0x12
一开始是由于不同架构的CPU处理多个字节数据的顺序不一样
后来互联网流行,TCP/IP协议规定为大端模式;
大小端模式各有优势:
- 小端模式强制转换类型时不需要调整字节内容,直接截取低字节即可;
- 大端模式由于符号位为第一个字节,很方便判断正负。
判断大小端
基本思想室根据数据截断来判断是大端还是小端
//根据强制类型转换
BOOL IsBigEndian()
{
short a = 0x1234;
char b = *(char*)&a;
if(0x12 == b)
{
return TRUE;
}
return FALSE;
}
//利用联合体共享内存的特性,截取低地址部分
BOOL IsBigEndian()
{
union NUM
{
short a;
char b;
}num;
num.a = 0x1234;
if(0x12 == num.b)
{
return TRUE;
}
return FALSE;
}
大小端转换
通信协议中的数据传输、数组的存储方式、数据的强制转换等这些都会牵涉到大小端问题。如果字节序不一致,就需要转换
对于16位字数据
#define BigtoLittle16(A) (( ((uint16)(A) & 0xff00) >> 8) | \ (( (uint16)(A) & 0x00ff) << 8))
对于32位字数据
#define BigtoLittle32(A) ((( (uint32)(A) & 0xff000000) >> 24) | \ (( (uint32)(A) & 0x00ff0000) >> 8) | \ (( (uint32)(A) & 0x0000ff00) << 8) | \ (( (uint32)(A) & 0x000000ff) << 24))
TCP/IP采用大端字节序
由于不同的处理器可以配置成大端或者小端,使得不同主机之间的通信变得复杂。
如果存在数据网络传输,如果大小端模式不一致,如果不经过转换,必然会导致数据不致,出现错误。
为此,网络协议指定了字节序。TCP/IP协议栈采用大端字节序,所以应用程序有时需要再处理器的字节序与网络的字节序之间进行转换。
对于TCP/IP应用程序,提供了以下四个通用函数进行转换:
#include <arpa/inet.h>
uint16_t ntohs(n) // 16位数据类型网络字节顺序到主机字节顺序的转换
uint16_t htons(n) // 16位数据类型主机字节顺序到网络字节顺序的转换
uint32_t ntohl(n) // 32位数据类型网络字节顺序到主机字节顺序的转换
uint32_t htonl(n) // 32位数据类型主机字节顺序到网络字节顺序的转换
计组
机器码
机器码分为原码,反码,补码, 0只有在补码中表示形式才是唯一的
真值->原码:转换为二进制,加符号位(纯小数的符号位为小数点左边原个位),0为正,1为负
真值->反码:先转换为原码,正数反码=原码,负数反码=原码除符号位取反
真值->补码:正数补码=原码,负数补码=反码+1
意义
通过将符号位也参与运算的方法。我们知道,根据运算法则减去一个正数等于加上一个负数,即:1-1 = 1 + (-1) = 0, 所以机器可以只有加法而没有减法,这样计算机运算的设计就更简单了。
为了解决原码做减法的问题, 出现了反码:
于是补码的出现,解决了0的符号问题以及0的两个编码问题:
补码运算(+,-,*,/)与溢出判断
1.补码加减法:两个数均用补码表示,符号位也参与运算
当XY两个数异号,实际上做加法运算,不会溢出;当同号且结果为正且超过最大整数,为正溢,反之为负溢
2.补码乘除法
3.十进制整数的加法运算
1.8421码:逢二进一,当和大于9,+6校正
2.余三码:逢二进一,当和无进位则-3,有进位则+3
4.浮点数加减运算
- 对阶:阶码小的数的尾数右移,每右移一位,其阶码+1,直到两数的阶码相等位置
- 尾数加/减
- 尾数结果规格化:左规可以多次,右规只能一次
- 舍入
- 溢出判断:右规后,根据阶码符号
- 上溢,机器停止运算,做溢出中断处理
- 下溢,浮点数趋于零,机器按机器零处理
溢出检测方法
加减法:1.采用一个符号位 :X,Y为两个数的符号位,S为结果的符号位
溢出=XYS+XY~S
2.采用进位位 : Cs-符号位是否产生进位 C1-最高数值位产生的进位
溢出=Cs⊕C1
3.采用变形补码(双符号位补码):
S1S2=00:结果正数,无溢出 S1S2=01:结果正溢
S1S2=10:结果负溢 S1S2=11:结果负数,无溢出