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

BIOS GRUB 부팅 경로 보강과 각론 형식 통일
첫 init의 실행과 ret_from_fork 설명을 부팅 장으로 이동
 
(같은 사용자의 중간 판 하나는 보이지 않습니다)
9번째 줄: 9번째 줄:
<code>execve(path, argv, envp)</code>에서 <code>argv</code>는 인자 문자열들의 배열이고, <code>envp</code>는 환경 변수 문자열들의 배열이다. 예를 들어 실행할 프로그램이 두 개의 인자를 받는다면, 새 프로그램은 인자 개수와 각 문자열의 주소를 알아야 한다.
<code>execve(path, argv, envp)</code>에서 <code>argv</code>는 인자 문자열들의 배열이고, <code>envp</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>
호출 당시의 포인터는 이전 프로그램의 주소 공간을 가리킨다. 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>


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


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>
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>
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>
119번째 줄: 119번째 줄:


<code>e_entry</code>는 보통 C의 <code>main()</code> 주소가 아니다. 일반적인 C 프로그램은 <code>_start</code> 같은 런타임 시작 코드에서 스택의 인자 정보를 읽고 실행 환경을 준비한 뒤 <code>main()</code>을 호출한다. ELF가 <code>_start</code>라는 이름을 강제하는 것도 아니다. 커널은 함수 이름을 찾지 않고 ELF에 기록된 진입 주소를 사용한다.
<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>을 부르는 것은 아니다. '''새 실행 이미지와 사용자 상태를 준비한 뒤, 그 상태로 사용자 모드에 진입한다'''는 기본 흐름이 그대로 적용된다.


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