2. 进程内存映射:mmap()系统调用详解、匿名映射与文件映射、MAP_SHARED与MAP_PRIVATE的区别

好,咱们今天聊聊 mmap()。这个系统调用,说白了就是QNX里进程管理内存的「瑞士军刀」。我做了这么多年嵌入式,几乎每个项目都离不开它。你想想看,一个进程怎么访问文件?怎么分配大块内存?怎么实现进程间共享数据?答案往往就是 mmap()

2.1 mmap() 系统调用:把「文件」变成「内存」

mmap() 的核心思想很简单:建立一段虚拟地址到物理内存或文件内容的映射。调用成功后,进程就能像读写内存一样操作文件,或者直接获得一块匿名内存。

它的原型长这样:

#include <sys/mman.h>

void *mmap(
    void *addr,          // 建议的起始地址,通常传 NULL
    size_t length,       // 映射长度(字节)
    int prot,            // 保护标志:PROT_READ, PROT_WRITE, PROT_EXEC, PROT_NONE
    int flags,           // 映射类型:MAP_SHARED, MAP_PRIVATE, MAP_ANONYMOUS 等
    int fd,              // 文件描述符(匿名映射传 -1)
    off_t offset         // 文件偏移量(通常为 0)
);

返回值是映射区的起始地址。如果失败,返回 MAP_FAILED(也就是 (void *)-1)。

关键参数解读:

  • addr:我个人习惯传 NULL,让内核自己选地址。除非你有特殊需求(比如固定地址的硬件寄存器映射),否则别瞎指定。
  • length:必须是页大小的整数倍。QNX 默认页大小是 4096 字节(4K)。如果你传 100 字节,内核会向上取整到 4096。
  • prot:权限组合。注意,PROT_EXEC 在 QNX 安全策略下可能受限,尤其是 SELinux 或沙箱环境。
  • flags:这是重点,决定了映射的行为。
  • fd:文件映射时传打开的文件描述符;匿名映射传 -1
  • offset:文件映射的起始偏移,也必须是页对齐的。

我的经验: 我曾经在一个项目中,因为 offset 没对齐,导致 mmap() 一直返回 EINVAL。查了半天手册才发现这个坑。嗯,记住:offset 必须是页大小的整数倍

2.2 匿名映射 vs 文件映射

这是 mmap() 的两大应用场景。我分别说说。

2.2.1 匿名映射:分配大块内存的利器

匿名映射,就是映射一块没有文件 backing 的内存。说白了,就是直接向内核要一块物理内存,映射到进程的虚拟地址空间。

用法很简单:flags 里加上 MAP_ANONYMOUSfd-1

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

int main() {
    size_t size = 1024 * 1024; // 1 MB
    void *ptr = mmap(NULL, size, PROT_READ | PROT_WRITE,
                     MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (ptr == MAP_FAILED) {
        perror("mmap failed");
        exit(1);
    }

    // 现在可以像普通数组一样使用这块内存
    char *buf = (char *)ptr;
    buf[0] = 'A';
    buf[1023] = 'Z';

    printf("匿名映射成功,地址:%p\n", ptr);

    // 使用完后记得释放
    munmap(ptr, size);
    return 0;
}

什么时候用匿名映射?

  • 需要分配大块内存(比如几 MB 甚至几百 MB),而 malloc() 可能因为堆碎片化而失败。
  • 需要内存对齐到页边界。
  • 需要共享内存给子进程(配合 MAP_SHARED)。

注意: 匿名映射分配的内存,默认是 零初始化 的。但别指望它像 calloc() 那样保证全零——QNX 内核确实会清零,但如果你用 MAP_UNINITIALIZED 标志(某些系统支持),可能得到脏内存。我建议你显式 memset 一下,除非你确定不需要。

2.2.2 文件映射:把文件当内存用

文件映射,就是把一个文件的内容映射到进程的地址空间。这样,读写文件就像读写内存一样简单。内核负责在后台处理页面的换入换出。

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

int main() {
    int fd = open("/tmp/data.bin", O_RDWR | O_CREAT, 0644);
    if (fd == -1) {
        perror("open failed");
        exit(1);
    }

    // 设置文件大小
    size_t size = 4096;
    ftruncate(fd, size);

    void *ptr = mmap(NULL, size, PROT_READ | PROT_WRITE,
                     MAP_SHARED, fd, 0);
    if (ptr == MAP_FAILED) {
        perror("mmap failed");
        close(fd);
        exit(1);
    }

    // 直接通过指针读写文件
    char *data = (char *)ptr;
    data[0] = 'H';
    data[1] = 'i';

    printf("文件映射成功,写入数据\n");

    // 同步到磁盘(可选)
    msync(ptr, size, MS_SYNC);

    munmap(ptr, size);
    close(fd);
    return 0;
}

文件映射的好处:

  • 省去了 read()/write() 的系统调用开销,尤其是随机访问时。
  • 内核自动管理页面缓存,多个进程映射同一个文件可以共享物理内存。
  • 适合大文件处理(比如数据库、日志文件)。

我的经验: 我曾经在一个数据采集系统中,用文件映射代替了传统的 fread()/fwrite()。结果呢?I/O 吞吐量提升了将近 30%。原因很简单:mmap() 避免了用户态和内核态之间的数据拷贝。但要注意,如果文件频繁修改且需要实时落盘,msync() 的调用时机要把握好。

2.3 MAP_SHARED vs MAP_PRIVATE:共享还是私有?

这两个标志决定了映射的「可见性」和「写行为」。这是 mmap() 最核心的区分,也是面试常考题。

特性 MAP_SHARED MAP_PRIVATE
写操作影响 修改直接写回底层文件(或共享内存) 修改只影响本进程,采用写时复制(Copy-on-Write)
其他进程可见性 其他映射同一文件的进程可见修改 其他进程不可见修改
典型用途 进程间共享内存、文件 I/O 优化 私有数据、代码段加载、调试
性能开销 写操作直接触发,无额外拷贝 首次写时触发缺页中断,拷贝页面

2.3.1 MAP_SHARED:大家一起改

MAP_SHARED 映射的内存,所有映射同一区域(或同一文件)的进程都能看到修改。而且,修改会写回底层文件(如果是文件映射)。

这简直就是进程间通信(IPC)的利器。我经常用它来实现共享内存,比 shmget() 那套 System V 接口灵活多了。

// 进程 A:写入数据
int fd = open("/tmp/shared", O_RDWR | O_CREAT, 0644);
ftruncate(fd, 4096);
void *ptr = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
sprintf((char *)ptr, "Hello from process A");

// 进程 B:读取数据
int fd = open("/tmp/shared", O_RDONLY);
void *ptr = mmap(NULL, 4096, PROT_READ, MAP_SHARED, fd, 0);
printf("Received: %s\n", (char *)ptr); // 输出 "Hello from process A"

注意: 使用 MAP_SHARED 时,必须确保文件描述符 fd 的打开权限与 prot 一致。比如 protPROT_WRITE,那么 fd 必须以 O_RDWR 打开。否则 mmap() 会返回 EACCES

2.3.2 MAP_PRIVATE:各改各的,互不影响

MAP_PRIVATE 则完全相反。它采用 写时复制(Copy-on-Write, CoW) 机制。初始时,所有进程共享同一物理页面。一旦某个进程尝试写入,内核会先拷贝一份页面给该进程,然后才允许修改。其他进程看到的还是原始数据。

这有什么用?

  • 程序加载:动态链接器加载共享库时,通常用 MAP_PRIVATE。这样多个进程可以共享代码段,但各自的数据段互不干扰。
  • 调试:你想修改一个文件的内容做测试,但又不想破坏原文件。用 MAP_PRIVATE 映射,随便改,原文件纹丝不动。
  • 安全:敏感数据不想被其他进程看到?用 MAP_PRIVATE
// 进程 A:用 MAP_PRIVATE 映射文件
int fd = open("/tmp/data", O_RDONLY);
void *ptr = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_PRIVATE, fd, 0);
// 修改只影响本进程
((char *)ptr)[0] = 'X';

// 进程 B:同样映射,但看到的是原始数据
int fd2 = open("/tmp/data", O_RDONLY);
void *ptr2 = mmap(NULL, 4096, PROT_READ, MAP_PRIVATE, fd2, 0);
printf("%c\n", ((char *)ptr2)[0]); // 输出原始字符,不是 'X'

避坑指南: 我曾经遇到一个性能问题:一个进程用 MAP_PRIVATE 映射了一个大文件(几百 MB),然后频繁修改其中的数据。结果呢?每次修改都触发缺页中断和页面拷贝,性能惨不忍睹。后来改成 MAP_SHARED 才解决。所以,如果你确定不需要写时复制,就别用 MAP_PRIVATE,尤其是大块内存频繁写的场景。

2.4 实际项目中的选择策略

说了这么多,到底怎么选?我总结了一个简单的决策树:

  1. 需要进程间共享数据? → 用 MAP_SHARED + 文件映射或匿名映射。
  2. 需要私有副本,不想影响原文件? → 用 MAP_PRIVATE + 文件映射。
  3. 需要分配大块私有内存? → 用 MAP_PRIVATE | MAP_ANONYMOUS
  4. 需要共享内存但不想创建文件? → 用 MAP_SHARED | MAP_ANONYMOUS(配合 fork()shm_open())。

我的建议: 在 QNX 实时系统中,我倾向于少用 MAP_PRIVATE 的写时复制。因为缺页中断的延迟不可预测,对实时性有影响。如果必须用,尽量在初始化阶段完成所有写操作,避免运行时触发 CoW。

好了,关于 mmap() 的核心内容就这些。记住:映射类型决定了行为,权限决定了安全,对齐决定了成败。下一节我们聊聊 munmap()msync() 的细节,以及如何正确管理映射生命周期。