운영체제 기본/Linux의 ELF 로딩/초기 스택과 사용자 모드 진입: 두 판 사이의 차이
초기 스택 그림의 고주소·저주소 순서와 열 정렬 교정 |
BIOS GRUB 부팅 경로 보강과 각론 형식 통일 |
||
| 1번째 줄: | 1번째 줄: | ||
[[운영체제 기본/Linux의 ELF 로딩|ELF 로딩 목차]] · 이전: [[운영체제 기본/Linux의 ELF 로딩/ELF 세그먼트와 주소 공간|ELF 세그먼트와 주소 공간]] | [[운영체제 기본/Linux의 ELF 로딩|ELF 로딩 목차]] · 이전: [[운영체제 기본/Linux의 ELF 로딩/ELF 세그먼트와 주소 공간|ELF 세그먼트와 주소 공간]] | ||
실행 파일의 | [[운영체제 기본/Linux의 ELF 로딩/ELF 세그먼트와 주소 공간|ELF 세그먼트와 주소 공간]]에서는 실행 파일의 코드·데이터와 BSS를 프로세스의 가상 주소 공간에 배치했다. 다음으로 필요한 것은 '''프로그램에 전달할 인자와 스택을 만들고, 어느 명령어부터 실행할지 CPU의 복귀 상태에 기록하는 것'''이다. | ||
여기서도 정적 링크된 <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> | <code>execve(path, argv, envp)</code>에서 <code>argv</code>는 인자 문자열들의 배열이고, <code>envp</code>는 환경 변수 문자열들의 배열이다. 예를 들어 실행할 프로그램이 두 개의 인자를 받는다면, 새 프로그램은 인자 개수와 각 문자열의 주소를 알아야 한다. | ||
<code> | 호출 당시의 포인터는 이전 프로그램의 주소 공간을 가리킨다. 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>, <code>kernel_execve()</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> | ||
두 아키텍처의 네이티브 64비트 초기 사용자 스택은 낮은 주소부터 <code>argc</code>, <code>argv</code>, <code>envp</code>, 보조 벡터 순서로 읽을 수 있다. 각 포인터 배열은 NULL로 끝난다. 다음 그림은 실제 주소가 증가하는 방향을 아래쪽으로 표시한 개념도이다. | |||
<pre> | <pre> | ||
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 | |||
</pre> | </pre> | ||
배열에 들어가는 것은 문자열 자체가 아니라 문자열을 가리키는 새 사용자 주소이다. 커널은 스택 정렬도 맞춘다. 보조 벡터(auxv)는 페이지 크기처럼 시작 코드가 필요로 하는 실행 환경 정보를 전달하는 항목들의 목록이다. 지금 단계에서는 커널이 인자 외에도 몇 가지 시작 정보를 함께 제공한다고 이해하면 된다. | |||
이 스택은 '''사용자 프로그램이 사용할 스택'''이다. 커널이 현재 ELF 로딩 함수를 실행하는 데 쓰는 커널 스택과는 다르다. 커널은 새 스택의 위치를 보관했다가 사용자 모드로 나갈 때 x86-64의 RSP 또는 RV64의 sp에 적용한다. | |||
== 사용자 모드 진입: x86-64와 RISC-V == | |||
ELF 세그먼트를 매핑하고 초기 사용자 스택을 만드는 큰 흐름은 공통이다. 마지막에 '''CPU가 어떤 주소·스택·권한으로 실행을 시작할지 준비하는 방법'''은 아키텍처마다 다르다. 여기서는 Linux v6.12, 정적 링크된 <code>ET_EXEC</code>를 기준으로 x86-64의 기본 복귀 경로와 RISC-V RV64의 S-mode 커널에서 U-mode로 나가는 경로를 비교한다. | |||
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> | |||
=== 먼저 저장된 복귀 상태를 바꾼다 === | |||
두 아키텍처 모두 <code>start_thread()</code>에서 곧바로 사용자 코드를 실행하지 않는다. 현재 태스크의 커널 스택에 있는 <code>pt_regs</code>에 '''나중에 복원할 사용자 상태'''를 기록한다. | |||
{| class="wikitable" | {| class="wikitable" | ||
! | ! 준비할 상태 !! x86-64 !! RISC-V RV64 | ||
|- | |- | ||
| <code> | | 첫 명령어 주소 || <code>regs->ip</code>: 나중의 RIP || <code>regs->epc</code>: 나중에 sepc에 쓸 값 | ||
|- | |- | ||
| <code> | | 초기 사용자 스택 || <code>regs->sp</code>: 나중의 RSP || <code>regs->sp</code>: 나중의 sp(x2) | ||
|- | |- | ||
| | | 사용자 권한으로 복귀 || 사용자 CS·SS 선택자 || 저장된 status의 SPP=0 | ||
|- | |- | ||
| | | 실행 상태 || 저장된 flags || 저장된 status의 SPIE=1 | ||
|} | |} | ||
<code> | 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> | ||
<code> | 특히 <code>regs->epc</code>는 메모리 속 필드이고 <code>sepc</code>는 CPU의 제어·상태 레지스터(CSR)이다. 이름이 비슷해도 같은 저장 장소가 아니다. 이 시점의 CPU는 여전히 커널 코드를 실행하며 커널 스택을 사용한다. | ||
=== 복귀 코드가 실제 CPU 상태를 적용한다 === | |||
<pre> | <pre> | ||
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 | |||
</pre> | </pre> | ||
<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>, <code>ret_from_fork</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> | |||
두 경우 모두 '''새로 준비한 복귀 상태가 이전 프로그램의 상태를 대체한다'''. 따라서 exec 성공 뒤에는 이전 프로그램의 호출 다음 줄이 아니라 새 프로그램의 진입 주소를 실행한다. | |||
== | == 커널에서 나가면 새 프로그램이 시작된다 == | ||
일반적인 | 일반적인 exec는 이전 사용자 프로그램이 시스템 콜(System Call)로 커널에 들어오면서 시작된다. 성공한 뒤 커널이 사용자 모드로 나갈 때는, 이전 호출 지점이 아니라 새로 설정한 진입 주소와 스택을 사용한다. | ||
<pre> | <pre> | ||
Old user program | 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 | |||
New user | |||
</pre> | </pre> | ||
따라서 성공한 exec는 이전 | 따라서 커널 내부의 exec 처리 함수가 성공을 반환하는 것과, 이전 사용자 프로그램의 <code>execve()</code> 호출 다음 줄로 돌아가는 것은 다르다. 성공한 exec는 이전 프로그램으로 돌아가지 않는다. 현재 태스크의 실행 이미지가 바뀌는 것이므로 새 프로세스를 만드는 동작도 아니며, 다른 태스크로 CPU를 넘기는 컨텍스트 스위치(Context Switch)와도 구별된다. | ||
첫 명령어를 가져올 때 해당 코드 페이지가 아직 연결되지 않았다면 페이지 폴트가 발생할 수 있다. 커널이 페이지를 준비한 뒤 같은 명령어 접근을 재개하므로, 이것도 정상적인 시작 과정의 일부이다. 자세한 내용은 [[운영체제 기본/Linux의 파일 메모리 매핑/첫 접근과 페이지 폴트|첫 접근과 페이지 폴트]]로 이어진다. | |||
<code>e_entry</code>는 보통 C의 <code>main()</code> 주소가 아니다. 일반적인 C 프로그램은 <code>_start</code> 같은 런타임 시작 코드에서 스택의 인자 정보를 읽고 실행 환경을 준비한 뒤 <code>main()</code>을 호출한다. ELF가 <code>_start</code>라는 이름을 강제하는 것도 아니다. 커널은 함수 이름을 찾지 않고 ELF에 기록된 진입 주소를 사용한다. | |||
== 첫 init은 커널에서 이 과정을 시작한다 == | |||
부팅 중 PID 1은 아직 이전 사용자 프로그램을 실행한 적이 없으므로, 사용자 코드가 시스템 콜을 호출하는 방식으로 시작할 수 없다. 어떤 파일을 init으로 선택하는지는 [[운영체제 기본/x86-64 Linux의 부팅/루트 파일시스템과 init 선택|루트 파일시스템과 init 선택]]에서 다루고, 여기서는 선택된 파일을 실행하는 연결만 살펴본다. | |||
PID 1 태스크는 먼저 커널의 <code>kernel_init()</code>를 수행한다. 여기서 <code>run_init_process()</code>가 <code>kernel_execve()</code>를 호출한다. 사용자 시스템 콜 입구를 통과하지 않는다는 차이는 있지만, 공통 exec 처리와 ELF 로더를 통해 주소 공간, 초기 스택, 저장된 사용자 레지스터를 준비하는 원리는 같다.<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> | |||
성공하면 <code>kernel_execve()</code>에서 돌아온 <code>kernel_init()</code>가 반환한다. x86-64에서는 이 시작 함수를 호출했던 <code>ret_from_fork()</code>가 사용자 복귀 준비를 수행하고, 이어 어셈블리 복귀 경로가 준비된 레지스터 상태를 복원한다. 이때 비로소 첫 사용자 명령어를 실행한다.<ref>[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>ret_from_fork()</code>.</ref><ref>[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>ret_from_fork_asm</code>, 사용자 모드 복귀 경로.</ref> | |||
RV64에서는 어셈블리 <code>ret_from_fork</code>가 <code>kernel_init()</code>을 호출하고, 반환 뒤 <code>syscall_exit_to_user_mode()</code>를 거쳐 <code>ret_from_exception</code>으로 이어진다. 여기서 저장된 상태를 복원한 뒤 <code>sret</code>로 사용자 모드에 진입한다.<ref name="rv-return" /> | |||
즉 init을 실행할 때도 커널이 C 함수 호출처럼 init의 <code>main()</code>을 부르는 것은 아니다. '''새 실행 이미지와 사용자 상태를 준비한 뒤, 그 상태로 사용자 모드에 진입한다'''는 기본 흐름이 그대로 적용된다. | |||
== 동적 링크된 프로그램에서는 무엇이 달라지는가 == | |||
이제 기본 흐름에 동적 링커를 추가할 수 있다. ELF에 <code>PT_INTERP</code>가 있으면 이 항목은 사용자 공간 동적 링커의 경로를 지정한다. 커널은 주 실행 파일에 더해 동적 링커도 매핑하고, 첫 실행 주소를 주 실행 파일이 아닌 '''동적 링커의 진입 주소'''로 준비한다.<ref name="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>로 전달되므로, 동적 링커의 첫 실행 주소와 구별할 수 있다. | |||
인터프리터 없는 정적 실행은 “커널 → 주 프로그램 시작 코드”이고, 일반적인 동적 실행은 “커널 → 동적 링커 → 주 프로그램 시작 코드”이다. 어느 쪽이든 커널이 매핑과 스택을 준비하고 사용자 레지스터를 설정한다는 골격은 같다. | |||
== 참고 문헌 == | == 참고 문헌 == | ||
<references /> | <references /> | ||