MMU는 메모리 관리 장치로, CPU와 실제 물리 메모리(RAM) 사이에 위치한 하드웨어 모듈이다. 프로세스가 사용하는 **가상 주소(virtual address)**를 실제 메모리의 **물리 주소(physical address)**로 변환하는 역할을 핵심으로 한다. 또한 보호, 캐싱, 페이징 등 메모리 관리의 여러 기능을 담당하는 필수 하드웨어다.
MMU는 다음과 같은 기능을 수행한다.
- CPU는 모든 메모리를 가상 주소로 접근한다.
- MMU는 이 가상 주소를 페이지 테이블을 참조하여 물리 주소로 변환한다.
- 이 과정에서 TLB(Translation Lookaside Buffer)를 이용해 변환 속도를 높인다.
- 각 페이지에 대해 접근 권한(Read/Write/Execute)을 지정할 수 있다.
- 프로세스가 허용되지 않은 페이지에 접근하면 MMU가 **페이지 폴트(page fault)**를 발생시켜 보호 기능을 수행한다.
- MMU는 OS가 사용하는 페이징 기법을 가능하게 한다.
- 메모리 부족 시 일부 페이지를 디스크로 스왑아웃하고 필요 시 다시 로드할 수 있게 한다.
MMU의 존재는 현대 운영체제 기능의 전제가 된다.
- 하나의 프로세스가 다른 프로세스의 메모리를 침범할 수 없다.
- 운영체제 자체의 메모리도 보호된다.
- 실제 RAM보다 더 큰 메모리를 사용하는 것처럼 운영체제가 동작할 수 있다.
- 디스크 공간을 확장 메모리로 활용할 수 있다.
- 잘못된 포인터 접근이 곧바로 시스템 전체 붕괴로 이어지지 않는다.
- 프로세스마다 독립적인 주소 공간을 제공한다.
- 페이지 단위로 필요한 부분만 물리 메모리에 올리기 때문에 메모리를 효율적으로 사용한다.
MMU가 없던 시절(초기 컴퓨터 또는 일부 임베디드 시스템)에는 다음과 같은 문제가 있었다.
- 모든 프로그램이 동일한 물리 주소 공간을 공유한다.
- 버그 있는 프로그램 하나가 전체 시스템을 망가뜨릴 수 있다.
- 물리 메모리의 크기가 곧바로 프로그램이 사용할 수 있는 메모리의 최대치가 된다.
- 메모리가 부족하면 프로그램이 실행 자체가 불가능해진다.
- 잘못된 포인터 접근 → 시스템 전체 크래시로 이어질 가능성이 매우 높았다.
이 질문은 두 가지 상황 중 하나를 의미한다.
- MMU가 없는 시스템은 연속된 물리 메모리 블록을 요구한다.
- 단편화(fragmentation)가 발생하면 충분한 총 메모리가 있어도 큰 공간을 확보하지 못했다.
- 해결책은 메모리 압축(compaction) 또는 재부팅 뿐이었다.
MMU가 있는 시스템에서는 다음과 같이 해결한다.
- OS는 가상 주소에서는 연속된 공간을 제공한다.
- 실제 물리 메모리에서는 불연속 페이지를 여러 개 묶어 매핑한다.
- 큰 연속 공간이 필요할 때도 물리 주소의 연속성은 고려할 필요가 없다.
- 부족한 경우에는 페이지를 디스크로 스왑하여 공간을 확보할 수 있다.
MMU가 있으면 연속된 물리 메모리 부족 문제를 거의 고민하지 않아도 된다. 가상 주소 공간은 넓고, 물리 메모리 단편화와 무관하게 주소 공간을 할당할 수 있기 때문이다.
완전히는 아니다. 그러나 대부분은 해결된다.
- 물리 메모리 단편화로 인해 큰 연속 메모리를 못 할당하는 문제
- 프로세스 간 메모리 보호
- 물리 메모리 용량 부족 시 가상 메모리를 활용하는 문제
- 주소 범위 충돌
- 주소 공간 부족(32비트 시스템에서는 여전히 문제)
- 물리 메모리 부족 → 스와핑 증가 → 성능 저하
- 실시간 시스템에서는 스와핑이 문제가 되므로 MMU를 사용하더라도 제약이 있음
- GPU나 DMA 장치에서는 연속된 물리 메모리가 필요한 경우가 있음(이를 위해 IOMMU를 사용)
프로세스의 가상 주소를 물리 주소로 변환하기 위한 핵심 자료 구조다. 가상 주소는 보통 다음 두 부분으로 구성된다.
- 페이지 번호(Page Number): 가상 페이지의 인덱스
- 페이지 오프셋(Offset): 페이지 내에서의 위치
페이지 테이블은 페이지 번호를 인덱스로 하여 해당 페이지가 어느 물리 페이지에 매핑되는지 저장한다.
예:
가상 페이지 3 → 물리 페이지 7
가상 페이지 4 → 스왑 영역(디스크)
가상 페이지 10 → 물리 페이지 2
TLB는 ‘주소 변환 캐시’다. CPU가 매우 자주 접근하는 주소에 대해 변환 결과를 캐싱한다.
- 매우 빠른 메모리(일종의 작은 내부 캐시)로 구성된다
- 페이지 테이블 접근을 줄여 주소 변환 속도를 크게 높인다
- TLB에 없으면 “TLB 미스”가 발생하고, MMU가 페이지 테이블을 다시 조회한다
TLB 미스 시 페이지 테이블의 자료 구조를 따라가며 정확한 매핑을 찾아오는 하드웨어 또는 마이크로코드다. 특히 다단계 페이지 테이블 구조에서 많이 사용된다.
각 페이지 엔트리에는 다음과 같은 플래그가 포함된다.
- Read / Write / Execute 권한
- 페이지 존재 여부(Present bit)
- Dirty bit, Accessed bit
- Cacheable 여부
이 플래그가 잘못된 경우 MMU는 하드웨어적으로 페이지 폴트를 발생시킨다.
x86 아키텍처는 계층적 페이지 테이블 구조를 사용한다. 64비트 모드에서 보통 4단계 또는 5단계 페이지 테이블을 사용한다.
예: (4단계 페이징)
VA → PML4 → PDP → PD → PT → PA
- 4KB 페이지 외에 2MB, 1GB의 대형 페이지(huge page)를 지원한다
- 페이지 테이블 계층이 깊어 탐색 비용이 증가하므로 TLB 의존도가 매우 크다
- PC와 서버 환경에서 가장 보편적으로 사용된다
ARM 아키텍처는 모바일 및 임베디드에 특화되어 있어 구조가 단순하면서도 유연하다.
- 1단계 또는 2단계 페이지 테이블 구조
- granule(4KB / 16KB / 64KB) 선택이 가능하다
- PMP(Physical Memory Protection), PXN (Privileged eXecute Never) 등 보안 기능이 발달했다
- ARMv8 이후로 x86과 비슷한 가상 주소 확장 및 EL(권한 레벨) 기반 접근 제어를 지원한다
모바일/임베디드 환경에서 효율성과 전력 최적화가 뛰어나다
페이지 폴트는 대부분 다음 원인으로 발생한다.
- 페이지가 물리 메모리에 없음
- 접근 권한 위반
- 읽기 전용 페이지에 쓰기를 시도
- 잘못된 주소 접근
- CPU가 메모리에 접근
- MMU가 페이지 테이블을 확인
- 페이지가 없거나 권한 위반 → 페이지 폴트 예외 발생
- OS 커널이 예외 핸들러 실행
-
- 필요한 페이지를 디스크에서 읽어와서 물리 메모리에 올림
- 페이지 테이블 업데이트
- TLB 업데이트
- CPU 명령 재시도
이 흐름 덕분에 물리 메모리가 부족해도 프로그램은 연속 주소를 사용하는 것처럼 실행된다.
CPU는 MMU가 있으므로 가상 주소를 보호받지만, DMA 같은 장치는 가상 주소를 이해하지 못하고 물리 주소만 직접 접근한다.
이때 IOMMU가 필요하다.
- 장치가 접근하는 주소도 가상화한다
- DMA가 잘못된 주소에 접근하는 것을 막는다
- 장치 메모리 맵핑을 더 쉽게 한다
- GPU, 네트워크 장비, SSD에서 널리 사용된다
DMA가 잘못된 물리 주소를 접근하면 OS가 파괴될 수 있다. IOMMU가 이를 방지한다.
- 메모리 보호
- 주소 변환
- 프로세스 간 메모리 격리
- 메모리 오버커밋(virtual memory) 가능
- 메모리 단편화 문제 제거
- 스왑/페이징 가능
MMU가 없다면 현대적인 OS(Windows, Linux, Android, iOS)는 성립할 수 없다. 멀티태스킹도 사실상 불가능하다.
현대 OS의 메모리 관리는 크게 네 층으로 나눌 수 있다.
-
CPU + MMU
- 가상 주소 → 물리 주소 변환
- 접근 권한 체크, 페이지 폴트 발생
-
페이지 테이블 구조
- 어떤 가상 페이지가 어떤 물리 페이지(또는 디스크)에 있는지 매핑
-
OS의 메모리 관리 알고리즘
- 어떤 페이지를 메모리에 유지하고, 어떤 페이지를 내보낼지 결정
- LRU, Clock, Working Set 등
-
동적 메모리 할당기 / 단편화 처리
malloc/free, 커널의 buddy allocator, slab allocator 등
각 층을 하나씩 깊게 들어가보겠다.
페이지 테이블은 메모리에 있다. 가상 주소를 물리 주소로 바꾸려면:
- 다단계 페이지 테이블(예: x86-64 4단계)을 여러 번 메모리에서 읽어야 한다
- 매번 메모리 접근이 몇 번씩 더 늘어나 속도가 확 떨어진다
그래서 MMU는 **“최근에 변환한 VA → PA 결과를 캐싱”**하는데, 이게 TLB다.
TLB는 CPU 내부에 있는 작은 캐시라고 보면 된다.
-
엔트리 예시
- 가상 페이지 번호(VPN)
- 물리 페이지 번호(PPN)
- 접근 권한(R/W/X)
- ASID 또는 PCID(어느 프로세스의 주소 공간인지 식별)
- 유효 비트 / 글로벌 비트 등
보통 L1 TLB, L2 TLB 같이 여러 계층이 있고, 명령용 TLB, 데이터용 TLB를 분리하기도 한다.
CPU가 어떤 가상 주소 VA로 메모리에 접근할 때:
-
VA에서 페이지 번호를 뽑는다
-
TLB에서 이 페이지 번호를 키로 검색한다
- 있으면 TLB hit → 물리 페이지 번호(PPN) 얻음
- 없으면 TLB miss → 페이지 테이블 walk 수행
-
PPN + offset을 합쳐 물리 주소 PA를 만든다
TLB 미스가 나면 MMU는 페이지 테이블을 직접 뒤져서 PTE(Page Table Entry)를 찾는다. 이 작업을:
- 하드웨어가 직접 하는 구조(x86, ARM 대부분)
- OS가 트랩 받아서 직접 하는 구조(옛날 MIPS, 일부 RISC)
둘 다 있다.
찾은 PTE가:
- 유효하고, 권한도 OK면 → TLB에 새 엔트리로 넣고, 다시 명령 재시도
- 아예 존재하지 않거나 권한이 안 되면 → 페이지 폴트 예외 발생
TLB도 캐시이므로 가득 차면 뭔가를 쫓아내야 한다.
- 하드웨어는 보통 간단한 pseudo-LRU, FIFO, random 교체를 사용한다
- 너무 똑똑한 알고리즘을 쓰면 하드웨어가 복잡해지기 때문이다
프로세스 전환(context switch)이나 페이지 테이블 변경 시:
- 예전 주소 공간의 TLB 엔트리를 무효화해야 한다
- 그렇지 않으면 다른 프로세스가 엉뚱한 주소 매핑을 사용하게 된다
그래서:
- 전체 TLB flush
- 혹은 주소 부분 무효화(INVLPG 등)
- 멀티 코어에서는 다른 코어의 TLB도 함께 무효화(TLB shootdown)
성능 상 매우 중요한 영역이라 커널 코드에서 신중히 다룬다.
- 가상 페이지 번호를 그대로 인덱스로 쓰는 커다란 배열
- 예: 32비트 주소, 페이지 크기 4KB라면 페이지 개수 = 2²⁰ = 약 100만
- PTE 하나 4바이트라고 하면 4MB (프로세스 하나당)
- 64비트 주소에서는 완전히 비현실적이라 거의 안 씀
- 상위 비트부터 나눠서 트리 구조처럼 사용
- 실제로 사용되는 범위에 대해서만 테이블을 할당
- x86-64 4단계 페이지 테이블이 대표적이다
64비트 주소 중 상위 몇 비트만 사용한다고 치고:
VA:
[ PML4 index | PDP index | PD index | PT index | Offset ]
각 index는 보통 9비트씩, offset은 12비트(4KB 페이지)다.
- PML4: 최상위 테이블, 보통 프로세스마다 하나
- PDP, PD, PT: 각각 더 세부 레벨로 내려가는 테이블
각 레벨의 엔트리는 다음 레벨 페이지 테이블의 물리 주소를 가리킨다. 마지막 PT 엔트리(PTE)가 실제 물리 페이지 번호를 담고 있다.
페이지 테이블 자체도 결국 일반 물리 메모리에 존재하는 데이터 구조다.
- 커널이
kmalloc등으로 페이지를 할당받아 테이블로 사용 - 그 페이지들의 물리 주소를 상위 테이블에 적어 넣는다
- CPU는 CR3 레지스터 등에 최상위 테이블(PML4)의 물리 주소를 담고 있다
즉, “MMU가 특별한 메모리에 페이지 테이블을 저장한다”가 아니라 OS가 일반 메모리 위에 페이지 테이블 구조를 만들고, 그 위치를 MMU에게 알려주는 구조다.
TLB 엔트리 하나가 커버할 수 있는 메모리 크기를 키우기 위해:
- x86: 4KB, 2MB, 1GB 페이지 지원
- ARM: 4KB, 16KB, 64KB 등
장점
- TLB 엔트리 수가 적어도 많은 메모리를 커버 가능
- TLB 미스가 줄어듦
단점
- 단편화 증가(큰 단위로 잡기 때문)
- 세밀한 보호/매핑이 어려움
DB, 게임 서버, JVM 같은 경우 huge page를 활용하는 경우가 많다.
현대 OS는 필요할 때만 페이지를 메모리에 올리는 방식을 사용한다.
- 프로세스를 실행할 때 전체 코드를 RAM에 올리지 않는다
- 코드/데이터/스택/힙 페이지가 실제로 접근되는 순간에만 페이지를 채운다
장점:
- 시작 시점 메모리 사용량 감소
- 프로그램 로딩 속도 향상
- 사용되지 않는 부분은 끝까지 디스크에 남겨둘 수 있다
-
존재하지만 아직 메모리에 안 올라온 페이지
- 예:
.text섹션이 아직 로드되지 않았거나, lazy allocation 등 - OS가 디스크에서 해당 페이지를 읽어오고, 페이지 테이블 업데이트 후 재시도
- 예:
-
스왑 아웃된 페이지
- 예전에 메모리에서 쫓겨나 swap 공간에 저장됨
- 다시 접근하면 swap에서 읽어와 복구
-
권한 위반 페이지
-
읽기 전용인데 쓰기를 하려는 경우 등
-
경우에 따라:
- 진짜 버그 → SIGSEGV
- copy-on-write(COW) 최적화의 일부로 쓰기도 한다 (fork 후 첫 write 등)
-
-
완전히 잘못된 주소
- 매핑 자체가 없음
- 즉시 SEGFAULT 같은 예외
두 용어가 섞여서 쓰이지만 전통적 의미는 조금 다르다.
- 프로세스 전체를 통째로 디스크에 내보내고, 필요 시 다시 올리는 방식
- 매우 오래된 기법, 현대 범용 OS에서는 거의 사용하지 않는다
- 요즘 “swap”이라고 말할 때도 내부적으로는 페이지 단위로 움직이는 경우가 많다
-
페이지 단위로 메모리와 디스크 사이를 오르내리는 방식
-
현대 가상 메모리 시스템의 기본
-
OS는 메모리가 부족하면
- 덜 쓰이는 페이지를 선택해서 디스크(스왑 영역)에 저장
- 그 물리 페이지를 다시 다른 용도로 사용
- swap partition / swap file에 페이지 단위로 저장
- 커널 입장에서는 “페이지를 스왑으로 보낸다” → paging이지만 용어는 그냥 “swap out”이라고 부르는 경우가 많다
요약하면, 요즘은 사실상 ≒ “paging + swap 공간 사용” 구조로 이해하면 충분하다 정도로 보면 된다.
메모리에서 어떤 페이지를 내보낼지 결정하는 것이 페이지 교체 알고리즘이다.
- 앞으로 가장 오래 동안 쓰이지 않을 페이지를 내보내는 알고리즘
- 이론상 최적이지만, 미래를 알아야 하므로 현실에서는 불가능
- 다른 알고리즘을 평가할 때 기준으로 사용된다
- 가장 오래 동안 참조되지 않은 페이지를 내보냄
- “최근에 사용된 것은 또 쓸 가능성이 높다”는 locality 가정에 기반
문제:
- 진짜 LRU를 구현하려면 페이지 접근 순서를 정확히 기록해야 한다
- 하드웨어/소프트웨어 모두 비용이 크다
그래서 근사 LRU를 많이 쓴다.
현실에서 많이 사용하는 LRU 근사 알고리즘이다.
구조:
- 페이지들을 원형 큐(“시계 바늘”)로 관리
- 각 페이지마다 **참조 비트(reference bit)**를 둔다
동작:
- 바늘이 가리키는 페이지의 참조 비트를 본다
-
- 참조 비트가 0이면 → 이 페이지를 내보낸다
- 참조 비트가 1이면 → 0으로 지우고, 바늘을 다음 페이지로 이동
결국 한 번이라도 최근에 사용된 페이지는 한 바퀴 기회를 더 받는 구조다. 단순하면서도 locality를 어느 정도 반영한다.
- NRU: 참조 비트, 수정 비트 등을 조합해서 “최근에, 또는 자주 쓰이지 않은 페이지”를 후보로
- Aging: 참조 비트를 주기적으로 시프트하면서 “최근 사용 정도”를 점수 형태로 쌓는 방식
실제 OS 구현은:
- 하드웨어가 제공하는
Accessed/Referenced bit,Dirty bit등을 활용 - 커널의 page reclaim 코드가 여러 heuristic을 섞어서 사용
Linux의 active, inactive 리스트, kswapd 같은 것들이 이 영역이다.
페이지 교체가 너무 자주 일어나서:
- CPU는 실제 일을 하는 시간보다
- 페이지를 넣었다 뺐다 하는 데 시간을 더 쓰게 되는 상태다
원인:
- 메모리가 너무 부족한데 많은 프로세스를 동시에 돌릴 때
- 워킹 셋(실제로 자주 사용하는 페이지 집합)을 메모리에 모두 담지 못할 때
해결:
- 동시에 실행되는 프로세스 수 줄이기
- 더 큰 메모리
- 워킹 셋 기반 스케줄링 등
-
내부 단편화(Internal)
- 요청한 크기보다 큰 블록을 할당해야 해서
- 블록 내부에 사용하지 못하는 남는 공간이 생기는 것
-
외부 단편화(External)
- 전체 빈 공간은 충분한데
- 사이사이에 쪼개져 있어서 “큰 연속 블록”을 못 잡는 상황
MMU + 페이징은 외부 단편화 문제를 크게 줄인다는 점이 중요하다.
-
물리 메모리는 고정 크기의 페이지 프레임(예: 4KB) 단위로 관리
-
프로세스가 연속된 100MB를 가상 주소로 요구하더라도
- 물리 메모리는 여기저기 흩어진 페이지 프레임 25,600개만 있으면 된다
-
연속된 물리 주소가 필요하지 않다
그래서 예전 “메모리는 많은데 연속된 큰 블록이 없어서 할당 실패” 문제는 거의 사라졌다고 봐도 된다(일부 특수한 장치/특수 페이지를 제외하고).
커널이 물리 메모리를 관리하는 대표적인 방법이다.
- 2의 거듭제곱 크기(4KB, 8KB, 16KB, …)의 블록으로 관리
- 필요할 때 큰 블록을 쪼개서 두 개의 “buddy” 블록으로 만든다
- 다시 합칠 수 있을 때는 buddy들을 붙여서 큰 블록으로 복구
장점:
- 블록 합치기가 쉬워 외부 단편화를 어느 정도 제어 가능
- 구현이 단순
자주 사용하는 작은 객체들(커널 구조체 등)에 최적화된 할당기다.
- 같은 종류의 객체를 모아 “슬랩” 단위로 관리
- 캐시 친화적이며, 내부 단편화를 줄이고, 할당/해제가 빠르다
유저 공간의 malloc/free도 내부적으로 비슷한 아이디어(여러 크기 클래스, freelist 등)를 사용한다.
-
프로그램이 물리 메모리를 연속 블록으로 직접 요구했다
-
외부 단편화 때문에
- 전체 빈 공간은 예를 들어 300MB인데
- 200MB 연속 블록이 없어서 할당 실패 같은 일이 자주 있었다
해결 방법은:
- 메모리 compaction(재배치)을 수시로 하거나
- 사실상 재부팅에 가까운 수준의 큰 비용을 치러야 했다
이제 프로세스는 연속된 가상 주소 공간만 알고 있으면 된다.
-
OS는 물리 메모리 여기저기 퍼져 있는 페이지들을
- 연속된 가상 주소로 이어 붙여서 보여준다
-
“전체 용량은 있는데 덩어리로 연속된 블록이 없다” 문제는 대부분 사라진다
다만 예외는 있다:
-
DMA / 장치용으로 연속된 물리 메모리가 필요한 경우
- IOMMU로 어느 정도 해결
- 그래도 어떤 상황에서는 여전히 큰 연속 물리 블록이 필요해 실패할 수 있다
-
Huge page(2MB, 1GB) 같은 큰 단위 페이지
- 물리 메모리의 특정 정렬/연속성을 요구하기 때문에 할당 실패 가능
완전히 “안 걱정해도 된다” 수준은 아니다.
여전히 고민해야 하는 것:
-
물리 메모리 부족 자체
- 아무리 가상 메모리가 있어도, 실제 RAM이 부족하면 성능이 급격히 떨어진다
- 스왑이 과도해지면 스래싱 발생
-
주소 공간 자체의 한계
- 32비트 프로세스는 가상 주소 공간이 4GB라서 그 안에서만 놀아야 한다
- 커널/유저 나눔, mmap, heap, stack 때문에 실제 usable 공간은 더 적어질 수 있다
-
특정 요구사항(연속 물리, hugepage, 장치 메모리)
- 게임 서버, DB, 네트워크 스택에서 성능 최적화를 위해 일부 상황에서는 여전히 고려 대상이다
그래도, 일반적인 “메모리 많아 보이는데 큰 배열 하나 못 잡는” 류의 문제는 MMU + 페이징 덕분에 현실 세계에서 거의 사라졌다고 보면 된다.
온라인 게임 서버 개발자 입장에서 체감할 수 있는 포인트를 정리하면:
-
OOM과 스왑
-
프로세스 메모리 사용량이 커지면
- OS가 다른 페이지를 스왑으로 쫓아내고
- 심해지면 스래싱 또는 OOM killer 동작
-
“메모리는 어떻게든 OS가 알아서 늘려주겠지”가 아니라
- 실제 RAM, 스왑 사용량, 워킹 셋을 항상 신경 써야 한다
-
-
메모리 사용 패턴
- locality가 좋게(연속 접근) 메모리를 설계하면 TLB miss, cache miss 감소
- “수많은 객체를 랜덤한 포인터로 여기저기 접근”하는 패턴은 페이징 및 캐시 측면에서 손해
-
Huge page 사용
- JVM, DB, 대형 메모리 서버에선 성능 이득이 있을 수 있다
- 하지만 할당 실패, 단편화, 설정 복잡도 등 트레이드오프 존재
-
fork + COW
-
리눅스에서
fork()는 copy-on-write 덕분에 빠르게 작동한다 -
실제로는 페이지를 복사하지 않고,
- 부모/자식이 같은 페이지를 공유
- 쓰기 시점에 그 페이지만 복사
-
이 모든 것이 MMU + 가상 메모리 + 페이지 테이블 + TLB 덕분에 가능한 동작이다.