Linux深入理解内存管理1(基于Linux6.6)---分段机制
一、分段机制概述
1. 分段的基本概念
分段(Segmentation)是一种内存管理机制,它将内存分割成多个段(Segment)。每个段代表程序中的一个逻辑单元,如代码段、数据段、堆栈段等。每个段都有一个起始地址和长度,操作系统通过段表来管理和映射这些段。
2. 段的类型
常见的内存段类型包括:
- 代码段(Code Segment, .text):存储程序的可执行代码,是只读的,程序在执行时访问此区域。
- 数据段(Data Segment, .data):存储已初始化的全局变量和静态变量。可以分为初始化数据段和未初始化数据段(BSS段)。
- 堆(Heap):用于动态分配内存,程序在运行时通过
malloc
或new
等操作动态分配内存。 - 栈(Stack):用于存储函数调用时的局部变量、函数参数、返回地址等。栈在程序执行过程中会不断增长和收缩。
- 共享段(Shared Segment):用于存储多个进程共享的数据。
1.1、分段机制产生的原因
1. 程序的逻辑结构需要
在早期的计算机系统中,程序的内存布局通常按照逻辑功能划分。例如,一个程序可能包括代码、数据、堆栈等不同部分,每一部分有不同的访问需求和权限。为了高效管理这些不同部分的内存,操作系统需要一种机制将内存划分成多个不同的段。分段机制的产生正是为了解决这个问题:
- 代码段(Code Segment):存储程序的可执行代码,通常是只读的,防止程序修改自己的代码。
- 数据段(Data Segment):存储全局变量、静态变量等可读写的数据。
- 堆栈段(Stack Segment):存储函数调用时的局部变量、返回地址等。
- 堆(Heap):用于动态分配内存。
通过这种划分,操作系统能够对不同类型的内存区域进行不同的权限设置,从而提供更好的内存保护和管理。
2. 内存保护与隔离
分段机制最初的设计目的是实现内存保护,尤其是在多任务操作系统中,分段机制使得操作系统能够对每个程序的内存空间进行严格的隔离,避免一个程序非法访问或修改另一个程序的内存空间。这对于提高操作系统的安全性和稳定性至关重要。
- 保护模式:分段机制能够为程序设置不同的访问权限,例如代码段只读、数据段可读写、堆栈段禁止外部访问等。这种权限控制有效避免了程序的越权访问,有助于防止程序因错误或恶意行为破坏其他程序或系统的内存。
- 内存隔离:通过对不同的段进行隔离,分段机制可以确保用户空间和内核空间的分离,从而防止用户程序直接访问操作系统内核的内存区域,提高系统的安全性。
3. 简化内存管理
分段机制的引入简化了程序的内存管理,尤其是在早期的计算机系统中。在没有现代分页机制的情况下,程序的内存分配和管理较为复杂。分段为操作系统提供了一种灵活的内存管理方式,程序员和操作系统可以通过对段的划分来决定程序的内存布局。例如,可以为代码、数据和堆栈分配不同大小的内存区域,以避免内存碎片问题。
4. 历史背景:硬件支持
分段机制的产生还与早期计算机硬件的支持密切相关。在x86等架构中,硬件提供了分段寄存器和段描述符表等机制,操作系统可以利用这些硬件特性实现内存管理和保护。例如,x86架构的段寄存器(如 CS
、DS
、SS
等)指示当前正在使用的段,操作系统通过设置段描述符来管理段的起始地址和权限。
在x86架构的早期阶段(如16位和32位模式),分段机制是内存保护和地址转换的重要工具。操作系统通过段表来管理各个段的地址和属性,从而实现内存保护、程序隔离以及内存的合理利用。
5. 分段与分页的结合
虽然分段机制有其优点,但它并不完美。由于段的大小通常是固定的且不灵活,容易导致内存碎片。此外,分段机制无法解决虚拟内存的需求,尤其是在多任务操作系统中,对于每个进程的虚拟地址空间的管理不如分页机制灵活。因此,分段和分页机制常常结合使用,以弥补彼此的不足。
- 分段与分页结合:在某些操作系统(如x86架构下的早期Windows)中,分段用于保护不同的内存区域(如用户空间和内核空间),而分页则用于虚拟地址到物理地址的映射。分页机制能够实现精细的内存管理,避免内存碎片,而分段则提供了更高层次的内存保护和隔离。
6. 分页的兴起与分段机制的逐步退场
随着计算机硬件的进步和操作系统的发展,分页机制逐渐取代了分段机制在内存管理中的主导地位。分页机制能够将内存划分为固定大小的页,提供更高效的内存管理,减少内存碎片,并能实现虚拟内存,使得进程拥有比物理内存更大的地址空间。
尽管如此,分段机制在某些特定场合仍然保留着作用,尤其是在x86架构中,它主要用于对程序的不同段进行逻辑划分,而分页机制则处理虚拟地址和物理地址之间的映射。
7. Linux中的分段机制
在现代Linux操作系统中,分段机制的作用大大减弱,操作系统主要依赖分页机制来管理内存。Linux的内存管理更多依赖于虚拟内存和页表,而分段机制仅在某些情况下(如内核和用户空间的分离、内存保护等)提供辅助支持。例如,在x86架构上,Linux使用分段机制来区分用户空间和内核空间,但并不会像早期操作系统那样依赖分段机制来进行内存的主要管理。
1.2、硬件分段机制
分段是一种隔离不同的代码、数据、栈模块的机制,能够保证不同进程或任务不会互相干扰。可以为一个进程分配属于它的段集合,CPU 的硬件机制会保证其代码不会越权访问段,也不会访问到段外的地址。
分段机制就是把虚拟地址空间中的虚拟内存组织成一些长度可变的的段的内存单元,80386虚拟地址空间中的逻辑地址由一个段部分和一个段内偏移部分构成,段是虚拟地址空间到线性地址转换的基础。每个段都有3个参数定义
- 段基地址:指定段在线性地址空间中的开始地址,基地址是线性地址对应于段中偏移0处
- 段限长:是虚拟地址空间中段内最大可用偏移地址,定义了段的长度
- 段属性:指定段的特性,如该段是否可读,可写或可执行,段的特权级
当需要访问处理器地址空间的某个字节时,段选择符指定了该字节所在的段,偏移量制定了该字节在段中相对于段基址的位置,处理器把逻辑地址转化成一个线性地址的过程如下:
- 1.使用段选择符中的偏移值(在GDT(全局描述符表) 或 LDT(局部描述符表)中定位相应的段描述符
- 2.利用段描述符校验段的访问权限和范围,以确保该段是可以访问的并且偏移量位于段界限内
- 3.利用段描述符中取得的段基地址加上偏移量,形成一个线性地址
1. 段选择符
段选择符(或称段选择子)是段的一个十六位标志符,如下图所示。段选择符并不直接指向段,而是指向段描述符表中定义段的段描述符。
段选择符包括 3 个字段的内容:
- 请求特权级RPL([0:1])
- 表指引标志TI([2])TI = 0 ,表示描述符在GDT中,TI = 1,表示描述符在LDT中
- 索引值,给出了描述符在GDT或LDT表中的索引项号
下面是一些段选择符的示例:
2. 段描述符
段描述符表是段描述符的一个数组,如下图所示。描述符表的长度可变,最多可以包含8192个 8 byte 描述符。有两个描述符表: 全局描述符表GDT (Global descriptor table); 局部描述符表 LDT (Local descriptor table),由段选择符的bit[2]会选择到对应的GDT表还是LDT表去拿到对应的段基址。
而对于段描述符,每个段描述符长度是 8 字节,含有三个主要字段:段基地址、段限长和段属性。段描述符通常由编译器。链接器、加载器或者操作系统来创建,绝不可能由应用程序来创建。
段描述符通用格式如下:
了解了这个过程,我们来总体的梳理下,如果使用分段机制,那么怎么使虚拟地址空间转到对应的物理地址空间呢?转换过程如下图所示 :
- 1.取出虚拟地址空间中的段选择符,根据TI位判断段描述符是存储在GDT还是LDT中。
- 2.段选择符中的index*8,也就是左移3位,就是段描述符在GDT中的位置,在加上GDT的基地址,就是段描述符的地址,从而去除段描述符。
- 3.段描述符中保存了该段的基地址,加上虚拟地址中的偏移量就是对应到的物理地址空间。
二、Linux中分段的实现原理
分段管理内存的基本原理是在内存中创建不同的段,每个段都有自己的地址空间。程序中的每个指令或者数据访问都是相对于某一段的偏移量。分段机制的核心概念是通过段寄存器来标识当前正在访问的段,再加上段内的偏移量来确定内存的具体位置。
地址结构
分段机制使用两部分来表示一个地址:
- 段选择符(Segment Selector):用于选择一个段的描述符。
- 偏移量(Offset):在该段内的偏移量。
这两个部分共同构成一个逻辑地址,通过段描述符表来映射到物理内存地址。
引入分页机制后,Linux很少使用分段,分段和分页在某些方面是冗余的,因为他们都可以把物理地址空间分割成不同部分:分段给每个进程分配不同的逻辑地址空间,而分页可以把相同的逻辑地址空间映射到不同的物理地址上。因此,Linux优先采用了分页(分页操作系统),基于以下原因:
- 内存管理更简单:所有进程使用相同段寄存器值,也就是相同的线性地址集。
- 出于兼容大部分硬件架构的考虑,RISC架构对分段支持的不是很好。
那么Linux内核是怎么支持分段机制呢?
比如,将虚拟地址空间分成4个段,用0-3来编号,每个段在段表中有一个项,在物理空间中,段的排列如下图所示:
如果要访问段2中偏移量为600的虚拟地址,可以计算出物理地址为段基地址+偏量=2000+600=2600。
三、Linux分段机制的软件实现
Linux对段机制的应用效果是等价于几乎绕过了段基址。在Linux中仅有4个段,用户代码段、数据段和内核代码段、数据段:
1. 用户代码段(User Code Segment)
这是程序执行的代码段,用于存放用户程序的可执行代码。在传统的分段模型中,代码段会有可执行的权限,而在Linux中,它通常对应于用户空间的虚拟地址空间。用户代码段的地址空间通常与内核空间是分开的,从而提供了基本的内存隔离。
2. 用户数据段(User Data Segment)
用户数据段用于存储用户程序的静态数据和堆栈(如全局变量、局部变量等)。在分段模型中,数据段通常是可读写的,但不可执行。Linux通过分页实现对该段的虚拟内存映射和保护。
3. 内核代码段(Kernel Code Segment)
内核代码段包含操作系统内核的执行代码,它通常具有更高的权限级别。在x86架构中,内核代码段是通过内核空间的段来隔离的,确保用户程序无法访问。内核代码段通常与内核数据段共享虚拟内存,具有专门的访问权限(通常是可执行和只读的)。
4. 内核数据段(Kernel Data Segment)
内核数据段存储内核的数据结构和全局变量,例如内核内部的缓冲区、队列、内存管理结构等。内核数据段的内容是对内核代码可访问的,且具有高权限。
这些段相应的选择器分别由以下宏定义:_USER_CS, __USER_DS, __KERNEL_CS, 和__KERNEL_DS。举例来说,如果要定位内核代码段,内核只需要加载__KERNEK_CS宏的值到cs寄存器中。 接下来看一下linux代码吧,进入保护模式的函数go_to_protected_mode:
arch/x86/boot/pm.c
void go_to_protected_mode(void)
{
/* Hook before leaving real mode, also disables interrupts */
realmode_switch_hook();
/* Enable the A20 gate */
if (enable_a20()) {
puts("A20 gate not responding, unable to boot...\n");
die();
}
/* Reset coprocessor (IGNNE#) */
reset_coprocessor();
/* Mask all interrupts in the PIC */
mask_all_interrupts();
/* Actual transition to protected mode... */
setup_idt();
setup_gdt();
protected_mode_jump(boot_params.hdr.code32_start,
(u32)&boot_params + (ds() << 4));
}
里面的函数略带一下吧,realmode_switch_hook()根据注释和函数命名可以知道这是在实模式切换前的钩子函数调用的地方;enable_a20()这个太熟悉了,就开启A20;reset_coprocessor()是把协处理器重置一下mask_all_interrupts()则是把中断关了,避免切换过程中出现状况。其中setup_idt()和setup_gdt()是本节的重点,函数名字告诉我们这是设置idt和gdt的,看一下两者具体代码吧:
arch/x86/boot/pm.c
static void setup_idt(void)
{
static const struct gdt_ptr null_idt = {0, 0};
asm volatile("lidtl %0" : : "m" (null_idt));
}
根据setup_idt()的实现,可以明显看到这没做什么,纯粹置一下idt为空的描述符表:
arch/x86/boot/pm.c
static void setup_gdt(void)
{
/* There are machines which are known to not boot with the GDT
being 8-byte unaligned. Intel recommends 16 byte alignment. */
static const u64 boot_gdt[] __attribute__((aligned(16))) = {
/* CS: code, read/execute, 4 GB, base 0 */
[GDT_ENTRY_BOOT_CS] = GDT_ENTRY(0xc09b, 0, 0xfffff),
/* DS: data, read/write, 4 GB, base 0 */
[GDT_ENTRY_BOOT_DS] = GDT_ENTRY(0xc093, 0, 0xfffff),
/* TSS: 32-bit tss, 104 bytes, base 4096 */
/* We only have a TSS here to keep Intel VT happy;
we don't actually use it for anything. */
[GDT_ENTRY_BOOT_TSS] = GDT_ENTRY(0x0089, 4096, 103),
};
/* Xen HVM incorrectly stores a pointer to the gdt_ptr, instead
of the gdt_ptr contents. Thus, make it static so it will
stay in memory, at least long enough that we switch to the
proper kernel GDT. */
static struct gdt_ptr gdt;
gdt.len = sizeof(boot_gdt)-1;
gdt.ptr = (u32)&boot_gdt + (ds() << 4);
asm volatile("lgdtl %0" : : "m" (gdt));
}
首先,之前的GDT entry的结构图如下:
GDT_ENTRY的定义如下:
arch/x86/include/asm/segment.h
/* Constructor for a conventional segment GDT (or LDT) entry */
/* This is a macro so it can be used in initializers */
#define GDT_ENTRY(flags, base, limit) \
((((base) & 0xff000000ULL) << (56-24)) | \
(((flags) & 0x0000f0ffULL) << 40) | \
(((limit) & 0x000f0000ULL) << (48-16)) | \
(((base) & 0x00ffffffULL) << 16) | \
(((limit) & 0x0000ffffULL)))
可以清楚得看到,base, limit和flag通过位移和或组成了GDT_ENTRY。其中flags代表了40-47位的access byte和52-55位的flags。
- CS和DS的flags为0xc0,所以G=1,意味着4K为一个页面,B/D为1,1-32位段;
- CS的Access Byte=0x9b,意味着P=1(合法的Entry Pr必须为1),DPL=0,S=1,这里该段只能在Ring 0下访问,该段是代码段
- DS的Access Byte=0x93,意味着P=1(合法的Entry Pr必须为1),DPL=0,S=1,这里该段只能在Ring 0下访问,该段是数据段
linux中逻辑地址等于线性地址。为什么这么说呢?因为Linux所有的段(用户代码段、用户数据段、内核代码段、内核数据段)的线性地址都是从 0x00000000 开始,长度4G,这样 线性地址=逻辑地址+ 0x00000000,也就是说逻辑地址等于线性地址了。通过分析,我们发现,所有的段的起始地址都是一样的,都是 0。这算哪门子分段嘛!所以,在 Linux 操作系统中,并没有使用到全部的分段功能。那分段是不是完全没有用处呢?分段可以做权限审核,例如用户态 DPL 是 3,内核态 DPL 是 0。当用户态试图访问内核态的时候,会因为权限不足而报错。
还是以 mov 0x80495b0, %eax 中的地址为例分析一下转换过程:
- 1.首先段选择符中的TI为0,表明段描述符在GDT表中,使用段选择符中的偏移值定位到相应的段描述符,找到15这个位置
- 2.从15号位置的段描述符,找到对应的访问权限,访问基地址(0)和访问范围(0xffff)
- 3.利用段描述符中去得到的段基址0x0000000,加上逻辑地址偏移0x80495b0,形成线性地址0x80495b0。
所以Linux没有采用严格的分段机制,已经慢慢的弱化分段机制,而使用分页机制来替换分段机制。
四、分段机制的优缺点
现在大致了解了分段的基本原理,系统运行时,地址空间中不同段被重定位到物理内存中,与之前的整个物理地址空间中只有一个基地址+偏移量的方式相比,大量的节省了物理内存。同时分段管理就是将一个程序按照逻辑单元分成多个程序段,每一个段使用自己单独的虚拟地址空间。例如,对于编译器来说,我们可以给其5个段,占用5个虚拟地址空间,如下图所示:
如此,一个段占用一个虚拟地址空间,不会发生空间增长时碰撞到另一个段的问题,从而避免因空间不够而造成编译失败的情况。如果某个数据结构对空间的需求超过整个虚拟之地所能够提供的空间,则编译仍将失败,开编提到的问题1好像得到了完美解决。
正是因为这种映射,使得程序无需关注物理地址是多少,只要虚拟地址没有改变,那么程序就不会操作地址不当,问题2也好像可以很好的解决。
但是问题3,是换入换出的问题,这个问题的关键是能不能在换出一个完整程序之后,把另外一个程序换进来,而这种分段机制,就存在一个很严重的问题。
物理内存很快就会被许多空间空间的小块,因为很难分配给新的段,或扩大已有的段,这种问题被成为外部碎片:
分段机制采用的是分段,这就导致一个问题,已分配的段有大有小,未使用的段也有大有小,将要分配的段也有大有小,各方需求不一定,理想的情况,但系统中的程序比较少,内存没有完全使用的情况下会如紧凑型分配。但是在程序运行过程中,有些程序运行完后,要释放新已分配的内存空间,当使用一段时间后,可能会出现非紧凑的情况,在这个例子中,一个进程需要分配一个20K的段,当前有24K的空闲,却不连续,因此操作系统无法满足这20K的请求。这也就是外部碎片,其特征如下:
- 外部碎片是指还没有被分配出去(不属于任何进程),但是由于太小了,无法分配给申请内存空间的新进程的内存空闲区域。
- 虽然这些存储块的总和可以满足当前申请的长度的要求,但是由于他们的地址不连续或者其他原因,使得系统无法满足当前的申请。