운영체제는 "현재 어떤 공유 객체(파일)의 어느 부분이 어떤 물리 메모리 프레임에 올라와 있는지"를 커널 내의 전역적인 자료구조를 통해 관리합니다. 이 정보가 없으면 공유 매핑 자체가 불가능합니다.
이 관리의 핵심 주체는 VFS(Virtual File System) 계층과 메모리 관리 서브시스템이 연동하여 사용하는 vnode (또는 inode)와 페이지 캐시(Page Cache)입니다.
vnode와 페이지 캐시프로세스가 100개이든 1개이든, libc.so 파일은 디스크 상에서 단 하나입니다. 커널은 이 파일이 메모리에 로드될 때, 이 파일의 커널 내 대표 객체(vnode)를 중심으로 물리 메모리 상태를 관리합니다.
libc.so를 로드합니다. (보통 mmap() 시스템 콜을 통해)libc.so 파일의 vnode(Linux에서는 inode)를 찾습니다.vnode에 연결된 페이지 캐시(struct address_space in Linux)를 확인합니다. "이 파일의 코드 세그먼트(0~40KB 오프셋)가 메모리에 있는가?"libc.so의 코드 세그먼트를 P로 읽어들입니다.vnode의 페이지 캐시에 등록합니다: "libc.so의 (오프셋 0~40KB)는 (물리 프레임 P)에 있다."(프로세스 A의 가상 주소 VA_A) (물리 프레임 P)libc.so를 로드합니다.libc.so 파일의 vnode를 찾습니다.vnode에 연결된 페이지 캐시를 확인합니다.vnode의 페이지 캐시에 "libc.so의 (오프셋 0~40KB)는 (물리 프레임 P)에 있다."는 정보가 이미 존재합니다.(프로세스 B의 가상 주소 VA_B) (물리 프레임 P)"어딘가"는 구체적으로 다음과 같은 커널 자료구조의 조합입니다.
struct address_space (파일 중심)inode는 자신만의 address_space 구조체를 가집니다.struct vm_area_struct (VMA) (프로세스 중심)0x7f00... ~ 0x7f10...)을 정의합니다.libc.so 파일(struct file 포인터)의 (오프셋 0)부터 매핑된 영역이다"라고 파일을 가리킵니다.프로세스 B가 mmap을 호출하면, 커널은 VMA를 생성하고 이 VMA가 가리키는 파일(libc.so)의 address_space(페이지 캐시)를 확인합니다. 그리고 그곳에 이미 캐시된 물리 프레임 P가 있다면, 프로세스 B의 페이지 테이블에 P를 매핑해주는 것입니다.
결론적으로, 운영체제는 파일(vnode/inode)을 중심으로 물리 메모리 점유 상태(페이지 캐시)를 관리하며, 각 프로세스는 자신의 가상 주소 영역(VMA)을 통해 이 공유되는 캐시에 연결(매핑)됩니다.
정확히는 프로세스의 main 함수가 실행되기 전에, "동적 링커(Dynamic Linker)"라는 특별한 프로그램이 먼저 실행되어 libc.so를 mmap()으로 로드합니다.
프로그램이 "자연스럽게" 로드하는 것처럼 보이지만, 실제로는 커널과 이 동적 링커(ld.so)가 협력하여 main 함수가 시작할 수 있도록 환경을 먼저 구축하는 것입니다.
./my_program을 실행하면, 커널에 execve 시스템 콜이 전달됩니다.my_program은 "동적 연결(Dynamically Linked)" 실행 파일입니다. 이 파일의 ELF 헤더에는 .interp (interpreter)라는 특별한 섹션이 있습니다..interp 섹션에는 "나를 실행하기 전에, 먼저 /lib64/ld-linux-x86-64.so.2 (이하 ld.so)라는 프로그램을 실행시켜라"고 적혀있습니다.my_program의 main 함수를 바로 실행하지 않습니다. 대신, 커널은 새 프로세스의 메모리 공간에 my_program의 코드/데이터와 함께 ld.so (동적 링커)를 함께 로드(매핑)합니다.my_program의 main이 아닌 ld.so의 시작점으로 넘깁니다.ld.so)의 역할: libc.so 로드하기 (이때 mmap 발생)이제부터는 커널이 아닌, ld.so 프로그램이 해당 프로세스 내(유저 레벨)에서 실행됩니다.
ld.so는 my_program의 ELF 헤더를 다시 읽어, "이 프로그램이 의존하는(필요로 하는) 공유 라이브러리 목록" (예: libc.so, libpthread.so 등)을 확인합니다.ld.so는 이 목록을 순회하며 libc.so를 찾습니다.ld.so가 직접 open("/usr/lib/libc.so") 시스템 콜을 호출하여 파일 디스크립터(fd)를 얻습니다.ld.so가 직접 mmap(..., fd, ...) 시스템 콜을 호출하여, libc.so의 코드와 데이터 세그먼트를 프로세스의 가상 메모리 공간에 "예약(VMA 생성)"합니다.ld.so는 비로소 my_program의 원래 시작점(결국 main 함수)으로 점프하여 실행을 넘깁니다.libc.so는 프로세스가 "자연스럽게" 로드하는 것이 아니라, 커널이 ld.so를 먼저 실행시키고,
이 ld.so가 main 함수가 실행되기 전에 필요한 libc.so를 mmap()으로 명시적으로 로드하는 것입니다.
mmap() 시스템 콜은 파일을 메모리에 직접 로드하는 것이 아니라, "메모리 예약"을 하고 실제 로드는 뒤로 미루는 방식으로 동작합니다.
핵심은 VMA(Virtual Memory Area) 생성과 페이지 폴트(Page Fault) 핸들링입니다.
mmap() 호출 시점: "가상 주소 예약"프로세스 A(의 동적 링커)가 libc.so를 로드하기 위해 mmap()을 호출하면, 커널은 다음 단계만 수행합니다.
mm_struct)을 살펴보고, 요청받은 크기(예: libc.so의 코드 세그먼트 크기)만큼의 비어있는 영역을 찾습니다. 이 시작 주소를 VA_start라고 하겠습니다.VA_start부터 끝(VA_end)까지의 영역을 관리하는 vm_area_struct (VMA)라는 자료구조를 생성합니다.vm_start, vm_end: VA_start ~ VA_endvm_prot: 메모리 접근 권한 (예: 읽기/실행, PROT_READ | PROT_EXEC)vm_flags: 매핑 속성 (예: 공유, VM_SHARED)vm_file: 매핑할 파일 포인터 (즉, libc.so 파일 객체)vm_pgoff: 파일 내 오프셋 (예: 0, 파일의 시작부터)mmap()은 이 VMA "예약"만 생성할 뿐, 물리 메모리를 할당하거나 프로세스의 페이지 테이블(PTE)을 수정하지 않습니다.VA_start를 프로세스에게 반환하고 사용자 모드로 복귀합니다.결과: mmap() 호출은 매우 빠릅니다. 실제 I/O 작업이 전혀 없습니다. 프로세스는 VA_start라는 주소를 얻었지만, 이 주소는 아직 어떤 물리 메모리와도 연결되지 않은, 말 그대로 "텅 빈" 가상 주소입니다.
mmap()이 반환된 후, 프로세스가 실제로 VA_start에 있는 코드를 실행하려는 순간(예: libc.so 안의 printf 함수 호출)에 모든 일이 벌어집니다.
VA_start를 물리 주소로 변환하려고 프로세스의 페이지 테이블을 조회합니다.mmap() 시점에서는 페이지 테이블을 수정하지 않았으므로, 해당 주소에 대한 PTE(Page Table Entry)는 "Invalid" (또는 "Present" 비트가 0)입니다. MMU는 주소 변환에 실패하고 페이지 폴트(Page Fault) 예외를 발생시킵니다.VA_start가 어떤 VMA에 속하는지 확인합니다. (1단계에서 생성한 libc.so VMA를 찾습니다.)libc.so 파일(vm_file)의 페이지 캐시(address_space)를 확인합니다. "이 파일의 이 오프셋(0번)에 해당하는 데이터가 이미 물리 메모리에 올라와 있는가?"libc.so를 로드해서 물리 프레임 P에 해당 내용이 있습니다.libc.so의 해당 부분을 읽어 P에 채웁니다. (I/O 발생)(파일, 오프셋) -> P 매핑을 페이지 캐시에 등록합니다. (다음 요청을 위해)(VA_start의 PTE) -> (물리 프레임 P)로 설정하고, "Present" 비트를 1로 켭니다.VA_start -> P라는 유효한 매핑을 찾아 주소 변환에 성공합니다. libc.so의 코드가 비로소 실행됩니다.이처럼 mmap()은 파일과 가상 주소 공간의 연결(reservation)만 설정하고, 실제 데이터 로드는 페이지 폴트(Page Fault)가 발생했을 때 필요한 페이지만큼만 처리(Demand Paging)합니다. 이것이 mmap()이 효율적으로 동작하고 메모리 공유를 구현하는 핵심 원리입니다.
페이지 캐시는 크게 두 부분으로 나뉘어 존재합니다.
libc.so의 코드)이 저장되는 곳.질문하신 "어디에 있는가"에 대한 답은 다음과 같습니다.
libc.so의 코드 블록)을 일반 물리 메모리 페이지 프레임(Page Frame)에 저장합니다.inode와 address_space이것이 사용자가 질문한 address_space의 정체이며, "관리 장부"의 역할을 합니다.
inode (in Kernel Memory):inode 구조체를 커널 메모리 내에 생성하여 유지합니다. (libc.so 파일도 마찬가지입니다.)inode는 해당 파일의 커널 내 "대표 객체"입니다.struct address_space (in Kernel Memory):inode 구조체는 i_mapping이라는 필드를 통해 struct address_space라는 또 다른 커널 자료구조를 가리킵니다.address_space가 바로 해당 파일의 페이지 캐시를 관리하는 "본체(장부)"입니다.address_space 구조체는 내부적으로 래딕스 트리(Radix Tree) (최근 커널에서는 XArray)라는 효율적인 검색 트리를 사용합니다.struct page 포인터)의 매핑을 저장합니다.struct page는 모든 물리 페이지 프레임을 1:1로 관리하는 또 다른 커널 메타데이터입니다.)페이지 폴트가 발생했을 때, 커널이 VMA를 통해 "페이지 캐시를 확인한다"는 것은 실제로는 다음 과정을 의미합니다.
VMA를 찾습니다.VMA에 연결된 vm_file 포인터를 따라가 file 구조체를 찾습니다.file 구조체를 통해 이 파일의 inode (커널 메모리)를 찾습니다.inode의 i_mapping 포인터를 따라가 address_space (커널 메모리)를 찾습니다.address_space의 래딕스 트리 (커널 메모리)를 탐색합니다. (Key: VMA의 vm_pgoff + 폴트난 주소의 오프셋)struct page 포인터를 찾으면, 이 struct page가 가리키는 물리 페이지 프레임 주소를 가져와 PTE에 매핑합니다.래딕스 트리(Radix Tree)와 XArray는 모두 매우 큰(희소한) 배열을 메모리 효율적으로 구현하기 위한 트리 기반의 자료구조입니다.
Linux 커널의 페이지 캐시(address_space)에서는 "파일의 번째 페이지(Key)"가 "어떤 물리 메모리 프레임(Value)"에 있는지를 매핑하는 데 사용됩니다.
래딕스 트리는 "기수(radix) 트리"라고도 불리며, 키(key)를 비트(bit) 조각으로 나누어 트리를 탐색하는 자료구조입니다. (일반적인 Trie의 일종입니다.)
pgoff_t), 예: 0, 1, 1000, 100000struct page* 포인터 (해당 물리 페이지 프레임의 주소)a.out 파일의 10,000번째 페이지를 찾는다고 가정해 봅시다. 10000이라는 정수(Key)를 2진수로 변환합니다 (예: 0b...0010 0111 0001 0000).
struct page* 포인터(Value)가 저장된 리프(Leaf) 노드에 도달합니다.XArray는 래딕스 트리의 현대적인 대체재입니다. Linux 커널 내부에서 기존의 래딕스 트리가 가진 한계를 극복하기 위해 개발되었습니다.
본질적으로 XArray도 래딕스 트리입니다. 하지만 커널 사용에 맞게 고도로 최적화되고 기능이 확장되었습니다.
NULL 값 저장 불가: 기존 래딕스 트리는 void* (포인터)를 저장했는데, NULL 포인터는 "슬롯이 비어있음"을 의미했습니다. 따라서 NULL 자체를 유효한 값으로 저장할 수 없었습니다.NULL을 포함한 모든 포인터 값을 저장할 수 있습니다.0입니다 (예: 0x...000). XArray는 이 사용되지 않는 하위 비트를 "태그(Tag)"처럼 활용합니다.001이면 "이것은 스왑 엔트리다"라고 구분하고, 나머지 상위 비트는 스왑 위치 정보를 담는 식입니다.Key)에 값을 저장하는 것처럼(xa_store(key, value)) 간단히 사용할 수 있으며, 내부적으로는 트리가 알아서 확장(extend)됩니다.| 특징 | 래딕스 트리 (Radix Tree) | XArray (eXtensible Array) |
|---|---|---|
| 기본 구조 | 키(정수)를 비트 조각으로 나눠 탐색하는 트리 | 래딕스 트리를 기반으로 고도로 최적화/확장 |
| 주요 용도 | 희소(Sparse)한 인덱스 값 매핑 | 래딕스 트리의 모든 용도 + @ |
| 저장 값 | void* (포인터), NULL은 "비어있음" | 모든 값 (포인터, NULL, 특수 엔트리) |
| 특징 | 메모리 효율성, 빠른 탐색 | 특수 엔트리(태깅), 단순한 API, 향상된 동시성 |
| 관계 | 구형(Legacy) | 신형(Modern) (래딕스 트리를 대체 중) |
결론적으로, 페이지 캐시에서 address_space가 사용하는 래딕스 트리/XArray는 "파일의 번째 페이지는 라는 물리 주소에 있다"라는 매핑 정보를, 수백만 개의 빈 슬롯을 낭비하지 않고 효율적으로 저장/검색하는 "희소 배열(Sparse Array)" 자료구조입니다.