LLVM을 이해하기 위한 나름의 정리

frogKing·2023년 1월 24일
0
post-thumbnail

배경

한 회사의 면접을 보면서 이런 질문을 받았다. “프로젝트가 컴파일되는 과정을 말씀해보세요”

내가 머뭇거리니까 면접관님이 말씀하시길, “그러면 어떤 컴파일러로 돌아가는지 아시나요?”

나는 이렇게 대답했다. “LLDB…?”

면접관 : …? 그건 Debugger이고..

이래서 LLVM이 정확히 무엇이고 어떤 역할을 하는지를 알아보고 싶었다. 나중에는 LLDB까지 무엇인지 확실히 알아서 최소한 다음에 이 질문을 받게 되면 대답은 제대로 할 수 있는 것이 내 목표이다.

컴파일러란 무엇인가

LLDB가 컴파일러라는 것은 어렴풋이 들어봤을 거다. 양보해서 컴파일러라는 말을 한번쯤 들어봤을 것이다. 우선 일반적인 C언어를 빌드할 때의 과정을 살펴보면…

(어휴 복잡하다)

큰 과정을 순서대로 나열하면 다음과 같다.

  1. 전처리
  2. 컴파일
  3. 어셈블리
  4. 링킹

간단하게만 각 과정을 설명해보면 다음과 같다.

빌드 과정

전처리(Pre-Processing)

우리가 코드를 짜면 주석도 있을 것이고 코드마다 공백, 줄바꿈, 헤더 파일 선언문 등이 있을텐데 전처리 단계에서는 기계에 조금 더 가깝게 기본 처리를 해주는 과정이라 보면 된다.

주석 제거, 헤더 파일 삽입, 매크로 치환 및 적용 등의 과정을 전처리기에서 수행을 하게 되고 소스 코드(.c)를 전처리된 소스 코드(.i)로 변환하게 된다.

컴파일(Compilation)

컴파일러를 통해 전처리된 소스 코드 파일(.i)을 어셈블리어 파일(.s)로 변환하는 과정이다.

이 과정에서 컴파일하면 생각나는 “언어의 문법 검사”가 이루어진다. (왜 있잖아 컴파일 누르면 컴파일 에러 뜨는..) 또한 Static한 영역들의 메모리 할당을 수행하게 된다.

어셈블리(Assembly)

어셈블러(Assembler)를 통해 어셈블리어 파일(.s)을 오브젝트 파일(.o)로 변환하는 과정이다.

링킹(Linking)

링커를 통해 오브젝트 파일(*.o)들을 묶어 실행 파일로 만드는 과정이다.

이 과정에서 오브젝트 파일들과 프로그램에서 사용하는 라이브러리 파일들을 링크하여 하나의 실행 파일을 만든다.

아무튼 이러한 방식으로 빌드가 이루어지게 된다. 이제 큰 그림을 대강 살펴봤으니 구체적으로 컴파일러의 구조를 살펴보도록 하자.

컴파일러의 구조

컴파일러의 내부는 크게 세가지의 컴포넌트로 나눌 수 있다. 프론트엔드, 미들엔드, 백엔드 각각이 어떤 역할을 하는지 살펴보자.

프론트엔드(Front-End)

소스 코드의 구문을 분석(어휘, 구문, 의미 분석)하여 오류를 확인하고 입력 코드를 나타내는 언어별 추상 구문 트리(AST)를 생성한다. 이 과정에서 C, C++, Java 같은 다양한 언어들이 각 언어에 맞게 처리된 후 공통된 중간 표현(IR)인 GIMPLE 트리로 변환되므로 언어 종속적인 부분을 처리할 수 있게 된다.

미들엔드(Middle-End)

Middle-End를 Optimizer라고 표현하는 경우도 있다. 이 곳에서는 코드의 실행 시간을 개선하기 위해 죽은 코드 제거, 중복 계산 제거, 루프 언롤링(Loop Unrolling) 등과 같이 다양한 변환 작업을 수행한다. 일반적으로 언어(C, C++, Java 등) 및 대상 아키텍쳐(x86, arm 등)에 다소 독립적이라고 볼 수 있다.

백엔드(Back-End)

백엔드는 코드 생성기라고도 하는데 코드를 명령어 집합에 매핑해서 기계어 코드를 생성하고 아키텍쳐의 특성에 맞는 최적화를 진행하기도 한다.

기존 컴파일러의 문제

  • 대부분의 컴파일러가 이렇게 같은 디자인을 따르고 있음에도 불구하고 실제로는 펄, 파이썬, 루비 컴파일러 사이에는 어떤 코드도 공유되고 있지 않다.
  • 자바와 .NET에서 사용하는 가상 머신도 이런 방식으로 설계되어 있지만 이러한 가상 머신 모델을 C/C++ 같은 네이티브 언어에는 적용할 수 없다.

⇒ 디자인이 같음에도 불구하고 언어가 다르면 서로 호환이 되지 않는다…!

물론 컴파일러 컴포넌트를 재사용하는 방법이 아주 없는 것은 아니다. 프론트엔드에서 무조건 C언어를 생성하면 미들엔드와 백엔드에서는 그냥 기존의 C 컴파일러를 사용하면 되긴 한다.

하지만 예외 처리를 구현하기도 어렵고 디버깅이 복잡하다. 그리고 사실상 두 번 컴파일 하니까 성능도 떨어지고.. 무엇보다 새로운 언어의 특정 기능이 C언어로 표현되지 않는다면 이런 방식은 사용할 수 없다.

네이티브 언어만 지원하긴 하지만 GCC도 이러한 컴파일 디자인을 잘 따르고 있다. C, C++, Fortran, Objective-C 등의 언어와 X86, ARM 등 다양한 아키텍쳐를 지원하면서 끊임없이 개선을 해왔다.

위에서 설명한 컴파일 디자인(프론트엔드, 미들엔드, 백엔드)로 단계를 나누게 되면 각자 관심에 맞게 원하는 부분에 기여할 수 있고 쉽게 새로운 언어와 아키텍쳐를 지원할 수 있다.

그러면 GCC를 잘 갖다 쓰면 되겠네.. 근데 왜 뭐가 문제야?

GCC의 문제

GCC 컴파일러는 단일 프로그램으로 구현되어 있어 다른 애플리케이션에서 GCC 전체 혹은 일부 모듈만 따로 가져와 사용하는 것이 불가능하다.

만약 GCC에서 C/C++ 파서만 뚝 떼어내서 라이브러리 형태로 사용할 수 있으면 정적 프로그램 분석, 코드 인덱싱, 리팩터링 툴은 쉽게 만들 수 있었을거라고 한다.

근데 뭐가 문제야? 뚝 떼어내면 되잖아!

일단 GCC는 전역변수를 많이 사용하고 데이터 구조가 부실하게 설계되어 있다.

코드 사이즈가 크고 컴파일을 줄이기 위해 내부적으로 매크로를 많이 사용하다 보니 일부 기능을 분리하거나 라이브러리 형태로 빌드할 수 없다.

게다가 워낙 오래된 프로젝트라 앞서 소개한 것 처럼 3단계로 딱 떨어지게 구현된게 아니었다.

또한 OpenGL 셰이더 언어, 정규 표현식 처리에도 컴파일 기술이 필요한데 이런 부분들은 기존 컴파일러들이 신경도 안 쓰고 있었다.

결국 하나의 컴파일 인프라를 잘 구축해 놓으면 모든 컴퓨터 언어에서 컴파일러 기능을 공유할 수 있었을거라 생각하고 크리스 래트너는 LLVM을 만들게 되었다.

LLVM

LLVM은 컴파일러의 프론트엔드와 백엔드를 개발하는데 필요한 기능을 제공하는 컴파일 인프라를 말한다.

프론트엔드에서 다양한 언어를 지원할 수 있고, 프론트엔드에서 처리된 결과는 언어에 독립적인 IR(Intermediate Representation)으로 나온다.

이를 옵티마이저에서 최적화하여 모든 프론트엔드가 같은 옵티마이저를 공유할 수 있게 된다.

또한 백엔드에서는 다양한 컴퓨터 아키텍쳐를 지원하여 기본적으로 프론트엔드만 구현하면 옵티마이저, 백엔드는 그대로 공유할 수 있다.

💡 IR(Intermediate Representation) 어셈블리어와 비슷한 저수준 프로그래밍 언어로 특정 아키텍쳐를 표현한 것이 아닌 CPU 명령어 세부 사항을 추상화하여 타입이 있는 RISC 명령어 셋으로 표현한 것을 말함.

이렇게 LLVM은 프론트엔드, 미들엔드, 백엔드가 서로 분리되어 있다 보니 위에서 제시했던 문제를 모두 해결할 수 있었다.

  1. LLVM으로 원하는 컴퓨터 언어에 대응하는 프론트엔드만 만들면 나머지는 이미 있는 LLVM에서 제공하는 기능으로 쉽게 컴파일러를 만들 수 있다.
  2. 컴파일러 최적화에 관심이 있으면 미들엔드(Optimizer)에서 여러가지 최적화 방법을 실행해볼 수 있다.
  3. 특정 CPU의 아키텍쳐에 최적화된 컴파일러를 지원하고 싶으면 백엔드에서 특정 아키텍쳐 만의 기능을 활성화하여 프로그램 실행 속도를 높일 수 있다.
  4. 모든 단계는 별도의 컴포넌트로 프로그램에 쉽게 임베딩할 수 있어 정적 분석에 활용될 수 있다.

Apple에서 LLVM 개발 시작

Apple에서는 기존에 GCC 프로젝트에 참여하고 있었는데 Objective-C가 GCC에서 차지하는 비중이 크지 않다보니 Objective-C를 개발하는 Apple 측의 코드를 반영하는데 시간이 많이 걸렸다. 또한 아이폰에서는 서명된 앱만 실행할 수 있는데 이는 앱 바이너리 수정을 막기 때문에 GPL3 라이센스와 충돌이 일어나게 되었고 결국 Apple 만의 독자적인 컴파일러가 필요했다.

결국 Apple은 LLVM 개발자인 크리스 래트너를 고용하고 llvm-gcc front 작업과 함께 PowerPC, X86 백엔드 최적화 작업을 진행하였다. 또한 OpenGL 셰이더 컴파일러에 LLVM을 적용하기도 하였다.

원래 Apple은 LLVM과 GCC를 통합하려 했지만 여러 사정으로 이를 포기하고 새로운 C/C++ 프론트엔드인 Clang 컴파일러를 만들어 2007년 릴리즈하게 된다.

현재

우리는 Xcode에서 Swift와 Objective-C가 혼재된 코드를 빌드하여 사용할 수 있다. 서로 다른 언어임에도 불구하고 빌드가 가능한 이유는 Xcode에서는 LLVM이 어셈블리어를 만들 목적코드를 생성하기 위해 Swift Compiler와 Clang이라는 프론트엔드를 갖고 있기 때문이다. 두 언어 모두 LLVM을 위한 중간 언어로 변환된 후에 실제 어셈블리어로 변환되기 때문에 기존의 Objective-C로 개발된 프로젝트도, Swift로 개발된 프로젝트도 모두 같은 LLVM에서 컴파일이 될 수 있다.

profile
내가 이걸 알고 있다고 말할 수 있을까

0개의 댓글