ディスアセンブラを書く
概要
以下のような出力を行う簡単なディスアセンブラを書いたので作り方をまとめる。
成果物
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)
上記の画像にある典型的なフォーマットの他にもjson
やyaml
を出力したり、最大表示行数などのオプションも機能として存在する(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 NinjaやIDA: 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 // 以下省略 )
上記の命令毎に(全てではないが)複数のアドレッシングモードが対応しており、例えば"LDA
のZeroPage
"などが存在する。
命令の定義
以下の構造体に個々の命令情報を定義する。
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
構造体の配列を保持し、jump
やreturn
系の命令を終端とした一種のサブルーチンを表現する。
命令が存在する場合には以下の処理に続く。
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{} }
上記では前述のjump
やreturn
系の命令だった場合にセクジョンの区切りを設けている。
結果の表示
表示に関してはヘッダから始まり、セクションやライン、命令情報、アドレスなど先ほど解析した情報を元によしなに行う。
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が続いており、それを画像として出力するのも面白いかもしれない(過去に一度やっており気が向いたらやろうと思う)。
ファミコンのROMからキャラクタデータを読み込んで可視化してみた。(Super Mario Bros in 1985) pic.twitter.com/dsB9ekFGwG
— 0n1shi (@0n1shi) 2017年3月30日