Skip to content

컴파일 과정

Rust 컴파일러(rustc)는 소스 코드를 여러 단계에 걸쳐 네이티브 바이너리로 변환한다.

소스 코드 (.rs)
→ 렉싱 → TokenStream
→ 파싱 → AST
→ 매크로 확장 → AST (확장됨)
→ 이름 해석 → AST (바인딩 완료)
→ HIR 변환 → HIR
→ 타입 검사 → HIR (타입 정보 첨부)
→ MIR 변환 → MIR
→ borrow check + 최적화 + 모노모피제이션 → MIR (최적화됨)
→ LLVM IR 생성 → LLVM IR
→ LLVM 최적화 + 코드 생성 → 오브젝트 파일
→ 링킹 → 실행 바이너리

렉싱 (Lexical Analysis)

소스 코드 텍스트를 의미 있는 최소 단위인 토큰(token)으로 쪼개는 단계다. tokenization이라고도 한다.

"let x = 42 + y;"
[Ident("let"), Ident("x"), Punct('='), Literal(42), Punct('+'), Ident("y"), Punct(';')]

이 시점에서 컴파일러는 각 토큰이 “무엇인지”만 알고, “어떤 구조인지”는 아직 모른다. let x = 42 + y가 변수 선언이라는 건 다음 단계에서 판단한다.

rustc의 렉서는 rustc_lexer 크레이트에 구현되어 있으며, 외부 의존성 없이 순수 Rust로 작성되어 있다.

파싱 (Parsing)

토큰 스트림을 AST(Abstract Syntax Tree, 추상 구문 트리)로 변환한다. rustc_parse 크레이트가 담당한다.

[Ident("let"), Ident("x"), Punct('='), Literal(42), Punct('+'), Ident("y"), Punct(';')]
LetStmt {
name: "x",
init: BinOp {
op: Add,
left: Literal(42),
right: Path("y"),
}
}

이 단계에서 문법 오류(syntax error)가 검출된다. 타입이나 의미에 대한 검사는 아직 하지 않는다.

매크로 확장 (Macro Expansion)

AST를 순회하면서 매크로 호출을 찾아 확장한다.

  • macro_rules!: AST 패턴 매칭으로 치환
  • 절차적 매크로: TokenStream을 매크로 함수에 넘기고, 반환된 TokenStream을 다시 파싱하여 AST에 삽입

절차적 매크로가 토큰 수준에서 동작하는 이유는, 렉싱 결과와 파싱 사이를 오가기 때문이다. 매크로가 반환한 TokenStream은 다시 파싱 단계를 거쳐 AST가 된다.

매크로 확장은 재귀적으로 수행된다. 매크로가 다른 매크로를 생성할 수 있으며, 더 이상 확장할 매크로가 없을 때까지 반복한다.

이름 해석 (Name Resolution)

AST의 모든 식별자가 어떤 정의를 가리키는지 결정한다. use 문, 모듈 경로, impl 블록 등을 분석하여 각 이름을 구체적인 정의에 바인딩한다. rustc_resolve 크레이트가 담당한다.

이 단계에서 “cannot find value x in this scope” 같은 에러가 발생한다.

HIR 변환 (HIR Lowering)

AST를 HIR(High-level Intermediate Representation)로 변환한다. HIR은 AST에서 문법적 편의(syntactic sugar)를 제거한 형태다.

  • for x in iter { ... }loop { match iter.next() { ... } }
  • if let Some(x) = opt { ... }match opt { Some(x) => { ... }, _ => {} }
  • async fn → 상태 머신 생성자

HIR은 컴파일러가 이후 분석을 수행하기에 더 단순한 구조를 가진다.

타입 검사 (Type Checking)

HIR 위에서 타입 추론과 타입 검사를 수행한다. Rust의 타입 시스템은 Hindley-Milner 기반에 trait 제약을 결합한 형태다.

  • 제네릭 타입 파라미터 추론
  • trait bound 충족 여부 확인
  • 소유권과 lifetime 검사 (borrow checker)
  • 패턴 매칭 exhaustiveness 검사

”mismatched types”, “lifetime may not live long enough”, “trait bound not satisfied” 등의 에러가 이 단계에서 나온다.

MIR 변환 (MIR Lowering)

HIR을 MIR(Mid-level Intermediate Representation)로 변환한다. MIR은 제어 흐름 그래프(CFG) 기반의 표현으로, 최적화와 검증에 적합한 구조다.

MIR에서 수행되는 주요 작업:

  • borrow check: 소유권 규칙의 최종 검증. NLL(Non-Lexical Lifetimes)이 MIR 위에서 동작한다.
  • 최적화: 인라이닝, 상수 전파(const propagation), dead code 제거 등
  • 모노모피제이션: 제네릭 함수를 구체적인 타입별로 복제. Vec<i32>Vec<String>이 별도의 코드가 되는 단계

LLVM IR 생성 (Code Generation)

MIR을 LLVM IR로 변환한다. rustc_codegen_llvm 크레이트가 담당한다. 여기서부터는 Rust 고유의 영역이 아니라 LLVM 백엔드가 처리한다.

LLVM이 수행하는 최적화:

  • 루프 최적화(unrolling, vectorization)
  • 레지스터 할당
  • 명령어 선택 및 스케줄링
  • LTO(Link-Time Optimization)

최종적으로 타겟 아키텍처에 맞는 오브젝트 파일(.o)을 생성하고, 링커가 이를 실행 바이너리나 라이브러리로 묶는다.

cargo build --release는 LLVM 최적화 레벨을 opt-level=3으로 올린다. 디버그 빌드에서는 이 단계를 최소화하여 컴파일 속도를 우선한다.

일반적으로 컴파일러는 크레이트 단위로 최적화한다. 크레이트 A에서 크레이트 B의 함수를 호출할 때, 컴파일러는 B의 내부 구현을 모르기 때문에 인라이닝이나 상수 전파 같은 최적화를 할 수 없다.

LTO는 이 경계를 없앤다. 링커가 모든 크레이트의 LLVM IR을 합친 뒤, 전체를 하나의 단위로 최적화한다. 크레이트 경계를 넘는 함수 인라이닝, dead code 제거, 상수 전파 등이 가능해진다.

Cargo.toml로 설정한다:

[profile.release]
lto = true # fat LTO — 전체 LLVM IR을 합쳐서 최적화
# lto = "thin" # thin LTO — 모듈 간 요약 정보만 공유, 빌드 속도와 최적화 사이 균형
# lto = false # 기본값 — LTO 비활성화
  • fat LTO: 최대 최적화. 모든 LLVM IR을 하나로 합쳐 최적화한다. 빌드 시간이 크게 늘어난다.
  • thin LTO: 모듈별 요약(summary)을 공유하여 크레이트 간 최적화를 수행하되, 전체를 합치진 않는다. fat LTO 대비 빌드 속도가 빠르면서 최적화 효과의 대부분을 얻는다.

바이너리 크기와 실행 속도가 중요한 릴리스 빌드에서 주로 사용한다.


참고