LLVM IR(Intermediate Representation)은 소스 코드에서 기계 코드로 변환되기 전에 거치는 중간 표현입니다. 코드 최적화와 분석의 중심이 되는 이 표현은 컴파일러가 어떻게 코드를 처리하는지 이해하는 데 중요한 도구입니다. 또한 이 변환과정을 살펴보다 보면, C++ 문법이 가지는 특성과 제약들이 왜 필요한지 더 깊이 이해할 수 있습니다.
이 글에서는 간단한 C++ 코드를 LLVM IR로 변환해 보고, 기본적인 함수 호출, 전역 변수, 클래스와 구조체의 변환을 살펴보면서 IR 의 작동 방식을 살펴봅니다.
Clang 은 최근의 Linux, macOS 시스템에서 기본적으로 제공되고 있습니다. 터미널에서 Clang 명령어가 정상적으로 실행되는지 확인하세요.
clang --version
만약 직접 빌드를 하고 싶다면, 이 링크를 참고하세요: Clang Getting Started
// 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;
}
clang -S -emit-llvm -o example1.ll example1.cpp
g_counter
는 IR에서 @g_counter
라는 심볼로 나타납니다.
@g_counter = global i32 0, align 4
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
}
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++의 특성 중 하나로, 이를 통해 다양한 타입의 데이터를 출력할 수 있습니다.
// 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;
}
clang -S -emit-llvm -o example2.ll example2.cpp
Point
는 IR에서 { i32, i32 }
형태로 정의됩니다.
%struct.Point = type { i32, i32 }
move
와 print
는 각각의 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
}
Point p{10, 20}
는 다음과 같이 구조체 초기화 코드로 변환됩니다.
@__const.main.p = private unnamed_addr constant %struct.Point { i32 10, i32 20 }, align 4
std::cout
와 구조체 사용
print()
함수 내부에서 std::cout
을 호출하는 흐름도 IR로 확인할 수 있습니다.다음 명령어를 사용해 최적화 옵션에 따른 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 생성간단한 함수 호출, 전역 변수, 구조체와 클래스 멤버 함수가 LLVM IR로 변환되는 과정을 살펴보았습니다. 직접 예제를 실행하고, IR을 분석해 보세요. 다음 글에서는 템플릿 함수, 람다, 예외 처리 등의 C++ 요소가 IR로 어떻게 변환되는지를 다룰 예정입니다.