はじめに
さて皆さん。次のコードはどんなふうにコンパイルされると思いますか。
long value = 1L;
「うーん、こんな感じ?」
;x64
movq $1, -8(%rbp)
;x86
sub rsp, 24
mov DWORD PTR a$[rsp], 1
正解です。
しかし、C#では違います。
結構違います。
さっき知ったこの驚きを共有しようと思って書きました。
そんな記事です。
結論から言えば、単なる定数代入が7パターンにコンパイルされます。
sharplab.ioも開きながら読めば分かりやすいかもしれません。
Cの逆コンパイルはCompiler Explorerを利用させていただきました。
アセンブラ(バイナリの長さ計算)はOnline x86 / x64 Assembler and Disassemblerを利用させていただきました。
逆コンパイル結果一覧
long value;
value=0;
value=1;
value=8;
value=9;
value=127;
value=128;
value=2147483647L;//0b1111111111111111111111111111111
value=2147483648L;//0b10000000000000000000000000000000
value=4294967167L;//0b11111111111111111111111101111111
value=4294967168L;//0b11111111111111111111111110000000
value=4294967294L;//0b11111111111111111111111111111110
value=4294967295L;//0b11111111111111111111111111111111
value=4294967296L;//0b100000000000000000000000000000000
value=-1;
value=-2;
value=-128;
value=-129;
value=-2147483648L;
value=-2147483649L;
MSIL: 逆アセンブル結果
.class private auto ansi '<Module>'
{
} // end of class <Module>
.class private auto ansi abstract sealed beforefieldinit '<Program>$'
extends [System.Private.CoreLib]System.Object
{
.custom instance void [System.Private.CoreLib]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
01 00 00 00
)
// Methods
.method private hidebysig static
void '<Main>$' (
string[] args
) cil managed
{
// Method begins at RVA 0x2050
// Code size 102 (0x66)
.maxstack 1
.entrypoint
.locals init (
[0] int64 'value'
)
IL_0000: ldc.i4.0
IL_0001: conv.i8
IL_0002: stloc.0
IL_0003: ldc.i4.1
IL_0004: conv.i8
IL_0005: stloc.0
IL_0006: ldc.i4.8
IL_0007: conv.i8
IL_0008: stloc.0
IL_0009: ldc.i4.s 9
IL_000b: conv.i8
IL_000c: stloc.0
IL_000d: ldc.i4.s 127
IL_000f: conv.i8
IL_0010: stloc.0
IL_0011: ldc.i4 128
IL_0016: conv.i8
IL_0017: stloc.0
IL_0018: ldc.i4 2147483647
IL_001d: conv.i8
IL_001e: stloc.0
IL_001f: ldc.i4 -2147483648
IL_0024: conv.u8
IL_0025: stloc.0
IL_0026: ldc.i4 -129
IL_002b: conv.u8
IL_002c: stloc.0
IL_002d: ldc.i4.s -128
IL_002f: conv.u8
IL_0030: stloc.0
IL_0031: ldc.i4.s -2
IL_0033: conv.u8
IL_0034: stloc.0
IL_0035: ldc.i4.m1
IL_0036: conv.u8
IL_0037: stloc.0
IL_0038: ldc.i8 4294967296
IL_0041: stloc.0
IL_0042: ldc.i4.m1
IL_0043: conv.i8
IL_0044: stloc.0
IL_0045: ldc.i4.s -2
IL_0047: conv.i8
IL_0048: stloc.0
IL_0049: ldc.i4.s -128
IL_004b: conv.i8
IL_004c: stloc.0
IL_004d: ldc.i4 -129
IL_0052: conv.i8
IL_0053: stloc.0
IL_0054: ldc.i4 -2147483648
IL_0059: conv.i8
IL_005a: stloc.0
IL_005b: ldc.i8 -2147483649
IL_0064: stloc.0
IL_0065: ret
} // end of method '<Program>$'::'<Main>$'
} // end of class <Program>$
C言語: ソースコード
void test() {
long a=0;
a=1;
a=2147483647L;
a=2147483648L;
a=4294967296L;
}
x64アセンブラ: x86-64 gcc 10.2
_Z4testv:
test():
pushq %rbp
movq %rsp, %rbp
movq $0, -8(%rbp)
movq $1, -8(%rbp)
movq $2147483647, -8(%rbp)
movl $2147483648, %eax
movq %rax, -8(%rbp)
movabsq $4294967296, %rax
movq %rax, -8(%rbp)
nop
popq %rbp
ret
x64アセンブラ: x86-64 gcc 4.1.2
_Z4testv:
test():
pushq %rbp
movq %rsp, %rbp
movq $0, -8(%rbp)
movq $1, -8(%rbp)
movq $2147483647, -8(%rbp)
movl $-2147483648, -8(%rbp)
movl $0, -4(%rbp)
movl $0, -8(%rbp)
movl $1, -4(%rbp)
leave
ret
同時公開のAdvent Calendar記事「【C#】 演算子のオーバーロードで遊ぶ」もどうぞ。
正数
0~8 : ldc.i4.*+conv.i8
パターン1: ldc.i4.*+conv.i8
long value = 1L;
1の代入はこうなります。
IL_0000: ldc.i4.1
IL_0001: conv.i8
IL_0002: stloc.0
よく分かりませんね。
1行目のldc.i4.1
というのは、
ldc.i4.1
Push 1 onto the stack as int32 (0x17)
だそうです。つまり、「スタックにint32で1をプッシュする」という操作です。
つまりdotnetの中間言語、MSIL(共通中間言語)には、「1をスタックにプッシュする」なんて命令があるんです。
-1~8まであります。
2行目はそれをint64に変換する命令。
conv.i8
Convert to int64, pushing int64 on stack (0x6A)
3行目はスタックから値を取り出して変数に代入する命令です。
stloc.0
Pop a value from stack into local variable 0 (0x0A)
変数の型と名前もきちんと残っています。これのおかげでdotnetの逆コンパイルはローカル変数まで見えます。
.locals init (
[0] int64 'value'
)
最初は特定の値をスタックに積む専用命令があって、しかもわざわざ型変換をしてるのには驚きました。
おそらくメリットは即値にほぼ0の8バイトを消費するより容量が小さくて済むことでしょう。
実在のCPUでこんな命令はなさそうです(というかCPUが型を認識したりしない)。
さらに言えば、アセンブラっぽいのに変数という概念があるのも不思議ですが、MSILってのはこういうものみたいです。
long value = 8L;
なお8の代入はこうなります。-1~8まで同様。
IL_0000: ldc.i4.8
IL_0001: conv.i8
IL_0002: stloc.0
C言語
ちなみにCでは最初に書いた通りこうなります(x64)。
movq $1, -8(%rbp)
movqってのはMove Quadword
で8バイトの値のコピーです。
%rbp
というのはベースポインタ、つまり「rbpは関数内においてスタック領域を扱う処理の基準」だそうです。
ヒープはプラス方向に、スタックはマイナス方向に延びるので+8ではなく-8。
普通ですね。
ちなみにこの一行で8バイトの命令になります。
なぜ8バイトの値をコピーするのに8バイトで済むのか疑問ですが、-8(%rbp)
みたいな場合の即値は4バイトだからのようです。
2147483647までは特に面白い事は起きません。
ちなみにC#のJITコンパイル結果 (Debug/x86)はこうなります。
L001f: mov dword ptr [ebp-0x10], 1
L0026: mov eax, [ebp-0x10]
L0029: mov edx, [ebp-0x10]
L002c: sar edx, 0x1f
L002f: mov [ebp-0xc], eax
L0032: mov [ebp-8], edx
x86のmovはx64と引数の順序が逆なこと(2番目の引数を1番目の場所に代入)、リトルエンディアンなのでメモリアドレスが多い方に上位ビットが来ることに注意してください。
ebpはrbpの32ビット版です(x64にもあります)。4バイト程度ずれてるようですが気にしないでおきます。
SARはShift Arithmetic Right
で算術右シフトのことです。すなわち正の場合は0で負の場合は1でシフトしたビットをセットするビットシフトです。
つまりsar edx, 0x1f
は正の場合は0に、負の場合は0xffffffff
(-1)になるわけです。
conv.i8
の指示通り、律儀にintからlongに変換しているわけですね。
やたら行数が多いですし、わざわざメモリを経由する必要があるのか、定数なんだからsar
使わずにxor
とかで0を作れば良さそうですし、色々謎ですね。
まぁJITですし、ILを複数行見るような処理はやらない設定なんでしょう多分。
intの場合は普通に1行になります。
32ビットCPUで64ビット型を使うのは、単なるストアの時点から命令が増えて遅いだろうなという話ですね。
L001c: mov dword ptr [ebp-8], 1
追記:この記事を書いた当時は気付いてなかったですが、Debugの場合です。
ちなみにC#のJITコンパイル結果 (Release/x86)はこうなります。
L0000: push 0
L0002: push 8
Releaseビルドだと普通にpush
命令になりますね…。
参考文献
- yone-ken (2007) 「[IL6] 定数のロードあれこれ」 KEN's .NET
- yone-ken (2007) 「[IL13] 型と型変換」 KEN's .NET
- @tobira-code (2016) 「x86-64プロセッサのスタックを理解する」 qiita
- 「X86アセンブラ/x86アーキテクチャ」 WIKIBOOKS
- @Nina_Petipa「Tips IA32(x86)汎用命令一覧 Sから始まる命令 SAL/SAR/SHL/SHR命令 」 0から作るソフトウェア開発 日々勉強中。。。
- Intel (2004) 「IA-32 インテル®アーキテクチャソフトウェア・デベロッパーズ・マニュアル 上巻」 7-14ページ
9~127 : ldc.i4.s+conv.i8
パターン2: ldc.i4.s+conv.i8
long a = 8L;
9からは即値を使います。ただし、即値は1バイトです。
IL_0009: ldc.i4.s 9
IL_000b: conv.i8
IL_000c: stloc.0
ldc.i4.s
はint8の即値をint32としてスタックにプッシュする命令です。これは127まで続きます。
地味にメモリの節約になりそうです。
ldc.i4.s
Push num onto the stack as int32, short form (0x1F )
128~2147483647 : ldc.i4+conv.i8
パターン3: ldc.i4+conv.i8
long value = 8L;
128からはint32の即値をスタックにプッシュする命令を使うようになります。
これは2147483647(int.MaxValue
)まで続きます。
IL_0011: ldc.i4 128
IL_0016: conv.i8
IL_0017: stloc.0
ldc.i4
Push num of type int32 onto the stack as int32 (0x20 )
2147483648~4294967167 : ldc.i4+conv.u8
パターン4: ldc.i4+conv.u8
long value = 2147483648L; //0b10000000000000000000000000000000
2147483648では負数が出てきます。可読性がぐんと下がります。
IL_001f: ldc.i4 -2147483648
IL_0024: conv.u8
IL_0025: stloc.0
たまたま符号のあるなしだけみたいになってますが、2147483649だと-2147483647です。
conv.u8
はその名の通りunsigned int64
に変換する命令です。
負数を符号なし整数型に変換することで大きな値を得ているわけですね。
conv.u8
Convert to unsigned int64, pushing int64 on stack (0x6E)
C言語
C言語ではコンパイラーによって結果が異なるようです。
x86-64 gcc 4.1.2
movl $-2147483648, -8(%rbp)
movl $0, -4(%rbp)
4バイトずつ代入しています。直観的です。
ちなみにこの2行で14バイトになります。
x86-64 gcc 10.2 / x86-64 clang 11.0.0
movl $2147483648, %eax
movq %rax, -8(%rbp)
EAXは、RAXに使っているレジスタの下位32ビットのことです。代入すると上位32ビットもクリアされる…のだと思います。
下位32ビットに代入し、64ビットコピーすることで目的の値を得ているわけですね。
この2行で9バイトです。上より5バイト節約、1を代入するだけより1バイト多いだけです。
コンパイラが進歩してるんだなぁと感じます。メモリの節約はCPUキャッシュなどでも大事です。
ちなみにC#のJITコンパイル結果 (x86)はこうなります。
L001f: mov eax, 0x80000000
L0024: xor edx, edx
L0026: mov [ebp-0xc], eax
L0029: mov [ebp-8], edx
xor edx, edx
は0を作っているだけです。
4バイトずつ書き込んでるだけですね。
命令長は合わせて13バイトです。
Releaseの場合、
L0000: push 0
L0002: push 0x80000000
各行2バイト、5バイトで合計7バイトになります。
4294967168~4294967294 : ldc.i4.s+conv.u8
パターン5: ldc.i4.s+conv.u8
long value = 4294967168L; //0b11111111111111111111111110000000
4294967168は何の変哲もない数字に見えます。どうなるか予想できますか。
IL_002d: ldc.i4.s -128
IL_002f: conv.u8
IL_0030: stloc.0
そうですldc.i4.s
の復活です。
見た目が変わり、(コンパイル結果が)短くなるだけでそんなに意味はないですね。
4294967295 : ldc.i4.m1+conv.u8
パターン6: ldc.i4.m1+conv.u8
long value = 4294967295L;//0b11111111111111111111111111111111
上で書きましたが、-1だけは特別な命令があるのでこうなります。
IL_0035: ldc.i4.m1
IL_0036: conv.u8
IL_0037: stloc.0
ldc.i4.m1
Push -1 onto the stack as int32 (0x15)
4294967296~ : ldc.i8
パターン7: ldc.i8
long value = 4294967296L;//0b100000000000000000000000000000000
これ以降は普通に8バイトの即値をストアするだけの簡単な作業です。
IL_0038: ldc.i8 4294967296
IL_0041: stloc.0
ldc.i8
Push num of type int64 onto the stack as int64 (0x21 )
C言語
C言語ではこうなります。
x86-64 gcc 4.1.2
movl $0, -8(%rbp)
movl $1, -4(%rbp)
こっちはいっしょ。
x86-64 gcc 10.2 / x86-64 clang 11.0.0
movabsq $4294967296, %rax
movq %rax, -8(%rbp)
こちらはmovabsq
という長い名前の命令が出てきます。
単純に64ビットの即値を取るmovですね。恐怖の10バイト命令です。
2行で14バイトも使います。
負数
では0からマイナス方向へ行きましょう。
ちなみに「-2~-128」のように絶対値の順に書きます。
基本、正数と同じなのでサクサク紹介していきましょう。
-1 : ldc.i4.*+conv.i8
パターン1: ldc.i4.*+conv.i8
long value = -1;
IL_0042: ldc.i4.m1
IL_0043: conv.i8
IL_0044: stloc.0
ldc.i4.m1
という命令があります。-2以降はありません。
既に書きましたね。
-2~-128 : ldc.i4.s+conv.i8
パターン2: ldc.i4.s+conv.i8
long value = -2;
IL_0045: ldc.i4.s -2
IL_0047: conv.i8
IL_0048: stloc.0
「9~127」と同じ。
2の補数表現なので絶対値が1ずれます。
-129~-2147483648 : ldc.i4+conv.i8
パターン3: ldc.i4+conv.i8
long value = -129;
IL_004d: ldc.i4 -129
IL_0052: conv.i8
IL_0053: stloc.0
「128~2147483647」と同じ。
-2147483649~ : ldc.i8
パターン7: ldc.i8
long value = -2147483649L;
IL_005b: ldc.i8 -2147483649
IL_0064: stloc.0
「4294967296~」と同じ。
負数をconv.u8
にするテクニックが使えないので、8バイトの即値を持つ命令を正数より広い範囲で使わざるを得ません。
感想
単なる定数のストアにこんなに種類があるのは驚きでした。7パターンもあります。
出力されるILのサイズをかなり真剣に削っているようです。
さらに-1~8の為の専用命令ってのも不思議ですね。驚きました。
一方で、JITコンパイルの結果はちょっと見た限りではあまり洗練されていないように感じました。
MSILはちょくちょく読みはするんですが、これくらい読み込む機会はないので新鮮でした。
普通のアセンブラもですね。
アセンブラ初心者なので用語とか知識とか間違っていれば申し訳ないです。
以前似た話を(岩永さんの?)ツイートで見たような記憶があるので既出かもしれません。
ただ知ってる人が多いわけでもなさそうなので良いんじゃないでしょうか。
これは元々Advent Calendar用の企画で演算子オーバーロードの調査をしている時に見つけたのですが、面白かったので記事にしたものです。
それなりの分量になったので、本題が間に合わなさそうならこっちを代わりにします。公開時期も遅らせときます。
表
命令長/パターンは以下になります。
数値 | パターン | 命令長(合計) | 命令 |
---|---|---|---|
-9,223,372,036,854,775,808 | 7 | 9 | ldc.i8 |
-2,147,483,649 | 7 | 9 | ldc.i8 |
-2,147,483,648 | 3 | 6 | ldc.i4+conv.i8 |
-129 | 3 | 6 | ldc.i4+conv.i8 |
-128 | 2 | 3 | ldc.i4.s+conv.i8 |
-2 | 2 | 3 | ldc.i4.s+conv.i8 |
-1 | 1 | 2 | ldc.i4.m1+conv.i8 |
8 | 1 | 2 | ldc.i4.8+conv.i8 |
9 | 2 | 3 | ldc.i4.s+conv.i8 |
127 | 2 | 3 | ldc.i4.s+conv.i8 |
128 | 3 | 6 | ldc.i4+conv.i8 |
2,147,483,647 | 3 | 6 | ldc.i4+conv.i8 |
2,147,483,648 | 4 | 6 | ldc.i4+conv.u8 |
4,294,967,167 | 4 | 6 | ldc.i4+conv.u8 |
4,294,967,168 | 5 | 3 | ldc.i4.s+conv.u8 |
4,294,967,294 | 5 | 3 | ldc.i4.s+conv.u8 |
4,294,967,295 | 6 | 2 | ldc.i4.m1+conv.u8 |
4,294,967,296 | 7 | 9 | ldc.i8 |
9,223,372,036,854,775,807 | 7 | 9 | ldc.i8 |
おまけ
C#上でILを手軽に触る方法もあります。
using System.Reflection.Emit;
using System.Reflection;
DynamicMethod method = new DynamicMethod("DynamicMethod", typeof(void), Type.EmptyTypes);
ILGenerator il = method.GetILGenerator();
il.DeclareLocal(typeof(long));
il.Emit(OpCodes.Ldc_I4_S, (sbyte)-128);
il.Emit(OpCodes.Conv_U8);
il.Emit(OpCodes.Stloc_0);
il.Emit(OpCodes.Ldloc_0);
il.Emit(OpCodes.Call, typeof(Console).GetMethod("WriteLine", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public, null, new Type[] { typeof(Int64) }, null));
il.Emit(OpCodes.Ret);
Action action = (Action)method.CreateDelegate(typeof(Action));
action();
- aharisu (2009) 「DynamicMethodで末尾再帰」 aharisuのごみ箱