5

读书笔记(三十一) 《装载、链接与库》#4 内核运行库

 3 years ago
source link: http://www.luzexi.com/2021/07/04/%E8%AF%BB%E4%B9%A6%E7%AC%94%E8%AE%B033
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

读书笔记(三十一) 《装载、链接与库》#4 内核运行库

已发布在微信公众号上,点击跳转

看此书的起源是我在了解Linux注入技术的时候翻阅到的,由于注入技术需要用到很多ELF格式的内容,很多网络上的技术文章都指向了同一本书。也刚好周围的同事有此书,便翻阅了一下,这一番翻阅打开了我对程序世界的又一扇大门。

很快我就自己买了此书并阅读完成,整本书给我很大的震撼,让我对程序从编译到链接到装载有了更深刻的认识。

我把我的整个学习过程以及对书本的理解,用自己的语言和自己画的图表达出来,让读者能够更容易接受到我所学的知识。

了解编译过程

了解动态库和静态库的装载细节

了解可执行程序装载和执行过程

了解可执行文件和动态库的数据格式

c/c++编译器是如何将cpp编译为可执行文件的?

多个c/c++文件是如何编译成一个可执行文件的?

操作系统内存是如何初始化和管理的?

动态库和静态库的链接和装载过程是怎样的?

操作系统的用户态和内核态是如何运作的?

前面我们介绍了Linux的动态库在装载时的动态链接过程,以及可执行文件的装载过程。这里简单介绍下Windows的动态库的装载过程。

Windows下的动态链接与Linux下的相比有很多相似之处,但也有很多不同的地方。Windows下的动态库称为DLL(Dynamic-Link Library),它和EXE文件实际上是一个概念,即都是PE格式的文件,区别在于PE文件头的符号位标识位不同,而且DLL文件的扩展名不一定是.dll,也可能是.ocx或.CPL。

与Linux不同的是Windows有符号导入表和导出表概念,即必须使用“__declspec(dllimport)”和“__declspec(dllexport)”来显式的声明符号的导入与导出。 Window编译器通过收集这些显式声明的导入符号,将它们放在一个导出表(Exprot Table)的符号集中,类似于Linux下的.dynsym段。 动态链接器根据需要导入的符号从导出表上查找到真实的函数地址,这期间导入表则起到了存储符号和记录地址的作用,类似于Linux下的GOT。

其实DLL中的代码段并不是地址无关的,Windows加载DLL时采用了重定基地址(Rebasing)的方法重定位每个绝对地址引用。 由于DLL内部的地址都是基于基地址或相对于基地址的RVA,因此在重定位时只需要加上一个固定差值就能完成重定位工作。 由于是绝对地址重定位,所以重定基地址比ELF的延迟定位机制要快很多,因为它不需要通过类似的GOT机制,对外部数据和函数的访问不需要每次都计算GOT位置。

内存堆与栈

前面我们说了很多关于系统内存的,包括虚拟内存、页表、缺页、置换等,而这节主要对堆内存和栈内存进行讲解。

当我们调用函数时,需要将当前函数的信息保存到栈上以便调用结束回到原来位置时所有数据都能恢复到原样。 我们把这些保存到栈上的数据称为栈帧(Stack Frame),这些数据主要包含:

  1. 调用需要的参数,以及调用结束时函数的返回地址
  2. 临时变量:包括函数的非静态局部变量以及编译器自动生成的临时变量
  3. 上下文:当前函数调用的寄存器数据

注意,每种编译器都有自己的调用惯例,比如参数的传递顺序和方式、栈维护的方式、函数的签名规则、返回的寄存器等,在各个编译器之间可能有细微的差别。

其中几乎所有的编译器调用惯例都将返回值存储在eax寄存器中,对于大于4字节的数据则存储在eax和edx中联合返回。

我们在写内存分配时通常使用内存分配接口malloc或new,这两个接口分配的内存通常都放在.heap堆内存区域中,而动态库文件在加载的时候,则通过mmap将动态库映射到.libraries文件映射区域中。

注意,malloc分配的内存到.heap段,mmap分配内存到.libraries段,而当malloc请求的内存大于MMAP_THRESHOLD时,则会转而调用mmap,申请的内存则在.libraries区域,这个阈值可以通过mallopt调整。

这里作者介绍了下堆内存管理的几种方式不是非常详细,简单介绍下(后面会写一个操作系统内存专题):

  1. 空闲链表法,把空闲的内存块加入到一个链表上,这个链表里全是空闲的内存块地址,当需要使用时就从这里找合适的。
  2. 分页分块法,把整个内存分成128K大小的XX页,然后再把每页分成32字节大小的内存块,用数组记录每个内存页和内存块的占用情况,这样在分配和释放时查找速度会比较快。
  3. 固定大小内存池,把整个分为固定大小的内存块,然后用空闲链表记录所有空闲的内存地址,每次分配都从空闲链表里推出一个内存使用。

作者专研的比较细,把进程启动到结束的整个过程都扒了出来,我们也跟着扒一下。

程序从开始运行到结束的步骤如下:

  1. 操作系统fork(创建)进程,把控制权交到程序入口,这个入口通常都是公共库的某个函数(libc或MSVC)
  2. 入口函数对运行库和程序运行环境进行初始化,包括堆、I/O、线程、全局变量的构造等等
  3. 入口函数在完成初始化之后,调用main函数,正式开始执行程序主体部分
  4. main函数执行完毕,返回入口函数,入口函数进行清理工作,包括全局变量析构、堆栈销毁、关闭I/O等,然后进程调用系统调用结束进程。

下面我们来说说在整个系统调用里各部分是如何运作的。

文件句柄原理

这里不得不提一下文件句柄的原理,因为我们常常使用文件句柄,却不知道文件句柄究竟是什么,以及是什么产生了文件句柄。

文件句柄总是和内核的文件对象相关联,内核可以通过句柄来计算出内核里文件对象的地址。 在内核中,每个进程都有一个私有的“打开文件表”,这个表是一个指针数组,每一个元素都指向一个内核的打开文件对象,文件句柄就是数组的下标。 当用户打开一个文件时,内核就会在内部生成一个文件对象,并在这个表里找到一个空项,让这一项指向生成的打开文件对象,并返回这一项的下标作为句柄值。 由于这个“打开文件表”处于内核,并且用户无法访问到,因此用户即使拥有句柄值,也无法直接打开文件对象的地址,只能通过系统提供的函数来操作。 Windows的句柄稍有不同,虽然类似于文件表的下标但它并不是下标而是经过某种线性变换的结果。

注意,文件表数组前三个元素填充的是stdin、stdout、stderr这三个内核对象。

读取文件实现原理

我们知道标准库中,文件在写入时会有缓冲区,因此在写入完毕后可以手动调用刷新接口来将缓冲取的内容清空到文件。

其实读取文件也是同样的道理,如果读取文件时总是对文件操作,那么每次都需要进入内核模式,完成后再回到用户模式。 很明显这样的读取方式消耗的比较大,因此读取文件也需要自己的缓冲,以提高文件读取效率。

那么标准库是如何实现文件读取的呢?

标准库中文件读取时的执行步骤:

  1. 每个文件读取时都会有一个缓冲区,缓冲区存储了已经读取的文件数据和还未读取的文件数据,
  2. 当读取文件时,先判断缓冲区中是否有此数据,
  3. 如果缓冲区中有我们需要的数据,则直接取得后返回即可,
  4. 如果没有我们需要的数据或者数据不够,则读取文件并将其后的数据一并读取放入到缓冲区中后再返回

变长参数的实现方式

这里作者说明了printf这种函数可变参数的实现方式:

  1. 编译时将多个实参打包成byte数组传入函数
  2. 在函数中根据下标和大小从byte数组指针中提取形参数据

glibc的组成

glibc中的内容由两部分组成,一部分是头文件,比如stdio.h、stdlib.h等,它们位于/usr/include,另一部分则是库的二进制文件,/lib/libc.so,或者/usr/lib/libc.a。

事实上glibc还有3个重要的运行库,

  • /usr/lib/crt1.o,包含了入口程序,负责调用__libc_start_main初始化libc,并调用main函数进入真正的程序主体。
  • /usr/lib/crti.o,包含了_init()部分的程序初始化指令, 所有的.init都会被收集起来在这里调用
  • /usr/lib/crtn.o,包含了_finit()部分的程序销毁指令,所有的.finit都会被收集起来在这里调用

注意,收集全局构造函数和析构函数的过程是,在链接时,将各个目标文件中.ctors段中的构造函数和析构函数合并为一个数组,在初始化和销毁时依次调用

由于crti.o必须保证在用户目标文件和系统库之前,crtn.o必须在用户目标文件和系统库之后,因此链接器输入文件顺序必须是:

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

注意,前面说的都是C语言,在C++中则提供了crtbeginT.o和crtend.o来配合glibc实现C++的全局构造和析构。

线程局部存储(TLS,Thread Local Storage)

线程也可以拥有私有数据,这就是线程局部存储机制(TLS,Thread Local Storage)。 TLS的用法很简单,只需要在它定义的时候加上关键字__thread就可以,在MSVC里为__declspec(thread)。

// Linux
__thread in number;

// MSVC
__declspec(thread) int number;

一旦一个全局变量被定义为TLS类型,那么每个线程都会拥有这个变量的副本,任何线程对该变量的修改都不会影响其他线程中该变量的副本。

在Windows中TLS的实现方式为:

  1. 编译器把__declspec(thread)定义的线程私有变量放到.tls段中
  2. 当进程启动一个线程时,在进程堆中分配一块足够大小的空间
  3. 把.tls段中的内容复制到这块空间中
  4. 于是每个线程都有自己独立的一个.tls数据副本

注意,在.rdata表中有一个TLS表,它保存了所有的TLS变量的构造和析构函数地址,在线程启动和退出时都会对TLS变量进行构造和析构。除了使用添加关键字定义TLS变量外,还可以通过系统接口的方式,实时申请TLS类型变量。

系统内核调用原理

为了安全操作系统将操作分为两种模式,一种用户模式,一种是内核模式,用户模式操作范围被限制在用户空间上,而内核模式的程序涉及的范围会更广一些,但也主要集中内核空间中。由于内核模式操作的权限更大,因此内核程序编写时需要更加谨慎并严格测试,通常我们都接触不到内核程序,除非自己编写或修改操作系统。

当用户模式要调用内核模式中的程序,要先通过EAX寄存器设置调用号,再调用中断指令,内核通过中断向量表找到系统调用程序,系统调用程序根据系统调用表查找到系统函数,最后执行系统调用。 x86下中断指令为0x80,通过寄存器EAX指定系统调用的调用号,内核程序根据EAX中的值找到系统函数最后执行。

举例创建进程时的系统调用过程:

  1. 用户调用创建进程函数,
  2. 创建进程函数设置eax寄存器并调用中断指令进入内核态,
  3. 中断指令根据0x80和中断向量表找到系统调用程序,
  4. 系统调用程序根据eax寄存器和系统调用表找到需要调用的函数,
  5. 系统调用完毕返回用户态函数返回地址。

当用户态和内核态切换时,首先SS要指向内核栈,ESP指向内核栈顶,这样才能让程序当前的栈从用户栈切换到内核栈。

其实每个进程都有两个栈,用户栈和内核栈,在切换到内核栈时要保存当前上下文,因此要向内核栈中推入ESP、SS、EFLASHS、CS、EIP的值,以便切回用户态时再弹出恢复。 除了这几个特殊的寄存器,向内核栈中推入的还有EBX、ECX、EDX、ESI、EDI和EBP这6个寄存器,而这6个寄存器刚好可以被系统调用函数用于实参。

实现自定义CRT(C Runtime)

作者根据前面的理论知识,实现了一个自己的CRT(C Runtime),在不使用标准库的情况下,支持自定义自己的函数入口、进程初始化与销毁、堆内存分配与释放、文件读取与写入、格式化输出。

我们这里讲解下实现CRT的几个关键点。

关键点一:

入口函数可以使用编译命令来指定,例如:

gcc -nostartfiles -e myentry source.c -o source

-e:设置程序的入口函数

关键点二:

进入main函数前要初始化堆内存,堆内存的管理分配与释放要自己来实现,比如实现一个FreeList空闲链表来存储销毁的堆内存以便下次使用。

//自定义堆内存块结构
typedef struct _heap_header
{
    unsigned size;
    struct _heap_header * next;
    struct _heap_header * prev;
} heap_header;

//空闲内存链表
static heap_header * freelist_head = NULL;

//自定义内存释放函数
void free(void * ptr)
{
    ....
}

//自定义堆内存申请函数
void * malloc(unsigned size)
{
    ....
}

关键点三:

堆内存的扩容可以通过brk系统调用设置进程的数据段边界,而sbrk可以移动进程的数据段边界,其实将数据段边界后移就相当于分配了一个定量的内存。

由brk/sbrk分配的内存仅仅分配了虚拟空间,这些空间是不会提交的(即不分配物理内存页),当进程访问某个地址时才会与物理页关联。

关键点四:

由于不使用标准库,因此文件系统也必须自己重写,重写文件读写底层时,我们可以用汇编写系统调用来实现。

其中系统调用中,打开文件的系统调用号是5、读取文件的系统调用号是3、写入文件的系统调用号是4、关闭文件的系统调用号是6。 因此在调用int 0x80前,设置eax为各调用号就可以,同时用ebx、ecx、edx将参数传入系统调用。

举例打开文件的系统调用:

asm("movl $5, %%eax \n\t" // 设置eax的调用号
    "movl %1, %%ebx \n\t" // 设置参数1
    "movl %2, %%ecx \n\t" // 设置参数2
    "movl %3, %%edx \n\t" // 设置参数3
    "int $0x80 \n\t" // 调用0x80系统中断
)

前面我们说标准输入输出是文件表的前3个句柄,因此句柄0,1,2为标准输入、标准输出、标准错误的句柄。

关键点五:

实现全局构造和析构时,从.ctors段中读取构造和析构函数数据,将构造和析构函数都合并到指针数组中,依次调用函数指针数组中的方法。

typedef void (*ctor_func)(void);

ctor_func ctors_begin[1] __attribute__ ((section(".ctors"))) = 
{
    (ctor_func) -1;
};

void run_ctors()
{
    const ctor_func * list = ctors_begin;
    while( (int)*++list != -1 )
    {
        (**list)();
    }
}
July 4, 2021 · 读书笔记, 前端技术

感谢您的耐心阅读

Thanks for your reading

版权申明

本文为博主原创文章,未经允许不得转载:

读书笔记(三十一) 《装载、链接与库》#4 内核运行库

Copyright attention

Please don't reprint without authorize.

qrcode_for_gzh.jpg

微信公众号,文章同步推送,致力于分享一个资深程序员在北上广深拼搏中对世界的理解

QQ交流群: 777859752 (高级程序书友会)


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK