- C로 작성한 Hello World 한 줄도 컴파일된 실행 파일, C 표준 라이브러리, 시스템 호출, 커널, 터미널을 차례로 거쳐야 화면에 나타남
gcc hello.c -o hello로 만든 결과물은 ELF 64-bit x86-64 실행 파일이며, ELF 헤더의 진입점 0x1060에서 _start 코드가 먼저 실행됨
- 사용자가 작성한
main()은 직접 시작되지 않고 _start와 __libc_start_main을 거치며, printf("Hello World!\n")는 최적화로 더 단순한 puts() 호출이 됨
- 문자열은
.rodata의 0x2004에 바이트열로 저장되고, C 문자열은 길이 정보 대신 NULL 종료자로 끝을 판단함
- 실제 출력 경로는 libc 버퍼링과 잠금,
write 또는 writev 시스템 호출, Linux 커널, pseudo-terminal, 터미널 에뮬레이터 렌더링까지 이어지며 실행 환경에 따라 달라질 수 있음
C Hello World에서 출발
#include <stdio.h>
int main() {
printf("Hello World!\n");
return 0;
}
- Python의
print('Hello World!')와 같은 결과를 내지만, C 프로그램은 인터프리터로 바로 실행하지 않고 먼저 컴파일해야 함
gcc hello.c -o hello
./hello
Hello World!
- C 또는 어셈블리에 대한 기본 지식이 있으면 흐름을 따라가기 쉬운 수준임
실행 파일의 정체
file hello 결과에서 핵심은 ELF executable, x86-64임
- ELF 실행 파일은 Linux에서 실행 가능한 프로그램 형식임
x86-64는 64비트 x86 프로세서용 기계어 프로그램을 뜻함
readelf -h hello로 ELF 헤더를 보면 Entry point address: 0x1060이 표시됨
- 이 주소는 프로그램이 로드된 뒤 CPU가 실행을 시작할 위치임
_start와 C 라이브러리 진입
objdump -D hello로 디스어셈블하면 0x1060 위치에 _start가 있음
_start는 사용자가 직접 작성한 코드가 아니라 컴파일러, 더 정확히는 링커가 자동으로 추가한 코드임
- 이 코드는 초기화를 수행한 뒤 다음 호출을 실행함
call *0x2f53(%rip) # 3fd8 <__libc_start_main@GLIBC_2.34>
- 해당 함수는 프로그램 안에 직접 정의되어 있지 않고 표준 C 라이브러리 쪽에 있음
readelf -d hello의 동적 섹션에는 libc.so.6 의존성이 표시됨
Shared library: [libc.so.6]
libc.so.6은 시스템의 표준 C 라이브러리이며, Linux의 .so 파일은 Windows의 .dll처럼 여러 프로그램이 공유할 수 있는 코드를 담음
- C 라이브러리는 명령행 인자와 환경 변수 처리 같은 초기화를 맡고,
main()을 호출한 뒤 반환값으로 프로그램을 종료함
main()에서 실제로 일어나는 일
- 디스어셈블 결과에서
main()은 0x1149에 있음
main()의 흐름은 다음과 같음
- 스택 프레임을 설정함
- 함수 호출 인자를 준비함
- Hello World 출력 함수를 호출함
- 스택 프레임을 정리함
- 종료 코드
0으로 반환함
- 핵심은 문자열 주소를 인자로 준비하고
puts@plt를 호출하는 부분임
lea 0xeac(%rip),%rax
call 1050 <puts@plt>
- 소스 코드에는
printf()가 있었지만, 컴파일러가 최적화해 puts() 로 바꿈
printf()는 형식화 출력 기능을 갖춘 복잡한 함수임
- 예제는 변수 삽입 같은 형식화 기능을 쓰지 않으므로 더 단순한
puts()로 대체됨
puts()는 문자열 뒤에 줄바꿈을 직접 붙이기 때문에 원래 문자열의 \n도 제거됨
문자열이 저장되는 방식
- 문자열은
.rodata 섹션의 0x2004 주소에 있음
- 해당 위치의 바이트는 다음과 같음
48 65 6c 6c 6f 20 57 6f 72 6c 64 21 00
- 이 바이트열은
"Hello World!"와 마지막 0x00으로 해석됨
0x00은 NULL 종료자이며, C 문자열의 끝을 나타냄
- C 문자열은 길이 정보를 함께 갖지 않기 때문에, 문자열을 받는 함수는 NULL 종료자를 만날 때까지 한 바이트씩 처리함
- 문자열 사이에 NULL 종료자가 없으면 C 함수가 여러 문자열을 이어서 처리하거나 허용되지 않은 메모리를 읽어 Segmentation Fault로 종료될 수 있음
Glibc의 puts() 경로
puts@plt는 결국 표준 라이브러리 쪽으로 이어짐
- Glibc에서
puts()는 _IO_puts로 연결됨
_IO_puts는 다음 작업을 수행함
- 문자열 길이를 구함
stdout 출력 스트림에 대한 잠금을 얻음
- 조건을 확인하고
_IO_sputn을 호출함
- 줄바꿈 문자를 출력함
- 잠금을 해제하고 출력한 문자 수를 반환함
- Glibc 내부 구현은 크고 복잡해, 더 작은 C 라이브러리인 musl libc 흐름으로 이어짐
musl libc에서 출력이 내려가는 과정
- musl의
puts()는 stdout 잠금을 얻고 fputs()와 putc_unlocked('\n', stdout)를 호출한 뒤 잠금을 해제함
fputs()는 문자열 길이를 구하고 fwrite()를 호출함
fwrite()는 다시 잠금을 얻고 __fwritex()를 호출함
__fwritex()는 버퍼 상태를 확인하고, 필요하면 출력 스트림의 write 함수 포인터를 호출함
stdout은 fd = 1로 정의되어 있으며, write 함수는 처음에 __stdout_write로 지정됨
__stdout_write()는 TIOCGWINSZ ioctl을 수행한 뒤 __stdio_write()를 호출함
__stdio_write()는 SYS_writev로 시스템 호출을 수행함
시스템 호출과 커널
- C 라이브러리만으로는 하드웨어와 직접 통신할 수 없으며, 하드웨어 접근은 운영체제 커널이 담당함
- 출력 요청은 결국 운영체제에 텍스트를 출력 스트림에 쓰라고 요청하는 시스템 호출로 끝남
- 일반적인 출력은
write 시스템 호출로 수행되며, musl은 여러 버퍼를 배열로 쓸 수 있는 writev를 사용함
- musl의 x86-64 시스템 호출 구현은 인자 개수별로
__syscall0부터 __syscall6까지 나뉨
- 각 함수는 인자를 CPU 레지스터에 설정하고
syscall 명령을 실행함
- 제어권은 커널로 넘어감
- 커널은 레지스터의 파라미터를 읽고 요청된 시스템 호출을 수행함
커널 이후 화면에 보이기까지
- Linux 커널은
write 시스템 호출을 받아 열린 파일이나 스트림에 데이터를 씀
write 시스템 호출은 파일 디스크립터, 쓸 버퍼, 쓸 바이트 수를 인자로 받음
- 예시 환경에서는 GNOME 터미널 에뮬레이터에서
hello 프로그램이 실행되고, stdout은 /dev/pts/0 pseudo-terminal로 연결됨
- 커널은 Hello World 메시지를 버퍼에 저장하고, 터미널 에뮬레이터가 이를 읽어 화면에 표시함
- 터미널 에뮬레이터는 텍스트를 프레임으로 렌더링하고, X 서버 또는 컴포지터가 다른 앱 화면과 합성한 뒤 커널을 통해 디스플레이에 표시함
- 실행 환경에 따라 이후 경로는 달라질 수 있음
- 원격 로그인에서는 커널이 텍스트를
sshd로 보내고, sshd가 암호화된 패킷으로 다시 커널에 전달해 인터넷으로 보냄
- 물리 터미널과 serial-to-USB 어댑터를 쓰면 커널이 텍스트를 USB 패킷으로 보냄
- framebuffer console에서는 커널이 텍스트를 프레임으로 렌더링해 디스플레이에 출력함
작은 출력 하나에 얽힌 복잡성
- Hello World 메시지 전송은 하나의 프로그램에서 발생한 하나의 시스템 호출일 뿐임
- 현대 소프트웨어와 하드웨어는 작은 동작 하나도 완전히 추적하기 어려울 만큼 복잡하고 세밀한 계층으로 구성됨
- 이 설명은 많은 세부 사항과 예외, 커널 내부 동작을 생략하고 주요 흐름만 따라감