mmap 的底层真相,以及射击游戏里的每一次代价
第 0 章:先建立直觉
0.1 一个比喻:同一本书,两个门牌号
图书馆有一本书,编号 物理书架 P7。
读者 A 的借书单上写着:"第 3 号阅览桌"
读者 B 的借书单上写着:"第 9 号阅览桌"
图书馆管理系统里记着: "物理书架 P7"
三个不同的"地址",指向同一本书。
这就是虚拟内存:每个进程有自己的"地址簿"(页表),不同的虚拟地址可以映射到同一个物理页。
read() 是"抄一份给你",mmap() 是"在你的地址簿上加一条,指向原书"。
0.2 一句话概括本文要讲的东西
mmap 不搬数据,它只是往你的页表里写了一行,
让某个虚拟地址的翻译结果,落在 page cache 已经持有的那个物理页帧上。
听起来简单,但这句话里每个词都有坑。下面逐层拆开。
第 1 章:三个必须先搞清的概念
1.1 进程看到的地址全是假的
byte* p = ...; // 假设值是 0x7f8a3c000000
Debug.Log((ulong)p); // 这个数字在物理内存里根本不存在
CPU 每次访问内存,都要先让 MMU(内存管理单元) 把虚拟地址翻译成物理地址。翻译规则存在页表里。
arm64 四级页表(4KB 页):
虚拟地址 48 位:
┌────────┬────────┬────────┬────────┬────────────┐
│ 9 bits │ 9 bits │ 9 bits │ 9 bits │ 12 bits │
│ PGD │ PUD │ PMD │ PTE │ 页内偏移 │
└────────┴────────┴────────┴────────┴────────────┘
│
└─▶ PTE 里存的是【物理页帧号 PFN】+ 权限位
物理地址 = (PFN << 12) | 页内偏移
关键:PTE(页表项)里存的是物理页帧号。谁能改 PTE,谁就能决定"这个虚拟地址指向哪块物理内存"。
内核就是靠改 PTE 实现 mmap 的。
1.2 Page Cache:文件在内存里的那份副本
你读一个文件,数据不会每次都从磁盘来。内核把读过的页缓存起来:
内核里的结构:
struct file → struct inode → struct address_space
│
└─ i_pages(XArray)
索引 = 文件内页号 (pgoff)
值 = struct page*
pgoff 0 → page(PFN=0x12345)
pgoff 1 → page(PFN=0x12346)
pgoff 2 → (不在,需要读盘)
pgoff 3 → page(PFN=0x1AF20)
Page Cache 的三个性质:
| 全局共享 | 一个文件的 page cache 只有一份,所有进程共用 |
| 按页管理 | 单位是 4KB(Android 15+ arm64 可能是 16KB) |
| 可回收 | 内存紧张时,干净页直接丢,脏页先写回再丢 |
1.3 为什么 read() 必须拷贝
char buf[4096];
read(fd, buf, 4096);
buf 是你的用户态栈/堆内存,它对应的物理页和 page cache 的物理页是两块不同的物理内存。
物理内存
┌──────────────┐
│ PFN=0x12345 │ ← page cache 的页(内核持有)
└──────────────┘
│ memcpy_to_user() ★ 一次真实的 CPU 拷贝
▼
┌──────────────┐
│ PFN=0x9ABCD │ ← 你的 buf 对应的页
└──────────────┘
内核不能把 buf 的 PTE 直接改成指向 page cache,因为:
- buf 是你先分配的,它已经有自己的物理页
- 你可能随意改 buf,改了不该影响文件内容
- buf 可能不是页对齐的(char buf[100])
所以 read() 必须拷贝。而 mmap 从一开始就不给你分配物理页,等你访问时才决定指向哪。
第 2 章:mmap 到底做了什么(几乎什么都没做)
2.1 mmap 返回时,页表是空的
void* p = mmap(NULL, 8*1024*1024, PROT_READ, MAP_SHARED, fd, 0);
// 映射 8MB。此时:
// ✅ 已建立一个 vm_area_struct(VMA)
// ✅ 已划出一段虚拟地址范围
// ❌ 页表里 0 条有效 PTE
// ❌ 磁盘 IO:0 次
// ❌ 物理内存分配:0 字节
VMA 是什么:内核里描述"这段虚拟地址是干什么用的"的元数据。
struct vm_area_struct {
unsigned long vm_start, vm_end; // 虚拟地址范围
struct file *vm_file; // ★ 关联哪个文件
unsigned long vm_pgoff; // ★ 从文件第几页开始
unsigned long vm_flags; // VM_SHARED / VM_READ / VM_WRITE
const struct vm_operations_struct *vm_ops; // ★ 缺页时调谁
};
mmap 的全部工作 = 造一个 VMA,插进红黑树,返回地址。
耗时约 1~10 微秒,和文件大小无关。映射 8MB 和映射 8GB 一样快。
2.2 第一次访问:缺页异常的完整流程
char c = ((char*)p)[0]; // 就这一行,触发下面全部流程
① CPU 发出虚拟地址 → MMU 查页表
PTE 无效(present 位为 0)
↓
② CPU 抛出同步异常(arm64: Data Abort)
切到内核态,进入 do_page_fault()
↓
③ 在当前进程的 VMA 红黑树里找:"这个地址属于哪个 VMA?"
找不到 → SIGSEGV(段错误)
找到了 → 检查权限(写只读区 → SIGSEGV)
↓
④ handle_mm_fault() → 判断 VMA 类型
vm_file != NULL → 文件映射,调 vma->vm_ops->fault
对普通文件就是 filemap_fault()
↓
⑤ filemap_fault():算出文件内页号
pgoff = vm_pgoff + (addr – vm_start) / PAGE_SIZE
在 address_space 的 XArray 里查 pgoff:
├─ 命中 → ★ Minor Fault,跳到 ⑦
└─ 未命中 → ★ Major Fault,走 ⑥
↓
⑥ 【Major Fault】
a. 分配一个物理页
b. 挂进 page cache(XArray[pgoff] = page)
c. 提交读 IO,把这一页填上
d. ★ 当前线程睡眠等 IO 完成(这就是卡顿来源)
↓
⑦ 建立 PTE
pfn = page_to_pfn(page); // ★ page cache 那一页的物理页帧号
set_pte_at(mm, addr, ptep, mk_pte(page, prot));
page->_mapcount++; // 记录"多了一个进程映射我"
↓
⑧ 从异常返回,CPU 重新执行那条 load 指令
这次 MMU 翻译成功,读到数据
2.3 关键的一句:PTE 指向的就是 page cache 的页
第 ⑦ 步是本文标题的答案:
pfn = page_to_pfn(page);
page 是从 page cache 的 XArray 里拿出来的 struct page*。它的物理页帧号被直接写进了你的 PTE。
从这一刻起:
你的虚拟地址 p+0
│ MMU 翻译
▼
物理页 PFN=0x12345
▲
│ page cache 的 XArray[0] 也指向它
内核 address_space
→ 同一块物理内存,两个"名字"
→ 你读它,就是读 page cache
→ 你写它(MAP_SHARED),就是改 page cache
2.4 一个重要优化:Fault-Around
如果每访问 4KB 就异常一次,8MB 文件要 2048 次异常,太慢。
内核做了 fault-around:一次 minor fault,顺便把周围已在 page cache 的页一起建 PTE。
// mm/memory.c
static unsigned long fault_around_bytes __read_mostly = 65536; // 16 页
do_fault_around():
以出错地址为中心,向前后扩展到 64KB 边界
对每一页:如果在 page cache 里 → 一起建 PTE
效果:顺序访问一个已缓存的文件,缺页次数降到 1/16。
这解释了一个现象:预热过的 bundle,加载时几乎看不到 fault;没预热的,fault 数量惊人。因为 fault-around 只映射已在缓存的页,缓存里没有的话它帮不上忙。
第 3 章:共享 vs 私有,以及写入的代价
3.1 MAP_SHARED:真正的共享
mmap(..., PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
进程 A 的 PTE ──┐
├──▶ 物理页 PFN=0x12345 ◀── page cache
进程 B 的 PTE ──┘
写入 → 直接改这块物理内存
→ PTE 的 dirty 位置 1
→ 内核标记 page 为 PageDirty
→ writeback 线程稍后写回文件
三个推论(都很重要):
3.2 MAP_PRIVATE:写时复制的陷阱
mmap(..., PROT_READ|PROT_WRITE, MAP_PRIVATE, fd, 0);
【读阶段】和 SHARED 一样,PTE 指向 page cache,但标记为只读
【第一次写】
① 写只读 PTE → 触发写保护缺页
② do_wp_page():分配一个新的匿名页
③ 把 page cache 那页的内容 memcpy 过去 ★ 一次真实拷贝
④ PTE 改指向新页,标记可写
⑤ 从此这一页和文件、和 page cache 完全脱钩
射击游戏里的真实事故:
// 某项目用 MAP_PRIVATE 映射了 200MB 的地图数据
// 然后在加载时做了"原地修正坐标"
for (int i = 0; i < vertexCount; i++)
vertices[i].y += terrainOffset; // ❌ 每写一页触发 COW
// 结果:
// 映射时 PSS 增量 ≈ 0
// 遍历完成后 PSS 增量 = 200 MB(全部 COW 成匿名页)
// 而且匿名页不能被直接回收,只能 swap(Android 上没 swap → 直接 OOM)
| 读 | 指向 page cache | 指向 page cache |
| 写 | 改 page cache | COW 出匿名页 |
| 内存归类 | file-backed,可直接回收 | anon,只能 swap |
| 会写回文件 | ✅ | ❌ |
| 适用 | 读写共享数据、日志、录像 | 只读为主的资源 |
📌 规则:只读资源用 MAP_PRIVATE 且绝不写入;要写就用 MAP_SHARED。 混着来必然出事。
第 4 章:内存压力下会发生什么(最容易被忽略)
4.1 mmap 的页也会被回收
很多人以为 mmap 之后页就"钉住"了。不是的。
内核 LRU 链表:
├─ active_file ← 最近访问过的文件页
├─ inactive_file ← 冷的文件页
├─ active_anon
└─ inactive_anon
内存不足时,kswapd / direct reclaim 从 inactive 尾部开刀:
干净的文件页(clean file page)
→ ★ 直接 free,零成本(内容和磁盘一致)
→ 清掉所有指向它的 PTE
脏的文件页(dirty)
→ 先写回文件,再 free
匿名页(anon)
→ 需要 swap;Android 通常无 swap 分区
→ 只能靠 zram 压缩,或者……杀进程
推论 1(好消息):mmap 的只读资源是系统眼里最便宜的内存。
推论 2(坏消息):被回收的页再访问,又是一次 major fault。
4.2 这就是"内存压力下的抖动"
射击游戏在 4GB 内存机器上,用户后台还开着微信:
T0:加载完地图,8MB bundle 的页全在 page cache
T1:玩家切后台看了眼微信
T2:微信要内存 → kswapd 回收干净文件页
→ 你的 bundle 页被回收(因为它们最"便宜")
T3:玩家切回来 → 重新访问模型数据
→ ★ major fault 风暴,卡 200ms
这就是"切后台再回来会卡"的底层原因之一。
4.3 mlock:钉住页的代价
mlock(addr, len); // 强制这段页常驻,不许回收
能解决抖动,但:
① RLIMIT_MEMLOCK 限制(Android 上通常很小,需要特权)
② 被 mlock 的页对 LMK 来说是"不可回收内存"
→ 你的 oom_score 变高
→ ★ 系统不回收你的页,改为直接杀你的进程
③ 相当于把"局部卡顿"换成"整体闪退"
更实用的替代方案:
// 温和的建议:告诉内核"这段我很快要用,尽量留着"
madvise(addr, len, MADV_WILLNEED);
// 告诉内核"这段我随机访问,别做顺序预读"
madvise(addr, len, MADV_RANDOM);
// 告诉内核"这段我不需要了,随便回收"(关卡切换时用)
madvise(addr, len, MADV_DONTNEED); // ⚠️ MAP_PRIVATE 下会丢弃 COW 的修改!
4.4 PSS 是怎么算的
Android 的 dumpsys meminfo 显示的 PSS(Proportional Set Size):
一个物理页被 N 个进程映射 → 每个进程算 1/N 页
例:libunity.so 的 .text 段被 3 个进程映射
→ 每个进程的 PSS 只计入 1/3
mmap 一个只有自己在用的 bundle
→ mapcount = 1 → PSS 全额计入
★ 但只有【实际 fault 进来的页】才计入
映射了 8MB 但只访问了 1MB → PSS 只增 1MB
这就是 LoadFromFile 比 LoadFromMemory 内存低 10 倍的真正原因:不是"更省",而是很多页从来没被访问过,所以从来没被 fault 进来。
第 5 章:Android 的特殊情况
5.1 APK 内 mmap 与页对齐
Unity 在 Android 上可以直接 mmap APK 里的 bundle:
var ab = AssetBundle.LoadFromFile(
Path.Combine(Application.streamingAssetsPath, "aa/Android/base.bundle"));
能成立的两个硬条件:
① 该 ZIP 条目必须是 STORED(不压缩)
→ 只有原样存放,文件内偏移才对应连续的字节流
→ Deflate 压缩的条目无法 mmap,必须全量解压
② mmap 的 offset 必须页对齐
→ mmap(fd, offset) 要求 offset % PAGE_SIZE == 0
→ 所以 zipalign 存在的意义就是让条目起始位置对齐
# 验证
adb shell pm path com.xxx.game
adb pull /data/app/.../base.apk
unzip -v base.apk | grep -E "bundle|\\.so"
8452096 Stored 8452096 0% assets/aa/Android/base.bundle ← ✅ 可 mmap
24336384 Stored 24336384 0% lib/arm64-v8a/libil2cpp.so ← ✅ 可 mmap
4231680 Defl:N 1892304 55% assets/config/weapons.json ← ❌ 需解压
Android 15 起 arm64 支持 16KB 页,对齐要求变成 16KB:
android {
// 保证 bundle 不被压缩
aaptOptions { noCompress '.bundle', '.unity3d', '.resS', '.resource' }
// 保证 so 16KB 对齐(NDK r27+ / AGP 8.5+)
packagingOptions { jniLibs { useLegacyPackaging false } }
}
5.2 外部存储的 FUSE 惩罚
/data/data/包名/files/x.bundle
→ ext4/f2fs,mmap 的 major fault 走标准块设备路径
→ 一次 fault ≈ 100 μs
/storage/emulated/0/Android/data/包名/files/x.bundle
→ ★ FUSE 虚拟文件系统,转发到 /data/media/0
→ major fault 需要 upcall 到用户态 daemon 再回来
→ 一次 fault ≈ 200~500 μs
结论:mmap 密集访问的文件必须放内部存储。 外部存储只放低频大文件。
5.3 匿名共享内存:ashmem / memfd
跨进程共享用不了普通文件,用匿名共享内存:
#include <android/sharedmem.h>
// 主进程
int fd = ASharedMemory_create("replay_ring", 8*1024*1024);
void* base = mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
// 通过 Binder 把 fd 传给录制进程
// 录制进程收到 fd 后
void* base2 = mmap(NULL, size, PROT_READ, MAP_SHARED, fd, 0);
// ★ base 和 base2 是不同的虚拟地址,指向同一批物理页
注意:这类内存不 backed by 文件,是匿名页。所以:
- 不会写回磁盘
- 内存压力下只能 swap/zram,不能直接丢
- 但可以 ASharedMemory_setProt 收紧权限,或用 MADV_FREE/purgeable 让内核可回收
第 6 章:射击游戏案例
案例 1:用 smaps 看清 LoadFromFile 的真相
做法:加载一个 80MB 的地图 bundle,用 /proc/self/smaps 观察。
PID=$(adb shell pidof com.xxx.game | tr -d '\\r')
adb shell run-as com.xxx.game "grep -A 12 'base.bundle' /proc/$PID/smaps"
LoadFromFile 之后立刻采样:
7b2c000000-7b30f00000 r–p 00000000 fd:03 1234567 …/base.bundle
Size: 80128 kB ← ★ 虚拟地址范围 80MB
Rss: 1436 kB ← ★ 实际驻留只有 1.4MB!
Pss: 1436 kB
Shared_Clean: 0 kB
Private_Clean: 1436 kB ← 干净的私有文件页
Private_Dirty: 0 kB
Swap: 0 kB
Size 80MB,Rss 只有 1.4MB。 这 1.4MB 是 Unity 读的元数据(AssetBundle 头、对象索引表)。
实例化了地图 Prefab 之后再看:
Size: 80128 kB
Rss: 47280 kB ← ★ 涨到 46MB(用到的部分被 fault 进来了)
Private_Clean: 47280 kB
对比 LoadFromMemory:
# 没有 base.bundle 的 VMA(因为不是映射,是拷进堆里)
# 但看 Native Heap
adb shell "dumpsys meminfo $PID | grep 'Native Heap'"
Native Heap 492580 492100 ← 涨了 80MB(bundle 副本)
# 加上 File.ReadAllBytes 那 80MB 在托管堆(LOH)
结论一目了然:
| 虚拟地址占用 | 80 MB | 0(不映射) |
| 实际物理内存 | 1.4 MB → 46 MB(按需) | 160 MB(80×2) |
| 内存归类 | file-backed clean,可回收 | anon + LOH,不可回收 |
| LMK 眼里 | 便宜 | 昂贵 |
案例 2:Major Fault 风暴——最难查的战斗卡顿
现象
某射击游戏改用 LoadFromFile 后,启动快了、内存低了,但战斗中偶发 40~90ms 卡顿。Unity Profiler 显示卡在一个普通的 Instantiate 上,看不出原因。
为什么 Profiler 看不见
major fault 发生时:
内核在【你的线程上下文】里睡眠等 IO
→ 从 Profiler 角度,就是"这个函数执行了很久"
→ 但函数里没有任何耗时代码
→ CPU 采样器(simpleperf)也看不到,因为线程在睡觉不在跑
用 /proc 抓现场
#!/bin/bash
PID=$(adb shell pidof com.xxx.game | tr -d '\\r')
prev_maj=0; prev_min=0
echo "sec,minflt_delta,majflt_delta"
for i in $(seq 1 120); do
S=$(adb shell cat /proc/$PID/stat | tr -d '\\r')
MIN=$(echo $S | awk '{print $10}')
MAJ=$(echo $S | awk '{print $12}')
echo "$i,$((MIN–prev_min)),$((MAJ–prev_maj))"
prev_min=$MIN; prev_maj=$MAJ
sleep 1
done
输出:
sec minflt_delta majflt_delta 现场
5 412 3 待机
30 1820 18 跳伞
78 6240 2104 ★ 敌人进视野,加载皮肤+武器
79 3180 1876 ★ 继续
80 890 142
2104 次 major fault × ~150μs ≈ 315ms 的磁盘等待,分散在几帧里 → 就是那个 90ms 卡顿。
精确定位:mincore
想知道"具体哪些页不在内存":
// 查询一段映射有哪些页已驻留
int check_residency(void* addr, size_t len, int* resident, int* total)
{
size_t pages = (len + 4095) / 4096;
unsigned char* vec = malloc(pages);
if (mincore(addr, len, vec) != 0) { free(vec); return –1; }
int r = 0;
for (size_t i = 0; i < pages; i++)
if (vec[i] & 1) r++; // bit0 = 已在内存
*resident = r; *total = pages;
free(vec);
return 0;
}
// 加载前后打点
Debug.Log($"[MMAP] {bundleName}: {resident}/{total} pages resident " +
$"({resident * 100 / total}%)");
// [MMAP] skin_yuki.bundle: 12/512 pages resident (2%) ← 几乎全要 fault
修复:分级预热
核心思路:把不可预测的 fault 成本,挪到玩家能接受的时刻。
// Plugins/Android/src/prefetch.c
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
// 温和方式:让内核后台预读,不阻塞
int warm_hint(const char* path, off_t off, size_t len)
{
int fd = open(path, O_RDONLY);
if (fd < 0) return –1;
posix_fadvise(fd, off, len, POSIX_FADV_WILLNEED); // 内核异步预读
close(fd);
return 0;
}
// 强力方式:映射后主动 touch,保证一定进 page cache
int warm_force(const char* path, off_t off, size_t len)
{
int fd = open(path, O_RDONLY);
if (fd < 0) return –1;
off_t aligned = off & ~(off_t)(sysconf(_SC_PAGESIZE) – 1);
size_t delta = off – aligned;
void* p = mmap(NULL, len + delta, PROT_READ, MAP_PRIVATE, fd, aligned);
if (p == MAP_FAILED) { close(fd); return –2; }
madvise(p, len + delta, MADV_WILLNEED);
// ★ 每页读一字节,强制 fault 进来
volatile unsigned char sink = 0;
size_t ps = sysconf(_SC_PAGESIZE);
for (size_t i = 0; i < len + delta; i += ps)
sink ^= ((volatile unsigned char*)p)[i];
munmap(p, len + delta); // ★ 解除映射,但 page cache 里的页留着!
close(fd);
return 0;
}
关键点:munmap 之后 page cache 里的页不会消失。 我们只是借这个映射去"敲醒"那些页。
public class BundleWarmup
{
[DllImport("prefetch")] static extern int warm_hint(string p, long off, ulong len);
[DllImport("prefetch")] static extern int warm_force(string p, long off, ulong len);
// ★ 开局前,在后台线程预热本局必需资源
public async Task WarmupForMatch(MatchInfo m)
{
// 分级:核心资源强力预热,次要资源只给提示
var critical = ResolveCritical(m); // 本局地图、10 人皮肤、常用武器
var optional = ResolveOptional(m); // 载具、稀有武器
await Task.Run(() => {
foreach (var b in critical) {
var e = packIndex[b];
warm_force(packPath, (long)e.offset, e.length);
}
foreach (var b in optional) {
var e = packIndex[b];
warm_hint(packPath, (long)e.offset, e.length); // 不阻塞
}
});
// 预热完成后再 LoadFromFile —— 此时几乎全是 minor fault
foreach (var b in critical)
loaded[b] = AssetBundle.LoadFromFile(packPath, 0, packIndex[b].offset);
}
}
效果
| 加载界面时长 | 1.2 s | 3.6 s |
| 战斗中 majflt 峰值 | 2104 /s | < 60 /s |
| 帧时间 P99 | 47 ms | 11 ms |
| 峰值 PSS | 720 MB | 780 MB |
代价是加载慢了 2.4 秒、内存多 60MB,换来战斗全程稳定。 对射击游戏这笔交易非常划算。
📌 本案例的核心教训:
mmap 没有消除成本,它把"一次集中的大拷贝"换成了"分散的、不可预测的 page fault"。
优化的本质是控制成本发生的时机,不是消除成本。
案例 3:mmap 环形缓冲——为什么 SIGKILL 也不丢数据
需求
Android LMK 杀进程用 SIGKILL,没有任何回调——finally、OnApplicationQuit、onDestroy 全部不执行。但我们需要崩溃前的最后几千条事件来定位问题。
为什么普通写入会丢
你的 byte[]
↓ FileStream.Write
FileStream 内部缓冲(用户态!) ← ★ SIGKILL 时这里的数据直接蒸发
↓ write() 系统调用
内核 page cache
↓ writeback
磁盘
为什么 mmap 不会丢
你写入 mmap 区域
= 直接写物理内存
= 这块物理内存【就是】page cache 的页
↓ PTE dirty 位置 1,内核标记 PageDirty
↓ 进程被 SIGKILL:进程死了,但 page cache 页归内核管
↓ writeback 内核线程照常把脏页写回文件
磁盘 ✅ 数据在
唯一会丢的情况:内核崩溃 / 断电 / 强制拔电池。这个概率比 LMK 杀进程低几个数量级。
实现
// mmap_ring.c
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdatomic.h>
#include <string.h>
typedef struct {
uint32_t magic, version, capacity, _pad;
_Atomic uint64_t cursor; // 单调递增,不回绕
} RingHdr;
static void* g_base = NULL;
static size_t g_size = 0;
static RingHdr* g_hdr = NULL;
static uint8_t* g_data = NULL;
int ring_open(const char* path, uint32_t cap)
{
int fd = open(path, O_RDWR | O_CREAT, 0600);
if (fd < 0) return –1;
g_size = sizeof(RingHdr) + cap;
// ★★ 必须先 ftruncate!
// mmap 超出文件末尾的区域,访问时是 SIGBUS 而不是自动扩展
if (ftruncate(fd, g_size) != 0) { close(fd); return –2; }
// ★★ MAP_SHARED —— MAP_PRIVATE 会 COW,永远写不进文件
g_base = mmap(NULL, g_size, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
close(fd); // ★ mmap 建立后 fd 可关,映射独立持有 inode 引用
if (g_base == MAP_FAILED) return –3;
g_hdr = (RingHdr*)g_base;
g_data = (uint8_t*)g_base + sizeof(RingHdr);
if (g_hdr->magic != 0x474E4952) { // "RING"
g_hdr->magic = 0x474E4952;
g_hdr->version = 1;
g_hdr->capacity = cap;
atomic_store(&g_hdr->cursor, 0);
}
return 0;
}
// ★ 写入:纯内存操作,零系统调用,约 20 ns
void ring_write(const void* src, uint32_t len)
{
if (!g_data || !len || len > g_hdr->capacity) return;
uint64_t pos = atomic_fetch_add_explicit(&g_hdr->cursor, len,
memory_order_relaxed);
uint32_t off = (uint32_t)(pos % g_hdr->capacity);
if (off + len <= g_hdr->capacity) {
memcpy(g_data + off, src, len);
} else {
uint32_t first = g_hdr->capacity – off;
memcpy(g_data + off, src, first);
memcpy(g_data, (const uint8_t*)src + first, len – first);
}
}
// 关键节点主动催一下写回(异步,不阻塞)
void ring_flush(void) { if (g_base) msync(g_base, g_size, MS_ASYNC); }
C# 侧:零分配面包屑
public enum Crumb : byte {
MapLoadBegin = 1, MapLoadEnd, PlayerSpawn, WeaponSwitch,
BundleLoad, VfxSpawn, MemPressure, NetReconnect, MajorFaultBurst,
}
public static class Breadcrumb
{
[DllImport("mmap_ring")] static extern int ring_open(string p, uint cap);
[DllImport("mmap_ring")] static extern void ring_write(IntPtr src, uint len);
[DllImport("mmap_ring")] static extern void ring_flush();
public static void Init() {
// ★ 必须放内部存储,外部存储走 FUSE,mmap 性能差
string p = Path.Combine(Application.persistentDataPath, "logs/crumb.ring");
Directory.CreateDirectory(Path.GetDirectoryName(p));
ring_open(p, 256 * 1024); // 256KB ≈ 65536 条
}
public static unsafe void Mark(Crumb id, ushort param) {
byte* b = stackalloc byte[4]; // 栈上,零 GC
b[0] = (byte)id;
*(ushort*)(b + 1) = param;
b[3] = (byte)(Time.frameCount & 0xFF);
ring_write((IntPtr)b, 4);
}
// 死亡、回合结束等关键点,催一次写回
public static void Checkpoint() => ring_flush();
}
崩溃后读取
void Start() {
string flag = Path.Combine(NoBackupDir(), "crash_flag");
if (File.Exists(flag)) {
// ★ 上次异常退出,但内核保住了 mmap 的脏页
var crumbs = ParseRing(ringPath);
UploadCrashContext(crumbs); // "死之前最后 65536 个事件"
}
File.WriteAllText(flag, "1");
}
void OnApplicationQuit() => File.Delete(...); // 正常退出才删
三种写入方式实测
| FileStream.Write | ~800 ns | ❌ 丢失 | 缓冲满时 |
| Write + Flush(true) | ~2 ms | ✅ 保留 | 每次 fsync |
| mmap 写入 | ~20 ns | ✅ 保留 | 0 |
mmap 是唯一同时做到"快"和"可靠"的方案,而这完全归功于"用户地址直接指向 page cache"。
案例 4:LMK 视角——mmap 让你更难被杀
原理
Android LMK 计算杀谁时,看的是"杀了你能释放多少"。而不同类型的内存"释放成本"完全不同:
你的 1GB 内存构成 A(LoadFromMemory 路线):
├─ 400 MB Native Heap(匿名页)→ 必须杀进程才能释放
├─ 300 MB 托管堆(匿名页) → 必须杀进程
└─ 300 MB 显存
你的 1GB 内存构成 B(LoadFromFile 路线):
├─ 150 MB Native Heap(匿名页)
├─ 100 MB 托管堆
├─ 450 MB file-backed clean(mmap 的 bundle)
│ → ★ kswapd 可以直接回收,不需要杀你
└─ 300 MB 显存
在内存压力下:
- 构成 A:系统没得选,只能杀进程
- 构成 B:系统先回收那 450MB 干净文件页 → 你活下来了(代价是后续 major fault)
实测验证
# 制造内存压力:后台开一堆 App,然后观察
adb shell "dumpsys meminfo com.xxx.game | grep -E '.apk mmap|Native Heap|TOTAL'"
构成 B 的输出:
Native Heap 152400 151900
.apk mmap 458200 0 458200 ← ★ Private Clean 全部可回收
TOTAL 1010000 580000 430000
注意 .apk mmap 那一行 Private Dirty = 0,Private Clean = 458200。
Private Clean 就是"随时可以丢的内存"。它虽然计入 PSS,但对 LMK 来说是"软的"。
压测对比
同一台 4GB 机器,后台开 8 个 App,玩 50 局:
| LoadFromMemory | 1180 MB | 12 / 50 | 9 ms |
| LoadFromFile(无预热) | 720 MB | 1 / 50 | 47 ms |
| LoadFromFile + 预热 | 780 MB | 0 / 50 | 11 ms |
第三行是最优解:mmap 提供"可回收"的属性避免被杀,预热提供"页已驻留"避免卡顿。
案例 5:大文件 Pack + Offset——减少 VMA 与 TLB 压力
问题
LoadFromFile 有个带 offset 的重载,但很多人不知道为什么要用它:
public static AssetBundle LoadFromFile(string path, uint crc, ulong offset);
200 个小文件 vs 1 个大文件
【200 个独立 bundle】
├─ 200 次 open/close 系统调用
├─ 200 个 VMA → VMA 红黑树变深,每次 fault 查找变慢
├─ 文件系统元数据:200 个 inode,每个至少占 1 个 block
├─ 磁盘布局分散 → 预读几乎无效,全是随机 IO
└─ Android FUSE 层每个文件都要转发
【1 个大 pack + offset】
├─ 1 次 open
├─ 可以只建 1 个大 VMA(如果一次映射整个 pack)
├─ 顺序布局 → 内核 readahead 命中率高
└─ fault-around 能一次映射 16 页
实现
public class PackedBundleStore
{
// 索引:name → (pageAlignedOffset, length, crc)
Dictionary<string, PackEntry> index;
string packPath;
public AssetBundle Load(string name) {
var e = index[name];
// ★ offset 必须页对齐,打包工具要保证这一点
return AssetBundle.LoadFromFile(packPath, e.crc, e.offset);
}
}
打包工具的关键约束:
// 生成 pack 时,每个 bundle 起始位置对齐到 16KB
// (兼容 Android 15 的 16KB 页;4KB 页机器上也安全)
const int ALIGN = 16 * 1024;
foreach (var bundle in bundles) {
long pad = (ALIGN – (writer.Position % ALIGN)) % ALIGN;
writer.Write(new byte[pad]); // 填充对齐
index[bundle.Name] = new PackEntry {
offset = (ulong)writer.Position,
length = (ulong)bundle.Length,
};
writer.Write(bundle.Data);
}
填充浪费:平均每个 bundle 浪费 8KB,200 个 = 1.6MB。换来的是加载速度 3 倍提升,非常值。
实测
| 200 个独立文件 | 1820 ms | +200 | 4210 |
| 1 个 pack + offset | 610 ms | +12 | 1180 |
案例 6:跨进程共享——录制进程
架构:主进程渲染,独立进程做编码推流,避免编码占用主进程 CPU。
主进程 录制进程
├─ 渲染 → 拿到帧数据 ├─ H.264 编码
├─ 音频混音 → PCM └─ 推流
└─ 需要传给录制进程
每帧 1080p RGBA = 8 MB
60fps → 480 MB/s
用 Binder 传 → ★ 直接把内存带宽打死
共享内存方案
// 主进程:创建
int fd = ASharedMemory_create("frame_ring", RING_BYTES);
void* base = mmap(NULL, RING_BYTES, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
// 通过 Binder 传 fd(只传 fd,不传数据!)
// 录制进程:映射同一批物理页
void* base2 = mmap(NULL, RING_BYTES, PROT_READ, MAP_SHARED, recv_fd, 0);
物理内存
┌────────────────────────┐
│ 匿名共享页(ashmem) │
└──┬──────────────────┬──┘
│ │
主进程 PTE 录制进程 PTE
(虚拟地址 A) (虚拟地址 B)
★ 两个不同的虚拟地址,同一批物理页
★ 主进程写入 → 录制进程立刻可见,零拷贝
无锁环形队列
typedef struct {
_Atomic uint64_t write_pos; // 只有生产者写
_Atomic uint64_t read_pos; // 只有消费者写
uint32_t slots, slot_size;
} ShmRing;
// 生产者
bool push(ShmRing* r, uint8_t* data, const void* src) {
uint64_t w = atomic_load_explicit(&r->write_pos, memory_order_relaxed);
uint64_t rd = atomic_load_explicit(&r->read_pos, memory_order_acquire);
if (w – rd >= r->slots) return false; // 满 → 丢帧
memcpy(data + (w % r->slots) * r->slot_size, src, r->slot_size);
// ★ release:保证 memcpy 对消费者可见后才更新游标
atomic_store_explicit(&r->write_pos, w + 1, memory_order_release);
return true;
}
三个必须知道的坑
① 绝对不能在共享内存里存指针
两个进程的虚拟地址不同,指针毫无意义
→ 只能存偏移量(offset)
② 不能存 C++ 对象
有虚表指针 → 就是指针问题
③ 对方进程崩溃时状态未定义
录制进程挂了 → 环形缓冲会填满 → 生产者必须能优雅丢帧
→ 需要心跳:在共享内存里放一个 heartbeat 计数器,
消费者定期 ++,生产者检测超时后停止 push
第 7 章:怎么度量
7.1 看映射全貌
PID=$(adb shell pidof com.xxx.game | tr -d '\\r')
# 汇总(最快看全局)
adb shell run-as com.xxx.game cat /proc/$PID/smaps_rollup
Rss: 742180 kB
Pss: 718240 kB
Shared_Clean: 58200 kB
Private_Clean: 412300 kB ← ★ 可直接回收的部分,越大越"抗杀"
Private_Dirty: 271680 kB ← ★ 必须杀进程才能释放
Swap: 0 kB
核心指标:Private_Clean / Private_Dirty 的比值。 比值越高,你在 LMK 眼里越安全。
7.2 看 Page Fault
# $10 = minflt, $12 = majflt
adb shell cat /proc/$PID/stat | awk '{print "minflt="$10, "majflt="$12}'
判读:
- minflt 大是正常的(首次触碰已缓存的页)
- majflt 在战斗中持续增长 = 有问题
7.3 看单个映射的驻留率
adb shell run-as com.xxx.game "grep -A 6 'base.pack' /proc/$PID/smaps"
Size: 820480 kB
Rss: 186240 kB ← 驻留率 22.7%
驻留率能告诉你"预热做得够不够"。
7.4 游戏内实时监控
public class FaultMonitor : MonoBehaviour
{
long lastMaj;
void Update() {
if (Time.frameCount % 60 != 0) return;
long maj = ReadMajFlt();
long delta = maj – lastMaj;
lastMaj = maj;
// ★ 战斗中每秒 major fault 超过 100 → 预热策略有漏洞
if (BattleState.InBattle && delta > 100) {
Debug.LogWarning($"[MMAP] majflt burst: +{delta}/s");
Breadcrumb.Mark(Crumb.MajorFaultBurst, (ushort)delta);
}
}
static long ReadMajFlt() {
try {
var f = File.ReadAllText("/proc/self/stat").Split(' ');
return long.Parse(f[11]); // 第 12 个字段(0-based 索引 11)
} catch { return 0; }
}
}
第 8 章:陷阱清单
| 未 ftruncate 就 mmap | SIGBUS(不是 SEGV,更难查) | mmap 前必须先设置文件大小 |
| MAP_PRIVATE 写入 | 内存翻倍(COW),文件里什么都没有 | 要写就用 MAP_SHARED |
| 只读资源不小心写了 | PSS 悄悄涨,anon 页增加 | 用 PROT_READ 强制,写就崩溃暴露问题 |
| offset 未页对齐 | mmap 返回 EINVAL | offset 对齐到 sysconf(_SC_PAGESIZE) |
| APK 内条目被压缩 | LoadFromFile 退化为全量解压 | gradle noCompress 配置 |
| 战斗中 major fault 风暴 | 随机 40~90ms 卡顿,Profiler 看不出 | 加载期 posix_fadvise 预热 |
| 切后台再回来卡 | 页被回收,重新 fault | 回前台时后台线程重新预热 |
| mlock 想钉住页 | 反而更容易被 LMK 整体杀掉 | 用 MADV_WILLNEED 代替 |
| 外部存储 mmap | fault 成本 2~5 倍 | 热数据必须放 /data 内部存储 |
| 共享内存存指针 | 另一进程解引用崩溃 | 只存偏移量 |
| 小文件 mmap | 比 read 还慢 | < 64KB 的文件直接 read |
| VMA 数量爆炸 | fault 变慢,/proc/pid/maps 几千行 | 合并成大 pack + offset |
| msync(MS_SYNC) 每次调 | 变成同步 IO,卡爆 | 用 MS_ASYNC,只在关键点用 SYNC |
| 忘记 munmap | 虚拟地址空间耗尽(32 位尤其) | 配对释放;64 位问题小但仍要管 |
结语
回到标题那句话,现在可以完整解释了:
“让用户空间的一段虚拟地址直接指向内核的 page cache”
= 内核在你的页表项(PTE)里,写入了 page cache 那个 struct page 的物理页帧号。
没有拷贝,只有一次页表写入。
从此你的虚拟地址和内核的 page cache 索引,指向同一块物理内存。
这个机制带来三个层次的价值,也带来三个层次的代价:
| 内存 | 只有访问过的页才驻留,PSS 降 10 倍 | 虚拟地址空间占满,VMA 增多 |
| 速度 | 加载 = 建 VMA,微秒级 | 成本推迟到 page fault,时机不可控 |
| 可靠性 | 脏页归内核,SIGKILL 也不丢 | 写回时机不确定,需要 msync 兜底 |
最重要的一课,来自案例 2:
mmap 不是"零成本",而是"把成本从一次集中的大拷贝,换成分散的、不可预测的 page fault"。
在加载界面,分散的 fault 是好事(内存低、启动快)。
在战斗中,不可预测的 fault 是灾难(卡顿无法归因)。
所以真正的工程解法从来不是"用 mmap",而是:
用 mmap 换内存,用预热换确定性,用 Private_Clean 换活命机会。
如果你的射击项目只做一件事,就做这个:
LoadFromFile 之前,先在后台线程 posix_fadvise(WILLNEED) 把本局资源敲进 page cache。 一个几十行的原生插件,能把战斗卡顿 P99 从 47ms 降到 11ms。
参考资源
内核机制
- Linux man pages — mmap(2)、madvise(2)、posix_fadvise(2)、msync(2)、mincore(2)、mlock(2)
- Linux Kernel Docs — Memory Management(vm_area_struct、address_space、page cache 与 XArray)
- 内核源码路径 — mm/filemap.c: filemap_fault()、mm/memory.c: handle_mm_fault() / do_fault_around() / do_wp_page()
- LWN.net — Fault-around、Page reclaim and the LRU lists、Transparent huge pages
- Mel Gorman — Understanding the Linux Virtual Memory Manager
Android 平台
- Android Developers — Memory allocation among processes、Process importance & LMK
- Android NDK — ASharedMemory、AHardwareBuffer
- Android — Support 16 KB page sizes(Android 15 arm64 对齐要求)
- /proc/<pid>/smaps、smaps_rollup、stat(minflt/majflt)字段说明
- zipalign 与 APK 内 STORED 条目可 mmap 的前提
Unity
- Unity Manual — AssetBundle compression(Uncompressed / LZ4 / LZMA 的加载行为差异)
- Unity Scripting API — AssetBundle.LoadFromFile(path, crc, offset)
- Unity Manual — Android: Split Application Binary、gradle noCompress 配置
性能测量
- Perfetto — 系统级 tracing,可观察 page fault 与 IO 阻塞
- Android simpleperf — CPU 采样(注意:睡眠等 IO 的时间采样器看不到)
网硕互联帮助中心





评论前必须登录!
注册