가상 메모리는 어떻게 물리 메모리로 매핑될까?
MMU, 페이지 테이블, TLB를 통해 가상 주소가 물리 주소로 변환되는 전체 흐름 이해하기
Table Of Contents
들어가기
컴퓨터에서는 브라우저, 메신저, 게임처럼 여러 프로세스가 동시에 돌아가요. 각 프로세스는 자기 데이터를 저장할 공간이 필요하고, 서로의 공간을 침범하지 않아야 안전해요.
이 글은 가상 메모리가 어떻게 동작하는지, 프로세스가 메모리를 쓸 때 CPU와 OS가 무엇을 하는지를 다뤄요.
- 가상 메모리를 한 문장으로 정의하고 왜 필요한지 설명할 수 있어요.
- 포인터로 주소에 접근할 때 CPU·MMU·TLB·페이지 테이블 쪽에서 일어나는 과정을 단계별로 말할 수 있어요.
- 프로세스 메모리 레이아웃(Text, Data, Heap, Stack)과 메모리 보호(User/Kernel 모드, 페이지 권한)가 접근을 어떻게 제한하는지 설명할 수 있어요.
가상 메모리의 탄생 배경
물리 주소의 한계와 가상 주소·가상 메모리
초기에는 프로세스가 물리 주소(Physical Address)를 그대로 사용했어요. 물리 주소는 RAM 칩에 붙어 있는 실제 주소로, CPU가 특정 위치의 데이터를 읽거나 쓸 때 그 위치를 지정하는 데 쓰는 번호예요.
물리 주소만 사용하면 세 가지 문제가 발생해요.
- 프로세스 간 메모리 격리가 어려워요. 프로세스 A가 쓰는 주소와 프로세스 B가 쓰는 주소가 겹칠 수 있어요. A가 잘못된 주소를 쓰면 B의 데이터를 덮어쓰거나, B가 A의 메모리를 읽을 수 있어요.
- 연속된 큰 공간이 필요해요. 프로세스가 1MB 연속 공간을 요구하면 물리 RAM에도 연속 1MB가 있어야 해요. 메모리가 조각 나 있으면 할당이 실패하고, 메모리의 데이터들을 재배치해야 해요.
- 물리 메모리 크기를 넘어서기 어려워요. RAM이 8GB라면, 8GB보다 큰 메모리를 필요로 하는 프로세스를 띄울 수 없어요.
그래서 프로세스가 보는 주소와 실제 RAM 주소를 나누는 방식이 등장했어요.
가상 주소(Virtual Address)는 프로세스가 사용하는 주소예요. 가상 메모리(Virtual Memory)는 프로세스마다 독립된 가상 주소 공간을 주고, 그 가상 주소를 필요할 때 물리 메모리나 디스크와 연결해 주는 방식이에요.
핵심은, 각 프로세스가 연속적이고 독립된 메모리를 가진 것처럼 보이게 만드는 추상화라는 점이에요. 각 프로세스는 0번지부터 시작하는 자기만의 가상 주소 공간을 갖고, 코드와 데이터가 그 공간 안에 연속으로 배치된 것처럼 동작해요. 다만 이 연속성은 가상 주소 관점의 모습이고, 실제 RAM에서는 대응되는 물리 주소가 서로 떨어져 있어도 괜찮아요. 프로세스마다 가상 주소를 물리 주소로 변환하는 매핑이 별도로 관리되기 때문에, 한 프로세스의 가상 주소가 다른 프로세스의 물리 영역으로 직접 연결되지는 않아요. 그래서 메모장, 게임, 브라우저 같은 프로세스가 모두 0번지부터 시작하는 주소 공간을 쓰더라도, 서로의 메모리를 침범하지 않고 동작할 수 있어요.
CPU가 명령을 실행할 때 내보내는 주소는 모두 가상 주소라서, 실제 RAM에 접근하려면 물리 주소로 바꾸는 단계가 필요해요. 이 변환을 담당하는 하드웨어가 MMU(Memory Management Unit)예요. 가상 주소를 물리 주소로 매핑하는 구현 방식에는 페이징(Paging)과 세그먼테이션(Segmentation) 두 가지가 있어요. 둘 다 MMU가 담당하고, OS가 만든 테이블(페이지 테이블 또는 세그먼트 테이블)을 MMU가 참조해 변환해요.
페이징에서는 가상·물리 메모리를 일정 크기로 자른 조각 단위로 나눠요. 동일한 크기의 블록이라도, 가상 주소 공간에서는 페이지(Page), 물리 메모리에서는 프레임(Frame)이라는 용어를 구분해 사용해요. 프레임은 RAM을 일정 크기로 분할한 물리적 단위이며, 프레임 번호(0, 1, 2, ...) 순서로 물리 주소 공간에 연속 배치돼요. 가상 페이지 번호와 물리 프레임 번호·권한을 매핑한 자료 구조가 페이지 테이블(Page Table)이고, MMU는 이 테이블을 보고 가상 주소를 물리 주소로 바꿔요. 고정 크기 단위라 외부 단편화가 적고, 페이지 단위로 스왑·디스크와 맞추기 좋아서 대용량 가상 공간에 잘 맞아요.
세그먼테이션에서는 가상 주소 공간을 논리 단위(코드, 데이터, 스택 등)로 나눠요. 각 세그먼트마다 시작 주소·길이·권한을 두고, 가상 주소를 (세그먼트 번호, 오프셋)으로 해석해요. MMU는 세그먼트 테이블을 보고 해당 세그먼트의 기준 주소에 오프셋을 더해 물리 주소를 계산해요. 세그먼트 크기가 가변이라 외부 단편화가 생기기 쉽고, 스왑·디스크와 연계하기도 페이징보다 까다로워요.
이렇게 가상 주소와 물리 주소를 나누면 위의 세 문제를 해결할 수 있어요.
- 프로세스 간 메모리 격리가 가능해요. 가상 주소를 프로세스마다 다른 물리 영역에 매핑해 두면, 한 프로세스가 다른 프로세스의 메모리를 읽거나 쓸 수 없어요.
- 연속된 공간이 없어도 연속처럼 쓸 수 있어요. 가상 주소 공간은 연속으로 보이게 하고, 실제 물리 메모리는 페이지 단위로 흩어져 있어도 돼요.
- RAM보다 큰 주소 공간을 쓸 수 있어요. RAM이 부족할 때 일부 페이지를 디스크로 내보내고 나중에 다시 올릴 수 있어요. 프로세스가 전체 주소 공간을 가진 것처럼 동작하면서 당장 쓰는 페이지만 물리 메모리에 두는 방식을 요구 페이징(demand paging)이라 해요.
현대 OS는 대부분 페이징을 쓰고, x86처럼 페이징과 세그먼테이션을 함께 쓰는 아키텍처도 있어요. 이 글에서는 페이징을 기준으로 주소 변환과 메모리 보호를 설명해요.
주소 변환: 포인터로 접근할 때 일어나는 일
프로세스는 실행 파일이 OS에 의해 메모리에 올라가서 돌아가는 단위예요.
브라우저, 서버, 게임 클라이언트처럼 사용자가 띄운 실행 단위 하나하나가 프로세스이고, 그 프로세스 안에서 변수·배열·함수 코드가 메모리에 올라가야 해요.
프로세스를 실행하면 CPU가 특정 변수를 읽으라거나 특정 위치에 저장하라고 하는데, 그 위치를 나타내는 주소가 필요해요.
이 주소가 포인터라는 변수에 담겨요.
C/C++에서는 int* ptr처럼 정수 값이 있는 메모리 위치를 가리키는 것을 명시적으로 쓰고, JavaScript나 Python처럼 포인터를 드러내지 않는 언어도 내부적으로는 객체가 있는 메모리 주소를 갖고 있어요.
주소는 최종적으로 메모리(RAM) 내의 물리적인 위치를 가리켜요. 메모리는 CPU가 명령을 실행할 때 데이터를 읽고 쓰는 저장 공간이에요. 프로세스가 변수를 읽거나 쓸 때마다 그 주소로 가서 데이터를 가져오거나 넣는 요청을 하는 것을 메모리에 접근한다고 해요. 프로세스가 변수를 읽거나 쓸 때 CPU가 쓰는 주소는 가상 주소이고, 실제 RAM에 접근하려면 이 주소를 물리 주소로 바꿔야 해요. 이제부터 이 주소를 물리 주소로 바꾸는 과정을 알아볼게요.
MMU와 페이지 테이블
페이지 크기는 OS에 따라 다르지만 보통 4KB나 16KB예요. 너무 작으면 관리 오버헤드가 커지고, 너무 크면 한 페이지 안에서 쓰지 않는 공간이 늘어나는 내부 단편화가 심해져요. OS가 프로세스마다 페이지 테이블을 하나씩 관리하고, MMU가 이 테이블을 사용해 변환해요.
변환 규칙은 OS가 페이지 테이블로 만들어 두어요.
프로세스가 생성될 때 OS는 그 프로세스 전용 페이지 테이블을 만들고, 사용 가능한 물리 프레임을 골라 할당한 뒤 가상 페이지 번호별로 물리 프레임과 권한(R/W/X)을 페이지 테이블에 기록해요.
CPU가 그 프로세스를 실행할 때는 이 프로세스의 페이지 테이블 시작 주소를 MMU에 알려 두고, MMU는 매 접근마다 그 테이블을 참조해 변환해요.
이후 프로세스가 malloc으로 힙을 확장하는 등 메모리를 더 필요로 할 때마다, 해당 가상 구간에 대한 매핑을 추가하거나 갱신해요.
변환 흐름은 다음과 같아요.
- 가상 주소를 VPN(Virtual Page Number, 페이지 번호)과 offset(페이지 안에서의 위치)으로 나눠요.
- MMU가 현재 프로세스의 페이지 테이블에서 VPN에 해당하는 물리 프레임 번호를 찾아요.
- 물리 주소 = (물리 프레임 번호 × 페이지 크기) + offset 으로 계산해요.
여기에서는 페이지 크기가 4KB일 때, 가상 주소를 물리 주소로 변환하는 방법을 살펴볼게요.
4KB = 2^2 * 2^10 Byte = 2^12 Byte = 4096 Byte이므로 한 페이지 안에는 4096개의 Byte가 있어요.
페이지 안에서 몇 번째 Byte인지 표현하려면 4096가지 값이 필요하므로, bit 12개(2^12 = 4096)면 0~4095번째 Byte를 모두 표현할 수 있어요.
가상 주소의 하위 12 bit는 offset, 나머지 상위 bit는 VPN을 나타내요.
예를 들어서, 가상 주소 0x12345678에서 하위 12 bit 0x678은 그 페이지 안에서 몇 번째 Byte인지(offset)를 나타내고, 하위 12 bit를 제외한 상위 bit 0x12345는 VPN이에요.
페이지 테이블에서 VPN 0x12345가 물리 프레임 0xABCDE에 매핑되어 있다고 한다면, 물리 주소는 0xABCDE번째 프레임의 0x678번째 Byte가 되므로 0xABCDE678이 돼요.
MMU는 변환할 때 권한(읽기 R, 쓰기 W, 실행 X)도 함께 확인해요. 허용되지 않은 접근(읽기 전용 영역에 쓰기, 사용자 코드가 커널 영역 접근 등)이면 예외(fault)를 발생시키고 OS가 처리해요.
페이지 테이블의 위치와 PTBR
MMU가 가상 주소를 물리 주소로 변환할 때, 현재 프로세스의 페이지 테이블을 먼저 참조해야 변환을 시작할 수 있어요. 페이지 테이블 역시 메모리(RAM)에 저장되고, 이 테이블의 위치를 MMU에 알려 주는 CPU 레지스터가 PTBR(Page Table Base Register)이에요. PTBR에는 현재 실행 중인 프로세스의 페이지 테이블이 시작되는 물리 주소가 들어 있어요.
OS가 컨텍스트 스위치로 다른 프로세스를 실행할 때는 PTBR 값을 새 프로세스의 페이지 테이블 시작 주소로 바꿔요. 즉, 프로세스가 바뀌면 참조하는 페이지 테이블이 바뀌고, 그에 따라 가상 주소가 가리키는 물리 주소도 전부 달라져서 주소 공간이 완전히 분리돼요.
TLB: 주소 변환 비용 줄이기
페이지 테이블이 물리 메모리에 있기 때문에, 변수 하나를 읽을 때도 메모리를 두 번 접근해야 해요. 한 번은 페이지 테이블에서 프레임 번호를 찾기 위해, 한 번은 변환된 물리 주소로 실제 데이터를 읽기 위해서예요. 이 비용을 줄이기 위해 자주 쓰는 변환 결과를 캐시하는 TLB(Translation Lookaside Buffer)가 있어요.
TLB는 MMU 안에 있는 작은 캐시로, 가상 페이지 번호에 대한 물리 프레임 번호와 권한 같은 변환 결과를 저장해요. 보통 수십~수백 개의 엔트리를 갖고 있어요. TLB는 CPU 칩 안에 있는 SRAM이라 RAM보다 훨씬 빠르기 때문에, 페이지 테이블 조회라는 느린 단계를 TLB 조회라는 빠른 단계로 대체하는 효과가 있어요. 가상 메모리를 쓰면 매번 주소 변환이 필요해서 이론적으로는 접근이 느려질 수 있지만, TLB가 변환 결과를 캐시해 두어서 대부분의 접근에서는 추가 비용이 거의 없어요. TLB가 없었다면 매 접근마다 페이지 테이블을 메모리에서 읽어야 해서 메모리 접근 횟수가 두 배로 늘어나고, 그만큼 지연이 커져요.
CPU가 가상 주소를 넘기면 MMU는 먼저 TLB에서 해당 VPN이 있는지 찾아요. TLB에 있으면(TLB hit) 페이지 테이블을 거치지 않고 곧바로 물리 주소를 만들어 메모리에 접근해요. TLB에 없으면(TLB miss) 페이지 테이블을 메모리에서 읽어서 변환한 뒤 그 결과를 TLB에 넣어 두고, 다음에 같은 VPN을 쓰면 TLB에서 바로 찾을 수 있어요.
포인터로 주소에 접근할 때의 전체 흐름은 다음과 같아요.
- CPU가 가상 주소로 메모리 접근을 요청해요.
- MMU가 TLB에서 변환 정보를 찾거나, 없으면 페이지 테이블을 조회해서 물리 주소로 바꿔요.
- 변환할 때 권한(읽기/쓰기/실행)을 확인하고, 위반이면 Page Fault 예외를 내요.
- 변환이 성공하면 물리 주소로 실제 RAM에 접근해요.
Page Fault와 스왑
Page Fault는 접근한 가상 주소에 대응하는 페이지가 아직 RAM에 없거나 권한이 맞지 않을 때 MMU/CPU가 발생시키는 예외예요. 발생하면 OS의 page fault handler가 처리해요.
OS는 프로세스를 띄울 때 필요한 페이지를 처음부터 전부 RAM에 올리지 않고, 해당 주소에 접근할 때만 페이지를 할당하고 올려요. 이런 방식을 요구 페이징(demand paging) 또는 지연 할당(lazy allocation)이라 해요. 그래서 처음 접근하는 페이지나 아직 매핑만 하고 물리 프레임을 안 붙인 페이지에 접근하면 Page Fault가 나고, 그때 handler가 페이지를 올리는 것은 정상 동작이에요. Page Fault는 반드시 오류만 의미하는 것이 아니라, 요구 페이징이 동작하는 과정에서 자연스럽게 발생하는 경우가 많아요.
Page Fault는 크게 세 가지 상황에서 발생해요.
- 요청한 페이지가 메모리에 없을 때: 처음 접근하는 페이지이거나, 스왑 아웃되어서 메모리에서 내려간 경우에요.
- 페이지 테이블에 매핑이 없을 때: 아직 해당 가상 주소에 물리 프레임이 할당되지 않았을 수 있어요.
- 접근 권한 위반일 때: 읽기 전용 페이지에 쓰기 요청을 보내거나, 사용자 코드가 커널 공간에 접근하려고 하는 경우에요.
페이지가 메모리에 없을 때나 매핑이 없을 때에는 해당 페이지를 물리 프레임에 올려야 해요. RAM이 부족할 때 OS는 당장 쓰지 않는 페이지를 디스크의 특별한 영역으로 빼 두는데, 그 디스크 영역을 스왑(swap)이라고 해요. RAM에 있던 페이지를 스왑으로 옮기는 걸 스왑 아웃, 스왑에 있던 페이지를 다시 RAM으로 올리는 걸 스왑 인이라 해요. 그래서 프로세스가 예전에 스왑 아웃된 페이지를 다시 접근하면, 그 페이지는 이미 RAM에 없으니까 Page Fault가 발생하고, handler가 그 페이지를 스왑에서 읽어와서(스왑 인) 프레임에 올려요. 빈 프레임이 없으면 handler는 내보낼 페이지를 골라 스왑 아웃해서 자리를 만든 뒤, 그 프레임에 필요한 페이지를 올려요. 이 흐름을 의사 코드로 옮기면 아래와 같아요.
void page_fault_handler(int fault_address) { if (is_protection_violation(fault_address)) { // 접근 권한 위반: SIGSEGV를 보내 프로세스를 종료해요 terminate_process(); return; } int frame = find_free_frame(); if (frame == -1) { // 빈 물리 프레임이 없으면 교체할 페이지를 골라 스왑 아웃시켜요 frame = evict_page_using_lru(); } // 디스크에서 필요한 페이지를 물리 프레임에 올려요 load_page_from_disk(fault_address, frame); // 페이지 테이블을 갱신해요 update_page_table(fault_address, frame); // 실패했던 명령을 다시 실행해요 restart_instruction(); }
내보낼 페이지를 고를 때 쓰는 알고리즘에는 대표적으로 LRU(Least Recently Used)와 FIFO(First-In First-Out)가 있어요.
FIFO는 가장 먼저 메모리에 들어온 페이지를 내보내는 방식이에요. 구현이 단순하지만, 오래 들어왔다는 이유만으로 아직 자주 쓰는 페이지가 내보내질 수 있어요.
LRU는 가장 오래 접근하지 않은 페이지를 내보내는 방식이에요. 최근에 쓴 페이지는 당분간 다시 쓸 가능성이 높다는 지역성(locality)을 이용해요. 그래서 FIFO보다 보통 Page Fault 횟수가 적어서, 실제로는 LRU에 가까운 정책을 많이 써요.
다단계 페이지 테이블: 페이지 테이블 메모리 오버헤드 줄이기
64bit OS처럼 가상 주소 공간이 큰 시스템에서는 가능한 VPN 개수가 너무 많아서, 단일 페이지 테이블을 쓰면 모든 VPN에 대한 엔트리를 한 테이블에 두어야 하고, 테이블 자체가 매우 커져요. 그러면 페이지 테이블만으로도 메모리를 많이 쓰는 문제가 생겨요. 다단계 페이지 테이블은 이런 현상을 줄이기 위한 구조예요. 상위 단계 테이블은 해당 구간에 실제 매핑이 있는지만 가리키고, 실제로 사용하는 가상 주소 구간에 대해서만 하위 단계 테이블을 할당해요. 사용하지 않는 주소 범위에는 하위 테이블을 만들지 않으므로, 필요한 구간만 희소하게(sparse) 관리해서 페이지 테이블이 차지하는 메모리를 줄일 수 있어요.
(출처: https://cs4118.github.io/www/2023-1/lect/18-x86-paging.html)
참고 자료
- Operating System Concepts (OSTEP) - 가상 메모리와 페이징 메커니즘
- Computer Systems: A Programmer's Perspective - 시스템 프로그래밍과 메모리 관리
- Understanding the Linux Virtual Memory Manager - Linux 가상 메모리 구조
- Intel 64 and IA-32 Architectures Software Developer's Manual - x86 메모리 관리와 페이지 테이블