嵌入式技术
虚拟内存技术是操作系统实现的一种高效的物理内存管理方式,具有以下作用:
页式内存管理技术是 Linux 实现的一种虚拟内存技术,其基本思想是将物理内存和虚拟内存都分割成多个固定大小的 Page(页),然后对这些 Pages 进行编址,并通过 Page Table(页表)将它们一一映射起来。当 CPU 访问虚拟地址空间时,Linux 会通过 Page Table 将虚拟地址转换为物理地址。
Linux 通过页式内存管理技术,除了能够高效地管理物理内存之外,还提供了许多额外的虚拟内存功能,例如:进程隔离、内存保护、共享物理内存等。
在 x86 32bit Linux 系统中,虚拟地址(也称为线性地址,Linear Address)的格式由 3 部分组成,总长度为 32bit,寻址范围为 2^32,最大可描述空间为 4G。
在 Kernel 中用于对虚拟地址进行寻址的数据结构称为 Kernel Page Table(内核页表),包括:
可见,32bit 系统中的 2 级页表结构,最多可以映射 1024*1024 个 Pages。而对于大于 4GB 的物理地址空间,则需要使用多级级页表结构,以支持更大的物理内存空间。
在 x86 64bit 系统中,可以描述的最长地址空间为 2^64(16EB),远远超过了目前主流内存卡的规格,所以在 Linux 中只使用了 48bit 长度,寻址空间为 2^48(256TB),User Space 和 Kernel Space 各占 128T。寻址空间分别为:
由于内存空间的扩大,x86 64bit 系统中的虚拟地址格式由 5 部分组成,占 64bit 中的 48bit。相应的,Linux Kernel 在 v2.6.10 中实现了四级页表,后来又在 v4.11 中引入了五级的页表结构。
就四级页表而言,虚拟内存空间被划分成了 4 个层次结构,每一级都有一个页表来记录该层次的映射关系。
当 CPU 需要访问一个虚拟地址时,会执行以下页表遍历流程:
在页表遍历的过程中,如果找到对应的物理页框,则可以进行对应的内存读写操作。反之,如果遇到了寻址失败的情况,则说明对应的物理页框没有被分配或者被换出到外存了,此时需要进行相应的页表调度和页表交换操作。
四级页表的优点是它可以映射非常大的虚拟内存空间,并且每个进程的页表都是独立的,相互干扰。缺点是每次访问内存都需要遍历四级页表,这会导致一定的性能损失。并且,当 Linux 设定的虚拟页大小越小时,单个进程中的页表项和虚拟页也就越多,页表的层级也可能越多,查询性能就越低。同时也需要注意,页面并非是越大越好,因为过大的页面会造成内存碎片,降低了内存的利用率。
因此,Linux 采用了一些优化措施,如 TLB(Translation Look-aside Buffer)缓存等,来加速页表遍历的过程。
Linux 虚实地址转换功能,除了需要由 Kernel 实现的内核页表(Page Table)数据结构之外,还需要硬件层面的 CPU MMU(Memory Management Unit,存储管理单元)支持。
MMU(Memory Management Unit,内存管理单元)内嵌在 CPU 芯片上,它是一个专用的硬件,利用存放在 Main Memory 中的 Page Table 来辅助完成虚实地址之间的动态翻译,而 Page Table 的内容就交由 Kernel 来统一管理。
当 CPU 访问虚拟地址时,MMU 首先将虚拟地址的高位部分作为页表的索引,查找对应的页表项。页表项中存储了与虚拟页对应的物理页的起始地址以及一些标志位,如是否可读、可写等。然后,MMU 将虚拟地址的低位部分作为偏移量,加上物理页的起始地址,得到实际的物理地址。
TLB(Translation Look-aside Buffer,翻译旁路缓冲器)同样是内嵌在 CPU 芯片上的一个专用硬件,作为缓存,旁挂在 MMU 上,缓存了最近访问过的虚拟地址与物理地址之间的映射关系,以便在下次访问时快速地进行翻译。TLB 的空间非常有限,一般只可缓存几十个到数百个条目。
通过 TLB,操作系统可以旁路掉多级页表遍历的流程,只需要在 TLB 中执行一次高速访问即可,前提是没有 TLB Miss(缓存失效)。如果 Miss 的话,就会回到常规的页表遍历流程,然后再利用局部性原理去更新 TLB。
为了保障多任务实时操作系统运行的安全性和稳定性,Intel x86 CPU 提供了 Ring0-3 这 4 种不同的运行模式,而 Linux 只使用了其中的 Ring0(特权指令模式)和 Ring3(非特权指令模式),为虚拟地址空间提供了 2 级保护机制。
相应的,在 32bit Linux 系统中,大小为 4G 的虚拟地址空间,被分成了 2 个部分:
所以,Linux 中的 Page Table 也可以被分为 2 种:
另外,User Space 可以通过 SCI(系统调用接口)来访问或操作 Kernel Space 的代码和数据,同时也会触发 CPU 运行模式的切换。例如:C 标准库中的 malloc() 函数底层调用了 sbrk() 或 brk() SCI 来分配堆内存;printf() 函数底层调用了 wirte() SCI 来输出字符串等等。
从上图可以看出 Linux 对虚拟地址空间作了复杂的分段布局,主要是为了实现更加高效的内存管理和保护机制。
以 User Space 为例:
划分不同的存储空间更有助于针对不同的数据内容进行合理的访问和存储规划。例如:
在 User Space 中,每个 User Process 都有一个 task_struct(进程描述符)。
struct task_struct {
pid_t pid; // User Process ID
pid_t tgid; // Kernel Thread ID
struct files_struct *files; // 文件描述符
struct mm_struct *mm; // 内存映射描述符
...
}
其中,除了 Environment Variables(程序运行时环境变量)和 Command-line arguments(程序运行指令行参数)之外,进程虚拟地址空间的内存布局都通过 mm_struct 结构体来进行描述。
User Process 下属的每个 User Thread 都有属于自己的用户线程栈。主要用于存储以下信息:
Stack Segment 的空间具有 “静态分配" 和 “动态分配” 这 2 种使用形势。其中,静态分配由 C 编译器自动分配和管理,主要应用在函数处理流程。而动态分配则由程序通过 alloca() 函数主动申请和释放。
例如:在一次函数调用中,C 编译器依次入栈的数据包括:
通过先进后出(FILO)的数据结构,使得被调函数退出后,可以继续执行主调函数的语句。
Stack Segment 是一块连续的空间,运行时大小可以由 Kernel 动态调整(向下增长),且最大容量 RLIMIT_STACK(8M)由系统预先定义,用户也可以通过 ulimit -s 指令来查看和设定栈的最大值。
$ ulimit -s
8192
当程序入栈数据超出容量之后,就会触发 Stack Overflow(溢出)错误,此时程序收到一个 Segmentation Fault(段错误)异常。
程序每执行一次函数调用都会在 Stack 中生成一个栈帧(Stack Frame),对应着一个未运行完的主调函数,用于存储被调函数的执行环境信息,包括:函数实际参数、函数局部变量、函数返回值地址等等。
栈帧主要通过两个指针寄存器来实现:
ebp 到 esp 之间的地址空间就是用于存储当前被调函数执行环境信息的空间。
另外,Stack Segment 很可能会同时存在多个栈帧(函数嵌套调用),此时多个栈帧会根据函数调用顺序在 Stack Segment 中先入后出。
例如:虽然 esp 会随着当前函数的入栈和出栈而不断移动,但由于 ebp 的存在,所以当前函数栈帧的边界始终是清晰的。当被调函数退出后,ebp 就会跳到主函数栈帧的底部,esp 也会随其自然的来到主函数栈帧的头部。
Memory Mapping Segment(内存映射段)的空间通过 mmap() SCI(系统调用接口)来使用,用于将外存(e.g. 硬盘)中的一个文件、或一段物理内存直接映射到 Memory Mapping Segment 中,而后 User Process 就可以采用指针的方式来访问一段内存,而不必再调用 read() / write() 等 SCI。mmap() 是一种高效的 I/O 方式。
Memory Mapping Segment 主要有 2 类应用场景:
Memory Mapping Segment 的空间大小同样可以由 Kernel 动态调整(向上增长)。
Heap Segment(运行时堆)的空间由程序自行使用,包括分配和释放。例如:开发者可通过 C 标准库 malloc() 函数申请并返回 void*(无类型指针),且无名称,只能通过指针访问。
在 Kernel 层面通过堆管理器来管理 Heap Segment 的空间。堆管理器通过链表存储结构来记录 Heap Segment 空间的使用情况,记录了包括:空闲的内存地址、已使用的内存地址等。
当程序申请一块内存时,堆管理器会遍历链表寻找第一个空间大于所申请空间的节点,并返回地址给程序,然后将该节点从空闲链表中删除。所以 Heap 空间中的多个内存块之间很可能是不连续的。
当目前的 Heap Segment 已经没有足够的空间时(可能由于内存碎片太多导致的),那么堆管理器可能会通过 brk() 或 sbrk() SCI 进行动态调整(向上增长),实际上是通过调整 Heap Segment 末端的 break 指针来实现。
Heap Segment 的空间总大小受到 CPU 架构和操作系统位数影响,例如:32bit 架构的 Heap Segment 最大可达 2.9G 空间。
当开发者经过编码、编译、汇编、链接一个 C 程序后就得到了一个可执行程序的文件。然后,就需要通过程序装载器(Loader)将可执行文件加载到 User Space 中并启动一个 User Process。在 Linux 上,可执行文件采用的是 ELF(Executable and Linkable File Format,可执行与可链接文件格式)格式。
ELF 文件由 4 部分组成,分别是:
其中,位于 Program Header Table 和 Section Header Table 之间的都是 Sections,这些 Sections 中的数据会在程序启动时被加载到相应的进程虚拟地址空间中。
关键的 Sections 包括以下几个:
BSS Segment 和 Data Segment 常被合并称为 “数据段”,都用于存储全局变量和静态变量,区别于存储在 Stack Segment 中的函数局部变量。
ELF .bss section 的特别之处在于没有具体的数值,所以只需要记录下全局变量和静态变量所需要的内存空间大小即可,但并不会分配真实的内存空间,即:只记录了全局变量和静态变量在虚拟地址空间中的开始和结束地址。
当程序加载器(Loader) 将 ELF .bss section 加载到 BSS Segment 后,这些数据会被 C 编译器自动的初始化为 0 或 NULL。这样可以有效的减少了 C object file 的体积。
例如:对于 int arr0[10000] = {1, 2, 3, …}
和 int ar1[10000]
这两个数组而言:
Text Segment(代码段)主要存储了从 ELF .text section 中加载的机器指令。
Text Segment 中的数据只能读不能写,但可以被执行,即:Text Segment 中的数据是可共享的,可以被其他的进程执行。例如:机器中有数个进程运行相同的一个程序,那么它们就可以使用同一个代码段。
可见,User Space 划分了明确的 “数据区” 和 “指令区",且数据区对于进程而言可读写,而指令区对于进程只读,以防止程序指令被误改。
基于 Linux 虚拟内存管理技术,每个 User Process 都拥有自己独立的虚拟地址空间,当一个 User Process 被 Kernel 加载并运行时,无需要一次性将 User Process 所有数据都加载到 Main Memory 中,而是当通过 Page Table 缺页中断的方式来动态加载。
虚拟地址的页表遍历过程中,当访问到某个页面时,通过页表项中的有效位,可以得知此页面是否在内存中,如果不存在,则通过缺页异常,将磁盘对应的数据拷贝到内存中,如果没有空闲内存,则选择牺牲页面,替换掉其他页面。在这个时候,被内存映射的文件实际上成了一个分页交换文件。
区别于 User Space 只拥有虚拟地址空间。Kernel Space 除了虚拟地址空间之外,还直接拥有一部分的物理地址空间。也就是说 Kernel Space 具有 2 种地址映射关系,如下图所示。
基于不同的用途,Linux 将物理内存划分为 3 个 ZONEs,从地址低到高为:
最终,通过结合两种映射方式,Linux Kernel 可以完全接管整个 4G 物理内存空间。如下图所示,蓝色区域为直接映射空间,绿色区域为动态映射空间,棕色区域为动态映射页面。
根据 User Process 对 Kernel Space 访问权限的不同,还可以将 Kernel Space 分为 “进程私有” 和 “进程共享” 这 2 块区域。
Kernel Space 中的物理直接映射区,属于 “进程共享区域",是为了让 Kernel Space 或者 User Space 可以直接访问某些特殊的物理内存区域。这些物理内存区域包括:
可见,物理直接映射区使得 Kernel 和 User Process 得以更方便地访问一些特殊的物理内存区域,从而简化了操作系统和设备驱动程序的编写。
DMA(Direct Memory Access,直接内存访问)指的是主机的 I/O 外设对 Main Memory 的直接访问。有了 DMA 机制之后,外设跟主存之间的数据交互主要由 DMA Controller 来完成的,从而避免了 CPU(包括 MMU)的参与。
物理内存中的 ZONE_NORMAL(高端内存区域)大小为 4G - 896M = 3200M,远远大于 Kernel Space 剩余的 1G - 896M = 128M 虚拟地址空间。所以 Kernel 对 ZONE_NORMAL 的访问需要采用动态映射的方式。
Kernel Space 中的 128M 统称为 “高端内存映射区”,主要有以下 3 个部分组成:
Fixing Kernel Mapping(固定映射区)通常用于静态分配内存,例如:驱动程序需要一段固定大小的内存来存储数据结构或缓冲区,它可以使用 Fixing Kernel Mapping 将一段物理内存空间映射到内核虚拟地址空间中,并在整个生命周期中保持映射关系。
Temporary Kernel Mapping(临时映射区)通常用于动态分配内存,例如:驱动程序需要在运行时创建一个临时缓冲区来存储数据,它可以使用 Temporary Kernel Mapping 来创建一个临时的虚拟内存区域,映射到物理内存空间中,并在使用完毕后释放虚拟内存空间。
Persistent Kernel Mapping(持久映射区)用于在 Kernel Space 中维护一组持久性的映射关系。这些映射通常是针对硬件设备或驱动程序的,允许 Kernel 直接访问这些设备或驱动程序的存储器区域。例如:用于 PCI I/O 外设进行内存映射的区域,大小由 PCI 规范决定。
Vmalloc Area(动态映射区)用于 Kernel 动态分配内存,例如:当 Kernel 需要访问 I/O 外设的存储空间时,就会使用 ioremap() SCI 将位于物理地址空间中的 MMIO 内存映射到 Kernel Space 的 vmalloc area 中,并在使用完之后释放映射关系。
vmalloc() 和 kmalloc() 函数都用于从 Kernel Space 中申请内存,但两者有很大的不同:
与 User Space 中的 malloc() 不同,在 Kernel Space 进行内存申请是直接分配的,区别于 User Space 的延迟分配(通过缺页机制来反馈)方式。一旦 vmalloc() 和 kmalloc() 申请内存,那么 Kernel 就必须立刻满足。
全部0条评论
快来发表一下你的评论吧 !