This page looks best with JavaScript enabled

MercuryOS Compilation, Linking, Stack Management, and Variables in C

 ·  🎃 kr0m

Assembly programming allows us absolute control over hardware, but advancing in this language is a very slow process. Also, our code is so specific to the CPU we are using that it makes it less portable to other architectures. For this reason, we will use C for most of our OS code. In this article, we will see how C manages the stack and variable definition.

Before we start, it is recommended that you read these previous articles:


In my case, I am using FreeBSD, and the compiler is the following:

cc -v

FreeBSD clang version 8.0.1 (tags/RELEASE_801/final 366581) (based on LLVM 8.0.1)  
Target: x86_64-unknown-freebsd12.1  
Thread model: posix  
InstalledDir: /usr/bin

The best way to understand how a compiler works is by compiling a small program and seeing the result:

vi basic.c

int my_function () {
 return 0xbaba ;
}

We compile the object file, which will contain the textual labels:

cc -ffreestanding -c basic.c -o basic.o

We dump the assembly code of the object:

objdump -M intel -d basic.o

basic.o:     file format elf64-x86-64-freebsd
Disassembly of section .text:
0000000000000000 <my_function>:
   0: 55                    push   rbp
   1: 48 89 e5              mov    rbp,rsp
   4: b8 ba ba 00 00        mov    eax,0xbaba
   9: 5d                    pop    rbp
   a: c3                    ret

As we can see, the steps to execute are:

  • Save the base value of the previous function’s stack
  • Adjust our base to the current top of the stack, thus creating a new stack frame for our function
  • Move the value 0xbaba to the ax register, where the upper function expects the result
  • Restore the value of the previous function’s stack base
  • Return

To generate a binary with all the object files, we will need a linker, which will convert all relative memory addresses of the object files to absolute addresses.

For example, call <label_X> will be translated to call 0x12345, where 0x12345 is the offset within the binary where the linker decided to put the code of the routine with label_X, the textual labels disappear.

We link the object files to form the final binary:

ld -o basic.bin -Ttext 0x0 --oformat binary basic.o

The linker can generate output in various formats. Depending on the output format, the binary will preserve metadata about how it should be loaded into memory, information about textual labels to help in software debugging.

By using the -Ttext 0x0 option, we are making all addresses have the base indicated by that parameter, which is equivalent to use the org ASM instruction that we saw . For now, this is not very important, but it will be when we load our kernel from the bootloader.

A binary can be disassembled into assembly code. The only drawback of obtaining assembly code from the binary is that some parts are data and will be displayed as ASM instructions. In our example code, we will not have this problem.

ndisasm -b 32 basic.bin > basic.dis
cat basic.dis

00000000  55                push ebp
00000001  48                dec eax
00000002  89E5              mov ebp,esp
00000004  B8BABA0000        mov eax,0xbaba
00000009  5D                pop ebp
0000000A  C3                ret
0000000B  0000              add [eax],al
0000000D  0000              add [eax],al
0000000F  00                db 0x00

Basically, it is the same code that we obtained from the object file. The linking has added some extra steps. For the curious, the first column (00000000) shown is the offset where the instructions will reside in memory.

Let’s now give an example with local variables in the function:

vi local_var.c

int my_function () {
 int my_var = 0xbaba ;
 return my_var ;
}
cc -ffreestanding -c local_var.c -o local_var.o
objdump -M intel -d local_var.o
local_var.o:     file format elf64-x86-64-freebsd
Disassembly of section .text:
0000000000000000 <my_function>:
   0: 55                    push   rbp
   1: 48 89 e5              mov    rbp,rsp
   4: c7 45 fc ba ba 00 00  mov    DWORD PTR [rbp-0x4],0xbaba
   b: 8b 45 fc              mov    eax,DWORD PTR [rbp-0x4]
   e: 5d                    pop    rbp
   f: c3                    ret    

The process is very similar to the previous one:

  • We save the previous bp
  • We adjust the new bp to sp
  • We move the value 0xbaba to the memory position bp-4 (the stack grows downwards)
  • We save the return value in the ax register
  • We restore the previous bp
  • Return

To debug it in a more interactive way, we can program a complete code with its main() function and load it into GDB-gef :

vi test.c

int my_function();

int main(int argc, char *argv[])
{
 my_function();
}

int my_function() {
 int my_var = 0xbaba;
 return my_var;
}
cc -g test.c -o test
gdb -q ./test
GEF for freebsd ready, type `gef' to start, `gef config' to configure  
80 commands loaded for GDB 9.2 [GDB v9.2 for FreeBSD] using Python engine 3.7  
Reading symbols from ./test...

Let’s see the source code in C:

gef➤  l
1 int main(int argc, char *argv[])
2 {
3 my_function();
4 }
5 
6 int my_function() {
7 int my_var = 0xbaba;
8 return my_var;
9 }
10

Let’s see the ASM code of our function:

gef➤  disassemble my_function
Dump of assembler code for function my_function:
   0x0000000000201310 <+0>: push   rbp
   0x0000000000201311 <+1>: mov    rbp,rsp
   0x0000000000201314 <+4>: mov    DWORD PTR [rbp-0x4],0xbaba
   0x000000000020131b <+11>: mov    eax,DWORD PTR [rbp-0x4]
   0x000000000020131e <+14>: pop    rbp
   0x000000000020131f <+15>: ret    
End of assembler dump.

We put a breakpoint just before calling our function, line 3 in the C code:

gef➤  break 3

We run the program:

gef➤  r

Let’s see in ASM where we are in the code:

gef➤  disassemble 
Dump of assembler code for function main:
   0x00000000002012e0 <+0>: push   rbp
   0x00000000002012e1 <+1>: mov    rbp,rsp
   0x00000000002012e4 <+4>: sub    rsp,0x20
   0x00000000002012e8 <+8>: mov    DWORD PTR [rbp-0x4],edi
   0x00000000002012eb <+11>: mov    QWORD PTR [rbp-0x10],rsi
=> 0x00000000002012ef <+15>: call   0x201310 <my_function>
   0x00000000002012f4 <+20>: xor    edi,edi
   0x00000000002012f6 <+22>: mov    DWORD PTR [rbp-0x14],eax
   0x00000000002012f9 <+25>: mov    eax,edi
   0x00000000002012fb <+27>: add    rsp,0x20
   0x00000000002012ff <+31>: pop    rbp
   0x0000000000201300 <+32>: ret    
End of assembler dump.

We execute a step in ASM:

=> 0x00000000002012ef <+15>: call 0x201310 <my_function>
gef➤  si

We check the value of bp and sp:

gef➤  registers $rbp $rsp
$rsp   : 0x7fffffffe0e8    
$rbp   : 0x7fffffffe110 

We check the values in memory near sp:

gef➤  x/10x $rsp-10
0x7fffffffe0de: 0x00000000 0x00000000 0x12f40000 0x00000020
0x7fffffffe0ee: 0xe1800000 0x7fffffff 0x00000000 0x00000000
0x7fffffffe0fe: 0xe1880000 0x7fffffff

We take one more step:

 →   0x201310 <my_function+0>  push   rbp
gef➤  si

We check the values near sp again:

gef➤  x/10x $rsp-10
0x7fffffffe0d6: 0x3e020000 0x0008003e 0xe1100000 0x7fffffff
0x7fffffffe0e6: 0x12f40000 0x00000020 0xe1800000 0x7fffffff
0x7fffffffe0f6: 0x00000000 0x00000000

We can see the value of bp in RAM:

$rbp : 0x7fffffffe110 -> 0xe1100000 0x7fffffff

sp will have decremented:

gef➤  registers $rsp
$rsp : 0x7fffffffe0e0 

The next instruction is to create the stack frame of our function, we check the value of the bp/sp registers:

gef➤  registers $rbp $rsp
$rsp   : 0x7fffffffe0e0    
$rbp   : 0x7fffffffe110   

We execute a step:

=> 0x0000000000201311 <+1>: mov rbp,rsp
gef➤  si

We consult the value of the bp/sp registers:

gef➤  registers $rbp $rsp
$rsp : 0x7fffffffe0e0 
$rbp : 0x7fffffffe0e0 

The next instruction is to push the variable onto the stack, memory position bp-4:

=> 0x0000000000201314 <+4>: mov DWORD PTR [rbp-0x4],0xbaba

As we can see, it does not use sp but calculates the positions by subtracting the variable size from the value of bp. From this, we can draw several interesting conclusions:

  • sp is only used to adjust the value of bp when defining the stack frame.
  • sp does not change its value when variables are defined, only when they are pushed/popped.

Depending on the compiler or its version, the ASM code may vary. Sometimes sp is decremented for memory alignment issues, thus wasting part of the RAM but accessing the data with a single access. One must choose between speed or optimizing the use of RAM.

Before executing the order, we check the value of that memory position:

gef➤  x/4x $rbp-4
0x7fffffffe0dc: 0x00000008 0xffffe110 0x00007fff 0x002012f4

We define the variable in RAM:

=> 0x0000000000201314 <+4>: mov DWORD PTR [rbp-0x4],0xbaba
gef➤  si

We check that it has been saved:

gef➤  x/4x $rbp-4
0x7fffffffe0dc: 0x0000baba 0xffffe110 0x00007fff 0x002012f4

Correct, “baba” appears in the appropriate position.

The next instruction is to copy “baba” to the eax register as a return value. We check the current value:

gef➤  registers $rax
$rax : 0x0000000000203028 → 0x0000000000000001 → 0x0000000000000001

We execute the instruction:

=> 0x000000000020131b <+11>: mov eax,DWORD PTR [rbp-0x4]
gef➤  si

We check it again:

gef➤  registers $rax
$rax : 0xbaba 

The next instruction is to recover the previously pushed value before returning to the main function. Remember that before returning, we must leave the stack frame as it was before calling the function. We will have to adjust both sp and bp to their original values. In our case, sp has not been touched, so we only do it with bp.

We check the status of bp:

gef➤  registers $rbp
$rbp : 0x7fffffffe0e0 

We execute the instruction:

     0x20131e <my_function+14> pop    rbp
gef➤  si

We check the status of bp:

gef➤  registers $rbp
$rbp : 0x7fffffffe110 

Correct, that was the value of bp just before executing the function.

If you liked the article, you can treat me to a RedBull here