La programación en ensamblador nos permite un control absoluto sobre el hardware, pero avanzar en este lenguaje es un proceso muy lento además nuestro código es tan específico para la CPU que estemos utilizando que lo hace poco portable a otras arquitecturas. Por ese motivo utilizaremos C para la mayoría de código de nuestro SO, en este artículo veremos como C gestiona la pila y la definición de variables.
Antes de comenzar es recomendable que leas estos artículos anteriores:
- Boot Sector
- Interrupciones
- Memoria
- Pila
- IF-ELSE
- Funciones
- Segmentación de memoria
- Lectura de datos desde disco
- Entrando a modo protegido 32bits
En mi caso estoy utilizando FreeBSD, el compilador es el siguiente:
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
La mejor forma de entender como funciona un compilador es compilando un pequeño programa y viendo el resultado:
int my_function () {
return 0xbaba ;
}
Compilamos el fichero objeto, este contendrá las etiquetas textuales:
Dumpeamos el código ensamblador del objeto:
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
Como podemos ver los pasos a ejecutar son:
- Guardar el valor base de la pila de la función anterior
- Ajustar nuestra base a la cima actual de la pila, de este modo estamos creando un stack frame nuevo para nuestra función
- Movemos el valor 0xbaba al registro ax que es donde espera el resultado la función superior
- Restauramos el valor de la base de la pila de la función anterior
- Retornamos
Para generar un binario con todos los ficheros objeto necesitaremos un linker, este convertirá todas las direcciones de memoria relativa de los ficheros objeto a direcciones absolutas.
Por ejemplo call <etiqueta_X> será traducido a call 0x12345, donde 0x12345 es el offset dentro del binario donde el linker decidió poner el código de la rutina con la etiqueta_X, las etiquetas textuales desaparecen.
Linkamos los ficheros objeto formando el binario final:
El linker puede generar la salida en varios formatos, según el formato de salida el binario conservará meta información sobre como debe cargarse en memoria, información sobre etiquetas textuales para ayudar en el debugging del software.
Mediante la opción -Ttext 0x0 estamos haciendo que todas las direcciones tengan como base la indicada por tal parámetro, es el equivalente a utilizar la instrucción ASM org que ya vimos . Por ahora esto no tiene mucha importancia pero si que la tendrá cuando carguemos nuestro kernel desde el bootloader.
Un binario puede ser desensamblado a código ensamblador, el único inconveniente de obtener el código ensamblador desde el binario es que algunas partes son datos y se mostrarán como instrucciones ASM, en nuestro código de ejemplo no tendremos ese problema.
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
Básicamente es el mismo código que habiamos obtenido del fichero objeto, el linkado ha añadido algunos pasos extra, para los mas curiosos decir que la primera columna(00000000) mostrada es el offset donde residirán las instrucciones en memoria.
Pongamos ahora un ejemplo con variables locales a la función:
int my_function () {
int my_var = 0xbaba ;
return my_var ;
}
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
El proceso es muy similar al anterior:
- Guardamos el bp anterior
- Ajustamos el nuevo bp a sp
- Movemos el valor 0xbaba a la posición de memoria bp-4(la pila crece hacia abajo)
- Guardamos el valor de retorno en el registro ax
- Restauramos el bp anterior
- Return
Para debugearlo de forma mas interactiva podemos programar un código completo con su función main() y cargarlo en
GDB-gef
:
int my_function();
int main(int argc, char *argv[])
{
my_function();
}
int my_function() {
int my_var = 0xbaba;
return my_var;
}
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...
Veamos el código fuente en 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
Veamos el código ASM de nuestra función:
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.
Ponemos un breakpoint justo antes de llamar a nuestra función, línea 3 en el código en C:
gef➤ break 3
Corremos el programa:
gef➤ r
Veamos en ASM en que punto del código estamos:
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.
Ejecutamos un paso en ASM:
=> 0x00000000002012ef <+15>: call 0x201310 <my_function>
gef➤ si
Consultamos el valor de bp y sp:
gef➤ registers $rbp $rsp
$rsp : 0x7fffffffe0e8
$rbp : 0x7fffffffe110
Consultamos los valores en memoria cerca de sp:
gef➤ x/10x $rsp-10
0x7fffffffe0de: 0x00000000 0x00000000 0x12f40000 0x00000020
0x7fffffffe0ee: 0xe1800000 0x7fffffff 0x00000000 0x00000000
0x7fffffffe0fe: 0xe1880000 0x7fffffff
Damos un paso mas:
→ 0x201310 <my_function+0> push rbp
gef➤ si
Volvemos a consultar los valores cercanos a sp:
gef➤ x/10x $rsp-10
0x7fffffffe0d6: 0x3e020000 0x0008003e 0xe1100000 0x7fffffff
0x7fffffffe0e6: 0x12f40000 0x00000020 0xe1800000 0x7fffffff
0x7fffffffe0f6: 0x00000000 0x00000000
Podemos ver el valor de bp en la RAM:
$rbp : 0x7fffffffe110 -> 0xe1100000 0x7fffffff
El sp habrá decrementado:
gef➤ registers $rsp
$rsp : 0x7fffffffe0e0
La próxima instrucción es crear el stack frame de nuestra función, consultamos el valor de los registros bp/sp:
gef➤ registers $rbp $rsp
$rsp : 0x7fffffffe0e0
$rbp : 0x7fffffffe110
Ejecutamos un paso:
=> 0x0000000000201311 <+1>: mov rbp,rsp
gef➤ si
Consultamos el valor de los registros bp/sp:
gef➤ registers $rbp $rsp
$rsp : 0x7fffffffe0e0
$rbp : 0x7fffffffe0e0
La próxima instrucción es meter la variable en la pila, posición de memoria bp-4:
=> 0x0000000000201314 <+4>: mov DWORD PTR [rbp-0x4],0xbaba
Como vemos no utiliza sp si no que calcula las posiciones restándoles lo que ocupan las variables al valor de bp, de esto podemos extraer varias conclusiones muy interesantes:
- sp solo se utiliza para ajustar el valor de bp cuando definimos el stack frame
- sp no cambia de valor cuando se definen variables, solo cuando se pushean/popean
Según el compilador o la versión de este puede que el código en ASM varíe, hay ocasiones en las que se decrementa el sp por temas de alineamiento de memoria de este modo se desperdicia parte de la RAM pero se accede al dato con un solo acceso, hay que elegir entre velocidad u optimización del uso de la RAM.
Antes de ejecutar la orden consultamos el valor de esa posición de memoria:
gef➤ x/4x $rbp-4
0x7fffffffe0dc: 0x00000008 0xffffe110 0x00007fff 0x002012f4
Definimos la variable en RAM:
=> 0x0000000000201314 <+4>: mov DWORD PTR [rbp-0x4],0xbaba
gef➤ si
Comprobamos que se haya guardado:
gef➤ x/4x $rbp-4
0x7fffffffe0dc: 0x0000baba 0xffffe110 0x00007fff 0x002012f4
Correcto “baba” aparece en la posición adecuada.
La próxima instrucción es copiar “baba” al registro eax a modo de valor de retorno. consultamos el valor actual:
gef➤ registers $rax
$rax : 0x0000000000203028 → 0x0000000000000001 → 0x0000000000000001
Ejecutamos la instrucción:
=> 0x000000000020131b <+11>: mov eax,DWORD PTR [rbp-0x4]
gef➤ si
Volvemos a consultarlo:
gef➤ registers $rax
$rax : 0xbaba
La próxima instrucción es recuperar el valor pusheado anteriormente antes de volver a la función main, recordad que antes de retornar hay que dejar el stack frame tal como estaba antes de llamar a la función, habrá que ajustar tanto sp como bp a los valores originales, en nuestro caso sp no se ha tocado tan solo lo hacemos con bp.
Consultamos el estado de bp:
gef➤ registers $rbp
$rbp : 0x7fffffffe0e0
Ejecutamos la instrucción:
0x20131e <my_function+14> pop rbp
gef➤ si
Consultamos el estado de bp:
gef➤ registers $rbp
$rbp : 0x7fffffffe110
Correcto, ese era el valor de bp justo antes de ejecutar la función.