프로세스 관리 (Process Management)

Linux 커널의 프로세스 관리 전반을 다룹니다. task_struct 구조체부터 스케줄러(CFS, EEVDF), fork/exec 메커니즘, Copy-on-Write, 컨텍스트 스위칭, 커널 스레드까지 심층적으로 분석합니다.

관련 표준: POSIX.1-2017 (프로세스 모델, 시그널), ELF (실행 파일 형식), System V ABI (호출 규약) — 커널 프로세스 관리가 따르는 핵심 규격입니다. 종합 목록은 참고자료 — 표준 & 규격 섹션을 참고하세요.
전제 조건: 커널 아키텍처(사용자/커널 공간, 특권 레벨)와 메모리 관리(가상 주소 공간)를 먼저 읽으세요.
일상 비유: 프로세스는 요리사와 같습니다. task_struct는 요리사의 신상 카드(이름, 상태, 할당된 주방, 사용 중인 도구 목록)이고, fork()는 요리사를 복제하는 것(같은 레시피, 같은 재료를 공유), exec()는 복제된 요리사에게 새 레시피를 건네주는 것입니다. 컨텍스트 스위치는 주방(CPU)에서 요리사를 교체하는 과정입니다.

핵심 요약

  • task_struct — 커널이 프로세스/스레드를 관리하는 핵심 자료구조. 상태, PID, 메모리, 파일 정보를 모두 담습니다.
  • fork() — 현재 프로세스를 복제하여 자식 프로세스를 생성합니다. Copy-on-Write로 효율적입니다.
  • exec() — 프로세스의 메모리 이미지를 새 프로그램(ELF)으로 교체합니다.
  • 컨텍스트 스위치 — CPU 레지스터를 저장/복원하여 다른 프로세스로 전환하는 과정입니다.
  • 커널 스레드 — 사용자 공간 없이 커널 내에서만 동작하는 스레드입니다 (kworker, kswapd 등).

단계별 이해

  1. task_struct 파악ps aux로 보이는 모든 프로세스는 커널 내부에서 task_struct 하나로 표현됩니다.

    /proc/<pid>/status에서 프로세스의 상태, 메모리, 스레드 정보를 확인할 수 있습니다.

  2. fork 이해fork()는 부모의 페이지 테이블을 복사하되, 실제 메모리는 Write 시에만 복사합니다(CoW).

    이 덕분에 fork는 매우 빠르고, 자식이 즉시 exec()하면 복사가 거의 발생하지 않습니다.

  3. exec 이해execve()는 ELF 파일을 파싱하여 코드/데이터 세그먼트를 새로 매핑합니다.

    기존 메모리 매핑은 모두 해제되고 새 프로그램의 진입점(_start)부터 실행됩니다.

  4. 프로세스 계층 — 모든 프로세스는 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)

TASK_RUNNING (실행/대기) TASK_INTERRUPTIBLE TASK_UNINTERRUPTIBLE __TASK_STOPPED __TASK_TRACED EXIT_ZOMBIE EXIT_DEAD schedule() wake_up() I/O wait 완료 SIGSTOP SIGCONT ptrace resume do_exit() wait()
프로세스 상태 전이 다이어그램 - 각 상태 간 전이 조건 표시

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())를 통해 처리됩니다. 주요 처리 과정은 다음과 같습니다:

  1. task_struct 할당: dup_task_struct()로 새로운 task_struct와 커널 스택을 할당합니다.
  2. 리소스 복제: copy_files(), copy_fs(), copy_sighand(), copy_signal(), copy_mm(), copy_namespaces() 등을 호출하여 각 리소스를 복제합니다.
  3. 스케줄러 초기화: sched_fork()로 새 태스크의 스케줄링 정보를 초기화합니다.
  4. PID 할당: alloc_pid()로 새 PID를 할당합니다.
  5. 런큐 추가: 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의 핵심 처리 흐름:

  1. fork()copy_mm() -> dup_mmap()에서 부모의 페이지 테이블을 복제합니다.
  2. copy_present_pte()에서 쓰기 가능 페이지를 읽기 전용으로 변경하고, 참조 카운트를 증가시킵니다.
  3. 쓰기 시 페이지 폴트 -> do_wp_page()에서 COW 처리를 수행합니다.
  4. 참조 카운트가 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 파일 레이아웃 ELF Header (Elf64_Ehdr) Program Header Table Segment 1 (PT_LOAD: .text) Segment 2 (PT_LOAD: .data) Segment 3 (PT_INTERP) Segment 4 (PT_DYNAMIC) ... 기타 세그먼트 ... Section Header Table 두 가지 관점 링킹 뷰 .text .rodata .data .bss .symtab .strtab .rela.text .shstrtab 섹션 기반 실행 뷰 LOAD (R-X) LOAD (RW-) DYNAMIC 세그먼트 기반 링킹 뷰: 링커가 섹션을 조합 실행 뷰: 커널이 세그먼트를 매핑 실행 파일: 섹션 헤더 선택적 오브젝트: 프로그램 헤더 불필요
ELF 파일 레이아웃 — 링킹 뷰(섹션)와 실행 뷰(세그먼트)의 이중 구조

ELF 파일의 유형은 e_type 필드로 구분됩니다:

타입설명용도
ET_REL1재배치 가능 파일 (.o)링커 입력, 커널 모듈(.ko)
ET_EXEC2실행 파일 (고정 주소)비-PIE 바이너리, 정적 링크
ET_DYN3공유 오브젝트공유 라이브러리(.so), PIE 실행 파일
ET_CORE4코어 덤프프로세스 크래시 분석

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~3EI_MAG0~30x7f 'E' 'L' 'F'ELF 매직 넘버
4EI_CLASS1=32bit, 2=64bit주소 크기 클래스
5EI_DATA1=LE, 2=BE바이트 순서(엔디안)
6EI_VERSION1 (EV_CURRENT)ELF 규격 버전
7EI_OSABI0=ELFOSABI_NONEOS/ABI 식별 (Linux는 보통 0)
8~15EI_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_LOAD1elf_map()으로 mmap메모리에 매핑되는 로드 가능 세그먼트. 코드(.text, R-X)와 데이터(.data/.bss, RW-) 영역
PT_INTERP3인터프리터 경로 추출동적 링커 경로 (예: /lib64/ld-linux-x86-64.so.2)
PT_NOTE4ABI 태그 검증빌드 ID, GNU ABI 태그 등 메타데이터
PT_DYNAMIC2동적 링커가 사용동적 링킹 정보 (.dynamic 섹션)
PT_PHDR6프로그램 헤더 자체프로그램 헤더 테이블의 위치와 크기
PT_GNU_STACK0x6474e551스택 실행 권한 결정PF_X 없으면 NX 스택 (기본). 커널이 스택 VMA 권한 설정에 사용
PT_GNU_RELRO0x6474e552동적 링커가 처리재배치 후 읽기 전용으로 전환될 영역 (.got, .init_array 등)
💡

p_memsz > p_filesz: .bss 세그먼트(초기화되지 않은 전역 변수)는 파일에 데이터를 저장하지 않으므로 p_fileszp_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;

주요 섹션과 그 역할:

섹션타입플래그설명
.textPROGBITSAX실행 가능 코드
.rodataPROGBITSA읽기 전용 데이터 (문자열 리터럴, const 변수)
.dataPROGBITSWA초기화된 전역/정적 변수
.bssNOBITSWA0 초기화 전역/정적 변수 (파일 공간 차지 안 함)
.pltPROGBITSAXProcedure Linkage Table — 지연 바인딩 트램펄린
.gotPROGBITSWAGlobal Offset Table — 전역 심볼의 런타임 주소
.got.pltPROGBITSWAPLT 전용 GOT 엔트리
.dynamicDYNAMICWA동적 링킹 메타데이터 (DT_NEEDED, DT_SYMTAB 등)
.symtabSYMTAB-심볼 테이블 (strip 시 제거됨)
.dynsymDYNSYMA동적 심볼 테이블 (런타임 필수, strip 불가)
.strtabSTRTAB-심볼 이름 문자열 테이블
.rela.pltRELAAPLT 엔트리 재배치 정보
.rela.dynRELAA동적 재배치 정보
.init_arrayINIT_ARRAYWA초기화 함수 포인터 배열 (main() 전 실행)
.fini_arrayFINI_ARRAYWA종료 함수 포인터 배열 (main() 후 실행)
.note.gnu.build-idNOTEA빌드 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_LOCAL0파일 내부에서만 참조 가능. static 함수/변수. 다른 오브젝트 파일의 동일 이름 심볼과 충돌하지 않음
STB_GLOBAL1모든 오브젝트 파일에서 참조 가능. 정의가 하나만 존재해야 함 (다중 정의 시 링커 에러)
STB_WEAK2전역 심볼과 유사하나 우선순위가 낮음. 같은 이름의 STB_GLOBAL 심볼이 있으면 대체됨. 미해석 시 에러 아님 (0으로 처리)

심볼 타입(Type) — 심볼이 참조하는 엔티티의 종류:

타입설명
STT_NOTYPE0타입 미지정
STT_OBJECT1데이터 오브젝트 (변수, 배열 등)
STT_FUNC2함수 또는 실행 가능 코드
STT_SECTION3섹션 자체를 나타내는 심볼
STT_FILE4소스 파일 이름
STT_COMMON5미초기화 공통 블록 (Fortran COMMON, C의 tentative definition)
STT_TLS6Thread-Local Storage 변수
STT_GNU_IFUNC10GNU 간접 함수 — 런타임에 CPU 기능에 따라 최적 구현 선택 (예: memcpy의 AVX/SSE 분기)

심볼 가시성(Visibility) — 동적 링킹에서의 심볼 노출 범위를 제어합니다:

가시성설명
STV_DEFAULT0기본 가시성. 바인딩 규칙에 따라 동적 심볼로 내보내짐
STV_INTERNAL1프로세서별 숨김 규칙 적용 (거의 사용되지 않음)
STV_HIDDEN2동적 심볼 테이블에 포함되지 않음. -fvisibility=hidden과 동일 효과
STV_PROTECTED3외부에서 참조 가능하나, 같은 공유 라이브러리 내에서는 심볼 인터포지션 불가

특수 섹션 인덱스 (st_shndx):

상수설명
SHN_UNDEF0미정의 심볼 — 다른 오브젝트/라이브러리에서 정의 필요
SHN_ABS0xfff1절대 주소 — 재배치에 영향받지 않음
SHN_COMMON0xfff2COMMON 블록 — 링커가 .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정적 심볼 이름.symtabst_name 필드
.dynstr동적 심볼 이름, 라이브러리 이름.dynsymst_name, .dynamicDT_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_641S + A절대 64비트 주소 (데이터 포인터)
R_X86_64_PC322S + A - PPC 상대 32비트 (근거리 call/jmp)
R_X86_64_PLT324L + A - PPLT를 통한 함수 호출
R_X86_64_COPY5실행 파일의 .bss에 공유 라이브러리 데이터 심볼 복사
R_X86_64_GLOB_DAT6SGOT 엔트리에 심볼 절대 주소 저장
R_X86_64_JUMP_SLOT7SPLT용 GOT 엔트리 (지연 바인딩 대상)
R_X86_64_RELATIVE8B + APIE/공유 라이브러리의 내부 참조 (base address 보정)
R_X86_64_TPOFF3223S + A - TPTLS 변수의 TP(Thread Pointer) 상대 오프셋

계산식에서: S = 심볼 값, A = addend, P = 패치 위치 주소, B = base address, L = PLT 엔트리 주소, TP = Thread Pointer.

재배치 처리 과정 (Relocation Process) 오브젝트 파일 (.o) .text: call 00000000 ; printf .rela.text: R_X86_64_PLT32 printf -4 offset=0x05, sym=printf, addend=-4 링커 실행 파일 / .so .text (정적 링킹): call 0x401040 ; printf@plt .rela.plt (동적 재배치): R_X86_64_JUMP_SLOT printf GOT[n] = printf 실제 주소 ld.so 런타임 메모리 GOT 패치 완료: GOT[n] = 0x7f...a340 call → PLT → GOT → printf 실제 주소 R_X86_64_RELATIVE 예시 (PIE 내부 참조) PIE 바이너리의 함수 포인터 테이블: .init_array[0] = 0x1234 (파일 내 오프셋) 로드 시 base = 0x55550000 이면: .init_array[0] = 0x55550000 + 0x1234 = 0x55551234 (B + A) R_X86_64_COPY 예시 (데이터 심볼 복사) libc.so에 정의된 전역 변수: extern int optind; /* libc 내부 */ 실행 파일의 .bss에 복사본 생성: optind@실행파일.bss = optind@libc.data → 모든 참조가 복사본을 가리킴
재배치 과정 — 컴파일 시 미해결 주소가 링킹/로딩 시 패치되는 흐름
# 재배치 엔트리 확인
$ 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_NEEDED1d_val필요한 공유 라이브러리 이름 (.dynstr 오프셋). 동적 링커가 재귀적으로 로드
DT_PLTRELSZ2d_valPLT 재배치 (.rela.plt) 전체 크기
DT_PLTGOT3d_ptr.got.plt 테이블 주소
DT_STRTAB5d_ptr동적 문자열 테이블 (.dynstr) 주소
DT_SYMTAB6d_ptr동적 심볼 테이블 (.dynsym) 주소
DT_RELA7d_ptr.rela.dyn 재배치 테이블 주소
DT_RELASZ8d_val.rela.dyn 테이블 전체 크기 (바이트)
DT_INIT12d_ptr초기화 함수 주소 (_init)
DT_FINI13d_ptr종료 함수 주소 (_fini)
DT_SONAME14d_val공유 라이브러리의 SONAME (.dynstr 오프셋)
DT_JMPREL23d_ptrPLT 재배치 테이블 (.rela.plt) 주소
DT_INIT_ARRAY25d_ptr초기화 함수 포인터 배열 (.init_array) 주소
DT_FINI_ARRAY26d_ptr종료 함수 포인터 배열 (.fini_array) 주소
DT_RUNPATH29d_val라이브러리 검색 경로 (-Wl,-rpath로 설정)
DT_FLAGS30d_val플래그: DF_ORIGIN, DF_SYMBOLIC, DF_BIND_NOW, DF_STATIC_TLS
DT_FLAGS_10x6ffffffbd_val확장 플래그: DF_1_NOW(즉시 바인딩), DF_1_PIE(PIE 표시)
DT_GNU_HASH0x6ffffef5d_ptrGNU 해시 테이블 주소 (심볼 검색 가속)
DT_VERNEED0x6ffffffed_ptr심볼 버전 필요(version needed) 테이블. 예: GLIBC_2.17
DT_NULL0동적 섹션 종료 표시
# 동적 섹션 확인
$ 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() 시스템 콜이 호출되면 커널은 다음 단계를 거쳐 프로세스 이미지를 교체합니다:

  1. 바이너리 열기: open_exec()으로 실행 파일을 열고, struct file을 얻습니다.
  2. bprm 구조체 준비: struct linux_binprm에 파일 첫 256바이트(매직 넘버 검사용), credentials, 파일 정보를 채웁니다.
  3. 포맷 핸들러 탐색: search_binary_handler()가 등록된 바이너리 포맷 핸들러(struct linux_binfmt)를 순회하며, 매직 넘버를 통해 ELF, 스크립트(#!), misc 등 적절한 핸들러를 찾습니다.
  4. ELF 헤더 검증: load_elf_binary()가 매직 넘버(0x7f ELF), 클래스(32/64bit), 엔디안, 아키텍처(e_machine)를 검증합니다.
  5. 인터프리터 로딩: PT_INTERP 세그먼트가 있으면 동적 링커(예: /lib64/ld-linux-x86-64.so.2)의 ELF 파일을 별도로 로드합니다.
  6. 기존 주소 공간 해제: begin_new_exec()에서 point-of-no-return을 넘기고, exec_mmap()으로 기존 mm_struct를 해제합니다.
  7. PT_LOAD 세그먼트 매핑: elf_map()으로 각 PT_LOAD 세그먼트를 do_mmap()하여 파일 내용을 가상 주소 공간에 매핑합니다.
  8. BSS 설정: set_brk().bss 영역(p_memsz > p_filesz 차이분)을 익명 매핑으로 할당합니다.
  9. 스택 설정: 새 스택에 argv, envp, auxiliary vector(AT_*)를 배치합니다.
  10. 실행 시작: 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_PHDR3프로그램 헤더 테이블의 메모리 주소
AT_PHENT4프로그램 헤더 엔트리 크기
AT_PHNUM5프로그램 헤더 엔트리 수
AT_PAGESZ6시스템 페이지 크기 (보통 4096)
AT_BASE7인터프리터(ld.so)의 로드 기본 주소
AT_FLAGS8프로세서별 플래그
AT_ENTRY9프로그램 엔트리 포인트 (e_entry)
AT_UID/GID11/12실제 사용자/그룹 ID
AT_EUID/EGID13/14유효 사용자/그룹 ID
AT_PLATFORM15플랫폼 문자열 (예: "x86_64")
AT_HWCAP16하드웨어 능력 비트마스크 (SSE, AVX 등)
AT_CLKTCK17sysconf(_SC_CLK_TCK) 값 (보통 100)
AT_RANDOM2516바이트 랜덤 데이터 주소 (스택 canary 시드 등)
AT_HWCAP226확장 하드웨어 능력 (CET, TME 등)
AT_EXECFN31실행 파일명 문자열 주소
AT_SYSINFO_EHDR33vDSO 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 basearch_mmap_rnd()공유 라이브러리, vDSO 로드 주소 랜덤화
힙 (brk)arch_randomize_brk()BSS 끝 이후 brk 시작점 랜덤화
PIE 바이너리ELF_ET_DYN_BASE + rndload_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 섹션과 세그먼트:

요소타입설명
.tdataPROGBITS초기화된 TLS 변수. SHF_TLS | SHF_ALLOC | SHF_WRITE 플래그
.tbssNOBITS미초기화 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-dynamicdlopen()으로 로드된 라이브러리의 TLS. __tls_get_addr() 호출 필요느림 (함수 호출)
Local Dynamic (LD)-ftls-model=local-dynamic같은 DSO 내 여러 TLS 변수 접근 시 GD 최적화. 모듈 base를 1회만 조회GD보다 약간 빠름
x86_64 TLS 메모리 레이아웃 스레드 1 실행파일 TLS (tls_var=42) libfoo.so TLS (음수 오프셋) TCB (FS 레지스터 →) DTV (Dynamic Thread Vector) dlopen() TLS (동적 할당) FS - 0x20: tls_var FS + 0x00: TCB (pthread_t) FS + 0x10: DTV 포인터 스레드 2 실행파일 TLS (tls_var=99) libfoo.so TLS (독립 복사본) TCB (FS 레지스터 →) DTV (Dynamic Thread Vector) dlopen() TLS (동적 할당) 각 스레드가 TLS 영역의 독립적인 복사본을 가짐 ELF 파일 PT_TLS 세그먼트: .tdata (초기화 이미지) .tbss (크기 정보만) → 스레드 생성 시 복사 커널 지원 clone(CLONE_SETTLS) arch_prctl(ARCH_SET_FS) → MSR_FS_BASE 설정
x86_64 TLS 레이아웃 — 각 스레드가 PT_TLS 이미지의 독립 복사본을 가짐
; 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 명령이 읽는 데이터
__versionsCRC 기반 심볼 버전 체크섬. 커널-모듈 ABI 호환성 검증에 사용 (CONFIG_MODVERSIONS)
.init.text모듈 초기화 코드 (module_init()). 로딩 후 해제 가능한 영역으로 분류
.exit.text모듈 제거 코드 (module_exit()). 내장 빌드 시 제거됨
__ksymtabEXPORT_SYMBOL()로 내보낸 심볼 테이블
__ksymtab_gplEXPORT_SYMBOL_GPL()로 내보낸 GPL 전용 심볼 테이블
__kcrctab내보낸 심볼의 CRC 체크섬
.gnu.linkonce.this_modulestruct module 인스턴스. 모듈 이름, init/exit 함수 포인터 포함
__parammodule_param()으로 정의한 모듈 파라미터 기술자
.altinstructionsCPU 기능에 따른 대체 명령어 (alternative instructions) 테이블
__bug_tableBUG() / WARN() 매크로의 위치 정보
__ex_table예외 테이블 — 사용자 공간 접근(copy_to_user 등)에서 페이지 폴트 시 복구 주소
커널 모듈 로딩 과정 (load_module) hello.ko (ET_REL) .text (코드) .rodata (상수) .data / .bss .init.text .modinfo .rela.text .symtab / .strtab __ksymtab .gnu.linkonce.this_module insmod load_module() 처리 단계 1. ELF 헤더/섹션 검증 2. .modinfo → vermagic 검증 3. SHF_ALLOC 섹션 분류 (core/init) 4. vmalloc 할당 + 섹션 복사 5. simplify_symbols() 심볼 해석 6. apply_relocations() 재배치 7. 특수 섹션 등록 (__ex_table 등) 8. mod→init() 실행 9. .init.* 메모리 해제 커널 심볼 테이블 __ksymtab (EXPORT_SYMBOL) printk, kmalloc, register_netdev, ... __ksymtab_gpl (GPL only) usb_register, i2c_add_adapter, sysfs_create_group, ... 심볼 검색 로드된 모듈 (메모리) core 영역: .text, .rodata, .data → 모듈 수명 동안 유지 init 영역: .init.text, .init.data → init() 완료 후 해제 /sys/module/hello/
커널 모듈 로딩 — ET_REL ELF를 커널이 직접 링킹하여 커널 주소 공간에 배치
/* 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_elffs/binfmt_elf.c\x7fELF (4바이트)ELF 실행 파일, 공유 라이브러리
binfmt_scriptfs/binfmt_script.c#! (2바이트)셸 스크립트, 인터프리터 스크립트
binfmt_miscfs/binfmt_misc.c사용자 정의 매직/확장자QEMU, Wine, Java, .NET 등
binfmt_flatfs/binfmt_flat.cBFLT 헤더MMU-less 시스템용 플랫 바이너리
binfmt_elf_fdpicfs/binfmt_elf_fdpic.c\x7fELF + FDPIC ABIMMU-less ELF (공유 텍스트 세그먼트)
execve() → search_binary_handler() 흐름 do_execveat_common() linux_binprm buf[256] ← 파일 첫 256B search_binary_handler() formats 리스트 순회 fmt→load_binary(bprm) 시도 binfmt_elf.load_binary() \x7fELF 매칭 → load_elf_binary() binfmt_script.load_binary() #! 매칭 → 인터프리터 재귀 호출 binfmt_misc.load_binary() 사용자 정의 매직/확장자 매칭 binfmt_script 재귀 처리 예시 #!/usr/bin/python3 ← 스크립트 파일의 첫 줄 → binfmt_script: 인터프리터 = /usr/bin/python3 → search_binary_handler() 재호출 (python3 바이너리) → binfmt_elf: \x7fELF 매칭 → load_elf_binary() binfmt_misc 등록 마운트: /proc/sys/fs/binfmt_misc 매직 바이트 또는 확장자 기반 F 플래그: fix-binary (컨테이너) P 플래그: preserve-argv[0] C 플래그: credentials 유지
바이너리 포맷 핸들러 탐색 — 매직 넘버 기반으로 적절한 로더를 선택하는 플러그인 구조
/* 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에서 심볼 버저닝이 동작하는 방식:

glibc 심볼 버저닝 예시: memcpy libc.so.6 (glibc) memcpy@@GLIBC_2.14 기본 버전 — 새로 링크되는 바이너리가 사용 memcpy@GLIBC_2.2.5 레거시 버전 — 이전 ABI로 컴파일된 바이너리용 .symver 어셈블러 디렉티브: .symver __memcpy_sse2_unaligned, memcpy@@GLIBC_2.14 .symver __memcpy_ssse3, memcpy@GLIBC_2.2.5 @@: 기본 버전, @: 호환 버전 새 바이너리 (gcc -o app) .gnu.version_r 요구: memcpy@GLIBC_2.14 → @@GLIBC_2.14 구현에 바인딩 레거시 바이너리 (CentOS 6) .gnu.version_r 요구: memcpy@GLIBC_2.2.5 → @GLIBC_2.2.5 구현에 바인딩
glibc 심볼 버저닝 — 동일 심볼의 여러 ABI 버전을 동시에 제공하여 하위 호환성 유지
# .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_frameSHT_PROGBITSCIE + FDE 레코드: 각 함수의 프레임 복원 규칙
.eh_frame_hdrSHT_PROGBITSFDE 이진 검색 테이블 — PT_GNU_EH_FRAME 세그먼트로 매핑
.debug_frameSHT_PROGBITSDWARF 디버그용 CFI (비할당, 스트립 시 제거)
.gcc_except_tableSHT_PROGBITSC++ 예외 처리 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  ...
스택 언와인딩 과정 (x86_64) 스택 (높은주소→낮은주소) main() 프레임 foo() 프레임 (saved RBP, RIP) bar() 프레임 ← 현재 RSP CFA = RSP + 16 saved RIP = *(CFA - 8) saved RBP = *(CFA - 16) → RIP로 이전 프레임의 FDE를 찾아 반복 .eh_frame_hdr 정렬된 (pc, FDE 포인터) 테이블 이진 검색으로 O(log N) FDE 탐색 pc=0x4010 → FDE[bar] pc=0x4080 → FDE[foo] FDE (bar 함수) pc: 0x4010..0x4050 +0: CFA=RSP+8, RIP=[CFA-8] +1: CFA=RSP+16, RBP=[CFA-16] +4: CFA=RBP+16 각 줄 = DW_CFA_* 바이트코드 언와인더 사용처 C++ throw/catch GDB/LLDB backtrace perf 콜 그래프 sigreturn (시그널 복귀) backtrace() / libunwind __attribute__((cleanup))
스택 언와인딩 — .eh_frame_hdr의 이진 검색으로 FDE를 찾고, CFI 바이트코드로 레지스터를 복원하며 호출 스택을 역추적

커널 스택 언와인딩 — 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_info2+핵심 디버그 정보: DIE(Debugging Information Entry) 트리 — 함수, 변수, 타입, 스코프
.debug_abbrev2+DIE 태그/속성 약어 테이블 — .debug_info의 압축 인코딩을 해석하는 스키마
.debug_line2+소스 파일명/줄 번호 ↔ 기계어 주소 매핑 (라인 넘버 프로그램)
.debug_str2+중복 제거된 문자열 풀 (변수명, 파일 경로 등)
.debug_loc2-4변수 위치 목록: PC 범위별로 변수가 레지스터/메모리 어디에 있는지 기술
.debug_loclists5.debug_loc의 DWARF 5 후속 — 더 컴팩트한 인코딩
.debug_ranges3-4비연속 주소 범위 목록 (인라인 함수, 최적화된 코드용)
.debug_rnglists5.debug_ranges의 DWARF 5 후속
.debug_aranges2+주소 → 컴파일 유닛 빠른 조회 테이블
.debug_types4타입 정보 전용 섹션 (DWARF 5에서 .debug_info에 통합)
.debug_str_offsets5문자열 오프셋 인덱싱 — 문자열 참조를 인덱스로 압축
.debug_line_str5라인 테이블 전용 문자열 풀
/* 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가 자동 탐색
debuginfodHTTP 서버로 .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의 주요 특징:

/* 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 플래그를 설정하여 "재스케줄링이 필요하다"는 신호를 남깁니다. 이 플래그는 다음 시점에 검사됩니다:

플래그가 설정되는 주요 시점:

선점 모델 (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_FIFOSCHED_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 메커니즘이 존재합니다:

기본 설정은 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 제외)를 갖습니다.

SCHED_DEADLINE 타이밍: runtime / deadline / period t runtime deadline period Q D runtime deadline Q = runtime (실행 예산) D = deadline (완료 기한) P = 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) 계층에서 수행됩니다. 하드웨어 토폴로지를 반영하며, 하위 도메인에서 먼저 밸런싱을 시도하고 필요 시 상위 도메인으로 확장합니다.

스케줄링 도메인 계층 (2-socket NUMA) SD_NUMA (Node 0 + Node 1) SD_DIE — Node 0 SD_MC (LLC 0) CPU0 CPU1 CPU2 CPU3 SD_MC (LLC 1) CPU4 CPU5 CPU6 CPU7 SD_DIE — Node 1 SD_MC (LLC 2) CPU8 CPU9 C10 C11 SD_MC (LLC 3) C12 C13 C14 C15
도메인 레벨 플래그 범위 밸런싱 주기
SD_MC SD_SHARE_PKG_RESOURCES 같은 LLC(Last-Level Cache) 공유 코어 짧음 (~4ms)
SD_DIE SD_SHARE_PKG_RESOURCES 같은 소켓(다이) 내 모든 코어 중간 (~16ms)
SD_NUMA SD_NUMA NUMA 노드 간 길음 (~64ms)

로드 밸런싱 동작

로드 밸런싱은 다음 세 가지 경로로 트리거됩니다:

/* 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로 태스크를 마이그레이션합니다:

그룹 스케줄링과 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_nssched_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.statnr_throttled도 반드시 점검해야 합니다.

컨텍스트 스위칭 (Context Switch)

컨텍스트 스위칭은 현재 실행 중인 태스크를 다른 태스크로 전환하는 과정입니다. context_switch() 함수에서 수행되며, 크게 두 단계로 나뉩니다.

  1. 주소 공간 전환: switch_mm_irqs_off()에서 페이지 테이블(CR3 레지스터)을 새 태스크의 것으로 교체합니다. 커널 스레드 간 전환이면 이 단계를 건너뜁니다.
  2. 레지스터/스택 전환: switch_to()에서 커널 스택 포인터, 프로그램 카운터, 범용 레지스터 등을 전환합니다. 이 작업은 아키텍처 의존적인 어셈블리 코드로 구현됩니다.

컨텍스트 스위칭 과정 다이어그램

Context Switch 흐름 Task A (실행 중) schedule() 호출 레지스터 저장 (A 스택) pick_next_task() context_switch() (prev=A, next=B) switch_mm_irqs_off() switch_to(A, B) 레지스터 복원 (B 스택) Task B (대기 중) Task B 선택 CR3 교체 스택/IP 전환
컨텍스트 스위칭 과정 - schedule()에서 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의 관계는 다음과 같습니다:

필드 메인 스레드 추가 스레드 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() 함수에서 처리됩니다. 종료 과정은 다음과 같습니다:

  1. PF_EXITING 플래그 설정: 이중 종료 방지를 위해 플래그를 설정합니다.
  2. 리소스 해제: 타이머, 시그널 핸들러, 파일 디스크립터, 메모리 디스크립터, 세마포어 등을 해제합니다.
  3. exit_code 설정: 종료 코드를 task_struct.exit_code에 저장합니다.
  4. exit_notify(): 부모에게 SIGCHLD 시그널을 보내고, 자식 프로세스를 입양(reparenting) 처리합니다.
  5. 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, &param);

/* 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_FIFOSCHED_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 등)에서는 반드시 설정해야 하는 보안 항목입니다.