컴퓨터 시스템 : CSAPP 9장 정리 - 9.8 메모리 매핑
9.8 메모리 매핑 (Memory Mapping)
리눅스는 가상 메모리 영역의 초기 내용을 디스크 상의 객체에 연결(mapping)함으로써 초기화한다. 이를 메모리 매핑(memory mapping)이라고 한다. 가상 메모리 영역은 다음 두 종류의 객체 중 하나에 매핑될 수 있다:
- 일반 파일
- 예: 실행 파일, 객체 파일 등.
- 파일의 일부분을 페이지 단위로 나누어 가상 페이지에 대응시킨다.
- 요구 페이징(demand paging)을 사용하므로, 해당 페이지에 실제 접근하기 전까지는 메모리에 로드되지 않는다.
- 영역 크기가 파일보다 크면, 나머지 공간은 0으로 패딩 된다.
- 익명 파일(anonymous file)
- 커널이 생성한, 내용이 모두 0으로 채워진 임시 객체.
- 이 영역의 페이지에 처음 접근할 때, 커널이 물리 메모리에서 희생 페이지를 선택하고, 그것을 0으로 덮어쓴 후 페이지 테이블을 갱신한다.
- 이 과정에서는 디스크 I/O가 발생하지 않으며, 해당 페이지들을 demand-zero pages라고 부른다.
한 번 초기화된 가상 페이지는 커널이 관리하는 스왑 파일(swap space)을 통해 디스크와 메모리 간에 교체된다. 이 스왑 공간은 현재 실행 중인 모든 프로세스가 가질 수 있는 총 가상 페이지 수의 상한선 역할을 한다.
9.8.1 공유 객체 재방문 (Shared Objects Revisited)
메모리 매핑의 기원은 가상 메모리 시스템과 기존 파일 시스템을 통합하면 프로그램과 데이터를 메모리에 효율적으로 로드할 수 있다는 통찰에서 출발한다.
공유 객체의 필요성
- 모든 프로세스는 독립적인 주소 공간을 가져야 한다. 그러나 대부분의 프로세스는 같은 읽기 전용 코드를 공유한다.
- 예: bash 셸을 실행하는 모든 프로세스는 동일한 코드 영역을 가진다.
- 모든 C 프로그램은 printf 등 표준 라이브러리 함수를 사용한다.
이런 코드를 각 프로세스마다 중복 보관하면 메모리 낭비가 심하다.
공유 객체와 사적 객체
- 공유 객체(shared object)로 매핑된 메모리 영역에 어떤 프로세스가 쓰기를 수행하면, 해당 쓰기는 다른 프로세스에도 보인다. 또한, 디스크 상의 원본에도 반영된다.
- 사적 객체(private object)로 매핑된 경우에는 쓰기가 다른 프로세스에 보이지 않으며, 디스크에도 반영되지 않는다.
이러한 공유 객체는 메모리 매핑을 통해 효율적으로 관리된다.
9.8.2 fork 함수 재방문
가상 메모리와 메모리 매핑 개념을 이해한 이후에는, fork 함수가 어떻게 독립적인 가상 주소 공간을 가진 새로운 프로세스를 생성하는지 더 명확히 알 수 있다.
fork 동작 원리
- 현재 프로세스가 fork()를 호출하면, 커널은 새로운 프로세스를 위한 자료구조들을 생성하고, 고유한 PID를 할당한다.
- 그리고 부모 프로세스의 가상 메모리 정보를 복사한다:
- mm_struct
- vm_area_struct (영역 구조체들)
- 페이지 테이블
- 이때 모든 페이지는 읽기 전용(read-only)으로 설정되며, 각 영역은 private copy-on-write로 표시된다.
- fork가 반환되면, 자식 프로세스는 부모의 가상 메모리 상태를 정확히 복제한 형태로 실행을 시작한다.
Copy-on-Write (COW) 기법
- 초기에는 부모와 자식이 동일한 물리 페이지를 공유한다.
- 둘 중 하나가 해당 페이지에 쓰기(write)를 시도하면, 보호 예외가 발생하고 커널이 새로운 물리 페이지를 만들어 할당한다.
- 이로 인해 페이지가 실제로 복사되는 시점은 쓰기가 발생한 순간까지 지연된다.
- 이 방식은 물리 메모리 사용을 최소화하면서도 주소 공간의 독립성을 보장한다.
9.8.3 execve 함수 재방문
가상 메모리와 메모리 매핑 개념을 이해하면, execve 함수가 프로그램을 메모리에 어떻게 로드하고 실행하는지 명확하게 이해할 수 있다.
동작 예시
프로세스가 다음과 같은 호출을 한다고 가정하자:
execve("a.out", NULL, NULL);
이 호출은 현재 프로세스를 대체하여 "a.out" 프로그램을 실행한다. 다음은 execve가 수행하는 주요 단계다:
1. 기존 사용자 영역 제거
- 현재 프로세스의 가상 주소 공간 중 사용자 영역을 구성하는 모든 area struct를 제거한다.
2. 새 프로그램 영역 매핑
- a.out 실행 파일에 기반해 새 코드(.text), 데이터(.data), BSS, 스택 영역을 생성한다.
- 코드와 데이터는 private copy-on-write로 설정되고, 각각 .text와 .data 섹션에 매핑된다.
- .bss, 힙, 스택은 demand-zero 영역으로 익명 파일에 매핑되며, 접근 시 0으로 초기화된다.
3. 공유 라이브러리 매핑
- a.out이 공유 객체(libc.so 등)를 링크하고 있다면, 이들도 가상 주소 공간의 공유 영역에 매핑된다.
4. 프로그램 카운터 설정
- 마지막으로, CPU의 프로그램 카운터(PC)를 새 코드 영역의 시작 지점(진입점)으로 설정한다.
- 이후 스케줄링이 되면, 해당 지점부터 실행이 시작되며, 필요한 페이지들은 요구 페이징에 의해 로딩된다.
9.8.4 사용자 수준 메모리 매핑 (mmap 함수 사용)
리눅스에서는 프로세스가 가상 메모리 영역을 직접 생성하고, 파일 또는 익명 객체를 그 영역에 매핑할 수 있도록 mmap 함수를 제공한다.
#include <unistd.h>
#include <sys/mman.h>
void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);
- start: 매핑을 시작할 가상 주소 (보통 NULL로 지정하여 커널이 자동 선택하게 함)
- length: 매핑할 바이트 수
- prot: 접근 권한
- PROT_EXEC: 실행 가능
- PROT_READ: 읽기 가능
- PROT_WRITE: 쓰기 가능
- PROT_NONE: 접근 불가
- flags: 매핑 유형
- MAP_ANON: 익명 객체 매핑 (디스크 없이 0으로 초기화)
- MAP_PRIVATE: 사적, copy-on-write
- MAP_SHARED: 공유 객체
- fd: 파일 디스크립터 (익명 매핑의 경우 -1)
- offset: 파일에서 매핑을 시작할 위치
예시:
bufp = mmap(NULL, size, PROT_READ, MAP_PRIVATE | MAP_ANON, -1, 0);
→ size 바이트 크기의 읽기 전용, 사적, 요구-제로(demand-zero) 메모리 영역을 생성함.
영역 제거: munmap
int munmap(void *start, size_t length);
- start부터 length 바이트만큼의 가상 메모리 영역을 제거함.
- 제거된 영역에 접근하면 segmentation fault 발생.