📘

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