C言語でNESアプリを作成(標準関数を使わないアプリの作成)

いよいよ、ここからが本番です。
標準関数を使うアプリケーションも作成できますが敢えて使わず、C言語の構文でプログラムが書けることに挑戦していきます。
アセンブラに比べたらプログラムが書きやすいかと思いきや、メモリや速度の面では不利ですので、色々工夫を凝らしていくこととなります。
ただ、全くアセンブラを使わないわけではなく、C言語で書きにくい箇所は素直にアセンブラを使います。

ベースプロジェクト
マッパー0向けにメモリレイアウトの変更を行い、標準関数を使わないベースとなるプロジェクトを用意します。
普通のC言語の使い方から外れますので、いくつか注意する点があります。
メモリレイアウト
リンカが参照するコンフィグレーション・ファイルの設定を変更します。
0x6000〜0x7FFFの拡張RAM領域の定義を外し、RAM領域の利用方法を変更します。
nes.cfgとして用意されているコンフィグレーション・ファイル内の定義は以下のようになっています。
SYMBOLS {
    __STACKSIZE__: type = weak, value = $0300; # 3 pages stack
}
MEMORY {
    ZP:     file = "", start = $0002, size = $001A, type = rw, define = yes;

    〜中略〜

    # standard 2k SRAM (-zeropage)
    # $0100-$0200 cpu stack
    # $0200-$0500 3 pages for ppu memory write buffer
    # $0500-$0800 3 pages for cc65 parameter stack
    SRAM:   file = "", start = $0500, size = __STACKSIZE__, define = yes;

    # additional 8K SRAM Bank
    # - data (run)
    # - bss
    # - heap
    RAM:    file = "", start = $6000, size = $2000, define = yes;
特徴は、拡張RAM領域を大域変数用に使っていること。
nes.cfgをコピーして別ファイルを作り、RAM領域側に収めるよう調整します。
以下は、改変後の定義です。
SYMBOLS {
    __STACKSIZE__: type = weak, value = $0200; # 2 pages stack
}

MEMORY {
    ZP:         start = $0000, size = $0100, type = rw, define = yes;

    # INES Cartridge Header
    HEADER:     start = $0000, size = $0010, file = %O, fill = yes;

    # 2 16K ROM Banks
    # - startup
    # - code
    # - rodata
    # - data (load)
    ROM0:       start = $8000, size = $7ffa, file = %O ,fill = yes, define = yes;

    # Hardware Vectors at End of 2nd 8K ROM
    ROMV:       start = $fffa, size = $0006, file = %O, fill = yes;

    # 1 8k CHR Bank
    ROM2:       start = $0000, size = $2000, file = %O, fill = yes;

    # standard 2k SRAM (-zeropage)
    # $0100-$0200 cpu stack
    # $0200-$0400 2 pages for cc65 parameter stack
    # $0400-$0680 2.5 pages for data, bss, heap
    # $0680-$0700 0.5 pages for ppu memory write buffer
    # $0700-$0800 1 pages for ppu dma buffer
    STACK:      start = $0200, size = __STACKSIZE__, type = rw, define = yes;
    RAM:        start = $0400, size = $0280,         type = rw, define = yes;
    CMDBUF:     start = $0680, size = $0080,         type = rw, define = yes;
    DMABUF:     start = $0700, size = $0100,         type = rw, define = yes;

    # additional 8K SRAM Bank
#   SRAM:       start = $6000, size = $2000, type = rw, define = yes;
}

SEGMENTS {
    HEADER:     load = HEADER,          type = ro;
    STARTUP:    load = ROM0,            type = ro,  define = yes;
    LOWCODE:    load = ROM0,            type = ro,                optional = yes;
    INIT:       load = ROM0,            type = ro,  define = yes, optional = yes;
    CODE:       load = ROM0,            type = ro,  define = yes;
    RODATA:     load = ROM0,            type = ro,  define = yes;
#   DATA:       load = ROM0, run = RAM, type = rw,  define = yes;
    VECTORS:    load = ROMV,            type = rw;
    CHARS:      load = ROM2,            type = rw;
    BSS:        load = RAM,             type = bss, define = yes;
    ZEROPAGE:   load = ZP,              type = zp;
}

FEATURES {
    CONDES: segment = INIT,
        type = constructor,
        label = __CONSTRUCTOR_TABLE__,
        count = __CONSTRUCTOR_COUNT__;
    CONDES: segment = RODATA,
        type = destructor,
        label = __DESTRUCTOR_TABLE__,
        count = __DESTRUCTOR_COUNT__;
    CONDES: segment = RODATA,
        type = interruptor,
        label = __INTERRUPTOR_TABLE__,
        count = __INTERRUPTOR_COUNT__;
}
ベクタテーブルも含め、できるだけマッパー0の仕様に合わせてみました。
変更点は以下の通り。
  1. ZP: ゼロページの領域を0x000〜0x0FFへ拡大。
  2. STACK: コンパイラのパラメータスタック領域を256バイトへ縮小し、RAM側へ移動。
  3. CMDBUF,DMABUF: PPUの書き込みバッファを、BG書き換え用のコマンドバッファとスプライトデータ用のDMA転送バッファとして明記。
  4. RAM,SRAM: RAM領域と拡張RAM領域の定義名を入れ替え。
  5. SRAM: 拡張RAM領域の定義名をコメントアウト。
  6. DATA: 初期値付き変数の定数領域セグメント名をコメントアウト。
初期化

main()関数の呼び出し前は、スタートアップルーチンによって大域変数の初期化や標準ライブラリの初期化が行われます。
このスタートアップルーチンはnes.lib内のcrt0.oに格納されているのですが、リンクしてしまうと内部で呼び出すC言語の初期化処理や画面出力用のフォントデータまで取り込むことになります。

そこで今回は、必要最低限の初期化を行うスタートアップルーチンを用意し、crt0.oより先にリンクするよう変更します。
2014年だけどファミコンのゲームを作りませんか? #1<http://akiym.hateblo.jp/entry/2014/05/02/230227>にて公開されていたstartup.asmを、用意したコンフィグレーションに合わせて少し改変。
合わせて、キャラクタデータなどのバイナリデータを追加しやすいようにCHARSセグメントの記述を別にしてみました。

startup.asm:crt0.oの代わりにスタートアップ処理を担う。C言語側のメイン関数はvoid NesMain()を呼び出す。
;----------------------------------------------------------------------------
;				NES Startup
;					Copyright (C) 2007, Tekepen
;					Arranged By 2023, @MIRROR_
;----------------------------------------------------------------------------
.setcpu		"6502"
.autoimport	on

.importzp	sp			; cc65パラメータスタックの宣言
.export		_Reboot			; C言語からの再起動用関数宣言

; iNESヘッダ
.segment "HEADER"
	.byte	$4E, $45, $53, $1A	; "NES" Header
	.byte	$02			; PRG-BANKS 16K x 2
	.byte	$01			; CHR-BANKS  8k x 1
	.byte	$01			; 0:Horizontal/1:Vertical/3:Vertical & WRAM Mirror
	.byte	$00			; 
	.byte	$00, $00, $00, $00	; 
	.byte	$00, $00, $00, $00	; 

.segment "STARTUP"

; リセット割り込み
.proc	RESET
	sei

	; CPUスタック設定 $0100~$01FF
	ldx	#$ff
	txs
	
	; cc65パラメータスタック設定
	lda	#<(__STACK_START__ + __STACK_SIZE__)
	sta	sp
	lda	#>(__STACK_START__ + __STACK_SIZE__)
	sta	sp + 1
	
	; メイン関数呼び出し
	jsr	_NesMain		; void NesMain(void);
.endproc

; リブート処理
.proc	_Reboot				; void Reboot(void);
	jmp	RESET
.endproc

; NMI割り込み
.proc	NMI
	; レジスタ退避
	pha
	txa
	pha
	tya
	pha

	; NMI割り込み処理呼び出し
	jsr	_NMIProc		; void NMIProc(void);

	; レジスタ復帰
	pla
	tay
	pla
	tax
	pla

	; 割り込み終了
	rti
.endproc

; IRQ/BRK割り込み
.proc	IRQ_BRK
	; レジスタ退避
	pha
	txa
	tsx
	pha
	tya
	pha

	; 退避されているステータスレジスタの値を取得
	inx
	inx
	lda	$0100, x

	; Bフラグの確認
	and	#$10
	bne	@CALL_BRK_PROC

	; IRQ割り込み処理呼び出し
	jsr	_IRQProc		; void IRQProc(void);
	jmp	@FINALLY

@CALL_BRK_PROC:
	; BRK割り込み処理呼び出し
	jsr	_BRKProc		; void BRKProc(void);

@FINALLY:
	; レジスタ復帰
	pla
	tay
	pla
	tax
	pla

	; 割り込み終了
	rti
.endproc

; ベクタテーブル
.segment "VECTORS"
	.word	NMI
	.word	RESET
	.word	IRQ_BRK

CPU起動時は、ベクタテーブルのRESETが参照するアドレスより実行します。
RESET内では、CPUのスタックとC言語用パラメータスタックの初期化を行い、NesMain()を呼び出します。
NMI/IRQ/BRK割り込み時は、NMIProc()/IRQProc()/BRKProc()を呼び出します。

NesMain()はメイン処理、NMIProc()はVSync同期処理、IRQProc()は240Hz定周期処理、BRKProc()はソフトウェア割り込み処理をC言語で記述できます。

_Rebootは、ソフトウェアリセット用です。
extern void Reboot(void); を宣言してBRKProc()より呼び出すことで、強制的にRESETへ遷移して初期化処理から再実行します。

data.asm:バイナリデータのインクルード用。
.setcpu		"6502"
.autoimport	on

.export	_palette

; パレット
.segment "RODATA"
_palette:
	.incbin "palette.dat"

; パターンテーブル
.segment "CHARS"
	.incbin "bg.chr"
	.incbin "sprite.chr"

data.asmは、直接バイナリファイルのデータをROM化するために用意しています。

パレット等のC言語から操作するデータは、RODATAセグメント内に「_」付きラベルを付け、必ずexportしてください。
C言語からは「_」無しで呼び出せます。型はC言語側で指定してください。

パターンテーブルのデータは、合計で8KBを超えないよう注意してください。

全体的な処理の流れ

メイン関数名は、crt0.oのリンクを避けるためNesMain()とします。
また、NMI割り込み時に呼び出されるNMIProc()も用意します。

一例となりますが、プログラム中に記述する内容をコメントで表すと、以下の様になります。

// 定数定義

// 大域変数定義

// メイン処理
void NesMain(void) {
	// 変数初期化

	// PPU,APUの初期化

	// 各種ドライバ初期化

	// 画面の初期化

	// 描画開始

	// メインループ
	while (1) {
		// 垂直同期処理完了待ち
		// (NMI割り込みを使わないの場合
		//  垂直同期待ちとスプライト/BG書き換えやサウンドドライバの呼び出しを記述)

		// コントローラ入力確認

		// 1フレーム毎の処理
	}
}

// NMI割り込み
void NMIProc(void) {
	// 処理落ち判定(処理落ち発生時は、必要最低限の更新のみ実行)

	// スプライト書き換え

	// BG書き換え

	// スクロール値の再設定

	// サウンドドライバ呼び出し
}

// IRQ割り込み
void IRQProc(void) {
	// 定周期処理、DPCM再生終了時処理
}

// startup.asmの_Reboot呼出用
extern void Reboot(void);

// BRK割り込み
void BRKProc(void) {
	// 中断処理

	// 再起動
	Reboot();
}

基本はNMI割り込みによる垂直同期処理を基準とした、フレーム単位の処理を記述します。
NMI割り込み処理には、主に画面更新とサウンド再生処理を記述します。
処理落ち発生時は、サウンド再生など必要最小限の更新を行うことで、全体的な遅延を回避できます。

割り込みを使わず、全ての処理をメインループ内に記述することも可能です。
ただし、処理落ちの発生は、全体的な遅延となりますので注意が必要です。

注意点は、以下の通り。
  1. 定数は必ずconst修飾子を付けてROM領域へ格納する。
  2. const修飾子が付かない大域変数やstatic変数は未初期化のため、プログラム側で初期化する。
  3. RAM領域は2KBをスタックやDMAバッファに切り分けているため、変数定義は慎重に。
  4. NesMain() は無限ループを作って終了しない。
  5. NMI割り込み処理は描画とサウンド制御に留める。
  6. 処理落ちが発生する場合を想定して、メインループ内の処理と割り込み処理が競合しないよう工夫する。
コンパイラ、リンカーオプション

ファイルの配置は下記の通りです。

nesapp/               ... 作業用のディレクトリ
│setenv.bat(.sh)     ... 環境変数設定用のバッチファイル(スクリプト)
│
└─hello/            ... プロジェクト用のディレクトリ(実行ファイル名と同じ)
    │Makefile        ... ビルド用のメイクファイル
    │nes-mapper0.cfg ... マッパー0用のコンフィグレーション・ファイル
    │
    ├─include/      ... ライブラリのヘッダーファイル用のディレクトリ
    │
    ├─lib/          ... ライブラリ用のディレクトリ
    │
    └─src/          ... ソースプログラム用のディレクトリ
          main.c      ... ソースプログラム
          startup.asm ... スタートアップのソースコード
          data.asm    ... バイナリデータのインクルード用ソースコード
          bg.chr      ... BG用キャラクタパターンテーブル
          sprite.chr  ... スプライト用キャラクタパターンテーブル
          palette.dat ... パレットデータ

includeとlibのディレクトリは、外部で用意したヘッダファイルやライブラリを格納する場所です。
ヘッダファイルは、#include <ヘッダファイル名>でインクルードできます。
ライブラリはMakefile中で自動検索しますので、ファイルを配置するだけでリンクします。

src内にはプログラムだけでなく、data.asm内でインクルードするデータファイルも格納します。

上記を踏まえた上で、コンパイラやリンカーに与えるオプションを変えます。

ASFLAGS  := -t none -I $(AS_INCDIR) -I $(CURDIR)/include
CFLAGS   := -t none -Or -Os -I $(CC_INCDIR) -I $(CURDIR)/include
LDFLAGS  := --config $(CFGFILE) -L $(LD_LIBDIR) -L $(CURDIR)/lib

変更点は以下の通り。

  1. ターゲットをnesからnoneへ変更
  2. 最適化のオプションを追加
  3. インクルード及びライブラリディレクトリ指定を追加
  4. コンフィグレーション・ファイルの指定を追加

コンパイラオプションの-jはchar型をsignedで扱うためのものですが、コード量が若干増えますので今回は外しています。
必要に応じて追加してください。

コンパイラ付属のincludeとlibディレクトリの指定、及び標準関数を含むnes.libをリンクはそのままです。
nes.libは外せなくはないのですが、複数の引数が使えないなどC言語としての書き方が制限されるのを避け、基本構文に必要な分だけはリンクするために使います。

Makefile

コンパイラやリンカーのオプション変更に伴い、Makefileを変更します。

PROJECT  := $(shell basename $(CURDIR))

BIN_DIR  := bin
SRC_DIR  := src
OBJ_DIR  := obj

TARGET   := $(BIN_DIR)/$(PROJECT).nes

CFGFILE  := $(wildcard ./*.cfg)
CFILES   := $(wildcard $(SRC_DIR)/*.c)
SFILES   := $(wildcard $(SRC_DIR)/*.s) $(wildcard $(SRC_DIR)/*.asm)
LIBS     := $(wildcard $(CURDIR)/lib/*.a) $(wildcard $(CURDIR)/lib/*.lib) nes.lib

OBJS     := \
	$(foreach file, $(CFILES), $(OBJ_DIR)/$(basename $(notdir $(file))).o) \
	$(foreach file, $(SFILES), $(OBJ_DIR)/$(basename $(notdir $(file))).o)

DEPENDS  := $(wildcard $(OBJ_DIR)/*.dep)

AS_INCDIR := $(CC65_HOME)/asminc
CC_INCDIR := $(CC65_HOME)/include
LD_LIBDIR := $(CC65_HOME)/lib

ASFLAGS  := -t none -I $(AS_INCDIR) -I $(CURDIR)/include
CFLAGS   := -t none -Or -Os -I $(CC_INCDIR) -I $(CURDIR)/include
LDFLAGS  := --config $(CFGFILE) -L $(LD_LIBDIR) -L $(CURDIR)/lib

CC       := cl65
AS       := cl65
LD       := ld65
RM       := rm -rf
MKDIR    := mkdir

$(OBJ_DIR)/%.o: $(SRC_DIR)/%.c
	$(CC) $(CFLAGS) --create-dep $@.dep -c -o $@ $<

$(OBJ_DIR)/%.o: $(SRC_DIR)/%.s
	$(AS) $(ASFLAGS) --create-dep $@.dep -c -o $@ $<

$(OBJ_DIR)/%.o: $(SRC_DIR)/%.asm
	$(AS) $(ASFLAGS) --create-dep $@.dep -c -o $@ $<

.PHONY: all
all: init $(TARGET)

.PHONY: clean
clean:
	- $(RM) $(BIN_DIR)
	- $(RM) $(OBJ_DIR)

.PHONY: init
init:
	@$(if $(shell ls | grep $(BIN_DIR)) , , $(MKDIR) $(BIN_DIR))
	@$(if $(shell ls | grep $(OBJ_DIR)) , , $(MKDIR) $(OBJ_DIR))

.PHONY: run
run: all
	$(VM_PATH) $(TARGET)

$(TARGET): $(OBJS)
	@ echo Create $@
	$(LD) $(LDFLAGS) -m $@.map -o $@ --obj $(OBJS) --lib $(LIBS)
	@ echo Done.

-include $(DEPENDS)