Write your own print function in x86 assembly - Part 2
Before we begin
If you haven’t read part 1 AND don’t have a basic knowledge of assembly x86, I recommend reading part 1 first as then this post would make more sense imo.
This is the second (and last) part in this series where I’m making a print function using Assembly which must be callable from C code. This post will cover functions, more instructions and some information with regards to linking code.
Debugging a binary
Assembly doesn’t provide warnings or errors like modern programming languages do during runtime. We’ll ocasionally see segmentation fault being mentioned. This indicates that the code written did not execute correctly, the program panicked and exitted.
To better understand the binary, It is recommend to use gdb. This is a debugger that can debug binaries and provide a better explanation of what the program is doing and where things go wrong.
Installation
Because I use Ubuntu for assembly development, I’ll show how this is done on Ubuntu. However doing this in your own distro shouldn’t be too difficult.
sudo apt install -y gdb
wget -O ~/.gdbinit-gef.py -q https://gef.blah.cat/py
echo source ~/.gdbinit-gef.py >> ~/.gdbinit
This should install both gdb and gef, which contains enhanced features and has a more user friendly interface. Let’s take example_1 from part 1.
example_1.asm
global _start
_start:
mov eax, 1
mov ebx, 1
int 0x80
Now let’s assemble it again and put it in the debugger:
nasm -f elf32 example_1.asm -o example_1.o \
ld -m elf_i386 example_1.o -o example_1
gdb ./example_1
Firstly, let’s set a breakpoint at _start by running b _start. This will pause the execution of the program at the beginning so we can easily take a look. Using r or run, the program will be executed (and pauses when it encounters a breakpoint). With ni you can navigate to the next instruction. I’ll provide a table with other commands that can be useful while debugging.
| Command | Description |
|---|---|
b <address> |
Creates a breakpoint at a specific address or label e.g. b _start or b *0x8048000 |
r / run |
Executes the binary and pauses when it encounters a breakpoint |
ni |
Executes the next instruction, does not step into a subroutine — treats call as one instruction |
si |
Executes the next instruction and does step into a subroutine on call |
n / next |
Steps to the next source line (C/C++), not instruction-level |
c / continue |
Continues execution until the next breakpoint |
info registers |
Shows the state of all current registers |
info registers <reg> |
Shows a specific register e.g. info registers eax |
p $<reg> |
Prints the value of a register e.g. p $eax |
x/3dw $esp |
Examines 3 words on the stack in decimal. Replace d with x for hex or s for string, and 3 for however many values you want |
layout regs |
Opens TUI mode showing registers and instructions updating live as you step |
q / quit |
Exits GDB |
Functions in x86
Finally we can let the fun stuff commence! We can write our first function in assembly!
If this is your first time hearing about functions: Functions are pieces of code that are written once and can be used in several places wihtin your code. It saves time writing code and can be recycled all the time. Writing good functions in software development can really make or break the functionality, overhead and maintenance of your software project (If you’re interested in this I can highly recommend reading up on the SOLID principles.)
Now that it is clear what a function is, let’s look at what a function looks like in x86:
global _start
_start:
mov eax, 0
mov ebx, 0
call inc_eax_1
int 0x80
inc_eax_1:
add eax, 1
ret
As can be seen above, the function that is being used with call, executes a function called inc_eax_1. The sole purpose of this function is to increment whatever value is in eax by one, the ret instruction signifies the end of the function. Once the function has ended, the execution will continue with the instruction after the call instruction, which will call the interrupt and exit the program.
This function is ofcourse rather useless and could’ve easily been substituted for inc eax, but this hopefully explains the idea of functions.
Local labels
Functions are a universal concept in programming languages, labels on the other hand, are more assembly specific. These are specific points within a function that can be referred to. The main idea of Labels in assembly is to improve code flow by being able to jump toward a certain point once a specific condition is met.
A For Loop in Assembly
Let’s look at such a label:
example_3.asm
global _start
_start:
push 5 ; Put the number 5 on the stack (e.g., position 996)
call myFunc ; Calls the function, puts the address of the called function on the stack (e.g., position 992)
mov eax, 1
int 0x80
myFunc:
push ebp ; save the base pointer to the stack (e.g., 988)
mov ebp, esp ; move the value of the stack pointer into ebp
mov eax, [ebp+8] ; move value of ebp+8 into eax (whatever is on the stack at 988+8)
.loop: ; This is a label, I called it loop because it represents a 'for-loop', but you can call it anything you like
cmp eax, 0 ; Check if eax is the value 0
je .done ; if equal, go to the label .done, else continue to next instruction
add ebx, 5 ; add 5 to ebx
dec eax ; remove 1 from eax (eax - 1)
jmp .loop ; go back to the beginning of .loop
.done:
pop ebp ; Put the next value of the stack back into ebp
ret
In the example above is the layout of a ‘for-loop’, which goes over the loop until a certain condition is met. In this case we check if eax is equal to 0. If this is the case we’re done. While that is not the case, 5 is added to ebx and eax is decremented by one.
Let’s go over it step by step:
push 5places the value5onto the stack.call myFuncpushes the return address onto the stack and jumps tomyFunc.push ebpsaves the current base pointer onto the stack so it can be restored later.mov ebp, espsetsebpto the current stack pointer, establishing the stack frame. This is, with a fancy term, known as the function prologue.mov eax, [ebp+8]reads the argument5from the stack intoeax. The offset is+8becauseebp+0is the savedebp,ebp+4is the return address, andebp+8is where the argument lives.
At this point the stack looks like:
| Address | Contents |
|---|---|
ebp |
saved ebp |
ebp+4 |
return address |
ebp+8 |
5 ← our argument |
.loopbegins.cmp eax, 0checks ifeaxhas reached0.je .donejumps to.doneifeaxis0, otherwise continues.add ebx, 5adds5toebx.dec eaxdecrementseaxby1.jmp .loopjumps back to the top of the loop.
Steps 6–10 repeat until eax reaches 0. Since we started with 5, the loop runs 5 times, leaving ebx = 25 (5 added five times).
pop ebprestores the original base pointer. This is known as the function epilogue.retpops the return address off the stack and jumps back to_start.- Back in
_start,mov eax, 1andint 0x80exit the program with code0.
Writing the Print Function
While using example_3.asm as a template, writing the print function can begin. I’ll call the custom print function “yeet”, as the function will “yeet” some text into the terminal.
Instead of starting with global start, we’ll start with global yeet, this will make sure the function is publicly available. Next we’ll setup the registers to use the sys_write systemcall with all the right variables:
- eax must hold the value
4, as this is the sys call number forsys_write; - ebx must hold the value
1, as this is the file descriptor forstdout; - ecx holds a pointer that points where the piece of text is in memory;
- edx holds the amount of characters the piece of text contains.
The first three registers are rather straight forward. However, because a print function doesn’t provide the length of the text, we’ll have to loop over the text to figure out how long it is. Hence why example_3.asm serves as a template:
yeet.asm
global yeet
yeet:
push ebp
mov ebp, esp
mov eax, 4; calling sys_write system call
mov ebx, 1; stdout file descriptor
mov ecx, [ebp+8]; this is a pointer to the msg in the data section
call calc_len
int 0x80
mov esp, ebp
pop ebp
ret
calc_len:
mov edx, 0
.loop:
cmp byte [ecx], 0
je .done
inc ecx
inc edx
jmp .loop
.done:
mov ecx, [ebp+8]
ret
Let’s go over this again, step by step:
push ebpsaves the current base pointer onto the stack.mov ebp, espestablishes the stack frame (function prologue, remember? :D)mov eax, 4sets up system call4, which issys_writeon Linux.mov ebx, 1sets the file descriptor to1, which is stdout.mov ecx, [ebp+8]reads the argument passed toyeetand stores the pointer it inecx.call calc_lenpushes the return address and jumps intocalc_len, which will calculate the length of the string and store it inedx.
Inside calc_len:
mov edx, 0initializes the length counter to0..loopbegins.cmp byte [ecx], 0checks if the current character in memory is a null terminator (\0), which marks the end of the text.je .donejumps to.doneif the null terminator is found, otherwise continues.inc ecxmoves the pointer forward by one byte to the next character.inc edxincrements the length counter.jmp .loopjumps back to the top of the loop.
Steps 8–12 repeat until the null terminator is found. At that point edx holds the length of the string.
mov ecx, [ebp+8]restoresecxback to the start of the string, since the loop advanced it to the end.retreturns toyeet, withedxholding the string length.
Back in yeet:
int 0x80triggers the Linux syscall handler. At this point all four arguments forsys_writeare set up:eax = 4(sys_write),ebx = 1(stdout),ecx= pointer to string,edx= length. This prints the string to the terminal.mov esp, ebprestores the stack pointer, this is called the function epilogue.pop ebprestores the original base pointer.retreturns to the caller.
Last but not least, we can assemble the yeet function:
nasm -f elf32 yeet.asm -o yeet.o
Writing the C program
Writing this function alone however, doesn’t make it work with C.
To enable this function in C, we need a header file to tell the linker what type of function this is. The header file will look like this:
int yeet(const char* format);
Only now can this function be properly used. The header file must be imported and, very important, the object file (yeet.o) must be present to make the yeet function callable:
#include <stdio.h>
#include "yeet.h"
int main() {
yeet("The quick brown fox jumps over the lazy dog\n");
return 0;
}
This results in the function yeet working properly and being able to write any text into the terminal.
$ ./yeet
The quick brown fox jumps over the lazy dog
Conclusion
That wraps it up! I myself thought it a fun exercise to dust off my assembly knowledge. Going over computer internals in part 1 and steadily building up to a function that is able to determine the length of a piece of text and print it to the terminal. It does fundamentally the same thing as printf. However, yeet is far less feature rich.
If you want to take this further, some interesting next steps would be:
- Adding support for format specifiers like %s or %d;
- Handling multiple arguments;
- Exploring how the C calling convention (cdecl) works in more depth.
Feel free to reach out if anything is unclear or if you spot a mistake. Thanks for reading!