# Learn Assembly by Writing Entirely Too Many Brainfuck Compilers _01 November 2020 · #assembly · #compilers_ **Table of Contents** - [Intro](#intro) - [What is brainfuck?](#what-is-brainfuck) - [Interpreting brainfuck](#interpreting-brainfuck) - [What is assembly?](#what-is-assembly) - [Intro to x86](#intro-to-x86) - [Compiling brainfuck to x86](#compiling-brainfuck-to-x86) - [Intro to ARM](#intro-to-arm) - [Compiling brainfuck to ARM](#compiling-brainfuck-to-arm) - [Intro to WebAssembly](#intro-to-webassembly) - [Compiling brainfuck to WebAssembly](#compiling-brainfuck-to-webassembly) - [Intro to LLVM](#intro-to-llvm) - [Compiling brainfuck to LLVM](#compiling-brainfuck-to-llvm) - [Optimization opportunities](#optimization-opportunities) - [Concluding thoughts](#concluding-thoughts) - [Discuss](#discuss) - [Further Reading](#further-reading) - [Notifications](#notifications) ## Intro Hey you! Have you ever wanted to become a _CPU Whisperer_? Me too! I'm a frontend web developer by trade but low-level assembly code and compilers have always fascinated me. I've procrastinated on learning either for a long time but after I recently picked up Rust and have been hanging out in a lot of online Rust communities it's given me the kick in the butt to dive in. Rustaceans use fancy words and acronyms like _auto-vectorization_, _inlining_, _alignment_, _padding_, _linking_, _custom allocators_, _endianness_, _system calls_, _LLVM_, _SIMD_, _ABI_, _TLS_ and I feel bad for not being able to follow the discussions because I don't know what any of that stuff is. All I know is that it vaguely relates to low-level assembly code somehow so I decided I'd learn assembly by writing entirely too many brainfuck compilers in Rust. How many is too many? Four! My compile targets are going to be x86, ARM, WebAssembly, and LLVM. The goal of this article is to be easily-digestible for anyone who has a modest amount of programming experience under their belt, even if they've never written a single line of assembly before. So why x86? x86 is not just an ISA but it is _the_ ISA. Most servers, desktop PCs, laptops, and home gaming consoles use x86 CPUs. Why ARM? ARM is not just an ISA but it is _the other_ ISA. Most mobile phones, tablets, mobile gaming consoles, and microcontrollers use ARM CPUs. Also Apple announced they will be switching all their laptops and desktops from x86 to ARM CPUs in 2021 which seems like a Pretty Big Deal. Why WebAssembly? WebAssembly has the potential to be the future of the web and also the future of containerized applications in general! Solomon Hykes, the creator of Docker, has tweeted _"If WASM + WASI existed in 2008, we wouldn't have needed to create Docker. That's how important it is. WebAssembly on the server is the future of computing. A standardized system interface was the missing link. Let's hope WASI is up to the task!"_ Why LLVM? LLVM because it can compile to x86, ARM, or WebAssembly. Also because many modern and successful programming languages like Rust and Swift compile to LLVM instead of to assembly directly. Since all of the above targets go by many names here's a quick list of their aliases: - 64-bit x86 is also called: x86_64, x64, AMD64 - 64-bit ARM is also called: aarch64, ARM64 - 32-bit WebAssembly is also called: wasm32 - LLVM is short for LLVM IR (Intermediate Representation) If you'd like to play around with the code in this article yourself then you're in luck! The article comes with a [companion code repository](https://github.com/pretzelhammer/brainfuck_compilers) which contains all the code and instructions on how to run it. Following along using the companion code repository is completely optional and the article can be easily read without it. ## What is brainfuck? Brainfuck is, oxymoronically, the most well-known esoteric programming language. It's fame largely comes from the fact it has the word "fuck" in its name but hobbyist compiler developers like it because it's a tiny language which makes it easy to write compilers for. Fun fact: people have written more brainfuck compilers than actual brainfuck programs. Fun fact: I did zero research for that previous fun fact but it's probably true. Overview of brainfuck: - Brainfuck programs have an implicit pointer, "the pointer", which is free to move around an array of 30k unsigned bytes, all initially set to 0. - Decrementing the pointer below 0 or incrementing the pointer above 30k is undefined behavior. - Decrementing a byte below 0 or incrementing a byte above 255 wraps its value. - The newline character is read and written as the value 10. - The EOF (End of File) character is read as the value 0. Brainfuck commands: | Command | Description | |-|-| | `>` | increment pointer | | `<` | decrement pointer | | `+` | increment current byte | | `-` | decrement current byte | | `.` | write current byte to stdout | | `,` | read byte from stdin and store value in current byte | | `[` | jump past matching `]` if current byte is zero | | `]` | jump back to matching `[` if current byte is nonzero | | any other character | ignore, treat as comment | As is customary in introducing any new programming language, here's _"Hello world!"_ in brainfuck: ```bf ++++++++++[>+++++++>++++++++++>+++>+<<<<-]>++.>+.+++++++..+++.>++.<<+++++++++++++++.>.+++.------.--------.>+.>. ``` ## Interpreting brainfuck Let's write a quick brainfuck interpreter first. We're going to parse brainfuck programs into an `Vec` where `Inst` is defined as: ```rust struct Inst { idx: usize, // index of instruction kind: InstKind, // kind of instruction times: usize, // run-length encoding of instruction } enum InstKind { IncPtr, DecPtr, IncByte, DecByte, WriteByte, ReadByte, // end_idx = index of instruction after matching LoopEnd LoopStart { end_idx: usize }, // start_idx = index of instruction after matching LoopStart LoopEnd { start_idx: usize }, } ``` Parsing brainfuck programs into the above format, namely: keeping track of every instruction's run-length encoding and calculating the `start_idx` and `end_idx` of loop instructions ahead of time, will allow us to write a much more efficient interpreter and also produce much more efficient assembly from our compilers. We'll skip going over the remaining brainfuck interpreter code as it's very unexciting. Let's get to the fun part and try interpreting some brainfuck programs! > If you're following along using the [companion code repository](https://github.com/pretzelhammer/brainfuck_compilers) the command we'll be using to interpret brainfuck programs is `just interpret {{name}}` where `{{name}}` is the name of the brainfuck source file in the `./input` directory. ```sh # prints "Hello world!" > just interpret hello_world Hello World! # prints fibonacci numbers under 100 > just interpret fibonacci 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89 # encrypts lines from stdin using rot13 cipher > just interpret rot13 unencrypted text harapelcgrq grkg ``` Cool, we have a working brainfuck interpreter. Let's start digging into assembly. ## What is assembly? A slightly better first question is what is an ISA? ISA stands for Instruction Set Architecture. An ISA is an interface which CPUs can implement. The most popular ISAs today are x86_64 and aarch64. If we write code using x86_64 instructions then any CPU which implements the x86_64 ISA will be able to run that code. So is "assembly" the same thing as an ISA? Well, not quite. The short answer is that "assembly" is any syntax understood by an assembler. An assembler is a utility program that allows people to write machine-code in a more human-friendly way, like with comments, whitespace, and symbolic names for machine instructions. "Assembly" therefore is a thin layer of abstraction over an ISA offered by an assembler. The assembler we will be using to assemble all of our x86_64 and aarch64 programs will be the GNU Assembler, often abbreviated to GAS. We'll be using Intel syntax instead of the default AT&T syntax for x86_64 assembly because it's closer to ARM syntax for aarch64 assembly which makes it less jarring to switch between the two. If that last sentence made no sense to you don't worry you're in good company. Also, we'll be executing all the compiled binaries in a Linux environment so we'll be making direct Linux system calls in our assembly programs when necessary. ## Intro to x86 x86_64 is a register-based ISA. A register is a container where we can store data. We can store data in RAM too but RAM is very far from the CPU whereas registers are directly _in_ the CPU and are where the CPU does all of its actual work. All of the instructions in x86_64 operate on registers directly or indirectly in some way. There are many different kinds of registers: some store integers, some store floats, some store vectors of integers, some are general purpose and some have a special purpose, and some we can modify directly and others we can only modify indirectly (as a byproduct of certain instructions). For the purposes of this article the only registers we'll be using are `rax`, `rdi`, `rsi`, `rdx`, and `r12` which all store 64-bit integers. Let's learn some instructions. ```s mov , # dest <- src ``` `mov` moves something from `` to `` where `` can be a literal value, register, or memory address and `` can be a register or memory address. ```s mov rax, 5 # store 5 in rax mov rsi, rdi # copy value in rdi to rsi mov [r12], 15 # store 15 at the memory address in r12 ``` The last instruction is actually illegal because it's ambiguous. In the first 2 examples it's clear we're working with 64-bit integers since we're using 64-bit registers as operands, however in the last example we're trying to store the value 15 in the memory address in `r12` but how "big" is the value "15"? Does it take up 1, 2, 4, or 8 bytes? We need to know how many bytes to write to memory, after all. We can clear up ambiguities by suffixing the ambiguous instruction with `b` (byte), `w` (word, 2 bytes), `l` (longword, 4 bytes), or `q` (quadword, 8 bytes). So we can fix the last instruction in any number of these ways: ```s movb [r12], 15 # write 15 as 1 byte to memory address in r12 movw [r12], 15 # write 15 as 2 bytes to memory address in r12 movl [r12], 15 # write 15 as 4 bytes to memory address in r12 movq [r12], 15 # write 15 as 8 bytes to memory address in r12 ``` Also, although it may have already been made obvious, if we want to dereference a memory address stored in a register or label we wrap it with square brackets `[]`. ```s mov rax, r12 # copy value from r12 to rax mov rax, [r12] # copy value from memory address stored in r12 to rax ``` Some arithmetic instructions: ```s add , # dest <- dest + src sub , # dest <- dest - src ``` Comparing values: ```s cmp , # compare op1 to op2, set flags in special rflags register ``` Control flow: ```s jmp