程序员的自我修养:库与运行库

10 内存

10.1 程序和内存空间

现代的程序都使用的是虚拟内存空间,它的大小仅仅和CPU地址线宽度相关。虚拟内存空间和物理内存通过页目录和页表来实现映射。

在虚拟空间中内存并不是所有的地方都可以随意使用,它被划分为了内核空间和用户空间。内核空间仅供操作系统使用,用户空间才是用户程序所使用的内存区域。在用户空间中,内存被分为了几个默认的区域:

  • 栈:用户维护函数调用时上下文参数,如参数和返回地址
  • 堆:用来提供给程序进行动态内存分配,像malloc函数分配的内存空间就在堆上
  • 可执行文件镜像:存储编译出来的可执行文件
  • 保留区:一部分不能被访问的空间区域的总称
  • 动态链接映射:如果程序是动态链接的,所需要的动态链接库会被映射到虚拟内存空间中

10.1-1

10.2 栈和调用惯例

10.2.1 栈的结构

栈是一种后进先出的数据结构,它在编程语言中重要的作用之一就是跟踪函数的调用状态。函数可以层层嵌套调用,然后再一一返回,中间不会回到其他函数,也不会跳过中间的部分直接回到顶部函数,这都依赖于栈的存在。

函数栈中保存了函数调用和返回所需要的信息,主要有以下几个部分:

  • 函数的返回地址和参数
  • 函数中声明的临时变量
  • 保存的上下文环境

在i386处理器中,使用ebp和esp这两个寄存器来跟踪一个函数调用,esp始终指向栈顶部,也就是说同时指向了当前调用的函数的最顶部,ebp始终指向当前调用的函数的最底部。

10.2-1

函数调用后会在函数栈中建立起对应的栈帧,一个完整的函数调用帧是由调用者和被调用者双方建立起来的,具体步骤如下:

  1. 调用者把所有或者部分参数压入栈中,如果有其他参数没有入栈,就用特定的寄存器传递参数

  2. 把当前指令的下一条指令压入栈中,作为返回地址

  3. 跳转到被调用的函数体执行具体逻辑 (2 和 3 这两个步骤是由汇编指令call来完成的,是一个原子操作)


  4. 被调用函数执行 push ebp把ebp压入栈中

  5. 被调用函数再执行mov ebp,esp把esp保存到ebp中

翻译成对应的汇编代码应该类似这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int func(int arg) {
push ebp ;这两行代码是每个函数体中都有的标准代码
mov ebp,esp ;用来维护栈帧结构,所有的函数默认应该都有前面着两行代码

sub esp,局部变量所占空间 ;可选,用来保存函数的局部变量
push xxx ;可选,保存指定的寄存器


;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;调用其他函数的一般方法 ;;
;;
;如果函数中要调用其他函数,一般是 ;;
mov 寄存器,参数 ;如果是用寄存器传递参数 ;;
push ;如果参数较多就用栈传递参数 ;;
call 函数名 ;call指令会保存下一条指令再跳转 ;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

mov eax,0 ;通过寄存器返回函数结果 即 return 0;
;太大的对象需要通过更复杂的方式来返回
}

10.2.2 调用惯例

一个完整的栈帧需要调用者和被调用者共同构建,所以两者之间的协议就很重要了。调用者和被调用者之间的协议叫调用惯例,一般会规定下面几个方面的内容:

  • 函数参数的传递顺序和方式

    参数较少时可以通过寄存器来传递(寄存器速度快,效率也更高); 但是参数较多时就要用栈,用栈传递时就要约定好参数时从左到右还是从右到左入栈,然后另一方再从栈中获取参数

  • 栈的维护方式

    参数入栈以后,调用函数,函数完成以后参数已经不再需要,此时需要把参数从栈中弹出释放空间,这个步骤调用者或者被调用者都可以做,所以也需要约定好

  • 名字修饰的算法

    从函数名到对应的符号名,这个算法由很多固定的约定,比如c语言的cdecl,就规定了把函数名前面添加一个下划线作为符号名。如foo函数,对应的符号名就是_foo

下面是几个典型的调用惯例:

调用惯例 参数出栈方 参数传递 名字修饰
cdecl 函数调用方 从右到左压栈 下划线+函数名
stdcall 函数本身 从右到左压栈 下划线+函数名+@+参数字节数
fastcall 函数本身 头两个dword(4字节)用寄存器,其他用栈 @+函数名+@+参数字节数

10.2.3 函数返回值传递

对于返回5~8字节的对象来说,几乎所有的调用惯例都是采用eax和edx联合返回的方式,在64位平台上可能会用更多的寄存器。但是如果返回对象过于庞大,则会用下面的方法来传递参数(用C语言作为例子,其他语言可能细节不一样):

  • 调用方在栈上开辟了一片空间,把这片空间作为返回对象的临时存放区域,这里称为temp
  • 把temp的地址作为隐藏参数传递给被调用函数
  • 被调用函数完成功能以后,把结果拷贝到temp空间中,并把temp空间的地址作为返回值返回(一般用eax寄存器来返回结果)
  • 回到主调函数后,主调函数再把eax寄存器指向的内容拷贝到真正的目的地内存中

可以看到temp的空间被拷贝了两次,所以在c语言中,一般不会直接返回大字节数的对象,而是事先用malloc申请一块内存,然后再把内存指针传入函数中。这样就避免了多次内存读写。

10.3 堆与内存管理

10.3.1 堆的结构

堆内存是应用程序中比较大的一块内存空间,也是使用起来最自由的内存空间(当然,也最容易出内存泄露的bug)。一般来讲,内存是操作系统管理的,可以用系统调用来直接申请内存,不过这样会频繁进行用户态/内核态的切换。

所以一般语言的runtime在申请堆内存时,会向操作系统“批发”一大块内存,然后再“零售”给程序。当内存用完时会再向系统“进货”。c语言的runtime对内存管理就是基于上述的机制,而且runtime本身也会严格管理内存的分配,不会把同一块内存空间分配两次。这个强大高效的算法就是堆的分配算法。

10.3.2 Linux进程堆管理

Linux提供了两个系统调用来管理堆空间:一个是brk(),一个是mmap()

brk调用的实际作用起始是设置进程数据段的结束地址,可以扩大或者缩小。如果向高地址空间扩展,扩展出来的空间就可以用来做堆空间。

mmap是用来向操作系统申请一段虚拟地址空间,这个空间可以映射到文件,也可以不映射。不映射到文件时,就称为匿名空间。匿名空间就可以拿来当堆空间。

10.3.4 堆分配算法

  • 空闲链表

    把堆上的空间块按照链表的方式穿起来。使用时,遍历链表查找满足条件的块

  • 位图

    把堆空间分割成固定大小的块,然后用一个位图来记录分配情况

  • 对像池

    如果分配对象的大小是固定的几个值,可以用对像池的办法来管理

  • 多种算法结合

    实际环境中,分配算法一般是根据多种算法复合而成。对于glibc来说,小于64字节的是采用类似于对像池的方法; 而对于大于512字节的空间申请采用的是最佳适配算法; 对于64字节和512字节之间的,会采取上述方法中的最佳折中策略; 对于大于128KB的申请,它会使用mmap机制直接向操作系统申请空间

11 运行库

11.1 入口函数和程序初始化

11.1.1 程序从main开始吗

操作系统装载C语言编写的程序以后,最先运行的并不是main函数,而是要进行一系列准备工作后才会调用main函数。

执行这些准备工作的函数称为入口函数,它一般是运行库的一部分,一个典型的准备工作大致有下面几个方面:

  • 操作系统创建进程,把控制权交给程序的入口,这个入口一般是程序语言对应的运行库中的入口函数
  • 入口函数对运行库和程序运行环境进行初始化,包括堆、I/O、线程、全局变量构造等等
  • 入口函数初始化完成后,调用main函数,正式开始执行程序主体部分
  • main函数执行完毕之后,返回到入口函数。入口函数进行收尾的清理工作,包括全局变量析构、堆销毁、关闭I/O等等。然后调用执行系统调用结束进程

11.1.2 入口函数如何实现

GLIBC入口函数

在Linux中glibc是C语言的运行库,它的程序入口为_start。它的大致逻辑用伪代码来表示类似于下面这样:

1
2
3
4
5
6
7
void _start()
{
%ebp = 0; //ebp寄存器设置为0
int argc = pop from stack
char** argv = top of stack
__libc_start_main(main, argc, __libc_csu_init, __libc_csu_fini, edx, top of stack)
}

_start函数主要是准备各种参数,再调用__libc_start_main函数来完成工作,它的函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int __libc_start_main (
int (*main) (int, char **, char **), //main函数
int argc, //参数个数
char * __unbounded *__unbounded ubp_av, //参数列表和环境变量
__typeof (main) init, //在main之前执行的逻辑
void (*fini) (void), //在main之后执行的逻辑
void (rtld_fini) (void), //runtime loader fini,动态加载相关的收尾逻辑
void * __unbounded stack_end //栈的最高地址
)

{//函数主体
// .........其他逻辑.........
__environ = ubp_ev;
__pthread_initialize_minimal();
__cxa_atexit(rtld_fini, NULL, NULL);
__libc_init_first(argc, argv, __environ);
__cxa_atexit(fini, NULL, NULL);
(*init)(argc, argv, __environ);

// .........其他逻辑.........

result = main(argc, argv, __environ);
exit(result);
}

可见在进行系列的初始化后,最终调用了main函数。

11.1.3 运行库与I/O

I/O是程序中非常重要的部分,包括文件、管道、网络、命令行、信号等都属于I/O的范围。而文件则是实际使用最多的I/O实体。

在Linux里,文件操作是通过文件描述符(File Descriptor)进行的。设置文件描述符的原因是防止用户随意读写操作系统内核的文件对象。无论是Linux还是Windows,文件描述符总是和内核的文件对象相关联的,内核可以通过文件描述符来计算出内核的文件对象的地址,但这个能力并不对普通用户开放。

在Linux中,值为0、1、2的fd分别代表标准输入、标准输出和标准错误输出。在程序中打开文件得到的fd从3开始增长。fd具体是什么呢?在内核中,每一个进程都有一个私有的“打开文件表”,这个表是一个指针数组,每一个元素都指向一个内核的打开文件对象。而fd就是这个表的下标。当用户打开一个文件时,内核会在内部生成一个打开文件对象,并在这个表里找到一个空项,让这一项指向生成的打开文件对象,并返回这一项的下标作为fd。由于这个表处于内核,并且用户无法访问到,因此用户即使拥有fd,也无法得到打开文件对象的地址,只能够通过系统提供的函数来操作。

11.2 C/C++运行库

11.2.1 C语言运行库

任何一个C语言程序,它背后都有一套庞大的代码来进行支撑,以使得该程序能够正常运行。这套代码至少包括入口函数,以及所依赖的函数所构成的函数集合。以及各种标准库函数的实现。这样的代码集合称为运行时库(Runtime Library)。

一个C语言运行库大致包含了如下功能:

  • 启动与退出:包括入口函数及入口函数所依赖的其他函数等
  • 标准函数:由C语言标准规定的C语言标准库所拥有的函数实现
  • 堆:堆的封装和实现
  • 语言实现:语言中一些特殊功能的实现
  • 调试:实现调试功能
11.2.2 C语言标准库

C语言现在有了统一的标准,即ANSI C,它的标准库由24个C头文件组成,例举几个常见的:

  • 标准输入输出、文件操作:stdio.h
  • 字符操作:ctype.h
  • 字符串操作:string.h
  • 数学函数:math.h
  • 资源管理、格式转换:stdlib.h
  • 时间/日期:time.h
  • 断言:assert.h
  • 常数定义:limits.h / float.h
  • 变长参数:stdarg.h
  • 非局部跳转:setjmp.h
变长参数

变长参数是C语言的特殊参数形式。如下面的函数声明:

1
int print(const char* format, ...);

printf函数除了第一个参数类型为const char*外,后面可以追加任意数量和类型的参数。这主要得益于C语言默认的cdecl调用约定,即由函数调用者负责参数的压栈和出栈。

非局部跳转

C语言中有goto语句,不过goto只能在函数内跳转,想要跳转到其他函数内部执行就不行了。不过C语言提供了setjmp.h库,来提供非局部跳转的能力。

11.2.3 glibc与MSVC CRT

glibc的标准静态库位于/usr/lib/libc.a 除了标准库外,它还有几个辅助程序运行的运行库,它们是/usr/lib/crt1.o/usr/lib/crti.o/usr/lib/crtn.o 这几个文件在程序加载中有着关键的作用。

crt1.o 里面包含的就是程序的入口函数_start,由它负责调用__libc_start_main 初始化libc并且调用main函数进入真正的程序主体。

由于C++的出现和ELF文件的改进,出现了必须在main函数之前执行的全局/静态对象构造和必须在main之后执行的全局/静态对象析构,为了满足这一要求,运行库在每个目标文件中引入两个与初始化相关的段.init.finit ,运行库会保证所有位于这两个段中的代码会先于/后于main函数执行。

为了方便运行库调用,最终输出文件中的.init.finit两个段实际上分别包含的是_init()_finit()这两个函数。crti.ocrtn.o 这两个目标文件中包含的代码实际上是_init()_finit()这两个函数的开始和结束部分,当这两个文件和其他目标文件按照顺序链接起来后,刚好形成两个完整的函数_init()_finit()

在最终链接完成之后,输出的目标文件中的.init 段只包含了一个函数_init(),这个函数的开始部分来自于crti.o.init 段,结束部分来自于crtn.o.init 段。为了保证最终输出文件中.init.finit 的正确顺序,我们必须保证在链接时,crti. o 必须在用户目标文件和系统库之前,而crtn.o 必须在用户目标文件和系统库之后。所以链接器的输入文件顺序一般是:

1
$ ld crt1.o crti.o [user_objects] [system_libraries] crtn.o

由于crt1.o 不包含.init 段和.finit 段,所以不会影响最终生成.init.fnit 段时的顺序。输出文件中的.init 段看上去应该如图所示(对于.finit 来说也是一样):

10.1-1

在默认情况下,链接器会将libccrt1.o等这些CRT和启动文件与程序的模块链接起来。但是有些时候我们可能不需要这些文件,或者希望使用自己的libccrt1.o等启动文件,以替代系统默认的文件,这种情况在嵌入式系统或操作系统內核编译的时候很常见。GCC提高了两个参数-nostartfile-nostdlib,分别用来取消默认的启动文件和C语言运行库。

由于.init.finit段的特殊性(在main之前/后,执行),所以除了用来进行全局对象构造和析构外。一些监控用户程序性能或者调试工具经常用它来进行一些初始化和反初始化的工作。不过这些代码不能使用普通函数,因为函数的返回指令会使_init()函数提前返回,所以必须使用汇编代码,不能让编译器产生ret指令。

12 系统调用与API

12.1 系统调用介绍

12.1.1 什么是系统调用

操作系统的主要功能就是把硬件资源抽象化,抽象化以后的硬件资源通过特定的接口给应用程序使用,而最基础的接口就是系统调用。系统调用最主要的特点就是调用以后系统会切换到内核态执行固定的内核代码,这个过程主要是通过中断来实现的。在Linux中使用0x80号中断作为系统调用入口,而在Windows里系统调用中断号是0x2E 。

系统调用涵盖的功能很广,有程序运行所必需的支持,例如创建/退出进程和线程、进程内存管理,也有对系统资源的访问,例如文件、网络、进程间通信、硬件设备的访问,也可能有对图形界面的操作支持,例如 Windows下的GUI机制。

不过对于Windows来说,系统调用实际上不是它和程序最低层的接口,它的最底层接口是API。设计API层的目的是为了消除不同硬件和内核版本的差异,这也造就了Windows恐怖的兼容性。

12.1.2 Linux系统调用

在x86系列的CPU中,系统调用由0x80中断完成,参数通过寄存器传递。EAX寄存器用于表示系统调用的接口号,比如EAX=1表示退出进程(exit); EAX=2表示创建进程(fork); EAX=3表示读取文件或IO(read); EAX=4表示写文件或lO( write)等,每个系统调用都对应于内核源代码中的一个函数。它们都是以“sys_”开头的,比如exit调用对应内核中的sys_exit函数。当系统调用返回时,EAX又作为调用结果的返回值。

EAX 名字 C语言定义 含义 参数
1 exit void _exit(int status); 退出进程 EBX:退出码
2 fork pid_ fork(void); 复制进程 EBX:复制参数
3 read ssize_t read(int fd, void *buf, size_t count); 读文件 EBX:文件handler
4 write ssize_t write(int fd, const void *buf, size_t count); 写文件 EBX:文件handler

这些系统调用可以直接用汇编代码根据中断定义来直接调用,也可以用C语言封装的接口来调用。不过直接虽然可以直接使用系统调用,但系统调用的功能太过原始。如直接用open()、read()、close(),来处理文件的话,需要自己处理缓冲、按行读取等功能,所以不如直接用glibc提供的fopen()、fread()、fclose()等现成的功能。

12.1.3 系统调用的弊端

系统调用的弊端是太过于底层,使用不方便。另外一点就是和系统实现相关,移植性不好。

所以人们在系统调用之上增加了跨平台的语言运行库,如C语言的运行库。使用C语言的fread函数时,在不同的系统中可能调用底层系统调用是不一样的,但向语言层面提供统一的接口,这样就可以只写一份代码然后运行在不同的系统上。

12.2 系统调用原理

12.2.1 特权级与中断

在x86系列的CPU上,系统代码和应用代码具有不同的特权等级。系统代码在最高级别运行,可以操作所有的硬件。应用代码在进行硬件操作时,就会使用中断来调用系统提供的功能来间接使用硬件。

中断是类似于高级语言中的回调方法,操作系统内核在启动后会注册一系列的中断处理方法,当应用程序调用中断时,会传入对应的终端号,来指明使用哪一个中断方法。

12.2.2 基于int的Linux的经典系统调用实现

触发中断

汇编指令int会触发CPU去查找事先准备好的中断表,用约定好的寄存器参数来调用中断处理函数。

切换堆栈

当实际执行中断表中的函数前,CPU必须先保存现场,以便完成任务后继续执行用户程序。这里保存现场主要就是保存栈寄存器(ESP、EBP等),以及当前的执行代码用到的寄存器(CS、EIP、EFLAG 等等)。保存好现场后,就会切换到内核态,所谓的切换起始就是向这些寄存器写入新的值,这些新值指向内核代码和内核内存区域。最后再执行中断处理函数。

中断处理函数

中断处理函数是操作系统内核预先设定好的,如任务切换,CPU执行异常处理等,它们在执行固定的逻辑后,把用户程序的执行环境恢复。最后再执行iret指令把执行流返回到之前发生中断的地方。

12.2.3 Linux的新型系统调用机制

Linux2.5版本后开始支持新的系统调用指令,即sysentersysexit,这两个指令和int 功能类似,但是把int执行的绝大部分逻辑都用硬件电路来实现了,这样性能要比int指令更好(int是一个通用的中断机制,并不是仅仅只能做内核/用户态的切换)。