icon

안동민 개발노트

8장 : 메모리 관리 기초

세그먼테이션


연속 할당의 단편화 문제를 해결하는 첫 번째 접근법은 프로그램을 논리적 단위로 나누는 것입니다. 프로그램은 코드, 데이터, 스택, 힙 등 서로 다른 성격의 영역으로 구성됩니다. 각 영역을 별도의 세그먼트(Segment)로 관리하면, 프로세스 전체가 연속될 필요가 없어집니다.

세그먼테이션은 프로그래머의 관점에서 메모리를 바라봅니다. 프로그래머는 main 함수의 코드, 전역 변수 영역, 스택을 따로 생각하지, 주소 0x0000부터 0xFFFF까지의 연속 공간으로 생각하지 않습니다.


세그먼트의 개념

세그먼테이션(Segmentation)은 프로세스의 주소 공간을 의미 있는 논리적 단위로 분할하는 기법입니다. 각 세그먼트는 독립적인 메모리 블록으로, 물리 메모리의 서로 다른 위치에 배치될 수 있습니다.

일반적인 C 프로그램의 세그먼트:

  • 텍스트 세그먼트(Text/Code): 컴파일된 기계어 코드. 읽기 전용, 실행 가능. 여러 프로세스가 공유 가능(같은 프로그램의 인스턴스).
  • 데이터 세그먼트(Data): 초기화된 전역/정적 변수. 프로세스별 독립.
  • BSS 세그먼트: 초기화되지 않은 전역/정적 변수. 0으로 초기화됨. 실행 파일에 실제 데이터를 저장하지 않으므로 파일 크기를 줄임.
  • 힙 세그먼트(Heap): malloc/new로 동적 할당되는 영역. 주소가 위로 자람.
  • 스택 세그먼트(Stack): 함수 호출 프레임, 지역 변수. 주소가 아래로 자람.
segments_example.c
#include <stdio.h>
#include <stdlib.h>

int global_init = 42;          /* Data 세그먼트 */
int global_uninit;             /* BSS 세그먼트 */

void function() {              /* Text 세그먼트 */
    int local_var = 10;        /* Stack 세그먼트 */
    int *dynamic = malloc(64); /* Heap 세그먼트 */

    printf("Text:  %p (function 주소)\n", (void *)function);
    printf("Data:  %p (global_init)\n", (void *)&global_init);
    printf("BSS:   %p (global_uninit)\n", (void *)&global_uninit);
    printf("Heap:  %p (dynamic)\n", (void *)dynamic);
    printf("Stack: %p (local_var)\n", (void *)&local_var);

    free(dynamic);
}

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

실행하면 각 세그먼트의 주소가 서로 떨어져 있는 것을 확인할 수 있습니다. Text와 Data는 낮은 주소, Stack은 높은 주소에, Heap은 그 사이에 위치합니다.

Linux에서 /proc/<pid>/maps로 프로세스의 세그먼트 매핑을 확인할 수 있습니다.

memory_map.py
import subprocess

# 현재 프로세스의 메모리 맵 확인 (Linux)
with open(f"/proc/self/maps") as f:
    for line in f:
        # 주소범위 권한 오프셋 디바이스 inode 경로
        print(line.strip())

세그먼트 테이블

논리 주소는 (세그먼트 번호 s, 오프셋 d) 쌍으로 구성됩니다. 세그먼트 번호로 세그먼트 테이블(Segment Table)을 조회하면 해당 세그먼트의 물리적 정보를 얻습니다.

세그먼트 테이블 엔트리

각 엔트리에는 다음 정보가 포함됩니다.

  • 베이스(Base): 세그먼트의 시작 물리 주소
  • 한계(Limit): 세그먼트의 크기
  • 보호 비트(Protection Bits): 읽기(R), 쓰기(W), 실행(X) 권한

주소 변환 과정

  1. 논리 주소에서 세그먼트 번호 s와 오프셋 d를 추출합니다.
  2. 세그먼트 테이블에서 엔트리 s를 조회합니다.
  3. 오프셋 d가 한계(Limit)보다 크면 → 세그먼테이션 폴트 발생.
  4. 보호 비트를 확인합니다. 쓰기 시도인데 W 비트가 없으면 → 보호 위반.
  5. 물리 주소 = 베이스(Base) + 오프셋(d).
segment_translation.c
/* 개념적 의사 코드 */
typedef struct {
    unsigned int base;
    unsigned int limit;
    unsigned char read;
    unsigned char write;
    unsigned char execute;
} SegmentEntry;

SegmentEntry segment_table[MAX_SEGMENTS];

unsigned int translate(unsigned int seg_num, unsigned int offset,
                       int is_write) {
    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;
}

세그먼트별 보호의 장점

세그먼트별로 권한을 다르게 설정할 수 있는 것은 큰 보안 이점입니다.

  • 코드 세그먼트: R+X (읽기+실행). 쓰기 불가. 악성 코드가 프로그램 자체를 수정하는 것을 방지합니다.
  • 데이터 세그먼트: R+W (읽기+쓰기). 실행 불가. 데이터 영역에 쉘코드를 삽입해도 실행할 수 없습니다.
  • 스택: R+W. NX(No eXecute) 비트로 실행을 금지합니다. 스택 기반 버퍼 오버플로우 공격을 방어합니다.

이 보호 비트가 바로 현대 CPU의 DEP(Data Execution Prevention) / NX bit의 기원입니다.


세그먼테이션 폴트

개발자라면 한 번쯤 만나봤을 Segmentation Fault입니다. 이 오류는 프로세스가 자신에게 할당되지 않은 메모리 영역에 접근하거나, 권한이 없는 접근을 시도할 때 발생합니다.

segfault_examples.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/* 원인 1: NULL 포인터 역참조 */
void null_deref() {
    int *ptr = NULL;
    *ptr = 42;  /* 주소 0번지 접근 → SIGSEGV */
}

/* 원인 2: 해제된 메모리 접근 (Use-After-Free) */
void use_after_free() {
    int *ptr = malloc(sizeof(int));
    *ptr = 100;
    free(ptr);
    printf("%d\n", *ptr);  /* 운이 좋으면 동작, 나쁘면 SIGSEGV */
}

/* 원인 3: 배열 범위 초과 */
void buffer_overflow() {
    int arr[10];
    arr[10000] = 1;  /* 스택 세그먼트 범위 초과 → SIGSEGV */
}

/* 원인 4: 읽기 전용 메모리 쓰기 */
void readonly_write() {
    char *str = "Hello";  /* 문자열 리터럴은 Text 세그먼트(읽기 전용) */
    str[0] = 'h';  /* 쓰기 시도 → SIGSEGV */
}

NULL 포인터를 역참조하면 주소 0번지에 접근합니다. 대부분의 OS에서 주소 공간의 가장 앞 부분(보통 첫 4KB)은 의도적으로 매핑하지 않습니다. NULL 포인터 역참조를 확실히 잡기 위해서입니다.

MMU가 이 접근을 감지하면 트랩을 발생시키고, OS가 프로세스에 SIGSEGV 시그널을 보냅니다. 프로세스가 이 시그널을 처리하지 않으면 종료됩니다.


세그먼테이션의 장단점

장점

  • 프로그래머 관점 일치: 코드, 데이터, 스택이라는 논리적 단위와 일치합니다.
  • 세그먼트별 보호: 코드는 읽기 전용, 스택은 실행 불가 등 세밀한 권한 설정이 가능합니다.
  • 세그먼트 공유: 두 프로세스가 같은 텍스트 세그먼트를 공유하면 메모리를 절약합니다. 공유 라이브러리의 기반입니다.
  • 세그먼트별 독립 증가: 힙과 스택이 독립적으로 크기가 변할 수 있습니다.

단점

  • 외부 단편화: 세그먼트 크기가 가변적이므로 여전히 외부 단편화가 발생합니다. 큰 세그먼트를 위한 연속 공간을 찾기 어려울 수 있습니다.
  • 크기 제한: 세그먼트는 연속된 물리 메모리여야 하므로, 물리 메모리보다 큰 세그먼트는 불가능합니다.

세그먼테이션 + 페이징

x86 아키텍처는 세그먼테이션과 페이징을 함께 사용합니다. 논리 주소가 세그먼테이션을 거쳐 선형 주소(Linear Address)가 되고, 선형 주소가 페이징을 거쳐 물리 주소가 됩니다.

하지만 현대 x86-64에서는 세그먼테이션이 사실상 비활성화되어 있습니다. 모든 세그먼트의 베이스를 0, 한계를 최대로 설정하여 평탄 모델(Flat Model)을 사용합니다. 실질적으로 페이징만으로 주소 변환을 수행합니다.

외부 단편화를 근본적으로 해결하는 것이 바로 페이징입니다. 페이징은 메모리를 고정 크기로 나누어 외부 단편화를 완전히 제거합니다. 다음 절에서 스와핑과 동적 메모리를 다루고, 9장에서 페이징을 본격적으로 살펴보겠습니다.

목차