《程序员的自我修养》(三)——库与运行库

库与运行库

内存

  • 应用程序使用的内存空间一般都会包括以下“默认”区域:

    • 栈:栈用于维护函数调用的上下文。通常栈在用户空间的最高地址处分配,可能会有数兆字节的大小。
    • 堆:堆是用于容纳应用程序动态分配的内存区域,当程序使用malloc或new分配内存时,得到的内存来自堆里。堆通常存在于栈的下方(低地址方向),在某些时候,堆也可能没有固定统一的存储区域。堆一般比栈大得多,可以有几十到数百兆字节的容量。
    • 可执行文件映像:由装载器在装载时将可执行文件的内存读取或映射到这里。
    • 保留区:保留区并不是一个单一的内存区域,而是对内存中受到保护而禁止访问的内存区域的总称。
    • 动态链接库映射区:用于映射动态链接库。
  • Linux下一个进程里典型的内存布局(内核版本2.4.x):

  • 栈保存了一个函数调用所需要的维护信息,这常常被称为堆栈帧(Stack Frame)活动记录(Activate Record)。堆栈帧一般包括如下几个方面内容:

    • 函数的返回地址和参数。
    • 临时变量:包括函数的非静态局部变量以及编译器自动生成的其他临时变量。
    • 保存的上下文:包括在函数调用前后需要保持不变的寄存器。
  • int foo () { return 123;}这个函数的反汇编(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步在栈上分配出来的空间。
  • 函数的调用方和被调用方对函数如何调用需要有统一的约定,这种统一的约定称为调用惯例(Calling Convention)。通常调用惯例包含如下几方面的内容。

    • 函数参数的传递顺序和方式。
    • 栈的维护方式。
    • 名字修饰的策略。

常见的调用惯例.png

  • 函数将返回值存储在eax中,返回后的函数的调用方在读取eax。对于返回5~8字节对象的情况,几乎所有的调用惯例都是采用eax和edx联合返回的方式进行的。如果返回值类型的尺寸太大,如下图所示,C语言的函数返回时会使用一个临时的栈上内存区域作为中转,结果返回值对象会被拷贝两次。因而不到万不得已,不要轻易返回大尺寸的对象。

  • 一个普通的Windows进程的地址空间分布可以如图所示。

  • Windows系统提供了一个API叫做VirtualAlloc(),用来向系统申请空间,它与Linux下的mmap非常相似。实际上VirtualAlloc()申请的空间不一定只用于堆,它仅仅是向系统预留了一块虚拟地址,应用程序可以按照需要随意使用。但是,使用VirtualAlloc()函数申请空间时,系统要求空间大小必须为页的整数倍,即对于x86系统来说,必须是4096字节的整数倍。这就是操作系统的“批发”内存的接口函数了,4096字节起批。
  • 在Windows中,堆管理器提供了一套与堆相关的API可以用来创建(HeapGreate)、分配(HeapAlloc)、释放(HeapFree)和销毁(HeapDestroy)堆空间。其中,HeapGreate就是通过VirtualAlloc()来实现向操作系统批发一块内存空间。堆管理器通过这些API实现了堆分配算法。
  • 我们经常使用的malloc函数实际上是运行库提供的函数。它实际上是堆Heapxxxx系列函数的封装,当一个堆空间不够时,它会在进程中创建额外的堆。
  • 堆分配算法实际上就是解决如何管理一大块连续的内存空间,能够按照需求分配、释放其中的空间的题。堆分配算法有很多种,例如简单的空闲列表算法、位图算法、对象池算法等,也有很复杂、适用于某些高性能或者其他特殊要求的场合。实际上很多现实应用中,堆的分配算法往往是采用多种算法复合而成的。

运行库

  • 一个典型的程序运行步骤大致如下:

    • 操作系统在创建进程后,把控制权交到了程序的入口,这个入口往往是运行库中的某个入口函数。
    • 入口函数对运行库和程序运行环境进行初始化,包括堆、I/O、线程、全局变量构造,等等。
    • 入口函数在完成初始化之后,调用main函数,正式开始执行程序主体部分。
    • main函数执行完毕之后,返回到入口函数,入口函数进行清理工作,包括全局变量析构、堆销毁、关闭I/O等,然后进行系统调用结束进程。
  • C语言文件操作是通过一个FILE结构的指针来进行的。在操作系统层面上,文件操作也有类似于FILE的一个概念,在Linux里,这叫做文件描述符(File descriptor),而在Windows里,叫做句柄(Handle)。对于Windows中的句柄,于Linux中的fd大同小异,不过Windows的句柄并不是打开文件表的下标,而是其下标经过某种线性变换之后的结果。
  • IO初始化函数需要在用户空间中建立stdin、stdout、stderr及其对应的FILE结构,使得程序进入main之后可以直接使用printf、scanf等函数。
  • MSVC的I/O初始化主要进行了如下几个工作:

    • 建立打开文件表。
    • 如果能够继承自父进程,那么从父进程获取继承的句柄。
    • 初始化标准输入输出。
  • 入口函数只是冰山一角,它隶属于一个庞大的代码集合,这个代码集合叫做运行库。
  • 一个C语言运行库大致包含了如下功能:

    • 启动与退出:包括入口函数及入口函数所依赖的其他函数等。
    • 标准函数:由C语言标准跪地的C语言标准库所拥有的函数实现。
    • I/O:I/O功能的封装和实现。
    • 堆:堆的封装和实现。
    • 语言实现:语言中的一些特殊功能的实现。
    • 调试:实现调试功能的代码。
  • C语言的运行库从某种程度上来讲是C语言的程序和不同操作系统平台之间的抽象层,它将不同的操作系统API抽象成相同的库函数。Linux和Windows平台下的两个主要C语言运行库分别为glibc(GNU C Library)和MSVCRT(Microsoft Visual C Run-time)。值得注意的是,像线程操作这样的功能并不是标准的C语言运行库的一部分,但是glibc和MSVCRT都包含了线程操作的库函数。所以glibc和MSVCRT事实上是标准C语言运行库的超集,它们各自对C标准库进行了一些扩展。

  • 当你的程序里包含了某个C++标准库的头文件时,MSVC编译器就认为该源代码文件是一个C++源代码程序,它会在编译时根据编译选项,在目标文件的“.drevtve”段增加相应的C++标准库链接信息。
  • 线程的访问非常自由,它可以访问进程内存里的所有数据,甚至包括其他进程的堆栈,但实际运用中线程也拥有自己的私有存储空间。其中包括栈、线程局部存储(Thread Local Storage,TLS)和寄存器。
  • C/C++运行库在多线程环境下有很多坑,最典型的就是errno,还有像strtok()、printf(),一些与信号相关的函数等等都是线程不安全的。CRT采用TLS、加锁和改进函数调用方式的办法来改进线程安全问题。
  • 一旦一个全局变量被定义成TLS类型的,那么每个线程都会拥有这个变量的一个副本,任何线程对该变量的修改都不会影响其他线程中该变量的副本。
  • TLS用法很简单,如果要定义一个全局变量为TLS类型的,只需要在它定义前加上相应的关键字即可。

    • 对于GCC来说,这个关键字就是__thread,定义方式:__thread int number;
    • 对于MSVC来说,想要的关键字为__declspec(thread),定义方式:__declspec(thread) int number;(注意:在Windows Vista和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开始),一个称为中断处理程序(Interrupt Service Routine,ISR)。不同的中断具有不同的中断号,而同时一个中断处理程序一一对应一个中断号。在内核中,有一个数组称为中断向量表(Interrupt Vector Table),这个数组的第n项包含了指向第n号中断的中断处理程序的指针。当中断到来时,CPU会暂停当前执行的代码,根据中断的中断号,在中断向量表中找到对应的中断处理程序,并调用它。中断处理程序执行完成之后,CPU会继续执行之前的代码。一个简单的示意图如下:

  • 由于中断号是很有限的,操作系统不会舍得用一个中断号来对应一个系统调用,而更倾向于用一个或少数几个中断号来对应所有的系统调用。那么,对于同一个中断号,操作系统如何知道是哪一个系统调用要被调用呢?和中断一样,系统调用都有一个系统调用号,这个系统调用号通常就是系统调用在系统调用表中的位置。以Linux的0x80中断为例,系统调用号是由eax传入的。用户将系统调用号放入eax,然后使用0x80调用中断,中断服务程序就可以从eax中取得系统调用号,进而调用对应的函数。下面是以fork为例的Linux系统调用的执行流程。

  • 很多操作系统是以系统调用作为应用程序最底层的,而Windows的最底层接口是Windows API。Windows API是Windows编程的基础,尽管Windows的内核提供了数百个系统调用(Windows又把系统调用称作系统服务),但是出于种种原因,微软并没有将这些系统调用公开,而在这些系统调用之上,建立了这样一个API层,让程序员只能调用API层的函数,而不是如Linux一般直接使用系统调用。Windows在加入API层以后,一个普通的fwrite()的调用路径如图:

  • Windows API是以DLL导出函数的形式暴露给应用程序开发者的。微软把这些Windows API DLL导出函数的声明的头文件、导出库、相关文件和工具一起提供给开发者,并让它们称为Software Development Kit(SDK)。当我们安装了Visual Studio后,可以在SDK的安装目录下找到所有的Windows API函数声明。其中有一个头文件“Windows.h”包含了Windows API的核心部分,只要我们在程序里面包含了它,就可以使用Windows API的核心部分了。
  • 在Windows NT系列的平台上,系统的DLL在实现上都会依赖一个更为底层的DLL,叫做NTDLL.DLL,由它来进行系统调用,NTDLL.DLL把Windows NT内核的系统调用都包装了起来,并且其导出函数对于应用程序开发者是不公开的,原则上应用程序不应该直接使用其中的任何导出函数。
阅读 426

推荐阅读

学技术简单记,吴尼玛带你记笔记。

0 人关注
32 篇文章
专栏主页