インラインアセンブラtips集

インラインアセンブラtips集

編集履歴

本記事で対象とする環境 #

本記事では以下の環境のインラインアセンブラに関するtipsについて記載する。

  • コンパイラ:gcc 11.4.0 以降
  • アーキテクチャ:x86-64

なお、インラインアセンブラの基本について解説は行わない。

Intel syntaxで記述したい #

gccはデフォルトではAT&T syntaxで記載されたインラインアセンブリをコンパイルすることができる。 しかしIntel syntaxで記載したいときもある。

その際は、コンパイルオプションに -masm=intel を追加すると、Intel syntaxで記載されたインラインアセンブリをコンパイルできる。

また、一部分だけIntel syntaxで記載したい場合は、コンパイルオプションではなく、インラインアセンブリ中に .intel_syntax noprefix を記載することで適用することができる。 .att_syntax を記載すれば、AT&T syntaxに戻すこともできる。

#include <stdio.h>

int main(void) {
	int a = 1, b;
	asm (
		".intel_syntax noprefix\n"
		"mov %0, %1\n"
		"add %0, 1\n"
		".att_syntax\n"
		"add $1, %0\n"
		: "=r" (b)
		: "r" (a)
	);

	printf ("%d\n", b);
}

上記の例では 3 が出力される。

レジスタに値を割り付けたい #

インラインアセンブラでは、レジスタを指定して値を割り付けることができる。 例えば以下の例がある。

uint64_t inline_asm_syscall(
    uint64_t syscall_no,
    uint64_t arg1,
    uint64_t arg2,
    uint64_t arg3,
    uint64_t arg4,
    uint64_t arg5,
    uint64_t arg6,
) {
    uint64_t ret;

    register uint64_t arg4_r10 asm("r10") = arg4;
    register uint64_t arg5_r8  asm("r8")  = arg5;
    register uint64_t arg6_r9  asm("r9")  = arg6;
    asm (
        "syscall"
        : "=a" (ret)
        : "a" (syscall_no),
          "D" (arg1), "S" (arg2), "d" (arg3),
          "r" (arg4_r10), "r" (arg5_r8), "r" (arg6_9)
        : "memory", "rcx", "r11"
    );

    return ret;
}

上記の例は syscall 命令を発行するインラインアセンブリである。

  • raxsyscall_no
  • rdiarg1
  • rsiarg2
  • rdxarg3
  • r10arg4
  • r8arg5
  • r9arg6

を割り当て、 syscall 命令を発行し、返り値を rax 経由で ret に格納している。

インラインアセンブラでは入力オペランドと出力オペランドでレジスタに値を割り付けることが可能だが、具体的にどのレジスタにどの値を割り付けたいといったことも指定可能である。

以下に表を記す。

レジスタ名 オペランド上の表記
rax a
rbx b
rcx c
rdx d
rsi S
rdi D

このように、x86が32bitだった時代から存在するレジスタには、表記方法が存在する。

x86-64になってからは、 r8 から r15 までのレジスタが増えた。 これらのレジスタは上記のような表記方法が存在しないため、次のようにレジスタを割り当てる。

    register uint64_t arg4_r10 asm("r10") = arg4;
    register uint64_t arg5_r8  asm("r8")  = arg5;
    register uint64_t arg6_r9  asm("r9")  = arg6;

上記の例は、 register を用いて r10 r8 r9 レジスタに値を割り当てる表記である。 これらは次のように用いる。

    asm (
        "syscall"
        : "=a" (ret)
        : "a" (syscall_no),
          "D" (arg1), "S" (arg2), "d" (arg3),
          "r" (arg4_r10), "r" (arg5_r8), "r" (arg6_9)
        : "memory", "rcx", "r11"
    );

上記のように、 register の文で割り当てた名前を用いて、 "r" で指定する。 すると、 syscall 命令が呼ばれる際に、 r10 r8 r9 に値が割り当てられる。

また、破壊されるレジスタの記載に関しては、そのままレジスタの名前が使える。 今回の場合は rcx r11 である。

インラインアセンブラだけの関数で、プロローグとエピローグを抑制したい #

組み込みなどの特殊環境下では、関数プロローグとエピローグを省略して、インラインアセンブラのみで構成された関数を使いたいといったこともある。 そのような際は、 __attribute__((naked)) を用いると良い。 以下が例である。

#include <stdio.h>

__attribute__((naked))
void f(void) {
	asm volatile("nop");
}

int main(void) {
	f();
	printf("hello\n");
	return 0;
}

上記のように、関数に __attribute__((naked)) でnaked属性を指定すると、関数プロローグが省略される。 objdump -d すると、次のような逆アセンブルが確認できる。

0000000000001149 <f>:
    1149:	f3 0f 1e fa          	endbr64 
    114d:	90                   	nop
    114e:	90                   	nop
    114f:	0f 0b                	ud2    

0000000000001151 <main>:
    1151:	f3 0f 1e fa          	endbr64 
    1155:	55                   	push   %rbp
    1156:	48 89 e5             	mov    %rsp,%rbp
    1159:	e8 eb ff ff ff       	call   1149 <f>
    115e:	48 8d 05 9f 0e 00 00 	lea    0xe9f(%rip),%rax        # 2004 <_IO_stdin_used+0x4>
    1165:	48 89 c7             	mov    %rax,%rdi
    1168:	e8 e3 fe ff ff       	call   1050 <puts@plt>
    116d:	b8 00 00 00 00       	mov    $0x0,%eax
    1172:	5d                   	pop    %rbp
    1173:	c3                   	ret    

naked属性を指定した関数 f() のプロローグとエピローグが消えていることがわかる。

なお、このプログラムは f() に突入した後、 114f: 0f 0b ud2 でSegmentation faultする。

追記: memory clobberについて #

レジスタに値を割り付けたい の節で登場した、

asm (
    "some instruction"
    :
    :
    : "memory"
);

この "memory" の意味について。

memory は、インラインアセンブラ内で、入出力に直接関係ないメモリへの読み書きが存在する場合にプログラマが記載しなければいけないキーワードである。 memory キーワードをつけると、コンパイラは最適化の過程で asm 文をまたいだメモリ操作のリオーダリングを抑制する。

気を付けるポイントとしては、コンパイラ内部で行う最適化の過程での話なので、プロセッサレベルではリオーダリングが起きる可能性がある。 そのため、プロセッサレベルでメモリ操作のリオーダリングを抑制する場合はfence命令を用いる必要がある。

LLVMにおける memory clobberの状況(@kubo39さんからの情報) #

@kubo39 さんが教えてくれたのだが、LLVMのインラインアセンブラでは、 memory clobberを無視するらしい。 実際に教えてもらった LLVMのコード を見ると、clobberの部分はレジスタ制約しか見ていない(っぽい)ことがわかる。

    case InlineAsm::isClobber: {

      const unsigned NumRegs = OpInfo.Regs.size();
      if (NumRegs > 0) {
        unsigned Flag = InlineAsm::Flag(InlineAsm::Kind::Clobber, NumRegs);
        Inst.addImm(Flag);

        for (Register Reg : OpInfo.Regs) {
          Inst.addReg(Reg, RegState::Define | RegState::EarlyClobber |
                               getImplRegState(Reg.isPhysical()));
        }
      }
      break;
    }

どうやら、LLVMインラインアセンブラはmemory operandを介したメモリアクセスについて記述できるが、memory clobberのような入出力に関係ないメモリアクセスを表現できないようである。 ただし、LLVMのインラインアセンブラは内部的に関数相当の実装をしており、かつ関数属性はデフォルトでメモリ読み書きが起きるという仮定があるのでmemory clobberがない場合でも問題になっていない。 逆に関数属性として読み込みも書き込みもない・読み込みがあるが書き込みがないといった場合は属性を付与する必要がある。 ちなみにRustだと、nomem 属性をつけることでこれを解除する ようである。

nomem: The asm! blocks does not read or write to any memory. This allows the compiler to cache the values of modified global variables in registers across the asm! block since it knows that they are not read or written to by the asm!. The compiler also assumes that this asm! block does not perform any kind of synchronization with other threads, e.g. via fences.

nomem 属性の実装部分の Rustコンパイラのコード を確認すると、ここでもLLVMに無視される旨が書かれている。

        if !options.contains(InlineAsmOptions::NOMEM) {
            // This is actually ignored by LLVM, but it's probably best to keep
            // it just in case. LLVM instead uses the ReadOnly/ReadNone
            // attributes on the call instruction to optimize.
            constraints.push("~{memory}".to_string());
        }

ちなみにこの辺の話は pasopediaのissue に寄せられた情報ほぼそのままである、詳しい人はそちらを読んだ方がいいかもしれない。

参考リンク #