En el número 5 de la primera época de Occam's Razor escribimos un artículo sobre como hacer nuestros programas mú pequeñitos. Conseguimos reducir el típico Hola Mundo a unos pocos Kilobytes... Pero todavía podemos hacerlo mejor!. Sigue leyendo para descubrir todos los secretos de los CiberJívaros!
Y por qué? os preguntaréis. Lo primero, porque mola. Lo segundo, porque mola conocer todos los detalles de como funcionan los programas que ejecuta nuestro ordenador... Y lo tercero, porque, a veces, solo algunas veces, necesitamos hacer que nuestro programa sea muy pequeño para que quepa en algún sistema con muy poca memoria o escaso espacio libre de almacenamiento... léase routers, y otras alimañas del Internet de las Cosillas.
Sin más preámbulos vamos al temita.
Destripando
Si observamos nuestro programa con detenimiento, lo único que hacemos es llamar a la función Reduciendo
Ahora estamos en condiciones de escribir una versión mínima de
Nuestra Referencia
Para comenzar este nuevo viaje hacia lo minúsculo, debemos comenzar en algún punto. Y ese punto va a ser, más o menos, donde lo dejamos la última vez. Nuestro Hola Mundo estático condietlibc
(Listado 1).
$ cat <<EOM > hola.c #include <stdio.h> int main (void) {printf ("Hola Mundo!\n"); return 0;} EOM $ diet gcc -Os -o hola-ref hola.c $ ls -lh hola-ref | awk '{print $5,$9}' 7.1K hola-ref
Listado 1. Programa de referencia
7 K para un binario estático no está nada mal. Si recordáis conseguimos reducirlo aún más, pero para este artículo vamos a comenzar desde este punto.
Evidentemente, imprimir un mensaje en la consola no require 7K de código, y nosotros no estamos añadiendo nada, por lo tanto, podemos concluir que el compilador está añadiendo un mogollón de código al programa. Lo que vamos a ver en este artículo es como ir eliminando todo ese código que añade el compilador hasta reducir nuestro programa de referencia a su mínima expresión.
Destripando printf
Si observamos nuestro programa con detenimiento, lo único que hacemos es llamar a la función printf
. En otras palabras, no tenemos mucho donde rascar, así que vamos a ver que hace esta función (Cuadro 1).
$ objdump -d hola-ref | grep -A 5 "<main>:" 0000000000400144 <main>: 400144: 50 push %rax 400145: bf 4a 05 40 00 mov $0x40054a,%edi 40014a: e8 1b 03 00 00 callq 40046a <puts> 40014f: 31 c0 xor %eax,%eax 400151: 5a pop %rdx
Cuadro 1. Desensamblado de la función main
Bueno, vemos que realmente, estamos llamando a la función puts
, que también es una función de la librería estándar. En realidad, el compilador ha detectado que nuestra cadena es estática (no contiene cadenas de formato) y en lugar de ejecutar printf
que trataría de interpretar la cadena en busca de cadenas como %d o %s para imprimir valores, llama directamente a la función puts
que no hace nada de eso y se va a ejecutar mucho más rápido.
Bien, veamos pues que hace puts
(Cuadro 2):
$ objdump -d hola-ref | grep -A 24 "<puts>:" 000000000040046a <puts>: 40046a: 53 push %rbx 40046b: 48 83 cb ff or $0xffffffffffffffff,%rbx 40046f: 31 c0 xor %eax,%eax 400471: 48 89 d9 mov %rbx,%rcx 400474: 48 89 fa mov %rdi,%rdx 400477: f2 ae repnz scas %es:(%rdi),%al 400479: 48 89 d7 mov %rdx,%rdi 40047c: 48 f7 d1 not %rcx 40047f: 48 8d 34 19 lea (%rcx,%rbx,1),%rsi 400483: e8 c3 ff ff ff callq 40044b <__stdio_outs> 400488: 85 c0 test %eax,%eax 40048a: 74 1b je 4004a7 <puts+0x3d> 40048c: be 01 00 00 00 mov $0x1,%esi 400491: bf 77 05 40 00 mov $0x400577,%edi 400496: e8 b0 ff ff ff callq 40044b <__stdio_outs> 40049b: 85 c0 test %eax,%eax 40049d: 0f 94 c0 sete %al 4004a0: 0f b6 c0 movzbl %al,%eax 4004a3: f7 d8 neg %eax 4004a5: eb 02 jmp 4004a9 <puts+0x3f> 4004a7: 89 d8 mov %ebx,%eax 4004a9: 5b pop %rbx 4004aa: c3 retq
Cuadro 2. Desensamblado de la función puts
Así, a bote pronto, y sin entrar en todos los detalles, la función primero calcula la longitud de la cadena (eso es el repnz scas
hacia el principio) y luego llama a la función __stdio_outs
para imprimir la cadena. La segunda llamada a __stdio_outs
imprime un retorno de carro... Sí, puts
añade un retorno de carro automáticamente a la cadena a imprimir.
Ahora ya solo nos queda ver que hace la función __stdio_outs
(Cuadro 3).
$ objdump -d hola-ref | grep -A 24 "<__stdio_outs>:" 000000000040044b <__stdio_outs>: 40044b: 53 push %rbx 40044c: 48 89 f2 mov %rsi,%rdx 40044f: 48 89 f3 mov %rsi,%rbx 400452: 48 89 fe mov %rdi,%rsi 400455: bf 01 00 00 00 mov $0x1,%edi 40045a: e8 61 00 00 00 callq 4004c0 <__libc_write> 40045f: 48 39 d8 cmp %rbx,%rax 400462: 0f 94 c0 sete %al 400465: 0f b6 c0 movzbl %al,%eax 400468: 5b pop %rbx 400469: c3 retq
Cuadro 3. Desensamblado de la función __stdio_outs
Después de mover un poco los parámetros que recibe, llama a la función __libc_write
. Bien... veamos que hace esta función (Cuadro 4):
$ objdump -d hola-ref | grep -A 24 "<__libc_write>:" 00000000004004c0 <__libc_write>: 4004c0: b0 01 mov $0x1,%al 4004c2: e9 ad fc ff ff jmpq 400174 <__unified_syscall>
Cuadro 4. Desensamblado de la función __libc_write
Vale. Parece que nos estamos acercamos.... una más! (Cuadro 5)
$ objdump -d hola-ref | grep -A 24 "<__unified_syscall>:" 0000000000400174 <__unified_syscall>: 400174: b4 00 mov $0x0,%ah 0000000000400176 <__unified_syscall_16bit>: 400176: 0f b7 c0 movzwl %ax,%eax 400179: 49 89 ca mov %rcx,%r10 40017c: 0f 05 syscall 000000000040017e <__error_unified_syscall>: 40017e: 48 3d 7c ff ff ff cmp $0xffffffffffffff7c,%rax 400184: 76 0f jbe 400195 <__you_tried_to_link_a_dietlibc_object_against_glibc> 400186: f7 d8 neg %eax 400188: 50 push %rax 400189: e8 08 00 00 00 callq 400196 <__errno_location> 40018e: 59 pop %rcx 40018f: 89 08 mov %ecx,(%rax) 400191: 48 83 c8 ff or $0xffffffffffffffff,%rax 0000000000400195 <__you_tried_to_link_a_dietlibc_object_against_glibc>: 400195: c3 retq 0000000000400196 <__errno_location>: 400196: b8 18 10 60 00 mov $0x601018,%eax 40019b: c3 retq
Cuadro 5. Desensamblado de la función __unified_syscall
Bien!. Al fin llegamos a algún lado. Como podéis ver en el segundo bloque encontramos una instrucción syscall
. Esta es la forma de llamar al kernel, de ejecutar una syscall
en un sistema Linux x86 de 64bits. Quizás recordéis la infame int 0x80
utilizada en los sistema de 32bits... bueno, pues esto es lo mismo.
Tras la llamada al sistema, nos encontramos un pequeño fragmento de código que comprueba errores y actualiza la variable errno
con el código de error pertinente. Bien, si ahora volvemos hacia atrás y vamos mapeando los distintos valores de los registros, veremos que lo que realmente estamos haciendo es llamando a la syscall
WRITE
en la salida estándar (stdout
, descriptor de fichero 1) con la cadena que pasamos como parámetro a la función puts
y la longitud de esa cadena, la cual calculamos al principio de todo. Dejamos como ejercicio para el lector este interesante proceso de reconstrucción inversa :).
Reduciendo puts
a la mínima expresión
Ahora estamos en condiciones de escribir una versión mínima de puts
. Tened en cuenta que todo el código que hemos visto en la sección anterior, está ahí por una buena razón, y en el caso general es necesario. Sabiendo que es lo que hace ese código, y siendo conscientes de las necesidades de nuestro caso partícular, nos podemos plantear hacer lo que vamos a hacer a continuación, pero no creáis que esto se puede hacer siempre.
Bien, con todo lo que hemos contado hasta el momento, podríamos pasar de puts
y utilizar directamente la llamada al sistema write
, con la longitud de la cadena pre-calculada. De esta forma nos ahorramos el código para calcular la longitud de la cadena (un strlen
de toda la vida). Si queréis añadir ese código vosotros mismos y crear vuestra propia versión de puts
... bueno, es un ejercicio interesante y os animamos a hacerlo.
Como hemos comentado, en lugar de puts
, vamos a utilizar write
para intentar reducir el tamaño del programa. Utilizando C, el programa quedaría más o menos así (Listado 2):
#include <unistd.h> int main (void) { write (1, "Hola Mundo\n", 13); return 0; }
Listado 2. Nuestro HOLA MUNDO sin printf
La función write
que estamos utilizando en este programa todavía pertenece a la librería estándar. Si no me creéis... bueno, ahora ya deberíais saber como comprobarlo :) (objdump
y grep
son tus amigos :).
Pero, para reducir puts
a la mínima expresión tenemos que ir un paso más allá. Tenemos que implementar nuestra propia llamada a write
... y esto lo haremos en ensamblador!!!!. Vamos a generar un fichero llamado write.s
y escribir el siguiente programa en ensamblador (Listado 3):
.text .global _write _write: mov $1, %rax syscall ret
Listado 3. Implementación de write en ensamblador para x86_64
Sí, así de fácil. Solo necesitamos poner en el registro rax
el número de la llamada al sistema. La razón es que los parámetros que pasamos a una función C en un programa de 64bits se almacenan en registros, y da la casualidad de que son los mismos registros que tenemos que utilizar para las llamadas al sistema... así que simplemente no los tocamos y ya está.
Para poder utilizar esta función tendremos que cambiar el write
de nuestro programa C, por _write
, el nombre que hemos utilizado en nuestro código ensamblador.
Ahora ya podemos compilar nuestro nuevo Hola Mundo que, en teoría, no utiliza la librería C estándar.
$ gcc -o hola-write hola-write.c write.sGenial!... bueno, más o menos. Si comprobáis el tamaño del ejecutable que hemos obtenido sigue siendo de unos 7K.... Algo más tendremos que hacer.
Casi Todo es Mentira
Te habrán contando muchas veces que la funciónmain
es lo primero que se ejecuta. Para la mayoría de los mortales (léase programadores de aplicaciones), dicha frase puede considerarse correcta... pero la verdad es que NO LO ES!.
No me creéis?. Pos vamos a comprobarlo:
$ readelf -h hola-ref | grep Entry Entry point address: 0x400153 $ objdump -d hola-ref | grep "<main>" 0000000000400144 <main>: 4003b1: e8 8e fd ff ff callq 400144 <main>
Cuadro 6. Dirección de la función main y punto de entrada del program!
WoW... El punto de entrada del programa (0x400153
en mi caso, puede ser diferente en vuestro sistema) no se corresponde con la función main
, la cual, en mi caso, está en 0x4003b1
.
Entonces que es lo primero que ejecuta el programa?
$ objdump -d hola-ref | grep "0*400153" 0000000000400153 <_start>: 400153: 5f pop %rdi
Cuadro 7. El punto de entrada es la función _start!
Bueno, antes de continuar, y por si alguien no está familiarizado con readelf
y objdump
, ahí va una pequeña explicación. El flag -h
de readelf
le indica al programa que solo debe mostrar la cabecera ELF del ejecutable, la cual contiene el auto-denominado punto de entrada... vamos, la posición en memoria donde el programa comenzará su ejecución.
Por otra parte, el flag -d
de objdump
nos permite desensamblar el binario que pasamos como parámetro. objdump
, como parte de su salida, mostrará los símbolos que pueda encontrar en el binario. Como no hemos stripeado el binario, los nombre de las funciones están ahí, y por eso podemos encontrar la función main
con grep
.
Así que sí. Lo primero que se ejecutar al lanzar un programa no es la función main
, sino una función llamada _start
que el compilador añade de porque sí durante el proceso de compilación. Bueno, realmente es necesaria, pero no vamos a hablar de esos en este artículo.
Pos vale... vamos a pasar de main
y escribir nuestra propia función _start
(Listado 4).
int _write (int, void *, int); int _start (void) { _write (1, "Hola Mundo!\n", 13); return 0; }
Listado 4. Moviendo nuestro código a nuestra propia función _start
Ahí está. Como podéis ver también hemos eliminado el include y añadido sólamente el prototipo de _write
. Compilemos y a ver que pasa:
$ gcc -static -o hola-start hola-start.c write.s
Sí... un mogollón de errores. A ver como lo solucionamos. Fijémonos en tres de ellos:$ gcc -static -o hola-start hola-start.c write.s (...) /tmp/ccNDWG8J.o: In function `_start': hola-start.c:(.text+0x0): multiple definition of `_start' (...) /usr/lib/gcc/x86_64-linux-gnu/4.8/../../../x86_64-linux-gnu/crt1.o:/build/eglibc-MjiXCM/eglibc-2.19/csu/../sysdeps/x86_64/start.S:118: first defined here /usr/lib/gcc/x86_64-linux-gnu/4.8/../../../x86_64-linux-gnu/crt1.o: In function `_start': (.text+0x20): undefined reference to `main' (...)
Cuadro 8. Errores de compilación con nuestra función _start
Bueno, parece que estamos redefiniendo _start
. O dicho de otra forma, el compilador sigue añadiendo el código de _start
como siempre y al encontrar nuestra redefinición la cosa peta de mala manera. Además, ese código añadido, todavía espera encontrar una función llamada main
.
Deshaciéndonos de la librería estándar
Bien, para poder solucionar este problema, tenemos que decirle al compilador que no queremos que añada su función_start
... que ya nos ocupamos nosotros. Esto lo podemos hacer utilizando el flag -nostartfiles
... el nombre es bastante obvio :
$ gcc -nostartfiles -static -o hola-start hola-start.c write.sParece que le ha gustado. Veamos como vamos :).
$ ls -lh hola-start | awk '{print $5,$9}' 5.5K hola-startUna mejora al fin. Veamos si el programa sigue funcionando:
$ ./hola-start Hola Mundo! Segmentation faultVaya, funciona pero se estrella al terminar.
Terminando correctamente
Bien, lo que pasa es que_start
hace algunas cosas y si nos la cargamos tan alegremente pues... el programa tiene problemas para terminar. Podéis echarle un ojo al ensamblador para sacar vuestras propias conclusiones, pero lo que pasa es que necesitamos llamar a la llamada del sistema EXIT
para terminar el programa. Cuando usamos la librería estándar, el código que añade el compilador se encarga de todo, y el return
de main
, de hecho, está retornando a _start
que llama a exit
eventualmente.
Así que lo que tenemos que hacer es ejecutar la llamada al sistema EXIT
cuando nuestro programa termine. Manos a la obra. Vamos a añadir una implementación de exit
en nuestro fichero en ensamblador que ahora llamaremos sistema.s
... llamarlo write
no tiene mucho sentido any more (Listado 5).
.text .global _write, __exit _write: mov $1, %rax syscall ret __exit: mov $0x3c, %rax syscall ret
Listado 5. Añadiendo la función _exit para terminar el proceso
Como imaginaréis, el número que identifica la llamada EXIT
es el 0x3c
. También tendremos que añadir la llamada en nuestro programa principal que quedará como (Listado 6):
int _write (int, void *, int); int __exit (int); int _start (void) { _write (1, "Hola Mundo!\n", 13); __exit (0); }
Listado 6. Nuestro nuevo hola mundo en C
Con todo esto:
$ gcc -nostartfiles -static -o hola-sys hola-sys.c sistema.s $ ./hola-sys Hola Mundo! $ ls -lh hola-sys | awk '{print $5,$9}' 1.5K hola-sys
Cuadro 9. Al fin nuestro hemos conseguido reducir el programa
Güay!... 1.5 Kbytes. Esto ya es una cosa más razonable. Si, además lo pasamos por strip -s
nuestro hola mundo se queda en algo menos de 1Kbyte. Y si usamos sstrip
el binario se nos queda en unos 400 bytes!.
Y hasta aquí podemos leer. Si ejecutamos objdump
sobre el binario ya solo obtendríamos algo tal que así (Cuadro 10):
$ objdump -d hola-sys hola-sys: file format elf64-x86-64 Disassembly of section .text: 000000000040010c <_start>: 40010c: 55 push %rbp 40010d: 48 89 e5 mov %rsp,%rbp 400110: ba 0d 00 00 00 mov $0xd,%edx 400115: be 44 01 40 00 mov $0x400144,%esi 40011a: bf 01 00 00 00 mov $0x1,%edi 40011f: e8 0c 00 00 00 callq 400130 <_write> 400124: bf 00 00 00 00 mov $0x0,%edi 400129: e8 0c 00 00 00 callq 40013a <__exit> 40012e: 5d pop %rbp 40012f: c3 retq 0000000000400130 <_write>: 400130: 48 c7 c0 01 00 00 00 mov $0x1,%rax 400137: 0f 05 syscall 400139: c3 retq 000000000040013a <__exit>: 40013a: 48 c7 c0 3c 00 00 00 mov $0x3c,%rax 400141: 0f 05 syscall 400143: c3 retq
Cuadro 10. Y este es todo el código que ahora contiene nuestro programa
Que es, prácticamente, todo el código que hemos escrito...
Todavía Más?
Sí... todavía podemos ir un poco más lejos, pero ahora ya rozando los límites de lo razonable :). No podemos reducir mucho más el código en sí, pero un ejecutable es algo más que código. Contiene información adicional que el sistema utiliza para cargar el programa en memoria y ejecutarlo. Así que, es posible reducir esa información también al mínimo. No vamos a profundizar mucho en esa cuestión. Primero, porque este artículo ya es un poco largo, y segundo por que no lo vamos a hacer mejor que en este enlace. A modo de resumen, lo que vamos a hacer es generar el fichero ejecutable byte a byte. O en otras palabras vamos a generar la estructura del fichero ELF y de esta forma reducirla a la mínima expresión. El artículo que mencionamos más arriba se centra en binarios de 32bits. Puesto que todo este artículo se ha centrado en los 64bits, aquí tenéis la versión del hola mundo reducida a su mínima expresión para esta arquitectura. El programa lo adaptó a los 64 bits otra persona que está encantada de que se difunda (Listado 7).BITS 64 org 0x400000 ehdr: ; Elf64_Ehdr db 0x7F, "ELF", 2, 1, 1, 0 ; e_ident times 8 db 0 dw 2 ; e_type dw 0x3e ; e_machine dd 1 ; e_version dq _start ; e_entry dq phdr - $$ ; e_phoff dq 0 ; e_shoff dd 0 ; e_flags dw ehdrsize ; e_ehsize dw phdrsize ; e_phentsize dw 1 ; e_phnum dw 0 ; e_shentsize dw 0 ; e_shnum dw 0 ; e_shstrndx ehdrsize equ $ - ehdr phdr: ; Elf64_Phdr dd 1 ; p_type dd 5 ; p_offset dq 0 dq $$ ; p_vaddr dq $$ ; p_paddr dq filesize ; p_filesz dq filesize ; p_memsz dq 0x1000 ; p_align phdrsize equ $ - phdr _start: mov rax, 1 mov rdi, 1 mov rsi, msg mov rdx, 14 syscall mov rax, 60 mov rdi, 0 syscall msg db "Hola Mundo!",0x0a key db 0 filesize equ $ - $$
Listado 7. Hola mundo construyendo el fichero ELF a mano
Al final del código podéis identificar fácilmente todo el ensamblador que hemos escrito a lo largo de este artículo. La principal diferencia es que la cadena a imprimir se almacena en el segmento de código (.text
.rodata). La parte inicial del fichero es la cabecera y el segmento para el código (lo que en lenguaje Élfico se conoce como Program Header). Los ELFs de 64bits tienen una estructura un poco diferente a los de 32bits, en resumen, algunos campos son de 64bits (de ahí los dq
Define QuadWord). Siguiendo las definiciones en el fichero elf.h
, que deberíais tener en vuestro sistema, el proceso es bastante directo.
El código utiliza las directivas del ensamblador NASM, así que tendréis que utilizarlo para generar el programa. Además tendremos que decirle a NASM que genere un fichero exactamente con el contenido de nuestro fichero fuente en lugar de un fichero ELF (ya hemos puesto nosotros las cosas para que el resultado sea un fichero ELF :). Esto lo conseguimos de la siguiente forma:
$ nasm -f bin -o hola64 hola64.asm $ chmod +x hola64 172 hola64
Cuadro 11. Generando el binario a partir del ELF creado manualmente
172 bytes!!!! WOW!!!!
Portabilidad
Bueno, ahora que hemos recorrido todo el camino, es el momento de hablar un poco de las desventajas de todo este proceso y de porqué solo deberías hacer este tipo de cosas si no te queda otro remedio... y creedme, a veces eso pasa. La primera de las razones es que, no importa lo buenos que creáis que sois. La librería estándar lleva ahí muchísimos años, y muchísima gente muy lista se ha encargado de que sea lo mejor posible. Quizás podáis mejorar una pequeña parte para un caso muy concreto, pero, en general, y sin ofender, nuestro código va a ser peor. La segunda de las razones es que, en cuanto empezamos a escribir ensamblador nuestro programa deja de ser portable. Ya no es posible simplemente recompilar el programa para otra plataforma. Todo el código en ensamblador debe ser re-escrito. Y para muestra un botón. Vamos a portar nuestro minúsculo hola mundo a una plataforma ARM. El programa C se quedaría igual, pero el ensamblador hay que cambiarlo como ya os comentamos. Sería algo así (Listado 8):.text .globl _write, _exit _write: mov r7, #4 swi #0 __exit: mov r7, #1 swi #0
Listado 8. Nuestra mini librería C estándard para ARM
Compilamos con un comando como este:
$ arm-linux-gnueabihf-gcc -static -nostartfiles -marm -o hola-arm hola-sys.c sistema-arm.s $ ls -lh hola-arm | awk '{print $5,$9}' 1.3K hola-arm
Cuadro 12. Compilando para ARM
Bien, algunos comentarios:
- Lo primero es que los números que identifican las llamadas al sistema son diferentes
- También lo son los registros. Por ejemplo, en lugar de usar
rax
(que no existe en ARM), debemos usarr7
. - Si no sabes que es
arm-linux-gnueabihf-gcc
. Léete esto. -
Por alguna razón, cuando mezclamos C y ensamblador con ARM tenemos que utilizar el flag
-marm
para generar código de 32bits. De lo contrario el programa C generará código de 32bits y el ensamblador generará código Thumb (16 bits)... y en ese caso las cosas no funcionan sin más. Para ver lo que esto significa, ejecutadobjdump
en el binario y mirar el código máquina generado por cada instrucción... todas son de 32 bits!. - El binario final ocupa unos 1.3Kbytes y tras ejecutar strip se queda en 712bytes... No está mal