Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

This is the development journal for Kasm — Ken’s Assembler — a small C11 assembler for a limited x86-64 (and now emerging x86-16) instruction set.

The README is Kasm’s user manual: it documents current behavior, supported syntax, and how to build and run it today. This book is different. It is a living journal, written in chronological order, that tells the story of how Kasm grew — one small, deliberate version at a time — from a bare lexer to an assembler that emits Windows COFF objects a linker can consume.

Every chapter here corresponds to one or more version bumps in CMakeLists.txt. Where the README states facts, this book explains why those facts came to be: what problem the version solved, what constraints shaped the design, and what was deliberately left for later.

Why write this down

Kasm was built in short, fast, test-gated milestones — usually one capability per version, each with a permanent regression test before moving on. That discipline is easy to lose sight of once the codebase is large. This journal exists to preserve the reasoning trail: not just what Kasm can do, but the order it learned to do it in, and the reviews and fixes along the way.

How to read this book

The chapters are in the same order the assembler grew. If you only want to know what Kasm can do right now, read the README instead. If you want to understand why jumps require an explicit near/short keyword, why data directives skip decoding, or why 16-bit mode only understands [bx], read on — each of those decisions gets a chapter.

Source of truth

This book is generated from the project’s own Git history and version table, not from memory. Dates are taken from annotated tags where they exist; some early versions were bundled into later tagged builds and are dated approximately from surrounding commits.

Genesis: A Lexer for Two Days (Sept 12)

Kasm began on September 12 with a bare CMake C project: an executable target, an install rule, and a CI workflow before there was anything interesting to build. The very first working piece of the assembler was not the assembler at all — it was the lexer.

Building the front door first

The earliest commits are almost entirely about turning raw source text into tokens:

  • Identifier and integer-literal tokens, plus a shared “digit value” helper that would later grow into hex, octal, and binary literal support.
  • Newline tokens, because whitespace-sensitivity mattered even before there was a parser to consume it.
  • Single-line (//) and multi-line (/* */) comment handling.
  • Small, isolated test drivers (tests/lexer_driver.c and CMake script fixtures) that checked token streams directly, long before there was any encoded output to check instead.

This is a pattern that holds for the entire project: a capability doesn’t exist until it has a permanent test. The lexer tests for decimal/binary literals and comment handling are still part of the test suite today.

Source ownership from day one

Even at this early stage, the project made a decision that shows up throughout its history: the assembler owns its source text rather than borrowing a pointer into the caller’s buffer. source.c/source.h were split out of main.c almost immediately (c7de71a — Refactor project structure: move main.c to src, add source.c and source.h), establishing the module boundaries — source, lexer, program, semantic, layout, encode, decode — that the codebase still uses.

Housekeeping that paid off later

A few unglamorous early commits mattered more than they looked:

  • CI workflow and versioned-tag release process, set up before there was a release worth making (4f86da7, 8008415, f26c88f).
  • License headers added to main.c for compliance.
  • GCC build instructions added to the README so the project would build on more than one toolchain from the start — this is what let later 16-bit and overflow-safety work be validated on both Windows and WSL2/GCC.

By the end of these first two days, the project had no assembler behavior a user would recognize — but it had a lexer, a test harness, a CI pipeline, and a release process. Everything from 0.1.0 onward was built on that foundation.

Parsing Expressions and Programs

With tokens in hand, the next stretch of commits built a recursive-descent expression parser and then wrapped it in a program structure that could hold more than one statement.

Expressions, in the order operator precedence demands

The parser grew in the textbook order for precedence climbing:

  1. Integer literals and parenthesized error handling (b3ea2aa).
  2. Addition and subtraction (c0daf03).
  3. Multiplication and parenthesized precedence (99d11f7) — multiplication had to bind tighter than the addition/subtraction level added just before it, and parentheses had to override both.

Each step landed with its own parser test, so by the time semantic evaluation existed, the precedence rules were already locked down by regression tests rather than by hope.

From a single expression to a program

6be02df — Implement program parsing and testing introduced the core model that the rest of the assembler still uses: a Program is a flat, growable array of Statement values (include/program.h). Two tests still in the suite trace directly back to this commit:

  • program_blocks.c / parser_blocks.cmake — multiple statements parse into the same flat array.
  • program_growth.c — the array actually grows past its initial capacity without corrupting existing entries.

Nested blocks are parser sugar, not a runtime concept

08cdd02 — Add support for nested blocks in parser added { } grouping with an explicit depth limit. Nested blocks never became a distinct concept in the Program model — they are flattened into the same statement array during parsing. This is a deliberate simplification: nothing downstream (layout, encoding, decoding) ever needs to know a block existed. It is worth calling out specifically because it is easy to assume, from the syntax alone, that blocks are scopes or basic blocks in a compiler sense. They are not; they are purely a source-level grouping convenience.

By the end of this phase, Kasm could parse a full program of expressions and statements — but it still couldn’t validate or encode anything. That came next, in 0.1.0.

0.1.0 — The First Encoded Bytes

Version 0.1.0 is the first release-worthy milestone: Kasm can parse and validate mov programs, evaluate their expressions, and print the encoded bytes.

Semantic checking arrives before encoding

381a87b — Implement semantic checking and evaluation added the step between parsing and encoding that still exists today: folding the expression AST into concrete integer values and validating that a mov’s destination register and immediate value make sense. This is the origin of the check_program stage in the pipeline documented in the project’s engineering notes (source_load → parse_program → check_program → layout → encode).

Putting semantic validation before encoding — rather than validating during encoding — set a pattern the project kept for every instruction added after it: new instructions get a parser rule, a semantic check, and an encoder entry, in that order, each backed by its own test.

mov eax, expr;

f0e5274 — Implement encoding for MOV instructions is the payoff commit: it takes a validated statement and emits real x86-64 bytes (B8 imm32) for mov eax, <expression>;. The accompanying tests compared CLI output against expected hex for the first time, establishing the “encode, then diff the hex” testing style used throughout the rest of the project (see tests/encode_file.cmake).

At this point Kasm could turn mov eax, 40+2; into five bytes of machine code and print them. It could not yet write those bytes anywhere useful, run them, or return from them — that arrives over the next three versions.

0.2.0–0.4.0 — RET, Binary Output, and Generated Headers

Three fast, small versions turned “prints hex” into “produces something you could plausibly link into a program.”

0.2.0 — ret;

0cdc732 — Implement RET instruction support added the C3 opcode. Trivial in isolation, but it meant a Kasm program could now be a complete, callable function body rather than just a mov: mov eax, 40+2; ret; is the canonical example that reappears throughout the README and this journal.

0.3.0 — Bytes that leave the process

3b83695 — Update project version to 0.3.0, add binary writing functionality, and implement reusable example encoding tests introduced write_binary, saving the encoded image to program.bin. This is also when the project adopted its per-example, isolated-output-directory testing convention: each example .asm file gets its own test output directory so parallel test runs never collide over program.bin.

0.4.0 — Bytes a C compiler can embed

6327c4a — Update project version to 0.4.0, implement write_c function, and add generated output files added write_c, emitting generated.h as a C byte array plus its length. A tiny inspector program could now #include the generated header and print the bytes back out — the first sign that Kasm’s output was meant to be consumed by other programs, not just inspected as hex on a terminal.

By 0.4.0, the round trip was: assemble → validate → encode → save as .bin → save as a C header. The only thing missing was actually running the result, which is the subject of the next chapter.

0.5.0–0.6.0 — Making It Actually Run

Generating correct bytes is one kind of confidence. Watching a CPU execute them and produce the expected result is a much stronger one.

0.5.0 — Linux execution

18b0a05 — Update project version to 0.5.0, enhance README with version history, and add Linux execution example added an x86-64 Linux runner example: allocate executable memory, copy in the bytes from generated.h, cast the buffer to a function pointer, and call it. For mov eax, 40+2; ret;, this is the moment the project could demonstrate — not just assert — that its encoder was correct: the call returns 42.

This runner is explicitly not part of the assembler. Execution lives in example code; Kasm itself only ever produces data. That separation of concerns (assembler vs. loader/runner) is a recurring theme — it shows up again later when load-time relocation and COFF output are added, both of which extend what a runner or linker can do with Kasm’s output rather than adding execution to Kasm itself.

0.6.0 — The same trick on Windows

Version 0.6.0 (recorded as a development milestone in the README’s version table) ported the runner concept to Windows x86-64 using the platform’s executable-memory APIs, proving the generated bytes were host-OS-agnostic — Kasm doesn’t encode anything OS-specific, only raw x86-64 instruction bytes.

With execution now demonstrated on two platforms, the project’s next problem became obvious: a single mov/ret pair isn’t a program, it’s a demonstration. Multi-instruction control flow needed labels, and labels needed layout — the subject of the next chapter.

0.7.0–0.9.0 — Labels, Layout, and Near Jumps

This trio of versions is where Kasm stopped being a straight-line instruction emitter and started being able to describe programs with control flow.

0.7.0 — Labels that don’t cost bytes

8f4f3b3 — Update project version to 0.7.0, implement label definitions, and enhance Windows execution support parsed label definitions — including consecutive labels pointing at the same location — without emitting any bytes for them. A label is purely a name bound to a position; it has zero size in the encoded image.

0.8.0 — A layout pass, finally

Parsing labels is easy. Knowing where they point requires knowing the size of everything before them, which requires a dedicated pass. e643517 — Update project version to 0.8.0, implement layout functionality, and add label handling introduced that pass: it walks the statement array once, assigns byte offsets to instructions and labels, rejects duplicate label names, and provides an offset lookup used by everything that follows — jumps, relocations, and (much later) data directives and location-aware $/ $$ expressions.

Introducing a distinct layout stage — separate from parsing and separate from encoding — is arguably the single most consequential structural decision in Kasm’s history. Every forward-reference feature added after this point (near/short/absolute jumps, conditional branches, TIMES/FILL counts, $/$$) depends on layout running to completion before encoding starts.

0.9.0 — Near jumps and forward references

d98a002 — Update project version to 0.9.0, implement near jump functionality, and enhance parser and encoder added jmp with a signed relative displacement, resolved against the label table built during layout. This is the first feature that genuinely required the layout pass: a forward jump’s displacement can’t be computed until the size of every instruction between the jump and its target is known.

At this point Kasm could express loops and forward branches, but only in one jump form, and only as a raw relative displacement. The next chapter adds a second jump form, a relocation mechanism, and jumps through memory rather than through an immediate displacement.

0.10.0–0.12.0 — Short Jumps, Relocation, and Absolute Jumps

0.10.0 — Two jump forms, one explicit keyword

bc36448 — Update project version to 0.10.0, implement short jump functionality added jmp short label; alongside the existing near jump, using a signed 8-bit displacement instead of a wider relative one. This version also removed the earlier, more permissive jmp answer; syntax in favor of requiring an explicit jmp near answer; or jmp short answer; — a rare instance of the README calling out a breaking syntax change directly: the ambiguity between two encodings for the same mnemonic was resolved by making the choice explicit at the syntax level rather than picking one automatically based on displacement size.

0.11.0 — Someone has to patch the address at load time

Not every address is known at assembly time — a runner loads Kasm’s raw image into memory at an address chosen by the OS, not by Kasm. db287f4 — Update project version to 0.11.0, implement load-time relocation, and enhance output handling introduced include/relocate.h: a helper that patches an image slot to a runtime address after the image has been loaded, plus relocation metadata emitted into the generated C header. Both the Linux and Windows runners were updated to apply these patches before executing the image — this is the first time the runner examples had to do more than “copy bytes and call them.”

0.12.0 — jmp abs, verified on WSL2

d0fd215 — Update project version to 0.12.0, implement absolute jump functionality added jmp abs label; through a full-width address slot: the target address is stored as data in the image, patched at load time via the 0.11.0 relocation mechanism, and jumped to indirectly. This was verified by actually executing a relocated jump under WSL2 — not just diffing hex — a reminder that relocation bugs are exactly the kind of thing that look correct in an encoded-bytes test but fail the moment the image runs at a different address.

Note on numbering: 0.13.0 does not appear in Kasm’s history. The project’s own version table marks 0.14.0 explicitly as “0.13.0 skipped” — this journal preserves that gap rather than renumbering around it, since the gap is part of the accurate record.

With relative, short, and absolute jumps all in place, Kasm had every jump form it needed except conditional ones — which meant no if/while-style control flow was possible yet. That’s the next milestone.

0.14.0 — Conditional Branches and a Decoder

Version 0.14.0 (0.13.0 was skipped) did two things that changed how the project validated itself from this point forward.

INC/DEC and JZ/JNZ

e0269d1 — Update project version to 0.14.0, add INC and DEC instructions, and implement conditional jumps (JZ, JNZ) gave Kasm its first real loop primitive: increment or decrement eax, then branch on whether the result was zero. Combined with the near/short jumps from 0.10.0, this was enough to express a counted loop for the first time.

A decoder, and a new kind of test

1ec928a — Implement decoding functionality and update tests for new instructions added a decoder — the mirror image of the encoder, turning an image back into a textual listing. This changed the testing strategy: up to this point, tests compared encoded hex against expected hex. From here on, tests could also compare a decoded listing against expected text, which catches a different class of bug (an encoder and decoder can independently agree on the wrong thing, but round-tripping through both makes an encoding error much more likely to surface as a decoding error too).

This decoder is also the origin of a bug that took several versions to surface. Because new instructions were added to the encoder and their decoder entries sometimes lagged behind, inc eax and jz briefly had correct encodings but incomplete decoding support — a gap the 0.18.1 milestone (see the next chapter) closed with a dedicated regression test.

With INC/DEC and JZ/JNZ in place, and a decoder to check work against, the project moved on to broadening the arithmetic instruction set: ADD, SUB, and several bitwise operations, all following the same “parse → validate → encode → decode” pattern established here.

0.15.0–0.20.0 — Growing the Arithmetic ISA

This stretch of versions is the most repetitive-looking part of Kasm’s history, and that repetition is the point: once the pattern of “parser rule → semantic check → encoder entry → decoder entry → permanent test” was established, adding an instruction became a mechanical, low-risk process.

VersionInstruction(s) addedEncoded form
0.15.0add/sub eax, imm3205 imm32 / 2D imm32
0.16.0or eax, imm32, push/pop rax0D imm32
0.17.0adc eax, imm3215 imm32
0.18.0int imm8unsigned 8-bit vector, range-checked
0.18.1(fix) decoding for inc eax and jzno new encoding, decoder-only fix
0.19.0cmp/and eax, imm32, jb/jl3D imm32 / 25 imm32
0.19.1(hardening) dynamic source bufferno new instruction
0.20.0xor eax, imm3235 imm32

The 0.18.1 regression fix is worth reading closely

103547d — Update project version to 0.18.1, add INC and JZ instruction support, and include regression tests for decoding is a bug-fix release inserted into the middle of otherwise linear feature growth. It closed the decoder gap noted in the previous chapter: inc eax (FF C0) and jz (0F 84) had valid encoders but an incomplete decoder, so a valid program would encode correctly, write its outputs, and then have the CLI exit with unknown or truncated encoding while printing the decoded listing. The fix added the missing decoder cases and a permanent end-to-end regression covering both instructions’ byte output and their decoded listing together, exactly the kind of test the 0.14.0 decoder made possible.

Registers beyond eax

Growing from eax-only forms to ecx and edx forms (folded into this era via 6809007 and 1384ac9) introduced the “second byte of the 0x81 family selects the operation and register” pattern documented in the project’s opcode lookup guide — 81 C1 is add ecx, imm32, 81 E9 is sub ecx, imm32. This is the first time Kasm had to disambiguate more than one instruction sharing an opcode prefix, which is also why register codes started being tracked explicitly on statements rather than inferred at encode time.

By 0.20.0, Kasm had a genuinely useful arithmetic and bitwise instruction set for eax. What it didn’t have yet was any explicit acknowledgment that 16-bit and 32-bit targets even existed as distinct concepts — that correction came next.

0.19.1–0.22.0 — Hardening the Frontend

Not every version added an instruction. A few were dedicated entirely to paying down assumptions baked into earlier, faster milestones.

0.19.1 — Source stops being a fixed 4096-byte buffer

c06e86c — Bump version to 0.19.1 and implement dynamic source buffer allocation replaced the original fixed-size, 4096-ASCII-byte source buffer (include/source.h) with dynamically growing storage. The NUL-free ASCII validation rule was kept — only the size ceiling was removed. This mattered because every later feature that grows source size (data directives with long lists, TIMES/FILL, boot-sector padding fixtures) would otherwise have run straight into an artificial 4 KB ceiling that had nothing to do with the instruction set.

0.21.0 — Straightening out opcode handling

6809007 — enhance opcode handling for MOV, ADD, and SUB instructions refactored how register codes fed into the 0x81-family ModR/M byte selection, in preparation for adding edx support (1384ac9) without duplicating the eax/ecx special-casing that had accumulated.

0.22.0 — CPU mode stops being implicit

4036930 — Update project version to 0.22.0; implement target mode handling and reject unsupported modes added --bits 16, --bits 32, and --bits 64 to the CLI, and made 16-bit and 32-bit modes explicitly rejected rather than silently mishandled. This is a defensive design choice: rather than letting an unsupported mode fall through to encoding logic that only ever assumed 64-bit registers, the CLI now fails fast with a clear mode error. That rejection was later narrowed as 16-bit support actually arrived (a regression test now checks that eax specifically is rejected in 16-bit mode, rather than rejecting the whole mode outright).

This sequence — remove an artificial limit, straighten out internal opcode handling, then make previously-implicit assumptions explicit and checked — is a recognizable pattern any time Kasm was about to expand into genuinely new territory. It shows up again just before the 16-bit work begins in earnest, and again just before COFF output.

0.23.0–0.25.1 — Data Directives and Location-Aware Layout

Up to 0.22.0, every statement in a Kasm program was an instruction. This era added a second category entirely: data that isn’t an instruction at all.

0.23.0 — DB/DW, then DD/DQ

4af6afc added db/byte and dw/word directives; two follow-up commits (ca576ae, 63bc4b3) added dd/dword and dq/qword. All four accept comma-separated expression lists terminated by ;, emit little-endian values, and are range-checked against their declared width:

SpellingBytesRange
db/byte10–255
dw/word20–65535
dd/dword40–2147483647 (current expression limit)
dq/qword80–2147483647 (current expression limit)

The README is explicit that DD and DQ’s storage is wider than the expression evaluator’s current range — the evaluator rejects results above 2147483647 before a value ever reaches the wider storage, so the diagnostic range and the storage width intentionally don’t match yet.

Because data isn’t an instruction, the CLI had to stop assuming every image was disassemblable: images containing data print Data emitted; instruction- only decoding skipped. instead of attempting a listing.

45ab217 — Refactor instruction size calculation for ST_SUB_RIM and ST_ADD_RIM landed alongside this work, fixing layout’s instruction-size accounting to account for register codes correctly — a reminder that adding a second statement category (data) forced a re-check of assumptions the layout pass had made when everything was an instruction.

0.24.0 / 0.24.1 — Repetition, then a compatibility fix

4c160d6 — Implement TIMES/FILL support for data directives added times <count> / fill <count> prefixes that repeat a directive’s entire value list, with overflow-safe size_t count validation. 642c3d3 — Bump version to 0.24.1 immediately followed to restore the default single-source-file CLI invocation to 64-bit mode — the explicit --bits flag from 0.22.0 had apparently changed default behavior in a way that broke existing tests and installed-package smoke checks, and 0.24.1 exists purely to fix that compatibility regression.

0.25.0 / 0.25.1 — $ and $$, and a CI portability fix

7ff337f — Bump version to 0.25.0; add location-aware expressions let $ (current image offset) and $$ (image section start) appear inside TIMES/FILL counts, resolved during layout after preceding statement sizes are known. The canonical example is NASM-style boot-sector padding:

mov eax, 42;
times 510-($-$$) db 0;
dw 0xAA55;

28d1e4e — Bump version to 0.25.1 fixed the regression test for this exact example: the original 512-byte padding check compared binary output directly, but CMake strings can’t safely carry embedded NUL bytes, which made the test’s behavior differ between Windows and Linux CI. The fix measures length through the CLI’s hex output instead, which is NUL-safe and portable — a small but instructive lesson about testing binary output through text-based tooling.

Data directives, once added, immediately needed most of the same infrastructure instructions had — layout, range checks, tests — which is why this era reads as dense as the arithmetic-ISA growth in the previous chapter, just for a different statement kind.

0.26.0–0.30.0 — The 16-Bit Adventures

Five versions turned --bits 16 from a rejected mode (0.22.0) into a mode with a real, if intentionally narrow, instruction subset.

0.26.0 — MOV/ADD/SUB on AX, CX, DX

d3a6cac — Bump version to 0.26.0; add initial 16-bit MOV/ADD/SUB support introduced operand-width as a property tracked on each statement, so layout and encoding could agree on instruction length without guessing from the target mode alone:

FormSizeBytes
mov ax, 42;3B8 2A 00
mov cx, 7;3B9 07 00
add ax, 7;305 07 00
sub cx, 3;481 E9 03 00

Validation here was unusually thorough for a “0.x-in-development” milestone: all 32 registered tests passed under WSL2/GCC in addition to Windows, and the GCC build caught a missing <string.h> include that MSVC had silently tolerated — a concrete payoff for keeping a second toolchain in CI since the project’s earliest days.

0.27.0 — One memory operand, deliberately narrow

0fcd4a2 — Bump version to 0.27.0; add 16-bit indirect MOV support from [bx] to AX/CX/DX added exactly one addressing form: mov ax, [bx];8B 07. The README is explicit that this is intentionally limited to [bx] — no other memory operand is supported. Scoping a feature to the single simplest case, with a name that makes the limitation obvious, is a recurring way Kasm avoids implying more capability than it has.

0.28.0 — A boot-sector-shaped fixture

19b5d52 — Bump project version to 0.28.0 combined 16-bit instructions, location-aware TIMES padding, and binary literals into a 512-byte image fixture ending in the 55 AA boot signature. The README is careful to call this a layout fixture, not a bootable program — there’s no defined entry convention, segment state, stack, or BIOS-service usage yet. It looks like a boot sector and is exactly the right size, but running it in a real (or emulated) BIOS boot path is future work.

0.29.0 — Segment registers

d314108 — Update project version to 0.29.0; add support for segment- register push/pop added push/pop for es, cs, ss, ds, fs, and gs. The legacy four use single-byte opcodes; fs/gs need the 0F two-byte escape. pop cs is deliberately not encoded — there is no valid modern x86 form for it, so the omission is a correctness decision, not a gap.

0.30.0 — Subtract with borrow

sbb joined the 16-bit immediate forms for ax/cx/dx, using the same 81 /3 ModR/M family as the earlier sub support. The README notes plainly that sbb consumes the processor carry flag as part of its semantics, but Kasm neither executes code nor otherwise establishes that flag — the encoding honors the contract; nothing in Kasm currently fulfills it.

Across this whole arc, the 16-bit instruction set stayed deliberately narrow — a handful of registers, one memory form, no general addressing modes — while still being real enough to produce a byte-exact, test-covered boot-sector-shaped image. That restraint is a design choice as much as anything encoded in opcodes.

0.31.0–0.32.0 — Odds, Ends, and Overflow Safety

Two short versions closed out gaps that had been quietly accumulating: missing trivial instructions, and unchecked signed-integer overflow in constant folding.

0.31.0 — HALT and PAUSE

cc2b435 — Bump version to 0.31.0; add operand-free HALT and PAUSE instructions with tests added the simplest possible instruction shape: opcodes with no operands at all.

InstructionBytes
haltF4
pauseF3 90

The interesting detail is pause: it’s a two-byte sequence (F3 90), and the decoder has to recognize it as a whole before falling through to treating 90 as a bare nop-shaped byte it doesn’t otherwise understand. Getting the order of decoder checks right — multi-byte sequences before single-byte fallbacks — is a small but real source of bugs in any byte-pattern decoder, and this version’s test checks both the binary and the decoded listing together to guard against it.

0.32.0 — Constant folding stops trusting signed overflow

e5805ad — Bump version to 0.32.0; implement overflow-safe arithmetic for constant expressions with tests replaced unchecked signed long long arithmetic in constant folding with checked addition, subtraction, and multiplication, ahead of Kasm’s existing signed-32-bit language limit. This closed a real correctness gap flagged during project review: signed integer overflow is undefined behavior in C, and the previous folding logic could overflow before the 32-bit range check ever ran, meaning the check itself couldn’t be trusted for large enough inputs. The fix checks for overflow at each individual operation, not just at the end result, and the regression test explicitly exercises overflowing addition, subtraction, and multiplication, alongside the existing precedence test.

Neither of these versions is glamorous, but they’re the kind of maintenance that has to happen between “add a new instruction” milestones — closing a decoder ordering gap and an undefined-behavior gap before building the next major feature (COFF object output) on top of a semantic layer that folds expressions correctly.

0.33.0 — COFF Objects and Linking with the Real World

Every prior version produced a raw image: bytes meant to be copied into executable memory directly by a hand-written runner. Version 0.33.0 is the first time Kasm’s output is meant to be consumed by someone else’s tool — a linker.

Why an object format, and why COFF first

5091655 — Implement COFF support and enhance expression handling added --format coff --export <label> -o <output-path>, producing a Windows x64 COFF object: symbol and string tables, one instruction-only .text section, one exported label, and both REL32 and ADDR64 relocation records. COFF was the natural first choice on a project developed and tested primarily on Windows, and it reuses infrastructure the project already trusted — the same 16 MiB image-size ceiling (KASM_IMAGE_LIMIT) applies to COFF output as to raw output.

.\build\windows-debug\Debug\kasm.exe --format coff --export answer -o .\build\coff_answer.obj examples\coff_answer.asm

Two things that had to stop being implicit

Two decisions in this milestone are direct descendants of the “make implicit assumptions explicit” pattern seen at 0.22.0 and 0.26.0:

  • Load-time absolute jumps (jmp abs) are rejected for object output. The 0.11.0/0.12.0 relocation mechanism patches a runtime address directly into a loaded image; a linker-resolved object needs a linker relocation record instead, which is a different mechanism entirely. Rather than silently emitting an incorrect or unusable relocation, object output explicitly rejects jmp abs until it can be converted into a proper linker relocation.
  • Raw output file naming changed. Previously fixed as program.bin / generated.h regardless of input filename, raw output now derives its name from the source file — examples/coff_answer.asm now produces coff_answer.bin and coff_answer.h. This matters once a single project might assemble more than one source file into more than one object; fixed output names stop being safe once linking multiple objects together is a realistic workflow.

Data directives learn about labels

This milestone also let data directives resolve label expressions after layout, not just constants and $/$$ location expressions. Constant-only expressions still go through the overflow-safe checked arithmetic added in 0.32.0; only the newly-supported label references need the post-layout resolution step.

Validation

All 40 Windows tests passed, including an executable end-to-end check: an exported function’s object was linked and executed, and a REL32 reference from a backend-generated object was resolved against it. Additional tests specifically covered output-path handling — directories containing spaces, a missing -o argument, and unwritable destinations — because -o is new CLI surface area that raw-mode output never had to validate.

Source-level extern declarations and separate data sections remain future frontend work; 0.33.0 supports exactly one instruction-only .text section and one exported label per object.

0.34.0 — Launching This Journal

Version 0.34.0 is documentation-only: no encoder, decoder, or semantic behavior changed. It marks the point where this journal itself became part of the project.

What actually changed

  • Added devlog/, an mdbook project containing this journal, sourced from the project’s own Git history and README version table rather than written from memory after the fact.
  • Trimmed the README’s long “Version history” table and its surrounding historical narrative paragraphs, replacing them with a short pointer to this journal. The README’s per-feature reference sections (16-bit forms, data directives, COFF output, and so on) were left in place — they document current behavior and usage, which is the README’s job, not the journal’s.
  • Added a publish-devlog job to the CI workflow: on every push to main, after the existing build/test/package job finishes, it builds this book with mdbook build devlog and deploys it to GitHub Pages. It runs independently of (not before) the release job that publishes the ZIP archives — both depend only on the same upstream build job.

Why bump the version for a docs-only change

Kasm’s version history, going back to 0.1.0, has always advanced the version number for every milestone worth recording — including a few, like 0.24.1 and 0.25.1, that were pure fixes rather than new features. Treating “the project’s own development record moved from ad hoc README prose to a maintained, published journal” as a milestone worth a version bump keeps that convention intact, and gives this chapter a natural place in the timeline rather than leaving it undated.

No test changes accompany this version, because no testable behavior changed. The existing test suite continues to be the record of what Kasm does; this journal is the record of how it got here.

Retrospective: Six Days, Thirty-Four Versions

Every version tag from 0.1.0 to 0.33.0 was created between September 12 and September 18, 2026 — six calendar days for thirty-three tagged versions (0.13.0 skipped). Version 0.34.0 followed on the same day as a documentation-only milestone: the launch of this journal. That pace is only sustainable because of habits that were established early and never abandoned:

What held up across the whole project

  • One capability per version. Nearly every version in this journal adds exactly one instruction, one directive, or one hardening fix — rarely more. That granularity is what makes a 6-day, 34-version history legible at all instead of a blur.
  • A permanent regression test before moving on. From the very first lexer commits through COFF object output, no capability shipped without a test that stayed in the suite. tests/encode_file.cmake’s “assemble, then diff hex and/or decoded listing” pattern, established around 0.1.0–0.3.0, is still how most instruction-level features are checked today.
  • Two toolchains in CI from day one. Windows/MSVC and WSL2/GCC both building and testing every milestone caught real bugs — a missing <string.h> include at 0.26.0, and a NUL-unsafe test at 0.25.0/0.25.1 — that a single-toolchain project would likely have shipped.
  • A layout pass, introduced early (0.8.0), that every forward-reference feature since has depended on — jumps, relocation, TIMES/FILL counts, $/$$ expressions, and label-valued data all lean on the same offset assignment pass rather than reinventing it.
  • Explicit rejection over silent misbehavior. --bits mode rejection (0.22.0), the [bx]-only memory operand (0.27.0), rejecting jmp abs in COFF output (0.33.0) — the project consistently chose to say “not supported yet” clearly rather than let an unsupported case fall through to incorrect output.

What’s still explicitly future work

As of 0.33.0, the README and this journal agree on what hasn’t been built yet:

  • General 16-bit addressing modes beyond [bx].
  • A defined boot-sector entry convention (segment state, stack, BIOS services) — the 0.28.0 fixture is byte-correct but not yet bootable.
  • Source-level extern declarations and multiple data sections for COFF objects.
  • Converting jmp abs into a linker relocation for object output.
  • Full unsigned 32-bit/64-bit expression values for dd/dq (the storage width already exceeds what the expression evaluator currently allows).
  • Consistent cleanup of parser.nodes, program.statements, and encoded byte buffers on error paths (a short-lived concern for a CLI process, but worth fixing before Kasm is used as anything but a one-shot tool).

Why this book exists

None of the above is a criticism — it’s the normal shape of an assembler built version-by-version with test-gated milestones. This journal exists so that shape stays visible: the order features arrived in, the constraints that shaped each one, and the handful of fix-up versions (0.18.1, 0.24.1, 0.25.1) that are just as instructive as the feature versions around them.

Future entries in this journal should keep the same discipline: one chapter per meaningful version or tightly related group of versions, written close to when the work happens, sourced from the actual commits and tests rather than from memory after the fact.

Version Reference

This table is the same version history the README maintains, reproduced here for quick lookup while reading the journal chapters. If the README and this table ever disagree, the README is the current source of truth for present-day behavior — this book is a historical record.

VersionAdded capability
0.1.0Parse and validate MOV programs, evaluate expressions, and print encoded bytes.
0.2.0Encode ret; as C3, allowing a small function to return to its caller.
0.3.0Save raw bytes in program.bin; test example files in separate output directories.
0.4.0Generate generated.h with a C byte array and length; inspect the data with a C program.
0.5.0Add a Linux x86-64 runner example that loads the generated bytes into executable memory and calls them.
0.6.0Add Windows x86-64 execution to the runner using the same generated bytes.
0.7.0Parse label definitions, including consecutive labels, without emitting extra bytes.
0.8.0Assign instruction and label offsets before encoding, reject duplicate labels, and provide label-offset lookup.
0.9.0Encode near jumps with signed relative displacements and resolve forward label references after layout.
0.10.0Add short jumps with signed 8-bit displacements and require explicit jmp near or jmp short syntax.
0.11.0Add a load-time relocation helper, emit patch metadata in generated headers, and apply patches in both runners before execution.
0.12.0Add jmp abs through a full-width address slot, generate relocation entries, and verify execution of a relocated jump in WSL2.
0.14.0 (0.13.0 skipped)Add INC and DEC on EAX, JZ and JNZ for conditional branches and loops, and a decoder with an exact-listing test.
0.15.0Add ADD/SUB EAX immediate expressions, five-byte encoding, and decoder support.
0.16.0Add OR EAX immediate expressions and PUSH/POP RAX, with layout, operand validation, and decoding support.
0.17.0Add ADC EAX immediate expressions, encoding and decoding, with runtime checks for carry clear and carry set.
0.18.0Add INT with an unsigned 8-bit vector, expression parsing, range validation, encoding, and decoding.
0.18.1Fix decoding for INC EAX and JZ, and add an end-to-end regression test for their byte output and decoded listing.
0.19.0Add CMP and AND EAX immediate expressions, five-byte encoding, flag-setting semantics, decoder support, and JB/JL conditional branches.
0.19.1Replace the fixed 4096-byte source buffer with dynamically growing storage.
0.20.0Add XOR EAX immediate expressions, five-byte encoding, and decoder support.
0.22.0Make CPU mode explicit with --bits 16, --bits 32, or --bits 64; reject unsupported 16-bit and 32-bit modes before encoding.
0.23.0Add DB/byte, DW/word, DD/dword, and DQ/qword data directives, expression lists, range checks, and little-endian emission.
0.24.0Add TIMES/FILL repetition for data directives.
0.24.1Restore the default one-source-file CLI invocation as 64-bit mode.
0.25.0Add $ and $$ location-aware expressions, resolving dynamic TIMES/FILL counts during layout.
0.25.1Fix the boot-padding regression test to measure binary output through HEX, keeping CI behavior consistent across platforms.
0.26.0Add initial 16-bit MOV/ADD/SUB encoding for AX/CX/DX, operand-width-aware layout, and target-aware decoding.
0.27.0Add 16-bit indirect mov from [bx] into AX/CX/DX.
0.28.0Add a 512-byte 16-bit boot-sector fixture using location-aware padding, binary literals, and the 55 AA boot signature.
0.29.0Add 16-bit segment-register push/pop for CS, ES, SS, DS, FS, and GS.
0.30.0Add 16-bit SBB immediate forms for AX, CX, and DX.
0.31.0Add operand-free HALT and PAUSE forms.
0.32.0Make signed constant-expression folding overflow-safe for addition, subtraction, and multiplication.
0.33.0Resolve label expressions in data directives after layout; add Windows x64 COFF object output; name raw binaries and headers after the input file.
0.34.0Documentation only: launch this journal, trim the README’s version history into a pointer to it, and publish the book to GitHub Pages via CI.

Tag timeline

TagDate
v0.1.0-build.3 / build.7 / build.292026-09-12 / 2026-09-13
v0.5.0-build.372026-09-14
v0.10.0-build.452026-09-14
v0.15.0-build.592026-09-15
v0.18.1-build.732026-09-16
v0.19.0-build.842026-09-16
v0.24.1-build.1392026-09-16
v0.25.1-build.1532026-09-17
v0.27.0-build.1672026-09-17
v0.28.0-build.1752026-09-17
v0.29.0-build.1852026-09-17
v0.32.0-build.1992026-09-18
v0.33.0-build.2082026-09-18
v0.34.02026-09-18

Overview

Kasm 0.34.0 (in development) is a small C assembler with a limited x86-64 instruction set and initial 16-bit MOV/ADD/SUB support. It owns source text, lexes tokens, parses expressions and statements, validates operands, assigns image offsets, resolves labels and relocations, emits machine-code bytes, and can decode supported instruction images back into a listing.

The project also emits raw data directives and assembly-time repetition. The default target is hosted x86-64 code; --bits 16 selects the developing 16-bit encoding path, while --bits 32 remains rejected. A raw data image is not automatically a bootable program. Kasm can also generate Windows x64 COFF objects containing one instruction-only .text section and an exported label; standalone executable formats are not yet supported.

COFF output

COFF output requires -o <output-path>. To choose the filename and directory:

.\build\windows-debug\Debug\kasm.exe --format coff --export answer -o .\build\coff_answer.obj examples/coff_answer.asm

The output directory must already exist; quote paths containing spaces. The export must name a label with instructions after it. Raw output remains the default. The COFF writer uses dynamically allocated storage with the existing 16 MiB image limit, supports long symbol names, and accepts REL32 and ADDR64 relocation records. Source-level external declarations and separate data sections remain future frontend work. Existing load-time absolute-jump patches are rejected for object output because they require conversion into linker relocations.

Raw output uses the source filename with its last extension replaced: for example, examples/coff_answer.asm writes coff_answer.bin and coff_answer.h in the current working directory. The runner and inspector examples default to program.h from program.asm; define KASM_GENERATED_HEADER when using another generated header.

See Using a release to try Kasm without a source checkout, or Building and testing to build it from source.

The instruction set and parser

The statement parser accepts mov <identifier>, <expression>;, add <identifier>, <expression>;, sub <identifier>, <expression>;, or <identifier>, <expression>;, xor <identifier>, <expression>;, and <identifier>, <expression>;, adc <identifier>, <expression>;, cmp <identifier>, <expression>;, int <expression>;, push <identifier>;, pop <identifier>;, halt;, pause;, the operand-free instruction ret;, jmp near <identifier>;, jmp short <identifier>;, jmp abs <identifier>;, inc <identifier>;, dec <identifier>;, jz <identifier>;, jnz <identifier>;, jb <identifier>;, and jl <identifier>;, as well as identifier: label definitions. For example, save this as example.asm:

mov eax, 40+2;
mov eax, 0b00000111;
mov eax, (2+3)*4;

Run it after building with one of the presets below:

.\build\windows-debug\Debug\kasm.exe example.asm
./build/GCC-debug/kasm example.asm

Both print:

B8 2A 00 00 00 B8 07 00 00 00 B8 14 00 00 00

Comments can appear on their own lines or alongside instructions:

// Set up the first value.
mov eax, 40+2; // A single-line comment runs to the end of this line.

/* This comment spans multiple lines.
   Use it to explain a group of instructions. */
mov eax, 0b00000111;

mov eax, (2+3) /* Multiply the grouped sum by four. */ *4;

This example prints the same encoded bytes shown above. The semicolons terminate the instructions; // and /* ... */ introduce comments. Block comments do not nest.

Instruction names are case-sensitive: use lowercase mov, ret, jmp, inc, dec, jz, jnz, jb, jl, add, sub, or, xor, and, adc, cmp, push, pop, and int. The parser accepts an identifier as the destination; semantic validation then requires lowercase eax for arithmetic and MOV, or rax for PUSH/POP. Each instruction requires a semicolon, including the last one. Statements can share a line or be separated by newlines and blank lines; a trailing newline is optional. Line breaks within an instruction are not supported, except inside block comments.

Label definitions

examples/labels.asm demonstrates multiple labels on one line:

entry: alias: mov eax,42; done: ret;

Each label is an identifier followed by a colon, with no semicolon. Labels can appear before instructions, on their own lines, consecutively, or inside blocks. The parser stores each definition as an ST_LABEL statement, and the encoder emits no bytes for it. This sample therefore produces the same six bytes as mov eax,42; ret;:

B8 2A 00 00 00 C3

Labels receive byte offsets during the layout pass described below. Duplicate names are rejected. jmp accepts a label name as its operand, including one defined later in the source. Labels are not supported as expression values.

Run the reusable test from the repository root after rebuilding:

cmake "-DKASM=build/windows-debug/Debug/kasm.exe" "-DSOURCE=examples/labels.asm" "-DEXPECTED_HEX=B8 2A 00 00 00 C3" -P tests/encode_file.cmake

Instruction offsets and label lookup

The layout pass runs after semantic checking and before byte encoding. Starting at byte offset zero, it stores the current offset in each Statement.offset, then advances by instruction_size(): five bytes for MOV, ADD, SUB, OR, ADC, CMP, or a near JMP, two for a short JMP, fourteen for an absolute JMP including its address slot, two for INC, DEC, or INT, six for JZ or JNZ, one for RET, PUSH, or POP, and zero for a label. Offsets are relative to the beginning of the encoded program, not source-file positions or runtime memory addresses.

examples/label_offsets.asm contains:

entry: mov eax,42; done: ret;

Its layout is:

StatementByte offsetEncoded size
entry:00
mov eax,42;05
done:50
ret;51

The total size is six bytes. Labels do not advance the offset, so consecutive labels name the same location. Blocks do not introduce a separate label scope. Label names are case-sensitive; repeating a name anywhere in the program reports duplicate label during layout.

After layout, label_offset() from include/layout.h looks up a label token and returns its byte offset through an output parameter. It returns 1 when found, or reports undefined label and returns 0 when absent. For this example, entry resolves to 0 and done to 5. The encoder uses this lookup API to resolve jump targets after all statements have received offsets.

To check this sample’s encoded output after rebuilding:

cmake "-DKASM=build/windows-debug/Debug/kasm.exe" "-DSOURCE=examples/label_offsets.asm" "-DEXPECTED_HEX=B8 2A 00 00 00 C3" -P tests/encode_file.cmake

This reusable test verifies the emitted bytes, not the stored offsets or lookup results. The CLI does not print offsets; inspecting Statement.offset or calling label_offset() in a C test checks those directly.

Near jumps and forward references

jmp near <label>; encodes an unconditional near jump as E9 followed by a signed 32-bit displacement in little-endian order. Every near jump occupies five bytes; the assembler does not automatically choose a shorter encoding.

The displacement is relative to the end of the jump instruction:

displacement = target offset - (jump offset + 5)

Layout assigns offsets to the entire program before encoding, so a target may be defined before or after the jump. A backward jump has a negative displacement. An absent target reports undefined label; a displacement outside the signed 32-bit range reports near jump outside signed 32-bit range.

examples/near_jump.asm demonstrates a forward reference:

jmp near answer; mov eax,99; answer: mov eax,42; ret;
StatementByte offsetEncoded size
jmp near answer;05
mov eax,99;55
answer:100
mov eax,42;105
ret;151

The displacement is 10 - (0 + 5) = 5. The full 16-byte encoding is:

E9 05 00 00 00 B8 63 00 00 00 B8 2A 00 00 00 C3

The jump skips the MOV that sets eax to 99 and lands on the MOV that sets it to 42. Test the expected bytes from the repository root after rebuilding:

cmake "-DKASM=build/windows-debug/Debug/kasm.exe" "-DSOURCE=examples/near_jump.asm" "-DEXPECTED_HEX=E9 05 00 00 00 B8 63 00 00 00 B8 2A 00 00 00 C3" -P tests/encode_file.cmake

Short jumps and explicit jump syntax

Jump instructions require a lowercase near, short, or abs modifier followed by a label name and a semicolon. Omitting the modifier reports expected near, short, or abs after jmp. The internal statement kinds are ST_NEAR_JMP, ST_SHORT_JMP, and ST_ABS_JMP.

Source syntaxOpcodeDisplacementTotal size
jmp near label;E9Signed 32-bit, little-endian5 bytes
jmp short label;EBSigned 8-bit, -128 through 1272 bytes

The abs form uses an indirect jump and an address slot, described below.

A short jump uses target offset - (jump offset + 2). The assembler checks the range and reports short jump outside signed 8-bit range if the target is too far away. It does not automatically widen a short jump to a near jump.

examples/short_jump.asm contains:

jmp short answer; mov eax,99; answer: mov eax,42; ret;

Here answer is at offset 7 and the jump ends at offset 2, so the displacement is +5. The complete output is 13 bytes, three fewer than the near-jump version:

EB 05 B8 63 00 00 00 B8 2A 00 00 00 C3

Run the byte check from the repository root after rebuilding:

cmake "-DKASM=build/windows-debug/Debug/kasm.exe" "-DSOURCE=examples/short_jump.asm" "-DEXPECTED_HEX=EB 05 B8 63 00 00 00 B8 2A 00 00 00 C3" -P tests/encode_file.cmake

Absolute indirect jumps

jmp abs <label>; emits a six-byte RIP-relative indirect jump followed by an eight-byte address slot. Layout reserves all fourteen bytes:

FF 25 00 00 00 00 | eight-byte target slot

The four zero displacement bytes make the instruction read its destination from the slot immediately after it. The slot initially contains the target label’s image offset in little-endian order. The encoder records a relocation at statement offset + 6; the loader replaces the stored offset with the loaded image’s base address plus that offset before execution. The CPU jumps to the address it reads; it does not execute the slot as instructions.

This uses a full 64-bit destination rather than a signed relative displacement. It is an indirect near jump in x86 terminology, not a segment-changing far jump. The current source operand must still name a label in the same image.

For example:

jmp abs answer; mov eax,99; answer: mov eax,42; ret;

answer is at offset 19 (0x13). The 25-byte image before relocation is:

FF 25 00 00 00 00 13 00 00 00 00 00 00 00 B8 63 00 00 00 B8 2A 00 00 00 C3

The generated header has patch_count = 1 and patch_offsets[] = {6}. The encoder allows up to 256 patches and reports too many relocation patches before exceeding that capacity. An unknown target reports undefined label.

To try this example, save it as examples/abs_jump.asm, rebuild Kasm, and run from the repository root:

cmake "-DKASM=build/windows-debug/Debug/kasm.exe" "-DSOURCE=examples/abs_jump.asm" "-DEXPECTED_HEX=FF 25 00 00 00 00 13 00 00 00 00 00 00 00 B8 63 00 00 00 B8 2A 00 00 00 C3" -P tests/encode_file.cmake

For execution, follow the runner commands in Generated C data, execution, and decoding, substituting abs_jump.asm for program.asm when generating the header beside the runner. Relocation is required before executing this image. The example’s exact bytes and patch metadata were checked, and the relocated image executed in WSL2 with result = 42.

Changing EAX and branching on flags

inc eax; adds one to EAX at runtime, and dec eax; subtracts one. Both require lowercase eax; other registers report only register eax is supported. Unlike mov eax,43-1;, which evaluates its expression during assembly, mov eax,43; dec eax; performs the subtraction when the generated code runs.

InstructionEncodingSize
inc eax;FF C02 bytes
dec eax;FF C82 bytes

These instructions perform 32-bit arithmetic with wraparound: decrementing zero produces FFFFFFFF, and incrementing that value produces zero. They update arithmetic flags, including ZF (set when the result is zero), while preserving the carry flag. Assembly-time expression range checks still apply to MOV expressions; they do not limit runtime arithmetic.

jz <label>; jumps when ZF is 1, and jnz <label>; jumps when ZF is 0. jb <label>; jumps when CF is 1, and jl <label>; jumps when SF differs from OF. Otherwise execution continues with the next instruction. These instructions read the existing flags; they do not themselves test EAX or change the flags. Unlike jmp, they take a label directly, with no near, short, or abs modifier.

InstructionOpcodeDisplacementTotal size
jz label;0F 84Signed 32-bit, little-endian6 bytes
jnz label;0F 85Signed 32-bit, little-endian6 bytes
jb label;0F 82Signed 32-bit, little-endian6 bytes
jl label;0F 8CSigned 32-bit, little-endian6 bytes

All four resolve labels after layout and calculate target offset - (instruction offset + 6). Missing targets report undefined label; out-of-range displacements report conditional jump outside signed 32-bit range.

For example, this loop counts down from three and returns zero:

mov eax,3;
again:
dec eax;
jnz again;
ret;

The label is at offset 5 and JNZ ends at offset 13, so the displacement is -8:

B8 03 00 00 00 FF C8 0F 85 F8 FF FF FF C3

To check it with the reusable script, save the snippet as examples/countdown.asm and run from the repository root after rebuilding:

cmake "-DKASM=build/windows-debug/Debug/kasm.exe" "-DSOURCE=examples/countdown.asm" "-DEXPECTED_HEX=B8 03 00 00 00 FF C8 0F 85 F8 FF FF FF C3" -P tests/encode_file.cmake

Manual byte and decode checks covered INC, DEC, forward conditional branches, the backward loop, and forward and backward JB/JL targets. WSL2 execution verified 43 decrementing to 42, 41 incrementing to 42, zero returning to zero after DEC then INC, both taken and untaken paths for JZ and JNZ, and the countdown returning zero. Invalid register operands were also rejected. The branch tests exercise ZF behavior; JB/JL runtime flags and other flags, including carry preservation, were not directly measured.

Comparing EAX and setting flags

cmp eax, <expression>; compares EAX with a signed 32-bit immediate without changing EAX. It updates the arithmetic flags as if the immediate were subtracted from EAX, including ZF, which allows a following conditional jump to test the result. The operand must be lowercase eax; other registers report only register eax is supported.

InstructionEncodingImmediateSize
cmp eax, expression;3DSigned 32-bit, little-endian5 bytes

The expression is evaluated during assembly and must be in the signed 32-bit range. For example:

mov eax,42;
cmp eax,40+2;
ret;

This produces the following bytes and leaves EAX unchanged at runtime:

B8 2A 00 00 00 3D 2A 00 00 00 C3

The decoded listing includes cmp eax, 42.

Blocks

Braces group statements into blocks, which can contain other blocks:

mov eax, 1;
{
    // Blank lines and comments are allowed inside blocks.
    mov eax, 40+2;

    {
        mov eax, (2+3)*4;
    }
}
{ mov eax, 0b00000111; }
{} // Empty blocks are valid too.

This prints:

B8 01 00 00 00 B8 2A 00 00 00 B8 14 00 00 00 B8 07 00 00 00

Statements retain their source order in one flat statement array; blocks do not create scopes or separate AST nodes. Each MOV still needs its semicolon, but a closing brace takes no semicolon.

Blocks may nest up to MAX_BLOCK_DEPTH, currently 16 in include/program.h. This guard bounds recursive parser calls to protect the C call stack. A 17th nested block reports blocks nested too deeply. Missing and extra closing braces report expected closing brace and unexpected closing brace, respectively.

The expression parser supports integer literals, unary -, binary + and -, multiplication (*), and parentheses. Multiplication binds more tightly than addition and subtraction; operators at the same precedence associate left to right. Thus 2+3*4 parses as 2+(3*4), while (2+3)*4 groups the addition first. Parentheses may nest up to 32 levels. Unary +, symbols, division, and other expression operators are not supported yet.

Each MOV, ADD, SUB, OR, AND, ADC, CMP, or INT statement references its expression’s root in the parser’s node array. Statement storage grows dynamically, starting at 16 entries and doubling as needed; there is no fixed 256-statement limit. Source files are stored in a dynamically growing buffer.

The CLI exits with status 0 after successful encoding and file output, and 1 on a loading, lexing, parsing, semantic, allocation, or file-output error, or incorrect command-line usage. Diagnostics go to stderr.

Opcode lookup guide

This quick reference is useful when you are extending the encoder and decoder for another register form. The general pattern is: parse the register, store a small numeric register code, then switch on that code during encoding and match the byte patterns during decoding.

Source formEncoded bytesMeaning
mov eax, imm32;B8 imm32Move a 32-bit immediate into eax.
mov ecx, imm32;B9 imm32Move a 32-bit immediate into ecx.
add eax, imm32;05 imm32Add a signed 32-bit immediate to eax.
add ecx, imm32;81 C1 imm32Add a signed 32-bit immediate to ecx.
sub eax, imm32;2D imm32Subtract a signed 32-bit immediate from eax.
sub ecx, imm32;81 E9 imm32Subtract a signed 32-bit immediate from ecx.
or eax, imm32;0D imm32Bitwise OR with eax.
xor eax, imm32;35 imm32Bitwise XOR with eax.
and eax, imm32;25 imm32Bitwise AND with eax.
cmp eax, imm32;3D imm32Compare eax against the immediate.
adc eax, imm32;15 imm32Add with carry into eax.

The second byte in the 0x81 family decides the exact operation and register:

  • 81 C1 = add ecx, imm32
  • 81 E9 = sub ecx, imm32

This is the same idea as the decoder: match the emitted bytes in reverse, then print back the matching source instruction. In other words, the decoder is the mirror image of the encoder for these register-aware instruction forms.

Semantic checking and encoding

Semantic checking and evaluation

The semantic checker evaluates expression nodes in dependency order, then validates operands and stores each MOV, ADD, SUB, OR, AND, ADC, CMP, or INT immediate in Statement.value. For example:

mov eax, (10+4)*3;

Produces:

B8 2A 00 00 00

MOV and arithmetic/bitwise instructions require eax; PUSH/POP require rax. Every literal and intermediate expression result must fit the signed 32-bit range -2147483648..2147483647. The final MOV immediate must also be nonnegative, giving an accepted range of 0..2147483647. These are the current language restrictions. The lexer’s larger literal range does not bypass semantic checking.

Examples rejected by the semantic checker:

InputDiagnostic
mov ebx, 7;only register eax is supported
mov eax, 1-2;mov immediate must be nonnegative in this language
mov eax, 2147483647+1;expression outside signed 32-bit range

Intermediate results are checked too: mov eax, (2147483647+1)-1; is rejected even though its final mathematical result would fit. Evaluation computes values for later encoding; it does not execute instructions or modify CPU registers.

Encoding and current limitations

The encoder emits five bytes per MOV: opcode B8, followed by the evaluated immediate as four bytes in little-endian order. For example, 42 becomes 2A 00 00 00. RET emits one byte, C3. The byte buffer grows dynamically as instructions are appended.

The current language supports MOV, ADD, SUB, OR, AND, ADC, CMP, INC, and DEC on eax, PUSH/POP on rax, INT with an immediate vector, operand-free RET, short, near, or absolute indirect JMP, and near JZ/JNZ/JB/JL to a label. Far jumps, short conditional jumps, other condition codes, other instructions and registers, labels in expressions, memory operands, directives beyond DB/DW/DD/DQ and their aliases, and object or executable file formats beyond COFF are not implemented. MOV immediates must be in 0..2147483647; ADD/SUB/OR/ADC accept signed 32-bit expression results, subject to the expression restrictions below. INT requires a final value in 0..255. Blocks provide grouping, not scope or control flow. Empty input or empty blocks print an empty hex line and Decoding successful., write an empty binary, and generate a header with code_size = 0 and a placeholder array element so the declaration remains valid C.

ADD and SUB immediate expressions

add eax, <expression>; adds the evaluated immediate to EAX at runtime; sub eax, <expression>; subtracts it. Only lowercase eax is accepted. The parser requires a comma and a final semicolon, just as for MOV.

InstructionOpcodeImmediateTotal size
add eax,7;0507 00 00 005 bytes
sub eax,7;2D07 00 00 005 bytes

The destination EAX is implicit in these opcodes. The immediate is four bytes in little-endian order. Layout reserves five bytes for either instruction, and the decoder prints the immediate as a signed value.

ADD/SUB accept expression results in -2147483648..2147483647; MOV retains its nonnegative restriction. Every literal and intermediate result must still fit the existing signed 32-bit expression limits. Unary minus can be written as -1 or -(1+1). Runtime arithmetic wraps to 32 bits and updates arithmetic flags, including carry; it does not use the assembler’s expression-overflow checks.

examples/add.asm loads 35 and adds 3+4, returning 42:

mov eax,35;
add eax,3+4;
ret;

Its bytes are B8 23 00 00 00 05 07 00 00 00 C3. The permanent encode.add test checks these bytes and the listing in tests/add_expected.txt. Run it after building:

ctest --test-dir build/windows-debug -C Debug -R encode.add --output-on-failure

The SUB counterpart mov eax,49; sub eax,3+4; ret; produces B8 31 00 00 00 2D 07 00 00 00 C3. Both examples returned 42 in manual WSL2 execution checks. SUB’s bytes and listing were also checked manually; SUB does not yet have a permanent CTest entry. These runtime checks did not measure flags.

Bitwise OR

or eax, <expression>; combines the current EAX value with the evaluated immediate, setting each result bit if that bit is set in either operand. It requires eax, a comma, an expression, and a semicolon. The expression uses the same signed 32-bit limits as ADD/SUB; a negative result supplies its 32-bit two’s-complement bit pattern. This adds a runtime instruction, not a new expression operator.

The encoding is 0D followed by four immediate bytes in little-endian order, for a total of five bytes. The decoder reads the immediate and prints or eax, <value> with a signed decimal value.

mov eax,40;
or eax,2;
ret;

The program produces B8 28 00 00 00 0D 02 00 00 00 C3. The binary patterns 00101000 (40) and 00000010 (2) combine to 00101010 (42). Exact bytes and the decoded listing were checked manually, and WSL2 execution returned result = 42. These checks are not yet registered as a permanent CTest test, and flags were not directly tested.

Bitwise AND

and eax, <expression>; combines the current EAX value with the evaluated immediate, clearing each result bit unless it is set in both operands. It requires eax, a comma, an expression, and a semicolon. The expression uses the same signed 32-bit limits as ADD/SUB/OR; a negative result supplies its 32-bit two’s-complement bit pattern.

The encoding is 25 followed by four immediate bytes in little-endian order, for a total of five bytes. The decoder reads the immediate and prints and eax, <value> with a signed decimal value.

mov eax,42;
and eax,-1;
ret;

This produces B8 2A 00 00 00 25 FF FF FF FF C3. Unary negative expressions are supported, so -1 is equivalent to 0-1.

Adding with carry

adc eax, <expression>; adds the evaluated immediate and the current carry flag (CF) to EAX at runtime:

EAX = EAX + immediate + CF

It requires lowercase eax and accepts signed 32-bit expression results under the same expression limits as ADD/SUB. Its encoding is 15 followed by four immediate bytes in little-endian order, for five bytes total. For example, adc eax,2+3; emits 15 05 00 00 00 and decodes as adc eax, 5.

The CPU performs 32-bit arithmetic and updates arithmetic flags, including CF. ADC consumes the incoming carry and produces a new carry, which lets additions propagate carry between parts of a larger number. MOV does not change CF, so loading EAX alone does not establish a known carry value.

This example explicitly sets carry before ADC:

mov eax,0;
sub eax,1;
mov eax,10;
adc eax,2+3;
ret;

Subtracting one from zero sets CF; the following MOV preserves it. ADC therefore computes 10 + 5 + 1 and returns 16. Change the first instruction to mov eax,1; and SUB clears CF, so the program returns 15 instead.

Both versions passed exact-byte and decoded-listing checks, and WSL2 execution returned 16 and 15 respectively. The existing 15 Windows CTest tests also passed. The ADC checks are currently temporary manual checks under build/adc-check, not permanent CTest entries. They verify incoming carry behavior, but do not directly measure the outgoing flags.

Software interrupt encoding

int <expression>; takes one immediate operand, with no register or comma. The internal statement kind is ST_INT_IMM8. The expression is evaluated during assembly and must produce a value in 0..255; values outside that range report interrupt vector must be in range 0..255. Existing expression limits still apply to literals and intermediate results.

The encoding is opcode CD followed by a single unsigned vector byte. Layout reserves two bytes, and the decoder displays the vector in hexadecimal:

SourceBytesDecoded instruction
int 0;CD 00int 0x00
int 0x10;CD 10int 0x10
int 8+8;CD 10int 0x10
int 255;CD FFint 0xFF

To check encoding, save int 0x10; as examples/interrupt.asm and run from the repository root after rebuilding:

cmake "-DKASM=build/windows-debug/Debug/kasm.exe" "-DSOURCE=examples/interrupt.asm" "-DEXPECTED_HEX=CD 10" -P tests/encode_file.cmake

This test assembles and decodes the bytes; it does not execute the interrupt. INT support alone does not add a real-mode target, boot-image generation, or BIOS services to the existing x86-64 Windows/WSL2 runner. BIOS-style use of int 0x10 needs the appropriate execution environment and CPU mode.

Manual tests checked the four valid cases above, each followed by RET to verify the next decoded offset is 2. Negative (0-1) and oversized (256) vectors were rejected. All 15 existing CTest tests passed. The temporary INT checks live under build/int-check; they are not permanent CTest entries, and no interrupts were executed.

Saving and restoring RAX

push rax; emits 50, and pop rax; emits 58. Both occupy one instruction byte, but in the x86-64 runner they transfer an eight-byte register value to or from the stack. Their source operand must be rax, not eax; other operands report push/pop require register rax. They have no immediate expression.

A balanced sequence can save a value while another instruction changes EAX:

mov eax,42;
push rax;
mov eax,99;
pop rax;
ret;

The expected result is 42. Restore the stack before ret so it reads the caller’s return address. The assembler does not verify stack balance.

Generated C data, execution, and decoding

Generated C data and execution

The C writer wraps the encoded bytes in program.h, including an include guard and <stddef.h>. For examples/program.asm, the declarations contain these values (shown compactly):

static const unsigned char code[] = {
    0xB8, 0x2A, 0x00, 0x00, 0x00, 0xC3,
};
static const size_t code_size = 6;
static const size_t patch_offsets[] = {0};
static const size_t patch_count = 0;

The examples below require a source checkout and a C compiler. After building Kasm with the GCC-debug preset, run these commands from the repository root on Linux x86-64 or x86-64 WSL2:

cd examples/generated
../../build/GCC-debug/kasm ../program.asm
cc -std=c11 inspect.c -o inspect
./inspect

inspect.c includes the generated header and prints its contents without executing them:

Size: 6
B8 2A 00 00 00 C3

To execute the same example:

cc -std=c11 -I../../include runner.c -o runner
./runner

Expected output:

result = 42

runner.c allocates writable memory with mmap, copies the array into it, applies relocation patches, changes the memory to readable and executable with mprotect, calls it as an int function with no arguments, and releases the memory with munmap. The example sets the return value in eax and returns with ret.

Windows x86-64

Open a Visual Studio Developer Command Prompt. Initialize the x64 tools before compiling; an x86 compiler produces a 32-bit runner even on 64-bit Windows. Starting from the repository root after building Kasm:

call "%VSINSTALLDIR%VC\Auxiliary\Build\vcvarsall.bat" x64
cd examples\generated
..\..\build\windows-debug\Debug\kasm.exe ..\program.asm
cl /W4 /std:c11 inspect.c /Fe:inspect.exe
inspect.exe
cl /W4 /std:c11 /I..\..\include runner.c /Fe:runner.exe
runner.exe

The compiler banner should say for x64. The inspector prints the same six bytes as on Linux, and the runner prints result = 42.

The Windows runner uses VirtualAlloc to allocate writable memory, copies and relocates the bytes, then uses VirtualProtect to make it readable and executable, FlushInstructionCache before calling the function, and VirtualFree to release the allocation.

Both runner implementations target x86-64 and use a platform-specific function-pointer conversion. They reject empty programs and non-x86-64 processes. Use a complete function such as examples/program.asm, whose final instruction is ret;. After changing the assembly, regenerate the header and recompile the C examples so they use the new bytes.

Load-time relocation

The helper in include/relocate.h converts an image-relative offset into an absolute address after the loader knows where the image resides:

patched address = load address + stored image offset

Each entry in patch_offsets identifies the start of an eight-byte field in the loaded image. relocate() reads that field as a little-endian unsigned 64-bit offset, adds the image’s base address, and writes the resulting address back into the same field. The patch location and the target offset are distinct: a patch at offset 0 containing the value 8 becomes base + 8 stored at offset 0.

The helper rejects patches whose eight-byte fields extend outside the image and targets at or beyond the image size. A target outside the image reports relocation target outside code. With zero patches, it leaves the image unchanged. Apply relocation once to a freshly copied image while the memory is writable, before changing its protection and executing it.

Generated headers now include patch_offsets and patch_count alongside code and code_size. A zero-count patch array contains a placeholder zero; that placeholder is not applied. program.bin contains only image bytes, without the patch table. Current relative jumps need no relocation because their source and target move together when the image is loaded.

The encoder adds one relocation entry for each jmp abs address slot. Programs using only MOV, RET, and relative jumps still generate patch_count = 0. A small demonstration in main.c patches a separate 16-byte buffer; it does not add a relocation to the assembled program. The absolute-jump example in The instruction set and parser exercises relocation of actual generated code.

For the runner workflow, regenerate program.h from an existing example such as program.asm using the commands above, then recompile the runner. The include path option is required to find relocate.h. Running the reusable encoding test writes its header under build/example-tests, not beside the example runner.

Manual checks on Windows x64 and WSL2 verified the patched value equals the buffer address plus 8, rejection of invalid patch bounds and an out-of-range target, and unchanged data for zero patches. Both runners compiled and returned 42 with an ordinary program containing no relocation entries. In 0.12.0, the WSL2 runner also executed the absolute-jump example with one actual relocation and returned 42. These manual checks supplement the CTest suite; they are not currently registered as CTest tests.

Decoding the generated bytes

The decoder inspects the encoded byte buffer after the CLI has written its output files. It displays instruction offsets and reconstructed operands; it does not execute the program. Offsets and targets are decimal, with instruction offsets padded to at least four digits.

examples/decode.asm contains:

mov eax,3; loop: dec eax; jnz loop; mov eax,42; ret;

The complete CLI output is:

B8 03 00 00 00 FF C8 0F 85 F8 FF FF FF B8 2A 00 00 00 C3
0000: mov eax, 3
0005: dec eax
0007: jnz target=5
0013: mov eax, 42
0018: ret
Decoding successful.

The JNZ displacement is -8: adding it to the instruction’s end at offset 13 recovers target offset 5. Original label names, comments, and expression spelling cannot be recovered from the bytes.

The current decoder recognizes MOV, ADD, SUB, OR, ADC, and CMP EAX immediate, INT imm8, PUSH/POP RAX, RET, DEC/INC EAX, short and near JMP, near JZ/JNZ/JB/JL, and Kasm’s fourteen-byte absolute-jump convention. For the latter, it reads the unrelocated address slot as an image offset and skips all fourteen bytes. Its display still uses jmpabs image-offset=...; relative JMP displays jmp target=.... This is inspection output, not source in Kasm’s explicit jmp abs, jmp near, or jmp short syntax.

Unknown or truncated encodings print unknown or truncated encoding and cause the CLI to exit with status 1, even though the output files have already been written. This decoder is limited to its supported patterns; it is neither a general x86 disassembler nor a verifier that code is safe to execute.

The decode.example CTest checks both the exact 19-byte image and the entire listing, including the backward target and success message. Expected text is stored in tests/decode_expected.txt. Run it after building:

ctest --test-dir build/windows-debug -C Debug -R decode.example --output-on-failure

The test writes its artifacts to build/windows-debug/examples/Debug/decode, separately from other examples. To reuse the script directly:

cmake "-DKASM=build/windows-debug/Debug/kasm.exe" "-DSOURCE=examples/decode.asm" "-DEXPECTED_HEX=B8 03 00 00 00 FF C8 0F 85 F8 FF FF FF B8 2A 00 00 00 C3" "-DEXPECTED_DECODE_FILE=tests/decode_expected.txt" -P tests/encode_file.cmake

The lexer

The implementation is in src/lexer.c, with the public token types and API in include/lexer.h.

Identifiers

Identifiers follow [A-Za-z_][A-Za-z0-9_]*, for example mov, R0, _start, and label_2. All names produce TK_IDENT; there are no dedicated tokens for instructions, registers, directives, or keywords. Spelling is preserved, and the token_is() helper compares it case-sensitively.

Integer literals

All supported integer forms produce TK_NUMBER with a decoded long long value.

BaseSyntaxExampleValue
DecimalDigits beginning with 1 through 94242
Hexadecimal0x or 0X, then one or more hexadecimal digits0x2A42
Binary0b or 0B, then one or more binary digits0b10101042
OctalLeading 0, followed by zero or more octal digits05242

0 is valid and has value zero. Hexadecimal digits accept both letter cases. Values must fit in 0..LLONG_MAX (9223372036854775807 on the current targets). Signs are separate tokens: -42 becomes TK_MINUS, then TK_NUMBER(42). Consequently, the magnitude in -9223372036854775808 exceeds the literal limit and is rejected before parsing.

The lexer consumes consecutive ASCII letters, digits, and underscores after a number starts, and rejects the whole literal if any are invalid for its base. Examples of rejected literals include 0x, 0b, 0b102, 08, 0xGG, 123abc, and 1_000. Numeric suffixes, digit separators, floating-point literals, and an explicit 0o octal prefix are not supported. Punctuation can directly follow a number: 42+7 produces three tokens.

Punctuation

CharacterToken kind
+TK_PLUS
-TK_MINUS
*TK_STAR
(TK_LPAREN
)TK_RPAREN
,TK_COMMA
;TK_SEMI
{TK_LBRACE
}TK_RBRACE
:TK_COLON

The parser uses arithmetic operators, parentheses, commas, semicolons, and braces. The statement parser recognizes an identifier followed by a colon as a label definition, for example label:. A label name can also be the operand of jmp. Other punctuation is rejected, including a standalone /, ., =, [ and ]. String and character literals are not supported.

Newlines, whitespace, and comments

  • Spaces, tabs, vertical tabs, and form feeds are skipped.
  • LF, CRLF, and lone CR produce TK_NEWLINE (token ID 13). CRLF is one token spanning two bytes; blank lines also produce newline tokens. No newline is inserted at EOF.
  • // starts a comment that ends before LF, CRLF, lone CR, or EOF. The terminating newline remains available as a token.
  • /* ... */ comments can span lines and end at the first */; they do not nest. Newlines inside block comments are skipped with the comment.
  • Comments produce no tokens and can touch other tokens. a/**/b produces two identifiers.
  • A semicolon is a TK_SEMI token, not a comment marker.

An unterminated /* comment is an error, with a span from the opening slash through EOF. Adjacent comments are skipped iteratively, without recursive calls.

Source limits, token spans, and errors

The source loader stores input in a dynamically growing buffer. There is no project-defined source-byte limit; practical limits are available memory and the platform’s allocation limits. It rejects embedded NUL bytes and non-ASCII input, including a UTF-8 BOM. Files are read in binary mode, preserving their original byte offsets.

Each Token contains its kind, a zero-based half-open byte span [start, end), and a numeric value. The span selects the original spelling in Source.text; it excludes skipped whitespace and comments. A TK_NEWLINE span covers the original line-ending bytes. Non-number tokens have value zero. TK_END marks EOF with an empty span.

An invalid character, malformed integer, or integer overflow sets Lexer.failed and reports invalid character or integer out of range. An unclosed block comment sets the same failure flag and reports unterminated block comment. Diagnostics go to stderr and include the file path, one-based line and column, and byte span. LF, CRLF, and lone CR each advance the line count once. Columns count bytes; tabs advance one column. Lexing stops on the first error, with no error recovery.

Using the lexer from a parser

  • lexer_start(&lexer, &source) initializes the lexer and reads the first token.
  • Inspect lexer.token, then call lexer_next(&lexer) to advance. Stop when lexer.failed is set or the token kind is TK_END.
  • token_is(&source, token, "word") compares the token’s source text exactly; it does not check the token kind, so check TK_IDENT separately when needed.
  • Keep the Source alive while using the lexer and resolving token spans.

Check failed before using a token: a malformed number still has kind TK_NUMBER, while invalid punctuation and unterminated block comments leave kind TK_END. Calling lexer_next() after failure returns TK_END without resuming scanning.

The statement parser skips TK_NEWLINE between statements and blocks at every nesting level. Block comments act as whitespace, including when they span multiple lines. EOF is accepted after the last semicolon or closing brace without a trailing newline.

Inspecting tokens

With BUILD_TESTING=ON, the dedicated lexer test driver prints tokens:

.\build\windows-debug\Debug\lexer_test_driver.exe tokens.asm

On Linux, use ./build/GCC-debug/lexer_test_driver tokens.asm. For a file containing label: 42+7; followed by an LF newline, the driver prints:

token 11 [0,5) value=0
token 10 [5,6) value=0
token 12 [7,9) value=42
token 1 [9,10) value=0
token 12 [10,11) value=7
token 7 [11,12) value=0
token 13 [12,13) value=0

The numeric token IDs come from the current TokenKind enum. EOF is not printed. With CRLF, the final newline span is [12,14); without a trailing newline, the final token 13 line is absent. The driver exits with status 0 on successful lexing and 1 on a loading or lexing error (or incorrect command-line usage).

Data directives and layout expressions

Data directives emit values directly, without an instruction opcode:

SpellingBytes per valueAccepted result
db or byte10 through 255
dw or word20 through 65535
dd or dword40 through 2147483647 (current expression limit)
dq or qword80 through 2147483647 (current expression limit)

All four accept comma-separated expressions terminated by a semicolon. Multi-byte values use little-endian byte order, with no automatic alignment or padding.

db 60+5,66,0;       // 41 42 00
byte 0xAA,0x55;    // AA 55
dw 4660;           // 34 12
word 0x55AA;       // AA 55
dd 0x12345678;     // 78 56 34 12
dword 40+2;        // 2A 00 00 00
dq 42;            // 2A 00 00 00 00 00 00 00
qword 0;          // 00 00 00 00 00 00 00 00

Each word list element occupies two bytes: word 0xAA,0x55; emits AA 00 55 00. Empty lists, trailing commas, missing commas, negative results, and results above the directive’s range are rejected. The existing signed 32-bit expression rules still apply to intermediate calculations. DD and DQ store four and eight bytes respectively, but full unsigned 32-bit and 64-bit expression values are not yet supported. Even though their current range diagnostics name wider storage limits, the expression evaluator rejects results above 2147483647 first.

Prefix a data directive with times <count> or fill <count> to repeat its complete comma-separated list. The count is an expression, must be nonnegative, and must fit size_t.

times 3 db 170;          // AA AA AA
fill 2 dw 4660,0;        // 34 12 00 00 34 12 00 00
times 2 dd 42;           // 2A 00 00 00 2A 00 00 00

Labels include the size of preceding data. Executable examples must jump over embedded data, as in examples/data_all_jump.asm, which mixes all four widths. Data-only examples are encoding fixtures, not functions to execute.

The CLI writes source-named .bin and .h files for images containing data, but prints Data emitted; instruction-only decoding skipped. instead of trying to disassemble the image. Instruction-only images retain their decoded listing. TIMES/FILL repetition is supported for data directives. Strings, alignment, and symbol-valued data remain future work.

Generated images are capped at 16 MiB (16,777,216 bytes) by KASM_IMAGE_LIMIT. This output limit includes instructions and repeated data; it is separate from dynamically allocated source-file storage.

Location-aware padding

The assembler also supports $ and $$ inside repeat-count expressions:

mov eax, 42;
times 510-($-$$) db 0;
dw 0xAA55;

$ is the current image offset and $$ is the image start, which is offset zero for the current single-image layout. The count is resolved during layout, after earlier statements have known sizes. In this example, mov eax, 42; is five bytes, so the padding count is 510 - (5 - 0) = 505, followed by the little-endian signature bytes 55 AA.

This produces a 512-byte data image. It is a tested layout fixture, not yet a bootable sector: a signature alone does not define an entry convention, segment state, stack, BIOS services, or an emulator workflow. Negative padding is rejected before the repeat count is converted to size_t.

Permanent tests cover all eight spellings, expression values, range boundaries, exact bytes, mixed-width layout, jump targets, constant and location-aware TIMES/FILL repetition, and invalid lists and ranges. The Windows Debug build validates the data-directive suite, including encode.times_fill, encode.boot_pad, and invalid-input cases within semantic.data_invalid. After configuring and building, run them with:

ctest --test-dir build/windows-debug -C Debug -R "(encode.data_|encode.times_fill|encode.boot_pad|semantic.data_invalid)" --output-on-failure

Symbol-valued data (label expressions inside directives) resolves after layout as of 0.33.0; constant-only expressions retain the checked arithmetic described in Control instructions and overflow safety.

The 16-bit instruction set

Use --bits 16 for the MOV, ADD, and SUB forms on AX, CX, and DX described below. The default remains --bits 64, using EAX, ECX, and EDX for these operations. 32-bit target mode is still unsupported. Operand width is stored on each statement so layout and emission agree on instruction length.

MOV, ADD, and SUB on AX/CX/DX

16-bit formSizeExample bytes
mov ax,42;3B8 2A 00
mov cx,7;3B9 07 00
mov dx,3;3BA 03 00
add ax,7;305 07 00
sub cx,3;481 E9 03 00
add dx,7;481 C2 07 00

The decoder receives the target mode: the same MOV opcode has a two-byte immediate in these 16-bit forms and a four-byte immediate in the supported 64-bit forms. ADD/SUB register forms also include a ModR/M byte where needed.

Run the permanent MOV fixture after configuring and building:

ctest --test-dir build/windows-debug -C Debug -R "^encode.mode16_mov$" --output-on-failure

Run the arithmetic example with the reusable file test from the repository root:

cmake "-DKASM=build/windows-debug/Debug/kasm.exe" "-DSOURCE=examples/mode16_add_sub.asm" "-DBITS=16" "-DEXPECTED_HEX=05 07 00 81 E9 03 00 81 C2 07 00" -P tests/encode_file.cmake

For WSL2, use -DKASM=build/GCC-debug/kasm and the same source, mode, and expected bytes. Outputs go into the test’s isolated directory under build/example-tests. Omitting BITS from the helper still selects 64-bit mode.

These are encoding/decoding checks, not execution tests. The existing hosted runners execute x86-64 code and must not be used to execute these 16-bit images. This does not establish general 16-bit instruction support, complete operand-range validation, or a bootable-program workflow; other instructions still need mode-specific review.

Validation: all 32 registered tests passed in WSL2 with GCC, and the 16-bit arithmetic example matched its expected bytes in both Windows and WSL2. The seven targeted Windows decoder/encoding regressions also passed. The obsolete blanket 16-bit rejection test now checks rejection of EAX in 16-bit mode. The GCC build also caught and prompted a missing <string.h> fix.

Indirect MOV from [bx]

16-bit mode supports loading AX, CX, or DX from the [bx] memory operand. For example, mov ax, [bx]; emits 8B 07 and is decoded back to the same instruction. This feature is intentionally limited to [bx]; other memory addressing forms remain unsupported.

Run the focused regression test from the repository root:

cmake "-DKASM=build/windows-debug/Debug/kasm.exe" "-DSOURCE=examples/mode16_mov_indirect.asm" "-DBITS=16" "-DEXPECTED_HEX=8B 07" -P tests/encode_file.cmake

Boot-sector image fixture

The 16-bit boot-sector example combines instruction encoding, location-aware padding, and binary literal parsing:

mov ax, 42;
add ax, 3;
times (512-0b00000010)-($-$$) db 0;
dw 0xAA55;

The resulting image is designed to be exactly 512 bytes, with zero-filled padding and the boot signature 55 AA in its final two bytes. The image begins with the two 16-bit instructions and is suitable for loading at 0x7C00 in a firmware emulator or raw-image virtual machine. It is a tested layout fixture, not yet a bootable sector — see Data directives and layout expressions for what “not yet bootable” specifically excludes.

Segment-register push/pop

16-bit mode supports pushing and popping the segment registers covered by the current instruction subset. The legacy registers use one-byte opcodes, while FS and GS use two-byte 0F opcode sequences:

InstructionBytes
push es / pop es06 / 07
push cs0E
push ss / pop ss16 / 17
push ds / pop ds1E / 1F
push fs / pop fs0F A0 / 0F A1
push gs / pop gs0F A8 / 0F A9

pop cs is not encoded because it has no valid modern x86 instruction form. The exact-byte segment-register regression is run with:

ctest --test-dir build/windows-debug -C Debug -R "^encode.mode16_segment$" --output-on-failure

Subtract-with-borrow

16-bit mode supports immediate sbb for AX, CX, and DX. The instruction uses the 81 /3 opcode family, so the register code is stored in the low three bits of the ModR/M byte:

InstructionBytes
sbb ax, 181 D8 01 00
sbb cx, 281 D9 02 00
sbb dx, 381 DA 03 00

The immediate is little-endian and the decoder prints signed 16-bit values. The exact-byte and exact-listing regression is run with:

ctest --test-dir build/windows-debug -C Debug -R "^encode.mode16_sbb$" --output-on-failure

sbb consumes the processor carry flag; the assembler encodes that contract but does not execute or otherwise establish the flag.

Control instructions and overflow safety

Operand-free control instructions

The instruction set also includes two operand-free forms:

InstructionBytes
haltF4
pauseF3 90

Both require a semicolon and have fixed layout sizes. pause is recognized as a two-byte sequence before a decoder reports an unknown byte. The end-to-end test checks both the binary and decoded listing:

ctest --test-dir build/windows-debug -C Debug -R "^encode.control$" --output-on-failure

Overflow-safe constant expressions

Constant expressions are evaluated with checked signed long long arithmetic before Kasm applies its signed 32-bit language limit. Addition, subtraction, and multiplication now reject intermediate overflow instead of relying on C signed overflow behavior. The semantic regression retains the successful precedence case and checks overflowing examples for all three operators:

ctest --test-dir build/windows-debug -C Debug -R "^semantic.expression$" --output-on-failure

Symbol-valued data remains a future layout feature at the time this checked-arithmetic behavior was added; as of 0.33.0, data directives can also resolve label expressions after layout — see Data directives and layout expressions.

Building and testing

Reusable example encoding test

tests/encode_file.cmake assembles an existing file and checks that the source-named .bin bytes match the first hexadecimal line of stdout. Pass EXPECTED_HEX to also check the expected instruction encoding. Run from the repository root after building:

cmake "-DKASM=build/windows-debug/Debug/kasm.exe" "-DSOURCE=examples/program.asm" "-DEXPECTED_HEX=B8 2A 00 00 00 C3" -P tests/encode_file.cmake

Change SOURCE to reuse the script with another example; omit EXPECTED_HEX when you only want to check successful assembly and binary/stdout consistency. By default, each source path gets its own directory under build/example-tests. The script prints the resulting binary path. Repeating the same example replaces its previous binary. You can override the directory with -DOUTPUT_DIR=<path>; use different directories for examples that run concurrently.

CTest uses this script for examples/mov_42.asm, examples/ret.asm, and examples/program.asm, saving separate binaries under build/windows-debug/examples/Debug/<test-name>/<input-name>.bin with the Windows Debug preset. To register another example, add a call inside BUILD_TESTING:

add_example_test(my_example examples/my_example.asm "B8 07 00 00 00 C3")

Configure and run the suite

Build from a source checkout with CMake and a C compiler. The Windows presets target Visual Studio 2026 with the C++ build tools installed. The Linux/WSL2 preset uses GCC and Make. Use a CMake version that supports your generator and the repository’s version-8 preset file; the basic CMake project requires 3.20 or newer when configuring without presets.

The registered CTest tests cover:

  • Exact MOV encoding: mov eax,42; produces B8 2A 00 00 00.
  • ADD expression evaluation, exact emitted bytes, and the decoded listing from examples/add.asm.
  • Exact decoding of examples/decode.asm, including instruction offsets, the backward JNZ target, and the success message.
  • Exact RET and combined MOV/RET encoding, with saved binary bytes checked against stdout and expected bytes in separate example directories.
  • Semantic evaluation of mov eax, (10+4)*3; to 42.
  • Exact expression ASTs for integers, addition/subtraction, multiplication precedence, and parentheses overriding precedence.
  • Multiple MOV statements with LF and CRLF line endings, binary literals in instructions, evaluated values (including inside nested blocks), and a missing-semicolon diagnostic.
  • Dynamic storage growth to 300 MOV statements, preserving their operands.
  • Empty, nested, and sibling blocks; blank lines within blocks; statement order and expression references; missing/extra brace diagnostics; and acceptance at MAX_BLOCK_DEPTH with rejection one level beyond it.
  • Integer literal lexing, comments, newline tokens, LF/CRLF/CR line endings, unterminated-comment diagnostics, and long sequences of adjacent comments.
  • The 16-bit, data-directive, control-instruction, and overflow-safety suites described in their own reference chapters.

Expression tests use expr_test_driver to inspect ASTs independently of the encoding CLI. Semantic and multiple-statement tests use semantic_test_driver to inspect statement counts and evaluated values. Lexer tests use lexer_test_driver. These drivers are built only when testing is enabled. The label and jump samples, C inspection, and Linux/Windows execution examples in Generated C data, execution, and decoding are manual checks; they are not currently registered as CTest tests.

Run these commands from the repository root (the directory containing CMakePresets.json). Configure and build before running CTest:

cmake --preset windows-debug -DBUILD_TESTING=ON
cmake --build --preset windows-debug
ctest --test-dir build/windows-debug -C Debug --output-on-failure -V
cmake --preset GCC-debug -DBUILD_TESTING=ON
cmake --build --preset GCC-debug
ctest --test-dir build/GCC-debug -C Debug --output-on-failure -V

The presets create separate build directories under build; CTest must point to the configured directory. For Release, use windows-release in all three commands and replace -C Debug with -C Release.

If your terminal is already in build/GCC-debug, run cd ../.. first to return to the repository root before using the commands above.

The commands include -V for verbose output even when tests pass; omit it for a shorter report.

After adding a new source file, rerun the configure command before building so the generated project includes it.

Extending the assembler: a checklist

Use this order when adding an instruction. ADD and SUB are useful examples of the full path from source text to bytes and back.

  1. Choose the exact syntax and encoding. Match the complete operand form and CPU mode in the instruction reference, not just the mnemonic. Write down the opcode, any prefixes or ModR/M bytes, immediate width, total size, and effects on registers and flags. Calculate a small expected byte sequence by hand before implementing it. add eax,7; uses 05 07 00 00 00; sub eax,7; uses 2D 07 00 00 00. Each instruction occupies five bytes.

  2. Add a statement kind in include/program.h. Give it a consistent name, such as ST_ADD_RIM or ST_SUB_RIM. Existing fields are sufficient for these forms: operand stores the register token, expression stores the expression root, value stores its evaluated immediate, and offset stores the instruction’s position in the image.

  3. Parse the operands in src/program.c. Recognize the mnemonic and assign its statement kind. For register/immediate instructions, follow MOV’s pattern: register, comma, expression, semicolon. Save the register token in s.operand and the result of parse_expression() in s.expression. INC/DEC’s one-operand parser is not enough for ADD/SUB. Ordinary instruction names are already identifier tokens; change the lexer only if the new syntax introduces something it cannot tokenize.

  4. Validate and evaluate in src/semantic.c. Include the new kind in the register check and, when it has an immediate expression, in the code that assigns s->value from the evaluated expression. Decide the accepted range explicitly. ADD/SUB currently use signed 32-bit expression results; MOV additionally requires a nonnegative immediate. Forgetting the value assignment can silently encode zero instead of the requested value.

  5. Reserve the full size in src/layout.c. Add the kind to instruction_size(). Count every emitted byte, including prefixes, operands, and embedded address slots. ADD/SUB reserve five bytes. An incorrect size shifts later labels and breaks jumps even if the instruction’s own bytes look correct.

  6. Emit bytes in src/encode.c. Select the correct opcode and write the operand in its required format. little_endian(bytes, value, 4) writes four operand bytes, not a four-byte instruction. ADD/SUB each write one opcode byte followed by four immediate bytes. FF is an opcode group, not a prefix to put before every instruction. For branches, resolve the target and calculate a displacement from the instruction’s end; for address slots, record relocation patches instead. Propagate allocation/write failures.

  7. Recognize the bytes in src/decode.c. Check enough bytes remain before reading operands, print the instruction and its reconstructed operands, and advance by the complete encoded size. Match the chosen signed or unsigned interpretation of immediates. The CLI currently decodes after writing its files, so forgetting this step can make a correctly encoded program exit with unknown or truncated encoding.

  8. Add an example and a permanent test. Put a small source file under examples/ and register it in CMakeLists.txt. Use add_example_test(name examples/name.asm "EXPECTED HEX") for byte checks. Follow encode.add when also checking the listing: pass EXPECTED_DECODE_FILE to the reusable script and commit that text fixture under tests/. Hand-calculated expectations should be independent of the encoder. Files under build/ are temporary and do not become regression tests automatically.

  9. Rebuild, test, and inspect runtime behavior. Run the build and CTest commands in Building and testing. Cover a normal value, zero, supported negative expressions, range boundaries, invalid registers, and malformed operands as appropriate. Test layout with a label or jump after the new instruction. For execution, regenerate program.h beside the runner, then recompile the runner before running it. Check the result against a value calculated by hand; test flags explicitly when their behavior matters. Run Windows and WSL2 checks before a release.

  10. Update documentation and the development milestone. Record the syntax, supported operands, byte format, limits, and test coverage in this reference, and add a chapter to the dev journal describing why the feature was added. Check the version in CMake and the version history agree. A new feature does not require an immediate release.

For example, mov eax,49; sub eax,3+4; ret; follows this path: the parser stores the expression, semantic checking computes 7, layout reserves five bytes for SUB, the encoder emits 2D 07 00 00 00, the decoder prints sub eax, 7, and execution returns 42. The expression is evaluated during assembly; the subtraction from EAX happens at runtime.

Using a release

Download the Windows x64 or Linux x64 ZIP from GitHub Releases and extract it. Each archive contains bin/kasm.exe (Windows) or bin/kasm (Linux), plus the project README and the Apache 2.0 license under share/doc/kasm.

From the extracted package directory, create example.asm containing mov eax,42;, then run:

.\bin\kasm.exe example.asm
.\bin\kasm.exe --bits 64 example.asm
./bin/kasm example.asm
./bin/kasm --bits 64 example.asm

Expected output with the current source build:

B8 2A 00 00 00
0000: mov eax, 42
Decoding successful.

The CLI accepts either one source-file path, which defaults to 64-bit mode, or --bits 16|32|64 followed by one source-file path. The current implementation supports the initial 16-bit forms described in The 16-bit instruction set and the existing 64-bit path; 32-bit mode is rejected. It prints one line of uppercase hexadecimal bytes, with a space after each byte, followed by a decoded listing and Decoding successful. when decoding succeeds. Older releases may print only the hexadecimal line. For program.asm, the CLI writes program.bin (raw bytes) and program.h (C declarations) in the current working directory, replacing previous files with those names before decoding. Redirecting stdout saves text, including the listing, rather than a raw binary. Unless a full listing is shown, the encoding examples elsewhere in this reference show only the first hexadecimal line. Instructions are encoded in source order and are not executed by the CLI. Windows packages use the static MSVC runtime; Linux packages are built on Ubuntu 24.04.

Source-file links in this reference point to the repository on GitHub; source files and test drivers are not included in the binary ZIPs.

Packaging and releases

The repository workflow builds and tests Debug and Release configurations on Windows x64 and Linux x64. On a push to main, successful builds produce two Release ZIPs through CPack and publish a GitHub Release. Tags use v<version>-build.<run-number>; the version comes from project(kasm VERSION ...) in CMakeLists.txt. A merge to main triggers this process through its push.

To build a Windows ZIP locally from the repository root:

cmake --preset windows-release -DBUILD_TESTING=ON
cmake --build --preset windows-release
ctest --test-dir build/windows-release -C Release --output-on-failure
cpack --config build/windows-release/CPackConfig.cmake -C Release -G ZIP -B dist

The package installs the executable under bin and documentation under share/doc/kasm. GitHub’s automatically generated source archives are separate from these executable packages.

License

Copyright 2026 Kenneth Looney. Licensed under the Apache License, Version 2.0. See LICENSE, included beside the README in release packages.