가상 메모리
가상 메모리는 프로그램이 부르는 메모리 주소를 실제 메모리 위의 주소와 갈라 놓습니다. 프로그램에는 자기만 쓰는 번호를 주고, 그 번호를 실제 번호로 바꿔 줍니다. 그래서 프로그램은 메모리가 얼마나 꽂혀 있는지 몰라도 되고, 옆 프로그램의 메모리에 닿지도 않습니다.
쉽고 빠른 이해
프로그램이 부르는 주소를 실제 메모리의 주소로 바꿔 주는 장치입니다. 같은 프로그램을 두 번 띄우면 둘 다 자기 코드가 같은 번호에 있다고 보지만, 올라간 메모리는 서로 다릅니다.
이게 없으면 메모리에 다 안 들어가는 프로그램은 사람이 손으로 덩어리 교체 계획을 짜 넣어야 합니다. 루틴이 저장 장치의 어느 번지에 놓이는지도 미리 맞춰 두어야 합니다.
어떻게 도는가:
- 메모리를 같은 크기의 덩어리로 자릅니다
- 어느 덩어리가 실제 메모리의 어느 칸에 놓였는지를 표에 적어 둡니다
- 프로그램이 주소를 부를 때마다 하드웨어가 그 표를 보고 실제 주소로 바꿉니다
대가가 있습니다. 주소를 부를 때마다 표를 한 번 더 읽어야 합니다. 표에 올라와 있지 않은 덩어리를 건드리면 운영체제가 끼어들어 저장 장치에서 읽어 오고, 그동안 그 프로그램은 멈춰 있습니다.
상세
사무실 건물마다 1번 책상이 있습니다. 어느 사무실 직원에게 물어도 「1번 책상」이라고 말하지만 그 말이 가리키는 책상은 사무실마다 다릅니다.
가상 메모리에서는 프로그램의 모든 메모리 접근이 가상 주소로 이루어집니다. 프로그램이 메모리를 읽거나 쓰는 명령을 낼 때마다, 그 명령에 적힌 가상 주소는 메모리 컨트롤러(메모리 칩에 읽기와 쓰기를 실제로 거는 회로)가 알아듣는 물리 주소로 번역됩니다. 한 프로세스가 쓸 수 있는 가상 주소의 범위를 주소 공간이라고 부릅니다.
이 번역 덕에 프로그램은 물리 메모리의 사정을 몰라도 됩니다. 꽂힌 메모리는 양이 정해져 있고 한 덩어리로 이어져 있지도 않습니다. 접근 가능한 구간이 여럿으로 끊겨 있을 수 있습니다. 그 구간을 어떻게 잡을지는 CPU(Central Processing Unit, 중앙 처리 장치) 종류마다 다릅니다. 번역을 한 겹 두면 프로그램 쪽에서는 그 차이가 안 보입니다.
번역은 하드웨어가 합니다. 주소를 바꿔 주는 이 하드웨어를 MMU(Memory Management Unit, 메모리 관리 장치)라고 부릅니다. MMU 가 없는 작은 마이크로컨트롤러에서는 프로그램이 부르는 주소가 곧 물리 주소입니다. 리눅스는 그런 기계를 위한 메모리 관리를 따로 두고 nommu 라고 부르며, 주소 변환을 설명하는 문서는 MMU 가 있다고 먼저 전제합니다. 아래 설명도 전부 MMU 가 있는 기계를 놓고 하는 이야기입니다.
페이지와 프레임
주소를 하나하나 짝지어 적으면 표가 메모리만큼 커집니다. 그래서 짝은 덩어리 단위로만 짓습니다. 물리 메모리를 같은 크기의 칸으로 잘라 놓고, 그 칸을 프레임이라고 부릅니다. 가상 주소 쪽의 같은 크기 덩어리는 페이지입니다. 페이지 하나는 비어 있는 어느 프레임에든 놓을 수 있고, 다른 것을 밀어내 자리를 만들 필요가 없습니다. 이렇게 페이지와 프레임을 짝지어 둔 것을 매핑이라고 부릅니다.
페이지 크기는 아키텍처가 정합니다. 여러 값 중에서 고르게 해 주는 아키텍처도 있고, 그럴 때는 커널을 빌드할 때 설정으로 정합니다. 널리 쓰이는 값은 4킬로바이트입니다.
크기가 고정이라 가상 주소는 두 토막으로 읽힙니다. 위쪽 비트는 몇 번째 페이지인지를 가리키고, 아래쪽 비트는 그 페이지 안에서 몇 바이트째인지를 가리킵니다. 뒤엣것을 오프셋이라고 합니다. 번역되는 것은 위쪽 토막뿐입니다. 아래쪽 토막은 손대지 않고 넘어갑니다. 페이지와 프레임의 크기가 같아서 덩어리 안에서의 거리가 양쪽에서 똑같기 때문입니다.
페이지 테이블
어느 페이지가 어느 프레임에 놓였는지는 페이지 테이블이 갖습니다. 프로그램마다 자기 표를 하나씩 갖습니다. 표의 한 줄은 페이지 번호로 찾아 프레임 번호를 내주고, 물리 주소는 가상 주소의 페이지 번호 토막을 그 프레임 번호로 갈아 끼운 값입니다.
그래서 짝은 이런 모양이 됩니다. 프로세스 둘이 똑같이 1번 페이지를 불러도 그 화살표가 닿는 프레임은 서로 다릅니다.
flowchart TD
subgraph A["프로세스 A의 주소 공간"]
A1["1번 페이지"]
A2["2번 페이지"]
end
subgraph B["프로세스 B의 주소 공간"]
B1["1번 페이지"]
B2["2번 페이지"]
end
subgraph M["물리 메모리"]
F3["3번 프레임"]
F5["5번 프레임"]
F8["8번 프레임"]
F9["9번 프레임"]
end
A1 --> F3
A2 --> F8
B1 --> F5
B2 --> F9
표의 한 줄에는 프레임 번호 말고도 표시가 몇 개 더 들어갑니다. 그 페이지를 읽어도 되는지 쓸 수도 있는지를 적는 권한 표시가 있습니다. 번역은 허용된 접근일 때만 이루어집니다. 그 페이지가 지금 물리 메모리에 올라와 있는지를 적는 표시도 있습니다. 이 표시 덕분에 주소 공간의 모든 페이지가 메모리에 있을 필요가 없어집니다. 프로그램이 건드리는 페이지만 올려 두고 나머지는 디스크 같은 저장 장치에 둘 수 있습니다.
가상 메모리가 주는 것은 셋입니다. 물리 메모리의 사정을 프로그램에서 감추는 것, 필요한 페이지만 올려 두는 것, 그리고 프로세스 사이에서 데이터를 보호하고 정해진 만큼만 나눠 쓰게 하는 것입니다. 격리는 앞의 권한 표시와 표 자체에서 나옵니다. 한 프로세스의 표에 적히지 않은 프레임은 그 프로세스가 부를 방법이 없고, 여러 프로세스의 표가 같은 프레임을 함께 가리키면 그 메모리는 나눠 쓰는 것이 됩니다.
배경
메모리에 다 안 들어가는 프로그램은 덩어리로 쪼개 두고, 그때그때 필요한 덩어리만 올려서 돌려야 합니다. 어느 시점에 어느 덩어리를 올리고 내릴지 계획을 짜는 일을 오버레이라고 불렀고, 예전에는 이 계획을 프로그래머가 손으로 짰습니다.
이 일은 간단한 프로그램에서도, 목표 기계의 메모리 크기를 미리 아는 경우에도 어려웠습니다. 일정을 짜는 문제 자체가 어려운 데다, 두 저장 장치 사이에서 정보를 옮기는 명령을 제대로 쓰려면 운영체제의 세부까지 알아야 했기 때문입니다. 1969년에 IBM 의 David Sayre 가 보고한 연구에서는 손으로 오버레이를 짜는 데 든 품이 전체 프로그래밍 시간의 약 40%였습니다. 사람 품이 이만큼 든다면 그 세부는 컴퓨터가 맡는 편이 낫습니다.
맨체스터 대학 팀이 만든 Atlas 가 이 전환을 해냅니다. 코어 저장 장치와 드럼 저장 장치를 프로그래머에게 한 겹으로 보이게 만들고, 그 사이의 정보 이동을 자동으로 처리했습니다. 이 팀은 자기 구조를 「one-level store」, 한 겹짜리 저장 장치라고 불렀습니다. 곁따라 온 이득도 있었습니다. 번지를 상대적으로 매길 수 있어 루틴이 저장 장치의 어디에서든 돌 수 있었고, 동시에 올라와 있는 다른 프로그램이 서로를 건드리지 못하게 막는 잠금 기능이 붙었습니다. 이런 구조들이 나중에 가상 메모리라고 불리게 된 것은, 없는 큰 메모리가 있는 것처럼 흉내 내는 방식이기 때문입니다.
동작
이 절은 프로그램이 낸 가상 주소 하나가 물리 주소가 되기까지 지나는 단계를 봅니다. 대상은 메모리를 읽거나 쓰는 명령 하나입니다. 재는 것은 그 한 번의 접근에 표를 몇 번 읽게 되는가입니다.
결과부터 말하면 대부분의 접근은 첫 단계에서 끝납니다. 주소를 건드릴 때마다 표를 읽는다면 메모리를 아끼려고 만든 장치가 메모리 읽기를 늘리는 셈입니다. 그래서 하드웨어는 최근 번역 결과를 담아 두는 작은 전용 저장소를 하나 더 갖습니다. 이것을 TLB(Translation Lookaside Buffer, 변환 색인 버퍼)라고 부릅니다. 이름은 「변환」이지만 이 편이 내내 번역이라고 부른 그 일입니다. 찾던 페이지가 거기 있으면 표를 읽지 않고 끝납니다.
거기 없을 때에야 페이지 테이블을 읽습니다. 표는 한 벌이 아니라 계층으로 쌓여 있습니다. 맨 위 표의 물리 주소는 레지스터(프로세서 안에 있는 아주 작은 저장소)에 들어 있습니다. 가상 주소의 위쪽 비트를 조금씩 잘라 층마다 몇 번째 줄인지를 고릅니다. 그 줄이 다음 층 표를 가리키면 한 층 더 내려가고, 프레임을 가리키면 거기서 번역이 끝납니다.
계층으로 쌓는 이유는 주소 공간이 대개 듬성듬성 쓰이기 때문입니다. 한 벌짜리 평평한 표를 두면 안 쓰는 구간까지 줄을 가져야 합니다. 계층이면 큰 빈 구간을 위층에서 한 줄로 「매핑 없음」이라고 적고 끝낼 수 있습니다. 표가 쌓인 모양은 이렇습니다.
flowchart TD
subgraph L1["맨 위 표 · 주소는 레지스터에 있다"]
A0["0번 줄 · 매핑 없음"]
A1["1번 줄"]
A2["나머지 줄들"]
end
subgraph L2["가운데 층 표"]
B0["0번 줄"]
B1["나머지 줄들"]
end
subgraph L3["맨 아래 표"]
C0["7번 줄 · 프레임 번호와 권한 표시"]
C1["나머지 줄들"]
end
A1 --> L2
B0 --> L3
C0 --> F["물리 메모리의 프레임"]
표를 걷다가 멈추는 경우가 있습니다. 걸어 내려간 줄에 「없음」 표시가 붙어 있으면 그 가상 주소에는 번역이 없습니다. 그러면 접근은 페이지 폴트 예외를 냅니다. 번역은 있는데 그 줄의 권한이 이번 접근을 허락하지 않을 때도 같은 예외가 납니다. 커널은 어느 주소에서 걸렸는지 알아야 하는데, x86-64 프로세서는 문제가 된 주소를 CR2(Control Register 2, 2번 제어 레지스터)에 담아 둡니다.
flowchart TD
A["프로그램이 가상 주소를 낸다"] --> B{"TLB 에 있나"}
B -->|있다| F["물리 주소로 메모리를 읽거나 쓴다"]
B -->|없다| C["페이지 테이블을 층마다 걷는다"]
C --> D{"줄이 프레임을 가리키나"}
D -->|가리킨다| F
D -->|없음 표시거나 권한이 안 맞는다| E["페이지 폴트 예외"]
페이지 폴트는 오류처럼 들리지만 대개는 정상 동작입니다. 예외가 나면 커널의 페이지 폴트 처리기가 끼어듭니다. 처리기는 그 페이지의 데이터를 바깥 저장 장치에서 읽어 물리 메모리에 올리고, 페이지 테이블을 고쳐 그 페이지를 물리 주소에 이어 줍니다. 빈 프레임이 없으면 올라와 있던 페이지 하나를 저장 장치로 내보내 자리를 만듭니다. 페이지를 이렇게 저장 장치로 내보내는 일을 스와핑이라고 하고, 내보낸 페이지가 놓이는 저장 장치의 구역을 스왑 영역이라고 합니다. 어느 페이지를 내보낼지 고르는 규칙은 페이지 교체입니다. 내보낼 페이지가 올라온 뒤에 바뀌었다면 내보내기 전에 저장 장치의 사본에 먼저 적어야 합니다.
flowchart TD
A["페이지 폴트가 났다"] --> B{"빈 프레임이 있나"}
B -->|있다| D["저장 장치에서 읽어 그 프레임에 올린다"]
B -->|없다| C["올라와 있던 페이지 하나를 내보낸다"]
C --> D
D --> E["페이지 테이블을 고친다"]
E --> F["멈췄던 명령을 다시 실행한다"]
처리기가 할 일을 마치고 예외에서 돌아오면, 예외를 냈던 명령이 처음부터 다시 실행됩니다. 이번에는 페이지가 올라와 있으므로 같은 명령이 성공합니다. 프로그램 쪽에서 보면 그 명령 하나가 오래 걸렸을 뿐 아무 일도 일어나지 않은 것과 같습니다. 필요해진 뒤에 페이지를 올리는 이 방식을 요구 페이징이라고 부릅니다.
번역이 끝나면 오프셋은 손대지 않은 채 물리 주소의 아래쪽 비트가 됩니다. 이 방식에서 하드웨어가 바꾸는 것은 언제나 페이지 번호 토막 하나뿐입니다.
예시
주소 공간의 실제 크기
주소 공간이 얼마나 넓은지는 아키텍처가 가상 주소의 몇 비트를 번역에 쓰기로 했느냐로 정해집니다. 넓히면 표가 한 층 깊어지고 걷는 시간도 늘어납니다. 실제 값을 셋만 봅니다.
x86-64 의 4단계 페이징은 48비트 가상 주소를 씁니다. 위쪽 36비트를 9비트씩 넷으로 잘라 층마다 한 줄씩 고르고, 남은 아래 12비트가 오프셋입니다.
block-beta columns 5 a["47:39 · 1층"] b["38:30 · 2층"] c["29:21 · 3층"] d["20:12 · 4층"] e["11:0 · 오프셋"]
왼쪽 네 칸이 층마다 고르는 줄 번호이고, 맨 오른쪽 칸은 번역되지 않고 물리 주소의 아래쪽이 됩니다. 표 하나는 4096바이트이고 줄 하나가 8바이트라 512줄이 들어갑니다. 5단계 페이징은 여기에 한 층을 더 얹어 57비트 가상 주소를 씁니다.
RISC(Reduced Instruction Set Computer, 축소 명령어 집합 컴퓨터) 계열의 명령어 집합인 RISC-V 에서는 Sv39 가 39비트 가상 주소를 4 KiB 페이지로 나눕니다. 27비트짜리 가상 페이지 번호가 3단계 표를 거쳐 44비트 물리 페이지 번호가 되고, 12비트 오프셋은 번역되지 않습니다. 39비트로 모자라면 Sv48 로 주소 공간을 넓힐 수 있습니다. 대신 페이지 테이블이 쓰는 물리 메모리와 표를 걷는 시간, 가상 주소를 담는 하드웨어의 크기가 함께 늘어납니다.
AArch64 리눅스는 4KB 페이지에서 3단계나 4단계를 쓰고, 각각 39비트(512GB)와 48비트(256TB) 가상 주소가 됩니다. 64KB 페이지를 쓰면 2단계만으로 42비트(4TB)가 되고, 커널이 주소 공간을 나눠 쓰는 배치는 4KB 페이지일 때와 같습니다.
주소 공간을 바꾸는 호출
mmap 은 프로세스의 주소 공간과 메모리 객체 사이에 매핑을 만드는 호출입니다. 일반 파일,
이름 없는 익명 메모리 객체, 공유 메모리 객체, 타입 있는 메모리 객체를 붙일 수 있습니다.
void *mmap(void *addr, size_t len, int prot, int flags,
int fildes, off_t off);
len 바이트만큼을 붙이고 성공하면 붙은 주소를 돌려줍니다. len 이 0이면 호출은 실패하고 매핑도
안 생깁니다. prot 은 그 메모리에 읽기, 쓰기, 실행 중 무엇을 허용할지를 정합니다.
이미 붙은 메모리의 권한을 바꾸는 호출은 따로 있습니다.
int mprotect(void *addr, size_t len, int prot);
addr 부터 len 바이트가 걸쳐 있는 페이지 전체의 권한이 prot 으로 바뀝니다. 성공하면 0,
실패하면 -1 을 돌려주고 errno 를 설정합니다. 값으로는 PROT_NONE · PROT_READ ·
PROT_WRITE · PROT_EXEC 를 쓰고, 구현에 따라 addr 이 페이지 크기의 배수일 것을 요구할 수
있습니다. 권한이 페이지 단위로만 바뀐다는 것이 이 호출의 시그니처에 드러납니다.
프로세스의 매핑을 읽는 파일
리눅스에서 /proc/<pid>/maps 는 그 프로세스에 지금 붙어 있는 메모리 구간과 권한을 줄마다
보여 줍니다.
08048000-08049000 r-xp 00000000 03:00 8312 /opt/test
0804a000-0806b000 rw-p 00000000 00:00 0 [heap]
a7ed5000-a8008000 r-xp 00000000 03:00 4222 /lib/libc.so.6
aff35000-aff4a000 rw-p 00000000 00:00 0 [stack]
맨 앞이 그 구간이 차지한 가상 주소 범위입니다. 그다음 네 글자가 권한입니다. 앞 셋은 r 이 읽기,
w 가 쓰기, x 가 실행입니다. 마지막 글자는 s 면 공유, p 면 사적입니다. 사적 구간은 쓸 때
복사하는 방식으로 다뤄집니다. 파일에서 온 구간은 경로가 붙고, 파일과 무관한 구간은 [heap] ·
[stack] 같은 이름이 붙거나 비어 있습니다.
실제 메모리보다 많이 약속하는 설정
주소 공간은 꽂힌 메모리와 따로 놉니다. 그래서 커널은 물리 메모리보다 많은 양을 프로세스들에게
약속해 줄 수 있고, 어디까지 약속할지는 vm.overcommit_memory 로 정합니다.
| 값 | 무엇을 하나 |
|---|---|
| 0 | 기본값. 딱 봐도 지나친 요청만 거절합니다 |
| 1 | 언제나 더 약속합니다. 거의 전부가 0으로 채워진 성긴 배열을 쓰는 계산 프로그램이 이 값을 씁니다 |
| 2 | 더 약속하지 않습니다. 시스템 전체의 약속량이 스왑 영역에 물리 메모리의 일정 비율(기본 50%)을 더한 값을 넘지 못합니다 |
값 2 에서 무슨 일이 벌어지는지는 얼마나 쓰느냐에 달렸습니다. 대개는 프로세스가 페이지를 건드리다 죽는 대신 메모리를 요청하는 단계에서 오류를 받게 됩니다.
경계
스왑이 꺼져 있으면 가상 메모리를 안 쓰는 것인가. 아닙니다. 가상 메모리를 세우는 것은 주소 변환입니다. 커널 문서도 모든 메모리 접근이 가상 주소를 거쳐 물리 주소로 번역된다는 데서 설명을 시작하고, 페이지를 저장 장치로 내보내는 스와핑은 한참 뒤에 나오는 회수 수단의 하나로 다룹니다. 쓰지 않는 페이지를 되찾는 방법에는 저장 장치에 이미 사본이 있는 페이지를 그냥 버리는 길도 있고, 스왑은 그중 한 갈래입니다. 스왑을 꺼도 주소 변환과 보호는 남습니다.
관련 항목
가상 메모리를 이루는 구성 요소
페이지 · 프레임 · 페이지 테이블 · 가상 주소 · 물리 주소 · 오프셋 · 주소 공간 · 페이지 크기
주소 변환을 맡는 하드웨어
MMU · TLB · CPU · 페이지 테이블 워크 · 주소 변환
페이지를 올리고 내리는 처리 단계
페이지 폴트 · 요구 페이징 · 스와핑 · 페이지 교체 · 스왑 영역 · 선반입
페이지 교체에 쓰이는 알고리즘
LRU · 클럭 알고리즘 · FIFO 페이지 교체 · 최적 페이지 교체 · 세컨드 챈스
가상 메모리가 지키는 성질
메모리 보호 · 프로세스 격리 · 주소 공간 배치 난수화 · 복사 후 쓰기 · 실행 방지 비트
가상 메모리를 굴릴 때 재는 지표
작업 집합 · 지역성 · 상주 집합 · 페이지 폴트율
가상 메모리에서 나는 성능 문제
스래싱 · 메모리 단편화 · TLB 미스 · 메모리 누수
가상 메모리를 바탕으로 도는 기능
메모리 맵 파일 · 공유 메모리 · 동적 링크 · 공유 라이브러리 · 힙 · 스택
가상 메모리와 맞세워지는 대립 개념
물리 메모리 · 실주소 모드 · 세그먼테이션 · 메모리 오버커밋
가상 메모리를 쓰지 않거나 제한하는 환경
실시간 시스템 · 마이크로컨트롤러 · 임베디드 시스템 · 페이지 고정
가상 메모리를 관리하는 상위 분류
다른 이름: virtual memory · 가상 기억 장치