LoginSignup
4
3

More than 1 year has passed since last update.

【逆アセ】 C#でlongに代入してみる

Last updated at Posted at 2020-12-22

はじめに

さて皆さん。次のコードはどんなふうにコンパイルされると思いますか。

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を利用させていただきました。

逆コンパイル結果一覧
C#: ソースコード
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)はこうなります。
ちなみにC#のJITコンパイル結果 (Debug/x86)はこうなります。 sharplab.ioでJIT Asmを選択した結果です。x64版は今のところなさそうです。

image.png

    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命令になりますね…。

参考文献

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)はこうなります。
Debugの場合、
    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バイトも使います。

image.png

負数

では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

image.png

おまけ

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();
4
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
3