gb: added banking support in sdcc 3.5

This commit is contained in:
Steven Hugg committed 2026-09-24 15:12:06 +02:00
1 parent d21d112fa2
commit 937f6dbf84
8 files changed
+294 -17

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
// Everything in this file goes in ROM bank 1.
#pragma bank 1
#include <stdint.h>
#include "gb/types.h"
#include "gb/hardware.h"
#include "gb/gb.h"
const char message1[] = "HELLO FROM BANK 1";
void get_message1(char* buf) __banked {
const char* p = message1;
while ((*buf++ = *p++));
}
uint8_t which_bank1(void) __banked {
return _current_bank;
}
+18
View File
@@ -0,0 +1,18 @@
// Everything in this file goes in ROM bank 2.
#pragma bank 2
#include <stdint.h>
#include "gb/types.h"
#include "gb/hardware.h"
#include "gb/gb.h"
const char message2[] = "HELLO FROM BANK 2";
void get_message2(char* buf) __banked {
const char* p = message2;
while ((*buf++ = *p++));
}
uint8_t which_bank2(void) __banked {
return _current_bank;
}
+60
View File
@@ -0,0 +1,60 @@
/*
ROM bank switching for Game Boy.
Code and data in bank1.c and bank2.c live in switchable
ROM banks, mapped at 0x4000-0x7FFF one bank at a time.
A __banked function call switches banks, runs the function,
and switches back.
The ROM grows to fit the highest bank, and the build sets
the ROM size in the header. We pick an MBC5 mapper here;
the build defaults to MBC5 when a ROM has banks.
*/
//#link "gb/sfr.sgb"
//#link "gb/crt0.sgb"
//#link "bank1.c"
//#link "bank2.c"
//#resource "gb/global.sgb"
//#symbol ld GB_MAPPER=0x19
#include <stdint.h>
#include "gb/types.h"
#include "gb/hardware.h"
#include "gb/gb.h"
#include "gbtext.h"
// defined in bank1.c and bank2.c
void get_message1(char* buf) __banked;
void get_message2(char* buf) __banked;
uint8_t which_bank1(void) __banked;
uint8_t which_bank2(void) __banked;
// banked data is only readable while its bank is mapped,
// so banked functions copy it into RAM for us
char buf[20];
void main(void) {
DISPLAY_OFF;
BGP_REG = 0xE4;
font_init();
put_str(2, 2, "BANK SWITCHING");
get_message1(buf);
put_str(2, 5, buf);
get_message2(buf);
put_str(2, 7, buf);
// _current_bank tracks the mapped bank
put_str(2, 10, "BANK 1 SAYS");
put_char(15, 10, '0' + which_bank1());
put_str(2, 12, "BANK 2 SAYS");
put_char(15, 12, '0' + which_bank2());
SHOW_BKG;
DISPLAY_ON;
while (1) {
wait_vbl_done();
}
}
+24
View File
@@ -212,6 +212,30 @@ the top of the file:
Either pragma slows builds but produces faster and smaller code.
### Game Boy ROM banks
On the Game Boy, `#pragma bank N` at the top of a file puts its code and
constant data in ROM bank *N*, which the CPU sees at `$4000-$7FFF`. Link
the file from your main file, and call its functions through `__banked`
prototypes. Each call switches to the function's bank and back again:
~~~c
// bank1.c
#pragma bank 1
void say_hello(char* buf) __banked { ... }
// main.c
//#link "bank1.c"
//#symbol ld GB_MAPPER=0x19
void say_hello(char* buf) __banked;
~~~
The ROM grows to fit the highest bank, and the build writes the ROM size
into the header. If `GB_MAPPER` is still 0 (ROM only), the build selects
MBC5. A bank holds 16 KB; overflowing it is a build error. Banked data
can only be read while its bank is mapped, so copy it out from a
`__banked` function. See the *ROM Bank Switching* preset.
## File extensions
The extension of your source file determines the tool used to assemble
+1
View File
@@ -16,6 +16,7 @@ const GB_PRESETS: Preset[] = [
{ id: 'galaxy.c', name: 'Window + Sprites' },
{ id: 'gbdecompress.c', name: 'Decompression' },
{ id: 'music.c', name: 'Music Player' },
{ id: 'banking.c', name: 'ROM Bank Switching' },
{ id: 'siegegame.c', name: 'Siege Game' },
{ id: 'climber.c', name: 'Climber Game' },
{ id: 'chase.c', name: "Shiru's Chase Game" },
+2 -1
View File
@@ -391,7 +391,8 @@ export var PLATFORM_PARAMS = {
arch: 'gbz80',
code_start: 0x0, // ROM starts @ 0x0, header @ 0x100, etc.
codeseg_start: 0x200, // _CODE area starts here
rom_size: 0x8000,
rom_size: 0x8000, // grows to fit the highest bank
rom_banking: { size: 0x4000, window: 0x4000 }, // #pragma bank N -> _CODE_N @ 0xN4000
data_start: 0xc0a0,
data_size: 0x1f60,
stack_end: 0xe000,
+106 -16
View File
@@ -15,45 +15,88 @@ function hexToArray(s, ofs) {
return arr;
}
function parseIHX(ihx: string, rom_start: number, rom_size: number, errors: WorkerError[]) {
/**
* Banked ROM layout, following the GBDK/devkitSMS convention: the linker places
* bank N's area at the virtual address (N << 16) | window, and the ROM image
* holds bank N at file offset N * size.
*/
export interface ROMBanking {
size: number; // bytes per bank (power of 2)
window: number; // CPU address the switchable bank is mapped at
}
/** Linker `-b` args for each `_CODE_N` area (N > 0) the object files declare. */
export function bankedAreaArgs(rels: string[], banking: ROMBanking): string[] {
let banks = new Set<number>();
for (let rel of rels) {
let re = /^A _CODE_(\d+) /gm, m;
while ((m = re.exec(rel))) {
let n = parseInt(m[1]);
if (n > 0) banks.add(n);
}
}
let args = [];
for (let n of Array.from(banks).sort((a, b) => a - b))
args.push('-b', `_CODE_${n}=0x${((n << 16) | banking.window).toString(16)}`);
return args;
}
/**
* Convert Intel HEX to a ROM image of rom_size bytes. With banking, records
* above 64 KB are placed by bank, and the image grows to the next power of 2
* that holds the highest bank.
*/
export function parseIHX(ihx: string, rom_start: number, rom_size: number, errors: WorkerError[], banking?: ROMBanking) {
var output = new Uint8Array(new ArrayBuffer(rom_size));
var high_size = 0;
var upper = 0; // from type 04 (extended linear address) records
for (var s of ihx.split("\n")) {
if (s[0] == ':') {
var arr = hexToArray(s, 1);
var count = arr[0];
var offset = (arr[1] << 8) + arr[2] - rom_start;
var address = upper + (arr[1] << 8) + arr[2];
var offset = address - rom_start;
var rectype = arr[3];
//console.log(rectype,address.toString(16),count,arr);
if (rectype == 0) {
if (banking && address >= 0x10000) {
let bank = address >>> 16;
let low = address & 0xffff;
if (low < banking.window || low + count > banking.window + banking.size) {
errors.push({line:0, msg:`Bank ${bank} overflows its 0x${banking.size.toString(16)}-byte window at 0x${low.toString(16)}`});
continue;
}
offset = bank * banking.size + low - banking.window;
if (offset + count > output.length) {
let newsize = output.length;
while (newsize < offset + count) newsize *= 2;
let grown = new Uint8Array(newsize);
grown.set(output);
output = grown;
}
}
// The linker also emits records for whatever it placed outside
// the ROM -- initialized data, or a GSINIT area that landed in
// the _DATA area's RAM. Those bytes are not part of the ROM
// image; reading past the end of output to check them yields
// undefined, which used to be reported as an overlap.
if (offset < 0 || offset + count > rom_size) {
console.log(`skipping IHX record outside ROM: 0x${offset.toString(16)} +${count}`);
if (offset < 0 || offset + count > output.length) {
console.log(`skipping IHX record outside ROM: 0x${address.toString(16)} +${count}`);
continue;
}
if (output[offset] !== 0) {
errors.push({line:0,msg:`IHX overlap offset 0x${(offset).toString(16)}`});
}
for (var i = 0; i < count; i++) {
var b = arr[4 + i];
output[i + offset] = b;
output[i + offset] = arr[4 + i];
}
if (i + offset > high_size) high_size = i + offset;
} else if (rectype == 1) {
break;
} else if (rectype == 4) {
upper = ((arr[4] << 8) | arr[5]) << 16;
} else {
console.log(s); // unknown record type
}
}
}
// TODO: return ROM anyway?
if (high_size > rom_size) {
//errors.push({line:0, msg:"ROM size too large: 0x" + high_size.toString(16) + " > 0x" + rom_size.toString(16)});
}
return output;
}
@@ -177,7 +220,27 @@ export function linkSDLDZ80(step: BuildStep) {
// //#symbol ld (sdldz80 uses -g sym=expr) and //#flag ld
args.push.apply(args, linkSymbolArgs('sdldz80', params.symbols && params.symbols.linker));
args.push.apply(args, extraArgsFor('sdldz80', params.buildArgs));
args.push.apply(args, step.args);
var objargs = step.args;
if (params.rom_banking) {
// place each #pragma bank N area, unless a //#flag ld already did
let banked = objargs.filter((fn) => fn.endsWith('.rel') && bankedAreaArgs([getWorkFileAsString(fn)], params.rom_banking).length);
let bargs = bankedAreaArgs(banked.map(getWorkFileAsString), params.rom_banking);
for (let i = 0; i < bargs.length; i += 2) {
let area = bargs[i + 1].split('=')[0] + '=';
if (!args.some((a) => a.startsWith(area)))
args.push(bargs[i], bargs[i + 1]);
}
// Link banked objects just before the last bank 0 object. The
// linker writes an IHX extended address record when it flushes
// buffered data, using the address width of the module it is
// reading at that moment; the XL2 (16-bit) libraries come last, so
// the last object's upper address is lost -- harmless for bank 0.
// The first objects stay first: they set the area order crt0 needs.
let rest = objargs.filter((fn) => !banked.includes(fn));
if (banked.length && rest.length)
objargs = rest.slice(0, -1).concat(banked, rest.slice(-1));
}
args.push.apply(args, objargs);
//console.log(args);
execMain(step, LDZ80, args);
if (errors.length) {
@@ -191,7 +254,7 @@ export function linkSDLDZ80(step: BuildStep) {
if (!anyTargetChanged(step, ["main.ihx", "main.noi"]))
return;
// parse binary file
var binout = parseIHX(hexout, params.rom_start !== undefined ? params.rom_start : params.code_start, params.rom_size, errors);
var binout = parseIHX(hexout, params.rom_start !== undefined ? params.rom_start : params.code_start, params.rom_size, errors, params.rom_banking);
if (errors.length) {
return { errors: errors };
}
@@ -240,8 +303,14 @@ export function linkSDLDZ80(step: BuildStep) {
}
}
}
// gameboy: compute checksum
// gameboy: fix up header for the final ROM size, compute checksum
if (step.params.arch === 'gbz80') {
if (binout.length > 0x8000) {
// ROM size code n means 32 KB << n
binout[0x148] = Math.log2(binout.length / 0x8000);
// a ROM-only cart can't switch banks; MBC5 accepts MBC1-style bank writes
if (binout[0x147] === 0) binout[0x147] = 0x19;
}
var checksum = 0;
for (var address = 0x0134; address <= 0x014C; address++) {
checksum = checksum - binout[address] - 1;
@@ -258,6 +327,26 @@ export function linkSDLDZ80(step: BuildStep) {
}
}
/**
* sdcc 3.6.5 compiles a __banked call to `call banked_call; .dw _fn; .dw 0`,
* with a "PENDING: bank support" comment where the bank number belongs. Supply
* it the way later sdcc versions do: a file with `#pragma bank N` defines
* `b_fn == N` for each function, and call sites reference `b_fn`.
*/
export function fixBankedCalls(asm: string): string {
asm = asm.replace(/^(\s*\.dw\s+(_\w+)\s*\n\s*\.dw\s+)0\s*; PENDING: bank support/gm,
(_m, head, fn) => head + 'b' + fn);
let m = /^\s*\.area\s+_CODE_(\d+)\b/m.exec(asm);
if (m && parseInt(m[1]) > 0) {
let defs = [];
let re = /^(_\w+)::/gm, f;
while ((f = re.exec(asm)))
defs.push(`\t.globl b${f[1]}\nb${f[1]} == ${m[1]}`);
if (defs.length) asm += '\n' + defs.join('\n') + '\n';
}
return asm;
}
export function compileSDCC(step: BuildStep): BuildStepResult {
gatherFiles(step, {
@@ -332,6 +421,7 @@ export function compileSDCC(step: BuildStep): BuildStepResult {
// massage the asm output
var asmout = FS.readFile(outpath, { encoding: 'utf8' });
asmout = " .area _HOME\n .area _CODE\n .area _INITIALIZER\n .area _DATA\n .area _INITIALIZED\n .area _BSEG\n .area _BSS\n .area _HEAP\n" + asmout;
if (isGBZ80) asmout = fixBankedCalls(asmout);
putWorkFile(outpath, asmout);
}
return {
+65
View File
@@ -0,0 +1,65 @@
import assert from "assert";
import { describe, it } from "mocha";
import { parseIHX, bankedAreaArgs, fixBankedCalls } from "../../src/worker/tools/sdcc";
// Intel HEX record with checksum
function rec(type: number, addr: number, data: number[]): string {
let bytes = [data.length, addr >> 8, addr & 0xff, type, ...data];
let sum = bytes.reduce((a, b) => a + b, 0);
bytes.push((-sum) & 0xff);
return ':' + bytes.map((b) => b.toString(16).padStart(2, '0').toUpperCase()).join('');
}
const GB_BANKING = { size: 0x4000, window: 0x4000 };
describe("sdcc ROM banking", function () {
it("places type 04 records by bank and grows the ROM", function () {
const ihx = [
rec(0, 0x0100, [0x11]),
rec(4, 0, [0x00, 0x01]), rec(0, 0x4000, [0x22]),
rec(4, 0, [0x00, 0x05]), rec(0, 0x4001, [0x55]),
rec(4, 0, [0x00, 0x00]), rec(0, 0x0200, [0x33]),
rec(1, 0, []),
].join('\n');
const errors = [];
const rom = parseIHX(ihx, 0, 0x8000, errors, GB_BANKING);
assert.deepStrictEqual(errors, []);
assert.strictEqual(rom.length, 0x20000); // bank 5 -> 0x14001, rounded up to 128 KB
assert.strictEqual(rom[0x100], 0x11);
assert.strictEqual(rom[0x4000], 0x22);
assert.strictEqual(rom[0x14001], 0x55);
assert.strictEqual(rom[0x200], 0x33);
});
it("reports a bank that overflows its window", function () {
const ihx = [rec(4, 0, [0x00, 0x01]), rec(0, 0x8000, [0x22]), rec(1, 0, [])].join('\n');
const errors = [];
parseIHX(ihx, 0, 0x8000, errors, GB_BANKING);
assert.strictEqual(errors.length, 1);
assert.match(errors[0].msg, /Bank 1 overflows/);
});
it("keeps the fixed size without banking", function () {
const ihx = [rec(0, 0x0000, [0x01]), rec(4, 0, [0x00, 0x01]), rec(0, 0x0000, [0x02]), rec(1, 0, [])].join('\n');
const rom = parseIHX(ihx, 0, 0x8000, [], undefined);
assert.strictEqual(rom.length, 0x8000);
assert.strictEqual(rom[0], 0x01); // record above 64 KB is skipped, not folded onto 0
});
it("generates -b args for #pragma bank areas", function () {
const rels = ["XL3\nA _CODE size 0 flags 0\nA _CODE_2 size 1F flags 0\n", "A _CODE_1 size 4 flags 0\nA _CODE_0 size 0 flags 0\n"];
assert.deepStrictEqual(bankedAreaArgs(rels, GB_BANKING),
['-b', '_CODE_1=0x14000', '-b', '_CODE_2=0x24000']);
});
it("fills in the bank of a __banked call and defines b_ symbols", function () {
const caller = "\tcall\tbanked_call\n\t.dw\t_foo\n\t.dw 0 ; PENDING: bank support\n";
assert.ok(fixBankedCalls(caller).includes("\t.dw b_foo"));
const callee = "\t.area _CODE_3\n_foo::\n\tret\n_bar::\n\tret\n";
const out = fixBankedCalls(callee);
assert.ok(out.includes("b_foo == 3"));
assert.ok(out.includes("b_bar == 3"));
// bank 0 code gets no b_ symbols
assert.ok(!fixBankedCalls("\t.area _CODE\n_foo::\n").includes("b_foo"));
});
});