ROSE-9?

-dealloc ·

14 min read Original article ↗

A few months ago I was thinking again about Pavel Panchenka’s (LLM-assisted) “We’d be Better Off with 9-bit Bytes”, which posits that standardizing on nine-bit bytes would have saved us from a number of encodings running out of space while not having much of a downside. They’re even historically plausible given the PDP machines from DEC with 18-bit and 36-bit words. But what would the impact have been on small systems, those that got the most out of every byte, if not necessarily every bit?

Well, many years ago I designed a toy tiny CPU, ROSE-8, and built a little game on top of it. That was an 8-bit CPU with 8 general-purpose registers, a 16-bit memory space, and variable-length instructions (1-2 bytes/words). To fit all of that in, it uses an “accumulator” model where most instructions operate on a special register called “the accumulator” (or just “it” for short), removing the need to specify an operand.

So what would a similar CPU design look like in the 9-bit world? It could just be a generous expansion of the instruction space, allowing for many more operations. It could just add more general-purpose registers, or provide names for some of the special-purpose ones—an extra bit takes you to not just 9 but 16 possible values. But really, the most interesting thing to do would be to make the design more ambitious by using only one byte per instruction (rather than ROSE-8’s variable-length instruction set). This isn’t just vanity; a simpler instruction set format would make both an assembler and the CPU easier to implement.

Reminder: I am not a professional CPU designer. Don’t take this as generally-applicable advice, or even the best answers for this intellectual exercise!

There were a few hurdles, of course:

  • Having 9-bit bytes doesn’t mean it’s a good idea to have 9 general-purpose registers. You can choose between 8 options in 3 bits, but you can’t choose between 9 with fewer than 4 bits. “A foolish consistency is the hobgoblin of little minds”, and so my design for a ROSE-9 still only has eight general-purpose registers.

  • Similarly, operations that refer to a specific bit position either need 4 bits (with a bunch of illegal encodings), or they need a workaround. Sometimes this works out nicely: rotating by 0 bits is pointless, so you can choose to encode 1-to-8-bit rotations with the values 0-7. Other times some manual nudging is needed, perhaps by reserving a secondary encoding.

The biggest change, of course, is that all those nice two-word operations that took an “immediate” value are now in danger. For arithmetic and many bitwise operations, that’s just the cost of the new design—instead of applying a constant to the accumulator directly, now you have to swap out the current value to a named register, load the constant, and then recombine. Alas, this means that some two-byte operations from ROSE-8 become three- or even four-byte operations in ROSE-9. But there are a few instructions from ROSE-8 that don’t fit that directly:

  • GETI (“get immediate”): load an 8-bit constant into it
  • JOFI (“jump offset immediate”): jump forward or backward a constant N bytes, encoded as a signed 8-bit integer
  • CABI (“call absolute immediate”): jump to the address encoded as a 8-bit constant, combined with the current value in the code special register; save the return address in the code and link registers
  • BEZI (“branch if equal to 0, immediate”): same as JOFI, but only if it is equal to 0

Skipping over GETI for the time being, we have JOFI. One option would be to introduce an instruction like JOFA instead, “jump to offset specified by accumulator”. (And indeed, ROSE-8 has a COFA that behaves that way.) That’s not terrible, but needing two instructions to do a plain old jump within a single function didn’t feel great (and we’ll see more about why later). At the same time, the fewer bits you have to encode a jump offset, the smaller the offset range you can encode—made worse by needing signed offsets so you can both jump forwards (for conditions) and jump backwards (for loops). Ultimately I bit the bullet and declared that ROSE-9’s JOFI would have a full 7-bit signed immediate, only half the range of ROSE-8’s. Hopefully that would still be enough—it’s a tremendous cost to the encoding space!

CABI is a bit more of a problem—we don’t really want to say “a function must start within the first 7 bits of a (9-bit) page”. So using “CABA” makes sense anyway—as long as we can find a good replacement for GETI. At least it won’t be longer than the two bytes used in ROSE-8…

BEZI is ROSE-8’s only conditional branch instruction, forcing you to build any other conditions out of it. The reason? Like JOFI, you want a good chunk of range for the branch, but that means any new condition requires an entire instruction. We already think the branch range for ROSE-9’s JOFI is tight, and so is the instruction space, so instead we’ll use an idea I had for ROSE-8 expansion that I’ve been calling “short branches” (though it’s similar in practice to “conditional execution” from real CPUs). Rather than arbitrary branches, we can have instructions like SKNZ “skip if non-zero” and SKLZ “skip if less than zero (negative)”. The ROSE-9 BEZI equivalent is thus SKNZ JOFI—still two bytes, but now allowing more flexible conditions. (And this is why it’s important to have JOFI only require a single instruction!)

That leaves GETI, and this one’s a doozy. There’s simply not room to squeeze an entire 9-bit constant into a 9-bit instruction, obviously. This is a well-understood problem, and the answer is to make a trade-off: certain values will take one instruction to “materialize” (usually smaller ones), and more complicated values will require chaining instructions together.

The most reasonable way to divide this up would either be 5-bits-then-4-bits (minimizing encoding space consumed) or 6-bits-then-3-bits (being consistent with other instructions with 3-bit immediates). For a while I was taken with the idea of a 7-bit GETI, which could materialize an entire ASCII value in one instruction. There’s precedent for a 7-bit immediate with JOFI, even! But it takes up way too much encoding space, and without the justification of ASCII, there’s not that much advantage to 6 bits over 5 bits. So ROSE-9’s GETI is 5 bits, sign-extended, and then you can make a different decision about the high bits using XORH.

Why XORH, rather than GETH (replacing the high bits)? Or ADDH (adding to the high bits) or IORH (inclusive OR of the high bits)? (Though IORH would require zero-extension rather than sign extension.) All of them handle the case where you need to tweak the high bits of the accumulator, whether to complete a constant or otherwise. The question, then, is what else could they be useful for…and, well, I don’t really know, having not written anything in ROSE-9, but XOR seems most likely to be useful, since it can toggle the high bit of a signed integer. Maybe that won’t actually be useful in the end! It doesn’t seem worse than anything else, anyway.

(If I were really trying to be cautious in design, I would require the modified high bits to be 0000 unless immediately following a GETI instruction, leaving the definition of how they affect existing values for a future iteration of the architecture. That feels a bit silly for a self-set design exercise, though.)

The last thing to do is take a second look at calls. CABA will now work for us, but it would take a total of three bytes to do an arbitrary call now (GETI XORH CABA). That’s worse than ROSE-8 for a pretty common operation, so I decided to add a CAXH (“call absolute with an XOR of the high bits”) that fuses XORH and CABA.

With that, we have the complete ROSE-9 instruction set!

000_000_000 STOP halts execution
        001 CABL call absolute link: pc <- code[link],
                 link <- return addr,
                 code <- return segment
        010
        011
        100 SKEZ skip next instruction if it ≠ 0
        101 SKNZ skip next instruction if it = 0
        110 SKLZ skip next instruction if it < 0
                 (i.e. if the highest bit is set)
        111

000_001_000 GETC it <- code
        001 GET1 it <- data1
        010 GET2 it <- data2
        011 GETL it <- link
        100 SETC code  <- it
        101 SET1 data1 <- it
        110 SET2 data2 <- it
        111 SETL link  <- it

000_010_000 PRNT print it (for debugging or toy programs)
        001 WTD1 wait on data1: spin or sleep until data1[it] > 0,
                 then decrement data1[it] (for MMIO)
        010 WTD2 wait on data2: spin or sleep until data2[it] > 0,
                 then decrement data2[it] (for MMIO)
        011
        100 COMP it <- ~it (bitwise complement)
        101 NEGT it <- -it (integer negation)
        110 INCA it <- it + 1
        111 DECA it <- it - 1

000_011_xxx

000_100_iii ROLI it <- (it << (imm3+1)) | (it >> (9 - (imm3+1)))
            (rotate left immediate)
     01_iii LSLI it <- it << (imm3+1) (logical shift left)
     10_iii LSRI it <- it >> (imm3+1) (logical shift, zero-extended)
     11_iii ASRI it <- it >>> (imm3+1) (arithmetic shift, sign-extended)

001_000_aaa LD1R it <- data1[ra]
    001_aaa ST1R data1[ra] <- it
    010_aaa LD1U it <- data1[ra], then ra <- ra + 1
    011_aaa ST1U data1[ra] <- it, then ra <- ra + 1
    110_aaa LD1D ra <- ra - 1, then it <- data1[ra] 
    111_aaa ST1D ra <- ra - 1, then data1[ra] <- it
    110_aaa 
    111_aaa

010_000_aaa LD2R it <- data2[ra]
    001_aaa ST2R data2[ra] <- it
    010_aaa LD2U it <- data2[ra], then ra <- ra + 1
    011_aaa ST2U data2[ra] <- it, then ra <- ra + 1
    110_aaa LD2D ra <- ra - 1, then it <- data2[ra] 
    111_aaa ST2D ra <- ra - 1, then data2[ra] <- it
    110_aaa 
    111_aaa

011_000_aaa GETR it <- ra
    001_aaa SETR ra <- it
    010_aaa SWAP (it, ra) <- (ra, it)
    011_aaa
    100_aaa SKER skip next instruction if it = ra
    101_aaa (SKNR?)
    110_aaa SKLR skip next instruction if it < ra
    111_aaa

100_000_aaa ADDR it <- it + ra
    001_aaa SUBR it <- it - ra
    010_aaa IORR it <- it | ra (bitwise inclusive OR)
    011_aaa XORR it <- it ^ ra (bitwise exclusive OR)
    100_aaa ANDR it <- it & ra (bitwise AND)
    101_aaa LSLR it <- it << ra
    110_aaa LSRR it <- it >> ra (logical shift)
    111_aaa ASRR it <- it >>> ra (arithmetic shift)

101_0ii_iii GETI it <- ±imm5 (sign-extended immediate)
    100_000 NOPE (would be XORH 0)
      i_iii XORH "xor high": it <- it ^ (imm4 << 4)
    110_000 CABA call absolute accumulator: pc <- code[it],
                 link <- return addr,
                 code <- return segment
      i_iii CAXH "call absolute with xor high" (fused XORH-then-CABA)

110_000_000 JOFA jump offset accumulator: pc <- pc + 1 ± it
  i_iii_iii JOFI jump offset (sign-extended) immediate: pc <- pc + 1 ± imm7
                 (imm7 nonzero; JOFI -1 is an infinite loop)

Which we can also render based on its…octits? octalites? octal digits:

000
STOP
001
CABL
002
003
004
SKEZ
005
SKNZ
006
SKLZ
007
010
GETC
011
GET1
012
GET2
013
GETL
014
SETC
015
SET1
016
SET2
017
SETL
020
PRNT
021
WTD1
022
WTD2
023
024
COMP
025
NEGT
026
INCA
027
DECA
100-7
LD1R
110-7
ST1R
120-7
LD1U
130-7
ST1U
140-7
LD1D
150-7
ST1D
160-7 170-7
200-7
LD2R
210-7
ST2R
220-7
LD2U
230-7
ST2U
240-7
LD2D
250-7
ST2D
260-7 270-7
300-7
GETR
310-7
SETR
320-7
SWAP
330-7 340-7
SKER
350-7 360-7
SKLR
370-7
400-7
ADDR
410-7
SUBR
420-7
IORR
430-7
XORR
440-7
ANDR
450-7
LSLR
460-7
LSRR
470-7
ASRR
500-537
GETI
600
JOFA
601-677
JOFI (forwards)
700-777
JOFI (backwards)

Honestly this is an improvement on ROSE-8 just for not wasting as much encoding space: this time there is only a single no-op encoding—an encoded instruction that does nothing besides the usual advancing of the program counter—compared to ROSE-8’s eleven (by my count; can you spot them all?).

I didn’t make an actual interpreter this time around, nor even a customasm definition. But I do think it was a fun exercise—I did indeed manage to cram an entire CPU instruction set into 9 bits, and I don’t think it would be too terrible to program for!1 A lot more swapping into scratch registers than ROSE-8, though—maybe you’d end up running out of registers more, but maybe you’d just swap more to your larger memory space. Still, it raises the question…

ROSE-8 in 8

…could you do the same for ROSE-8? (Which looks like this.) Or, well, an 8-bit CPU of some kind. The answer is…kinda, but it gets super squished:

0000_0000 STOP halts execution
     0001 PRNT print it (for debugging or toy programs)
     0010 WAIT spin or sleep until data1[it] > 0,
               then decrement data1[it] (for MMIO)
     0011 SKNZ skip next instruction if it ≠ 0
     0100 CABA call absolute code[it],
               link <- return addr,
               code <- return segment
     0101 CABL call absolute code[link],
               link <- return addr,
               code <- return segment
     0110
     0111

0000_1000 GET1 it <- data1
     1001 GET2 it <- data2
     1010 GETC it <- code
     1011 GETL it <- link
     1100 SET1 data1 <- it
     1101 SET2 data2 <- it
     1110 SETC code  <- it
     1111 SETL link  <- it

0001_0000 COMP it <- ~it (bitwise complement)
     0001 NEGT it <- -it (negation)
     0010 LSL1 it <- it << 1
     0011 LSR1 it <- it >> 1 (logical shift, zero-extended)
     0100 ASR1 it <- it >>> 1 (arithmetic shift, sign-extended)
     0101
     0110
     0111

0001_1xxx

0010_0aaa GETR it <- ra
   0_1aaa SETR ra <- it
   1_0aaa SWAP (it, ra) <- (ra, it)
   1_1aaa

0100_0aaa ADDR it <- it + ra
  00_1aaa SUBR it <- it - ra
  01_0aaa ANDR it <- it & ra (bitwise AND)
  01_1aaa IORR it <- it | ra (bitwise inclusive OR)
  10_0aaa XORR it <- it ^ ra (bitwise exclusive OR)
  10_1aaa LSLR it <- it << ra
  11_0aaa LSRR it <- it >> ra (logical shift)
  11_1aaa ASRR it <- it >>> ra (arithmetic shift)

1000_0aaa LD1R it <- data1[ra]
  00_1aaa ST1R data1[ra] <- it
  01_0aaa LD1U it <- data1[ra], then ra <- ra + 1
  01_1aaa ST1U data1[ra] <- it, then ra <- ra + 1
  10_0aaa LD2R it <- data2[ra]
  10_1aaa ST2R data2[ra] <- it
  11_0aaa LD2U it <- data2[ra], then ra <- ra + 1
  11_1aaa ST2U data2[ra] <- it, then ra <- ra + 1

1100_0000 NOPE (ADDI 0)
   0_iiii ADDI it <- it ± imm4 (sign-extended immediate)
   1_iiii GETH it <- imm4 << 4 ("get high")

1110_0000 JOFA branch pc + 1 ± it
   i_iiii JOFI branch pc + 1 ± imm5 (sign-extended immediate)
  • Signed 5-bit branch range is pretty tiny; there’s a good chance you’ll end up needing to chain branches a bunch of the time, or give up and do an absolute jump instead.
  • We’re going to only commit to exactly one “short branch”, SKNZ, so that we can emulate the full ROSE-8’s BEZI (“branch if zero, immediate”) with SKNZ JOFI. At least it’s still two bytes! (…if the branch is close enough.) Probably we’d end up wanting a SKLZ as well before too long.
  • The trade-off for immediates has swung to maximum space saving and thus instead of GETI/XORH I went with a two-part GETH with low nybble zeroed and a signed ADDI to fill it in—slightly better on space because it allows us to eliminate the dedicated INCR and DECR instructions (INCA/DECA in ROSE-9) in addition to ZERO. Amusingly, this tradeoff makes it easier to add small positive or negative numbers than in ROSE-9, at the cost of not being able to materialize small positive or negative numbers for other purposes as compactly.
  • Apart from the immediate instructions, this also drops reverse memory traversal APIs LD2D/ST2D and the dubiously-useful ISLT, while replacing alternate call API COFA with a plain JOFA and re-encoding NOPE as ADDI 0. Everything else is there though!

Register-based operations then take up most of the remaining encoding space—nothing exotic, just the basic bitwise, arithmetic, and memory operations you’d be used to. (I refused to give up the two separate data lanes though.) There’s still space for a few more instructions (2 register-based or imm3, 5 operand-less), but without trying to write something in this variant of ROSE-8 I’d hesitate to fill them in with anything right away. But it does fit in the end, and maybe this would indeed have been a simpler CPU to build in the first place.

This entry was posted on August 21, 2026 and is filed under Technical. Tags: ROSE-8, Assembly