Proven C Book←↑→

부록 N — 기억을 다스리는 장치들

malloc 이 주소 하나를 돌려준다. 그 주소는 무엇의 주소인가?

이 책은 여기까지 오는 동안 기억을 여러 번 다루었다. 프로그램이 나뉘어 앉는 구역 (5장), 레지스터에서 주기억까지의 사다리(12장), 운영체제가 정해 주는 배치(98장), 그리고 할당자의 속 (99장). 그런데 그 이야기들은 모두 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,050 나노초에 다시 쓰기 약 35 나노초, 그리고 두 번 이어 붙인 사상·경비 쪽·「쓰기와 실행을 함께」 요청의 허락까지 같았다. cow 는 처음에 폰의 clang 이 512 MB 채우기를 지워 0 MB 가 나왔다. 시연을 고쳐 다시 돌리니 x86-64 와 같았다 — fork 직후의 자식은 512 MB 를 모두 부모와 나눠 가졌고, 모든 쪽을 읽어도 그대로였으며, 모든 쪽에 쓴 뒤에야 512 MB 가 제 몫이 되었다. promise 는 빈 기억이 약 9.3 GB 있어야 하는데 폰의 여유 기억이 약 2 GB 여서 돌리지 못했다.

세 갈래의 기계#

기억을 다스리는 방식은 크게 셋이다. 이 표가 이 부록의 뼈대이고, 나머지 절이 칸을 채운다.

주소 번역이 있다
(MMU, memory management unit)
보호만 있다(MPU)맨 주소
포인터가 가리키는 것가상 주소물리 주소물리 주소
같은 주소, 다른 프로그램서로 다른 곳같은 곳같은 곳
권한을 거는 단위쪽(page, 보통 4 KiB)구역(region, 여덟 남짓)없다
fork된다(복사-후-쓰기)안 된다안 된다
경계를 넘겨 쓰면사상(mapping)된 자리를 벗어나야 쪽 오류. 같은 쪽 안이면 조용하다구역 밖이면 오류아무 일도 안 일어난다
어디서 만나나PC·서버·스마트폰Cortex-M 계열, RTOS(real-time operating system)8비트 MCU, 부팅 초기

표 115.1 — 기억을 다스리는 세 갈래

★ 마지막 두 줄이 이 표의 알맹이다. 56장이 「가장 나쁜 결과는 아무 일도 안 일어나는 것」이라고 말했는데, 그것이 여기서는 하드웨어의 성질로 나온다. 맨 주소 기계에서 배열을 넘겨 쓰면 옆 변수가 조용히 바뀐다. 잡아 줄 장치가 없어서다.

다만 오해하지 말 것 — MMU 가 있다고 넘겨 쓰기를 잡아 주는 것은 아니다. 그물코가 쪽(4 KiB)이라, 같은 쪽 안에서 옆 변수를 밟는 일은 번역이 있는 기계에서도 조용히 벌어진다. 뒤에 나올 예제가 그 증거다 — 권한을 거둔 쪽에 닿기까지 8192바이트를 아무 일 없이 넘겨 쓴다. MMU 가 거는 것은 쪽 단위의 그물이지 변수의 울타리가 아니다.

주소 번역의 구조 — MMU 가 실제로 하는 일#

주소 번역이 있는 기계에서 포인터의 값은 가상 주소다. 그것을 물리 주소로 바꾸는 표가 따로 있고, 그 표는 한 층이 아니라 여러 층이다. 주소를 몇 조각으로 잘라 층마다 하나씩 따라 내려간다.

방식층가상 주소 폭쓸 수 있는 쪽 크기
Sv39339비트4 KiB · 2 MiB · 1 GiB
Sv48448비트4 KiB · 2 MiB · 1 GiB · 512 GiB

표 115.2 — 쪽 표의 층과 쪽 크기 — RISC-V 의 두 방식

층이 셋이면 번역 한 번에 기억을 세 번 읽어야 한다. 그 비용을 줄이려고 최근 번역 결과를 담아 두는 것이 TLB(translation lookaside buffer)이고, TLB 를 넘어서면 무슨 일이 벌어지는지는 부록 M 이 이미 재어 두었다 — 여기서는 다시 재지 않는다. 큰 쪽(2 MiB)을 쓰는 이유도 이 표에서 나온다. 같은 넓이를 덮는 데 표 항목이 512분의 1 이면 TLB 도 그만큼 덜 쓴다.

표의 각 항목에는 주소만이 아니라 권한이 함께 적힌다. 읽기·쓰기·실행이 쪽마다 따로 걸리고, 그래서 프로그램의 코드는 읽기와 실행만, 자료는 읽기와 쓰기만 얻는다. 뒤의 layout 예제가 이 프로그램에 지금 걸려 있는 사상(寫像, mapping)들을 훑어 권한별로 세어 보인다.

주소 번역 때문에 벌어지는 세 가지#

하나 — 할당은 약속이다#

8 GiB 를 달라고 하면 8 GiB 가 온다고 생각하기 쉽다. 재어 보면 그렇지 않다.

examples/apx-memory/promise/promise.c (발췌)

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");

실행 결과

== 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 (발췌)

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;

실행 결과

== what a page costs the first time it is touched ==

  first touch          1161.0 ns per page
  touched again           8.5 ns per page
  factor                137.0 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 으로 채우고, 표에 적고, 돌아온다. 두 번째는 그냥 쓰기다.

★ 그래서 시간을 재는 프로그램은 재기 전에 쪽을 미리 만져 둔다. 안 그러면 재려던 것이 아니라 쪽 부재를 재게 된다. 부록 M 의 예제들이 실제로 그렇게 데운다.

셋 — 복사하지 않는 복사#

주소 번역이 있으면 「같은 것을 둘이 나누어 보다가, 고치는 쪽만 제 것을 갖는」 재주를 부릴 수 있다. 복사-후-쓰기(copy-on-write)다.

examples/apx-memory/cow/cow.c (발췌)

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");

실행 결과

== 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 를 채운 뒤 fork 로 갈라졌는데, 자식이 제 몫으로 가진 쪽은 0 이다. 전부 읽어도 0 이다. 전부 쓰고 나서야 512 MB 가 된다. fork 가 싼 이유가 이것이고, 이 재주는 주소를 번역하지 않는 기계에서는 부릴 수 없다 — 뒤에서 그 결과를 본다.

주소 공간을 부려 쓰는 재주#

MMU 는 막는 장치로만 알려져 있지만, 부려 쓰는 장치이기도 하다. 같은 기억을 주소 공간의 두 자리에 함께 놓을 수도 있고, 기억이 하나도 붙지 않은 자리를 만들 수도 있다.

examples/apx-memory/layout/layout.c (발췌)

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);
/* … */
memcpy(a + pg - 8, "ABCDEFGHIJKL", 12);
/* … */
mprotect(p + pg * 2, pg, PROT_NONE);            /* 마지막 쪽의 권한을 거둔다 */

실행 결과

== 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 는 하드웨어가 강제하는 법이 아니라 링커·로더·커널 정책의 합의다. 기계마다 다르고, 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 으로 옮긴다
단편화가 치명적이다흩어진 물리 쪽을 이어 붙일 수 없다아레나·풀 — 104장

표 115.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 인 세계

표 115.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.

셋이 드러났고, 그 밖에 하나가 내 예상과 달랐다.

첫째, __flash 는 선언에 붙는다. const __flash char rom[] 은 const 와 나란한 자리에 선다. 값에 붙는 것이 아니라 타입의 일부가 된다.

둘째, 그러나 __flash 는 표준 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애초에 캐시를 거치지 않는 전용 기억을 쓴다크기가 작고 기계마다 다르다

표 115.5 — DMA 와 캐시가 어긋날 때

TCM(tightly coupled memory)은 캐시가 아니라 주소 공간의 한 자리다. 캐시는 자동으로 채워지지만 TCM 은 프로그래머가 무엇을 둘지 정한다. 같은 성격의 것이 여러 이름으로 있다 — 스크래치패드, 지역 기억. 「빠른 기억을 자동으로 얻는 대신 손으로 배치한다」는 갈래가 임베디드와 가속기 쪽에 늘 있어 왔다.

포인터에 살이 붙는다#

마지막으로, 지금 벌어지고 있는 흐름 하나. 하드웨어가 포인터에 무언가를 더 싣고 있다.

★ 이 책의 논지 하나가 여기서 닫힌다. 38장이 「포인터는 정수가 아니다」라고 말했을 때, 그것은 표준의 까다로움처럼 들렸을 것이다. 지금 그 까다로움이 실리콘으로 내려오고 있다. 포인터를 정수로 다루는 코드는 이 기계들에서 실제로 깨진다 — 맨 위 바이트에 실려 있던 것을 지워 버렸기 때문에, 또는 좁혀 받은 권한을 정수로 되돌렸다가 다시 넓히려 했기 때문에.

복습 정리

  • malloc 이 준 주소는 대개 기억 칸의 번호가 아니라 주소 공간 위의 한 자리다. 그 자리를 실제 기억으로 옮겨 주는 장치가 있는 기계와 없는 기계에서, C 로 짜는 법이 달라진다.
  • 주소 번역이 있으면 할당은 약속이고, 비용은 첫 접촉에서 치른다 — 이 기계에서 쪽당 100배 넘는 차이로 잰다.
  • 복사-후-쓰기는 「쓸 때 잡아채는 장치」가 있어야 성립한다. 그래서 fork 는 주소를 번역하지 않는 기계에 아예 없다.
  • MMU 는 막기만 하지 않는다. 같은 기억을 두 자리에 함께 놓아 고리 버퍼를 만들고, 기억이 붙지 않은 자리를 만들어 경비로 세운다.
  • 작은 기계에는 MPU 만 있거나 아무것도 없다. 아무것도 없으면 넘겨 써도 조용하다.
  • 주소 공간이 하나라는 전제는 임베디드에서 자주 깨진다. 그러면 그 사정이 문법으로 올라온다 — 컴파일러가 far·__flash 같은 예약어를 언어에 더하고, 프로그래머는 「이것이 어느 공간의 것인지」를 const 처럼 선언에 적어야 한다.
  • 캐시는 다른 장치가 같은 기억을 만질 때 드러난다. 푸는 길은 비캐시 구역·정리와 무효화·TCM 이다.
  • 포인터에 꼬리표와 범위가 실리기 시작했다. 「포인터는 정수가 아니다」가 표준의 말에서 하드웨어의 말이 되어 가고 있다.