Assemblyで見る関数とマクロの違い
マクロと関数って何が違うの?と聞かれたとして、テンプレートな反応としては「関数はまあ所謂関数、マクロはコンパイル前に展開されるんやでー」と言う説明になるかと思います
この説明でも必要十分ではありますが、そろそろLv2に上がりませうと言う事でもうちょっと突っ込んだ見方をしてみます
サンプルコードやassemblyの生成にはrustを使っています
内容はわりかし易しめなのでassemblyが読めなくても問題ありません
というか僕も読めません
ほな早速
結論
- マクロと関数は最終的に生成される機械語が異なる
- 但し最適化はしないものとする
具体的には?
どういうことかを話す前にassembly(及び機械語。以下冗長なのでassemblyとのみ書きます)の世界から見た関数について説明する必要があります
実はassemblyにも、通常のプログラミング言語と同様の「関数」が存在します
さらに言うと歴史的には逆で、assemblyにおけるサブルーチンをトレースしたのがC言語における関数です
C言語以外の言語においても一番根本的な部分は同じです
従ってassemblyにおける関数も引数を取りますし返り値を持ちます
assembly
具体的なコードを見てみましょう
以下のrustコードをビルドしてみます
アセンブリの出力が見やすいのでbare-metalにしてます
#![no_std]
#![no_main]
use core::arch::asm;
use core::panic::PanicInfo;
macro_rules! aiueo {
($numeral:expr) => {
$numeral * 2
};
}
#[unsafe(no_mangle)]
fn _start() {
let _rslt = aiueo(666,);
// let _rslt = aiueo!(666);
}
#[unsafe(no_mangle)]
fn aiueo(a1: u32,) -> u32 {
a1 * 2
}
#[panic_handler]
fn panic(_panic: &PanicInfo,) -> ! {
loop {
unsafe { asm!("wfe") };
}
}
ビルドコマンド↓
(ターゲットはaarch64でなくても勿論大丈夫ですが、適宜mnemonicを読み替える必要があります。でもそれが出来る人は多分これ読まない🫠)
cargo rustc --target aarch64-unknown-none -- --emit=asm
ビルドする際の注意点ですが、Cargo.tomlに
[profile.dev]
panic = "abort"
を追記しないとエラーになります(rustc 1.88.0-nightly (25cdf1f67 2025-04-28))
成果物へのパスはtarget/aarch64-unknown-none/debug/deps/<crate_name>-<hash>.s
です
生成されるassemblyファイルは千行近くありますが、上のコードと直接関係があるのは冒頭の数十行だけです
その部分をのぞいてみましょう
_start:
.Lfunc_begin0:
.file 1 "/Users/a/Downloads/QwQ/sampleeeeeeeeeeeeeee" "src/main.rs"
.loc 1 14 0
.cfi_sections .debug_frame
.cfi_startproc
sub sp, sp, #32
str x30, [sp, #16]
.cfi_def_cfa_offset 32
.cfi_offset w30, -16
mov w0, #666
.Ltmp0:
.loc 1 15 14 prologue_end
bl aiueo
str w0, [sp, #12]
.loc 1 17 2 epilogue_begin
ldr x30, [sp, #16]
add sp, sp, #32
ret
.Ltmp1:
.Lfunc_end0:
.size _start, .Lfunc_end0-_start
.cfi_endproc
.section .text.aiueo,"ax",@progbits
.globl aiueo
.p2align 2
.type aiueo,@function
aiueo:
.Lfunc_begin1:
.loc 1 20 0
.cfi_startproc
sub sp, sp, #16
.cfi_def_cfa_offset 16
str w0, [sp, #12]
.Ltmp3:
.loc 1 21 2 prologue_end
adds w0, w0, w0
cset w8, hs
str w0, [sp, #8]
tbnz w8, #0, .LBB1_2
b .LBB1_1
.LBB1_1:
.loc 1 0 2 is_stmt 0
ldr w0, [sp, #8]
.loc 1 22 2 epilogue_begin is_stmt 1
add sp, sp, #16
ret
.LBB1_2:
.loc 1 21 2
adrp x0, .Lanon.d1a37084b803bf665bf7f378c3c66524.1
add x0, x0, :lo12:.Lanon.d1a37084b803bf665bf7f378c3c66524.1
bl _ZN4core9panicking11panic_const24panic_const_mul_overflow17h487c3ecacabfb908E
.Ltmp4:
.Lfunc_end1:
.size aiueo, .Lfunc_end1-aiueo
.cfi_endproc
.section .text._RNvCsW2kLHQHBa7_7___rustc17rust_begin_unwind,"ax",@progbits
.hidden _RNvCsW2kLHQHBa7_7___rustc17rust_begin_unwind
.globl _RNvCsW2kLHQHBa7_7___rustc17rust_begin_unwind
.p2align 2
.type _RNvCsW2kLHQHBa7_7___rustc17rust_begin_unwind,@function
見づらいのでdirectiveを除いてみたものがこちら
_start:
sub sp, sp, #32
str x30, [sp, #16]
mov w0, #666
.Ltmp0:
bl aiueo
str w0, [sp, #12]
ldr x30, [sp, #16]
add sp, sp, #32
ret
.Ltmp1:
aiueo:
.Lfunc_begin1:
sub sp, sp, #16
str w0, [sp, #12]
.Ltmp3:
adds w0, w0, w0
cset w8, hs
str w0, [sp, #8]
tbnz w8, #0, .LBB1_2
b .LBB1_1
.LBB1_1:
ldr w0, [sp, #8]
add sp, sp, #16
ret
.LBB1_2:
adrp x0, .Lanon.d1a37084b803bf665bf7f378c3c66524.1
add x0, x0, :lo12:.Lanon.d1a37084b803bf665bf7f378c3c66524.1
bl _ZN4core9panicking11panic_const24panic_const_mul_overflow17h487c3ecacabfb908E
_startラベルの三行目にmov
mnemonicがあります
aarch64におけるmovを雑に説明するとレジスタに値をコピーする事ができるやつです
aarch64のabiではx0~x7レジスタが整数とポインタの引数を保持します(それだけだと引数を8つまでしか取れないので9つ以上引数がある場合は余った分がスタックに積まれます)
w0レジスタの実態は0xレジスタの下位32bitなので、この行でaiueo
関数を呼び出す為に引数の準備をしていると推測できます
そして次の処理をみてみるとbl
mnemonicがあります
aarch64のblは指定されたラベルにジャンプします
ここで指定されているのはaiueo
ラベルです
丁度rustコードにおけるaiueo
関数の呼び出しに対応しています
マクロを使った場合を見てみる
では次にマクロを使った場合のassemblyを見てみましょう
まずはrust側のコード
コメントアウトの位置を変えた以外はさっきと全く同じコードです
#![no_std]
#![no_main]
use core::arch::asm;
use core::panic::PanicInfo;
macro_rules! aiueo {
($numeral:expr) => {
$numeral * 2
};
}
#[unsafe(no_mangle)]
fn _start() {
// let _rslt = aiueo(666,);
let _rslt = aiueo!(666);
}
#[unsafe(no_mangle)]
fn aiueo(a1: u32,) -> u32 {
a1 * 2
}
#[panic_handler]
fn panic(_panic: &PanicInfo,) -> ! {
loop {
unsafe { asm!("wfe") };
}
}
これを再びビルドしてみます
cargo rustc --target aarch64-unknown-none -- --emit=asm
得られた成果物(を抜粋してディレクティブを除いたもの)がこちら
_start:
.Lfunc_begin0:
sub sp, sp, #16
mov w8, #1332
str w8, [sp, #8]
mov w8, wzr
.Ltmp1:
cbnz w8, .LBB0_2
b .LBB0_1
.LBB0_1:
ldr w8, [sp, #8]
str w8, [sp, #12]
add sp, sp, #16
ret
.LBB0_2:
adrp x0, .Lanon.d1a37084b803bf665bf7f378c3c66524.1
add x0, x0, :lo12:.Lanon.d1a37084b803bf665bf7f378c3c66524.1
bl _ZN4core9panicking11panic_const24panic_const_mul_overflow17h487c3ecacabfb908E
ちゃんと?bl
が使われていない事が確認できます
_start
ラベルからはaiueo
関数もとい、aiueo
ラベルは呼ばれていません
[!NOTE]
最後の行にbl
使われてるやん、と思うかも知れないですが、.LBB0_2ラベル部分で行ってる事は乗算がオーバーフローした時のパニック処理です
同様の処理が関数を利用したverのaiueo
ブロック内にもあります
また面白い点として.Lfunc_begin0
ローカルラベルの二行目
mov w8, #1332
と言う記述があり、666 * 2の計算がコンパイル時にされている事がわかります
改めて比較
関数を使用した場合とマクロを使用した場合のassemblyを改めて比較してみます
関数を利用した場合
_start:
sub sp, sp, #32
str x30, [sp, #16]
mov w0, #666
.Ltmp0:
bl aiueo
str w0, [sp, #12]
ldr x30, [sp, #16]
add sp, sp, #32
ret
.Ltmp1:
aiueo:
.Lfunc_begin1:
sub sp, sp, #16
str w0, [sp, #12]
.Ltmp3:
adds w0, w0, w0
cset w8, hs
str w0, [sp, #8]
tbnz w8, #0, .LBB1_2
b .LBB1_1
.LBB1_1:
ldr w0, [sp, #8]
add sp, sp, #16
ret
.LBB1_2:
adrp x0, .Lanon.d1a37084b803bf665bf7f378c3c66524.1
add x0, x0, :lo12:.Lanon.d1a37084b803bf665bf7f378c3c66524.1
bl _ZN4core9panicking11panic_const24panic_const_mul_overflow17h487c3ecacabfb908E
次にマクロを利用した場合
_start:
.Lfunc_begin0:
sub sp, sp, #16
mov w8, #1332
str w8, [sp, #8]
mov w8, wzr
.Ltmp1:
cbnz w8, .LBB0_2
b .LBB0_1
.LBB0_1:
ldr w8, [sp, #8]
str w8, [sp, #12]
add sp, sp, #16
ret
.LBB0_2:
adrp x0, .Lanon.d1a37084b803bf665bf7f378c3c66524.1
add x0, x0, :lo12:.Lanon.d1a37084b803bf665bf7f378c3c66524.1
bl _ZN4core9panicking11panic_const24panic_const_mul_overflow17h487c3ecacabfb908E
関数を利用した場合はbl
mnemonicによる無条件ジャンプ、引数の準備、ret
mnemonicによる関数から戻る処理がされている事がわかります
一方マクロを利用した場合これらの関数呼び出しに係る処理がごっそり無くなっています
最適化するとどうなるか
実行速度最適化の場合、関数のインライン展開が積極的に行われます
インライン展開が何をするか思い出してみましょう
インライン展開は、関数呼び出しの部分を関数が行う処理自体に置き換える、と言う最適化手法でした
これにより関数呼び出しのコストをなくす事ができ、実行速度が向上します(バイナリサイズは増えます)
これ、施されるタイミングは違えどマクロの展開とおんなじ事をしているのが分かります
では実際にassemblyをみて確認しましょうと言いたいところなのですが、サンプルコードが単純すぎるため
最適化を有効にすると
_start:
ret
この様に灰燼に帰す羽目になります
Discussion