6502 アセンブラ プログラミング入門

Yusuke Shinyama, Mar. 2024

概要: この記事では 8ビット CPU 6502 を 使ったアセンブラプログラミングを紹介する。 「アセンブラプログラミング」とは、プログラミング言語を使わず、 CPU のネイティブ命令列を直接書くプログラミング方法である。 6502 はいまから約50年前に開発され、 ファミコンや Apple II など多くのハードウェアで利用された。 しかし、その原理は今日のコンピュータとほとんど変わっていない。 ここでは 6502 のプログラミングを通して、コンピュータの本質を学ぶ。

  1. 6502 プログラミング入門
  2. 6502エミュレータを使った演習
  3. アセンブラを使ったプログラミング
  4. 16ビットの値を計算する
  5. ファミコン (NES) と 6502
  6. 現代のコンピュータとの違い

1. 6502 プログラミング入門

1.1. コンピュータの原理

入力装置 演算装置 出力装置 記憶装置
  1. 入力装置 … マウス、キーボードなど。
  2. 出力装置 … 画面、スピーカなど。
  3. 記憶装置 … メモリ、ハードディスクなど。
  4. 演算装置 … プログラムの実行をおこなう中心部分 (CPU)。
演習. 入力装置・出力装置・記憶装置

入力装置・出力装置・記憶装置の例を 2つずつ挙げよ。

本日使う CPU (のエミュレータ): MOS 6502

機械語 (machine language): Python や JavaScript のような現代的なプログラミング言語とは違い、 文字ではなく数値 (命令語) の列によって表現する。

6502 の機能:

記憶装置 プログラム 命令語 PC +1 CPU A X Y

CPU にある特別な変数: PC (プログラム・カウンタ)

CPU の動作 (JavaScript 風、実際にはこれは電子回路によって実現されている)
// メモリの内容 (65536要素の配列)
MEMORY = [0, 0, 0, 0, 0, ... ]
// PCは現在実行する命令の位置。
PC = 0

// 以下を永久にくり返す。
while (true) {
    // 現在の命令を調べる。
    command = MEMORY[PC]
    if (command == 1) {
        A = A + 1  // 変数 A に1を足す。
    } else if (command == 2) {
        A = A - 1  // 変数 A から1を引く。
    } else if
        ...
    }
    // 次の命令を実行。
    PC = PC + 1
}

1.2. レジスタとは

レジスタ (register): CPU における変数のこと。

6502 に装備されているレジスタ:

名前大きさ機能
PC16ビットこれから実行する命令のメモリ上の位置。
Aレジスタ8ビット計算のために使う。
Xレジスタ8ビットメモリ上の位置を指すために使う。(後述)
Yレジスタ8ビット(今回は使わない)
Zフラグ1ビット計算結果がゼロになったときに 1 になる。(後述)
Cフラグ1ビット計算結果が桁あふれしたときに 1 になる。(後述)

1.3. 16進数とは

10進数2進数16進数
000000
100011
200102
300113
401004
501015
601106
701117
810008
910019
101010a / A
111011b / B
121100c / C
131101d / D
141110e / E
151111f / F
演習. 16進数から2進数の変換

以下の 16進数を 2進数に変換せよ:

以下の 2進数を 16進数に変換せよ:

2. 6502エミュレータを使う

http://visual6502.org/ は本物の 6502 の電子回路の動きをブラウザ上で仮想的に再現するエミュレータである。

2.1. メモリに値を格納する

最初のプログラムとして、メモリ上のある位置に数値を格納する処理をやってみる。

プログラム1
0000: A9 01     ; LDA #$01 - Aレジスタに $01 を格納。
0002: 95 10     ; STA $10  - Aレジスタの値をメモリの $10 番地に格納。
0004: 00        ; BRK      - CPUの停止。

プログラムは、メモリ上の 16進数が書かれている部分を ダブルクリックして直接入力する。

命令語 (16進)バイト数アセンブリ表記機能
A9 XX 2 LDA #$XX Aレジスタに値 16進数 XX を記録する。
95 XX 2 STA $XX Aレジスタの値をメモリの XX 番地に記録する。
00 1 BRK 制御装置を停止する。

上のプログラムは、JavaScript でいえば以下のような処理に等しい:

A = 1             // LDA #$01
MEMORY[0x10] = A  // STA $10

(JavaScript では、0x640b1111 などと書けば 16進数・2進数 → 10進数に変換できる)

演習. エミュレータを使った実行

上のプログラム1を Visual 6502 エミュレータ上で実際に実行せよ。

2.2. メモリの値を増加させながらループする

次に足し算命令 ADC命令 とジャンプ命令 JMP を使ってみる。

プログラム2
0000: A9 01
0002: 95 10
0004: 69 02     ; ADC #$02 - Aレジスタに $02 を足す。
0006: 4C 02 00  ; JMP $0002 - $0002番地の命令にジャンプする。

「ジャンプ命令」を命令を実行すると、CPU は PC レジスタの値を書き換える。 つまり、指定されたアドレスの命令から実行できる。

命令語 (16進)バイト数アセンブリ表記機能
69 XX 2 ADC #$XX Aレジスタの値に 16進数で XX を加える。
4C PP QQ 3 JMP $QQPP 16進数で QQPP 番地から実行を開始する。
アドレスの上2桁、下2桁が逆になっていることに注意。 (リトルエンディアン)

JavaScript でいえば、以下のような処理に(ほぼ)等しい:

A = 1                 // LDA #$01
while (true) {
    MEMORY[0x10] = A  // STA $10
    A = A + 2         // ADC #$02
}
演習. エミュレータを使った実行

上のプログラム2をエミュレータ上で実際に実行せよ。 Aレジスタの値が FF を超えるとどうなるか?

3. アセンブラを使ったプログラム

6502 には数百種類の命令がある:

いちいち命令語の数値を調べるのは面倒くさいので、 ここでは アセンブラ (assembler) というものを使う。

3.1. 最初のプログラム (改良版)

  1. 別のサイト http://6502asm.com を開く。
  2. 以下のプログラムを入力する。
  3. Compile ボタンを押して、機械語に変換する。
  4. Run ボタンを押して実行する。
  5. Reset ボタンを押して止める。
プログラム3
LDA #$01
STA $0200

アセンブラではさらに、以下のような表記の決まりがある:

このエミュレータでは、 メモリ上の $0200$05ff の範囲が 画面の各ピクセルと連結している。 ここに値を格納すると、それが実際に画面に表示される。 つまり、ここではメモリへの書き込みが出力装置も兼ねている。 コンピュータにとって、画面やハードディスク (記憶装置) は、 どれもただの巨大な配列でしかない。

演習. エミュレータを使った実行

上のプログラム3をエミュレータ上で実際に実行せよ。

3.2. アセンブラを使ったジャンプ命令

アセンブラを使うと、プログラム中のアドレスとしてラベルを使うことができ、 命令語のバイト数を考える必要がない。

プログラム4
    LDA #$01
loop:           ; ラベル "loop" をここに設定。
    STA $0200
    ADC #$02
    JMP loop    ; "loop" のアドレスにジャンプする。

注意: ラベル (loop:) 自体はただプログラム中の位置を表すもので、実際の命令ではない。

演習. エミュレータを使った実行

上のプログラム4をエミュレータ上で実際に実行せよ。

3.3. 差分アドレッシング

差分アドレッシングという機能を使うと、 メモリ上の可変の位置のデータを読み書きできる。

プログラム5
    LDA #$01
    LDX #$00     ; Xレジスタに $00 を格納。
loop:
    STA $0200,X  ; Aレジスタの値を ($0200+X) の位置に格納。
    ADC #$02
    INX          ; Xレジスタの値を 1だけ増やす。
    JMP loop
命令語機能
LDX #$XX Xレジスタに値 $XX を記録する。
STA $ZZZZ,X Aレジスタの値を ($ZZZZ+X) の位置に格納する。 (差分アドレッシング)
INX Xレジスタの値を 1だけ増やす。

以下、JavaScript 相当の処理:

A = 1                     // LDA #$01
X = 0                     // LDX #$00
while (true) {
    MEMORY[0x0200+X] = A  // STA $0200,X
    A = A + 2             // ADC #$02
    X = X + 1             // INX
}
演習. エミュレータを使った実行

上のプログラム5をエミュレータ上で実際に実行せよ。 なぜ画面の一部しか更新されないのか?

3.4. 条件分岐

「画面の特定の場所のみ、色を変える」にはどうするか?

プログラム5
    LDX #$00
loop:
    CPX #$10     ; Xレジスタの値を $10 と比較。
    BEQ on       ; 等しければ、on に分岐する。
    JMP off      ; 等しくなければ、off に分岐する。
on:
    LDA #$01
    JMP put
off:
    LDA #$02
put:
    STA $0200,X
    INX
    JMP loop

6502 では、比較・演算命令 (ADC, CPX, INX など) の結果によって 内部のフラグ (flag) が変化する。

命令語機能
CPX #$XX Xレジスタの値を $XX と比較し、 等しければ Zフラグを 1 にする。
BEQ ラベル Zフラグが 1 ならば、 指定されたラベルに分岐する。

CPX命令は実際には何も計算してないように見えるが、 内部的には 2つの数の引き算をおこなっている。これによって、 2つの数が等しいときに結果が 0 になり、結果として Zフラグが 1 になる。

JavaScript 相当の処理:

X = 0                     // LDX #$00
while (true) {
    if (X == 0x10) {      // CPX #$10, BEQ on
        A = 1             // LDA #$01
    } else {
        A = 2             // LDA #$01
    }
    MEMORY[0x0200+X] = A  // STA $0200,X
    X = X + 1             // INX
}

3.5. 条件分岐 その2

上の条件分岐は、以下のようにも書ける:

プログラム5 (改良版)
    LDX #$00
loop:
    LDA #$02
    CPX #$10
    BNE put      ; 等しくなければ、put に分岐する。
    LDA #$01
put:
    STA $0200,X
    INX
    JMP loop
命令語機能
BNE ラベル Zフラグが 0 ならば、 指定されたラベルに分岐する。

4. 16ビットの値を計算する

MOS 6502 ではほとんどの計算は 8ビットでしかできないが、 工夫することで 16ビットの計算が可能である。じつは "ADC" 命令は 与えられた数に加えて C フラグの値も加える ようにできており、 これを使って 8ビットの数を 2回に分けて計算する。

$31 $30 01 FF 00 01 + C $31 $30
CLC        ; Cフラグをクリアする。
LDA $30    ; メモリ$30番地の値を Aレジスタに読み込む。
ADC #$01   ; A = A + 1 + 0
STA $30    ; Aレジスタの値をメモリ $30番地に書き込む。
LDA $31    ; メモリ$31番地の値を Aレジスタに読み込む。
ADC #$00   ; A = A + 0 + C
STA $31    ; Aレジスタの値をメモリ $31番地に書き込む。
命令語機能
CLC Cフラグの値を 0 にする。

4.1. 16ビット値を使った画面書き換え

以上のテクニックと以下の「間接差分アドレッシング」を組み合わせると、 256バイト (=8ビット) 以上のメモリ領域にアクセスできる。 つまり、画面のより広い領域に描画できるようになる。

命令語バイト数機能
STA ($ZZ,X) 2 (命令 1 + アドレス 1) 間接差分アドレッシング。
  1. メモリ上の ($ZZ+X) 番地に書かれている値を2バイト分 (16ビット分) 読み込む。
    ($ZZ+X) 番地の内容 … PP
    ($ZZ+X+1) 番地の内容 … QQ
    $ZZ $ZZ+1 この2バイトで表されるアドレス
  2. その値がさすアドレス ($QQPP) に A レジスタの値を書き込む。
    アドレスの上2桁、下2桁が逆になっていることに注意。
プログラム6
    LDA #$00
    STA $30
    LDA #$02
    STA $31
loop:
    LDX #$00
    LDA #$01
    STA ($30,X)  ; A をメモリ ($30+X) 番地に書かれている番地に書き込む。
??? ; 16ビットの加算をおこなう ...
JMP loop
演習. 16ビットの演算

上のプログラム 6. を完成させ、 画面全体を塗りつぶすようにせよ。

おまけ演習. いろいろなプログラムを試してみる

以下のいろいろなプログラムを試してみよう:

5. ファミコン (NES) と 6502

ファミリーコンピュータ (Nintendo Entertainment System、ファミコン) は 1983年に任天堂から発売されたゲーム機である。

ファミコンのハードウェアはかなり複雑で、 画面表示には CPU が PPU と通信する必要がある。 また、初期のゲームは最大 32KB までに制限されていた。 「スーパーマリオブラザーズ」もプログラム・ グラフィックス・音楽等込みで32KB の領域に収まっている。

ファミコンのアドレス構造

アドレス範囲機能
$0000 〜 $07FF メインメモリ
$0800 〜 $1FFF 未使用領域
$2000 〜 $2007 PPU 制御レジスタ
$2008 〜 $3FFF 未使用領域
$4000 〜 $401F APU レジスタ等
$4020 〜 $5FFF カートリッジ拡張ROM
$6000 〜 $7FFF SRAM
$8000 〜 $FFFF プログラム用ROM

6. 現代のコンピュータとの違い

現代の 最新鋭の演算装置でも、 基本的にやっていることは変わらない。ただ量は増えている。

1975年2021年
レジスタの数440
計算できるビット数864
メモリの容量65,53634,359,738,368
1秒間の命令実行数1,000,0001,000,000,000
プログラムの大きさ10,00010,000,000,000

他にも、現代のPC (サーバ、スマートフォン等) は以下のような違いがある:

現在、コンパイルされた「バイナリ」や「Docker イメージ」などと呼ばれているものの実体は すべてこれらのCPU用に書かれた機械語の命令列である。

6.1. オペレーティングシステム (OS) とは何か?

現在のコンピュータでは、一般人が上で示したような プログラムを書く必要はない。文字表示やファイル処理など非常に基本的な部分は、 「オペレーティングシステム (OS, 基本ソフトウェア)」として 最初からPCと一緒に提供されているためである。

オペレーティングシステム (OS) アプリ アプリ アプリ

また、OS は多くの仮想化処理を実現している。

OS によって作り出されている見せかけの例

結局のところ、コンピュータはみな非常に単純な原理で動いている。 これをソフトウェアの力で複雑な処理をしているように見せかけているのが、 現代のコンピュータである。


Yusuke Shinyama