While working on the previous blog post, I went on a tangent and tried to solve it by looking a bit more deeply into LLVM architecture. While that tangent was not fruitful for that post, I thought I would write those findings in another!
LLVM is a collection of software tools that can parse, optimize, and link several programming languages into several targets.
The main programming languages that use LLVM are,
- C
- C++
- Rust
- Objective C
- Swift
However, you are free to write your frontend for LLVM for your own programming language. There is even a nice tutorial on how to do that.
The ’targets’ are specific to both operating systems and the underlying microprocessor architecture. While the actual targets it can support is massive, the important ones can be summarized as mac/Linux/Windows over x32/x64/ARM/WASM.
Architecture
While we know as a whole what a tool like clang
/gcc
/rustc
does, sometimes it is useful to understand what is going on underneath. The below is what I figured at a high level what is going on,
- Frontend : Responsible for parsing, tokenizing and generating an LLVM IR (Intermediate Representation). Generates
*.ll
file - Backend : Responsible for optimizing and emitting machine code. Generates
*.obj
file. - Linking : Combining multiple object files into a single executable/library that the operating system can understand.
Code Example
What good is a lot of theory if you can’t prove it with code? Let us look at how we can break these steps down in actual practice.
All code shared in this blog is available in this git repo.
First, let me share the simple C
program that we will be compiling.
In ./util.h
, we have
extern int add(int a, int b);
In ./util.c
we have below,
#include "util.h"
int add(int a, int b) {
return a + b;
}
And in ./main.c
, we have,
#include <stdio.h>
#include "util.h"
int main() {
printf("Hello, clang %d\n", add(12, 3));
return 0;
}
Setup
We will run this experiment on macOS with the arm64
target (M1 chip). In case you are interested, I have another repository where I did the same for the wasm32
target as well here.
We need llvm tool chain,
$ brew install llvm
Compile and run
To compile and run it in a combined single step,
$ clang -o main main.c util.c
$ ./main
Hello, clang 15
Now we will break this down into different stages and run independently.
Step 1: Frontend
To generate LLVM IR, let us run,
$ clang -S -emit-llvm util.c
$ clang -S -emit-llvm main.c
$ cat main.ll
; ModuleID = 'main.c'
source_filename = "main.c"
target datalayout = "e-m:o-i64:64-i128:128-n32:64-S128"
target triple = "arm64-apple-macosx13.0.0"
@.str = private unnamed_addr constant [17 x i8] c"Hello, clang %d\0A\00", align 1
; Function Attrs: noinline nounwind optnone ssp uwtable(sync)
define i32 @main() #0 {
%1 = alloca i32, align 4
store i32 0, ptr %1, align 4
%2 = call i32 @add(i32 noundef 12, i32 noundef 3)
%3 = call i32 (ptr, ...) @printf(ptr noundef @.str, i32 noundef %2)
ret i32 0
}
declare i32 @printf(ptr noundef, ...) #1
declare i32 @add(i32 noundef, i32 noundef) #1
attributes #0 = { noinline nounwind optnone ssp uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+crc,+crypto,+dotprod,+fp-armv8,+fp16fml,+fullfp16,+lse,+neon,+ras,+rcpc,+rdm,+sha2,+sha3,+sm4,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8.5a,+v8a,+zcm,+zcz" }
attributes #1 = { "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+crc,+crypto,+dotprod,+fp-armv8,+fp16fml,+fullfp16,+lse,+neon,+ras,+rcpc,+rdm,+sha2,+sha3,+sm4,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8.5a,+v8a,+zcm,+zcz" }
!llvm.module.flags = !{!0, !1, !2, !3}
!llvm.ident = !{!4}
!0 = !{i32 1, !"wchar_size", i32 4}
!1 = !{i32 8, !"PIC Level", i32 2}
!2 = !{i32 7, !"uwtable", i32 1}
!3 = !{i32 7, !"frame-pointer", i32 1}
!4 = !{!"Homebrew clang version 16.0.6"}
This generates both main.ll
and util.ll
independently.
Step 2: Backend
To generate object files, we do,
$ llc -filetype=obj util.ll
$ llc -filetype=obj main.ll
$ ls -alh
total 64
drwxr-xr-x@ 10 anoopelias staff 320B Aug 27 22:43 .
drwxr-xr-x@ 34 anoopelias staff 1.1K Aug 27 20:15 ..
-rw-r--r--@ 1 anoopelias staff 480B Aug 27 21:29 Makefile
-rw-r--r--@ 1 anoopelias staff 114B Aug 27 21:08 main.c
-rw-r--r--@ 1 anoopelias staff 1.5K Aug 27 22:40 main.ll
-rw-r--r--@ 1 anoopelias staff 784B Aug 27 22:43 main.o
-rw-r--r--@ 1 anoopelias staff 64B Aug 27 21:08 util.c
-rw-r--r--@ 1 anoopelias staff 31B Aug 27 21:08 util.h
-rw-r--r--@ 1 anoopelias staff 1.1K Aug 27 22:41 util.ll
-rw-r--r--@ 1 anoopelias staff 536B Aug 27 22:43 util.o
This creates main.o
and util.o
, again independent of each other.
Step 3: Linking
In this stage, the linker will combine the object files along with system library object files into a single executable. This is a slightly complex command since we need to provide that path for system files.
$ ld -syslibroot /Library/Developer/CommandLineTools/SDKs/MacOSX13.sdk \
-o main \
main.o util.o \
-lSystem /opt/homebrew/Cellar/llvm/16.0.6/lib/clang/16/lib/darwin/libclang_rt.osx.a
This generates the executable main
, which we can run,
$ ./main
Hello, clang 15
Phew! That was easy! 😀
Summary
So yeah, with such a flexible product like LLVM, you can break down a compilation command into its parts. Not only in theory but also in practice!
Credits: Excalidraw