学习《程序员的自我修养》一书,进行记录总结。本文为第十章的主要内容。本章主要讨论了程序内存布局、调用惯例、堆栈内存管理。

程序的内存布局

linux 2.4.x内存布局.png
基本上,进程的堆栈空间布局如上图所示。左边是单线程模型下的布局情况,右边是多线程下的情况,这也就从一方面说明了主要的操作系统的栈大小都是有限制的,也只有对栈空间的大小加以限制,才能够在单线程的内存布局下简单地加入多线程机制。linux下默认是8192K,进程递归次数过多,也就会报stack overflow。

栈与调用惯例

栈保存了函数调用所需要的维护信息,一个函数的栈空间一般包含了函数返回地址和调用参数,函数内局部变量,保存的上下文,一个函数的信息被称为堆栈帧或者活动记录。
对于参数和局部变量比较容易理解。而上下文指的是需要在进行函数调用返回后需要保持不变的寄存器。
书上介绍的i386cpu中使用到如下重要的寄存器。

  • esp指向栈顶,压栈esp向下走。
  • ebp指向函数活动记录的一个固定位置,一般来说是老的ebp,即调用当前函数的函数的那个ebp。
    栈活动记录.png

esp随着栈内局部变量和其他数据的扩充会进行扩展,而ebp在一个函数调用内始终保持不变指向老的ebp从而构成一条从栈底到当前函数活动记录的链表。在函数返回时ebp又被指向之前的ebp,从而能够快速恢复一个函数的活动记录。gcc可以通过--fomit-frame-pointer来取消ebp指针,好处是多获得一个寄存器,坏处是帧上寻址速度变慢,无法准确定位函数调用轨迹。
典型的函数调用汇编代码过程如下:

int foo(){
    return 123;
}
push ebp                        #保存旧的ebp,(jmp指令调用时即函数调用发生时,返回地址即ip寄存器被压入栈中)
mov ebp esp                     #栈指针前进
sub esp 0C0h                    #栈上开辟12字节空间,存3个32位寄存器
#------------------------------------------------------------------------
push ebx                        #压入三个寄存器变量,EBX 是"基地址"(base)寄存器, 在内存寻址时存放基地址。
push esi                        #源/目标索引寄存器
push edi
xxxxx                           #加入调试信息
#------------------------------------------------------------------------
mov eax 7Bh                     #函数的实际内容,直接返回,返回值通过eax寄存器传递,7Bh为16进制的123
#------------------------------------------------------------------------
pop edi                         #弹寄存器变量
pop esi
pop ebx
#------------------------------------------------------------------------
mov esp ebp                     #esp回退
pop ebp                         #恢复栈指针
ret                             #弹出最初压入的返回地址,将ip指针设置为弹出值

源/目标索引寄存器:这个是指在串操作指令里,如 movs/cmps/stos/lods这类指令里,esi 和 edi 的使用是固定的,比如 movs 是由 ds:[esi] 复制到 es:[edi] 处。在设置好相关寄存器后可以方便地使用CPU内建串指令进行字符串操作(其视还会配合ecx规定串操作的长度)。

调用惯例

即调用方的压入参数的顺序与函数内部认为的压入参数的顺序应该相同,为了相同,大家商量好到底该怎么压该怎么用。具体包含以下三点:

  • 函数参数的传递顺序和方式。
  • 栈的维护方式,压栈肯定由调用方来做,但是弹栈由谁来做?
  • 名字修饰策略,不同的调用惯例会对函数进行不同的名词修饰。
    默认的调用惯例是cdecl。而一个函数声明的完整形式是调用惯例加上函数声明。gcc中写法如下:
int foo(int m, int n)__attribute((cdecl));

cdecl特性:

  • 名字修饰:函数名前加一个下划线
  • 参数传递:从左向右压参数入栈
  • 出栈方:函数调用方。

返回值

eax是返回值传递的默认通道。eax只能传递4字节,对于大于四字节的参数传递。如调用方将被盗用方的返回值赋值给n变量,即n=called_func()
在C语言中,调用方会在栈上额外开辟空间,作为中转空间temp,然后temp的地址被作为参数传递给被调用方,被调用方负责将数据(自己可能还要开辟空间存放或者在堆上或者在栈上)填充到temp的地址中,然后temp的地址又被放到eax中。之后调用方将eax指向的temp拷贝给n
在C++中,大致类似,只不过数据到临时对象的拷贝被拷贝构造函数代替,临时对象到接受对象的拷贝被赋值运算符代替,而临时对象还需要进行析构。
总之,都需要两次拷贝,一次析构,析构在C中只涉及到esp的移动。

Linux进程堆管理

两种分配方式:brk,mmap两个系统调用。

  • brk:linux下数据段和BSS合并在一起统称数据段,将数据段的结束地址向高地址移动,那么扩大的部分就可以被使用。
  • mmap:向操作系统申请一块虚拟地址空间,mmap本来用来映射文件,但是不指定文件时就为匿名空间,可被用来做堆空间。

堆分配算法

空闲链表法

就是将堆中的各个空闲块按照链表的方式连接起来,链表节点至少包含prev,next。请求时遍历整个链表,找到合适的大小将其拆分,释放时合并到空闲链表中。

位图

将堆分块,每个块用两个位来表示其状态:11为head,10为主体,00为空闲。从而可以实现分配与释放。

对象池

按照请求的特征将内存分成多种小块,每次根据请求从特定小块中取出。而特定小块的实现可以是位图或者是空闲链表。
实际的分配策略是多种策略的混合,glibc:小于64字节的使用对象池方法,大于512的使用最佳适配算法(空闲链表?),大于128KB的申请直接用mmap。

标签: 编译链接, 程序员的自我修养

仅有一条评论

  1. 这里多线程stack模型有问题,多线程的栈是在进程堆里分出来的,待改正!

添加新评论