부록 P — 기억을 다스리는 장치들
malloc 이 주소 하나를 돌려준다. 그 주소는 무엇의 주소인가?
이 책은 여기까지 오는 동안 기억을 여러 번 다루었다. 프로그램이 나뉘어 앉는 구역 (5장), 레지스터에서 주기억까지의 사다리(12장), 운영체제가 정해 주는 배치(88장), 그리고 할당자의 속 (89장). 그런데 그 이야기들은 모두 C 쪽이거나 결과였다. 구역을 강제하는 것, 사다리 맨 아래에서 주소를 번역하는 것, 배치를 지키는 것 — 그 일을 하는 기계 장치는 한 번도 정면으로 나오지 않았다.
이 부록이 그 장치를 연다. 그리고 이 책의 규율대로, 말한 것을 이 기계에서 재어 보인다.
먼저 낱말 하나를 정해 두자. 이 부록에서 주소 번역(address translation)이라고 하면, 「프로그램이 말한 주소」와 「기억 칸에 실제로 붙은 번호」가 다르고, 그 사이에서 앞의 것을 뒤의 것으로 바꾸어 주는 장치가 있다는 뜻이다. 글을 옮기는 번역이 아니다. 번역이 있는 기계에서 p = 0x1000 은 「1000번 칸」이 아니라 「지금 이 프로그램에게 1000번으로 보이기로 한 어딘가」다. 이 하나가 있고 없고로 기계가 갈린다.
플랫폼 노트. 이 부록의 근거와 한계
개념의 권위는 각 아키텍처의 명세다 — 쪽 표는 각 ISA(instruction set architecture)의 특권 명세, MPU 는 Arm 의 PMSA, 이름 붙은 주소 공간은 ISO/IEC TR 18037 과 각 컴파일러 문서.
실측은 이 기계 하나에서 잰 것이다. x86-64 리눅스이고 쪽은 4 KiB 다. 예제 넷은 POSIX 를 쓰고(mmap·fork·/proc), 다섯째는 AVR 교차 컴파일러로 짓는다. 표준 C 만으로는 이 부록의 어느 것도 볼 수 없다 — 볼 수 없다는 사실 자체가 이 부록의 논지의 절반이다.
POSIX 예제 넷은 aarch64 로도 교차 컴파일해 에뮬레이터(qemu 사용자 모드)에서 돌려 보았다. 넷 다 서고 끝까지 돌며, 약속과 도착(promise)·쪽 크기와 겹쳐 매핑·가드 쪽·권한 집계 (layout)의 결론은 같았다. 다만 이 에뮬레이터는 시스템 호출을 호스트의 x86-64 커널에 넘기므로, 첫 접촉의 시간이나 fork 뒤 공유·사유의 집계 같은 커널 쪽 수는 ARM 의 증거가 못 된다.
안드로이드 폰(ARM 실기)에서는 firsttouch 와 layout 이 같은 결론을 냈다 — 첫 접촉 약 1,140 나노초에 다시 쓰기 약 39 나노초, 그리고 겹쳐 매핑·가드 쪽·「쓰기와 실행을 함께」 요청의 허락까지 같았다. cow 는 폰의 clang 이 512 MB 채우기를 지워 0 MB 가 나왔고(시연을 고쳤다), 고친 판은 폰에서 아직 다시 돌리지 않았다. promise 는 8 GB 에 못 미치는 폰의 기억으로는 돌릴 수 없었다.
세 갈래의 기계#
기억을 다스리는 방식은 크게 셋이다. 이 표가 이 부록의 뼈대이고, 나머지 절이 칸을 채운다.
| 주소 번역이 있다 (MMU, memory management unit) | 보호만 있다(MPU) | 맨 주소 | |
|---|---|---|---|
| 포인터가 가리키는 것 | 가상 주소 | 물리 주소 | 물리 주소 |
| 같은 주소, 다른 프로그램 | 서로 다른 곳 | 같은 곳 | 같은 곳 |
| 권한을 거는 단위 | 쪽(page, 보통 4 KiB) | 구역(region, 여덟 남짓) | 없다 |
fork | 된다(복사-후-쓰기) | 안 된다 | 안 된다 |
| 경계를 넘겨 쓰면 | 사상된 자리를 벗어나야 쪽 오류. 같은 쪽 안이면 조용하다 | 구역 밖이면 오류 | 아무 일도 안 일어난다 |
| 어디서 만나나 | PC·서버·스마트폰 | Cortex-M 계열, RTOS(real-time operating system) | 8비트 MCU, 부팅 초기 |
표 105.1 — 기억을 다스리는 세 갈래
★ 마지막 두 줄이 이 표의 알맹이다. 54장 이 「가장 나쁜 결과는 아무 일도 안 일어나는 것」이라고 말했는데, 그것이 여기서는 하드웨어의 성질로 나온다. 맨 주소 기계에서 배열을 넘겨 쓰면 옆 변수가 조용히 바뀐다. 잡아 줄 장치가 없어서다.
다만 오해하지 말 것 — MMU 가 있다고 넘겨 쓰기를 잡아 주는 것은 아니다. 그물코가 쪽(4 KiB)이라, 같은 쪽 안에서 옆 변수를 밟는 일은 번역이 있는 기계에서도 조용히 벌어진다. 뒤에 나올 예제가 그 증거다 — 권한을 거둔 쪽에 닿기까지 8192바이트를 아무 일 없이 넘겨 쓴다. MMU 가 거는 것은 쪽 단위의 그물이지 변수의 울타리가 아니다.
주소 번역의 구조 — MMU 가 실제로 하는 일#
주소 번역이 있는 기계에서 포인터의 값은 가상 주소다. 그것을 물리 주소로 바꾸는 표가 따로 있고, 그 표는 한 층이 아니라 여러 층이다. 주소를 몇 조각으로 잘라 층마다 하나씩 따라 내려간다.
| 방식 | 층 | 가상 주소 폭 | 쓸 수 있는 쪽 크기 |
|---|---|---|---|
| Sv39 | 3 | 39비트 | 4 KiB · 2 MiB · 1 GiB |
| Sv48 | 4 | 48비트 | 4 KiB · 2 MiB · 1 GiB · 512 GiB |
표 105.2 — 쪽 표의 층과 쪽 크기 — RISC-V 의 두 방식
층이 셋이면 번역 한 번에 기억을 세 번 읽어야 한다. 그 값을 줄이려고 최근 번역 결과를 담아 두는 것이 TLB(translation lookaside buffer)이고, TLB 를 넘어서면 무슨 일이 벌어지는지는 부록 O 가 이미 재어 두었다 — 여기서는 다시 재지 않는다. 큰 쪽(2 MiB)을 쓰는 이유도 이 표에서 나온다. 같은 넓이를 덮는 데 표 항목이 512분의 1 이면 TLB 도 그만큼 덜 쓴다.
표의 각 항목에는 주소만이 아니라 권한이 함께 적힌다. 읽기·쓰기·실행이 쪽마다 따로 걸리고, 그래서 프로그램의 코드는 읽기와 실행만, 자료는 읽기와 쓰기만 얻는다. 아래 예제가 이 프로그램에 지금 걸려 있는 사상(寫像, mapping)들을 훑어 그것을 세어 보인다.
그래서 벌어지는 세 가지#
하나 — 할당은 약속이다#
8 GiB 를 달라고 하면 8 GiB 가 온다고 생각하기 쉽다. 재어 보면 그렇지 않다.
examples/apx-memory/promise/promise.c
/* 할당은 약속이다 --- 8 GiB 를 빌려도, 만지기 전에는 기억이 오지 않는다.
VmSize 는 *배치도에 그은 넓이*이고 VmRSS 는 *실제로 붙은 쪽*이다. 이 둘이
갈라지는 것이 번역이 있는 기계의 성격이다. */
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
static long field_kb(const char *key)
{
FILE *f = fopen("/proc/self/status", "r");
char line[256], pat[64];
long v = -1;
if (!f)
return -1;
snprintf(pat, sizeof pat, "%s %%ld", key);
while (fgets(line, sizeof line, f))
if (sscanf(line, pat, &v) == 1)
break;
fclose(f);
return v;
}
static void show(const char *key, const char *when)
{
long vsz = field_kb("VmSize:") / 1024, rss = field_kb("VmRSS:") / 1024;
printf(" %-24s map %6ld MB real %6ld MB\n", when, vsz, rss);
printf("#DATA %s %ld %ld\n", key, vsz, rss);
}
/* ★ 버퍼를 *바깥에서도 보이는 기억*으로 만든다. 지역 포인터로만 쥐고 있으면 컴파일러는
「아무도 읽지 않는 기억」으로 보고 malloc 과 쪽마다의 쓰기를 통째로 지운다 --- Clang 22 는 그랬고
8 GiB 약속이 아예 사라졌다. 이 기계의 GCC 14 에서만 우연히 살아 있었다(폰에서 드러났다).
volatile 전역에 한 번 담으면, 바깥 호출(fopen)이 그 기억을 볼 수 있다고 가정해야 한다. */
static char *volatile held;
int main(void)
{
const size_t GIB = 1024u * 1024u * 1024u;
const size_t want = 8 * GIB;
printf("== a promise of %zu MB ==\n\n", want / 1024 / 1024);
printf(" %-24s %-13s %s\n", "", "address space", "memory that arrived");
printf("#DATA-BEGIN\n");
show("before", "before the request");
char *p = malloc(want);
held = p;
if (!p) {
printf("#DATA-END\n");
puts("\n * this machine refused the promise; the point below still holds.");
return 0;
}
show("malloc", "right after malloc");
/* 쪽마다 한 바이트씩 --- 쪽 하나를 붙이는 데 접촉 한 번이면 된다. */
for (size_t i = 0; i < want; i += 4096)
p[i] = 1;
show("touched", "after touching it all");
free(p);
show("freed", "after free");
printf("#DATA-END\n");
puts("\n * malloc drew a map; it did not fetch memory. The memory arrived one page");
puts(" at a time, as the program touched it.");
puts(" * a block this large gets a mapping of its own, so free hands the whole map");
puts(" back. A small block does not: it returns to the heap, and the address");
puts(" space stays as wide as it was.");
return 0;
}
실행 결과
== a promise of 8192 MB ==
address space memory that arrived
before the request map 2 MB real 1 MB
right after malloc map 8194 MB real 1 MB
after touching it all map 8194 MB real 8193 MB
after free map 2 MB real 1 MB
* malloc drew a map; it did not fetch memory. The memory arrived one page
at a time, as the program touched it.
* a block this large gets a mapping of its own, so free hands the whole map
back. A small block does not: it returns to the heap, and the address
space stays as wide as it was.
주소 공간에 넓이를 그었을 뿐, 기억은 오지 않았다. 8 GiB 를 요청한 직후에도 실제로 붙은 것은 1 MB 다. 이것이 게으른 할당이고, 이 성질 위에 서 있는 관용구가 많다 — 큰 배열을 넉넉히 잡아 두고 쓰는 만큼만 만지는 방식이 대표적이다.
흔한 오해. 메모리를 많이 잡으면 그만큼 느려진다
둘 — 그 약속의 값은 나중에 치른다#
공짜가 아니라 미룬 것이다. 첫 접촉에서 청구서가 온다.
examples/apx-memory/firsttouch/firsttouch.c
/* 약속의 값은 나중에 치른다 --- 쪽을 처음 만지는 접촉과 두 번째 접촉을 견준다.
첫 접촉에서 벌어지는 일: 번역이 없어 오류가 나고, 커널이 쪽 하나를 찾아
0 으로 채우고, 쪽 표에 적고, 돌아온다. 두 번째 접촉은 그냥 쓰기다. */
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <sys/mman.h>
static double ns(void)
{
struct timespec t;
clock_gettime(CLOCK_MONOTONIC, &t);
return (double)t.tv_sec * 1e9 + (double)t.tv_nsec;
}
static int cmp_d(const void *a, const void *b)
{
double x = *(const double *)a, y = *(const double *)b;
return x < y ? -1 : x > y;
}
int main(void)
{
const size_t PAGE = 4096, PAGES = 64u * 1024; /* 256 MiB */
const int R = 5;
double first[5], again[5];
printf("== what a page costs the first time it is touched ==\n\n");
printf("#DATA-BEGIN\n");
for (int r = 0; r < R; r++) {
char *p = mmap(NULL, PAGE * PAGES, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (p == MAP_FAILED) {
puts("mmap refused; this machine cannot run the experiment.");
return 0;
}
double t0 = ns();
for (size_t i = 0; i < PAGES; i++) p[i * PAGE] = 1;
double t1 = ns();
for (size_t i = 0; i < PAGES; i++) p[i * PAGE] = 2;
double t2 = ns();
first[r] = (t1 - t0) / (double)PAGES;
again[r] = (t2 - t1) / (double)PAGES;
munmap(p, PAGE * PAGES);
}
qsort(first, R, sizeof *first, cmp_d);
qsort(again, R, sizeof *again, cmp_d);
printf(" %-16s %10.1f ns per page\n", "first touch", first[R / 2]);
printf(" %-16s %10.1f ns per page\n", "touched again", again[R / 2]);
printf(" %-16s %10.1f x\n", "factor", first[R / 2] / again[R / 2]);
printf("#DATA first %.1f\n", first[R / 2]);
printf("#DATA again %.1f\n", again[R / 2]);
printf("#DATA factor %.1f\n", first[R / 2] / again[R / 2]);
printf("#DATA-END\n");
puts("\n * the second write is a write. The first one is a fault, a page, a zeroing");
puts(" and an entry in the page table -- the bill for the memory that was");
puts(" promised earlier and not delivered until now.");
return 0;
}
실행 결과
== what a page costs the first time it is touched ==
first touch 1089.2 ns per page
touched again 8.8 ns per page
factor 123.1 x
* the second write is a write. The first one is a fault, a page, a zeroing
and an entry in the page table -- the bill for the memory that was
promised earlier and not delivered until now.
같은 줄의 같은 쓰기인데 처음과 두 번째가 100배 넘게 차이 난다. 처음에는 그 쪽의 번역이 아직 없어 오류가 나고, 커널이 쪽을 하나 찾아 0 으로 채우고, 표에 적고, 돌아온다. 두 번째는 그냥 쓰기다.
★ 그래서 시간을 재는 프로그램은 재기 전에 쪽을 미리 만져 둔다. 안 그러면 재려던 것이 아니라 쪽 오류를 재게 된다. 부록 O 의 예제들이 실제로 그렇게 데운다.
셋 — 복사하지 않는 복사#
주소 번역이 있으면 「같은 것을 둘이 나누어 보다가, 고치는 쪽만 제 것을 갖는」 재주를 부릴 수 있다. 복사-후-쓰기(copy-on-write)다.
examples/apx-memory/cow/cow.c
/* 복사-후-쓰기를 눈으로 본다 --- fork 는 512 MiB 를 복사하지 않는다.
세는 것은 RSS 가 아니라 *사유 더러운 쪽*(Private_Dirty)이다. RSS 는 공유하는
쪽까지 세기 때문에, 나누어 쓰는 중인지 갈라선 뒤인지를 구별하지 못한다. */
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
/* ★ smaps_rollup 은 커널 4.14 부터다. 읽을 수 없으면 영역마다 적힌 smaps 를 더한다.
둘 다 못 읽으면 -1 --- 예전에는 그 -1 을 1024 로 나눠 「0 MB」로 찍었다(폰에서 실제로). */
static long roll_kb(const char *key)
{
char line[256], pat[64];
long v, sum = -1;
snprintf(pat, sizeof pat, "%s %%ld", key);
FILE *f = fopen("/proc/self/smaps_rollup", "r");
if (f) {
while (fgets(line, sizeof line, f))
if (sscanf(line, pat, &v) == 1) { sum = v; break; }
fclose(f);
if (sum >= 0)
return sum;
}
f = fopen("/proc/self/smaps", "r");
if (!f)
return -1;
while (fgets(line, sizeof line, f))
if (sscanf(line, pat, &v) == 1)
sum = (sum < 0 ? 0 : sum) + v;
fclose(f);
return sum;
}
static void show(const char *key, const char *who)
{
long sh_kb = roll_kb("Shared_Dirty:"), pr_kb = roll_kb("Private_Dirty:");
if (sh_kb < 0 || pr_kb < 0) {
printf(" %-28s (unavailable: this system does not let the program read /proc/self/smaps)\n", who);
printf("#DATA %s -1 -1\n", key);
return;
}
long sh = sh_kb / 1024, pr = pr_kb / 1024;
printf(" %-28s shared %5ld MB private %5ld MB\n", who, sh, pr);
printf("#DATA %s %ld %ld\n", key, sh, pr);
}
/* ★ 버퍼를 *바깥에서도 보이는 기억*으로 만든다. 지역 포인터로만 쥐고 있으면 컴파일러는
「아무도 읽지 않는 기억」으로 보고 malloc·memset·쓰기를 통째로 지운다 --- Clang 22 는 그랬고
GCC 16 은 부모의 채우기를 지웠다. 이 기계의 GCC 14 에서만 우연히 살아 있었다(폰에서 드러났다).
volatile 전역에 한 번 담으면, 바깥 호출(fopen·fork)이 그 기억을 볼 수 있다고 가정해야 한다. */
static char *volatile held;
int main(void)
{
const size_t N = 512u * 1024 * 1024;
char *p = malloc(N);
held = p;
long sum = 0;
if (!p) { puts("not enough memory for the experiment."); return 0; }
memset(p, 7, N);
printf("== what fork copies ==\n\n");
printf("#DATA-BEGIN\n");
show("parent0", "parent, 512 MB filled");
fflush(stdout); /* ★ 자식이 부모의 버퍼를 물려받지 않도록 */
pid_t kid = fork();
if (kid == 0) {
show("child0", " child, just forked");
for (size_t i = 0; i < N; i += 4096) sum += p[i];
show("child_read", " child, read every page");
for (size_t i = 0; i < N; i += 4096) p[i] = 9;
show("child_wrote", " child, wrote every page");
(void)sum;
fflush(stdout);
_exit(0);
}
wait(NULL);
show("parent1", "parent, after the child");
printf("#DATA-END\n");
puts("\n * forking cost nothing, and reading cost nothing. Writing cost everything.");
puts(" * the pages were shared until the moment one side changed them: that is the");
puts(" whole of copy-on-write, and it needs a machine that can translate.");
free(p);
return 0;
}
실행 결과
== what fork copies ==
parent, 512 MB filled shared 0 MB private 512 MB
child, just forked shared 512 MB private 0 MB
child, read every page shared 512 MB private 0 MB
child, wrote every page shared 0 MB private 512 MB
parent, after the child shared 0 MB private 512 MB
* forking cost nothing, and reading cost nothing. Writing cost everything.
* the pages were shared until the moment one side changed them: that is the
whole of copy-on-write, and it needs a machine that can translate.
512 MB 를 채운 뒤 갈라졌는데, 자식이 제 몫으로 가진 쪽은 0 이다. 전부 읽어도 0 이다. 전부 쓰고 나서야 512 MB 가 된다. fork 가 싼 이유가 이것이고, 이 재주는 주소를 번역하지 않는 기계에서는 부릴 수 없다 — 뒤에서 그 결과를 본다.
주소 공간을 부려 쓰는 재주#
MMU 는 막는 장치로만 알려져 있지만, 부려 쓰는 장치이기도 하다. 같은 기억을 주소 공간의 두 자리에 함께 놓을 수도 있고, 기억이 하나도 붙지 않은 자리를 만들 수도 있다.
examples/apx-memory/layout/layout.c
/* 배치도는 막는 장치이자 *쓰는* 장치다.
셋을 보인다.
① 같은 물리 기억을 주소 공간에 두 번 이어 붙여 「감기지 않는 고리 버퍼」를 만든다.
② 마지막 쪽의 권한을 거두어 경비 쪽으로 삼는다 --- 넘겨 쓰면 거기서 멈춘다.
③ 이 프로그램 자신의 사상 목록에서 권한 조합을 세어 본다. */
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <signal.h>
#include <setjmp.h>
#include <unistd.h>
#include <sys/mman.h>
#if defined(__ANDROID__)
/* ★ Termux 가 겨누는 옛 안드로이드 API 에서는 Bionic 이 memfd_create 선언을 감춘다(API 30 부터
보인다). 시스템 호출은 커널에 있으므로 번호로 직접 부른다. */
#include <sys/syscall.h>
#define memfd_create(name, flags) ((int)syscall(__NR_memfd_create, (name), (flags)))
#endif
static sigjmp_buf back;
static void on_segv(int sig)
{
(void)sig;
siglongjmp(back, 1);
}
static void ring_of_one_page(size_t pg)
{
int fd = memfd_create("ring", 0);
if (fd < 0 || ftruncate(fd, (long)pg) != 0) { puts(" (memfd unavailable)"); return; }
/* 먼저 두 쪽 넓이의 자리를 잡아 두고, 그 위에 같은 기억을 두 번 덮어 사상한다. */
char *base = mmap(NULL, pg * 2, PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
char *a = mmap(base, pg, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_FIXED, fd, 0);
char *b = mmap(base + pg, pg, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_FIXED, fd, 0);
if (a == MAP_FAILED || b == MAP_FAILED) { puts(" (mapping refused)"); return; }
strcpy(a, "hello");
printf(" two windows %zu bytes apart; wrote at the first, read \"%s\" at the second\n",
(size_t)(b - a), b);
/* 끝에서 8 바이트 앞에 12 바이트를 쓴다 --- 감기 검사 없이 그냥 이어 쓴다. */
memcpy(a + pg - 8, "ABCDEFGHIJKL", 12);
printf(" wrote 12 bytes 8 before the end; the last 4 came out at the start: \"%.4s\"\n", a);
printf("#DATA ring %.4s\n", a);
close(fd);
}
static void guard_page(size_t pg)
{
struct sigaction sa;
char *p = mmap(NULL, pg * 3, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (p == MAP_FAILED) { puts(" (mapping refused)"); return; }
mprotect(p + pg * 2, pg, PROT_NONE); /* 마지막 쪽의 권한을 거둔다 */
memset(&sa, 0, sizeof sa);
sa.sa_handler = on_segv;
sigaction(SIGSEGV, &sa, NULL);
if (sigsetjmp(back, 1) == 0) {
for (size_t i = 0; i < pg * 3; i++) p[i] = 1;
puts(" the overrun ran past the guard -- nothing stopped it");
printf("#DATA guard none\n");
} else {
printf(" the overrun stopped at the guard page, %zu bytes in\n", pg * 2);
printf("#DATA guard stopped\n");
}
signal(SIGSEGV, SIG_DFL);
}
static void permissions_of_this_program(void)
{
FILE *f = fopen("/proc/self/maps", "r");
char line[512], perm[8];
long r = 0, x = 0, w = 0, none = 0, wx = 0, total = 0;
if (!f) return;
while (fgets(line, sizeof line, f)) {
if (sscanf(line, "%*s %7s", perm) != 1) continue;
total++;
if (perm[0] == 'r' && perm[2] == 'x') x++;
else if (perm[1] == 'w') w++;
else if (perm[0] == 'r') r++;
if (perm[0] == '-' && perm[1] == '-' && perm[2] == '-') none++;
if (perm[1] == 'w' && perm[2] == 'x') wx++;
}
fclose(f);
printf(" %ld regions: %ld read-only, %ld readable+writable, %ld executable, %ld with no access\n",
total, r, w, x, none);
printf(" regions that are writable AND executable: %ld\n", wx);
printf("#DATA regions %ld\n", total);
printf("#DATA wx %ld\n", wx);
}
int main(void)
{
size_t pg = (size_t)sysconf(_SC_PAGESIZE);
void *both;
printf("== the page size of this machine: %zu bytes ==\n\n", pg);
printf("#DATA-BEGIN\n");
printf("#DATA pagesize %zu\n", pg);
puts("-- one page of memory, mapped twice in a row --");
ring_of_one_page(pg);
puts("\n-- a page with its permissions taken away --");
guard_page(pg);
puts("\n-- the permissions this program is running under --");
permissions_of_this_program();
both = mmap(NULL, pg, PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
printf(" asking for one that is both writable and executable: %s\n",
both == MAP_FAILED ? "refused" : "granted");
printf("#DATA wxrequest %s\n", both == MAP_FAILED ? "refused" : "granted");
printf("#DATA-END\n");
puts("\n * the same memory can sit at two addresses, and an address can have no");
puts(" memory at all. What a pointer names is a place on a map.");
return 0;
}
실행 결과
== the page size of this machine: 4096 bytes ==
-- one page of memory, mapped twice in a row --
two windows 4096 bytes apart; wrote at the first, read "hello" at the second
wrote 12 bytes 8 before the end; the last 4 came out at the start: "IJKL"
-- a page with its permissions taken away --
the overrun stopped at the guard page, 8192 bytes in
-- the permissions this program is running under --
27 regions: 10 read-only, 12 readable+writable, 4 executable, 1 with no access
regions that are writable AND executable: 0
asking for one that is both writable and executable: granted
* the same memory can sit at two addresses, and an address can have no
memory at all. What a pointer names is a place on a map.
세 가지가 한꺼번에 나왔다.
- 고리 버퍼: 한 쪽을 주소 공간에 두 번 이어 붙였다. 그래서 끝에서 여덟 바이트 앞에 열두 바이트를 써도 「끝에 닿았는가」를 묻지 않아도 된다 — 넘친 넷이 저절로 앞자리에 나타난다. 끝에서 앞으로 되돌아가는 검사가 잦은 통신·오디오 코드에서 쓰는 오래된 재주다.
- 경비 쪽: 마지막 쪽의 권한을 거두어 두면 넘겨 쓰기가 거기서 멈춘다. 조용히 옆을 망가뜨리는 대신 그 자리에서 죽는다. 스택 끝에 이것이 깔려 있다.
- W^X: 이 프로그램에 걸린 사상 가운데 쓰기와 실행을 함께 가진 것은 하나도 없다. 코드는 실행되지만 고쳐지지 않고, 자료는 고쳐지지만 실행되지 않는다.
★ 마지막 줄은 잰 그대로 적는다. 이 기계는 지금 걸려 있는 사상 중에는 쓰기+실행이 없지만, 새로 하나 달라고 하면 내준다. 곧 W^X 는 하드웨어가 강제하는 법이 아니라 링커·로더·커널 정책의 합의다. 기계마다 다르고, JIT 컴파일러는 이 문을 열어 쓴다.
주소를 번역하지 않는 기계#
작은 기계에는 MMU 가 없다. 대신 MPU 만 있거나, 그것마저 없다.
MPU(memory protection unit)는 주소를 바꾸지 않는다. 물리 주소 위에 구역을 긋고 권한만 건다. Cortex-M 계열이 쓰는 PMSA 가 그것이고, 구역 수는 대개 여덟이며, ARMv7-M 에서는 각 구역이 32 바이트에서 4 GB 까지 2의 제곱 크기여야 한다. RISC-V 에도 비슷한 것이 있다 — PMP(physical memory protection)로, 물리 주소 구역 16 또는 64 개에 정책을 걸되 가장 높은 특권(머신 모드)에서만 설정한다.
주소 번역이 없으면 무엇이 달라지는가. C 로 짜는 방식이 달라진다.
| 무엇이 | 왜 | 대신 무엇을 |
|---|---|---|
fork 가 없다 | 자식에게 줄 「같은 주소, 다른 기억」을 만들 수 없다 | vfork — 기억을 나누어 쓰고 부모는 멈춰 기다린다 |
| 복사-후-쓰기가 없다 | 쓰기를 가로챌 장치가 없다 | 처음부터 복사하거나, 나누어 쓰거나 |
| 주소가 고정되지 않는다 | 프로그램마다 다른 자리에 실린다 | FDPIC 같은 위치 독립 실행 형식 |
| 코드가 RAM 을 안 먹는다 | 플래시에서 그대로 실행(XIP) | 자료만 RAM 으로 옮긴다 |
| 단편화가 치명적이다 | 흩어진 물리 쪽을 이어 붙일 수 없다 | 아레나·풀 — 94장 |
표 105.3 — 주소를 번역하지 않는 기계에서 달라지는 것
★ 마지막 줄이 이 책의 다른 자리와 이어진다. 아레나와 풀이 임베디드에서 유난히 자주 쓰이는 이유는 취향이 아니라 이어 붙일 장치가 없기 때문이다. 번역이 있는 기계에서는 물리적으로 흩어진 쪽을 가상 주소에서 나란히 보이게 할 수 있지만, 없는 기계에서는 흩어지면 그걸로 끝이다.
주소 공간이 여럿일 때#
여기서부터가 이 부록에서 가장 낯선 자리다. 지금까지는 주소 공간이 하나뿐이라고 여기고 말해 왔다. 그렇지 않은 기계가 많다.
| 방식 | 무엇인가 | C 에 어떻게 드러나나 |
|---|---|---|
| 하버드 구조 | 코드와 자료의 주소 공간이 아예 다르다(AVR 등) | 같은 수 0x100 이 플래시의 자리이기도 하고 RAM 의 자리이기도 하다 |
| 이름 붙은 주소 공간 | 어느 공간인지를 타입에 적는다. ISO/IEC TR 18037 의 확장 | const __flash char msg[] = "hi"; — 읽을 때 다른 명령이 나간다. AVR 은 const 를 요구한다 |
| 뱅크 전환 | 주소선이 모자라, 주소 공간에 창을 하나 내고 그 창에 보일 뱅크(bank, 기억 덩이)를 갈아 끼운다 | 「지금 창에 어느 뱅크가 걸려 있는가」가 프로그램의 상태가 된다 |
| 세그먼트(x86 실모드) | 16비트 둘을 겹쳐 20비트 주소를 만든다 | near·far·huge 가 컴파일러가 문법에 더한 예약어였다. 라이브러리 함수가 아니라 const 처럼 선언에 붙였다 |
| 말 단위 주소(DSP, digital signal processor) | 가장 작은 주소 단위가 8비트가 아니다 | TI C55x 는 char 가 16비트 — char 와 int 의 sizeof 가 똑같이 1 인 세계 |
표 105.4 — 주소 공간이 하나가 아닌 기계들
표의 둘째 줄을 조금 더 본다. avr-gcc 에서 __flash 는 플래시에 둔 읽기 전용 자료를 가리키고, 그 포인터를 따라갈 때는 보통의 읽기 명령이 아니라 플래시 전용 명령이 나간다.
여기서 중요한 것은 __flash 가 무엇으로 더해졌는가다. 매크로도, 라이브러리 함수도, #pragma 도 아니다. 타입 한정자다 — GCC 문서의 말로는 「const 나 volatile 같은 다른 C 타입 한정자와 똑같이 쓸 수 있다」. 그러니 자리도 그것들과 같다.
그래서 AVR 교차 컴파일러로 실제로 지어 본다. 이 예제는 돌리는 프로그램이 아니다 — 컴파일되는 것 자체가 실험이라, 세 번 짓고 그때 나온 것을 그대로 싣는다.
examples/apx-memory/named_space/named_space.c
/* 이름 붙은 주소 공간을 눈으로 --- AVR 의 `__flash`.
이 파일은 이 기계(x86-64)에서 도는 프로그램이 아니다. *컴파일되는 것 자체가
실험*이라, 옆의 `run.sh` 가 AVR 교차 컴파일러로 이 파일을 세 번 짓고 그때
나온 것을 보인다.
보이려는 것 셋.
① `const __flash` 는 선언에 붙는 *타입 한정자*다 --- `const` 와 같은 자리.
② 그런데 표준 C 의 낱말이 아니다. `-std=c23`(엄격 ISO)에서는 이 파일이
아예 컴파일되지 않는다. `-std=gnu23` 이라야 선다.
③ 같은 첨자 `x[i]` 인데 어느 공간의 것이냐에 따라 *다른 기계 명령*이 나가고,
자료도 다른 구역에 놓인다. */
const char ram[] = "hi"; /* RAM(.rodata) 에 놓인다 */
const __flash char rom[] = "hi"; /* 플래시(.progmem.data) 에 놓인다 */
char from_ram(int i) { return ram[i]; }
char from_rom(int i) { return rom[i]; }
/* ★ 그리고 이것 --- 공간이 다른 두 포인터를 섞는다.
한쪽은 RAM 을, 다른 쪽은 플래시를 가리키는데 그냥 대입된다. */
const char *p;
const __flash char *q;
void mix(void) { p = q; }
실행 결과
== compiler ==
avr-gcc (GCC) 16.1.0
== 1. strict ISO mode: -std=c23 ==
named_space.c:15:14: error: expected ';' before 'char'
-> __flash is a GNU extension, so the strict mode does not have the word.
== 2. GNU mode: -std=gnu23 -Wall -Wextra -Wpedantic ==
compiled, and said nothing --- not even about mixing the two spaces
in 'p = q' (see the source). The qualifier is dropped in silence.
== 3. the same subscript, two instructions ==
from_ram:
subi r24,lo8(-(ram))
sbci r25,hi8(-(ram))
movw r30,r24
ld r24,Z
ret
from_rom:
subi r24,lo8(-(rom))
sbci r25,hi8(-(rom))
movw r30,r24
lpm r24,Z
ret
where each array landed:
rom -> .progmem.data
ram -> .rodata
* ld reads data memory; lpm reads program memory. The C text was the same
'x[i]' in both functions -- the type chose the instruction.
셋이 드러났고, 그중 하나는 내가 예상한 것과 달랐다.
첫째, 선언에 붙는다. const __flash char rom[] 은 const 와 나란한 자리에 선다. 값에 붙는 것이 아니라 타입의 일부가 된다.
둘째, 그러나 표준 C 의 낱말이 아니다. -std=c23 — 엄격 ISO 모드 — 으로 지으면 이 파일은 컴파일조차 되지 않는다. 「char 앞에 ; 가 와야 한다」는 문법 오류가 난다. 그 모드의 C 에는 그런 낱말이 아예 없기 때문이다. -std=gnu23 이라야 선다. 언어에 낱말을 더한 것은 표준이 아니라 컴파일러라는 사실이 이 한 줄에 다 들어 있다.
셋째, 컴파일러가 그 타입을 보고 명령을 고른다. from_ram 과 from_rom 은 C 로는 똑같이 x[i] 인데, 나온 것은 ld(자료 기억을 읽는 명령)와 lpm(프로그램 기억을 읽는 명령)으로 갈렸다. 배열이 놓인 구역도 .rodata 와 .progmem.data 로 갈렸다. 타입 한정자 하나가 어느 기억에 놓을지와 어떤 명령으로 읽을지를 함께 정한 것이다.
흔한 오해. 공간이 다른 포인터를 섞으면 컴파일러가 막아 준다
p = q — 플래시를 가리키던 포인터를 RAM 포인터에 넣는 대입 — 는 -Wall -Wextra -Wpedantic 을 다 켜고도 아무 말 없이 통과했다 (avr-gcc 16.1.0). const 를 떼는 대입에는 잔소리를 하는 컴파일러가 여기서는 조용하다. 그리고 그렇게 만든 p 를 따라가면 ld 가 나가서 같은 번호의 RAM 자리를 읽는다 — 플래시에 있는 글자가 아니라. 이 표의 첫 줄 — 하버드 구조에서 같은 수 0x100 이 두 곳을 가리킨다 — 이 그대로 사고가 되는 자리다. 막아 주는 것은 컴파일러가 아니라 프로그래머다.__memx 는 한 걸음 더 간다 — 24비트 포인터를 쓰고 높은 바이트에 어느 공간인가를 실어, 어느 쪽이든 같은 포인터로 따라갈 수 있게 한다. 그 대신 따라갈 때마다 「어느 공간인가」를 보는 값이 붙는다.
★ 이것이 왜 중요한가. 이 책은 38장 에서 포인터가 「주소를 담은 정수」가 아니라 객체를 가리키는 것이라고 말했다. 그 말이 까다롭게 들렸다면, 여기 근거가 있다. 같은 정수 값이 기계에서 두 곳을 가리킬 수 있다. 그래서 표준은 「주소」가 아니라 「객체」로 적는다.
역사도 같은 자리에 놓인다. 16비트 도스 시절의 far 도 함수나 매크로가 아니라 컴파일러가 언어에 더한 예약어였다. 그냥 포인터(near)는 16비트 변위만 담아서, 그 값이 어느 바이트를 뜻하는지는 그때 세그먼트 레지스터에 무엇이 들어 있느냐에 달렸다. 그래서 다른 세그먼트의 자료를 가리키려면 far 라고 선언에 적어 세그먼트까지 담게 해야 했다.
곧 두 시대가 같은 일을 했다. 기계에 주소 공간이 여럿이면, 그 사정이 문법으로 올라온다. 어느 공간인지를 컴파일러에게 알려 줄 자리가 언어 안에 없으면 프로그램을 적을 수 없기 때문이다. 표준 C 에는 그 자리가 없어서, 그때는 far 라는 예약어로, 지금은 __flash 같은 주소 공간 한정자로 각 컴파일러가 만들어 넣는다.
캐시와 DMA, 그리고 스크래치패드#
주소 번역과 별개로, 기억에는 사본이 생긴다. 캐시다(12장). 프로그램만 기억을 만지는 동안에는 이것이 보이지 않지만, 다른 장치가 같은 기억을 만지기 시작하면 드러난다.
Cortex-M7 처럼 캐시가 있는 마이크로컨트롤러에서 DMA(direct memory access)를 쓰면 두 방향으로 어긋난다. DMA 가 RAM 에 새 자료를 넣었는데 CPU 는 캐시의 옛 사본을 읽고, CPU 가 보낼 자료를 적었는데 그것이 아직 캐시에 있어 DMA 는 옛 RAM 을 읽는다. 푸는 길이 셋이다.
| 방법 | 무엇을 하는가 | 값 |
|---|---|---|
| 비캐시 구역 | MPU 로 그 버퍼만 캐시에 담기지 않게 한다 | 그 버퍼의 접근이 느려진다 |
| 정리와 무효화 | DMA 가 읽기 전에 캐시의 내용을 기억으로 내려 쓰고(clean), DMA 가 쓴 뒤에는 캐시의 사본을 버린다(invalidate) | 줄 크기 단위라 작은 버퍼에는 성가시다 |
| TCM | 애초에 캐시를 거치지 않는 전용 기억을 쓴다 | 크기가 작고 기계마다 다르다 |
표 105.5 — DMA 와 캐시가 어긋날 때
TCM(tightly coupled memory)은 캐시가 아니라 주소 공간의 한 자리다. 캐시는 자동으로 채워지지만 TCM 은 프로그래머가 무엇을 둘지 정한다. 같은 성격의 것이 여러 이름으로 있다 — 스크래치패드, 지역 기억. 「빠른 기억을 자동으로 얻는 대신 손으로 배치한다」는 갈래가 임베디드와 가속기 쪽에 늘 있어 왔다.
포인터에 살이 붙는다#
마지막으로, 지금 벌어지고 있는 흐름 하나. 하드웨어가 포인터에 무언가를 더 싣고 있다.
- MTE(memory tagging extension, ARMv8.5): 기억을 16바이트씩 잘라 그 한 도막(granule)마다 4비트 표를 붙이고, 포인터의 맨 위 바이트에도 같은 4비트를 싣는다. 둘이 다르면 하드웨어가 잡는다. 이미 반납한 기억을 다시 쓰면 표가 어긋나므로 그 자리에서 죽는다.
- CHERI(capability hardware enhanced RISC instructions)/Morello: 포인터를 128비트로 늘려 주소와 함께 범위와 권한, 그리고 위조를 막는 유효 표시를 싣는다. 권한은 줄일 수만 있고 늘릴 수 없다.
★ 이 책의 논지 하나가 여기서 닫힌다. 38장 이 「포인터는 정수가 아니다」라고 말했을 때, 그것은 표준의 까다로움처럼 들렸을 것이다. 지금 그 까다로움이 실리콘으로 내려오고 있다. 포인터를 정수로 다루는 코드는 이 기계들에서 실제로 깨진다 — 맨 위 바이트에 실려 있던 것을 지워 버렸기 때문에, 또는 좁혀 받은 권한을 정수로 되돌렸다가 다시 넓히려 했기 때문에.
복습 정리
malloc이 준 주소는 대개 기억 칸의 번호가 아니라 주소 공간 위의 한 자리다. 그 자리를 실제 기억으로 옮겨 주는 장치가 있는 기계와 없는 기계에서, C 로 짜는 법이 달라진다.- 주소 번역이 있으면 할당은 약속이고, 값은 첫 접촉에서 치른다 — 이 기계에서 쪽당 100배 넘는 차이로 잰다.
- 복사-후-쓰기는 「쓸 때 잡아채는 장치」가 있어야 성립한다. 그래서
fork는 주소를 번역하지 않는 기계에 아예 없다. - MMU 는 막기만 하지 않는다. 같은 기억을 두 자리에 함께 놓아 고리 버퍼를 만들고, 기억이 붙지 않은 자리를 만들어 경비로 세운다.
- 작은 기계에는 MPU 만 있거나 아무것도 없다. 아무것도 없으면 넘겨 써도 조용하다.
- 주소 공간이 하나라는 전제는 임베디드에서 자주 깨진다. 그러면 그 사정이 문법으로 올라온다 — 컴파일러가
far·__flash같은 예약어를 언어에 더하고, 프로그래머는 「이것이 어느 공간의 것인지」를const처럼 선언에 적어야 한다. - 캐시는 다른 장치가 같은 기억을 만질 때 드러난다. 비캐시 구역·정리와 무효화·TCM.
- 포인터에 표와 범위가 실리기 시작했다. 「포인터는 정수가 아니다」가 표준의 말에서 하드웨어의 말이 되어 가고 있다.