In this blog post, I’ll guide you through a project to start learning assembly, showing you all the steps and breaking the code down into manageable sections. For the project, we’ll code a number guessing game for a retro processor using an emulator that makes learning assembly easier. Since you’re here reading this blog, you probably already have some interest in learning about assembly; however, before we get into the technical details, I’ll kick off with some extra motivation for learning.
5 Reasons to Learn Assembly
Here are my top five reasons to learn assembly, plus one bonus reason. If you have other reasons for learning, those are valid too and I’d love to hear about them!
- It will help you fully understand how computers work at the lowest level. Personally, this knowledge has helped me with a lot of computer science, programming, and cybersecurity concepts that finally “clicked” after learning how processors and low-level memory management work.
- It will help you better understand malware both for analysis and reverse engineering and for writing your own malware or shellcode.
- It will make you a better programmer, even when writing in high-level languages by being aware of what happens when your code is compiled or interpreted.
- If you’re interested in IoT or embedded systems it is very valuable for writing and reverse engineering firmware.
- It will help you with security research and exploit development.
Bonus: When the robot overlords overthrow us, it will be valuable to speak their native tongue.
What is Assembly?
Assembly is a low-level programming language that translates machine code (binary) instructions into a more human-readable/writeable format. Let’s start by covering the basics of assembly and the difference between it, machine code, and higher-level languages. If you’re already familiar with those concepts, then feel free to JMP on below to the next section (if you got that joke, then you’re probably ready too), where we’ll get hands-on writing assembly programs.
You’ve probably heard before that computers speak in 1s and 0s, and that’s absolutely true. Computers read data and instructions to perform computations as electrical voltages of either high or low, you can think of it like flipping on a light switch, it’s either on or off. If you added multiple lights together in a row, you could start to communicate using sequences of on/off, and we refer to this as binary. This is the very basics of how computers work as well. Inside processors are millions to billions of tiny circuits that perform different operations based on the electrical inputs to the pins of the CPU.
When you write a program in a higher-level language (like C, C++, GO, Rust, etc.) it must be compiled before running. The end of the compilation process will yield an executable file, sometimes called a binary because it’s literally just made up of 1s and 0s. The binary code or hex (another way to represent binary) that gives instructions to the computer is called the machine code. Each processor has a set of instructions that it can perform and these instructions can be translated into a binary number. We can string thousands to millions of these little instructions together to complete more complex tasks. Writing and reading machine code, as you may have guessed, is extremely difficult. Luckily, there is an intermediate language that translates the machine code instructions into a more human-readable format, and this is referred to as assembly language.
The Nuts and Bolts
In the above screenshot, the furthestmost left column represents the memory address offset, the next column over is the contents of that memory address in hexadecimal (this is the machine code or data), and the last column to the right is the assembly language representation of that machine code. Looking at the second line at offset 1004, the machine code is \\0x48\\0x83\\0xec\\0x08, which to me means nothing, however, the assembly is:
sub rsp, 0x8
This means ‘subtract 8’ (in hex) from the stack pointer register. A little easier to read, isn’t it? When you compile a high-level language, a very simplified version of the steps are 1) the compiler turns the high-level source code into assembly code, 2) an assembler assembles the source code into machine code, and 3) a linker links to any external libraries creating an executable binary.
The TL;DR on the topic is that machine code is the 1s and 0s that give computers instructions. The specifics of what function different numbers indicate to a processor are detailed in the instruction list for the processor. These instructions can also be represented in a more human-readable format called assembly language.
Assembly Toolbox: Intel 8086 and x86-16
As previously mentioned, in this project, we’ll learn about the legendary Intel 8086 processor and the instruction set it uses called x86-16. The 8086 is almost 50 years old at this point, however it is still influential in modern computing. The x86 architecture and instruction set evolved over the years from x86-16 to x86-32 to the present day x86-64. Currently, this is the most popular architecture for personal computers, and if you’re reading this blog right now from a PC, there’s a good chance it’s using x86-64 instructions to display this text. While there have been many advancements in computing since the early days of the 8086, the basics of how the processor functions and the core assembly instructions are still present in modern-day computers. In my opinion, starting to learn on 16-bit in an emulator is a much easier way to dive into assembly.
Later on in this blog series, when we start writing x86-64 assembly, you’ll see how most of what you learned from writing assembly code for the 8086 can be easily transferred to modern assembly.
Emulator Tutorial: “Hello World!”
Convinced? Let’s check out the emulator! In this lesson, we’ll be using the open-source Online 8086 Emulator. You can use the emulator here: https://yjdoc2.github.io/8086-emulator-web/ or alternatively, if you prefer to self-host check out their GitHub for building instructions.
To launch the emulator, click on the “TRY ONLINE 8086 COMPILER” button which will navigate you to the emulator page.
The first time you launch it, you’ll see a basic example of the “Hello World” program and walk through a quick tutorial on how to use the emulator. If the tutorial doesn’t launch or you want to see it again, click the question mark help button in the top-left corner.
To run the code, click the compile button, and then you can either run the program all the way through or alternatively line by line with the next button. The ability to easily step through the code line by line and see how the registers, flags, and memory are impacted is what makes learning in an emulator like this so beneficial.
After you hit compile, you’ll notice that line 7 underneath the start is highlighted; this will be the next instruction run if you press next. Directly above is what’s referred to as a ‘label’ which signifies a specific memory address in the program. Labels make it easier to reference specific memory addresses in our program for things like jumping to code or accessing data, as we don’t need to remember the specific number. The start label is a special label that indicates the entry point of the program, which is the first instruction that will be run when it’s launched. Above the start label you can see an assembler directive to store the string “Hello World.” This tells the assembler this line isn’t an instruction and is instead to be treated as data.
Let’s step through line by line and see what the instructions do. After executing the first line of code, you’ll notice that the AH (H is for high) register value changes from 00 to 13. The MOV instruction does exactly as it would sound; it moves the destination operand (in this case, 13 in hex) into the source operand. If you’re ever unsure of what an instruction does, there is a handy instructions document built right into the emulator that you can access it by clicking the “i” button in the top right corner.
When working in the assembly syntax, we’ll be using (intel syntax) the instructions follow the format: instruction destination, source. The destination and source are referred to as the operands, I like to think of them as the arguments to the instruction. Keep in mind not all instructions have operands, and some only have one. Looking back at the mov instruction from line 7, mov is the instruction, AH is the destination, and 0x13 is the source.
The registers of a processor are a small amount of internal memory that are used to pass data into the processor itself for calculations, receive data back from the processor, receive and store data into memory, and also pass data from one piece of code to another such as arguments to a function. The 8086 has 4 general purpose registers AX, BX, CX, and DX. Each of the registers holds 2 bytes (also referred to as a word), and the high and low bytes of each register can be accessed and are referred to by either H or L (e.g., AH, AL). When working with and reading assembly, you’ll notice a very familiar pattern of a series of MOV instructions followed by some sort of function or interrupt call. These mov instructions set the registers and, in some situations, the memory to pass parameters or arguments into whatever code it’s calling. In the Hello World example, we call an interrupt on line 13. For the purpose of this blog you can think of interrupts like our program asking the operating system to do something. For int 0x10, it expects the following data:
Register | Parameters for int 0x10 |
AH | BIOS interrupt sub function number |
CX | How many characters to print |
ES | Memory segment of string location |
BP | Pointer to start of string |
DL | Column to start printing |
If you inspect the code for the example program, you’ll notice that all it does is set up the registers in this fashion. 0x13 is the int 0x10 sub-function for printing a string, “Hello World” is 11 characters long. The ES register can only be accessed by using a general-purpose register and this is why we move 0 into BX and then ES. The OFFSET of the hello label refers to the address of the label, which holds the first character in the string, essentially a pointer to the start of the string stored in memory. Step through the program and notice how the registers change. Once the interrupt is called, “Hello World” is displayed in the bottom output section.
Code a Number Guessing Game in Assembly
Now that you’ve got the basics out of the way, let’s modify the program to instead take in an input of a single number and print out the number that the user input. I’ve written the code down below so feel free to copy or re-write it yourself.
; Program to accept user input and print it back
hello: DB "Please input a number between 0-9"
result: DB "The number you input is: "
buffer: DB 0x00
; actual entry point of the program, must be present
start:
; Print first string asking for input
MOV AH, 0x13 ; move BIOS interrupt number in AH
MOV CX, OFFSET result ; move offset result into CX
SUB CX, OFFSET hello ; calculate length of hello from offets
MOV BX, 0 ; mov 0 to bx, so we can move it to es
MOV ES, BX ; move segment start of string to es, 0
MOV BP, OFFSET hello ; move start offset of string in bp
MOV DL, 0 ; start writing from col 0
int 0x10 ; BIOS interrupt
; accept user input
mov AH, 0x1 ; 0xa is subfunction for accepting a char
int 0x21 ; BIOS interrupt
mov byte buffer, AL ; move the result of the interrupt into the buffer
; accept user input
mov AH, 0x1 ; 0xa is subfunction for accepting a char
int 0x21 ; BIOS interrupt
mov byte buffer, AL ; move the result of the interrupt into the buffer
; Print the result
MOV AH, 0x13 ; move BIOS interrupt number in AH
MOV CX, OFFSET buffer ; move offset buf
I’ve made a few modifications we should call out. First, we’ve allocated one byte in memory to store the input from the user, initialized it to 0x00, and labeled it as buffer. The first print performs the exact same as before, except instead of hardcoding the number of characters, we are instead calculating it by subtracting the succeeding memory address from it to get the length.
To accept user input, we are using int 0x21 subfunction 0x1, which stores one character of data into AL. Afterward, we move the character from AL into the buffer location we allocated, which is conveniently directly after the second string. In order to interact with memory, we have to tell the assembler how much data to move, in this example a byte. When you run through the program, it will halt when an input is required. To pass in an input, click on the input line, type in character, and then press the check mark button. To continue the program, either hit run or step through line by line. Notice at the end how the value you input moves into the AL register and then into memory. The value doesn’t match the number you input and this is because our keyboard input is encoded in ASCII. The ASCII characters for 0-9 in hex are 0x30 – 0x39.
Now it’s your turn to add some code in. We’ll be coding a basic number guessing game where we hardcode a number between 0-9, ask the user to input a number between 0-9 and then tell them they either got it right or wrong. For the next step, change the code so we have three messages, one asking for input and a “correct” and “wrong” message. Finally, add in the hardcoded value with a label. Don’t forget about ASCII, also I suggest adding it at the end of the data to make calculating string lengths easier.
Now that we’ve got all of our data setup and prompts for the user, we need to add some conditional logic to determine if they got the guess right or not. For that, we’ll use a combination of two instructions, CMP and JMPs. The CMP instruction compares two values and then sets the flag register based on the result. It does this by performing a subtraction and then checking the result. For example, if they are equal to each other, the result will be zero and it will set the ZF (short for Zero Flag).
We can then use a conditional JMP that will only execute depending on the status of the flags. The JMP command moves the instruction pointer (which tells the processor which line of code to run next) to a specified memory location or address. You can choose from many conditional jumps, such as jump if it’s above, below, or equal. Check out the instruction page to see all of them. In our example, we’ll use JNE (or “jump if not equal to”) jump to print the “wrong” message if the number they guessed isn’t equal to the hardcoded value. We’ll layout our code so that if the JNE doesn’t execute instead, the program flows directly into the section that prints the “right” message. Here’s my implementation:
; Basic Number Guessing Game
prompt: DB "Please input a number between 0-9"
wrong: DB "Oops, that's not right!"
right: DB "Good job, that's correct!"
number: DB 0x35
; actual entry point of the program, must be present
start:
; Print first string asking for input
MOV AH, 0x13 ; move BIOS interrupt number in AH
MOV CX, OFFSET wrong ; move offset result into CX
SUB CX, OFFSET prompt ; calculate length of hello from offets
MOV BX, 0 ; mov 0 to bx, so we can move it to es
MOV ES, BX ; move segment start of string to es, 0
MOV BP, OFFSET prompt ; move start offset of string in bp
MOV DL, 0 ; start writing from col 0
int 0x10 ; BIOS interrupt
; accept user input
mov AH, 0x1 ; 0xa is subfunction for accepting a char
int 0x21 ; BIOS interrupt
; check input against hardcoded number
CMP AL, byte number
JNE print_wrong
; set up register to print right message
print_right:
MOV CX, OFFSET number ; move offset buffer into CX
SUB CX, OFFSET right ; calculate length of result from offets
MOV BP, OFFSET right ; move start offset of string in bp
JMP call_int ; Jump to interrupt
print_wrong:
MOV CX, OFFSET right ; move offset buffer into CX
SUB CX, OFFSET wrong ; calculate length of result from offets
MOV BP, OFFSET wrong ; move start offset of string in bp
; Print out the response
call_int:
MOV AH, 0x13 ; move BIOS interrupt number in AH
MOV BX, 0 ; mov 0 to bx, so we can move it to es
MOV ES, BX ; move segment start of string to es, 0
MOV DL, 0 ; start writing from col 0
int 0x10 ; BIOS interrupt
For simplicity, we compare the result directly by comparing the hardcoded number in memory to the value input into AL. Notice again in the emulator that when interacting with memory contents, we need to specify how much memory we want to work with, in this example, a single byte. If the numbers are not equal, the JNE will execute, and we jump over the print_right label and section to the print_wrong section. If the numbers are equal, we continue over the JNE into the print_right section, and then at the end of that, we have an unconditional jump that goes to the final section of code which calls the interrupt. By laying out the code like this, we have essentially created an if/else statement. If the numbers are equal, print the correct number, else print the “wrong” message.
Challenge Time!
If you want to test what you’ve learned so far, try to modify the code to perform basic input validation. For example, if the user inputs any character outside of 0-9 instead of simply telling them that they got the wrong number, in this scenario, remind them to only input characters between 0-9. The rest of the functionality of the program should remain unchanged.
Okay, now it’s time to get loopy and finish up this number-guessing game. As it stands, if the user gets the answer wrong, we still exit the program. Let’s instead give them a specific amount of guesses they can have and let them retry until they either get it right or run out of guesses. In a higher-level language, we would use a for loop to accomplish this. We can create a for loop in assembly with the knowledge we already have. A for loop is just conditional logic that jumps backward in the code flow with a counter implemented, and a while loop is just conditional logic that jumps backward in code flow unless a condition is met. Below is my implementation of the looping guessing game that allows 3 guesses.
; Basic Number Guessing Game
prompt: DB "Please input a number between 0-9"
wrong: DB "Oops, that's not right, try again!"
exit_right: DB "Good job, that's correct!"
exit_wrong: DB "Game over, you're out of guesses!"
number: DB 0x35
; actual entry point of the program, must be present
start:
; Print first string asking for input
mov CX, 3 ; set loop counter to 3
for_loop: ; start of for loop
PUSH CX ; preserve loop counter to the stack
MOV AH, 0x13 ; move BIOS interrupt number in AH
MOV CX, OFFSET wrong ; move offset result into CX
SUB CX, OFFSET prompt ; calculate length of hello from offets
MOV BX, 0 ; mov 0 to bx, so we can move it to es
MOV ES, BX ; move segment start of string to es, 0
MOV BP, OFFSET prompt ; move start offset of string in bp
MOV DL, 0 ; start writing from col 0
int 0x10 ; BIOS interrupt
; accept user input
mov AH, 0x1 ; 0xa is subfunction for accepting a char
int 0x21 ; BIOS interrupt
; check input against hardcoded number
CMP AL, byte number
JE print_right_exit
; If wrong print the interim wrong message
MOV CX, OFFSET exit_right ; move offset buffer into CX
SUB CX, OFFSET wrong ; calculate length of result from offets
MOV BP, OFFSET wrong ; move start offset of string in bp
MOV AH, 0x13 ; move BIOS interrupt number in AH
MOV BX, 0 ; mov 0 to bx, so we can move it to es
MOV ES, BX ; move segment start of string to es, 0
MOV DL, 0 ; start writing from col 0
int 0x10 ; BIOS interrupt
POP CX ; pop the loop counter off of the stack
CMP CX, 0 ; check if loop counter is 0
DEC CX ; decrement loop counter by 1
JA for_loop ; if not at end of for loop jump back to loop start
print_wrong_exit:
MOV CX, OFFSET number ; move offset buffer into CX
SUB CX, OFFSET exit_wrong ; calculate length of result from offets
MOV BP, OFFSET exit_wrong ; move start offset of string in bp
JMP call_int ; Jump to interrupt
; set up register to print right message
print_right_exit:
MOV CX, OFFSET exit_wrong ; move offset buffer into CX
SUB CX, OFFSET exit_right ; calculate length of result from offets
MOV BP, OFFSET exit_right ; move start offset of string in bp
; Print out the response
call_int:
MOV AH, 0x13 ; move BIOS interrupt number in AH
MOV BX, 0 ; mov 0 to bx, so we can move it to es
MOV ES, BX ; move segment start of string to es, 0
MOV DL, 0 ; start writing from col 0
int 0x10 ; BIOS interrupt
end:
Let’s walk through some of the changes to get this thing looping.
First off, notice that we set CX to 3 to be used as a loop counter. The convention is to use CX for looping and counters. The 8086 actually has a built-in looping function that can perform a for loop using the CX register. Usually, it’s more commonplace to just implement your own looping logic, as we have above since it’s more flexible.
We then add a label in our code that we can jump back to for the start of our loop, I’ve called it for_loop because that’s what we’re implementing, but this can be called whatever you want outside of actually calling it “loop” because this is a preserved word in this emulator for using the assemblers looping functionality.
The first thing we do inside the loop is push the value of CX onto the stack. We’ll learn more about the stack in the next project, but for now, all you need to know is that it’s a special place in memory for temporary variables. By pushing CX, we put it onto the top of the stack. This is done to preserve the loop counter stored there because, as you can see, we need to use CX for other purposes before the end of the loop. The rest of the functionality in the loop is pretty similar. If the guess is right, we use JE to jump to print the success message and exit. Otherwise, we print the interim wrong message that prompts the user to try again.
Finally, at the end of the loop, we restore the counter by popping it off of the stack into CX and we then compare it with 0 to see if we’re at the end of the for loop before either breaking the loop or looping back we decrement CX by one. The JA will trigger if CX is still greater than 0 and jump code execution back up to the start of the loop to run through again. Run the program and watch how the looping and conditional logic work.
Challenge Time!
If you want another challenge to test out what you’ve learned, finish the game by adding the following modifications. First, add another message that says “Close but not quite” that will print if the user is wrong but within +/- 1 of the correct answer. Secondly, when they guess wrong, tell them how many guesses they have left.
Stay Tuned for More Assembly!
That wraps up this project and our use of the emulator in this blog series. That being said, it’s an excellent platform for learning assembly and I’d encourage you to take a shot at writing some other programs or implementing algorithms in it. If you need some inspiration google coding challenges and try a shot of some of those in assembly, or check out the free TCM Security Programming Fundamentals Course and try to implement some of the examples in assembly instead of Python. As you’ll see in the next blog in this series, a lot of the concepts and even instructions from coding for the 8086 can be directly put to use to read and write assembly for modern processors in x86-64 bit.
If you’re interested in learning more about assembly language, including writing and reading assembly code in x86-64 make sure to check out the new Assembly 101 course on the TCM Security Academy.
About the Author: Andrew Bellini
My name is Andrew Bellini and I sometimes go as DigitalAndrew on social media. I’m an electrical engineer by trade with a bachelor’s degree in electrical engineering and am a licensed Professional Engineer (P. Eng) in Ontario, Canada. While my background and the majority of my career has been in electrical engineering, I am also an avid and passionate ethical hacker.
I am the instructor of our Beginner’s Guide to IoT and Hardware Hacking, Practical Help Desk, and Assembly 101 courses and I also created the Practical IoT Pentest Associate (PIPA) certification.
In addition to my love for all things ethical hacking, cybersecurity, CTFs and tech I also am a dad, play guitar and am passionate about the outdoors and fishing.
About TCM Security
TCM Security is a veteran-owned, cybersecurity services and education company founded in Charlotte, NC. Our services division has the mission of protecting people, sensitive data, and systems. With decades of combined experience, thousands of hours of practice, and core values from our time in service, we use our skill set to secure your environment. The TCM Security Academy is an educational platform dedicated to providing affordable, top-notch cybersecurity training to our individual students and corporate clients including both self-paced and instructor-led online courses as well as custom training solutions. We also provide several vendor-agnostic, practical hands-on certification exams to ensure proven job-ready skills to prospective employers.
Pentest Services: https://tcm-sec.com/our-services/
Follow Us: Email List | LinkedIn | YouTube | Twitter | Facebook | Instagram | TikTok
Contact Us: sales@tcm-sec.com
See How We Can Secure Your Assets
Let’s talk about how TCM Security can solve your cybersecurity needs. Give us a call, send us an e-mail, or fill out the contact form below to get started.