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-start
Una 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
-marmpara 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, ejecutadobjdumpen 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
