世界を動かす技術を、日本語で。

マシンコードは怖くない

概要

  • 最初に学んだプログラミング言語は ActionScript、高水準なWeb系言語への興味
  • 低水準言語やマシンコードへの苦手意識を克服し、実際はそれほど難しくないと気付く
  • マシンコードには 命令セット ごとの違いがあり、ARM(aarch64)とx86-64が代表的
  • 基本概念は 命令・レジスタ・メモリ の3つ
  • 低水準の知識習得は、プログラミング全体の理解を深める鍵

マシンコードへの苦手意識と克服

  • ActionScript からプログラミングを始めた経緯
  • 高水準な Web言語 への興味、低水準言語への苦手意識
  • マシンコード(機械語)は難解だという先入観
  • Google検索 でも学習よりも否定的な情報が多い現状
  • 目標達成のため、苦手意識を乗り越える必要性を痛感

マシンコードの本質と基礎

  • マシンコードは 怖くない、基本を押さえれば理解可能
  • JSONスキーマに従ったJSONを作れるなら、マシンコードも書ける
  • 命令セット の多様性(x86-64、ARM、その他アーキテクチャ)
  • 本記事の目的は、特定命令セットの深掘りではなく、マシンコード一般の理解促進
  • 例として ARM 64bit(aarch64) を中心に解説、後半でx86-64にも触れる

マシンコードの3要素

  • 命令 :実行する処理内容(加算、移動、減算、ジャンプなど)
  • レジスタ :値を一時的に保存する場所、変数のような役割
  • メモリ :データの格納場所、リストや配列のイメージ

ARM命令の構造

  • 例:加算命令(add immediate)のビット構成
    • 各ビットは命令の要素や値を表現
    • sf :64bit/32bitレジスタの指定
    • sh :シフト指定、imm12と連携して大きな数値を表現
    • imm12 :12ビット即値(定数)
    • Rn/Rd :ソース・デスティネーションレジスタ指定
  • 命令は データ構造 として捉えられる

レジスタの役割

  • ARM(AArch64)では X0~X30 の31個の汎用レジスタ
  • レジスタ番号は 5ビット で表現
  • 呼び出し規約(Calling Convention)による役割分担
  • 実際のコーディングでは アセンブリ記法 を利用
    • 例:add x1, x0, #0x2a(42を加算)

メモリ操作命令

  • 例: STR(store)命令 でレジスタの値をメモリに保存
    • メモリアドレス+オフセットに値を書き込む
    • xビットで64bit/32bit指定
    • アセンブリ記法例:str x2, [x1, #0x2]

x86-64命令の特徴

  • 命令・レジスタ・メモリ の基本構造は同じ
  • レジスタ名は rax, rbx, rcx, rdx, rsi, rdi, rbp, rsp, r8~r15
  • x86は 可変長命令、32bit固定ではない
  • 命令構成要素
    • REXプレフィックス :64bit操作やレジスタ拡張
    • ModR/M :レジスタ/メモリ指定
    • OpCode :操作内容を表す数値
  • 例:REX.W + C7 /0 id(32bit即値を64bitレジスタへ)

まとめと学習のすすめ

  • 低水準の知識は プログラミング理解の底上げ に不可欠
  • ライブラリ依存からの脱却、根本理解の深化
  • ドキュメントや解説の分かりにくさが障壁の主因
  • Compiler Explorer などのツールで実際に試すことの重要性
  • 低水準が苦手な人ほど、基礎から学ぶ価値

Hackerたちの意見

俺は80年代中頃に8ビットのBBCマイクロコンピュータでプログラミングを独学したんだ。BASICのリスティングを打ち込んでね。BASICは結構理解できて、自分で構造化されたBASICプログラムも書けたけど、機械語はいつも手が届かない感じだった。最初に足し算や引き算を教える本を読もうとしたけど、BASICでできるような入力のポーリングや音を鳴らしたり、画面に文字を描いたりするような複雑なことにどうつながるのか全然わからなかった。だけど、上級者向けのガイドを手に入れて、OSのコマンドを知ったときにやっと腑に落ちたんだ。複雑なことっていうのは、正しいデータを正しいメモリやレジスタに配置して、特定のOSコマンドを呼び出して「これが欲しいデータだよ」って言うだけだったんだ。

そうそう、問題は教育方法が「計算機」とOSの部分をどうつなげるかを明確にしてないことだよね。子供の頃に俺もこれに悩んだ。足し算がどうやって画面に何かを描くことにつながるの?もちろん、直接は関係ないんだけど、ハードウェアやOSに特有の情報が必要なんだよね。

最近Forthを作り始めたんだけど、インタプリタやトランスパイラじゃなくて、メモリのバイトにマッピングしてそのまま実行することにしたんだ。この最適化しないJITは、今まで見た怖い記事やコメントが言ってたよりずっと簡単だったよ。もう数週間でAarch64とRISC-Vの両方で動かす準備ができてるところだ。

とても興味深いね、ソースを共有してくれる?

俺はWebAssembly WAT(リスプに文法的に似た中間表現)を使って、リスプのASTをほぼそのままWAT IRにマッピングして、そこからバイトコードを出力するってことをやったんだ。結構楽しかったよ。

昔、Java版のタイガー言語を扱ってたときに似たようなアプローチをしたよ。コンパイラのIRをアセンブリマクロにモデル化して、古典的なUNIXコンパイラのビルドパイプラインに従ったんだ。だから、世界で最もパフォーマンスの良いコンパイラではなかったけど、最終的におもちゃのコンパイラが実際の実行可能ファイルを生成するのを楽しめたんだ。

まあ、そんなに難しいわけじゃないんだけど、一部の命令セットのエンコーディングが本当にクソで、特に32ビットと64ビットのx86がその代表例だし、Thumb-2もそれに続く。あと、既存のコードを動的にパッチする場合、現代のOS(特に「ハードニング」パッチ)によって、独自の互換性のない方法で面倒になることもあるし(libffiの大半を参照)、現代のCPUは自己修正コード周りでバグが多いからね。それ以外は、どこにでも行くためには、面倒だけど単純な作業がかなり必要だよ。

俺にとって機械語の「怖い」部分は、実際のロジックじゃなかったんだ。いつもあのヘックスやニーモニックの壁を見つめて、秘密のデコーダーリングが必要だと感じてたよ!

うん、それは役に立たないね。俺には、何か複雑なテトリスゲームみたいに見える。プログラムをレジスタや命令のピースで表現できるかもしれないけど、今あるツールはすごく簡潔で、テキストベースなんだよね。

ASMが最初のプログラミング言語として合理的だって人に納得させようとしたことがあるんだけど、神秘的なアートみたいに思われてるのがあんまり良くないんだよね。実際、指示はシンプルなんだ。やること自体は難しくないけど、基本的なレベルで考えられないスケールのタスクになると難しくなる。大きなプログラムを作るのはすぐに面倒になるし、そんなに難しくはないけど、管理が大変になる。だからこそ、最初の言語として教えるべきなんだよ。簡単なことを学びながら、プログラミング言語が使われる理由も学べる。問題を解決することを教えてから、もっと高度なプログラミングの概念を教えるべきだと思う。

個人的には、アセンブリの種類によると思う。学ぶのに最適なISAはおそらくMotorola 68000で、その次がいくつかの8ビットCPU(6502、6809、Z80)かな。ARM1も多分いいけど、扱ったことはないんだ。x86アセンブリは見た目が悪いと思ってたし(IntelでもAT&Tでも関係なく)。> 現代のツールを使うと大きなプログラムを書くのはすぐに面倒になるけど、アセンブリコーディングは意外と生産的になれるよ。例えば、8ビットのホームコンピュータ用にVSCodeの拡張機能を書いたことがあって、ちょっとしたデモも作ったんだけど、それが昔のデバイス上のアセンブラや、数字で機械語を打ち込むのと比べてずっと生産的に感じた。

いいマクロアセンブラがあれば、Cよりちょっとだけ難しいくらいだよ。最初に覚えることが多いけど(呼び出し規約やレジスタの使い方とか)。最初に教えるつもりはないけど、他の言語の基本を知った後に、実際にどう動くのかを見るのは楽しいかもね。

ASMを初心者に最初の言語として教える最大の問題は、非常に面倒でエラーが起きやすく、細部に敏感だってことだよ。それに、構造がなくて、将来学ぶ言語とは全く異なる制御フローのプリミティブを使うから、複雑なプログラムを学ぶ準備ができていないことになる。だから、ifやwhile、(ローカル)変数、スコープ、型、さらには本物の関数呼び出しもない言語を教える意味はあるのかな?コンピュータがどう機能するかを理解するための良い練習にはなるし、教育において明確な役割があるのは認めるけど、最初に学ぶ言語としては最悪だと思う。

最初はZ-80アセンブリから始めて、その後BASIC、6502アセンブリ、そしてCやPerlみたいな高級言語に進んだんだ。アセンブリのおかげで、内部で何が起こっているかの基礎ができたと思う。アセンブリを他の言語と同じ「言語」と呼ぶかは微妙だけどね。命令はあるけど、文はないし、文法も本当にないから。もし一般向けのプログラミングコースを教えるなら、まずはBASICを少し触れて、変数やループみたいな広い概念を紹介してから、アセンブリを少し見せて「これがその時に何が起こっているかだよ」って感じで進めるかな。それからCに進んで、デバッグ用に生成されるアセンブリを見た時に、レジスタや分岐みたいな概念には少し慣れているだろうし。だから、僕がやった順番とはちょっと違うけど、似たような感じだね。

初心者にはすぐにポジティブなフィードバックが必要だよね。アセンブリ言語ではそれが無理なんだ。

「ASMが最初の段階の教育言語として合理的だと人々を説得しようとしたことがある。」エンジニアリングハードウェアを準備している人を教えているのでなければ、ASMはこの目的には絶対に間違った言語だと思う。最初の理由は、プログラミングは問題解決に関するものであって、特定のアーキテクチャの詳細をいじることではないから。ASMは問題領域の言語で解決策を明確に表現するのが得意じゃない。領域の言語でプログラミングする代わりに、実装の詳細であるビットをひっくり返すことに忙しくなっちゃう。これはハードウェアとインターフェースを取るための言語なんだ。もっと厄介な結果は、ASMを教えることでハードウェアが神格化されて、コンピュータサイエンスやプログラミングが計算デバイスに関するものであるという考えが強化されること。実際にはそうじゃない。計算デバイスは主題に関しては完全に補助的なものだ。実用的には絶対に必要だけど、プログラミングが本質的に関わっているものではない。天文学者が望遠鏡をうまく操作できるのは良いけど、彼は望遠鏡を研究しているわけじゃない。望遠鏡のエンジニアがそれをやるんだ。

昔、最初にBASICを学んで、その後Cを学ぼうとしたけどポインタが理解できなくて、ASMを学んだらポインタが明確になって、またCに戻ったんだ。Cを使ったりハードウェアに関わることをするなら、ASMを学ぶのは本当に役立つと思うよ。マシンがどう動いてるかを理解するためにはね。

彼らに物事をやらせるのは難しくないが、基本的なレベルで考えられるスケールを超えるタスクになると難しさが出てくる。確かに、難しいタスクに取り組まなくても知的なメリットは得られるよ。コンピュータが何かをしっかり理解したら、SICPのアイデアを吸収できるようになる。

ほとんどのCSプログラムでは、学生は早いうちにアセンブリを学ぶと思うよ。最初の言語ではないかもしれないけど、ほとんどのアーキテクチャのコースでは必須の第二言語としてね。

アセンブリ言語の入門コースでTAをやってたんだけど、そのおかげでアセンブリに苦しむ学生たちと一対一で向き合って、彼らがクラスを通過するための障害を乗り越える手助けをしてたんだ。アセンブリ言語は、最初のプログラミング言語としては合理的じゃないよ。プログラミング教育には不向きな要素がたくさんあるから。特に、アセンブリは構造がない。変数なんて存在しないし、関数もない。慣習でそれっぽくすることはできるけど、間違いを犯すと(プログラミング入門の学生は必ず間違える)、何かが間違ってるって教えてくれるものがないから、ただ間違った結果が出るだけなんだ。

Z80用の似たような(もっと深い)オペコードデコーディングのレシピがあるよ、エミュレーター開発に役立つよ:http://www.z80.info/decoding.htm 実際に機械語でプログラミングするには、内部のオペコード構造を理解するのはあまり役に立たないけど、通常はアセンブラがないときに、左側にすべての可能なアセンブリ命令、右側に対応する機械語のバイトがあるルックアップテーブルを使ってた。機械語を16進エディタに打ち込むのは可能だけど、アセンブラがないときの最後の手段としてしかお勧めしないよ。主に、すべてのグローバル定数やサブルーチンのエントリアドレスを追跡しなきゃいけないからね。アセンブラがやってくれることだから、コードをパッチするために、物を動かさずに済むように戦略的に隙間を空けておく必要があるんだ。

ASMプログラミングは楽しいよ。機械語(ASMがエンコードするもの)は怖くないけど、扱うのは本当に面倒だよ。そういう苦痛を感じたいなら、Casey Muratoriの「パフォーマンス意識のあるプログラミング」コースの最初の部分をお勧めするよ。

知識を維持するには、実際にプロダクションでやる必要があると思う。趣味でやるだけだと、ほとんどの人はある時点で諦めちゃうんだよね。無駄に頭を壁に打ち付ける理由がないから。解決すべき本当の問題が必要だよ。

1982年には、ZX81をプログラムするためにアセンブリを手作業で16進数に変換してた。BASICが遅すぎたからね。アセンブリを紙に書いて、リファレンステーブルを使って16進数に変換して、シンプルなBASICのFORループを使って、機械語のために確保したメモリに値をPOKEしてた。すべての値をPOKEしたら、テープに保存してRAND USR 16514で実行してた。そのメモリアドレスは今でも脳裏に焼き付いてる。良くも悪くも怖くもなくて、ただ自分が作りたいプログラムを作るためにやらなきゃいけなかったことだったんだ。

僕も同じことをやったよ、48kのスペクトラムで、1年か2年後にね。関数の間にNOPを入れるのも忘れずに、変更があった時に相対ジャンプ命令を再計算しなくて済むようにしたんだ。

この1年くらい、近所のティーンエイジャーの男の子たちが日曜日の午後にプログラミングをしに来るんだ。最初はすごく簡単に始めて、テキストベースのタスクをやってからpygameを見せた。Pythonの内部がどうなっているかも教えてあげたいと思ってる。Python自体がただのプログラムだってことをね。僕がプログラミングを学んだのは70年代後半で、TRS-80やApple IIは機械語レベルでも理解しやすかった。エミュレーターを使ってその体験を再現できるけど、それもまた抽象的に感じるんだ。彼らにはもっと直接的な体験をしてほしい。でもx86はすごく広範囲で複雑な命令セットだから、かなり intimidating だよね。もちろん、簡略化した命令のサブセットに留めるけど、それでもPCで動かすための出力を作るのは、昔の8ビットマシンで特定の場所に書き込んで画面に表示されるのとは比べ物にならないくらい手間がかかる気がする。

僕が理解できたきっかけになったことを試してみるといいよ、x86が主流になった後でもね。Logisim(または新しいDigitalみたいな)で動いているCPUを見せて、プログラムをROMに入れるとどうなるか、配線が光ったりゲートが切り替わったりデータラインがアクティブになったりする様子を見せるんだ。

Steamで「Human Resource Machine」を買ってあげるか、(できればDRMなしの)Good Old Gamesで手に入れてあげて。これは昔の8ビットCPUで機械語を書くのがどんな感じだったかをゲーム化したものなんだ。HRMのパズルチャレンジは、単一のアキュムレータと非常にシンプルな命令という自然な制約から生まれているから、本物の体験だよ。Zachtronicsのゲームみたいに不自然に制約を加えたものではないから、良いけど学習ツールとしてはあまりお勧めしないかな。

このスレッドを読んでいると、アセンブリ言語を学ぶことを推奨しているほとんどの投稿者は、実際の生産環境で使ったことがない印象を受ける。マジでクソだよ!圧倒的多数のプログラマーにとって、アセンブリは全くメリットがない。僕はBASICを学んだ後に(MC6809)アセンブリを学んだんだ。コンパイラがまだかなり高価だった時代に、組み込みシステムのプログラマーになったし、ケチな会社で働いてた。キャリアの最初の10年間で、いろんなマイクロコントローラのために膨大な量のアセンブリを書いたけど、Cでプログラミングするよりも得られたメリットは正直言ってあまりなかった。全てがすごく時間がかかっただけ。ある時、サイドギグで、8ビットのアキュムレータしかないプロセッサで16ビットの長除算ルーチンを書かなきゃならなかった。それがきっかけで、もうアセンブリプログラムは書かないと決めたんだ。幸いなことに、その頃にはgccが小さなプロセッサをサポートしていたから、Atmel AVRシリーズに切り替えられたよ。

アセンブリ言語を学ぶことを推奨している人のほとんどは、実際のプロダクション環境で使ったことがない… 大多数のプログラマーにとって、アセンブリは全くメリットがない。よくわからないな。アセンブリがプロダクション環境で役立つか楽しい必要があるの?学ぶことが役立つためには。大学でいくつかのアセンブリのバリエーションを教わったけど、マシンが実際に何をしているのかを理解するのにはかなり役立ったよ。Cよりもね。抽象化は結局、何かに根ざさないといけないから。

実際にはプロダクションで使ったことはないけど、学ぶことで絶対にメリットがあったよ。アセンブリを学ぶまでポインタが理解できなかったからね。

一度、サイドギグで、8ビットのアキュムレータしかないプロセッサで16ビットの長除算ルーチンを書く必要があった。その時点で、もう二度とアセンブリプログラムを書くことはないと決めた。こういう仕事は楽しそうだな!明確な要件がある技術的な挑戦ができる。数独パズルを解くのが好きな人もいれば、プログラミングパズルを解くのが好きな人もいる。俺は「大多数のプログラマー」じゃないってことかな。

6502やZ80のような8ビットのホームコンピュータCPUでは、Cのような高級プログラミング言語は選択肢にならなかった。パフォーマンスを無駄にしすぎるから(手書きのアセンブリに比べてBASICは簡単に100倍遅かったし)。Forthはパフォーマンス的にはかなり良かったけど、良いマクロアセンブラの上くらいだね。そして8ビットの後、アミーガでのアセンブリコーディングは純粋な楽しみだったよ。大きなプログラムでも、素晴らしい68k ISAのおかげで、アセンブリコーディングが便利になるようにアミーガのハードウェアとOSが書かれていたからね(Cは68kではずっと良かったけど、真剣なプログラムのほとんどはCとアセンブリのミックスを使ってた)。それに、今アセンブリコードを書くことはそれほど重要じゃないけど、コンパイラの出力を見て、なぜコンパイラが俺の高級コードを台無しにしたのかを理解するためには、アセンブリコードを読むことは絶対に重要だよ。

ほとんどの人はプロダクションで使うチャンスがないよね。そこから憧れが生まれたんだと思う。現代のアジャイルなウェブやデータ開発シーンに疲れ果てて、利害関係者が数時間ごとにプレッシャーをかけてくることなく、特定の分野に深く掘り下げたいと思ってる。アセンブリプログラミングはそれを便利に提供してくれる。俺は、強制的でも自発的でも、システムの理解を深め、低レベルのコードを脳内で実行する能力を大幅に向上させるための試練だと考えてる。苦痛なのか?確かにそうだけど、これは技術的なスキルをもたらす苦痛だよ。ほとんどの人は、ちゃんと苦しむ特権がない。君は、不完全なドキュメントや非常に低レベルのコードで頭を壁に打ちつける苦しみを選ぶ?それとも、不完全なドキュメントや層層の抽象化、毎日変わる利害関係者の要求に苦しむことを選ぶ?俺にとっては簡単な選択だと思う。アセンブリ言語やCの仕事があれば、今の給料の半分でやるよ。

C言語を発見する前に、アセンブラプログラミングをたくさんやってたんだ。だから、Cは1時間くらいで覚えられたよ。アセンブラを知らないと、Cで何が高コストな操作か、どれが良いコードを生成するかの視点がちょっと欠けちゃうんだよね。例えば、プログラムのデバッグには生成されたアセンブラを見なきゃいけないこともある。最近、わざとヌルポインタをデリファレンスしても例外が発生しない理由を考えてたんだけど、アセンブラを見たら、そのための命令が生成されてなかった。ヌルポインタのデリファレンスは未定義の動作だから、コンパイラはそのための命令を生成する必要がなかったんだ。

アセンブリを書くこと自体は、ほとんどの開発者にはあまりメリットがないと思う。でも、アセンブリを読んで理解することは一般的に役立つよ。完全なソースコードがないバイナリやクラッシュダンプのデバッグができるし、Windowsに付属するDLLやサードパーティのDLLも扱える。コンパイラ(従来のものやJIT)によってソースコードがどう変わったかを理解するのも役立つし、パフォーマンス最適化をする際には特に重要だよね。

アセンブラで書くのが本当に必要なわけじゃないこともあるよね。例えば、AArch64で定数をレジスタにロードする時とか。これをやるための命令はちょっと変わってて、正しい組み合わせで値をロードしたかどうか見極めるのが難しいんだ。だから、コンパイラにやらせるのが一番だよ(それかgodbolt.orgを使って正しいミックスを得るとか)。浮動小数点定数も同じ。AArch64のコードジェネレータでこのコードシーケンスを正しく設定できたら、もう二度と考えなくて済むからね!

現代のコンパイラは、手でアセンブリを書くよりも最適化されたコードを生成するのが得意だって意見も聞いたことがある。どれだけ真実かはわからないけど、現代のCPUの理解しがたい複雑さを考えると、信じられそうだよね。

私はアセンブリからキャリアをスタートさせて、時間が経つにつれて減ってきた。ゲーム開発の仕事の終わり頃には、まだたくさんのアセンブリを読んでたけど、書くことはなくなった(代わりにインライン関数を使ってた)。書くのは確実に遅かったけど、Cではできない、または難しいことがいくつかあったんだ。- テールコールの保証 - メモリに触れずに複数の値を返す - スタックポインタを一般的なポインタとしてメモリに書き込むために使う - コルーチンをサポートするためにスタックポインタを変更する - 自分たちのレジスタ/呼び出し規約を使う(例えば、すべてのルーチンで使えるようにレジスタを確保する) - よく使うルーチンや高速なロングジャンプのためにスタックを解体してレジスタのセットアップを減らす - VMの「ジャンプテーブル」を使って、ジャンプ先を知るための間接参照を必要としない

マシンコードは怖くないけど、その本質は誤解されがちだよね。命令をコードブロックにまとめるのを飛ばすと、次に論理的に出てくるのが関数だ。関数はメモリ内のコードやデータへの参照を持ってる。メモリ内で関数を移動させたいなら、リロケーションの概念を導入して、これらの参照を注釈する必要があるし、特定の場所に固定するためのリンカも必要になる。でも、リンカが仕事を終えたら、その関数はもうリロケータブルじゃなくなる。動かせないってこと… それをまともな人が言うかもしれないけど、リンカの仕事を元に戻せるなら、実行ファイルからリロケータブルな関数を抽出できるんだ。そうやって新しい実行ファイルに再利用できるし、最初にデコンパイルする必要もない。結局、抽出したものが元のリロケータブルな関数と同じなら、同じことができるからね。このプロセスを実行ファイル全体に繰り返せば、部品を剥がして、リンカで再組み立てる準備ができる。いくつかの部分を変えれば、オブジェクトファイルを置き換えるかのように修正できるし、バイナリをその場でパッチ当てるときの制約もなくなる。マシンコードはレゴブロックみたいなもので、ちょっと変わった視点(と、デリンクの技術を磨くための時間)があればそれに気づけるんだ。