Aprender el lenguaje ensamblador
El lenguaje ensamblador es el equivalente legible para el ser humano al nivel de software más bajo de la programación de ordenadores: el código máquina.
Mientras que el ordenador entiende todos los programas como números, donde varios números diferentes instruyen al ordenador para hacer diferentes operaciones, esto es demasiado tedioso para el consumo humano (por no hablar de la autoría). Por lo tanto, los humanos programan utilizando el lenguaje ensamblador, que tiene una correspondencia casi 1:1 con el código máquina.
Aprende el lenguaje de programación C. Si conoce C (o C++), el ensamblaje será más fácil.
C# y Java son buenos lenguajes, pero hay pocas o ninguna herramienta que le muestre cómo es el código de C# y Java en lenguaje ensamblador. (De hecho, Java y C# tienen su propio lenguaje ensamblador de código de bytes que es bastante diferente, y distante, de las arquitecturas del conjunto de instrucciones de la CPU del hardware real.)
Desde el código C (y C++) podemos utilizar los compiladores para mostrarnos cómo es el lenguaje ensamblador. C es un lenguaje de bajo nivel, y si sabes cómo se traduce cada construcción en C al lenguaje ensamblador, ¡conocerás el lenguaje ensamblador! Ejemplos de las construcciones de C que queremos conocer para el lenguaje ensamblador son
Construcción en C | Equivalente en lenguaje ensamblador |
---|---|
variable local | Registro de la CPU o ubicación de memoria de la pila |
variable global | acceso global o ubicación de memoria const |
referencia de matriz | un patrón de suma y acceso a memoria |
goto | instrucción de bifurcación incondicional |
declaración if | un patrón en if-goto: prueba condicional & instrucciones de bifurcación |
bucle while | un patrón en if-goto |
asignación | modificación de registro o memoria |
instrucciones aritméticas | aritméticas |
parámetro paso | modificación de registro o memoria de pila |
recepción de parámetros | acceso a registro o memoria de pila |
función llamada | instrucción de llamada |
función de entrada | patrón de prólogo |
función de salida | patrón de prólogo |
valor de retorno | modificación del registro y epílogo |
Cómo difiere el lenguaje ensamblador de otros lenguajes de programación
En el lenguaje ensamblador, estarás expuesto a los recursos desnudos del hardware subyacente de la CPU. (Además, las cosas que podemos escribir en C en una línea requieren varias líneas en ensamblador.)
-
Estará al tanto de cosas como los registros de la CPU, y la pila de llamadas – estos recursos tienen que ser gestionados para que un programa funcione (y en otros lenguajes, estos recursos se gestionan automáticamente por nosotros.) El conocimiento de estos recursos es crítico para la llamada y retorno de funciones. (Los registros de la CPU son escasos en comparación con la memoria, sin embargo, son súper rápidos, y también consistentes en su rendimiento.)
-
No hay sentencias estructuradas en el lenguaje ensamblador – así que, todas las construcciones de flujo de control tienen que ser programadas en un estilo «If-Goto» (en otros lenguajes, el compilador traduce las sentencias estructuradas a If-Goto.) El estilo if-goto utiliza etiquetas como objetivos. (Aunque C admite el uso de goto y etiquetas, los programadores prefieren las sentencias estructuradas la mayoría de las veces, por lo que no solemos ver goto y etiquetas en C.)
Lenguaje ensamblador frente a código máquina
El lenguaje ensamblador está pensado para que los humanos lo lean y lo escriban.
Etiquetas
En el lenguaje ensamblador, utilizamos muchas etiquetas. El uso de las etiquetas se traduce en números en código máquina y las etiquetas desaparecen en el código máquina. Las etiquetas (frente al uso de números directamente) hacen posible hacer pequeños cambios en los programas – sin ellas incluso un pequeño cambio sería en realidad un gran cambio.
El procesador necesita saber dónde está un operando, quizás un operando de datos o quizás una localización de código. Para las localizaciones de código, por ejemplo, típicamente el procesador querrá saber a qué distancia están esos elementos de la localización de código que está ejecutando en ese momento – esto se llama direccionamiento relativo al PC. Así, para ejecutar una sentencia if-then, podríamos decirle al procesador: bajo ciertas condiciones, salta hacia delante N instrucciones, donde N representa el tamaño o el recuento de las instrucciones que se van a saltar (o no), es decir, N es el tamaño de la parte de entonces – eso sería en código máquina. Si añadimos una instrucción a la parte de entonces, el número de instrucciones a omitir aumenta (y si eliminamos una instrucción de la parte de entonces, disminuye).
El uso de las etiquetas tiene en cuenta automáticamente los cambios (por ejemplo añadiendo, quitando instrucciones) – una instrucción de bifurcación que utiliza una etiqueta como objetivo se adapta a los cambios en el recuento de cuántas instrucciones se van a saltar condicionalmente.
Pseudoinstrucciones
La mayoría de las instrucciones en lenguaje ensamblador se traducen 1:1 en instrucciones de código máquina. Sin embargo, ocasionalmente, tenemos que decirle al ensamblador algo que no se traduce directamente en código máquina. Ejemplos típicos son decirle al ensamblador que las siguientes instrucciones son código (normalmente mediante una directiva .text
) frente a datos (normalmente mediante una directiva .data
). Al igual que las instrucciones regulares, estas instrucciones se escriben típicamente en su propia línea, sin embargo, informan al ensamblador en lugar de generar instrucciones de código máquina directamente.
Escribiendo lenguaje ensamblador
En C, escribimos sentencias; estas sentencias se ejecutan consecutivamente una tras otra, por defecto de forma secuencial, hasta que alguna construcción de flujo de control (bucle, if) cambia el flujo. En el lenguaje ensamblador ocurre lo mismo.
En C, las sentencias tienen efectos sobre las variables – podemos considerar que las sentencias separadas se comunican entre sí a través de las variables. Lo mismo ocurre en el lenguaje ensamblador: las instrucciones se comunican entre sí a través del efecto que tienen sobre las variables, aunque como hay más instrucciones (necesariamente, ya que las instrucciones son más sencillas), también habrá necesariamente más variables, muchas de ellas son temporales de muy corta duración que se utilizan para comunicarse de una instrucción a otra.
Basic Assembly Langauge Programming
Alternamos entre la selección de registros y la selección de instrucciones. Dado que las instrucciones del lenguaje ensamblador son bastante simples, a menudo necesitamos utilizar varias instrucciones, sí, interconectadas mediante valores como variables en los registros de la cpu. Cuando esto ocurre, necesitaremos seleccionar un registro para mantener los resultados intermedios.
Selección de registros
Para seleccionar registros, necesitamos un modelo mental de qué registros están ocupados y cuáles están libres. Con ese conocimiento podemos elegir un registro libre para utilizarlo para mantener un resultado temporal (de corta duración). Ese registro permanecerá ocupado hasta que lo utilicemos por última vez, y luego volverá a estar libre; una vez que esté libre, se podrá reutilizar para algo completamente diferente. (En cierto sentido, el procesador no sabe realmente que estamos haciendo esta constante reutilización, ya que no conoce la intención del programa.)
Selección de instrucciones
Una vez que hemos establecido los recursos a utilizar, podemos elegir la instrucción específica que necesitamos. A menudo no encontraremos una instrucción que haga exactamente lo que queremos, por lo que tendremos que hacer un trabajo con 2 o más instrucciones, lo que significa, sip, más selección de registros para los temporales.
Por ejemplo, MIPS y RISC V son arquitecturas similares que proporcionan una instrucción de bifurcación &, pero sólo pueden comparar dos registros de la cpu. Por lo tanto, si queremos ver si un byte en una cadena es un determinado carácter (como una nueva línea), entonces tenemos que proporcionar (cargar) ese valor de la nueva línea (una constante numérica) en un registro de la cpu antes de que podamos utilizar la instrucción de bifurcación compare &.
Llamada a funciones & Retorno
¡Un programa consiste en quizás miles de funciones! Aunque estas funciones tienen que compartir la memoria, ésta es muy amplia, por lo que pueden llevarse bien. Sin embargo, con una CPU hay un número limitado y fijo de registros (como 32 o 16), y, ¡todas las funciones tienen que compartir estos registros!
Para que esto funcione, los diseñadores de software definen convenciones – que pueden ser pensadas como reglas – para compartir estos recursos fijos limitados. Además, estas convenciones son necesarias para que una función (un caller) llame a otra función (un callee). Las reglas se conocen como la Convención de Llamada, que identifica para el llamador/receptor, dónde colocar/encontrar argumentos y valores de retorno. Una convención de llamada forma parte de la Interfaz Binaria de Aplicación, que nos indica a grandes rasgos cómo debe comunicarse una función con el mundo exterior (otras funciones) en código máquina. La convención de llamada se define para el uso del software (al hardware no le importa).
Estos documentos nos dicen qué hacer en el paso de parámetros, uso de registros, asignación/desasignación de pila y valores de retorno. Estos documentos son publicados por el proveedor del conjunto de instrucciones, o el proveedor del compilador, y son bien conocidos para que el código de varios lugares pueda interoperar correctamente. Las convenciones de llamada publicadas deben tomarse como dadas, aunque el hardware no las conozca.
Sin dicha documentación, no sabríamos cómo hacer estas cosas (paso de parámetros, etc..). No tenemos que usar convenciones de llamada estándar, pero es poco práctico inventar las nuestras para cualquier cosa que no sea un simple programa de juguete ejecutado en un simulador. Hacer nuestra propia convención de llamadas significa que no hay interoperabilidad con otro código, lo que necesitamos hacer en cualquier programa realista, como hacer llamadas al sistema y a la biblioteca.
El hardware no sabe ni le importan las convenciones de uso del software. Simplemente ejecuta ciegamente instrucción tras instrucción (muy rápido). Por ejemplo, para el hardware, la mayoría de los registros son equivalentes, aunque los registros estén divididos o tengan asignados usos particulares por las convenciones del software.
Registros dedicados
Algunos registros de la CPU están dedicados a fines específicos. Por ejemplo, la mayoría de las CPUs de hoy en día tienen alguna forma de puntero de pila. En MIPS y RISC V el puntero de pila es un registro ordinario que no se diferencia de los demás, excepto por la definición de las convenciones de software – la elección de qué registro es arbitraria, pero sí se elige, y esta elección se publica para que todos sepamos de qué registro se trata. En x86, por otro lado, el puntero de pila es soportado por instrucciones dedicadas push
& pop
(así como las instrucciones de llamada y retorno), por lo que para el registro del puntero de pila sólo hay una elección lógica.
Parcelación de registros
Para poder compartir los demás registros entre miles de funciones, se particionan, por convención de software, normalmente en cuatro conjuntos:
- Registros para pasar parámetros
- Registros para recibir valores de retorno
- Registros que son «volátiles»
- Registros que son «no volátiles»
Parámetro &Registros de Retorno
¡Los registros de parámetros se utilizan para pasar parámetros! Al adherirse a la convención de llamada, los llamados saben dónde encontrar sus argumentos, y los llamadores saben dónde colocar los valores de los argumentos. Los registros de parámetros se utilizan para el paso de parámetros según las reglas establecidas en la convención de llamada, y, cuando se pasan más parámetros de los que la convención de la CPU permite, el resto de los parámetros se pasan utilizando la memoria de la pila.
Los registros de valores de retorno mantienen los valores de retorno de las funciones, por lo que cuando una función se completa, su trabajo es haber puesto su valor de retorno en el registro adecuado, antes de devolver el flujo de control a la persona que llama. Al adherirse a la convención de llamada, tanto el que llama como el que recibe la llamada saben dónde encontrar el valor de retorno de la función.
Registros Volátiles & Registros No Volátiles
Los registros volátiles son libres de ser utilizados por cualquier función sin preocuparse por el uso anterior de o los valores en ese registro. Son buenos para los temporales necesarios para interconectar una secuencia de instrucciones, y, también pueden ser utilizados para las variables locales. Sin embargo, ya que estos registros son libres de ser utilizados por cualquier función, no podemos contar con que mantengan sus valores a través de una llamada de función – ¡por definición (convención de software)! Por lo tanto, después de que la función regrese, su llamador no puede saber qué valores estarían en esos registros – cualquier función es libre de reutilizarlos, aunque con el conocimiento de que no podemos confiar en que sus valores se conserven después de que una función llamada regrese. Por lo tanto, estos registros se utilizan para las variables y temporales que no necesitan abarcar una llamada de función.
Para las variables que sí necesitan abarcar una llamada de función, hay registros no volátiles (y la memoria de la pila, también). Estos registros se conservan a través de una llamada de función (por el software que se adhiere a la convención de llamada). Por lo tanto, si una función desea tener una variable en un registro rápido de la CPU, pero esa variable está viva a través de una llamada (establecida antes de una llamada, y utilizada después de la llamada), podemos elegir un registro no volátil, con el requisito (definido por la convención de llamada) de que el valor anterior del registro sea restaurado al volver a su llamador. Esto requiere guardar el valor actual del registro no volátil (en la memoria de la pila) antes de reutilizar el registro, y restaurarlo (usando el valor anterior de la memoria de la pila) al regresar.