smalltalk-from-scratch — Part 3
Building a Universe from a Blueprint
The cold-start loader reads a JSON description of every Smalltalk class and builds a complete, correctly-linked heap from scratch. The garbage collector tells you when you got it wrong.
Part 3 of 7. Part 2 covered the compiler and the JSON intermediate representation. This part covers the cold-start loader: building a complete Smalltalk-80 heap from that JSON, and using garbage collection as a correctness test.
I had a JSON file. The JSON described, in complete detail, every class in the Smalltalk-80 system: names, variables, methods, bytecodes, metaclass relationships. The February 2026 build of that file contains 530 class records (including metaclasses), 5,310 compiled methods, 274 init expressions, and 548 global associations — all derived from a 50,000-line source file. What I needed to do now was use that description to build an actual running Smalltalk-80 heap — to allocate every object that would be present in a live system and link them all together correctly.
This process is called cold start. It’s the bootstrap phase that only has to happen once. Once you have a correct heap, you serialize it to a binary image file and load that image on every subsequent startup. Cold start is slow, careful work. Image loading is fast.
But to get to image loading, you first have to get cold start right. And getting cold start right meant understanding exactly how Smalltalk-80 objects are laid out in memory.
The Object Table
Smalltalk-80’s object memory model is built around an object table — a flat array where each entry describes one object. An object is not referenced by its memory address. It is referenced by its index into this table: a small integer called an OOP, for Object Oriented Pointer.
An OOP is tagged. If the low bit is 1, the value is a SmallInteger, stored directly in the OOP itself (the integer value is in the upper bits). If the low bit is 0, the OOP is a heap reference: its value is an index into the object table.
Each entry in the object table contains:
- The object’s size (number of fields or bytes, depending on object format)
- The object’s class pointer (itself an OOP — a reference to the object’s class in the object table)
- The object’s format (pointer object, byte-indexed object, word-indexed object, etc.)
- The address of the object’s data in heap memory
When code sends a message to an object, the interpreter dereferences the OOP through the table to find the object’s class, then looks up the method in the class hierarchy. When code reads an instance variable, the interpreter dereferences the OOP, finds the data address, and reads the appropriate slot.
The indirection through the object table has a specific benefit: objects can be moved in heap memory (during compaction) without updating all the pointers to them, because the pointers are OOPs that index into the table, and only the table entry needs to be updated when the object moves.
The first thing I built was the C# infrastructure for this: an ObjectTableManager class that allocated table entries and assigned OOPs, a heap allocator for object data, and the tagging conventions for SmallIntegers. This part was mechanical but had to be exact — every subsequent piece of the system depends on it.
Object Formats
Smalltalk-80 objects come in four formats, distinguished by a two-bit field in the object table entry:
Pointer objects store a fixed number of OOP-sized fields. Class instances are pointer objects. A Point instance has two fields: x and y. A MethodContext has many more. Each field is an OOP that references another object (or encodes a SmallInteger directly).
Byte-indexed objects store arbitrary bytes. Strings are byte-indexed. The object’s data is a sequence of raw bytes. You can access individual bytes by index, but you can’t store OOPs in them.
Word-indexed objects store 16-bit or 32-bit words. LargeInteger uses this format. So does the display bitmap.
Pointer-and-byte objects store a fixed header of OOP fields followed by a variable-length byte array. CompiledMethod is the primary example: it has a fixed number of header fields (the method header, the literal frame entries) followed by the bytecode array.
Each allocation in the cold-start loader has to use the right format for the object being allocated. Getting the format wrong produces an object that the interpreter will silently misread, because the interpreter determines how to access an object’s fields entirely from the format bits in the object table entry.
The Cold-Start Phases
The cold-start loader processes the JSON in multiple phases. The phases have to be ordered carefully because of the chicken-and-egg relationships between objects: you can’t set a class’s superclass pointer if the superclass object hasn’t been allocated yet, and you can’t install a method in a class’s method dictionary if the method dictionary object doesn’t exist yet.
The phases, briefly:
Phase 1 allocates the root objects that everything else will reference: nil, true, false, the small integer objects for common values. These need to exist before anything else, because almost every subsequent allocation will immediately need to reference nil for uninitialized fields.
Phase 2 allocates the core class infrastructure: Object, Behavior, ClassDescription, Class, Metaclass, and all their metaclasses. This has to happen before any user-defined class can be allocated, because every class has Object or one of these core classes in its superclass chain.
Phase 3 allocates all 530 class and metaclass objects for every entry in the JSON. At this stage they’re allocated but not fully connected — superclass and method dictionary pointers are set to nil as placeholders. The JSON records make this enumerable:
class:Point (instVars: x y, superName: Object, format: 16386)
meta:Point (isMetaclass: true, metaclassOf: class:Point)
class:SmallInteger (instVars: none, superName: Integer, format: ...)
meta:SmallInteger (isMetaclass: true, ...)
... 526 more
Phase 4 wires the superclass and metaclass relationships. For each class, the class’s superclass pointer is set to the already-allocated superclass object. Each metaclass’s superclass pointer is set to the metaclass of its corresponding class’s superclass. This phase is where the full class hierarchy comes together.
Phase 5 allocates and populates method dictionaries. For each class, a MethodDictionary object is allocated, and each method is compiled from the JSON bytecode array into a CompiledMethod object and installed. This phase processes all 5,310 methods across 530 classes.
Phase 6 installs global variables. The Smalltalk dictionary — the SystemDictionary — is populated with all the class objects and any global variables present in the JSON.
Phase 7 onward handles initialization: creating the ProcessorScheduler, the CharacterTable, pool dictionaries, and other objects that need to exist before the interpreter can run anything.
Fourteen phases in total. Each one was validated by running a garbage collection at its conclusion — anything collected meant something wasn’t linked correctly.
A Worked Example: CompiledMethod
To make this concrete, here is what happens when the cold-start loader processes a single method from the JSON.
The JSON for a method includes: the selector ("at:put:"), a primitive number (-1 if none), argument names (["anIndex", "anObject"]), temp variable names ([]), a literal frame (["anIndex", "anObject"]… typically not user-visible), and a bytecodes array ([16, 17, 129, 96, 120] for a trivial method).
The loader:
- Allocates a
Symbolobject for the selector. Symbols are interned —"at:put:"always resolves to the same OOP regardless of how many methods use it. - Allocates a
ByteSymbolorByteStringobject for each literal in the literal frame. - Allocates a
CompiledMethodobject: a pointer-and-byte object whose header section contains the method header word (encoding primitive number, number of arguments, number of temps, and literal count), followed by one OOP field per literal, followed by the bytecode array. - Installs the method into the class’s method dictionary under the selector’s OOP.
The method dictionary is a MethodDictionary object (a subtype of Dictionary) whose structure is a hash table: an array of selector OOPs and a parallel array of compiled method OOPs. The hash function is based on the selector’s OOP value.
After all methods are installed, the method dictionary’s tally field (a count of installed entries) must be set correctly. This was a specific bug: early versions of the loader set the tally after each insertion, but the hash table can undergo internal reorganization during insertion, so the final tally had to be set once after all insertions were complete.
The OOP Collision Bug
The first serious bug in cold start was discovered in September: Object and ObjectClass (Object’s metaclass) were both being assigned OOP 0.
OOP 0 is special in Smalltalk-80 — it conventionally represents nil (though technically nil is its own object with its own OOP; OOP 0 is a sentinel for “no object”). Assigning OOP 0 to two different objects caused one of them to be silently aliased to nil in certain contexts.
The symptom was intermittent: most operations worked, because most code never triggered the specific path where the class/metaclass boundary was crossed using an OOP-0 pointer. But certain metaclass method lookups — sending a message to a class that caused a lookup to traverse from the class’s metaclass up to Metaclass itself — would end up reading from nil’s object table entry instead of Object’s metaclass entry. The resulting method lookup failure looked like a missing method in a completely unrelated class.
The fix was straightforward once identified: ensure that OOP allocation starts from 1 (or some other non-zero seed), and that the sentinel value 0 is reserved. The detection was the hard part: finding the one codepath that crossed the class/metaclass boundary in a way that exposed the collision required reading method lookup logic very carefully against the Blue Book’s description.
The Garbage Collector as a Correctness Oracle
Mark-sweep garbage collection works by starting from a set of root references and marking every object reachable from those roots, then sweeping the heap to collect anything not marked.
In a normal runtime, you run the GC when memory is low and you want to reclaim objects that are no longer reachable — things the running program has finished with. But during cold start, you’re not running anything. You’ve just finished allocating a heap full of objects and linking them together. No object should be unreachable at this point; every object in the system is part of the live Smalltalk environment and should be reachable from the root references (the SystemDictionary and the special objects array).
Running the GC immediately after cold start completion is therefore a correctness test, not a memory management operation. If the GC collects anything, it means you forgot to link something. An object was allocated but never connected to the reachable graph. Some pointer was left as nil when it should have been set to a real OOP.
This was invaluable. Every time I added a new allocation phase — creating pool dictionaries, the process scheduler, the character table — I’d run the GC afterward and watch what it collected. A freshly allocated ProcessorScheduler being immediately collected meant I’d allocated it but hadn’t installed it in the Smalltalk global dictionary yet. A set of Symbol objects being collected meant I’d failed to add them to the symbol table.
The GC didn’t tell me what was wrong — that still required reading the code — but it told me immediately that something was wrong, and how many objects were affected. A run that collected 50 objects was worse than one that collected 5, and that relative count gave useful signal about whether a fix was on the right track.
The target was zero collections. The moment cold start ran with zero garbage collected was the first real milestone of the project.
The Double-Tagging Bug
Zero collections was not the same as a correct heap. The GC confirmed reachability; it didn’t confirm that the content of each object was correct.
The double-tagging bug illustrated this distinction. Literals in compiled methods — string constants, symbol constants, class references — need to be allocated in the heap as real objects and referenced from the method’s literal frame. The literal frame is a section of the CompiledMethod object containing OOP-sized fields, one per literal.
The bug: literals were being stored in the literal frame as SmallInteger-tagged values (odd-tagged OOPs, treating the literal’s heap address as if it were a small integer value) rather than as normal heap OOPs (even-tagged, indexing the object table). The literal objects were correctly allocated and reachable — so the GC didn’t notice. But when the interpreter tried to access a literal by reading its literal frame slot and dereferencing the OOP, it would see an odd-tagged value and interpret it as a SmallInteger, not as a reference to a String or Symbol.
The symptom was methods that worked correctly for arithmetic and simple message sends but produced nonsense when they accessed any literal. A string comparison would compare an integer against a string. A symbol lookup would search for an integer OOP in a dictionary instead of the symbol.
This was found by writing test methods that explicitly accessed literals and checking the interpreter’s results against expectations. It took several days of narrowing down which codepath produced the error before the tagging distinction was identified.
After this fix, the heap was both fully reachable (zero GC collections) and correctly structured (literals accessible as the right types). With that, it was time to build the thing that would actually use the heap.
What Cold Start Produced
By October 4th, cold start ran to completion with zero garbage collected. The heap contained:
- All Smalltalk-80 system classes, correctly allocated with the right object formats
- All metaclasses, with correct superclass chains and metaclass relationships wired
- All compiled methods installed in their method dictionaries
- All global variables in the SystemDictionary
- All special objects (nil, true, false, SmallInteger instances, the symbol table) in the well-known locations the interpreter expects
Nothing in that heap could execute yet. To execute, you need the interpreter. But the heap was structurally correct, verified by a GC that found nothing it shouldn’t.
The next step was building the machine that would run it.