가상 메모리 주소 변환은 세그멘테이션이 아니라 페이징 혼자 한다

프로그램이 &변수로 얻는 주소는 실제 메모리 칩 위의 자리가 아니다. 아래는 같은 프로세스 안에서 코드·전역 변수·힙·스택의 주소를 뽑고, 각각이 실제로 어느 물리 프레임에 올라가 있는지 커널에 물어본 결과다

페이지 크기 4096 바이트

  코드 (main)     가상 00561a37c093e2  → 물리 00012d0663e2   (프레임 번호 1232998)
  전역 변수        가상 00561a37c0c010  → 물리 0001298dd010   (프레임 번호 1218781)
  힙 1번째 페이지   가상 00561a637e52a0  → 물리 0001247102a0   (프레임 번호 1197840)
  힙 2번째 페이지   가상 00561a637e62a0  → 아직 물리 메모리에 없음
  힙 3번째 페이지   가상 00561a637e72a0  → 물리 00012931d2a0   (프레임 번호 1217309)
  스택 (지역 변수)  가상 007fff2d8fb36c  → 물리 00011f71336c   (프레임 번호 1177363)

왼쪽 가상 주소는 코드에서 힙, 스택으로 질서 있게 늘어서 있다. 오른쪽 물리 프레임 번호는 뒤죽박죽이다. 힙의 1번째와 3번째 페이지는 가상 주소로 8KiB 떨어져 있는데 물리 프레임은 19,469개, 약 76MiB 떨어져 있다. 그리고 건드리지 않은 2번째 페이지에는 아직 물리 메모리가 붙어 있지도 않다

가상 메모리 주소 변환을 누가 하는지가 이 글의 주제다. 오늘날 x86-64에서 그 일을 하는 것은 페이징 하나이고, 세그멘테이션은 명령어 수준에서 거의 꺼져 있다. 마지막 절에서 세그먼트 레지스터를 직접 읽어 확인한다

이 글에서 자주 나오는 용어

  • 논리 주소(Logical Address): 프로세스와 CPU가 보는 주소. 가상 주소와 같은 말로 쓴다
  • 물리 주소(Physical Address): 메모리 관리자가 보는 실제 주소
  • 페이지 / 프레임: 논리 주소 공간을 나눈 일정한 크기의 조각이 페이지, 물리 주소 공간을 같은 크기로 나눈 조각이 프레임이다
  • 페이지 테이블(Page Table): 페이지 번호를 프레임 번호로 바꿔 주는 표
  • TLB(Translation Lookaside Buffer): 최근에 변환한 페이지 번호와 프레임 번호를 담아 두는 CPU 안의 캐시
  • 내부 단편화: 할당된 조각 안에서 남아 버린 공간
  • 외부 단편화: 전체 여유 공간은 충분한데 연속되지 않아 쓰지 못하는 상황
  • THP(Transparent Huge Pages): 4KiB 대신 2MiB 페이지를 쓰게 해 주는 리눅스 기능

프로세스가 보는 주소는 진짜 주소가 아니다

메모리를 컴퓨터에 꽂으면 0번지부터 시작하는 주소 공간이 생긴다. 이것이 물리 주소 공간이다. 프로세스가 보는 주소 공간은 따로 있고 이것이 논리 주소 공간이다. 프로세스는 자기가 0번지부터 시작한다고 믿고, 메모리 관리자가 요청이 올 때마다 실제 위치로 옮겨 준다. 옮기는 일을 실행 중에 계속한다고 해서 동적 주소 변환이라고 부른다

이 구조가 주는 것이 세 가지다. 프로그래머는 물리 메모리 크기나 자기 프로그램이 몇 번지에 올라갈지 몰라도 되고, 프로세스끼리 서로의 메모리를 볼 수 없고, 물리 메모리보다 큰 주소 공간을 쓸 수 있다

주소 공간이 프로세스마다 따로라는 것은 같은 프로그램을 두 번 실행해 보면 드러난다. 리눅스는 실행할 때마다 배치를 무작위로 바꾼다

  코드 (main) 가상 0055deaa9f33e2     스택 가상 007fff9a02179c
  코드 (main) 가상 0055dd593e43e2     스택 가상 007fff6043111c
  코드 (main) 가상 0056065e63a3e2     스택 가상 007ffc979d8d1c

같은 실행 파일인데 세 번 다 다른 주소에 올라갔다. 이것이 ASLR이고, 공격자가 특정 주소를 노리기 어렵게 만든다. 프로그램이 컴파일 시점에 자기 주소를 못 박아 두지 않고도 동작한다는 뜻이기도 하다

여기서 용어를 하나 갈라 둬야 한다. “가상 메모리 크기”라는 말이 두 가지로 쓰인다. 하나는 프로세스 하나가 쓸 수 있는 주소 공간의 폭이고, 이것은 CPU의 주소 비트 수로 정해진다. 다른 하나는 시스템 전체가 담을 수 있는 양이고, 이것은 물리 메모리에 스왑 영역을 더한 값이다. 앞의 것은 프로세스마다 따로 있고 뒤의 것은 시스템에 하나뿐이다. 참고로 이 글의 측정에 쓴 환경에는 스왑이 잡혀 있지 않아, 스왑이 실제로 오가는 장면은 다음 편에서 다룬다

메모리 계층은 가격이 아니라 지연 시간으로 갈린다

메모리는 한 종류가 아니다. 레지스터, 캐시, 메인 메모리, 보조저장장치 순으로 느려지고 대신 싸지고 커진다. 레지스터는 CPU 안에 있고, 32비트 CPU와 64비트 CPU를 가르는 기준도 이 레지스터의 크기다

계층이 몇 배씩 벌어지는지는 직접 잴 수 있다. 무작위로 연결된 포인터를 따라가면 CPU가 다음 주소를 미리 알 수 없어 앞질러 읽어 오지 못하므로, 접근 한 번의 실제 지연이 드러난다. 작업 집합 크기를 키워 가며 쟀다

  작업 집합 크기      접근 1회 지연
      16 KiB            1.8 ns
      64 KiB            3.0 ns
     256 KiB            5.0 ns
    1024 KiB            7.3 ns
    4096 KiB           24.4 ns
   16384 KiB           40.2 ns
   65536 KiB           95.2 ns
  262144 KiB          142.0 ns
  524288 KiB          163.5 ns

16KiB에서 1.8나노초, 512MiB에서 163.5나노초다. 91배 차이가 난다. 중간의 꺾이는 지점들이 캐시 경계다. 이 CPU는 코어당 L1 데이터 캐시 48KiB, L2 2MiB이고 L3는 260MiB를 공유하는데, 64KiB와 4MiB 부근에서 값이 크게 뛰는 것이 그 경계와 맞는다

계층을 두는 이유를 가격으로 설명하는 경우가 많은데, 설계자가 실제로 다루는 것은 이 지연 시간 차이다. 91배를 한 층으로 평탄하게 만들 방법이 없으니 자주 쓰는 것을 위에, 드물게 쓰는 것을 아래에 둔다. 아래에서 다룰 TLB도 같은 이유로 존재하는 캐시다

64비트 CPU라고 해서 주소를 64비트 전부 쓰지는 않는다. 이 장비의 CPU가 보고하는 값은 이렇다

$ lscpu | grep 'Address sizes'
Address sizes:  52 bits physical, 57 bits virtual

물리 52비트, 가상 57비트다. 리눅스 커널 문서 기준으로 4단계 페이징은 가상 256TiB와 물리 64TiB를, 5단계 페이징은 가상 128PiB와 물리 4PiB를 다룬다(5-level paging, Linux Kernel documentation). 2의 64승인 16EiB와는 자릿수가 다르다. 게다가 커널은 기존 프로그램과의 호환을 위해 기본 할당을 47비트 범위로 제한한다. 앞에서 뽑은 주소들이 전부 0x7fff... 아래에 있는 것이 그 결과다

연속으로 할당하면 반드시 단편화가 남는다

가상 메모리가 나오기 전에는 프로세스를 물리 메모리의 연속된 구간에 통째로 올렸다. 나누는 방법은 둘이었고 각각 다른 낭비를 남긴다

[고정 분할] 미리 정한 크기로 자른다
 ┌────────┬────────┬────────┬────────┐
 │ P1  ▨▨ │ P2 ▨▨▨ │ P3   ▨ │ (빈칸)  │   ▨ = 칸 안에서 남은 공간
 └────────┴────────┴────────┴────────┘   → 내부 단편화

[가변 분할] 프로세스 크기에 맞춰 자른다
 ┌──────────┬───┬─────┬──────┐
 │    P1    │▨▨▨│ P3  │ 빈칸  │   P2가 끝나 3칸이 비었지만
 └──────────┴───┴─────┴──────┘   빈칸과 붙어 있지 않다
                                  → 외부 단편화

고정 분할은 칸보다 작은 프로세스가 들어가면 남는 자리가 버려지고, 칸보다 크면 아예 들어가지 못한다. 가변 분할은 크기를 맞추니 그 낭비는 없지만, 프로세스가 들어오고 나가기를 반복하면 빈 공간이 잘게 흩어진다. 총합은 충분한데 연속된 자리가 없어 못 쓰는 상황이 외부 단편화다

외부 단편화는 개념만 있는 이야기가 아니다. 리눅스 커널은 남은 물리 메모리를 연속된 덩어리 크기별로 세어 /proc/buddyinfo에 적어 둔다

  zone        2^0    2^1    2^2    2^3    2^4    2^5    2^6    2^7    2^8    2^9   2^10
  DMA32         0      0      1      0      1      2      1      2      1      0    752
  Normal     1228   1032    659    416    221    102     47     27     25     18    835

  전체 여유 메모리              6514.5 MiB
  그중 2 MiB 이상 연속인 것     6398.0 MiB  (98.2%)

각 열은 연속된 페이지 2의 N제곱 개짜리 덩어리가 몇 개 남아 있는지다. 이 장비는 지금 여유 메모리의 98.2%가 2MiB 이상 연속이라 거의 단편화되지 않았다. 사용자 프로세스에게는 애초에 연속된 물리 메모리를 줄 필요가 없어졌기 때문인데, 그 이유가 다음 두 절에서 나온다. 반대로 말하면 이 표가 필요한 곳, 즉 연속된 물리 공간을 반드시 요구하는 쪽은 커널과 일부 장치 드라이버로 남았다

세그멘테이션은 크기가 제각각이라 Bound를 들고 다닌다

가상 메모리 시스템은 물리 메모리를 통째로 주는 대신 조각으로 나눠 준다. 나누는 방식이 둘이고, 앞 절의 가변 분할을 이어받은 것이 세그멘테이션이다

프로그램을 코드, 데이터, 힙, 스택처럼 의미 있는 단위로 자르고 각 조각을 세그먼트라고 부른다. 세그먼트들은 물리 메모리에서 서로 붙어 있을 필요가 없다. 메모리 관리자가 세그먼트 테이블을 들고 있다가 논리 주소를 물리 주소로 옮긴다

CPU: "세그먼트 1번의 632번지"
        │
        ▼
  세그먼트 테이블        Base    Bound
        0            1400      800
        1            5200     1000     ← 1번 항목을 본다
        2            3000      400
        3             500      600
        │
        ├─ 632 < Bound 1000  →  통과
        └─ 물리 주소 = Base 5200 + 632 = 5832

Bound는 그 세그먼트의 크기다. 논리 주소가 Bound보다 크면 자기 세그먼트를 벗어난 접근이므로 메모리 관리자가 인터럽트를 걸어 프로세스를 종료시킨다. 세그먼트 3번의 750번지를 요청하면 Bound가 600이므로 750이 더 커서 위반이 된다. 비교 대상은 Base가 아니라 Bound다. Base는 통과한 뒤에 더하는 값이다

세그먼트 테이블 자체는 물리 메모리에 있고, 메모리 관리자 안의 세그먼트 테이블 베이스 레지스터가 그 위치를 가리킨다. 프로세스마다 테이블이 다르므로 컨텍스트 스위칭 때마다 이 레지스터를 갈아 끼워야 한다. 앞 편들에서 컨텍스트 스위칭이 무겁다고 한 이유에 이 작업도 들어간다

세그멘테이션의 장점은 자르는 단위가 논리적이라는 데서 나온다. 코드 세그먼트는 읽기와 실행만, 스택 세그먼트는 읽기와 쓰기만 허용하는 식으로 영역별 권한을 걸기 쉽고, 라이브러리 코드 세그먼트 하나를 여러 프로세스가 공유하기도 쉽다. 단점은 가변 분할에서 물려받은 외부 단편화다

페이징은 크기를 고정해 Bound를 없앤다

외부 단편화를 없애려면 조각 크기를 전부 같게 하면 된다. 논리 주소 공간을 일정한 크기로 자른 것이 페이지, 물리 주소 공간을 같은 크기로 자른 것이 프레임이다. 크기가 같으니 어떤 페이지든 어떤 빈 프레임에나 들어가고, 연속된 자리를 찾을 필요가 없다

크기가 고정이면 Bound를 들고 다닐 이유도 사라진다. 세그먼트 테이블에는 Base와 Bound가 있었지만 페이지 테이블에는 프레임 번호만 있으면 된다. 이 장비의 페이지 크기는 4096바이트다

$ getconf PAGESIZE
4096

주소를 옮기는 계산은 나눗셈 하나다. 페이지 번호는 논리 주소를 페이지 크기로 나눈 몫, 오프셋은 나머지다. 논리 주소 10000번지라면 이렇게 된다

  페이지 번호 = 10000 / 4096 = 2
  오프셋      = 10000 % 4096 = 1808

  페이지 테이블의 2번 항목을 본다 → 프레임 7번
  물리 주소 = 7 × 4096 + 1808 = 30480

페이지 크기가 2의 거듭제곱이므로 실제로는 나눗셈을 하지 않고 주소의 비트를 자른다. 4KiB 페이지에서 하위 12비트가 오프셋이고 나머지 상위 비트가 페이지 번호다

페이지 테이블의 위치를 가리키는 레지스터도 세그멘테이션과 같은 구조로 있다. x86-64에서는 CR3가 그 역할을 하고, 프로세스마다 테이블이 다르므로 컨텍스트 스위칭 때마다 값이 바뀐다. 이 값을 갈아 끼우면 이전 프로세스의 변환 결과가 담긴 TLB를 버려야 하므로, 전환 직후에는 다음 절에서 다룰 변환 비용을 다시 치르게 된다. 요즘 CPU는 TLB 항목에 프로세스 식별자를 붙여 두는 기능으로 이 손실을 줄이는데, 이 장비의 CPU도 pcidinvpcid 기능을 갖고 있다

페이징에서 외부 단편화는 생기지 않는다. 대신 마지막 페이지에 남는 자투리가 내부 단편화로 남는다. 파일 하나가 12,456바이트면 4096바이트 페이지 네 개, 즉 16,384바이트를 차지하고 3,928바이트가 낭비된다. 페이지 하나 크기 안쪽의 낭비이므로 외부 단편화와 비교하면 다루기 쉬운 문제다

잃는 것도 있다. 자르는 기준이 논리적 의미가 아니라 고정된 크기이므로, 코드 영역만 떼어 공유하거나 스택 영역에만 권한을 거는 일이 세그멘테이션보다 까다로워진다. 이 문제를 어떻게 해결했는지는 마지막 절에 나온다

페이지 테이블은 4단계로 접혀 있다

페이징의 비용은 페이지 테이블 자체가 메모리를 쓴다는 것이다. 프로세스마다 하나씩 필요하고, 프로세스가 많아지면 그만큼 사용자 영역이 줄어든다

얼마나 쓰는지 쟀다. 리눅스는 프로세스가 쓰는 페이지 테이블 크기를 /proc/PID/statusVmPTE에 적어 둔다

  4 KiB 페이지  VmRSS  2098900 kB   VmPTE   4148 kB   PTE/RSS 0.198%

  이론값: 4 KiB 페이지 하나에 8바이트 항목 → 8/4096 = 0.195%

2GiB를 잡은 프로세스의 페이지 테이블이 4,148KiB다. 측정값 0.198%가 이론값 0.195%와 맞는다. 비율 자체는 작아 보이지만 이것은 실제로 쓰는 메모리에 대한 비율이라는 점이 중요하다. 주소 공간 전체에 대해 표를 만들면 이야기가 달라진다

가상 주소 공간이 256TiB인데 4KiB 페이지로 나누면 페이지가 2의 36승 개, 항목 하나가 8바이트면 표 하나가 512GiB다. 프로세스마다 이런 표를 들고 있을 수는 없다. 그래서 표를 한 장으로 두지 않고 여러 단계로 접는다. 실제로 쓰는 구간의 하위 표만 만들면 되므로, 비어 있는 주소 공간에는 표가 존재하지 않는다

x86-64의 4단계 페이징은 48비트 주소를 이렇게 자른다

  가상 주소 0x7f6800628000
    PML4 (1단계)  비트 47~39 → 인덱스 254
    PDPT (2단계)  비트 38~30 → 인덱스 416
    PD   (3단계)  비트 29~21 → 인덱스   3
    PT   (4단계)  비트 20~12 → 인덱스  40
    오프셋        비트 11~ 0 → 0

  한 단계에 512개 항목(9비트) × 4단계 = 2^36 페이지 = 256 TiB

9비트씩 네 번을 거쳐 마지막에 12비트 오프셋을 더한다. 각 단계의 표는 정확히 4KiB, 즉 페이지 하나에 들어가고 항목은 512개다. 5단계 페이징은 여기에 한 층을 더 얹어 가상 128PiB까지 늘린 것이다

TLB가 없으면 주소 변환이 접근 시간의 5분의 1을 먹는다

접는 데에는 대가가 있다. 논리 주소 하나를 물리 주소로 바꾸려면 표를 네 번 읽어야 하고, 그 표들도 메모리에 있다. 데이터 한 번 읽으려고 메모리를 다섯 번 읽는 셈이다

그래서 CPU 안에 최근 변환 결과를 담아 두는 캐시를 둔다. TLB다. 같은 페이지를 다시 건드리면 표를 뒤지지 않고 TLB에서 바로 프레임 번호가 나온다. 문제는 TLB의 항목 수가 제한돼 있어서, 프로그램이 건드리는 페이지 수가 그 범위를 넘으면 매번 표를 다시 읽어야 한다는 것이다

이 비용을 분리해서 재 봤다. 같은 크기의 메모리를 같은 방식으로 무작위 순회하되, 한쪽은 4KiB 페이지로 다른 한쪽은 2MiB 페이지로 잡았다. 2MiB 페이지를 쓰면 같은 메모리를 덮는 데 필요한 TLB 항목이 512분의 1로 줄어든다. 데이터도 접근 패턴도 같으므로 차이는 주소 변환에서만 나온다

   작업 집합     4 KiB 페이지     2 MiB 페이지        차이
     8 MiB        32.9 ns        32.9 ns      -0% 빠름
    32 MiB        48.1 ns        45.5 ns       5% 빠름
   128 MiB       118.6 ns        98.5 ns      17% 빠름
   512 MiB       169.2 ns       133.9 ns      21% 빠름

8MiB까지는 차이가 없다. 그 정도는 4KiB 페이지로도 TLB가 감당한다. 작업 집합이 커질수록 벌어져서 512MiB에서는 21%가 된다. 접근 한 번에 드는 169나노초 중 35나노초가 주소를 찾는 데 쓰였다는 뜻이다. 큰 페이지가 실제로 쓰였는지는 따로 확인했다. 2MiB로 요청했을 때 AnonHugePages가 정확히 요청한 크기만큼 잡혔고, 4KiB로 요청했을 때는 0이었다

여기서 예상이 하나 빗나갔다. 큰 페이지를 쓰면 페이지 테이블 항목 수가 512분의 1이 되니 VmPTE도 그만큼 줄어들 것이라고 보고 같이 쟀는데, 움직이지 않았다

  4 KiB 페이지  VmRSS 2098900 kB   VmPTE 4148 kB   AnonHugePages       0 kB
  2 MiB 페이지  VmRSS 2098900 kB   VmPTE 4144 kB   AnonHugePages 2097152 kB

AnonHugePages는 2GiB가 잡혔는데 VmPTE는 4,148KiB에서 4,144KiB로 사실상 그대로다. 관측한 것은 여기까지이고, 리눅스가 큰 페이지를 나중에 쪼갤 수 있도록 하위 단계 표를 미리 확보해 두기 때문이라는 것이 내 해석이다. 커널 문서에서 이 동작을 명시한 문장은 찾지 못했다. 확실한 것은 큰 페이지의 이득이 페이지 테이블 용량이 아니라 TLB 적중률에서 나온다는 사실이고, 그것은 위 표의 21%로 확인된다

x86-64에서 세그멘테이션은 사실상 꺼져 있다

세그멘테이션과 페이징을 섞은 페이지드 세그멘테이션이라는 기법이 있다. 세그먼트 테이블에 권한 비트를 두고 Base 대신 페이지 번호를, Bound 대신 페이지 개수를 넣어 두 방식의 장점을 합친다는 발상이다. 논리 영역별 권한과 공유는 세그먼트가 맡고 물리 메모리 관리는 페이징이 맡는다

x86-64는 이 길을 가지 않았다. 세그먼트 자체를 주소 변환에서 빼 버렸다. 실행 중인 프로세스의 세그먼트 레지스터를 읽어 보면 드러난다

$ ./segreg
  CS=0x0033 DS=0x0000 ES=0x0000 SS=0x002b FS=0x0000 GS=0x0000
  FS base = 0x00007fd14ee6a740   GS base = 0x0000000000000000

데이터 세그먼트 레지스터 DS가 0이다. 그런데 이 프로그램은 전역 변수도 힙도 스택도 아무 문제 없이 읽고 쓴다. 세그먼트가 주소 계산에 관여하지 않기 때문이다. 컴파일된 코드를 보면 더 분명하다

$ objdump -d seg.o

0000000000000000 <read_normal>:          ; 보통 전역 변수
   4:   mov    0x0(%rip),%eax            ; 세그먼트 접두사가 없다

0000000000000010 <read_per_thread>:      ; 스레드 지역 변수
  14:   mov    %fs:0x0,%eax              ; %fs 접두사가 붙는다

보통 변수를 읽는 명령에는 세그먼트 접두사가 아예 없고 명령어 포인터 기준 상대 주소만 쓴다. 세그먼트 접두사가 붙는 곳은 스레드 지역 변수 하나뿐이다. Intel 매뉴얼이 규정한 그대로다

In 64-bit mode, segmentation is generally (but not completely) disabled,
creating a flat 64-bit linear-address space. The processor treats the
segment base of CS, DS, ES, SS as zero, creating a linear address that is
equal to the effective address. The exceptions are the FS and GS segments,
whose segment registers (which hold the segment base) can be used as
additional base registers in some linear address calculations.

출처는 Intel 64 and IA-32 Architectures Software Developer’s Manual Volume 1의 세그멘테이션 절이다. CS, DS, ES, SS의 세그먼트 베이스는 0으로 취급되고, 예외는 FSGS뿐이다. 위 측정에서 FS base만 값을 갖고 있던 이유가 이것이고, 그 값이 가리키는 곳이 스레드 지역 저장소다. 세그멘테이션은 주소 변환 기법이 아니라 스레드별 기준 포인터 두 개로 남았다

그러면 세그먼트가 하던 영역별 권한 관리는 어디로 갔는가. 페이지 테이블 항목의 권한 비트로 옮겨 갔다. 프로세스의 메모리 지도를 보면 구간마다 권한이 따로 붙어 있다

$ cat /proc/self/maps
56237f9e6000-56237f9eb000  r-xp  /usr/bin/cat          ← 코드: 읽기+실행, 쓰기 없음
56237f9ee000-56237f9ef000  rw-p  /usr/bin/cat          ← 데이터: 읽기+쓰기, 실행 없음
5623b82f9000-5623b831a000  rw-p  [heap]                ← 힙: 읽기+쓰기, 실행 없음
7f6800628000-7f68007b0000  r-xp  libc.so.6             ← 라이브러리 코드: 읽기+실행

코드 구간은 r-xp로 쓰기가 막혀 있고 힙과 데이터는 rw-p로 실행이 막혀 있다. 세그먼트 단위로 걸던 권한을 페이지 단위로 거는 것이고, 검사는 주소를 변환할 때마다 하드웨어가 한다. 논리 영역별 보호라는 세그멘테이션의 장점만 페이징 쪽으로 넘어온 셈이다

마무리

가상 메모리는 두 가지를 동시에 해결했다. 프로세스가 물리 메모리의 사정을 몰라도 되게 만들었고, 연속된 물리 공간을 요구하지 않게 만들어 외부 단편화를 없앴다. 대가는 주소를 옮기는 비용이고, 그 비용이 접근 시간의 5분의 1까지 올라가기 때문에 TLB와 큰 페이지 같은 장치가 계속 붙는다

세그멘테이션과 페이징 중 무엇이 남았는지는 세그먼트 레지스터 하나만 읽어 보면 끝난다. DS가 0인데 프로그램이 멀쩡히 도는 시스템에서 주소 변환을 하는 것은 페이징뿐이다


다음 편 예고

다음 편은 디맨드 페이징과 페이지 교체다. 이 글의 첫 출력에서 건드리지 않은 힙 페이지에 물리 메모리가 붙어 있지 않았던 이유, 물리 메모리가 꽉 찼을 때 어떤 페이지를 내보낼지 정하는 알고리즘들, 그리고 메모리를 늘렸는데 오히려 느려지는 현상을 다룬다


출처와 범위

이 시리즈는 인프런의 그림으로 쉽게 배우는 운영체제(감자님) 강의를 따라 정리한 것이고, 강의 전체를 일곱 편으로 나눈 다섯 번째 편이다. 개념 구성과 설명 순서의 출처는 강의이며, 하드웨어 동작과 주소 폭을 말하는 문장은 본문에 건 Intel 매뉴얼과 커널 문서로 확인했다. 코드 블록에 실은 /proc/self/pagemap, /proc/buddyinfo, /proc/self/maps, lscpu, objdump 출력과 지연 시간 측정값은 2코어 x86-64 리눅스 가상 머신에서 직접 돌린 결과 그대로다. 캐시 크기와 TLB 구성이 다른 CPU에서는 꺾이는 지점과 백분율이 달라진다. 이 환경에는 스왑이 설정돼 있지 않아 스왑 인·아웃이 실제로 일어나는 장면은 재지 못했다. 측정에 쓴 C 코드는 본문에 인용한 부분 외에는 싣지 않았고 공개 저장소도 없다

참고 자료