先记
最近在重新分析SGX的源码《再回顾sgx_create_enclave》,一路发现,我还需要重新思考ELF文件格式(我在《SGX初始化中ElfParser::run_parser()做了什么》里面有讲解)、Linux内存机制。因此这里就对Linux内存机制进行笔记。(令人头大……)
内核内存是一个很复杂的东西(令人头大……),这里已目前浅学进行一个系统归纳。如有错误,劳烦指出。
下文与SGX无关
内存布局
0xC0000000到0xFFFFFFFF | 内核直接映射空间(可能还有内核动态映射空间) |
(0xBFFFFFFF-该区域长度,简单地记为argvBegin)到0xBFFFFFFF | argv和环境变量 |
某个位置到(argvBegin-1) | 栈,从高地址往低地址增长 |
夹堆和栈中间的某个位置 | 动态库 |
dataEnd+1到某个位置 | 堆,从低地址往高地址增长 |
(textEnd+1)到(textEnd+数据段长度,简单地记为dataEnd) | 数据段,从低地址往高地址依次包括.data、.bss节,.data里面是已初始化数据,.bss里面是未初始化数据或初始化为0的全局变量 |
0x08048000到(0x08048000+只读段长度,简单地记为textEnd) | 只读段,包括了.init、.rodata、.text节 |
0x00000000到0x08047FFF | 保留区 |
一般来说,我们看到的内存布局是如上表这个样子的,可以参考这个。其实,这个视图是从链接器和操作系统看过去的视图。具体来说,链接器会将(从源码被编译成的)Object链接成一定格式的文件,比如ELF文件、PE文件。
用过IDA或其他工具我们可以看到文件里面其实是被分成了若干个Section,比如.text、.data、.bss节,也就是如下左图链接试图,我们只关注Section,由Section头表维护。右图是运行视图,是程序实实在在跑在内存里,内核看过去的试图,这里只关注Segment,由Segment头表维护。
段寄存器
我们知道CPU提供了一些特殊的段寄存器CS、DS、ES、FS、GS。那么这些段寄存器和内存布局有什么关系呢?
寄存器名 | 说明 |
CS | 代码段寄存器 |
DS | 数据段寄存器 |
SS | 栈段寄存器 |
ES | 扩展段寄存器,作为一个辅助数据段 |
FS、GS | 通用目的段寄存器,根据编译器的偏好进行具体使用 |
我们知道C/C++的源代码最终会被编译成汇编代码(如Intel格式、AT&T格式),汇编代码最终会变成机器码。
在编译器对源码的编译过程中,编译器往往会使用CS寄存器来记录当前代码的代码段起始位置,用DS寄存器来记录当前代码的数据段起始位置,用SS寄存器来记录栈的起始位置(注意栈是反向增长的)。
ES、FS、GS是需要根据编译器的偏好来进行使用,ES常被也用于记录辅助数据段的起始位置,FS、GS的使用情况很多,有的时候会被用来记录TLS(线程局部存储)的起始位置,这也是本文之后着重想讨论的一个点。
一个有趣的点是,堆这个区并没有专门的硬件寄存器来记录它的起始位置,堆和TLS均是内核层面的概念,CPU并不管这件事,所以一般堆区是根据编译器的优化,找到空闲的通用寄存器(如RAX等,通用寄存器和通用目的段寄存器是两个不同的东西)来进行记录。TLS相关描述见后文。
下面在试图引入TLS之前需要扩展一些概念。
虚拟地址和物理地址
为了更加安全,并提供进程及进程间隔离的概念,我们要和以前直面物理地址的时代说再见。如今的操作系统均支持页表机制和虚实地址转换机制。
《Linux内核设计的艺术》里面描述了页表机制的建立。内核先将页目录表、4张页表按序存放于0x0开始的物理地址上,然后设置页目录表前四项指向这4张页表,同时4张页表依次指向0-16M的物理地址空间。每一个页表项占4个字节,共有4(张页表)*1k(项页表项,因为每张页表4k字节,一个页表项4字节)=4K(个页表项)。每个页表项指向一个4k页,因此4张页表能指向4k(个页表项)*4k(页的大小)=16M(的物理地址)。之后CR3指向0x0,随后(假设CR0第0位PE位置为1,表示保护模式开启)会将CR0的第31位(从0开始计数)PG位置为1,表示页表机制开启。
上述讲述的是Linux0.11中的内核页表机制【存放于init_mm.pgd(swapper_pg_dir)】,是由内核管理的,在Linux0.11中似乎是多个进程共用同一个内核页表,不同的进程似乎是占用不同的虚拟地址空间。之前内核页目录表前4项分别指向4张页表,后面的项可以归进程使用,用来完成虚实地址对应。
但现在是每个进程都有一个4G的虚拟地址空间,这应该是归因于进程页表机制【存放于task_struct.pgd(task_struct是进程管理块,每个进程都有一个)】,他记录了每个进程自己的页目录表,当切换到当前进程的时候,将当前进程PGD页目录表加载到CR3中。之前讨论的“内存布局”其实就是进程虚拟地址空间、进程页表机制的概念,着重点是进程。同时内存布局最高的1G内存空间是直接映射到物理地址0x0开始的内核区域。
最后,页目录表里面记录了下一层级的页表位置。
常见的有三级页表机制,依次是PGD、(P4D,四级页表机制中相较于三级页表额外的一层)、PMD、PTE,PTE最终指向Page具体的页,里面保存着数据,不过页表也都是保存在某个页上面的。多级页表好处在于不再需要连续的物理地址来存储一整张页表,而是以零散的方式存储页表。但是坏处是,访存的次数增加了,比如三级页表会有三次访存。
PCB
Process Control Block,进程控制块,Linux中是一个叫做task_struct的结构体,存放于在内核数据区(内核数据区的位置由GDT中的DS来指明)中的Task数组中。【Windows里似乎是叫做PEB】这个结构体主要是存放了进程的运行环境状态。
TCB
类似进程控制块,线程也有线程的线程控制块Thread Control Block,存放着线程的运行环境状态。【Windows里似乎是叫做TEB】
GDT、LDT、TSS
GDT是全局描述符表,一个系统只有一个,里面保存了每个进程的LDT本地描述符表的地址,用于完成进程执行过程中各Segment的寻址;还保存了TSS任务状态段,用于完成进程中断时的现场保护以及进程恢复时的现场恢复。
每次创建一个进程,我们会在内核数据区的Task数组中找到一个空的task_struct,对其初始化(除了进程0,其他进程中task_struct的大部分内容拷贝自父进程),同时也初始化一个LDT(拷贝父进程的段基址和偏移)和一个TSS(拷贝父进程的各类寄存器值),并且挂接到GDT中。值得注意的是,进程0是没有任何可拷贝的对象的,全靠Boot过程中,一点一点的设计。
线程栈
根据这里的理解。线程栈位于进程虚拟地址空间的堆区(个人觉得可能任何未使用的虚拟内存空间都可以,由线程库具体部署,就好比堆区本身就是内核层面的概念,而不是CPU层面的概念)。线程栈的起始地址和大小保存在如下的结构体中。
typedef struct __pthread_attr_s
{
int __detachstate; //分离状态
int __schedpolicy;//调度策略
struct __sched_param __schedparam;
int __inheritsched;
int __scope;//线程优先级的有效范围
size_t __guardsize;//
int __stackaddr_set;
void *__stackaddr;//起始地址
size_t __stacksize;//栈的大小。
}pthread_attr_t;
TLS线程局部存储
首先TLS线程局部存储,目的是让线程拥有自己独立的数据空间,每个线程都有自己的TLS,这样线程就不单单是使用进程的数据空间了。同时线程之间的TLS应该是互不干扰的(有说可以读取其他线程的TLS)。
TLS也是一个内核的概念,CPU并没有提供直接的硬件指令。现有的做法是将FS(32位中)、GS(64位中)用来指向一块特殊的内存,并且作为一个独立的Segment。这里保存了所有线程的TLS,通过线程ID来进行寻找(似乎是这样)。
因此最开始的内存布局需要添加一个新的区域,TLS段。他的具体位置是由编译器决定的。
小扩展
SGX中Enclave线程为什么需要TLS呢?我自己的了解还不深入,经以为大佬的解答,是为了让Enclave线程能够具有自己的局部存储,而不再依赖属于进程的全局变量,有助于提高Enclave自身的效率,此外还能让Enclave彼此间更加独立,互相影响少(比如不用锁去抢占一个进程内部线程间共享的资源),使代码更简洁,易扩展。
总结来说就是有助于Enclave更独立、更高效、更简洁、更易扩展。