Rust에서 main 이전에도 실행되는 코드가 있다

10 hours ago 4
  • Rust 바이너리는 fn main() 전에 런타임 초기화 단계를 거치며, 이 단계에서 패닉·언와인딩 처리와 프로그램 인자 변환 같은 작업이 수행됨
  • 운영체제 로더가 엔트리포인트로 제어를 넘기면 C 런타임과 Rust 런타임이 초기화 함수를 실행하며, #[unsafe(link_section = "...")]와 생성자 방식으로 pre-main 코드를 배치할 수 있음
  • 링커 섹션은 여러 크레이트가 제출한 데이터를 바이너리 작성 시점에 한곳으로 모아 주며, link-section은 이를 Rust 슬라이스처럼 다루게 해줌
  • ctor와 link-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는 start와 stop 심볼을 지원하지만 이름이 다르고, Windows는 start와 stop 심볼을 지원하지 않지만 사실상 동등한 섹션 정렬 규칙을 가짐
  • 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$start와 section$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가 아니므로, 별도의 래퍼 타입이 필요함
  • 예제는 SyncUnsafeCell과 MaybeUninit<SyncUnsafeCell<...>>을 사용해 경계 심볼과 항목을 구성함
  • 정렬 가능한 문자열 인터닝 풀 예제는 링크 시점에 문자열 풀을 정의하고, 런타임 초기에 슬라이스를 정렬해 이후 이진 검색으로 문자열을 찾음
  • 수동 구현은 보일러플레이트가 많지만, ctor와 link-section을 쓰면 TypedMutableSection과 생성자로 같은 구조를 간결하게 만들 수 있음
  • TypedMutableSection의 항목은 const여야 하며, 이는 수동 구현 예제와 비슷한 방식의 코드가 내부적으로 쓰이기 때문임

링크 섹션 패턴의 이점

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

이 방식을 쓰지 말아야 할 때

  • 링크 시점 계산은 강력하지만 항상 적절한 도구는 아님
  • 링크 시점 방식 대신, 데이터를 기여하려는 각 크레이트를 볼 수 있는 크레이트에서 수동으로 데이터를 수집할 수 있음
  • 수동 수집은 불편할 수 있으며, 기여자들이 핵심 크레이트의 단일 기여 지점을 보는 대신 많은 크레이트 참조를 가진 수집 크레이트가 필요함
  • 데드코드 제거는 어려워짐
  • link-section과 linkme는 항목에 #[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가 있음
Read Entire Article