L10: Practica 4 - myTeachingURJC/2019-20-LAB-AO GitHub Wiki
Sesión Laboratorio 10: Práctica 4-2
- Tiempo: 2h
- Objetivos de la sesión:
- Entender los niveles de profundidad y el encadenamiento de llamadas de subrutinas
- Comprender mejor el criterio de uso de los registros (ABI RISCV)
- Distinguir la diferencia entre Registros preservados y NO preservados
- Saber usar la pila para preservar registros y llamar a subrutinas de cualquier nivel
- Practicar, practicar, practicar
Vídeos
- Fecha: 2019/Dic/05
Vídeo 1/5: Subrutinas multinivel
Haz click en la imagen para ver el vídeo en Youtube
Vídeo 2/5: Preservando registros
Haz click en la imagen para ver el vídeo en Youtube
Vídeo 3/5: La pila
Haz click en la imagen para ver el vídeo en Youtube
Vídeo 4/5: Usando la pila
Haz click en la imagen para ver el vídeo en Youtube
Vídeo 5/5: Otro ejemplo de uso de la pila
Haz click en la imagen para ver el vídeo en Youtube
Contenido
- Introducción
- Niveles de profundidad
- Registros preservados y NO preservados
- Respetando la ABI RISCV
- Subrutinas intermedias y dirección de retorno
- Subrutinas y memoria
- La pila
- Ejemlo de función de impresión de mensaje de error
- Actividades NO guiadas
- Autores
- Licencia
- Enlaces
Introducción
Para completar el estudio de las subrutinas, necesitamos conocer el mecanismo que nos permite realizar llamadas entre subrutinas a varios niveles, y no sólo con un nivel, como hemos visto hasta ahora. Además, aparecen problemas al mezclar el uso de los registros, definidos en la ABI del RISCV, con las llamadas a subrutinas. Para solucionar todos esos problemas necesitamos utilizar la pila
Niveles de profundidad
Ya sabemos dividir nuestros programas en dos partes: el programa principal y una subrutina, que podemos llamar tantas veces como queramos, y que nos devolverá el control al punto siguiente desde donde fue invocada
Para abordar problemas más complejos, nos gustaría poder dividir la subrutina, a su vez, en más subrutinas. Así, tenemos una serie de subrutinas encadenadas, unas llamando a otras. Cada vez que llamamos a una subrutina, decimos que descendemos un nivel de profundidad. El programa principal está en el Nivel de profundidad 0. Al llamar a una subrutina, estamos en el nivel 1. Si desde el nivel 1, llamamos a otra subrutina, estaremos en el nivel de profundidad 2. Y así sucesivamente
El número de niveles depende del número total de funciones en las que se haya particionado el problema y de cómo están organizadas. Es algo que define el jefe de proyecto. Nosotros en esta asignatura nos limitaremos a implementar las subrutinas tal cual nos las pidan
Según el nivel de profundidad donde se encuentra cada función, establecemos la siguiente clasificación:
- Programa principal: Es el que está en el Nivel 0. Se encarga de invocar al resto de funciones
- Subrutina Hoja (o función Hoja): Es una subrutina que NO invoca a más subrutinas. Por tanto está en su último nivel de profundidad
- Subrutina intermedia: Es una subrutina que NO es el programa principal NI una subrutina hoja. Se encuentran a partir del nivel 1 y realizan al menos una llamada a otra función (que estará en el nivel 2)
Registros preservados y NO preservados
La ABI del RISCV divide los registros en dos categorías, según el comportamiento que tienen al invocar a las subrutinas:
- Registros preservados: Su valor debe ser el mismo ANTES y DESPUÉS de realizar la llamada a la subrutina. Es decir, son registros que está prohibida su MODIFICACIÓN en la subrutina. Se pueden usar dentro de la subrutina, pero sin alterar su valor. Antes de usarlos hay que guardar su valor en memoria, y recuperarlo antes de retornar de la subrutina. Así, el nivel superior los debe ver como si no se hubiesen modificado nunca
Los registros que se preservan son los estáticos: s0-s11 y el puntero de pila: sp
Este fragmento de código es correcto:
#..... Por aquí habría más instrucciones
#-- Inicializar s0 a 30
li s0, 30
#-- s0 vale 30
jal tarea1
#-- s0 sigue valiendo 30
addi s0, s0, 1
#-- Ahora vale 31. Como su valor se ha preservado al llamar a tarea1,
#-- lo podemos incrementar al retornar
#... Por aquí habría más instrucciones
- Registros NO preservados: Las subrutinas pueden alterar su valor. No sabemos qué subrutinas alteran qué registros. Por eso SIEMPRE supondremos que al llamar a una subrutina, estos registros PIERDEN SU VALOR. Y por tanto, tras cada llamada habrá que inicializarlos otra vez
Los registros que NO preservan su valor son los temporales: t0-t6 y los de argumentos: a0-a7
Así, este ejemplo es TOTALMENTE INCORRECTO, y viola el convenio, aunque llegase a funcionar bien:
#-- Asignar 30 a t0
li t0, 30
#-- t0 es un registro temporal. No se preserva su valor al
#-- llamar a una subrutina
jal tarea1
#-- ¿cuanto vale t0?: INDEFINIDO. No se puede usar
#-- Hay que inicializarlo
#-- Si hacemos esto, VIOLAMOS el convenio
addi t0, t0, 1
Podría ocurrir que la subrutina tarea1 no usase el registro t0, y por tanto que al ejecutar este programa funcionase perfectamente. Sin embargo, en algún momento, otro ingeniero podría modificar la subrutina tarea1 y usar t0 (porque el convenio se lo permite, lo puede hacer). Entonces dejaría de funcionar este trozo de código (y sería un error difícil de encontrar si es un proyecto grande)
Respetando la ABI RISCV
Debido a la existencia de estos dos grupos de registros: los estáticos (cuyo valor se preserva entre llamadas) y los temporales (cuyo valor se pierda entre llamadas) hay que tener cuidado al usarlos. Dependiendo de la parte del programa en la que estemos trabajando, podremos usar unos registros u otros
Trabajando en el programa principal
Los registros estáticos los podemos usar a discreción. Los temporales también, pero teniendo en cuenta que los tenemos que INICIALIZAR siempre después de cada llamada a subrutina
Usando registros estáticos en el programa principal
En el programa principal, al estar en el nivel 0, podemos usar los registros estáticos s0-s11 como queramos y cuando queramos. Tenemos garantizado que su valor NO LO CAMBIARÁ ninguna subrutina
Así, el siguiente programa de ejemplo siempre debería escribir en la consola el valor 25, independientemente de cómo esté implementada la subrutina tarea1
#-- Ejemplo de programa principal
#-- que cumple correctamente con el convenio
#-- de uso de registros de la ABI del RISCV
.include "servicios.asm"
.text
li s0, 25
#--- Llamar a la tarea1
jal tarea1
#-- Imprimir el valor de s0
#-- Al llamar a tarea1 SU VALOR SE HA PRESERVADO
mv a0,s0
li a7, PRINT_INT
ecall
#-- Terminar
li a7, EXIT
ecall
#-----------------------------------------
#-- Subrutina Tarea 1
#-- Estaría definida en otro fichero, pero
#-- la incluimos aquí por comodidad
#--------------------------------------------
.data
msg1: .string "Tarea 1\n"
.text
#--- Punto de entrada de la subrutina
tarea1:
#-- Imprimir un mensaje
la a0, msg1
li a7, PRINT_STRING
ecall
#-- Punto de salida
ret
El valor de s0 tras retornar de tarea1 DEBERÁ SER SIEMPRE 25. Si resulta que tarea1 ha modficiado s0 y su valor cambia, SE HA VIOLADO EL CONVENIO del uso de registros. Es un error grave
Usando registros temporales el programa principal
Los registros temporales (t0-t7, a0-a7) los podemos usar para cualquier cálculo intermedio. Lo único que debemos tener en cuenta es que tras realizar una llamada a una subrutina, el valor que contenían hay que darlo por perdido. EN TODOS LOS CASOS. Si no lo suponemos así, estamos violando el convenio (y es un error grave)
Este es un ejemplo igual que el anterior, pero usando el registro t0 (que NO preserva su valor). Aunque el programa funciona... ¡ES INCORRECTO! ¡ESTAMOS VIOLANDO EL CONVENIO DE USO DE LOS REGISTROS!!
#-- Ejemplo de programa principal
#-- que VIOLA EL CONVENIO
#-- de uso de registros de la ABI del RISCV
#---------¡¡¡¡¡¡CUIDADO!!!!!
.include "servicios.asm"
.text
#-- Inicializar el registro t0
li t0, 25
#--- Llamar a la tarea1
jal tarea1
#-- Imprimir el valor de t0
#--- ¡¡¡¡NOOOOOOO!!!! NO PODEMOS USAR t0
#--- de esa forma. Se ha llamada a tarea 1
#--- por lo que debemos suponer que t0
#--- no conserva su valor!!!
mv a0,t0
li a7, PRINT_INT
ecall
#--- NOTA: Si ejecutamos este programa, funciona correctamente
#-- Se imprime el valor 25
#-- Porque la subrutina tarea1 la hemos hecho nosotros
#-- y no se está modificando t0
#-- Pero No podemos suponer que t0 preserva su valor
#-- (El convenio nos dice lo contrario)
#-- Si quieres tener garantizado que se preserve su valor...
#-- debes usar los registros estáticos
#-- Terminar
li a7, EXIT
ecall
#-----------------------------------------
#-- Subrutina Tarea 1
#-- Estaría definida en otro fichero, pero
#-- la incluimos aquí por comodidad
#--------------------------------------------
.data
msg1: .string "Tarea 1\n"
.text
#--- Punto de entrada de la subrutina
tarea1:
#-- Imprimir un mensaje
la a0, msg1
li a7, PRINT_STRING
ecall
#-- Punto de salida
ret
Para no violar el convenio en este ejemplo que está en el programa principal podemos hacer dos cosas:
- La solución más sencilla es usar algún registro estático para no perder el valor entre llamadas
- También podemos almacenar el valor de t0 en memoria, llamar a la subrutina, y recuperar su valor anterior de memoria
Trabajando en una subrutina
Cuando implementamos una subrutina tenemos que tener cuidado de no violar el convenio. Necesitamos un mecanismo para preservar los registros: almacenarlos en la memoria para recuperar su valor posteriormente. Este mecanismo lo veremos en detalle más adelante: la pila
Usando registros estáticos
Dentro de una subrutina NO PODEMOS modificar los registros estáticos: del s0 al s11. Por tanto, si los queremos usar (o necesitamos usarlos), no tendremos más remedio que guardar su valor en memoria.
El siguiente código de la subrutina tarea1 es INCORRECTO, porque está modificando el registro estático s0:
#-- Ejemplo de subrutina
#-- que VIOLA EL CONVENIO
#-- de uso de registros de la ABI del RISCV
#---------¡¡¡¡¡¡CUIDADO!!!!!
.globl tarea1
.include "servicios.asm"
.text
#-----------------------------------------
#-- Subrutina Tarea 1
#--------------------------------------------
.data
msg1: .string "Tarea 1\n"
.text
#--- Punto de entrada de la subrutina
tarea1:
#-- Imprimir un mensaje
la a0, msg1
li a7, PRINT_STRING
ecall
#-- INCORRECTO! NO PODEMOS MODIFICAR
#-- NINGUN registro estatico
addi s0,s0,1
#-- Punto de salida
ret
Si necesitamos usar s0 (en este ejemplo no hace falta), hay que guardarlo primero en memoria. La forma exacta de hacerlo será guardándolo en la pila. Aquí sólo indicamos en los comentarios lo que habría que hacer:
#-- Ejemplo de subrutina
#-- que respeta el convenio
#-- aunque no está implementada todavía
#-- Es un boceto
.globl tarea1
.include "servicios.asm"
.text
#-----------------------------------------
#-- Subrutina Tarea 1
#--------------------------------------------
.data
msg1: .string "Tarea 1\n"
.text
#--- Punto de entrada de la subrutina
tarea1:
#-- Imprimir un mensaje
la a0, msg1
li a7, PRINT_STRING
ecall
#-- Antes de modificar s0, hay que almacenarlo
#-- en la pila:
#-- GUARDA S0 EN LA PILA
#-- USAR S0. Modificarlo
addi s0,s0,1
#-- RECUPERAR s0 de la PILA
#-- De esta forma es como si no se hubiese modificado
#-- Punto de salida
ret
Usando registros temporales
Los registros temporales los usamos para cualquier uso en la subrutina, sin mayor preocupación. Sin embargo, si realizamos una llamada a subrutina y tenemos en algún registro temporal un valor que necesitamos más adelante, será necesario guardarlo en la pila, realizar la llamada, y luego recuperarlo de la pila
Así, el siguiente ejemplo de subrutina intermedia, es INCORRECTO. El valor del registro t0 se pierde al invocar a la subrutina tarea2
-- Ejemplo de subrutina
#-- que VIOLA EL CONVENIO de USO de REGISTROS
#-- ¡¡¡CUIDADO!!
.globl tarea1
.include "servicios.asm"
.text
#-----------------------------------------
#-- Subrutina Tarea 1
#--------------------------------------------
.data
msg1: .string "Tarea 1\n"
.text
#--- Punto de entrada de la subrutina
tarea1:
#-- Imprimir un mensaje
la a0, msg1
li a7, PRINT_STRING
ecall
#-- Inicializar t0 a 25
li t0, 25
#-- Llamar a tarea 2
jal tarea2
#-- ¡VIOLACION! NO podemos usar el valor de t0. Hay que
#-- darlo por perdido!
addi t0,t0,1
#-- Punto de salida
ret
La solución es almacenar t0 en la pila antes de llamar a la función tarea2 y al retornar recuperar el valor de t0. Ya veremos cómo se hace. Pero la solución comentada sería así:
#-- Ejemplo de subrutina
#-- que respeta el convenio
#-- No está implementada, es un boceto de
#-- lo que se debería hacer para no violar
#-- el convenio
.globl tarea1
.include "servicios.asm"
.text
#-----------------------------------------
#-- Subrutina Tarea 1
#--------------------------------------------
.data
msg1: .string "Tarea 1\n"
.text
#--- Punto de entrada de la subrutina
tarea1:
#-- Imprimir un mensaje
la a0, msg1
li a7, PRINT_STRING
ecall
#-- Inicializar t0 a 25
li t0, 25
#--- Como necesitaremos t0 tras llamar a la
#--- subrutina, hay que guardar su valor
#--- GUARDAR t0 EN LA PILA
#-- Llamar a tarea 2
jal tarea2
#--- RECUPERAR EL VALOR DE t0 de la PILA
#-- Ahora ya podemos usar su valor normalente
addi t0,t0,1
#-- Punto de salida
ret
Subrutinas intermedias y dirección de retorno
Cuando desde el programa principal llamamos a una subrutina que está en el nivel 1, la dirección de retorno se almacena en el registro ra. Esto garantiza que al ejecutar la instrucción ret se vuelve a la siguiente posición desde donde se invocó la subrutina
Pero ¿qué ocurre si desde esta subrutina de nivel 1 llamamos a su vez a otra de nivel 2?
Ahora es necesario recordar 2 direcciones de retorno. Una es la de retorno al nivel 0 (programa principal), y la otra es la del retorno al nivel 1. La subrutina de nivel 2 es una función hoja, que no llama a ninguna otra, por lo que no tiene que recordar nada
Pero como sólo tenemos un registro ra con ese propósito, al guardar la dirección de retorno de la segunda llamada, machacamos el valor de la primera, perdiéndose su valor.
Esto lo podemos probar con el siguiente ejemplo, en el que se ha definido un programa principal que invoca a tarea1 y esta a su vez a tarea 2. Ninguna función realiza nada, sólo las llamadas y el retorno
#-- Ejemplo de profundidad 2
#-- El programa principal llama a tarea1
#-- Tarea1 llama a tarea 2
#-- Este programa ES INCORRECTO!
#-- SE QUEDA EN UN BUCLE INFINITO!
.include "servicios.asm"
#-------------------------------------------
#-- PROGRAMA PRINCIPAL
#-------------------------------------------
.text
#-- En t0 ponemos el nivel
#-- Estamos en el nivel 0
li t0, 0
#-- Llamar a tarea1
jal tarea1
#-- Direccion de retorno al nivel 0
r0:
#-- Estamos en el nivel 0 otra vez
li t0, 0
#-- Terminar
li a7, EXIT
ecall
#-----------------------------------------
#-- Subrutina Tarea1
#--------------------------------------------
.text
#--- Punto de entrada de la subrutina
tarea1:
#-- Estamos en el nivel 1
li t0, 1
#-- Llamar a tarea2
jal tarea2
r1: #-- Direccion de retorno al nivel 1
#-- Estamos en el nivel 1 otra vez
li t0, 1
#-- Punto de salida
ret
#-----------------------------------------------
#-- Subrutina Tarea2
#-----------------------------------------------
tarea2:
#-- Estamos en el nivel 2
li t0, 2
#-- Punto de salida
ret
Al ensamblar y ejecutar este programa, vemos que ¡se queda en un bucle infinito!. Al realizar la llamada a la segunda subrutina, su dirección de retorno se guarda en ra, machacando la anterior, por lo que NUNCA retorna al programa principal
¿Cómo lo solucionamos?
En todas las subrutinas INTERMEDIAS (en las hojas no hace falta) hay que guardar el contenido del registro ra en la pila y recuperarlo antes de ejecutar la instrucción RET.
Así, la plantilla para que funcione el ejemplo anterior sería así (no está implementada la solución todavía):
#-- Ejemplo de profundidad 2
#-- El programa principal llama a tarea1
#-- Tarea1 llama a tarea 2
#-- Este programa es un boceto de la
#-- solucion correcta, pero NO está implementada todavía
.include "servicios.asm"
#-------------------------------------------
#-- PROGRAMA PRINCIPAL
#-------------------------------------------
.text
#-- En t0 ponemos el nivel
#-- Estamos en el nivel 0
li t0, 0
#-- Llamar a tarea1
jal tarea1
#-- Direccion de retorno al nivel 0
r0:
#-- Estamos en el nivel 0 otra vez
li t0, 0
#-- Terminar
li a7, EXIT
ecall
#-----------------------------------------
#-- Subrutina Tarea1
#--------------------------------------------
.text
#--- Punto de entrada de la subrutina
tarea1:
#-- Como es una subrutina intermedia
#-- tenemos que guardar el registro RA
#-- en la pila para no perder la direccion
#-- de retorno hacia el nivel anterior
#---- GUARDAR RA EN LA PILA
#-- Estamos en el nivel 1
li t0, 1
#-- Llamar a tarea2
jal tarea2
r1: #-- Direccion de retorno al nivel 1
#-- Estamos en el nivel 1 otra vez
li t0, 1
#----- RECUPERAR RA DE LA PILA
#-- Punto de salida
ret
#-----------------------------------------------
#-- Subrutina Tarea2
#-----------------------------------------------
tarea2:
#-- Estamos en el nivel 2
li t0, 2
#-- Punto de salida
ret
Subrutinas y memoria
Hemos visto que las subrutinas necesitan una zona de memoria propia para almacenar la dirección de retorno, preservar los registros y también para guardar variables locales
Este trozo de memoria asignado a cada subrutina lo podemos visualizar de esta forma:
Sería una posible implementación. Sin embargo, es un gasto excesivo de memoria el asignar una zona a cada subrutina. Si tenemos muchas subrutinas, necesitaríamos mucha memoria
¿Cómo podríamos asignar memoria a cada subrutina, pero gastando menos? La solución es usar una pila. Las subrutinas que están en el mismo nivel pueden reutilizar la memoria, ya que mientras se ejecuta una subrutina, las otras de su nivel no tienen que almacenar nada
En esta figura está representada esta idea de un trozo de memoria por cada nivel de profundidad
El puntero de pila, SP, apunta siempre a la cima de la pila. Se usa como dirección base para el almacenamiento de la subrutina de cada nivel. Así, cuando el programa principal llama a la primera subrutina, esta decrementa el puntero de pila y deposita la información que necesita guardar en esa zona. Al llamar al siguiente nivel, este puntero se vuelve a decrementar y esa zona es la usada por la subrutina de segundo nivel.
Cuando una subrutina retorna, incrementa el puntero de pila para que vuelva a la posición que tenía al invocar a la función. De esta forma, la memoria se reutiliza con diferentes subrutinas. Es mucho más eficiente
La pila
La pila es una zona de memoria disponible para asignar a las subrutinas. Cada subrutina tiene su propia zona de pila en exclusividad: una zona de memoria sólo para ella, para almacenar la dirección de retorno, el contenido de los registros que se quieren preservar y las variables locales
La pila se sitúa en las zonas altas de la memoria y crece hacia las direcciones bajas. Su tamaño es variable. Inicialmente es de 0 palabras. Cada vez que una subrutina necesita la pila, crea su zona en ella, incrementando el tamaño. Al terminar de ejecutarse la subrutina, se recupera el espacio. Este es el mapa de memoria:
Para gestionar la pila se usa el registro sp (stack pointer, x2). NO SE PUEDE USAR PARA OTRA COSA. Su uso es exclusivo para la pila. El registro sp apunta siempre a la cima de la pila: su último elemento introducido
Creación y liberación de la pila en las subrutinas
Cada vez que una subrutina necesita usar la pila, tiene reservar espacio. Esto lo hace decrementando el registro sp en el tamaño en bytes que se quiere para su pila. Sólo se pueden usar valores múltiplos de 16 bytes (4 palabras). Típicamente usaremos pilas de 16 bytes (4 palabras) o 32 bytes (8 palabras)
Así, si hay varias subrutinas encadenadas, la pila crecerá. Cada zona de la pila es exclusiva de cada subrutina. En este dibujo se muestra el crecimiento de la pila cuando se han encadenado N subrutinas
El esquema de creación de la pila será siempre el mismo, aunque el tamaño se puede variar: se decrementa el registro sp al comenzar, y se vuelve a su valor original antes de retornar
Este es un ejemplo en el que se reserva espacio para 4 palabras (16 bytes). Pero se podría utilizar cualquier múltiplo de 16: 32, 48, 64... según el espacio que necesitemos
#-- Comienzo de la subrutina
addi sp, sp, -16
.
.
.
#-- Instrucciones de la subrutina
.
.
.
#-- Fin de la subrutina
addi sp, sp, 16
ret
Almacenamiento de la dirección de retorno
Una vez creada la pila, lo primero que se hace es almacenar el registro ra, que contiene la dirección de retorno a la subrutina anterior. Antes de terminar la subrutina, se recupera este valor y se retorna. Así, el esquema de uso de la pila queda así. Reservamos espacio para 4 palabras:
#-- Comienzo subrutina
addi sp, sp, -16
sw ra, 12(sp) #-- Guardar registro ra en la pila
.
#-- Instrucciones de la subrutina
.
#-- Fin de la subrutina
lw ra, 12(sp) #-- Recuperar el registro ra de la pila
addi sp, sp, 16
ret
Este es el aspecto de la pila de una subrutina de nivel 1: tiene espacio para guardar 4 palabras. En la superior está almacenada la dirección de retorno (registro ra) y el puntero de pila apunta a la posición más baja
Si necesitamos una pila de mayor tamaño, usamos una de 32 bytes:
#-- Comienzo subrutina
addi sp, sp, -32
sw ra, 28(sp) #-- Guardar registro ra en la pila
.
#-- Instrucciones de la subrutina
.
#-- Fin de la subrutina
lw ra, 28(sp) #-- Recuperar el registro ra de la pila
addi sp, sp, 32
ret
El mapa de los offset es el siguiente. Ahora tenemos espacio para guardar 8 palabras
Ejemplo: Llamada a subrutina del nivel 2
Ahora ya podemos solucionar el problema que nos encontramos anteriormente: cómo invocar a la subrutina de nivel 2. Esta solución es válida para encadenar N subrutinas. Lo que tenemos que hacer es crear una pila para la subrutina del nivel 1, para almacenar el registro ra. NO es necesario crearla para la subrutina de nivel 2, ya que es una función hoja y no llama a ninguna otra
#-- Ejemplo de profundidad 2
#-- El programa principal llama a tarea1
#-- Tarea1 llama a tarea 2
#-- Se crea una pila en la tarea1 para almacenar la
#-- direccion de retorno del nivel 0
.include "servicios.asm"
#-------------------------------------------
#-- PROGRAMA PRINCIPAL
#-------------------------------------------
.text
#-- En t0 ponemos el nivel
#-- Estamos en el nivel 0
li t0, 0
#-- Llamar a tarea1
jal tarea1
#-- Direccion de retorno al nivel 0
r0:
#-- Estamos en el nivel 0 otra vez
li t0, 0
#-- Terminar
li a7, EXIT
ecall
#-----------------------------------------
#-- Subrutina Tarea1
#--------------------------------------------
.text
#--- Punto de entrada de la subrutina
tarea1:
#-- Crear la pila mínima: 16 bytes
addi sp, sp, -16
#---- GUARDAR RA EN LA PILA
sw ra, 12(sp)
#-- Estamos en el nivel 1
li t0, 1
#-- Llamar a tarea2
jal tarea2
r1: #-- Direccion de retorno al nivel 1
#-- Estamos en el nivel 1 otra vez
li t0, 1
#----- RECUPERAR RA DE LA PILA
lw ra, 12(sp)
#-- Liberar el espacio de la pila
addi sp, sp, 16
#-- Punto de salida
ret
#-----------------------------------------------
#-- Subrutina Tarea2
#-----------------------------------------------
tarea2:
#--- Es una funcion hoja: No llamamos a otra funcion
#-- NO hace falta guardar la direccion de retorno
#-- No hay que crear pila
#-- Estamos en el nivel 2
li t0, 2
#-- Punto de salida
ret
Ensamblamos y ejecutamos el programa paso a paso. Ahora ya finaliza correctamente. Retorna sin problemas al programa principal. Ya no hay bucle infinito
Ejemplo: Función de impresión de mensaje de error
Partimos de la función linea(num) que imprime tantos caracteres asterisco (*) como indique su parámetro num. Esta función se propuso en el ejercicio 2 de la sesión anterior. La subrutina, guardada en su propio fichero, es esta:
#-----------------------------------------------------
#-- SUBRUTINA LINEA
#--- * Entrada: a0: numero de asteriscos a imprimir
#--- * Salida: Ninguna
#-----------------------------------------------------
.include "servicios.asm"
.globl linea
.text
linea: #-- PUNTO DE ENTRADA
#--- Estamos dentro de una subrutina
#--- usamos SIEMPRE registros temporales
#-- t0: contador. Inicializado a 0
li t0, 0
#-- a0 contiene el numero de asteriscos
#-- Lo guardamos en t1 para no perderlo
mv t1, a0
bucle:
#-- Si t0 > t1 --> Terminar
bge t0, t1, fin
#-- Imprimir un asterisco
li a0, '*'
li a7, PRINT_CHAR
ecall
#-- Incrementar contador de asteriscos
addi t0, t0, 1
#-- Repetir bucle
b bucle
fin:
#-- Retornar
ret
Definimos la subrutina void print_error(pcad) de forma que se imprima el mensaje apuntado por su parámetro pcad, enmarcado en una línea superior de 40 asteriscos y una línea inferior de también 40 asteriscos. Debe estar en su propio fichero
Por último, el programa principal llama dos veces a la subrutina print_error(), con la cadena "MENSAJE DE ERROR". De esta forma, al ejecutar el programa principal, debe aparecer en la consola lo siguiente:
****************************************
MENSAJE DE ERROR
****************************************
****************************************
MENSAJE DE ERROR
****************************************
¿Cómo sería el programa completo?. Cada parte debe estar en un fichero separado
La subrutina print_error() es una subrutina intermedia: llama a su vez a otra subrutina. Por tanto, será necesario crear la pila para guardar la dirección de retorno. Por el parámetro a0 le pasamos la dirección del mensaje a imprimir. Pero como tenemos que imprimir primero la línea superior, hay que usar el registro a0 para indicar a la función línea la cantidad de asteriscos a imprimir (que son 40, según me indican en las especificaciones)
Por ello, antes de llamar a linea() es necesario guardar a0 en la pila, o de lo contrario perderíamos su valor. Así, en la pila habrá que guardar la dirección de retorno (ra) y el primer parámetro (a0)
#---------------------------------------------
#-- SUBRUTINA: Print_error(*error)
#--
#-- Se imprime el mensaje de error apuntado por el parametro error.
#-- Tanto antes como despues del mensaje se imprimen dos líneas
#-- de asteriscos para resaltarlo
#--
#-- Entrada:
#-- a0: Puntero al mensaje de error a sacar
#--
#-- Salida: Ninguna
#-----------------------------------------------------
.globl print_error
.eqv TAM 40
.include "servicios.asm"
.text
#-- Punto de entrada
print_error:
#-- Necesitamos crear la pila para guardar la direccion de
#-- retorno (es una subrutina intermedia)
addi sp, sp, -16
#-- Guardamos la direccion de retorno
sw ra, 12(sp)
#-- En a0 tenemos el puntero a la cadena a imprimir....
#-- Pero primero hay que llamar a la funcion linea
#-- pasando como parametro el valor 40 en a0
#-- Para no perder lo que hay en a0, lo guardamos
#-- en la pila. Por ejemplo en la posicion por debajo de ra
sw a0, 8(sp)
#-- Imprimir '\n'
li a0, '\n'
li a7, PRINT_CHAR
ecall
#-- Imprimir una linea de 40 asteriscos
#-- linea(40)
li a0, TAM
jal linea
#-- Imprimir '\n'
li a0, '\n'
li a7, PRINT_CHAR
ecall
#-- Recuperar la direccion del mensaje. La metemos en a0
lw a0, 8(sp)
#-- Llamamos a PRINT_STRING
li a7, PRINT_STRING
ecall
#-- Imprimir '\n'
li a0, '\n'
li a7, PRINT_CHAR
ecall
#-- Imprimir la linea inferior
li a0, TAM
jal linea
#-- Imprimir '\n'
li a0, '\n'
li a7, PRINT_CHAR
ecall
#-- Recuperar la direccion de retorno
lw ra, 12(sp)
#-- Recuperar la pila
addi sp, sp, 16
#-- Punto de salida
ret
Por último hacemos el programa principal. Hay que llamar dos veces a la función print_error(). PERO... hay que tener cuidado. Uno estaría tentado de hacer esto:
la a0, msg
jal print_error
jal print_error
Si embargo es ¡UN ERROR! ¡Estamos violando el convenio! El registro a0 es temporal. Al llamar a print_error() la primera vez, tenemos que dar por PERDIDO el valor de a0, porque es un registro que no preserva su valor al llamar a las subrutinas
Para preservar su valor, como estamos en el programa principal, podemos usar los registros estáticos. Por ejemplo s0. Así el programa principal completo será:
#-- Programa principal: main
.include "servicios.asm"
.data
msg: .string "MENSAJE DE ERROR"
.text
#-- Cargamos en s0 la direccion de msg
#-- Lo hacemos en s0 porque hay que llamar dos veces
la s0, msg
#-- Llamar a la funcion print_error
mv a0, s0
jal print_error
#-- Llamar a la funcion print_error
mv a0, s0
jal print_error
#-- Terminar
li a7, EXIT
ecall
Hay que tener un poco de cuidado al usar los registros. Por ello hay que practicar mucho
Actividades NO guiadas
Nos acercamos al final del laboratorio. En el examen tendréis que hacer programas desde cero que tengan subrutinas de varios niveles, pila y habrá que preservar registros. Para entenderlo bien es necesario practicar. Mucho. Haz los ejercicios. Cuantos más, mejor
Ejercicio 1
- Dentro de un programa principal se tiene el siguiente fragmento de código
#....
li s0, 30
li t0, 5
jal tarea1
mv a0, s0
mv a1, t0
#....
- ¿Cuánto vale a0?
- ¿Cuánto vale a1?
- Este fragmento de programa principal llama a la función print_int(num), que imprime en la consola el número entero pasado como parámetro y no retorna ningún valor. ¿Es correcto? ¿Qué números se imprimen en la consola?
#....
li a0, 5
jal print_int
jal print_int
#.....
- Este es un fragmento de código de un programa principal. ¿Cuál es el valor del registro t0 al ejecutarse?
li t0, 5
li s0, 25
jal tarea1
addi s1, s0, 1
jal tarea2
addi s2, s1, 1
mv t0, s0
- La función tarea1() no tiene ningún parámetro de entrada, ni retorna ningún valor. Este es un fragmento de código de su implementacion
# -- Punto de entrada
tarea1:
mv t1, s0
#.....aquí habría más código...
¿Se está violando el convenio de uso de registros?
- La función inc(x) recibe un valor entero y lo devuelve incrementado en una unidad. Este es un fragmento de su implementación. ¿Es correcto?
#-- Punto de entrada
inc:
#-- Leer el primer argumento
mv s0, a0
#-- Incrementar su valor en una unidad
addi s0, s0, 1
#-- Devolver el valor incrementado por a0
mv a0, s0
ret
- Este es un fragmento de la función par() que comprueba si el número de caracteres de una cadena es par. Si lo es, llama a la función mensaje_par() que imprime en la consola un mensaje indicando que es par. Luego devuelve por a0 el código 1. ¿Es correcto?
#-- Punto de entrada
par:
#.... resto de instrucciones
#.... es par
li t0, 1
#-- Imprimir mensaje
jal mensaje_par
#-- devolver codigo de retorno (que está en t0)
mv a0, t0
ret
Ejercicio 2
En los siguientes fragmentos de código se está violando alguno de los convenios. Escribir el código necesario para solucionarlo, de forma que se cumplan las normas de la ABI RISCV
- Fragmento de código dentro de un programa principal
#....
li s0, 30
li t0, 5
jal tarea1
mv a0, s0
mv a1, t0
#....
- Este fragmento de programa principal. La función print_int(num) tiene un parámetro de entrada y ninguno de salida
#....
li a0, 5
jal print_int
jal print_int
#.....
- Fragmento de la función inc(x) recibe un valor entero y lo devuelve incrementado en una unidad
#-- Punto de entrada
inc:
#-- Leer el primer argumento
mv s0, a0
#-- Incrementar su valor en una unidad
addi s0, s0, 1
#-- Devolver el valor incrementado por a0
mv a0, s0
ret
- Fragmento de la función par() que comprueba si el número de caracteres de una cadena es par. Si lo es, llama a la función mensaje_par() que imprime en la consola un mensaje indicando que es par. Luego devuelve por a0 el código 1
#-- Punto de entrada
par:
#.... resto de instrucciones
#.... es par
li t0, 1
#-- Imprimir mensaje
jal mensaje_par
#-- devolver codigo de retorno (que está en t0)
mv a0, t0
ret
Ejercicio 3
En un fichero tenemos este programa principal, que imprime un mensaje en la consola, llama a la función tarea1() y termina
#-- Programa principal
.include "servicios.asm"
.data
msg: .string "Programa principal\n"
.text
#-- Imprimir el mensaje
la a0, msg
li a7, PRINT_STRING
ecall
#-- Llamar a la funcion tarea1
jal tarea1
#-- Imprimir el mensaje
la a0, msg
li a7, PRINT_STRING
ecall
#-- Terminar
li a7, EXIT
ecall
La función tarea1() se encuentra en otro fichero:
#---------------------------------
#-- Subrutina Tarea 1
#-- ENTRADAS: Ninguna
#-- SALIDAS: Ninguna
#---------------------------------
#-- Punto de entrada
.globl tarea1
.include "servicios.asm"
.data
msg: .string " Tarea 1\n"
.text
#-------- Punto de entrada
tarea1:
#-- Imprimir el mensaje
la a0, msg
li a7, PRINT_STRING
ecall
#------- Punto de salida
ret
El ejecutar el programa principal, se obtiene esto en la consola:
Modificar la función tarea1() para que imprima el mensaje, llame a la función tarea2(), definida en otro fichero separado, imprima de nuevo el mensaje y termine. La función tarea2() imprime el mensaje "Tarea2" y termina
Al ejecutar el nuevo programa, la salida por consola debe ser como esta:
Ejercicio 4
Escribir un programa principal que pida al usuario un número entero y llame a la función print_int(num) que imprime este número precedido por la cadena "-->". La función print_int tiene como parámetro de entrada el número entero y no devuelve nada. Tanto el programa principal como la función print_int() deben estar en archivos separadados
Esta es una animación del funcionamiento
Ejercicio 5
Ampliar el ejemplo anterior. La función print_int() debe imprimir un carácter '\n' al final. El programa pricipal debe invocar a la función operar(num) en vez de print_int() (el resto es igual). La subrutina operar() tiene como parámetro de entrada un número entero y no devuelve nada. Debe imprimir el valor del número entero pasado, llamando a print_int(), luego debe multiplicar este número por dos, llamando a la función mult2(), y finalmente debe volver a imprimir el número original llamando otra vez a print_int()
En esta animación se muestra el funcionamiento:
La función mult2(num) tiene como parámetro de entrada el número que se quiere multiplicar por dos. Como salida, devuelve por a0 el número multiplicado por dos
Ejercicio 6
Escribe el siguiente programa, formado por tres ficheros independientes: el programa principal, la función operar() y la función print_vec()
-
La función print_vect(x,y) tiene dos argumentos de entrada que son números enteros: la coordenada x y la coordenada y. No tiene ningún parámetro de salida. Se imprime en la consola el vector de esta forma: "(x,y)"
-
La función operar(x,y) tiene los mismos argumentos que print_vec. Imprime en la consola el vector original (x,y), llamando a print_vec y otro debajo con las componentes incrementadas en una unidad (x+1, y+1) (también llamando a print_vec)
-
El programa principal pide al usuario que introduzca las coordenadas x,y y luego llama a la función operar()
Ejercicio 7
Ampliar el programa anterior para que la función operar(x,y,incx,incy) admita ahora 4 argumentos: las coordenas x,y y los incrementos de x y de y. En vez de imprimirse el vector incrementado en una unidad cada componente, debe imprimir (x,y, x+incx, y+incy)
El programa principal llamará a la función operar(x,y,10,100), usando los valores constantes 10 y 100 para el incremento de la x y la y respectivamente
Ejercicio 8
Escribe el siguiente programa, formado por tres ficheros independientes: el programa principal, la función box(car,anch,alt) y la función line(car,anch)
-
La line(car,anch) tiene dos argumentos de entrada. El primero es un caracter y el segundo un entero. No tiene ningún parámetro de salida. Se imprime una línea formada por el carácter car repetido anch veces
-
La función box(car, anch, alt) tiene tres argumentos de entrada: un carácter, la anchura y la altura. No tiene ningún parámetro de salida. Imprime un rectángulo en la consola formado por el carácter car, y con las dimensiones de altura y anchura indicadas en los parámetros
-
El programa principal pide al usuario que introduzca el carácter, la anchura y la altura. Luego llama a box() con estos parámetros para imprimir el rectángulo en la consola. Por último, termina
Ejercicio 9
Escribe el siguiente programa, formado por dos ficheros independientes: el programa principal y la función concat(pcad1, pcad2)
-
La función concat(pcad1, pcad2) tiene dos argumentos de entrada: son punteros a dos cadenas. No tiene ningún parámetro de salida. La cadena 2 se copia al final de la cadena 1 (los caracteres '\n' y '\0' del cadena 1 se aliminan, sobreescribiendo encima los de la cadena 2) de forma que la cadena 1 es ahora una única cadena, formada mediante la concatenación de la primera con la segunda
-
El programa principal pide al usuario una cadena, luego un prefijo y finalmente un sufijo. Mediante llamadas a la función concat() debe construir una cadena formada por el prefijo-cadena-sufijo. Esta cadena se imprimirá en la consola
Ejercicio 10
Escribe el siguiente programa, formado por tres ficheros independientes: el programa principal, la función cifrar(pcad,k) y la función descifrar(pcad, k)
-
La función cifrar(pcad,k) tiene dos argumentos de entrada. El primero es un puntero a la cadena a cifrar y el segundo es la clave para cifrar. Es un número entero entre 1 y 255. Es el valor que se suma a cada carácter de la cadena original para cifrar
-
La función descifrar(pcad, k) tiene los mismos parámetros que la función cifrar() pero realiza el descifrado. Está implementanda llamando a la función de cifrar pero con la clave -k (la clave cambiada de signo)
-
El programa principal pide al usuario que introduzca una cadena inicial y la clave. Imprime primero la cadena sin cifrar. Luego llama a cifrar() y la imprime cifrada. Después llama a descifrar() y la imprime descifrada. Finalmente termina
Nota para el profesor:
- Título informal de la clase: "Hasta el infinito y ... stack overflow!"
- Para aplicar la metodología de "Divide y vencerás", y descomponer nuestro programa en muchas subrutinas en diferentes niveles... necesitamos un componente nuevo: La pila
- Al descomponer en niveles surgen dos problemas principales: almacenar las direcciones de retorno, y cumplir LA LEY de la ABI. Necesitamos una zona segura de memoria para ello: la PILA
Autores
- Katia Leal Algara
- Juan González-Gómez (Obijuan)