-[ 0x02]--------------------------------------------------------------------- -[ Tutorial de ensamblador AT&T para microprocesadores x86]------------------ -[ by Cemendil ]-----------------------------------------------------SET-29-- TUTORIAL DE ENSAMBLADOR AT&T PARA MICROPROCESADORES x86 Autor : Cemendil Fecha : 29/10/2003 Version : 0.91 ---------------------------| Contenidos. |--------------------------- Prefacio. Introduccion. Licencia. Herramientas. Parte 1 : Ensamblador en formato AT&T. 1.1 Un codigo de ejemplo. 1.2 Reglas generales. 1.3 Algunas directivas interesantes. 1.3.1. Directivas de almacenamiento de datos. 1.3.2. Directivas de alineamiento. 1.3.3. Otras directivas importantes. 1.4 Usando el preprocesador. Parte 2 : Ensamblando subrutinas. 2.1 Normas de llamada a subrutinas. 2.1.1. Como llama el compilador de C a una subrutina. 2.1.2. Como se comporta una subrutina en C. 2.2 Subrutinas 'canonicas'. 2.3 Un ejemplo de subrutina 'canonica'. 2.4 Subrutinas sin 'frame pointer'. Parte 3 : Ensamblando 'inline'. 3.1 El metodo Frankenstein. 3.1.1 Insercion de ensamblador. 3.1.2 Injertos monoliticos. 3.1.3 Un ejemplo retorcido. 3.2 Introduccion al metodo ortodoxo. 3.3 Implementando el metodo ortodoxo. 3.3.1 Simbolos solapados. 3.3.2 Formatos mas comunes. 3.3.3 Ejemplos de formatos de la CPU. 3.3.4 Ejemplos de formatos de la FPU. 3.3.5 Registros corruptos. 3.4 Que se nos queda en el tintero. Referencias. -----------------------------| Prefacio. |--------------------------- El presente tutorial explica los fundamentos del ensamblador en formato AT&T (el mas empleado en maquinas tipo UNIX), para micros ix86 en modo de 32 bits. Se expondran los fundamentos de este formato (parte 1), como programar subrutinas compatibles con los compiladores de C -- especialmente el gcc -- (parte 2), y como intercalar codigo ensamblador en el interior de un programa en C de manera que gcc sea capaz de compilarlo de manera optima (parte 3). Esta ultima parte es especialmente complicada, puesto que gcc requiere mucha informacion para poder admitir el ensamblador dentro del codigo en C. Basta pensarlo un momento: si nuestro fragmento de ensamblador modifica el registro %ebx, el compilador debe saberlo. De otro modo podria ocurrir que una variable tipo 'register' almace- nada en %ebx por el compilador fuese corrompida al ejecutarse el fragmento de ensamblador, con resultados posiblemente tragicos. Si por el contrario el compilador esta al tanto, el mismo se encargara de hacer el baile de registros necesario para que nada se vaya al garete. Para entender este tutorial necesitaras ciertos conocimientos previos de ensamblador. No es mi intencion hacer un cursillo de introduccion al ensamblador, si no explicar como sacar partido a la potencia del ensamblado con gcc. El que el tutorial tenga mas de 100Kb no debe hacerte pensar que el tema es muy largo o complicado; he escrito este documento con abundancia de ejemplos y procurando ser lo mas redundante posible. A menudo un tema se introduce informalmente, luego se repite de manera formal y finalmente se estudia un ejemplo elaborado. Esto hace que el documento sea muy grande, aunque espero que sea tambien mucho mas instructivo. Realmente es necesario que haya mas gente dispuesta a programar en ensamblador de una manera moderna. Los viejos tiempos, en que los programas se hacian 100% en ensamblador, han pasado, pero eso no quiere decir que tengamos que programar en Visual Basic. Creo que un tutorial como este es necesario, dado que es bastante complicado encontrar documentacion en nuestra lengua sobre ensamblado en UNIX. Por mi experiencia en foros, de vez en cuando aparece alguien totalmente confundido por esos garabatos que vomita UNIX cuando las cosas se tuercen. Bueno, si estas en ese grupo, espero que en este articulo encuentres un alivio a tu confusion. Si tienes experiencia en estos temas, quizas las partes avanzadas del tutorial (sobre todo la Parte 3) te sirvan como minimo de referencia. --------------------------| Introduccion. |-------------------------- Es muy posible que estes familiarizado con el lenguaje ensambla- dor para microprocesadores x86, y que hayas escrito algunos progra- millas usando la notacion Intel, la mas comun para estos menesteres, de la que el siguiente fragmento de codigo es un ejemplo: or [bx], bp add di, ax or dh, [bx+si] add [301ah], di add [si+0ah], bp xor al, [bx+di] int 3 Lo que haga este codigo es irrelevante. Lo importante es el for- mato del codigo: estamos ante ensamblador tipo Intel en 16 bits. Este formato es el que obtendras usando la opcion 'u' del debug de MS-DOS, o cualquier desensamblador generico para Windows. Pero si has estado hozando un poco en UNIX, es posible que te hayas encontrado con desensamblados como el siguiente (usa, por ejemplo, 'gcc -S' con cualquier programa en C, y abre el fichero con extension .s que aparece tras la compilacion): addl $-8,%esp movl -8(%ebp),%eax pushl %eax movzwl -10(%ebp),%eax movl 8(%ebp),%edx movsbl (%eax,%edx),%eax pushl %eax call _putc addl $16,%esp Las diferencias respecto al codigo anterior son claras: por un lado, aparecen simbolos curiosos como '%' y '$'. Ademas, instrucciones familiares como 'add' y 'push' adquieren un sufijo, como 'addl' y 'pushl'. Por otro lado, el codigo es de 32 bits, como demuestra el uso de registros extendidos (%eax, %edx, etc.). El hecho de que el ensamblador sea de 32 bits no es importante. Naturalmente, puedes programar en 32 bits con formato Intel, por ejemplo con nasm. De lo que nos ocuparemos sera de explicar las primeras diferencias, las referentes a la notacion. Algunos hackers de bastante prestigio detestan el formato AT&T: basta ver la muestra de arriba para darse cuenta de que hay que escribir mucho mas que con formato Intel, con todos esos '%', '$' y sufijos. Sin embargo, la cosa es como el eterno debate entre vi y emacs: puede que el formato AT&T sea poco amigable, pero si te acostumbras acaba siendo casi irreemplazable. Yo emepece programando en formato Intel, pero una vez que me hice a programar a la AT&T, ya casi no puedo ver el ensamblado en modo Intel. Como no es nuestra tarea iniciar 'holy wars' por motivos tan irrelevantes (aunque nos encante ;) sera mejor que juzgues por ti mismo. En ultima instancia, el formato AT&T es inevitable en ciertas ocasiones, por ejemplo al usar gdb. Si no tienes experiencia en el tema tratado por este tutorial, mi consejo es que lo leas con una consola abierta, dispuesto a compilar y experimentar con cada ejemplo que se propone. Es dificil captar muchos aspectos de estas cuestiones sin dedicar un tiempo a la practica. Un documento tan largo, y que pretende cubrir un area tan extensa, necesariamente tiene deficiencias. Si observas alguna, por favor enviame un mail. Tambien puedes editar el documento libremente (consulta la licencia). -----------------------------| Licencia. |--------------------------- Eres invitado a copiar, distribuir y modificar libremente este documento, con las siguientes restricciones: i) En caso de modificacion: o bien eliminaras toda referencia a mi (Cemendil) del documento, o bien indicaras en lugar bien visible quien eres y que cambios has introducido. ii) El autor no se hace responsable de cualquier contingencia derivada del uso, correcto o incorrecto, de este documento. ---------------------------| Herramientas. |------------------------- Lo unico necesario para ensamblar a gusto en el formato AT&T es el ensmablador GNU, el gas. Este viene con todas las distribu- ciones de UNIX libres, de modo que no tendras problemas en este sentido. Para moverte en las partes 2 y 3 necesitaras tambien el compilador gcc; de nuevo, en UNIX no hay problema. Para conseguirte las ultimas versiones de gas o gcc, visita www.gnu.org y dirigete a un mirror ftp que contenga las distribu- ciones en codigo fuente (ftp.aeneas.mit.edu, en /pub/gnu/, por ejemplo). Para conseguir gas, descargate las 'binutils'. La ultima version en fecha de escribir esto es la 2.6. Para conseguir 'gcc', vete al directorio de ese nombre. La ultima version disponible es la 3.3. Ten en cuenta que si quieres montarte toda la suite de compilacion tambien necesitaras el linkador 'ld'. Ahora, si tienes Windows tambien puedes disfrutar del ensamblado en este formato. Para ello, consiguete el gcc para Windows. Yo conozco dos distribuciones, el djgpp (que mas bien es para MS-DOS, al menos la ultima vez que lo use), y el Bloodshed Dev-C++, que tiene una GUI incorporada y es bastante agradable de usar. Cualquiera de ellos te lo puedes conseguir navegando un poco por la red. Ten en cuenta que el Visual C de Microsoft trabaja solo en formato Intel. De todos modos, el Visual C cuesta un ojo de la cara, asi que si no quieres caer en los oscuros abismos de la pirateria, es bueno saber que tienes esas alternativas que, si no son tan versatiles como el VC, al menos te permiten salvar la candidez de tu alma :). Hace bastante tiempo vi por la red un programa llamado 'gas2masm'; el programa traducia automaticamente entre ensamblador Intel y AT&T. Si no recuerdo mal, venia una copia con el codigo fuente del Quake. Una herramienta semejante, que debe andar por algun lado en la red, puede resultarte muy util para hacer comparaciones. El programa es sencillo, de modo que puedes imaginarte que las diferencias entre los dos formatos son bastante faciles de aprender. --------------| PARTE 1 : Ensamblador en formato AT&T |-------------- En esta parte nos ocuparemos de la estructura del ensamblador en formato AT&T, y de como programar con gas. Ten en cuenta que esto es un tutorial, no un 'white paper'. Los conceptos del ensamblador estaran mezclados con el uso de gas: por ejemplo, '.p2align' es una directiva de gas que no tiene por que ser reconocida por cualquier otro ensamblador en formato AT&T. En fin, dominando gas y usando un poco el sentido comun es posible adaptarse a cualquier otro ensamblador de ese mismo genero (en el dudoso caso de que te encuentres con uno que no sea gas). ---| 1.1 Un codigo de ejemplo. Lo mejor para familiarizarse con la situacion es trabajar sobre un ejemplo que podamos comprender. Nuestro ejemplo sera la siguiente subrutina (no empleamos un programa principal para ahorrarnos, por ahora, problemas con los valores pasados y devueltos): /* Subrutina hola mundo. */ #include static char msg[] = "Hola, mundo!"; void saluda(void) { register short i = 0; while (++i < 10) if (i == 5) puts(msg); } Supongamos que tenemos este codigo fuente en el fichero 'hola.c'. En estas condiciones, solo tenemos que hacer desde una shell: demeter# gcc -S hola.c demeter# ls hola.c hola.s Si ahora editamos hola.s, obtenemos: .file "test.c" gcc2_compiled.: ___gnu_compiled_c: .data _msg: .ascii "Hola, mundo!\0" .text .align 4 .globl _saluda .def _saluda; .scl 2; .type 32; .endef _saluda: pushl %ebp movl %esp,%ebp subl $20,%esp pushl %ebx xorl %ebx,%ebx .p2align 4,,7 L3: incw %bx cmpw $9,%bx jle L5 jmp L4 .p2align 4,,7 L5: cmpw $5,%bx jne L6 addl $-12,%esp pushl $_msg call _puts addl $16,%esp L6: jmp L3 .p2align 4,,7 L4: L2: movl -24(%ebp),%ebx leave ret .def _puts; .scl 2; .type 32; .endef Bien, aunque esto parece muy complicado, en realidad tenemos tan solo cuatro tipos de comandos genericos: 1) Directivas como .p2align , .align , .file , .text, etc. 2) Etiquetas como L2: , _msg: , _saluda: , etc. 3) Instrucciones en ensamblador (el resto). 4) Comentarios, que aqui no aparecen. Los comentarios son identicos a los de C, es decir, van entre '/*' y '*/'. Podemos ver que todos los comandos estan bien delimitados. Las directivas empiezan con un punto, las declaraciones de etiquetas acaban con dos puntos, los comentarios van como en C, y el resto son instrucciones de ensamblador. (Aveces, algunos compiladores ponen un punto antes de las etiquetas, p. ej. '.L0:'. No confundas esto con una directiva). Por directivas entendemos instrucciones especiales que indican al ensamblador que hacer en ciertas situaciones. Por ejemplo, las directivas .data y .text le indican al ensamblador donde estan los datos y donde esta el codigo. Las directivas tipo .align le dicen al ensamblador que alinee el codigo o datos respecto a una cierta cantidad de bytes (lo tipico es 4, una doble palabra, pero para ciertos micros el optimo puede ser hasta 16, dependiendo de la situacion). La directiva .globl indica al compilador que 'saluda' es el nombre de la subrutina a efectos de linkado. La directiva .ascii marca el inicio de una cadena de caracteres. De la misma manera, una directiva .byte indicaria una cadena de bytes. Existen otras directivas de las que nos ocuparemos en la seccion 1.3. Nota: ten en cuenta que ciertas directivas solo las emplea en la practica el compilador. No es necesario aprender todas las directivas, si no las mas relevantes. Un buen ejemplo es la directiva .def, que introduce informacion para el debugger. Entender las etiquetas es mas facil. Podemos ver como los saltos (jmp, jle) se refieren claramente a las etiquetas. Por otro lado, tenemos una instruccion mas que se ocupa de etiquetas. Es la 'pushl $_msg'. Como es facil suponer, lo que hace esta instruccion es meter en la pila la direccion de la cadena asociada a la etiqueta '_msg:', es decir, un puntero a "hola, mundo!\0". Analicemos un poco la instruccion, dado que es un buen ejemplo del formato. Tenemos: pushl $_msg Los elementos raros aqui son el sufijo 'l' a push y el '$' antes de la etiqueta. Es importante que introduzcamos ahora dos nociones fundamentales: A) En el formato AT&T, toda instruccion que realiza una modificacion de datos _debe_ indicar la magnitud de los datos modificados. Cada magnitud se indica con un sufijo a la instruccion, y hay en total tres magnitudes: byte (sufijo 'b'), palabra (sufijo 'w') y doble palabra (sufijo 'l'). El byte son 8 bits, la palabra 16, y la doble palabra 32. En general, se corresponden con los tipos enteros en C: char, short, long. Ejemplos (no todos los ejemplos estan en el codigo de arriba): 1) pushl $_msg : Dado que un puntero es de 32 bits, 'push' lleva el sufijo 'l'. 2) cmpw $5, %bx : Dado que %bx es un registro de 16 bits, 'cmp' lleva el sufijo 'w'. 3) inb $20, %al : Para el registro de 8 bits %al, la instruccion 'in' lleva el sufijo 'b'. 4) ret : Esta instruccion no modifica datos, asi que no lleva sufijo. (Ya se, en rigor modifica %eip, pero es que _todas_ las instrucciones modifican %eip, no?) NOTA: Para la programacion de instrucciones en coma flotante y MMX aparecen otros sufijos, o los mismos con diferente significado. Si vas a programar codigo con la FPU o MMX, deberias leerte la documentacion de gas, donde se detallan todos los sufijos. Algunos ejemplos de la parte 3 de este tutorial emplean la FPU; puedes consultarlos para orientarte. B) Cuando se quiere indicar una cantidad 'inmediata' en un movimiento de datos, siempre se indica el prefijo '$' antes de esa cantidad. Igualmente, cuando un '$' precede a una etiqueta, se hace referencia a la direccion de memoria a la que apunta la etiqueta. Si no le precede un '$', se entiende que se hace referencia al contenido de la posicion apuntada por la etiqueta. (Aqui el '$' juega un papel analogo al del '&' en C: sin '&' se da el contenido, con '&' se da la direccion). Ejemplos: 1) pushl $_msg : Mete el puntero de 32 bits que apunta a "Hola, mundo!\0" en la pila. 2) pushb $0x5a : Mete en la pila el byte 0x5a. 3) pushw $0xbb5a : Mete en la pila la palabra 0xbb5a. 4) pushl _msg : Mete en la pila la dpalabra formada por los caracteres 'H', 'o', 'l' y 'a'. Observa como los numeros hexadecimales se indican exactamente igual que en C. Por defecto, si un numero no lleva el prefijo '0x', se asume que es decimal. Exactamente igual que en C. ADVERTENCIA: Si una cantidad inmediata no va precedida por un =========== '$', el ensamblador entiende que se trata de una referencia a la memoria. Ejemplo: pushl 0x5a : Mete en la pila la doble palabra que se encuentra en la direccion de memoria 0x5a en el segmento de datos. Ten muy en cuenta esta advertencia, dado que el olvidarse de un '$' es un motivo tipico para obtener un desesperante 'segmentation fault - core dumped' tras otro, al acceder el programa a la memoria arbitrariamente en vez de almacenar datos inmediatos. Salvo que estes programando un sistema operativo, un sector de arranque o algo asi, nunca querras direccionar memoria directa- mente, de manera que sospecha de toda cantidad inmediata que vaya sin su correspondiente '$'. Bueno, una vez que tenemos cierta idea de las directivas, etiquetas y que quieren decir los '$', vamos a ver que quieren decir los '%'. Un simple vistazo nos muestra que siempre que aparece un '%' es como prefijo a un nombre de registro: %ebp, %ebx, %bx, etc. Precisamente la funcion del signo '%' es la de hacer que los nombres de los registros no se puedan confundir con el de alguna variable o subrutina escrita en C. Ten en cuenta que gas es el ensamblador que usa gcc, asi que si en un programa en C usas una variable a la que llamas 'eax', o una subrutina 'esp' (por 'espera', por ejemplo) el ensamblador debe saber que no te estas refiriendo a un registro. Obviamente, no es tarea del gcc anunciar al programador que esta usando nombres de registros: se supone que C es un lenguaje de alto nivel. Asi pues, al ensamblar, antes de un nombre de registro hay que escribir siempre el signo '%'. Una vez que te acostumbras a ello, es una ayuda el poder diferenciar de un vistazo donde se usan variables y donde registros. NOTA: De todas maneras, algunas versiones de 'gcc' tienen otro mecanismo de seguridad para no confundir simbolos del ensamblador con simbolos del codigo en C. El mecanismo consiste en prefijar todo nombre declarado en C con un subrayado '_'. Si observas el codigo mas arriba, podras ver como la variable 'msg' es ensamblada como '_msg', y la subrutina 'saluda' es asociada a la etiqueta '_saluda'. Este mecanismo de seguridad se explicara con mas detalle en la seccion 2.1.2. Ten en cuenta que no todas las versiones de gcc implementan este mecanismo. Con esto tenemos casi todo lo relativo al formato AT&T. Bueno, falta un par de cosillas... que suelen disuadir a mucha gente de usar este formato. Observa esta linea, al principio del codigo: subl $20, %esp Bien, ahora podemos entender la cosa facilmente. Es una instruccion 'sub' (restar), que actua sobre magnitudes de 32 bits (sufijo 'l'), y se refiere a una cantidad inmediata de valor 20 decimal ($20), y al registro esp (%esp). Pero un momento ... segun el convenio Intel, lo que haria esta instruccion seria restar a una cantidad inmediata el contenido de un registro! En otras palabras, equivaldria a 20 = 20 - %esp en pseudocodigo, lo que no tiene sentido. Lo que sucede, naturalmente, es que en formato AT&T el orden de los operadores va _al_reves_ que en el formato Intel. Si en el formato Intel tenemos la forma general: instruccion destino, fuente ( p. ej. sub esp, 20 ) en el formato AT&T tenemos la forma general: instruccion fuente, destino ( p. ej. subl $20, %esp ) Esto echa para atras a montones de gente, que de pronto ven que tienen que 'volverse zurdos' (o diestros ;) para manejar este nuevo formato. Mi consejo para cambiar rapido el chip es el siguiente: IMAGINATE QUE ESTAS LEYENDO EL CODIGO. Si, como si estuviese escrito en español o ingles. Veamos: como escribirias en una frase lo que hace la instruccion 'subl $20, %esp'. Pues mas o menos: "resta 20 a esp". Si te fijas, el orden de los operadores va exactamente igual que en el formato AT&T. Asi, las lineas anteriores a la que hemos visto son: pushl %ebp " %ebp a la pila " movl %esp,%ebp " mete %esp en %ebp " subl $20,%esp " resta $20 a %esp " ... ... Vaya, no se lo que pensaras tu, pero una vez que te acostumbras la cosa es bastante natural. Lo unico que tienes que hacer es leer de izquierda a derecha. Compara eso con 'sub esp, 20'. Para entender esta instruccion debes leerte el mnemonico 'sub' en la izquierda, luego irte al extremo derecho para ver la fuente y entonces al centro para ver el destino. En esta instruccion quizas no sea muy molesto, pero hay codigos con instrucciones mas cargadas que una pizza especial. Ademas, leer estas instrucciones es como leer "restale a esp 20", lo cual es sintacticamente correcto, pero a mi me parece definitivamente incomodo. Para que la notacion Intel tuviera sentido deberia ser, creo yo, 'esp sub 20', que se podria traducir por "esp menos 20", lo cual tiene mas sentido. Con tener en cuenta esto, ya puedes leer practicamente todo el listado en ensamblador del ejemplo, con una excepcion. La linea movl -24(%ebp), %ebx Esta es la ultima peculiaridad del formato AT&T. La expresion (%ebp) indica al ensamblador que se emplee la direccion de memoria a la que apunta %ebp; en esencia, indica que %ebp actua como puntero. El -24 es un desplazamiento respecto a ese puntero. La expresion -24(%ebp) indica que la fuente del 'movl' es la doble palabra almacenada en la direccion 24 bytes por debajo de la apuntada por %ebp. En su forma mas general, el direccionamiento en un i386 tiene un puntero base, un desplazamiento inmediato, un indice y un factor de desplazamiento del indice. Por ejemplo, movl -120(%eax, %ebx, 4), %ecx almacena en %ecx el contenido de la direccion de memoria indicado por 120 + %eax + 4*%ebx. La forma general del direccionamiento es por tanto: desp(base, ind , fact) . Ejemplos: AT&T Intel (%eax) [eax] -5(%eax) [eax-5] (%eax,%ebx) [eax + ebx] 0x543(%ebp,%eax,2) [ebp + eax*2 + 543h] 13(,%eax,2) [eax*2 + 13] Lo interesante de esta notacion de direccionamiento es que es muy homogenea. Cada cosa esta en su lugar, y es facil de localizar. Hacer un direccionamiento es como llenar una ficha. NOTA: Adicionalmente, puedes indicar un registro de segmento respecto al cual direccionar la memoria, por ejemplo, %ds:20(%esi, %ebx, 2). Sin embargo, en la practica es muy poco comun usar esta opcion. Ahora si que tenemos todo lo necesario para entender el codigo en ensamblador que proponiamos al principio. Vamos a ver algunas partes concretas. Salvando las directivas iniciales, las primeras lineas son: pushl %ebp movl %esp,%ebp subl $20,%esp pushl %ebx estas lineas son muy comunes en subrutinas. Lo que se esta haciendo es conservar la referencia de pila (%ebp) de la subrutina que nos llama (pushl %ebp), definir una nueva referencia (movl %esp,%ebp), definir una total de 20 bytes para almacenamiento de variables locales (subl $20, %esp) y finalmente se conserva el valor de %ebx en la pila (pushl %ebx), dado que se va a usar como variable registro. Veremos mas de esto en la Parte 2. NOTA: El compilador gcc intenta por todos los medios mantener la pila alineada en 16 bytes al comienzo de las variables locales de cada subrutina, por motivos de eficiencia. Por eso se reservan en la pila 20 bytes que no se van a emplear para nada. Observa tambien los ajustes en la pila antes y despues de la llamada a puts. Ahora vamos con el cuerpo del programa: L3: incw %bx cmpw $9,%bx jle L5 jmp L4 .p2align 4,,7 L5: cmpw $5,%bx jne L6 addl $-12,%esp pushl $_msg call _puts addl $16,%esp L6: jmp L3 .p2align 4,,7 L4: L2: Una primera observacion es que gcc alinea siempre los saltos (directiva '.p2align', que comentaremos en la seccion 1.3.2), lo cual es una politica muy sensata. Los saltos mal alineados son tremendamente ineficientes. Este codigo tiene un fallo: hay muchos mas saltos de los necesarios, pero compilando con -O la cosa cambia mucho (haz la prueba). Por lo demas, es sencillo reconocer lo que pasa: desde L3 a L5 tienes la condicion del bucle, observa que %bx se corresponde con el entero corto 'i' del programa en C. Naturalmente, es tratado con el sufijo 'w'. Desde L5 a L6 tenemos el if, inclu- yendo la llamada a puts. De L6 a L4 se cierra el bucle. Finalmente queda por ver la salida de la subrutina: movl -24(%ebp),%ebx leave ret el par leave, ret es el usual en estos casos. El movl recupera el valor de %ebx que se metio en la pila al comienzo de la subrutina. Lo mismo habria dado hacer popl %ebx. ---| 1.2 Reglas generales. Pasemos a resumir todas las reglas que hemos observado, para tener una referencia rapida. REGLAS FUNDAMENTALES: 1) El orden de los operadores es: OPERACION FUENTE, DESTINO ( ej. adcl $26140, %eax ) 2) La magnitud de los operadores esta determinada por el sufijo del 'opcode'. Los sufijos de la ALU son 'b', 'w', 'l', que corresponden respectivamente a 8, 16 y 32 bits. (ej. incb %al ; pushw $0x5a ; xorl %eax, %eax ) NOTA: gas es capaz de interpretar correctamente las instrucciones que no lleven sufijo pero cuya magnitud sea obvia, como 'inc %al', pero dara error cuando no sea asi, como en 'push $0x5a'. 3) Cantidades inmediatas y referencias. 3.a) Las cantidades inmediatas deben ir precedidas de un '$', o se consideraran referencias absolutas de memoria. ( movl $0x5abb, %eax != movl 0x5abb, %eax ) 3.b) Si una etiqueta _no_ va precedida de un '$' se esta haciendo referencia al contenido de la zona de memoria a la que apunta la etiqueta. (ej. movl variable, %eax /* Almacena contenido de */ ) ( /* 'variable' en %eax */ ) Si escribes un '$' antes de la etiqueta, se hace referencia a la etiqueta como puntero. (ej. movl $variable, %eax /* Estas instrucciones */ ) ( movl %eax, ptr /* equivalen en C a: */ ) ( /* ptr = &variable; */ ) 4) La forma general de direccionamiento es: SEG:DESP(BASE,IND,FACT) donde: SEG == registro de segmento a emplear. DESP == desplazamiento inmediato a usar. BASE == registro base. IND == registro indice. FACT == factor de multiplicacion del indice. por ejemplo, leal 120(%eax, %eax, 2), %eax equivale a %eax = 120 + %eax + 2*%eax en pseudocodigo. 5) Los nombres de registros van siempre precedidos de '%'. ( ej. %aex, %ebx, %ecx, %edx, %esi, %edi, %esp, %ebp ) UNA CONVENCION IMPORTANTE: En el codigo de la seccion 1.1 habras observado que los nombres de las etiquetas de los bucles son L2, L3, L4, etc. En cualquier programa en ensamblador existen etiquetas que no quieres que sean visibles para el linkador; un caso especialmente importante es el de las etiquetas contenidas en las macros. Cualquier etiqueta que comience por 'L' (ele mayuscula), es considerada por gas como una _etiqueta_local_ y por tanto es traducida directamente a una direccion en memoria sin dejar ninguna referencia en el fichero objeto generado. Ejemplos de etiquetas locales: L0, L1, ... , L9 (estas son especiales, ver mas abajo) Lsalto, Lfalsa, Lloop7, ... Las etiquetas L0, ... , L9 son especiales porque puedes redefinirlas tantas veces como quieras (gas se encarga de interpretar a cual te refieres en cada momento). Esto las hace especialmente bien dotadas para las macros (tienes un ejemplo de este uso en la seccion 1.4, fichero 'arch.h'). Por ejemplo, puedes definir L0 noventa veces en el mismo programa. En esos casos, si quieres referirte a la etiqueta L0 inmediatamente anterior usa 'L0b' (L0 'backwards'), y si quieres referirte a la inmediatamente posterior usa 'L0f' (L0 'forward'). Por lo tanto, puedes considerar a las etiquetas locales como 'etiquetas basura', que usas en una parte del programa y a continuacion olvidas. Para mas datos sobre el uso de las L0...L9 consulta la documentacion de gas, donde se precisa como se almacenan internamente. Una detalle que merece explicacion: en la siguiente seccion veremos que para exportar al linkador un simbolo es necesario usar la directiva .globl (o .global). Entonces, dado que cualquier etiqueta no deberia ser exportada a menos que se use .global, ?por que molestarse en usar etiquetas locales? Bueno, la realidad es que aunque no exportes una etiqueta, cierta informacion de la misma si se exporta, y eso causa que 'gdb' se haga un lio a la hora de desensamblar las subrutinas que has escrito. Una etiqueta no local pero no declarada .global no es lo bastante global como para llamarla desde el linkador, pero es lo suficientemente no local como para que el debugger la trate como una subrutina aparte. Mi consejo es que emplees etiquetas locales dentro de lo posible, y que recurras a las no locales solo en situaciones importantes. Si esto te parece un lio, ten en cuenta que el uso de etiquetas no locales no afecta para nada a la eficiencia del codigo; puedes pasar completamente de las etiquetas locales. En la seccion 1.4 tienes un ejemplo en el que 'gdb' es capaz de desensamblar el codigo asociado a una etiqueta no local que sin embargo no ha sido declarada .global. Aunque te parezca una ventaja poder desensamblar el codigo en cada etiqueta que usas, esto tiene un inconveniente: 'gdb' solo desensamblara de una vez el codigo que haya entre dos etiquetas no locales dadas. Esto puede resultar incomodo si abusas de las etiquetas no locales. ---| 1.3 Algunas directivas interesantes. Por lo general, un programa en ensamblador sin directivas es completamente inutil. Veamos algunas directivas interesantes; la referencia completa la puedes encontrar en la documentacion de gas. 1.3.1. Directivas de almacenamiento de datos. .ascii : almacena en memoria una o varias cadenas de caracteres. Ten en cuenta que esta cadena no es null-terminada (cf. la directiva .asciz) (ej: .ascii "Hola, mundo!", "\n\0" ) .asciz : identico a .ascii, pero se incluye '\0' al final de cada cadena. .byte : almacena en esa posicion de memoria uno o mas bytes. Esta instruccion es interesante para definir instrucciones no soportadas por el ensamblador. (ej: .byte 0x0f, 0x31 /* Incluir estos datos en */ ) ( /* el codigo equivale a */ ) ( /* la instruccion rdtsc */ ) .double : almacena una o varias cantidades en coma flotante, de doble precision (64 bits). (ej: .double 0.314159265358979324e+1 ) .float : almacena una o varias cantidades en coma flotante, de precision simple (32 bits). (Sinonimo de .single) (ej: .float 2.718281, 0.69314718055 ) .hword : almacena una o varias palabras de 16 bits. Es sinonimo de .short. .int : almacena una o varias dpalabras (32 bits). Es sinonimos de .long. .octa : almacena uno o varios numeros de 16 bytes (128 bits) de longitud. .quad : almacena una o varias cuadruples palabras (64 bits de longitud). Puede ser muy util. (ej: .quad 0x000000003040ffff ) 1.3.2. Directivas de alineamiento. .align : alinea la siguiente etiqueta/instruccion en la cantidad de bytes indicada. Esta directiva puede tomar hasta tres parametros: .align AL, PAD, SKIP AL : bytes respecto a los que alinear. PAD : byte con el que rellenar. SKIP : la directiva sera ignorada si hay que añadir mas de SKIP bytes para alinear. (ej: .align 16,0,7 /* Alinea por 16 bytes a cero, pero */ ) ( /* que eso no suponga rellenar mas */ ) ( /* de 7 posiciones. */ ) Si en esta directiva no indicas el campo PAD, el ensamblador rellena con instrucciones tipo NOP, lo cual es conveniente en el codigo, claro. .p2align : identico a .align, pero el alineamiento se hace segun el numero de bits indicado por el primer parametro de la directiva. Asi, .p2align 2 equivale a .align 4 y .p2align 4 equivale a .align 16. Para ejemplos del uso de .p2align, observa el codigo de la seccion 1.1. Existen otras directivas de alineamiento; si te interesa emplearlas, consulta la documentacion de gas. 1.3.3. Otras directivas importantes. .data : indica a gas que los siguientes comandos deben ensamblarse en la seccion de datos. Esta directiva admite un comando, el numero de seccion, pero es dudoso que lo encuentres util. Consulta la documentacion, si crees que te puede servir de algo. .equ : un clasico de los ensambladores. Iguala un simbolo a una expresion, como '.equ STDIN 0'. .equiv : identico a .equ, pero si el simbolo ya ha sido definido, gas aborta con un error. Puede resultar util. .file : indica a gas que el codigo que sigue corresponde a cierto fichero. Puedes ver un ejemplo en el codigo de la seccion 1.1. .global : declara cierto simbolo como global, de manera que 'ld' y 'gdb' pueden reconocerlo. Esto es fundamental a la hora de programar subrutinas, y puede ser muy util para definir breakpoints particulares para 'gdb'. (ej: .global subrut /* Hace visible para */ ) ( subrut: /* 'ld' el nombre */ ) ( ... /* 'subrut'. */ ) .globl : sinonimo de .global. .include : permite cargar un fichero dentro de otro. Esto puede emplearse, por ejemplo, para cargar cabeceras o subrutinas interesantes dentro de un programa. (ej: .include "comun.s" ) .rept : repite una cierta seccion de codigo tantas veces como las especificadas en el parametro. El final de la zona a repetir se indica con '.endr'. (ej: .rept 10 ) ( movsd ) ( .endr ) .text : indica a gas que debe ensamblar los siguientes comandos en la seccion de codigo. En realidad, .data y .text significan algo mas generico que 'datos' y 'codigo', pero esta distincion funciona perfectamente en todos los casos. Existen muchas otras directivas, algunas de las cuales pueden resultarte de utilidad en ciertos casos. Recuerda que una de las mayores ventajas de gas es que si necesitas un ejemplo de como codificar algo, siempre puedes recurrir a un programa en C que haga algo parecido, y compilar con -S. Esta tecnica te proveera de infinidad de ejemplos interesantes que funcionan de verdad. Unas directivas interesantes son las dedicadas a definir macros. Las he omitido del documento por dos motivos: en primer lugar, es algo pesado explicarlas (consulta la documentacion si realmente estas interesado; no es complicado). En segundo lugar, el ensamblador ya es bastante ilegible sin necesidad de macros. Francamente, si la cosa esta tan mal, podrias considerar el uso de un lenguaje de alto nivel, y afinar las zonas criticas en vez de escribir un tochoprograma en ensamblador. De todos modos, en la siguiente seccion explicaremos como usar el preprocesador de gcc en el codigo ensamblado, lo que permite usar un monton de facilidades de gcc (#define, #ifdef, etc.) dentro de tu programa ensamblado, lo que hace la vida mas facil y ahorra la necesidad de macros en bastantes casos. La idea es usar la mayor cantidad de mecanismos conocidos antes que aprender otros nuevos. ?Pereza mental? Yo prefiero llamarlo economia. ---| 1.4 Usando el preprocesador. Una de las mejores caracteristicas de gas es su compenetracion con gcc. El primer ejemplo que veremos, antes del plato fuerte de la parte tercera, sera el uso del preprocesador. En primer lugar, recuerda que los ficheros que continen codigo ensamblador AT&T siempre tienen la extension '.s'. A los ficheros que contengan ensamblador AT&T que requieren preprocesamiento se les pone siempre la extension '.S'. Ejemplo: hola.s <-- Fichero con codigo ensamblador normal. macro.S <-- Fichero ensamblador con preprocesamiento. Si quieres algun ejemplo de fichero .S, echale un vistazo al codigo fuente del nucleo de cualquier UNIX. Podras ver una alegre mezcla de ensamblador y directivas del 'cpp'. Por ejemplo, al observar el codigo fuente de mi FreeBSD, localizo rapidamente varios ficheros .S en /sys/i386/boot/biosboot. Estos ficheros incluyen un fichero de cabecera llamado 'asm.h' empleando #include "asm.h", una instruccion del preprocesador. Este asm.h contiene varias macros bastante utiles, por ejemplo: ( fragmento de asm.h de FreeBSD ) ... #define String .ascii #define Value .word #define Times(a,b) (a*b) #define Divide(a,b) (a/b) #define INB inb %dx, %al #define OUTB outb %al, %dx #define INL inl %dx, %eax #define OUTL outl %eax, %dx ... La ventaja de usar el preprocesador es evidente. Otro ejemplo mas mundano de ensamblador con preprocesamiento podria ser el siguiente (hazlo tu mismo): 1) Crea un fichero 'arch.h' con las siguientes lineas: #ifdef i686 #define ALGN 16,,8 #define IFR(C1, C2, FROM, TO) cmpl C1, C2 ; \ cmovel FROM, TO #else /* i686 */ #define ALGN 4 #define IFR(C1, C2, FROM, TO) cmpl C1, C2 ; \ jnz L8f ; \ movl FROM, TO ; \ .align 4 ; \ L8: #endif /* i686 */ NOTA: En gas, el separador de varias instrucciones dentro de la misma linea es el simbolo ';', exactamente igual que en C. Mira las macros de arriba: cada vez que hay que separar dos instrucciones en la misma linea empleamos el ';'. Aunque el preprocesador expanda la macro IFR en una sola linea, gas sera perfectamente capaz de diferenciar los comandos individuales. 2) Ahora crea un fichero 'dumb.S' con las siguientes lineas: #define i686 #include "arch.h" .file "dumb.S" .text nada: .align ALGN /* Alineamiento dado por arch.h */ nop IFR(-5(%edx), %eax, %esi, %ebx) nop 3) Desde la shell, ejecuta el preprocesador: demeter# cpp dumb.S > dumb.i demeter# ls dumb.i dumb.S 4) Edita dumb.i, y obtendras: ( algunos datos del preprocesador al principio ) .file "dumb.S" .text nada: .align 16,,8 nop cmpl -5(%edx), %eax ; cmovel %esi, %ebx nop Este programa en ensamblador no hace nada (util), pero es un buen ejemplo de preprocesamiento. Ahora veamos como se compila un fichero '.S'. No puede ser mas sencillo. Para compilar 'dumb.S' simplemente debes recurrir a gcc, como si de un programa en C se tratara. Dado que nuestro programa en ensamblador no es un programa principal (le falta el _main), lo compilaremos como un modulo: demeter# gcc -c dumb.S demeter# ls dumb.S dumb.i dumb.o Ya que hemos llegado hasta aqui, confirmemos que esta todo bien compilado: demeter# gdb dumb.o (gdb saluda y suelta la retahila) (gdb) disassemble nada 0x0 : nop 0x1 : cmp 0xfffffffb(%edx), %eax 0x4 : cmove %esi,%ebx 0x7 : nop 0xa : add %al,(%eax) ... End of assembler dump. (gdb) Bueno, aqui esta nuestro programa, bien compilado. Recuerda lo que indicamos al final de la seccion 1.2: aunque no hemos declarado como .global a la etiqueta 'nada', el gdb la reconoce perfectamente. Esto sucedera con todas las etiquetas no locales que empleemos. De paso, esta version de 'gdb' tiene la mala costumbre de comerse los sufijos de magnitud cuando no son necesarios. Resumiendo, puedes usar 'gcc' para compilar un programa '.S' exactamente de la misma manera que con un programa en C. Esto ayuda bastante a la hora de definir makefiles, aunque por lo general deberas separar los ficheros '.S' de los '.c' en el makefile, puesto que gcc suele llevar opciones de compilacion diferentes para ambos. Hacer 'gcc -O3' a un fichero en ensamblador no es incorrecto, pero no tendra ningun efecto y es inelegante. ----------------| PARTE 2 : Ensamblando subrutinas |----------------- La manera mas sencilla de programar en ensamblador consiste en hacer el esqueleto del programa en C y desarrollar las subrutinas mas importantes en ensamblador. Esta estrategia es muy eficente, dado que descarga todo el trabajo pesado en el compilador, y todo el trabajo delicado en el programador. Sin embargo, existe un desventaja. Si la subrutina es muy corta, la perdida de tiempo asociada a las manipulaciones de pila y los saltos ('call' y 'ret') pueden contrarrestar las ventajas de usar ensamblador. En estos casos es mejor emplear el ensamblado 'inline' (ver la parte 3). Estudiaremos dos modos fundamentales de programar subrutinas. El modo 'canonico', que consiste en emplear el puntero base de pila %ebp para referirse a las variables locales. Cualquier subrutina compilada con 'gcc -S' usara este modo. El modo 'no canonico' consiste en emplear el puntero de pila %esp como referencia para las variables locales, lo que nos permite ratear el registro %ebp para nuestro propio uso. Puedes generar ejemplos de este tipo a partir de ficheros '.c' compilando con 'gcc -fomit-frame-pointer -S'. ---| 2.1 Normas de llamada a subrutinas. Para entender lo que sigue, debes de tener bien claro lo que es la pila de tu microprocesador, y como funciona. Debes saber como se altera la pila al hacer una instruccion tipo 'call', 'ret', 'push', 'pop', 'enter', 'leave', etc. Si no tienes mucha idea, consulta cualquier buen libro de ensamblador (si eres muy novato, mejor evita los 'white papers' al principio y consiguete una buena introduccion). Es tal la ola de ideologia RISC que nos invade, que posiblemente no necesites conocer algunas instrucciones 'vectoriales', tales como 'enter' y 'leave'. Algunos manuales de microprocesadores modernos desaconsejan su uso, pero como puedes ver (consulta el codigo en la seccion 1.1) el compilador 'gcc' sigue siendo adepto a ellas. Ademas, en todos los siguientes codigos de ejemplo supondremos que se esta trabajando con ensamblador con preprocesador (extension '.S', consulta la seccion 1.4). Esto no es esencial, y es muy facil conseguir el codigo preprocesado (usa 'gcc -save-temps'), pero nos valdra como practica. Las normas que vamos a exponer son validas para casi cualquier compilador de C, pero sobre estas cosas nunca puedes estar del todo seguro. Cuando menos, valen para gcc, y _deberian_ valer para los demas. Tambien son validas con pocas modificaciones para FORTRAN y Pascal. 2.1.1. Como llama el compilador de C a una subrutina. Conceptualmente, las subrutinas son subprogramas a los que se llama desde un cierto programa, mandandoles una cierta cantidad de datos de manera que la subrutina devuelva uno o ningun dato. Asi pues, la llamada a la subrutina tiene tres componentes: a) El programa principal le envia ciertos datos. b) El programa principal le cede el control. c) La subrutina devuelve uno o ningun datos. Estas componentes se gestionan como sigue: -> Puesto que los datos que se envian a una subrutina en C son desechables, lo mas logico es introducir esos datos en la pila _antes_ de llamar a la subrutina. El propio modulo que llama a la subrutina se encarga luego de desechar los valores de la pila. -> Puesto que la subrutina puede ser llamada desde varios modulos diferentes, la cesion de control (componente (b)) debe realizarse mediante una instruccion 'call'. La direccion de retorno quedara entonces contenida en la pila hasta que la subrutina devuelva el control al llamador, mediante un 'ret'. -> Dado que el valor devuelto es tan solo de un tipo de dato (caracter, puntero, en coma flotante, etc.) es logico devolver ese dato en un registro, dado que la pila de la subrutina es desechada al salir de la misma. Tambien pueden devolverse datos de tipo mas complejo, como estructuras, pero no estudiaremos esto en el tutorial. Por lo tanto, ya podemos hacer un esquema en ensamblador de como un cierto modulo llama a una subrutina. Los pasos a seguir deben ser: 1) Se meten en la pila los argumentos que se mandan a la subrutina. Por convenio, los argumentos se empujan de derecha a izquierda (es decir, en orden inverso a como es declarada la subrutina). Mas abajo tienes un ejemplo. 2) A continuacion se hace un 'call' a la subrutina. Esto cede el control a la misma. 3) Una vez que el modulo retoma el control, se eliminan los argumentos empujados a la pila, usualmente con un 'subl CANTIDAD, %esp', donde CANTIDAD es el numero de bytes reservados previamente (p. ej., CANTIDAD == $16, o lo que sea). 4) Por convenio, el dato devuelto esta almacenado, segun su tipo, en los siguientes registros: Puntero : %eax Caracter : %al Palabra : %ax Dpalabra : %eax Qpalabra : %edx y %eax Coma flotante : %st(0) (el TOS de la FPU) 5) Opcionalmente, el compilador de C hace algunos chanchullos para asegurarse de que la pila queda bien alineada en la subrutina. No nos preocuparemos por esto (si tienes curiosidad por esto, mira los comentarios que hicimos al respecto en 1.1). Con esto tenemos suficiente para dar un ejemplo: supongamos que un programa en C desea llamar a la siguiente subrutina, extern struct barf * llama(long , char , short ); de manera que la subrutina devuelve un puntero, y tiene como argumentos formales una palabra, un caracter y una dpalabra. En nuestro codigo C podemos hacer la llamada con la instruccion: static long largo; static char corto; static short medio; static struct barf *ptr; main() { ... ptr = llama(largo, corto, medio); ... } la llamada se compilaria como: ... pushw _medio pushb _corto pushl _largo /* Hasta aqui 1) */ call _llama /* Esto es 2) */ addl $7, %esp /* Esto es 3) */ movl %eax, _ptr /* Esto es 4) */ ... Observa que hemos hecho que todas las variables enviadas sean estaticas para facilitar la notacion. Si las variables largo, medio, corto y _ptr fueran automaticas, las instrucciones 'push' estarian referidas al puntero de pila, que es donde se almacenan las variables automaticas en un programa en C. Nota que la cantidad de bytes metidos en la pila es de 7 en total, que no es multiplo de 16. El compilador de C, para mantener el alineamiento, posiblemente incluira una instruccion 'subl $9, %esp' antes del codigo que hemos descrito, y un 'addl $16, %esp' en vez del 'addl $7, %esp', todo ello con la noble intencion de acelerar el codigo a costa de unos cuantos bytes de pila. 2.1.2. Como se comporta una subrutina en C. Ahora que ya sabemos lo que el compilador espera de nosotros, es relativamente sencillo emular una subrutina C en ensamblador. En primer lugar, deberemos darle un nombre a la subrutina y hacer que ese nombre sea visible para el linkador. Esto se logra haciendo: .text .global _llama _llama: al principio de la subrutina. Ahora el linkador ya sabe a donde tiene que saltar cuando se encuentre con el 'call _llama' en el modulo en C. Una vez que el modulo nos ha cedido el control, tenemos que ocuparnos de lo que pasa en la pila, suponiendo que nuestra subrutina maneje algunos datos. Como suponemos que nuestra subrutina es la 'llama' definida mas arriba, sabemos que nos estan mandando 3 variables en la pila. Justo en el momento en que el control se cede a nuestra subrutina, la estructura de la pila es la siguiente: ... ... (tamaño) 9(%esp) ---> medio (16 bits) 8(%esp) ---> corto ( 8 bits) 4(%esp) ---> largo (32 bits) 0(%esp) ---> %eip de retorno (32 bits) Para darse cuenta no hay mas que mirar como ingresaron los datos en la pila, en el codigo de ejemplo de la seccion 2.1.1. Es con estos datos con los que tenemos que trabajar para desarrollar el codigo de la subrutina. Como veremos, hay dos estrategias: sacrificando un registro para acceder a la pila ordenadamente (modo 'canonico'), o accediendo a ella desordena- damente, pero ahorrando un registro (modo sin 'frame pointer'). UNA REGLA FUNDAMENTAL: Como puedes ver, el modulo en C demuestra una extraordinaria confianza en las subrutinas a las que llama. El modulo llamador depende muy probablemente del contenido de registros como %ebp y %ebx (y quizas de muchos otros) para funcionar correctamente. Si al volver de la subrutina has modificado %ebp, %ebx u otro registro que use tu llamador, habras saboteado con toda seguridad su funcionamiento. La norma acerca de registros para gcc es la siguiente: NORMA: Los registros %esp, %ebp, %ebx, %esi, %edi _deben_ ser devueltos intactos por toda subrutina. Lo mismo se aplica, por supuesto, a los registros de segmento. COROLARIO: Puedes emplear libremente los registros %eax, %ecx, %edx en el interior de tu subrutina. Olvidar esta regla es dejar una puerta abierta al caos, la desesperacion, la locura y lo peor de todo, el mensajillo 'segmentation fault - core dumped'. UNA CONVENCION IMPORTANTE: El compilador 'gcc' se emplea en muchos sistemas opertivos, como FreeBSD, NetBSD, linux, windows, etc. Lamentablemente, los que implementaron gcc en esos sistemas hicieron convenios distintos acerca de como se llama a las rutinas en ensamblador desde C. El siguiente convenio es necesario para cygwin (implementacion de 'gcc' sobre windows), y posiblemente para otros sistemas: Cuando programes una subrutina en ensamblador con la intencion de que se acceda a ella desde C, debes tener en cuenta que todos los simbolos que exportes con .globl o .global deben ir precedidos por un signo de subrayado '_'. Por ejemplo, para definir una subrutina en ensamblador llamada 'procesa', que pueda ser llamada desde C, debes usar en tu programa ensamblador la directiva: .text .global _procesa _procesa: ... (el resto de la subrutina) Lo mismo vale para una variable estatica que quieras que sea visible desde C: .data .global _cadena _cadena: .asciz "Hola, mundo!\n" Cualquier programa escrito en C podra acceder a 'procesa' o 'cadena' por su nombre, sin el subrayado, mientras que cualquier programa en ensamblador accedera a ellos con el nombre completo, con subrayado. Esto se ha pensado para evitar colisiones accidentales de nombres entre el compilador y el ensamblador. En otras implementaciones de 'gcc' el subrayado puede no ser necesario. Para comprobarlo, basta que compiles con -S algun programa en C y veas como se declaran los nombres de las subrutinas. Para curarnos en salud, en este tutorial hemos empleado el subrayado en todos los ejemplos. ---| 2.2 Subrutinas 'canonicas'. Con la informacion obtenida en 2.1 ya tenemos suficentes datos como para empezar a programar subrutinas. Veamos el primer tipo. Llamamos subrutinas 'canonicas' a las que se comportan de acuerdo con la tradicion de codificacion en C. Esta tradicion indica que hay que utilizar el registro %ebp para referirse a las variables locales, que estan en la pila. Se dice entonces que %ebp actua como 'frame pointer'. Si no se respeta este convenio, el debugger 'gdb' puede mostrarse algo confuso acerca del contenido de la pila de una subrutina. La definicion de un nuevo 'frame pointer' se consigue con el siguiente mecanismo (siempre tomamos como ejemplo la subrutina 'llama' de la seccion anterior): .text .global _llama _llama: pushl %ebp /* Almacenamos el viejo frame pointer. */ movl %esp, %ebp /* Nuevo frame pointer. */ subl $12, %esp /* Espacio para variables automaticas. */ ... /* Resto de la subrutina. */ addl $12, %esp /* Se liberan las variables automat. */ popl %ebp /* Se recupera el viejo frame pointer. */ ret /* Retorno a modulo llamador. */ las tres instrucciones finales pueden condensarse en dos: leave ret esto es mas compacto, pero puede ser levemente ineficiente en algunas maquinas. La idea que se persigue con el 'frame pointer' es tener un acceso facil a las variables automaticas de la subrutina. Si observamos la estructura de la pila despues de la instruccion 'subl $12, %esp', tenemos: ... ... ... 25(%esp) ---> (16 bits) medio <--- 13(%ebp) 24(%esp) ---> ( 8 bits) corto <--- 12(%ebp) 20(%esp) ---> (32 bits) largo <--- 8(%ebp) 16(%esp) ---> %eip de retorno <--- 4(%ebp) 12(%esp) ---> %ebp viejo <--- 0(%ebp) 8(%esp) ---> (32 bits) var1 <--- -4(%ebp) 4(%esp) ---> (32 bits) var2 <--- -8(%ebp) 0(%esp) ---> (32 bits) var3 <--- -12(%ebp) Hemos referido la pila a los dos punteros, %esp y %ebp. A las variables reservadas (tres enteros largos, 12 bytes en total) las hemos llamado var1, var2 y var3. La ventaja de usar un 'frame pointer' para nuestra subrutina es evidente si suponemos que la pila se altera durante la ejecucion de la subrutina. Por ejemplo, imaginemos que el codigo de nuestra subrutina es: .text .global _llama _llama: pushl %ebp /* Almacenamos el viejo frame pointer. */ movl %esp, %ebp /* Nuevo frame pointer. */ subl $12, %esp /* Espacio para variables automaticas. */ pushl %ebx /* Conservamos para no corromperlo. */ xorl %ebx, %ebx /* Resto de la subrutina ... */ ... Despues de la instruccion 'pushl %ebx' el puntero de pila %esp ha disminuido en 4 unidades, por lo que ahora la variable 'var1' ha pasado a ser 12(%esp), ya no 8(%esp). Y el argumento 'corto' ha pasado a ser 28(%esp), cuando antes era 24(%esp). Esto demuestra que encontrar las variables locales, o los argumentos de la subrutina, es bastante complicado si usas %esp, dado que tienes que tener una idea exacta del contenido de la pila _en_el_mismo_instante_de_acceder_a_ella_. Sin embargo, dado que el puntero %ebp no ha cambiado durante el 'pushl %ebx', las variables y argumentos siguen teniendo la misma direccion que al principio. Es muy facil referirse a variables y argumentos a traves de %ebp. NORMA: Si usas el 'frame pointer' pierdes un registro de proposito general pero ganas legibilidad y comodidad a la hora de acceder a variables automaticas y argumentos de la subrutina. Cualquier modificacion que hagas a un programa ensamblador _sin_ 'frame pointer' podria obligarte a revisar todo el codigo, si es que se produce una alteracion en el orden de la pila. Debes calibrar cuidadosamente si quieres usar %ebp como 'frame pointer' o no. Las unicas ventajas son de legibilidad, mantenimiento del codigo, y debugging. No se obtienen ventajas evidentes en terminos de velocidad o espacio. ---| 2.3 Un ejemplo de subrutina 'canonica'. Vamos a ver un ejemplo formado por un programa principal escrito en C y una subrutina en ensamblador. Lo que hara el programa sera pasar una cadena de caracteres a mayusculas, y devolver el numero de caracteres procesados en una variable tipo short. Aqui tenemos el programa principal, main.c: /******** Aqui empieza el fichero main.c **********/ #include #include extern short procesa (char *); main() { char *c; short l; c = (char *) malloc(256 * sizeof(char)); if (c == NULL) exit(1); printf("Introduce cadena (>255 caracteres): "); scanf("%255s", c); l = procesa(c); printf("\n\nCadena: %s\n", c); printf("Caracteres: %d\n", l); free(c); exit(0); } /*********** Fin de main.c **************/ y ahora el segundo fichero, sub.S: /********** Aqui empieza sub.S *********/ #define ALINEA 16,,8 .file "sub.S" .text .globl _procesa .align ALINEA _procesa: pushl %ebp movl %esp, %ebp subl $2, %esp /* 2 bytes de variables, */ /* ver abajo. */ pushl %ebx /* Usaremos este registro. */ /* En este momento: * -2(%ebp) == variable a devolver (16 bits). * 8(%ebp) == puntero a caracteres. */ xorw %ax, %ax movw %ax, -2(%ebp) /* Contador a cero. */ movl 8(%ebp), %eax /* %eax apunta a la cadena. */ .align ALINEA /* Alinea salto. */ L1: movb (%eax), %bl /* Caracter en %bl. */ testb %bl, %bl /* Fin de la cadena ? */ jz Lfin incw -2(%ebp) /* Aumenta contador. */ andb $0xdf, (%eax) /* Pasa a mayuscula. */ incl %eax /* Siguiente elemento. */ jmp L1 /* Cierra el lazo. */ .align ALINEA Lfin: movw -2(%ebp), %ax /* Valor de retorno. */ popl %ebx /* Recupera registro. */ addl $2, %esp /* Libera vars. locales. */ popl %ebp /* Libera stack frame. */ ret /* Hecho. */ /************** Fin de sub.S **************/ El programa es extraordinariamente tonto, pero consigue expresar la mayor parte de los conceptos que hemos visto. Observa en particular el uso de etiquetas locales, el uso de sufijos de todos los tipos en los operadores, y el uso del preprocesador. Tambien hemos usado un registro de mas, %ebx, que hemos tenido el cuidado de conservar. Para compilar este programa, y ejecutarlo: demeter# gcc -o cap main.c sub.S demeter# ./cap Introduce cadena (>256 caracteres): HoLaYaDiOs Cadena: HOLAYADIOS Caracteres: 10 demeter# Usando este ejemplo como punto de partida, puedes desarrollar casos mucho mas perfeccionados. Ten en cuenta que el anterior ejemplo esta concebido como codigo de ejemplo, no como un codigo eficiente. Un par de correciones al mismo serian muy pertinentes. Por ejemplo, para mantener la pila alineada respecto a 4 bytes, se deberia usar 'subl $4, %esp' en vez de 'subl $2, %esp' al principio de la subrutina, incluso si de esos 4 bytes solo vamos a usar 2. Ademas, en vez de %ebx podriamos haber usado %edx, lo que nos habria ahorrado el andar conservando el registro en la pila. Mas aun, usando %ecx podriamos habernos ahorrado el uso de la variable local. Pero con estas correciones creo que el codigo habria sido mucho menos instructivo. ---| 2.4 Subrutinas sin 'frame pointer'. Entendiendo correctamente la seccion 2.2, es facil darse cuenta de en que consiste este tipo de subrutinas. Sencillamente, no empleamos el puntero %ebp como 'frame pointer', lo que exige usar algo mas el coco. Como primer paso, reescribamos la subrutina del ejemplo anterior sin 'frame pointer'. Ademas de eliminar el uso de %ebp, cambiaremos de posicion el par de comandos 'pushl %ebx', 'popl %ebx' para que puedas apreciar como varia la manera de referirse a la variable local, que en unos momentos es (%esp) y en otros 4(%esp). Para usar esta subrutina, guardala en un fichero ('sub2.S', por ejemplo), y compilala como la anterior. /*************** Aqui empieza sub2.S *************/ #define ALINEA 16,,8 .file "sub2.S" .text .globl _procesa .align ALINEA _procesa: subl $2, %esp /* 2 bytes de variables, */ /* ver abajo. */ /* En este momento, la estructura de la pila es: * * 6(%esp) --> puntero que nos han pasado. * 2(%esp) --> %eip de retorno. * 0(%esp) --> variable local (16 bits) */ xorw %ax, %ax movw %ax, (%esp) /* Contador a cero. */ movl 6(%esp), %eax /* %eax apunta a la cadena. */ pushl %ebx /* Usaremos este registro. */ /* La pila ha cambiado a: * * 10(%esp) --> puntero que nos han pasado. * 6(%esp) --> %eip de retorno * 4(%esp) --> variable local (16 bits) * 0(%esp) --> antiguo %ebx */ .align ALINEA /* Alinea salto. */ L1: movb (%eax), %bl /* Caracter en %bl. */ testb %bl, %bl /* Fin de la cadena ? */ jz Lfin incw 4(%esp) /* Aumenta contador. */ andb $0xdf, (%eax) /* Pasa a mayuscula. */ incl %eax /* Siguiente elemento. */ jmp L1 /* Cierra el lazo. */ .align ALINEA Lfin: popl %ebx /* Recupera registro. */ /* La pila ha vuelto a cambiar: * * 6(%esp) --> puntero que nos han pasado. * 2(%esp) --> %eip de retorno. * 0(%esp) --> variable local (16 bits) */ movw (%esp), %ax /* Valor de retorno. */ addl $2, %esp /* Libera vars. locales. */ ret /* Hecho. */ /************** Fin de sub2.S **************/ Este programa apenas tiene un tamaño insignificantemente menor que el anterior, pero da una idea del metodo. Ahora todas las referencias de memoria son relativas a %esp, y dispondriamos del registro %ebp si estuvieramos realmente cortos de registros libres. Cuantos mas 'push', 'pop', 'call' o cualquier otra instruccion que modifique %esp en medio del codigo, mas liante se vuelve la subrutina. -----------------| PARTE 3 : Ensamblando 'inline' |------------------ El ensamblado 'inline' consiste en la introduccion de codigo ensamblador en el interior de un codigo fuente en C. Esta tecnica es tan vieja como el lenguaje C, y el ensamblador 'gcc' posee unas capacidades extraordinarias para incluir ensamblador 'inline' en los programas en C, llegando a optimar la interaccion entre ambos lenguajes. Todo esto viene a un precio. Hay que dar al compilador una informacion muy completa de los datos que nuestro codigo ensamblador usa como entrada, de los que usa como salida, y de los que usa como almacenamiento temporal. Sin esta informacion 'gcc' no podra optimar nuestro codigo y, lo que es peor, hasta puede ser inducido a error. Cuando tiras una piedra en una laguna, no solo la zona de impacto es afectada por la piedra, si no que el efecto se expande por toda el agua. De la misma manera, introducir un codigo extraño en un programa C puede causar fallos donde menos lo esperas, si no avisas al compilador de lo que tu codigo hace exactamente. En esta parte veremos dos tipos fundamentales de ensamblado dentro de C. El primero, al que llamaremos 'metodo Frankenstein', consiste en introducir el ensamblador a lo bruto, en plan protesis, usando algunas tecnicas elementales para asegurarte de que las cosas van a funcionar. El metodo Frankenstein es el que se usaba en los viejos tiempos. Su ventaja es que casa bien con la ley universal "cuando tengas dudas, usa la fuerza bruta"; es un metodo rapido y directo. Su desventaja es que nada te asegura que un cambio en la version del compilador, en las opciones de compilacion o en el codigo en C no vayan a desestabilizar completa- mente el programa. El segundo metodo es el feten. Consiste en decirle a gcc en su propio lenguaje todo lo que necesita saber para que el trabajo quede bien hecho. Esta opcion tiene todas las ventajas, excepto que es un poco latosa de aprender. Bueno, tambien es menos divertida ;). ---| 3.1 El metodo Frankenstein. La idea es simple, brutal y victoriana. Tomas los diferentes pedazos (brazos, piernas, placas de metal...), lo coses todo y lo echas a correr. El resultado suele ser feo y demoniaco, pero contundente. Como hemos aprendido en innumerables peliculas de serie B, eso de jugar a ser Dios conlleva el pago de un alto precio... asi que preparate para enfrentarte a los monstruos que vas a crear. Bromas aparte, el metodo Frankenstein consiste en los siguientes pasos: 1) Escribe el codigo en C que quieres modificar. 2) Compilalo con 'gcc -S' y localiza la zona en la que quieres injertar el ensamblador. 3) Observa bien la zona de implantacion para asegurarte de que el injerto sera compatible con el codigo. 4) Injerta el ensamblador en el codigo C. Esto se hara usualmente en forma monolitica (tus instrucciones en ensamblador no deberan ser procesadas por el compilador, que se limitara a pasarlas al ensamblador 'tal cual'). 5) Compila el codigo injertado con 'gcc -S' y comprueba que la cosa marcha como tu quieres. 6) Ahora compila y linka el programa injertado. 7) Ejecutalo. Si funciona, debes lanzar el grito ritual: "Estaaaaa viiiiivooo! Muhahahahah!" Ya estas listo para castigar al mundo con tu creacion infernal. 3.1.1 Insercion de ensamblador. Para insertar ensamblador en un codigo en C hay que utilizar el comando 'asm'. Sin embargo, para evitar advertencias por parte del compilador (por ejemplo, compilando con comaptibilidad ANSI), es mejor usar '__asm__', con dos subrayados antes y despues de la directiva. Como primer ejemplo, imaginemos que queremos introducir una etiqueta inocua en el codigo en C, de manera que al ejecutar 'gdb' podamos saltar rapidamente a esa parte de codigo. Si tenemos un programa como el siguiente (guardalo en un fichero 'memo.c'): #include main (int argc, char *argv[]) { int i; puts("Este programa ha sido invocado como:"); for (i = 0 ; i < argc ; i++) { printf("%s", argv[i]); putchar(' '); } putchar('\n'); exit(0); } otro de nuestros programas tontos patentados. Lo que hace esta claro: demeter# gcc -o memo memo.c demeter# ./memo jarl de peich morl! Este programa ha sido invocado como: /root/tut/memo jarl de peich morl! demeter# ./memo *.c Este programa ha sido invocado como: /root/tut/memo bitm.c main.c memo.c test.c demeter# Etcetera. Ahora supongamos que queremos definir un 'breakpoint' para gdb entre el 'printf' y el 'putchar' del bucle. Para ello introducimos entre ambas instrucciones un fragmento de ensamblador: ... printf("%s", argv[i]); __asm__(" .global _brk ; _brk: "); putchar(' '); ... Bien, ahora volvemos a compilar y ejecutamos gdb: demeter# gcc -o memo memo.c demeter# gdb memo (gdb saluda y se identifica) (gdb) disassemble brk Dump of assembler code for function brk: : add $0xfffffff4, %esp : call ... (mas lineas en ensamblador) End of assembler dump. (gdb) Este diminuto (e inocuo) parche en ensamblador nos permite fijar la atencion en la parte del fichero que nos interesa. Como primer ejemplo de inoculacion de codigo, es bastante util. En general, para insertar lineas mas largas de ensamblador todo lo que necesitas es usar habilmente el separador de comandos ';' de gas y el simbolo de union de lineas '\' del compilador. Un ejemplo hipotetico seria: ... (codigo en C) __asm__(" .globl _franky ;" \ " _franky: ;" \ " addl $4, %esp ;" \ " incl %eax ;" \ " leave ;" \ " ret "); ... (codigo en C) Las normas son: 1) Cada comando en ensamblador va entre comillas. 2) Usa un ';' para separar los comandos. Si compilas con 'gcc -S' veras que este codigo injertado se escribe en una sola linea, con los ';' como separadores. Si quieres que al hacer 'gcc -S' el codigo en ensamblador se vea mas agradable, en lineas separadas, en vez del ';' usa el par '\n\t'. Por ejemplo: __asm__(" .globl _franky \n\t" \ " _franky: \n\t" \ " addl $4, %esp \n\t" \ " incl %eax \n\t" \ " leave \n\t" \ " ret "); Aunque es algo molesto de escribir, el resultado estetico vale sobradamente la pena. Si has compilado con '-S' alguno de los ejemplos anteriores, habras notado que el codigo injertado aparece en el ensamblador entre los comandos '/APP' y '/NO_APP', por ejemplo: /APP .globl _brk ; _brk: /NO_APP Estos comandos le dicen al ensamblador que las lineas que has injertado han sido escritas por un humano, por lo que el ensamblador debe tener especial cuidado al leerlas. A donde hemos ido a parar. Los ordenadores nos tratan con condescendencia! En todo caso, gracias a esto podemos localizar rapidamente los comandos injertados en el fichero '.s'. Resumiendo: 1) Para injertar ensamblador en gcc, basta con usar la directiva __asm__(" ... "); . 2) La cadena entre parentesis se puede romper en varias, que se consideraran parte de una sola linea del ensamblador. En forma general: __asm__(" ... " " ... " ... " ... "); . 3) Para separar comandos de ensablador, o bien usas ';', lo cual es sencillo pero queda feo en el fichero '.s', o bien usas '\n\t', que te hace escribir algo mas pero resulta mas bonito en el '.s'. 4) Tu codigo injertado va en el fichero '.s' entre los comandos '\APP' y '\NO_APP'. 3.1.2 Injertos monoliticos. El compilador 'gcc' es lo bastante inteligente como para darse cuenta de que ciertas lineas en ensamblador que introduces no son necesarias, lo cual le puede llevar a eliminarlas. Tambien puede ocurrir que decida mezclar tus lineas en ensamblador con las que el genera, para acelerar el codigo. Si estas haciendo un injerto a lo bruto, esto puede ser de lo mas indeseable. Para decirle al compilador que nuestro codigo es peligroso se emplea el comando '__volatile__', justo despues del comando '__asm__'. Es decir: __asm__ __volatile__(" ... "), donde la (o las) cadenas en el interior de los parentesis van exactamente igual que las descritas en la seccion anterior. El compilador entiende el '__volatile__' como un signo de peligro y no tocara lo que haya dentro, en la medida de lo posible. 3.1.3 Un ejemplo retorcido. Naturalmente. Los ejemplos de injertos son siempre retorcidos. Para apartarnos un poco de nuestra politica de programas idiotas, consideremos un ejemplo un poco mas pintoresco. El siguiente programa genera un fichero llamado 'sal.bmp', que contiene el dibujo de una espiral de Arquimedes, en blanco y negro y resolucion de 300x300 pixels. Almacena el siguiente programa en un fichero ('bitm.c'): /************ Aqui comienza 'bitm.c' ***************/ #include #include /* Cabecera de un fichero BMP 300x300 monocromo. */ static char head[] = { \ 0x42,0x4d,0x1e,0x2f,0,0,0,0,0,0,0x3e,0,0,0,0x28,0, \ 0,0,0x2c,0x01,0,0,0x2c,0x01,0,0,0x01,0,0x01,0,0,0, \ 0,0,0xe0,0x2e,0,0,0xc4,0x0e,0,0,0xc4,0x0e,0,0,0,0, \ 0,0,0,0,0,0,0,0,0,0,0xff,0xff,0xff,0}; /* Mascara que localiza un bit en un byte. */ static char masc[] = {128,64,32,16,8,4,2,1}; /* Constantes del BMP. */ #define TAM (40*300) #define LIN 40 #define HDSIZ 62 #define RADIO 3.0 /* Escala del radio de la espiral. */ main() { char *imag, *alias; FILE *fich; unsigned long i, j; double x,y,t; if ((fich = fopen("sal.bmp","w")) == 0) { puts("Error: fopen"); exit(1); } imag = (char *) malloc(TAM * sizeof(char)); if (imag == NULL) { puts("Error: malloc"); exit(1); } /* Escribe cabecera. */ for (i = 0 ; i < HDSIZ ; i++) putc(head[i], fich); /* Inicializa imagen */ alias = imag; for (i = 0 ; i < 300 ; i++) { for (j = 0 ; j < (LIN-3) ; j++) *(alias++) = 0xff; *(alias++) = 0xf0; *(alias++) = 0; *(alias++) = 0; } /* Genera imagen. */ for (t = 0 ; t < 36 ; t += 0.001) { /* Calculamos coords. de la espiral. */ x = 150.0 + RADIO * t * cos(t); y = 150.0 + RADIO * t * sin(t); /* Eliminamos puntos fuera del BMP. */ if ((x > 300) || (x < 0)) continue; if ((y > 300) || (y < 0)) continue; /* Incluimos el punto en la imagen. */ j = (unsigned long) y; i = (unsigned long) (x/8); imag[(LIN*j)+i] &= ~masc[((unsigned long) x)%8]; } /* Escribe imagen en fichero. */ for (i = 0 ; i < TAM ; i++) putc(imag[i], fich); free(imag); fclose(fich); exit(0); } /***************** Fin de 'bitm.c ****************/ Supongamos que queremos sustituir el calculo de las coordenadas de la espiral por una version en ensamblador. Nuestra primera tarea es localizar la zona de injerto, lo cual se puede lograr sencillamente intercalando un par de etiquetas que pasaran al ensamblador: ... __asm__ __volatile__("comienzo:"); x = 150.0 + RADIO * t * cos(t); y = 150.0 + RADIO * t * sin(t); __asm__ __volatile__("final:"); ... Ahora compilamos con 'gcc -ffast-math -S' y hacemos una busqueda de la zona entre las etiquetas. El uso de la opcion '-ffast-math' ayuda a simplificar el codigo que queremos localizar. Veamos que es lo que encontramos: /APP comienzo: /NO_APP addl $-8,%esp fldl -48(%ebp) subl $8,%esp fstpl (%esp) call _cos addl $16,%esp fldl LC5 fmulp %st,%st(1) fmull -48(%ebp) fldl LC6 faddp %st,%st(1) fstpl -32(%ebp) addl $-8,%esp fldl -48(%ebp) subl $8,%esp fstpl (%esp) call _sin addl $16,%esp fldl LC5 fmulp %st,%st(1) fmull -48(%ebp) fldl LC6 faddp %st,%st(1) fstpl -40(%ebp) /APP final: /NO_APP Este codigo nos permite formular algunas conjeturas: 1) -32(%ebp) almacena la variable x. 2) -40(%ebp) almacena la variable y. 3) -48(%ebp) almacena la variable t. 4) LC5 almacena la constante RADIO (que vale 3.0) 5) LC6 almacena la constante 150.0 que es mas o menos todo lo que necesitamos para desarrollar nuestro codigo. Pero antes de comenzar, observemos algo que puede causarnos muchos problemas: -> Las constantes LC5 y LC6 han sido definidas por el -> compilador precisamente porque las hemos usado en las -> lineas de codigo que queremos sustituir. Asi que si ahora -> eliminamos esas lineas, LC5 y LC6 ya no existiran, o bien -> estaran asociados a otras constantes. En consecuencia, -> nuestro codigo ensamblador debe definir estas dos -> constantes (con otro nombre que no interfiera con C). En esencia, tendremos que definir dos cantidades de doble precision en nuestro codigo ensamblador. Una sera 3.0 y la otra 150.0. Este tipo de cosas son las que te hacen amar los chanchullos con ensamblador. Ahora que ya nos hemos salvado de este diabolico efecto colateral, pasemos a construir un codigo que haga el calculo de las cantidades que nos interesan. Por ejemplo, esto podria servir: .data Lradio: .double 3.0 Lcentro: .double 150.0 .text /* Pila de la FPU */ fldl -48(%ebp) /* t */ fld %st(0) /* t : t */ fldl Lradio /* 3 : t : t */ fmulp %st(2) /* t : 3t */ fsincos /* Cos(t) : Sen(t) : 3t */ fmul %st(2) /* 3tCos(t) : Sen(t) :3t */ fxch %st(2) /* 3t : Sen(t) : 3tCos(t) */ fmulp %st(1) /* 3tSen(t) : 3tCos(t) */ fldl Lcentro /* 150 : 3tSen(t) : 3tCos(t) */ fadd %st(0),%st(2) /* 150 : 3tSen(t) : 150+3tCos(t) */ faddp %st(1) /* 150+3tSen(t) : 150+3tCos(t) */ fstpl -40(%ebp) /* 150+3tCos(t) */ fstpl -32(%ebp) Lo cual, insertado en modo ensamblador seria: __asm__ __volatile__(" .data \n\t" \ " Lradio: \n\t" \ " .double 3.0 \n\t" \ " Lcentro: \n\t" \ " .double 150.0 \n\t" \ " .text \n\t" \ " fldl -48(%ebp) \n\t" \ " fld %st(0) \n\t" \ " fldl Lradio \n\t" \ " fmulp %st(2) \n\t" \ " fsincos \n\t" \ " fmul %st(2) \n\t" \ " fxch %st(2) \n\t" \ " fmulp %st(1) \n\t" \ " fldl Lcentro \n\t" \ " fadd %st(0), %st(2) \n\t" \ " faddp %st(1) \n\t" \ " fstpl -40(%ebp) \n\t" \ " fstpl -32(%ebp) \n\t"); Prueba a sustituir las dos lineas en C que definen 'x' e 'y' por este injerto en ensamblador, y compila el resultado. Ten en cuenta que tu compilador podria almacenar 'x', 'y' y 't' en otras posiciones. Salvo esto, el codigo deberia funcionar de manera general. Por si te has perdido, he aqui la forma final del codigo mixto para el programa en C anterior: /***************** Aqui comienza bitm2.c *********************/ #include #include /* Cabecera de un fichero BMP 300x300 monocromo. */ static char head[] = { \ 0x42,0x4d,0x1e,0x2f,0,0,0,0,0,0,0x3e,0,0,0,0x28,0, \ 0,0,0x2c,0x01,0,0,0x2c,0x01,0,0,0x01,0,0x01,0,0,0, \ 0,0,0xe0,0x2e,0,0,0xc4,0x0e,0,0,0xc4,0x0e,0,0,0,0, \ 0,0,0,0,0,0,0,0,0,0,0xff,0xff,0xff,0}; /* Mascara que localiza un bit en un byte. */ static char masc[] = {128,64,32,16,8,4,2,1}; /* Constantes del BMP. */ #define TAM (40*300) #define LIN 40 #define HDSIZ 62 #define RADIO 3.0 /* Escala del radio de la espiral. */ main() { char *imag, *alias; FILE *fich; unsigned long i, j; double x,y,t; if ((fich = fopen("sal.bmp","w")) == 0) { puts("Error: fopen"); exit(1); } imag = (char *) malloc(TAM * sizeof(char)); if (imag == NULL) { puts("Error: malloc"); exit(1); } /* Escribe cabecera. */ for (i = 0 ; i < HDSIZ ; i++) putc(head[i], fich); /* Inicializa imagen */ alias = imag; for (i = 0 ; i < 300 ; i++) { for (j = 0 ; j < (LIN-3) ; j++) *(alias++) = 0xff; *(alias++) = 0xf0; *(alias++) = 0; *(alias++) = 0; } /* Genera imagen. */ for (t = 0 ; t < 36 ; t += 0.001) { /* Calculamos coords. de la espiral. */ __asm__ __volatile__(" .data \n\t" \ " Lradio: \n\t" \ " .double 3.0 \n\t" \ " Lcentro: \n\t" \ " .double 150.0 \n\t" \ " .text \n\t" \ " fldl -48(%ebp) \n\t" \ " fld %st(0) \n\t" \ " fldl Lradio \n\t" \ " fmulp %st(2) \n\t" \ " fsincos \n\t" \ " fmul %st(2) \n\t" \ " fxch %st(2) \n\t" \ " fmulp %st(1) \n\t" \ " fldl Lcentro \n\t" \ " fadd %st(0), %st(2) \n\t" \ " faddp %st(1) \n\t" \ " fstpl -40(%ebp) \n\t" \ " fstpl -32(%ebp) \n\t"); /* Eliminamos puntos fuera del BMP. */ if ((x > 300) || (x < 0)) continue; if ((y > 300) || (y < 0)) continue; /* Incluimos el punto en la imagen. */ j = (unsigned long) y; i = (unsigned long) (x/8); imag[(LIN*j)+i] &= ~masc[((unsigned long) x)%8]; } /* Escribe imagen en fichero. */ for (i = 0 ; i < TAM ; i++) putc(imag[i], fich); free(imag); fclose(fich); exit(0); } /********************* Fin de bitm2.c **************************/ ---| 3.2 Introduccion al metodo ortodoxo. El metodo Frankenstein esta sujeto a muchos fallos y es muy dificil de mantener. Si recordamos el ejemplo de la seccion 3.1.3, los problemas que afrontamos fueron: 1) Localizar las variables de entrada para nuestro codigo. 2) Localizar las variables de salida para nuestro codigo. 3) Asegurarnos de que no modificamos accidentalmente ningun dato necesario para el compilador. El metodo 'ortodoxo' de ensamblado inline nos permite instruir a 'gcc' para que resuelva automaticamente estos tres problemas. Intuitivamente, puedes suponer que se produce la siguiente situacion: tu codigo, con su informacion sobre 1), 2) y 3), es como una burbuja de codigo ensamblador metida entre el codigo ensamblador generado por 'gcc'. Dado que se tiene toda la informa- cion sobre como interactua ese codigo, en una fase avanzada de la compilacion el gcc rompe la burbuja de tu codigo y lo mezcla todo, optimando el resultado total. La desventaja del metodo Frankestein es que la burbuja injertada es irrompible y, si entra en conflicto con el compilador, puede llegar a convertirse en un cancer para el codigo. La anterior metafora puede hacernos suponer que nos vamos a comunicar con el compilador a un nivel bastante bajo. Tendremos que darle los datos bastante masticados para que los entienda. La cosa puede ser complicada si queremos injertar fragmentos de codigo muy largos; pero recuerda que estos ultimos casos los puedes meter en subrutinas (ver Parte 2). El tema es delicado, asi que comencemos con algunos casos simples antes de pasar a la situacion general. Lee esta seccion de una vez, intentando captar la idea general; mas adelante se iran perfilando los detalles. En primer lugar, recordemos el codigo que usamos en la seccion 3.1 para incluir etiquetas en un programa C: __asm__(" .globl _brk ; _brk: "); Este injerto no tiene datos de entrada, no tiene datos de salida y no modifica el contenido de ninguna variable ni registro: sencillamente es una directiva y una etiqueta. Toda la informacion generada son algunos simbolos para el linkador, asi que no nos tenemos por que preocupar de que el compilador se indigeste. Este codigo no necesita modificacion para ser 'ortodoxo'. Vamos a ver un ejemplo mucho mas ambicioso. Recuerda el ejemplo de 3.1.3, el codigo en coma flotante. Alli nuestro problema era que el codigo necesitaba incorporar ciertas variables de C, y no sabiamos muy bien como hacerlo. Observa que diferente es la aproximacion ortodoxa de la Frankestein: Sustituyamos el calculo de las coordenadas de la espiral en 'bitm.c' por las tres lineas (guardalo en 'bitm3.c'): __asm__("fsincos" : "=t" (x), "=u" (y) : "0" (t)); x = (x * t * RADIO) + 150.0; y = (y * t * RADIO) + 150.0; Lo que hemos hecho es: a) Ejecutamos 'fsincos', con dato de entrada 't' y datos de salida 'x' e 'y'. Indicar al compilador estas entradas y salidas se logra con los comandos que van con los ':' al final de la primera linea (veremos la sintaxis de estos comandos algo mas abajo; por ahora olvidate de ello). b) Ahora que x == Sen(t) e y == Cos(t), hacemos el calculo normal de las coordenadas, en las lineas restantes. La ventaja de las tres lineas que tenemos arriba es que podemos compilar el codigo con cualquier nivel de optimacion, dado que el compilador se encargara de hacer los arreglos. Por ejemplo, usando 'gcc -O6' el codigo marcha a la perfeccion. Dado que a este nivel de optimacion el compilador opera perfectamente con sumas y productos, la unica optimacion relevante es la que le hemos pedido, es decir, que use 'fsincos'. Compila este programa con 'gcc -O6 -S' y observa hasta que punto optima el compilador el resto del codigo. Se ha logrado un resultado al menos tan bueno como nuestro Frankestein sin necesidad de escribir mas que una linea en ensamblador. La idea del ensamblador inline bien hecho se reduce muchas veces a insinuar a 'gcc' que debe usar un comando complicado (como 'fsincos'), dejandole que haga el resto del trabajo pesado. Tambien puedes introducir fragmentos largos de ensamblador en el codigo, pero puede resultar una mala politica. Lograr una eficiencia como la de gcc con instrucciones de proposito general puede llevarte un tiempo que puedes emplear en otras cosas mejores. Veamos otro ejemplo, que personalmente siempre he echado de menos en los lenguajes de alto nivel. A menudo, cuando operamos con C queremos saber si cierta instruccion de suma ha producido un acarreo, por ejemplo cuando se opera con enteros en precision arbitraria. Veamos una solucion al problema del acarreo: #include main() { unsigned short i, j, sum, acarreo; i = 40000; j = 50000; printf("Sumando %d y %d:\n",i,j); __asm__(" xorw %0, %0 \n\t" \ " xorw %1, %1 \n\t" \ " movw %2, %1 \n\t" \ " addw %3, %1 \n\t" \ " adcw %0, %0 " : "=r" (acarreo) , "=r" (sum) : "rm" (i) , "rm" (j) ); printf("\nSuma : %d\n", sum); printf("Acarreo: %d\n", acarreo); exit(0); } Introduce este codigo en un fichero ('aca.c') y compilalo: demeter# gcc -o aca aca.c demeter# ./aca Sumando 40000 y 50000 Suma : 24464 Acarreo: 1 demeter# Una vez mas es un ejemplo tonto, pero que implementa una funcionalidad que a C le resulta dificil imitar. Observa que los comandos en ensamblador operan sobre %0, %1, %2 y %3. Esto es natural, dado que no sabemos como va a llamar el compilador a los argumentos de las instrucciones en ensamblador: la solucion es darles los nombres simbolicos %0, %1, %2 y %3. La informacion que se incluye al final se encargara de decirle al ensamblador como sustituir esos simbolos. Veamos brevemente como compila esto gcc. La zona interesante es la siguiente (haz un 'gcc -S'): /APP xorw %dx, %dx xorw %ax, %ax movw -2(%ebp), %ax addw -4(%ebp), %ax adcw %dx, %dx /NO_APP De esto podemos deducir que el compilador ha hecho las sustituciones: %0 == %dx %1 == %ax %2 == -2(%ebp) ( la variable 'i' ) %3 == -4(%ebp) ( la variable 'j' ) Siguiendo el codigo en ensamblador un poco mas, podemos deducir que %ax va a parar a la variable 'sum' y que %dx va a parar a 'acarreo'. ?Como se corresponde esto con las sugerencias que hemos hecho en el __asm__? Veamos la sintaxis y el significado de las mismas. Tenemos: : "=r" (acarreo) , "=r" (sum) : "rm" (i) , "rm" (j) a) La primera linea corresponde a los argumentos de salida del injerto, en tanto que la segunda linea corresponde a las entradas. Cada linea va precedida siempre por un ':'. Si no hubiera salidas, aun asi habria que poner el ':', para que el compilador no se confunda. b) Los argumentos dentro de cada linea (sean entradas o salidas) son separados por comas, en caso de haber varios. c) Cada argumento consta de dos partes: c1) La primera, entre comillas, indica como se escribe el argumento dentro del injerto en ensamblador; es decir, cuando se sustituyen %0, %1, etc., el compilador debe saber si cada uno de esos simbolos tiene que sustituirse por un registro, por una referencia a memoria, por un registro de la FPU, por una constante, etc. c2) La segunda, entre parentesis, indica a que variable del programa en C se corresponde esa entrada o salida. Ejemplos: "rm" (i) --- esto indica que la variable 'i' debe meterse en el injerto como una referencia a memoria o como un registro, que es lo significa la cadena "rm". "=r" (sum) --- esto indica que la variable 'sum' debe meterse en el injerto como un registro. Por convenio, toda salida lleva siempre un signo '=' metido en la primera parte. Asi, 'registro y salida' se escribe "=r". Mas adelante veremos las posibilidades mas comunes para la parte c1). d) El compilador asocia los simbolos %0, %1, %2, %3... a los argumentos de entrada y salida por orden de aparicion. Asi, tenemos que: Simbolo: --> Corresponde a: -> En el injerto: --> En el .s: %0 "=r" (acarreo) sera un registro %dx %1 "=r" (sum) sera un registro %ax %2 "rm" (i) registro o memoria -2(%ebp) %3 "rm" (j) registro o memoria -4(%ebp) dado que es en este orden en el que hemos introducido los datos en las lineas de entrada y salida. Como puedes ver, el compilador tiene a veces opciones para elegir, como en el caso "rm": es gcc quien decide si el simbolo %2 debe ser rellenado con un registro o con una referencia a memoria, segun convenga. En nuestro ejemplo se ha inclinado por una referencia a memoria. Es aconsejable dejar esta flexibilidad al compilador, puesto que los registros no son muy abundantes en los ix86. A veces es necesario restringir estas opciones. Por ejemplo, en las entradas solo permitimos referencias a registros, dado que la instruccion 'xor %0, %0', por ejemplo, no podria aceptar que se sustituyese %0 por una referencia a memoria. Este tipo de instrucciones con argumento repetido solo funcionan con registros (por ejemplo, 'xor (%ebp), (%ebp)' es ilegal para los microprocesadores ix86). De acuerdo. La primera vez que uno lee esto resulta muy complicado. Mi sugerencia es que recuerdes que en realidad le estamos comunicando al compilador una informacion muy sencilla: entradas y salidas. Ponte en el lugar del compilador e imagina que es lo que necesitas saber para cada entrada o salida: 1) A que variable de C corresponde esa entrada o salida. 2) Que simbolo asocio a esa entrada o salida. 3) Como sustituyo ese simbolo en el codigo ensamblador. Nada mas. Ahora hay que hacerse a la curiosa notacion que han escogido los desarrolladores de gcc para especificar esta informacion. Pero esto no es mas que la parte burocratica; echale un poco de tiempo y basta. Antes de entrar en una descripcion mas detallada del formato, veamos un ultimo ejemplo. En la primera parte de este tutorial hablamos de la manera de introducir instrucciones no soportadas por el ensamblador, tales como 'rdtsc'. Esta instruccion, presente en la mayor parte de los micros de la serie i686, carga en el par de registros %edx:%eax la cantidad de ciclos de reloj transcurridos desde el arranque del microprocesador (modulo 2 elevado a 64). Esto quiere decir que, por ejemplo, el contador de rdtsc (llamado TSC, 'time stamp counter') se incrementa en 700 millones de unidades cada segundo en mi K7-700. La instruccion rdtsc permite hacer mediciones de tiempo muy precisas, o calcular el numero de ciclos de reloj transcu- rridos, aproximadamente, entre dos eventos. Para implementar 'rdtsc' en ensamblador inline, tengamos en cuenta que esta instruccion no tiene argumentos de entrada, no corrompe ningun registro, y como registros de salida tiene especificamente a %edx y %eax. Si miramos la documentacion de 'gcc' (www.gnu.org), en la seccion de 'Extensiones al lenguaje C', subseccion de 'Restricciones para maquinas concretas' (en ingles), podemos encontrar que la manera de indicar al gcc que una entrada o salida corresponde al par %edx:%eax es usando "A". Por tanto, el comando ensamblador sera: /* rdtsc */ /* salidas */ __asm__(".byte 0x0f, 0x31" : "=A" (lectura)); Donde 'lectura' sera la variable que nos interese. Veamos un ejemplo: #include #include main() { unsigned long long lectura1, lectura2; double x,y; __asm__(".byte 0x0f, 0x31" : "=A" (lectura1)); for (x = 0.0 ; x < 10.0 ; x += 0.01) y = exp(x); __asm__(".byte 0x0f, 0x31" : "=A" (lectura2)); lectura2 = (lectdura2 - lectura1) / 1000; printf("Numero de ciclos: %d\n", lectura2); exit(0); } Tras guardar esto en el fichero 'rdtsc.c', lo compilamos y ejecutamos: demeter# gcc -lm -o rdtsc rdtsc.c demeter# ./rdtsc Numero de ciclos: 230 demeter# Es decir, que al micro le lleva en torno a 230 ciclos el ejecutar cada exponencial en el bucle, lo cual es razonable teniendo en cuenta que cada llamada a 'exp' es canalizada por la libreria matematica. NOTA : Como curiosidad, he compilado y ejecutado este programa en linux, FreeBSD y Windows 98. En los dos primeros los tiempos de ejecucion varian entre 210 y 240 ciclos, en tanto que en Win98 el tiempo ronda los 310 ciclos. La libreria matematica de Win98 no es todo lo eficiente que se podria desear. Veamos que sucede si calculamos la exponencial directamente en ensamblador. Si sustituimos la linea 'y = exp(x)' por un equivalente en ensamblador, obtenemos: #include main() { unsigned long long lectura1, lectura2; double x,y; __asm__(".byte 0x0f, 0x31" : "=A" (lectura1)); for (x = 0.0 ; x < 10.0 ; x += 0.01) { __asm__(" fldl2e \n\t" \ " fmulp \n\t" \ " fld %0 \n\t" \ " fld %0 \n\t" \ " frndint \n\t" \ " fsubrp \n\t" \ " f2xm1 \n\t" \ " fld1 \n\t" \ " faddp \n\t" \ " fscale \n\t" \ " fxch \n\t" \ " fstp %0 \n\t" : "=t" (y) : "0" (x) ); } __asm__(".byte 0x0f, 0x31" : "=A" (lectura2)); lectura2 = (lectura2 - lectura1) / 1000; printf("Numero de ciclos: %d\n", lectura2); exit(0); } Guardando el resultado en 'rdtsc2.c' y compilando, obtenemos: demeter# ./rdtsc2 Numero de ciclos: 119 Lo que ciertamente es una mejora. Como es natural, la libreria matematica da ciertas seguridades (comprobacion de errores sobre todo) que no se deben despreciar a la ligera. Pero si tus calculos no son de caracter cientifico, siempre te puedes apoyar en el ensamblador para acelerar enormemente tus programas de calculo en coma flotante. Si observas el ejemplo de arriba, lo que tenemos es en la practica un hibrido entre ensamblador y C. El C se encarga de gestionar variables y estructuras reiterativas mientras nosotros implementamos lo interesante en ensamblador. Vaaale, quizas este tipo de programas no son muy portables, pero a fin de cuentas, como dice el Fortune File, "Portable == Inutil en cualquier maquina". ---| 3.3 Implementacion del metodo ortodoxo. Ocupemonos pues de la implementacion del ensamblado 'inline' como Dios manda. El formato general del comando __asm__ es el siguiente: __asm__ (" ... " : [entradas] : [salidas] : [corrupto] ); Donde: [entradas] : Se refiere a las entradas en el codigo ensamblador. [salidas] : Se refiere a las salidas del codigo ensamblador. [corrupto] : Se refiere a los datos/registros corrompidos por el codigo ensamblador. En 'jargon', corrupto se traduce como 'clobbered' (vapuleado). Cada uno de estos tres campos puede constar de cero o mas partes, cada una de ellas denotando una correspondencia entre un simbolo en el codigo ensamblado y un dato en el programa en C. En caso de haber varias partes en un campo, se separan mediante comas. Cada parte en los dos primeros campos (entradas y salidas) tiene la forma general: "fmt" (var) donde: (var) : indica a que corresponde esta parte en el programa en C. No tiene por que ser una variable. Tambien puede ser, por ejemplo, una constante. Lo importante es que la expresion entre parentesis se refiere a la expresion de C que queremos meter en el codigo en ensamblador. "fmt" : indica como se mete en el ensamblador la expresion de C que hemos indicado con (var). Hay muchas maneras de meter simbolos: como constantes, como referencias absolutas de memoria, como referencias de memoria inde- xadas, como un registro arbitrario, como un registro concreto, como un par de registros, como un registro de la FPU, y algunas mas. La forma del tercer campo (corrupto) es una lista de nombres de registros (entre comillas y sin el signo '%') que son corrompidos como efecto colateral de nuestro codigo. Veremos ejemplos de corrupcion de registros en la seccion 3.3.5. Asi pues, la forma mas general del comando __asm__ es: __asm__("..." ["..." ... ] [ : ["fmt" (var) [, ...] ] ] [ : ["fmt" (var) [, ...] ] ] [ : ["reg" [, ...] ] ] ); Observa la logica y la simplicidad de la notacion. Como ya hemos indicado antes, hay que decirle a 'gcc' que pasa con las entradas, las salidas y la informacion corrupta. Pues bien, de esto se encarga cada uno de los tres campos. Ahora bien, para un dato de entrada, salida o corrupto, siempre tenemos que indicar al menos dos cosas: que forma tiene ese dato en el ensamblador y que forma tiene ese dato en el C. Una vez que el compilador sabe ambas cosas, no tiene mas que sustituir simbolos. De hacer esa doble identificacion se encargan los pares '"fmt" (var)'. Bien, ahora sabemos la estructura de los tres campos, pero queda una cuestion por resolver. Dado que el ensamblador debe tener una idea clara de _absolutamente_todo_ dato que usamos en nuestro codigo ensamblador, no tiene sentido usar datos especificos en el codigo ensamblador. Por ejemplo. Supongamos que queremos meter en la variable 'dato' (tipo long) la cantidad decimal 7. Si sabemos que el codigo almacena 'dato' en -4(%ebp), podriamos hacer: __asm__(" movl $7, -4(%ebp)" : "=m" (dato) ); y considerar que esto esta bien hecho. A fin de cuentas, hemos indicado al compilador que nuestro codigo tiene como dato de salida una referencia de memoria indexada (de ahi el "=m"), que va a caer en la variable 'dato'. Pero esto es una metedura de pata. Lo que necesitamos es que el compilador _llene_ nuestro codigo con la posicion correcta de 'dato', no con la que nosotros le impongamos. Esto implica que el codigo ensamblador debe ir escrito en forma de 'plantilla', de modo que el compilador lo llene con los datos correctos. Para esto se emplean los simbolos %0, %1, ... hasta %10 (hasta %30 en las versiones avanzadas de gcc). Por ejemplo: __asm__(" movl $7, %0 " : "=m" (dato) ); es correcto, y le dice al compilador que coja la variable 'dato', la incruste en el codigo ensamblado en forma de acceso indexado a memoria en el lugar donde esta '%0', y que tenga en cuenta que 'dato' es variable de salida para este codigo. Resumiendo: el formato __asm__ bien hecho esta compuesto de: 1) Tres campos en el formato arriba indicado, que indican los datos de entrada, salida y corruptos. 2) Un codigo ensamblador en forma de plantilla, que va entre comillas al principio del comando __asm__. Los 'huecos' de la plantilla se indican %0, %1, etc. Una pregunta importante es como reconoce 'gcc' a que dato corresponden %0, %1, %2, etc. La cosa es facil: gcc va nombrando los datos por orden segun los va leyendo. Por ejemplo, en el codigo (que ya vimos en la seccion anterior): __asm__(" xorw %0, %0 \n\t" \ " xorw %1, %1 \n\t" \ " movw %2, %1 \n\t" \ " addw %3, %1 \n\t" \ " adcw %0, %0 \n\t" : "=r" (acarreo) , "=r" (sum) : "rm" (i) , "rm" (j) ); tenemos 4 datos entre entradas y salidas, es decir, que habra que definir simbolos desde %0 hasta %3. Entonces, tendremos que %0 se corresponde con "=r" (acarreo), %1 con "=r" (sum), %2 con "rm" (i) y %3 con "rm" (j). Se leen de izquierda a derecha, segun van apareciendo. Si se entiende lo que hemos visto hasta ahora en esta seccion, se tiene buena parte del camino andado. La idea general, resumida una vez mas: 1) Escribe el ensamblador usando simbolos, de manera que sea el compilador el que los rellene. 2) Una vez que tienes tu ensamblador escrito con simbolos, escribe las lineas de entradas, salidas y corruptos de manera que esos simbolos sean sustituidos adecuada- mente (por registros, refs. a memoria, etc.) y se correspondan con los datos en C que te interesan. Y eso es todo. Observa el ejemplo que acabamos de ver arriba. Para construirlo, en primer lugar compuse el codigo en ensamblador usando simbolos. Me quedo: xorw %0, %0 xorw %1, %1 movw %2, %1 addw %3, %1 adcw %0, %0 Ahora, yo sabia que una instruccion tipo 'xor , ' solo puede ser sustituida correctamente si es un registro. De manera que introduje en la primera linea de las salidas "=r", que obliga a 'gcc' a sustituir %0 por un registro de su eleccion. Ademas, como %0 debia ser al final del ensamblador el valor del acarreo, le pedi a gcc que almacenase %0 en la variable 'acarreo', lo cual se logra con poner (acarreo) despues del "=r". Con esto ya podia estar seguro de que no tenia que preocuparme mas por %0. Con %1, %2 y %3 procedi exactamente igual. El resultado final fue el codigo completo, tal y como fue empleado en la seccion anterior. 3.3.1 Simbolos solapados. Aunque el formato expuesto hasta ahora es conceptualmente muy sencillo, cuando uno echa las cosas a correr siempre aparecen algunas complicaciones muy naturales que a primera vista no se toman en cuenta. Piensa en el siguiente ejemplo: queremos hacer una diminuta linea de ensamblador que calcule el 'not' (complemento a 1) de una palabra. En principio podriamos hacer la construccion: __asm__("notl %0" : "=r" (var1) : "r" (var2)); Pero el problema que aparece es el siguiente: como el registro de entrada es el mismo que el de salida, el compilador se va a hacer un lio. Tal y como hemos escrito el codigo, 'gcc' va a definir los simbolos %0 y %1, y se mostrara muy confundido cuando vea que el codigo solo emplea %0. Una solucion aceptable seria hacer lo siguiente: __asm__("movl %0, %1 ; notl %1" : "=r" (var1) : "r" (var2)); Esto es perfectamente correcto, pero no siempre es aplicable (por ejemplo, al usar la FPU no se tiene tanta facilidad para echar mano de registros adicionales). --> El problema es por tanto: como decirle a 'gcc' que un simbolo --> de salida coincide con un simbolo de entrada. Naturalmente, esto tiene solucion; de hecho, ya hemos visto algun ejemplo en las secciones anteriores. La solucion es simplemente indicar en el argumento repetido el numero de la etiqueta que se repite (sin el '%'). Por ejemplo: __asm__("notl %0" : "=r" (var1) : "0" (var2)); Con esto el compilador se da cuenta de que '%0' es compartido por la entrada y la salida, dado que en la entrada aparece "0" como formato, haciendo referencia a %0. Veamos algunos ejemplos que empleamos anteriormente: __asm__("fsincos" : "=t" (x), "=u" (y) : "0" (t)); En este caso, los formatos "t" y "u" indican respectivamente la primera y la segunda posiciones en la pila de la FPU. El fragmento en ensamblador espera la entrada de 't' en la primera posicion de la pila y, tras la instruccion 'fsincos', se pone Sen(t) en la segunda y Cos(t) en la primera posiciones de la FPU. De manera que la variable de salida 'x' y la de entrada 't' comparten la primera posicion en la pila de la FPU. Por este motivo, se usa el "0" para indicar el solapamiento entre la entrada y la salida. (Este tipo de complicaciones es tipico de las operaciones con la FPU; como ejemplo es un tanto enrevesado). Otro ejemplo de la misma naturaleza es la verion en ensamblador de 'y = exp(x)': __asm__(" fldl2e \n\t" \ " fmulp \n\t" \ " fld %0 \n\t" \ " fld %0 \n\t" \ " frndint \n\t" \ " fsubrp \n\t" \ " f2xm1 \n\t" \ " fld1 \n\t" \ " faddp \n\t" \ " fscale \n\t" \ " fxch \n\t" \ " fstp %0 \n\t" : "=t" (y) : "0" (x) ); Una vez mas, la entrada y la salida comparten el registro %st(0) de la FPU, de manera que se usa el solapamiento. Veamos un ultimo ejemplo, tonto pero instructivo: un fragmento de ensamblador que calcula el complemento a 1 de una variable y el complemento a 2 de otra: __asm__(" notl %0 \n\t" \ " negl %1 " : "=r" (var1) , "=r" (var2) : "0" (var3) , "1" (var4) ); En este caso tenemos dos solapamientos, el de %0 y el de %1. Como puedes ver, esta situacion ha sido cubierta con facilidad y elegancia por 'gcc'. Ningun problema por esta parte. 3.3.2 Formatos mas comunes. Hasta ahora hemos recurrido a los ejemplos para aprender los formatos "=" (salida), "r" (registro), "m" (memoria indexada), "f" (registro de la FPU), "t" (primer registro de FPU), "u" (segundo registro de FPU), y los de solapamiento "0", "1", etc. Tambien hemos observado (en 3.2) que se pueden combinar varias sugerencias en la misma cadena, como "rm" (registro o memoria indexada). Pueden hacerse muchas otras combinaciones, siempre que tengan sentido, como "=rm" o "mf", etc. En la documentacion en ingles nuestros 'formatos' se traducen como 'constraints' (restricciones). Para informarse sobre todos los tipos importantes, incluyendo algunos a medio documentar (como los registros SSE), consulta la documentacion de referencia del 'gcc'. FORMATOS GENERICOS DE LA CPU: "r" : cualquier registro de la CPU (%eax, %ebx, etc.) "m" : una referencia a memoria ( p. ej. -8(%eax, %ebx, 2) ). "q" : un registro que que puede operar con bytes. Es decir, %eax, %ebx, %ecx o %edx. "A" : el par de 64 bits %edx:%eax. Tambien vale para indicar uno cualquiera de los dos, %edx o %eax. REGISTROS ESPECIFICOS DE LA CPU: "a" : %eax, %ax, %ah, %al. "b" : %ebx, %bx, %bh, %bl. "c" : %ecx, %cx, %ch, %cl. "d" : %edx, %dx, %dh, %dl. "D" : %edi, %di. "S" : %esi, %si. CONSTANTES ENTERAS: "i" : una cantidad inmediata de 32 bits. "n" : una cantidad inmediata conocida en tiempo de compilacion. "I" : una constante entera entre 0 y 31. Se usa para indicar desplazamientos (shl, ror, etc.) "J" : una constante entera entre 0 y 64. Se usa para desplazamientos de 64 bits. "K" : equivale a 0xff. "L" : equivale a 0xffff. "M" : 0, 1, 2 o 3. Util para el factor de escala en 'lea', o en cualquier direccionamiento indexado. "N" : constante entera entre 0 y 255. REGISTROS DE LA FPU: "f" : cualquier registro de la FPU. "t" : el TOS ('top of stack') de la FPU: %st(0). "u" : %st(1). "G" : una constante en doble precision estandar para el 387. "F" : una constante en doble precision. COSAS CURIOSAS: "x" : un registro MMX. "y" : un registro SSE. Gracias a estos formatos podemos especificar mediante simbolos practicamente cualquier cosa. En la siguiente seccion veremos ejemplos que se corresponden con los mas interesantes de estos formatos, pero antes veamos otro tipo de formatos, de los que forma parte el "=" que vemos tan a menudo en los argumentos de salida: MODIFICADORES: "=" : cuando se indica con un formato, se da a entender que el simbolo es escrito pero no leido por el codigo en ensamblador. Es comun usar "=" como formato para cualquier salida, sobre todo en versiones mas viejas de gcc. "+" : esto indica que el argumento es leido y escrito por el codigo. Es mas restrictivo que "=", dado que implica que 'gcc' no puede utilizar este simbolo como almacena- miento intermedio antes de ser escrito de manera definitiva. Me parece que el compilador se porta de manera caprichosa con esta opcion; es casi mejor pasar de ella. Aun asi, en ocasiones funciona. Existen otros modificadores, pero corresponden a casos de mucha mas sutileza, y no son criticos. Observa la documentacion para mas detalles. 3.3.3 Ejemplos de formatos de la CPU. Ya hemos visto ejemplos del uso de "r", "m", "A", "=", "f", "t" y "u". Tambien de algunas combinaciones, como "=r" y "rm". Acerca de la FPU hablaremos con mas detalle en 3.3.4, asi que por ahora nos concentraremos en la CPU. En primer lugar, veamos que pasa con las operaciones a nivel de byte. Si queremos usar registros que funcionen con bytes es importante que nos limitemos a "q", "a", "b", "c" o "d". Por ejemplo: __asm__("negb %0" : "=q" (var1) : "0" (var2)); __asm__("negb %0" : "=a" (var1) : "0" (var2)); curiosamente, he intentado compilar infringiendo la regla, con: __asm__("negb %0" : "=S" (var1) : "0" (var2)); y el compilador, sabiamente, ha cambiado %esi por %al de manera automatica. Aun asi es mejor no tentar a la suerte. Veamos un uso de constantes raras. Mete esto en un codigo en C: #define FACT 2 main() { long var1, var2; __asm__(" shll %1, %0 " : "=a" (var1) : "I" (FACT), "0" (var2) ); } Si lo compilas con '-S' y te fijas en la zona injertada, tenemos: /APP shll $2, %eax /NO_APP que es justo lo que le pedimos. Esto es un buen ejemplo del uso de un formato raro como "I". De paso te permite ver como se pueden meter definiciones del preprocesador en el ensamblado inline. Vamos a echar una mirada a las constantes inmediatas. Prueba a compilar con 'gcc -S' el siguiente codigo: #define BASE 23144 main() { long var1; __asm__("movl %1, %0" : "=m" (var1) : "i" (BASE + 69)); } compliando y editando el ensamblador obtenemos: /APP movl $23213, -21(%ebp) /NO_APP Esto da un ejemplo de cantidad inmediata de 32 bits, y de operaciones dentro de los valores introducidos en el ensamblador, mezclado con preprocesamiento. Puedes desarrollar ejemplos mucho mas complejos. Llegados a este punto, supongo que te haces una idea de como se manipula el resto de los formatos. Sencillamente, siempre hay uno (o quizas varios) formatos adecuados para una cierta situacion. Uno debe ser lo menos restrictivo posible con el tipo de formato, para no contrarrestar la capacidad de optimacion del 'gcc'. 3.3.4 Ejemplos de formatos de la FPU. La FPU de los x86 es bastante curiosa. Si llevas algun tiempo en el mundo de la informatica, es posible que recuerdes FORTH, un genial lenguaje interpretado que empleaba varias pilas en sus accesos a la memoria, en vez de accesos aleatorios a traves de variables. La FPU de los x86 fue concebida de la misma manera, lo que la hace francamente entretenida de programar. Quizas un fallo de los lenguajes tipo FORTH es que son poco concurrentes, dado que es muy dificil paralelizar instrucciones que acceden a una pila. Lo que nos ocupa ahora es que la estructura de pila de la FPU hace muy dificil el andar jugando con registros especificos de la FPU. Mientras que con la CPU podiamos especificar alegremente que registro de proposito general queriamos usar, esto no es factible con la FPU (salvando %st(0) y %st(1) que, como vimos, se pueden indicar con los formatos "t" y "u"). Sin embargo, en muchas situacio- nes es suficiente con poder usar %st(0) y %st(1), si se programa con cuidado. Ensamblar inline con la FPU se parece un poco a lo que haciamos en la Parte 2 con las subrutinas. En los argumentos de entrada le decimos al 'gcc' que datos queremos que nos meta en la pila, mientras que en las salidas le decimos que datos hemos dejado en la pila para que los recoja. Un ejemplo lo tenemos en el caso que ya hemos visto: __asm__("fsincos" : "=t" (x), "=u" (y) : "0" (t)); El dato de entrada, "0" (t), le dice al compilador que nuestro codigo en ensamblador asume que en %st(0) se encuentra la variable 't'. Entonces, 'fsincos' toma de la pila este valor y devuelve Cos(t) en %st(0) y Sen(t) en %st(1). Los argumentos de salida le dicen al compilador que puede recoger de esas dos posiciones de la pila los valores de 'x' e 'y'. El otro caso que hemos visto, el de la exponencial, es tambien interesante: __asm__(" fldl2e \n\t" \ " fmulp \n\t" \ " fld %0 \n\t" \ " fld %0 \n\t" \ " frndint \n\t" \ " fsubrp \n\t" \ " f2xm1 \n\t" \ " fld1 \n\t" \ " faddp \n\t" \ " fscale \n\t" \ " fxch \n\t" \ " fstp %0 \n\t" : "=t" (y) : "0" (x) ); Una vez mas, el dato de entrada es 'pusheado' por el compilador, que recoge de la pila el dato de salida una vez que hemos terminado. Lo interesante de este ejemplo es que durante su ejecucion se introducen y se sacan muchos otros datos de la pila (como el logaritmo de 'e' en base 2) pero, debido a que todos los datos adicionales que se introducen son sacados antes del final del injerto, el 'gcc' no tiene por que preocuparse de eso. Esto es un principio fundamental. Si te ocupas de mantener la coherencia de la pila, y te limitas a decirle al 'gcc' que meta o saque los datos que necesitas, escribir codigo para la FPU es algo realmente facil. De todos modos, hay sutilezas que debes tener en cuenta para que el compilador se entere de que manipulaciones has operado en la pila. Si estas interesado en escribir ejemplos complicados de ensamblador inline con manipulaciones en la pila un tanto exoticas (por ejemplo, cuando hay mas valores de entrada que de salida), debes consultar la documentacion de 'gcc' (parte 'Assembler Instructions with C Expression Operands') que ofrece una lista de pasos a seguir para manejar esos casos curiosos. El problema esta en que el compilador puede tener problemas para decidir si los datos que se introdujeron inicialmente en la pila fueron expulsados de ella o no por tu codigo en ensamblador. Ese tipo de circunstancias es facil de decidir, aunque introducir las reglas en un tutorial como este podria ser un tanto molesto para los que no pretenden dedicarse especificamente a programar la FPU. Mi consejo es que uses ensambles inline solo instrucciones de la FPU relativamente cortas pero exoticas, como las que hemos visto, en vez de escribir partes largas que te llevaran a complica- ciones innecesarias (aunque no insalvables, en absoluto). 3.3.5 Registros corruptos. Hemos estado obviando este campo de la instruccion __asm__ hasta ahora, no porque sea complicado (no lo es en absoluto), si no porque no es muy usual encontrarse con este tipo de casos. Veamos sin embargo un primer ejemplo: supongamos que queremos hacer la instruccion 'rdtsc', pero almacenando solo los 32 bits menos significativos del par %edx:%eax, en tanto que nos importa poco que sucede con los bits mas significativos. En otras palabras, nos quedaremos con el contenido de %eax, despreciando lo que haya en %edx (esta situacion puede aparecerte generando numeros aleatorios mediante temporizadores de mucha precision). En este caso, tenemos que advertir a 'gcc' que el registro %edx, aunque no forma parte de nuestras entradas ni salidas, ha sido alterado. Esto se indicaria como: __asm__(".byte 0x0f, 0x31" : "=a" (tiempo) : : "edx" ); El compilador asume facilmente la situacion. Observa que los registros corruptos van, como ya indicamos en 3.3, con su nombre completo y sin el '%' de prefijo. Vamos a ver un caso mucho mas divertido. Ejecutemos la instruccion 'cpuid' y extraigamos la familia, modelo y 'stepping' del micro que estamos empleando. Para ello hay que ejecutar las instrucciones en ensamblador: movl $1, %eax cpuid Despues de estos comandos se tiene que %eax almacena los datos que queremos, en tanto que %ebx y %ecx quedan en estado indefinido, y %edx contiene informacion sobre las facilidades que ofrece el microprocesador. Aqui tenemos casos sobrados de corrupcion. Vamos a hacer un programilla que nos diga algo sobre el micro. Guarda el siguiente codigo en un fichero ('cpuid.c'): #include main() { unsigned long id, carac; /* Esto hace el cpuid */ __asm__(" movl %2, %0 \n\t" \ " .byte 0x0f, 0xa2 " : "=a" (id) , "=d" (carac) : "i" (1) : "ebx" , "ecx" ); /* Ahora escribimos algo de informacion. */ printf("Informacion del microprocesador:\n"); printf(" Familia : %d\n", ((id & 0xf00)>>8)); printf(" Modelo : %d\n", ((id & 0xf0)>>4)); printf(" Stepping: %d\n\n", (id &0xf)); /* Ahora deducimos alguna facilidad del micro. */ if (carac & (1<<23)) printf("El micro soporta MMX\n"); else printf("El micro no soporta MMX\n"); if (carac & (1<<15)) printf("El micro soporta CMOV\n"); else printf("El micro no soporta CMOV\n"); if (carac & (1<<5)) printf("El micro soporta MSRs\n"); else printf("El micro no soporta MSRs\n"); if (carac & (1<<25)) printf("El micro soporta XMM\n"); else printf("El micro no soporta XMM\n"); exit(0); } Compilamos y ejecutamos: demeter# gcc -o cpuid cpuid.c demeter# ./cpuid Informacion del microprocesador: Familia : 6 Modelo : 2 Stepping: 1 El micro soporta MMX El micro soporta CMOV El micro soporta MSRs El micro no soporta XMM demeter# Lo cual es correcto. Mi micro es uno de los primeros AMD K7, de ahi que tenga un Modelo y Stepping tan bajos. Naturalmente, soporta MMX, CMOV y MSRs (Model Specific Registers). Pero, al ser un AMD, no soporta la basurilla multimedia de Intel; tiene su propia basurilla multimedia (3DNow! y todo eso). Desarrollando un poco este codigo se podria lograr un buen identificador de micros. ---| 3.4 Que se nos queda en el tintero. Intentar cubrir todas las opciones del ensamblado inline es un proyecto enormemente ambicioso, que no se podria conseguir sin complicar mucho este tutorial. Creo que con lo que hemos visto hasta ahora tienes, como minimo, para cubrir todos los casos importantes con los que te puedes topar en la practica. Al menos, espero haber podido dar en esta parte los funda- mentos para entender otros textos que circulan en la red (todos los que he visto estaban en ingles salvo uno, en italiano) sobre ensamblado inline. Es sorprendente lo facil que es escribir ensamblador inline una vez que uno se familiariza un minimo con la materia. Como ya he indicado, nos falta por detallar algunas opciones especiales en los formatos de entrada y salida (cosas como "%", "&", "#", etc.) Tambien nos hemos dejado en el tintero el mecanismo para tratar el ensamblado inline de la FPU en sus formas mas complicadas, pero eso es algo que puedes consultar en la documentacion 'gnu' una vez que tienes una experiencia minima. Seguro que quedan otros muchos agujeros por tapar, algunos de ellos imperdonables. Si encuentras alguno, y crees que debe taparse, enviame un mail. Si crees que puedes hacerlo tu mismo, adelante, este documento es _tu_ documento. Disfrutalo. ---------------------------| Referencias |---------------------------- He aqui lo que puedes consultar, antes, despues o durante la lectura de este tutorial. La bibliografia no es completa, pero puede ayudarte a conseguir una buena cultura elemental en el tema del ensamblador AT&T con 'gcc'. Me he tomado la libertad de comentar algunas de las referencias. ---| Jon Beltran de Heredia, "Lenguaje Ensamblador de los 80x86" Anaya Multimedia, ISBN 84-7614-622-1 Desde mi punto de vista, la mejor introduccion al ensamblador x86 en nuestra lengua. Aunque solo cubre el ensamblado en 16 bits en formato Intel (para el anticuado 'masm'), es insustituible si quieres aprender los fundamentos del ensamblador en estas maquinas. ---| Intel Corp., "Intel Architecture Software Developer's Manual. Volume 2: Instruction Set Reference". Order number 243191. El 'white paper' sobre la arquitectura x86 por excelencia. Los volumenes 1 y 3 (Order # 243190 y 243192 respectivamente) son tambien muy interesantes. Si quieres conseguirtelos en .pdf vete a la web de Intel y busca el documento. Te recomiendo que lo hagas por el 'Order number', que es la manera mas rapida de dar con el. La pagina web de AMD tambien tiene 'white papers' muy interesantes. ---| GNU, "Using the GNU Compiler Collection" Este documento cubre todos los detalles relevantes del compilador 'gcc', y puedes descargartelo desde www.gnu.org en varios formatos. Mucha de la informacion que hemos omitido por ser latoso o complicado incluirla aqui puedes encontrarla en este documento. ---| GNU, "The GNU Assembler" Otro documento de www.gnu.org. Este documento es insustituible para enterarse de las directivas y principales convenios del funcionamiento del compilador 'gas'. Siempre es bueno tenerlo a mano. ---| Colin Plumb, "A Brief Tutorial on GCC inline asm (x86 biased)" 20 April 1998. Una de esas joyas con las que te encuentras en la red. Escrito por el hacker Colin Plumb, es una fantastica introduccion al ensamblado inline, publicado originalmente en una lista de correo y actualmente disponible en varias URLs (una busqueda en google deberia localizartelo). Yo encontre una copia en www.opennet.ru, en una coleccion de documentos sobre ensamblador. Absolutamente imprescindible. ---| Aparte de estos documentos puedes encontrar muchas otras referencias, buena parte de ellas obras amateur con mas buena intencion que rigor. De todos modos, un google con 'inline assembler' arroja muchos resultados, algunos de ellos de primera linea. *EOF*