From 3527812c41b11306f6166d901b011289ae067dc1 Mon Sep 17 00:00:00 2001 From: Anachronaut Date: Sat, 5 Sep 2026 10:21:06 -0400 Subject: [PATCH] A symbol table says which memory, and where the name was written The dump was an address and a name. Both of the questions it gets asked were only half answered. "What is at this address" was ambiguous, because Program and Data are separate memories and an address alone does not say which one. That is easy to miss in a loadable program, where the segments are usually based far apart - and immediate in a boot image, where both start at zero: replCalculator has a Program 0003 and a Data 0003 and the old file printed both as "0003 ". "Where is this defined" was not answered at all, and it is the one that matters more as a program grows. A name defined once and called in forty places is hard to find by searching. Lander's table names five files besides its own; CosmOS and its libraries define over a thousand names across a dozen. So: memory, address, name, file, line, separated by tabs, sorted by memory and then address with Program first. Tabs because that makes it a table cut, awk and sort already read, and no heading line because nothing should have to know to skip one. Everything needed was already being passed to addLabel and thrown away; the file name points at the copy the include list owns, which outlives the label table. The manual describes the five fields, and docs.sh now settles that description against a real dump - the shape, not the values, so that an example cannot go stale and turn editing a program into editing a manual. Verified with break.sh three ways: a reordered field, a dropped field, and a field renamed in the manual. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW --- Source/Assembler/secondPass.c | 54 +++++++++++++++++++++------- Source/Assembler/secondPass.h | 9 +++++ SplitBit Assembler Manual.md | 24 +++++++++++-- Tests/docs.sh | 66 +++++++++++++++++++++++++++++++++++ 4 files changed, 139 insertions(+), 14 deletions(-) diff --git a/Source/Assembler/secondPass.c b/Source/Assembler/secondPass.c index cf14417..d20ea02 100644 --- a/Source/Assembler/secondPass.c +++ b/Source/Assembler/secondPass.c @@ -23,19 +23,38 @@ int labelCount = 0; // ---- Where everything ended up ---- // -// Every label and the address it was given, in address order. The assembler knows this and -// nothing else does: a program on the disk is bytes, and the machine's own monitor can -// disassemble it but has no idea what any of it is called. +// Every label with the memory and address it was given and the place it was written, one +// per line, fields separated by tabs. The assembler knows all of this and nothing else +// does: a program on the disk is bytes, and the machine's own monitor can disassemble it +// but has no idea what any of it is called or where it came from. // -// WHAT IT IS FOR is telling where a program spends its time. Counting which addresses get -// called says a great deal and names nothing, so the answer arrives as a list of numbers -// and somebody has to work out by hand which routine each one is inside. With this, a -// tally of call targets becomes a list of routine names. +// WHAT IT IS FOR is two questions. The first is where a program spends its time - counting +// which addresses get called says a great deal and names nothing, so the answer arrives as +// a list of numbers and somebody has to work out by hand which routine each one is inside. +// The second is where a name lives, which matters more the bigger the program gets: CosmOS +// and its libraries define over a thousand names across a dozen files, and finding the one +// definition of a routine means grepping for it and reading past every place it is called. +// The file and line answer that outright. // -// Sorted by address rather than by name, because the question asked of it is always "what -// is at this address", and a label table is small enough that sorting it is free. -static int byAddress(const void *left, const void *right) { +// TABS, so that the file is a table every ordinary tool already understands - cut -f3, awk +// -F'\t', sort -k1,2 - and so that a name never has to be quoted. No header line, for the +// same reason: nothing that reads it should have to know to skip one. +// +// THE MEMORY IS THE FIRST FIELD BECAUSE THIS IS A HARVARD MACHINE. Program and Data are +// separate address spaces, so 0x3000 is two different places and an address alone does not +// say which. That is invisible in a loadable program, where the two segments are based +// somewhere apart, and immediate in a boot image, where both start at zero and every +// address in the file appears twice. +// +// Sorted by memory and then address rather than by name, because the question asked of it +// is always "what is at this address"; sorting by address alone would interleave two +// address spaces and make the first column flicker between them. A label table is small +// enough that sorting it is free. +static int byPlace(const void *left, const void *right) { const Label *a = left, *b = right; + if (a->type != b->type) { + return a->type < b->type ? -1 : 1; // PROGRAM is 1 and DATA is 2, so code first. + } if (a->address != b->address) { return a->address < b->address ? -1 : 1; } @@ -55,9 +74,18 @@ void writeSymbolFile(const char *path) { exit(1); } memcpy(sorted, labelArray, (size_t)labelCount * sizeof(Label)); - qsort(sorted, (size_t)labelCount, sizeof(Label), byAddress); + qsort(sorted, (size_t)labelCount, sizeof(Label), byPlace); for (int i = 0; i < labelCount; i++) { - fprintf(file, "%04X %s\n", sorted[i].address, sorted[i].label); + // A label is put in one of the two segments by populateLabelTable and there is no + // third, but the name is printed from the type rather than assumed, so a label that + // somehow arrived as neither says so instead of being filed under Data. + const char *memory = sorted[i].type == PROGRAM ? "Program" + : sorted[i].type == DATA ? "Data" + : "?"; + fprintf(file, "%s\t%04X\t%s\t%s\t%d\n", + memory, sorted[i].address, sorted[i].label, + sorted[i].fileName ? sorted[i].fileName : "?", + sorted[i].lineNumber); } free(sorted); fclose(file); @@ -124,6 +152,8 @@ void addLabel(char *labelName, uint16_t address, int type, const char *fileName, labelArray[labelCount].label = cleanedLabel; labelArray[labelCount].address = address; labelArray[labelCount].type = type; + labelArray[labelCount].fileName = fileName; + labelArray[labelCount].lineNumber = lineNumber; if (debugSecondPass) printf("Added label %s with address %04X\n", labelName, labelArray[labelCount].address); labelCount++; } else { diff --git a/Source/Assembler/secondPass.h b/Source/Assembler/secondPass.h index f2a2d5c..abb1ef7 100644 --- a/Source/Assembler/secondPass.h +++ b/Source/Assembler/secondPass.h @@ -27,6 +27,15 @@ typedef struct { char* label; uint16_t address; int type; + // Where the name was written. Only the symbol file reads these, and it is the source + // of the whole feature: an address tells you a routine exists and a file and line tell + // you where to go and read it. + // + // NEITHER IS OWNED HERE. fileName points at the copy the include list keeps, the same + // storage every intermediateElement points at, and the include list outlives the label + // table - assemblerCleanup frees the labels first. So freeLabelList must not free it. + const char* fileName; + int lineNumber; } Label; // Every label and the address it was given, in address order, so that a tally of diff --git a/SplitBit Assembler Manual.md b/SplitBit Assembler Manual.md index e87691f..ec23ea7 100644 --- a/SplitBit Assembler Manual.md +++ b/SplitBit Assembler Manual.md @@ -420,12 +420,32 @@ Assembler [options] | -o, --output \ | Write the output to this path. Without it, the output is named after the source file, in the directory the assembler was run from, taking .bin if it is a boot image and .sbx if it is a loadable program. | | -I, --include \ | Look in this directory for included files. May be given more than once, and the directories are searched in the order given. | | -M, --depend \ | Write out which source files went into the output, as a make rule. | -| -S, --symbols \ | Write every label and the address it was given, in address order, to this path. | +| -S, --symbols \ | Write a symbol table to this path: every label, with the memory and address it was given and the file and line it was written on. | | -h, --help | Print the options and stop. | Every option above that takes a \ names a file the assembler WRITES, and the source is the bare argument at the end. So `Assembler -S program.asm` does not dump the symbols of program.asm. It asks for the symbol file to be called program.asm, and then finds it has no source left to assemble. --S is for looking at what the assembler decided. Every label in the program, in address order, with the address it ended up at: which segment a name landed in, how far apart two routines really are, and whether the label you are looking for was assembled at all. +-S is for looking at what the assembler decided, and for finding your way around a program that has grown past holding in your head. It writes one line for every label, with five fields separated by tabs: + +| Field | Meaning | +| -- | -- | +| Memory | Program or Data. | +| Address | Four hexadecimal digits: the address the label was given. | +| Name | The label itself, without its colon. | +| File | The source file the label was written in, named the way the assembler was given it. | +| Line | Which line of that file, counting from one. | + +``` +Program 5000 start game.asm 31 +Program 670D int16add Libraries/math.asm 5 +Data 3000 Score game.asm 118 +``` + +Tabs rather than aligned columns, so that the file is a table every ordinary tool already reads without being taught anything, and no heading line, so that nothing has to know to skip one. The file field is what makes it worth having on a big program: a name defined once and called in forty places is hard to find by searching, and this says where it was written. + +The memory comes first because Program and Data are separate memories on this machine, so an address on its own does not say where something is. In a loadable program the two segments are usually based far enough apart that the difference is easy to overlook. In a boot image both start at 0x0000, and every address in the table appears twice. + +The table is sorted by memory and then by address, with Program first. The assembler stops at the first error, says which file and line it was in, and exits without writing anything. diff --git a/Tests/docs.sh b/Tests/docs.sh index f7d41e6..d50fad2 100755 --- a/Tests/docs.sh +++ b/Tests/docs.sh @@ -820,6 +820,72 @@ for line in readme.split("\n"): problems.append("the CosmOS README says %s is %s bytes and it is %s" % (named[0], said.group(1), real.group(1))) +# ---- The symbol file has the fields the manual says it has ---- +# +# The manual describes -S field by field, and a program that reads a symbol file is written +# against that description rather than against the assembler. So a field reordered, renamed +# or added would leave every such program cutting the wrong column, and the manual would go +# on describing a format nobody writes. +# +# The values are not checked, because an example naming real addresses in real files goes +# stale the moment either changes and would turn every edit to a program into an edit to the +# manual. The SHAPE is the contract: five fields, tab separated, in the documented order. +import subprocess +import tempfile + +if "## Running the Assembler:" not in am: + pass # Already reported above. +else: + section = am.split("## Running the Assembler:")[1].split("\n## ")[0] + said = re.findall(r'^\| (Memory|Address|Name|File|Line) \|', section, re.M) + if said != ["Memory", "Address", "Name", "File", "Line"]: + problems.append("the Assembler Manual does not describe the symbol file's five" + " fields in order; it lists %s" % (said or "none")) + else: + with tempfile.TemporaryDirectory() as scratch: + dump = os.path.join(scratch, "symbols") + # A program with labels in BOTH memories, so that both names the first field can + # take are seen. replCalculator is a boot image, which is also the case where the + # memory field is load bearing: both segments start at zero. + built = subprocess.run(["./Assembler", "-I", "Programs/Libraries", + "-I", "Programs/CosmOS/Source", "-S", dump, + "-o", os.path.join(scratch, "out.bin"), + "Programs/Examples/replCalculator.asm"], + capture_output=True, text=True) + if built.returncode != 0: + problems.append("could not assemble a program to check the symbol file") + else: + memories = set() + for number, line in enumerate(open(dump).read().splitlines(), 1): + fields = line.split("\t") + if len(fields) != 5: + problems.append("line %d of a symbol file has %d fields and the" + " Assembler Manual describes five" + % (number, len(fields))) + break + memory, address, name, source, where = fields + memories.add(memory) + if memory not in ("Program", "Data"): + problems.append("a symbol file says a label is in %r, and the" + " Assembler Manual allows Program and Data" % memory) + break + if not re.fullmatch(r'[0-9A-F]{4}', address): + problems.append("a symbol file gives %r as an address, and the" + " Assembler Manual says four hexadecimal digits" + % address) + break + if not name or not source or not where.isdigit(): + problems.append("a symbol file line is missing a name, a file or a" + " line number: %r" % line) + break + else: + # Only meaningful if every line was well formed, which is what the else + # on the loop says. + if memories != {"Program", "Data"}: + problems.append("a program with labels in both memories produced a" + " symbol file naming only %s" + % ", ".join(sorted(memories))) + if problems: print("The manuals and the code disagree:") for p in problems: