← System Programming DUJINLABS.COM

Syscall / ELF · Linux userspace / kernel ABI

./xxx 입력에서 main()과 종료까지

셸에 ./xxx를 입력했을 때 명령 해석, child 생성, execve, ELF 적재, 동적 링커, _start, main, exit_group, wait가 이어지는 전체 과정을 따라갑니다.

Series
02 / 37
Build
cc -std=c17 -Wall -Wextra -O2 exec_path.c -o exec_path
Run
./exec_path alpha beta; printf 'status=%d\n' $?
Kernel
Linux 6.18.37 LTS

터미널에서 ./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 상태로 바꾸고 프롬프트를 다시 출력한다.

구조 그림

그림 1. 셸 parent와 실행 child가 갈라졌다 다시 만나는 지점
$ ./xxx alpha beta
interactive shellparse · expand · redirection · ./ 경로 확정
spawn

PID A

parent shell

  • child PID/PGID 기록
  • foreground terminal 양도
  • wait4(child)에서 대기
blocked in wait

PID B

child before exec

  • fd redirection
  • signal state 정리
  • execve("./xxx", argv, envp)
kernel exec
  • cwd 기준 path walk
  • execute permission / LSM
  • ELF 또는 #! handler
  • PT_LOAD · stack · auxv
  • 새 IP/SP 설정
new userspace image
  • ld-linux entry
  • dependency · relocation
  • _start
  • __libc_start_main
  • main(argc, argv)
exit_group → wait4zombie status 소비 · $? 갱신 · terminal 회수 · prompt

세로선은 같은 PID에서 이어지는 실행이고, 좌우 분기는 parent shell과 child의 동시 존재를 뜻한다. execve 성공 뒤 child PID는 그대로지만 old userspace image는 사라진다.

호출 흐름

그림 2. 사용자 코드에서 관찰 가능한 결과까지
shell parse ./xxx와 argv, redirection 확정
spawn child fork/clone/vfork/posix_spawn
execve 같은 PID의 image 교체
runtime ld.so → _start → main
shell wait exit status를 $?에 기록

부모 셸과 실행 child를 한 process로 생각하면 wait와 exec를 혼동하게 된다. exec는 child를 새로 만들지 않고, 이미 만들어진 실행 주체의 userspace image를 교체한다.

그림 3. 커널 내부에서 지나가는 주요 지점
do_execveat_common filename, argv, envp 복사
do_open_execat pathname과 execute permission
search_binary_handler ELF 또는 #! 선택
load_elf_binary PT_LOAD, stack, auxv
start_thread 새 IP와 SP로 userspace 복귀

함수 이름을 외우기 위한 그림이 아니다. 반환값, 파일 디스크립터, 메모리 매핑, 대기 큐 가운데 무엇이 다음 단계로 전달되는지 확인한다.

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 회수

실행 예제 원본

아래 코드는 설명을 위해 중간 줄을 생략한 의사 코드가 아니다. 파일로 빌드해 실행할 수 있는 최소 예제다.

빌드cc -std=c17 -Wall -Wextra -O2 exec_path.c -o exec_path
01#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}

코드 조각별 설명

실제 코드 9행__attribute__((constructor))

main보다 먼저 실행되지만 ELF entry가 이 함수인 것은 아니다. 동적 링커와 libc startup이 relocation을 끝낸 뒤 .init_array를 순회하면서 호출한다.

실제 코드 14행dprintf(STDERR_FILENO

stderr fd 2가 close-on-exec로 닫히지 않았다면 셸에서 child로 상속된다. redirection을 사용했다면 셸이 exec 전에 fd 2의 대상을 바꿔 둔다.

실제 코드 25행readlink("/proc/self/exe"

argv[0]은 호출자가 정하는 문자열이라 실제 실행 파일 경로의 증거가 아니다. /proc/self/exe symlink는 현재 mm에 연결된 executable file을 가리킨다.

실제 코드 35행for (int i = 0; i < argc

셸이 quote와 expansion을 처리한 결과가 argv 배열로 전달된다. 따옴표 문자는 제거되며 glob은 셸에서 이미 여러 인자로 펼쳐진다.

실제 코드 39행return 7;

main 반환값은 libc의 exit 경로로 들어가 destructor와 stdio flush를 수행한 뒤 exit_group status가 된다. 부모 셸은 wait status에서 이를 꺼내 $?에 7을 기록한다.

세부 동작

01

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에 .이 없다면 실행되지 않는 이유다.

02

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 계열 최적화가 같은 의미를 구현할 수 있다.

03

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로 종료될 수 있다.

04

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 처리를 반복한다.

05

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에 기록한다.

06

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가 없어 이 동적 링커 단계를 건너뛴다.

07

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다.

08

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를 출력한다.

객체와 수명

대상언제 생기고 없어지는가확인할 값
shell process터미널 세션 동안 유지되며 child 생성과 wait, job-control을 소유한다PID/PGID, cwd, fd table, job table
child taskspawn에서 생기고 exec 뒤 같은 PID로 새 image를 실행하며 wait 전에는 zombie가 될 수 있다PID, parent, credentials, exit status
linux_binprmexec 준비 동안만 존재하고 binary handler 선택 및 stack 구성에 사용된다file, buf, argc/envc, interp
new mm_structELF 적재 중 확정되고 다음 exec 또는 process exit까지 새 주소 공간을 소유한다PT_LOAD VMA, stack, brk, mmap base
wait statuschild 종료 때 기록되고 parent 셸이 wait로 소비한다exit code, terminating signal, rusage

실패 조건과 오해하기 쉬운 부분

겉으로 보이는 현상실제 원인 후보확인 방법
bash: ./xxx: No such file or directoryxxx 자체가 없거나 PT_INTERP/shebang interpreter가 없음ls -l, file, readelf -l, head -1
Permission deniedexecute bit, directory search permission, noexec mount, LSM denialnamei -l, findmnt -no OPTIONS, audit log
Exec format error인식 가능한 ELF나 shebang 형식이 아님file, readelf -h, hexdump 첫 byte
main 전에 segfaultdynamic relocation, loader, constructor, ABI 또는 initial stack 문제gdb starti, LD_DEBUG, core dump
셸 상태가 126/127126은 찾았지만 실행 불가, 127은 명령 탐색 실패라는 셸 관례셸 diagnostic과 실제 execve errno를 strace로 함께 확인

직접 확인

  1. ./exec_path 'two words' '*.c'를 실행해 quote 제거 뒤 argv가 어떤 문자열로 전달되는지 확인한다.
  2. chmod -x exec_path, noexec tmpfs, 존재하지 않는 PT_INTERP를 각각 만들어 EACCES와 ENOENT를 구분한다.
  3. readelf -l ./exec_path에서 INTERP와 LOAD를 확인하고 strace의 execve/mmap 순서와 맞춰 본다.
  4. gdb에서 starti로 멈춘 뒤 info proc mappings와 x/32gx $rsp를 사용해 entry point와 initial stack을 확인한다.
  5. 프로그램을 정상 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" $?'

원문