C++ 코드의 LLVM IR 변환 (1)

클래스/구조체 등의 C++ 요소가 LLVM IR로 어떻게 변환될까

Posted by Jongmyeong on January 19, 2025 · 8 mins read

1. LLVM IR 소개

LLVM IR(Intermediate Representation)은 소스 코드에서 기계 코드로 변환되기 전에 거치는 중간 표현입니다. 코드 최적화와 분석의 중심이 되는 이 표현은 컴파일러가 어떻게 코드를 처리하는지 이해하는 데 중요한 도구입니다. 또한 이 변환과정을 살펴보다 보면, C++ 문법이 가지는 특성과 제약들이 왜 필요한지 더 깊이 이해할 수 있습니다.

이 글에서는 간단한 C++ 코드를 LLVM IR로 변환해 보고, 기본적인 함수 호출, 전역 변수, 클래스와 구조체의 변환을 살펴보면서 IR 의 작동 방식을 살펴봅니다.


2. 준비 사항

Clang 은 최근의 Linux, macOS 시스템에서 기본적으로 제공되고 있습니다. 터미널에서 Clang 명령어가 정상적으로 실행되는지 확인하세요.

clang --version

만약 직접 빌드를 하고 싶다면, 이 링크를 참고하세요: Clang Getting Started


3. 기본 함수 호출과 전역 변수

3.1 C++ 코드
// file: example1.cpp
#include <iostream>

int g_counter = 0;     // 전역 변수

int add(int a, int b) {
    ++g_counter;       // 전역 변수 접근
    return a + b;
}

int main() {
    std::cout << "Sum: " << add(3, 4) << "\n";
    std::cout << "Global counter: " << g_counter << "\n";
    return 0;
}
3.2 IR로 변환하기
clang -S -emit-llvm -o example1.ll example1.cpp
3.3 주요 IR 분석
  1. 전역 변수 표현
    전역 변수 g_counter는 IR에서 @g_counter라는 심볼로 나타납니다.
    @g_counter = global i32 0, align 4
    
  2. 함수 정의와 호출
    함수 add(int, int)define 키워드로 정의되며, 네임 맹글링에 의해 _Z3addii로 변환됩니다. 파라미터의 개수나 타입이 달라지면 다른 이름으로 변환되는데, 이것이 C++가 함수 오버로딩을 지원하는 방식입니다. 함수 내부에서는 전역 변수 @g_counter를 참조합니다.
    define i32 @_Z3addii(i32 noundef %0, i32 noundef %1) #0 {
      %3 = load i32, ptr @g_counter, align 4
      %4 = add nsw i32 %3, 1
      store i32 %4, ptr @g_counter, align 4
      %5 = add nsw i32 %0, %1
      ret i32 %5
    }
    
  3. std::cout와 IR 표현
    std::cout은 내부적으로 여러 번의 call 지시어로 변환됩니다.
    %2 = call noundef nonnull align 8 dereferenceable(8) ptr @_ZNSt3__1lsB8ue170006INS_11char_traitsIcEEEERNS_13basic_ostreamIcT_EES6_PKc(ptr noundef nonnull align 8 dereferenceable(8) @_ZNSt3__14coutE, ptr noundef @.str)
    %3 = call i32 @_Z3addii(i32 noundef 3, i32 noundef 4)
    %4 = call noundef nonnull align 8 dereferenceable(8) ptr @_ZNSt3__113basic_ostreamIcNS_11char_traitsIcEEElsEi(ptr noundef %2, i32 noundef %3)
    

    첫 번째 함수 호출은 std::cout<< 연산자를 호출하고, 두 번째 함수 호출은 add 함수의 결과를 출력합니다. 이렇게 여러 번의 함수 호출을 거쳐 std::cout을 사용하는 것은 C++의 특성 중 하나로, 이를 통해 다양한 타입의 데이터를 출력할 수 있습니다.


4. 클래스와 구조체 변환

4.1 C++ 코드
// file: example2.cpp
#include <iostream>

struct Point {
    int x;
    int y;

    void move(int dx, int dy) {
        x += dx;
        y += dy;
    }

    void print() const {
        std::cout << "(" << x << ", " << y << ")\n";
    }
};

int main() {
    Point p{10, 20};
    p.move(3, 4);
    p.print();

    return 0;
}
4.2 IR로 변환하기
clang -S -emit-llvm -o example2.ll example2.cpp
4.3 주요 IR 분석
  1. 구조체 멤버의 배치 구조체 Point는 IR에서 { i32, i32 } 형태로 정의됩니다.
    %struct.Point = type { i32, i32 }
    
  2. 멤버 함수 호출 구조체의 멤버 함수 moveprint는 각각의 IR로 변환되며, 객체의 포인터를 첫 번째 매개변수로 전달받습니다.
    define void @_ZN5Point4moveEii(ptr noundef nonnull align 4 %this, i32 noundef %0, i32 noundef %1) {
      %3 = getelementptr inbounds %struct.Point, ptr %this, i32 0, i32 0
      %4 = load i32, ptr %3, align 4
      %5 = add nsw i32 %4, %0
      store i32 %5, ptr %3, align 4
    }
    
  3. 구조체 객체 초기화 Point p{10, 20}는 다음과 같이 구조체 초기화 코드로 변환됩니다.
    @__const.main.p = private unnamed_addr constant %struct.Point { i32 10, i32 20 }, align 4
    
  4. std::cout와 구조체 사용 print() 함수 내부에서 std::cout을 호출하는 흐름도 IR로 확인할 수 있습니다.

5. 최적화 옵션 실험

다음 명령어를 사용해 최적화 옵션에 따른 IR의 변화를 확인해 보세요.

clang -S -emit-llvm -O0 -o example2_O0.ll example2.cpp
clang -S -emit-llvm -O2 -o example2_O2.ll example2.cpp
  • -O0: 원래 코드와 유사한 구조로 변환
  • -O2: 불필요한 부분을 제거하고 최적화된 IR 생성

6. 마무리

간단한 함수 호출, 전역 변수, 구조체와 클래스 멤버 함수가 LLVM IR로 변환되는 과정을 살펴보았습니다. 직접 예제를 실행하고, IR을 분석해 보세요. 다음 글에서는 템플릿 함수, 람다, 예외 처리 등의 C++ 요소가 IR로 어떻게 변환되는지를 다룰 예정입니다.