C言語をLLVM IR(LLVM Assembly)に変換して比べてみる
先日はLLVM IR Referenceから攻めようとしたがこれを読んだって書けるようになりそうになかったので,
その方針は諦めてC言語から実際にLLVM IRのコードを出力しながら触って覚えることにする.
どちらにせよそんなに複雑なコードは書く予定はないのでこれでよいだろう.
ここで書いている内容は個人的な解釈が多分に含まれており,おなおかつ勘違いもあると思うのであまり信用せず,
最後は自分で責任を持って調べて欲しい.
ありがちな間違いとして記事の中ではLLVM Assemblyをいい感じに意訳しているため,
例えば変数の番号の順番が抜けたり入れ替わったりしている場合がある.
こうなるとそのままではLLVM Assemblyをコンパイルすることができなくなってしまう.
でもこの記事ではそれを分かっていて分かりやすさを重要視しているためあしからず.
とりあえず上から順番に試したものを記述していく.
なのでif
はこう展開しないといけないとかいうのを言いたいわけではなく,
用意されている命令を好きに使って展開すればよい.
なので,基本的な書き方については別の記事にまとめようと思う.
事前情報として
LLVMってLLVM-なんとかというの名前がたくさんあるのでその整理を.
LLVMはコンパイラの役割の内,機種に依存したり最適化の部分を上手くやってくれる所に相当する.
そこで,中間言語を定義しておき一旦中間言語に落とせばそこから先はLLVMがいい感じにアセンブリにまで落としてくれる.(JITとかもできるらしい)
LLVM IRがその中間表現にあたる.
そしてLLVM IRをテキスト形式にしたものがLLVM Assemblyだ.
このテキスト表現は機種に非依存であり,アセンブリでありつつも汎用性を高く書ける.
そしてコンパイラを実装する時にどうやってLLVM Assemblyを出力させるかだが,
どうやら直接出力するコードを書く(例えばprintfとか使って)ではなく,
LLVMのバインディングを使う.
つまり構文木を付かって,後はそのライブラリを呼び出して上手くLLVM IRにしてやればAssemblyが出力できるわけだ.
とはいえ,今回の自作言語の用途の場合そんな依存関係はなくしたい.
LLVMのアップデートに追従できなくなるリスクを取ってでもゴリゴリとprintfで出力することにする.
そのためツールの使い方ではなく,自らAssemblyを書けるようにしていく.
とはいえ大型のプログラムはさすがにしんどいので,C言語の基本部分がLLVM Assemblyで書けるようになることを目指して学んでいく.
その方法はひたすらC言語で色々書いて,それをLLVM Assemblyに変換しその対応関係から学んでいく.
ここではソースコードの記述を除き,コマンド入力している行は先頭に$
を付けることとする.
まずは簡単なmain関数から
とりあえず一番シンプルな関数から出力してみる.
main.c
として以下の内容を記述したファイルを用意する.
int main(void){
return 0;
}
そして以下のコマンドでLLVM Assemblyに変換したファイルを作る.
$ clang -emit-llvm -O0 -S main.c
-emit-llvm
はハイフンが一つで正解だ.
すると,main.ll
が出力される.私の環境の場合その中身は次のようになっている.
; ModuleID = 'main.c'
source_filename = "main.c"
target datalayout = "省略"
target triple = "x86_64-pc-linux-gnu"
; Function Attrs: noinline nounwind optnone sspstrong uwtable
define dso_local i32 @main() #0 {
%1 = alloca i32, align 4
store i32 0, ptr %1, align 4
ret i32 0
}
attributes #0 = {省略}
!llvm.module.flags = !{!0, !1, !2, !3, !4}
!llvm.ident = !{!5}
!0 = !{i32 1, !"wchar_size", i32 4}
!1 = !{i32 8, !"PIC Level", i32 2}
!2 = !{i32 7, !"PIE Level", i32 2}
!3 = !{i32 7, !"uwtable", i32 2}
!4 = !{i32 7, !"frame-pointer", i32 2}
!5 = !{!"clang version 18.1.8"}
これがLLVM Assemblyである.
前の記事でも書いたが,;
はそこから行末までの文字をコメントとして扱う.
そして,いらなさそうなのを全部消すとこんな感じ.(根拠はなく多分そんな感じがするぐらいで)
define i32 @main(){
ret i32 0
}
LLVMはC言語っぽくもあり,アセンブリっぽくもあるということを聞いていたので,私なりに解釈すると次のようになる.
LLVM IR Referenceによるとdefine
というキーワードは関数を定義するのに使われるため,main関数を定義しているので間違いないだろう.
@main
と@
がついているのは,これは大域的変数(グローバル変数)を意味している.
またC言語で戻り値はint
を使っていたのでi32
というのはint
でかつ32bitの幅を持っているのではなかろうか.
ここで,最近のCPUは64bitマシンなのに何で32bitになっているんだろうと気になった.
そこでsizeof(int)
を調べてみると4であり,1byte=8bitと解釈すると32bitだった.
これはもうそういう仕様だと思うのがよさそうだ.恐らく通常はintは32bitで十分で必要な時に64bitにすればいいということだろう.
また,ret
というキーワードはreturn
に対応しており,数字の定義は型 数字
という並びになっているのであろう.
何となくの感じは掴めてきた.ret
行の書き方はまさにアセンブリっぽい反面,関数の定義はほぼC言語と言えるだろう.
これをアセンブリに変換するには以下のコマンドを使う.
$llc main.ll
するとmain.s
という本当のアセンブリが出力される.
私の環境ではmain.s
の中身は次のようになった.
.text
.file "main.ll"
.globl main # -- Begin function main
.p2align 4, 0x90
.type main,@function
main: # @main
.cfi_startproc
# %bb.0:
xorl %eax, %eax
retq
.Lfunc_end0:
.size main, .Lfunc_end0-main
.cfi_endproc
# -- End function
.section ".note.GNU-stack","",@progbits
これをさらにコンパイルして実行可能な形式にするには以下のコマンドを使う.
今回はclang
を使ったが別にgcc
を使ってもよい.
$ clang main.s
すると私の環境では,a.out
というファイルが出力される.
これを実行すると何も起こらないプログラムが実行される.
$ ./a.out
念のため,この出力結果を以下のコマンドで確認すると次のようになった.
$ ./a.out
$ echo $?
0
つまり終了ステータスは0だ.
少しだけ補足すると,私の使っているShellはBashで,直前に実行したプログラムの終了コードは$?
という変数に入る.
なのでその結果をecho
で確認するというのが上のコマンドの意味だ.
そして終了コードはmain.c
で言う所の,return 0;
に相当する.
なので,試しにreturn -1;
とかに変えて実行すると,終了コードも変化する.
main.c
から変更して試してもいいが,せっかくなのでmain.ll
から修正してみる.
終了コードを2
に修正してみる.
define i32 @main(){
ret i32 2
}
後は同じようにしてこのプログラムをコンパイルして実行してみると次のようになった.
$ llc main.ll
$ clang main.s
$ ./a.out
$ echo $?
2
この数字を色々変えて遊んでいて初めて知ったのだが,Bashの終了コード範囲は0-255らしく,負の値や256以上の値を入れても正しい数字として表示されず,0-255の範囲に丸められてしまっていた.
これはこういうものらしい.
ここで疑問に思ったのだが,main
関数はなぜ実行されるのだろうか.
define
というキーワドはあくまでmain
関数を定義するだけであって,それを実行するわけではない.
main
関数という名前の関数を定義すればそれは自動的に実行されるようになっているのだろうか.
というわけで,main.ll
の中身を次のように変更して実行してみた.
define i32 @non_main(){
ret i32 0
}
main
をnon_main
にしてみる.
すると次のようエラーが出力された.
$ llc main.ll
$ clang main.s
(.text+0x1b): undefined reference to `main'
clang: error: linker command failed with exit code 1 (use -v to see invocation)
どうやら単純にmain
という関数を実行しているようだ.
ちなみにmain.s
の中身を確認してみると次のようになっていた.
.text
.file "main.ll"
.globl non_main # -- Begin function non_main
.p2align 4, 0x90
.type non_main,@function
non_main: # @non_main
.cfi_startproc
# %bb.0:
xorl %eax, %eax
retq
.Lfunc_end0:
.size non_main, .Lfunc_end0-non_main
.cfi_endproc
# -- End function
.section ".note.GNU-stack","",@progbits
元々のアセンブリコードと見比べると分かるが,違いはラベルがmain:
かnon_main
だけだ.
つまり,main
という名前の関数を定義すれば,自動的にそれが実行されるということでよいのだろう.
これで簡単なmain関数から試してみるのはおしまい.
次に移る前に少しだけ.上記コマンドはこれから何回も実行するので,make
で自動化しておく.
まず次のようなMakefileを作る.面倒だから全て直書きだ.
all: a.out
main.ll: main.c
clang -emit-llvm -S -O0 main.c
main.s: main.ll
llc main.ll
a.out: main.s
clang main.s
後はmake
コマンドを実行するだけでよい.
main.llだけ更新した場合でもそこから先のmain.sとa.outのファイルだけ生成される.
引き数有りのmain関数
同じ様に引数有りのバージョンを試してみる.
int main(int argc, char *argv[]) {
return 0;
}
LLVM Assemblyはこうなった.
; ModuleID = 'main.c'
source_filename = "main.c"
target datalayout = "省略"
target triple = "x86_64-pc-linux-gnu"
; Function Attrs: noinline nounwind optnone sspstrong uwtable
define dso_local i32 @main(i32 noundef %0, ptr noundef %1) #0 {
%3 = alloca i32, align 4
%4 = alloca i32, align 4
%5 = alloca ptr, align 8
store i32 0, ptr %3, align 4
store i32 %0, ptr %4, align 4
store ptr %1, ptr %5, align 8
ret i32 0
}
attributes #0 = {省略}
!llvm.module.flags = !{!0, !1, !2, !3, !4}
!llvm.ident = !{!5}
!0 = !{i32 1, !"wchar_size", i32 4}
!1 = !{i32 8, !"PIC Level", i32 2}
!2 = !{i32 7, !"PIE Level", i32 2}
!3 = !{i32 7, !"uwtable", i32 2}
!4 = !{i32 7, !"frame-pointer", i32 2}
!5 = !{!"clang version 18.1.8"}
省略したらこんな感じ.
define i32 @main(i32 %0, ptr %1) {
ret i32 0
}
一応コンパイルは通ったし実行もできた.
また,これだけだと分かりにくいのでargc
を返す場合を考えてみる.
int main(int argc, char *argv[]) {
return argc;
}
これをそのままコンパイルしてmain関数の部分だけ取り出すとこんな感じになる.
define i32 @main(i32 %0, ptr %1) {
%3 = alloca i32, align 4
%4 = alloca i32, align 4
%5 = alloca ptr, align 8
store i32 0, ptr %3, align 4
store i32 %0, ptr %4, align 4
store ptr %1, ptr %5, align 8
%6 = load i32, ptr %4, align 4
ret i32 %6
}
絶対にこんなにいらない.
argcにあたる%0
の定義だけ追いかけていくとこんな感じになる.
define i32 @main(i32 %0, ptr %1) {
%4 = alloca i32, align 4
store i32 %0, ptr %4, align 4
%6 = load i32, ptr %4, align 4
ret i32 %6
}
すると%4
を定義してから%0
を%4
に代入し,さらに%4
をi32
で読み出した結果を%6
に入れ,それをretで返却している.
ゴチャゴチャしているが結局は%0を返却していることに変化はない.
とすると色々省略するとこんな感じだろう.
define i32 @main(i32 %0, ptr %1) {
ret i32 %0
}
これで実行してみると確かに合っている.
$ ./a.out aa bb
$ echo $?
3
ここから分かることはptr
という変数はvoid*
みたいなものでどんなものであっても受け取れるようになっているっぽい.
また変数であってもi32
のように解釈して返している.
さらにalloca
は変数を定義するのに配列のようにメモリを確保している.
これはfree
は必要なくて関数が終了する時に自動的に解放されるのだろう.
また,LLVMの変数を定義する時は=
を使ってよいが,変数に値を別の変数に代入する場合はstore
を使うっぽい.
これでなんとなくの使い方が分かってきた.
次は変数をどう書けばよいのかを見ていく.
変数の定義
まずは局所的変数から順番に.
そしてその他として,static
やextern
,const
について動作を見ていく.
数値の変数
とりあえず適当に定義してみる.
int main(void){
int a = 10;
return 0;
}
いる所はこんな感じ.
define i32 @main() {
%1 = alloca i32, align 4
%2 = alloca i32, align 4
store i32 0, ptr %1, align 4
store i32 10, ptr %2, align 4
ret i32 0
}
いつもこの謎の%1
が入るがこれは使っていないので変数int a
にあたるのは%2
であろう.
なので変数の定義だけ取り出すとこんな感じ.
%2 = alloca i32, align 4
store i32 10, ptr %2, align 4
これを見ると引数有りのmain
関数と同じでalloca
を使って変数を定義し,store
を使って値を代入している.
align
はアライメントのことでi32
で32bitだから4byteのアライメントにしているのだろう.
アライメントの話はここでは詳説はしないが,メモリの区切りの話になる.
例えば4byteのアライメントのアーキテクチャの場合4byte区切りでメモリが配置されている.
intを4byteの変数として,short intが2byte,charが1byteの環境を考える.
この時,intの変数だけ定義していれば問題ないが,short intの後にintを定義したとするとアライメント跨ぎが発生する可能性がある.,
short intアライメントの最初から2byteを使うが,その次のアライメントはshort intの後2byteだ.
ところがその次にintを定義すると,このintはアライメントを跨いで2byteと2byteの合計4byteを使うことになる.
アライメントを跨ぐ場合一発でレジストリに読み出せない場合があり,2回読み出して組み合わせるとかする必要がある.
賢いコンパイラであればいい感じにしてくれるが,こうした面倒なことが発生するのでアライメントはきっちりと揃えておいた方が変なことは発生しない.
例えば||がアライメントだとするとこんな感じ.
|| 1byte | 1byte | 1byte | 1byte || 1byte | 1byte | 1byte | 1byte ||
intを2個定義するとこうなる
|| int || int ||
short intを2個定義するとこうなる
|| short int | short int || 1byte | 1byte | 1byte | 1byte ||
short intとintをアライメント跨ぎが発生するように配置した場合
|| short int | int | 1byte | 1byte ||
アライメントを揃えた場合
|| short int | 1byte | 1byte || int ||
次に色々な変数を定義してみる.構造体は後に回すとして単純な数値から.
#include <stdint.h>
int main(void) {
int8_t a = -8;
int16_t b = -16;
int32_t c = -32;
int64_t d = -64;
uint8_t e = 8;
uint16_t f = 16;
uint32_t g = 32;
uint64_t h = 64;
float i = 16.0;
double j = 32.0;
long double k = 64.0;
return 0;
}
LLVM Assemblyは以下の通り.
define i32 @main() {
%2 = alloca i8, align 1
%3 = alloca i16, align 2
%4 = alloca i32, align 4
%5 = alloca i64, align 8
%6 = alloca i8, align 1
%7 = alloca i16, align 2
%8 = alloca i32, align 4
%9 = alloca i64, align 8
%10 = alloca float, align 4
%11 = alloca double, align 8
%12 = alloca x86_fp80, align 16
store i8 -8, ptr %2, align 1
store i16 -16, ptr %3, align 2
store i32 -32, ptr %4, align 4
store i64 -64, ptr %5, align 8
store i8 8, ptr %6, align 1
store i16 16, ptr %7, align 2
store i32 32, ptr %8, align 4
store i64 64, ptr %9, align 8
store float 1.600000e+01, ptr %10, align 4
store double 3.200000e+01, ptr %11, align 8
store x86_fp80 0xK40058000000000000000, ptr %12, align 16
ret i32 0
}
見れば分かる通りunsignedは存在せず全てintになっている.
そこで,こんないじわるをしてみたらどうなるのか試してみた.
#include <stdint.h>
int main(void) {
uint16_t f =UINT16_MAX;
return 0;
}
define i32 @main() {
%2 = alloca i16, align 2
store i16 -1, ptr %2, align 2
ret i32 0
}
C言語の規格上そうなっていたような気もするが,こういう表現もOKということか.
構造体の変数
次に構造体の変数を定義してみる.
まずはシンプルな1変数を持つ構造体から.
struct A {
int a;
};
int main(void) {
struct A a = {.a = 0};
return 0;
}
%struct.A = type { i32 }
define i32 @main() {
%2 = alloca %struct.A, align 4
call void @llvm.memset.p0.i64(ptr align 4 %2, i8 0, i64 4, i1 false)
ret i32 0
}
declare void @llvm.memset.p0.i64(ptr nocapture writeonly, i8, i64, i1 immarg) #1
これだけ見るとほとんどC言語の構造体と同じだとういことが分かる.
そして構造体のメモリの確保は通常の変数と同じalloca
を使っているが,
構造体の初期化にはllvm.memset
という関数を宣言して使っていた.
複数の要素を持つ場合は次の通り.
struct A {
int a;
short int b;
};
int main(void) {
struct A a = {0};
return 0;
}
%struct.A = type { i32, i16 }
define i32 @main() {
%2 = alloca %struct.A, align 4
call void @llvm.memset.p0.i64(ptr align 4 %2, i8 0, i64 8, i1 false)
ret i32 0
}
declare void @llvm.memset.p0.i64(ptr nocapture writeonly, i8, i64, i1 immarg) #1
基本は全く同じで,type
の中身が増えただけ.
さらに構造体の構造体はどのようになっているのかを見てみる.
struct A {
int a;
short int b;
};
struct B {
int c;
struct A d;
};
int main(void) {
struct B b = {0};
return 0;
}
%struct.B = type { i32, %struct.A }
%struct.A = type { i32, i16 }
define i32 @main() {
%2 = alloca %struct.B, align 4
call void @llvm.memset.p0.i64(ptr align 4 %2, i8 0, i64 12, i1 false)
ret i32 0
}
declare void @llvm.memset.p0.i64(ptr nocapture writeonly, i8, i64, i1 immarg) #1
これもC言語とそっくりで構造体の中に構造体をそのまま入れた構造になっている.
次に何か値を代入してみる.
struct A {
int a;
short int b;
};
int main(void) {
struct A a = {0};
a.a = 0;
a.b = 1;
return 0;
}
%struct.A = type { i32, i16 }
define i32 @main() {
%2 = alloca %struct.A, align 4
call void @llvm.memset.p0.i64(ptr align 4 %2, i8 0, i64 8, i1 false)
%3 = getelementptr inbounds %struct.A, ptr %2, i32 0, i32 0
store i32 0, ptr %3, align 4
%4 = getelementptr inbounds %struct.A, ptr %2, i32 0, i32 1
store i16 1, ptr %4, align 4
ret i32 0
}
declare void @llvm.memset.p0.i64(ptr nocapture writeonly, i8, i64, i1 immarg) #1
getelementptr
を使って構造体のメンバにアクセスしているように見える.
おそらくポインタの操作をしているのだろう.
予想ではi32のアライメントで要素を取得した後,store
でi16なのかi32なのかを切り替えている.
アライメントと合わせて結構ややこしいことが起こっていそうなのでこの解釈は後に回す.
もしかすると何番目の要素にアクセスするためにはアライメントをどうやってとか計算した上でアクセスが必要なのかもしれない.
こうなると配列のアクセスの仕方にも絡んでくるので一旦後に.
最後に構造体の構造体に代入してみる.
struct A {
int a;
short int b;
};
struct B {
int c;
struct A d;
};
int main(void) {
struct B b = {0};
b.c = 0;
b.d.a = 1;
b.d.b = 2;
return 0;
}
%struct.B = type { i32, %struct.A }
%struct.A = type { i32, i16 }
define i32 @main() {
%2 = alloca %struct.B, align 4
call void @llvm.memset.p0.i64(ptr align 4 %2, i8 0, i64 12, i1 false)
%3 = getelementptr inbounds %struct.B, ptr %2, i32 0, i32 0
store i32 0, ptr %3, align 4
%4 = getelementptr inbounds %struct.B, ptr %2, i32 0, i32 1
%5 = getelementptr inbounds %struct.A, ptr %4, i32 0, i32 0
store i32 1, ptr %5, align 4
%6 = getelementptr inbounds %struct.B, ptr %2, i32 0, i32 1
%7 = getelementptr inbounds %struct.A, ptr %6, i32 0, i32 1
store i16 2, ptr %7, align 4
ret i32 0
}
declare void @llvm.memset.p0.i64(ptr nocapture writeonly, i8, i64, i1 immarg) #1
さっきと一緒で全てgetelementptr
とstore
で操作していることが分かる.
これの解釈も後回し.
次にビットフィールドも見てみる.
struct A {
int a : 1;
int b : 3;
};
int main(void) {
struct A a = {0};
return 0;
}
%struct.A = type { i8, [3 x i8] }
@__const.main.a = private unnamed_addr constant %struct.A { i8 0, [3 x i8] undef }, align 4
define i32 @main() {
%2 = alloca %struct.A, align 4
call void @llvm.memcpy.p0.p0.i64(ptr align 4 %2, ptr align 4 @__const.main.a, i64 4, i1 false)
ret i32 0
}
i32
で定義したはずだが,一番小さいi8
に圧縮されている.
まあこれでも十分ことたりるのだが,i1
ぐらい使ってもよさげな気はする.
謎なのは[3 x i8]
の所で配列っぽいがこれで合っているのだろうか?
配列の定義からするとi8
が3つという解釈になりそうだが,i1
が3つなのでは?
共用体の変数
同じように共用体も試してみる.
union A {
int a;
short int b;
};
int main(void) {
union A a = {0};
return 0;
}
%union.A = type { i32 }
define i32 @main() {
%2 = alloca %union.A, align 4
call void @llvm.memset.p0.i64(ptr align 4 %2, i8 0, i64 4, i1 false)
ret i32 0
}
declare void @llvm.memset.p0.i64(ptr nocapture writeonly, i8, i64, i1 immarg) #1
びっくりするぐらい構造体と一緒だが,i32
だけ定義されている.
一番大きい値だけ使われるのかと思ってこんなことをしてみたらやはりそうだった.
union A {
short int b;
};
%union.A = type { i16 }
; Function Attrs: noinline nounwind optnone sspstrong uwtable
define dso_local i32 @main() #0 {
%1 = alloca i32, align 4
%2 = alloca %union.A, align 2
store i32 0, ptr %1, align 4
call void @llvm.memset.p0.i64(ptr align 2 %2, i8 0, i64 2, i1 false)
ret i32 0
}
最初に戻って代入の場合はどうなるのかを見てみる.
union A {
int a;
short int b;
};
int main(void) {
union A a = {0};
a.a = 0;
a.b = 1;
return 0;
}
%union.A = type { i32 }
define i32 @main() {
%2 = alloca %union.A, align 4
call void @llvm.memset.p0.i64(ptr align 4 %2, i8 0, i64 4, i1 false)
store i32 0, ptr %2, align 4
store i16 1, ptr %2, align 4
ret i32 0
}
これを見ると初期化は違うがそれ以外は直接store
しておりその引数が違うだけだ.
enumの変数
とりあえず書いてみたがただの変数で,特に何もなかった.
全部自分で展開しろとのことだろう.
enum E { A, B };
int main(void) {
enum E e = A;
return 0;
}
define i32 @main() {
%2 = alloca i32, align 4
store i32 0, ptr %2, align 4
ret i32 0
}
ポインタの変数
次にポインタを見てみる.
int main(void) {
int a = 0;
int *p = &a;
return 0;
}
define dso_local i32 @main() #0 {
%2 = alloca i32, align 4
%3 = alloca ptr, align 8
store i32 0, ptr %2, align 4
store ptr %2, ptr %3, align 8
ret i32 0
}
%2
がa
で,%3
がp
にあたる.
a
はそのままだが,p
への代入を見てみると,変数%2
に対してptr
でアクセスしている.
これだけで&a
にあたるのだろう.
そして,ptr %3
に代入している.
しかし,ここで疑問に思うのがptr %2
がa
のアドレスだとして,ptr %3
はなぜアドレスにならないのだろうか.
store
には何かルールがありそう.
後でLLVM IR Referenceを見て追記する.
配列の変数
int main(void) {
int a[10] = {0};
return 0;
}
define i32 @main() {
%2 = alloca [10 x i32], align 16
call void @llvm.memset.p0.i64(ptr align 16 %2, i8 0, i64 40, i1 false)
ret i32 0
}
declare void @llvm.memset.p0.i64(ptr nocapture writeonly, i8, i64, i1 immarg) #1
局所的変数の配列として定義した場合はalloca
を使い,その引数が増えるイメージだ.
memset
については後に回す.
まとめて比較するとこんな感じになる.
uint8_t a[10] = {0};
uint16_t b[10] = {0};
uint32_t c[10] = {0};
uint64_t d[10] = {0};
float e[10] = {0};
double f[10] = {0};
unsigned
はint
扱いと一緒だろうからこちらだけその通りか確かめた.
%2 = alloca [10 x i8], align 1
%3 = alloca [10 x i16], align 16
%4 = alloca [10 x i32], align 16
%5 = alloca [10 x i64], align 16
%6 = alloca [10 x float], align 16
%7 = alloca [10 x double], align 16
次に配列の要素にアクセスする方法を見ていく.
さっきの続きと見做してもらってよい.
uint8_t aa = a[1];
uint16_t bb = b[1];
uint32_t cc = c[1];
uint64_t dd = d[1];
float ee = e[1];
double ff = f[1];
%14 = getelementptr inbounds [10 x i8], ptr %2, i64 0, i64 1
%15 = load i8, ptr %14, align 1
store i8 %15, ptr %8, align 1
%16 = getelementptr inbounds [10 x i16], ptr %3, i64 0, i64 1
%17 = load i16, ptr %16, align 2
store i16 %17, ptr %9, align 2
%18 = getelementptr inbounds [10 x i32], ptr %4, i64 0, i64 1
%19 = load i32, ptr %18, align 4
store i32 %19, ptr %10, align 4
%20 = getelementptr inbounds [10 x i64], ptr %5, i64 0, i64 1
%21 = load i64, ptr %20, align 8
store i64 %21, ptr %11, align 8
%22 = getelementptr inbounds [10 x float], ptr %6, i64 0, i64 1
%23 = load float, ptr %22, align 4
store float %23, ptr %12, align 4
%24 = getelementptr inbounds [10 x double], ptr %7, i64 0, i64 1
%25 = load double, ptr %24, align 8
store double %25, ptr %13, align 8
構造体の時と同じくgetelemptr
が出てきた.
inbouds
は今回が固定のアドレスだから範囲を指定できるという意味だろう.
getelementptr
自体はあくまでポインタを返すだけだから,load
で読み出してstore
でセットしている.
ここで分からないのが,getelementptr
の最後の引数で,これが何番目の要素を取るのかに対応しているはず.
そうなると,ptr %2
などで渡された値の型が何かによってアドレスは変化するはずだ.
しかしそれっぽい型の指定はinbounds
ぐらいでそれ以外の引数が存在していない.
inbounds
の指定を読んでいい感じにしているということだろうか.
このあたりもLLVM IR Referenceを読み込む必要がありそうだ.
今度はさらに続きで値の書き込みを見てみる.
a[2] = 1;
b[2] = 1;
c[2] = 1;
d[2] = 1;
e[2] = 1;
f[2] = 1;
%26 = getelementptr inbounds [10 x i8], ptr %2, i64 0, i64 2
store i8 1, ptr %26, align 1
%27 = getelementptr inbounds [10 x i16], ptr %3, i64 0, i64 2
store i16 1, ptr %27, align 4
%28 = getelementptr inbounds [10 x i32], ptr %4, i64 0, i64 2
store i32 1, ptr %28, align 8
%29 = getelementptr inbounds [10 x i64], ptr %5, i64 0, i64 2
store i64 1, ptr %29, align 16
%30 = getelementptr inbounds [10 x float], ptr %6, i64 0, i64 2
store float 1.000000e+00, ptr %30, align 8
%31 = getelementptr inbounds [10 x double], ptr %7, i64 0, i64 2
store double 1.000000e+00, ptr %31, align 16
getelementptr
の使い方自体は全く一緒.
違いはstore
になっているぐらいだ.
関数ポインタ
ここで書きたい所だが関数の呼び出し方とかと比較しないといけないので,
関数の定義や呼び出し方を見てからもう一度振り替える.
最後の方に載せている.
演算子
演算子は数字を取る場合とVectorを取る場合があり,ここでは分かりやすさのために数字を取る場合を主に見ていく.
四則演算
次は四則演算をどう書けばよいのかを見ていく.
題材は次のmain.cだ.
int main(void) {
return 1+2;
}
と思ったのだが,-O0
指定をしているのに最適化されてret i32 3
になってしまっていた.
なので次を試す.
int main(void) {
int a = 2;
int b = 3;
int c = a + b;
return 0;
}
define i32 @main() {
%2 = alloca i32, align 4
%3 = alloca i32, align 4
%4 = alloca i32, align 4
store i32 2, ptr %2, align 4
store i32 3, ptr %3, align 4
%5 = load i32, ptr %2, align 4
%6 = load i32, ptr %3, align 4
%7 = add nsw i32 %5, %6
store i32 %7, ptr %4, align 4
ret i32 0
}
%2
がa
,%3
がb
,%4
がc
にあたる.
最初のalloca
は変数の確保で,その次のstore
はa=2, b=3
を表している.
その次の%5, %6
は一度別の変数に移し変えて代入している.
見た感じからするに,add
のような演算の前は自動的に新しい変数で受けるようになっているっぽい.
そして,今回のメインとなる演算がその次の%7
に代入している行だ.
%7 = add nsw i32 %5, %6
nsw
は何を意味しているのか?
LLVM IR Referenceを見てみると,
add [nuw] [nsw] <ty> <op1>, <op2>
のようになっておりnuw
とnsw
が設定できるようになっている.
nuw
はNo Unsigned Wrapの略で,nsw
はNo Signed Wrapの略のようだ.
そしてこの指定があるとオーバーフローが発生した場合の値は未定義になる.
最適化する場合にオーバーフローが起きないことを明示するためのものっぽい.
なので実装する時には外しても問題はないだろう.
その後は元々確保していた%4
にstore
して完了.
次に片方が変数で片方が値だった場合を見てみる.
int main(void) {
int a = 2;
int c = a + 3;
return 0;
}
define i32 @main() {
%2 = alloca i32, align 4
%3 = alloca i32, align 4
store i32 2, ptr %2, align 4
%4 = load i32, ptr %2, align 4
%5 = add nsw i32 %4, 3
store i32 %5, ptr %3, align 4
ret i32 0
}
%2
がa
に%3
がc
に対応している.
一番の差はadd
の所で,本来は変数が入っていた所に直接値が入っている.
%5 = add nsw i32 %4, 3
アセンブリの場合即値かどうかで命令が変わることがあるが,
LLVM Assemblyはそんなことはないようだ.
次にかけ算や割り算を見ていく.
int main(void) {
int a = 2;
int b = 3;
int c = a + b;
int d = a - b;
int e = a * b;
int f = a / b;
int g = a % b;
return 0;
}
%11 = add nsw i32 %9, %10
%14 = sub nsw i32 %12, %13
%17 = mul nsw i32 %15, %16
%20 = sdiv i32 %18, %19
%23 = srem i32 %21, %22
直接値が入っている場合も次の通り.
int main(void) {
int a = 2;
int c = a + 3;
int d = a - 3;
int e = a * 3;
int f = a / 3;
int g = a % 3;
return 0;
}
%9 = add nsw i32 %8, 3
%11 = sub nsw i32 %10, 3
%13 = mul nsw i32 %12, 3
%15 = sdiv i32 %14, 3
%17 = srem i32 %16, 3
まあ予想通り.
次にunsigned
だった場合を見てみると,
int main(void) {
int a = 2;
int b = 3;
int c = a + b;
int d = a - b;
int e = a * b;
int f = a / b;
int g = a % b;
return 0;
}
%11 = add i32 %9, %10
%14 = sub i32 %12, %13
%17 = mul i32 %15, %16
%20 = udiv i32 %18, %19
%23 = urem i32 %21, %22
nsw
がつくつかないの判断はよく分からないがこんなものなのだろう.
(unsigned
なので2-3
はオーバーフローが発生するから分かるが,なぜadd
でもnsw
が消えるのか)
sdiv
とsrem
の最初のs
はsignedで,udiv
とurem
の最初のuはunsignedの意味だろう.
uint64_t
の場合はi32
がi64
に変化するだけ.ただし,mul
とadd
のnsw
が消える.
int64_t
の場合はi32
がi64
に変化するだけ.ただし,mul
とadd
のnsw
が復活.
float
の場合は
%10 = fadd float %8, %9
%13 = fsub float %11, %12
%16 = fmul float %14, %15
%19 = fdiv float %17, %18
double
の場合は
%10 = fadd double %8, %9
%13 = fsub double %11, %12
%16 = fmul double %14, %15
%19 = fdiv double %17, %18
インクリメントは次のようになった.
%4 = add i32 %3, 1
ここまで来れば後は大体予想が付くので次に移る.
ビット演算
まずは,AND,OR,NOT,XORを見ていく.
int main(void) {
unsigned int a = 0;
unsigned int b = 1;
unsigned int c = a & b;
unsigned int d = a | b;
unsigned int e = a ^ b;
unsigned int f = ~a;
return 0;
}
%10 = and i32 %8, %9
%13 = or i32 %11, %12
%16 = xor i32 %14, %15
%18 = xor i32 %17, -1
ここでNOTがxor <ty> <op1>, -1
で表現されているのがおやっと思ったポイントだ.
-1
なので全て1
が立ったものに対してXORを取ることになる.
考えてみれば,もし相手側が0
であれば1
になるし,1
であれば0
と確かにNOTになっている.
次にシフト演算を追加してみる.
unsigned int g = a >> b;
unsigned int h = a << b;
%23 = lshr i32 %21, %22
%26 = shl i32 %24, %25
あんまり対称的な名前ではないのが気になる.
shl
はshift leftでいいだろう.
lshr
はlogical shift rightのことらしい.
となるとarithmaticがあるはずなので,試しにint
にしてみる.
int main(void) {
int a = -1;
int b = -2;
int g = a >> b;
int h = a << b;
return 0;
}
%8 = ashr i32 %6, %7
%11 = shl i32 %9, %10
左方向にはいくらシフトしようが符号が問題になることはないが,
右方向にシフトする場合は型によって符号が問題になることがある.
だから予想通りashr
というarithmatic shift rightになっていた.
比較演算
次は比較演算子を見てみる.
int main(void) {
int a = 1;
int b = 2;
int c = a < b;
int d = a > b;
int e = a <= b;
int f = a >= b;
return 0;
}
%10 = icmp slt i32 %8, %9
%11 = zext i1 %10 to i32
%14 = icmp sgt i32 %12, %13
%15 = zext i1 %14 to i32
%18 = icmp sle i32 %16, %17
%19 = zext i1 %18 to i32
%22 = icmp sge i32 %20, %21
%23 = zext i1 %22 to i32
ここではicmp
とzext
で比較演算を実現している.
icmp
はその名の通りinteger comparisonではなかろうか.
文法は次のようになっている.
icmp <cond> <ty> <op1>, <op2>
<cond>
には色々あって以下の通りだ.
- eq: equal
- ne: not equal
- ugt: unsigned greater than
- uge: unsigned greater or equal
- ult: unsigned less than
- ule: unsigned less or equal
- sgt: signed greater than
- sge: signed greater or equal
- slt: signed less than
- sle: signed less or equal
戻り値はi1
になる.
zext
は0拡張と言うもので,i1
をi32
に拡張していると読める.
そしてi1
なので1bitしかないが,i32
は32bitあるので,その差分の31bitを0で拡張しているのだろう.
次にdouble
の場合を見ていく.
%10 = fcmp olt double %8, %9
%11 = zext i1 %10 to i32
%12 = sitofp i32 %11 to double
%15 = fcmp ogt double %13, %14
%16 = zext i1 %15 to i32
%17 = sitofp i32 %16 to double
%20 = fcmp ole double %18, %19
%21 = zext i1 %20 to i32
%22 = sitofp i32 %21 to double
%25 = fcmp oge double %23, %24
%26 = zext i1 %25 to i32
%27 = sitofp i32 %26 to double
icomp
がfcomp
に変化した.
またfcomp
はicomp
とおおよそ同じだが<cond>
の条件が違う.
LLVM IR Referenceによると以下だ.
- false: no comparison, always returns false
- oeq: ordered and equal
- ogt: ordered and greater than
- oge: ordered and greater than or equal
- olt: ordered and less than
- ole: ordered and less than or equal
- one: ordered and not equal
- ord: ordered (no nans)
- ueq: unordered or equal
- ugt: unordered or greater than
- uge: unordered or greater than or equal
- ult: unordered or less than
- ule: unordered or less than or equal
- une: unordered or not equal
- uno: unordered (either nans)
- true: no comparison, always returns true
ここでのorderedとは引数の値がQNANではないこと,unorderedの意味はoperandがQNANを取り得ることを意味している.
まあとりあえずunorderedにしておけば何とかはなりそう.
また,最後のsitofp
についてはこちらも型の変換にあたる.
文法は以下だ.
siteofp <ty> <value> to <ty2>
見ての通り,i32
の値をdouble
に変換している.
論理演算
次は論理演算のAND,OR,NOTを見ていく.
#include <stdbool.h>
int main(void) {
bool a = true;
bool b = false;
bool c = a && b;
bool e = a || b;
bool d = !a;
return 0;
}
これはちょっと色々な命令が出てきてしまった.
ざっくりとしたイメージだが,ANDとORの命令を条件分岐で上手く実現している.
%8 = trunc i8 %7 to i1
br i1 %8, label %9, label %12
9:
%11 = trunc i8 %10 to i1
br label %12
12:
%13 = phi i1 [ false, %0 ], [ %11, %9 ]
%14 = zext i1 %13 to i8
%16 = trunc i8 %15 to i1
br i1 %16, label %20, label %17
17:
%19 = trunc i8 %18 to i1
br label %20
20:
%21 = phi i1 [ true, %12 ], [ %19, %17 ]
%22 = zext i1 %21 to i8
%24 = trunc i8 %23 to i1
%25 = xor i1 %24, true
%26 = zext i1 %25 to i8
trunc
はinteger型の数値を型変換する命令だ.
trunc <ty> <value> to <ty2>
br
は制御構文でありちょっと先取りしてしまうが,ifとgotoを一緒にしたような命令だ.
br i1 <cond>, label <iftrue>, label <iffalse>
br label <dest>
それ以外のtrunc
そしてzext
は全てi1
やi8
への変換をしているだけ.
次にphi
について説明する.
phi <ty> [ <val0>, <label0>], ...
これは静的単一代入(SSA)におけるφ関数に相当する.
何のこっちゃなので調べてみると,どうやらどのブロックからやってきたのかで値を切り替えるもののようだ.
具体的には下で見ていく.
またこのphi
は基本ブロックの先頭にある必要がある.
基本ブロックの説明は後で.
&&
にあたる命令は,
%13 = phi i1 [ false, %0 ], [ %11, %9 ]
そもそもbool a
は巡って%8
になり,%9
のラベルへと導かれている.
そしてbool b
は巡って%11
になっている.
したがって,phi
に到達する前のブロックが%0
からやってきた場合はfalse
を,
%9
からやってきた場合は%11
を返す.
先程の流れからもある通り,このブロックへは%9
を通ってやって来るので,
%11
が選択されることになる.
したがって,もっと単純化すると次のようなイメージ.
0:
if (<a>) { goto %9; } else { goto %12 };
9:
goto %12;
12:
ret = if (<from %0>) { false; } if (<from %9>) { <b>; };
もし<a>
がtrue
であれば%9
,%12
と経由し<b>
が評価され,
<a>
がfalse
であれば%12
に直接入ってfalse
となる.
言い替えれば<a>
がfalse
であればfalse
に,
<a>
がtrue
であれば<b>
の値が評価されることになる.
これは確かにANDになっている.
次は||
を見てみる.
%21 = phi i1 [ true, %12 ], [ %19, %17 ]
さっきとの差分はphi
の最初の判定の値がfalse
ではなくtrue
になっていることだ.
同じように書き直すと次のようになる.
12:
if (<a>) { goto %20; } else { goto %17 };
17:
goto %20;
20:
ret = if (<from %12>) { true; } if (<from %17>) { <b>; };
<a>
がtrue
であればtrue
を,<a>
がfalse
であれば<b>
の値を返す.
ちょうどANDと逆のようなロジックでこれも確かにORになっている.
最後に!
にあたる命令は
%25 = xor i1 %24, true
これ自体はビット演算のNOTと考え方と同じなので省略する.
制御構文
どうやって,条件によって実行される命令が変化させるのかを見ていく.
条件分岐(if)
まずは簡単なif
から.
#include <stdbool.h>
int main(void) {
bool a = true;
bool b = false;
if (a) {
b = true;
} else {
b = false;
}
return 0;
}
br i1 %5, label %6, label %7
6:
store i8 1, ptr %3, align 1
br label %8
7:
store i8 0, ptr %3, align 1
br label %8
8:
%5
がa
,%3
がb
にあたる.
phi
が出てくるのかと思ったら,もっとシンプルにbr
だけで実現していた.
書いてあるままなので詳細は省略.
条件分岐(三項演算子)
厳密には制御構文ではなく名前の通り演算子の所に入れるべきかもしれないが一旦はここに配置しておく.
#include <stdbool.h>
int main(void) {
bool a = true;
int b = a ? 1 : 2;
return 0;
}
ここはちょっと面白いことをしていたので処理の少し前から載せる.
%2 = alloca i8, align 1
%3 = alloca i32, align 4
store i8 1, ptr %2, align 1
%4 = load i8, ptr %2, align 1
%5 = trunc i8 %4 to i1
%6 = zext i1 %5 to i64
%7 = select i1 %5, i32 1, i32 2
store i32 %7, ptr %3, align 4
まずa
はbool
だからi8
で確保され1
が代入される.
そして,i8
からi1
に変換した後(trunc
),i64
に変換している(zext
).
にもかかわらず,結局使っているのは%5
でtrunc
した後の値で,zext
は使っていない.
結局は下記の通りにselect
というそのままの名前のものを呼び出している.
%7 = select i1 %5, i32 1, i32 2
条件分岐(switch)
switch
はどうなるのかを見てみる.
int main(void) {
int a = 10;
switch (a) {
case 1:
a = 1;
break;
case 2:
a = 2;
break;
default:
a = 10;
break;
}
return 0;
}
define i32 @main() {
%2 = alloca i32, align 4
store i32 10, ptr %2, align 4
%3 = load i32, ptr %2, align 4
switch i32 %3, label %6 [
i32 1, label %4
i32 2, label %5
]
4: ; preds = %0
store i32 1, ptr %2, align 4
br label %7
5: ; preds = %0
store i32 2, ptr %2, align 4
br label %7
6: ; preds = %0
store i32 10, ptr %2, align 4
br label %7
7: ; preds = %6, %5, %4
ret i32 0
}
そのまんまの命令が出てきて驚いた.
%6
がdefault
なんだろうぐらいはあるが,それ以外はそのまんまなので詳しい解説はしない.
ジャンプ
次はgoto
がどうなるかを見ていく.
int main(void) {
int a = 0;
goto LABEL;
a = 1;
LABEL:
a = 2;
return 0;
}
%2 = alloca i32, align 4
store i32 0, ptr %2, align 4
br label %3
3:
store i32 2, ptr %2, align 4
見ての通り単純にbr
が呼ばれているだけだった.
そういえばbr
は何の略なのだろうか.
ループ
まずは単純なwhile
から.
#include <stdbool.h>
int main(void) {
int c = 0;
while (true) {
c++;
if (c > 10) {
break;
}
}
return 0;
}
define i32 @main() {
%2 = alloca i32, align 4
store i32 0, ptr %2, align 4
br label %3
3:
%4 = load i32, ptr %2, align 4
%5 = add nsw i32 %4, 1
store i32 %5, ptr %2, align 4
%6 = load i32, ptr %2, align 4
%7 = icmp sgt i32 %6, 10
br i1 %7, label %8, label %9
8:
br label %10
9:
br label %3
10:
ret i32 0
}
単純なbr
の組み合わせで,goto
を使ってwhile
を書き直したらこんな感じかという印象だが,
不思議に思ったのがこの部分だ.
br i1 %7, label %8, label %9
<中略>
9:
br label %3
ここはこんな書き方しなくても
br i1 %7, label %8, label %3
これで終わるはずなのだが,わざわざ9:
を挟んでいる.何故なのだろうか?
さっきあったif
の展開の仕方と組み合わせるとこんな風になるのだろうが,
とするとwhile
の本質はどこになるのだろうか.
試しに一番単純な無限ループを変換してみる.
#include <stdbool.h>
int main(void) {
while (true)
;
return 0;
}
define i32 @main() {
br label %2
2:
br label %2
}
ここから類推すると8:
と9:
はif
によって発生し,
9:
の基本ブロックの中のbr label %3
がwhile
によって発生したと考えるのが妥当だろう.
次にfor
を見てみる.
int main(void) {
for (int i = 0; i < 10; i++)
;
return 0;
}
define i32 @main() {
%2 = alloca i32, align 4
store i32 0, ptr %2, align 4
br label %3
3:
%4 = load i32, ptr %2, align 4
%5 = icmp slt i32 %4, 10
br i1 %5, label %6, label %10
6:
br label %7
7:
%8 = load i32, ptr %2, align 4
%9 = add nsw i32 %8, 1
store i32 %9, ptr %2, align 4
br label %3, !llvm.loop !6
10:
ret i32 0
}
解釈としてはfor
が始まった時点でbr
でジャンプする.(br label %3
)
そして3:
のブロックではif
と同じ扱いでi<10
を処理している.
そのif
の行き先が6:
と10:
になっている.
6:
はそのまま7:
にいってfor
の中身の処理をしている.
10:
はループの終了だ.
最後にdo while
を見ている.
int main(void) {
int c = 0;
do {
c++;
} while (c < 10);
return 0;
}
define i32 @main() {
%2 = alloca i32, align 4
store i32 0, ptr %2, align 4
br label %3
3:
%4 = load i32, ptr %2, align 4
%5 = add nsw i32 %4, 1
store i32 %5, ptr %2, align 4
br label %6
6:
%7 = load i32, ptr %2, align 4
%8 = icmp slt i32 %7, 10
br i1 %8, label %3, label %9, !llvm.loop !6
9:
ret i32 0
}
さっきのfor
との違いを見てみると,足し算とif
の基本ブロックの順番が逆になっている.
さらにwhile
やfor
と比べてもブロックの数が1つ少ないという特徴がある.
while
やfor
の場合はif
文とgoto
それぞれ分かれて作られていたのが,do while
だと一体化しているようなイメージ.
型のキャスト
次にキャストについて扱う.すでに何度かでてきたのでそれで全部かを確認してみる.
#include <stdint.h>
int main(void) {
int8_t a = 10;
int16_t b = a;
int32_t c = a;
int64_t d = a;
float e = a;
double f = a;
a = d;
b = d;
c = d;
e = d;
f = d;
a = f;
b = f;
c = f;
d = f;
e = f;
f = e;
return 0;
}
%9 = zext i8 %8 to i16
%11 = zext i8 %10 to i32
%13 = zext i8 %12 to i64
%15 = sitofp i8 %14 to float
%17 = sitofp i8 %16 to double
%19 = trunc i64 %18 to i8
%21 = trunc i64 %20 to i16
%23 = trunc i64 %22 to i32
%25 = sitofp i64 %24 to float
%27 = sitofp i64 %26 to double
%29 = fptosi double %28 to i8
%31 = fptosi double %30 to i16
%33 = fptosi double %32 to i32
%35 = fptosi double %34 to i64
%37 = fptrunc double %36 to float
%39 = fpext float %38 to double
整数同士
小さい型から大きい型へ.
zext <ty> <op1> to <ty2>
大きい型から小さい型へ
trunc <ty> <op1> to <ty2>
整数と浮動小数点数
整数から浮動小数点数であれば.
sitofp <ty> <op1> to <ty2>
uitofp <ty> <op1> to <ty2>
浮動小数点数から整数であれば
fptosi <ty> <op1> to <ty2>
fptoui <ty> <op1> to <ty2>
浮動小数点数同士
小さい型から大きい型へ.
fpext <ty> <op1> to <ty2>
大きい型から小さい型へ
fptrunc <ty> <op1> to <ty2>
関数の定義と呼び出し・宣言
いよいよ終わりに近付いてきた.
まずは関数の定義と呼び出しと定義の方法から.
int add(int a, int b) {
return a + b;
}
int main(void) {
return add(1, 2);
}
適当に省略するとこんな感じ.
define i32 @add(i32 %0, i32 %1) {
%7 = add nsw i32 %0, %1
ret i32 %7
}
define i32 @main() {
%2 = call i32 @add(i32 1, i32 2)
ret i32 %2
}
定義の方法はmain
関数と全く同じでよさそう.
そして呼び出しの場合には戻り値の型と@
を付けた関数名,そして引数が続く.
次は簡単な再帰関数を見てみる.
int fact(int n) {
if (n == 0) {
return 0;
} else {
return n * fact(n - 1);
}
}
int main(void) {
return fact(5);
}
いい感じに省略するとこんな感じになる.
define i32 @fact(i32 noundef %0) {
%2 = alloca i32, align 4
%5 = icmp eq i32 %0, 0
br i1 %5, label %6, label %7
6:
store i32 1, ptr %2, align 4
br label %13
7:
%10 = sub nsw i32 %0, 1
%11 = call i32 @fact(i32 noundef %10)
%12 = mul nsw i32 %0, %11
store i32 %12, ptr %2, align 4
br label %13
13:
%14 = load i32, ptr %2, align 4
ret i32 %14
}
define i32 @main() {
%2 = call i32 @fact(i32 noundef 5)
ret i32 %2
}
再帰関数はそのまま書けるようだ.
関数ポインタ
まずはシンプルな関数ポインタの変数を宣言して代入し,使ってみる.
int increment(int a) {
return a++;
}
int main(void) {
int (*func)(int) = increment;
return 0;
}
define i32 @increment(i32 noundef %0) {
%2 = alloca i32, align 4
store i32 %0, ptr %2, align 4
%3 = load i32, ptr %2, align 4
%4 = add nsw i32 %3, 1
store i32 %4, ptr %2, align 4
ret i32 %3
}
define dso_local i32 @main() #0 {
%2 = alloca ptr, align 8
store ptr @increment, ptr %2, align 8
%3 = load ptr, ptr %2, align 8
%4 = call i32 %3(i32 noundef 1)
ret i32 0
}
見る限りは普通に代入しているだけに見える.
また関数呼び出しは通常の呼び出しと全く一緒に見える.
得に意識する必要がないということだろうか.
おまけ(何もしないmain関数の説明)
LLVM Assemblyは関数の定義の仕方がC言語に非常に似ている.
例えば何もしないプログラムはC言語で次のように書ける.
int main(void){
return 0;
}
これをLLVM Assemblyに変換すると次の通り.
あまりに長い行があったので適当に改行している.(文法的に正しいのかは不明)
また,行頭には(行番号:)を追記している.
1: ; ModuleID = 'main.c'
2: source_filename = "main.c"
3: target datalayout =
4: "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-f80:128-n8:16:32:64-S128"
5: target triple = "x86_64-pc-linux-gnu"
6:
7: ; Function Attrs: noinline nounwind optnone sspstrong uwtable
8: define dso_local i32 @main() #0 {
9: %1 = alloca i32, align 4
10: store i32 0, ptr %1, align 4
11: ret i32 0
12: }
13:
14: attributes #0 = { noinline nounwind optnone sspstrong uwtable "
15: frame-pointer"="all" "min-legal-vector-width"="0"
16: "no-trapping-math"="true" "stack-protector-buffer-size"="8"
17: "target-cpu"="x86-64"
18: "target-features"="+cmov,+cx8,+fxsr,+mmx,+sse,+sse2,+x87"
19: "tune-cpu"="generic" }
20:
21: !llvm.module.flags = !{!0, !1, !2, !3, !4}
22: !llvm.ident = !{!5}
23:
24: !0 = !{i32 1, !"wchar_size", i32 4}
25: !1 = !{i32 8, !"PIC Level", i32 2}
26: !2 = !{i32 7, !"PIE Level", i32 2}
27: !3 = !{i32 7, !"uwtable", i32 2}
28: !4 = !{i32 7, !"frame-pointer", i32 2}
29: !5 = !{!"clang version 18.1.8"}
上から順番に説明していく.
1行目はコメントだ.
LLVM Assemblyではコメントは;
から始まり行末までだ.
なので命令の後ろにコメントがついてもよい.
例えばこんな感じに.
ret i32 0 ;This line means return 0;
ここでは;This line means return 0;
がコメントになる.
2行目から5行目まではLLVM IR Referenceには記載されていない.
ただ,名前を見る限りはsource_filename
はそのままソースコードのファイル名.
target datalayout
はデータレイアウトに関する何かしらの情報.
target triple
は使っているCPUとOSなどの環境を示していると思われる.
このあたりはReferenceではなくソースコードを見た方がよさそう.
ちなみにこの指定はなかったとしてもコンパイルして動作させることはできた.
次の8-12行目までがmain関数にあたる.
8行目のdefine
から始まる所はmain関数を定義する所だ.
見ての通りほとんどC言語そのままだ.
define
は関数を定義するための命令だ.
実際には様々なオプションを付けられるようになっている.
dso_local
はランタイムプリエンプション指定子の一つで,
同じリンク単位の中でシンボル解決をする.
そして,同じコンパイル単位時に定義されていなかったとしても直接アクセスできるようになる.
イメージとしては,リンクをする時に同名の関数呼出しがあればそれを解決する.
main関数は重複すると困るのでこの設定になっているのであろう.
ちなみにもう一つの指定子はdso_preemptable
で,リンクしても同名であれば上書きされる可能性がある.
その次のi32
は戻り値の型を示しており,main関数の戻り値がint
であることを示している.(64bitでもコンパイルするとint
が32bitになりうる.)
@main
はその名前の通りmainという名前の関数を定義している.
mainの前の@
は変数名の定義から来ており@
は大域的変数(グローバル変数)であることを意味する.また後で触れるが局所的変数(ローカル変数)は%
がつく.
ただし,Referenceによるとdefine
で関数を定義する時は関数名の先頭は@
でないといけないため,関数には%
から始まる名前を付けることはできないようだ.
もっと厳密に言えば関数名は@main
ではなくmain
で@
は修飾だ.
そしてその次の()
は引数を示しており,今回はvoid
で定義したため中には何も入っていない.
ちなみに,int main(int argc, char *argv[])
の場合は次のようになる.
define dso_local i32 @main(i32 noundef %0, ptr noundef %1) #0 {
引数は変数を順番に定義していっている.
引数における変数の定義は以下のようになっている.(通常の変数の定義とは違うので注意)
型 オプション 変数名
ここでのオプションにおけるnoundef
は,未定義な値が入っていた場合に動作が未定義になることを意味している.
LLVM Assemblyではポインタ型は全て同じptr
という型で表現される.
そして実際に使う段になって初めて型を指定して読み出す.
ちょっと脱線してしまったが,引数リストの次の#0
の意味について説明する.
これは属性グループの名前になっている.
14行目にattributes #0 = {...}
とあるが,これが属性を定義しており,
毎回同じ属性を関数を定義する度に書くのは大変なので名前を付けて管理している.
名前の付け方のルールは特に載っていなさそうだったが,
どうやら連番になることが多そうだ.
9,10行目は%1
という変数を定義し0を代入しているが,
実際にはこの関数は何もしない.定義されているが,
そして11行目はreturn 0;
にあたる.
14-19行目は先程の説明通り属性グループの定義だ.
21行目から下はモジュールフラグのメタデータになっており,Key Valueのペアになっている.
!llvm.module.flags
はどんなフラグがあるのかを定義しており,24-28行目でその内容を定義している.
24-28行目の定義は3つ組になっており,最初の要素は動作フラグで,2つ以上のモジュールが結合された場合の挙動を定義している.
2つ目の要素は文字列でメタデータのユニークなIDになっている.
そして,それぞれのモジュールはユニークなIDに対して1つのフラグエントリを持つことができる.(Require動作は含まない)
そして,3つ目の値はフラグの値.
動作フラグは次のようになる.
1の場合はエラー,8の場合はMax,7の場合はMinを取る.
22行目の!llvm.ident
は29行目の!5 = ...
に対応している.
これもメタデータの一種だがあまり説明がなかった.
まあ,名前からするにclang
の情報を載せているだけかと思われる.
まとめ
ここまでで一通りの機能を見てきた.
ただし,include
とかそういうプリプロセッサは見ていないが,普通にアセンブリを書く分には問題なさそうだったので,
必要になってから追記することにする.
型の扱いは非常にシンプルになっていて,
特にメモリの確保がほぼ共通化されていることに驚いた.
アセンブリではそこまでやらないだろうとは思っていたがその通り.
また,関数呼び出しそのものがサポートされているので,gotoだけで関数を作る必要がない所も便利だ.
ただ,ここまでの機能を全て構文木から構成できるようにするのは骨が折れるので,
gotoを駆使して書く方がまずは簡単そうな気がする.
何となくの書き方は見えてきたので,次の記事ではそのあたりをもっときれいにまとめていく.
また,一部無視した所や命令で疑問が残る所があったので,そのあたりも含めて詳しくLLVM IR Referenceを読み込んでいく.