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

운영체제 기본/Linux의 ELF 로딩/execve와 실행 이미지 교체

~ldmsys

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

이 절은 Linux v6.12의 x86-64를 기준으로, 실행 파일을 받은 커널이 기존 프로그램을 새 프로그램으로 바꾸는 흐름을 설명한다. 먼저 동적 링커가 필요 없는 정적 링크된 ET_EXEC 실행 파일을 대상으로 삼는다.

execve는 현재 프로세스의 프로그램을 바꾼다

execve()는 호출한 프로세스의 실행 이미지를 교체하는 시스템 콜(System Call)이다. 새 프로세스를 만드는 호출은 아니다. 예를 들어 셸은 먼저 자식 프로세스를 만든 다음, 그 자식에서 execve()를 호출하여 실행할 프로그램을 바꿀 수 있다. 프로세스 생성과 프로그램 로딩은 서로 다른 작업이다.[1]

실행 이미지에는 코드뿐 아니라 데이터, 스택과 같은 사용자 주소 공간의 내용과 실행을 시작할 위치도 포함된다. execve()에 성공하면 기존 프로그램의 코드와 스택으로 돌아가지 않고 새 프로그램을 실행한다. 그러나 프로세스의 PID는 유지된다. 즉 “다른 프로세스가 된다”기보다 “같은 프로세스가 다른 프로그램을 실행한다”라고 이해하는 편이 정확하다. 프로세스 생성과 실행의 관계는 프로세스와 스레드를 참고한다.

실행 요청에서 열린 파일과 준비 상태로

사용자 프로그램은 execve(path, argv, envp)로 실행 파일의 경로, 인수 문자열 배열, 환경 문자열 배열을 전달한다. 커널은 먼저 사용자 공간의 경로명을 가져온 뒤 공통 실행 준비 함수인 do_execveat_common()으로 들어간다. 함수 이름에 at가 붙은 이유는 실행 대상을 지정하는 또 다른 시스템 콜인 execveat()와 준비 경로를 공유하기 때문이다. 여기서는 일반적인 execve()의 흐름만 따라간다.[2]

execve
  -> do_execve
     -> do_execveat_common
        -> alloc_bprm
           -> do_open_execat -> do_filp_open
           -> bprm_mm_init
        -> copy argument and environment strings
        -> bprm_execve
           -> exec_binprm
              -> search_binary_handler
                 -> load_elf_binary

alloc_bprm()은 실행 파일을 열고, 이번 실행 요청의 준비 상태를 담을 linux_binprm 객체를 만든다. 파일을 여는 내부 경로는 do_open_execat()에서 do_filp_open()으로 이어진다. VFS가 경로를 탐색하여 파일을 열면 커널은 그 결과인 struct file을 보관한다. 이후 로더는 경로 문자열만 보고 동작하는 것이 아니라, 이 열린 파일 객체를 통해 실행 파일을 읽는다. 이름과 열린 파일 객체의 차이는 VFS와 파일 객체에서 설명한다.[2]

linux_binprm은 새 프로세스가 아니다. 열린 파일, 새 주소 공간, 인수 문자열을 쌓을 위치 등 로딩 중에 필요한 상태를 묶어 둔 객체다. bprm_mm_init()은 새 주소 공간을 나타내는 mm_struct와 임시 스택 영역을 준비한다. 이때는 아직 현재 프로세스의 주소 공간을 바꾸지 않는다.[2]

커널은 인수와 환경 문자열의 크기를 확인하고 새 스택에 사용할 공간으로 문자열을 복사한다. 기존 주소 공간을 나중에 버리더라도 새 프로그램에 전달할 입력은 남아 있어야 하기 때문이다. 다만 이 단계에서 최종 사용자 스택의 모든 배열과 값까지 완성되는 것은 아니다. 전체 스택 구성은 ELF 로더가 실행 이미지에 맞춰 마무리한다.

binfmt가 ELF 로더를 선택한다

커널은 파일 이름의 확장자로 실행 형식을 결정하지 않는다. 열린 파일의 앞부분을 읽고, 등록된 실행 형식 핸들러가 이를 해석할 수 있는지 확인한다. 이 실행 형식 처리 체계를 binfmt라고 한다.

bprm_execve()에서 exec_binprm()을 거쳐 호출되는 search_binary_handler()는 먼저 prepare_binprm()으로 파일의 앞부분을 bprm->buf에 읽는다. v6.12에서 이 버퍼의 크기는 256바이트다. 이것은 실행 파일 전체를 담는 버퍼가 아니라, 형식을 알아보기 위한 작은 입력이다.[2][3]

그 다음 등록된 핸들러의 load_binary()를 호출한다. 아직 이미지 교체를 확정하지 않은 핸들러가 -ENOEXEC를 반환하면 다음 핸들러를 시도한다. ELF 핸들러의 이 함수는 load_elf_binary()다. ELF 매직과 실행 파일 형식, 대상 아키텍처 등을 확인한 뒤 실제 ELF 로딩을 진행한다.[4]

이렇게 공통 준비와 형식별 처리를 나누면 파일 열기와 인수 복사는 공통 경로가 맡고, “파일의 어느 부분을 어떤 주소에 배치할 것인가”는 해당 형식을 아는 로더가 맡을 수 있다. ELF 로더는 처음 읽은 버퍼만 사용하는 것이 아니라, 프로그램 헤더를 추가로 읽어 메모리에 배치할 구간을 알아낸다. 이 구간의 해석이 다음 절의 주제다.

기존 주소 공간을 버리고 새 이미지로 전환한다

지금까지는 기존 프로그램의 주소 공간을 유지하면서 파일을 열고, 인수를 복사하고, ELF 헤더를 검사했다. 준비 중 파일을 찾지 못하거나 형식을 처리할 수 없으면 기존 프로그램으로 돌아가 오류를 보고할 수 있다. 가능한 오류를 먼저 확인해야 실행할 수도 없는 파일 때문에 현재 프로그램을 잃지 않는다.

ELF 로더가 교체를 진행하기로 하면 begin_new_exec()를 호출한다. 이 함수 안에서 point_of_no_return을 설정한 뒤 기존 실행 상태를 정리하고, exec_mmap()으로 준비한 새 주소 공간을 현재 프로세스에 설치한다. 여러 스레드가 있었다면 이 과정에서 실행을 요청한 스레드만 남는다. 이후 ELF 로더가 새 주소 공간에 프로그램의 각 구간과 스택을 구성한다.[2][4]

old user image
  |
  | open file, copy inputs, inspect ELF
  | ordinary failure can return to old image
  v
begin_new_exec
  point_of_no_return
  clean up old execution state
  exec_mmap(new_mm)
  |
  v
new address space
  map ELF segments
  build initial stack
  set initial user registers

여기서 주소 공간 교체는 기존 프로그램의 물리 메모리에 파일 내용을 그대로 덮어쓰는 작업이 아니다. 현재 프로세스가 사용할 가상 주소 공간과 매핑 관계를 새 것으로 바꾸는 작업이다. 실행 파일 전체를 즉시 물리 메모리로 복사하는 것과도 구별해야 한다.

point_of_no_return은 실제 주소 공간 교체보다 먼저 설정된다. 이미 다른 스레드와 실행 상태를 정리하기 시작했다면 단순히 이전 코드로 돌아갈 수 없기 때문이다. 이 경계를 넘은 뒤 로딩에 실패하면 커널은 기존 프로그램으로 정상 복귀하지 않고 프로세스를 종료시킨다. 따라서 실행 교체는 모든 단계에서 되돌릴 수 있는 작업은 아니다.[2]

새 프로그램의 시작과 PID 1의 실행

로딩이 끝나면 ELF 로더는 새 프로그램의 진입 주소와 초기 스택 주소를 사용자 레지스터 상태에 설정한다. 커널 내부 함수들은 반환할 수 있지만, 사용자 모드로 나갈 때 실행할 위치는 이미 바뀌었다. 이것이 “execve()는 성공하면 반환하지 않는다”는 말의 의미다. 이전 프로그램의 호출 다음 줄이 아니라 새 프로그램의 시작 코드가 실행된다.[4]

부팅 중 PID 1의 프로그램을 실행할 때도 같은 로더를 사용한다. 어떤 파일을 실행할지는 루트 파일시스템과 init 선택에서 다룬다. 선택된 파일을 실행하는 run_init_process()는 kernel_execve()를 호출한다. 이 함수는 커널에 있는 인수 문자열을 복사한다는 차이가 있지만, bprm_execve()에서 일반 실행 경로와 합쳐져 같은 binfmt와 ELF 로더를 사용한다.[2][5]

일반 execve()는 시스템 콜의 사용자 복귀 경로로 나가고, PID 1의 첫 실행은 초기 태스크의 커널 함수가 반환하는 경로에서 사용자 모드로 나간다. 두 경우 모두 로더가 준비한 새 주소 공간과 레지스터로 실행을 시작한다.[6]

이제 실행 파일은 열렸고 ELF 로더도 선택되었다. 다음 ELF 세그먼트와 주소 공간에서는 로더가 프로그램 헤더를 읽어 코드와 데이터를 가상 주소 공간에 배치하는 방법을 살펴본다.

참고 문헌

  1. ↑ Linux man-pages, execve(2). 실행 이미지 교체와 프로세스 속성.
  2. ↑ 2.0 2.1 2.2 2.3 2.4 2.5 2.6 Linux v6.12, fs/exec.c. 실행 요청의 준비, 형식 탐색과 이미지 교체.
  3. ↑ Linux v6.12, include/uapi/linux/binfmts.h. BINPRM_BUF_SIZE.
  4. ↑ 4.0 4.1 4.2 Linux v6.12, fs/binfmt_elf.c. load_elf_binary()와 ELF 핸들러 등록.
  5. ↑ Linux v6.12, init/main.c. run_init_process()와 kernel_init().
  6. ↑ Linux v6.12, arch/x86/kernel/process.c, ret_from_fork().