基于OpenEuler的操作系统内存管理机制深度探究实验
基于OpenEuler 的操作系统内存管理机制深度探究实验
一、 实验目的
- 透视虚拟内存:打破“内存黑盒”认知,通过实战观测进程的虚拟地址空间布局(代码段、数据段、堆、栈),理解虚拟地址与物理地址的隔离机制。
- 掌握内核分配机制:深入 OpenEuler 内核,通过 proc 文件系统实时观测伙伴系统(Buddy System)的内存分配行为,理解外部碎片问题。
- 分析内存瓶颈与 Swap:通过压力测试模拟内存资源枯竭场景,观察页面置换(Swap)现象与系统抖动(Thrashing),掌握性能分析工具的使用。
- 内核态地址转换(高阶):编写 Linux 内核模块(LKM),模拟 MMU 硬件行为,手动遍历多级页表,实现“逻辑地址到物理地址”的转换。
二、 实验环境
- 基础平台:OpenEuler 22.03/24.03 虚拟机。
- 开发工具:VS Code (Remote-SSH 连接), gcc, make。
- 内核环境:需安装 kernel-devel 包(用于编译内核模块)。
- 分析工具: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都显著增加。系统占用增加,开始出现卡顿等不良现象
- 点赞
- 收藏
- 关注作者
评论(0)