mmap() 실습: CoW, truncate, fixed address를 따로 확인합니다

mmap()으로 받은 pointer를 읽고 쓸 수 있다는 사실만 확인하면 중요한 조건을 놓치기 쉽습니다. File의 내용이 바뀌는 것과 크기가 줄어드는 것은 다르고, 원하는 virtual address에 mapping하는 것과 남의 mapping을 덮어쓰는 것도 다릅니다. 이 글에서는 작은 mapping 네 개로 그 경계를 확인하겠습니다.

아래 코드는 책의 listing을 복원한 것이 아니라 별도로 작성한 Linux C 예제입니다. 학습 범위는 Linux 4.17 이상, glibc 2.27 이상과 C11 compiler입니다. 이 범위는 MAP_FIXED_NOREPLACE를 포함한 실습 묶음의 기준이며, 모든 API가 그 version에서 처음 도입됐다는 뜻은 아닙니다. API 설명은 Linux man-pages 6.19를 기준으로 확인했습니다.

Anonymous mapping은 한 page만 사용합니다

첫 번째 문제는 file 없이 read/write 영역을 만들고, 초기 내용과 양 끝의 접근 범위를 확인하는 것입니다. Page size를 4096으로 고정하지 않고 sysconf()로 구합니다. 실패 판정도 NULL이 아니라 MAP_FAILED와 비교합니다.

anonymous.c의 전체 코드입니다.

anonymous.c

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

int main(void)
{
    long page = sysconf(_SC_PAGESIZE);
    if (page <= 0) {
        fputs("cannot determine page size\n", stderr);
        return EXIT_FAILURE;
    }
    size_t length = (size_t)page;
    unsigned char *area = mmap(NULL, length, PROT_READ | PROT_WRITE,
                               MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (area == MAP_FAILED) {
        perror("anonymous mmap");
        return EXIT_FAILURE;
    }
    int result = EXIT_SUCCESS;
    for (size_t i = 0; i < length; ++i) {
        if (area[i] != 0) {
            fputs("unexpected initial byte\n", stderr);
            result = EXIT_FAILURE;
            break;
        }
    }
    area[0] = 17;
    area[length - 1] = 29;
    printf("length=%zu first=%u last=%u\n", length,
           (unsigned)area[0], (unsigned)area[length - 1]);
    if (munmap(area, length) == -1) {
        perror("munmap");
        result = EXIT_FAILURE;
    }
    return result;
}

MAP_PRIVATE | MAP_ANONYMOUS로 받은 영역은 초기 byte가 0입니다. 이 프로그램은 그 값을 실제로 읽은 다음 첫 byte와 마지막 byte를 바꿉니다. 이 결과를 곧바로 physical page 개수나 page fault 횟수로 해석하지는 않겠습니다. Allocation의 물리적인 시점까지 측정하는 프로그램은 아니기 때문입니다. mmap(2)

예제 파일이 있는 labs directory에서 다음과 같이 실행하실 수 있습니다.

gcc -std=c11 -Wall -Wextra -Wpedantic -Werror -O2 anonymous.c -o anonymous
./anonymous

아래 실습들은 Linux filesystem에서 실행해 주세요. 이번 검증에서는 Windows drive를 연결한 /mnt/c의 작업 directory에서, 이미 unlink()한 임시 file을 mapping하는 단계가 ENOENT로 실패했습니다. 같은 source를 Linux 자체 /tmp에서 실행했을 때는 뒤의 두 file 실습도 통과했습니다. 이 차이를 모든 host filesystem의 공통 특성으로 일반화하지는 않겠습니다.

Shared write와 private write는 다른 곳에 남습니다

다음 문제는 같은 file을 MAP_SHARED와 MAP_PRIVATE로 각각 mapping한 뒤, 첫 byte를 통해 write의 도착지를 구별하는 것입니다. 빈 file을 그대로 mapping해 접근하지 않도록 먼저 한 page 크기로 늘립니다. 문자열 끝을 찾는 %s 대신 정해진 위치의 byte만 읽습니다.

두 file 실습이 함께 사용하는 file_fixture.h는 임시 file 생성, 크기 설정, descriptor 정리를 담당합니다. 생성 직후 이름을 제거하므로 다른 file을 실수로 덮어쓰지 않습니다. 중간에 실패하더라도 생성한 descriptor와 아직 남은 이름을 cleanup할 수 있도록 상태를 보관합니다.

file_fixture.h

#ifndef LAB_FILE_FIXTURE_H
#define LAB_FILE_FIXTURE_H
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

struct file_fixture {
    int fd;
    int linked;
    size_t length;
    char path[64];
};

static inline int file_fixture_open(struct file_fixture *file, const char *pattern)
{
    file->fd = -1;
    file->linked = 0;
    long page = sysconf(_SC_PAGESIZE);
    if (page <= 0 || strlen(pattern) >= sizeof(file->path)) {
        errno = EINVAL;
        return -1;
    }
    file->length = (size_t)page;
    memcpy(file->path, pattern, strlen(pattern) + 1);
    file->fd = mkstemp(file->path);
    if (file->fd == -1)
        return -1;
    file->linked = 1;
    if (unlink(file->path) == -1)
        return -1;
    file->linked = 0;
    return ftruncate(file->fd, page);
}

static inline int file_fixture_close(struct file_fixture *file)
{
    int result = 0;
    if (file->fd != -1 && close(file->fd) == -1) {
        perror("temporary file close");
        result = -1;
    }
    if (file->linked && unlink(file->path) == -1) {
        perror("temporary file unlink");
        result = -1;
    }
    return result;
}

static inline int file_byte_write(int fd, char byte)
{
    ssize_t count = pwrite(fd, &byte, 1, 0);
    if (count == 1)
        return 0;
    if (count >= 0)
        errno = EIO;
    return -1;
}
#endif

file_cow.c에서는 private mapping에 먼저 P를 써 CoW를 일으킨 다음, shared mapping에는 S를 씁니다. 이어서 descriptor를 통해 같은 file offset에 W를 pwrite()합니다. 어느 단계에도 크기 축소나 O_TRUNC는 없습니다.

file_cow.c

#define _GNU_SOURCE
#include "file_fixture.h"
#include <sys/mman.h>

int main(void)
{
    struct file_fixture file = {.fd = -1};
    int result = EXIT_FAILURE;
    unsigned char *shared = MAP_FAILED, *private = MAP_FAILED;
    if (file_fixture_open(&file, "./mmap-cow-XXXXXX") == -1)
        goto out;
    if (file_byte_write(file.fd, 'A') == -1)
        goto out;
    shared = mmap(NULL, file.length, PROT_READ | PROT_WRITE,
                  MAP_SHARED, file.fd, 0);
    if (shared == MAP_FAILED)
        goto out;
    private = mmap(NULL, file.length, PROT_READ | PROT_WRITE,
                   MAP_PRIVATE, file.fd, 0);
    if (private == MAP_FAILED)
        goto out;
    private[0] = 'P';
    shared[0] = 'S';
    if (msync(shared, file.length, MS_SYNC) == -1)
        goto out;
    unsigned char file_byte = 0;
    ssize_t count = pread(file.fd, &file_byte, 1, 0);
    if (count != 1) {
        if (count >= 0)
            errno = EIO;
        goto out;
    }
    printf("shared write: file=%c private=%c\n", file_byte, private[0]);
    if (file_byte != 'S' || private[0] != 'P') {
        errno = EPROTO;
        goto out;
    }
    /* An in-place overwrite: no O_TRUNC and no size change. */
    if (file_byte_write(file.fd, 'W') == -1)
        goto out;
    printf("pwrite: shared=%c private=%c\n", shared[0], private[0]);
    result = EXIT_SUCCESS;
out:
    if (result != EXIT_SUCCESS)
        perror("file/CoW lab");
    if (private != MAP_FAILED && munmap(private, file.length) == -1) {
        perror("private munmap");
        result = EXIT_FAILURE;
    }
    if (shared != MAP_FAILED && munmap(shared, file.length) == -1) {
        perror("shared munmap");
        result = EXIT_FAILURE;
    }
    if (file_fixture_close(&file) == -1)
        result = EXIT_FAILURE;
    return result;
}
gcc -std=c11 -Wall -Wextra -Wpedantic -Werror -O2 file_cow.c -o file_cow
./file_cow

msync(MS_SYNC)는 shared mapping의 변경을 filesystem에 반영하도록 요청하고 완료를 기다리는 단계입니다. 이것을 두 process의 data 소유권을 정하는 synchronization으로 사용해서는 안 됩니다. 또한 이 실습의 pread()는 file에서 보이는 byte를 확인할 뿐, 전원 장애 후의 durability를 시험하지 않습니다. msync(2)

검증 환경에서 출력은 다음과 같았습니다.

shared write: file=S private=P
pwrite: shared=W private=P

여기서 확인한 사실은 이 Linux 실행에서 이미 CoW가 발생한 private page의 P가 일반적인 in-place overwrite 뒤에도 유지됐다는 것입니다. MAP_PRIVATE를 file 전체의 영구적인 snapshot으로 확대 해석해서는 안 됩니다. File mapping 뒤 외부 file 변경의 가시성은 API에서 일반적인 snapshot 계약으로 보장하지 않습니다. 그래서 두 번째 출력은 관찰값으로 보여 주되 모든 환경의 성공 조건으로 assert하지 않았습니다. mmap(2)

Truncate는 별도 child에서 fault를 확인합니다

Shell의 > file이나 일부 저장 방식에는 내용 overwrite뿐 아니라 truncate가 포함될 수 있습니다. 반면 pwrite()는 기존 크기 안에 쓴다면 크기를 줄이지 않습니다. 따라서 "file을 다시 썼더니 private data가 없어졌다"는 설명에는 실제 사용한 operation이 필요합니다. ftruncate(fd, 0)은 file 길이를 명시적으로 0으로 바꿉니다. truncate(2)

truncate_probe.c는 private page에 P를 쓴 뒤 child를 만듭니다. Child만 file을 0 byte로 줄이고 그 mapping을 읽습니다. Parent는 child의 termination status를 확인하며, truncate 이후의 mapping을 직접 읽지 않습니다.

truncate_probe.c

#define _GNU_SOURCE
#include "file_fixture.h"
#include <signal.h>
#include <sys/mman.h>
#include <sys/resource.h>
#include <sys/wait.h>

int main(void)
{
    struct file_fixture file = {.fd = -1};
    int result = EXIT_FAILURE;
    volatile unsigned char *area = MAP_FAILED;
    if (file_fixture_open(&file, "./mmap-truncate-XXXXXX") == -1)
        goto out;
    area = mmap(NULL, file.length, PROT_READ | PROT_WRITE,
                MAP_PRIVATE, file.fd, 0);
    if (area == MAP_FAILED)
        goto out;
    area[0] = 'P';
    pid_t child = fork();
    if (child == -1)
        goto out;
    if (child == 0) {
        struct rlimit no_core = {0, 0};
        struct sigaction action = {.sa_handler = SIG_DFL};
        sigset_t signals;
        sigemptyset(&action.sa_mask);
        sigemptyset(&signals);
        sigaddset(&signals, SIGBUS);
        if (setrlimit(RLIMIT_CORE, &no_core) == -1 ||
            sigaction(SIGBUS, &action, NULL) == -1 ||
            sigprocmask(SIG_UNBLOCK, &signals, NULL) == -1 ||
            ftruncate(file.fd, 0) == -1)
            _exit(2);
        unsigned char byte = area[0];
        (void)byte;
        _exit(3);
    }
    int status;
    pid_t waited;
    do {
        waited = waitpid(child, &status, 0);
    } while (waited == -1 && errno == EINTR);
    if (waited == -1)
        goto out;
    if (WIFSIGNALED(status) && WTERMSIG(status) == SIGBUS) {
        puts("child received SIGBUS after truncate-to-zero");
        result = EXIT_SUCCESS;
    } else {
        errno = EPROTO;
    }
out:
    if (result != EXIT_SUCCESS)
        perror("truncate probe");
    if (area != MAP_FAILED && munmap((void *)area, file.length) == -1) {
        perror("munmap");
        result = EXIT_FAILURE;
    }
    if (file_fixture_close(&file) == -1)
        result = EXIT_FAILURE;
    return result;
}
gcc -std=c11 -Wall -Wextra -Wpedantic -Werror -O2 truncate_probe.c -o truncate_probe
./truncate_probe

검증에서는 child received SIGBUS after truncate-to-zero가 출력됐고, parent는 성공으로 종료했습니다. 여기서 성공은 child의 접근이 안전했다는 뜻이 아니라, 의도한 fault를 parent가 확인했다는 뜻입니다. Child의 core dump는 끄고, 상속됐을 수 있는 SIGBUS disposition과 signal mask도 이 시험에 맞게 설정했습니다. Code의 volatile은 해당 load가 제거되지 않게 하기 위한 것이며 IPC synchronization 수단이 아닙니다.

이 실험은 한 page였던 file을 0 byte로 줄인 조건에 한정됩니다. File 끝의 partial page, truncate 후 재확장, file 교체까지 동일한 결과로 묶지는 않습니다. File을 mapping하는 동안 다른 writer가 크기를 바꿀 수 있다면 application 차원에서 그 operation을 제한해야 합니다.

Fixed address는 직접 확보한 arena 안에서만 사용합니다

마지막 문제는 virtual address를 임의의 상수로 정하는 것이 아닙니다. Kernel이 선택해 준 세 page의 arena를 먼저 소유하고, 가운데 page만 writable mapping으로 바꾸겠습니다. 양옆의 PROT_NONE page에는 접근하지 않습니다. 접근 권한이 없는 영역은 정상적인 작업 buffer가 아닙니다. mprotect(2)

MAP_FIXED는 겹치는 기존 mapping을 교체하므로 주소 공간을 손상시킬 수 있습니다. 아래 예제의 안전 조건은 arena를 계속 예약한 채, 그 내부의 page-aligned slot만 교체한다는 것입니다. 예약을 munmap()한 뒤 같은 주소를 다시 얻으려는 틈을 만들지 않습니다. 이 단일 thread 프로그램 외의 코드가 arena를 해제하지 않는다는 전제도 필요합니다. mmap(2)

fixed_arena.c

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

int main(void)
{
    long page = sysconf(_SC_PAGESIZE);
    if (page <= 0) {
        fputs("cannot determine page size\n", stderr);
        return EXIT_FAILURE;
    }
    size_t unit = (size_t)page, length = 3 * unit;
    unsigned char *arena = mmap(NULL, length, PROT_NONE,
                                MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (arena == MAP_FAILED) {
        perror("reserve arena");
        return EXIT_FAILURE;
    }
    int result = EXIT_FAILURE;
    unsigned char *slot = arena + unit;
    errno = 0;
    void *probe = mmap(slot, unit, PROT_READ | PROT_WRITE,
                       MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED_NOREPLACE,
                       -1, 0);
    if (probe != MAP_FAILED) {
        /* Older kernels can ignore NOREPLACE and return another address. */
        if (probe != slot && munmap(probe, unit) == -1)
            perror("unmap unexpected result");
        fputs("expected occupied-address rejection\n", stderr);
        goto out;
    }
    if (errno != EEXIST) {
        perror("MAP_FIXED_NOREPLACE");
        goto out;
    }
    puts("NOREPLACE rejected the occupied slot");
    /* Only replace a page inside our still-reserved, private arena. */
    void *mapped = mmap(slot, unit, PROT_READ | PROT_WRITE,
                        MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, -1, 0);
    if (mapped == MAP_FAILED) {
        perror("replace owned slot");
        goto out;
    }
    if (mapped != slot) {
        fputs("unexpected fixed mapping address\n", stderr);
        if (munmap(mapped, unit) == -1)
            perror("unmap unexpected fixed result");
        goto out;
    }
    slot[0] = 53;
    printf("owned middle page: %u\n", (unsigned)slot[0]);
    result = EXIT_SUCCESS;
out:
    if (munmap(arena, length) == -1) {
        perror("release arena");
        result = EXIT_FAILURE;
    }
    return result;
}
gcc -std=c11 -Wall -Wextra -Wpedantic -Werror -O2 fixed_arena.c -o fixed_arena
./fixed_arena

먼저 MAP_FIXED_NOREPLACE로 이미 예약된 가운데 page를 요청합니다. 검증에서는 EEXIST로 거절됐습니다. 다음 MAP_FIXED는 그 slot만 교체했고, owned middle page: 53을 출력했습니다. NOREPLACE는 예약된 공간 내부를 활성화하는 flag가 아니라 기존 mapping과의 충돌을 거절하는 flag라는 차이가 드러납니다. 오래된 kernel이 flag를 무시하고 다른 주소를 반환할 가능성에도 대비해 반환 주소를 검사하고, 그런 결과는 실패로 처리합니다.

마지막 munmap(arena, length)는 예약 page와 교체 page를 포함한 전체 소유 범위를 해제합니다. 대규모 virtual reservation, allocator의 thread safety, overcommit 정책의 한계까지 검증한 예제는 아닙니다.

확인한 범위

2026-11-03에 x86-64 WSL2 Linux 6.18.33.2-microsoft-standard-WSL2, GCC 15.2.0, glibc 2.43에서 네 프로그램을 compile하고 Linux 자체 /tmp에서 실행했습니다. 위 출력은 그 실행의 관찰값입니다. 다른 architecture, Linux 4.17의 실제 machine, 다른 libc, network filesystem과 전원 장애 상황은 미검증입니다. Error 경로는 획득한 mapping을 해제하고 descriptor를 닫지만, 모든 system call의 강제 실패를 주입한 것은 아닙니다.

참고 자료

  • Lorenzo Stoakes, The Linux Memory Manager, Early Access edition, 2025, §14.3. 학습 문제의 출발점이며 위 코드는 독립 예제입니다.
  • Linux man-pages 6.19, mmap(2), msync(2), truncate(2), mprotect(2). 2026-11-03 열람.