메뉴
HN
Hacker News 47일 전

Rust에서 main 함수 이전의 생명주기

IMP
6/10
핵심 요약

이 글은 Rust 프로그램이 실행될 때 개발자가 작성한 main 함수가 호출되기 전, 런타임과 운영체제 수준에서 어떤 일이 일어나는지 심도 있게 다룹니다. C 런타임을 기반으로 구축된 Rust 런타임의 초기화 과정을 설명하며, 이 전처리 단계를 활용해 가변 데이터(Mutable data) 등을 다루는 새로운 기법을 소개합니다. Rust 바이너리의 저수준 동작 원리와 부트스트래핑(Bootstrapping) 환경의 중요성을 이해하려는 시스템 프로그래머에게 유용한 통찰을 제공합니다.

번역된 본문

투명한 정보 공개 🧠 이 글은 100% 인간이 직접 작성했습니다. Claude는 피드백 및 링커 심볼 다이어그램 작성을 보조하는 데 사용되었으며, Cursor는 피드백 및 예제 코드가 컴파일되도록 검증하는 데 사용되었습니다. 이 글의 저자는 'main 이전의 생명주기(life-before-main)'라는 주제에 깊은 관심을 가지고 있으며, 아래 예제에서 사용할 'ctor' 크레이트(crate)의 저자이자 'linktime' 프로젝트의 창시자이기도 합니다.

모든 Rust 바이너리에는 한 가지 공통점이 있습니다. 바로 fn main() 입니다. C 언어를 다뤄보셨다면 int main(argc, argv)라는 형태가 더 익숙하실 것입니다. 일부 플랫폼에서는 이를 조금 더 감출 수도 있지만, 내부적으로 모든 바이너리는 진입점(Entrypoint)을 가지고 있습니다. 우리는 main 함수가 호출되기 전에 어떤 일이 일어나는지, 그리고 그 시점에 우리가 어떤 흥미로운 작업들을 할 수 있는지 논의할 것입니다. 더불어, 현재 Rust 생태계에서 흔히 사용되지 않는 가변 데이터(Mutable data)를 다루기 위한 몇 가지 새로운 기법도 보여줄 것입니다.

이 글은 Rust 소스 코드가 Rust 바이너리가 되는 기술적인 세부 사항에 대한 심층 분석입니다. 독자에게 Rust의 참조(References), Unsafe Rust 등의 배경 지식이 있으면 이해하는 데 도움이 될 것입니다.

main 이전의 과정 (Before main) 대부분의 개발자들에게 익숙하지 않은 부분은 바로 어떻게 main 함수 안으로 진입하게 되는지 하는 점일 것입니다. 모든 언어의 내부에는 런타임(Runtime)이 존재합니다. C 언어에는 libc로 잘 알려진 C 런타임이 있습니다. Rust에도 자체적인 런타임인 Rust 표준 라이브러리가 있습니다. 그리고 실행 가능한 코드의 런타임 표준 언어(링구아 프랑카)가 대부분 C 언어이기 때문에, Rust는 C의 런타임 위에 자체 런타임을 구축합니다. 즉, C의 런타임을 캡슐화하는 더 높은 수준의 추상화 계층을 효과적으로 만드는 것입니다.

런타임을 정의하기란 조금 모호할 수 있습니다. 디스크에 존재하는 실행 가능한 코드이기도 하며, 컴파일 타임에 사용되는 컴파일 가능한 헤더와 라이브러리이기도 합니다. 하지만 런타임의 목적은 항상 같습니다. 바로 개발자가 작성한 코드를 플랫폼의 운영체제와 통합하는 것입니다.

개발자가 main으로 선언한 함수가 시작되기 전에도 처리해야 할 거대한 생태계가 존재합니다. C는 이 전단계를 사용하여 메모리 할당, 파일 접근, 스레드 로컬 저장소(Thread-local storage) 및 기타 C 런타임 서비스를 구성합니다. Rust 역시 이 시간을 활용해 자체 언어 및 런타임의 일부를 구성합니다. 구체적으로, Rust는 패닉(Panic) 및 스택 되감기(Unwinding)를 처리하는 인프라를 갖추고 있습니다. 또한 C 스타일의 프로그램 인자를 Rust 자체의 std::env::args 인터페이스로 변환해야 합니다. 이 모든 메커니즘은 Rust 컴파일러 프로젝트에서 확인할 수 있습니다.

런타임이 이러한 main 호출 전 단계(Pre-main phase)를 활용하는 이유는, 이 단계가 (1) 사용자 코드보다 먼저 실행된다는 것을 보장하며, (2) 안정적이고 결정론적인 초기화를 가능하게 하는 단일 스레드 기반의 높은 일관성과 예측 가능한 순서를 가진 환경을 제공하기 때문입니다. 이 환경을 활용하지 않는다면, 당신은 매우 유용한 부트스트래핑(Bootstrapping) 단계를 낭비하고 있는 셈입니다. 이 글의 뒷부분에서는 이 'main 이전의 생명주기'를 활용해 유용한 기본 요소(Primitives)들을 어떻게 구축할 수 있는지 살펴볼 것입니다.

진입점 (Entry Points) 바이너리는 운영체제의 로더(Loader) - 바이너리를 메모리에 로드하고 환경을 설정하는 OS의 일부 - 가 제어권을 넘겨줄 때 시작됩니다. 런타임은 로더로부터 이 제어권을 넘겨받는 역할을 담당합니다. 모든 운영체제에는 제어권을 넘겨받기 위한 플랫폼별 후크(Hook)가 존재하며, 어떤 면에서 이것이 진짜 main이라고 할 수 있습니다. Linux에서는 ELF 헤더의 e_entry 필드에 진입점이 저장되며, 기본적으로 링커(Linker)는 _start라는 이름의 심볼 주소를 이곳에 배치합니다. Windows에도 유사한 후크가 존재하며, _WinMainCRTStartup이라는 이름의 함수에서 실행 파일을 부팅합니다.

이 시점에서 C 런타임은 자신을 구성(Configuring)할 기회를 갖게 되며, 모든 런타임이 이 작업을 수행하는 방식은 바로 초기화 함수(Initialization functions)를 통하는 것입니다. 초기 런타임에서 부트스트래핑은 '파일 I/O 초기화, 할당자(Allocator) 초기화' 등과 같은 정적인 트리(Tree) 형태의 함수 호출이었습니다. 런타임이 점점 더 복잡해지면서 이러한 함수 호출 트리 역시 복잡해졌고, 필요하지 않을 수도 있는 더 많은 C 런타임 기능을 흡수하면서 바이너리 크기도 함께 증가했습니다.

시간이 지나면서 링커는 사용하지 않는 코드(사용되지 않는 C 런타임 부분 포함)를 바이너리를 디스크에 기록하기도 전에 폐기(Discard)하는 능력을 개발했습니다. 그리고 이 과정과 함께 정적 초기화 호출 트리를 대체할 수 있는 방법의 필요성이 대두되었습니다.

원문 보기
원문 보기 (영어)
Disclosures 🧠 This post is 100% human-written . Claude was used for feedback and to assist with the linker symbol diagram. Cursor was used for feedback and to ensure examples were compilable. The author of this post is deeply interested in the topic of life-before-main: he is the author of the ctor crate, and the creator of the linktime project that we’ll be using in the examples below. Every Rust binary has one thing in common: fn main() . If you come from the C world, that might be more familiar as int main(argc, argv) . Some platforms might obfuscate it a bit more, but under the hood, every binary has an entrypoint. We’re going to discuss what happens before main and what interesting things we can do there. In addition, we’ll be showing some novel techniques for mutable data that aren’t in common use in the Rust ecosystem today. This post is a deep dive into some technical details of how Rust source becomes a Rust binary. Some background knowledge may be helpful to the reader, including: References in Rust Unsafe Rust Before main What might not be familiar to most developers is how you get into the main function. You see, under the hood for every language is the runtime . C has one: the C runtime that you might recognize as libc . Rust also has its own runtime: the Rust standard library. And because C is the lingua franca of runtimes for most executable code 1 , Rust builds its own runtime atop of C’s, effectively building its own higher-level abstraction encapsulating C’s. A runtime is a bit fuzzy to define. It’s both the executable code that lives on disk and compilable headers and libraries used at compile time. But the purpose of a runtime is always the same: integrating developer code with the platform’s operating system. There’s an entire ecosystem of processing that happens before the function you declared as main starts up. C uses this to configure allocation, file access, thread-local storage and other C runtime services. Rust uses this time to configure parts of its own language and runtime. Specifically, Rust has infrastructure to handle panics and unwinding. Rust also needs to translate the C-style program arguments 2 into its own std::env::args interface. The machinery for all this is visible in the Rust compiler project . Runtimes make use of this pre-main phase because it guarantees (1) running before user code, and (2) a single-threaded, highly-consistent and predictably-ordered environment, which allow for reliable and deterministic initialization. By not taking advantage of this environment, you are missing out on a very useful bootstrapping phase. We’ll see later on in this post how we can build some useful primitives making use of life before main. Entry Points A binary starts when the operating system’s loader 3 - the part of the OS that loads the binary into memory and sets up the environment - hands off control. The runtime is responsible for accepting the hand-off from the loader. There’s a platform-specific hook on every OS that accepts the hand-off - to some extent this is the real main. On Linux, the entry point is stored in the e_entry field of the ELF header, and by default, the linker places the address of a symbol named _start there. A similar hook exists on Windows , and boots the executable in a function named _WinMainCRTStartup . At this point the C runtime has a chance to configure itself, and the way that all runtimes do this is via initialization functions. In early iterations of runtimes, bootstrapping was a static tree of function calls: initialize file I/O, initialize the allocator, etc. As runtimes became more complex, this tree of function calls became more complex, and binary sizes increased to absorb more C runtime functionality that they may or may not need. Over time, linkers developed the ability to discard unused code before even writing the binary to disk (including unused parts of the C runtime), and with that came a need for a replacement for the static init call trees. The most popular method 4 of declaring init code came from GCC: __attribute__((constructor)) . The way this worked was to place a list of init functions into a contiguous chunk of the binary on disk. When the C runtime started, it could walk through each of these functions and call them, allowing various bits of the C runtime to request initialization without strongly coupling subsystems, and allowing the linker to jettison unused subsystems, init code and all. Eventually the need for constructor ordering became important enough that constructors could be given a priority and run in a specific order, allowing the runtime to initialize subsystems before and after each other. E.g., the memory allocation ( malloc ) subsystem might be needed for buffered file I/O. On most platforms 5 , the linker was called in to do the priority work: each platform ended up with a way to prioritize the order in which data gets written to sections, which allowed for the C runtime to end up with a well-ordered list of function pointers 6 . We can even build an example of this by hand in Rust using the #[unsafe(link_section = "...")] attribute ( try it in the Rust Playground ): /// Linux example: the modern glibc runtime uses `.init_array` to hold function /// pointers, and a numeric suffix allows them to be ordered. Note that priorities /// less than or equal to 100 are reserved for the runtime itself, so any code that /// wants to use the C runtime must use a priority of 101 or higher. // On Linux, `.init_array` holds _function pointers_, not functions. // We can convert a function to a function pointer with one of the below // blocks which is equivalent to this: // // #[used] // <-- without this, Rust might decide the init function is unused and remove it // #[unsafe(link_section = ".init_array.NNNNN")] // <-- the section where we place the function pointer // static INIT_ARRAY_FN_PTR: extern "C" fn() // = function; // <-- the function pointer data: we assign the function to it // // extern "C" fn function() { ... } // <-- the function itself #[used] #[unsafe(link_section = ".init_array.101" )] static INIT_FN_FIRST : extern "C" fn () = const { extern "C" fn init () { println! ( "Initializing (first!)" ); } init }; #[used] #[unsafe(link_section = ".init_array.201" )] static INIT_FN_SECOND : extern "C" fn () = const { extern "C" fn init () { println! ( "Initializing (second!)" ); } init }; fn main () { println! ( "Main!" ) } linktime: ctor, link-section and more The examples in this post will work on Linux and various BSDs, but are not designed to be cross-platform examples. For example, macOS has start and stop symbols, but they are named differently 7 . Windows does not support start and stop symbols, but has a set of rules for sorting sections that is effectively equivalent. Because platforms are so widely variable, we’ll be introducing the ctor and link-section crates (from the linktime project ) as a way to abstract away platform-specific differences and hide the general complexity of linker work. The excellent inventory and linkme are two other very popular crates built on the same principles, but have limitations 8 that make them less suitable for the examples in this post. If you’d like to learn more, the link-section crate contains a detailed report on platform-specific behaviour . The ctor crate is designed to handle all of the boilerplate of registering constructors in a cross-platform way. This allows us to simplify our examples above to: use ctor :: ctor ; #[ctor(unsafe, priority = 101 )] fn init1 () { println! ( "Initializing (first)!" ); } #[ctor(unsafe, priority = 201 )] fn init2 () { println! ( "Initializing (second)!" ); } fn main () { println! ( "Main!" ) } Note that neither example explicitly calls the init functions. The linker organized them in a way that the C runtime called them for us! Sections and Linker Scripts The process in which constructors are linked isn’t mysterious, though. In fact, compilers allow you to name the loc