heap 内存管理之堆和栈

32位Linux系统,即起始地址为0x08048000,可以看到顺序为只读段(代码段等)、读写段(数据段、bss段等)、堆(向上即高地址扩展)、用于堆扩展的未使用空间、动态库的映射位置(0x40000000开始)、之后就是栈(向下即低地址扩展)以及用于栈扩展的未使用空间、最后是内核空间。

其中栈最大的不同就是从高地址向低地址扩展,同时有esp指针一直指向栈顶(低地址),另外栈的原则是先入后出。



栈保存了函数调用需要维护的信息,这称为堆栈帧或活动记录



即函数调用时会产生的一个在栈中保存的信息,其中使用ebp及esp两个寄存器来划定函数的活动记录。
esp始终指向栈顶部(esp指针一直指向栈顶(低地址)),即当前的函数的活动记录的顶部,而ebp则固定不动,其指向的是调用函数前ebp的数据,于是在调用完成后,ebp会重新获得调用前的值。



至于一个堆栈帧的结构是这样的原因就是因为调用函数时所需要进行的一系列操作导致的:



1、将所有参数或一部分参数入栈
2、将当前指令的下一条指令地址入栈(返回地址)
3、跳转到函数体执行,在函数体开始执行时还需要完成一部分操作:ebp入栈,将ebp指向esp(栈顶),分配所需字节的临时空间,保存寄存器



其中第2和3步在汇编代码中就是由call指令完成的,而在i386中函数体的开头的一般形式中可以看出第2和3步执行后还需要完成的那一部分操作





  1. push ebp /将old ebp入栈/




  2. mov ebp,esp /将ebp指向栈顶,经过上面的一步,此时esp即指向了old ebp的位置,因此完成将ebp寄存器指向old ebp的功能/




  3. sub esp,临时空间字节数 /可选,根据局部变量的具体情况来定/




  4. push寄存器名 /可选,即保存寄存器的值,对于多个寄存器来说可以重复多次/





因此可以想象,实际的调用函数完成后的返回过程与之前相反:
1、将寄存器出栈,还原需要保存的寄存器值
2、将ebp的地址赋给esp,即回收临时空间
3、ebp出栈,还原ebp值
4、ret 执行原来保存的当前指令的下条指令



对应于具体的汇编代码就是这样





  1. pop寄存器名 /对应于上面的最后一步操作,有n个寄存器就需要出栈n次/




  2. mov esp,ebp /将esp指向ebp指向的位置,简单来说就是将栈顶设置在了old ebp的具体存储位置上,那么堆与局部变量的部分就属于了栈外,相当于回收了局部变量占用的空间/




  3. pop ebp /将ebp的值恢复/




  4. ret /位于ebp上面的部分就是返回地址,从而ret命令获取返回地址,并跳转到该位置上,于是完成了一个函数的调用结束返回的工作/





对于上面调用一个函数的堆栈帧的过程大多数都是这样的情形,不过出现同时满足下面两种情况的话则不一定
1、函数声明为static(即函数仅能在此编译单元内访问)
2、函数在本编译单元中仅被直接调用,即没有任何函数指针指向过这个函数
这种情况下编译器能够确定在别的编译单元中不需要使用到这个函数,那么就可以随意修改这个函数的各个方面,其中也包括了进入与退出指令序列,从而达到优化的目的。



调用惯例
什么是调用惯例?简单来说就是调用方与被调用方约定好的一些规则,比方说函数参数的传递顺序和方式,栈的维护方式以及名字修饰等等。
一般来说C语言使用cdecl这个调用惯例,它是C语言默认的调用惯例,它的具体内容是:



1、参数传递方式:从右至左的顺序压参数入栈(对于一个foo(int m,int n )这样一个函数来说,参数入栈时的顺序就是先n后m)
2、出栈方:由函数调用方将参数等出栈(因此在前面的函数调用收尾的代码中,并没有出现对于返回地址上面的参数部分的出栈工作,它是由调用方完成的,对于被调用函数来说对其进行任何操作)
3、名字修饰:直接在函数名称前加1个下划线”_”



2、堆与内存管理
光有栈对于面向过程的程序设计还远远不够:栈上的数据在函数返回时就会被释放掉,因此栈无法将数据传递至函数外部;而全局变量能够将数据传递至函数外部却无法动态产生,只能在编译时定义,缺乏表现力。那么堆就是唯一的选择了。



堆时常占据虚拟空间的绝大部分,程序可以在其中申请一块连续内存并自由使用。



大家基本都知道如何申请一个堆空间,无非就是使用malloc函数,那么它到底是如何实现的呢?
有一个很简单的预想方法,将进程的内存管理交给操作系统内核去做,那么就需要提供一个系统调用,可以让程序调用它申请内存。
的确这样的方法能够行得通,但是这样每次申请堆空间的时候都需要进行系统调用,而系统调用对性能方面的开销很大,如果对堆的操作比较频繁,必然会影响到程序的性能。
如何解决这个问题?其实很简单,对文件的读写时用到缓存的技术来解决性能的问题,对于堆空间的申请问题在我看来也是一样,提前向操作系统申请一块适当大小的堆空间由程序自己管理就行了。实际的运行中,管理堆空间分配的往往是程序的运行库。



这样对堆的分配就有了大概的了解了,运行库向操作系统“批发”了较大的堆空间,零售给程序使用,当全部使用完或者程序有大量内存需求时,再向操作系统“进货”,当然,一件货物不能卖两次,于是运行库就需要有算法来管理堆空间,避免一段空间连续分配,这就是堆的分配算法,一般来说比较简单的有空闲链表、位图以及对象池等,不过很多实际的运用中,堆的分配算法往往采用多种算法复合而成。



Linux进程堆管理
最后了解下Linux进程堆如何管理。



首先,Linux下进程堆管理提供了两种堆空间分配的方式,即两个系统调用:brk( )及mmap( )
这就是前文中说的“批发”堆空间的手段或方法,那么两种有什么区别呢?
1、bar( )
brk的实际作用实际上是设置进程数据段的结束地址,即它可以扩大或者缩小数据。想象一下,我们将数据段的大小增大,那么数据段多出来的那部分我们就能够使用了,将这块空间作为堆空间是最常见的做法。
另外,glibc中还有个sbrk函数,其实就是bar的包装,通过brk实现。



2、mmap( )
mmap的作用就是向OS申请一段虚拟地址空间,当然这块空间可以映射到某个文件,但是当它不将地址空间映射到某个文件时,我们就将这块空间称为匿名空间。
来看下mmap( )系统调用





  1. void mmap( void *start, /申请空间的起始地址,为0时则OS自动挑选合适地址*/




  2. size_t length, /申请空间的长度/




  3. int prot, /申请空间的权限(可读、可写或可执行)/




  4. int flags, /申请空间的映射类型(文件映射、匿名空间),可以看到这里就指明了两种映射类型,后一种即我们所说的可以实现批发堆空间的方式/




  5. int fd, /文件映射时指定文件描述符/




  6. off_t offset) ; /文件映射时指定文件偏移/





那么我们基本了解了brk及mmap这两个申请内存空间的系统调用,最后来看看malloc如何处理用户的空间请求,基于前面的信息可以看出对于一定大小以内的空间申请时,是不会使用以上两个系统调用的。
实际上malloc对于小于128KB的请求,会在现有的堆空间根据堆分配算法分配一块空间并返回;大于128KB的请求则会使用mmap函数为其分配一块匿名空间,然后在这个匿名空间中为用户分配空间。
但是这里要注意,mmap是基于系统虚拟空间来申请函数的,申请空间的起始地址和大小必须是系统页的大小的整数倍,另外申请的空间大小不能超出空闲内存+空闲交换空间的总和。



前面说过实际情况下,堆分配算法往往采用多种算法合成,在glibc中就运用了这样的方法,malloc分配空间时不只是判断是否小于128KB,具体情况为:小于64B,采用对象池方法;大于512B采用最佳适配算法;大于64B但小于512B,根据情况选择最佳的折中策略,会在空闲链表、位图以及对象池几种算法中选择一种方法;最后,大于128KB则使用mmap机制直接向操作系统申请空间。



什么是堆



  光有栈对于面向过程的程序设计还远远不够,因为栈上的数据在函数返回的时候就会被释放掉,所以无法将数据传递至函数外部。而全局变量没有办法动态地产生,只能在编译的时候定义,有很多情况下缺乏表现力。在这种情况下,堆是唯一的选择。



  堆是一块巨大的内存空间,常常占据着整个虚拟空间的绝大部分。在这片空间里,程序可以请求一块连续内存,并自由地使用,这块内存在程序主动放弃之前都会一直保持有效。下面是申请空间最简单的例子。  



int main()
{
char p = (char)malloc(1000);
free (p)‘
}
  上面的程序用malloc申请了1000个字节的空间后,程序可以自由地使用这1000个字节,直到程序用free函数释放它。



  进程的内存管理并没有交给操作系统内核管理,这样做性能较差,因为每次程序申请或者释放对空间都要进行系统调用。我们知道系统调用的性能开销是很大的,当程序对堆的操作比较频繁时,这样做的结果是会严重影响程序性能的。比较好的做法就是程序向操作系统申请一块适当大小的堆空间,然后由程序自己管理这块空间,而具体来讲,管理着堆空间分配往往是程序的运行库。



  运行库相当于向操作系统批发了一块较大的堆空间,然后“零售”给程序用。当全部“售完”或程序有大量的内存需求时,在根据实际需求向操作系统“进货”。当然运行库在向零售堆空间时,必须管理它批发来的堆空间,不能把同一块地址出售两次,导致地址的冲突。我们首先来了解运行库是怎么向操作系统批发内存的。我们以linux为例。



Linux进程堆管理



  进程地址空间中,除了可执行文件、共享库和栈之外,剩余的未分配的空间都可以被用来作为堆空间。Linux下的进程管理稍微有些复杂,因为它提供了两种堆分配方式,即两个系统调用:一个是brk()系统调用,另外一个是mmap()。brk()的C语言形式声明如下:



int brk(void* end_data_segment)
  brk()的作用实际上就是设置进程是数据段的结束地址,即它可以扩大或者缩小数据段(Linux下数据段和BSS合并在一起统称为数据段)。如果我们将数据段的结束地址向高地址移动,那么扩大的那部分空间就可以被我们使用,把这块空间拿来作为堆空间是最常见的做法之一。Giblic中海油一个函数叫做sbrk,它的功能与brk类似,只不过参数和返回值略有不同。sbrk以一个增量作为参数,即需要增加(负数为减少)的空间大小,返回值是增加(或减少)后数据段结束地址,这个函数实际上是对brk系统调用的包装,它通过brk()实现的。



  mmap()的作用和Windows系统下的VirtualAlloc很相似,它的作用就是向操作系统申请一段虚拟地址空间,当然这块虚拟地址空间可以映射到某个文件(这也是系统调用的最初的作用),当它不将地址空间映射到某个文件时,我们又称这块空间为匿名空间,匿名空间就可以拿来做堆空间。它的声明如下:



void *mmap{void *start, size_t length, int prot, int flags, int fd,off_t offset);
  mmap的前两个参数分别用于指定需要申请的空间的起始地址和长度,如果起始地址设置为0,那么linux系统会自动挑选合适的起始地址。prot/flags这两个参数用于设置申请的空间的权限(可读,可写,可执行)以及映像类型(文件映射、匿名空间等),最后两个参数用于文件映射时指定文件描述符和文件偏移的,我们在这里并不关心它们。



  glibc的malloc函数是这样处理用户空间请求的:对于小于128kb的请求来说,它会在现有的堆空间里面,按照堆分配算法为它分配一块空间并返回;对于大于128KB的请求来说,它会使用mmap()函数为它分配一块匿名空间,然后再这个匿名空间中为用户分配空间。当然我们直接使用mmap也可以轻而易举地实现malloc函数:



void *malloc(size_t nbytes)
{
void *ret = mmap(0, nbytes, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, 0, 0);
if (ret == MAP_FAILED)
return 0;
return ret;
}
  由于mmap()函数与VirtualAlloc()类似,它们都是系统虚拟空间申请函数,它们申请的空间起始地址和大小都必须是系统页的大小的整数倍。



堆空间管理



  既然我们已经从操作系统“批发”一块内存用作堆,那么我们就要来思考如何管理这块大的内存。主要有三种方法,空闲链表和位图法以及对象池。



  



  空闲链表



  空闲链表(Free List)的方法实际上就是把堆中各个空闲的块按照链表的方式连接起来,当用户请求一块空间时,可以遍历整个链表,直到找到合适大小的块并且将它拆分;当用户释放空间时将它合并到空闲链表中。



  空闲链表是这样一种结构,在堆里的每一个空闲空间的开头(或结尾)有一个头,头结构里记录了上一个和下一个空闲块的地址,也就是说,所有的空闲块形成了一个链表。如下所示:



  技术分享



  在这样的结构下如何分配空间呢?首先在空闲链表查找足够容纳请求大小的一个空闲块,然后将这个块分为两部分,一部分为程序请求的空间,另一部分为剩余下来的空闲链表。下面将链表里对应原来空闲块的结构更新为新的剩下的空闲块,如果剩下的空闲块大小为0,则直接将这个结构从链表里删除。下图演示了用户请求一块和空闲块2恰好相等的内存空间后堆的状态。



  技术分享  



  位图



  位图的核心思想是将整个堆划分为大量的块,每个块的大小相同。当用户请求内存的时候,总是分配整数个块的空间给用户,第一个块我们称之为已分配区域的头,其余的称为已分配区域的主体。而我们可以使用一个整数数组来记录块的使用情况。由于每个块只有头/主体/空闲三种状态,因此仅仅需要两位即可表示一个块,因此称为位图。假设堆的大小为1MB,那么让一个块大小为128字节,那么总共就有1M/128=8k个块,可以用8k/(32/2)=512个int来存储。这有512个int的数组就是一个位图,其中每两位代表一个块。当用户请求300字节的内存时,堆分配给用户3个块,并将相应的位图的相应位置标记为头或躯体。



  下面是一个实例:



  技术分享



  这个堆分配了3片内存,分别有2/4/1个块,用虚线标出。其对应的位图将是:



  (HIGH) 11 00 00 10 10 10 11 00 00 00 00 00 00 00 10 11 (LOW)



  其中11表示H(头),10表示主体(Body),00表示空闲(Free)。



 



  对象池:



  以上介绍的堆管理方法是最为基本的两种,实际上在一些场合,被分配对象的大小是较为固定的几个值,这时候我们可以针对这样的特征设计一个更为高效的堆算法,称为对象池。



  对象池的思路很简单,如果每一次分配的空间大小都一样,那么就可以按照这个每次请求分配的大小作为一个单位,把整个堆空间划分为大量的小块,每次请求的时候只需要找到一个小块就可以了。



  对象池的管理方法可以采用空闲链表,也可以采用位图,与它们的区别仅仅在于它假定了每次请求的都是一个固定的大小,因此实现起来比较容易。由于每次总是只请求一个单位的内存,因此请求得到满足的速度非常快,无须查找一个足够大的空间。



  实际上很多现实应用中,堆的分配算法往往是采用多种算法复合而成。比如对于glibc来说,它对于小于64字节的空间申请时采用类似于对象池的方法;而对于大于512字节的空间申请采用的是最佳适配算法;对于大于64字节而小于512字节的,它会根据情况采用上述方法中的折中策略;对于大于128KB的申请,它会使用mmap机制直接向操作系统申请空间。
  
 
 1.进程直接使用物理内存的坏处:第一、地址空间不隔离,一个进程可能改写另一个进程的数据,从而导致系统崩溃。第二、内存使用效率低,频繁的数据换入换出,效率低。第三、程序运行地址不稳定,每次重新装载的空闲区域位置不确定。



2.虚拟地址和物理地址是为了隔离进程。



3.分段使用一段虚拟空间和物理空间的映射解决了地址空间隔离和程序运行地址稳定的问题。但是内存使用效率因为内存不足时换入换出到磁盘的还是整个程序,因此会有大量的磁盘访问操作,粒度比较大。程序在运行时候,往往只会访问部分数据,大部分数据是不会被频繁的使用的,因此将粒度更小化,局部性原理得到利用,就有了分页机制。



4.Linux中的缺页异常机制,其实这本质上是一种动态分配内存的机制,然后每个页有自己的属性,利用MMU来进行页映射。



5.线程是轻量级的进程,一个标准的线程是由线程ID、当前指令指针PC、寄存器集合和堆栈组成,线程共享程序的内存空间,包括代码段,数据段,堆等。



6.多线程的好处:多线程可以有效利用一个线程等待的时候去做其他的事情;长时任务,一个线程负责计算,一个线程负责交互;适应多核或者多cpu计算机;共享数据效率高。



7.线程私有数据为局部变量、TLS局部存储、函数参数,共享的为全局变量、堆数据、函数静态变量、程序代码和打开的文件。



8.线程的状态有运行、就绪和等待。



9.频繁计算的线程叫CPU密集型线程,频繁等待的线程叫I/O密集型线程,后者相对来说更加受欢迎。



10.fork和execl,写时复制机制,LDDR3讲的很清楚。



11.i++这个指令转化成汇编的时候不只是一条指令,因此有可能在执行到一半的时候被打断,单指令操作叫做原子操作。



12.临界区的作用范围仅限于本进程,其他的进程无法获取该锁。其余性质和互斥量基本相同。



13.一个函数可重入,表示这个函数没有执行完成,由于外部因素或者内部调用又一次进入函数执行,一个函数要被重入,有两种情况:第一、多个线程同时执行这个函数。第二、函数自身(可能是经过多层调用之后)调用自身。



14.可重入的特点。。。,保障并发安全。



15.volatile关键字可以制图组织过度优化,这个在很多寄存器操作中用到过。



16.linux内核代码中使用了众多的if语句,这里得到解释,原来是可以将lock的调用开销降低到最小,具体为什么,后期揣摩。



17.barrier指令是用于阻止cpu将该指令之前的指令交换到之后,linux内核源码中也见到过。



18.一对一的线程模型让多线程程序在多处理器的系统上具有更好的表现,但是内核限制内核线程的数量,一对一线程会让用户的线程数量受到限制,另外上下文切换开销较大,



19.多对一的线程模型可以拥有无上限的线程,上下文切换效率高,但是多处理器性能不会有明显提升,并且当一个阻塞,其余的都会阻塞。



1.gcc是后台程序的包装,不同的语言调用不用的预编译和编译工具。



2.重新计算各个目标地址的过程叫做重定位。



3.符号被用来表示表示一段子程序(函数)或者变量的起始地址。汇编语言中jmp等指令可以理解为符号?



4.预编译、编译、汇编和链接。



5.动态链接库和静态链接库都按照可执行文件进行存储。



6.程序源代码编译后的机器指令存放在代码段,.test或者.code,全局变量、局部静态变量存放在.data段,未初始化的全局变量、局部静态变量存放在.bss段(预留),目标文件的开头包含段属性,文件内的偏移地址,还有每个段的信息。



7.指令和数据分离的好处:第一、数据和指令的权限不同。第二、支持数据缓存和指令缓存。第三、节省内存。



8.readelf和obkdump工具的使用。



9.编译器的优化屏蔽了一些细节,对了解系统工作机制和调试有一定的影响。



10..bss段存放的是未初始化的全局变量和局部静态变量,.data存放了初始化的全局静态变量和局部静态变量。



11.有时希望函数中的局部变量的值在函数调用结束后不消失而保留原值,即其占用的存储单元不释放,在下一次该函数调用时,该变量保留上一次函数调用结束时的值。这时就应该指定该局部变量为静态局部变量(static local variable)。 把局部变量改变为静态变量后是改变了它的存储方式,即改变了它的生存期。把全局变量改变为静态变量后是改变了它的作用域,限制了它的使用范围。



12.马屁股决定航天飞机的宽度的故事,遗留问题。



13.链接的过程实际上是对地址的引用,即对函数和变量地址的引用,所谓符号就是函数和变量的名称,符号值就是他们的地址。可以使用nm来查看符号。



14.ELF文件中的符号表是一个段,名称为.symtab。



15.readelf –s xxxx.o查看符号在符号表中的状态。



16.使用ld来链接的时候,会自动定义一些特殊符号,例如程序起始地址,代码段结束地址,数据段结束地址等。



17.编译后的符号前后加上_是为了解决符号名冲突的问题,后来使用“签名机制”解决该问题。



18.extern “C”是表示编译器按照C规则进行编译。



19.强符号、弱符号,强引用和弱引用概念。



20.gcc –g添加调试信息。



21.链接器采用两步链接方法,即将各个目标文件中相同的段进行合并,再链接成一个大的目标文件。第一步:空间与地址分配(虚拟地址,注意BSS段)。第二步:符号解析与重定位。重定位过程最重要!



22.ld a.o b.o –e main –o ab,-e指定程序的入口为main函数,ld默认的为_start。



23.符号解析的确定是通过分配得各个段的起始虚拟地址加上偏移量来的,(操作系统中,偏移量的使用无处不在啊)



24.重定位表被用来保存与重定位相关的信息,.rel.text,也叫重定位段,链接器根据这个重定位符号。



25.符号的解析占据了链接过程的大部分。



26.undefined reference xxxx一般都是因为链接时候缺少了某个库,或者输入目标文件路径不正确或者符号的声明与定义不一致。



27.指令修正,绝对地址和相对地址,分别为S+A和S+A-P,绝对地址为该符号的实际地址,相对地址为与符号与被修正地址的地址差。



28.common块机制处理弱符号解析的问题。



29.API是源码级别的应用程序接口,例如POSIX。ABI是二进制文件层面的接口,规范更为严格。



30.静态库可以简单地看做一组目标文件的集合。



动态链接



31.GCC手册内嵌汇编知识



32.Ld连接器的语句分为连接语句和赋值语句。



33.BFD库将编译器和链接器与目标文件格式隔离开。



34.Runtimelib运行时动态库。



35.程序的局部性原理,将运行的部分装载。



36.覆盖载入在早期内存不够用的时候用的较多,现在已经很少使用,但是在DSP中也许还有用武之地。关键词:禁止树间调用和明确调用路劲。



37.为什么叫映像文件,因为可执行文件在装载的时候是被映射到虚拟地址空间的。



38.Linux缺页异常机制。do_page_fault(),建立可执行文件头文件和操作系统进程虚存之间的关系。当访问该内存时候,发现为空,则产生缺页异常。



39.对于相同权限的段,把他们合并到一起当做一个段进行映射可以有效地减少系统内存的碎片。



40.从链接角度,按照section划分,链接视角;从装载角度,按照segment划分,执行视角。



41.Linux装载过程,./可执行文件之后,工作台调用fork,然后fork调用execve,这时候工作台等待fork返回并接受用户输入的参数。:fork()->execve()->sys_execve()->do_execve()->search_binary_handler…。利用魔术确定文件的格式和类型.



42.静态链接的缺点:浪费内存和磁盘空间,因为每个一个进程都有自己用到的库的拷贝副本。第二是影响程序的跟新和发布,一个lib模块的更新,整个程序都要更新发布。



43.动态链接的优势:减少内存,减少物理页的换入换出,增加CPU缓存的命中率,升级容易,降低耦合率,增加程序的可扩展性和兼容性。缺点在于需要完善的共享库管理机制。linux下为.so,windows下为.dll



44.静态库为链接时重定位,动态库为装载时重定位。



45.地址无关代码技术。。不怎么看得懂。



46.如果一个lib.so中定义了一个全局变量a,进程A和进程B都使用了这个全局变量,一方的修改不会影响另一方,因为他们有各自的数据段副本,是数据段副本。。。但是同一个进程的两个线程,任何一边的修改另一个是会受到影响的。



47.通过延迟绑定优化动态链接的速度。ELF通过PLT来实现,具体实现函数为_dl_runtime_reslove。



48.动态链接器其实也是一个共享库,系统通过映射方式将其加载到进程的地址空间中,并将控制权交给他。动态链接库进行一些列链接之后,将控制权交给可执行文件的入口地址。



49..interp段指定动态链接器的路径。一般是一个软链接。.dynamic段保存了动态链接器所需要的各种信息,比如哪些共享对象,动态链接符号表的位置,动态链接重定位表的位置,共享对象初始化代码等。.symtab为静态链接符号表,动态链接符号表为.dynsym,其中之保存于动态链接相关的符号。231页



50.rel.dyn是动态库对数据引用的修正(数据段),.rel.plt是对函数引用的修正(代码段)。



51.elf.h包含了动态链接时候的辅助信息。



52.动态链接的步骤:启动动态链接器本身,装载所需要的共享对象,重定位和初始化。



53.动态链接器自举后,首先找到自己的GOT段,第一个存放的是.dynamic段,找到自己的重定位表和符号表等。



54.共享对象全局符号介入指的是在两个模块定义了同一个符号的时候,其中一个将另外一个覆盖的现象,即他们之间存在优先级的顺序。Linux的LD采用的是,如果即将加入的符号和已经存在的符号同名的话,就忽略即将加入的这个符号。



55.动态链接器本身是静态链接的。也是PIC的(地址无关代码)。



56.运行时加载,显示模块加载,linux驱动insmod和rmmod。



57.对动态库的操作分为四个函数:打开(dlopen),查找符号(dlsym),错误处理(dlerror),关闭动态库(dlclose)



58.Libname.x.y.z.so,x主版本号,y次版本号,z发布版本号



59.SO-NAME机制,去掉次版本号和发布版本号,建立软连接指向次版本号和发布版本号最新的库。



60.程序的运行环境包括,内存、运行库和系统调用。



61.栈一般分配在高地址内存区域,用来保存程序的上下文,它会向下生长。堆一般分配在低地址的内存区域,程序malloc和new出来的内存就在此分配,它是向上生长。



62.指针在初始化为NULL后,使用前一定要给他指向一个地址。栈上的指针也需要初始化好,不然随机指针会出现错误。



63.栈保存了:函数返回的地址和参数;临时变量包括局部的非静态的局部变量以及编译器生成的临时变量;保存的上下文包括函数调用前后需要保存不变的寄存器。



64.Ebp寄存器成为帧指针,固定在函数活动的位置,调用函数的时候首先将参数压入栈中,然后将下一条指令压入栈中,跳转到函数体执行。



65.函数的调用惯例:1.函数参数的传递方式和顺序,例如从左到右还有利用寄存器传递参数提高性能。2.栈的维护方式,主要是弹出的时候由栈本身完成还是有函数体自己完成。3.名字修饰策略。默认的在C语言中使用的是_cdec1。(318页)



31.eax用来传递函数的返回值。



32.堆空间由运行库先向操作系统批发,再向各个程序零售,当零售光了的时候运行库再向系统批发。运行库有维护堆空间的算法。分别为mmap和brk。



33.在Linux2.6内核版本里,malloc可以分配的内存空间多达2.9G。



34.Malloc申请的内存,在进程结束以后就不存在了,因为进程结束以后,进程的资源包括内存,打开的文件,网络接口等都会被操作系统回收。



35.堆的管理算法:简单的方法有:空闲链表,位图。针对固定分配大小的内存,可以使用对象池。一般会采用多种方法的综合。



36.Main函数之前会运行入口函数或者叫入口点,操作系统在创建进程后,将系统的的控制权交给了程序的入口,入口函数都程序运行环境和运行库进行初始化,包括堆、I/O、线程、全局变量等等。



37.入口函数初始化结束之后,调用Main函数,main函数执行结束返回到入口函数,然后回收资源,进行清理工作。



38.环境变量存储的是一些系统的公开信心,例如路劲,版本等。



39._start传给main的参数除了argc和argv里面的参数意外,还包括init:main调用前的初始化工作;fini:main结束后的收尾工作。Rtld_fini:和动态加载有关的收尾工作。



40.其实main函数的退出也隐式的调用了exit函数,在exit后面还有个hlt是为了防止exit没有调用,强制的把进程给干掉。



41.程序的I/O包括文件、管道、信号、网络、命令等。



42.I/O对文件的操作,初始化主要包括:建立打开文件表,如果能够继承自父进程,那么从父进程获取继承的句柄,初始化标准输入输出。



43.C语言的运行库大致包含以下功能:1.启动与退出。2.标准函数。3.I/O。4.堆的封装和实现。5.语言实现。6.调试功能



C语言的标准库由美国国家标准协会制定,最后一次扩充是1999年。。



66.(374)



67.线程私有数据包括:局部变量,函数的参数,线程局部存储的数据。线程共享的数据包括:全局变量,堆上的数据,函数里面的静态变量,程序代码和打开的文件。



68.printf/fprintf在被多线程共享使用时,也会造成混乱。C库当中一些函数做成可重入的。



69.可重入函数主要用于多任务环境中,一个可重入的函数简单来说就是可以被中断的函数,也就是说,可以在这个函数执行的任何时刻中断它,转入OS调度下去执行另外一段代码,而返回控制时不会出现什么错误。



70.由于glibc在malloc和new中支持了多线程,所以在使用前后不用加锁。但是一些例如memcopy,还是需要做保护。



71.缓冲机制的目的是为了避免减少系统调用或者读盘的次数,减少切换开销,framebuffer,还有等等都是这样。



72.linux下fread的实现:



73.linux下使用int 0x80来出发所有的系统调用。例如fork之后,会产生int 0x80的中断,然后去查找中断向量表,会发现是sys_call,这时候fork的系统调用好会存放在eax中,再去比对系统调用号的表,找到sys_fork,执行后返回到用户态。



74.ESP是栈指针寄存器,指向系统栈最上面一个栈的栈顶。



75.EBP是基址指针寄存器,指向系统栈最上面一个栈的底部。



76.EAX一般用来保存函数的返回值,EB/C/DX等一般用来传递参数。



内存、栈、堆的一点小总结
程序的内存布局
【前言】在32位系统中,大家可能认为我们可以用一个32位的指针访问任意内存地址。如下:
int p = (int *)0x12345678;
++
p;
  但事实上用户可以直接读取的内存大小是达不到4GB的。大多数操作系统都会将其中的一部分分配给内核使用,应用程序是无法直接访问这一段内存的,这部分被称为内核空间。Linux默认将高地址的1GB空间分配给内核;win默认下将高地址的2GB分配给内核,但是也可以人为配置成1GB。(前面已介绍)
  但是处理上述的内核空间之外的用户空间中也有一些特殊的空间有特殊的用处,而应用程序能用的内存空间是如下的“默认区域”:




  用于维护函数调用的上下文,离开了栈,函数的调用就办法实现了。栈通常在用户更高的地址空间处分配,通常有数兆字节的大小。

  堆用来容纳应用程序动态分配的内存区域,当程序使用malloc或new的时候就是得到来自堆中的内存。堆统称在栈的下方(低地址方向,但是不是紧邻的)。堆一般比栈要更大一点,一般会达到几十甚至是数百兆字节。
可执行文件映像
  顾名思义,这里存储着可执行文件在内存里的映像。装载器在装载的时候会将可执行文件读取或者映射到这里。
保留区
  是对内存中受到保护而禁止访问的内存区域的总称。这个区域大家应该都比较熟悉,比如,大多数操作系统中,极小的地址区域都是不允许访问的,如NULL。
  相关的内存布局如下:



  上图中还有一个区域,“动态链接库映射区”,这个区域用于映射装载的动态链接库。比如,如果可执行文件依赖于其他的共享库,那么系统就会为他从0x40000000开始的地址分配相应的空间,并将该共享库载入到该空间(动态链接共享对象的内存地址分配)。
  【注意】栈向低地址增长;堆向高地址增长。当栈或者堆的现有大小不够用的时候,它将按照图中的增长方向扩大自身的尺寸,直到预留的空间(unused)被用完。
  【补充】在Linux或者是win内存中,有些地址是始终不能读写的,例如0地址,当指针指向这些地址的时候,就会出现“段错误(segment fault)”。造成这种情况的两种最普遍的原因:
1.程序员将指针初始化为NULL,但是没有赋予合理的初值就开始使用。
2.程序员没有初始化栈上的指针,指针的值一般是随机数,之后就开始使用该指针。



在经典的操作系统中,栈总是向下(低地址)增长的。
堆栈帧或活动记录
  栈保存一个函数调用所需要的维护信息,常备称为堆栈帧或者是活动记录,堆栈帧一般包括:
(1)函数的返回地址和参数;
(2)临时变量:包括函数的非静态局部变量以及编译器生成的其他局部变量;
(3)保存的上下文:包括在函数调用前后保持不变的寄存器。
栈中有两个重要的寄存器:esp和ebp
(1)esp
  该寄存器标明栈顶,在栈上压入数据会导致esp减小,反之esp增大。再者,减小esp的值等于在栈上开辟空间,而增大esp的值等效于在栈上回收空间。
  esp不仅仅指向栈的顶部,同时也就意味着指向当前整个函数活动记录的顶部(见下图)。
(2)ebp
  ebp寄存器指向函数活动记录的一个固定位置。ebp寄存器又被称为帧指针。如下:



  ebp具体的固定位置如上图所示。如图,(注意栈向低地址增长)ebp之前是该函数的返回地址,再之前是压入栈中的参数。而ebp指向的数据是调用该函数之前的ebp的值,保存旧的ebp的值的原因是,函数返回时,可以通过读取该值恢复到调用之前的ebp的值(回到之前指向的位置)。
调用惯例



概念
  函数的调用方和被调用方对于函数如何调用需要有一个明确的约定(比如参数入栈的顺序等)。这样的约定就被称为调用惯例。
调用惯例一般包括:
(1)函数参数的传递顺序和方式。可以有很多方式,最常见的就是通过栈传递,参数入栈的顺序要事先由调用惯例确定;有些调用惯例为了提升性能还会选择用寄存器进行参数传递。
(2)栈的维护方式。参数入栈后,函数体会被调用,参数使用的时候会被弹出的,可以是函数调用方进行参数的弹出或者是由函数本身进行参数的弹出。
(3)名字修饰的策略。C语言实际上存在多种调用惯例,一般默认的(没有显示指定的情况下)是cdecl(不同的编辑器写法有别):
int _cdecl foo(int n, float m);
foo函数布局
具体如下图所示:



  当foo函数返回的时候,程序会首先使用pop恢复保存在栈中的寄存器,然后从栈里取出返回地址,并返回至调用方,调用方再调整esp将堆栈恢复。下面给出一个具体的多级调用栈的布局:



函数返回值传递



如果返回值的可以用4个字节表达,我们经常讲返回值保存在eax寄存器里面。返回后,函数的调用方将读取eax寄存器中的值即可。
对于返回4~8字节对象的情况,几乎所有的调用惯例都采用eax和edx联合返回的方式进行的。
超过8字节的返回值(大致思路):
先看一段代码:
#include
typedef struct big_thing
{
  char buf[128];
}big_thing;
big_thing return_test()
{
  big_thing b;
  b.buf[0] = 0;
  return b;
}
int main()
{
  big_thing n = return_test();
  return 0;
}
大致解读
首先main函数在栈上额外开辟一片空间,并将这块空间的一部分作为传递返回值的临时对象,这里称为temp;
将temp对象的地址作为隐藏参数传递给return_test函数;
return_test函数将数据拷贝给temp对象,并将temp的地址用eax传出;
return_test函数返回后,main函数将eax指向的temp对象的内容拷贝给了n。
返回值传递流程如下:



【注意】结果返回值对象会被拷贝两次,所以不到万不得已不要返回大尺寸的对象。





简介
  堆是一块巨大的内存空间,常常占用整个虚拟空间的绝大部分。在这片空间里,程序可以请求一块连续内存,并自由使用,这块内存在程序主动放弃之前都会一直保存。
malloc的实现
  不能采用系统调用的方式,开销较大;较好的做法是程序向操作系统申请一会适合大小的堆空间,然后由程序自己管理这块空间,具体来讲,管理着堆空间分配的往往是程序的运行库。
  运行库相当于向操作系统”批发“了一块较大的堆空间,之后”零售“给程序使用,如全部售完或者程序有大量的内存需求,再根据实际情况向操作系统“进货”。
Linux进程堆管理
  提供两种堆分配方式,即两个系统调用:brk()系统调用;mmap()系统调用。
brk()
该函数的C语言声明如下:
int brk(void *end_data_segment)
  该函数申请堆的方式是:设置进程的数据段的结束地址,即可以扩大或者是缩小数据段(Linux下数据段和BSS段合称数据段)。如果我们将数据段的结束地址向高地质移动(数据段变小),那么扩大的那部分空间常常用来作为堆空间。
mmap()
  该函数的作用是向操作系统申请一段虚拟空间,这块虚拟地址空间可以映射到某个文件,当它不进行映射的时候,我么又称这块空间为匿名空间,匿名空间就可以用来作为堆空间。
  glibc的malloc函数是这样处理用户的空间请求的:
(1)对于小于128kb的请求,它会在现有的堆空间里面,按照堆空间分配算法为其分配一块地址并返回;
(2)对于大于128kb的请求,它会使用mmap()函数为它分配一块匿名空间。使用mmap()函数实现malloc的代码如下:
void *malloc(size_t nbytes)
{
  void *ret = mmap(0,nbytes,PROT_READ | PROT_WRTIE, MAP_PRIVATE | MAP_ANONYMOUS,0,0);
  if(ret == MAP_FAILED)
   return 0;
  return ret;
}
【需要注意的是】mmap()函数和VirtualAloc()类似,他们都是虚拟空间的申请函数,它们申请的空间的其实地址和大小必须是系统页的大小的整数倍。
堆分配算法



空闲链表法
简介
  该方法将堆中的各个空闲块按照链表的方式连接起来,当用户请求一块空间的时候,可以遍历整个链表,直到找到合适大小的块并将它拆分,当用户释放空间的时候将他合并到空闲链表中。
结构
  在堆里的每一个空闲空间的开头(或结尾)有一个头(header),头结构里面有两个指针prev和next,如下图:



操作过程
  首先在空闲链表查找足够容纳请求大小的一个空闲块,然后将这个块分为两部分,一部分是程序请求的空间,另一部分是剩余的空闲空间。
【注意】当采用空闲链表的方式时需要释放空闲空间的时候,有一个简单的解决方法:当用户请求k个字节的时候,我们实际分配k+4个字节,这四个字节用来存储该分配的大小。
位图
核心思想
  将整个堆划分为大量的大小相等的“块”。当用户请求的时候,总是分配整数个块的空间给用户。分配的块中,第一个块我们称为已分配区域的头(Head),其余的称为已分配区域的主体(Body)。我们使用整数数组来记录块的使用情况,由于每个块只有头/主体/空闲三种状态,因此可以使用两个bit位来表示一个块。
举例
  假设堆的大小为1MB,那么我们让一个块大小为128字节,则总的块数:8k/(32/2)=512个int来存储,。这有512个int的数组就是一个位图,其中每两个bit位代表一个块。
优点
(1)速度快。由于整个堆的空闲信息都存储在一个数组内,因此访问该数据的时候cache容易命中。
(2)稳定性好。为避免用户越界读写破坏数据,我们只需简单地备份一下位图即可。而且即使部分数据被破坏,也不会导致整个堆无法工作。
(3)块不需要额外信息,易于管理
缺点
(1)分配内存的时候容易产生碎片。例如,分配300字节时,实际上分配了3个块即384个字节,这样就浪费了84个字节(128*3,按照整数个块进行分配)。
(2)如果堆很大但是设定的块很小(这样可以减少碎片的数量),但同时也会导致位图的规模很大,可能会失去cache命中率高的优势,同时也会浪费一定的空间。针对这种情况,我们可以使用多级位图(不在介绍)。
对象池
使用情况
  被分配的大小是较为固定的几个值。
大致思路
  如果每次分配的空间大小都一样,那么就可以按照这个每次请求分配的大小作为一个单位,把整个堆空间划分为大量的小块,每次请求的时候只需要找到一个小块就可以了。
管理
  对象池的管理方法可以是空闲链表或者是位图。
实际应用   实际的应用中,堆的分配算法其实是采取多种算法的复合。


Category linux