컴퓨터공학 300 주제 시리즈의 111번째 글이다. 전체 지도는 여기.

한 줄 요약

세그멘테이션은 주소 공간을 코드·힙·스택처럼 의미 있는 가변 크기 단위(세그먼트)로 나누고, 세그먼트마다 base 와 limit 으로 주소를 변환·보호하는 방식이며, 외부 단편화 때문에 범용 메모리 관리의 주역 자리는 페이징에 넘겼지만 개념과 용어는 지금도 남아 있다.

왜 필요한가

가장 원시적인 메모리 가상화는 프로세스 하나에 base 레지스터 하나, bound 레지스터 하나를 주는 것이다. 가상 주소에 base 를 더해 물리 주소를 만들고, bound 를 넘으면 막는다. 간단하지만 문제가 있다.

가상 주소 공간 (16KB)
+--------+  0KB
|  코드  |
+--------+  2KB
|  힙 -> |
|        |
|  (빈)  |   <- 이 큰 빈 공간까지 물리 메모리를 통째로 차지한다
|        |
|  <- 스택|
+--------+ 16KB

힙과 스택 사이의 거대한 빈 공간까지 물리 메모리에 연속으로 잡아야 한다. 세그멘테이션은 이 base/bound 쌍을 논리 단위마다 하나씩 두자는 아이디어다. 코드, 힙, 스택을 각각 물리 메모리 아무 곳에 따로 놓을 수 있으니 빈 공간은 메모리를 쓰지 않는다.

이 개념을 알아야 하는 이유가 하나 더 있다. 우리가 매일 보는 segmentation fault 라는 말이 여기서 왔다. 지금의 리눅스는 대부분 페이징으로 보호를 하지만, 이름은 세그먼트 시절의 것이다.

핵심 개념

세그먼트 테이블과 주소 변환

가상 주소는 (세그먼트 번호, 오프셋) 쌍으로 해석된다.

가상 주소 = [ seg 번호 | offset ]
                |          |
                v          |
     세그먼트 테이블        |
     +------+-------+------+-----+
     | seg  | base  | limit| 권한 |
     +------+-------+------+-----+
     |  0   | 0x8000| 0x800| r-x |  코드
     |  1   | 0x9000| 0x1000| rw- |  힙
     |  2   | 0xC000| 0x400| rw- |  스택
     +------+-------+------+-----+
                |
     offset < limit 인가? 권한이 맞는가?
         아니면 -> 폴트 (segmentation fault)
         맞으면 -> 물리 주소 = base + offset

세그먼트마다 권한을 따로 줄 수 있어 코드는 읽기·실행만, 데이터는 읽기·쓰기만 허용한다. 같은 코드 세그먼트를 여러 프로세스가 공유하기도 쉽다. 스택처럼 아래로 자라는 세그먼트는 방향 비트를 따로 두어 처리하기도 했다.

페이징과 비교

  세그멘테이션 페이징
조각 크기 가변(논리 단위) 고정(보통 4KB)
프로그래머에게 보이나 보인다(코드/데이터/스택) 보이지 않는다
단편화 외부 단편화 내부 단편화(작음)
변환 정보 세그먼트당 base, limit 페이지당 PTE
보호·공유 단위 논리 단위라 자연스럽다 페이지 단위

외부 단편화

세그먼트는 크기가 제각각이라, 만들고 지우기를 반복하면 물리 메모리에 크기가 다른 구멍이 흩어진다. 구멍들의 합은 충분한데 하나하나가 작아서 새 세그먼트를 못 놓는 상황이 외부 단편화다.

대응책은 있지만 모두 비용이 든다.

  • 압축(compaction): 세그먼트를 한쪽으로 몰아 구멍을 합친다. 복사 비용이 크고 그동안 프로세스를 멈춰야 한다.
  • 배치 전략: first-fit(처음 맞는 구멍), best-fit(가장 딱 맞는 구멍), worst-fit(가장 큰 구멍) 등. 어떤 전략도 단편화를 없애지는 못한다.

이 문제가 결국 고정 크기의 페이징을 주류로 만들었다. 같은 문제가 지금도 malloc 같은 사용자 공간 할당기 안에서 그대로 반복된다는 점도 기억해 두자.

세그먼트 + 페이징

두 방식을 합칠 수도 있다. 세그먼트로 논리 단위를 나누고, 각 세그먼트 안을 다시 페이지로 나눈다. Multics 가 대표적인 예이고, 32비트 x86 도 세그먼트 변환 뒤 페이징을 거치는 구조였다.

x86-64 리눅스에서의 현재 모습

리눅스 커널 문서에 따르면 64비트 모드에서 CS/SS/DS/ES 세그먼트는 무시되고 base 가 항상 0 이다. 사실상 평평한(flat) 주소 공간 + 페이징이다. 예외가 FS 와 GS 다. 이 둘은 64비트에서도 base 주소가 동작하며, FS 는 흔히 스레드 지역 저장소(TLS) 를 가리키는 데 쓰인다. 스레드마다 FS base 를 다르게 두면 같은 코드로 스레드별 변수를 찾을 수 있다. 세그멘테이션은 이렇게 작은 역할로 살아남았다.

또한 리눅스의 /proc/PID/maps 에 보이는 코드·힙·스택·라이브러리 영역(VMA)은 개념적으로 “논리 단위로 나눈 주소 공간”이라는 세그멘테이션의 생각을 페이징 위에서 구현한 것이라고 볼 수 있다.

직접 해 보기

세그먼트 테이블 변환과, 가변 크기 할당이 외부 단편화를 만드는 장면을 함께 흉내 낸다.

# 1) 세그먼트 테이블로 주소 변환: (세그먼트 번호, 오프셋) -> 물리 주소
SEG = {  # 번호: (이름, base, limit, 권한)
    0: ("code",  0x8000, 0x0800, "r-x"),
    1: ("heap",  0x9000, 0x1000, "rw-"),
    2: ("stack", 0xC000, 0x0400, "rw-"),
}
def translate(seg, off, mode="r"):
    name, base, limit, perm = SEG[seg]
    if off >= limit:
        return f"seg {seg}({name}) off 0x{off:x}: limit 0x{limit:x} 초과 -> segmentation fault"
    if mode not in perm:
        return f"seg {seg}({name}) off 0x{off:x}: '{mode}' 권한 없음 -> protection fault"
    return f"seg {seg}({name}) off 0x{off:x} -> 물리 0x{base + off:x}"

print(translate(1, 0x0123))
print(translate(2, 0x0500))
print(translate(0, 0x0010, "w"))

# 2) 가변 크기 할당이 만드는 외부 단편화 (first-fit)
MEM = 100
free = [(0, MEM)]                       # (시작, 크기) 빈 구멍 목록
def alloc(size):
    for i, (s, n) in enumerate(free):
        if n >= size:
            free[i] = (s + size, n - size)
            if free[i][1] == 0: free.pop(i)
            return s
    return None
def release(start, size):
    free.append((start, size)); free.sort()
    merged = [free[0]]                  # 이웃한 구멍 합치기
    for s, n in free[1:]:
        ps, pn = merged[-1]
        merged[-1:] = [(ps, pn + n)] if ps + pn == s else [(ps, pn), (s, n)]
    free[:] = merged

blocks = [(alloc(20), 20) for _ in range(5)]   # 20 x 5 = 100 꽉 참
for b in blocks[::2]: release(*b)              # 0, 2, 4 번째 해제
print("빈 구멍:", free, "합계", sum(n for _, n in free))
print("30 할당 시도 ->", alloc(30))
seg 1(heap) off 0x123 -> 물리 0x9123
seg 2(stack) off 0x500: limit 0x400 초과 -> segmentation fault
seg 0(code) off 0x10: 'w' 권한 없음 -> protection fault
빈 구멍: [(0, 20), (40, 20), (80, 20)] 합계 60
30 할당 시도 -> None

빈 공간이 합계 60 이나 있는데 30 짜리 하나를 못 놓는다. 이것이 외부 단편화다. 페이징이었다면 30 을 페이지 단위로 쪼개 세 구멍에 흩어 놓으면 그만이다.

현업에서는

  • Segmentation fault 읽기: 리눅스에서 SIGSEGV 는 실제로는 페이지 테이블에 매핑이 없거나 권한이 맞지 않는 접근에서 난다. dmesg 에 찍히는 segfault at <주소> ip <주소> ... error <코드> 줄의 접근 주소가 0 근처면 널 포인터 역참조, 스택 근처면 스택 오버플로를 먼저 의심한다.
  • /proc/PID/maps 로 메모리 지도 보기: 프로세스의 코드, 힙, 스택, 공유 라이브러리, mmap 영역이 권한(r-xp, rw-p)과 함께 나온다. 메모리가 어디서 늘었는지 추적할 때 /proc/PID/smaps 와 함께 쓴다.
  • 할당기 단편화: 오래 도는 서비스의 RSS 가 실제 사용량보다 계속 커지는 현상은 사용자 공간 할당기 안의 단편화일 때가 많다. glibc malloc 대신 jemalloc 이나 tcmalloc 으로 바꾸는 것이 흔한 처방이고, 효과는 워크로드마다 측정해서 확인한다.
  • TLS 와 FS 레지스터: C 의 __thread 변수, 각 언어 런타임의 스레드 지역 변수는 x86-64 에서 FS 세그먼트 기반 주소로 접근된다. 디스어셈블에서 %fs: 접두어를 보면 TLS 접근이다.

확인 문제

  1. 단일 base/bound 방식과 비교해 세그멘테이션이 얻는 이점은?
  2. 세그멘테이션에서 외부 단편화가 생기는 이유는? 페이징은 왜 그렇지 않은가?
  3. 세그먼트 테이블 항목에 보통 들어가는 정보 세 가지는?
  4. x86-64 리눅스에서 세그먼트 레지스터 중 여전히 base 가 의미 있는 것은 무엇이고, 어디에 쓰이는가?

풀이

  1. 코드·힙·스택을 따로 배치하므로 그 사이의 빈 가상 공간이 물리 메모리를 차지하지 않는다. 세그먼트별 권한과 공유도 가능하다.
  2. 세그먼트 크기가 제각각이라 해제 후 크기가 다른 구멍이 흩어지기 때문이다. 페이징은 모든 조각이 같은 크기라 아무 빈 프레임이나 쓸 수 있다.
  3. base(물리 시작 주소), limit(크기), 보호 권한(읽기/쓰기/실행).
  4. FS 와 GS 다. FS 는 주로 스레드 지역 저장소(TLS)를 가리키는 데 쓰인다.

더 읽을거리 (References)