El inquietante caso de la Estructura Misteriosa
FENÓMENOS EXTRAÑOS
El inquietante caso de la Estructura Misteriosa
2026-03-12
Por
Carolyn Lightrun

¿Qué pensarías de un programa que imprime los elementos de un struct sin acceder a sus campos?. ¿Inquietante verdad?. Si que lo es. Cuando vi esto por primera vez me quedé perplejo, o como se dice ahora, me quedé como…. What?! eso compila?. Luego miré el ensamblador generado y todo tomó sentido. Si estás tan sorprendido como yo, quizás deberías seguir leyendo :)

Ocurrió hace unos días, viendo un video en Youtube. Uno de esos con el careto de alguien en la esquina de abajo mientras el resto de la pantalla muestra el terminal donde escriben código mientras hablan… Ooops, creo que yo también he hecho eso… pero sin careto XD. Estoy divagando. De vuelta a la historia, de repente en el terminal se ve un programa como el que sigue (la verdad es que mientras yo hablaba el tipo del vídeo fue escribiendo todo el código que muestro a continuación).

#include <stdio.h>

struct array_t {
  int    *p;
  size_t len;
};

int main () {
    struct array_t a;
    
    a.len = 10;
    a.p = (int *)0x11223344;
    printf ("%p %ld\n", a);
}

Si compiláis y ejecutáis obtenéis:

$ make test01
cc     test01.c   -o test01
$ ./test01
0x11223344 10

WTF? Super misterioso,¿verdad?. Bueno, no os agobiéis, para eso estoy aquí. Carolyn Lightrun viene al rescate.

Formalmente hablando el código anterior es Undefined Behavior o comportamiento indefinido. ¿Qué que es esto?. Bueno, así es como llama el estándar del lenguaje C a las cosas que no deberíamos hacer con ese lenguaje y la razón por la que no deberíamos hacerlo es porque… TADÁ!… el comportamiento de ese código es indefinido.

Los comportamientos definidos con UB (normalmente se refieren a esto con sus siglas en inglés), pueden producir distintos resultados dependiendo del compilador, el sistema operativo, el procesador o incluso el entorno. Básicamente el estándar no dice cual es el resultado de esa operación y depende de quién escriba el compilador decidir que hace.

Así que, lo primero que debemos tener claro es que este código es algo que no se debería usar en el mundo real. Afortunadamente, como todos sabemos, YouTube es un mundo de fantasía, así que ahí todo está permitido :). Este código solo os va a ahorra la pulsación de unas pocas teclas y os puede traer bastantes quebraderos de cabeza en el futuro… Ahora bien, si el programa es solo para el momento y queréis parecer güays, pues la verdad que es un truco bastante vistoso.

System V ABI

Aunque parezca que esto funciona por obra y gracia del espíritu santo, realmente lo hace por su equivalente cibernético: la ABI. La ABI o Application Binary Interface es una serie de reglas y formatos que los programas y librerías deben seguir para que puedan interactuar entre ellos y con el sistema operativo. La ABI define cosas como el formato de los binarios (ELF en el caso de Linux), como llamar a funciones de forma que podamos usar librerías y que nuestras librerías puedan ser usadas por otros programas, y cosas por el estilo. Es realmente una lectura muy interesante si tienes curiosidad por la programación de sistemas o simplemente quieres saber como funcionan los programas por dentro.

Linux al igual que muchos otros tipos de UNIX usa la denominada System V ABI, una ABI definida para el UNIX System V, que junto con BSD fueron las versiones más populares de UNIX en el principio de los tiempos, allá por el epoch. Una de las cosas que define el ABI es como llamar a funciones. Sin embargo, esa parte de la ABI es dependiente de la plataforma. Puesto que los procesadores son diferentes (tienen diferentes registros e instrucciones), el convenio para llamar a una función también tiene que ser diferente.

Para el caso de los procesadores X86_64 una de las reglas que define este documento es que si pasamos una estructura a una función, cada uno de los elementos de la misma se pasará en un registro, suponiendo que quepa en él. Así que, para esta arquitectura (de hecho la ABI define operaciones similares para otras arquitecturas también, pero usando los elementos específicos de cada plataforma), y para la estructura en nuestro programa de ejemplo, esa es la regla que se aplica.

Veamos que código gcc genera para el programa anterior:

0000000000001139 <main>:
    1139:   55                      push   rbp
    113a:   48 89 e5                mov    rbp,rsp
    113d:   48 83 ec 10             sub    rsp,0x10
    1141:   48 c7 45 f8 0a 00 00    mov    QWORD PTR [rbp-0x8],0xa
    1148:   00
    1149:   48 c7 45 f0 44 33 22    mov    QWORD PTR [rbp-0x10],0x11223344
    1150:   11
    1151:   48 8b 55 f0             mov    rdx,QWORD PTR [rbp-0x10]
    1155:   48 8b 45 f8             mov    rax,QWORD PTR [rbp-0x8]
    1159:   48 89 d6                mov    rsi,rdx
    115c:   48 89 c2                mov    rdx,rax
    115f:   48 8d 05 9e 0e 00 00    lea    rax,[rip+0xe9e]        # 2004 <_IO_stdin_used+0x4>
    1166:   48 89 c7                mov    rdi,rax
    1169:   b8 00 00 00 00          mov    eax,0x0
    116e:   e8 bd fe ff ff          call   1030 <printf@plt>
    1173:   b8 00 00 00 00          mov    eax,0x0
    1178:   c9                      leave
    1179:   c3                      ret

En el código podéis ver como se copia en rsi (segundo parámetro según la ABI System V) el valor de nuestro puntero (0x11223344) y en rdx (tercer parámetro según la misma ABI) el valor del tamaño. rdi (el primer parámetro de acuerdo a la ABI) se inicializa con el format string de printf que se encuentra en la dirección 0x2004 en el caso del código anterior.

PASO DE PARÁMETROS

La ABI System V para x86_64 especifica que para parámetros de tipos simples (bueno tenéis que leer el documento para la definición correcta), estos han de pasarse en los siguientes registros:

  • RDI : Parámetro 1
  • RSI : Parámetro 2
  • RDX : Parámetro 3
  • RCX : Parámetro 4
  • R8 : Parámetro 5
  • R9 : Parámetro 6
 

El compilador ha hecho un excelente trabajo implementando literalmente lo que la ABI dice en referencia a la llamada a funciones. Perfecto.

Rompamos el código

Ahora vamos a hacer una pequeña modificación y añadir un campos extra a la estructura para ver que pasa.

#include <stdio.h>

struct array_t {
  int    *p;
  size_t len;
  long   a;

};

int main () {
    struct array_t a;
    a.p = (int*)0x11223344;
    a.len = 42;
    a.a = 10;
    printf ("%p %zu %ld \n", a);
}

Si compilamos y ejecutamos obtenemos algo como esto.

$ make test02
cc     test02.c   -o test02
$ ./test02
0x7ffd5eb53668 42 140726192387344

Los números serán diferentes en vuestra máquina, son valores que se encuentran en la pila a la hora de la llamada a la función. Si ahora echamos un ojo al ensamblador:

0000000000001139 <main>:
    1139:   55                      push   rbp
    113a:   48 89 e5                mov    rbp,rsp
    113d:   48 83 ec 20             sub    rsp,0x20
    1141:   48 c7 45 e0 44 33 22    mov    QWORD PTR [rbp-0x20],0x11223344
    1148:   11
    1149:   48 c7 45 e8 2a 00 00    mov    QWORD PTR [rbp-0x18],0x2a
    1150:   00
    1151:   48 c7 45 f0 0a 00 00    mov    QWORD PTR [rbp-0x10],0xa
    1158:   00
    1159:   48 83 ec 08             sub    rsp,0x8
    115d:   48 83 ec 18             sub    rsp,0x18
    1161:   48 89 e1                mov    rcx,rsp
    1164:   48 8b 45 e0             mov    rax,QWORD PTR [rbp-0x20]
    1168:   48 8b 55 e8             mov    rdx,QWORD PTR [rbp-0x18]
    116c:   48 89 01                mov    QWORD PTR [rcx],rax
    116f:   48 89 51 08             mov    QWORD PTR [rcx+0x8],rdx
    1173:   48 8b 45 f0             mov    rax,QWORD PTR [rbp-0x10]
    1177:   48 89 41 10             mov    QWORD PTR [rcx+0x10],rax
    117b:   48 8d 05 82 0e 00 00    lea    rax,[rip+0xe82]        # 2004 <_IO_stdin_used+0x4>
    1182:   48 89 c7                mov    rdi,rax
    1185:   b8 00 00 00 00          mov    eax,0x0
    118a:   e8 a1 fe ff ff          call   1030 <printf@plt>
    118f:   48 83 c4 20             add    rsp,0x20
    1193:   b8 00 00 00 00          mov    eax,0x0
    1198:   c9                      leave
    1199:   c3                      ret

Wow la cosa se ha complicado un poquito. Veamos paso a paso que hace esto:

    1141:   mov    QWORD PTR [rbp-0x20],0x11223344 -> a.p   = rbp-0x20
    1149:   mov    QWORD PTR [rbp-0x18],0x2a       -> a.len = rbp-0x18
    1151:   mov    QWORD PTR [rbp-0x10],0xa        -> a.a   = rbp-0x10
    1159:   sub    rsp,0x8                         -> reservamos 8 bytes
    115d:   sub    rsp,0x18                        -> reservamos 0x18 bytes mas
    1161:   mov    rcx,rsp                         -> RCX apunta a la region reservada en la pila
    1164:   mov    rax,QWORD PTR [rbp-0x20]        -> RAX = a.p
    1168:   mov    rdx,QWORD PTR [rbp-0x18]        -> RDX = a.len
    116c:   mov    QWORD PTR [rcx],rax             -> [PILA] = a.p
    116f:   mov    QWORD PTR [rcx+0x8],rdx         -> [PILA + 8] = a.len
    1173:   mov    rax,QWORD PTR [rbp-0x10]        -> RAX = a/a
    1177:   mov    QWORD PTR [rcx+0x10],rax        -> [PILA + 16] = a.len
    117b:   lea    rax,[rip+0xe82]        # 2004 <_IO_stdin_used+0x4>
    1182:   mov    rdi,rax
    1185:   mov    eax,0x0
    118a:   call   1030 <printf@plt>

Es decir, el compilador a implementado al dedillo lo que dice el ABI, que ahora os reproduzco para vuestro deleite personal.. bueno, solo las partes relevantes:

The classification of aggregate (structures and arrays) and union types works as follows:

1. If the size of an object is larger than eight eightbytes, or it contains unaligned fields, it has class MEMORY

2. If a C++ object is non-trivial for the purpose of calls, as specified in the C++ ABI 16, it is passed by invisible reference (the object is replaced in the parameter list by a pointer that has class INTEGER)

3. If the size of the aggregate exceeds a single eightbyte, each is classified separately. Each eightbyte gets initialized to class NO_CLASS.

4. Each field of an object is classified recursively so that always two fields are considered. The resulting class is calculated according to the classes of the fields in the eightbyte:

    (a) If both classes are equal, this is the resulting class.

    (b) If one of the classes is NO_CLASS, the resulting class is the other class.

    (c) If one of the classes is MEMORY, the result is the MEMORY class.

    (d) If one of the classes is INTEGER, the result is the INTEGER.

    (e) If one of the classes is X87, X87UP, COMPLEX_X87 class, MEMORY is used as class.

    (f) Otherwise class SSE is used.

5. Then a post merger cleanup is done:

    (a) If one of the classes is MEMORY, the whole argument is passed in memory.

    (b) If X87UP is not preceded by X87, the whole argument is passed in memory.

    (c) If the size of the aggregate exceeds two eightbytes and the first eightbyte isn’t SSE or any other eightbyte isn’t SSEUP, the whole argument is passed in memory.

    (d) If SSEUP is not preceded by SSE or SSEUP, it is converted to SSE.

Vayamos regla por regla para ver lo que pasa:

  • Regla 1, si la estructura ocupa más de 8 longs (8 bytes eightbytes) va directamente a memoria, pero este no es nuestro caso, la estructura ocupa 3 longs
  • Regla 2 es para C++, así que no aplica. La saltamos
  • Regla 3. Si el tamaño de la estructura es de mas de un long, cada elemento de la estructura se inicializa a NO_CLASS (a.p [NO_CLASS], a.len [NO_CLASS], a.a [NO_CLASS])
  • Regla 4. Ahora cada elemento de la estructura asignando, de forma que por cada campo tenemos dos classes, en este caso, los campos individuales se clasifican como INTEGER : (a.p [NO_CLASS|INTEGER], a.len [NO_CLASS|INTEGER], a.a [NO_CLASS|INTEGER])… esto se especifica un poco antes en el ABI, pero no voy a copiarla toda aquí. En nuestro caso, tras aplicar la regla cuatro, el resultado sería a.p [INTEGER], a.len [INTEGER], a.a [INTEGER]
  • Regla 5. Caemos en el caso (c), el tamaño de la estrucura excede 16 bytes y el primer elemento de la estructura no tiene clase SSE así que la estructura se pasa en memoria, es decir, en la pila.

Comparad esto con el caso anterior. Todo es igual, excepto que en la regla 5, el tamaño es de 16 bytes y por lo tanto la clase asociada a cada campo no se modifica y los valores se pasan en registros.

Eso es lo que dice el ABI. Así que rsi que debería contener el segundo parámetro no se inicializa, puesto que se supone que la función que va a recibir esta estructura como parámetro sabe que la va a recibir en la pila (de ahí la importancia de los prototipos de las funciones en C). Pero printf no espera recibir nada en la pila ya que espera tipos básicos y por lo tanto toma el valor que sea que tengan rsi, rdx y rcx y los imprime.

Visteis que el segundo parámetro a.len=42 si se imprime correctamente?. Si, esto es ya de chiripa. Como podéis ver en el ensamblador, el valor a.len se lee primero en el registro rdx antes de moverlo en la pila. Puesto que el registro no se vuelve a usar, cuando llamamos a printf ese valor (el tercer parámetro) es correcto, pero de casualidad. Si el compilador decide usar otros registros para cargar los datos en memoria el resultado sería otro.

Una nota final. Si habéis leído atentamente las reglas del ABI, veréis que el ABI considera los arrays como tipos agregados, y deberían ser manejados de la misma forma. Sin embargo, no he conseguido reproducir ese caso, al parecer, gcc siempre pasa los arrays como un puntero (lo cual tiene todo el sentido del mundo) y por lo tanto eso se considera de clase INTEGER y es el puntero lo que es pasado en el registro en la llamada a la función y no los valores a los que apunta. Sería interesante investigar esto un poco más.

HASTA LA PRÓXIMA

Un nuevo misterio resuelto por Carolyn. De nada chavales. Espero que os haya resultado interesantes y que ahora sepáis un poco mejor como funcionan los programas por dentro. Nos vemos en el próximo misterio.


SOBRE Carolyn Lightrun
Carolyn posee una sensibilidad especial para entran en contacto con el más allá. Cuando programa, las cosas más extrañas ocurren y Carolyn ha dedicado su carrera a investigar estos inquietantes fenómenos y encontrar la explicación lógica tras ellos.

 
Tu publicidad aquí :)