프로세스 관리 (Process Management)
Linux 커널의 프로세스 관리 전반을 다룹니다. task_struct 구조체부터 스케줄러(CFS, EEVDF), fork/exec 메커니즘, Copy-on-Write, 컨텍스트 스위칭, 커널 스레드까지 심층적으로 분석합니다.
task_struct는 요리사의 신상 카드(이름, 상태, 할당된 주방, 사용 중인 도구 목록)이고,
fork()는 요리사를 복제하는 것(같은 레시피, 같은 재료를 공유),
exec()는 복제된 요리사에게 새 레시피를 건네주는 것입니다.
컨텍스트 스위치는 주방(CPU)에서 요리사를 교체하는 과정입니다.
핵심 요약
- task_struct — 커널이 프로세스/스레드를 관리하는 핵심 자료구조. 상태, PID, 메모리, 파일 정보를 모두 담습니다.
- fork() — 현재 프로세스를 복제하여 자식 프로세스를 생성합니다. Copy-on-Write로 효율적입니다.
- exec() — 프로세스의 메모리 이미지를 새 프로그램(ELF)으로 교체합니다.
- 컨텍스트 스위치 — CPU 레지스터를 저장/복원하여 다른 프로세스로 전환하는 과정입니다.
- 커널 스레드 — 사용자 공간 없이 커널 내에서만 동작하는 스레드입니다 (kworker, kswapd 등).
단계별 이해
- task_struct 파악 —
ps aux로 보이는 모든 프로세스는 커널 내부에서task_struct하나로 표현됩니다./proc/<pid>/status에서 프로세스의 상태, 메모리, 스레드 정보를 확인할 수 있습니다. - fork 이해 —
fork()는 부모의 페이지 테이블을 복사하되, 실제 메모리는 Write 시에만 복사합니다(CoW).이 덕분에 fork는 매우 빠르고, 자식이 즉시
exec()하면 복사가 거의 발생하지 않습니다. - exec 이해 —
execve()는 ELF 파일을 파싱하여 코드/데이터 세그먼트를 새로 매핑합니다.기존 메모리 매핑은 모두 해제되고 새 프로그램의 진입점(
_start)부터 실행됩니다. - 프로세스 계층 — 모든 프로세스는 PID 1(init/systemd)을 루트로 하는 트리 구조를 형성합니다.
pstree명령어로 프로세스 트리를 시각적으로 확인할 수 있습니다.
task_struct 구조체 (Task Descriptor)
Linux 커널에서 프로세스(또는 스레드)를 표현하는 핵심 자료구조는 struct task_struct입니다. 이 구조체는 include/linux/sched.h에 정의되어 있으며, 커널이 프로세스를 관리하는 데 필요한 모든 정보를 담고 있습니다. 크기가 수 킬로바이트에 달하는 대형 구조체로, 프로세스의 상태, 스케줄링 정보, 메모리 디스크립터, 파일 디스크립터 테이블, 시그널 핸들러, credentials 등을 포함합니다.
핵심 필드 분석 (Key Fields)
다음은 task_struct의 주요 필드들을 기능별로 분류한 것입니다.
| 필드 | 타입 | 설명 |
|---|---|---|
state / __state |
unsigned int |
프로세스의 현재 상태 (TASK_RUNNING, TASK_INTERRUPTIBLE 등) |
flags |
unsigned int |
프로세스 플래그 (PF_EXITING, PF_KTHREAD 등) |
prio / static_prio / normal_prio |
int |
스케줄링 우선순위 (동적/정적/정규 우선순위) |
sched_class |
const struct sched_class * |
해당 태스크가 사용하는 스케줄링 클래스 |
se |
struct sched_entity |
CFS 스케줄링 엔티티 (vruntime 등 포함) |
mm |
struct mm_struct * |
메모리 디스크립터 (유저 프로세스의 주소 공간) |
active_mm |
struct mm_struct * |
실제 사용 중인 mm (커널 스레드는 이전 태스크의 mm을 빌림) |
pid / tgid |
pid_t |
프로세스 ID / 스레드 그룹 ID |
real_parent / parent |
struct task_struct * |
실제 부모 프로세스 / ptrace 부모 |
children / sibling |
struct list_head |
자식 프로세스 리스트 / 형제 프로세스 리스트 |
files |
struct files_struct * |
열린 파일 디스크립터 테이블 |
fs |
struct fs_struct * |
파일시스템 정보 (root, pwd) |
signal |
struct signal_struct * |
시그널 핸들러 정보 (스레드 그룹 공유) |
nsproxy |
struct nsproxy * |
네임스페이스 프록시 (PID/mount/net/user NS 등) |
thread_info |
struct thread_info |
아키텍처 의존적 스레드 정보 (커널 스택과 연결) |
cred |
const struct cred * |
프로세스 자격 증명 (UID, GID, capabilities) |
task_struct 할당 (Allocation)
task_struct는 slab allocator를 통해 할당됩니다. 커널 초기화 시 task_struct 전용 kmem_cache가 생성되며, 새로운 프로세스 생성 시 alloc_task_struct_node()를 통해 할당합니다. 커널 스택은 별도로 할당되며, x86_64의 경우 기본 16KB(4페이지)입니다. thread_info 구조체는 최근 커널에서 task_struct 내부에 임베딩되어 있습니다.
/* task_struct 할당 - kernel/fork.c */
static struct kmem_cache *task_struct_cachep;
void __init fork_init(void)
{
task_struct_cachep = kmem_cache_create("task_struct",
arch_task_struct_size, align,
SLAB_PANIC|SLAB_ACCOUNT, NULL);
}
/* 현재 프로세스의 task_struct에 접근 */
struct task_struct *current = get_current();
printk(KERN_INFO "PID: %d, comm: %s\n",
current->pid, current->comm);
current 매크로: current는 현재 CPU에서 실행 중인 프로세스의 task_struct 포인터를 반환하는 매크로입니다. x86_64에서는 per-CPU 변수인 current_task를 통해 접근하며, GS 세그먼트 레지스터를 활용합니다.
프로세스 상태 (Process States)
Linux 커널의 프로세스는 다음과 같은 상태를 가질 수 있으며, task_struct.__state 필드에 비트마스크로 저장됩니다.
| 상태 | 값 | 설명 |
|---|---|---|
TASK_RUNNING |
0x00000000 |
실행 중이거나 런큐에서 실행 대기 중. CPU를 할당받을 수 있는 유일한 상태 |
TASK_INTERRUPTIBLE |
0x00000001 |
슬립 상태. 시그널 또는 대기 조건 충족 시 깨어남 |
TASK_UNINTERRUPTIBLE |
0x00000002 |
시그널로 깨울 수 없는 슬립 상태. 디스크 I/O 대기 시 주로 사용 (D state) |
__TASK_STOPPED |
0x00000004 |
SIGSTOP/SIGTSTP 등에 의해 중지된 상태 |
__TASK_TRACED |
0x00000008 |
ptrace에 의해 트레이싱 중인 상태 |
EXIT_DEAD |
0x00000010 |
최종 종료 상태. task_struct가 곧 해제됨 |
EXIT_ZOMBIE |
0x00000020 |
종료되었으나 부모가 wait()을 호출하지 않은 상태 |
TASK_KILLABLE |
TASK_WAKEKILL | TASK_UNINTERRUPTIBLE |
치명적 시그널(SIGKILL)에만 반응하는 슬립 상태 |
상태 전이 다이어그램 (State Transition Diagram)
D state 주의: TASK_UNINTERRUPTIBLE(D state)인 프로세스는 kill -9로도 종료시킬 수 없습니다. 이 상태가 장기간 지속되면 시스템 문제를 의심해야 합니다. 커널 5.14 이후에는 TASK_KILLABLE을 적극 활용하여 이 문제를 완화하고 있습니다.
프로세스 생성 (Process Creation)
Linux에서 새로운 프로세스는 항상 기존 프로세스를 복제하여 생성됩니다. 최초의 프로세스(PID 0, swapper/idle)만 커널 부팅 시 정적으로 생성되고, 이후 PID 1(init/systemd) 등은 모두 fork 계열 시스템 콜을 통해 생성됩니다.
fork() 시스템 콜
fork()는 호출한 프로세스의 거의 완전한 복사본을 생성합니다. 커널 내부에서는 kernel_clone() 함수(과거 do_fork())를 통해 처리됩니다. 주요 처리 과정은 다음과 같습니다:
- task_struct 할당:
dup_task_struct()로 새로운task_struct와 커널 스택을 할당합니다. - 리소스 복제:
copy_files(),copy_fs(),copy_sighand(),copy_signal(),copy_mm(),copy_namespaces()등을 호출하여 각 리소스를 복제합니다. - 스케줄러 초기화:
sched_fork()로 새 태스크의 스케줄링 정보를 초기화합니다. - PID 할당:
alloc_pid()로 새 PID를 할당합니다. - 런큐 추가:
wake_up_new_task()로 새 프로세스를 런큐에 넣습니다.
/* kernel/fork.c - kernel_clone() 핵심 흐름 (간략화) */
pid_t kernel_clone(struct kernel_clone_args *args)
{
struct task_struct *p;
pid_t nr;
/* 1. task_struct + 커널 스택 복제 */
p = copy_process(NULL, 0, args);
if (IS_ERR(p))
return PTR_ERR(p);
/* 2. PID 가져오기 */
nr = pid_vnr(get_task_pid(p, PIDTYPE_PID));
/* 3. 새 프로세스를 런큐에 넣고 실행 가능하게 만듦 */
wake_up_new_task(p);
return nr;
}
vfork() / clone() / clone3()
Linux는 프로세스 생성을 위해 여러 시스템 콜을 제공하며, 내부적으로는 모두 kernel_clone()을 호출합니다.
| 시스템 콜 | 특징 | 주요 용도 |
|---|---|---|
fork() |
부모 프로세스의 완전한 복제. COW 사용 | 일반적인 프로세스 생성 |
vfork() |
부모가 자식이 exec/exit 할 때까지 블록됨. 주소 공간 공유 | exec 직전 사용 (성능 최적화, 현재는 거의 불필요) |
clone() |
플래그로 공유 리소스를 세밀하게 제어 | 스레드 생성 (pthread_create 내부) |
clone3() |
확장 가능한 구조체 기반 인터페이스. 커널 5.3+ | 새로운 기능 (cgroup, PID fd 등) 지원 |
/* clone() 호출 시 주요 플래그 */
#define CLONE_VM 0x00000100 /* 주소 공간 공유 */
#define CLONE_FS 0x00000200 /* 파일시스템 정보 공유 */
#define CLONE_FILES 0x00000400 /* 파일 디스크립터 테이블 공유 */
#define CLONE_SIGHAND 0x00000800 /* 시그널 핸들러 공유 */
#define CLONE_THREAD 0x00010000 /* 같은 스레드 그룹 */
#define CLONE_NEWNS 0x00020000 /* 새 마운트 네임스페이스 */
#define CLONE_NEWPID 0x20000000 /* 새 PID 네임스페이스 */
/* 스레드 생성 시 사용되는 플래그 조합 (glibc pthread_create) */
const int thread_flags = CLONE_VM | CLONE_FS | CLONE_FILES |
CLONE_SIGHAND | CLONE_THREAD |
CLONE_SYSVSEM | CLONE_SETTLS |
CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID;
Copy-on-Write (COW) 메커니즘
Copy-on-Write는 fork()의 효율성을 크게 높이는 핵심 메커니즘입니다. fork() 시점에 부모와 자식은 같은 물리 페이지를 공유하되, 페이지 테이블 엔트리를 읽기 전용으로 표시합니다. 이후 둘 중 하나가 해당 페이지에 쓰기를 시도하면 페이지 폴트(page fault)가 발생하고, 커널이 해당 페이지를 복사한 뒤 쓰기를 허용합니다.
COW의 핵심 처리 흐름:
fork()시copy_mm()->dup_mmap()에서 부모의 페이지 테이블을 복제합니다.copy_present_pte()에서 쓰기 가능 페이지를 읽기 전용으로 변경하고, 참조 카운트를 증가시킵니다.- 쓰기 시 페이지 폴트 ->
do_wp_page()에서 COW 처리를 수행합니다. - 참조 카운트가 1이면 복사 없이 쓰기 권한만 복원하고, 2 이상이면 새 페이지를 할당하여 복사합니다.
COW의 장점: 대부분의 fork() 호출은 직후에 exec()를 수행하여 주소 공간을 완전히 교체합니다. COW 덕분에 이런 fork-exec 패턴에서 불필요한 메모리 복사가 발생하지 않아 성능이 크게 향상됩니다.
Android 프로세스 모델
Android는 fork()의 COW 메커니즘을 최대한 활용하는 Zygote 패턴을 사용한다. Zygote 프로세스가 ART(Android Runtime)와 공통 프레임워크 클래스를 미리 로드한 뒤, 앱 시작 요청 시 fork()로 빠르게 자식 프로세스를 생성한다.
/* Zygote fork 흐름 (커널 관점) */
/* Zygote: ART + 프레임워크 클래스 미리 로드된 상태 */
pid = fork(); /* copy_process() → COW 페이지 테이블 복사 */
if (pid == 0) {
/* 자식: 앱 프로세스 */
setuid(app_uid); /* 앱별 고유 UID (앱 샌드박스) */
prctl(PR_SET_SECCOMP); /* seccomp 필터 */
selinux_android_setcontext(...); /* SELinux 컨텍스트 전환 */
/* exec() 없이 ART에서 바로 앱 코드 실행 → COW 페이지 공유 유지 */
}
일반적인 fork-exec 패턴과 달리 Zygote는 exec()를 호출하지 않으므로, 부모와 공유하는 COW 페이지(ART, 프레임워크 코드)가 장기간 유지되어 메모리 절약 효과가 크다. Android 10+에서는 USAP(Unspecialized App Process) 풀을 미리 fork하여 cold start 시간을 더욱 단축한다. 자세한 내용은 Android 커널 — Zygote를 참고하라.
exec() 계열과 ELF 로딩 (exec and ELF Loading)
exec() 계열 시스템 콜은 현재 프로세스의 주소 공간을 새로운 프로그램으로 대체합니다. 커널 내부에서는 do_execveat_common()을 통해 처리됩니다. Linux에서 사용자 공간 프로그램의 표준 실행 파일 형식은 ELF(Executable and Linkable Format)이며, 커널은 fs/binfmt_elf.c에서 이를 처리합니다.
ELF 포맷 개요 (ELF Format Overview)
ELF는 System V ABI에서 정의한 바이너리 형식으로, 실행 파일, 공유 라이브러리, 오브젝트 파일, 코어 덤프를 모두 표현합니다. ELF 파일은 크게 세 부분으로 구성됩니다:
ELF 파일의 유형은 e_type 필드로 구분됩니다:
| 타입 | 값 | 설명 | 용도 |
|---|---|---|---|
ET_REL | 1 | 재배치 가능 파일 (.o) | 링커 입력, 커널 모듈(.ko) |
ET_EXEC | 2 | 실행 파일 (고정 주소) | 비-PIE 바이너리, 정적 링크 |
ET_DYN | 3 | 공유 오브젝트 | 공유 라이브러리(.so), PIE 실행 파일 |
ET_CORE | 4 | 코어 덤프 | 프로세스 크래시 분석 |
PIE와 ET_DYN: 현대 배포판에서 기본 컴파일 옵션(-fPIE -pie)으로 빌드된 실행 파일은 ET_DYN 타입입니다. 커널은 load_elf_binary()에서 ET_DYN이면서 인터프리터가 있으면 ASLR 적용 대상으로 판단하여 랜덤 로드 주소를 배정합니다. ET_EXEC 바이너리는 ELF 헤더에 지정된 고정 가상 주소에 로드됩니다.
ELF 헤더 (ELF Header)
모든 ELF 파일은 파일 오프셋 0에서 시작하는 ELF 헤더를 가집니다. 이 헤더는 파일의 전체적인 속성과 나머지 헤더 테이블의 위치를 지정합니다.
/* include/uapi/linux/elf.h */
typedef struct elf64_hdr {
unsigned char e_ident[EI_NIDENT]; /* ELF 식별자 (16바이트) */
Elf64_Half e_type; /* 파일 타입: ET_EXEC, ET_DYN 등 */
Elf64_Half e_machine; /* 아키텍처: EM_X86_64, EM_AARCH64 등 */
Elf64_Word e_version; /* ELF 버전 (항상 EV_CURRENT = 1) */
Elf64_Addr e_entry; /* 엔트리 포인트 가상 주소 */
Elf64_Off e_phoff; /* 프로그램 헤더 테이블의 파일 오프셋 */
Elf64_Off e_shoff; /* 섹션 헤더 테이블의 파일 오프셋 */
Elf64_Word e_flags; /* 프로세서별 플래그 */
Elf64_Half e_ehsize; /* ELF 헤더 크기 (64바이트) */
Elf64_Half e_phentsize; /* 프로그램 헤더 엔트리 크기 */
Elf64_Half e_phnum; /* 프로그램 헤더 엔트리 수 */
Elf64_Half e_shentsize; /* 섹션 헤더 엔트리 크기 */
Elf64_Half e_shnum; /* 섹션 헤더 엔트리 수 */
Elf64_Half e_shstrndx; /* 섹션 이름 문자열 테이블 인덱스 */
} Elf64_Ehdr;
e_ident 배열(16바이트)은 ELF 파일을 식별하는 매직 넘버와 기본 속성을 담고 있습니다:
| 인덱스 | 이름 | 값 | 설명 |
|---|---|---|---|
| 0~3 | EI_MAG0~3 | 0x7f 'E' 'L' 'F' | ELF 매직 넘버 |
| 4 | EI_CLASS | 1=32bit, 2=64bit | 주소 크기 클래스 |
| 5 | EI_DATA | 1=LE, 2=BE | 바이트 순서(엔디안) |
| 6 | EI_VERSION | 1 (EV_CURRENT) | ELF 규격 버전 |
| 7 | EI_OSABI | 0=ELFOSABI_NONE | OS/ABI 식별 (Linux는 보통 0) |
| 8~15 | EI_ABIVERSION~ | 0 | 패딩 (예약) |
# readelf로 ELF 헤더 확인
$ readelf -h /bin/ls
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
Type: DYN (Position-Independent Executable file)
Machine: Advanced Micro Devices X86-64
Entry point address: 0x6b10
Start of program headers: 64 (bytes into file)
Start of section headers: 140224 (bytes into file)
Number of program headers: 13
Number of section headers: 31
프로그램 헤더 — 세그먼트 (Program Headers)
프로그램 헤더 테이블은 커널이 프로세스 이미지를 생성할 때 사용하는 세그먼트(segment) 정보를 담고 있습니다. 커널의 load_elf_binary()는 이 테이블을 순회하면서 각 세그먼트를 처리합니다.
/* include/uapi/linux/elf.h */
typedef struct elf64_phdr {
Elf64_Word p_type; /* 세그먼트 타입 */
Elf64_Word p_flags; /* 세그먼트 권한: PF_R|PF_W|PF_X */
Elf64_Off p_offset; /* 파일 내 세그먼트 시작 오프셋 */
Elf64_Addr p_vaddr; /* 메모리에 매핑될 가상 주소 */
Elf64_Addr p_paddr; /* 물리 주소 (사용되지 않음) */
Elf64_Xword p_filesz; /* 파일 내 세그먼트 크기 */
Elf64_Xword p_memsz; /* 메모리 내 세그먼트 크기 (≥ p_filesz) */
Elf64_Xword p_align; /* 정렬 요구사항 */
} Elf64_Phdr;
커널이 처리하는 주요 세그먼트 타입:
| 타입 | 값 | 커널 처리 | 설명 |
|---|---|---|---|
PT_LOAD | 1 | elf_map()으로 mmap | 메모리에 매핑되는 로드 가능 세그먼트. 코드(.text, R-X)와 데이터(.data/.bss, RW-) 영역 |
PT_INTERP | 3 | 인터프리터 경로 추출 | 동적 링커 경로 (예: /lib64/ld-linux-x86-64.so.2) |
PT_NOTE | 4 | ABI 태그 검증 | 빌드 ID, GNU ABI 태그 등 메타데이터 |
PT_DYNAMIC | 2 | 동적 링커가 사용 | 동적 링킹 정보 (.dynamic 섹션) |
PT_PHDR | 6 | 프로그램 헤더 자체 | 프로그램 헤더 테이블의 위치와 크기 |
PT_GNU_STACK | 0x6474e551 | 스택 실행 권한 결정 | PF_X 없으면 NX 스택 (기본). 커널이 스택 VMA 권한 설정에 사용 |
PT_GNU_RELRO | 0x6474e552 | 동적 링커가 처리 | 재배치 후 읽기 전용으로 전환될 영역 (.got, .init_array 등) |
p_memsz > p_filesz: .bss 세그먼트(초기화되지 않은 전역 변수)는 파일에 데이터를 저장하지 않으므로 p_filesz가 p_memsz보다 작습니다. 커널은 p_filesz만큼 파일에서 매핑하고, 나머지(p_memsz - p_filesz) 영역은 0으로 초기화된 익명 매핑(anonymous mapping)으로 채웁니다. 이 처리는 load_elf_binary() 내 set_brk()와 padzero()에서 이루어집니다.
섹션 헤더 — 섹션 (Section Headers)
섹션 헤더 테이블은 링커가 오브젝트 파일을 조합할 때 사용하는 정보입니다. 실행 시에는 프로그램 헤더(세그먼트)만 필수이며, 섹션 헤더는 선택 사항입니다. 하지만 디버깅과 심볼 분석에 중요합니다.
/* include/uapi/linux/elf.h */
typedef struct elf64_shdr {
Elf64_Word sh_name; /* 섹션 이름 (.shstrtab 내 오프셋) */
Elf64_Word sh_type; /* 섹션 타입: SHT_PROGBITS, SHT_SYMTAB 등 */
Elf64_Xword sh_flags; /* 플래그: SHF_WRITE, SHF_ALLOC, SHF_EXECINSTR */
Elf64_Addr sh_addr; /* 메모리 내 주소 (로드 가능 시) */
Elf64_Off sh_offset; /* 파일 내 섹션 시작 오프셋 */
Elf64_Xword sh_size; /* 섹션 크기 */
Elf64_Word sh_link; /* 연관된 섹션 인덱스 */
Elf64_Word sh_info; /* 추가 정보 */
Elf64_Xword sh_addralign; /* 정렬 제약 */
Elf64_Xword sh_entsize; /* 테이블 엔트리 크기 (고정 크기 테이블인 경우) */
} Elf64_Shdr;
주요 섹션과 그 역할:
| 섹션 | 타입 | 플래그 | 설명 |
|---|---|---|---|
.text | PROGBITS | AX | 실행 가능 코드 |
.rodata | PROGBITS | A | 읽기 전용 데이터 (문자열 리터럴, const 변수) |
.data | PROGBITS | WA | 초기화된 전역/정적 변수 |
.bss | NOBITS | WA | 0 초기화 전역/정적 변수 (파일 공간 차지 안 함) |
.plt | PROGBITS | AX | Procedure Linkage Table — 지연 바인딩 트램펄린 |
.got | PROGBITS | WA | Global Offset Table — 전역 심볼의 런타임 주소 |
.got.plt | PROGBITS | WA | PLT 전용 GOT 엔트리 |
.dynamic | DYNAMIC | WA | 동적 링킹 메타데이터 (DT_NEEDED, DT_SYMTAB 등) |
.symtab | SYMTAB | - | 심볼 테이블 (strip 시 제거됨) |
.dynsym | DYNSYM | A | 동적 심볼 테이블 (런타임 필수, strip 불가) |
.strtab | STRTAB | - | 심볼 이름 문자열 테이블 |
.rela.plt | RELA | A | PLT 엔트리 재배치 정보 |
.rela.dyn | RELA | A | 동적 재배치 정보 |
.init_array | INIT_ARRAY | WA | 초기화 함수 포인터 배열 (main() 전 실행) |
.fini_array | FINI_ARRAY | WA | 종료 함수 포인터 배열 (main() 후 실행) |
.note.gnu.build-id | NOTE | A | 빌드 ID 해시 (디버그 심볼 매칭용) |
세그먼트 vs 섹션: 하나의 세그먼트(PT_LOAD)는 여러 섹션을 포함할 수 있습니다. 예를 들어, 코드 세그먼트(R-X)에는 .text, .rodata, .plt 등이, 데이터 세그먼트(RW-)에는 .data, .bss, .got 등이 포함됩니다. 커널은 세그먼트 단위로 mmap()하므로, 같은 세그먼트에 속한 섹션은 동일한 메모리 보호 속성을 갖습니다.
심볼 테이블 (Symbol Table)
심볼 테이블은 프로그램에서 사용되는 함수, 변수, 섹션 등의 이름과 속성을 기록합니다. ELF에는 두 종류의 심볼 테이블이 있습니다: .symtab(전체 심볼, strip 시 제거 가능)과 .dynsym(동적 심볼, 런타임 필수이므로 제거 불가).
/* include/uapi/linux/elf.h */
typedef struct elf64_sym {
Elf64_Word st_name; /* 심볼 이름 (.strtab/.dynstr 내 오프셋) */
unsigned char st_info; /* 바인딩(상위 4비트) + 타입(하위 4비트) */
unsigned char st_other; /* 가시성 (하위 2비트) */
Elf64_Half st_shndx; /* 심볼이 속한 섹션 인덱스 */
Elf64_Addr st_value; /* 심볼 값 (주소 또는 오프셋) */
Elf64_Xword st_size; /* 심볼 크기 (바이트) */
} Elf64_Sym;
/* st_info 매크로 */
#define ELF64_ST_BIND(info) ((info) >> 4)
#define ELF64_ST_TYPE(info) ((info) & 0xf)
#define ELF64_ST_INFO(bind, type) (((bind) << 4) + ((type) & 0xf))
/* st_other 매크로 */
#define ELF64_ST_VISIBILITY(other) ((other) & 0x3)
심볼 바인딩(Binding) — 심볼의 링킹 범위를 결정합니다:
| 바인딩 | 값 | 설명 |
|---|---|---|
STB_LOCAL | 0 | 파일 내부에서만 참조 가능. static 함수/변수. 다른 오브젝트 파일의 동일 이름 심볼과 충돌하지 않음 |
STB_GLOBAL | 1 | 모든 오브젝트 파일에서 참조 가능. 정의가 하나만 존재해야 함 (다중 정의 시 링커 에러) |
STB_WEAK | 2 | 전역 심볼과 유사하나 우선순위가 낮음. 같은 이름의 STB_GLOBAL 심볼이 있으면 대체됨. 미해석 시 에러 아님 (0으로 처리) |
심볼 타입(Type) — 심볼이 참조하는 엔티티의 종류:
| 타입 | 값 | 설명 |
|---|---|---|
STT_NOTYPE | 0 | 타입 미지정 |
STT_OBJECT | 1 | 데이터 오브젝트 (변수, 배열 등) |
STT_FUNC | 2 | 함수 또는 실행 가능 코드 |
STT_SECTION | 3 | 섹션 자체를 나타내는 심볼 |
STT_FILE | 4 | 소스 파일 이름 |
STT_COMMON | 5 | 미초기화 공통 블록 (Fortran COMMON, C의 tentative definition) |
STT_TLS | 6 | Thread-Local Storage 변수 |
STT_GNU_IFUNC | 10 | GNU 간접 함수 — 런타임에 CPU 기능에 따라 최적 구현 선택 (예: memcpy의 AVX/SSE 분기) |
심볼 가시성(Visibility) — 동적 링킹에서의 심볼 노출 범위를 제어합니다:
| 가시성 | 값 | 설명 |
|---|---|---|
STV_DEFAULT | 0 | 기본 가시성. 바인딩 규칙에 따라 동적 심볼로 내보내짐 |
STV_INTERNAL | 1 | 프로세서별 숨김 규칙 적용 (거의 사용되지 않음) |
STV_HIDDEN | 2 | 동적 심볼 테이블에 포함되지 않음. -fvisibility=hidden과 동일 효과 |
STV_PROTECTED | 3 | 외부에서 참조 가능하나, 같은 공유 라이브러리 내에서는 심볼 인터포지션 불가 |
특수 섹션 인덱스 (st_shndx):
| 상수 | 값 | 설명 |
|---|---|---|
SHN_UNDEF | 0 | 미정의 심볼 — 다른 오브젝트/라이브러리에서 정의 필요 |
SHN_ABS | 0xfff1 | 절대 주소 — 재배치에 영향받지 않음 |
SHN_COMMON | 0xfff2 | COMMON 블록 — 링커가 .bss에 할당 |
# 심볼 테이블 확인 예시
$ readelf -s /bin/ls | head -20
Symbol table '.dynsym' contains 127 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FUNC GLOBAL DEFAULT UND abort@GLIBC_2.2.5
2: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __errno_location@GLIBC_2.2.5
...
60: 000000000001f060 920 FUNC GLOBAL DEFAULT 16 main
61: 0000000000023400 8 OBJECT GLOBAL DEFAULT 26 stdout
# Weak 심볼 예시 — 정의 없어도 링킹 성공
$ nm -D /lib/x86_64-linux-gnu/libc.so.6 | grep ' W '
0000000000044f10 W pthread_cond_signal@@GLIBC_2.3.2
0000000000044a00 W pthread_mutex_lock@@GLIBC_2.2.5
# IFUNC 심볼 — 런타임 최적 구현 선택
$ readelf -s /lib/x86_64-linux-gnu/libc.so.6 | grep IFUNC | head -5
132: 00000000001a3700 0 IFUNC GLOBAL DEFAULT 16 memcpy@@GLIBC_2.14
156: 00000000001a37a0 0 IFUNC GLOBAL DEFAULT 16 memset@@GLIBC_2.2.5
173: 00000000001a37f0 0 IFUNC GLOBAL DEFAULT 16 strcmp@@GLIBC_2.2.5
.symtab vs .dynsym: .symtab은 모든 심볼(로컬 포함)을 담는 완전한 심볼 테이블로, 디버깅과 분석에 사용됩니다. strip 명령으로 제거할 수 있습니다. .dynsym은 동적 링킹에 필요한 심볼만 포함하며, SHF_ALLOC 플래그가 설정되어 런타임에 메모리에 로드됩니다. 실행 파일에서 strip해도 .dynsym은 유지됩니다.
문자열 테이블 (String Tables)
ELF의 문자열 테이블은 NUL(\0)로 종료되는 문자열의 연속 배열입니다. 심볼 이름, 섹션 이름 등은 문자열을 직접 저장하지 않고, 해당 문자열 테이블 내의 바이트 오프셋을 참조합니다.
| 문자열 테이블 | 용도 | 참조 위치 |
|---|---|---|
.strtab | 정적 심볼 이름 | .symtab의 st_name 필드 |
.dynstr | 동적 심볼 이름, 라이브러리 이름 | .dynsym의 st_name, .dynamic의 DT_NEEDED 등 |
.shstrtab | 섹션 이름 | 섹션 헤더의 sh_name 필드. ELF 헤더의 e_shstrndx가 이 섹션의 인덱스 |
오프셋: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
내용: \0 m a i n \0 p r i n t f \0 _ s t a r t \0
st_name = 1 → "main"
st_name = 6 → "printf"
st_name = 13 → "_start"
st_name = 0 → "" (빈 문자열, 이름 없는 심볼)
재배치 (Relocation)
재배치(Relocation)는 링커(정적) 또는 동적 링커(런타임)가 심볼 참조를 최종 주소로 해석하는 과정입니다. 오브젝트 파일에서 아직 결정되지 않은 주소(외부 함수, 전역 변수 등)는 재배치 엔트리로 기록되며, 링킹 시 실제 주소로 패치됩니다.
/* include/uapi/linux/elf.h */
/* Rel: 암시적 addend (코드 내 기존 값을 addend로 사용) */
typedef struct elf64_rel {
Elf64_Addr r_offset; /* 패치할 위치 (섹션 내 오프셋 또는 가상 주소) */
Elf64_Xword r_info; /* 심볼 인덱스(상위 32비트) + 재배치 타입(하위 32비트) */
} Elf64_Rel;
/* Rela: 명시적 addend 포함 (x86_64에서 표준) */
typedef struct elf64_rela {
Elf64_Addr r_offset; /* 패치할 위치 */
Elf64_Xword r_info; /* 심볼 인덱스 + 재배치 타입 */
Elf64_Sxword r_addend; /* 주소 계산에 더해지는 상수 */
} Elf64_Rela;
#define ELF64_R_SYM(info) ((info) >> 32)
#define ELF64_R_TYPE(info) ((info) & 0xffffffff)
x86_64 주요 재배치 타입:
| 타입 | 값 | 계산식 | 용도 |
|---|---|---|---|
R_X86_64_64 | 1 | S + A | 절대 64비트 주소 (데이터 포인터) |
R_X86_64_PC32 | 2 | S + A - P | PC 상대 32비트 (근거리 call/jmp) |
R_X86_64_PLT32 | 4 | L + A - P | PLT를 통한 함수 호출 |
R_X86_64_COPY | 5 | — | 실행 파일의 .bss에 공유 라이브러리 데이터 심볼 복사 |
R_X86_64_GLOB_DAT | 6 | S | GOT 엔트리에 심볼 절대 주소 저장 |
R_X86_64_JUMP_SLOT | 7 | S | PLT용 GOT 엔트리 (지연 바인딩 대상) |
R_X86_64_RELATIVE | 8 | B + A | PIE/공유 라이브러리의 내부 참조 (base address 보정) |
R_X86_64_TPOFF32 | 23 | S + A - TP | TLS 변수의 TP(Thread Pointer) 상대 오프셋 |
계산식에서: S = 심볼 값, A = addend, P = 패치 위치 주소, B = base address, L = PLT 엔트리 주소, TP = Thread Pointer.
# 재배치 엔트리 확인
$ readelf -r /bin/ls | head -20
Relocation section '.rela.dyn' at offset 0x1570 contains 189 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000023bc0 000000000008 R_X86_64_RELATIVE 9c70
000000023bc8 000000000008 R_X86_64_RELATIVE 9c30
000000023fe8 000600000006 R_X86_64_GLOB_DAT 0000000000000000 __ctype_toupper_loc + 0
Relocation section '.rela.plt' at offset 0x1d38 contains 68 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000024018 000100000007 R_X86_64_JUMP_SLOT 0000000000000000 abort + 0
000000024020 000200000007 R_X86_64_JUMP_SLOT 0000000000000000 __errno_location + 0
커널 모듈의 재배치: 커널 모듈(.ko)은 ET_REL 타입이므로, 커널의 load_module()이 직접 재배치를 수행합니다. 아키텍처별 apply_relocate_add() 함수가 각 .rela.* 섹션을 처리하여 모듈 코드 내 심볼 참조를 커널 심볼의 실제 주소로 패치합니다. 이 과정에서 __ksymtab에 등록된 커널 심볼 테이블을 검색합니다.
동적 섹션 (.dynamic)
.dynamic 섹션은 동적 링커가 사용하는 메타데이터 테이블입니다. PT_DYNAMIC 세그먼트가 이 섹션을 가리키며, 런타임에 메모리에 로드되어 동적 링커가 라이브러리 로딩, 심볼 해석, 재배치를 수행하는 데 필요한 모든 정보를 제공합니다.
/* include/uapi/linux/elf.h */
typedef struct elf64_dyn {
Elf64_Sxword d_tag; /* 엔트리 타입 (DT_*) */
union {
Elf64_Xword d_val; /* 정수 값 */
Elf64_Addr d_ptr; /* 주소 값 */
} d_un;
} Elf64_Dyn;
주요 동적 태그:
| 태그 | 값 | d_un | 설명 |
|---|---|---|---|
DT_NEEDED | 1 | d_val | 필요한 공유 라이브러리 이름 (.dynstr 오프셋). 동적 링커가 재귀적으로 로드 |
DT_PLTRELSZ | 2 | d_val | PLT 재배치 (.rela.plt) 전체 크기 |
DT_PLTGOT | 3 | d_ptr | .got.plt 테이블 주소 |
DT_STRTAB | 5 | d_ptr | 동적 문자열 테이블 (.dynstr) 주소 |
DT_SYMTAB | 6 | d_ptr | 동적 심볼 테이블 (.dynsym) 주소 |
DT_RELA | 7 | d_ptr | .rela.dyn 재배치 테이블 주소 |
DT_RELASZ | 8 | d_val | .rela.dyn 테이블 전체 크기 (바이트) |
DT_INIT | 12 | d_ptr | 초기화 함수 주소 (_init) |
DT_FINI | 13 | d_ptr | 종료 함수 주소 (_fini) |
DT_SONAME | 14 | d_val | 공유 라이브러리의 SONAME (.dynstr 오프셋) |
DT_JMPREL | 23 | d_ptr | PLT 재배치 테이블 (.rela.plt) 주소 |
DT_INIT_ARRAY | 25 | d_ptr | 초기화 함수 포인터 배열 (.init_array) 주소 |
DT_FINI_ARRAY | 26 | d_ptr | 종료 함수 포인터 배열 (.fini_array) 주소 |
DT_RUNPATH | 29 | d_val | 라이브러리 검색 경로 (-Wl,-rpath로 설정) |
DT_FLAGS | 30 | d_val | 플래그: DF_ORIGIN, DF_SYMBOLIC, DF_BIND_NOW, DF_STATIC_TLS |
DT_FLAGS_1 | 0x6ffffffb | d_val | 확장 플래그: DF_1_NOW(즉시 바인딩), DF_1_PIE(PIE 표시) |
DT_GNU_HASH | 0x6ffffef5 | d_ptr | GNU 해시 테이블 주소 (심볼 검색 가속) |
DT_VERNEED | 0x6ffffffe | d_ptr | 심볼 버전 필요(version needed) 테이블. 예: GLIBC_2.17 |
DT_NULL | 0 | — | 동적 섹션 종료 표시 |
# 동적 섹션 확인
$ readelf -d /bin/ls
Dynamic section at offset 0x22df0 contains 28 entries:
Tag Type Name/Value
0x0000000000000001 (NEEDED) Shared library: [libselinux.so.1]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
0x000000000000000c (INIT) 0x4000
0x000000000000000d (FINI) 0x172e4
0x0000000000000019 (INIT_ARRAY) 0x23b90
0x000000006ffffef5 (GNU_HASH) 0x3a0
0x0000000000000005 (STRTAB) 0xec0
0x0000000000000006 (SYMTAB) 0x3d8
0x000000000000001e (FLAGS) BIND_NOW
0x000000006ffffffb (FLAGS_1) Flags: NOW PIE
0x0000000000000000 (NULL) 0x0
GNU 해시 테이블: DT_GNU_HASH는 기존 SYSV 해시(DT_HASH)를 대체하는 고속 심볼 검색 메커니즘입니다. Bloom filter와 해시 버킷을 사용하여 평균 검색 시간을 크게 단축합니다. 현대 Linux 바이너리는 거의 모두 GNU 해시를 사용하며, 동적 링커 시작 시간이 약 50% 감소하는 효과가 있습니다.
ELF 노트 섹션 (Note Sections)
ELF 노트는 벤더별 메타데이터를 구조화된 형태로 저장하는 섹션입니다. .note.* 섹션과 PT_NOTE 세그먼트에 포함되며, 각 노트는 이름-타입-설명의 삼중 구조를 가집니다.
/* ELF 노트 헤더 — 각 노트 앞에 위치 */
typedef struct elf64_note {
Elf64_Word n_namesz; /* 이름 문자열 길이 (NUL 포함) */
Elf64_Word n_descsz; /* 설명(descriptor) 데이터 크기 */
Elf64_Word n_type; /* 노트 타입 (이름별 의미가 다름) */
/* 이후: 이름(4바이트 정렬) + 설명(4바이트 정렬) */
} Elf64_Nhdr;
주요 GNU 노트 타입:
| 섹션 | 이름 | n_type | 설명 |
|---|---|---|---|
.note.gnu.build-id | "GNU" | NT_GNU_BUILD_ID (3) | 바이너리의 고유 식별 해시 (SHA1 등). 디버그 심볼 매칭, debuginfod에서 사용 |
.note.ABI-tag | "GNU" | NT_GNU_ABI_TAG (1) | 최소 커널 버전 요구사항 (예: Linux 3.2.0). load_elf_binary()에서 검증 |
.note.gnu.property | "GNU" | NT_GNU_PROPERTY_TYPE_0 (5) | CET(IBT/SHSTK), BTI 등 보안 속성 표시. 커널이 arch_setup_elf_property()에서 처리 |
# 노트 섹션 확인
$ readelf -n /bin/ls
Displaying notes found in: .note.gnu.property
Owner Data size Description
GNU 0x00000020 NT_GNU_PROPERTY_TYPE_0
Properties: x86 feature: IBT, SHSTK
x86 ISA needed: x86-64-baseline
Displaying notes found in: .note.gnu.build-id
Owner Data size Description
GNU 0x00000014 NT_GNU_BUILD_ID (unique build ID bitstring)
Build ID: 2b0e4e5b0c7c0a4f3e6d...
Displaying notes found in: .note.ABI-tag
Owner Data size Description
GNU 0x00000010 NT_GNU_ABI_TAG (ABI version tag)
OS: Linux, ABI: 3.2.0
CET과 NT_GNU_PROPERTY: Intel CET(Control-flow Enforcement Technology)의 IBT(Indirect Branch Tracking)와 SHSTK(Shadow Stack)이 활성화된 바이너리는 .note.gnu.property에 해당 속성이 기록됩니다. 커널은 arch_setup_elf_property()에서 이 노트를 파싱하여 CR4.CET과 MSR을 설정합니다. 모든 공유 라이브러리에도 CET 속성이 있어야 전체적으로 활성화됩니다.
exec() 처리 흐름 (Execution Flow)
execve() 시스템 콜이 호출되면 커널은 다음 단계를 거쳐 프로세스 이미지를 교체합니다:
- 바이너리 열기:
open_exec()으로 실행 파일을 열고,struct file을 얻습니다. - bprm 구조체 준비:
struct linux_binprm에 파일 첫 256바이트(매직 넘버 검사용), credentials, 파일 정보를 채웁니다. - 포맷 핸들러 탐색:
search_binary_handler()가 등록된 바이너리 포맷 핸들러(struct linux_binfmt)를 순회하며, 매직 넘버를 통해 ELF, 스크립트(#!), misc 등 적절한 핸들러를 찾습니다. - ELF 헤더 검증:
load_elf_binary()가 매직 넘버(0x7f ELF), 클래스(32/64bit), 엔디안, 아키텍처(e_machine)를 검증합니다. - 인터프리터 로딩:
PT_INTERP세그먼트가 있으면 동적 링커(예:/lib64/ld-linux-x86-64.so.2)의 ELF 파일을 별도로 로드합니다. - 기존 주소 공간 해제:
begin_new_exec()에서 point-of-no-return을 넘기고,exec_mmap()으로 기존mm_struct를 해제합니다. - PT_LOAD 세그먼트 매핑:
elf_map()으로 각PT_LOAD세그먼트를do_mmap()하여 파일 내용을 가상 주소 공간에 매핑합니다. - BSS 설정:
set_brk()로.bss영역(p_memsz > p_filesz 차이분)을 익명 매핑으로 할당합니다. - 스택 설정: 새 스택에 argv, envp, auxiliary vector(AT_*)를 배치합니다.
- 실행 시작:
start_thread()로 레지스터를 설정하고, 동적 링크 바이너리이면 인터프리터의 엔트리 포인트에서, 아니면 ELF의e_entry에서 실행을 시작합니다.
/* ELF 바이너리 로더 - fs/binfmt_elf.c (간략화) */
static int load_elf_binary(struct linux_binprm *bprm)
{
struct elfhdr *elf_ex = (struct elfhdr *)bprm->buf;
struct elf_phdr *elf_ppnt, *elf_phdata;
struct elfhdr *interp_elf_ex = NULL;
unsigned long load_addr = 0, load_bias = 0;
unsigned long elf_entry;
int executable_stack = EXSTACK_DEFAULT;
/* 1. ELF 매직 넘버 및 타입 검증 */
if (memcmp(elf_ex->e_ident, ELFMAG, SELFMAG) != 0)
return -ENOEXEC;
if (elf_ex->e_type != ET_EXEC && elf_ex->e_type != ET_DYN)
return -ENOEXEC;
/* 2. 프로그램 헤더 테이블 전체 읽기 */
elf_phdata = load_elf_phdrs(elf_ex, bprm->file);
/* 3. 프로그램 헤더 1차 순회: PT_INTERP, PT_GNU_STACK 처리 */
for (i = 0; i < elf_ex->e_phnum; i++, elf_ppnt++) {
if (elf_ppnt->p_type == PT_INTERP) {
/* 동적 링커 경로 읽기 (예: /lib64/ld-linux-x86-64.so.2) */
elf_interpreter = kmalloc(elf_ppnt->p_filesz, GFP_KERNEL);
elf_read(bprm->file, elf_interpreter,
elf_ppnt->p_filesz, elf_ppnt->p_offset);
/* 인터프리터 ELF 파일도 별도로 열어서 헤더 로드 */
interp_elf_ex = ...;
}
if (elf_ppnt->p_type == PT_GNU_STACK) {
/* 스택 실행 권한 결정 */
executable_stack = (elf_ppnt->p_flags & PF_X)
? EXSTACK_ENABLE_X : EXSTACK_DISABLE_X;
}
}
/* 4. Point of no return — 기존 주소 공간 해제 */
begin_new_exec(bprm);
setup_new_exec(bprm);
/* 5. ET_DYN(PIE)이면 ASLR용 load_bias 계산 */
if (elf_ex->e_type == ET_DYN) {
load_bias = ELF_ET_DYN_BASE; /* ASLR randomization 적용 */
if (current->flags & PF_RANDOMIZE)
load_bias += arch_mmap_rnd();
}
/* 6. PT_LOAD 세그먼트 매핑 */
for (i = 0; i < elf_ex->e_phnum; i++) {
if (elf_ppnt->p_type != PT_LOAD)
continue;
/* elf_map() → do_mmap(): 파일을 가상 주소에 매핑 */
error = elf_map(bprm->file,
load_bias + vaddr, /* 매핑 주소 */
elf_ppnt, /* 세그먼트 정보 */
elf_prot, elf_flags, total_size);
}
/* 7. BSS 영역 설정 (p_memsz > p_filesz 부분) */
set_brk(elf_bss, elf_brk, bss_prot);
/* 8. 인터프리터가 있으면 로드 */
if (interp_elf_ex)
elf_entry = load_elf_interp(interp_elf_ex, ...);
else
elf_entry = elf_ex->e_entry + load_bias;
/* 9. auxiliary vector를 스택에 배치 */
create_elf_tables(bprm, elf_ex, interp_load_addr,
e_entry, phdr_addr);
/* 10. 엔트리 포인트에서 실행 시작 */
start_thread(regs, elf_entry, bprm->p);
return 0;
}
Auxiliary Vector (보조 벡터)
커널은 exec() 시 사용자 공간 스택에 auxiliary vector(보조 벡터)를 배치합니다. 이 벡터는 동적 링커(ld-linux.so)와 C 라이브러리가 프로세스 환경을 파악하는 데 사용하는 키-값 쌍입니다.
스택 레이아웃 (낮은 주소 → 높은 주소):
┌─────────────────────────────────┐ (높은 주소)
│ 환경 변수 문자열 (envp strings) │
│ 인자 문자열 (argv strings) │
│ 패딩 / 정렬 │
├─────────────────────────────────┤
│ NULL (envp 종료) │
│ envp[n-1] ... envp[0] │
│ NULL (argv 종료) │
│ argv[argc-1] ... argv[0] │
│ argc │
├─────────────────────────────────┤
│ AT_NULL (종료 마커) │
│ ... │
│ AT_ENTRY: 프로그램 엔트리 포인트│
│ AT_PHDR: 프로그램 헤더 주소 │
│ AT_PHNUM: 프로그램 헤더 수 │
│ AT_PAGESZ: 페이지 크기 │
│ AT_RANDOM: 16바이트 난수 주소 │
│ Auxiliary Vector │
├─────────────────────────────────┤
│ 스택 성장 방향 ↓ │ (낮은 주소)
└─────────────────────────────────┘
주요 auxiliary vector 엔트리 (create_elf_tables()에서 생성):
| 키 | 값 | 설명 |
|---|---|---|
AT_PHDR | 3 | 프로그램 헤더 테이블의 메모리 주소 |
AT_PHENT | 4 | 프로그램 헤더 엔트리 크기 |
AT_PHNUM | 5 | 프로그램 헤더 엔트리 수 |
AT_PAGESZ | 6 | 시스템 페이지 크기 (보통 4096) |
AT_BASE | 7 | 인터프리터(ld.so)의 로드 기본 주소 |
AT_FLAGS | 8 | 프로세서별 플래그 |
AT_ENTRY | 9 | 프로그램 엔트리 포인트 (e_entry) |
AT_UID/GID | 11/12 | 실제 사용자/그룹 ID |
AT_EUID/EGID | 13/14 | 유효 사용자/그룹 ID |
AT_PLATFORM | 15 | 플랫폼 문자열 (예: "x86_64") |
AT_HWCAP | 16 | 하드웨어 능력 비트마스크 (SSE, AVX 등) |
AT_CLKTCK | 17 | sysconf(_SC_CLK_TCK) 값 (보통 100) |
AT_RANDOM | 25 | 16바이트 랜덤 데이터 주소 (스택 canary 시드 등) |
AT_HWCAP2 | 26 | 확장 하드웨어 능력 (CET, TME 등) |
AT_EXECFN | 31 | 실행 파일명 문자열 주소 |
AT_SYSINFO_EHDR | 33 | vDSO ELF 이미지 주소 |
# auxiliary vector 확인 방법
$ LD_SHOW_AUXV=1 /bin/true
AT_SYSINFO_EHDR: 0x7ffd5c9fe000 # vDSO 주소
AT_HWCAP: 178bfbff # CPU 기능 비트맵
AT_PAGESZ: 4096 # 페이지 크기
AT_CLKTCK: 100 # clock ticks/sec
AT_PHDR: 0x55a3c4200040 # 프로그램 헤더 주소
AT_PHENT: 56 # phdr 엔트리 크기
AT_PHNUM: 13 # phdr 엔트리 수
AT_BASE: 0x7f2e8c400000 # ld.so 기본 주소
AT_ENTRY: 0x55a3c4201a90 # _start 주소
AT_RANDOM: 0x7ffd5c9d4a09 # 16바이트 난수 위치
AT_EXECFN: /bin/true
AT_PLATFORM: x86_64
ASLR과 PIE (Address Space Layout Randomization)
ASLR은 프로세스의 주요 메모리 영역(스택, 힙, mmap, 실행 코드)의 기본 주소를 매 실행마다 랜덤화하여 공격자가 코드/데이터 위치를 예측하기 어렵게 만듭니다.
| 영역 | 랜덤화 소스 | 커널 코드 |
|---|---|---|
| 스택 | arch_align_stack() | setup_arg_pages()에서 스택 top에 랜덤 오프셋 추가 |
| mmap base | arch_mmap_rnd() | 공유 라이브러리, vDSO 로드 주소 랜덤화 |
| 힙 (brk) | arch_randomize_brk() | BSS 끝 이후 brk 시작점 랜덤화 |
| PIE 바이너리 | ELF_ET_DYN_BASE + rnd | load_elf_binary()에서 ET_DYN 바이너리에 적용 |
/* load_elf_binary()에서 PIE(ET_DYN) ASLR 처리 */
if (elf_ex->e_type == ET_DYN) {
/*
* ET_DYN 바이너리는 상대 주소로 컴파일되어 임의 위치에 로드 가능.
* ELF_ET_DYN_BASE는 아키텍처별 기본 주소:
* x86_64: (1UL << 47) * 2 / 3 ≈ 0x555555555000 부근
*/
load_bias = ELF_ET_DYN_BASE;
if (current->flags & PF_RANDOMIZE)
load_bias += arch_mmap_rnd();
load_bias = ELF_PAGESTART(load_bias);
}
/* ET_EXEC는 ELF에 지정된 고정 가상 주소에 로드 (ASLR 불가) */
if (elf_ex->e_type == ET_EXEC)
load_bias = 0; /* p_vaddr를 그대로 사용 */
# ASLR 수준 확인 및 설정
$ cat /proc/sys/kernel/randomize_va_space
2 # 0=비활성, 1=스택+mmap+vDSO, 2=1+brk(힙)
# 같은 PIE 바이너리를 두 번 실행하면 주소가 다름
$ cat /proc/self/maps | head -1
55a3c4200000-55a3c4205000 r--p ... # 첫 번째 실행
$ cat /proc/self/maps | head -1
564e8b400000-564e8b405000 r--p ... # 두 번째 실행 (주소 변경됨)
동적 링킹 (Dynamic Linking)
동적 링크 바이너리의 경우, 커널은 실행 파일과 함께 동적 링커(인터프리터)를 메모리에 로드하고, 인터프리터의 엔트리 포인트에서 실행을 시작합니다. 동적 링커(ld-linux.so)는 실제 프로그램의 main()이 실행되기 전에 공유 라이브러리 로드와 심볼 재배치를 수행합니다.
/* 동적 링킹 처리 흐름 (커널 + 사용자 공간) */
/* [커널] load_elf_binary() */
/* 1. PT_INTERP 세그먼트에서 인터프리터 경로 획득 */
/* 예: "/lib64/ld-linux-x86-64.so.2" */
/* 2. 인터프리터 ELF 파일을 로드 (load_elf_interp) */
/* 3. 엔트리 포인트를 인터프리터의 e_entry로 설정 */
/* [사용자 공간] ld-linux.so 시작 */
/* 4. _dl_start() → 자기 자신(ld.so)을 재배치 */
/* 5. _dl_main() → .dynamic 섹션 파싱 */
/* → DT_NEEDED 라이브러리를 재귀적으로 로드 */
/* → 심볼 해석 및 GOT/PLT 재배치 */
/* 6. 초기화 함수 실행 (.init_array) */
/* 7. 프로그램 엔트리 포인트(AT_ENTRY)로 점프 */
/* → _start → __libc_start_main → main() */
GOT/PLT와 지연 바인딩(Lazy Binding): 외부 함수 호출은 PLT(Procedure Linkage Table)를 거칩니다. PLT 엔트리는 GOT(Global Offset Table)에서 실제 주소를 읽어 점프합니다. 최초 호출 시 GOT 엔트리는 PLT의 리졸버 코드를 가리키며, 리졸버가 심볼을 해석하여 GOT를 갱신합니다. 이후 호출은 GOT에서 직접 점프하여 오버헤드가 없습니다.
; PLT를 통한 printf() 호출 예시 (x86_64)
; 사용자 코드에서 printf 호출
call printf@plt ; → PLT 엔트리로 점프
; .plt 섹션의 printf 엔트리
printf@plt:
jmp *printf@GOTPLT(%rip) ; GOT에서 주소 읽어 간접 점프
; 최초 호출 시 GOT는 아래 push를 가리킴 (리졸버로 이동)
push $0x3 ; 재배치 인덱스
jmp .plt ; _dl_runtime_resolve 호출
; _dl_runtime_resolve()가 printf의 실제 주소를 찾아
; GOT 엔트리를 갱신 → 이후 호출은 직접 printf로 점프
Full RELRO: -Wl,-z,relro,-z,now로 빌드하면 동적 링커가 프로그램 시작 시 모든 GOT 엔트리를 즉시 바인딩(eager binding)한 뒤, PT_GNU_RELRO 영역을 mprotect(PROT_READ)로 읽기 전용으로 전환합니다. 이를 통해 GOT overwrite 공격을 방지할 수 있습니다. 현대 배포판에서는 보안 강화를 위해 기본 활성화되는 추세입니다.
vDSO (virtual Dynamic Shared Object)
vDSO는 커널이 모든 사용자 공간 프로세스의 주소 공간에 자동으로 매핑하는 특수한 공유 라이브러리입니다. gettimeofday(), clock_gettime(), getcpu() 등 빈번하게 호출되지만 실제 특권이 불필요한 시스템 콜을 커널 진입 없이 사용자 공간에서 처리할 수 있게 합니다.
/* vDSO 매핑 — arch/x86/entry/vdso/ */
/* 커널이 exec 시 vDSO를 매핑하는 과정 */
/* load_elf_binary() → arch_setup_additional_pages() */
int arch_setup_additional_pages(struct linux_binprm *bprm, int uses_interp)
{
/* vDSO 이미지를 프로세스 주소 공간에 매핑 */
return map_vdso(&vdso_image_64, 0);
/* AT_SYSINFO_EHDR auxv 엔트리가 이 주소를 가리킴 */
}
/* vDSO 내 함수 예시: 시스템 콜 없이 시간 획득 */
int __vdso_clock_gettime(clockid_t clock, struct timespec *ts)
{
/* 커널이 공유 메모리(vvar)에 갱신해둔 시간 데이터를 직접 읽음 */
/* → syscall 오버헤드(~100ns) 제거 */
...
}
# vDSO 확인
$ cat /proc/self/maps | grep vdso
7ffd5c9fe000-7ffd5ca00000 r-xp 00000000 00:00 0 [vdso]
# vDSO가 export하는 심볼 확인
$ objdump -T /proc/self/root/usr/lib/vdso/vdso64.so 2>/dev/null || \
dd if=/proc/self/mem bs=1 skip=$((0x7ffd5c9fe000)) count=8192 2>/dev/null | \
readelf -Ws /dev/stdin 2>/dev/null
# __vdso_clock_gettime, __vdso_gettimeofday, __vdso_time, __vdso_getcpu
ELF 분석 도구 (Analysis Tools)
# readelf — ELF 구조 분석의 표준 도구
$ readelf -h /bin/ls # ELF 헤더
$ readelf -l /bin/ls # 프로그램 헤더 (세그먼트)
$ readelf -S /bin/ls # 섹션 헤더
$ readelf -s /bin/ls # 심볼 테이블
$ readelf -d /bin/ls # 동적 섹션 (DT_NEEDED 등)
$ readelf -r /bin/ls # 재배치 엔트리
$ readelf -n /bin/ls # 노트 섹션 (빌드 ID 등)
# objdump — 디스어셈블리와 섹션 분석
$ objdump -d /bin/ls # 코드 섹션 디스어셈블리
$ objdump -j .plt -d /bin/ls # PLT 엔트리만 디스어셈블
$ objdump -R /bin/ls # 동적 재배치 테이블
# nm — 심볼 목록
$ nm -D /bin/ls # 동적 심볼만 표시
# ldd — 동적 라이브러리 의존성
$ ldd /bin/ls # 의존 공유 라이브러리와 로드 주소
# file — 파일 타입 식별
$ file /bin/ls
/bin/ls: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV),
dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2,
BuildID[sha1]=..., for GNU/Linux 3.2.0, stripped
TLS — Thread-Local Storage (스레드 로컬 저장소)
TLS(Thread-Local Storage)는 각 스레드가 독립적인 복사본을 가지는 변수를 구현하는 메커니즘입니다. C/C++에서 __thread 또는 _Thread_local 키워드로 선언하며, ELF에서는 PT_TLS 세그먼트와 TLS 관련 재배치로 구현됩니다.
/* TLS 변수 선언 예시 */
__thread int tls_var = 42; /* 초기화됨 → .tdata 섹션 */
__thread int tls_bss_var; /* 미초기화 → .tbss 섹션 */
_Thread_local char buf[256]; /* C11 표준 키워드 (동일) */
/* 커널 내 per-CPU 변수도 유사한 개념 */
DEFINE_PER_CPU(int, my_percpu_var); /* 커널에서는 __percpu로 구현 */
TLS 관련 ELF 섹션과 세그먼트:
| 요소 | 타입 | 설명 |
|---|---|---|
.tdata | PROGBITS | 초기화된 TLS 변수. SHF_TLS | SHF_ALLOC | SHF_WRITE 플래그 |
.tbss | NOBITS | 미초기화 TLS 변수 (0 초기화). .bss와 유사하게 파일 공간 불필요 |
PT_TLS | 세그먼트 (7) | TLS 초기화 이미지. 새 스레드 생성 시 이 이미지를 복사하여 스레드별 TLS 영역 생성 |
STT_TLS | 심볼 타입 (6) | TLS 변수 심볼. st_value는 TLS 블록 내 오프셋 |
TLS 접근 모델: 컴파일러는 TLS 변수의 가시성과 링킹 상황에 따라 4가지 접근 모델을 사용합니다:
| 모델 | 플래그 | 적용 상황 | 성능 |
|---|---|---|---|
| Local Exec (LE) | -ftls-model=local-exec | 실행 파일 내부에서 자체 TLS 접근. TP에서 고정 오프셋으로 직접 접근 | 최고 (단일 명령어) |
| Initial Exec (IE) | -ftls-model=initial-exec | 실행 파일에서 공유 라이브러리의 TLS 접근. GOT에서 TP 오프셋을 읽음 | 빠름 (GOT 1회 접근) |
| General Dynamic (GD) | -ftls-model=global-dynamic | dlopen()으로 로드된 라이브러리의 TLS. __tls_get_addr() 호출 필요 | 느림 (함수 호출) |
| Local Dynamic (LD) | -ftls-model=local-dynamic | 같은 DSO 내 여러 TLS 변수 접근 시 GD 최적화. 모듈 base를 1회만 조회 | GD보다 약간 빠름 |
; Local Exec 모델 (x86_64) — 가장 효율적
; 실행 파일 자체의 TLS 변수 접근
mov eax, dword ptr fs:[tls_var@TPOFF] ; FS + 고정 오프셋
; Initial Exec 모델 — GOT에서 오프셋 로드
mov rax, qword ptr [rip + tls_var@GOTTPOFF] ; GOT에서 TP 오프셋
mov eax, dword ptr fs:[rax] ; FS + 오프셋
; General Dynamic 모델 — __tls_get_addr() 호출
lea rdi, [rip + tls_var@TLSGD] ; TLS descriptor 주소
call __tls_get_addr@PLT ; 동적 링커가 주소 반환
mov eax, [rax] ; 반환된 주소로 접근
# TLS 세그먼트 확인
$ readelf -l /usr/bin/python3 | grep TLS
TLS 0x000000000029a890 0x000000000049a890 0x000000000049a890
0x0000000000000010 0x0000000000000088 R 0x8
# TLS 심볼 확인
$ readelf -s /lib/x86_64-linux-gnu/libc.so.6 | grep TLS
16: 0000000000000000 4 TLS GLOBAL DEFAULT 32 errno@@GLIBC_PRIVATE
18: 0000000000000008 8 TLS GLOBAL DEFAULT 32 __resp@@GLIBC_PRIVATE
# TLS 관련 재배치
$ readelf -r /usr/bin/python3 | grep TPOFF
00000049c038 001b00000012 R_X86_64_TPOFF64 0000000000000000 _Py_tss_tstate + 0
x86_64 TLS와 FS 레지스터: x86_64에서 FS 세그먼트 레지스터는 TCB(Thread Control Block)를 가리킵니다. 실행 파일의 TLS 블록은 TCB 아래(음수 오프셋)에, dlopen()으로 로드된 모듈의 TLS는 DTV(Dynamic Thread Vector)를 통해 동적으로 할당됩니다. 커널은 arch_prctl(ARCH_SET_FS, addr) 또는 clone() 시 CLONE_SETTLS 플래그로 MSR_FS_BASE를 설정합니다.
코어 덤프 (Core Dump)
프로세스가 치명적 시그널(SIGSEGV, SIGABRT 등)을 받으면 커널은 프로세스의 메모리 상태를 ELF 코어 덤프 파일로 저장합니다. 코어 파일은 ET_CORE 타입의 ELF이며, 크래시 분석에 필수적인 정보를 포함합니다.
코어 덤프 생성 경로:
/* 시그널 처리에서 코어 덤프까지의 커널 경로 */
do_signal()
└→ get_signal()
└→ sig->flags & SIGNAL_GROUP_COREDUMP
└→ do_coredump() /* fs/coredump.c */
├→ format_corename() /* core_pattern 해석 */
├→ file = filp_open() /* 코어 파일 생성 */
└→ binfmt->core_dump() /* ELF 포맷 핸들러 호출 */
└→ elf_core_dump() /* fs/binfmt_elf.c */
코어 파일 구조:
┌──────────────────────────────────┐
│ ELF Header (ET_CORE) │ e_type = 4, e_phnum = 세그먼트 수
├──────────────────────────────────┤
│ Program Header: PT_NOTE │ 레지스터, 시그널 정보, 프로세스 상태
│ Program Header: PT_LOAD │ 메모리 세그먼트 1 (코드)
│ Program Header: PT_LOAD │ 메모리 세그먼트 2 (데이터)
│ Program Header: PT_LOAD │ 메모리 세그먼트 3 (스택)
│ ... │ 각 VMA 영역마다 PT_LOAD 생성
├──────────────────────────────────┤
│ NOTE: NT_PRSTATUS │ 레지스터 상태 (각 스레드별)
│ NOTE: NT_PRPSINFO │ 프로세스 이름, 상태, PID
│ NOTE: NT_SIGINFO │ 시그널 정보 (si_signo, si_addr)
│ NOTE: NT_AUXV │ Auxiliary Vector
│ NOTE: NT_FILE │ 메모리 매핑된 파일 목록
│ NOTE: NT_FPREGSET │ FPU/SSE 레지스터
│ NOTE: NT_X86_XSTATE │ 확장 상태 (AVX, AVX-512)
├──────────────────────────────────┤
│ 메모리 세그먼트 데이터 ... │ 실제 메모리 내용
└──────────────────────────────────┘
/* fs/binfmt_elf.c — 코어 덤프 핵심 함수 (간략화) */
static int elf_core_dump(struct coredump_params *cprm)
{
struct elfhdr elf;
struct elf_phdr *phdr;
/* 1. ELF 헤더 작성 (ET_CORE) */
fill_elf_header(&elf, segs + 1, ELF_ARCH, 0);
elf.e_type = ET_CORE;
/* 2. PT_NOTE 세그먼트 작성 — 레지스터, 시그널 정보 */
fill_note(&psinfo_note, "CORE", NT_PRPSINFO, ...);
fill_note(&siginfo_note, "CORE", NT_SIGINFO, ...);
/* 각 스레드의 레지스터 상태 */
for_each_thread(p, t) {
fill_note(&t->prstatus_note, "CORE", NT_PRSTATUS,
sizeof(struct elf_prstatus), &t->prstatus);
}
/* 3. VMA 순회 — 각 메모리 영역을 PT_LOAD로 기록 */
for_each_vma(cprm->mm, vma) {
/* 코어 덤프 필터에 따라 포함 여부 결정 */
if (!vma_dump_size(vma, cprm->mm_flags))
continue;
phdr->p_type = PT_LOAD;
phdr->p_vaddr = vma->vm_start;
phdr->p_memsz = vma->vm_end - vma->vm_start;
/* 메모리 내용을 파일에 기록 */
dump_user_range(cprm, vma->vm_start, size);
}
}
# 코어 덤프 설정
$ cat /proc/sys/kernel/core_pattern
|/usr/lib/systemd/systemd-coredump %P %u %g %s %t %c %h
# 코어 덤프 필터 (비트마스크로 포함할 VMA 유형 제어)
$ cat /proc/self/coredump_filter
00000033 # bit 0: 익명 private, bit 1: 익명 shared
# bit 4: ELF headers, bit 5: DAX private
# 코어 파일 분석
$ readelf -h core | grep Type
Type: CORE (Core file)
$ readelf -n core | head -20
Displaying notes found at file offset 0x... with length 0x...:
Owner Data size Description
CORE 0x00000150 NT_PRSTATUS (prstatus structure)
CORE 0x00000088 NT_PRPSINFO (prpsinfo structure)
CORE 0x00000080 NT_SIGINFO (siginfo_t data)
CORE 0x00000130 NT_AUXV (auxiliary vector)
CORE 0x00000563 NT_FILE (mapped files)
# GDB로 코어 파일 분석
$ gdb /path/to/binary core
(gdb) bt # 크래시 시점 backtrace
(gdb) info registers # 레지스터 상태
(gdb) x/10i $rip # 크래시 위치 디스어셈블리
# systemd 환경에서 코어 덤프 조회
$ coredumpctl list
$ coredumpctl debug # 최근 코어 덤프로 GDB 실행
커널 모듈 ELF (.ko)
커널 모듈(.ko 파일)은 ET_REL(재배치 가능) 타입의 ELF 오브젝트입니다. 일반 사용자 공간 실행 파일과 달리 프로그램 헤더가 없으며, 커널의 load_module()이 링커 역할을 수행하여 섹션 기반으로 로딩과 재배치를 처리합니다.
모듈 전용 ELF 섹션:
| 섹션 | 설명 |
|---|---|
.modinfo | 모듈 메타데이터: license=, description=, author=, alias=, depends=, vermagic=. modinfo 명령이 읽는 데이터 |
__versions | CRC 기반 심볼 버전 체크섬. 커널-모듈 ABI 호환성 검증에 사용 (CONFIG_MODVERSIONS) |
.init.text | 모듈 초기화 코드 (module_init()). 로딩 후 해제 가능한 영역으로 분류 |
.exit.text | 모듈 제거 코드 (module_exit()). 내장 빌드 시 제거됨 |
__ksymtab | EXPORT_SYMBOL()로 내보낸 심볼 테이블 |
__ksymtab_gpl | EXPORT_SYMBOL_GPL()로 내보낸 GPL 전용 심볼 테이블 |
__kcrctab | 내보낸 심볼의 CRC 체크섬 |
.gnu.linkonce.this_module | struct module 인스턴스. 모듈 이름, init/exit 함수 포인터 포함 |
__param | module_param()으로 정의한 모듈 파라미터 기술자 |
.altinstructions | CPU 기능에 따른 대체 명령어 (alternative instructions) 테이블 |
__bug_table | BUG() / WARN() 매크로의 위치 정보 |
__ex_table | 예외 테이블 — 사용자 공간 접근(copy_to_user 등)에서 페이지 폴트 시 복구 주소 |
/* kernel/module/main.c — 모듈 로딩 핵심 흐름 (간략화) */
static int load_module(struct load_info *info, const char *uargs, int flags)
{
/* 1. ELF 헤더 검증 (ET_REL, 아키텍처, 버전) */
err = elf_validity_check(info);
/* 2. 섹션 헤더 테이블 파싱 — 모든 섹션 정보 수집 */
err = setup_load_info(info, flags);
/* 3. .modinfo에서 vermagic 검증 */
err = check_modinfo(info->mod, info, flags);
/* vermagic = "6.1.0 SMP preempt mod_unload" */
/* 4. SHF_ALLOC 섹션을 core/init 영역으로 분류 */
module_frob_arch_sections(info);
err = move_module(info->mod, info);
/* vmalloc으로 메모리 할당, 섹션 데이터 복사 */
/* 5. 심볼 해석 — 커널 심볼 테이블에서 외부 참조 검색 */
err = simplify_symbols(info);
/* find_symbol() → __ksymtab 검색 → 심볼 주소 resolve */
/* 6. 재배치 수행 */
err = apply_relocations(info);
/* 각 .rela.* 섹션에 대해 apply_relocate_add() 호출 */
/* 모듈 코드 내 주소 참조를 실제 커널 심볼 주소로 패치 */
/* 7. __ex_table, __bug_table 등 특수 섹션 등록 */
post_relocation(info->mod, info);
/* 8. 모듈 초기화 함수 실행 */
return do_init_module(info->mod);
/* mod->init() 호출 후 .init.* 섹션 메모리 해제 */
}
# 커널 모듈의 ELF 구조 확인
$ readelf -h drivers/net/ethernet/intel/e1000e/e1000e.ko
ELF Header:
Type: REL (Relocatable file)
Machine: Advanced Micro Devices X86-64
# 모듈 전용 섹션 확인
$ readelf -S e1000e.ko | grep -E 'modinfo|ksymtab|init\.text|versions'
[11] .modinfo PROGBITS ...
[13] __versions PROGBITS ...
[21] .init.text PROGBITS ...
[35] __ksymtab PROGBITS ...
[36] __ksymtab_gpl PROGBITS ...
# .modinfo 내용 확인
$ modinfo e1000e.ko
filename: e1000e.ko
license: GPL v2
description: Intel(R) PRO/1000 Network Driver
author: Intel Corporation
alias: pci:v00008086d00001533...
depends:
vermagic: 6.1.0 SMP preempt mod_unload
# 모듈의 재배치 엔트리 — 커널 심볼 참조
$ readelf -r e1000e.ko | head -10
Relocation section '.rela.text' at offset ... contains 1234 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000000054 002c00000002 R_X86_64_PC32 0000000000000000 printk - 4
00000000009e 003400000002 R_X86_64_PC32 0000000000000000 __kmalloc - 4
EXPORT_SYMBOL과 심볼 네임스페이스: 커널 5.4부터 EXPORT_SYMBOL_NS(sym, ns)로 심볼 네임스페이스를 지정할 수 있습니다. 모듈이 특정 네임스페이스의 심볼을 사용하려면 MODULE_IMPORT_NS(ns)를 선언해야 합니다. __ksymtab 섹션에 네임스페이스 정보가 포함되며, 커널은 로딩 시 이를 검증합니다. 이는 커널 내부 API의 무분별한 사용을 제한하고 서브시스템 간 경계를 명확히 합니다.
binfmt 핸들러 프레임워크 (Binary Format Handlers)
Linux 커널은 다양한 실행 파일 형식을 플러그인 방식으로 지원합니다. exec() 시스템 콜이 호출되면 커널은 등록된 바이너리 포맷 핸들러를 순회하며, 파일의 매직 넘버를 검사하여 적절한 핸들러를 찾습니다. 이 프레임워크의 핵심은 struct linux_binfmt입니다.
/* include/linux/binfmts.h */
struct linux_binfmt {
struct list_head lh; /* 전역 리스트 연결 */
struct module *module; /* 소유 모듈 (참조 카운트) */
int (*load_binary)(struct linux_binprm *); /* 바이너리 로드 */
int (*load_shlib)(struct file *); /* uselib() 지원 (레거시) */
int (*core_dump)(struct coredump_params *cprm); /* 코어 덤프 생성 */
unsigned long min_coredump; /* 최소 코어 덤프 크기 */
};
커널에 내장된 주요 바이너리 포맷 핸들러:
| 핸들러 | 소스 | 매직 / 판별 | 처리 대상 |
|---|---|---|---|
binfmt_elf | fs/binfmt_elf.c | \x7fELF (4바이트) | ELF 실행 파일, 공유 라이브러리 |
binfmt_script | fs/binfmt_script.c | #! (2바이트) | 셸 스크립트, 인터프리터 스크립트 |
binfmt_misc | fs/binfmt_misc.c | 사용자 정의 매직/확장자 | QEMU, Wine, Java, .NET 등 |
binfmt_flat | fs/binfmt_flat.c | BFLT 헤더 | MMU-less 시스템용 플랫 바이너리 |
binfmt_elf_fdpic | fs/binfmt_elf_fdpic.c | \x7fELF + FDPIC ABI | MMU-less ELF (공유 텍스트 세그먼트) |
/* fs/exec.c — 바이너리 포맷 핸들러 탐색 (간략화) */
static int search_binary_handler(struct linux_binprm *bprm)
{
struct linux_binfmt *fmt;
int retval;
/* 등록된 모든 binfmt 핸들러를 순회 */
list_for_each_entry(fmt, &formats, lh) {
if (!try_module_get(fmt->module))
continue;
/* 각 핸들러의 load_binary() 호출 */
retval = fmt->load_binary(bprm);
module_put(fmt->module);
if (retval == -ENOEXEC)
continue; /* 이 포맷이 아님 → 다음 핸들러 시도 */
return retval; /* 성공(0) 또는 에러 → 탐색 종료 */
}
return retval; /* -ENOEXEC: 어떤 핸들러도 인식하지 못함 */
}
/* fs/binfmt_elf.c — ELF 핸들러 등록 */
static struct linux_binfmt elf_format = {
.module = THIS_MODULE,
.load_binary = load_elf_binary, /* ELF 실행 파일 로드 */
.load_shlib = load_elf_library, /* 레거시 uselib() */
.core_dump = elf_core_dump, /* ET_CORE 코어 덤프 */
.min_coredump = ELF_EXEC_PAGESIZE,
};
/* fs/binfmt_script.c — #! 스크립트 핸들러 */
static int load_script(struct linux_binprm *bprm)
{
/* 첫 2바이트가 '#!' 인지 확인 */
if ((bprm->buf[0] != '#') || (bprm->buf[1] != '!'))
return -ENOEXEC;
/* 인터프리터 경로 파싱 (예: /usr/bin/python3) */
i_name = bprm->buf + 2; /* '#!' 이후 */
...
/* bprm의 파일을 인터프리터로 교체 후 재귀 호출 */
bprm->interpreter = open_exec(i_name);
return search_binary_handler(bprm); /* 재귀! */
}
binfmt_misc — 사용자 정의 바이너리 핸들러: binfmt_misc는 사용자 공간에서 /proc/sys/fs/binfmt_misc를 통해 임의의 바이너리 포맷을 등록할 수 있는 강력한 확장 메커니즘입니다. 컨테이너 환경에서 다른 아키텍처의 바이너리를 투명하게 실행하는 데 특히 유용합니다.
# binfmt_misc 마운트 확인
$ mount | grep binfmt_misc
systemd-1 on /proc/sys/fs/binfmt_misc type binfmt_misc
# QEMU user-mode emulation 등록 (ARM64 바이너리를 x86_64에서 실행)
$ echo ':qemu-aarch64:M::\x7fELF\x02\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\xb7\x00:\xff\xff\xff\xff\xff\xff\xff\x00\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff:/usr/bin/qemu-aarch64-static:FPC' \
> /proc/sys/fs/binfmt_misc/register
# 등록된 핸들러 확인
$ cat /proc/sys/fs/binfmt_misc/qemu-aarch64
enabled
interpreter /usr/bin/qemu-aarch64-static
flags: FPC
offset 0
magic 7f454c460201010000000000000000000200b700
mask ffffffffffffff00fffffffffffffffffeffffff
# Java JAR 파일 직접 실행 등록
$ echo ':Java:M::\xca\xfe\xba\xbe::/usr/bin/java:' \
> /proc/sys/fs/binfmt_misc/register
# Wine으로 PE (.exe) 바이너리 실행 등록 (매직: MZ)
$ echo ':DOSWin:M::MZ::/usr/bin/wine:' \
> /proc/sys/fs/binfmt_misc/register
# 현재 등록된 모든 핸들러 확인
$ ls /proc/sys/fs/binfmt_misc/
qemu-aarch64 qemu-arm Java DOSWin register status
F(fix-binary) 플래그와 컨테이너: binfmt_misc 등록 시 F 플래그를 사용하면, 등록 시점에 인터프리터 바이너리의 struct file을 미리 열어 둡니다. 이는 Docker/Podman에서 멀티 아키텍처 이미지를 빌드할 때 핵심적인데, 컨테이너 내부에 QEMU 바이너리가 없어도 호스트의 QEMU를 사용할 수 있기 때문입니다. 커널 4.8에서 도입된 이 기능이 없으면, 컨테이너 내에서 인터프리터 경로를 찾을 수 없어 -ENOENT 에러가 발생합니다.
재귀 깊이 제한: #! 스크립트가 또 다른 #! 스크립트를 인터프리터로 지정하는 재귀적 구조를 방지하기 위해, 커널은 linux_binprm.recursion_depth로 최대 재귀 깊이를 4로 제한합니다 (BINPRM_MAX_RECURSION). 이를 초과하면 -ENOEXEC를 반환합니다. 예를 들어, 셸 스크립트(#!/bin/bash) → bash(ELF) 은 깊이 2이므로 정상 동작합니다.
심볼 버저닝 (Symbol Versioning)
심볼 버저닝은 공유 라이브러리가 동일한 심볼의 여러 버전을 동시에 제공할 수 있게 하는 ELF 확장입니다. glibc는 이 메커니즘을 활용하여 하위 호환성을 유지하면서 ABI를 발전시킵니다. 버저닝 정보는 .gnu.version, .gnu.version_r, .gnu.version_d 섹션에 저장됩니다.
/* 심볼 버저닝 관련 ELF 구조체 */
/* .gnu.version — 심볼 버전 인덱스 테이블
* .dynsym의 각 심볼에 1:1 대응하는 16비트 버전 인덱스 */
typedef struct {
Elf64_Half vd_ndx; /* 버전 인덱스 (0=local, 1=global, 2+=정의된 버전) */
} Elf64_Versym;
/* .gnu.version_d — 라이브러리가 정의(제공)하는 버전 */
typedef struct {
Elf64_Half vd_version; /* 구조체 버전 (VER_DEF_CURRENT = 1) */
Elf64_Half vd_flags; /* VER_FLG_BASE: 기본 버전 */
Elf64_Half vd_ndx; /* 버전 인덱스 */
Elf64_Half vd_cnt; /* Verdaux 엔트리 수 */
Elf64_Word vd_hash; /* 버전 이름 ELF 해시 */
Elf64_Word vd_aux; /* Verdaux 오프셋 */
Elf64_Word vd_next; /* 다음 Verdef 오프셋 (0=마지막) */
} Elf64_Verdef;
/* .gnu.version_r — 바이너리가 요구(필요)하는 버전 */
typedef struct {
Elf64_Half vn_version; /* 구조체 버전 (VER_NEED_CURRENT = 1) */
Elf64_Half vn_cnt; /* Vernaux 엔트리 수 */
Elf64_Word vn_file; /* 라이브러리 이름 오프셋 (.dynstr) */
Elf64_Word vn_aux; /* Vernaux 오프셋 */
Elf64_Word vn_next; /* 다음 Verneed 오프셋 (0=마지막) */
} Elf64_Verneed;
/* Vernaux — 요구 버전의 상세 항목 */
typedef struct {
Elf64_Word vna_hash; /* 버전 이름 ELF 해시 */
Elf64_Half vna_flags; /* VER_FLG_WEAK: 약한 버전 참조 */
Elf64_Half vna_other; /* .gnu.version에서 사용하는 인덱스 */
Elf64_Word vna_name; /* 버전 문자열 오프셋 (예: "GLIBC_2.17") */
Elf64_Word vna_next; /* 다음 Vernaux 오프셋 (0=마지막) */
} Elf64_Vernaux;
glibc에서 심볼 버저닝이 동작하는 방식:
# .gnu.version 섹션 — 각 동적 심볼의 버전 인덱스
$ readelf -V /bin/ls
Version symbols section '.gnu.version' contains 120 entries:
Addr: 0x0000000000004ab8 Offset: 0x004ab8 Link: 6 (.dynsym)
000: 0 (*local*) 2 (GLIBC_2.3) 3 (GLIBC_2.14) 1 (*global*)
004: 4 (GLIBC_2.3.4) 2 (GLIBC_2.3) 2 (GLIBC_2.3) 0 (*local*)
# .gnu.version_r — 필요한 버전 목록
Version needs section '.gnu.version_r' contains 1 entry:
Addr: 0x0000000000004c48 Offset: 0x004c48 Link: 7 (.dynstr)
000000: Version: 1 File: libc.so.6 Cnt: 7
0x0010: Name: GLIBC_2.14 Flags: none Version: 3
0x0020: Name: GLIBC_2.3 Flags: none Version: 2
0x0030: Name: GLIBC_2.3.4 Flags: none Version: 4
0x0040: Name: GLIBC_2.17 Flags: none Version: 5
0x0050: Name: GLIBC_2.28 Flags: none Version: 6
0x0060: Name: GLIBC_2.34 Flags: none Version: 7
0x0070: Name: GLIBC_2.2.5 Flags: none Version: 8
# 특정 심볼의 버전 확인
$ objdump -T /bin/ls | grep memcpy
0000000000000000 DF *UND* 0000000000000000 GLIBC_2.14 memcpy
# glibc가 제공하는 memcpy 버전들 확인
$ objdump -T /lib/x86_64-linux-gnu/libc.so.6 | grep ' memcpy'
00000000000a7910 g DF .text 0000000000000039 GLIBC_2.2.5 memcpy
0000000000098fc0 g DF .text 0000000000000244 (GLIBC_2.14) memcpy
# 바이너리가 요구하는 최소 glibc 버전 확인
$ objdump -T /bin/ls | grep -oP 'GLIBC_\d+\.\d+(\.\d+)?' | sort -Vu | tail -1
GLIBC_2.34
ld.so의 버전 해석 과정: 동적 링커(ld-linux.so)는 심볼 해석 시 .gnu.version과 .gnu.version_r을 교차 참조합니다. 바이너리의 .gnu.version에 기록된 인덱스를 .gnu.version_r에서 찾아 버전 문자열(예: GLIBC_2.14)을 얻고, 라이브러리의 .gnu.version_d에서 해당 버전이 제공되는지 확인합니다. 버전이 일치하지 않으면 "version `GLIBC_2.34' not found" 에러를 출력합니다. 이 과정은 _dl_check_map_versions() (elf/dl-version.c)에서 수행됩니다.
커널 모듈의 심볼 버저닝 (CONFIG_MODVERSIONS): 커널 모듈은 glibc와 다른 자체 버저닝 메커니즘을 사용합니다. EXPORT_SYMBOL()로 내보낸 각 심볼에 대해 CRC32 체크섬을 계산하여 __kcrctab 섹션에 저장합니다. 모듈 로딩 시 check_version()이 모듈의 __versions 섹션에 기록된 CRC와 커널의 CRC를 비교하여, 함수 시그니처 변경으로 인한 ABI 불일치를 감지합니다. 이것이 "disagrees about version of symbol"/"Invalid module format" 에러의 원인입니다.
스택 언와인딩과 .eh_frame (Stack Unwinding)
스택 언와인딩(unwinding)은 현재 실행 지점에서 호출 스택을 역추적하는 과정으로, 예외 처리(C++ throw/catch), 디버거 백트레이스, 시그널 처리, 성능 프로파일링에 필수적입니다. ELF에서는 .eh_frame과 .eh_frame_hdr 섹션에 DWARF CFI(Call Frame Information) 형식으로 언와인딩 정보를 저장합니다.
| 섹션 | 타입 | 설명 |
|---|---|---|
.eh_frame | SHT_PROGBITS | CIE + FDE 레코드: 각 함수의 프레임 복원 규칙 |
.eh_frame_hdr | SHT_PROGBITS | FDE 이진 검색 테이블 — PT_GNU_EH_FRAME 세그먼트로 매핑 |
.debug_frame | SHT_PROGBITS | DWARF 디버그용 CFI (비할당, 스트립 시 제거) |
.gcc_except_table | SHT_PROGBITS | C++ 예외 처리 LSDA (Language Specific Data Area) |
/* DWARF CFI 핵심 구조: CIE (Common Information Entry) + FDE (Frame Description Entry) */
/* CIE — 공통 정보 엔트리: 함수군에 대한 공통 언와인딩 규칙 */
struct cie_record {
uint32_t length; /* CIE 크기 (0xffffffff이면 64비트 확장) */
uint32_t cie_id; /* 항상 0 (CIE 식별자) */
uint8_t version; /* CFI 버전 (1 또는 3) */
char augmentation[]; /* "zR", "zPLR" 등 확장 문자열 */
/* ULEB128 code_alignment_factor; 텍스트 정렬 단위 (x86_64: 1) */
/* SLEB128 data_alignment_factor; 데이터 정렬 단위 (x86_64: -8) */
/* ULEB128 return_address_register; RA 레지스터 번호 (x86_64: 16=RIP) */
/* initial_instructions[]; 초기 CFA 규칙 바이트코드 */
};
/* FDE — 프레임 기술 엔트리: 개별 함수의 언와인딩 규칙 */
struct fde_record {
uint32_t length; /* FDE 크기 */
uint32_t cie_pointer; /* 소속 CIE로의 오프셋 (≠0이면 FDE) */
/* pc_begin; 함수 시작 주소 (인코딩은 CIE augmentation에 따름) */
/* pc_range; 함수 크기 */
/* instructions[]; DW_CFA_* 바이트코드: 주소별 프레임 규칙 변화 */
};
CFI 바이트코드의 핵심 개념: 각 명령어 주소에서 CFA(Canonical Frame Address)와 레지스터 복원 규칙을 정의합니다. CFA는 일반적으로 이전 프레임의 스택 포인터 값입니다.
주요 DW_CFA 명령어와 의미
┌──────────────────────────┬──────────────────────────────────────────┐
│ 명령어 │ 의미 │
├──────────────────────────┼──────────────────────────────────────────┤
│ DW_CFA_def_cfa reg, off │ CFA = reg + off (프레임 기준 주소 정의) │
│ DW_CFA_def_cfa_offset n │ CFA의 오프셋을 n으로 변경 │
│ DW_CFA_offset reg, off │ reg은 CFA+off 위치에 저장됨 │
│ DW_CFA_advance_loc n │ 코드 위치를 n × code_align만큼 전진 │
│ DW_CFA_restore reg │ reg 복원 규칙을 CIE 초기 상태로 복원 │
│ DW_CFA_remember_state │ 현재 규칙 집합을 스택에 push │
│ DW_CFA_restore_state │ 스택에서 규칙 집합을 pop │
└──────────────────────────┴──────────────────────────────────────────┘
# .eh_frame의 CIE/FDE 레코드 확인
$ readelf --debug-dump=frames /bin/ls | head -40
Contents of the .eh_frame section:
00000000 0000000000000014 00000000 CIE
Version: 1
Augmentation: "zR"
Code alignment factor: 1
Data alignment factor: -8
Return address column: 16
Augmentation data: 1b # DW_EH_PE_pcrel|DW_EH_PE_sdata4
DW_CFA_def_cfa: r7 (rsp) ofs 8 # 초기: CFA = RSP + 8
DW_CFA_offset: r16 (rip) at cfa-8 # RIP는 CFA-8에 저장됨
00000018 0000000000000024 0000001c FDE cie=00000000 pc=0000c040..0000c0a2
DW_CFA_advance_loc: 4
DW_CFA_def_cfa_offset: 16 # push 이후: CFA = RSP + 16
DW_CFA_offset: r6 (rbp) at cfa-16 # RBP가 CFA-16에 저장됨
DW_CFA_advance_loc: 4
DW_CFA_def_cfa_register: r6 (rbp) # mov rbp,rsp 이후: CFA = RBP + 16
# .eh_frame_hdr — FDE 이진 검색 테이블
$ readelf --debug-dump=frames-interp /bin/ls 2>/dev/null | head -5
# PT_GNU_EH_FRAME 세그먼트 확인
$ readelf -l /bin/ls | grep EH_FRAME
GNU_EH_FRAME 0x000000000000e8e4 0x000000000000e8e4 ... R 0x4
# C++ 예외 테이블 확인
$ readelf -S /usr/bin/c++filt | grep except
[15] .gcc_except_table PROGBITS ...
커널 스택 언와인딩 — ORC vs DWARF:
| 특성 | DWARF CFI (.eh_frame) | ORC (Oops Rewind Capability) |
|---|---|---|
| 사용 환경 | 사용자 공간 (glibc, libunwind) | 커널 전용 (x86_64, 커널 4.14+) |
| 데이터 형식 | 가변 길이 바이트코드 (인터프리터 필요) | 고정 크기 6바이트 엔트리 (직접 조회) |
| 정확도 | 높음 (임의 레지스터 복원 가능) | 높음 (SP/BP/IP만 복원) |
| 크기 | 상대적으로 큼 | 작음 (~2-3% 커널 텍스트 크기) |
| 속도 | 상대적으로 느림 (바이트코드 해석) | 빠름 (테이블 직접 조회) |
| 생성 도구 | GCC/Clang (-fasynchronous-unwind-tables) | objtool (컴파일 후 오브젝트 분석) |
| 커널 설정 | CONFIG_UNWINDER_FRAME_POINTER (레거시) | CONFIG_UNWINDER_ORC (기본값) |
/* arch/x86/include/asm/orc_types.h — ORC 엔트리 구조 (6바이트) */
struct orc_entry {
s16 sp_offset; /* SP 복원 오프셋 */
s16 bp_offset; /* BP 복원 오프셋 */
unsigned sp_reg:4; /* SP 기준 레지스터 (SP, BP, SP_INDIRECT 등) */
unsigned bp_type:4; /* BP 상태 (undefined, prev_sp, regs 등) */
unsigned type:2; /* 프레임 타입 (CALL, REGS, REGS_PARTIAL) */
unsigned signal:1; /* 시그널 프레임 여부 */
unsigned end:1; /* 언와인딩 종료 지점 */
} __packed;
/* .orc_unwind + .orc_unwind_ip 섹션에 저장
* objtool이 정적 분석으로 각 명령어 주소의 프레임 상태를 사전 계산 */
/* arch/x86/kernel/unwind_orc.c — ORC 언와인더 핵심 루프 */
void __unwind_start(struct unwind_state *state,
struct task_struct *task, ...)
{
/* IP → ORC 엔트리 테이블 이진 검색 */
orc = orc_find(state->ip);
...
}
bool unwind_next_frame(struct unwind_state *state)
{
struct orc_entry *orc = orc_find(state->ip);
/* ORC 엔트리의 sp_reg/sp_offset으로 이전 SP 계산 */
switch (orc->sp_reg) {
case ORC_REG_SP:
sp = state->sp + orc->sp_offset; break;
case ORC_REG_BP:
sp = state->bp + orc->sp_offset; break;
...
}
/* IP = *(sp - 8): 리턴 주소로 이전 프레임의 IP 복원 */
state->ip = *((unsigned long *)sp - 1);
state->sp = sp;
return true;
}
-fomit-frame-pointer와 언와인딩: 현대 컴파일러는 기본적으로 -fomit-frame-pointer로 RBP 프레임 포인터를 최적화합니다(범용 레지스터로 활용). 이 경우 전통적인 RBP 체이닝 방식의 백트레이스가 불가능하므로, .eh_frame(사용자 공간) 또는 ORC(커널)의 테이블 기반 언와인딩이 필수적입니다. perf record --call-graph dwarf는 .eh_frame을 사용하고, perf record --call-graph fp는 프레임 포인터를 사용합니다. 프레임 포인터가 없는 바이너리에서 fp 모드는 잘못된 백트레이스를 생성합니다.
DWARF 디버그 정보 (Debug Information)
DWARF(Debugging With Attributed Record Formats)는 ELF 바이너리에 포함되는 디버그 정보의 표준 형식입니다. 소스 수준 디버깅(GDB, LLDB), 심볼 해석, 소스-라인 매핑에 사용되며, 컴파일러가 -g 옵션으로 생성합니다. 현재 널리 사용되는 버전은 DWARF 4(-gdwarf-4, GCC 기본)와 DWARF 5(-gdwarf-5, Clang 기본)입니다.
| 섹션 | DWARF 버전 | 설명 |
|---|---|---|
.debug_info | 2+ | 핵심 디버그 정보: DIE(Debugging Information Entry) 트리 — 함수, 변수, 타입, 스코프 |
.debug_abbrev | 2+ | DIE 태그/속성 약어 테이블 — .debug_info의 압축 인코딩을 해석하는 스키마 |
.debug_line | 2+ | 소스 파일명/줄 번호 ↔ 기계어 주소 매핑 (라인 넘버 프로그램) |
.debug_str | 2+ | 중복 제거된 문자열 풀 (변수명, 파일 경로 등) |
.debug_loc | 2-4 | 변수 위치 목록: PC 범위별로 변수가 레지스터/메모리 어디에 있는지 기술 |
.debug_loclists | 5 | .debug_loc의 DWARF 5 후속 — 더 컴팩트한 인코딩 |
.debug_ranges | 3-4 | 비연속 주소 범위 목록 (인라인 함수, 최적화된 코드용) |
.debug_rnglists | 5 | .debug_ranges의 DWARF 5 후속 |
.debug_aranges | 2+ | 주소 → 컴파일 유닛 빠른 조회 테이블 |
.debug_types | 4 | 타입 정보 전용 섹션 (DWARF 5에서 .debug_info에 통합) |
.debug_str_offsets | 5 | 문자열 오프셋 인덱싱 — 문자열 참조를 인덱스로 압축 |
.debug_line_str | 5 | 라인 테이블 전용 문자열 풀 |
/* DWARF DIE (Debugging Information Entry) 구조 개념
* 각 DIE는 태그(종류)와 속성(키-값) 목록으로 구성됨
* DIE는 트리 구조로 스코프를 표현 */
/* 예: 다음 C 코드의 DWARF DIE 표현 */
int add(int a, int b) { return a + b; }
/*
* DIE 트리:
* DW_TAG_compile_unit ← 컴파일 유닛 (소스 파일)
* DW_AT_name: "math.c"
* DW_AT_comp_dir: "/home/dev/project"
* DW_AT_language: DW_LANG_C11
* DW_AT_producer: "GCC 13.2.0"
* DW_AT_stmt_list: (→ .debug_line 오프셋)
*
* DW_TAG_base_type ← 기본 타입
* DW_AT_name: "int"
* DW_AT_byte_size: 4
* DW_AT_encoding: DW_ATE_signed
*
* DW_TAG_subprogram ← 함수
* DW_AT_name: "add"
* DW_AT_low_pc: 0x401000 ← 함수 시작 주소
* DW_AT_high_pc: 0x401020 ← 함수 끝 주소
* DW_AT_type: → (int) ← 반환 타입 참조
* DW_AT_frame_base: DW_OP_reg6(rbp)
*
* DW_TAG_formal_parameter ← 매개변수
* DW_AT_name: "a"
* DW_AT_type: → (int)
* DW_AT_location: DW_OP_fbreg(-20) ← RBP-20에 저장
*
* DW_TAG_formal_parameter
* DW_AT_name: "b"
* DW_AT_type: → (int)
* DW_AT_location: DW_OP_fbreg(-24)
*/
# DWARF 디버그 섹션 크기 확인 — 디버그 정보가 바이너리보다 큰 경우가 흔함
$ readelf -S /usr/lib/debug/usr/bin/ls.debug | grep debug
[28] .debug_aranges PROGBITS ... 000002d0 ...
[29] .debug_info PROGBITS ... 00032a41 ... # 200KB+
[30] .debug_abbrev PROGBITS ... 00001e25 ...
[31] .debug_line PROGBITS ... 0000d8a2 ...
[32] .debug_str PROGBITS ... 00005e12 ...
# DIE 트리 덤프 — 함수와 변수의 디버그 정보 확인
$ readelf --debug-dump=info a.out | head -50
Contents of the .debug_info section:
Compilation Unit @ offset 0x0:
Length: 0x120
Version: 5 # DWARF 5
Unit Type: DW_UT_compile
Abbrev Offset: 0x0
Pointer Size: 8
<0><c>: Abbrev Number: 1 (DW_TAG_compile_unit)
<d> DW_AT_producer : GNU C17 13.2.0
<22> DW_AT_language : 29 (C11)
<23> DW_AT_name : math.c
<2a> DW_AT_comp_dir : /home/dev/project
<1><3e>: Abbrev Number: 2 (DW_TAG_subprogram)
<3f> DW_AT_name : add
<43> DW_AT_low_pc : 0x401000
<4b> DW_AT_high_pc : 32
<4f> DW_AT_type : <0x70>
# 소스 줄 ↔ 기계어 주소 매핑
$ readelf --debug-dump=decodedline a.out
Decoded dump of debug contents of section .debug_line:
CU: math.c:
File name Line number Starting address View Stmt
math.c 1 0x401000 x
math.c 2 0x401004 x
math.c 2 0x40100a
math.c 2 0x401013 x
# addr2line — 주소를 소스 위치로 변환 (내부적으로 .debug_line 사용)
$ addr2line -e a.out -f 0x401004
add
/home/dev/project/math.c:2
# dwarfdump (libdwarf) — 더 상세한 DWARF 분석
$ dwarfdump --print-all a.out 2>/dev/null | head -20
Split DWARF와 debuginfod — 대규모 프로젝트의 디버그 정보 관리:
| 기술 | 설명 | 장점 |
|---|---|---|
Split DWARF (-gsplit-dwarf) | 디버그 정보를 .dwo 파일로 분리. 바이너리에는 .debug_addr과 스켈레톤 CU만 남김 | 링크 시간 단축(디버그 정보 병합 불필요), 릴리스 바이너리 크기 감소 |
DWARF 패키지 (dwp) | 여러 .dwo 파일을 단일 .dwp 패키지로 병합 | 배포/관리 편의성, GDB가 자동 탐색 |
| debuginfod | HTTP 서버로 .debug 파일을 온디맨드 제공. DEBUGINFOD_URLS 환경 변수 | 로컬 디버그 심볼 설치 불필요, GDB/perf 자동 다운로드 |
| GNU Build ID | .note.gnu.build-id — 바이너리의 고유 SHA1 식별자 | debuginfod에서 바이너리↔디버그 파일 매칭 키로 사용 |
.gnu_debuglink | 별도 디버그 파일(*.debug)의 경로와 CRC32 | 스트립된 바이너리에서 디버그 파일 위치 힌트 |
# Split DWARF 컴파일 — .dwo 파일 생성
$ gcc -g -gsplit-dwarf -c math.c -o math.o
$ ls math.*
math.c math.dwo math.o
# .dwo 파일 병합 (대규모 프로젝트)
$ dwp -e myapp -o myapp.dwp
# GNU Build ID 확인
$ readelf -n /bin/ls | grep 'Build ID'
Build ID: 2f31f68c55e02a7e7a72ad2758e04bd50c4e1e3a
# debuginfod 설정 (Fedora/Ubuntu/Arch 기본 제공)
$ export DEBUGINFOD_URLS="https://debuginfod.elfutils.org/"
$ gdb /bin/ls
Reading symbols from /bin/ls...
Downloading separate debug info for /bin/ls...
# 수동으로 별도 디버그 파일 설치 (Debian/Ubuntu)
$ sudo apt install coreutils-dbgsym
# → /usr/lib/debug/.build-id/2f/31f68c...debug
# 스트립 + debuglink 생성
$ objcopy --only-keep-debug myapp myapp.debug
$ strip --strip-debug myapp
$ objcopy --add-gnu-debuglink=myapp.debug myapp
커널의 디버그 정보 (vmlinux): 커널은 CONFIG_DEBUG_INFO로 DWARF 디버그 정보를 생성합니다. vmlinux에 포함된 디버그 정보는 수백 MB에 달할 수 있으며, crash dump 분석(crash 유틸리티), SystemTap, BPF CO-RE(BTF)의 기반이 됩니다. 커널 5.2+에서는 CONFIG_DEBUG_INFO_BTF로 DWARF에서 추출한 경량 타입 정보인 BTF(BPF Type Format)를 생성하여, BPF 프로그램이 커널 데이터 구조에 안전하게 접근할 수 있게 합니다. BTF는 DWARF의 1/100 이하 크기로 커널 이미지에 포함됩니다 (.BTF, .BTF.ext 섹션).
링커 스크립트 (Linker Script)
링커 스크립트는 링커(ld)에게 입력 섹션을 출력 세그먼트에 어떻게 배치할지 지시하는 설정 파일입니다. 사용자 공간 프로그램은 시스템 기본 링커 스크립트(ld --verbose)를 사용하지만, 커널(vmlinux.lds.S)과 부트로더, 펌웨어, 베어메탈 프로그램은 맞춤 링커 스크립트가 필수적입니다.
/* 기본 링커 스크립트 구조 — 섹션에서 세그먼트로의 매핑 */
/* ENTRY: 프로그램 진입점 (ELF e_entry에 설정) */
ENTRY(_start)
/* PHDRS: 출력 세그먼트(프로그램 헤더) 정의 */
PHDRS
{
headers PT_PHDR PHDRS; /* 프로그램 헤더 자체 */
interp PT_INTERP; /* 인터프리터 경로 */
text PT_LOAD FLAGS(5); /* R-X: 코드 세그먼트 */
rodata PT_LOAD FLAGS(4); /* R--: 읽기 전용 데이터 */
data PT_LOAD FLAGS(6); /* RW-: 데이터 세그먼트 */
dynamic PT_DYNAMIC FLAGS(6); /* 동적 링킹 정보 */
note PT_NOTE FLAGS(4); /* 빌드 ID 등 메타데이터 */
eh_frame PT_GNU_EH_FRAME FLAGS(4); /* .eh_frame_hdr */
stack PT_GNU_STACK FLAGS(6); /* 스택 속성 (NX) */
relro PT_GNU_RELRO FLAGS(4); /* RELRO 영역 */
}
/* SECTIONS: 입력 섹션을 출력 섹션에 배치하고, 세그먼트에 할당 */
SECTIONS
{
/* 코드 영역 — text 세그먼트 */
.text : {
*(.text .text.*) /* 모든 입력의 .text 섹션 */
*(.text.hot .text.hot.*) /* 핫 코드 (PGO) */
} :text /* → text 세그먼트에 배치 */
/* 읽기 전용 데이터 — rodata 세그먼트 */
.rodata : {
*(.rodata .rodata.*)
} :rodata
/* 동적 링킹 — data 세그먼트 */
.dynamic : { *(.dynamic) } :data :dynamic
/* GOT/PLT — RELRO 보호 대상 */
.got : { *(.got) } :data :relro
.got.plt : { *(.got.plt) }
/* 초기화된 데이터 */
.data : { *(.data .data.*) } :data
/* BSS — 파일에 저장되지 않음 (p_memsz > p_filesz) */
.bss : { *(.bss .bss.*) *(COMMON) } :data
/* PROVIDE: 심볼이 정의되지 않았을 때만 제공 */
PROVIDE(_end = .);
PROVIDE(__bss_start = ADDR(.bss));
}
리눅스 커널의 링커 스크립트 (vmlinux.lds.S): 커널은 아키텍처별 링커 스크립트 템플릿(arch/x86/kernel/vmlinux.lds.S)을 C 전처리기로 처리하여 최종 vmlinux.lds를 생성합니다. 커널만의 특수한 섹션 배치가 정의됩니다.
/* arch/x86/kernel/vmlinux.lds.S — 커널 링커 스크립트 (핵심 발췌) */
#include <asm-generic/vmlinux.lds.h>
SECTIONS
{
. = __START_KERNEL; /* x86_64: 0xffffffff81000000 */
.text : AT(ADDR(.text) - LOAD_OFFSET) {
_text = .;
_stext = .;
/* HEAD_TEXT: startup_64 등 부팅 초기 코드 */
HEAD_TEXT
/* TEXT_TEXT: 일반 커널 코드 */
TEXT_TEXT
...
_etext = .;
}
/* .init 영역: 부팅 후 해제되는 코드/데이터 */
.init.text : AT(ADDR(.init.text) - LOAD_OFFSET) {
_sinittext = .;
INIT_TEXT /* __init 매크로 함수들 */
_einittext = .;
}
.init.data : AT(ADDR(.init.data) - LOAD_OFFSET) {
INIT_DATA /* __initdata 변수들 */
}
/* 커널 특수 섹션 */
.altinstructions : {
__alt_instructions = .;
*(.altinstructions) /* CPU 기능별 대체 명령어 */
__alt_instructions_end = .;
}
__ex_table : {
__start___ex_table = .;
*(__ex_table) /* 예외 테이블 */
__stop___ex_table = .;
}
/* ORC 언와인더 데이터 */
.orc_unwind_ip : {
__start_orc_unwind_ip = .;
*(.orc_unwind_ip)
__stop_orc_unwind_ip = .;
}
.orc_unwind : {
__start_orc_unwind = .;
*(.orc_unwind)
__stop_orc_unwind = .;
}
/* __start/__stop 심볼: 링커가 자동 생성하는 섹션 경계 포인터 */
/* 커널 코드에서 for_each 매크로로 섹션 내용을 순회할 때 사용 */
}
# 시스템 기본 링커 스크립트 확인
$ ld --verbose 2>/dev/null | grep -A3 'SECTIONS'
SECTIONS
{
PROVIDE (__executable_start = SEGMENT_START("text-segment", 0x400000));
. = SEGMENT_START("text-segment", 0x400000) + SIZEOF_HEADERS;
# 커널 빌드에서 생성된 실제 링커 스크립트
$ head -20 /usr/src/linux/vmlinux.lds
# 또는 빌드 디렉토리에서:
$ head -20 /lib/modules/$(uname -r)/build/vmlinux.lds
# 커스텀 링커 스크립트 사용 (임베디드/베어메탈)
$ gcc -T custom.ld -nostdlib -o firmware startup.o main.o
# 섹션→세그먼트 매핑 확인
$ readelf -l /bin/ls | head -30
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
PHDR 0x000040 0x0000000000000040 0x0000000000000040 0x0002d8 0x0002d8 R 0x8
INTERP 0x000318 0x0000000000000318 0x0000000000000318 0x00001c 0x00001c R 0x1
LOAD 0x000000 0x0000000000000000 0x0000000000000000 0x003a58 0x003a58 R 0x1000
LOAD 0x004000 0x0000000000004000 0x0000000000004000 0x013581 0x013581 R E 0x1000
LOAD 0x018000 0x0000000000018000 0x0000000000018000 0x007b10 0x007b10 R 0x1000
LOAD 0x020000 0x0000000000020000 0x0000000000020000 0x001258 0x002560 RW 0x1000
...
Section to Segment mapping:
Segment Sections...
00
01 .interp
02 .interp .note.gnu.property .note.gnu.build-id .note.ABI-tag .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt
03 .init .plt .plt.got .plt.sec .text .fini
04 .rodata .eh_frame_hdr .eh_frame
05 .init_array .fini_array .data.rel.ro .dynamic .got .data .bss
__start/__stop 매직 심볼과 커널 섹션 순회: GNU ld는 __start_SECTION과 __stop_SECTION 심볼을 자동 생성합니다(섹션 이름이 C 식별자 규칙을 만족하는 경우). 커널은 이 메커니즘을 광범위하게 활용합니다. 예를 들어 __start___ex_table부터 __stop___ex_table까지 순회하면 모든 예외 테이블 엔트리에 접근할 수 있습니다. module_init()/module_exit() 매크로가 함수 포인터를 .initcall*.init 섹션에 배치하고, 커널이 부팅 시 __initcall_start부터 순회하며 드라이버 초기화를 실행하는 것도 같은 원리입니다.
스케줄러 (Scheduler)
Linux 커널의 프로세스 스케줄러는 어떤 태스크에 CPU 시간을 할당할지 결정합니다. 스케줄러의 핵심 진입점은 schedule() 함수이며, __schedule()에서 다음 실행할 태스크를 선택합니다.
CFS - Completely Fair Scheduler
CFS는 Linux 2.6.23에서 도입된 일반(normal) 프로세스용 스케줄러로, "이상적인 공정한 멀티태스킹"을 목표로 합니다. 각 태스크에 가중치에 비례하는 CPU 시간을 할당하며, Red-Black 트리를 사용하여 O(log N) 시간에 다음 실행 태스크를 선택합니다.
CFS 동작 원리 (How CFS Works)
CFS의 핵심 개념은 vruntime(가상 실행 시간)입니다. 각 태스크가 실제로 실행된 시간을 가중치로 정규화하여 vruntime을 계산합니다. 스케줄러는 항상 vruntime이 가장 작은(가장 "불공정하게" CPU를 적게 받은) 태스크를 다음에 실행합니다.
/* kernel/sched/fair.c - vruntime 갱신 (간략화) */
static void update_curr(struct cfs_rq *cfs_rq)
{
struct sched_entity *curr = cfs_rq->curr;
u64 now = rq_clock_task(rq_of(cfs_rq));
u64 delta_exec;
delta_exec = now - curr->exec_start;
curr->exec_start = now;
curr->sum_exec_runtime += delta_exec;
/* vruntime = delta_exec * (NICE_0_LOAD / weight) */
curr->vruntime += calc_delta_fair(delta_exec, curr);
update_min_vruntime(cfs_rq);
}
EEVDF 스케줄러 (Earliest Eligible Virtual Deadline First)
Linux 6.6에서 CFS를 대체하여 기본 스케줄러로 채택된 EEVDF는 각 태스크에 가상 데드라인(virtual deadline)을 부여합니다. 태스크가 요청한 타임 슬라이스(request)와 현재 vruntime을 기반으로 데드라인을 계산하며, "적격(eligible)" 조건을 만족하는 태스크 중 가장 이른 데드라인을 가진 태스크를 선택합니다.
EEVDF의 주요 특징:
- 지연 시간(latency) 개선: 기존 CFS의
sched_latency/sched_min_granularity휴리스틱을 제거하고, 데드라인 기반으로 더 예측 가능한 지연 시간을 제공합니다. - Lag 기반 판단: 태스크의 "lag"(이상적 실행 시간과 실제 실행 시간의 차이)를 계산하여 적격 여부를 판단합니다.
- nice 값 반영: 가중치에 따라 request 크기가 달라져, nice 값이 낮을수록 더 큰 타임 슬라이스를 받습니다.
/* EEVDF: virtual deadline 계산 (개념적) */
/* deadline = vruntime + (request / weight) * NICE_0_LOAD */
struct sched_entity {
u64 vruntime; /* 가상 실행 시간 */
u64 deadline; /* 가상 데드라인 */
u64 min_vruntime; /* 최소 vruntime */
s64 vlag; /* lag = ideal_runtime - actual_runtime */
u64 slice; /* 요청 타임 슬라이스 */
...
};
CFS vs EEVDF: CFS에서는 RB-tree의 가장 왼쪽 노드(최소 vruntime)를 선택했지만, EEVDF에서는 RB-tree를 deadline 순으로 관리하되, lag가 0 이상인(적격 조건) 태스크 중에서 가장 이른 deadline을 가진 태스크를 선택합니다. 이를 통해 latency-sensitive 워크로드의 응답성이 개선됩니다.
스케줄링 클래스 (Scheduling Classes)
Linux 스케줄러는 모듈화된 설계를 가지고 있으며, 여러 스케줄링 클래스가 우선순위 체인으로 연결되어 있습니다. 높은 우선순위의 클래스가 먼저 검사되며, 해당 클래스에 실행 가능한 태스크가 있으면 그 태스크를 선택합니다.
| 우선순위 | 스케줄링 클래스 | 정책 | 설명 |
|---|---|---|---|
| 1 (최고) | stop_sched_class |
- | CPU hotplug, migration 등 특수 용도. 가장 높은 우선순위 |
| 2 | dl_sched_class |
SCHED_DEADLINE | Earliest Deadline First. 주기, 런타임, 데드라인을 명시적으로 지정 |
| 3 | rt_sched_class |
SCHED_FIFO, SCHED_RR | 실시간 스케줄링. 우선순위 기반 선점형 |
| 4 | fair_sched_class |
SCHED_NORMAL, SCHED_BATCH | CFS/EEVDF. 일반 프로세스용. nice 값 -20 ~ +19 |
| 5 (최저) | idle_sched_class |
SCHED_IDLE | 시스템이 완전히 유휴 상태일 때만 실행 |
/* kernel/sched/sched.h - 스케줄링 클래스 체인 */
/* 우선순위: stop > dl > rt > fair > idle */
#define sched_class_highest (&stop_sched_class)
#define for_each_class(class) \
for (class = sched_class_highest; \
class != &idle_sched_class; \
class = class->next)
/* 다음 실행 태스크 선택 - __schedule() 내부 */
struct task_struct *pick_next_task(struct rq *rq)
{
const struct sched_class *class;
for_each_class(class) {
struct task_struct *p = class->pick_next_task(rq);
if (p)
return p;
}
/* idle 태스크 반환 */
return idle_sched_class.pick_next_task(rq);
}
런큐(runqueue)와 vruntime 계산
각 CPU마다 하나의 struct rq(런큐)가 존재하며, 이 안에 각 스케줄링 클래스별 서브 런큐가 포함됩니다. CFS/EEVDF의 경우 struct cfs_rq가 Red-Black 트리로 실행 가능한 태스크를 관리합니다.
/* 런큐 구조체 핵심 필드 */
struct rq {
unsigned int nr_running; /* 이 CPU의 총 실행 가능 태스크 수 */
struct cfs_rq cfs; /* CFS/EEVDF 런큐 */
struct rt_rq rt; /* 실시간 런큐 */
struct dl_rq dl; /* Deadline 런큐 */
struct task_struct *curr; /* 현재 실행 중인 태스크 */
struct task_struct *idle; /* idle 태스크 */
u64 clock; /* 런큐 클럭 */
};
struct cfs_rq {
struct load_weight load; /* 런큐 총 가중치 */
unsigned int nr_running; /* CFS 실행 가능 태스크 수 */
u64 min_vruntime; /* 최소 vruntime (새 태스크 기준점) */
struct rb_root_cached tasks_timeline; /* RB-tree */
};
vruntime 계산 공식:
vruntime += delta_exec * (NICE_0_LOAD / se->load.weight)NICE_0_LOAD는 nice 0에 대응하는 가중치입니다. nice 값이 낮을수록 weight가 커져서 vruntime 증가량이 줄어들고, 결과적으로 더 많은 CPU 시간을 할당받게 됩니다. nice 1 단계 차이는 약 1.25배의 CPU 시간 비율 차이를 만듭니다.
schedule() 호출 경로와 선점 (Preemption)
스케줄러의 핵심 진입점인 schedule()은 내부적으로 __schedule()을 호출합니다. 이 함수는 현재 CPU의 런큐에서 다음 실행할 태스크를 선택하고, 필요 시 컨텍스트 스위칭을 수행합니다.
/* kernel/sched/core.c - __schedule() 핵심 흐름 (간략화) */
static void __schedule(unsigned int sched_mode)
{
struct task_struct *prev, *next;
struct rq *rq;
int cpu;
cpu = smp_processor_id();
rq = cpu_rq(cpu);
prev = rq->curr;
/* 1. 현재 태스크 상태 갱신 */
if (sched_mode == SM_NONE) {
/* 자발적 선점이 아니면 이전 태스크 dequeue */
if (!signal_pending_state(prev->__state, prev))
deactivate_task(rq, prev, DEQUEUE_SLEEP);
}
/* 2. 다음 실행 태스크 선택 */
next = pick_next_task(rq, prev, &rf);
/* 3. TIF_NEED_RESCHED 플래그 클리어 */
clear_tsk_need_resched(prev);
/* 4. 컨텍스트 스위칭 (prev != next 인 경우) */
if (likely(prev != next)) {
rq->nr_switches++;
rq->curr = next;
rq = context_switch(rq, prev, next, &rf);
}
}
TIF_NEED_RESCHED 플래그
커널은 즉시 스케줄링을 수행하지 않고, TIF_NEED_RESCHED 플래그를 설정하여 "재스케줄링이 필요하다"는 신호를 남깁니다. 이 플래그는 다음 시점에 검사됩니다:
- 시스템 콜 복귀 시: 유저 공간으로 돌아가기 전 검사
- 인터럽트 복귀 시: 인터럽트 핸들러 완료 후 검사
- 선점 가능 구간:
preempt_count가 0이 되는 시점 (PREEMPT설정 시)
플래그가 설정되는 주요 시점:
- 타이머 tick:
scheduler_tick()→task_tick()에서 현재 태스크의 타임 슬라이스 소진 여부 확인 - 태스크 wakeup:
try_to_wake_up()에서 깨어난 태스크가 현재 태스크보다 우선순위가 높으면 설정 - nice 값 변경: 우선순위 변경으로 재스케줄링이 필요한 경우
선점 모델 (Preemption Model)
Linux 커널은 4가지 선점 모델(PREEMPT_NONE, PREEMPT_VOLUNTARY, PREEMPT, PREEMPT_RT)을 지원하며, 커널 코드 실행 중 스케줄링이 허용되는 범위가 달라집니다. preempt_count 메커니즘을 통해 선점 비활성화 상태를 추적하며, 이 값이 0이 아니면 커널 선점이 불가합니다. 각 모델의 상세 비교, 자발적 선점 포인트(cond_resched(), might_sleep() 등), preempt_count 비트 필드 구조, CONFIG_PREEMPT_DYNAMIC(런타임 전환), PREEMPT_LAZY(커널 6.12+), PREEMPT_RT 심화 내용은 프로세스 스케줄러 — 선점 모델을 참고하세요.
sched_class 인터페이스
각 스케줄링 클래스는 struct sched_class 인터페이스를 구현하여 스케줄러 코어와 상호작용합니다. 이 구조체는 함수 포인터 테이블로, 태스크의 큐 삽입/제거, 다음 태스크 선택, 선점 검사 등의 콜백을 정의합니다.
/* kernel/sched/sched.h - struct sched_class 핵심 콜백 */
struct sched_class {
/* 태스크를 런큐에 삽입 (wakeup, fork 등) */
void (*enqueue_task)(struct rq *rq, struct task_struct *p, int flags);
/* 태스크를 런큐에서 제거 (sleep, exit 등) */
void (*dequeue_task)(struct rq *rq, struct task_struct *p, int flags);
/* 새 태스크가 wakeup될 때 현재 태스크를 선점할지 확인 */
void (*check_preempt_curr)(struct rq *rq, struct task_struct *p, int flags);
/* 다음 실행할 태스크 선택 */
struct task_struct *(*pick_next_task)(struct rq *rq);
/* 현재 태스크를 내려놓기 (다른 태스크로 전환 전) */
void (*put_prev_task)(struct rq *rq, struct task_struct *p);
/* 다음 태스크로 설정 (pick 후 context_switch 직전) */
void (*set_next_task)(struct rq *rq, struct task_struct *p, bool first);
/* 타이머 tick마다 호출 — 타임슬라이스 관리 */
void (*task_tick)(struct rq *rq, struct task_struct *p, int queued);
/* fork 시 태스크를 배치할 CPU 선택 */
int (*select_task_rq)(struct task_struct *p, int prev_cpu,
int sd_flag, int wake_flags);
/* 태스크 마이그레이션 콜백 */
void (*migrate_task_rq)(struct task_struct *p, int new_cpu);
/* 태스크의 우선순위/정책 변경 시 */
void (*prio_changed)(struct rq *rq, struct task_struct *p, int oldprio);
/* 스케줄링 클래스 간 전환 시 */
void (*switched_to)(struct rq *rq, struct task_struct *p);
};
각 스케줄링 클래스별 주요 구현 특성:
| 콜백 | fair_sched_class | rt_sched_class | dl_sched_class |
|---|---|---|---|
enqueue_task |
RB-tree에 vruntime 기준 삽입 | 우선순위별 연결 리스트에 삽입 | RB-tree에 deadline 기준 삽입 |
pick_next_task |
적격 태스크 중 earliest deadline (EEVDF) | 가장 높은 우선순위의 첫 번째 태스크 | 가장 이른 absolute deadline |
task_tick |
vruntime 갱신, 재스케줄 여부 확인 | SCHED_RR인 경우 타임슬라이스 감소 | runtime 소진 확인, throttle 처리 |
select_task_rq |
wake affine + LLC 내 idle CPU 탐색 | 가장 낮은 우선순위 CPU 탐색 | deadline feasibility 기반 CPU 선택 |
실시간(RT) 스케줄링 상세
RT 스케줄링 클래스(rt_sched_class)는 SCHED_FIFO와 SCHED_RR 두 가지 정책을 제공합니다. 모두 고정 우선순위(1~99) 기반이며, 일반 태스크(SCHED_NORMAL)보다 항상 먼저 실행됩니다.
| 특성 | SCHED_FIFO | SCHED_RR |
|---|---|---|
| 타임슬라이스 | 없음 (무한) | 있음 (기본 100ms, sched_rr_timeslice_ms) |
| 같은 우선순위 태스크 간 | 먼저 실행된 태스크가 자발적으로 양보할 때까지 계속 실행 | 라운드 로빈 — 타임슬라이스 소진 시 같은 우선순위 큐의 끝으로 이동 |
| 선점 | 더 높은 우선순위의 RT 태스크가 깨어나면 즉시 선점 | |
| 용도 | 이벤트 기반 실시간 처리 | 여러 동등한 RT 태스크 간 공정한 분배 |
/* kernel/sched/rt.c - RT 런큐 구조 */
struct rt_prio_array {
DECLARE_BITMAP(bitmap, MAX_RT_PRIO+1); /* 비트맵: O(1) 최고 우선순위 탐색 */
struct list_head queue[MAX_RT_PRIO]; /* 우선순위별 연결 리스트 */
};
struct rt_rq {
struct rt_prio_array active;
unsigned int rt_nr_running;
int highest_prio; /* 캐시된 최고 우선순위 */
u64 rt_time; /* 이 RT 런큐의 누적 실행 시간 */
u64 rt_runtime; /* 허용 runtime (throttling) */
};
RT Throttling
RT 태스크가 CPU를 독점하여 일반 태스크가 기아(starvation) 상태에 빠지는 것을 방지하기 위해 RT throttling 메커니즘이 존재합니다:
/proc/sys/kernel/sched_rt_runtime_us: 주기 내 RT 태스크 최대 실행 시간 (기본: 950000 = 950ms)/proc/sys/kernel/sched_rt_period_us: RT throttling 주기 (기본: 1000000 = 1s)
기본 설정은 1초 주기 중 950ms까지 RT 태스크가 실행할 수 있고, 나머지 50ms는 일반 태스크에 보장됩니다. RT 태스크가 할당량을 소진하면 해당 CPU의 RT 런큐가 throttle되어, 할당량이 리필될 때까지 일반 태스크가 실행됩니다.
Priority Inversion과 PI (Priority Inheritance)
우선순위 역전(Priority Inversion)은 높은 우선순위 태스크가 낮은 우선순위 태스크가 보유한 lock을 기다리는 동안, 중간 우선순위 태스크가 CPU를 점유하여 결과적으로 높은 우선순위 태스크가 무한히 지연되는 현상입니다.
Linux 커널은 PI(Priority Inheritance)로 이를 해결합니다:
/* RT mutex의 Priority Inheritance 동작 */
/*
* 태스크 A (우선순위 99): mutex M을 기다림
* 태스크 B (우선순위 1): mutex M 보유 중
* 태스크 C (우선순위 50): CPU 사용 중
*
* PI 없이: B(1)는 C(50)에게 선점당해 M을 해제 못함 → A(99) 무한 대기
* PI 적용: B가 M을 보유하는 동안 A의 우선순위(99)를 일시 상속
* → B(99)가 C(50)를 선점하여 빠르게 M 해제 → A 실행
*/
struct rt_mutex {
struct rb_root_cached waiters; /* 대기자 RB-tree (우선순위 순) */
struct task_struct *owner; /* lock 보유자 */
};
/* PI chain을 따라 우선순위를 전파 */
int rt_mutex_adjust_prio_chain(struct task_struct *task, ...);
PI futex: 유저 공간에서도 PTHREAD_PRIO_INHERIT 속성의 pthread mutex를 사용하면 커널의 futex(FUTEX_LOCK_PI)를 통해 Priority Inheritance가 동작합니다. 실시간 애플리케이션에서는 반드시 PI mutex를 사용해야 합니다.
SCHED_DEADLINE 상세
SCHED_DEADLINE은 Linux 3.14에서 도입된 스케줄링 정책으로, 태스크에 명시적인 runtime, deadline, period 세 가지 파라미터를 지정합니다. CBS(Constant Bandwidth Server) 알고리즘을 기반으로 하며, 모든 스케줄링 클래스 중 가장 높은 우선순위(stop 제외)를 갖습니다.
각 파라미터의 관계와 의미:
- runtime (Q): 매 period마다 태스크에 보장되는 CPU 실행 시간
- deadline (D): period 시작으로부터 runtime을 소진해야 하는 기한 (D ≤ P)
- period (P): 태스크의 반복 주기. 새 period 시작 시 runtime이 리필됨
- 관계 제약:
runtime ≤ deadline ≤ period
Admission Control
DEADLINE 태스크 추가 시 커널은 admission control을 수행합니다. 시스템 전체의 총 bandwidth가 이용 가능한 CPU 수를 초과하면 sched_setattr()이 -EBUSY로 실패합니다:
/* Admission control 조건 */
/* 각 DEADLINE 태스크의 bandwidth = runtime / period */
/* 총 bandwidth 합계 <= 전체 CPU 수 (또는 cpuset 내 CPU 수) */
/* Σ (runtime_i / period_i) <= M (M = 사용 가능 CPU 수) */
/* 사용자 공간에서 SCHED_DEADLINE 설정 */
struct sched_attr attr = {
.size = sizeof(attr),
.sched_policy = SCHED_DEADLINE,
.sched_runtime = 10 * 1000 * 1000, /* 10ms runtime */
.sched_deadline = 30 * 1000 * 1000, /* 30ms deadline */
.sched_period = 100 * 1000 * 1000, /* 100ms period */
};
/* bandwidth = 10ms / 100ms = 0.1 → CPU의 10% 사용 */
sched_setattr(pid, &attr, 0);
Deadline Miss 처리
DEADLINE 태스크가 runtime을 소진하면 throttle되어 다음 period까지 실행이 중단됩니다. CBS 알고리즘에 의해 absolute deadline이 자동으로 재계산되므로, 일시적인 burst를 수용하면서도 다른 태스크의 보장을 침해하지 않습니다.
CPU Affinity와 격리
CPU affinity는 특정 태스크가 실행될 수 있는 CPU 집합을 제한하는 메커니즘입니다. 각 태스크는 cpus_allowed 비트마스크를 갖고 있으며, 스케줄러는 이 마스크에 포함된 CPU에서만 해당 태스크를 실행합니다.
/* 커널 내부: CPU affinity 설정 */
struct task_struct {
cpumask_t cpus_mask; /* 허용된 CPU 비트마스크 */
const cpumask_t *cpus_ptr; /* 실제 사용 마스크 (cpus_mask 가리킴) */
int nr_cpus_allowed; /* 허용 CPU 개수 (최적화) */
...
};
/* 시스템 콜을 통한 affinity 설정 */
long sched_setaffinity(pid_t pid, const struct cpumask *new_mask)
{
struct task_struct *p = find_process_by_pid(pid);
/* cpuset cgroup 제약과 교집합 */
cpumask_and(&allowed, new_mask, task_cpu_possible_mask(p));
/* 빈 집합이면 실패 */
if (cpumask_empty(&allowed))
return -EINVAL;
/* 마스크 설정 및 필요 시 마이그레이션 */
__set_cpus_allowed_ptr(p, &allowed, 0);
}
CPU 격리 방법
| 방법 | 범위 | 설명 |
|---|---|---|
sched_setaffinity() |
개별 태스크 | 특정 태스크를 지정 CPU에 고정. 유저 공간에서 taskset 명령으로도 사용 가능 |
isolcpus= |
부트 파라미터 | 지정 CPU를 일반 스케줄링 도메인에서 제외. 명시적 affinity 설정 태스크만 실행 |
cpuset cgroup |
프로세스 그룹 | cgroup 내 태스크가 사용할 수 있는 CPU/메모리 노드를 제한 |
nohz_full= |
부트 파라미터 | 지정 CPU에서 타이머 tick을 제거 (adaptive-ticks). 단일 태스크 실행 시 tick 인터럽트 없음 |
irqaffinity= |
부트 파라미터 | IRQ 처리에 사용할 CPU 지정. 격리 CPU에서 IRQ를 제거 |
# 유저 공간에서 CPU affinity / 격리 활용 예
# 1. taskset: 프로세스를 CPU 2,3에 고정
taskset -c 2,3 ./my_realtime_app
# 2. isolcpus: 부트 커맨드라인에서 CPU 2,3을 격리
# GRUB: isolcpus=2,3 nohz_full=2,3 rcu_nocbs=2,3
# 3. cpuset cgroup v2로 격리
echo "+cpuset" > /sys/fs/cgroup/cgroup.subtree_control
mkdir /sys/fs/cgroup/rt_group
echo "2-3" > /sys/fs/cgroup/rt_group/cpuset.cpus
echo "0" > /sys/fs/cgroup/rt_group/cpuset.mems
echo $$ > /sys/fs/cgroup/rt_group/cgroup.procs
실시간 격리 조합: 실시간 워크로드를 위한 이상적인 CPU 격리는 isolcpus + nohz_full + rcu_nocbs의 조합입니다. 이렇게 하면 지정 CPU에서 스케줄러 로드 밸런싱, 타이머 tick, RCU 콜백이 모두 제거되어 최소 지연 시간을 달성할 수 있습니다.
로드 밸런싱과 스케줄링 도메인
SMP 시스템에서 각 CPU는 독립적인 런큐를 갖고 있어, CPU 간 부하 불균형이 발생할 수 있습니다. 커널의 로드 밸런서는 주기적으로 또는 CPU가 유휴 상태가 될 때 태스크를 바쁜 CPU에서 한가한 CPU로 마이그레이션합니다.
스케줄링 도메인 계층
로드 밸런싱은 스케줄링 도메인(sched_domain) 계층에서 수행됩니다. 하드웨어 토폴로지를 반영하며, 하위 도메인에서 먼저 밸런싱을 시도하고 필요 시 상위 도메인으로 확장합니다.
| 도메인 레벨 | 플래그 | 범위 | 밸런싱 주기 |
|---|---|---|---|
| SD_MC | SD_SHARE_PKG_RESOURCES |
같은 LLC(Last-Level Cache) 공유 코어 | 짧음 (~4ms) |
| SD_DIE | SD_SHARE_PKG_RESOURCES |
같은 소켓(다이) 내 모든 코어 | 중간 (~16ms) |
| SD_NUMA | SD_NUMA |
NUMA 노드 간 | 길음 (~64ms) |
로드 밸런싱 동작
로드 밸런싱은 다음 세 가지 경로로 트리거됩니다:
- 주기적 밸런싱 (
scheduler_tick()→trigger_load_balance()): 각 도메인의 밸런싱 주기마다load_balance()를 호출하여 가장 바쁜 그룹에서 태스크를 당겨옴(pull migration) - Idle 밸런싱 (
newidle_balance()): CPU가 유휴 상태가 되려 할 때 다른 CPU에서 태스크를 가져옴. 가장 빈번하고 효과적 - Fork/exec 밸런싱 (
select_task_rq()): 새 태스크 생성이나 exec 시 최적 CPU를 선택
/* kernel/sched/fair.c - 로드 밸런싱 핵심 흐름 (간략화) */
static int load_balance(int this_cpu, struct rq *this_rq,
struct sched_domain *sd,
enum cpu_idle_type idle)
{
struct sched_group *busiest;
struct rq *busiest_rq;
/* 1. 도메인 내에서 가장 바쁜 그룹 찾기 */
busiest = find_busiest_group(sd, this_cpu, idle);
if (!busiest)
return 0; /* 밸런싱 불필요 */
/* 2. 그 그룹에서 가장 바쁜 런큐 찾기 */
busiest_rq = find_busiest_queue(sd, busiest);
/* 3. 태스크 마이그레이션 (pull) */
detach_tasks(&env); /* busiest에서 태스크 분리 */
attach_tasks(&env); /* this_rq에 태스크 부착 */
}
Wake Affine
태스크가 깨어날 때, 스케줄러는 wake affine 휴리스틱을 사용하여 깨우는 태스크(waker)와 같은 LLC 내의 유휴 CPU를 찾으려 합니다. 이는 캐시 공유의 이점을 활용하고 NUMA 간 마이그레이션의 비용을 줄입니다.
NUMA Balancing
NUMA 시스템에서 커널은 Automatic NUMA Placement를 수행합니다. 태스크의 메모리 접근 패턴을 관찰하여, 자주 접근하는 메모리가 있는 NUMA 노드의 CPU로 태스크를 마이그레이션합니다:
- 주기적으로 태스크의 페이지 테이블 엔트리를
PROT_NONE으로 변경 (NUMA hinting fault 유발) - 페이지 폴트 발생 시 해당 페이지가 어떤 NUMA 노드에 있는지, 태스크가 어떤 노드에서 실행 중인지 기록
- 원격 접근이 빈번하면 태스크를 해당 메모리의 노드로 마이그레이션하거나, 메모리를 태스크가 있는 노드로 마이그레이션
그룹 스케줄링과 Bandwidth Control
그룹 스케줄링은 여러 태스크를 그룹으로 묶어 그룹 단위로 CPU 자원을 공정하게 분배하는 메커니즘입니다. cgroup의 CPU controller와 연동되며, 컨테이너 환경에서 CPU 자원 격리의 핵심 기술입니다.
CONFIG_FAIR_GROUP_SCHED
이 옵션이 활성화되면, CFS는 태스크뿐만 아니라 그룹 스케줄링 엔티티(group sched_entity)도 관리합니다. 각 cgroup은 자체 cfs_rq를 갖고, 상위 cgroup의 CFS 런큐에서 하나의 엔티티로 참여합니다.
/* 그룹 스케줄링 구조 (간략화) */
struct task_group {
struct sched_entity **se; /* per-CPU 스케줄링 엔티티 */
struct cfs_rq **cfs_rq; /* per-CPU 그룹 CFS 런큐 */
unsigned long shares; /* 그룹 가중치 (cpu.weight) */
struct cfs_bandwidth cfs_bandwidth; /* bandwidth 제한 */
};
/*
* 계층 구조 예:
* root cfs_rq
* ├─ group A (se) → group A의 cfs_rq
* │ ├─ task 1 (se)
* │ └─ task 2 (se)
* └─ group B (se) → group B의 cfs_rq
* ├─ task 3 (se)
* └─ task 4 (se)
*
* group A와 B의 shares가 동일하면
* 각 그룹이 CPU 50%를 받음 (내부 태스크 수 무관)
*/
cgroup v2 CPU Controller
| 파일 | 형식 | 설명 |
|---|---|---|
cpu.weight |
1~10000 (기본: 100) | CFS 가중치. 같은 레벨의 cgroup 간 CPU 비율 결정 |
cpu.weight.nice |
-20~19 | nice 값 형식으로 가중치 설정 |
cpu.max |
$QUOTA $PERIOD |
bandwidth 제한. "max 100000"이면 무제한. "50000 100000"이면 50% |
cpu.stat |
읽기 전용 | usage_usec, user_usec, system_usec, nr_periods, nr_throttled 등 |
# cgroup v2 CPU controller 설정 예
# 1. cpu controller 활성화
echo "+cpu" > /sys/fs/cgroup/cgroup.subtree_control
# 2. 그룹 생성
mkdir /sys/fs/cgroup/app_group
# 3. 가중치 설정 (기본 100 대비 2배의 CPU 비율)
echo 200 > /sys/fs/cgroup/app_group/cpu.weight
# 4. bandwidth 제한: 100ms 주기 중 최대 50ms (50% CPU)
echo "50000 100000" > /sys/fs/cgroup/app_group/cpu.max
# 5. 프로세스 할당
echo $PID > /sys/fs/cgroup/app_group/cgroup.procs
# 6. throttle 상태 확인
cat /sys/fs/cgroup/app_group/cpu.stat
# nr_throttled 59 ← throttle 발생 횟수
# throttled_usec 2340000 ← 총 throttle 시간
CFS Bandwidth Throttling
cpu.max에 quota와 period를 설정하면 CFS bandwidth throttling이 동작합니다. 각 period 동안 그룹 내 태스크가 quota만큼의 CPU 시간을 소진하면, 해당 그룹의 cfs_rq가 throttle되어 다음 period까지 실행이 중단됩니다.
컨테이너 CPU 제한 주의: Kubernetes의 resources.limits.cpu는 내부적으로 cpu.max를 설정합니다. 기본 period는 100ms로, CPU 집중 워크로드에서 빈번한 throttle이 발생할 수 있습니다. 멀티스레드 애플리케이션은 throttle quota를 스레드 수에 맞게 충분히 설정해야 합니다.
스케줄러 튜닝과 디버깅
Linux 커널은 /proc/sys/kernel/sched_* 파라미터를 통해 스케줄러 동작을 런타임에 튜닝할 수 있습니다. 또한 다양한 디버깅 도구를 제공합니다.
주요 튜닝 파라미터
| 파라미터 | 기본값 | 설명 |
|---|---|---|
sched_min_granularity_ns |
750000 (0.75ms) | 태스크의 최소 실행 시간 보장. 너무 작으면 컨텍스트 스위칭 오버헤드 증가 |
sched_latency_ns |
6000000 (6ms) | CFS의 목표 스케줄링 주기. 이 시간 내에 모든 실행 가능 태스크가 한 번씩 실행 |
sched_wakeup_granularity_ns |
1000000 (1ms) | wakeup preemption 임계값. 깨어난 태스크의 vruntime이 이 값보다 작아야 선점 |
sched_migration_cost_ns |
500000 (0.5ms) | 마이그레이션 비용 추정. 이보다 최근에 실행된 태스크는 마이그레이션하지 않음 |
sched_nr_migrate |
32 | 한 번의 로드 밸런싱에서 이동할 최대 태스크 수 |
sched_rt_runtime_us |
950000 | RT throttling: 주기 내 RT 최대 실행 시간 (-1이면 무제한) |
sched_rt_period_us |
1000000 | RT throttling 주기 |
EEVDF와 튜닝: Linux 6.6+에서 EEVDF가 기본 스케줄러가 되면서, sched_latency_ns와 sched_min_granularity_ns의 역할이 줄어들었습니다. EEVDF는 deadline 기반으로 동작하므로 이러한 휴리스틱 파라미터에 덜 의존합니다.
디버깅 인터페이스
# 1. /proc/sched_debug — 전역 스케줄러 상태 덤프
cat /proc/sched_debug
# 각 CPU의 런큐 상태, 실행 중/대기 중 태스크 목록, 도메인 정보 출력
# 2. /proc/[pid]/sched — 개별 태스크 스케줄러 통계
cat /proc/self/sched
# se.vruntime, se.sum_exec_runtime, nr_switches,
# nr_voluntary_switches, nr_involuntary_switches 등
# 3. schedstat — CPU별 스케줄러 통계
cat /proc/schedstat
# CPU별 컨텍스트 스위칭 횟수, 런큐 대기 시간 등
perf sched 도구
perf sched는 스케줄러 이벤트를 기록하고 분석하는 전용 도구입니다:
# 스케줄러 이벤트 기록
perf sched record -- sleep 5
# 스케줄링 지연 시간 분석
perf sched latency
# Task | Runtime ms | Switches | Avg delay ms |
# my_app:1234 | 1500.312 | 3421 | 0.045 |
# CPU별 스케줄링 맵 (시각적 타임라인)
perf sched map
# *A0 B0 . . . ← 각 CPU의 실행 태스크 시간축
# 스케줄링 타임차트 (SVG 출력)
perf sched timechart
ftrace 스케줄러 트레이스포인트
ftrace의 스케줄러 관련 tracepoint를 활용하면 실시간으로 스케줄링 이벤트를 추적할 수 있습니다:
# sched_switch: 컨텍스트 스위칭 이벤트 추적
echo 1 > /sys/kernel/debug/tracing/events/sched/sched_switch/enable
echo 1 > /sys/kernel/debug/tracing/events/sched/sched_wakeup/enable
# 트레이스 읽기
cat /sys/kernel/debug/tracing/trace
# <idle>-0 [002] 123.456: sched_switch: prev=swapper/2:0 [120]
# ==> next=my_app:1234 [120]
# my_app-1234 [002] 123.458: sched_wakeup: comm=kworker/3:0
# pid=56 prio=120 target_cpu=003
# 주요 스케줄러 tracepoint 목록:
# sched_switch — 태스크 전환 시점
# sched_wakeup — 태스크 깨어남
# sched_migrate_task — CPU 간 마이그레이션
# sched_process_fork — fork 이벤트
# sched_stat_runtime — 실행 시간 통계
# sched_stat_wait — 런큐 대기 시간 통계
실전 디버깅 팁: 스케줄링 지연 문제를 분석할 때는 perf sched latency로 전체 그림을 파악한 후, 문제 태스크의 /proc/[pid]/sched에서 nr_involuntary_switches(비자발적 선점 횟수)와 se.statistics.wait_sum(총 런큐 대기 시간)을 확인하세요. RT 태스크의 경우 cpu.stat의 nr_throttled도 반드시 점검해야 합니다.
컨텍스트 스위칭 (Context Switch)
컨텍스트 스위칭은 현재 실행 중인 태스크를 다른 태스크로 전환하는 과정입니다. context_switch() 함수에서 수행되며, 크게 두 단계로 나뉩니다.
- 주소 공간 전환:
switch_mm_irqs_off()에서 페이지 테이블(CR3 레지스터)을 새 태스크의 것으로 교체합니다. 커널 스레드 간 전환이면 이 단계를 건너뜁니다. - 레지스터/스택 전환:
switch_to()에서 커널 스택 포인터, 프로그램 카운터, 범용 레지스터 등을 전환합니다. 이 작업은 아키텍처 의존적인 어셈블리 코드로 구현됩니다.
컨텍스트 스위칭 과정 다이어그램
/* kernel/sched/core.c - context_switch() 핵심 (간략화) */
static void context_switch(struct rq *rq,
struct task_struct *prev, struct task_struct *next)
{
/* 1. 주소 공간 전환 */
if (!next->mm) {
/* 커널 스레드: 이전 태스크의 mm을 빌림 (lazy TLB) */
next->active_mm = prev->active_mm;
} else {
/* 유저 프로세스: 페이지 테이블 전환 */
switch_mm_irqs_off(prev->active_mm, next->mm, next);
}
/* 2. 레지스터 + 스택 전환 (아키텍처 의존) */
switch_to(prev, next, prev);
/* 이 시점부터 next 태스크의 컨텍스트에서 실행 */
finish_task_switch(prev);
}
Lazy TLB: 커널 스레드는 자체 유저 주소 공간이 없으므로(mm == NULL), 이전 태스크의 mm을 빌려 사용합니다. 이를 통해 불필요한 TLB flush를 방지하여 성능을 향상시킵니다.
스레드 (Threads)
Linux에서 스레드는 프로세스와 본질적으로 같은 객체입니다. 둘 다 task_struct로 표현되며, 스레드는 같은 스레드 그룹에 속한 task_struct들이 주소 공간, 파일 디스크립터, 시그널 핸들러 등을 공유하는 것일 뿐입니다.
커널 스레드 (Kernel Threads)
커널 스레드는 유저 공간 주소 공간 없이(mm == NULL) 커널 공간에서만 실행되는 태스크입니다. 대표적인 커널 스레드로는 kthreadd(PID 2, 커널 스레드 생성 관리), ksoftirqd, kworker, migration, rcu_gp 등이 있습니다.
/* 커널 스레드 생성 API */
/* 방법 1: kthread_create + wake_up_process */
struct task_struct *kth;
kth = kthread_create(my_thread_fn, data, "my-kthread/%d", cpu);
if (!IS_ERR(kth))
wake_up_process(kth);
/* 방법 2: kthread_run (create + wake_up 합친 매크로) */
kth = kthread_run(my_thread_fn, data, "my-kthread");
/* 커널 스레드 함수 시그니처 */
static int my_thread_fn(void *data)
{
while (!kthread_should_stop()) {
/* 작업 수행 */
do_work(data);
/* 조건부 슬립 */
set_current_state(TASK_INTERRUPTIBLE);
if (!has_work())
schedule();
__set_current_state(TASK_RUNNING);
}
return 0;
}
/* 커널 스레드 중지 */
kthread_stop(kth); /* kthread_should_stop()이 true 반환하게 함 */
PID / TID 관계 (PID and TID Relationship)
Linux에서 PID와 TID의 관계는 다음과 같습니다:
- TID (Thread ID):
task_struct.pid필드. 각 태스크(스레드)마다 고유합니다.gettid()시스템 콜로 확인 가능합니다. - PID (Process ID):
task_struct.tgid(Thread Group ID) 필드. 같은 프로세스의 모든 스레드는 동일한 tgid를 가집니다.getpid()는 tgid를 반환합니다. - 메인 스레드의 경우
pid == tgid이고, 추가 스레드는pid != tgid입니다.
| 필드 | 메인 스레드 | 추가 스레드 1 | 추가 스레드 2 |
|---|---|---|---|
pid (= TID) |
1000 | 1001 | 1002 |
tgid (= PID) |
1000 | 1000 | 1000 |
getpid() |
1000 | 1000 | 1000 |
gettid() |
1000 | 1001 | 1002 |
PID 네임스페이스: PID는 네임스페이스에 따라 다른 값을 가질 수 있습니다. 컨테이너 안에서 PID 1로 보이는 프로세스가 호스트에서는 전혀 다른 PID를 가집니다. task_struct 내부의 struct pid는 각 네임스페이스 수준별 PID 번호를 보관합니다.
프로세스 종료 (Process Termination)
프로세스 종료는 do_exit() 함수에서 처리됩니다. 종료 과정은 다음과 같습니다:
- PF_EXITING 플래그 설정: 이중 종료 방지를 위해 플래그를 설정합니다.
- 리소스 해제: 타이머, 시그널 핸들러, 파일 디스크립터, 메모리 디스크립터, 세마포어 등을 해제합니다.
- exit_code 설정: 종료 코드를
task_struct.exit_code에 저장합니다. - exit_notify(): 부모에게 SIGCHLD 시그널을 보내고, 자식 프로세스를 입양(reparenting) 처리합니다.
- ZOMBIE 상태 전환:
EXIT_ZOMBIE상태로 전환하고schedule()을 호출합니다. 이 태스크는 다시 스케줄되지 않습니다.
/* kernel/exit.c - do_exit() 핵심 흐름 (간략화) */
void __noreturn do_exit(long code)
{
struct task_struct *tsk = current;
tsk->flags |= PF_EXITING;
exit_signals(tsk); /* 시그널 처리 정리 */
exit_mm(); /* 주소 공간 해제 */
exit_sem(tsk); /* SysV 세마포어 정리 */
exit_files(tsk); /* 파일 디스크립터 닫기 */
exit_fs(tsk); /* 파일시스템 참조 해제 */
exit_task_namespaces(tsk);
tsk->exit_code = code;
exit_notify(tsk, group_dead);
tsk->__state = TASK_DEAD;
__schedule(SM_NONE); /* 다시는 돌아오지 않음 */
BUG(); /* 도달 불가 */
}
wait()과 좀비 수거 (Zombie Reaping)
부모 프로세스가 wait()/waitpid()/waitid()를 호출하면 커널은 do_wait()을 통해 좀비 상태의 자식을 찾아 종료 상태를 수집하고, release_task()로 task_struct를 최종 해제합니다.
부모가 wait()을 호출하지 않으면 자식은 좀비 상태로 남아 task_struct 메모리를 계속 점유합니다. 부모가 먼저 종료되면 고아(orphan) 프로세스는 init(PID 1)에게 입양되며, init이 주기적으로 wait()을 호출하여 좀비를 수거합니다.
좀비 프로세스 누수: 자식 프로세스를 생성하는 데몬에서 wait()를 적절히 호출하지 않으면 좀비 프로세스가 누적되어 PID 고갈 문제가 발생할 수 있습니다. SIGCHLD 핸들러에서 waitpid(-1, &status, WNOHANG)를 루프로 호출하거나, signal(SIGCHLD, SIG_IGN)으로 자동 수거를 활성화하세요.
코드 예제 (Code Examples)
커널 모듈에서 커널 스레드 생성
#include <linux/module.h>
#include <linux/kthread.h>
#include <linux/delay.h>
#include <linux/sched.h>
static struct task_struct *my_thread;
static int thread_func(void *data)
{
int count = 0;
pr_info("thread started: pid=%d, tgid=%d\n",
current->pid, current->tgid);
while (!kthread_should_stop()) {
pr_info("thread iteration %d, state=%u\n",
count++, current->__state);
/* task_struct 정보 접근 예시 */
pr_info(" comm=%s, prio=%d, policy=%u\n",
current->comm,
current->prio,
current->policy);
pr_info(" on_cpu=%d, nr_cpus_allowed=%d\n",
current->on_cpu,
current->nr_cpus_allowed);
msleep(2000);
}
pr_info("thread stopping\n");
return 0;
}
static int __init my_init(void)
{
pr_info("creating kernel thread\n");
my_thread = kthread_run(thread_func, NULL, "my_kthread");
if (IS_ERR(my_thread)) {
pr_err("failed to create thread: %ld\n",
PTR_ERR(my_thread));
return PTR_ERR(my_thread);
}
return 0;
}
static void __exit my_exit(void)
{
if (my_thread)
kthread_stop(my_thread);
pr_info("module unloaded\n");
}
module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("Kernel Thread Example");
프로세스 리스트 순회 (Traversing the Task List)
#include <linux/sched/signal.h>
/* 모든 프로세스 순회 */
struct task_struct *task;
for_each_process(task) {
pr_info("[%5d] %s state=%u prio=%d\n",
task->pid, task->comm,
task->__state, task->prio);
}
/* 특정 프로세스의 모든 스레드 순회 */
struct task_struct *thread;
struct task_struct *leader = current->group_leader;
for_each_thread(leader, thread) {
pr_info(" thread: pid=%d comm=%s\n",
thread->pid, thread->comm);
}
/* PID로 task_struct 찾기 */
struct task_struct *found;
rcu_read_lock();
found = find_task_by_vpid(1234);
if (found)
pr_info("found: %s\n", found->comm);
rcu_read_unlock();
스케줄링 파라미터 확인 및 변경
/* 유저 공간에서 스케줄링 정책/우선순위 변경 */
#include <sched.h>
/* SCHED_FIFO로 변경, 우선순위 50 */
struct sched_param param;
param.sched_priority = 50;
sched_setscheduler(0, SCHED_FIFO, ¶m);
/* SCHED_DEADLINE 설정 (clone3 또는 sched_setattr 필요) */
struct sched_attr attr = {
.size = sizeof(attr),
.sched_policy = SCHED_DEADLINE,
.sched_runtime = 10000000, /* 10ms */
.sched_deadline = 30000000, /* 30ms */
.sched_period = 30000000, /* 30ms */
};
sched_setattr(0, &attr, 0);
RT 프로세스 주의: SCHED_FIFO나 SCHED_RR 정책의 프로세스가 무한 루프에 빠지면 해당 CPU의 다른 일반 프로세스는 실행되지 못합니다. 커널 설정 CONFIG_RT_GROUP_SCHED와 /proc/sys/kernel/sched_rt_runtime_us(기본 950000, 즉 1초당 최대 950ms)로 RT 프로세스의 CPU 독점을 제한할 수 있습니다.
프로세스 관리 관련 주요 버그 사례
리눅스 커널의 프로세스 관리 서브시스템에서 발견된 주요 보안 취약점과 버그 사례를 분석합니다. 이러한 사례를 이해하면 커널 코드 작성 시 유사한 실수를 방지할 수 있습니다.
Dirty Pipe (CVE-2022-0847) — pipe 버퍼 플래그 오류
splice() 시스템 콜로 pipe에 데이터를 넣을 때, pipe 버퍼의 PIPE_BUF_FLAG_CAN_MERGE 플래그가 올바르게 초기화되지 않는 버그입니다. 이로 인해 pipe 버퍼가 페이지 캐시(page cache) 페이지를 참조하면서도 쓰기 가능한 상태가 되어, 읽기 전용 파일이나 SUID 바이너리를 덮어쓸 수 있는 로컬 권한 상승 취약점이 발생했습니다.
심각도: CRITICAL — Linux 5.8부터 5.16.10까지 영향을 받으며, 비특권 사용자가 읽기 전용 파일을 수정하여 root 권한을 획득할 수 있습니다. 발견자: Max Kellermann. CVSS 점수 7.8.
취약점의 핵심은 copy_page_to_iter_pipe() 함수에서 새로 할당된 pipe 버퍼의 flags 필드를 초기화하지 않은 것입니다. 이전에 PIPE_BUF_FLAG_CAN_MERGE가 설정된 버퍼가 재사용되면, splice()로 매핑된 페이지 캐시 페이지에 일반 write()로 데이터를 병합(merge)할 수 있게 됩니다.
/* 취약한 코드 (fs/pipe.c) — 플래그 미초기화 */
static size_t copy_page_to_iter_pipe(struct page *page,
size_t offset, size_t bytes,
struct iov_iter *i)
{
struct pipe_inode_info *pipe = i->pipe;
struct pipe_buffer *buf;
unsigned int p_tail = pipe->tail;
...
buf = &pipe->bufs[p_tail & (pipe->ring_size - 1)];
buf->page = page;
buf->offset = offset;
buf->len = bytes;
/* BUG: buf->flags 초기화 누락!
* 이전 사용에서 PIPE_BUF_FLAG_CAN_MERGE가 남아 있으면
* 페이지 캐시 페이지에 쓰기가 가능해짐 */
...
}
/* 수정된 코드 — 플래그를 명시적으로 초기화 */
buf->flags = 0; /* PIPE_BUF_FLAG_CAN_MERGE 제거 */
공격 시나리오: 공격자는 (1) pipe를 생성하고 버퍼를 모두 채운 뒤 비워서 PIPE_BUF_FLAG_CAN_MERGE를 설정하고, (2) splice()로 대상 파일의 페이지 캐시를 pipe에 매핑한 후, (3) write()로 원하는 데이터를 덮어씁니다. 이 과정은 /etc/passwd나 SUID 바이너리에 적용하여 root 쉘을 획득할 수 있습니다.
ELF 로더 취약점 — Stack Clash (CVE-2017-1000364)
리눅스 커널의 ELF 로더가 사용자 공간 스택을 설정할 때, guard page가 충분하지 않아 스택이 다른 메모리 영역(heap, mmap 등)과 충돌하는 취약점입니다. 재귀 호출이나 큰 로컬 변수 할당을 통해 guard page를 한 번에 건너뛸 수 있었습니다.
심각도: HIGH — 스택과 heap/mmap 영역의 경계 보호가 단일 guard page(4KB)에만 의존했기 때문에, 4KB 이상의 스택 프레임을 할당하는 함수 호출로 guard page를 건너뛰어 인접 메모리 영역을 덮어쓸 수 있었습니다. 이를 통해 로컬 권한 상승이 가능했습니다.
/* 취약점 원리: 스택 가드 페이지 우회 */
/* 높은 주소 */
/* ┌─────────────────────┐ */
/* │ User Stack │ ← 아래로 성장 */
/* ├─────────────────────┤ */
/* │ Guard Page (4KB) │ ← 단일 페이지, 건너뛸 수 있음! */
/* ├─────────────────────┤ */
/* │ mmap / heap │ ← 위로 성장 */
/* └─────────────────────┘ */
/* 낮은 주소 */
/* 공격 코드 예시: 큰 로컬 변수로 guard page 건너뛰기 */
void exploit(void) {
char buf[1024 * 1024]; /* 1MB — guard page(4KB)를 한 번에 건너뜀 */
buf[0] = 'A'; /* heap/mmap 영역에 쓰기 발생 */
}
/* 수정: stack_guard_gap 확대 (mm/mmap.c) */
unsigned long stack_guard_gap = 256UL << PAGE_SHIFT; /* 256 페이지 = 1MB */
static int __init cmdline_parse_stack_guard_gap(char *p)
{
unsigned long val;
if (!p)
return -EINVAL;
if (kstrtoul(p, 10, &val))
return -EINVAL;
stack_guard_gap = val << PAGE_SHIFT;
return 0;
}
__setup("stack_guard_gap=", cmdline_parse_stack_guard_gap);
RLIMIT_STACK과의 상호작용: RLIMIT_STACK은 프로세스 스택의 최대 크기를 제한하지만, guard page 크기와는 독립적으로 동작합니다. 수정 후에는 stack_guard_gap(기본 1MB)이 스택 확장 시 인접 VMA와의 최소 거리를 보장합니다. 커널 부트 파라미터 stack_guard_gap=N으로 페이지 단위 조정이 가능합니다.
ASLR 우회 기법과 대응
ASLR(Address Space Layout Randomization)은 프로세스의 메모리 레이아웃을 무작위화하여 공격을 어렵게 만드는 핵심 보안 메커니즘입니다. 그러나 다양한 정보 누출(information leak) 경로를 통해 ASLR이 무력화될 수 있으며, 커널은 이에 대한 대응책을 지속적으로 강화해 왔습니다.
정보 누출 경로: /proc/self/maps 파일은 프로세스의 전체 메모리 맵을 노출하며, 이를 통해 ASLR이 완전히 무력화됩니다. 또한 커널 로그(dmesg)에 출력되는 커널 포인터 주소도 KASLR 우회에 사용될 수 있습니다.
/* printk의 %p → %pK 변환 (commit 57e734423ad) */
/* 변경 전: 커널 주소가 그대로 노출 */
printk(KERN_INFO "object at %p\n", obj);
/* 출력: "object at ffff8880123abc00" ← 실제 주소 노출! */
/* 변경 후: 해시된 주소 출력 */
printk(KERN_INFO "object at %p\n", obj);
/* 출력: "object at 00000000deadbeef" ← 해시값 (비특권 사용자) */
/* 제한된 포인터 출력 (%pK): kptr_restrict에 따라 동작 */
printk(KERN_INFO "symbol at %pK\n", sym);
/* kptr_restrict=0: 실제 주소 출력 (기본값) */
/* kptr_restrict=1: CAP_SYSLOG 없으면 0 출력 */
/* kptr_restrict=2: 항상 0 출력 */
/* ASLR 강화를 위한 sysctl 설정 */
/* dmesg 접근 제한 */
# sysctl -w kernel.dmesg_restrict=1
/* 커널 포인터 출력 제한 */
# sysctl -w kernel.kptr_restrict=1
/* /proc/sys/kernel/randomize_va_space 설정값 */
/* 0: ASLR 비활성화 */
/* 1: mmap, 스택, VDSO 무작위화 */
/* 2: 1 + brk 무작위화 (기본값, 완전 ASLR) */
Stack canary와 ASLR의 상보적 보호: Stack canary는 버퍼 오버플로우를 탐지하고, ASLR은 공격 대상 주소의 예측을 방지합니다. 두 메커니즘은 독립적으로 동작하지만, 함께 사용할 때 보호 효과가 극대화됩니다. Stack canary가 우회되더라도 ASLR이 유효하면 ROP 공격이 어렵고, ASLR이 우회되더라도 canary가 스택 기반 공격을 차단합니다. GCC의 -fstack-protector-strong 옵션으로 canary를 활성화할 수 있습니다.
fork() bomb과 PID 고갈
fork()를 무한 반복 호출하여 시스템의 PID를 고갈시키는 fork bomb은 가장 단순하면서도 효과적인 서비스 거부(DoS) 공격입니다. 리눅스 커널은 여러 계층에서 이를 방어하는 메커니즘을 제공합니다.
/* 고전적인 fork bomb */
/* bash: :(){ :|:& };: */
#include <unistd.h>
int main(void)
{
while (1)
fork(); /* 프로세스 수가 기하급수적으로 증가 */
return 0;
}
/* 방어 1: RLIMIT_NPROC — 사용자별 프로세스 수 제한 */
#include <sys/resource.h>
struct rlimit rl;
rl.rlim_cur = 1024; /* 소프트 한계: 1024개 */
rl.rlim_max = 4096; /* 하드 한계: 4096개 */
setrlimit(RLIMIT_NPROC, &rl);
/* /etc/security/limits.conf 설정 */
/* * hard nproc 4096 */
/* * soft nproc 1024 */
/* 방어 2: cgroup pids 컨트롤러 — 그룹 단위 PID 제한 */
/* cgroup v2에서 pids.max 설정 */
# cgroup 생성 및 PID 제한 설정
# mkdir /sys/fs/cgroup/mygroup
# echo 100 > /sys/fs/cgroup/mygroup/pids.max
# echo $$ > /sys/fs/cgroup/mygroup/cgroup.procs
/* 커널 코드: cgroup pids 제한 확인 (kernel/cgroup/pids.c) */
static int pids_can_fork(struct task_struct *task,
int *retval)
{
struct pids_cgroup *pids;
int64_t limit;
pids = css_to_pids(task_css(task, pids_cgrp_id));
limit = atomic64_read(&pids->limit);
if (limit != PIDS_MAX &&
atomic64_read(&pids->counter) >= limit) {
*retval = -EAGAIN;
return 1; /* fork 거부 */
}
atomic64_inc(&pids->counter);
return 0;
}
PID namespace 격리: PID namespace는 컨테이너 환경에서 fork bomb의 영향 범위를 제한하는 핵심 메커니즘입니다. 각 PID namespace는 독립적인 PID 번호 공간을 가지며, namespace 내부의 프로세스 폭주가 호스트의 전체 PID 공간을 고갈시키지 않습니다. cgroup pids.max와 결합하면 namespace당 프로세스 수를 엄격하게 제한할 수 있어, 컨테이너 오케스트레이션 환경(Kubernetes 등)에서는 반드시 설정해야 하는 보안 항목입니다.