操作系统总结

这里写目录标题

操作系统

1.操作系统是什么

操作系统(operating system,简称OS)是管理计算机硬件与软件资源的计算机程序。操作系统需要处理如管理与配置内存、决定系统资源供需的优先次序、控制输入设备与输出设备、操作网络与管理文件系统等基本事务。操作系统也提供一个让用户与系统交互的操作界面。

2.malloc的使用原理

3.同步与异步

参考文章>>

同步:例如


funcA() {
    // 等待函数funcB执行完成
    funcB();
    
    // 继续接下来的流程
}

则在函数B运行结束前,函数A无法进行别的操作
在这里插入图片描述
一般情况下函数A和B是运行在两个线程中的,但并不是同步只出现在同一个线程中,还可以出现在不同的线程中,例如:


read(file, buf);

其执行流程为
在这里插入图片描述
同步调用:函数与被调函数是否运行在同一个线程是没有关系

异步:在异步这种调用方式下,调用方不会被阻塞,函数调用完成后可以立即执行接下来的程序。

异步调用总是和I/O操作等耗时较高的任务如影随形,像磁盘文件读写、网络数据的收发、数据库操作等,异步的重点就在于调用方接下来的程序执行可以和文件读取同时进行,从上图中我们也能看出这一点,这就是异步的高效之处。
对于异步调用,程序就出现了两种情况:
1.调用方根本就不关心执行结果
2.调用方需要知道执行结果

对于1,可以采用回调函数的方式来解决,也就是callback函数。

在这里插入图片描述
为什么这个函数要传递给数据库线程而不是数据库线程自己定义自己调用呢?
因为从软件组织结构上讲,这不是被调用函数该做的工作。

例如,数据库线程需要做的仅仅就是查询数据库、然后调用一个处理函数,至于这个处理函数做了些什么数据库线程根本就不关心,也不应该关心。

对于2,通常采用信号机制,当被调函数执行完任务后通过发送信号的方式来通知主动调用的一方任务完成,信号的实现方式有多种,例如Linux中的signal或者信号量机制。其执行流程为
在这里插入图片描述
大多数情况下,这种方式没有1高效,因为数据库查询线程比较清闲。

4.设计一个操作系统内核需要设计哪些功能

主要包括5个方面的管理功能:进程与处理机管理、作业管理、存储管理、设备管理、文件管理。
操作系统(Operating System,简称OS)是一管理电脑硬件与软件资源的程序,同时也是计算机系统的内核与基石。操作系统是一个庞大的管理控制程序,目前微机上常见的操作系统有DOS、OS/2、UNIX、XENIX、LINUX、Windows、Netware等。操作系统的功能主要体现在对计算机资源――微处理器、存储器、外部设备、文件和作业五大计算机资源的管理,操作系统将这种管理功能分别设置成相应的程序管理模块,每个管理模块分管一定的功能。即操作系统的五大功能。

1、微处理器管理功能
需要一个微处理器,同时可管理多个作业。负责选出其中一个作业进入主存储器难备运行,怎样为这个作业分配微处理器等等,要对系统中各个微处理器的状态进行登记,还要登记各个作业对微处理器的要求。管理模块还要用一个优化算法实现最佳调度规则。把所有的微处理器分配给各个用户作业使用。最终目的是提高微处理器的利用率。
2、内存管理功能
内存储器的管理,由内存管理模块来完成。内存管理模块对内存的管理分三步。首先为各个用户作业分配内存空间;其次是保护已占内存空间的作业不被破坏;最后,是结合硬件实现信息的物理地址至逻辑地址的变换。使用户在操作中不必担心信息究竟在四个具体空间――即实际物理地址,就可以操作,这样就方便了用户对计算机的使用和操作。需要设计一种优化算法对内存管理进行优化处理,以提高内存的利用率。

3、外部设备管理功能
设备管理模块的任务是当用户要求某种设备时,应马上分配给用户所要求的设备,并按照用户要求驱动外部设备以供用户应用。对外部设备的中断请求,设备管理模块要给以响应并处理。
4、文件管理功能
对文件的管理主要是通过文件管理模块来实现的。文件管理模块管理的范围包括文件目录、文件组织、文件操作和文件保护。
5、进程管理功能
也就是作业管理,作业管理是由进程管理模块来控制的,进程管理模块对作业执行的全过程进行管理和控制。

5.原子操作 是如何实现的

参考文章>>
参考文章>>
随着多核时代的到来,并发操作已经成了很正常的现象,操作系统必须要有一些机制和原语,以保证某些基本操作的原子性,比如处理器需要保证读一个字节或写一个字节是原子的,那么它是如何实现的呢?有两种机制:总线锁定和缓存一致性。
前提:为提高读取速度,CPU内会缓存内存中的部分数据,多CPU中每个CPU都会有缓存,当数据发生更新时,其他CPU内的缓存数据没有改变,出现同步问题。
1、使用总线加锁
顾名思义,总线锁就是用来锁住总线的,我们可以通过上图来了解总线在这个场景中所处的位置。当一个CPU核执行一个线程去访问数据做操作的时候,它会向总线上发送一个LOCK信号,此时其他的线程想要去请求主内存的时候,就会被阻塞,这样该处理器核心就可以独享这个共享内存。可以理解为,总线锁通过把内存和CPU之间的通信锁住,把并行化的操作变成了串行,这会导致很严重的性能问题,这与我们需要多核多线程并行操作来提高程序的效率的目的大相径庭。
所以,随着技术的发展,就出现了缓存锁。
2、使用缓存锁定
在同一时刻,我们只需保证对某个内存地址的操作时原子性即可,但总线锁定把CPU和内存之间的通信锁住了,开销比较大,目前处理器在某些场合下使用缓存锁定来进行优化

缓存一致性机制就整体来说,是当某块CPU对缓存中的数据进行操作了之后,就通知其他CPU放弃储存在它们内部的缓存,或者从主内存中重新读取,经典的MESI阐述原理如下:

MESI协议:是以缓存行(缓存的基本数据单位,在Intel的CPU上一般是64字节)的几个状态来命名的(全名是Modified、Exclusive、 Share or Invalid)。该协议要求在每个缓存行上维护两个状态位,使得每个数据单位可能处于M、E、S和I这四种状态之一,各种状态含义如下:

   M:被修改的。处于这一状态的数据,只在本CPU中有缓存数据,而其他CPU中没有。同时其状态相对于内存中的值来说,是已经被修改的,且没有更新到内存中。
    E:独占的。处于这一状态的数据,只有在本CPU中有缓存,且其数据没有修改,即与内存中一致。
    S:共享的。处于这一状态的数据在多个CPU中都有缓存,且与内存一致。
    I:无效的。本CPU中的这份缓存已经无效。

通过几种状态之间的配合则可以实现缓存锁定。
但是有两种情况处理器不会使用缓存锁定:
1、当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行时,处理器会使用总线锁定
2、有些处理器不支持缓存锁定。

内存管理

2.内存分区

在C++中,内存分成5个区
1.栈

由编译器在需要的时候分配,在不需要的时候自动清除的变量的存储区。里面的变量通常是局部变量、函数参数等。

2.堆

由malloc等分配的内存块,用free来结束自己的生命。
2.1.自由存储区
由new分配的内存块,他们的释放编译器不去管,由我们的应用程序去控制,一般一个new就要对应一个delete。如果程序员没有释放掉,那么在程序结束后,操作系统会自动回收。
堆是操作系统维护的一块内存,而自由存储是C++中通过new与delete动态分配和释放对象的抽象概念。堆与自由存储区并不等价。
new操作符从自由存储区(free store)上为对象动态分配内存空间,自由存储区是C++基于new操作符的一个抽象概念,凡是通过new操作符进行内存申请,该内存即为自由存储区。而堆是操作系统中的术语,是操作系统所维护的一块特殊内存,用于程序的内存动态分配,C语言使用malloc从堆上分配内存,使用free释放已分配的对应内存。自由存储区不等于堆,堆是一个实际的区域,而自由存储区是一个更上层的概念。

3.全局/静态存储区

全局变量和静态变量被分配到同一块内存中,在以前的C语言中,全局变量又分为初始化的和未初始化的,在C++里面没有这个区分了,他们共同占用同一块内存区。

4.常量存储区

这是一块比较特殊的存储区,他们里面存放的是常量,不允许修改。

5.代码区

函数代码被存放在代码区

1.LRU和LFU的数据结构

**LRU(最近最少使用)**实现采用双向链表 + Map 来进行实现。这里采用双向链表的原因是:如果采用普通的单链表,则删除节点的时候需要从表头开始遍历查找,效率为O(n),采用双向链表可以直接改变节点的前驱的指针指向进行删除达到O(1)的效率。使用Map来保存节点的key、value值便于能在O(logN)的时间查找元素,对应get操作。
map的key即为key,value为数据域为value的链表节点。
链表节点的数据结构为:key值,value值,头指针,尾指针。

LFU(最不经常使用),和LRU类似,但不同的在于缓存满了删除的时候,LFU是删除 使用次数(频率)最少的元素。
1.同样的,根据题目要求O(1)的解法,所以我们需要一个Hash表来进行元素的定位(缓存元素)。Map<Integer,Node> cache,内部储存当前缓存区内的元素。
2.然后我们采用多个双向链表,来存储每个频率有那些元素Map<Integer,DLinked> num_map,将其链接起来,链表head的就是当前频率中最久未使用的,tail的就是当前频率中最近使用的。每条链表内元素的使用频率相同,从头到尾表示上次使用离现在的时间越来越近,不同链表表示的使用频率不同,当cache内的元素大于缓存区大小时,将到表示最低频率的链表的头节点,确定其value,将cache中,表示value的节点删除,同时在对应链表中删除该节点。
3.我们需要一个min标记,用来标记当前最小的频率是几。
删除操作过程:通过min标记得到最小频率, 通过num_map.get(min)得到频率对应的双向链表DLinked,DLinked.head.next就是对应最近最少使用的Node, 然后进行相关删除操作即可。

8.Windows内存管理的几种方式

参考文章>>
参考文章>>
1.页式管理:

页式管理的基本原理是将各进程的虚拟空间划分为若干个长度相等的页;
页式管理把内存空间按照页的大小划分成片或者页面,然后把页式虚拟地址与内存地址建立一一对应的页表;并用相应的硬件地址变换机构来解决离散地址变换问题。
页式管理采用请求调页或预调页技术来实现内外存存储器的统一管理。不需要装入一个进程的所有页,每次只需将进程运行需要的页装入到内存中不一定连续的页框中,非驻留页在以后需要时自动调入内存。
优点:是没有外碎片,每个内碎片不超过页的大小。
缺点:程序全部装入内存,要求有相应的硬件支持。例如地址变换机构缺页中断的产生和选择淘汰页面等都要求有相应的硬件支持。这增加了机器成本,增加了系统开销。

2. 段式管理:

段式管理的基本思想是把程序按照内容或过程函数关系分段,每段都有自己的名字。
一个用户作业或进程所包括的段对应一个二维线形虚拟空间,也就是一个二维虚拟存储器。段式管理程序以段为单位分配内存,然后通过地址映射机构把段式虚拟地址转换为实际内存物理地址。不需要装入一个进程的所有段,每次只需将进程运行需要的段装入到内存中不一定连续的某些动态分区中,非驻留段在以后需要时自动调入内存。
优点:是可以分别编写和编译,可以针对不同类型的段采用不同的保护,可以按段为单位来进行共享,包括通过动态链接进行代码共享。
缺点:产生碎片。

3. 段页式管理:

为了实现段页式管理,系统必须为每个作业或进程建立一张段表以管理内存分配与释放、缺段处理等。
另外由于一个段又被划分成了若干个页。每个段必须建立一张页表以把段中的虚页变换成内存中的实际页面。显然与页式管理时相同,页表中也要有相应的实现缺页中断处理和页面保护等功能的表项。
段页式管理的段式管理与页式管理方案结合而成的所以具有他们两者的优点。 但反过来说,由于管理软件的增加,复杂性和开销也就随之增加了。另外需要的硬件以及占用的内存也有所增加。使得速度降下来。

9. 堆区和栈区的区别及使用场景

1. 内存分配

1.栈区(stack)
由编译器自动分配释放 ,存放函数的参数值局部变量的值等。其
操作方式类似于数据结构中的栈。

int main() {
	int b;				//栈
	char s[] = "abc"; 	//栈
	char *p2;			//栈
}

其中函数中定义的局部变量按照先后定义的顺序依次压入栈中,也就是说相邻变量的地址之间不会存在其它变量。栈的内存地址生长方向与堆相反由高到底所以后定义的变量地址低于先定义的变量,比如上面代码中变量 s 的地址小于变量 b 的地址,p2 地址小于 s 的地址。栈中存储的数据的生命周期随着函数的执行完成而结束。
2、堆区(heap)
一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回
收 。注意它与数据结构中的堆是两回事,分配方式类似于链表。

int main() {
	// C 中用 malloc() 函数申请
	char* p1 = (char *)malloc(10);
	cout<<(int*)p1<<endl;		//输出:00000000003BA0C0
	
	// 用 free() 函数释放
	free(p1);
   
	// C++ 中用 new 运算符申请
	char* p2 = new char[10];
	cout << (int*)p2 << endl;		//输出:00000000003BA0C0
	
	// 用 delete 运算符释放
	delete[] p2;
}

其中 p1 所指的 10 字节的内存空间与 p2 所指的 10 字节内存空间都是存在于堆。堆的内存地址生长方向与栈相反,由低到高,但需要注意的是,后申请的内存空间并不一定在先申请的内存空间的后面,即 p2 指向的地址并不一定大于 p1 所指向的内存地址,原因是先申请的内存空间一旦被释放,后申请的内存空间则会利用先前被释放的内存,从而导致先后分配的内存空间在地址上不存在先后关系。堆中存储的数据若未释放,则其生命周期等同于程序的生命周期。

3、全局区(静态区)(static)
全局变量和静态变量的存储是放在一块的。
初始化的全局变量和静态变量在一块区域,。
未初始化的全局变量和未初始化的静态变量在相邻的另一块区域。
程序结束后由系统释放。
4、文字常量区
常量字符串就是放在这里的。
程序结束后由系统释放。
5、程序代码区
存放函数体的二进制代码。

数据结构:
堆(数据结构):堆可以被看成是一棵树,如:堆排序
栈(数据结构):一种后进先出的的数据结构。

2.申请方式

栈:
由系统自动分配。 例如,声明在函数中一个局部变量 int b; 系统自动在栈中为b开辟空间。

需要程序员自己申请,并指明大小。
在c中malloc函数
如p1 = (char *)malloc(10);
在C++中用new运算符
如p2 = new char[10];
但是注意p1、p2本身是在栈中的。

3.分配方式

栈:
只要栈的剩余空间大于所申请空间,系统将为程序提供内存,否则将报异常提示栈溢
出。
堆:
关于堆上内存空间的分配过程,首先应该知道操作系统有一个记录空闲内存地址的链表,当系统收到程序的申请时,会遍历该链表,寻找第一个空间大于所申请空间的堆节点,然后将该节点从空闲节点链表中删除,并将该节点的空间分配给程序。
另外,对于大多数系统,会在这块内存空间中的首地址处记录本次分配的大小,这样,代码中的delete语句才能正确地释放本内存空间。
由于找到的堆节点的大小不一定正好等于申请的大小,系统会自动地将多余的那部分重新放入空闲链表。

区别:

堆与栈实际上是操作系统对进程占用的内存空间的两种管理方式,主要有如下几种区别:
(1)管理方式不同。
栈由操作系统自动分配释放,无需我们手动控制;堆的申请和释放工作由程序员控制,容易产生内存泄漏
(2)空间大小不同。
个进程拥有的栈的大小要远远小于堆的大小。理论上,程序员可申请的堆大小为虚拟内存的大小,进程栈的大小 64bits 的 Windows 默认 1MB,64bits 的 Linux 默认 10MB;
(3)生长方向不同。
堆的生长方向向上,内存地址由低到高;栈的生长方向向下,内存地址由高到低。
(4)分配方式不同。
堆都是动态分配的,没有静态分配的堆。栈有2种分配方式:静态分配和动态分配。静态分配是由操作系统完成的,比如局部变量的分配。动态分配由malloc函数进行分配,但是栈的动态分配和堆是不同的,他的动态分配是由操作系统进行释放,无需我们手工实现。
(5)分配效率不同。
栈由操作系统自动分配,会在硬件层级对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高则是由C/C++提供的库函数或运算符来完成申请与管理,实现机制较为复杂,频繁的内存申请容易产生内存碎片。显然,堆的效率比栈要低得多。
(6)存放内容不同。
最常见的是函数的调用过程由栈来实现**,函数返回地址、EBP、实参和局部变量都采用栈的方式存放。而堆中具体存放内容是由程序员来填充**的。

18.进程虚拟内存分布

进程的虚拟内存地址空间的分配模型
参考文章>>
参考文章>>
虚拟地址分为用户空间内存空间
用户空间:堆 栈 未初始化的数据(全局变量和局部静态变量)、已初始化的数据、执行指令。
内核空间:【内核代码和数据、物理存储器】(进程共享)、与进程相关的数据结构(页表、打开的文件、文件描述符表、打开的文件资源、task、mm结构=>负责管理进程的内存空间、内核栈 )。
操作操作系统用了3个数据结构来为每个进程管理它打开的文件资源:
文件描述符表:存储打开的文件描述符。
打开的文件表:存储打开文件的位置信息。
node指针表:inode记录了该文件的文件名,路径,访问权限等元数据。
在这里插入图片描述

  • 进程上下文切换保存的内容有:
    页表 – 对应虚拟内存资源
    文件描述符表/打开文件表 – 对应打开的文件资源
    寄存器 – 对应运行时数据
    信号控制信息/进程运行信息。
    内核堆栈
  • 进程通信方式:
    管道pipe
    命名管道FIFO
    共享存储SharedMemory
    信号量Semaphore
    套接字Socket
    信号 ( sinal )

2.线程:

  • 同一进程内的线程上下文切换需要保存的资源
    线程的id
    寄存器中的值
    栈数据
    程序计数器
  • 线程通信的方式
    参考文章>>
  • 1.全局变量方式:这是我们线程间通讯最常规的办法,因为全局变量的存储位置位于data段,没有存在于函数栈中,程序的其他函数都可以访问。需要添加volatile关键字,volatile提醒编译器它后面所定义的变量随时都有可能改变,因此编译后的程序每次需要存储或读取这个变量的时候,都会直接从变量地址中读取数据。如果没有volatile关键字,则编译器可能优化读取和存储,可能暂时使用寄存器中的值,如果这个变量由别的程序更新了的话,将出现不一致的现象。
  • 2.消息传递方式:我们可以在一个线程的执行函数中向另一个线程发送自定义的消息来达到通信的目的。一个线程向另外一个线程发送消息是通过操作系统实现的。利用Windows操作系统的消息驱动机制,当一个线程发出一条消息时,操作系统首先接收到该消息,然后把该消息转发给目标线程,接收消息的线程必须已经建立了消息循环。
    参考文章>>

参考文章>>
Linux虚拟内存管理
在这里插入图片描述

1.area:包含了连续的多个页,是Linux管理虚拟内存的重要方式
2.task_struct:内核为每个进程都维护了一个tast_struct,task_struct的mm指针指向了mm_struct结构。
3.mm_struct:描述了虚拟内存的运行状态,mm_struct的pgd指针指向该进程的一级页表的基地址,mmap指针指向vm_area_struct链表,即mmap为该链表的头节点。
4.vm_area_struct:用来描述area结构的链表,链表节点的几个重要属性如下:
vm_start:表示area开始位置
vm_end:表示area结束位置
vm_pro:描述area内的页的读写权限
vm_flags:描述area内的页面与其他进程共享还是进程私有
vm_next:指向下一个vm_area_struct节点
当发生缺页异常时
在Linux系统中,当MMU翻译一个虚拟地址发生缺页异常时,跳转到内核的缺页异常处理程序。

  • 1.Linux的缺页异常处理程序会先检查一个虚拟地址是哪个area内的地址。只需要比较所有area结构的vm_start和vm_end就可以知道。area都是一个连续的块。如果这个虚拟地址不属于任何一个area,将发生一个段错误,终止进程
  • 2.要访问的目标地址是否有相应的读写权限,如果没有,将触发一个保护异常,终止进程
  • 3.选择一个牺牲页,如果牺牲页被修改过,那么把它交换出去。从磁盘加载虚拟页内容到物理页,更新页表

虚拟内存到物理内存的转换

参考>>

想要把虚拟内存地址,映射到物理内存地址,最直观的办法,就是来建一张映射表。这个映射表,能够实现虚拟内存里面的页,到物理内存里面的页的映射。这个映射表,在计算机里面,就叫作页表。

页表地址转换,把一个内存地址分成页号(Directory) 和偏移量(Offset) 两个部分。以一个 32 位的内存地址,页的大小 4KB 为例,内存地址的 20 位的高位表示页号,12 位(212 = 4KB)的低位表示偏移量。
总结
对于一个内存地址转换,其实就是这样三个步骤:
把虚拟内存地址,切分成页号和偏移量的组合
从页表里面,查询出虚拟页号,对应的物理页号
直接拿物理页号,加上前面的偏移量,就得到了物理内存地址

25.共享内存

参考文章>>
在这里插入图片描述
共享内存时虚拟地址中的一块区域,不同的进程可以页表和MMU机制将他们映射到同一段物理内存中,因此他们就完成了对内存的共享。(图中的共享库的存储器映射区域)
特点
1.不需要数据复制,传输速度快。
2.不提供同步互斥机制,不能保证数据安全。

26.内存调度算法

1.最佳替换算法
替换下次访问距当前时间最长的页。opt算法需要知道操作系统将来的事件,显然不可能实现,只作为一种衡量其他算法的标准。

2.先进先出算法
将页面看做一个循环缓冲区,按循环方式替换。这是实现最为简单的算法,隐含的逻辑是替换驻留在内存时间最长的页。但由于一部分程序或数据在整个程序的生命周期中使用频率很高,所以会导致反复的换入换出。
3.最近最少使用
替换上次使用距离当前最远的页。根据局部性原理:替换最近最不可能访问到的页。性能最接近OPT,但难以实现。可以维护一个关于访问页的栈或者给每个页添加最后访问的时间标签,但开销都很大。
4.时钟替换算法
给每个页帧关联一个使用位。当该页第一次装入内存或者被重新访问到时,将使用位置为1。如果碰到使用位为1的帧,将使用位置为0,继续扫描,可以进行替换的条件为:标志位为0。如果所谓帧的使用位都为0,则替换第一个帧。

参考文章>>

27 磁盘调度算法

磁盘调度在多道程序设计的计算机系统中,各个进程可能会不断提出不同的对磁盘进行读/写操作的请求。由于有时候这些进程的发送请求的速度比磁盘响应的还要快,因此我们有必要为每个磁盘设备建立一个等待队列,常用的磁盘调度算法有以下四种。

1.先来先服务算法(FCFS)
是一种比较简单的磁盘调度算法。它根据进程请求访问磁盘的先后次序进行调度
优点:此算法的优点是公平、简单,且每个进程的请求都能依次得到处理,不会出现某一进程的请求长期得不到满足的情况。,各进程得到服务的响应时间的变化幅度较小。
缺点:此算法由于未对寻道进行优化。
2.最短寻道时间优先算法(SSTF):
该算法选择这样的进程,其要求访问的磁道与当前磁头所在的磁道距离最近,以使每次的寻道时间最短。
优点:该算法可以得到比较好的吞吐量,
缺点:但却不能保证平均寻道时间最短。其缺点是对用户的服务请求的响应机会不是均等的,因而导致响应时间的变化幅度很大。在服务请求很多的情况下,对内外边缘磁道的请求将会无限期的被延迟,有些请求的响应时间将不可预期。
3.扫描算法(SCAN)(电梯调度算法)):
扫描算法不仅考虑到欲访问的磁道与当前磁道的距离,更优先考虑的是磁头的当前移动方向。例如,当磁头正在自里向外移动时,扫描算法所选择的下一个访问对象应是其欲访问的磁道既在当前磁道之外,又是距离最近的。这样自里向外地访问,直到再无更外的磁道需要访问才将磁臂换向,自外向里移动。这时,同样也是每次选择这样的进程来调度,即其要访问的磁道,在当前磁道之内,从而避免了饥饿现象的出现。由于这种算法中磁头移动的规律颇似电梯的运行,故又称为电梯调度算法。
优点:克服了最短寻道时间优先算法的服务集中于中间磁道和响应时间变化比较大的缺点,而具有最短寻道时间优先算法的优点即吞吐量较大,平均响应时间较小。
缺点:但由于是摆动式的扫描方法,两侧磁道被访问的频率仍低于中间磁道。
4.循环扫描算法(CSCAN)
自里向外移动,当磁头移到最外的被访问磁道时,磁头立即返回到最里的欲访磁道,即将最小磁道号紧接着最大磁道号构成循环,进行扫描。

28.用户栈和内核栈的区别和切换

参考文章>>
一个进程有两个堆栈,用户栈和系统栈
用户堆栈的空间指向用户地址空间,内核堆栈的空间指向内核地址空间。

操作系统中有一个CPU堆栈指针寄存器。
进程运行的状态有用户态和内核态:
当进程运行在用户态时,CPU堆栈指针寄存器指向的是用户堆栈地址,使用的是用户堆栈;
当进程运行在内核态时,CPU堆栈指针寄存器指向的是内核堆栈地址,使用的是内核堆栈。

堆栈切换
当系统因为系统调用(软中断)或硬件中断,CPU切换到特权工作模式,进程陷入内核态,进程使用的栈也要从用户栈转向系统栈。

用户态到内核态切换的两个步骤
从用户态到内核态要两步骤,首先是将用户堆栈地址保存到内核堆栈中,然后将CPU堆栈指针寄存器指向内核堆栈。
当由内核态转向用户态,步骤首先是将内核堆栈中得用户堆栈地址恢复到CPU堆栈指针寄存器中。

当进程由于中断进入内核态时,系统会把一些用户态的数据信息保存到内核栈中,当返回到用户态时,取出内核栈中得信息恢复出来,返回到程序原来执行的地方。

用户栈和内核栈的区别

  • 内核栈是系统运行在内核态的时候使用的栈,用户栈是系统运行在用户态时候使用的栈。
  • 内核栈是属于操作系统空间的一块固定区域,可以用于保存中断现场保存操作系统子程序间相互调用的参数、返回值等。
  • 用户栈是属于用户进程空间的一块区域,用户保存用户进程子程序间的相互调用的参数、返回值等。

如果只设置一个栈,那么用户就可以修改栈内容来突破内核安全保护,如果使用一些危险的指令,对内核的安全造成威胁。

29.大端小端

参考文章>>
参考文章>>
参考文章>>

大端(存储)模式,是指数据的低位保存在内存的高地址中,而数据的高位保存在内存的低地址中
小端(存储)模式,是指数据的低位保存在内存的低地址中,而数据的高位保存在内存的高地址中
存在原因
每个地址单元都是以字节为单位,即8位,当所需存储的数据大于1字节时,就存在一个存储顺序的问题,因此就有了大小端的问题。
计算机内小端字节序:计算机电路先处理低位字节,效率比较高,因为计算都是从低位开始的,所以,计算机的内部处理都是小端字节序。
外部使用大端字节序:人类还是习惯读写大端字节序,所以,除了计算机的内部处理,其他的场合几乎都是大端字节序,比如网络传输文件储存

总结:大端模式就是数据从高字节到低字节在内存中排列,小端模式就是数据从低字节到高字节在内存中排列,数据本身字节是高字节在左,低字节在右。(如二进制8421)。
大小端检测方法
原理:由于数据对齐的原因,地址的增长方向是由小变大,c是1字节,i是四字节,联合体公用一块内存。在这里就是共用4字节的内存。c放在最开始的第一个字节,如果数据时小端存储,则1应该在c的位置,如果是大端存储,则c的位置上放的是0。

#include <iostream>
#include <stdio.h>
int check()
{
    union UN
    {
        char c;
        int i;
    }un;
    un.i = 1;
    return un.c;
}

int main(void)
{
    if(check()==1)
        printf("小端模式存储!\n");
    else
        printf("大端模式存储!\n");
    return 0;
}

30.函数调用的过称

参考文章>>
函数调用的过程可分三步
1)在栈中申请参数及函数体内定义的变量的内存空间
2)根据调用的函数名找到函数入口;
3)函数执行完后,返回值并释放函数在栈中的审请的参数和变量的空间

栈 地址由高到低,栈底在高地址,栈顶低地址,每当有新元素入栈,栈顶指针减小。

具体流程,例如在main函数中调用一个add函数,add内部实现两数相加并返回相加值。
(1)main函数开辟栈帧
1.main函数的栈帧开辟:将ebp(栈底指针)压栈,用于函数返回后的现场恢复,为当前函数的地址。
2.将原函数(对于main函数来说,原函数是_tmainCRTStartup()函数)的esp(栈顶指针)
指向ebp
3.esp减去0E4h(某个值),完成main函数的空间分配。
4.将ebx、esi、edi压栈:

  • ebx:基地址寄存器,用于内存寻址时存放基地址 esp减小
  • esi:源索引寄存器 esp减小
  • edi:目标索引寄存器 esp减小
    5.将edi指向的区域初始化为0cccccccc
    6.初始化变量,将值放入变量的地址中
    7.实参压栈,分配地址&a,&b,并将值放入地址中,逆序入栈 esp减小
    8.通过call指令调用add函数,调用之前会将call指令的下一条指令(next)的地址保存在栈中 esp减小
    (2)add函数开辟栈帧
    1.将main函数的ebp压栈,用于函数返回后的线程恢复 ebp
    2.执行于main函数相同的操作(分配空间,ebx,esi,edi入栈,初始化edi指向的区域)
    3.创建临时变量z,在edi指向的区域分配一个地址&z,将0放入&z。
    4.将形参a,b的值相加并保存在临时变量z中。
    5.将z中的结果保存在eax寄存器(累加器,是很多乘法加法指令的缺省寄存器)中。
    6.计算完成,结果通过eax带回到main函数中
    7.将edi esi ebx寄存器出栈,将esp指向ebp处,将ebp出栈。
    8.再调用ret指令执行一次出栈,此时出栈的是刚才保存的call函数的下一条指令next,并执行该指令。
    至此,整个add函数调用结束。

每次开辟新的栈帧时,先保存调用函数之后的下一条指令,再使用ebp保存上一个函数的地址,使用esp指向该地址并-0E4h为函数分配空间。

31.快表

快表, 也称地址变换高速缓存(TLB, translation lookaside buffer ) , 是一种访问速度比内存快很多的高速缓存(TLB不是内存! ) , 是寄存器,cpu访问寄存器的速度比内存快,用来存放最近访问的页表项的副本, 可以加速地址变换的速度。与此对应, 内存中的页表常称为慢表

  • 按照逻辑地址中的页号查快表
  • 若该页已存在快表中,则由页架号和单元号形成绝对地址
  • 若该页不在快表中,则再查主存页表,与单元号形成绝对地址,同时将该页登记到快表中
  • 当快表填满后,又要登记新页时,则需要按照一定替换策略淘汰一个旧的登记项

优点:在没有快表的情况下,一个虚拟地址要转成一个物理地址至少需要两次访问物理内存,第一次是查询内存中的页表,第二次是访问物理页框。如果有暂存着目标页表项的快表,则通过快表访问页表会比在内存中访问页表快很多,有利于缩短从一个虚拟地址转换成一个物理地址的过程的时间。

流程:
① CPU给出逻辑地址, 由某个硬件算得页号、 页内偏移量, 将页号与快表中的所有页号进行比较。
② 如果找到匹配的页号, 说明要访问的页表项(存放页号与页框号)在快表中有副本, 则直接从中取出该页对应的内存块号, 再将内存块号与页内偏移量拼接形成物理地址, 最后, 访问该物理地址对应的内存单元。 因此,若快表命中, 则访问某个逻辑地址仅需一次访存即可。
③ 如果没有找到匹配的页号, 则需要访问内存中的页表, 找到对应页表项, 得到页面存放的内存块号, 再将内存块号(页框号)与页内偏移量拼接形成物理地址, 最后, 访问该物理地址对应的内存单元。 因此,若快表未命中, 则访问某个逻辑地址需要两次访存(注意: 在找到页表项后, 应同时将其存入快表,以便后面可能的再次访问。 但若快表已满, 则必须按照一定的算法对旧的页表项进行替换)由于查询快表的速度比查询页表的速度快很多, 因此只要快表命中, 就可以节省很多时间。因为局部性原理, 一般来说快表的命中率可以达到 90% 以上。

TLB优势
当cpu要访问一个虚拟地址/线性地址时,CPU会首先根据虚拟地址的高20位(20是x86特定的,不同架构有不同的值)在TLB中查找。如果是表中没有相应的表项,称为TLB miss,需要通过访问慢速RAM中的页表计算出相应的物理地址。同时,物理地址被存放在一个TLB表项中,以后对同一线性地址的访问,直接从TLB表项中获取物理地址即可,称为TLB hit。

想像一下x86_32架构下没有TLB的存在时的情况,对线性地址的访问,首先从PGD(全局页表目录)中获取PTE(页表项page table entry)(第一次内存访问),在PTE中获取页框地址(第二次内存访问),最后访问物理地址,总共需要3次RAM的访问。如果有TLB存在,并且TLB hit,那么只需要一次RAM访问即可。

TLB表项
TLB内部存放的基本单位是页表条目,对应着RAM中存放的页表条目。页表条目的大小固定不变的,所以TLB容量越大,所能存放的页表条目越多,TLB hit的几率也越大。但是TLB容量毕竟是有限的,因此RAM页表和TLB页表条目无法做到一一对应。因此CPU收到一个线性地址,那么必须快速做两个判断:

1 所需的也表示否已经缓存在TLB内部(TLB miss或者TLB hit)

2 所需的页表在TLB的哪个条目内

为了尽量减少CPU做出这些判断所需的时间,那么就必须在TLB页表条目和内存页表条目之间的对应方式做足功夫
快表与页表项的映射方式
那问题就来了,CPU 如何知道目标页表项在不在快表里?在的话,是快表里的第几项?这就涉及到页表项与快表项的映射关系了。

页表项和快表项有三种映射方法,一是全相连,二是直接匹配,三是组相连。

  • 全相连:一个快表项可以存放任意虚拟地址对应的页表项。优点是快表空间利用率高,缺点是每次查询页表项都要遍历快表每一项。

  • 直接匹配:通过对每一个虚拟页号进行模运算得到其在快表中的索引号,但冲突率较高。

  • 组相连:将快表划分为若干个组,给每个组按顺序编号。通过对每一个虚拟页号进行模运算得到其在快表中的组号,然后在组内进行遍历查得目的页表项。如果每一组中有 n 个页表项,则称该方法为 n 路组相连。

简而言之,就是CPU先从缓存(快表)读取页表项,读不到则到页表中去找,找不到触发缺页中断来加载,加载完存到页表后同时更新缓存(快表)。通常快表处于MMU中。

32.Cache

概念:高速缓冲存储器是存在于主存与CPU之间的一级存储器, 由静态存储芯片(SRAM)组成,容量比较小但速度比主存高得多, 接近于CPU的速度。
高速缓冲存储器是存在于主存与CPU之间的一级存储器, 由静态存储芯片(SRAM)组成,容量比较小但速度比主存高得多, 接近于CPU的速度。

主要由三大部分组成
Cache存储体:存放由主存调入的指令与数据块。
地址转换部件:建立目录表以实现主存地址到缓存地址的转换。
替换部件:在缓存已满时按一定策略进行数据块替换,并修改地址转换部件

Chche存什么
为了充分发挥Cache的能力,使得机器的速度能够切实的得到提高,必须要保障CPU访问的指令或数据大多情况下都能够在Cache中找到,这样依靠程序访问的局部性原理。
CaChe与TLB的区别:TLB专门缓存存放在内存中的页表,容量相对较小,而cache则用于缓存普通内存,容量相对较大。

时间局部性: 当前正在使用的指令和数据,在不久的将来还会被使用到。那就是如果使用了指令和数据,将这些指令和数据放入到cache中,后面再用的时候直接从cache中获取。
空间局部性: 当前正在使用的指令和数据,在不久的将来相邻的数据或指令可能会被使用到。那就是,如果使用了指令和数据,需要将相邻的指令和数据也放入到cache中。
所以放入cache中的数据是以块为单位的,块包含了当前正在使用的指令和数据和相邻的指令和数据,块的大小要通过实验的方式进行确定。

33.malloc的实现原理

进程线程

关于进程与线程的形象化解释>>

4.进程与线程的区别及内存空间

  1. 进程拥有独立的虚拟内存地址空间和内核数据结构(页表,打开文件表等),物理内存是不同的,线程共享进程的虚拟地址空间和内核数据结构,以及堆和方法区(全局数据和静态数据以及程序文件)。
  2. 进程只能采用进程间的通信方式,线程只能采用线程间的通信方式。
  3. 进程上下文切换需要切换页表文件描述符表内核堆栈等重量级资源,线程上下文切换只需要切换寄存器程序计数器线程id各自的栈空间等轻量级数据。
  4. 进程是操作系统资源分配的基本单位,而线程是处理器任务调度和执行的基本单位。
  5. 一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃整个进程都死掉。所以多进程要比多线程健壮。

:是线程共有的空间,分全局堆局部堆。全局堆就是所有没有分配的空间,局部堆就是用户分配的空间。堆在操作系统对进程初始化的时候分配,运行过程中也可以向系统要额外的堆,但是记得用完了要还给操作系统,要不然就是内存泄漏。

:是每个线程独有的,保存其运行状态和局部自动变量的。栈在线程开始的时候初始化,每个线程的栈互相独立,因此,栈是线程安全的。操作系统在切换线程的时候会自动的切换栈,就是切换 SS/ESP寄存器。

1.进程:
进程的虚拟内存地址空间的分配模型
参考文章>>
参考文章>>
虚拟地址分为用户空间内存空间
用户空间:堆 栈 未初始化的数据(全局变量和局部静态变量)、已初始化的数据、执行指令。
内核空间:【内核代码和数据、物理存储器】(进程共享)、与进程相关的数据结构(页表、打开的文件、文件描述符表、打开的文件资源、task、mm结构=>负责管理进程的内存空间、内核栈 )。
操作操作系统用了3个数据结构来为每个进程管理它打开的文件资源:
文件描述符表:存储打开的文件描述符。
打开的文件表:存储打开文件的位置信息。
node指针表:inode记录了该文件的文件名,路径,访问权限等元数据。
在这里插入图片描述

  • 进程上下文切换保存的内容有:
    页表 – 对应虚拟内存资源
    文件描述符表/打开文件表 – 对应打开的文件资源
    寄存器 – 对应运行时数据
    信号控制信息/进程运行信息。
    内核堆栈
  • 进程通信方式:
    管道pipe
    命名管道FIFO
    共享存储SharedMemory
    信号量Semaphore
    套接字Socket
    信号 ( sinal )

2.线程:

  • 同一进程内的线程上下文切换需要保存的资源
    线程的id
    寄存器中的值
    栈数据
    程序计数器
  • 线程通信的方式
    参考文章>>
  • 1.全局变量方式:这是我们线程间通讯最常规的办法,因为全局变量的存储位置位于data段,没有存在于函数栈中,程序的其他函数都可以访问。需要添加volatile关键字,volatile提醒编译器它后面所定义的变量随时都有可能改变,因此编译后的程序每次需要存储或读取这个变量的时候,都会直接从变量地址中读取数据。如果没有volatile关键字,则编译器可能优化读取和存储,可能暂时使用寄存器中的值,如果这个变量由别的程序更新了的话,将出现不一致的现象。
  • 2.消息传递方式:我们可以在一个线程的执行函数中向另一个线程发送自定义的消息来达到通信的目的。一个线程向另外一个线程发送消息是通过操作系统实现的。利用Windows操作系统的消息驱动机制,当一个线程发出一条消息时,操作系统首先接收到该消息,然后把该消息转发给目标线程,接收消息的线程必须已经建立了消息循环。
    参考文章>>

5.线程安全(同步)

线程安全:假设有 4 个线程 A、B、C、D,当前一个线程 A 对内存中的共享资源进行访问的时候,其他线程 B, C, D 都不可以对这块内存进行操作,直到线程 A 对这块内存访问完毕为止,B,C,D 中的一个才能访问这块内存,剩余的两个需要继续阻塞等待,以此类推,直至所有的线程都对这块内存操作完毕。 线程对内存的这种访问方式就称之为线程同步,通过对概念的介绍,我们可以了解到所谓的同步并不是多个线程同时对内存进行访问,而是按照先后顺序依次进行的。

线程同步的方式:

互斥锁 读写锁 条件变量 信号量

6.Linux同步机制

1.原子操作
原子操作是由编译器来保证的,保证一个线程对数据的操作不会被其他线程打断。
原子操作的实现:?
2.自旋锁
参考文章>>
什么是自旋锁?
自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。

获取锁的线程一直处于活跃状态,但是并没有执行任何有效的任务,使用这种锁会造成busy-waiting。

它是为实现保护共享资源而提出一种锁机制。其实,自旋锁与互斥锁比较类似,它们都是为了解决对某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就说,在任何时刻最多只能有一个执行单元获得锁。但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,”自旋”一词就是因此而得名。

3.读写锁
读锁要实现多个进程能同时读同一数据结构,但是读的过程中不允许写。
写锁要实现仅能有一个进程获取写锁进入临界区,获取写锁时同时保证没有进程已经获取读锁。
4.顺序锁
与读写锁的区别的,赋予了写操作更高的优先级,顺序锁在进程写操作时,可以允许多个线程进行读操作,但不允许进行其他进程再进行写操作。写者可在线程读操作时直接写入。
如果读执行单元在读操作期间,写执行单元已经发生了写操作,那么,读执行单元必须重新开始,这样保证了数据的完整性
缺点:
顺序锁的缺陷在于,互斥访问的资源不能是指针,因为写操作有可能导致指针失效,而读操作对失效的指针进行操作将会发生意外错误。
 顺序锁在某些场合比读写锁更加高效,但读写锁可以适用于所有场合,而顺序锁不行,所以顺序锁不能完全替代读写锁。
5.信号量
信号量也是一种锁,和自旋锁不同的是,线程获取不到信号量的时候,不会像自旋锁一样循环区试图获取锁,而是进入睡眠,直至有信号量释放出来时,才会唤醒睡眠的线程,进入临界区执行。
由于使用信号量时,线程会睡眠,所以等待的过程不会占用 CPU 时间。所以信号量适用于等待时间较长的临界区。
信号量消耗 CPU 时间的地方在于使线程睡眠和唤醒线程。
6.互斥量
在这里插入图片描述
7.顺序和屏障
防止编译器优化我们的代码,让我们代码的执行顺序与我们所写的不同,就需要顺序和屏障。

7.线程状态

在这里插入图片描述

10.线程池

基础概念
线程池: 当进行并发的任务作业操作时,线程的建立与销毁的开销是,阻碍性能进步的关键,因此线程池,由此产生。使用多个线程,无限制循环等待队列,进行计算和操作。帮助快速降低和减少性能损耗。
线程池的组成
线程池管理器:初始化和创建线程,启动和停止线程,调配任务;管理线程池
工作线程:线程池中等待并执行分配的任务
任务接口:添加任务的接口,以提供工作线程调度任务的执行。
任务队列:用于存放没有处理的任务,提供一种缓冲机制,同时具有调度功能,高优先级的任务放在队列前面。
线程池工作的四种情况

  1. 没有任务要执行,缓冲队列为空
  2. 队列中任务数量,小于等于线程池中线程任务数量
  3. 任务数量大于线程池数量,缓冲队列未满
  4. 任务数量大于线程池数量,缓冲队列已满

线程池的设计原则:
1.尽量避免局部变量创建线程池
2.线程池大小和队列设置原则
3.最好能设计一个可监控的线程池

原则
当一个新的任务加入到workQueue时:

  • 如果此时线程池中的数量小于corePoolSize,即使线程池中的线程都处于空闲状态,也要创建新的线程来处理被添加的任务。
  • 如果此时线程池中的数量等于 corePoolSize,但是缓冲队列 workQueue未满,那么任务被放入缓冲队列。
  • 如果此时线程池中的数量大于corePoolSize,缓冲队列workQueue满,并且线程池中的数量小于maximumPoolSize,建新的线程来处理被添加的任务。
  • 如果此时线程池中的数量大于corePoolSize,缓冲队列workQueue满,并且线程池中的数量等于maximumPoolSize,那么通过 handler所指定的策略来处理此任务。也就是:处理任务的优先级为:核心线程corePoolSize、任务队列workQueue、最大线程maximumPoolSize,如果三者都满了,使用handler处理被拒绝的任务。
  • 当线程池中的线程数量大于 corePoolSize时,如果某线程空闲时间超过keepAliveTime,线程将被终止。这样,线程池可以动态的调整池中的线程数。

11.进程间通信方式和线程间通信方式

进程间通信方式:
管道的实质是一个内核缓冲区,进程以先进先出的方式从缓冲区存取数据:管道一端的进程顺序地将进程数据写入缓冲区,另一端的进程则顺序地读取数据,该缓冲区可以看做一个循环队列,读和写的位置都是自动增加的,一个数据只能被读一次,读出以后再缓冲区都不复存在了。当缓冲区读空或者写满时,有一定的规则控制相应的读进程或写进程是否进入等待队列,当空的缓冲区有新数据写入或慢的缓冲区有数据读出时,就唤醒等待队列中的进程继续读写

  • 管道pipe:管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。
  • 命名管道FIFO:有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信。它可以看成是一种特殊的文件,对于它的读写也可以使用普通的read、write 等函数。但是它不是普通的文件,并不属于其他任何文件系统,并且只存在于内存中
  • 消息队列MessageQueue:消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。
  • 共享存储SharedMemory:共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号量,配合使用,来实现进程间的同步和通信。
  • 信号量Semaphore:信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。
  • 套接字Socket:套解口也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同及其间的进程通信。
  • 信号 ( sinal ) : 信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。
    参考文章>>
    线程间通信方式:
    1.互斥锁
    mutex;
    lock_guard (在构造函数里加锁,在析构函数里解锁)
    unique_lock 自动加锁、解锁
    atomic 基本类型的原子操作
    2.读写锁
    3.条件变量condition_variable
    条件变量可以让等待共享数据条件的线程进入休眠,并在条件达成时唤醒等待线程(通知一个等待线程或通知多个等待线程),提供一种更高效的线程同步方式。条件变量一般和互斥锁同时使用,提供一种更高效的线程同步方式。
    4.信号量
    信号量用在多线程多任务同步的,一个线程完成了某一个动作就通过信号量告诉别的线程,别的线程再进行某些动作。信号量不一定是锁定某一个资源,而是流程上的概念,比如:有 A,B 两个线程,B 线程要等 A 线程完成某一任务以后再进行自己下面的步骤,这个任务并不一定是锁定某一资源,还可以是进行一些计算或者数据处理之类。

信号量(信号灯)与互斥锁和条件变量的主要不同在于” 灯” 的概念,灯亮则意味着资源可用,灯灭则意味着不可用。信号量主要阻塞线程,不能完全保证线程安全,如果要保证线程安全,需要信号量和互斥锁一起使用。

12.死锁的必要条件

1、互斥: 某种资源一次只允许一个进程访问,即该资源一旦分配给某个进程,其他进程就不能再访问,直到该进程访问结束。
2、占有且等待: 一个进程本身占有资源(一种或多种),同时还有资源未得到满足,正在等待其他进程释放该资源。
3、不可抢占: 别人已经占有了某项资源,你不能因为自己也需要该资源,就去把别人的资源抢过来。
4、循环等待: 存在一个进程链,使得每个进程都占有下一个进程所需的至少一种资源。

13.避免死锁的方法

===》
1、死锁预防 ----- 确保系统永远不会进入死锁状态
a、破坏“占有且等待”条件。
b、破坏“不可抢占”条件。
c、破坏“循环等待”条件。
2、避免死锁 ----- 在使用前进行判断,只允许不会产生死锁的进程申请资源
a、银行家算法的相关数据结构
3、死锁检测与解除 ----- 在检测到运行系统进入死锁,进行恢复。
每当一个线程获得了锁,会在线程和锁相关的数据结构中(map、graph等等)将其记下。除此之外,每当有线程请求锁,也需要记录在这个数据结构中。
4.设置加锁顺序(线程按照一定的顺序加锁)
按照顺序加锁是一种有效的死锁预防机制。但是,这种方式需要你事先知道所有可能会用到的锁(译者注:并对这些锁做适当的排序),但总有些时候是无法预知的。
5.加锁时限
另外一个可以避免死锁的方法是在尝试获取锁的时候加一个超时时间,这也就意味着在尝试获取锁的过程中若超过了这个时限该线程则放弃对该锁请求。若一个线程没有在给定的时限内成功获得所有需要的锁,则会进行回退并释放所有已经获得的锁,然后等待一段随机的时间再重试。这段随机的等待时间让其它线程有机会尝试获取相同的这些锁,并且让该应用在没有获得锁的时候可以继续运行(译者注:加锁超时后可以先继续运行干点其它事情,再回头来重复之前加锁的逻辑)。

6.一个更好的方案是给这些线程设置优先级,让一个(或几个)线程回退,剩下的线程就像没发生死锁一样继续保持着它们需要的锁。如果赋予这些线程的优先级是固定不变的,同一批线程总是会拥有更高的优先级。为避免这个问题,可以在死锁发生的时候设置随机的优先级。

14.死锁的解除方法

死锁的解除
如果利用死锁检测算法检测出系统已经出现了死锁 ,那么,此时就需要对系统采取相应的措施。常用的解除死锁的方法:
1、抢占资源:从一个或多个进程中抢占足够数量的资源分配给死锁进程,以解除死锁状态。
2、终止(或撤销)进程:终止或撤销系统中的一个或多个死锁进程,直至打破死锁状态。
a、终止所有的死锁进程。这种方式简单粗暴,但是代价很大,很有可能会导致一些已经运行了很久的进程前功尽弃。
b、逐个终止进程,直至死锁状态解除。该方法的代价也很大,因为每终止一个进程就需要使用死锁检测来检测系统当前是否处于死锁状态。另外,每次终止进程的时候终止那个进程呢?每次都应该采用最优策略来选择一个“代价最小”的进程来解除死锁状态。一般根据如下几个方面来决定终止哪个进程:
进程的优先级

  • 进程已运行时间以及运行完成还需要的时间
  • 进程已占用系统资源
  • 进程运行完成还需要的资源
  • 终止进程数目
  • 进程是交互还是批处理

15.死锁示例代码

#include <iostream>
#include <thread>
#include <mutex>
#include <unistd.h>

using namespace std;

int data = 1;
mutex mt1,mt2;

void a2() {
	mt2.lock();
	sleep(1);
	data = data * data;
	mt1.lock();  //此时a1已经对mt1上锁,所以要等待
	cout<<data<<endl;
	mt1.unlock();
	mt2.unlock();
}
void a1() {
	mt1.lock();
	sleep(1);
	data = data+1;
	mt2.lock();  //此时a2已经对mt2上锁,所以要等待
	cout<<data<<endl;
	mt2.unlock();
	mt1.unlock();
}

int main() {
	thread t2(a2);
	thread t1(a1);
	
	t1.join();
	t2.join();
	cout<<"main here"<<endl;  //要t1线程、t2线程都执行完毕后才会执行
	return 0;
}

17.并发模型

并发和并行都可以是相对于进程或是线程来说。并发是指一个或若干个CPU对多个进程或线程之间进行多路复用,用简单的语言来说就是CPU轮着执行多个任务,每个任务都执行一小段时间,从宏观上看起来就像是全部任务都在同时执行一样。并行则是指多个进程或线程同一时刻被执行,这是真正意义上的同时执行,它必须要有多个CPU的支持。
有多个CPU的现代计算机依靠并行并发机制能更快地执行任务,但是如何通过并发并行来执行一个任务是有很多种不同的方式的,即不同的并发模型。
1、Future模型
Future模型是将异步请求和代理模式结合的产物
2、Fork/Join模型
将任务分割成足够小的小任务,然后让不同的线程来做这些分割出来的小事情,然后完成之后再进行join,将小任务的结果组装成大任务的结果。
在这里插入图片描述
3、Actor模型
每个线程都是一个Actor,这些Actor不共享任何内存,所有的数据都是通过消息传递的方式进行的
一个Actor指的是一个最基本的计算单元。它能接收一个消息并且基于其执行计算。
尽管许多actors同时运行,但是一个actor只能顺序地处理消息。也就是说其它actors发送了三条消息给一个actor,这个actor只能一次处理一条。所以如果你要并行处理3条消息,你需要把这条消息发给3个actors。
消息异步地传送到actor,所以当actor正在处理消息时,新来的消息应该存储到别的地方。Mailbox就是这些消息存储的地方。
4、生产者消费者模型
核心是使用一个缓存来保存任务。开启一个/多个线程来生产任务,然后再开启一个/多个来从缓存中取出任务进行处理。
5、Master-Worker模型
核心思想:系统有两个进程协作工作
Master进程,负责接收和分配任务;
Worker进程,负责处理子任务。
当Worker进程将子任务处理完成后,结果返回给Master进程,由Master进程做归纳汇总,最后得到最终的结果。
在这里插入图片描述

29.协程

参考文章>>
参考文章>>
参考文章>>
参考文章>>
协程,英文Coroutines,是一种比线程更加轻量级的存在。正如一个进程可以拥有多个线程一样,一个线程也可以拥有多个协程。在合适的时机,我们可以把一个协程 切换到 另一个协程。只要这个过程中保存或恢复 CPU上下文那么程序还是可以运行的。
协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态执行)。

携程与线程的差异:线程切换从系统层面远不止保存和恢复 CPU上下文这么简单。操作系统为了程序运行的高效性每个线程都有自己缓存Cache等等数据,操作系统还会帮你做这些数据的恢复操作。所以线程的切换非常耗性能。但是协程的切换只是单纯的操作CPU的上下文,所以一秒钟切换个上百万次系统都抗的住。
协程可以理解为一种特殊的函数,可以在某个地方挂起,并且可以重新在挂起处继续运行。一个线程内的多个协程却绝对串行的,无论有多少个CPU(核)。
协程。它能保留上一次调用时的状态(即所有局部状态的一个特定组合),每次过程重入时,就相当于进入上一次调用的状态,换种说法:进入上一次离开时所处逻辑流的位置。普通过程(函数)可看成这个特殊过程的一个特例:只有一个状态,每次进入时局部状态重置。

协程的切换
每个协程池里面有一个调度器,这个调度器是被动调度的。意思就是他不会主动调度。而且当一个协程发现自己执行不下去了(比如异步等待网络的数据回来,但是当前还没有数据到),这个时候就可以由这个协程通知调度器,这个时候执行到调度器的代码,调度器根据事先设计好的调度算法找到当前最需要CPU的协程。切换这个协程的CPU上下文把CPU的运行权交个这个协程,直到这个协程出现执行不下去需要等等的情况,

优点:协程的切换很轻。保证了代码的 容易编写和可读性。在高IO密集型的程序下很好。
缺点
1.协程不适合计算密集型的场景。协程适合I/O 阻塞型。
2.无法使用 CPU 的多核,必须使用阻塞模式。

30.进程堆栈 线程堆栈的大小

进程堆栈
参考文章>>
32位Windows:一个进程栈的默认大小是1M,在vs的编译属性可以修改程序运行时进程的栈大小。
linux:ulimit -s 查看并修改默认栈空间大小,8M(可使用命令“ulimit -s 字节数”:临时修改栈空间大小,如下图所示)。
在这里插入图片描述
32位windows:进程的高位2G留给内核,低位2G留给用户,所以进程堆的大小小于2G。
Linux:进程的高位1G留给内核,低位3G留给用户,所以进程堆大小小于3G。
线程堆栈
通过ulimit -s 命令查看出的,默认的线程堆栈大小为8192:

31.fork()的返回值

参考文章>>

返回值:
负数:如果出错,则fork()返回-1,此时没有创建新的进程。最初的进程仍然运行。
:在子进程中,fork()返回0
正数:在负进程中,fork()返回正的子进程的PID

在执行完fork后,在子进程会完全copy父进程,并继续执行往后的语句,即对于fork后的语句,父子进程都会执行。

		#include <unistd.h>
        #include <stdio.h>
int main()
{
        pid_t pid;
        int count=0;
        pid = fork();
        printf( "This is first time, pid = %d\n", pid );
        printf( "This is second time, pid = %d\n", pid ); 
        cout<<"count = "<<count<<endl;
        count++;
        printf( "count = %d\n", count );
        if ( pid>0 )
        {
        printf( "This is the parent process,the child has the pid:%d\n", pid );
        } 
        else if ( !pid )
         {
        printf( "This is the child process.\n")
        }
        else

        {
        printf( "fork failed.\n" );
        } 
        printf( "This is third time, pid = %d\n", pid );
        printf( "This is fouth time, pid = %d\n", pid );
        return 0;
}

32.僵尸进程、孤儿进程

参考文章>>
孤儿进程:父进程先于子进程结束,由init进程接管孤儿进程,init进程pid = 1.
僵尸进程:子进程结束后,父进程没有及时获取子进程的状态信息,导致没有清理子进程的相关信息,该子进程就被称为僵尸进程。
僵尸进程已经放弃了几乎所有内存空间,没有任何可执行代码,也不能被调度,仅仅在进程列表中保留一个位置,记载该进程的退出状态等信息供其他进程收集。
如果父进程结束了,init进程会接管该僵尸进程,并为其释放进程信息。
僵尸进程危害:如果父进程一直不结束且不调用wait,则僵尸子进程会一直存在。如果有大量的僵尸进程驻在系统之中,必然消耗大量的系统资源。但是系统资源是有限的,因此当僵尸进程达到一定数目时,系统因缺乏资源而导致奔溃。
僵尸进程解决方式
1.暴力的做法是将其父进程杀死,代价比较大。
2.SIGCHLD信号
当子进程终止时,内核就会向它的父进程发送一个SIGCHLD信号,父进程可以选择忽略该信号,也可以提供一个接收到信号以后的处理函数。对于这种信号的系统默认动作是忽略它。我们不希望有过多的僵尸进程产生,所以当父进程接收到SIGCHLD信号后就应该调用 wait 或 waitpid 函数对子进程进行善后处理,释放子进程占用的资源。
当多个子进程关闭后都发送了SIGCHLD信号给父进程,由于发送SIGCHLD信号时父进程可能处于僵尸进程处理函数中,所以有些信号会丢失,所以需要改进进程回收函数。
未改进
并没有使用队列等方式来存储同一种信号

void deal_child(int num)
{
	printf("deal_child into\n");
	wait(NULL);
}

改进:
只有检验没有僵尸进程,才会返回0,这样就可以确保所有的僵尸进程都被杀死。

void deal_child(int sig_no)
 
{
    for (;;) {
        if (waitpid(-1, NULL, WNOHANG) == 0)
            break;
    }  
}

33.fork()进程

在这里插入图片描述
进程的数据区也就是未初始化的数据(.bss)、已初始化的数据(.data);进程的栈区也就是进程的用户栈和堆;进程程序代码就是进程的程序文件(.text);除此之外还有进程的系统堆栈区。

流程
函数fork()却是分裂出了两个进程:因为自函数fork()之后执行了两遍之后的代码(先子进程一次,后父进程一次)。同时,这也证明了父进程和子进程运行的是同一个程序,也正是这个理由,系统并未在内存中给子进程配置独立的程序运行空间,而只是简单地将程序指针指向父进程的代码;
两个进程具有各自的数据区和用户堆栈,在函数fork()生成子进程时,将父进程数据区和用户堆栈的内容分别复制给了子进程。同时,接下来的内容,父进程和子进程都是对自己的数据区和堆栈中的内容进行修改运算了。

参考文章>>

34.进程的执行

35.进程的五个状态

在这里插入图片描述
创建状态:进程在创建时需要申请一个空白PCB,向其中填写控制和管理进程的信息,完成资源分配。如果创建工作无法完成,比如资源无法满足,就无法被调度运行,把此时进程所处状态称为创建状态

就绪状态:进程已经准备好,已分配到所需资源,只要分配到CPU就能够立即运行

执行状态:进程处于就绪状态被调度后,进程进入执行状态

阻塞状态:正在执行的进程由于某些事件(I/O请求,申请缓存区失败)而暂时无法运行,进程受到阻塞。在满足请求时进入就绪状态等待系统调用

终止状态:进程结束,或出现错误,或被系统终止,进入终止状态。无法再执行

如果进程运行时间片使用完也会进入就绪状态。
另外为用户观察需要,进程还有挂起和激活两种操作。挂起后进程处于静止状态进程不再被系统调用,对于操作是激活操作。

36.多线程打印

int number = 1;
int maxnum = 10;
mutex mutex_num;

void addjishu()
{
    while(1)
    {
        mutex_num.lock();
        if(number >maxnum)
        {
            mutex_num.unlock();
            break;
        }
        if(number %2!=0)
        {
            cout<<"thread1:"<<number<<endl;
            number++;
        }
        mutex_num.unlock();
    }
    cout<<"thread1 finish"<<endl;
}


void addoushu()
{
    while(1)
    {
        mutex_num.lock();
        if(number >maxnum)
        {
            mutex_num.unlock();
            break;
        }
        if(number %2==0)
        {
            cout<<"thread2:"<<number<<endl;
            number++;
        }
        mutex_num.unlock();
    }
    cout<<"thread2 finish"<<endl;
}
int main()
{
        thread *th1 = new thread(addjishu);
        thread *th2 = new thread(addoushu);
        th1->join();
        th2->join();
}

37.互斥锁底层原理

Futex
参考文章>>
Futex按英文翻译过来就是 快速用户空间互斥体。其设计思想其实 不难理解,在传统的Unix系统中,System V IPC(inter process communication),如 semaphores, msgqueues, sockets还有文件锁机制(flock())等进程间同步机制都是对一个内核对象操作来完成的,这个内核对象对要同步的进程都是可见的,其提供了共享 的状态信息和原子操作。当进程间要同步的时候必须要通过系统调用(如semop())在内核中完成。可是经研究发现,很多同步是无竞争的,即某个进程进入 互斥区,到再从某个互斥区出来这段时间,常常是没有进程也要进这个互斥区或者请求同一同步变量的。但是在这种情况下,这个进程也要陷入内核去看看有没有人 和它竞争,退出的时侯还要陷入内核去看看有没有进程等待在同一同步变量上。这些不必要的系统调用(或者说内核陷入)造成了大量的性能开销。为了解决这个问 题,Futex就应运而生,Futex是一种用户态和内核态混合的同步机制。首先,同步的进程间通过mmap共享一段内存,futex变量就位于这段共享 的内存中且操作是原子的,当进程尝试进入互斥区或者退出互斥区的时候,先去查看共享内存中的futex变量,如果没有竞争发生,则只修改futex,而不 用再执行系统调用了。当通过访问futex变量告诉进程有竞争发生,则还是得执行系统调用去完成相应的处理(wait 或者 wake up)。简单的说,futex就是通过在用户态的检查, (motivation)如果了解到没有竞争就不用陷入内核了,大大提高了low-contention时候的效率。 Linux从2.5.7开始支持Futex。
2. Futex系统调用
Futex是一种用户态和内核态混合机制,所以需要两个部分合作完成,linux上提供了sys_futex系统调用,对进程竞争情况下的同步处理提供支持。
其原型和系统调用号为
#include <linux/futex.h>
#include <sys/time.h>
int futex (int *uaddr, int op, int val, const struct timespec *timeout,int *uaddr2, int val3);
#define __NR_futex 240

虽然参数有点长,其实常用的就是前面三个,后面的timeout大家都能理解,其他的也常被ignore。
uaddr就是用户态下共享内存的地址,里面存放的是一个对齐的整型计数器。
op存放着操作类型。定义的有5中,这里我简单的介绍一下两种,剩下的感兴趣的自己去man futex
FUTEX_WAIT: 原子性的检查uaddr中计数器的值是否为val,如果是则让进程休眠,直到FUTEX_WAKE或者超时(time-out)。也就是把进程挂到uaddr相对应的等待队列上去。
FUTEX_WAKE: 最多唤醒val个等待在uaddr上进程。

可见FUTEX_WAIT和FUTEX_WAKE只是用来挂起或者唤醒进程,当然这部分工作也只能在内核态下完成。有些人尝试着直接使用futex系统调 用来实现进程同步,并寄希望获得futex的性能优势,这是有问题的。应该区分futex同步机制和futex系统调用。futex同步机制还包括用户态 下的操作,我们将在下节提到。

  1. Futex同步机制
    所有的futex同步操作都应该从用户空间开始,首先创建一个futex同步变量,也就是位于共享内存的一个整型计数器。
    当 进程尝试持有锁或者要进入互斥区的时候,对futex执行"down"操作,即原子性的给futex同步变量减1。如果同步变量变为0,则没有竞争发生, 进程照常执行。如果同步变量是个负数,则意味着有竞争发生,需要调用futex系统调用的futex_wait操作休眠当前进程。
    当进程释放锁或 者要离开互斥区的时候,对futex进行"up"操作,即原子性的给futex同步变量加1。如果同步变量由0变成1,则没有竞争发生,进程照常执行。如 果加之前同步变量是负数,则意味着有竞争发生,需要调用futex系统调用的futex_wake操作唤醒一个或者多个等待进程。

这里的原子性加减通常是用CAS(Compare and Swap)完成的,与平台相关。CAS的基本形式是:CAS(addr,old,new),当addr中存放的值等于old时,用new对其替换。在x86平台上有专门的一条指令来完成它: cmpxchg

可见: futex是从用户态开始,由用户态和核心态协调完成的。

  1. 进/线程利用futex同步
    进程或者线程都可以利用futex来进行同步。
    对于线程,情况比较简单,因为线程共享虚拟内存空间,虚拟地址就可以唯一的标识出futex变量,即线程用同样的虚拟地址来访问futex变量。
    对 于进程,情况相对复杂,因为进程有独立的虚拟内存空间,只有通过mmap()让它们共享一段地址空间来使用futex变量。每个进程用来访问futex的 虚拟地址可以是不一样的,只要系统知道所有的这些虚拟地址都映射到同一个物理内存地址,并用物理内存地址来唯一标识futex变量。

小结:

  1. Futex变量的特征:1)位于共享的用户空间中 2)是一个32位的整型 3)对它的操作是原子的
  2. Futex在程序low-contention的时候能获得比传统同步机制更好的性能。
  3. 不要直接使用Futex系统调用。
  4. Futex同步机制可以用于进程间同步,也可以用于线程间同步。

参考>>
1 原子的“compare and set”操作原理
互斥锁是对临界区的保护,能否进入临界区即能否获取到锁,思路都是判断并置位一个标志位,这个标志位本身可以是一个普通的内存变量,关键在于,对于这个变量的"判断并置位"操作需要是原子的,例如假设有伪代码:

b=0;
a =0;
while a != 0;
a=1;
b++;
a=0;

如果有两个任务都执行这个代码,则b的值有可能不为2,因为在任务1判断a的时候,可能发生调度任务2也走到判断a的代码,这样此时两任务都判断a为0,均获取到锁,则均同步执行线程不安全的b++操作,也就是此时没有起到临界区保护的作用。这里的问题在于标志位a的判断和职位操作不是原子的,即中间可以被调度或者在无法保证多核同时执行该判断。至于怎么实现原子性就有很多方式了,对于单核平台,关中断防止调度即可,多核下理论上可以通过spinlock保护,但实际上一般更多通过原子汇编指令实现,比如x86的atomic_flag类型数据的tsl指令(test and set),可以用一条"无法继续分割的"汇编指令实现判断变量值并根据是否为0进行置位,**具体这个指令实现原子性一般通过锁总线实现,也就是我执行这条指令时,其它核都不能访问这个地址了,当然是原子了。**这就是mutex的原子性要求的底层原理。
2. 根据锁是否获得,来决定执行什么策略
如果锁持有,那么当然是继续往下,执行,如果锁没有获取到,如何处理呢?各种mutex以及变形的实现的花样主要就在这里了,常规的做法是获取不到则将当前任务挂起,并附在mutex变量对应的链表上,一旦有其他获取到锁的任务释放锁时,就查找锁上挂起的任务并唤醒。还有就是如果获取不到就回头再检查一次,如此反复,直到有一次突然发现可以持有了,这个其实就是自旋锁的实现思路。

汇总:设置标志位,并且该标志位的操作是原子类型的,判断标志位实现阻塞。标志位为false,要么挂起(互斥锁),要么一直询问(自旋锁)。

自己实现互斥锁
参考文章>>
参考文章>>

#include <iostream>
#include <atomic>
#include <thread>

class jiaoshouLock{

private:
	//原子类型的bool类型的值
    std::atomic_flag alock = ATOMIC_FLAG_INIT;

public:

    void lock(){
    //alock.test_and_set() 先test获取当前的值并返回,然后设置为true
    //当alock之前被设置为true,那么返回true则会阻塞,并再次设置为true,如果之前没有被设置过,那么返回false,并设置为true,不会阻塞。
        while(true == alock.test_and_set()){}
    }

    void unlock(){
        alock.clear();
    }

};


jiaoshouLock mylock;

void foo(int *x)
{
  while(*x<100)
  {
    mylock.lock();
    std::cout <<  "foo: " << *x << std::endl;
    ++(*x);
    mylock.unlock();
  }
}

void bar(int *x)
{
  while(*x<100)
  {
    mylock.lock();
    std::cout << "bar: " << *x << std::endl;
    ++(*x);
    mylock.unlock();
  }
}

int main()
{

  int i = 0;

  std::thread first (foo,&i);
  std::thread second (bar,&i);

  first.join();
  second.join();

  return 0;
}

38.进程的几种状态

R — TASK_RUNNING(可执行状态)
S — TASK_INTERRUPTIBLE(可中断的睡眠状态)
D — TASK_UNINTERRUPTIBLE(不可中断的睡眠状态)
T — TASK_STOPPED或TASK_TRACED(暂停状态或跟踪状态)
Z — TASK_DEAD - EXIT_ZOMBIE(退出状态,进程成为僵尸进程)
X — TASK_DEAD - EXIT_DEAD(退出状态,进程即将被销毁)

参考文章>>

39.进程D状态

如何关闭处于D状态的进程
进程出现D状态的原因:参考文章>>
1.重启
2.修改内核,遍历进程列表,找到处于D状态的进程,将其状态转换为别的状态,然后kill掉,此方法可能会引起一些不良后果。
参考文章>>

40.无锁编程

CAS算法 即compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS算法涉及到三个操作数

需要读写的内存值 V
进行比较的值 A
拟写入的新值 B
当且仅当 V 的值等于 A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试。

系统中断/调度/切换

3.CPU上下文切换

1.为什么要上下文切换

大于cpu核数的任务在系统上运行所以不可避免的出现cpu资源竞争,竞争CPU会导致 上下文切换
2.什么是CPU上下文切换

CPU在不同的任务之前切换需要保存任务的运行资源记录:CPU得知道从哪里去加载任务,又从哪里开始运行所以需要用到CPU寄存器程序计数器。在理解上面的基础上CPU上下文切换就是保存上一个任务运行的寄存器和计数器信息切换到加载下一个任务的寄存器和计数器的过程.
3.上下文切换的分类

  • 进程上下文切换
    什么是系统调用:Linux系统分为内核空间和 用户空间, 内核空间具有最高的访问权限可以直接访问所有资源;用户空间不能直接访问内存等硬件设备,只有通过系统调用陷入到内核空间才能访问磁盘等资源
    从 用户态到内核态的转变成为系统调用
    在发生系统调用的时候会触发两次CPU上下文切换,从用户态 -》 内核态 和 内核态 -》 用户态
    系统调用和进程上下文切换的区别

    系统调用都是在同一个进程中发生
    进程上下文切换是从一个进程切换到第二个进程中运行
    进程上下文切换除了需要保存 虚拟内存,栈,全局变量 等用户空间的资源,还包括 内核堆栈,寄存器等内核空间的状态

触发进程上下文切换的情景
1) 进程时间片用完,系统进行调度
2)进程申请的系统资源不足(比如内存不足)
3)调用 Sleep 函数主动挂起进程
4)当有优先级更高的进程运行时,CPU上的进程会被挂起。

  • 线程上下文切换:

线程与进程的区别: 线程是调度执行的基本单位,进程是资源**拥有(分配)**的基本单位 。

系统内核中的任务调度实际上调度的对象是线程,而进程只是给线程提供了虚拟内存,全局变量等资源,这些资源在线程上下文切换时是不需要修改的。

如果是同一个进程之间的线程上下文切换时,进程提供的全局资源不需要单独保存;如果是不同进程之前的线程上下文切换相对前一种会消耗资源是cpu更多。

  • 中断上下文切换:
    1)中断优先级会打断进程的正常调度和执行。
    2)对于同一个CPU来说,中断处理比进程拥有更高的优先级。

16.用户态切换到内核态的3种方式

与系统相关的一些特别关键性的操作必须由高级别的程序来完成,这样可以做到集中管理,减少有限资源的访问和使用冲突。CPU为了集中管理资源,一般会对不同的程序划分了不同的特权等级。0级最高,3级最低。Linux中,0级为内核态,3级为用户态。
用户态切换到内核态的三种方式

  • 1.发生系统调用时

    这是处于用户态的进程主动请求切换到内核态的一种方式。用户态的进程通过系统调用申请使用操作系统提供的系统调用服务例程来处理任务(因为系统调用处理程序都是运行在内核态下)。而系统调用的机制,其核心仍是使用了操作系统为用户特别开发的一个中断机制来实现的,即软中断。比如printf()函数

  • 2、产生异常时
    当CPU执行运行在用户态下的程序时,发生了某些事先不可知的异常,这时会触发由当前运行的进程切换到处理此异常的内核相关的程序中,也就是转到了内核态,如缺页异常

  • 3、外设产生中断时
    外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序,如果先前执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了由用户态到内核态的切换。比如硬盘读写操作的完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。

19.CPU/进程资源调度方法

1.FCFS/FIFO(First Come First Servered/First In First Out)先来先服务

先来先服务的调度算法,哪个任务先进来,就为哪个任务先服务。

优点:实现简单,谁先来谁先得到CPU使用权。
缺点:任务时间短的要等前面任务结束才能执行,如果前面任务时间较长,则导致短任务的周转时间也很长。

2.SJF(Short Job First)短作业优先

哪个任务的服务时间短就先调度哪个,减少以短任务为主的系统的平均周转时间。
短作业优先的调度算法的平均周转时间比先来先服务的调度算法平均周转时间要低。

优点:如果系统多是多任务,则每个任务的平均周转时间都减少。
缺点:没有考虑到需要快速响应的任务,比如在word中输入需要快速响应。

3.STCF(Shortest Time-to-Completion First)最短完成时间优先

与SJF相比,SJF是非抢占式的,即先开始执行的长任务如果不结束,那么还是轮不到短任务,短任务只能等。
STCF是抢占式的,只要来到的任务时间比正在执行的任务的任务时间短,它就可以去抢占CPU。

优点:短任务可以主动拿到CPU使用权,其任务平均周转时间小于SJF。
缺点:长任务可能一直得不到CPU使用权。

4.RR算法(Round Robin)

按时间片来轮转调度,为每个进程分配固定的CPU时间,轮转调度执行,这样每个进程的响应时间就变小。
5.优先权调度算法

系统将从后备队列中选择若干个优先权最高的作业装入内存。当用于进程调度时,该算法是把执行权分配给就绪队列中优先权最高的进程,这时,又可进一步把该算法分成如下两种。

  1. 非抢占式优先权算法
  2. 抢占式优先权调度算法

参考文章>>
参考文章>>
参考文章>>
参考文章>>

20.为什么并发可以提高效率

多线程通过提供CPU利用率来提高效率。数据库访问、磁盘IO等操作的速度比CPU执行代码速度慢很多,单线程环境下,这些操作会阻塞程序执行,导致CPU空转,因此对于会产生这些阻塞的程序来说,使用多线程可以避免在等待期间CPU的空转,提高CPU利用率。

23.系统中断

参考文章>>
中断是CPU中止现在正在处理的程序而转向处理另一些事件的操作,它是现代CPU能处理多进程的基石。
内核级别指令:启动I/O, 内存清零,修改cpu状态字,设置时钟,允许/禁止终端,停机。
用户级别指令:控制转移,算术运算, 访管指令。
解决问题:解决串行执行、系统资源利用低的问题。
中断的本质:发生中断就意味着需要操作系统介入,开展管理工作。

24.系统调用

系统调用函数由操作系统核心提供,运行于核心态;而普通的函数调用由函数库或用户自己提供,运行于用户态。
Linux内核中设置了一组用于实现各种系统功能的子程序,称为系统调用。用户可以通过系统调用命令在自己的应用程序中调用它们。用户程序主动请求由用户态切换到内核态。从某种角度来看,系统调用和普通的函数调用非常相似。区别仅仅在于,系统调用由操作系统核心提供,运行于核心态;而普通的函数调用由函数库或用户自己提供,运行于用户态。

25.函数可重入与线程安全

可重入函数
若一个程序或子程序可以安全的被并行执行,则称其为可重入(reentrant或re-entrant)的;即当该子程序正在运行时,可以再次 进入并执行它。若一个函数是可重入的,则该函数必须满足一下必要条件:
1、不能含有静态(全局)非常量数据。
2、不能返回静态(全局)非常量数据的地址。
3、只能处理由调用者提供的数据。 作为可重入函数的输入参数,只能由调用者提供,而且所提供的输入数据必须满足上面两点要求
4、不能依赖于单实例模式资源的锁。
5、不能调用不可重入的函数:函数内部,尽量不能用 malloc 和 free 之类的方法进行内存分配和释放,如果使用,一般情况下会造成该函数的不可重入。
参考文章>>

26 fread、fwrite与read、write的区别

参考文献>>
1.fread与read:fread是通过read实现的,fread是C语言的库函数,read是系统调用。
差别:read每次读的数据是调用者要求的大小,比如调用者要求读取10个字节数据,read就会从内核缓冲区(操作系统开辟的一段空间用来存储磁盘上的数据)读10个字节数据到数组中,所以每次调用read会涉及到用户态与內核态之间的切换从而损耗一定的性能。
fread每次都会从内核缓冲区读出比要求更多的数据,然后放到应用进程缓冲区(首地址存在FILE结构体中),这样下次再读数据只需要到应用进程缓冲区中去取而无需过多的系统调用。
2.fwrite与write
fwrite也是通过write来实现的,fwrite是C语言的库,而write是系统调用。
write:write每次写的数据是调用者要求的大小,比如调用者要求写入10个字节数据,write就会写10个字节数据到内核缓冲区中,所以依然涉及到用户态与內核态之间的切换,操作系统会定期地把这些存在内核缓冲区的数据写回磁盘中。
fwrite:fwrite每次都会先把数据写入一个应用进程缓冲区,等到该缓冲区满了,或者调用类似调用=fflush==这种冲洗缓冲区的函数时,系统会调用write一次性把相应数据写进内核缓冲区中。同样减少了系统调用(即write调用)。

注意:实际上write和read不会直接从磁盘文件中读写数据。例如read是从磁盘所关联的一个内核缓冲区读写数据。(因为磁盘读取数据速度实在太慢了,所以操作系统往往采用预读技术读取磁盘数据)。write也是仅将数据写进内核缓冲区,操作系统通过运行一个守护进程,定期的将内核缓冲区写入磁盘。

27 fopen fclose与open close

参考文章1>>
参考文章2>>
1.open

int open(const char *path, int access,int mode)
  access 访问模式,宏定义和含义如下:                        
        O_RDONLY         1    只读打开                         
        O_WRONLY         2    只写打开                         
        O_RDWR           4    读写打开   

open属于低级IO,返回一个文件描述符,无缓冲,通常与read、write等系统调用配合使用,open 是系统调用。一般用open打开设备文件。

FILE *fopen(char *filename, char *mode)
    filename 文件名称
    mode 打开模式:                                            
        r   只读方式打开一个文本文件                           
        rb  只读方式打开一个二进制文件                         
        w   只写方式打开一个文本文件                           
        wb  只写方式打开一个二进制文件  

fopen属于高级IO,返回一个文件指针,有缓冲,通常与 fread, fwrite配合使用,fopen是标准c函数。一般用fopen打开普通文件。

最主要区别:open和open最主要的区别是fopen在用户态下就有了缓存,在进行read和write的时候减少了用户态和内核态的切换,而open则每次都需要进行内核态和用户态的切换;表现为,如果顺序访问文件,fopen系列的函数要比直接调用open系列快;如果随机访问文件open要比fopen快。
fopen是在open的基础上扩充而来的,在大多数情况下,用fopen。
缓冲文件系统与非缓冲文件系统
1.缓冲文件系统
特点:在内存开辟一个“缓冲区”,为程序中的每一个文件使用,当执行读文件的操作时,从磁盘文件将数据先读入内存“缓冲区”, 装满后再从内存“缓冲区”依此读入接收的变量。执行写文件的操作时,先将数据写入内存“缓冲区”,待内存“缓冲区”装满后再写入文件。由此可以看出,内存 “缓冲区”的大小,影响着实际操作外存的次数,内存“缓冲区”越大,则操作外存的次数就少,执行速度就快、效率高。一般来说,文件“缓冲区”的大小随机器 而定。
fopen, fclose, fread, fwrite, fgetc, fgets, fputc, fputs, freopen, fseek, ftell, rewind等都具有缓冲区。
2.非缓冲文件系统
缓冲文件系统是借助文件结构体指针来对文件进行管理,通过文件指针来对文件进行访问,既可以读写字符、字符串、格式化数据,也可以读写二进制数 据。非缓冲文件系统依赖于操作系统,通过操作系统的功能对文件进行读写,是系统级的输入输出,它不设文件结构体指针,只能读写二进制文件,但效率高、速度 快,由于ANSI标准不再包括非缓冲文件系统,因此建议大家最好不要选择它。本书只作简单介绍。open, close, read, write, getc, getchar, putc, putchar 等。

编译原理:

21.C++程序编译的四个步骤

以一个例子说明
hello.h

#ifndef  HELLO_H
#define HELLO_H


void sayHello(int a,float b,int c);

#ifdef happy
	void sayHappy();
#else
	void sayAngry();
#endif

#endif


hello.cpp

#include "hello.h"
using namespace std;

void sayHello(int a, float b,int c)
{

int d= a +b+c;

}
#ifdef happy
void sayHappy()
{
}
else
void sayAngry()
{
}

main.cpp

#include "hello.h"
#define N 10
int main()
{
        int i = N+1;
        sayHello(1,2,3);
}

1.预处理阶段 原文>>

main.cpp经预处理后,得到main.i文件
指令 g++ -E main.cpp>main.i

# 1 "main.cpp"
# 1 "<built-in>"
# 1 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 1 "<command-line>" 2
# 1 "main.cpp"
# 1 "hello.h" 1
void sayHello(int a,float b,int c);

void sayAngry();
# 2 "main.cpp" 2

int main()
{
 int i = 10 +1;
 sayHello(1,2,3);
}
~


预处理器的主要作用就是把通过预处理的内建功能对一个资源进行等价替换,最常见的预处理有:文件包含,条件编译、布局控制和宏替换4种。

1,文件包含:#include 是一种最为常见的预处理,主要是做为文件的引用组合源程序正文。
2,条件编译:#if,#ifndef,#ifdef,#endif,#undef等也是比较常见的预处理,主要是进,行编译时进行有选择的挑选,注释掉一些指定的代码,以达到版本控制、防止对文件重复包含的功能。
3,布局控制:#progma,这也是我们应用预处理的一个重要方面,主要功能是为编译程序提供非常规的控制流信息。
4,宏替换: #define,这是最常见的用法,它可以定义符号常量、函数功能、重新命名、字符串的拼接等各种功能。

常用指令:
#define 宏定义
#undef 未定义宏
#include 文本包含
#ifdef 如果宏被定义就进行编译
#ifndef 如果宏未被定义就进行编译
#endif 结束编译块的控制
#if 非零就对代码进行编译
#else 作为其他预处理的剩余选项进行编译
#elif 这是一种#else和#if的组合选项
#line 改变当前的行数和文件名称
#error 输出一个错误信息
#pragma 为编译程序提供非常规的控制流信息

2.编译阶段 原文>>

先看下编译后的.s文件,命令 g++ -S main.cpp,程序编程了汇编代码

        .file   "main.cpp"
        .text
        .globl  main
        .type   main, @function
main:
.LFB0:
        .cfi_startproc
        pushq   %rbp
        .cfi_def_cfa_offset 16
        .cfi_offset 6, -16
        movq    %rsp, %rbp
        .cfi_def_cfa_register 6
        subq    $16, %rsp
        movl    $11, -4(%rbp)
        movl    $3, %esi
        movss   .LC0(%rip), %xmm0
        movl    $1, %edi
        call    _Z8sayHelloifi@PLT
        movl    $0, %eax
        leave
        .cfi_def_cfa 7, 8
        ret
        .cfi_endproc
.LFE0:
        .size   main, .-main
        .section        .rodata
        .align 4
.LC0:
        .long   1073741824
        .ident  "GCC: (Ubuntu 7.5.0-3ubuntu1~18.04) 7.5.0"
        .section        .note.GNU-stack,"",@progbits

编译程序的工作过程一般可以划分为五个阶段:词法分析、语法分析、语义分析与中间代码产生、优化、目标代码生成。

第一阶段:词法分析

词法分析的任务是:输入源程序,对构成源程序的字符串进行扫描和分解,识别出一个个的单词(亦称单词符号或简称符号),如基本字(begin、end、if、for、while),标识符、常数、运算符和界符(标点符号、左右括号)。

第二阶段:语法分析

语法分析的任务是:在词法分析的基础上,根据语言的语法规则,把单词符号串分解成各类语法单位(语法范畴),如“短语”、“句子”、“程序段”和“程序”等。通过语法分析,确定整个输入串是否构成语法上正确的“程序”。语法分析所依循的是语言的语法规则。

在这里插入图片描述

第三阶段:语义分析与中间代码产生

这一阶段的任务是:对语法分析所识别出的各类语法范畴,分析其含义,并进行初步翻译(产生中间代码)。这一阶段通常包含两个方面的工作。首先,对每种语法范畴进行语义安插,例如,变量是否定义、类型是否正确等等。如果语义正确,则进行另一方面工作,即进行中间代码的解释。这一阶段所依循的是语言的语义规则。通常使用属性文法描述语义规则。

中间代码”是一种含义明确、便于处理的记号系统,它通常独立于具体的硬件。这种记号系统或者与现代计算机的指令形式有某种程度的接近,或者能够比较容易地把它变换成现代计算机的机器指令。

四元式中间代码,按语言的语法规则把各类范畴翻译成四元式序列。
在这里插入图片描述
例如:Z = (X + 0.418) * Y / W;
在这里插入图片描述
T1 T2为中间变量

第四阶段:代码优化

优化的任务在于对前段产生的中间代码进行加工变换,以期在最后阶段能产生出更为高效(省时间和空间)的目标代码。

优化的主要方面有:循环优化、删除无用代码等等。
第五阶段:目标代码生成

这一阶段的任务是:把中间代码(或经优化处理之后)变换成特定机器上的低级语言代码(汇编)。这阶段实现了最后的翻译,它的工作有赖于硬件系统结构和机器指令含义。

符号修饰与函数签名参考文章>>
为了解决程序经编译后生成的符号名出线相同而导致冲突的情况,需要对函数名或者变量名进行修饰,以区别与库函数中相同的变量或函数名,最开始是函数名或变量前后加下划线,由于区别方式比较简单,程序较多,命名不规范时还是会出现冲突。
C++这样的后来者考虑了这个问题,增加了命名空间(namespace)来解决符号冲突。

复杂的C++拥有类、继承、虚机制、重载、命名空间等特性,这使得符号管理更为复杂。
C++允许函数重载,C++还在语言级别支持命名空间。由此我们引入了“函数签名(Function Signature)”,
函数签名包含了一个函数的信息,包括函数名、参数类型、参数数量、参数顺序及函数所在类和命名空间及其他信息。例如上面的.s文件中的函数签名 _Z8sayHelloifi@PLT,ifi即为参数。
在这里插入图片描述

具体的函数签名方法视不同的编译器而定。
例如:

	int func(int)    >>>>签名后>>>>    ?func@@YAHH@Z
    float func(float)    >>>>签名后>>>>    ?func@@YAMM@Z
    int C::func(int)    >>>>签名后>>>>    ?func@C@@AAEHH@Z
    int C::C2::func(int)    >>>>签名后>>>>    ?func@C2@C@@AAEHH@Z
    int N::func(int)    >>>>签名后>>>>    ?func@N@@YAHH@Z
    int N::C::func(int)    >>>>签名后>>>>    ?func@C@N@@AAEHH@Z

为了区分C++和C在混合编程时出现的不兼容问题,可以用extern "C"来告诉编译器这段代码需要用C的编译方式来编译。

  extern "c"
    {
        int func( int ) ;
        int var ;
    }extern "C" int func( int ) ;
    extern "C" int var;

C++编译器会把在extern "C"内的代码当作C语言代码处理,其中C++的名称修饰机制将不会起作用。

3 .汇编

查看.o文件,命令 g++ -c main.cpp
vim查看是乱码。。

^?ELF^B^A^A^@^@^@^@^@^@^@^@^@^A^@>^@^A^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@<90>^B^@^@^@^@^@^@^@^@^@^@@^@^@^@^@^@@^@^L^@^K^@UH<89>åH<83>ì^PÇEü^K^@^@^^@^@^@^^@^@^@^@ÉÃ^@GCC: (Ubuntu 7.5.0-3ubuntu1~18.04) 7.5.0^@^@^@^@^T^@^@^@^@^@^@^@^AzR^@^Ax^P^A^[^L^G^H<90>^A^@^@^\^@^@^@^\^@^@^@^@^@^@^@^[^@^@^@^@A^N^P<86>^BC^M^FV^L^G^H^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^A^@^@^@^D^@ñÿ^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^C^@^A^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^C^@^C^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^C^@^D^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^C^@^F^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^C^@^G^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^C^@^E^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@
^@^@^@^R^@^A^@^@^@^@^@^@^@^@^@^[^@^@^@^@^@^@^@^O^@^@^@^P^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@%^@^@^@^P^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@main.cpp^@main^@_GLOBAL_OFFSET_TABLE_^@_Z8sayHellov^@^@^@^@^@^@^@^P^@^@^@^@^@^@^@^D^@^@^@
^@^@^@üÿÿÿÿÿÿÿ ^@^@^@^@^@^@^@^B^@^@^@^B^@^@^@^@^@^@^@^@^@^@^@^@.symtab^@.strtab^@.shstrtab^@.rela.text^@.data^@.bss^@.comment^@.note.GNU-stack^@.rela.eh_frame^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@ ^@^@^@^A^@^@^@^F^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@@^@^@^@^@^@^@^@^[^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^A^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^[^@^@^@^D^@^@^@@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^B^@^@^@^@^@^@^X^@^@^@^@^@^@^@     ^@^@^@^A^@^@^@^H^@^@^@^@^@^@^@^X^@^@^@^@^@^@^@&^@^@^@^A^@^@^@^C^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@[^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^A^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@,^@^@^@^H^@^@^@^C^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@[^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^A^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@1^@^@^@^A^@^@^@0^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@[^@^@^@^@^@^@^@*^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^A^@^@^@^@^@^@^@^A^@^@^@^@^@^@^@:^@^@^@^A^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@<85>^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^A^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@O^@^@^@^A^@^@^@^B^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@<88>^@^@^@^@^@^@^@8^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^H^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@J^@^@^@^D^@^@^@@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^X^B^@^@^@^@^@^@^X^@^@^@^@^@^@^@   ^@^@^@^G^@^@^@^H^@^@^@^@^@^@^@^X^@^@^@^@^@^@^@^A^@^@^@^B^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^^@^@^@^@^@^@^@^H^A^@^@^@^@^@^@
^@^@^@^H^@^@^@^H^@^@^@^@^@^@^@^X^@^@^@^@^@^@^@  ^@^@^@^C^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^^A^@^@^@^@^@^@2^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^A^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^Q^@^@^@^C^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@0^B^@^@^@^@^@^@Y^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^A^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@
~

将汇编代码转换成二进制机器码的目标文件。
目标文件里有什么?
一般C语言的编译后执行语句都编译成机器代码保存在.text段中;已初始化的全局变量和局部静态变量都保存在.data段里;未初始化的全局变量和局部静态变量一般放在.bss段。

程序的指令和数据为何分开存放?
● 当程序被装载后,数据指令分别被映射到两个虚存区域,这两个虚存区域的权限可以被分别设置成可读写只读的。
● 对于现代的CPU来说,他们有着极其强大的缓存(cache)体系。指令区和数据区的分离有利于提高程序的局部性。现代CPU的缓存一般都被设计成数据缓存指令缓存分离,所以程序的指令和数据被分开存放对CPU的缓存命中率提高有好处。
● 最重要的原因是当系统中运行着多个该程序的副本时,它们的指令都是一样的,所以内存中只要保存一份该程序的指令部分。对于其他的只读数据也是一样的,程序中的图标、图片、文本等资源也是属于可共享的。

目标文件中的符号?
在目标文件中,我们将函数和变量统称为符号(symbol);函数名或变量名就是符号名(symbol name)。 每个目标文件都会有一个相应的符号表(symbol table),这个表里记录了目标文件中所用到的所有符号。每个定义的符号有一个对应的值,叫做符号值(symbol value),对于变量和函数来说,符号值就是它们的地址。将符号表中的所有符号进行分类,可得:
定义在本目标文件中的全局符号,可以被其他目标文件引用。比如其中定义的一些函数全局变量
● 在本目标文件中引用的全局符号,却并没有定义在本目标文件,这一般称为外部符号(external symbol)。最典型的就是Hello World中的printf。
段名,由编译器产生,它的值就是该段的起始地址。
● 局部符号,这类符号只在编译单元内部可见,如函数中的局部变量。这些局部符号对于链接过程没有作用,链接器往往忽略它们。
● 行号信息。
对于我们而言,最值得关注的就是全局符号,即上面的第一和第二类。因为链接过程只关心全局符号的相互粘合。

`

4.链接

参考文章1>>
编译后生成的obj文件为二进制文件,但是并不一定可以运行,因为其不一定含有main入口函数。链接器根据每个obj文件的信息(未解决符号信息和所包含的已定义符号信息)寻找各obj文件之间的联系,保证每一个未解决的符号都在其他obj文件中找到引用,并且没有重复定义的符号。然后根据obj文件之间的联系将所有的obj单元链接为一个整体,产生最后的可执行文件exe或库文件。检查各个文件之间是否有符号重定义或者缺定义是链接的一个重要作用,这也解释了为什么我们不在头文件中定义变量或者函数,因为头文件有可能被多个cpp文件包含,每个cpp文件否会被编译生成一个obj文件,这样就会导致在链接时出现符号重定义的错误。

下文参考连接>>
现代的编译和链接过程也并非想象中那么复杂。比如我们在程序模块main.c中使用另外一个模块func,c中的函数foo()。我们在main.c模块中每一处调用foo()的时候都必须确切知道foo()的地址,但由于每个模块都是单独编译的,在编译器编译main.c的时候并不知道foo的函数地址,所以它暂时把这些调用foo()的指令的目标地址搁置,等待最后链接的时候由链接器去将这些指令的目标地址修正。如果没有链接器,我们必须手工把每个调用foo()的指令进行修正,这是相当繁琐的工作。 将地址修正的过程也称“重定位(relocation)”,每个要修正的地方称为一个“重定位入口

链接的具体实现细节
和上面所说的一样,链接就是要确定目标文件中每个符号名的地址,也就是确定每个变量和函数的地址,不然就不知道这个变量将会是什么值,这个函数是怎么执行的。
现在的链接器一般采用一种叫两步链接(Two-pass Linking)的方法:
(1)空间与地址分配。
(2)符号解析与重定位。
空间分配一般采用相似段合并的方法,即将相同性质(如.text 、.data、.bss)的段合并到一起。合并后输入文件中各个段在链接完成后的虚拟地址就已经确定了,比如“.text”段起始地址为0x08048094。之后,链接器开始计算并确定各个符号的虚拟地址,因为各个符号在段内的相对位置是固定的。
外部符号怎么解决呢
当一个"*.c"被编译成目标文件时,编译器并不知道其中外部符号(引用外部的全局变量)的地址,因为它们定义在其他目标文件中。所以编译器就暂时把这些地址先搁置(比如直接填0),到链接时再对相关指令进行修正。
那么链接器是怎么知道哪些指令需要调整?这些指令的哪些部分要被调整?怎么调整
在目标文件中有一个叫重定位表(Relocation Table)的结构专门用来保存与重定位相关的信息。一个重定位表就是一个段,故重定位表也可成为重定位段。
每个要重定位的地方叫重定位入口(Relocation Entry)。
重定位过程中,每个重定位入口都是对一个符号的引用,那么当链接器对某个符号进行重定位时,它就要确定这个符号的目标地址。这时链接器就会去查找由所有输入目标文件的符号表组成的全局符号表,找到相应的符号后进行重定位
在链接器扫描完所有的输入目标文件之后,那些未定义的符号都应该能够在全局符号表中找到,如果存在不能找到地址的符号,链接器就报符号未定义错误。

对于静态链接和动态链接。
参考文章>>
参考文章>>

22.函数的调用过程

参考文章>>
大概流程:main函数的栈帧中,参数从右到左逆序压如栈中,为调用的函数创建栈帧,同时保存这个函数执行完后下一步要执行的指令地址,执行函数,返回返回值,执行下一步函数。参考>>

33.动态链接库和静态链接库的区别

静态链接库 xxxxx.lib

动态链接库的导入库 xxxxx.lib
动态链接库文件 xxxxx.dll
1、 静态链接库的后缀名为lib,动态链接库的导入库的后缀名也为lib。不同的是,静态库中包含了函数的实际执行代码,而对于导入库而言,其实际的执行代码位于动态库.dll文件中,导入库.lib只包含了地址符号表等,确保程序找到对应函数的一些基本地址信息;
2、由于静态库是在编译期间直接将代码合到可执行程序中,而动态库是在执行期时调用DLL中的函数体,所以执行速度比动态库要快一点;
3、 静态库链接生成的可执行文件体积较大,且包含相同的公共代码,每个生成的可执行文件中都包含一份静态你链接库的副本,造成内存浪费;
4、 使用动态链接库的应用程序不是自完备的,它依赖的DLL模块也要存在,如果使用载入时动态链接,程序启动时发现DLL不存在,系统将终止程序并给出错误信息。而使用运行时动态链接,系统不会终止,但由于DLL中的导出函数不可用,程序会加载失败;
5、 DLL文件与可执行文件文件独立,只要输出接口不变(即名称、参数、返回值类型和调用约定不变),更换DLL文件不会对EXE文件造成任何影响,因而极大地提高了可维护性和可扩展性适用于大规模的软件开发,使开发过程独立、耦合度小,便于不同开发者和开发组织之间进行开发和测试
参考文章>>

程序一般优先连接动态链接库,除非用-static参数指定链接静态库。

交叉编译

参考文章>>
一种计算机环境中运行的编译程序,能编译出在另外一种环境下运行的代码,我们就称这种编译器支持交叉编译。这个编译过程就叫交叉编译。简单地说,就是在一个平台上生成另一个平台上的可执行代码。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Michael.Scofield

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值