前書き

言い訳

本書はまだ執筆途中です.不完全な部分があることをお許しください.

しかしながら,誤りの指摘や改善のためのコメントは歓迎いたします. 本書のGithubリポジトリはこちらです.

本書の目的

本書は筆者(権藤克彦)が東京工業大学情報工学系で 長年担当したアセンブリ言語の授業の資料をオンライン資料として まとめ直したものです. Intel x86-64,Linux,GNUアセンブラを前提として「アセンブリ言語とは何か」 「具体的にどうプログラミングすればいいのか」を分かりやすくお伝えすることが目的です.

ただし,本書では以下は扱っていません.

  • 浮動小数点命令
  • (デバイスドライバの実装に必要な)I/O命令
  • (OSの実装に必要な)特権命令
  • MMX/SSE/AVXなどの拡張命令

いや,書いてもいいのですが分量が膨大になるので面倒くさいんです. もしOS自作に興味があるなら書籍ゼロからのOS自作入門を強くお勧めします.

本書で使う環境

本書では以下の環境を使用しています.皆さんの環境がLinuxであれば多少違っても大丈夫なはずです.

  • Ubuntu 22.04 LTS (OS)
  • GNU gcc-11.3.0 (コンパイラ)
  • GNU binutils-2.38 (バイナリ・ユーティリティ,GNUアセンブラasを含む)
  • GNU gdb-12.1 (デバッガ)

デバッガはアセンブリ言語の実行結果を確認するために便利ですので,ぜひ準備して下さい.

しかし,WindowsやmacOSの場合は,本書の内容と大きく異なってしまいます. アセンブリ言語は環境への依存度が高く,そのため移植性がとても低いからです.

皆さんのパソコンがWindowsやmacOSだった場合,Linux環境を導入する方法として以下のようないろいろな方法があります.筆者のお勧めは

  • WindowsならWSL2を使う
  • Intel Macなら仮想マシンVirtualBoxをインストールして,Ubuntuをインストールする (Apple Silicon Mac用のVirtualBoxは2023/12/6時点でベータ版です)

です.

Linux環境を導入する方法:

  • WSL2 (Windows Subsystem for Linux 2)を使えるように設定する
  • VirtualBoxVMWare Fusion などの仮想マシンをインストールして,その仮想マシン上にUbuntuなどのLinuxをインストールする.
  • Dockerなどのコンテナ実行環境をインストールして,その上でUbuntuなどのLinuxをインストールする.既存のイメージを使っても良い.Apple Silicon Mac上のDockerで,Intel Linuxのイメージが動作可能です.
  • オンライン環境(例えばrepl.it)を使う.

Linux環境の導入方法を書くと切りが無いので,皆さん自身でググって下さい.

私が使った Ubuntu 22.04 LTSにはgccなどが未インストールなので, 以下のコマンドでインストールしました.

$ sudo apt install build-essential

本書のライセンス

Copyright (C) 2023 Katsuhiko Gondow

本書はクリエイティブ・コモンズ4.0表示(CC-BY-NC 4.0)で提供します.

本書の作成・公開環境

本書のお約束

メモリの図では0番地が常に上

本書ではメモリの図を書く時,必ず0番地(低位アドレス)が上, 高位アドレスが下になるようにします.

その結果,本書の図では「スタックは上に成長」,「ヒープは下に成長」することになります (メモリレイアウト).

❶❷などの黒丸数字は説明用

実行結果中の❶や❷などの黒丸数字は,説明のために私が追加したものです. 実行結果の出力ではありません. 例えば,以下が例で,fileコマンドの出力例です. 本文中の説明と実行結果のどこが対応しているのかを明示するために使います.

$ file add5.o
add5.o: ❶ELF 64-bit ❷LSB ❸relocatable, x86-64, ❹version 1 (SYSV), ❺not stripped

Practical Binary Analysis という書籍がこうしていて便利なので真似させてもらっています.

一部を隠してます.

「細かい説明」「演習問題の答え」などはdetailsタグを使って隠しています. 最初は読み飛ばして構いません.読む場合は▶ボタンを押して下さい.

←このボタン(またはこの行)を押してみて下さい

これが隠されていた内容です.

一部の図はタブ表示にしています

一部の図はタブ切り替えでパラパラ漫画のように表示しています. 一度に全部を表示するとゴチャゴチャする場合などに使います. 以下はタブ表示の例です.

サンプルコードがあります

サンプルコード には2種類のファイルがあります.

  • *.s アセンブリコード
  • *.txt gdbのコマンド列が書かれたファイル

これらのファイルとデバッガgdbを使って機械語命令を実行・確認する方法は, こちらに説明があります. (サンプルコードの準備,めっちゃ大変だったので活用して頂けるととても嬉しいです).

(説明せず)擬似コードを使っている部分があります

例えば,mov命令の説明では movq %rax, %rbxの動作の説明として,%rbx = %raxと書いています. %rbx = %raxはアセンブリ言語でも無くC言語でも無い, C言語風の擬似コード(psuedo code)です. 「%raxレジスタの値を%rbxレジスタに格納する」という動作を 簡潔に表現する手段として使わせて下さい.

本書のお断り

2023/10/5現在,日本語検索に対応しました.

「ですます」調と「だである」調がまざってる

すみません,自覚してますがとりあえず放置です. 後で統一するかも知れませんし,しないかも知れません.

サンプルコードのインデントがおかしい

すみません,インデントしたコードブロック中でmdbookの#include機能を使うと 表示が狂ってしまうため,意図的にインデントしていない箇所が多々あります.

Todo

  • Intel CET対応のtigerlakeでサンプルコードを試していない.

    デフォルトのビルドで(endbr64が無い)サンプルコードがこけるとまずい どなたか tigerlakeのパソコンを貸して下さい😁

アセンブリ言語の概要

機械語とアセンブリ言語とは何か?(短い説明)

機械語(マシン語):

  • CPUが直接実行できる唯一の言語.
  • 機械語命令を2進数(バイナリ,数字の列)で表現.

アセンブリ言語

  • 機械語を記号で表現したプログラミング言語.
  • 例1:機械語命令01010101をアセンブリ言語ではpushq %rbpという記号(ニモニック,mnemonic)で表す(x86-64の場合,以下同様).
  • 例2:メモリのアドレス1000番地をアセンブリ言語ではadd5などの記号(ラベル)で表す.
pushq %rbpとは

「レジスタ%rbp中の値をスタックにプッシュする」という命令です. ここで説明します.

2進数の機械語命令と,機械語命令のニモニックは概ね,1対1に対応しており, 機械的に変換できます.ただし,その変換方法を覚える必要はありません. アセンブル逆アセンブルしてくれる コマンド(プログラム)にやってもらえばいいのです.

ただ,アセンブリ言語の仕組みを理解するには,オブジェクトファイル*.oや 実行可能ファイルa.outの中身や仕組みを理解する必要があるため, バイナリファイルの節では説明が多くなっています.

機械語とアセンブリ言語の具体例(逆アセンブル)

まず以下の簡単なCのプログラムadd5.cを用意して下さい.

// add5.c
int add5 (int n)
{
    return n + 5;
}

add5.cgcc -cで処理すると, オブジェクトファイルadd5.oができます. このadd5.oに対してobjdump -dを実行すると, 逆アセンブル(disassemble)した結果が表示されます.

$ gcc -c add5.c
$ ls
add5.c  add5.o
$ objdump -d add5.o
./add5.o:     file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <add5>:
   0:	f3 0f 1e fa          	endbr64 
   4:	55                   	push   %rbp
   5:	48 89 e5             	mov    %rsp,%rbp
   8:	89 7d fc             	mov    %edi,-0x4(%rbp)
   b:	8b 45 fc             	mov    -0x4(%rbp),%eax
   e:	83 c0 05             	add    $0x5,%eax
  11:	5d                   	pop    %rbp
  12:	c3                   	ret    

逆アセンブルとは,a.out*.o中の機械語命令を アセンブリ言語のニモニック表現に変換することです. 上の実行例で,左側に機械語命令,右側にニモニックが表示されています. (一番左側の数字は,.textセクションの先頭からのバイト数(16進表記)です). 例えば,4バイト目にある55は機械語命令(を16進数で表記したもの), 55の右側のpush %rbpが,55に対応するニモニックです. 16進数を使っているのは,2進数で表記すると長くなるからです.

Cコードをアセンブリコードにコンパイルする

add5.cに対して, 以下のコマンドを実行して,add5.sを作成して下さい. これで「アセンブリ言語で書かれたプログラム(アセンブリコード)」がどんなものかを見れます.

$ gcc -S add5.c
$ ls
add5.c  add5.s

-Sオプションをつけて処理すると, gccはCのプログラム(add5.c)からアセンブリコード(add5.s)を生成します. この処理を「狭義のコンパイル」と呼びます (広義のコンパイルはCのプログラムから実行可能ファイル(a.out)を 生成する処理を指します). gcc -Sは「コンパイラ」と呼ばれます.コンパイルするコマンドだからです.

add5.sの中身は例えば以下となります.

注意: gccのバージョンの違いにより,同じLinuxでもadd5.sの中身が以下と異なることがあります.

以下では表示が長いので省略しています. 全てを表示するには右にあるボタンを押して下さい. (ここではadd5.sの中身は理解できなくてOKです).

$ cat add5.s
        .file   "add5.c"
        .text
        .globl  add5
        .type   add5, @function
add5:
.LFB0:
        .cfi_startproc
        endbr64
        pushq   %rbp
        .cfi_def_cfa_offset 16
        .cfi_offset 6, -16
        movq    %rsp, %rbp
        .cfi_def_cfa_register 6
        movl    %edi, -4(%rbp)
        movl    -4(%rbp), %eax
        addl    $5, %eax
        popq    %rbp
        .cfi_def_cfa 7, 8
        ret
        .cfi_endproc
.LFE0:
        .size   add5, .-add5
        .ident  "GCC: (Ubuntu 11.3.0-1ubuntu1~22.04.1) 11.3.0"
        .section        .note.GNU-stack,"",@progbits
        .section        .note.gnu.property,"a"
        .align 8
        .long   1f - 0f
        .long   4f - 1f
        .long   5
0:
        .string "GNU"
1:
        .align 8
        .long   0xc0000002
        .long   3f - 2f
2:
        .long   0x3
3:
        .align 8
4:

このうち実行に関係する部分だけを残したアセンブリコードが以下になります.

# add5.s
    .text
    .globl add5
    .type add5, @function
add5:
    pushq %rbp
    movq  %rsp, %rbp
    movl  %edi, -4(%rbp)
    movl  -4(%rbp), %eax
    addl  $5, %eax
    popq  %rbp
    ret
    .size  add5, .-add5

各行の意味は次の次の節で説明しますが, ちょっとだけ説明します.

  • .textなどドット.で始まる命令はアセンブラ命令です
  • add5:など名前の後ろにコロン:があるものはラベルの定義です
  • %rbpなど,パーセント%で始まるものはレジスタです
  • $5など,ドル$で始まるものは定数(即値)です.
  • addl $5, %eaxは「レジスタ%eaxの値と定数の5を足し算した結果を %eaxレジスタに格納する」という動作を行う機械語命令です
  • #から行末まではコメントです
AT&T形式とIntel形式とは

x86-64用のアセンブラには本書で扱うGNUアセンブラ以外にも, NASM (netwide assembler)などいくつかあり, 困ったことにアセンブリ言語の表記が異なります. この表記方法には大きく2種類:AT&T形式Intel形式があります. 本書で扱うGNUアセンブラはAT&T形式,NASMやIntelのマニュアルはIntel形式を使っています.

一番大きな違いは機械語命令の引数(オペランドといいます)の順番です.

  • AT&T形式は「左から右へ」,つまり代入先のオペランドを右に書きます
  • Intel形式は「右から左へ」,つまり代入先のオペランドを左に書きます

他にもAT&T形式には%$がつくなど,細かい違いがあります. ここで詳しく説明します.

なお,gcc-S -masm=intelとオプションを設定すると, 出力されるアセンブリコードをIntel形式に変更できます.

$ gcc -S -masm=intel add5.c
        .intel_syntax noprefix
        .text
        .globl  add5
        .type   add5, @function
add5:
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR -4[rbp], edi
        mov     eax, DWORD PTR -4[rbp]
        add     eax, 5
        pop     rbp
        ret
        .size   add5, .-add5

(DWORDは4バイト (double word)を意味しています)

なお,消した行の説明を以下に書きますが,読み飛ばしてOKです.

.cfi_とは

.cfiで始まるもの(アセンブラ命令)は call frame information を扱う命令です. 本書の範囲では不要です.詳細はdwarf5仕様書を参照下さい.

.fileと.identとは

.file.identはコメントとほぼ同じで,実行には関与しません.

.section .note.とは

以下の2つはセキュリティ上,実際には重要です(本書では消してしまいますが).

  • .section .note.GNU-stack,"",@progbitsはスタック上の機械語命令を実行不可と指定しています.
  • .section .note.gnu.property,"a"はIntel CETというセキュリティ技術の一部である IBT (indirect branch tracking)と SHSTK (shadow stack) のための指示です.
endbr64とは

endbr64もセキュリティ上,重要です. 間接ジャンプは脆弱性の大きな原因です. endbr64はセキュリティ技術であるIntel CET技術の命令であり, 間接ジャンプ先の命令がendbr64以外の時は実行エラーとする,というものです. 本書の学習者としては「endbr64はセキュリティ上,重要だけど,アセンブリ言語を学習する立場では「endbr64nop命令(何も実行しない命令)」と思えば十分です.

add5.sの各行の意味の説明の前に,説明の都合上, アセンブルアセンブラを説明します.

アセンブリコードをオブジェクトファイルにアセンブルする

add5.sに対して,以下のコマンドを実行すると, add5.oが生成されます.この処理をアセンブル(assemble)といいます. そして,アセンブルを行うプログラム(コマンド)を アセンブラ(assembler)と呼びます. gcc -cは内部的にアセンブラasを呼び出します. asは本書で使用するGNUアセンブラのコマンド名です.

$ gcc -c add5.s
$ ls
add5.c add5.o add5.s

アセンブル処理は逆アセンブルとちょうど逆の関係です. (逆アセンブルは,バイナリから機械語命令のニモニックを復元しますが, アセンブラ命令やラベルやコメントは復元できません. ですので,完全な逆の関係ではありません.)

add5.oはバイナリファイルです. また,add5.oから作成する実行可能ファイルa.outもバイナリファイルです. バイナリ(の中身)については次の章,3節.バイナリで説明します.

アセンブリ言語の構成要素

add5.sはアセンブリ言語のプログラムであり, アセンブリコード (assembly code)と呼びます. アセンブリコードは以下の4つを組み合わせて書きます.

  • 機械語命令 (例:pushq %rbp)
  • アセンブラ命令 (例:.text)
  • ラベル定義 (例:add5:)
  • コメント (例:# add5.s)

特に機械語命令(machine instruction)とアセンブラ命令(assembler directive) の違いに注意して下さい.

  • 機械語命令はCPUが実行する命令です. 例えば,pushq %rbpは機械語命令(のニモニック)です. このpushq %rbpa.outが実行された時にCPUが実行します.

    一方,アセンブラがすることは例えば add5.s中のpushq %rbpという機械語命令のニモニックを 0x55という2進数(ここでは16進数表記)に変換して,add5.oに出力するだけです. アセンブラはpushq %rbpという機械語命令を実行しません. アセンブラにとって,pushq %rbp0x55も両方とも単なるデータに過ぎないのです.

  • アセンブラ命令はアセンブラが実行する命令です. 例えば,.textはアセンブラ命令です. 本書が使用するGNUアセンブラではドット記号.で始まる命令は全てアセンブラ命令です.

    アセンブラadd5.sからadd5.oを出力(アセンブル)します. そのアセンブラに対して行う指示がアセンブラ命令です. 例えば,.textは「出力先を.textセクションにせよ」を アセンブラに指示しています. アセンブラはアセンブル時に.textというアセンブラ命令を実行します (CPUがa.outを実行するときではありません).

アセンブリ言語は1行に1つが基本

アセンブリ言語は基本的に1行に1つだけ, 「機械語命令」「アセンブラ命令」「ラベル定義」「コメント」 のいずれかを書くのが基本です. ただし,複数を組み合わせて1行にできる場合もあります. 以下に可能な例を示します. (正確な定義はGNUアセンブラの文法を参照下さい).

  • OK add5: pushq %rbp (ラベル定義と機械語命令)
  • OK pushq %rbp; movq %rsp, %rbp (機械語命令と機械語命令,セミコロン;で区切る)
  • OK pushq %rbp # コメント (機械語命令とコメント)
  • OK .text # コメント (アセンブラ命令とコメント)

add5.s中の# add5.s

# add5.sgcc -Sの出力ではなく,私が付け加えた行です. この行はコメントです.#から行末までがコメントとなり, アセンブラは単にコメントを無視します. つまりコメントは(C言語のコメントと同じで)人間が読むためだけのものです.

add5.s中の.text

.textは「出力先を.textセクションにせよ」と アセンブラに指示しています. セクションでも説明しますが, add5.oa.outなどのバイナリファイルの中身はセクションという単位で 区切られています. このため,アセンブラが機械語やデータを2進数に変換して出力する時, 「どのセクションに出力するのか」の指定が必要となるのです.

.textセクション以外には,代表的なセクションとして, .dataセクション,.rodataセクションがあります. それぞれの役割は以下の通りです.

  • .text 機械語命令(例:pushq %rbp)を置くセクション
  • .data 初期化済みの静的変数の値(例:0x1234)を置くセクション
  • .rodata 読み込みのみ(read only)の値(例:"hello\n\0")を置くセクション

例えば,以下のアセンブリコードfoo.sがあるとします (.rodataセクションを指定する際は,.sectionが必要です).

# foo.s
.text            # .textセクションに出力せよ
pushq %rbp
movq %rsp, %rbp
.data            # .dataセクションに出力せよ
.long 0x11223344
.section .rodata # .rodataセクションに出力せよ
.string "hello\n"

このfoo.sをアセンブラが処理すると以下になります(以下の図を見ながら読んで下さい).

  • pushq %rbpを2進数にすると 0x55movq %rsp, %rbpを2進数にすると 0x48 0x89 0xe5 なので, これら合計4バイトを.textセクションに出力します.

  • .dataは「.dataセクションに出力せよ」 .longは「指定したデータを4バイトの2進数として出力せよ」という意味です. 0x11223344を2進数にすると 0x44 0x33 0x22 0x11なので これら4バイトを.dataセクションに出力します. (出力が逆順になっているように見えるのは x86-64がリトルエンディアンだからです.)

  • .section .rodataは「.rodataセクションに出力せよ」 .stringは「指定した文字列をASCIIコードの2進数として出力せよ」という意味です. "hello\n"を2進数にすると 0x68 0x65 0x6c 0x6c 0x64 0x0a 0x00なので, これら7バイトを.rodataセクションに出力します. (最後の'\0'はヌル文字です.C言語では文字列定数の最後に自動的に ヌル文字が追加されますが,アセンブリ言語では必ずしもそうではありません. .stringはヌル文字を追加します. 一方,(ここでは使っていませんが).asciiはヌル文字を追加しません). ASCIIコードman asciiで確認できます.

.bssセクションは?

.text.datarodataに加えて,.bssセクションも代表的なセクションですが, ここでは説明を省略しました. .bssセクションは未初期化の静的変数の実体を格納するセクションなのですが, ちょっと特殊だからです. 未初期化の静的変数はゼロで初期化されることになっているので, バイナリファイル中では(サイズの情報等をのぞいて)実体は存在しません. プログラム実行時に初めてメモリ上で.bssセクションの実体が確保され, その領域はゼロで初期化されます.

add5.s中のadd5:.globl add5.type add5, @function.size add5, .-add5

add5:はラベルの定義

add5:add5というラベルを定義しています. ラベルはアドレスを表しています. もっと具体的には「ラベルは,そのラベル直後の機械語命令や値が, メモリ上に配置された時のアドレス」になります.

例えば,次のアセンブリコードがあり,

add5:
    pushq %rbp 

このpushq %rbp命令の2進数表現0x550x1234番地に置かれたとします.

この時,ラベルadd5の値は0x1234になります. (ここでは話を単純化しています.ラベルの値が最終的に決まるまで, 再配置(relocation)などの処理が入ります)

ラベルの参照

で,大事なラベルの使い方(参照)です. 機械語命令のニモニック中で,アドレスを書ける場所にはラベルも書けるのです. 例えば,関数をコールする命令call命令でadd5関数を呼び出す時, 以下の2行はどちらも同じ意味になります. ラベルadd5の値は0x1234になるからです. (ここでも話を単純化しています.関数や変数のアドレスは 絶対アドレスではなく,相対アドレスなどが使われることがあるからです).

    call 0x1234
    call add5    

どちらの書き方でも,アセンブラのアセンブル結果は同じになります. (もちろん通常はラベルを使います.具体的なアドレスを使って アセンブリコードを書くのは人間にとってはつらいからです).

記号表がラベルを管理する

アセンブラはラベルのアドレスが何番地になるかを管理するために, アセンブル時に記号表(symbol table)を作ります. 記号表中の情報は割と単純で,主に以下の6つです.

アドレス配置される
セクション
グローバル
か否か
サイズラベル名
(シンボル名)
0x1234.textグローバル関数15add5

ここで,add5.sのラベルadd5

  • 配置されるセクションが.textなのは,ラベルの定義add5:の前に.textが指定されているから
  • グローバルなのは,.globl add5と指定されているから
  • 関数という型なのは,.type add5, @functionと指定されているから
  • サイズが15バイトなのは,.size add5, .-add5と指定されているから (サイズ15バイトは.-add5から自動計算されます)

です. ここでグローバルの意味は,C言語のグローバル関数やグローバル変数と(ほぼ)同じです. グローバルなシンボルは他のファイルからも参照できます.

ラベル or シンボル?

アセンブラが扱うシンボルのうち,アドレスを表すシンボルのことをラベルと呼んでいます. シンボルはアドレス以外の値も保持できます. つまりシンボルの一部がラベルであり,add5は関数add5の先頭アドレスを表すシンボルなのでラベルです.

.-add5 とは

.-add5はアドレスの引き算をしています..は特別なラベルで「この行のアドレス」を意味します.add5add5:のアドレスを意味します. ですので,.-add5は「最後のret命令の次のアドレスから, 最初のpushq %rbp命令のアドレスを引いた値」になります. つまり引き算の結果は「関数add5中の機械語命令の合計サイズ(単位はバイト)」です.

nmコマンドを使うと記号表の中身を表示できます.

$ nm ./a.out |egrep add5
0000000000001234 T add5

大文字Tは「.text中のグローバルシンボル」であることを意味しています. (小文字tだと「.text中のグローバルではないシンボル」という意味になります). このnmの出力では「add5が関数」という情報とサイズが表示できていません. readelfコマンドを使うと,❶関数であることとサイズが❷15バイトであることを表示できます.

$ readelf -s ./a.out | egrep add5
     1: 0000000000001234    ❷15 ❶FUNC    GLOBAL DEFAULT    1 add5
readelfコマンドとは

objdumpは汎用のコマンド(ELFバイナリ以外のバイナリにも使える)ため, ELF特有の情報を表示できないことがあります. ELF専用のコマンドであるreadelfを使えば,ELF特有の情報も表示できます. 例えば,以下ではreadelfを使って記号表(❶.symtab)のセクションがあることを確認できました.

$ readelf -S add5.o セクションヘッダを表示
There are 12 section headers, starting at offset 0x258:
Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .text             PROGBITS         0000000000000000  00000040
       0000000000000013  0000000000000000  AX       0     0     1
  [ 2] .data             PROGBITS         0000000000000000  00000053
       0000000000000000  0000000000000000  WA       0     0     1
  [ 3] .bss              NOBITS           0000000000000000  00000053
       0000000000000000  0000000000000000  WA       0     0     1
(中略)↓これが記号表 (symbol table)
  [ 9]❶.symtab           SYMTAB           0000000000000000  000000d8
       00000000000000f0  0000000000000018          10     9     8

add5.s中のpushq %rbpmovq %rsp, %rbppopq %rbp

movq %rsp, %rbp

%rsp%rbpはどちらもレジスタです. (GNUアセンブラではレジスタの名前の先頭に必ず%が付きます). レジスタはCPU内の高速なメモリです.CPUはメモリにアクセスするよりも, はるかに高速にレジスタにアクセスできます. %rsp%rbpはどちらも8バイト長のデータを格納できます.

movq %rsp, %rbpという機械語命令は 「%rspレジスタの値を%rbpにコピーする」という命令です. movqのmovは「move (移動)」,qは「処理するサイズが8バイト」であることを意味しています. (moveといいつつ,実行内容はコピーです.%rspに古い値が残るからです.)

なぜqが8バイト?

qはクアッドワード(quad word)の略だからです. 以下の通り,クワッドワードは「ワード2バイトの4個分」なので8バイトになります.

  • ワード(word)はバイト(byte)と同様に情報量の単位ですが, ワードが何バイトかはCPUごとに異なります. x86-64ではワードは2バイトです. x86の元祖であるIntel 8086が16ビットCPUだったことに由来します.
  • クアッド(quad)は4を意味します. 例えば,quadrupleは「4倍の」,quad bikeは「4輪バイク」を意味します.

仮にmovq %rsp, %rbpを実行する前に, %rspの値が0x11223344%rbpの値が0x55667788とします. movq %rsp, %rbpを実行すると, %rspの値が%rbpにコピーされるので, %rspの値も%rbpの値も0x11223344になります. 要するに,movq命令はC言語の代入文と同じです.

pushq %rbppopq %rbp

pushq %rbpは「スタックに%rbpの値をプッシュする」機械語命令です. 以下の図のように,%rbp中の値をスタックの一番上にコピーします. スタックはコピー先の部分を含めて上に成長します(赤枠の部分がスタック全体).

popq %rbpは「スタックからポップした値を%rbpに格納する」という機械語命令です. 以下の図のように,スタックの一番上の値を%rbpにコピーします. スタックはコピー元の部分だけ下に縮みます(赤枠の部分がスタック全体).

これだけだと,pushq %rbppopq %rbpの役割がよく分かりませんね. 実はこの2つの命令は以下で説明するスタックフレームの処理に関係しています.

データ構造としてのスタック

スタック(stack)は超基本的なデータ構造であり, 以下の図の通り,プッシュ操作ポップ操作でデータの格納と取り出しを行います.

  • プッシュはスタックの一番上にデータを格納します
  • ポップはスタックの一番上からデータを取り出します

最後に格納したデータが,取り出す時は先に取り出されるので, 後入れ先出し方式(LIFO: last in first out)とも呼ばれます.

スタックは関数呼び出しの実装に便利なデータ構造です. 関数呼び出しからリターンするときは,呼び出された順番とは逆順にリターンするからです.

キューqueueは?

ちなみに超基本的なデータ構造としてキュー(queue)も重要です. こちらは先に格納したデータが,先に取り出されるので 先入れ先出し方式(FIFO: first in first out)になります.

スタックとスタックフレーム

スタックとはプロセス(実行中のプログラム)が使用するメモリの領域の1つです. ここでのスタックは関数呼び出しのためのスタックなので, コールスタック(call stack)と呼ぶのが正式名称なのですが, 慣習に習って本書でも単にスタックと呼びます.

関数を呼び出すと,スタックフレームというデータがスタックに追加(プッシュ)されて, スタックは上に成長します.その関数からリターンすると, そのスタックフレームはスタックから削除(ポップ)されて縮みます. スタックフレームは関数呼び出し1回分のデータで, 局所変数,引数,返り値,戻り番地(リターンアドレス),退避したレジスタの値などを含みます.

例えば,main関数がadd5関数を呼び出して,add5からリターンすると以下の図になります.

%rsp%rbpは一番上のスタックフレームの上下を指す

さて,ここでようやく%rspレジスタと%rbpレジスタの出番です. 実は%rsp%rbpは以下の図のように, スタック上の一番上のスタックフレームの上下を指す役割を担っています.

「レジスタがスタックを指す」というのは具体的に以下の図の状態です. つまり, スタックフレームの一番上のアドレス(例えば0x11223344)が %rspに入っていて,%rspの値をそのアドレスとして使う意図がある場合, 「%rspはスタックフレームの一番上を指す」と言い, 上の図のように矢印で図表現します. (%rbpも同様です)

%rspは常にスタックの一番上を指す

pushq命令で プッシュすると%rspはプッシュしたデータの一番上を指すようにずれるので, %rspは常にスタックの一番上(スタックトップ)を指します. また,%rbpをプッシュしたので下図のように プッシュした値もスタックフレームの一番下を指しています.

同様にpopq命令でポップした時はポップで取り出したデータ分だけ %rspが指す先は下にずれて,やはり%rspはスタックトップを指します. 下図では保存した%rbpの値をポップして%rbpに格納したので, この時だけ「ひとつ下のスタックフレームの一番下」を%rbpは指しています (が,通常,この直後にリターンして一番上のスタックフレームは破棄されます. ですので,すぐに「%rsp%rbpは常に一番上のスタックフレームの上下を指す」 という状態に戻ります.)

pushq %rbpmovq %rsp, %rbp は新しいスタックフレームを作る

関数を呼び出すと,その関数のための新しくスタックフレームを作る必要があります. 誰が作るのかというと「呼び出された関数自身」が作ります(これはABIが定める事項です).

ここでは関数mainが関数add5call命令で呼び出すとして説明します.

main:
   ...
   call add5
add5:
   pushq %rbp
   movq %rsp, %rbp

これらの命令を実行した時のスタックの様子は以下の図のとおりです. (「call前」等のボタンを押して,図を切り替えて下さい)

一つずつ説明していきます.

  • call命令実行前はmain関数が一番上のスタックフレームです. その上下を%rsp%rbpが指しています.
  • call命令を実行してadd5関数に実行を移す際に, call命令はスタック上に戻り番地(リターンアドレス)をプッシュします. 戻り番地とは「関数からリターンした時にどのアドレスに実行を戻せばよいか」  を表す番地です.この場合ではcall add5命令の次のアドレスが戻り番地になります.
  • push %rbp命令を実行すると,今の%rbpレジスタの値をスタック上にプッシュします. の説明と見比べて下さい. 新しいスタックフレームを作る際に,%rbpに新しい値を設定する必要があるために, 今の%rbpの値をスタック上に退避(保存)するため,pushq %rbpが必要となります.
  • 次に movq %rsp, %rbp を実行します. 実はadd5のスタックフレームはとても小さくて「古い%rbp」しか入っていません. ですので,%rspの値を%rbpにコピーすれば, 「add5のスタックフレームの上下を%rsp%rspが指している」という状態にできます. この動作もで説明したので見比べて下さい.

以上で,add5のための新しいスタックフレームを作れました.

popq %rbpは今のスタックフレームを捨てる

これは前節での説明のちょうど逆になります.

popq %rbp
ret

を実行すると,スタックフレームは以下の図になります.

  • popq %rbpの実行前は,スタックトップ付近はこの図の状態になっています. (コンパイラがこの図の状態になるようにアセンブリコードを出力します. 自分でアセンブリコードを書く場合は,この図の状態になるように正しくプログラムする必要があります) 「この図の状態」をもう少し説明すると以下になります.

    • スタックトップには 古い%rbp が格納されていて, その 古い%rbp は1つ前のスタックフレームの一番下を指している.
    • スタックトップのひとつ下には戻り番地が格納されている.
    • さらにその下にはadd5を呼び出した関数(ここではmain)のスタックフレームがある.
  • popq %rbpを実行すると,%rbpmain関数のスタックフレームの一番下を 指すようになります.(上の説明と合わせて読んで下さい.) また,ポップの結果,%rspが指す先が下にずれて,戻り番地を指すように変わりました.

  • ret命令はスタックトップから戻り番地をポップして,次に実行する命令のアドレスをポップした戻り番地に設定します.スタックの状態はadd5を呼び出す前の状態に戻りました.

「この図の状態」の例外

この図の状態にならないことがあります. -fomit-frame-pointerというオプション付きでコンパイルすると, %rbpは「スタックフレームの一番下を指すポインタ(ベースポインタ)」として 使うのではなく,汎用レジスタ(好きな目的のために使えるレジスタ)として使われます. このため,関数からリターンする直前にこの図の状態にはなりません. -O2などの最適化オプションを指定すると, -fomit-frame-pointerも有効になることが多いです.

全てのスタックフレームは「古い`%rbp`」で数珠つなぎ

実は下の図のように全てのスタックフレームは「古い%rbp」で数珠つなぎ, つまり線形リスト(linked list)になっています

戻り番地とプログラムカウンタ

一般的にCPUはプログラムカウンタと呼ばれる特別な役割を持つレジスタを備えています. プログラムカウンタは「次に実行する機械語命令のアドレス」を保持します. そして,ret命令などでプログラムカウンタ中のアドレスを変更すると, 「次に実行する機械語命令のアドレス」を変更できるのです.

x86-64では%ripレジスタがプログラムカウンタです. ret命令はスタックをポップして取り出した戻り番地を プログラムカウンタ%ripに格納することで,「関数からリターンする」 (つまり,call add5命令の直後の命令を次に実行する)という動作を実現しています.

add5.s中の movl %edi, -4(%rbp)movl -4(%rbp), %eaxaddl $5, %eax

ここでは以下の3命令を説明します. 直感的にはこの3命令で「n + 5」を計算しています.

  movl  %edi, -4(%rbp)
  movl  -4(%rbp), %eax
  addl  $5, %eax
  • まず-4(%rbp)を説明します. これは「%rbp中のアドレスから4を引いた数」をアドレスとしてメモリを 読み書きすることを意味しています.以下の図はスタックをより正確に描いています.

    • メモリは1バイトごとにアドレスが付いています. 古い%rbpや戻り番地のデータはそれぞれ8バイトなので, アドレス8つ分(つまり8バイト)の場所を占めています.
    • 多バイト長のデータはそのデータが占めている先頭のアドレスを使って メモリを読み書きします.(本書の図ではメモリの0番地が常に上にあることを思い出してください). ですので,1バイトごとのアドレスで考えると,%rbpはスタックフレームの 一番下を指していません.
    • そして,-4(%rbp)は「%rbpから4を引いたアドレスのメモリ」ですので, 以下の図で-4(%rbp)が指している場所を先頭とするメモリ領域になります.
  • 次に%edi%eaxについて説明します.

    • 以下の図のようにx86-64には8バイト長の%rdi%raxという 汎用レジスタがあります(他にも汎用レジスタはありますがここでは割愛). その右半分にそれぞれ%edi%eaxという名前が付いています. %edi%eaxは4バイト長です.
    • %rdiレジスタは関数呼び出しでは第1引数を渡すために使われます. add5の第1引数nint型で,この場合は4バイト長だったため, %edinの値が入っています.
    • %raxレジスタは関数呼び出しでは返り値を返すために使われます. add5の返り値の方がint型なので,%eaxに値を入れてから 関数をリターンすれば,返り値が返せることになります.
  • 次に以下の2つの命令を説明します.
  movl  %edi, -4(%rbp)
  movl  -4(%rbp), %eax
  • movllは4バイトのデータをコピーすることを表しています.ですので, 例えば,movl %edi, -4(%rbp)%edi中の4バイトデータを 先頭アドレスが-4(%rbp)から4バイト分の領域 (この図で一番上の赤い部分) にコピーする命令になります.
なぜl(エル)が4バイト

l(エル)はlongの略で,GNUアセンブラでは以下の通り,longが4バイトを意味するからです. Intelマニュアルなどでは4バイトのことをdouble wordと呼びます.

2バイト4バイト8バイト
GNUアセンブラshortlongquad
Intelマニュアルworddouble wordquad word
  • この2つの命令で「%edi中の4バイトを-4(%rbp)にコピー」して,次に 「-4(%rbp)中の4バイトを%eaxにコピー」しています. 「%ediから%eaxに直接コピーすればいいんじゃね?」と思った方,正解です. 実はこの場合は(-4(%rbp)に格納しても使われないので)不要なのですが, コンパイラは 「引数nの実体の場所を-4(%rbp)としたので, -4(%rbp)にもnの値を格納する」という判断をしたようです.

  • addl $5, %eax命令を説明します.

    • この命令は%eaxの値と定数5の値を足し算した結果を%eaxに格納します.
    • つまり,n + 5の結果がこの命令の実行後に%eaxに入ります.
    • GNUアセンブラでは定数の先頭にはドルマーク$が付きます. ただし,-4(%rbp)-4など,ドルマークが付かないこともあります.

以上でadd5.sの説明が終わりました(お疲れ様でした).

即値とは

上で$5は定数と説明しましたが,アセンブラ用語では 即値(immediate value)と呼びます. それは機械語命令の2進数の中に 即値の値が埋め込まれており,即座に(つまりメモリやレジスタにアクセスすることなく) 値を取り出せることに由来しています. x86-64のマニュアルなどで imm32 などが出てきます.imm32は「32ビット長の即値」を意味しています.

%rspより上のメモリ領域に勝手に書き込んで良いのか(レッドゾーン)

LinuxのABI System V ABIではOKです. LinuxのABIでは%rspレジスタの上,128バイトの領域をレッドゾーンと呼び, この領域には好きに読み書きして良いことになっています. (ABIが「割り込みハンドラやシグナルハンドラが実行されても, レッドゾーンの値は破壊されない」ことを保証しています.) もちろん,自分自身で関数を呼び出すとレッドゾーン中の値は壊れるので, レッドゾーンは葉関数(leaf function),つまり関数を呼び出さない関数 が使うのが一般的です. レッドゾーンのおかげで,%rspをずらさずにメモリの読み書きができるので, その分だけ実行が高速になります.

バイナリファイル

バイナリファイルの中身を見る

16進ダンプ

add5.cadd5.sはテキストファイルですが, 2節のアセンブリ言語で作成した add5.oはバイナリファイルです. バイナリファイルなので,lessコマンドでは中身を読めません.

$ less add5.o
^?❶ELF^B^A^A^@^@^@^@^@^@^@^@^@^A^@>^@^A^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@X^B
^@^@^@^@^@^@^@^@^@^@@^@^@^@^@^@@^@^L^@^K^@<F3>^O^^<FA>UH<89><E5><89>}<FC><8B>E
(長いので省略)
❶ELFとは

上のlessコマンドの結果にELFという文字が見える理由を説明します. ELFはLinuxが採用しているバイナリ形式(binary format)です. このELFのバイナリファイルの先頭4バイトにはマジックナンバーという バイナリファイルを識別する特別な数値が入っています. ELFバイナリのマジックナンバーは 7F 45 4C 46です. 45 4C 46はASCII文字で E L F なので,lessコマンドがELFと表示したわけです.

バイナリファイルの中身を読むには例えばodコマンドを使います.

$ od -t x1 add5.o
0000000 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
0000020 01 00 3e 00 01 00 00 00 00 00 00 00 00 00 00 00
(長いので省略)

一番左の数字が「先頭からのバイト数(16進表記)」, その右側に並んでいるのが「1バイトごとに16進表記したファイルの中身」です. (1バイトのデータは2桁の16進数で表せることを思い出しましょう. 例えば,add5.oの中身の先頭4バイトの値は7F 45 4C 46です).

-t x1というオプションは「1バイトごとに16進数で表示せよ」という意味です. このような出力を16進ダンプ(hex dump)と言います. 他に16進ダンプするコマンドとして,xxdhexdumpなどがあります.

ちなみに,add5.cはテキストファイルですが,内容は2進数で保存されて いますので,odコマンドで中身を表示できます.

$ od -t x1 add5.c
0000000 69 6e 74 20 61 64 64 35 20 28 69 6e 74 20 6e 29
0000020 0a 7b 0a 20 20 20 20 72 65 74 75 72 6e 20 6e 20
0000040 2b 20 35 3b 0a 7d 0a
0000047

先頭の69はASCII文字iの文字コード, 同様に,次の6eは文字n,その次の74は文字tなので, add5.cの先頭3文字がintであることを確認できます. ASCIIコード表はman asciiコマンドで閲覧できます. (例えば,16進数の0x69は10進数の105です. ASCIIコード表の105番目の文字はiです.)

manコマンドとは

manコマンドはLinux上でマニュアルを表示するコマンドです.

例えばman asciiを実行すると以下のように表示されます.

$ man ascii

ASCII(7)                   Linux Programmer's Manual                  ASCII(7)

NAME
       ascii - ASCII character set encoded in octal, decimal, and hexadecimal

DESCRIPTION
       ASCII is the American Standard Code for Information Interchange.  It is
       a 7-bit code.  Many 8-bit codes (e.g., ISO  8859-1)  contain  ASCII  as
       their  lower  half.  The international counterpart of ASCII is known as
       ISO 646-IRV.

       The following table contains the 128 ASCII characters.

       C program '\X' escapes are noted.

       Oct   Dec   Hex   Char                        Oct   Dec   Hex   Char
       ────────────────────────────────────────────────────────────────────────
       000   0     00    NUL '\0' (null character)   100   64    40    @
       001   1     01    SOH (start of heading)      101   65    41    A
       002   2     02    STX (start of text)         102   66    42    B
(以下略)

デフォルトではlessコマンドで1ページずつ表示されるので, スペースキーで次のページが,bを押せば前のページが表示されます. 終了するにはqを押します.hを押せばヘルプを表示し,/で検索もできます. 例えば,/backspaceと入力してリターンを押すと,backspaceを検索してくれます.

manコマンドは章ごとに分かれています.例えば

  • 1章はコマンド (例:ls)
  • 2章はシステムコール (例:open)
  • 3章はライブラリ関数 (例:printf)

となっています. printfというコマンドがあるので, man printfとすると(ライブラリ関数ではなく)コマンドのprintfの マニュアルが表示されてしまいます. ライブラリ関数のprintfを見たい場合は man 3 printfと章番号も指定します.

なお,odコマンドに-cオプションをつけると, (文字として表示可能なバイトは)文字が表示されます.

$ od -t x1 -c add5.c
0000000  69  6e  74  20  61  64  64  35  20  28  69  6e  74  20  6e  29
          i   n   t       a   d   d   5       (   i   n   t       n   )
0000020  0a  7b  0a  20  20  20  20  72  65  74  75  72  6e  20  6e  20
         \n   {  \n                   r   e   t   u   r   n       n    
0000040  2b  20  35  3b  0a  7d  0a
          +       5   ;  \n   }  \n
0000047

コンピュータの中のデータはすべて01から成る

ここで大事なことを復習しましょう. それは 「コンピュータの中のデータは,どんな種類のデータであっても, 機械語命令であっても,すべて01だけで表現されている」 ということです. ですので,テキストはバイナリでもあるのです.

  • テキスト=文字として表示可能な2進数だけを含むデータ
  • バイナリ=文字以外の2進数も含んだデータ

注意: 本書で,テキスト(text)という言葉には2種類の意味があることに注意して下さい.

  • 1つは「文字」を意味します.例:「テキストファイル」(文字が入ったファイル)
  • もう1つは「機械語命令列」を意味します.例:「テキストセクション」(機械語命令列が格納されるセクション)

2進数と符号化

前節で説明した通り, コンピュータ中では全てのものを0と1の2進数で表現する必要があります. そのため,データの種類ごとに2進数での表現方法,つまり符号化 (encoding)の方法が定められています. 例えば,

  • 文字UをASCII文字として符号化すると,01010101になります.
  • pushq %rbpをx86-64の機械語命令として符号化すると,01010101になります.

おや,どちらも同じ01010101になってしまいました. この2進数がPなのかpushq %rbpなのか,どうやって区別すればいいでしょう? 答えは「これだけでは区別できません」です.

別の手段(情報)を使って,いま自分が注目しているデータが, 文字なのか機械語命令なのかを知る必要があります. 例えば,この後で説明する.textセクションにある 2進数のデータ列は「.textセクションに存在するから」という理由で 機械語命令として解釈されます.

fileコマンド

16進ダンプ以外の方法で,add5.oの中身を見てみます. まずはfileコマンドです. fileコマンドはファイルの種類の情報を教えてくれます.

$ file add5.o
add5.o: ❶ELF 64-bit ❷LSB ❸relocatable, x86-64, ❹version 1 (SYSV), ❺not stripped

これで,add5.oが64ビットの❶ELFバイナリであることが分かりました. ELFはバイナリ形式(バイナリを格納するためのファイルフォーマット)の1つです. Linuxを含めて多くのOSがELFをバイナリ形式として使っています.

❷LSBとは

多バイト長のデータをバイト単位で格納する順序をバイトオーダ(byte order)といいます. LSBは最下位バイトから順に格納するバイトオーダ (Least Significant Byte first), つまりリトルエンディアン を意味しています.

x86-64のバイトオーダがリトルエンディアンのため, このELFバイナリもリトルエンディアンになっています. ELFバイナリがビッグエンディアンかリトルエンディアンかどうかを示すデータが, ELFバイナリのヘッダに格納されています. これはreadelf -hコマンドで調べられます❶.

$ readelf -h a.out
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 
  Class:        ELF64
  Data:         2's complement, ❶little endian
  Version:      1 (current)
  OS/ABI:       UNIX - System V
(以下略)

リトルエンディアンでの注意は16進ダンプする時に,多バイト長データが逆順に表示されることです. 以下で多バイト長データ❶0x11223344.textセクションに配置してアセンブルした little.oを逆アセンブルすると,❸44 33 22 11と逆順に表示されています. (objdump -hの出力から,.textセクションのオフセット(ファイルの先頭からのバイト数)が❷0x40バイトであることを使って,odコマンドに-j0x40オプションを使い,.textセクションの先頭付近の情報を表示しています)

$ cat little.s
.text
❶.long 0x11223344
$ gcc -c little.s
$ objdump -h little.o
foo.o:     file format elf64-x86-64
Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         00000004  0000000000000000  0000000000000000 ❷00000040  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .data         00000000  0000000000000000  0000000000000000  00000044  2**0
                  CONTENTS, ALLOC, LOAD, DATA
  2 .bss          00000000  0000000000000000  0000000000000000  00000044  2**0
                  ALLOC
$ od -t x1 -j0x40 little.o | head -n1
0000100 ❸44 33 22 11 00 00 00 00 00 00 00 00 00 00 00 00
❸relocatableとは

バイナリ中のアドレスを再配置 (relocate)できるバイナリのことを 再配置可能 (relocatable)であるといいます.オブジェクトファイルはリンク時や実行時にアドレスを変更できるよう, relocatableであることが多いです.

❹version 1 (SYSV)とは

LinuxのABI(バイナリ互換規約)であるSystem V ABI に準拠していることを表しています.

❺not strippedとは

バイナリには実行に直接関係ない記号表デバッグ情報などが 含まれていることがよくあります. この「実行に直接関係ない情報」が削除されたバイナリのことを stripped binaryと呼びます. stripコマンドで「実行に直接関係ない情報」を削除できます. 削除された分,サイズが少し減っています.

$ ls -l add5.o
-rw-rw-r-- 1 gondow gondow 1368 Jul 19 10:09 add5.o
$ strip add5.o
$ ls -l add5.o
-rw-rw-r-- 1 gondow gondow 880 Jul 19 14:58 add5.o
.textセクションだけ抜き出す

GNU binutilsのobjcopyコマンドを使うと,特定のセクションだけ抜き出せます. 以下ではlittle.oから.textセクションを抜き出して,ファイルfooに書き込んでいます.

$ objcopy --dump-section .text=foo little.o
$ od -t x1 foo
0000000 44 33 22 11
0000004

objcopyはセクションの注入も可能です. 以下ではファイルfooの内容をlittle.oの新しいセクション.text2として注入しています. 新しいセクション❶.text2が出来ていることが分かります.

$ objcopy --add-section .text2=foo --set-section-flags .hoge=noload,readonly little.o
$ objdump -h little.o
little.o:     file format elf64-x86-64
Sections:
Idx Name       Size      VMA               LMA               File off  Algn
  0 .text      00000004  0000000000000000  0000000000000000  00000040  2**0
               CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .data      00000000  0000000000000000  0000000000000000  00000044  2**0
               CONTENTS, ALLOC, LOAD, DATA
  2 .bss       00000000  0000000000000000  0000000000000000  00000044  2**0
               ALLOC
  3 ❶.text2     00000004  0000000000000000  0000000000000000  00000044  2**0
               CONTENTS, READONLY

なお,fileコマンドはバイナリ以外のファイルにも使えます.

$ file add5.c
add5.c: ASCII text
$ file add5.s
add5.s: assembler source, ASCII text
$ file .
.:  directory
$ file /dev/null
/dev/null: character special (1/3)

セクションとobjdump -hコマンド

バイナリファイルの構造はざっくり以下の図のようになっています.

  • 最初のヘッダ以外の四角をセクション(section)と呼びます.
  • バイナリはセクションという単位で区切られていて,それぞれ別の目的でデータが格納されます.
  • ヘッダは目次の役割で「どこにどんなセクションがあるか」という情報を保持しています.

ヘッダの情報はobjdump -hで表示できます.

$ objdump -h add5.o
add5.o:     file format elf64-x86-64
Sections:
Idx Name     Size      VMA               LMA               File off  Algn
  0 .text    00000013  0000000000000000  0000000000000000  00000040  2**0
             CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .data    00000000  0000000000000000  0000000000000000  00000053  2**0
             CONTENTS, ALLOC, LOAD, DATA
  2 .bss     00000000  0000000000000000  0000000000000000  00000053  2**0
             ALLOC
(以下略)

ここでは「.text.data.bssという3つのセクションがある」ことを 見ればOKです.

VMAとLMAとは

VMAはvirtual memory addressの略で「メモリ上で実行される時の このセクションのメモリアドレス」です. 一方,LMAはload memory addressの略で「メモリ上にロード(コピー,配置)する時の このセクションのメモリアドレス」です. 通常,セクションをメモリにロードした後で,移動せずにそのまま実行するため,VMAとLMAは同じアドレスになります. add5.oではアドレスが決まってないので,VMAもLMAもゼロになっています.

File offとは

File offはファイルオフセットを表しています.このセクションがバイナリファイルの先頭から何バイト目から始まっているかを16進表記で表しています.

Algnとは

Algnはアラインメント(alignment)を表しています. 例えば「このセクションをメモリ上に配置する時,その先頭アドレスが8の倍数になるようにしてほしい」という状況の時,この部分が2**3となります(2の3乗=8).

CONTENTS, ALLOC, LOAD, READONLY, CODEとは

これらはセクションフラグと呼ばれるセクションの属性値です.

  • CONTENTS このセクションには中身がある (例えば,.bssはCONTENTSが無いので(ファイル中では)中身が空のセクションです)
  • ALLOC ロード時にこのセクションのためにメモリを割り当てる必要がある
  • LOAD このセクションは実行するためにメモリ上にロードする必要がある
  • READONLY メモリ上では「読み込みのみ許可(書き込み禁止)」と設定する必要がある
  • CODE このセクションは実行可能な機械語命令を含んでいる

3つのセクション .text.data.bss の役割は以下の通りです:

  • .textセクションは機械語命令を格納します.例えば,pushq %rbpを表す0x55.textセクションに格納されます.
  • .dataセクションは初期化済みの静的変数の値を格納します.例えば,大域変数int x=999;があったとき,999の2進数表現が.dataセクションに格納されます.
  • .bssセクションは未初期化の静的変数の値を格納します.例えば,大域変数int y;があったとき,(概念的には)初期値0の2進数表現が.bssセクションに格納されます.
なぜ概念的

実はファイル中では.bssセクションにはサイズ情報などごくわずかの情報しか持っていません.実行時にメモリ上に.bssセクションを作る際に,実際に必要なメモリを確保して,そのメモリ領域をすべてゼロで初期化すれば十分だからです(ファイル中に大量のゼロの並びを保持する必要はありません).

// bss.c
int a [1024];
int main (void)
{
   return a[0];
}

例えば,bss.cint a[1024]; の変数aは未初期化なので, 変数aの実体は.bssセクションに置かれます.アセンブリコードを見てみると,

$ gcc -S bss.c
$ cat bss.s
(関係する箇所以外は削除)
 ❶ .bss                # 以下を.bssセクションに出力
    .align 32           # 次の出力アドレスを32の倍数にせよ
    .type   a, @object  # ラベルaの型はオブジェクト(関数ではなくデータ)
    .size   a, 4096     # ラベルaのサイズは4096バイト
a:                      # ラベルaの定義
 ❷ .zero   4096        # 4096バイト分のゼロを出力せよ

.bssセクションに❷4096バイト分のゼロを出力するように見えますが, ヘッダを見てみると,ファイル中の.bssセクションの中身は0バイトだと分かります.

$ gcc -g bss.c
$ objdump -h ./a.out
Sections:
Idx Name          Size      VMA               LMA                File off  Algn
(中略)
23 ❸.bss     ❹ 00001020  0000000000004020  0000000000004020 ❺ 00003010  2**5
                ❼ALLOC
24   .comment    0000002b  0000000000000000  0000000000000000 ❻ 00003010  2**0
                  CONTENTS, READONLY

.bssセクションのサイズは16進数で❹ 0x1020バイト(10進数では4128バイト)ですが, ファイルオフセットを比較してみると,❺と❻が同じ値(000033010)なので, ファイル中での.bssセクションのサイズは0バイトだと分かります. また,セクション属性が❼ALLOCのみで, CONTENTS(中身がある)が無いことからも0バイトと分かります.

さらに代表的なセクションである.rodataも説明します.

  • .rodataセクションは読み込みのみ(read-only)なデータの値を格納します.例えば,C言語の文字列定数"hello"は書き込み禁止なので,"hello"の2進数表現が.rodataセクションに格納されます.

バイナリファイルには上記以外のセクションも数多く使われますが, まずはこの基本の4種類 (.text.data.bss.rodata) を覚えましょう.

記号表の中身を表示させる(nmコマンド)

バイナリファイル中には記号表(symbol table)があることが多いです. 記号表とは「変数名や関数名がバイナリ中では何番地のアドレスになっているか」という情報です. nmコマンドでバイナリファイル中の記号表を表示できます. まず,以下のfoo.cを準備して下さい.

// foo.c
int g1 = 999;
int g2;
int s1 = 888;
int s2;
int main ()
{
    static int s3 = 777;
    static int s4;
    int ❼i1 = 666;
    int ❼i2;
}

そしてコンパイルして,nmコマンドで記号表の中身を表示させます.

$ gcc -c foo.c
$ nm foo.o
0000000000000000 ❶D g1
0000000000000000 ❸B g2
0000000000000000 ❺T main
0000000000000004 ❶D s1
0000000000000004 ❸B s2
0000000000000008 ❷d ❻s3.0
0000000000000008 ❹b ❻s4.1

この出力の読み方は以下の通りです.

  • Dと❷d.dataセクションのシンボル,❸Bと❹b.bssセクションのシンボル,❺Tt.textセクションのシンボルであることを表す
  • 大文字はグローバル(ファイルをまたがって有効なシンボル),小文字はファイルローカルなシンボルであることを表す
  • static付きの局所変数を表すシンボルは 他の関数中の同名のシンボルと区別するために, ❻.0.1などが付加されることがある.
  • 左側の000408がシンボルに対応するアドレスですが,再配置前(relocation前)なので仮のアドレス(各セクションの先頭からのオフセット)
  • (staticのついてない)局所変数❼は記号表には含まれていない. 局所変数(自動変数)は実行時にスタック上に実体が確保されます.

ASLRとPIE(ちょっと脱線)

オブジェクトファイルのセクションごとの仮のアドレスは, リンク後のa.outでは具体的なアドレスになります

$ gcc foo.c
$ nm ./a.out | egrep g1
0000000000004010 D g1
$ nm ./a.out | egrep main
                 U __libc_start_main@@GLIBC_2.34
0000000000001129 T main
U __libc_start_main@@GLIBC_2.34とは

バイナリ中で参照されているけど定義がないシンボルがあると, nmコマンドはundefinedを意味するUを表示します. 実はa.outmain関数を呼び出す前に__libc_start_mainという GLIBC中の関数を(動的リンクした上で)呼び出します. __libc_start_mainは 様々な初期化を行った後,(その名の通り)main関数を呼び出すのが主な役割です.

ちなみに__libc_start_main_startが呼び出します.

$ readelf -h ./a.out | egrep Entry
  Entry point address:           ❶ 0x1040
$ objdump -d ./a.out | egrep 1040
0000000000001040 ❷ <_start>:
    1040:	f3 0f 1e fa          	endbr64 

a.outエントリポイント(最初に実行するアドレス)は ❶ 0x1040番地です.この番地には❷_startがあるので, a.outを実行すると最初に実行される関数は_startと分かります.

出力が長くなるので,g1mainのアドレスだけ載せています. g1のアドレスは0x4010番地,mainのアドレスは0x1129番地となりました. ただし,このまま実行すると,g1mainのアドレスはこれらのアドレスにはならず, 実行するたびに変わります. これはASLRPIEというセキュリティ対策機能のためです.

確かめてみましょう. 以下のfoo2.cを普通にコンパイルして実行してみます.

// foo2.c
#include <stdio.h>
int g1 = 999;
int main ()
{
    printf ("%p, %p\n", &g1, main);
}

以下の通り,g1mainのアドレスは実行するたびに変わりますし, nmが出力したアドレスとも異なります.

$ gcc foo2.c
$ ./a.out
0x557f2361e010, 0x557f2361b149
$ ./a.out
0x55a40e6f5010, 0x55a40e6f2149
$ ./a.out
0x562750663010, 0x562750660149
$ 

ここではASLRとPIEの機能を無効にして,アドレスが変わらなくなることを確認します.

$ sudo sysctl -w kernel.randomize_va_space=0  # ASLRをオフ
$ gcc -no-pie foo2.c                          # PIEをオフ
$ nm ./a.out | egrep main
                 U __libc_start_main@@GLIBC_2.34
0000000000401136 T main
$ nm ./a.out | egrep g1
0000000000404030 D g1
$ ./a.out
&g1=0x404030, main=0x401136
$ ./a.out
&g1=0x404030, main=0x401136
$ ./a.out
&g1=0x404030, main=0x401136

ASLRとPIEの機能をオフにすることで,アドレスが変わらなくなり, かつnmが出力するアドレスと同じになることが確認できました.

注意: 不用意なASLRとPIEの無効化はセキュリティ機能を下げるので避けるべきです. しかしデバッグ作業ではアドレスが変わらなくなるので ASLRとPIEの無効化が有用な場合もあります. なお,デバッガ中ではASLRは無効化されていることが多いです.

ASLRとは

ASLR (address space layout randomizationの略)は, アドレス空間の配置をランダム化する機能です. テキスト(実行コード),ライブラリ,スタック,ヒープなどをメモリ上に 配置するアドレスを実行するたびにランダムに変化させます. 以下を実行するとASLRは無効化され,

$ sudo sysctl -w kernel.randomize_va_space=0

以下を実行するとASLRは有効化されます.

$ sudo sysctl -w kernel.randomize_va_space=1
PIEとは

PIE (position independent executableの略)は位置独立実行可能ファイルを意味します. 通常,動的ライブラリは位置独立コードPIC (position independent code)としてコンパイルされます. 動的ライブラリはメモリ上で共有されるため,どのアドレスに配置してもそのまま再配置せずに,実行したいからです. PIEは動的ライブラリだけでなく,a.outも位置独立にした実行可能ファイルを指します. -no-pieオプションでコンパイルすると,PIEを無効化できます.

$ gcc -no-pie foo2.c

逆アセンブル再び

逆アセンブルで説明した通り, objdump -d ./a.outで逆アセンブル結果が表示されます(再掲).

$ objdump -d add5.o
add5.o:     file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <add5>:
   0:	f3 0f 1e fa          	endbr64 
   4:	55                   	push   %rbp
   5:	48 89 e5             	mov    %rsp,%rbp
   8:	89 7d fc             	mov    %edi,-0x4(%rbp)
   b:	8b 45 fc             	mov    -0x4(%rbp),%eax
   e:	83 c0 05             	add    $0x5,%eax
  11:	5d                   	pop    %rbp
  12:	c3                   	retq   

objdumpコマンドはadd5.o.textセクションを抽出し, そのデータを機械語命令として解釈して,対応するニモニックを出力しています.

この出力によれば,.textセクションの先頭4バイトはF3 0F 1E FAで, この4バイトがendbr64命令になります (x86-64の命令長は可変長で,1バイト〜15バイトです).

以下では.textセクションの先頭4バイトがF3 0F 1E FAであることを確認します.

セクションのヘッダを出力するコマンドobjdump -hの出力を再掲します.

$ objdump -h add5.o
add5.o:     file format elf64-x86-64
Sections:
Idx Name     Size      VMA               LMA                File off  Algn
  0 .text    00000013  0000000000000000  0000000000000000 ❶00000040  2**0
             CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .data    00000000  0000000000000000  0000000000000000   00000053  2**0
             CONTENTS, ALLOC, LOAD, DATA
  2 .bss     00000000  0000000000000000  0000000000000000   00000053  2**0
             ALLOC

.textセクションのFile offの欄を見ると❶00000040とあります. これは.textセクションがadd5.oの先頭から16進数で40バイト 目(以後,0x40と表記)にあることを意味しています.

odコマンドの-jオプションを使うと,指定したバイト数だけ, 先頭をスキップしてくれます. この-jオプションを使って,0x40バイトスキップして, .textセクションの最初だけを16進ダンプします (head -n3は先頭の3行だけ表示します).

$ od -t x1 -j0x40 add5.o | head -n3
0000100 ❶f3 0f 1e fa 55 48 89 e5 89 7d fc 8b 45 fc 83 c0
0000120   05 5d c3 00 47 43 43 3a 20 28 55 62 75 6e 74 75
0000140   20 39 2e 34 2e 30 2d 31 75 62 75 6e 74 75 31 7e

この結果❶を見ると,.textセクションの最初の4バイトは F3 0F 1E FAであることが分かります. これは上の逆アセンブルの結果の先頭4バイトと一致しており, endbr64命令が,add5.oの先頭から0x40バイト目に存在することが分かりました.

広義のコンパイルとリンク

ここでは広義のコンパイル,つまりCのプログラムfoo.cから 実行可能ファイルa.outを生成する処理の中身を見ていきます. いちばん大事なのは最後のリンク(link)です.

  • ❶ Cの前処理,すなわち#include#defineなどの前処理命令の処理と,マクロ(例えば<stdio.h>が定義するNULLEOF)の展開を行います.gcc -Eコマンドで実行できますが,内部的にはカッコ内のcppcc1コマンドが実行されています(現在はcc1).
  • ❷ 狭義のコンパイル処理で,Cのプログラムをアセンブリコードに変換します.
  • ❸ アセンブラ(asコマンド)によるアセンブル処理で,オブジェクトファイルfoo.oを生成します.foo.o中にはバイナリの機械語命令が入っています.
  • foo.oだけでは実行可能ファイルは作れません.例えば,printfなどのライブラリ関数の実体は, libc.a(静的ライブラリ)やlibc.so(動的ライブラリ)の中にあるからです. また,main関数を呼び出すためのCスタートアップルーチン(多くの場合,crt*.oというファイル名)も必要です. また,分割コンパイルの機能を使った結果,foo.oは他のC言語のプログラムをアセンブルしたオブジェクトファイル*.oが必要なことがよくあります. 「このような他のバイナリとfoo.oを合体させてa.outを生成する処理」のことをリンク(link)と呼びます.

広義のコンパイルで具体的にどのような処理が行われてるのかを見るには, -vをつけてgcc -vとコンパイルすれば表示されます. (以下では表示を省略しています.全てを表示するにはボタンを押して下さい).

$ gcc -v main.c add5.s |& tee out
Using built-in specs.
COLLECT_GCC=gcc
COLLECT_LTO_WRAPPER=/usr/lib/gcc/x86_64-linux-gnu/11/lto-wrapper
OFFLOAD_TARGET_NAMES=nvptx-none:amdgcn-amdhsa
OFFLOAD_TARGET_DEFAULT=1
Target: x86_64-linux-gnu
 Configured with: ../src/configure -v --with-pkgversion='Ubuntu 11.3.0-1ubuntu1~22.04.1' --with-bugurl=file:///usr/share/doc/gcc-11/README.Bugs --enable-languages=c,ada,c++,go,brig,d,fortran,objc,obj-c++,m2 --prefix=/usr --with-gcc-major-version-only --program-suffix=-11 --program-prefix=x86_64-linux-gnu- --enable-shared --enable-linker-build-id --libexecdir=/usr/lib --without-included-gettext --enable-threads=posix --libdir=/usr/lib --enable-nls --enable-bootstrap --enable-clocale=gnu --enable-libstdcxx-debug --enable-libstdcxx-time=yes --with-default-libstdcxx-abi=new --enable-gnu-unique-object --disable-vtable-verify --enable-plugin --enable-default-pie --with-system-zlib --enable-libphobos-checking=release --with-target-system-zlib=auto --enable-objc-gc=auto --enable-multiarch --disable-werror --enable-cet --with-arch-32=i686 --with-abi=m64 --with-multilib-list=m32,m64,mx32 --enable-multilib --with-tune=generic --enable-offload-targets=nvptx-none=/build/gcc-11-aYxV0E/gcc-11-11.3.0/debian/tmp-nvptx/usr,amdgcn-amdhsa=/build/gcc-11-aYxV0E/gcc-11-11.3.0/debian/tmp-gcn/usr --without-cuda-driver --enable-checking=release --build=x86_64-linux-gnu --host=x86_64-linux-gnu --target=x86_64-linux-gnu --with-build-config=bootstrap-lto-lean --enable-link-serialization=2
 Thread model: posix
 Supported LTO compression algorithms: zlib zstd
 gcc version 11.3.0 (Ubuntu 11.3.0-1ubuntu1~22.04.1) 
 COLLECT_GCC_OPTIONS='-v' '-mtune=generic' '-march=x86-64' '-dumpdir' 'a-'
  /usr/lib/gcc/x86_64-linux-gnu/11/cc1 -quiet -v -imultiarch x86_64-linux-gnu main.c -quiet -dumpdir a- -dumpbase main.c -dumpbase-ext .c -mtune=generic -march=x86-64 -version -fasynchronous-unwind-tables -fstack-protector-strong -Wformat -Wformat-security -fstack-clash-protection -fcf-protection -o /tmp/ccTw9Mym.s
 GNU C17 (Ubuntu 11.3.0-1ubuntu1~22.04.1) version 11.3.0 (x86_64-linux-gnu)
 	compiled by GNU C version 11.3.0, GMP version 6.2.1, MPFR version 4.1.0, MPC version 1.2.1, isl version isl-0.24-GMP
 
 GGC heuristics: --param ggc-min-expand=100 --param ggc-min-heapsize=131072
 ignoring nonexistent directory "/usr/local/include/x86_64-linux-gnu"
 ignoring nonexistent directory "/usr/lib/gcc/x86_64-linux-gnu/11/include-fixed"
 ignoring nonexistent directory "/usr/lib/gcc/x86_64-linux-gnu/11/../../../../x86_64-linux-gnu/include"
 #include "..." search starts here:
 #include <...> search starts here:
  /usr/lib/gcc/x86_64-linux-gnu/11/include
  /usr/local/include
  /usr/include/x86_64-linux-gnu
  /usr/include
 End of search list.
 GNU C17 (Ubuntu 11.3.0-1ubuntu1~22.04.1) version 11.3.0 (x86_64-linux-gnu)
 	compiled by GNU C version 11.3.0, GMP version 6.2.1, MPFR version 4.1.0, MPC version 1.2.1, isl version isl-0.24-GMP
 
 GGC heuristics: --param ggc-min-expand=100 --param ggc-min-heapsize=131072
 Compiler executable checksum: e13e2dc98bfa673227c4000e476a9388
 COLLECT_GCC_OPTIONS='-v' '-mtune=generic' '-march=x86-64' '-dumpdir' 'a-'
  as -v --64 -o /tmp/cc5o7Jgg.o /tmp/ccTw9Mym.s
 GNU assembler version 2.38 (x86_64-linux-gnu) using BFD version (GNU Binutils for Ubuntu) 2.38
 COLLECT_GCC_OPTIONS='-v' '-mtune=generic' '-march=x86-64' '-dumpdir' 'a-'
  as -v --64 -o /tmp/ccUs2R16.o add5.s
 GNU assembler version 2.38 (x86_64-linux-gnu) using BFD version (GNU Binutils for Ubuntu) 2.38
 COMPILER_PATH=/usr/lib/gcc/x86_64-linux-gnu/11/:/usr/lib/gcc/x86_64-linux-gnu/11/:/usr/lib/gcc/x86_64-linux-gnu/:/usr/lib/gcc/x86_64-linux-gnu/11/:/usr/lib/gcc/x86_64-linux-gnu/
 LIBRARY_PATH=/usr/lib/gcc/x86_64-linux-gnu/11/:/usr/lib/gcc/x86_64-linux-gnu/11/../../../x86_64-linux-gnu/:/usr/lib/gcc/x86_64-linux-gnu/11/../../../../lib/:/lib/x86_64-linux-gnu/:/lib/../lib/:/usr/lib/x86_64-linux-gnu/:/usr/lib/../lib/:/usr/lib/gcc/x86_64-linux-gnu/11/../../../:/lib/:/usr/lib/
 COLLECT_GCC_OPTIONS='-v' '-mtune=generic' '-march=x86-64' '-dumpdir' 'a.'
  /usr/lib/gcc/x86_64-linux-gnu/11/collect2 -plugin /usr/lib/gcc/x86_64-linux-gnu/11/liblto_plugin.so -plugin-opt=/usr/lib/gcc/x86_64-linux-gnu/11/lto-wrapper -plugin-opt=-fresolution=/tmp/ccgnuv0i.res -plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lgcc_s -plugin-opt=-pass-through=-lc -plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lgcc_s --build-id --eh-frame-hdr -m elf_x86_64 --hash-style=gnu --as-needed -dynamic-linker /lib64/ld-linux-x86-64.so.2 -pie -z now -z relro /usr/lib/gcc/x86_64-linux-gnu/11/../../../x86_64-linux-gnu/Scrt1.o /usr/lib/gcc/x86_64-linux-gnu/11/../../../x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/11/crtbeginS.o -L/usr/lib/gcc/x86_64-linux-gnu/11 -L/usr/lib/gcc/x86_64-linux-gnu/11/../../../x86_64-linux-gnu -L/usr/lib/gcc/x86_64-linux-gnu/11/../../../../lib -L/lib/x86_64-linux-gnu -L/lib/../lib -L/usr/lib/x86_64-linux-gnu -L/usr/lib/../lib -L/usr/lib/gcc/x86_64-linux-gnu/11/../../.. /tmp/cc5o7Jgg.o /tmp/ccUs2R16.o -lgcc --push-state --as-needed -lgcc_s --pop-state -lc -lgcc --push-state --as-needed -lgcc_s --pop-state /usr/lib/gcc/x86_64-linux-gnu/11/crtendS.o /usr/lib/gcc/x86_64-linux-gnu/11/../../../x86_64-linux-gnu/crtn.o
 COLLECT_GCC_OPTIONS='-v' '-mtune=generic' '-march=x86-64' '-dumpdir' 'a.'

バイナリファイルの種類

実行可能ファイルa.outに関連するバイナリファイルには 以下の4種類があります:

  • オブジェクトファイル(*.o)
  • 実行可能ファイル(a.out)
  • 静的ライブラリファイル(lib*.a)
  • 動的ライブラリファイル(lib*.so)

オブジェクトファイル(*.o)

オブジェクトファイルとはLinuxでファイル名の拡張子が.oなファイルです. オブジェクトファイルは機械語命令を含んでいますが, このオブジェクトファイル単体では実行することができません. 実行を可能にするにはリンク(link)処理を経て, 実行可能ファイル を作成する必要があります.

オブジェクトファイルは再配置可能オブジェクトファイル (relocatable object file)と呼ばれることもあります. オブジェクトファイルはリンク時に再配置(アドレス調整)が可能だからです.

実行可能ファイル(a.out)

実行可能ファイル(executable file)はその名前の通り,OSに実行を依頼すればそのままで実行できるバイナリファイルのことです. 例えば,hello wordの実行可能ファイルa.outはシェル上で以下のように実行できます.

$ ./a.out
hello, world
シェルとは

シェル (shell)とは「ユーザが入力したコマンドを解釈実行するプログラム」です. 例えば,bash, zsh, csh, sh, ksh, tcshなどはすべてシェルです. Linux上ではユーザが自由にどのシェルを使うかを選ぶことができます. シェルという名前は(OSの実体をカーネル(核)と呼ぶのに対して) シェルがユーザに最も近い位置,つまりコンピュータシステムの外殻にあることに 由来してます(シェルの英語の意味は貝殻の殻(から)です). シェルは,ユーザが指定したa.outなどのプログラムの実行を, システムコールexecve等を使ってOS(カーネル)に依頼します.

ちなみにターミナル (端末,terminal),あるいはターミナルエミュレータは, ユーザの入出力処理を行うプログラムであり,ターミナル上でシェルは動作しています.

lsなどのシェル上で実行可能なコマンドも実行可能ファイルです.

$ which ls
/usr/bin/ls
$ file /usr/bin/ls
/usr/bin/ls: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, ❶interpreter /lib64/ld-linux-x86-64.so.2, ❷BuildID[sha1]=2f15ad836be3339dec0e2e6a3c637e08e48aacbd, for GNU/Linux 3.2.0, stripped
$ ls
a.out add5.c add5.o add5.s

❶interpreterとは

ELFバイナリの動的リンカのことを(なぜか)interpreterと呼びます. プログラミング言語処理系のインタプリタとは何の関係もありません. ELFバイナリでは動的リンカのフルパスを指定することができ, そのフルパス名をバイナリに埋め込みます. この場合は /lib64/ld-linux-x86-64.so.2 が埋め込まれています. OSがa.outを実行する際に, OSはまず動的リンカ(interpreter)をメモリにロードして, ロードした動的リンカに制御を渡します. 動的リンカはa.out中の他の部分や,動的ライブラリをメモリにロードし, 動的リンクを行ってから,a.outエントリポイント (最初に実行を開始するアドレス)にジャンプします. その後,いくつかの初期化を行ってから,main関数が呼び出されます.

a.outのエントリポイントはreadelf -hコマンドで確認できます. エントリポイントは0x401050番地でした❶.

$ readelf -h ./a.out
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              EXEC (Executable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
❶Entry point address:               0x401050
  Start of program headers:          64 (bytes into file)
  Start of section headers:          16832 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         13
  Size of section headers:           64 (bytes)
  Number of section headers:         36
  Section header string table index: 35

逆アセンブルすると0x401050番地は_startという関数がありました❷. a.out_start関数から実行が始まることが分かりました.

$ objdump -d ./a.out | egrep 401050 -A 5
0000000000401050 ❷ <_start>:
  401050:	f3 0f 1e fa          	endbr64 
  401054:	31 ed                	xor    %ebp,%ebp
  401056:	49 89 d1             	mov    %rdx,%r9
  401059:	5e                   	pop    %rsi
  40105a:	48 89 e2             	mov    %rsp,%rdx
  40105d:	48 83 e4 f0          	and    $0xfffffffffffffff0,%rsp

❷BuildID[sha1]とは

BuildIDはバイナリファイルが同じかどうかを識別するユニークな番号(背番号)です. ここでは2f15で始まる40桁の16進数が /usr/bin/lsのBuildIDです. BuildIDはLinux ELF特有の機能です. stripしてもBuildIDは変化しないので,strip前後のファイルが同じかの確認に使えます.

$ gcc hello.c
$ cp a.out a.out.stripped
$ strip a.out.stripped
$ file a.out a.out.stripped
a.out:          ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=308260da4f7fb6d4116c12670adf6e503637abba, for GNU/Linux 3.2.0, not stripped
a.out.stripped: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=308260da4f7fb6d4116c12670adf6e503637abba, for GNU/Linux 3.2.0, stripped

ここでは説明しませんがコアファイル (core file)にもBuildIDが入っており, そのコアファイルを出力したa.outを探すことができます.

ちなみにsha1はSHA-1を意味しており,SHA-1は160ビットのハッシュを生成するハッシュ関数です. gitのハッシュはSHA-1を使っています. sha1sumコマンドでSHA-1のハッシュを計算できます.

$ sha1sum ./a.out
ff99525ad6a48d78d35d3108401af935a6ca9bbe  ./a.out

この結果から分かる通り,BuildIDのハッシュは,単純にa.outから作ったハッシュ値ではありません. ELFバイナリのヘッダとセクションの一部からハッシュを計算しているようですが,正確な情報は見つかりませんでした(どうやら未公開のようです).

実行可能なコマンドには実行可能ファイルではなく, スクリプトなことがあります.

$ which shasum
/usr/bin/shasum
$ file /usr/bin/shasum
/usr/bin/shasum: Perl script text executable
$ head -3 /usr/bin/shasum
#!/usr/bin/perl
    eval 'exec /usr/bin/perl -S $0 ${1+"$@"}'
	if 0; # ^ Run only under a shell

shasumコマンドは(実行可能ファイルではなく)Perlスクリプトでした.

静的ライブラリ(lib*.a)

静的ライブラリ(static library)は静的リンク するときに使われるライブラリです. ライブラリとは複数のオブジェクトファイルを1つのファイルにまとめたもの(アーカイブ)です.

LinuxなどのUNIX系のOSでは静的ライブラリのファイル拡張子は.aが多いです. またWindowsでは.libです. printfの実体が入っているC標準ライブラリの 静的ライブラリのファイル名はlibc.aです.

動的ライブラリ(lib*.so)

動的ライブラリ(dynamic library)は動的リンク するときに使われるライブラリです. 動的ライブラリは共有ライブラリ(shared library)とも呼ばれます. 動的ライブラリは複数のプロセスからメモリ上で共有されるからです.

Linuxでは動的ライブラリのファイル拡張子は.soです(shared objectの略). 処理系の都合でファイル拡張子に数字がつくことがあります(例:.so.6). 動的ライブラリのファイル拡張子はUnix系のOSでも様々です. Windowsでは.dllです.

静的リンクと動的リンク

静的ライブラリは静的リンクに使われるライブラリで, 動的ライブラリは動的リンクに使われるライブラリです.

静的リンク

静的リンクとはコンパイル時にリンクを行う手法です. 仕組みは単純ですが,ファイルやメモリの使用量が増える欠点があります. この図で説明したリンクは実は静的リンクでした.

静的リンクしたファイルa.outはリンク済みなので, ライブラリ関数(例えばprintf)の実体もa.outの中に入っています.

a.outごとにprintfのコピーが作られるので, ファイルの使用量が無駄に増えてしまいます. またa.out中のprintfは実行時にもメモリ上で共有されないので, メモリの使用量も無駄に増えてしまいます.

静的リンクでコンパイルしてみる

// hello.c
#include <stdio.h>
int main (int ac, char **ag)
{
    printf ("hello (%d)\n", ac);
}

静的リンクするには-staticオプションをつけます(-static無しだと動的リンクになります). printfに第2引数を与えているのは,こうしないと,コンパイラが勝手に printfの呼び出しをputsに変更してしまうことがあるからです.

a.outfileコマンドで確認するとstatically linkedとあり❶, 静的リンクできたことが分かります.

$ gcc -static hello.c
$ file ./a.out
./a.out: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), ❶statically linked, BuildID[sha1]=40fe6c0daaf2d49fabad4d37bc34fcdd12cb8da9, for GNU/Linux 3.2.0, not stripped
$ ./a.out
hello (1)
練習問題:静的にリンクしたa.out中にprintfの実体があることを確認せよ

a.outを逆アセンブルし,❶<main>:を含む行から15行を表示させます. (❷-A 14は「マッチした行の後ろ14行も表示する」というオプションです). main関数は(printfではなく)❸_IO_printfを呼び出していることを確認できます.

$ objdump -d ./a.out | egrep ❷-A 14 ❶"<main>:"
0000000000401cb5 <main>:
  401cb5:	f3 0f 1e fa          	endbr64 
  401cb9:	55                   	push   %rbp
  401cba:	48 89 e5             	mov    %rsp,%rbp
  401cbd:	48 83 ec 10          	sub    $0x10,%rsp
  401cc1:	89 7d fc             	mov    %edi,-0x4(%rbp)
  401cc4:	48 89 75 f0          	mov    %rsi,-0x10(%rbp)
  401cc8:	8b 45 fc             	mov    -0x4(%rbp),%eax
  401ccb:	89 c6                	mov    %eax,%esi
  401ccd:	48 8d 3d 30 33 09 00 	lea    0x93330(%rip),%rdi        # 495004 <_IO_stdin_used+0x4>
  401cd4:	b8 00 00 00 00       	mov    $0x0,%eax
  401cd9:	e8 72 ec 00 00       	callq  410950 ❸<_IO_printf>
  401cde:	b8 00 00 00 00       	mov    $0x0,%eax
  401ce3:	c9                   	leaveq 
  401ce4:	c3                   	retq   

注:ここではegrep -A 14としてますが,皆さんが試す時は,

$ objdump -d ./a.out | less

としてから,/<main>:とリターンを入力して検索する方が便利でしょう.

次に同じくa.outを逆アセンブルし,`<_IO_printf>:'を含む行から数行を表示させます.

$ objdump -d ./a.out | egrep -A 5 "<_IO_printf>:"
0000000000410950 <_IO_printf>:
  410950:	f3 0f 1e fa          	endbr64 
  410954:	48 81 ec d8 00 00 00 	sub    $0xd8,%rsp
  41095b:	49 89 fa             	mov    %rdi,%r10
  41095e:	48 89 74 24 28       	mov    %rsi,0x28(%rsp)
  410963:	48 89 54 24 30       	mov    %rdx,0x30(%rsp)

これは_IO_printfの定義なので,a.outprintfの実体があることを確認できました. なお,以下のnmコマンドでも,a.outprintfの実体があることを確認できます.

$ nm ./a.out | egrep _IO_printf
0000000000410950 T _IO_printf

実は_IO_printfprintfも実体は同じです.処理系の都合で, 「実体は同じだけど別の名前をつける」ことがあり,それをエイリアス(別名)といいます. 0x410950番地で調べると,これを確認できます.

$ nm ./a.out | egrep 410950
0000000000410950 T _IO_printf
0000000000410950 T __printf
0000000000410950 T printf

動的リンク

動的リンクとは実行を始める際のロード時a.outをメモリにコピーする時) あるいは実行途中にメモリ上でリンクを行う手法です. 現在ではファイルやメモリの消費量を抑えるため,デフォルトで動的リンクが使われることが多いです.

動的リンクしたファイルa.outには 「ライブラリ関数(例えばprintf)とのリンクが必要だよ」という 小さな参照情報だけが入っており,printfの実体は入っていません. 実際のリンクは実行時にメモリ上で行います.

a.outにはprintfを含まないので,ファイルの使用量を抑えられます. またa.out中のprintfは実行時にはメモリ上で共有されるので, メモリの使用量も抑えられます.

ファイルサイズを比較してみると,静的リンクしたa.out-staticは約870KB, 動的リンクしたa.out-dynamicは約17KBで,50倍ものサイズ差がありました.

$ gcc -static -o a.out-static hello.c
$ gcc -o a.out-dynamic hello.c
$ ls -l a.out*
-rwxrwxr-x 1 gondow gondow  16696 Jul 20 17:52 a.out-dynamic
-rwxrwxr-x 1 gondow gondow 871832 Jul 20 17:51 a.out-static

動的リンクでコンパイルしてみる

Linuxでは-staticオプションをつけなければ動的リンクになります.

$ gcc hello.c
$ file a.out
a.out: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=308260da4f7fb6d4116c12670adf6e503637abba, for GNU/Linux 3.2.0, not stripped
$ ./a.out
hello (1)

実行時にリンクが必要な動的ライブラリの情報はlddコマンドで表示できます.

$ ldd ./a.out
	❶linux-vdso.so.1 (0x00007ffd21638000)
	❷libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fcfef5c1000)
	❸/lib64/ld-linux-x86-64.so.2 (0x00007fcfef7d8000)

このa.outlinux-vsdo.so.1libc.so.6ld-linux-x86-64.so.2という 3つの動的ライブラリと実行時にリンクする必要があることを表示しています. libc.so.6は(LD_LIBRARY_PATHなどの設定がなければ) 絶対パス/lib/x86_64-linux-gnu/libc.so.6とリンクされます.

❶linux-vdso.so.1とは

vDSO (virtual dynamic shared objectの略)で,カーネル空間で実行する必要が無い システムコール(例えばgettimeofday)を高速に実行するための仕組みです.

❷libc.so.6とは

C標準ライブラリが入った動的ライブラリです. nm -Dコマンドで調べると,printfの実体が入っていることが分かります. (-Dは共有ライブラリで使われる動的シンボルを表示させるオプションです)

$ nm -D /lib/x86_64-linux-gnu/libc.so.6 | egrep ' T printf'
0000000000061c90 T printf
0000000000061100 T printf_size
0000000000061bb0 T printf_size_info

-Dオプションをつけないと「❶シンボルが無いよ」と言われてしまいます. (動的シンボル以外はstripされているからです)

$ nm /lib/x86_64-linux-gnu/libc.so.6
nm: /lib/x86_64-linux-gnu/libc.so.6: ❶no symbols
❸ld-linux-x86-64.so.2とは

動的リンクを行うプログラム(共有ライブラリ),つまり動的リンカです. interpreterとはも参照下さい.

LD_LIBRARY_PATHとは

a.out実行時には, 動的リンカは動的ライブラリをある手順に従って検索します(詳細はman ld). 通常はデフォルトのパス(/lib/usr/libなど)にある動的ライブラリを使いますが, 環境変数LD_LIBRARY_PATHにディレクトリ(複数ある場合は コロン:で区切る)をセットすることで検索パスを追加できます. 具体的には, 動的リンカはLD_LIBRARY_PATHで指定したディレクトリを (デフォルトの検索パスよりも先に)検索し, そこにある動的ライブラリを優先的に使います. (LD_RUN_PATHも参照下さい).

練習問題:動的にリンクしたa.out中にprintfの実体が無いことを確認せよ

nmコマンドでa.outにはmainを始めごく少数の 関数しか定義しておらず,その中にprintfは入っていないことが以下で確認できます.

$ nm ./a.out | egrep ' T '
00000000000011f8 T _fini
00000000000011f0 T __libc_csu_fini
0000000000001180 T __libc_csu_init
0000000000001149 T main
0000000000001060 T _start

またnmの出力をprintfで検索すると,GLIBC中のprintfへの参照はあるが a.out中では未定義(U)となっていることが分かります.

$ nm ./a.out | egrep 'printf'
                 U printf@@GLIBC_2.34

なお逆アセンブルすると<printf@plt>という小さな関数が見つかりますが, これはprintfの実体ではありません.

$ objdump -d ./a.out | egrep -A 5 "<printf"
0000000000001050 <printf@plt>:
    1050:	f3 0f 1e fa          	endbr64 
    1054:	f2 ff 25 75 2f 00 00 	bnd jmpq ❶*0x2f75(%rip)        # 3fd0 <printf@GLIBC_2.34>
    105b:	0f 1f 44 00 00       	nopl   0x0(%rax,%rax,1)

<printf@plt>printfを呼び出す単なる踏み台で, PLT (procedure linkage table)という仕組みです. PLTはprintfの最初の呼び出しまでprintfアドレス解決 (address resolution)を遅延します.具体的には次の2ステップになります.

  • printf@pltの間接ジャンプ先❶の初期値は「動的リンクする関数(動的リンカ)」になっているため,最初にprintf@pltが呼ばれると,動的リンクを行い,その結果,間接ジャンプ先が「printfの実体」に変更されます❷. そして動的リンカは何もなかったかのようにprintfを呼び出します. (ちなみにprintf@pltの間接ジャンプで参照するメモリ領域は GOT (global offset table)と呼ばれます)
  • その結果,2回目以降の以下の間接ジャンプ❶では(動的リンカを経由せずに)printfが呼ばれます.

つまり,GOTにprintfのアドレスを格納することが,ここではアドレス解決になっています.

静的ライブラリを作成してみる

// main.c
#include <stdio.h>
int add5 (int n);
int main (void)
{
    printf ("%d\n", add5 (10));
}

// add5.c
int add5 (int n)
{
    return n + 5;
}

$ gcc -c add5.c   
$ ar rcs libadd5.a add5.o  ❶
$ ar t libadd5.a
add5.o  ❷
$ file libadd5.a
libadd5.a: current ar archive
$ gcc ❸-static -o a.out-static main.c ❹-L. ❺-ladd5
$ file a.out-static
file ./a.out-static
./a.out-static: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, BuildID[sha1]=1bf84a77504302513d6219e4b27316309d08ed2d, for GNU/Linux 3.2.0, not stripped
$ ./a.out-static 
15 ❻
  • ar rcsコマンドでadd5.oからlibadd5.aを作成します.
  • ar tコマンドでlibadd5.aの中身を調べます.中身はadd5.oだけでした.
  • ❸❹❺ gccmain.clibadd5.aを静的リンクします. 静的リンクするために❸-staticオプションが必要です. libadd5.aがカレントディレクトリにあることを伝えるために❹-L.が必要です. 静的リンクする静的ライブラリがlibadd5.aであることを伝えるために ❺-ladd5が必要です.(前のlibと後の.aは自動的に付加されます)
  • ❻ 実行してみると,静的ライブラリlibadd5.a中のadd5関数を呼び出せました.

動的ライブラリを作成してみる

add5.cmain.cは 前節と同じものを使います.

$ gcc -c add5.c   
$ gcc ❶-fPIC ❷-shared -o libadd5.so add5.o
$ file libadd5.so
libadd5.so: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, BuildID[sha1]=415ef51f32145b59c51e836a25959f0f66039768, not stripped
$ gcc main.c -ladd5 -L. ❸-Wl,-rpath .
$ file ./a.out
./a.out: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=a5d4f8ef61cef4e0b063376333f07170d312c546, for GNU/Linux 3.2.0, not stripped
$ ldd ./a.out
	linux-vdso.so.1 (0x00007ffff7fcd000)
	libadd5.so => ❹./libadd5.so (0x00007ffff7fbd000)
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007ffff7dad000)
	/lib64/ld-linux-x86-64.so.2 (0x00007ffff7fcf000)
$ ./a.out
15 ❺ 
  • ❶❷ add5.cから動的ライブラリlibadd5.soを作ります. libadd5.so位置独立コード(PIC)にするために,❶-fPICが必要です. libadd5.so共有オブジェクト(shared object)にするために,❷-sharedが必要です.
  • gccmain.clibadd5.soを動的リンクします. 実行時に動的ライブラリを探索するパスを❸-Wl,-rpath .で指定しています. ここではlibadd5.soをカレントディレクトリに置いているためです. (セキュリティ上,実際に使う際は絶対パスを指定する方が安全でしょう). ちなみに-Wl,-rpath .gccに指定すると, ldコマンド-rpath .というオプションが渡されます .
  • lddコマンドで調べると,a.out中のlibadd5.so./libadd5.soを参照していることを確認できました.
  • ❺ 実行してみると,動的ライブラリlibadd5.so中のadd5関数を呼び出せました.
-rpath,LD_RUN_PATH,LD_LIBRARY_PATH

-Wl,-rpath .はコンパイル時に「動的ライブラリの検索パス」をa.out中に埋め込みます. 以下のコマンド等で確認できます(❻の部分).

$ readelf -d ./a.out | egrep PATH
 0x000000000000001d (RUNPATH)            Library runpath: ❻[.]

-Wl,-rpath .で指定する検索パスは環境変数LD_RUN_PATHでも指定できます. (複数の検索パスはコロン:で区切ります).

$ export LD_RUN_PATH="."
$ gcc main.c -ladd5 -L. 
$ readelf -d ./a.out | egrep PATH
 0x000000000000001d (RUNPATH)            Library runpath: [.]

LD_LIBRARY_PATHを使うと, a.out中の検索パス以外の動的ライブラリを実行時に動的リンクできます. 例えば,以下でlddコマンドを使うと, /tmp/libadd5.soが使われることを確認できます❼.

$ export LD_LIBRARY_PATH="/tmp"
$ cp libadd5.so /tmp
$ ldd ./a.out
	libadd5.so => ❼/tmp/libadd5.so (0x00007ffffffb8000)
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fffffd8b000)
	/lib64/ld-linux-x86-64.so.2 (0x00007ffffffc4000)

なおLD_LIBRARY_PATHは危険で強力なので,なるべく使うのは避けるべきです. 使う場合は最新の注意を払って使いましょう. なぜならば,例えば,/tmp/libc.so.6という悪意のある動的ライブラリがあると, /tmp/libc.so.6中のprintfが呼び出されてしまうからです. (このprintfの中身はコンピュータウイルスかも知れません)

位置独立コードとは

位置独立コード(position independent code, PIC)とはメモリ上の どこにロードしても,そのまま実行できるコードです. 位置独立コードでは絶対アドレスは使わず(再配置が必要になってしまうから), 相対アドレスか間接アドレス参照だけを使います. 位置独立コードにすることで,メモリ上で動的ライブラリを共有できるため, メモリ使用量を抑えることができます.

デバッグ情報

デバッグ情報とは

デバッグ情報とはgcc-gオプションをつけると バイナリに付加される情報で, デバッグ時に有用なソースコード中の情報を含んでいます. 例えば,変数の型情報や,ソースコード中の行番号が挙げられます.

$ gcc ❶ -g main.c add5.c
$ file ./a.out
./a.out: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=68a01f5977ae542600062913c447a7ba7f2fad62, for GNU/Linux 3.2.0, ❷ with debug_info, not stripped

-gオプションをつけてコンパイルしてから,fileコマンドで調べると, ❷デバッグ情報が含まれていることを確認できます.

コンパイラは様々なデバッグ情報の形式を扱えます. LinuxのELFバイナリではDWARFデバッグ情報 が使われることが多いです.(以下,DWARFを前提として説明します)

デバッグ情報が無いと,デバッガでファイル名や行番号が表示されない

デバッグ情報無しでデバッガgdbを使うとどうなるか試してみましょう. add5.cmain.cは 前節と同じものを使います. (gdbの使い方の詳細はデバッガgdbの使い方を参照下さい).

$ gcc ❶ main.c add5.c
$ gdb ./a.out
(gdb) ❷ b add5
Breakpoint 1 at 0x1175
(gdb) ❸ r
Starting program: /tmp/a.out 
Breakpoint 1, 0x0000555555555175 in ❹ add5 ()
(gdb) bt
#0 ❻ 0x0000555555555175 in ❺ add5 ()
#1    0x000055555555515b in    main ()
(gdb) quit

-gオプション無しで❶コンパイルしています. add5関数にブレークポイントを設定❷します. ブレークポイントとはプログラムの実行を一時的に停止する場所です. 関数名add5でブレークポイントを指定したので, 実行するとadd5関数の先頭で実行が一時停止します.

❸ runコマンド (rはrunコマンドの省略形)で実行した所, add5関数でブレーク(実行を一時停止)できたのですが, 関数名add5だけが表示され,ファイル名や行番号が表示されません❹. バックトレースを出力しても同様です❺.

バックトレースとは「main関数から現在実行中の関数までの, 関数呼び出し系列」のことです. ここではmain関数がadd5関数を呼び出しただけなので, バックトレースは2行しかありません. ❻0x0000555555555175add5関数が 0x0000555555555175番地の機械語命令を実行する直前で実行を停止していることを 示しています.

デバッグ情報があると,デバッガでファイル名や行番号が表示される

今回はデバッグ情報ありでデバッガを使ってみます.

$ gcc ❶ -g main.c add5.c
$ gdb ./a.out
(gdb) b add5
Breakpoint 1 at 0x1175: ❷ file add5.c, line 2.
(gdb) r
Starting program: /tmp/a.out 
Breakpoint 1, add5 (n=10) at ❸ add5.c:2
2	{
(gdb) bt
#0  add5 (n=10) at ❹ add5.c:2
#1  0x000055555555515b in main () at main.c:5

$ gcc ❶ -g main.c add5.c
$ gdb ./a.out
(gdb) b add5
Breakpoint 1 at 0x1183: ❷ file add5.c, line 3.
(gdb) r
Breakpoint 1, add5 (n=10) at ❸ add5.c:3
3	    return n + 5;
(gdb) bt
#0  add5 (n=10) at ❹ add5.c:3
#1  0x000055555555515b in main () at main.c:5
  • -gをつけたので,a.outにはデバッグ情報が付加されています.
  • 先程とは異なり,❷❸❹ファイル名add5.cや行番号3が付加されています.

デバッグ情報があると,行番号とアドレスを相互変換できる.

アドレス→行番号の変換

デバッグ情報があるバイナリに対しては, addr2lineコマンドでアドレスを対応する行番号に変換できます.

$ gcc -g main.c add5.c
$ objdump -d ./a.out | egrep -A 4 "<main>:"
0000000000001149 <main>:
    1149:	f3 0f 1e fa          	endbr64 
    114d:	55                   	push   %rbp
    114e:	48 89 e5             	mov    %rsp,%rbp
    1151:	bf 0a 00 00 00       	mov    $0xa,%edi
$  addr2line -e ./a.out ❶ 0x1149
❷/tmp/main.c:4

上の実行例ではaddr2lineコマンドで, 0x1149番地の機械語命令はソースコードでは❷/tmp/main.cの4行目に 対応していることが分かりました.

デバッガ上でも確かめてみましょう.

(gdb) b main
Breakpoint 1 at 0x1151: file main.c, line 5.
(gdb) r
Breakpoint 1, main () at main.c:5
5	    printf ("%d\n", add5 (10));
(gdb) ❶ disas
Dump of assembler code for function main:
   0x0000555555555149 <+0>:	endbr64 
   0x000055555555514d <+4>:	push   %rbp
   0x000055555555514e <+5>:	mov    %rsp,%rbp
=> 0x0000555555555151 <+8>:	mov    $0xa,%edi
   0x0000555555555156 <+13>:	call   0x555555555178 <add5>
(以下略)
(gdb) ❷ info line *0x0000555555555149 
Line 4 of "main.c" starts at address 0x555555555149 <main>
   and ends at 0x555555555151 <main+8>.
(gdb) ❸ info line main.c:4
Line 4 of "main.c" starts at address 0x555555555149 <main>
   and ends at 0x555555555151 <main+8>.
  • (objdumpコマンドでも可能ですが) gdb上でも逆アセンブルできます. 逆アセンブルのコマンドはdisassembleですが長いので, 短縮名disasをここでは使っています. (gdbは他のコマンドと区別できる範囲で,コマンド名を省略できます). ASLRとPIEが有効な場合, デバッガ上で逆アセンブルすると,実際のメモリのアドレスが表示されて便利です. この場合,では0x1149番地だったのに, 0x0000555555555149番地に変わっています.
  • gdbの❷info lineコマンドを使うと,アドレスから行番号に変換できます. 0x555555555149番地はmain.cの4行目に対応しており, また,この行は機械語命令では0x555555555149番地から0x555555555151に 対応していると表示されています.
  • gdb上では❸info lineコマンドを使って, 行番号からアドレスへの変換もできます.

なお,gdblayout asmとすると逆アセンブル結果を常に表示できます. ブレークポイント(左端のbB)や次に実行する機械語命令の位置(>)が 表示されて分かりやすいです.

B+ってどういう意味
  • Bは少なくても一度はブレークしたブレークポイント
  • bは一度もブレークしていないブレークポイント
  • +は有効化されているブレークポイント
  • -は無効化されているブレークポイント

行番号→アドレスの変換

コマンドライン上で,行番号をアドレスに変換するには (コマンドがちょっと長くなりますが)以下のようにgdbを使います.

$ gdb ./a.out -ex "info line main.c:4" --batch
Line 4 of "main.c" starts at address ❶0x1149 <main> and ends at 0x1151 <main+8>.

上ではプログラムを実行せずにアドレスを取得したので, a.outファイル中のアドレス❶0x1149が表示されています. 実行時のアドレスを表示したいなら,以下のようにします (バッチモードで,b mainruninfo line main.c:4という3つのコマンドを実行しています). 実行時のアドレス❷0x555555555149を表示できました.

$ gdb ./a.out -ex "b main" -ex "r" -ex "info line main.c:4" --batch
Breakpoint 1, main () at main.c:5
5	    printf ("%d\n", add5 (10));
Line 4 of "main.c" starts at address ❷0x555555555149 <main> and ends at 0x555555555151 <main+8>.

以下のようにline2addrなどの名前でシェル関数を定義すれば, 短く書けます(が,そんなに頻繁には使わないかも).

$ function line2addr () {
> command gdb $1 -ex "info line $2" --batch
> }
$ line2addr ./a.out main.c:4
Line 4 of "main.c" starts at address 0x1149 <main> and ends at 0x1151 <main+8>.

デバッグ情報があると,逆アセンブル時にソースコードも表示できる

デバッグ情報がある場合, (objdump -dではなく)objdump -Sで逆アセンブルすると ソースコードも表示できます. ❶関数add5の定義部分であること, ❷return n + 5;の行のコンパイル結果であること, などが見やすくなります.

$ gcc -g -c add5.c
$ objdump -S ./add5.o
./add5.o:     file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <add5>:
❶ int add5 (int n)
{
   0:	f3 0f 1e fa          	endbr64 
   4:	55                   	push   %rbp
   5:	48 89 e5             	mov    %rsp,%rbp
   8:	89 7d fc             	mov    %edi,-0x4(%rbp)
 ❷ return n + 5;
   b:	8b 45 fc             	mov    -0x4(%rbp),%eax
   e:	83 c0 05             	add    $0x5,%eax
}
  11:	5d                   	pop    %rbp
  12:	c3                   	ret    

デバッガでレジスタの値を確認する

デバッガでレジスタの値を確認できます.

$ gcc -g main.c add5.c
$ gdb ./aout
(gdb) b add5
Breakpoint 1 at 0x1183: file add5.c, line 3.
(gdb) r
Breakpoint 1, add5 (n=10) at add5.c:3
3	    return n + 5;
(gdb) p ❶ $rdi
$1 = 10
(gdb) ❷ info reg
Undefined info command: "regs".  Try "help info".
(gdb) info reg
rax            0x555555555149      93824992235849
rbx            0x0                 0
rcx            0x555555557dc0      93824992247232
rdx            0x7fffffffe048      140737488347208
rsi            0x7fffffffe038      140737488347192
rdi            0xa                 10
(以下略,qを押して表示を停止)
  • gdbでは%ではなく$をつけてレジスタ名を指定します. pprintコマンドの省略名です.%rdiの値が10であることが分かりました. 16進数で表示したい場合は,p/x $rdi/xをつけます
  • ❷ レジスタの値一覧はinfo regで表示できます.ページャが起動されるので,qを押して表示を停止します.

gdblayout regsとすると,レジスタの値を常に表示できます.

  • layout regsするとレジスタの値一覧が表示されます. 上から「レジスタ表示」「ソースコード表示」「コマンド入力」のためのウィンドウです.
  • focus regsや,ctrl-x oなどを入力すると,レジスタ表示ウィンドウが選択されます. この状態で↓キーを押すと(あるいはマウスでスクロールされると) レジスタ表示ウィンドウの表示をスクロールできます.
  • ctrl-x aを入力すると,元の表示方法に戻ります.

デバッガでメモリの値を確認する

add5.cmain.cを を実行し,add5関数のスタックフレームが作成された直後は 以下の図(ここで使った図の再掲) になっています.

これをデバッガで確認しましょう.

$ gcc -g main.c add5.c
$ gdb ./a.out
(gdb) b add5
Breakpoint 1 at 0x1183: file add5.c, line 3.
(gdb) r
Breakpoint 1, add5 (n=10) at add5.c:3
3	    return n + 5;
(gdb) disas
Dump of assembler code for function add5:
   0x0000555555555178 <+0>:	endbr64 
   0x000055555555517c <+4>:	push   %rbp
   0x000055555555517d <+5>:	mov    %rsp,%rbp
   0x0000555555555180 <+8>:	mov    %edi,-0x4(%rbp)
=> 0x0000555555555183 <+11>:	mov    -0x4(%rbp),%eax
   0x0000555555555186 <+14>:	add    $0x5,%eax
   0x0000555555555189 <+17>:	pop    %rbp
   0x000055555555518a <+18>:	ret    
(gdb) ❶ p/x $rsp
$1 = 0x7fffffffdf10
(gdb) ❷ p/x $rbp
$2 = 0x7fffffffdf10
(gdb) ❸ x/1gx 0x7fffffffdf10
0x7fffffffdf10:	0x00007fffffffdf20
(gdb) ❹ x/1gx $rsp
0x7fffffffdf10:	0x00007fffffffdf20
(gdb) ❺ x/8bx $rsp
0x7fffffffdf10:	0x20 0xdf 0xff 0xff 0xff 0x7f 0x00 0x00
  • ❶❷ %rsp%rbpレジスタの値を調べると,どちらも 0x7fffffffdf10番地でした.

  • x/1gx 0x7fffffffdf10 はメモリの中身を表示するコマンドです.

    • xのコマンド名は examine memory から来ています.
    • /1gxは出力形式を指定しています. この場合は「8バイトのデータを16進表記で1つ表示」という意味です.
xコマンドの表示オプション

xコマンドの表示オプションには以下があります(他にもあります).

  • x 16進数
  • d 符号あり10進数
  • u 符号なし10進数
  • t 2進数
  • c 文字
  • s 文字列

データのサイズ指定には以下があります.

  • b 1バイト (byte)
  • h 2バイト (halfword)
  • w 4バイト (word)
  • g 8バイト (giant)
サイズの用語がバラバラ過ぎる!

以下の通り,GNUアセンブラ(AT&T形式),Intel形式,gdbで各サイズに対する 用語がバラバラです.混乱しやすいので要注意です.

1バイト2バイト4バイト8バイト
GNUアセンブラbyte (b)short (s)long (l)quad (q)
Intel形式byteworddouble word (dword)quad word (qword)
gdbbyte (b)halfword (h)word (w)giant (g)
  • ❹ 具体的なアドレス(ここでは0x7fffffffdf10)ではなく, レジスタ名 (ここでは$rsp)を指定して,  そのレジスタが指しているメモリの中身を表示できます.
  • /1gxではなく/8bxと表示形式を指定すると, 「1バイトのデータを16進表記で8個表示」という意味になります. 0x7FFFFFFFDF10から0x7FFFFFFFDF17までの各番地には,それぞれ, 以下の図の通り, 0x200xDF0xFF0xFF0xFF0x7F0x000x00という値が メモリ中に入っていることが分かります. この格納されている8バイトのデータ0x00007fffffffdf20はアドレスであり, 以下の図の一番下のアドレス(赤字の部分)を指しています.
(上のデバッグの続き)
(gdb) ❻ x/1gx $rsp+8
0x7fffffffdf18:	0x000055555555515b
(gdb) ❼ x/8bx $rsp+8
0x7fffffffdf18:	0x5b	0x51	0x55	0x55	0x55	0x55	0x00	0x00
(gdb) ❽ disas 0x000055555555515b
Dump of assembler code for function main:
   0x0000555555555149 <+0>:	endbr64 
   0x000055555555514d <+4>:	push   %rbp
   0x000055555555514e <+5>:	mov    %rsp,%rbp
   0x0000555555555151 <+8>:	mov    $0xa,%edi
   0x0000555555555156 <+13>:	call   0x555555555178 <add5>
❾ 0x000055555555515b <+18>:	mov    %eax,%esi
   0x000055555555515d <+20>:	lea    0xea0(%rip),%rax        # 0x555555556004
   0x0000555555555164 <+27>:	mov    %rax,%rdi
   0x0000555555555167 <+30>:	mov    $0x0,%eax
   0x000055555555516c <+35>:	call   0x555555555050 <printf@plt>
   0x0000555555555171 <+40>:	mov    $0x0,%eax
   0x0000555555555176 <+45>:	pop    %rbp
   0x0000555555555177 <+46>:	ret    
End of assembler dump.
  • x/1gxを使って,上の図の8(%rsp)のアドレスの中身を表示させています. 8(%rsp)の意味は「%rspの値に8を足したアドレス」です. gdb中では「$rsp + 8」と入力します.
  • x/8bxを使って,上の図の8(%rsp)のアドレスを1バイトごとに表示しました. 上記の図の通り, 0x7FFFFFFFDF18から0x7FFFFFFFDF1Fまでの各番地には,それぞれ, 0x5B0x510x550x550x550x550x000x00が 格納されていることが分かりました.
  • ❻の結果で得た0x000055555555515b番地を使って❽逆アセンブルしてみると, ❾この番地は「call add5」の次の命令 (この場合は mov %eax, %esi)であることが 分かりました. このように,戻り番地 (return address)は通常, 「その関数を呼び出したcall命令の次の命令のアドレス」になります.
戻り番地が通常ではない場合って?

末尾コール最適化 (tail-call optimization; TCO)が起こった時が該当します.

  • 上の「末尾コール最適化の前」の図ではmain関数がAを呼び, 関数ABを呼んでいます.また逆の順番でリターンします. しかし,call Bの次の命令がret (次の命令❷)になっているため, 関数Bからリターンした後,関数Aでは何もせず,mainにリターンしています.
  • そこで「末尾コール最適化の後」の図のように,関数A中のcall命令を 無条件ジャンプ命令 jmpに書き換えて,関数Bからは(Aを経由せず) 直接,main関数のリターンするように書き換えて無駄なリターンを省くことができます. これが末尾コール最適化です.
  • その結果,関数Bのリターンアドレスは,関数A中のcall命令の次のアドレス (次の命令❷)ではなく,関数main中の「次の命令❶」となってしまいました. これが戻り番地が通常ではない場合の一例です.

デバッグ情報を直接見る

objdumpreadelfllvm_dwarfdumpコマンドを使うと, デバッグ情報の中身を直接見ることができます.

objdump -W

デバッグ情報には例えば,以下のものがあります

  • デバッグ情報 (.debug_info)
  • 行情報 (.debug_line)
  • アドレス情報 (.debug_aranges)
  • フレーム情報 (.eh_frame)
  • 省略情報 (.debug_abbrev)

objdump -W add5.o とすると,add5.o中のデバッグ情報を全て表示します -Wi-Wl-Wr-Wf-Waとすると, それぞれ,デバッグ情報,行情報,アドレス情報,フレーム情報, 省略情報だけを表示できます.

$ objdump -W add5.o | less
add5.o:     file format elf64-x86-64

Contents of the .debug_info section:

  Compilation Unit @ offset 0x0:
   Length:        0x62 (32-bit)
   Version:       5
   Unit Type:     DW_UT_compile (1)
   Abbrev Offset: 0x0
   Pointer Size:  8
 <0><c>: Abbrev Number: 1 (DW_TAG_compile_unit)
    <d>   DW_AT_producer    : (indirect string, offset: 0x5): GNU C17 11.3.0 -mtune=generic -march=x86-64 -g -fasynchronous-unwind-tables -fstack-protector-strong -fstack-clash-protection -fcf-protection
    <11>   DW_AT_language    : 29       (C11)
    <12>   DW_AT_name        : (indirect line string, offset: 0x5): add5.c
(以下略)

上記の出力例では例えば「ファイル名add5.cを省略番号1とします」という情報を含んでいます(詳細は省略し. コンパイル単位 (compile unit)とはファイルのことです.

例えば,以下の部分は 仮引数の情報として「変数名は❻n, ❷add5.cの❸1行目❹15カラム目で宣言されていて, 型は❺<0x5e>を見てね.変数の場所は❻(DW_OP_fbreg: -20)」となってます.

<2><50>: Abbrev Number: 3 (DW_TAG_formal_parameter)
    <51>   DW_AT_name        : ❶ n
    <53>   DW_AT_decl_file   : ❷ 1
    <54>   DW_AT_decl_line   : ❸ 1
    <55>   DW_AT_decl_column : ❹ 15
    <56>   DW_AT_type        : ❺ <0x5e>
    <5a>   DW_AT_location    : 2 byte block: 91 6c ❻ (DW_OP_fbreg: -20)
❻DW_OP_fbreg: -20とは

「CFA (canonical frame address)から -20バイトのオフセットの位置」を意味しています. CFAはDWARFデバッグ情報が定める仮想的なレジスタでCPUごとに異なります. x86-64の場合は「call命令を実行する直前の%rspの値」なので,以下になります. (call命令が戻り番地をスタックにプッシュすることを思い出しましょう). 引数n(下図で赤い部分)の先頭アドレスは, CFAからちょうど-20バイトの場所にあることが確認できました.

-fomit-frame-pointerでコンパイルされていなければ, (通常は関数の先頭でpush %rbpするので)以下の式が成り立ちます.

CFA == %rbp + 16

なお,fbreg は frame base registerの略だと思います.

Abbrev Number (省略番号)とは

例えば,以下のDIE(デバッグ情報の部品)で Abbrev Number は ❶4となっています.

$ objdump -Wi add5.o
(一部略)
<1><5e>: Abbrev Number: ❶4 (DW_TAG_base_type)
    <5f>   DW_AT_byte_size   : 4
    <60>   DW_AT_encoding    : 5         (signed)
    <61>   DW_AT_name        : int

objdump -Wa.debug_abbrevを表示すると4番目のエントリは 以下となっています.つまり,

  • ❷4番のAbbrev Number (省略番号)を持つDIEは ❸DW_TAG_base_type である
  • DW_TAG_base_typeには例えば,❹変数名の情報があり,その型は❺DW_FORM_stringである

と分かります.

$ objdump -Wa add5.o
(一部略)
❷4 ❸DW_TAG_base_type    [no children]
    DW_AT_byte_size    DW_FORM_data1
    DW_AT_encoding     DW_FORM_data1
  ❹DW_AT_name       ❺DW_FORM_string
    DW_AT value: 0     DW_FORM value: 0

要するに.debug_abbrevの情報は.debug_infoのメタ情報(型情報)であり, この場合,4という数字を保持するだけで, 「このDIEはDW_TAG_base_typeである.その内容は…(以下略)」 という情報を持てるのです.

これによりサイズの圧縮が可能になっています. objdump -Wはある程度は散っている情報をまとめて表示していて親切です.

LEB128とは

LEB128 (little endian base 128)は任意の大きさの整数を扱える 可変長の符号化方式です.直感的にはLEB128はUTF-8の整数版です.

LEB128はDWARFやWebAssemblyなどで使われています. (ですので,DWARFデバッグ情報にはLEB128の符号化が使われている箇所があります. デバッグ情報の16進ダンプを解析する際は注意しましょう).

LEB128には符号ありと符号なしの2種類がありますが,以下では符号なしで説明します.

ここでは123456を符号なしLEB128形式に変換します. 結果は最下位バイトから,0xC00xC40x07の3バイトになります. まずbcコマンドで2進数にします❶.

$ bc
obase=2
123456
❶ 11110001001000000

次に以下のステップを踏みます.

ステップ4の結果をbcコマンドで16進数にします❷.

$ bc
obase=16
ibase=2
000001111100010011000000
❷ 7C4C0

結果の16進数❷0x7C4C0 を1バイトごとに最下位バイトから出力すると, 最終的な結果は0xC00xC40x07となります. LEB128の最上位バイトの最上位ビットは必ず0で, それ以外のバイトはの最上位ビットは1なので, サイズ情報がなくても, 元の整数に戻す際,どのバイトまで処理すればよいかが分かります.

型の情報<0x5e>は以下にありました. 「サイズは❼ 4バイト,❽符号あり,型名は❾int」です.

<1><5e>: Abbrev Number: 4 (DW_TAG_base_type)
    <5f>   DW_AT_byte_size   : ❼ 4
    <60>   DW_AT_encoding    : 5        ❽ (signed)
    <61>   DW_AT_name        : ❾ int

上記の.debug_info中の情報である, DW_TAG_formal_parameterやDW_TAG_base_typeなどは DIE (debug information entry)というデバッグ情報の単位の1つです. DIEは全体で木構造になっています.

またデバッグ情報情報があちこちに散っています. 例えば,❷「ファイル1」の情報はどこにあるかというと

    <53> ❷ DW_AT_decl_file   : 1

行情報にありました. 以下でエントリ1の情報を見ると,add5.cと分かりました.

$ objdump -Wl add5.o | less
(中略)
The File Name Table (offset 0x2c, lines 2, columns 2):
  Entry Dir     Name
  0     0       (indirect line string, offset: 0x11): add5.c
  1     0       (indirect line string, offset: 0x18): add5.c

readelf

readelfコマンドでもobjdumpと同様にDWARFデバッグ情報を表示できます. 以下は実行例です.

$ readelf -wi ./add5.o
Contents of the .debug_info section:

  Compilation Unit @ offset 0x0:
   Length:        0x62 (32-bit)
   Version:       5
   Unit Type:     DW_UT_compile (1)
   Abbrev Offset: 0x0
   Pointer Size:  8
 <0><c>: Abbrev Number: 1 (DW_TAG_compile_unit)
    <d>   DW_AT_producer    : (indirect string, offset: 0x5): GNU C17 11.3.0 -mtune=generic -march=x86-64 -g -fasynchronous-unwind-tables -fstack-protector-strong -fstack-clash-protection -fcf-protection
    <11>   DW_AT_language    : 29       (C11)
    <12>   DW_AT_name        : (indirect line string, offset: 0x5): add5.c
    <16>   DW_AT_comp_dir    : (indirect line string, offset: 0x0): /tmp
    <1a>   DW_AT_low_pc      : 0x0
    <22>   DW_AT_high_pc     : 0x13
    <2a>   DW_AT_stmt_list   : 0x0
(以下略)

メモリマップを見る

pmapコマンドでメモリマップを見る

pmapコマンドを使うと, 実行中のプログラム(プロセス)がどのメモリ領域を使用しているか (メモリマップ)を調べられます. (この出力は/procファイルシステムの /proc/プロセス番号/mapsの内容から作られています).

$ cat 
❶ ^Z   
[1]+  Stopped                 cat
$ ps | egrep cat
❷  7687 pts/0    00:00:00 cat
$ ❸ pmap 7687
7687:   cat
❼000055f74daf2000      8K r---- cat
❼000055f74daf4000     16K r-x-- cat
❼000055f74daf8000      8K r---- cat
❼000055f74dafa000      4K r---- cat
❼000055f74dafb000      4K rw--- cat
000055f74dafc000    132K rw---   [ anon ]
00007f63a7e00000   6628K r---- locale-archive
00007f63a8600000    160K r---- libc.so.6
00007f63a8628000   1620K r-x-- libc.so.6
00007f63a87bd000    352K r---- libc.so.6
00007f63a8815000     16K r---- libc.so.6
00007f63a8819000      8K rw--- libc.so.6
00007f63a881b000     52K rw---   [ anon ]
00007f63a8829000    148K rw---   [ anon ]
00007f63a885d000      8K rw---   [ anon ]
00007f63a885f000      8K r---- ld-linux-x86-64.so.2
00007f63a8861000    168K r-x-- ld-linux-x86-64.so.2
00007f63a888b000     44K r---- ld-linux-x86-64.so.2
00007f63a8897000      8K r---- ld-linux-x86-64.so.2
00007f63a8899000      8K rw--- ld-linux-x86-64.so.2
❹ 00007fff86f9f000 132K ❺rw---   ❻[ stack ]
00007fff86ff8000     16K r----   [ anon ]
00007fff86ffc000      8K r-x--   [ anon ]
ffffffffff600000      4K --x--   [ anon ]
 total             9560K
$ fg
❽ ^D
  • まず catコマンドを起動します.ファイル名を指定していないので, 標準入力からの入力待ちになります. ここで❶ ctrl-z を入力して,catコマンドの実行を中断 (suspend)します. pmapコマンドは実行中のプロセスにしか実行できないため, catコマンドが実行中のまま終了しないように,こうしています.

  • 次にpsコマンドでcatコマンドのプロセス番号を調べます. ❷7687がプロセス番号と分かりました.

  • ❸プロセス番号7687を引数としてpmapコマンドを実行します.

  • 出力の各行が使用中のメモリ領域の情報を示しています.例えば,❹の行は次を意味しています.

    ❹ 00007fff86f9f000 132K ❺rw--- ❻[ stack ]

    • ❹アドレス`00007fff86f9f000'からサイズ132KBの領域を使用している.
    • このメモリ領域の❻アクセス権限は読み書きが可能で,実行は不可.
      • r 読み込み可能
      • w 書き込み可能
      • x 実行可能
    • このメモリ領域は❻スタックとして使用している
  • catコマンド自身は以下の5つのメモリ領域を使用しています.

    ❼000055f74daf2000      8K r---- cat
    ❼000055f74daf4000     16K r-x-- cat
    ❼000055f74daf8000      8K r---- cat
    ❼000055f74dafa000      4K r---- cat
    ❼000055f74dafb000      4K rw--- cat
    
    • アクセス権限が r-x--のものは,.textセクションでしょう. (.textセクションは通常,実行可能かつ書き込み禁止にするからです)
    • アクセス権限が rw----のものは,.dataセクションでしょう. (.dataセクションは通常,実行禁止かつ書き込み可能にするからです)
    • 残りの3つのアクセス権限が r---- のものは,.rodataセクションなどでしょう. (詳細は調べていません)
    • 使用しているサイズが4KBの倍数なのは,x86-64でよくある ページ(page)サイズが4KBだからです. (ページとは仮想記憶方式の1つであるページングで使われる, 固定長(例えば4KB)に区切ったメモリ領域のことです). プロセスはmmapシステムコールを使って,OSからページ単位でメモリを割り当ててもらい,その際にページごとにアクセス権限を設定できます.
  • 最後に❽で,中断していたcatコマンドをfgコマンドで実行を再開し, ctrl-Dを入力してcatコマンドの実行を終了しています.

gdbでメモリマップを見る

gdbでもメモリマップを見ることができます

$ gdb /usr/bin/cat
(gdb) r
ctrl-z
Program received signal SIGTSTP, Stopped (user).
(gdb) info proc map
process 7821
Mapped address spaces:

          Start Addr           End Addr       Size     Offset  Perms  objfile
      0x555555554000     0x555555556000     0x2000        0x0  r--p   /usr/bin/cat
      0x555555556000     0x55555555a000     0x4000     0x2000 ❶r-xp   /usr/bin/cat
      0x55555555a000     0x55555555c000     0x2000     0x6000  r--p   /usr/bin/cat
      0x55555555c000     0x55555555d000     0x1000     0x7000  r--p   /usr/bin/cat
      0x55555555d000     0x55555555e000     0x1000     0x8000  rw-p   /usr/bin/cat
      0x55555555e000     0x55555557f000    0x21000        0x0  rw-p   [heap]
      0x7ffff7400000     0x7ffff7a79000   0x679000        0x0  r--p   /usr/lib/locale/locale-archive
      0x7ffff7c00000     0x7ffff7c28000    0x28000        0x0  r--p   /usr/lib/x86_64-linux-gnu/libc.so.6
      0x7ffff7c28000     0x7ffff7dbd000   0x195000    0x28000  r-xp   /usr/lib/x86_64-linux-gnu/libc.so.6
      0x7ffff7dbd000     0x7ffff7e15000    0x58000   0x1bd000  r--p   /usr/lib/x86_64-linux-gnu/libc.so.6
      0x7ffff7e15000     0x7ffff7e19000     0x4000   0x214000  r--p   /usr/lib/x86_64-linux-gnu/libc.so.6
      0x7ffff7e19000     0x7ffff7e1b000     0x2000   0x218000  rw-p   /usr/lib/x86_64-linux-gnu/libc.so.6
      0x7ffff7e1b000     0x7ffff7e28000     0xd000        0x0  rw-p   
      0x7ffff7f87000     0x7ffff7fac000    0x25000        0x0  rw-p   
      0x7ffff7fbb000     0x7ffff7fbd000     0x2000        0x0  rw-p   
      0x7ffff7fbd000     0x7ffff7fc1000     0x4000        0x0  r--p   [vvar]
      0x7ffff7fc1000     0x7ffff7fc3000     0x2000        0x0  r-xp   [vdso]
      0x7ffff7fc3000     0x7ffff7fc5000     0x2000        0x0  r--p   /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
      0x7ffff7fc5000     0x7ffff7fef000    0x2a000     0x2000  r-xp   /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
      0x7ffff7fef000     0x7ffff7ffa000     0xb000    0x2c000  r--p   /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
      0x7ffff7ffb000     0x7ffff7ffd000     0x2000    0x37000  r--p   /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
      0x7ffff7ffd000     0x7ffff7fff000     0x2000    0x39000  rw-p   /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
      0x7ffffffde000     0x7ffffffff000    0x21000        0x0  rw-p   [stack]
  0xffffffffff600000 0xffffffffff601000     0x1000        0x0  --xp   [vsyscall]
アクセス権限rwxpの❶pとは

mmapでメモリ領域をマップする際に, フラグとしてMAP_PRIVATEを指定するとpMAP_SHAREDを指定するとsと表示されます.

  • MAP_PRIVATE マップした領域への変更はプロセス間で共有されません. このマップはcopy-on-writeなので,書き込まれるまで自分専用のコピーは発生せず,共有されます. (copy-on-writeとは「書き込みが起こるまでコピーを遅延する」というテクニックです).

  • MAP_SHARED マップした領域への変更はプロセス間で共有されます. すなわちマップした領域に書き込みを行うと, その変更は他のプロセスにも見えます. ただし,msyncを使う必要があります.

.textセクションの共有設定もpとなっています. これは.textセクションもmmapMAP_PRIVATEでマップしているからです. 動的リンクした実行可能ファイルの.textセクションは 物理メモリ上で共有されていますが, その共有とMAP_SHAREDは関係ないのです.

再配置情報

再配置情報の概要

再配置情報(relocation information)とは「後でアドレス調整する時のために, 機械語命令中のどの場所をどんな方法で書き換えればよいか」を表す情報です. オブジェクトファイル*.oは一般的に再配置情報を含んでいます.

// asm/reloc-main.c
#include <stdio.h>
extern int x;
int main ()
{
    printf ("%d\n", x);
}
// asm/reloc-sub.c
int x = 999;

例えば,上のreloc-main.creloc-sub.cを見て下さい. reloc-main.c中で参照している変数xの実体はreloc-main.c中には無く, 実体はreloc-sub.c中にあります.

ですので,reloc-main.s中の movq x(%rip), %eaxをアセンブルしてreloc-main.oを作っても, この時点ではxのアドレスが不明なので,仮のアドレス(上図では00 00 00 00) にするしかありません. そこで,このmovq x(%rip), %eax命令に対する再配置情報として 「この命令の2バイト目から4バイトを4バイト長の%rip相対アドレスで埋める」 という情報(R_X86_64_PC32後述)を reloc-main.o中に保持しておき,リンク時に正しいアドレスを埋め込むのです.

$ gcc -c reloc-main.c
$ gcc -c reloc-sub.c
$ gcc reloc-main.o reloc-sub.o
なんでgccを3回?

通常はgcc reloc-main.c reloc-sub.cと,gccを一回実行して a.outを作ります.が,ここではreloc-main.oの中の再配置情報を 見たいので,わざわざ別々にreloc-main.oreloc-sub.oを作り, 最後にリンクしてa.outを作っています.

reloc-main.oreloc-sub.oをリンクしてa.outを作ると, (様々な*.o中のセクションを一列に並べることで) 変数xのアドレスが0x4010に決まり, 上図の「次の命令」のアドレスも0x1157に決まりました. 仮のアドレスに埋めたかったのは,%rip相対番地でしたので, 0x4010-0x1157=0x2EB9と計算した0x2EB9番地を仮のアドレスの部分に埋めました. これが再配置です.

様々な*.o中のセクションを一列に並べることで,とは

例えば上図でfoo2.o中の変数xのアドレスは仮アドレス0x1000ですが, foo1.ofoo2.o中のセクションを1列に並べると, リンク後は「a.outの先頭アドレスが(例えば)0x4000なので,先頭から数えると, (0x4000 + 0x0500 + 0x1000 = 0x5500という計算をして) 変数xのアドレスは0x5500に決まりますよね」という話です.

objdump -dr で再配置情報を見てみる

$ gcc -g -c reloc-main.c
$ objdump -dr reloc-main.o
./reloc-main.o:     file format elf64-x86-64
Disassembly of section .text:

0000000000000000 <main>:
   0:	f3 0f 1e fa          	endbr64 
   4:	55                   	push   %rbp
   5:	48 89 e5             	mov    %rsp,%rbp
   8:	8b 05 ❶ 00 00 00 00    mov    0x0(%rip),%eax        # e <main+0xe>
		❷ a: R_X86_64_PC32	x-0x4
   e:	89 c6                	mov    %eax,%esi
  10:	48 8d 05 ❸ 00 00 00 00 	lea    0x0(%rip),%rax        # 17 <main+0x17>
		❹ 13: R_X86_64_PC32	.rodata-0x4
  17:	48 89 c7             	mov    %rax,%rdi
  1a:	b8 00 00 00 00       	mov    $0x0,%eax
  1f:	e8 00 00 00 00       	call   24 <main+0x24>
			20: R_X86_64_PLT32	printf-0x4
  24:	b8 00 00 00 00       	mov    $0x0,%eax
  29:	5d                   	pop    %rbp
  2a:	c3                   	ret    

前節の説明を,実際に再配置情報を見ることで確かめます. 上の実行例はobjdump -drreloc-main.oの逆アセンブルの結果と 再配置情報の両方を表示させたものです.

  • ❶を見るとの通り,仮のアドレス 00 00 00 00 を確認できます.
  • ❷のa: R_X86_64_PC32 x-0x4が再配置情報です.
    • aは仮のアドレスを書き換える場所(.textセクションの先頭からのオフセット)です. 命令mov 0x0(%rip), %eaxの先頭のオフセットが0x8なので, 0x82を足した値が0xaとなっています (このmov命令の最初の2バイトはオペコード).
    • R_X86_64_PC32は再配置の方法を表しています. 「%rip相対アドレスで4バイト(32ビット)としてアドレスを埋める」ことを意味しています. (PCはプログラムカウンタ,つまり%ripを使うことを意味しています).
    • x-0x4は「変数xのアドレスを使って埋める値を計算せよ. その際に-0x4を足して調整せよ」を意味しています.
-4はどう使うのか

R_X86_64_PC32System V ABIが 定めており,埋めるアドレスのサイズは4バイト(32ビット), 埋めるアドレスの計算方法はS + A - Pと定めています.

  • S はそのシンボルのアドレス (上の例では0x4010)
  • A は調整用の値 (addend と呼びます.上の例では-4)
  • P は仮アドレスを書き換える場所 (上の例では0x1157 - 4番地)

なので,計算すると

0x4010 + (-4) - (0x1157 - 4) = 0x2EB9

となります.

  • ❸は"%d\n"という文字列の仮アドレス,❹はその仮アドレスの再配置情報です. ❶❷と同様です.

readelf -rで再配置情報を見てみる

$ readelf -r reloc-main.o | less
Relocation section '.rela.text' at offset 0x5b0 contains 3 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
00000000000a  000a00000002 R_X86_64_PC32     0000000000000000 x - 4
000000000013  000300000002 R_X86_64_PC32     0000000000000000 .rodata - 4
000000000020  000b00000004 R_X86_64_PLT32    0000000000000000 printf - 4
(略)

readelf -rでもobjdump -drと同様の結果が得られます.

PLTの再配置情報

printfの再配置情報も見てみましょう.

$ objdump -dr ./reloc-main.o
./reloc-main.o:     file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
   0:   f3 0f 1e fa             endbr64 
   4:   55                      push   %rbp
   5:   48 89 e5                mov    %rsp,%rbp
   8:   8b 05 00 00 00 00       mov    0x0(%rip),%eax        # e <main+0xe>
                        a: R_X86_64_PC32        x-0x4
   e:   89 c6                   mov    %eax,%esi
  10:   48 8d 05 00 00 00 00    lea    0x0(%rip),%rax        # 17 <main+0x17>
                        13: R_X86_64_PC32       .rodata-0x4
  17:   48 89 c7                mov    %rax,%rdi
  1a:   b8 00 00 00 00          mov    $0x0,%eax
  1f:   e8 ❶ 00 00 00 00       call   24 <main+0x24>
             ❷ 20: R_X86_64_PLT32      printf-0x4
  24:   b8 00 00 00 00          mov    $0x0,%eax
  29:   5d                      pop    %rbp
  2a:   c3                      ret    
$ objdump -d ./a.out
0000000000001149 <main>:
    1149:       f3 0f 1e fa             endbr64 
    114d:       55                      push   %rbp
    114e:       48 89 e5                mov    %rsp,%rbp
    1151:       8b 05 b9 2e 00 00       mov    0x2eb9(%rip),%eax        # 4010 <x>
    1157:       89 c6                   mov    %eax,%esi
    1159:       48 8d 05 a4 0e 00 00    lea    0xea4(%rip),%rax        # 2004 <_IO_stdin_used+0x4>
    1160:       48 89 c7                mov    %rax,%rdi
    1163:       b8 00 00 00 00          mov    $0x0,%eax
    1168:     ❸e8 e3 fe ff ff          call   1050 <printf@plt>
    116d:       b8 00 00 00 00          mov    $0x0,%eax
    1172:       5d                      pop    %rbp
    1173:       c3                      ret    

先程のxの場合とほぼ同じです.

  • ❶を見るとの左側の通り,仮のアドレス 00 00 00 00 を確認できます.
  • ❷の20: R_X86_64_PLT32 printf-0x4が再配置情報です.
    • 20は仮のアドレスを書き換える場所(オフセット)です.
    • R_X86_64_PLT32は再配置の方法を表しており 「printf@pltへの%rip相対アドレス (4バイト(32ビット))を埋める」ことを意味しています.
    • printf-0x4は「変数printf@pltのアドレスを使って埋める値を計算せよ. その際に-0x4を足して調整せよ」を意味しています.
-4はどう使うのか

R_X86_64_PLT32System V ABIが 定めており,埋めるアドレスのサイズは4バイト(32ビット), 埋めるアドレスの計算方法はL + A - Pと定めています.

  • L はそのシンボルのPLTエントリのアドレス (上の例ではprintf@pltのアドレス0x1050)
  • A は調整用のaddend (上の例では-4)
  • P は仮アドレスを書き換える場所 (上の例では0x116D - 4番地)

なので,計算すると

0x1050 + (-4) - (0x116D - 4) = -0x11D = 0xFFFFFEE3

となります.

  • a.out中では「次の命令」が0x116D番地,printf@plt0x1050番地と決まったので,0x1050 - 0x116D = -0x11D = 0xFFFFFEE3番地が ❸の部分に埋め込まれました.

ここでも説明した通り, printfの実体はCライブラリの中にあり, (gccのデフォルト動作である)動的リンクの場合, PLTとGOTの仕組みを使って,printfを呼び出します. これは先程のxの場合は 「(main関数中の)次の命令と変数xの相対アドレスは固定で決まる」のに対して, printfの場合は固定で決まらないからです (Cライブラリが実行時に何番地にロードされるか不明だから).

そこで,

  • main関数中では(printfを直接呼ぶのではなく), (printfのための踏み台である)printf@pltを呼び出す.
  • printf@pltはGOT領域に実行時に書き込まれるprintfのアドレスを使い, 間接ジャンプ (上図ではbnd jmp *0x2f75(%rip))して, 本物のprintfを呼び出す.

という仕組みになっています.

ABI と API

ABIとAPIはどちらも互換性のための規格(お約束)ですが, 対象がそれぞれ,バイナリソースコード,と異なります.

ABI

  • ABI = Application Binary Interface
  • バイナリコードのためのインタフェース規格.
  • 同じABIをサポートするシステム上では再コンパイル無しで 同じバイナリを使ったり実行できる.
  • ABIはコーリングコンベンション(関数呼び出し規約, calling convention), バイトオーダ,アラインメント,バイナリ形式などを定める
  • Linux AMD64のABI はSystem V ABI (AMD64)

API

  • API = Application Programming Interface
  • ソースコードのためのインタフェース規格
  • 同じAPIをサポートするシステム上では再コンパイルすれば 同じソースコードを実行できる.
  • 例えば,POSIXはUNIXのAPIであり,LinuxはPOSIXにほぼ準拠している.
    POSIXはシステムコール,ライブラリ関数,マクロなどの形式や意味を定めている
    • POSIXはここに書いてあるとおり,opengroup.orgに登録することで無料で入手可能

アーキテクチャ

一般的なコンピュータの構成要素

コンピュータの基本構造

  • コンピュータでは上図のように,CPUメインメモリ(以後,単にメモリ), 入出力装置が,バス(bus)と呼ばれる信号線でつながっています.

  • CPUは制御装置ALU(演算装置),レジスタから構成されています

  • バスはデジタルの信号線です. アドレスバス,データバス,制御バスがあります(図にはこの区別は書いていません).

  • 上図にはキャッシュMMUなどがありませんが, 本書の範囲ではこの図の知識で十分です.

CPUの基本構成

  • 制御装置 = フェッチ実行サイクルをひたすら繰り返します
  • ALU = 四則演算や論理演算などを計算します
  • レジスタ
    • 高速・小容量・固定長のメモリです
    • 特定の役割を持つ専用レジスタ(例: プログラムカウンタ%rip)と, 様々な用途に使える汎用レジスタ(例: %rax)に概ね分かれています.

メモリ

  • メモリはRAMです.揮発性があります(電源が切れると記憶内容は失われます).
  • メモリは巨大なバイトの配列です
    • メモリのアドレスを指定して,メモリの内容を読み書きします
    • バイト単位だけでなく,4バイトや8バイトなどの連続するメモリ領域も読み書きできます
  • 通常,メモリには1バイトごとに連番のアドレスがつきます これをバイトアドレッシングといいます.

実際の物理アドレスには,RAMだけでなく,ROMや memory-mapped I/O (メモリのアドレスを使ってアクセスする入出力装置,例えばVRAM)も マップされています.ただし,これらは通常はユーザプロセスからは見えないので気にしなくて良いです.

フェッチ実行サイクル

  • CPUは次の動作をひたすら繰り返します

    1. フェッチ(fetch)
    • プログラムカウンタ(%rip)が指す機械語命令を メモリからCPUに読み込みます
    • 次の機械語命令を指すように,プログラムカウンタの値を増やします
    1. デコード(decode)
    • 読み込んだ命令を解析して,実行の準備をします
    • 例えば,必要ならメモリからオペランドの値をCPUに読み込みます
    1. 実行(execute)
    • 読み込んだ機械語命令を実行します

x86-64のレジスタ

汎用レジスタ

  • 上記16個のレジスタが汎用レジスタ(general-purpose register)です. 原則として,プログラマが自由に使えます.
  • ただし,%rspスタックポインタ%rbpベースポインタと呼び, 一番上のスタックフレームの上下を指す という役割があります. (ただし,-fomit-frame-pointer オプションでコンパイルされたa.out中では,%rbpはベースポインタとしてではなく, 汎用レジスタとして使われています).

caller-save/callee-saveレジスタ

汎用レジスタ
caller-saveレジスタ%rax, %rcx, %rdx, %rsi, %rdi, %r8%r11
callee-saveレジスタ%rbx, %rbp, %rsp, %r12%r15

引数

引数レジスタ
第1引数%rdi
第2引数%rsi
第3引数%rdx
第4引数%rcx
第5引数%r8
第6引数%r9

プログラムカウンタ(命令ポインタ)

ステータスレジスタ(フラグレジスタ)

本書で扱うフラグ

ステータスレジスタのうち,本書は以下の6つのフラグを扱います. フラグの値が1になることを「フラグがセットされる」「フラグが立つ」と表現します. またフラグの値が0になることを「フラグがクリアされる」「フラグが消える」と表現します.

フラグ名前説明
CFキャリーフラグ算術演算で結果の最上位ビットにキャリーかボローが生じるとセット.それ以外はクリア.符号なし整数演算でのオーバーフロー状態を表す.
OFオーバーフローフラグ符号ビット(MSB)を除いて,整数の演算結果が大きすぎるか小さすぎるかするとセット.それ以外はクリア.2の補数表現での符号あり整数演算のオーバーフロー状態を表す.
ZFゼロフラグ結果がゼロの時にセット.それ以外はクリア.
SF符号フラグ符号あり整数の符号ビット(MSB)と同じ値をセット.(0は0以上の正の数,1は負の数であることを表す)
PFパリティフラグ結果の最下位バイトの値1のビットが偶数個あればセット,奇数個であればクリア.
AF調整フラグ算術演算で,結果のビット3にキャリーかボローが生じるとセット.それ以外はクリア.BCD演算で使用する(本書ではほとんど使いません).

CFフラグが立つ例

# asm/cf.s
    .text
    .globl main
    .type main, @function
main:
    movb $0xFF, %al
    addb $1, %al  # オーバーフローでCFが立つ
    ret
    .size main, .-main
$ gcc -g cf.s
$ gdb ./a.out
(gdb) b 8
Breakpoint 1 at 0x112d: file cf.s, line 8.
(gdb) r
Breakpoint 1, main () at cf.s:8
8	    ret
(gdb) p $al
$1 = ❶ 0
(gdb) p $eflags
$2 = [ ❷ CF PF AF ZF IF ]
(gdb) quit
  • movb $0xFF, %aladdb $1, %alで,1バイト符号なし整数の加算0xFF+1をすると,オーバーフローが起きて%alの値は❶ 0になります. (1バイト符号なし整数の範囲は0〜255です.0xFF+1は255+1=256となり (1バイト符号なし整数として)オーバーフローが起きています).
  • p $eflagsでステータスフラグを調べると,❷ CF フラグが立っています.

OFフラグが立つ例

# asm/of.s
    .text
    .globl main
    .type main, @function
main:
    movb $0x7F, %al
    addb $1, %al  # オーバーフローでOFが立つ
    ret
    .size main, .-main
$ gcc -g of.s
$ gdb ./a.out
(gdb) b main
Breakpoint 1 at 0x1129: file of.s, line 6.
(gdb) r
Breakpoint 1, main () at of.s:6
6	    movb $0x7F, %al
(gdb) si
7	    addb $1, %al  # オーバーフローでOFが立つ
(gdb) si
main () at of.s:8
8	    ret
(gdb) p $al
$1 = ❶ -128
(gdb) p $eflags
$1 = [ AF SF IF ❷ OF ]
(gdb) ❸ p/u $al
$2 = ❹ 128
(gdb) quit
  • movb $0x7F, %aladdb $1, %alで,1バイト符号あり整数の加算0x7F+1をすると,オーバーフローが起きて%alの値は❶ -128になります. (1バイト符号あり整数の範囲は-128〜127です.0x7F+1は127+1=128となり (1バイト符号あり整数として)オーバーフローが起きています).
  • p $eflagsでステータスフラグを調べると,❷ OF フラグが立っています.
  • なお,符号なし(❸u)オプションをつけて%alレジスタの値を表示させると, 符号なしの結果として正しい❹ 128という結果になりました. (x86-64は符号なし・符号ありを区別せず,どちらに対しても正しい結果を 計算します).
  • ここで説明する通り, 符号あり整数のオーバーフローは未定義動作になるので, 符号あり整数のオーバーフローを起こすプログラムは書いてはいけません.

レジスタの別名

%raxレジスタの別名 (%rbx, %rcx, %rdxも同様)

  • %raxの下位32ビットは%eaxとしてアクセス可能
  • %eaxの下位16ビットは%axとしてアクセス可能
  • %axの上位8ビットは%ahとしてアクセス可能
  • %axの下位8ビットは%alとしてアクセス可能
%raxに値を入れて,%eax, %ax, %ah, %alにアクセスする例
# asm/reg.s
    .text
    .globl main
    .type main, @function
main:
    movq $0x1122334455667788, %rax # ❶
    ret
    .size main, .-main
$ gcc -g reg.s
$ gdb ./a.out
(gdb) b main
Breakpoint 1 at 0x1129: file reg.s, line 6.
(gdb) r
Breakpoint 1, main () at reg.s:6
6	    movq $0x1122334455667788, %rax
(gdb) si
main () at reg.s:7
7	    ret
(gdb) p/x $rax
$1 = ❷ 0x1122334455667788
(gdb) p/x $eax
$2 = ❸ 0x55667788
(gdb) p/x $ax
$3 = ❹ 0x7788
(gdb) p/x $ah
$4 = ❺ 0x77
(gdb) p/x $al
$5 = ❻ 0x88
(gdb) q
  • %raxレジスタに❶ 0x1122334455667788を入れると, 当たり前ですが%raxレジスタには❷0x1122334455667788が入っています.
  • %eaxには(%raxの下位4バイトなので)❸ 0x55667788が入っています.
  • %axには(%raxの下位2バイトなので)❹ 0x7788が入っています.
  • %ahには(%axの上位1バイトなので)❺0x77が入っています.
  • %alには(%axの下位1バイトなので)❻0x88が入っています.

%rbpレジスタの別名 (%rsp, %rdi, %rsiも同様)

  • %rbpの下位32ビットは%ebpとしてアクセス可能
  • %ebpの下位16ビットは%bpとしてアクセス可能
  • %bpの下位8ビットは%bplとしてアクセス可能

%r8レジスタの別名 (%r9%r15も同様)

  • %r8の下位32ビットは%r8dとしてアクセス可能
  • %r8dの下位16ビットは%r8wとしてアクセス可能
  • %r8wの下位8ビットは%r8bとしてアクセス可能

同時に使えない制限

  • 一部のレジスタは%ah, %bh, %ch, %dhと一緒には使えない.
  • 例:movb %ah, (%r8)movb %ah, %bplはエラーになる.
  • 正確にはREXプリフィクス付きの命令では,%ah, %bh, %ch, %dhを使えない.

32ビットレジスタ上の演算は64ビットレジスタの上位32ビットをゼロにする

  • 例:movl $0xAABBCCDD, %eaxを実行すると%raxの上位32ビットが全てゼロになる
  • 例: movw $0x1122, %axmovb $0x11, %alでは上位をゼロにすることはない
上位32ビットをゼロにする実行例
# asm/zero-upper32.s
    .text
    .globl main
    .type main, @function
main:
    movq $0x1122334455667788, %rax
    movl $0xAABBCCDD, %eax
    movq $0x1122334455667788, %rax
    movw $0x1122, %ax
    movq $0x1122334455667788, %rax
    movb $0x11, %al
    ret
    .size main, .-main
# zero-upper32.txt
b 7
r
list 6,7
p/z $rax
si
p/z $rax
echo # 以下が出力されれば成功\n
echo # $1 = 0x1122334455667788 (%raxは8バイトの値を保持)\n
echo # $2 = 0x00000000aabbccdd (%raxの上位4バイトがゼロになった)\n
quit
$ gcc -g zero-upper32.s
$ gdb ./a.out -x zero-upper32.txt
Breakpoint 1, main () at zero-upper32.s:7
7	    movl $0xAABBCCDD, %eax
6	    movq $0x1122334455667788, %rax
7	    movl $0xAABBCCDD, %eax
$1 = 0x1122334455667788
8	    movq $0x1122334455667788, %rax
$2 = 0x00000000aabbccdd
# 以下が出力されれば成功
# $1 = 0x1122334455667788 (%raxは8バイトの値を保持)
# $2 = 0x00000000aabbccdd (%raxの上位4バイトがゼロになった)

オペレーティングシステムの存在

OSは邪魔!?

  • アセンブリ言語の利点はCPUや 周辺機器のハードウェア(入出力装置)がよく見えることです
  • でもユーザプロセス(皆さんが普通に実行しているプログラム)は OS上で動作するので,OSはCPUやハードウェアの詳細を見せない働きをします
    • この抽象化のおかげで,通常のアプリケーションを開発する際に, CPUやハードウェアの詳細を気にすること無くプログラミングできるわけですが.
  • 例えば,OSは以下を隠しています.以下でもう少し詳しく説明します.
    • OSによるマルチタスク処理: ユーザプロセスはCPUを専有しているように見えます.
    • ハードウェアの詳細(例: ハードディスク): ユーザプロセスはシステムコール経由でハードウェア等にアクセスします.
    • 物理メモリ: ユーザプロセスは仮想メモリのみアクセス可能で, 物理メモリへの直接アクセスはできません.
    • 割り込み (interrupt): ハードウェアがCPUに非同期的に(asyncronously)送る信号が割り込みです. ユーザプロセスが割り込みを直接受け取ることはありません.   

OSによるマルチタスク処理

  • ユーザプロセスから見ると「ずっとCPUやレジスタを専有している」ように見えます.
  • メモリはユーザプロセスごとに割り当てられますが, CPUの数は少ないので,OSはCPU上で実行するユーザプロセスを定期的に切り替えています.
    • このユーザプロセスの切り替えにはタイマー割り込みを使っています. タイマー割り込みが発生すると,OSがブート時に設定した割り込みハンドラを CPUが自動的に起動します.
    • その際,ユーザプロセスが使っていたCPUのレジスタ値はメモリに退避します. 実行再開時にはレジスタ値をメモリから回復します.
  • ユーザプロセスのプログラムを書く時は, マルチタスクのことを気にする必要はありません(ただしリアルタイムシステムなどは除く).

システムコール

  • ユーザプロセスは直接,周辺機器のハードウェアを操作できません. ユーザプロセスが直接操作するのは面倒だし危険なので, 通常,OSが「ユーザプロセスによるハードウェア操作」を禁止しているからです.
    • (Linuxではシステムコールiopermioplを使って, この禁止を解除できますが,本書では説明しません).
  • そのため,ユーザプロセスはシステムコールを使って, ハードウェア操作をカーネル(OS本体)に依頼します.
    • ユーザプロセスが動作するアドレス空間をユーザ空間, カーネルが動作するアドレス空間をカーネル空間と呼びます. カーネル空間ではCPUの特権命令の実行やハードウェア操作が可能です.
    • printfなどのライブラリ関数の呼び出しにはcall命令とret命令を使います. 一方,writeなどのシステムコールの呼び出しは トラップ命令(ソフトウェア割り込み命令)である, syscall/sysretsysenter/sysexitint/iretなどを使います. システムコールの呼び出しにはユーザ空間からカーネル空間への切り替えが 必要だからです.
  • システムコール内では入出力命令(in, out, mov)を実行することで ハードウェアの操作を行います. ハードウェア側から来る割り込みは,予めOSが設定した割り込みハンドラが対処します. ユーザ空間ではCPUの特権命令を実行できないので, ユーザプロセス内では(iopermioplを使わない限り)これらの操作をできません.

仮想メモリ

  • OSはx86-64のページング機能などを使って仮想メモリを有効化しています.
    • 上図でプロセスAとプロセスBは物理メモリの一部を仮想メモリとして (仮想アドレスを使って)アクセスできる状態です.
    • printfはプロセスAとプロセスBで共有しています.
    • 共有しているprintf以外は,プロセスはお互いに他のプロセスのメモリの中身にアクセスできません.
  • 仮想メモリが有効な状態では, CPUが扱うアドレス(%ripやメモリ参照でアクセスするアドレス) はすべて仮想アドレスです. CPU上で動作するプログラム(a.out)は 物理メモリのアドレスを使ってのアクセスはできません.

  • 仮想アドレスから物理アドレスへの変換:

    • OSは「仮想アドレスと物理アドレスの対応表」であるページテーブルを  物理メモリ上で管理します.
    • 実際の変換はCPUとバスの間にあるMMU(memory management unit)が高速に行います. MMUはCPUから仮想アドレスを受け取り,それを物理アドレスに変換してから, バス上で物理アドレスを使って物理メモリにアクセスします.

割り込みとシグナル

  • CPUはバスを介して周辺機器とつながってます. 例えば,キーボードのキーを押すと,キーを押すたび,離すたびに, CPUに割り込みを伝えます.
  • 割り込み(interrupt)は周辺機器側からCPUに非同期的(asynchronously)に 送る信号です.
  • CPUは割り込みを受け取ると,(ブート時にOSが設定した)割り込みハンドラを自動的に 起動して,その割り込みに対処します.
  • 一部の割り込みはユーザプロセスにUNIXシグナルとして配送されます. 例えば,ユーザプロセスはタイマー割り込みを直接,受け取ることはできませんが, (alarmsetitimerなどのシステムコールを使えば) SIGALRMというUNIXシグナルを受け取ることができます.

バイナリ(2進数)でのデータ表現

2進数と符号化

ここでも説明しましたが, コンピュータの中のデータは,どんな種類のデータ(例えば,整数,文字, 音声,画像)であっても, 機械語命令であっても,すべて01だけで表現されています.

そして,そのためにデータの種類ごとに2進数での表現方法,つまり符号化 (encoding)の方法が定められています. 例えば,

  • 文字UをASCII文字として符号化すると,01010101になります.
  • pushq %rbpをx86-64の機械語命令として符号化すると,01010101になります.

おや,どちらも同じ01010101になってしまいました. この2進数がPなのかpushq %rbpなのか,どうやって区別すればいいでしょう? 答えは「これだけでは区別できません」です.

別の手段(情報)を使って,いま自分が注目しているデータが, 文字なのか機械語命令なのかを知る必要があります. 例えば,この後で説明する.textセクションにある 2進数のデータ列は「.textセクションに存在するから」という理由で 機械語命令として解釈されます.

以下では,整数(特に2の補数による負の数の表現), ASCII文字コード,機械語命令の符号化などを説明します.

2進数と10進数と16進数

位取り記数法

  • 10進数で \( 234 \) という数字の値は \[ 2\times 10^2 + 3\times 10^1 + 4\times 10^0 = 200 + 30 + 4 = 234 \]
  • 2進数で \( 1101 \) という数字の値は (2進数→10進数の変換)

\[ 1\times 2^3 + 1\times 2^2 + 0\times 2^1 + 1\times 2^0 = 8+4+0+1=13 \]

  • 一般的に,n進数で \( d_m d_{m-1} \cdots d_2 d_1 d_0 \) という数字の値は \[ \sum_{i=0}^m d_i\times n^i = d_m\times n^m + d_{m-1}\times n^{m-1} + \cdots d_2\times n^2 + d_1\times n^1 + d_0\times n^0 \]

    • この「数字を並べる記法」を位取り記数法 (positional notation)という.
    • nを (base), あるいは基数 (radix)と呼ぶ.
  • 16進数も同様

    • 0から9,AからFまでの数字を使う.Aは10,Bは11,\(\cdots\),Fは15を表す.
    • 16進数で \( 1\mathrm{F}4 \) という数字の値は (16進数→10進数の変換) \[ 1\times 16^2 + \mathrm{F}\times 16^1 + 4\times 16^0 = 256 + 240 + 4 = 500 \]
    • 2進数は表記が長くなるため,16進数を使って短く表記すると便利(だから使う).

対応表

10進数0123456789101112131415
2進数01101110010111011110001001101010111100110111101111
16進数0123456789ABCDEF

変換方法

2進数→16進数,16進数→2進数

  • 最下位ビットから4桁ずつまとめる(上位ビットには0を埋める). 4ビットごとに16進数にすれば良い(10進数ではこの方法は使えない)

10進数→2進数

  • 割り算を使う方法

    • 0になるまで繰り返し2で割り,余りを逆順に並べる.
    • 例: 13を2進数に変換

    \[ \left. \begin{align} 13\div 2 = 6 \cdots 1 \\ 6\div 2 = 3 \cdots 0 \\ 3\div 2 = 1 \cdots 1 \\ 1\div 2 = 0 \cdots 1 \end{align} \right\} 余りを下から上に並べて 1101 \]

    • これでうまくいく理由は以下の計算と同じだからです

\[ \begin{eqnarray} 13 &=& 2\times6 + \textcolor{red}{1} \\ &=& 2 \times (2 \times 3 + \textcolor{green}{0}) + \textcolor{red}{1}\\ &=& 2 \times (2 \times (2 \times 1 + \textcolor{blue}{1}) + \textcolor{green}{0}) + \textcolor{red}{1}\\ &=& 2 \times ( 2 \times (2 \times (2 \times 0 + \textcolor{magenta}{1}) + \textcolor{blue}{1}) + \textcolor{green}{0}) + \textcolor{red}{1}\\ &=& 2 \times ( 2 \times (2^2 \times 0 + 2^1 \times \textcolor{magenta}{1} + 2 ^0 \times \textcolor{blue}{1}) + \textcolor{green}{0}) + \textcolor{red}{1}\\ &=& 2 \times ( 2^3 \times 0 + 2^2 \times \textcolor{magenta}{1} + 2^1 \times \textcolor{blue}{1} + 2^0 \times \textcolor{green}{0}) + \textcolor{red}{1}\\ &=& 2^4 \times 0 + 2^3 \times \textcolor{magenta}{1} + 2^2 \times \textcolor{blue}{1} + 2^1 \times \textcolor{green}{0} + 2^0 \times \textcolor{red}{1}\\ &=& (2進数で)\textcolor{magenta}{1}\textcolor{blue}{1}\textcolor{green}{0}\textcolor{red}{1}\\ \end{eqnarray} \]

  • 2のべき乗を使う方法

    • 大きい2のべき乗から順番に, 例えば,\(2^3=8\)を引けたら,\(2^3\)の位を1にする. 0になるまで繰り返す.
    • 例: 13を2進数に変換

\[ \left. \begin{eqnarray} 13 - 2^3 &=& 5 \rightarrow 2^3の位は1 \\ 5 - 2^2 &=& 1 \rightarrow 2^2の位は1 \\ 1 - 2^1 &=& 引けない \rightarrow 2^1の位は0 \\ 1 - 2^0 &=& 0 \rightarrow 2^0の位は1 \end{eqnarray} \right\} 上から下に並べて 1101 \]

  • bcコマンドを使う方法

    • bcは電卓コマンド.

      $ bc
      1+2*3+10/5
      9
      ^D         (ctrl-dで終了)
      $
      
    • 例: 10進数 13を2進数に変換

      $ bc
      obase=2  (出力の底を2に変更)
      13
      1101     (13の2進数は1101)
      ^D
      $
      
    • 例: 16進数 1F4 を2進数に変換

      $ bc
      obase=2    (出力の底を2に変更)
      ibase=16   (入力の底を16に変更,入力の底の変更は出力の後が良い)
      1F4
      111110100  (1F4の2進数は111110100)
      ^D
      $
      

用語: ビット,バイト,LSB,MSB

ビットとバイト

  • ビット (bit)

    • ビットはコンピュータが扱うデータ量の最小単位.binary digit の略.
    • 2進数のひと桁が1ビット.1ビットで0か1かの2通りの状態を表現.
  • バイト (byte)

    • 通常,8ビットのこと
通常,8ビット?

現在では1バイト=8ビットのコンピュータしか目にしませんが, かつてはそうではないコンピュータもありました. そのため,厳密に8ビットを指すための言葉として, オクテット(octet)という言葉も使われています. ですが,1バイト=8ビットと考えて問題ありません.

  • ビットやバイトはレジスタやメモリなどの記憶領域の容量の単位としても使われます
    • レジスタ%raxは8バイトのデータを格納できます.
    • (バイトアドレッシングの)メモリは1つのアドレスごとに1バイトのデータを格納できます
バイトアドレッシング

バイトアドレッシングなメモリとは 1つのアドレスごとに1バイトのデータを格納できるメモリのこと. つまり,1バイトの配列としてアクセスするメモリのことです. 一方,ワード(バイト数はアーキテクチャ依存)の配列としてアクセスするメモリを ワードアドレッシングなメモリと言います.

MSBとLSB

  • ビット列で最左のビットを最上位ビット (MSB, most significant bit)といいます
  • ビット列で最右のビットを最下位ビット (LSB, least significant bit)といいます
  • 多倍長データの最上位バイト (most significant byte)と 最下位バイト (least significant byte)も略称がMSBとLSBなので, どちらを指すかは要注意です

ビットの呼び方

  • LSBから左に(0から始めて)0ビット目,1ビット目,...,7ビット目と呼ぶことが多いです (例えばIntelのマニュアルで Intel 64 and IA-32 Architectures Software Developer Manuals)
  • 0から数え始めることを0オリジン (zero-origin),あるいは0ベース (zero-based)といいます

ワード,ロング,クアッド

サイズ
(バイト)
サイズ
(ビット)
Cのデータ型
(LP64)
アセンブラ
命令
命令
サフィックス
バイト18char.bytemovb
ワード216short.wordmovw
ロング,
ダブルワード
432int.longmovl
クアッド864long,
ポインタ
.quadmovq
  • ワード,ロング,クアッドのサイズはアーキテクチャ依存です. x86-64では,それぞれ,2バイト,4バイト,8バイトになります.
  • C言語の整数型やポインタ型のサイズはプラットフォーム依存です. 最近主流のLP64データモデルでは, 上記の通り,intは4バイト,longとポインタは8バイトになります.
  • GNUアセンブラでは次の表記等でデータのサイズを指定します.
    • アセンブラ命令で整数データを出力する際には, サイズに応じて,.byte.word.long.quadを使います
    • 多くの機械語命令でオペランドのサイズを命令サフィックスで指定します. 例えば,movqは オペランドのサイズが8バイトであることを示します

<stdint.h>

  • 符号あり
型名説明
int8_t符号あり8ビット
int16_t符号あり16ビット
int32_t符号あり32ビット
int64_t符号あり64ビット
intptr_t符号ありポインタ用
  • 符号なし
型名説明
uint8_t符号なし8ビット
uint16_t符号なし16ビット
uint32_t符号なし32ビット
uint64_t符号なし64ビット
uintptr_t符号なしポインタ用
  • <stdint.h>は標準ヘッダファイルで,固定長の整数や 符号の有無を確実に扱いたい時に便利です.

文字コード

文字コードとは

  • 文字コード = 各文字を区別するために,重複無く割り振った番号です
    • ASCIIコードで文字Aの文字コードは(7ビットの)65
    • ASCIIコードで文字9の文字コードは(7ビットの)57
// ascii.c
#include <stdio.h>
int main ()
{
    printf ("'%c', %d\n", 'A', 'A');
    printf ("'%c', %d\n", '9', '9');
}
$ gcc -g ascii.c
$ ./a.out
'A', 65
'9', 57
  • 文字コードにはいろいろな種類がありますが,GNUアセンブラではASCIIコードのみを使います.

  • 文字コードはフォント(書体)や文字の大きさの情報は含んでいません.

    例えば,上図の文字Aはフォントも大きさも異なりますが, どちらもASCIIコードでは同じ「7ビットの65」です.

ASCIIコード

ASCIIコード表

番号文字番号文字番号文字番号文字番号文字番号文字番号文字番号文字
0^@16^P3248064@80P96`112p
1^A17^Q33!49165A81Q97a113q
2^B18^R34"50266B82R98b114r
3^C19^S35#51367C83S99c115s
4^D20^T36$52468D84T100d116t
5^E21^U37%53569E85U101e117u
6^F22^V38&54670F86V102f118v
7^G23^W39'55771G87W103g119w
8^H24^X40(56872H88X104h120x
9^I25^Y41)57973I89Y105i121y
10^J26^Z42*58:74J90Z106j122z
11^K27^[43+59;75K91[107k123{
12^L28^\44,60<76L92\108l124`
13^M29^]45-61=77M93]109m125}
14^N30^^46.62>78N94^110n126~
15^O31^_47/63?79O95_111o127^?
  • 128個の文字を扱うコード体系
    • 通常,MSBを0にした1バイトデータとして扱う
  • 英字アルファベット,数字,記号,制御文字 (control character)を含む

制御文字

  • 制御文字は出力装置に(文字表示以外の)動作を要求します
    • 例: 改行文字 ^J (line feed, C言語では\n)は端末ディスプレイに改行を要求する. 「次に表示する位置を変更する」という動作を要求しています.

    • 例: エスケープ文字 ^[で始まる文字列 ^[[7mは文字反転, ^[[0mは元に戻すというANSIエスケープシーケンスです. 「次に表示する文字と背景の色を変更する」という動作を要求しています.

      $ echo -e "aaa \E[7mbbb\E[0m ccc"
      aaa bbb ccc
      $ cat
      aaa  ^[[7mbbb  ^[[0mccc     (ctrl-vを押してからエスケープキーを入力)
      aaa  bbb  ccc
      

      echoコマンドの-eは「バックスラッシュによるエスケープを解釈する」というオプションです.また,\Ebashでエスケープ文字を表すエスケープシーケンスです. ほとんどの端末ソフトで文字列bbbと背景色の色が反転します. catコマンドの場合は,ctrl-vを押してからエスケープキーを押すと エスケープ文字が入力できて,^[と表示されます(2文字に見えますがこれで1文字です).

    • ASCIIの以下の制御文字は覚えておきましょう.

      制御文字意味C言語のエスケープ文字キーボード中のキー
      ^@ヌル文字\0
      ^DEnd of File (EOF)
      ^HBack Space (後退)\bBack Space
      ^IHorizontal Tab (水平タブ)\tTab
      ^JLine Feed (改行)\n
      ^MCarriage Return (復帰)\rEnter
      ^[Escape (エスケープ)Esc
      ^?Delete (削除)Delete
制御文字Deleteが127である理由

パンチカード時代に「穴が開いているビットは1」と扱っていて, Deleteを127 (2進数で1111111)にしておけば, 「どんな文字に対しても全てのビットの穴を開ければ, その文字を削除できたから」です. なおパンチカードの実物は私も見たことはありません. (大昔のゴジラの映画で見た貴ガス).

ctrl-jctrl-m で改行できる(ことが多い)理由

  • 歴史的な話ですが,ctrlキーを押しながら,あるキー(例えばj)を押すと, jのASCIIコード(2進数8ビット表記で 01101010)の 上位3ビットをゼロにした 00001010 (つまり改行文字 ^J)を入力できました.
  • そのなごりで,今でもctrl-jctrl-mを押すとEnterキーの入力と同じ動作を するソフトウェアが多くあります. 同様に,ctrl-iでTabを,ctrl-[でEscapeを,ctrl-hでBack Spaceを 入力できるソフトウェアが多いです.
  • もちろん,現在ではキーの処理はソフトウェアごとに自由に決められるので, ctrl-jで常に改行できるわけではありません.

ファイルの改行文字

OSファイル中の
改行文字
記号
Linux, macOS^JLF
Windows^M ^JCR LF
  • LinuxやmacOSのファイルでは,通常,ファイル中の改行を Line Feed (^j, LF)の1文字で表します. 一方,Windows では Carriage Return (^m, CR)とLine Feed (^j, LF)の2文字で表すことが多いです. (ファイル中の改行文字はエディタの設定で通常,変更可能).

  • このため,Windows で作成したファイルを Linux で開くと, 行末に ^M が見えることがあります.「改行が CR LF なんだな」と思って下さい.

    $ ❶ cat foo2.txt
    hello
    byebye
    $ ❷ cat -v foo2.txt
    ❸ hello^M
    byebye^M
    
    $ ❹ od -c -t x1 foo2.txt
    0000000   h   e   l   l   o ❺ \r  \n   b   y   e   b   y   e  \r  \n
             68  65  6c  6c  6f    0d  0a  62  79  65  62  79  65  0d  0a
    0000017
    
    • 例えば,改行が CR LF なファイルfoo2.txtを用意して, ❶ catで表示すると普通に表示されますが, ❷ -vオプション(制御文字を可視化)を使うと,❸^Mが表示されました.
    • odコマンドを使っても,CRの存在を確認できます (❺ \r).

文字集合と符号化方式,UnicodeとUTF-8

GNUアセンブラではASCIIコードのみを使用するので, この節の話はスキップ可能です.

  • ASCIIコードでは文字コード(文字の背番号,コードポイント)をそのままバイナリ表現として使っていました.
  • 一方,多くの文字コード体系では文字集合符号化方式を区別しています.
    • 例えば,Unicodeは(ほぼ世界中の)文字を定める文字集合です. Unicodeで日本語の「あ」のコードポイントは0x3042です.

    • UTF-8はUnicodeの符号化方式の一つです. UTF-8で「あ」をバイト列に符号化すると,0xE30x810x82になります. (Unicodeの他の符号化方式として,UTF-16やUTF-32もあります).

      $ cat a.txt
      あ
      $ od -t x1 a.txt
      0000000 e3 81 82 0a
      0000004
      

      odコマンドで確かめると「あ」が0xE30x810x82のバイト列と確認できます. 最後の0x0Aは改行文字 (\n)ですね.

    • 111010を使う理由は あるバイトが文字の最初のバイトなのか,2バイト目以降なのかを簡単に 区別できるからです. また,1バイトの文字 (例えば A)と混同する心配もありません.

    • UTF-8はASCIIと互換性があるのが大きな利点です. ASCIIコードで書かれたテキストはそのままUTF-8として処理できます.

符号なし整数

符号なし整数のビット表現

  • 2進数の各桁をそのままビット表現とする
    • 例: 2を8ビットの符号なし整数で表現すると, 2の2進数は10なので,00000010 になる. (余った上位ビットに0を入れることに注意)

符号なし整数の一覧表

  • 前半
ビット表現10進数16進数
0000000000x0
0000000110x1
0000001020x2
\(\vdots\)\(\vdots\)\(\vdots\)
011111101260x7E
011111111270x7F
  • 後半
ビット表現10進数16進数
100000001280x80
100000011290x81
100000101300x82
\(\vdots\)\(\vdots\)\(\vdots\)
111111102540xFE
111111112550xFF

符号なし整数の扱える範囲

  • 固定長の整数の範囲は有限
    • 例: 8ビット符号なし整数が表現できる範囲は \(0\)から\(255\)まで
  • 一般に\(n\)ビット符号なし整数の範囲は \(0\)から\(2^n-1\)まで
  • (符号なしなので当然ですが)負の値は表現できない

符号なし整数の最大値と最小値のビットパターン

ビット表現10進数16進数
8ビットの最小値0000000000x0
8ビットの最大値11111111255=\(2^8-1\)0xFF
16ビットの最小値00000000 0000000000x0
16ビットの最大値11111111 1111111165535=\(2^{16}-1\)0xFFFF
32ビットの最小値00000000 00000000 00000000 0000000000x0
32ビットの最大値11111111 11111111 11111111 111111114294967295=\(2^{32}-1\)0xFFFFFFFF
  • 見やすさのため8ビットごとにスペースを入れてます
  • 最小値は全てのビットが0,最大値は全てのビットが1
  • 32ビット符号なし整数の最大値は約42.9億.現在の世界の人口(約80億人)を数えられない

符号なし整数のオーバーフロー

  • 演算の結果,表現できる範囲を超えた場合をオーバーフロー (overflow)と言う.
  • 8ビット符号なし整数が表現できる範囲は0から255まで(復習)
  • 例: 8ビット符号なし整数の255に1を足すと,256は表現できる範囲外なので, オーバーフローして結果は0になる
// overflow1.c
#include <stdio.h>
#include <stdint.h>
int main ()
{
    uint8_t x = 255;    
    x++;
    printf ("%d\n", x);
}
$ gcc -g overflow1.c 
$ ./a.out
0
  • 例: 8ビット符号なし整数の0から1を引くと,-1は表現できる範囲外なので, オーバーフローして結果は255になる
// overflow2.c
#include <stdio.h>
#include <stdint.h>
int main ()
{
    uint8_t x = 0;
    x--;
    printf ("%d\n", x);
}
$ gcc -g overflow2.c 
$ ./a.out
255
  • 8ビット符号なし整数のオーバーフロー時の動作: 0から255までの範囲に収まるように,256で割った余りを計算結果とする (これはC言語規格の定義とも合致する).
  • 一般的には\(n\)ビット符号なし整数の場合, オーバーフローの結果は,\(0\)から\(2^n-1\)の範囲に収まるように, \(2^n\)で割った余りを計算結果とする.
「割った余りを計算結果とする」の別の言い方

「\(2^n\)で割った余りを計算結果とする」の別の言い方として, 以下の言い方をすることがあります.

  • モジュロ (modulo) \(2^n\)を取る」
  • 「\(2^n\)をとする」

キャリーとボロー

  • キャリー (carry)は繰り上げ,ボロー(borrow)は繰り下げのこと

  • 符号なし整数のオーバーフローはキャリーやボローの有無でチェックできる

  • 例: 8ビット符号なし整数で255+1を計算すると,キャリーが発生する

  • 例: 8ビット符号なし整数で0-1を計算すると,ボローが発生する

  • x86-64では符号なし整数のオーバーフローは, キャリーフラグ(CF)で検出できる. キャリーやボローが発生するとキャリーフラグが立つから.

符号あり整数,2の補数

符号あり整数のビット表現

  • 非負(0か正数)の数は2進数の各桁をそのままビット表現, 負の数は2の補数 (two's complement)を使う

    • MSBは符号ビットになる.0なら非負,1なら負になる.
    • 2の補数以外にも負の数の表現方法はある. 2の補数を使う理由は,(1) 減算を加算処理で行えるから, (2) x86-64が2の補数を使っているからです.
  • 例: 8ビットの符号あり整数の場合,

    • 前半の00000000〜01111111を0と正の数に, 後半の10000000〜11111111を負の数にする.
    • 例えば,126に対する(8ビットの場合の)2の補数は130(=256-126)なので, 130の2進表記 10000010 を -126 のビット表現とする.

一覧表

  • 前半 (0と正の数)
ビット表現10進数16進数
0000000000x0
0000000110x1
0000001020x2
\(\vdots\)\(\vdots\)\(\vdots\)
011111101260x7E
011111111270x7F
  • 後半 (負の数)
ビット表現10進数16進数
10000000-128-0x80
10000001-127-0x7F
10000010-126-0x7E
\(\vdots\)\(\vdots\)\(\vdots\)
11111110-2-0x2
11111111-1-0x1

2の補数と1の補数

  • 2の補数とは(キャリーを無視して)加算して全てのビットが0になる数です

    • 例: 8ビットの場合,126 (2進数で 01111110)の2の補数は10000010 (=130)です. 01111110+10000010=00000000になるからです(キャリーを無視すれば). \(126+130=256=2^8\)

  • 1の補数とは(キャリーを無視して)加算して全てのビットが1になる数です

    • 例: 8ビットの場合,126 (2進数で 01111110)の1の補数は 10000001 (=129)です. 01111110+10000001=11111111になるからです. \(126+129=255=2^8-1\)

  • 2の補数の求め方: 各ビットを反転してから,LSBに1を加えれば良い

  • 一般的に,\(n\)進数に対して,\(n\)の補数と,\((n-1)\)の補数が存在します

    • \(n\)の補数は「足すとちょうど桁が上がる数」です. 10進数で2桁の数を考える時,\(65\)に対する10の補数は\(35\)になります. 足すと\(100\)になるから.
    • \(n-1\)の補数は「足してもギリギリ桁が上がらない数」です. 10進数で2桁の数を考える時,\(65\)に対する9の補数は\(34\)になります. 足すと\(99\)になるから.

符号なし整数と符号あり整数の関係

  • 8ビットの場合,符号なし整数の128〜255の範囲のビット表現を, 符号あり整数の-128〜-1の範囲にシフトしたことになる.

  • シフトの幅がどれもちょうど256であることがポイント.

  • 「130を足す」のと「126を引く」のは同じ

    • 8ビット整数の場合,256を足しても引いても値は変化しませんよね. キャリーやボローとしてはみ出るだけだからです.
    • 例えば,1 + 256 = 1 ですし,1 - 256 = 1 です. (数式ではこれを \(1 + 256 \equiv 1 \pmod {256}\) と書きます. 「256で割った余りで考えれば,同じ値である」という意味です. ここでは単に = を使います).
    • ですので,4 - 126 = 4 + (-126) = 4 + 256 + (-126) = 4 + 130 となります.
  • 時計で考えると分かりやすいかも.時計の上で,「7時間足す」のと「5時間引く」のは同じ.

扱える範囲

  • 例: 8ビット符号あり整数が表現できる範囲は \(-128\)から\(127\)まで
    • \(128\)まででは無いのはプラス側にはゼロ (0)があるから
  • 一般に\(n\)ビット符号なし整数の範囲は \(-2^n/2\)から\(2^n/2-1\)まで
    • \(n\)ビットの場合,\(2^n\)通りの数が扱える. 正と負で半分ずつ使ってる (\(2^n/2\))が, 正の方はゼロ (0)があるので \(-1\)となる.

最大値と最小値のビットパターン

ビット表現10進数16進数
8ビットの最小値10000000-128=\(-2^8/2\)-0x80
8ビットの最大値01111111127=\(2^8/2-1\)0x7F
16ビットの最小値10000000 00000000-32768=\(-2^{16}/2\)-0x8000
16ビットの最大値01111111 1111111132767=\(2^{16}/2-1\)0x7FFF
32ビットの最小値10000000 00000000 00000000 00000000-2147483648=\(-2^{32}/2\)0x80000000
32ビットの最大値01111111 11111111 11111111 111111112147483647=\(2^{32}/2-1\)0x7FFFFFFF
  • 見やすさのため8ビットごとにスペースを入れてます
  • 最小値はMSBが1でそれ以外の全ビットが0,最大値はMSBが0でそれ以外の全ビットが1

x86-64は符号の有無を気にせず加算する→どちらの結果も正しい

  • 2の補数の利点 = 足し算回路で引き算ができる

  • 例: 31-126は,31と -126 のビット表現の加算で計算できる.

    • つまり 00011111 + 10000010 = 10100001 = -95
    • この結果が 31 + 130 = 10100001 = 161 の結果と,ビット表現が一致することに注意
    • これは当然で,8ビットの場合,31 + (-126) = 31 + 256 + (-126) = 31 + 130 だから
  • 実際,x86-64は整数が符号ありか符号なしかを区別せずに加算している. 計算結果はどちらに対しても正しい.

    • プログラマ(あるいはコンパイラ)は符号ありか符号なしかを意識する必要がある. 符号あり・符号なしでビット表現が意味する値が変わるし (上の例では161か-95か), オーバーフローの判定にキャリーフラグ(CF)とオーバーフローフラグ(OF)の どちらを使うべきかを判断する必要があるから.

符号あり整数のオーバーフロー

  • 8ビット符号あり整数の64に64を足すと,オーバーフローして結果は-128になる (64 + 64 = 128 は8バイト符号あり整数が表現可能な-128〜127の範囲を超えている).

  • C言語の規格上,符号あり整数のオーバーフローは未定義動作(undefined behavior). つまり,符号あり整数をオーバーフローさせてはいけない.

    • 未定義動作を含むプログラムに対して,コンパイラはどのように振る舞っても良い.
      • コンパイルエラーにしても良いし,実行するたびに異なる結果を出力するコードを出力しても良いし,それっぽい答え(ここではオーバーフローしたビット表現)を出力しても良い
    • ここでは-128の結果を得たが,C言語の規格上,他の結果がでる可能性がある

符号あり整数では,オーバーフローとキャリーの有無は関係ない

  • 符号あり整数ではオーバーフローとキャリーの有無は関係ありません

    • 例: 8ビット符号あり整数で,64+64=128 は,表現可能な-128〜127を超えているのでオーバーフロー発生.しかしキャリーは0
    • 例: 8ビット符号あり整数で,(-1)+(-1)=-2は,表現可能な-128〜127の範囲内なので オーバーフローは発生していない.しかしキャリーは1
  • このため,x86-64では符号あり整数のオーバーフローを(キャリーフラグ(CF)ではなく) オーバーフローフラグ(OF)で検出する

  • なお人間が判断する場合は以下で簡単に判定できる

    • 正と正の数同士(どちらもMSBは0)を足して,負になったら(MSBは1)オーバーフロー発生
    • 負と負の数同士(どちらもMSBは1)を足して,正になったら(MSBは0)オーバーフロー発生
    • 正と負の数同士の足し算では決してオーバーフローは発生しない

C言語で整数計算に注意が必要な場合

符号あり整数のオーバーフロー (未定義動作)

// overflow4.c
#include <stdio.h>
#include <stdint.h>
int main ()
{
    int32_t x1 = 10 * 10000 * 10000; // 10億
    int32_t x2 = 15 * 10000 * 10000; // 15億
    int32_t x3 = x1 + x2; // オーバーフロー
    printf ("%d\n", x3); // -1794967296
}
$ gcc -g overflow4.c
$ ./a.out
-1794967296
  • 符号あり整数のオーバーフローはセキュリティ上の脆弱性になるため避けるべきです. -ftrapvオプションをつけると符号あり整数のオーバーフロー時に トラップが発生します. 試した所,シグナルSIGABRTが発生してプログラムは終了しました.
$ gcc -g -ftrapv overflow4.c
$ ./a.out
Aborted

絶対値を計算できない数がある

// overflow5.c
#include <stdio.h>
#include <stdint.h>
int main ()
{
    int8_t  x1 = -128;
    int16_t x2 = -32768;
    int32_t x3 = -2147483648;
    x1 = -x1;
    x2 = -x2;
    x3 = -x3;
    printf ("%d, %d, %d\n", x1, x2, x3);
}
$ gcc -g overflow5.c
$ ./a.out
-128, -32768, -2147483648
  • 符号あり整数で表現可能な最小の数は絶対値を計算できません.
  • 例えば8ビット符号あり整数-128の絶対値は計算できません. 絶対値の128が,表現可能な範囲-128〜127を超えてしまうからです.
  • この場合もオーバーフローが起きているので未定義動作となります.

符号ありと符号なしを混ぜると直感に反する結果になる

// signed-unsigned.c
#include <stdio.h>
#include <stdint.h>
int main ()
{
    int32_t x1 = -1;
    uint32_t x2 = 0;
    printf ("%d\n", x1 < x2); // 0
}
$ gcc -g singed-unsigned.c
$ ./a.out
0
  • これはオーバーフローではなくC言語の規格に関する話です.
  • 上のsigned-unsigned.cを実行すると0が返りました. (-1 < 0は真なので1が返って欲しいのに).
  • これは違う型同士の演算ではC言語ではまず型を同じにするためです (通常の算術型変換といいます). この場合は-1を符号なしに変換します. その結果,0xFFFFFFFFという大きな正の数になり, 0xFFFFFFFF < 0を比較して0が返るわけです.
  • この問題があるため,C言語では符号ありと符号なしを混ぜて使うことを避けるべきです.
    • 通常は符号ありの整数を使う
    • ビット演算をしたい場合などに限定して符号なしの整数を使う (符号あり整数へのビット演算は(未定義動作ではないが) 処理系定義の動作を引き起こす場合があるため).

機械語命令の符号化

覚える必要はありません

  • 機械語命令の符号化方法はCPUごとに異なります
  • x86-64の機械語命令の符号化方法はx86-64のマニュアル Intel 64 and IA-32 Architectures Software Developer Manualsに書いてありますし,アセンブラが自動変換してくれるので,私達は覚える必要は全くありません
  • とはいえ,「機械語命令も2進数で符号化されている」ことを確認するために, 機械語命令の符号化をちょっとだけ見てみましょう

概要

  • x86-64はCISC (complex instruction set computer)なので, 「1つの命令で複雑な処理をする」という設計哲学です. そのため命令は可変長(1バイト〜15バイト)で複雑です. 一方,RISC (reduced instruction set computer)では命令長は固定長で, 1命令が処理する内容は単純なことが多いです.

命令フォーマット

  • 上図はx86-64の基本的な命令フォーマットです(この図と異なる例外的な命令もあります).
  • 命令プリフィクス (instruction prefix)には例えばLOCKプリフィクスがあります. LOCKプリフィクスはメモリアクセスをアトミックにする効果がありますが, LOCKプリフィクスをつけて良い命令は一部に限定されています. また,以下ではREXプリフィクスも登場します.
  • ModR/MバイトとSIBバイトは, アドレッシングモード,レジスタやメモリオペランドを指定します.
  • 変位(displacement)と即値(immediate)はどちらも定数です. pushq 4(%rsp)4が変位,pushq $44が即値です.

処理方向ビット

  • 2つのオペランドを持つ多くの命令で, 1バイトのオペコードの (LSBを0ビット目と数えて)1ビット目が処理方向ビット(operation direction bit)になります.
  • 処理方向ビットが0の時,代入の方向はregr/mになります. 一方1の時,代入の方向はr/mregになります.
  • 例えば,上図で movq r64, r/m64のオペコードはREX.W 89movq r/m64, r64のオペコードはREX.W 8Bです. 確かに,処理方向ビットがそれぞれ01になっています. (REX.Wは命令プリフィクスで,オペランドサイズを64ビットにします).

REXプリフィクス

Intelマニュアル表記意味
REX.Wオペランドサイズを64ビットにする
REX.RModR/MのRegフィールドの拡張
REX.XSIBのIndexフィールドの拡張
REX.BModR/MのR/M,SIBのBase,オペコードのRegフィールドの拡張
  • REXプリフィクスはx86-64で追加された1バイト長の命令プリフィクスです.
  • GNUアセンブラが自動挿入するので,アセンブリコードでプログラマが 明示的にREXプリフィクスを記述する必要は通常はありません.
  • REX.Wはオペランドサイズを64ビットにします.
  • REX.RREX.XREX.Bはレジスタを指定する際に使われます. これらの値が1の時,新しいレジスタ%r8%r15を指定したことになります.

ModR/MバイトとSIBバイト

  • ModR/Mバイトは上図のように分割されてます.
Mod意味
00メモリ参照,変位は無し.ただしR/M=101の時は例外あり(以下参照)
01メモリ参照,変位は1バイト
10メモリ参照,変位は4バイト
11レジスタ参照
  • ModR/MのModフィールドの意味は上記のとおりです.
$ gcc -g movq-10.s
$ objdump -d ./a.out
0000000000001129 <main>:
  1129: 4c 89 c0             	mov %r8,%rax        # Mod=11,レジスタ
  112c: 4c 89 00             	mov %r8,(%rax)      # Mod=00, メモリ参照,変位無し
  112f: 4c 89 40 08          	mov %r8,0x8(%rax)   # Mod=01,メモリ参照,変位1バイト (0x08)
  1133: 4c 89 80 e8 03 00 00 	mov %r8,0x3e8(%rax) # Mod=10, メモリ参照,変位4バイト (0xE8, 0x03, 0x00, 0x00)
  • ModR/MのRegフィールドはREX.Rの1ビットと合わせて,レジスタを指定します(以下の表参照).

  • オペランドが1つの時はRegフィールドはレジスタの指定ではなく, オペコードの一部として使われることがあります(なので図中でOpcodeと書いています).

REX.RReg指定される
レジスタ
0000%rax
0001%rcx
0010%rdx
0011%rbx
0100%rsp
0101%rbp
0110%rsi
0111%rdi
1000%r8
1001%r9
1010%r10
1011%r11
1100%r12
1101%r13
1110%r14
1111%r15
  • ModR/MのR/MフィールドはREX.Bの1ビットと合わせて,レジスタやメモリ参照を指定します.
    • Mod=11の時はR/Mフィールドは下の表のレジスタを指定します.
    • Mod\(\neq\)11の時はR/Mフィールドは下の表のメモリ参照を指定します.
      • ただし,R/M=100の時はSIBバイトを使うことを意味します. また,R/M=101の時は%rip相対アドレッシングを使うことを意味します. R/M=101の時,(本来はMod=00は変位無しですが) Mod=00でもMod=10でも変位が4バイトになります.
REX.BR/M指定される
レジスタ
(Mod=11)
指定される
メモリ参照
(Mod\(\neq\)11)
0000%rax(%rax)
0001%rcx(%rcx)
0010%rdx(%rdx)
0011%rbx(%rbx)
0100%rspSIB使用
0101%rbp%rip相対
0110%rsi(%rsi)
0111%rdi(%rdi)
1000%r8(%r8)
1001%r9(%r9)
1010%r10(%r10)
1011%r11(%r11)
1100%r12SIB使用
1101%r13%rip相対
1110%r14(%r14)
1111%r15(%r15)
  • SIBの各フィールドは,Scale,Indexレジスタ, Baseレジスタを指定します. (例えば,メモリ参照 (%rax, %rbx, 2)で, %raxがBaseレジスタ,%rbxがIndexレジスタ,2がScaleです).
Scaleの値乗数
001
012
104
118
$ gcc -g movq-11.s
$ objdump -d ./a.out
0000000000001129 <main>:
  1129: 4c 89 05 e8 03 00 00 	mov  %r8,0x3e8(%rip)
  1130: 4e 89 84 48 e8 03 00 	mov  %r8,0x3e8(%rax,%r9,2)
  1137: 00 
  • mov %r8, 0x3e8(%rip)の機械語バイト列を見てみます

    • Mod=00で,メモリ参照,変位は4バイトを意味します(これはR/M=101の時の例外).
    • REX.R=1とReg=000で,%r8レジスタを意味します.
    • R/M=101で,%rip相対アドレッシングを意味します.
    • 89movqのオペコードです. 処理方向ビットが0なのでRegR/Mに代入を意味します.
    • 最後の4バイト (E8 03 00 00)は4バイトの変位です.

  • movq %r8, 0x3e8(%rax,%r9,2)の機械語バイト列を見てみます

    • Mod=10で,メモリ参照,変位は4バイトを意味します.
    • REX.R=1とReg=000で,%r8レジスタを意味します.
    • R/M=100で,SIBバイトの使用を意味します.
    • Scale=01で,0x3e8(%rax,%r9,2)2を意味します.
    • REX.B=0とBase=000で,Indexレジスタとして%raxを使用します.
    • REX.X=1とIndex=001で,Baseレジスタとして%r9を使用します.
  • SIBバイトのBaseフィールドはREX.BとともにBaseレジスタを指定します. ただし,❶❷の部分が特別扱いです.

    • ❶❷の101かつMod=00の場合,(%rbp)というメモリアクセスではなく, 32ビットの変位のみでのメモリアクセスになります.
    • 例えば,movq %r8,0x1000の機械語バイト列は 4c 89 04 25 00 10 00 00になります. Mod=00, Base=101となっていますね.
REX.BBase指定される
Baseレジスタ
0000%rax
0001%rcx
0010%rdx
0011%rbx
0100%rsp
0❶101%rbp,Mod=00の時は4バイトの変位のみ
0110%rsi
0111%rdi
1000%r8
1001%r9
1010%r10
1011%r11
1100%r12
1❷101%r13,Mod=00の時は4バイトの変位のみ
1110%r14
1111%r15
  • SIBバイトのIndexフィールドはREX.Xと合わせて,Indexレジスタを指定します.

    • ただし%rspは指定不可です
REX.XIndexReg
0000%rax
0001%rcx
0010%rdx
0011%rbx
0100無し(%rspは使用不可)
0101%rbp
0110%rsi
0111%rdi
1000%r8
1001%r9
1010%r10
1011%r11
1100%r12
1101%r13
1110%r14
1111%r15

データの変換

ゼロ拡張

  • ゼロ拡張 (zero extension)は上位ビットを0で埋めてビット列を大きくする変換.
  • 符号なし整数をゼロ拡張すると,値は変化しない.
    • 例: 2バイトの符号なし整数65535をゼロ拡張で4バイトに変換しても,値は変化しない.
データサイズビット表現
2バイト11111111 1111111165535
4バイト00000000 00000000 11111111 1111111165535

符号拡張

  • 符号拡張 (sign extension)は
    • 上位ビットを元データのMSBで埋めてビット列を大きくする変換.
    • つまり,正の場合は0を,負の場合は1を上位ビットに埋める.
  • 符号あり整数を符号拡張すると,値は変化しない.
データサイズビット表現
2バイト01111111 1111111132767
4バイト00000000 00000000 01111111 1111111132767

データサイズビット表現
2バイト11111111 11111111-1
4バイト11111111 11111111 11111111 11111111-1
  • 符号あり整数をゼロ拡張すると,値が変化する.
データサイズビット表現
2バイト11111111 11111111-1
4バイト00000000 00000000 11111111 1111111165535

movz␣␣movs␣␣命令

  • movz␣␣はゼロ拡張をしてデータをコピーする命令 (move with zero extension)
  • movs␣␣は符号拡張をしてデータをコピーする命令 (move with sign extension)
  • 通常,値を変化させたくないので,符号なし整数にはmovz␣␣を使い, 符号あり整数にはmovs␣␣命令を使う.

切り詰め (truncation)

  • 切り詰め = 上位ビットを捨ててビット列を小さくする変換
  • 符号あり整数を切り詰めると,正負が変わることがある
データサイズビット表現
4バイト00000000 00000001 10000110 10100000100000
2バイト10000110 10100000-31072
#include <stdio.h>
#include <stdint.h>
int main (void)
{
    int32_t i = 100000;
    int16_t s = i;
    printf ("%d\n", s);
}

$ gcc -g trunc.c
$ ./a.out
-31072
  • 4バイトから2バイトへ切り詰めは, 例えば,%eaxに値を入れて,%axで値を取り出せば可能.
# asm/trunc2.s
    .text
    .globl main
    .type main, @function
main:
    movl $100000, %eax
    movw %ax, %bx
    ret
    .size main, .-main
$ gcc -g trunc2.s
$ gdb ./a.out -x trunc2.txt
Breakpoint 1 at 0x1131: file trunc2.s, line 8.
Breakpoint 1, main () at trunc2.s:8
8	    ret
$1 = -31072
# -31072 が出力されれば成功

メモリレイアウト

多バイト長データはメモリの連続領域に格納

  • 通常,メモリはバイトアドレッシングです. つまり,1つの番地ごとに1バイトの情報を格納できます.
  • 多バイト長データ(2バイト以上のデータ)をメモリに格納する時は, メモリの連続領域を使ってデータを格納します.
    • 上図では4バイトのデータ 0x11223344をメモリの1000〜1003番地を使って格納していることを示しています.
    • このデータを読み書きする場合は,先頭番地の1000番地を使います.

バイトオーダとエンディアン

  • 多バイト長のデータをメモリに格納するには1バイトごとに分割して格納します. 多バイト長のデータをバイト単位で格納する順序をバイトオーダ(byte order)といいます.

  • 多バイト長データで最下位のバイトをLeast Significant Byte (LSB), 最上位のバイトをMost Significant Byte (MSB)と呼びます.

    • 例えば,0x11223344という4バイトのデータのLSBは0x44,MSBは0x11です.
  • 多バイト長データをメモリに格納する時,

    • LSBから先にメモリに格納する方法を
      リトルエンディアン
      (little endian)
    • MSBから先にメモリに格納する方法をビッグエンディアン (big endian) と呼びます.
エンディアンの由来とは

エンディアン(endian)という言葉はガリバー旅行記から来ています. お話の中で,卵の殻は尖った方からむくべき派 (little endian)と 丸い方からむくべき派 (big endian)が争うのです.なのでインディアンとは何の関係もありません.

アラインメントとパディング

アラインメント

アラインメント制約
1バイト整数1の倍数のアドレス
2バイト整数2の倍数のアドレス
4バイト整数4の倍数のアドレス
8バイト整数8の倍数のアドレス
ポインタ(8バイト長)8の倍数のアドレス
16バイト整数16の倍数のアドレス
スタックフレーム16の倍数のアドレス
  • アラインメント (境界調整,alignment)とは, 特定のアドレス(例: 16の倍数のアドレス)にデータを格納することです
    • 16の倍数のアドレスに格納することを, 16バイト境界 (16-byte boundary)に格納する,という言い方もします.
  • ABIはアラインメントを守ることを プログラムに要求します.このお約束をアラインメント制約といいます. 上の表は System V ABI (AMD64) が定めるアラインメント制約です.
    • スタックフレームの16バイト境界への制約は call命令実行時に%rspの値が16の倍数であることを要求しています.

アラインメント制約に違反すると最悪,クラッシュする

  • アラインメント制約に違反すると,実行速度が遅くなったり, Segmentation faultなどの実行時エラーが生じます(例: AVX命令のmovdqa). このため,コンパイラはアラインメント制約を満たすコードを出力します. 人手でアセンブリコードを書く場合は, プログラマがアラインメント制約を守るよう注意する必要があります.

アラインメント調整 (.align)とパディング

  • アラインメント制約はアセンブラ命令.alignを使って満たせます (他にも .skip, .space, .zeroなどのアセンブラ命令でも可能です).

    char x1 = 10;
    int  x2 = 20;
    
        .globl  x1
        .data
        .type   x1, @object
        .size   x1, 1
    x1:
        .byte   10
        .globl  x2
      ❶.align 4
        .type   x2, @object
        .size   x2, 4
    x2:
        .long   20
    
    • 例えば,上のchar型のx1int型のx2というグローバル変数の宣言があった場合,コンパイラは上のようなアセンブリコードを出力します.
    • 仮にx1を1000番地に配置したとすると,そのまま次の領域にx2を配置すると x2は1001番地に配置することになります. 1001番地は4バイト境界(4の倍数のアドレス) ではないのでアラインメント制約違反になってしまいます.
    • これを避けるため❶.align 4というアセンブラ命令を使用します. .align 4は「次に出力するアドレスを4の倍数になるよう(最小限増やして)調整しろ」という意味です.その結果,x2のアドレスは1004番地になります.
    • なお未使用の1001〜1003番地のメモリ領域のことをパディング(詰め物,padding)といいます.

構造体のパディング

// asm/struct3.c
struct foo {
    char x1;
    int x2;
};
struct foo f = {10, 20};
    .globl  f
    .data
    .align 8            # 構造体全体を8バイト境界に配置
    .type   f, @object
    .size   f, 8
f:
    .byte   10          # f.x1
    .zero   3           # 3バイトのゼロを出力(3バイトのパディング)
    .long   20          # f.x2
  • アラインメント制約のために,構造体の中にもパディングが生じます
  • 上の例ではchar型のx1int型のx2の間に3バイトのパディングができています
  • 構造体の先頭にパディングが入ることはありません. 一方,構造体の末尾にパディングが入ることはあります(次の節で説明)

配列のためのパディング

  • (構造体にはパディングが生じますが)配列にはパディングは入りません. 配列のすべての要素は常にぴったりくっついて, メモリ上で連続したアドレスに格納されます. 例えば,int a1 [3]; のメモリレイアウトは以下の図になります. (配列の各要素(ここではint)もアラインメント制約を満たしています (ここではアドレスが4の倍数になっています))

  • これは2次元配列になっても同じです. 例えば,int a2 [2][3];のメモリレイアウトは(C言語では)以下の図になります.

  • 「じゃあ,サイズが5バイトの構造体を作って,その構造体の配列を定義したら, その配列の要素はアラインメント制約を満たせないのでは?」

    答えは「はい,満たせなくなります.ですので,(構造体中にint型など アラインメント制約を持つメンバーがある場合は)サイズが5バイトの構造体は作れません」「その場合は構造体のお尻にパディングが入ります」. (なお構造体のメンバーがcharchar[]など,どの場所にも置けるデータのみの 場合は5バイトの構造体を作れます).

// asm/struct4.c
#include <stdio.h>
struct foo2 {
    int x2;
    char x1;
};
struct foo2 f = {10, 20};
int main ()
{
    printf ("%ld\n", sizeof (struct foo2));
}
    .globl  f
    .data
    .align 8
    .type   f, @object
    .size   f, 8  # 構造体全体のサイズは8バイト
f:
    .long   10
    .byte   20
    .zero   3     # 構造体のお尻に3バイトのパディングが入っている

実際にやってみると,構造体のお尻に3バイトのパディングが入りました.

x86-64機械語命令

x86-64機械語命令の実行方法

概要:デバッガ上で実行します

機械語命令を実行しても,単にa.outを実行するだけでは 意図通りに実行できたかの確認が難しいです. (アセンブリコード内からprintfを呼び出せばいいのですが, そのコードもうざいので). そこで本書ではデバッガを使って機械語命令の実行と確認を行います.

以下では例としてmovq $999, %raxという機械語命令を実行してみます. (これらのファイルはサンプルコードから入手できます). movq $999, %raxは「定数999%raxレジスタに格納する」という命令ですので, 実行後,%raxレジスタに999という値が入っていれば, うまく実行できたことを確認できます.

# asm/movq-4.s
    .text
    .globl main
    .type main, @function
main:
    movq $999, %rax
    ret
    .size main, .-main

実行確認のためのgdbのコマンド列を書いたファイルも用意しています.

# asm/movq-4.txt
b 7
r
list 6,6
p $rax
echo # %raxの値が999なら成功\n
quit

デバッガ上で実行:gdbコマンドを手入力

asm/movq-4.txt中のgdbコマンドを 以下のように1行ずつ入力してみて下さい. (この章でもgdbの使い方を説明していきますが, gdbの使い方の詳細はデバッガgdbの使い方にもまとめてあります).

$ gcc -g movq-4.s
$ gdb ./a.out ❶
(gdb) b 7 ❷
Breakpoint 1 at 0x1130: file movq-4.s, line 6.
(gdb) r ❸
Breakpoint 1, main () at movq-4.s:6
6	    ret
(gdb) list 6,6 ❹
5	    movq $999, %rax
(gdb) p $rax ❺
$1 = 999
(gdb) quit
  • ❶ コンパイルしたa.outgdb上で実行
  • ❷ ブレークポイントを7行目(movq $999, %raxの次の行)に設定 (bはbreakの略)
  • ❸ 実行開始 (r は run の略)
  • ❹ ソースコードの6行目だけを表示
  • ❺ レジスタ%raxの値を(10進表記で)表示 (pはprintの略)
  • movq-4.txt の最後の行 echo # %raxの値が999なら成功\nは, 「どうなると正しく実行できたか」を確認するメッセージを出力するコマンドですので, ここでは入力不要です. ❺の結果と一致したので「正しい実行」と確認できました.

デバッガ上で実行:gdbコマンドを自動入力 (-xオプションを使う)

gdbコマンドを手入力して,よく使うgdbコマンドを覚えることは良いことです. とはいえ,手入力は面倒なので,自動入力も使いましょう.

$ gcc -g movq-4.s
$ gdb ./a.out ❶ -x movq-4.txt
Breakpoint 1, main () at movq-4.s:7
7	    ret
6	 ❷ movq $999, %rax
❸ $1 = 999 
❹ # %raxの値が999なら成功
(gdb) 

  • -x movq-4.txt というオプションをつけると,指定したファイル(ここではmovq-4.txt)の中に書かれているgdbコマンドを1行ずつ順番に実行してくれます
  • list 6,6を実行した結果,❷6行目のmovq $999, %rax が表示されています
  • p $raxを実行した結果,%raxレジスタの値が❸999であると表示されています. ($1gdbが扱う変数です.ここでは無視して下さい)
  • echo # %raxの値が999なら成功\ngdbが実行した結果, ❹ # %raxの値が999なら成功というメッセージが表示されています. このメッセージと❸の実行結果を見比べれば「実行結果が正しい」ことを確認できます.

デバッガ上で実行:gdbコマンドを自動入力 (sourceコマンドを使う)

$ gcc -g movq-4.s
$ gdb ./a.out
(gdb) ❶ source movq-4.txt
Breakpoint 1, main () at movq-4.s:7
7	    ret
6	    movq $999, %rax
$1 = 999
# %raxの値が999なら成功

gdbは通常通りに実行開始して, ❶ sourceコマンドを使えば,movq-4.txt中のgdbコマンドを実行できます. -xオプションとsourceコマンドは好きな方を使って下さい.

アドレッシングモード (オペランドの表記方法)

アドレッシングモードの概要

機械語命令は命令(オペコード(opcode))と その引数のオペランド(operand)から構成されています. 例えば,movq $999, %raxという命令では, movqがオペコードで,$999%raxがオペランドです.

アドレッシングモードとはオペランドの書き方のことです. (元々は「メモリのアドレスを指定する記法」という意味で「アドレッシングモード」という用語が使われています). x86-64では大きく,以下の4種類の書き方ができます.

アドレッシング
モードの種類
オペランドの値

即値(定数)

定数の値movq $0x100, %rax
movq $foo, %rax

レジスタ参照

レジスタの値movq %rbx, %rax

直接メモリ参照

定数で指定した
アドレスのメモリ値
movq 0x100, %rax
movq foo, %rax

間接メモリ参照

レジスタ等で計算した
アドレスのメモリ値
movq (%rsp), %rax
movq 8(%rsp), %rax
movq foo(%rip), %rax
  • fooはラベル(その値はアドレス)であり,定数と同じ扱い.(定数を書ける場所にはラベルも書ける).
  • メモリ参照では例えば-8(%rbp, %rax, 8)など複雑なオペランドも指定可能. 参照するメモリのアドレスは-8+%rbp+%rax*8になる. (以下を参照).

アドレッシングモード:即値(定数)

定数 $999

即値(immediate value,定数)には$をつけます. 例えば$999は定数999を意味します.

# asm/movq-4.s
    .text
    .globl main
    .type main, @function
main:
    movq $999, %rax
    ret
    .size main, .-main

movq-4.sの6行目の movq $999, %raxは「定数999をレジスタ%raxに格納する」という意味です. デバッガで動作を確認します (デバッガの操作手順はmovq-4.txtにもあります).

$ gcc -g movq-4.s
$ gdb ./a.out
(gdb) b main
Breakpoint 1 at 0x1129: file movq-4.s, line 6.
(gdb) r
Breakpoint 1, main () at movq-4.s:6
6	    movq $999, %rax
(gdb) si
main () at movq-4.s:7
7	    ret
(gdb) p $rax
$1 = 999

確かに%raxレジスタ中に999が格納されていました.

なお,多くの場合,即値は32ビットまでで,オペランドのサイズが64ビットの場合, 32ビットの即値は,64ビットの演算前に 64ビットに符号拡張 されます (ゼロ拡張だと 負の値が大きな正の値になって困るからです). 64ビットに符号拡張される例はこちら を見て下さい. 例外はmovq命令で,64ビットの即値を扱えます. 実行例はこちらを見て下さい.

ラベル $main

定数が書ける場所にはラベル(その値はアドレス)も書けます. ラベルは関数名やグローバル変数の実体があるメモリの先頭番地を 示すために使われます(それ以外にはジャンプのジャンプ先としても使われます). ですので,main関数の先頭番地を示すmainというラベルが main関数をコンパイルしたアセンブリコード中に存在します.

# asm/movq-6.s
    .text
    .globl main
    .type main, @function
main:
    movq $main, %rax
    ret
    .size main, .-main

movq-6.sの6行目のmovq $main, %raxは 「ラベルmainが表すアドレスを%raxレジスタに格納する」という意味です. gdbで確かめます.

$ gcc ❶ -no-pie -g movq-6.s
$ gdb ./a.out
(gdb) b main
Breakpoint 1 at ❷ 0x40110a: file movq-6.s, line 7.
(gdb) r
Breakpoint 1, main () at movq-6.s:7
7   ❸  movq $main, %rax
(gdb) ❹ si
main () at movq-6.s:8
8	ret
(gdb) p/x $rax
$1 = ❺ 0x40110a 
  • まず❶ -no-pieオプションをつけてコンパイルして下さい. (-staticオプションを使ってもうまくいくと思います)
なぜ -no-pieオプション

-no-pieオプションをつけないと以下のエラーが出てしまうからです.

$ gcc -g movq-6.s
/usr/bin/ld: /tmp/ccqHsPbg.o: relocation R_X86_64_32S against symbol `main' can not be used when making a PIE object; recompile with -fPIE
/usr/bin/ld: failed to set dynamic section sizes: bad value
collect2: error: ld returned 1 exit status

-no-pieは「位置独立実行可能ファイル (PIEの説明1PIEの説明2)を生成しない」 というオプションです. 最近のLinuxのgccでは,PIEがデフォルトで有効になっている事が多いです. PIC(位置独立コード)やPIEは「再配置(アドレス調整)無しに どのメモリ番地に配置しても,そのまま実行可能」という機械語命令列です. そのため,PIEやPICのメモリ参照では絶対アドレス(absolute address)が使えません.

-no-pieオプションが無いと, アセンブラはmovq $main, %raxという命令中のmainというラベルを 「絶対アドレスだ」と解釈してエラーにするようです.

絶対アドレス,相対アドレスとは

絶対アドレスとは「メモリの先頭0番地から何バイト目か」で示すアドレスです. 上図で青色のメモリ位置の絶対アドレスは0x1000番地となります. 一方,相対アドレス(relative address)は(0番地ではなく)別の何かを起点とした差分のアドレスです. x86-64では%ripレジスタ(プログラムカウンタ)を起点とすることが多いです. 上図では青色のメモリ位置の相対アドレスは %ripを起点とすると,-0x500番地となります(0x1000 - 0x1500 = -0x500).

また,相対アドレスに起点のアドレスを足すと絶対アドレスになります (-0x500 + 0x1500 = 0x1000).

なぜ PICやPIEで絶対アドレスが使えないかと言うと, 機械語命令列を何番地に置くかで,絶対アドレスが変化してしまうからです.

もうちょっと具体的に

例えば,movq $main, %raxという命令は main関数のアドレスを%raxレジスタに格納するわけですが, このアドレスが絶対アドレスの場合,出力される機械語命令に 絶対アドレスが埋め込まれてしまいます.

$ gcc -no-pie -g movq-6.s
$ objdump -d ./a.out
(一部略)
000000000040110a <main>:
  40110a:  48 c7 c0 ❷ 0a 11 40 00    mov ❶$0x40110a,%rax
  401111:  c3                        ret    

上の逆アセンブル結果を見ると,確かにmain関数のアドレス❶ 0x40110aが 機械語命令列に❷埋め込まれています. (x86-64はリトルエンディアンなので,バイトの並びが逆順に見えることに注意).

相対アドレスだと大丈夫なことも見てみます. leaq-1.s中の leaq main(%rip), %raxは, 「%ripを起点としたmainの相対アドレスと, %ripの値との和を%raxレジスタに格納する」という命令です. (lea は load effective address の略です.effective addressは日本語では実効アドレスです).

# asm/leaq-1.s
    .text
    .globl main
    .type main, @function
main:
    leaq main(%rip), %rax
    ret
    .size main, .-main
$ gcc -g leaq-1.s
$ objdump -d ./a.out
(一部略)
0000000000001129 <main>:
 ❶ 1129:  48 8d 05 ❸ f9 ff ff ff    lea    ❷ -0x7(%rip),%rax  # 1129 <main>
 ❹ 1130:  c3                      ret    

上のように逆アセンブルすると以下が分かります.

  • main関数の(ファイルa.out中での)アドレスは❶ 0x1129番地
  • leaq main(%rip), %rax%ripの値は❸ 0x1130番地 (プログラムカウンタ %ripは「次に実行する機械語命令のアドレス」を保持しています).
  • 機械語命令に埋め込まれているアドレスは相対アドレスで, ❶ 0x1129 - ❸ 0x1130 = ❷ -0x7 = ❸ 0xFFFFFFF9 です.

0x1129 や ❹ 0x1130 のアドレスは, main関数がどのアドレスに配置されるかで変化します. しかし,この相対アドレス❷ -0x7main関数がどのアドレスに配置されても変化しないので, この機械語命令はPICやPIEとして使えるわけです.

-0x7 が ❸ 0xFFFFFFF9 として埋め込まれているのは, 2の補数表現だからですね

なお,相対アドレスが固定にならない場合(例えば,printf関数のアドレス)もあります. その場合はGOTやPLTを使います. printf関数のアドレスを機械語命令列(.textセクション)に埋め込むのではなく, 別の書込み可能なセクション(例:gotセクション)に格納し, そのアドレスを使って間接コール(indirect call)するのです.

-staticオプションとは

-staticオプションは(動的リンクではなく) 静的リンク せよという,gccへの指示になります.

  • main関数の先頭にブレークポイントを設定します. main関数の先頭アドレスが❷ 0x40110aと分かります.

  • movq $main, %raxの実行直前で止まっているので, ❹ siで1命令実行を進めます.

  • %raxレジスタ中にmain関数のアドレス❷ 0x40110aが入っていました.

アドレッシングモード:レジスタ参照

# asm/movq-1.s
    .text
    .globl main
    .type main, @function
main:
    movq $999, %rax
    movq %rax, %rbx
    ret
    .size main, .-main

movq-1.s中のmovq %rax, %rbxは 「%raxレジスタ中の値を%rbxに格納する」という意味です.

$ gcc -g movq-1.s
$ gdb ./a.out
(gdb) b main
Breakpoint 1 at 0x1129: file movq-1.s, line 6.
(gdb) r
Breakpoint 1, main () at movq-1.s:6
6	    ❶ movq $999, %rax
(gdb) si
7	    ❷ movq %rax, %rbx
(gdb) si
main () at movq-1.s:8
8	    ret
(gdb) p $rax
$1 = ❸ 999
(gdb) p $rbx
$2 = ❹ 999

gdb上での実行で,❶ 定数999%raxに格納され, ❷ %rax中の999がさらに%rbxに格納されたことを ❸❹確認できました.

アドレッシングモード:直接メモリ参照

直接メモリ参照はアクセスするメモリ番地が定数となるメモリ参照です. 以下の例ではラベルxを使ってメモリ参照していますが, これは直接メモリ参照になります. アセンブル時に(つまり実行する前に)アドレスが具体的に(以下では0x404028番地)と決まるからです.

# asm/movq-7.s
    .data
x:
    .quad 999 
    .text
    .globl main
    .type main, @function
main:
    movq x, %rax
    ret
    .size main, .-main
$ gcc -g -no-pie movq-7.s
$ gdb ./a.out -x movq-7.txt
Breakpoint 1, main () at movq-7.s:10
10	    ret
9	    movq x, %rax
$1 = ❶ 999
# %raxの値が999なら成功

以下の図で0x401106<main>は「ラベルmainが示すアドレスは0x401106番地」 「ラベルxが示すアドレスは0x404028番地」であることを示してます.

そしてmovq-7.s中の以下の3行で,以下は

    .data
x:
    .quad 999 

.dataセクションにサイズが8バイトのデータとして値999を配置せよ」 「そのデータの先頭アドレスをラベルxとして定義せよ」を意味しています (quadが8バイトを意味しています). ですので,実行時には上図のように 「.dataセクションのある場所(上図では0x404028番地)に値999が入っていて, ラベルxの値は0x404028」となっています.

ですので,movq-7.s中のmovq x, %raxは 「ラベルxが表すアドレス(上図では0x404028番地)のメモリの中身(上図では999) を%raxレジスタにコピーせよ」を意味します.

実行するとmovq x, %raxの実行で,x中の999%raxレジスタに コピーされたことを確認できました❶.

ここで$マークの有無,つまりx$xの違いに注意しましょう (上図も参照).

movq x, %rax    # x はメモリの中身を表す
movq $x, %rax   # $x はアドレスを表す

以下のようにmovq $x, %raxを実行すると, %raxレジスタにはアドレス(ここでは0x404028番地)が 入っていることを確認できました❷.

-8(%rbp)の-8には(定数なのに)$マークが付かない

以下でも説明しますが, 例えば-8(%rbp)とオペランドに書いた時,-8は($マークが無いのに) 定数として扱われます. そして,-8(%rbp)は,%rbp - 8の計算結果をアドレスとするメモリの中身を意味します.  ちなみにこの-8のことは Intelのマニュアルでは変位 (displacement)と呼ばれています. つまり「変位は定数だけど$マークはつきません」.

# asm/movq-8.s
    .data
x:
    .quad 999 
    .text
    .globl main
    .type main, @function
main:
    movq $x, %rax
    ret
    .size main, .-main
$ gcc -g -no-pie movq-8.s
$ gdb ./a.out -x movq-8.txt
Breakpoint 1, main () at movq-8.s:10
10	    ret
9	    movq $x, %rax
$1 = 0x404028 ❷
nm ./a.out | egrep 'd x'
0000000000404028 d x
# %raxの値と nmコマンドによるxのアドレスが一致すれば成功

ちなみに,xのアドレスが0x404028になると分かっていれば,

movq x, %rax          # これと
movq 0x404028, %rax   # これは同じ意味

上の2行は全く同じ意味(0x404028番地のメモリの中身)になります. しかし,何番地になるか事前に分からないのが普通なので, 通常はラベル(ここではx)を使います.

アドレッシングモード:間接メモリ参照

間接メモリ参照はアクセスするメモリ番地が変数となるメモリ参照です. アセンブリ言語では変数という概念は無いので, 正確には「実行時に決まるレジスタの値を使って, 参照先のメモリアドレスを計算して決める」という参照方式です. 以下では3つの例が出てきます(以下でより複雑な間接メモリ参照を説明します).

間接メモリ参照計算するアドレス
(%rsp)%rsp
8(%rsp)%rsp + 8
foo(%rip)%rip + foo

以下のmovq-9.spushq $777まで実行すると, メモリの状態は上図のようになっています. (%rspが指す777のひとつ下のアドレスが%rsp+8なのは, pushq $777命令が「サイズが8バイトの値777をスタックにプッシュしたから」です).

# asm/movq-9.s
    .data
foo:
    .quad 999 
    .text
    .globl main
    .type main, @function
main:
    pushq $888
    pushq $777
    movq (%rsp), %rax
    movq 8(%rsp), %rbx
    movq foo(%rip), %rcx
    ret
    .size main, .-main
  • (%rsp) は「アドレスが %rspの値のメモリ」なので値777が入っている部分を参照します
  • 8(%rsp) は「アドレスが %rsp + 8の値のメモリ」なので値888が入っている部分を参照します
  • foo(%rip) はちょっと特殊です.この形式は %rip相対アドレッシング といいます. この形式の時,ラベルfooの値はプログラムカウンタ%rip中のアドレスを起点とした 相対アドレス になります.ですので,%rip + foofoo絶対アドレス になるので, foo(%rip)はラベルfooのメモリ部分,つまり999が入っている部分になります.
gdbでの実行結果
$ gcc -g movq-9.s
$ gdb ./a.out -x movq-9.txt
Breakpoint 1, main () at movq-9.s:14
14	    ret
11	    movq (%rsp), %rax
12	    movq 8(%rsp), %rbx
13	    movq foo(%rip), %rcx
$1 = 777
$2 = 888
$3 = 999
# 777, 888, 999なら成功

メモリ参照の一般形

前節では, (%rsp)8(%rsp)foo(%rip)という間接メモリ参照の例を説明しました. ここではメモリ参照の一般形を説明します. 以下がx86-64のメモリ参照の形式です.

AT&T形式Intel形式計算されるアドレス
通常のメモリ参照disp (base, index, scale)[base + index * scale + disp]base + index * scale + disp
%rip相対参照disp (%rip)[rip + disp]%rip + disp
「segment: メモリ参照」という形式

実は「segment: メモリ参照」という形式もあるのですが, あまり使わないので,ここでは省いて説明します. 興味のある人はこちらを参照下さい.

disp (base, index, scale) でアクセスするメモリのアドレスは base + index * scale + disp で計算します. disp(%rip)でアクセスするメモリのアドレスは disp + %ripで計算します. disp,base,index,scaleとして指定可能なものは次の節で説明します.

メモリ参照で可能な組み合わせ(64ビットモードの場合)

通常のメモリ参照

通常のメモリ参照では,disp,base,index,scaleに以下を指定できます.

  • disp には符号あり定数を指定する.ただし「64ビット定数」は無いことに注意. アドレス計算時に64ビット長に符号拡張される. dispは変位(displacement)を意味する.
  • base には上記のいずれかのレジスタを指定可能.省略も可.
  • index には上記のいずれかのレジスタを指定可能.省略も可. %rspを指定できないことに注意.
  • scale を省略すると 1 と同じ

注: dispの例外. mov␣命令のみ,64ビットのdispを指定可能. この場合,movabs␣というニモニックを使用可能. (absはおそらく絶対アドレス absolute address から). メモリ参照はdispのみで,base,index,scaleは指定不可. 他方のオペランドは%raxのみ指定可能.

movq     0x1122334455667788, %rax
movabsq  0x1122334455667788, %rax
movq     %rax, 0x1122334455667788
movabsq  %rax, 0x1122334455667788

%rip相対参照

%rip相対参照では32ビットのdispと%ripレジスタのみが指定可能です.

メモリ参照の例

以下がメモリ参照の例です.

AT&T形式Intel形式指定したもの計算するアドレス
8[8]disp8
foo[foo]dispfoo
(%rbp)[rbp]base%rbp
8(%rbp)[rbp+8]dispとbase%rbp + 8
foo(%rbp)[rbp+foo]dispとbase%rbp + foo
8(%rbp,%rax)[rbp+rax+8]dispとbaseとindex%rbp + %rax + 8
8(%rbp,%rax, 2)[rbp+rax*2+8]dispとbaseとindexとscale%rbp + %rax*2 + 8
(%rip)[rip]base%rip
8(%rip)[rip+8]dispとbase%rip + 8
foo(%rip)[rip+foo]dispとbase%rip + foo
%fs:-4fs:[-4]segmentとdisp%fsのベースレジスタ - 4
なんでこんな複雑なアドレッシングモード?

x86-64はRISCではなくCISCなので「よく使う1つの命令で複雑な処理が できれば,それは善」という思想だからです(知らんけど). 例えば,以下のCコードの配列array[i]へのアクセスはアセンブリコードで movl (%rdi,%rsi,4), %eaxの1命令で済みます. (ここではsizeof(int)4なので,scaleが4になっています. 配列の先頭アドレスがarrayの,i番目の要素のアドレスは, array + i * sizeof(int)で計算できることを思い出しましょう. なお,array.sの出力を得るには,gcc -S -O2 array.cとして下さい. 私の環境では-O2が無いとgccは冗長なコードを吐きましたので).

// array.c
int foo (int array [], int i)
{
    return array [i];
}
	.text
	.p2align 4
	.globl	foo
	.type	foo, @function
foo:
	endbr64
	movslq	%esi, %rsi
	movl	(%rdi,%rsi,4), %eax
	ret
	.size	foo, .-foo

オペランドの表記方法

以下の機械語命令の説明で使う記法を説明します. この記法はその命令に許されるオペランドの形式を表します.

オペランド,即値(定数)

記法説明
op1第1オペランド
op2第2オペランド
imm$100imm8, imm16, imm32のどれか
$foo
imm8$1008ビットの即値(定数)
imm16$10016ビットの即値(定数)
imm32$10032ビットの即値(定数)
  • 多くの場合,サイズを省略して単にimmと書きます. 特にサイズに注意が必要な時だけ,imm32などとサイズを明記します.
  • 一部例外を除き, x86-64では64ビットの即値を書けません(32ビットまでです).

汎用レジスタ

記法説明
r%raxr8, r16, r32, r64のどれか
r8%al8ビットの汎用レジスタ
r16%ax16ビットの汎用レジスタ
r32%eax32ビットの汎用レジスタ
r64%rax64ビットの汎用レジスタ

メモリ参照

記法説明
r/m%rbpr/m8, r/m16, r/m32, r/m32, r/m64のどれか
100
-8(%rbp)
foo(%rbp)
r/m8-8(%rbp)r8 または 8ビットのメモリ参照
r/m16-8(%rbp)r16 また は16ビットのメモリ参照
r/m32-8(%rbp)r32 また は32ビットのメモリ参照
r/m64-8(%rbp)r64 また は64ビットのメモリ参照
m-8(%rbp) メモリ参照

x86-64機械語命令:転送など

nop命令: 何もしない

nopは転送命令ではありませんが,最も簡単な命令ですので最初に説明します.


記法何の略か動作
nopno operation何もしない(プログラムカウンタのみ増加)
nop op1no operation何もしない(プログラムカウンタのみ増加)

詳しい記法例の動作サンプルコード
nopnop何もしないnop.s nop.txt
nop r/mnopl (%rax)何もしないnop2.s nop2.txt


  • nopは何もしない命令です(ただしプログラムカウンタ%ripは増加します). フラグも変化しません.
  • 機械語命令列の間を(何もせずに)埋めるために使います.
  • nopの機械語命令は1バイト長です. (なのでどんな長さの隙間にも埋められます).
  • nop r/m という形式の命令は2〜9バイト長のnop命令になります. 1バイト長のnopを9個並べるより, 9バイト長のnopを1個並べた方が,実行が早くなります.
  • 「複数バイトのnop命令がある」という知識は, 逆アセンブル時にnopl (%rax)などを見て「なんじゃこりゃ」とビックリしないために必要です.

mov命令: データの転送(コピー)


記法何の略か動作
mov␣ op1, op2moveop1の値をop2にデータ転送(コピー)

詳しい記法例の動作サンプルコード
mov␣ r, r/mmovq %rax, %rbx%rbx = %raxmovq-1.s movq-1.txt
movq %rax, -8(%rsp)*(%rsp - 8) = %raxmovq-2.s movq-2.txt
mov␣ r/m, rmovq -8(%rsp), %rax%rax = *(%rsp - 8)movq-3.s movq-3.txt
mov␣ imm, rmovq $999, %rax%rax = 999movq-4.s movq-4.txt
mov␣ imm, r/mmovq $999, -8(%rsp)*(%rsp - 8) = 999movq-5.s movq-5.txt


  • mov命令は第1オペランドの値を第2オペランドに転送(コピー)します. 例えば,movq %rax, %rbxは「%raxの値を%rbxにコピー」することを意味します.
movq-1.sの実行例
$ gcc -g movq-1.s
$ gdb ./a.out -x movq-1.txt
Breakpoint 1, main () at movq-1.s:8
8	    ret
7	    movq %rax, %rbx
# p $rbx
$1 = 999
# %rbxの値が999なら成功
movq-2.sの実行例
$ gcc -g movq-2.s
$ gdb ./a.out -x movq-2.txt
Breakpoint 1, main () at movq-2.s:8
8	    ret
7	    movq %rax, -8(%rsp)
# x/1gd $rsp-8
0x7fffffffde90:	999
# -8(%rsp)の値が999なら成功
  • オペランドには,即値,レジスタ,メモリ参照を組み合わせて指定できますが, メモリからメモリへの直接データ転送はできません.

  • には命令サフィックス (q, l, w, b)を指定します. 命令サフィックスは転送するデータのサイズを明示します (順番に,8バイト,4バイト,2バイト,1バイトを示します).

    • movq $0x11, (%rsp) は値0x118バイトのデータとして(%rsp)に書き込む
    • movl $0x11, (%rsp) は値0x114バイトのデータとして(%rsp)に書き込む
    • movw $0x11, (%rsp) は値0x112バイトのデータとして(%rsp)に書き込む
    • movb $0x11, (%rsp) は値0x111バイトのデータとして(%rsp)に書き込む

機械語命令のバイト列をアセンブリコードに直書きできる

movq %rax, %rbxをコンパイルして逆アセンブルすると, 機械語命令のバイト列は48 89 C3となります. .byteというアセンブラ命令を使うと, アセンブラに指定したバイト列を出力できます. 例えば,次のように.byte 0x48, 0x89, 0xC3と書くと, .textセクションに0x48, 0x89, 0xC3というバイト列を出力できます.

# asm/byte.s
    .text
    .globl main
    .type main, @function
main:
    movq %rax, %rbx          # これと
    .byte 0x48, 0x89, 0xC3   # これは同じ意味
    ret
    .size main, .-main
$ gcc -g byte.s
$ objdump -d ./a.out
(中略)
0000000000001129 <main>:
    1129:   ❶48 89 c3     ❸mov    %rax,%rbx
    112c:   ❷48 89 c3     ❹mov    %rax,%rbx
    112f:   c3               ret    

コンパイルして逆アセンブルしてみると, ❷0x48, 0x89, 0xC3を出力できています. 一方,❶0x48, 0x89, 0xC3にも同じバイト列が並んでいます. これは❸movq %rax, %rbx命令の機械語命令バイト列ですね. さらに❷0x48, 0x89, 0xC3の逆アセンブル結果として, ❹movq %rax, %rbxとも表示されています.

つまり,アセンブラにとっては,

  • movq %rax, %rbx というニモニック
  • .byte 0x48, 0x89, 0xC3 というバイト列

は全く同じ意味になるのでした. ですので,.textセクションにニモニックで機械語命令を書く代わりに, .byteを使って直接,機械語命令のバイト列を書くことができます.

異なる機械語のバイト列で,同じ動作のmov命令がある

  • 質問: %raxの値を%rbxにコピーしたい時, movq r, r/mmovq r/m, r のどちらを使えばいいのでしょう?
  • 答え: どちらを使ってもいいです.ただし,異なる機械語命令のバイト列に なることがあります.

実は0x48, 0x89, 0xC3というバイト列は, movq r, r/m を使った時のものです. 一方,movq r/m, r という形式を使った場合は, バイト列は 0x48, 0x8B, 0xD8になります.確かめてみましょう.

# asm/byte2.s
    .text
    .globl main
    .type main, @function
main:
    .byte 0x48, 0x89, 0xC3
    .byte 0x48, 0x8B, 0xD8
    ret
    .size main, .-main
$ gcc -g byte2.s
$ objdump -d ./a.out
(中略)
0000000000001129 <main>:
    1129:    ❶48 89 c3      ❸mov    %rax,%rbx
    112c:    ❷48 8b d8      ❹mov    %rax,%rbx
    112f:      c3             ret    

48 89 c3と❷48 8b d8は異なるバイト列ですが 逆アセンブル結果としては ❸mov %rax,%rbxと❹mov %rax,%rbxと,どちらも同じ結果になりました.

このように同じニモニック命令に対して,複数の機械語のバイト列が存在する時, アセンブラは「実行が速い方」あるいは「バイト列が短い方」を適当に選んでくれます. (そして,アセンブラが選ばない方をどうしても使いたい場合は, .byte等を使って機械語のバイト列を直書きするしかありません).

xchg命令: オペランドの値を交換


記法何の略か動作
xchg op1, op2exchangeop1op2 の値を交換する

詳しい記法例の動作サンプルコード
xchg r, r/mxchg %rax, (%rsp)%rax(%rsp)の値を交換するxchg.s xchg.txt
xchg r/m, rxchg (%rsp), %rax(%rsp)%raxの値を交換するxchg.s xchg.txt

  • xchg命令はアトミックに2つのオペランドの値を交換します.(LOCKプリフィクスをつけなくてもアトミックになります)
  • このアトミックな動作はロックなどの同期機構を作るために使えます.
xchg.sの実行例
$ gcc -g xchg.s
$ gdb ./a.out -x xchg.txt
Breakpoint 1, main () at xchg.s:9
9	    xchg %rax, (%rsp)
1: /x $rax = 0x99aabbccddeeff00
2: /x *(void **)($rsp) = 0x1122334455667788
10	    xchg (%rsp), %rax
1: /x $rax = 0x1122334455667788
2: /x *(void **)($rsp) = 0x99aabbccddeeff00
11	    popq %rax
1: /x $rax = 0x99aabbccddeeff00
2: /x *(void **)($rsp) = 0x1122334455667788
# 値が入れ替わっていれば成功
機械語1命令の実行はアトミックとは限らない

機械語1命令の実行はアトミックとは限りません. 例えば,inc命令(オペランドを1増やす命令)は マニュアルによると「LOCKプリフィックスをつければアトミックに実行される」とあります. inc命令にLOCKプリフィックスがない場合には(たまたまアトミックに実行されるかも知れませんが) 「常にアトミックである」と期待してはいけないのです(マニュアルで「アトミックだ」と明記されていない限り).

なお,incは「メモリから読んだ値に1を足して書き戻す」ため アトミックにならない可能性がありますが,読むだけまたは書くだけでかつ, 適切にアラインメントされていれば, そのメモリ操作はアトミックになります

lea命令: 実効アドレスを計算


記法何の略か動作
lea␣ op1, op2load effective addressop1 の実効アドレスを op2 に代入する

詳しい記法例の動作サンプルコード
lea␣ m, rleaq -8(%rsp, %rsi, 4), %rax%rax=%rsp+%rsi*4-8leaq-2.s leaq-2.txt

  • lea命令は第1オペランド(常にメモリ参照)の実効アドレスを計算して, 第2オペランドに格納します.
  • lea命令はアドレスを計算するだけで,メモリにはアクセスしません.
leaq-2.sの実行例
$ gcc -g lea.s
$ gdb ./a.out -x lea.txt
Breakpoint 1, main () at leaq-2.s:8
8	    ret
# p/x $rsp
$1 = 0x7fffffffde98
# p/x $rsi
$2 = 0x8
# p/x $rax
$3 = 0x7fffffffdeb0
# %rax == %rsp + %rsi * 4 なら成功
実効アドレスとリニアアドレスの違いは?→(ほぼ)同じ
  • 実効アドレス(effective address)は メモリ参照で disp (base, index, scale) や disp (%rip)から計算したアドレスのことです.
  • x86-64のアセンブリコード中のアドレスは論理アドレス (logical address)といい, セグメント実効アドレスのペアとなっています. このペアをx86-64用語でfarポインタとも呼びます. (本書ではfarポインタは扱いません).
  • セグメントが示すベースアドレスと実効アドレスを加えたものが リニアアドレス(linear address)です. 例えば64ビットアドレス空間だと,リニアアドレスは0番地から264-1番地 まで一直線に並ぶのでリニアアドレスと呼ばれています. リニアアドレスは仮想アドレス(virtual address)と等しくなります.
  • また,x86-64では%fsと%gsを除き, セグメントが示すベースアドレスが0番地なので, 実効アドレスとリニアアドレスは等しくなります
  • リニアアドレス(仮想アドレス)はCPUのページング機構により, 物理アドレスに変換されて,最終的なメモリアクセスが行われます.
  • コンパイラは加算・乗算を高速に実行するためlea命令を使うことがあります.

例えば,

movq $4, %rax
addq %rbx, %rax
shlq $2, %rsi   # 左論理シフト.2ビット左シフトすることで%rsiを4倍にしている
addq %rsi, %rax

は,%rax = %rbx + %rsi * 4 + 4という計算を4命令でしていますが, lea命令なら以下の1命令で済みます

leaq 4(%rbx, %rsi, 4), %rax

注: 実行時間は命令ごとに異なりますので,命令数だけで 実行時間を比較することはできません.

pushpop命令: スタックとデータ転送


記法何の略か動作
push␣ op1pushop1 をスタックにプッシュ
pop␣ op1popスタックから op1 にポップ

詳しい記法例の動作サンプルコード
push␣ immpushq $999%rsp-=8; *(%rsp)=999push1.s push1.txt
push␣ r/m16pushw %ax%rsp-=2; *(%rsp)=%axpush2.s push2.txt
push␣ r/m64pushq %rax%rsp-=8; *(%rsp)=%raxpush-pop.s push-pop.txt
pop␣ r/m16popw %ax*(%rsp)=%ax; %rsp += 2pop2.s pop2.txt
pop␣ r/m64popq %rbx%rbx=*(%rsp); %rsp += 8push-pop.s push-pop.txt


  • push命令はスタックポインタ%rsp減らしてから, スタックトップ(スタックの一番上)にオペランドの値を格納します.
  • pop命令はスタックトップの値をオペランドに格納してから, スタックポインタを増やします.
  • 64ビットモードでは,32ビットのpushpopはできません.
  • 抽象データ型のスタックは(スタックトップに対する)プッシュ操作とポップ操作しか できませんが,x86-64のスタック操作はスタックトップ以外の部分にも自由にアクセス可能です(例えば,-8(%rsp)-8(%rbp)などへのメモリ参照で).
  • 一番右側の図(popq %rbx後)で,ポップ後も%rspよりも上に古い値が残っています (0x110x88).このように,ポップしてもスタック上に古い値がゴミとして残ります.
push1.sの実行例
$ gcc -g push1.s
$ gdb ./a.out -x push1.txt
Breakpoint 1, main () at push1.s:6
6	    pushq $999
# p/x $rsp
$1 = 0x7fffffffde98
main () at push1.s:7
7	    ret
# p/x $rsp
$2 = 0x7fffffffde90
# x/1gd $rsp
0x7fffffffde90:	999
# %rsp が8減って,(%rsp)の値が999なら成功
push2.sの実行例
$ gcc -g push2.s
$ gdb ./a.out -x push2.txt
Breakpoint 1, main () at push2.s:6
6	    pushw $999
# p/x $rsp
$1 = 0x7fffffffde98
main () at push2.s:7
7	    ret
# p/x $rsp
$2 = 0x7fffffffde96
# x/1hd $rsp
0x7fffffffde96:	999
# %rsp が2減って,(%rsp)の値が999なら成功
pop2.sの実行例
$ gcc -g pop2.s
$ gdb ./a.out -x pop2.txt
Breakpoint 1, main () at pop2.s:7
7	    popw %ax
# p/x $rsp
$1 = 0x7fffffffde96
main () at pop2.s:8
8	    ret
# p/x $rsp
$2 = 0x7fffffffde98
# p/d $ax
$3 = 999
# %rsp が2増えて,%axの値が999なら成功
push-pop.sの実行例
$ gcc -g push-pop.s
$ gdb ./a.out -x push-pop.txt
Breakpoint 1, main () at push-pop.s:8
8	    pushq %rax
# p/x $rsp
$1 = 0x7fffffffde98
main () at push-pop.s:9
9	    popq  %rbx
# p/x $rsp
$2 = 0x7fffffffde90
# x/8bx $rsp
0x7fffffffde90:	0x88	0x77	0x66	0x55	0x44	0x33	0x22	0x11
# %rsp の値が8減って,スタックトップ8バイトが 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11なら成功

x86-64機械語命令: 算術・論理演算

概要とステータスフラグ

ここでは以下の算術・論理演算を説明します.

演算の種類主な命令
算術add, sub, mul, div, inc, dec, not
論理and, or, not, xor
シフトsal, sar, shl, shr, rol, ror, rcl, rcr
比較cmp, test
変換(拡張)movs, movz, cbtw, cqto

これらの命令のほとんどが演算の結果として, ステータスフラグ の値を変化させます. 本書ではステータスフラグの変化を以下の記法で表します.

CFOFSFZFPFAF
 !?01

記法の意味は以下の通りです.

記法意味
空白フラグ値に変化なし
!フラグ値に変化あり
?フラグ値は未定義(参照禁止)
0フラグ値はクリア(0になる)
1フラグ値はセット(1になる)

add命令: 足し算


記法何の略か動作
add␣ op1, op2addop1op2 に加える
adc␣ op1, op2add with carryop1 と CF を op2 に加える

詳しい記法例の動作サンプルコード
add␣ imm, r/maddq $999, %rax%rax += 999add-1.s add-1.txt
add␣ r, r/maddq %rax, (%rsp)*(%rsp) += %raxadd-2.s add-2.txt
add␣ r/m, raddq (%rsp), %rax%rax += *(%rsp)add-2.s add-2.txt
adc␣ imm, r/madcq $999, %rax%rax += 999 + CFadc-1.s adc-1.txt
adc␣ r, r/madcq %rax, (%rsp)*(%rsp) += %rax + CFadc-2.s adc-2.txt
adc␣ r/m, radcq (%rsp), %rax%rax += *(%rsp) + CFadc-3.s adc-3.txt

CFOFSFZFPFAF
!!!!!!
  • addadcはオペランドが符号あり整数か符号なし整数かを区別せず, 両方の結果を正しく計算します.
  • adcは例えば,多倍長整数(任意の桁数の整数)を実装する時の 「繰り上がり」の計算に便利です.
add-1.sの実行例
$ gcc -g add-1.s
$ gdb ./a.out -x add-1.txt
Breakpoint 1, main () at add-1.s:8
8	    ret
# p $rax
$1 = 1000
# %raxが1000なら成功
add-2.sの実行例
$ gcc -g add-2.s
$ gdb ./a.out -x add-2.txt
Breakpoint 1, main () at add-2.s:10
10	    popq %rbx
# p $rax
$1 = 1001
# x/1gd $rsp
0x7fffffffde90:	1000
# %raxが1001,(%rsp)が1000なら成功
adc-1.sの実行例
$ gcc -g adc-1.s
$ gdb ./a.out -x adc-1.txt
reakpoint 1, main () at adc-1.s:8
8	    adcq $2, %rax
# p $rax
$1 = 0
# p $eflags
$2 = [ CF PF AF ZF IF ]
main () at adc-1.s:9
9	    ret
# p $rax
$3 = 3
# %rflagsでCFが立っていて,%raxが3なら成功
adc-2.sの実行例
$ gcc -g adc-2.s
$ gdb ./a.out -x adc-2.txt
Breakpoint 1, main () at adc-2.s:9
9	    adcq $2, (%rsp)
# p $rax
$1 = 0
# p $eflags
$2 = [ CF PF AF ZF IF ]
main () at adc-2.s:10
10	    ret
x/1gd $rsp
0x7fffffffde90:	1002
# %rflagsでCFが立っていて,(%rsp)が1002なら成功
adc-3.sの実行例
$ gcc -g adc-3.s
$ gdb ./a.out -x adc-3.txt
Breakpoint 1, main () at adc-3.s:9
9	    adcq (%rsp), %rax
# p $rax
$1 = 0
# p $eflags
$2 = [ CF PF AF ZF IF ]
main () at adc-3.s:10
10	    ret
# p $rax
$3 = 1000
# %rflagsでCFが立っていて,%raxが1000なら成功

sub, sbb命令: 引き算


記法何の略か動作
sub␣ op1, op2subtractop1op2 から引く
sbb␣ op1, op2subtract with borrowop1 と CF を op2 から引く

詳しい記法例の動作サンプルコード
sub␣ imm, r/msubq $999, %rax%rax -= 999sub-1.s sub-1.txt
sub␣ r, r/msubq %rax, (%rsp)*(%rsp) -= %raxsub-2.s sub-2.txt
sub␣ r/m, rsubq (%rsp), %rax%rax -= *(%rsp)sub-2.s sub-2.txt
sbb␣ imm, r/msbbq $999, %rax%rax -= 999 + CFsbb-1.s sbb-1.txt
sbb␣ r, r/msbbq %rax, (%rsp)*(%rsp) -= %rax + CFsbb-2.s sbb-2.txt
sbb␣ r/m, rsbbq (%rsp), %rax%rax -= *(%rsp) + CFsbb-2.s sbb-2.txt

CFOFSFZFPFAF
!!!!!!
  • addと同様に,subsbbは オペランドが符号あり整数か符号なし整数かを区別せず, 両方の結果を正しく計算します.
sub-1.sの実行例
$ gcc -g sub-1.s
$ gdb ./a.out -x sub-1.txt
Breakpoint 1, main () at sub-1.s:8
8	    ret
# p $rax
$1 = 1
# %raxが1なら成功
sub-2.sの実行例
$ gcc -g sub-2.s
$ gdb ./a.out -x sub-2.txt
Breakpoint 1, main () at sub-2.s:10
10	    popq %rbx
# p $rax
$1 = -997
# x/1gd $rsp
0x7fffffffde90:	998
# %raxが-997,(%rsp)が998なら成功
sbb-1.sの実行例
$ gcc -g sbb-1.s
$ gdb ./a.out -x sbb-1.txt
Breakpoint 1, main () at sbb-1.s:8
8	    sbbq $2, %rax
# p $rax
$1 = 0
# p $eflags
$2 = [ CF PF AF ZF IF ]
main () at sbb-1.s:9
9	    ret
# p $rax
$3 = -3
# %rflagsでCFが立っていて,%raxが-3なら成功
sbb-2.sの実行例
$ gcc -g sbb-2.s
$ gdb ./a.out -x sbb-2.txt
Breakpoint 1, main () at sbb-2.s:9
9	    sbbq $2, (%rsp)
# p $rax
$1 = 0
# p $eflags
$2 = [ CF PF AF ZF IF ]
10	    sbbq (%rsp), %rax
main () at sbb-2.s:11
11	    ret
x/1gd $rsp
0x7fffffffde90:	996
# p $rax
$3 = -996
# %rflagsでCFが立っていて,(%rsp)が996,%raxが-996なら成功

mul, imul命令: かけ算


記法何の略か動作
mul␣ op1unsigned multiply符号なし乗算.(%rdx:%rax) = %rax * op1
imul␣ op1signed multiply符号あり乗算.(%rdx:%rax) = %rax * op1
imul␣ op1, op2signed multiply符号あり乗算.op2 *= op1
imul␣ op1, op2, op3signed multiply符号あり乗算.op3 = op1 * op2

詳しい記法例の動作サンプルコード
mul␣ r/mmulq %rbx(%rdx:%rax) = %rax * %rbxmul-1.s mul-1.txt
imul␣ r/mimulq %rbx(%rdx:%rax) = %rax * %rbximul-1.s imul-1.txt
imul␣ imm, rimulq $4, %rax%rax *= 4imul-2.s imul-2.txt
imul␣ r/m, rimulq %rbx, %rax%rax *= %rbximul-2.s imul-2.txt
imul␣ imm, r/m, rimulq $4, %rbx, %rax%rax = %rbx * 4imul-2.s imul-2.txt

CFOFSFZFPFAF
!!????

  • オペランドが1つの形式では,%raxが隠しオペランドになります. このため,乗算の前に%raxに値をセットしておく必要があります. また,8バイト同士の乗算結果は最大で16バイトになるので, 乗算結果を%rdx%raxに分割して格納します (16バイトの乗算結果の上位8バイトを%rdxに,下位8バイトを%raxに格納します). これをここでは(%rdx:%rax)という記法で表現しています.
  • imulだけ例外的に,オペランドが2つの形式と3つの形式があります. 2つか3つの形式では乗算結果が8バイトを超えた場合, 越えた分は破棄されます(乗算結果は8バイトのみ).
mul-1.sの実行例
$ gcc -g mul-1.s
$ gdb ./a.out -x mul-1.txt
Breakpoint 1, main () at mul-1.s:9
9	    ret
# p $rdx
$1 = 0
# p $rax
$2 = 6
# %rdxが0, %raxが6なら成功
imul-1.sの実行例
$ gcc -g imul-1.s
$ gdb ./a.out -x imul-1.txt
Breakpoint 1, main () at imul-1.s:9
9	    ret
# p $rdx
$1 = 0xffffffffffffffff
# p $rax
$2 = -6
# %rdxが0xFFFFFFFFFFFFFFFF, %raxが-6なら成功
imul-2.sの実行例
$ gcc -g imul-2.s
$ gdb ./a.out -x imul-2.txt
Breakpoint 1, main () at imul-2.s:8
8	    imulq $4, %rax
9	    imulq %rbx, %rax
1: $rax = -8
10	    imulq $5, %rbx, %rax
1: $rax = 24
main () at imul-2.s:11
11	    ret
1: $rax = -15
# %raxが-8, 24, -15なら成功

div, idiv命令: 割り算,余り


記法何の略か動作
div␣ op1unsigned divide符号なし除算と余り
%rax = (%rdx:%rax) / op1
%rdx = (%rdx:%rax) % op1
idiv␣ op1signed divide符号あり除算と余り
%rax = (%rdx:%rax) / op1
%rdx = (%rdx:%rax) % op1

詳しい記法例の動作サンプルコード
div␣ r/mdivq %rbx%rax = (%rdx:%rax) / %rbx
%rdx = (%rdx:%rax) % %rbx
div-1.s div-1.txt
idiv␣ r/midivq %rbx%rax = (%rdx:%rax) / %rbx
%rdx = (%rdx:%rax) % %rbx
idiv-1.s idiv-1.txt

CFOFSFZFPFAF
??????
  • 16バイトの値 %rdx:%rax を第1オペランドで割った商が%raxに入り, 余りが%rdxに入ります.
  • 隠しオペランドとして%rdx%raxが使われるので, 事前に値を設定しておく必要があります. idivを使う場合,もし%rdxを使わないのであれば, cqto命令で%rax%rdx:%raxに符号拡張しておくと良いです.
div-1.sの実行例
$ gcc -g div-1.s
$ gdb ./a.out -x div-1.txt
Breakpoint 1, main () at div-1.s:10
10	    ret
# p $rax
$1 = 33
# p $rdx
$2 = 9
# %raxが33, %rdxが9なら成功
idiv-1.sの実行例
$ gcc -g idiv-1.s
$ gdb ./a.out -x idiv-1.txt
Breakpoint 1, main () at idiv-1.s:9
9	    idivq %rbx
# p/x $rdx
$1 = 0xffffffffffffffff
main () at idiv-1.s:10
10	    ret
# p $rax
$2 = -33
# p $rdx
$3 = -9
# 最初の%rdxが0xFFFFFFFFFFFFFFFF, %raxが-33, 2番目の%rdxが-9なら成功

inc, dec命令: インクリメント,デクリメント


記法何の略か動作
inc␣ op1incrementop1の値を1つ増加
dec␣ op1decrementop1の値を1つ減少

詳しい記法例の動作サンプルコード
inc␣ r/minc %rax%rax++inc-1.s inc-1.txt
dec␣ r/mdec %rax%rax--dec-1.s dec-1.txt

  • incdecはオーバーフローしてもCFが変化しないところがポイントです.
inc-1.sの実行例
$ gcc -g inc-1.s
$ gdb ./a.out -x inc-1.txt
Breakpoint 1, main () at inc-1.s:8
8	    ret
# p $rax
$1 = 1
# %raxが1なら成功
dec-1.sの実行例
$ gcc -g dec-1.s
$ gdb ./a.out -x dec-1.txt
reakpoint 1, main () at dec-1.s:8
8	    ret
# p $rax
$1 = -1
# %raxが-1なら成功

neg命令: 符号反転


記法何の略か動作
neg␣ op1negation2の補数によるop1の符号反転

詳しい記法例の動作サンプルコード
neg␣ r/mneg %rax%rax = -%raxneg-1.s neg-1.txt

CFOFSFZFPFAF
!!!!!!
neg-1.sの実行例
$ gcc -g neg-1.s
$ gdb ./a.out -x neg-1.txt
Breakpoint 1, main () at neg-1.s:7
7	    neg %rax
1: $rax = 999
8	    neg %rax
1: $rax = -999
main () at neg-1.s:9
9	    ret
1: $rax = 999
# %raxが 999 → -999 → 999 と変化すれば成功

not命令: ビット論理演算 (1)


記法何の略か動作
not␣ op1bitwise notop1の各ビットの反転 (NOT)

詳しい記法例の動作サンプルコード
not␣ r/mnotq %rax%rax = ~%raxnot-1.s not-1.txt

not-1.sの実行例
$ gcc -g not-1.s
$ gdb ./a.out -x not-1.txt
Breakpoint 1, main () at not-1.s:7
7	    not %al
1: /t $al = 11001010
8	    not %al
1: /t $al = 110101
main () at not-1.s:9
9	    ret
1: /t $al = 11001010
# %alが 11001010 → 110101 → 11001010 と変化すれば成功

and, or, xor命令: ビット論理演算 (2)


記法何の略か動作
and␣ op1, op2bitwise andop1op2の各ビットごとの論理積(AND)
or␣ op1, op2bitwise orop1op2の各ビットごとの論理和(OR)
xor␣ op1, op2bitwise xorop1op2の各ビットごとの排他的論理和(XOR)

詳しい記法例の動作サンプルコード
and␣ imm, r/mandq $0x0FFF, %rax%rax &= 0x0FFFand-1.s and-1.txt
and␣ r, r/mandq %rax, (%rsp)*(%rsp) &= %raxand-1.s and-1.txt
and␣ r/m, randq (%rsp), %rax%rax &= *(%rsp)and-1.s and-1.txt
or␣ imm, r/morq $0x0FFF, %rax%rax |= 0x0FFF or-1.s or-1.txt
or␣ r, r/morq %rax, (%rsp)*(%rsp) |= %raxor-1.s or-1.txt
or␣ r/m, rorq (%rsp), %rax%rax |= *(%rsp)or-1.s or-1.txt
xor␣ imm, r/mxorq $0x0FFF, %rax%rax ^= 0x0FFFxor-1.s xor-1.txt
xor␣ r, r/mxorq %rax, (%rsp)*(%rsp) ^= %raxxor-1.s xor-1.txt
xor␣ r/m, rxorq (%rsp), %rax%rax ^= *(%rsp)xor-1.s xor-1.txt

CFOFSFZFPFAF
00!!!?

xyx & yx | yx ^ y
00000
01011
10011
11110
  • &, |, ^はC言語で,それぞれ,ビットごとの論理積,論理和,排他的論理積です (忘れた人はC言語を復習しましょう).
and-1.sの実行例
$ gcc -g and-1.s
$ gdb ./a.out -x and-1.txt
Breakpoint 1, main () at and-1.s:8
8	    pushq $0B00001111
# p/t $al
$1 = 10001000

Breakpoint 2, main () at and-1.s:12
12	    ret
# x/1bt $rsp
0x7fffffffde90:	00001000
# p/t $al
$2 = 0
# 表示される値が 10001000, 00001000, 0 なら成功
or-1.sの実行例
$ gcc -g or-1.s
$ gdb ./a.out -x or-1.txt
Breakpoint 1, main () at or-1.s:8
8	    pushq $0B00001111
# p/t $al
$1 = 11101110

Breakpoint 2, main () at or-1.s:12
12	    ret
# x/1bt $rsp
0x7fffffffde90:	11101111
# p/t $al
$2 = 11111111
# 表示される値が 11101110, 11101111, 11111111 なら成功
xor-1.sの実行例
$ gcc -g xor-1.s
$ gdb ./a.out -x xor-1.txt
Breakpoint 1, main () at xor-1.s:8
8	    pushq $0B00001111
# p/t $al
$1 = 1100110

Breakpoint 2, main () at xor-1.s:12
12	    ret
# x/1bt $rsp
0x7fffffffde90:	01101001
# p/t $al
$2 = 10011110
# 表示される値が 1100110, 01101001, 10011110 なら成功

sal, sar, shl, shr: シフト


記法何の略か動作
sal␣ op1[, op2]shift arithmetic left算術左シフト
shl␣ op1[, op2]shift logical left論理左シフト
sar␣ op1[, op2]shift arithmetic right算術右シフト
shr␣ op1[, op2]shift logical right論理右シフト

詳しい記法例の動作サンプルコード
sal␣ r/msalq %rax%raxを1ビット算術左シフトsal-1.s sal-1.txt
sal␣ imm8, r/msalq $2, %rax%raxを2ビット算術左シフトsal-1.s sal-1.txt
sal␣ %cl, r/msalq %cl, %rax%rax%clビット算術左シフトsal-1.s sal-1.txt
shl␣ r/mshlq %rax%raxを1ビット論理左シフトshl-1.s shl-1.txt
shl␣ imm8, r/mshlq $2, %rax%raxを2ビット論理左シフトshl-1.s shl-1.txt
shl␣ %cl, r/mshlq %cl, %rax%rax%clビット論理左シフトshl-1.s shl-1.txt
sar␣ r/msarq %rax%raxを1ビット算術右シフトsar-1.s sar-1.txt
sar␣ imm8, r/msarq $2, %rax%raxを2ビット算術右シフトsar-1.s sar-1.txt
sar␣ %cl, r/msarq %cl, %rax%rax%clビット算術右シフトsar-1.s sar-1.txt
shr␣ r/mshrq %rax%raxを1ビット論理右シフトshr-1.s shr-1.txt
shr␣ imm8, r/mshrq $2, %rax%raxを2ビット論理右シフトshr-1.s shr-1.txt
shr␣ %cl, r/mshrq %cl, %rax%rax%clビット論理右シフトshr-1.s shr-1.txt

CFOFSFZFPFAF
!!!!!?
  • op1[, op2] という記法は「op2は指定してもしなくても良い」という意味です.
  • シフトとは(指定したビット数だけ)右か左にビット列をずらすことを意味します. op2がなければ「1ビットシフト」を意味します.
  • 論理シフトとは「空いた場所に0を入れる」, 算術シフトとは「空いた場所に符号ビットを入れる」ことを意味します.
  • 左シフトの場合は(符号ビットを入れても意味がないので),論理シフトでも算術シフトでも,0を入れます.その結果,算術左シフトsalと論理左シフトshlは全く同じ動作になります.
  • C言語の符号あり整数に対する右シフト(>>)は算術シフトか論理シフトかは 決まっていません(実装依存です). C言語で,ビット演算は符号なし整数に対してのみ行うようにしましょう.
sal-1.sの実行例
$ gcc -g sal-1.s
$ gdb ./a.out -x sal-1.txt
Breakpoint 1, main () at sal-1.s:8
8	    salq %rax
1: /t $rax = 11111111
9	    salq $2, %rax
1: /t $rax = 111111110
10	    salq %cl, %rax
1: /t $rax = 11111111000
main () at sal-1.s:11
11	    ret
1: /t $rax = 11111111000000
# 表示される値が 11111111, 111111110, 11111111000, 11111111000000 なら成功
shl-1.sの実行例
$ gcc -g shl-1.s
$ gdb ./a.out -x shl-1.txt
reakpoint 1, main () at shl-1.s:8
8	    shlq %rax
1: /t $rax = 11111111
9	    shlq $2, %rax
1: /t $rax = 111111110
10	    shlq %cl, %rax
1: /t $rax = 11111111000
main () at shl-1.s:11
11	    ret
1: /t $rax = 11111111000000
# 表示される値が 11111111, 111111110, 11111111000, 11111111000000 なら成功
sar-1.sの実行例
$ gcc -g sar-1.s
$ gdb ./a.out -x sar-1.txt
Breakpoint 1, main () at sar-1.s:8
8	    sarq %rax
1: /t $rax = 1111111111111111111111111111111111111111111111111111111100000000
9	    sarq $2, %rax
1: /t $rax = 1111111111111111111111111111111111111111111111111111111110000000
10	    sarq %cl, %rax
1: /t $rax = 1111111111111111111111111111111111111111111111111111111111100000
main () at sar-1.s:11
11	    ret
1: /t $rax = 1111111111111111111111111111111111111111111111111111111111111100
# 表示される値が 1111111111111111111111111111111111111111111111111111111100000000, 1111111111111111111111111111111111111111111111111111111110000000, 1111111111111111111111111111111111111111111111111111111111100000, 1111111111111111111111111111111111111111111111111111111111111100 なら成功
shr-1.sの実行例
$ gcc -g shr-1.s
$ gdb ./a.out -x shr-1.txt
reakpoint 1, main () at shr-1.s:8
8	    shrq %rax
1: /t $rax = 1111111111111111111111111111111111111111111111111111111100000000
9	    shrq $2, %rax
1: /t $rax = 111111111111111111111111111111111111111111111111111111110000000
10	    shrq %cl, %rax
1: /t $rax = 1111111111111111111111111111111111111111111111111111111100000
main () at shr-1.s:11
11	    ret
1: /t $rax = 1111111111111111111111111111111111111111111111111111111100
# 表示される値が 1111111111111111111111111111111111111111111111111111111100000000, 111111111111111111111111111111111111111111111111111111110000000, 1111111111111111111111111111111111111111111111111111111100000, 1111111111111111111111111111111111111111111111111111111100 なら成功

rol, ror, rcl, rcr: ローテート


記法何の略か動作
rol␣ op1[, op2]rotate left左ローテート
rcl␣ op1[, op2]rotate left through carryCFを含めて左ローテート
ror␣ op1[, op2]rotate right右ローテート
rcr␣ op1[, op2]rotate right through carryCFを含めて右ローテート

詳しい記法例の動作サンプルコード
rol␣ r/mrolq %rax%raxを1ビット左ローテートrol-1.s rol-1.txt
rol␣ imm8, r/mrolq $2, %rax%raxを2ビット左ローテートrol-1.s rol-1.txt
rol␣ %cl, r/mrolq %cl, %rax%rax%clビット左ローテートrol-1.s rol-1.txt
rcl␣ r/mrclq %rax%raxを1ビットCFを含めて左ローテートrcl-1.s rcl-1.txt
rcl␣ imm8, r/mrclq $2, %rax%raxを2ビットCFを含めて左ローテートrcl-1.s rcl-1.txt
rcl␣ %cl, r/mrclq %cl, %rax%rax%clビットCFを含めて左ローテートrcl-1.s rcl-1.txt
ror␣ r/mrorq %rax%raxを1ビット右ローテートror-1.s ror-1.txt
ror␣ imm8, r/mrorq $2, %rax%raxを2ビット右ローテートror-1.s ror-1.txt
ror␣ %cl, r/mrorq %cl, %rax%rax%clビット右ローテートror-1.s ror-1.txt
rcr␣ r/mrcrq %rax%raxを1ビットCFを含めて右ローテートrcr-1.s rcr-1.txt
rcr␣ imm8, r/mrcrq $2, %rax%raxを2ビットCFを含めて右ローテートrcr-1.s rcr-1.txt
rcr␣ %cl, r/mrcrq %cl, %rax%rax%clビットCFを含めて右ローテートrcr-1.s rcr-1.txt

  • op1[, op2] という記法は「op2は指定してもしなくても良い」という意味です.
  • ローテートは,シフトではみ出したビットを空いた場所に入れます.
  • ローテートする方向(右か左),CFを含めるか否かで,4パターンの命令が存在します.
rol-1.sの実行例
$ gcc -g rol-1.s
$ gdb ./a.out -x rol-1.txt
Breakpoint 1, main () at rol-1.s:8
8	    rolq %rax
1: /t $rax = 11111111
9	    rolq $2, %rax
1: /t $rax = 111111110
10	    rolq %cl, %rax
1: /t $rax = 11111111000
main () at rol-1.s:11
11	    ret
1: /t $rax = 11111111000000
# 表示される値が 11111111, 111111110, 11111111000, 11111111000000 なら成功
rcl-1.sの実行例
$ gcc -g rcl-1.s
$ gdb ./a.out -x rcl-1.txt
Breakpoint 1, main () at rcl-1.s:10
10	    rclq %rax
1: /t $rax = 11111111
11	    rclq $2, %rax
1: /t $rax = 111111111
12	    rclq %cl, %rax
1: /t $rax = 11111111100
main () at rcl-1.s:13
13	    ret
1: /t $rax = 11111111100000
# 表示される値が 11111111, 111111111, 11111111100, 11111111100000 なら成功
ror.sの実行例
$ gcc -g ror.s
$ gdb ./a.out -x ror.txt
Breakpoint 1, main () at ror-1.s:8
8	    rorq %rax
1: /t $rax = 11111111
9	    rorq $2, %rax
1: /t $rax = 1000000000000000000000000000000000000000000000000000000001111111
10	    rorq %cl, %rax
1: /t $rax = 1110000000000000000000000000000000000000000000000000000000011111
main () at ror-1.s:11
11	    ret
1: /t $rax = 1111110000000000000000000000000000000000000000000000000000000011
# 表示される値が 11111111, 1000000000000000000000000000000000000000000000000000000001111111, 1110000000000000000000000000000000000000000000000000000000011111, 1111110000000000000000000000000000000000000000000000000000000011 なら成功
rcr-1.sの実行例
$ gcc -g rcr-1.s
$ gdb ./a.out -x rcr-1.txt
Breakpoint 1, main () at rcr-1.s:10
10	    rcrq %rax
1: /t $rax = 11111010
11	    rcrq $2, %rax
1: /t $rax = 1000000000000000000000000000000000000000000000000000000001111101
12	    rcrq %cl, %rax
1: /t $rax = 1010000000000000000000000000000000000000000000000000000000011111
main () at rcr-1.s:13
13	    ret
1: /t $rax = 1101010000000000000000000000000000000000000000000000000000000011
# 表示される値が 11111010, 1000000000000000000000000000000000000000000000000000000001111101, 1010000000000000000000000000000000000000000000000000000000011111, 1101010000000000000000000000000000000000000000000000000000000011 なら成功

cmp, test: 比較

cmp命令


記法何の略か動作
cmp␣ op1[, op2]compareop1op2の比較結果をフラグに格納(比較はsub命令を使用)

詳しい記法例の動作サンプルコード
cmp␣ imm, r/mcmpq $999, %raxsubq $999, %raxのフラグ変化のみ計算.オペランドは変更なしcmp-1.s cmp-1.txt
cmp␣ r, r/mcmpq %rax, (%rsp)subq %rax, (%rsp)のフラグ変化のみ計算.オペランドは変更なしcmp-1.s cmp-1.txt
cmp␣ r/m, rcmpq (%rsp), %raxsubq (%rsp), %raxのフラグ変化のみ計算.オペランドは変更なしcmp-1.s cmp-1.txt

CFOFSFZFPFAF
!!!!!!
  • cmp命令はフラグ計算だけを行います. (レジスタやメモリは変化しません).
  • cmp命令は条件付きジャンプ命令と一緒に使うことが多いです. 例えば以下の2命令で「%raxが(符号あり整数として)1より大きければジャンプする」という意味になります.
cmpq $1, %rax
jg L2
cmp-1.sの実行例
$ gcc -g cmp-1.s
$ gdb ./a.out -x cmp-1.txt
reakpoint 1, main () at cmp-1.s:8
8	    cmpq $1, %rax       # %rax (=0) - 1
9	    cmpq %rax, (%rsp)   # (%rsp) (=1) - %rax (=0)
1: $eflags = [ CF PF AF SF IF ]
10	    cmpq (%rsp), %rax   # %rax (=0) - (%rsp) (=1)
1: $eflags = [ IF ]
main () at cmp-1.s:11
11	    ret
1: $eflags = [ CF PF AF SF IF ]
# 表示されるステータスフラグが以下なら成功
# 1: $eflags = [ CF PF AF SF IF ] (SF==1 → 結果は負)
# 1: $eflags = [ IF ]             (SF==0 → 結果は0か正)
# 1: $eflags = [ CF PF AF SF IF ] (SF==1 → 結果は負)

test命令


記法何の略か動作
test␣ op1[, op2]logical compareop1op2の比較結果をフラグに格納(比較はand命令を使用)

詳しい記法例の動作サンプルコード
test␣ imm, r/mtestq $999, %raxandq $999, %raxのフラグ変化のみ計算.オペランドは変更なしtest-1.s test-1.txt
test␣ r, r/mtestq %rax, (%rsp)andq %rax, (%rsp)のフラグ変化のみ計算.オペランドは変更なしtest-1.s test-1.txt
test␣ r/m, rtestq (%rsp), %raxandq (%rsp), %raxのフラグ変化のみ計算.オペランドは変更なしtest-1.s test-1.txt

CFOFSFZFPFAF
00!!!?
  • cmp命令と同様に,test命令はフラグ計算だけを行います. (レジスタやメモリは変化しません).
  • cmp命令と同様に,test命令は条件付きジャンプ命令と一緒に使うことが多いです. 例えば以下の2命令で「%raxが0ならジャンプする」という意味になります.
testq %rax, %rax
jz L2
  • 例えば%raxが0かどうかを知りたい場合, cmpq $0, %raxtestq %rax, %raxのどちらでも調べることができます. どちらの場合も,ZF==1なら,%raxが0と分かります (testq %rax, %raxはビットごとのANDのフラグ変化を計算するので, %raxがゼロの時だけ,ZF==1となります). コンパイラはtestq %rax, %raxを使うことが多いです. testq %rax, %raxの方が命令長が短くなるからです.
test-1.sの実行例
$ gcc -g test-1.s
$ gdb ./a.out -x test-1.txt
Breakpoint 1, main () at test-1.s:8
8	    testq $0, %rax       # %rax (=1) & 0
9	    testq %rax, (%rsp)   # (%rsp) (=1) & %rax (=1)
1: $eflags = [ PF ZF IF ]
10	    testq (%rsp), %rax   # %rax (=1) & (%rsp) (=1)
1: $eflags = [ IF ]
main () at test-1.s:11
11	    ret
1: $eflags = [ IF ]
# 表示されるステータスフラグが以下なら成功
# 1: $eflags = [ PF ZF IF ] (ZF==1 → 結果は0)
# 1: $eflags = [ IF ]       (ZF==0 → 結果は非0)
# 1: $eflags = [ IF ]       (ZF==0 → 結果は非0)

movs, movz, cbtw, cqto命令: 符号拡張とゼロ拡張

movs, movz命令


記法(AT&T形式)記法(Intel形式)何の略か動作
movs␣␣ op1, op2movsx op2, op1
movsxd op2, op1
move with sign-extentionop1を符号拡張した値をop2に格納
movz␣␣ op1, op2movzx op2, op1move with zero-extentionop1をゼロ拡張した値をop2に格納

詳しい記法例の動作サンプルコード
movs␣␣ r/m, rmovslq %eax, %rbx%rbx = %eaxを8バイトに符号拡張した値movs-movz.s movs-movz.txt
movz␣␣ r/m, rmovzwq %ax, %rbx%rbx = %axを8バイトにゼロ拡張した値movs-movz.s movs-movz.txt

␣␣に入るもの何の略か意味
bwbyte to word1バイト→2バイトの拡張
blbyte to long1バイト→4バイトの拡張
bqbyte to quad1バイト→8バイトの拡張
wlword to long2バイト→4バイトの拡張
wqword to quad2バイト→8バイトの拡張
lqlong to quad4バイト→8バイトの拡張

  • movs, movz命令はAT&T形式とIntel形式でニモニックが異なるので注意です.
  • GNUアセンブラではAT&T形式でも実はmovsx, movzxのニモニックが使用できます. ただし逆アセンブルすると,movslq, movzwqなどのニモニックが表示されるので, movslq, movzwqなどを使う方が良いでしょう.
  • movzlq (Intel形式ではmovzxd)はありません.例えば,%eaxに値を入れると, %raxの上位32ビットはクリアされるので, movzlqは不要だからです.
  • Intel形式では,4バイト→8バイトの拡張の時だけ, (movsxではなく)movsxdを使います.
movs-movz.sの実行例
$ gcc -g movs-movz.s
$ gdb ./a.out -x movs-movz.txt
Breakpoint 1, main () at movs-movz.s:7
7	    movslq %eax, %rbx
8	    movzwq %ax, %rbx
1: /x $rbx = 0xffffffffffffffff
main () at movs-movz.s:9
9	    ret
1: /x $rbx = 0xffff
# 以下が表示されれば成功
# 1: /x $rbx = 0xffffffffffffffff
# 1: /x $rbx = 0xffff

cbtw, cqto命令


記法(AT&T形式)記法(Intel形式)何の略か動作
c␣t␣c␣␣␣convert ␣ to ␣%rax (または%eax, %ax, %al)を符号拡張

詳しい記法
(AT&T形式)
詳しい記法
(Intel形式)
例の動作サンプルコード
cbtwcbwcbtw%al(byte)を%ax(word)に符号拡張cbtw.s cbtw.txt
cwtlcwdecwtl%ax(word)を%eax(long)に符号拡張cbtw.s cbtw.txt
cwtdcwdcwtd%ax(word)を%dx:%ax(double word)に符号拡張cbtw.s cbtw.txt
cltdcdqcltd%eax(long)を%edx:%eax(double long, quad)に符号拡張cbtw.s cbtw.txt
cltqcdqecltd%eax(long)を%rax(quad)に符号拡張cbtw.s cbtw.txt
cqtocqocqto%rax(quad)を%rdx:%rax(octuple)に符号拡張cbtw.s cbtw.txt

  • cqtoなどはidivで割り算する前に使うと便利(%rdx:%raxidivの隠しオペランドなので).
  • GNUアセンブラはIntel形式のニモニックも受け付ける.
cbtw.sの実行例
$ gcc -g cbtw.s
$ gdb ./a.out -x cbtw.txt
Breakpoint 1, main () at cbtw.s:7
7	    cbtw   # %al -> %ax
9	    cwtl   # %ax -> %eax
$1 = -1
$2 = 0xffff
11	    cwtd   # %ax -> %dx:%ax
$3 = -1
$4 = 0xffffffff
13	    cltd   # %eax -> %edx:%eax
$5 = {-1, -1}
$6 = {0xffff, 0xffff}
15	    cltq   # %eax -> %rax
$7 = {-1, -1}
$8 = {0xffffffff, 0xffffffff}
17	    cqto   # %rax -> %rdx:%rax
$9 = -1
$10 = 0xffffffffffffffff
main () at cbtw.s:19
19	    ret
$11 = {-1, -1}
$12 = {0xffffffffffffffff, 0xffffffffffffffff}
# 以下が表示されれば成功
# $1 = -1
# $2 = 0xffff
# $3 = -1
# $4 = 0xffffffff
# $5 = {-1, -1}
# $6 = {0xffff, 0xffff}
# $7 = {-1, -1}
# $8 = {0xffffffff, 0xffffffff}
# $9 = -1
# $10 = 0xffffffffffffffff
# $11 = {-1, -1}
# $12 = {0xffffffffffffffff, 0xffffffffffffffff}

ジャンプ命令

  • ジャンプとは「次に実行する命令を(『次の番地の命令』ではなく) 『別の番地の命令』にすることです. ジャンプの仕組みは簡単で「ジャンプ先のアドレスをプログラムカウンタ %ripに代入する」だけです. C言語風に書くと%rip = ジャンプ先のアドレスとなります (ジャンプ先が相対アドレスで与えられた場合は, %rip += 相対アドレスになります).
  • 無条件ジャンプはC言語のgoto文と同じで常にジャンプします. 条件付きジャンプは条件が成り立った時だけジャンプします. 条件付きジャンプをC言語風に書くとif (条件) goto ジャンプ先;になります.

絶対ジャンプと相対ジャンプ

  • 絶対ジャンプ (absolute jump)は絶対アドレス, つまりメモリの先頭からのオフセットでジャンプ先のアドレスを指定するジャンプです. 上の例で,AからBにジャンプする時,jmp 0x1000は絶対ジャンプになります.
  • 相対ジャンプ (relative jump)は プログラムカウンタ%ripを起点とする相対アドレスで ジャンプ先のアドレスを指定するジャンプです. 上の例で,AからBにジャンプする時,jmp -0x500は相対ジャンプになります. (プログラムカウンタは「次に実行する命令を指すレジスタ」なので, 正確には「Aの一つ前の命令からBにジャンプする時」になります).

直接ジャンプと間接ジャンプ

  • 直接ジャンプ (direct jump)はジャンプ先のアドレスを 即値 (定数)で指定するジャンプです. 上の例で一番左のjmp 0x1000は直接ジャンプです.

  • 間接ジャンプ (indirect jump)はジャンプ先のアドレスを レジスタメモリで指定して,その中に格納されている値を ジャンプ先のアドレスとするジャンプです.

    • 上の例で真ん中のjmp *%raxレジスタを使った間接ジャンプです. レジスタ中のアドレス (ここでは0x1000番地)にジャンプします. (なぜアスタリスク*が必要なのかは謎です.GNUアセンブラの記法です.)
    • 上の例で一番右のjmp *(%rax)メモリ参照を使った間接ジャンプです. メモリ中のアドレス (ここでは0x1000番地)にジャンプします.

jmp: 無条件ジャンプ


記法何の略か動作
jmp op1jumpop1にジャンプ

詳しい記法例の動作サンプルコード
jmp reljmp 0x10000x1000番地に相対直接ジャンプ (%rip += 0x1000)jmp.s jmp.txt
jmp foofoo番地に相対直接ジャンプ (%rip += foo)jmp.s jmp.txt
jmp r/mjmp *%rax*%rax番地に絶対間接ジャンプ (%rip = *%rax))jmp.s jmp.txt
jmp r/mjmp *(%rax)*(%rax)番地に絶対間接ジャンプ (%rip = *(%rax))jmp.s jmp.txt
---
  • x86-64では,相対・直接と絶対・間接の組み合わせしかありません. (つまり,相対・間接ジャンプや絶対・直接ジャンプはありません. なお,ここで紹介していないfarジャンプでは絶対・直接もあります).
  • 相対・直接ジャンプでは符号ありの8ビット(rel8)か 32ビット(rel32)の整数定数で相対アドレスを指定します. (64ビットの相対アドレスは指定できません.64ビットのジャンプをしたい時は 絶対・間接ジャンプ命令を使います).
  • rel8rel32かはアセンブラが勝手に選んでくれます. 逆にjmpbjmplなどとサフィックスをつけて指定することはできません.
  • なぜか,定数なのにrel8rel32にはドルマーク$をつけません. 逆にr/mの前にはアスタリスク*が必要です. GNUアセンブラのこの部分は一貫性がないので要注意です.

条件付きジャンプの概要

  • 条件付きジャンプ命令 j␣は  ステータスフラグ (CF, OF, PF, SF, ZF)をチェックして, 条件が成り立てばジャンプします. 条件が成り立たない場合はジャンプせず,次の命令に実行を進めます.
  • 条件付きジャンプは比較命令と一緒に使うことが多いです. 例えば以下の2命令で「%raxが(符号あり整数として)1より大きければジャンプする」という意味になります.
cmpq $1, %rax
jg L2
  • 条件付きジャンプ命令のニモニックでは次の用語を使い分けます
    • 符号あり整数の大小には less/greater を使う
    • 符号なし整数の大小には above/below を使う

条件付きジャンプ: 符号あり整数用


記法何の略か動作ジャンプ条件
jg rel
jnle rel
jump if greater
jump if not less nor equal
op2>op1ならrelにジャンプ
!(op2<=op1)ならrelにジャンプ
ZF==0&&SF==OF
jge rel
jnl rel
jump if greater or equal
jump if not less
op2>=op1ならrelにジャンプ
!(op2<op1)ならrelにジャンプ
SF==OF
jle rel
jng rel
jump if less or equal
jump if not greater
op2<=op1ならrelにジャンプ
!(op2>op1)ならrelにジャンプ
ZF==1||SF!=OF
jl rel
jnge rel
jump if less
jump if not greater nor equal
op2<op1ならrelにジャンプ
!(op2>=op1)ならrelにジャンプ
SF!=OF

詳しい記法例の動作サンプルコード
jg relcmpq $0, %rax
jg foo
if (%rax>0) goto foojg.s jg.txt
jnle relcmpq $0, %rax
jnle foo
if (!(%rax<=0)) goto foojg.s jg.txt
jge relcmpq $0, %rax
jge foo
if (%rax>=0) goto foojge.s jge.txt
jnl relcmpq $0, %rax
jnl foo
if (!(%rax<0)) goto foojge.s jge.txt
jle relcmpq $0, %rax
jle foo
if (%rax<=0) goto foojle.s jle.txt
jng relcmpq $0, %rax
jng foo
if (!(%rax>0)) goto foojle.s jle.txt
jl relcmpq $0, %rax
jl foo
if (%rax<0) goto foojl.s jl.txt
jnge relcmpq $0, %rax
jnge foo
if (!(%rax>=0)) goto foojl.s jl.txt

  • op1op2 は条件付きジャンプ命令の直前で使用したcmp命令のオペランドを表します.
  • jgjnleは異なるニモニックですが動作は同じです. その証拠にジャンプ条件はZF==0&&SF==OFと共通です. 他の3つのペア,jgejnljlejngjljngeも同様です.
なぜ ZF==0&&SF=OF が(符号ありの場合の)op2>op1になるのか
  • 復習: cmp␣ op1, op2は (op2 - op1)という引き算を計算した時の フラグ変化を計算します.
  • ①: OF==0(オーバーフロー無し)の場合:
    • SF==0 だと引き算の結果は0以上→ op2 - op1 >= 0 → op2 >= op1
  • ②: OF==1(オーバーフローあり)の場合:
    • 結果の正負が逆になる.つまり SF==1 だと引き算の結果は負(OF==1で逆になるので正)→ op2 - op1 >= 0 → op2 >= op1
  • ③: ①と②から,(OF==0&&SF==0)||(OF==1&&SF==1)なら,op2 >= op1 になる. (OF==0&&SF==0)||(OF==1&&SF==1)を簡単にすると OF==SF になる.
  • ④: ③に ZF==0 (結果はゼロではない)という条件を加えると, ZF==0&&SF=OF が op2 > op1 と等価になる.
  • 上の例で,OF==1の時,引き算結果の大小関係(SF)が逆になることを見てみます.
    • (+64)-(-64)はオーバーフローが起きて,結果は-128になります(SF==1). 引き算の結果は負ですが,大小関係は (+64) > (-64) です(逆になってます).
    • (-64)-(+65)はオーバーフローが起きて,結果は127になります(SF==0). 引き算の結果は正ですが,大小関係は (-64) < (+65) です(逆になってます).

条件付きジャンプ: 符号なし整数用


記法何の略か動作ジャンプ条件
ja rel
jnbe rel
jump if above
jump if not below nor equal
op2>op1ならrelにジャンプ
!(op2<=op1)ならrelにジャンプ
CF==0&ZF==0
jae rel
jnb rel
jump if above or equal
jump if not below
op2>=op1ならrelにジャンプ
!(op2<op1)ならrelにジャンプ
CF==0
jbe rel
jna rel
jump if below or equal
jump if not above
op2<=op1ならrelにジャンプ
!(op2>op1)ならrelにジャンプ
CF==1&&ZF==1
jb rel
jnae rel
jump if below
jump if not above nor equal
op2<op1ならrelにジャンプ
!(op2>=op1)ならrelにジャンプ
CF==1

詳しい記法例の動作サンプルコード
ja relcmpq $0, %rax
ja foo
if (%rax>0) goto fooja.s ja.txt
jnbe relcmpq $0, %rax
jnbe foo
if (!(%rax<=0)) goto fooja.s ja.txt
jae relcmpq $0, %rax
jae foo
if (%rax>=0) goto foojae.s jae.txt
jnb relcmpq $0, %rax
jnb foo
if (!(%rax<0)) goto foojae.s jae.txt
jbe relcmpq $0, %rax
jbe foo
if (%rax<=0) goto foojbe.s jbe.txt
jna relcmpq $0, %rax
jna foo
if (!(%rax>0)) goto foojbe.s jbe.txt
jb relcmpq $0, %rax
jb foo
if (%rax<0) goto foojb.s jb.txt
jnae relcmpq $0, %rax
jnae foo
if (!(%rax>=0)) goto foojb.s jb.txt

  • op1op2 は条件付きジャンプ命令の直前で使用したcmp命令のオペランドを表します.
  • jajnbeは異なるニモニックですが動作は同じです. その証拠にジャンプ条件はCF==0&&ZF==0と共通です. 他の3つのペア,jaejnbjbejnajbjnaeも同様です.

条件付きジャンプ: フラグ用


記法何の略か動作ジャンプ条件
jc reljump if carryCF==1ならrelにジャンプCF==1
jnc reljump if not carryCF==0ならrelにジャンプCF==0
jo reljump if overflowOF==1ならrelにジャンプOF==1
jno reljump if not overflowOF==0ならrelにジャンプOF==0
js reljump if signSF==1ならrelにジャンプSF==1
jns reljump if not signSF==0ならrelにジャンプSF==0
jz rel
je rel
jump if zero
jump if equal
ZF==1ならrelにジャンプ
op2==op1ならrelにジャンプ
ZF==1
jnz rel
jne rel
jump if not zero
jump if not equal
ZF==0ならrelにジャンプ
op2!=op1ならrelにジャンプ
ZF==0
jp rel
jpe rel
jump if parity
jump if parity even
PF==1ならrelにジャンプPF==1
jnp rel
jpo rel
jump if not parity
jump if parity odd
PF==0ならrelにジャンプPF==0

詳しい記法例の動作サンプルコード
jc reljc fooif (CF==1) goto foojc.s jc.txt
jnc reljnc fooif (CF==0) goto foojc.s jc.txt
jo reljo fooif (OF==1) goto foojo.s jo.txt
jno reljno fooif (OF==0) goto foojo.s jo.txt
js reljs fooif (SF==1) goto foojs.s js.txt
jns reljns fooif (SF==0) goto foojs.s js.txt
jz reljz fooif (ZF==1) goto foojz.s jz.txt
je relcmpq $0, %rax
je foo
if (%rax==0) goto foojz.s jz.txt
jnz reljnz fooif (ZF==0) goto foojz.s jz.txt
jne relcmpq $0, %rax
jne foo
if (%rax!=0) goto foojz.s jz.txt
jp reljp fooif (PF==1) goto foojp.s jp.txt
jpe reljpe fooif (PF==1) goto foojp.s jp.txt
jnp reljnp fooif (PF==0) goto foojp.s jp.txt
jpo reljpo fooif (PF==0) goto foojp.s jp.txt

  • jzjeは異なるニモニックですが動作は同じです. その証拠にジャンプ条件はZF==1と共通です. 他の3つのペア,jnzjnejpjpejnpjpoも同様です.
  • AFフラグのための条件付きジャンプ命令は存在しません.

call, ret命令: 関数を呼び出す,リターンする


記法何の略か動作
call op1call procedure%ripをスタックにプッシュしてから op1にジャンプする
(pushq %rip; %rip = op1)
retreturn from procedureスタックからポップしたアドレスにジャンプする
(popq %rip)

詳しい記法例の動作サンプルコード
call relcall foo相対・直接の関数コールcall.s call.txt
call r/mcall *%rax絶対・間接の関数コールcall.s call.txt
retret関数からリターンcall.s call.txt

call.sの実行例
$ gcc -g call.s
$ gdb ./a.out -x call.txt
reakpoint 1, main () at call.s:12
12	    call foo
1: /x $rip = 0x401107
# info address foo
Symbol "foo" is at ❶0x401106 in a file compiled without debugging.
Breakpoint 2 at 0x401106: file call.s, line 6.
❷Breakpoint 2, foo () at call.s:6
6	    ret
1: /x $rip = 0x401106

❸Breakpoint 2, foo () at call.s:6
6	    ret
1: /x $rip = 0x401106

❹Breakpoint 2, foo () at call.s:6
6	    ret
1: /x $rip = 0x401106
# 3回,関数fooを呼び出して,リターンできていれば成功
  • info address fooコマンドで,fooのアドレスは ❶0x401106番地と分かりました.
  • ❷❸❹より3回,fooを呼び出せていることが分かります.

注: 関数呼び出し規約(calling convention),スタックレイアウトなどは ABIが定めるお約束です. 以下ではLinuxのABIに基づいて説明します.

関数の呼び出し時に戻り番地をスタックに積む,リターン時に戻り番地をスタックから取り出す

関数呼び出しとリターンにはスタックを使います(スタック超重要). スタックは以下の図の通り,プロセスが使うメモリの一部の領域です.

関数呼び出しにジャンプ命令(jmp)を使うと, (一般的に呼び出す側は複数箇所なので) リターン時にどこに戻ればよいかが分かりません. そこで,戻る場所(戻り番地 (return address))をスタックに保存しておきます. call命令はこの「戻り番地をスタックに保存する」ことを自動的にやってくれます. 以下で具体例call2.sを見てみましょう. call2.sでは関数mainから関数foocall命令で呼び出して, 関数fooから関数mainret命令でリターンしています.

# asm/call2.s
    .text

    .type foo, @function
foo:
    ret
    .size foo, .-foo

    .globl main
    .type main, @function
main:
    call foo
    ret
    .size main, .-main
$ gcc -g -no-pie call2.s
$ objdump -d ./a.out
(中略)
0000000000401106 <foo>:
❷401106:	c3                   	ret    

0000000000401107 <main>:
  401107:	e8 fa ff ff ff       	call   401106 <foo>
❶40110c:	c3                   	ret    

-no-pieオプションは 実行するたびにアドレスが変わらないためにつけています. -no-pieオプション無しでも仕組みは変わりません.

  • call foo実行直前: 図(左)が示す通り%ripcall foo命令を指しています. ここで,call foo命令を実行すると,

    • %ripcall foo命令の次の命令(ここではmain関数中のret命令)を指します. (%ripは「実行中の命令の次の命令」を指すことを思い出しましょう).
    • call fooはまず%ripの値(上図では❶0x40110C)をスタックにプッシュします. その結果,スタック上に0x40110Cが書き込まれます. この0x40110Cが(関数fooからリターンする際の)戻り番地となります.
    • 次に,call fooは関数fooの先頭番地(上図では❷0x401106)にジャンプします.
  • call foo実行直後: 図(中)が示す通り%ripfoo関数のret命令を指しています. 一方,スタックトップ(%rspが指している場所)には 戻り番地0x40110Cが格納されています. ここで,ret命令を実行すると,

    • スタックから戻り番地 0x40110Cをポップして取り出して, %ripに格納します(つまり0x40110C番地にジャンプします).
  • 関数fooret実行直後: 無事に関数maincall foo命令の次の命令(ここではret命令)に戻ってこれました.

このように戻り番地をスタックに格納すれば,(メモリ不足にならない限り) どれだけ数多くの関数呼び出しが続いても,正しい順番でリターンすることができます. 戻り番地の格納にスタックを使えば, 「コールした順番とは逆の順序で戻りアドレスを取り出せる」からです.

例えば,上図のようにA→B→C→Dという順番で関数コールをした場合, 上図の順番で「Aへの戻り番地」「Bへの戻り番地」「Cへの戻り番地」が スタックに積まれます. リターンするときはD→C→B→Aという逆の順番になるわけですが, スタックを使っているので, ポップするたびに「Cへの戻り番地」「Bへの戻り番地」「Aへの戻り番地」 という逆の順番で戻り番地を正しく取り出せます.

C言語の関数ポインタと,間接call命令

// asm/fp.c
int add5 (int n)
{
    return n + 5;
}

int main ()
{
    int (*fp)(int n);
    fp = add5;
    return fp (10);
}
$ gcc -g fp.c
$ objdump -d ./a.out
(中略)
000000000000113c <main>:
    113c:	f3 0f 1e fa          	endbr64 
    1140:	55                   	push   %rbp
    1141:	48 89 e5             	mov    %rsp,%rbp
    1144:	48 83 ec 10          	sub    $0x10,%rsp
    1148:	48 8d 05 da ff ff ff ❷ lea    -0x26(%rip),%rax        # 1129 <add5>
    114f:	48 89 45 f8          	mov    %rax,-0x8(%rbp)
    1153:	48 8b 45 f8          	mov    -0x8(%rbp),%rax
    1157:	bf 0a 00 00 00       	mov    $0xa,%edi
    115c:	ff d0                ❶ call   *%rax
    115e:	c9                   	leave  
    115f:	c3                   	ret    
  • C言語で関数ポインタを使うと,間接call命令にコンパイルされます. asm/fp.c中の
    int (*fp)(int n);

の部分は「『int型の引数をもらい,int型を返す関数』へのポインタを 格納する変数fpを定義しています. そして,fp = add5と代入を行い,fp (10)することで, 関数ポインタを使って間接的にadd5関数を呼び出しています.

  • このCコードをコンパイルして逆アセンブルすると, 関数ポインタを使った関数呼び出しは, 間接call命令 (ここでは❶ call *%rax)になっていることが分かります. %raxには関数add5の先頭アドレスが入っています (ここでは ❷ lea -0x26(%rip),%raxを実行することで).
fp = add5 であってる?

fp = add5ではなくfp = &add5が正しいのでは?と思った人はいますか? fp = add5で正しいです. (sizeofや単項演算子&のオペランドであるときを除いて) 式中では「関数関数へのポインタ」に暗黙的に型変換されます. ですので,式中でadd5の型は「関数へのポインタ」になり, fpadd5は同じ型になります (fp = &add5としても動くんですけどね).

fp (10)も同様です.「fpは関数へのポインタなのだから, (*fp) (10)が正しいのでは?」と思うかも知れません. でも,fp (10)で正しいです. そもそも関数呼び出しの文法は「関数 ( 引数の列 )」ではなく, 「関数へのポインタ ( 引数の列 )」です. add5 (10)add5の型は関数へのポインタなんです. ちなみに(*fp)(10)としても動きます. (*fp)は「関数へのポインタを関数」に戻しますが,その戻った関数型は すぐに「関数型へのポインタ」に変換されるからです. ですので,(******fp)(10)でも動きます.

enter, leave命令: スタックフレームを作成する,解放する

スタックフレーム

  • 戻り番地はスタックに格納しますが, それ以外のデータ(例えば,局所変数,引数,返り値,退避したレジスタの値など)も スタックを使います.
  • スタック上で管理する,関数呼び出し1回分のデータのことを スタックフレーム (stack frame)といいます.

例えば,main関数がadd5関数を呼び出して,add5からリターンすると以下の図になります. スタックフレームにはいろいろなデータが入っていますが, スタックフレームまるごとでプッシュしたりポップしたりします. ですので,関数を呼び出したりリターンする時はこの 「スタックフレームをプッシュしたり,ポップしたり」, つまり「スタックフレームを作成したり,破棄したり」する機械語命令列を 使う必要があります(以下で説明します).

そして%rsp%rbpは以下の図のように, スタック上の一番上のスタックフレームの上下を指す役割を担っています. (ただし,-fomit-frame-pointer オプションでコンパイルされている場合を除く).

enter, leave命令


記法何の略か動作
enter op1, op2make stack frameサイズop1のスタックフレームを作成する
leavediscard stack frame今のスタックフレームを破棄する

詳しい記法例の動作サンプルコード
enter imm16, imm8enter $0x20, $0pushq %rbp
movq %rsp, %rbp
subq $0x20, %rsp
enter.s enter.txt
leaveleavemovq %rbp, %rsp
popq %rbp
enter.s enter.txt

  • enter命令のop2には関数のネストレベルを指定するのですが, C言語では入れ子の関数がない(つまりネストレベルは常にゼロ)なので 常にゼロを指定します.
  • ただし,enterは遅いので通常は使いません. 代わりに同等の動作をするpushq %rbp; movq %rsp, %rbp; subq $size, %rspを使います.(sizeは新しいスタックフレームで確保するバイトサイズです). スタックは0番地に向かって成長するので,足し算ではなく引き算を使います.
enter命令はどのぐらい遅いのか(3〜4倍?)
$ gcc -g rdtscp-enter.c
$ ./a.out
processor ID = 0
processor ID = 0
processor ID = 0
240966
60796
$ ./a.out
processor ID = 0
processor ID = 0
processor ID = 0
165718
46368
$ ./a.out
processor ID = 1
processor ID = 1
processor ID = 1
204346
49530

インラインアセンブラを使ったCプログラムrdtscp-enter.cで,以下のコードを10000万回繰り返して, タイムスタンプカウンタの差分を調べた所, (単純な調べ方ですが)概ね3〜4倍という結果になりました.

# 遅い
asm volatile ("enter $32, $0; leave");
# 速い
asm volatile ( "pushq %rbp; movq %rsp, %rbp;"
               "subq $32, %rsp; leave");

leaveを入れないとスタックを使い切ってしまうのでleaveを入れています. leaveを除いて計測すればもうちょっと差が開くかも知れません.

というわけで,enterは遅いので,コンパイラがenterの代わりに出力する 機械語命令列で説明します.

# asm/stack-frame.s
    .text
    .type foo, @function
foo:
    pushq %rbp
    movq %rsp, %rbp	
    subq $32, %rsp
    # 本来はここにfoo関数本体の機械語列が来る
    leave # movq %rbp, %rsp; pop %rbp と同じ
    ret

    .globl main
    .type main, @function
main:
    call foo
    ret
    .size main, .-main
$ gcc -g enter2.s
$ gdb ./a.out -x stack-frame.txt
  • 関数fooの最初の3行が「関数fooのスタックフレーム」を作ります.
    pushq %rbp
    movq %rsp, %rbp	
    subq $32, %rsp
  • call前: %rsp%rbpは関数mainのスタックフレームの上下を指しています.
  • call後: call命令が戻り番地をプッシュしてから, (図にはありませんが)関数fooにジャンプします.
  • pushq %rbp後: スタックに%rbpの値(図中では古い%rbpの値)をプッシュします. この値はmainのスタックフレームの一番下を指しています.
  • movq %rsp, %rbp後: %rbpの値をスタック上に退避した(保存した)ので, movq %rsp, %rbpにより, %rbpが「関数fooのスタックフレームの一番下」を指すようにします.
  • subq $32, %rspにより,fooのスタックフレームを確保しました. これでfooのスタックフレームは完成です. ここでは32バイト確保していますが,関数fooの中身によって適宜,増減します.
  • 関数fooの最後の2行(leaveret)が 「関数fooのスタックフレーム」を破棄します. leave命令はmovq %rbp, %rsp; popq %rbpと同じ動作をします.
    leave
    ret
  • leave前: %rsp%rbpが関数fooのスタックフレームの上下を指しています.
  • leave前半(movq %rbp, %rsp)後: %rspが関数fooのスタックフレームの一番下を指します.
  • leave後半(popq %rbp)後: 退避しておいた「古い%rbp」をポップして%rbpに格納することで, %rbpは関数mainのスタックフレームの一番下を指します.
  • ret後: スタックトップに戻り番地がある状態に戻ったので, ret命令で関数fooからmainにリターンします. ret命令はスタックからポップして戻り番地を取り出すので, スタック上から戻り番地が無くなります. これでスタックは関数fooを呼び出す前と同じ状態に戻りました. %rsp%rbpは関数mainのスタックフレームの上下を指しています.

caller-saveレジスタとcallee-saveレジスタ

  • レジスタの数は限られているので,必要に応じて, レジスタの値はスタック上に退避(保存)する必要があります.
  • その保存の仕方で,レジスタは caller-saveレジスタcallee-saveレジスタに分類されます.これを以下で説明します.

calleeとcaller

関数Aが関数Bを呼び出す時,

  • 関数Aをcaller(呼び出す側),
  • 関数Bをcallee(呼び出される側),といいます.

雇用者を employer,被雇用者(雇われてる人)を employee って呼ぶのと同じ言い方ですね. デバッグする側(debugger),デバッグされる側(debuggee), テストする側(tester),テストされる側(testee)という言い方もあります.

レジスタ退避と回復

  • 関数呼び出しで,レジスタの退避と回復が必要になることが良くあります. レジスタの数が有限でごく少ないからです.
  • レジスタの退避・回復のやり方は大きく2種類あります:
    • caller側で退避・回復: caller側でレジスタのプッシュとポップを行う
    • callee側で退避・回復: callee側でレジスタのプッシュとポップを行う

LinuxのABIでの caller-saveレジスタとcallee-saveレジスタ

レジスタの退避と回復は,caller側でもcallee側でもできますが, レジスタごとにどちらでやるかを決めておくと便利です.

  • caller側で退避・回復を行うレジスタをcaller-saveレジスタと呼びます
  • callee側で退避・回復を行うレジスタをcallee-saveレジスタと呼びます

LinuxのABIでは 以下のように,caller-saveレジスタとcallee-saveレジスタが決まっています.

汎用レジスタ
caller-saveレジスタ%rax, %rcx, %rdx, %rsi, %rdi, %r8%r11
callee-saveレジスタ%rbx, %rbp, %rsp, %r12%r15

%rspのcallee側での退避・回復には, プッシュやポップを使いませんが, 「caller側にリターンする前に元に戻す,という約束をcallee側は守る(責任がある)」 という意味で,%rspもcallee-saveレジスタになります.

関数呼び出し規約 (calling convention)

関数呼び出し規約 (calling convention)は ABIが定める「callerとcalle間のお約束」です.例えば,以下を定めます:

引数の渡し方

引数レジスタ
第1引数%rdi
第2引数%rsi
第3引数%rdx
第4引数%rcx
第5引数%r8
第6引数%r9
  • 第1引数〜第6引数は上記の通り,レジスタを介して渡します
  • 第7引数以降はレジスタではなくスタックを介して渡します

スタックレイアウト

  • 上図は典型的なスタックレイアウトです.
  • 局所変数と第7以降の引数はスタック上に置きます. スタック上の局所変数や引数は%rbpを使ってアクセスします. 例えば,上図ではメモリ参照-16(%rbp)は局所変数2, メモリ参照24(%rbp)は第8引数への参照になります. %rbpを使う理由は, これらの絶対アドレスがコンパイル時に決まりませんが, %rbpに対する相対アドレスはコンパイル時に決まるからです. (-fomit-frame-pointerオプションが 指定された場合は,%rbpではなく%rspを使ってアクセスします).
  • スタックに置く局所変数や引数が8バイト未満の場合は アラインメント制約を満たすために, 隙間(パディング)を入れる必要があることがあります.

レジスタの役割

  • %rsp%rbpは一番上のスタックフレームの上下を指します (-fomit-frame-pointerオプションが 指定されていなければ).
  • (8バイト以下の整数であれば)返り値は%raxに入れて返します.
  • 可変長引数の関数(例えば printf)を呼び出す時は, 呼び出す前に %alに「使用するベクタレジスタ(例えば%xmm0)の数」を入れます.

レッドゾーン (redzone)

  • レッドゾーン%rspレジスタの上,128バイトの領域のことです. この領域には好きに読み書きして良いことになっています.

アラインメント制約

  • call命令実行時に%rspレジスタは16バイト境界を満たす, つまり%rspの値が16の倍数である必要があります. これを守らないとプログラムがクラッシュすることがあるので要注意です

関数プロローグとエピローグ

  • 関数本体実行前に準備を行うコードを関数プロローグ(function prologue), 関数本体実行後に後片付けを行うコードを関数エピローグ(function epilogue) といいます.
  • 上図は典型的な関数プロローグとエピローグです.
    • 関数プロローグでは,スタックフレームの作成, callee-saveレジスタの退避(必要があれば), (局所変数や引数のために必要な)スタックフレーム上での領域の確保, などを行います.
    • 関数エピローグでは,概ね,関数プロローグの逆を行います. callee-saveレジスタの回復の順番も,退避のときと逆になっている点に注意して下さい (退避の時は%rbx%r12,回復の時は逆順で%r12%rbx).
  • コンパイラに-O2などの最適化オプションを指定すると, 不要な命令が削られたり移動するため,プロローグとエピローグの内容が 大きく変わることがあります.

Cコードからアセンブリコードを呼び出す

// asm/mix1/main.c
#include <stdio.h>
int sub (int, int);
int main (void)
{
    printf ("%d\n", sub (23, 7));
}

# asm/mix1/sub.s
    .text
    .globl sub
    .type sub, @function
sub:
    pushq %rbp
    movq  %rsp, %rbp
    subq  %rsi, %rdi
    movq  %rdi, %rax
    leave
    ret
    .size sub, .-sub
$ gcc -g main.c sub.s
$ ./a.out
16
  • 関数規約が守られていれば, Cからアセンブリコードの関数を呼び出したり, アセンブリコードからCの関数を呼び出すことができます.
  • 上の例では関数mainから,アセンブリコード中の関数subを呼び出しています.

アセンブリコードからCコードを呼び出す

# asm/mix2/main.s
    .text
    .globl main
    .type sub, @function
main:
    pushq %rbp
    movq %rsp, %rbp
    movq $23,  %rdi
    movq $7,   %rsi
    call sub
    leave
    ret
    .size main, .-main
// asm/mix2/sub.c
int sub (int a, int b)
{
    return a - b;
}
$ gcc -g main.s sub.c
$ ./a.out
$ echo $?
16
  • 上の例ではアセンブリコードからCの関数を呼び出しています. 関数subの計算結果をここでは終了ステータスとして表示しています. 関数subが計算結果を%raxに入れて返した後, 関数main%raxを壊さず終了したので, 引き算の結果がそのまま終了ステータスになっています.
  • 終了ステータスの値は関数mainreturnした値,またはexitの引数に渡した値です. ただし,下位1バイトしか受け取れないので,終了ステータスの値は0から255までになります.

アセンブリコードからprintfを呼び出す

# asm/printf.s
    .section .rodata
L_fmt:
    .string "%d\n"

    .text
    .globl main
    .type sub, @function
main:
    pushq %rbp
    movq  %rsp, %rbp
    leaq  L_fmt(%rip), %rdi
    movq  $999,  %rsi
#    pushq $888   # ❶このコメントを外すと segmentation fault になることも
    movb  $0, %al # ❷
    call  printf
    leave
    ret
    .size main, .-main
$ gcc -g printf.s
$ ./a.out
999
  • アセンブリコードからprintfなどのライブラリ関数を呼び出せます.
  • call命令実行時には%rspの値は16の倍数で無くてはいけません (%rspのアラインメント制約). なので,❶の行のコメントを外して実行すると,segmentation fault が起きることがあります(起きないこともありますが,それはたまたまです). ❶の行のコメントを外さなければ, 「戻り番地の8バイトと古いrbpの値の8バイト」でちょうど16バイトが積まれて, %rspの値は16の倍数になります.
  • printfは可変長引数を持つ関数なので,呼び出し前に %alにベクトルレジスタ(例 %xmm0)の数を入れておく必要があります (ここではベクトルレジスタを使っていないのでゼロに設定).

アセンブラ命令

アセンブラとアセンブラ命令

  • アセンブラ (assembler)はアセンブリコード (foo.s)を オブジェクトファイル (foo.o)に変換するプログラムです.
    • 例えば,gcc -c foo.sとするとアセンブルできます. gcc -cは内部でasコマンドを呼んでいて,これがアセンブラ本体です. (gcc-vオプションを付けると,asコマンドの実行が見えます).
  • アセンブラ命令 (assembler directive)はアセンブラへの指示です.
    • 例えば,.textはアセンブラに「出力先を.textセクションに切り替えろ」と 指示しています.
    • GNUアセンブラのアセンブラ命令はすべてドット.で始まります.
  • アセンブラ命令はCPUが実行しない命令なので, 疑似命令(pseudo instruction)とも呼ばれます. (が,分かりにくい用語なので本書では使いません).

アセンブラ命令と機械語命令

何が実行いつ実行バイナリファイル中に
アセンブラ命令.textアセンブラアセンブル時残らない
機械語命令addl $5, %eaxCPUa.out実行時残る
  • アセンブラがアセンブリコード(*.s)からオブジェクトファイル(*.o)を作る際に,
    • アセンブラ命令(例: .text)に従って処理をします.例えば,addl $5, %eaxを バイト列に変換した結果 0x83 0xC0 0x05.textセクションに出力します.
    • その結果,アセンブラ命令 .textはオブジェクトファイルには残りません. (オブジェクトファイル中に.textセクションはありますが, これはアセンブラ命令.textではありません).
  • 一方,機械語命令 addl $5, %eaxはオブジェクトファイルに残っています (バイト列 0x83 0xC0 0x05と見た目は変わっていますが). a.outを実行したときに,CPUがこの機械語命令を実行します.

アセンブラの主な仕事

概要

アセンブラの主なお仕事は大きく次の4つです.

  • 機械語命令やデータを2進数表現に変換する

    • 例: pushq %rax0x50 に変換する
    • 例: .string "%d\n"0x25 0x64 0x0A 0x00に変換する (.stringは自動的にヌル文字 0x00を最後に付加します)
    • アセンブラにとって,機械語命令もデータも 「2進数にして出力する」という意味で,どちらも単なるデータです.
  • 変換した2進数を指定されたセクションに出力する

    • アセンブラは各セクションごとにロケーションカウンタ (location counter) を持っています.ロケーションカウンタは「機械語命令やデータを次に出力する際の アドレス」です. .align 8は「次に出力するアドレスを8の倍数にする(次の出力を8バイト境界にする)」というアセンブラ命令ですが, ロケーションカウンタという言葉を使うと 「ロケーションカウンタを8の倍数になるように増やす」と言い換えられます.
  • 記号表 (symbol table)を作って,ラベルをアドレスに変換する

  • 最後に変換したデータをバイナリ形式(LinuxではELF形式)にしてファイル出力する

セクションへの出力

セクションとは

バイナリファイルの構造はざっくり以下の図のようになっています.

  • 基本的に,バイナリ (オブジェクトファイル*.oや実行可能ファイルa.out)は セクション (section)という単位で区切られていて,それぞれ別の目的でデータが格納されます.
  • ヘッダ (header)は目次の役割で「どこにどんなセクションがあるか」という情報を保持しています.

代表的なセクション

セクション名説明
.text機械語命令(例:pushq %rbp)を置くセクション
.data初期化済みの静的変数の値(例:0x1234)を置くセクション
.bss未初期化(実行時に0で初期化する)の静的変数の値を置くセクション
.rodata読み込みのみ(read only)の値(例:"hello\n")を置くセクション

各セクションに出力する例

例えば,以下のアセンブリコードfoo.sがあるとします (.rodataセクションを指定する際は,.sectionが必要です).

# foo.s
.text            # .textセクションに出力せよ
pushq %rbp
movq %rsp, %rbp
.data            # .dataセクションに出力せよ
.long 0x11223344
.section .rodata # .rodataセクションに出力せよ
.string "hello\n"

このfoo.sをアセンブラが処理すると以下になります.

  • pushq %rbpを2進数にすると 0x55movq %rsp, %rbpを2進数にすると 0x48 0x89 0xe5 なので, これら合計4バイトを.textセクションに出力します.

  • .dataは「.dataセクションに出力せよ」, .long 0x11223344は 「0x11223344を4バイトの2進数として出力せよ」という意味です. 0x11223344を2進数にすると 0x44 0x33 0x22 0x11なので これら4バイトを.dataセクションに出力します. (出力が逆順になっているように見えるのは x86-64がリトルエンディアンだからです.)

  • .section .rodataは「.rodataセクションに出力せよ」, .string "hello\n"は 「文字列"hello\n"ASCIIコードの2進数として出力せよ」という意味です. "hello\n"を2進数にすると 0x68 0x65 0x6c 0x6c 0x64 0x0a 0x00なので, これら7バイトを.rodataセクションに出力します. (最後の0x00はヌル文字'\0'です.C言語では文字列定数の最後に自動的に ヌル文字が追加されますが,アセンブリ言語では必ずしもそうではありません. .stringはヌル文字を自動的に追加します. 一方,(ここでは使っていませんが).asciiはヌル文字を自動的に追加しません).

    ASCIIコードman asciiで確認できます.

セクションを確認する

  • セクションの内容はobjdump -hreadelf -Sコマンドで表示できます.

  • objdump -h の例と読み方

$ gcc -g -c add5.c
$ objdump -h add5.o
add5.o:     file format elf64-x86-64

Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         00000013  0000000000000000  0000000000000000  00000040  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .data         00000000  0000000000000000  0000000000000000  00000053  2**0
                  CONTENTS, ALLOC, LOAD, DATA
  2 .bss          00000000  0000000000000000  0000000000000000  00000053  2**0
                  ALLOC
  3 .debug_info   00000066  0000000000000000  0000000000000000  00000053  2**0
                  CONTENTS, RELOC, READONLY, DEBUGGING, OCTETS
  4 .debug_abbrev 0000004d  0000000000000000  0000000000000000  000000b9  2**0
                  CONTENTS, READONLY, DEBUGGING, OCTETS
  5 .debug_aranges 00000030  0000000000000000  0000000000000000  00000106  2**0
                  CONTENTS, RELOC, READONLY, DEBUGGING, OCTETS
  6 .debug_line   0000004f  0000000000000000  0000000000000000  00000136  2**0
                  CONTENTS, RELOC, READONLY, DEBUGGING, OCTETS
  7 .debug_str    00000093  0000000000000000  0000000000000000  00000185  2**0
                  CONTENTS, READONLY, DEBUGGING, OCTETS
  8 .debug_line_str 00000089  0000000000000000  0000000000000000  00000218  2**0
                  CONTENTS, READONLY, DEBUGGING, OCTETS
  9 .comment      0000002c  0000000000000000  0000000000000000  000002a1  2**0
                  CONTENTS, READONLY
 10 .note.GNU-stack 00000000  0000000000000000  0000000000000000  000002cd  2**0
                  CONTENTS, READONLY
 11 .note.gnu.property 00000020  0000000000000000  0000000000000000  000002d0  2**3
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
 12 .eh_frame     00000038  0000000000000000  0000000000000000  000002f0  2**3
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
説明
Idx1セクションの通し番号
Name.textセクションの名前
Size00000013セクションのサイズ (16進数,バイト)
VMA00000000実行時のセクションのアドレス (virtual memory address, 16進数)
LMA00000000ロード時のセクションのアドレス (load memory address, 16進数)
File Off00000040ファイルオフセット(16進数,バイト)
Algn2**0セクションのアラインメント制約(バイト), 2**nは\(2^n\)を意味

属性説明
CONTENTSこのセクションには中身がある(つまり中身が空のセクションもある)
ALLOCロード時にこのセクションのためにメモリを割り当てる必要がある
LOADこのセクションは実行するためにメモリ上にロードする必要がある
READONLYメモリ上では「読み込みのみ許可(書き込み禁止)」と設定する必要がある
CODEこのセクションは実行可能な機械語命令を含んでいる
RELOCこのセクションは再配置情報を含んでいる
  • readelf -S の例と読み方
$ gcc -g -c add5.c
$ readelf -S add5.o
There are 21 section headers, starting at offset 0x640:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .text             PROGBITS         0000000000000000  00000040
       0000000000000013  0000000000000000  AX       0     0     1
  [ 2] .data             PROGBITS         0000000000000000  00000053
       0000000000000000  0000000000000000  WA       0     0     1
  [ 3] .bss              NOBITS           0000000000000000  00000053
       0000000000000000  0000000000000000  WA       0     0     1
  [ 4] .debug_info       PROGBITS         0000000000000000  00000053
       0000000000000066  0000000000000000           0     0     1
  [ 5] .rela.debug_info  RELA             0000000000000000  00000410
       00000000000000c0  0000000000000018   I      18     4     8
  [ 6] .debug_abbrev     PROGBITS         0000000000000000  000000b9
       000000000000004d  0000000000000000           0     0     1
  [ 7] .debug_aranges    PROGBITS         0000000000000000  00000106
       0000000000000030  0000000000000000           0     0     1
  [ 8] .rela.debug_[...] RELA             0000000000000000  000004d0
       0000000000000030  0000000000000018   I      18     7     8
  [ 9] .debug_line       PROGBITS         0000000000000000  00000136
       000000000000004f  0000000000000000           0     0     1
  [10] .rela.debug_line  RELA             0000000000000000  00000500
       0000000000000060  0000000000000018   I      18     9     8
  [11] .debug_str        PROGBITS         0000000000000000  00000185
       0000000000000093  0000000000000001  MS       0     0     1
  [12] .debug_line_str   PROGBITS         0000000000000000  00000218
       0000000000000089  0000000000000001  MS       0     0     1
  [13] .comment          PROGBITS         0000000000000000  000002a1
       000000000000002c  0000000000000001  MS       0     0     1
  [14] .note.GNU-stack   PROGBITS         0000000000000000  000002cd
       0000000000000000  0000000000000000           0     0     1
  [15] .note.gnu.pr[...] NOTE             0000000000000000  000002d0
       0000000000000020  0000000000000000   A       0     0     8
  [16] .eh_frame         PROGBITS         0000000000000000  000002f0
       0000000000000038  0000000000000000   A       0     0     8
  [17] .rela.eh_frame    RELA             0000000000000000  00000560
       0000000000000018  0000000000000018   I      18    16     8
  [18] .symtab           SYMTAB           0000000000000000  00000328
       00000000000000d8  0000000000000018          19     8     8
  [19] .strtab           STRTAB           0000000000000000  00000400
       000000000000000d  0000000000000000           0     0     1
  [20] .shstrtab         STRTAB           0000000000000000  00000578
       00000000000000c6  0000000000000000           0     0     1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
  L (link order), O (extra OS processing required), G (group), T (TLS),
  C (compressed), x (unknown), o (OS specific), E (exclude),
  D (mbind), l (large), p (processor specific)
説明
[Nr][1]セクションの通し番号
Name.textセクションの名前
TypePROGBITSセクションのタイプ (以下参照)
Address0000000000000000セクションのアドレス
Offset00000040ファイルオフセット(16進数,バイト)
Size0000000000000013セクションのサイズ (16進数,バイト)
EntSize0000000000000000表中の固定長のエントリのサイズ (エントリがない場合は0)
FlagsAXセクションのフラグ (以下参照)
Link0関連するセクションの通し番号([Nr]) (存在しない場合は0)
Info0関連情報 (ない場合は0)
Align1セクションのアラインメント制約(バイト)

セクションのタイプ説明
NULLこのセクションは使われていない
PROGBITSこのセクションはプログラムがフォーマットと意味を決める情報を含む
NOBITS未初期化の領域を含む (ファイル中ではサイズゼロ)
RELAこのセクションは再配置情報を含む (調整用の値(addend)を含む)
NOTE言語処理系が定義・使用するメモ書き
SYMTABこのセクションは記号表 (symbol table)
STRTABこのセクションは文字列表 (string table)

フラグ説明
W (write)このセクションは実行時に書き込み可能にしておく必要がある
A (alloc)ロード時にこのセクションのためにメモリを割り当てる必要がある
X (execute)このセクションは実行可能な機械語命令を含む
M (merge)このセクション中のデータは重複を避けるためにマージ可能
S (strings)このセクションはヌル終端の文字列を含む
I (info)このセクションはセクションのタイプに依存した付加情報を保持してる
L (link order)このセクションはリンカに対して特別な順序を要求する
O (extra OS processing required)このセクションはOS固有の特別な処理が必要である
G (group)このセクションはセクショングループのメンバーである
T (TLS)このセクションはスレッドローカルストレージを持つ
C (compressed)このセクションの内容は圧縮されている (AやNOBITSとの併用はNG)
x (unknown)このセクションは不明なセクションである
o (OS specific)OS固有の意味を持つ
E (exclude)このセクションは参照もメモリ割り当てもされなければ削除される
D (mbind)このセクションは特別なメモリ領域に置く必要がある.Infoがそのメモリタイプを示す
l (large)このセクションは(2GB以上の)largeコードモデルである
p (processor specific)プロセッサ固有の意味を持つ
  • readelf -t で,より詳細なセクション情報を表示可能
  • readelf -nNOTEセクションの内容を表示可能

記号表とアドレスへの変換

# sym-main.s
	.text
	.globl	main
	.type	main, @function
main:
	movl	x(%rip), %eax # ラベル x の参照
	ret
	.size	main, .-main
# sym-sub.s
	.globl	x
	.data
	.align 4
	.type	x, @object
	.size	x, 4
x:                     # ラベル x の定義
	.long	999
$ gcc -c sym-main.s
$ gcc -c sym-sub.s
$ gcc sym-main.o sym-sub.o
$ nm sym-main.o
0000000000000000 T main
              ❶ U x
$ nm sym-sub.o
❷ 0000000000000000 D x 
$ nm a.out | egrep ' x'
❸ 0000000000004010 D x

$ objdump -D ./a.out
0000000000001129 <main>:
    1129: 8b 05 e1 2e 00 00    	mov  ❹ 0x2ee1(%rip),%eax  # 4010 <x>
 ❻ 112f: c3                   	ret    
(中略)
0000000000004010 <x>:
 ❺ 4010: e7 03                 out    %eax,$0x3
$ bc
obase=16
ibase=16
4010-112F
❼ 2EE1
  • アセンブラは記号表を作り,ラベルを見つけるたびに記号表に加えます. 記号表はリンク時にも使うので,アセンブラは記号表をオブジェクトファイルに埋め込みます. 最終的に,記号表の情報を使って,ラベルをアドレスに変換します.
  • 例えば,上記の例ではラベルxを記号表で管理して,最終的にアドレス0x4010に変換しています.
    • sym-main.smovl x(%rip), %eax ではラベルxが登場するので, アセンブラはラベルxを記号表に加えます.sym-main.s中には定義が無いので, nmコマンドで記号表を見ると「xは未定義 (❶ U x)」となっています.
    • 一方,sym-sub.s中にラベルxの定義があるので, nmで調べると,「xの(仮の)アドレスは0番地 ❷」と表示されます.
    • sym-main.osym-sub.oをリンクしたa.outを調べると, 「xのアドレスは0x4010番地 ❸」となってます.
    • objdump -Dで(.dataセクションも含めて)逆アセンブルすると, xのアドレスは確かに❹0x4010番地となっていて, movl x(%rip), %eax中のラベルxは相対アドレス❹0x2EE1に なっています(❺ 0x4010 - ❻ 0x112F = ❼ 0x2EE1).

バイナリ形式として出力

  • ここまでの処理で,アセンブラはセクション別に2進数のバイト列を生成しています.
  • アセンブラはこれらのバイト列をバイナリ形式のフォーマットに従って ファイルに出力します.
  • 本書の範囲ではバイナリ形式の詳細を知る必要はありませんが, 以下でELFバイナリ形式の全体の構造だけ説明します. この知識がないとreadelfコマンドを使う際に「ヘッダが3種類ある」と混乱するからです.

ELFバイナリ形式の構造

  • ELFには3種類のヘッダがあります
ヘッダの種類表示コマンド説明
ELFヘッダreadelf -hELFファイル全体の目次とメタ情報.必ずファイル先頭に存在
セクションヘッダreadelf -Sセクションの目次
プログラムヘッダreadelf -lセグメントの目次

  • ELFのセクションはリンクのための処理の単位です.
  • ELFのセグメントは複数のセクションが1つになったもので, 実行時にメモリにロードするための処理の単位です.
    • セクションと区別するために,「セグメント」という異なる名前が付いているがかえって紛らわしい.
  • ELFヘッダ以外は,配置する場所や順番に決まりはないです. (セクションヘッダは(ヘッダという名前なのに)ファイルの最後に配置されることが多いです). セクションヘッダとプログラムヘッダの位置(オフセット)は ELFヘッダ中に書かれています.
ELFとDWARF

バイナリ形式 ELF は executable linkage format の略です. 「北欧神話でおなじみのエルフ(ELF)と来ればドワーフ(DWARF)も欲しいでしょ」 というダジャレで, (元々はELF形式のために)DWARFという名前のデバッグ情報形式が生まれたようです.

GNUアセンブラの文法

  • GNUアセンブラの(statement)はアセンブリコードの構成要素であり, 上記の6つのいずれもが1つの文になります.

  • 文は改行かセミコロン;で区切ります.

    • セミコロンで区切れば,複数の文を1行に書いて良いです. 以下はどちらでもOKです

      pushq %rbp
      movq %rsp, %rbp
      
      pushq %rbp; movq %rsp, %rbp
      

コメント

  • GNUアセンブラのコメントは(C言語と同様に)プログラムのメモ書きです. アセンブラは単に無視します.

  • 行コメント: シャープ記号#から行末までがコメントになります

    # これは行コメントです
    

    注: 行コメントに使う記号はアーキテクチャ依存です. 例えば,x86-64は#ですが,ARMは@,H8は;,SPARCは!です.

  • ブロックコメント: C言語と同じで/*から*/までがコメントです. C言語と同様にブロックは入れ子禁止です(ブロックコメントの中にブロックコメントは書けません)

    /* これはブロックコメントです.
       複数行でもOKです           */
    
  • 入れ子のコメントを書くには,C前処理命令 #if 0#endifを使います.

    • ただし,ファイル拡張子を(大文字の).Sにする必要があります.
    • 拡張子を.SにするとC前処理が実行されるので,#define#includeも使えます.
    #if 0
    これはC前処理命令を使ったコメントです.
    これは入れ子にできます.
    #endif
    

定数

種類説明
10進数定数0で始まらないpushq $74
16進数定数0x0Xで始まるpushq $0x4A
8進数定数0で始まるpushq $0112
2進数定数0b0Bで始まるpushq $0b01001010
文字定数'(クオート)で始まるpushq $'J
'(クオート)で囲むpushq $'J'
\バックスラッシュ
でエスケープ可
pushq $'\n
文字列定数"(ダブルクオート)で囲む.string "Hello\n"
  • 上の例の最初の4つの定数は全部,値が同じです
  • GNUアセンブラでは文字定数の値はASCIIコードです. 上の例では,文字'J'の値は74です.
  • バックスラッシュでエスケープできる文字は, \b, \f, \n, \r, \t, \", \\ です. また\123は8進数,\x4Fは16進数で指定した文字コードになります.
  • 文字列定数では,.string.ascizは自動的にヌル文字終端しますが, .asciiはヌル文字終端しません(必要なら明示的に\0と書く必要があります). 文字列定数は即値や変位には使えません.

leaq main+10(%rip), %rax # 加算
movq $1<<12, %rax        # 左シフト
movq $1024*1024, %rax    # 乗算
  • 定数やラベルを書ける場所 (即値や変位)にはを書けます.

  • ただし,式には静的に(アセンブル時に)計算できるものだけが書けます.

    • レジスタやメモリの値は参照できません.
  • 上の例では加算,左シフト,乗算を使った例です.

  • 単項演算子

演算子意味
-2の補数による負数
~ビットごとの反転 (1の補数)
  • 2項演算子
演算子意味
*乗算
/除算
%剰余
<<左シフト
>>右シフト

演算子意味
|ビットOR
&ビットAND
^ビットXOR
!ビットOR NOT (a | ~bと同じ)

演算子意味
+加算
-減算
==等しい
!=等しくない
<>等しくない
<小さい
>大きい
<=以下
>=以上

演算子意味
&&論理AND
||論理OR
  • 一番上の表が最も優先度が高く,順番に下に行くほど優先度が下がります. 同じ表中の演算子同士は同じ優先度です.
  • 述語演算子は,真の時は-1 (つまり全ビットが1),負の時は0 (つまり全ビットが0)を返します.
  • 2023/9: =が入ってる演算子を使うと, foo.s:9: Error: invalid character '=' in operand 1というエラーがでます.なぜなんだぜ

ラベルと識別子

シンボル

  • シンボルはGNUアセンブラが使う一種の変数で,値を保持できます.
  • GNUアセンブラのシンボル名に使える文字は英語の大文字と小文字,数字,_$.です.
    • ただし,シンボル名は数字で始まってはいけません
    • 大文字と小文字は区別します (case-sensitive)
    • $.は使わない方が良いでしょう (即値やアセンブラ命令と紛らわしいから)
  • シンボルは主にラベルに使います.
    • ラベルはアセンブラが自動的にアドレスを割り当てます.つまり値がアドレスなシンボルがラベルです.
  • アセンブラ命令 .set でシンボルの値をセットできます(あまり使いませんが)
.set x, 999  # シンボルxの値を999にする

ラベル

  • シンボルの直後にコロン:が来ると (例: add5:),それはラベルの定義になります.
  • そのラベルの値はadd5:を処理した時のロケーションカウンタ (次に出力するアドレス)になります. つまり,アセンブラ が自動的にラベルをアドレスに変換します.
  • 変換するアドレスが絶対アドレスか相対アドレスかはGNUアセンブラが自動的に判断します (例: leaq foo(%rip), %raxfooは相対アドレスになります)
  • ラベルは関数名や変数名などの識別子名 (identifier),ジャンプ先を表すために使います
  • アドレスを書ける場所 (即値や変位)にはラベルを書いて良い (例: movq %rax, foo (%rip))

ラベルのスコープ

  • 同じファイル中に同じラベル名があってはいけません(二重定義). グローバルではないラベルのスコープはそのファイル.
foo:
foo:  # 二重定義でNG
  • グローバルなラベルは他のファイルに同じラベル名があってもいけません. グローバルなラベルのスコープはリンクするファイルすべて.
# file1.s
.globl foo
foo:
# file2.s
.globl foo
foo: # 二重定義でNG
  • 片方がweakなラベルなら二重定義でもOK
    • weakラベルと同名のラベルがあれば,(エラーなしで)weakではない方のラベルが使われる
    • weakラベルと同名のラベルがなければ,(エラーなしで)weakラベルが使われる
    • weakラベルは「他の定義に上書きされても良い,デフォルトの関数や変数」を定義するのに便利
# file1.s
.globl foo
foo:
# file3.s
.weak foo
.globl foo
foo: # OK (二重定義にならない)
  • C言語でも変数や関数をweakにできる
    • GCC独自拡張機能である__attribute__((weak))を使う
// weak-main.c
#include <stdio.h>
__attribute__((weak)) void foo ()
{
    printf ("I am weak foo\n");
}
int main ()
{
    foo ();
}
// weak-sub.c
#include <stdio.h>
void foo ()
{
    printf ("I am non-weak foo\n");
}
$ gcc -g weak-main.c
$ ./a.out
I am weak foo
$ gcc -g weak-main.c weak-sub.c
$ ./a.out
I am non-weak foo

記号表に含まれないラベル (.Lで始まるラベル)

// label.s
.text
.global foo1
foo1:
foo2:
.Lfoo3:
$ gcc -g -c label.s
$ nm label.o
0000000000000000 T foo1
0000000000000000 t foo2
  • ELFバイナリの場合,.Lで始まるラベルは記号表に含まれません.
  • コンパイラが出力するジャンプ先のラベルは.Lで始まることが多いです (例: .LFB0, .LFE0, .L2). ジャンプ先のラベルはデバッグ等に不要なことが多いからです.
  • 上の例で.Lfoo3は記号表に含まれないため,nmコマンドで出力されませんでした.

特別なドットラベル .

  • ドット .は特別なラベルで,ロケーションカウンタ自身(つまり現在のアドレス)を表します.

  • 例: fooの中身はfooのアドレスになります

    foo:
    .quad .
    
    foo:
    .quad foo  # こう書いても同じ
    
  • 例: .-add5は現在のアドレスからadd5のアドレスを引くので, 「関数add5の先頭から現在のアドレスまでの,機械語命令のバイト数」が計算できます

    .size   add5, .-add5
    

数値ラベル

  • 数値ラベル = 正の整数にコロン:がついたもの
  • 数値ラベルは再定義が可能 (2重定義にならない)
  • 参照時には fbを付ける
    • b (backward)は後方で最初の同名のラベルを参照
    • f (forward)は前方で最初の同名のラベルを参照
  • インラインアセンブリコードで使うと 2重定義を気にせず使えるので便利.

変数名とラベル

int x1 = 111;
int main ()
{
}
    .globl  x1
    .data
    .align 4
    .type   x1, @object
    .size   x1, 4
x1:
    .long   111
  • 静的な変数x1はそのままアセンブリコードのラベルx1になります. ラベルx1の値は「変数x1の実体が置かれるメモリ領域の先頭番地」になります.
// var2.c
void foo ()
{
    static int x2 = 222;
}

int main ()
{
    static int x2 = 333;
}
    .data
    .align 4
    .type   x2.1, @object
    .size   x2.1, 4
x2.1:
    .long   222

    .align 4
    .type   x2.0, @object
    .size   x2.0, 4
x2.0:
    .long   333
  • 関数内の静的変数x2は,同名の変数と区別するため,コンパイラが x2.0x2.1と数字を付け足しています.
    .text
    .globl  main
    .type   main, @function
main:
  • 関数mainもそのままアセンブリコードでラベルmainになります. ラベルmainの値は「関数mainの実体が置かれるメモリ領域の先頭番地」になります.

  • staticではない局所変数(自動変数)は記号表には含まれません.

    • 同じ関数が呼ばれるたびに,局所変数の実体がスタック上で確保されるため, アドレスが1つに確定しないからです.
    • 局所変数はコンパイル時にベースポインタ (%rbp)やスタックポインタ (%rsp)との 相対アドレスが確定します.局所変数はこの相対アドレスを使ってアクセスします.

アセンブラ命令

アセンブラ命令の種類

種類意味
セクション指定.text出力先を.textセクションにせよ
データ出力.long 0x123456784バイトの整数値0x12345678
2進数表現を出力せよ
出力アドレス調整.align 44バイト境界にアラインメント調整せよ
(4の倍数になるようにロケーションカウンタを増やせ)
シンボル情報.globl mainシンボルmainをグローバルとせよ
その他.ident "GCC.."(ELF形式の場合)文字列"GCC..".commentセクションに出力する

アセンブラ命令.identで指定した情報が, 本当にバイナリ中の.commentセクションに入ってるかを確認します.

.ident	"GCC: (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0"

確認にはここではobjdumpコマンドを使います.

$ objdump -s -j .comment ./a.out

./a.out:     file format elf64-x86-64

Contents of section .comment:
 0000 4743433a 20285562 756e7475 2031312e  GCC: (Ubuntu 11.
 0010 342e302d 31756275 6e747531 7e32322e  4.0-1ubuntu1~22.
 0020 30342920 31312e34 2e3000             04) 11.4.0.     

-j .commentは「.commentセクションだけを出力せよ」, -sは「出力するセクションの中身をすべてダンプする」という指示です.

確かに.commentセクションに入っていました.

セクション指定

アセンブラ命令説明
.text.text出力先を.textセクションに変更
.data.data出力先を.dataセクションに変更
.bss.bss出力先を.bssセクションに変更
.section セクション名.section .rodata出力先を指定したセクション名に変更

セクション役割
.text機械語命令列を保持
.data初期化済みの静的変数を保持
.bss未初期化の静的変数を保持
.rodata読み込みのみ(書き込み禁止)データを保持 (例: Cの文字列定数)
  • アセンブラ命令.text.dataなどは何度でも指定できます

Cの変数とセクションの対応

// var3.c
       int x1 = 10;     // コンパイル時に.dataセクションに確保
       int x2;          // コンパイル時に.bssセクションに確保
static int x3 = 10;     // コンパイル時に.dataセクションに確保
static int x4;          // コンパイル時に.bssセクションに確保
extern int x5;          // 何も確保しない

int main (void)
{
    int y1 = 10;        // 実行時にスタック上に確保
    int y2;             // 実行時にスタック上に確保
    static int y3 = 10; // コンパイル時に.dataセクションに確保
    static int y4;      // コンパイル時に.bssセクションに確保
    extern int y5;      // 何も確保しない
}
$ gcc -g var3.c
$ nm ./a.out
0000000000001129 T main
0000000000004010 D x1
0000000000004020 B x2
0000000000004014 d x3
0000000000004024 b x4
0000000000004018 d y3.0
0000000000004028 b y4.1
シンボルタイプ説明
T.textセクション中のシンボル(関数名)
D.dataセクション中のシンボル
B.bssセクション中のシンボル
U参照されているが未定義のシンボル
  • 初期化済みの静的変数は.dataセクションに置かれます
  • 未初期化の静的変数は.bssセクションに置かれます
  • 大文字のシンボルタイプはグローバルスコープ,小文字はファイルスコープを表します
  • extern int x5;は「x5の型はintだよ」とコンパイラに伝えるだけなので,実体は確保しません(念のため)

データ配置

アセンブラ命令説明
.byte 式, ....byte 0x11, 0x221バイトデータを2つ (0x110x22)出力
.word 式, ....word 0x110x11を2バイトデータとして出力
.long 式, ....long 0x110x11を4バイトデータとして出力
.quad 式, ....quad 0x110x11を8バイトデータとして出力
`.string" 文字列, ....string "hello"文字列"hello"を出力(ヌル文字を付加する)
`.ascii" 文字列, ....ascii "hello\0"文字列"hello\0"を出力(ヌル文字を付加しない)
`.asciz" 文字列, ....asciz "hello"文字列"hello"を出力(ヌル文字を付加)
`.fill データ数,サイズ,値.fill 10, 8, 0x12340x1234を8バイトデータとして10個出力
(サイズと値は省略可能.省略時はそれぞれ1と0になる)

出力アドレス調整 (アラインメント)

アセンブラ命令説明
.align.align 8出力先アドレスを8バイト境界にせよ
(ロケーションカウンタを8の倍数に増やせ)
.p2align.p2align 3出力先アドレスを\(2^3=8\)バイト境界にせよ
.space.space 3ロケーションカウンタを3増やせ (.skipでも同じ)
.zero.zero 33バイトのゼロを出力せよ
.org.zero 510ロケーションカウンタを510にせよ(増やす方向のみ)

.p2alignのp2は「2のべき乗 (power of 2)」を意味します.

シンボル情報

アセンブラ命令説明
.globl シンボル.globl fooシンボルfooをグローバルにせよ
.type シンボル, 型.type main, @functionシンボルmainの型は関数とせよ
.size シンボル, サイズ.size main, .-mainシンボルmainのサイズを.-mainの計算結果(単位はバイト)とせよ
.local シンボル.local fooシンボルfooをローカルにせよ
.comm シンボル, サイズ, アラインメント.comm foo, 4, 4.bssセクションにシンボルfooを作成せよ
(サイズは4バイト,アラインメントは4バイト境界で)
  • .typeでは型を @function (関数)か @object (普通のデータ)で指定します. (ELFの仕様によれば, @section@file@notypeなども使えます.これらはreadelf -sの出力にシンボルの型として出てきます).

  • .commについて補足します.

    int x; // グローバル変数
    

    に対して,gcc -S

        .globl  x
        .bss
        .align 4
        .type   x, @object
        .size   x, 4
    x:
          .zero   4
    

    というアセンブリコードを出力します.一方,

    static int y; 
    

    に対して,gcc -S

    # ❶
        .bss
        .align 4
        .type   y, @object
        .size   y, 4
    y:
        .zero   4
    

    ではなく

    # ❷
        .local  y 
        .comm   y,4,4
    

    を出力します(.localが必要なのは.localが無いと.commで指定したシンボルがグローバルになってしまうからです). .comm y,4,4の最初の4は定義するyのサイズが4,次の4はアラインメント制約が4バイトであることを示しています. 実は❶と❷は全く同じ意味なのです(なのでグローバル変数の場合と統一して,❶を出力してくれた方が分かりやすくて良いと思うのですが…).

# var4.s
	.data

	.bss
	.align 4
	.type	x, @object
	.size	x, 4
x:
	.zero	4

	.local	y
	.comm	y,4,4

	.text
	.globl	main
	.type	main, @function
main:
	endbr64
	ret
	.size	main, .-main
$ gcc -g var4.s
$ readelf -s ./a.out
symbol table '.symtab' contains 37 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
(中略)
    12: 0000000000004014     4 OBJECT  LOCAL  DEFAULT   24 x ❸
    13: 0000000000004018     4 OBJECT  LOCAL  DEFAULT   24 y ❹

$ nm ./a.out
(中略)
0000000000004014 b x ❺
0000000000004018 b y ❻

念のため,readelf -snmで記号表の中身を比べると❸〜❻は全く同じになりました.

AT&T形式とIntel形式

コマンド等での選択

  • gccでは,-masm=att (デフォルト),-masm=intelで出力するアセンブリコードの形式を選択可能です
$ gcc -S -masm=intel add5.c
$ cat add5.s
    .intel_syntax noprefix
    .text
    .globl  add5
    .type   add5, @function
add5:
    push    rbp
    mov     rbp, rsp
    mov     DWORD PTR -4[rbp], edi
    mov     eax, DWORD PTR -4[rbp]
    add     eax, 5
    pop     rbp
    ret
    .size   add5, .-add5
  • アセンブリコード中では,アセンブラ命令 .att_syntax (デフォルト)と .intel_syntaxで,どちらの記法を使うか選択可能です

  • objdump -dで逆アセンブルする際は,-M att(デフォルト)と-M intelで選択可能です.

$ objdump -d -M intel add5.o

add5.o:     file format elf64-x86-64

Disassembly of section .text:

0000000000000000 <add5>:
   0:	f3 0f 1e fa          	endbr64 
   4:	55                   	push   rbp
   5:	48 89 e5             	mov    rbp,rsp
   8:	89 7d fc             	mov    DWORD PTR [rbp-0x4],edi
   b:	8b 45 fc             	mov    eax,DWORD PTR [rbp-0x4]
   e:	83 c0 05             	add    eax,0x5
  11:	5d                   	pop    rbp
  12:	c3                   	ret    

AT&T形式とIntel形式の主な違い

  • オペランドの順序,即値,レジスタの表記が異なります
AT&T形式での例Intel形式での例説明
オペランドの代入の順序addq $4, %raxadd rax, 4AT&T形式では左→右
Intel形式では右→左に代入
即値の表記pushq $4push 4AT&T形式では即値に$がつく
レジスタの表記pushq %rbppush rbpAT&T形式ではレジスタに%がつく
  • オペランドのサイズ指定方法が異なります
    • AT&T形式では命令サフィックス(例えば,movbb)で指定します
    • Intel形式では BYTE PTRなどの記法を使います
AT&T形式の
サイズ指定
Intel形式の
サイズ指定
メモリオペランドの
サイズ
AT&T形式での例Intel形式での例
bBYTE PTR1バイト(8ビット)movb $10, -8(%rbp)mov BYTE PTR [rbp-8], 10
wWORD PTR2バイト(16ビット)movw $10, -8(%rbp)mov WORD PTR [rbp-8], 10
lDWORD PTR4バイト(32ビット)movl $10, -8(%rbp)mov DWORD PTR [rbp-8], 10
qQWORD PTR8バイト(64ビット)movq $10, -8(%rbp)mov QWORD PTR [rbp-8], 10
  • メモリ参照の記法が違います
AT&T形式Intel形式計算されるアドレス
通常のメモリ参照disp (base, index, scale)[base + index * scale + disp]base + index * scale + disp
%rip相対参照disp (%rip)[rip + disp]%rip + disp
  • 一部の機械語命令のニモニックが違います

    • 変換系の命令

    記法(AT&T形式)記法(Intel形式)何の略か動作
    c␣t␣c␣␣␣convert ␣ to ␣%rax (または%eax, %ax, %al)を符号拡張

    詳しい記法
    (AT&T形式)
    詳しい記法
    (Intel形式)
    例の動作サンプルコード
    cbtwcbwcbtw%al(byte)を%ax(word)に符号拡張cbtw.s cbtw.txt
    cwtlcwdecwtl%ax(word)を%eax(long)に符号拡張cbtw.s cbtw.txt
    cwtdcwdcwtd%ax(word)を%dx:%ax(double word)に符号拡張cbtw.s cbtw.txt
    cltdcdqcltd%eax(long)を%edx:%eax(doube long, quad)に符号拡張cbtw.s cbtw.txt
    cltqcdqecltd%eax(long)を%rax(quad)に符号拡張cbtw.s cbtw.txt
    cqtocqocqto%rax(quad)を%rdx:%rax(octuple)に符号拡張cbtw.s cbtw.txt

    • ゼロ拡張,符号拡張の命令

    記法(AT&T形式)記法(Intel形式)何の略か動作
    movs␣␣ op1, op2movsx op2, op1
    movsxd op2, op1
    move with sign-extentionop1を符号拡張した値をop2に格納
    movz␣␣ op1, op2movzx op2, op1move with zero-extentionop1をゼロ拡張した値をop2に格納

    詳しい記法例(AT&T形式)例(Intel形式)例の動作サンプルコード
    movs␣␣ r/m, rmovslq %eax, %rbxmovsxd rbx,eax%rbx = %eaxを8バイトに符号拡張した値movs-movz.s movs-movz.txt
    movz␣␣ r/m, rmovzwq %ax, %rbxmovzx rbx,ax%rbx = %axを8バイトにゼロ拡張した値movs-movz.s movs-movz.txt

    ␣␣に入るもの何の略か意味
    bwbyte to word1バイト→2バイトの拡張
    blbyte to long1バイト→4バイトの拡張
    bqbyte to quad1バイト→8バイトの拡張
    wlword to long2バイト→4バイトの拡張
    wqword to quad2バイト→8バイトの拡張
    lqlong to quad4バイト→8バイトの拡張

インラインアセンブラ

インラインアセンブラの概要

  • インラインアセンブラ (inline assembler)はコンパイラの機能の一部であり, 高級言語(例えばC言語)中にアセンブリコードを記述する(埋め込む)ことを可能にします

    • 埋め込んだC言語中のアセンブリコードをインラインアセンブリコードといいます
  • なるべく(アセンブリコードだけで記述するのではなく),C言語中でインラインアセンブラを使うべきです

    • アセンブリコードの生産性・保守性・移植性は非常に低いからです
    • インラインアセンブラを使えば「大半のコードはC言語で記述し,どうしてもアセンブリコードが必要な部分だけインラインアセンブラを使って書く」ことで,アセンブリコードの記述量を減らせます
    • この資料では,Linuxカーネルのアセンブリコードは 0.8% で,ほとんどがC言語だそうです (ただし,Cコード中のインラインアセンブリコードは正しくカウントされていないかも知れません)
  • GCCではasm構文を使ってインラインアセンブリコードを記述します

    • 例: nop命令を埋め込んだ例.実行しても何も起きないのであまり意味はないです. これは基本asm構文の例です.

      // inline-asm1.c (基本asm構文の例)
      int main (void)
      {
          asm ("nop");
      }
      
      
      $ gcc -g inline-asm1.c
      $ ./a.out
      (何も起きない)
      
    • 例: スタックポインタ %rspの値を変数addrに格納して表示する例. これはC言語だけでは書けないので意味があります. これは拡張asm構文の例です.

      // inline-asm2.c (拡張asm構文の例)
      #include <stdio.h>
      int main (void)
      {
          void *addr;
          asm volatile ("movq %%rsp, %0": "=m"(addr));
          printf ("rsp = %p\n", addr);
      }
      
      $ gcc -g inline-asm2.c
      $ gdb ./a.out
      (gdb) b main
      Breakpoint 2 at 0x555555555175: file foo.c, line 3.
      (gdb) r
      Breakpoint 2, main () at foo.c:3
      3	{
      (gdb) n
      5	asm ("movq %%rsp, %0": "=m"(addr));
      (gdb) n
      6	printf ("rsp = %p\n", addr);
      (gdb) p/x $rsp
      $2 = 0x7fffffffdee0 ❶
      (gdb) c
      Continuing.
      rsp = 0x7fffffffdee0 (printfの出力.❶の%rspの値と同じになっている)
      (gdb) q
      
  • インラインアセンブラの機能はC言語規格ではなく,コンパイラの独自拡張です.

    • インラインアセンブラの記法はコンパイラごとに異なります
    • gcc -std=c11 -pedanticなど,言語規格への遵守を強くするオプションを指定すると,コンパイルエラーになることがあります. asm__asm__にすればコンパイルできる場合もあります.
__asm__は予約識別子

_と大文字」あるいは「__(下線2つ)と小文字」で始まる識別子(名前)は 予約識別子 (予約語とは別のものです)と呼ばれ, 言語処理系が定義するための名前です. __asm__も予約識別子なので,アプリケーションプログラムが定義することはできず, 二重定義を避けられるというわけです. (二重定義を避けられても,asm構文がGCCの独自拡張であり, C言語規格には違反であることは同じですが…)

  • インラインアセンブラの使い方の注意(概要): コンパイラの最適化の影響をなるべく避けるため,以下に注意:
    • なるべく,基本asm構文ではなく拡張asm構文を使う
    • 拡張asm構文には修飾子volatileを必ず付ける
    • なるべくまとめて,1つの拡張asm構文で記述する
    • 必要ならgcc -Sで出力したアセンブリコードを(意図通りにasm構文が展開されているかを)確認する
    • 必要なら「人工的な変数の依存関係」を導入する
    • コンパイラの最適化により,拡張asm構文が移動させられたり,場合によっては 消去される可能性があります.なのでgcc -Sで出力を確認する必要があるのです.

基本asm構文

  • 基本asm構文は以下の形式です

    asm 修飾子 ( "アセンブリコード" );
    
  • 以下はnop命令のみを基本asm構文で指定した例です.

    asm ("nop");
    
  • 基本asm構文では(拡張asm構文でも)機械語命令以外に,ラベル定義,アセンブラ命令,コメントも書けます.

    asm ("foo: .text; nop # this is a comment");
    
  • 修飾子は volatileinlineを指定可能です

修飾子説明
volatile(ある程度)最適化を抑制する
(基本asm構文では指定しなくてもvolatileとみなされる)
inlineこの基本asm構文を含む関数がインライン化されやすい
ようにasm構文中の機械語命令のバイト数を少なく見積もる
  • アセンブリコードを指定する文字列中では生の改行文字を入れてはいけません(\nなら良い)

    • まず,最適化の影響を避けるため,なるべく1つのasm構文にまとめるべきです. 最適化がasm構文を移動したり削除したりすることがあるからです.

      // 良くない例 (1つのasm構文にまとめるべき)
      asm ("nop");
      asm ("nop");
      asm ("nop");
      
    • まとめる際は見やすさのため,各行ごとに文字列定数に分割した上で改行するの良いですし,これが最もお勧めです.

      // OK & お勧め
      asm ("nop\n\t"
           "nop\n\t"
           "nop\n\t");
      
      • カンマで区切られていない文字列定数の並び (ここでは "nop\n\t" "nop\n\t" "nop\n\t") はコンパイルの初期段階で1つの文字列として連結され, "nop\n\tnop\n\tnop\n\t" になります.
      • \tは無くてもOKです.出力されたアセンブリコードのインデントのためにつけています.
    • 文字列定数の途中で改行してはいけません. (文字列定数の途中で改行するとコンパイルエラーになります)

      // NG (コンパイルエラー)
      asm ("nop
            nop
            nop");
      
  • 基本asmコードはCのコードと協調(例: Cの変数へのアクセス)ができません. → 拡張asm構文を使え

// inline-bad.c
#include <stdio.h>
long x = 111;
long y = 222;   
int main ()
{
    asm ("movq x(%rip), %rax; addq %rax, y(%rip)");
    printf ("x = %ld, y = %ld\n", x, y);
}
$ gcc -g inline-bad.c
$ ./a.out
x = 111, y = 333

上記のように基本asm構文でも無理やりCの変数xyにアクセスできますが(これは悪い例), 「コンパイラが変数xx(%rip)として出力する」ことを大前提にしたコードです.つまり「たまたま動いている」だけです. このため,多くの場合,基本asm構文ではなく拡張asm構文を使うべきです. 上記の例を拡張asm構文で書き直すと以下になります. ("+rm"+は指定した変数y(%0)が,読み書きの両方で使われていることを示し, "rm"は「レジスタかメモリ参照として展開せよ」という指示(制約)です).

// inline-good.c
#include <stdio.h>
long x = 111;
long y = 222;   
int main ()
{
    asm volatile ("movq %1, %%rax; addq %%rax, %0"
                  : "+rm" (y) // 変数yのアセンブリコードを%0で展開
                  : "rm" (x)  // 変数xのアセンブリコードを%1で展開
                  : "%rax"    // レジスタ%raxの破壊の存在をコンパイラに伝達
        );
    printf ("x = %ld, y = %ld\n", x, y);
}
  • その他の注意点
    • 基本asm構文は関数内と関数外の両方で使える(拡張asm構文は関数内のみ)が, 関数外で使う場合はvolatileinlineも付けてはいけない.
    • asm構文から他のasm構文へのジャンプはしてはいけない. (拡張asm構文ならCのラベルへのジャンプはしても良い).
    • asm構文をGCCが複製する可能性があり(ループ展開とかで),その結果, シンボルの2重定義などが起こる可能性がある.これを避けるには %=を使う(ここでは詳細省略)
    • 基本asm構文ではアセンブリコードをそのまま出力する.レジスタ名も%rspをそのまま出力する.一方,拡張asm構文中は%を特別扱いするので,%%rspと書く必要があるので注意 (printfのフォーマット中で%を出力するために%%と書くのと同じ).
    • 基本asm構文は-masmで指定された方言に従うので, asm ("pushq $99");gcc -masm=intelとするとコンパイルエラーになる.

拡張asm構文

拡張asm構文の例 (引数を%順番で指定)

// inline-asm2.c (拡張asm構文の例)
#include <stdio.h>
int main (void)
{
    void *addr;
    asm volatile ("movq %%rsp, %0": "=m"(addr));
    printf ("rsp = %p\n", addr);
}
  • 拡張asm構文はコロン:で区切られた引数 (この場合は"=m" (attr))を持ちます. 引数は対応するアセンブリコードに変換した上で,%0などの部分を展開します.

    • 引数が複数個ある場合は,先頭から順番に%0, %1, %2, ... と参照します.
  • 上の例ではCの変数addrを対応するメモリ参照-16(%rbp)に変換して, %0の場所に展開しています.

    • 拡張asm構文の第1引数(命令テンプレート)中の%0などは展開対象です. つまり,%は特別扱いしています. %文字自体を使うには%%とする必要があります. このため,レジスタ%rspは命令テンプレート中で%%rspとなっています.
    • "=m"はアセンブリコードに変換する際の指示(制約)です. =は出力,mはメモリ参照として展開せよという意味になります.

拡張asm構文の例 (引数を%名前で指定)

// inline-asm3.c
#include <stdio.h>
int main (void)
{
    void *addr;
    asm volatile ("movq %%rsp, %[addr]": [addr] "=m"(addr));
    printf ("rsp = %p\n", addr);
}
  • 展開する場所(例: %0)は順番だけでなく名前でも指定できます.
  • 上の例では変数addrに,[addr]とすることでaddrという名前をつけて, 命令テンプレート中で%[addr]と参照しています.
    • ここではたまたま変数名addrと名前addrを同じにしましたが,別にしてもOKです
    • "=m (addr)"addrの部分には変数だけでなくC言語の式を書けます

拡張asm構文の例 (グルーコードが生じる例)

  • グルーコードが生じる例 (単純な例)
// inline-asm4.c
#include <stdio.h>
int main (void)
{
    void *addr;
    asm volatile ("movq %%rsp, %0": "=r"(addr));
    printf ("rsp = %p\n", addr);
}
$ gcc -S inline-asm4.c
$ cat inline-asm4.s
(中略)
#APP
# 6 "inline-asm4.c" 1
 ❶ movq  %rsp, %rax
# 0 "" 2
#NO_APP
 ❷ movq  %rax, -8(%rbp)
  • inline-asm2.cの制約 "=m" (メモリ参照)を "=r" (レジスタ)に変更してみます.すると, 制約の指定がレジスタなので,%0は❶ %raxに展開されました. しかし,最終的な格納先である変数addrはメモリ (-8(%rbp))だったので, ❷ movq %rax, -8(%rbp)が追加され,変数addrに代入されるようになりました.

  • この追加された命令❷をグルーコード(glue code)といいます. グルーは接着剤という意味です. グルーコードはCのコードとインラインアセンブリコードの間の隙間を 接着する役割を担ってます.

  • この場合,制約を=rと指定した結果, inline-asm2.cでは不要だった グルーコードが増えてしまいました.制約はなるべく緩く指定して, コンパイラに最適な出力を任せる方が良いことが多いでしょう. この場合は,レジスタでもメモリでも良いので,"rm"と指定するのが最適だと思います.

グルーコードが生じる例 (rdtscpの例)

// rdtscp.c
#include <stdio.h>
#include <stdint.h>

uint64_t rdtscp (void) {
    uint64_t hi, lo;
    uint32_t aux;
    asm volatile ("rdtscp":"=a"(lo), "=d"(hi), "=c"(aux));
    printf ("processor ID = %d\n", aux);
    return ((hi << 32) | lo);
}

int main (void) {
    printf ("%lu\n", rdtscp ());
}
$ gcc -S rdtscp.c
$ less rdtscp.s
(一部略)
#APP
# 8 "rdtscp.c" 1
    rdtscp
# 0 "" 2
#NO_APP
 ❶  movq    %rax, -16(%rbp)
 ❷  movq    %rdx, -8(%rbp)
 ❸  movl    %ecx, -20(%rbp)
  • rdtscp命令には明示的なオペランドは無く, 64ビットのタイムスタンプカウンタの値を%edx:%eaxに格納します. (そしてプロセッサIDを%ecxに格納します).
  • gcc -Sの出力を見ると,rdtscp命令に加えてグルーコード❶❷❸が追加されています.
    • ❶❷❸はrdtscp命令がレジスタに格納した値を変数hi, lo, auxに 格納しています.

拡張asm構文の形式

  • 拡張asm構文の形式は次の2種類があります. 以下ではコロン:の手前で改行していますが,これは見やすさのためだけで, 1行で書いても構いません.

    asm 修飾子 (命令テンプレート
             : 出力オペランド列
             : 入力オペランド列  # 省略可能
             : 破壊レジスタ列   # 省略可能
    	   );
    
    asm 修飾子 (命令テンプレート
             : 出力オペランド列
             : 入力オペランド列
             : 破壊レジスタ列
             : gotoラベル列
    	   );
    
    • 最初の形式の修飾子にはgotoを含んではいけません. 2番目の形式の修飾子にはgotoを含めなくてはいけません.

    • 最初の形式の場合,出力オペランド列より後は(不要なら)省略可能です. : 入力オペランド列を省略した場合は,: 破壊レジスタ列も省略しなければいけません. 途中に空の部分がある場合は asm ("nop":::"%rax"); などとコロン:を並べます. 一方,2番目の形式では省略できません.

    • 拡張asm構文は関数中でのみ使えます.関数の外では使えません.

    • 修飾子は以下を指定可能です.

    修飾子説明
    volatile(ある程度)最適化を抑制する (拡張asm構文には常に指定を推奨)
    inlineこの基本asm構文を含む関数がインライン化されやすい
    ようにasm構文中のasm命令(バイト数)を少なく見積もる
    goto命令オペランド中から「gotoラベル列」中のCラベルにジャンプする可能性を示す
    (gotoを指定すると,volatileの指定なしでもvolatileになる)
    • ○○列の中身が複数ある場合はカンマ,で区切ります.
  • 拡張asm構文の例 (goto無し)

// inline-asm5.c
#include <stdio.h>
int main (void)
{
    int x = 111, y = 222, z;
    // z = x + y;
    asm volatile ("movl %1, %%eax; addl %2, %%eax; movl %%eax, %0"
                  : "=rm" (z)
                  : "rm" (x), "rm" (y)
                  : "%eax" );
    printf ("x = %d, y = %d, z = %d\n", x, y, z);
}
  • 命令テンプレート中の %0, %1, %2 は出力オペランド列と入力オペランド列中の z, x, yに対応します.
    • 名前を使うと,順番ではなく,%[z], %[x], %[y]と名前での参照も可能です.
  • 例えば,出力オペランド列中の "=rm" (z)は1つの出力オペランドです. "=rm"は制約を示していて,"="はこのオペランドが出力であること, "rm"はこのオペランドをレジスタかメモリ参照として%0を展開することを指示しています.
  • 命令テンプレート中で値を破壊するレジスタは「破壊レジスタ列」で指定する.
    • この指定が無いとコンパイラは「命令テンプレート中でどのレジスタが破壊されるか」が分からないからです.コンパイラが気づかず同じレジスタを使うと, 命令テンプレート中のレジスタ書き込みにより,値が壊れてしまいます.
    • 出力オペランド列で指定したレジスタは破壊レジスタ列で指定する必要はありません
  • 特別な記法として,メモリの値を壊す場合は "memory", フラグレジスタの値を壊す場合は "cc" を「破壊レジスタ列」に指定する.

  • コンパイラは必要に応じて,命令テンプレートの前に「レジスタの値をメモリに退避(書き戻し)」,命令テンプレートの後に「メモリの値をレジスタに復帰させる」コードを追加します

  • 拡張asm構文の例 (gotoあり)

// inline-asm6.c
#include <stdio.h>
int add (unsigned char arg1, unsigned char arg2)
{
    asm goto ("addb %1, %0; jc %l[overflow]" // ❷ %l3 でも可
              : "+rm" (arg2)
              : "rm" (arg1)
              :
              : overflow ); // ❶
    return arg2;
overflow:
    printf ("overflow: arg1 = %d, arg2 = %d\n", arg1, arg2);
    return arg2;
}

int main (void)
{
    printf ("result = %d\n", add (254, 1));
    printf ("result = %d\n", add (255, 1));
}
$ gcc -S inline-asm6.c
$ cat inline-asm6.c
(中略)
    movl    %edi, %edx
    movl    %esi, %eax
    movb    %dl, -4(%rbp)
    movb    %al, -8(%rbp)
    movzbl  -8(%rbp), %eax
#APP
# 5 "inline-asm6.c" 1
    addb -4(%rbp), %al; jc .L2
# 0 "" 2
#NO_APP
(中略)
.L2:

$ gcc -g inline-asm6.c
$ ./a.out
result = 255
overflow: arg1 = 255, arg2 = 0
result = 0
  • 命令テンプレート中からCラベルにジャンプする可能性がある場合, goto付きの拡張asm構文を使う必要があります. その場合はジャンプする可能性があるCのラベルを「gotoラベル列」に列挙します(ダブルクオート"では囲みません).

  • 上の例では,❶「gotoラベル列」にoverflowを指定しています. 命令テンプレート中でラベルを参照するには次のどちらかを使います.

    • 引数の順番を使う: "+"は入出力で1回ずつ出現すると数えるので, 出力オペランドが1つ (%0),入力オペランドが2つ(%1, %2)になるので, ラベルoverflowは(0から数えて)3番目になります.頭に%l(%と小文字のエル)をつけて,%l3とします.
    • 引数の名前を使う: %l とラベル名で,❷ %l[overflow]とします.
  • ある実行パスで出力を設定しない場合,入力でも出力でも使われないことになり, 出力コードがおかしくなることがあるそうです (マニュアルによると).その場合は制約+を使って,必ず入力として使うと指定すれば大丈夫だそうです(試してません).

出力オペランド列と入力オペランド列

  • 出力オペランド列と入力オペランド列はどちらも以下の形式になります
"制約文字列" (Cの式), "制約文字列" (Cの式), …
  • 「Cの式」は変数を指定することが多いですが,一般的なCの式でもOKです. ただし,出力オペランドの場合は「Cの式」は左辺値(アドレスを持つ式)でなければいけません.
  • 使用できる制約文字列は次の節で示します.

制約

制約の一覧表

代表的な制約を以下に示します. 他の制約はGCCインラインアセンブラを参照下さい.

  • 入出力を指定する制約
制約説明
=オペランドは出力専用(指定するなら必ず1文字目)
+オペランドは入出力(指定するなら必ず1文字目)
(指定なし)オペランドは入力専用
  • 汎用の制約
制約説明
rオペランドはレジスタ
mオペランドはメモリ
iオペランドは整数即値
gオペランドは制約無し ("rmi"と同じ)
&オペランドは早期破壊レジスタ
0マッチング制約 (19も同じ)
%オペランドは交換可能(可換)
書き込みオペランドには指定不可
  • x86用の制約 (レジスタ)

    制約説明
    a%rax, %eax, %ax, %alのいずれか
    b%rbx, %ebx, %bx, %blのいずれか
    c%rcx, %ecx, %cx, %clのいずれか
    d%rdx, %edx, %dx, %dlのいずれか
    D%rdi, %edi, %di, %dilのいずれか
    S%rsi, %esi, %si, %silのいずれか
    A制約 a, d のいずれか
    Q制約 abcdのいずれか
    q任意の整数レジスタ (%rsp%rbpは使わない)
    Ucaller-saveレジスタ
    • q制約は,-fomit-frame-pointerオプションをgccに付けると%rbpを使用する (%rbpが汎用レジスタとして使えるようになるため)
  • x86用の制約 (定数)
制約説明
I範囲0〜31の整数 (32ビットシフト用)
J範囲0〜63の整数 (64ビットシフト用)
K範囲-128〜127の整数 (符号あり8ビット整数定数用)
L0xFF, 0xFFFF, 0xFFFFFFFF (マスク用)
M0, 1, 2, 3 (メモリ参照のスケール用)
N範囲0〜255の整数 (in, out命令用))

制約m(メモリ)と制約r(レジスタ)の違い

// inline-asm2.c
#include <stdio.h>
int main (void)
{
    void *addr;
    asm volatile ("movq %%rsp, %0": "=m"(addr));
    printf ("rsp = %p\n", addr);
}
$ gcc -S inline-asm2.c
$ cat inline-asm2.s
(中略)
  movq  %rsp, -16(%rbp)
// inline-asm4.c
#include <stdio.h>
int main (void)
{
    void *addr;
    asm volatile ("movq %%rsp, %0": "=r"(addr));
    printf ("rsp = %p\n", addr);
}
$ gcc -S inline-asm4.c
$ cat inline-asm4.s
(中略)
  movq  %rsp, %rax
  movq  %rax, -16(%rbp)
  • 上のコードで制約m(メモリ)を使うと,%0はメモリ参照-16(%rbp)に展開されました. -16(%rbp)は変数 addrなので,変数addrへの代入はこれで終了です.
  • 一方, 制約r(レジスタ)を使うと,%0はレジスタ%raxに展開されました. また,%raxの値をaddrに代入するために, グルーコード movq %rsp, -16(%rbp)が追加されました.

読み書きするオペランドには制約+を使う

// inline-asm7.c
#include <stdio.h>
int main (void)
{
    long in = 111, out = 222;
    asm volatile ("movq %1, %0": "=rm"(out): "rm" (in)); // out = in;
    printf ("in = %ld, out = %ld\n", in, out);
}
// inline-asm8.c
#include <stdio.h>
int main (void)
{
    long in = 111, out = 222;
    asm volatile ("addq %1, %0": "+rm"(out): "rm" (in)); // out += in;
    printf ("in = %ld, out = %ld\n", in, out);
}
// inline-asm9.c
#include <stdio.h>
int main (void)
{
    long in = 111, out = 222;
    asm volatile ("addq %1, %0": "=rm"(out): "rm" (in), "0" (out)); // out += in;
    printf ("in = %ld, out = %ld\n", in, out);
}
  • inline-asm7.cの❶はmovq命令なので,outは出力専用です. このため,入出力の制約は=を指定しています.
  • 一方,inline-asm8.cの❷はaddq命令なので,outは入力と出力の両方になります. このため,入出力の制約は+を指定しています.
  • addq命令に対してはマッチング制約 (ここでは"0")を使う方法もあります. inline-asm9.cの❸では,出力オペランド列ではout=を指定し, 入力オペランド列にもoutを指定し,その制約に"0"を指定しています. この"0"の指定で「このオペランドoutは出力オペランドの%0と同じオペランドだ」と伝えているのです.
    • マッチング制約で「同じオペランドだ」と指定された場合, コンパイラはそれらのオペランドに同じレジスタやメモリ参照を 割り当てようとします.

早期破壊オペランド制約 &

要約:

  • 入力オペランドの参照よりも前に,出力オペランドへの代入がある場合は, 早期破壊オペランド制約 &を使う必要がある.
  • 早期破壊オペランド制約 & は「同じレジスタに割り当てるな」という指示
    • cf. マッチング制約は「同じレジスタに割り当てろ」という指示
// early-clobber.c
#include <stdio.h>
int main (void)
{
    int a = 20, b;
    asm volatile ("movl $10, %0; addl %1, %0"
                  : "=r"(b) : "r"(a)); // b = 10; b += a;
    printf ("b = %d\n", b);
}
// early-clobber2.c
#include <stdio.h>
int main (void)
{
    int a = 20, b;
    asm volatile ("movl $10, %0; addl %1, %0"
                  : "=&r"(b) : "r"(a)); // b = 10; b += a;
    printf ("b = %d\n", b);
}
$ gcc -g early-clobber.c
$ ./a.out
b = 20    結果が正しくない
$ gcc -g early-clobber2.c
$ ./a.out
b = 30    結果は正しい
  • GCCは「命令テンプレートでは,入力オペランドを全て参照した後で, 出力オペランドへ代入している」という仮定をしています.
  • その理由は多くの場合,その仮定は成り立つし,成り立てば,入力オペランドと出力オペランドに同じレジスタを割り当てられるからです.
    • OKな例: y = x + 3;xを参照した後で,yに代入しています.ですので, xyに同じレジスタ%eaxを割り当てて,addl $3, %eaxとしてもOKです.
    • NGな例: b = 10; b += a;bへの代入の後で,入力aを参照しています. GCCの仮定に反しているのに,abに同じレジスタ%eaxを割り当てて movl $10, %eax; addl %eax, %eaxとしてしまうと,aの元の値が破壊されてしまいます.これが上のearly-clobber.cの状況です.
  • early-clobber.c(上図の左)では,出力の制約を❶=としているだけなので, %0%1が同じレジスタ %eaxになり❷, 意図通りの計算結果になりません(aの初期値20が失われています).
  • これを防ぐには 早期破壊オペランド制約 & (early-clobber operand constraint)を 制約に指定します❸.その結果,%0%1には別のアドレスが割り当てられました.

破壊レジスタ列

  • 出力オペランド列に指定したレジスタやメモリ以外への書き込みがある場合は, 破壊レジスタ列に指定する必要があります. コンパイラは指定されたレジスタやメモリへの読み書きが整合する範囲でのみ, 最適化のためのasm構文の移動を考えてくれます.

  • レジスタの破壊を指定して退避される例

int main ()
{
    asm ("movl $999, %%ebx" :::"%ebx");
}

❶ pushq  %rbx             # %rbxの退避
   movl   $999, %ebx       # %rbxの上位32ビットはクリアされる
❷ movq   -8(%rbp), %rbx   # %rbxの回復

破壊レジスタ列に%rbxを指定すると,コンパイラは前後に %rbxの退避❶と回復❷のコードを付け足します. %rbxはcallee-saveレジスタなので,main関数からリターンする前に, %rbxの値を元の値に戻す必要があるからです.

  • メモリの破壊を指定してコードが変化する例
// clobber-mem.c
#include <stdio.h>
int x = 111, y = 222;
int main ()
{
    y = x;
//    asm volatile ("":::"memory");
    return x;
}

// clobber-mem2.c
#include <stdio.h>
int x = 111, y = 222;
int main ()
{
    y = x;
    asm volatile ("":::"memory"); // ❶
    return x;
}

$ gcc -S clobber-mem.c
$ cat clobber-mem.s
(一部省略)
main:
    movl  x(%rip), %eax
    movl  %eax, y(%rip)
    ret
$ gcc -S clobber-mem2.c
$ cat clobber-mem2.s
(一部省略)
main:
 ❷ pushq  %rbp
 ❷ movq   %rsp, %rbp
 ❸ movl   x(%rip), %eax
    movl   %eax, y(%rip)
    # asm volatile ("":::"memory");
 ❹ movl   x(%rip), %eax
 ❷ popq   %rbp
    ret
  • 上のコードで"memory"を付けてメモリの破壊の存在をコンパイラに伝えた所, 以下の2つの変化がありました
    • スタックフレームが壊されないように,main用のスタックフレームを作りました❷
    • 変数xの値が変化しているかも知れないので,❸で読んだ値は使わず, メモリが変化したと言われた後の ❹で改めて読んでいます.
  • なお,上の命令テンプレート❶は空ですが,コンパイラはメモリ破壊を信じてくれています

GCCは%以外の命令テンプレートの中身を見ない

// inline-hoge.c
#include <stdio.h>
long x = 111;
long y = 222;   
int main ()
{
    asm volatile ("hogehoge %1, %0": "+rm" (y): "rm" (x));
    printf ("x = %ld, y = %ld\n", x, y);
}
    movq    x(%rip), %rax
    movq    y(%rip), %rdx
    hogehoge %rax, %rdx
    movq    %rdx, y(%rip)
  • GCCは(%を除いて)命令テンプレートの中身は見ません. 入出力オペランド列や破壊レジスタ列などの情報だけを使って, %0などの部分を展開したり,グルーコードを出力します.
  • その証拠に,上の例では存在しない命令hogehogeに対して,上記の展開とグルーコード付加をGCCは行いました.

局所変数をレジスタ割当にする

// local-reg.c
#include <stdio.h>
int main ()
{
    register long foo asm ("%r15") = 999;
    printf ("%ld\n", foo);
}
  • 非常に頻繁に使う局所変数をレジスタ名を指定してレジスタ割当にしたい場合があります.その場合は上記の記法で指定できます.
  • 注意:
    • registerは必要です.static, const, volatileなどはつけてはいけません.
    • この記法は指定したレジスタを予約するものではありません. コンパイラは他の部分で指定したレジスタを上書きするので,それを前提として使う必要があります.

アセンブラ方言の扱い

  • GCCはアセンブリ記法の方言を出力できます.例えば,x86-64ではgcc -masm=attでAT&T記法を,gcc -masm=intelでIntel記法を出力できます. (デフォルトはAT&T記法です)
  • gccへのオプション (-masm=att, -masm=intel)でどちらの記法を出力するか切り替えられますが,インラインアセンブリコードの中身は自動的には切り替わりません
  • どちらのオプションが指定されても大丈夫にする方法がGCCにはいろいろ用意されています..例えば,以下の記法は
int main ()
{
    asm volatile ("{movslq %%eax, %%rbx | movsxd rbx,eax}":);
}

AT&T形式の際は movslq %%eax, %%rbxを出力し,Intel形式の時は movsxd rbx,eaxを出力します. (このように命令テンプレート中で,{, |, }も, (そして =も) 特別な意味を持つので,これらの文字自身を出力したい場合は, それぞれ,%{, %|, %}, %=と記述する必要があります). つまり,インラインアセンブリコードを書く人が AT&T形式とIntel形式の記述の両方を併記する必要があります. さらに,

int main ()
{
    int x = 111;
    asm volatile ("inc %q0":"+r" (x));
}

と書くと,%q0の部分は,AT&T形式に対しては例えば%rax,Intel形式に対してはraxなどと展開します.詳細はGCCインラインアセンブラx86 Operand Modifiersを御覧ください.

GCCが生成したアセンブリコードを読む

この章では単純なCのプログラムをGCCがどのようなアセンブリコードに変換するかを見ていきます. これより以前の章で学んだ知識を使えば,C言語のコードが意外に簡単にアセンブリコードに変換できることが分かると思います. 実際にコンパイラの授業で構文解析の手法を学べば, (そして最適化器の実装を諦めれば)コンパイラは学部生でも十分簡単に作れます.

制御文

if文

// if.c
int x = 111;
int main ()
{
    if (x > 100) {
        x++;
    }
}
  • ifの条件式x > 100は反転して「x <= 100なら,then部分をスキップして,thenの直後(ラベル.L2)にジャンプする」というコードを出力しています. (反転する必然性はありません.x > 0を評価してその結果が0に等しければ,.L2にジャンプするコードでも(実行速度を除けば)同じ動作になります)
  • then部分では「xに1を加える」コードを出力しています.
なぜGCCはinc命令を使わないの?

incl x(%rip)なら1命令で済みますよね. もし-O-O2などの最適化オプションを付ければ, GCCはincを使うかも知れませんが, そうしてしまうと,(最適化が賢すぎて)元のif文の構造がガラリと変わってしまう可能性があるため避けています. この章では全て「最適化オプションを付けていないので,GCCは無駄なコードを出力することがある」と考えて下さい.

if-else文

// if-else.c
int x = 111;
int main ()
{
    if (x > 100) {
        x++;
    } else {
        x--;
    }
}
  • ifの条件式x > 100は反転して「x <= 100なら,then部分をスキップして,thenの直後(ラベル.L2)にジャンプする」というコードを出力しています.
  • then部分では「xに1を加える.次にelse部分をスキップするために.L3にジャンプする」コードを出力しています.
  • else部分では「xから1減らす」コードを出力しています.

while文

// while.c
int x = 111;
int main ()
{
    while (x > 100) {
        x--;
    }
}
  • while条件判定はwhileボディのコードの後に置かれています(これは必然ではありません). 最初にwhileループに入る時,.L2にジャンプします.
  • whileの条件式x > 100が成り立つ間は,.L3に繰り返しジャンプします.
  • 「whileボディ」実行後に必ず「while条件判定」が必要になるので,これでうまくいきます.

for文

// for.c
int x = 111;
int main ()
{
    for (int i = 0; i < 10; i++) {
        x--;
    }
}
  • ほぼwhile文と同じです.違いは「for初期化」 (int i = 0)があることと, 「forボディ」の直後に「for更新」(i++)があることだけです.
  • GCCが条件判定のコードを, i < 10 (cmpl $10, -4(%rbp); jl .L3)ではなく, i <= 9 (compl $9, -4(%rbp); jl .l3)としています. どちらも同じなのですが,なぜこうしたのか,GCCの気持ちは分かりません.

switch文 (単純比較)

// switch.c
int x = 111;
int main ()
{
    switch (x) {
    case 1:
        x++;
        break;
    case 111:
        x--;
        break;
    default:
        x = 0;
        break;
    }
}
  • ジャンプテーブルを使わない,単純に比較するコード生成です. 例えば,case 1:は,if (x == 1)と同様のコード生成になっています.
  • この方法ではcaseが\(n\)個あると,\(n\)回比較する必要があるので, \(O(n)\)の時間がかかってしまいます.

switch文 (ジャンプテーブル)

// switch2.c
int x = 111;
int main ()
{
    switch (x) {
    case 1:  x++;   break;
    case 2:  x--;   break;
    case 3:  x = 3; break;
    case 4:  x = 4; break;
    case 5:  x = 5; break;
    default: x = 0; break;
    }
}
// switch3.c
int x = 111;
int main ()
{
    void *jump_table [] = {&&L2, &&L8, &&L7, &&L6, &&L5, &&L3} ; // ❶
    goto *jump_table [x]; // ❷

L8: // case 1:
    x++;   goto L9;
L7: // case 2:
    x--;   goto L9;
L6: // case 3:
    x = 3; goto L9;
L5: // case 4:
    x = 4; goto L9;
L3: // case 5:
    x = 5; goto L9;
L2: // default:
    x = 0; goto L9;
L9:
}
  • switch2.cをジャンプテーブルを使って書き換えたのがswitch3.cです. ❶と❷の部分はGCC拡張機能で「ラベルの値を配列変数に格納し❶」, 「gotoで格納したラベルにジャンプ❷」することができます.
  • 変数xの値をジャンプテーブル(配列)のインデックスとして, ジャンプ先アドレスを取得し,間接ジャンプしています.
    • movl %eax, %eaxはレジスタ%raxの上位32ビットをゼロクリアしています.
    • notrackはIntel CET (control-flow enforcement technology)による拡張命令です. notrack付きの場合,間接ジャンプ先がendbr64ではなくても例外は発生しなくなります.
  • ジャンプテーブルを使うと,\(O(1)\)でcase文を選択できます. ただし,ジャンプテーブルが使えるのはcaseが指定する範囲がある程度, 密な場合に限ります(巨大な配列を使えば,疎な場合でも扱えますが…).

定数

整数定数

// const-int.c
int main ()
{
    return 999;
}
main:
    endbr64
    pushq   %rbp
    movq    %rsp, %rbp
    movl ❶ $999, %eax
    popq    %rbp
    ret
  • 整数定数 999 は❶即値 ($999)としてコード生成されています

文字定数

// const-char.c
int main ()
{
    return 'A';
}
main:
    endbr64
    pushq   %rbp
    movq    %rsp, %rbp
    movl ❶ $65, %eax
    popq    %rbp
    ret
  • 文字定数は❶即値 ($65, 65は文字AのASCIIコード)としてコード生成されています

文字列定数

// const-string.c
#include <stdio.h>
int main ()
{
    puts ("hello\n");
}
❶  .section  .rodata
❷ .LC0:
❸  .string "hello\n"

    .text
    .globl  main
    .type   main, @function
main:
    endbr64
    pushq   %rbp
    movq    %rsp, %rbp
    leaq ❹ .LC0(%rip), %rax
    movq    %rax, %rdi
    call    puts@PLT
    movl    $0, %eax
    popq    %rbp
    ret
  • 文字列定数 "hello\n"の実体は❶.rodataセクションに置かれます. ❸.string命令で"hello\n"のバイナリ値を配置し, その先頭アドレスを❷ラベル.LC0:と定義しています.
  • 式中の"hello\n"の値は「その文字列の先頭アドレス」ですので, ❹ .LC0(%rip)と参照しています(ここでは文字列の先頭アドレスが%raxに格納されます)

配列

以下で使うint a[3]の配列のメモリレイアウトは上の図のようになってます. このため,a[2]を参照する時はアドレスa+8のメモリを読み, a[i]を参照する時はアドレスa+i*4のメモリを読む必要があります.

// array3.c
int a [] = {111, 222, 333};
int main ()
{
    return a [2];
}
    .globl  a
    .data
    .align 8
    .type   a, @object
    .size   a, 12
❶ a:
❷  .long   111
❷  .long   222
❷  .long   333

    .text
    .globl  main
    .type   main, @function
main:
    endbr64
    pushq   %rbp
    movq    %rsp, %rbp
    movl ❸ 8+a(%rip), %eax
    popq    %rbp
    ret
  • 配列はメモリ上で連続する領域に配置されます. この場合は❷ .long命令で4バイトずつ,111, 222, 333の2進数を隙間なく配置しています.個人的には .long 111, 222, 333と1行で書いてくれる方が嬉しいのですが… (なお,配列とは異なり,構造体の場合はメンバー間や最後にパディング(隙間)が入ることがあります)
  • 配列の先頭アドレスを❶ラベルa:と定義しています.
  • a[2]の参照は❸ 8+a(%rip)という%rip相対のメモリ参照になっています. 指定したインデックスが定数(2)だったため,変位が 8+aになっています. (a[i]などとインデックスが変数の場合はアセンブラの足し算は使えません).

a[i]の場合のコンパイル結果は例えば以下のとおりです. a[i]の値を読むために,a+i*4の計算をする必要があり, 少し複雑になっています.

// array4.c
int a [] = {111, 222, 333};
int i = 1;
int main ()
{
    return a [i];
}
    .globl  a
    .data
    .align 8
    .type   a, @object
    .size   a, 12
a:
    .long   111
    .long   222
    .long   333
    .globl  i
    .align 4
    .type   i, @object
    .size   i, 4
i:
    .long   1

    .text
    .globl  main
    .type   main, @function
main:
    endbr64
    pushq   %rbp
    movq    %rsp, %rbp
    movl    i(%rip), %eax      # iの値を %eax に代入
    cltq                       # %eax を %rax に符号拡張
    leaq    0(,%rax,4), %rdx   # i*4 を %rdx に代入
    leaq    a(%rip), %rax      # aの絶対アドレスを %rax に代入
    movl    (%rdx,%rax), %eax  # アドレス a+i*4 の値(4バイト,これがa[i])を %eax に代入
    popq    %rbp
    ret
    .size   main, .-main

構造体

// struct5.c
struct foo {
    char x1;
    int x2;
};
struct foo f = {10, 20};
int main ()
{
    return f.x2;
}
    .globl  f
    .data
    .align 8
    .type   f, @object
    .size   f, 8
❶ f:
❷  .byte   10 # x1
❸  .zero   3  # 3バイトのパディング
❹  .long   20 # x2

    .text
    .globl  main
    .type   main, @function
main:
    endbr64
    pushq   %rbp
    movq    %rsp, %rbp
    movl ❺ 4+f(%rip), %eax
    popq    %rbp
    ret
  • 構造体fooがメモリ上に配置される際,アラインメント制約を満たすために パディングが入ることがあります.
  • ここでは,メンバー❷x1と❹x2の間に❸3バイトのパディングが入っています.
  • 構造体メンバーの参照 foo.x2は ❺4+f(%rip)という%rip相対のメモリ参照になっています.

共用体

// union2.c
#include <stdio.h>
union foo {
    char x1 [5];
    int  x2;
};
union foo f = {.x1[0] = 'a'};

int main ()
{
    f.x2 = 999;
    return f.x2;
}
    .globl  f
    .data
    .align 8
    .type   f, @object
❶  .size   f, 8
❷ f:
❸  .byte   97
❹  .zero   4
❺  .zero   3

    .text
    .globl  main
    .type   main, @function
main:
    endbr64
    pushq   %rbp
    movq    %rsp, %rbp
    movl    $999, ❻ f(%rip)
    movl ❼ f(%rip), %eax
    popq    %rbp
    ret
  • 共用体は以下を除き,構造体と一緒です
    • 同時に1つのメンバーにしか代入できない
    • 全てのメンバーはオフセット0でアクセスする
    • 共用体のサイズは最大サイズを持つメンバ+パディングのサイズになる
      • 上の例では最大サイズのメンバ char x1 [5]に3バイトのパディングがついて, 共用体 fのサイズは8バイトになってます
  • 上の例では指示付き初期化子(designated initializer)の記法 (union foo f = {.x1[0] = 'a'};)を使って,メンバx1を初期化しています. ❸'a'の値が.byteで配置され,残りの7バイトは❹❺0で初期化されています.
  • 共用体fへの代入や参照はメモリ参照❻❼f(%rip)でアクセスされています.

変数

初期化済みの静的変数

// var-init-static.c
int x = 999;
int main ()
{
    return x;
}
    .globl  x
❶  .data
    .align 4
    .type   x, @object
    .size   x, 4
❷ x:
❸  .long   999

    .text
    .globl  main
    .type   main, @function
main:
    endbr64
    pushq   %rbp
    movq    %rsp, %rbp
    movl ❹ x(%rip), %eax
    popq    %rbp
    ret
  • 初期化済みの静的変数の実体は❶.dataセクションに配置されます. 初期値 (999)を(この場合はint型なので).long命令で 999のバイナリ値を配置し,その先頭アドレスをラベルx:と定義しています.
  • 変数xの参照は❹x(%rip)という%rip相対のメモリ参照です.

未初期化の静的変数

// var-uninit-static.c
int x;
int main ()
{
    x = 999;
    return x;
}
    .globl  x
❶  .bss
    .align 4
    .type   x, @object
    .size   x, 4
❷ x:
❸  .zero   4

    .text
    .globl  main
    .type   main, @function
main:
    endbr64
    pushq   %rbp
    movq    %rsp, %rbp
    movl    $999, ❹ x(%rip)
    movl    ❺ x(%rip), %eax
    popq    %rbp
    ret
  • 未初期化の静的変数の実体は❶ .bssセクションに配置されます. .bssセクション中の変数は❸ゼロで初期化されます. 初期化した4バイトの先頭アドレスをラベル❷ x:と定義しています.
    • ただし.bssセクションが実際にゼロで初期化されるのは実行直前です
  • 変数xの参照は,メモリ参照❹❺ x(%rip)でアクセスされています.

自動変数 (static無しの局所変数)

// var-auto.c
int main ()
{
    int x;
    x = 999;
    return x;
}
    .text
    .globl  main
    .type   main, @function
main:
    endbr64
    pushq   %rbp
    movq    %rsp, %rbp
    movl    $999, ❶ -4(%rbp)
    movl ❷ -4(%rbp), %eax
    popq    %rbp
    ret
  • 自動変数は実行時にスタック上にその実体が配置されます. 上の例では変数xは❶❷-4(%rbp)から始まる4バイトに割り当てられ, アクセスされています. (この変数xレッドゾーンに配置されています)

実引数

// arg.c
void foo (long a1, long a2, long a3, long a4, long a5, long a6, long a7);
int main ()
{
    foo (10, 20, 30, 40, 50, 60, 70);
}
    .text
    .globl  main
    .type   main, @function
main:
    endbr64
    pushq   %rbp
    movq    %rsp, %rbp
❹  subq    $8, %rsp    # パディング
❷  pushq   $70         # 第7引数
❶  movl    $60, %r9d   # 第6引数
❶  movl    $50, %r8d   # 第5引数
❶  movl    $40, %ecx   # 第4引数
❶  movl    $30, %edx   # 第3引数
❶  movl    $20, %esi   # 第2引数
❶  movl    $10, %edi   # 第1引数
❸  call    foo@PLT
❺  addq    $16, %rsp   # 第7引数とパディングを捨てる
    movl    $0, %eax
    leave
    ret
  • 第6引数までは❶レジスタ渡しになります. 第7引数以降は❷スタックに積んでから関数を呼び出します.
  • System V ABI (AMD64) により ❸call命令実行時には%rspの値は16の倍数である必要があります. そのため,❹で8バイトのパディングをスタックに置いています.
  • 関数からリターン後は❷でスタックに積んだ引数と❹パディングを❸スタック上から取り除きます.

仮引数

// parameter.c
#include <stdio.h>
void
foo (long a1, long a2, long a3, long a4, long a5, long a6, long a7)
{
    printf ("%ld\n", a1 + a7);
}
    .text
    .section .rodata
.LC0:
    .string "%ld\n"
    .text
    .globl  foo
    .type   foo, @function
foo:
    endbr64
    pushq   %rbp
    movq    %rsp, %rbp
    subq    $48, %rsp
    movq ❶ %rdi, -8(%rbp)   # 第1引数
    movq ❶ %rsi, -16(%rbp)  # 第2引数
    movq ❶ %rdx, -24(%rbp)  # 第3引数
    movq ❶ %rcx, -32(%rbp)  # 第4引数
    movq ❶ %r8, -40(%rbp)   # 第5引数
    movq ❶ %r9, -48(%rbp)   # 第6引数
    movq    -8(%rbp), %rdx  
    movq ❷ 16(%rbp), %rax   # 第7引数
    addq    %rdx, %rax
    movq    %rax, %rsi
    leaq    .LC0(%rip), %rax
    movq    %rax, %rdi
    movl    $0, %eax
    call    printf@PLT
    nop
    leave
    ret
  • GCCはレジスタで受け取った第1〜第6引数をスタック上に❶置いています. 一方,第7引数はスタック渡しで,その場所は❻16(%rbp)でした.

式 (expression)

単項演算子 (unary operator)

// unary.c
int x = 111;
int main ()
{
    return -x;
}
    .text
    .globl  x
    .data
    .align 4
    .type   x, @object
    .size   x, 4
x:
    .long   111
    .text
    .globl  main
    .type   main, @function
main:
    endbr64
    pushq   %rbp
    movq    %rsp, %rbp
    movl    x(%rip), %eax
❶  negl    %eax
    popq    %rbp
    ret
  • 単項演算は対応する命令,ここでは❶ neglを使うだけです.

二項演算子(単純な加算)

// binop-add.c
int x = 111;
int main ()
{
    return x + 89;
}
    .globl  x
    .data
    .align 4
    .type   x, @object
    .size   x, 4
x:
    .long   111

    .text
    .globl  main
    .type   main, @function
main:
    endbr64
    pushq   %rbp
    movq    %rsp, %rbp
    movl    x(%rip), %eax
❶  addl    $89, %eax
    popq    %rbp
        ret
  • 基本的に二項演算子も対応する命令 (ここでは❷addl)を使うだけです.

二項演算子(割り算)

// binop-div.c
int x = 111, y = 9;
int main ()
{
    return x / y;
}
    .globl  x
    .data
    .align 4
    .type   x, @object
    .size   x, 4
x:
    .long   111

    .globl  y
    .align 4
    .type   y, @object
    .size   y, 4
y:
    .long   9

    .text
    .globl  main
    .type   main, @function
main:
    endbr64
    pushq   %rbp
    movq    %rsp, %rbp
    movl    x(%rip), %eax
    movl    y(%rip), %ecx
❷  cltd
❶  idivl   %ecx
    popq    %rbp
    ret
  • 割り算はちょっと注意が必要です.
  • 例えば,32ビット符号ありの割り算を❶idivl命令で行う場合, %edx:%eaxを第1オペランドで割った商が%eax に入ります.
  • このため,idivlを使う前に❷cltd命令等を使って, %eaxを符号拡張した値を%edxに設定しておく必要があります (%edxの値の設定を忘れるて%idivlを実行すると,割り算の結果がおかしくなります)

二項演算(ポインタ演算)

  • 復習: C言語のポインタ演算 (オペランドがポインタの場合の演算)は普通の加減算と意味が異なります.ポインタが指す先のサイズを使って計算する必要があります.
演算意味 (int i, *p, *qの場合)
p + ip + (i * sizeof (*p))
i + qp + (i * sizeof (*p))
p + qコンパイルエラー
p - ip - (i * sizeof (*p))
i - qコンパイルエラー
p - q(p - q) / sizeof (*p)
// pointer-arith.c
#include <stdio.h>
int a [] = {0, 10, 20, 30};
int main ()
{
    printf ("%p, %p\n", a, &a[0]);     // 同じ
    printf ("%p, %p, %p\n", &a[2], a + 2, 2 + a); // 同じ
    printf ("%p, %p\n", a, &a[2] - 2); // 同じ
    printf ("%ld\n", &a[2] - &a[0]);
    // printf ("%p\n", &a[2] + &a[0]);    // コンパイルエラー
    // printf ("%p\n", 2 - &a[2]);        // コンパイルエラー
}
$ gcc -g -no-pie pointer-arith.c
$ ./a.out
0x404030, 0x404030
0x404038, 0x404038, 0x404038
0x404030, 0x404030
2
  • 復習: 式中で配列名(a)はその配列の先頭要素のアドレス(&a[0])を意味します.
  • 復習: 式中で配列要素へのアクセス a[i]は, *(a+i)*(i+a)と書いても同じ意味です.
  • 例えば,上の例でa+2は,配列の要素がint型で,sizeof(int)が4なので, \(0x404030 + 2\times 4 = 0x404038\) という計算になります. このため,a+2&a[2]と同じ値になります.
// pointer-arith2.c
int a [] = {0, 10, 20, 30};
int* foo ()
{
    return a + 2; // ❶
}
    .globl  a
    .data
    .align 16
    .type   a, @object
    .size   a, 16
a:
    .long   0
    .long   10
    .long   20
    .long   30

    .text
    .globl  foo
    .type   foo, @function
foo:
    endbr64
    pushq   %rbp
    movq    %rsp, %rbp
    leaq ❷ 8+a(%rip), %rax
    popq    %rbp
    ret
  • 上の例でCコード中の❶a+2は,アセンブリコード中では❷8+aになっています. 配列要素のサイズ4をかけ算して,\(a + 2\times 4\)という計算をするからです.

アドレス演算子&と逆参照演算子*

// op-addr.c
int x = 111;
int *p;
int main ()
{
    p = &x;
    return *p;
}
    .globl  x
    .data
    .align 4
    .type   x, @object
    .size   x, 4
x:
    .long   111

    .globl  p
    .bss
    .align 8
    .type   p, @object
    .size   p, 8
p:
    .zero   8

    .text
    .globl  main
    .type   main, @function
main:
    endbr64
    pushq   %rbp
    movq    %rsp, %rbp
❶  leaq    x(%rip), %rax
    movq    %rax, p(%rip)
❷  movq    p(%rip), %rax
❸  movl    (%rax), %eax
    popq    %rbp
    ret
  • アドレス演算子&xには変数xのアドレスを計算すれば良いので,leaq命令を使います. 具体的には❶leaq x(%rip), %raxで,xの絶対アドレスを%raxに格納しています.
  • 逆参照演算子*pにはメモリ参照を使います. まず❷movq p(%rip), %raxで変数pの中身を%raxに格納し, ❸movl (%rax), %eaxとすれば,メモリ参照(%rax)pが指す先の値を得られます.

比較演算子

// pred.c
int x = 111;
int main ()
{
    return x > 100;
}
    .globl  x
    .data
    .align 4
    .type   x, @object
    .size   x, 4
x:
    .long   111

    .text
    .globl  main
    .type   main, @function
main:
    endbr64
    pushq   %rbp
    movq    %rsp, %rbp
    movl    x(%rip), %eax
❶  cmpl    $100, %eax
❷  setg    %al
❸  movzbl  %al, %eax
    popq    %rbp
    ret
  • >などの比較演算子にはset␣命令を使います.
  • 例えば,x > 100の場合,
    • cmpl命令で比較を行い,
    • setgを使って「より大きい」という条件が成り立っているかどうかを%alに格納し
    • movzblを使って,必要なサイズ(ここでは4バイト)にゼロ拡張しています

論理ANDと論理OR,「左から右への評価」

// land.c
int x = 111;
int main ()
{
    return 50 < x && x < 200;
}
    .globl  x
    .data
    .align 4
    .type   x, @object
    .size   x, 4
x:
    .long   111

    .text
    .globl  main
    .type   main, @function
main:
    endbr64
    pushq   %rbp
    movq    %rsp, %rbp
    movl    x(%rip), %eax
    cmpl    $50, %eax
❶  jle     .L2             # if x <= 50 goto .L2
    movl    x(%rip), %eax
    cmpl    $199, %eax
❷  jg      .L2             # if x > 199 goto .L2
    movl    $1, %eax        # 結果に1をセット
    jmp     .L4
.L2:
    movl    $0, %eax        # 結果に0をセット
.L4:
    popq    %rbp
    ret
  • 多くの二項演算子では「両方のオペランドを計算してから,その二項演算子 (例えば加算)を行う」というコードを生成すればOKです.

  • しかし,論理AND (&&) や論理OR (||)ではそのやり方ではNGです. 論理ANDと論理ORは左から右への評価 (left-to-right evaluation)を 行う必要があるからです.

    • 論理ANDでは,まず左オペランドを計算し,その結果が真の時だけ, 右オペランドを計算します.(左オペランドが偽ならば,右オペランドを計算せず,全体の結果を偽とする)

    • 論理OR では,まず左オペランドを計算し,その結果が偽の時だけ, 右オペランドを計算します.(左オペランドが真ならば,右オペランドを計算せず,全体の結果を真とする)

    • 要するに左オペランドだけで結果が決まる時は,右オペランドを計算してはいけないのです. このおかげで,以下のようなコードが記述可能になります. (右オペランド *p > 100が評価されるのはpNULLではない場合のみになります)

      int *p;
      if (p != NULL && *p > 100) { ...
      
  • このため,上のコード例でも❶左オペランドが真の場合だけ, ❷右オペランドが計算されています.

代入

// assign.c
int x = 111;
int main ()
{
    return x = 100;
}
    .globl  x
    .data
    .align 4
    .type   x, @object
    .size   x, 4
x:
    .long   111

    .text
    .globl  main
    .type   main, @function
main:
    endbr64
    pushq   %rbp
    movq    %rsp, %rbp
❶  movl    $100, x(%rip)
❷  movl    x(%rip), %eax
    popq    %rbp
    ret
  • 代入式は単純に❶mov命令を使えばOKです.
  • 代入式には(代入するという副作用以外に)「代入した値そのものを その代入式の評価結果とする」という役割もあります.  そのため❷で,returnで返す値を%eaxに格納しています.

文 (statement)

式文

// exp-stmt.c
int x = 111;
int main ()
{
    x = 222;
    333;
}
    .globl  x
    .data
    .align 4
    .type   x, @object
    .size   x, 4
x:
    .long   111

    .text
    .globl  main
    .type   main, @function
main:
    endbr64
    pushq   %rbp
    movq    %rsp, %rbp
❶  movl    $222, x(%rip)
    movl    $0, %eax
    popq    %rbp
    ret

  • 復習: 式にセミコロン;を付けたものが式文です.
  • x = 222;という式文(代入文)は,代入式の ❶ mov命令をそのまま出力すればOKです.
    • 式文中の式の計算にスタックを使った場合は,スタック上の値を捨てる必要があることがあります
  • 333;は文法的に正しい式文なのですが,意味がないのでGCCはこの式文を無視しました

ブロック文

// block-stmt.c
int x = 111;
int main ()
{
    {
        x = 222;
        x = 333;
        x = 444;
    }
}
    .globl  x
    .data
    .align 4
    .type   x, @object
    .size   x, 4
x:
    .long   111

    .text
    .globl  main
    .type   main, @function
main:
    endbr64
    pushq   %rbp
    movq    %rsp, %rbp
❶  movl    $222, x(%rip)  # 文1
❷  movl    $333, x(%rip)  # 文2
❸  movl    $444, x(%rip)  # 文3
    movl    $0, %eax
    popq    %rbp
    ret
  • 復習: ブロック文 (あるいは複合文 (compound statement))は, 複数の文が並んだ文です.
  • ブロック文のコード出力は簡単で,文の並びの順番に,それぞれの アセンブリコード❶❷❸を出力するだけです.

goto文とラベル文

// goto.c
int x = 111;
int main ()
{
foo:
    x = 222;
    goto foo;
}
    .globl  x
    .data
    .align 4
    .type   x, @object
    .size   x, 4
x:
    .long   111

    .text
    .globl  main
    .type   main, @function
main:
    endbr64
    pushq   %rbp
    movq    %rsp, %rbp
.L2:
    movl    $222, x(%rip)
    jmp     .L2
  • C言語のラベルfooはアセンブリコードでは.L2になっていますが, (名前の重複に気をつければ)ラベルとして出力すればOKです
  • goto文もそのまま無条件ジャンプjmpにすればOKです

return文 (intを返す)

// return.c
int x = 111;
int main ()
{
    return x;
}
    .globl  x
    .data
    .align 4
    .type   x, @object
    .size   x, 4
x:
    .long   111

    .text
    .globl  main
    .type   main, @function
main:
    endbr64
    pushq   %rbp
    movq    %rsp, %rbp
❶  movl    x(%rip), %eax
❷  popq    %rbp
❸  ret
  • intなどの整数型を返すreturn文は簡単です. ❶返す値を%raxレジスタに格納し,❷スタックフレームの後始末をしてから, ❸ret命令で,リターンアドレスに制御を移せばOKです.

return文 (構造体を返す)

// return2.c
struct foo {
    char x1;
    long x2;
};
struct foo f = {'A', 0x1122334455667788};
struct foo func ()
{
    return f;
}
    .globl  f
    .data
    .align 16
    .type   f, @object
    .size   f, 16
f:
    .byte   65
    .zero   7
    .quad   1234605616436508552

    .text
    .p2align 4
    .globl  func
    .type   func, @function
func:
    endbr64
❶  movq    8+f(%rip), %rdx
❷  movq    f(%rip), %rax
    ret
  • 復習: C言語では,配列や関数を,関数の引数に渡したり,関数から返すことはできません (配列へのポインタや,関数へのポインタなら可能ですが). 一方,構造体や共用体は,関数の引数に渡したり,関数から返すことができます.

  • 8バイトより大きい構造体や共用体を関数引数や返り値にする場合, 通常のレジスタ以外のレジスタやスタックを使ってやりとりをします. 具体的な方法は System V ABI (AMD64) が定めています.

  • 上の例では%rax%rdxを使って,構造体fを関数からリターンしています. (コードが簡単になるように,ここではgcc -O2 -Sの出力を載せています)

関数

関数定義

// add5.c
int add5 (int n)
{
    return n + 5;
}
    .text
    .globl  add5
    .type   add5, @function
❷ add5:                      # 関数名のラベル定義
    endbr64
    pushq   %rbp              # スタックフレーム作成
    movq    %rsp, %rbp        # スタックフレーム作成
❶  movl    %edi, -4(%rbp)    # 関数本体
❶  movl    -4(%rbp), %eax    # 関数本体
❶  addl    $5, %eax          # 関数本体
    popq    %rbp              # スタックフレーム破棄
    ret                       # リターン
    .size   add5, .-add5
  • 関数を定義するには関数本体のアセンブリコード❶の前に関数プロローグ, 後に関数エピローグのコードを出力します. また,関数の先頭で❷関数名のラベル(add5;)を定義します.
  • 関数プロローグはスタックフレームの作成や,callee-saveレジスタの退避などを行います.
  • 関数エピローグはcallee-saveレジスタの回復や,スタックフレームの破棄などを行い,retでリターンします.

関数コール

// main.c
#include <stdio.h>
int add5 (int n);
int main ()
{
    printf ("%d\n", add5 (10));
}
    .section        .rodata
.LC0:
    .string "%d\n"

    .text
    .globl  main
    .type   main, @function
main:
    endbr64
    pushq   %rbp
    movq    %rsp, %rbp
❶  movl    $10, %edi
❷  call    add5@PLT
❸  movl    %eax, %esi
    leaq    .LC0(%rip), %rax
    movq    %rax, %rdi
    movl    $0, %eax
    call    printf@PLT
    movl    $0, %eax
    popq    %rbp
    ret
  • 関数コールをするには,call命令の前に引数をレジスタやスタック上に格納してから, call命令を実行します.その後,%raxに入っている返り値を引き取ります.
  • 上の例では,
    • ❶で 10を第1引数として%ediレジスタにセットしてから,
    • ❷で callを実行して,制御をadd5関数に移します
    • ❸でadd5の返り値 (%eax)を引き取っています
  • デフォルトの動的リンクを前提としたコンパイルなので, 関数名がadd5ではなく❷add5@PLTとなっています (PLTについてはこちらを参照)

関数コール(関数ポインタ)

    .section        .rodata
.LC0:
    .string "%d\n"

    .text
    .globl  main
    .type   main, @function
main:
    endbr64
    pushq   %rbp
    movq    %rsp, %rbp
    subq    $16, %rsp
❶  movq    add5@GOTPCREL(%rip), %rax # GOT領域のadd5のエントリ(中身はadd5の絶対アドレス)
    movq    %rax, -8(%rbp)
    movq    -8(%rbp), %rax
❷  movl    $10, %edi
❸  call    *%rax
    movl    %eax, %esi
    leaq    .LC0(%rip), %rax
    movq    %rax, %rdi
    movl    $0, %eax
    call    printf@PLT
    movl    $0, %eax
    leave
    ret
  • 上のコード例ではadd5を変数fpに代入して, fp中の関数ポインタを使って,add5を呼び出しています.
  • これをアセンブリコードにすると❶ movq add5@GOTPCREL(%rip), %raxになります. add5@GOTPCRELはGOT領域のadd5のエントリなので, メモリ参照add5@GOTPCREL(%rip)で,add5の絶対アドレスを取得できます (GOT領域についてはこちらを参照)
  • ❷で第1引数(10)を%ediに渡して
  • call *%rax%rax中の関数ポインタを間接コールしています

ライブラリ関数コール

// main.c
#include <stdio.h>
int add5 (int n);
int main ()
{
    printf ("%d\n", add5 (10));
}
    .section        .rodata
.LC0:
    .string "%d\n"

    .text
    .globl  main
    .type   main, @function
main:
    endbr64
    pushq   %rbp
    movq    %rsp, %rbp
    movl    $10, %edi
    call    add5@PLT
❷  movl    %eax, %esi
❶  leaq    .LC0(%rip), %rax
❶  movq    %rax, %rdi
❸  movl    $0, %eax
❹  call    printf@PLT
    movl    $0, %eax
    popq    %rbp
    ret
  • ここではライブラリ関数代表として,printfを呼び出すコードを見てみます.
    • ❶でprintfの第1引数である文字列"%d\n"の先頭アドレス (.LC0(%rip))を第1引数のレジスタ%rdiに格納します
    • ❷はadd5が返した値を,第2引数のレジスタ%esiに格納します
    • ❸で%eax0を格納しています.
      • %alprintfなどの可変長引数を持つ関数の隠し引数です
      • %alにはベクタレジスタを使って渡す浮動小数点数の引数の数をセットします
      • これはSystem V ABI (AMD64)が定めています
    • ❹でprintfをコールしています

システムコール

// syscall-exit.c
#include <unistd.h>
int main ()
{
    _exit (0);
}
    .text
    .globl  main
    .type   main, @function
main:
    endbr64
    pushq   %rbp
    movq    %rsp, %rbp
    movl    $0, %edi
❶  call    _exit@PLT
  • exitはライブラリ関数なので,ここではシステムコールである_exitを呼び出しています.
  • が,_exitもただのラッパ関数で, _exitの中で実際のシステムコールを呼び出します. このため,❶を見れば分かる通り,_exitの呼び出しは ライブラリ関数の呼び出し方と同じになります.
$ objdump -d /lib/x86_64-linux-gnu/libc.so.6 | less
(中略)
00000000000eac70 <_exit>:
   eac70:       f3 0f 1e fa             endbr64 
   eac74:       4c 8b 05 95 e1 12 00    mov    0x12e195(%rip),%r8        # 218e10 <_DYNAMIC+0x250>
   eac7b:       be e7 00 00 00          mov    $0xe7,%esi
   eac80:       ba 3c 00 00 00          mov    $0x3c,%edx
   eac85:       eb 16                   jmp    eac9d <_exit+0x2d>
   eac87:       66 0f 1f 84 00 00 00    nopw   0x0(%rax,%rax,1)
   eac8e:       00 00 
   eac90:       89 d0                   mov    %edx,%eax
   eac92:       0f 05                ❶ syscall 
   eac94:       48 3d 00 f0 ff ff       cmp    $0xfffffffffffff000,%rax
   eac9a:       77 1c                   ja     eacb8 <_exit+0x48>
   eac9c:       f4                      hlt    
   eac9d:       89 f0                   mov    %esi,%eax
   eac9f:       0f 05                ❶ syscall 
   eaca1:       48 3d 00 f0 ff ff       cmp    $0xfffffffffffff000,%rax
   eaca7:       76 e7                   jbe    eac90 <_exit+0x20>
   eaca9:       f7 d8                   neg    %eax
   eacab:       64 41 89 00             mov    %eax,%fs:(%r8)
   eacaf:       eb df                   jmp    eac90 <_exit+0x20>
   eacb1:       0f 1f 80 00 00 00 00    nopl   0x0(%rax)
   eacb8:       f7 d8                   neg    %eax
   eacba:       64 41 89 00             mov    %eax,%fs:(%r8)
   eacbe:       eb dc                   jmp    eac9c <_exit+0x2c>
  • _exit関数の中身を逆アセンブルしてみると, ❶syscall命令を使ってシステムコールを呼び出している部分を見つけられます. (お作法を正しく守れば,_exitを使わず,直接,syscallでシステムコールを呼び出すこともできます)

memcpyと最適化

  • ライブラリ関数memcpyの呼び出しは,最適化の有無により例えば次の3パターンになります:

    • call memcpy (通常の関数コール)
    • movdqa src(%rip), %xmm0; movaps %xmm0, dst(%rip) (SSE/AVX命令)
    • rep movsq (ストリング命令)
  • 最適化無しの場合

// memcpy.c
#include <stdio.h>
#include <string.h>
char src [4096], dst [4096];
int main ()
{
    memcpy (dst, src, 64);
}
$ gcc -S memcpy.c
$ cat memcpy.s
main:
    pushq   %rbp
    movq    %rsp, %rbp
    movl    $64, %edx
    leaq    src(%rip), %rax
    movq    %rax, %rsi
    leaq    dst(%rip), %rax
    movq    %rax, %rdi
    call    memcpy@PLT    # 普通の call命令でライブラリ関数memcpyを呼ぶ
  • 最適化した場合

    $ gcc -S -O2 memcpy.c
    $ cat memcpy.s
    main:
        movdqa  src(%rip), %xmm0     # 16バイト長の%xmm0レジスタに16バイトコピー
        movdqa  16+src(%rip), %xmm1
        xorl    %eax, %eax
        movdqa  32+src(%rip), %xmm2
        movdqa  48+src(%rip), %xmm3
        movaps  %xmm0, dst(%rip)     # %xmm0レジスタからメモリに16バイトコピー
        movaps  %xmm1, 16+dst(%rip)
        movaps  %xmm2, 32+dst(%rip)
        movaps  %xmm3, 48+dst(%rip)
    
    • memcpy.c-O2でコンパイルすると,movdqamovaps命令を使うコードを出力しました.アラインメントなどの条件が合うと,こうなります.
    • %xmm0%xmm3はSSE拡張で導入された16バイト長のレジスタです.
  • サイズを増やして最適化した場合

// memcpy2.c
#include <stdio.h>
#include <string.h>
char src [4096], dst [4096];
int main ()
{
    memcpy (dst, src, 1024);
}
$ gcc -S -O2 memcpy2.c
$ cat memcpy.s
main:
    leaq    dst(%rip), %rax
    leaq    src(%rip), %rsi
    movl    $128, %ecx
    movq    %rax, %rdi
    xorl    %eax, %eax
    rep movsq
  • サイズを増やすと,rep movsqというストリング命令を出力しました.
  • rep movsqは,%ecx回の繰り返しを行います. 各繰り返しでは,メモリ(%rsi)の値を(%rdi)に8バイトコピーし, %rsi%rdiの値を8増やします. (DFフラグが1の場合は8減らしますが,ABIが「関数の出入り口で(DF=1にしていたら)DF=0に戻せ」と定めているので,ここではDF=0です)
%rsiと%rdiの名前の由来

%rsiは source index,%rdiは destination index が名前の由来です.

デバッガgdbの使い方

デバッガの概要

なぜデバッガ?

  • gdbなどのデバッガの使用をお薦めする理由は「プログラムのデバッグ」をとても楽にしてくれるからです!!
  • デバッグが難しいのは実行中のプログラムの中身(実行状態)が外からでは見えにくいからです. printfを埋め込むことでも変数の値や実行パスを調べられますが, デバッガを使うともっと効率的に調べることができます.
  • デバッガは「簡単に習得できて,効果も高いお得な開発ツール」です. 慣れることが大事です.
  • デバッガはつまみ食いOKです. 最初は「自分が使いたい機能,使える機能」だけを使えばいいのです. デバッガを使うために「デバッガの全て」を学ぶ必要はありません.

デバッガとは

デバッガは主に以下の機能を組み合わせて,プログラムの実行状態を調べることで, バグの原因を探します.

  • ① プログラム実行の一時停止: 「実行を止めたい場所(ブレークポイント)」や止めたい条件を設定できます. ブレークポイントには関数名や行番号やアドレスなどを指定できます.
  • ステップ実行: ①でプログラムの実行を一時停止した後, ステップ実行の機能を使って,ちょっとずつ実行を進めます.
  • ③ 実行状態の表示: 変数の値,現在の行番号,スタックトレース(バックトレース)などを表示できます.
  • ④ 実行状態の変更: 変数に別の値を代入したり,関数を呼び出したりして, 「ここでこう実行したら」を試せます.

gdbとは

  • Linux上で使える代表的で高性能なデバッガです.
  • C/C++/アセンブリ言語/Objective-C/Rustなど,多くの言語をサポートしています.
  • Linux/x86-64を含む,多くのOSやプロセッサに対応しています.
  • オープンソースで無料で使えます (GNU GPLライセンス).
  • 一次情報: GDB: The GNU Project Debugger

gdbの実行例 (C言語編)

起動 run と終了 quit

// hello.c
#include <stdio.h>
int main ()
{
    printf ("hello\n");
}
$ gcc ❶ -g hello.c
$ ./a.out
hello
$ ❷ gdb ./a.out
❸(gdb) ❹ run
hello
(gdb) ❺ quit
A debugging session is active.
	Inferior 1 [process 20186] will be killed.
Quit anyway? (y or n)  ❻ y
$
  • gdbでデバッグする前に,gccのコンパイルに❶-gオプションを付けます. -ga.outデバッグ情報を付加します. デバッグ情報がなくてもデバッグは可能ですが, ファイル名や行番号などの情報が表示されなくなり不便です.
gcc -g -Og オプションがベスト

gdbのマニュアルに以下の記述があります.

  • -O2などの最適化オプションを付けてコンパイルしたプログラムでもgdbで デバッグできるが,行番号がずれたりする.なので可能なら 最適化オプションを付けない方が良い.
  • gdbでデバッグするベストな最適化オプションは-Ogであり, -Og-O0よりも良い.

ですので,デバッグ時には gcc -g -Ogオプションがベストなようです.

Inferior はデバッグ対象のプログラムのこと

上の実行例中のInferior 1 [process 20186] will be killed.は 「gdbの終了に伴って,デバッグ対象のプログラムも実行終了させます」という ことを意味しています. inferiorは「下位の」「劣った」という意味ですね (劣等感は英語で inferiority complex). gdbのマニュアルでもデバッグ対象のプログラムを一貫してinferiorと呼んでいます. なお他の文献ではデバッグ対象のプログラムのことをデバッギ (debuggee)と呼ぶことがあります.

  • gdb ./a.out と,引数にデバッグ対象のバイナリ(ここでは./a.out)を指定してgdbを起動します.
gdb起動メッセージの抑制

デフォルトでgdbを起動すると以下のような長い起動メッセージが出ます.

$ gdb ./a.out 
GNU gdb (Ubuntu 12.1-0ubuntu1~22.04) 12.1
Copyright (C) 2022 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Type "show copying" and "show warranty" for details.
This GDB was configured as "x86_64-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<https://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
    <http://www.gnu.org/software/gdb/documentation/>.

For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from ./a.out...
(gdb) 

これを抑制するには,gdb -qオプションを付けるか, ~/.gdbearlyinitファイルに以下を指定します.

set startup-quietly on
  • (gdb)gdbのプロンプトです.gdbのコマンドが入力可能なことを示します.
  • rungdb上でプログラムの実行を開始します. ここではブレークポイントを指定していないため,そのままhelloを出力して プログラムは終了しました.
  • quitgdbを終了させます. (ここではすでにデバッグ対象のプログラムの実行は終了していますが) デバッグ対象のプログラムが終了しておらず, 「本当に終了して良いか?」と聞かれたら,❻ yと答えて終了させます.
コマンドの省略名

gdbのコマンドは(区別できる範囲で)短く省略できます. 例えば,runrquitq,それぞれ1文字でコマンドを指定できます. 慣れてきたらコマンドの省略名を使いましょう.

コマンドライン引数argvを指定して実行

// argv.c
#include <stdio.h>
int main (int argc, char *argv[])
{
    for (int i = 0; i < argc; i++) {
        printf ("argv[%d]=%s\n", i, argv [i]);
    }
}
$ gcc -g argv.c
$ gdb ./a.out
Reading symbols from ./a.out...
(gdb) ❶ run a b c d
argv[0]=/mnt/hgfs/gondow/project/linux-x86-64-programming/src/asm/a.out
argv[1]=a
argv[2]=b
argv[3]=c
argv[4]=d
[Inferior 1 (process 20303) exited normally]
(gdb) 
  • コマンドライン引数を与えてデバッグしたい場合は, runコマンドに続けて引数を与えます(ここではa b c d).

標準入出力を切り替えて実行

// cat.c
#include <stdio.h>
int main ()
{
    int c;
    while ((c = getchar ()) != EOF) {
        putchar (c);
    }
}
$ gcc -g cat.c
$ cat foo.txt
hello
byebye
$ gdb ./a.out
(gdb) run ❶ < foo.txt ❷ > out.txt
(gdb) quit
$ cat out.txt
hello
byebye
  • 標準入出力をリダイレクトして(切り替えて)実行したい場合は, 通常のシェルのときと同様にrunコマンドの後で, ❶ < や❷ >を使って行います.

segmentation fault (あるいは bus error)の原因を探る

// segv.c
#include <stdio.h>
int main ()
{
    int *p = (int *)0xDEADBEEF; // アクセスNGそうなアドレスを代入
    printf ("%d\n", *p); 
}
$ gcc -g segv.c
$ ./a.out
❶ Segmentation fault (core dumped)
$ gdb ./a.out
(gdb) r
Program received signal SIGSEGV, ❷ Segmentation fault.
0x0000555555555162 in ❸ main () at ❹ segv.c:6
6	 ❺ printf ("%d\n", *p); 
(gdb) ❻ print/x p
$1 = ❼ 0xdeadbeef
(gdb) ❽ print/x *p
❾ Cannot access memory at address 0xdeadbeef
(gdb) quit
  • segv.cをコンパイルして実行すると❶ segmentation fault が起きました. segumentation fault や bus error は正しくないポインタを使用して メモリにアクセスすると発生します.
  • gdb上でa.outを実行すると,gdb上でも ❷ segmentation fault が起きました. 発生場所は ❹ ファイルsegv.c6行目,❸ main関数内と表示されています. また,6行目のソースコード ❺ printf ("%d\n", *p);も表示されています.
  • 変数pが怪しいので,❻ print/x pコマンドで変数pの値を表示させます. /xは「16進数で表示」を指示するオプションです. 怪しそうな0xDEADBEEFという値が表示されました. (printコマンドはpと省略可能です).
怪しいアドレスとは

まず,8の倍数ではないアドレスは怪しいです(正しいアドレスのこともあります). 特に奇数のアドレスは怪しいです(正しいこともありますが). アラインメント制約を守るため, 多くのデータが4の倍数や8の倍数のアドレスに配置されるからです.

また慣れてくると,例えば「0x7ffde9a98000はスタックのアドレスっぽい」と 感じるようになります. 「これ,どこのメモリだろう」と思ったら メモリマップgdb上でinfo proc mapの結果を見て調べるのが良いです.

  • 念のため,printコマンドで*pを表示させると (❽ print/x *p), この番地にはアクセスできないことが確認できました (❾ Cannot access memory at address 0xdeadbeef).

変数の値を表示 (print)

// calcx.c
int main ()
{
    int x = 10;
    x += 3;
    x += 4;
    return x;
}
$ gcc -g calcx.c
$ gdb ./a.out
(gdb) ❶ b main
Breakpoint 1 at 0x1131: file calcx.c, line 3.
(gdb) ❷ r
❸ Breakpoint 1, main () at calcx.c:3
3	 ❹ int x = 10;
(gdb) ❺ s
❻ 4	    x += 3;
(gdb) ❼ p x
❽ $1 = 10
(gdb) s
5	    x += 4;
(gdb) p x
$2 = 13
(gdb) q
  • 使った短縮コマンド: b(break), r(run), s(step), p(print), q(quit)
  • b mainで,main関数にブレークポイントを設定し, ❷ rで実行を開始すると,❸main関数で実行が一時停止しました. ❹ int x = 10;`は次に実行する文です(まだ実行していません).
breakで設定できる場所

ここではb mainと関数名を指定しました. 他にも以下のように行番号やファイル名も使えます.

場所の指定説明
b 10(今実行中のファイルの)10行目
b +5今の実行地点から5行後
b -5今の実行地点から5行前
b main(今実行中のファイルの)関数main
b main.c:mainファイルmain.c中のmain関数
b main.c:10ファイルmain.cの10行目
  • sで,1行だけ実行を進めます. 4行目 (❻ 4 x += 3;)を実行する手前で実行が止まります.
  • ここで ❼ p xとして xの値を表示させます. ❽ $1 = 10と表示され,xの値は10と分かりました. ($1gdb中で使える変数ですが,ここでは使っていません).

変数の値を自動表示 (display)

// calcx.c
int main ()
{
    int x = 10;
    x += 3;
    x += 4;
    return x;
}
$ gcc -g calcx.c
$ gdb ./a.out
(gdb) b main
Breakpoint 1 at 0x1131: file calcx.c, line 4.
(gdb) r
Breakpoint 1, main () at calcx.c:4
4	    int x = 10;
(gdb) ❶ disp x
❷ 1: x = 21845
(gdb) s
5	    x += 3;
❸ 1: x = 10
(gdb) s
6	    x += 4;
❹ 1: x = 13
(gdb) s
7	    return x;
❺ 1: x = 17
(gdb) q
  • 使った短縮コマンド: b(break), r(run), s(step), disp(display), q(quit)
  • 「何度もp xと入力するのが面倒」という人はdisplayを使って, 変数の値を自動表示させましょう.displayは実行が停止するたびに, 指定した変数の値を表示します.
  • ここでは❶ disp xとして,変数xの値を自動表示させます. (❷ 1: x = 21845と出てるのは,変数xが未初期化のため,ゴミの値が表示されたからです).
  • sで1行ずつ実行を進めるたびに, 変数xの値が,❸ 1: x = 10→ ❹ 1: x = 13→ ❺ 1: x = 17 と変化するのが分かります.

条件付きブレークポイントの設定とバックトレース表示 (break if, backtrace)

// fact.c
#include <stdio.h>
int fact (int n)
{
    if (n <= 0)
         return 1;
     else
        return n * fact (n - 1);
}

int main ()
{
    printf ("%d\n", fact (5));
}
$ gcc -g fact.c
$ gdb ./a.out
(gdb) ❶ b fact if n==0
Breakpoint 1 at 0x1158: file fact.c, line 5.
(gdb) ❷ r
❸ Breakpoint 1, fact (n=0) at fact.c:5
5	    if (n <= 0)
(gdb) ❹ bt
#0  fact (n=0) at fact.c:5
#1  0x0000555555555172 in fact (n=1) at fact.c:8
#2  0x0000555555555172 in fact (n=2) at fact.c:8
#3  0x0000555555555172 in fact (n=3) at fact.c:8
#4  0x0000555555555172 in fact (n=4) at fact.c:8
#5  0x0000555555555172 in fact (n=5) at fact.c:8
❺#6  0x000055555555518a in main () at fact.c:13
(gdb) q
  • 使った短縮コマンド: b(break), r(run), bt(backtrace), q(quit)
  • 階乗を計算するfactは再帰的に何度も呼び出されますが, ここでは「引数nの値が0のときだけブレークしたい」とします.
  • b fact if n==0で,引数n0の時だけfactの実行を停止する設定をして, ❷ rで実行を開始すると,意図通り ❸ fact (n=0)で実行停止できました.
  • ここで,❹btとしてバックトレースを表示させます. バックトレースとは「今,実行中の関数から遡ってmain関数に至るまでの 関数呼び出し系列」のことです. ❺main関数から,fact(n=5)fact(n=4)→(中略) →fact(n=0)と呼び出されたことが分かります.
  • なお,backtrace fullとすると, バックトレースに加えて,局所変数の値も表示されます.

注: Ubuntu 20.04 LTSなど,少し古いLinuxを使っている人は バックトレース中の引数の値が間違った表示 になることがあります(私はなりました). これは古いgdbendbr64命令に非対応だったからです. Ubuntu 22.04 LTSなど最新のLinuxにすることをお勧めします (2023年8月現在).

変数や式の変更監視 (watch)

// calcx.c
int main ()
{
    int x = 10;
    x += 3;
    x += 4;
    return x;
}
$ gcc -g calcx.c
$ gdb ./a.out
(gdb) b main
Breakpoint 1 at 0x1131: file calcx.c, line 4.
(gdb) r
Breakpoint 1, main () at calcx.c:4
4	    int x = 10;
(gdb) ❶ wa x
Hardware watchpoint 2: x
(gdb) ❷ c
Continuing.

Hardware watchpoint 2: x
❸ Old value = 21845
❹ New value = 10
main () at calcx.c:5
5	    x += 3;
(gdb) c
Continuing.

Hardware watchpoint 2: x
Old value = 10
New value = 13
main () at calcx.c:6
6	    x += 4;
(gdb) c
Continuing.

Hardware watchpoint 2: x
Old value = 13
New value = 17
main () at calcx.c:7
7	    return x;
(gdb) q
  • 使った短縮コマンド: b(break), r(run), wa(watch), c(continue), q(quit)

  • watchは指定した変数や式の変化(書き込み)を監視します. 「どこで値が変わるのかわからない」という場合に便利です. ここでは ❶ wa xとして変数xを監視する設定を行い, 実行を再開します (❷ c). 変更箇所で自動的にブレークされて, 変更前後の値が表示されました(❸ Old value = 21845,❹ New value = 10).

  • breakと同様に,watchにもifで条件を指定できます. 例えば,wa x if x==13とすると,変数の値が13になった時点でブレークできます.

  • watchはハードウェア機能を使うため, 高速ですが指定できる個数に限りがあります.

  • watchには-lというオプションを指定可能です. このオプションを指定すると,指定した変数や式の左辺値(つまりアドレス)を計算して, そのアドレスへの書き込みを(変数のスコープを無視して)監視します. 計算結果がアドレスでなかった場合(つまり左辺値を持たない式だった場合)はgdbはエラーを表示します.

  • watchは「書き込み」を監視します. 「読み込み」を監視したい時はrwatch, 「読み書き」の両方を監視したい時はawatchを使って下さい.

実行中断と,実行途中での変数の値の変更

// inf-loop.c
#include <stdio.h>
int main ()
{
    int x = 1, n = 0;
    while (x != 0) {
        n++;
    }
    printf ("hello, world\n");
}
$ gcc -g inf-loop.c
$ gdb ./a.out
(gdb) r

❶ ^C
❷ Program received signal SIGINT, Interrupt.
main () at inf-loop.c:7
7	    while (x != 0) {
(gdb) ❸ p x=0
$1 = 0
(gdb) ❹ c
Continuing.
❺ hello, world
(gdb) q
  • 使った短縮コマンド: r(run), p(print), c(continue), q(quit)
  • このプログラムは無限ループがあるため,実行を開始すると gdbに制御が戻ってきません.そこで,ctrl-c (❶ ^C)を入力して プログラムを一時停止します.
  • 変数xの値をゼロにすれば無限ループを抜けるので, printコマンドで ❸ p x=0とすることで,変数xにゼロを代入します. このようにprintコマンドは変数を変更したり, 副作用のある関数を呼び出すことができます(例えば,p printf("hello\n")として).
  • 実行を再開すると (❹c),❺ hello, worldが表示され, 無事に無限ループを抜けることができました.

再開場所の変更 (jump)

// inf-loop2.c
#include <stdio.h>
#include <time.h>
int main ()
{
    int n = 0;
    while (time (NULL)) {
        n++;
    }
    printf ("hello, world\n");
}
$ gcc -g inf-loop2.c
$ gdb ./a.out
(gdb) r

^C
Program received signal SIGINT, Interrupt.
main () at inf-loop2.c:8
8	        n++;
(gdb) ❶ l
3	#include <time.h>
4	int main ()
5	{
6	    int n = 0;
7	    while (time (NULL)) {
8	        n++;
9	    }
❷ 10	    printf ("hello, world\n");
11	}
(gdb) ❸ j 10
Continuing at 0x555555555191.
❹ hello, world
(gdb) q
  • 使った短縮コマンド: r(run), l(list), j(jump), q(quit)
  • 先程と異なり,今回,無限ループを抜けるのに, 単純に変数の値を変える方法は使えません. (システムコールtimeは1970/1/1からの経過秒数を返します). そこで,ここではjumpコマンドを使います. jumpは「指定した場所から実行を再開」します. (一方,continueは「実行を一時停止した場所から実行を再開」します).
別の方法

別の方法として,timeが返した戻り値は%raxレジスタに入っているので, timeからのリターン直後にp $rax=0とする方法もあります (レジスタ%raxの値が0になります). また p $rip=0x0000555555555191として,直接 %ripレジスタの値を 変更する方法もあります(jumpコマンドの中身はまさにこれでしょう).

  • 何行目から実行を再開すればよいかを調べるために, listコマンドを使ってソースコードの一部を表示します. (listに表示する行番号や関数名を指定することもできます). 10行目から再開すれば良さそうと分かります.
  • 10行目から実行を再開すると(❷ j 10), 無事に無限ループを抜けられました (❸ hello, world).

なお,layout srcとすると,ソースコードを表示するウインドウが現れます. ソースコードと現在の実行位置を見ながらデバッグできるので便利です. (時々画面が乱れるので,その時はctrl-l(コントロールL)を押して, 画面を再描画して下さい). このモードから抜けるには,tui disableあるいはctrl-x aを入力します.

型の表示 (whatis, ptype)

// struct2.c
#include <stdio.h>
#include <stddef.h> // for size_t

struct foo {
   int a1;
   char a2;
   size_t a3;
};

int main ()
{
    struct foo f = {10, 'a', 20};
    printf ("%d\n", f.a1);
}
$ gcc -g struct2.c
$ gdb ./a.out
Reading symbols from ./a.out...
(gdb) b main
Breakpoint 1 at 0x1155: file struct2.c, line 12.
(gdb) r
Breakpoint 1, main () at struct2.c:12
12	    struct foo f = {10, 'a', 20};
(gdb) ❶ whatis f
type = struct foo
(gdb) ❷ ptype f
type = struct foo {
    int a1;
    char a2;
    size_t a3;
}
(gdb) ❸ ptype/o f
/* offset      |    size */  type = struct foo {
/*      0      |       4 */    int a1;
/*      4      |       1 */    char a2;
/* XXX  3-byte hole      */
/*      8      |       8 */    size_t a3;

                               /* total size (bytes):   16 */
                             }
(gdb) ❹ ptype struct foo
type = struct foo {
    int a1;
    char a2;
    size_t a3;
}
(gdb) ❺ whatis f.a3
type = size_t
(gdb) ❻ ptype f.a3
type = unsigned long
(gdb) ptype size_t
type = unsigned long
(gdb) ❼ info types foo
All types matching regular expression "foo":

File struct2.c:
4:	struct foo;
(gdb) q
  • whatisptypeは式や型名の型情報を表示します.

  • whatisは構造体の中身を表示しませんが (❶ whatis f), ptypeは表示します (❷ ptype f). /oオプションを付けると,構造体のフィールドのオフセットとサイズ, 構造体中のパディング(ホール,穴)も表示してくれます (❸ ptype/o f).

  • whatisptypeには型名も指定できます (❹ ptype struct foo).

  • whatistypedefを1レベルまでしか展開しませんが (❺ whatis f.a3), ptypeは全て展開します (❻ ptype f.a3).

  • info typesを使うと,正規表現にマッチする型名一覧を表示します (❼ info types foo).

gdbの実行例 (アセンブリ言語編)

アドレス指定でブレイク,レジスタの値を表示

// hello.c
#include <stdio.h>
int main ()
{
    printf ("hello\n");
}
$ gcc -g hello.c
$ gdb ./a.out
(gdb) ❶ b main
Breakpoint 1 at 0x1151: file hello.c, line 5.
(gdb) r
Breakpoint 1, main () at hello.c:5
5	    printf ("hello\n");
(gdb) ❷ disas
Dump of assembler code for function main:
   0x0000555555555149 <+0>:	endbr64 
   0x000055555555514d <+4>:	push   %rbp
   0x000055555555514e <+5>:	mov    %rsp,%rbp
❸ => 0x0000555555555151 <+8>:	lea    0xeac(%rip),%rax        # 0x555555556004
   0x0000555555555158 <+15>:	mov    %rax,%rdi
   0x000055555555515b <+18>:	call   0x555555555050 <puts@plt>
   0x0000555555555160 <+23>:	mov    $0x0,%eax
   0x0000555555555165 <+28>:	pop    %rbp
   0x0000555555555166 <+29>:	ret    
End of assembler dump.
(gdb) ❹ b *0x0000555555555149
Breakpoint 2 at 0x555555555149: file hello.c, line 4.
(gdb) r
The program being debugged has been started already.
Start it from the beginning? (y or n) y

Breakpoint 2, main () at hello.c:4
4	{
(gdb) ❺ disp/i $rip
1: x/i $rip
❻ => 0x555555555149 <main>:	endbr64 
(gdb) ❼ si
0x000055555555514d	4	{
❽ 1: x/i $rip
❾ => 0x55555555514d <main+4>:	push   %rbp
(gdb) si
0x000055555555514e	4	{
1: x/i $rip
=> 0x55555555514e <main+5>:	mov    %rsp,%rbp
(gdb) ❿ p/x $rbp
$1 = 0x1
(gdb) ⓫ i r
rax            0x555555555149      93824992235849
rbx            0x0                 0
rcx            0x555555557dc0      93824992247232
rdx            0x7fffffffdfb8      140737488347064
rsi            0x7fffffffdfa8      140737488347048
rdi            0x1                 1
rbp            0x1                 0x1
rsp            0x7fffffffde90      0x7fffffffde90
(長いので中略)
(gdb) q
  • 使った短縮コマンド: b(break), r(run), disas(disassemble), si(stepi), p(print), i r(info registers), q(quit)

  • まず,main関数にブレークポイントを設定して(❶ b main), 実行を開始すると実行が一時停止するのですが, 逆アセンブル (❷ disas)して確かめると, 機械語命令レベルではmain関数の先頭で実行を一時停止していません.

    • => 0x0000555555555151 <+8>: の <+8>が 「main関数の先頭から8バイト目」であることを示しています. gdbは関数名を指定してブレークした場合, 関数プロローグが終わった場所でブレークします.
    • disassembleは逆アセンブル結果を表示します. 何も指定しないと実行中の場所付近の逆アセンブル結果を表示します. 関数名やアドレスを指定することも可能です. また,disassembleには以下のオプションを指定可能です.
オプション説明
/sソースコードも表示 (表示順は機械語命令の順番)
/mソースコードも表示 (表示順はソースコードの順番)
/r機械語命令の16進ダンプも表示
  • ここではmain関数の先頭番地を指定してブレークポイントを設定してみます (❹ b *0x0000555555555149).行番号と区別するため *が必要です. 実行を開始するとブレークしました.

  • ブレークした番地と,その番地の機械語命令を表示すると便利です. そのため,❺ disp/i $ripとしました. これはプログラムカウンタ%ripの値を命令として(i, instruction)自動表示せよ, という意味です.(gdbではレジスタを指定するのに%ripではなく, $ripのようにドルマーク$を使います). これにより,❻ => 0x555555555149 <main>: endbr64が表示されました.

    • 次に実行する番地は 0x555555555149番地
    • その番地の命令は endbr64命令
  • stepi (❼ si)を使うと,1行ではなく,機械語命令を1つ実行する ステップ実行になります. ❺disp/i $ripの効果で, 次に実行される命令の番地とニモニックが表示されました (❾ => 0x55555555514d <main+4>: push %rbp). なお,❽ 1: x/i $ripとあるのは, ❺disp/i $ripprintではなく, x/i $ripコマンドで機械語命令を出力するからです (xはメモリ中の値を表示するコマンドです. /iはフォーマット指定で「機械語命令」(instruction)を意味します).

  • レジスタの値を表示するにはprintを使います (❿ p/x $rbp) /xはフォーマット指定で「16進数」(hexadecimal)を意味します. 値は1でした ($1 = 0x1).

  • なお,info registers (⓫ i r)で,全ての汎用レジスタの値を 一括表示できます.

メモリ中の値(機械語命令)を表示する (x)

// hello.c
#include <stdio.h>
int main ()
{
    printf ("hello\n");
}
$ gdb ./a.out
Reading symbols from ./a.out...
(gdb) b main
Breakpoint 1 at 0x1151: file hello.c, line 5.
(gdb) disp/x $rip
1: /x $rip = <error: No registers.>
(gdb) r
Breakpoint 1, main () at hello.c:5
5	    printf ("hello\n");
1: /x $rip = 0x555555555151
(gdb) ❶ disas/r
Dump of assembler code for function main:
   0x0000555555555149 <+0>:	f3 0f 1e fa	endbr64 
   0x000055555555514d <+4>:	55	push   %rbp
   0x000055555555514e <+5>:	48 89 e5	mov    %rsp,%rbp
❷ => 0x0000555555555151 <+8>:	48 8d 05 ac 0e 00 00	lea    0xeac(%rip),%rax        # 0x555555556004
   0x0000555555555158 <+15>:	48 89 c7	mov    %rax,%rdi
   0x000055555555515b <+18>:	e8 f0 fe ff ff	call   0x555555555050 <puts@plt>
   0x0000555555555160 <+23>:	b8 00 00 00 00	mov    $0x0,%eax
   0x0000555555555165 <+28>:	5d	pop    %rbp
   0x0000555555555166 <+29>:	c3	ret    
End of assembler dump.
(gdb) ❸ x/7xb 0x0000555555555151
❹ 0x555555555151 <main+8>:	0x48	0x8d	0x05	0xac	0x0e	0x00	0x00
(gdb) ❺ x/7xb $rip
0x555555555151 <main+8>:	0x48	0x8d	0x05	0xac	0x0e	0x00	0x00
(gdb) ❻ x/7xb $rip+7
0x555555555158 <main+15>:	0x48	0x89	0xc7	0xe8	0xf0	0xfe	0xff
(gdb) q
  • 使った短縮コマンド: b(break), r(run), disp (display), disas(disassemble), x(x), q(quit)

  • 16進ダンプ付き(/r)で逆アセンブルすると (❶ disas/r),

❷ => 0x0000555555555151 <+8>:	48 8d 05 ac 0e 00 00	lea    0xeac(%rip),%rax        # 0x555555556004

と表示されました. 0x0000555555555151番地には lea 0xeac(%rip),%raxという命令があり, 機械語バイト列としては 48 8d 05 ac 0e 00 00だと分かりました.

  • xコマンドでメモリ中の値を表示できます. 例えば,❸ x/7xb 0x0000555555555151は, 「0x0000555555555151番地のメモリの値を表示せよ. 表示は1バイト単位(b),16進表記(x)のものを7個,表示せよ」という意味です. その結果,逆アセンブル結果と同じ値が表示されました (❹ 0x555555555151 <main+8>: 0x48 0x8d 0x05 0xac 0x0e 0x00 0x00).

  • なお,xコマンドに与える指定は NFT という形式です.
    N は表示個数(デフォルト1),Fはフォーマット,Uは単位サイズの指定です. FUの順番は逆でもOKです. (例: 4gx は「8バイトデータを16進数表記で4個表示」を意味する). FUで指定できるものは以下の通りです.

フォーマット F説明
x16進数 (hexadecimal)
d符号あり10進数 (decimal)
u符号なし10進数 (unsigned)
t2進数 (two)
c文字 (char)
s文字列 (string)
i機械語命令 (instruction)

単位サイズ U説明
b1バイト (byte)
h2バイト (half-word)
w4バイト (word)
g8バイト (giant)
  • xへのアドレス指定にレジスタの値 (❺ x/7xb $rip)や レジスタ値を使った足し算 (❻ x/7xb $rip+7)も指定できます.

メモリ中の値(スタック)を表示する (x)

// fact.c
#include <stdio.h>
int fact (int n)
{
    if (n <= 0)
         return 1;
     else
        return n * fact (n - 1);
}

int main ()
{
    printf ("%d\n", fact (5));
}
$ gcc -g fact.c
$ gdb ./a.out
Reading symbols from ./a.out...
(gdb) b main
Breakpoint 1 at 0x1180: file fact.c, line 13.
(gdb) b fact
Breakpoint 2 at 0x1158: file fact.c, line 5.
(gdb) r
Breakpoint 1, main () at fact.c:13
13	    printf ("%d\n", fact (5));
(gdb) ❶ p/x $rbp
$1 = ❷ 0x7fffffffde90
(gdb) c
Continuing.

Breakpoint 2, fact (n=5) at fact.c:5
5	    if (n <= 0)
(gdb) ❸ x/1xg $rbp + 8
0x7fffffffde88:	❹ 0x000055555555518a
(gdb) disas main
Dump of assembler code for function main:
   0x0000555555555178 <+0>:	endbr64 
   0x000055555555517c <+4>:	push   %rbp
   0x000055555555517d <+5>:	mov    %rsp,%rbp
   0x0000555555555180 <+8>:	mov    $0x5,%edi
   0x0000555555555185 <+13>:	call   0x555555555149 <fact>
❺ 0x000055555555518a <+18>:	mov    %eax,%esi
   0x000055555555518c <+20>:	lea    0xe71(%rip),%rax        # 0x555555556004
   0x0000555555555193 <+27>:	mov    %rax,%rdi
   0x0000555555555196 <+30>:	mov    $0x0,%eax
   0x000055555555519b <+35>:	call   0x555555555050 <printf@plt>
   0x00005555555551a0 <+40>:	mov    $0x0,%eax
   0x00005555555551a5 <+45>:	pop    %rbp
   0x00005555555551a6 <+46>:	ret    
End of assembler dump.
(gdb) ❻ x/1gx $rbp
0x7fffffffde80:	❼ 0x00007fffffffde90
(gdb) q
  • 使った短縮コマンド: b(break), r(run), p (print), c(continue), x(x), disas(disassemble), q(quit)

  • mainfactにブレークポイントを設定し, main関数でブレークした時点で,%rbpの値を調べると (❶ p/x $rbp), ❷ 0x7fffffffde90と分かりました. これはmain関数のスタックフレームの一番下のアドレスです.

  • factでブレークした時点で,スタックフレームは上図になっているはずです. まずメモリ参照8(%rbp)に正しく戻り番地が入っているか調べます. $rbp+8番地のメモリの値を調べると (❸ x/1xg $rbp+8), ❹ 0x000055555555518aが入っていました. (1xgは,8バイトデータを16進数で1個分出力する,を意味します).

    main関数を逆アセンブルすると,

   0x0000555555555185 <+13>:	call   0x555555555149 <fact>
❺ 0x000055555555518a <+18>:	mov    %eax,%esi

この番地(❹ 0x000055555555518a)はcall factの次の命令なので, 戻り番地として正しいことを確認できました.

  • 次にfact(5)のスタックフレーム中の「古い%rbp」の値が正しいかを調べます. %rbpが指すメモリの値を調べると(❻ x/1gx $rbp), ❼ 0x00007fffffffde90が入っていました. これは ❷ 0x7fffffffde90と一致するので, 「古い%rbp」が正しいことを確認できました.

シンボルテーブル (info address, info symbol)

// hello.c
#include <stdio.h>
int main ()
{
    printf ("hello\n");
}
$ gcc -g hello.c
$ gdb ./a.out
Reading symbols from ./a.out...
(gdb) ❶ info address main
Symbol "main" is a function at address ❷ 0x1149.
(gdb) b main
Breakpoint 1 at 0x1151: file hello.c, line 5.
(gdb) r
Breakpoint 1, main () at hello.c:5
5	    printf ("hello\n");
(gdb) ❸ info address main
Symbol "main" is a function at address ❹ 0x555555555149.
(gdb) info address printf
Symbol "printf" is at 0x7ffff7c60770 in a file ❺ compiled without debugging.
(gdb) disas main
Dump of assembler code for function main:
   0x0000555555555149 <+0>:	endbr64 
   0x000055555555514d <+4>:	push   %rbp
   0x000055555555514e <+5>:	mov    %rsp,%rbp
=> 0x0000555555555151 <+8>:	lea    0xeac(%rip),%rax        # 0x555555556004
   0x0000555555555158 <+15>:	mov    %rax,%rdi
   0x000055555555515b <+18>:	call   0x555555555050 <puts@plt>
   0x0000555555555160 <+23>:	mov    $0x0,%eax
   0x0000555555555165 <+28>:	pop    %rbp
   0x0000555555555166 <+29>:	ret    
End of assembler dump.
(gdb) ❻ info symbol 0x0000555555555149
main in section .text of /mnt/hgfs/gondow/project/linux-x86-64-programming/src/asm/a.out
(gdb) ❼ info symbol 0x0000555555555166
main + 29 in section .text of /mnt/hgfs/gondow/project/linux-x86-64-programming/src/asm/a.out
(gdb) q
  • 使った短縮コマンド: b(break), r(run), disas(disassemble), q(quit)

  • info addressは指定したシンボルのアドレスを表示します. プログラム実行前の場合(❶ info address main), ファイルa.out中のアドレスを表示します(❷ 0x1149). これはnmコマンドやobjdump -dで得られるアドレスと同じです.

$ nm ./a.out | egrep main
                 U __libc_start_main@GLIBC_2.34
0000000000001149 T main
  • 一方,実行後では (❸ info address main), main関数のメモリ上でのアドレスが得られます (❹ 0x555555555149). なお,printfのアドレスを調べると, デバッグ情報無しでコンパイルされた旨のメッセージも表示されました (❺ compiled without debugging).

  • info symbolは指定したアドレスを持つシンボルを返します. 例えば,main関数の先頭アドレスを指定すると ( ❻ info symbol 0x0000555555555149),mainを表示しました. アドレスはmain関数の途中のアドレスでも大丈夫です ( ❼ info symbol 0x0000555555555166).

お便利機能

helpコマンド

help (h)はコマンドのヘルプ(説明)を表示します.

(gdb) help step
step, s
Step program until it reaches a different source line.
Usage: step ❶ [N]
Argument N means step N times (or till program stops for another reason).

例えば,help stepとすると,stepに回数を指定できる❶ことが分かりました. [N]のカギカッコは省略可能な引数を意味します.

aproposコマンド

apropos(apr)は指定した正規表現をヘルプに含むコマンドを表示します.

(gdb) apropos break
advance -- Continue the program up to the given location (same form as args for break command).
break, brea, bre, br, b -- Set breakpoint at specified location.
break, brea, bre, br, b -- Set breakpoint at specified location.
break-range -- Set a breakpoint for an address range.
breakpoints -- Making program stop at certain points.
clear, cl -- Clear breakpoint at specified location.
commands -- Set commands to be executed when the given breakpoints are hit.
(以下略)

例えば,apropos breakとすると,breakをヘルプに含むコマンド一覧を表示します. breakに関係するコマンドを知りたい場合に便利です.

補完とヒストリ機能

コマンド省略名説明
ctrl-p1つ前のコマンドを表示
ctrl-n1つ後のコマンドを表示
show commands自分が入力したコマンド履歴を表示
ctrl-iコマンド等を補完 (TABキーでも同じ)
2回押すと候補一覧を表示
ctrl-l画面をクリア・リフレッシュ
(gdb) br TAB  (br とTABの間にはスペースを入れない)
(gdb) break  (breakまで補完)
(gdb) break TAB (ここで2回TABを押すと)
break        break-range     (breakで始まるコマンドの一覧を表示)
(gdb) b main
(gdb) r
(gdb) step
(gdb) ctrl-p  (ctrl-p を押すと)
(gdb) step    (1つ前のコマンド step が表示された)

TUI (テキストユーザインタフェース)

layoutコマンドで,TUIの表示モードを使えます. src (ソースコード),asm (アセンブリコード), regs (レジスタ表示)を選べます.

上図はlayout asm後にlayout regsとした時の画面です. 元の表示方法に戻るにはctrl-x aとして下さい.

ブレークポイントの設定

場所の指定

場所の指定説明
b 10(今実行中のファイルの)10行目
b +5今の実行地点から5行後
b -5今の実行地点から5行前
b main(今実行中のファイルの)関数main
b main.c:mainファイルmain.c中のmain関数
b main.c:10ファイルmain.cの10行目

条件付きブレークポイント

// fact.c
#include <stdio.h>
int fact (int n)
{
    if (n <= 0)
         return 1;
     else
        return n * fact (n - 1);
}

int main ()
{
    printf ("%d\n", fact (5));
}
$ gcc -g fact.c
$ gdb ./a.out
Reading symbols from ./a.out...
(gdb) ❶ b fact if n==0
Breakpoint 1 at 0x1158: file fact.c, line 5.
(gdb) ❷ i b
Num     Type           Disp Enb Address            What
1       breakpoint     keep y   0x0000000000001158 in fact at fact.c:5
	stop only if ❸ n==0
(gdb) ❹ cond 1 n==1
(gdb) i b
Num     Type           Disp Enb Address            What
1       breakpoint     keep y   0x0000000000001158 in fact at fact.c:5
	stop only if n==1
(gdb) ❺ cond 1
Breakpoint 1 now unconditional.
(gdb) i b
Num     Type           Disp Enb Address            What
1       breakpoint     keep y   0x0000000000001158 in fact at fact.c:5
(gdb) q
  • 使った短縮コマンド: b(break), i b(info breakpoints), cond(condition), q(quit)
  • 条件付きブレークポイントはifを使って指定します (❶ b fact if n==0).
  • i bで,現在のブレークポイントの状況を確認できます (❷ i b). 番号1のブレークポイントとして,❸ n==0という条件が設定されています.
  • condで,指定した番号のブレークポイントの条件を変更できます. ここでは ❹ cond 1 n==1として,条件をn==1に変更しました.
  • condで新しい条件を指定しないと,条件が外れます(❺ cond 1).

コマンド付きブレークポイント

commandsで「ブレークした時に実行するコマンド列」を指定できます.

$ gcc -g fact.c
$ gdb ./a.out
(gdb) b fact 
Breakpoint 1 at 0x1158: file fact.c, line 5.
(gdb) ❶ commands
Type commands for breakpoint(s) 1, one per line.
End with a line saying just "end".
>print n
>c
❷>end
(gdb) ❸ r

Breakpoint 1, fact (n=5) at fact.c:5
5	    if (n <= 0)
$1 = 5

Breakpoint 1, fact (n=4) at fact.c:5
5	    if (n <= 0)
$2 = 4

Breakpoint 1, fact (n=3) at fact.c:5
5	    if (n <= 0)
$3 = 3

Breakpoint 1, fact (n=2) at fact.c:5
5	    if (n <= 0)
$4 = 2

Breakpoint 1, fact (n=1) at fact.c:5
5	    if (n <= 0)
$5 = 1

Breakpoint 1, fact (n=0) at fact.c:5
5	    if (n <= 0)
$6 = 0
120
(gdb) 
  • 引数無しで❶commandsとすると,最後に設定したブレークポイントに対して コマンドを設定します. commands 2commands 5-7などブレークポイントの番号や範囲の指定もできます.
  • commandsに続けて,実行したいコマンドを入力します. 最後に❷endを指定します.
  • ❸実行すると,全てのfactの呼び出しが一気に表示できました. 指定したコマンド中にcontinueを指定できるのがとても便利です.
  • ここでは不使用ですが,コマンド列の最初にsilentを使用すると, ブレーク時のメッセージを非表示にできます.

ステップ実行

ステップ実行の種類

ステップ実行の種類gdbコマンド短縮形説明
ステップインsteps1行実行を進める(関数呼び出しは中に入って1行を数える)
ステップオーバーnextn1行実行を進める(関数呼び出しはまたいで1行を数える)
ステップアウトfinishfin今の関数がリターンするまで実行を進める
実行再開continuecブレークされるまで実行を進める

  • 上図で,今,B();を実行する直前でブレークしているとします.
  • stepすると,関数Bprintf("B\n");まで実行を進めます.
  • nextすると,関数Aprintf("A\n");まで実行を進めます.
  • finishすると,関数mainprintf("main\n");まで実行を進めます.

ステップインの実行例 (step)

#include <stdio.h>
void B ()
{
    printf ("B\n");
}
void A ()
{
    B ();
    printf ("A\n");
}
int main ()
{
    A ();
    printf ("main\n");
}

$ gcc -g step.c
$ gdb ./a.out
Reading symbols from ./a.out...
(gdb) b A
Breakpoint 1 at 0x116b: file step.c, line 8.
(gdb) r
Breakpoint 1, A () at step.c:8
8	    B ();
(gdb) ❶ s
B () at step.c:4
4	❷    printf ("B\n");

step(❶ s)すると,❷ printf ("B\n");まで実行しました.

ステップオーバーの実行例 (next)

$ gcc -g step.c
$ gdb ./a.out
Reading symbols from ./a.out...
(gdb) b A
Breakpoint 1 at 0x116b: file step.c, line 8.
(gdb) r
Breakpoint 1, A () at step.c:8
8	    B ();
(gdb) ❸ n
B
9	❹    printf ("A\n");

next(❸ n)すると,❹ printf ("A\n");まで実行しました.

ステップオーバーの実行例 (finish)

$ gdb ./a.out
Reading symbols from ./a.out...
(gdb) b A
Breakpoint 1 at 0x116b: file step.c, line 8.
(gdb) r
Starting program: /mnt/hgfs/gondow/project/linux-x86-64-programming/src/asm/a.out 
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".

Breakpoint 1, A () at step.c:8
8	    B ();
(gdb) ❺ fin
Run till exit from #0  A () at step.c:8
B
A
main () at step.c:14
14	❻    printf ("main\n");

finish(❺ fin)すると,❻ printf ("main\n");まで実行しました.

実行再開の実行例 (continue)

$ gdb ./a.out
Reading symbols from ./a.out...
(gdb) b A
Breakpoint 1 at 0x116b: file step.c, line 8.
(gdb) r
Breakpoint 1, A () at step.c:8
8	    B ();
(gdb) ❼ c
Continuing.
B
A
main

continue(❼ c)すると,ブレークポイントがなかったので, 最後まで実行して実行終了しました.

変数の値の表示

配列 (@)

// array2.c
#include <stdlib.h>
int main (int argc, char **argv)
{
   int arr [4] = {0, 10, 20, 30};
   int *p = malloc (sizeof (int) * 4);
   p [0] = 40;
   p [1] = 50;
   p [2] = 60;
   p [3] = 70;
}
$ gcc -g array2.c
(gdb) b 11
Breakpoint 1 at 0x5555555551ee: file array2.c, line 11.
(gdb) r aa bb cc dd
Starting program: /mnt/hgfs/gondow/project/linux-x86-64-programming/src/asm/a.out aa bb cc dd

Breakpoint 1, main (argc=5, argv=0x7fffffffdf78) at array2.c:11
11	}
(gdb) ❶ p arr
❷ $1 = {0, 10, 20, 30}
(gdb) ❸ p *p
❹ $2 = 40
(gdb) ❺ p *p@4
❻ $3 = {40, 50, 60, 70}
(gdb) ❼ p *argv@5
❽ $4 = {
  0x7fffffffe2f7 "/mnt/hgfs/gondow/project/linux-x86-64-programming/src/asm/a.out", 0x7fffffffe337 "aa", 0x7fffffffe33a "bb", 0x7fffffffe33d "cc", 
  0x7fffffffe340 "dd"}
  • 普通の配列はprintでそのまま表示できます. 例えば ❶ p arrとすると,❷ $1 = {0, 10, 20, 30}と表示されます.
  • mallocで配列を確保した場合, 単純に ❸ p *pとすると,pの型はint *なので, ❹ $2 = 40しか表示されません. この場合は@を使って ❺ p *p@4とすると, 4要素の配列としてうまく表示できます(❻ $3 = {40, 50, 60, 70}).
  • 同様にargvも ❼ p *argv@5とすると,うまく表示できます(❽).

スコープの指定 ('::')

#include <stdio.h>
int x = 111;
int main ()
{
    static int x = 222;
    {
        int x = 333;
        printf ("hello\n");
    }
}
$ gcc -g scope.c
$ gdb ./a.out
Reading symbols from ./a.out...
(gdb) b 8
Breakpoint 1 at 0x115c: file scope.c, line 8.
(gdb) r
Breakpoint 1, main () at scope.c:8
8		printf ("hello\n");
(gdb) p x
$1 = 333
(gdb) ❶ p 'scope.c'::x
$2 = 111
(gdb) ❷ p main::x
$3 =  222

'::'を使うと,特定のファイルや関数中の変数の値を表示できます.

  • p 'scope.c'::xscope.cのグローバル変数xの値を表示します. (ファイル名をクオート文字 ' で囲む必要があります).
  • p main::x は関数mainの静的変数xの値を表示します.

構造体 (リスト構造)

// list.c
#include <stdio.h>
struct list {
    int data;
    struct list *next;
};

int main ()
{
    struct list n1 = {10, NULL};
    struct list n2 = {20, &n1};
    struct list n3 = {30, &n2};
    struct list *p = &n3;
}
$ gdb ./a.out
Reading symbols from ./a.out...
(gdb) b 14
Breakpoint 1 at 0x119e: file list.c, line 14.
(gdb) r
Breakpoint 1, main () at list.c:14
14	}
(gdb) p p
$1 = (struct list *) 0x7fffffffde70
(gdb) ❶ p *p
❷ $2 = {data = 30, next = 0x7fffffffde60}
(gdb) ❸ p *p->next
$3 = {data = 20, next = 0x7fffffffde50}
(gdb) ❹ p *p->next->next
$4 = {data = 10, next = 0x0}
  • pが指すリスト構造は上図のようになっています.
  • printを使って(❶ p *p), 構造体の中身を普通に表示できます(❷ {data = 30, next = 0x7fffffffde60}).
  • *p->nextなどのC言語の式と(ほぼ)同じ記法で, リスト構造をたどって中身を表示できます (❸ p *p->next,❹ p *p->next->next).

共用体

// union.c
#include <stdio.h>

union foo {
    int  u1;
    float u2;
};

int main ()
{
    union foo f;
    f.u1 = 999;
    f.u2 = 123.456;
}
(gdb) b 13
Breakpoint 3 at 0x555555555138: file union.c, line 13.
(gdb) r
Breakpoint 3, main () at union.c:13
13	    f.u2 = 123.456;
(gdb) ❶ p f
❷ $1 = {u1 = 999, u2 = 1.39989717e-42}
(gdb) ❸ p f.u1
❹ $2 = 999
(gdb) s
14	}
(gdb) p f
$3 = {u1 = 1123477881, u2 = 123.456001}
(gdb) p f.u2
$4 = 123.456001
  • 共用体をprintすると (❶ p f), u1u2のどちらのメンバが使われているかgdbは分からないので, 両方の可能性を表示します (❷ {u1 = 999, u2 = 1.39989717e-42}).
  • メンバ名をu1と指定すると (❸ p f.u1), そのメンバに対する値を表示します (❹ $2 = 999).

特定の値をメモリ中から探す (find)

// find.c
#include <stdio.h>
int arr [1000];

int main ()
{
    arr [500] = 0xDEADBEEF;
    printf ("%p\n", &arr [500]);
}
(gdb) b 8
Breakpoint 1 at 0x115b: file find.c, line 8.
(gdb) r
Breakpoint 1, main () at find.c:8
8	    printf ("%p\n", &arr [500]);
(gdb) ❶ p/x &arr[500]
❷ $1 = 0x555555558810
(gdb) ❸ find /w arr, arr+4000, 0xDEADBEEF
❹ 0x555555558810 <arr+2000>
1 pattern found.
  • 上のプログラムではarr[500]0xDEADBEEFという値が入っています. この値が格納されている場所のアドレスはprintで(❶ p/x &arr[500]), ❷ 0x555555558810番地と分かります.
  • ここで仮に,配列のどこに0xDEADBEEFが入っているか分からず, この配列に入っているか調べたいとします. findコマンドで調べられます. ❸ find /w arr, arr+4000, 0xDEADBEEFは, 指定したアドレス範囲 (arr番地からarr+4000番地まで), 4バイト (/w)の値 0xDEADBEEFを探せ,という意味になります. 正しく結果が表示されました (❹ 0x555555558810 <arr+2000>).

変数,レジスタ,メモリに値をセット (set)

setを使うと,変数,レジスタ,メモリに値をセットできます.

// calcx.c
int main ()
{
    int x = 10;
    x += 3;
    x += 4;
    return x;
}
$ gcc -g calcx.c
$ gdb ./a.out
(gdb) b main
Breakpoint 1 at 0x1131: file calcx.c, line 4.
(gdb) r
Breakpoint 1, main () at calcx.c:4
4	    int x = 10;
(gdb) s
5	    x += 3;
(gdb) ❶ set x = 20
(gdb) p x
$1 = 20
(gdb) ❷ p x = 30
$2 = 30
(gdb) p x
$3 = 30
(gdb) ❸ p/x &x
$4 = 0x7fffffffde8c
(gdb) ❹ set {int}0x7fffffffde8c = 40
(gdb) p x
❺ $5 = 40
(gdb) p/x $rax
$6 = 0x555555555129
(gdb) ❻ set $rax = 0xDEADBEEF
(gdb) p/x $rax
$7 = 0xdeadbeef
  • set x = 20 で,変数xに20を代入しています.
  • p/x &xで変数xのアドレスを調べて, そのアドレスに代入してみます(❹ set {int}0x7fffffffde8c = 40). 変数xの値が40に変わりました (❺ $5 = 40).
  • set $rax = 0xDEADBEEFで,レジスタ%raxの値を変更しました.
  • なお,変数,メモリ,レジスタのどの場合でも, printコマンドを使っても代入できます (❷ p x = 30).

gdb の主なコマンドの一覧

起動・終了

コマンド省略名説明
gdb ./a.outgdbの起動
runr実行開始
quitqgdbの終了
ctrl-c実行中のプログラムを一時停止
(シグナルSIGINTを使用)
ctrl-z実行中のプログラムを一時停止
(シグナルSIGTSTPを使用)
⏎ (改行)前と同じコマンドを再実行
  • ctrl-cSIGINTgdbではなく実行中のプログラムに渡すには, handle SIGINT nostop passとします. gdbのシグナル処理状態はinfo signalsで見られます.
  • gdbのプロンプト(gdb)が出ている状態で,ctrl-zを入力すると, gdb自体の実行を一時停止します.再開するにはfgコマンドなどを使います.

ヘルプ

コマンド省略名説明
help コマンドhコマンドのヘルプ(説明)を表示
apropos [-v] 正規表現apr正規表現をヘルプに含むコマンドを表示(-vは詳細表示)

ヒストリ(コマンド履歴)と補完(コンプリーション)など

コマンド省略名説明
ctrl-p1つ前のコマンドを表示
ctrl-n1つ後のコマンドを表示
show commands自分が入力したコマンド履歴を表示
ctrl-iコマンド等を補完 (TABキーでも同じ)
2回押すと候補一覧を表示
ctrl-l画面をクリア・リフレッシュ

ブレークポイント・ウォッチポイント

コマンド省略名説明
break 場所bブレークポイントの設定
rbreak 正規表現rb正規表現にマッチする全関数にブレークポイントの設定
watch 場所waウォッチポイント(書き込み)の設定
rwatch 場所rwウォッチポイント(読み込み)の設定
awatch 場所awウォッチポイント(読み書き)の設定
info breaki bブレークポイント・ウォッチポイント一覧表示
break 場所 if 条件b条件付きブレークポイントの設定
condition 番号 条件condブレークポイントに条件を設定
commands [番号]commブレークした時に実行するコマンド列を設定(endで終了)
delete 番号dブレークポイントの削除
deleted全ブレークポイントの解除 (clearでも同じ)

場所の指定方法
関数名main
行番号6
ファイル名:行番号main.c:6
ファイル名:関数名main.c:main
*アドレス*0x55551290

ステップ実行

コマンド省略名説明
steps次の行までステップ実行(関数コールに入って1行を数える)
nextn次の行までステップ実行(関数コールはまたいで1行を数える)
finishfin今の関数を終了するまで実行
continuecブレークポイントに当たるまで実行
until 場所u指定した場所まで実行(ループを抜けたい時に便利)
jump 場所j指定した場所から実行を再開(%ripを書き換えて再開に相当)
stepisi次の機械語命令を1つだけ実行して停止(関数コールに入って1命令を数える)
nextini次の機械語命令を1つだけ実行して停止(関数コールはまたいで1命令を数える)

式,変数,レジスタ,メモリの表示

コマンド省略名説明
print/フォーマット   式p式を実行して値を表示
display/フォーマット   式disp実行停止毎にprintする
info displayi didisplayの設定一覧表示
undisplay   番号unddisplayの設定解除
x/NFU  アドレスxメモリの内容を表示 (examine)
info registersi r全汎用レジスタの内容を表示
info all-registersi al全汎用レジスタの内容を表示
  • 表示する「式」は副作用があっても良い. 代入式でも良いし,副作用のある関数呼び出しやライブラリ関数呼び出しでも良い. (例: p x = 999p printf ("hello\n")). このためprintfコマンドは単なる「実行状態の表示コマンド」ではなく 「実行状態の変更」も可能 (このためにgdbは裏で結構すごいことやってる).
説明
$レジスタ名レジスタ参照
アドレス@要素数配列「アドレス[要素数]」として処理
  • N は表示個数(デフォルト1),Fはフォーマット,Uは単位サイズを指定する. FUの順番は逆でも良い. (例: 4gx は「8バイトデータを16進数表記で4個表示」を意味する)
フォーマット F説明
x16進数 (hexadecimal)
z16進数 (上位バイトのゼロも表示)
o8進数 (octal)
d符号あり10進数 (decimal)
u符号なし10進数 (unsigned)
t2進数 (two)
c文字 (char)
s文字列 (string)
i機械語命令 (instruction)
aアドレス (address)
f浮動小数点数 (float)

単位サイズ U説明
b1バイト (byte)
h2バイト (half-word)
w4バイト (word)
g8バイト (giant)

変数,レジスタ,メモリの変更

コマンド省略名説明
set 変数 = 式set変数に式の値を代入する
  • 変数には通常の変数(x),レジスタ($rax), メモリ ({int}0x0x1200),  デバッガ変数 ($foo)が指定できます.

スタック表示

コマンド省略名説明
backtracebt, baコールスタックを表示
where, info stackでも同じ
backtrace fullbt f, ba fコールスタックと全局所変数を表示

プログラム表示

コマンド省略名説明
list 場所lソースコードを表示
disassemble 場所disas逆アセンブル結果を表示
  • disassembleへのオプション
オプション説明
/sソースコードも表示 (表示順は機械語命令の順番)
/mソースコードも表示 (表示順はソースコードの順番)
/r機械語命令の16進ダンプも表示

TUI (テキストユーザインタフェース)

コマンド省略名説明
layout レイアウトlaTUIレイアウトを変更

レイアウト説明
asmアセンブリコードのウインドウを表示
regsレジスタのウインドウを表示
srcソースコードのウインドウを表示
splitソースとアセンブリコードのウインドウを表示
next次のレイアウトを表示
prev前のレイアウトを表示

キーバインド説明
ctrl-x aTUIモードのオン・オフ
ctrl-x 1ウインドウを1つにする
ctrl-x 2ウインドウを2つにする
ctrl-x o選択ウインドウを変更
ctrl-x sシングルキーモードのオン・オフ
ctrl-lウインドウをリフレッシュ(再表示)

シングルキーモードの
キーバインド
説明
ccontinue
ddown
ffinish
nnext
onexti
qシングルキーモードの終了
rrun
sstep
istepi
vinfo locals
vwhere

シンボルテーブル

コマンド省略名説明
info address シンボルi adシンボルのアドレスを表示
info symbol アドレスi sそのアドレスを持つシンボルを表示

型の表示

コマンド省略名説明
whatis 式または型名whaその式や型名の型情報を表示
ptype 式または型名ptその式や型名の型情報を詳しく表示
info types 正規表現i types正規表現にマッチする型を表示
  • whatistypedefを1レベルだけ展開します. ptypetypedefを全て展開します.
  • ptype/oオプションを付けると,構造体のフィールドの オフセットとサイズも表示します.

その他の使い方

どんなものがあるか,ごく簡単に説明します(詳しくは説明しません). 詳しくはGDBマニュアルを参照下さい.

初期化ファイル

ファイル名説明
~/.gdbearlyinitgdbの初期化前に読み込まれる初期化ファイル
~/.gdbinitgdbの初期化後に読み込まれる初期化ファイル
./.gdbinit最後に読み込まれる初期化ファイル
  • よく使うgdbの設定,ユーザ定義コマンドコマンドエイリアスは  初期化ファイルに記述しておくと便利です.
  • gdbの起動メッセージを抑制する set startup-quietly on~/.gdbearlyinit に指定する必要があります.
  • ./.gdbinitは個別の設定の記述に便利です. ただしデフォルトでは許可されていないので, add-auto-load-safe-path パスset auto-load safe-path /~/.gdbinitに書く必要があります.

ユーザ定義コマンド

defineendでユーザ定義コマンドを定義できます.

$ cat ~/.gdbinit
define hello
    echo hello, ❶ $arg0\n
end
❷ define start
    b main
    r
end
define ❸ hook-print
    echo size: b (1 byte), h (2 byte), w (4 byte), g (8 byte)\n
end
define ❹ hook-stop
    x/i $rip
end
$ gdb ./a.out
(gdb) hello gdb
hello, gdb
(gdb) start
=> 0x555555555151 <main+8>:	lea    0xeac(%rip),%rax        # 0x555555556004
Breakpoint 1, main () at hello.c:5
5	    printf ("hello\n");
(gdb) p main
size: b (1 byte), h (2 byte), w (4 byte), g (8 byte)
$1 = {int ()} 0x555555555149 <main>
(gdb) ❺ help user-defined
User-defined commands.
The commands in this class are those defined by the user.
Use the "define" command to define a command.

List of commands:

hello -- User-defined.
hook-print -- User-defined.
hook-stop -- User-defined.
start -- User-defined.
(gdb) 
  • ユーザ定義コマンドの引数は,❶ $arg0, $arg1... と参照します.
  • 例えば「毎回 b mainrを2回打つのは面倒だ」 という場合はユーザ定義コマンド❷ startを定義すると便利かも知れません. (ここでは使っていませんが) ifwhilesetを組み合わせて スクリプト的なユーザ定義コマンドも定義可能です.
  • hook-で始まるコマンド名は特別な意味を持ちます. 例えば,❸hook-printprintを実行するたびに実行されるユーザ定義コマンドになります.(ここでは試しにサイズ指定 bhwg の意味を表示しています).
  • hook-stopはプログラムが一時停止するたびに実行されるユーザ定義コマンドです.
  • help user-definedで,ユーザ定義コマンドの一覧を表示できます.

コマンドエイリアス

$ cat ~/.gdbinit
❶ alias di = disassemble
$ gdb ./a.out
(gdb) b main
Breakpoint 1 at 0x1151: file hello.c, line 5.
(gdb) r
Breakpoint 1, main () at hello.c:5
5	    printf ("hello\n");
(gdb) ❷ di
Dump of assembler code for function main:
   0x0000555555555149 <+0>:	endbr64 
   0x000055555555514d <+4>:	push   %rbp
   0x000055555555514e <+5>:	mov    %rsp,%rbp
=> 0x0000555555555151 <+8>:	lea    0xeac(%rip),%rax        # 0x555555556004
   0x0000555555555158 <+15>:	mov    %rax,%rdi
   0x000055555555515b <+18>:	call   0x555555555050 <puts@plt>
   0x0000555555555160 <+23>:	mov    $0x0,%eax
   0x0000555555555165 <+28>:	pop    %rbp
   0x0000555555555166 <+29>:	ret    
End of assembler dump.
(gdb) ❸ help di
disassemble, di
Disassemble a specified section of memory.
Usage: disassemble[/m|/r|/s] START [, END]
(以下略)
(gdb) ❹ help aliases
User-defined aliases of other commands.

List of commands:

di -- Disassemble a specified section of memory.
  • aliasコマンドでコマンドの別名を定義できます. ここではalias di = disassembleとして,❷diで逆アセンブルができるようにしました.
  • 素晴らしいことに,❸ help がユーザ定義のエイリアスに対応していて, help diでヘルプを表示できます.
  • また,❹ help aliasesでエイリアスの一覧を表示できます. (-aオプションで定義したエイリアスは,補完の対象にならず, エイリアス一覧にも表示されません).

プロセスのアタッチとデタッチ (attach, detach)

gdb -pオプションやattachを使うと,すでに実行中のプログラムを gdbの支配下に置けます(これをプロセスにアタッチするといいます).

// inf-loop.c
#include <stdio.h>
int main ()
{
    int x = 1, n = 0;
    while (x != 0) {
        n++;
    }
    printf ("hello, world\n");
}
$ ❶ sudo sysctl -w kernel.yama.ptrace_scope=0
$ gcc -g inf-loop.c
$ ./a.out
 ❷^Z
[1]+  Stopped                 ./a.out
$ ❸ bg
[1]+ ./a.out &
$ ps | egrep a.out
❹ 27373 pts/0    00:00:10 a.out
$ ❺ gdb -p 27373
Attaching to process 27373
main () at inf-loop.c:6
6	    while (x != 0) {
(gdb) bt
#0  main () at inf-loop.c:6
(gdb) ❻ kill
Kill the program being debugged? (y or n) y
[Inferior 1 (process 27373) killed]
(gdb) q
  • まず ❶ sudo sysctl -w kernel.yama.ptrace_scope=0として, プロセスへのアタッチを許可します.デフォルトでは以下のメッセージが出て アタッチができません.❶の操作はLinuxを再起動するまで有効です.
$ gdb -p 27373
Attaching to process 27373
Could not attach to process.  If your uid matches the uid of the target
process, check the setting of /proc/sys/kernel/yama/ptrace_scope, or try
again as the root user.  For more details, see /etc/sysctl.d/10-ptrace.conf
ptrace: Operation not permitted.
  • ここでは無限ループするinf-loop.cをコンパイルして実行します. ❷ctrl-zで実行をサスペンド(一時中断)して,❸bgで バックグラウンド実行にします. (別の端末からgdbを起動するなら,❷❸の作業は不要です)
  • psコマンドでa.outのプロセス番号を調べると❹27373と分かりました. ❺gdb -p 27373とすると,プロセス番号27373のプロセスを gdbが支配下に置きます(これをプロセスにアタッチすると言います).
  • ここでは単に killコマンドでa.outを終了させました. 終了させたくない場合は,調査後に detachするかgdbを終了すれば, a.outはそのまま実行を継続できます.
  • gdb起動後に,attachコマンドを使ってもアタッチできます.

コアファイルによる事後デバッグ

コアファイルとは

コアファイル(core file)あるいはコアダンプ(core dump)とは, 実行中のプロセスのメモリやレジスタの値を記録したファイルのことです. 再現性がないバグに対してコアファイルがあると, 後から何度でもそのコアファイルを使ってデバッグできるので便利です.

コアファイルのコアはメモリを意味する

コアファイルのコア (core)はメモリを意味します. これはかつて(大昔)のメモリが磁気コアだったことに由来します.

コアファイルを生成する設定

セキュリティ等の理由で,デフォルトの設定ではコアファイルが生成されません. 以下でコアファイルを生成する設定にできます.

$ ❶ ulimit -c unlimited
$ ❷ sudo sysctl -w kernel.core_pattern=core

❶でコアファイルのサイズを無制限に設定します.  ❷で,コアファイル名のパターンをcoreにします (生成されるファイル名は core.<pid> となります.<pid>はそのプロセスのプロセス番号です). ❶の設定はそのシェル内のみ,❷の設定はLinuxを再起動するまで有効です.

コアファイルで事後解析してみる

segmentation faultでクラッシュしたプログラムの事後解析をしてみます.

$ gcc -g segv.c
$ ./a.out
❶ Segmentation fault (core dumped)
$ ls -l core*
❷ -rw------- 1 gondow gondow 307200  8月 25 10:54 core.2224
$ ❸ gdb ./a.out core.2224
Reading symbols from ./a.out...
Core was generated by `./a.out'.
Program terminated with signal SIGSEGV, Segmentation fault.
#0  0x0000559ad81bb162 in main () at segv.c:6
6	    printf ("%d\n", *p); 
(gdb) p p
❹ $1 = (int *) 0xdeadbeef
(gdb) bt
#0  0x0000559ad81bb162 in main () at segv.c:6

segv.cをコンパルして実行すると,segmentation fault を起こし, コアファイルが作成されました(❷). gdbにコアファイル名も指定して起動すると(❸), segmentation faultが起きた状態でデバッグが可能になりました. 例えば,変数pの値を表示できています (❹ $1 = (int *) 0xdeadbeef).

注: 著者の環境(仮想マシンVMWare Fusion 上のUbuntu 22.04LTS,ホストマシン macOS 13.4)の共有フォルダ上で上記を行った場合, 作られたコアファイルのサイズが0になってしまいました. 共有フォルダではなく/tmpなどでは問題なくコアファイルが作られました.

動作中のプロセスのコアファイルを生成する

gcoreコマンドや,gdbgcoreコマンドで, 動作中のプロセスのコアファイルを生成できます.

$ gcc -g inf-loop.c 
$ ./a.out &
[1] 2325
$ ❶ sudo sysctl -w kernel.yama.ptrace_scope=0
kernel.yama.ptrace_scope = 0
$ ❷ gcore 2325
0x0000561775b05169 in main ()
Saved corefile core.2325
$ ❸ gdb ./a.out core.2325 
Reading symbols from ./a.out...
Core was generated by `./a.out'.
#0  main () at inf-loop.c:6
6	    while (x != 0) {

❶でアタッチを可能にする設定が必要です. gcoreコマンドが対象プログラムにアタッチするからです. gcoreコマンドでコアファイルを生成し(❷),gdbでコアファイルを指定すると(❸), 無事にデバッグ可能になりました.

$ gcc -g inf-loop.c 
$ gdb ./a.out
(gdb) r
Starting program: /tmp/a.out 
❶ ^C
Program received signal SIGINT, Interrupt.
main () at inf-loop.c:6
6	    while (x != 0) {
(gdb) ❷ gcore
Saved corefile core.2369

gdb上でもコアファイルを生成できます. gdb上でa.outを実行後,このプログラムは無限ループしてるので, ctrl-c (❶)で実行を中断してから, gcoreコマンドを使うと,コアファイルを生成できました.

キャッチポイント (catch)

キャッチポイントは様々なイベント発生時にブレークする仕組みです. キャッチポイントが扱えるイベントには, 例外,execforkvfork, システムコール(syscall), ライブラリのロード・アンロード(load, unload), シグナル (signal)などがあります.

システムコールをキャッチしてみる

$ gcc -g hello.c
$ gdb ./a.out
(gdb) ❶ catch syscall write
Catchpoint 1 (syscall 'write' [1])
(gdb) r

❷ Catchpoint 1 (call to syscall write), 0x00007ffff7d14a37 in __GI___libc_write (fd=1, buf=0x5555555592a0, nbytes=6) at ../sysdeps/unix/sysv/linux/write.c:26
26	../sysdeps/unix/sysv/linux/write.c: No such file or directory.
(gdb) ❸ bt
#0  0x00007ffff7d14a37 in __GI___libc_write (fd=1, buf=0x5555555592a0, 
    nbytes=6) at ../sysdeps/unix/sysv/linux/write.c:26
#1  0x00007ffff7c8af6d in _IO_new_file_write (
    f=0x7ffff7e1a780 <_IO_2_1_stdout_>, data=0x5555555592a0, n=6)
    at ./libio/fileops.c:1180
#2  0x00007ffff7c8ca61 in new_do_write (to_do=6, 
    data=0x5555555592a0 "hello\n", fp=0x7ffff7e1a780 <_IO_2_1_stdout_>)
    at ./libio/libioP.h:947
#3  _IO_new_do_write (to_do=6, data=0x5555555592a0 "hello\n", 
    fp=0x7ffff7e1a780 <_IO_2_1_stdout_>) at ./libio/fileops.c:425
#4  _IO_new_do_write (fp=fp@entry=0x7ffff7e1a780 <_IO_2_1_stdout_>, 
    data=0x5555555592a0 "hello\n", to_do=6) at ./libio/fileops.c:422
#5  0x00007ffff7c8cf43 in _IO_new_file_overflow (
    f=0x7ffff7e1a780 <_IO_2_1_stdout_>, ch=10) at ./libio/fileops.c:783
#6  0x00007ffff7c8102a in __GI__IO_puts (str=0x555555556004 "hello")
    at ./libio/ioputs.c:41
#7  0x0000555555555160 in main () at hello.c:5
(gdb) 

catch syscall writeで,writeシステムコールをキャッチしてみます. printfが最終的にはwriteシステムコールを呼ぶはずです. やってみたら,無事にキャッチできました(❷). バックトレースを見ると(❸),main関数からwriteが呼ばれるまでの  関数呼び出しを表示できました.

シグナルをキャッチしてみる (handle, catch signal)

handleを使う
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

void handler (int n)
{
    fprintf (stderr, "I am handler\n");
}

int main (void)
{
    signal (SIGUSR1, handler);
    while (1) {
        fprintf (stderr, ".");
        sleep (1);
    }
}
$ gcc -g sigusr1.c
$ gdb ./a.out
Reading symbols from ./a.out...
(gdb) ❶ handle SIGUSR1 
Signal        Stop	Print	Pass to program	Description
SIGUSR1       Yes	Yes	Yes		User defined signal 1
(gdb) r
❷ ........... 
❸ Program received signal SIGUSR1, User defined signal 1.
0x00007ffff7ce57fa in __GI___clock_nanosleep (clock_id=clock_id@entry=0, flags=flags@entry=0, req=req@entry=0x7fffffffde50, rem=rem@entry=0x7fffffffde50) at ../sysdeps/unix/sysv/linux/clock_nanosleep.c:78
78	../sysdeps/unix/sysv/linux/clock_nanosleep.c: No such file or directory.
(gdb) ❹ handle SIGUSR1 nostop noprint pass
Signal        Stop	Print	Pass to program	Description
SIGUSR1       No	No	Yes		User defined signal 1
(gdb) ❺ c
Continuing.
❻ I am handler
......I am handler
.....❼ ^C
Program received signal SIGINT, Interrupt.
0x00007ffff7ce57fa in __GI___clock_nanosleep (clock_id=clock_id@entry=0, flags=flags@entry=0, req=req@entry=0x7fffffffde50, rem=rem@entry=0x7fffffffde50) at ../sysdeps/unix/sysv/linux/clock_nanosleep.c:78
78	in ../sysdeps/unix/sysv/linux/clock_nanosleep.c
(gdb)
  • handle SIGUSR1とすると, gdbがシグナルSIGUSR1を受け取った時の処理設定が表示されます.

    • Stop Yes: gdba.outの実行を一時停止します.
    • Print Yes: gdbSIGUSR1を受け取ったことを表示します.
    • Pass Yes: gdba.outSIGUSR1を渡します.
  • ❷ 実行を開始すると,a.outは1秒ごとに.を出力しながらSIGUSR1を待ちます.

  • 別端末からa.outのプロセス番号を調べて(ここでは2696), kill -USR1 2696として,a.outSIGUSR1を送信しました. その結果,a.outの実行が一時停止し(❸),gdbに制御が戻りました.

  • 今度はSIGUSR1の設定を変えてやってみます ❹ handle SIGUSR1 nostop noprint passは, 「SIGUSR1で一時停止しない,表示もしない,a.outSIGUSR1を渡す」 という設定を意味します (stop, nostop, print, noprint, pass, nopassを指定可能です). gdbSIGUSR1を受け取った時, gdba.outを一時停止させず,SIGUSR1a.outに渡すはずです.

  • 実行を再開すると (❺ c), ❻ I am handlerが表示されています. これは先程送ったSIGUSR1に対してa.outのシグナルハンドラが出力した表示です. ここでもう一度, kill -USR1 2696として,a.outSIGUSR1を送信すると, (gdba.outを一時停止させること無く) 再度I am handlerが表示されました.期待した通りの動作です.

  • ctrl-C (❼ ^C)を押して,a.outの動作を一時停止しました.

catch signalを使う
$ gcc -g sigusr1.c
$ gdb ./a.out
Reading symbols from ./a.out...
(gdb) ❶ catch signal SIGUSR1
Catchpoint 1 (signal SIGUSR1)
(gdb) r
..........
❷ Catchpoint 1 (signal SIGUSR1), 0x00007ffff7ce57fa in __GI___clock_nanosleep (clock_id=clock_id@entry=0, flags=flags@entry=0, req=req@entry=0x7fffffffde50, rem=rem@entry=0x7fffffffde50) at ../sysdeps/unix/sysv/linux/clock_nanosleep.c:78
78	../sysdeps/unix/sysv/linux/clock_nanosleep.c: No such file or directory.
(gdb)
  • catch signal SIGUSR1 で,SIGUSR1をキャッチする設定をします.
  • 別端末から kill -USR1 2696として,a.outSIGUSR1を送信すると, 期待通り,SIGUSR1をキャッチしてa.outの実行が一時停止されました (❷ Catchpoint 1 (signal SIGUSR1)).
  • handlecatchもシグナルをキャッチできるのですが, catchhandleより嬉しいのは,catchを使うと 停止する条件や 停止時に実行する コマンドを設定できることです.
  • なお catch を設定すると,handlenostop設定は無視されます.

GDBのPythonプラグイン

PythonでGDBのユーザ定義コマンドを定義できます.

# gdb-script.py
class python_test (❶ gdb.Command):
    """Python Script Test"""

    def __init__ (self):
        super (python_test, self).__init__ (
            "python_test", gdb.COMMAND_USER
        )

    def invoke (self, args, from_tty):
        val = ❷ gdb.parse_and_eval (args)
        print ("args = " + args)
        print ("val  = " + str (val))
        ❸ gdb.execute ("p/x" + str (val) + "\n");

python_test ()        
  • 例えば上のgdb-script.pypython_testというユーザ定義コマンドを定義します. ~/.gdbinitなどでこのファイルをsource gdb-script.pyとして読み込む必要があります.
  • 定義するコマンドは❶gdb.Commandのサブクラスとして定義します. ❷ gdb.parse_and_evalを使えば与えられた引数をgdbの下で評価できます. ❸ gdb.executeを使えば,gdbのコマンドとして実行できます.
$ gdb ./a.out
(gdb) b main
Breakpoint 1 at 0x1180: file fact.c, line 13.
(gdb) r
Breakpoint 1, main () at fact.c:13
13	    printf ("%d\n", fact (5));
(gdb) ❹ python_test $rsp
args = $rsp
val  = 0x7fffffffde60
$1 = 0x7fffffffde60
(gdb) help user-defined
User-defined commands.
The commands in this class are those defined by the user.
Use the "define" command to define a command.

List of commands:

❺ python_test -- Python Script Test
(gdb) 
  • gdb上で定義したpython_testというコマンドを実行すると(❹) 意図通り実行できました(%rspの値が評価されて0x7fffffffde60になっています).
  • help user-definedすると,ちゃんと登録されていました(❺).

GDB/MI machine interface

gdbのMI(マシンインタフェース)とは gdbとのやり取りをプログラムで処理しやすくするためのモードです.

$ gdb --interpreter=mi ./a.out
=thread-group-added,id="i1"
=cmd-param-changed,param="auto-load safe-path",value="/"
~"Reading symbols from ./a.out...\n"
(gdb) 
❶ b main
❷ &"b main\n"
❸ ~"Breakpoint 1 at 0x1180: file fact.c, line 13.\n"
❹ =breakpoint-created,bkpt={number="1",type="breakpoint",disp="keep",enabled="y",addr="0x0000000000001180",func="main",file="fact.c",fullname="/mnt/hgfs/gondow/project/linux-x86-64-programming/src/asm/fact.c",line="13",thread-groups=["i1"],times="0",original-location="main"}
^done
(gdb) 
r
&"r\n"
~"Starting program: /mnt/hgfs/gondow/project/linux-x86-64-programming/src/asm/a.out \n"
=thread-group-started,id="i1",pid="5171"
=thread-created,id="1",group-id="i1"
=breakpoint-modified,bkpt={number="1",type="breakpoint",disp="keep",enabled="y",addr="0x0000555555555180",func="main",file="fact.c",fullname="/mnt/hgfs/gondow/project/linux-x86-64-programming/src/asm/fact.c",line="13",thread-groups=["i1"],times="0",original-location="main"}
=library-loaded,id="/lib64/ld-linux-x86-64.so.2",target-name="/lib64/ld-linux-x86-64.so.2",host-name="/lib64/ld-linux-x86-64.so.2",symbols-loaded="0",thread-group="i1",ranges=[{from="0x00007ffff7fc5090",to="0x00007ffff7fee335"}]
^running
*running,thread-id="all"
(gdb) 
=library-loaded,id="/lib/x86_64-linux-gnu/libc.so.6",target-name="/lib/x86_64-linux-gnu/libc.so.6",host-name="/lib/x86_64-linux-gnu/libc.so.6",symbols-loaded="0",thread-group="i1",ranges=[{from="0x00007ffff7c28700",to="0x00007ffff7dbaabd"}]
~"[Thread debugging using libthread_db enabled]\n"
~"Using host libthread_db library \"/lib/x86_64-linux-gnu/libthread_db.so.1\".\n"
=breakpoint-modified,bkpt={number="1",type="breakpoint",disp="keep",enabled="y",addr="0x0000555555555180",func="main",file="fact.c",fullname="/mnt/hgfs/gondow/project/linux-x86-64-programming/src/asm/fact.c",line="13",thread-groups=["i1"],times="1",original-location="main"}
~"\n"
~"Breakpoint 1, main () at fact.c:13\n"
~"13\t    printf (\"%d\\n\", fact (5));\n"
*stopped,reason="breakpoint-hit",disp="keep",bkptno="1",frame={addr="0x0000555555555180",func="main",args=[],file="fact.c",fullname="/mnt/hgfs/gondow/project/linux-x86-64-programming/src/asm/fact.c",line="13",arch="i386:x86-64"},thread-id="1",stopped-threads="all",core="1"
(gdb) 
quit
&"quit\n"
=thread-exited,id="1",group-id="i1"
  • gdbのMIは「CSVのようなもの」です.
  • b mainとブレークポイントの設定をすると, ❷ &"b main\n"と入力したコマンドが返り, その結果 ❸ ~"Breakpoint 1 at 0x1180: file fact.c, line 13.\n"と 付属情報が表示されます ❹ =breakpoint-created,bkpt={number="1",type="breakpoint",disp="keep",enabled="y",addr="0x0000000000001180",func="main",file="fact.c",fullname="/mnt/hgfs/gondow/project/linux-x86-64-programming/src/asm/fact.c",line="13",thread-groups=["i1"],times="0",original-location="main"} 各行は1行で,カンマ ,などの区切り子(デリミタ)で区切られており, プログラムで処理しやすい出力になっています.
  • JSONで出力してくればいいのにと思ったり. gdbのMI出力をJSONに変換するツールpygdbmi はあるようです(試していません).

遠隔デバッグ (gdbserver, target remote)

gdbは遠隔デバッグが可能です. 遠隔デバッグとは,デバッグ対象のプログラムが動作しているマシンとは 異なるマシン上でデバッグすることです. リソースが貧弱な組み込みシステムなどで,遠隔デバッグは有用です.

ここでは(簡単のため)ローカルホスト,つまり同じマシン上で遠隔デバッグをしてみます

まず予め gdbserverをインストールしておく必要があります.

$ sudo apt install gdbserver

gdbserverを使ってデバッグしたいプログラムa.outを起動します.

$ gdbserver :1234 ./a.out
Process ./a.out created; pid = 5195
Listening on port 1234

:1234は遠隔でバッグに使用するポート番号です.

$ ❶ gdb ./a.out
Reading symbols from ./a.out...
(gdb) ❷ target remote localhost:1234
Remote debugging using localhost:1234
Reading /lib64/ld-linux-x86-64.so.2 from remote target...
(gdb) ❸ c
Continuing.
Reading /lib/x86_64-linux-gnu/libc.so.6 from remote target...
[Inferior 1 (process 5195) exited normally]
(gdb) 
  • gdbを起動して(❶),デバッグ対象を 遠隔で対象はlocalhost:1234と指定します(❷). (localhostを省略して :1234だけ指定してもOKです).
  • デバッグ対象のプログラムはすでに実行を開始しているので, ❸cで実行を再開します.その後は通常のgdbと同様の操作が可能です.

トレースポイント (trace, actions, collect, tstart, tstop, tfind, tdump, tstatus)

通常,gdbを使う時はブレークポイントを使ってプログラムを一時的に停止させて, 対話的にデバッグ作業を行います. 一方,トレースポイントを使うとプログラムを一時停止させずに, プログラムの動作を観察できます. 手順は以下の通りです.

  1. 遠隔デバッグでプログラムを gdbの監視下に置きます. (現在,トレースポイントは遠隔デバッグでのみ有効です).
  2. tracecollectを使って,観察したい場所とデータを事前に設定します.
  3. tstarttstopを使って,プログラムのデータ収集の開始と停止を指示します.
  4. 事後にtfind, tdump, tstatusで収集したデータを調査します.
$ gcc -g -static fact.c
$ gdbserver :1234 ./a.out
Process ./a.out created; pid = 5696
Listening on port 1234

ここでは簡単のため静的リンクでコンパイルしたa.outを使って 遠隔でバッグの準備をします.

$ gdb ./a.out
Reading symbols from ./a.out...
(gdb) target remote :1234
Remote debugging using :1234
0x0000000000401620 in _start ()
(gdb) ❶ trace fact
Tracepoint 1 at 0x401754: file fact.c, line 5.
(gdb) ❷ actions
Enter actions for tracepoint 1, one per line.
End with a line saying just "end".
>❸ collect n
>end
(gdb) b 14
Breakpoint 2 at 0x4017a1: file fact.c, line 14.
(gdb) ❹ tstart
(gdb) c
Continuing.

Breakpoint 2, main () at fact.c:14
14	}
(gdb) ❺ tstop
(gdb) ❻ tstatus
Trace stopped by a tstop command ().
Collected 6 trace frames.
Trace buffer has 5237852 bytes of 5242880 bytes free (0% full).
Trace will stop if GDB disconnects.
Not looking at any trace frame.
Trace started at 135843.311816 secs, stopped 5.701432 secs later.
(gdb) ❼ tfind start
Found trace frame 0, tracepoint 1
#0  fact (n=5, n@entry=<error reading variable: PC not available>) at fact.c:5
5	    if (n <= 0)
(gdb) ❽ tdump
Data collected at tracepoint 1, trace frame 0:
n = 5
(gdb) ❾ tfind
Found trace frame 1, tracepoint 1
#0  fact (n=4, n@entry=<error reading variable: PC not available>) at fact.c:5
5	    if (n <= 0)
(gdb) tfind
Found trace frame 2, tracepoint 1
#0  fact (n=3, n@entry=<error reading variable: PC not available>) at fact.c:5
5	    if (n <= 0)
(gdb) tfind
Found trace frame 3, tracepoint 1
#0  fact (n=2, n@entry=<error reading variable: PC not available>) at fact.c:5
5	    if (n <= 0)
(gdb) tfind
Found trace frame 4, tracepoint 1
#0  fact (n=1, n@entry=<error reading variable: PC not available>) at fact.c:5
5	    if (n <= 0)
(gdb) tfind
Found trace frame 5, tracepoint 1
#0  fact (n=0, n@entry=<error reading variable: PC not available>) at fact.c:5
5	    if (n <= 0)
(gdb) quit
  • 事前準備をします. 関数factにトレースポイントを設定します(❶ trace fact). コマンド付きブレークポイントcomandsと同じ要領で, ❷ actionsを使って,トレースポイントで収集するデータや動作を指定します. ここでは単に引数$n$の値を収集します (❸ collect n).

  • tstartでデータの収集を開始します. continueでプログラムの実行を再開すると, トレースポイントにヒットした情報が集められます. ❺ tstopで収集を終了します.

  • 事後の調査をします. ❻ tstatusで収集状況を調べると, 6回トレースポイントにヒットしてデータを収集していました (Collected 6 trace frames). ❼ tfind startで最初の収集データを見ます. ❽ tdumpとするとその収集データの内容を全て表示します (が,ここでは引数nの値しか表示されません). 引数無しで ❾ tfindとすると,次の収集データを表示します. 引数nの値が,6から0まで変化したことが分かりました.

トレースポイントに関する付記:

  • traceにはifを使って ヒットする条件を指定可能です.
  • traceint3などのトラップ命令を使って計装(instrumentation)するので遅いです.ftraceを使うと5バイト長のジャンプ命令を使って計装するので高速になります (が,計装位置の命令長が5バイト以上必要です). (試していませんが)静的計装を行うstraceもあります.

実行の記録とリプレイ,逆実行 (record full, reverse-step)

gdbでは実行状態の記録とリプレイが可能です. またリプレイ機能を使って逆実行も可能です. リプレイでは実際には機械語命令の実行を行わず, 実行ログの内容を使って,メモリやレジスタの値を変化させます.

$ gcc -g fact.s
$ gdb ./a.out
Reading symbols from ./a.out...
(gdb) b main
Breakpoint 1 at 0x40177c: file fact.c, line 13.
(gdb) r
Breakpoint 1, main () at fact.c:13
13	    printf ("%d\n", fact (5));
(gdb) ❶ record full
(gdb) b fact if n==0
Breakpoint 2 at 0x401754: file fact.c, line 5.
(gdb) c
Continuing.

Breakpoint 2, fact (n=0) at fact.c:5
5	    if (n <= 0)
(gdb) reverse-TABTAB
reverse-continue  reverse-next      reverse-search    reverse-stepi
reverse-finish    reverse-nexti     reverse-step      
(gdb) ❷ reverse-next
8	        return n * fact (n - 1);
(gdb) (改行のみ入力,以下も同様)
5	    if (n <= 0)
(gdb) 
8	        return n * fact (n - 1);
(gdb) 
5	    if (n <= 0)
(gdb) 
8	        return n * fact (n - 1);
(gdb) ❸ print n
$1 = 3
(gdb) quit
  • record fullで実行状態の記録を開始します. ソフトウェア的に全実行状態を保存します. (当然,メモリを激しく消費します).

  • reverse-nextなど逆実行用のステップ実行を行うと, 逆実行できます(実際には実行はせず,元の状態に戻すだけですが). ❸ nの値が3の状態まで戻りました.

実行の記録とリプレの付記:

  • record fullではなく,record btrace ptなどとすると, ハードウェア機能(例えば,Intel PT)を使った高速な記録になりますが, リングバッファを使うため,バッファがあふれると古いデータは捨てられます.
  • record stopとすると実行の記録を止めて実行ログは破棄されます. 実行ログは record save ファイル名record restore ファイル名で 保存や回復が可能です.上の例だとファイルサイズは約700KBでした.
  • x86-64ではAVX命令などが非対応のようです. 例えば,AVX512のvmovdqu命令を試すと,以下のエラーとなりました(2023/8/27現在).
main () at movdqu.s:23
23	vmovdqu (%rsp), %ymm0
(gdb) 
Process record does not support instruction 0xc5 at address 0x555555555171.
Process record: failed to record execution log.
(gdb) 

glibcなどのライブラリは-O2などでコンパイルされているため, AVX命令が使われることが多くあります. 試しにhello.cで試した所,同じエラーとなりました.

(gdb) n
5	    printf ("hello\n");
(gdb) n
Process record does not support instruction 0xc5 at address 0x7ffff7d9d969.
Process record: failed to record execution log.

x86-64 命令一覧

概要と記号

add5.cgcc -S add5.cでコンパイルした結果 add5.s(余計なものの削除後)を用いて, x86-64アセンブリ言語の概要と記号を説明します.

  • $ gcc -S add5.cを実行(コンパイル)すると,アセンブリコードadd5.sが生成されます.(結果は環境依存なので,add5.sの中身が違ってても気にしなくてOK)
  • ドット記号 . で始まるのはアセンブラ命令(assembler directive)です.
  • コロン記号 : で終わるのはラベル定義です.
  • シャープ記号 # から行末までと,/**/で囲まれた範囲(複数行可)はコメントです.
  • movq %rsp, %rbp機械語命令(2進数)の記号表現(ニモニック(mnemonic))です.movqが命令でオペコード(opcode),%rsp%rbpは引数でオペランド(operand)と呼ばれます.
  • ドル記号 $ で始まるのは即値(immediate value,定数)です.
  • パーセント記号 %で始まるのはレジスタです.
  • -4(%rbp)間接メモリ参照です.この場合は「%rbp-4の計算結果」をアドレスとするメモリの内容にアクセスすることを意味します.

命令サフィックス

AT&T形式の
サイズ指定
Intel形式の
サイズ指定
メモリオペランドの
サイズ
AT&T形式での例Intel形式での例
bBYTE PTR1バイト(8ビット)movb $10, -8(%rbp)mov BYTE PTR [rbp-8], 10
wWORD PTR2バイト(16ビット)movw $10, -8(%rbp)mov WORD PTR [rbp-8], 10
lDWORD PTR4バイト(32ビット)movl $10, -8(%rbp)mov DWORD PTR [rbp-8], 10
qQWORD PTR8バイト(64ビット)movq $10, -8(%rbp)mov QWORD PTR [rbp-8], 10
  • 間接メモリ参照ではサイズ指定が必要です (「何バイト読み書きするのか」が決まらないから)
  • AT&T形式では命令サフィックス(instruction suffix)でサイズを指定します. 例えばmovq $10, -8(%rbp)qは, 「メモリ参照-8(%rbp)への書き込みサイズが8バイト」であることを意味します.
サフィックスとプリフィックス

サフィックス(suffix)は接尾語(後ろに付けるもの), プリフィックス(prefix)は接頭語(前に付けるもの)という意味です.

  • Intel形式ではメモリ参照の前に,BYTE PTRなどと指定します.
  • 他のオペランドからサイズが決まる場合は命令サフィックスを省略可能です. 例えば,movq %rax, -8(%rsp)mov %rax, -8(%rsp)にできます. mov命令の2つのオペランドはサイズが同じで %raxレジスタのサイズが8バイトだからです.

即値(定数)

種類説明
10進数定数0で始まらないpushq $74
16進数定数0x0Xで始まるpushq $0x4A
8進数定数0で始まるpushq $0112
2進数定数0b0Bで始まるpushq $0b01001010
文字定数'(クオート)で始まるpushq $'J
'(クオート)で囲むpushq $'J'
\バックスラッシュ
でエスケープ可
pushq $'\n
  • 即値(immediate value,定数)には$をつけます
  • 上の例の定数は最後以外は全部,値が同じです
  • GNUアセンブラでは文字定数の値はASCIIコードです. 上の例では,文字'J'の値は74です.
  • バックスラッシュでエスケープできる文字は, \b, \f, \n, \r, \t, \", \\ です. また\123は8進数,\x4Fは16進数で指定した文字コードになります.
  • 多くの場合,即値は32ビットまでで, オペランドのサイズが64ビットの場合, 32ビットの即値は,64ビットの演算前に 64ビットに符号拡張 されます (ゼロ拡張だと 負の値が大きな正の値になって困るから)
64ビットに符号拡張される例(1)
# asm/add-imm2.s
    .text
    .globl main
    .type main, @function
main:
    movq $0, %rax
    addq $-1, %rax
    ret
    .size main, .-main

上のaddq $-1, %rax命令の即値$-1は 32ビット(以下の場合もあります)の符号あり整数$0xFFFFFFFFとして 機械語命令に埋め込まれます. addq命令が実行される時は, この$0xFFFFFFFFが64ビットに符号拡張されて$0xFFFFFFFFFFFFFFFFとなります. 以下の実行例でもこれが確認できました.

0 + 0xFFFFFFFFFFFFFFFF = 0xFFFFFFFFFFFFFFFF
$ gcc -g add-imm2.s
$ gdb ./a.out -x add-imm2.txt
Breakpoint 1, main () at add-imm2.s:8
8	    ret
7	    addq $-1, %rax
$1 = 0xffffffffffffffff
# 0xffffffffffffffff が表示されていれば成功
64ビットに符号拡張される例(2)

32ビットの即値が,64ビットの演算前に64ビットに符号拡張されることを見てみます. 32ビット符号あり整数が表現できる範囲は-0x80000000から0x7FFFFFFFです.

# asm/add-imm.s
    .text
    .globl main
    .type main, @function
main:
    movq $0, %rax
    addl $0xFFFFFFFF, %eax          # OK (0xFFFFFFFF = -1)
#    addq $0xFFFFFFFF, %rax         # NG (0x7FFFFFFFを超えている)
    addq $0xFFFFFFFFFFFFFFFF, %rax  # OK (0xFFFFFFFFFFFFFFFF = -1)
    addq $0x7FFFFFFF, %rax          # OK
    addq $-0x80000000, %rax         # OK
    addq $0xFFFFFFFF80000000, %rax  # OK (0xFFFFFFFF80000000 = -0x80000000)
    ret
    .size main, .-main
  • 7行目の$0xFFFFFFFFは32ビット符号あり整数として-1, つまり32ビット符号あり整数が表現できる範囲内なのでOKです.

  • 一方,8行目の$0xFFFFFFFFは 64ビット符号あり整数として$0x7FFFFFFFを超えてるのでNGです. (アセンブルするとエラーになります)

  • 9行目の$0xFFFFFFFFFFFFFFFFは一見NGですが, 64ビット符号あり整数としての値は-1なので, GNUアセンブラはこの即値を-1として機械語命令に埋め込みます.

  • いちばん大事なのは最後の2つのaddq命令です. addq $-0x80000000, %raxの 即値$-0x80000000は(機械語命令中では32ビットで埋め込まれますが) 足し算を実行する前に64ビットに符号拡張されるので, $0xFFFFFFFF80000000という値で足し算されます. (つまり,$0x80000000を引きます). 以下の実行例を見ると,その通りの実行結果になっています.

    • ❶ 0x17ffffffd - $0x80000000 = ❷ 0xfffffffd
    • ❷ 0xfffffffd - $0x80000000 = ❸ 0x7ffffffd

    一方,もし $-0x80000000を(符号拡張ではなく) ゼロ拡張 すると, $0x0000000080000000となるので, $0x80000000を(引くのではなく)足すことになってしまいます.

$ gcc -g add-imm.s
$ gdb ./a.out -x add-imm.txt
Breakpoint 1, main () at add-imm.s:7
7	    addl $0xFFFFFFFF, %eax          # OK (0xFFFFFFFF = -0x1)
9	    addq $0xFFFFFFFFFFFFFFFF, %rax  # OK (0xFFFFFFFFFFFFFFFF = -0x1)
1: /x $rax = 0xffffffff
10	    addq $0x7FFFFFFF, %rax          # OK
1: /x $rax = 0xfffffffe
11	    addq $-0x80000000, %rax         # OK
1: /x $rax = ❶ 0x17ffffffd
12	    addq $0xFFFFFFFF80000000, %rax  # OK (0xFFFFFFFF80000000 = -0x80000000)
1: /x $rax = ❷ 0xfffffffd
main () at add-imm.s:13
13	    ret
1: /x $rax = ❸ 0x7ffffffd
#以下が表示されていれば成功
#1: /x $rax = 0xffffffff
#1: /x $rax = 0xfffffffe
#1: /x $rax = 0x17ffffffd
#1: /x $rax = 0xfffffffd
#1: /x $rax = 0x7ffffffd

以下の通り,逆アセンブルすると, 32ビット以下の即値として機械語命令中に埋め込まれていることが分かります.

$ gcc -g add-imm.s
$ objdump -d ./a.out
0000000000001129 <main>:
    1129:	48 c7 c0 00 00 00 00 	mov    $0x0,%rax
    1130:	83 c0 ff             	add    $0xffffffff,%eax
    1133:	48 83 c0 ff          	add    $0xffffffffffffffff,%rax
    1137:	48 05 ff ff ff 7f    	add    $0x7fffffff,%rax
    113d:	48 05 00 00 00 80    	add    $0xffffffff80000000,%rax
    1143:	48 05 00 00 00 80    	add    $0xffffffff80000000,%rax
    1149:	c3                   	ret    
  • mov命令は例外で64ビットの即値を扱えます
64ビットの即値を扱う例
# asm/movqabs-1.s
    .text
    .globl main
    .type main, @function
main:
    movq $0x1122334455667788, %rax
    movabsq $0x99AABBCCDDEEFF00, %rax
    ret
    .size main, .-main
$ gcc -g movqabs-1.s
$ gdb ./a.out -x movqabs-1.txt
Breakpoint 1, main () at movqabs-1.s:6
6	    movq $0x1122334455667788, %rax
7	    movabsq $0x99AABBCCDDEEFF00, %rax
1: /x $rax = 0x1122334455667788
main () at movqabs-1.s:8
8	    ret
1: /x $rax = 0x99aabbccddeeff00
# 以下が表示されれば成功
# 1: /x $rax = 0x1122334455667788
# 1: /x $rax = 0x99aabbccddeeff00
  • ジャンプでは64ビットの相対ジャンプができないので, 間接ジャンプを使う必要がある
64ビットの相対ジャンプの代わりに間接ジャンプを使う例
# asm/jmp-64bit.s
    .text
    .globl main
    .type main, @function
main:
#    jmp 0x1122334455667788           # NG
    movq $0x1122334455667788, %rax
    jmp *%rax
    ret
    .size main, .-main

0x1122334455667788はいい加減なアドレスなので, コンパイルは可能ですが,実行すると segmentation fault になります.

レジスタ

汎用レジスタ

  • 上記16個のレジスタが汎用レジスタ(general-purpose register)です. 原則として,プログラマが自由に使えます.
  • ただし,%rspスタックポインタ%rbpベースポインタと呼び, 一番上のスタックフレームの上下を指す という役割があります. (ただし,-fomit-frame-pointer オプションでコンパイルされたa.out中では,%rbpはベースポインタとしてではなく, 汎用レジスタとして使われています).

caller-save/callee-saveレジスタ

汎用レジスタ
caller-saveレジスタ%rax, %rcx, %rdx, %rsi, %rdi, %r8%r11
callee-saveレジスタ%rbx, %rbp, %rsp, %r12%r15

引数

引数レジスタ
第1引数%rdi
第2引数%rsi
第3引数%rdx
第4引数%rcx
第5引数%r8
第6引数%r9
  • 第7引数以降はレジスタではなくスタックを介して渡します

プログラムカウンタ(命令ポインタ)

ステータスレジスタ(フラグレジスタ)

本書で扱うフラグ

ステータスレジスタのうち,本書は以下の6つのフラグを扱います.

フラグ名前説明
CFキャリーフラグ算術演算で結果の最上位ビットにキャリーかボローが生じるとセット.それ以外はクリア.符号なし整数演算でのオーバーフロー状態を表す.
OFオーバーフローフラグ符号ビット(MSB)を除いて,整数の演算結果が大きすぎるか小さすぎるかするとセット.それ以外はクリア.2の補数表現での符号あり整数演算のオーバーフロー状態を表す.
ZFゼロフラグ結果がゼロの時にセット.それ以外はクリア.
SF符号フラグ符号あり整数の符号ビット(MSB)と同じ値をセット.(0は正の数,1は負の数であることを表す)
PFパリティフラグ結果の最下位バイトの値1のビットが偶数個あればセット,奇数個であればクリア.
AF調整フラグ算術演算で,結果のビット3にキャリーかボローが生じるとセット.それ以外はクリア.BCD演算で使用する(本書ではほとんど使いません).

ステータスフラグの変化の記法

x86-64命令を実行すると,ステータスフラグが変化する命令と 変化しない命令があります. ステータスフラグの変化は以下の記法で表します.

CFOFSFZFPFAF
 !?01

記法の意味は以下の通りです.

記法意味
空白フラグ値に変化なし
!フラグ値に変化あり
?フラグ値は未定義(参照禁止)
0フラグ値はクリア(0になる)
1フラグ値はセット(1になる)

レジスタの別名

%raxレジスタの別名 (%rbx, %rcx, %rdxも同様)

  • %raxの下位32ビットは%eaxとしてアクセス可能
  • %eaxの下位16ビットは%axとしてアクセス可能
  • %axの上位8ビットは%ahとしてアクセス可能
  • %axの下位8ビットは%alとしてアクセス可能

%rbpレジスタの別名 (%rsp, %rdi, %rsiも同様)

  • %rbpの下位32ビットは%ebpとしてアクセス可能
  • %ebpの下位16ビットは%bpとしてアクセス可能
  • %bpの下位8ビットは%bplとしてアクセス可能

%r8レジスタの別名 (%r9%r15も同様)

  • %r8の下位32ビットは%r8dとしてアクセス可能
  • %r8dの下位16ビットは%r8wとしてアクセス可能
  • %r8wの下位8ビットは%r8bとしてアクセス可能

同時に使えない制限

  • 一部のレジスタは%ah, %bh, %ch, %dhと一緒には使えない.
  • 例:movb %ah, (%r8)movb %ah, %bplはエラーになる.
  • 正確にはREXプリフィクス付きの命令では,%ah, %bh, %ch, %dhを使えない.

32ビットレジスタ上の演算は64ビットレジスタの上位32ビットをゼロにする

  • 例:movl $0xAABBCCDD, %eaxを実行すると%raxの上位32ビットが全てゼロになる
  • 例: movw $0x1122, %axmovb $0x11, %alでは上位をゼロにすることはない
上位32ビットをゼロにする実行例
# asm/zero-upper32.s
    .text
    .globl main
    .type main, @function
main:
    movq $0x1122334455667788, %rax
    movl $0xAABBCCDD, %eax
    movq $0x1122334455667788, %rax
    movw $0x1122, %ax
    movq $0x1122334455667788, %rax
    movb $0x11, %al
    ret
    .size main, .-main
$ gcc -g zero-upper32.s
$ gdb ./a.out -x zero-upper32.txt
Breakpoint 1, main () at zero-upper32.s:7
7	    movl $0xAABBCCDD, %eax
6	    movq $0x1122334455667788, %rax
7	    movl $0xAABBCCDD, %eax
$1 = 0x1122334455667788
8	    movq $0x1122334455667788, %rax
$2 = 0x00000000aabbccdd
# 以下が出力されれば成功
# $1 = 0x1122334455667788 (%raxは8バイトの値を保持)
# $2 = 0x00000000aabbccdd (%raxの上位4バイトがゼロになった)

アドレッシングモード(オペランドの記法)

アドレッシングモードの種類

アドレッシング
モードの種類
オペランドの値計算するアドレス

即値(定数)

定数の値movq $0x100, %rax
movq $foo, %rax

レジスタ参照

レジスタの値movq %rbx, %rax

直接メモリ参照

定数で指定した
アドレスのメモリ値
movq 0x100, %rax0x100
movq foo, %raxfoo

間接メモリ参照

レジスタ等で計算した
アドレスのメモリ値
movq (%rsp), %rax%rsp
movq 8(%rsp), %rax%rsp+8
movq foo(%rip), %rax%rip+foo
  • fooはラベルであり,定数と同じ扱い.(定数を書ける場所にはラベルも書ける).
  • メモリ参照では例えば-8(%rbp, %rax, 8)など複雑なオペランドも指定可能. 参照するメモリのアドレスは-8+%rbp+%rax*8になる. (以下を参照).

メモリ参照の形式

AT&T形式Intel形式計算されるアドレス
通常のメモリ参照disp (base, index, scale)[base + index * scale + disp]base + index * scale + disp
%rip相対参照disp (%rip)[rip + disp]%rip + disp

注: Intelのマニュアルには「segment: メモリ参照」という形式もあるとありますが, segment: はほとんど使わないので,省いて説明します.

segmentは使わないの?(いえ,ちょっと使います)
segment(正確にはsegment-override)はx86-64ではほとんど使いません. が,segmentを使ったメモリ参照をする場合があります. 例えば,`%fs:0xfffffffffffffffc`がその例です.

%fsセグメントレジスタと呼ばれる16ビット長のレジスタで, 他には%cs%ds%ss%es%gsがあります. x86-64では%cs%ds%ss%esは常にベースアドレスが0と扱われます. %fs:という記法が使われた時は, 「%fsレジスタが示すベースアドレスをアクセスするアドレスに加える」 ことを意味します.

%fsのベースレジスタの値を得るには次の方法があります.

  • arch_prctl()システムコールを使う (ここでは説明しません).
  • gdbp/x $fs_baseを実行する. (なお,p/x $fsを実行すると0が返りますがこの値は嘘です)
  • rdfsbase命令を使う.
rdfsbase命令はCPUとOSの設定に依存

rdfsbase命令が使えるかどうかは,CPUとOSの設定に依存します. /proc/cpuinfoflagsの表示にfsgsbaseがあれば,rdfsbase命令は使えます. (以下の出力例ではfsgsbaseが入っています).

$ less /proc/cpuinfo
processor       : 0
cpu family      : 6
model name      : Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz
(一部略)
flags           : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss syscall nx pdpe1gb rdtscp lm constant_tsc arch_perfmon nopl xtopology tsc_reliable nonstop_tsc cpuid tsc_known_freq pni pclmulqdq ssse3 fma cx16 pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor lahf_lm abm 3dnowprefetch cpuid_fault invpcid_single ssbd ibrs ibpb stibp ibrs_enhanced fsgsbase tsc_adjust bmi1 avx2 smep bmi2 invpcid rdseed adx smap clflushopt xsaveopt xsavec xgetbv1 xsaves arat md_clear flush_l1d arch_capabilities

Linuxでは%fs:%gs:を使って スレッドローカルストレージ(TLS)を実現しています. スレッドローカルストレージとは「スレッドごとの(一種の)グローバル変数」です. マルチスレッドはスレッド同士がメモリ空間を共有しているので, 普通にグローバル変数を使うと,他のスレッドに内容が破壊される可能性があります. スレッドローカルストレージを使えば,他のスレッドに破壊される心配がなくなります. スレッドごとに%fsレジスタの値を変えて, (プログラム上では同じ変数に見えても)スレッドごとに別のアドレスを 参照させて実現しているのでしょう. (CPUがスレッドの実行を停止・再開するたびに, スレッドが使用しているレジスタの値も退避・回復するので, 見た目上,「スレッドはそれぞれ独自のレジスタセットを持っている」ように動作します).

C11からは_Thread_localgccでは__threadというキーワードで, スレッドローカルな変数を宣言できます.

// tls.c
#include <stdio.h>
❶ __thread int x = 0xdeadbeef;
int main ()
{
    printf ("x=%x\n", x);
}
$ gcc -g tls.c
$ ./a.out
x=deadbeef
$ objdump -d ./a.out | less
0000000000001149 <main>:
    1149:  f3 0f 1e fa             endbr64 
    114d:  55                      push   %rbp
    114e:  48 89 e5                mov    %rsp,%rbp
    1151:  64 8b 04 25 fc ff ff    mov  ❷ %fs:0xfffffffffffffffc,%eax
    1158:  ff 
    1159:  89 c6                   mov    %eax,%esi
$ gdb ./a.out
(gdb) b main
Breakpoint 1 at 0x1151: file tls.c, line 5.
(gdb) r
Breakpoint 1, main () at tls.c:5
5	    printf ("x=%x\n", x);
(gdb) p/x x
$1 = 0xdeadbeef
(gdb) ❸ p/x $fs_base
$2 = ❹ 0x7ffff7fa9740
(gdb) x/1wx $fs_base - 4
0x7ffff7fa973c:	❺ 0xdeadbeef
  • __threadキーワードを使って,変数xをスレッドローカルにします.
  • コンパイルしたa.outを逆アセンブルすると, ❷ %fs:0xfffffffffffffffcというメモリ参照があります. これがスレッドローカルな変数xの実体の場所で, 「%fsのベースレジスタ - 4」がxのアドレスになります.
  • p/x $fs_baseを使って,%fsのベースレジスタの値を調べると ❹ 0x7ffff7fa9740と分かりました.
  • アドレス$fs_base - 4のメモリの中身(4バイト)を調べると, 変数xの値である❺ 0xDEADBEEFが入っていました.
0xDEADBEEFとは

バイナリ上でデバッグする際,「ありそうもない値」を使うと便利なことがあります. 12だと偶然の一致がありますが,「ありそうもない値」を使うと 「高い確率でこれは私が書き込んだ値だよね」と分かるからです. 0xDEADBEEFは正しい16進数でありながら,英単語としても読めるので, 「ありそうもない値」として便利です. 他にはCAFEBABEもよく使われます. 0xDEADBEEF0xCAFEBABEはバイナリを識別するマジックナンバーとしても使われます.

%fs:はスタック保護でも使われる
$ gcc -S -fstack-protector-all add5.c

add5.c-fstack-protector-allオプションで スタック保護機能をオンにしてコンパイルします. (最近のLinuxのgccのデフォルトでは,-fstack-protector-strongが有効に なっています.これはバッファを使用する関数のみにスタック保護機能を加えます. ここでは-fstack-protector-allを使って全関数にスタック保護機能を加えました).

    .text
    .globl  add5
    .type   add5, @function
add5:
    endbr64
    pushq   %rbp
    movq    %rsp, %rbp
    subq    $32, %rsp
    movl    %edi, -20(%rbp)
    movq ❶ %fs:40, %rax
    movq ❷ %rax, -8(%rbp)
    xorl    %eax, %eax
    movl    -20(%rbp), %eax
    addl    $5, %eax
    movq ❸ -8(%rbp), %rdx
    subq ❹ %fs:40, %rdx
    je      .L3
    call ❺ __stack_chk_fail@PLT
.L3:
    leave   
    ret     
    .size   add5, .-add5
  • 関数の最初の方で,スレッドローカルストレージの❶ %fs:40の値を, (%rax経由で)スタック上の-8(%rbp)に書き込みます.
  • 関数の終わりの方で,❸-8(%rbp)の値を%rdxレジスタにコピーし, ❹ %fs:40の値を引き算しています.
  • もし,引き算の結果がゼロでなければ(つまり❸と❹の値が異なれば), 「バッファオーバーフローが起きた」と判断して, ❺ __stack_chk_fail@PLTを呼び出して プロセスを強制終了させます(つまりバッファオーバーフロー攻撃を防げたことになります).

メモリ参照で可能な組み合わせ(64ビットモードの場合)

通常のメモリ参照

  • disp には符号あり定数を指定する.ただし「64ビット定数」は無いことに注意. アドレス計算時に64ビット長に符号拡張される. dispは変位(displacement)を意味する.
  • base には上記のいずれかのレジスタを指定可能.省略も可.
  • index には上記のいずれかのレジスタを指定可能.省略も可. %rspを指定できないことに注意.
  • scale を省略すると 1 と同じ

注: dispの例外. mov␣命令のみ,64ビットのdispを指定可能. この場合,movabs␣というニモニックを使用可能. メモリ参照はdispのみで,base,index,scaleは指定不可. 他方のオペランドは%raxのみ指定可能.

movq     0x1122334455667788, %rax
movabsq  0x1122334455667788, %rax
movq     %rax, 0x1122334455667788
movabsq  %rax, 0x1122334455667788

%rip相対参照

%rip相対参照では32ビットのdispと%ripレジスタのみが指定可能です.

メモリ参照の例

AT&T形式Intel形式指定したもの計算するアドレス
8[8]disp8
foo[foo]dispfoo
(%rbp)[rbp]base%rbp
8(%rbp)[rbp+8]dispとbase%rbp + 8
foo(%rbp)[rbp+foo]dispとbase%rbp + foo
8(%rbp,%rax)[rbp+rax+8]dispとbaseとindex%rbp + %rax + 8
8(%rbp,%rax, 2)[rbp+rax*2+8]dispとbaseとindexとscale%rbp + %rax*2 + 8
(%rip)[rip]base%rip
8(%rip)[rip+8]dispとbase%rip + 8
foo(%rip)[rip+foo]dispとbase%rip + foo
%fs:-4fs:[-4]segmentとdisp%fsのベースレジスタ - 4
  • メモリに読み書きするサイズの指定方法 (先頭アドレスだけだと,何バイト読み書きすればいいのか不明):
    • AT&T形式では命令サフィックス (q, l, w, b)で指定する.例: movq $4, 8(%rbp)

    • Intel形式では,メモリ参照の前に QWORD PTR, DWORD PTR, WORD PTR, BYTE PTRを付加する (順番に,8バイト,4バイト,2バイト,1バイトを意味する). 例: `mov QWORD PTR [rbp+8], 4

「記法」「詳しい記法」欄で用いるオペランドの記法と注意

以下の機械語命令の説明で使う記法を説明します. この記法はその命令に許されるオペランドの形式を表します.

オペランド,即値(定数)

記法説明
op1第1オペランド
op2第2オペランド
imm$100imm8, imm16, imm32のどれか
$foo
imm8$1008ビットの即値(定数)
imm16$10016ビットの即値(定数)
imm32$10032ビットの即値(定数)
  • 多くの場合,サイズを省略して単にimmと書きます. 特にサイズに注意が必要な時だけ,imm32などとサイズを明記します.
  • 一部例外を除き, x86-64では64ビットの即値を書けません(32ビットまでです).

汎用レジスタ

記法説明
r%raxr8, r16, r32, r64のどれか
r8%al8ビットの汎用レジスタ
r16%ax16ビットの汎用レジスタ
r32%eax32ビットの汎用レジスタ
r64%rax64ビットの汎用レジスタ

メモリ参照

記法説明
r/m%rbpr/m8, r/m16, r/m32, r/m32, r/m64のどれか
100
-8(%rbp)
foo(%rbp)
r/m8-8(%rbp)r8 または 8ビットのメモリ参照
r/m16-8(%rbp)r16 また は16ビットのメモリ参照
r/m32-8(%rbp)r32 また は32ビットのメモリ参照
r/m64-8(%rbp)r64 また は64ビットのメモリ参照
m-8(%rbp) メモリ参照

ジャンプ・コール用のオペランド

記法説明
rel0x100rel8, rel32のどちらか
foo
rel80x1008ビット相対アドレス(直接ジャンプ,定数だが$は不要)
rel320x100032ビット相対アドレス(直接ジャンプ,定数だが$は不要)
*r/m64*%raxr64 または64ビットのメモリ参照による絶対アドレス
(間接ジャンプ,*が前に必要)
*(%rax)
*-8(%rax)
  • GNUアセンブラの記法のおかしな点

    • 直接ジャンプ先の指定relは,定数なのに$をつけてはいけない
    • 間接ジャンプ先の指定**r/m64は, (他のr/m*オペランドでは不要だったのに) *をつけなくてはいけない
    • 相対アドレスでrel8rel32をプログラマは選べない (jmp命令に命令サフィックスblをつけると怒られる.アセンブラにお任せするしか無い)
  • *%rax*(%rax)の違いに注意(以下の図を参照)

データ転送(コピー)系の命令

mov命令: データの転送(コピー)


記法何の略か動作
mov␣ op1, op2moveop1の値をop2にデータ転送(コピー)

詳しい記法例の動作サンプルコード
mov␣ r, r/mmovq %rax, %rbx%rbx = %raxmovq-1.s movq-1.txt
movq %rax, -8(%rsp)*(%rsp - 8) = %raxmovq-2.s movq-2.txt
mov␣ r/m, rmovq -8(%rsp), %rax%rax = *(%rsp - 8)movq-3.s movq-3.txt
mov␣ imm, rmovq $999, %rax%rax = 999movq-4.s movq-4.txt
mov␣ imm, r/mmovq $999, -8(%rsp)*(%rsp - 8) = 999movq-5.s movq-5.txt


  • 命令サフィックス
  • mov命令(および他のほとんどのデータ転送命令)はステータスフラグの値を変更しない
  • mov命令はメモリからメモリへの直接データ転送はできない

xchg命令: オペランドの値を交換


記法何の略か動作
xchg op1, op2exchangeop1op2 の値を交換する

詳しい記法例の動作サンプルコード
xchg r, r/mxchg %rax, (%rsp)%rax(%rsp)の値を交換するxchg.s xchg.txt
xchg r/m, rxchg (%rsp), %rax(%rsp)%raxの値を交換するxchg.s xchg.txt

  • xchg命令はアトミックに2つのオペランドの値を交換します.(LOCKプリフィクスをつけなくてもアトミックになります)
  • このアトミックな動作はロックなどの同期機構を作るために使えます.

lea命令: 実効アドレスを計算


記法何の略か動作
lea␣ op1, op2load effective addressop1 の実効アドレスを op2 に代入する

詳しい記法例の動作サンプルコード
lea␣ m, rleaq -8(%rsp, %rsi, 4), %rax%rax=%rsp+%rsi*4-8lea.s lea.txt

pushpop命令: スタックとデータ転送


記法何の略か動作
push␣ op1pushop1 をスタックにプッシュ
pop␣ op1popスタックから op1 にポップ

詳しい記法例の動作サンプルコード
push␣ immpushq $999%rsp-=8; *(%rsp)=999push1.s push1.txt
push␣ r/m16pushw %ax%rsp-=2; *(%rsp)=%axpush2.s push2.txt
push␣ r/m64pushq %rax%rsp-=8; *(%rsp)=%raxpush-pop.s push-pop.txt
pop␣ r/m16popw %ax*(%rsp)=%ax; %rsp += 2pop2.s pop2.txt
pop␣ r/m64popq %rbx%rbx=*(%rsp); %rsp += 8push-pop.s push-pop.txt

四則演算・論理演算の命令

add, adc命令: 足し算


記法何の略か動作
add␣ op1, op2addop1op2 に加える
adc␣ op1, op2add with carryop1 と CF を op2 に加える

詳しい記法例の動作サンプルコード
add␣ imm, r/maddq $999, %rax%rax += 999sub-1.s sub-1.txt
add␣ r, r/maddq %rax, (%rsp)*(%rsp) += %raxadd-2.s add-2.txt
add␣ r/m, raddq (%rsp), %rax%rax += *(%rsp)add-2.s add-2.txt
adc␣ imm, r/madcq $999, %rax%rax += 999 + CFadc-1.s adc-1.txt
adc␣ r, r/madcq %rax, (%rsp)*(%rsp) += %rax + CFadc-2.s adc-2.txt
adc␣ r/m, radcq (%rsp), %rax%rax += *(%rsp) + CFadc-3.s adc-3.txt

CFOFSFZFPFAF
!!!!!!
  • addadcはオペランドが符号あり整数か符号なし整数かを区別せず, 両方の結果を正しく計算する.

sub, sbb命令: 引き算


記法何の略か動作
sub␣ op1, op2subtractop1op2 から引く
sbb␣ op1, op2subtract with borrowop1 と CF を op2 から引く

詳しい記法例の動作サンプルコード
sub␣ imm, r/msubq $999, %rax%rax -= 999sub-1.s sub-1.txt
sub␣ r, r/msubq %rax, (%rsp)*(%rsp) -= %raxsub-2.s sub-2.txt
sub␣ r/m, rsubq (%rsp), %rax%rax -= *(%rsp)sub-2.s sub-2.txt
sbb␣ imm, r/msbbq $999, %rax%rax -= 999 + CFsbb-1.s sbb-1.txt
sbb␣ r, r/msbbq %rax, (%rsp)*(%rsp) -= %rax + CFsbb-2.s sbb-2.txt
sbb␣ r/m, rsbbq (%rsp), %rax%rax -= *(%rsp) + CFsbb-2.s sbb-2.txt

CFOFSFZFPFAF
!!!!!!
  • addと同様に,subsbbは オペランドが符号あり整数か符号なし整数かを区別せず, 両方の結果を正しく計算する.

mul, imul命令: かけ算


記法何の略か動作
mul␣ op1unsigned multiply符号なし乗算.(%rdx:%rax) = %rax * op1
imul␣ op1signed multiply符号あり乗算.(%rdx:%rax) = %rax * op1
imul␣ op1, op2signed multiply符号あり乗算.op2 *= op1
imul␣ op1, op2, op3signed multiply符号あり乗算.op3 = op1 * op2

詳しい記法例の動作サンプルコード
mul␣ r/mmulq %rbx(%rdx:%rax) = %rax * %rbxmul-1.s mul-1.txt
imul␣ r/mimulq %rbx(%rdx:%rax) = %rax * %rbximul-1.s imul-1.txt
imul␣ imm, rimulq $4, %rax%rax *= 4imul-2.s imul-2.txt
imul␣ r/m, rimulq %rbx, %rax%rax *= %rbximul-2.s imul-2.txt
imul␣ imm, r/m, rimulq $4, %rbx, %rax%rax = %rbx * 4imul-2.s imul-2.txt

CFOFSFZFPFAF
!!????
  • オペランドが1つの形式では,%raxが隠しオペランドになる. このため,乗算の前に%raxに値をセットしておく必要がある. また,8バイト同士の乗算結果は最大で16バイトになるので, 乗算結果を%rdx%raxに分割して格納する (16バイトの乗算結果の上位8バイトを%rdxに,下位8バイトを%raxに格納する). これをここでは(%rdx:%rax)という記法で表現している.
  • imulだけ例外的に,オペランドが2つの形式と3つの形式がある. 2つか3つの形式では乗算結果が64ビットを超えた場合, 越えた分は破棄される(乗算結果は8バイトのみ).

div, idiv命令: 割り算,余り


記法何の略か動作
div␣ op1unsigned divide符号なし除算と余り
%rax = (%rdx:%rax) / op1
%rdx = (%rdx:%rax) % op1
idiv␣ op1signed divide符号あり除算と余り
%rax = (%rdx:%rax) / op1
%rdx = (%rdx:%rax) % op1

詳しい記法例の動作サンプルコード
div␣ r/mdivq %rbx%rax = (%rdx:%rax) / %rbx
%rdx = (%rdx:%rax) % %rbx
div-1.s div-1.txt
idiv␣ r/midivq %rbx%rax = (%rdx:%rax) / %rbx
%rdx = (%rdx:%rax) % %rbx
idiv-1.s idiv-1.txt

CFOFSFZFPFAF
??????
  • 16バイトの値 %rdx:%rax を第1オペランドで割った商が%raxに入り, 余りが%rdxに入る.
  • 隠しオペランドとして%rdx%raxが使われるので,事前に値を設定しておく必要がある. idivを使う場合,もし%rdxを使わないのであれば, cqto命令で%rax%rdx:%raxに符号拡張しておくと良い.

inc, dec命令: インクリメント,デクリメント


記法何の略か動作
inc␣ op1incrementop1の値を1つ増加
dec␣ op1decrementop1の値を1つ減少

詳しい記法例の動作サンプルコード
inc␣ r/minc %rax%rax++inc-1.s inc-1.txt
dec␣ r/mdec %rax%rax--dec-1.s dec-1.txt

  • incdecはオーバーフローしてもCFが変化しないところがポイント.

neg命令: 符号反転


記法何の略か動作
neg␣ op1negation2の補数によるop1の符号反転

詳しい記法例の動作サンプルコード
neg␣ r/mneg %rax%rax = -%raxneg-1.s neg-1.txt

CFOFSFZFPFAF
!!!!!!

not命令: ビット論理演算 (1)


記法何の略か動作
not␣ op1bitwise notop1の各ビットの反転 (NOT)

詳しい記法例の動作サンプルコード
not␣ r/mnotq %rax%rax = ~%raxnot-1.s not-1.txt

and, or, xor命令: ビット論理演算 (2)


記法何の略か動作
and␣ op1, op2bitwise andop1op2の各ビットごとの論理積(AND)
or␣ op1, op2bitwise orop1op2の各ビットごとの論理和(OR)
xor␣ op1, op2bitwise xorop1op2の各ビットごとの排他的論理和(XOR)

詳しい記法例の動作サンプルコード
and␣ imm, r/mandq $0x0FFF, %rax%rax &= 0x0FFFand-1.s and-1.txt
and␣ r, r/mandq %rax, (%rsp)*(%rsp) &= %raxand-1.s and-1.txt
and␣ r/m, randq (%rsp), %rax%rax &= *(%rsp)and-1.s and-1.txt
or␣ imm, r/morq $0x0FFF, %rax%rax |= 0x0FFF or-1.s or-1.txt
or␣ r, r/morq %rax, (%rsp)*(%rsp) |= %raxor-1.s or-1.txt
or␣ r/m, rorq (%rsp), %rax%rax |= *(%rsp)or-1.s or-1.txt
xor␣ imm, r/mxorq $0x0FFF, %rax%rax ^= 0x0FFFxor-1.s xor-1.txt
xor␣ r, r/mxorq %rax, (%rsp)*(%rsp) ^= %raxxor-1.s xor-1.txt
xor␣ r/m, rxorq (%rsp), %rax%rax ^= *(%rsp)xor-1.s xor-1.txt

CFOFSFZFPFAF
00!!!?

xyx & yx | yx ^ y
00000
01011
10011
11110
  • &, |, ^はC言語で,それぞれ,ビットごとの論理積,論理和,排他的論理積です (忘れた人はC言語を復習しましょう).

sal, sar, shl, shr命令: シフト


記法何の略か動作
sal␣ op1[, op2]shift arithmetic left算術左シフト
shl␣ op1[, op2]shift logical left論理左シフト
sar␣ op1[, op2]shift arithmetic right算術右シフト
shr␣ op1[, op2]shift logical right論理右シフト

詳しい記法例の動作サンプルコード
sal␣ r/msalq %rax%raxを1ビット算術左シフトsal-1.s sal-1.txt
sal␣ imm8, r/msalq $2, %rax%raxを2ビット算術左シフトsal-1.s sal-1.txt
sal␣ %cl, r/msalq %cl, %rax%rax%clビット算術左シフトsal-1.s sal-1.txt
shl␣ r/mshlq %rax%raxを1ビット論理左シフトshl-1.s shl-1.txt
shl␣ imm8, r/mshlq $2, %rax%raxを2ビット論理左シフトshl-1.s shl-1.txt
shl␣ %cl, r/mshlq %cl, %rax%rax%clビット論理左シフトshl-1.s shl-1.txt
sar␣ r/msarq %rax%raxを1ビット算術右シフトsar-1.s sar-1.txt
sar␣ imm8, r/msarq $2, %rax%raxを2ビット算術右シフトsar-1.s sar-1.txt
sar␣ %cl, r/msarq %cl, %rax%rax%clビット算術右シフトsar-1.s sar-1.txt
shr␣ r/mshrq %rax%raxを1ビット論理右シフトshr-1.s shr-1.txt
shr␣ imm8, r/mshrq $2, %rax%raxを2ビット論理右シフトshr-1.s shr-1.txt
shr␣ %cl, r/mshrq %cl, %rax%rax%clビット論理右シフトshr-1.s shr-1.txt

CFOFSFZFPFAF
!!!!!?
  • op1[, op2] という記法は「op2は指定してもしなくても良い」という意味です.
  • シフトとは(指定したビット数だけ)右か左にビット列をずらすことを意味します. op2がなければ「1ビットシフト」を意味します.
  • 論理シフトとは「空いた場所に0を入れる」, 算術シフトとは「空いた場所に符号ビットを入れる」ことを意味します.
  • 左シフトの場合は(符号ビットを入れても意味がないので),論理シフトでも算術シフトでも,0を入れます.その結果,算術左シフトsalと論理左シフトshlは全く同じ動作になります.
  • C言語の符号あり整数に対する右シフト(>>)は算術シフトか論理シフトかは 決まっていません(実装依存です). C言語で,ビット演算は符号なし整数に対してのみ行うようにしましょう.

rol, ror, rcl, rcr命令: ローテート


記法何の略か動作
rol␣ op1[, op2]rotate left左ローテート
rcl␣ op1[, op2]rotate left through carryCFを含めて左ローテート
ror␣ op1[, op2]rotate right右ローテート
rcr␣ op1[, op2]rotate right through carryCFを含めて右ローテート

詳しい記法例の動作サンプルコード
rol␣ r/mrolq %rax%raxを1ビット左ローテートrol-1.s rol-1.txt
rol␣ imm8, r/mrolq $2, %rax%raxを2ビット左ローテートrol-1.s rol-1.txt
rol␣ %cl, r/mrolq %cl, %rax%rax%clビット左ローテートrol-1.s rol-1.txt
rcl␣ r/mrclq %rax%raxを1ビットCFを含めて左ローテートrcl-1.s rcl-1.txt
rcl␣ imm8, r/mrclq $2, %rax%raxを2ビットCFを含めて左ローテートrcl-1.s rcl-1.txt
rcl␣ %cl, r/mrclq %cl, %rax%rax%clビットCFを含めて左ローテートrcl-1.s rcl-1.txt
ror␣ r/mrorq %rax%raxを1ビット右ローテートror-1.s ror-1.txt
ror␣ imm8, r/mrorq $2, %rax%raxを2ビット右ローテートror-1.s ror-1.txt
ror␣ %cl, r/mrorq %cl, %rax%rax%clビット右ローテートror-1.s ror-1.txt
rcr␣ r/mrcrq %rax%raxを1ビットCFを含めて右ローテートrcr-1.s rcr-1.txt
rcr␣ imm8, r/mrcrq $2, %rax%raxを2ビットCFを含めて右ローテートrcr-1.s rcr-1.txt
rcr␣ %cl, r/mrcrq %cl, %rax%rax%clビットCFを含めて右ローテートrcr-1.s rcr-1.txt

  • op1[, op2] という記法は「op2は指定してもしなくても良い」という意味です.
  • ローテートは,シフトではみ出したビットを空いた場所に入れます.
  • ローテートする方向(右か左),CFを含めるか否かで,4パターンの命令が存在します.

cmp, test命令: 比較

cmp命令


記法何の略か動作
cmp␣ op1[, op2]compareop1op2の比較結果をフラグに格納(比較はsub命令を使用)

詳しい記法例の動作サンプルコード
cmp␣ imm, r/mcmpq $999, %raxsubq $999, %raxのフラグ変化のみ計算.オペランドは変更なしcmp-1.s cmp-1.txt
cmp␣ r, r/mcmpq %rax, (%rsp)subq %rax, (%rsp)のフラグ変化のみ計算.オペランドは変更なしcmp-1.s cmp-1.txt
cmp␣ r/m, rcmpq (%rsp), %raxsubq (%rsp), %raxのフラグ変化のみ計算.オペランドは変更なしcmp-1.s cmp-1.txt

CFOFSFZFPFAF
!!!!!!
  • cmp命令はフラグ計算だけを行います. (レジスタやメモリは変化しません).
  • cmp命令は条件付きジャンプ命令と一緒に使うことが多いです. 例えば以下の2命令で「%raxが(符号あり整数として)1より大きければジャンプする」という意味になります.
cmpq $1, %rax
jg L2

test命令


記法何の略か動作
test␣ op1[, op2]logical compareop1op2の比較結果をフラグに格納(比較はand命令を使用)

詳しい記法例の動作サンプルコード
test␣ imm, r/mtestq $999, %raxandq $999, %raxのフラグ変化のみ計算.オペランドは変更なしtest-1.s test-1.txt
test␣ r, r/mtestq %rax, (%rsp)andq %rax, (%rsp)のフラグ変化のみ計算.オペランドは変更なしtest-1.s test-1.txt
test␣ r/m, rtestq (%rsp), %raxandq (%rsp), %raxのフラグ変化のみ計算.オペランドは変更なしtest-1.s test-1.txt

CFOFSFZFPFAF
00!!!?
  • cmp命令と同様に,test命令はフラグ計算だけを行います. (レジスタやメモリは変化しません).
  • cmp命令と同様に,test命令は条件付きジャンプ命令と一緒に使うことが多いです. 例えば以下の2命令で「%raxが0ならジャンプする」という意味になります.
testq %rax, %rax
jz L2
  • 例えば%raxが0かどうかを知りたい場合, cmpq $0, %raxtestq %rax, %raxのどちらでも調べることができます. どちらの場合も,ZF==1なら,%raxが0と分かります (testq %rax, %raxはビットごとのANDのフラグ変化を計算するので, %raxがゼロの時だけ,ZF==1となります). コンパイラはtestq %rax, %raxを使うことが多いです. testq %rax, %raxの方が命令長が短くなるからです.

movs, movz, cbtw, cqto命令: 符号拡張とゼロ拡張

movs, movz命令


記法(AT&T形式)記法(Intel形式)何の略か動作
movs␣␣ op1, op2movsx op2, op1
movsxd op2, op1
move with sign-extentionop1を符号拡張した値をop2に格納
movz␣␣ op1, op2movzx op2, op1move with zero-extentionop1をゼロ拡張した値をop2に格納

詳しい記法例の動作サンプルコード
movs␣␣ r/m, rmovslq %eax, %rbx%rbx = %eaxを8バイトに符号拡張した値movs-movz.s movs-movz.txt
movz␣␣ r/m, rmovzwq %ax, %rbx%rbx = %axを8バイトにゼロ拡張した値movs-movz.s movs-movz.txt

␣␣に入るもの何の略か意味
bwbyte to word1バイト→2バイトの拡張
blbyte to long1バイト→4バイトの拡張
bqbyte to quad1バイト→8バイトの拡張
wlword to long2バイト→4バイトの拡張
wqword to quad2バイト→8バイトの拡張
lqlong to quad4バイト→8バイトの拡張

  • movs, movz命令はAT&T形式とIntel形式でニモニックが異なるので注意です.
  • GNUアセンブラではAT&T形式でも実はmovsx, movzxのニモニックが使用できます. ただし逆アセンブルすると,movslq, movzwqなどのニモニックが表示されるので, movslq, movzwqなどを使う方が良いでしょう.
  • movzlq (Intel形式ではmovzxd)はありません.例えば,%eaxに値を入れると, %raxの上位32ビットはクリアされるので, movzlqは不要だからです.
  • Intel形式では,4バイト→8バイトの拡張の時だけ, (movsxではなく)movsxdを使います.

cbtw, cqto命令


記法(AT&T形式)記法(Intel形式)何の略か動作
**c␣t␣c␣␣␣convert ␣ to ␣%rax (または%eax, %ax, %al)を符号拡張

詳しい記法
(AT&T形式)
詳しい記法
(Intel形式)
例の動作サンプルコード
cbtwcbwcbtw%al(byte)を%ax(word)に符号拡張cbtw.s cbtw.txt
cwtlcwdecwtl%ax(word)を%eax(long)に符号拡張cbtw.s cbtw.txt
cwtdcwdcwtd%ax(word)を%dx:%ax(double word)に符号拡張cbtw.s cbtw.txt
cltdcdqcltd%eax(long)を%edx:%eax(doube long, quad)に符号拡張cbtw.s cbtw.txt
cltqcdqecltd%eax(long)を%rax(quad)に符号拡張cbtw.s cbtw.txt
cqtocqocqto%rax(quad)を%rdx:%rax(octuple)に符号拡張cbtw.s cbtw.txt

  • cqtoなどはidivで割り算する前に使うと便利(%rdx:%raxidivの隠しオペランドなので).
  • GNUアセンブラはIntel形式のニモニックも受け付ける.

ジャンプ命令

jmp: 無条件ジャンプ


記法何の略か動作
jmp op1jumpop1にジャンプ

詳しい記法例の動作サンプルコード
jmp reljmp 0x10000x1000番地に相対直接ジャンプ (%rip += 0x1000)jmp.s jmp.txt
jmp foofoo番地に相対直接ジャンプ (%rip += foo)jmp.s jmp.txt
jmp r/mjmp *%rax*%rax番地に絶対間接ジャンプ (%rip = *%rax))jmp.s jmp.txt
jmp r/mjmp *(%rax)*(%rax)番地に絶対間接ジャンプ (%rip = *(%rax))jmp.s jmp.txt
---
  • x86-64では,相対・直接と絶対・間接の組み合わせしかありません. (つまり,相対・間接ジャンプや絶対・直接ジャンプはありません. なお,ここで紹介していないfarジャンプでは絶対・直接もあります).
  • 相対・直接ジャンプでは符号ありの8ビット(rel8)か 32ビット(rel32)の整数定数で相対アドレスを指定します. (64ビットの相対アドレスは指定できません.64ビットのジャンプをしたい時は 絶対・間接ジャンプ命令を使います).
  • rel8rel32かはアセンブラが勝手に選んでくれます. 逆にjmpbjmplなどとサフィックスをつけて指定することはできません.
  • なぜか,定数なのにrel8rel32にはドルマーク$をつけません. 逆にr/mの前にはアスタリスク*が必要です. GNUアセンブラのこの部分は一貫性がないので要注意です.

条件付きジャンプの概要

  • 条件付きジャンプ命令 j␣は  ステータスフラグ (CF, OF, PF, SF, ZF)をチェックして, 条件が成り立てばジャンプします.
  • 条件付きジャンプは比較命令と一緒に使うことが多いです. 例えば以下の2命令で「%raxが(符号あり整数として)1より大きければジャンプする」という意味になります.
cmpq $1, %rax
jg L2
  • 条件付きジャンプ命令のニモニックでは次の用語を使い分けます
    • 符号あり整数の大小には less/greater を使う
    • 符号なし整数の大小には above/below を使う

条件付きジャンプ: 符号あり整数用


記法何の略か動作ジャンプ条件
jg rel
jnle rel
jump if greater
jump if not less nor equal
op2>op1ならrelにジャンプ
!(op2<=op1)ならrelにジャンプ
ZF==0&&SF==OF
jge rel
jnl rel
jump if greater or equal
jump if not less
op2>=op1ならrelにジャンプ
!(op2<op1)ならrelにジャンプ
SF==OF
jle rel
jng rel
jump if less or equal
jump if not greater
op2<=op1ならrelにジャンプ
!(op2>op1)ならrelにジャンプ
ZF==1||SF!=OF
jl rel
jnge rel
jump if less
jump if not greater nor equal
op2<op1ならrelにジャンプ
!(op2>=op1)ならrelにジャンプ
SF!=OF

詳しい記法例の動作サンプルコード
jg relcmpq $0, %rax
jg foo
if (%rax>0) goto foojg.s jg.txt
jnle relcmpq $0, %rax
jnle foo
if (!(%rax<=0)) goto foojg.s jg.txt
jge relcmpq $0, %rax
jge foo
if (%rax>=0) goto foojge.s jge.txt
jnl relcmpq $0, %rax
jnl foo
if (!(%rax<0)) goto foojge.s jge.txt
jle relcmpq $0, %rax
jle foo
if (%rax<=0) goto foojle.s jle.txt
jng relcmpq $0, %rax
jng foo
if (!(%rax>0)) goto foojle.s jle.txt
jl relcmpq $0, %rax
jl foo
if (%rax<0) goto foojl.s jl.txt
jnge relcmpq $0, %rax
jnge foo
if (!(%rax>=0)) goto foojl.s jl.txt

  • op1op2 は条件付きジャンプ命令の直前で使用したcmp命令のオペランドを表します.
  • jgjnleは異なるニモニックですが動作は同じです. その証拠にジャンプ条件はZF==0&&SF==OFと共通です. 他の3つのペア,jgejnljlejngjljngeも同様です.

条件付きジャンプ: 符号なし整数用


記法何の略か動作ジャンプ条件
ja rel
jnbe rel
jump if above
jump if not below nor equal
op2>op1ならrelにジャンプ
!(op2<=op1)ならrelにジャンプ
CF==0&ZF==0
jae rel
jnb rel
jump if above or equal
jump if not below
op2>=op1ならrelにジャンプ
!(op2<op1)ならrelにジャンプ
CF==0
jbe rel
jna rel
jump if below or equal
jump if not above
op2<=op1ならrelにジャンプ
!(op2>op1)ならrelにジャンプ
CF==1&&ZF==1
jb rel
jnae rel
jump if below
jump if not above nor equal
op2<op1ならrelにジャンプ
!(op2>=op1)ならrelにジャンプ
CF==1

詳しい記法例の動作サンプルコード
ja relcmpq $0, %rax
ja foo
if (%rax>0) goto fooja.s ja.txt
jnbe relcmpq $0, %rax
jnbe foo
if (!(%rax<=0)) goto fooja.s ja.txt
jae relcmpq $0, %rax
jae foo
if (%rax>=0) goto foojae.s jae.txt
jnb relcmpq $0, %rax
jnb foo
if (!(%rax<0)) goto foojae.s jae.txt
jbe relcmpq $0, %rax
jbe foo
if (%rax<=0) goto foojbe.s jbe.txt
jna relcmpq $0, %rax
jna foo
if (!(%rax>0)) goto foojbe.s jbe.txt
jb relcmpq $0, %rax
jb foo
if (%rax<0) goto foojb.s jb.txt
jnae relcmpq $0, %rax
jnae foo
if (!(%rax>=0)) goto foojb.s jb.txt

  • op1op2 は条件付きジャンプ命令の直前で使用したcmp命令のオペランドを表します.
  • jajnbeは異なるニモニックですが動作は同じです. その証拠にジャンプ条件はCF==0&&ZF==0と共通です. 他の3つのペア,jaejnbjbejnajbjnaeも同様です.

条件付きジャンプ: フラグ用


記法何の略か動作ジャンプ条件
jc reljump if carryCF==1ならrelにジャンプCF==1
jnc reljump if not carryCF==0ならrelにジャンプCF==0
jo reljump if overflowOF==1ならrelにジャンプOF==1
jno reljump if not overflowOF==0ならrelにジャンプOF==0
js reljump if signSF==1ならrelにジャンプSF==1
jns reljump if not signSF==0ならrelにジャンプSF==0
jz rel
je rel
jump if zero
jump if equal
ZF==1ならrelにジャンプ
op2==op1ならrelにジャンプ
ZF==1
jnz rel
jne rel
jump if not zero
jump if not equal
ZF==0ならrelにジャンプ
op2!=op1ならrelにジャンプ
ZF==0
jp rel
jpe rel
jump if parity
jump if parity even
PF==1ならrelにジャンプPF==1
jnp rel
jpo rel
jump if not parity
jump if parity odd
PF==0ならrelにジャンプPF==0

詳しい記法例の動作サンプルコード
jc reljc fooif (CF==1) goto foojc.s jc.txt
jnc reljnc fooif (CF==0) goto foojc.s jc.txt
jo reljo fooif (OF==1) goto foojo.s jo.txt
jno reljno fooif (OF==0) goto foojo.s jo.txt
js reljs fooif (SF==1) goto foojs.s js.txt
jns reljns fooif (SF==0) goto foojs.s js.txt
jz reljz fooif (ZF==1) goto foojz.s jz.txt
je relcmpq $0, %rax
je foo
if (%rax==0) goto foojz.s jz.txt
jnz reljnz fooif (ZF==0) goto foojz.s jz.txt
jne relcmpq $0, %rax
jne foo
if (%rax!=0) goto foojz.s jz.txt
jp reljp fooif (PF==1) goto foojp.s jp.txt
jpe reljpe fooif (PF==1) goto foojp.s jp.txt
jnp reljnp fooif (PF==0) goto foojp.s jp.txt
jpo reljpo fooif (PF==0) goto foojp.s jp.txt

  • op1op2 は条件付きジャンプ命令の直前で使用したcmp命令のオペランドを表します.
  • jzjeは異なるニモニックですが動作は同じです. その証拠にジャンプ条件はZF==1と共通です. 他の3つのペア,jnzjnejpjpejnpjpoも同様です.
  • AFフラグのための条件付きジャンプ命令は存在しません.

関数呼び出し(コール命令)

call, ret命令: 関数を呼び出す,リターンする


記法何の略か動作
call op1call procedure%ripをスタックにプッシュしてから op1にジャンプする
(pushq %rip; %rip = op1)
retreturn from procedureスタックからポップしたアドレスにジャンプする
(popq %rip)

詳しい記法例の動作サンプルコード
call relcall foo相対・直接の関数コールcall.s call.txt
call r/mcall *%rax絶対・間接の関数コールcall.s call.txt
retret関数からリターンcall.s call.txt

enter, leave命令: スタックフレームを作成する,破棄する


記法何の略か動作
enter op1, op2make stack frameサイズop1のスタックフレームを作成する
leavediscard stack frame今のスタックフレームを破棄する

詳しい記法例の動作サンプルコード
enter imm16, imm8enter $0x20, $0pushq %rbp
movq %rsp, %rbp
subq $0x20, %rsp
enter.s enter.txt
leaveleavemovq %rbp, %rsp
popq %rbp
enter.s enter.txt

  • enter命令のop2には関数のネストレベルを指定するのですが, C言語では入れ子の関数がない(つまりネストレベルは常にゼロ)なので 常にゼロを指定します.
  • ただし,enterは遅いので通常は使いません. 代わりに同等の動作をするpushq %rbp; movq %rsp, %rbp; subq $n, %rspを使います.

その他

nop命令


記法何の略か動作
nopno operation何もしない(プログラムカウンタのみ増加)
nop op1no operation何もしない(プログラムカウンタのみ増加)

詳しい記法例の動作サンプルコード
nopnop何もしないnop.s nop.txt
nop r/mnopl (%rax)何もしないnop2.s nop2.txt

  • nopは何もしない命令です(ただしプログラムカウンタ%ripは増加します).
  • 機械語命令列の間を(何もせずに)埋めるために使います.
  • nopの機械語命令は1バイト長です.
  • nop r/m という形式の命令は2〜9バイト長のnop命令になります. 1バイト長のnopを9個並べるより, 9バイト長のnopを1個並べた方が,実行が早くなります.
  • 「複数バイトのnop命令がある」という知識は, 逆アセンブル時にnopl (%rax)などを見てビックリしないために必要です.

cmpxchg, cmpxchg8b, cmpxchg16b命令: CAS (compare-and-swap)命令

cmpxchg命令


記法何の略か動作
cmpxchg op1, op2compare and exchange%raxop2を比較し,同じならop2=op1,異なれば %rax=op2

詳しい記法例の動作サンプルコード
cmpxchg r, r/mcmpxchg %rbx, (%rsp)if (*(%rsp)==%rax) *(%rsp)=%rbx;
else %rax=*(%rsp);
cmpxchg.s cmpxchg.txt

CFOFSFZFPFAF
!!!!!!
  • cmpxchg命令などのCAS命令は,lock-free,つまりロックを使わず 同期機構を実現するために使われます. アトミックに実行する必要があるため,通常,LOCKプリフィックスをつけて使います.
  • 気持ち:
    • あるメモリにあるop2を新しい値op1で書き換えたい.
    • ただし,代入前のop2の値は%raxと同じはずで, もし(割り込まれて)知らない間に別の値になっていたら,この代入は失敗させる.
    • 代入が失敗したことを知るために, (他の誰かが更新した最新の)op2の値を%raxに入れる. cmpxchg実行後に%raxの値を調べれば,無事にop1への代入ができたかどうかが分かる.

cmpxchg8b, cmpxchg16b命令


記法何の略か動作
cmpxchg8b op1compare and exchange bytes%edx:%eaxop1を比較し,同じならop1=%ecx:%ebx,異なれば %edx:%eax=op1
cmpxchg16b op1compare and exchange bytes%rdx:%raxop1を比較し,同じならop1=%rcx:%rbx,異なれば %rdx:%rax=op1

詳しい記法例の動作サンプルコード
cmpxchg8b m64cmpxchg8b (%rsp)if (*(%rsp)==%edx:%eax) *(%rsp)=%ecx:%ebx;
else %edx:%eax=*(%rsp);
cmpxchg8b.s cmpxchg8.txt
cmpxchg16b m128cmpxchg16b (%rsp)if (*(%rsp)==%rdx:%rax) *(%rsp)=%rcx:%rbx;
else %rdx:%rax=*(%rsp);
cmpxchg16b.s cmpxchg16.txt

  • cmpxchg8b, cmpxchg16bもCAS命令の一種ですが, cmpxchgとステータスフラグの変化が異なるので,分けて書いています.
  • cmpxchg16b命令が参照するメモリは16バイト境界のアラインメントが必要です. (つまりメモリアドレスが16の倍数である必要があります).

rdtsc, rdtscp命令: タイムスタンプを読む


記法何の略か動作
rdtscread time-stamp counter%edx:%eax = 64ビットタイムスタンプカウンタ
rdtscpread time-stamp counter and processor ID%edx:%eax = 64ビットタイムスタンプカウンタ
%ecx = 32ビットプロセッサID

詳しい記法例の動作サンプルコード
rdtscrdtsc%edx:%eax = 64ビットタイムスタンプカウンタrdtsc.s rdtsc.txt
rdtscprdtscp%edx:%eax = 64ビットタイムスタンプカウンタ
%ecx = 32ビットプロセッサID
rdtscp.s rdtscp.txt

  • x86-64は64ビットのタイムスタンプカウンタ (TSC: time stamp counter)を備えており, リセット後のCPUのサイクル数を数えています. 原理的には「サイクル数の差分をCPUのクロック周波数で割れば実行時間が得られる」 はずですが,実際にはout-of-order実行や, 内部クロックの変化などを考慮する必要があります. 詳しくはHow to Benchmark Code Execution Times on Intel® IA-32 and IA-64 Instruction Set Architectures を参照して下さい.
  • rdtscp命令を使うと,プロセッサIDも取得できます. rdtscrdtscpではシリアライズ処理が異なるため,得られるサイクル数も異なります. 詳しくは x86-64のマニュアルSDM を参照して下さい.

int3命令


記法何の略か動作
int3call to interrupt procedureブレークポイントトラップを発生

詳しい記法例の動作サンプルコード
int3int3ブレークポイントトラップを発生int3.s int3.txt

  • int3命令はブレークポイントトラップ(ソフトウェア割り込みの一種)を発生させます. 通常実行ではint3を実行した時点でプロセスは強制終了となりますが, デバッガ上ではその時点でブレークします.continueコマンドでその後の実行も継続できます.ブレークしたい場所が分かっている場合は, Cコード中にasm ("int3");と書くことでデバッガ上でブレークさせることができます.

ud2命令


記法何の略か動作
ud2undefined instruction無効オペコード例外を発生させる

詳しい記法例の動作サンプルコード
ud2ud2無効オペコード例外を発生させるud2.s ud2.txt

  • ud2命令は無効オペコード例外を発生させます. 通常実行ではud2を実行した時点でプロセスは, シグナルSIGILL (illegal instruction)を受け取り,強制終了となります デバッガ上でも, Program received signal SIGILL, Illegal instruction. というメッセージが出て,プロセスは終了になります. 本書では「実行が通るはずがない場所が本当かどうか」の確認のため ud2を使います.(通るはずがない場所にud2を置いて,SIGILLが発生しなければOKです)
例外 (exception)とは

例外(exception)はCPUが発生させる割り込み(ソフトウェア割り込み)です. Intel用語で,例外はさらにフォールト(fault),トラップ(trap), アボート(abort)に分類されます. 例えばゼロ割はフォールト,ブレークポイントはトラップです. マイOS作りたい人は頑張って勉強して下さい.

endbr64命令


記法何の略か動作
endbr64end branch 64 bit間接ジャンプ先として許す

詳しい記法例の動作サンプルコード
endbr64endbr64間接ジャンプ先として許すendbr64.s endbr64.txt

  • Intel CET IBT技術に対応したCPUの場合, 間接ジャンプ後のジャンプ先がendbr64以外だった場合, 例外が発生してプログラムは強制終了となります.
  • Intel CET IBT技術に未対応のCPUの場合は,nop命令として動作します.
  • 逆アセンブルしてendbr64を見てもビックリしないためにこの説明を書いています.
  • 私のPCが古すぎて,Intel CET未対応だったため,2023/8/17現在,クラッシュが発生するサンプルコードを作れていません.

bndプリフィクス

Intel MPX (Memory Protection Extensions)の機能の一部で, 制御命令 (ジャンプ命令やリターン命令など)に指定できます. BND0からBND3レジスタに指定した境界に対して境界チェックを行います. この機能をサポートしてないCPUではnopとして動作します.

  • 逆アセンブルしてbndを見てもビックリしないためにこの説明を書いています.
    以下のようにPLTセクションを見ると❶bndが使われています.
$ objdump -d /bin/ls | less
(中略)
Disassembly of section .plt:

0000000000004030 <.plt>:
    4030:       ff 35 2a dc 01 00       push   0x1dc2a(%rip)        # 21c60 <_ob
stack_memory_used@@Base+0x114b0>
    4036:       f2 ff 25 2b dc 01 00 ❶ bnd jmp *0x1dc2b(%rip)        # 21c68 <_obstack_memory_used@@Base+0x114b8>
    403d:       0f 1f 00                nopl   (%rax)

set␣命令: ステータスフラグの値を取得


記法何の略か動作
set␣ op1set byte on conditionif (条件␣が成立) op1=1; else op1=0;

詳しい記法例の動作サンプルコード
set␣ r/m8setz %al%al = ZFsetz.s setz.txt
setg %al より大きい(greater)条件が成立なら%al =1,違えば%al =0setg.s setg.txt

  • set␣命令はステータスフラグの値を取得します. には条件付きジャンプ命令j␣と同じものをすべて入れられます.

ストリング命令

movsなどのストリング命令はREPプリフィクスと組み合わせて使います.

  • REPプリフィクス

記法何の略か動作
rep insnrepeat%ecx==0になるまで
命令insn%ecx--を繰り返し実行
repe insnrepeat while equal%ecx==0またはフラグZF==0になるまで
命令insn%ecx--を繰り返し実行
repz insnrepeat while zero
repne insnrepeat while not equal%ecx==0またはフラグZF==1になるまで
命令insn%ecx--を繰り返し実行
repnz insnrepeat while not zero

詳しい記法例の動作サンプルコード
rep insnrep movsbwhile (%ecx-- > 0) (*%rdi++) = (*%rsi++);
# 1バイトずつコピー
rep.s rep.txt
repe insn
repz insn
repe cmpsb
repz cmpsb
while (%ecx-- > 0 && (*%rdi++ == *%rsi++));
# 1バイトずつ比較
repe.s repe.txt
repz.s repz.txt
repne insn
repnz insn
repne cmpsb
repnz cmpsb
while (%ecx-- > 0 && (*%rdi++ != *%rsi++));
# 1バイトずつ比較
repne.s repne.txt
repnz.s repnz.txt

注意: DFフラグ(direction flag)が0の場合,%rsi%rdiを増やす.DFが1の場合は減らす.上記の説明はDF==0を仮定.

注意: ストリング命令はセグメントレジスタ%ds%esを使って,%ds:(%rsi)%es:(%rdi)にアクセスします.が,x86-64では%ds%esもベースレジスタをゼロと扱うので,%ds%esは無視して構いません.

  • ストリング命令

    記法何の略か動作
    movs␣move string(%rsi)(%rdi)nバイト転送; %rsi += n; %rdi += n;
    lods␣load string(%rsi)%raxnバイト転送; %rsi += n;
    stos␣store string%rax(%rdi)nバイト転送; %rdi += n;
    ins␣input stringI/Oポート%dxから(%rdi)nバイト転送; %rdi += n;
    outs␣output string(%rsi)からI/Oポート%dxnバイト転送; %rsi += n;
    cmps␣compare string(%rsi)(%rdi)nバイト比較; %rsi += n; %rdi += n;
    scas␣scan string%rax(%rdi)nバイト比較; %rdi += n;
    詳しい記法例の動作サンプルコード
    rep movsbrep movsbwhile (%ecx-- > 0) (*%rdi++) = (*%rsi++);
    # 1バイトずつコピー
    rep.s rep.txt
    rep lodsbrep lodsbwhile (%ecx-- > 0) %al = (*%rsi++);
    # 1バイトずつコピー
    lods.s lods.txt
    rep stosqrep stosqwhile (%ecx-- > 0) {(*%rdi) = %rax; %rdi+=8; }
    # 8バイトずつコピー
    stos.s stos.txt
    repe cmpsbrepe cmpsbwhile (%ecx-- > 0 && (*%rdi++) == (*%rsi++);
    # 1バイトずつ比較
    repe.s repe.txt
    repne scasbrepne scasbwhile (%ecx-- > 0 && (*%rdi++) != %rax);
    # 1バイトずつ比較
    scas.s scas.txt
    • にはbwlqが入り,それぞれ, メモリ参照のサイズ(上ではnと表記)が1バイト,2バイト,4バイト,8バイトになる. (ただし,insoutsbwlのみ指定可能).
    • %raxはオペランドサイズにより,%rax%eax%ax%alのいずれかになる.
    • ins␣out␣の実例はここでは無し.
  • DFフラグ(方向フラグ)とcld命令・std命令

    記法何の略か動作
    cldclear direction flagDF=0
    stdset direction flagDF=1
    詳しい記法例の動作サンプルコード
    cldcldDF=0cld.s cld.txt
    stdstdDF=1cld.s cld.txt
    • DFフラグはストリング命令で,%rsi%rdiを増減する方向を決めます.
      • DF=0 の時は%rsi%rdiを増やします
      • DF=1 の時は%rsi%rdiを減らします
    • DFフラグの変更はcldstdで行います (一般的にフラグレジスタの値を変更する場合,pushfでフラグレジスタの値を保存し, popfで元に戻すのが安全です).
    • Linux AMD64のABIにより, 関数の出入り口ではDF=0に戻す必要があります.このお約束のため, 自分でstdしていなければ,必ずDF==0となります(わざわざcldする必要はありません).

リンク集