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

초기 스택 그림의 고주소·저주소 순서와 열 정렬 교정
첫 init의 실행과 ret_from_fork 설명을 부팅 장으로 이동
 
(같은 사용자의 중간 판 2개는 보이지 않습니다)
1번째 줄: 1번째 줄:
[[운영체제 기본/Linux의 ELF 로딩|ELF 로딩 목차]] · 이전: [[운영체제 기본/Linux의 ELF 로딩/ELF 세그먼트와 주소 공간|ELF 세그먼트와 주소 공간]]
[[운영체제 기본/Linux의 ELF 로딩|ELF 로딩 목차]] · 이전: [[운영체제 기본/Linux의 ELF 로딩/ELF 세그먼트와 주소 공간|ELF 세그먼트와 주소 공간]]


실행 파일의 세그먼트를 매핑했다고 해서 사용자 프로그램이 곧바로 실행되는 것은 아니다. 커널은 시작할 명령어 주소, 최초의 사용자 스택, 인자와 환경 변수, 실행 환경을 설명하는 보조 정보를 함께 준비해야 한다. 동적 링크된 프로그램에서는 첫 사용자 명령어가 주 실행 파일이 아니라 동적 링커에 속할 수도 있다.
[[운영체제 기본/Linux의 ELF 로딩/ELF 세그먼트와 주소 공간|ELF 세그먼트와 주소 공간]]에서는 실행 파일의 코드·데이터와 BSS를 프로세스의 가상 주소 공간에 배치했다. 다음으로 필요한 것은 '''프로그램에 전달할 인자와 스택을 만들고, 어느 명령어부터 실행할지 CPU의 복귀 상태에 기록하는 것'''이다.


이 절은 [[운영체제 기본/Linux의 ELF 로딩/ELF 세그먼트와 주소 공간|ELF 세그먼트와 주소 공간]] 이후의 과정을 Linux v6.12 x86-64 기준으로 설명한다. 일반적인 <code>execve()</code>와 부팅 중 PID 1의 첫 실행은 같은 ELF 로더를 활용하지만, 커널에 들어온 경로와 사용자 모드로 나가는 경로는 구분해야 한다.
여기서도 정적 링크된 <code>ET_EXEC</code>를 먼저 설명한다. 앞의 예처럼 코드가 <code>0x401000</code>에 배치되고 ELF 헤더의 <code>e_entry</code>도 <code>0x401000</code>이라면, 이 주소가 새 프로그램의 첫 실행 주소이다. 기준 구현은 Linux v6.12의 x86-64와 RISC-V RV64이다. 실행 파일의 명령어와 대상 아키텍처 정보는 각각의 CPU에 맞아야 한다.


== 주 실행 파일의 진입점과 실제 첫 명령어 ==
== 인자와 환경 변수를 새 스택에 준비한다 ==


<code>e_entry</code>는 ELF 헤더가 지정하는 진입 주소이다. 주 실행 파일에 <code>load_bias</code>가 적용되면 실제 주소는 <code>e_entry + load_bias</code>가 된다. 그러나 이것이 항상 CPU가 처음 실행할 사용자 명령어 주소인 것은 아니다.
<code>execve(path, argv, envp)</code>에서 <code>argv</code>는 인자 문자열들의 배열이고, <code>envp</code>는 환경 변수 문자열들의 배열이다. 예를 들어 실행할 프로그램이 두 개의 인자를 받는다면, 새 프로그램은 인자 개수와 각 문자열의 주소를 알아야 한다.


<code>PT_INTERP</code>가 있으면 커널은 그 항목에 기록된 경로의 ELF 인터프리터를 열고 검증한다. 일반적인 GNU/Linux 환경에서는 <code>ld-linux-x86-64.so.2</code> 같은 동적 링커가 여기에 해당한다. 인터프리터도 ELF 파일이므로 <code>load_elf_interp()</code>가 그 프로그램 헤더와 <code>PT_LOAD</code>를 이용하여 별도로 매핑한다. 주 프로그램과 인터프리터는 각각 자신의 배치 기준과 진입점을 가진다.<ref name="elf">[https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/fs/binfmt_elf.c?h=v6.12 Linux v6.12, fs/binfmt_elf.c], <code>load_elf_binary()</code>, <code>load_elf_interp()</code>, <code>create_elf_tables()</code>.</ref>
호출 당시의 포인터는 이전 프로그램의 주소 공간을 가리킨다. exec는 그 주소 공간을 새 이미지로 교체하므로 포인터 값만 그대로 넘길 수 없다. 커널은 exec 준비 과정에서 문자열을 새 실행을 위한 메모리에 복사하고, 새 사용자 스택의 위치를 정한다. 이어 ELF 로더의 <code>create_elf_tables()</code>가 복사된 문자열을 가리키는 새 포인터 배열을 작성한다.<ref name="exec">[https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/fs/exec.c?h=v6.12 Linux v6.12, fs/exec.c], <code>copy_strings()</code>, <code>setup_arg_pages()</code>.</ref><ref name="elf">[https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/fs/binfmt_elf.c?h=v6.12 Linux v6.12, fs/binfmt_elf.c], <code>create_elf_tables()</code>, <code>load_elf_binary()</code>.</ref>


{| class="wikitable"
두 아키텍처의 네이티브 64비트 초기 사용자 스택은 낮은 주소부터 <code>argc</code>, <code>argv</code>, <code>envp</code>, 보조 벡터 순서로 읽을 수 있다. 각 포인터 배열은 NULL로 끝난다. 다음 그림은 실제 주소가 증가하는 방향을 아래쪽으로 표시한 개념도이다.
! 실행 형태 !! 커널이 준비하는 첫 사용자 실행 주소 !! 주 프로그램의 진입 주소
|-
| <code>PT_INTERP</code> 없음 || 주 실행 파일의 <code>e_entry + load_bias</code> || 첫 실행 주소와 같다.
|-
| <code>PT_INTERP</code> 있음 || 인터프리터의 <code>e_entry + 인터프리터 배치 기준</code> || 보조 벡터의 <code>AT_ENTRY</code>로 전달된다.
|}
 
Linux 코드에서는 주 실행 파일의 보정된 진입 주소를 담는 <code>e_entry</code>와, 최종적으로 <code>START_THREAD</code>에 넘기는 <code>elf_entry</code>를 구분해 읽으면 이해하기 쉽다. 인터프리터가 없으면 둘이 같아지지만, 있으면 <code>elf_entry</code>는 인터프리터의 진입 주소가 된다.
 
'''e_entry가 항상 main의 주소라는 설명은 틀리다.''' 일반적인 C 실행 파일에서는 런타임 시작 코드인 <code>_start</code> 등이 진입점이 되고, 그 코드가 필요한 런타임 초기화와 호출 규약 준비를 수행한 뒤 <code>main()</code>으로 이어진다. 그렇다고 ELF 규격이 <code>_start</code>라는 이름을 강제하는 것도 아니다. 링크 설정이나 언어 런타임에 따라 다른 진입점을 사용할 수 있다. 커널은 심벌 이름으로 <code>main</code>이나 <code>_start</code>를 찾지 않는다.
 
== 커널 로더와 동적 링커의 역할 ==
 
커널의 ELF 로더는 실행할 이미지와 인터프리터를 배치하고, 사용자 프로그램이 시작할 수 있는 기본 실행 상태를 만든다. 모든 공유 라이브러리를 찾아 연결하고, 각 심벌의 주소를 해석하는 일을 커널이 대신 수행하는 것은 아니다.
 
사용자 공간의 동적 링커는 자신의 초기 실행 상태를 정리하고, 주 프로그램의 동적 정보를 해석한다. 일반적인 동적 링크 과정에서는 필요한 공유 객체를 찾고 매핑하며, 심벌을 해석하고 재배치를 수행하고, TLS와 초기화 절차 등을 준비한다. 이후 주 프로그램의 시작 코드로 제어를 넘긴다. 구체적인 순서와 지연 바인딩 여부는 동적 링커 구현과 링크 옵션에 따라 달라지며, 지연 바인딩을 사용하면 일부 심벌의 해석은 첫 호출까지 미뤄진다.<ref>[https://man7.org/linux/man-pages/man8/ld.so.8.html Linux man-pages, ld.so(8)], DESCRIPTION 및 LD_BIND_NOW.</ref>


<pre>
<pre>
Kernel ELF loader
Lower addresses
  map main executable
initial SP  --> +-------------------------------+
  map PT_INTERP interpreter
                | argc                          |
  build initial stack and auxv
                | argv[0]                      |
  prepare user registers
                | argv[1]                      |
            |
                | ...                          |
            v
                | NULL                          |
Dynamic linker entry      [user mode]
                | envp[0]                       |
  prepare runtime linking
                | envp[1]                      |
  map required shared objects
                | ...                          |
  resolve relocations as required
                | NULL                          |
            |
                | Auxiliary vector: type, value |
            v
                | ...                          |
Main executable entry      [user mode]
                | AT_NULL, 0                    |
  language/runtime startup
                | Padding / other startup data  |
            |
                | Argument/environment strings  |
            v
                +-------------------------------+
main() or another language-level entry
Higher addresses
</pre>
</pre>


이 그림은 인터프리터가 있는 일반적인 동적 실행의 개념도이다. 인터프리터 없는 실행에서는 첫 사용자 실행 주소가 곧 주 실행 이미지의 진입점이며, 정적 PIE처럼 자체 시작 코드가 필요한 재배치를 수행하는 형태도 있다. 커널이 주소 배치에 <code>load_bias</code>를 적용했다는 사실과, 사용자 공간에서 재배치 항목을 처리했다는 사실을 혼동해서는 안 된다.
배열에 들어가는 것은 문자열 자체가 아니라 문자열을 가리키는 새 사용자 주소이다. 커널은 스택 정렬도 맞춘다. 보조 벡터(auxv)는 페이지 크기처럼 시작 코드가 필요로 하는 실행 환경 정보를 전달하는 항목들의 목록이다. 지금 단계에서는 커널이 인자 외에도 몇 가지 시작 정보를 함께 제공한다고 이해하면 된다.


== argv와 envp가 새 주소 공간으로 넘어가는 방법 ==
이 스택은 '''사용자 프로그램이 사용할 스택'''이다. 커널이 현재 ELF 로딩 함수를 실행하는 데 쓰는 커널 스택과는 다르다. 커널은 새 스택의 위치를 보관했다가 사용자 모드로 나갈 때 x86-64의 RSP 또는 RV64의 sp에 적용한다.


<code>execve(path, argv, envp)</code>를 호출하는 시점의 <code>argv</code>와 <code>envp</code>는 이전 사용자 주소 공간의 포인터이다. 이전 주소 공간을 없애고 새 프로그램을 실행할 것이므로, 커널은 그 포인터 값을 그대로 새 프로그램에 넘길 수 없다.
== 사용자 모드 진입: x86-64와 RISC-V ==


exec 준비 과정은 인자와 환경 변수의 개수 및 크기를 확인하고 문자열들을 새 실행을 위한 메모리에 복사한다. 새 스택의 최종 위치는 <code>setup_arg_pages()</code> 등에서 정리되며, ELF 로더의 <code>create_elf_tables()</code>가 복사된 문자열을 가리키는 새 사용자 주소들을 사용해 포인터 배열과 보조 벡터를 작성한다. 인자 개수·총량 제한과 메모리 접근 실패 처리는 이 준비 과정의 일부이다.<ref name="exec">[https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/fs/exec.c?h=v6.12 Linux v6.12, fs/exec.c], <code>copy_strings()</code>, <code>setup_arg_pages()</code>, <code>do_execveat_common()</code>, <code>kernel_execve()</code>.</ref>
ELF 세그먼트를 매핑하고 초기 사용자 스택을 만드는 큰 흐름은 공통이다. 마지막에 '''CPU가 어떤 주소·스택·권한으로 실행을 시작할지 준비하는 방법'''은 아키텍처마다 다르다. 여기서는 Linux v6.12, 정적 링크된 <code>ET_EXEC</code>를 기준으로 x86-64의 기본 복귀 경로와 RISC-V RV64의 S-mode 커널에서 U-mode로 나가는 경로를 비교한다.


따라서 새 프로그램이 받는 것은 “이전 프로그램의 argv 포인터”가 아니라 '''새 주소 공간에 존재하는 문자열과 그것을 가리키는 새 포인터 배열'''이다. 환경 문자열도 같은 원리로 전달된다.
ELF 로더는 마지막에 <code>START_THREAD(..., elf_entry, bprm->p)</code>를 호출한다. RV64에서는 공통 헤더의 기본 매크로가 이를 <code>start_thread(regs, elf_entry, start_stack)</code>로 연결한다. 진입 주소는 이 예의 <code>e_entry</code>이며, 스택 주소는 인자와 환경 변수가 준비된 새 사용자 스택이다.<ref>[https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/fs/binfmt_elf.c?h=v6.12 Linux v6.12, fs/binfmt_elf.c], <code>load_elf_binary()</code>; [https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/include/linux/elf.h?h=v6.12 include/linux/elf.h], <code>START_THREAD</code>.</ref>


== 최초 사용자 스택의 구조 ==
=== 먼저 저장된 복귀 상태를 바꾼다 ===


x86-64의 최초 사용자 스택에는 인자 개수 <code>argc</code>, 인자 포인터 배열, 환경 변수 포인터 배열, 보조 벡터(auxiliary vector, auxv)가 놓인다. 문자열과 난수 데이터 등은 같은 초기 스택 영역 안에 별도로 자리 잡고 포인터가 이를 가리킨다. <ref name="elf" />
두 아키텍처 모두 <code>start_thread()</code>에서 곧바로 사용자 코드를 실행하지 않는다. 현재 태스크의 커널 스택에 있는 <code>pt_regs</code>에 '''나중에 복원할 사용자 상태'''를 기록한다.
 
<pre>
Higher addresses
+------------------------------------+
| Argument/environment strings      |
| Platform strings, random bytes    |
| Alignment/padding as needed        |
+------------------------------------+
| AT_NULL, 0                        |
| ...                                |
| auxv[0].type, auxv[0].value        |
+------------------------------------+
| NULL                              |
| envp[envc-1]                      |
| ...                                |
| envp[0]                            |
+------------------------------------+
| NULL                              |
| argv[argc-1]                      |
| ...                                |
| argv[0]                            |
+------------------------------------+
| argc                              | <-- initial RSP
+------------------------------------+
Lower addresses
</pre>
 
메모리에서 낮은 주소부터 읽는 순서는 <code>argc → argv → NULL → envp → NULL → auxv</code>이다. x86-64 네이티브 ABI에서는 스택 슬롯과 포인터를 64비트 단위로 해석하며, 커널은 초기 스택의 정렬도 맞춘다. 실행 시작은 일반 C 함수에 <code>call</code>한 결과가 아니므로, 초기 스택에 <code>main()</code>의 정상적인 호출자 반환 주소가 있다고 가정하면 안 된다. 런타임 시작 코드가 이 원시 시작 상태를 읽어 언어 런타임과 함수 호출 규약에 맞는 상태를 만든다.
 
또한 새 스택은 사용자 스택이다. 커널이 현재 ELF 로딩을 수행하는 데 사용하는 커널 스택과는 별개이다. <code>bprm->p</code>에 마련된 새 스택 위치를 저장해 두었다가, 나중에 사용자 모드로 나갈 레지스터 상태의 RSP로 사용한다.
 
== 보조 벡터는 커널과 시작 코드 사이의 약속이다 ==
 
<code>argv</code>와 <code>envp</code>가 호출자가 제공한 문자열 정보라면, 보조 벡터는 커널이 실행 환경에 관해 제공하는 구조화된 정보이다. 각 항목은 종류와 값의 쌍이며, 값은 숫자일 수도 있고 새 주소 공간의 주소일 수도 있다.


{| class="wikitable"
{| class="wikitable"
! 항목 !! 의미와 활용
! 준비할 상태 !! x86-64 !! RISC-V RV64
|-
|-
| <code>AT_PHDR</code> || 주 실행 파일의 프로그램 헤더 테이블이 메모리에 놓인 주소. 단순한 파일 오프셋 <code>e_phoff</code>가 아니다.
| 첫 명령어 주소 || <code>regs->ip</code>: 나중의 RIP || <code>regs->epc</code>: 나중에 sepc에 쓸 값
|-
|-
| <code>AT_PHENT</code>, <code>AT_PHNUM</code> || 프로그램 헤더 항목의 크기와 개수
| 초기 사용자 스택 || <code>regs->sp</code>: 나중의 RSP || <code>regs->sp</code>: 나중의 sp(x2)
|-
|-
| <code>AT_ENTRY</code> || 주 실행 파일의 보정된 진입 주소. 인터프리터가 있을 때 최초 RIP와 구별해야 한다.
| 사용자 권한으로 복귀 || 사용자 CS·SS 선택자 || 저장된 status의 SPP=0
|-
|-
| <code>AT_BASE</code> || ELF 인터프리터의 배치 기준 주소. 주 실행 파일의 PIE 기준 주소를 뜻하지 않으며, 인터프리터가 없는 일반적인 경우 0이다.
| 실행 상태 || 저장된 flags || 저장된 status의 SPIE=1
|-
| <code>AT_PAGESZ</code> || 실행 환경이 전달하는 페이지 크기
|-
| <code>AT_RANDOM</code> || 커널이 초기 스택에 배치한 난수 바이트를 가리키는 주소
|-
| <code>AT_UID</code>, <code>AT_EUID</code>, <code>AT_GID</code>, <code>AT_EGID</code> || 실행 후 자격 증명에 관한 값
|-
| <code>AT_SECURE</code> || 동적 링커와 런타임이 보안상 제한된 실행 환경을 판단하는 데 사용하는 값
|-
| <code>AT_EXECFN</code> || 실행 파일 이름 문자열을 가리키는 주소
|-
| <code>AT_NULL</code> || 보조 벡터의 끝
|}
|}


<code>AT_PHDR</code>를 만들 때 Linux는 프로그램 헤더 테이블의 파일 위치를 포함하는 <code>PT_LOAD</code>를 찾아 해당 세그먼트의 파일 오프셋과 가상 주소 사이의 관계로 메모리 주소를 계산하고 <code>load_bias</code>를 반영한다. 동적 링커는 이 정보로 주 프로그램의 세그먼트와 동적 정보에 접근할 수 있다.<ref name="elf" />
x86-64의 <code>start_thread_common()</code>은 새 명령어·스택 주소, 사용자 선택자, 초기 플래그를 설정한다. RV64의 <code>start_thread()</code>는 <code>epc</code>와 <code>sp</code>를 기록하고, <code>status</code>를 <code>SR_PIE</code>에서 시작해 구성한다. 여기서 <code>SR_PIE</code>는 <code>SR_SPIE</code>를 뜻하며, 복귀 권한을 나타내는 <code>SPP</code>는 0이다.<ref>[https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/kernel/process_64.c?h=v6.12 Linux v6.12, arch/x86/kernel/process_64.c], <code>start_thread_common()</code>; [https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/riscv/kernel/process.c?h=v6.12 arch/riscv/kernel/process.c], <code>start_thread()</code>; [https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/riscv/include/asm/csr.h?h=v6.12 arch/riscv/include/asm/csr.h].</ref>


auxv는 동적 링크 전용 구조가 아니다. 인터프리터가 없는 프로그램의 시작 코드도 페이지 크기나 하드웨어 기능 같은 정보를 활용할 수 있다. 반대로 auxv의 모든 값이 주소인 것도 아니므로, 종류를 무시하고 일괄 역참조해서는 안 된다.
특히 <code>regs->epc</code>는 메모리 속 필드이고 <code>sepc</code>는 CPU의 제어·상태 레지스터(CSR)이다. 이름이 비슷해도 같은 저장 장소가 아니다. 이 시점의 CPU는 여전히 커널 코드를 실행하며 커널 스택을 사용한다.


== start_thread는 CPU를 즉시 사용자 모드로 바꾸지 않는다 ==
=== 복귀 코드가 실제 CPU 상태를 적용한다 ===
 
<code>load_elf_binary()</code>의 마지막 부분은 실행 상태 마무리를 거쳐 <code>START_THREAD</code>를 호출한다. x86-64 네이티브 실행에서는 <code>start_thread()</code>가 <code>start_thread_common()</code>으로 이어지고, 현재 태스크의 <code>pt_regs</code>에 새 사용자 실행 상태를 기록한다.<ref name="process64">[https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/kernel/process_64.c?h=v6.12 Linux v6.12, arch/x86/kernel/process_64.c], <code>start_thread_common()</code>, <code>start_thread()</code>.</ref>
 
<code>start_thread_common()</code>에서 새 진입 주소와 스택, 세그먼트 선택자를 저장하는 부분은 다음과 같다.<ref name="process64" />


<pre>
<pre>
regs->ip = new_ip;
                ELF mappings + initial user stack
regs->sp  = new_sp;
                                |
regs->csx = _cs;
                      saved state: pt_regs
regs->ssx = _ss;
                                |
            +------------------+------------------+
            |                                    |
          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
</pre>
</pre>


<code>new_ip</code>와 <code>new_sp</code>는 사용자 RIP와 RSP가 된다. 네이티브 x86-64의 <code>start_thread()</code>는 <code>_cs</code>와 <code>_ss</code>에 <code>__USER_CS</code>와 <code>__USER_DS</code>를 전달한다. 플래그와 나머지 세그먼트 상태도 함께 정리한다.
RISC-V의 <code>ret_from_exception</code>은 저장된 <code>status</code>와 <code>epc</code>를 각각 <code>sstatus</code>, <code>sepc</code>에 쓰고 일반 레지스터와 사용자 <code>sp</code>를 복원한 뒤 <code>sret</code>를 실행한다. <code>sret</code>는 <code>SPP=0</code>에 따라 U-mode로 전환하고 <code>sepc</code>의 주소에서 실행을 시작한다. 이때 SIE는 SPIE 값으로 갱신된다. '''스택 포인터를 복원하는 것은 앞의 어셈블리 명령이며, sret 자체가 사용자 스택을 찾아 주는 것은 아니다.'''<ref name="rv-return">[https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/riscv/kernel/entry.S?h=v6.12 Linux v6.12, arch/riscv/kernel/entry.S], <code>ret_from_exception</code>.</ref><ref>[https://docs.riscv.org/reference/isa/v20260120/priv/supervisor.html RISC-V Supervisor-Level ISA], <code>sstatus</code>, <code>sepc</code>, SRET.</ref>
 
x86-64에서는 복귀 어셈블리가 저장된 상태를 복원하고, RIP·CS·RFLAGS·RSP·SS로 이루어진 복귀 프레임을 <code>iretq</code>가 적용하여 CPL 3의 사용자 코드로 나간다. 일반 시스템 콜에는 SYSRET 복귀도 있지만, 여기의 exec가 준비한 새 레지스터 상태는 그 경로의 조건을 만족하지 않아 IRET 경로로 간다.<ref>[https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/include/asm/elf.h?h=v6.12 Linux v6.12, arch/x86/include/asm/elf.h], <code>elf_common_init()</code>; [https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/entry/common.c?h=v6.12 arch/x86/entry/common.c], <code>do_syscall_64()</code>; [https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/entry/entry_64.S?h=v6.12 arch/x86/entry/entry_64.S], 사용자 복귀 경로와 <code>iretq</code>.</ref>


이 시점에 커널 C 코드가 사용자 주소로 일반 함수 호출을 하거나, 그 자리에서 CPU 권한 수준을 낮추는 것은 아니다. '''나중에 사용자 모드로 복귀할 때 사용할 저장된 레지스터 상태를 바꾸는 것'''이다. 실제 권한 전환과 레지스터 복원은 아키텍처의 사용자 복귀 경로가 수행한다.
두 경우 모두 '''새로 준비한 복귀 상태가 이전 프로그램의 상태를 대체한다'''. 따라서 exec 성공 뒤에는 이전 프로그램의 호출 다음 줄이 아니라 새 프로그램의 진입 주소를 실행한다.


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


== 일반 execve는 이전 프로그램으로 돌아가지 않는다 ==
== 커널에서 나가면 새 프로그램이 시작된다 ==


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


<pre>
<pre>
Old user program
Old user program: execve(...)
    execve(...)
                |
        |
                v
        v
Kernel: select ELF handler
System call entry
        map code, data and BSS
    exec preparation and binary-format dispatch
        build initial user stack
    load_elf_binary()
        set entry = e_entry, stack = new stack
    START_THREAD(new_entry, new_stack)
                |
        |
                v
        v
User-return path: restore prepared state
Kernel system-call exit path
                |
        |
                v
        v
New user program: first instruction at e_entry
New user image, not the old execve call site
</pre>
</pre>


따라서 성공한 exec는 이전 프로그램 입장에서 반환하지 않는다. 성공을 나타내는 커널 내부 반환값이 있더라도, 호출 뒤에 있던 이전 사용자 명령어를 실행하지 않는다. 실패가 이전 이미지를 유지할 수 있는 단계에서 발생했다면 호출자에게 오류가 돌아갈 수 있다. 반면 이미지 교체를 되돌릴 수 없는 지점 이후의 실패는 단순히 이전 프로그램으로 복귀하는 형태로 처리할 수 없다.
따라서 커널 내부의 exec 처리 함수가 성공을 반환하는 것과, 이전 사용자 프로그램의 <code>execve()</code> 호출 다음 줄로 돌아가는 것은 다르다. 성공한 exec는 이전 프로그램으로 돌아가지 않는다. 현재 태스크의 실행 이미지가 바뀌는 것이므로 새 프로세스를 만드는 동작도 아니며, 다른 태스크로 CPU를 넘기는 컨텍스트 스위치(Context Switch)와도 구별된다.
 
x86-64의 전통적인 사용자 복귀 코드에는 빠른 <code>SYSRET</code>을 쓸 수 있는지 검사하는 경로와, 더 일반적인 상태를 복원하는 <code>IRET</code> 경로가 있다. exec 이후처럼 사용자 레지스터가 새로 구성된 경우까지 “시스템 콜은 언제나 SYSRET으로 복귀한다”라고 설명해서는 안 된다. 또한 v6.12에는 FRED 지원에 따른 별도 복귀 경로도 있으므로, 특정 최종 명령을 모든 시스템에 무조건 적용하지 않는다.<ref name="entry">[https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/entry/entry_64.S?h=v6.12 Linux v6.12, arch/x86/entry/entry_64.S], <code>entry_SYSCALL_64</code>, <code>ret_from_fork_asm</code>, 사용자 모드 복귀 경로.</ref>
 
이러한 커널 모드와 사용자 모드 사이의 전환은 그 자체로 다른 태스크를 선택하는 컨텍스트 스위치(Context Switch)와 같지 않다. exec는 현재 실행 이미지의 교체이며, 반드시 새 PID를 만들거나 다른 프로세스로 CPU를 넘기는 연산이 아니다.
 
== PID 1은 사용자 시스템 콜 없이 같은 로더를 사용한다 ==
 
어떤 파일을 init으로 선택하는지는 [[운영체제 기본/x86-64 Linux의 부팅/루트 파일시스템과 init 선택|루트 파일시스템과 init 선택]]에서 다룬다. 여기서는 선택된 실행 파일을 사용자 공간에서 시작하는 과정에 집중한다.


부팅 중 처음 실행되는 PID 1은 아직 이전 사용자 프로그램에서 <code>execve()</code>를 호출할 수 없다. Linux v6.12의 <code>rest_init()</code>는 <code>user_mode_thread(kernel_init, NULL, CLONE_FS)</code>로 PID 1 태스크를 만들며, 이 태스크는 처음에는 커널 함수 <code>kernel_init()</code>를 수행한다. 여기서 “커널 함수를 실행하며 시작한다”와 일반적인 <code>PF_KTHREAD</code> 커널 스레드라는 분류를 구분해야 한다. 실제 <code>kernel_execve()</code>는 <code>PF_KTHREAD</code>인 태스크의 호출을 거부한다.<ref name="init">[https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/init/main.c?h=v6.12 Linux v6.12, init/main.c], <code>rest_init()</code>, <code>kernel_init()</code>, <code>run_init_process()</code>.</ref><ref name="exec" />
첫 명령어를 가져올 때 해당 코드 페이지가 아직 연결되지 않았다면 페이지 폴트가 발생할 수 있다. 커널이 페이지를 준비한 뒤 같은 명령어 접근을 재개하므로, 이것도 정상적인 시작 과정의 일부이다. 자세한 내용은 [[운영체제 기본/Linux의 파일 메모리 매핑/첫 접근과 페이지 폴트|첫 접근과 페이지 폴트]]로 이어진다.


<code>kernel_init()</code>가 선택한 init 프로그램을 실행할 때는 <code>run_init_process()</code>가 커널에 있는 파일 이름·인자·환경 문자열을 <code>kernel_execve()</code>로 넘긴다. 이 함수는 사용자 포인터를 받는 시스템 콜 입구를 우회하지만, 공통 exec 처리와 바이너리 형식 핸들러를 통해 ELF 로더에 도달한다.
<code>e_entry</code>는 보통 C의 <code>main()</code> 주소가 아니다. 일반적인 C 프로그램은 <code>_start</code> 같은 런타임 시작 코드에서 스택의 인자 정보를 읽고 실행 환경을 준비한 뒤 <code>main()</code>을 호출한다. ELF가 <code>_start</code>라는 이름을 강제하는 것도 아니다. 커널은 함수 이름을 찾지 않고 ELF에 기록된 진입 주소를 사용한다.


<pre>
== 동적 링크된 프로그램에서는 무엇이 달라지는가 ==
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
</pre>


중요한 점은 <code>kernel_execve()</code>가 성공했다고 해서 C 함수 호출의 중간에서 바로 사용자 모드로 점프하지 않는다는 것이다. 성공하면 <code>kernel_init()</code>가 반환하고, 태스크의 시작 함수 호출을 감싸고 있던 <code>ret_from_fork()</code>가 이후 처리를 수행한다. v6.12 x86의 <code>ret_from_fork()</code>는 시작 함수가 돌아온 뒤 <code>regs->ax</code>를 정리하고 <code>syscall_exit_to_user_mode()</code>를 호출한다. 이어 <code>ret_from_fork_asm</code>의 사용자 복귀 경로가 새 레지스터 상태를 이용해 사용자 모드로 진입한다.<ref name="process">[https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/kernel/process.c?h=v6.12 Linux v6.12, arch/x86/kernel/process.c], <code>copy_thread()</code>, <code>ret_from_fork()</code>.</ref><ref name="entry" />
이제 기본 흐름에 동적 링커를 추가할 수 있다. ELF에 <code>PT_INTERP</code>가 있으면 이 항목은 사용자 공간 동적 링커의 경로를 지정한다. 커널은 주 실행 파일에 더해 동적 링커도 매핑하고, 첫 실행 주소를 주 실행 파일이 아닌 '''동적 링커의 진입 주소'''로 준비한다.<ref name="elf" />


여기서 <code>syscall_exit_to_user_mode</code>라는 함수 이름은 앞서 사용자 프로그램이 실제 시스템 콜 명령을 실행했다는 뜻이 아니다. 사용자 모드로 나가기 전에 필요한 공통 정리를 이 경로에서도 이용하는 것이다. 첫 PID 1과 일반 exec가 만나는 지점은 '''ELF 이미지와 초기 사용자 상태를 준비하는 공통 실행 로직'''이며, 출발점까지 같은 것은 아니다.
동적 링커는 사용자 모드에서 필요한 공유 라이브러리를 준비하고 주소 연결 작업을 수행한 뒤 주 프로그램의 시작 코드로 제어를 넘긴다.<ref>[https://man7.org/linux/man-pages/man8/ld.so.8.html Linux man-pages, ld.so(8)].</ref> 이 라이브러리 연결 작업을 커널 ELF 로더가 전부 수행하는 것은 아니다. 주 프로그램의 진입 주소는 초기 스택의 보조 벡터 <code>AT_ENTRY</code>로 전달되므로, 동적 링커의 첫 실행 주소와 구별할 수 있다.


또한 PID 1이 동적 링크된 ELF라면 첫 사용자 명령어는 init 실행 파일의 진입점이 아니라 그 <code>PT_INTERP</code>가 지정한 동적 링커의 진입점일 수 있다. “커널이 init의 main으로 점프한다”는 표현은 실행 파일 로딩, 런타임 시작, 사용자 권한 전환을 모두 생략한 설명이다. 정확한 경계는 '''커널이 준비한 첫 사용자 실행 상태로 복귀한 뒤, 사용자 공간의 시작 절차가 이어진다'''는 것이다.
인터프리터 없는 정적 실행은 “커널 → 주 프로그램 시작 코드”이고, 일반적인 동적 실행은 “커널 → 동적 링커 → 주 프로그램 시작 코드”이다. 어느 쪽이든 커널이 매핑과 스택을 준비하고 사용자 레지스터를 설정한다는 골격은 같다.


== 참고 문헌 ==
== 참고 문헌 ==
<references />
<references />