This article is a hands-on introduction to the Bedrock computer system, showing how to write a text-printing function with the Bedrock assembly language.
The system
Bedrock is an 8-bit computer system that can be used to write programs that will last forever. Programs written for Bedrock will be able to run on any computer, console, or handheld, so long as someone has implemented a Bedrock emulator for that system. The emulator is designed to be straightforward for a solo programmer to implement in their spare time.
The project page lists all of the systems that can run Bedrock programs so far.
The language
Bedrock provides a bare-bones assembler with its own language. The language is small, providing only labels, macros, bytes, and strings, but it’s capable enough for writing both very small and very large programs. Labels and macros can be used to create high-level abstractions.
The assembler manual describes all of the features of the language.
The program
We’ll be writing a program that will read a string of characters from memory and print it out as text. This will allow us to dig into the design of the Bedrock system, and will demonstrate how to use instructions, labels, strings, and loops.
We will be taking a few detours and diversions along the way to explain some concepts that experienced programmers might already be familiar with. If this isn’t your speed, click here to skip directly to the final program.
Getting started
To write a program for Bedrock, we write the source code of our program using the Bedrock assembly language, and then we feed that source code into the assembler to turn it into a working Bedrock program.
Using the assembler
Take a look at the following piece of code. If you click on the code block it will turn into a text editor with a built-in assembler. The CHECK
button will check your code for errors (showing the bytes of the assembled program below), and the RUN
button will assemble and run your program:
PSH: 01
When you run the program, an emulator will appear under the assembler. The STATE
panel is opened by default to show you the state of your program. The WST
line shows all of the bytes currently on the working stack, RST
shows the return stack, and PC
shows the address of the next byte to be executed.
Stacking bytes
Bedrock uses a pair of stacks to pass around and hold onto data. A stack is a list of bytes, where bytes can only be added to or removed from the end of the list. A popular metaphor is a stack of dinner plates, where plates can only be safely added to or removed from the top of the stack. Adding a byte to the top of a stack is called pushing, and removing a byte is called popping.
The stack that we use most of the time is called the working stack, abbreviated as WST
. The other stack is the return stack, or RST
, which we use rarely. The two stacks are interchangeable, but you’ll see later on why it’s useful to have both of them.
Our first instruction
Bedrock has an instruction set with 32 different instructions. Each instruction will pop some bytes from a stack as input, perform an operation using those bytes, and then push some bytes onto the stack as output.
The first instruction that we’ll introduce is called PSH
. It pops a byte from the return stack and then pushes that byte onto the working stack. This isn’t very useful to us right now, both stacks start off empty so there aren’t any bytes to move around. What we need is a way to make the instruction push a new byte onto the working stack, instead of just moving an existing byte.
In addition to the 32 instructions, there are also three modes (denoted r
, *
, and :
) that can be applied to each instruction, with each mode modifying some aspect of the instruction when it runs. The :
mode is called immediate mode, and causes an instruction to read a byte directly from the program instead of popping a byte from the stack.
We can use the PSH
instruction in immediate mode by writing the instruction as PSH:
. This instruction will now read the next byte of the program and push it onto the working stack. If you run the following program and look at the WST
line, you’ll see that the byte 02
has been pushed onto the working stack:
PSH: 02
Chaining instructions together
By introducing the ADD
instruction, we’ll be able to see how instructions can be chained together, with the stack holding values as they’re being operated on. The ADD
instruction will pop two bytes from the working stack, adding them together and pushing the result back onto the working stack:
PSH:02 PSH:03 ADD
After running the program, you’ll see that the bytes 02
and 03
have been consumed, and the result 05
has been placed on the working stack. To see the effect of each instruction as the program runs, click the stop button to reset the program, and then click the step button repeatedly to see the values on the stacks after each instruction.
We aren’t limited to working on just one or two values at a time. The following program pushes the bytes 01 02 03 04
to the stack and then adds them all together with three ADD
instructions, summing to 10:
PSH:01 PSH:02 PSH:03 PSH:04 ADD ADD ADD
You might have noticed that the value on the working stack is 0A
, not 10
. This is because Bedrock uses hexadecimal numbers for bytes, and 0A
is 10 in hexadecimal.
Intermission: A crash course on hexadecimal
Computers store numbers as bytes, with each byte representing a number between 0 and 255. Instead of writing these out as decimal numbers, we write bytes as hexadecimal, using sixteen different digits instead of just the usual ten:
0 1 2 3 4 5 6 7 8 9 A B C D E F
In hexadecimal, A
is equal to 10, B
is 11, C D E
are 12, 13, 14, F` is 15, and 10
is 16. This follows the same pattern as decimal, where the largest digit 9 is followed by 10.
We can change a hexadecimal number to a decimal number by multiplying each digit by its place value. In decimal, the place value of each digit is ten times the previous digit: 1, 10, 100, 1000. In hexadecimal, place values are multiplied by sixteen: 1, 16, 256, 4096.
For the hexadecimal number 2A
, we multiply the 2
by 16 and the A
by 1. This gives 32 and 10 (because A
is equal to 10), which add to 42. The hexadecimal number 2A
is 42 in decimal.
Going further
Now that we’ve introduced how instructions work, we can introduce the second instruction mode and start to build more complex programs.
Bigger values
So far we’ve been only been able to work with bytes, which means that we’ve only been able to use values up to 255. If we were to run the program PSH:80 PSH:80 ADD
, we’d end up with a result of 00
because the value would overflow and wrap around to zero.
To prevent this issue, Bedrock supports a second, larger type of value, called a double. A double is made up of two bytes, and can represent any number from 0 to 65535.
The instruction mode *
is called wide mode, and causes an instruction to use double values instead of byte values. We can use the PSH
instruction in both wide and immediate mode by writing PSH*:
:
PSH*:0002 PSH*:0002 ADD*
When you run the above program, you’ll see that it leaves the two bytes 00
and 04
on the working stack. There isn’t any real difference between a double and a pair of bytes, it’s only when an instruction reads a double from the stack that the pair of bytes is treated as a single larger value.
Jumping around the program
The JMP
instruction reads a double from the working stack (whether in wide mode or not) and jumps directly to that address in the program, allowing us to skip over some code or return to code that has already run. The first byte of the program has address 0000
, the next byte has address 0001
, then 0002
and so on, with instructions each taking up one byte of space.
The following program will jump repeatedly back to the first instruction, looping forever until the program is manually stopped. We use the immediate mode :
on the JMP
instruction to get it to read the address directly from the program:
JMP:0000
Labelling an address
If we want to jump to a particular instruction in the program, we would have to recalculate the address of that instruction every time we add or remove code from the program. This is thankless work, and would make working on larger programs very tedious:
PSH:00 ( 0002 -> ) INC JMP:0002
Instead, we can mark our program with labels and have the assembler calculate the addresses for us automatically. A label is a name prefixed with an @
character, like @my-label
. We can then use the label name anywhere in the program, and the assembler will replace the name with the correct address. The following program, once assembled, is identical to the previous program:
PSH:00 @loop INC JMP:loop
Conditional jumps
All of our jumps so far have created endless loops when run. It would be more useful to loop back only a specific number of times and then break out, continuing onwards to the next part of the program.
The JCN
instruction is similar to the JMP
instruction, reading an address double from the working stack, but it will also read a byte that determines whether or not it should jump. The instruction will jump only if the byte is not zero.
This instruction allows us to create a short-lived loop in the program: we’ll keep a byte on the stack as a loop counter, decrement it with each iteration of the loop, and then use it as the condition byte for the JCN
instruction. The loop will run until the loop counter reaches zero, at which point the loop breaks and the program continues.
The following program will loop eight times before halting. The DB1
instruction is a special debug instruction, it will pause the emulator at the start of the loop so that you can see the changing value of the loop counter (click the play button to advance the program):
PSH:08 @loop DB1 DEC DUP JCN:loop
Working with text
We’ve reached the point where we can start working with text. Congratulations for making it this far; we’ve had to cover a lot of ground to be able to see out how all of the pieces fit together. There isn’t all that much left of Bedrock to learn after this, just a handful of instructions and devices.
Writing to a device
So far we’ve just been moving bytes around on the stack. To do useful work, we need to be able to send bytes elsewhere. Bedrock uses devices to interact with the different parts of a computer, with each device port representing a different value or action that can be performed. We’ll be writing bytes to a port on the stream device to print characters to the terminal.
The STD
instruction (‘store to device’) has the signature ( v p. -- )
: it reads two bytes from the working stack, a value v
and a port number p
, and it writes that value to that device port. We won’t be exploring devices any further in this tutorial, just know that writing a byte to port 86
will print a character to the terminal:
PSH:42 PSH:86 STD
If you run this program, you’ll see that a new panel appears at the bottom of the emulator. It contains all of the characters that have been printed to the terminal by the program so far, in this case just the letter B
.
Intermission: a crash course on ASCII
Computers store text as bytes, with each byte representing a single character. The byte 51
translates to ‘Q’, 7A
to ‘z’, 24
to ‘$’, and so on. This mapping between bytes and characters is called ASCII.
We don’t have to remember which byte maps to which character, we can have the assembler figure it out for us. If we wrap some characters in single quotes, like 'ABC'
, the assembler will convert them to bytes for us:
'ABC'
If you click CHECK
on that piece of code, you’ll see that it assembles to the three bytes 41 42 43
, which works out to be one byte per character. Don’t worry about clicking RUN
, it isn’t a proper program so it won’t do anything useful.
Printing a string, first attempt
To print a whole string of characters, we can push each character to the stack one at a time and print them out. We’ve used the PSH:
instruction before to push a single byte to the stack, and because each character assembles to a single byte, we can use it to push characters as well:
PSH:'B' STD:86 PSH:'e' STD:86 PSH:'d' STD:86 PSH:'r' STD:86 PSH:'o' STD:86 PSH:'c' STD:86 PSH:'k' STD:86
While this technically works, it leaves a lot to be desired. The program is long, repetitive, and the string is broken up.
Loading bytes from memory
We saw before that the assembler can convert a whole string of characters into bytes in the program. We just need to be able to read the bytes from where they’re stored in the program and push each of them to the working stack.
The LDA
instruction (‘load from address’) has the signature ( a* -- v )
: it reads an address double from the working stack, reads the byte stored at that address in the program, and then pushes that byte to the stack:
In the following program, we store the bytes for the word ‘Bedrock’ at the end of the program, marking the start of the word with the label @string
. We push the address of that label to the stack, load the byte stored at that address (the byte 42
, or ‘B’), and then we print that letter to the terminal. The HLT
instruction halts the program before it runs into the stored string:
PSH*:string LDA STD:86 HLT @string 'Bedrock'
We can load each successive letter of the string by incrementing the address double multiple times with INC*
. Insert a few more INC*
instruction before the LDA
in order to print the other characters in the string:
PSH*:string INC* LDA STD:86 HLT @string 'Bedrock'
Printing a string, second attempt
We can use LDA
to make a loop that loads and prints each character from the string. We’ll keep incrementing the starting address, giving us the address of each subsequent character.
This program starts by pushing two addresses onto the working stack: the first address points to the end of the string, and the second address points to the start. We then enter our loop: we duplicate the address that points to the start of the string, we load the character stored at that address, and then we print that character to the terminal. The next line increments the address to point to the next character, and then we loop if the address isn’t yet equal to the end address:
PSH*:end PSH*:string @loop DUP* LDA STD:86 INC* NQK* JCN:loop HLT @string 'Bedrock' @end
We’ve introducing a new instruction here to manage our loop. The NQK*
instruction compares the two address doubles on the stack; it will push the byte FF
if the two addresses are not equal, meaning that we haven’t reached the end of the string, and it will push 00
otherwise, making JCN
break us out of the loop.
This is better than our first attempt, it’s smaller and we don’t have to break the string up into small pieces. There’s still room for improvement though, it would be easier to use if we only had to push a single address to the stack.
Null terminated strings
To get the program to detect the end of the string for us, we can place a zero byte at the end of the string (known as a null terminator). We can check the value of each byte as we load it, and if we find a zero byte then we’ll know that we’ve reached the end of the string.
Null-terminated strings are used all the time in programs, so there’s an easy way to write them. If we wrap a string in double quotes, like "ABC"
, the assembler will automatically append a zero byte to the end for us. This piece of code will assemble to 41 42 43 00
:
"ABC"
Printing a string, third attempt
By using a null-terminated string, we can get the loop to find the end of the string automatically, without us passing in the end address.
This program starts by pushing the address of the string onto the working stack. We then enter our loop: we duplicate the address, we load the character stored at that address, and then we print that character to the terminal. The next line increments the address to point to the next character, loads that character, and loops if it isn’t the null terminator:
PSH*:string @loop DUP* LDA STD:86 INC* DUP* LDA JCN:loop HLT @string "Bedrock"
This is smaller again than the previous attempt. The final step will be to turn this into a function so that we can use it multiple times in a program, printing multiple different strings with the same piece of code.
Using the return stack
This is where we need to introduce the third and final instruction mode, r
. Until now, we’ve been exclusively using the working stack, but in order to use functions we’ll need to be able to place return addresses onto the return stack.
The r
mode is called return mode, and causes an instruction to use the return stack instead of the working stack, and the working stack instead of the return stack. The following example will push 02
and 03
to the return stack, and then add them together. The result will be placed on the return stack:
PSHr:02 PSHr:03 ADDr
Calling a function
A function is a reusable piece of code. We can place a piece of code anywhere in the program, using a label to mark where it starts, and then we can use a jump instruction to jump to that label, running the code:
JMP:print-b JMP:print-b HLT ( Print the letter B. ) @print-b ( -- ) PSH:'B' STD:86
This isn’t quite right though. Our function will only run once, with the program halting once we reach the bottom. We need some way of returning back to where we came from once the function has finished, so that we can call the function again and again.
The JMS
instruction is similar to the JMP
instruction, reading and jumping to an address on the working stack. Before it jumps though, it will write the address of the next instruction to the return stack. When we want to return from our function, we can use the return mode instruction JMPr
to read this address from the return stack and get us back to where we came from:
JMS:print-b JMS:print-b HLT ( Print the letter B. ) @print-b ( -- ) PSH:'B' STD:86 JMPr
Finally, we’re able to pass values into functions when we call them. We do this by pushing a value to the working stack before we call the function, so that the code in the function is able to read it off the stack and do something with it. This is why we use the return stack for return addresses, it keeps them safely out of the way:
PSH:'A' JMS:print-char PSH:'B' JMS:print-char PSH:'C' JMS:print-char HLT ( Print any character. ) @print-char ( char -- ) STD:86 JMPr
The most recent example might look superficially like the code in printing a string, first attempt, but we aren’t limited in what code we can run with our function.
Bringing it all together
This is our fourth and final string printing program, it brings together everything we’ve learned so far.
We’ve taken the string printing code from printing a string, third attempt and wrapped it in a function called print-string
that takes a string address as an argument. It can be called any number of times:
PSH*:string1 JMS:print-string PSH*:string2 JMS:print-string HLT @string1 "This is a string. " @string2 "This is also a string. " ( Prints a string to the terminal. ) @print-string ( addr* -- ) DUP* LDA STD:86 INC* DUP* LDA JCN:print-string POP* JMPr
Further reading
- The instruction set manual will show you how each of the 32 instructions in the instruction set can be used in a program, with runnable examples.
- The device manuals will give you an idea of how you can make interactive programs using the different devices.
- The examples page contains a few examples of larger programs.