Harmonized Assembly Layer

The Harmonized Assembly Layer is a set of words implemented by all Dusk kernels which have the same semantics and compile native code that has consistent results on all architectures. For example, "RSP) 2 +) 16b) +," will, on all arches, compile a set of instructions that will result in the 16-bit addition of RSP+2 into the Work register. On i386, this is the same as "ax sp 2 d) 16b) add,".

This layer allows us to generate performant code in a cross-arch manner. It is also what compilers such as the C compiler rely on to generate code.

Of course, as with any abstraction, we sometimes lose a little bit in speed and binary space compared to direct assembler instructions, but in general, the result is pretty good and direct assembler should be needed only in the tightest of the loops.

The HAL is implemented at the kernel level and is available from the very beginning of the boot sequence, which makes extensive use of it to bootstrap into a usable system.

The HAL is always for the "live" system. It has not been designed with cross- compiling in mind.


Register allocation

The HAL has 5 virtual registers: W, A, S, PSP, RSP. Each architecture implementing the HAL will need to map those virtual registers to actual registers. Here is the list of mappings for all supported CPUs:

i386 W=eax A=ebx S=edx PSP=esi RSP=esp ARM W=r9 A=r11 S=r8 PSP=r10 RSP=r13

RSP and PSP registers

RSP and PSP registers map directly to Forth's RS pointer and PS pointer. They're the same.

W register

The W register is the "work" register and the default destination of all HAL instructions. When we say that "@," means "fetch", we mean "fetch into the destination", which is the W register by default.

The W register is PS's top of stack. This means that, for example, increasing the W register by 1 is the exact same thing as executing the "1+" word.

A and S register

The HAL has two extra registers that regular Forth doesn't have: the A (Address) register and the S (Scratch) register.

Both are general purpose registers that can be used both as source and destination. To use as a source, there's the A) and S) words that behave like W).

To have an operand target one of those registers as their destination, you use the A>) and S>) words.

In terms of capabilities, the A register has the exact same ones as W. The S register can also be used in the same way, but it's used by the "/mod," operation as a result register, so it's a tiny bit less "permanent".

Register permanence

The A and S registers cannot be expected to keep their value across word calls. As soon as another word is called, we must consider those values destroyed.

However, all HAL operations and macros must preserve A's value (unless A is the destination, of course). Therefore, we can rely on A's value as long as the code we generate doesn't branch to other words.

This applies to all "compiling" words such as dup, drop, if, then, etc. Those words are supposed to leave the A register alone.

The S register doesn't have the same guarantees and is used by some HAL operations and macros as temporary storage. That's why it's called "Scratch". Operations and macros using it with will mention it in their documentation.

Rule of thumb: if you're coding a HAL macro, use the S register. If you're coding a regular word, use A because the S register might be swept under your feet.

It goes without saying that W, being PS's top of stack, is preserved at all times.


All HAL instructions take either no operand (inherent) or one operand parameter. That operand parameter is a 32-bit number with an arch-specific (that is, opaque) bit structure and that contains all the information the instruction needs to know the source and destination of the instruction.

Operand words all end with ")". For example, "A) +," means "add 32-bit location where the A register points to the W register".

Some operand words are not directly operands, but operand modifiers. For example, "+)" adds a numerical offset to an operand. "W) 4 +)" refers to the memory location where W points to, with a 4 bytes displacement. The "8b)" modifier transforms the operand into a 8-bit operand.

By default, all operands refer to a memory location. Only through the "&)" operand (see below) can we refer directly to a value in a register.

&) operand modifier

The &) word takes an input operand and returns its dereferenced counterpart. For example, m) becomes i), W) becomes a direct reference to W, etc. This also works with displacements. For example, "RSP) 4 +) &)" yields an operand that points to RSP+4.

This operand might not be adressable directly by the host CPU. In that case, the HAL operator will compile two instructions. For example, "RSP) 4 +) &) +," under i386 would yield "dx sp 4 +) lea, ax dx add," ("dx" being any scratch register).

The "&)" word never writes instructions directly, only operator words. The "lea," above wouldn't be written when "&)" is called, but when "+," is.

The &) operand always results in a 32-bit operation. Don't try to apply 16b) or 8b) afterwards, this results in undefined behavior.

&) can't be used with i).

<>) operand modifier

The <>) word inverts the destination and the source of the HAL instruction, allowing arithmetic result to be stored directly in memory. For example, "$1234 m) 8b) <>) +," adds the 8-bit value at address $1234 to W and stores the result directly in address $1234 without affecting W.

8b) and 16b) arithmetics

8b) and 16b) modifiers only apply to memory access and all arithmetics are "upscaled" to 32-bit with regards to flags settings and carry management (the C flag is never set in 16b) or 8b) mode).

This also applies to compare, which means that, for example, "$4242 i) @, RSP) 8b) compare," will never set the Z flag because even if RSP) is $42, comparison is done one the whole W register.

RSP) and [rcnt]

The only HAL operation that automatically adjusts [rcnt] (see "Local variables" in [doc/usage/rs]) is rs+,. Other HAL operations don't touch [rcnt]. Therefore, special care must be taken when using the RSP) operand.

If you're inside of a regular "code" word, you don't care about [rcnt], so you can ignore this warning.

However, if you're writing HAL as part of a macro that could be used in a word that has local variables, then every time you write a HAL operation that modifies RSP ("RSP) @+," for example), you need to adjust [rcnt] accordingly or else you'll break local variables.

Branching and flags

The HAL can generate branching, conditional or not, through its "branch" instructions. "branchC,", the conditional branching generator, takes a "cond" argument. This argument is generated by words like "Z)", ">)", etc. and the number they yield is arch-specific. The idea is that through this number, the "branchC," instruction knows the kind of native branch instruction to generate.

These conditions depend on flags being set (or not) and the conditions under which these flags are set is not exactly the same across achitectures.

To be able to rely on consistant condition branching, HAL instructions make guarantees on the flags set by certain instructions. If an instruction has a "Z" next to it in the listing below, it's safe to conditionally branch using "Z)" or "NZ)" right after having called it. Even if the native instruction for a particular HAL word doesn't supply that flag, the HAL instruction will generate the necessary native instructions to make it so, at the cost of speed. For this reason, we minimize flag guarantees in HAL words.

Condition flags are only valid right after the instruction that's supposed to set it. Flags are considered destroyed as soon as you compile another instruction... with one exception: branching preserves flags. This means that after a branch, branchC, or branchR, flags are the same as they were before.

Arithmetic conditions (">)", "<=)", etc.) have no associated flag and can only be used after a "compare,".

If you look at branching words signatures, you'll notice something weird: they take an address parameter and yield an address result. This is because those words can be used for both backward branching or forward branching. What they do is to write down a branch to the supplied address, but also yield an address to a memory location that can then be used by "branch!".

Therefore, a backward branch looks like "begin .. branch, drop" and a forward branch looks like "0 branch, .. here swap branch!"

All addresses passed to branching words are absolute addresses. If the native instructions use relative branching addressing, the HAL takes care of the translation.

pushret, and popret,

In Dusk, "Call" means "Push the address of the instruction following the current one to RSP and then jump to the address being called". "Return" means "Pop RSP and jump to that address".

On "traditional" CPU architectures, this maps exactly to the behavior of the native "call" and "return" instructions, so we can live a happy life of blissful ignorance when using these CPUs.

On some CPUs such as ARM, the native "call" model is to save the address we'll want to return to to a register and leave the task of push/popping to RSP to the programmer.

Of course, one thing we could do is to simply wrap all calls and returns in Dusk into RSP push/pop operations, but that would squander a wonderful speedup opportunity: With such an approach to calling, we can avoid one push and one pop on each "leaf" routine call, that is, on each call to a routine that doesn't call any other routine. That adds up to quite a lot of pushes and pops.

To grab this opportunity, the HAL has two words: pushret, and popret,

On "traditional" CPUs, these are noops. On ARM, these words push and pop the return address register to and from RSP.

Words defined through "high level" mechanism such as ":" call those words automatically, no need to worry. However, words created with "code" don't.

This means that if you create such a word and that this word calls another word, it needs to call "pushret," as a prelude and to call "popret," before it returns. Leaf words don't need to do that, which makes them faster.

Word marks

On some architectures (on WASM), there is a strong separation between "code" and "data" and memory areas containing executable code have to be "marked" as such. We do so with "wordmark,". Calling this results in an arbitrary number of bytes to be written to "here" to serve as such mark (it's a noop in architectures not needing it).

This mark also serves as JIT status, which means that when a piece of code changes (for example in "realias"), its word mark should be re-written.

These marks apply to - every word on sysdict (as well as their code16 and code8 metadata) - every location that is targeted by a CALL instruction

The "code", "code8b", "code16b" words will automatically write a word mark, while the "entry" word will not.


Operand words:

W) -- op Indirect W register A) -- op Indirect A register S) -- op Indirect S register PSP) -- op Indirect PSP register RSP) -- op Indirect RSP register i) n -- op Immediate operand. Can't use with <>) m) addr -- op Absolute address +) op disp -- op Apply displacement to op. Can be applied multiple times. Displacement can be negative. W>) op -- op Set destination to W A>) op -- op Set destination to A S>) op -- op Set destination to S &) op -- op Dereference operand (see above) <>) op -- op Direction of the operation is inverted (see above) 8b) op -- op Make op 8-bit 16b) op -- op Make op 16-bit 32b) op -- op Make op 32-bit (default)

Operand query words:

(W? ( op -- f ) Yields whether "op"'s base register is W, regardless of its direct/displacement/invert flags. Because W is the top of stack, there's often special processing to do in that case. (split ( op -- width dst src ) Split "op" in three components. The broad idea is that "or"-ring those components together will yield "op" back. "src" is the "heaviest" component. It includes displacement/invert flags. "src" is "destination-less" and "width-less" (not the same thing as 32-bit) and cannot be used as-is with an operation. Either "or" it back with a "dst/width" or re-apply explicit destination/width words on it. Conversely, you shouldn't "or" a component back with a "full" op, only a "splitted" one. "dst" only includes the destination register. "width" is a set of flags, not a number of bytes. To get a number of bytes, apply "(sz" to it. (sz ( op -- n ) Yields "op" width in bytes, that is, 4, 2 or 1.

Branching and conditions:

Z) "Zero" flag set. On "compare,", this means "equal". NZ) "Zero" flag not set. On "compare," this means "not equal". <) <=)

) =) s<) Signed comparison s<=) s>) s>=)

C>W, cond -- If cond is met, W=1. Otherwise, W=0.

branch, a -- a Branch to address a, yielding a "forward" address for "branch!" branchC, a cond -- a Branch to address a if condition is met, yielding "a" for "branch!" branch! tgtaddr braddr -- Given "braddr" yielded by a previous "branch" instruction, change the reference at the address so that it targets "tgtaddr". Used for forward branching. branchR, a -- Compile a branch to address a while at the same time setting the "return address" (commonly, that means pushing to RSP, but not always) to the instruction directly following this one. This is commonly called a "call". branchA, -- Branch to the address held in the A register. exit, -- Compile a return from a call. pushret, -- Push the current return address to RSP (on relevant CPUs) popret, -- Pop RSP in return address register (on relevant CPUs) wordmark, -- Write a "word mark". See section above.


@, op -- Read source into dest @!, op -- Swap dest and source +, op -- Z dest + source -, op -- Z dest - source *, op -- dest * source. /mod, op -- divide dest by source and put remainder in S register. Can't be used with S>). <<, op -- dest lshift source

, op -- dest rshift source s>>, op -- Arithmetic ("signed") shift right. Instead of filling the "right" part of dest with zeroes, it fills it with its b31. &, op -- Z dest and source |, op -- Z dest or source ^, op -- Z dest xor source @+, op -- Read source into dest and then add 4/2/1 to operand's dereferenced source. Cannot be used with m) i) &) If source is the same as dest, behavior is undefined. -@, op -- Subtract 4/2/1 to operand's dereferenced source and then read source into dest. Decrement happens before fetch, hence the symbol order being the opposite of "@+". Cannot be used with m) i) &). compare, op -- * Compare source to dest (all flags set) example: if W=1 and A=2, "A) &) compare," makes "<)" condition true. +n, n op -- Z Add n to source without affecting dest Can't use with i) or <>) -W, -- W = -W

HAL macros

These words below aren't implemented in kernels and are combinations of the words above, but they're pretty useful nonetheless.

(src op -- src Same as "(split rot> 2drop" (dst op -- dst Same as "(split rot 2drop" (width op -- width Same as "(split 2drop" ps+, n -- Add n to PSP rs+, n -- Add n to RSP

!, op -- Write dest to source. Shortcut for "<>) @," !+, op -- Equivalent to "<>) @+,". Source==dest is weird, but fine. -!, op -- Equivalent to "<>) -@,".

[@+], ( op -- ) Do an indirect fetch+increase, that is: Fetch a 32-bit address at op's src and fetch perform a "@," on that address. Then, increase op's src by op's size in bytes.

Cannot be used with "S)", "&)", "i)" or "<>)". Destroys the S register, but using it with "S>)" is fine.

[!+], ( op -- ) Do an indirect store+increase, that is: Fetch a 32-bit address at op's src and fetch perform a "!," on that address. Then, increase op's src by op's size in bytes.

Cannot be used with "S)", "S>)", "&)", "i)" or "<>)". Destroys the S register.


To give a better idea of how the HAL works, here are examples with their corresponding i386 instructions:

PSP) @, ax si 0 d) mov, A) 8b) !, bx 0 d) al mov, RSP 4 +) A>) +, bx sp 4 d) add, PSP) &) A>) @!, bx si xchg, PSP) <>) <<, cx ax mov, si 0 d) cl shl, RSP) @+, ax sp 0 d) mov, sp 4 i) add, A) 16b) !+, bx 0 d) 16b) ax mov, bx 2 i) add, A) 16 +) &) @, bx 16 d) lea, $1234 m) +n, $1234 m) 42 i) add, 42 PSP) &) +n, si 42 i) add, 54 i) -, ax 54 i) sub,

Here are actual word implementations:

code drop PSP) @+, exit, code dup PSP) -!, exit, code swap PSP) @!, exit, code nip 4 ps+, exit, code over PSP) -!, PSP) 4 +) @, exit, code 1+ 1 i) +, exit, code lshift PSP) <>) <<, PSP) @+, exit, code c@ W) 8b) @, exit, code , HERE m) A>) @, 4 HERE m) +n, A) !, PSP) @+, exit, code not 0 i) compare, Z) C>W, exit, code execute A) &) !, PSP) @+, branchA,


' foo branchR, \ call "foo" ' foo branch, drop \ jump to "foo" 42 i) compare, ' foo Z) branchC, drop \ jump to "foo" if W=42 here branch, drop \ infinite loop \ Execute code "..." only if W <= A A) &) compare, 0 >) branchC, ... here swap branch!

HAL number bank

Numbers supplied to i) m) and +) can be any number of the 32-bit range. Nevertheless, as per HAL API constraints, all operands occupy only one PS slot.

Therein lies a problem: how can a 32-bit operand include its necessary metadata along with a possible offset that can be anything in the 32-bit range? It does so through a number bank mechanism.

The number bank is a 4b * 16 global and static rolling buffer. This allows us to assign arbitrary number to slots numbering from 0 to 15. This slot number occupies only 4 bit in our HAL operand, which is much more manageable.

This allows up to 16 operands associated with numbers to coexist at once on PS, making HAL and assemblers (which piggy-back on this API) pretty macro-able.

Every kernel implement this number bank and expose this API:

hbank' ( slot -- a ) Get address associated to bank slot.

hbank! ( n -- slot ) Reserve a new slot and write "n" to it. Yield the ID of the new slot.

hbank@ ( slot -- n ) Yield number in slot.