云计算百科
云计算领域专业知识百科平台

mmap的真相:不拷贝,只映射

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 线程稍后写回文件

三个推论(都很重要):

  • A 写的内容 B 立刻看得到(同一块物理内存,无需任何同步机制)
  • 内容会落到文件里(这是持久化的基础)
  • 进程被 SIGKILL 也不丢(脏页在内核,进程死了内核照样写回)
  • 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)

    MAP_SHAREDMAP_PRIVATE
    指向 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)

    结论一目了然:

    LoadFromFileLoadFromMemory
    虚拟地址占用 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,$((MINprev_min)),$((MAJprev_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(...); // 正常退出才删

    三种写入方式实测

    方式单次耗时SIGKILL 后系统调用
    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 局:

    加载方式平均 PSS被 LMK 杀次数战斗卡顿 P99
    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 bundleVMA 数量majflt
    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 的时间采样器看不到)
    赞(0)
    未经允许不得转载:网硕互联帮助中心 » mmap的真相:不拷贝,只映射
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!