いよいよ、ここからが本番です。
標準関数を使うアプリケーションも作成できますが敢えて使わず、C言語の構文でプログラムが書けることに挑戦していきます。
アセンブラに比べたらプログラムが書きやすいかと思いきや、メモリや速度の面では不利ですので、色々工夫を凝らしていくこととなります。
ただ、全くアセンブラを使わないわけではなく、C言語で書きにくい箇所は素直にアセンブラを使います。
- マッパー0向けにメモリレイアウトの変更を行い、標準関数を使わないベースとなるプロジェクトを用意します。
普通のC言語の使い方から外れますので、いくつか注意する点があります。 -
-
リンカが参照するコンフィグレーション・ファイルの設定を変更します。
0x6000〜0x7FFFの拡張RAM領域の定義を外し、RAM領域の利用方法を変更します。
-
nes.cfgとして用意されているコンフィグレーション・ファイル内の定義は以下のようになっています。
特徴は、拡張RAM領域を大域変数用に使っていること。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;
nes.cfgをコピーして別ファイルを作り、RAM領域側に収めるよう調整します。
-
以下は、改変後の定義です。
ベクタテーブルも含め、できるだけマッパー0の仕様に合わせてみました。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__; }
変更点は以下の通り。
- ZP: ゼロページの領域を0x000〜0x0FFへ拡大。
- STACK: コンパイラのパラメータスタック領域を256バイトへ縮小し、RAM側へ移動。
- CMDBUF,DMABUF: PPUの書き込みバッファを、BG書き換え用のコマンドバッファとスプライトデータ用のDMA転送バッファとして明記。
- RAM,SRAM: RAM領域と拡張RAM領域の定義名を入れ替え。
- SRAM: 拡張RAM領域の定義名をコメントアウト。
- 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割り込み処理には、主に画面更新とサウンド再生処理を記述します。
処理落ち発生時は、サウンド再生など必要最小限の更新を行うことで、全体的な遅延を回避できます。割り込みを使わず、全ての処理をメインループ内に記述することも可能です。
ただし、処理落ちの発生は、全体的な遅延となりますので注意が必要です。 - 注意点は、以下の通り。
- 定数は必ずconst修飾子を付けてROM領域へ格納する。
- const修飾子が付かない大域変数やstatic変数は未初期化のため、プログラム側で初期化する。
- RAM領域は2KBをスタックやDMAバッファに切り分けているため、変数定義は慎重に。
- NesMain() は無限ループを作って終了しない。
- NMI割り込み処理は描画とサウンド制御に留める。
- 処理落ちが発生する場合を想定して、メインループ内の処理と割り込み処理が競合しないよう工夫する。
-
ファイルの配置は下記の通りです。
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
変更点は以下の通り。
- ターゲットをnesからnoneへ変更
- 最適化のオプションを追加
- インクルード及びライブラリディレクトリ指定を追加
- コンフィグレーション・ファイルの指定を追加
コンパイラオプションの-jはchar型をsignedで扱うためのものですが、コード量が若干増えますので今回は外しています。
必要に応じて追加してください。コンパイラ付属のincludeとlibディレクトリの指定、及び標準関数を含むnes.libをリンクはそのままです。
nes.libは外せなくはないのですが、複数の引数が使えないなどC言語としての書き方が制限されるのを避け、基本構文に必要な分だけはリンクするために使います。
-
コンパイラやリンカーのオプション変更に伴い、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)
-
リンカが参照するコンフィグレーション・ファイルの設定を変更します。