QUESTION
터미널에서 ./xxx를 입력하면 어느 process가 무엇을 실행하는가?
./xxx에는 slash가 들어 있으므로 셸은 PATH를 검색하지 않는다. 현재 작업 directory를 기준으로 pathname을 정하고, quote 제거와 변수 확장, redirection 준비를 마친 뒤 외부 명령을 실행할 process를 만든다. 대개 셸은 parent로 남아 foreground job을 기다리고 child가 execve를 호출하지만, 셸 종류와 실행 위치에 따라 posix_spawn, vfork, clone 계열 또는 마지막 명령 exec 최적화가 사용될 수 있다.
execve가 성공하면 child PID는 유지되지만 기존 userspace image는 돌아오지 않는다. 커널은 executable을 열고 binary format handler를 선택한다. ELF이면 PT_LOAD와 초기 stack을 만들고, PT_INTERP가 있으면 동적 링커 entry로 복귀한다. 동적 링커가 relocation과 constructor를 끝낸 뒤 _start와 __libc_start_main을 거쳐 main을 호출한다. main 반환 뒤 exit_group으로 종료하면 parent 셸이 wait 결과를 $?, signal 표기, job 상태로 바꾸고 프롬프트를 다시 출력한다.
STRUCTURE
구조 그림
PID A
parent shell
- child PID/PGID 기록
- foreground terminal 양도
- wait4(child)에서 대기
PID B
child before exec
- fd redirection
- signal state 정리
- execve("./xxx", argv, envp)
- cwd 기준 path walk
- execute permission / LSM
- ELF 또는 #! handler
- PT_LOAD · stack · auxv
- 새 IP/SP 설정
- ld-linux entry
- dependency · relocation
- _start
- __libc_start_main
- main(argc, argv)
세로선은 같은 PID에서 이어지는 실행이고, 좌우 분기는 parent shell과 child의 동시 존재를 뜻한다. execve 성공 뒤 child PID는 그대로지만 old userspace image는 사라진다.
CALL PATH
호출 흐름
부모 셸과 실행 child를 한 process로 생각하면 wait와 exec를 혼동하게 된다. exec는 child를 새로 만들지 않고, 이미 만들어진 실행 주체의 userspace image를 교체한다.
함수 이름을 외우기 위한 그림이 아니다. 반환값, 파일 디스크립터, 메모리 매핑, 대기 큐 가운데 무엇이 다음 단계로 전달되는지 확인한다.
SOURCE COORDINATES
Linux 6.18.37 LTS 소스 위치
glibc 함수에서 멈추지 않고 syscall 구현과 커널 객체가 만나는 파일까지 내려간다. 링크는 동일한 태그의 원본 파일을 가리킨다.
| 파일 | 함수·구조체 | 여기서 볼 것 |
|---|---|---|
| fs/exec.c | do_execveat_common(), bprm_execve() | filename, argv, envp를 linux_binprm에 모으고 binary handler를 호출하는 공통 경로 |
| fs/namei.c | do_open_execat(), path_openat() | ./xxx를 current working directory에서 찾고 mount·permission 조건을 확인하는 경로 |
| fs/binfmt_elf.c | load_elf_binary(), create_elf_tables() | ELF segment와 PT_INTERP, argc/argv/envp/auxv가 있는 초기 stack을 만드는 경로 |
| fs/binfmt_script.c | load_script() | 첫 두 byte가 #!인 script를 interpreter 실행으로 다시 구성하는 경로 |
| kernel/exit.c | do_exit(), do_wait() | 실행 process 종료와 parent 셸의 exit status 회수 |
COMPLETE PROGRAM
실행 예제 원본
아래 코드는 설명을 위해 중간 줄을 생략한 의사 코드가 아니다. 파일로 빌드해 실행할 수 있는 최소 예제다.
cc -std=c17 -Wall -Wextra -O2 exec_path.c -o exec_path01#define _GNU_SOURCE
02#include <limits.h>
03#include <stdio.h>
04#include <stdlib.h>
05#include <unistd.h>
06
07extern char **environ;
08
09static void before_main(void) __attribute__((constructor));
10static void after_main(void) __attribute__((destructor));
11
12static void before_main(void)
13{
14 dprintf(STDERR_FILENO, "constructor: pid=%ld\n", (long)getpid());
15}
16
17static void after_main(void)
18{
19 dprintf(STDERR_FILENO, "destructor: normal exit path\n");
20}
21
22int main(int argc, char **argv)
23{
24 char executable[PATH_MAX];
25 ssize_t length = readlink("/proc/self/exe", executable,
26 sizeof(executable) - 1);
27 if (length < 0) {
28 perror("readlink");
29 return 1;
30 }
31 executable[length] = '\0';
32
33 printf("pid=%ld ppid=%ld exe=%s\n",
34 (long)getpid(), (long)getppid(), executable);
35 for (int i = 0; i < argc; ++i)
36 printf("argv[%d]=%s\n", i, argv[i]);
37 printf("first environment entry: %s\n",
38 environ[0] != NULL ? environ[0] : "(empty)");
39 return 7;
40}
CODE NOTES
코드 조각별 설명
__attribute__((constructor))main보다 먼저 실행되지만 ELF entry가 이 함수인 것은 아니다. 동적 링커와 libc startup이 relocation을 끝낸 뒤 .init_array를 순회하면서 호출한다.
dprintf(STDERR_FILENOstderr fd 2가 close-on-exec로 닫히지 않았다면 셸에서 child로 상속된다. redirection을 사용했다면 셸이 exec 전에 fd 2의 대상을 바꿔 둔다.
readlink("/proc/self/exe"argv[0]은 호출자가 정하는 문자열이라 실제 실행 파일 경로의 증거가 아니다. /proc/self/exe symlink는 현재 mm에 연결된 executable file을 가리킨다.
for (int i = 0; i < argc셸이 quote와 expansion을 처리한 결과가 argv 배열로 전달된다. 따옴표 문자는 제거되며 glob은 셸에서 이미 여러 인자로 펼쳐진다.
return 7;main 반환값은 libc의 exit 경로로 들어가 destructor와 stdio flush를 수행한 뒤 exit_group status가 된다. 부모 셸은 wait status에서 이를 꺼내 $?에 7을 기록한다.
DETAILS
세부 동작
1. 셸이 명령줄을 실행 계획으로 바꾼다
셸은 먼저 tokenization, quote 처리, parameter expansion, command substitution, pathname expansion을 수행한다. 따라서 kernel이 받는 argv에는 shell quote 문자가 없고 wildcard도 이미 filename 목록으로 바뀌어 있다.
명령 이름에 slash가 없으면 builtin·function·alias와 PATH 검색이 관여한다. ./xxx에는 slash가 있으므로 PATH cache와 PATH directory 순회 없이 current working directory 기준 pathname을 사용한다. ./를 생략한 xxx가 현재 directory에 있어도 PATH에 .이 없다면 실행되지 않는 이유다.
2. redirection과 process 관계가 먼저 준비된다
foreground 외부 명령은 보통 shell이 child를 만들고 parent는 job-control 정보를 보존한다. pipeline이면 여러 child를 같은 process group에 넣고 terminal foreground PGID도 바꾼다.
child는 exec 전에 dup2, close, setpgid, signal disposition reset 같은 준비를 한다. 그래서 ./xxx >out 2>&1의 stdout/stderr는 프로그램이 시작되기 전에 이미 새 open file description을 가리킨다. 구현은 fork 하나로 고정되지 않으며 posix_spawn과 vfork 계열 최적화가 같은 의미를 구현할 수 있다.
3. execve 경계에서 돌아갈 수 있는 코드가 사라진다
execve는 pathname, argv pointer 배열, envp pointer 배열을 syscall ABI로 넘긴다. 커널은 user pointer가 가리키는 문자열을 제한된 크기의 kernel memory로 복사하고 executable file을 연다. 성공하면 기존 stack과 heap pointer는 더 이상 유효하지 않으며 execve 다음 C 문장은 실행되지 않는다.
실패는 아직 old image를 유지할 수 있는 단계에서 -1과 errno로 돌아오는 경우가 일반적이다. 하지만 loader가 point of no return을 지난 뒤 치명적 오류가 나면 old image로 복귀할 수 없어 process가 signal로 종료될 수 있다.
4. pathname, permission, binary format을 구분한다
현재 directory의 search permission, file execute bit, mount의 noexec, LSM policy가 각각 실행을 막을 수 있다. regular file을 열었다는 사실만으로 실행 가능한 것은 아니다.
kernel은 linux_binprm의 앞부분을 읽고 registered binary handler를 순회한다. ELF magic이면 binfmt_elf, #!이면 binfmt_script가 선택된다. script는 shebang interpreter pathname과 optional argument, script pathname을 새 argv로 조립해 interpreter에 대해 exec 처리를 반복한다.
5. ELF loader는 mapping과 초기 register를 만든다
load_elf_binary는 architecture, ELF class, program header 범위를 검증하고 PT_LOAD를 VMA로 만든다. p_filesz보다 p_memsz가 큰 부분은 zero-fill되어 bss가 된다. PIE는 load bias를 정하고 ASLR 가능한 위치에 배치된다.
초기 stack에는 argc, argv, envp 문자열과 pointer 배열뿐 아니라 AT_PHDR, AT_ENTRY, AT_RANDOM, AT_EXECFN, AT_SYSINFO_EHDR 같은 auxiliary vector가 들어간다. start_thread는 새 instruction pointer와 stack pointer를 architecture register state에 기록한다.
6. 동적 ELF의 첫 userspace PC는 ld.so일 수 있다
PT_INTERP가 있으면 kernel은 main executable과 interpreter를 함께 mapping하고 interpreter entry로 userspace에 복귀한다. 이때 main executable의 원래 entry는 AT_ENTRY로 전달된다.
ld-linux는 DT_NEEDED dependency를 mapping하고 relocation, TLS, RELRO, constructor 순서를 처리한다. 그런 다음 main executable의 _start로 제어를 넘긴다. static executable은 PT_INTERP가 없어 이 동적 링커 단계를 건너뛴다.
7. _start가 C 실행 환경을 완성한다
_start는 compiler가 자동으로 만드는 main의 prologue가 아니라 crt startup object에서 제공하는 symbol이다. 초기 stack에서 argc와 argv를 꺼내고 stack alignment를 맞춘 뒤 __libc_start_main 계열 진입점에 main 주소를 넘긴다.
libc는 thread-local storage, stack canary, constructor와 stdio에 필요한 상태를 정리한 뒤 main을 일반 C ABI 함수처럼 호출한다. 프로그램이 관찰하는 getpid 값은 exec 전 child와 같지만 주소 공간의 code/data/stack은 새 image다.
8. main 반환부터 셸 프롬프트까지도 실행 과정이다
main이 반환하면 libc exit가 atexit handler와 .fini_array destructor를 호출하고 stdio buffer를 flush한다. 마지막에는 exit_group syscall이 thread group을 종료한다. _exit나 fatal signal 경로에서는 이 userspace cleanup 일부가 생략된다.
kernel은 task를 EXIT_ZOMBIE 상태로 두고 SIGCHLD 또는 wait queue wakeup으로 parent를 알린다. 셸은 waitpid/wait4 결과에서 정상 exit, signal 종료, stop/continue를 구분하고 terminal foreground를 회수한 뒤 $?와 job table을 갱신하고 다음 prompt를 출력한다.
OBJECTS
객체와 수명
| 대상 | 언제 생기고 없어지는가 | 확인할 값 |
|---|---|---|
shell process | 터미널 세션 동안 유지되며 child 생성과 wait, job-control을 소유한다 | PID/PGID, cwd, fd table, job table |
child task | spawn에서 생기고 exec 뒤 같은 PID로 새 image를 실행하며 wait 전에는 zombie가 될 수 있다 | PID, parent, credentials, exit status |
linux_binprm | exec 준비 동안만 존재하고 binary handler 선택 및 stack 구성에 사용된다 | file, buf, argc/envc, interp |
new mm_struct | ELF 적재 중 확정되고 다음 exec 또는 process exit까지 새 주소 공간을 소유한다 | PT_LOAD VMA, stack, brk, mmap base |
wait status | child 종료 때 기록되고 parent 셸이 wait로 소비한다 | exit code, terminating signal, rusage |
FAILURE PATH
실패 조건과 오해하기 쉬운 부분
| 겉으로 보이는 현상 | 실제 원인 후보 | 확인 방법 |
|---|---|---|
| bash: ./xxx: No such file or directory | xxx 자체가 없거나 PT_INTERP/shebang interpreter가 없음 | ls -l, file, readelf -l, head -1 |
| Permission denied | execute bit, directory search permission, noexec mount, LSM denial | namei -l, findmnt -no OPTIONS, audit log |
| Exec format error | 인식 가능한 ELF나 shebang 형식이 아님 | file, readelf -h, hexdump 첫 byte |
| main 전에 segfault | dynamic relocation, loader, constructor, ABI 또는 initial stack 문제 | gdb starti, LD_DEBUG, core dump |
| 셸 상태가 126/127 | 126은 찾았지만 실행 불가, 127은 명령 탐색 실패라는 셸 관례 | 셸 diagnostic과 실제 execve errno를 strace로 함께 확인 |
LAB
직접 확인
- ./exec_path 'two words' '*.c'를 실행해 quote 제거 뒤 argv가 어떤 문자열로 전달되는지 확인한다.
- chmod -x exec_path, noexec tmpfs, 존재하지 않는 PT_INTERP를 각각 만들어 EACCES와 ENOENT를 구분한다.
- readelf -l ./exec_path에서 INTERP와 LOAD를 확인하고 strace의 execve/mmap 순서와 맞춰 본다.
- gdb에서 starti로 멈춘 뒤 info proc mappings와 x/32gx $rsp를 사용해 entry point와 initial stack을 확인한다.
- 프로그램을 정상 return, _exit(7), SIGSEGV 세 경로로 종료해 destructor 실행 여부와 셸 $? 값을 비교한다.
./exec_path alpha beta; printf 'status=%d\n' $?strace -f -e trace=clone,clone3,vfork,execve,openat,mmap,mprotect,wait4,exit_group bash -c './exec_path alpha beta; printf "status=%d\n" $?'PRIMARY REFERENCES