作为一名有政治觉悟的、坚持政治思想正确的、胸口飘扬鲜红的工卡的程序员,我们要谨记这句话:

没有党就没有新中国,没有栈就没有过程调用。

是的,栈就是过程调用的基础。为保证顺利的过程调用,栈主要做了以下四点:

  • 保存函数参数。
  • 保存返回地址。
  • 保存寄存器。
  • 调整帧指针(ebp)和栈指针(esp)。

保存函数参数这是必须的,不多说了,当然有可能是寄存器保存,具体细节下面会聊到。保存返回地址,保存call指令的下一条指令的地址,ret指令会返回这一地址。保存寄存器这一步,主要是为了防止被调用者覆盖调用者的寄存器。而帧指针和栈指针之间的空间就是本次栈帧的空间,调整这两个指针以访问空间。

常见的栈结构如下:

计算机栈与队列有什么用(计算机体系)(1)

过程调用可以分为三个部分,初始化栈帧、主体、销毁栈帧。初始化栈帧和销毁栈帧部分涉及到的就是栈主要做的四点。主体部分涉及到的就是使用栈帧的部分。

常见的过程调用汇编如下:

计算机栈与队列有什么用(计算机体系)(2)

可以看到,过程调用的初始化和销毁部分是完全相反的。并且,栈在过程调用前后现场必须是一致的。

Calling-Convention(调用惯例)

就像平时写代码时会用到的协议模式,双方之间需要规定一些规则才能合力将一件事做好。过程调用也一样,需要调用者和被调用者遵守一组“协议”,以顺利地共同完成过程调用。Calling Convention就可以看做是这样的一组协议。Calling Convention主要包括以下三个方面:

  • 函数参数的传递方式和顺序。
  • 栈恢复现场的方式。
  • 函数返回值的传递方式。

先聊第一点,函数参数的传递顺序和方式。方式指的是参数是放在寄存器上还是栈上还是混合?ia32规定参数全部放在栈上,而x86-64则规定先放寄存器上,如果超过6个再放到栈上。这是因为ia32有8个通用寄存器,而x86-64有16个通用寄存器。函数的传递顺序指的是,从左至右存储还是从右至左存储。比较常见的Calling-Convention比如C语言默认的cdecl规定就是从右至左存储。

对于第二点,栈恢复现场的方式。要做到栈在过程调用前后现场一致这一点,就要将参数都从栈上pop掉。实际上,只要调整esp就可以。这件事可以由调用者做,也可以由被调用者做,大部分Calling Convention规定由调用者做。Calling Convention就要规定这一点。除了参数,被保存的寄存器也有自己的Calling Convention,被保存的寄存器可以被分为调用者保存或者被调用者保存,栈恢复现场的时候,被保存的寄存器也要按Calling Convention从栈上pop掉。这第二点是必须要做的,Calling Convention就是规定由调用者做还是被调用者做。

第三点,函数返回值的传递方式。几乎所有的Calling Convention都规定小于4字节的返回值,放到eax中。小于8字节、大于4字节的返回值,放到eax和edx中。若是更大的返回值,怎么办?总不能都放在寄存器中吧。那就不直接传递值,传递地址。调用者先在栈上分配足够大的空间,然后将空间的地址以隐式参数(implicit argument )的方式传递给被调用者,被调用者将返回值先复制到作为隐式参数的地址中,再将地址放到eax中。调用者将eax中地址的内容再复制到返回值中,这样通过一个中介空间和其地址就可以处理更大的返回值。

关于隐式参数再聊一点。支持面向对象的语言当中,比如,C 中的this,Objective-C中的Self,代表对象自己的变量都会作为方法的第一个隐式参数。

字节对齐(alignment)、字节陪衬(padding)

在聊堆之前,先铺垫一些字节对齐和字节陪衬内容,在后面会用到。字节对齐、字节陪衬都源于CPU读取存储器中的数据看上去是“随机访问”的,实则是按单位(chunk)访问的。最常见的是以4字节作为单位访问,也就是说CPU只能访问4的n倍到4的(n 1)倍地址的数据。那你应该就能看出问题了,如果数据起始地址不是4的n倍数,CPU就需要更多次的存储器访问。

数据的字节对齐指的是:数据的起始地址,应该正好落在CPU访问存储器地址的边界上(the address fall evenly on the processor’s memory access boundary)。更加详细的资料看IBM文档、WikiPedia、MicroSoft文档。而我们一般说数据对n字节对齐就是数据的起始地址正好落在n的整数倍上。

如果出现了不对齐的数据,最好的对齐方式就为起始地址的前一数据末尾添加字节,相当于将不对齐数据迁移(shift)到边界上,完成对齐。这就是字节陪衬,为数据末尾添加一些无意义的陪衬字节(insert some meaningless bytes),以对齐后面的数据。

这时,要明确字节对齐由谁来做?自然是编译器,编译器会帮你进行自然对齐,自然对齐会按数据类型的大小作为数据字节对齐的n。当然,还有强制对齐,你可以使用attribute((aligned()))指明对齐的n,C 中还可以使用alignas关键字。

那么,对于基本类型数据考虑自身对齐就可以了。如果是复合类型数据呢,比如说Array、Struct?对于复合类型数据有两条规则要遵守:

  • 保证每个元素字节对齐。
  • 保证自身字节对齐。

对于Array来说,将Array起始地址根据元素类型对齐就是保证自身字节对齐。如果Array中的元素是基础类型数据,只要Array自身字节对齐就已经满足每个元素对齐了。而如果是复合类型,复合类型自身要负责在Array中每个元素对齐,要保证复合类型数据大小能够整除Array自身对齐的n。

对于Struct来说,要遵守以下三点,实际上这三点都是保证每个元素字节对齐、保证自身对齐这两条规则的印证:

  • 为了保证每个元素字节对齐,元素要按照自己的offset自然对齐。
  • 为了保证自身对齐,Struct将包含的所有元素类型大小的最大值作为n进行对齐。
  • 当Struct在复合类型数据中,Struct作为复合类型数据的元素,为了保证复合类型数据中每个元素对齐,Struct的大小要整除Struct包含的所有元素类型大小的最大值n。

比如,如下Struct:

计算机栈与队列有什么用(计算机体系)(3)

按照第一条,long类型的元素l,起始offset是8而不是5。按照第二条,S1要对8进行对齐。

而如下Struct:

计算机栈与队列有什么用(计算机体系)(4)

按照第三条,S2的大小是12而不是9。

实际上,在我们使用Struct的时候,声明元素的顺序会影响Struct的真实大小。在使用Struct的时候要注意这一点。

数据除了栈这种随着过程调用分配、释放的管理方式,还需要要一种主动控制分配、释放时机的管理方式。这种管理方式就是堆,更加明确的来说是堆管理器。

注意,这里要明确非常关键的一点。堆管理器管理的是虚拟空间,并且仅将虚拟空间从未分配转换到已分配,等到真正使用再发生缺页中断,将虚拟空间从已分配转换到已缓存。这里如果有疑惑可以回顾一下上篇文章再看装载这一部分的这段话:

从虚拟空间来看,进程创建后,虚拟空间也被创建。此时,虚拟空间是未分配的,装载将映射记录在进程的vma中,就是将虚拟空间从未分配转换为已分配。等到缺页中断,再将虚拟空间从已分配转换到已缓存。

明确这一点后,可以思考这样的一个问题,堆分配空间和mmap有什么本质的不同?实则没有,都是向虚拟存储器申请一段空间,这里我们再提高一下领悟层次、总结一下。实际上,装载、mmap、堆都是虚拟存储器的“最佳实践(best practice)”。堆管理器实质上就是向操作系统申请资源,堆管理器有两种管理方式。有趣的是,其中一种就是由mmap实现的,直接将映射类型传入匿名空间(MAP_ANONYMOUS),操作系统就会挑一个合适的虚拟空间分配。这种管理方式操作系统职责较多、分配的空间碎片化。碎片化指的并不是单次分配空间内不是连续的,而是多次分配空间之间不是连续的。

而另一种管理方式操作系统职责较少、分配的空间连续化,连续化管理方式要先向操作系统“批发”适当的资源,只要“批发”下来的资源够使就从这些资源中”零售”,不够使再另“批发”资源,堆管理器就成了以计算机资源作为商品线上的“连锁超市”。连续化管理方式防止每次”零售”都要进行系统调用,只有“批发”的时候进行系统调用。连续化管理方式分配的空间从进程空间中的数据段结尾处开始,并向高地址增长。已分配的空间与未分配的空间之间由brk指针区分。

brk指针

“批发”被操作系统形式化成增加(increment)brk指针。直接调用Linux提供的brk和sbrk接口,进行系统调用,增加brk指针,等同于从操作系统申请资源。brk指针还有个有趣的作用,就是在Linux中可以通过它分辨地址是在栈上还是在堆上,详细点我。

glibc在分配的空间小于128kb时会使用连续化管理方式,大于时使用碎片化管理方式。下面主要聊聊操作系统职责较少的管理方式,操作系统职责较多的管理方式实现没有复杂度,调用mmap就好。

堆管理器设计

CRT(C Runtime Library)已经为我们实现了堆管理器,其相关API就是malloc、calloc、free、realloc,CRT实现的堆管理器自然是高效的、可靠的,无需我们再重复造轮子。但学习堆管理器的最好办法就是自行实现一个mini堆管理器,这里就聊聊如何设计一个堆管理器,聊一聊其背后的思想。

在设计堆管理器之前,我们要明确衡量堆管理器是否高效的标准。堆管理器有两个标准,一个是堆管理器的响应速度(吞吐率)、一个是存储器存储真正分配空间的比率(使用率)。这也就是说,在设计堆管理的时候不能为了节省空间使用过于复杂的算法拖慢响应速度,不能用过于复杂的数据结构,从而恢复性质导致拖慢响应速度,也不能过于浪费存储器空间。很明显,要在这两点之间做trade-off。

结构组织

首先,第一个要意识到的就是除了分配的空间,我们至少要记录下空间的大小、占用情况以管理空间。那么如何组织分配的空间和空间的信息?将分配的空间组织成metadata和payload的chunk形式。

Unix中为了让任意类型的数据字节对齐,对分配的空间以8进行字节对齐。我们在设计的时候,可以仅使整体chunk以8字节对齐,不需强制metadata和payload各自对齐,或者对metadata和payload都强制8字节对齐,这里我们使整体chunk以8字节对齐就可以了。如何做到呢?通过上文的字节陪衬,为每个chunk插入padding,使chunk size为8的整数倍。

强制对齐chunk以后,表示chunk大小的字段最低3位必定是000,这3位就可以用来记录chunk是否空闲。这样一个典型的组织如下:

计算机栈与队列有什么用(计算机体系)(5)

通过每个chunk的size就可以按顺访问chunk。实际上,这就将chunk组织成无需next指针域的链表。

分配和释放

分配:先要确定请求是否合理。然后,对齐请求的空间大小,查找链表中是否有size合适而且free的chunk,如果有直接将free置为1,并且返回payload地址。如果没有,要通过sbrk向操作系统申请空间或用早已申请好的空间,构建新的chunk。

释放:同样,先确定请求是否合理。然后找到chunk,将free置为0。

查找

查找有以下三种算法:

  • 首次适配(first fit):按顺序遍历链表,直到找到第一个合适的free chunk。
  • 下一次适配(next fit):从上次查找结构开始遍历链表,直到找到第一个合适的free chunk。
  • 最佳适配(best fit):遍历整个链表,找到合适的free chunk。

这里简单分析一下这三种算法,首次适配会导致整个链表的chunk size分布不均匀,小size chunk都集中在头部,大size chunk都集中在尾部,堆管理器的使用率还不错,吞吐率由于查找大size chunk不会太理想。下一次适配会使整个链表的chunk size较为平均,堆管理器的使用率不如首次适配,但是吞吐率会高于首次适配。而最佳适配有最好的使用率,最差的吞吐率。综合来看,建议使用首次适配,简单而高效。

碎片

然而,光是这样分配和释放会造成很多碎片。比如将一个大size的chunk分配给了一个小空间的申请造成剩余size浪费的内部碎片,或者多个小size的chunk无法响应一个大空间的申请而造成的外部碎片。这时,需要引入对chunk进行split(分割)和coalesce(合并)。

split比较简单,当first fit找到个合适的chunk后,只要chunk还有比最小chunk还大的空间,就在此空间上构建一个新的free chunk。

coalesce相对复杂一点,coalesce需要检查前一节点和后一节点是否free,如果free就合并。而合并就是累加所需coalesce的chunk size给coalesce中首个chunk就行。复杂的地方在,检查前一节点要求chunk有快速访问前一节点的能力。最简单的方式就是为chunk metadata添加一个prev前向指针域,实际上这就是转化为双向链表。还有一种方式就是边界标记法(boundary tag),为chunk添加footer,footer就是metadata的copy。这样每次chunk想要访问前一节点,只要访问metadata的前4字节就可以。并且只有free的chunk拥有footer就行,不需要所有chunk都拥有footer,相对于添加prev增大了使用率,则chunk组织更改如下:

计算机栈与队列有什么用(计算机体系)(6)

可以看到metadata和footer为8字节,payload加上padding大小必定是8的整数倍。

这里还有一个原因不使用添加prev前向指针域解决问题。在查找合适chunk的时候,需要查找所有chunk,完全不需要这样做。可以为所有free的chunk添加一个指向前一个free chunk的prev域,一个指向后一个free chunk的next域,来降低吞吐率,最后chunk组织更改如下:

计算机栈与队列有什么用(计算机体系)(7)

当然,还有更多的堆管理器设计方式,比如说用位图。还有一种比较有趣的分离存储(segregated storage),这种管理方式维护多个链表,每个链表的chunk size近似。这种方式就像是,按请求空间的大小作为请求模式去分离请求,每个链表只应对一种请求模式。

ABI(应用二进制接口)

ABI全名是application binary interface,实际上它也是一个“协议”,“协议”的主体有两种类型,一种是二进制代码、一种是承载二进制代码的操作系统。实际上,二进制代码与操作系统之间可以遵守同样的“协议”,二进制代码与二进制代码之间也可以遵守同样的“协议”。只要生成的二进制代码遵守这个“协议”,任何同样遵守这个“协议”的操作系统都可以运行,遵守同样“协议”的二进制代码可以一起运行,比如说不同语言需要遵守同样的“协议”才可以一起运行。ABI包括:

  • 基本类型大小、布局以及上文所提到的对齐。还包括面向对象语言中比如C 中对象的多重继承布局。
  • 上文提到的Calling Convention。
  • 系统调用的方法。
  • 目标文件格式的二进制格式、系统库格式。

而二进制代码要遵守ABI,主要的工作在汇编、链接阶段,比如说用同样的指令集、使用同样的符号修饰,更多详细信息参见。接下来的字节序就当是本篇文章的彩蛋吧O(∩_∩)O

字节序

字节序是计算机存储一个多字节数据(比如说int)的顺序,指的是字节与字节之间的顺序,而不是单个字节内的顺序。字节序分为大端法(big endian)和小端法(little endian)。很明显,大端法高位在前,小端法低位在前。比如一个int类型16进制数据为0×1234567,大端法和小端法分别如下:

计算机栈与队列有什么用(计算机体系)(8)

大端法和小端法没有明显的优劣,只要统一使用一种就不会有问题,如果不同字节序的计算机之间传送数据,网络应用程序就要做好转换,将四字节大端法转换为小端法如下:

计算机栈与队列有什么用(计算机体系)(9)

计算机栈与队列有什么用(计算机体系)(10)

,