Hello World가 화면에 찍히기까지
(thecoder08.github.io)- 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에서 출발
- 예제 프로그램은 C로 작성한 다음 코드임
#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()는TIOCGWINSZioctl을 수행한 뒤__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/0pseudo-terminal로 연결됨 - 커널은 Hello World 메시지를 버퍼에 저장하고, 터미널 에뮬레이터가 이를 읽어 화면에 표시함
- 터미널 에뮬레이터는 텍스트를 프레임으로 렌더링하고, X 서버 또는 컴포지터가 다른 앱 화면과 합성한 뒤 커널을 통해 디스플레이에 표시함
- 실행 환경에 따라 이후 경로는 달라질 수 있음
- 원격 로그인에서는 커널이 텍스트를
sshd로 보내고,sshd가 암호화된 패킷으로 다시 커널에 전달해 인터넷으로 보냄 - 물리 터미널과 serial-to-USB 어댑터를 쓰면 커널이 텍스트를 USB 패킷으로 보냄
- framebuffer console에서는 커널이 텍스트를 프레임으로 렌더링해 디스플레이에 출력함
- 원격 로그인에서는 커널이 텍스트를
작은 출력 하나에 얽힌 복잡성
- Hello World 메시지 전송은 하나의 프로그램에서 발생한 하나의 시스템 호출일 뿐임
- 현대 소프트웨어와 하드웨어는 작은 동작 하나도 완전히 추적하기 어려울 만큼 복잡하고 세밀한 계층으로 구성됨
- 이 설명은 많은 세부 사항과 예외, 커널 내부 동작을 생략하고 주요 흐름만 따라감
댓글과 토론
Hacker News 의견들
-
심심해서 macOS에서 Rust로 비슷한 걸 해봤는데,
#![no_std],#![no_main], 직접WRITE/EXIT시스템 호출을 쓰는 “Hello, world!”도 Ghidra로 보면 출력이 어떻게 해도 약 16KB 정도였음
더 줄이는 코드 골프는 가능하겠지만, 이미 누군가 해보고 문서화했을 가능성이 커 보임- Windows에서는 비슷한 프로그램이 3072바이트였고,
rustc hello.rs -C panic=abort -C opt-level=3 -C link-arg=/entry:main로 컴파일했음
kernel32의ExitProcess,GetStdHandle,WriteFile을 직접 호출했고, hello world라서 패닉 처리기는 대충 둠. 실행 파일 안에 아직 패딩이 꽤 있어서 크기를 늘리지 않고도 더 넣을 수 있고, 더 “범죄적인” 방법으로 줄일 수도 있겠지만 큰 의미는 없어 보임
참고로 관련 PDB 디버그 데이터베이스는 208,896바이트였음 - 가장 작게 만들려면
main을 아예 버리고_start를 써야 하며, 섹션 정렬을 하지 않도록 링커 플래그도 넘겨야 함
https://darkcoding.net/software/a-very-small-rust-binary-ind...를 보면 이런 방식으로 쉽게 500바이트 근처까지 갈 수 있음 - 코드 골프는 재미있지만, 요즘 페이지 크기가 얼마인지도 봐야 함
스택이 있는 언어라면 실행 파일은 결국 읽기 전용/읽기-쓰기 페이지로 최소 두 페이지 정도는 올라갈 가능성이 큼 - min-sized-rust 프로젝트에는 Rust 바이너리 크기를 줄이는 최적화가 많이 정리돼 있음
모든 최적화를 적용하면 hello world가 최종적으로 약 8KB 정도였던 것으로 기억함: https://github.com/johnthagen/min-sized-rust - XNU는 한 페이지보다 작은 Mach-O를 로드하지 않아서, 그 플랫폼에서는 아쉽게도 작은 바이너리 놀이를 할 여지가 많지 않음
- Windows에서는 비슷한 프로그램이 3072바이트였고,
-
Musl이 건너뛴 또 다른 토끼굴이 있음. Linux에서 시스템 함수를 호출하는 게
syscall을 직접 쓰는 것만은 아님
더 “예의 바른” 방식은 vDSO를 호출하는 것임. 커널이 주소 공간에 자동으로 매핑해 주는 마법 같은 작은 라이브러리라서, 커널은 시스템 호출을 수행하는 데 최적인 코드를 제공할 수 있음
일부 시스템 호출은 사용자 공간에서 실행되어syscall자체가 필요 없어질 수도 있고, 예전에는 vDSO가int 0x80이나sysenter같은 커널 호출 메커니즘 중 하나를 고르기도 했음
https://man7.org/linux/man-pages/man7/vdso.7.html- 32비트 x86에서만 vDSO가 범용 빠른 시스템 호출 셔임을 담고 있음
x86-64에서는 표준 시스템 호출 방식이SYSCALL명령이고, vDSO에는 시간 관련 함수와 SGX 관련 함수 몇 개만 들어 있음
- 32비트 x86에서만 vDSO가 범용 빠른 시스템 호출 셔임을 담고 있음
-
언어별 “Hello World” 프로그램의 오버헤드를 비교한 글도 볼 만함: https://drewdevault.com/2020/01/04/Slow.html
후속 글: https://drewdevault.com/2020/01/08/Re-Slow.html
Linux에서 가장 작은 프로그램을 만든 전설적인 글도 있음. 프로그램은 단순히 상태 코드 42로 종료함: https://www.muppetlabs.com/~breadbox/software/tiny/teensy.ht...
같은 사이트에서 가장 작은 “Hello World” 프로그램도 찾을 수 있음 -
이 글은 사실상 프로그램의 진짜 진입점이라고도 볼 수 있는 동적 링커의 역할을 거의 건너뜀
이 관점이 궁금하면 https://gist.github.com/kenballus/c7eff5db56aa8e4810d39021b2...를 보면 됨 -
DOS 애호가라면, DOS에서 어셈블리/기계어로 쓴 “hello, world”는 23바이트까지 작아질 수 있었음: https://github.com/susam/hello
이 23바이트 중 15바이트는 달러 기호로 끝나는 문자열 자체가 차지하므로, 실제 기계어 코드는 x86 명령 네 개로 된 8바이트뿐임 -
글은 좋았지만 두 가지를 더 했으면 함.
printf가puts로 바뀌게 만든 최적화와 인라이닝을 끄거나, 애초에puts를 직접 쓰는 hello world로 작성하는 편이 나음
또 컴파일 단계를 전처리, 컴파일, 어셈블, 링크라는 네 단계로 나누거나cc에--save-temps를 붙여 생성되는 파일들을 설명하면 좋음. 파이프라인을 직접 보면 마법처럼 보이는 부분이 훨씬 줄어듦 -
대학 시스템 프로그래밍 수업에서 좋아하던 과제가 떠오름: “C++ hello world 조각을 주고, 가능한 가장 작은 컴파일된 바이너리를 제출하라”는 과제였음
readelf와objdump같은 도구로 프로그램을 들여다보고, 레이어와 컴파일러 최적화를 조금씩 벗겨내면서 여전히 “hello world”를 출력하는 가장 작은 바이너리까지 줄였던 기억이 남
당연히 검색해 보니 학생들보다 훨씬 잘한 사람이 있었음: https://www.muppetlabs.com/%7Ebreadbox/software/tiny/teensy....- 그 조각이 C++였다는 게 의미가 있나 싶음
그냥 hello world를 출력하는 가장 작은 바이너리를 만들고, 의미상 동등하다고 주장할 수 있지 않나. 문자열 데이터까지 포함해도 x86 명령 열 개 남짓이면 될 것 같음 - 좋아하는 과제라면 왜 “hello world”가 아닌 프로그램에서도 가능한 가장 작은 바이너리를 만드는 사람이 더 많이 보이지 않는지 궁금함
개인적으로는 내가 가진 컴퓨터의 공간을 아끼는 걸 좋아해서 재미있지만, 요즘은 10MiB, 20MiB, 50MiB, 100MiB가 넘는 프로그램이 많이 작성됨. 상업 환경에서 상업적 목적으로 만들어진 것도 있지만, 순수한 즐거움으로 쓴다는 프로그램도 많음. 작은 프로그램을 쓰는 즐거움은 없는 걸까
- 그 조각이 C++였다는 게 의미가 있나 싶음
-
“자정이 넘었으니 자야겠다”는 식의 마무리는 오히려 이 글에 완벽한 결말이었음
-
아쉽게도 많은 “hello world” 심층 탐구처럼, 이 글도
write시스템 호출에서 멈추고 나머지를 대충 넘김
시스템 호출 전까지는 본질적으로printf가puts를 부르고,puts가write를 부르며char const*를 전달하고 약간의 장부 처리를 하는 함수 호출 연쇄라서 개인적으로 가장 흥미로운 부분은 아님
정말 흥미롭고 복잡해지는 건 시스템 호출 이후임. 커널이 프로세스의stdout을 터미널 에뮬레이터 쪽 입력에 연결하고, 터미널은 글꼴 렌더링 라이브러리와 GPU 드라이버로 프레임버퍼를 준비함. 문자 바이트에 맞는 글꼴 윤곽을 디스크에서 읽고, 뷰포트에 맞추고, 크기 조정·커닝·글꼴 메트릭을 적용한 뒤, GPU가 래스터화와 안티앨리어싱을 수행함
이후 창 관리자가 터미널 창 프레임과 데스크톱을 합성하고, 투명도나 서리 낀 유리 효과가 있으면 셰이더로 처리함. 결과 프레임버퍼는 모니터 해상도와 색상 깊이에 맞춰 HDMI나 DisplayPort 신호로 패킷화되고, 케이블과 디스플레이 입력 회로를 거쳐 픽셀 주소 지정 신호로 바뀜. LCD, OLED, 플라즈마, CRT에 따라 새로 고침 방식이 다르고, 예를 들어 3840×2400 WRGB OLED는 약 3,686만 개의 서브픽셀을 다뤄야 함
이 모든 과정이 60Hz 기준 한 프레임 시간인 16.67ms 안에 일어남- 설명은 좋지만, 결국 인간의 시각 시스템에서 멈춘 셈이고 거기가 또 정말 흥미롭고 복잡한 부분임 :)
https://en.wikipedia.org/wiki/Visual_system - 이런 깊이의 탐구가 좋다면 Gynvael Coldwind의 Windows에서 Python hello world가 어떻게 실행되는지 파고드는 글도 좋아할 듯함
CPython 내부, Windowsconhost, 글꼴 래스터화, GPU 렌더링 등을 다룸: https://gynvael.coldwind.pl/?id=754 - 이 대부분은 프로그램 자체와는 무관함. 예를 들어 출력을
/dev/null로 파이프했다면 이런 일은 일어나지 않음 _start이전에 일어나는 일도 빠져 있음. 예를 들면 Linux에서 프로세스가 태어나는 방식, 특히 꽤 이상한execve, 프로그램이 메모리에 로드되는 과정,binfmt_*와 강력한binfmt_misc, 재배치, 예외 처리 프레임, 섹션, ELF 로더 전반, 필요한malloc을 포함한 운영체제 자원 할당 등이 있음
- 설명은 좋지만, 결국 인간의 시각 시스템에서 멈춘 셈이고 거기가 또 정말 흥미롭고 복잡한 부분임 :)
-
“Python과 달리 이 프로그램을 실행하려고 인터프리터를 호출할 수는 없다”는 말은 꼭 맞지는 않음
tcc -run hello.c를 쓰면 가능함. 엄밀히는 인터프리터가 아니라 메모리 내 컴파일러에 가깝지만
추가 괴짜 점수를 원하면 프로그램이 “Hello world” 대신 “Hellorld”라고 말하게 만들면 됨