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.
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 3longs - 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 aNO_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íaa.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
SSEasí 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.
■
