현대 시스템은 메인 메모리를 더 효율적이고 안전하게 관리하기 위해 가상 메모리(VM)라는 추상화 개념을 제공합니다. 가상 메모리는 하드웨어와 커널 소프트웨어의 상호작용을 통해 각 프로세스에게 크고, 균일하며, 독립적인 전용 주소 공간을 제공하는 메커니즘입니다.
메인 메모리를 여러 프로세스가 직접 공유하면 다음과 같은 문제가 발생합니다.
가상 메모리는 하나의 메커니즘을 통해 다음 세 가지 중요한 기능을 제공합니다.
가상 메모리는 자동으로 동작하지만, 프로그래머가 이를 이해해야 하는 이유는 다음과 같습니다.

컴퓨터의 메인 메모리는 연속된 바이트 크기의 셀 배열로 구성되며, 각 바이트는 고유한 물리 주소(Physical Address, PA)를 가집니다.

현대 프로세서는 가상 주소 지정 방식을 사용합니다.
주소 공간은 음이 아닌 정수 주소들의 순서 있는 집합 {0, 1, 2, ...} 입니다. 이 정수들이 연속적일 때, 이를 선형 주소 공간(linear address space)이라고 부릅니다.
{0, 1, 2, ..., N-1}{0, 1, 2, ..., M-1}주소 공간이라는 개념은 데이터 객체(바이트)와 그 속성(주소)을 명확하게 분리합니다.
이러한 분리를 통해, 각 데이터 객체가 서로 다른 주소 공간에 속한 여러 개의 독립적인 주소를 가질 수 있게 됩니다. 이것이 바로 가상 메모리의 기본 아이디어입니다. 메인 메모리의 각 바이트는 가상 주소 공간에 속한 가상 주소와, 물리 주소 공간에 속한 물리 주소를 모두 가지게 됩니다.

개념적으로 가상 메모리는 디스크에 저장된 N개의 연속적인 바이트 배열로 볼 수 있습니다. 디스크에 있는 이 배열의 내용은 메인 메모리에 캐시(cache)됩니다.
메모리 계층의 다른 캐시와 마찬가지로, 하위 계층(디스크)의 데이터는 상위 계층(메인 메모리)으로 전송될 때 블록(block) 단위로 움직입니다.
어떤 시점에서든, 가상 페이지의 집합은 다음 세 가지의 서로 다른 부분 집합으로 나뉩니다.
↓ 추가 내용
Unallocated는 운영체제(OS)가 해당 프로세스를 위해 아직 어떠한 가상 메모리 관련 자료구조도 만들지 않은 상태를 의미합니다.
a.out 또는 .exe)만 존재할 뿐입니다.사용자가 ./a.out을 실행하여 프로세스가 생성되는 순간, OS 로더(loader)가 동작하며 가상 메모리 공간을 초기화합니다. 이 과정이 바로 할당(Allocation)입니다.
.text), Initialized Data(.data) 세그먼트: 이 세그먼트에 해당하는 가상 페이지들은 스왑(swap) 영역으로 복사되지 않습니다. 대신, 커널은 각 PTE에 "이 가상 페이지의 내용은 디스크에 있는 실행 파일의 몇 번째 오프셋에 있다"라고 매핑 정보만 기록합니다. 이를 메모리 매핑(Memory Mapping)이라고 합니다..bss), 스택(Stack), 힙(Heap) 세그먼트: 이 세그먼트들은 실행 파일에 내용이 없으므로, 커널은 이 가상 페이지들이 스왑 공간을 사용하도록 PTE에 표시합니다. 혹은 당장 필요할 때 0으로 채워진 물리 페이지를 주기로 약속하는 요구-제로 페이지(demand-zero page)로 설정합니다..bss, 스택, 힙 세그먼트가 스왑 공간을 사용하는 이유는, 이 페이지들이 특정 파일에 기반하지 않는 익명 페이지(Anonymous Pages)이기 때문입니다.
모든 가상 페이지는 물리 메모리(RAM)에서 쫓겨날 경우 돌아갈 수 있는 뒷받침 저장소(Backing Store)가 반드시 필요합니다. 이 저장소의 종류에 따라 페이지는 두 가지로 나뉩니다.
.text (코드), .data (초기화된 데이터) 세그먼트.bss, 스택, 힙 세그먼트간단히 말해, .text 페이지는 RAM에서 쫓겨나도 돌아갈 "본가"(실행 파일)가 있지만, .bss, 스택, 힙 페이지는 그런 "본가"가 없기 때문에 임시 거처인 "스왑 공간"을 뒷받침 저장소로 지정하는 것입니다.
핵심 아이디어는 "프로세스가 실제로 해당 메모리에 쓰기 전까지는, 물리 메모리(RAM)는 물론 디스크(스왑)에도 아무런 공간을 할당하지 않고 약속만 해두는 것"입니다.
.bss나 스택을 위한 가상 메모리 공간이 예약됩니다..bss 배열)에 대해서는 물리 메모리나 디스크 공간을 전혀 낭비하지 않습니다. "요구(Demand)"가 있을 때만 자원을 할당하므로 매우 효율적입니다."가상 메모리 공간이 예약된다"는 말은, 실제 물리 메모리(RAM)나 디스크를 사용하는 것이 아니라, 커널이 관리 장부에 "이 주소 범위는 앞으로 이런 용도로 사용할 것이다"라고 논리적으로 기록만 해두는 것을 의미합니다.
이는 실제 공간을 할당하는 행위가 아니라, 주소 범위를 특정 목적(예: 스택, 힙)으로 선점해두는 개념적인 약속입니다.
프로세스가 시작될 때, 커널은 해당 프로세스를 관리하기 위한 자료구조를 자신의 메모리 공간에 만듭니다. 리눅스의 경우, VMA(vm_area_struct)라는 구조체를 사용합니다.
"스택을 위한 가상 메모리 공간이 예약된다"는 것은 커널이 다음과 같은 내용을 담은 VMA를 생성한다는 의미입니다.
스택 VMA:
- 주소 범위:
0x7ffffffde000부터0x7ffffffff000까지- 접근 권한: 읽기/쓰기 가능
- 특징: 아래 방향으로 자라나는(grows down) 영역
이 VMA가 생성되는 순간이 바로 "예약"이 완료된 시점입니다. 이 단계에서는:
이것은 커널이 프로세스에게 "네가 이 주소 범위를 사용하면, 내가 알아서 처리해 줄게"라고 약속하는 것과 같습니다. 실제로 프로세스가 이 주소 범위 내의 특정 주소에 처음 접근하여 페이지 폴트를 일으키기 전까지는 어떠한 물리적 자원도 소모되지 않습니다.
네, 정확히 그 시점이 맞습니다. 하지만 조금 더 포괄적으로 말하면, .bss 세그먼트에 속한 전역/정적 변수나, 힙(heap), 스택(stack) 영역에 있는 특정 가상 주소에 처음으로 접근(read 또는 write)하는 바로 그 순간에 요구-제로 페이지를 달라고 요청하게 됩니다.
요구-제로 페이지를 "요청"하는 행위는 프로그램이 getPage() 같은 함수를 호출하는 것이 아닙니다. 이 요청은 하드웨어 수준에서 발생하는 페이지 폴트(Page Fault)를 통해 이루어집니다.
.bss에 있는 int global_var;는 가상 주소 공간에 논리적으로만 존재합니다.global_var를 위한 어떤 물리적 공간도 존재하지 않습니다.global_var를 처음으로 읽거나 쓰려는 명령(mov global_var, %eax 등)을 실행합니다.global_var의 가상 주소를 물리 주소로 변환하려고 PTE를 확인합니다.global_var는 실제 물리 메모리에 자신의 공간을 갖게 됩니다.이 "필요할 때만 할당하는" 원리는 .bss뿐만 아니라 다른 메모리 영역에도 동일하게 적용됩니다.
malloc을 호출하여 새로운 메모리를 요청했을 때, 커널은 VMA를 통해 가상 주소 공간만 예약해주고, 프로그램이 그 영역에 처음 접근할 때 페이지 폴트를 통해 실제 물리 페이지를 할당해 줍니다.페이지를 항상 스왑 영역에서 가져오는 것은 아닙니다. 페이지의 종류에 따라 가져오는 위치, 즉 뒷받침 저장소(Backing Store)가 다릅니다.
페이지 폴트가 발생했을 때, 커널은 해당 페이지가 어떤 종류인지에 따라 다음 세 가지 중 하나의 동작을 수행합니다.
.text)나 초기화된 전역 변수(.data)처럼, 원본 내용이 실행 파일에 그대로 있는 페이지..bss)나, 이제 막 할당된 힙/스택 공간.프로세스가 실제로 명령어를 실행하며 특정 가상 주소에 접근하는 순간, 다음과 같은 과정이 발생합니다.
메모리 계층의 여러 캐시를 명확히 구분하기 위해 다음과 같이 용어를 정의합니다.
DRAM 캐시의 구성 방식은 전적으로 막대한 미스 비용(enormous cost of misses)에 의해 결정됩니다.
이러한 성능 차이 때문에, SRAM 캐시 미스는 DRAM에서 데이터를 가져오지만, DRAM 캐시 미스는 디스크에서 데이터를 가져와야 하므로 비교할 수 없을 정도로 큰 성능 저하를 유발합니다.
이러한 막대한 미스 비용 때문에 DRAM 캐시는 다음과 같은 특징을 갖도록 설계되었습니다.
VM 시스템은 페이지 테이블을 사용하여 가상 페이지(VP)가 DRAM에 캐시되었는지, 만약 그렇다면 어느 물리 페이지(PP)에 있는지 판단합니다. 페이지 폴트가 발생하면, 페이지 테이블을 참조하여 디스크 상의 페이지 위치를 찾아 물리 메모리로 가져옵니다.
이 과정은 운영체제(OS), MMU(메모리 관리 장치), 그리고 물리 메모리에 저장된 페이지 테이블의 협력으로 이루어집니다.
페이지 테이블은 페이지 테이블 항목(PTE, Page Table Entry)들의 배열입니다. 각 가상 페이지는 페이지 테이블의 고정된 위치에 자신만의 PTE를 가집니다.
각 PTE는 최소한 두 가지 정보를 포함합니다.

제공된 텍스트의 예시(Figure 9.4)는 8개의 가상 페이지와 4개의 물리 페이지가 있는 시스템을 보여줍니다. DRAM 캐시는 완전 연관 방식이므로, 어떤 물리 페이지든 어떤 가상 페이지든 담을 수 있습니다.

CPU가 요청한 가상 주소의 페이지가 이미 DRAM에 캐시되어 있는 경우를 페이지 히트라고 합니다.


가상 메모리에서 DRAM 캐시 미스가 발생한 경우를 페이지 폴트라고 합니다.
가상 메모리 시스템은 SRAM 캐시와 용어가 약간 다릅니다.

malloc 호출과 같이 새로운 가상 메모리 페이지가 필요할 때, 운영체제는 다음과 같이 페이지를 할당합니다.
이때 할당된 페이지는 아직 물리 메모리에 없으므로, "캐시되지 않음(Uncached)" 상태로 시작합니다.
가상 메모리는 막대한 미스 비용 때문에 매우 비효율적일 것이라는 첫인상을 주기 쉽습니다. 하지만 실제로는 지역성(locality) 원리 덕분에 매우 잘 동작합니다.
가상 메모리(VM)는 단순히 DRAM을 디스크의 캐시로 사용하는 것을 넘어, 메모리 관리를 대폭 단순화하고 메모리를 보호하는 강력한 도구입니다.
이 기능의 핵심은 운영체제가 각 프로세스마다 별도의 페이지 테이블(page table)을 제공한다는 점입니다. 결과적으로, 모든 프로세스는 자신만의 독립적인 가상 주소 공간(virtual address space)을 갖게 됩니다. 이를 통해 링킹, 로딩, 공유, 메모리 할당이 매우 단순해집니다.
독립적인 주소 공간 덕분에, 모든 프로세스는 코드와 데이터가 실제 물리 메모리 어디에 위치하는지와 상관없이 동일하고 표준적인 메모리 레이아웃을 사용할 수 있습니다.
0x400000에서 시작하고, 스택은 최상위 주소에서 아래로 자랍니다.가상 메모리는 실행 파일을 메모리로 가져오는 로딩 과정을 매우 효율적으로 만듭니다.

독립적인 주소 공간은 프로세스 간 코드 및 데이터 공유를 위한 일관된 메커니즘을 제공합니다.
printf 등)나 커널 코드는 모든 프로세스가 공통으로 사용합니다. 각 프로세스마다 이 코드를 메모리에 따로 올리는 대신, 운영체제는 여러 프로세스의 서로 다른 가상 페이지들이 동일한 물리 페이지를 가리키도록 매핑합니다.가상 메모리는 프로그램이 malloc을 호출하여 추가 메모리를 요청할 때 할당 과정을 단순화합니다.
malloc(16MB) 요청을 처리하기 위해 16MB 크기의 연속된 빈 물리 메모리 공간을 찾아야 합니다. 메모리 단편화(fragmentation) 때문에 이는 매우 어렵습니다.프로그램이 malloc(16MB)를 호출하면, C 라이브러리는 커널에게 더 많은 힙 공간을 요청합니다 (sbrk 또는 mmap 시스템 콜 사용).
이때 커널이 하는 일은 다음과 같습니다.
vm_area_struct)를 생성하여 "이 영역은 힙을 위한 공간이다"라고 논리적으로 기록합니다.이것이 바로 "16MB 크기의 연속된 가상 페이지를 할당한다"는 설명의 의미입니다.
이제 프로그램이 malloc으로 받은 16MB 공간 내의 특정 주소에 처음으로 접근하려고 시도합니다.
이것이 바로 "malloc할 때 디스크(스왑 공간)에 공간을 만들어 놓는다"는 설명의 진짜 의미입니다. 정확히는, "만든다"기보다는 "필요할 때 저장할 장소로 지정한다"는 것이 더 정확한 표현입니다.
두 설명을 malloc(16MB)의 전체 과정에 맞춰 정리하면 다음과 같습니다.
malloc과 디스크의 관계)결론적으로, malloc은 가상적으로는 연속적인 공간을 얻지만, 물리적으로는 흩어져 있는 RAM 페이지를 할당받으며, 이 페이지들은 디스크의 스왑 공간을 보험으로 가지고 있는 것입니다.
널은 malloc 요청에 대한 연속된 빈 가상 주소 공간을 찾기 위해, 전체 주소 공간을 처음부터 끝까지 스캔하는 비효율적인 방식을 사용하지 않습니다. 대신, 이미 할당된 메모리 영역(VMA)들만 효율적으로 관리하는 자료구조를 사용하여 빈 공간을 매우 빠르게 찾아냅니다.
만약 커널이 for 루프처럼 주소를 하나씩 스캔한다면, 다음과 같은 문제가 발생합니다.
커널은 "어디가 비어있는가?"를 찾는 대신, "어디가 사용 중인가?"를 기록하고 그 사이의 빈틈(gap)을 찾아냅니다.
vm_area_struct): 커널은 각 프로세스의 메모리 영역(코드, 데이터, 힙, 스택, 공유 라이브러리 등)을 VMA라는 구조체로 관리합니다. 각 VMA는 시작 주소, 끝 주소, 권한 등의 정보를 담고 있습니다.malloc 등으로 메모리를 요청합니다.현대 운영체제는 가상 메모리(VM) 메커니즘을 확장하여 메모리 시스템에 대한 접근을 정교하게 제어하고 보호합니다.
운영체제는 다음과 같은 상황을 막아야 합니다.
각 프로세스에 독립적인 가상 주소 공간을 제공하는 것만으로도 프로세스 간의 메모리 분리가 어느 정도 이루어집니다. 하지만 가상 메모리는 페이지 테이블 항목(PTE)에 접근 권한 비트(permission bits)를 추가함으로써 훨씬 더 세밀한 접근 제어를 제공합니다.
MMU(주소 변환 하드웨어)는 모든 메모리 접근 시마다 PTE를 읽기 때문에, 이 권한 비트를 확인하는 것은 자연스럽고 효율적입니다.

만약 어떤 명령어가 PTE에 설정된 권한을 위반하면, 다음과 같은 과정이 발생합니다.
SIGSEGV 시그널을 보냅니다.int rbtree_erase(rbtree *t, node_t *p) {
if (t == NULL || p == NULL || t->root == NULL) {
return 0;
}
node_t *find_node = rbtree_find(t, p->key);
if (find_node == NULL) {
return 0;
}
node_t *removed_node = NULL;
node_t *replacement_node = NULL;
node_t *parent_of_replacement = NULL;
color_t removed_color;
int is_root_deleted = find_node == t->root;
// Case 1: 자녀가 하나 혹은 없을 경우
if (find_node->left == NULL || find_node->right == NULL) {
removed_color = find_node->color;
parent_of_replacement = find_node->parent;
replacement_node = (find_node->left != NULL) ? find_node->left : find_node->right;
removed_node = remove_node_with_one_or_zero(&find_node);
if (is_root_deleted) {
t->root = find_node;
}
}
// Case 2: 자녀가 두 개일 경우
else {
node_t *successor = find_successor(find_node);
removed_color = successor->color;
replacement_node = successor->right;
parent_of_replacement = successor->parent;
if (parent_of_replacement == find_node) {
parent_of_replacement = successor;
}
removed_node = remove_node_with_one_or_zero(&successor);
find_node->key = successor->key;
}
if (removed_color == RBTREE_BLACK) {
rebalanceAfterDeletion(t, replacement_node, parent_of_replacement);
}
if (removed_node != NULL) {
free(removed_node);
}
return 1;
}
node_t* remove_node_with_one_or_zero(node_t ** node) {
if (node == NULL || *node == NULL) {
return NULL;
}
node_t *original_node_to_free = *node;
node_t *parent = original_node_to_free->parent;
node_t *child = (original_node_to_free->left != NULL) ? original_node_to_free->left : original_node_to_free->right;
if (child != NULL) {
child->parent = parent;
}
if (parent == NULL) {
*node = child;
} else {
if (parent->left == original_node_to_free) {
parent->left = child;
} else {
parent->right = child;
}
}
return original_node_to_free;
}
node_t * find_successor(node_t * node) {
if (node == NULL || node->right == NULL) {
return NULL;
}
node_t *cur = node->right;
while (cur->left != NULL) {
cur = cur->left;
}
return cur;
}
void rebalanceAfterDeletion(rbtree *t, node_t *node, node_t *parent) {
node_t *sibling;
// node가 루트가 아니고, "doubly black" 속성을 가질 동안 반복
while (node != t->root && (node == NULL || node->color == RBTREE_BLACK)) {
// node가 NULL이거나 parent가 없으면 더 이상 진행할 수 없습니다.
if (parent == NULL) break;
// Case: node가 왼쪽 자식인 경우
if (node == parent->left) {
sibling = parent->right;
// Case 1: 형제 sibling가 RED
if (sibling != NULL && sibling->color == RBTREE_RED) {
sibling->color = RBTREE_BLACK;
parent->color = RBTREE_RED;
node_t **ptr_to_parent = (parent->parent == NULL) ? &(t->root)
: (parent == parent->parent->left ? &(parent->parent->left) : &(parent->parent->right));
left_rotate(ptr_to_parent);
sibling = parent->right; // 회전 후 형제가 바뀌었으므로 갱신
}
// Case 2: 형제 sibling가 BLACK이고, sibling의 두 자식이 모두 BLACK
if (sibling == NULL || ((sibling->left == NULL || sibling->left->color == RBTREE_BLACK) &&
(sibling->right == NULL || sibling->right->color == RBTREE_BLACK))) {
if (sibling != NULL) sibling->color = RBTREE_RED;
node = parent; // 문제를 부모로 전가
parent = node->parent;
continue; // 다음 루프에서 처리
}
// Case 3: 형제 sibling이 BLACK, sibling의 왼쪽 자식이 RED, 오른쪽 자식이 BLACK
if (sibling->right == NULL || sibling->right->color == RBTREE_BLACK) {
if (sibling->left != NULL) sibling->left->color = RBTREE_BLACK;
sibling->color = RBTREE_RED;
right_rotate(&(parent->right)); // sibling는 parent의 오른쪽 자식이므로 &(parent->right)를 전달
sibling = parent->right; // 회전 후 형제가 바뀌었으므로 갱신
}
// Case 4: 형제 node가 BLACK, node의 오른쪽 자식이 RED
sibling->color = parent->color;
parent->color = RBTREE_BLACK;
if (sibling->right != NULL) sibling->right->color = RBTREE_BLACK;
node_t **ptr_to_parent = (parent->parent == NULL) ? &(t->root)
: (parent == parent->parent->left ? &(parent->parent->left) : &(parent->parent->right));
left_rotate(ptr_to_parent);
node = t->root; // 모든 문제가 해결되었으므로 루프 종료
} else { // Case: node가 오른쪽 자식인 경우
sibling = parent->left;
// Case 1: 형제 sibling가 RED
if (sibling != NULL && sibling->color == RBTREE_RED) {
sibling->color = RBTREE_BLACK;
parent->color = RBTREE_RED;
node_t **ptr_to_parent = (parent->parent == NULL) ? &(t->root)
: (parent == parent->parent->left ? &(parent->parent->left) : &(parent->parent->right));
right_rotate(ptr_to_parent);
sibling = parent->left;
}
// Case 2: 형제 sibling가 BLACK이고, sibling의 두 자식이 모두 BLACK
if (sibling == NULL || ((sibling->left == NULL || sibling->left->color == RBTREE_BLACK) &&
(sibling->right == NULL || sibling->right->color == RBTREE_BLACK))) {
if (sibling != NULL) sibling->color = RBTREE_RED;
node = parent;
parent = node->parent;
continue;
}
// Case 3: 형제 sibling가 BLACK, sibling의 오른쪽 자식이 RED, 왼쪽 자식이 BLACK
if (sibling->left == NULL || sibling->left->color == RBTREE_BLACK) {
if (sibling->right != NULL) sibling->right->color = RBTREE_BLACK;
sibling->color = RBTREE_RED;
left_rotate(&(parent->left));
sibling = parent->left;
}
// Case 4: 형제 sibling가 BLACK, sibling의 왼쪽 자식이 RED
sibling->color = parent->color;
parent->color = RBTREE_BLACK;
if (sibling->left != NULL) sibling->left->color = RBTREE_BLACK;
node_t **ptr_to_parent = (parent->parent == NULL) ? &(t->root)
: (parent == parent->parent->left ? &(parent->parent->left) : &(parent->parent->right));
right_rotate(ptr_to_parent);
node = t->root;
}
}
if (node != NULL) {
node->color = RBTREE_BLACK;
}
}
void in_order(node_t *node, key_t *arr, int *index, size_t size) {
if (node == NULL || *index >= size) return;
in_order(node->left, arr, index, size);
if (*index >= size) return;
arr[*index] = node->key;
(*index)++;
in_order(node->right, arr, index, size);
}
void right_rotate(node_t **node) {
node_t *cur = *node;
node_t *child = cur->left;
cur->left = child->right;
if (child->right != NULL)
child->right->parent = cur;
child->parent = cur->parent;
child->right = cur;
cur->parent = child;
*node = child;
}
void left_rotate(node_t **node) {
node_t *cur = *node;
node_t *child = cur->right;
cur->right = child->left;
if (child->left != NULL)
child->left->parent = cur;
child->parent = cur->parent;
child->left = cur;
cur->parent = child;
*node = child;
}