본문으로 이동
  • 현재 모든 문서는 작성 중입니다. 보시는 데 불편함이 있더라도 양해 부탁드립니다.

운영체제 기본/Linux의 ELF 로딩/초기 스택과 사용자 모드 진입

~ldmsys
관리자 (토론 | 기여)님의 2026년 10월 31일 (토) 08:46 판 (Linux v6.12 ELF 실행 이미지와 사용자 진입 설명)
(차이) ← 이전 판 | 최신판 (차이) | 다음 판 → (차이)

ELF 로딩 목차 · 이전: ELF 세그먼트와 주소 공간

실행 파일의 세그먼트를 매핑했다고 해서 사용자 프로그램이 곧바로 실행되는 것은 아니다. 커널은 시작할 명령어 주소, 최초의 사용자 스택, 인자와 환경 변수, 실행 환경을 설명하는 보조 정보를 함께 준비해야 한다. 동적 링크된 프로그램에서는 첫 사용자 명령어가 주 실행 파일이 아니라 동적 링커에 속할 수도 있다.

이 절은 ELF 세그먼트와 주소 공간 이후의 과정을 Linux v6.12 x86-64 기준으로 설명한다. 일반적인 execve()와 부팅 중 PID 1의 첫 실행은 같은 ELF 로더를 활용하지만, 커널에 들어온 경로와 사용자 모드로 나가는 경로는 구분해야 한다.

주 실행 파일의 진입점과 실제 첫 명령어

e_entry는 ELF 헤더가 지정하는 진입 주소이다. 주 실행 파일에 load_bias가 적용되면 실제 주소는 e_entry + load_bias가 된다. 그러나 이것이 항상 CPU가 처음 실행할 사용자 명령어 주소인 것은 아니다.

PT_INTERP가 있으면 커널은 그 항목에 기록된 경로의 ELF 인터프리터를 열고 검증한다. 일반적인 GNU/Linux 환경에서는 ld-linux-x86-64.so.2 같은 동적 링커가 여기에 해당한다. 인터프리터도 ELF 파일이므로 load_elf_interp()가 그 프로그램 헤더와 PT_LOAD를 이용하여 별도로 매핑한다. 주 프로그램과 인터프리터는 각각 자신의 배치 기준과 진입점을 가진다.[1]

실행 형태 커널이 준비하는 첫 사용자 실행 주소 주 프로그램의 진입 주소
PT_INTERP 없음 주 실행 파일의 e_entry + load_bias 첫 실행 주소와 같다.
PT_INTERP 있음 인터프리터의 e_entry + 인터프리터 배치 기준 보조 벡터의 AT_ENTRY로 전달된다.

Linux 코드에서는 주 실행 파일의 보정된 진입 주소를 담는 e_entry와, 최종적으로 START_THREAD에 넘기는 elf_entry를 구분해 읽으면 이해하기 쉽다. 인터프리터가 없으면 둘이 같아지지만, 있으면 elf_entry는 인터프리터의 진입 주소가 된다.

e_entry가 항상 main의 주소라는 설명은 틀리다. 일반적인 C 실행 파일에서는 런타임 시작 코드인 _start 등이 진입점이 되고, 그 코드가 필요한 런타임 초기화와 호출 규약 준비를 수행한 뒤 main()으로 이어진다. 그렇다고 ELF 규격이 _start라는 이름을 강제하는 것도 아니다. 링크 설정이나 언어 런타임에 따라 다른 진입점을 사용할 수 있다. 커널은 심벌 이름으로 main이나 _start를 찾지 않는다.

커널 로더와 동적 링커의 역할

커널의 ELF 로더는 실행할 이미지와 인터프리터를 배치하고, 사용자 프로그램이 시작할 수 있는 기본 실행 상태를 만든다. 모든 공유 라이브러리를 찾아 연결하고, 각 심벌의 주소를 해석하는 일을 커널이 대신 수행하는 것은 아니다.

사용자 공간의 동적 링커는 자신의 초기 실행 상태를 정리하고, 주 프로그램의 동적 정보를 해석한다. 일반적인 동적 링크 과정에서는 필요한 공유 객체를 찾고 매핑하며, 심벌을 해석하고 재배치를 수행하고, TLS와 초기화 절차 등을 준비한다. 이후 주 프로그램의 시작 코드로 제어를 넘긴다. 구체적인 순서와 지연 바인딩 여부는 동적 링커 구현과 링크 옵션에 따라 달라지며, 지연 바인딩을 사용하면 일부 심벌의 해석은 첫 호출까지 미뤄진다.[2]

Kernel ELF loader
  map main executable
  map PT_INTERP interpreter
  build initial stack and auxv
  prepare user registers
            |
            v
Dynamic linker entry       [user mode]
  prepare runtime linking
  map required shared objects
  resolve relocations as required
            |
            v
Main executable entry      [user mode]
  language/runtime startup
            |
            v
main() or another language-level entry

이 그림은 인터프리터가 있는 일반적인 동적 실행의 개념도이다. 인터프리터 없는 실행에서는 첫 사용자 실행 주소가 곧 주 실행 이미지의 진입점이며, 정적 PIE처럼 자체 시작 코드가 필요한 재배치를 수행하는 형태도 있다. 커널이 주소 배치에 load_bias를 적용했다는 사실과, 사용자 공간에서 재배치 항목을 처리했다는 사실을 혼동해서는 안 된다.

argv와 envp가 새 주소 공간으로 넘어가는 방법

execve(path, argv, envp)를 호출하는 시점의 argv와 envp는 이전 사용자 주소 공간의 포인터이다. 이전 주소 공간을 없애고 새 프로그램을 실행할 것이므로, 커널은 그 포인터 값을 그대로 새 프로그램에 넘길 수 없다.

exec 준비 과정은 인자와 환경 변수의 개수 및 크기를 확인하고 문자열들을 새 실행을 위한 메모리에 복사한다. 새 스택의 최종 위치는 setup_arg_pages() 등에서 정리되며, ELF 로더의 create_elf_tables()가 복사된 문자열을 가리키는 새 사용자 주소들을 사용해 포인터 배열과 보조 벡터를 작성한다. 인자 개수·총량 제한과 메모리 접근 실패 처리는 이 준비 과정의 일부이다.[3]

따라서 새 프로그램이 받는 것은 “이전 프로그램의 argv 포인터”가 아니라 새 주소 공간에 존재하는 문자열과 그것을 가리키는 새 포인터 배열이다. 환경 문자열도 같은 원리로 전달된다.

최초 사용자 스택의 구조

x86-64의 최초 사용자 스택에는 인자 개수 argc, 인자 포인터 배열, 환경 변수 포인터 배열, 보조 벡터(auxiliary vector, auxv)가 놓인다. 문자열과 난수 데이터 등은 같은 초기 스택 영역 안에 별도로 자리 잡고 포인터가 이를 가리킨다. [1]

Higher addresses
+--------------------------------+
| Argument/environment strings   |
| Platform strings, random bytes |
| Alignment/padding as needed    |
+--------------------------------+
| Auxiliary vector               |
|   (type, value) pairs          |
|   ...                          |
|   AT_NULL, 0                   |
+--------------------------------+
| envp[0], envp[1], ...           |
| NULL                           |
+--------------------------------+
| argv[0], argv[1], ...           |
| NULL                           |
+--------------------------------+
| argc                           | <-- initial RSP
+--------------------------------+
Lower addresses

메모리에서 낮은 주소부터 읽는 순서는 argc → argv → NULL → envp → NULL → auxv이다. x86-64 네이티브 ABI에서는 스택 슬롯과 포인터를 64비트 단위로 해석하며, 커널은 초기 스택의 정렬도 맞춘다. 실행 시작은 일반 C 함수에 call한 결과가 아니므로, 초기 스택에 main()의 정상적인 호출자 반환 주소가 있다고 가정하면 안 된다. 런타임 시작 코드가 이 원시 시작 상태를 읽어 언어 런타임과 함수 호출 규약에 맞는 상태를 만든다.

또한 새 스택은 사용자 스택이다. 커널이 현재 ELF 로딩을 수행하는 데 사용하는 커널 스택과는 별개이다. bprm->p에 마련된 새 스택 위치를 저장해 두었다가, 나중에 사용자 모드로 나갈 레지스터 상태의 RSP로 사용한다.

보조 벡터는 커널과 시작 코드 사이의 약속이다

argv와 envp가 호출자가 제공한 문자열 정보라면, 보조 벡터는 커널이 실행 환경에 관해 제공하는 구조화된 정보이다. 각 항목은 종류와 값의 쌍이며, 값은 숫자일 수도 있고 새 주소 공간의 주소일 수도 있다.

항목 의미와 활용
AT_PHDR 주 실행 파일의 프로그램 헤더 테이블이 메모리에 놓인 주소. 단순한 파일 오프셋 e_phoff가 아니다.
AT_PHENT, AT_PHNUM 프로그램 헤더 항목의 크기와 개수
AT_ENTRY 주 실행 파일의 보정된 진입 주소. 인터프리터가 있을 때 최초 RIP와 구별해야 한다.
AT_BASE ELF 인터프리터의 배치 기준 주소. 주 실행 파일의 PIE 기준 주소를 뜻하지 않으며, 인터프리터가 없는 일반적인 경우 0이다.
AT_PAGESZ 실행 환경이 전달하는 페이지 크기
AT_RANDOM 커널이 초기 스택에 배치한 난수 바이트를 가리키는 주소
AT_UID, AT_EUID, AT_GID, AT_EGID 실행 후 자격 증명에 관한 값
AT_SECURE 동적 링커와 런타임이 보안상 제한된 실행 환경을 판단하는 데 사용하는 값
AT_EXECFN 실행 파일 이름 문자열을 가리키는 주소
AT_NULL 보조 벡터의 끝

AT_PHDR를 만들 때 Linux는 프로그램 헤더 테이블의 파일 위치를 포함하는 PT_LOAD를 찾아 해당 세그먼트의 파일 오프셋과 가상 주소 사이의 관계로 메모리 주소를 계산하고 load_bias를 반영한다. 동적 링커는 이 정보로 주 프로그램의 세그먼트와 동적 정보에 접근할 수 있다.[1]

auxv는 동적 링크 전용 구조가 아니다. 인터프리터가 없는 프로그램의 시작 코드도 페이지 크기나 하드웨어 기능 같은 정보를 활용할 수 있다. 반대로 auxv의 모든 값이 주소인 것도 아니므로, 종류를 무시하고 일괄 역참조해서는 안 된다.

start_thread는 CPU를 즉시 사용자 모드로 바꾸지 않는다

load_elf_binary()의 마지막 부분은 실행 상태 마무리를 거쳐 START_THREAD를 호출한다. x86-64 네이티브 실행에서는 start_thread()가 start_thread_common()으로 이어지고, 현재 태스크의 pt_regs에 새 사용자 실행 상태를 기록한다.[4]

start_thread_common()에서 새 진입 주소와 스택, 세그먼트 선택자를 저장하는 부분은 다음과 같다.[4]

regs->ip  = new_ip;
regs->sp  = new_sp;
regs->csx = _cs;
regs->ssx = _ss;

new_ip와 new_sp는 사용자 RIP와 RSP가 된다. 네이티브 x86-64의 start_thread()는 _cs와 _ss에 __USER_CS와 __USER_DS를 전달한다. 플래그와 나머지 세그먼트 상태도 함께 정리한다.

이 시점에 커널 C 코드가 사용자 주소로 일반 함수 호출을 하거나, 그 자리에서 CPU 권한 수준을 낮추는 것은 아니다. 나중에 사용자 모드로 복귀할 때 사용할 저장된 레지스터 상태를 바꾸는 것이다. 실제 권한 전환과 레지스터 복원은 아키텍처의 사용자 복귀 경로가 수행한다.

선택자와 GDT 항목의 관계는 GDT와 LDT 초기화에서 설명한다. CS와 SS의 사용자 선택자는 사용자 권한 수준으로 나갈 상태를 구성하지만, 프로그램이 접근할 수 있는 주소 범위와 읽기·쓰기·실행 보호는 페이지 테이블 등과 함께 결정된다. 세그먼트 선택자만 바꾸면 임의의 메모리 접근이 가능해지는 것은 아니다. 이 구분은 보호와 가상 메모리의 내용과 연결된다.

일반 execve는 이전 프로그램으로 돌아가지 않는다

일반적인 execve()는 사용자 코드가 시스템 콜(System Call)을 통해 커널에 들어오면서 시작한다. 성공하면 새 실행 이미지와 초기 스택이 준비되고, 저장된 사용자 복귀 상태가 새 진입점과 새 스택으로 바뀐다. 커널의 exec 처리 함수들이 성공을 반환하는 것과, 이전 사용자 프로그램의 호출 지점으로 돌아가는 것은 다른 일이다.

Old user program
    execve(...)
        |
        v
System call entry
    exec preparation and binary-format dispatch
    load_elf_binary()
    START_THREAD(new_entry, new_stack)
        |
        v
Kernel system-call exit path
        |
        v
New user image, not the old execve call site

따라서 성공한 exec는 이전 프로그램 입장에서 반환하지 않는다. 성공을 나타내는 커널 내부 반환값이 있더라도, 호출 뒤에 있던 이전 사용자 명령어를 실행하지 않는다. 실패가 이전 이미지를 유지할 수 있는 단계에서 발생했다면 호출자에게 오류가 돌아갈 수 있다. 반면 이미지 교체를 되돌릴 수 없는 지점 이후의 실패는 단순히 이전 프로그램으로 복귀하는 형태로 처리할 수 없다.

x86-64의 전통적인 사용자 복귀 코드에는 빠른 SYSRET을 쓸 수 있는지 검사하는 경로와, 더 일반적인 상태를 복원하는 IRET 경로가 있다. exec 이후처럼 사용자 레지스터가 새로 구성된 경우까지 “시스템 콜은 언제나 SYSRET으로 복귀한다”라고 설명해서는 안 된다. 또한 v6.12에는 FRED 지원에 따른 별도 복귀 경로도 있으므로, 특정 최종 명령을 모든 시스템에 무조건 적용하지 않는다.[5]

이러한 커널 모드와 사용자 모드 사이의 전환은 그 자체로 다른 태스크를 선택하는 컨텍스트 스위치(Context Switch)와 같지 않다. exec는 현재 실행 이미지의 교체이며, 반드시 새 PID를 만들거나 다른 프로세스로 CPU를 넘기는 연산이 아니다.

PID 1은 사용자 시스템 콜 없이 같은 로더를 사용한다

어떤 파일을 init으로 선택하는지는 루트 파일시스템과 init 선택에서 다룬다. 여기서는 선택된 실행 파일을 사용자 공간에서 시작하는 과정에 집중한다.

부팅 중 처음 실행되는 PID 1은 아직 이전 사용자 프로그램에서 execve()를 호출할 수 없다. Linux v6.12의 rest_init()는 user_mode_thread(kernel_init, NULL, CLONE_FS)로 PID 1 태스크를 만들며, 이 태스크는 처음에는 커널 함수 kernel_init()를 수행한다. 여기서 “커널 함수를 실행하며 시작한다”와 일반적인 PF_KTHREAD 커널 스레드라는 분류를 구분해야 한다. 실제 kernel_execve()는 PF_KTHREAD인 태스크의 호출을 거부한다.[6][3]

kernel_init()가 선택한 init 프로그램을 실행할 때는 run_init_process()가 커널에 있는 파일 이름·인자·환경 문자열을 kernel_execve()로 넘긴다. 이 함수는 사용자 포인터를 받는 시스템 콜 입구를 우회하지만, 공통 exec 처리와 바이너리 형식 핸들러를 통해 ELF 로더에 도달한다.

PID 1 first scheduled
  ret_from_fork_asm
    ret_from_fork
      kernel_init
        run_init_process
          kernel_execve
            common exec / binary-format dispatch
              load_elf_binary
                prepare mappings, stack, pt_regs
          return success
        return success
      return from kernel_init
    syscall_exit_to_user_mode
  architecture user-return path
    first user instruction

중요한 점은 kernel_execve()가 성공했다고 해서 C 함수 호출의 중간에서 바로 사용자 모드로 점프하지 않는다는 것이다. 성공하면 kernel_init()가 반환하고, 태스크의 시작 함수 호출을 감싸고 있던 ret_from_fork()가 이후 처리를 수행한다. v6.12 x86의 ret_from_fork()는 시작 함수가 돌아온 뒤 regs->ax를 정리하고 syscall_exit_to_user_mode()를 호출한다. 이어 ret_from_fork_asm의 사용자 복귀 경로가 새 레지스터 상태를 이용해 사용자 모드로 진입한다.[7][5]

여기서 syscall_exit_to_user_mode라는 함수 이름은 앞서 사용자 프로그램이 실제 시스템 콜 명령을 실행했다는 뜻이 아니다. 사용자 모드로 나가기 전에 필요한 공통 정리를 이 경로에서도 이용하는 것이다. 첫 PID 1과 일반 exec가 만나는 지점은 ELF 이미지와 초기 사용자 상태를 준비하는 공통 실행 로직이며, 출발점까지 같은 것은 아니다.

또한 PID 1이 동적 링크된 ELF라면 첫 사용자 명령어는 init 실행 파일의 진입점이 아니라 그 PT_INTERP가 지정한 동적 링커의 진입점일 수 있다. “커널이 init의 main으로 점프한다”는 표현은 실행 파일 로딩, 런타임 시작, 사용자 권한 전환을 모두 생략한 설명이다. 정확한 경계는 커널이 준비한 첫 사용자 실행 상태로 복귀한 뒤, 사용자 공간의 시작 절차가 이어진다는 것이다.

참고 문헌

  1. ↑ 1.0 1.1 1.2 Linux v6.12, fs/binfmt_elf.c, load_elf_binary(), load_elf_interp(), create_elf_tables().
  2. ↑ Linux man-pages, ld.so(8), DESCRIPTION 및 LD_BIND_NOW.
  3. ↑ 3.0 3.1 Linux v6.12, fs/exec.c, copy_strings(), setup_arg_pages(), do_execveat_common(), kernel_execve().
  4. ↑ 4.0 4.1 Linux v6.12, arch/x86/kernel/process_64.c, start_thread_common(), start_thread().
  5. ↑ 5.0 5.1 Linux v6.12, arch/x86/entry/entry_64.S, entry_SYSCALL_64, ret_from_fork_asm, 사용자 모드 복귀 경로.
  6. ↑ Linux v6.12, init/main.c, rest_init(), kernel_init(), run_init_process().
  7. ↑ Linux v6.12, arch/x86/kernel/process.c, copy_thread(), ret_from_fork().