Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,20 @@ jobs:
ghc-version: '9.10.3'
cabal-version: 'latest'

- name: Configure Cabal repository
run: |
mkdir -p ~/.cabal
cat > ~/.cabal/repositories.cfg << 'EOF'
repository hackage.haskell.org
url: https://hackage.haskell.org/
secure: True
root-keys: 07c59cb65787031cbb810701bd8ff5515f9ebe3e758d3b589d4a3a337f91dc08
2e8555dde16ebd8df076f1a396506f99fcfa541ee7a37b104d33177db735604c
8b9f40ae9b992d206ceabf2f4786226ae94036defc92874bd34ce6f1565d37b9
key-threshold: 3
EOF
cabal update

- name: Update Cabal cache
run: cabal update

Expand Down Expand Up @@ -52,4 +66,4 @@ jobs:
make linker-test

- name: LOC Report
run: python3 scripts/loc.py
run: python3 scripts/loc.py
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/) - while
the project is in `0.x`, breaking changes may land in MINOR releases; PATCH
releases are reserved for bug fixes only.

## [1.7.1] 2026-07-24

### Fixed:

- **Fixed CG-11:** *Oversized structs permitted causing wrapping on 8-bit indexing* Structs are now hard capped at max size of 255 bytes
- **Fixed FE-20:** *Nested field assignments not sticking* Added recursive load and store instructions for each nested field
- **Fixed FE-19:** *increment / decrement of pointers, registers and struct values not modifying* Fixed issue by matching on operand type in `lowerExpr`.
- **Addresed FE-22:** *compiler externs not injected* You must include the standard libarary header,
`stddef.c02h` to access these forward decls.
- **Fixed FE-2:** *Root file self include:* Can no longer include yourself causing redefinition errors
instead it produces a better error message `cannot include yourself '<FILE>'.`
- **Fixed CG-1, CG-2:** *Struct by value errors:* Added code to the analyzer to forbid passing or
returning struct types by value. This both keeps memory cleaner without copying structs around
and eliminates these two silent miscompiles.
- **Fixed CG-8:** *binary footer overwritten error:* Added guards for `code`, `data`, and `symbol table`
sections overwriting the binary footer which contains the vector table and would produce a broken
binary if overwritten.
- **Fixed CG-9:** *Added guards for exhausted ram on global emit:* Added guards for exhausting ram
space with global variables and compiler extern variables.
- **Fixed FE-14, CG-6:** *interrupt handling:* Added warning for wrongly defining an interrupt that
does not match the golden format, the warning drops the interrupt qualifier and continues. Also
added error for explicitly calling interrupt functions, because interrupt functions us `RTI` and
not `RTS` instructions so directly calling them would corrupt the stack.


## [1.7.0] 2026-07-17

- Added char literal to parser, 'a' now gets translated to its ascii value in that case, 97.
Expand Down
114 changes: 67 additions & 47 deletions c02-as/src/generator.c
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,29 @@
// Memory map
// ----------------------------------------------------------------

#define RAM_START 0x0200 // 6502 hardware stack occupies 0x0100 - 0x01FF
#define RAM_TOP 0x3FFF // see docs/memmap.md
#define ROM_START 0x8000
#define ROM_SIZE 0x8000
#define RAM_START 0x0200 // 6502 hardware stack occupies 0x0100 - 0x01FF
#define RAM_TOP 0x3FFF // see docs/memmap.md
#define ROM_START 0x8000
#define ROM_SIZE 0x8000

// ROM footer layout (offsets from ROM_START):
// $FFF6-$FFF7 SYMTABLE_START_PTR: little-endian absolute address of the "C02S" symbol
// table header, or $EAEA (NOP fill) if no table was written. The disassembler
// reads this first; old binaries that lack a table have $EAEA here, which is
// in range but fails the magic-byte check, so they degrade gracefully.
// $FFF8-$FFF9 SYMTABLE_BOUNDARY_PTR: little-endian absolute address of the first byte
// past the code+data region (= start of NOP fill). Used by the disassembler
// to know where executable/data bytes end and padding begins.
// $FFFA-$FFFF NMI / Reset / IRQ vectors (written by emit_vectors).
#define SYMTABLE_START_PTR (0xFFF6 - ROM_START)
#define SYMTABLE_BOUNDARY_PTR (0xFFF8 - ROM_START)
#define ROM_VECTOR_TABLE_START (0xFFFA - ROM_START)

#define FP 0x00
#define RET 0x02
#define REG_START 0x04
#define PARAM_START 0xEF

#define FP 0x00
#define RET 0x02
#define REG_START 0x04
#define PARAM_START 0xEF

// ABI zone: fixed 2-byte slots per parameter ($EF/$F0, $F1/$F2, ..., $FD/$FE).
// Using fixed 2-byte slots simplifies caller/callee agreement at the cost of 1 byte
Expand Down Expand Up @@ -298,6 +312,11 @@ static void allocate_globals(emitter_t *e, ir_gen_t *gen) {
entry->size = (uint8_t)size;
entry->type = g->type;
e->ram_pos += (uint16_t)size;

if (e->ram_pos > RAM_TOP) {
e->overflow = 1;
return;
}
}
}

Expand All @@ -306,16 +325,16 @@ static void allocate_globals(emitter_t *e, ir_gen_t *gen) {
// Op code emitters
// ----------------------------------------------------------------

#define EMIT(OP_CODE) do { \
if (e->code_pos >= ROM_SIZE) { e->overflow = 1; } \
else { e->rom[e->code_pos++] = (uint8_t)(OP_CODE); } \
#define EMIT(OP_CODE) do { \
if (e->code_pos >= SYMTABLE_START_PTR) { e->overflow = 1; } \
else { e->rom[e->code_pos++] = (uint8_t)(OP_CODE); } \
} while (0)

// Write one byte to an already-emitted position (branch offset backpatch).
// Guards against positions recorded after an overflow (which would be >= ROM_SIZE).
#define PATCH_BYTE(POS, VAL) do { \
if ((POS) < ROM_SIZE) e->rom[(POS)] = (uint8_t)(VAL); \
else e->overflow = 1; \
// Guards against positions recorded after an overflow (which would be >= SYMTABLE_START_PTR).
#define PATCH_BYTE(POS, VAL) do { \
if ((POS) < SYMTABLE_START_PTR) e->rom[(POS)] = (uint8_t)(VAL); \
else e->overflow = 1; \
} while (0)

#define OP_EMITTER_SINGLE_ARG(NAME, OP_CODE) \
Expand Down Expand Up @@ -529,7 +548,7 @@ static void emit_data_section(emitter_t *e, ir_gen_t *gen) {

// Write NMI, Reset, and IRQ vectors at $FFFA-$FFFF.
static void emit_vectors(emitter_t *e) {
unsigned pos = 0xFFFA - ROM_START;
unsigned pos = ROM_VECTOR_TABLE_START;

// Only functions actually declared `interrupt` get wired into the vector table —
// a plain function that happens to be named "nmi"/"irq" was emitted with a normal
Expand All @@ -553,11 +572,11 @@ static void emit_vectors(emitter_t *e) {
}

e->rom[pos++] = (uint8_t)(nmi_addr & 0xFF); // NMI low
e->rom[pos++] = (uint8_t)(nmi_addr >> 8); // NMI high
e->rom[pos++] = ROM_START & 0xFF; // Reset low
e->rom[pos++] = ROM_START >> 8; // Reset high
e->rom[pos++] = (uint8_t)(nmi_addr >> 8); // NMI high
e->rom[pos++] = ROM_START & 0xFF; // Reset low
e->rom[pos++] = ROM_START >> 8; // Reset high
e->rom[pos++] = (uint8_t)(irq_addr & 0xFF); // IRQ low
e->rom[pos++] = (uint8_t)(irq_addr >> 8); // IRQ high
e->rom[pos++] = (uint8_t)(irq_addr >> 8); // IRQ high
}


Expand Down Expand Up @@ -1759,18 +1778,6 @@ static void emit_sdiv16_helper(emitter_t *e) {
}


// ROM footer layout (offsets from ROM_START):
// $FFF6-$FFF7 SYMTABLE_START_PTR: little-endian absolute address of the "C02S" symbol
// table header, or $EAEA (NOP fill) if no table was written. The disassembler
// reads this first; old binaries that lack a table have $EAEA here, which is
// in range but fails the magic-byte check, so they degrade gracefully.
// $FFF8-$FFF9 SYMTABLE_BOUNDARY_PTR: little-endian absolute address of the first byte
// past the code+data region (= start of NOP fill). Used by the disassembler
// to know where executable/data bytes end and padding begins.
// $FFFA-$FFFF NMI / Reset / IRQ vectors (written by emit_vectors).
#define SYMTABLE_START_PTR (0xFFF6 - ROM_START)
#define SYMTABLE_BOUNDARY_PTR (0xFFF8 - ROM_START)

// Write the "C02S" symbol table into the NOP fill area immediately after the data section,
// then store its absolute address at SYMTABLE_START_PTR so the disassembler can find it.
// Each entry is: u16 address (LE) + null-terminated name. Covers three symbol kinds —
Expand All @@ -1792,8 +1799,10 @@ static void emit_symbol_table(emitter_t *e) {
for (unsigned i = 0; i < reg_count; i++)
sym_size += 2 + strlen(e->gen->module.regs[i].name) + 1;

if (e->code_pos + sym_size > SYMTABLE_START_PTR)
if (e->code_pos + sym_size > SYMTABLE_START_PTR) {
e->overflow = 1;
return;
}

uint16_t symtab_addr = (uint16_t)(ROM_START + e->code_pos);

Expand Down Expand Up @@ -1826,8 +1835,9 @@ static void emit_symbol_table(emitter_t *e) {
EMIT(0);
}

PATCH_BYTE(SYMTABLE_START_PTR, symtab_addr & 0xFF);
PATCH_BYTE(SYMTABLE_START_PTR + 1, symtab_addr >> 8);
// patch in symbol table ptr, can't use PATCH_BYTE because it checks if the pos < SYMTABLE_START_PTR wich will always fail in this case
e->rom[SYMTABLE_START_PTR] = (uint8_t)(symtab_addr & 0xFF);
e->rom[(SYMTABLE_START_PTR + 1)] = (uint8_t)(symtab_addr >> 8);
}


Expand Down Expand Up @@ -1871,14 +1881,18 @@ static int emit_compiler_extern_inits(emitter_t *e, ir_gen_t *gen) {
ir_extern_t *ext = &gen->module.externs[i];
if (ext->is_function) continue;

if (strcmp(ext->name, "__heap_start") == 0)
ALLOC_COMPILER_SLOT(ext);
else if (strcmp(ext->name, "__memory_top") == 0)
if (strcmp(ext->name, "__heap_start") == 0 || strcmp(ext->name, "__memory_top") == 0)
ALLOC_COMPILER_SLOT(ext);
else {
fprintf(stderr, "codegen: unresolved extern '%s'\n", ext->name);
return 0;
}

if (e->ram_pos > RAM_TOP) {
e->overflow = 1;
}

return 1;
}

// Pass 2: emit initialisers (e->ram_pos is now fully settled).
Expand All @@ -1902,6 +1916,16 @@ static void emitter_free(emitter_t *e) {
}


#define OVERFLOW_ERROR_CHECK(msg) \
if (e.overflow) { \
fprintf(stderr, msg); \
free(e.rom); \
emitter_free(&e); \
*final_rom_size = 0; \
return NULL; \
}


uint8_t *generate_rom(ir_gen_t *gen, size_t *final_rom_size, int emit_symbols) {
emitter_t e = { 0 };

Expand All @@ -1918,6 +1942,8 @@ uint8_t *generate_rom(ir_gen_t *gen, size_t *final_rom_size, int emit_symbols) {
e.zp_next = REG_START;

allocate_globals(&e, gen);
OVERFLOW_ERROR_CHECK("Global variables overflowed RAM.\n")

emit_bootstrap(&e);
emit_global_init(&e, gen);

Expand All @@ -1927,6 +1953,9 @@ uint8_t *generate_rom(ir_gen_t *gen, size_t *final_rom_size, int emit_symbols) {
*final_rom_size = 0;
return NULL;
}

// compiler externs are just globals and can also overflow ram
OVERFLOW_ERROR_CHECK("Global variables overflowed RAM.\n")

if (!emit_call_main(&e)) {
free(e.rom);
Expand Down Expand Up @@ -1960,15 +1989,6 @@ uint8_t *generate_rom(ir_gen_t *gen, size_t *final_rom_size, int emit_symbols) {
return NULL;
}

#define OVERFLOW_ERROR_CHECK(msg) \
if (e.overflow) { \
fprintf(stderr, msg); \
free(e.rom); \
emitter_free(&e); \
*final_rom_size = 0; \
return NULL; \
}

OVERFLOW_ERROR_CHECK("Code section generation failed: output exceeds 32 KB ROM.\n");

emit_data_section(&e, gen);
Expand Down
28 changes: 21 additions & 7 deletions c02-frontend/app/Main.hs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import Text.Megaparsec
import C02.Parser.Parser (parseProgram)
import C02.Analyzer.Includes (resolveIncludes)
import C02.Analyzer.Analyze (analyze)
import C02.Analyzer.Diagnostic (Diag(..), Diagnostic, render)
import C02.Analyzer.Diagnostic (Diag(..), Diagnostic, Severity(..), render, severityOf, diagWhat)
import C02.Lowering.Module (lowerModule)
import C02.Parser.AST (Program, Pos(..))
import C02.Lowering.Serialize (serializeModule)
Expand Down Expand Up @@ -69,12 +69,19 @@ main = do
resolved <- resolveIncludes path includeDirs prog
case resolved of
Left err -> hPutStr stderr (errorBundlePretty err) >> exitFailure
Right (resolvedProg, srcs) -> case analyze resolvedProg of
[] -> if dumpAST then print resolvedProg else writeOutput resolvedProg outPath
Right (resolvedProg, srcs) ->
-- The root file's source isn't in @srcs@ (the resolver only
-- records files it read); add it so the renderer can show root
-- diagnostics too.
diags -> hPutStr stderr (renderDiags path (Map.insert path src srcs) diags) >> exitFailure
let diags = analyze resolvedProg
hasErr = any ((== Error) . severityOf . diagWhat) diags
in do
if null diags
then pure ()
else hPutStr stderr (renderDiags path (Map.insert path src srcs) diags)
if hasErr
then exitFailure
else if dumpAST then print resolvedProg else writeOutput resolvedProg outPath
_ -> hPutStrLn stderr "usage: c02-frontend [--parse-only] [-I <dir>]... [-o <out.o>] <file.c02>" >> exitFailure


Expand Down Expand Up @@ -114,10 +121,17 @@ extractIncludeDirs = go [] []
-- file, then a summary count closes the report.
renderDiags :: FilePath -> Map FilePath String -> [Diag] -> String
renderDiags rootPath srcs diags =
concatMap fileBundle (Map.toList byFile) ++ freeStr
++ "\nSemantic analysis failed with " ++ show n ++ " errors.\n"
concatMap fileBundle (Map.toList byFile) ++ freeStr ++ summary
where
n = length diags
nErrors = length [ () | d <- diags, severityOf (diagWhat d) == Error ]
nWarnings = length diags - nErrors
summary
-- Preserve the original wording exactly when there are no warnings
-- (every existing golden pins this string, always-plural "errors").
| nErrors > 0 && nWarnings == 0 = "\nSemantic analysis failed with " ++ show nErrors ++ " errors.\n"
| nErrors > 0 = "\nSemantic analysis failed with " ++ show nErrors
++ " errors and " ++ show nWarnings ++ " warnings.\n"
| otherwise = "\n" ++ show nWarnings ++ " warnings.\n"
frees = [ d | Free d <- diags ]
-- group located diags by file; Map.toList then yields files in a stable
-- (path-sorted) order, and each group is non-empty by construction.
Expand Down
2 changes: 2 additions & 0 deletions c02-frontend/c02-frontend.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ library
C02.Lowering.Module
C02.Lowering.Serialize
build-depends: base ^>=4.20.2.0,
directory,
megaparsec,
parser-combinators,
containers,
Expand All @@ -89,6 +90,7 @@ executable c02-frontend

-- Other library packages from which modules are imported.
build-depends: base ^>=4.20.2.0,
directory,
megaparsec,
containers,
bytestring,
Expand Down
Loading
Loading