レガシーガジェット研究所

気になったことのあれこれ。

ディスアセンブラをつくる

概要

以下のような出力を行う簡単なディスアセンブラを書いたので作り方をまとめる。

成果物

github: https://github.com/0n1shi/dines

上記はファミコンで使用されているCPU"MOS Technology 6502"(以下6502)のカスタムモデルを対象にしたディスアセンブラである(余談だが"MOS Technology 6502"はファミリーコンピュータApple Ⅱなどで採用されている)。

NAME:
   Dines - A disassembler for customed 8-bit microprocessor, "MOS Technology 6502" in Nintendo Entertainment System written in Golang.

USAGE:
   dines [global options] command [command options] [arguments...]

VERSION:
   v1.0.1

COMMANDS:
   help, h  Shows a list of commands or help for one command

GLOBAL OPTIONS:
   --rom value     A file path of NES ROM
   --output value  output format, "json" or "yaml", default is like a typical diassembler (default: normal)
   --color         color output (*only available without "output" option) (default: false)
   --max value     max number of lines of output excluding header (*only available without "output" option) (default: -1)
   --help, -h      show help (default: false)
   --version, -v   print the version (default: false)

上記の画像にある典型的なフォーマットの他にもjsonyamlを出力したり、最大表示行数などのオプションも機能として存在する(helpの出力を盛り上げたかったというのは否めない)。

ディスアセンブラとは

Wikipediaで調べると以下のように定義されている。

実行ファイルないしオブジェクトファイルの機械語コード(とシンボルテーブルなどの付随情報)を基に、アセンブリ言語ソースコードを生成する 引用: https://ja.wikipedia.org/wiki/%E9%80%86%E3%82%A2%E3%82%BB%E3%83%B3%E3%83%96%E3%83%A9

有名なモノにはBinary NinjaIDA: Interactive Disassemblerなどがあるが、今回書いたのはリッチなGUIを持たずCUIのみで動作する。

実装

ここから実装の大まかな流れを説明する。CPUに関する事前知識と実際のコードを交えた実装の概要となる。

事前知識

CPUレジスタ

レジスタは以下の6種類のみで非常にシンプルなものとなっている。特徴的なのはスタックポインタ(SP)やインデックスレジスタ(X)でプログラムカウンタが16bitと64KBにアクセス可能なのに対して、SPやXは8bitとアクセス可能な範囲がかなり限定されている。

レジスタ サイズ 名前 用途
A 8bit アキュームレータ 汎用演算
X 8bit インデックスレジスタ アドレッシング、カウンタなど
Y 8bit インデックスレジスタ アドレッシング、カウンタなど
S 8bit スタックポインタ スタックの位置を保持
P 8bit ステータスレジスタ CPUの各種状態を保持
PC 16bit プログラムカウンタ 実行している位置を保持

アドレッシングモード

6502の1つの特徴である発売当時としては豊富だったアドレッシングモードは以下の13種類が存在する。貧弱なレジスタを表現力豊かなアドレッシングモードで補っているのがわかる(当時価格破壊となった6502の発売は設計をシンプルにすることで実現された)。

名称 表記例 動作
Implied TAX AをXにコピー
Accumulator LSR A Aを左に1bitシフト
Immediate LDA #IM8 即値IM8をAにロード
Zeropage LDA IM8 アドレス「IM8」の8bit値をAにロード
Zeropage, X LDA IM8, X アドレス「IM8 + X」の8bit値をAにロード
Zeropage, Y LDA IM8, Y アドレス「IM8 + Y」の8bit値をAにロード
Relative BEQ IM8 ステータスレジスタZがオンの時、アドレス「PC + IM8」へジャンプ
Absolute LDA IM16 アドレス「IM16」の8bit値をAにロード
Absolute, X LDA IM16, X アドレス「IM16 + X」の8bit値をAにロード
Absolute, Y LDA IM16, Y アドレス「IM16 + Y」の8bit値をAにロード
(Indirect) JMP (IM16) アドレス 「アドレス「IM16」の16bit値」へジャンプ
(Indirect, X) LDA (IM8, X) アドレス「アドレス「IM8 + X」の16bit値」の8bit値をAにロード
(Indirect), Y LDA (IM8), Y アドレス「アドレス「IM8」の16bit値 + Y」の8bit値をAにロード

メモリマップ

メモリマップは以下のようになっており今回書いたディスアセンブラの出力もプログラムROMの先頭アドレス(0x8000)を開始位置としている。

アドレス 内容 ミラーアドレス
$0000-$07FF WRAM
$0800-$1FFF 未使用 $0000-$07FF
$2000-$2007 I/Oポート (PPU)
$2008-$3FFF 未使用 $2000-$2007
$4000-$401F I/Oポート (APU, etc)
$4020-$5FFF 拡張RAM(特殊なマッパー使用時)
$6000-$7FFF バッテリーバックアップRAM
$8000-$BFFF プログラムROM LOW
$C000-$FFFF プログラムROM HIGH

ディスアセンブル

ここから実際のコードも交えて実装を見ていく。

CPUとアドレッシングモードの定義

まず最初に上記で説明したアドレッシングモードを定義する。

type AddressingType int

const (
    AddressingTypeImplied = AddressingType(iota)
    AddressingTypeAccumulator
    AddressingTypeImmediate
    AddressingTypeZeroPage
    AddressingTypeZeroPageX
    AddressingTypeZeroPageY
    AddressingTypeRelative
    AddressingTypeAbsolute
    AddressingTypeAbsoluteX
    AddressingTypeAbsoluteY
    AddressingTypeIndirect
    AddressingTypeIndirectX
    AddressingTypeIndirectY
)

次にCPUの命令種別を定義する。ここでは命令の種別のみを定義しアドレッシングモードの情報は含まない。

type OpcodeType int

const (
    OpcodeLDA = OpcodeType(iota)
    OpcodeLDX
    OpcodeLDY
    OpcodeSTA
    OpcodeSTX
    OpcodeSTY
    OpcodeTXA
    OpcodeTYA
    OpcodeTXS
    OpcodeTAY
    OpcodeTAX
    OpcodeTSX
   // 以下省略
)

上記の命令毎に(全てではないが)複数のアドレッシングモードが対応しており、例えば"LDAZeroPage"などが存在する。

命令の定義

以下の構造体に個々の命令情報を定義する。

type Instruction struct {
    OpcodeType     OpcodeType // 命令種別
    AddressingType AddressingType // アドレッシングモード
    Bytes          int // sizeof(opcode + operand), 実際はアドレッシングモードがわかればこの値は必要ない
    Cycle          int // 今回は使用しない
}

上記で定義した構造体を値にMapを定義し命令に対応するOpcodeをキーとする。

var InstructionMap = map[int]Instruction{
    0x00: {
        OpcodeType:     OpcodeBRK,
        AddressingType: AddressingTypeImplied,
        Bytes:          1,
        Cycle:          7,
    },
    0x01: {
        OpcodeType:     OpcodeORA,
        AddressingType: AddressingTypeIndirectX,
        Bytes:          2,
        Cycle:          6,
    },
    0x05: {
        OpcodeType:     OpcodeORA,
        AddressingType: AddressingTypeZeroPage,
        Bytes:          2,
        Cycle:          3,
    },
    0x06: {
        OpcodeType:     OpcodeASL,
        AddressingType: AddressingTypeZeroPage,
        Bytes:          2,
        Cycle:          5,
    },
    0x08: {
        OpcodeType:     OpcodePHP,
        AddressingType: AddressingTypeImplied,
        Bytes:          1,
        Cycle:          3,
   },
   // 以下省略
}

ここまでで必要な情報は粗方定義ができている。

ROMヘッダの読み込み

ファミコンのROMのヘッダは16byteで以下のような構造になっている。

bit 用途
0-3 マジックバイト $4E $45 $53 $1A ("NES")
4 プログラムROMのサイズ(16KB単位)
5 キャラクタROM(画像データ)のサイズ(8KB単位)
6 マッパー(ROM)種別やカートリッジにバッテリを含むかなど
7 マッパー(ROM)種別など
8 Flags 8 - プログラムRAMサイズ(稀に拡張として利用される)
9 Flags 9 - TVシステム(稀に拡張として利用される)
10 Flags 10 - TVシステムやプログラムRAMが含まれるかなど(稀に拡張として利用される)
11-15 未使用(パディング)

今回特に必要なのはプログラムROMのサイズで、ヘッダ以降続くプログラムROMを何バイト読む必要があるのかを決定する。

以下で読み込んだROMデータからヘッダ情報の一部(各種ROMのサイズや個数、マッパー種別など)を抽出している。

func disassembleHeader(data []byte) (*Header, error) {
    header := &Header{}
    header.ProgramBank = &Bank{
        Count: int(data[4]),
        Size:  int(data[4]) * ProgramBankSize,
    }
    header.CharacterBank = &Bank{
        Count: int(data[5]),
        Size:  int(data[5]) * ProgramBankSize,
    }
    header.Mapper = int(data[7]&0xF0) | int((data[6]&0xF0)>>4)
    return header, nil
}

プログラムROMの解析

まず以下のforで順次データを読んでいく。

for index := HeaderSize; index < HeaderSize+(ProgramBankSize*programBankCount); {
   :
}

上記でヘッダサイズ(プログラムROMの開始位置)でROMデータ配列のインデックス(index)を初期化し、それをヘッダサイズ+プログラムROMサイズを上限に処理を繰り返す。以降for文内での処理となる。

次にディスアセンブラの出力1行に対応するLine構造体を初期化する。

line := &Line{}
line.Address = ProgramROMStartAt + index - HeaderSize

Line構造体は前述の命令情報(Instruction)とアドレスを保持する構造体となっており、上記ではインデックスを起点に命令のアドレスを初期化する。

次にOpcodeを読み込む。

opcode := data[index]
ins, ok := InstructionMap[int(opcode)]

ROMデータを読み込み、それ対応するOpcodeが存在するかを確認する。

存在しない場合には以下の処理が続く。

if !ok {
   line.Data = append(line.Data, int(opcode))
   index++
   section.Lines = append(section.Lines, line)
   continue
}

存在しない場合にはdb命令とみなす。sectionは複数のLine構造体の配列を保持し、jumpreturn系の命令を終端とした一種のサブルーチンを表現する。

命令が存在する場合には以下の処理に続く。

line.Instruction = &ins
for i := index; i < index+line.Instruction.Bytes; i++ {
   line.Data = append(line.Data, int(data[i]))
}
section.Lines = append(section.Lines, line)
index += ins.Bytes

命令の情報と実際のデータ(Operandを含む)保存する。

最後に以下の処理でセクションを区切る。

if IsEndOfSubRoutinue(ins.OpcodeType) {
   sections = append(sections, section)
   section = &Section{}
}

上記では前述のjumpreturn系の命令だった場合にセクジョンの区切りを設けている。

結果の表示

表示に関してはヘッダから始まり、セクションやライン、命令情報、アドレスなど先ほど解析した情報を元によしなに行う。

magic number: NES
program Bank: 2 (32768 byte)
character Bank: 1 (16384 byte)
mapper: 0 (NROM)

0x8000: 78              sei
0x8001: D8              cld
0x8002: A9 10           lda #$10
0x8004: 8D 00 20        sta $2000
0x8007: A2 FF           ldx #$FF
0x8009: 9A              txs
0x800A: AD 02 20        lda $2002
0x800D: 10 FB           bpl $00FB      # to $800A
0x800F: AD 02 20        lda $2002
0x8012: 10 FB           bpl $00FB      # to $800F
0x8014: A0 FE           ldy #$FE
0x8016: A2 05           ldx #$05
0x8018: BD D7 07        lda $07D7, X
0x801B: C9 0A           cmp #$0A
0x801D: B0 0C           bcs $000C      # to $802B
0x801F: CA              dex
0x8020: 10 F6           bpl $00F6      # to $8018
0x8022: AD FF 07        lda $07FF
0x8025: C9 A5           cmp #$A5
0x8027: D0 02           bne $0002      # to $802B

課題

実装自体に複雑な箇所は特になく一見簡単そうだが課題も残った。

初期化データ

実際にGithub上で公開されているROMのアセンブリコードなどを読むと(ELFフェイルで言う)データセクション(.data)が多数存在しており、それらを厳密にディスアセンブルするのは非常に難しい。

ファミコンのROMにはELFのようなデバッグ情報は存在しないため現状考えられる手段としてはエミュレータなどで実際の動作をふまえて解析を行うくらいしかない。

展望

キャラクタROMの復元

上記で形跡したプログラムROM以降にはキャラクタROMが続いており、それを画像として出力するのも面白いかもしれない(過去に一度やっており気が向いたらやろうと思う)。

参考

www.amazon.co.jp