Linux内存机制浅见——从内存布局到线程局部存储TLS

先记

最近在重新分析SGX的源码《再回顾sgx_create_enclave》,一路发现,我还需要重新思考ELF文件格式(我在《SGX初始化中ElfParser::run_parser()做了什么》里面有讲解)、Linux内存机制。因此这里就对Linux内存机制进行笔记。(令人头大……)

内核内存是一个很复杂的东西(令人头大……),这里已目前浅学进行一个系统归纳。如有错误,劳烦指出。

下文与SGX无关

内存布局

0xC0000000到0xFFFFFFFF内核直接映射空间(可能还有内核动态映射空间
(0xBFFFFFFF-该区域长度,简单地记为argvBegin)到0xBFFFFFFFargv和环境变量
某个位置到(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更独立、更高效、更简洁、更易扩展。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值