Chapter 4

메모리 가상화 기초

  • 4.1 초기 시스템과 주소 공간
  • 4.2 메모리 API
  • 4.3 주소 변환과 베이스/바운드
  • 4.4 세그멘테이션
  • 4.5 빈 공간 관리

메모리 가상화의 핵심은 각 프로세스가 자기만의 거대한 메모리를 가진 것처럼 보이게 만드는 것이야. CPU 가상화가 시분할로 "자기만의 CPU" 환상을 만든 것처럼, 메모리 가상화는 **주소 공간(address space)**이라는 추상화로 "자기만의 메모리" 환상을 만들지.

아주 초기의 컴퓨터에서는 OS가 메모리 한쪽에, 프로그램이 다른 쪽에 있었고 보호 같은 건 없었어. 프로그램이 하나만 돌았으니까. 그다음 멀티프로그래밍 시대가 오면서 여러 프로그램을 메모리에 동시에 올려놓고 번갈아 실행하게 됐는데, 프로그램 A가 프로그램 B의 메모리를 읽거나 OS 메모리를 덮어쓰면 큰일이잖아. 그래서 OS가 주소 공간이라는 추상화를 만든 거야.

주소 공간에는 **코드(text)**가 맨 아래에, 그 위에 (아래에서 위로 자람), 맨 위에 스택(위에서 아래로 자람)이 있어. 힙과 스택이 서로 반대 방향으로 자라서 사용하지 않는 공간을 공유할 수 있지. 프로세스가 보는 주소는 전부 가상 주소고, 실제 물리 메모리 주소와 달라. OS와 하드웨어가 협력해서 가상 주소를 물리 주소로 변환하는데, 이 **주소 변환(address translation)**이 메모리 가상화의 핵심 메커니즘이야. 가상 메모리 시스템이 달성해야 하는 건 투명성(프로세스가 모르게), 효율성(느려지지 않게), 보호(서로 침범 못 하게) 세 가지야.

실전적인 측면도 짚고 가자. C에서 메모리 할당은 두 종류야. 스택 메모리는 함수 안 지역 변수로, 컴파일러가 자동 관리해. 힙 메모리malloc()으로 할당하고 free()로 해제하는데, 프로그래머가 직접 관리해야 하지. malloc()은 void 포인터를 리턴하니까 캐스팅해야 하고, sizeof()를 쓰는 게 좋아. free()에 크기를 안 넘겨도 되는 건 할당된 블록 앞에 헤더로 크기를 저장해두기 때문이야. C 메모리 버그는 악몽인데 — 할당 잊기(세그폴트), 부족하게 할당(버퍼 오버플로우), 초기화 잊기(쓰레기 값), 해제 잊기(메모리 누수), 해제 후 사용(dangling pointer), 이중 해제(double free) 같은 것들이 흔해. malloc()/free()는 시스템 콜이 아니라 라이브러리 함수고, 내부적으로 brk()(힙 확장/축소)이나 mmap()(메모리 매핑)을 호출해.

가장 단순한 주소 변환 기법이 **베이스와 바운드(base and bounds)**야. 각 CPU에 두 개의 레지스터가 있는데, 베이스는 주소 공간이 물리 메모리 어디서 시작하는지, 바운드는 주소 공간의 크기지. 변환 공식은 단순해:

물리 주소 = 가상 주소 + 베이스

프로세스가 가상 주소 0x1000에 접근하면 하드웨어(MMU)가 베이스 값(예: 0x30000)을 더해서 물리 주소 0x31000에 접근하고, 바운드를 넘으면 예외를 발생시켜서 프로세스를 격리하지. 베이스/바운드 레지스터는 커널 모드에서만 변경 가능하고, 컨텍스트 스위치 시 PCB에 저장/복원돼. 하지만 한계가 있어 — 힙과 스택 사이 빈 공간도 물리 메모리를 차지하는 내부 단편화 문제지.

이걸 해결한 게 세그멘테이션이야. 주소 공간을 통째로 매핑하는 게 아니라 코드, 힙, 스택 세그먼트별로 독립적으로 매핑하는 거지. 각 세그먼트마다 베이스와 바운드가 있고, 사용하지 않는 빈 공간은 물리 메모리를 차지하지 않아. 가상 주소의 상위 비트로 세그먼트를 식별하고 하위 비트가 오프셋이 돼. 오프셋이 바운드를 넘으면 세그멘테이션 폴트 — 이 이름이 여기서 나온 거야! 스택은 아래 방향으로 자라니까 하드웨어에 성장 방향 비트가 추가로 필요하고, **보호 비트(RWX)**를 두면 코드 세그먼트를 여러 프로세스가 안전하게 공유할 수도 있어.

근데 세그멘테이션이 내부 단편화는 해결했지만 외부 단편화라는 새 문제를 만들어. 세그먼트들이 생성되고 해제되면서 물리 메모리에 자잘한 빈 공간(hole)들이 흩어지거든. 총 빈 공간은 충분한데 연속된 공간이 부족해서 새 세그먼트를 못 배치하는 상황이 생기지. 컴팩션(세그먼트를 한쪽으로 밀어서 빈 공간 모음)으로 해결할 수 있지만 비용이 크고, 결국 빈 공간 관리 알고리즘이 필요해.

빈 공간 관리의 핵심 메커니즘은 **분할(splitting)**과 **병합(coalescing)**이야. 할당할 때는 큰 빈 블록을 쪼개서 필요한 만큼만 주고, 해제할 때는 인접한 빈 블록을 합쳐서 큰 블록으로 만들지. 빈 공간들은 **빈 리스트(free list)**로 관리하는데, 빈 공간 자체에 리스트 노드를 넣는 게 영리한 포인트야.

어떤 빈 블록을 골라서 할당할지는 여러 전략이 있어. Best Fit(가장 작은 충분한 블록)은 낭비를 최소화하지만 느리고 자투리가 많이 생기고, First Fit(처음 발견한 충분한 블록)은 빠르지만 앞부분에 조각이 몰리고, Next Fit(마지막 탐색 위치부터 이어서)은 편중을 방지해. 만능은 없어 — 워크로드에 따라 성능이 달라지지.

실전에서는 더 영리한 기법들을 써. **분리 빈 리스트(segregated lists)**는 흔한 크기별로 별도 리스트를 유지하는 건데, **슬랩 할당자(slab allocator)**가 이걸 발전시킨 거야 — 커널 오브젝트(inode, lock 등)마다 전용 풀을 만들지. **버디 할당(buddy allocation)**은 2의 거듭제곱 크기로만 할당해서 병합을 쉽게 만드는 방식인데, 버디 주소를 비트 연산으로 바로 계산할 수 있어. 7바이트 요청에 8바이트를 할당하는 내부 단편화는 감수해야 하지만.


정리

4장 읽고 기억할 거 세 가지:

  1. 주소 공간은 프로세스에게 제공하는 메모리의 환상이야. 가상 주소를 물리 주소로 변환하는 게 핵심이고, 가장 단순한 방법이 베이스/바운드, 그 발전이 세그멘테이션이지
  2. 세그멘테이션은 내부 단편화를 해결하지만 외부 단편화를 만들어. 세그먼트별 독립 매핑으로 빈 공간 낭비를 줄이지만, 가변 크기 할당이 만드는 조각화 문제는 피할 수 없어
  3. 빈 공간 관리의 핵심은 분할과 병합이야. Best Fit, First Fit 같은 전략 중 만능은 없고, 실전에서는 슬랩 할당자나 버디 할당 같은 고급 기법을 조합해서 쓰지