- C 94.7%
- Shell 5.3%
| examples | ||
| samples | ||
| self | ||
| vscode | ||
| .gitignore | ||
| AGENTS.md | ||
| b99.c | ||
| benchmark.md | ||
| build.sh | ||
| CLAUDE.md | ||
| README.md | ||
b99
A programming language between assembly and C99. You write what the machine runs: labels, registers, jumps and syscalls. The language adds names, types, checked calls and a register allocator. There are no parentheses, no commas, no runtime and no libc. The compiler is one C99 file.
section rodata
msg: u8 "hello, world\n"
section text
fn cstrlen reg in s u8* reg out n i64
fn print reg in buf u8* reg in len i64
fn _start:
reg slen i64
do cstrlen msg slen
do print msg slen
rax = 60; rdi = 0; @syscall # exit(0)
# the direction: s only goes in, and n only carries the result back
fn cstrlen:
reg in s u8*
reg out n i64
reg c u8
n = 0
addr loop
c = [s]
if c == 0 goto done
n += 1; s += 1
goto loop
addr done
end
fn print:
reg in buf u8*
reg in len i64
@syscall 1 1 buf len # write(1, buf, len)
end
slen, c, s and n are virtual registers. The compiler assigns the
physical registers, and refuses the program when they run out. A value
never moves to memory without an instruction in the source. A call is the
body of its callee, expanded in place. The language adds nothing else.
Method
- A library of samples drives the design.
- Each sample is a folder under
samples/. The folder name says what the sample does. - A folder holds three files:
asm.asm(Intel-syntax assembly),b99.b99andc99.c. They show the level below the language, the language, and the level above. - For each sample, the assembly, the b99 source and the C file must produce the same behavior.
- The C file is a behavioral reference, not a third version of the
subject. It is plain C99 with
write(), and it exits by return frommain. - Some samples have machine mechanics as the subject: register pressure, scratch registers, or expansion. Only the behavior survives the translation to C, and the header comment of the C file says what is lost.
- An error sample (
samples/e...) holds ab99.b99file that must not compile. It also holdserr.err, the exact message that the compiler must give. The diagnostics are part of the design, so the library includes them. - Every word that is not code follows ASD-STE100 Simplified Technical English: this file, each other document, each comment of the compiler, each comment of a sample, and each message the compiler gives. One term names one thing through the whole tree. A description uses the simple present and the active voice, and holds 25 words at most. An instruction uses the imperative, states its condition first, and holds 20 words at most. The standard is a free download at asd-ste100.org.
Target
- The compiler writes assembly in Intel syntax for the GNU assembler. The program runs on x86-64, it calls the kernel of Linux directly, and it links to nothing.
- The baseline is x86-64-v3. Intel has it since Haswell in 2013, and
AMD since Excavator in 2015, so
roundsdstands with the other instructions of the float bank and no word of the language asks what the machine has. Each part of the low-power line before Gracemont in 2021 is out of the baseline, because those have no AVX2. - The compiler writes the VEX encoding of each instruction of the float
bank, and it writes scalar instructions alone: no 256-bit register,
and no operation on more than one value at a time. Thus the upper
half of each register stays zero, the two encodings never mix, and
the program needs no
vzeroupper. - The VEX encoding takes three operands, so an operation of the bank writes a register that is neither source, and no copy comes before it. The scalar forms that write less than the full register take the bytes above the result from the first source. Where the language names no value for those bytes, the compiler gives the destination, which is the register that the statement writes anyway.
vfmaddis in the baseline, and the compiler writes it where the source writes the multiply and the addition together. The instruction rounds once where two instructions round twice, so the two are different programs, and the source says which one the machine runs.- The x87 stack is not a bank of this language.
fcmovmoves a float on a condition, and it writesST(0)alone, so a value crosses memory to reach it. The bank is the xmm registers, andvminssandvmaxssare the conditional move it has.
Language
Storage and labels
-
A label gives the address of its storage. Brackets read through it:
[msg]loads the value. The declared type sets the access width. -
A colon shows that a definition comes after the name: storage after
msg:, and a function body afterfn print:. A line that only declares takes no colon:reg,mem,addr, andfnwithout a body. -
_declares anonymous storage. The storage exists, but no name reaches it. -
A storage line takes a list, and the list is the storage in order: one element of the declared type for each number, the bytes of each string, and the address of each label. Only a pointer type can hold an address.
@lencounts the whole line. -
Under
section bss, storage takes a count of elements in place of the list.buf: u8 64reserves 64 zeroed bytes. The count is one when the line has none. The section holds no bytes, so nothing else can go there. -
Each name must have a declaration before its first use.
-
Only storage and file-scope labels are global.
-
The words the language reads as syntax are not names. A statement that took one would read as something else, so the compiler refuses the declaration:
section,rodata,data,bss,text,fn,addr,goto,if,rec,off,reg,in,out,io,mem,do,endanddef. This holds for a value, storage, a record, a number, a function and a label. A field is not one of these, because it comes after the record and a dot._is not one, because it declares what no name reaches.msg: u8 "hello, world\n" storage p10: i64 1 10 100 1000 a table of four words: u8* one two three a table of addresses
Named numbers
-
defgives a name to a number. It reserves no byte and emits no instruction. The compiler holds the value, and the name stands wherever a number stands: an operand, a count of elements, a scale, a displacement, and the value of anotherdef. -
The value is a number, a character, a field,
@lenof a record, or anotherdef. One operation can follow the first, as an operation follows=in a body:+ - * / & | ^ << >>. The compiler does that arithmetic, so the program pays nothing for it. There is no fma here, because adefnames an integer. -
The compiler reads
recanddeflines before the rest of the file, in the order of the file. Thus a record can count a field with a name that adefabove it gives. Adefcan take@lenof a record above it.@lenof storage belongs to a body, because the walk over the file measures the storage. -
A
defname is not storage and not a register. Nothing assigns to it, and no other declaration takes the name.def WBITS 64 def WMASK WBITS - 1 one operation, before the program runs def STEP @len cell the bytes of a record
Registers
-
Physical registers are machine words without a type. You can use each by name. The compiler examines assignments to them for width only.
-
regdeclares a virtual register: a name and a type. It has no initializer, because an initializer makes a declaration into storage.reg buf u8* a virtual register
-
The compiler selects the physical register that backs a virtual one. If the physical registers run out, the compiler refuses the program. The error names the statement and the values that are live there. Then move the values that the registers cannot hold to
memstorage, in the source. -
A virtual register is local to the function that declares it. Two functions can each declare
reg c u8without a conflict. -
A physical register that the source names is "raw". The compiler does not touch a raw register: it does not save, load or reassign it.
rbx = 1does that, and nothing more.
Live ranges and allocation
- Before it emits a function, the compiler calculates the live range of each virtual register, from the first write to the last read. It follows all jumps, and the back edges of loops.
- Two virtual registers with live ranges that do not overlap share one physical register. Thus a function can name more registers than the machine has, if few enough are live at one time. If too many are live, the compiler refuses the program. The limit is 512 virtual registers per body, with the expanded calls included. The live ranges are a bitmask, one bit for each register, held in as many machine words as that number needs.
- The compiler never puts a live value in a register that a statement
writes while the value is live. It knows the exact clobbers of each
statement:
- A syscall clobbers
rax,rcx,r11and its argument registers. - A division clobbers
raxandrdx, the registers that it uses. - A raw assignment clobbers the register that it names.
- A call clobbers nothing of its own. A call is an expansion, and the compiler sees the writes of the spliced statements one at a time.
- A syscall clobbers
Frame storage
-
memdeclares storage in the stack frame of the function: a name, a type and an element count. -
It takes no initializer, because the frame does not exist until the function runs. The code of the function fills it.
-
The name is the address, as for a label.
[buf]accesses the first element, and a pointer walks the other elements. -
memblocks have live ranges, as virtual registers do: from the declaration to the last mention. Two blocks with live ranges that do not overlap share one frame slot. -
If the address escapes, because the source passes it on or stores it, the compiler can no longer see its uses. A mention of the bare name counts as raw, and the compiler does not reuse or move raw storage.
mem buf u8 64 64 bytes of the frame
Functions
-
fn _start:is the entry point of the program, and the only function that the compiler emits. Each other function is a pattern, and a call is its body, spliced in place of thedostatement. There is no calling convention, no prologue and no saved registers. Only the statements of the bodies remain. -
A call site lists its arguments in order, and the compiler compares them with the declaration. Bare
do fis legal whenfdeclares no arguments. There are no return values. By convention, the last argument carries the result. -
An argument is a declaration with a direction:
regormem, thenin,outorio, then the name and the type. Without the direction, the line is the declaration that the body would write. Aregargument is a virtual register, and amemargument is storage.reg in x T copies the operand in reg out x T carries the argument back reg io x T both copies mem in b T n binds storage: the callee only reads it mem out b T n binds storage: the callee only writes it mem io b T n binds storage: the callee does both
-
A
regargument moves through copies. The expansion copies the operand in at the top, back out at the end, or both, as the direction says. If the caller and the callee use the same physical register, a copy costs no instruction. -
incarries nothing back, so its operand is anything a statement can read: a register, a number, a label or a bracket. The argument dies at its last read, and the operand is not live after the call. Writeinfor each argument that the call only reads. It is most of them. -
outandiocarry back, so their operand must be a virtual or a raw register. A number receives nothing, and the compiler refuses it. -
outcopies nothing in, so the argument holds what its register held, as aregdoes. The callee must write it before it reads it. The call does not read the operand, so the caller sets no value before the call, and the operand is not live there. Writeoutfor each argument that carries a result and takes none. -
A
memargument copies nothing. The name of the callee binds to the storage of the operand: amemblock of the caller, or a label. The callee reads and writes that storage as its own, so the binding costs no instruction and no register. The access in the expansion is[rsp + slot], or[rip + label]when the operand is file storage. -
The element type and the count of a
memargument must match the block of the call site.@lenof the argument must be a number that the compiler knows. -
The direction of a
memargument is a promise, and the compiler enforces it: no store goes through amem inbinding, and no read through amem outone. A binding passed to a further call binds the storage of the first call site, and the direction only narrows:iopasses as anything,inandoutpass only as themselves. The bare name is an address, as the name of a block is, and the direction does not follow an address. -
A declaration and its body must give each argument the same direction, as they already give it the same name and the same type.
fn cstrlen reg in s u8* reg out n i64 n comes back, and takes nothing in
-
A call is
do, and the word that leaves a body isend. Neither word names the machine instruction that it resembles:do fsplices the body offin place, and in an expansionendjumps to the end of that spliced body. Anendthat is already at that position emits nothing. -
A function cannot call itself, directly or through other functions. A call is the body of the callee in place, and a body cannot contain itself. Loops iterate. Calls do not.
-
Two expansions of one function share its virtual registers. Their labels get different names, so each expansion jumps inside itself.
fn print: reg in buf u8* reg in len i64 ... do print msg [msg_len]
-
A
fnline without the colon at the end is a forward declaration. Thus a function can call one that the file defines later. The declaration and the definition must agree exactly, with the argument names included, because the compiler compares each call with the declaration.fn print reg in buf u8* reg in len i64 declaration fn print: body
-
Code can appear only in a function. At file scope there are only storage, labels,
sectionlines,recanddefdeclarations, andfndeclarations.
Assignments and stores
-
An assignment copies one value:
n = a. Each operation has an in-place form:n += 1,n *= 10,n >>= 2. -
A number can be negative:
n = -1. The minus sign belongs to the number, so it works in each position where a number works. -
At most one operation can follow
=:i = n + 1,d = m >> cl. The operations are+ - * / & | ^ << >>. The fma of the float bank is the one exception, and it takes two, because the machine has one instruction for them. An addition or a subtraction with only 8-byte registers as operands compiles to onelea. Any other one compiles to a copy, then the operation. -
An addition can carry a scaled index.
p = duos + i*@len duois oneleaof the machine, when the scale is 1, 2, 4 or 8. A label as the base goes into a register first, which costs one morelea. Noleamultiplies by another scale, so**writes that multiply:q = p + i**@is oneimulinto the scratch register, then aleawith no scale. The base of a**is a register, aspis here. -
A field is a number, so it adds in that same
lea. A leading dot takes its record from the value on the left:q = p + .nandp += .n. -
A division gives both results at once, because the division of the machine does. One
idivleaves the quotient inraxand the remainder inrdx. A pair of destinations receives them in that order, and_discards one of them. There is no modulo operator, and the remainder is the second result.n d = n / 10 quotient and remainder, one idiv _ d = n / 10 the remainder alone n /= 10 the quotient, in place
-
Multiply and divide take 2, 4 or 8 bytes, and they occur in a register, never in memory.
-
A shift counts by a number or by
cl, the register that the machine shifts by. The compiler refuses a count in another register. -
The type of the destination selects the signed instructions (
idiv,sar). A raw destination takes its sign from the operand, or it is unsigned. -
Pointer arithmetic counts bytes, not elements.
-
An assignment does not change the width of a value. The compiler refuses
n = cwhen the two types have different widths.@castand@narrowwrite the change, and they are the only way to make one. -
A store is an assignment with a bracket expression on the left side.
[count] = nwrites through a label, and[p] = cwrites through a pointer. The in-place forms that the machine does in memory also work:[count] += 1increments in place, and[flags] |= 4sets a bit. -
Storage under
section data, together with stores, gives you global variables.
Floats
-
f32andf64are the float types, of 4 and 8 bytes. A float lives in an xmm register, and an integer in a general one. Each set is a bank, and the banks meet only in a conversion. -
The compiler allocates
xmm0toxmm14as it allocates the general registers: live ranges, interference, and the same refusal when the values run out. A program cannot name an xmm register, because the name carries no width. The width goes in the instruction, asvmovssandvmovsdshow. Between two registers the copy is onevmovaps, which moves the register and reads nothing. -
A syscall writes no xmm register. Thus a float value crosses a syscall with no help, and takes none of the general registers that survive one.
-
A float literal is digits, a dot, digits, and an exponent when one follows:
1.5,0.001,6.02e23. The minus sign belongs to the literal, as it belongs to a number. -
No instruction takes a float immediate. The compiler puts the literal in a pool after the code, and the instruction reads the entry. One entry serves each different text, and the labels stay out of the object file.
-
The compiler converts no decimal. It writes the text of the literal after
.doubleor.float, and the assembler makes the bytes. Thus the compiler holds no conversion that could disagree with another one. -
Storage takes a float the same way:
pi: f64 3.14159writes.double 3.14159. A number is refused there, because a float takes a float literal. The same holds in a body:x = 3is refused, andx = 3.0loads the pool entry. -
A float adds, subtracts, multiplies and divides. Each operation is one instruction, on a register or straight from memory. The bank has no
& | ^ << >>, nolea, and no remainder, so the compiler refuses them. An operation in place on float storage is refused too: the machine computes a float in a register alone. -
The instruction takes three operands, so the destination is neither source and no copy comes before it.
z = x + yis onevaddss, andx += yis the same instruction with the destination as the first source. The first source is a register, so a first operand that reads memory goes throughxmm15and costs one load. -
A float comparison is one
ucomis, and it sets the unsigned flags. The machine knows above and below, so<and<=compare the two sides the other way around. Thus a NaN fails all four relations.==and!=also read the parity flag, so a NaN is equal to nothing, itself included. -
Under a float condition the conditional move still writes an integer:
if x < y n = mis onecmova. The compiler refuses==and!=there, because a NaN needs the parity flag and a cmov reads one condition. -
No cmov writes an xmm register. The bank has
vminssandvmaxssinstead, and each one compares the two values that it moves. Thus the conditional move of a float is one instruction, and it takes no comparison and no branch.if v < m m = v vminss: the smaller of the two if v > m m = v vmaxss: the larger if v < w m = v the same, into a third register if x > 10.0 x = 10.0 a clamp, straight from the pool
-
The operand is one side of the relation, and the other side is the value that the statement keeps. The compiler compares them, and it refuses each other move, because the machine writes a float on a condition in no other way.
-
The destination is free, because the instruction takes three operands. It can be either side, as
if v < m m = vshows, and it can be a register that the relation does not name. There the two sides both survive the statement. -
The two instructions compare with
<and>. The machine has no form for<=and>=, so the compiler refuses those two and names the jump. -
The statement writes the operand twice: once in the relation, and once in the move. Thus the address of a label needs a register first, as it does for
**, and the compiler refuses the two names of one address. -
The instruction compares by itself, and the corner cases are its own: it gives the operand when a value is a NaN, and when the two are zeroes of different signs. A jump and a copy give the other side there. Thus the two are not the same program, and the source says which one the machine runs.
-
The machine multiplies and adds in one instruction, and it rounds once. A multiply and then an addition round twice, so the two give different values, and the source says which one it wants. Thus
d = x * y + zis one instruction, and it is the one place where two operations follow=.d = x * y + z vfmadd: one rounding d = x * y - z vfmsub d += x * y the same, spelled short d -= x * y vfnmadd: the product negates
-
The instruction reads the register that it writes, so the destination must be one of the three values: the one that the instruction adds to, or one of the two that it multiplies. Each place has its own form of the instruction. A fourth register is none of them, and the compiler refuses it and names the two statements that round twice.
-
The comparison is on the name that the source wrote, and not on the register that the compiler gave it. Thus the allocation decides no refusal.
-
d -= x * ynegates the product, andd = x * y - dsubtracts the destination. The two are different values, and each one has its own instruction. -
The second operand can read memory, and the first one goes through
xmm15. The integer bank has no fma, so two operations there are an error. -
A syscall takes no float operand.
@bitsgives the bytes to an integer, and the kernel receives an integer. -
The pool also holds an integer that no instruction takes as an immediate. A
cmpreads 4 bytes of immediate, so a comparison with a wider number reads the pool.
Addressing
-
A bracket expression is the addressing mode of the machine, written as the machine defines it: a base, at most one index register, and a byte displacement.
-
The base sets the access width. Bare
[s]is a plain dereference. -
The index is always 8 bytes wide, and the machine always adds it. It can carry a scale of 1, 2, 4 or 8, the scales that the hardware supports. Thus
i*8indexes a table of 8-byte elements. -
The stride names itself three ways, and each way says more than the last. A number is the operand of the machine, and the compiler examines nothing:
i*8. The compiler compares a type with the element that the base addresses, and refuses a stride of another width:i*i64,i*rle,i*@len rle.@takes the stride from that element, so the source repeats nothing and no number goes stale:i*@.@needs a base with a type, and a raw register has none. -
*is the scale of the hardware and nothing else.**is the stride that the hardware has no scale for, and the compiler multiplies it: oneimulinto the scratch register, then the one access. Each stride has one spelling and the compiler refuses the other:*by 24 and**by 8 are both errors, so the line says what it costs before the compiler runs.[p + i**@ + rle.ch]and[tab + i*8]are one access each. Only the first pays for an instruction of its own. -
The address of a label takes the same scratch register that the product does, so
**addresses from a register. Put the address there first, which also lifts theleaout of the loop. -
The displacement names itself two ways.
rle.chnames the record, and the compiler compares it with the base. A leading dot takes the record from that same base, so the line writes the field and nothing else:[p + .ch]. Like@, it needs a base with a type, and a raw register has none. -
A physical register can be a base. It carries no width, so the context must give one.
-
To index from a label, the address of the label must be in a register first. The compiler loads it into a scratch register.
c = [s + i] k = [tab + i8] the scale of the machine k = [tab + i@] the same, from the type of tab c = [p + i**@ + rle.ch] one imul, then one access c = [p + i**@ + .ch] the same, the record from p [buf + i + 1] = c if [digits + i] == 0 goto done
Records
-
recdeclares a record: a name for each byte offset in a layout. Anoffline follows for each field, with a name, a type, and an element count when there is more than one. In this language a field is its offset, as a label is its address. The fields sit in declaration order, and the compiler pads nothing. Padding is a_field, written on purpose.rec rle off ch u8 off _ u8 7 off n i64
-
A record never generates an instruction. A field reference is the byte offset of the field.
rle.nis 8, a number that the compiler knows, and it is legal in each position where a number is legal.@len rleis the number of bytes of the full record. -
In a bracket expression, a field is a displacement with a width and a condition: the base must hold the record that the field belongs to, and the type of the field sets the access width.
c = [p + rle.ch]is the one instruction ofc = [p], with the displacement written as a name. A raw register as the base carries no type, so the compiler accepts the type of the field. -
The base already names the record, so the field can come alone, after a dot and nothing else:
c = [p + .ch]. The compiler reads the record off the base. Writerle.chwhere the record says something, and.chwhere repeating it says nothing. A raw register has no type and no record, so the dot needs the name there. -
Outside a bracket, a field is a number, and the addition of it is a
lea. The value on the left holds the record, so the dot works there too:q = p + .n, andp += .n. The dot changes no instruction. It says which record the offset comes from, so the compiler examines it. The named field in that position is a number like any other, and the compiler examines nothing. -
In each other position where a field is legal, nothing stands to its left, so the compiler refuses the dot: the value of a
def, a stride, an argument of a call. Write the record there. -
A field of record type nests.
[p + msg.hd + hdr.len]adds two offsets into one displacement, and it is still one access. Each field gives the record of the next, so[p + .hd + .len]is the same access. -
The name of a record is a type.
reg p rle*declares a pointer to a record. Storage undersection bssreserves an array of records (runs: rle 3), andmemtakes a block of them in the frame. -
A record is never a value. It does not fit in a register, no assignment copies one, and no argument passes one. To copy a record, write the moves. To walk an array of records, advance a pointer:
p += @len rle. A scaled index reaches only the records with a size that the hardware scales by.**reaches every other size, and the compiler multiplies nothing that the source did not write. -
The compiler reads a record before everything else, as it reads a function. Thus storage and bodies can name a record that the file declares below them.
recanddefshare that pass, and there the order of the file counts: adefsees the records above it, and a record counts its fields with the numbers above it.
Control flow
-
Control flow is labels and jumps:
goto, and the conditionalif x == y goto l. -
addrdeclares a code label. The label is only a name, so it can share a line with the statement that comes after it.addr loop if c == 0 goto done n += 1; s += 1 goto loop addr done
-
The machine takes a condition on more than a jump, and each form puts the condition in front, as
ifalready does.if x == y n = mis onecmov, andif x == y endis one jump.if hi < v hi = v # cmovl, and no branch if c < '0' end # jb to the end of the expansion
-
A conditional move writes a register of 2, 4 or 8 bytes, from a register or from memory. The destination keeps its old value when the condition fails, so the statement reads it as well as writes it, and the live range follows. The machine has no conditional store, no conditional number and no one-byte form, so the compiler refuses those three.
-
An
endof an expansion is a jump to the end of the spliced body. Thusif c endis one conditional jump, and it costs whatif c gotocosts. The entry point returns with theretof the machine, and the machine has no conditional one, so the compiler refusesif c endoutside an expansion. Write the jump and the label there. -
The position of the label sets its scope. In a function, the label is local: two functions can use the same label name, and the name does not go into the object file. At file scope, a label is global and names storage, not code.
-
A function body goes from its
fnline to the nextfn, the nextsection, or the end of the file. The compiler reads the full body before it emits it, so a forward jump needs no special syntax.
Compiler builtins
-
Builtins start with
@, so they can never collide with the names of the program. A program cannot declare an@name. -
@syscalltakes the syscall number first, then the arguments, in position. The compiler assigns them to the registers of the kernel. It puts the moves in an order that writes no value before it reads it. Bare@syscall, with the registers set in the source, is also legal.@syscall 1 1 buf len write(1, buf, len)
-
@lentakes the name of storage and gives the number of bytes that it holds: the bytes of a string, the size of a number, or the reserve of a bss block. It is a number that the compiler knows, so it costs nothing at run time.@syscall 1 1 msg @len msg write the whole string
-
@framegives the number of bytes that the body took offrspfor itsmemblocks. The entry point has no caller and no prologue. Thus the kernel leavesargcat the top of the stack, and the arguments above it. The environment comes above the zero that ends them. That address isrsp + @frame, and the number follows thememlines of the body, so a declaration added later does not move it. -
The compiler knows the number once it has placed the blocks, which is after it reads a body and before it writes it. Thus
@framebelongs to a statement. The compiler reads adef, a storage line and a count before any body, and it refuses@framethere.n = [rsp + @frame] argc, over the frame p = rsp + @frame the address of argc p += 8 and the arguments above it
-
@castand@narrowchange the width of a value. The destination gives the width, and the source gives the sign. The two of them give the instruction, so neither word names one. -
@castloses no byte. It widens a value into a larger destination:movzxfor an unsigned source,movsxfor a signed one, andmovsxdfromi32toi64. If the two widths are equal,@castretypes the value, and it costs no instruction when the two registers are the same. -
@narrowreads the low bytes of a value and discards the others. The destination must be strictly narrower. This conversion destroys data, so it has its own word. There is no check at run time, because the language has no runtime. -
The source can be a bracket expression.
n = @cast [s]is one instruction, and it needs no register of the width of the source. -
The destination can be a store, because a store also gives a width.
[p] = @narrow nwrites one byte with one instruction. A widening store is two instructions: the machine widens into a register, so the value goes through a scratch register, then into memory. -
A source with no type of its own widens with zeroes. A number has no width, so the compiler refuses to convert one. A raw register as the base of a store gives no width either, so convert into a register first.
-
A conversion is the whole right side of an assignment. No operation follows it, and the in-place forms do not take one.
n = @cast c a u8 into an i64: movzx n = @cast [s] the same, straight from memory q = @cast p u8* to i8*: no byte moves c = @narrow d the low byte of a value [p + i] = @narrow d the same, straight into memory
-
With the floats, seven words convert, and each one names a cost.
@castkeeps the value.@roundgives the nearest value.@truncgoes toward zero,@floordown, and@ceilup.@narrowdiscards the high bytes.@bitsmoves the bytes between the banks, and converts nothing. -
The destination gives the width and the bank. The word and the destination select the instruction, so no word names one.
n = @trunc x f64 to i64, toward zero: cvttsd2si n = @round x f64 to i64, to the nearest even: cvtsd2si y = @trunc x f64 to f64, and the fraction goes: roundsd y = @floor x the same, down x = @cast m i32 to f64, and no bit is lost x = @round n i64 to f64, and it can round f = @cast s f32 to f64 s = @round f f64 to f32 n = @bits x the eight bytes, unchanged: movq x = @bits n the same bytes, back
-
The machine converts a signed value of 4 or 8 bytes, and nothing else. The compiler refuses the unsigned types and the narrow ones, and the message names the step to take. It writes no sequence behind one word.
-
The compiler refuses the word that misstates the cost, in both directions:
@castwhere the value can round, and@roundwhere nothing rounds. Thusx = @cast mholds an exact conversion, andx = @round na rounding one, and the source says which. -
A store also converts:
[p] = @trunc xgoes through the scratch register of the bank, then into memory, as a widening store does.[p] = @bits xis onemovq, because the bytes move.
Syntax
-
The language has no parentheses and no commas. Spaces separate argument lists, calls, syscalls and value lists.
-
A statement ends at the end of the line. Thus the compiler knows where a list stops.
;also ends a statement, so related statements can share a line. -
A character literal is a number.
'a'is 97, soif c == 'a'compares bytes. The escapes are\n,\t,\r,\0,\\and\'.rdi = 1; rsi = buf; rdx = len; @syscall
Layout
b99.c the compiler (single file, C99)
samples/s000-hello/ hello, world
samples/s001-reg/ virtual registers
samples/s002-do/ function call with named arguments
samples/s003-syscall/ syscall with positional arguments
samples/s004-cstrlen/ arithmetic, pointers and a loop
samples/s005-count/ a register per body, and two results
samples/s006-spill/ live ranges reused, and the frame written in the source
samples/s007-store/ a global counted in place, and written through a pointer
samples/s008-mem/ the storage of the body: two buffers, one place
samples/s009-rev/ arguments, registers and the frame in one body
samples/s010-index/ a base, an index and a displacement in one operand
samples/s011-scratch/ labels walked by an index, through the scratch registers
samples/s012-scale/ a scaled index: a u64 table walked by eights
samples/s013-nest/ a call inside a call inside a call, expanded into one body
samples/s014-upper/ a string made uppercase in bss: characters, @len, and a minus one
samples/s015-radix/ a square by multiply, its digits by divide, shift and mask
samples/s016-rec/ a record: named offsets into a layout that the source writes
samples/s018-lea/ the address of the i-th record in one lea, the scale written as @len
samples/s019-cast/ the same bytes summed twice: @cast, @narrow, and the sign of the source
samples/s020-out/ the direction of an argument: out carries a result and takes none
samples/s021-in/ the direction in: numbers and @len as operands, and nothing back
samples/s022-memarg/ a mem argument: the callee bound to the storage of the call site
samples/s023-cmov/ the conditional move: a largest and a smallest without a branch
samples/s024-ifend/ the conditional end: a guard that leaves the expansion
samples/s025-def/ a name for a number, and the arithmetic behind it
samples/s026-table/ a storage line that takes a list: numbers and addresses
samples/s027-stride/ the stride of an index: the type gives it, and '**' pays for it
samples/s028-frame/ the frame as a number: the stack of the kernel over the storage of the body
samples/s029-float/ float values: the literal pool, arithmetic and @trunc
samples/s030-convert/ the words of a conversion, both directions, and @bits
samples/s031-fltminmax/ the min and the max of the float bank, and a clamp
samples/s032-fltsel/ the same two, into a register that the relation does not name
samples/s033-fltfma/ the multiply and the addition in one instruction, and one rounding
samples/e000-noreg/ fourteen values live at once
samples/e001-recurse/ a function that calls itself
samples/e002-shift/ a shift counted by the wrong register
samples/e003-discard/ a division with both results discarded
samples/e004-field/ a field reached through the wrong record
samples/e005-scale/ a stride that the machine has no scale for
samples/e007-lea/ a lea scaled by sixteen
samples/e008-cast/ a @cast that would discard bytes
samples/e009-out/ an out argument passed a number
samples/e010-io/ an io argument passed a number
samples/e011-mem/ a mem argument passed a number
samples/e012-memin/ a store through a mem in binding
samples/e013-cmovnum/ a conditional move of a number
samples/e014-cmovmem/ a conditional store
samples/e015-cmovbyte/ a conditional move of one byte
samples/e016-topend/ a conditional end at the top
samples/e017-defwrite/ an assignment to a def name
samples/e018-defptr/ a label in a list of numbers
samples/e019-stride/ a stride of the wrong type
samples/e020-mulbase/ a '**' that walks a label
samples/e021-mulscale/ a '**' by a scale the machine has
samples/e022-frame/ '@frame' outside a body
samples/e023-word/ a word of the language as a name
samples/e024-dot/ a leading dot on a base with no record
samples/e025-dotlea/ a leading dot on a lea that adds the wrong record
samples/e026-fltcast/ a @cast that would round a float
samples/e027-fltuns/ an unsigned type in a conversion
samples/e028-fltbits/ a @bits between two widths
samples/e029-fltpair/ a float division with two results
samples/e030-fltop/ a shift of a float
samples/e031-fltcmov/ a conditional move of a float that no side of the relation feeds
samples/e032-fltnum/ a number where a float stands
samples/e033-fltminmax/ a min on a relation the machine has no form for
samples/e034-fltmmaddr/ a min that names one address twice
samples/e035-fmadst/ an fma whose destination is none of its three values
samples/e036-fmaint/ a multiply and an addition in the integer bank
self/b99.b99 the compiler again, written in b99
self/t/ its own tests
self/check.sh the three stages, and the samples with both
self/README.md what it takes, and where it differs
build/ build output (generated)
build.sh ./build.sh <sample>|all [run]
vscode/ an editor extension: the reserved words, and no more
-
build.shbuilds the compiler, then builds the sample three times: from its reference assembly, from its b99 source, and from its C99 equivalent. Withrun, it runs the three binaries, and their output and exit status must be the same. Withall, it does each sample, with the error samples included. The error samples have no C file, because what they refuse is legal C. It compiles each C file with-ffp-contract=off, so the C compiler writes no fma of its own and each float operation keeps its own rounding, as the b99 source says. -
self/check.shbuildsself/b99.b99withb99.c. It then compiles every test and every sample with both compilers, and it compares what the two programs write and what they return. It then compilesself/b99.b99with itself twice: the second stage and the third must be the same file. -
To install
vscode/, make a symlink to it in the extensions directory of the editor. Then start the editor again. The directory is~/.vscode/extensionsfor Visual Studio Code, and~/.vscode-oss/extensionsfor Code OSS.ln -s "$PWD/vscode" ~/.vscode-oss/extensions/b99
-
The extension gives TextMate scopes, and no colors, because colors belong to the theme. The scopes are the standard ones, for the reserved words, comments, strings, numbers, types, records, fields and labels. Thus the theme colors them, and
settings.jsonneeds no rule. A character literal takes the scope of a number, because it is one. A record name takes the scope of a type, asu8does.
Status
All samples match: the reference assembly and the compiled b99 produce the same output.
self/b99.b99 is a second compiler for the language, written in the
language. Its _start is the walk over the file. Each pass of the
compiler is a function beside it, and one call expands it.
It reads the whole language: the direction of an argument, the
conditions, def and the storage list, the strides, @frame, the
leading dot, the widths of @narrow, and the float bank with its pool,
its comparisons, its min and max, and its seven conversion words. It compiles every
sample, every test under self/t/, and examples/flash/, the largest
b99 program that is not a compiler. Each one gives the behavior that
b99.c gives, and most of them the same bytes. The rest differ by the
scratch register alone.
It refuses every error sample, and it gives the message that err.err
holds, with the path and the line before it. The two compilers write the
same line for each of the thirty-seven. b99.c holds many more messages,
and this compiler gives one line for the errors that no sample names.
It compiles itself to a fixed point: the compiler it produces writes the
same file again. self/README.md keeps the account, and it says what the
language costs when you write a compiler in it.
The compiler makes four passes over the file:
- Tokenize.
- Read each
recand eachdef: the layouts and the numbers. - Collect each
fn: its signature and the position of its body. - Emit
_start, with each call expanded in place.
The compiler counts the registers that the program names raw after the
expansion, over the statements that _start contains. A function that no
call expands changes nothing, because its clobbers exist only where the
compiler splices it.
The compiler expands the calls while it reads _start into statements. A
call becomes four parts:
- a copy-in of its operands
- the statements of the callee, under its own names and labels
- an end label for its
retstatements - a copy-back into the operands
If the compiler expands a function that is already in expansion, it refuses the recursion. For each statement, the compiler records what it reads, what it writes, which physical registers it clobbers, and its possible successors. One statement alone does not give this information, because a forward jump names a label that the file defines later.
Register allocation then continues in order:
- The compiler calculates the live-in and live-out sets of each statement. It iterates backwards until the sets do not change. From these sets it knows which virtual registers are live at the same time, and which physical registers each one must avoid. The result is an interference graph, and the compiler colors it, with the node with the most reads first.
- Among the registers that a value can take, the compiler prefers one that its uncolored neighbors cannot take. A value that does not cross a syscall stays out of the registers that survive one. Those registers stay free for the values that need them.
- If the compiler cannot color a virtual register, it refuses the
program. The error names the statement where the most values are live
beside it, and names those values. Thus the correction starts at the
correct line: keep fewer values live, or move one to
mem. - The entry point has no caller, so the compiler saves, pushes and restores nothing. Each allocatable register is available.
s006 shows the two limits. It names sixteen virtual registers on a
machine with thirteen allocatable ones, because the second eight are
declared after the first eight are dead. It also has nine values that
cross a syscall, and a syscall leaves only eight registers. Thus the
source puts the ninth value in a mem slot for that time.
Three registers are reserved as scratch, and the compiler never allocates
them: r11 for addresses, rbp for integer values, and xmm15 for
float ones. The machine has no addressing mode for a label plus an index
register. Thus the compiler puts the address in r11 with a lea, then
makes the access. A comparison of two such loads uses one scratch
register for each. A store through such an address keeps the address in
r11, and brings a memory value through rbp, or through xmm15 when
the value is a float. An address is always an integer, so the float bank
needs no address scratch of its own. A write to r11 or rbp in the
source is raw, and the compiler gives you no protection from it.
Both compilers read the floats: the two types, the literals, the pool,
the arithmetic, the comparisons, the fma and the seven conversion words.
Both write the VEX encoding, with the three operands of an operation and
of a min and a max. The five float samples are on the list of
check.sh, and both compilers write the same bytes for them.
The author decides the design choices, not the tooling.
AGENTS.md and CLAUDE.md are symlinks to this file.