【Linux mmap 文件映射】从虚拟地址到页缓存:读写文件、匿名映射与极简 malloc

🔥 星光编译者 · 个人主页

📚 学习专栏: 《C/C++ 成长笔记》 · 《Linux 实践手册》 · 《数据结构与算法》

🌄 向云端飞扬,编译属于自己的代码星河。


☕ 写在开篇

  你好,这里是 星光编译者

  这里记录我在 C/C++、Linux、数据结构与算法 学习中遇到的真实问题、亲手验证过的代码,以及那些容易被忽略的实现细节。

  比起简单罗列结论,我更愿意从问题出发,把一个知识点的来由讲清楚,把"为什么会这样"和"应该怎样解决"说明白,让每一次踩坑都沉淀成可以复用的经验。

  如果这篇记录能帮你少绕一点路,或让某个模糊的地方忽然变得清晰,那么这次分享便有了意义。愿我们在一次次阅读、编译与调试中稳步向前,慢慢搭起属于自己的技术世界。


🔥 本文定位 :从 mmap 的六个参数出发,完整串起文件映射、共享/私有语义、任意偏移对齐、缺页加载、页缓存、匿名映射和 munmap 生命周期,并把原始示例改造成边界清楚、可以直接编译运行的版本。

💡 学习目标 :理解 mmap 建立的是虚拟地址区间与后端对象之间的映射关系,而不是立刻复制完整文件;能正确选择 PROT_*MAP_SHAREDMAP_PRIVATEMAP_ANONYMOUS;能处理空文件、文件尾、页对齐、MAP_FAILEDSIGBUS 和持久化等关键问题。

📌 阅读说明:示例面向 Linux,使用 POSIX API 和 C++17。文中既保留"映射文件读写"和"用匿名映射极简模拟 malloc"两条实验主线,也会明确指出它们与真正工程实现之间的距离。


文章目录


一、先建立全局视角:mmap 到底映射了什么

传统文件读取通常先准备一段用户缓冲区,再调用 read

cpp 复制代码
std::vector<char> buffer(4096);
ssize_t n = ::read(fd, buffer.data(), buffer.size());

应用随后访问 buffer。从程序视角看,"文件"和"内存数组"是两个对象,read 负责把数据搬到用户缓冲区。

mmap 换了一种接口:它在进程虚拟地址空间中保留一段区间,并把这段区间关联到文件、设备或匿名内存。调用成功后,应用可以像访问数组一样访问映射区:

cpp 复制代码
const char* data = static_cast<const char*>(mapped);
char first = data[0];

但这并不等于 mmap 调用瞬间把整个文件复制进物理内存。

1.1 建立的是地址关系,不是完整数据副本

对文件映射而言,可以先把关系理解成:

text 复制代码
进程虚拟地址区间
        ↓
VMA(虚拟内存区域,记录范围和权限)
        ↓
文件页 / 页缓存
        ↓
底层文件系统与存储设备

mmap 成功时,内核主要完成地址区间、权限、映射类型和后端文件等元数据的登记。真正访问某个尚未建立页表映射的地址时,CPU 才会触发缺页异常,由内核把对应文件页接入当前进程页表。

因此,一个 20 GB 文件可以映射出很大的虚拟地址区间,但不代表此时立即占用 20 GB 物理内存。实际驻留多少,取决于访问模式、可用内存、回收策略和页缓存状态。

1.2 "少一次复制"应该怎样准确理解

常见说法是"mmap 零拷贝"。如果不限定上下文,这句话很容易让人误会。

更准确地说:

  • read 路径通常需要把数据从内核页缓存复制到应用提供的用户缓冲区;
  • 文件映射允许应用直接访问映射到自身地址空间的文件页,省掉这次显式的用户缓冲区复制;
  • 数据若不在内存中,仍可能需要从存储设备读取到物理页;
  • CPU 仍然会读取缓存层次,应用后续若执行 memcpy,复制当然仍然存在;
  • 页表建立、缺页处理、TLB 和缓存一致性也都有成本。

所以,mmap 的价值不是一句笼统的"没有任何复制",而是把文件 I/O 转换为按需的内存访问,并减少显式 read/write 和用户缓冲区管理。

1.3 哪些场景适合 mmap

mmap 常见于:

  • 随机读取大文件中的局部区域;
  • 数据库、索引、模型文件等需要地址化访问的格式;
  • 多个进程共享同一个文件或共享内存对象;
  • 加载可执行文件与动态库;
  • 自定义大块内存分配器;
  • 设备内存映射。

它并不天然适合所有顺序 I/O。一次性顺序扫描小文件时,read 的代码更直接;对错误处理、文件截断和访问边界要求很高的场景,mmap 也可能带来额外复杂度。


二、读懂 mmap 的六个参数与返回值

接口定义在 <sys/mman.h>

c 复制代码
void* mmap(void* addr,
           size_t length,
           int prot,
           int flags,
           int fd,
           off_t offset);

int munmap(void* addr, size_t length);

六个参数看似零散,其实分别回答六个问题:

参数 它回答的问题 常见选择
addr 希望从哪个虚拟地址开始 通常传 nullptr
length 映射多少字节 文件大小或目标区间长度
prot CPU 可以怎样访问 PROT_READPROT_WRITE
flags 修改怎样传播、映射是否匿名 MAP_SHAREDMAP_PRIVATE
fd 后端对象是谁 已打开的文件描述符;匿名映射传 -1
offset 从后端对象哪里开始 必须满足页对齐要求

2.1 addr:通常让内核选地址

最常见写法是:

cpp 复制代码
void* p = ::mmap(nullptr, length, prot, flags, fd, offset);

nullptr 表示没有地址偏好,让内核选择合适的页对齐地址。

如果传入非空地址但不使用 MAP_FIXED,它通常只是提示,不保证返回值等于提示值。MAP_FIXED 会要求使用指定地址,并可能替换已有映射;除非正在实现加载器、运行时或非常清楚地址空间布局,否则不要随意使用。

2.2 length:请求映射的逻辑字节数

这里有一个必须纠正的高频误区:

length 不要求调用者自己传成系统页大小的整数倍;它必须大于 0。内核以页为单位管理映射,尾端会覆盖到包含最后一个请求字节的页面。

例如系统页大小为 4096 字节:

cpp 复制代码
::mmap(nullptr, 3500, ...);

这样的调用本身是允许的。映射在页粒度上占据一页,但程序的合法文件访问范围仍应由真实文件大小和业务长度约束,不能因为底层按页管理就随意访问请求区间之外。

真正必须满足页粒度约束的是文件映射的 offset

2.3 prot:定义访问权限

常见保护位包括:

  • PROT_READ:允许读;
  • PROT_WRITE:允许写;
  • PROT_EXEC:允许执行;
  • PROT_NONE:暂时不可访问。

多个权限通过按位或组合:

cpp 复制代码
int prot = PROT_READ | PROT_WRITE;

权限既要符合业务意图,也要与文件打开方式兼容。例如,对文件建立可写 MAP_SHARED 映射时,描述符通常必须以 O_RDWR 打开;否则 mmap 可能以 EACCES 失败。

安全上应遵循最小权限原则:只读数据不要添加 PROT_WRITE,普通数据不要添加 PROT_EXEC

2.4 flags:共享、私有与匿名是两组维度

文件映射必须在下列两种主要传播语义中选一种:

  • MAP_SHARED:写入属于共享修改,可被映射同一后端对象的其他进程观察,并可反映到底层文件;
  • MAP_PRIVATE:私有写时复制视图,当前进程写入不会写回文件,也不会成为其他进程的共享修改。

是否有文件后端由 MAP_ANONYMOUS(也可写作 MAP_ANON)表达:

cpp 复制代码
MAP_PRIVATE | MAP_ANONYMOUS
MAP_SHARED  | MAP_ANONYMOUS

Linux 中没有通用的 MAP_ANONYMOUS_SHARED 这个单独标志;匿名与共享应组合表达。

2.5 fd:文件映射需要有效后端

文件映射时,fd 指向已经打开的普通文件或设备:

cpp 复制代码
int fd = ::open(path, O_RDONLY);

匿名映射没有文件后端,通常传:

cpp 复制代码
fd = -1;

映射成功后可以关闭 fd,已有映射不会因此失效。文件描述符和映射是两种不同的进程资源。

2.6 offset:文件起点必须页对齐

文件映射的 offset 必须是系统页大小的整数倍。页大小应在运行时获取:

cpp 复制代码
long value = ::sysconf(_SC_PAGE_SIZE);
if (value <= 0) {
    // 处理错误
}
size_t page_size = static_cast<size_t>(value);

不要硬编码 4096 或用 offset & ~0xFFF。常见 Linux 环境确实使用 4 KiB 基页,但代码不应把"常见"写成"协议"。

2.7 返回值:失败要与 MAP_FAILED 比较

成功时返回映射起始地址;失败时返回 MAP_FAILED,通常等价于 (void*)-1

cpp 复制代码
void* p = ::mmap(nullptr, length, prot, flags, fd, offset);
if (p == MAP_FAILED) {
    std::perror("mmap");
}

不要写成:

cpp 复制代码
if (p == nullptr) { /* 错误的失败判断 */ }

munmap 成功返回 0,失败返回 -1 并设置 errno


三、文件写映射:从 open 到落盘的完整路径

文件写映射的主线可以压缩为:

text 复制代码
open(O_RDWR)
    ↓
必要时 ftruncate 设置文件长度
    ↓
mmap(PROT_READ | PROT_WRITE, MAP_SHARED)
    ↓
像数组一样修改映射区
    ↓
需要明确持久化时 msync(MS_SYNC)
    ↓
munmap
    ↓
close

3.1 为什么新文件必须先设置长度

O_CREAT 新建的普通文件可能是 0 字节。mmap 不会替你把文件扩展到 length

cpp 复制代码
int fd = ::open(path, O_CREAT | O_RDWR, 0644);
::ftruncate(fd, 4096);

ftruncate 把文件逻辑长度设为目标值;扩展出来的区域读起来为零。若直接映射空文件,或者映射后访问超出有效文件范围的页面,程序可能收到 SIGBUS

注意,ftruncate 不是每次写映射都必须调用。若映射的是已有文件且不想改变它的长度,应先 fstat 获取真实大小,再验证目标范围。

3.2 一个边界完整的写入示例

下面创建一个 4096 字节文件,写入循环字母,并显式同步:

cpp 复制代码
#include <cerrno>
#include <cstdio>
#include <cstdlib>
#include <fcntl.h>
#include <iostream>
#include <limits>
#include <string>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>

namespace
{
constexpr size_t kFileSize = 4096;

bool ResizeFile(int fd, size_t size)
{
    if (size > static_cast<size_t>(std::numeric_limits<off_t>::max()))
    {
        errno = EFBIG;
        return false;
    }

    return ::ftruncate(fd, static_cast<off_t>(size)) == 0;
}
}

int main(int argc, char* argv[])
{
    if (argc != 2)
    {
        std::cerr << "Usage: " << argv[0] << " <file>\n";
        return 1;
    }

    int fd = ::open(argv[1], O_CREAT | O_RDWR | O_TRUNC, 0644);
    if (fd == -1)
    {
        std::perror("open");
        return 2;
    }

    if (!ResizeFile(fd, kFileSize))
    {
        std::perror("ftruncate");
        ::close(fd);
        return 3;
    }

    void* raw = ::mmap(nullptr,
                       kFileSize,
                       PROT_READ | PROT_WRITE,
                       MAP_SHARED,
                       fd,
                       0);
    if (raw == MAP_FAILED)
    {
        std::perror("mmap");
        ::close(fd);
        return 4;
    }

    auto* bytes = static_cast<unsigned char*>(raw);
    for (size_t i = 0; i < kFileSize; ++i)
    {
        bytes[i] = static_cast<unsigned char>('a' + (i % 26));
    }

    if (::msync(raw, kFileSize, MS_SYNC) == -1)
    {
        std::perror("msync");
        ::munmap(raw, kFileSize);
        ::close(fd);
        return 5;
    }

    if (::munmap(raw, kFileSize) == -1)
    {
        std::perror("munmap");
        ::close(fd);
        return 6;
    }

    if (::close(fd) == -1)
    {
        std::perror("close");
        return 7;
    }

    return 0;
}

完整编译命令:

bash 复制代码
g++ -std=c++17 -Wall -Wextra -Wpedantic -O2 mmap_write.cc -o mmap_write
./mmap_write sample.bin

可以查看结果:

bash 复制代码
ls -l sample.bin
xxd -l 96 sample.bin

3.3 为什么写共享映射通常使用 O_RDWR

PROT_WRITE 描述映射区允许执行写访问,MAP_SHARED 又要求写入可以传播到底层文件。内核因此需要文件描述符具备相应权限:

cpp 复制代码
::open(path, O_RDWR);

只用 O_WRONLY 也不是正确替代。对文件建立映射通常至少需要可读描述符;共享可写映射则使用 O_RDWR 最清楚。

3.4 msync 是什么,不是什么

MAP_SHARED 文件映射的修改会进入共享的文件页,并最终写回底层文件,但"最终"不等于"函数返回前已经稳定落盘"。

cpp 复制代码
::msync(addr, length, MS_SYNC);

MS_SYNC 会等待指定范围的更新完成,适合实验验证或业务需要明确同步点的场景。MS_ASYNC 发起异步写回请求,Linux 上具体行为与内核写回机制有关。

如果你还要求文件元数据或存储设备层面的更强持久化保证,需要结合文件系统语义、fsync/fdatasync、硬件缓存和应用协议综合设计。不要把一次 msync 简化成数据库事务。


四、文件读映射:边界正确比能打印更重要

只读映射一般使用:

cpp 复制代码
int fd = ::open(path, O_RDONLY);
void* raw = ::mmap(nullptr,
                   file_size,
                   PROT_READ,
                   MAP_PRIVATE,
                   fd,
                   0);

如果完全不写,MAP_PRIVATEMAP_SHARED 都能读到文件内容;只读工具常用 MAP_PRIVATE 来表达"不把本进程可能发生的写入作为共享文件修改"。

4.1 不要把任意文件当 C 字符串输出

原始实验中常见下面写法:

cpp 复制代码
std::cout << static_cast<char*>(raw) << std::endl;

这隐含要求映射内容以 \0 结尾。普通文本文件和二进制文件都不保证这一点。流会继续向后读取,直到偶然遇到零字节,可能越过映射边界并触发错误。

正确做法是始终带长度:

cpp 复制代码
std::cout.write(static_cast<const char*>(raw), file_size);

二进制文件更适合按块处理,而不是输出到终端。

4.2 一个安全的只读示例

cpp 复制代码
#include <cerrno>
#include <cstdio>
#include <fcntl.h>
#include <iostream>
#include <limits>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>

int main(int argc, char* argv[])
{
    if (argc != 2)
    {
        std::cerr << "Usage: " << argv[0] << " <file>\n";
        return 1;
    }

    int fd = ::open(argv[1], O_RDONLY);
    if (fd == -1)
    {
        std::perror("open");
        return 2;
    }

    struct stat st {};
    if (::fstat(fd, &st) == -1)
    {
        std::perror("fstat");
        ::close(fd);
        return 3;
    }

    if (st.st_size == 0)
    {
        ::close(fd);
        return 0;
    }

    if (st.st_size < 0 ||
        static_cast<unsigned long long>(st.st_size) >
            std::numeric_limits<size_t>::max() ||
        static_cast<unsigned long long>(st.st_size) >
            static_cast<unsigned long long>(
                std::numeric_limits<std::streamsize>::max()))
    {
        std::cerr << "file is too large for this address space\n";
        ::close(fd);
        return 4;
    }

    const size_t size = static_cast<size_t>(st.st_size);
    void* raw = ::mmap(nullptr,
                       size,
                       PROT_READ,
                       MAP_PRIVATE,
                       fd,
                       0);
    if (raw == MAP_FAILED)
    {
        std::perror("mmap");
        ::close(fd);
        return 5;
    }

    const char* bytes = static_cast<const char*>(raw);
    std::cout.write(bytes, static_cast<std::streamsize>(size));

    const bool output_ok = static_cast<bool>(std::cout);
    if (::munmap(raw, size) == -1)
    {
        std::perror("munmap");
        ::close(fd);
        return 6;
    }

    if (::close(fd) == -1)
    {
        std::perror("close");
        return 7;
    }

    return output_ok ? 0 : 8;
}

编译运行:

bash 复制代码
g++ -std=c++17 -Wall -Wextra -Wpedantic -O2 mmap_read.cc -o mmap_read
./mmap_read sample.bin | head -c 96

4.3 空文件必须单独处理

mmaplength 必须大于 0,而空文件大小正好是 0。下面写法会失败:

cpp 复制代码
::mmap(nullptr, 0, PROT_READ, MAP_PRIVATE, fd, 0);

所以只读程序在 fstat 后应先判断 st.st_size == 0。这不是"罕见特殊情况",而是文件处理接口必须明确支持的一种正常输入。

4.4 映射期间文件大小可能变化

fstat 与后续访问之间不是天然原子操作。若另一个进程在映射后把文件截短,当前进程访问已经超出新文件末尾的页面时可能收到 SIGBUS

需要稳定快照的程序不能只做一次边界判断,还应通过以下方式之一控制并发修改:

  • 约定文件只读且不可替换;
  • 使用文件锁或更高层协议;
  • 写入临时文件后通过 rename 原子发布新版本;
  • 映射前复制到受控快照;
  • 设计能够恢复 SIGBUS 的隔离边界,但这通常比想象中复杂。

五、MAP_SHARED 与 MAP_PRIVATE:共享和写时复制

两者都可以把同一文件内容映射到地址空间,差异集中在"当前进程写入后发生什么"。

维度 MAP_SHARED MAP_PRIVATE
本进程可读 取决于 prot 取决于 prot
本进程可写 取决于 prot 与 fd 权限 取决于 prot
写入对其他共享映射可见
写入反映到底层文件 可以 不会
主要机制 共享文件页 私有写时复制页
常见用途 修改文件、共享状态 只读解析、私有修改视图

5.1 MAP_SHARED:共享的是更新语义

多个进程对同一文件区域建立 MAP_SHARED 映射时,它们可以观察同一组共享更新。不同进程返回的虚拟地址不需要相同:

text 复制代码
进程 A:0x7f18... ─┐
                   ├─ 同一组文件页
进程 B:0x7a20... ─┘

注意,MAP_SHARED 只提供共享可见性,不提供互斥、事件顺序或消息边界。两个进程同时写同一字段仍然会产生数据竞争或协议破坏。跨进程协作必须另行设计锁、原子变量、序列号或其他同步机制。

5.2 MAP_PRIVATE:第一次写入才复制

私有映射并不是在 mmap 调用时复制整个文件。初始读取仍可共享文件页;某个进程第一次写某页时,内核为它建立私有副本:

text 复制代码
写前:进程私有 PTE ──→ 文件页
写后:进程私有 PTE ──→ 匿名私有页
                         文件页保持原内容

这就是写时复制(Copy-on-Write,COW)。复制粒度是页面,而不是整个映射区。

5.3 一个最小对比实验

准备文件:

bash 复制代码
printf 'hello mmap\n' > demo.txt

把示例中的映射改成可写私有映射:

cpp 复制代码
void* raw = ::mmap(nullptr,
                   size,
                   PROT_READ | PROT_WRITE,
                   MAP_PRIVATE,
                   fd,
                   0);

static_cast<char*>(raw)[0] = 'H';

当前进程从映射区读取会看到 Hello mmap,但重新执行:

bash 复制代码
cat demo.txt

文件仍是 hello mmap

若改为 MAP_SHARED,并用 O_RDWR 打开,再执行 msync,文件首字母会变为 H

5.4 文件后来被别处修改,私有映射能看到吗

对于 MAP_PRIVATE,映射建立后底层文件发生变化是否能在尚未写时复制的映射区中观察到,不应作为可移植程序依赖的契约。需要明确一致性语义时,应建立自己的版本、锁或快照协议。


六、任意文件区间怎样映射:对齐、偏移与文件尾

如果只映射整个文件,offset = 0 天然页对齐。真正容易出错的是"从第 5000 字节开始读取 100 字节"。

直接写:

cpp 复制代码
::mmap(nullptr, 100, PROT_READ, MAP_PRIVATE, fd, 5000);

通常会以 EINVAL 失败,因为 5000 不是页大小的整数倍。

6.1 向下对齐,再补块内偏移

设:

text 复制代码
logical_offset = 5000
page_size      = 4096

计算:

text 复制代码
aligned_offset = floor(5000 / 4096) * 4096 = 4096
delta          = 5000 - 4096               = 904
map_length     = delta + requested_length

真正传给 mmap 的文件偏移是 4096,返回给业务的地址则是:

cpp 复制代码
static_cast<std::byte*>(map_base) + delta

不要用只适用于 2 的幂且硬编码页大小的魔法位运算。通用写法更清楚:

cpp 复制代码
off_t aligned = logical_offset - (logical_offset % page_size);
off_t delta = logical_offset - aligned;

6.2 一个可复用的只读区间映射类

下面的 RAII 封装只负责映射一个已经验证位于文件范围内的区间:

cpp 复制代码
#include <cerrno>
#include <cstddef>
#include <cstdint>
#include <limits>
#include <stdexcept>
#include <string>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>

class ReadOnlyMappedRange
{
public:
    ReadOnlyMappedRange(int fd, off_t logical_offset, size_t length)
        : base_(MAP_FAILED), map_length_(0), data_(nullptr), size_(0)
    {
        if (logical_offset < 0)
        {
            throw std::invalid_argument("negative file offset");
        }

        if (length == 0)
        {
            return;
        }

        struct stat st {};
        if (::fstat(fd, &st) == -1)
        {
            throw std::runtime_error("fstat failed");
        }

        if (st.st_size < 0 || logical_offset > st.st_size)
        {
            throw std::out_of_range("offset outside file");
        }

        const auto available =
            static_cast<unsigned long long>(st.st_size - logical_offset);
        if (static_cast<unsigned long long>(length) > available)
        {
            throw std::out_of_range("range crosses file end");
        }

        const long page_value = ::sysconf(_SC_PAGE_SIZE);
        if (page_value <= 0)
        {
            throw std::runtime_error("cannot query page size");
        }

        const off_t page_size = static_cast<off_t>(page_value);
        const off_t aligned =
            logical_offset - (logical_offset % page_size);
        const off_t delta_off = logical_offset - aligned;

        if (static_cast<unsigned long long>(delta_off) >
            std::numeric_limits<size_t>::max() - length)
        {
            throw std::overflow_error("mapping length overflow");
        }

        const size_t delta = static_cast<size_t>(delta_off);
        map_length_ = delta + length;

        base_ = ::mmap(nullptr,
                       map_length_,
                       PROT_READ,
                       MAP_PRIVATE,
                       fd,
                       aligned);
        if (base_ == MAP_FAILED)
        {
            throw std::runtime_error(
                "mmap failed, errno=" + std::to_string(errno));
        }

        data_ = static_cast<const std::byte*>(base_) + delta;
        size_ = length;
    }

    ~ReadOnlyMappedRange()
    {
        if (base_ != MAP_FAILED)
        {
            ::munmap(base_, map_length_);
        }
    }

    ReadOnlyMappedRange(const ReadOnlyMappedRange&) = delete;
    ReadOnlyMappedRange& operator=(const ReadOnlyMappedRange&) = delete;

    const std::byte* data() const noexcept { return data_; }
    size_t size() const noexcept { return size_; }

private:
    void* base_;
    size_t map_length_;
    const std::byte* data_;
    size_t size_;
};

这个类特意保留两个地址:

  • base_mmap 返回的页对齐起点,只能用它传给 munmap
  • data_:业务真正请求的逻辑起点,等于 base_ + delta

若错误地对 data_ 调用 munmap,它通常不是页对齐地址,解除映射会失败。

6.3 文件尾最后一页有什么特殊之处

文件长度通常不是页大小的整数倍。例如文件大小为 10000 字节,页大小为 4096:

text 复制代码
[0, 4096)       完整页
[4096, 8192)    完整页
[8192, 10000)   最后 1808 字节

最后一个包含文件末尾的页面中,超出文件末尾的部分在读取时通常表现为零,但对那部分的修改不会写入文件。更重要的是,访问完全位于文件末尾之后的页面会导致 SIGBUS

工程代码不应把"页还在那里"当成业务可访问范围。合法范围始终是:

text 复制代码
0 <= offset
offset <= file_size
length <= file_size - offset

采用 length <= file_size - offset,比直接判断 offset + length <= file_size 更容易避免整数溢出。

6.4 为什么大文件常常分窗口映射

64 位进程的虚拟地址空间通常很大,但"能把整个文件映射进去"不等于"永远应该这样做"。窗口映射有这些价值:

  • 兼容地址空间较小的进程;
  • 控制 VMA 数量和映射生命周期;
  • 让应用明确管理热点区域;
  • 对外部文件变化建立更小的失效边界;
  • 配合 LRU、预取或按块校验。

窗口越小,mmap/munmap 次数可能越多;窗口越大,地址空间、错误影响范围和资源持有时间也更大。块大小应依据访问局部性衡量,而不是机械等于一页。


七、缺页异常与页缓存:mmap 不是一次性把文件读入内存

mmap 调用成功,只说明映射关系已经建立。第一次读取某个尚未驻留的文件页时,大致会经历:

text 复制代码
CPU 访问虚拟地址
        ↓
页表项尚未建立或不满足权限
        ↓
触发缺页异常
        ↓
内核根据 VMA 找到文件与偏移
        ↓
页缓存命中?
   ├─ 是:直接关联已有物理页
   └─ 否:从存储读取文件页
        ↓
更新页表并重新执行原指令

7.1 mmap 成功不代表数据已经驻留

可以把两个动作分开理解:

  • mmap:创建地址空间布局和后端关系;
  • 访问映射区:按需触发页面接入。

因此,mmap 很快并不意味着后续访问也不会阻塞。首次触碰冷数据时,缺页路径仍可能等待磁盘 I/O。

7.2 minor fault 与 major fault

从观察角度看,缺页大致可以分为:

  • minor fault(次缺页):不需要从存储读取,例如目标页已经在页缓存中,只需建立页表关系;
  • major fault(主缺页):需要从存储设备调入数据,代价明显更高。

"应用第一次访问某映射页"不等于"磁盘第一次读取该页"。另一个进程或此前的 read 可能已经把文件页放入页缓存。

7.3 顺序访问与随机访问怎样给内核提示

Linux 提供 madvise 告诉内核预期访问模式:

cpp 复制代码
::madvise(addr, length, MADV_SEQUENTIAL);
::madvise(addr, length, MADV_RANDOM);
::madvise(addr, length, MADV_WILLNEED);

这类调用是建议,不是结果保证。是否有效取决于内核、文件系统、工作集和真实访问模式。优化前应先测量缺页数、I/O 吞吐和延迟分布。

对于文件本身的访问建议,也可研究 posix_fadvise。它与 madvise 的作用对象和接口不同,不应混为同一个 API。

7.4 映射越大,性能就越好吗

不一定。大映射减少窗口切换,但仍可能带来:

  • 更大的页表压力;
  • 更随机的缺页;
  • 更难控制的预取;
  • 更长的错误影响范围;
  • 32 位进程地址空间不足;
  • 文件外部截断时更多潜在危险地址。

真正决定性能的是访问局部性、缺页成本、页缓存命中、存储设备特性和同步方式,而不是映射长度这个单一数字。


八、匿名映射与极简 malloc:能分配不等于像 malloc

文件映射依赖 fd + offset。匿名映射没有文件后端,常用于向内核申请按页管理的零初始化虚拟内存:

cpp 复制代码
void* p = ::mmap(nullptr,
                 length,
                 PROT_READ | PROT_WRITE,
                 MAP_PRIVATE | MAP_ANONYMOUS,
                 -1,
                 0);

8.1 私有匿名映射的语义

MAP_PRIVATE | MAP_ANONYMOUS 表示:

  • 映射不关联普通文件;
  • 初始内容为零;
  • 当前进程可以在权限允许时读写;
  • fork 后父子进程可基于写时复制继承这些页;
  • munmap 解除映射后,原地址立即失效。

8.2 一个更清楚的极简分配接口

c 复制代码
#include <errno.h>
#include <stdio.h>
#include <sys/mman.h>

void* page_alloc(size_t size)
{
    if (size == 0)
    {
        errno = EINVAL;
        return NULL;
    }

    void* p = mmap(NULL,
                   size,
                   PROT_READ | PROT_WRITE,
                   MAP_PRIVATE | MAP_ANONYMOUS,
                   -1,
                   0);

    return p == MAP_FAILED ? NULL : p;
}

int page_free(void* ptr, size_t size)
{
    if (ptr == NULL || size == 0)
    {
        errno = EINVAL;
        return -1;
    }

    return munmap(ptr, size);
}

int main(void)
{
    const size_t size = 1024;
    unsigned char* p = page_alloc(size);
    if (p == NULL)
    {
        perror("page_alloc");
        return 1;
    }

    for (size_t i = 0; i < size; ++i)
    {
        p[i] = (unsigned char)('A' + (i % 26));
    }

    printf("mapped at %p, first bytes: %.16s\n", (void*)p, (char*)p);

    if (page_free(p, size) == -1)
    {
        perror("page_free");
        return 2;
    }

    return 0;
}

编译:

bash 复制代码
gcc -std=c11 -D_DEFAULT_SOURCE -Wall -Wextra -Wpedantic -O2 \
    mmap_alloc.c -o mmap_alloc
./mmap_alloc

即使请求 1024 字节,内核仍以页面粒度建立映射。/proc/<pid>/maps 中通常会看到覆盖整页或与相邻匿名区域合并后的 VMA。但这不代表程序获得了"可以随意访问整页"的接口契约;调用者仍应只使用自己请求和管理的范围。

8.3 为什么它远不是 libc malloc

真正的 malloc 需要处理:

  • 任意小尺寸分配与对齐;
  • 块元数据;
  • 空闲链表、size class 或 arena;
  • 碎片与合并;
  • 多线程竞争;
  • realloccalloc 和错误语义;
  • 小块复用,避免每次都进入内核;
  • 大块释放策略;
  • 安全加固与调试支持。

每个 24 字节对象都单独 mmap 一次会浪费页内空间、增加 VMA、页表和系统调用成本。现实分配器通常从操作系统取得较大区域,再在用户态切分小块;较大的分配才可能直接使用 mmap

所以这个示例更准确的名字是 page_alloc,它展示"用匿名映射向内核申请一段区域",不是一个可替代标准库的 malloc

8.4 MAP_SHARED | MAP_ANONYMOUS 可以做什么

如果匿名映射在 fork 之前建立:

cpp 复制代码
void* p = ::mmap(nullptr,
                 size,
                 PROT_READ | PROT_WRITE,
                 MAP_SHARED | MAP_ANONYMOUS,
                 -1,
                 0);

子进程会继承这段映射,父子可以通过它共享数据。独立启动、没有继承关系的进程无法仅凭一个地址重新找到这段匿名映射;这时更适合使用 shm_openmemfd_create 或普通文件作为可传递/可重新打开的后端。

无论后端是什么,共享内存都只提供共享字节。并发读写仍需要同步协议。


九、close、msync、munmap 与文件生命周期

这几个 API 常常一起出现,但职责完全不同:

操作 作用对象 核心效果
close(fd) 文件描述符 释放当前进程的描述符引用
msync(addr, len, ...) 共享文件映射范围 请求同步映射修改
munmap(addr, len) 虚拟地址区间 解除当前进程映射
ftruncate(fd, size) 文件对象 改变文件逻辑长度
unlink(path) 目录项 删除路径名称,不一定立即销毁已打开对象

9.1 mmap 成功后可以立刻 close fd

映射成功后,内核已经保存了映射对后端对象的引用。下面顺序是允许的:

cpp 复制代码
void* p = ::mmap(..., fd, 0);
if (p == MAP_FAILED) { /* 处理错误 */ }

::close(fd);
// p 仍然可以在映射权限与有效范围内访问

这不会自动执行 munmap,也不会让 p 失效。

9.2 munmap 之后任何旧指针都不能再用

cpp 复制代码
::munmap(base, length);

成功后,这段虚拟地址不再属于原映射。所有指向其中的指针、引用、string_viewspan 和迭代器都随之失效。

即使稍后某个新映射碰巧复用了相同数值地址,它也已经是另一个对象。用旧指针访问属于未定义行为,并可能直接触发 SIGSEGV

9.3 先 close 还是先 munmap

映射成功后,两种顺序都可以:

text 复制代码
munmap → close
close   → 使用映射 → munmap

关键是所有权清楚:

  • fd 由谁关闭;
  • 映射由谁解除;
  • 映射中的视图可以活多久;
  • 出错和提前返回时是否仍能释放;
  • 多线程是否可能在解除映射时仍在访问。

C++ 中应优先用 RAII 封装,而不是在每个 return 前手写不同清理组合。

一个文件被 unlink 后,路径名称消失;只要仍有打开描述符或映射引用,底层对象仍可继续存在。直到最后一个引用释放,存储才真正回收。

这与"路径能否再次打开"和"现有映射能否继续访问"是两个问题。

9.5 外部 truncate 是危险边界

如果另一个进程把映射中的文件截短:

bash 复制代码
truncate -s 0 mapped.bin

当前进程的 VMA 可能仍显示原范围,但其中一些地址已经没有有效文件内容支撑。继续访问可能收到 SIGBUS

这类问题不能靠 try/catch 捕获,因为它不是 C++ 异常。根本解决方案是控制文件版本和生命周期。


十、用 strace、proc 与 gdb 观察映射

系统编程最可靠的学习方式之一,是让地址空间变化真正出现在工具输出中。

10.1 strace:确认调用参数与返回值

bash 复制代码
strace -e trace=openat,ftruncate,mmap,msync,munmap,close \
    ./mmap_write sample.bin

你会看到类似:

text 复制代码
openat(..., "sample.bin", O_RDWR|O_CREAT|O_TRUNC, 0644) = 3
ftruncate(3, 4096)                                      = 0
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_SHARED, 3, 0) = 0x...
msync(0x..., 4096, MS_SYNC)                             = 0
munmap(0x..., 4096)                                     = 0
close(3)                                                = 0

这能快速发现:

  • 文件是否以错误模式打开;
  • offset 是否传错;
  • mmap 是否返回 EINVALEACCESENOMEM
  • 是否遗漏 munmap
  • 实际映射长度与标志是什么。

10.2 /proc//maps:看 VMA 区间

让程序在映射后暂时等待:

cpp 复制代码
std::cout << "pid=" << ::getpid()
          << ", addr=" << raw << '\n';
std::cin.get();

另一个终端执行:

bash 复制代码
cat /proc/<pid>/maps
pmap -x <pid>

典型一行:

text 复制代码
7f2c...-7f2c... rw-s 00000000 08:01 ... /path/sample.bin

权限字段可以帮助识别映射:

text 复制代码
r  可读
w  可写
x  可执行
s  shared
p  private

匿名映射通常没有普通文件路径,工具还可能把相邻、权限相同的匿名区域合并显示。不要假设每次 mmap 必然对应 maps 中独立的一行。

10.3 /proc//smaps:看更细的内存统计

bash 复制代码
less /proc/<pid>/smaps

这里可以观察:

  • Size:虚拟区间大小;
  • Rss:当前驻留集合;
  • Pss:共享页按比例分摊后的统计;
  • Shared_Clean / Shared_Dirty
  • Private_Clean / Private_Dirty
  • VmFlags

SizeRss 对比起来,就能直观看到"映射很大"与"已经驻留很多物理页"不是同一件事。

10.4 gdb:比较 mmap 前后的地址空间

bash 复制代码
gdb ./mmap_alloc

设置断点并运行:

gdb 复制代码
break page_alloc
run
info proc mappings
next
finish
info proc mappings

也可以检查内存:

gdb 复制代码
x/32cb p
x/16gx p

info proc mappings 展示的是虚拟地址区间。看到新增一页并不代表 length 参数必须传整页,只说明 Linux 以页面粒度管理地址空间。

10.5 perf:观察缺页与整体行为

bash 复制代码
perf stat -e page-faults,minor-faults,major-faults \
    ./mmap_read large.bin > /dev/null

重复执行时结果可能不同,因为页缓存已经变热。性能实验应说明:

  • 是否为冷缓存;
  • 文件系统与存储设备;
  • 文件大小和访问模式;
  • 是否有其他进程影响页缓存;
  • 统计的是首次延迟还是稳定吞吐。

十一、工程边界:同步、性能、安全与可移植性

11.1 MAP_SHARED 不等于线程安全或进程安全

假设共享映射中有:

cpp 复制代码
struct State
{
    uint64_t version;
    char payload[256];
};

一个进程先写 payload 再写 version,另一个进程不能仅凭"映射是共享的"就假设读取顺序正确。编译器重排、CPU 内存序、并发写入和撕裂都需要同步协议处理。

可选机制包括:

  • 放在共享内存中的进程共享 mutex;
  • 适当的原子类型和内存序,但必须确认实现支持跨进程语义;
  • 文件锁;
  • futex 或基于事件的通知;
  • 单写者、多读者加版本校验;
  • 双缓冲或日志式发布。

11.2 不要在共享映射中保存普通裸指针

不同进程可能把同一文件映射到不同虚拟地址:

cpp 复制代码
struct BadRecord
{
    char* data;  // 只在写入这个地址值的进程里有意义
};

跨进程持久结构应更偏向:

  • 相对映射起点的偏移量;
  • 固定宽度整数;
  • 明确字节序;
  • 明确对齐和填充;
  • magic + version + size 头部;
  • 校验和或提交标记。

11.3 文件格式不能直接依赖 C++ 对象布局

把带虚函数、std::stringstd::vector 或普通指针的对象直接放进文件映射,重启或跨进程后通常不可用。

即使是简单结构体,也要考虑:

  • ABI 与编译器差异;
  • 32/64 位差异;
  • 对齐与 padding;
  • 大小端;
  • 版本升级;
  • 原子写入边界;
  • 崩溃时的中间状态。

mmap 只是访问方式,不会自动把内存布局变成可靠文件格式。

11.4 高频小映射可能比 read 更慢

mmap/munmap 会修改进程地址空间,并可能涉及 VMA 管理、页表和 TLB 失效。若每读取几十字节就建立并销毁一次映射,开销可能比复用缓冲区的 pread 更高。

常见改进方式:

  • 一次映射更大的窗口;
  • 缓存最近使用的映射块;
  • 使用 pread 避免共享文件偏移;
  • 批量处理;
  • 根据基准测试选择阈值。

11.5 地址不稳定,不要序列化映射地址

ASLR、地址空间碎片、动态库和线程栈都会影响映射地址。程序重启后,同一文件几乎没有理由必须落在相同虚拟地址。

持久化时记录文件偏移,不要记录当前进程中的绝对指针。

11.6 资源限制也会让 mmap 失败

即使磁盘空间充足,mmap 仍可能因为以下原因失败:

  • 虚拟地址空间不足;
  • VMA 数量限制;
  • RLIMIT_AS
  • 权限不匹配;
  • 参数组合不合法;
  • 文件偏移不对齐;
  • 内核无法满足提交或映射要求。

因此每一次 mmap 都必须检查 MAP_FAILED,不能因为"只是映射,不立刻分配物理页"就省略错误处理。


十二、常见误区、高频面试题与练习

12.1 常见误区

误区一:mmap 成功后,整个文件已经读入物理内存

不是。调用主要建立映射关系,页面通常在实际访问时按需接入。是否需要磁盘 I/O取决于页缓存。

误区二:length 必须是页大小的整数倍

不是。length 必须大于 0,内核按页管理尾端;文件映射的 offset 才必须满足页对齐要求。

误区三:失败时 mmap 返回 nullptr

不是。失败返回 MAP_FAILED,必须与它比较。

误区四:MAP_SHARED 会自动处理并发竞争

不会。它只定义写入传播语义,不提供锁、消息边界和先后顺序。

误区五:MAP_PRIVATE 会立刻复制整个文件

不会。它通常依赖写时复制,第一次写某页时才为该页建立私有副本。

误区六:只要映射是 PROT_WRITE,文件用 O_RDONLY 打开也能共享写

不行。保护权限、映射类型和文件描述符访问模式必须兼容,共享可写文件映射通常需要 O_RDWR

误区七:open(O_CREAT) 后可以直接映射 4096 字节

新文件初始长度可能为 0。必须先用 ftruncate 设置有效长度,或者写入数据扩展文件。

误区八:输出 char* 会自动在映射末尾停止

不会。普通文件没有隐含 \0,必须使用带长度的输出或解析接口。

误区九:close(fd) 会自动解除映射

不会。描述符关闭后映射仍可存在;需要单独 munmap

误区十:munmap 后旧 string_view 还能用

不能。任何指向映射区的非拥有型视图都会失效。

误区十一:munmap 会强制内核立即丢弃文件页缓存

不会。它解除当前进程地址映射,文件页是否继续留在页缓存由内核决定。

误区十二:访问最后一页中超出文件末尾的区域总是安全

不能把页面粒度当业务边界。访问完全位于文件末尾之后的页面可能触发 SIGBUS;程序应只使用真实文件范围。

误区十三:MAP_ANONYMOUS_SHARED 是标准写法

不是。Linux 下共享匿名映射写成 MAP_SHARED | MAP_ANONYMOUS

误区十四:每次小对象分配都调用 mmap,就是 malloc

不是。真正分配器还要复用页、管理元数据、对齐、碎片、多线程和不同尺寸类别。

误区十五:用 mmap 一定比 read 快

不一定。结果取决于文件大小、访问模式、缺页、复用、映射频率和平台。应以真实负载测量。

12.2 高频面试题

问题 1:mmap 的核心作用是什么

在进程虚拟地址空间建立一段映射,把虚拟地址范围与文件、设备或匿名内存关联起来,使程序可以通过普通内存访问指令操作这段区域。

问题 2:mmap 与 read 的主要区别是什么

read 把数据复制到应用提供的缓冲区;mmap 把文件页接入进程地址空间,由缺页机制按需建立页表映射。两者最终都可能使用内核页缓存。

问题 3:为什么 mmap 的 offset 必须页对齐

页表、VMA 和文件页都以页面为基本管理粒度。文件偏移必须能与映射虚拟页边界一一对应。

问题 4:怎样映射从文件第 5000 字节开始的 100 字节

把 5000 向下对齐到页边界,计算块内偏移 delta,映射长度设为 delta + 100,业务地址使用 map_base + delta

问题 5:MAP_SHARED 与 MAP_PRIVATE 有什么区别

MAP_SHARED 的写入属于共享修改,可以被其他共享映射观察并反映到文件;MAP_PRIVATE 使用私有写时复制,写入不会写回文件。

问题 6:mmap 为什么可能触发 SIGBUS

典型原因是访问文件映射中没有有效后端支撑的页面,例如文件被截短后继续访问原范围,或访问超过文件有效末尾的页面。

问题 7:close(fd) 后还能访问映射吗

可以。成功映射后,映射拥有独立的后端引用;关闭描述符不等于解除映射。

问题 8:munmap 后文件数据一定已经落盘吗

不要把 munmap 当成明确的持久化协议。需要同步共享文件映射修改时使用 msync,更强持久化还要结合文件系统和应用协议。

问题 9:为什么 mmap 不一定立即占用与 length 相同的物理内存

因为它首先保留虚拟地址范围,页面通常按需接入;尚未访问或已被回收的页不会都同时驻留。

问题 10:匿名私有映射与文件私有映射有什么共同点

两者都可以使用 MAP_PRIVATE,写入属于当前进程的私有修改;fork 后通常依赖写时复制。区别在于匿名映射没有普通文件作为初始内容来源。

问题 11:为什么共享映射中不适合保存普通指针

不同进程映射地址可以不同,普通指针只对创建它的地址空间有意义。应保存相对偏移或可重定位标识。

问题 12:mmap 适合实现小对象分配吗

直接一对象一映射通常不适合。它更适合为分配器取得大块区域,再由用户态分割和复用。

问题 13:页缓存命中与应用第一次访问矛盾吗

不矛盾。应用第一次访问只表示当前页表尚未建立关系;文件页可能早已被其他访问放入内核页缓存,因此只产生次缺页而不需要磁盘 I/O。

问题 14:MAP_SHARED 能否替代 msync

不能这样比较。MAP_SHARED 定义修改传播语义;msync 用于请求对指定范围进行同步。一个是映射类型,一个是同步操作。

12.3 排查清单

遇到 mmap 失败、数据没更新或程序偶发崩溃时,可以按以下顺序检查:

  • length 是否大于 0?
  • 是否与 MAP_FAILED 比较返回值?
  • 文件 offset 是否按运行时页大小对齐?
  • offset 是否非负?
  • offset + length 的判断是否避免整数溢出?
  • 新文件是否先设置了足够长度?
  • 映射范围是否完全位于当前文件大小内?
  • PROT_WRITEMAP_SHAREDO_RDWR 是否匹配?
  • 是否把 MAP_PRIVATE 误当成可写回文件?
  • 是否把任意文件当以 \0 结尾的字符串?
  • 是否错误地对 base + delta 调用 munmap
  • munmap 的长度是否与底层映射范围对应?
  • 是否在映射解除后继续使用指针或视图?
  • 映射期间是否有其他进程截短或替换文件?
  • 多线程/多进程是否在没有同步协议时并发访问?
  • MAP_SHARED 修改是否需要明确 msync
  • 是否因为高频小映射制造了系统调用和 VMA 开销?
  • 失败路径是否关闭 fd、解除已成功的映射?

12.4 建议练习

练习一:观察 length 的页粒度

分别匿名映射 1、1024、4095、4096、4097 字节,查看 /proc/<pid>/maps,解释逻辑请求长度与虚拟页粒度的差异。

练习二:制造 offset 未对齐

把文件映射偏移设置为 1,记录 mmap 返回值和 errno;再实现向下对齐版本。

练习三:比较 MAP_SHARED 与 MAP_PRIVATE

用两个进程映射同一文件,分别修改首字节,观察另一个进程和磁盘文件看到什么。

练习四:验证 close 不会解除映射

mmap 成功后立刻关闭 fd,再读取映射;随后 munmap,确认生命周期差异。

练习五:复现文件尾 SIGBUS

仅在隔离实验中,让一个进程映射文件,另一个进程执行 truncate -s 0,再访问旧映射。记录信号并解释原因。

练习六:实现十六进制查看器

使用窗口映射读取大文件,每行输出偏移、16 个十六进制字节和可打印字符;正确处理任意起始偏移与文件尾。

练习七:实现 RAII 可写映射

为可写映射封装 move 构造、move 赋值、Flush()data()size(),禁止复制,并验证每条错误路径。

练习八:比较 mmap 与 pread

设计顺序扫描、均匀随机和热点随机三种负载,在冷缓存与热缓存下分别测量吞吐、P99 延迟和缺页次数。

练习九:给匿名分配器增加页复用

一次 mmap 取得较大 arena,再用空闲链表切分小块;实现对齐、释放、相邻空闲块合并,并统计内部/外部碎片。

练习十:实现父子进程共享计数器

fork 前建立 MAP_SHARED | MAP_ANONYMOUS 映射,把进程共享同步对象也放入映射区,验证没有同步和正确同步时的差异。

12.5 参考资料


总结

mmap 最容易被一句"把文件映射到内存"讲得似懂非懂。真正需要建立的是一条完整主线:

text 复制代码
open / 匿名后端
        ↓
确定有效长度与页对齐 offset
        ↓
mmap 建立 VMA 和后端关系
        ↓
首次访问触发缺页,文件页接入页表
        ↓
MAP_SHARED 传播共享修改
MAP_PRIVATE 写时复制为私有页
        ↓
需要时 msync
        ↓
munmap 解除地址区间
        ↓
所有指针与视图失效

最后,把全文压缩成六条必须记住的原则:

  1. mmap 建立映射关系,不代表完整文件已经进入物理内存。
  2. length 可以不是整页,文件 offset 才必须按页对齐。
  3. 失败返回 MAP_FAILED,空文件、文件尾和外部截断必须单独处理。
  4. MAP_SHAREDMAP_PRIVATE 定义写入传播方式,不替你完成并发同步。
  5. closemsyncmunmap 管理不同资源和阶段,不能互相替代。
  6. 匿名 mmap 能提供页级区域,但真正的 malloc 还需要完整的用户态分配策略。

理解这些边界后,文件映射就不再是一段背参数的系统调用,而是一种把虚拟内存、文件系统、页缓存和进程协作连接起来的基础机制。

相关推荐
Zixhy1 小时前
Boost 库实现异步高并发服务器
linux·服务器·c++
阿明61 小时前
基础编辑工具使用【Linux】
linux·运维·服务器
Tinyundg1 小时前
关于Termux的基本使用——安卓手机爆改Linux服务器?
linux·运维·服务器·智能手机·个人开发
鱼很腾apoc1 小时前
【Linux】第15期 讲解IP网络层核心原理与机制
linux·服务器·网络协议·学习·tcp/ip·ip·网络层
zx_741484811 小时前
【Linux入门】常用基本命令(一)——帮助、文件目录操作、时间日期与用户组管理
linux·服务器
AutumnWind04202 小时前
【Shell 知识速查表】
linux
风哥2号2 小时前
数据库教程FGMT04‑生产环境Linux+Oracle19c RAC集群安装配置与项目实战
linux·数据库
qetfw2 小时前
Debian Postfix + Dovecot 基础配置:DNS、Maildir、IMAP 与邮件队列验证
linux·服务器·网络·debian
吴声子夜歌2 小时前
Shell脚本——数组
linux·运维·shell