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.cand 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.cfor 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:
- Integer literals and parenthesized error handling (
b3ea2aa). - Addition and subtraction (
c0daf03). - 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.
| Version | Instruction(s) added | Encoded form |
|---|---|---|
| 0.15.0 | add/sub eax, imm32 | 05 imm32 / 2D imm32 |
| 0.16.0 | or eax, imm32, push/pop rax | 0D imm32 |
| 0.17.0 | adc eax, imm32 | 15 imm32 |
| 0.18.0 | int imm8 | unsigned 8-bit vector, range-checked |
| 0.18.1 | (fix) decoding for inc eax and jz | no new encoding, decoder-only fix |
| 0.19.0 | cmp/and eax, imm32, jb/jl | 3D imm32 / 25 imm32 |
| 0.19.1 | (hardening) dynamic source buffer | no new instruction |
| 0.20.0 | xor eax, imm32 | 35 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:
| Spelling | Bytes | Range |
|---|---|---|
db/byte | 1 | 0–255 |
dw/word | 2 | 0–65535 |
dd/dword | 4 | 0–2147483647 (current expression limit) |
dq/qword | 8 | 0–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:
| Form | Size | Bytes |
|---|---|---|
mov ax, 42; | 3 | B8 2A 00 |
mov cx, 7; | 3 | B9 07 00 |
add ax, 7; | 3 | 05 07 00 |
sub cx, 3; | 4 | 81 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.
| Instruction | Bytes |
|---|---|
halt | F4 |
pause | F3 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 rejectsjmp absuntil it can be converted into a proper linker relocation. - Raw output file naming changed. Previously fixed as
program.bin/generated.hregardless of input filename, raw output now derives its name from the source file —examples/coff_answer.asmnow producescoff_answer.binandcoff_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-devlogjob to the CI workflow: on every push tomain, after the existing build/test/package job finishes, it builds this book withmdbook build devlogand 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.
--bitsmode rejection (0.22.0), the[bx]-only memory operand (0.27.0), rejectingjmp absin 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
externdeclarations and multiple data sections for COFF objects. - Converting
jmp absinto 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.
| Version | Added capability |
|---|---|
| 0.1.0 | Parse and validate MOV programs, evaluate expressions, and print encoded bytes. |
| 0.2.0 | Encode ret; as C3, allowing a small function to return to its caller. |
| 0.3.0 | Save raw bytes in program.bin; test example files in separate output directories. |
| 0.4.0 | Generate generated.h with a C byte array and length; inspect the data with a C program. |
| 0.5.0 | Add a Linux x86-64 runner example that loads the generated bytes into executable memory and calls them. |
| 0.6.0 | Add Windows x86-64 execution to the runner using the same generated bytes. |
| 0.7.0 | Parse label definitions, including consecutive labels, without emitting extra bytes. |
| 0.8.0 | Assign instruction and label offsets before encoding, reject duplicate labels, and provide label-offset lookup. |
| 0.9.0 | Encode near jumps with signed relative displacements and resolve forward label references after layout. |
| 0.10.0 | Add short jumps with signed 8-bit displacements and require explicit jmp near or jmp short syntax. |
| 0.11.0 | Add a load-time relocation helper, emit patch metadata in generated headers, and apply patches in both runners before execution. |
| 0.12.0 | Add 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.0 | Add ADD/SUB EAX immediate expressions, five-byte encoding, and decoder support. |
| 0.16.0 | Add OR EAX immediate expressions and PUSH/POP RAX, with layout, operand validation, and decoding support. |
| 0.17.0 | Add ADC EAX immediate expressions, encoding and decoding, with runtime checks for carry clear and carry set. |
| 0.18.0 | Add INT with an unsigned 8-bit vector, expression parsing, range validation, encoding, and decoding. |
| 0.18.1 | Fix decoding for INC EAX and JZ, and add an end-to-end regression test for their byte output and decoded listing. |
| 0.19.0 | Add CMP and AND EAX immediate expressions, five-byte encoding, flag-setting semantics, decoder support, and JB/JL conditional branches. |
| 0.19.1 | Replace the fixed 4096-byte source buffer with dynamically growing storage. |
| 0.20.0 | Add XOR EAX immediate expressions, five-byte encoding, and decoder support. |
| 0.22.0 | Make CPU mode explicit with --bits 16, --bits 32, or --bits 64; reject unsupported 16-bit and 32-bit modes before encoding. |
| 0.23.0 | Add DB/byte, DW/word, DD/dword, and DQ/qword data directives, expression lists, range checks, and little-endian emission. |
| 0.24.0 | Add TIMES/FILL repetition for data directives. |
| 0.24.1 | Restore the default one-source-file CLI invocation as 64-bit mode. |
| 0.25.0 | Add $ and $$ location-aware expressions, resolving dynamic TIMES/FILL counts during layout. |
| 0.25.1 | Fix the boot-padding regression test to measure binary output through HEX, keeping CI behavior consistent across platforms. |
| 0.26.0 | Add initial 16-bit MOV/ADD/SUB encoding for AX/CX/DX, operand-width-aware layout, and target-aware decoding. |
| 0.27.0 | Add 16-bit indirect mov from [bx] into AX/CX/DX. |
| 0.28.0 | Add a 512-byte 16-bit boot-sector fixture using location-aware padding, binary literals, and the 55 AA boot signature. |
| 0.29.0 | Add 16-bit segment-register push/pop for CS, ES, SS, DS, FS, and GS. |
| 0.30.0 | Add 16-bit SBB immediate forms for AX, CX, and DX. |
| 0.31.0 | Add operand-free HALT and PAUSE forms. |
| 0.32.0 | Make signed constant-expression folding overflow-safe for addition, subtraction, and multiplication. |
| 0.33.0 | Resolve label expressions in data directives after layout; add Windows x64 COFF object output; name raw binaries and headers after the input file. |
| 0.34.0 | Documentation 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
| Tag | Date |
|---|---|
| v0.1.0-build.3 / build.7 / build.29 | 2026-09-12 / 2026-09-13 |
| v0.5.0-build.37 | 2026-09-14 |
| v0.10.0-build.45 | 2026-09-14 |
| v0.15.0-build.59 | 2026-09-15 |
| v0.18.1-build.73 | 2026-09-16 |
| v0.19.0-build.84 | 2026-09-16 |
| v0.24.1-build.139 | 2026-09-16 |
| v0.25.1-build.153 | 2026-09-17 |
| v0.27.0-build.167 | 2026-09-17 |
| v0.28.0-build.175 | 2026-09-17 |
| v0.29.0-build.185 | 2026-09-17 |
| v0.32.0-build.199 | 2026-09-18 |
| v0.33.0-build.208 | 2026-09-18 |
| v0.34.0 | 2026-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:
| Statement | Byte offset | Encoded size |
|---|---|---|
entry: | 0 | 0 |
mov eax,42; | 0 | 5 |
done: | 5 | 0 |
ret; | 5 | 1 |
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;
| Statement | Byte offset | Encoded size |
|---|---|---|
jmp near answer; | 0 | 5 |
mov eax,99; | 5 | 5 |
answer: | 10 | 0 |
mov eax,42; | 10 | 5 |
ret; | 15 | 1 |
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 syntax | Opcode | Displacement | Total size |
|---|---|---|---|
jmp near label; | E9 | Signed 32-bit, little-endian | 5 bytes |
jmp short label; | EB | Signed 8-bit, -128 through 127 | 2 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.
| Instruction | Encoding | Size |
|---|---|---|
inc eax; | FF C0 | 2 bytes |
dec eax; | FF C8 | 2 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.
| Instruction | Opcode | Displacement | Total size |
|---|---|---|---|
jz label; | 0F 84 | Signed 32-bit, little-endian | 6 bytes |
jnz label; | 0F 85 | Signed 32-bit, little-endian | 6 bytes |
jb label; | 0F 82 | Signed 32-bit, little-endian | 6 bytes |
jl label; | 0F 8C | Signed 32-bit, little-endian | 6 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.
| Instruction | Encoding | Immediate | Size |
|---|---|---|---|
cmp eax, expression; | 3D | Signed 32-bit, little-endian | 5 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 form | Encoded bytes | Meaning |
|---|---|---|
mov eax, imm32; | B8 imm32 | Move a 32-bit immediate into eax. |
mov ecx, imm32; | B9 imm32 | Move a 32-bit immediate into ecx. |
add eax, imm32; | 05 imm32 | Add a signed 32-bit immediate to eax. |
add ecx, imm32; | 81 C1 imm32 | Add a signed 32-bit immediate to ecx. |
sub eax, imm32; | 2D imm32 | Subtract a signed 32-bit immediate from eax. |
sub ecx, imm32; | 81 E9 imm32 | Subtract a signed 32-bit immediate from ecx. |
or eax, imm32; | 0D imm32 | Bitwise OR with eax. |
xor eax, imm32; | 35 imm32 | Bitwise XOR with eax. |
and eax, imm32; | 25 imm32 | Bitwise AND with eax. |
cmp eax, imm32; | 3D imm32 | Compare eax against the immediate. |
adc eax, imm32; | 15 imm32 | Add with carry into eax. |
The second byte in the 0x81 family decides the exact operation and
register:
81 C1=add ecx, imm3281 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:
| Input | Diagnostic |
|---|---|
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.
| Instruction | Opcode | Immediate | Total size |
|---|---|---|---|
add eax,7; | 05 | 07 00 00 00 | 5 bytes |
sub eax,7; | 2D | 07 00 00 00 | 5 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:
| Source | Bytes | Decoded instruction |
|---|---|---|
int 0; | CD 00 | int 0x00 |
int 0x10; | CD 10 | int 0x10 |
int 8+8; | CD 10 | int 0x10 |
int 255; | CD FF | int 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.
| Base | Syntax | Example | Value |
|---|---|---|---|
| Decimal | Digits beginning with 1 through 9 | 42 | 42 |
| Hexadecimal | 0x or 0X, then one or more hexadecimal digits | 0x2A | 42 |
| Binary | 0b or 0B, then one or more binary digits | 0b101010 | 42 |
| Octal | Leading 0, followed by zero or more octal digits | 052 | 42 |
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
| Character | Token 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/**/bproduces two identifiers. - A semicolon is a
TK_SEMItoken, 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 calllexer_next(&lexer)to advance. Stop whenlexer.failedis set or the token kind isTK_END. token_is(&source, token, "word")compares the token’s source text exactly; it does not check the token kind, so checkTK_IDENTseparately when needed.- Keep the
Sourcealive 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:
| Spelling | Bytes per value | Accepted result |
|---|---|---|
db or byte | 1 | 0 through 255 |
dw or word | 2 | 0 through 65535 |
dd or dword | 4 | 0 through 2147483647 (current expression limit) |
dq or qword | 8 | 0 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 form | Size | Example bytes |
|---|---|---|
mov ax,42; | 3 | B8 2A 00 |
mov cx,7; | 3 | B9 07 00 |
mov dx,3; | 3 | BA 03 00 |
add ax,7; | 3 | 05 07 00 |
sub cx,3; | 4 | 81 E9 03 00 |
add dx,7; | 4 | 81 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:
| Instruction | Bytes |
|---|---|
push es / pop es | 06 / 07 |
push cs | 0E |
push ss / pop ss | 16 / 17 |
push ds / pop ds | 1E / 1F |
push fs / pop fs | 0F A0 / 0F A1 |
push gs / pop gs | 0F 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:
| Instruction | Bytes |
|---|---|
sbb ax, 1 | 81 D8 01 00 |
sbb cx, 2 | 81 D9 02 00 |
sbb dx, 3 | 81 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:
| Instruction | Bytes |
|---|---|
halt | F4 |
pause | F3 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;producesB8 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;to42. - 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_DEPTHwith 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.
-
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;uses05 07 00 00 00;sub eax,7;uses2D 07 00 00 00. Each instruction occupies five bytes. -
Add a statement kind in include/program.h. Give it a consistent name, such as
ST_ADD_RIMorST_SUB_RIM. Existing fields are sufficient for these forms:operandstores the register token,expressionstores the expression root,valuestores its evaluated immediate, andoffsetstores the instruction’s position in the image. -
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.operandand the result ofparse_expression()ins.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. -
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->valuefrom 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. -
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. -
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.FFis 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. -
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. -
Add an example and a permanent test. Put a small source file under
examples/and register it in CMakeLists.txt. Useadd_example_test(name examples/name.asm "EXPECTED HEX")for byte checks. Followencode.addwhen also checking the listing: passEXPECTED_DECODE_FILEto the reusable script and commit that text fixture undertests/. Hand-calculated expectations should be independent of the encoder. Files underbuild/are temporary and do not become regression tests automatically. -
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.hbeside 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. -
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.