x86: assembler y call conventions

Para realizar el lab, se debe instalar el software necesario. La entrega se realiza en horario de clase del día indicado siguiendo las instrucciones de entrega en papel. Se recomienda usar la siguiente estructura de Makefile (ver sección make del lab kern0):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
CFLAGS := -m32 -g -std=c99 -Wall -Wextra -Wpedantic
CFLAGS += -O1 -fno-pic -fno-omit-frame-pointer -no-pie
ASFLAGS = $(CFLAGS)

ASMS := ...  # wildcard *.S
SRCS := ...  # wildcard *.c

# Como en este lab el código de cada programa reside en un
# único archivo, los wildcard transformarán prog.c o prog.S
# directamente a "prog", sin usar compilación intermedia; esto
# es, sin que make llegue a recibir prog.o como objetivo.
PROG := ...  # patsubst %.S → %
PROG += ...  # patsubst %.c → %

all: $(PROG)

clean:
	rm -f $(PROG) *.o core

.PHONY: clean all

Índice

Los ejercicios marcados con ★ son opcionales, y no es obligatoria su entrega.

Llamadas a biblioteca y llamadas al sistema

  • Lecturas obligatorias1

    • KERR
      • cap. 3: §1-3
      • cap. 4: §1-2
  • Lecturas recomendadas

    • KERR
      • cap. 1: §1-3
      • cap. 2: §1-5
      • cap. 3: §6(1, 3)

El siguiente programa, hello.c, escribe un mensaje por pantalla en un sistema POSIX, y termina con estado numérico 7:

1
2
3
4
5
6
7
8
#include <unistd.h>

const char msg[] = "Hello, world!\n";

int main(void) {
    write(1, msg, sizeof msg - 1);
    _exit(7);
}

Nótese que se usan directamente las llamadas al sistema write(2) y exit(2) en lugar de las funciones fputs(3) y exit(3) de la biblioteca estándar.

En el archivo libc_hello.S se encuentra una versión en assembler del mismo programa; según la convención estándar de GCC, los parámetros se pasan en orden inverso en la pila:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.globl main
main:
        push $len
        push $msg
        push $1

        call write

        push $7
        call _exit

.data
msg:
        .ascii "Hello, world!\n"

.set len, . - msg

Ej: x86-write

  • Lecturas obligatorias

    • KERR
      • cap. 4: §5
    • REES
      • cap. 4
        • Quick Review of Arrays
        • Pointer Notation and Arrays
  • Lecturas recomendadas

    • BRY2
      • cap 3: §8, 10

Sobre el código anterior, responder:

  • ¿Por qué se le resta 1 al resultado de sizeof?
  • ¿Funcionaría el programa si se declarase msg como const char *msg = "...";? ¿Por qué?
  • Explicar el efecto del operador . en la línea .set len, . - msg.

Compilar ahora libc_hello.S y verificar que funciona correctamente. Explicar el propósito de cada instrucción, y cómo se corresponde con el código C original. Después:

  • Mostrar un hex dump de la salida del programa en assembler. Se puede obtener con el comando od:

    1
    2
    
    $ ./libc_hello | od -t x1 -c
    0000000 ...
    
  • Cambiar la directiva .ascii por .asciz y mostrar el hex dump resultante con el nuevo código. ¿Qué está ocurriendo?

Finalmente, como alternativa a .set len, también se podría usar la función strlen(3) para calcular la cantidad de bytes a imprimir. En C:

1
2
3
4
5
6
7
8
9
#include <string.h>
#include <unistd.h>

const char *msg = "Hello, world!\n";

int main(void) {
    write(1, msg, strlen(msg));
    _exit(7);
}

Actualizar el archivo libc_hello.S eliminando la definición de len en favor de una llamada a strlen(3) para calcular el tercer parámetro de la llamada a write; deberá usarse .ascii o .asciz según corresponda. (Incluir esta modificación directamente como parte del ejercicio siguiente, x86-call.)

Ej: x86-call

  • Lecturas obligatorias

    • BRY2
      • cap. 3: §7

El comando disassemble de GDB permite examinar directamente las instrucciones del programa que se está depurando. Por ejemplo, con disas main se muestra:

1
2
3
4
5
6
7
8
9
$ gdb -q ./libc_hello
(gdb) disas main
Dump of assembler code for function main:
   0x0804843b <+0>:     push   $0xe
   0x08048440 <+5>:     push   $0x804a020
   0x08048445 <+10>:    push   $0x1
   0x08048447 <+12>:    call   0x8048320 <write@plt>
   0x0804844c <+17>:    push   $0x7
   0x0804844e <+19>:    call   0x8048300 <_exit@plt>

Sin argumentos, disas muestra las instrucciones que se ejecutarían a continuación, esto es, las instrucciones a partir del program counter actual:2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
(gdb) b main
Breakpoint 1 at 0x804843b: file libc_hello.S, line 9.

(gdb) r
Starting program: ./libc_hello
Breakpoint 1, main () at libc_hello.S:9
9               push $len

(gdb) disas
Dump of assembler code for function main:
=> 0x0804843b <+0>:     push   $0xe
   0x08048440 <+5>:     push   $0x804a020
   0x08048445 <+10>:    push   $0x1
   0x08048447 <+12>:    call   0x8048320 <write@plt>
   0x0804844c <+17>:    push   $0x7
   0x0804844e <+19>:    call   0x8048300 <_exit@plt>

(gdb) p $pc
$1 = (void (*)()) 0x804843b <main>

(gdb) p/x $pc
$2 = 0x804843b

... →

Mostrar en una sesión de GDB cómo imprimir las mismas instrucciones usando la directiva x $pc y el modificador i. Después, usar el comando stepi (step instruction) para avanzar la ejecución hasta la llamada a write. En ese momento, mostrar los primeros cuatro valores de la pila justo antes e inmediatamente después de ejecutar la instrucción call, y explicar cada uno de ellos.3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
... →

(gdb) x/6.. $pc
=> 0x804843b <main>:    push   $0xe
   0x8048440 <main+5>:  push   $0x804a020
   0x8048445 <main+10>: push   $0x1
   0x8048447 <main+12>: call   0x8048320 <write@plt>
   0x804844c <main+17>: push   $0x7
   0x804844e <main+19>: call   0x8048300 <_exit@plt>

(gdb) display...  # Opcional
=> 0x804843b <main>:    push   $0xe

(gdb) stepi↩︎
10              push $msg
=> 0x8048440 <main+5>:  push   $0x804a020

(gdb) si↩︎
11              push $1
=> 0x8048445 <main+10>: push   $0x1

(gdb) ↩︎
14              call write
=> 0x8048447 <main+12>: call   0x8048320 <write@plt>

(gdb) x/... $sp
0x...:  ...

(gdb) si
...

(gdb) x/... $sp
0x...:  ...

Finalmente, sustituir la instrucción call write por jmp write, y añadir el código y preparaciones necesarias para que el programa siga funcionando (ayuda: usar una etiqueta posicion_retorno: dentro de main para computar la dirección de retorno). Las llamadas a strlen y _exit pueden quedar. Incluir esta última versión en la entrega.

Ayuda adicional GDB: stepi ejecuta solamente la siguiente instrucción máquina; así, al realizar stepi sobre call write la ejecución salta adentro del código de la función. Para ejecutar en un solo paso el resto de la función, y volver a main rápidamente, se puede usar el comando finish de GDB. Alternativamente, si no se necesitase “entrar” en la función, se puede usar next para ejecutar no una instrucción, sino una línea de código.

Ej: x86-libc

Las llamadas a write y _exit en los programas anteriores no son directamente llamadas al sistema, sino que pasan por la biblioteca estándar de C (libc). Para cada syscall, libc proporciona un wrapper que es quien realiza la verdadera invocación al sistema.

Con el comando nm se puede ver cómo el linker incluyó referencias a dichos wrappers tanto en la versión en C, como en la versión en assembler:

1
2
3
4
5
6
7
8
9
$ nm --undefined hello
         U __libc_start_main@@GLIBC_2.0
         U _exit@@GLIBC_2.0
         U write@@GLIBC_2.0

$ nm -u libc_hello
         U __libc_start_main@@GLIBC_2.0
         U _exit@@GLIBC_2.0
         U write@@GLIBC_2.0

Como se explica en la bibliografía, las llamadas al sistema no se realizan con la instrucción call sino mediante una “excepción controlada” o trap (instrucción int o, en x86_64, syscall). Cada sistema operativo define su propia convención de llamada, que puede variar según la arquitectura; en el caso de Linux, los argumentos no se pasan por la pila, sino mediante registros. (Ver páginas de manual syscalls(2) y syscall(2).)

Así, para llamar directamente al syscall write (no al wrapper de libc) se pasan los argumentos mediante los registros %ebx, %ecx y %edx. En %eax se escribe una constante numérica que le indica al kernel qué syscall debe realizar. Finalmente, se cede el control de la CPU al kernel mediante una instrucción int:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <sys/syscall.h>  // SYS_write, SYS_exit

.globl main
main:
        mov $SYS_write, %eax   // %eax == syscall number
        mov $1, %ebx           // %ebx == 1st argument (fd)
        mov $msg, %ecx         // %ecx == 2nd argument (buf)
        mov $len, %edx         // %edx == 3rd argument (count)
        int $0x80

        mov $SYS_exit, %eax
        mov $7, %ebx
        int $0x80

Se pide:

  1. Compilar y ejecutar el archivo completo int80_hi.S. Mostrar la salida de nm --undefined para este nuevo binario.

    ¿Errores de compilación?

    La compilación no debería fallar si están instalados los paquetes gcc-multilib y linux-libc-dev. No obstante, si GCC no encuentra algún archivo include, quizá sea necesario indicar un directorio adicional de búsqueda, que en Debian y Ubuntu es:

    1
    
    CPPFLAGS := -I/usr/include/x86_64-linux-gnu
    

    En otros sistemas, se puede usar el siguiente comando para determinar desde qué ruta se están incluyendo los archivos:

    1
    2
    3
    4
    5
    6
    7
    
    $ echo "#include <asm/unistd.h>\n SYS_write = __NR_write" |
        gcc -E -P -H -xc -
    
    . /usr/include/x86_64-linux-gnu/asm/unistd.h
    .. /usr/include/x86_64-linux-gnu/asm/unistd_64.h
    
    SYS_write = 1
    

    Y verificar que es posible encontrar los mismos archivos combinando -m32 con la opción -I adecuada (nota: el valor de SYS_write es distinto entre x86 y x86_64):

    1
    2
    3
    4
    5
    6
    7
    
    $ echo "#include <asm/unistd.h>\n SYS_write = __NR_write" |
        gcc -m32 -I... -E -P -H -xc -
    
    . /usr/include/x86_64-linux-gnu/asm/unistd.h
    .. /usr/include/x86_64-linux-gnu/asm/unistd_32.h
    
    SYS_write = 4
    
  2. Escribir una versión modificada llamada int80_strlen.S en la que, de nuevo eliminando la directiva .set len, se calcule la longitud del mensaje (tercer parámetro para write) usando directamente strlen(3) (el código será muy parecido al de ejercicios anteriores). Mostrar la salida de nm --undefined para este nuevo binario.

  3. En la convención de llamadas de GCC, ciertos registros son caller-saved (por ejemplo %ecx) y ciertos otros callee-saved (por ejemplo %ebx). Responder:

    • ¿qué significa que un registro sea callee-saved en lugar de caller-saved?

    • en x86 ¿de qué tipo, caller-saved o callee-saved, es cada registro según la convención de llamadas de GCC?

  4. Copiar int80_strlen.S a un nuevo archivo sys_strlen.S, renombrando main a _start en el proceso. Mostrar la salida de nm --undefined para este nuevo binario, y describir brevemente las diferencias con los casos anteriores.

    (Al compilar, se produce un error; se necesitará una de las opciones -nodefaultlibs o -nostartfiles para corregirlo. Se puede leer sobre estas opciones en Guide to Bare Metal Programming with GCC, en la sección: Linker options for default libraries and start files.)

  5. Añadir al archivo Makefile una regla que permita compilar sys_strlen.S sin errores, así como cualquier otro archivo cuyo nombre empiece por sys:

    1
    2
    
     sys_%: sys_%.S
             $(CC) $(ASFLAGS) $(CPPFLAGS) -no... $< -o $@
    

Ej: x86-ret

  • Lecturas recomendadas

    • BRY2
      • cap. 3: §11

Un main estándar devuelve int, y comúnmente se usa return, no exit, para devolver un código de error. Los “start files” de libc y su definición de _start se encargan de propagar ese valor de retorno al syscall exit.

Se pide ahora modificar int80_hi.S para que, en lugar de invocar a a _exit(), la ejecución finalice sencillamente con una instrucción ret. ¿Cómo se pasa en este caso el valor de retorno?

Para que ret funcione, %esp debe volver a su valor original (el que tenía al entrar en la función). En los casos más sencillos, basta con asegurar que se realizan tantos pop como push.

Se pide también escribir un nuevo programa, libc_puts.S, que use una instrucción ret en lugar de una llamada a _exit. Al contrario que int80_hi.S, este programa sí modifica la pila. Para simplificar la tarea, libc_puts.S puede usar puts(3) en lugar de write(2):

1
2
3
4
5
6
7
8
// Versión C de libc_puts.S

#include <stdio.h>

int main(void) {
    puts("Hello, world!");
    return 7;
}

libc y exit

Se puede depurar libc_puts con GDB para mostrar que, efectivamente, libc propaga el valor de retorno de main a la llamada a exit que termina el programa.

Se pide mostrar, usando un catchpoint, una sesión de GDB el momento en que el binario libc_puts realiza la llamada a exit con int $0x80 o sysenter, y dónde reside dicha instrucción. Se incluye un guión de ejemplo más abajo.

Un catchpoint en GDB es un tipo especial de breakpoint que, en lugar de detener la ejecución en una línea o función, lo hace cuando se invoca una categoría particular de función o instrucción. Se puede usar, por ejemplo, para capturar excepciones de C++ o aserciones fallidas en otros lenguajes.

En este caso, el interés residirá en detener la ejecución en el momento de la llamada al syscall exit. Como se explica en la documentación de catch syscall, este comando recibe simplemente el nombre de la llamada al sistema, pudiéndose usar auto-completado.

En el momento en que se llegue a la condición de corte y se detenga la ejecución, se debe mostrar el código colindante con disas y los marcos de ejecución mediante el comando backtrace de GDB.

Finalmente se indicará, para cada función en el backtrace, en qué archivo o biblioteca se aloja, esto es, la correspondencia entre las funciones del backtrace, las posiciones de memoria donde reside el código de cada función y los archivos donde se aloja el código.

Muchas de estas funciones residen en bibliotecas cargadas de dinámicamente en tiempo de ejecución. GDB puede informar de qué bibliotecas usó el mediante el comando info shared.

Ayuda:

  • es posible que libc no llame a exit(2), sino a exit_group(2), por lo que se recomienda establecer un catchpoint para ambas syscalls.

  • las direcciones de memoria del comando bt corresponden a la dirección de retorno de la llamada en curso; para obtener la dirección donde reside el comienzo de la función, se puede usar print nombre_fun.

  • se puede abreviar catch syscall con cat sys.

Guión de ejemplo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
$ gdb -q ./libc_puts
(gdb) catch syscall ...
Catchpoint 1 ...

(gdb) r
Starting program: ...
...
Catchpoint 1 (call to syscall ...) ... in ...

(gdb) disas
Dump of assembler code for function ...
   0xf7...
   0xf7...
=> 0xf7...
   0xf7...
   0xf7...
   0xf7...
End of assembler dump.

(gdb) bt
#0  0xf7... in ... () from ...
...

(gdb) info shared
From | To | Syms Read | Shared Object Library
...

Ej: x86-watch ★

Un método más rudimentario para detectar la llegada a un syscall sin catch syscall sería el comando watch de GDB, que vigila un registro o posición de memoria hasta que toma un cierto valor. Así, se podría detener la ejecución cuando %eax u otros registros indiquen que se está por llamar a exit.

Se pide un guión de GDB similar al del ejercicio anterior, usando la funcionalidad watch en lugar de catch.

Guía:

  • usar watch contra el registro que aloje el argumento de la llamada al sistema exit.

  • cambiar en el código el valor de retorno a algo con menos chances de provocar falsos positivos (por ejemplo, return 66 o return 0x42 en lugar de return 7).

  • para afinar la búsqueda, se puede vigilar también el valor de %eax, que tomará el valor de la constante SYS_exit justo antes de la instrucción int. Así, en lugar de:

    1
    
    (gdb) watch $e.. == 0x42
    

    usar:

    1
    
    (gdb) watch $e.. == 0x42 && $eax == ...
    
  • como ya se explicó, es posible que libc no llame a exit(2), sino a exit_group(2). Encontrar también el valor de SYS_exit_group, y combinar las condiciones usando ||.

    Verificar además que los prototipos de ambas funciones son compatibles y responder: ¿cómo cambiaría la expresión booleana si —hipotéticamente— exit_group() tomara el valor de salida como segundo parámetro?

  • el comando watch funciona una vez arrancado el programa (b main; r o, lo que es lo mismo, start), de lo contrario quizá GDB diga “No registers”.

Finalmente, incluir de nuevo la salida del comando info shared y compararla con la información de regiones de memoria que Linux proporciona vía procfs. Así, sin cerrar aún GDB, incluir la salida de:

1
$ cat /proc/`pidof libc_puts`/maps

y revisar si las bibliotecas indicadas por GDB corresponden a las ubicaciones indicadas por el kernel.

Guión de ejemplo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
$ gdb -q ./libc_puts
(gdb) start
Temporary breakpoint 1 at 0x80...: file libc_puts.S, line 3.

(gdb) watch ...
Watchpoint 2: ...

(gdb) c
Continuing.
Hello, world!

Watchpoint 2: ...

Old value = 0
New value = 1
0xf7... in ... () from ...

(gdb) p $eax
$1 = ...

(gdb) p $e..
$2 = 66

(gdb) disas
Dump of assembler code for function ...
   0xf7...
   0xf7...
=> 0xf7...
   0xf7...
   0xf7...
   0xf7...
End of assembler dump.

(gdb) bt
#0  0xf7... in ... () from ...
...

(gdb) info shared
From | To | Syms Read | Shared Object Library
...

Stack frames y calling conventions

En el código visto hasta ahora, se han invocado varias funciones pasando sus argumentos por la pila, pero no se ha implementado ninguna función con parámetros. Obviamente, es en la pila donde una función buscará los valores de sus parámetros.

La búsqueda de parámetros en la pila se puede ver afectada por el uso de la misma, esto es: un argumento que esté alojado en 4(%esp) al entrar en la función, estará en 8(%esp) si el propio código invoca a una instrucción push.

Para ello, se suele usar el registro %ebp como “frame pointer”, guardando el valor original del registro %esp de manera que las referencias a los argumentos vía %ebp no varíen a lo largo de la función:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func:
    // Al entrar, el primer argumento está en 4(%esp)

    push %ebp
    movl %esp, %ebp

    // Ahora el primer argumento está en 8(%ebp), 8(%esp)

    push $len
    push $msg

    // El primer argumento sigue en 8(%ebp), pero 16(%esp)
    ...

    // Se debe restaurar %esp y %ebp antes de "ret"
    movl %ebp, %esp
    popl %ebp

    ret

A menudo, el código generado por GCC no usa %ebp, pues desde el compilador resulta más fácil llevar la cuenta de la ubicación de los argumentos aun si cambia el valor de %esp (pues el propio compilador controla cuándo lo cambia). Se puede forzar el uso de frame pointers con la opción -fno-omit-frame-pointer.

Ej: x86-ebp

Podemos comparar el assembler escrito a mano de libc_hello.S con el assembler generado por GCC a partir de C (usando la opción -fno-omit-frame-pointer). El comando objdump -S hello mostraría el código de programa completo, pero se puede invocar a GDB en modo no interactivo para obtener el código de una sola función:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ gdb -batch -ex 'disas/s main' ./hello
5       int main(void) {
   ...
   0x08048445 <+10>:    push   %ebp
   0x08048446 <+11>:    mov    %esp,%ebp
   ...

6           write(1, msg, sizeof msg - 1);
   0x0804844c <+17>:    push   $0xe
   0x0804844e <+19>:    push   $0x80484f0
   0x08048453 <+24>:    push   $0x1
   0x08048455 <+26>:    call   0x8048320 <write@plt>

7           _exit(3);
   0x0804845a <+31>:    movl   $0x7,(%esp)
   0x08048461 <+38>:    call   0x8048300 <_exit@plt>
End of assembler dump.

Mientras que el código resultante de compilar libc_hello.S es, simplemente:

1
2
3
4
5
6
7
8
$ gdb -batch -ex 'disas main' libc_hello
Dump of assembler code for function main:
   0x0804843b <+0>:     push   $0xe
   0x08048440 <+5>:     push   $0x804a020
   0x08048445 <+10>:    push   $0x1
   0x08048447 <+12>:    call   0x8048320 <write@plt>
   0x0804844c <+17>:    push   $0x7
   0x0804844e <+19>:    call   0x8048300 <_exit@plt>

Ambos son bien similares, pero tiene algunas diferencias:

  1. ¿Qué valor sobreescribió GCC cuando usó mov $7, (%esp) en lugar de push $7 para la llamada a _exit? ¿Tiene esto alguna consecuencia?

  2. La versión C no restaura el valor original de los registros %esp y %ebp. Cambiar la llamada a _exit(7) por return 7, y mostrar en qué cambia el código generado. ¿Se restaura ahora el valor original de %ebp?

  3. Crear un archivo llamado lib/exit.c con la siguiente función:

    1
    2
    3
    4
    5
    
    #include <unistd.h>
    
    void my_exit(int status) {
        _exit(status);
    }
    

    y usar en hello.c my_exit(7):

    1
    2
    3
    4
    5
    6
    
    extern void my_exit(int status);
    
    int main(void) {
        // ...
        my_exit(7);
    }
    

    ¿Qué ocurre con %ebp?

    Nota: el binario se puede compilar con make hello tras añadir la siguiente línea al Makefile:

    1
    
    hello: hello.c lib/exit.c
    
  4. En hello.c, cambiar la declaración de my_exit a:

    1
    
    extern void __attribute__((noreturn)) my_exit(int status);
    

    y verificar qué ocurre con %ebp, relacionándolo con el significado del atributo noreturn.

Ej: x86-frames

  • Lecturas obligatorias

    • BRY2
      • cap. 7: §4

Dada la convención de llamadas en x86, es posible saber la secuencia de llamadas anidadas que condujo al estado actual de ejecución. En particular, si la primera instrucción de cada función es:

1
push %ebp

se está almacenando entonces en el stack de cada función una referencia al marco de ejecución inmediatamente anterior. De ahí, se puede obtener el punto de retorno y el frame pointer anterior “saltando” hacia atrás a modo de lista enlazada.

Responder, en términos del frame pointer %ebp de una función f:

  • ¿dónde se encuentra (de haberlo) el primer argumento de f?
  • ¿dónde se encuentra la dirección a la que retorna f cuando ejecute ret?
  • ¿dónde se encuentra el valor de %ebp de la función anterior, que invocó a f?
  • ¿dónde se encuentra la dirección a la que retornará la función que invocó a f?

Se pide ahora escribir una función:

1
void backtrace();

que obtenga, usando __builtin_frame_address(0), el frame pointer actual, e imprima la secuencia de marcos anidados en el formato que se indica a continuación:

1
#numfrm [FP] ADDR ( ARG1 ARG2 ARG3 )

donde para cada frame FP es el frame pointer (registro %ebp), ADDR es el punto de retorno a la función, y ARGS sus tres primeros argumentos.

Por ejemplo, para un programa de ejemplo backtrace.c (no olvidar compilar con -fno-omit-frame-pointer):

1
2
3
4
5
6
7
8
9
#1 [0xffffd3a8] 0x8048515 ( 0x2 0x8048667 0xf )
#2 [0xffffd3c8] 0x8048570 ( 0x0 0x0 0xf7ffdad8 )
#3 [0xffffd3e8] 0x804855a ( 0x1 0x1 0xf7fd3b48 )
#4 [0xffffd408] 0x804855a ( 0x2 0x0 0xf7fe3100 )
#5 [0xffffd428] 0x804855a ( 0x3 0xf7ffd920 0xffffd450 )
#6 [0xffffd448] 0x804855a ( 0x4 0xf7fae000 0xf7e07e18 )
#7 [0xffffd468] 0x804855a ( 0x5 0x2 0xf7e29880 )
#8 [0xffffd488] 0x8048582 ( 0xf7fae3dc 0xffffd4b0 0x0 )
#9 [0xffffd498] 0x804859d ( 0x1 0xf7fae000 0x0 )

En este ejemplo, el primer frame muestra la dirección de my_write() a que se retornará, y los argumentos con que fue llamada. Nótese que la propia función backtrace() no aparece en la salida.

Nota: no usar la funcion __builtin_return_address(...); solamente __builtin_frame_address(0).

Incluir en la entrega:

  1. el código de la función backtrace.

  2. una sesión de GDB en la que se muestre la equivalencia entre el comando bt de GDB y el código implementado; en particular, se debe incluir:

    • la salida del comando bt al entrar en la función backtrace

    • la salida del programa al ejecutarse la función backtrace (el número de frames y sus direcciones de retorno deberían coincidir con la salida de bt)

    • usando los comandos de selección de frames, y antes de salir de la función backtrace, el valor de %ebp en cada marco de ejecución detectado por GDB (valores que también deberían coincidir).

Se puede usar el comando until de GDB para saltar a la última línea de la función sin finalizar su ejecución. Guión de ejemplo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
...
Breakpoint 1, backtrace () at backtrace.c:4

(gdb) bt
#0  backtrace () at backtrace.c:4
#1  0x08048515 in my_write (fd=...
#2  0x08048570 in recurse (level=...
...

(gdb) list
...
10    for (...) {
...
13    }

(gdb) until 13
#1 [0xffffd3a8] 0x8048515 ( ...
#2 [0xffffd3c8] 0x8048570 ( ...
...

(gdb) up
...
(gdb) p/x $ebp
$1 = 0xffffd3a8

(gdb) up
...

(gdb) p/x $ebp
$2 = 0xffffd3c8

...

(gdb) up
Initial frame selected; you cannot go up.

(gdb) frame 0
(gdb) c
...

Ej: x86-argv ★

  • Lecturas obligatorias

    • REES
      • cap. 4
        • Using a One-Dimensional Array of Pointers
    • BRY2
      • cap. 8: §4(5) (figura 8.21)
      • cap. 3: §6(1-2)
  • Lecturas recomendadas

    • REES
      • cap. 5
        • Passing Arguments to an Application
    • BRY2
      • cap. 3: §6(3)

En este ejercicio se pide implementar un programa en assembler que imprima sus argumentos por pantalla, uno por línea. Se implementarán tres versiones sucesivas:

  1. sys_argv.S: imprime el primer argumento solamente, usando una llamada directa al sistema.

  2. libc_argv.S: imprime el primer argumento solamente, usando el wrapper write(2).

  3. libc_argv2.S: imprime todos los argumentos recibidos, realizando una llamda a puts(3) para cada uno de ellos.

El estado de salida del programa debe ser:

  • para sys_argv y libc_argv, la longitud en bytes del primer argumento.
  • para sys_argv2, el número total de argumentos.

Guía para cada versión:

  • sys_argv es la versión más sencilla, ya que el layout de los argumentos en memoria es más simple: argc está en (%esp), y el primer argumento directamente en 8(%esp).

    Responder: ¿qué hay en 4(%esp)?

  • libc introduce un nivel de indirección, pues argc está en 4(%esp) pero en 8(%esp) está la dirección del arreglo de argumentos argv.

    Responder: ¿qué hay en (%esp)?

  • para el bucle de libc_argv2, se recomienda acceder a cada argumento con push (%edi,%ebx,X), habiendo guardado en %edi la dirección de argv, y estando en %ebx el índice de argv a acceder.

    Usar ahora el comando strace(1) para averiguar cuántas veces se invoca la llamada al sistema write según el número de argumentos:4

    1
    2
    3
    
    $ strace ./libc_argv2 a
    $ strace ./libc_argv2 a b
    $ strace ./libc_argv2 a b c
    

    Ahora, volver a ejecutar los comandos redirigiendo la salida a un archivo de texto:

    1
    2
    3
    
    $ strace -e write ./libc_argv2 a   >salida1.txt
    $ strace -e write ./libc_argv2 a b >salida2.txt
    ...
    

    ¿Cambió el número de llamadas a write al usar redirección? De ser así ¿quién fue responsable: el kernel, o glibc?

Consideraciones:

  • en sys_argv y libc_argv se puede asumir que siempre se pasa al menos un argumento; no es necesario, por tanto, comprobar el valor de argc en estos dos programas.

  • se puede asumir que ningún argumento termina en salto de línea; así, se puede añadir incondicionalmente uno.

  • en libc_argv, imprimir el salto de línea con una segunda llamada a write(2). Se recomienda definir una constante .ascii "\n" llamada newline.

  • realizar, en sys_argv, una sola llamada a write; así, será necesario remplazar el '\0' final del argumento por '\n'. Se recomienda usar la notación mov $('\n'), (%reg,P,X).

Ejemplos de invocación:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ ./sys_argv palabra; echo $?
palabra
7

$ ./libc_argv ""; echo $?

0

$ ./libc_argv2 una palabra y otra; echo $?
una
palabra
y
otra
4

$ ./libc_argv2; echo $?
0

Estimación de código:

1
2
3
4
$ wc --lines *argv*.S
  19 sys_argv.S
  24 libc_argv.S
  26 libc_argv2.S
  1. Para ayudar a la y evitar confusión por cambios entre ediciones, cada referencia numérica incluye un “tooltip” con el título del capítulo o sección. ↩︎

  2. Nótese cómo hace falta una llamada a run (r) para comenzar la ejecución del programa bajo GDB. No ocurría así en el depurado con QEMU, donde una llamada a continue resultó ser suficiente (porque para GDB el programa remoto ya aparece como en ejecución). ↩︎

  3. Como se puede observar, stepi se puede abreviar como si. Además, si se presiona ENTER en el prompt de GDB sin haber escrito nada, se ejecuta de nuevo la acción anterior. ↩︎

  4. Como la salida de strace puede ser muy larga, se puede usar la opción -e para restringir la traza a determinadas llamadas al sistema. Se puede consultar el significado de esta opción en la página de manual pero, siendo el futuro, hay por supuesto tutoriales de strace en forma de fanzine. (Blog completo de la autora aquí.) ↩︎