Linux内核浅析-进程地址空间

本文介绍下linux如何管理内存。作为架构师,我去做一个系统时,通常从两个方面入手:1)了解上层业务和需求。2)熟悉下层可以使用的工具和能力。本质上,做任何系统,或者做任何事情吧,纵向上来说,我们都是做上层和下层之间的枢纽,横向上来说,我们做的是整个任务链上的一个节点,也是前、后节点之间的传动齿轮。linux就是这样,是上层业务程序和下层体系结构之间的枢纽,其封装了底层体系结构的复杂性,以更方便的操作界面供上层操作,用最近流行的一个词语,”认知折叠“,那linux就是折叠了体系结构的认知,让上层不必关注硬件体系结构,而专注于自身的业务。

内存管理的背景

上层业务需求

1、save and get:程序将运行所需要的指令数据进行暂存(不需要持久化),并在需要时能够准确索引之前暂存的任何值,并load入cpu寄存器进行运算。

2、安全性:要做到进程之间的隔离,用户态、内核态的隔离,保证系统级别的安全,即使一个进程出现内存溢出、寻址越界等情况,不能影响系统和其他进程的运行。

3、高效:meta信息要少,内存操作要快。

底层提供的能力

最底层的能力就是内存条,能够根据索引存储数据和获取数据,但是x86体系结构封装了cpu和内存的交互过程,通过下列x86汇编完成cpu和内存交互。

可以看到,x86提供了基于(段地址 + 段偏移)进行存取的能力,x86内部会将(段地址+段偏移)转换为物理地址后进行存储,但地址转换时需要依赖于页表机制,页表的内容由linux写入,整个过程后面会详细聊到(https://zhuanlan.zhihu.com/p/82406447),此处只是了解下底层能力。

可以看到,上层需要如此丰满,底层能力如此露骨,我们通常面对的就是这种情况,但这种情况下恰恰可以大有作为。


1)管理的粒度:linux将内存分页,每页默认大小为4k。这里有个trade off的问题,如果粒度小,比如1 byte,则可以提升空间利用率,但是管理的meta信息就会很多,如果分配较大空间时,耗时就会更长;如果粒度大,则meta信息少,分配大内存时耗时少,但分配小块内存时会有浪费。所以一般在线系统或者mysql库,使用默认页大小(此处不是只mysql的页,而是mysql使用的linux的页),olap系统或者离线存储系统一般使用大页,linux提供配置参数可以配置大页。

2)进程地址空间:每个进程独有的,用字段task_struct -> mm_struct表示,其也叫线性地址,其转换关系:线性地址 -> 逻辑地址 -> 物理地址。

解耦:因为刚才提到的都是x86支持的方式,还是arm等体系结果需要支持,所以linux通过进程地址空间这个entity进行解耦,所以内存的操作都在用户态完成,真正分配内存时,通过缺页异常搞定。

隔离:由于进程地址空间按进程单位进行隔离,保证进程访问时相互隔离。同时空间内部也分为kernel space和user space,保证用户态和内核态的访问的内存相互隔离。同时采用分段机制,将指令、不同类型的数据分开存储,以支持进程运行模型。

text segment:存储代码指令的区域

data segment:存储已初始化的全局或静态变量

bss segment:存储未初始化的全局或静态变量

heap:堆,用于动态开辟内存空间,brk或malloc开辟的空间

memory mapping space:mmap系统调用使用的空间,通常用于文件映射到内存或匿名映射(开辟大块空间),当malloc大于128k时(此处依赖于glibc的配置),也使用该区域。在进程创建时,会将程序用到的平台、动态链接库加载到该区域

stack:进程运行的栈

空间利用率: 由于32位只支持4G物理内存寻址,一种方式N个进程平分4G内存,这是最简单的方式,但有个问题,有些进程占用内存,但一直在sleep,相当于很浪费。另一种方式就是把内存空间给最需要的进程,把sleep进程的内存swap到硬盘,需要的时候再swap进内存。此时就有一种极端情况,就是一个进程需要独占4G内存,linux显然选择第二种利用率更高的方式,所以进程地址空间能映射4G的物理内存。

3)逻辑地址:逻辑地址 = 段地址 + 段偏移,段地址和段偏移的值由linux进行提供。

4)页表:保存逻辑地址到物理地址的映射,其数据由linux初始化,并将热页表项加载TLB快表进行缓存,加快转换速度。然后x86体系的硬件依赖页表做逻辑地址 -> 物理地址的转换。

5)伙伴系统:管理物理内存的分配,其在缺页中断中被调用,仅负责更改页表和meta(strcut page)。当逻辑地址和物理地址映射上后,指令使用物理地址写入内存设备。

进程地址空间管理

struct mm_struct {

    struct vm_area_struct * mmap;  //指向虚拟区间(VMA)的链表

    struct rb_root mm_rb;          //指向线性区对象红黑树的根

    pgd_t * pgd;                  //指向页全局目录

    unsigned long mmap_base;   //表示mmap区域的起始位置

    unsigned long total_vm;   //总共映射的页面数,包括映射到内存中和已经换出到银盘的

    unsigned long locked_vm;   //表示不能swap到硬盘的页数

    unsigned long pinned_vm;   //表示不能换出,不能移动的页数

    unsigned long data_vm;   //存储数剧占的总页数

    unsigned long exec_vm;   //存储指令占的总页数

    unsigned long stack_vm;   //栈所占的总页数

    // text、data segment的开始和结束地址

    unsigned long start_code, end_code, start_data, end_data;

    // 堆的开始、当前位置。栈的起始位置,栈的当前地址在esp寄存器中

    unsigned long start_brk, brk, start_stack;

    // 命令行参数列表、环境变量的起始、结束地址,其都位于栈的高地址

    unsigned long arg_start, arg_end, env_start, env_end;

}

task_struct -> mm_struct是对进程地址空间描述的结构体,主要包含其统计信息和各个segment的起始、结束地址,几个变量如下图右侧的标注。


进程地址空间的地址是向上增长的。可以看到有几个radom offset,其在段与段之间缝隙,防止固定的内存布局被黑客黑掉。

vm_area_struct是描述每个段具体信息结构体,其实一个单链表,通过task_struct- > mm_struct -> mmap表示,由于每次分配内存时会要到vm_area_struct,需要快速寻找,所以task_struct- > mm_struct -> mm_rb是根据vm_area_struct -> vm_start字段,构建vm_area_struct的一棵红黑树。

struct vm_area_struct {

/* The first cache line has the info for VMA tree walking. */

unsigned long vm_start; /* Our start address within vm_mm. */

unsigned long vm_end; /* The first byte after our end address within vm_mm. */

/* linked list of VM areas per task, sorted by address */

struct vm_area_struct *vm_next, *vm_prev;

struct rb_node vm_rb;

struct mm_struct *vm_mm; /* The address space we belong to. */

struct list_head anon_vma_chain; /* Serialized by mmap_sem &

  * page_table_lock */

struct anon_vma *anon_vma; /* Serialized by page_table_lock */

/* Function pointers to deal with this struct. */

const struct vm_operations_struct *vm_ops;

struct file * vm_file; /* File we map to (can be NULL). */

void * vm_private_data; /* was vm_pte (shared mem) */

} __randomize_layout;

每个字段的具体含义可以参见注释。其中anon_vma是匿名映射,即分配大块内存,vm_file即是指向文件映射映射的文件。vm_ops即是这段内存上对应的操作。

mm_struct和vm_area_struct的初始化是在load_elf_binary的时候初始化,主要做的几个事情如下:

1)调用 setup_new_exec,设置内存映射区 mmap_base。

2)调用setup_arg_pages,设置栈的vm_area_struct和current -> mm -> start_stack。

3)调用elf_map,将elf文件中的代码映射到对应区域中。

4)set_brk,设置堆的vm_area_struct,current -> mm -> start_brk = current -> mm -> brk,此时栈是空的。

5)load_elf_interp将依赖的so加载到mmap区域。

load_elf_binary完成后,线性空间的布局就基本如上图,当进程运行时会修改栈空间的大小,通过malloc申请空间时会改变heap、mmmap空间的大小。

brk & mmap

分配内存常使用malloc,这是glibc提供的方法,其内部也有block的管理。但是底层都依赖于linux提供的系统调用:brk和mmap。若malloc小于128k,则使用brk,大于则使用mmap,可通过M_MMAP_THRESHOLD修改128k这个边界值。如下图,A = malloc(30K),B = malloc(40K),C = malloc(200K),D=malloc(100K)。


brk

其通过系统调用设置current -> mm -> brk指针,在heap空间来分配和回收内存空间。

SYSCALL_DEFINE1(brk, unsigned long, brk)

{

unsigned long retval;

unsigned long newbrk, oldbrk;

struct mm_struct *mm = current->mm;

struct vm_area_struct *next;

        // 1、如果新的brk和当前mm->brk按页对齐后相等,则说明不需要跨页分配,则直接设置当前brk即可

newbrk = PAGE_ALIGN(brk);

oldbrk = PAGE_ALIGN(mm->brk);

if (oldbrk == newbrk)

goto set_brk;

// 2、如果新的brk小于mm->brk,说明需要进行线性空间的回收

if (brk <= mm->brk) {

if (!do_munmap(mm, newbrk, oldbrk-newbrk, &uf))

goto set_brk;

goto out;

}

// 3、找到next vma,vm_start_gap可以理解返回的是next.vm_start,然后比较下一个vma和当前vma是否

        // 能容纳新申请的空间。这里有两个注意点:

        // 1)oldbrk是按页对齐后的brk地址,此时通过fina_vma找到的是第一个满足vma.vm_end > oldbrk的 

        // vma,所以是下一个vma,用next指针表示

        // 2)brk的入参是一个addr,所以只能查看当前vma和下一个vma之间是否足够分配,不能线性

        // 查找,这一点不同于mmap方式。

next = find_vma(mm, oldbrk);

if (next && newbrk + PAGE_SIZE > vm_start_gap(next))

goto out;

        // 4、对于brk,此处仅设置vm_area_struct的vm_end和其他相关指针,不会新分配vma

if (do_brk(oldbrk, newbrk-oldbrk, &uf) < 0)

goto out;

// 5、设置brk

set_brk:

mm->brk = brk;

return brk;

out:

retval = mm->brk;

return retval

mmap

通过遍历mmap区域,找到大小合适的区域进行文件映射或匿名映射,其入口是SYSCALL_DEFINES6,主要逻辑在do_mmap中。

unsigned long do_mmap(struct file *file, unsigned long addr,

                        unsigned long len, unsigned long prot,

                        unsigned long flags, vm_flags_t vm_flags,

                        unsigned long pgoff, unsigned long *populate,

                        struct list_head *uf) {

        struct mm_struct *mm = current->mm;

        int pkey = 0;

        *populate = 0;

        if (!len)

                return -EINVAL;

.......

/* pang */

        len = PAGE_ALIGN(len);

        if (!len)

                return -ENOMEM;

// 判断该进程的地址空间的虚拟区间数量是否超过了限制

        if (mm->map_count > sysctl_max_map_count)

                return -ENOMEM;

    // 从mmap区域获取未被映射且length合适的vma addr

        addr = get_unmapped_area(file, addr, len, pgoff, flags);

.......

        /* file指针不为nullptr, 即从文件到虚拟空间的映射 */

    if (file) {

.......

        } else {

                switch (flags & MAP_TYPE) {

                case MAP_SHARED:

.......             

                case MAP_PRIVATE:

.......

                default:

                        return -EINVAL;

                }

        }

        // 映射到vm_area_struct

        addr = mmap_region(file, addr, len, vm_flags, pgoff, uf);

        if (!IS_ERR_VALUE(addr) &&

            ((vm_flags & VM_LOCKED) ||

            (flags & (MAP_POPULATE | MAP_NONBLOCK)) == MAP_POPULATE))

                *populate = len;

        return addr;

get_unmapped_area:从mmap区域中找到未被映射,且length满足要求的vma的起始addr。

若addr != 0,则从指定addr查找。先调用fina_vma,找到vma,使其满足vma.vm_end > addr同时还需要满足addr + len < vma.vm.start,则返回该addr。

若addr == 0或步骤1找不到符合要求的addr,则从mm->free_area_cache从新开始全局搜索,如果还搜索不到,就返回错误。mm->free_area_cache在初始化时被设置为用户空间的三分之一(1G的位置,1G以下是为text、data、bss保留)。

mmap_region:根据addr,映射vma,可能是和原有的vma合并,也可能是重新创建vma,逻辑在vma_merge中,具体可以参见http://edsionte.com/techblog/archives/3586

线性地址与逻辑地址的映射

逻辑地址 = 段地址 + 段偏移,从上图可以看出线性地址 = 段偏移,在linux中,段地址都被初始化为0(也可参见https://zhuanlan.zhihu.com/p/73937048)。我理解原因有两个:

1)不是所有体系结构都有段地址的概念,比如arm,linux为了支持多个体系架构,所以做了兼容设计。

2)避免了一次地址转换,使得线性地址直接对应段偏移地址,减少了计算复杂度。

那为何不直接干掉段地址,linux干嘛还初始化为0?因为x86体系要求啊,x86会通过mmu将段地址 + 段偏移 -> 物理地址,如果linux不设置段地址,x86就不work了,就没法转换为物理地址,这个后面一篇文章会详细讲。

内核地址空间

kernel代码运行需要使用的内存地址,比如创建task_struct描述符,内核代码运行栈等等。

直接映射区:也叫高端映射区,其和物理内存一一对应,这并不是说内核可以直接使用物理地址,而是这段区域和物理地址的映射关系是一一对应的。虚拟地址空间的3G + 896M直接映射物理地址的低896M。

内核动态映射区:用户态malloc会在堆分配,那内核使用vmalloc函数分配的空间就在该区域。该区域和用户区域的映射规则一样。比如物理内存896M~2G被用户态使用,那内核态就需要映射2G以上的物理内存,但是内核态的线性地址只有1G,此时就不能用直接映射了,该区域的映射可以映射到任何物理内存。

永久映射区:分配alloc_pages,用管理物理内存。

很多人到这里有疑问吧,为什么要有高端映射?完全统一为动态映射,用户态和内核态保持一致不就ok?内核需要保证足够的物理内存来运行,如果这段区域不连续的话,不方便统计,每次用户态分配内存时是不是都要检查?那干脆加一个直接映射区,同时让用户态无法映射到该物理区域就ok了。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 201,924评论 5 474
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,781评论 2 378
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 148,813评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,264评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,273评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,383评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,800评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,482评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,673评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,497评论 2 318
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,545评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,240评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,802评论 3 304
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,866评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,101评论 1 258
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,673评论 2 348
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,245评论 2 341

推荐阅读更多精彩内容