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

库与运行库

内存

  • 应用程序使用的内存空间通常都会包括如下“默认”区域:程序员

    • 栈:栈用于维护函数调用的上下文。一般栈在用户空间的最高地址处分配,可能会有数兆字节的大小。
    • 堆:堆是用于容纳应用程序动态分配的内存区域,当程序使用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内核的系统调用都包装了起来,而且其导出函数对于应用程序开发者是不公开的,原则上应用程序不该该直接使用其中的任何导出函数。
相关文章
相关标签/搜索