在开发工作中,经常要与内存管理系统打交道,有一天突然想出一个问题:VMA会在什么时候创建。
本文档总结了工作中遇到的Linux内核中VMA(Virtual Memory Area)的创建场景,请大家补充遗漏情形。
目录
- [1. mmap() - 最常见的VMA创建方式](#1. mmap() - 最常见的VMA创建方式)
- [2. malloc() - 大内存分配](#2. malloc() - 大内存分配)
- [3. 程序和库加载](#3. 程序和库加载)
- [4. mprotect() - 改变保护属性](#4. mprotect() - 改变保护属性)
- [5. 线程创建](#5. 线程创建)
- [6. GPU/设备内存映射](#6. GPU/设备内存映射)
- [7. 其他操作](#7. 其他操作)
- [8. 实际示例](#8. 实际示例)
1. mmap() - 最常见的VMA创建方式
匿名映射(分配内存)
c
// 创建匿名VMA
void *ptr = mmap(NULL, 4096,
PROT_READ|PROT_WRITE,
MAP_PRIVATE|MAP_ANONYMOUS,
-1, 0);
结果 :创建1个新VMA [ptr, ptr+4096),权限为可读写。
文件映射
c
// 将文件映射到内存
int fd = open("file.dat", O_RDONLY);
void *mapped = mmap(NULL, 8192,
PROT_READ,
MAP_PRIVATE,
fd, 0);
结果:创建1个新VMA,关联到文件,权限为只读。
共享内存
c
// 创建共享内存对象
int shm_fd = shm_open("/myshm", O_CREAT|O_RDWR, 0666);
ftruncate(shm_fd, 1024);
void *shm = mmap(NULL, 1024,
PROT_READ|PROT_WRITE,
MAP_SHARED,
shm_fd, 0);
结果:创建1个新VMA,多进程可共享。
2. malloc() - 大内存分配
glibc malloc行为
c
// 小分配:使用现有堆VMA(不创建新VMA)
void *small = malloc(100); // 从堆VMA分配
// 大分配:调用mmap创建新VMA(通常>128KB)
void *large = malloc(1024 * 1024); // 创建新VMA
内核行为机制
glibc malloc阈值:MMAP_THRESHOLD (默认128KB)
分配大小 <= 128KB:
└─> brk()/sbrk() 扩展堆VMA
└─> 不创建新VMA,只是扩展现有堆
分配大小 > 128KB:
└─> mmap(MAP_PRIVATE|MAP_ANONYMOUS)
└─> 创建独立的匿名VMA
示例对比
c
#include <stdlib.h>
#include <stdio.h>
int main() {
// 小分配:不创建新VMA
for (int i = 0; i < 100; i++) {
malloc(1000); // 扩展堆VMA
}
// 大分配:每次创建新VMA
for (int i = 0; i < 10; i++) {
malloc(1024 * 1024); // 创建10个新VMA
}
getchar(); // 暂停,可查看 /proc/<pid>/maps
return 0;
}
3. 程序和库加载
这个是大家所熟悉的程序启动时,为各个段创建VMA 。
execve() - 加载可执行文件
c
// 执行新程序
execve("./app", argv, envp);
创建的VMA:
1. Text段VMA (r-xp): 可执行代码
2. Rodata段VMA (r--p): 只读数据
3. Data段VMA (rw-p): 已初始化数据
4. BSS段VMA (rw-p): 未初始化数据
5. 堆VMA (rw-p): 动态分配
6. 栈VMA (rw-p): 函数调用栈
7. VDSO VMA (r-xp): 虚拟动态共享对象
8. VVAR VMA (r--p): 内核变量
查看程序VMA布局
bash
# 查看当前进程的内存映射
cat /proc/self/maps
# 示例输出:
00400000-00401000 r-xp 00000000 08:01 123 /bin/ls # Text段
00600000-00601000 r--p 00000000 08:01 123 /bin/ls # Rodata
00601000-00602000 rw-p 00001000 08:01 123 /bin/ls # Data
01234000-01255000 rw-p 00000000 00:00 0 [heap] # 堆
7ffff7a00000-... r-xp 00000000 08:01 456 /lib/x86_64-linux-gnu/libc.so.6
7ffffffde000-... rw-p 00000000 00:00 0 [stack] # 栈
dlopen() - 动态加载库
c
// 加载共享库
void *handle = dlopen("libfoo.so", RTLD_LAZY);
创建的VMA:
- 库的text段VMA(代码)
- 库的data段VMA(数据)
- 库的bss段VMA(未初始化数据)
4. mprotect() - 改变保护属性
VMA分割场景
c
// 创建一个8KB的VMA
void *mem = mmap(NULL, 8192,
PROT_READ|PROT_WRITE,
MAP_PRIVATE|MAP_ANONYMOUS,
-1, 0);
// 此时:1个VMA [mem, mem+8192), RW权限
// 可通过 /proc/<pid>/maps 查看:
// 7f1234567000-7f1234569000 rw-p ...
// 改变后半部分的保护属性
mprotect(mem + 4096, 4096, PROT_READ);
// 结果:分割成2个VMA
// VMA1: [mem, mem+4096) RW
// VMA2: [mem+4096, mem+8192) R-
/proc/maps中的变化
bash
# 执行mprotect前:
7f1234567000-7f1234569000 rw-p 00000000 00:00 0
# 执行mprotect后:
7f1234567000-7f1234568000 rw-p 00000000 00:00 0 # VMA1
7f1234568000-7f1234569000 r--p 00000000 00:00 0 # VMA2(注意权限变化)
常见分割场景
| 操作 | 分割原因 | 结果 |
|---|---|---|
mprotect(中间部分) |
权限不同 | 3个VMA |
madvise(MADV_DONTNEED) |
建议不同 | 可能分割 |
mbind() |
NUMA策略不同 | 可能分割 |
5. 线程创建
pthread_create() 创建栈VMA
c
#include <pthread.h>
void *thread_func(void *arg) {
// 线程代码
return NULL;
}
int main() {
pthread_t thread;
// 创建线程,默认栈大小8MB
pthread_create(&thread, NULL, thread_func, NULL);
// 结果:为新线程创建栈VMA
// 7f1230000000-7f1230800000 rw-p ... [stack:12345]
pthread_join(thread, NULL);
return 0;
}
自定义栈大小
c
pthread_t thread;
pthread_attr_t attr;
// 初始化线程属性
pthread_attr_init(&attr);
// 设置栈大小为2MB
pthread_attr_setstacksize(&attr, 2 * 1024 * 1024);
// 创建线程(创建2MB的栈VMA)
pthread_create(&thread, &attr, thread_func, NULL);
pthread_attr_destroy(&attr);
6. 设备内存映射
设备文件mmap
c
// 打开GPU设备
int fd = open("/dev/kfd", O_RDWR);
// 映射设备内存到用户空间
void *gpu_mem = mmap(NULL, 4096,
PROT_READ|PROT_WRITE,
MAP_SHARED,
fd, gpu_offset);
// 创建设备映射VMA,标志包括:
// - VM_IO: I/O映射
// - VM_PFNMAP: 物理帧号映射
7. 其他操作
操作对比表
| 操作 | 创建VMA | 说明 | 示例 |
|---|---|---|---|
brk()/sbrk() |
扩展现有或新建 | 扩展堆VMA,堆不存在时创建 | brk(current + 4096) |
mremap() |
可能 | 调整VMA大小,可能移动并创建新VMA | mremap(addr, old_size, new_size, MREMAP_MAYMOVE) |
fork() |
复制所有VMA | 子进程获得父进程VMA的副本(写时复制) | fork() |
madvise() |
可能分割 | 某些建议会导致VMA分割 | madvise(addr, len, MADV_DONTNEED) |
munmap() |
删除或分割 | 取消映射,可能分割VMA | munmap(addr + 4096, 4096) |
remap_file_pages() |
否 | 已弃用,修改现有VMA | N/A |
brk()示例
c
#include <unistd.h>
void *old_brk = sbrk(0); // 获取当前堆顶
// 扩展堆(扩展现有堆VMA)
brk(old_brk + 4096);
// 如果堆VMA不存在,则创建新的堆VMA
mremap()示例
c
// 创建初始VMA
void *addr = mmap(NULL, 4096, PROT_READ|PROT_WRITE,
MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
// 扩展VMA到8KB
void *new_addr = mremap(addr, 4096, 8192,
MREMAP_MAYMOVE);
// 如果原地扩展失败,创建新VMA并删除旧VMA
8. 实际示例
c
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <unistd.h>
void print_maps(const char *label) {
char cmd[512];
printf("\n=== %s ===\n", label);
// 匿名映射特征:设备号00:00,inode为0,无文件路径
sprintf(cmd, "cat /proc/%d/maps | awk '/heap|stack|00:00 0[[:space:]]*$/' | tail -5",
getpid());
system(cmd);
}
void show_address_vmas(unsigned long start_addr, unsigned long size) {
char cmd[512];
unsigned long end_addr = start_addr + size;
// 显示包含该地址范围的所有VMA(通过十六进制匹配)
sprintf(cmd, "cat /proc/%d/maps | awk 'BEGIN {s=strtonum(\"0x%lx\"); e=strtonum(\"0x%lx\")} "
"{split($1,a,\"-\"); vs=strtonum(\"0x\"a[1]); ve=strtonum(\"0x\"a[2]); "
"if((vs>=s && vs<e) || (ve>s && ve<=e) || (vs<=s && ve>=e)) print}'",
getpid(), start_addr, end_addr);
system(cmd);
}
int main() {
char cmd[512];
printf("进程ID: %d\n", getpid());
// 1. 初始状态
print_maps("1. 初始状态");
// 2. 小内存分配(使用现有堆VMA)
printf("\n执行: malloc(100)");
void *small = malloc(100);
print_maps("2. malloc(100) 后");
// 3. 大内存分配(创建新VMA)
printf("\n执行: malloc(1MB)");
void *large = malloc(1024 * 1024);
print_maps("3. malloc(1MB) 后 - 注意新增VMA");
// 4. mmap匿名映射(创建新VMA)
printf("\n执行: mmap(4KB)");
void *mapped = mmap(NULL, 4096,
PROT_READ|PROT_WRITE,
MAP_PRIVATE|MAP_ANONYMOUS,
-1, 0);
print_maps("4. mmap(4KB) 后 - 注意新增VMA");
// 5. mprotect分割VMA
printf("\n执行: mmap(8KB) 然后 mprotect(后半部分)");
void *mem = mmap(NULL, 8192,
PROT_READ|PROT_WRITE,
MAP_PRIVATE|MAP_ANONYMOUS,
-1, 0);
printf("\nmmap地址: %p\n", mem);
printf("\nmmap后(1个VMA):\n");
show_address_vmas((unsigned long)mem, 8192);
mprotect(mem + 4096, 4096, PROT_READ);
printf("\nmprotect后(应该分割成2个VMA,注意权限变化):\n");
show_address_vmas((unsigned long)mem, 8192);
// 暂停,允许手动检查
printf("\n按Enter继续...");
getchar();
// 清理
free(small);
free(large);
munmap(mapped, 4096);
munmap(mem, 8192);
return 0;
}
编译和运行
bash
# 编译
gcc -o vma_demo vma_demo.c
# 运行
./vma_demo
# 在另一个终端实时查看
watch -n 1 "cat /proc/\$(pgrep vma_demo)/maps"
9. 核心总结
产生新VMA的根本原因
1. 显式请求映射
└─> mmap(), shm_open(), shmget()+shmat()
2. 大内存分配
└─> malloc/new 超过阈值(通常128KB)
└─> 触发内部mmap调用
3. 加载代码/数据
└─> execve(): 加载程序
└─> dlopen(): 加载共享库
4. 属性不兼容
└─> mprotect(): 权限变化导致分割
└─> madvise(): 某些建议导致分割
5. 线程栈
└─> pthread_create(): 为每个线程创建栈VMA
6. 设备映射
└─> 设备文件mmap(): GPU、DMA等
VMA创建频率排序
最频繁:
1. malloc大内存 (应用程序频繁分配)
2. mmap (文件I/O、匿名内存)
3. mprotect (权限变化、JIT代码)
中等频率:
4. dlopen (动态库加载)
5. pthread_create (多线程程序)
较少频率:
6. execve (程序启动时一次)
7. GPU设备映射 (GPU应用)
判断是否创建新VMA的方法
bash
# 方法1: 比较/proc/maps
cat /proc/<pid>/maps > before.txt
# ... 执行操作 ...
cat /proc/<pid>/maps > after.txt
diff before.txt after.txt # 新增行 = 新VMA
# 方法2: 使用strace追踪系统调用
strace -e mmap,mprotect,brk ./your_program
# 方法3: 使用bpftrace实时监控
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_mmap {
printf("mmap by PID %d\n", pid);
}'
VMA属性标志
c
// /proc/maps中的权限标志
r : PROT_READ - 可读
w : PROT_WRITE - 可写
x : PROT_EXEC - 可执行
s : MAP_SHARED - 共享映射
p : MAP_PRIVATE - 私有映射(写时复制)
// 内核VMA标志(vm_flags)
VM_READ - 可读
VM_WRITE - 可写
VM_EXEC - 可执行
VM_SHARED - 共享
VM_MAYSHARE - 可能共享
VM_GROWSDOWN - 向下增长(栈)
VM_PFNMAP - 物理帧号映射(设备)
VM_IO - I/O映射
VM_DONTEXPAND - 不能扩展
VM_DONTCOPY - fork时不复制
调试技巧
bash
# 1. 查看进程VMA数量
cat /proc/<pid>/maps | wc -l
# 2. 按类型统计VMA
cat /proc/<pid>/maps | awk '{print $6}' | sort | uniq -c
# 3. 查看最大的VMA
cat /proc/<pid>/maps | awk '{
start=strtonum("0x"$1);
split($1,a,"-");
end=strtonum("0x"a[2]);
size=end-start;
print size/1024/1024 " MB:", $0
}' | sort -rn | head
# 4. 实时监控VMA变化
watch -d -n 0.5 'cat /proc/<pid>/maps | wc -l'
# 5. 使用pmap查看内存映射摘要
pmap -x <pid>
性能考虑
过多VMA的影响:
1. 内核维护成本
└─> 每个VMA: ~200字节内核内存
2. 查找性能下降
└─> VMA存储在红黑树中
└─> 查找时间: O(log n)
3. fork()开销增加
└─> 需要复制所有VMA结构
4. /proc/maps读取变慢
└─> 需要遍历所有VMA
最佳实践:
1. 合并相邻VMA
└─> 使用相同权限的连续mmap
2. 避免过度mprotect
└─> 减少不必要的VMA分割
3. 使用大页
└─> 减少页表和TLB压力
4. 及时munmap
└─> 释放不需要的VMA
附录:相关系统调用
VMA相关系统调用列表
| 系统调用 | 功能 | 是否创建VMA |
|---|---|---|
mmap() |
创建内存映射 | ✅ 是 |
munmap() |
删除内存映射 | ❌ 否(删除) |
mremap() |
调整映射大小 | 🔄 可能 |
mprotect() |
修改权限 | 🔄 可能(分割) |
madvise() |
内存使用建议 | 🔄 可能(分割) |
mlock()/mlockall() |
锁定内存 | ❌ 否 |
brk()/sbrk() |
调整堆 | 🔄 扩展现有 |
shmat() |
附加共享内存 | ✅ 是 |
shmdt() |
分离共享内存 | ❌ 否(删除) |