We are currently working on new rules for what content should and shouldn't be allowed on this website, and are looking for feedback! See Esolang:2026 topicality proposal to view and give feedback on the current draft.

User:Enny/ENNCPU

From Esolang
Jump to navigation Jump to search
ENNCPU
Paradigm(s) imperative
Designed by Juice F. Ennedy
Appeared in 2025
Memory system register, cell
Computational class Turing-complete
Reference implementation https://gitlab.com/kneelian/enncpu/
Influenced by ez80, ARM7
File extension(s) .enn

ENNCPU is an instruction set architecture (ISA) for a fantasy 24-bit CPU.

Design

ENNCPU is meant to be a somewhat serious ISA inspired by real CPUs and designs of the eighties and nineties, meant to represent a CPU that would power high-end workstations in an alternative computing history. It is not very esoteric, as it is not particularly difficult to program in, but it nevertheless has some unusual and otherwise rare design choices. General outline of important or interesting features:

  • Eight general-purpose 24-bit registers named A, B, C, D, E, F, G, H
  • Shadow registry i.e. secondary register file that can be swapped out at need (cf. z80, ez80)
  • Special-purpose registers, including a stack pointer, inaccessible for general computation
  • 24-bit address space (cf. ez80), and all GP registers can be used as address registers
  • Two permission levels (kernel and usermode)
  • Simple MMU managing mapping and permissions for 4K pages
  • Load-store architecture (general RISC ideas) with pre/post-increment/decrement (syntax inspired by m68k)
  • Big-endian memory access and instructions (cf. SPARC)
  • Two-operand instructions (cf. x86) where the destination is one of the sources
  • Fixed-width 16-bit instructions (cf. Arm Thumb 1)
  • 24-bit floating point instructions operating on the same registers as normal arithmetic (cf. RISC-V Zfinx ext.)
  • Packed 3x8-bit operations providing SIMD in a single GP register (cf. RISC-V P ext.)
  • Predication for all instructions (cf. Arm6/7/11), determined by single bit
  • Size of immediate arguments limited to 6 or rarely 8 bits (cf. small immediates in Arm)
  • Non-predicated conditional jumps JxZy, JxNZy based on whether a register is (non)zero (cf. Aarch64 CBZ, CBNZ)

ENNCPU is trivially the same computation class as an average general-purpose CPU; if we say that x86 or the ez80 are Turing-complete, then so is ENNCPU (despite memory limitations).

The exact instruction set of ENNCPU is not yet finalised and is subject to change

Example code: Fibonacci generator

Here is an example of a short ENNCPU program that generates the first 38 members of the Fibonacci sequence (up to register limits):

.ORG 0x0000
.SEC %PGA
@START
    ADRL  A, @STK       ; 6800
    ADRM  A, @STK       ; 7004   / loads addr. of @STK into A
    WSP   A             ; 2c11   / writes A to SP

@FIB
    MOV   A, #0         ; 6800
    MOV   B, #1         ; 6a01
    MOV   H, #37        ; 6725
    
    POPS  D
@FIB_LOOP
    PSHS  A             ; 2c02
    
    MOV   C, B          ; 0391
    ADD   B, A          ; 0208
    MOV   A, C          ; 0382
    
    SUB   H, #1         ; 23c1
    JMNZO H, @FIB_LOOP  ; 55e5

    PSHS  D             ; 2cc2

    RET                 ; 2e00

    ; at this point the stack contains the 
    ; Fibonacci numbers in reverse order
   
%PGA

.ORG 0x0400
@STK

Let's break it down.

Sections and the Preprocessor

The above code was written for the ASSENNBLER, the reference assembler for ENNASM that produces ENNCPU object code. As the object code can be pretty sparse (kilobytes or megabytes between sections), the ASSENNBLER provides an option to assemble "sections" of code into a chunk, starting at a given address and incrementing automatically, independently. This is done through the following preprocessor idiom:

.ORG #0xZZZZZZ
.SEC %SECTION_NAME
   ...
   ...
   ...
%SECTION_NAME

The .ORG directive tells the preprocessor to make its internal address counter start from a new address, and the .SEC %X ... %X 'harness' places invisible start and end labels that the preprocessor uses to assemble the rest of the code. This harness also reserves 8 bytes of bookkeeping information at the start of every section, to help the loader know where to load the given code.

This is technically not obligatory to include, but given as the reference implementation relies on this idiom and the 8 bytes of bookkeeping, it is practically unavoidable.

Setting up the Stack

The following three instructions set up the stack pointer:

   ...
   ADRL A, @STK
   ADRM A, @STK
   WSP  A
   ...

.ORG 0x0400
@STK

As ENNCPU uses a fixed-width, 16-bit instruction format, immediates are limited in size. Therefore, the address of the designated stack area (and, really, pretty much any other construct) has to be built up in thirds. As we know the stack area is located in the first 64K (specifically, at 0x400, as the preprocessor directive shows), we can avoid loading the top byte: the upper bytes of the register are already zeroed out by ADRL. The ASSENNBLER takes care to replace instances of label names in these kinds of instructions with the actual label address or label offset, depending on the exact instruction type.

The instruction WSP Rs simply moves the value of the register into the stack pointer; consider it an equivalent of Arm's mov sp, x0 in this case.

Labels

Like in all assemblers, labels do not actually exist: they're tools of convenience to help the programmer write jumps and memory accesses more fluently. In ENNASM, labels are prefixed with @ and are an ASCII string up to 16 characters long. There are no format restrictions (e.g. a label can start with a number), but every label must be unique; unlike in some assemblers, new instances of labels do not overwrite previous instances (you can't do things like b.gt 1f that you can in Arm).

@FIB
   ...

Loading Immediates

The instruction mnemonics ADRL Rd, #imm8 and MOV Rd, #imm8 actually alias the same underlying instruction.

@FIB
   MOV  A, #0
   MOV  B, #1
   MOV  H, #37

Using the Stack

ENNCPU has a rich array of memory access operations, which include pushes and pops using the internal SP register. All memory operations can be in one of three widths: byte access (8-bit), word access (16-bit), and sesqui access (24-bit). The first operation here pops the return address (more on this later) into a temporary storage register, D. After this, we see the Fibonacci loop pushing the value stored in reg. A at the start of every iteration. As we have already prepared the register with 0 before the start of the loop, it first pushes 0 (the first Fibonacci number).

   POPS  D
@FIB_LOOP
   PSHS  A
   ...

Register Arithmetic

What follows is a pretty simple sequence of moving to a temporary, summing two registers, and moving that temporary back into the main registers. As ENNCPU is a two-operand architecture much like x86, all arithmetic operations store the result in the first operand register listed: the instruction ADD B, A will produce a value that is the sum of these two registers, and then store it in B at the end of its execution. When there is an immediate (#imm6 for arithmetic instructions), the same principles of storage apply.

   ...
   MOV  C, B
   ADD  B, A
   MOV  A, C
   SUB  H, #1

Jumps

ENNCPU has a variety of jump types. Aside from regular offset jumps (adding or subtracting from the instruction pointer) (JxO), it can also do quick jumps to the first 512 bytes (occasionally called 'page A'/PGA in examples and comments) (JxA), or jump to an address stored in a register (JxR). It furthermore includes conditional jumps (like Arm64's cbz and cbnz), whose execution depends on the value of the first register argument (JxZy and JxNZy). All regular jumps start with the mnemonic prefix JM Finally, all ENNCPU jumps can also involve linking to ease subroutine/procedure implementation: linking jumps push the return address, i.e. IP + 2, to the stack before jumping, and their mnemonics start with the prefix JL.

At the end of a subroutine, it's common to see a simple RET, representing popping the return address from the top of the stack and jumping to it. Improper stack management can lead to false returns to nonsense memory addresses.

   ...
   JMNZO  H, @FIB_LOOP

   PSHS   D
   RET

This short sequence of code tells us that execution will jump to @FIB_LOOP only if H is nonzero (i.e. the loop counter is still true), and otherwise (i.e. when the loop ends), we push the return address that we have previously stashed in D, and then return. Of course, it is possible to also just do JMR D, as it stores the return address, but this is semantically more opaque, and might be less optimised in the future.

Example code: bigint integer square root

Here is another code example, taken from a wider int48 library. The bigint/int48 library implements 48-bit integers using a pair of 24-bit registers. For one-operand functions, the input is in {A, B} = {47:24, 23:0} (i.e. big-endian format), and for two-operand functions, the second operand is in {C, D} in the same format. Much like regular register operations, this library assumes that the first source operand will also be the destination.

This bit of code implements the integer square root function for such integers:

@ISQRT_I48
    MOV   C, #0 
    MOV   D, #0
    SET   D, #23

@ISQRT_LOOP
    MOV   E, C
    BOR   E, D
    
    MOV   F, E
    MULA  F, E
    MOV   H, E
    MULB  H, E

    CEQ   H, A
    CLE.P F, B
 OR CLT   H, A
    MOV.P C, E

    LSHR  D, #1
    JMNZO D, @ISQRT_LOOP

    MOV   B, C
    MOV   A, #0
    RET

Let's break down the new concepts.

Multiplication

Unlike most other arithmetic instructions, ENNCPU multiplication comes in two parts: the MULA Rd, Rs instruction provides the low 24 bits of a 24 x 24 multiplication, and MULB Rd, Rs gives the high 24 bits. This instruction pair cannot take immediate arguments. A sequence of MULA and MULB thus provides the full possible range of multiplication products split in two registers; this is exactly how it's used here:

    MOV   F, E
    MULA  F, E
    MOV   H, E
    MULB  H, E

Thus, H:F now contain the square of E.

Bitwise operations

ENNCPU has a robust set of bitwise operations. Apart from the expected BOR (i.e. bitwise-or), it also features a bidirectional barrel shifter (LSHL Rd, Rs/#imm6 for leftward shifts, and LSHR Rd, Rs/#imm6 for rightward shifts), and per-bit toggles: SET Rd, #imm6 will toggle only the specified bit, counting from the right edge with the LSB being #0 and the MSB #23. Immediates larger than 23 are ignored.

Conditional execution

Leaning on the design choices present in 32-bit versions of the Arm architecture, almost every instruction can be predicated. Unlike Arm predication, and due to encoding limitations (as all instructions are 16-bit), there is only one predication bit and one predication type, corresponding to a boolean internal flag register. To compensate for this, ENNCPU has a range of conditional instructions that set or clear the flag depending on the outcome.

Predicated instructions are suffixed with .P in the assembly, and are thus clearly visible; the dot is not optional. Pretty much any instruction (other than debug instructions and the error instructions) can be predicated, including conditional instructions themselves.

Conditional instructions come in two groups: one large group that sets the flag register if they evaluate to true and clears it if they evaluate to false, and one smaller group that only sets the flag register if they evaluate to true but preserves it if they evaluate to false; this second group is prefixed by a separate OR Cxx in the assembly stream, although they nevertheless still assemble to one 16-bit instruction. These two types, alongside predication of conditional instructions, allow chaining of conditionals in a way that can form more complex predicates. In the above code, we have the following section:

    ...
    CEQ   H, A
    CLE.P F, B
 OR CLT   H, A
    MOV.P C, E
    ...

In less technical terms, this translates to if( ((H == A) and (F <= B)) or (H < A) ) { C = E; }, and shows both predicated conditionals (i.e. if CEQ H, A evaluates to true, then also check CLE F, B) and or-conditionals (i.e. regardless of whether the first two checks passed, set the flag to true if CLT H, A evaluates to true). At the end, we have a predicated move, which will execute only if the previous condition block evaluates to true.

ENNCPU predicates are not consumed after a predicated instruction passes: you can chain any number of predicated instructions off of one conditional check.

Example code: Mandelbrot set fragments

We won't cover the whole Mandelbrot set subroutine here as it's somewhat long (~200 lines of code), but there are a few more things I want to draw attention to.

Preprocessor literals and memory operations

The ENNASM preprocessor supports embedding literals in the object code bytestream. As far as numerical types are concerned, there are preprocessor directives for .INT8, .INT16, .INT24 and .FP24. To operate on these, you put a label before them, load the label's address, and then do memory accesses. In the Mandelbrot set program, the following occurs:

   ...
   ADRL A, @VARS
   ADRM A, @VARS
   LDRS D, A+
   LDRS H, A+
   ...
   LDRS E, A+
   LDRS H, A
   ...

@VARS
.FP24 1.0
.FP24 -2.0
.FP24 1.0
.FP24 -1.0
   ...

Here, after the address has been loaded into A using the previously encountered ADRL/ADRM Rd, #imm8, this address is used to load a sesqui-word (24 bits) into D using the instruction LDRS Rd, Rs. Other load sizes available are byte loads (through LDRB Rd, Rs) and word loads (through LDRW Rd, Rs). Although not present in this program, stores are correspondingly done using the STRB/STRW/STRS Rs, Rd instructions. Since floating points are, as the name shows, 24 bits wide, they have to be moved using sesqui loads and stores.

The plus suffix after the address register in LDRS D, A+ means that the instruction does a post-increment, i.e. after loading from the address, an offset equivalent to the size of the loaded data (here this is 3 because a sesqui is three bytes). All four combinations of pre- and post-increment and -decrement are available, using plus and minus suffixes and prefixes. The syntax of these addressing modes is inspired by m68k equivalents.

ENNCPU does not support address displacement; if you need to use the address register as a base, you have to do integer arithmetic on it before memory operations.

Shadow registry

A powerful feature of the x80 and descendant ISAs is the idea of the shadow registry. This is a parallel register file that can partially or entirely be swapped into the active set to provide a sort of extended registry without expending encoding space. In the Mandelbrot program, the shadow registry is used to store some constants that get accessed frequently:

   ...
   SHDW B 
   SHDW D
   SHDW H
   ...
   LITE B
   ...

Aside from SHDW Rs, which copies the register's contents into the corresponding shadow register, and LITE Rd, which copies the contents of the corresponding shadow register onto the active register, there is also SWPR Rd, which exchanges values between shadow and light registry without overwriting anything (compare z80's EX AF, AF'), and SWAP which exchanges the entire registry (compare z80's EXX).

Floating point support

ENNCPU provides some hardware support for floating point numbers, in a fp24 format. Floating point operations operate on the same registry integer and other arithmetic does: there is no separate float register file (similar to the RISC-V Zfinx extension) The floating point format used by ENNCPU is a truncation of IEEE-754 32-bit floats, cutting off the low 8 bits of the mantissa. This gives them one sign bit, 8 bits of exponent, and 15+1 mantissa bits. It is possible to bitwise convert to floats used on, for example, Arm from the ENNCPU format simply by doing std::bit_cast<float>( u32(Rd) << 8 ); on the register containing the desired floating point value.

Floating point operations do not support any immediates at all; all operations must be done between two registers. To load values into registers for arithmetic or comparisons, you must either load them from memory, or must first move an integer value into a register and perform a float cast. The first was already shown above; the second is found elsewhere in the code:

   ...
   MOVL  H, #0x72
   MOVM  H, #0x01  ; 370
   FCNV  H, H
   ...

That is, the FCNV Rd, Rs instruction takes the unsigned integer value stored in Rs and casts it to fp24. If you need a negative value, a further negation step using FNEG Rd, Rs is also necessary.

ENNCPU supports a range of floating point operations, which you can see in more detail in the ISA overview elsewhere on this page. In the Mandelbrot program, the following arithmetic instructions are used, in order of appearance:

   FSUB  D, H
   FDIV  D, H
   FMUL  G, D
   FADD  G, H
   FCGT  H, A

Notably, ENNCPU does not have a floating point equality conditional: all fp24 checks must be done either through a greater-than or smaller-than (FCGT Rd, Rs and FCLT Rd, Rs). This was chosen due to the low precision of the fp24 type, and programmers are instead advised to load a satisfactorily small epsilon value into a register and check for equality by a sequence of FSUB A, B and FCLT A, E (assuming E holds the epsilon).

Also appearing in the code are the floating point constant instructions, in the format of FC1/2/PI/... Rd that load a constant into a register. These are inspired by the x87 FLDPI and related instructions. These can sometimes be used to bypass loading from memory or moving an immediate through the int-to-float conversion pipeline. The only such instruction used in the Mandelbrot program is FC2 A. Others are described in the ISA overview.

Example code: Hello World!

For interfacing with the outside world, ENNCPU can either do syscalls that execute specific functions in its handler (platform-specific and more involved, but ultimately more powerful), or rely on the emulator's debugging facilities (debug instructions, pretty simple). Let's print "Hello World!" using debug instructions:

.ORG 0x0000
.SEC %PGA

ADRL A, @STRING
ADRM A, @STRING

@LOOP
   LDRB  B, A+
   DBGC  B
   JMNZO B, @LOOP

MOV  B, '\n'
DBGC B
ERR

@STRING
.ASCIZ Hello World!

%PGA

The instruction DBGC Rs prints out the register content as a character/u8 to the debug console. It has siblings in DBGB/DBGW/DBGS/DBGF Rs that print out the value in the register as one of a few number formats.

The .ASCIZ directive embeds a string as a stream of bytes into the binary, terminated by a zero byte. In the current implementation of the assennbler, strings do not have to be put in quotes, and due to a logic bug in source parsing commas must be 'escaped' by a sequence of two commas: .ASCIZ This is 100% software-rendered,, from a string. Whitespace is collapsed in string literals (any sequence of spaces is treated as a single space), and trailing whitespace is ignored.

The MOV Rd, #imm8 instruction can also use single quotes to denote character literals; apart from the regular character set, ENNASM also includes special handling for newlines and tabs, escaped by a backslash.

Defines and Macros

ENNASM supports some relatively simple preprocessor defines and macros. These function as a simple string replacement: there are no functional macros. Defines replace a string with another string and can be useful to rename registers or input constants; macros insert a block of code with the appropriate numbered registers into the instruction stream. Both of these facilities operate before the assembler even sees the instructions, so disambiguations and aliases still work fine.

Defines are done with $def X Y, after which all instances of X will be replaced by Y. A define is removed using $undef X, after which replacement will not be done.

Macro blocks are opened with $macro NAME NR and closed with $endm. The name of a macro must be an alphanumeric string; the number count shows how many registers the macro takes. Instead of the register names [A, H], macro registers are instead named m0, m1, … m9 for however many registers the macro takes, allowing up to 10 register names. Macro bodies can also contain real register names, in case that a static register is required. A macro pseudo-instruction can be used like any other built-in instruction mnemonic, with the caveat that the name must be prefixed with an underscore. A simple example with defines and macros:

$def X A

$macro int48add 3
   ADD   m0, m1
   CGT   m1, m0
   ADD.P m2, #1
$endm

.ORG 0x000
.SEC %PGA
   SUB A, #1
   SUB B, #1
   _int48add X, B, C ; expands to ADD A, B / ...
$undef X
%PGA

Due to the fact that macro expansion is simply string replacement, macro arguments can also be immediates or even instruction mnemonics. A good example of this is the following RGB888 to RGB565 macro I wrote:

; m0 is the target reg
; m1 - m3 are the colours
; m4 is a temp register
$macro 888_TO_565 5
   MOV  m0, #0
	
   MOVL m0, #m1 
   LSHR m0, #3
   BOR  m4, m0

   MOVL m0, #m2
   LSHR m0, #2
   LSHL m4, #6
   BOR  m4, m0

   MOVL m0, #m3
   LSHR m0, #3
   LSHL m4, #5
   BOR  m4, m0

   ENDW m0, m4
$endm

; ...

   _888_TO_565 A, 0xff, 0x00, 0xff, B ; magenta 

Once defined, a macro cannot be undefined.

General ABI Principles

To write larger programs in ENNASM, some degree of modularity is required. Most code written by me follows these principles:

  • Shadow registers are all considered volatile (but the callee must swap back before returning)
  • Light registers are callee-saved, except for arguments which are volatile
  • Code reached from interrupts cannot use the shadow registry at all

The first two points ensure that no registers will get overwritten unpredictably by a called function, while the third ensures that interrupts cannot corrupt a function's state. Functions that do not use the shadow registry are thus interrupt-callable, assuming they also take adequate care of spilling the remaining registers onto the stack. Popping wantonly off the stack and grabbing the caller's own stack contents is, of course, ill-advised.

Syscalls and Interfacing

ENNCPU supports a unified interface for requesting functions from outside the "computation sandbox". These are called syscalls and are done using the instruction SYSCI #imm8. Unlike basically all other instructions, syscalls take arguments in fixed registers [ABCD], and write back to them without further CPU interaction. Syscall #0 is guaranteed to never work.

Currently, the only syscall implemented is SYSCI #1, which is the syscode for interfacing with DEVICEs. The CPU harness holds a vector of attached, enabled DEVICEs, starting at zero. Currently, ENNCPU can have up to 65535 DEVICEs (highly unlikely it will ever need more), and all DEVICEs implement a common interface and a set of unique functions. The arguments to SYSCI #1 are:

   A :  device number
   B :  function number
   C :  argument 1
   D :  argument 2

All DEVICEs guarantee that they will implement function #1 (A == 0x01), which is IDENTIFY(), in pseuco-C++ looking like:

void IDENTIFY(CPU* cpu)
{
	cpu->ACTIVE_SET.A = 0x00xxxxxx; // name pt. 1
	cpu->ACTIVE_SET.B = 0x00yyyyyy; // name pt. 2
	cpu->ACTIVE_SET.C = 0x0000zzzz; // number of functions
	return;
}

That is, after the syscall resolves, registers A and B will store up to 6 bytes corresponding to the DEVICE's name or identifier, and register C will store the number of functions the DEVICE implements. Functions on every DEVICE start at 1 and are numbered sequentially without any gaps; function #0 is guaranteed to return cleanly without change of CPU state. All implemented functions will also write back to register A their specific signature for executing correctly, which can be up to 6 bytes long.

The only DEVICE currently implemented is the DSKENN, the disk device, implementing three functions:

   01 :  IDENTIFY()
   02 :  TAKE_SECTOR(u32 which, u32 whence) -> A := 0x14  // write to disk
   03 :  GIVE_SECTOR(u32 which, u32 where)  -> A := 0x24  // read from disk

The DSKENN is automatically instantiated when the emulator is provided a floppy disk image as an optional commandline argument; it currently handles only 1.44MB floppy disk images with 512B sectors, regardless of the filesystem implemented on them.

The function GIVE_SECTOR() will copy a sector of 512 bytes from the disk (indicated by the argument C = which) to the CPU's memory (at the address indicated by argument D = where), notifying the CPU with the signature 0x24. While DSKENN also exposes TAKE_SECTOR() which will correctly write back its signature (notifying the CPU that there was no error), DSKENN is currently a read-only interface.

Example use of a syscall to call the DSKENN DEVICE to give sector zero (boot sector) of a floppy image:

.ORG 0x0000
.SEC %PROG
    MOV   A, #0
    MOV   B, #1
    SYSCI #1

    ADRL  C, @DSKENN_ID
    ADRM  C, @DSKENN_ID
    LDRS  D, C+
    LDRS  E, C+

    CNE   A, D
 OR CNE   B, E
FAR JMO.P @KILL

    MOV   A, #0
    MOV   B, #3  ; GIVE_SECTOR()
    MOV   C, #0  ; zeroth
    ADRL  D, @DESTINATION
    ADRM  D, @DESTINATION
    SYSCI #1

    CNE   A, #0x24
FAR JMO.P @KILL

    ; reaching this point we can be sure
    ; that @DESTINATION holds 512 bytes of
    ; floppy disk contents

@KILL
    ERR

@DSKENN_ID
.ASCII DSK
.ASCII ENN

%PROG

.ORG 0x0400
@DESTINATION

Hacks and Tricks

It's obvious that ENNASM is a program of questionable code quality and robustness; some things that might seem obvious to you are not really idiomatic or even easy to do in it. In the future, these shortcomings might even be fully fixed, but for the time being they remain an unavoidable part of the assembler. Nevertheless, there are ways to compensate for these limitations.

Stack manipulation

The stack pointer is not directly modifiable, but sometimes it is necessary to discard state off it. To avoid popping into a register (for example, if all registers are currently live), there exist the zero-argument instructions JUNKB, JUNKW, JUNKS, that discard the top value off the stack. Furthermore, there also exist zero-argument instructions to swap the top two elements of the stack without popping, these being SWVB, SWVW, SWVS. In case data needs to remain on the stack but is still needed, the one-argument PEEKB/PEEKW/PEEKS Rd will do the same as a pop but without advancing the SP.

Loading large immediates

As was mentioned before, ENNCPU immediates are generally limited to 6 or 8 bits, and ENNASM follows this limitation by generally disallowing immediates larger than an instruction's allowed width. Nevertheless, one immediate type can be used to bypass this: labels. As a label resolves to a number that is potentially up to 24 bits long, one can use the following trick to load a large immediate into a register:

.ORG 0x028A
@FB_WIDTH
 ; ...
   MOVL A, @FB_WIDTH
   MOVM A, @FB_WIDTH

Since the .ORG directive and labels do not actually insert any code, and MOVx and ADRx are aliases of each other, you can use the label resolution scheme to load a register-sized immediate without having to manually break it down or use magic numbers in code.

Saving predication state

In many cases, predication state might be worthwhile to preserve. While it's not recommended to rely on predication state remaining the same across a subroutine call, saving the predication state is unavoidable when, for example, an interrupt is raised and execution is redirected. The instruction pair RPS/WPS does read the internal state register, but this contains a lot of different flags, and extracting the predication bit from it can get messy. Keeping in mind that predication state is preserved until a conditional instruction is hit, one can simply use the existing predication state to store something to the stack:

   PSHS  A
   MOV.P A, #1
   PSHB  A

 ; ...

   POPB  A
   CEQ   A, #1
   POPS  A

Instruction Set Overview

Instruction Full Name Pseudo-C++ Equivalent
𝑖𝑛𝑠𝑛.P ... predicated (any insn.) if(FLAG) { /* ... */ }
ERR error quit();
MOV Rd, Rs move register Rd = Rs;
MOVL Rd, #imm8 move into low Rd = (arg & 0xff);
ADRL Rd, @label
MOVM Rd, #imm8 move into mid
Rd &= 0xff00ff;
Rd |= ((arg & 0xff) << 8);
ADRM Rd, @label
MOVH Rd, #imm8 move into high
Rd &= 0x00ffff;
Rd |= ((arg & 0xff) << 16);
ADRH Rd, @label
GETL Rd, Rs get low Rd = (Rs & 0x0000ff);
GETM Rd, Rs get mid Rd = (Rs & 0x00ff00) >> 8;
GETH Rd, Rs get high Rd = (Rs & 0xff0000) >> 16;
HALFA Rd, Rs get lower 12b Rd = Rs & 0xfff;
HALFB Rd, Rs get upper 12b Rd = (Rs >> 12) & 0xfff;
XCG Rd, Rs exchange registers t = Rd; Rd = Rs; Rs = t;
INV Rd, Rs invert register Rd = ~Rs;
SWAP swap with shadow registry
CURR_REG[] = SHADOW_REG[];
SHADOW_REG[] = OLD_REG[];
SWPR Rd swap single register
CURR_REG[Rd] = SHADOW_REG[Rd];
SHADOW_REG[Rd] = OLD_REG[Rd];
SHDW Rd store Rd in shadow SHADOW_REG[Rd] = CURR_REG[Rd];
LITE Rd get Rd from shadow CURR_REG[Rd] = SHADOW_REG[Rd];
RSP Rd read stack pointer Rd = SP;
WSP Rd write stack pointer SP = Rd;
ADD Rd, Rs/#imm6 add Rd += Rs;
Rd += imm6;
SUB Rd, Rs/#imm6 subtract Rd -= Rs;
Rd -= imm6;
MULA Rd, Rs multiply low
u64 temp = u64(Rd) * Rs; 
Rd = temp & 0x00ffffff;
MULB Rd, Rs multiply high
u64 temp = u64(Rd) * Rs; 
Rd = (temp >> 24) & 0x00ffffff;
DIV Rd, Rs integer divide Rd /= Rs;
MOV Rd, Rs modulo/remainder Rd %= Rs;
ENDW Rd, Rs reverse word endianness Rd = (Rs_low << 8) | (Rs_mid >> 8);
ENDS Rd, Rs reverse sesqui endianness Rd = (Rs_low << 16) | Rs_mid | (Rs_hi >> 16);
CEQ Rd, Rs/#imm6 check equal FLAG = (Rd == Rs);
OR CEQ Rd, Rs/#imm6 if(Rd == Rs) { FLAG = true; }
CNE Rd, Rs/#imm6 check nonequal FLAG = (Rd != Rs);
OR CNE Rd, Rs/#imm6 if(Rd != Rs) { FLAG = true; }
CGT Rd, Rs/#imm6 conditional greater FLAG = (Rd > Rs);
OR CGT Rd, Rs/#imm6 if(Rd > Rs) { FLAG = true; }
CGE Rd, Rs/#imm6 conditional greater-or-equal FLAG = (Rd >= Rs);
OR CGE Rd, Rs/#imm6 if(Rd >= Rs) { FLAG = true; }
CLT Rd, Rs/#imm6 conditional less than FLAG = (Rd < Rs);
OR CLT Rd, Rs/#imm6 if(Rd < Rs) { FLAG = true; }
CLE Rd, Rs/#imm6 conditional less-or-equal than FLAG = (Rd <= Rs);
OR CLE Rd, Rs/#imm6 if(Rd <= Rs) { FLAG = true; }
CAND Rd, Rs conditional AND FLAG = (Rd && Rs);
COR RD, Rs conditional OR FLAG = (Rd || Rs);
CNAND Rd, Rs conditional NAND FLAG = !(Rd && Rs);
CNOR Rd, Rs conditional NOR FLAG = !(Rd || Rs);
BAND Rd, Rs/#imm6 bitwise AND Rd = (Rd & Rs);
BOR Rd, Rs/#imm6 bitwise OR Rd |= Rs;
BXOR Rd, Rs bitwise XOR Rd ^= Rs;
BNOR Rd, Rs bitwise NOR Rd = ~(Rd | Rs);
SET Rd, #imm6 set bit Rd |= (1 << arg);
CLR Rd, #imm6 clear bit Rd &= ~(1 << arg);
TGL Rd, #imm6 toggle bit Rd ^= (1 << arg);
CBIT Rd, #imm6 test bit FLAG = (Rd & (1 << arg));
POPC Rd, Rs popcount Rd = std::popcnt(Rs);
PARI Rd, Rs bit parity Rd = std::popcnt(Rs) % 2;
LEAD Rd, Rs count leading zeros Rd = std::countl_zero(Rs);
TAIL Rd, Rs count tailing zeros ...
PSHI #imm8 push immediate SP -= 1; PUSH8(imm8);
PSHB Rs push byte SP -= 1; PUSH8(Rs);
POPB Rd pop byte Rd = POP8(); SP += 1;
PSHW Rs push word SP -= 2; PUSH16(Rs);
POPW Rd pop word Rd = POP16(); SP += 2;
PSHS Rs push sesqui SP -= 3; PUSH24(Rs);
POPS Rd pop sesqui Rd = POP24(); SP += 3;
SVWB swerve bytes t1 = POP8(); t2 = POP8(); PUSH8(t1); PUSH8(t2);
SWVW swerve words ...
SWVS swerve sesquis
LDRB Rd, Rs load byte Rd = READ8(Rs);
LDRW Rd, Rs load word Rd = READ16(Rs);
LDRS Rd, Rs load sesqui Rd = READ24(Rs);
STRB Rs, Rd store byte STORE8(Rd, Rs & 0xff);
STRW Rs, Rd store word STORE16(Rd, Rs & 0xffff);
STRS Rs, Rd store sesqui STORE24(Rd, Rs & 0xffffff);
LDRx Rd, -Rs load with predecrement Rs -= x; Rd = READx(Rs);
LDRx Rd, +Rs load with preincrement Rs += x; Rd = READx(Rs);
LDRx Rd, Rs- load with postdecrement Rd = READx(Rs); Rs -= x;
LDRx Rd, Rs+ load with postincrement Rd = READx(Rs); Rs += x;
STRx Rs, ±Rd store with preinc/dec. ...
STRx Rs, Rd± store with postinc/dec. ...
PSH2W Rs1Rs2 push two words
SP -= 2; PUSH16(Rs1);
SP -= 2; PUSH16(Rs2);
PSH2S Rs1Rs2 push two sesquis
SP -= 3; PUSH24(Rd2);
SP -= 3; PUSH24(Rd1);
POP2W Rd1Rd2 pop two words
Rd1 = POP16(); SP += 2;
Rd2 = POP16(); SP += 2;
POP2S Rd1Rd2 pop two sesquis
Rd1 = POP24(); SP += 3
Rd2 = POP24(); SP += 3;
LD2S Rd1Rd2, Rs load two sesquis
Rd1 = READ24(Rs); 
Rd2 = READ24(Rs + 3);
LD2S Rd1Rd2, -Rs load with predecrement
Rs -= 6; 
Rd1 = READ24(Rs); 
Rd2 = READ24(Rs + 3);
LD2S Rd1Rd2, Rs+ load with postincrement
Rd1 = READ24(Rs); 
Rd2 = READ24(Rs + 3); 
Rs += 6;
STR2S Rs1Rs2, Rd store two sesquis
STORE24(Rd, Rs1);
STORE24(Rd+3, Rs2);
STR2S Rs1Rs2, -Rd ... ...
STR2S Rs1Rs2, Rd+ ... ...
FCNV Rd, Rs convert to float Rd = f24(Rs);
FCST Rd, Rs cast from float Rd = u24(Rs);
FC0 Rd fp24 constant zero Rd = 0.0;
FC1 Rd fp24 constant one Rd = 1.0;
FC2 Rd fp24 constant two Rd = 2.0;
FCSQ2 Rd fp24 constant √2 Rd = 1.414213562;
FCPHI Rd fp24 constant φ Rd = 1.6180339;
FCPI Rd fp24 constant π Rd = 3.1415926;
FTAU Rd fp24 constant τ Rd = 6.283185;
FCE Rd fp24 constant e Rd = 2.718282;
FNEG Rd, Rs floating point negate Rd = -f24(Rs);
FABS Rd, Rs floating point abs. value Rd = std::abs(Rs);
FREC Rd, Rs floating point reciprocal Rd = 1.0/Rs;
FADD Rd, Rs floating point add Rd = f24(Rd) + f24(Rs);
FSUB Rd, Rs floating point sub Rd = f24(Rd) - f24(Rs);
FMUL Rd, Rs floating point mul Rd = f24(Rd) * f24(Rs);
FDIV Rd, Rs floating point div Rd = f24(Rd) / f24(Rs);
FMOD Rd, Rs floating point remainder Rd = f24(Rd) % f24(Rs);
FABS Rd, Rs floating point absolute value Rd = std::abs(f24(Rs));
FSQT Rd, Rs floating point square root Rd = std::sqrt(f24(Rs));
FLOG Rd, Rs floating point logarithm Rd = std::log(f24(Rs));
FSIN Rd, Rs floating point sine ...
FCOS Rd, Rs floating point cosine
FTAN Rd, Rs floating point tangent
FASIN Rd, Rs floating point arcsine
FCGT Rd, Rs floating point greater than FLAG = f24(Rd) > f24(Rs);
FCLT Rd, Rs floating point smaller than ...
RET return from jump-link IP = POP24();
JMA @label/#imm9 jump absolute (to first 512B) IP = label_addr;
JLA @label/#imm9 jump-link to first 512B
PUSH24(IP);
IP = label_addr;
JxO @label/±#imm5 jump with offset IP += i6(arg);
JxR Rs jump to register IP = Rs;
JxZO Rt, @label/±#imm5 jump with offset if zero if(Rt == 0) { IP += i6(arg); }
JxZR Rt, Rs jump to register if zero if(Rt == 0) { IP = Rs; }
JxNZO Rt, @label/±#imm5 jump with offset if nonzero if(Rt) { IP += i6(arg); }
JxNZR Rt, Rs jump to register if nonzero if(Rt) { IP = Rs; }
FAR JMO @label/±#imm13 far jump with offset IP += i14(arg);
FAR JLO @label/±#imm13 far jump-link with offset
PUSH24(IP);
IP += i14(arg);