domingo, 21 de abril de 2013

Resumen T2: Programación en ensamblador para MIPS

Introducción


En esta  práctica del tema 2 "Programación en ensamblador (MIPS)" hemos aprendido a programar en el lenguaje ensamblador del procesador MIPS (R2000/R3000) usando el simulador MARS. Como haríamos uso de los elementos antes nombrados debíamos conocer algunas características, como por ejemplo:

El lenguaje ensamblador es un lenguaje que emplea códigos nemónicos para representar instrucciones (Ej: ADD Rd, Rs, Rt #Instrucción que suma) además de utilizar nombre simbólicos para referirse a datos y registros (Ej: registro $t0).

El MIPS es un procesador de 32 bits con arquitectura RISC (Esta arquitectura es un tipo de diseño de CPU que se caracteriza por instrucciones de tamaño fijo y presentadas en un reducido número de formatos además sólo las instrucciones de carga y almacenamiento acceden a la memoria de datos su objetivo es permitir el paralelismo en la ejecución de instrucciones y reducir los accesos a memoria) y un banco con 32 registros de 32 bits.


Su modelo de ejecución es del tipo Registro-Registro y su modelo de memoria corresponde el bit de menor peso con la dirección de palabra (Little-Endian).

Posee tres tipos de instrucciones (R, I y J) todas ellas de 32 bits.



El simulador MARS es una aplicación multiplataforma con licencia MIT(Massachusetts Institute of Technology) , lo que nos permite usarlo sin restricciones además de tener una funciones de depuración muy intuitivas y una interfaz bastante sencilla y cómoda que hace de la finalidad pedagógica un hecho.

Finalmente, con unos conocimientos superficiales sobre las herramientas que usaríamos se nos propusieron unos ejercicios con los que iríamos adquiriendo una serie de conocimientos.

¿Que se ha aprendido?



Bueno, la teoría está muy bien, pero ¿que hemos aprendido a hacer?
Lo primero que hay que entender, es que al contrario que en un lenguaje de alto nivel, aquí no trabajas con variables, sino con registros y posiciones de memoria por lo cual esto es lo primero que aprendemos a usar.

Uso de registros y memoria


Como hemos mencionado en la introducción, disponemos de 32 registros, cada uno de 32 bits. Esto nos permite almacenar bytes, medias palabras y palabras. 

Para hacer referencia a un registro solo tenemos que escribir el signo de dólar '$' seguido del registro a utilizar. Por convención usaremos mayormente los registros $tN para el cálculo de valores intermedios, $v0 para especificar funciones a la syscall, y $aN para pasar parámetros. Más adelante veremos que es una syscall.

Algunas de las instrucciones que hacen de operadores básicos en código ensamblador son las siguientes:

ADD $t1, $t2,$t3
Suma el contenido de $t2 y $t3 y lo almacena en $t1.
SUB $t1, $t2, $t3
Resta el contenido de $t2 y $t3 y lo almacena en $t1.
LI $t1, (valor inmediato)
Almacena el valor inmediato, en el registro $t1. Pensad en esto como una asignación tipo x=2.

El valor inmediato es un número cualquiera escrito directamente en el código; es decir no es una referencia. Los registros $t1, $t2 y $t3 se usan como ejemplo, pero pueden usarse cualesquiera de los citados anteriormente en la introducción. 

Sin embargo, en ocasiones no nos bastan los 32 registros de 32 bits para almacenar lo que necesitamos, en ese caso recurrimos a la memoria principal y trabajamos con direcciones de memoria, mediante etiquetas. Para los que hayáis programado en C, podéis entender estas etiquetas como un puntero, pero explicaré que es una en mas detalle en la reserva de memoria.

Algunas de las instrucciones para manejar datos en memoria son las siguientes:
LW $t1, (dirección de memoria)
Carga en $t1, el dato de tamaño palabra situado en la dirección de memoria dada.
SW $t1, (dirección de memoria)
Guarda la palabra almacenada en el registro $t1, en la dirección de memoria dada.

Donde “(dirección de memoria)” es un dato inmediato que puede ser dado con un número, o bien con una etiqueta. De nuevo, el registro $t1 puede ser cualquier otro. Además disponemos de variantes de estas instrucciones para cargar y guardar datos más pequeños como bytes y medias palabras (LH, LB, SH y SB).
A continuación veremos de que hablamos cuando nos referimos a una etiqueta.

Reserva de memoria


Antes de entrar en detalle en la reserva de memoria conviene explicar que es una etiqueta. Una etiqueta, es un nombre que se le asigna a una posición de memoria. Bien sea de la sección de datos, o de la sección de código, pero solo se refiere a una sola dirección de memoria que no cambia durante toda la ejecución del programa.

Cuando tenemos, bien un dato muy grande, o una serie de datos que necesitamos manipular, es recomendable usar la memoria principal. Para ello podemos reservar memoria al inicio de nuestro programa para los datos que ya sabemos vamos a manipular. 

Las reservas de memoria se especifican en la sección .data de nuestro código mediante etiquetas y tipos. Estos tipos especifican el tamaño del dato o el tipo de dato que se va a almacenar. Son:

.word – reserva una palabra
.half – reserva media palabra
.byte – reserva un byte
.ascii – reserva una cadena de caracteres
.asciiz – reserva una cadena de caracteres terminados con el carácter nulo.
.space – reserva un número especificado de bytes.

La diferencia entre .space y los demás tipos, es que con estos últimos almacenas datos justo después de la reserva, estando disponibles desde el inicio del programa. Con .space sin embargo reservas un numero de bytes que comienzan sin contenido.

Una instrucción muy útil en este punto, es la instrucción LA, que permite almacenar una dirección de memoria dada por una etiqueta en un registro para poder trabajar más cómodamente con esa dirección. Recordemos que la dirección a la que apunta una etiqueta no puede ser modificada.

Con estos conocimientos, hicimos la siguiente práctica:

#Problema 1: Cargar en los registros s0, s1, s2 y s3 el vector denominado array_media.
.data
array_media: .half 10,20,30,40
.text
.globl main
main:
la $t1,array_media #Apunta a la dirección del 1º elemento de array_media 
lh $s0,0($t1) # Carga el 1º elemento del vector en s0
lh $s1,2($t1) # Carga el 2º elemento del vector en s1
lh $s2,4($t1) # Carga el 3º elemento del vector en s2
lh $s3,6($t1) # Carga el 4º elemento del vector en s3


Implementación de estructuras de control


En ensamblador, las estructuras de control que se encuentran en los lenguajes de alto nivel no existen, han de ser implementadas por el programador. A continuación muestro cómo serían las estructuras de control if, if-else, while, do-while y for en ensamblador, comparándolas con las de C para una mejor comprensión.

IF


La estructura de control “if” puede ser implementada usando una etiqueta y un salto condicional, de forma que saltemos a la etiqueta si nuestro criterio para la ejecución del bloque “if” no se cumple.

Ejemplo C:

if(i<5){ 
//bloque if 
}
Ejemplo ensamblador:
bge $t1, 5, fin_if
#bloque if
fin_if:

IF-ELSE


Similar al anterior, solo que ahora saltamos hacia el bloque else. También necesitamos una etiqueta y un salto incondicional adicionales.

Ejemplo C:
if(i<5){
//bloque if
}
else
{
//bloque else
}

Ejemplo ensamblador:
bge $t1, 5, else
#bloque if
j fin_if #evitamos la ejecución del bloque else
else:
#bloque else
fin_if:

DO-WHILE


Para implementar un bucle while solo necesitamos un salto condicional y una etiqueta que indique el inicio del bloque del bucle. Adjunto ejemplo solo en ensamblador ya que creo que es fácil de entender.

Ejemplo ensamblador:
bucle:
#bloque del do-while
blt $t1, 5, bucle #Iteramos si el contenido de $t1 es menor que 5

WHILE


El bucle while, es como el DO-WHILE con la diferencia de un salto condicional justo antes de entrar al bloque del bucle, y una etiqueta al final del mismo.

Ejemplo ensamblador:
bge $t1, 5, fin_bucle
bucle:
#bloque while
blt $t1, 5, bucle   #salta si el contenido de $t1 es menor que 5
fin_bucle:

FOR


El bucle for solo necesitaría de un registro actuando de contador sobre la misma estructura de un bucle while, sumando un valor a este contador.

Ejemplo C:
for(i=0;i<5;i++)
{
    //bloque instrucciones
}
Ejemplo ensamblador:
bge $t1, 5, fin_bucle
bucle:
#bloque del for
addi $t1, $t1, 1 #aumentamos el contador
blt $t1, 5, bucle #salta si el contenido de $t1 es menor que 5
fin_bucle:

Syscalls, impresión de datos por pantalla e introducción de datos

Cuando se quiere imprimir datos por pantalla es necesario recurrir a lo que se llama una syscall.
Las syscalls son llamadas al sistema operativo, en este caso al usar un simulador son llamadas a este simulador, que nos proporcionan funcionalidades extra, como la introducción de datos desde teclado como la impresión de datos por pantalla, ahorrándonos trabajo repetitivo y a demasiado bajo nivel como es comunicarse con cada dispositivo del sistema para llevar a cabo la tarea.
En nuestro caso, para realizar las siguientes prácticas, nos interesó usar las funciones número 1 y 4, que imprimen por pantalla un entero y una cadena de caracteres, respectivamente.
Cabe decir que para usar una syscall el procedimiento es en la mayoría de los casos el mismo. Se almacena en el registro $v0 el número de la función que se quiere utilizar, se almacena en $a0 el dato que pide la función para trabajar y seguidamente se llama a la syscall con la instrucción del mismo nombre, syscall.

#Ejemplo 1: Imprimir por pantalla el entero 10.
.data
texto: .asciiz “El número es: “
.text
.globl main
main:
la $a0,texto #Carga la cadena texto en $a0 para imprimirla cuando hagamos el syscall.
li $v0,4 #Carga en $v0 4 para indicar que se va a imprimir una cadena de caracteres.

syscall #Llamada al systema para imprimir lo que se encuentra en $a0 del tipo $v0.

#Tras esta linea se imprime sin comillas “El número es: “
li $a0,10 #Carga en $a0 el valor del entero 10 para imprimirlo por pantalla.

li $v0, 1 #Carga en $v0 1 para indicar que se quiere imprimir un entero.

Syscall #Llamada al sistema para imprimir el tipo de carácter de $v0 y el contenido de $a0.

#Para terminar tras esta línea se imprime sin comillas “10” quedando por tanto: “El número es: 10”
Tras esto sabremos imprimir cualquier cosa por la pantalla, pero algo diferente es introducir un dato desde el exterior a nuestro programa.

Para comenzar, en $v0 se tendrá que cargar el dato 5 y hacer una llamada al sistema con syscall, tras esto se interrumpirá la ejecución del programa hasta en introduzcamos el dato, el cual en vez de guardarse en $a0 como podría ser lo normal, se sobrescribe en $v0, veamos un ejemplo mezclado con el caso anterior, donde esta vez nosotros introduzcamos el carácter y se imprima por pantalla:

#Ejemplo 2: Imprimir por pantalla un entero introducido por el usuario.
.data
texto: .asciiz “El número es: “
.text
.globl main
main:
#Primero debe de imprimirse el texto para que el valor que se encuentre en $v0 no se sobrescriba si hacemos primero la introducción del número.
la $a0,texto #Carga la cadena texto en $a0 para imprimirla cuando hagamos el syscall.

li $v0,4 #Carga en $v0 4 para indicar que se va a imprimir una cadena de caracteres.
syscall #Llamada al sistema para imprimir lo que se encuentra en $a0 del tipo $v0.

#Tras esta línea se imprime sin comillas “El número es: “ tal como en el ejemplo anterior, es ahora cuando viene lo diferente.

li $v0,5 #Carga 5 en $v0 por lo que se prepara para pedir un dato al usuario.
syscall #Llamada al sistema y se quedara esperando hasta que metamos el dato por pantalla, por ejemplo metemos 6.

li $a0,$v0 #Carga en $a0 el valor introducido por pantalla que se almacena en $v0 como antes se comentó.

li $v0, 1 #Carga en $v0 1 para indicar que se quiere imprimir un entero.

syscall #Llamada al sistema para imprimir el tipo de carácter de $v0 y el contenido de $a0.

#Para terminar tras esta línea se imprime sin comillas “6” quedando por tanto: “El número es: 6”



Recorriendo un vector


Cabe decir antes que nada, que un vector o array como tal no existe en el lenguaje ensamblador, sin embargo puede ser implementado fácilmente almacenando los datos consecutivamente en memoria.

Existen tres métodos para recorrer un vector en MIPS, para todos ellos necesitamos implementar un bucle en el que calculemos en cada iteración la dirección del siguiente elemento del vector.

Primer método, Mediante Sumas:

Este es el método más sencillo de entender y el único que se puede usar para un array de tipo .byte. El cálculo requiere tomar la dirección del primer elemento del vector como dirección base, e ir sumando a estos tantos múltiplos del tamaño que tiene el elemento del vector como elementos tiene antes del elemento al que vamos a acceder, es decir:
dirección_del_elemento=dirección_base+tamaño_elemento*posición_elemento

Con estos conocimientos, realizamos la siguiente práctica:


#Problema 6: Modificar el código anterior para que al término de la suma del array guarde en memoria el resultado, recorriendo el vector mediante sumas.

#Considere que inicialmente habrá que efectuar una reserva de memoria para esta variable a la que denominaremos "SUMA"..data
valor: .word 0xa,0xb,0x01,0x02 #Declaración del vector 10,11,1,2
suma: .word #Declaración de la variable suma
.text
.globl main
main:
move $t0,$zero 
move $t1,$zero # $t1&lt;-- "suma" con valor inicial 0

la $s0, valor
bucle:
lw $s1,0($s0) # Carga del elemento referenciado por la dirección guardada en t1, se carga en s1
add $t1,$t1,$s1 # Suma el elemento a la suma anterior
add $t0,$t0,1 # Incremento del indice
addi $s0,$s0,4 # Incrementamos la dirección base sumándole 4 bytes para que así apunte al siguiente elemento del vector la dirección de memoria.

blt $t0,$t2,bucle # Repite el bucle si no se ha llegado al ultimo elemento (indice&lt;4)
sw $t1,suma #Almacenamos en el registro $t1 el valor de la variable suma

lw $a0,suma #Cargamos en el registro $a0 el valor de la variable suma

li $v0,1 #Solicitamos imprimir un entero
syscall #Hacemos la llamada al sistema



Segundo método, Mediante Multiplicaciones:
Este método solo se puede usar para arrays de tipo .half y .word, ya que se tratara de ir multiplicando un índice por 2 en el caso de .half o por 4 en el caso de .word y este número usarlo como índice tal como se hace en el lenguaje C de forma que quede, vector(índice):

#Problema 6: Modificar el código anterior para que al término de la suma del array guarde en memoria el resultado, recorriendo el vector mediante multiplicaciones.

#Considere que inicialmente habrá que efectuar una reserva de memoria para esta variable a la que denominaremos "SUMA"..data
valor: .word 0xa,0xb,0x01,0x02 #Defino array 4 palabras
suma: .word 0 #Defino valor de variable suma
.text
.globl main
main:
move $t0,$zero # $t0&lt;-- "índice" con valor inicial 0
move $t1,$zero# $t1&lt;-- "suma" con valor inicial 0
li $t2,4 # $t2&lt;-- constante

bucle:
mul $t3,$t0,$t2 # Multiplicamos el índice por 4
lw $t4,valor($t3) # $t4 &lt;-- valor[índice]
add $t1,$t1,$t4 # suma=suma+valor [índice]
add $t0,$t0,1 # índice=índice+1
ble $t0,$t2,bucle # Repite BUCLE si índice&lt;4
sw $t1,suma # Guardamos en suma el valor de $t1
li $v0,1
lw $a0,suma
syscall #Hacemos la llamada al sistema


Tercer método, Mediante Desplazamientos:
Este método solo se puede usar para arrays de tipo .half y .word, ya que se tratara de ir haciendo mediante desplazamientos lógicos que un numero se multiplique según nos convenga, siendo el desplazamiento de 1 bit a la izquierda lo mismo que multiplicar por 2 (.half) y el desplazar 2 bits a la izquierda lo mismo que multiplicar por 4 y este número usarlo como índice tal como se hace en el lenguaje C de forma que quede, vector(índice, al igual que en el método de multiplicaciones:
#Problema 6: Modificar el código anterior para que al término de la suma del array guarde en memoria el resultado, recorriendo el vector mediante desplazamientos.

#Considere que inicialmente habrá que efectuar una reserva de memoria para esta variable a la que denominaremos "SUMA"..data
valor: .word 0xa,0xb,0x01,0x02 #Defino array 4 palabras
suma: .word 0 #Defino valor de variable suma
.text
.globl main
main:
move $t0,$zero # $t0&lt;-- "índice" con valor inicial 0
move $t1,$zero # $t1&lt;-- "suma" con valor inicial 0
li $t2,4 # $t2&lt;-- constante

bucle:
sll $t3,$t0,2 # Desplazamiento de dos bits a la izquierda de "índice", que es lo mismo que multiplicar por 4.
lw $t4,valor($t3) # $t4 &lt;-- valor[índice]
add $t1,$t1,$t4 # suma=suma+valor [índice]
add $t0,$t0,1 # índice=índice+1
ble $t0,$t2,bucle # Repite BUCLE si índice&lt;4
sw $t1,suma
li $v0,1
lw $a0,suma
syscall


Conclusión


Tal como se puede comprobar, el lenguaje ensamblador es un lenguaje de muy bajo nivel con una complejidad elevada en comparación con lenguajes de un nivel más alto, es necesario conocerse el funcionamiento, arquitectura y juego de instrucciones de un CPU para poder programar para ella, pero en contraposición, es un lenguaje que sigue usándose para según que campos debido al rendimiento que se puede conseguir con él, no comparable con el de otros lenguajes de alto nivel como puede ser Java, C# e incluso C++ o C.

A pesar de su gran ventaja de rendimiento, sus defectos suelen prevalecer y no es el lenguaje más usado debido a que, un programa programado para la arquitectura MIPS (R2000/R3000), no será compatible con una CPU de arquitectura por ejemplo x86, esto puede resultar normal ya que un procesador es de arquitectura RISC y otro CISC, pero el defecto de este lenguaje va más lejos, pues es posible que un programa en ensamblador pensado para una arquitectura de CPU x86 Pentium 4 no sea compatible con una CPU reciente como por ejemplo un x86 i7, pues sus arquitecturas, juego de instrucciones y funcionamiento no son iguales, lo que obligaría a portar un mismo programa para cada CPU, en la que se quiera que funcione, con el consiguiente esfuerzo de tiempo, aprendizaje y recursos.

2 comentarios: