Inspección de objetos binarios y análisis de código en lenguaje ensamblador x86 - stefano-sosac/arquitectura-de-computadoras GitHub Wiki

Nota: Para este capítulo, debe tener en claro los conceptos de Lenguaje Ensamblador, Punto Flotante y Convención de llamadas a función en 64 bits

Banderas de optimización en GCC

Los códigos en C son compilados a través de GCC. Este compilador nos permite generar diferentes niveles de optimización a tarvés de las banderas de optimización. En este capítulo, incluiremos algunas banderas de optimización que servirán como ejemplo debido a que su desensamblaje suele ser más fácil de entender, sobretodo, si recién está iniciando en el mundo de x86-64. Si es de interés del lector, puede encontrar la lista completa de optimizadores aquí. Es importante recalcar que otros optimizadores podrían desensamblar códigos más complejos que incluyen el uso de la pila, instrucciones SIMD, entre otras instrucciones con mayor complejidad.

Ejemplo de análisis

Para esta sección, analizaremos directamente un ejemplo en C. Se adjunta el código mul_esc_vec.c que tiene como función mul_esc_vec:

void mul_esc_vec(int *a, int b, int *c, int N){
    for (int i = 0; i<N;i++)
    {
        c[i] = b*a[i];
    }    
}

En el código proporcionado, se especifican cuatro argumentos de entrada. El propósito de la función es multiplicar cada elemento del arreglo a por el valor de b y almacenar el resultado en el arreglo c. Aunque esta función, por sí misma, no realiza ninguna operación a menos que sea invocada, es posible proceder a su descomposición en lenguaje ensamblador. Tras definir la función en un archivo de C, surge el interés por crear un archivo que facilite su desensamblaje. Para este fin, se selecciona la bandera de optimización -Os, ya que produce el código más compacto. El comando para llevar a cabo esta operación es el siguiente:

gcc -Os -c mul_esc_vec.c -o mul_esc_vec.o

Es importante recalcar que GCC tiene la capacidad de no solo generar archivos ejecutables sino también archivos object file. Es por ese motivo que se agrega el flag -c

Posteriormenete, se utilizará la función objdump para ver las instrucciones asociadas al object file considerando las siguientes opciones y banderas:

  • -M intel para que use sintaxis de intel
  • -j .text para que solo muestre el segmento de código
  • -D para que muestre el disassembly
objdump -M intel -j .text -D mul_esc_vec.o

El resultado será el siguiente:

Disassembly of section .text:

0000000000000000 <mul_esc_vec>:
   0:	f3 0f 1e fa          	endbr64 
   4:	31 c0                	xor    eax,eax
   6:	39 c1                	cmp    ecx,eax
   8:	7e 11                	jle    1b <mul_esc_vec+0x1b>
   a:	44 8b 04 87          	mov    r8d,DWORD PTR [rdi+rax*4]
   e:	44 0f af c6          	imul   r8d,esi
  12:	44 89 04 82          	mov    DWORD PTR [rdx+rax*4],r8d
  16:	48 ff c0             	inc    rax
  19:	eb eb                	jmp    6 <mul_esc_vec+0x6>
  1b:	c3                   	ret

Para analizar este ejemplo, se detallará línea por línea las acciones tomadas por el procesador:

  • Línea 0x0 (endbr64): es una medida de seguridad en sistemas de 64 bits, parte de Intel Control-flow Enforcement Technology (CET), que previene ataques manipulando el flujo de control del programa. Actúa como un marcador para transiciones de control de flujo indirecto, asegurando que los saltos se realicen solo a puntos autorizados, ayudando a mitigar vulnerabilidades y ataques de explotación.

  • Línea 0x4 (xor eax, eax): eax = eax xor eax = 0

A partir de este punto, es importante relacionar el código con la convención de llamadas. En nuestro ejemplo, se establece que el valor del puntero a se almacena en el registro rdi, el valor de b en rsi, el valor del puntero c en rdx y el valor de N en rcx.

  • Línea 0x6 (cmp ecx,eax): Se compara el valor de N con 0. Esto debido a que si no hay elementos en el arreglo simplemente se debe acabar el programa en la siguiente línea. De lo contrario, las siguientes líneas de código procederán a la iteración de ir elemento por elemento del arreglo a y multiplicarlo por la constance en b y almacenarlo en cada posición de c.

  • Línea 0x8 (jle 1b): Jump lower or equal. Saltará a la dirección 0x1b si es que ecx (que contiene el valor de N) es menor o igual que eax (que contiene 0)

  • Línea 0xa (mov r8d,DWORD PTR [rdi+rax4]): En caso el contador de programa se encuentre en esta línea, se buscarán los 4 bytes que hay desde la dirección de memoria [rdi+4rax]. Recordar que rdi contiene el puntero a a; por tanto, esta instrucción busca mover los valores del arreglo a al registro de 4 bytes r8d conforme rax aumente su valor de uno en uno.

  • Línea 0xe (imul r8d,esi): Dado que r8d contiene un elemento del arreglo a y esi el valor de b, esta instrucción simplemente realiza la multiplicación entre los elementos. Es importante aclarar que si bien la convención de llamadas menciona que rsi tiene el valor de b, esta variable fue declarada como entera (4 bytes) y, por tanto, entra exactamente en la parte menos significativa de rsi ( esi).

  • Línea 0x12 (mov DWORD PTR [rdx+rax4],r8d): Esta instrucción migra el valor de la multiplicación previamente calculada en el espacio correspondiente que hace referencia al arreglo representado por c.

  • Línea 0x16 (inc rax): Incrementa el registro rax en 1. Esto debido a que en las líneas 0xa y 0x12 se tiene la operación 4*rax. La razón de ello es que al ser arreglos de enteros, la posición de memoria debe ir de 4 en 4 para tomar el valor de todos los bytes que hacen un elemento entero.

  • Línea 0x19 (jmp 6): La ejecución de esta instrucción implica un salto directo a la línea 6 la cual hace referencia a la comparación de eax y ecx en donde se busca evaluar si eax ya alcanzó el valor de N.

  • Línea 0x1b (ret): Implica el retorno a la función principal.

Aclaraciones:

  • En la línea 0x8, la instrucción completa dice jle 1b <mul_esc_vec+0x1b>. Lo que se encuentra entre < > implica el desplazamiento (offset) existente a partir de lo asignado por la etiqueta mul_esc_vec y su lugar en la RAM. Lo mismo para la línea 0x19.
  • Los números hexadecimales que se encuentran entre la línea del código y la instrucción (e.g f3 0f 1e fa para endbr64) son los opcode de cada instrucción.
  • En la línea 0x4, en teoría, el xor debería ser con rax debido que en el direccionamiento indirecto se utiliza rax; sin embargo, en este caso particular, esta operación afecta a todo el registro de 64 bits, según la documentación de Intel x86.

Para fines comparativos, se realizará la comparativa con el código generado por la bandera de optimización -O1

gcc -O1 -c mul_esc_vec.c -o mul_esc_vec.o
objdump -M intel -j .text -D mul_esc_vec.o
Disassembly of section .text:

0000000000000000 <mul_esc_vec>:
   0:	f3 0f 1e fa          	endbr64 
   4:	85 c9                	test   ecx,ecx
   6:	7e 1c                	jle    24 <mul_esc_vec+0x24>
   8:	89 c9                	mov    ecx,ecx
   a:	b8 00 00 00 00       	mov    eax,0x0
   f:	41 89 f0             	mov    r8d,esi
  12:	44 0f af 04 87       	imul   r8d,DWORD PTR [rdi+rax*4]
  17:	44 89 04 82          	mov    DWORD PTR [rdx+rax*4],r8d
  1b:	48 83 c0 01          	add    rax,0x1
  1f:	48 39 c8             	cmp    rax,rcx
  22:	75 eb                	jne    f <mul_esc_vec+0xf>
  24:	c3                   	ret   

Se procede de forma similar al ejemplo anterior:

  • Línea 0x0 (endbr64): Explicado en el ejemplo anterior.
  • Línea 0x4 (test ecx,ecx): Esta instrucción realiza una operación AND bit a bit entre ECX y él mismo. Aunque el resultado de esta operación no se almacena, afecta a los flags de estado en el registro de flags. Específicamente:

El Zero Flag (ZF) se establece si ECX es 0 (lo que significa que todos los bits en ECX son 0). El Sign Flag (SF) refleja el bit más significativo del resultado de la operación AND, que en este caso sería el bit más significativo de ECX mismo, ya que estamos haciendo AND con el mismo valor.

  • Línea 0x6 (jle 24): Esta instrucción ha sido utilizada previamente con el comando CMP. Típicamente, todos los saltos se utilizan de forma conjunta con instrucciones de comparación pero este caso es una de las excepciones. Todas las instrucciones de salto funcionan leyendo el registro de FLAGS. En este caso, El procesador realiza este salto si el resultado de la última operación aritmética o lógica cumplió alguna de estas condiciones:

El ZF está establecido, lo que indicaría que el resultado fue igual a cero. El SF no coincide con el Overflow Flag (OF), indicando un resultado negativo en operaciones con signo. Por tanto, esta línea cumplirá el salto cuando ECX (que contiene el valor de N) tenga valor 0 o sea negativo.

  • Línea 0x8 (mov ecx,ecx): En particular, esta instrucción parece no ser de utilidad en el código. Esto podría ser parte de un artefacto por el proceso de compilación el cual no contribuye al desarrollo del código.

  • Línea 0xa (mov eax, 0x0): Asigna 0 a eax.

  • Línea 0xf (mov r8d,esi): Asigna el puntero de b al registro r8d

  • Línea 0x12 (imul r8d,DWORD PTR [rdi+rax*4]): Realiza la multiplicación con signo de los elementos de a con r8d y actualiza r8d con ese valor.

  • Línea 0x17 (mov DWORD PTR [rdx+rax*4],r8d): Lleva el valor de r8d como elemento al puntero c.

  • Línea 0x1b (add rax,0x1): Se incrementa en 1 el registro rax.

  • Línea 0x1f (cmp rax,rcx): Se compara si rax ya llegó a ser el valor de rcx (que contiene el valor de N)

  • Línea 0x22 (jne f): Retorna a la posición Línea 0xf hasta que rax es igual a rcx (que contiene el valor de N)

  • Línea 0x24 (ret): Explicado en el ejemplo anterior.

Como se observa, ambos códigos cumplen el mismo objetivo; sin embargo, lo hacen a través de algunas variaciones tanto en instrucciones como en sus argumentos de entrada. Esta comparativa permite entender mejor cómo GCC puede dar diferentes compilaciones. Esto permite hallar qué opción genera un mayor número de instrucciones así como su espacio en memoria principal.

Ejercicios

A continuación, se le invita a resolver la siguiente lista de ejercicios.

Ejercicio 1

Se le brinda el siguiente código en lenguaje ensamblador:


0000000000000000 <code>:
   0:	f3 0f 1e fa          	endbr64 
   4:	31 c0                	xor    eax,eax
   6:	39 c6                	cmp    esi,eax
   8:	7e 16                	jle    20 <code+0x20>
   a:	f3 0f 2a d0          	cvtsi2ss xmm2,eax
   e:	f3 0f 59 d0          	mulss  xmm2,xmm0
  12:	f3 0f 58 d1          	addss  xmm2,xmm1
  16:	f3 0f 11 14 87       	movss  DWORD PTR [rdi+rax*4],xmm2
  1b:	48 ff c0             	inc    rax
  1e:	eb e6                	jmp    6 <code+0x6>
  20:	c3                   	ret 

Se le pide:

a) Asumiendo que eax es 10, indicar cuál es el valor de xmm2 si se ejecuta la línea 0xa

b) Asumiendo que xmm0 es 2 y xmm2 es 4, indicar cuál es el valor de xmm2 si se ejecuta la línea 0xe

c) Indicar el equivalente en lenguaje c

d) Indicar cuál es el prototipo de la función en c

Ejercicio 2

Se le brinda la siguiente función en c:

float int_sum(float a, float *b, int N){
    for (int i = 0; i<N; i++)
    {
        b[0] = b[0] + (b[i] + a);
    }
    return b[0];
}

Se le pide:

a) Desensamblar el código utilizando la bandera de optimización -Os y describir el resultado línea por línea

b) Desensamblar el código utilizando la bandera de optimización -O3 y describir el resultado línea por línea

c) ¿Qué código contiene mayor cantidad de instrucciones para N = 8?

d) ¿Qué código ocupa mayor cantidad de bytes para N = 8?

Ejercicio 3

Se le brindan las siguientes funciones en C:

int sumaFor(int N) {
    int suma = 0;
    for(int i = 1; i <= N; i++) {
        suma += i;
    }
    return suma;
}
int sumaWhile(int N) {
    int suma = 0;
    int i = 1;
    while(i <= N) {
        suma += i;
        i++;
    }
    return suma;
}

Se le pide:

a) Desensamblar ambas funciones utilizando el mismo optimizador para ambas y comparar los códigos resultantes.

b) Comentar cuál de las optimizaciones es más apropiada para utilizar esta función.

Ejercicio 4

Se le brinda el siguiente código en C:

void funcion(int N, int *resultados) {
    int acumuladorA = 0, acumuladorB = 0;
    for(int i = 1; i <= N; i++) {
        if(i % 2 == 0) {
            acumuladorA += i;
        } else {
            acumuladorB += i;
        }
    }
    resultados[0] = acumuladorA; 
    resultados[1] = acumuladorB; 
}

Se le pide:

a) Describir qué es lo que hace el programa.

b) Desensamblar el programa con el optimizador -Os y generalizar el número de instrucciones para cualquier N.

c) Desensamblar el programa con el optimizador -O3 y generalizar el número de instrucciones para cualquier N.

d) Comparar cuál de las optimizaciones genera el menor número de instrucciones para diferentes N.

⚠️ **GitHub.com Fallback** ⚠️