세그먼테이션
코드·데이터·힙·스택을 논리적 세그먼트로 분리하고 세그먼트 테이블의 주소 변환과 보호 검사를 추적합니다.
프로세스 전체를 연속 배치해야 하는 제약을 줄이는 접근법은 프로그램을 논리적 단위로 나누는 것입니다.
프로그램은 코드, 데이터, 스택, 힙 등 서로 다른 성격의 영역으로 구성됩니다.
각 영역을 별도의 세그먼트(Segment)로 관리하면, 프로세스 전체가 연속될 필요가 없어집니다.
세그먼테이션은 프로그래머의 관점에서 메모리를 바라봅니다.
프로그래머는 main 함수의 코드, 전역 변수 영역, 스택을 따로 생각하지, 주소 0x0000부터 0xFFFF까지의 연속 공간으로 생각하지 않습니다.
세그먼트의 개념
세그먼테이션(Segmentation)은 프로세스의 주소 공간을 의미 있는 논리적 단위로 분할하는 기법입니다.
각 세그먼트는 독립적인 메모리 블록으로, 물리 메모리의 서로 다른 위치에 배치될 수 있습니다.
일반적인 C 실행 환경에서 구분하는 영역은 다음과 같습니다. 이는 코드·데이터의 용도 구분이며, 현대 OS가 각 영역을 하드웨어 세그먼트 하나로 구현한다는 뜻은 아닙니다.
- 텍스트 세그먼트(Text/Code): 컴파일된 기계어 코드. 읽기 전용, 실행 가능. 여러 프로세스가 공유 가능(같은 프로그램의 인스턴스).
- 데이터 세그먼트(Data): 초기화된 전역/정적 변수. 프로세스별 독립.
- BSS 세그먼트: 초기화되지 않은 전역/정적 변수. 0으로 초기화됨. 실행 파일에 실제 데이터를 저장하지 않으므로 파일 크기를 줄임.
- 힙 세그먼트(Heap):
malloc/new로 동적 할당되는 영역. 전통적인 배치 예에서는 높은 주소 쪽으로 증가하지만mmap할당 등은 별도 매핑을 사용할 수 있음. - 스택 세그먼트(Stack): 함수 호출 프레임, 지역 변수. 많은 환경에서 낮은 주소 쪽으로 증가하지만 방향은 ABI에 따름.
#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;
}이 예제는 객체·함수의 주소를 출력합니다. 배치 순서와 간격은 컴파일·링크·ASLR·할당 설정에 따라 달라지며, 실제 실행값을 고정하지 않습니다. 함수 포인터를 void *로 바꾸는 출력 방식도 모든 ISO C 구현에 이식 가능한 표현은 아닙니다.
Linux의 /proc/<pid>/maps는 가상 메모리 매핑을 보여 줍니다. 아래 내용은 하드웨어 세그먼트 테이블을 덤프하는 코드가 아닙니다.
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) 권한
주소 변환 과정
이 학습 모형에서 limit은 길이이므로 유효한 오프셋은 0 ≤ offset < limit입니다. 테이블을 읽기 전에 세그먼트 번호도 검사합니다.
/* 개념적 의사 코드 */
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) {
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;
}세그먼트 번호, 배타적 오프셋 상한, 쓰기 권한을 순서대로 확인한다.
| 도달한 검사 | 해당 조건 | 의사 코드 결과 |
|---|---|---|
| 테이블 조회 전 | 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 구현은 아닙니다.
세그먼트별 보호의 장점
세그먼트별로 권한을 다르게 설정할 수 있는 것은 큰 보안 이점입니다.
- 코드 세그먼트: R+X (읽기+실행). 쓰기 불가. 그 매핑을 통한 코드 쓰기를 막습니다.
- 데이터 세그먼트: R+W (읽기+쓰기). 실행 불가. 그 매핑의 데이터를 명령으로 실행하지 못하게 합니다.
- 스택: R+W. NX(No eXecute) 비트로 실행을 금지합니다. 스택에 삽입한 코드의 실행을 막지만 버퍼 범위를 넘는 쓰기 자체를 막는 것은 아닙니다.
현대의 페이지 기반 보호도 읽기·쓰기·실행 권한을 구분합니다. DEP(Data Execution Prevention)와 NX는 실행 금지 보호와 관련됩니다.
세그먼테이션 폴트
개발자라면 한 번쯤 만나봤을 Segmentation Fault입니다.
이 오류는 프로세스가 자신에게 할당되지 않은 메모리 영역에 접근하거나, 권한이 없는 접근을 시도할 때 발생합니다.
다음 함수들은 잘못된 C 메모리 접근의 예입니다. 호출하면 모두 정의되지 않은 동작이며, 특정 출력이나 SIGSEGV 발생을 보장하는 테스트가 아닙니다.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* 원인 1: NULL 포인터 역참조 */
void null_deref() {
int *ptr = NULL;
*ptr = 42; /* NULL 역참조: 정의되지 않은 동작 */
}
/* 원인 2: 해제된 메모리 접근 (Use-After-Free) */
void use_after_free() {
int *ptr = malloc(sizeof(int));
*ptr = 100;
free(ptr);
printf("%d\n", *ptr); /* 해제 뒤 접근: 정의되지 않은 동작 */
}
/* 원인 3: 배열 범위 초과 */
void buffer_overflow() {
int arr[10];
arr[10000] = 1; /* 배열 범위 초과: 정의되지 않은 동작 */
}
/* 원인 4: 읽기 전용 메모리 쓰기 */
void readonly_write() {
char *str = "Hello"; /* 문자열 리터럴: 수정하면 안 됨 */
str[0] = 'h'; /* 문자열 리터럴 수정: 정의되지 않은 동작 */
}Linux 등에서는 낮은 주소 매핑을 제한해 흔한 NULL 역참조를 발견하도록 돕습니다. 제한 범위는 설정에 따라 달라집니다. 접근이 MMU의 매핑·보호 검사를 위반하면 OS가 SIGSEGV로 처리할 수 있지만, C 배열 경계나 해제된 객체의 수명까지 MMU가 모두 아는 것은 아닙니다.
세그먼테이션의 장단점
장점
- 프로그래머 관점 일치: 코드, 데이터, 스택이라는 논리적 단위와 일치합니다.
- 세그먼트별 보호: 코드는 읽기 전용, 스택은 실행 불가 등 세밀한 권한 설정이 가능합니다.
- 세그먼트 공유: 두 프로세스가 같은 텍스트 세그먼트를 공유하면 메모리를 절약합니다. 공유 라이브러리의 기반입니다.
- 세그먼트별 독립 증가: 힙과 스택이 독립적으로 크기가 변할 수 있습니다.
단점
- 외부 단편화: 세그먼트 크기가 가변적이므로 여전히 외부 단편화가 발생합니다. 큰 세그먼트를 위한 연속 공간을 찾기 어려울 수 있습니다.
- 크기 제한: 세그먼트는 연속된 물리 메모리여야 하므로, 물리 메모리보다 큰 세그먼트는 불가능합니다.
세그먼테이션 + 페이징
x86 아키텍처는 세그먼테이션과 페이징을 함께 사용합니다.
논리 주소가 세그먼테이션을 거쳐 선형 주소(Linear Address)가 되고, 선형 주소가 페이징을 거쳐 물리 주소가 됩니다.
x86-64의 64비트 모드에서는 CS·DS·ES·SS의 주소 베이스 효과가 0으로 취급됩니다. 다만 FS와 GS는 계속 사용되며 TLS 같은 용도로 별도 베이스를 가질 수 있습니다.
페이징은 프로세스 전체나 가변 크기 세그먼트를 하나의 연속 물리 블록에 담는 제약을 줄입니다. 큰 연속 물리 블록을 따로 요구하는 경우의 단편화까지 없애는 것은 아닙니다.
다음 절에서 스와핑과 동적 메모리를 다루고, 9장에서 페이징을 본격적으로 살펴보겠습니다.