From bee1b9948cd86c7ce9a13b688f51337aa0af3432 Mon Sep 17 00:00:00 2001 From: jackwthake Date: Wed, 15 Jul 2026 00:14:30 -0700 Subject: [PATCH 01/12] Added Ocaml project for c02-ld --- .gitignore | 7 +++++-- Makefile | 10 ++++++++-- c02-ld/Makefile | 10 ++++++++++ c02-ld/bin/c02_ld.ml | 1 + c02-ld/bin/dune | 2 ++ c02-ld/dune-project | 1 + scripts/c02c.py | 9 +++++++-- 7 files changed, 34 insertions(+), 6 deletions(-) create mode 100644 c02-ld/Makefile create mode 100644 c02-ld/bin/c02_ld.ml create mode 100644 c02-ld/bin/dune create mode 100644 c02-ld/dune-project diff --git a/.gitignore b/.gitignore index f966d1a..8cc5ba6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ *.o *.vsix .vscode -bin/ +/bin/ *.out *.bin *.d @@ -16,4 +16,7 @@ dist/ .ghc.environment.* cabal.project.local cabal.project.local~ -*.hi \ No newline at end of file +*.hi + +#Ocaml +_build \ No newline at end of file diff --git a/Makefile b/Makefile index b0463c6..dd6b6cb 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,8 @@ -.PHONY: all clean c02-frontend c02-as c02-objdump emu-test +.PHONY: all clean c02-frontend c02-ld c02-as c02-objdump emu-test BIN_DIR = bin -all: $(BIN_DIR)/c02c c02-frontend c02-as c02-objdump +all: $(BIN_DIR)/c02c c02-frontend c02-ld c02-as c02-objdump # Runtime tests: compile each test/emu/*.c02 to a ROM and execute it in py65, # checking the code generator's output against embedded EXPECT directives. @@ -23,6 +23,10 @@ c02-frontend: c02-frontend/ @printf '==> c02-frontend\n' @$(MAKE) -C c02-frontend +c02-ld: c02-ld/ + @printf '==> c02-ld\n' + @$(MAKE) -C c02-ld + c02-as: c02-as/ @printf '==> c02-as\n' @$(MAKE) -C c02-as @@ -34,6 +38,8 @@ c02-objdump: c02-objdump/ clean: @printf '==> c02-frontend clean\n' @$(MAKE) -C c02-frontend clean + @printf '==> c02-ld clean\n' + @$(MAKE) -C c02-ld clean @printf '==> c02-as clean\n' @$(MAKE) -C c02-as clean @printf '\n==> c02-objdump clean\n' diff --git a/c02-ld/Makefile b/c02-ld/Makefile new file mode 100644 index 0000000..fe36aa8 --- /dev/null +++ b/c02-ld/Makefile @@ -0,0 +1,10 @@ +BIN_DIR = ../bin +BIN = $(BIN_DIR)/c02-ld + +all: $(BIN_DIR) + dune build + install -m 755 _build/default/bin/c02_ld.exe $(BIN_DIR)/c02-ld + +clean: + dune clean + rm -f $(BIN) \ No newline at end of file diff --git a/c02-ld/bin/c02_ld.ml b/c02-ld/bin/c02_ld.ml new file mode 100644 index 0000000..60731e5 --- /dev/null +++ b/c02-ld/bin/c02_ld.ml @@ -0,0 +1 @@ +() = print_endline "Hello, World!" \ No newline at end of file diff --git a/c02-ld/bin/dune b/c02-ld/bin/dune new file mode 100644 index 0000000..7bcabbd --- /dev/null +++ b/c02-ld/bin/dune @@ -0,0 +1,2 @@ +(executable + (name c02_ld)) \ No newline at end of file diff --git a/c02-ld/dune-project b/c02-ld/dune-project new file mode 100644 index 0000000..ab2f679 --- /dev/null +++ b/c02-ld/dune-project @@ -0,0 +1 @@ +(lang dune 3.24) \ No newline at end of file diff --git a/scripts/c02c.py b/scripts/c02c.py index 30c5a07..7f303db 100755 --- a/scripts/c02c.py +++ b/scripts/c02c.py @@ -60,9 +60,10 @@ def main(): (args, src_files, o_files) = parse_args() frontend = os.path.join(SCRIPT_DIR, "c02-frontend") + linker = os.path.join(SCRIPT_DIR, "c02-ld") codegen = os.path.join(SCRIPT_DIR, "c02-as") - check_stages([frontend, codegen]) + check_stages([frontend, linker, codegen]) frontend_args = [] if args.parse_only: frontend_args += ['--parse-only'] @@ -85,10 +86,14 @@ def main(): sys.exit(result.returncode) if args.c or args.parse_only or args.dump_ast: - sys.exit(0) + clean_o_file_temps(temps) + sys.exit(0) # --- 2. link + optimize (future OCaml pass) ----------------------------- # TODO: merge IR objects and run optimization passes here. + result = subprocess.run([linker]) + if result.returncode != 0: + sys.exit(result.returncode) linked = o_files[0] # TODO: this will be replaced with the result of the linking stage From cdb79735911a06ab14c3ad412482b6f57ed0b04b Mon Sep 17 00:00:00 2001 From: jackwthake Date: Wed, 15 Jul 2026 00:19:35 -0700 Subject: [PATCH 02/12] Added Ocaml to LOC report --- scripts/loc.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/scripts/loc.py b/scripts/loc.py index ea75d0a..66d2592 100755 --- a/scripts/loc.py +++ b/scripts/loc.py @@ -18,6 +18,7 @@ C_STYLE = CommentStyle("//", ("/*", "*/")) # C, Rust HASKELL = CommentStyle("--", ("{-", "-}")) +OCAML = CommentStyle("", ("(*", "*)")) HASH_STYLE = CommentStyle("#", None) # Python, Makefiles NO_STYLE = CommentStyle(None, None) # unknown: blank-line skip only @@ -128,14 +129,20 @@ def count_lines(): ["Makefile", os.path.join("c02-as", "Makefile")], os.path.join("c02-as", "src")) + ld_total, ld_code = count_dir( + {".ml": OCAML}, + [], + os.path.join("c02-ld", "bin")) + + frontend_total, frontend_code = count_dir( {".hs": HASKELL}, - ["Makefile", os.path.join("c02-frontend", "Makefile")], + [], os.path.join("c02-frontend", "src")) objdump_total, objdump_code = count_dir( {".rs": C_STYLE}, - ["Makefile", os.path.join("c02-objdump", "Makefile")], + [], os.path.join("c02-objdump", "src")) parser_tests_total, parser_test_code = count_dir( @@ -161,6 +168,7 @@ def count_lines(): # directory/extension-style constants above. sections_compiler = [ Section("c02-frontend", frontend_total, frontend_code), + Section("c02-ld", ld_total, ld_code), Section("c02-as", compiler_total, compiler_code), ] From 4d1dc80d824a78c8dcb339cf1034a4af83f1061c Mon Sep 17 00:00:00 2001 From: jackwthake Date: Wed, 15 Jul 2026 00:28:27 -0700 Subject: [PATCH 03/12] Added Ocaml to CI runner --- .github/workflows/ci.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77d896c..2b2b2a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,13 +31,22 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@stable + - name: Set up OCaml and Dune + uses: ocaml/setup-ocaml@v3 + with: + ocaml-compiler: '5.3' + dune-cache: true + + - name: Install Dune + run: opam install -y dune + - name: Build - run: make + run: opam exec -- make - name: Test Suite run: | python3 scripts/test.py - make emu-test + opam exec -- make emu-test - name: LOC Report run: python3 scripts/loc.py \ No newline at end of file From 523931097dacd1946864ebdbeb2c9bc84011fb00 Mon Sep 17 00:00:00 2001 From: jackwthake Date: Wed, 15 Jul 2026 00:40:55 -0700 Subject: [PATCH 04/12] Fixed CI --- .github/workflows/ci.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b2b2a3..e424ebf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,15 +38,17 @@ jobs: dune-cache: true - name: Install Dune - run: opam install -y dune + run: | + opam install -y dune + echo "$(opam var bin)" >> "$GITHUB_PATH" - name: Build - run: opam exec -- make - + run: make + - name: Test Suite run: | python3 scripts/test.py - opam exec -- make emu-test + make emu-test - name: LOC Report run: python3 scripts/loc.py \ No newline at end of file From b80ba0c1aad335772e0f333c87e1b575c8725797 Mon Sep 17 00:00:00 2001 From: jackwthake Date: Wed, 15 Jul 2026 11:37:36 -0700 Subject: [PATCH 05/12] Started scaffolding wire format for linker --- c02-frontend/src/C02/Lowering/Module.hs | 2 +- c02-frontend/src/C02/Lowering/TAC.hs | 1 - c02-ld/bin/c02_ld.ml | 49 ++++++- c02-ld/bin/tac.ml | 164 ++++++++++++++++++++++++ 4 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 c02-ld/bin/tac.ml diff --git a/c02-frontend/src/C02/Lowering/Module.hs b/c02-frontend/src/C02/Lowering/Module.hs index c8d0d8b..f6a7545 100644 --- a/c02-frontend/src/C02/Lowering/Module.hs +++ b/c02-frontend/src/C02/Lowering/Module.hs @@ -68,7 +68,7 @@ lowerFunction genv f = T.Cfg { T.fnName = funcName f , T.retType = (funcReturnType f, funcReturnPtrDepth f) , T.params = params f - , T.blocks = [ T.Block { T.blockId = 0, T.instructions = allInstrs, T.successors = [] } ] + , T.blocks = [ T.Block { T.blockId = 0, T.instructions = allInstrs } ] , T.nextTemp = nextTemp finalSt , T.nextLabel = nextLabel finalSt , T.isInterrupt = isInterrupt f diff --git a/c02-frontend/src/C02/Lowering/TAC.hs b/c02-frontend/src/C02/Lowering/TAC.hs index 1959489..2af4500 100644 --- a/c02-frontend/src/C02/Lowering/TAC.hs +++ b/c02-frontend/src/C02/Lowering/TAC.hs @@ -80,7 +80,6 @@ data Instr = Instr data Block = Block { blockId :: Int , instructions :: [Instr] - , successors :: [Block] } deriving (Show) diff --git a/c02-ld/bin/c02_ld.ml b/c02-ld/bin/c02_ld.ml index 60731e5..3bb3a7a 100644 --- a/c02-ld/bin/c02_ld.ml +++ b/c02-ld/bin/c02_ld.ml @@ -1 +1,48 @@ -() = print_endline "Hello, World!" \ No newline at end of file +(* c02-ld — entry point and IR object-file reader. + * The data model and wire numbering live in tac.ml (module Tac). *) + +open Tac + +(* A mutable cursor over a slurped file: advance `pos` on every read. + * RULE: every cursor read gets its own `let ... in`. Never put two cursor + * reads in the same tuple/record/argument list — OCaml leaves evaluation + * order unspecified there, which silently reads the fields out of order. *) +type cursor = { buf : bytes; mutable pos : int } + +let u32 c = (* mirrors read_u32 *) + let v = Int32.to_int (Bytes.get_int32_le c.buf c.pos) in + c.pos <- c.pos + 4; + v + +let i64 c = (* mirrors read_i64; stays int64 *) + let v = Bytes.get_int64_le c.buf c.pos in + c.pos <- c.pos + 8; + v + +let str c = (* mirrors read_str; len 0 -> None *) + let len = u32 c in + if len = 0 then None + else + let s = Bytes.sub_string c.buf c.pos len in + c.pos <- c.pos + len; + Some s + +(* Wire type_t is (kind, is_ptr, ptr_depth, [name if STRUCT]); we collapse it to + * Tac.ty = (base_type, depth). is_ptr is redundant (it's just depth > 0), so we + * read past it. Kind 5 (STRUCT) additionally pulls the length-prefixed name. *) +let read_type c : ty = + let kind = u32 c in + let _is_ptr = u32 c in + let depth = u32 c in + let base = + match kind with + | 0 -> U8 | 1 -> I8 | 2 -> U16 | 3 -> I16 | 4 -> Void + | 5 -> + (match str c with + | Some name -> StructName name + | None -> failwith "struct type with empty name") + | n -> failwith (Printf.sprintf "bad type kind %d" n) + in + (base, depth) + +let () = print_endline "c02-ld" diff --git a/c02-ld/bin/tac.ml b/c02-ld/bin/tac.ml new file mode 100644 index 0000000..3f543ab --- /dev/null +++ b/c02-ld/bin/tac.ml @@ -0,0 +1,164 @@ +(* tac.ml — TAC / IR data model and wire-format numbering. + * + * Port of the frontend's C02.Lowering.TAC (Haskell), which is the *write* side + * of the same object-file contract this linker reads. The enum numbers below + * are the contract: keep them in lockstep with c02-as/src/ir.h and the Haskell + * tag functions. Serialization (the cursor + read_* logic) lives elsewhere, not + * here — this module is just the shapes and the numbering. + *) + +let c02_magic = 0x43303249 (* "C02I" *) +let ir_version = 3 + +(* -------------------------------------------------------------------------- + * Types, in dependency order — OCaml requires each type defined before use + * (unlike Haskell). Only `block` is self-recursive, which is legal on its own; + * nothing here needs `and`-chaining. + * ------------------------------------------------------------------------ *) + +(* Haskell: data BaseType = U8|I8|U16|I16|Void | StructName String. + * The struct name rides inside the constructor, so `ty` needs no separate + * struct_name field. (Wire TYPE_INVALID never appears in a well-formed .o.) *) +type base_type = + | U8 | I8 | U16 | I16 | Void + | StructName of string + +(* Haskell: type Ty = (BaseType, Int) -- (base type, pointer depth) *) +type ty = base_type * int + +(* Haskell: type NamedType = (BaseType, Int, String) -- (base, depth, name) *) +type named_type = base_type * int * string + +type tac_op = + | TacAdd | TacSub | TacMul | TacDiv | TacMod + | TacLt | TacGt | TacLte | TacGte | TacEq | TacNeq + | TacAnd | TacOr + | TacShl | TacShr | TacBand | TacBxor | TacBor + | TacNeg | TacNot | TacBnot + | TacInc | TacDec + | TacAddrOf | TacLoad | TacStore + | TacCopy + | TacLabel | TacJump | TacCondJump + | TacCall | TacReturn | TacContinue | TacBreak + | TacFieldLoad | TacFieldStore + | TacCast + +type ir_init_kind = IRInitNone | IRInitInt | IRInitStr + +(* Every operand carries a `ty` first, then its kind-specific payload. *) +type operand = + | OperandNone of ty + | OperandTemp of ty * int + | OperandVar of ty * string + | OperandConstInt of ty * int + | OperandConstStr of ty * string + +(* Fat, fixed-shape instruction: every field is present on the wire for every + * opcode, so the reader consumes them all regardless of `op`. *) +type instr = { + op : tac_op; + dst : operand; + src1 : operand; + src2 : operand; + label : int option; + call_name : string option; + call_args : operand list option; + field_name : string option; + cast_type : ty option; +} + +(* `successors` is NOT on the wire — rebuild it from terminators after load. + * Compare blocks by `block_id`; structural `=` loops on cyclic back-edges. *) +type block = { + block_id : int; + instructions : instr list; + successors : block list; +} + +type cfg = { + fn_name : string; + ret_type : ty; + params : named_type list; + blocks : block list; + next_temp : int; + next_label : int; + is_interrupt : bool; +} + +type global = { + glob_name : string; + glob_type : ty; + glob_init_kind : ir_init_kind; + glob_int_val : int; + glob_str_val : string; +} + +type ir_field = { + ir_field_name : string; + ir_field_type : ty; + ir_field_offset : int; +} + +type ir_struct = { + ir_struct_name : string; + ir_struct_fields : ir_field list; + ir_struct_size : int; +} + +type reg = { + reg_name : string; + reg_type : ty; + reg_addr : int; +} + +type extern = { + extern_is_func : bool; + extern_name : string; + extern_type : ty; + extern_params : named_type list; +} + +(* `module` is a reserved keyword in OCaml, so the top-level type is `ir_module`. *) +type ir_module = { + externs : extern list; + structs : ir_struct list; + globals : global list; + regs : reg list; + cfgs : cfg list; +} + +(* -------------------------------------------------------------------------- + * Wire numbering — mirrors c02-as/src/ir.h and TAC.hs. These ints are the + * contract with the frontend's serializer. + * ------------------------------------------------------------------------ *) + +(* Every operand variant carries a ty first; pull it out regardless of kind. *) +let operand_type = function + | OperandNone t | OperandTemp (t, _) | OperandVar (t, _) + | OperandConstInt (t, _) | OperandConstStr (t, _) -> t + +(* base_type -> wire TYPE_* kind. The inverse lives in the reader's read_type, + * because kind 5 (STRUCT) must also pull a length-prefixed name off the cursor + * to fill in `StructName`. *) +let type_kind_tag = function + | U8 -> 0 | I8 -> 1 | U16 -> 2 | I16 -> 3 | Void -> 4 + | StructName _ -> 5 + +let tac_op_of_int = function + | 0 -> TacAdd | 1 -> TacSub | 2 -> TacMul | 3 -> TacDiv | 4 -> TacMod + | 5 -> TacLt | 6 -> TacGt | 7 -> TacLte | 8 -> TacGte | 9 -> TacEq | 10 -> TacNeq + | 11 -> TacAnd | 12 -> TacOr + | 13 -> TacShl | 14 -> TacShr | 15 -> TacBand | 16 -> TacBxor | 17 -> TacBor + | 18 -> TacNeg | 19 -> TacNot | 20 -> TacBnot + | 21 -> TacInc | 22 -> TacDec + | 23 -> TacAddrOf | 24 -> TacLoad | 25 -> TacStore + | 26 -> TacCopy + | 27 -> TacLabel | 28 -> TacJump | 29 -> TacCondJump + | 30 -> TacCall | 31 -> TacReturn | 32 -> TacContinue | 33 -> TacBreak + | 34 -> TacFieldLoad | 35 -> TacFieldStore + | 36 -> TacCast + | n -> failwith (Printf.sprintf "bad tac_op %d" n) + +let ir_init_kind_of_int = function + | 0 -> IRInitNone | 1 -> IRInitInt | 2 -> IRInitStr + | n -> failwith (Printf.sprintf "bad ir_init_kind %d" n) From 3f0618372d302250113a0f0367ddb3136fd62721 Mon Sep 17 00:00:00 2001 From: jackwthake Date: Wed, 15 Jul 2026 17:40:13 -0700 Subject: [PATCH 06/12] c02-ld can now deserialize object files correctly --- CHANGELOG.md | 19 +++++ c02-ld/Makefile | 4 +- c02-ld/bin/c02_ld.ml | 57 ++++--------- c02-ld/bin/serializer.ml | 175 +++++++++++++++++++++++++++++++++++++++ c02-ld/bin/tac.ml | 10 +-- scripts/c02c.py | 2 +- 6 files changed, 218 insertions(+), 49 deletions(-) create mode 100644 c02-ld/bin/serializer.ml diff --git a/CHANGELOG.md b/CHANGELOG.md index b49c96f..1094822 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,25 @@ releases are reserved for bug fixes only. ## [Unreleased] +### Added + +- **`c02-ld` IR object-file reader (OCaml).** The linker now deserializes the + frozen IR wire format (`c02-as/src/ir.h` + `ir-serial.c`, as emitted by the + frontend's `C02.Lowering.Serialize`) into native OCaml types. `bin/tac.ml` + ports the TAC/IR data model and its wire numbering; `bin/serializer.ml` is a + cursor-based reader covering the whole format — types, operands, the + fixed-shape ("fat") instruction record, blocks, CFGs, structs, globals, + registers, and externs — with magic/version enforcement up front. The reader + is a faithful transcription of the stream: instruction fields absent for a + given opcode are stored raw (empty string / `0` / `[]`) rather than modelled + as options, since the linker consumes TAC and never re-emits it. Validated by + parsing all 69 `test/emu` objects with no desync (section counts match + `c02-as --dump-ir`). Symbol resolution, layout, and relocation are next. +- **OCaml/dune wired into the build and CI.** The root `make` builds `c02-ld` + through dune; CI provisions the OCaml toolchain via `ocaml/setup-ocaml`, and + the `c02-ld` Makefile wraps dune calls in `opam exec` so builds don't depend + on an active opam environment. + ## [1.5.1] 2026-07-14 ### Added diff --git a/c02-ld/Makefile b/c02-ld/Makefile index fe36aa8..4227971 100644 --- a/c02-ld/Makefile +++ b/c02-ld/Makefile @@ -2,9 +2,9 @@ BIN_DIR = ../bin BIN = $(BIN_DIR)/c02-ld all: $(BIN_DIR) - dune build + opam exec -- dune build install -m 755 _build/default/bin/c02_ld.exe $(BIN_DIR)/c02-ld clean: - dune clean + opam exec -- dune clean rm -f $(BIN) \ No newline at end of file diff --git a/c02-ld/bin/c02_ld.ml b/c02-ld/bin/c02_ld.ml index 3bb3a7a..78b1bbf 100644 --- a/c02-ld/bin/c02_ld.ml +++ b/c02-ld/bin/c02_ld.ml @@ -1,48 +1,23 @@ (* c02-ld — entry point and IR object-file reader. * The data model and wire numbering live in tac.ml (module Tac). *) +open Serializer open Tac -(* A mutable cursor over a slurped file: advance `pos` on every read. - * RULE: every cursor read gets its own `let ... in`. Never put two cursor - * reads in the same tuple/record/argument list — OCaml leaves evaluation - * order unspecified there, which silently reads the fields out of order. *) -type cursor = { buf : bytes; mutable pos : int } +let read_whole_file filename = + In_channel.with_open_bin filename In_channel.input_all -let u32 c = (* mirrors read_u32 *) - let v = Int32.to_int (Bytes.get_int32_le c.buf c.pos) in - c.pos <- c.pos + 4; - v +let parse_file filename = + let content = read_whole_file filename in + let cursor = { buf = Bytes.of_string content; pos = 0 } in + read_module cursor -let i64 c = (* mirrors read_i64; stays int64 *) - let v = Bytes.get_int64_le c.buf c.pos in - c.pos <- c.pos + 8; - v - -let str c = (* mirrors read_str; len 0 -> None *) - let len = u32 c in - if len = 0 then None - else - let s = Bytes.sub_string c.buf c.pos len in - c.pos <- c.pos + len; - Some s - -(* Wire type_t is (kind, is_ptr, ptr_depth, [name if STRUCT]); we collapse it to - * Tac.ty = (base_type, depth). is_ptr is redundant (it's just depth > 0), so we - * read past it. Kind 5 (STRUCT) additionally pulls the length-prefixed name. *) -let read_type c : ty = - let kind = u32 c in - let _is_ptr = u32 c in - let depth = u32 c in - let base = - match kind with - | 0 -> U8 | 1 -> I8 | 2 -> U16 | 3 -> I16 | 4 -> Void - | 5 -> - (match str c with - | Some name -> StructName name - | None -> failwith "struct type with empty name") - | n -> failwith (Printf.sprintf "bad type kind %d" n) - in - (base, depth) - -let () = print_endline "c02-ld" +let () = + (* argv.(0) is the program name; the rest are the files to read *) + let files = Array.to_list Sys.argv |> List.tl in + List.iter (fun f -> + let m = parse_file f in + Printf.printf "%s: structs=%d globals=%d regs=%d cfgs=%d externs=%d\n" + f (List.length m.structs) (List.length m.globals) + (List.length m.regs) (List.length m.cfgs) (List.length m.externs) + ) files diff --git a/c02-ld/bin/serializer.ml b/c02-ld/bin/serializer.ml new file mode 100644 index 0000000..c34a3b5 --- /dev/null +++ b/c02-ld/bin/serializer.ml @@ -0,0 +1,175 @@ +open Tac + +(* A mutable cursor over a slurped file: advance `pos` on every read. + * RULE: every cursor read gets its own `let ... in`. Never put two cursor + * reads in the same tuple/record/argument list — OCaml leaves evaluation + * order unspecified there, which silently reads the fields out of order. *) +type cursor = { buf : bytes; mutable pos : int } + +let u32 c = (* mirrors read_u32 *) + let v = Int32.to_int (Bytes.get_int32_le c.buf c.pos) in + c.pos <- c.pos + 4; + v + +let i64 c = (* mirrors read_i64; stays int64 *) + let v = Bytes.get_int64_le c.buf c.pos in + c.pos <- c.pos + 8; + v + +let str c = (* mirrors read_str; len 0 -> None *) + let len = u32 c in + if len = 0 then None + else + let s = Bytes.sub_string c.buf c.pos len in + c.pos <- c.pos + len; + Some s + +(* Wire type_t is (kind, is_ptr, ptr_depth, [name if STRUCT]); we collapse it to + * Tac.ty = (base_type, depth). is_ptr is redundant (it's just depth > 0), so we + * read past it. Kind 5 (STRUCT) additionally pulls the length-prefixed name. *) +let read_type c : ty = + let kind = u32 c in + let _is_ptr = u32 c in + let depth = u32 c in + let base = + match kind with + | 0 -> U8 | 1 -> I8 | 2 -> U16 | 3 -> I16 | 4 -> Void + | 5 -> + (match str c with + | Some name -> StructName name + | None -> failwith "struct type with empty name") + | n -> failwith (Printf.sprintf "bad type kind %d" n) + in + (base, depth) + +let read_named_type c : named_type = + let (base, depth) = read_type c in + let name = Option.value (str c) ~default:"" in + (base, depth, name) + +let read_operand c : operand = + let kind = u32 c in + let op_type = read_type c in + match kind with + | 0 -> OperandNone (op_type) + | 1 -> OperandTemp (op_type, u32 c) + | 2 -> OperandVar (op_type, match str c with + | Some s -> s + | None -> failwith "Missing VAR name") + | 3 -> OperandConstInt (op_type, Int64.to_int(i64 c)) + | 4 -> OperandConstStr (op_type, Option.value (str c) ~default:"") + | n -> failwith (Printf.sprintf "bad operand kind %d" n) + +let read_instr c : instr = + let op = tac_op_of_int (u32 c) in + let dst = read_operand c in + let src1 = read_operand c in + let src2 = read_operand c in + let label = u32 c in + let call_name = Option.value (str c) ~default:"" in + let call_args_count = u32 c in + + let call_args = List.init call_args_count (fun _ -> read_operand c) in + + let field_name = Option.value (str c) ~default:"" in + let cast_type = read_type c in + + { op; dst; src1; src2; label; field_name; cast_type; call_name; call_args } + +let read_block c : block = + let block_id = u32 c in + let instruction_count = u32 c in + let instructions = List.init instruction_count (fun _ -> read_instr c) in + + { block_id; instructions; successors=[] } (* successors is not serialized *) + +let read_cfg c : cfg = + let fn_name = match str c with + | Some s -> s + | None -> failwith "Missing FUNCTION name" in + let ret_type = read_type c in + + let param_count = u32 c in + let params = List.init param_count (fun _ -> read_named_type c) in + + let block_count = u32 c in + let blocks = List.init block_count (fun _ -> read_block c) in + + let next_temp = u32 c in + let next_label = u32 c in + let is_interrupt = match u32 c with 0 -> false | n -> true in + + { fn_name; ret_type; params; blocks; next_temp; next_label; is_interrupt } + +(* Struct field: name, type, then byte offset within the struct. *) +let read_ir_field c : ir_field = + let ir_field_name = Option.value (str c) ~default:"" in + let ir_field_type = read_type c in + let ir_field_offset = u32 c in + { ir_field_name; ir_field_type; ir_field_offset } + +(* Struct: name, field_count, total_size, THEN the fields (size precedes them + * on the wire). *) +let read_ir_struct c : ir_struct = + let ir_struct_name = Option.value (str c) ~default:"" in + let field_count = u32 c in + let ir_struct_size = u32 c in + let ir_struct_fields = List.init field_count (fun _ -> read_ir_field c) in + { ir_struct_name; ir_struct_fields; ir_struct_size } + +(* Global: name, type, init_kind, then a kind-dependent payload (an i64, a + * string, or nothing). We store the raw value; the unused half defaults. *) +let read_global c : global = + let glob_name = Option.value (str c) ~default:"" in + let glob_type = read_type c in + let glob_init_kind = ir_init_kind_of_int (u32 c) in + let (glob_int_val, glob_str_val) = + match glob_init_kind with + | IRInitInt -> (Int64.to_int (i64 c), "") + | IRInitStr -> (0, Option.value (str c) ~default:"") + | IRInitNone -> (0, "") + in + { glob_name; glob_type; glob_init_kind; glob_int_val; glob_str_val } + +(* Register: name, type, then a u64 hardware address. The wire address is 8 + * bytes; a 6502 address fits an int, so we narrow. *) +let read_reg c : reg = + let reg_name = Option.value (str c) ~default:"" in + let reg_type = read_type c in + let reg_addr = Int64.to_int (i64 c) in + { reg_name; reg_type; reg_addr } + +(* Extern (forward decl): is_function flag, name, type, then param_count params. + * `params` is only meaningful when is_function, but always present on the wire. *) +let read_extern c : extern = + let extern_is_func = u32 c <> 0 in + let extern_name = Option.value (str c) ~default:"" in + let extern_type = read_type c in + let param_count = u32 c in + let extern_params = List.init param_count (fun _ -> read_named_type c) in + { extern_is_func; extern_name; extern_type; extern_params } + +let read_module c : ir_module = + let magic = u32 c in + if magic <> c02_magic then failwith "Invalid object file"; + + let version = u32 c in + if version <> ir_version then + failwith (Printf.sprintf "Bad IR format expected version v%d got v%d" ir_version version); + + let struct_count = u32 c in + let structs = List.init struct_count (fun _ -> read_ir_struct c) in + + let global_count = u32 c in + let globals = List.init global_count (fun _ -> read_global c) in + + let reg_count = u32 c in + let regs = List.init reg_count (fun _ -> read_reg c) in + + let cfg_count = u32 c in + let cfgs = List.init cfg_count (fun _ -> read_cfg c) in + + let extern_count = u32 c in + let externs = List.init extern_count (fun _ -> read_extern c) in + + { externs; structs; globals; regs; cfgs } diff --git a/c02-ld/bin/tac.ml b/c02-ld/bin/tac.ml index 3f543ab..8a7f6a5 100644 --- a/c02-ld/bin/tac.ml +++ b/c02-ld/bin/tac.ml @@ -60,11 +60,11 @@ type instr = { dst : operand; src1 : operand; src2 : operand; - label : int option; - call_name : string option; - call_args : operand list option; - field_name : string option; - cast_type : ty option; + label : int; + call_name : string; + call_args : operand list; + field_name : string; + cast_type : ty; } (* `successors` is NOT on the wire — rebuild it from terminators after load. diff --git a/scripts/c02c.py b/scripts/c02c.py index 7f303db..d89644f 100755 --- a/scripts/c02c.py +++ b/scripts/c02c.py @@ -91,7 +91,7 @@ def main(): # --- 2. link + optimize (future OCaml pass) ----------------------------- # TODO: merge IR objects and run optimization passes here. - result = subprocess.run([linker]) + result = subprocess.run([linker] + o_files) if result.returncode != 0: sys.exit(result.returncode) From ba184b4777f4025005e3c01c5135e35b71a4304a Mon Sep 17 00:00:00 2001 From: jackwthake Date: Wed, 15 Jul 2026 20:55:49 -0700 Subject: [PATCH 07/12] Implemented linking logic --- CHANGELOG.md | 20 ++++++++- c02-ld/bin/c02_ld.ml | 15 ++++--- c02-ld/bin/link.ml | 98 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 c02-ld/bin/link.ml diff --git a/CHANGELOG.md b/CHANGELOG.md index 1094822..3635314 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,9 +20,25 @@ releases are reserved for bug fixes only. registers, and externs — with magic/version enforcement up front. The reader is a faithful transcription of the stream: instruction fields absent for a given opcode are stored raw (empty string / `0` / `[]`) rather than modelled - as options, since the linker consumes TAC and never re-emits it. Validated by + as options — a lossless choice, since the wire never distinguished absent from + empty, so the raw form re-serializes to byte-identical output. Validated by parsing all 69 `test/emu` objects with no desync (section counts match - `c02-as --dump-ir`). Symbol resolution, layout, and relocation are next. + `c02-as --dump-ir`). +- **`c02-ld` cross-module linking (merge + symbol resolution).** Given several + `.o` inputs, `bin/link.ml` merges them into one module and resolves the symbol + namespace. Registers, globals, functions, and externs are keyed by name in one + symbol table (a single namespace per SPEC §7.2; structs are a separate + namespace), and a `combine` pass applies the linkage rules on each name + collision: identical registers dedup, globals follow the tentative-definition + rule (at most one initializer), duplicate function definitions and cross-kind + name clashes are rejected, and forward declarations are *resolved* — an + `extern` is matched to its definition (return type plus parameter *types*, + names ignored) and dropped, leaving only genuinely-unresolved externs in the + output for the code generator to satisfy (partial-link semantics). Exactly one + `void main()` is enforced at link time (§7.4 — the existence half the analyzer + defers). Verified end-to-end: cross-module resolution, signature-mismatch and + redefinition rejection, and a clean 69/69 single-module regression. + Re-serializing the merged module to a `.o` and de-duplicating structs remain. - **OCaml/dune wired into the build and CI.** The root `make` builds `c02-ld` through dune; CI provisions the OCaml toolchain via `ocaml/setup-ocaml`, and the `c02-ld` Makefile wraps dune calls in `opam exec` so builds don't depend diff --git a/c02-ld/bin/c02_ld.ml b/c02-ld/bin/c02_ld.ml index 78b1bbf..a976901 100644 --- a/c02-ld/bin/c02_ld.ml +++ b/c02-ld/bin/c02_ld.ml @@ -3,6 +3,7 @@ open Serializer open Tac +open Link let read_whole_file filename = In_channel.with_open_bin filename In_channel.input_all @@ -15,9 +16,11 @@ let parse_file filename = let () = (* argv.(0) is the program name; the rest are the files to read *) let files = Array.to_list Sys.argv |> List.tl in - List.iter (fun f -> - let m = parse_file f in - Printf.printf "%s: structs=%d globals=%d regs=%d cfgs=%d externs=%d\n" - f (List.length m.structs) (List.length m.globals) - (List.length m.regs) (List.length m.cfgs) (List.length m.externs) - ) files + let modules = List.map (fun f -> parse_file f) files in + + let linked = link modules in + if not (List.exists (fun f -> f.fn_name = "main") linked.cfgs) then failwith "no main function in linked program"; + + Printf.printf "structs=%d globals=%d regs=%d cfgs=%d externs=%d\n" + (List.length linked.structs) (List.length linked.globals) + (List.length linked.regs) (List.length linked.cfgs) (List.length linked.externs) diff --git a/c02-ld/bin/link.ml b/c02-ld/bin/link.ml new file mode 100644 index 0000000..1f7e37c --- /dev/null +++ b/c02-ld/bin/link.ml @@ -0,0 +1,98 @@ +open Serializer +open Tac + +type symbol = Reg of reg | Global of global | Func of cfg | Extern of extern +type symbol_table = (string, symbol) Hashtbl.t + +let make_symtab x : symbol_table = Hashtbl.create x + +let merge_two_module module_a module_b = + let externs = List.append module_a.externs module_b.externs in + let structs = List.append module_a.structs module_b.structs in + let globals = List.append module_a.globals module_b.globals in + let regs = List.append module_a.regs module_b.regs in + let cfgs = List.append module_a.cfgs module_b.cfgs in + + { externs; structs; globals; regs; cfgs } + +let rec merge_modules modules : ir_module = + match modules with + | [] -> { externs=[]; structs=[]; globals=[]; regs=[]; cfgs=[] } + | [x] -> x + | x :: y :: tail -> merge_modules (List.append [(merge_two_module x y)] tail) + +let populate_symbol_table m : symbol_table = + let len = (List.length m.externs) + (List.length m.structs) + (List.length m.globals) + + (List.length m.regs) + (List.length m.cfgs) in + let symtab = make_symtab len in + + List.iter (fun e -> Hashtbl.add symtab e.extern_name (Extern e)) m.externs; + List.iter (fun g -> Hashtbl.add symtab g.glob_name (Global g)) m.globals; + List.iter (fun r -> Hashtbl.add symtab r.reg_name (Reg r)) m.regs; + List.iter (fun f -> Hashtbl.add symtab f.fn_name (Func f)) m.cfgs; + symtab + +let combine a b = + match a, b with + | Extern e1, Extern e2 -> if e1 = e2 then a else failwith "Conflicting externs" + (* resolution: sig-check e against def -> keep def *) + | Extern e, def | def, Extern e -> if e.extern_is_func then + (match def with + | Func f -> + let param_types ps = List.map (fun (bt, d, _) -> (bt, d)) ps in + if e.extern_type = f.ret_type + && param_types e.extern_params = param_types f.params + then def + else failwith ("signature mismatch for " ^ e.extern_name) + | _ -> failwith "declared as function, defined as variable") + else + (match def with + | Global g -> if e.extern_type = g.glob_type then def + else failwith ("type mismatch for " ^ e.extern_name) + | _ -> failwith "declared as variable but defined as a register") + (* same type AND addr? -> keep one; else conflict *) + | Reg r1, Reg r2 -> if r1 = r2 then a else failwith ("Conflicting registers: " ^ r1.reg_name ^ " and " ^ r2.reg_name) + (* same type, <=1 initializer? -> merge; else err *) + | Global g1, Global g2 -> if g1.glob_type = g2.glob_type then + begin match g1.glob_init_kind, g2.glob_init_kind with + | IRInitNone, IRInitNone -> a + | IRInitNone, _ -> b + | _, IRInitNone -> a + | _, _ -> failwith ("global " ^ g1.glob_name ^ " has multiple initializers") + end + else failwith "Conflicting global types" + (* duplicate function definition -> conflict *) + | Func a, Func _ -> failwith ("Redefined function: " ^ a.fn_name) + (* cross-kind same name -> namespace collision *) + | _, _ -> failwith "Redefined symbol" + +module Names = Set.Make (String) + +(* Reconcile the populated table: for each DISTINCT name, fold that name's + bindings through `combine` down to the single surviving symbol (or fail on a + real conflict). Resolved externs disappear here — `combine` keeps the + definition — so only genuinely-unresolved externs survive, for codegen. *) +let reconcile (symtab : symbol_table) : symbol list = + let names = Hashtbl.fold (fun name _ acc -> Names.add name acc) symtab Names.empty in + Names.fold (fun name acc -> + match Hashtbl.find_all symtab name with + | [] -> acc (* unreachable: name came from the table *) + | first :: rest -> List.fold_left combine first rest :: acc + ) names [] + +(* Partition the reconciled symbols back into an ir_module. Structs aren't part + of the symbol namespace, so they ride through separately. *) +let symbols_to_module (symbols : symbol list) (structs : ir_struct list) : ir_module = + let regs = List.filter_map (function Reg r -> Some r | _ -> None) symbols in + let globals = List.filter_map (function Global g -> Some g | _ -> None) symbols in + let cfgs = List.filter_map (function Func f -> Some f | _ -> None) symbols in + let externs = List.filter_map (function Extern e -> Some e | _ -> None) symbols in + { regs; globals; cfgs; externs; structs } + +(* Whole-program link: gather every module, resolve the symbol namespace, and + rebuild one merged module. *) +let link (modules : ir_module list) : ir_module = + let merged = merge_modules modules in + let symtab = populate_symbol_table merged in + let symbols = reconcile symtab in + symbols_to_module symbols merged.structs \ No newline at end of file From 443d40790bf9bed982641a6a83960f88243d2ac5 Mon Sep 17 00:00:00 2001 From: jackwthake Date: Wed, 15 Jul 2026 22:55:08 -0700 Subject: [PATCH 08/12] Implemented struct deduping and error checking --- c02-ld/bin/link.ml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/c02-ld/bin/link.ml b/c02-ld/bin/link.ml index 1f7e37c..ae7bb48 100644 --- a/c02-ld/bin/link.ml +++ b/c02-ld/bin/link.ml @@ -89,10 +89,19 @@ let symbols_to_module (symbols : symbol list) (structs : ir_struct list) : ir_mo let externs = List.filter_map (function Extern e -> Some e | _ -> None) symbols in { regs; globals; cfgs; externs; structs } +let dedup_structs structs : ir_struct list = + List.fold_left (fun acc s -> + match List.find_opt (fun k -> k.ir_struct_name = s.ir_struct_name) acc with + | None -> s :: acc + | Some k when k = s -> acc + | Some _ -> failwith ("Struct: " ^ s.ir_struct_name ^ " redeclaration") + ) [] structs + (* Whole-program link: gather every module, resolve the symbol namespace, and rebuild one merged module. *) let link (modules : ir_module list) : ir_module = let merged = merge_modules modules in let symtab = populate_symbol_table merged in let symbols = reconcile symtab in - symbols_to_module symbols merged.structs \ No newline at end of file + + symbols_to_module symbols (dedup_structs merged.structs) \ No newline at end of file From 5c8563db61514731200bd80ab3b7e6e4e6f04cfd Mon Sep 17 00:00:00 2001 From: jackwthake Date: Thu, 16 Jul 2026 14:25:39 -0700 Subject: [PATCH 09/12] Hooked linker up end to end. Started libc02 standard library --- .gitignore | 2 + CHANGELOG.md | 47 ++++++++++++-- Makefile | 18 ++++-- c02-as/src/generator.c | 26 +++++++- c02-ld/bin/c02_ld.ml | 26 ++++++-- c02-ld/bin/serializer.ml | 120 +++++++++++++++++++++++++++++++++++ c02-ld/bin/tac.ml | 58 +++++++++++++---- examples/lcd_hello_world.c02 | 41 +----------- libc02/lcd.c02 | 37 +++++++++++ scripts/c02c.py | 76 ++++++++++++---------- 10 files changed, 349 insertions(+), 102 deletions(-) create mode 100644 libc02/lcd.c02 diff --git a/.gitignore b/.gitignore index 8cc5ba6..061c2ac 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ *.vsix .vscode /bin/ +/lib/ +/scratchpad/ *.out *.bin *.d diff --git a/CHANGELOG.md b/CHANGELOG.md index 3635314..52279ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ 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. -## [Unreleased] +## [1.6.0] 2026-07-16 ### Added @@ -34,11 +34,48 @@ releases are reserved for bug fixes only. name clashes are rejected, and forward declarations are *resolved* — an `extern` is matched to its definition (return type plus parameter *types*, names ignored) and dropped, leaving only genuinely-unresolved externs in the - output for the code generator to satisfy (partial-link semantics). Exactly one - `void main()` is enforced at link time (§7.4 — the existence half the analyzer - defers). Verified end-to-end: cross-module resolution, signature-mismatch and + output for the code generator to satisfy (partial-link semantics). The linker + never requires a `main`: it always emits a relocatable object, which may be a + library. Verified end-to-end: cross-module resolution, signature-mismatch and redefinition rejection, and a clean 69/69 single-module regression. - Re-serializing the merged module to a `.o` and de-duplicating structs remain. +- **`c02-ld` struct de-duplication.** Structs are their own namespace (not in the + symbol table), so merging is handled separately: `dedup_structs` folds the + concatenated struct list, keeping one copy when a repeated name has an identical + layout and rejecting a genuine conflict (same name, differing fields/size). + Field order is significant, so a reordered layout is treated as a conflict. +- **`c02-ld` IR object-file writer + `-o` output.** The linker now re-serializes + the merged module back to the wire format, so its output is a real `.o` the code + generator consumes. `bin/serializer.ml` gains a `put_*` for every `read_*` + (types, operands, the fat instruction, blocks, CFGs, structs, globals, + registers, externs), built on OCaml's `Buffer`; the `tac_op` wire numbering is + driven by a single array so the read and write directions cannot drift apart. + `c02-ld -o out.o in.o …` writes the linked object. Validated: all 69 objects + round-trip idempotently (link, re-link, `cmp` — proving the writer inverts the + reader); `c02-as` accepts the output; 65/69 assemble to byte-identical ROMs + versus the un-linked pipeline, the other 4 differing only in function *order* + (which is layout-only — the reset stub calls `main` by label — and all 4 still + pass the emulator suite); and a real multi-file program (an LCD library split + into its own translation unit, referenced through forward declarations) links to + a correct, same-sized binary. +- **`c02c` driver wires the linker into the pipeline, with `-c` library builds.** + The driver now runs the real three-stage pipeline (frontend → `c02-ld` → + `c02-as`): each source is compiled to a temp object, the objects are merged by + the linker (skipped when there is only one, which goes straight to codegen), and + the merged object is assembled to a ROM. `-c` stops after the link and writes the + merged object to the requested `-o`, so several sources compile into a single + reusable library object (a `decl fn` forward declaration in a client resolves + against it at final link). Temp objects are cleaned up on every exit path via a + `try/finally`. Verified: an LCD driver library split into its own translation + unit compiles under `-c` and links into a working program, and a real + library-plus-`main` round trip runs on the emulator. +- **Entry-point (`main`) existence is enforced by the code generator.** Because + `c02-ld` only ever emits a relocatable object (which may be a library), the check + for a program entry point moved to `c02-as` — the one stage that produces an + executable and emits `jsr main`. `emit_call_main` now reports a clear + `program has no 'main' function` when no `main` is defined, rather than surfacing + it as an unresolved-symbol error. The analyzer still validates `main`'s + *signature* (`void`, no parameters) when it is defined; existence is a + whole-program property left to codegen (SPEC §7.4). - **OCaml/dune wired into the build and CI.** The root `make` builds `c02-ld` through dune; CI provisions the OCaml toolchain via `ocaml/setup-ocaml`, and the `c02-ld` Makefile wraps dune calls in `opam exec` so builds don't depend diff --git a/Makefile b/Makefile index dd6b6cb..ec5c97a 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,9 @@ .PHONY: all clean c02-frontend c02-ld c02-as c02-objdump emu-test BIN_DIR = bin +LIB_DIR = lib -all: $(BIN_DIR)/c02c c02-frontend c02-ld c02-as c02-objdump +all: $(BIN_DIR)/c02c c02-frontend c02-ld c02-as c02-objdump lib/libc02.o # Runtime tests: compile each test/emu/*.c02 to a ROM and execute it in py65, # checking the code generator's output against embedded EXPECT directives. @@ -19,22 +20,29 @@ $(BIN_DIR)/c02c: scripts/c02c.py | $(BIN_DIR) $(BIN_DIR): mkdir -p $@ +$(LIB_DIR): + mkdir -p $@ + c02-frontend: c02-frontend/ - @printf '==> c02-frontend\n' + @printf '\n==> c02-frontend\n' @$(MAKE) -C c02-frontend c02-ld: c02-ld/ - @printf '==> c02-ld\n' + @printf '\n==> c02-ld\n' @$(MAKE) -C c02-ld c02-as: c02-as/ - @printf '==> c02-as\n' + @printf '\n==> c02-as\n' @$(MAKE) -C c02-as c02-objdump: c02-objdump/ @printf '\n==> c02-objdump\n' @$(MAKE) -C c02-objdump +lib/libc02.o: libc02/ $(LIB_DIR)/libc02.o | $(LIB_DIR) + @printf '\n==> Building standard lib\n' + ./bin/c02c -c libc02/*.c02 -o $(LIB_DIR)/libc02.o + clean: @printf '==> c02-frontend clean\n' @$(MAKE) -C c02-frontend clean @@ -44,4 +52,6 @@ clean: @$(MAKE) -C c02-as clean @printf '\n==> c02-objdump clean\n' @$(MAKE) -C c02-objdump clean + @printf '\n==> standard lib clean\n' + @rm -rf $(LIB_DIR) rm -f $(BIN_DIR)/c02c diff --git a/c02-as/src/generator.c b/c02-as/src/generator.c index 1b8e063..6a1b8c4 100644 --- a/c02-as/src/generator.c +++ b/c02-as/src/generator.c @@ -577,12 +577,29 @@ static void emit_bootstrap(emitter_t *e) { // Emit JSR main followed by an infinite halt loop (JMP to self). -static void emit_call_main(emitter_t *e) { +// A whole program must define an entry point (SPEC §7.4): this is where that is +// enforced, since the generator is the only stage that produces an executable — +// a linked object may legitimately be a library with no main. +static int emit_call_main(emitter_t *e) { + int has_main = 0; + for (unsigned i = 0; i < e->gen->module.cfg_count; ++i) { + if (strcmp(e->gen->module.cfgs[i].name, "main") == 0) { + has_main = 1; + break; + } + } + + if (!has_main) { + fprintf(stderr, "codegen: program has no 'main' function\n"); + return 0; + } + jsr(e, "main"); // halt loop uint16_t halt_addr = (uint16_t)(ROM_START + e->code_pos); jmp_abs(e, halt_addr); + return 1; } @@ -1911,7 +1928,12 @@ uint8_t *generate_rom(ir_gen_t *gen, size_t *final_rom_size, int emit_symbols) { return NULL; } - emit_call_main(&e); + if (!emit_call_main(&e)) { + free(e.rom); + emitter_free(&e); + *final_rom_size = 0; + return NULL; + } for (unsigned i = 0; i < gen->module.cfg_count; ++i) { if (!emit_function_from_cfg(&e, &gen->module.cfgs[i])) { diff --git a/c02-ld/bin/c02_ld.ml b/c02-ld/bin/c02_ld.ml index a976901..bbd7bd7 100644 --- a/c02-ld/bin/c02_ld.ml +++ b/c02-ld/bin/c02_ld.ml @@ -14,13 +14,25 @@ let parse_file filename = read_module cursor let () = - (* argv.(0) is the program name; the rest are the files to read *) - let files = Array.to_list Sys.argv |> List.tl in + (* argv.(0) is the program name; the rest are the args *) + let args = Array.to_list Sys.argv |> List.tl in + + (* parse args: expect -o plus one or more input files *) + let rec walk acc_files out = function + | [] -> (List.rev acc_files, out) + | "-o" :: file :: rest -> walk acc_files (Some file) rest + | flag :: _ when flag = "-o" -> failwith "-o requires a filename" + | f :: rest -> walk (f :: acc_files) out rest + in + let (files, outfile) = walk [] None args in + let outfile = match outfile with Some f -> f | None -> failwith "missing -o output file" in + let modules = List.map (fun f -> parse_file f) files in + (* The linker always emits a relocatable object; it never requires a main. + Entry-point existence (§7.4) is enforced by the generator, the only stage + that turns an object into an executable — a linked object may legitimately + be a library. Conflict rejection, struct dedup, and extern pass-through + still happen in `link`. *) let linked = link modules in - if not (List.exists (fun f -> f.fn_name = "main") linked.cfgs) then failwith "no main function in linked program"; - - Printf.printf "structs=%d globals=%d regs=%d cfgs=%d externs=%d\n" - (List.length linked.structs) (List.length linked.globals) - (List.length linked.regs) (List.length linked.cfgs) (List.length linked.externs) + write_module linked outfile diff --git a/c02-ld/bin/serializer.ml b/c02-ld/bin/serializer.ml index c34a3b5..f95cd73 100644 --- a/c02-ld/bin/serializer.ml +++ b/c02-ld/bin/serializer.ml @@ -173,3 +173,123 @@ let read_module c : ir_module = let externs = List.init extern_count (fun _ -> read_extern c) in { externs; structs; globals; regs; cfgs } + + +(* writing *) +let put_u32 b v = Buffer.add_int32_le b (Int32.of_int v) +let put_i64 b v = Buffer.add_int64_le b (Int64.of_int v) +let put_u64 b v = Buffer.add_int64_le b (Int64.of_int v) (* reg addr; same bytes *) +let put_str b s = put_u32 b (String.length s); Buffer.add_string b s + + +let put_type b (t : ty) = + let (bt, ptr_d) = t in + put_u32 b (type_kind_tag bt); + put_u32 b (if ptr_d > 0 then 1 else 0); + put_u32 b ptr_d; + match bt with StructName nm -> put_str b nm | _ -> () + +let put_named_type b (n : named_type) = + let (base, depth, name) = n in + put_type b (base, depth); + put_str b name + +(* A length-prefixed sequence: [u32 count][elements]. The write-side counterpart + * to the reader's `let n = u32 c in List.init n (fun _ -> read_x c)` — write the + * count, then iterate the element putter over the list. *) +let put_list b put_elem lst = + put_u32 b (List.length lst); + List.iter (fun x -> put_elem b x) lst + +(* read_operand: kind, type (always), then the kind-specific payload. *) +let put_operand b (op : operand) = + put_u32 b (operand_tag op); + put_type b (operand_type op); + match op with + | OperandNone _ -> () + | OperandTemp (_, i) -> put_u32 b i + | OperandVar (_, n) -> put_str b n + | OperandConstInt (_, n) -> put_i64 b n + | OperandConstStr (_, s) -> put_str b s + +(* read_instr order: op, dst, src1, src2, label, call_name, call_arg_count, + * [args], field_name, cast_type. Fat instruction — every field unconditionally, + * regardless of op. Our fields are raw (""/0 for absent), so no option handling. *) +let put_instr b (i : instr) = + put_u32 b (tac_op_tag i.op); + put_operand b i.dst; + put_operand b i.src1; + put_operand b i.src2; + put_u32 b i.label; + put_str b i.call_name; + put_list b put_operand i.call_args; + put_str b i.field_name; + put_type b i.cast_type + +(* read_block: id, instr_count, [instrs]. successors is NOT on the wire. *) +let put_block b (blk : block) = + put_u32 b blk.block_id; + put_list b put_instr blk.instructions + +(* read_cfg: name, ret_type, params, blocks, next_temp, next_label, is_interrupt. *) +let put_cfg b (c : cfg) = + put_str b c.fn_name; + put_type b c.ret_type; + put_list b put_named_type c.params; + put_list b put_block c.blocks; + put_u32 b c.next_temp; + put_u32 b c.next_label; + put_u32 b (if c.is_interrupt then 1 else 0) + +(* struct field: name, type, offset. *) +let put_ir_field b (f : ir_field) = + put_str b f.ir_field_name; + put_type b f.ir_field_type; + put_u32 b f.ir_field_offset + +(* struct: name, field_count, total_size, THEN fields (size precedes them). *) +let put_ir_struct b (s : ir_struct) = + put_str b s.ir_struct_name; + put_u32 b (List.length s.ir_struct_fields); + put_u32 b s.ir_struct_size; + List.iter (fun f -> put_ir_field b f) s.ir_struct_fields + +(* global: name, type, init_kind, then a kind-dependent payload. *) +let put_global b (g : global) = + put_str b g.glob_name; + put_type b g.glob_type; + put_u32 b (ir_init_kind_tag g.glob_init_kind); + (match g.glob_init_kind with + | IRInitInt -> put_i64 b g.glob_int_val + | IRInitStr -> put_str b g.glob_str_val + | IRInitNone -> ()) + +(* register: name, type, u64 hardware address. *) +let put_reg b (r : reg) = + put_str b r.reg_name; + put_type b r.reg_type; + put_u64 b r.reg_addr + +(* extern: is_function flag, name, type, params. *) +let put_extern b (e : extern) = + put_u32 b (if e.extern_is_func then 1 else 0); + put_str b e.extern_name; + put_type b e.extern_type; + put_list b put_named_type e.extern_params + +(* Sections in the reader's order (structs, globals, regs, cfgs, externs), which + * is NOT the ir_module record's field order. *) +let put_module b (m : ir_module) = + put_u32 b c02_magic; + put_u32 b ir_version; + put_list b put_ir_struct m.structs; + put_list b put_global m.globals; + put_list b put_reg m.regs; + put_list b put_cfg m.cfgs; + put_list b put_extern m.externs + +let write_module (m : ir_module) filename = + let b = Buffer.create 4096 in + put_module b m; + Out_channel.with_open_bin filename (fun oc -> Buffer.output_buffer oc b) + \ No newline at end of file diff --git a/c02-ld/bin/tac.ml b/c02-ld/bin/tac.ml index 8a7f6a5..bb95175 100644 --- a/c02-ld/bin/tac.ml +++ b/c02-ld/bin/tac.ml @@ -137,6 +137,14 @@ let operand_type = function | OperandNone t | OperandTemp (t, _) | OperandVar (t, _) | OperandConstInt (t, _) | OperandConstStr (t, _) -> t +(* operand -> wire OPERAND_* kind. Inverse of read_operand's `match kind`. *) +let operand_tag = function + | OperandNone _ -> 0 + | OperandTemp _ -> 1 + | OperandVar _ -> 2 + | OperandConstInt _ -> 3 + | OperandConstStr _ -> 4 + (* base_type -> wire TYPE_* kind. The inverse lives in the reader's read_type, * because kind 5 (STRUCT) must also pull a length-prefixed name off the cursor * to fill in `StructName`. *) @@ -144,21 +152,43 @@ let type_kind_tag = function | U8 -> 0 | I8 -> 1 | U16 -> 2 | I16 -> 3 | Void -> 4 | StructName _ -> 5 -let tac_op_of_int = function - | 0 -> TacAdd | 1 -> TacSub | 2 -> TacMul | 3 -> TacDiv | 4 -> TacMod - | 5 -> TacLt | 6 -> TacGt | 7 -> TacLte | 8 -> TacGte | 9 -> TacEq | 10 -> TacNeq - | 11 -> TacAnd | 12 -> TacOr - | 13 -> TacShl | 14 -> TacShr | 15 -> TacBand | 16 -> TacBxor | 17 -> TacBor - | 18 -> TacNeg | 19 -> TacNot | 20 -> TacBnot - | 21 -> TacInc | 22 -> TacDec - | 23 -> TacAddrOf | 24 -> TacLoad | 25 -> TacStore - | 26 -> TacCopy - | 27 -> TacLabel | 28 -> TacJump | 29 -> TacCondJump - | 30 -> TacCall | 31 -> TacReturn | 32 -> TacContinue | 33 -> TacBreak - | 34 -> TacFieldLoad | 35 -> TacFieldStore - | 36 -> TacCast - | n -> failwith (Printf.sprintf "bad tac_op %d" n) +(* tac_op wire numbering: the index into this array IS the tag. Both directions + * are derived from it, so they cannot drift apart — a hand-written inverse would + * be a second list to keep in lockstep, and a mismatch there is a silent binary + * desync. Line grouping mirrors the type declaration above; keep it that way so + * the two stay diffable against ir.h's enum. *) +let tac_ops = [| + TacAdd; TacSub; TacMul; TacDiv; TacMod; + TacLt; TacGt; TacLte; TacGte; TacEq; TacNeq; + TacAnd; TacOr; + TacShl; TacShr; TacBand; TacBxor; TacBor; + TacNeg; TacNot; TacBnot; + TacInc; TacDec; + TacAddrOf; TacLoad; TacStore; + TacCopy; + TacLabel; TacJump; TacCondJump; + TacCall; TacReturn; TacContinue; TacBreak; + TacFieldLoad; TacFieldStore; + TacCast; +|] + +let tac_op_of_int n = + if n < 0 || n >= Array.length tac_ops then failwith (Printf.sprintf "bad tac_op %d" n) + else tac_ops.(n) + +(* Inverse by construction. A constructor added to tac_op but missing from + * tac_ops fails loudly here instead of emitting a wrong tag. *) +let tac_op_tag op = + let rec go i = + if i >= Array.length tac_ops then failwith "tac_op missing from tac_ops" + else if tac_ops.(i) = op then i + else go (i + 1) + in + go 0 let ir_init_kind_of_int = function | 0 -> IRInitNone | 1 -> IRInitInt | 2 -> IRInitStr | n -> failwith (Printf.sprintf "bad ir_init_kind %d" n) + +let ir_init_kind_tag = function + | IRInitNone -> 0 | IRInitInt -> 1 | IRInitStr -> 2 diff --git a/examples/lcd_hello_world.c02 b/examples/lcd_hello_world.c02 index a43700e..868acb4 100644 --- a/examples/lcd_hello_world.c02 +++ b/examples/lcd_hello_world.c02 @@ -1,41 +1,6 @@ -reg u8 PORTB @ 0x6000; -reg u8 PORTA @ 0x6001; -reg u8 DDRB @ 0x6002; -reg u8 DDRA @ 0x6003; - -/* -PORTB = LCD data lines -PORTA = top 3 bits for control lines, PA7 = enable, PA6 = read/write, PA6 = register select -*/ - - -fn lcd_send_command(u8 cmd) -> void { - PORTB = cmd; // Put command on data lines - PORTA = 0x80; // RS=0, E=1 to latch command - PORTA = 0x00; // E=0 to complete command -} - -fn lcd_init() -> void { - // Set the data direction registers for PORTA and PORTB to output - DDRB = 0xFF; // Set all pins of PORTB as output - DDRA = 0xE0; // Set top 3 bits of PORTA as output (for RS, RW, E) - - // Clear the ports to start with a known state - PORTB = 0; // Clear PORTB - PORTA = 0; // Clear PORTA - - // Initialize the LCD (following a typical initialization sequence) - lcd_send_command(0x38); // Function set: 8-bit, 2 lines, 5x8 dots - lcd_send_command(0x0C); // Display on, cursor off - lcd_send_command(0x06); // Entry mode set: increment cursor, no shift -} - -fn lcd_putc(u8 ch) -> void { - PORTB = ch; // Put character on data lines - PORTA = 0x32; // RS - PORTA = 0xA0; // RS | E - PORTA = 0x32; // RS -} +// From libc02/lcd.c02 +decl fn lcd_init() -> void; +decl fn lcd_putc(u8 ch) -> void; fn main() -> void { u8 *msg = "Hello C02!"; diff --git a/libc02/lcd.c02 b/libc02/lcd.c02 new file mode 100644 index 0000000..794c6bb --- /dev/null +++ b/libc02/lcd.c02 @@ -0,0 +1,37 @@ +reg u8 PORTB @ 0x6000; +reg u8 PORTA @ 0x6001; +reg u8 DDRB @ 0x6002; +reg u8 DDRA @ 0x6003; + +/* +PORTB = LCD data lines +PORTA = top 3 bits for control lines, PA7 = enable, PA6 = read/write, PA6 = register select +*/ + +fn lcd_send_command(u8 cmd) -> void { + PORTB = cmd; // Put command on data lines + PORTA = 0x80; // RS=0, E=1 to latch command + PORTA = 0x00; // E=0 to complete command +} + +fn lcd_init() -> void { + // Set the data direction registers for PORTA and PORTB to output + DDRB = 0xFF; // Set all pins of PORTB as output + DDRA = 0xE0; // Set top 3 bits of PORTA as output (for RS, RW, E) + + // Clear the ports to start with a known state + PORTB = 0; // Clear PORTB + PORTA = 0; // Clear PORTA + + // Initialize the LCD (following a typical initialization sequence) + lcd_send_command(0x38); // Function set: 8-bit, 2 lines, 5x8 dots + lcd_send_command(0x0C); // Display on, cursor off + lcd_send_command(0x06); // Entry mode set: increment cursor, no shift +} + +fn lcd_putc(u8 ch) -> void { + PORTB = ch; // Put character on data lines + PORTA = 0x32; // RS + PORTA = 0xA0; // RS | E + PORTA = 0x32; // RS +} diff --git a/scripts/c02c.py b/scripts/c02c.py index d89644f..1bb52d6 100755 --- a/scripts/c02c.py +++ b/scripts/c02c.py @@ -53,7 +53,12 @@ def make_temp_object(): def clean_o_file_temps(o_files): for f in o_files: - os.remove(f) + # A temp path is registered before its file is written, so a stage that + # fails early can leave a path here that never became a file — tolerate it. + try: + os.remove(f) + except FileNotFoundError: + pass def main(): @@ -72,42 +77,49 @@ def main(): output = "a.out" if args.output == None else args.output temps = [] - # --- 1. frontend: c02 source -> IR object ------------------------------- - for src in src_files: - if args.c: - src_out = output - else: + # The pipeline registers temp objects in `temps` as it goes; the finally + # clause deletes them on every exit path — success, a failing stage's + # sys.exit (which raises SystemExit and passes through finally), or a crash. + try: + # --- 1. frontend: c02 source -> IR object ----------------------------- + for src in src_files: src_out = make_temp_object() temps += [src_out] # collect temps for deletion after pipeline - - o_files += [src_out] - result = subprocess.run([frontend] + frontend_args + ['-o', src_out] + [src]) - if result.returncode != 0: + o_files += [src_out] + result = subprocess.run([frontend] + frontend_args + ['-o', src_out] + [src]) + if result.returncode != 0: + sys.exit(result.returncode) + + if args.parse_only or args.dump_ast: + sys.exit(0) + + # --- 2. link: merge every object into one ----------------------------- + # With -c we stop here and write the merged object to the requested -o. It + # may be a library with no main, which is fine: the generator, not the + # linker, requires an entry point. Without -c the merged object feeds codegen. + if args.c: + result = subprocess.run([linker] + o_files + ["-o", output]) sys.exit(result.returncode) - if args.c or args.parse_only or args.dump_ast: + if len(o_files) > 1: # more than one object: merge them + linked = make_temp_object() + temps += [linked] + result = subprocess.run([linker] + o_files + [ "-o", linked]) + if result.returncode != 0: + sys.exit(result.returncode) + else: + linked = o_files[0] # single object nothing to merge + + generater_args = [] + if args.dump_ir: generater_args += ['--dump-ir'] + if args.strip_debug: generater_args += ['--strip-debug'] + + # --- 3. codegen: IR object -> 6502 ROM -------------------------------- + result = subprocess.run([codegen, linked, "-o", output] + generater_args) + if result.returncode != 0: + sys.exit(result.returncode) + finally: clean_o_file_temps(temps) - sys.exit(0) - - # --- 2. link + optimize (future OCaml pass) ----------------------------- - # TODO: merge IR objects and run optimization passes here. - result = subprocess.run([linker] + o_files) - if result.returncode != 0: - sys.exit(result.returncode) - - linked = o_files[0] # TODO: this will be replaced with the result of the linking stage - - generater_args = [] - if args.dump_ir: generater_args += ['--dump-ir'] - if args.strip_debug: frontend_args += ['--strip-debug'] - - # --- 3. codegen: IR object -> 6502 ROM ----------------------------------- - # TODO: consume the frontend's emitted .o. Until that exists, run the - result = subprocess.run([codegen, linked, "-o", output] + generater_args) - - clean_o_file_temps(temps) - if result.returncode != 0: - sys.exit(result.returncode) if __name__ == "__main__": main() From fd97d3683073f061549ce179385cba6e24f32e1c Mon Sep 17 00:00:00 2001 From: jackwthake Date: Thu, 16 Jul 2026 16:37:54 -0700 Subject: [PATCH 10/12] Fixed type coercion in frontend --- CHANGELOG.md | 17 +++++++++++++++++ c02-frontend/src/C02/Lowering/Lower.hs | 15 +++++++++++++-- examples/lcd_hello_world.c02 | 2 +- test/emu/ptr_store_zero.c02 | 11 +++++++++++ test/emu/store_zero_reg.c02 | 12 ++++++++++++ 5 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 test/emu/ptr_store_zero.c02 create mode 100644 test/emu/store_zero_reg.c02 diff --git a/CHANGELOG.md b/CHANGELOG.md index 52279ba..166e16e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ 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.6.1] 2026-07-16 + +### Fixed + +- **Store of the literal `0` to a `u8` lvalue no longer clobbers the adjacent + byte.** The literal `0` is typed `void*` (the null type, SPEC §3.4), and the + code generator sizes a `TacStore` by its *source* operand — so `PORTA = 0` or + `*p = 0` into a `u8` target emitted a two-byte store, spilling a second zero + into the next address. In the LCD hello-world example this left `DDRB = 0`, + turning PORTB back to inputs so no character ever reached the display. The + frontend's assignment lowering (`C02.Lowering.Lower`) now coerces the value to + the destination's type on the register-write and pointer-store (`Deref`) paths + before emitting the store, restoring parity with cc02's `NODE_ASSIGN`. (The + struct-field path was unaffected: `TacFieldStore` is sized by the field's own + declared width, not the source operand.) Guarded by new `store_zero_reg` and + `ptr_store_zero` emulator tests. + ## [1.6.0] 2026-07-16 ### Added diff --git a/c02-frontend/src/C02/Lowering/Lower.hs b/c02-frontend/src/C02/Lowering/Lower.hs index 1221148..f43d74d 100644 --- a/c02-frontend/src/C02/Lowering/Lower.hs +++ b/c02-frontend/src/C02/Lowering/Lower.hs @@ -308,14 +308,25 @@ lowerStmt env _loop st (Assign lhs op rhs) = -- (populated only for lowering) is what tells the two apart, since both -- are 'VarSym' to the type system. Mirrors cc02's NODE_ASSIGN. Var name -> case Map.lookup name (registers env) of + -- Coerce the value to the register's own type first: codegen sizes a + -- store by its source operand, so an un-narrowed source (e.g. the + -- literal 0, typed void* per S-3.4) would store two bytes and clobber + -- the adjacent register. Just addr -> - ([(emptyInstr TacStore) { instrDst = OperandConstInt (U16, 0) addr, instrSrc1 = val }], st1) + let (val', coerceInstrs, st1') = widenIfNeeded st1 val (typeOf env lhs) + in (coerceInstrs ++ [(emptyInstr TacStore) { instrDst = OperandConstInt (U16, 0) addr, instrSrc1 = val' }], st1') Nothing -> let dst = OperandVar (typeOf env lhs) name in ([(emptyInstr TacCopy) { instrDst = dst, instrSrc1 = val }], st1) + -- Coerce the value to the pointee type for the same reason as the register + -- write above: TacStore is sized by its source operand, so `*p = 0` into a + -- u8 target must narrow the void*-typed literal 0 or it stores two bytes. + -- (TacFieldStore below is sized by the field's own width in codegen, so it + -- needs no such coercion.) Deref ptr -> let (ptrOp, ptrInstrs, st') = lowerExpr env st1 ptr - in (ptrInstrs ++ [(emptyInstr TacStore) { instrDst = ptrOp, instrSrc1 = val }], st') + (val', coerceInstrs, st'') = widenIfNeeded st' val (typeOf env lhs) + in (ptrInstrs ++ coerceInstrs ++ [(emptyInstr TacStore) { instrDst = ptrOp, instrSrc1 = val' }], st'') Field base fname -> let (baseOp, baseInstrs, st') = lowerExpr env st1 base in (baseInstrs ++ [(emptyInstr TacFieldStore) { instrDst = baseOp, instrSrc1 = val, instrFieldName = Just fname }], st') diff --git a/examples/lcd_hello_world.c02 b/examples/lcd_hello_world.c02 index 868acb4..8aa9316 100644 --- a/examples/lcd_hello_world.c02 +++ b/examples/lcd_hello_world.c02 @@ -2,8 +2,8 @@ decl fn lcd_init() -> void; decl fn lcd_putc(u8 ch) -> void; +u8 *msg = "Hello C02!"; fn main() -> void { - u8 *msg = "Hello C02!"; lcd_init(); for (u8 *p = msg; *p != null; ++p) { diff --git a/test/emu/ptr_store_zero.c02 b/test/emu/ptr_store_zero.c02 new file mode 100644 index 0000000..11502a4 --- /dev/null +++ b/test/emu/ptr_store_zero.c02 @@ -0,0 +1,11 @@ +// DESC: *p = 0 stores one byte; the literal 0 (void*, S-3.4) is narrowed to the +// pointee u8 so the store does not clobber the byte above (Deref store path). +// EXPECT: mem8[0x6000] == 0 +// EXPECT: mem8[0x6001] == 0xAB +reg u8 PORTA @ 0x6001; + +fn main() -> void { + u8 *p = (u8*)0x6000; + PORTA = 0xAB; // sentinel one byte above *p + *p = 0; // pre-fix: stored 2 bytes and wiped PORTA +} diff --git a/test/emu/store_zero_reg.c02 b/test/emu/store_zero_reg.c02 new file mode 100644 index 0000000..7c496d2 --- /dev/null +++ b/test/emu/store_zero_reg.c02 @@ -0,0 +1,12 @@ +// DESC: reg = 0 stores one byte; the literal 0 is void*-typed (S-3.4) and must be +// narrowed to the register's u8, else the store spills a second byte into the +// adjacent register. Regression: the LCD hello-world PORTB=0 clobbered DDRB. +// EXPECT: mem8[0x6000] == 0 +// EXPECT: mem8[0x6001] == 0xAB +reg u8 PORTB @ 0x6000; +reg u8 PORTA @ 0x6001; + +fn main() -> void { + PORTA = 0xAB; // sentinel one byte above PORTB + PORTB = 0; // pre-fix: stored 2 bytes and wiped PORTA +} From 6277fe9a5a1d544f724b0cd1e71bb8929f4a5065 Mon Sep 17 00:00:00 2001 From: jackwthake Date: Thu, 16 Jul 2026 17:03:55 -0700 Subject: [PATCH 11/12] Added test suite for linker --- .github/workflows/ci.yml | 1 + CHANGELOG.md | 22 ++ Makefile | 6 +- test/linker/README.md | 107 ++++++++ test/linker/run.py | 238 ++++++++++++++++++ test/linker/tests/call_across_modules/lib.c02 | 3 + .../linker/tests/call_across_modules/main.c02 | 6 + test/linker/tests/call_across_modules/spec | 3 + test/linker/tests/extern_resolved/lib.c02 | 3 + test/linker/tests/extern_resolved/main.c02 | 6 + test/linker/tests/extern_resolved/spec | 3 + .../tests/global_across_modules/def.c02 | 1 + .../tests/global_across_modules/main.c02 | 6 + test/linker/tests/global_across_modules/spec | 3 + .../tests/global_tentative_merge/def.c02 | 1 + .../tests/global_tentative_merge/main.c02 | 6 + test/linker/tests/global_tentative_merge/spec | 3 + test/linker/tests/library_no_main/lib.c02 | 3 + test/linker/tests/library_no_main/spec | 3 + test/linker/tests/reg_dedup/lib.c02 | 6 + test/linker/tests/reg_dedup/main.c02 | 6 + test/linker/tests/reg_dedup/spec | 3 + .../tests/reject_conflicting_externs/a.c02 | 3 + .../tests/reject_conflicting_externs/b.c02 | 1 + .../tests/reject_conflicting_externs/spec | 3 + .../reject_conflicting_global_types/a.c02 | 3 + .../reject_conflicting_global_types/b.c02 | 1 + .../reject_conflicting_global_types/spec | 3 + .../tests/reject_conflicting_registers/a.c02 | 3 + .../tests/reject_conflicting_registers/b.c02 | 2 + .../tests/reject_conflicting_registers/spec | 3 + test/linker/tests/reject_cross_kind/a.c02 | 3 + test/linker/tests/reject_cross_kind/b.c02 | 1 + test/linker/tests/reject_cross_kind/spec | 3 + test/linker/tests/reject_dup_function/a.c02 | 3 + test/linker/tests/reject_dup_function/b.c02 | 1 + test/linker/tests/reject_dup_function/spec | 3 + test/linker/tests/reject_func_vs_var/a.c02 | 3 + test/linker/tests/reject_func_vs_var/b.c02 | 1 + test/linker/tests/reject_func_vs_var/spec | 3 + .../tests/reject_global_multiple_init/a.c02 | 3 + .../tests/reject_global_multiple_init/b.c02 | 1 + .../tests/reject_global_multiple_init/spec | 3 + .../tests/reject_signature_mismatch/a.c02 | 3 + .../tests/reject_signature_mismatch/b.c02 | 1 + .../tests/reject_signature_mismatch/spec | 3 + test/linker/tests/reject_struct_redecl/a.c02 | 4 + test/linker/tests/reject_struct_redecl/b.c02 | 2 + test/linker/tests/reject_struct_redecl/spec | 3 + .../tests/reject_var_type_mismatch/a.c02 | 3 + .../tests/reject_var_type_mismatch/b.c02 | 1 + .../tests/reject_var_type_mismatch/spec | 3 + test/linker/tests/reject_var_vs_reg/a.c02 | 3 + test/linker/tests/reject_var_vs_reg/b.c02 | 1 + test/linker/tests/reject_var_vs_reg/spec | 3 + test/linker/tests/struct_dedup/lib.c02 | 7 + test/linker/tests/struct_dedup/main.c02 | 9 + test/linker/tests/struct_dedup/spec | 3 + .../tests/unresolved_extern_partial/main.c02 | 6 + .../tests/unresolved_extern_partial/spec | 4 + 60 files changed, 547 insertions(+), 1 deletion(-) create mode 100644 test/linker/README.md create mode 100644 test/linker/run.py create mode 100644 test/linker/tests/call_across_modules/lib.c02 create mode 100644 test/linker/tests/call_across_modules/main.c02 create mode 100644 test/linker/tests/call_across_modules/spec create mode 100644 test/linker/tests/extern_resolved/lib.c02 create mode 100644 test/linker/tests/extern_resolved/main.c02 create mode 100644 test/linker/tests/extern_resolved/spec create mode 100644 test/linker/tests/global_across_modules/def.c02 create mode 100644 test/linker/tests/global_across_modules/main.c02 create mode 100644 test/linker/tests/global_across_modules/spec create mode 100644 test/linker/tests/global_tentative_merge/def.c02 create mode 100644 test/linker/tests/global_tentative_merge/main.c02 create mode 100644 test/linker/tests/global_tentative_merge/spec create mode 100644 test/linker/tests/library_no_main/lib.c02 create mode 100644 test/linker/tests/library_no_main/spec create mode 100644 test/linker/tests/reg_dedup/lib.c02 create mode 100644 test/linker/tests/reg_dedup/main.c02 create mode 100644 test/linker/tests/reg_dedup/spec create mode 100644 test/linker/tests/reject_conflicting_externs/a.c02 create mode 100644 test/linker/tests/reject_conflicting_externs/b.c02 create mode 100644 test/linker/tests/reject_conflicting_externs/spec create mode 100644 test/linker/tests/reject_conflicting_global_types/a.c02 create mode 100644 test/linker/tests/reject_conflicting_global_types/b.c02 create mode 100644 test/linker/tests/reject_conflicting_global_types/spec create mode 100644 test/linker/tests/reject_conflicting_registers/a.c02 create mode 100644 test/linker/tests/reject_conflicting_registers/b.c02 create mode 100644 test/linker/tests/reject_conflicting_registers/spec create mode 100644 test/linker/tests/reject_cross_kind/a.c02 create mode 100644 test/linker/tests/reject_cross_kind/b.c02 create mode 100644 test/linker/tests/reject_cross_kind/spec create mode 100644 test/linker/tests/reject_dup_function/a.c02 create mode 100644 test/linker/tests/reject_dup_function/b.c02 create mode 100644 test/linker/tests/reject_dup_function/spec create mode 100644 test/linker/tests/reject_func_vs_var/a.c02 create mode 100644 test/linker/tests/reject_func_vs_var/b.c02 create mode 100644 test/linker/tests/reject_func_vs_var/spec create mode 100644 test/linker/tests/reject_global_multiple_init/a.c02 create mode 100644 test/linker/tests/reject_global_multiple_init/b.c02 create mode 100644 test/linker/tests/reject_global_multiple_init/spec create mode 100644 test/linker/tests/reject_signature_mismatch/a.c02 create mode 100644 test/linker/tests/reject_signature_mismatch/b.c02 create mode 100644 test/linker/tests/reject_signature_mismatch/spec create mode 100644 test/linker/tests/reject_struct_redecl/a.c02 create mode 100644 test/linker/tests/reject_struct_redecl/b.c02 create mode 100644 test/linker/tests/reject_struct_redecl/spec create mode 100644 test/linker/tests/reject_var_type_mismatch/a.c02 create mode 100644 test/linker/tests/reject_var_type_mismatch/b.c02 create mode 100644 test/linker/tests/reject_var_type_mismatch/spec create mode 100644 test/linker/tests/reject_var_vs_reg/a.c02 create mode 100644 test/linker/tests/reject_var_vs_reg/b.c02 create mode 100644 test/linker/tests/reject_var_vs_reg/spec create mode 100644 test/linker/tests/struct_dedup/lib.c02 create mode 100644 test/linker/tests/struct_dedup/main.c02 create mode 100644 test/linker/tests/struct_dedup/spec create mode 100644 test/linker/tests/unresolved_extern_partial/main.c02 create mode 100644 test/linker/tests/unresolved_extern_partial/spec diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e424ebf..ba727f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,7 @@ jobs: run: | python3 scripts/test.py make emu-test + make linker-test - name: LOC Report run: python3 scripts/loc.py \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 166e16e..915f70d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ 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.6.2] 2026-07-16 + +### Added + +- **Linker test suite (`test/linker/`).** 19 cases exercising `c02-ld`'s + cross-module merge and symbol resolution, driven end-to-end through the + `bin/c02c` driver. `test/linker/run.py` discovers one directory per case + (`.c02` module sources plus a `spec` of directives) and checks three kinds of + outcome: `run` cases link into a whole program that is executed in the py65 + emulator (reusing `test/emu`'s `run_rom`) and asserted on final machine state; + `link` cases assert on the merged IR via `--dump-ir` (de-dup collapse, a + resolved extern being dropped, an unresolved extern surviving a partial link); + and `reject` cases assert that linking fails with a specific diagnostic — one + per `failwith` site in `link.ml`'s `combine`/`dedup_structs` (duplicate + function, conflicting externs, signature/type mismatch, function-vs-variable + and variable-vs-register kind clashes, conflicting registers, multiple global + initializers, conflicting global types, cross-kind collision, struct + redeclaration). Modules reference each other with the `decl` keyword (SPEC + §4.6). `make linker-test` runs it. Every rejection branch was first confirmed + reachable from valid single-file source, so the suite needs no hand-built + object fixtures. + ## [1.6.1] 2026-07-16 ### Fixed diff --git a/Makefile b/Makefile index ec5c97a..97dac2c 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all clean c02-frontend c02-ld c02-as c02-objdump emu-test +.PHONY: all clean c02-frontend c02-ld c02-as c02-objdump emu-test linker-test BIN_DIR = bin LIB_DIR = lib @@ -12,6 +12,10 @@ emu-test: all @printf '\n==> emu-test\n' @python3 test/emu/run.py --no-build +linker-test: all + @printf '\n==> linker-test\n' + @python3 test/linker/run.py + # Install the driver script alongside the binaries it invokes. $(BIN_DIR)/c02c: scripts/c02c.py | $(BIN_DIR) @printf '\n==> c02c\n' diff --git a/test/linker/README.md b/test/linker/README.md new file mode 100644 index 0000000..5e60426 --- /dev/null +++ b/test/linker/README.md @@ -0,0 +1,107 @@ +# Linker tests for `c02-ld` + +These tests exercise the **linker** — the stage that merges several IR object +files into one and resolves the symbol namespace (`c02-ld/bin/link.ml`). +Where `test/emu` pins what a single compiled program *does*, these pin what +happens when several translation units are brought together: cross-module +references resolve, duplicate definitions are rejected, identical registers and +structs are de-duplicated, and a partial link keeps unresolved symbols for a +later stage. + +Every case is driven through the toolchain driver `bin/c02c`, exactly as a user +would link multiple sources — `c02-frontend` compiles each `.c02` to an object, +then `c02-ld` merges them (`-c` stops there; without it, `c02-as` finishes the +ROM). Positive cases that produce a whole program are then *run* in the same +py65 emulator the `test/emu` suite uses, so a successful link is proven by +executing the linked ROM, not just by exit code. + +## Running + +```sh +python3 test/linker/run.py # run every case +python3 test/linker/run.py reg_dedup # only the named case(s) +python3 test/linker/run.py -v # show every assertion, not just failures +make linker-test # build the toolchain, then run +``` + +Requires `py65` (`pip install py65`), same as the emulator suite. + +## Layout + +Each subdirectory of `tests/` is one case: + +``` +tests// + *.c02 one or more module sources (separate translation units) + spec directives (below) +``` + +Modules reference each other with the `decl` forward-declaration keyword +(SPEC §4.6) — `decl fn f(...) -> T;` for a function, `decl T name;` for a +variable — which the frontend emits as an *extern* for the linker to resolve. + +## Spec directives + +`spec` is a plain `KEY: value` file (`#` comments allowed): + +| Directive | Meaning | +|---|---| +| `DESC: ` | human description (required) | +| `MODE: run \| link \| reject` | what the case checks (required) | +| `FILES: a.c02 b.c02` | link order (optional; default: sorted `*.c02`) | +| `PARTIAL: yes` | link with `-c` — partial link, no codegen, no `main` required | + +The assertions depend on `MODE`: + +**`MODE: run`** — link, generate a ROM, run it, check final machine state. Uses +the same target/operator vocabulary as `test/emu` (see that README): + +``` +EXPECT: mem8[0x6000] == 42 +``` + +**`MODE: reject`** — linking *must* fail; the case asserts the diagnostic. The +link is run with `-c` so the failure is isolated to the linker: + +``` +STDERR: Redefined function: foo # substring of c02-ld's stderr +``` + +**`MODE: link`** — linking must succeed; assert on the merged IR +(`--dump-ir`). Used where runtime is blind to the property (a de-dup collapse, +an extern being dropped or kept): + +``` +IR_COUNT: struct Pt == 1 # count of matching lines +IR_PRESENT: Externs # substring must appear +IR_ABSENT: Externs # substring must NOT appear +``` + +A `link` case with no `IR_*` directive just asserts the link succeeded (e.g. +`library_no_main`). + +## Coverage + +The cases map onto the linker's resolution rules (`combine` and `dedup_structs` +in `link.ml`): + +- **Resolution across modules** (`run`): a `decl`'d function and a `decl`'d + global are matched to their definitions and the program runs; a tentative + global merges with an initialized one. +- **De-duplication** (`link`): an identical register or struct declared in two + modules collapses to one; a resolved extern is dropped; an unresolved extern + survives a partial link; a `main`-less module links as a library. +- **Rejection** (`reject`): one case per `failwith` site — duplicate function, + conflicting externs, extern/definition signature or type mismatch, function + vs. variable and variable vs. register kind clashes, conflicting registers, + multiple global initializers, conflicting global types, cross-kind namespace + collision, and struct redeclaration. + +## Writing a new case + +1. `mkdir tests/` and add the module `.c02` files. +2. Add a `spec` with `DESC`, `MODE`, and the matching assertions. +3. For `run` cases, report results by writing to `$6000` and keep the program + terminating (no unbroken loop). For `reject`, use a `STDERR` substring that + is distinctive to the linker so a frontend error can't satisfy it. +4. `python3 test/linker/run.py ` to check it. diff --git a/test/linker/run.py b/test/linker/run.py new file mode 100644 index 0000000..2628ba0 --- /dev/null +++ b/test/linker/run.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +# +# Linker (c02-ld) test suite. +# +# Each subdirectory of tests/ is one self-contained case made of: +# *.c02 one or more module sources (separate translation units) +# spec directives describing how to link them and what to assert +# +# Everything is driven through bin/c02c (the toolchain driver) exactly as a +# user would invoke it — the frontend compiles each source to an object, then +# c02-ld merges them. Runtime assertions reuse the emulator harness's run_rom, +# so a linked program is proven by actually executing the ROM it produces. +# +# ── spec format ──────────────────────────────────────────────────────────── +# DESC: human-readable summary (required) +# MODE: run | link | reject what the case checks (required) +# FILES: a.c02 b.c02 link order (optional; default: sorted *.c02) +# PARTIAL: yes link with `-c` (no codegen, no main needed) +# +# MODE run — link + generate a ROM, run it, assert final machine state: +# EXPECT: mem8[0x6000] == 42 (same target/op vocabulary as test/emu) +# +# MODE reject — linking MUST fail; assert the diagnostic: +# STDERR: Redefined function: foo (substring of c02-ld's stderr) +# +# MODE link — linking must succeed; assert on the merged IR (--dump-ir): +# IR_COUNT: == N count of matching lines (e.g. dedup => 1) +# IR_PRESENT: substring must appear +# IR_ABSENT: substring must NOT appear +# A `link` case with no IR_* directive just asserts the link succeeded. +# +# Run all: python3 test/linker/run.py (or `make linker-test`) +# Run one / verbose: python3 test/linker/run.py -v reg_dedup + +import os +import re +import subprocess +import sys + +SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__)) +TESTS_DIR = os.path.join(SCRIPT_DIR, "tests") +ROOT = os.path.realpath(os.path.join(SCRIPT_DIR, "..", "..")) +C02C = os.path.join(ROOT, "bin", "c02c") +C02AS = os.path.join(ROOT, "bin", "c02-as") +EMU_DIR = os.path.join(ROOT, "test", "emu") + +# Reuse the emulator harness: run_rom drives py65, and _target_value/_CMP/ +# _parse_value give us the identical EXPECT vocabulary the emu suite uses. +sys.path.insert(0, EMU_DIR) +from run import run_rom, _target_value, _CMP, _parse_value, _fmt # noqa: E402 + +MAX_CYCLES = 200000 +_EXPECT_RE = re.compile(r"(.+?)\s+(==|!=|<=|>=|<|>)\s+(.+)") +_IR_COUNT_RE = re.compile(r"(.+?)\s+==\s+(\d+)$") + +# ANSI colors, but only when writing to a terminal. +_TTY = sys.stdout.isatty() +def _c(code, text): + return f"\033[{code}m{text}\033[0m" if _TTY else text + + +class Spec: + def __init__(self, path): + self.path = path + self.desc = None + self.mode = None + self.files = None # explicit link order, or None => sorted glob + self.partial = False + self.expects = [] # (raw, target, op, value) [run] + self.stderr = None # substring [reject] + self.ir_checks = [] # (kind, substring, count) [link] + self._parse() + + def _parse(self): + for line in open(self.path): + s = line.strip() + if not s or s.startswith("#"): + continue + key, _, val = s.partition(":") + key, val = key.strip(), val.strip() + if key == "DESC": + self.desc = val + elif key == "MODE": + self.mode = val + elif key == "FILES": + self.files = val.split() + elif key == "PARTIAL": + self.partial = val.lower() in ("yes", "true", "1") + elif key == "EXPECT": + m = _EXPECT_RE.match(val) + if not m: + raise ValueError(f"{self.path}: malformed EXPECT: {val!r}") + self.expects.append((val, m.group(1), m.group(2), + _parse_value(m.group(3)))) + elif key == "STDERR": + self.stderr = val + elif key == "IR_COUNT": + m = _IR_COUNT_RE.match(val) + if not m: + raise ValueError(f"{self.path}: malformed IR_COUNT: {val!r}") + self.ir_checks.append(("count", m.group(1).strip(), int(m.group(2)))) + elif key == "IR_PRESENT": + self.ir_checks.append(("present", val, None)) + elif key == "IR_ABSENT": + self.ir_checks.append(("absent", val, None)) + else: + raise ValueError(f"{self.path}: unknown directive {key!r}") + if self.mode not in ("run", "link", "reject"): + raise ValueError(f"{self.path}: MODE must be run|link|reject") + + +def _sources(case_dir, spec): + names = spec.files or sorted(f for f in os.listdir(case_dir) + if f.endswith(".c02")) + return [os.path.join(case_dir, n) for n in names] + + +def _run_c02c(args): + return subprocess.run([C02C] + args, capture_output=True, text=True) + + +def _check_ir(ir_text, checks): + """Evaluate IR_* directives against --dump-ir output. Returns list of fails.""" + fails = [] + lines = ir_text.splitlines() + for kind, needle, count in checks: + if kind == "count": + got = sum(1 for ln in lines if needle in ln) + if got != count: + fails.append(f"IR_COUNT {needle!r}: expected {count}, got {got}") + elif kind == "present": + if needle not in ir_text: + fails.append(f"IR_PRESENT {needle!r}: not found") + elif kind == "absent": + if needle in ir_text: + fails.append(f"IR_ABSENT {needle!r}: unexpectedly present") + return fails + + +def run_case(case_dir, tmp_dir, verbose): + """Return (ok: bool, messages: list[str]).""" + spec = Spec(os.path.join(case_dir, "spec")) + srcs = _sources(case_dir, spec) + name = os.path.basename(case_dir) + + if spec.mode == "reject": + out = os.path.join(tmp_dir, f"{name}.o") + r = _run_c02c(["-c"] + srcs + ["-o", out]) + if r.returncode == 0: + return False, ["expected link to fail, but it succeeded"] + if spec.stderr and spec.stderr not in r.stderr: + return False, [f"stderr missing {spec.stderr!r}", + f" actual: {r.stderr.strip()!r}"] + return True, [f"rejected: {spec.stderr}"] + + if spec.mode == "link": + out = os.path.join(tmp_dir, f"{name}.o") + if spec.partial: + r = _run_c02c(["-c"] + srcs + ["-o", out]) + if r.returncode != 0: + return False, ["link failed", f" {r.stderr.strip()}"] + ir = subprocess.run([C02AS, "--dump-ir", out], + capture_output=True, text=True).stdout + else: + r = _run_c02c(["--dump-ir"] + srcs) + if r.returncode != 0: + return False, ["link/codegen failed", f" {r.stderr.strip()}"] + ir = r.stdout + fails = _check_ir(ir, spec.ir_checks) + return (not fails), (fails or ["linked; IR checks passed"]) + + # spec.mode == "run" + rom_path = os.path.join(tmp_dir, f"{name}.bin") + r = _run_c02c(srcs + ["-o", rom_path]) + if r.returncode != 0: + return False, ["link/codegen failed", f" {r.stderr.strip()}"] + rom = open(rom_path, "rb").read() + mpu, trace, halted = run_rom(rom, set(), MAX_CYCLES) + if not halted: + return False, [f"program did not reach halt loop in {MAX_CYCLES} cycles"] + msgs, ok = [], True + for raw, target, op, expected in spec.expects: + actual = _target_value(target, rom, mpu, trace) + exp = [ord(c) for c in expected] if isinstance(expected, str) else expected + if _CMP[op](actual, exp): + msgs.append(f"{raw} [{_fmt(actual)}]") + else: + ok = False + msgs.append(f"FAIL {raw}: got {_fmt(actual)}") + return ok, msgs + + +def main(): + args = sys.argv[1:] + verbose = False + if args and args[0] in ("-v", "--verbose"): + verbose = True + args = args[1:] + only = set(args) + + for tool in (C02C, C02AS): + if not os.access(tool, os.X_OK): + print(f"missing {tool} — run `make` first", file=sys.stderr) + return 2 + + import tempfile + cases = sorted(d for d in os.listdir(TESTS_DIR) + if os.path.isdir(os.path.join(TESTS_DIR, d))) + if only: + cases = [c for c in cases if c in only] + + passed = 0 + with tempfile.TemporaryDirectory() as tmp: + for name in cases: + case_dir = os.path.join(TESTS_DIR, name) + try: + ok, msgs = run_case(case_dir, tmp, verbose) + except Exception as e: # a broken spec/case is a failure, not a crash + ok, msgs = False, [f"harness error: {e}"] + tag = _c("32", "PASS") if ok else _c("31", "FAIL") + spec_desc = "" + try: + spec_desc = Spec(os.path.join(case_dir, "spec")).desc or "" + except Exception: + pass + print(f" {tag} {name} {spec_desc}") + if (verbose or not ok) and msgs: + for m in msgs: + print(f" {m}") + passed += ok + + total = len(cases) + print(f"\n{passed}/{total} passed") + return 0 if passed == total else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/linker/tests/call_across_modules/lib.c02 b/test/linker/tests/call_across_modules/lib.c02 new file mode 100644 index 0000000..835e623 --- /dev/null +++ b/test/linker/tests/call_across_modules/lib.c02 @@ -0,0 +1,3 @@ +fn add(u8 a, u8 b) -> u8 { + return a + b; +} diff --git a/test/linker/tests/call_across_modules/main.c02 b/test/linker/tests/call_across_modules/main.c02 new file mode 100644 index 0000000..99fd2c0 --- /dev/null +++ b/test/linker/tests/call_across_modules/main.c02 @@ -0,0 +1,6 @@ +reg u8 PORTB @ 0x6000; +decl fn add(u8 a, u8 b) -> u8; + +fn main() -> void { + PORTB = add(40, 2); +} diff --git a/test/linker/tests/call_across_modules/spec b/test/linker/tests/call_across_modules/spec new file mode 100644 index 0000000..add4ae3 --- /dev/null +++ b/test/linker/tests/call_across_modules/spec @@ -0,0 +1,3 @@ +DESC: a decl'd function is resolved to its definition in another module and called +MODE: run +EXPECT: mem8[0x6000] == 42 diff --git a/test/linker/tests/extern_resolved/lib.c02 b/test/linker/tests/extern_resolved/lib.c02 new file mode 100644 index 0000000..835e623 --- /dev/null +++ b/test/linker/tests/extern_resolved/lib.c02 @@ -0,0 +1,3 @@ +fn add(u8 a, u8 b) -> u8 { + return a + b; +} diff --git a/test/linker/tests/extern_resolved/main.c02 b/test/linker/tests/extern_resolved/main.c02 new file mode 100644 index 0000000..99fd2c0 --- /dev/null +++ b/test/linker/tests/extern_resolved/main.c02 @@ -0,0 +1,6 @@ +reg u8 PORTB @ 0x6000; +decl fn add(u8 a, u8 b) -> u8; + +fn main() -> void { + PORTB = add(40, 2); +} diff --git a/test/linker/tests/extern_resolved/spec b/test/linker/tests/extern_resolved/spec new file mode 100644 index 0000000..be661bf --- /dev/null +++ b/test/linker/tests/extern_resolved/spec @@ -0,0 +1,3 @@ +DESC: a resolved extern is dropped from the merged module (no Externs section remains) +MODE: link +IR_ABSENT: Externs diff --git a/test/linker/tests/global_across_modules/def.c02 b/test/linker/tests/global_across_modules/def.c02 new file mode 100644 index 0000000..bf8b0d4 --- /dev/null +++ b/test/linker/tests/global_across_modules/def.c02 @@ -0,0 +1 @@ +u8 g = 42; diff --git a/test/linker/tests/global_across_modules/main.c02 b/test/linker/tests/global_across_modules/main.c02 new file mode 100644 index 0000000..f4766ac --- /dev/null +++ b/test/linker/tests/global_across_modules/main.c02 @@ -0,0 +1,6 @@ +decl u8 g; +reg u8 PORTB @ 0x6000; + +fn main() -> void { + PORTB = g; +} diff --git a/test/linker/tests/global_across_modules/spec b/test/linker/tests/global_across_modules/spec new file mode 100644 index 0000000..492900b --- /dev/null +++ b/test/linker/tests/global_across_modules/spec @@ -0,0 +1,3 @@ +DESC: a decl'd global is resolved to its initialized definition in another module +MODE: run +EXPECT: mem8[0x6000] == 42 diff --git a/test/linker/tests/global_tentative_merge/def.c02 b/test/linker/tests/global_tentative_merge/def.c02 new file mode 100644 index 0000000..defbdd9 --- /dev/null +++ b/test/linker/tests/global_tentative_merge/def.c02 @@ -0,0 +1 @@ +u8 g = 55; diff --git a/test/linker/tests/global_tentative_merge/main.c02 b/test/linker/tests/global_tentative_merge/main.c02 new file mode 100644 index 0000000..9c96525 --- /dev/null +++ b/test/linker/tests/global_tentative_merge/main.c02 @@ -0,0 +1,6 @@ +u8 g; +reg u8 PORTB @ 0x6000; + +fn main() -> void { + PORTB = g; +} diff --git a/test/linker/tests/global_tentative_merge/spec b/test/linker/tests/global_tentative_merge/spec new file mode 100644 index 0000000..b20880b --- /dev/null +++ b/test/linker/tests/global_tentative_merge/spec @@ -0,0 +1,3 @@ +DESC: a tentative global (no initializer) merges with an initialized one; the initializer wins +MODE: run +EXPECT: mem8[0x6000] == 55 diff --git a/test/linker/tests/library_no_main/lib.c02 b/test/linker/tests/library_no_main/lib.c02 new file mode 100644 index 0000000..59206be --- /dev/null +++ b/test/linker/tests/library_no_main/lib.c02 @@ -0,0 +1,3 @@ +fn helper() -> u8 { + return 7; +} diff --git a/test/linker/tests/library_no_main/spec b/test/linker/tests/library_no_main/spec new file mode 100644 index 0000000..523e735 --- /dev/null +++ b/test/linker/tests/library_no_main/spec @@ -0,0 +1,3 @@ +DESC: a module with no main links cleanly as a library under partial link (-c) +MODE: link +PARTIAL: yes diff --git a/test/linker/tests/reg_dedup/lib.c02 b/test/linker/tests/reg_dedup/lib.c02 new file mode 100644 index 0000000..472be39 --- /dev/null +++ b/test/linker/tests/reg_dedup/lib.c02 @@ -0,0 +1,6 @@ +reg u8 PORTB @ 0x6000; + +fn helper() -> u8 { + PORTB = 7; + return 99; +} diff --git a/test/linker/tests/reg_dedup/main.c02 b/test/linker/tests/reg_dedup/main.c02 new file mode 100644 index 0000000..30248bd --- /dev/null +++ b/test/linker/tests/reg_dedup/main.c02 @@ -0,0 +1,6 @@ +reg u8 PORTB @ 0x6000; +decl fn helper() -> u8; + +fn main() -> void { + PORTB = helper(); +} diff --git a/test/linker/tests/reg_dedup/spec b/test/linker/tests/reg_dedup/spec new file mode 100644 index 0000000..dd483ce --- /dev/null +++ b/test/linker/tests/reg_dedup/spec @@ -0,0 +1,3 @@ +DESC: an identical register declared in two modules is deduplicated to one entry +MODE: link +IR_COUNT: reg u8 PORTB @ 0x6000 == 1 diff --git a/test/linker/tests/reject_conflicting_externs/a.c02 b/test/linker/tests/reject_conflicting_externs/a.c02 new file mode 100644 index 0000000..b6454d4 --- /dev/null +++ b/test/linker/tests/reject_conflicting_externs/a.c02 @@ -0,0 +1,3 @@ +decl fn foo(u8 x) -> void; +reg u8 PORTB @ 0x6000; +fn main() -> void { PORTB = 1; } diff --git a/test/linker/tests/reject_conflicting_externs/b.c02 b/test/linker/tests/reject_conflicting_externs/b.c02 new file mode 100644 index 0000000..5267191 --- /dev/null +++ b/test/linker/tests/reject_conflicting_externs/b.c02 @@ -0,0 +1 @@ +decl fn foo() -> void; diff --git a/test/linker/tests/reject_conflicting_externs/spec b/test/linker/tests/reject_conflicting_externs/spec new file mode 100644 index 0000000..84027bf --- /dev/null +++ b/test/linker/tests/reject_conflicting_externs/spec @@ -0,0 +1,3 @@ +DESC: two decls of the same name with different signatures (neither defined) conflict +MODE: reject +STDERR: Conflicting externs diff --git a/test/linker/tests/reject_conflicting_global_types/a.c02 b/test/linker/tests/reject_conflicting_global_types/a.c02 new file mode 100644 index 0000000..0197543 --- /dev/null +++ b/test/linker/tests/reject_conflicting_global_types/a.c02 @@ -0,0 +1,3 @@ +u8 g = 5; +reg u8 PORTB @ 0x6000; +fn main() -> void { PORTB = g; } diff --git a/test/linker/tests/reject_conflicting_global_types/b.c02 b/test/linker/tests/reject_conflicting_global_types/b.c02 new file mode 100644 index 0000000..28fd3f7 --- /dev/null +++ b/test/linker/tests/reject_conflicting_global_types/b.c02 @@ -0,0 +1 @@ +u16 g = 7; diff --git a/test/linker/tests/reject_conflicting_global_types/spec b/test/linker/tests/reject_conflicting_global_types/spec new file mode 100644 index 0000000..1600783 --- /dev/null +++ b/test/linker/tests/reject_conflicting_global_types/spec @@ -0,0 +1,3 @@ +DESC: the same global name with different types in two modules conflicts +MODE: reject +STDERR: Conflicting global types diff --git a/test/linker/tests/reject_conflicting_registers/a.c02 b/test/linker/tests/reject_conflicting_registers/a.c02 new file mode 100644 index 0000000..3fbe127 --- /dev/null +++ b/test/linker/tests/reject_conflicting_registers/a.c02 @@ -0,0 +1,3 @@ +reg u8 X @ 0x6000; +decl fn s() -> u8; +fn main() -> void { X = s(); } diff --git a/test/linker/tests/reject_conflicting_registers/b.c02 b/test/linker/tests/reject_conflicting_registers/b.c02 new file mode 100644 index 0000000..e4767dd --- /dev/null +++ b/test/linker/tests/reject_conflicting_registers/b.c02 @@ -0,0 +1,2 @@ +reg u8 X @ 0x6001; +fn s() -> u8 { return 1; } diff --git a/test/linker/tests/reject_conflicting_registers/spec b/test/linker/tests/reject_conflicting_registers/spec new file mode 100644 index 0000000..8c9b033 --- /dev/null +++ b/test/linker/tests/reject_conflicting_registers/spec @@ -0,0 +1,3 @@ +DESC: the same register name mapped to different addresses in two modules conflicts +MODE: reject +STDERR: Conflicting registers: X and X diff --git a/test/linker/tests/reject_cross_kind/a.c02 b/test/linker/tests/reject_cross_kind/a.c02 new file mode 100644 index 0000000..56b1bf4 --- /dev/null +++ b/test/linker/tests/reject_cross_kind/a.c02 @@ -0,0 +1,3 @@ +fn foo() -> void { } +reg u8 PORTB @ 0x6000; +fn main() -> void { foo(); PORTB = 0; } diff --git a/test/linker/tests/reject_cross_kind/b.c02 b/test/linker/tests/reject_cross_kind/b.c02 new file mode 100644 index 0000000..5fca2e8 --- /dev/null +++ b/test/linker/tests/reject_cross_kind/b.c02 @@ -0,0 +1 @@ +reg u8 foo @ 0x6001; diff --git a/test/linker/tests/reject_cross_kind/spec b/test/linker/tests/reject_cross_kind/spec new file mode 100644 index 0000000..2a2dc00 --- /dev/null +++ b/test/linker/tests/reject_cross_kind/spec @@ -0,0 +1,3 @@ +DESC: a name defined as a function in one module and a register in another collides +MODE: reject +STDERR: Redefined symbol diff --git a/test/linker/tests/reject_dup_function/a.c02 b/test/linker/tests/reject_dup_function/a.c02 new file mode 100644 index 0000000..56b1bf4 --- /dev/null +++ b/test/linker/tests/reject_dup_function/a.c02 @@ -0,0 +1,3 @@ +fn foo() -> void { } +reg u8 PORTB @ 0x6000; +fn main() -> void { foo(); PORTB = 0; } diff --git a/test/linker/tests/reject_dup_function/b.c02 b/test/linker/tests/reject_dup_function/b.c02 new file mode 100644 index 0000000..00c7e83 --- /dev/null +++ b/test/linker/tests/reject_dup_function/b.c02 @@ -0,0 +1 @@ +fn foo() -> void { } diff --git a/test/linker/tests/reject_dup_function/spec b/test/linker/tests/reject_dup_function/spec new file mode 100644 index 0000000..b8b1327 --- /dev/null +++ b/test/linker/tests/reject_dup_function/spec @@ -0,0 +1,3 @@ +DESC: two modules that both define the same function are rejected +MODE: reject +STDERR: Redefined function: foo diff --git a/test/linker/tests/reject_func_vs_var/a.c02 b/test/linker/tests/reject_func_vs_var/a.c02 new file mode 100644 index 0000000..502ecd2 --- /dev/null +++ b/test/linker/tests/reject_func_vs_var/a.c02 @@ -0,0 +1,3 @@ +decl fn foo() -> void; +reg u8 PORTB @ 0x6000; +fn main() -> void { PORTB = 0; } diff --git a/test/linker/tests/reject_func_vs_var/b.c02 b/test/linker/tests/reject_func_vs_var/b.c02 new file mode 100644 index 0000000..9b160e0 --- /dev/null +++ b/test/linker/tests/reject_func_vs_var/b.c02 @@ -0,0 +1 @@ +u8 foo = 0; diff --git a/test/linker/tests/reject_func_vs_var/spec b/test/linker/tests/reject_func_vs_var/spec new file mode 100644 index 0000000..e746beb --- /dev/null +++ b/test/linker/tests/reject_func_vs_var/spec @@ -0,0 +1,3 @@ +DESC: a function decl whose name is defined as a variable is rejected +MODE: reject +STDERR: declared as function, defined as variable diff --git a/test/linker/tests/reject_global_multiple_init/a.c02 b/test/linker/tests/reject_global_multiple_init/a.c02 new file mode 100644 index 0000000..0197543 --- /dev/null +++ b/test/linker/tests/reject_global_multiple_init/a.c02 @@ -0,0 +1,3 @@ +u8 g = 5; +reg u8 PORTB @ 0x6000; +fn main() -> void { PORTB = g; } diff --git a/test/linker/tests/reject_global_multiple_init/b.c02 b/test/linker/tests/reject_global_multiple_init/b.c02 new file mode 100644 index 0000000..baabb2b --- /dev/null +++ b/test/linker/tests/reject_global_multiple_init/b.c02 @@ -0,0 +1 @@ +u8 g = 7; diff --git a/test/linker/tests/reject_global_multiple_init/spec b/test/linker/tests/reject_global_multiple_init/spec new file mode 100644 index 0000000..1adbb03 --- /dev/null +++ b/test/linker/tests/reject_global_multiple_init/spec @@ -0,0 +1,3 @@ +DESC: the same global initialized in two modules (two initializers) is rejected +MODE: reject +STDERR: global g has multiple initializers diff --git a/test/linker/tests/reject_signature_mismatch/a.c02 b/test/linker/tests/reject_signature_mismatch/a.c02 new file mode 100644 index 0000000..45474db --- /dev/null +++ b/test/linker/tests/reject_signature_mismatch/a.c02 @@ -0,0 +1,3 @@ +decl fn foo(u8 a) -> void; +reg u8 PORTB @ 0x6000; +fn main() -> void { foo(1); PORTB = 0; } diff --git a/test/linker/tests/reject_signature_mismatch/b.c02 b/test/linker/tests/reject_signature_mismatch/b.c02 new file mode 100644 index 0000000..e584577 --- /dev/null +++ b/test/linker/tests/reject_signature_mismatch/b.c02 @@ -0,0 +1 @@ +fn foo(u16 a) -> void { } diff --git a/test/linker/tests/reject_signature_mismatch/spec b/test/linker/tests/reject_signature_mismatch/spec new file mode 100644 index 0000000..b7d512f --- /dev/null +++ b/test/linker/tests/reject_signature_mismatch/spec @@ -0,0 +1,3 @@ +DESC: an extern function resolved to a definition with a different parameter type is rejected +MODE: reject +STDERR: signature mismatch for foo diff --git a/test/linker/tests/reject_struct_redecl/a.c02 b/test/linker/tests/reject_struct_redecl/a.c02 new file mode 100644 index 0000000..dbddce7 --- /dev/null +++ b/test/linker/tests/reject_struct_redecl/a.c02 @@ -0,0 +1,4 @@ +struct Pt { u8 x; } +reg u8 PORTB @ 0x6000; +decl fn side() -> u8; +fn main() -> void { Pt p; p.x = 5; PORTB = side(); } diff --git a/test/linker/tests/reject_struct_redecl/b.c02 b/test/linker/tests/reject_struct_redecl/b.c02 new file mode 100644 index 0000000..d0f0b14 --- /dev/null +++ b/test/linker/tests/reject_struct_redecl/b.c02 @@ -0,0 +1,2 @@ +struct Pt { u8 x; u8 y; } +fn side() -> u8 { Pt q; q.y = 9; return q.y; } diff --git a/test/linker/tests/reject_struct_redecl/spec b/test/linker/tests/reject_struct_redecl/spec new file mode 100644 index 0000000..9f6eda1 --- /dev/null +++ b/test/linker/tests/reject_struct_redecl/spec @@ -0,0 +1,3 @@ +DESC: the same struct name with a different layout in two modules is rejected +MODE: reject +STDERR: Struct: Pt redeclaration diff --git a/test/linker/tests/reject_var_type_mismatch/a.c02 b/test/linker/tests/reject_var_type_mismatch/a.c02 new file mode 100644 index 0000000..8706a04 --- /dev/null +++ b/test/linker/tests/reject_var_type_mismatch/a.c02 @@ -0,0 +1,3 @@ +decl u8 g; +reg u8 PORTB @ 0x6000; +fn main() -> void { PORTB = g; } diff --git a/test/linker/tests/reject_var_type_mismatch/b.c02 b/test/linker/tests/reject_var_type_mismatch/b.c02 new file mode 100644 index 0000000..28fd3f7 --- /dev/null +++ b/test/linker/tests/reject_var_type_mismatch/b.c02 @@ -0,0 +1 @@ +u16 g = 7; diff --git a/test/linker/tests/reject_var_type_mismatch/spec b/test/linker/tests/reject_var_type_mismatch/spec new file mode 100644 index 0000000..d1374ad --- /dev/null +++ b/test/linker/tests/reject_var_type_mismatch/spec @@ -0,0 +1,3 @@ +DESC: a variable decl resolved to a definition of a different type is rejected +MODE: reject +STDERR: type mismatch for g diff --git a/test/linker/tests/reject_var_vs_reg/a.c02 b/test/linker/tests/reject_var_vs_reg/a.c02 new file mode 100644 index 0000000..c4cb49b --- /dev/null +++ b/test/linker/tests/reject_var_vs_reg/a.c02 @@ -0,0 +1,3 @@ +decl u8 x; +reg u8 PORTB @ 0x6000; +fn main() -> void { PORTB = x; } diff --git a/test/linker/tests/reject_var_vs_reg/b.c02 b/test/linker/tests/reject_var_vs_reg/b.c02 new file mode 100644 index 0000000..868447b --- /dev/null +++ b/test/linker/tests/reject_var_vs_reg/b.c02 @@ -0,0 +1 @@ +reg u8 x @ 0x6001; diff --git a/test/linker/tests/reject_var_vs_reg/spec b/test/linker/tests/reject_var_vs_reg/spec new file mode 100644 index 0000000..1383e29 --- /dev/null +++ b/test/linker/tests/reject_var_vs_reg/spec @@ -0,0 +1,3 @@ +DESC: a variable decl whose name is defined as a register is rejected +MODE: reject +STDERR: declared as variable but defined as a register diff --git a/test/linker/tests/struct_dedup/lib.c02 b/test/linker/tests/struct_dedup/lib.c02 new file mode 100644 index 0000000..ae9ff09 --- /dev/null +++ b/test/linker/tests/struct_dedup/lib.c02 @@ -0,0 +1,7 @@ +struct Pt { u8 x; u8 y; } + +fn side() -> u8 { + Pt q; + q.y = 9; + return q.y; +} diff --git a/test/linker/tests/struct_dedup/main.c02 b/test/linker/tests/struct_dedup/main.c02 new file mode 100644 index 0000000..1a00793 --- /dev/null +++ b/test/linker/tests/struct_dedup/main.c02 @@ -0,0 +1,9 @@ +struct Pt { u8 x; u8 y; } +reg u8 PORTB @ 0x6000; +decl fn side() -> u8; + +fn main() -> void { + Pt p; + p.x = 5; + PORTB = p.x + side(); +} diff --git a/test/linker/tests/struct_dedup/spec b/test/linker/tests/struct_dedup/spec new file mode 100644 index 0000000..fb24def --- /dev/null +++ b/test/linker/tests/struct_dedup/spec @@ -0,0 +1,3 @@ +DESC: an identical struct declared in two modules is deduplicated to one layout +MODE: link +IR_COUNT: struct Pt == 1 diff --git a/test/linker/tests/unresolved_extern_partial/main.c02 b/test/linker/tests/unresolved_extern_partial/main.c02 new file mode 100644 index 0000000..6073fc8 --- /dev/null +++ b/test/linker/tests/unresolved_extern_partial/main.c02 @@ -0,0 +1,6 @@ +reg u8 PORTB @ 0x6000; +decl fn ext() -> u8; + +fn main() -> void { + PORTB = ext(); +} diff --git a/test/linker/tests/unresolved_extern_partial/spec b/test/linker/tests/unresolved_extern_partial/spec new file mode 100644 index 0000000..455f71a --- /dev/null +++ b/test/linker/tests/unresolved_extern_partial/spec @@ -0,0 +1,4 @@ +DESC: an unresolved extern survives a partial link (-c) for a later stage to satisfy +MODE: link +PARTIAL: yes +IR_PRESENT: Externs From c9d12850621fb5d540e7cc456104df05a8bb8e7c Mon Sep 17 00:00:00 2001 From: jackwthake Date: Thu, 16 Jul 2026 17:16:16 -0700 Subject: [PATCH 12/12] Updated docs --- CONTRIBUTING.md | 23 +++++++++++++++++------ README.md | 34 +++++++++++++++++++++------------- 2 files changed, 38 insertions(+), 19 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 39dba7b..7063837 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,8 @@ the workflow below is intentionally lightweight, but consistent. ## Project Status C02 compiles through a split toolchain: a Haskell frontend (`c02-frontend`: -tokenizer, parser, analyzer, IR generation) and a C code generator (`c02-as`) +tokenizer, parser, analyzer, IR generation), an OCaml linker (`c02-ld`: merges +IR objects and resolves cross-module symbols), and a C code generator (`c02-as`) that emits working 65C02 ROMs, driven by `c02c` — see the [README's Current Status & Limitations](./README.md#current-status--limitations) for what works today and what's still unimplemented. The toolchain is under active @@ -45,8 +46,9 @@ terms. 3. **Commit as you go.** Work-in-progress commits on your branch don't need to be clean - history gets squashed on merge (see below). -4. **Add tests.** There are two kinds, and a change should come with whichever - fits — frontend changes get golden tests, codegen changes get emulator tests. +4. **Add tests.** There are three kinds, and a change should come with whichever + fits — frontend changes get golden tests, codegen changes get emulator tests, + and linker changes get linker tests. **Golden-file tests** (frontend), run by `scripts/test.py`, grouped by stage: @@ -84,6 +86,14 @@ terms. --ir-on-fail` shows whether a failure is in frontend lowering or codegen. See [`test/emu/README.md`](./test/emu/README.md) for the directive format. + **Linker tests** (`c02-ld`), under `test/linker/`, run by `make linker-test` + (or `test/linker/run.py`). Each case is a directory of `.c02` module sources + plus a `spec`; the suite links them through `c02c` and asserts the outcome — + a linked program runs in the emulator (`run`), the merged IR is checked + (`link`), or the link must fail with a given diagnostic (`reject`). Add one + for any change to merge or symbol-resolution behavior. See + [`test/linker/README.md`](./test/linker/README.md) for the spec format. + 5. **Update the changelog.** Add a line under `[Unreleased]` in [CHANGELOG.md](./CHANGELOG.md) describing the change. This keeps the changelog accurate without a separate bookkeeping pass at release time. @@ -101,9 +111,10 @@ terms. - Match the existing style in the file you're editing over any external convention — each part of the toolchain has its own consistent patterns. - The frontend (`c02-frontend`) is Haskell, under `src/C02/` (`Parser/`, - `Analyzer/`, `Lowering/`); the code generator (`c02-as`) is C and the - disassembler (`c02-objdump`) is Rust. Follow the surrounding module's idioms - rather than importing conventions across the language boundary. + `Analyzer/`, `Lowering/`); the linker (`c02-ld`) is OCaml; the code generator + (`c02-as`) is C and the disassembler (`c02-objdump`) is Rust. Follow the + surrounding module's idioms rather than importing conventions across the + language boundary. - In `c02-as`, codegen helpers that read/write operands must go through the global-aware helpers (`emit_load_byte`, `emit_store_byte`, etc.) — never raw `zp_map_lookup`. See the `OP_EMITTER_*` and `GLOBAL_AWARE_ALU_HELPER` diff --git a/README.md b/README.md index 53f3859..5fe9ff4 100644 --- a/README.md +++ b/README.md @@ -27,28 +27,27 @@ C02 compiles through a small pipeline of standalone tools, wired together by the `c02c` driver: -1. **`c02-frontend`** (Haskell) — tokenizer, recursive-descent parser, lexically-scoped semantic analyzer, and IR generator. Lowers `.c02` source into a self-contained three-address-code (TAC) intermediate representation — struct layouts with computed field offsets, globals/registers with hardware addresses baked in, and one flat instruction stream per function — then serializes it to a `.o` object. Incremental compilation (`-c`) stops here. -2. **`c02-as`** (C) — the code generator. Consumes a `.o` IR object and emits a 32 KB 65C02 ROM: bootstrap runtime, interrupt vectors, and flat zero-page allocation of locals, temporaries, and parameters (no slow stack-machine execution). Globals live in RAM (`$0200+`) and are initialized before `JSR main`; string literals go in a ROM data section with backpatch fixups. Every emit path is bounds-checked against the 32 KB limit, so overflow is a clear diagnostic rather than silent corruption. -3. **`c02c`** — the driver that runs the frontend, then `c02-as`, managing intermediate `.o` files. This is the command you normally invoke. -4. **`c02-objdump`** (Rust) — disassembler. Decodes a compiled `.bin` back into annotated 65C02 assembly, resolving jump targets to named labels, with section-aware output (`.text` / `.data`), hex dumps, and ROM usage summaries. See [c02-objdump](c02-objdump/). +1. **`c02-frontend`** (Haskell) — tokenizer, recursive-descent parser, lexically-scoped semantic analyzer, and IR generator. Lowers `.c02` source into a self-contained three-address-code (TAC) intermediate representation — struct layouts with computed field offsets, globals/registers with hardware addresses baked in, and one flat instruction stream per function — then serializes it to a `.o` object, one per translation unit. +2. **`c02-ld`** (OCaml) — the linker. Merges one or more `.o` objects into a single relocatable object and resolves the symbol namespace: it binds cross-module references (a `decl` forward declaration in one file to its definition in another), de-duplicates identical `reg` and `struct` declarations, applies the tentative-definition rule to globals, and rejects genuine conflicts (duplicate definitions, signature/type mismatches, namespace collisions). It never requires a `main`, so a merged object may be a library. Incremental compilation (`-c`) stops here, emitting the merged object. +3. **`c02-as`** (C) — the code generator. Consumes the linked `.o` IR object and emits a 32 KB 65C02 ROM: bootstrap runtime, interrupt vectors, and flat zero-page allocation of locals, temporaries, and parameters (no slow stack-machine execution). Globals live in RAM (`$0200+`) and are initialized before `JSR main`; string literals go in a ROM data section with backpatch fixups. Every emit path is bounds-checked against the 32 KB limit, so overflow is a clear diagnostic rather than silent corruption. +4. **`c02c`** — the driver that runs the frontend on each source, then `c02-ld`, then `c02-as`, managing intermediate `.o` files. This is the command you normally invoke. +5. **`c02-objdump`** (Rust) — disassembler. Decodes a compiled `.bin` back into annotated 65C02 assembly, resolving jump targets to named labels, with section-aware output (`.text` / `.data`), hex dumps, and ROM usage summaries. See [c02-objdump](c02-objdump/). The function-call ABI passes up to 8 parameters through a fixed 2-byte-per-param zero-page zone (`$EF–$FE`); a callee-saves convention (PHA/PLA over the ZP slots) preserves caller locals across calls and enables bounded recursion. Compiler implicit globals (`__heap_start`, `__memory_top`) are injected automatically. ## Current Status & Limitations -This repository is a rewrite: the frontend (`c02-frontend`) is Haskell, replacing the original C compiler — which still ships as `bin/cc02` for reference. The toolchain builds non-trivial programs end-to-end — `.c02` source compiles to valid 32 KB 65C02 ROMs — verified by the emulator test suite under [`test/emu`](test/emu) (py65). See the [CHANGELOG](CHANGELOG.md) for the current version. - -The single-file language is broadly in place: data movement and hardware-register I/O; `if`/`else`, `while`, `for`, `break`/`continue`; arithmetic, bitwise, shift, and comparison operators across `u8`/`i8`/`u16`/`i16` (multiply/divide via `__mul8`/`__div8`/`__mul16`/`__div16` software routines); pointers (`&`, `*`, `ptr ± int`), type casts, structs and field access, globals, string literals, and function calls with recursion. [`docs/SPEC.md`](docs/SPEC.md) is the **normative** definition of the language and its exact semantics; [`docs/DEVIATIONS_hs_impl.md`](docs/DEVIATIONS_hs_impl.md) records where this implementation currently diverges from it (it is generally *stricter* than the original). +The language is broadly in place: data movement and hardware-register I/O; `if`/`else`, `while`, `for`, `break`/`continue`; arithmetic, bitwise, shift, and comparison operators across `u8`/`i8`/`u16`/`i16` (multiply/divide via `__mul8`/`__div8`/`__mul16`/`__div16` software routines); pointers (`&`, `*`, `ptr ± int`), type casts, structs and field access, globals, string literals, and function calls with recursion. [`docs/SPEC.md`](docs/SPEC.md) is the **normative** definition of the language and its exact semantics; [`docs/DEVIATIONS_hs_impl.md`](docs/DEVIATIONS_hs_impl.md) records where this implementation currently diverges from it (it is generally *stricter* than the original). Not yet implemented: **arrays** (use pointer arithmetic, `*(ptr + i)`, in the meantime) and **compound bitwise/shift assignment** (`&=`, `|=`, `^=`, `<<=`, `>>=` — the arithmetic forms `+= -= *= /= %=` work). -If you're exploring the codebase, the two stages live in [`c02-frontend/`](c02-frontend/) (Haskell) and [`c02-as/`](c02-as/) (C, the code generator). Issues and PRs are welcome. +If you're exploring the codebase, the stages live in [`c02-frontend/`](c02-frontend/) (Haskell), [`c02-ld/`](c02-ld/) (OCaml, the linker), and [`c02-as/`](c02-as/) (C, the code generator). Issues and PRs are welcome. ## Toolchain Usage ### Compiling the Toolchain -The three stages need a C compiler (`c02-as`), GHC + Cabal (the Haskell `c02-frontend`), and Rust (`c02-objdump`); Python 3 drives `c02c` and the tests. +The four stages need a C compiler (`c02-as`), GHC + Cabal (the Haskell `c02-frontend`), OCaml + dune (the `c02-ld` linker), and Rust (`c02-objdump`); Python 3 drives `c02c` and the tests. ```shell sudo apt install build-essential curl python3 python3-pip -y @@ -56,6 +55,11 @@ sudo apt install build-essential curl python3 python3-pip -y # GHC + Cabal for the Haskell frontend (ghcup is the usual installer) curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | sh +# OCaml + dune for the linker (c02-ld); opam ships the compiler +sudo apt install opam -y +opam init -y && eval $(opam env) +opam install dune -y + # Rust, for c02-objdump curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh @@ -81,17 +85,21 @@ c02c [OPTIONS] ... - ``: Input file(s) — `.c02` source and/or `.o` IR objects - `-h, --help`: Show help message - `-o, --output`: Output path (default `a.out`) -- `-c`: Incremental compile — stop after the frontend and emit a `.o` IR object +- `-c`: Compile and link to a relocatable `.o` object (a partial link — no `main` required) instead of a full ROM - `--parse-only`: Check syntax only, produce no output - `--dump-ast`: Print the AST after parsing (no output file) - `--dump-ir`: Print the IR (TAC) the code generator consumes (no output file) - `--strip-debug`: Omit the `C02S` symbol table from the final ROM -**Incremental compilation:** +**Separate compilation and linking:** + +Pass several sources and/or `.o` objects and `c02c` compiles each source, then links everything into one output. A `.c02` references a symbol defined in another file with a `decl` forward declaration (`decl fn f(u8 x) -> void;`, `decl u8 counter;`). ```bash -c02c -c hello_world.c02 -o hello_world.o # compile to an IR object -c02c --dump-ir hello_world.c02 # inspect the IR before codegen +c02c -c util.c02 -o util.o # compile + partial-link to a relocatable object +c02c main.c02 util.o -o program.bin # link that object into a full ROM +c02c main.c02 util.c02 -o program.bin # or compile and link several sources at once +c02c --dump-ir main.c02 util.c02 # inspect the linked IR before codegen ``` ---