ハードウェアの知識が無い人向けのアセンブリ言語の話(draft)

公開:2021/01/10
更新:2021/01/11
7 min読了の目安(約6300字TECH技術記事 5

本記事は書きかけなので内容(タイトルすらも)は随時書き換わっていきます。ドラフトのうちは内容の正確性や文書全体としての整合性についても荒っぽい部分が多々あります。ご容赦ください。

はじめに

本記事はソフトウェア開発者がハードウェアに近い低レイヤといわれる領域に入門するとき、とくにアセンブリ言語に出会ったときにつまずきがちなことを紹介します。主な対象読者はJavaScriptやPythonなどのスクリプト言語などによるアプリ開発からソフトウェア開発に入った、それより下のレイヤになじみのない人です。

筆者は常々アセンブリ言語は技術的にものすごく難しいわけではないものの、学習につまずく人が非常に多いという印象を持っています。その主な原因の一つは、みなさんが普段慣れ親しんでいる人間に使いやすいように作られた高級プログラミング言語(以下高級言語)と、機械に解釈させやすいように作られているアセンブリ言語プログラミングとの常識がかけ離れていることにあると推測しています。

本記事はアセンブリ言語について技術的な詳細を説明するのではなく、現代的なアプリケーションのプログラマにはまったくなじみがないものの、アセンブリ言語プログラミングができるプログラマにとっては常識であることを説明します。これによって、アセンブリ言語の学習につまずく要因を減らすことが目的です。より踏み込んだところについては参考文献をごらんください。

データを保存する領域を2種類考慮しなければいけない

高級言語ではデータを保存、計算するのに変数を使います。オブジェクト指向プログラミング言語でいうとクラスのインスタンス、オブジェクトなどと読み替えていただいてもよいです。変数は見かけ上制限なく作れます[1]。プログラムの実行は変数の値を読みだして、計算して、また変数に書き戻して…というように進みます。簡単のためSSDなどのストレージデバイスについてはここでは考えません。

アセンブリ言語の世界では常識が変わってきます。まずアセンブリ言語の世界ではデータの保存、計算のために二つのことを考える必要があります。一つはメモリ、もう一つはレジスタです。

名前 役割 容量 物理的な位置
レジスタ データの保存、計算 整数数個~数十個程度。PCに使われるx86_64アーキテクチャのCPUでは16個 CPUの中
メモリ データの保存のみ[2] 現代的なPCでは数GB~数十GB(整数データの数に換算すると数億個~数十億個) CPUの外の独立したデバイス(メモリ)の中[3]

非常に短いプログラムであればデータはレジスタにすべて収まりますが、実用的なプログラムであれば、データは普段メモリ上に保存しておき、計算をするときはメモリ上のデータを一時的にCPUの中にあるレジスタ上に読み出し、計算し、結果をまたメモリ上に書き戻すという流れになります。

こんなややこしいことをせずにレジスタの数を増やして全てのデータをレジスタに置けば解決なのでは、と思うかもしれません。しかし話はそう簡単ではありません。そういうことをすると次のような問題が起きるため、現実的ではありません。

  • CPUが物理的に巨大化する
  • 処理が遅くなる
  • 消費電力、発熱量が増える
  • 製造コストが増える

そのかわりにレジスタよりもはるかに安価に容量を増やせるハードウェアであるメモリを使う、そうせざるをえない、というわけです。

説明手法

この後は具体的なアセンブリ言語の簡単なコードを眺めながらアセンブリ言語の初歩の初歩について学びます。主にx86_64 Linux向けのGNU Assembler(GAS)で書くアセンブリ言語について扱います。開発環境としてはUbuntu18.04を使います。

おおよそ以下のようなテンプレで説明をしていきます。

  1. あることをするプログラムを高級言語であるC言語[4]のコードを見る
  2. x86_64アセンブリ言語で実現するコードを見る

x86_64を選んだ理由は一番参考文献が多いこと、および普及率が高くて開発環境を用意しやすいことです。ただしx86_64は有名どころのアーキテクチャの中でもかなり複雑なアーキテクチャであるため、お世辞にもアセンブリ言語が書きやすいとは言えません。このため、適宜理解しやすくなるように他のアーキテクチャについても例を挙げるなど、補足を入れていきたいと思います。

x86_64のレジスタの名前が覚えにくい

レジスタの数、および名前はアーキテクチャによって異なります。わかりやすいところではスマホやタブレットなどで広く使われているArm64というアーキテクチャでは汎用レジスタと呼ばれる整数演算に使うレジスタを31個持っていて、それぞれX0~X30という名前がついています。RISC-Vとしうアーキテクチャは同じく31本の汎用レジスタを持っていて、名前はX1~X31です。

ではx86_64ではどうでしょう。x86_64には16個の汎用レジスタがあります。名前はそれぞれrax,rbx,rcx,rdx,rdi,rsi,rbp,rsp,r9,r10,r11,r12,r13,r14,r15です。嫌がらせかというくらい名前がバラバラですね。でもこれは複雑な歴史的経緯があってこういう命名になっています。それらについては追ってお話しますが、いまのところは「そういうものだ」と思ってさっさと覚えてしまうほうが建設的です。

名前のうち重要なところについて一つだけ書いておきますと、全レジスタの先頭にある"r"の文字は64bitのレジスタであることを示しています。じゃあ他のビット数のレジスタがあるのかといわれると実はあるのですが、それについては別の節で説明しようと思います。

汎用レジスタは整数演算に使うと書きましたが、実はそれ以外にも用途があります。どのレジスタが何に使われるのかについては、これまた別の節において触れます。もう一点、汎用レジスタ以外にも浮動小数点演算に使うためのレジスタや主にOSが使うためのコントロールレジスタと呼ばれるレジスタなど様々なものがあるのですが、これらについても別の節で述べます。

コードの見た目が直感的じゃなくて辛い

以下、C言語で書いた簡単な足し算命令です。

a = a + b

これと同じことをするARM64アセンブリ言語は次のようになります。

add x1, x1, x2

CおよびCライクな言語のように見た目が数式に似ていないので辛いですね。これは第一引数(x1レジスタ)に、第二引数(x1レジスタ)の値と第三引数(X2レジスタ)の値を足すという意味です。そう言われてみると理解できるのですが、アセンブリ言語に慣れていない人からすると四則演算に慣れ親しんだ"+","-","*","/"といった記号を使わないなど、初見殺しなところがあります。

ここで上述のコードを例に、いくつか本記事のこの後の説明で使うために用語の説明をしておきます。

  • ニーモニック: 数値だけで構成される機械語を人間に見やすいように文字列で置き換えたもの。上述の例でいうと"add x1, x1, x2"という部分。
  • オペコード: どういうことをする命令なのかを示す。上記コードでは"add"の部分
  • オペランド: オペコードの処理対象。上記コード上でいうと","で区切られた3つの要素

高級言語の関数に例えるとオペコードは関数名あるいはメソッド名、オペランドは引数と考えるとわかりやすいでしょう。このへんの用語はさっさと暗記してしましょう。一度に全部暗記する必要はなくて、わからなければこの節を見直すくらいでいいです。

x86_64のコードはさらに辛い

前節において説明したコードが直感的ではないという話がx86_64になると次のようにもっとひどくなります。

addq %rbx, %rax

突っ込みどころは2つあって、一つめは「"q"って何?」で、二つ目は「引数少なくない?」です。まず"q"については64bitの整数演算をすることを示しています。二つ目についてはx86_64のadd命令の定義を知っていて初めて理解できます。その定義とは「第一オペランド(%rbx)の値と第二オペランド(%rax)の値を足したものを第二オペランド(%rax)に保存する」です。高級言語ユーザからすると見づらいことこの上ないのですが、CPUアーキテクチャがそうなっているのですからしかたありません。あきらめて受け入れましょう。

「あきらめて受け入れましょう」ではあまりにも芸がないので、こうなっている理由の一つについて説明しておきます。オペランドを少なくすることによるメリットの一つは、CPUの回路が単純化できることです。x86_64はもとをたどれば16bitアーキテクチャである8086というCPUから出発しました[5]。おそらくは、このときは回路をなるべく単純化する必要があったので加算演算命令がこのような形になり(当時レジスタは16bit)、その後バイナリレベルでの互換性を保ちつつ新しいCPUアーキテクチャを生み出してきたという事情によって現在までそのままになっているということだと推測します。

同じアーキテクチャなのにニーモニックが複数あることも

同じアーキテクチャであればニーモニックは一つだろう、と思うとそうではありません。代表的なものはx86_64です。すでにx86_64アセンブリ言語に少しでも触れたことがあるかたはご存じかもしれませんが、このアーキテクチャにおいてはニーモニックの構文が二種類あります。名前はそれぞれAT&T構文、Intel構文です。

前節において紹介した"addq %rbx, %rax"はAT&T構文で、GASにおいてはこの構文を採用しています[6]。これに対して同じことをIntel構文で表現しようとすると"add rax, rbx"のようになります。Intel構文はその名の通り、Intelのマニュアルで採用されています。それ以外にもNetwide Assembler(NASM)で採用されています。

2つの構文の一番大きな違いはオペランドの順序が左右逆転していることでしょう。これ以外にもIntel構文ではオペランド先頭の"q"が無い、レジスタ名の前に"%"が無い、など、細かい違いがいろいろあります。構文が2つ存在する経緯、および、どこがどう違うのかについては以下の記事が参考になるでしょう。気になるかたは見てください。

本記事としては、次のことが達成できれば十分です。

  • 二つの表記を見たときに混乱せずに「そういえば二つあったっけ」と思えること
  • 2つの表記を適宜読み替えられること。とくに最初はあまり肩肘張らずフィーリングで読み替えること

ニーモニックと機械語は一対一対応していない

x86_64だとADDが20個以上ある

アセンブリ言語ソースに登場するニーモニックではないもの

拡張ニーモニック、マクロ

副作用がやたらと多い

計算したらフラグレジスタが書き換わるとか、計算直後に唐突にtestやjmpが出てきて暗黙のうちにフラグレジスタを使うとかいう初見殺し

いきなりプロセッサのマニュアルを見ると死ぬ

  • とにかくデカい(ことが多い)
  • 通読するものではなく、辞書的に使うもの
  • まずは参考サイトを見たり、(cc -S foo.cなどによって)簡単なプログラムのコンパイル結果のアセンブリソースを見て見様見真似で書いていくのがいい

関数呼び出しにはルールがある

  • 規約を知っていないといきなり何も入ってなさそうなレジスタを使っていたりして意味がわからない
  • 意味を調べようとぐぐろうにもぐぐるべきワード("呼び出し規約"や"calling convention")がわからない
  • caller save register, callee save register
  • スタック
  • フレームポインタ
  • 同じアーキテクチャでもOSや言語などによって規約はいろいろ

システムコール呼び出しにもルールがある

TBD

最適化

  • 高級言語のソースとコンパイル後のアセンブリ言語ソースはコンパイラやそのバージョンに、およびオプションによって全然違う
  • 例としてcc -S foo.cでいろいろ見てみる
  • 原因は書き方の違い、および、最適化具合の違いによるもの
  • 最初は最適化を切って見るとよい

おわりに

TBD

参考文献

TBD

脚注
  1. 作りすぎるとメモリ不足になってプログラムが強制終了させられます。 ↩︎

  2. CPUアーキテクチャによってはメモリ上のデータを直接計算に使う命令がありますが、内部的には結局レジスタにいったんデータを置いています ↩︎

  3. 正確にはハードウェアとしてのメモリとプログラムから見えるメモリは同じものではありません。知りたいかたは"仮想記憶"で検索してみてください。 ↩︎

  4. Cはメモリを直接扱えたりもするので本当に高級言語なのかといわれると答えに窮するのですが、ここではそういうことにしておいてください。 ↩︎

  5. 厳密にいうと8bit CPUである8080がもっと前にいるのですが、ややこしいので説明を省きます ↩︎

  6. 正確にはIntel構文も使えますがデフォルトはあくまでAT&T構文です。 ↩︎