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:

  1. push 5 places the value 5 onto the stack.
  2. call myFunc pushes the return address onto the stack and jumps to myFunc.
  3. push ebp saves the current base pointer onto the stack so it can be restored later.
  4. mov ebp, esp sets ebp to the current stack pointer, establishing the stack frame. This is, with a fancy term, known as the function prologue.
  5. mov eax, [ebp+8] reads the argument 5 from the stack into eax. The offset is +8 because ebp+0 is the saved ebp, ebp+4 is the return address, and ebp+8 is 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
  1. .loop begins. cmp eax, 0 checks if eax has reached 0.
  2. je .done jumps to .done if eax is 0, otherwise continues.
  3. add ebx, 5 adds 5 to ebx.
  4. dec eax decrements eax by 1.
  5. jmp .loop jumps 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).

  1. pop ebp restores the original base pointer. This is known as the function epilogue.
  2. ret pops the return address off the stack and jumps back to _start.
  3. Back in _start, mov eax, 1 and int 0x80 exit the program with code 0.

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 for sys_write;
  • ebx must hold the value 1, as this is the file descriptor for stdout;
  • 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:

  1. push ebp saves the current base pointer onto the stack.
  2. mov ebp, esp establishes the stack frame (function prologue, remember? :D)
  3. mov eax, 4 sets up system call 4, which is sys_write on Linux.
  4. mov ebx, 1 sets the file descriptor to 1, which is stdout.
  5. mov ecx, [ebp+8] reads the argument passed to yeet and stores the pointer it in ecx.
  6. call calc_len pushes the return address and jumps into calc_len, which will calculate the length of the string and store it in edx.

Inside calc_len:

  1. mov edx, 0 initializes the length counter to 0.
  2. .loop begins. cmp byte [ecx], 0 checks if the current character in memory is a null terminator (\0), which marks the end of the text.
  3. je .done jumps to .done if the null terminator is found, otherwise continues.
  4. inc ecx moves the pointer forward by one byte to the next character.
  5. inc edx increments the length counter.
  6. jmp .loop jumps 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.

  1. mov ecx, [ebp+8] restores ecx back to the start of the string, since the loop advanced it to the end.
  2. ret returns to yeet, with edx holding the string length.

Back in yeet:

  1. int 0x80 triggers the Linux syscall handler. At this point all four arguments for sys_write are set up: eax = 4 (sys_write), ebx = 1 (stdout), ecx = pointer to string, edx = length. This prints the string to the terminal.
  2. mov esp, ebp restores the stack pointer, this is called the function epilogue.
  3. pop ebp restores the original base pointer.
  4. ret returns 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!