一篇围绕 Unix 用户态内存接口展开的学习笔记。


内存基础 中已从操作系统视角讨论分页、页表、TLB、交换和内存映射。本篇将这些概念落到用户态最常用的内存接口上,分为两部分:

  1. 系统调用级内存接口:mmapmunmapmprotectmsync
  2. 用户态库提供的动态内存分配:malloccallocreallocfree

前者直接改变进程地址空间,后者由 libc 分配器在用户态管理堆块。

内存API概述

1. 进程地址空间

从高地址到低地址,进程地址空间常见的组成如下:

区域作用
内核空间内核映射和特权地址范围,用户态不可直接访问
函数调用现场、局部变量
映射空间mmap 建立的文件映射、匿名映射、共享库等
用户态分配器主要管理的动态内存区域
数据区全局变量、静态变量
代码区程序指令和只读常量

这和本篇的接口正好对应:

接口操作对象
mmap / munmap / mprotect / msync映射空间
brk / sbrk堆(heap
malloc / calloc / realloc / free堆 / 映射空间

2. 系统调用与用户库函数

层级代表接口操作对象备注
系统调用mmapmunmapmprotectmsync映射空间-
系统调用brksbrk用于改变堆大小
用户库函数malloccallocreallocfree分配器维护的内存块在用户态完成块管理

共享内存相关系统调用参见 POSIX进程间通信

系统调用

1. mmap

mmap 用来在当前进程地址空间建立一段映射:

c
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);

参数与返回值如下:

含义
addr期望映射到的起始地址;通常传 NULL,由内核决定
length映射长度,单位为字节
prot访问权限,如 PROT_READPROT_WRITEPROT_EXECPROT_NONE
flags映射类型和附加行为,如 MAP_SHAREDMAP_PRIVATEMAP_ANONYMOUS
fd文件描述符;匿名映射时通常传 -1
offset从文件哪个偏移开始映射;通常要求按页对齐
成功返回映射区起始地址
失败返回MAP_FAILED,并设置 errno

mmap 的核心语义是:在当前进程地址空间建立一段新的映射区,并把它绑定到文件、共享内存对象或匿名页。

MAP_SHARED、MAP_PRIVATE 与 MAP_ANONYMOUS

mmap 最关键的三个标志如下:

标志含义典型效果
MAP_SHARED共享映射修改对共享同一映射对象的进程可见,并可同步回后备文件
MAP_PRIVATE私有映射写入采用写时复制,对其他进程和底层文件不可见
MAP_ANONYMOUS匿名映射映射不绑定普通文件,通常与 fd = -1 一起使用

三者关系实际上是两个层级:

标志说明
MAP_SHARED / MAP_PRIVATE映射共享/私有
MAP_ANONYMOUS映射是否有普通文件后备对象

因此:

组合含义
MAP_SHARED + 文件fd共享文件映射
MAP_PRIVATE + 文件fd私有文件映射
MAP_PRIVATE | MAP_ANONYMOUS私有匿名映射,是最常见的匿名映射写法

2. munmap

munmap 用来撤销映射:

c
#include <sys/mman.h>
int munmap(void *addr, size_t length);

参数与返回值如下:

含义
addr要解除映射的起始地址
length解除映射的长度
成功返回0
失败返回-1,并设置 errno

munmapmmap 都对应内核地址空间操作。调用成功后,这段内存映射会立刻从当前进程地址空间移除;继续访问该地址会再次陷入内核,并通常以访问异常结束。

3. mprotect

mprotect 用来修改一段已有映射的访问权限:

c
#include <sys/mman.h>
int mprotect(void *addr, size_t len, int prot);

参数与返回值如下:

含义
addr目标地址区间起始地址
len目标区间长度
prot新权限,如 PROT_READPROT_WRITEPROT_EXECPROT_NONE
成功返回0
失败返回-1,并设置 errno

常见用途如下:

场景用途
代码页控制区分可写和可执行
保护页把某页设为 PROT_NONE,用于捕获越界
分阶段初始化先写入数据,再改成只读

4. msync

msync 只对文件后备的共享映射有意义,用来将映射区的数据写入到磁盘:

c
#include <sys/mman.h>
int msync(void *addr, size_t length, int flags);

参数与返回值如下:

含义
addr目标映射区起始地址
length需要同步的字节数
flags同步方式,如 MS_SYNCMS_ASYNCMS_INVALIDATE
成功返回0
失败返回-1,并设置 errno

flags:

标志含义
MS_ASYNC调度所有写操作完毕立刻返回,异步写入
MS_SYNC写操作完毕返回,同步写入
MS_INVALIDATE使其他映射失效*,并在下次读取前更新

若映射是MAP_ANONYMOUSMAP_PRIVATE,那么msync是无意义的,前者无后备文件,后者不会写入后备文件。

5. brk 与 sbrk

brksbrk 改变 program break 的位置。program break 是堆的末端(堆顶),因此这两个接口实际上改变持程序的堆内存大小。

c
#include <stdint.h>
#include <unistd.h>
int brk(void *addr);
void *sbrk(intptr_t increment);

参数与返回值如下:

接口作用成功返回失败返回
brk把 program break 设到指定位置0-1,并设置 errno
sbrk按增量移动 program break旧的 break 地址(void *)-1,并设置 errno

6. 示例:匿名映射

以下示例介绍了匿名映射的基本使用方法:

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

int main(void) {
    size_t len = 4096;
    int *buf = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (buf == MAP_FAILED) {
        perror("mmap");
        return 1;
    }

    for (int i = 0; i < 8; ++i) {
        buf[i] = i * i;
    }

    for (int i = 0; i < 8; ++i) {
        printf("%d\n", buf[i]);
    }

    if (munmap(buf, len) != 0) {
        perror("munmap");
        return 1;
    }
    return 0;
}

这个例子里没有普通文件对象,mmap 只是向内核申请了一段匿名页,并把它映射到当前进程地址空间。

7. 示例:文件映射

以下示例介绍了文件映射的基本使用方法:

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

int main(void) {
    int fd = open("data.txt", O_RDONLY);
    if (fd < 0) {
        perror("open");
        return 1;
    }

    struct stat st;
    if (fstat(fd, &st) != 0) {
        perror("fstat");
        close(fd);
        return 1;
    }

    char *p = mmap(NULL, st.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
    if (p == MAP_FAILED) {
        perror("mmap");
        close(fd);
        return 1;
    }

    write(STDOUT_FILENO, p, st.st_size);

    munmap(p, st.st_size);
    close(fd);
    return 0;
}

它和 read 的区别在于:程序不再显式把文件内容复制到用户缓冲区,而是直接把文件页纳入地址空间,再按普通内存读取。

用户库函数

1. malloc 与 free

最常见的一组动态分配接口如下:

c
#include <stdlib.h>
void *malloc(size_t size);
void free(void *ptr);

malloc 的参数与返回值如下:

含义
size申请的字节数
成功返回指向可用内存块的指针
失败返回NULL

free 的参数与返回值如下:

含义
ptr之前由分配器返回的指针
返回值

其语义如下:malloc 从分配器维护的空闲块集合里找出一块合适的内存返回给程序,free 再把这块内存交还给分配器。

这里需要明确一点:free 的目标是把块归还给分配器,而不是保证立刻归还给内核。

2. calloc 与 realloc

另外两个常见接口是 callocrealloc

c
#include <stdlib.h>
void *calloc(size_t nmemb, size_t size);
void *realloc(void *ptr, size_t size);

calloc一般用于分配并初始化数组,其参数与返回值如下:

含义
nmemb元素个数
size每个元素大小
成功返回指向一块零填充内存的指针
失败返回NULL

realloc 用于改变分配内存大小,其参数与返回值如下:

含义
ptr旧指针;也可传 NULL
size新大小
成功返回指向新内存块的指针,可能与原地址相同,也可能不同
失败返回NULL;失败时旧指针仍然有效

3. 分配器如何管理块

从程序员视角看,malloc 只是返回一个指针;从分配器视角看,它维护的是一组块(chunk)。

最常见的组织方式如下:

text
+---------+----------------------+
| header  | user payload         |
+---------+----------------------+
          ^
          malloc 返回给程序的位置

程序真正拿到的是 payload,而分配器通常会在前面放置元数据。常见元数据包括:

元数据作用
块大小确定该块覆盖范围
使用状态区分已分配块和空闲块
链接信息当块空闲时,挂到空闲链表或 bin 上

分配器的动作通常只有三类:

动作含义
分裂 split大空闲块切成“已分配部分 + 剩余空闲部分”
合并 coalesce相邻空闲块重新合并,减少碎片
复用 reuse新申请优先复用已有空闲块,而不是立刻向内核要内存

因此,malloc / free 的主要工作是在用户态先维护一套更细粒度的块管理。

4. 示例:动态扩容缓冲区

以下示例展示了 malloc + realloc 的用法:

c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void) {
    size_t cap = 8;
    char *buf = malloc(cap);
    if (buf == NULL) {
        perror("malloc");
        return 1;
    }

    strcpy(buf, "unix");

    cap *= 2;
    char *new_buf = realloc(buf, cap);
    if (new_buf == NULL) {
        free(buf);
        perror("realloc");
        return 1;
    }
    buf = new_buf;

    strcat(buf, "-memory");
    puts(buf);

    free(buf);
    return 0;
}

相互关系

当用户态分配器手里没有足够空闲块时,才需要向内核扩张可管理的地址空间。常见路径如下:

路径代表接口对象特点
扩张数据段brksbrk进程堆顶端线性扩张堆内存
新建映射区mmap独立映射区易于回收

malloc 不是系统调用,但它在必要时会借助更底层的地址空间接口向内核申请新区域。传统堆扩张通常对应 brk/sbrk ;而较大的独立区域会使用 mmap。如下:

text
malloc/free

用户态分配器:维护 chunk、空闲链表、分裂/合并

必要时向内核申请更多地址空间

brk/sbrk 或 mmap

mmap 是系统调用,执行时会陷入内核,开销远远大于malloc ,因此更适合如下几类场景:

场景原因
文件映射mmap可映射文件
共享内存共享内存暴露的接口类似于文件
大块独立区域希望和普通堆分离,便于独立回收
权限控制需要 mprotect 调整读写执行权限

附注

1. msyncflags

在man手册中,对msync及其flags描述如下:

text
msync() flushes changes made to the in-core copy of a file that was mapped into memory using mmap(2) back to disk.
Without use of this call there is no guarantee that changes are written back before munmap(2) is called. 
To be more precise, the part of the file that corresponds to the memory area starting at addr and having length lengthis updated.

The flags argument may have the bits MS_ASYNC, MS_SYNC, and MS_INVALIDATEset, 
but not both MS_ASYNC and MS_SYNC. MS_ASYNC specifies that an update be scheduled, 
but the call returns immediately. MS_SYNC asks for an update and waits for it to complete. 
MS_INVALIDATE asks to invalidate other mappings of the same file (so that they can be updated with the fresh values just written).

即:MS_ASYNCMS_SYNC指定msync调用与实际写入磁盘的时序关系;MS_INVALIDATE 把同一个文件的其他内存映射标记为“无效”,这样它们下次访问时会重新从文件中读取最新的数据。

MS_SYNC | MS_INVALIDATEMS_ASYNC | MS_INVALIDATE是合法的,但MS_ASYNC | MS_SYNC是非法的。