LibraryandRuntimeMemory应用程序使用的内存空间一般包括以下“默认”区域:Stack:栈用于维护函数调用的上下文。通常堆栈分配在用户空间的最高地址,大小可以是几兆字节。堆:堆是用来容纳应用程序动态分配的内存区域。当程序使用malloc或new分配内存时,获取的内存来自堆。堆通常存在于栈的下方(低地址方向),在某些时候,堆可能没有固定统一的存储区域。堆一般比栈大很多,可以有几十到几百兆字节的容量。可执行文件映像:可执行文件的内存在加载时被加载器读取或映射到这里。保留区:保留区不是单一的内存区域,而是内存中受保护的内存区域的总称,禁止访问。动态链接库映射区:用于映射动态链接库。Linux(内核版本2.4.x)下一个进程中的典型内存布局:栈中存储了函数调用所需的维护信息,通常称为栈帧(StackFrame)或活动记录(ActivateRecord)。栈帧一般包括以下几个方面:函数的返回地址和参数。临时变量:包括函数的非静态局部变量和编译器自动生成的其他临时变量。保留上下文:包括函数调用前后需要保持不变的寄存器。intfoo(){return123;}该函数的反汇编(VC9,i386,Debug模式)代码:第4步代码用于调试,大致相当于如下伪代码:edi=ebp-0xC0;ecx=0x30;eax=0xCCCCCCCC;for(;ecx!=0;--ecx,edi+=4)*((int*)edi)=eax;可以看出,这段代码实际上改变了内存地址,从ebp-x0c0开始,一直到ebp的段都被初始化为0xCC(0xCCCC的汉字代码比较火,所以我们会看到未初始化的变量或者内存区的值为“热”)调试时。正好是第2步在栈上分配的空间,函数的调用者和被调用者需要对如何调用函数有一个统一的约定。这种统一的约定称为调用约定(CallingConvention)。通常调用约定包括以下几个方面。传递函数参数的顺序和方式。如何维护堆栈。名称修改策略。函数将返回值存储在eax中,返回函数的调用者正在读取eax。对于返回5-8字节对象的情况,几乎所有的调用约定都是以eax和edx联合返回的方式进行的。如果返回值类型的大小过大,如下图所示,C语言函数在返回时会使用栈上的一块临时内存区域作为传递,得到的返回值对象会被复制两次。因此,除非万不得已,否则不要轻易退还大件物品。一个常见的Windows进程的地址空间分布如图所示。Windows系统提供了一个名为VirtualAlloc()的API,用于向系统申请空间,这与Linux下的mmap非常相似。实际上,VirtualAlloc()申请的空间并不一定只用于堆,它只是为系统预留了一个虚拟地址,应用程序可以根据需要使用。但是在使用VirtualAlloc()函数申请空间时,系统要求空间大小必须是页的整数倍,即对于x86系统,必须是4096字节的整数倍。这是操作系统“批发”内存的接口函数,从4096字节开始。在Windows中,堆管理器提供了一组堆相关的API,可以用来创建(HeapGreate)、分配(HeapAlloc)、释放(HeapFree)和销毁(HeapDestroy)堆空间。其中,HeapGreate是通过VirtualAlloc()实现将一块内存空间批发给操作系统。堆管理器通过这些API实现堆分配算法。我们经常使用的malloc函数其实是运行时库提供的函数。它实际上是对Heapxxxx系列函数的封装。当一个堆空间不够时,它会在进程中创建一个额外的堆。堆分配算法实际上解决的是如何管理一大片连续的内存空间,按需分配和释放空间。堆分配算法有很多种,比如简单的freelist算法、位图算法、对象池算法等,也有的很复杂,适用于一些性能要求高或者有其他特殊要求的场合。事实上,在很多实际应用中,堆分配算法往往是由多种算法组合而成。一个典型的运行时库程序运行过程大致如下:操作系统创建进程后,将控制权交给程序的入口,而这个入口往往是运行时库中的一个入口函数。入口函数初始化运行库和程序运行环境,包括堆、I/O、线程、全局变量构造等。入口函数完成初始化后,调用main函数正式开始执行程序的主体部分。main函数执行完后,返回入口函数。入口函数执行清理工作,包括全局变量销毁、堆销毁、关闭I/O等,然后执行系统调用结束进程。C语言文件操作是通过一个指向FILE结构的指针来执行的。在操作系统层面,文件操作也有一个类似于FILE的概念。在Linux中,这被称为文件描述符(Filedescriptor),而在Windows中,它被称为句柄(Handle)。对于Windows中的句柄,类似于Linux中的fd,但Windows中的句柄不是打开文件表的下标,而是下标线性变换的结果。IO初始化函数需要在用户空间建立stdin、stdout、stderr及其对应的FILE结构,这样程序进入main后就可以直接使用printf、scanf等函数。MSVC的I/O初始化主要完成以下任务:建立一个打开的文件表。如果可以从父进程继承,则从父进程获取继承的句柄。初始化标准输入和输出。入口函数只是冰山一角,它属于称为运行时库的庞大代码集合。一个C语言运行时库大致包括以下函数:启动和退出:包括入口函数和入口函数所依赖的其他函数。标准函数:由跪在C语言标准中的C语言标准库拥有的函数实现。I/O:I/O功能的封装和实现。堆:堆的封装和实现。语言实现:语言中一些特殊功能的实现。Debug:实现调试功能的代码。从某种程度上说,C语言运行时库是C语言程序与不同操作系统平台之间的一个抽象层,它将不同操作系统的API抽象成相同的库函数。Linux和Windows平台下的两个主要的C语言运行时库是glibc(GNUCLibrary)和MSVCRT(MicrosoftVisualCRun-time)。值得注意的是,线程操作等函数并不是标准C语言运行时库的一部分,但是glibc和MSVCRT都包含了线程操作的库函数。所以glibc和MSVCRT实际上是标准C语言运行时库的超集,它们都对C标准库进行了一些扩展。当你的程序包含一个C++标准库的头文件时,MSVC编译器认为源代码文件是一个C++源代码程序,它会在该节中添加“。相应的C++标准库链接信息。”线程访问非常自由,它可以访问进程内存中的所有数据,甚至包括其他进程的栈,但是在实际使用中,线程也有自己私有的存储空间。这些包括栈,ThreadLocalStorage(TLS),并注册。C/C++运行时库在多线程环境中有很多陷阱。最典型的就是errno,strtok()、printf()等函数,还有一些信号相关的函数都不是线程安全的。CRT使用TLS、锁和改进的函数调用方式来改善线程安全问题。一旦一个全局变量被定义为TLS类型,每个线程都会有该变量的副本,任何线程对该变量的任何修改都不会影响其他线程中该变量的副本。TLS的使用非常简单。如果要定义一个全局变量为TLS类型,只需要在其定义前加上相应的关键字即可。对于GCC,这个关键字是__thread,定义为:__threadintnumber;对于MSVC,所需的关键字是__declspec(thread),定义为:__declspec(thread)intnumber;(注意:在WindowsVista和2008之前的操作系统中此方法不可用。)对于Windows系统,一般情况下一个全局变量或静态变量会放在“.data”或“.bss”段中,但当我们使用__declspec(thread)定义线程私有变量时,编译器会将这些变量放入PE文件的“.tls”部分。当系统启动一个新的线程时,它会从进程堆中分配一个足够大小的空间,然后将“.tls”部分的内容复制到这个空间中,因此每个线程都有自己独立的“.tls”副本。使用CRT时(基本上所有程序都使用CRT),请尽量使用_beginthread()/_beginthreadex()/_endthread()/_endthreadex这组函数来创建线程。在MFC中,有一组类似的函数AfxBeginThread()和AfxEndThread(),它们是MFC层面的线程包装函数。他们会维护MFC的线程相关结构。我们在使用MFC类库的时候,尽量利用它提供的线程包装器功能来保证程序正确运行。系统调用和API为了让应用程序具有访问系统资源的能力,并允许程序使用操作系统来执行一些操作系统必须支持的行为,每个操作系统都为应用程序提供了一组接口使用。这些接口通常通过中断来实现。例如Linux使用中断号0x80作为系统调用的入口,Windows使用中断号0x2E作为系统调用的入口。中断一般有两个属性,一个叫中断号(从0开始),一个叫中断处理程序(InterruptServiceRoutine,ISR)。不同的中断有不同的中断号,同时一个中断处理程序与一个中断号一一对应。在内核中,有一个叫做中断向量表(InterruptVectorTable)的数组,这个数组的第n项包含了一个指针,指向第n个中断的中断处理程序。当有中断到来时,CPU会暂停当前正在执行的代码,根据中断的中断号在中断向量表中找到对应的中断处理程序,并调用它。中断处理程序执行完后,CPU会继续执行之前的代码。一个简单的图如下:由于中断号非常有限,操作系统不会愿意用一个中断号去对应一个系统调用,而是更愿意用一个或几个中断号去对应所有的系统调用.那么,对于同一个中断号,操作系统如何知道要调用哪个系统调用呢?和中断一样,系统调用也有一个系统调用号,通常是系统调用在系统调用表中的位置。以Linux的0x80中断为例,系统调用号由eax传入。用户将系统调用号放入eax,然后使用0x80调用中断,中断服务程序可以从eax中获取系统调用号,然后调用相应的函数。以下是以fork为例的Linux系统调用的执行流程。许多操作系统使用系统调用作为应用程序的底层,而Windows的底层接口是WindowsAPI。WindowsAPI是Windows编程的基础。虽然Windows内核提供了上百种系统调用(Windows也称系统调用为系统服务),但是由于种种原因,微软并没有将这些系统调用公开,而在这些系统调用之上,建立了这样一个API层,让程序员只能调用API层的函数,不能像linux那样直接使用系统调用。Windows加入API层后,一个常见的fwrite()调用路径如图所示:WindowsAPI以DLL导出函数的形式暴露给应用程序开发者。微软向开发者提供了这些WindowsAPIDLL导出函数的声明头文件、导出库、相关文件和工具,并称之为SoftwareDevelopmentKit(SDK)。当我们安装好VisualStudio后,我们可以在SDK安装目录下找到所有的WindowsAPI函数声明。其中一个头文件“Windows.h”包含了WindowsAPI的核心部分,我们只要在程序中包含它,就可以使用WindowsAPI的核心部分。在WindowsNT系列平台上,系统DLL会依赖一个名为NTDLL.DLL的低级DLL来进行系统调用。NTDLL.DLL封装了WindowsNT内核的系统调用。并且其导出函数不对应用开发者开放,原则上应用程序不应直接使用任何导出函数。
