1P by GN⁺ | ★ favorite | 댓글 1개
  • Rust 바이너리는 fn main() 전에 런타임 초기화 단계를 거치며, 이 단계에서 패닉·언와인딩 처리와 프로그램 인자 변환 같은 작업이 수행됨
  • 운영체제 로더가 엔트리포인트로 제어를 넘기면 C 런타임과 Rust 런타임이 초기화 함수를 실행하며, #[unsafe(link_section = "...")]와 생성자 방식으로 pre-main 코드를 배치할 수 있음
  • 링커 섹션은 여러 크레이트가 제출한 데이터를 바이너리 작성 시점에 한곳으로 모아 주며, link-section은 이를 Rust 슬라이스처럼 다루게 해줌
  • ctorlink-section을 함께 쓰면 CLI 서브커맨드 등록, 문자열 인터닝 풀 정렬 같은 패턴을 main 전에 구성하고 이후에는 잠금 없이 읽을 수 있음
  • 이 방식은 할당 없는 집계와 제어 역전을 제공하지만, 데드코드 제거 어려움, 생성자 제약, 플랫폼 차이, Miri 호환성 한계 때문에 적용 범위를 신중히 골라야 함

Rust 바이너리의 main 이전 단계

  • 모든 Rust 바이너리는 fn main()을 갖지만, 실제 실행 흐름은 운영체제 로더와 런타임 초기화를 거친 뒤 main에 도달함
  • C에는 libc로 인식되는 C 런타임이 있고, Rust는 표준 라이브러리를 통해 자체 런타임을 가지며 C 런타임 위에 더 높은 수준의 추상화를 구성함
  • 런타임의 목적은 개발자 코드와 플랫폼 운영체제를 통합하는 데 있음
  • C 런타임은 main 이전 단계에서 할당, 파일 접근, 스레드 로컬 저장소 등 런타임 서비스를 구성함
  • Rust는 이 시점에 패닉과 언와인딩 처리를 준비하고, C 스타일 프로그램 인자를 std::env::args 인터페이스로 변환함
  • pre-main 단계는 사용자 코드보다 먼저 실행되고, 단일 스레드이며, 순서가 예측 가능한 환경이라는 점에서 결정적 초기화에 적합함

엔트리포인트

  • 바이너리는 운영체제 로더가 바이너리를 메모리에 올리고 환경을 설정한 뒤 제어를 넘기면서 시작됨
  • Linux에서는 ELF 헤더의 e_entry 필드에 엔트리포인트가 저장되며, 기본적으로 링커가 _start라는 심볼 주소를 배치함
  • Windows에도 유사한 훅이 있으며, 실행 파일은 _WinMainCRTStartup 함수에서 시작됨
  • 초기 런타임 부트스트래핑은 파일 I/O 초기화, 할당자 초기화 같은 정적 함수 호출 트리였음
  • 런타임이 복잡해지면서 정적 초기화 호출 트리도 커졌고, 바이너리는 필요할 수도 있고 아닐 수도 있는 C 런타임 기능을 더 많이 포함하게 됨
  • 링커가 사용되지 않는 코드를 바이너리 작성 전에 제거할 수 있게 되면서, 정적 초기화 호출 트리를 대체할 방식이 필요해짐
  • GCC의 __attribute__((constructor)) 방식은 초기화 함수 포인터 목록을 바이너리의 연속 영역에 배치하고, C 런타임이 시작 시 이를 순회해 호출하는 구조였음
  • 생성자에는 우선순위를 줄 수 있게 되었고, 예를 들어 버퍼링된 파일 I/O보다 malloc 초기화가 먼저 필요할 수 있음
  • Linux의 최신 glibc 런타임은 .init_array에 함수 포인터를 보관하며, 숫자 접미사로 실행 순서를 정할 수 있음
  • 우선순위 100 이하 값은 런타임 자체에 예약되어 있어 C 런타임을 쓰는 코드는 101 이상을 사용해야 함
  • Rust에서는 #[used]#[unsafe(link_section = ".init_array.101")] 같은 속성으로 초기화 함수 포인터를 배치할 수 있음

linktime: ctor, link-section

  • 예제는 Linux와 여러 BSD에서 동작하지만, 크로스플랫폼 예제로 설계되지는 않았음
  • macOS는 startstop 심볼을 지원하지만 이름이 다르고, Windows는 startstop 심볼을 지원하지 않지만 사실상 동등한 섹션 정렬 규칙을 가짐
  • ctorlink-sectionlinktime 프로젝트의 크레이트이며, 플랫폼별 차이와 링커 작업 복잡성을 추상화함
  • inventorylinkme는 같은 원리 위에 만들어진 널리 쓰이는 크레이트지만, 예제에는 한계가 있음
  • ctor 크레이트는 생성자를 크로스플랫폼 방식으로 등록하는 보일러플레이트를 처리함
  • #[ctor(unsafe, priority = 101)] 같은 속성을 붙인 함수는 코드에서 직접 호출하지 않아도 링커가 정리한 뒤 C 런타임이 호출함

섹션과 링커 스크립트

  • 컴파일러는 데이터나 코드를 바이너리 안의 특정 위치, 대부분의 플랫폼에서 섹션이라고 부르는 영역에 배치할 수 있게 함
  • Rust도 link_section 속성을 통해 같은 조직화 기능을 사용할 수 있음
  • 많은 링커는 개발자가 링커 스크립트를 제공할 수 있게 하며, 이 텍스트 파일은 오브젝트 파일들이 어떻게 조립될지 링커에 지시함
  • 링커 스크립트를 사용하면 하나의 C 파일이 Linux 실행 파일이 되거나, 하드디스크 부트 섹터에 놓이는 원시 어셈블리 블록이 될 수 있음
  • 링커 스크립트는 소스 파일에는 없지만 C 코드에서 로드된 바이너리의 기본 데이터 포인터에 접근하는 데 쓸 수 있는 가상 심볼을 정의할 수 있음
  • 예시 링커 스크립트의 _TEXT_START__TEXT_END_.text 섹션의 시작과 끝을 가리키도록 정의됨
  • _TEXT_START_ = .;의 마침표는 바이너리의 현재 출력 주소에 가까운 값으로 해석되는 위치 카운터를 의미함

링커 심볼

  • 링커는 시작·끝 심볼의 값을 포인터로 설정하는 것이 아니라, 같은 이름의 static이 놓이는 주소를 설정함
  • 시작·끝 심볼은 *const Type 포인터가 아니며, 자체 데이터 없이 주소만 의미를 가짐
  • 섹션은 시작 심볼을 포함하고 끝 심볼을 제외하는 범위에 있는 데이터로 구성됨
  • 많은 링커는 실행 파일의 모든 섹션 경계를 자동으로 정의하는 기능을 갖게 되었음
  • GNU 도구체인에서는 MY_SECTION이라는 섹션에 __start_MY_SECTION__stop_MY_SECTION 심볼이 자동 정의됨
  • macOS는 각 섹션에 대해 section$startsection$end 심볼을 합성하는 유사한 패턴을 가짐
  • GNU 링커에서 링커 스크립트에 명시되지 않은 섹션은 고아 섹션이라고 불림
  • 섹션 이름이 C 심볼 이름과 호환될 때만 링커가 _start·_stop 접두 심볼을 자동 정의함
  • our_strings는 동작하지만, our.strings.our_strings는 같은 방식으로 동작하지 않음
  • 경계 심볼은 데이터가 없고 주소만 중요하므로 예제에서는 MaybeUninit<()>으로 표현됨
  • Stable Rust에는 이상적인 “불투명 외부 타입”이 아직 구현되어 있지 않아 MaybeUninit이 대체 역할을 함
  • &raw const 포인터를 static 항목에 대해 만드는 것은 항상 유효하므로, 값을 읽지 않고 주소만 안전하게 얻을 수 있음
  • link-section은 이런 링커 섹션 세부사항을 추상화하고 표준 슬라이스 연산을 쓸 수 있는 Rust 슬라이스로 변환함
  • 링크 섹션의 힘은 바이너리에 코드를 제공하는 어떤 크레이트에서도 같은 섹션에 항목을 제출할 수 있고, 최종 바이너리 작성 직전에 링커가 모두 모아 준다는 점에 있음

의존성 주입

  • 섹션 기반 등록 패턴은 의존성 주입과 같은 원리로 동작함
  • DaggerSpring 같은 프레임워크도 등록 데이터의 소비자가 제공자와 결합하지 않아야 한다는 원리 위에 있음
  • 제공자는 정의 위치에서 데이터를 등록하고, 소비자는 레지스트리를 읽음
  • 전통적인 의존성 주입에서는 프레임워크가 시작 시 모듈 그래프를 걷거나 로드된 클래스를 스캔해 제공자와 소비자를 찾아야 하는 경우가 많음
  • 링커 섹션에서는 바이너리가 작성될 때 링커가 제공자 데이터를 수집하고 소비자가 쉽게 읽을 수 있게 만듦
  • CLI 서브커맨드 등록 예제는 link_section::section으로 서브커맨드를 등록하는 이 패턴의 사례임
  • Turbopack은 문자열 풀 상수, 직렬화·역직렬화 등록 장치, turbotask 증분 컴파일 함수 등록에 이 패턴을 사용함
  • 가상의 웹서버도 라우트와 미들웨어를 빌드 시점에 자동 수집하도록 이 패턴을 사용할 수 있음

등록에 섹션 사용

  • main 이전 작업의 장점은 명시적으로 시작하지 않는 한 스레드가 실행되지 않는다는 점임
  • 이 환경에서는 많은 경우 잠금이나 동기화 프리미티브의 복잡성을 피할 수 있음
  • 데이터의 생애주기를 main 이전의 쓰기 가능 단계와 main 이후의 불변 단계로 명확히 나눌 수 있음
  • 실행 중인 프로그램에서 데이터를 접근할 때 잠금 획득과 해제를 피하면 구조가 단순해지고 효율이 높아질 수 있음
  • 예제는 CliSubcommand 구조체, const 생성자 함수, #[section]으로 서브커맨드를 수집함
  • list, add, help 같은 서브커맨드는 코드 어디에나 위치할 수 있음
  • main 함수는 CLI_SUBCOMMANDS 섹션 정의만 볼 수 있으면 등록된 서브커맨드 이름과 위치를 몰라도 동적으로 디스패치할 수 있음
  • 등록된 서브커맨드가 없으면 기본 서브커맨드로 돌아가며, 예제에서는 help가 기본값으로 동작함

불변 데이터를 넘어서

  • 앞선 예제는 링크된 데이터가 불변이라고 가정하지만, 링커 기반 데이터 조직화는 가변 데이터에도 사용할 수 있음
  • 전역 정적 데이터의 가변성은 Rust에서 흔한 문제이며, 뮤텍스나 원자 타입 같은 내부 가변성 도구로 해결할 수 있음
  • 뮤텍스와 원자 타입은 경쟁이 없을 때 비싸지 않지만, 반드시 무료는 아님
  • Rust에서 데이터를 안전하게 변경하려면 변경이 스레드 안전하게 이뤄져야 하고, 가변 참조가 존재할 때 같은 데이터에 대한 다른 참조가 없어야 함
  • pre-main 환경은 명시적으로 스레드를 시작하지 않는 한 단일 스레드이므로 원자적 작업이 필요하지 않음
  • 단일 스레드 환경에서는 변경이 이후 읽기보다 먼저 일어나는 happens-before 관계가 자동으로 성립함
  • main 이전 링크 섹션 데이터 변경은 이후 어떤 스레드에서도 잠금 없이 안전하게 접근할 수 있음
  • 가변 참조를 main 이전에만 만들고 닫으면, 가변 참조가 존재할 때 다른 참조가 없는 조건도 충족됨
  • 링크 섹션의 슬라이스는 섹션 안의 정적 항목에 대한 별칭이므로, 슬라이스와 정적 항목 모두에 별칭 규칙이 적용됨
  • 슬라이스를 통해 안전하게 변경하려면 정적 항목을 반드시 UnsafeCell 안에 배치해야 함
  • UnsafeCell로 감싸지 않은 정적 항목은 LLVM이 값을 캐시하거나 재정렬하거나 데이터에 대해 가정을 할 수 있음
  • UnsafeCell 자체는 Sync가 아니므로, 별도의 래퍼 타입이 필요함
  • 예제는 SyncUnsafeCellMaybeUninit<SyncUnsafeCell<...>>을 사용해 경계 심볼과 항목을 구성함
  • 정렬 가능한 문자열 인터닝 풀 예제는 링크 시점에 문자열 풀을 정의하고, 런타임 초기에 슬라이스를 정렬해 이후 이진 검색으로 문자열을 찾음
  • 수동 구현은 보일러플레이트가 많지만, ctorlink-section을 쓰면 TypedMutableSection과 생성자로 같은 구조를 간결하게 만들 수 있음
  • TypedMutableSection의 항목은 const여야 하며, 이는 수동 구현 예제와 비슷한 방식의 코드가 내부적으로 쓰이기 때문임

링크 섹션 패턴의 이점

  • 이 패턴은 태그된 항목을 보장된 방식으로 집계하고, 모든 데이터를 미리 할당된 연속 메모리에 배치함
  • 등록 위치를 코드 어디에나 분산할 수 있음
  • 섹션 안 항목 수를 보장된 값으로 얻을 수 있음
  • 링크 섹션은 별도 할당이 필요하지 않음
  • 링크 섹션 없이 같은 구조를 만들면 HashMap, Vec 또는 다른 자료구조를 할당하고, 항목을 모으면서 여러 번 크기를 조정할 수 있음
  • 전통적인 수집 방식에서는 공유 타입 모듈, 기여 모듈, 수집 모듈 사이의 의존성이 깊게 얽힘
  • 링크 섹션을 쓰면 수집자가 어디에나 위치할 수 있고, 어떤 모듈이 데이터를 기여하는지 신경 쓰지 않아도 됨
  • scattered-collect는 링크 시점 지원을 갖춘 여러 자료구조 유사체를 제공함
    • Scattered*Slice는 슬라이스를 제공하는 다양한 Vec 유사 구조이며, 선택적으로 정렬을 지원함
    • ScatteredMapScatteredSet은 최소한의 pre-main 초기화로 해시 기반 키-값 조회를 제공하는 HashMap·HashSet 유사 구조임

이 방식을 쓰지 말아야 할 때

  • 링크 시점 계산은 강력하지만 항상 적절한 도구는 아님
  • 링크 시점 방식 대신, 데이터를 기여하려는 각 크레이트를 볼 수 있는 크레이트에서 수동으로 데이터를 수집할 수 있음
  • 수동 수집은 불편할 수 있으며, 기여자들이 핵심 크레이트의 단일 기여 지점을 보는 대신 많은 크레이트 참조를 가진 수집 크레이트가 필요함
  • 데드코드 제거는 어려워짐
  • link-sectionlinkme는 항목에 #[used]를 붙이므로 링커가 사용되지 않는 데이터를 제거할 수 없음
  • 인터닝된 문자열 원자처럼 작은 데이터에서는 문제가 아닐 수 있지만, 원시 JSON·JavaScript 조각이나 큰 데이터 구조를 인터닝하면 식별하기 어려운 데드코드가 많이 쌓일 수 있음
  • pre-main 생성자 함수에는 제한이 있음
  • 생성자 함수는 패닉을 일으키면 안 되며, Rust는 모든 표준 라이브러리 함수가 사용 가능하다고 보장하지 않음
  • 같은 우선순위 안에서 초기화 함수 호출 순서는 보장되지 않고 플랫폼 의존성이 큼
  • 이 제한은 신중한 설계로 우회할 수 있지만, pre-main 방식은 미묘하고 디버깅하기 어려운 이유로 올바르지 않을 수 있음
  • Miri는 모든 pre-main 생성자와 링크 섹션 구성을 완전히 지원하지 않음
  • 현재 Miri는 pre-main 실행을 매우 기본적으로만 보고, 링크 섹션을 모델링하지 않음
  • 정의되지 않은 동작 테스트에는 ASan, TSan 등 LLVM 새니타이저가 권장됨
  • 제어 역전 패턴은 링크 섹션에 데이터를 기여하는 모든 위치를 감사하기 어렵게 만들 수 있음
  • 널리 배포되고 많이 쓰이는 Rust 프로그램 다수는 이미 ctor, link-section, inventory, linkme 같은 pre-main 기능에 의존함

WASM에 대한 짧은 정리

  • WASM은 과거 선택의 영향으로 링커 섹션을 네이티브로 지원하지 않음
  • #[link_section] 주석은 항목을 진짜 코드 섹션에 배치하지 못하고, WASM 코드 자체에서 접근할 수 없는 WASM 커스텀 섹션에 배치함
  • linktime 크레이트는 WASM을 지원하며, WASM 바이너리에서도 접근법이 동작하게 하는 에뮬레이션 우회책을 제공함
  • 적절한 WASM 지원을 추가하는 방안은 향후 제안될 수 있음

결론

  • main 이전에는 특정 사례에서 상당한 이점을 주는 작업을 많이 수행할 수 있음
  • pre-main 환경은 순서가 높게 통제되고 제어 가능성이 높아 잠금, 원자 타입, 기타 동기화 프리미티브 없이도 많은 작업을 더 자신 있게 수행할 수 있음
  • 링크 섹션은 전체 바이너리에 걸쳐 관련 데이터를 임의로 집계하고 함께 배치할 수 있게 하며, 어색한 크레이트 의존성 순서를 피하게 해줌
  • 많은 경우 할당을 완전히 피할 수 있어, 반복 할당으로 인한 단편화 같은 할당자 문제에서 멀어질 수 있음
  • 관련 크레이트로는 ctor, dtor, link-section, scattered-collect가 있음

댓글과 토론

Lobste.rs 의견들
  • 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 headerAddressOfEntryPoint로 찾음. PE 헤더 시작 기준 Offset 0x0028에 있으며, 이 PE 헤더는 MZ(DOS EXE) 헤더와 DOS Stub 뒤에 옴
    PE 헤더의 세부를 배우려면 Making the smallest Windows applicationTiny 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은 그저 또 하나의 함수일 뿐이며 초기화는 대체로 개발자 몫이 됨
      비슷한 용도로 코드베이스에 직접 만든 반복 코드가 꽤 있어서, 이런 크레이트들이 임베디드 환경과 어떻게 맞물리는지 궁금함