본문 바로가기

세그먼테이션

프로그램을 코드, 데이터, 힙, 스택 같은 논리적인 단위로 나누는 세그먼테이션을 살펴봅니다. 세그먼트 번호와 오프셋으로 이루어진 주소를 세그먼트 테이블로 변환하며 번호, 범위, 권한을 차례로 검사하는 과정을 모델로 따라가고, 세그먼테이션 폴트가 생기는 원인, 세그먼테이션의 장단점, x86-64에서 세그먼테이션이 남은 모습을 정리합니다.

앞 글에서 프로세스 전체를 하나의 연속 블록에 놓으면 외부 단편화가 생긴다는 것을 보았습니다. 이 제약을 줄이는 한 가지 방법은 프로세스를 여러 조각으로 나누어 각각 따로 놓는 것입니다. 그렇다면 어떤 기준으로 나눌까요? 프로그래머는 프로그램을 0번지부터 어딘가까지 이어진 바이트 덩어리로 생각하지 않습니다. main 함수의 코드, 전역 변수, 함수 호출이 쌓이는 스택, 실행 중에 할당하는 힙처럼 쓰임새가 다른 영역들로 생각합니다.

세그먼테이션(segmentation)은 프로세스의 주소 공간을 이런 논리적인 단위로 나누고, 각 단위를 세그먼트(segment)로 관리하는 방법입니다. 세그먼트마다 크기가 다르고, 물리 메모리의 서로 다른 곳에 놓일 수 있으며, 서로 다른 권한을 가질 수 있습니다. 이 글에서는 세그먼트로 나눈 주소를 어떻게 변환하고 검사하는지, 그리고 이 방식의 장점과 한계는 무엇인지 살펴봅니다.

프로그램을 쓰임새대로 나누기

C 프로그램의 주소 공간은 보통 다음 영역들로 나누어 설명합니다. 3장에서 본 프로세스의 메모리 영역과 같은 구분입니다.

영역담는 것알맞은 권한
텍스트(text)컴파일된 기계어 코드읽기, 실행
데이터(data)초깃값이 있는 전역 변수와 정적 변수읽기, 쓰기
BSS초깃값이 없거나 0인 전역 변수와 정적 변수읽기, 쓰기
힙(heap)malloc이나 new로 실행 중에 할당한 메모리읽기, 쓰기
스택(stack)함수 호출 프레임과 지역 변수읽기, 쓰기

영역마다 알맞은 권한이 다르다는 점이 중요합니다. 코드는 실행해야 하지만 고쳐서는 안 되고, 데이터는 고칠 수 있어야 하지만 실행해서는 안 됩니다. 또 영역마다 크기가 변하는 방식도 다릅니다. 텍스트와 데이터는 프로그램을 적재할 때 크기가 정해지지만, 힙과 스택은 실행하면서 커지거나 줄어듭니다. 이 영역들을 각각 세그먼트로 두면, 크기와 권한을 영역마다 따로 관리할 수 있습니다.

다만 이것은 프로그램의 영역을 쓰임새로 나눈 설명이며, 오늘날의 운영체제가 각 영역을 하드웨어 세그먼트 하나로 구현한다는 뜻은 아닙니다. 지금의 Linux는 각 영역을 페이지 단위의 가상 메모리 매핑으로 구현하고, 그 모습은 3장에서 본 /proc/<PID>/maps로 볼 수 있습니다. 각 영역에 놓인 변수의 주소는 다음 프로그램으로 확인할 수 있습니다.

segments_example.c
#include <stdio.h>
#include <stdlib.h>

int global_init = 42;          /* 데이터 */
int global_uninit;             /* BSS */

void function(void)            /* 텍스트 */
{
    int local_var = 10;        /* 스택 */
    int *dynamic = malloc(64); /* 힙 */

    printf("text:  %p\n", (void *) function);
    printf("data:  %p\n", (void *) &global_init);
    printf("bss:   %p\n", (void *) &global_uninit);
    printf("heap:  %p\n", (void *) dynamic);
    printf("stack: %p\n", (void *) &local_var);

    free(dynamic);
}

int main(void)
{
    function();
    return 0;
}

출력되는 주소의 순서와 간격은 컴파일과 링크 옵션, ASLR, 할당기에 따라 달라지므로 특정 값을 기대할 수는 없습니다. 함수 포인터를 void *로 바꾸어 출력하는 것도 C 표준이 아니라 POSIX 시스템에서 동작하는 방식입니다.

세그먼트 테이블로 주소 바꾸기

세그먼테이션을 쓰는 시스템에서 논리 주소는 세그먼트 번호 ss와 그 세그먼트 안의 오프셋 dd의 쌍입니다. 운영체제는 프로세스마다 세그먼트 테이블(segment table)을 두고, 테이블의 각 항목에 세그먼트의 정보를 적어 둡니다.

항목뜻
베이스(base)세그먼트가 놓인 물리 메모리의 시작 주소
한계(limit)세그먼트의 길이
보호 비트읽기(R), 쓰기(W), 실행(X) 가운데 허락된 것

8장 1편의 베이스와 한계 레지스터가 프로세스 전체에 한 쌍 있었다면, 세그먼테이션은 세그먼트마다 한 쌍씩 있는 셈입니다. 주소를 바꿀 때 MMU는 먼저 번호 ss가 테이블 안에 있는지 확인하고, 테이블에서 ss번 항목을 읽어, 오프셋 dd가 한계보다 작은지 확인합니다. 한계는 길이이므로 유효한 오프셋은 0부터 한계보다 1 작은 값까지이고, 한계와 같은 오프셋은 범위 밖입니다. 그다음 요청한 접근이 보호 비트에서 허락되는지 확인하고, 모두 통과하면 베이스에 오프셋을 더해 물리 주소를 만듭니다. 다음 C 코드는 이 과정의 일부를 나타낸 것입니다.

segment_translation.c (개념을 나타낸 코드)
typedef struct {
    unsigned int base;
    unsigned int limit;
    unsigned char read, write, execute;
} SegmentEntry;

SegmentEntry segment_table[MAX_SEGMENTS];

unsigned int translate(unsigned int seg_num, unsigned int offset, int is_write)
{
    if (seg_num >= MAX_SEGMENTS) {            /* 번호부터 확인한다. */
        raise_trap(SEGFAULT);
        return (unsigned int) -1;
    }
    SegmentEntry *entry = &segment_table[seg_num];

    if (offset >= entry->limit) {             /* 한계와 같아도 범위 밖이다. */
        raise_trap(SEGFAULT);
        return (unsigned int) -1;
    }
    if (is_write && !entry->write) {          /* 쓰기 요청만 권한을 확인한다. */
        raise_trap(PROTECTION_FAULT);
        return (unsigned int) -1;
    }
    return entry->base + offset;
}

아래 단계 개념도는 이 코드가 1번 세그먼트(베이스 0x20000, 한계 0x1000, 쓰기 불가)의 오프셋 0x0FFF를 읽는 요청을 처리하는 과정을 보여 줍니다.

주소 변환 조각이 검사하는 조건

테이블을 읽기 전에 번호를 확인한다. base와 offset을 합한다. 각 단계의 값과 전환 이유를 비교합니다.

주소 변환 · 검사 범위

번호·offset 범위·쓰기 권한을 통과하면 base + offset을 반환합니다

본문의 개념적 의사 코드 순서입니다. limit은 길이이므로 유효한 offset은 0 이상·limit 미만이며, C 객체의 배열 경계와 수명을 모두 아는 검사가 아닙니다.

세그먼트 번호 범위와 쓰기 보호의 검사 분기번호>=MAX이면조회전SEGFAULT. 아니면entry조회뒤offset>=limit이면SEGFAULT. 아니면쓰기이고write권한없을때PROTECTIONFAULT. 세조건이모두거짓이면base+offset반환한다. 모바일왼쪽외곽선은각아니오분기이며오류노드를거쳐다음검사로진행하지않는다. read와execute는원코드가검사하지않는다.번호·offset쓰기 여부번호 ≥MAX ?SEGFAULT조회 전 오류예아니오offset ≥limit ?SEGFAULT범위 오류예아니오is_write&& !write ?PROTECTIONFAULT예아니오유효한 테이블entry 조회base + offset주소 반환예: 오류 후 반환아니오: 다음 검사read·execute 미검사
세그먼트 번호 범위와 쓰기 보호의 검사 분기번호>=MAX이면조회전SEGFAULT. 아니면entry조회뒤offset>=limit이면SEGFAULT. 아니면쓰기이고write권한없을때PROTECTIONFAULT. 세조건이모두거짓이면base+offset반환한다. 모바일왼쪽외곽선은각아니오분기이며오류노드를거쳐다음검사로진행하지않는다. read와execute는원코드가검사하지않는다.번호·offset쓰기 여부번호 ≥MAX ?SEGFAULT조회 전 오류예아니오offset ≥limit ?SEGFAULT범위 오류예아니오is_write&& !write ?PROTECTIONFAULT예아니오유효한 테이블entry 조회base + offset주소 반환예: 오류 후 반환아니오: 다음 검사read·execute 미검사
주소 변환 조각이 검사하는 조건
도달한 검사해당 조건의사 코드 결과
테이블 조회 전seg_num ≥ MAX_SEGMENTS범위 오류 처리
유효한 세그먼트offset ≥ limit범위 오류 처리
범위 안의 쓰기is_write이고 write 권한이 없음보호 오류 처리
앞 검사를 모두 통과base + offset을 계산변환 주소 반환
테이블 조회 전
해당 조건: seg_num ≥ MAX_SEGMENTS
의사 코드 결과: 범위 오류 처리
유효한 세그먼트
해당 조건: offset ≥ limit
의사 코드 결과: 범위 오류 처리
범위 안의 쓰기
해당 조건: is_write이고 write 권한이 없음
의사 코드 결과: 보호 오류 처리
앞 검사를 모두 통과
해당 조건: base + offset을 계산
의사 코드 결과: 변환 주소 반환

이 조각은 read·execute 비트를 선언하지만 검사하지 않습니다. 유효한 테이블 값과 주소 폭 안의 덧셈을 전제하며 전체 MMU 구현은 아닙니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 번호 검사부터 주소 반환까지 진행합니다.
관계도의 보충 설명
전체 도표의 표기·관계·조건
구분전체 도표의 내용
관계 설명번호>=MAX이면 조회 전 SEGFAULT. 아니면 entry 조회 뒤 offset>=limit이면 SEGFAULT. 아니면 쓰기이고 write 권한이 없을 때 PROTECTIONFAULT. 세 조건이 모두 거짓이면 base+offset을 반환한다. 모바일 왼쪽 외곽선은 각 아니요 분기이며 오류 노드를 거쳐 다음 검사로 진행하지 않는다. read와 execute는 원 코드가 검사하지 않는다.
노드·선 표기아니요
노드·선 표기아니요: 다음 검사
표의 값테이블 조회 전. 해당 조건: seg_num ≥ MAX_SEGMENTS. 의사 코드 결과: 범위 오류 처리. 유효한 세그먼트. 해당 조건: offset ≥ limit. 의사 코드 결과: 범위 오류 처리. 범위 안의 쓰기. 해당 조건: is_write이고 write 권한이 없음. 의사 코드 결과: 보호 오류 처리. 앞 검사를 모두 통과. 해당 조건: base + offset을 계산. 의사 코드 결과: 변환 주소 반환.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
번호 검사

테이블을 읽기 전에 번호를 확인한다.

  • 주소·권한 1: 1<MAX4
  • entry 1 조회
범위 검사

limit은 길이이며 같은 값은 범위 밖이다.

  • 주소·권한 1: 0x0fff<0x1000
  • offset 허용
쓰기 조건

현재 요청은 읽기다.

  • 주소·권한 1: is_write=false
  • 주소·권한 2: write0
  • 보호 오류 조건 거짓
주소 반환

base와 offset을 합한다.

  • 주소·권한 1: 0x20000+0x0fff=0x20fff
  • 주소 반환; 선언 read/execute는 검사 안 함

주소 변환 조각이 검사하는 조건

테이블을 읽기 전에 번호를 확인한다. base와 offset을 합한다. 각 단계의 값과 전환 이유를 비교합니다.

주소 변환 · 검사 범위

번호·offset 범위·쓰기 권한을 통과하면 base + offset을 반환합니다

본문의 개념적 의사 코드 순서입니다. limit은 길이이므로 유효한 offset은 0 이상·limit 미만이며, C 객체의 배열 경계와 수명을 모두 아는 검사가 아닙니다.

세그먼트 번호 범위와 쓰기 보호의 검사 분기번호>=MAX이면조회전SEGFAULT. 아니면entry조회뒤offset>=limit이면SEGFAULT. 아니면쓰기이고write권한없을때PROTECTIONFAULT. 세조건이모두거짓이면base+offset반환한다. 모바일왼쪽외곽선은각아니오분기이며오류노드를거쳐다음검사로진행하지않는다. read와execute는원코드가검사하지않는다.번호·offset쓰기 여부번호 ≥MAX ?SEGFAULT조회 전 오류예아니오offset ≥limit ?SEGFAULT범위 오류예아니오is_write&& !write ?PROTECTIONFAULT예아니오유효한 테이블entry 조회base + offset주소 반환예: 오류 후 반환아니오: 다음 검사read·execute 미검사
세그먼트 번호 범위와 쓰기 보호의 검사 분기번호>=MAX이면조회전SEGFAULT. 아니면entry조회뒤offset>=limit이면SEGFAULT. 아니면쓰기이고write권한없을때PROTECTIONFAULT. 세조건이모두거짓이면base+offset반환한다. 모바일왼쪽외곽선은각아니오분기이며오류노드를거쳐다음검사로진행하지않는다. read와execute는원코드가검사하지않는다.번호·offset쓰기 여부번호 ≥MAX ?SEGFAULT조회 전 오류예아니오offset ≥limit ?SEGFAULT범위 오류예아니오is_write&& !write ?PROTECTIONFAULT예아니오유효한 테이블entry 조회base + offset주소 반환예: 오류 후 반환아니오: 다음 검사read·execute 미검사
주소 변환 조각이 검사하는 조건
도달한 검사해당 조건의사 코드 결과
테이블 조회 전seg_num ≥ MAX_SEGMENTS범위 오류 처리
유효한 세그먼트offset ≥ limit범위 오류 처리
범위 안의 쓰기is_write이고 write 권한이 없음보호 오류 처리
앞 검사를 모두 통과base + offset을 계산변환 주소 반환
테이블 조회 전
해당 조건: seg_num ≥ MAX_SEGMENTS
의사 코드 결과: 범위 오류 처리
유효한 세그먼트
해당 조건: offset ≥ limit
의사 코드 결과: 범위 오류 처리
범위 안의 쓰기
해당 조건: is_write이고 write 권한이 없음
의사 코드 결과: 보호 오류 처리
앞 검사를 모두 통과
해당 조건: base + offset을 계산
의사 코드 결과: 변환 주소 반환

이 조각은 read·execute 비트를 선언하지만 검사하지 않습니다. 유효한 테이블 값과 주소 폭 안의 덧셈을 전제하며 전체 MMU 구현은 아닙니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 번호 검사부터 주소 반환까지 진행합니다.

관계도의 보충 설명

전체 도표의 표기·관계·조건
구분전체 도표의 내용
관계 설명번호>=MAX이면 조회 전 SEGFAULT. 아니면 entry 조회 뒤 offset>=limit이면 SEGFAULT. 아니면 쓰기이고 write 권한이 없을 때 PROTECTIONFAULT. 세 조건이 모두 거짓이면 base+offset을 반환한다. 모바일 왼쪽 외곽선은 각 아니요 분기이며 오류 노드를 거쳐 다음 검사로 진행하지 않는다. read와 execute는 원 코드가 검사하지 않는다.
노드·선 표기아니요
노드·선 표기아니요: 다음 검사
표의 값테이블 조회 전. 해당 조건: seg_num ≥ MAX_SEGMENTS. 의사 코드 결과: 범위 오류 처리. 유효한 세그먼트. 해당 조건: offset ≥ limit. 의사 코드 결과: 범위 오류 처리. 범위 안의 쓰기. 해당 조건: is_write이고 write 권한이 없음. 의사 코드 결과: 보호 오류 처리. 앞 검사를 모두 통과. 해당 조건: base + offset을 계산. 의사 코드 결과: 변환 주소 반환.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
번호 검사

테이블을 읽기 전에 번호를 확인한다.

  • 주소·권한 1: 1<MAX4
  • entry 1 조회
범위 검사

limit은 길이이며 같은 값은 범위 밖이다.

  • 주소·권한 1: 0x0fff<0x1000
  • offset 허용
쓰기 조건

현재 요청은 읽기다.

  • 주소·권한 1: is_write=false
  • 주소·권한 2: write0
  • 보호 오류 조건 거짓
주소 반환

base와 offset을 합한다.

  • 주소·권한 1: 0x20000+0x0fff=0x20fff
  • 주소 반환; 선언 read/execute는 검사 안 함

번호 1은 테이블 안에 있으므로 1번 항목을 읽습니다. 오프셋 0x0FFF는 한계 0x1000보다 작으므로 범위를 통과합니다. 읽기 요청이므로 쓰기 권한 검사의 조건은 거짓이 되어 통과하고, 0x20000 + 0x0FFF = 0x20FFF를 돌려줍니다. 같은 세그먼트에서 오프셋 0x1000은 한계와 같아 범위 밖이고, 오프셋 0x0010에 쓰려는 요청은 범위 안이지만 쓰기가 허락되지 않아 보호 오류가 됩니다. 테이블에 없는 번호는 항목을 읽기 전에 거절합니다.

이 코드에는 빠진 것이 있습니다. 항목에 read와 execute 필드가 있지만, 코드는 쓰기 요청일 때만 권한을 확인합니다. 그래서 읽기가 허락되지 않은 세그먼트를 읽거나, 실행이 허락되지 않은 세그먼트의 코드를 실행하려는 요청도 통과합니다. 필드가 있다는 것과 실제로 검사한다는 것은 다릅니다. 다음 Python 모델은 요청한 접근의 종류를 받아 세 권한을 모두 확인하도록 고친 것입니다.

class Fault(Exception):
    pass

SEGMENTS = [                       # (이름, base, limit, 권한)
    ("code",  0x10000, 0x4000, "rx"),
    ("data",  0x20000, 0x1000, "r"),
    ("heap",  0x30000, 0x0080, "rw"),
    ("stack", 0x40000, 0x2000, "rw"),
]

def translate(seg, offset, access):
    if seg >= len(SEGMENTS):                       # 1. 번호부터 확인한다
        raise Fault(f"세그먼트 {seg}번 없음")
    name, base, limit, perms = SEGMENTS[seg]
    if offset >= limit:                            # 2. 한계는 길이이므로 같아도 밖이다
        raise Fault(f"{name} 범위 밖(한계 {limit:#x})")
    if access not in perms:                        # 3. 요청한 접근의 권한을 확인한다
        raise Fault(f"{name}에 '{access}' 권한 없음")
    return base + offset                           # 4. 모두 통과하면 주소를 만든다

requests = [(1, 0x0FFF, "r"), (1, 0x1000, "r"), (1, 0x0010, "w"),
            (2, 0x007F, "w"), (2, 0x0010, "x"), (0, 0x0100, "x"), (4, 0x0000, "r")]
for seg, offset, access in requests:
    try:
        result = f"{translate(seg, offset, access):#07x}"
    except Fault as fault:
        result = f"오류: {fault}"
    print(f"({seg}, {offset:#06x}) {access} -> {result}")
(1, 0x0fff) r -> 0x20fff
(1, 0x1000) r -> 오류: data 범위 밖(한계 0x1000)
(1, 0x0010) w -> 오류: data에 'w' 권한 없음
(2, 0x007f) w -> 0x3007f
(2, 0x0010) x -> 오류: heap에 'x' 권한 없음
(0, 0x0100) x -> 0x10100
(4, 0x0000) r -> 오류: 세그먼트 4번 없음

처음 세 줄은 앞의 C 코드와 같은 결과입니다. 힙의 마지막 바이트인 오프셋 0x7F에 쓰는 것은 허락되지만, 힙에 있는 데이터를 실행하려는 요청은 실행 권한이 없어 거절됩니다. 코드 세그먼트를 실행하는 요청은 통과합니다. 검사의 순서도 의미가 있습니다. 범위 밖이면서 권한도 없는 요청은 범위 검사에서 먼저 거절되므로, 오류의 종류를 보면 어느 검사에서 걸렸는지 알 수 있습니다.

세그먼트마다 다른 권한이 주는 이점

세그먼트마다 권한을 다르게 줄 수 있다는 것은 보안에 큰 도움이 됩니다. 코드에 읽기와 실행만 허락하면, 프로그램의 버그나 공격자가 그 매핑을 통해 코드를 고쳐 쓸 수 없습니다. 데이터와 스택에 읽기와 쓰기만 허락하고 실행을 막으면, 공격자가 버퍼 오버플로우(배열의 크기를 넘어 써서 옆의 메모리를 덮어쓰는 일)로 스택에 기계어를 써 넣더라도 그 코드를 실행할 수 없습니다. 오늘날에는 이 보호를 페이지 단위로 하며, 페이지 테이블 항목의 실행 금지 비트를 x86에서는 NX(no-execute) 비트라고 부르고, Windows는 이 비트로 데이터 페이지의 실행을 막는 기능을 DEP(data execution prevention)라고 부릅니다. 다만 실행을 막는다고 버퍼 오버플로우 자체가 막히는 것은 아닙니다. 넘친 데이터가 복귀 주소를 덮어써 이미 있는 코드로 실행을 돌리는 공격은 여전히 가능하므로, 12장에서 볼 다른 방어와 함께 씁니다.

세그먼테이션 폴트

C 프로그램을 짜다 보면 "Segmentation fault"라는 메시지와 함께 프로그램이 끝나는 일을 겪게 됩니다. 이름은 세그먼테이션에서 왔지만, 오늘날에는 프로세스가 매핑되지 않은 주소에 접근하거나, 허락되지 않은 방식으로 접근해 MMU의 검사에 걸렸을 때를 두루 가리킵니다. 이때 커널은 프로세스에 SIGSEGV 신호를 보내고, 프로세스가 이 신호를 따로 처리하지 않으면 끝납니다. 흔한 원인은 다음과 같습니다.

잘못된 메모리 접근의 예 (모두 정의되지 않은 동작)
#include <stdio.h>
#include <stdlib.h>

void null_deref(void)
{
    int *ptr = NULL;
    *ptr = 42;               /* NULL 포인터에 쓰기 */
}

void use_after_free(void)
{
    int *ptr = malloc(sizeof *ptr);
    *ptr = 100;
    free(ptr);
    printf("%d\n", *ptr);    /* 해제한 메모리 읽기 */
}

void out_of_bounds(void)
{
    int arr[10];
    arr[10000] = 1;          /* 배열 범위를 크게 벗어난 쓰기 */
}

void write_literal(void)
{
    char *str = "Hello";
    str[0] = 'h';            /* 읽기 전용인 문자열 상수에 쓰기 */
}

네 함수는 모두 C에서 정의되지 않은 동작이므로, 호출했을 때 반드시 세그먼테이션 폴트가 난다고 기대해서는 안 됩니다. NULL 포인터에 쓰는 것은 대개 폴트가 납니다. Linux는 vm.mmap_min_addr 설정으로 주소 0 근처의 낮은 주소를 매핑하지 못하게 막아 두므로, 그 주소로의 접근이 MMU에서 걸리기 때문입니다. 문자열 상수에 쓰는 것도 문자열이 읽기 전용 영역에 놓이므로 대개 폴트가 납니다. 반면 해제한 메모리를 읽는 것은, 그 메모리가 아직 프로세스에 매핑되어 있으면 아무 일도 없이 옛 값이나 엉뚱한 값을 읽습니다. 배열 범위를 조금 벗어난 쓰기도 그 주소가 다른 변수의 영역이라면 폴트 없이 그 변수를 덮어씁니다. MMU는 주소가 매핑되어 있는지와 권한만 볼 뿐, C의 배열 범위나 객체의 수명까지는 알지 못하기 때문입니다. 이런 버그는 6장에서 본 ThreadSanitizer와 같은 계열의 AddressSanitizer(-fsanitize=address) 같은 도구로 찾습니다.

더 깊이: 폴트의 종류를 알려 주는 신호 정보

sigaction()에 SA_SIGINFO를 주어 SIGSEGV 처리 함수를 등록해 두면, 커널이 넘겨주는 siginfo_t에서 폴트가 난 까닭을 더 자세히 알 수 있습니다. sigaction(2)에 따르면 si_addr에는 폴트를 일으킨 메모리 주소가 들어가고, si_code는 원인을 나눕니다. SEGV_MAPERR는 그 주소에 매핑된 것이 없다는 뜻이고, SEGV_ACCERR는 매핑은 있지만 권한이 맞지 않는다는 뜻입니다. 앞의 예로 말하면 NULL 포인터에 쓴 경우는 앞쪽, 문자열 상수에 쓴 경우는 뒤쪽에 해당합니다. 이 밖에 Linux 3.19부터 주소 범위 검사의 실패를 뜻하는 SEGV_BNDERR가, 4.6부터 메모리 보호 키(memory protection key)로 막힌 접근을 뜻하는 SEGV_PKUERR가 더해졌습니다.

비슷한 상황에서 SIGSEGV가 아니라 SIGBUS가 오기도 합니다. mmap(2)는 파일을 매핑한 영역 가운데 파일의 끝을 넘어선 페이지에 접근하면 SIGBUS가 전달된다고 적고 있습니다. 주소는 매핑한 범위 안에 있지만 그 페이지에 대응하는 파일 내용이 없기 때문입니다. 그래서 파일을 매핑해 쓰는 프로그램이 신호로 끝났다면, 두 신호 가운데 어느 것이었는지가 원인을 좁히는 단서가 됩니다.

세그먼테이션의 장점과 한계

세그먼테이션의 장점은 프로그래머가 생각하는 단위와 메모리를 관리하는 단위가 일치한다는 것입니다. 코드는 읽기와 실행만, 스택은 실행 금지처럼 단위마다 알맞은 권한을 줄 수 있습니다. 두 프로세스가 같은 프로그램을 실행하면 텍스트 세그먼트를 공유해 메모리를 아낄 수 있고, 이것이 공유 라이브러리의 바탕이 됩니다. 또 힙과 스택은 서로 다른 세그먼트이므로 각자 독립적으로 커지거나 줄어들 수 있습니다.

한계는 세그먼트의 크기가 제각각이라는 데서 옵니다. 세그먼트 하나는 여전히 물리 메모리의 연속된 공간에 놓여야 하므로, 프로세스 전체를 놓을 때보다는 덜하지만 외부 단편화가 그대로 생깁니다. 큰 세그먼트를 위한 연속 공간을 찾기 어려울 수 있고, 물리 메모리보다 큰 세그먼트는 놓을 수조차 없습니다.

x86-64에 남은 세그먼테이션

x86 아키텍처는 역사적으로 세그먼테이션과 페이징을 함께 썼습니다. 논리 주소가 세그먼테이션을 거쳐 선형 주소(linear address)가 되고, 선형 주소가 다시 페이징을 거쳐 물리 주소가 되는 두 단계였습니다. 하지만 64비트 모드에서는 세그먼테이션이 거의 사라졌습니다. CS, DS, ES, SS 세그먼트 레지스터의 베이스는 0으로 취급되고 한계도 검사하지 않으므로, 논리 주소가 그대로 선형 주소가 됩니다. 예외는 FS와 GS로, 이 두 레지스터는 베이스를 가질 수 있어 스레드마다 다른 영역을 가리키는 데 씁니다. Linux의 사용자 프로그램은 FS를 스레드 지역 저장소(TLS)를, 커널은 GS를 CPU마다의 데이터를 가리키는 데 씁니다. 자세한 내용은 Linux 커널의 FS와 GS 문서에 있습니다.

결국 오늘날의 운영체제는 세그먼테이션 대신 페이징으로 주소를 변환하고 보호합니다. 페이징은 메모리를 같은 크기의 작은 페이지로 나누므로 세그먼트를 연속 공간에 놓아야 하는 제약이 없고, 코드, 데이터, 스택 같은 논리적인 구분과 권한은 페이지 단위의 매핑에 담습니다. 페이징은 9장에서 자세히 다룹니다.

정리

  • 세그먼테이션은 프로세스의 주소 공간을 코드, 데이터, 힙, 스택 같은 논리적인 단위로 나누고, 단위마다 크기와 위치와 권한을 따로 관리합니다.
  • 논리 주소는 세그먼트 번호와 오프셋의 쌍이며, MMU는 세그먼트 테이블에서 베이스와 한계, 보호 비트를 읽어 번호, 범위, 권한을 차례로 검사한 뒤 베이스에 오프셋을 더합니다. 한계는 길이이므로 같은 오프셋은 범위 밖입니다.
  • 권한 필드가 있어도 검사하지 않으면 보호가 되지 않습니다. 코드에는 쓰기를, 데이터와 스택에는 실행을 막는 것이 보안의 기본이며, 오늘날에는 페이지의 NX 비트로 합니다.
  • 세그먼테이션 폴트는 매핑되지 않은 주소나 허락되지 않은 접근이 MMU의 검사에 걸린 것입니다. C의 정의되지 않은 동작이 언제나 폴트로 드러나는 것은 아닙니다.
  • 세그먼트의 크기가 제각각이어서 외부 단편화가 남으므로, 오늘날의 운영체제는 페이징을 쓰고, x86-64에서 세그먼트 레지스터는 FS와 GS의 베이스 정도만 쓰입니다.

연습 문제

1. 개념 코드로 세 요청 판단하기

본문의 C 코드에서 MAX_SEGMENTS가 4이고, 2번 항목이 베이스 0x30000, 한계 0x80, 쓰기 허락이라고 합시다. (2, 0x7F)에 쓰기, (2, 0x80)에 쓰기, (4, 0)에서 읽기를 차례로 판단하세요. 2번 항목의 read를 0으로 바꾸면 첫 결과가 달라질까요?

해설 보기

(2, 0x7F) 쓰기는 번호 2가 4보다 작고, 오프셋 0x7F가 한계 0x80보다 작으며, 쓰기가 허락되어 있으므로 0x30000 + 0x7F = 0x3007F를 돌려줍니다. (2, 0x80) 쓰기는 오프셋이 한계와 같아 범위 검사에서 SEGFAULT로 끝나므로 쓰기 권한 검사까지 가지 않습니다. (4, 0) 읽기는 번호 4가 MAX_SEGMENTS와 같아 번호 검사에서 걸리므로, 테이블의 4번 항목을 읽지도 않습니다. read를 0으로 바꾸어도 첫 결과는 같습니다. 이 코드는 쓰기 요청일 때만 권한을 확인하고 read 필드는 전혀 읽지 않기 때문입니다.

2. 폴트가 나지 않는 버그

동료가 "프로그램을 여러 번 실행했는데 세그먼테이션 폴트가 한 번도 나지 않았으니 메모리 버그는 없다"고 말했습니다. 본문의 use_after_free와 out_of_bounds 같은 버그가 폴트 없이 지나갈 수 있는 이유를 설명하고, 이런 버그를 찾는 방법을 제안하세요.

해설 보기

MMU는 접근하는 주소가 프로세스에 매핑되어 있는지와 그 매핑의 권한만 검사합니다. 해제한 메모리는 할당기가 운영체제에 돌려주지 않고 다음 할당을 위해 쥐고 있는 경우가 많아, 여전히 매핑되어 있으므로 읽어도 폴트가 나지 않고 옛 값이나 다른 데이터를 읽게 됩니다. 배열 범위를 벗어난 쓰기도 그 주소가 같은 스택이나 힙의 다른 변수 자리라면 매핑되어 있고 쓰기도 허락되므로, 폴트 없이 다른 변수를 덮어씁니다. 그래서 폴트가 나지 않았다는 것은 버그가 없다는 뜻이 아닙니다. AddressSanitizer(-fsanitize=address)로 빌드하면 할당한 메모리 주변과 해제한 메모리를 따로 표시해 두었다가, 그곳에 접근하는 순간 오류를 보고해 이런 버그를 찾을 수 있습니다. Valgrind의 Memcheck도 비슷한 일을 합니다.

3. 실행 금지와 버퍼 넘침

스택에 실행 금지(NX)를 설정한 시스템에서, 공격자가 지역 배열의 버퍼 오버플로우로 스택에 기계어를 써 넣고 복귀 주소를 그 기계어로 바꾸었습니다. 이 공격은 성공할까요? 실행 금지가 막지 못하는 공격도 하나 설명하세요.

해설 보기

성공하지 못합니다. 함수가 반환하며 스택의 기계어로 실행을 넘기려 하면, 그 페이지에 실행 권한이 없으므로 CPU가 명령을 가져오는 단계에서 폴트를 일으키고, 커널은 프로세스를 끝냅니다. 하지만 실행 금지는 버퍼 오버플로우 자체를 막지 못합니다. 공격자는 스택에 새 코드를 넣는 대신, 복귀 주소를 이미 실행 권한이 있는 라이브러리 함수, 예를 들어 system()의 주소로 바꾸고 그 인자를 함께 조작할 수 있습니다. 이를 리턴 투 libc(return-to-libc) 공격이라고 하며, 이미 있는 코드 조각들을 이어 붙이는 ROP(return-oriented programming)도 같은 원리입니다. 그래서 실행 금지는 ASLR, 스택 카나리(stack canary) 같은 다른 방어와 함께 써야 합니다.

다음 글 스와핑과 동적 메모리에서는 물리 메모리가 모자랄 때 메모리의 내용을 디스크로 내보내는 스와핑과, 프로그램 안에서 메모리를 할당하고 회수하는 방법을 살펴봅니다.