基于OpenEuler的操作系统内存管理机制深度探究实验

举报
pluto1447 发表于 2025/12/13 04:04:07 2025/12/13
【摘要】 本实验旨在深入探究 OpenEuler 操作系统的内存管理机制,全方位展示了从应用层到内核层的内存管理核心技术。

基于OpenEuler 的操作系统内存管理机制深度探究实验

一、 实验目的

  1. 透视虚拟内存:打破“内存黑盒”认知,通过实战观测进程的虚拟地址空间布局(代码段、数据段、堆、栈),理解虚拟地址与物理地址的隔离机制。
  2. 掌握内核分配机制:深入 OpenEuler 内核,通过 proc 文件系统实时观测伙伴系统(Buddy System)的内存分配行为,理解外部碎片问题。
  3. 分析内存瓶颈与 Swap:通过压力测试模拟内存资源枯竭场景,观察页面置换(Swap)现象与系统抖动(Thrashing),掌握性能分析工具的使用。
  4. 内核态地址转换(高阶):编写 Linux 内核模块(LKM),模拟 MMU 硬件行为,手动遍历多级页表,实现“逻辑地址到物理地址”的转换。

二、 实验环境

  1. 基础平台:OpenEuler 22.03/24.03 虚拟机。
  2. 开发工具:VS Code (Remote-SSH 连接), gcc, make。
  3. 内核环境:需安装 kernel-devel 包(用于编译内核模块)。
  4. 分析工具:pmap, vmstat, stress-ng (可选), cat /proc/*。

三、 实验内容与步骤

1. 进程虚拟地址空间观测

虚拟内存是一种计算机系统内存管理技术,它使得应用程序认为自己拥有连续的可用内存(一个连续完整的地址空间),而实际上,这些内存可能是被分散存储在物理内存和外部存储设备(如硬盘)中的。虚拟内存通过将程序使用的内存地址映射到物理内存地址,实现了内存的按需分配和管理。其核心是将进程的逻辑地址(虚拟地址)与物理内存的物理地址解耦,由操作系统内核配合 MMU(内存管理单元)完成地址转换。

核心目标:验证程序中的变量到底存在内存的哪个角落,理解 VMA (Virtual Memory Area)。

(1) 编写内存解剖程序

创建文件 mem_anatomy.c。该程序申请了不同类型的内存,用于观测分布。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h>

// 全局变量 -> 数据段
int global_init_var = 100;

int main() {
    // 局部变量 -> 栈
    int local_var = 200;
    
    // 动态分配 -> 堆
    char *heap_ptr = (char *)malloc(1024 * 1024 * 10); // 10MB
    
    // 内存映射 -> mmap区
    void *mmap_ptr = mmap(NULL, 4096, PROT_READ|PROT_WRITE, 
                          MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);

    printf("=== Process Memory Layout (PID: %d) ===\n", getpid());
    printf("Code Segment:  %p\n", main);
    printf("Data Segment:  %p\n", &global_init_var);
    printf("Heap Segment:  %p\n", heap_ptr);
    printf("Mmap Segment:  %p\n", mmap_ptr);
    printf("Stack Segment: %p\n", &local_var);
    
    printf("\n程序正在运行中,请不要关闭终端...\n");
    getchar(); // 暂停以供观测

    free(heap_ptr);
    munmap(mmap_ptr, 4096);
    return 0;
}  

(2)编译并观察

1.编译运行:

gcc -o mem_anatomy mem_anatomy.c #编译
./mem_anatomy #运行

此时,操作系统会打印出一连串的0x开头的地址,如图

2.验证:
此时重新开一个终端,输入指令:

# 将 PID 替换为程序输出的进程号
cat /proc/<PID>/maps

将程序打印的 Heap Segment 地址与 maps 输出中的 [heap] 范围进行比对,
并思考: 为什么 maps 显示的地址范围通常是以 000 结尾的?(提示:页对齐)。

2.按需分配机制分析

通过对C语言的学习,我们认识到 malloc 函数是用来申请内存的。如果 malloc 返回非空指针,我们就认为物理内存已经到手了。

然而,在 OpenEuler (Linux) 这样的现代操作系统中,申请成功不等于拥有物理内存。内核采用了一种 “不见兔子不撒鹰” 的策略(Lazy Allocation):它只会在虚拟内存中进行分配,只有当你真正去读/写这块内存时,内核才会触发缺页中断 (Page Fault),火速分配物理页框。接下来,我们将通过实验去验证内核中的按需分配机制

2.1编写内核模块paddr_tracer.c(lazy_detector)

#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/mm.h>
#include <linux/sched.h>
#include <linux/pid.h>
#include <linux/pgtable.h>
#include <asm/pgtable.h>
#include <linux/io.h> 

// 模块参数:目标 PID 和 虚拟地址
static int target_pid = 0;
static unsigned long target_vaddr = 0;
module_param(target_pid, int, 0644);
module_param(target_vaddr, ulong, 0644);

// 手动遍历页表的核心函数
static void inspect_page_table(void) {
    struct pid *pid_struct;
    struct task_struct *task;
    struct mm_struct *mm;
    pgd_t *pgd;
    p4d_t *p4d;
    pud_t *pud;
    pmd_t *pmd;
    pte_t *pte;
    unsigned long pfn = 0;

    // 1. 获取进程描述符
    pid_struct = find_get_pid(target_pid);
    if (!pid_struct) {
        printk(KERN_ERR "MemLab: PID %d 不存在\n", target_pid);
        return;
    }
    task = pid_task(pid_struct, PIDTYPE_PID);
    if (!task) {
        printk(KERN_ERR "MemLab: 无法获取 task_struct\n");
        return;
    }
    mm = task->mm;
    if (!mm) {
        printk(KERN_INFO "MemLab: 该进程没有内存空间 (可能是内核线程)\n");
        return;
    }

    // 2. 遍历页表 (Page Table Walk)
    printk(KERN_INFO "=== MemLab: 分析进程 %d, 虚拟地址 0x%lx ===\n", target_pid, target_vaddr);

    // Level 1: PGD
    pgd = pgd_offset(mm, target_vaddr);
    if (pgd_none(*pgd)) {
        printk(KERN_INFO "  -> PGD 为空 (未映射)\n"); 
        return;
    }

    // Level 2: P4D (x86通常折叠)
    p4d = p4d_offset(pgd, target_vaddr);
    if (p4d_none(*p4d)) {
        printk(KERN_INFO "  -> P4D 为空\n"); 
        return;
    }

    // Level 3: PUD
    pud = pud_offset(p4d, target_vaddr);
    if (pud_none(*pud)) {
        printk(KERN_INFO "  -> PUD 为空\n"); 
        return;
    }

    // Level 4: PMD
    pmd = pmd_offset(pud, target_vaddr);
    if (pmd_none(*pmd)) {
        printk(KERN_INFO "  -> PMD 为空\n"); 
        return;
    }
    // 检查是否是大页 (HugePage)
    if (pmd_large(*pmd)) {
         printk(KERN_INFO "  -> 这是一个 2MB 大页 (HugePage)!\n");
         return;
    }

    // Level 5: PTE (页表项)
    // 【关键修改】Kernel 6.6 修复:
    // 原来的 pte_offset_map 在新内核中不再导出符号。
    // 在 x86_64 上,我们可以直接用 pte_offset_kernel 来获取 PTE 指针。
    pte = pte_offset_kernel(pmd, target_vaddr);
    
    if (!pte) {
        printk(KERN_ERR "  -> 无法获取 PTE\n"); 
        return;
    }

    // 3. 核心判断
    if (!pte_present(*pte)) {
        printk(KERN_ALERT "  -> [结果] PTE 存在,但 Present 位为 0!(物理页未分配)\n");
    } else {
        // 获取物理页框号 (PFN)
        pfn = pte_pfn(*pte);
        // 检查读写权限
        int is_writable = pte_write(*pte);
        
        printk(KERN_ALERT "  -> [结果] 物理页已分配!\n");
        printk(KERN_ALERT "     PFN (物理页框号): 0x%lx\n", pfn);
        printk(KERN_ALERT "     物理地址: 0x%lx\n", (pfn << PAGE_SHIFT) | (target_vaddr & ~PAGE_MASK));
        printk(KERN_INFO  "     Writeable: %s\n", is_writable ? "Yes (R/W)" : "No (Read-Only)");
    }

    // 注意:使用 pte_offset_kernel 不需要调用 pte_unmap
}

// 模块入口
static int __init my_init(void) {
    if (target_pid != 0) {
        inspect_page_table();
    }
    return 0;
}

// 模块出口
static void __exit my_exit(void) {
    printk(KERN_INFO "MemLab: 模块已卸载\n");
}

module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");

2.2.准备Makefile文件并编译:

# 这里的 paddr_tracer.o 必须跟你的 .c 文件名对应
obj-m += paddr_tracer.o

# 指向本机内核源码构建目录
KDIR := /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)

all:
	make -C $(KDIR) M=$(PWD) modules

clean:
	make -C $(KDIR) M=$(PWD) clean
make

2.3.编写用户态程序lazy_target.c

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h> // 引入 mmap 头文件

int main() {
    printf("PID: %d\n", getpid());
    
    // 【关键修改】使用 mmap 申请内存,而不是 malloc
    // PROT_READ | PROT_WRITE : 可读写
    // MAP_PRIVATE | MAP_ANONYMOUS : 私有匿名映射 (不关联文件)
    char *p = (char *)mmap(NULL, 4096, PROT_READ | PROT_WRITE, 
                           MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);

    if (p == MAP_FAILED) {
        perror("mmap failed");
        return 1;
    }
    
    printf("虚拟地址: %p\n", p);
    printf("【阶段1】mmap 完成,但尚未写入数据。\n");
    printf("       -> 此时内核只在 VMA 记了账,还没分配物理页。\n");
    printf("       -> 请加载模块观测 (预期: PGD/PTE 为空 或 Present=0)。\n");
    getchar(); // 暂停等待观测

    // 2. 写入内存 (触发缺页中断)
    p[0] = 'A'; 
    printf("【阶段2】数据 'A' 已写入。\n");
    printf("       -> 缺页中断已发生,物理页应已分配。\n");
    printf("       -> 请再次加载模块观测 (预期: 物理页已分配)。\n");
    getchar(); // 暂停等待观测

    // 释放内存
    munmap(p, 4096);
    return 0;
}

2.3.运行程序,观察分配过程:

准备两个终端窗口A,B,A窗口用于运行程序,B窗口用于监控内存分配情况

(1)A窗口输入指令,运行程序,并记录PID,Address

gcc -o lazy_target lazy_target.c
./lazy_target

(2)B窗口再次执行指令

sudo insmod paddr_tracer.ko target_pid=[your_pid] target_vaddr=[your_address]
sudo dmesg | tail
sudo rmmod paddr_tracer 

从图中可以看出,PTE页表Present位为0,因此可以判断出,系统并未给进程分配任何物理内存

(3)A窗口按下回车,继续执行程序,B窗口再次执行指令

从图中可以看出,只有真正到了I/O操作的时候,系统才会为程序开始分配内存空间

3.写时复制(COW)机制分析与验证

注:本实验是在实验2按需分配模块中paddr_tracer内核模块的基础上进行的,在开始前请确保已完成这一步操作!!

3.1了解COW机制

在操作系统教材中,fork() 被描述为创建进程的操作。如果按照最原始的理解,父进程有 1GB 内存,fork() 后子进程也该有 1GB,系统瞬间应该消耗 2GB

但在实际的 openEuler 系统中,fork() 几乎是瞬间完成的,且不消耗额外内存。这是因为内核使用了 COW 技术:父子进程暂时共享同一物理内存,只有当任意一方修改数据时,内核才会复制那一页。COW技术的流程如下:

fork() 后,父子进程的虚拟地址指向同一个物理页 (PFN 相同),且该页在硬件层面被标记为 Read-Only(即使程序认为是可写的)。当任一方尝试写入时,CPU 触发异常,内核将物理页复制一份,并更新页表。
本实验将通过比对父子进程的 PFN (物理页框号) 来验证这一过程。

3.2.准备COW程序cow_target

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>

int main() {
    // 申请内存并初始化,确保父进程已经有了物理页
    char *p = (char *)malloc(4096);
    memset(p, 'X', 4096);
    
    pid_t pid = fork();

    if (pid == 0) { // 子进程
        printf("【子进程】 PID: %d, 虚拟地址: %p\n", getpid(), p);
        printf("   -> 此时未修改数据,应该共享物理页。\n");
        printf("   -> 请用内核模块分别查看父、子进程的 PFN。\n");
        getchar(); // 暂停1

        p[0] = 'Y'; // 触发写时复制
        printf("【子进程】 数据已修改!\n");
        printf("   -> 此时应拥有独立的物理页。\n");
        printf("   -> 请再次查看 PFN。\n");
        getchar(); // 暂停2
        exit(0);
    } else { // 父进程
        printf("【父进程】 PID: %d, 虚拟地址: %p\n", getpid(), p);
        wait(NULL);
    }
    return 0;
}

(2)创建终端窗口A,运行程序,获得 父PID、子PID和 虚拟地址 。

gcc -o cow_target cow_target.c
./cow_target

(3)创建窗口B,输入指令并观察父、子进程内存分配情况

sudo insmod paddr_tracer.ko target_pid=[父进程] target_vaddr=0x5600...
sudo dmesg | tail
sudo rmmod paddr_tracer
sudo insmod paddr_tracer.ko target_pid=[子进程] target_vaddr=0x5600...
sudo dmesg | tail (记下 PFN_A,注意看 Writeable 状态)
sudo rmmod paddr_tracer

实验现象:你会发现 PFN_A == PFN_B!而且,虽然 VMA 是可写的,但内核日志里显示的 PTE 权限却是 Read-Only (这是 COW 的实现原理)。

(4)窗口A按下回车继续执行程序,在B窗口再次输入相同指令

实验现象:此时子进程的 PFN 变了!且权限变回了 R/W。说明只有子进程发生读写时,系统才会正式分配内存供其使用

4.TLB性能分析和大页表机制

4.1.了解TLB

在现代操作系统中,几乎都使用虚拟内存技术。这意味着程序看到的地址(虚拟地址)和实际存储数据的内存地址(物理地址)是不一样的,因此需要页表机制。但问题在于,页表本身存储于内存(RAM)之中,CPU每次存取数据都需要访问两次内存(一次查表,一次访问物理地址)。如果是多级页表(如 x86-64 的 4 级页表),访问一次数据可能需要访问多次内存(n次查表 + 1 次取数)。这将导致 CPU 性能极其低下。

TLB 应运而生。它是一块极高速的硬件缓存,专门用来存“最近用过的 VPN 到 PFN 的映射关系”。

TLB 的全称是 Translation Lookaside Buffer,中文通常翻译为页表缓存、转址旁路缓存或快表。
它是计算机体系结构和操作系统中非常关键的一个硬件组件,位于 CPU 的 MMU(内存管理单元) 内部。简单来说,TLB 就是一张为了加速虚拟地址到物理地址转换而设计的“小抄”或“高速缓存”。

4.2.TLB的内部结构

TLB 通常由 关联存储器 (Content Addressable Memory, CAM) 实现,这意味着它可以并行地搜索所有条目,而不是像数组那样遍历。

一个典型的 TLB 表项包含以下内容:

字段 描述
Tag (VPN) 虚拟页号,用于匹配搜索。
Physical Page (PFN) 转换后的物理页框号。
Valid Bit 有效位,标识该条目是否有效。
Protection Bits 保护位(读/写/执行权限)。
Dirty Bit 脏位,表示页面是否被修改过。
ASID 地址空间 ID(用于区分不同进程)。

4.3.TLB的工作原理:

4.4.为什么有时需要使用大页?

由于TLB空间资源有限,因此通过使用大页(Huge Pages)缓解 TLB 的压力,提升 TLB 的命中率。Linux 默认页大小为 4KB。对于 1GB 的内存,需要 262,144 个页表项。如果使用 2MB 的大页,1GB 内存仅需 512 个页表项,即:用同样数量的 TLB 条目,覆盖更大的内存范围

4.5.TLB机制分析实验

4.5.1. 编写程序 tlb_cost_analysis.c

#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/time.h>
#include <string.h>

// 内存大小:1GB
#define MEM_SIZE (1024L * 1024L * 1024L)
// 访问次数:1亿次
#define STEPS 100000000L

// 链表节点
struct node {
    struct node *next;
    char padding[64]; // 填充以避免 Cache Line 干扰
};

// 获取高精度时间(微秒)
long long get_time_us() {
    struct timeval tv;
    gettimeofday(&tv, NULL);
    return (long long)tv.tv_sec * 1000000 + tv.tv_usec;
}

int main(int argc, char *argv[]) {
    int use_huge = (argc > 1 && strcmp(argv[1], "huge") == 0);
    
    // 1. 内存分配
    int flags = MAP_PRIVATE | MAP_ANONYMOUS;
    if (use_huge) flags |= MAP_HUGETLB; // 启用大页

    struct node *head = mmap(NULL, MEM_SIZE, PROT_READ | PROT_WRITE, flags, -1, 0);
    if (head == MAP_FAILED) {
        perror("mmap failed");
        return 1;
    }

    printf("=== TLB Cost Analysis (%s) ===\n", use_huge ? "HugePage 2MB" : "Standard 4KB");

    // 2. 构建最坏情况链表 (Worst Case)
    // 步长 = 4096 (x86 页大小)。
    // 这意味着每一次 p = p->next 都在访问一个新的页面。
    // 在 4KB 模式下,这将 100% 击穿 L1 TLB (通常只有 64 个条目)。
    long num_pages = MEM_SIZE / 4096;
    struct node *current = head;
    
    // 简单地线性跨页连接,足以制造 TLB 压力
    for (long i = 1; i < num_pages; i++) {
        struct node *next_node = (struct node *)((char *)head + i * 4096);
        current->next = next_node;
        current = next_node;
    }
    current->next = head; // 闭环

    // 3. 测量核心循环时间
    struct node *p = head;
    long long start = get_time_us();

    // 这里的每一次跳转,CPU 都要做一次虚拟地址转换
    for (long i = 0; i < STEPS; i++) {
        p = p->next; 
    }

    long long end = get_time_us();
    
    // 4. 输出结果
    double total_time_sec = (end - start) / 1000000.0;
    double ns_per_access = (total_time_sec * 1e9) / STEPS;
    
    printf("Total Time: %.4f s\n", total_time_sec);
    printf("Latency per Step: %.2f ns\n", ns_per_access);

    return 0;
}

4.5.2.编译程序,并准备环境

sudo sysctl -w vm.nr_hugepages=600
gcc -O2 tlb_cost_analysis.c -o tlb_exp

这里提前分配内存,是由于大页表中需要连续的内存空间进行存储,因此需要提前进行分配让内存为大页表腾出空间。通过下图可以看到:内存多占用了1.2GB的内存占用空间(openEuler操作系统中大页大小默认为2GB)

4.5.3.分别以大小页表模式运行程序

./tlb_exp standard
./tlb_exp huge

4.5.4.实验结论

为什么 2MB 模式快?
在 2MB 模式下,虽然我们也是每隔 4096 字节跳一次,但 512 次跳转才跨越一个大页。

为什么 4KB 模式慢?
每次跳转都换页,L1 TLB(64条目)瞬间爆满,TLB Miss率几乎高达100%,因此大量开销用在查页表上

在本实验中,我们观测到了大页(HugePages)带来的显著性能提升(TLB Miss 减少,延迟降低)。但在被大页的“极速”折服时,请务必保持清醒的工程思维:操作系统中没有银弹(No Silver Bullet),一切优化本质上都是 Trade-off(权衡)。

为了探究大页的作用,本实验刻意隔离了其他变量,仅探究了大页对 TLB 的加速作用。在实际的生产环境中,盲目开启大页也可能会带来极大的内存碎片,因此需要辩证看待

4. 伙伴系统(Buddy System)动态观测

OpenEuler 的伙伴系统(Buddy System)是其内存管理的核心机制,用于高效分配和回收连续物理内存块,解决内存碎片化问题,适配服务器、嵌入式等多场景内存需求。
算法详细原理参照:https://blog.csdn.net/chongbin007/article/details/122626919 , 这里不详细展开

4.1.了解Buddyinfo

执行以下命令:

cat /proc/buddyinfo

解读:

  • 每一行代表一个内存节点(Node)和管理区(Zone)。
  • 后面的数字列分别代表:1页块(4KB)、2页块(8KB)、4页块(16KB) … 1024页块(4MB) 的剩余数量

4.2.伙伴系统模拟实现

本实验采用 “线性表存储逻辑树” 的方式来模拟伙伴系统。虽然伙伴系统的逻辑结构是二叉树(不断的二分与合并),但在本模拟实验中,我们选择用简单的线性数组来映射内存空间。这种方式利用了完全二叉树可以无损映射到线性数组的特性。还原了伙伴系统算法的真实原理

将下面代码保存为buddy_modular.c:
4.2.1.基础数据结构定义

#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#include <string.h>

// 配置
#define MAX_ORDER 4                 // 最大阶数:4 (即最大块为 2^4 = 16页)
#define TOTAL_PAGES (1 << MAX_ORDER) // 总页数:16
#define PAGE_SIZE 4096              // 假设一页是 4KB 

// 模拟Linux的struct Page
typedef struct {
    int is_free;    // 1: 空闲, 0: 已分配
    int order;      // 当前块的阶数 (仅当该页是块的首地址时有效)
    int id;         // 页索引 
} Page;

// 全局物理内存映射表 
Page mem_map[TOTAL_PAGES];

// === 初始化模块 ===
void init_system() {
    printf("[System] 初始化内存系统,总页数: %d\n", TOTAL_PAGES);
    for (int i = 0; i < TOTAL_PAGES; i++) {
        mem_map[i].is_free = 0; // 先默认不可用,稍后初始化大块
        mem_map[i].order = -1;  // -1 表示它不是块的头部
        mem_map[i].id = i;
    }

    // 初始状态:整个内存是一块巨大的空闲块 (Order = MAX_ORDER)
    mem_map[0].is_free = 1;
    mem_map[0].order = MAX_ORDER;
    
    // 注意:只有块的第一个页记录 Order,其余页的 Order 意义不大
    // 但为了逻辑严谨,我们通常只操作块首
}

4.2.2.辅助计算工具

// 工具 1: 计算需要的阶数 (例如: 申请 3页 -> 需要 4页 -> Order 2)
int get_needed_order(int size) {
    if (size <= 0) return 0;
    int order = 0;
    while ((1 << order) < size) {
        order++;
    }
    return order;
}

// 工具 2: 计算伙伴块的索引
// 原理: 伙伴地址 = 当前地址 XOR 块大小
int get_buddy_index(int idx, int order) {
    return idx ^ (1 << order);
}

// 工具 3: 打印当前内存可视化状态
void print_memory_map() {
    printf("   [Map] ");
    int i = 0;
    while (i < TOTAL_PAGES) {
        if (mem_map[i].is_free) {
            int size = 1 << mem_map[i].order;
            printf("| Free(%d) ", size);
            i += size; // 跳过整个空闲块
        } else if (mem_map[i].order != -1) {
            // 这是一个已分配块的头部
            int size = 1 << mem_map[i].order;
            printf("| Used(%d) ", size);
            i += size;
        } else {
            // 这种情况理论不应在遍历首地址时出现,除非逻辑错误
            printf("| ? ");
            i++;
        }
    }
    printf("|\n");
}

4.2.3.核心逻辑:内存块的“分割”与“分配”

// 核心动作:将 index 处的 order 阶块,分裂成两个 (order-1) 阶块
void split_block(int index, int current_order) {
    int half_size = 1 << (current_order - 1);
    int buddy_index = index + half_size; // 分裂出来的右半部分

    // 更新左半部分 (Index 保持不变,阶数减半)
    mem_map[index].order = current_order - 1;
    
    // 更新右半部分 (成为新的空闲块)
    mem_map[buddy_index].is_free = 1;
    mem_map[buddy_index].order = current_order - 1;

    printf("       -> [分裂] 索引 %d (Order %d) 拆分为 %d 和 %d\n", 
           index, current_order, index, buddy_index);
}

// 接口:内存分配
int buddy_alloc(int size) {
    int target_order = get_needed_order(size);
    printf("\n[Alloc] 请求: %d 页 (Target Order: %d)\n", size, target_order);

    // 1. 最佳适应算法 (Best Fit) 的变种:寻找满足条件的最小块
    int best_idx = -1;
    int min_order = MAX_ORDER + 1;

    for (int i = 0; i < TOTAL_PAGES; ) {
        if (mem_map[i].is_free) {
            int curr_order = mem_map[i].order;
            // 如果块足够大
            if (curr_order >= target_order) {
                // 如果这是目前找到的最小的“足够大”块,记录下来
                if (curr_order < min_order) {
                    min_order = curr_order;
                    best_idx = i;
                }
                // 优化:如果刚好相等,直接停止查找
                if (curr_order == target_order) break;
            }
            // 跳过当前块,继续查找
            i += (1 << curr_order);
        } else {
            // 如果是被占用的,必须跳过已占用的长度
            // 注意:这里需要知道已占用块的大小。
            // 简化处理:由于我们mem_map[i].order记录了分配时的大小
            if (mem_map[i].order != -1) 
                i += (1 << mem_map[i].order);
            else 
                i++; // 异常保护
        }
    }

    // 2. 如果没找到可用块
    if (best_idx == -1) {
        printf("       -> 失败:无可用内存 (OOM)\n");
        return -1;
    }

    // 3. 执行分裂操作 (如果选中的块比目标大)
    while (mem_map[best_idx].order > target_order) {
        split_block(best_idx, mem_map[best_idx].order);
    }

    // 4. 标记为占用
    mem_map[best_idx].is_free = 0;
    printf("       -> 成功分配在索引: %d\n", best_idx);
    print_memory_map();
    return best_idx;
}

4.2.4.核心逻辑:合并与释放

// 核心动作:尝试合并
// 返回 1 表示发生了合并,需要继续检查;返回 0 表示无法合并
int try_merge(int index) {
    int order = mem_map[index].order;
    // 达到最大阶,无法合并
    if (order >= MAX_ORDER) return 0;

    int buddy_idx = get_buddy_index(index, order);
    
    // 边界检查
    if (buddy_idx >= TOTAL_PAGES) return 0;

    // 检查伙伴条件:
    // 1. 伙伴必须是空闲的
    // 2. 伙伴的阶数必须和当前块一致 (避免大块吃小块)
    if (mem_map[buddy_idx].is_free && mem_map[buddy_idx].order == order) {
        printf("       -> [合并] 索引 %d 与 伙伴 %d 合并为 Order %d\n", 
               index, buddy_idx, order + 1);
        
        // 关键:合并后的首地址是两者中较小的那个
        int new_head = (index < buddy_idx) ? index : buddy_idx;
        
        mem_map[new_head].order = order + 1;
        mem_map[new_head].is_free = 1;
        
        // 清除那个被合并掉的块的状态 (可选,为了干净)
        int clean_idx = (index < buddy_idx) ? buddy_idx : index;
        mem_map[clean_idx].order = -1; 
        
        return 1; // 告诉调用者,状态变了,请继续检查上一级
    }
    
    return 0;
}

// 接口:内存释放
void buddy_free(int index) {
    // 异常检查
    if (index < 0 || index >= TOTAL_PAGES || mem_map[index].is_free) {
        printf("\n[Free] 错误:无效的释放请求 (Index %d)\n", index);
        return;
    }

    int size = 1 << mem_map[index].order;
    printf("\n[Free] 释放: 索引 %d (大小 %d页)\n", index, size);

    // 1. 标记为空闲
    mem_map[index].is_free = 1;

    // 2. 循环尝试合并
    // 只要发生了合并,index 就会变更为新的大块首地址,然后继续循环
    int current_idx = index;
    while (try_merge(current_idx)) {
        // 如果合并成功,更新 current_idx 到更低位的地址
        // 因为 try_merge 内部已经更新了 order,我们只需要确保 current_idx 指向合并后的头
        int order = mem_map[current_idx].order; // 现在的order已经是加过1的了
        // 重新计算合并前的对齐。
        // 其实 try_merge 里的逻辑是: buddy_idx = idx ^ (1<<old_order)
        // 合并后的头是 min(idx, buddy_idx)。
        // 简单做法:利用对齐特性,合并后的块首地址 = current_idx & ~(mask)
        // 但我们在 try_merge 里已经算好了 new_head,这里简化逻辑,
        // 我们需要一种方式追踪新的头。
        
        // 更简单的写法是让 try_merge 返回新的头索引,或者我们在循环里自己算
        int old_order = order - 1; 
        int buddy = get_buddy_index(current_idx, old_order); // 这里逻辑有点绕,看下面优化
        if (buddy < current_idx) current_idx = buddy;
        
        // 继续循环
    }
    
    print_memory_map();
}

4.2.5.测试与验证

int main() {
    init_system();
    print_memory_map();

    // === 测试场景 1: 连续分配 ===
    // 初始: [16]
    int p1 = buddy_alloc(3); // 需要4. 切分: 16->8+8 -> 8+4+4. 分配第一个4. 剩余: Used(4), Free(4), Free(8)
    int p2 = buddy_alloc(3); // 需要4. 直接用第二个4.
    int p3 = buddy_alloc(7); // 需要8. 直接用那个8.
    
    // 此时内存应该全满
    int p4 = buddy_alloc(1); // 应该失败

    // === 测试场景 2: 释放与合并 ===
    // 释放 p2 (中间的4),此时无法合并
    buddy_free(p2);
    
    // 释放 p1 (开头的4),应该与 p2 合并成 8
    buddy_free(p1);

    // 释放 p3 (最后的8),应该与前面的 8 合并成 16 (恢复初始状态)
    buddy_free(p3);

    return 0;
}

4.2.6.编译运行

gcc -o buddy_modular buddy_modular.c
./buddy_modular

运行结果:

过程如图所示:

4.2.7.思考:

关于开销: 如果内存碎片化严重(全是 Order 0 的小块),执行一次 alloc(Order 10) 需要循环合并多少次?
对比内核: 在 OpenEuler 真实内核中,struct page 是用双向链表链接在 free_area 数组里的。我们的模拟是用数组遍历。请分析两者的查找效率差异。

4.3.伙伴系统内核实践

模拟实验代码虽然写的很简单,但是在真实的openEuler内核中,成千上万个进程是如何共用这一套机制的?让我们去“案发现场”看看。

4.3.1静态观测

执行:

cat /proc/buddyinfo

运行结果:

从左到右依次是 Order 0 (4KB), Order 1 (8KB) … Order 10 (4MB) 。其中,DMA为内存最开始的一小块(通常是前 16MB),用于兼容非常古老的硬件,因为现代设备很少用这个区域,所以它通常比较空。我们主要观察Normal区,可以发现这里内存碎片化比较明显,各个内存块均有使用,由此可以观察出伙伴系统的工作方式

4.3.2安装内核开发包

sudo dnf install kernel-devel-$(uname -r) kernel-headers-$(uname -r) elfutils-libelf-devel

4.3.3编写内核模块代码

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/gfp.h>
#include <linux/mm.h>
#include <linux/io.h>  /* <--- 新增这一行,解决 virt_to_phys 报错 */

// 定义模块参数:申请的阶数 (默认申请 Order 2 = 4页 = 16KB)
static int order = 2;
module_param(order, int, 0644);
MODULE_PARM_DESC(order, "Order of pages to allocate (0-10)");

// 保存申请到的虚拟地址
static unsigned long vaddr = 0;

static int __init buddy_test_init(void)
{
    printk(KERN_INFO "BuddyTest: Module loaded, trying to alloc order %d pages...\n", order);

    // 核心:直接向伙伴系统申请 2^order 个物理页
    vaddr = __get_free_pages(GFP_KERNEL, order);

    if (!vaddr) {
        printk(KERN_ERR "BuddyTest: Failed to allocate memory!\n");
        return -ENOMEM;
    }

    printk(KERN_INFO "BuddyTest: Alloc success! Vaddr: 0x%lx\n", vaddr);
    
    // 强制转换为 unsigned long long 以匹配 %llx 格式,防止编译警告
    printk(KERN_INFO "BuddyTest: Phys addr: 0x%llx\n", (unsigned long long)virt_to_phys((void *)vaddr));
    
    // 写入数据验证
    memset((void *)vaddr, 'A', PAGE_SIZE * (1 << order));
    printk(KERN_INFO "BuddyTest: Memory written successfully.\n");

    return 0;
}

static void __exit buddy_test_exit(void)
{
    if (vaddr) {
        printk(KERN_INFO "BuddyTest: Freeing order %d pages...\n", order);
        // 核心:释放内存
        free_pages(vaddr, order);
        printk(KERN_INFO "BuddyTest: Module unloaded.\n");
    }
}

module_init(buddy_test_init);
module_exit(buddy_test_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("OpenEuler Student");
MODULE_DESCRIPTION("Buddy System Experiment");

4.3.4配置Makefile

obj-m += buddy_test.o

# 指向当前运行系统的内核源码目录
KDIR := /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)

all:
	$(MAKE) -C $(KDIR) M=$(PWD) modules

clean:
	$(MAKE) -C $(KDIR) M=$(PWD) clean

4.3.5执行操作流程

为了看清现象,我们需要申请较大的内存块(如 Order 8, 9, 10),因为系统中有大量的小块(Order 0-2),申请小块很难观察到 buddyinfo 的变动

编译模块:

make

打开终端,实时监控:

watch -n 1 "cat /proc/buddyinfo"

加载模块并申请内存

sudo insmod buddy_test.ko order=10
sudo dmesg | tail  # 查看内核打印的地址信息

卸载模块,释放内存

sudo rmmod buddy_test
sudo dmesg | tail

通过上述内核实验,我们有效了解到了openEuler中伙伴系统的功能和作用

5.内存瓶颈分析、页面置换机制与系统抖动

在 Linux 的世界观里,“空闲的内存就是浪费”。为了加速文件读写,OpenEuler 会尽可能把未使用的物理内存(RAM)全部拿来做 Page Cache(页缓存)

而内存瓶颈是指系统物理内存(RAM)资源不足,导致进程运行缓慢、响应延迟甚至服务中断的现象。在 OpenEuler 等 Linux 系统中,内存瓶颈的本质是 “内存需求> 物理内存供给”,进而触发 Swap 频繁使用、CPU 等待 I/O 等连锁反应。

以下是出现内存瓶颈的原因分析:

应用层面: 进程内存泄漏(如未释放动态分配的内存)、单个进程占用过高内存(如大文件加载)、多进程并发导致内存总和超限

系统层面: 物理内存配置不足、Swap 空间未配置或过小、内核参数不合理(如内存分配策略、缓存机制)

业务层面: 高并发场景下的请求峰值、批量数据处理(如日志分析、数据导入)、虚拟化环境中内存过度分配

当内存瓶颈出现时,openEuler这种Linux系统内核会启动以下三种内存回收机制:

第一道防线:缓存回收 (Cache Reclaim)
策略:扔掉那些“可有可无”的文件缓存。
代价:极低。下次读取文件时再去磁盘读就是了。
表现:系统略微变慢,但依然流畅。
第二道防线:页面置换 (Swapping)
策略:当缓存都扔光了还是不够,内核只能把不常用的匿名页(Anonymous Pages,如堆、栈数据)暂时写到磁盘上的 Swap 分区。
代价:高昂。内存速度是纳秒级,磁盘是毫秒级,速度相差 10 万倍。
表现:程序切换变卡,硬盘灯狂闪。
第三道防线:OOM Killer (内存刺客)
策略:如果 Swap 也满了,或者系统卡死到无法动弹,内核会启动“刺客机制”,根据算法选择一个占用内存最大且非核心的进程,直接杀掉(SIGKILL)。
代价:惨重。服务中断,数据丢失。
表现:终端突然提示 Killed,程序闪退。

实验步骤:

(1)准备vmstat工具,工具详细指标如下:

指标 全称 含义
swpd Swap Used 已用交换空间
free Free RAM 空闲物理内存
buff/cache Buffer/Cache 缓存占用
si Swap In 磁盘 -> 内存 (单位: KB/s)
so Swap Out 内存 -> 磁盘 (单位: KB/s)
wa IO Wait 等待 I/O 的CPU时间

(2)内存检查:

free -h

可以从结果截图中看到系统的内存分配情况

(3)编写压力测试代码:mem_control.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

// 这是一个“压力测试”工具,用来测试系统的极限
int main(int argc, char *argv[]) {
    if (argc != 3) {
        printf("用法: %s <申请大小MB> <模式>\n", argv[0]);
        printf("模式 0:  (只申请,不读写)\n");
        printf("模式 1: (反复读写,制造抖动)\n");
        return 1;
    }

    long mb = atol(argv[1]);
    int mode = atoi(argv[2]);
    long long bytes = mb * 1024 * 1024;
    
    printf("PID: %d | 准备吞噬 %ld MB 内存...\n", getpid(), mb);

    // Step 1: (虚拟内存)
    char *ptr = (char *)malloc(bytes);
    if (ptr == NULL) {
        perror("内存申请失败,");
        return 1;
    }

    // Step 2: (物理内存)
    // Linux 有"写时复制"机制,只有当你真正去写的时候,内核才会给你分配物理页
    printf("正在初始化 (填充数据,触发缺页中断)...\n");
    memset(ptr, 0xAA, bytes); 
    printf("内存已填满!物理内存已被消耗。\n");

    // Step 3: 根据模式行动
    if (mode == 0) {
        printf(">>> 模式0启动:保持静默。<<<\n");
        printf("我现在是不活跃内存,内核应该会优先把我换出到 Swap。\n");
        while(1) sleep(10);
    } else {
        printf(">>> 模式1启动:疯狂读写 (Thrashing)。<<<\n");
        printf("我不断访问所有页面,强迫内核把 Swap 里的数据再读回来。\n");
        long long count = 0;
        while (1) {
            // 跳跃式读写,破坏局部性原理
            for (long long i = 0; i < bytes; i += 4096) { 
                ptr[i] = (char)(count % 255);
            }
            count++;
            if (count % 50 == 0) printf("已完成 %lld 轮全内存扫描...\n", count);
        }
    }
    return 0;
}

(3)编译代码:

gcc -o mem_control mem_control.c

(4)缓存回收机制观测:

打开一个新的终端窗口A,输入:

vmstat 1

再新开一个终端窗口B,执行代码:

./mem_control 6000 0 #申请6GB内存,此时会启动缓存回收机制,挤压cache

结果如下:

从图中可以看出,当申请内存超出当前空闲内存时,系统将会启动缓存回收机制,释放缓存

(5)页面置换机制观测:

在终端B中执行:

./mem_control 8000 0 #申请8GB内存,此时超出物理内存最大容量,启用页面置换机制

结果如下

从图中可以看出,当申请内存大于系统最大内存8GB时,系统启动页面置换机制,swap_out显著增加

(6)系统抖动观测:

在终端B执行:

./mem_control 7500 1 #申请7.5GB内存,并进行反复读写,强制给系统增加读写压力开销

结果如下:

从图中可以看出,一旦申请内存超过系统内存,且需要反复进行磁盘I/O读写时,此时swap_in和swap_out都显著增加。系统占用增加,开始出现卡顿等不良现象

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。