Wyrm's Hole

Uncovering assembly

Posted on 2026-08-21

  1. What are we talking about?
    1. Registers
    2. Immediates
  2. Variations
    1. Operand count
    2. CISC vs RISC
    3. Variable-length encoding
  3. Instruction encoding
    1. Overview of the RISC-V and x86 ISAs
    2. RISC-V encoding types
    3. Behind the opcode
    4. Some RISC-V examples
    5. Understanding add in x86
    6. I instructions
    7. MR/RM instructions
    8. MI instructions
  4. Reproducing the results yourself
    1. RISC-V instructions
    2. x86 instructions
    3. Raw bytes
  5. A binary mess

This page contains a JavaScript piece of code which allows to make tables text bigger in order to improve readability. It is not needed to view this post and remains completely optional.

Today we're going to talk about assembly! Not production lines where things are made, but what our computer actually[1] runs! Maybe this sounds scary to you, but worry not, we'll go through this step by step. By the end of this post, I hope you'll know how the mythical "binary" is actually composed.
If you've read my previous post about branch predictors in VHDL, this one should be simpler. We're many steps higher in the abstraction world. How high level is that?!

The purpose of this post is to provide some understanding of assembly language and the "binary". I will not explain how it's handled inside the processor itself.

# What are we talking about?

We've got to start somewhere so, let's review the basics. When I talk about here assembly I mean the words that we usually write in a text file, in a readable character set such as ASCII.
The assembly then goes into a fantastic tool called, guess what, an assembler. This tool in itself can be quite complicated! The process to go from the words you write, the mnemonics, into the final product, is far from direct[2].
Its job is to transform the words you write into what the CPU really understands: the machine code. This is usally what people mean when they say everything is ones and zeroes, the "binary". Personally I'd rather refer machine code as "hexadecimal", simply because that's what we end up reading or sometimes writing.

Given the sheer size of some instructions, you'll really want to know your hexadecimal well. On some architectures an average instruction can be as large as 32 bits, 32 characters for a single instruction!

The definition of the mnemonics and hexadecimal representations are generally grouped in what is called an Instruction Set Architecture, or ISA for short.

Now let's define some common terms used in this field.

# Registers

Register is the most used "entity" in assembly. Where you use variables in most[3] other programming languages, ISAs define registers. Registers are a set of special memory locations which hold values, pretty much the same as a variable then!
But do remember that we're doing assembly here, registers can't just hold anything. There's no way you're going to store an entire string (a list of characters) in there!

There are many kinds of registers but we're only going to talk about one: general purpose registers. These registers are intended to hold integers values, which can be about anything. These can be addresses, numbers, parts of complex datastructures. Such values are called words.

If a register can hold 32-bits numbers, then a word is 32-bit wide. Then there are half words, 16 bits here, double words, 64 bits here, etc. An 8-bit value is always called a byte, there's no reason to do otherwise.

# Immediates

So an assembly program is defined as a list of instructions. Each instruction does one thing on its own and grouping them allows you to make anything. Just like every other programming language.

But how do we actually put numbers in our registers? We said that these could represent whatever we want. Some instructions are dedicated to handling numbers directly in them, these are called immediates. These numbers can't be changed, otherwise you'd be changing the instruction itself!

To sum up, an immediate is a number which is encoded in the instruction itself. Having many instructions which handle immediates is really useful, as constant numbers are used in many places.

# Variations

ISAs can vary in many ways. We'll mainly talk about basic variations which interest us in our quest to understanding how the hexadecimal is written.

# Operand count

This is certainly the biggest aspect an assembly programmer is going to notice. An operand is a parameter used inside of an instruction. For example: suppose we have three registers x1, x2, and x3. We wish to write to x1 the sum of the two other registers. With an instruction accepting three operands, we could write is as such:

add x1, x2, x3

In many modern assembly languages, the destination is the leftmost operand. This is not always the case though.

The operand count can vary from zero to four for very complex instructions![4] That being said, the operand count of instructions within the same ISA does not vary much. It is either greater due to extensions or lower for simpler instructions. You will rarely see a division which has only one operand and a multiplication which has three[5].

Let's see how things work when the operand count is reduced to two. Such instructions are called dyadic and have one operand which is both read and written to. If we wish to add ebx to eax we now have:

add eax, ebx

In C you would typically write this as eax += ebx. With three operands, we would have simply done the following to add x2 to x1:

add x1, x1, x2

Most of the time, operands are either registers or immediates.

We won't review operand count of one or zero. These are used for very small architectures which we don't care about here.

# CISC vs RISC

That one is a bit more complex to talk about. Those denominations have mostly been used in the past when architecture technology was still rapidly developing and improvements were sparse.
CISC stands for Complex Instruction Set Computer, while RISC stands for Reduced Instruction Set Computer. An ISA is said to be CISC or RISC depending on the number of task a single instruction does and the simplicity of its encoding, at least that's how we define it here[6].

In general a RISC instruction does a simple single thing: load a value, do an arithmetic operation, jump to an address.[7] On the other hand, CISC can merge multiple aspects into a single instruction.

Let's have some examples shall we? We'll reuse our two ISAs above, that is RISC-V and x86. RISC-V is a RISC ISA using three operands while x86 is a CISC ISA using two operands.

Suppose we want to load a 32-bit value from memory at address x2 (resp. ebx) and add it to the register x1 (resp. eax).

In the first case, for RISC-V, we will need to do the following:

lw x2, x2
add x1, x1, x2

While in x86 this can be done in a single operation:

add eax, [ebx]

Now, another example to emphasize this difference. Suppose we have an array address at register x3 (resp. ecx), with an index pointing to one of its values saved in register x2 (resp. ebx). We wish to get the the 64-bit product of this value and x1 (resp. eax). Note that this is an array of 32-bit values so we need to scale the index by 4.

Here is the previous operation written in C, x86, and RISC-V:

#include <stdint.h>
uint64_t res = (uint64_t)eax + (uint64_t)ecx[ebx];
mul [ecx+4*ebx]
; multiply eax by operand and put result in edx:eax
slli x2, x2, 2   ; multiply x2 by 4
add x3, x3, x2   ; get the address of the value
lw x3, x3        ; load it
mulhu x2, x1, x3 ; high 32 bits of the product
mulu x1, x1, x3  ; low 32 bits of the product

Multiplying or dividing an integer by a power of two can be done efficiently with bitshifts. Here we shift the x2 by 2, effectively multiplying it by 4.

# Variable-length encoding

Encoding refers to the way the instructions are translated into hexadecimal. An instruction can be encoded in many bytes. When the length of all instructions never varies, the ISA is said to have a fixed length encoding.
However, when the size of the instruction depends on one of its operands or varies between instructions of the same category, the ISA is said to have a variable length encoding.

We don't know how instructions are encoded yet, but we can use simple examples to see this difference in size. Let's first go with x86, which has an encoding length I'll let you guess.

Until now we implicitely used what is called the "Intel" syntax of the x86 assembly language. This results in a simpler syntax but requires some parameters and options to set.

The little manipulation we'll do only works on a Linux machine running on x86. You can reproduce this on another kind of machine, it'll be harder though. Anyways, let's first write out test file:

; adds.s
.intel_syntax noprefix
add eax, 30
add eax, 400
add ebx, 30
add ebx, 400
add eax, ebx

Then assemble it into a file which by default is named a.out:

as -msyntax=intel adds.s

Finally, we'll do something kind of backward. Disassemble the resulting file! I'll filter out the lines that interest us:

objdump -D -Mintel a.out
Disassembly of section .text:

0000000000000000 <.text>:
   0:	83 c0 1e             	add    eax,0x1e
   3:	05 90 01 00 00       	add    eax,0x190
   8:	83 c3 1e             	add    ebx,0x1e
   b:	81 c3 90 01 00 00    	add    ebx,0x190
  11:	01 d8                	add    eax,ebx

As we can see, the length varies a lot. It's not the same whether the immediate is small or big, whether the register is eax or ebx, or even whether we add registers or immediates!

I'll do it short for RISC-V, below, every instruction is four bytes!

addi x1, x1, 30
addi x1, x1, 400
addi x2, x2, 30
addi x2, x2, 400
add x1, x1, x2

Know that in RISC-V there is also an extension which allows the use of "compressed" instructions[8], that is, instructions that only use two bytes. Technically, RISC-V could accomodate longer instructions as well. To this day it does not.
I do not consider this variable-length because it does not depend on the operands.

# Instruction encoding

We are now going to see how exactly an instruction is encoded. The process of finding what instructions are available and how they are encoded can be quite involved. In general, what you want to do when studying an ISA is refer to its specification. That's a "water is wet" kind of statement, but it really helps.

To be clear, what I refer to as "encoding" is the hexadecimal representation of an instruction.

For the rest of this post, hexadecimal numbers will be prefixed with 0x and binary numbers with 0b. Sometimes these will be omitted depending on context.

In the following subsections we'll mainly study RISC-V because it is well designed and simple. We'll also quickly learn to encode x86 so you can see how different it is.

Below are provided links to their respective specifications, in PDF:

# Overview of the RISC-V and x86 ISAs

As it is the case in all the article, we only care about the 32-bit variants of each ISA here. That is, a word is 32 bits.

RISC-V is a modern ISA designed to be used both academically and in practice. It is open by nature and, betrayed by its name, follows deeply the RISC design. Its nature makes it an ideal choice to create a CPU.
Its key features are:

x86 on the other hand is one of the oldest ISAs that is used in everyday devices. It emphasizes on backward compatibility by supporting older versions up to the 8086, released in 1978. It received many iterations to accomodate to modern uses. Its design is mainly represented by:

# RISC-V encoding types

RISC-V encoding types
bits3130292827262524232221201918171615141312111009080706050403020100
S-typeimm[11:5]rs2rs1funct3imm[4:0]opcode
B-typeimm[12|10:5]rs2rs1funct3imm[4:1|11]opcode
R-typefunct7rs2rs1funct3rdopcode
I-typeimm[11:0]rs1funct3rdopcode
U-typeimm[31:12]rdopcode
J-typeimm[20|10:1]imm[11|19:12]rdopcode

The way RISC-V encodes its instructions might be one of the simplest out there. It defines four main types which all have the same components:

Notice that registers take up 5 bits, that's because RISC-V has a total of 32 registers starting from x0. This special register is always equal to zero and writes to it do nothing. So for example, x7 is simply encoded as 7.

Now that we have all encoding types[9] we can also see that something is clearly impossible. How can one encode a 32-bit immediate if an instruction itself is 32-bit?
We see that most instructions using an immediate have a 12-bit immediate. These are sign-extended, that is, their highest bit is copied to all the higher bits. For example 0x800 is extended to 0xfffff800 while 0x700 becomes 0x00000700.
However, if we still wish to set the highest 20 bits of a register, we must use the dedicated instructions which are U-type.

While we're examining the encoding types, what do those letters mean? R is for Register, S for Store, I for Immediate and U for Upper. The latest one emphasizes the type's role of encoding the immediate range other types can't.

Knowing all that, we can now encode some RISC-V instructions!

# Behind the opcode

The following section is entirely optional and is not needed to understand how to encode RISC-V instructions. However, it can help understanding how opcodes are chosen. This is especially useful if you wish to try the exercise in the next section.

We said above that the opcode is equivalent to a magic number. While it serves this purpose, it is not a simple identifier which is enumerated for every instruction.
Instead, the opcode itself is defined according to a table available in Chapter 36, page 584. This further divides the opcode in categories in which the instructions can be classified. All instructions in a given category have the same encoding type.

RISC-V base opcode map, inst[1:0]=11
inst[4:2]000001010011100101110111
inst[6:5]
00LOADLOAD-FPcustom-0MISC-MEMOP-IMMAUIPCOP-IMM-32reserved
01STORESTORE-FPcustom-1AMOOPLUIOP-32reserved
10MADDMSUBNMSUBNMADDOP-FPOP-Vcustom-2reserved
11BRANCHJALRreservedJALSYSTEMOP-VEcustom-3reserved

For each type corresponds a color given in the table below. If an opcode is on a transparent background then we don't care about it.

Type-Color map
TypeSBRIUJ

# Some RISC-V examples

Let's start with our previous example. We wish to sum x2 and x3 into x1. Looking at page 585 of the manual, chapter 36, we have the instruction listings of RV32G. Let's look at the one corresponing to add.

bit layout for the add instruction
bits3130292827262524232221201918171615141312111009080706050403020100
add0000000rs2rs1000rd0110011

Now, converting our add x1, x2, x3 instruction is simple! We just have to plug-in the numbers for each register into their corresponding field.

bits3130292827262524232221201918171615141312111009080706050403020100
add00000000001100010000000010110011
hex003100b3

So what does this instruction end up in? A simple 4-byte number whose value is equal to 0x003100b3.

Now that we've seen how it works for the simplest type of instruction, let's go with one which accepts immediates. Let's go with one which illustrates the sign-extension capabilities as well: ori x12, x5, -4. Same as before, we'll look out in the big table given by the specification the corresponding opcode and funct3. Then we'll fill out the gaps. Here it is in one single step, as opposed to the more detailed example above:

bits3130292827262524232221201918171615141312111009080706050403020100
ori1111 1111 110000101110011000010011
hexffc2e613

This one gives us an hexadecimal value of 0xffc2e613. As we can see in the RISC-V instruction encoding -- and as we could have guessed by lookin at the table -- it is not evident to find back the registers in such value. Moreover, here we have a neatly encoded immediate, note that in other types this is not the case.

And that's it, you already know everything to encode RISC-V instructions by hand! Because of its sheer simplicity, there's not much to add. Below is a small table which contains all kind of instructions, so you can check these yourself.

Can you find back what these mean from the manual?

bits3130292827262524232221201918171615141312111009080706050403020100
a01111000000000000000110000010011
a hex78000c13
b11111110110101001000110000100011
b hexfed48c23
c00000000100001100001011000110011
c hex00861633
d00000000100110001100011001100011
d hex0098c663

The answers are given when we'll reproduce the results ourselves.

# Understanding add in x86

This section is just here to give you an overview of other encodings, here it is especially about x86 and for simplicity's sake we'll only look at the add instruction.

Due to the sheer complexity of the x86 ISA, its manual spans thousands of pages. A website is also available, its purpose is to easily give access to the list of instructions and their encoding. While the manual is always better, here the website for x86 instructions to find them easily.

Looking at ADD's page (Volume 2A section 1.3, page 3-14) we get the following table:

OpcodeInstructionOp/EnDescription
04 ibADD AL, imm8IAdd imm8 to AL
05 iwADD AX, imm16IAdd imm16 to AX
05 idADD EAX, imm32IAdd imm32 to EAX
80 /0 ibADD r/m8, imm8MIAdd imm8 to r/m8
81 /0 iwADD r/m16, imm16MIAdd imm16 to r/m16
81 /0 idADD r/m32, imm32MIAdd imm32 to r/m32
83 /0 ibADD r/m16, imm8MIAdd sign-extended imm8 to r/m16
83 /0 ibADD r/m32, imm8MIAdd sign-extended imm8 to r/m32
00 /rADD r/m8, r8MRAdd r8 to r/m8
01 /rADD r/m16, r16MRAdd r16 to r/m16
01 /rADD r/m32, r32MRAdd r32 to r/m32
02 /rADD r8, r/m8RMAdd r/m8 to r8
03 /rADD r16, r/m16RMAdd r/m16 to r16
03 /rADD r32, r/m32RMAdd r/m32 to r32

That's not even the whole table here! Instructions concerning 64-bit registers have been filtered out.

There are many things to see here. First of all, there is not one but four different encodings available here. These are denoted for each instruction in the "Op/En" column. I, R, and M stand for Immediate, Register, and Memory respectively. Also note that a M can also be a register.

In x86 registers can be subdivided in smaller units. For example EAX refers to the 32 bits of the register, while AX refers to its low 16 bits and AL to its low 8 bits. Not all registers can be subdivded like so. So to be clear, AL, AX, and EAX refer to the same register! Just not te same portion.

Register layout in x86.
EAXEBXECXEDXESPEBPESIEDI
--AX--BX--CX--DX--SP--BP--SI--DI
----AHAL----BHBL----CHCL----DHDL--------------------------------

# I instructions

There are many notations given in the "Opcode" column. What do "ib", "/0", "/r" mean? All of those entries are defined in the Volume 2A, section 3.1.1.1, page 3-2. Let's first go with the first three instructions which are argueably the simplest. We have the following meaning (taken from the manual):

ib, iw, id, io -- A 1-byte (ib), 2-byte (iw), 4-byte (id) or 8-byte (io) immediate operand to the instruction that follows the opcode, ModR/M bytes or scale-indexing bytes. The opcode determines if the operand is a signed value. All words, doublewords, and quadwords are given with the low-order byte first.

The last sentence indicated that x86 instructions are written in a little-endian order.

So for example, if we wish to encode add al, 13 the resulting machine code is 04 0d. What about all the 16-bit and 32-bit versions of instructions here? They share the same opcode! How do we distinguish them?
On x86 by default, instructions are 32-bit. That is, every instruction which has both 16-bit and 32-bit versions will default to the 32-bit one.[10] In order to encode the 16-bit version, one must prefix the opcode.
There are many different prefixes here, these are given in Volume2A section 2.1.1 pages 2-1 and 2-2. Here we will use the "operand-size override prefix" whose value is 0x66.

Hence, to encode add eax, 267 we will write 05 0b 01 00 00. Notice how the immediate size is tied to the register size, here we need to write 4 bytes to encode it.
Now to encode add ax, 267, all you do is prefix the machine code shown above with the corresponding byte. This will give us 66 05 0b 01.

Great, we know how to encode those instructions in x86, surely the next ones will be a breeze... Not so quickly!

# MR/RM instructions

For now we will skip over the opcodes in the MI format. Let's directly go to the ones after, whose encoding is MR or RM. We see here in the Opcode column a /r, this little letters hides a lot:

/r -- Indicates that the ModR/M byte of the instruction contains a register operand and an r/m operand.

But what is a ModR/M byte? To be simple: the main workhorse behind x86's capability to reference memory! This is the byte responsible of telling whether or not we dereference a register, point to an absolute location in memory, a relative one, or just a register.
With its many fields, it can encode the two operands of the add addition in a single byte. Due to its sub-byte decomposition, ModR/M fields are written in binary. Resulting machine codes are written in hexadecimal though.

The ModR/M byte is decomposed in the following fields:

bits0706050403020100
ModR/Mmodregr/m

Each of these bits have a specific meaning and consequence:

Each register is given the following value:

registerEAXECXEDXEBXESPEBPESIEDI
value (bin)000001010011100101110111

Finally, the displacements for mod=01 and mod=10 are simply encoded as bytes immediately following the ModR/M byte.

A whole table for the ModR/M byte is given in Volume 2A section 2.1.3, page 2-6. The "SIB" byte[12] is explained there as well.

That's a lot to take into account! So let's have some examples to have a feel on how exactly this magic byte works.
When adding two registers together it doesn't matter if we use the MR or RM encoding, so we'll go with the first one.

We have successfully encoded some complex instructions in x86! If we wished to encode add edx, [esi+4] instead, all we'd have to do is to replace the opcode's first byte 03 with 01 instead.

# MI instructions

Wow, the last section was a tough one. Hopefully this one is easier to understand, now that we've mostly grasped how the ModR/M byte works. Notice that in our opcodes, instead of /r we are left with /0 instead. Here is its meaning:

/digit -- A digit between 0 and 7 indicates that the ModR/M byte of the instruction uses only the r/m (register or memory) operand. The reg field contains the digit that provides an extension to the instruction's opcode.

So this is what we explained before in the ModR/M byte, the reg field can be used as an extension as well. Here the encoding is done in the exact same way as before, but with reg=000 instead for our case.

Without detailing further, here is the machine code corresponding to the add [ecx+12], 90 instruction: 83 41 0c 5a.

# Reproducing the results yourself

In the end, this post might not be as simple as I expected it to be, especially for the x86 part. This shows how complicated an ISA can be, and we only scratched the surface here!
I might have gotten the results wrong here, or you might do when trying to apply the rules yourself by hand. This smaller section is dedicated on reproducing the results we saw above.
As you might have guessed, in practice encoding and decoding instructions by hand is done very rarely. Thankfully there are many tools available to assemble and disassemble them.

On the Linux side we quickly saw how to use as for the assembling process and objdump for the disassembling process. These tools might not be available on your distro for all available architectures, and they themselves require time to learn. Moreover they also have ample documentation and tutorials available, as opposed to what I'm going to present here.

We will here use DuskOS as a software to both assemble and disassemble various ISAs. Currently it supports four of them: ARM, m68k, RISC-V, and x86.

DuskOS is built around the Forth programming language which I won't bother explaining in details here. Here's what you need to know:

You can simply download it from its repository on SourceHut. For the code below I will use the version 34 of the operating system.

Below are the instructions to build DuskOS in usermode. If for some reason this doesn't work, go simply in the duskos folder and reproduce the make command.

git clone https://git.sr.ht/~vdupras/duskos
cd duskos/usermode
git checkout v34
make dusk

This will leave you with an executable named dusk in the directory you currently are.

# RISC-V instructions

There are two main assembler designs in DuskOS. One consists of accumulating arguments into a number which will then be written to memory, it is best suited for fixed-length encoding.
The other design directly writes to memory, while less flexible, it works significantly better with variable-length encoding.

We will only scratch the surface of what is possible with the assemblers. DuskOS provides significant documentation which I greatly advise you to read if you want to know more.

In the RISC-V assembler, instructions are started with an accumulator word ending in ). For example addi) will start the assembling of the corresponding instructions. Here we will use 3p) as an accumulator word, it writes 3 arguments to the underlying instruction. Then ,) is used to write the result down to memory.

Let's write our RISC-V tests in a file named riscv.fs:

needs asm/riscv asm/riscvd

code testword
  add) x1 x2 x3 3p) ,)     \ assemble the add instruction
  ori) x12 x5 -4 3p) ,)    \ do the same with ori
  addi) x24 x0 1920 3p) ,) \ these are the mystery instructions 
  sb) x13 x9 -8 3p) ,)
  sll) x12 x12 x8 3p) ,)
  blt) x17 x9 12 3p) ,)
  exit,

6 to listingsz \ by default the disassembler doesn't know when to stop,
               \ here we know that we'll have only 2 instructions

' testword dis \ disassemble our words

Finally, we can simply test out result with:

./dusk -f riscv.fs

And with just this, we obtain our instructions!

0002e184 add    xRA, xRSP, xPSP                   003100b3
0002e188 ori    x12, xA, -4                       ffc2e613
0002e18c addi   x24, xZERO, 1920 -> $00000780     78000c13
0002e190 sb     x13, x9[-8]                       fed48c23
0002e194 sll    x12, x12, x8                      00861633
0002e198 blt    x17, x9, 12 -> $0002e1a4          0098c663

As with every disassembler output, on the left we have the address of the word being decoded. Then on the right we have our raw instructions in hexadecimal representation. It's exactly the same as predicted.

In DuskOS xZERO, xRA, xPSP, and xA are aliases for x0, x1, x3, and x5 respectively.

# x86 instructions

As you might have guessed the process above was really simple due to RISC-V's simple encoding and format. This is a bit more involved for x86, but hopefully utility words are provided. A few of them are:

Now let's see those instructions! We'll write it this time in x86.fs.

needs asm/x86 asm/x86d

32bmode \ tell that we want to compile for the 32-bit version

code testword
  al 13 imm) add,
  ax 267 imm) add,
  \ ax word) 267 imm) add, doesn't seem to work!
  \ the issue above is fixed in v35
  bx cx add,
  bx 0 d) cx add, \ displacement of 0 to dereference the register
  si 4 d) dx add,
  6712 abs) di add,
  dx si 4 d) add,
  cx 12 d) 90 imm) byte) add,

8 to listingsz

' testword dis
./dusk -f x86.fs
00033920 ADD  AL,0d         04 0d
00033922 ADD  EAX,0000010b  05 0b 01 00 00
00033927 ADD  EBX,ECX       03 d9
00033929 ADD  [EBX],ECX     01 0b
0003392b ADD  [ESI+04],EDX  01 56 04
0003392e ADD  [00001a38],EDI01 3d 38 1a 00 00
00033934 ADD  EDX,[ESI+04]  03 56 04
00033937 ADD  [ECX+0c],5a   83 41 0c 5a

Notice that the encoding used for the register addition is not the same as the one we described above. It actually uses RM and not MR! Both of those versions are functionally equivalent.

Otherwise, everything is the same[13], wonderful!

# Raw bytes

Notice how we couldn't manage to assemble the add ax, 267 instruction above. There might be other instructions we can't assemble, and it seems kind of backward to assemble code just to disassemble it!

Of course the diassembler does not need the machine code to come from the assembler. Though it does not have the same capabilities as the assembler, it works entirely on its own!

So let's check what we couldn't assemble above:

DuskOS provides words to directly write to memory. c, writes a byte, , writes four bytes. There are also the le, and be, variant for little-endian and big-endian. RISC-V is little-endian for example so if you're on a big-endian machine you will need to use le,.

Let's make a quick file named hand.fs, for the hexadecimal code we wrote by hand:

\ Notice how we don't need asm/x86 which is the assembler
needs asm/x86d

code testword
  $66 c, $05 c, $0b c, $01 c,
  $01 c, $cb c,
  $03 c, $56 c, $04 c,

3 to listingsz

' testword dis
./dusk -f hand.fs
00033920 ADD  AX,010b       66 05 0b 01
00033924 ADD  EBX,ECX       01 cb
00033926 ADD  EDX,[ESI+04]  03 56 04 

Sure enough, we've got correct results!

By the way, the words we just written here are totally valid if we were to execute them on an x86 machine. This can be a nice way to test code and write it directly in binary.
Make sure to understand the DuskOS calling convention though.

It can be long to write all those "c," by hand. For this purpose, DuskOS provides a word named "map<" which execute the given word until the rest of the line. So we could rewrite our code like so:[14]

code testword
  map< c, $66 $05 $0b $01
  map< c, $01 $cb
  map< c, $03 $56 $04

# A binary mess

Yet another big post, I thought it was going to be smaller than the former one but it might not be! I truly hope you learned something here and enjoyed it. That's a lot to take in just to write this sacred "binary"!

There are many other ISAs out there that I didn't explain, simply because I don't know them well enough. There are also many things which I glanced over, especially for x86: the SIB byte, the REX prefix, the opcode tables... many things for you to discover!
I could have made another section about the Motorola 680x0 instruction set, aka m68k, but I think we've got enough here[15].

Places where raw machine code is written are extremely rare nowadays. Writing as well as improving assemblers and disassemblers for DuskOS helped me a lot in understanding how all of this worked.

Even if you don't end up using it, because you likely won't, you now know a lot more about CPUs and what language they speak! That's a nice flex!

Have a wonderful rest of your day!


  1. A footnote? Already? That's because I oversimplified things, as always. Computers do not always run assembly directly, there can be even more steps down the line which a developer can never know about! See the Wikipedia page on microcode.

  2. Moreover, assemblers often have useful tools which are not provided by the assembly language alone; mainly macros and labels.

  3. Most? Well yes, if you didn't already know, some programming languages mainly disregard variables! One good example of this peculiarity is the Forth programming language.

  4. I am not aware of any instruction which requires 5 operands. A number of 4 is already unusual so greater must be even rarer.

  5. Felt oddly specific? That's because it is. Looking at you x86.

  6. In reality many other aspects of the ISA can be taken into account in order to classify it as CISC or RISC.

  7. In this idea I do consider the ARM instruction set to be close to a CISC one. The use of a shift operation in most instructions and the ability to post-increment or pre-decrement is far from RISC.

  8. A fun side-effect of compressing instructions: most of them become dyadic! Because you can't magically store ways to identify a register, you have to make room for it, hence this change.
    RISC-V compressed instructions are designed to have a non-compressed counterpart so that the CPU can easily convert them.

  9. Technically the B-type and J-type are not proper types but derived from S-type and U-type respectively. These are the same apart from the immediate encoding.
    As you can see these are quite unusual immediate encoding. They have be designed to reduce the number of multiplexers in the CPU, that is, the range of immediate are as close as possible to their original place.
    Finally, other types are defined for extensions of the ISA.

  10. This quirk is due to x86's long history. It is backward compatible with the original instruction set from the 8086 chip which was operating on 16 bits. Thus, 16-bit and 32-bit instructions share the same opcode.
    To retain total backward compatibility, it is possible to change a core's "mode" so that it can execute 8086 code natively, that is, without prefix and other incompatibilities.

  11. Yes, it is possible to have a 16-bit displacement. As with a 16-bit operand, prefix your opcode with a specific byte. Here the prefix is 0x67, however all register combinations are changed.

  12. In x86 there is yet another optional byte which encodes addressing, it is called the "SIB" byte for Scale Index Base. This is the byte responsible of crazy addressing modes such as add [eax+2*esi+10], ebx.
    I will not explain further how it works, as x86 is already complicated enough as is. ↩2

  13. For this to work we had to use the 32bmode. This is because on a 64-bit architecture DuskOS encodes absolute addresses as addresses relative to rbp. This assembler is made primarly for the operating system itself, so it has some specificities not present in others.
    As to why it's relative to rbp, see DuskOS' hardware amd64 documentation.

  14. You can avoid rewriting map< c, multiple times; refer to DuskOS' own documentation for this.

  15. It's also a quite old ISA which is sadly no longer use. I really liked its orthogonality and its novelty in the effective address system.