Rust에서 main 이전에도 실행되는 코드가 있다
요약
본 글은 다양한 운영체제(Apple, OpenBSD, Linux, Windows 등)에서 프로그램 실행 시 C 런타임 라이브러리 및 시스템 호출이 어떻게 관리되고 진입점(_start, _WinMainCRTStartup)이 설정되는지 기술적으로 분석합니다. 각 OS의 ABI 안정성 경계와 바이너리 구조(ELF, PE)를 비교하며 깊이 있는 이해를 제공합니다.
핵심 포인트
- OS별 C 런타임 및 시스템 호출 관리 방식 차이점 파악
- Linux ELF 파일의 진입점은 e_entry 필드를 통해 지정됨
- Windows는 MZ/DOS 헤더와 PE 헤더 구조를 따름
- 프로그램 시작 지점(진입점) 설정에 대한 역사적 배경 설명
Go는 대부분 플랫폼에서 C 런타임을 피한다는 점에서 예외적이지만, Apple은 시스템 호출 접근에 C 런타임을 요구함
Apple은 시스템 호출의 ABI 안정성 경계로 libSystem.dylib를 쓰고, NT 계열 Windows는 시스템 호출이 아니라 ntdll.dll을 ABI 안정성 경계로 둠: not syscalls
OpenBSD에서는 Go가 로더가 설정한 읽기 전용 libc 매핑 밖에서 시스템 호출을 시도하면 커널이 종료시키는 정책을 피하려고, NX 비트 강제 적용을 끄는 식의 메타데이터 플래그를 설정했던 것으로 보임
다만 libSystem.dylib contains the functionality which would normally be libc.so plus other things이므로, 그런 면에서는 BSD 계열의 “libc가 안정성 경계”라는 방식과 같음
또 As of Go 1.16부터 Go는 OpenBSD의 시스템 호출 정책을 따르기 위해 libc를 사용함
Linux는 안정적인 시스템 호출 번호를 가진 경우라 상대적으로 드문 편인데, 다른 OS들처럼 “프로세스 주소 공간에 동적 라이브러리로 로드되는 커널 조각이 커널 모드 코드와 불안정한 시스템 호출 enum 정의를 공유하는” 구조가 아니기 때문이며, Linux와 glibc가 다른 곳들처럼 같은 저장소에서 함께 개발되지 않기 때문임
Windows에서는 C 런타임이 MS-DOS가 복사해 왔고 Windows의 하위 프로세스 생성 API도 이어받은 CP/M식 명령 문자열을 POSIX식 argv 배열로 파싱하는 일도 맡음
그래서 Python subprocess 문서에 Converting an argument sequence to a string on Windows 섹션이 있고, MS C 런타임에 박힌 따옴표 규칙에 따라 argv 배열을 문자열로 바꾸는 방식을 설명함. 호출된 하위 프로세스의 자체 파서는 원하면 이 규칙과 다르게 동작할 수 있음
Linux의 _start도 정확히는 링커가 그 이름의 심볼을 자동으로 바이너리에 넣는다는 뜻이 아님. ELF 형식 바이너리가 라이브러리가 아니라 실행 파일이면 헤더의 e_entry 필드, 즉 오프셋 0x18에 로더가 메모리 설정 뒤 점프할 주소가 들어감 _start는 libc가 제공하는 진입점을 쓰지 않을 때 e_entry가 가리킬 대상을 지정하는 GCC 관례이고, NASM 같은 도구도 이를 따르는 것으로 기억함
Windows의 _WinMainCRTStartup도 로더가 PE header의 AddressOfEntryPoint로 찾음. PE 헤더 시작 기준 Offset 0x0028에 있으며, 이 PE 헤더는 MZ(DOS EXE) 헤더와 DOS Stub 뒤에 옴
PE 헤더의 세부를 배우려면 Making the smallest Windows application와 Tiny PE가 좋음. Tiny PE는 Windows가 받아들이는 방식으로 PE 명세를 어기기도 하는데, 예를 들어 OS가 읽지 않을 부분을 겹쳐 놓거나 쓰지 않는 헤더 필드에 코드를 넣음. 이 정도까지 가면 Windows가 받아들이는 최소 파일 크기는 실행하는 Windows 버전에 따라 달라짐
Linux의 아주 작은 ELF 실행 파일에 대해서는 A Whirlwind Tutorial on Creating Really Teensy ELF Executables for Linux도 볼 만함
FreeBSD와 NetBSD의 시스템 호출은 시스템 라이브러리와 마찬가지로 ABI 안정성을 가짐
_start와 관련해, a.out 시스템에서는 커널이 실행 파일로 들어가는 진입점이 전통적으로 csu/crt0에 선언된 start였음. 예를 들면 7th edition, VAX BSD가 있음
그 시절 C 컴파일러는 전역 심볼 앞에 _를 붙였기 때문에 V7은 _main을 선언하고, BSD는 C의 start()에 대한 어셈블리 이름을 장식 없는 start로 선언한 것을 볼 수 있음
당시 프로그램은 파일 시작 지점에서 시작했고, cc의 링커 호출이 crt0가 맨 앞에 오도록 배치했음. csu는 C 시작 코드, crt0는 0번째 C 런타임 지원 객체를 뜻함
ELF가 나온 System V에서 정확히 어떻게 동작했는지는 찾기 더 어렵지만, start 또는 _start가 csu/crt0에 선언된 프로그램 진입점으로 계속 쓰였음
ELF가 _ 접두 처리를 어떻게 바꿨는지는 제대로 이해해 본 적이 없지만, 아마 재미 삼아 한 겹을 더 추가한 탓에 start가 어떤 이유로 _start가 된 것 같음
분명한 짝으로는 ELF가 _end를 추가한 듯한데, 이는 BSS의 상단에 해당하고 malloc()이 힙을 만들기 전 sbrk(0)이 반환할 위치와 대응함
Rust에서 main 이전의 삶에 관심이 있었고, 그것이 무엇이며 왜 유용한지 한 글로 정리하면 좋겠다고 봄
링커 집계를 활용해 더 빠른 컬렉션을 만드는 방법 같은 후속 글 아이디어도 있지만, 우선 이 입문 중심 주제에 대한 피드백을 듣고 싶음
임베디드 Rust를 많이 해 왔고, 그래서 no_std와 때로는 alloc도 없는 환경에서 main은 그저 또 하나의 함수일 뿐이며 초기화는 대체로 개발자 몫이 됨
비슷한 용도로 코드베이스에 직접 만든 반복 코드가 꽤 있어서, 이런 크레이트들이 임베디드 환경과 어떻게 맞물리는지 궁금함
AI 자동 생성 콘텐츠
본 콘텐츠는 GeekNews의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기