Linux深入理解内存管理29(基于Linux6.6)---进程虚拟地址介绍
一、概述
Linux 操作系统的内存管理体系中,进程的内存管理是非常核心的概念之一。进程虚拟地址是现代操作系统内存管理的重要组成部分,通过虚拟内存机制,操作系统能够更有效地管理内存资源,提高系统的稳定性、安全性和效率。
在深入理解进程虚拟地址之前,需要先了解一些基础概念:
1. 虚拟内存(Virtual Memory)
虚拟内存是一种内存管理技术,它为每个进程提供一个独立的内存空间。这意味着每个进程看到的内存地址是虚拟地址,而不是物理内存的实际地址。操作系统通过硬件支持的内存管理单元(MMU,Memory Management Unit)将虚拟地址映射到物理内存地址。
虚拟内存的主要优势包括:
- 隔离性:每个进程拥有自己独立的虚拟地址空间,避免了进程间的内存干扰,提高了系统的稳定性。
- 扩展性:操作系统可以使用磁盘空间作为虚拟内存的一部分(如通过交换空间或交换文件),从而有效扩大可用内存。
- 共享内存:不同的进程可以共享同一块物理内存区域,而它们的虚拟地址空间中仍然是独立的。
2. 虚拟地址空间的结构
在 Linux 操作系统中,每个进程的虚拟地址空间通常被划分成多个区域。虚拟地址空间结构对于内存管理至关重要,它决定了如何有效地划分和使用内存。大致上,一个进程的虚拟地址空间包括以下几个主要部分:
-
用户空间(User Space):
- 用户空间地址是进程用来执行其代码和存取数据的地方,通常占用虚拟地址的较低部分。
- 这部分内存包括进程的代码段、数据段、堆、栈等。
-
内核空间(Kernel Space):
- 内核空间是操作系统内核用来运行的地方,通常位于虚拟地址的高端部分。
- 进程无法直接访问内核空间,内核通过系统调用和中断机制与用户空间进行交互。
-
虚拟地址范围:
- 在 32 位系统中,虚拟地址通常是 4GB 的空间,其中低 3GB(0~3GB)用于用户空间,高 1GB(3GB~4GB)用于内核空间。
- 在 64 位系统中,虚拟地址的空间非常大(理论上可以达到 2^64),通常操作系统将地址分成更细的区域。
3. 虚拟地址空间的主要部分
进程的虚拟地址空间结构可以进一步细分为多个区域:
-
文本段(Text Segment):
- 存储进程的执行代码(即程序的指令)。这个部分是只读的,防止进程修改自己的代码。
-
数据段(Data Segment):
- 存储已初始化的全局变量和静态变量。
-
BSS段(BSS Segment):
- 存储未初始化的全局变量和静态变量。这部分的内存空间在程序运行时会被操作系统初始化为零。
-
堆(Heap):
- 堆用于存储动态分配的内存,如通过
malloc()
或new
分配的内存区域。 - 堆的大小是动态的,可以随着程序的需要而增长或缩小。
- 堆用于存储动态分配的内存,如通过
-
栈(Stack):
- 栈用于存储局部变量、函数调用的返回地址等。
- 栈的大小通常是固定的,每次函数调用都会在栈中分配一块新的内存区域,函数返回时相应的栈空间被释放。
-
内存映射区域(Memory-mapped Region):
- 存储由
mmap()
系统调用映射的文件内容或设备内存。 - 共享库和共享内存区域也会映射到这个区域。
- 存储由
4. 进程虚拟地址的映射
虚拟地址空间并不是直接与物理内存一一对应的,操作系统利用页表将虚拟地址映射到物理地址。这一过程由硬件的内存管理单元(MMU)完成。
- 页表(Page Table):是操作系统用来存储虚拟地址和物理地址之间映射关系的数据结构。每个进程都有一个独立的页表。
- 页表项(Page Table Entry, PTE):每一项存储了虚拟地址到物理地址的映射信息。
- 页面(Page):内存被分割成固定大小的块,通常为 4KB(在 64 位系统中也可能为更大的页面)。这些块称为页面。
- 段(Segment):在一些系统中,内存不仅通过页面来管理,还可能使用段来管理。段更大范围地划分内存,用来存储不同类型的数据(如代码段、数据段)。
5. 虚拟地址转换
虚拟地址转换的基本过程如下:
-
虚拟地址分为页号和页内偏移:
- 页号:用于查找页表项,页表项存储虚拟页到物理页的映射。
- 页内偏移:表示在该页内的具体位置。
-
查找页表项:
- 根据虚拟地址中的页号查找对应的页表项,得到物理页号。
-
计算物理地址:
- 将物理页号与页内偏移结合起来,得到最终的物理地址。
6. 内核的虚拟地址空间的管理
- 用户进程的虚拟地址空间是Linux的一个重要抽象,它向每个运行进程提供了同样的系统,每个应用程序都有自身的地址空间,与所有的应用程序分割开,不会干扰到其他进程内存的内容。
- 在内核的虚拟地址空间中,只有很少的段可用于各个用户空间进程,这些段彼此有一定的距离,内核需要一些数据结构来有效的管理这些分布的段。
- 地址空间中只有极少的一部分与物理页直接关联,不经常使用的部分,仅当必要时与页帧关联
- 内核无法信任用户进程,所以各个操作系统用户地址空间的操作伴随着各种检查,以确保程序的权限不会超出应有的限制,进而危及到系统的稳定性和安全性。
- 用户空间的内存分配方法。
二、进程虚拟地址空间
理论上,64Bit地址支持访问空间是[0, 0xFFFF FFFF FFFF FFFF],而实际上现有的应用程序都不会用这么大的地址空间,而现在ARM64芯片上也不支持访问这么大的地址空间,现有的架构最大支持访问48bit的地址空间。而对于进程有用户态和内核态,同样进程地址空间包括用户地址空间和内核地址空间,用户态访问用户地址空间。对于各个进程的虚拟地址空间起始于地址0,延伸到TASK_SIZE - 1,其上是内核地址空间。
- 在ARM32系统上,地址空间范围为4GB,总的地址空间通常按照3:1划分,各个用户空间进程可用的部分是3GB。
- 在ARM64系统上,64位虚拟地址中,并不是所有位都用上,除了高16位用于区分内核空间和用户空间外,有效位的配置可以是:36, 39, 42, 47。这可决定Linux内核中地址空间的大小。比如以采用4KB的页,4级页表,虚拟地址为48位的系统为例(从ARMv8.2架构开始,支持虚拟地址和物理地址的大小最多为52位),其虚拟地址空间的范围为256TB ,按照1:1的比例划分,内核空间和用户空间各占128TB。
对于用户程序只能访问整个地址空间的下半部分,不能访问内核部分。同时无论当前哪个用户进程处于活动状态,虚拟地址空间内核部分的内容总是相同的。
2.1、进程地址空间的布局
一个进程通常由加载一个elf文件启动,而elf文件是由若干segments组成的,同样的,进程地址空间也由许多不同属性的segments组成。虚拟地址空间中包含了若干区域,其分布方式特定于体系结构,但所有的方法都有下列共同的特点,如下图所示:
text段:包含了当前运行进程的二进制代码,其起始地址在IA32体系中中通常为0x08048000,在IA64体系中通常为0x0000000000400000。
- data段:包含程序显式初始化的全局变量和静态变量,即已初始化且初值不为0的全局变量(也包括静态全局变量)和静态局部变量,这些数据是在程序真正运行前就已经确定的数据,所以可以提前加载到内存保存好。
- bss段:未初始化的全局变量和静态变量,这些变量的值是在程序真正运行起来并为其赋值后才能确定的,所以程序加载之初,只需要记录它的内存地址和所需大小。出于历史原因,这段空间也称为 BSS 段。
- heap段:存储动态分配的内存中的数据,堆用于存储那些生存期与函数调用无关的数据。如用系统调用 malloc 申请的内存便在堆上,这些申请的内存在不需要时必须手动释放,否则便会出现内存泄漏。
- stack段:用于保存局部变量和实现函数/过程调用的上下文,它们的大小都是会在进程运行过程中发生变化的,因此中间留有空隙,heap向上增长,stack向下增长,因为不知道heap和stack哪个会用的多一些,这样设置可以最大限度的利用中间的空隙空间。进程每调用一次函数,都将为该函数分配一个栈帧,栈帧中保存了该函数的局部变量、参数值和返回值。
- 文件映射段:这个段比较特殊,是mmap()系统调用映射出来的。mmap映射的大小也是不确定的。3GB的虚拟地址空间已经很大了,但heap段, stack段,mmap段在动态增长的过程还是有重叠(碰撞)的可能。为了避免重叠发生,通常将mmap映射段的起始地址选在TASK_SIZE/3(也就是1GB)的位置。如果是64位系统,则虚拟地址空间更加巨大,几乎不可能发生重叠。
例子内存空间布局如下图所示:
2.2、建立布局
进程在运行过程中的内存空间分布情况,那么如何建立起这种内存空间呢?首先,在Linux系统中运行一个可执行的ELF文件时,内核首先需要识别这个文件,然后解析并装载它以构建进程的内存空间,最后切换到新的进程来运行。
首先,我们来看一下elf文件的格式,Section头表包含了描述文件Sections的信息。每个Section在这个表中有一个入口,每个入口给出了该Section的名字,大小等信息。同时可执行文件有一个头部,里面有一些关键信息,Entry point Address,入口地址,即程序的起点,后面有一些代码,数据:
当在linux的shell命令中执行某个elf可执行文件的时候,linux系统是如何装载该ELF并执行的呢?其主要是以下几个步骤:
- 创建新进程:首先在用户层面,shell进程会调用fork()系统调用创建一个新的进程,然后新的进程调用execve()系统调用来执行指定的ELF。
- 检查可执行文件的类型:当进入execve()系统调用之后,Linux内核就开始真正的装载工作。在内核中,execve()系统调用相应的入口是sys_execve(),会执行do_execve()查找被执行的文件,如果找到文件,则读取文件的前128个字节,通过来判断该执行文件是哪一种elf文件,例如a.out,java程序,以及脚本开头的文件。
- 搜索匹配的装载处理过程:do_execve()读取128个字节的文件头部后,调用search_binary_handle()去搜索和匹配合适的可执行文件,最常见的可执行文件及处理过程如下:
- ELF可执行文件:load_elf_binary
- a.out 可执行文件:load_aout_library
- 可执行脚本程序:load_script()
在装载的过程中,对于可执行文件,应该创建对应的.text段、.data段、stack段等。在Linux中,每个段都用一个vm_area_strcutvm_area_strcut结构体表示,vma是通过一个双向链表串起来,现存的vma按照起始地址依次递增被归入链表中,每个vma是这个链表的一个节点,首先来看一个进程有一个struct mm_struct用来描述进程的内存信息。
include/linux/mm_types.h
struct mm_struct {
struct {
struct maple_tree mm_mt;
#ifdef CONFIG_MMU
unsigned long (*get_unmapped_area) (struct file *filp,
unsigned long addr, unsigned long len,
unsigned long pgoff, unsigned long flags);
#endif
unsigned long mmap_base; /* base of mmap area */
unsigned long mmap_legacy_base; /* base of mmap area in bottom-up allocations */
#ifdef CONFIG_HAVE_ARCH_COMPAT_MMAP_BASES
/* Base addresses for compatible mmap() */
unsigned long mmap_compat_base;
unsigned long mmap_compat_legacy_base;
#endif
unsigned long task_size; /* size of task vm space */
pgd_t * pgd;
#ifdef CONFIG_MEMBARRIER
/**
* @membarrier_state: Flags controlling membarrier behavior.
*
* This field is close to @pgd to hopefully fit in the same
* cache-line, which needs to be touched by switch_mm().
*/
atomic_t membarrier_state;
#endif
/**
* @mm_users: The number of users including userspace.
*
* Use mmget()/mmget_not_zero()/mmput() to modify. When this
* drops to 0 (i.e. when the task exits and there are no other
* temporary reference holders), we also release a reference on
* @mm_count (which may then free the &struct mm_struct if
* @mm_count also drops to 0).
*/
atomic_t mm_users;
/**
* @mm_count: The number of references to &struct mm_struct
* (@mm_users count as 1).
*
* Use mmgrab()/mmdrop() to modify. When this drops to 0, the
* &struct mm_struct is freed.
*/
atomic_t mm_count;
#ifdef CONFIG_MMU
atomic_long_t pgtables_bytes; /* PTE page table pages */
#endif
int map_count; /* number of VMAs */
spinlock_t page_table_lock; /* Protects page tables and some
* counters
*/
/*
* With some kernel config, the current mmap_lock's offset
* inside 'mm_struct' is at 0x120, which is very optimal, as
* its two hot fields 'count' and 'owner' sit in 2 different
* cachelines, and when mmap_lock is highly contended, both
* of the 2 fields will be accessed frequently, current layout
* will help to reduce cache bouncing.
*
* So please be careful with adding new fields before
* mmap_lock, which can easily push the 2 fields into one
* cacheline.
*/
struct rw_semaphore mmap_lock;
struct list_head mmlist; /* List of maybe swapped mm's. These
* are globally strung together off
* init_mm.mmlist, and are protected
* by mmlist_lock
*/
unsigned long hiwater_rss; /* High-watermark of RSS usage */
unsigned long hiwater_vm; /* High-water virtual memory usage */
unsigned long total_vm; /* Total pages mapped */
unsigned long locked_vm; /* Pages that have PG_mlocked set */
atomic64_t pinned_vm; /* Refcount permanently increased */
unsigned long data_vm; /* VM_WRITE & ~VM_SHARED & ~VM_STACK */
unsigned long exec_vm; /* VM_EXEC & ~VM_WRITE & ~VM_STACK */
unsigned long stack_vm; /* VM_STACK */
unsigned long def_flags;
/**
* @write_protect_seq: Locked when any thread is write
* protecting pages mapped by this mm to enforce a later COW,
* for instance during page table copying for fork().
*/
seqcount_t write_protect_seq;
spinlock_t arg_lock; /* protect the below fields */
unsigned long start_code, end_code, start_data, end_data;
unsigned long start_brk, brk, start_stack;
unsigned long arg_start, arg_end, env_start, env_end;
unsigned long saved_auxv[AT_VECTOR_SIZE]; /* for /proc/PID/auxv */
/*
* Special counters, in some configurations protected by the
* page_table_lock, in other configurations by being atomic.
*/
struct mm_rss_stat rss_stat;
struct linux_binfmt *binfmt;
/* Architecture-specific MM context */
mm_context_t context;
unsigned long flags; /* Must use atomic bitops to access */
#ifdef CONFIG_AIO
spinlock_t ioctx_lock;
struct kioctx_table __rcu *ioctx_table;
#endif
#ifdef CONFIG_MEMCG
/*
* "owner" points to a task that is regarded as the canonical
* user/owner of this mm. All of the following must be true in
* order for it to be changed:
*
* current == mm->owner
* current->mm != mm
* new_owner->mm == mm
* new_owner->alloc_lock is held
*/
struct task_struct __rcu *owner;
#endif
struct user_namespace *user_ns;
/* store ref to file /proc/<pid>/exe symlink points to */
struct file __rcu *exe_file;
#ifdef CONFIG_MMU_NOTIFIER
struct mmu_notifier_subscriptions *notifier_subscriptions;
#endif
#if defined(CONFIG_TRANSPARENT_HUGEPAGE) && !USE_SPLIT_PMD_PTLOCKS
pgtable_t pmd_huge_pte; /* protected by page_table_lock */
#endif
#ifdef CONFIG_NUMA_BALANCING
/*
* numa_next_scan is the next time that PTEs will be remapped
* PROT_NONE to trigger NUMA hinting faults; such faults gather
* statistics and migrate pages to new nodes if necessary.
*/
unsigned long numa_next_scan;
/* Restart point for scanning and remapping PTEs. */
unsigned long numa_scan_offset;
/* numa_scan_seq prevents two threads remapping PTEs. */
int numa_scan_seq;
#endif
/*
* An operation with batched TLB flushing is going on. Anything
* that can move process memory needs to flush the TLB when
* moving a PROT_NONE mapped page.
*/
atomic_t tlb_flush_pending;
#ifdef CONFIG_ARCH_WANT_BATCHED_UNMAP_TLB_FLUSH
/* See flush_tlb_batched_pending() */
atomic_t tlb_flush_batched;
#endif
struct uprobes_state uprobes_state;
#ifdef CONFIG_PREEMPT_RT
struct rcu_head delayed_drop;
#endif
#ifdef CONFIG_HUGETLB_PAGE
atomic_long_t hugetlb_usage;
#endif
struct work_struct async_put_work;
#ifdef CONFIG_IOMMU_SVA
u32 pasid;
#endif
#ifdef CONFIG_KSM
/*
* Represent how many pages of this process are involved in KSM
* merging.
*/
unsigned long ksm_merging_pages;
/*
* Represent how many pages are checked for ksm merging
* including merged and not merged.
*/
unsigned long ksm_rmap_items;
#endif
#ifdef CONFIG_LRU_GEN
struct {
/* this mm_struct is on lru_gen_mm_list */
struct list_head list;
/*
* Set when switching to this mm_struct, as a hint of
* whether it has been used since the last time per-node
* page table walkers cleared the corresponding bits.
*/
unsigned long bitmap;
#ifdef CONFIG_MEMCG
/* points to the memcg of "owner" above */
struct mem_cgroup *memcg;
#endif
} lru_gen;
#endif /* CONFIG_LRU_GEN */
} __randomize_layout;
/*
* The mm_cpumask needs to be at the end of mm_struct, because it
* is dynamically sized based on nr_cpu_ids.
*/
unsigned long cpu_bitmap[];
};
对于mmap指向的vm_area_struct,其定义如下:
include/linux/mm_types.h
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. */
struct mm_struct *vm_mm; /* The address space we belong to. */
/*
* Access permissions of this VMA.
* See vmf_insert_mixed_prot() for discussion.
*/
pgprot_t vm_page_prot;
unsigned long vm_flags; /* Flags, see mm.h. */
/*
* For areas with an address space and backing store,
* linkage into the address_space->i_mmap interval tree.
*
* For private anonymous mappings, a pointer to a null terminated string
* containing the name given to the vma, or NULL if unnamed.
*/
union {
struct {
struct rb_node rb;
unsigned long rb_subtree_last;
} shared;
/*
* Serialized by mmap_sem. Never use directly because it is
* valid only when vm_file is NULL. Use anon_vma_name instead.
*/
struct anon_vma_name *anon_name;
};
/*
* A file's MAP_PRIVATE vma can be in both i_mmap tree and anon_vma
* list, after a COW of one of the file pages. A MAP_SHARED vma
* can only be in the i_mmap tree. An anonymous MAP_PRIVATE, stack
* or brk vma (with NULL file) can only be in an anon_vma list.
*/
struct list_head anon_vma_chain; /* Serialized by mmap_lock &
* 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;
/* Information about our backing store: */
unsigned long vm_pgoff; /* Offset (within vm_file) in PAGE_SIZE
units */
struct file * vm_file; /* File we map to (can be NULL). */
void * vm_private_data; /* was vm_pte (shared mem) */
#ifdef CONFIG_SWAP
atomic_long_t swap_readahead_info;
#endif
#ifndef CONFIG_MMU
struct vm_region *vm_region; /* NOMMU mapping region */
#endif
#ifdef CONFIG_NUMA
struct mempolicy *vm_policy; /* NUMA policy for the VMA */
#endif
struct vm_userfaultfd_ctx vm_userfaultfd_ctx;
} __randomize_layout;
vm_area_struct结构中包含区域起始和终止地址以及其他相关信息,同时也包含一个vm_ops指针,其内部可引出所有针对这个区域可以使用的系统调用函数。这样,进程对某一虚拟内存区域的任何操作需要用要的信息,都可以从vm_area_struct中获得。mmap函数就是要创建一个新的vm_area_struct结构,并将其与文件的物理磁盘地址相连。
至此,可以看出,虚拟内存即为由一个个vm_area_struct结构体,通过链表组装起来的空间,其示意图如下图所示:
用户进程拥有用户空间的地址,其可以通过malloc和mmap等函数来申请内存,malloc和mmap等函数的实现都是基于进程线性区描述struct vm_erea_struct,内核管理进程地址空间的数据结构struct vm_erea_struct,简称VMA。
对于每个进程的内存描述符mm_struct,都有各自的VMA,通过mm->mmap链表将所有的VMA管理起来,同时会记录到mm->mm_rb的红黑树,用于高速查找合并VMA等操作。
三、虚拟内存区域的表示
先来说说task_struct,task_struct是一个结构体,这个结构体非常的庞大,linux下用它来完整的描述一个进程的所有信息。在每装载一个进程的时候,内核就会帮去创建一个新的task_struct结构体。然后知道一个每一个独立的进程都有自己独立的虚拟空间,所以,在task_struct结构体里会有一个struct mm_struct *mm成员,这个mm成员就是用来描述和管理进程的虚拟空间的。由上图可知,每个区域通过一个vm_eara_struct实例描述,进程的各区域按照以下两种方式排序:
- 在一个双链表上(开始于mm_struct->mmap)。
- 在一个红黑树上,跟节点位于mm_rb。
总结来说,简单的理解这三者的关系就是task_struct结构体包含了一个mm_sturcut结构体成员,mm_struct结构体包含了一个vm_area_struct结构体成员mmap,然后这个mmap成员指向一个VMA链表,管理所有的VMA。
用户虚拟地址空间中的每个区域由开始和结束地址描述,现存的区域按照起始地址以递增次序被归入链表中。扫描链表找到与特定地址关联的区域,在有大量区域时是非常低效的操作。因此vm_eara_struct的各个实例可以通过红黑树管理,可以显著加快扫描速度。
四、总结
当一个进程要运行起来需要以下的内存结构:
用户态:
- 代码段、全局变量、BSS
- 函数栈
- 堆
- 内存映射区
内核态:
- 内核的代码、全局变量、BSS
- 内核数据结构例如 task_struct
- 内核栈
- 内核中动态分配的内存
对于64位的系统,其进程运行状态如下图所示:
Linux 为虚拟内存不同的段,提供了不同的数据结构来描述:
在 Linux 内核眼中所有的进程、线程都是 task 都适用 task_struck 描述。
- task_struck 数据结构中的 mm 字段(mm_struct 类型)描述了进程或者线程用户态的内存信息;
- mm_struct 中有映射页的统计信息(总页数, 锁定页数, 数据/代码/栈映射页数等)以及各区域地址
- mm_struct 维护着 vm_area_struct 的链表,每个链表节点都描述了用户空间虚拟内存的布局划分。