LabHub

블로그

[운영체제] 09. 메인 메모리 관리

한국어English日本語

배경

기본 개념

CPU가 직접 접근할 수 있는 저장소는 레지스터와 메인 메모리뿐이다. 디스크의 데이터는 반드시 메모리에 올라와야 CPU가 처리할 수 있다.

메모리 접근에는 시간이 걸리므로, CPU와 메모리 사이에 캐시를 두어 속도 차이를 완화한다.

CPU <-> 레지스터 (~1ns) <-> 캐시 (~10ns) <-> 메인 메모리 (~100ns)

주소 바인딩 (Address Binding)

프로그램에서 사용하는 주소를 실제 메모리 주소로 변환하는 과정이다. 바인딩 시점에 따라 세 가지로 나뉜다.

[주소 바인딩 시점]

1. 컴파일 시간 바인딩
   - 프로세스가 메모리의 어디에 올라갈지 컴파일 시 결정
   - 위치가 바뀌면 재컴파일 필요
   -: MS-DOS.COM 프로그램

2. 로드 시간 바인딩
   - 프로그램을 메모리에 올릴 때 주소 결정
   - 재배치 가능 코드(relocatable code) 사용
   - 로드 후 주소 변경 불가

3. 실행 시간 바인딩 (현대 OS)
   - 실행 중에 주소를 동적으로 변환
   - MMU(Memory Management Unit) 하드웨어 필요
   - 프로세스를 메모리 내에서 이동 가능

논리적 주소와 물리적 주소

[MMU를 통한 주소 변환]

CPU --[논리적 주소: 346]--> MMU --[물리적 주소: 14346]--> 메모리
                            |
                      재배치 레지스터
                      (기준값: 14000)

물리적 주소 = 논리적 주소 + 재배치 레지스터 값
346 + 14000 = 14346

사용자 프로그램은 논리적 주소만 다루며, 물리적 주소를 직접 볼 수 없다.

동적 로딩과 동적 링킹

동적 로딩: 루틴이 호출될 때만 메모리에 적재한다. 사용하지 않는 루틴은 메모리를 차지하지 않으므로 메모리 이용률이 향상된다.

동적 링킹: 라이브러리를 실행 시간에 링크한다.

[정적 링킹 vs 동적 링킹]

정적 링킹:
  프로그램 A: [코드 + libc 복사본]   -- 50MB
  프로그램 B: [코드 + libc 복사본]   -- 50MB
  총 메모리: 100MB

동적 링킹 (공유 라이브러리):
  프로그램 A: [코드 + libc 참조]     -- 10MB
  프로그램 B: [코드 + libc 참조]     -- 10MB
  libc.so:   [공유 라이브러리]       -- 40MB
  총 메모리: 60MB

Linux: .so 파일 (Shared Object)
Windows: .dll 파일 (Dynamic-Link Library)

연속 메모리 할당

가장 단순한 메모리 관리 방식으로, 각 프로세스를 연속된 메모리 영역에 배치한다.

메모리 보호

[기준 레지스터와 한계 레지스터]

     기준(base)              한계(limit)
        |                       |
        v                       v
+-------+=======================+-------+
| OS    | 프로세스 P의 영역      | 기타  |
+-------+=======================+-------+
  0     300040                 420940

CPU가 생성한 주소 addr에 대해:
  if (addr >= base && addr < base + limit)
      접근 허용 -> 물리 메모리 접근
  else
      트랩 발생 -> OS가 오류 처리

메모리 할당 전략

빈 공간(hole) 목록에서 프로세스에게 메모리를 할당하는 방법이다.

[메모리 할당 예시]

초기 상태:
|---OS---|--P1--|-------빈공간-------|--P3--|---빈공간---|

할당 전략:
1. 최초 적합(First Fit): 첫 번째로 충분한 빈 공간에 할당
   장점: 빠름

2. 최적 적합(Best Fit): 가장 작은 충분한 빈 공간에 할당
   장점: 작은 남은 공간 생성
   단점: 전체 탐색 필요, 매우 작은 조각 생성

3. 최악 적합(Worst Fit): 가장 큰 빈 공간에 할당
   장점: 남은 공간이 커서 재활용 가능
   단점: 전체 탐색 필요

단편화 (Fragmentation)

[외부 단편화 vs 내부 단편화]

외부 단편화:
|P1||P2||P3||P4|
    100KB     50KB     80KB

총 빈 공간: 230KB인데, 200KB 프로세스를 올릴 수 없음!
(연속 공간이 아니므로)

해결: 압축(Compaction) - 프로세스를 한쪽으로 이동
|P1|P2|P3|P4|-----230KB 빈 공간-----|

내부 단편화:
메모리를 고정 크기 블록으로 할당할 때
프로세스가 블록보다 작으면 내부에 남는 공간 발생

프로세스 크기: 18,462 바이트
할당 블록 크기: 20,000 바이트
내부 단편화: 1,538 바이트 낭비

단편화가 만들어지는 과정

위 그림은 단편화가 이미 생긴 상태를 보여 준다. 그 상태가 어떻게 만들어지는지 할당과 해제를 순서대로 따라가 보면, 왜 이것이 피할 수 없는 현상인지 이해하기 쉽다.

[외부 단편화가 생기는 과정: 총 1000KB 메모리]

0) 시작: 전체가 하나의 빈 공간
   |----------------- 1000 비어 있음 -----------------|

1) A(200), B(300), C(150), D(250) 순서로 할당 (최초 적합)
   |--A:200--|----B:300----|--C:150--|---D:250---|-100-|

2) B 종료 -> 300KB 반환
   |--A:200--|---300 빈공간---|--C:150--|---D:250---|-100-|

3) E(120) 할당 -> B가 있던 자리 앞부분에 들어감
   |--A:200--|-E:120-|-180-|--C:150--|---D:250---|-100-|

4) D 종료 -> 250KB 반환
   |--A:200--|-E:120-|-180-|--C:150--|--250 빈공간--|-100-|

이제 총 빈 공간 = 180 + 250 + 100 = 530KB
그런데 300KB 프로세스는 들어갈 수 없다.
가장 큰 연속 구간이 250KB뿐이기 때문이다.

3번 단계가 핵심이다. 300KB짜리 구멍에 120KB를 넣는 순간 180KB짜리 쓸모없는 조각이 생겼다. 어떤 할당 전략도 이 문제를 없애지는 못한다. 최적 적합은 남는 조각을 작게 만들다가 오히려 더 잘게 부수고, 최악 적합은 큰 조각을 유지하려다 큰 구멍을 빨리 소진한다. 프로세스 크기가 제각각인 이상 구멍의 크기와 요청의 크기가 딱 맞아떨어질 이유가 없다.

압축은 원리상 이 문제를 해결한다. 살아 있는 프로세스를 전부 한쪽으로 밀어붙이면 530KB가 하나로 합쳐진다. 문제는 비용이다. 프로세스를 옮긴다는 것은 그 프로세스의 메모리 전체를 실제로 복사한다는 뜻이고, 복사하는 동안 그 프로세스는 실행될 수 없다. 수 GiB를 옮긴다면 초 단위의 정지가 생긴다. 게다가 옮긴 뒤에는 재배치 레지스터를 갱신해야 하므로 실행 시간 바인딩이 전제되어야 한다. 컴파일 시간이나 로드 시간에 주소가 고정된 프로그램은 애초에 옮길 수 없다.

내부 단편화는 성격이 다르다. 이쪽은 할당 단위를 고정 크기로 잡는 순간 확정적으로 발생하고, 평균 낭비량을 미리 계산할 수 있다. 4 KiB 페이지를 쓰면 프로세스마다 평균 2 KiB 정도가 마지막 페이지에서 낭비된다. 요청 크기가 페이지 경계에 균등하게 흩어진다고 보면 마지막 페이지는 평균적으로 절반만 차기 때문이다. 페이지 크기를 키우면 뒤에서 볼 주소 변환 효율은 좋아지지만 이 낭비도 같이 커진다. 어느 쪽을 택할지는 워크로드가 결정한다.


페이징 (Paging)

페이징은 논리적 주소 공간을 비연속적으로 할당하여 외부 단편화를 완전히 제거하는 기법이다.

기본 개념

[페이징 주소 변환]

논리적 주소 = 페이지 번호(p) + 페이지 오프셋(d)

: 페이지 크기 4KB (2^12), 논리 주소 32비트
  상위 20비트 = 페이지 번호 (최대 2^20 = 1M 페이지)
  하위 12비트 = 오프셋 (0 ~ 4095)

+--------+--------+
| p (20) | d (12) |       논리적 주소
+--------+--------+
     |
     v
[페이지 테이블]
 p -> f (프레임 번호)
     |
     v
+--------+--------+
| f (20) | d (12) |       물리적 주소
+--------+--------+
[페이징 예시]

논리 메모리 (4페이지):        물리 메모리 (8프레임):
+------+                     +------+
|페이지0| ----+              |      | 프레임0
+------+     |              +------+
|페이지1| --+ |              |페이지2| 프레임1
+------+   | |              +------+
|페이지2| -+| |              |      | 프레임2
+------+  || |              +------+
|페이지3|  || +------------>|페이지0| 프레임3
+------+  ||                +------+
          |+--------------->|페이지1| 프레임4
          |                 +------+
          +---------------->|페이지2| 프레임5 (X)
                            +------+
                            |페이지3| 프레임6
                            +------+
                            |      | 프레임7
                            +------+

페이지 테이블:
  페이지0 -> 프레임3
  페이지1 -> 프레임4
  페이지2 -> 프레임1
  페이지3 -> 프레임6

주소 하나를 끝까지 따라가 보기

위 그림은 페이지가 프레임에 대응된다는 사실만 보여 준다. 실제로 CPU가 주소 하나를 던졌을 때 어떤 산술이 일어나는지 숫자를 넣어 끝까지 따라가 보자. 조건은 페이지 크기 4 KiB, 논리 주소 32비트다.

페이지 크기가 4 KiB이면 한 페이지 안의 바이트를 가리키는 데 필요한 비트 수가 자동으로 정해진다. 4096은 2의 12제곱이므로 오프셋은 정확히 12비트다. 이 12라는 숫자는 설계자가 고른 것이 아니라 페이지 크기에서 따라 나온 결과다. 32비트 주소에서 아래 12비트를 오프셋이 가져가면 위에 남는 20비트가 페이지 번호가 된다.

[논리 주소 0x00004A3C를 변환하기]

논리 주소:  0000 0000 0000 0000 0100 1010 0011 1100  (0x00004A3C)
             |<------ 상위 20비트 ------>|<- 12비트 ->|

1) 오프셋 d = 하위 12비트
   0xA3C = 2620
   (검산: 0x4A3C = 19004, 190044096으로 나눈 나머지 = 2620)

2) 페이지 번호 p = 상위 20비트
   0x00004A3C >> 12 = 4
   (검산: 190044096으로 나눈 몫 = 4)

3) 페이지 테이블에서 4번 항목을 읽는다
   유효 비트가 0이면 여기서 페이지 폴트 -> OS로 트랩
   유효 비트가 1이면 프레임 번호 f를 얻는다. 여기서는 f = 9

4) 물리 주소 = (f * 페이지 크기) + d
   = (9 * 4096) + 2620
   = 36864 + 2620
   = 39484  (0x00009A3C)

여기서 눈여겨볼 것이 두 가지 있다. 첫째, 오프셋은 변환되지 않고 그대로 지나간다. 페이지와 프레임의 크기가 같기 때문이고, 그래서 하위 세 자리 A3C가 결과에도 그대로 남아 있다. 둘째, 실제 하드웨어는 곱셈이 아니라 비트 시프트로 이 일을 한다. 페이지 크기를 2의 거듭제곱으로 잡는 이유가 여기 있다. 나눗셈이나 곱셈 회로 없이 배선만으로 주소를 자를 수 있다.

이 변환은 프로그램이 메모리를 건드릴 때마다 한 번씩 일어난다. 명령어를 가져올 때 한 번, 그 명령어가 데이터를 읽으면 또 한 번이다. 그런데 페이지 테이블 자체가 메모리에 있으므로, 순진하게 구현하면 메모리 접근 한 번이 메모리 접근 두 번이 된다. 프로그램이 정확히 두 배 느려진다는 뜻이고, 이것이 TLB가 존재하는 이유다.

TLB (Translation Lookaside Buffer)

매번 페이지 테이블을 메모리에서 조회하면 메모리 접근이 두 배로 늘어난다. TLB는 페이지 테이블의 캐시 역할을 한다.

[TLB를 통한 주소 변환]

CPU --논리 주소(p, d)--> TLB 검색
                          |
              +-----------+-----------+
              |                       |
          TLB 히트                 TLB 미스
          (빠름, ~1ns)             (느림)
              |                       |
          프레임 f 획득          페이지 테이블 조회
              |                  프레임 f 획득
              |                  TLB에 등록
              |                       |
              +----------+------------+
                         |
                  물리 주소 (f, d)                  메모리 접근

유효 접근 시간(EAT) 계산:
  TLB 히트율 = 99% (일반적)
  메모리 접근 시간 = 100ns
  TLB 접근 시간 = 10ns

  EAT = 0.99 * (10 + 100) + 0.01 * (10 + 100 + 100)
      = 0.99 * 110 + 0.01 * 210
      = 108.9 + 2.1
      = 111ns

  TLB 없이: 200ns (메모리 2번 접근)
  TLB 있으면: 111ns (44.5% 개선)

EAT 식이 왜 저런 모양인지 손으로 확인하기

위 EAT 식이 왜 그렇게 생겼는지는 미스가 났을 때 실제로 메모리를 몇 번 만지는지 세어 보면 드러난다.

히트일 때 하드웨어는 TLB를 한 번 뒤지고(위 예시에서 10ns) 얻은 물리 주소로 데이터를 한 번 읽는다(100ns). 합이 110ns다. 미스일 때는 TLB를 뒤져서 없다는 사실을 확인하고(10ns), 페이지 테이블을 읽으러 메모리에 한 번 가고(100ns), 그렇게 얻은 물리 주소로 데이터를 읽으러 또 한 번 간다(100ns). 합이 210ns다. 즉 미스 비용은 히트 비용에 메모리 접근 한 번이 더해진 값이고, EAT는 이 두 값을 히트율로 가중 평균한 것에 지나지 않는다.

숫자를 바꿔서 한 번 더 계산해 보자. 히트율이 98퍼센트로 떨어지고 메모리 접근이 80ns, TLB 접근이 1ns인 기계라면 이렇게 된다.

[히트율 98%, 메모리 80ns, TLB 1ns인 경우]

히트 비용 = 1 + 80        = 81ns
미스 비용 = 1 + 80 + 80   = 161ns

EAT = 0.98 * 81 + 0.02 * 161
    = 79.38 + 3.22
    = 82.6ns

TLB가 전혀 없다면: 80 + 80 = 160ns
이상적인 경우(81ns) 대비 오버헤드 = 82.6 / 81 =1.02
히트율이 90%로 떨어지면:
EAT = 0.90 * 81 + 0.10 * 161
    = 72.9 + 16.1
    = 89.0ns
오버헤드 = 89.0 / 81 =1.10

히트율 98퍼센트와 90퍼센트의 차이는 겨우 8퍼센트포인트인데, 오버헤드는 2퍼센트에서 10퍼센트로 다섯 배가 된다. 미스 한 번의 비용이 히트보다 두 배 가까이 크기 때문이며, 캐시 성능을 이야기할 때 히트율의 마지막 몇 퍼센트가 유난히 비싸다고 말하는 이유가 이것이다.

TLB 항목 수는 수십에서 수백 개 수준이라 커버할 수 있는 메모리 범위가 제한된다. 4 KiB 페이지 항목 64개를 담는 TLB라면 한 번에 커버하는 범위가 256 KiB뿐이다. 이보다 넓은 영역을 무작위로 훑는 프로그램은 히트율이 급격히 떨어진다. 이때 페이지 크기를 키우는 방법이 효과를 내는데, 같은 항목 수로 훨씬 넓은 범위를 커버할 수 있기 때문이다. 앞 절에서 본 내부 단편화 증가가 그 대가다.

페이지 테이블 구조

프로세스의 주소 공간이 크면(예: 64비트) 페이지 테이블 자체가 매우 커진다.

얼마나 커지는지는 직접 곱해 보면 감이 온다. 리눅스 커널 문서에 따르면 현재 x86-64에서 지원되는 가상 주소 폭은 48비트와 57비트다. 48비트 주소 공간에 4 KiB 페이지를 쓰면 페이지 개수는 2의 36제곱, 약 687억 개다. 항목 하나를 8바이트로 잡아도 테이블 하나가 512 GiB이며, 이것이 프로세스마다 하나씩 필요하다.

[평면 페이지 테이블의 크기 계산]

48비트 가상 주소, 4 KiB(2^12) 페이지:
  페이지 개수  = 2^48 / 2^12 = 2^36 = 68,719,476,736  항목 크기    = 8바이트 가정
  테이블 크기  = 2^36 * 8 = 2^39 바이트 = 512 GiB

프로세스 하나가 실제로 쓰는 메모리가 10 MiB라도
테이블은 512 GiB를 미리 잡아야 한다.

57비트 가상 주소라면:
  페이지 개수  = 2^57 / 2^12 = 2^45
  테이블 크기  = 2^45 * 8 = 2^48 바이트 = 256 TiB

물리 메모리보다 페이지 테이블이 훨씬 크다는 이 우스운 상황이 계층 구조가 필요한 이유다. 핵심 관찰은 주소 공간이 희소하다는 것이다. 프로세스는 48비트 공간의 극히 일부만 쓰고, 코드 영역과 힙과 스택 사이에는 거대한 빈 구간이 있다. 계층 구조는 이 빈 구간에 대응하는 하위 테이블을 아예 만들지 않는 방식으로 크기를 줄인다. 상위 테이블의 해당 항목을 없음으로 표시해 두면 그 아래에 매달릴 테이블 전체가 사라진다.

계층적 페이지 테이블 (Multi-level Page Table)

[2단계 페이지 테이블]

32비트 주소, 4KB 페이지:
+--------+--------+--------+
| p1(10) | p2(10) | d(12)  |
+--------+--------+--------+

외부 페이지 테이블 (1단계)
+---+
| 0 |---> 2단계 테이블 A
+---+      +---+
| 1 |--+   | 0 |--> 프레임 번호
+---+  |   +---+
| 2 |  |   | 1 |--> 프레임 번호
+---+  |   +---+
       |   | ...|
       |   +---+
       |
       +-> 2단계 테이블 B
           +---+
           | 0 |--> 프레임 번호
           +---+
           | ...|
           +---+

장점: 사용하지 않는 영역의 2단계 테이블은 생성하지 않음
-> 메모리 절약

x86-64의 페이지 워크

리눅스 커널 문서는 페이지 테이블 계층을 위에서부터 PGD(Page Global Directory), P4D(Page Level 4 Directory), PUD(Page Upper Directory), PMD(Page Middle Directory), PTE(Page Table Entry)의 다섯 이름으로 부른다. P4D는 5단계 페이지 테이블을 수용하려고 도입된 층이고, 4단계 구성에서는 PUD가 그 자리를 맡는다. 하드웨어 벤더 문서는 같은 계층을 다른 이름으로 부르지만, 여기서는 확인된 커널 문서의 이름을 그대로 쓴다.

같은 커널 문서는 원래 x86-64가 4단계 페이징 때문에 가상 주소 공간이 256 TiB로 제한되었고, 5단계 페이징이 이를 128 PiB로 넓혔다고 설명한다. 다만 5단계 기계라도 기본적으로는 47비트 위쪽 주소를 할당하지 않는다. 애플리케이션이 명시적으로 높은 힌트 주소를 요구할 때만 그 위를 쓴다. 포인터의 상위 비트에 자기 태그를 심어 둔 프로그램들이 있어서, 주소 공간을 갑자기 넓히면 그것들이 깨지기 때문이다.

주소 하나가 네 단계를 어떻게 지나가는지 번호를 붙여 보면 이렇다.

[4단계 페이지 워크: 48비트 주소를 자르는 방법]

48비트 가상 주소를 9 + 9 + 9 + 9 + 12 로 나눈다
+--------+--------+--------+--------+-----------+
| L4 (9) | L3 (9) | L2 (9) | L1 (9) | 오프셋(12) |
+--------+--------+--------+--------+-----------+
  각 단계 인덱스가 9비트 = 테이블 하나에 항목 512  항목 8바이트 * 512= 4096바이트 = 정확히 한 페이지

1) CPU의 페이지 테이블 베이스 레지스터에서
   최상위 테이블(PGD)의 물리 주소를 읽는다
2) L4 인덱스로 PGD 항목을 읽는다        -> 메모리 접근 1   다음 단계 테이블의 물리 주소를 얻는다
3) L3 인덱스로 그 테이블 항목을 읽는다   -> 메모리 접근 24) L2 인덱스로 다음 테이블 항목을 읽는다 -> 메모리 접근 35) L1 인덱스로 최종 항목(PTE)을 읽는다   -> 메모리 접근 4   여기서 프레임 번호와 보호 비트가 나온다
6) 프레임 번호에 오프셋을 붙여 물리 주소를 완성하고
   실제 데이터를 읽는다                  -> 메모리 접근 5
TLB 미스 한 번의 비용 = 메모리 접근 5TLB 히트 한 번의 비용 = 메모리 접근 1

각 단계의 인덱스가 9비트인 것은 우연이 아니다. 항목 하나가 8바이트이므로 512개가 모이면 4096바이트, 즉 정확히 한 페이지가 된다. 페이지 테이블 자체를 페이지 단위로 관리할 수 있으니 할당과 교체가 균일해진다.

그리고 앞 절의 EAT 계산이 4단계 환경에서는 훨씬 무거워진다는 점도 확인할 수 있다. 미스 한 번이 메모리 접근 한 번이 아니라 네 번을 더 요구한다. 실제 CPU는 이 부담을 줄이려고 중간 단계 항목을 따로 캐시해 두지만, TLB 미스가 잦은 워크로드가 왜 그렇게 느린지는 이 그림 하나로 설명된다.

해시 페이지 테이블

[해시 페이지 테이블]

논리 페이지 번호 p를 해시 함수에 입력
-> 해시 테이블의 슬롯으로 매핑
-> 체인(연결 리스트)에서 p를 검색
-> 대응하는 프레임 번호 반환

64비트 주소 공간처럼 매우 큰 경우에 유용

역 페이지 테이블 (Inverted Page Table)

[역 페이지 테이블]

일반 페이지 테이블: 프로세스당 하나 (논리 -> 물리)
역 페이지 테이블:   시스템에 하나 (물리 프레임 -> 프로세스, 페이지)

물리 메모리의 각 프레임에 대해 하나의 항목:
프레임 0: (PID=5, 페이지=3)
프레임 1: (PID=2, 페이지=7)
프레임 2: (비어 있음)
프레임 3: (PID=5, 페이지=0)
...

장점: 물리 메모리 크기에 비례하는 테이블 크기
단점: 주소 변환 시 전체 테이블 검색 필요 (해시로 해결)

스와핑 (Swapping)

프로세스 전체 또는 일부를 디스크로 내보내 메모리를 확보하는 기법이다.

[표준 스와핑]

메인 메모리                    백킹 스토어(디스크)
+---------+                   +---------+
|   OS    |                   |         |
+---------+                   |  P2|
|   P1    |  <-- swap in ---  |  이미지  |
+---------+                   |         |
|   P3    |  --- swap out --> |         |
+---------+                   +---------+
|   빈공간  |
+---------+

현대 시스템에서는 프로세스 전체가 아닌
페이지 단위로 스와핑 (페이지 스와핑)
[페이지 단위 스와핑]

프로세스의 특정 페이지만 디스크로 이동:
- 오랫동안 사용하지 않은 페이지를 swap out
- 필요할 때 다시 swap in
- 가상 메모리의 기반 기술

메모리 보호

페이징 환경에서의 메모리 보호는 페이지 테이블에 보호 비트를 추가하여 구현한다.

[페이지 테이블 항목의 구조]

+-------+-----+-----+-----+-------+
| 프레임 | 유효 | 읽기 | 쓰기 | 실행  |
| 번호   | 비트 | 가능 | 가능 | 가능  |
+-------+-----+-----+-----+-------+

유효 비트(Valid bit):
  1 = 이 페이지가 프로세스의 논리 주소 공간에 속함
  0 = 유효하지 않은 페이지 (접근 시 트랩 발생)

보호 비트:
  읽기 전용 페이지에 쓰기 시도 -> 하드웨어 트랩
  코드 페이지에 대해 실행만 허용, 쓰기 불허
// 메모리 보호 예시: mprotect 시스템 콜
#include <sys/mman.h>
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>

void segfault_handler(int sig) {
    printf("세그먼트 폴트 발생! 보호된 메모리 접근 시도\n");
    exit(1);
}

int main() {
    signal(SIGSEGV, segfault_handler);

    // 페이지 크기 단위로 메모리 할당
    size_t page_size = getpagesize();  // 보통 4096
    void *ptr = aligned_alloc(page_size, page_size);

    // 데이터 쓰기
    *(int *)ptr = 42;
    printf("값: %d\n", *(int *)ptr);

    // 메모리를 읽기 전용으로 보호
    mprotect(ptr, page_size, PROT_READ);

    // 쓰기 시도 -> 세그먼트 폴트!
    *(int *)ptr = 100;

    free(ptr);
    return 0;
}

실패 사례와 함정

메모리 문제는 대개 장비가 느리다는 모호한 신고로 도착한다. 증상을 숫자로 분리하는 순서만 정해져 있으면 원인까지 몇 분이면 간다. 아래 명령은 리눅스 기준이며, vmstatfree는 procps-ng 패키지에 들어 있다.

증상 1: 장비가 전반적으로 느리고 디스크가 계속 돈다

가장 먼저 볼 것은 스와핑 여부다. vmstat을 1초 간격으로 돌린다.

vmstat 1

예시 출력이다.

procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 2  1 512340  81234  12044 240188  184  392  2210  1180 1420 3100 12  8 41 39  0
 1  2 528900  76120  11980 231044  256  512  2680  1420 1510 3320 11  9 38 42  0
 3  1 541220  72880  11902 226310  312  604  3010  1690 1620 3480 10 10 35 45  0

man 페이지의 필드 설명은 다음과 같다. si는 "Amount of memory swapped in from disk (/s)", so는 "Amount of memory swapped to disk (/s)"이다. 메모리 절의 swpd는 "the amount of swap memory used", free는 "the amount of idle memory", buff는 "the amount of memory used as buffers", cache는 "the amount of memory used as cache"다.

판정 기준은 단순하다. siso가 계속 0이 아니면 지금 스와핑 중이다. 반대로 swpd가 큰 값이지만 siso가 0이라면, 예전에 밀려난 페이지가 스왑에 남아 있을 뿐 지금은 문제가 없다는 뜻이다. 이 구분을 놓치고 swpd 숫자만 보고 놀라는 경우가 흔하다. 위 출력에서는 so가 계속 커지고 있고 I/O 대기를 뜻하는 wa 열이 40 근처이므로, 메모리가 부족해서 디스크로 밀어내는 중이고 CPU는 그 디스크를 기다리느라 놀고 있다.

증상 2: free가 거의 0인데 정말 부족한 것인가

여기서 가장 흔한 오해가 나온다.

free -h

예시 출력이다.

               total        used        free      shared  buff/cache   available
Mem:            15Gi       4.2Gi       324Mi       210Mi        11Gi        10Gi
Swap:          8.0Gi       501Mi       7.5Gi

free 열이 324 MiB라고 해서 메모리가 없는 것이 아니다. man 페이지에 따르면 buff/cache는 buffers와 cache의 합이고, available은 "Estimation of how much memory is available for starting new applications, without swapping"이다. 커널은 남는 메모리를 페이지 캐시로 채워 두고 필요하면 즉시 회수하므로, 봐야 할 숫자는 free가 아니라 available이다. 위 출력에서는 10 GiB가 남아 있으므로 여유로운 상태다. 같은 문서는 used가 total에서 available을 뺀 값으로 계산된다고 명시한다. -h 옵션은 각 값을 세 자리로 읽히는 단위로 바꾸고 B, Ki, Mi, Gi, Ti, Pi 같은 단위 표기를 붙인다.

증상 3: 프로세스가 아무 로그도 없이 사라졌다

애플리케이션 로그에 종료 흔적이 없고 셸이 보고하는 종료 코드가 SIGKILL을 가리킨다면, 커널이 죽였을 가능성이 높다. 커널 링 버퍼를 본다.

dmesg -T | grep -i -E 'out of memory|oom'

예시 출력이다.

[Sat Aug 15 04:12:31 2026] myserver invoked oom-killer: order=0, oom_score_adj=0
[Sat Aug 15 04:12:31 2026] Out of memory: Killed process 4711 (myserver)
                           total-vm:8394204kB, anon-rss:7912044kB, file-rss:0kB

정확한 문구는 커널 버전에 따라 달라지므로 위 출력은 형태를 보여 주는 예시로만 읽는 편이 좋다. 확실한 것은 어느 프로세스를 죽일지 커널이 점수로 정한다는 사실이다. man 페이지는 /proc/PID/oom_score를 "the current score that the kernel gives to this process for the purpose of selecting a process for the OOM-killer"로 설명한다. 점수는 주로 메모리 사용량을 반영하며, oom_score_adj 설정이 그 값을 조정한다.

어느 프로세스가 실제로 얼마나 쓰는지 확인할 때는 프로세스의 메모리 맵을 본다. man 페이지에 따르면 pmap은 "the memory map of a process or processes"를 보고하고, 문법은 pmap [option ...] pid ...이다. -x는 확장 형식을, -X는 그보다 더 자세한 정보를 보여 준다. 다만 -X의 출력 형식은 /proc/PID/smaps를 따라 바뀐다고 문서가 경고한다.

getconf PAGESIZE
pmap -x 4711 | tail -3

예시 출력이다.

4096

Address           Kbytes     RSS   Dirty Mode  Mapping
...
total kB         8394204 7912044 7910020

getconf PAGESIZE가 돌려주는 값은 POSIX가 정의한 시스템 설정값이다. man 페이지는 PAGESIZE를 "Size of a page in bytes. Must not be less than 1."로 정의하고, PAGE_SIZE는 그 동의어라고 밝힌다. 이 값을 알아야 앞에서 계산한 오프셋 비트 수가 정해지고, 프로세스가 실제로 몇 페이지를 쓰는지도 나눠서 확인할 수 있다.


언제 쓰지 않나

이 장의 절반은 연속 메모리 할당 이야기인데, 오늘날 범용 운영체제에서 이 방식을 그대로 쓰는 곳은 없다. 리눅스도 윈도우도 macOS도 페이징을 쓰며, 최초 적합이나 최적 적합으로 프로세스 전체를 연속 구간에 배치하지 않는다. 외부 단편화 때문에 실용성이 없었고, 그 문제를 없애려고 나온 것이 바로 페이징이기 때문이다.

그래도 이 내용을 배우는 이유는 두 가지다. 하나는 페이징이 무엇을 해결했는지 이해하려면 해결되기 전의 상태를 알아야 한다는 것이고, 다른 하나는 연속 할당이 여전히 살아 있는 자리가 있다는 것이다.

첫 번째 자리는 MMU가 없거나 쓰지 않는 임베디드 시스템과 실시간 운영체제다. 여기서는 주소 변환 자체가 없으므로 물리 메모리를 구간으로 잘라 나눠 주는 것 외에 방법이 없다. 실시간 시스템은 오히려 이쪽을 선호하기도 한다. 페이지 폴트가 언제 터질지 모르는 것보다 접근 시간이 항상 일정한 편이 낫기 때문이다.

두 번째 자리는 현대 시스템 안에도 있다. 장치가 DMA로 직접 읽고 쓰는 버퍼는 물리적으로 연속이어야 하는 경우가 많고, 대용량 페이지를 미리 예약해 두는 설정도 결국 큰 연속 구간을 확보하는 일이다. 이런 예약을 왜 부팅 직후에 하는 편이 성공률이 높은지도 이 장의 내용으로 설명된다. 시스템이 오래 돌수록 물리 메모리가 잘게 부서져서 큰 연속 구간을 찾기 어려워진다.

반대로 이 장의 페이징 부분은 유효 기간이 없다. 주소 변환, TLB, 다단계 테이블, 보호 비트는 지금 쓰는 모든 서버와 노트북과 휴대폰에서 매 명령어마다 동작하고 있다. 응용 개발자가 페이지 테이블을 직접 만질 일은 없지만, 성능 문제를 추적하다 보면 결국 이 계층이 튀어나온다. 왜 메모리를 순차로 훑는 코드가 무작위로 훑는 코드보다 훨씬 빠른지, 왜 대용량 페이지가 어떤 워크로드에서만 효과가 있는지 같은 질문의 답이 전부 여기에 있다.


참고 자료


정리

메인 메모리 관리는 프로세스에게 효율적이고 안전한 메모리 공간을 제공하기 위한 핵심 기능이다. MMU가 논리적 주소를 물리적 주소로 변환하고, 페이징은 외부 단편화를 제거하며, TLB가 주소 변환 성능을 보장한다. 계층적 페이지 테이블과 역 페이지 테이블은 대용량 주소 공간을 효율적으로 관리하고, 보호 비트를 통해 프로세스 간 메모리 격리를 보장한다.

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다