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

マシンコードは怖くない

概要

  • 最初に学んだプログラミング言語は 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よりちょっとだけ難しいくらいだよ。最初に覚えることが多いけど(呼び出し規約やレジスタの使い方とか)。最初に教えるつもりはないけど、他の言語の基本を知った後に、実際にどう動くのかを見るのは楽しいかもね。

Hacker Newsで議論の続きを見る