메모리 관리 장치
더 많은 작업
- Memory Management Unit, MMU; 메모리 관리 장치
- CPU가 내는 모든 메모리 주소를 검사하고 가상 주소를 물리 주소로 바꾸는 하드웨어
메모리 관리 장치(MMU)는 CPU가 메모리에 접근할 때마다 그 주소를 가로채, 프로그램이 쓰는 가상 주소를 실제 메모리 칩의 물리 주소로 바꾸고, 그 접근이 허용되는지 검사하는 하드웨어다. 운영체제는 프로세스마다 주소 변환표(페이지 테이블)를 만들어 두고, MMU는 그 표를 읽어 변환과 검사를 한다. 이 두 역할 덕분에 가상 메모리, 프로세스 사이의 메모리 보호, 요구 페이징, 쓰기 시 복사 같은 운영체제 기능이 가능하다.
지금의 범용 CPU는 MMU를 칩 안에 넣고 있으며, 변환 결과를 캐시하는 변환 색인 버퍼(TLB)도 MMU의 일부다. 초기 마이크로프로세서 시절에는 MMU가 CPU와 메모리 사이에 따로 놓인 칩이었다. MMU라는 말은 넓게는 가상 메모리를 구현하지 않는 단순한 뱅크 전환 회로에도 쓰인다.
- 주소 변환: 가상 주소를 물리 주소로 바꾼다. 서로 다른 프로세스가 같은 가상 주소 0x1000을 써도 각자의 표에 따라 다른 물리 주소로 간다.
- 메모리 보호: 페이지마다 읽기, 쓰기, 실행, 사용자 모드 접근 허용 여부를 검사하고 어기면 예외를 낸다.
- 부재 알림: 변환 정보가 없거나 페이지가 메모리에 없으면 페이지 폴트를 일으켜 운영체제에 처리를 넘긴다.
- 사용 기록: 페이지를 읽었는지(접근 비트), 썼는지(더티 비트)를 표에 기록해 운영체제의 페이지 교체 알고리즘을 돕는다.
- 캐시 속성: 페이지마다 캐시 가능 여부와 쓰기 방식을 정해 메모리 사상 입출력 영역이 캐시되지 않게 한다.
- 물리 주소 확장: 가상 주소보다 넓은 물리 주소를 쓰게 해 준다. 16비트 PDP-11은 MMU로 18비트, 22비트 물리 주소를 썼다.
주소 공간을 크기가 같은 페이지(page)로, 물리 메모리를 같은 크기의 프레임(frame)으로 나눈다. 가상 주소의 아랫부분(페이지 안의 오프셋)은 그대로 두고, 윗부분(가상 페이지 번호)만 페이지 테이블에서 찾아 물리 프레임 번호로 바꾼다. 페이지는 4KB가 가장 흔하고, 16KB, 64KB를 쓰는 아키텍처도 있으며, 2MB, 1GB 같은 거대 페이지(huge page)도 지원한다.
페이징은 물리 메모리가 조각나 있어도 연속된 가상 주소를 만들어 줄 수 있어 외부 단편화 문제가 없다. 대신 페이지 크기보다 작은 요청에도 페이지 하나를 통째로 쓰므로 내부 단편화가 생긴다. 이 트레이드오프 때문에 페이지 크기를 무작정 키우지 못한다.
주소를 (세그먼트 번호, 오프셋) 쌍으로 보고, 세그먼트 표에서 시작 주소와 길이, 권한을 찾아 변환한다. 세그먼트는 크기가 제각각이라 코드, 데이터, 스택 같은 논리 단위와 잘 맞고 기준 주소와 한계값 두 개만 있으면 되지만, 메모리를 할당하고 해제하다 보면 빈 공간이 조각나는 외부 단편화가 생긴다. 자세한 내용은 세그먼테이션 문서를 보라.
GE 645(멀틱스)처럼 세그먼트 표 항목이 세그먼트별 페이지 테이블을 가리키게 해 둘을 겹쳐 쓰는 방식도 있었다. x86도 80386부터 세그먼트로 만든 선형 주소를 다시 페이징하는 2단 구조다. 지금의 운영체제는 세그먼트를 0부터 시작하는 평탄한 하나로 두고 사실상 페이징만 쓴다.
8비트 시절에는 주소선보다 많은 메모리를 붙이려고, 레지스터 값으로 어느 메모리 뱅크를 주소 공간 창에 보일지 고르는 단순한 회로를 썼다. 아타리 130XE의 128KB 확장, 코모도어 128이 그 예이며, 이런 회로도 MMU라 불렀다.
64비트 주소 공간을 단일 표로 덮으면 표만 수백 TB가 필요하다. 그래서 가상 페이지 번호를 여러 조각으로 나눠 나무 구조의 표를 차례로 따라간다. 쓰지 않는 영역은 중간 표 자체를 만들지 않으므로 드문드문 쓰는 주소 공간을 적은 메모리로 표현한다. 표를 따라가며 최종 항목을 찾는 과정을 페이지 테이블 워크(page table walk)라 한다. 현대 x86과 ARM은 이 워크를 하드웨어가 직접 한다.
| 모드 | 가상 주소 | 단계 | 주소 분할(비트) | 비고 |
|---|---|---|---|---|
| x86 32비트 | 32 | 2 | 10 + 10 + 12 | 80386. 4MB 큰 페이지(PSE, 펜티엄) |
| x86 PAE | 32 | 3 | 2 + 9 + 9 + 12 | 펜티엄 프로. 물리 주소 36비트, NX 비트 사용 가능 |
| x86-64 4단계 | 48 | 4 | 9 + 9 + 9 + 9 + 12 | PML4, PDPT, PD, PT. 가상 256TiB, 물리 최대 64TiB(리눅스 기준) |
| x86-64 5단계(LA57) | 57 | 5 | 9 × 5 + 12 | 가상 128PiB, 물리 4PiB |
| AArch64(4KB 그래뉼) | 48(최대 52) | 4 | 9 × 4 + 12 | 16KB, 64KB 그래뉼도 지원 |
x86-64의 48비트 모드에서는 상위 16비트가 47번 비트와 같아야 하는 정규(canonical) 주소 규칙이 있다. 그래서 주소 공간이 아래쪽(사용자 공간)과 위쪽(커널 공간)으로 나뉘고 가운데에 쓸 수 없는 구멍이 생긴다. 리눅스는 4.14에서 5단계 페이징을 지원했다. 포인터 상위 비트를 따로 쓰는 JIT 컴파일러 등과의 호환을 위해, 기본으로는 47비트 아래 주소만 주고 mmap에 높은 주소를 힌트로 준 프로그램에만 그 위를 쓰게 한다.[1][2]
아래는 x86-64 4단계 페이징에서 가상 주소를 각 단계의 색인으로 나누는 계산이다.
#include <stdint.h>
#include <stdio.h>
int main(void) {
uint64_t va = 0x00007f3a12345678ULL;
unsigned pml4 = (va >> 39) & 0x1ff; /* 47~39번 비트 */
unsigned pdpt = (va >> 30) & 0x1ff; /* 38~30 */
unsigned pd = (va >> 21) & 0x1ff; /* 29~21 */
unsigned pt = (va >> 12) & 0x1ff; /* 20~12 */
unsigned off = va & 0xfff; /* 11~0: 페이지 안 오프셋 */
printf("PML4 %u, PDPT %u, PD %u, PT %u, offset 0x%x\n",
pml4, pdpt, pd, pt, off);
return 0;
}
워크는 CR3 레지스터가 가리키는 최상위 표에서 시작해 단계마다 항목 하나를 읽는다. 4단계면 TLB 미스 한 번에 메모리를 최대 4번 더 읽는다. 2MB 거대 페이지는 PD 단계에서 멈추고(21비트 오프셋), 1GB 페이지는 PDPT 단계에서 멈춘다. MMU는 중간 단계 항목을 따로 캐시(페이징 구조 캐시)해 워크를 줄인다.
- 역 페이지 테이블(inverted page table): 가상 페이지마다가 아니라 물리 프레임마다 항목 하나를 두고, 가상 주소를 해시해 찾는다. PowerPC와 IBM POWER가 해시 페이지 테이블을 썼다. 표 크기가 물리 메모리에 비례하는 장점이 있지만, 해시 때문에 이웃한 페이지의 항목이 흩어져 캐시 지역성이 나쁘다.
- 소프트웨어 관리: MIPS, 초기 SPARC 등은 TLB에 없으면 예외를 내고 운영체제가 표를 찾아 TLB를 직접 채운다. 알파는 이를 펌웨어(PALcode)가 맡았다. 자세한 내용은 변환 색인 버퍼 문서를 보라.
- 표를 MMU 안의 정적 RAM에 두는 방식: 선 마이크로시스템즈의 Sun-1~Sun-3 MMU는 문맥 레지스터, 세그먼트 맵, 페이지 맵을 보드 위 메모리에 두고, 문맥 레지스터만 바꿔 최대 16개 주소 공간을 오갔다.
x86-64 페이지 테이블 항목(PTE) 하나는 8바이트이며 물리 프레임 번호와 여러 속성 비트로 이뤄진다.
| 비트 | 이름 | 뜻 |
|---|---|---|
| P | Present | 1이면 유효한 변환. 0이면 접근 시 페이지 폴트 |
| R/W | Read/Write | 0이면 쓰기 금지 |
| U/S | User/Supervisor | 0이면 커널 모드에서만 접근 |
| A | Accessed | 페이지를 읽거나 쓰면 하드웨어가 1로 설정 |
| D | Dirty | 페이지에 쓰면 1로 설정. 스왑 아웃 때 디스크에 써야 하는지 판단 |
| G | Global | 주소 공간을 바꿔도 TLB에서 지우지 않음(커널 영역용) |
| PCD, PWT, PAT | 캐시 속성 | 캐시 금지, 연속 쓰기 등 메모리 형식 지정 |
| NX(XD) | No-Execute | 1이면 이 페이지의 코드를 실행할 수 없음 |
| PK | Protection Key | 4비트 보호 키. PKRU 레지스터로 사용자 공간에서 빠르게 권한 전환 |
- NX 비트: AMD64가 2003년 페이지 단위 실행 금지를 넣었고(인텔 명칭 XD), 스택이나 힙에 주입한 코드를 실행하는 공격을 막는 DEP와 W^X 정책의 바탕이 되었다. 그전 x86은 세그먼트 단위로만 실행을 막을 수 있어서, OpenBSD의 W^X나 리눅스 Exec Shield, PaX는 코드 세그먼트 길이를 줄이는 우회책을 썼다.
- SMEP와 SMAP: 커널 모드가 사용자 페이지의 코드를 실행하거나(SMEP) 데이터를 무심코 읽고 쓰는 것(SMAP)을 막는 x86 기능이다. 커널 취약점으로 사용자 공간에 준비한 코드로 뛰는 공격을 어렵게 한다.
- ARM의 페이지 속성에는 권한 수준별 읽기·쓰기 권한, 캐시 속성, 실행 금지 비트(UXN, PXN), 보안 세계를 구분하는 NS 비트가 있다.
- 커널과 사용자 공간을 같은 주소 공간에 두고 U/S 비트로만 가르면 시스템 호출 때 표를 바꿀 필요가 없어 빠르다. 그러나 멜트다운은 추측 실행으로 이 검사를 우회해 커널 메모리를 읽어 냈고, 대응책인 커널 페이지 테이블 격리(KPTI)는 사용자 모드용 표에서 커널 영역을 거의 다 빼는 방식이라 성능 비용이 따른다.
MMU가 변환할 수 없거나 권한을 어긴 접근을 만나면 페이지 폴트 예외를 낸다. x86은 폴트를 낸 주소를 CR2 레지스터에, 원인(없음, 쓰기 위반, 사용자 모드, 명령 가져오기 등)을 오류 코드에 담아 운영체제의 페이지 폴트 처리기로 넘긴다. 처리기는 원인에 따라 다음 중 하나를 한다.
| 종류 | 상황 | 처리 |
|---|---|---|
| 마이너 폴트 | 할당만 하고 아직 물리 페이지를 붙이지 않은 주소에 처음 접근, 또는 페이지 캐시에 이미 있는 파일 페이지 | 빈 프레임을 붙이거나 기존 프레임을 연결하고 재실행. 디스크 입출력 없음 |
| 메이저 폴트 | 스와프나 파일에서 읽어 와야 하는 페이지 | 디스크에서 읽어 온 뒤 재실행. 수 밀리초까지 걸림 |
| 쓰기 시 복사 | fork 뒤 공유 중인 읽기 전용 페이지에 쓰기 |
페이지를 복제해 각자 갖게 하고 재실행 |
| 잘못된 접근 | 매핑이 없는 주소, 권한 위반 | 사용자 프로세스에 SIGSEGV(윈도우는 접근 위반 예외)를 보냄 |
- 요구 페이징(demand paging)과 지연 할당: 운영체제는
malloc이나mmap요청에 가상 주소 범위만 주고 물리 메모리는 실제로 접근할 때 마이너 폴트로 붙인다. 그래서 스와프를 전혀 쓰지 않는 시스템에서도 페이지 폴트는 끊임없이 일어나며, 예약한 크기와 실제 사용량(RSS)이 다르다. - 실행 파일과 메모리 사상 파일의 코드 페이지는 디스크 원본과 같으므로, 메모리가 모자라면 디스크에 쓰지 않고 그냥 버렸다가 필요할 때 다시 읽는다. 힙과 스택 같은 익명 페이지는 스와프에 써야 버릴 수 있다.
- 커널은 모든 메모리를 페이지 폴트로 처리할 수 있는 것이 아니다. 인터럽트 처리 중이거나 스케줄러를 부를 수 없는 문맥(윈도우의 IRQL이 DISPATCH_LEVEL 이상인 상태)에서는 폴트 처리기가 디스크를 기다릴 수 없으므로, 이런 코드가 쓰는 메모리는 절대 내보내지 않는 비페이징 영역(nonpaged pool)에 둔다. 드라이버가 이 규칙을 어기면 윈도우는 IRQL_NOT_LESS_OR_EQUAL이나 PAGE_FAULT_IN_NONPAGED_AREA(0x50) 오류로 멈추고, 리눅스는 커널 oops나 패닉을 낸다.
- 페이지 폴트가 너무 잦아 CPU가 대부분의 시간을 페이지를 들이고 내보내는 데 쓰는 상태를 스래싱(thrashing)이라 한다.
- 메모리 보호 장치(MPU, Memory Protection Unit): 주소 변환은 하지 않고, 몇 개(보통 8~16개)의 영역마다 시작 주소, 크기, 권한만 검사한다. ARM Cortex-M, Cortex-R 같은 마이크로컨트롤러가 선택 사항으로 갖추며, 실시간 운영체제가 작업과 커널을 서로 보호하는 데 쓴다. 과거의 8~16비트 마이크로컨트롤러는 보호 기능도 없었지만, 안전과 보안 요구 때문에 MPU 채택이 늘고 있다.
- MMU 없는 리눅스(μClinux): 리눅스는
CONFIG_MMU=n으로 MMU 없는 프로세서에서도 빌드된다. 가상 주소가 없으므로fork()가 없고vfork()나CLONE_VM을 쓴clone()만 된다. 일반 파일의 공유 매핑이 되지 않고, 사적 매핑도 연속된 물리 페이지로만 만든다.[3] 프로세스 사이 보호가 없어 한 프로그램의 버그가 전체를 망칠 수 있다. - 버로스 B5000(1961년)과 그 뒤를 이은 유니시스 MCP 시스템은 MMU 대신 서술자(descriptor) 기반 구조를 썼다. 모든 메모리 참조가 하드웨어 태그가 붙은 서술자를 거치며, 서술자의 존재 비트가 0이면 운영체제가 세그먼트를 불러온다. 서술자가 페이지 테이블 항목 역할을 겸하는 셈이다.
가상 머신 안의 게스트 운영체제도 자기 페이지 테이블로 게스트 가상 주소를 게스트 물리 주소로 바꾼다. 하이퍼바이저는 게스트 물리 주소를 다시 실제 호스트 물리 주소로 바꿔야 한다.
- 그림자 페이지 테이블(shadow page table): 하드웨어 지원이 없던 시절, 하이퍼바이저가 게스트의 표를 감시하며 두 번의 변환을 합친 표를 따로 만들어 MMU에 물렸다. 게스트가 표를 고칠 때마다 가로채야 해서 비용이 컸다.
- 2단계 주소 변환(SLAT): MMU가 게스트 표와 하이퍼바이저 표를 모두 걷는다. AMD가 2007년 3세대 옵테론(바르셀로나)에서 NPT(Nested Page Tables, RVI)를, 인텔이 2008년 네할렘에서 EPT(Extended Page Tables)를 넣었다. ARM은 Stage 2 변환이라 부른다. VMware의 측정에서 EPT는 그림자 페이지 테이블보다 MMU 부하가 큰 작업에서 크게 빨랐고, TLB 미스 비용이 커지는 문제는 게스트와 호스트 양쪽에 거대 페이지를 쓰면 줄어들었다.[4] 두 단계 표를 모두 걷는 2차원 워크의 비용은 변환 색인 버퍼 문서에 정리했다.
- 입출력 장치의 DMA 주소도 같은 방식으로 바꿔야 하는데, 이것은 CPU의 MMU가 아니라 IOMMU가 맡는다.
DMA를 쓰는 장치는 CPU를 거치지 않고 메모리에 직접 접근하므로 CPU의 MMU를 우회한다. IOMMU는 장치 쪽에 놓인 별도의 MMU로, 장치가 내는 주소를 운영체제가 허락한 물리 페이지로만 바꿔 준다. 가상화의 장치 직접 할당과 DMA 공격 방어에 쓴다. 자세한 내용은 IOMMU 문서를 보라.
| 연도 | 사건 |
|---|---|
| 1961~1962 | 맨체스터 대학교 톰 킬번 팀의 아틀라스(Atlas) 컴퓨터가 연관 기억 방식의 페이지 주소 레지스터로 가상 주소를 실제 주소로 바꾸는 한 단계 저장소(one-level store), 곧 최초의 페이징 가상 메모리를 구현했다. 첫 양산기는 1962년 12월 가동되었다.[5] |
| 1961 | 버로스 B5000이 세그먼트 방식 가상 메모리를 상용화했다. |
| 1965 | IBM System/360 모델 67이 동적 주소 변환(DAT) 장치를 넣었다. GE 645는 멀틱스를 위해 세그먼트와 페이징을 결합했다. |
| 1970년대 | PDP-11이 세그먼트형 MMU로 16비트 가상 주소를 18비트, 22비트 물리 주소로 넓혔다. 1972년부터 IBM System/370에도 비슷한 DAT 기반 MMU가 들어갔다. |
| 1980년대 초 | 마이크로프로세서용 외장 MMU 칩이 나왔다. 모토로라 68451(세그먼트, 68010용), 지로그 Z8010과 Z8015, 내셔널 세미컨덕터 NS16082, 시그네틱스 68905 등이다. |
| 1982 | 인텔 80286이 보호 모드 세그먼테이션을 CPU에 넣었다. 페이징은 없었다. |
| 1984 | 모토로라 68851 PMMU가 68020용 페이징 MMU로 나왔다. 매킨토시 II(1987년)는 68851 자리에 애플 HMMU(VLSI VI475)를 기본으로 달았다. |
| 1985 | 인텔 80386이 32비트 페이징(2단계, 4KB 페이지)을 CPU에 넣었다. |
| 1987 | 모토로라 68030이 페이징 MMU를 CPU에 통합했다. |
| 1990년대 | 펜티엄의 4MB 페이지(PSE), 펜티엄 프로의 PAE(36비트 물리 주소)와 전역 페이지가 추가되었다. |
| 2003 | AMD64가 4단계 페이징과 NX 비트를 도입하고 세그먼테이션을 사실상 없앴다(FS, GS만 스레드 지역 저장소용으로 남음). |
| 2007~2008 | AMD NPT, 인텔 EPT로 가상화용 2단계 변환이 하드웨어에 들어갔다. |
| 2017 | 리눅스 4.14가 5단계 페이징(57비트 가상 주소)을 지원했다. 이후 인텔 아이스레이크 서버 프로세서 등이 이를 구현했다. |