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

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

~ldmsys

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

ELF 세그먼트와 주소 공간에서는 실행 파일의 코드·데이터와 BSS를 프로세스의 가상 주소 공간에 배치했다. 다음으로 필요한 것은 프로그램에 전달할 인자와 스택을 만들고, 어느 명령어부터 실행할지 CPU의 복귀 상태에 기록하는 것이다.

여기서도 정적 링크된 ET_EXEC를 먼저 설명한다. 앞의 예처럼 코드가 0x401000에 배치되고 ELF 헤더의 e_entry도 0x401000이라면, 이 주소가 새 프로그램의 첫 실행 주소이다. 기준 구현은 Linux v6.12의 x86-64와 RISC-V RV64이다. 실행 파일의 명령어와 대상 아키텍처 정보는 각각의 CPU에 맞아야 한다.

인자와 환경 변수를 새 스택에 준비한다

execve(path, argv, envp)에서 argv는 인자 문자열들의 배열이고, envp는 환경 변수 문자열들의 배열이다. 예를 들어 실행할 프로그램이 두 개의 인자를 받는다면, 새 프로그램은 인자 개수와 각 문자열의 주소를 알아야 한다.

호출 당시의 포인터는 이전 프로그램의 주소 공간을 가리킨다. exec는 그 주소 공간을 새 이미지로 교체하므로 포인터 값만 그대로 넘길 수 없다. 커널은 exec 준비 과정에서 문자열을 새 실행을 위한 메모리에 복사하고, 새 사용자 스택의 위치를 정한다. 이어 ELF 로더의 create_elf_tables()가 복사된 문자열을 가리키는 새 포인터 배열을 작성한다.[1][2]

두 아키텍처의 네이티브 64비트 초기 사용자 스택은 낮은 주소부터 argc, argv, envp, 보조 벡터 순서로 읽을 수 있다. 각 포인터 배열은 NULL로 끝난다. 다음 그림은 실제 주소가 증가하는 방향을 아래쪽으로 표시한 개념도이다.

Lower addresses
initial SP  --> +-------------------------------+
                | argc                          |
                | argv[0]                       |
                | argv[1]                       |
                | ...                           |
                | NULL                          |
                | envp[0]                       |
                | envp[1]                       |
                | ...                           |
                | NULL                          |
                | Auxiliary vector: type, value |
                | ...                           |
                | AT_NULL, 0                    |
                | Padding / other startup data  |
                | Argument/environment strings  |
                +-------------------------------+
Higher addresses

배열에 들어가는 것은 문자열 자체가 아니라 문자열을 가리키는 새 사용자 주소이다. 커널은 스택 정렬도 맞춘다. 보조 벡터(auxv)는 페이지 크기처럼 시작 코드가 필요로 하는 실행 환경 정보를 전달하는 항목들의 목록이다. 지금 단계에서는 커널이 인자 외에도 몇 가지 시작 정보를 함께 제공한다고 이해하면 된다.

이 스택은 사용자 프로그램이 사용할 스택이다. 커널이 현재 ELF 로딩 함수를 실행하는 데 쓰는 커널 스택과는 다르다. 커널은 새 스택의 위치를 보관했다가 사용자 모드로 나갈 때 x86-64의 RSP 또는 RV64의 sp에 적용한다.

사용자 모드 진입: x86-64와 RISC-V

ELF 세그먼트를 매핑하고 초기 사용자 스택을 만드는 큰 흐름은 공통이다. 마지막에 CPU가 어떤 주소·스택·권한으로 실행을 시작할지 준비하는 방법은 아키텍처마다 다르다. 여기서는 Linux v6.12, 정적 링크된 ET_EXEC를 기준으로 x86-64의 기본 복귀 경로와 RISC-V RV64의 S-mode 커널에서 U-mode로 나가는 경로를 비교한다.

ELF 로더는 마지막에 START_THREAD(..., elf_entry, bprm->p)를 호출한다. RV64에서는 공통 헤더의 기본 매크로가 이를 start_thread(regs, elf_entry, start_stack)로 연결한다. 진입 주소는 이 예의 e_entry이며, 스택 주소는 인자와 환경 변수가 준비된 새 사용자 스택이다.[3]

먼저 저장된 복귀 상태를 바꾼다

두 아키텍처 모두 start_thread()에서 곧바로 사용자 코드를 실행하지 않는다. 현재 태스크의 커널 스택에 있는 pt_regs에 나중에 복원할 사용자 상태를 기록한다.

준비할 상태 x86-64 RISC-V RV64
첫 명령어 주소 regs->ip: 나중의 RIP regs->epc: 나중에 sepc에 쓸 값
초기 사용자 스택 regs->sp: 나중의 RSP regs->sp: 나중의 sp(x2)
사용자 권한으로 복귀 사용자 CS·SS 선택자 저장된 status의 SPP=0
실행 상태 저장된 flags 저장된 status의 SPIE=1

x86-64의 start_thread_common()은 새 명령어·스택 주소, 사용자 선택자, 초기 플래그를 설정한다. RV64의 start_thread()는 epc와 sp를 기록하고, status를 SR_PIE에서 시작해 구성한다. 여기서 SR_PIE는 SR_SPIE를 뜻하며, 복귀 권한을 나타내는 SPP는 0이다.[4]

특히 regs->epc는 메모리 속 필드이고 sepc는 CPU의 제어·상태 레지스터(CSR)이다. 이름이 비슷해도 같은 저장 장소가 아니다. 이 시점의 CPU는 여전히 커널 코드를 실행하며 커널 스택을 사용한다.

복귀 코드가 실제 CPU 상태를 적용한다

                 ELF mappings + initial user stack
                                |
                       saved state: pt_regs
                                |
             +------------------+------------------+
             |                                     |
          x86-64                              RISC-V RV64
     ip, sp, cs, ss, flags                  epc, sp, status
             |                                     |
     user-return assembly                   ret_from_exception
             |                              epc    -> sepc
     IRETQ return frame                     status -> sstatus
             |                              sp     -> x2
           IRETQ                                   |
             |                                    SRET
             |                                     |
    RIP=e_entry, CPL=3                    PC=e_entry, U-mode
    RSP=new user stack                    sp=new user stack

RISC-V의 ret_from_exception은 저장된 status와 epc를 각각 sstatus, sepc에 쓰고 일반 레지스터와 사용자 sp를 복원한 뒤 sret를 실행한다. sret는 SPP=0에 따라 U-mode로 전환하고 sepc의 주소에서 실행을 시작한다. 이때 SIE는 SPIE 값으로 갱신된다. 스택 포인터를 복원하는 것은 앞의 어셈블리 명령이며, sret 자체가 사용자 스택을 찾아 주는 것은 아니다.[5][6]

x86-64에서는 복귀 어셈블리가 저장된 상태를 복원하고, RIP·CS·RFLAGS·RSP·SS로 이루어진 복귀 프레임을 iretq가 적용하여 CPL 3의 사용자 코드로 나간다. 일반 시스템 콜에는 SYSRET 복귀도 있지만, 여기의 exec가 준비한 새 레지스터 상태는 그 경로의 조건을 만족하지 않아 IRET 경로로 간다.[7]

두 경우 모두 새로 준비한 복귀 상태가 이전 프로그램의 상태를 대체한다. 따라서 exec 성공 뒤에는 이전 프로그램의 호출 다음 줄이 아니라 새 프로그램의 진입 주소를 실행한다.


커널에서 나가면 새 프로그램이 시작된다

일반적인 exec는 이전 사용자 프로그램이 시스템 콜(System Call)로 커널에 들어오면서 시작된다. 성공한 뒤 커널이 사용자 모드로 나갈 때는, 이전 호출 지점이 아니라 새로 설정한 진입 주소와 스택을 사용한다.

Old user program: execve(...)
                |
                v
Kernel: select ELF handler
        map code, data and BSS
        build initial user stack
        set entry = e_entry, stack = new stack
                |
                v
User-return path: restore prepared state
                |
                v
New user program: first instruction at e_entry

따라서 커널 내부의 exec 처리 함수가 성공을 반환하는 것과, 이전 사용자 프로그램의 execve() 호출 다음 줄로 돌아가는 것은 다르다. 성공한 exec는 이전 프로그램으로 돌아가지 않는다. 현재 태스크의 실행 이미지가 바뀌는 것이므로 새 프로세스를 만드는 동작도 아니며, 다른 태스크로 CPU를 넘기는 컨텍스트 스위치(Context Switch)와도 구별된다.

첫 명령어를 가져올 때 해당 코드 페이지가 아직 연결되지 않았다면 페이지 폴트가 발생할 수 있다. 커널이 페이지를 준비한 뒤 같은 명령어 접근을 재개하므로, 이것도 정상적인 시작 과정의 일부이다. 자세한 내용은 첫 접근과 페이지 폴트로 이어진다.

e_entry는 보통 C의 main() 주소가 아니다. 일반적인 C 프로그램은 _start 같은 런타임 시작 코드에서 스택의 인자 정보를 읽고 실행 환경을 준비한 뒤 main()을 호출한다. ELF가 _start라는 이름을 강제하는 것도 아니다. 커널은 함수 이름을 찾지 않고 ELF에 기록된 진입 주소를 사용한다.

첫 init은 커널에서 이 과정을 시작한다

부팅 중 PID 1은 아직 이전 사용자 프로그램을 실행한 적이 없으므로, 사용자 코드가 시스템 콜을 호출하는 방식으로 시작할 수 없다. 어떤 파일을 init으로 선택하는지는 루트 파일시스템과 init 선택에서 다루고, 여기서는 선택된 파일을 실행하는 연결만 살펴본다.

PID 1 태스크는 먼저 커널의 kernel_init()를 수행한다. 여기서 run_init_process()가 kernel_execve()를 호출한다. 사용자 시스템 콜 입구를 통과하지 않는다는 차이는 있지만, 공통 exec 처리와 ELF 로더를 통해 주소 공간, 초기 스택, 저장된 사용자 레지스터를 준비하는 원리는 같다.[8]

성공하면 kernel_execve()에서 돌아온 kernel_init()가 반환한다. x86-64에서는 이 시작 함수를 호출했던 ret_from_fork()가 사용자 복귀 준비를 수행하고, 이어 어셈블리 복귀 경로가 준비된 레지스터 상태를 복원한다. 이때 비로소 첫 사용자 명령어를 실행한다.[9][10]

RV64에서는 어셈블리 ret_from_fork가 kernel_init()을 호출하고, 반환 뒤 syscall_exit_to_user_mode()를 거쳐 ret_from_exception으로 이어진다. 여기서 저장된 상태를 복원한 뒤 sret로 사용자 모드에 진입한다.[5]

즉 init을 실행할 때도 커널이 C 함수 호출처럼 init의 main()을 부르는 것은 아니다. 새 실행 이미지와 사용자 상태를 준비한 뒤, 그 상태로 사용자 모드에 진입한다는 기본 흐름이 그대로 적용된다.

동적 링크된 프로그램에서는 무엇이 달라지는가

이제 기본 흐름에 동적 링커를 추가할 수 있다. ELF에 PT_INTERP가 있으면 이 항목은 사용자 공간 동적 링커의 경로를 지정한다. 커널은 주 실행 파일에 더해 동적 링커도 매핑하고, 첫 실행 주소를 주 실행 파일이 아닌 동적 링커의 진입 주소로 준비한다.[2]

동적 링커는 사용자 모드에서 필요한 공유 라이브러리를 준비하고 주소 연결 작업을 수행한 뒤 주 프로그램의 시작 코드로 제어를 넘긴다.[11] 이 라이브러리 연결 작업을 커널 ELF 로더가 전부 수행하는 것은 아니다. 주 프로그램의 진입 주소는 초기 스택의 보조 벡터 AT_ENTRY로 전달되므로, 동적 링커의 첫 실행 주소와 구별할 수 있다.

인터프리터 없는 정적 실행은 “커널 → 주 프로그램 시작 코드”이고, 일반적인 동적 실행은 “커널 → 동적 링커 → 주 프로그램 시작 코드”이다. 어느 쪽이든 커널이 매핑과 스택을 준비하고 사용자 레지스터를 설정한다는 골격은 같다.

참고 문헌

  1. ↑ Linux v6.12, fs/exec.c, copy_strings(), setup_arg_pages(), kernel_execve().
  2. ↑ 2.0 2.1 Linux v6.12, fs/binfmt_elf.c, create_elf_tables(), load_elf_binary().
  3. ↑ Linux v6.12, fs/binfmt_elf.c, load_elf_binary(); include/linux/elf.h, START_THREAD.
  4. ↑ Linux v6.12, arch/x86/kernel/process_64.c, start_thread_common(); arch/riscv/kernel/process.c, start_thread(); arch/riscv/include/asm/csr.h.
  5. ↑ 5.0 5.1 Linux v6.12, arch/riscv/kernel/entry.S, ret_from_exception, ret_from_fork.
  6. ↑ RISC-V Supervisor-Level ISA, sstatus, sepc, SRET.
  7. ↑ Linux v6.12, arch/x86/include/asm/elf.h, elf_common_init(); arch/x86/entry/common.c, do_syscall_64(); arch/x86/entry/entry_64.S, 사용자 복귀 경로와 iretq.
  8. ↑ Linux v6.12, init/main.c, rest_init(), kernel_init(), run_init_process().
  9. ↑ Linux v6.12, arch/x86/kernel/process.c, ret_from_fork().
  10. ↑ Linux v6.12, arch/x86/entry/entry_64.S, ret_from_fork_asm, 사용자 모드 복귀 경로.
  11. ↑ Linux man-pages, ld.so(8).