|
|
| (같은 사용자의 중간 판 2개는 보이지 않습니다) |
| 1번째 줄: |
1번째 줄: |
| [[운영체제 기본/Linux의 ELF 로딩|ELF 로딩 목차]] · 다음: [[운영체제 기본/Linux의 ELF 로딩/ELF 세그먼트와 주소 공간|ELF 세그먼트와 주소 공간]] | | [[운영체제 기본/Linux의 ELF 로딩|ELF 로딩 목차]] · 다음: [[운영체제 기본/Linux의 ELF 로딩/ELF 세그먼트와 주소 공간|ELF 세그먼트와 주소 공간]] |
|
| |
|
| 이 절은 Linux v6.12의 x86-64 네이티브 실행 경로를 기준으로 한다. 실행 파일의 이름을 받은 커널이 파일을 열고, 실행 형식을 판별하고, 기존 실행 이미지를 새 이미지로 바꾸기로 결정하는 과정을 다룬다. ELF 세그먼트의 실제 매핑과 초기 사용자 스택 구성은 [[운영체제 기본/Linux의 ELF 로딩/ELF 세그먼트와 주소 공간|ELF 세그먼트와 주소 공간]]에서 이어서 설명한다. | | 이 절은 Linux v6.12의 x86-64를 기준으로, 실행 파일을 받은 커널이 기존 프로그램을 새 프로그램으로 바꾸는 흐름을 설명한다. 먼저 '''동적 링커가 필요 없는 정적 링크된 <code>ET_EXEC</code> 실행 파일'''을 대상으로 삼는다. |
|
| |
|
| == 새 프로세스를 만드는 것이 아니라 실행할 프로그램을 바꾼다 == | | == execve는 현재 프로세스의 프로그램을 바꾼다 == |
|
| |
|
| <code>execve()</code>는 호출한 프로세스의 실행 이미지를 교체하는 시스템 콜(System Call)이다. 프로세스를 새로 만드는 <code>fork()</code>나 <code>clone()</code>과 역할이 다르다. 셸이 자식 프로세스를 만든 뒤 자식에서 <code>execve()</code>를 호출하는 경우에도, 프로세스 생성은 앞선 단계에서 이미 끝났다. <code>execve()</code>가 하는 일은 그 자식이 실행할 코드, 데이터, 스택과 실행 시작 상태를 새 프로그램에 맞게 바꾸는 것이다.<ref name="man-exec">Linux man-pages, [https://man7.org/linux/man-pages/man2/execve.2.html execve(2)]. 사용자 공간 인터페이스와 보존되거나 초기화되는 프로세스 속성.</ref> | | <code>execve()</code>는 호출한 프로세스의 실행 이미지를 교체하는 시스템 콜(System Call)이다. 새 프로세스를 만드는 호출은 아니다. 예를 들어 셸은 먼저 자식 프로세스를 만든 다음, 그 자식에서 <code>execve()</code>를 호출하여 실행할 프로그램을 바꿀 수 있다. 프로세스 생성과 프로그램 로딩은 서로 다른 작업이다.<ref name="man">Linux man-pages, [https://man7.org/linux/man-pages/man2/execve.2.html execve(2)]. 실행 이미지 교체와 프로세스 속성.</ref> |
|
| |
|
| 여기서 실행 이미지란 실행 파일의 바이트만을 뜻하지 않는다. 사용자 가상 주소 공간, 해당 공간에 배치할 프로그램과 스택, 최초로 사용자 모드에 진입할 때의 레지스터 상태까지 함께 생각해야 한다. 반면 프로세스 식별과 외부 관계가 모두 새로 생기는 것은 아니다. 프로세스의 PID, 즉 사용자에게 프로세스 ID로 보이는 스레드 그룹 ID는 유지된다. 부모 프로세스와의 관계도 이어진다. <code>FD_CLOEXEC</code>가 설정되지 않은 열린 파일 디스크립터도 보통 유지되므로, 셸이 미리 구성한 표준 입출력과 파이프를 새 프로그램이 사용할 수 있다.<ref name="man-exec" />
| | 실행 이미지에는 코드뿐 아니라 데이터, 스택과 같은 사용자 주소 공간의 내용과 실행을 시작할 위치도 포함된다. <code>execve()</code>에 성공하면 기존 프로그램의 코드와 스택으로 돌아가지 않고 새 프로그램을 실행한다. 그러나 프로세스의 PID는 유지된다. 즉 “다른 프로세스가 된다”기보다 “같은 프로세스가 다른 프로그램을 실행한다”라고 이해하는 편이 정확하다. 프로세스 생성과 실행의 관계는 [[운영체제 기본/프로세스와 스레드|프로세스와 스레드]]를 참고한다. |
|
| |
|
| 멀티스레드 프로세스에서는 이 설명에 주의가 필요하다. 실행을 요청한 스레드 이외의 스레드는 제거된다. 요청한 스레드가 기존 그룹 리더가 아니었다면, 커널은 그 스레드가 기존 리더의 PID를 이어받도록 식별자를 정리한다. 따라서 “프로세스 PID는 유지된다”와 “호출한 스레드의 TID도 그대로다”는 같은 말이 아니다. 이 작업은 <code>de_thread()</code>에서 수행한다.<ref name="exec-source">Linux v6.12, [https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/fs/exec.c?h=v6.12 fs/exec.c]. <code>do_execveat_common()</code>, <code>alloc_bprm()</code>, <code>bprm_execve()</code>, <code>exec_binprm()</code>, <code>search_binary_handler()</code>, <code>begin_new_exec()</code>, <code>de_thread()</code>, <code>exec_mmap()</code>.</ref> 프로세스와 스레드의 식별 관계는 [[운영체제 기본/프로세스와 스레드|프로세스와 스레드]]를 참고한다.
| | == 실행 요청에서 열린 파일과 준비 상태로 == |
|
| |
|
| == 두 진입점이 하나의 실행 준비 경로로 합쳐진다 ==
| | 사용자 프로그램은 <code>execve(path, argv, envp)</code>로 실행 파일의 경로, 인수 문자열 배열, 환경 문자열 배열을 전달한다. 커널은 먼저 사용자 공간의 경로명을 가져온 뒤 공통 실행 준비 함수인 <code>do_execveat_common()</code>으로 들어간다. 함수 이름에 <code>at</code>가 붙은 이유는 실행 대상을 지정하는 또 다른 시스템 콜인 <code>execveat()</code>와 준비 경로를 공유하기 때문이다. 여기서는 일반적인 <code>execve()</code>의 흐름만 따라간다.<ref name="exec">Linux v6.12, [https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/fs/exec.c?h=v6.12 fs/exec.c]. 실행 요청의 준비, 형식 탐색과 이미지 교체.</ref> |
| | |
| <code>execve()</code>는 경로명과 인수 배열, 환경 배열을 받는다. <code>execveat()</code>는 여기에 기준 파일 디스크립터와 플래그를 더한다. 상대 경로를 특정 디렉터리 파일 디스크립터를 기준으로 해석할 수 있고, <code>AT_EMPTY_PATH</code>와 빈 경로를 사용하면 지정한 파일 디스크립터가 가리키는 실행 파일을 대상으로 삼을 수 있다. 둘은 실행 이미지 교체 규칙이 다른 시스템 콜이 아니라, 실행 대상을 지정하는 방식이 다른 진입점이다.<ref>Linux man-pages, [https://man7.org/linux/man-pages/man2/execveat.2.html execveat(2)]. 경로 해석, <code>AT_EMPTY_PATH</code>, 스크립트와 close-on-exec의 제약.</ref> | |
| | |
| 두 시스템 콜은 <code>do_execveat_common()</code>에서 합류한다.<ref name="exec-source" />
| |
|
| |
|
| <pre> | | <pre> |
| execve execveat | | execve |
| do_execve do_execveat | | -> do_execve |
| \ /
| | -> do_execveat_common |
| do_execveat_common
| | -> alloc_bprm |
| alloc_bprm
| | -> do_open_execat -> do_filp_open |
| do_open_execat
| | -> bprm_mm_init |
| do_filp_open
| | -> copy argument and environment strings |
| bprm_mm_init
| | -> bprm_execve |
| count / bprm_stack_limits / copy_strings
| | -> exec_binprm |
| bprm_execve
| | -> search_binary_handler |
| exec_binprm
| | -> load_elf_binary |
| search_binary_handler
| |
| prepare_binprm
| |
| security_bprm_check
| |
| fmt->load_binary
| |
| load_elf_binary [ELF handler]
| |
| </pre> | | </pre> |
|
| |
|
| <code>getname()</code> 또는 <code>getname_uflags()</code>는 사용자 주소 공간의 경로명을 커널이 사용할 이름 객체로 가져온다. <code>do_execveat_common()</code>은 실행 요청의 공통 준비를 담당한다. 이 단계에서는 아직 기존 사용자 프로그램의 주소 공간을 파괴하지 않는다. 파일 열기 실패, 잘못된 인수 포인터, 지나치게 큰 인수 목록처럼 미리 확인할 수 있는 문제를 먼저 발견해야 하기 때문이다. | | <code>alloc_bprm()</code>은 실행 파일을 열고, 이번 실행 요청의 준비 상태를 담을 <code>linux_binprm</code> 객체를 만든다. 파일을 여는 내부 경로는 <code>do_open_execat()</code>에서 <code>do_filp_open()</code>으로 이어진다. VFS가 경로를 탐색하여 파일을 열면 커널은 그 결과인 <code>struct file</code>을 보관한다. 이후 로더는 경로 문자열만 보고 동작하는 것이 아니라, 이 열린 파일 객체를 통해 실행 파일을 읽는다. 이름과 열린 파일 객체의 차이는 [[운영체제 기본/Linux의 파일시스템/VFS와 파일 객체|VFS와 파일 객체]]에서 설명한다.<ref name="exec" /> |
| | |
| == 경로 문자열이 실행 대상 파일 객체가 되는 과정 ==
| |
| | |
| <code>alloc_bprm()</code>은 <code>do_open_execat()</code>으로 실행 대상을 연 다음 <code>struct linux_binprm</code>을 할당한다. <code>do_open_execat()</code>은 실행 의도를 나타내는 <code>__FMODE_EXEC</code>, 접근 검사에 사용할 <code>MAY_EXEC</code> 등을 지정하여 <code>do_filp_open()</code>을 호출한다. 이때 실제 경로 탐색과 파일 열기는 VFS 경로를 따른다. 일반 파일인지 확인하는 검사는 이 버전에서 이미 하위 열기 경로의 <code>may_open()</code>으로 옮겨져 있으며, <code>do_open_execat()</code>은 <code>path_noexec()</code>도 검사한다.<ref name="exec-source" />
| |
| | |
| 따라서 파일명 끝이 <code>.elf</code>인지 확인해서 실행하는 것이 아니다. 먼저 파일시스템의 경로 해석과 실행 허용 여부를 거쳐 <code>struct file</code>을 얻고, 이후 그 파일의 내용을 읽어 실행 형식을 판별한다. 경로명은 이름이고, 열린 파일 객체는 이번 로딩 작업이 참조하는 대상이다. 이 구분은 경로와 inode, 열린 파일 객체의 관계를 설명하는 [[운영체제 기본/Linux의 파일시스템/VFS와 파일 객체|VFS와 파일 객체]]와 연결된다.
| |
| | |
| 또한 실행 가능 비트 하나만으로 모든 보안 판단이 끝나는 것도 아니다. 실행 파일을 여는 단계의 접근 검사 외에, 이후 LSM 검사와 자격 증명 준비 및 적용이 이어진다. 기존 프로세스가 추적되고 있는지, <code>no_new_privs</code>가 설정되어 있는지, 파일에 set-user-ID 등의 속성이 있는지에 따라 새 실행 이미지에 적용할 권한이 달라질 수 있다. <code>bprm_execve()</code>는 <code>prepare_bprm_creds()</code>로 새 자격 증명을 준비하고, 실행의 안전 조건과 보안 훅을 거쳐 로더를 호출한다. 권한이 최종 적용되는 단계까지 관련 동기화가 유지된다.<ref name="exec-source" /> 권한 변경과 격리의 기본 원리는 [[운영체제 기본/보호|보호]]를 참고한다.
| |
| | |
| == linux_binprm은 교체 준비 상태를 보관한다 ==
| |
| | |
| <code>linux_binprm</code>은 새 프로세스 자체가 아니라, 진행 중인 실행 요청의 준비 상태를 담는 객체다. 열린 실행 파일, 파일명과 인터프리터 정보, 인수와 환경의 개수, 새 주소 공간, 인수 문자열을 쌓는 위치, 형식 판별용 버퍼와 되돌아갈 수 없는 단계 진입 여부 등이 이 객체를 통해 연결된다.<ref name="exec-source" /><ref>Linux v6.12, [https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/include/linux/binfmts.h?h=v6.12 include/linux/binfmts.h]. <code>struct linux_binprm</code>과 <code>struct linux_binfmt</code>.</ref>
| |
| | |
| <code>bprm_mm_init()</code>은 새 <code>mm_struct</code>를 준비한다. MMU를 사용하는 x86-64에서는 인수 문자열을 보관할 임시 스택 VMA도 준비한다. 이때 새 주소 공간은 아직 <code>current->mm</code>을 대체하지 않았다. 기존 프로그램의 주소 공간을 유지한 채 새 이미지에 필요한 입력을 먼저 확보하는 것이다. 인수와 환경 문자열을 복사하는 작업은 나중에 완성될 사용자 스택의 준비 단계이며, 이 시점에 최종 <code>argc</code>, 포인터 배열, 보조 벡터까지 모두 완성되는 것은 아니다.<ref name="exec-source" />
| |
| | |
| <code>do_execveat_common()</code>은 <code>argv</code>와 <code>envp</code>의 원소 수를 세고, 스택과 인수 크기 제한을 확인한 뒤 실행 파일명, 환경 문자열, 인수 문자열을 복사한다. 사용자가 넘긴 포인터가 올바르지 않으면 이 과정에서 실패할 수 있다. 이러한 복사는 기존 주소 공간을 버린 뒤에도 새 프로그램의 시작 입력을 사용할 수 있게 만드는 데 필요하다. 임의로 큰 인수 목록을 허용하지 않는 것은 커널과 새 스택이 무제한 자원을 소비하지 않게 하기 위한 제한이다.
| |
| | |
| == binfmt 디스패치는 파일 내용과 핸들러의 응답으로 결정된다 ==
| |
| | |
| <code>exec_binprm()</code>은 <code>search_binary_handler()</code>를 호출한다. 후자는 먼저 <code>prepare_binprm()</code>으로 <code>bprm->buf</code>를 0으로 채우고 실행 파일의 처음부터 읽는다. v6.12의 <code>BINPRM_BUF_SIZE</code>는 256바이트다. 이 작은 버퍼는 파일 전체를 적재하는 공간이 아니라, 각 핸들러가 자신의 형식인지 판별할 수 있게 하는 공통 입력이다.<ref name="exec-source" /><ref>Linux v6.12, [https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/include/uapi/linux/binfmts.h?h=v6.12 include/uapi/linux/binfmts.h]. <code>BINPRM_BUF_SIZE</code> 및 인수 문자열 관련 상수.</ref>
| |
| | |
| 그 다음 <code>security_bprm_check()</code>를 통과하면 등록된 <code>linux_binfmt</code> 목록을 순회하면서 각 핸들러의 <code>load_binary(bprm)</code>를 호출한다. 이 구조에서 형식 선택의 핵심은 다음과 같다.<ref name="exec-source" />
| |
| | |
| * 핸들러가 <code>-ENOEXEC</code>를 반환하고 아직 교체 확정 단계에 들어가지 않았다면 다음 핸들러를 시도한다. 즉 이 값은 탐색 문맥에서 “이 형식으로 실행할 수 없다”는 신호다.
| |
| * 다른 결과를 반환하면 무조건 다음 핸들러로 넘기지 않는다. 파일 형식이 맞더라도 접근 실패나 자원 부족 등이 발생했다면 그 실패를 실행 요청의 결과로 다룬다.
| |
| * <code>bprm->point_of_no_return</code>이 설정되었다면 결과가 무엇이든 다른 핸들러로 재시도하지 않는다. 이미 기존 실행 상태를 해체하는 단계에 들어갔기 때문이다.
| |
| | |
| 핸들러 호출 동안 목록 잠금을 계속 잡고 있는 것은 아니다. 커널은 해당 모듈의 참조를 확보하고 목록 잠금을 풀어 핸들러를 호출한 뒤, 다시 잠금과 참조를 정리한다. 실제 파일 읽기와 메모리 준비처럼 오래 걸리거나 잠들 수 있는 로딩 작업을 전체 형식 목록의 잠금 아래에서 수행하지 않으면서 핸들러의 수명을 보장하려는 구조다. 모듈 지원 구성에서는 인식 실패 후 바이너리 형식 모듈을 요청하고 제한적으로 다시 탐색하는 경로도 있다.
| |
|
| |
|
| ELF 핸들러의 <code>load_elf_binary()</code>는 ELF 매직을 확인하고, <code>ET_EXEC</code> 또는 <code>ET_DYN</code>인지, 해당 아키텍처에 맞는지 등을 확인한다. 처음 256바이트만으로 로딩이 끝나는 것은 아니다. 핸들러가 ELF임을 받아들인 뒤에는 프로그램 헤더 등을 따로 읽어 메모리 배치에 필요한 검증과 준비를 계속한다.<ref name="elf-source">Linux v6.12, [https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/fs/binfmt_elf.c?h=v6.12 fs/binfmt_elf.c]. <code>load_elf_binary()</code>, <code>load_elf_interp()</code>, ELF 핸들러 등록.</ref>
| | <code>linux_binprm</code>은 새 프로세스가 아니다. 열린 파일, 새 주소 공간, 인수 문자열을 쌓을 위치 등 로딩 중에 필요한 상태를 묶어 둔 객체다. <code>bprm_mm_init()</code>은 새 주소 공간을 나타내는 <code>mm_struct</code>와 임시 스택 영역을 준비한다. 이때는 아직 현재 프로세스의 주소 공간을 바꾸지 않는다.<ref name="exec" /> |
|
| |
|
| == 스크립트 인터프리터와 ELF 인터프리터는 다른 경로다 ==
| | 커널은 인수와 환경 문자열의 크기를 확인하고 새 스택에 사용할 공간으로 문자열을 복사한다. 기존 주소 공간을 나중에 버리더라도 새 프로그램에 전달할 입력은 남아 있어야 하기 때문이다. 다만 이 단계에서 최종 사용자 스택의 모든 배열과 값까지 완성되는 것은 아니다. 전체 스택 구성은 ELF 로더가 실행 이미지에 맞춰 마무리한다. |
|
| |
|
| <code>#!</code>로 시작하는 파일은 <code>binfmt_script</code>의 <code>load_script()</code>가 처리한다. 이 핸들러는 스크립트 본문을 기계어처럼 메모리에 적재하지 않는다. 첫 줄에서 인터프리터 경로와 선택적 인수를 읽고, 새 인수 목록에 인터프리터 이름과 스크립트 경로를 넣은 뒤, 인터프리터 파일을 열어 <code>bprm->interpreter</code>에 지정한다.<ref name="script-source">Linux v6.12, [https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/fs/binfmt_script.c?h=v6.12 fs/binfmt_script.c]. <code>load_script()</code>와 스크립트 핸들러 등록.</ref>
| | == binfmt가 ELF 로더를 선택한다 == |
|
| |
|
| <code>exec_binprm()</code>은 이 결과를 보고 <code>bprm->file</code>을 인터프리터 파일로 교체한 다음 형식 탐색을 반복한다. 따라서 스크립트의 인터프리터가 ELF 실행 파일이면 다음 탐색에서 ELF 핸들러가 이를 처리한다. 스크립트가 또 다른 스크립트를 가리키는 등의 연쇄가 끝없이 이어지지 않도록 반복 깊이를 제한하며, v6.12 코드에서는 반복 시작 시 <code>depth > 5</code>를 검사하여 <code>-ELOOP</code>로 종료한다. <ref name="exec-source" />
| | 커널은 파일 이름의 확장자로 실행 형식을 결정하지 않는다. 열린 파일의 앞부분을 읽고, 등록된 실행 형식 핸들러가 이를 해석할 수 있는지 확인한다. 이 실행 형식 처리 체계를 <code>binfmt</code>라고 한다. |
|
| |
|
| 반면 ELF의 <code>PT_INTERP</code>는 이미 선택된 ELF 핸들러 내부에서 해석하는 프로그램 헤더 항목이다. ELF 로더는 여기에 적힌 동적 링커 파일을 열고 검증한 뒤, 주 실행 파일과 함께 그 ELF 인터프리터를 매핑한다. 이 과정은 스크립트처럼 <code>bprm->interpreter</code>를 사용하여 전체 binfmt 탐색을 다시 시작하는 경로가 아니다. 동적 링커가 필요한 경우 최초 사용자 실행 주소는 보통 주 실행 파일의 진입점이 아니라 동적 링커의 진입점이 된다.<ref name="elf-source" />
| | <code>bprm_execve()</code>에서 <code>exec_binprm()</code>을 거쳐 호출되는 <code>search_binary_handler()</code>는 먼저 <code>prepare_binprm()</code>으로 파일의 앞부분을 <code>bprm->buf</code>에 읽는다. v6.12에서 이 버퍼의 크기는 256바이트다. 이것은 실행 파일 전체를 담는 버퍼가 아니라, 형식을 알아보기 위한 작은 입력이다.<ref name="exec" /><ref>Linux v6.12, [https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/include/uapi/linux/binfmts.h?h=v6.12 include/uapi/linux/binfmts.h]. <code>BINPRM_BUF_SIZE</code>.</ref> |
|
| |
|
| <pre> | | 그 다음 등록된 핸들러의 <code>load_binary()</code>를 호출한다. 아직 이미지 교체를 확정하지 않은 핸들러가 <code>-ENOEXEC</code>를 반환하면 다음 핸들러를 시도한다. ELF 핸들러의 이 함수는 <code>load_elf_binary()</code>다. ELF 매직과 실행 파일 형식, 대상 아키텍처 등을 확인한 뒤 실제 ELF 로딩을 진행한다.<ref name="elf">Linux v6.12, [https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/fs/binfmt_elf.c?h=v6.12 fs/binfmt_elf.c]. <code>load_elf_binary()</code>와 ELF 핸들러 등록.</ref> |
| script file
| |
| -> binfmt_script
| |
| -> rewrite arguments and select interpreter file
| |
| -> search_binary_handler again
| |
| -> binfmt_elf, if the interpreter is ELF
| |
|
| |
|
| ELF file with PT_INTERP | | 이렇게 공통 준비와 형식별 처리를 나누면 파일 열기와 인수 복사는 공통 경로가 맡고, “파일의 어느 부분을 어떤 주소에 배치할 것인가”는 해당 형식을 아는 로더가 맡을 수 있다. ELF 로더는 처음 읽은 버퍼만 사용하는 것이 아니라, 프로그램 헤더를 추가로 읽어 메모리에 배치할 구간을 알아낸다. 이 구간의 해석이 다음 절의 주제다. |
| -> binfmt_elf
| |
| -> map main ELF and interpreter ELF
| |
| -> initial user entry in the interpreter
| |
| </pre>
| |
|
| |
|
| == 되돌아갈 수 없는 지점은 주소 공간 교체보다 먼저 온다 == | | == 기존 주소 공간을 버리고 새 이미지로 전환한다 == |
|
| |
|
| ELF 로더는 기존 이미지를 버리기 전에 실행 파일과 인터프리터의 헤더 등을 검사한다. 그러나 새 프로그램을 실행할 수 있다는 모든 조건을 기존 상태를 유지한 채 완전히 보장할 수는 없다. 기존 스레드 정리, 주소 공간의 전환, 새 매핑 구성 등은 상태를 실제로 바꾸는 작업이며, 그 이후에도 메모리 부족 같은 실패가 발생할 수 있다.<ref name="elf-source" /><ref name="exec-source" /> | | 지금까지는 기존 프로그램의 주소 공간을 유지하면서 파일을 열고, 인수를 복사하고, ELF 헤더를 검사했다. 준비 중 파일을 찾지 못하거나 형식을 처리할 수 없으면 기존 프로그램으로 돌아가 오류를 보고할 수 있다. 가능한 오류를 먼저 확인해야 실행할 수도 없는 파일 때문에 현재 프로그램을 잃지 않는다. |
|
| |
|
| v6.12의 경계는 <code>load_elf_binary()</code>가 호출하는 <code>begin_new_exec()</code> 안에 명시되어 있다. 이 함수는 파일에 따른 자격 증명 계산을 거친 뒤 <code>bprm->point_of_no_return = true</code>를 설정한다. 그 다음에 <code>de_thread()</code>로 다른 스레드를 종료시키고, 파일 테이블을 분리하고, <code>exec_mmap()</code>으로 새 주소 공간을 설치한다. 즉 '''되돌아갈 수 없다는 표시를 설정하는 시점과 <code>current->mm</code>을 교체하는 시점은 같지 않다'''. 다른 스레드를 없애기 시작한 이후에는 주소 공간만 그대로라고 해서 이전 프로그램을 정상 상태로 복구할 수 없기 때문이다.<ref name="exec-source" />
| | ELF 로더가 교체를 진행하기로 하면 <code>begin_new_exec()</code>를 호출한다. 이 함수 안에서 <code>point_of_no_return</code>을 설정한 뒤 기존 실행 상태를 정리하고, <code>exec_mmap()</code>으로 준비한 새 주소 공간을 현재 프로세스에 설치한다. 여러 스레드가 있었다면 이 과정에서 실행을 요청한 스레드만 남는다. 이후 ELF 로더가 새 주소 공간에 프로그램의 각 구간과 스택을 구성한다.<ref name="exec" /><ref name="elf" /> |
|
| |
|
| <pre> | | <pre> |
| prepare and validate
| | old user image |
| old image still usable on ordinary failure | | | |
| |
| | | open file, copy inputs, inspect ELF |
| v
| | | ordinary failure can return to old image |
| | v |
| begin_new_exec | | begin_new_exec |
| compute credentials
| | point_of_no_return |
| point_of_no_return = true | | clean up old execution state |
| de_thread
| |
| unshare_files | |
| exec_mmap(new_mm) | | exec_mmap(new_mm) |
| reset execution state / close CLOEXEC descriptors | | | |
| commit credentials | | v |
| |
| | new address space |
| v
| | map ELF segments |
| ELF mappings and initial stack
| | build initial stack |
| set initial user registers | | set initial user registers |
| |
| |
| v
| |
| return path to the new user image
| |
| </pre> | | </pre> |
|
| |
|
| <code>exec_mmap()</code>은 새 <code>mm_struct</code>를 현재 태스크의 주소 공간으로 연결하고 기존 주소 공간에 대한 참조를 정리한다. 기존 주소 공간이 다른 참조 없이 마지막으로 사용되었다면 해제되지만, 이를 “기존 사용자 페이지를 같은 물리 위치에서 새 파일 바이트로 덮어쓴다”라고 이해하면 안 된다. 핵심은 가상 주소 공간과 그 매핑 관계를 교체한다는 데 있다.
| | 여기서 주소 공간 교체는 기존 프로그램의 물리 메모리에 파일 내용을 그대로 덮어쓰는 작업이 아니다. 현재 프로세스가 사용할 가상 주소 공간과 매핑 관계를 새 것으로 바꾸는 작업이다. 실행 파일 전체를 즉시 물리 메모리로 복사하는 것과도 구별해야 한다. |
|
| |
|
| 실패 의미도 이 경계를 기준으로 나뉜다. 확정 전의 일반적인 오류는 호출한 사용자 프로그램으로 돌아가 오류를 보고할 수 있다. 확정 뒤에는 이전 사용자 코드로 돌아가서는 안 된다. <code>bprm_execve()</code>의 오류 경로는 <code>point_of_no_return</code>이 설정되었고 이미 치명적 시그널이 대기 중인 것이 아니라면 <code>SIGSEGV</code>로 프로세스를 종료시키도록 한다. 따라서 “exec가 실패하면 언제나 원래 프로그램에서 오류 처리를 계속한다”는 설명도 엄밀하지 않다.<ref name="exec-source" />
| | <code>point_of_no_return</code>은 실제 주소 공간 교체보다 먼저 설정된다. 이미 다른 스레드와 실행 상태를 정리하기 시작했다면 단순히 이전 코드로 돌아갈 수 없기 때문이다. 이 경계를 넘은 뒤 로딩에 실패하면 커널은 기존 프로그램으로 정상 복귀하지 않고 프로세스를 종료시킨다. 따라서 실행 교체는 모든 단계에서 되돌릴 수 있는 작업은 아니다.<ref name="exec" /> |
|
| |
|
| 이 구조는 실행 교체를 완전한 롤백 가능한 거래로 만들지 않는 대신, 가능한 검증과 입력 복사는 앞쪽에 두고 되돌릴 수 없는 구간은 명확히 표시한다. 형식별 로더는 교체를 확정하기 전에 자신의 형식인지 판단해야 하며, 확정 후 실패를 다른 형식의 핸들러가 만회하도록 넘겨서는 안 된다.
| | == 새 프로그램의 시작 == |
|
| |
|
| == 성공한 exec는 어디로 돌아가는가 ==
| | 로딩이 끝나면 ELF 로더는 새 프로그램의 진입 주소와 초기 스택 주소를 사용자 레지스터 상태에 설정한다. 커널 내부 함수들은 반환할 수 있지만, 사용자 모드로 나갈 때 실행할 위치는 이미 바뀌었다. 이것이 “<code>execve()</code>는 성공하면 반환하지 않는다”는 말의 의미다. 이전 프로그램의 호출 다음 줄이 아니라 새 프로그램의 시작 코드가 실행된다.<ref name="elf" /> |
| | |
| 사용자 API 관점에서 <code>execve()</code>는 성공하면 반환하지 않는다. 그러나 이것이 커널의 C 함수 호출이 전부 중간에서 사라진다는 뜻은 아니다. 로더가 성공하면 내부 함수들은 정리 경로를 거쳐 반환할 수 있다. 달라진 것은 사용자 모드로 나갈 때 사용할 주소 공간과 레지스터다. | |
| | |
| ELF 로더의 마지막 부분은 <code>START_THREAD</code>를 통해 시작 상태를 설정한다. x86-64의 <code>start_thread()</code>와 <code>start_thread_common()</code>은 저장된 사용자 레지스터에 새 명령 포인터와 스택 포인터, 사용자 코드 및 스택 세그먼트 등을 설정한다. 따라서 이후 사용자 모드로 나갈 때는 원래 프로그램의 <code>execve()</code> 호출 다음 명령이 아니라 새 이미지의 시작 주소에서 실행한다. 이 함수 이름의 “thread”는 여기서 스레드를 하나 더 생성한다는 뜻이 아니다. 초기 스택과 복귀 레지스터의 상세한 구성은 [[운영체제 기본/Linux의 ELF 로딩/초기 스택과 사용자 모드 진입|초기 스택과 사용자 모드 진입]]에서 이어서 설명한다.<ref name="elf-source" /><ref>Linux v6.12, [https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/kernel/process_64.c?h=v6.12 arch/x86/kernel/process_64.c]. <code>start_thread()</code>와 <code>start_thread_common()</code>.</ref>
| |
| | |
| == 첫 사용자 프로그램도 같은 로더를 사용한다 ==
| |
| | |
| 어떤 파일을 첫 사용자 프로그램으로 선택하는지, 루트 파일시스템이 언제 준비되는지는 [[운영체제 기본/x86-64 Linux의 부팅/루트 파일시스템과 init 선택|루트 파일시스템과 init 선택]]에서 다룬다. 여기서는 선택이 끝난 뒤 공통 로더에 합류하는 경계만 살펴본다.
| |
| | |
| 부팅 중 첫 사용자 프로그램을 실행하는 경로는 사용자 공간에서 시스템 콜 명령으로 들어오지 않는다. v6.12의 <code>rest_init()</code>은 <code>user_mode_thread(kernel_init, ...)</code>로 초기 태스크를 만들고, <code>kernel_init()</code> 쪽의 <code>run_init_process()</code>가 <code>kernel_execve()</code>를 호출한다.<ref name="init-source">Linux v6.12, [https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/init/main.c?h=v6.12 init/main.c]. <code>rest_init()</code>, <code>kernel_init()</code>, <code>run_init_process()</code>.</ref>
| |
| | |
| <code>kernel_execve()</code>는 커널 주소 공간에 있는 파일명과 인수 및 환경 문자열을 받는다. 따라서 사용자 포인터를 읽는 <code>do_execveat_common()</code>을 그대로 호출하지 않는다. <code>getname_kernel()</code>, <code>alloc_bprm()</code>, 커널 문자열용 개수 계산과 복사 함수를 사용한 뒤 '''<code>bprm_execve()</code>에서 일반 exec 경로와 합쳐진다'''. 이후 형식 탐색과 ELF 로딩, 새 이미지 구성은 공통이다. 또한 이 버전은 <code>PF_KTHREAD</code>가 설정된 일반 커널 스레드에서의 호출을 거부한다. “커널에서 호출한다”는 말만으로 모든 커널 스레드가 이 함수를 사용할 수 있다고 확대하면 안 된다.<ref name="exec-source" />
| |
| | |
| <pre>
| |
| userspace execve kernel_init
| |
| user-pointer argument copy run_init_process
| |
| do_execveat_common kernel_execve
| |
| kernel-string argument copy
| |
| \ /
| |
| bprm_execve
| |
| exec_binprm
| |
| binfmt handler
| |
| ELF loader
| |
| </pre>
| |
|
| |
|
| 일반 exec와 PID 1의 첫 실행이 사용자 모드로 넘어가는 경로는 [[운영체제 기본/Linux의 ELF 로딩/초기 스택과 사용자 모드 진입|초기 스택과 사용자 모드 진입]]에서 이어진다.
| | 이제 실행 파일은 열렸고 ELF 로더도 선택되었다. 다음 [[운영체제 기본/Linux의 ELF 로딩/ELF 세그먼트와 주소 공간|ELF 세그먼트와 주소 공간]]에서는 로더가 프로그램 헤더를 읽어 코드와 데이터를 가상 주소 공간에 배치하는 방법을 살펴본다. |
|
| |
|
| == 참고 문헌 == | | == 참고 문헌 == |
| <references /> | | <references /> |
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]
새 프로그램의 시작
로딩이 끝나면 ELF 로더는 새 프로그램의 진입 주소와 초기 스택 주소를 사용자 레지스터 상태에 설정한다. 커널 내부 함수들은 반환할 수 있지만, 사용자 모드로 나갈 때 실행할 위치는 이미 바뀌었다. 이것이 “execve()는 성공하면 반환하지 않는다”는 말의 의미다. 이전 프로그램의 호출 다음 줄이 아니라 새 프로그램의 시작 코드가 실행된다.[4]
이제 실행 파일은 열렸고 ELF 로더도 선택되었다. 다음 ELF 세그먼트와 주소 공간에서는 로더가 프로그램 헤더를 읽어 코드와 데이터를 가상 주소 공간에 배치하는 방법을 살펴본다.
참고 문헌