读书笔记-《程序员的自我修养》

程序员的自我修养


第一部分 温故知新

1.2 硬件基础

  • PCI总线:Peripheral Component Interconnect,外设部件互联标准。一种局部并行总线标准,由ISA总线(Industy Standard Architecture,工业标准结构)。后又发展出PCI Express

  • 北桥:连接高速设备(PCI Brigde),南桥:连接低速设备,然后汇总到北桥。CPU、内存、I/O连接。

  • 主机频率:时钟脉冲信号的频率。

  • 一个程序的CPU执行时间 = 一个程序的时钟周期数 × 单位时钟周期时间 = 一个程序的CPU时钟周期数/时钟频率(时钟周期时间与时钟频率互为倒数),这个公式表明硬件设计者要提高硬件性能,就要减少一个程序的时钟周期数,或者是减少单位时钟周期时间。

  • CPI:clock circle per instruction

  • CPU自带一级缓存、二级缓存(但缓存还是不够快,且缓存中地址不固定,每次读写都要寻址),于是自带寄存器(存储最常用的数据,寄存器依靠名称区分数据);32位CPU寄存器大小4字节,64位CPU寄存器大小8字节。

  • 寄存器:早期x86由8个寄存器(EAX,EBX,ECX,EDX,EDI,ESI,EBP,ESP)

1.3 体系结构

  • 计算机体系结构:层次结构,下层提供接口,上层使用接口来实现功能。
  • API:Apllication Programming Interface, 应用程序编程接口。 API的提供者是运行库,什么样的运行库提供什么样的API

Linux的Glibc提供POSIX的API

Windows的运行库提供Windows API, 32位Windows提供的API:Win32

  • 运行库使用操作系统提供的系统调用接口System call

1.4 操作系统

  • 分时系统:每个程序运行一段时间后主动让出CPU,使得一段时间内每个程序都有机会运行一小段时间。
  • 多任务系统:操作系统接管硬件资源(运行在受硬件保护的级别),所有应用程序都以进程的方式运行在比操作系统权限更低的级别。 CPU由操作系统统一分配。

抢占式Preemptive:操作系统可以强制剥夺CPU资源,分配给他认为目前最需要的进程。

  • 硬件驱动程序:操作系统的一部分,与操作系统内核一起运行在特权级。 为硬件生产厂商提供接口和框架,由硬件生产厂商提供符合的驱动程序。

文件系统(硬盘(多个盘片,每个盘片两个盘面,磁道(同心圆),每个磁道划分扇区(512字节)))

读取硬盘:read系统调用——>文件系统向硬盘驱动发送读取请求(读取逻辑扇区?号-?号)——>硬盘驱动程序向硬盘发出硬件命令(通常通过读写I/O端口寄存器端口地址:例如IDE接口,IDE0通道的I/O端口地址0x1F0-0x1F7及0x376-0x377,端口地址用来写入所需读取的逻辑扇区的LBA地址。具体见P13)

  • IDE接口:电子集成驱动器,普遍使用的外部接口,主要接硬盘和光驱。
  • LBA地址:逻辑区块地址,描述数据所在区块。

CHS模式:磁柱-磁头-扇区 寻址模式

1.5 内存

  • 32位机器,即32条计算机地址线,地址空间为2的32次方字节=4GB(4x1024x1024x1024),十六进制表示为0x00000000-0xFFFFFFFF(8x4=32)
  • 虚拟地址,通过某些映射方法,将虚拟地址转换成实际的物理地址,可以将程序的地址空间隔离,程序也不需要再重定位
  • 分段:以程序为单位,内存不足时,整个程序要被换入换出到磁盘,内存使用效率低,粒度较大
  • 分页:地址空间分为固定大小的页,一个程序中有些页放在磁盘中,用到时从磁盘中读出放入内存;有些页与物理页建立映射。
  • CPU内部集成MMU(内存管理单元,硬件),实现虚拟存储,进行页映射(CPU发出虚拟地址,经MMU转换为物理地址)

1.6 线程

  • 线程的优先级可由用户手动设置,系统也会根据不同线程的表现自动调整优先级。
  • IO密集型线程:频繁等待的线程(频繁进入等待状态);CPU密集型线程:很少等待的线程。
  • 原子操作:单指令操作(i++ 被编译为汇编代码后不只一条指令,可能会被调度系统打断)
  • 同步:相关进程之间的相互制约关系,为协调推进速度,在某些点需要互相等待或者唤醒。

锁:实现同步常用的方法,线程访问数据或资源试图获取锁,访问结束之后释放。

信号量:允许多个线程同时访问共享资源。同一个信号量可以被一个线程获取后,被另一个线程释放

互斥量:类似二元信号量,只同时允许一个线程访问;可以跨进程(一个进程创建,另一个进程可以获取,获取了的进程负责释放)

临界区:同一个进程内。


第二部分 编译链接

2 编译和链接

  • 预编译:预处理头文件,以#打头的指令,

如#include<stdio.h>,将系统中的头文件插入程序

删除#define,展开所有宏定义

删除注释等。

  • 编译:将预处理的文件进行一系列词法分析、语法分析、语义分析及优化后,产生相应的汇编代码文件。

词法分析:源代码输入到扫描器,将源代码的字符序列分割为一系列记号(关键字、标识符、数字、字符串等),运用有限状态机算法等;lex词法分析器;

语法分析:对扫描器产生的记号进行语法分析,产生语法树;语法分析过程中,可以确定运算符号的优先级和含义,还要对多重含义的符号进行区分(例如*);yacc语法分析器

语义分析:确定语句是否有意义;静态语义(声明和类型的匹配,类型转换等);动态语义(运行期才能确定的语义);语法树都要被标识了类型。

中间代码生成:源代码级别的优化,直接在语法树上优化有难度,先转换为中间代码(三地址码,P-代码等);(例如,常量加减可以直接计算出来);中间代码一般跟目标机器和运行时环境无关。

目标代码生成及优化

  • 汇编:将汇编代码转变成机器可以执行的指令(机器语言指令,二进制文件),得到目标程序。
  • 链接:将库函数的目标文件合并到目标文件中(例如:printf,标准库C的函数,存在于printf.o的编译好的目标文件中),程序被分为多个模块,每个模块独立编译后链接(把各个模块之间相互引用的部分处理好,使得各个模块之间能够正确的衔接)。

主要包括地址和空间分配、符号决议、重定位(重新计算各个目标的地址)

静态链接:编译后的目标文件与库(常见的运行时库:支持程序运行的基本函数的集合,一组目标文件的包,一些常用代码编译成目标文件后打包存放)一起链接形成最终可执行文件。

一个模块调用了另一个模块的函数,编译时并不知道函数的地址,暂时将调用函数的指令的目标地址搁置,链接时由链接器将这些指令的目标地址修正(重定位)。

3 目标文件结构

  • 目标文件是源代码编译后未进行链接的那些中间文件(windows:.obj/linux:.o),它与可执行文件的内容和结构很相似,一般跟可执行文件格式一起采用一种格式存储(windows: PE-COFF / linux: ELF)。

  • 动态链接库:windows .dll / linux .so;静态链接库:windows .lib / linux .a

  • 以ELF为例,目标文件结构中有

文件头:描述整个文件的属性,是否可执行、静态/动态链接、入口地址等;文件头中有一个段表,描述文件中各个段在文件中的偏移位置及段的属性。

编译后的机器代码在.text段

已初始化的全局变量和局部静态变量在.data段

未初始化的…在.bss段(不占磁盘空间)

.rodata:只读数据段,如const修饰的变量和字符串常量

  • 代码段和数据段分离的好处:

程序被装载时,数据和指令分别被映射到两个虚存区域,数据区域是进程可读写的,指令区域是进程只读的,可以防止程序指令被改写。

CPU缓存一般被设计为数据缓存和指令缓存分离,所以程序也分离有利于CPU缓存命中率。

共享指令:当运行多个该程序副本时,指令是一样的,所以内存只需保存一份该程序指令部分。数据区域是进程私有的。这样可以节省内存。

  • binutils的工具objdump(显示关键段)可以查看目标文件内部结构(-h:打印各个段基本信息;-x:打印更多信息;-s:打印段的内容;-d:将包含指令的段反汇编)

  • GCC提供了扩展机制:程序员可以指定变量所处的段 attribute((section(“name”)))

  • 魔数:文件最开始的几个字节,用来确认文件类型,ELF文件开头四个字节是魔数(0x7F,0x45,0x4c,0x46)

  • 段表:保存各个段的基本属性,ELF文件的段结构就是由段表决定的,编译器、链接器、装载器都是靠段表来定位和访问各个段的属性的。

段表是以Elf32_Shdr结构体为元素的数组(一个结构体描述一个段)

Elf32_Shrt结构体内有十个描述段属性的字段(段名、段类型、段标志位、段偏移等)

  • 重定位表:对目标文件的某些部位进行重定位,重定位的信息记录在重定位表中;每个需要定位的代码段或数据段,对应一个重定位表,一个重定位表也是一个段。

  • 字符串表 .strtab:保存普通的字符串集(ELF文件中引用字符串只需给出其在字符串表的下标即可)

  • 段表字符串表 .shstrtab:保存段表中用到的字符串表 (他俩都是以段的形式保存)

  • 符号表 .symtab(文件中的一个段):记录目标文件中所用到的所有符号;每个目标文件有一个相应的符号表;

符号指的是函数和变量,函数名和变量名叫符号值。

符号是链接过程的“粘合剂”,目标文件相互拼合实际上是目标文件之间对地址的引用,即对函数和变量的地址的引用。

符号值:对于变量和函数来说,符号值是他们的地址。

全局符号:链接过程只关心全局符号的相互粘合(定义在本文件的可被其他文件引用的;本文件所引用的定义在其他文件的外部符号)。

其他符号比如:段名、局部符号、行号信息,对于其他文件来说是不可见的,对于链接过程无关紧要。
符号表是一个以Elf32_Sym结构体为元素的数组,一个结构体对应一个符号(结构体内有符号名、符号值、符号大小、符号类型及信息、符号所在段)

  • 为解决符号名冲突:

C语言源代码中的符号名经编译后,前面加‘_’ (foo ——> _foo)(Linux下GCC已经取消这种方式,windows还保留);

C++采用符号修饰,利用函数签名(描述函数信息:函数名、参数类型、所在类及命名空间等),C++源码编译后的目标文件中使用的符号名是修饰后的名称。

  • 不同编译器采用不同名称修饰方法,所以由不同编译器编译产生的目标文件之间无法正常相互链接。

  • 强符号:函数、初始化了的全局变量;弱符号:未初始化的全局变量;链接器的规则如下:

不允许强符号多次定义(不同目标文件中不能有同名的强符号,如果出现,链接器会发生符号重复定义错误)

一个符号在某个目标文件中是强符号,其他是弱符号,则选择强符号

一个符号在所有文件中都是弱符号,选择占用空间大的那个(尽量不要使用多个不同类型的弱符号,容易导致很难发现的程序错误)

  • 强引用:对外部符号的引用,链接时找不到定义则报错;弱引用:引用的外部符号未被定义,链接器不报错;(例如:可以将扩展功能模块定义为弱引用,则去掉某些功能模块时,程序也能正常链接,只是少了些功能。)
  • 目标文件中还可能保存调试信息(占用很大空间)

4 静态链接

  • 对齐:对齐单位,例如对齐单位是4096字节,如果长度只有1字节,内存中也要占4096字节。

  • 链接器在链接过程中将几个输入目标文件加工后合并成一个输出文件。(链接器为目标文件分配地址和空间:一方面是在输出的可执行文件中的空间,另一方面是装载后的虚拟地址中的虚拟地址空间。(对于.text和.data这种有实际数据的段,文件中和虚拟地址中都要分配,而.bss这样的段只分配虚拟地址空间。))

  • 合并采用相似段合并,将相同性质的段合并在一起。

  • 两歩链接:1,空间与地址分配,获得输入目标文件各段长度等属性,计算出输出文件中各段合并后的长度和位置,并建立映射关系。
    2,符号解析与重定位,读取输入文件中段的数据、重定位信息,进行符号解析与重定位,调整代码中的地址等。

  • VMA虚拟内存地址,LMA加载地址(正常情况下二者是一样的);链接之前目标文件中所有段的VMA都是0,因为还没分配虚拟地址

  • Linux下,ELF可执行文件默认从地址0x08048000开始分配。

  • 符号地址的确定:符号在段内的相对位置是固定的,链接后的起始位置变了,所以要给符号加偏移值(例如main符号在.text中的偏移值为X,链接后.text段位于虚拟地址0x08048094,那么main的地址为‘0x08048094+X’)

这里能确定的符号应该是定义在本模块的符号,本模块引用的外部符号需要下一步的符号解析和重定位

本模块的符号在本模块的地址确定了,则下一步重定位时,外部模块就能引用外部符号的实际地址了

  • call:近址相对位移调用指令(后面跟的是所调用的函数,相对于下一条指令的偏移量;即下一条指令的地址+call后面的偏移量=所调用函数的地址)
  • 链接器依靠重定位表得知哪些指令需要如何调整

每个要被重定位的ELF段都要有一个对应的重定位表(.text——>.rel.text .data——>.rel.data)

一个重定位表往往是ELF文件中的一个段,也叫重定位段

重定位入口:要修正的位置的第一个字节相对于段起始的偏移(段起始+偏移=代码中需要修改的地址)

重定位入口的类型和符号:低8位表示入口的类型,高24位表示重定位入口的符号在符号表中的下标(即寻找对应符号的真实地址)

每个重定位的入口都是对一个符号的引用,当链接器需要对某个符号重定位时,就会去查找由所有输入目标文件的符号表组成的全局符号表(之前更新好了,否则会报符号未定义错误)

  • 不同处理器寻址方式千差万别(相对近址寻址、绝对近址寻址等)
  • 链接器本身不支持符号的类型,变量类型对链接器是透明的,只知道符号的名字,不知道类型是否一致
  • 编译器会将未初始化的全局变量设为COMMON类型

未初始化的全局变量是弱符号

弱符号要选用最大的那个

编译时无法确定确定弱符号大小,因为可能其他编译单元中更大;链接过程可以确定弱符号大小,最终在输出文件的BSS段分配空间

一个未初始化的全局变量不是以COMMOM块的形式存在时,相当于一个强符号。

  • C++相关:重复代码消除

c++中的模板,可能会在不同的编译单元生成相同的代码

目前采用的方法是:将每个模板的实例代码都单独的存放在一个段里,每个段只包含一个模板实例。

链接器在链接时可以区分这些相同的模板实例段,将他们合并进最后的代码段。

  • 函数级别链接:当我们需要用到某个目标文件中的任意一个函数或变量时,就要把它整个链接起来(也就是说那些没用的函数也被链接进来);函数级别链接就是让所有函数分别单独保存在一个段里。(链接器要计算依赖关系,重定位难度增大)

  • _start函数,Linux系统下一般程序的入口,初始化部分完成一系列初始化过程后执行main函数,main函数执行完成后回到初始化部分,它进行一些清理工作,结束进程。(.init 存放初始化代码;.fini 存放进程终止代码指令)

  • ABI:Application Binary Interface,与可执行代码 二进制兼容性相关的内容

二进制级别的重用很难实现:由于各种硬件平台、编程语言、编译器、链接器、操作系统之间,ABI互相不兼容,各个目标文件之间无法相互链接。

C++二进制兼容性不好,,不仅不同的编译器编译的二进制代码之间无法互相兼容,有时同一个编译器的不同版本兼容性也不好(基本形成以微软VISUAL C++和GNU的GCC为首的两大派系)

  • 静态库链接

操作系统提供API

程序如何使用API:一种语言的开发环境往往会附带语言库(Language Library)(对操作系统API的包装)——>例如程序调用C语言Printf函数——>printf函数对字符串进行一些必要的处理——>调用操作系统提供的API(各个操作系统下API不一样,Linux: write系统调用;Windows: WriteConsole)

不作任何输入输出的函数,不调用操作系统API(strlen()取字符串长度函数)

  • 静态库可以简单的看成一组目标文件的集合(Linux下C语言静态库libc位于/usr/lib/libc.a,使用ar压缩程序压缩后的)
  • 使用GCC编译hello.c流程:

cc1程序(GCC的c语言编译器),将hello.c编译成一个临时的汇编文件 .s

as程序(GCC的汇编器),将.s文件汇编成临时目标文件 .o

collect2程序完成链接(ld连接器的一个包装,它调用ld链接器链接目标文件,然后对链接结果进行一些处理,比如收集所有与程序初始化相关的信息并构造初始化的结构);将一些库和目标文件链接入了最终的可执行文件。

  • 静态运行库一个目标文件只包含一个函数(printf.o里只有printf()函数),每个函数独立的放在一个目标文件中可以尽量减少空间浪费,那些没有被用到的目标文件(函数)就不要链接到最终的输出文件中。

  • BIOS:Basic Input Output System

  • 链接过程控制:通常情况,链接器使用默认链接规则对目标文件进行链接。

对于受限于一些特殊条件的程序,例如需要制定输出文件的各个段的虚拟地址、段名称、段存放顺序等

如:操作系统内核、BIOS、内核驱动程序,没有操作系统的情况下运行的程序(引导程序Boot Loader),脱离操作系统的硬盘分区软件PQMagic等。
ld链接脚本在/usr/lib/ldscripts/, 不同机器平台、输出文件格式都有相应的链接脚本(ld链接生成一个可执行文件时,用elf_i386.x;生成一个共享目标文件时,用elf_i386.xs)

  • GCC内嵌汇编:将汇编语言嵌入程序,使得程序能够脱离c语言运行库(程序直接中断,使用系统调用),成为一个独立于任何库的程序。

普通使用库的程序,必须要有main函数,因为程序入口在库的_start,初始化后会去调用main函数来执行程序主体部分;

不使用库,可不用main函数,修改程序入口(-e nomain:将ELF文件头的e_entry成员赋值成nomain函数的地址)。

  • ld链接脚本语法:命令语句、赋值语句

命令语句:关键字(参数):ENTRY(nomain) 表示nomain为入口程序

  • MIPS:采用精简指令集(RISC)的处理器架构
  • BFD库(Binary File Descriptor library)二进制文件描述库:

设计目的是希望通过一种统一的接口来处理不同的目标文件格式

将目标文件抽象成一个统一的模型,然后在内部将数据从抽象视图转换到目标处理器和文件格式所要求的。(抽象,更能概括一种东西,使得抽象后的模型包含的范围更广(shaping APR S1的抽象修改空间))

BFD的主要用户是GNU汇编器(GAS),GNU连接器(GLD),和其他GNU二进制实用程序(“binutils”)工具,和GNU调试器(GDB),这些工具都通过BFD库来处理目标文件,将编译器链接器同具体的目标文件格式隔离。

5 Windows PE/COFF

  • 32位Windows系统上可执行文件格式 PE(Protable Executable),COFF(Common Object File Format)发展来的;

windows平台上 VISUAL C++编译器产生的目标文件格式仍然使用COFF格式。

本书中 windows上统称为PE/COFF文件(可执行文件PE/目标文件COFF)

与ELF文件的基本结构相同,基于段,至少包含一个代码段,不同编译器产生的目标文件的段名不同

  • Microsoft Visual C++编译环境:编译器cl(compiler),链接器link
  • COFF文件结构:文件头+若干个段+符号表和调试信息

文件头包括:映像头IMAGE_FILE_HEADER(描述文件总体结构和属性)、段表IMAGE_SECTION_HEADER(描述文件中段的属性)

段表中每个元素代表一个段(结构体,描述段的各个属性):VirtualSize(段被加载到内存后的大小)、VirtualAddress(段被加载到内存的虚拟地址)、SizeOfRawData(段在文件中的大小,往往比VirtualSize小)、Characteristics(段类型、对齐方式、可读可写可执行等权限,标志位的组合)

COFF中的代码段、数据段、.bss段都跟ELF一样

.drectve段和.debug$S段 在ELF中没有

  • .drectve段:内容是编译器传递给链接器的指令

该段的Characteristics中标志位为0x100A00:1字节对齐、最终链接时可抛弃、INFO(信息段)

RAW DATA:原始数据,十六进制(例如,解析为LIBCMT:表示编译器告诉链接器,该目标文件需要LIBCMT这个库)

  • 符号表:符号编号(符号在符号表中的下标)、符号大小(符号所表示的对象所占用的空间)、符号所在位置、符号类型、符号可见范围、符号名

  • PE可执行文件格式,兼容DOS“MZ”格式(早期Windows兼容DOS系统)

32位PE文件格式 PE32;64位PE文件格式 PE32+


第三部分 装载与动态链接

6 可执行文件的装载与进程

  • 可执行文件装载到内存后被CPU执行

一般来说,C语言指针大小的位数与虚拟空间的位数相同(32位平台指针为32位,4字节;64位平台指针64位,8字节)

  • 进程只能使用操作系统分配给进程的地址,如果访问未经允许的空间,操作系统会捕获到这些访问,当做非法操作强制结束进程

Windows:进程因非法操作需要关闭;Linux:Segmentation fault

  • 32位硬件平台,4GB虚拟地址空间;Linux下1GB操作系统空间、Windows下默认2GB操作系统空间(可以修改启动参数将操作系统空间设备1GB)

32位CPU下,虚拟地址空间不可能超过4GB;因为32位CPU只能使用32位指针,它的寻址空间最大为4GB(CPU的位数是指CPU能一次同时寄存和处理二进制数码的位数,这和CPU中寄存器的位数对应。)

32位CPU,内存空间可以超过4GB,采用36位地址线即可(64GB的物理内存空间)————PAE(Physical Address Extension)

  • 装载:程序执行时需要的指令和数据必须在内存中才能够正常运行。

静态装入:将程序运行需要的指令和数据全都装入内存。

动态装入:将程序最长要几个的部分驻留在内存中,一些不太常用的数据存放在磁盘里(覆盖装入、页映射)

  • 覆盖装入:(几乎淘汰)程序员手动将程序分割成若干块,然后编写覆盖管理器来管理这些模块何时在内存何时被替换

多个模块的情况下,程序员需要将模块按照它们之间的调用依赖关系组织成树状结构。

当某模块被调用时,整个调用路径上的模块必须都在内存内

禁止跨树间调用

  • 页映射:将内存和所有磁盘中的数据和指令按照“页”为单位划分成若干个页(Intel IA32 处理器一般使用4096Byte为页大小)

将内存划分为页、程序划分为页;然后按照程序执行顺序,装载管理器将程序所在页装入内存

出现替换时,使用合适的算法选择替换的页(FIFO(先进先出)算法、LUR(最少使用)算法)

这个装载管理器其实就是现代操作系统的存储管理器

几乎所有的硬件都采用一个叫MMU(Memory Management Unit)的部件来进行页映射(CPU发出的是虚拟地址,即程序看到的是虚拟地址,经过MMU转化后就变成了物理地址,一般MMU集中在CPU内部)

  • 可执行文件的装载

创建一个独立的虚拟地址空间(实际是创建映射函数所需要的相应的数据结构,在Linux i386 中是分配一个页目录)

读取可执行文件头,建立虚拟空间与可执行文件的映射关系(当操作系统捕获缺页错误时,要知道程序当前需要的页在可执行文件中的哪一个位置)

将CPU的指令寄存器设置成可执行文件的入口地址,启动执行。

  • Linux将进程虚拟空间中的一个段叫做虚拟内存区域 VMA(Virtual Memory Area)

  • 页错误:以上步骤只是建立了可执行文件和进程虚存之间的映射关系,并没有装入内存。

入口地址装入CPU指令寄存器后,CPU开始执行指令,发现程序入口地址所在页面是个空页面,发生页错误;将控制权交给操作系统。

操作系统查询之前建立的数据结构,找到空页面所在的VMA(虚拟内存区域)——>计算出相应页面在可执行文件中的偏移——>在物理内存中分配物理页面——>将虚拟页与物理页建立映射关系;将控制权交还给进程。

进程从页错误的位置重新开始执行。

6.4 进程虚存空间分布
  • ELF文件被映射时,以系统的页长度作为单位,每个段在映射时的长度都是系统页长度的整数倍(不足一页的也将占一页)
  • 操作系统实际上并不关心可执行文件各个段所包含的实际内容,只关心一些跟装载相关的问题,最主要是段的权限(可读、可写、可执行);对于相同权限的段,把它们合并到一起当做一个段进行映射。

代码段等 权限为可读可执行的段

数据段、BSS段等 权限为可读可写的段

只读数据段等 权限为只读的段

  • ELF可执行文件引入“Segment”,一个“Segment”包含一个或多个属性类似的“Section”。

ELF中把这些属性相似、又连在一起的段叫做一个“Segment”

“Segment”从装载的角度重新划分了ELF的各个段

系统正是按照“Segment”来映射可执行文件的

  • 描述Segment的结构叫做程序头(Program Header)

LOAD类型的Segment才是需要被映射的,其他类型的例如NOTE、TLS等都是在装载时起辅助作用的

  • 装载时的段指的是“Segment”、其他情况下段指的是“Section”
  • 进程在执行时需要用到堆、栈,他们在进程的虚拟空间中也是以VMA的形式存在的;一个进程中的堆和栈分别都有一个对应的VMA;堆、栈没有映射到文件中,叫做匿名虚拟内存区域(AVMA)
  • 一个特殊的VMA“vdso”:它的地址处于内核(大于0xC0000000),进程可以通过访问这个VMA来跟内核进行一些通信。
  • 小结:操作系统通过给进程空间划分出一个个VMA来管理进程的虚拟空间;基本原则是将相同权限属性的、有相同映像文件的映射成一个VMA;一个进程基本上可以分为一下几种VMA区域:

代码VMA,只读、可执行;有映像文件

数据VMA,可读写、可执行;有映像文件

堆VMA,可读写、可执行;无映像文件,匿名,可向上扩展

栈VMA,可读写、不可执行;无映像文件,匿名,可向下扩展

  • 段地址对齐:已经按权限分为“Segment”后,若直接按页长度对齐(P170,一个Segment不与其他Segment共享物理页面),仍然会造成很多内存碎片;可以让那些各个段接壤的部分共享一个物理页面,然后将该物理页面映射两次(P171)
  • 进程栈初始化:进程刚开始启动时,需要知道一些进程运行的环境(例如系统环境变量、进程的运行参数),一般操作系统会在进程启动前将这些信息提前保存到进程的虚拟空间的栈中。进程启动后,程序的库会把堆栈里的初始化信息中的参数传递给main()函数(argc:命令行参数数量,argv:命令行参数字符串指针数组)

栈顶寄存器esp指向初始化后栈的顶部

然后是命令行参数数量(Argument Count——>argc)

然后是指向参数字符串的指针…详见P172

  • Windows PE的装载:

不考虑ELF里段地址对齐之类的问题,但会浪费一些磁盘和内存空间;

PE文件中,链接器在生产可执行文件时,往往将所有的段尽可能的合并,所以一般只有代码、数据、BSS、只读数据等少数几个段

RVA(Relative Virtual Address)相对虚拟地址:相对于装载基地址的偏移地址;由于PE可以装载到任何地址,基地址并不固定,使用RVA可以保证当基地址变化时,PE文件中的各个RVA都保持一致。

7 动态链接

  • 静态链接使得不同的程序开发者能够相对独立地开发和测试自己的程序模块,促进程序开发的效率。但缺点是浪费内存和磁盘空间、模块更新困难

每个程序内部出了保留着printf函数、scanf函数等公用库函数,还有数量相当可观的其他库函数及它们所需要的辅助数据结构

一些被多个程序共享的目标文件,在磁盘内存中可能会有多个副本。

程序中所使用的某个目标文件有第三方厂商提供,那么一旦更新该文件,程序就要与其重新链接。即,程序中任何模块更新,整个程序就要重新链接、发布给用户。

  • 动态链接基本思想:把程序按照模块拆分成各个相对独立部分,把链接过程推迟到运行时进行,在程序运行时将它们链接在一起形成一个完整的程序。

节省内存空间:当要运行proj1.o时,系统加载proj1.o,然后加载用到的目标文件,例如lib.o,所需要的目标文件全部加载至内存后,系统开始链接工作(链接工作与静态链接相似)。这时如果运行proj2.o,并且也用到了lib.o,系统不用重新加载lib.o。此时内存中只有一份lib.o。

更新:当要升级程序某个共享库时,理论上只要简单地将旧的目标文件覆盖掉,而无须将所有的程序再重新链接一遍。

动态链接使得开发过程中各个模块更加独立,耦合度更小。(甚至可以使用不同的编程语言)

可扩展性:程序在运行时可以动态地选择加载各种程序模块(插件plug-in)

平台兼容性

Linux中ELF动态链接文件.so(dynamic shared objects);windows中.dll(dynamical linking library)
程序本身分为程序主要模块、动态链接库(Lib.so);由动态链接器完成链接。

  • 静态链接时,整个程序最终只有一个可执行文件,是一个不可分割的整体。动态链接时,程序被分为若干个模块(程序主体(可执行文件)+程序依赖的共享对象(Lib.so等))。

当程序将被链接时,链接器首先确定程序中用到的外部函数的性质(如果函数定义在某个动态共享对象,链接器会将这个符号的引用标记为动态链接的符号,不对它进行重定位,把这个过程留到装载时进行

所以链接时要用到Lib.so,其中保存了完整的符号信息

动态链接时,动态链接器ld-2.6.so也会被映射到进程的地址空间;系统开始运行可执行文件之前,首先会把控制权交给动态链接器,由它完成所有动态链接工作后再把控制权交给程序,开始执行。

共享对象的最终装载地址在编译时是不确定的

  • 共享库装载:共享对象装载时,如何确定他在进程的虚拟地址空间中的位置。

由于在链接产生输出文件时就要假设模块被装载的目标地址,但共享库是装载时在知道地址(动态链接,装载时重定位)

静态链接中的重定位叫链接时重定位、动态链接时叫装载时重定位(windows中又叫基址重置)

装载时重定位的缺点是指令部分无法在多个进程之间共享(动态链接模块被装在映射至虚拟空间后,指令部分是多个进程之间共享的,可修改数据部分对于多个进程过来说有多个副本)

指令被重定位后对于每个进程来说是不同的(地址会变),不适合用装载时重定位;动态链接库中的可修改数据部分可以采用装载时重定位。

GCC参数-shared:输出采用装载时重定位的共享对象

GCC参数-fPIC:PIC(position independent code地址无关代码),将指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,数据部分可以在每个进程中拥有一个副本。

  • 地址无关代码:按模块中各种类型的地址引用方式分类

模块内部调用或跳转(被调用的函数与调用者处于同一模块中,此时相对位置固定,使用相对地址调用,即这条调用指令是地址无关的,无论模块被装在到哪个位置,这条指令都有效)

模块内部数据访问:指令中不能直接包含数据的绝对地址,要采用相对寻址。任何一条指令与它需要访问的模块内部数据之间的相对位置是固定的,那么只需要相对于当前指令加上固定的偏移量就可以访问模块内部数据了。

模块间数据访问:在数据段建立一个GOT(global offset table全局偏移表),指向这些变量的指针数组。当代码需要引用该全局变量时,通过GOT中相对应的项间接引用。

模块间调用、跳转:与上面类似,GOT中相应的项保存的是目标函数的地址。

地址无关代码的动态链接文件中没有重定位表

  • 可执行文件动态链接时,默认情况下GCC会使用PIC的方法产生代码段部分,在GOT:.got段。

  • 延迟绑定(通过PLT Procedure Linkage Table实现):优化动态链接性能。当函数第一次被用到时才进行绑定(符号分析和重定位)(一些错误处理函数或用户很少用到的功能模块 用得少)

GOT表不在链接器初始化时全部填充,而是等到发生函数引用时,再通过一层间接跳转填充

当引用某函数时,在重定位表中找到该函数的下标,压入堆栈;再将所在的模块ID压入堆栈,之后调用_dl_runtime_resolve()

_dl_runtime_resolve()进行一系列工作后将函数真正的地址填入到GOT表。

  • 动态链接过程:操作系统装载可执行文件(映射到进程虚拟内存)——>启动动态链接器(ld.so 一个共享文件)——>映射动态链接器到进程虚拟空间——>控制权交给ld.so的入口地址,ld.so执行一系列初始化操作,对可执行文件进行动态链接(装载所有共享对象、重定位和初始化)——>ld.so将控制权交给可执行文件入口地址,程序开始执行

ld.so的地址由ELF可执行文件决定,可执行文件中的.interp段中保存所需要的动态链接器的路径

Linux下几乎都是/lib/ld-linux.so.2,是一个软链接,指向真正的动态链接器

动态链接器在Linux下是Glibc的一部分,属于系统库级别,与Glibc版本号一致

Glibc更新时,软链接会自动更改指向,可执行文件不需要修改

  • .dynamic段中保存动态链接所需要的基本信息(符号表、字符串表、重定位表位置等)(ELF文件头保存静态链接相关内容)
  • .dynsym段:动态符号表,保存与动态链接相关的符号,不保存模块内部的符号和模块私有变量。(类似静态链接符号表.symtab)

很多时候动态链接的模块同时有.dynsym和.symtab两个表,.symtab中往往保存所有符号,包括.dynsym中的符号

动态符号表需要一些辅助表:.dynstr 动态符号字符串表(对应静态的.strtab)、符号哈希表.hash(加快符号查找速度)

  • 动态链接重定位表.rel.dyn(相当于静态下.rel.text)对数据引用的修正(.got段及数据段)、.rel.plt(相当于.rel.data)对函数引用的修正(.got.plt)

一个不是以PIC模式编译的共享对象,在装载时需要重定位;一个以PIC模式编译的共享对象,也需要重定位(代码段不需要,但数据段中有绝对地址的引用)

  • 动态链接器需要的可执行文件及进程一些信息,由操作系统传递给动态链接器,保存在进程的堆栈中(辅助信息数组)

  • 动态链接详细步骤:启动动态链接器——>装载所有需要的共享对象——>重定位和初始化

启动动态链接器:动态链接器自举(启动代码,完成它自己的需要的全局变量和静态变量的重定位工作;动态链接器本身不可以依赖其他任何共享对象);自举代码中,不可以使用全局变量和静态变量,不可以调用函数。

自举完成后,动态链接器将可执行文件和链接器本身的符号表合并到全局符号表

到.dynamic段中寻找所依赖的共享对象,将名字放到装载集合中

然后链接器在装载集合中取共享对象的名字,找到相应文件,将他相应的代码段和数据段映射到进程空间。

直到所有共享对象被装载进来为止,它们的符号表会合并到全局符号表中

然后链接器重新遍历可执行文件和每个共享对象的重定位表,将它们的GOT/PLT中的每个需要重定位的位置进行修正

  • 全局符号介入:一个共享对象里面的全局符号被另一个共享对象的同名全局符号覆盖。

linux的动态链接器,当一个符号需要被加入到全局符号表时,如果相同的符号名已经存在,则后加入的符号被忽略。

  • 内核在装载完可执行文件后,返回用户空间,将控制权交给程序入口:

静态链接:程序入口是ELF文件头里面的e_entry指定的入口

动态链接:入口是动态链接器自举代码的入口

  • 动态链接器本身可以作为可执行程序运行,本身是静态链接的(不能依赖于共享对象)

  • 显示运行时链接:更加灵活的模块加载方式,也叫运行时加载。让程序自己在运行时控制加载指定的模块,并且在不需要该模块时卸载。

在运行时装载进内存的共享对象,叫做动态装载库,文件本身的格式上与一般的共享对象没有区别。

但是动态库的装载是通过一系列动态链接器提供的API进行的,程序通过这几个API对动态库操作(而动态链接中,这一系列工作是由动态链接库自动完成的,对于程序本身是透明的)

8 Linux共享库的组织

  • 共享库的兼容性:共享库版本的更新可能会导致接口的更改或删除,导致依赖于该共享库的程序无法正常运行。这里的接口指二进制接口ABI(函数调用的堆栈结构、符号命名、参数规则、数据结构的内存分布等),导致共享库ABI改变的行为有:

导出函数的行为发生改变

导出函数被删除

导出数据的结构发生变化(结构体变量结构改变:结构成员删除、顺序改变等)

导出函数的接口发生变化(函数返回值、参数更改)

  • C++中的ABI兼容问题非常严重(C++复杂,模板、虚函数表等),最好不要使用C++作为共享库的接口。
  • linux采用共享库版本命名的方式解决兼容性问题,定义一套规则:

共享库的文件命名格式 libname.so.x.y.z
前缀lib,中间库名,后缀so,x主版本号,y次版本号,z发行版本号

主版本号:表示库的重大升级,不同主版本号的库之间不兼容(程序要重新编译)

次版本号:库的增量升级,增加一些新的接口符号,保持原符号。高的次版本号兼容低的次版本号

发行版本号:库的错误修正、性能改进,不添加不改变接口。相同主次版本号之间,发行版本号完全兼容。

linux中也有不遵守的,Glibc:它的基本c语言库使用libc-x.y.z.so

  • SO-NAME:一种共享库的命名机制,用来表示可执行程序依赖的是哪个共享库,每个共享库对应一个SO-NAME(共享库名+主版本号)

系统为每个共享库创建一个以它的SO-NAME命名的软链接,指向这个共享库。

这个软连接指向系统中最新版的共享库。

模块在编译链接和运行时,都使用共享库的SO-NAME,不使用详细版本号

共享库更新时,Linux中的“ldconfig”会遍历所有默认共享库目录(/lib,/usr/lib),然后更新所有软链接(如果添加共享库,会创建软链接)

  • 次版本号交会问题:一些历史系统中,将文件使用的共享库的名字、主、次版本号都记录,那么如果系统最终查找到的共享库的次版本号比它用到的低,就可能导致重定位错误或程序不能运行(根据不同处理策略)

  • 符号版本机制(为解决次版本号交会问题):让每个符号都有一个相关联的版本(主+次)。某个共享库L中包含6个版本的符号(符号版本到.6)(假设此时共享库次版本号为.6),一个程序只用到了该共享库的.3版本的符号(但要求的次版本号为.6),那么如果该程序在L的次版本号大于等于3的系统中运行就没有问题。

Linux下共享库的符号版本机制没有广泛使用,主要使用的是Glibc软件包中的20多个共享库。

GCC中允许多个版本的符号存在于一个共享库,即某种形式的符号重载:当共享库发生较小的变化时(仅更改一个符号的接口或含义),新版库能够完全保持向后兼容。

  • FHS(File Hierarchy Standard):这个标准规定系统中的系统文件应该如何存放,包括各个目录的结构、组织和作用,有利于促进开源操作系统之间的兼容性。FHS规定系统中主要有3个存放共享库的位置:

/lib:存放系统最关键和基础的共享库。动态链接库、C语言运行库、数学库、系统启动时需要的库。

/usr/lib:非系统运行时所需的关键共享库,主要是一些开发时用到的,一般那不会被用户的程序或shell脚本直接用到。

/usr/local/lib:一些跟操作系统本身并不十分相关的库,主要是第三方的应用程序的库

  • 共享库查找:程序所依赖的共享库的信息保存在.dynamic段中DT_NEED类型的项中,一般是相对地址。

动态链接器会在/lib、/usr/lib和/etc/ld.so.conf配置文件(文本配置文件,存放目录信息)中指定的目录中查找共享库。

ldconfig程序会创建共享库相应的SO-NAME,并集中放到/etc/ld.so.cache文件中,建立一个SO-NAME的缓存

动态链接器查找共享库时,直接到/etc/ld.so.cache中找,如果找不到再去遍历目录。

  • 环境变量:

LD_LIBRARY_PATH:指定某个应用程序的共享库查找路径。由若干路径组成,动态链接器会首先查找它指定的目录。

LD_PRELOAD:指定预先装载的一些共享库或目标文件,其中的文件会在动态链接器搜索共享库之前装载。其中的全局符号会覆盖后面加载的同名全局符号。

LD_DEBUG:可以打印动态链接器的调试功能。变量的值可以指定打印出来的信息。

  • 共享库创建:与创建一般共享对象一致。-shared参数、-fPIC参数、-soname等。

  • 创建共享库后要将它安装在系统中,最简单的方法:复制到某个标准共享库目录,运行ldconfig(需要root权限)

  • 共享库构造函数:attribute((constructor))属性的函数,在共享库被装载之前进行一些初始化工作(打开文件、网络连接之类)

9 Windows下的动态链接

  • DLL(Dynamic-Link-Library)动态链接库,对应linux中的共享对象。实际上和exe文件一样都是PE格式的二进制文件(PE文件头有的符号位记录是dll还是exe)

windows的API都是通过DLL的形式提供给程序开发者的。

  • windows平台上大量的大型软件都通过升级dll的形式自我完善,微软将这些升级补丁累计到一定程度形成软件更新包。
  • 一个DLL在不同进程中有不同的私有数据副本,DLL的代码不是地址无关的,只是在某些情况下可被多个进程共享。
  • 基地址:PE文件头中的Image Base的值,是这个PE文件的优先装载地址(进程地址空间的起始地址);相对地址(RVA):一个地址相对于基地址的偏移。

对于DLL文件,Image Base一般是0x10000000;对于EXE,一般是0x400000

windows装载dll时,首先尝试装载到Image Base指定的虚拟地址。

  • DLL共享数据段:windows允许将DLL的数据段设成共享(将需要进程间共享的变量分离出来,放到共享数据段,DLL有两个数据段:共享、私有)

  • DLL中需要显示的告诉编译器需要导出某个符号(__declspec(dllexport)),否则编译器默认所有符号都不导出(ELF中默认导出全部符号)

  • 使用DLL:引用DLL中导出函数和符号的过程,即导入过程(__declspec(dllimport))。

  • 使用/LD编译生成Release版的DLL,会生成“.dll” “.obj” “.exp” “.lib”;

“.lib”用来描述.dll的导出符号,包含目标文件与.dll链接时多需要的导入符号以及一部分“桩”代码,又被称作“胶水”代码,便于程序与DLL黏在一起。
“.exp”创建DLL时的临时文件:链接器在创建DLL时采用两边扫描,第一遍:收集所有目标文件中的导出符号信息,创建DLL导出表(导出表放在临时目标文件EXP文件的.edata段);第二遍:将EXP与其他目标文件链接在一起输出DLL,.eadta段被输出到DLL文件总成为导出表(但是一般链接器会把.edata链接到.rdata段中)

  • 由于PE的DLL中的代码段不是地址无关的,也就是说被装载时有个固定的目标地址(基地址),如果目标地址被占用,就要重新分配一个地址,此时代码段中涉及到的绝对地址引用,采用装载时重定位(对每个绝对地址都进行重定位:加上一个目标装载地址与实际装载地址的差值————重定位基址)。

  • 序号:一个导出函数的序号是函数在EAT(导出地址表)的地址中的下标加上一个Base值(默认是1),这样省去了函数名查找过程,函数名表也不用保存在内存里。(序号由程序员手工维护(.def文件中指定 Add @1 ),故现在基本不采用序号,而是直接使用符号名)

  • DLL绑定:每次程序运行时,所有依赖的DLL会被装载,一系列的导入导出符号依赖关系会被重新解析,大多数情况下,这些DLL会以同样的顺序被装载到同样的地址,所以导出符号的地址都是不变的。DLL绑定就是将这些导出函数的地址保存到模板的导入表中,省去每次启动时符号解析的过程。(editbin:MSVC中提供bind.exe用于DLL绑定,editbin对被绑定的程序的导入符号进行遍历查找,找到以后就把符号的运行时目标地址写入被绑定程序的导入表内。)

  • DLL HELL:DLL噩梦,即版本更新时出现的不兼容问题。

  • Manifest文件:清单文件,用来描述程序集,名字、版本号、程序集的各种资源、运行时依赖的资源(DLL及其他资源文件),通常是XML文件。

每个DLL有自己的Manifest文件,每个应用程序也有自己的Manifest。
XP以后的系统,执行可执行文件时会先读取程序集的manifest文件,获得该可执行文件需要调用的DLL列表,再根据DLL的manifest文件去寻找对应DLL并调用。

  • XP以后的系统在\windows下的WinSxs(Windows side-by-side)目录,有各个版本的DLL,每个DLL有一个独立的目录,目录名是机器类型+名字+公钥+版本号(强文件名)

第四部分 库与运行库

10 内存

  • 内存中从低地址到高地址:

保留区域(内存中收到保护而禁止访问的区域)

可执行文件映像(可执行文件的各个段,.int、.rodata、.text、.data、.bss等)

堆(应用程序动态分配的内存区域,用malloc或new分配的,向高地址增长)

unused(预留空间,堆可以向它扩大)

动态链接库映射区(映射装载的动态链接库,可执行文件依赖的共享库,从0x40000000开始)

unused(预留空间,栈向下扩大)

栈(维护函数调用的上下文)

内核空间(linux默认1GB,windows默认2GB)

  • 非法指针解引用:当指针指向一个不允许读或写的内存地址,而程序却试图利用指针来读或写该地址的时候。

系统的内存布局中,有些地址是始终不能读写的,例如0地址(C语言中常将无效指针赋值为0,正常情况下0地址上不可能有有效的可访问数据)

有些地址是一开始不允许读写,应用程序需要首先请求获取这些地方的读写权

某些地方一开始没有映射到实际的物理内存,应用程序需要事先请求将这些地址映射到实际物理地址(commit),然后才能读写这些地址。

当指针指向这些区域,利用它来读写就会引发错误。(实际情况中:程序员将指针初始为null,然后未赋一个合理值就使用)

  • 栈:栈保存了一个函数调用所需要的维护信息(堆栈帧Stack Frame,活动记录Active Record),没有栈就没有函数、没有局部变量。

esp寄存器:保存栈顶地址

ebp寄存器:帧指针,指向函数活动记录的一个固定位置。ebp所直接指向的数据是调用函数之前的ebp的值(将ebp的值压入栈,然后让ebp指向它),为了在函数返回时方便回复以前的ebp值。

  • 函数调用大体步骤:

把所有或一部分参数压入栈

当前指令的下一条指令的地址压入栈

跳转到函数体执行,函数体的标准开头如下:


push ebp                //ebp压入栈(old ebp)
mov ebp,esp             //让ebp指向此时的栈顶old ebp
(可选) sub esp,xxx       //在栈上分配xxx字节的临时空间
(可选) push xxx          //保留名为xxx的寄存器,因为编译器可能会要求某些寄存器在调用前后保持不变

函数实际内容

(可选)pop xxx            //恢复保存的寄存器
mov esp ebp             //恢复esp,回收局部变量空间(esp指向栈顶,即ebp的位置,即局部变量空间被回收)
pop ebp                 //从栈中恢复ebp的值
ret                     //从栈中取得返回地址,并跳转到该位置 
  • “烫”:有时看到一些没有初始化的变量或内存区域的值是烫。 因为sub这一步中,开辟一块栈空间,将每一个字节都初始化为0xCC,汉字编码就是烫。

将未初始化数据设置为0xCC,有助于判断一个变量是否没有被初始化,有时会使用0xCDCDCDCD作为未初始化标记:屯屯

  • 钩子(Hook):允许用户在某些时刻截获特性函数的调用。

有些函数在标准进入指令序列之前插入了一些特殊内容,例如:mov edi, edi(汇编后为占用2字节的机器码,作为占位符,无实际意义)

将这些占位符替换为跳转指令,原函数的调用就被转换为新函数的调用。(占用5个字节的nop指令可替换为一个jmp指令,2字节的mov指令替换为一个近跳指令(跳跃至当前地址前后127字节范围的目标地址))

  • 调用惯例:函数调用方和被调用方遵守的约定

函数参数的传递顺序和方式:传递方式一般是通过栈传递,也可以通过寄存器。调用方将参数压入栈,函数自己从栈中取出

栈的维护方式:函数参数的弹出工作有哪一方完成

名字修饰的策略:对函数本身名字的修饰

C语言中默认的cdecl惯例:参数从右到左入栈、函数调用方将参数弹出栈、在函数名前加一个下划线;

还有其他调用惯例:stdcall、fastcall、naked call、thiscall(C++中类成员函数的调用)等

  • 函数返回值的传递

eax是传递返回值的通道,函数将返回值存储在eax中,然后函数调用方读取eax,但eax只有4字节,对于5-8字节的返回对象,几乎所有调用惯例都是用eax+edx联合返回。

对于超过8字节的:C语言中使用一个临时的栈上内存区域作为中转,eax寄存器存储指向这块地址的指针。 临时对象的地址作为隐藏参数传递给函数——>函数将返回值数据拷贝给临时对象——>将临时对象的地址用eax传递——>函数返回后,调用方将eax指向的临时对象拷贝给实际目标对象。

  • 堆:一块巨大的内存空间,程序可以请求并自由使用,在程序主动放弃之前都会一直保持有效。

堆分配如果交给操作系统内核去做,每次申请释放堆内存都要系统调用,影响性能。

堆空间的分配一般是程序的运行库管理,运行库先向系统申请一块较大的堆空间,然后零售给程序用。 运行库需要堆分配算法来管理堆空间。

  • Linux进程堆管理:堆分配有关的系统调用:brk()、mmap()

brk():int brk(void* end_data_segment) 设置进程数据段的结束地址,将数据段的结束地址向高地址移动,扩大的那部分拿来作堆空间(指向堆空间结尾)

sbrk():是对brk系统调用的包装,参数是增量,返回值是增加后数据段结束地址。

mmap():向操作系统申请一段虚拟地址空间,可以映射到某个文件(起始地址和大小要是页的整数倍)。 当不映射到某个文件时,为匿名空间,可拿来作为堆空间。

malloc()函数在处理用户的空间请求时,小于128KB的请求,他会在现有的堆空间按照堆分配算法分配;大于128KB的请求会使用mmap()分配一块匿名空间。

在linux内核2.6版本里,共享库的装载地址被挪到了靠近栈的位置(0xbfxxxxxx附近,原来是从0x40000000),这样一来,malloc的最大申请数在2.9G左右。

  • Windows堆管理器

windows下进程地址空间较零碎,一个进程中有多个栈(一个线程一个独立的栈,默认栈1MB),一个进程中分配给堆用的空间也不是连续的(无法扩展时要创建新的)

windows下一个API,VirtualAlloc()用来向系统申请空间(类似Linux下mmap())要求的大小也必须是页的整数倍

堆管理器提供一套API实现堆的创建、分配、释放、销毁(HeapCreat()创建,是通过VirtualAlloc()实现的),malloc函数实际上是这一系列函数的包装。

堆管理器有两个位置:一个是NTDLL.DLL,另一份在内核Ntoskrnl.exe中,负责内核堆空间分配。

  • brk()中增长方式是向上增长,windows中大部分堆使用HeapCreate(),完全不遵照向上增长。(它们的上层都是malloc())
  • malloc()申请的空间,如果是虚拟空间,则每一次分配后返回的空间都可以看做是一块连续的地址。如果指物理空间,则不一定连续。因为一块连续的虚拟空间可能是由若干个不连续的物理页拼凑的。
  • 堆分配算法:管理一大块连续的内存空间,按照需求分配释放其中的空间(空闲链表)、位图、对象池。
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值