L5: Practica 2 - myTeachingURJC/2019-20-LAB-AO GitHub Wiki
Sesión Laboratorio 5: Práctica 2-2
- Tiempo: 2h
- Objetivos de la sesión:
- Aprender cómo realizar llamadas al sistema
- Conocer los nombres ABI de los registros
- Saber usar los registros temporales tx y los de argumento ax
- Manejo de las llamadas al sistema PrintInt, ReadInt y PrintChar
Vídeos
- Fecha: 2019/Nov/07
Vídeo 1/3: Registros de la ABI RISC-V
Haz click en la imagen para ver el vídeo en Youtube
Vídeo 2/3: Instrucción ECALL
Haz click en la imagen para ver el vídeo en Youtube
Vídeo 3/3: Llamadas al sistema PrintInt, ReadInt y PrintChar
Haz click en la imagen para ver el vídeo en Youtube
Contenido
- Introducción
- La interfaz binaria de aplicaciones (ABI) de RISC-V
- Llamadas al sistema
- Usando el RARs en la línea de comandos
- Recopilación de instrucciones hasta el momento
- Actividades NO guiadas
- Autores
- Licencia
- Enlaces
Introducción
La Entrada/salida se realiza mediante el mecanismo de dispositivos mapeados en memoria. Sin embargo los sistemas operativos incorporan servicios para gestionar la entrada/salidas y facilitar su acceso desde nuestros programas. Aprenderemos cómo invocar estos servicios. Es lo que denominamos llamadas al sistema
La Interfaz binaria de aplicaciones (ABI) de RISC-V
Nuestros programas no se ejecutan de forma aislada, sino que interactúan con el sistema operativo y las bibliotecas del sistema, que son programas diseñados por otras personas
Para que dos programas (módulos) puedan interactuar entre sí, se definen una ABI: Interfaz binaria de aplicaciones. En esta interfaz se determina cómo deben intercambiar información estas aplicaciones para que sean compatibles
Nombre ABI de los registros
Una de las cosas que se especifica en la ABI es el nombre de los registros, y el uso que se les da. En el RISC-V tenemos 32 registros, denotados como x0-x31. A nivel de procesador, todos los registros (salvo el x0), son iguales: los podemos utilizar en cualquier instrucción. No hay diferencia a nivel hardware entre ellos
Sin embargo, a nivel de programación, a cada registro se le assigna un uso diferente, y por tanto, un nombre distinto. Esta nueva denominación la llamamos nombres ABI. En esta tabla se resumen los nombres ABI asignados a los registros
Nombre | Nombre ABI | Uso | Descripción |
---|---|---|---|
x0 | zero | Zero | (Inmutable). Siempre está a 0 |
x1 | ra | Dirección de retorno | |
x2 | sp | Puntero de Pila (stack pointer) | Los accesos a la pila se hacen a través de este registro |
x3 | gp | Puntero global (Global pointer) | NO USAR. Se reserva para la gestión de excepciones |
x4 | tp | Puntero de hilo (Thread pointer) | NO USAR. Se reserva para la gestión de excepciones |
x5-x7 | t0-t2 | Registros temporales | Se usan en cualquier momento, por cualquiera, para cualquier uso |
x8-x9 | s0-s1 | Registro estáticos | Se garantiza que mantienen su valor al llamar a cualquier módulo |
x10-x17 | a0-a7 | Registro de argumentos | Son registros también temporales, usados para pasar datos entre módulos |
x18-x27 | s2-s11 | Registro estáticos | Se garantiza que mantienen su valor al llamar a cualquier módulo |
x28-x31 | t3-t6 | Registros temporales | Se usan en cualquier momento, por cualquiera, para cualquier uso |
A lo largo del curso iremos viendo para qué sirve cada registro. Empezaremos usando estos registros:
- t0-t6: Registros temporales. Los usaremos para cualquier cálculo intermedio o temporal
- a0-a7: Registros de argumentos. Se usan para almacenar los argumentos en las llamadas a otros módulos, y pasar así información entre ambos módulos. Son registro temporales. Los podemos usar para cualquier cálculo intermedio
A partir de ahora, SIEMPRE USAREMOS LOS NOMBRES ABI
Registros en el simulador RARs
En la columna de la izquierda, dentro de la ventana de registros del simulador RARs, están puestos los nombres ABI de los registros. En la columna central se encuentra el número de registro real: x0,x2,x3... omitiendo la x.
Ejemplo: Cálculo de una expresión
Como ejemplo vamos a calcular la expresión (a - b) + (c + 5) para los valores iniciales de a=1, b=2 y c=3, pero usando los registros con sus nombres ABI
Usamos los registros temporales: t0 - t6. Podemos usar cualquier de ellos para almacenar los valores de a, b y c. Por ejemplo: t0=a, t1=b y t2=c. El resultado final lo almacenamos en t6
El programa para evaluar la expresión es:
#-- Calculo de la expresión (a - b) + (c + 5)
#-- Usamos los nombres ABI de los registros
#-- Asignación: t0 = a, t1 = b, t2 = c
#-- Resultado en t6
#-- Evaluar para a=1, b=2, c=3
.text
#-- Inicialización de los registros
#-- t0 = a = 1
li t0, 1
#-- t1 = b = 2
li t1, 2
#-- t2 = c = 3
li t2, 3
#-------- Realizar el calculo de la expresión
#-- t3 = (a - b)
sub t3, t0, t1
#-- t4 = c + 5
addi t4, t2, 5
#-- t6 = t3 + t4 = (a - b) + (c + 5)
add t6, t3, t4
#-- Terminar
li a7, 10
ecall
Lo ensamblamos y lo ejecutamos. Nos fijamos en la primera instrucción
li t0, 1
Sabemos que li es una pseudo-instrucción, y que el ensamblador la transforma en addi. Pero además, el registro t0 se convierte a su equivalente x5, por lo que la instrucción real en ensamblador es:
addi x5, x0, 1
También observamos que en los registro t0, t1 y t2 contienen los valores iniciales: 1, 2 y 3. Y que efectivamente en t6 se encuentra el resultado final: t6 = 7
Llamadas al sistema
Hemos visto cómo nuestros programas se comunican con los periféricos mediante entrada/salida mapeada. Esto es típico de los sistema empotrados. Sin embargo, la mayoría de las veces existe un sistema operativo debajo que se encarga de la gestión de toda la entrada/salida, no teniendo que implementarla nosotros
El mecanismo que tienen nuestros programas para utilizar servicios proporcionados por el sistema operativo se denomina llamadas al sistema
Esta idea está esquematizada en la siguiente figura. Una vez que nuestro programa se empieza a ejecutar, en cualquier momento se puede realizar una llamada al sistema para acceder a uno de los servicios que ofrece, como por ejemplo imprimir un número en la consola, o leerlo de ella
En ese momento, el control lo toma el sistema operativo, que realiza la acción pedida. Una vez finalizada, devuelve el control a nuestro programa, que se sigue ejecutando hasta que se termina
El sistema operativo, en nuestro simulador, se encuentra en las direcciones bajas, mientras que nuestro programa está en el segmento de código. El esquema anterior lo podemos representar también de esta forma:
Arranca nuestro programa(1), que se encuentra en el segmento de código (empieza en 0x00400000). En un momento determinado se realiza una llamada al sistema(2), por lo que el control se pasa al sistema operativo(3) (cuyo código está en la región debajo del segmento de código). Al finalizar devuelve el control otra vez a nuestro programa (4). Nuestro programa puede realizar tantas llamadas al sistema como sea conveniente, aunque en la figura, por simplicidad, sólo se ha esquematizado una. Por último nuestro programa termina (5)
Llamando al sistema: ecall
Para realizar una llamada al sistema utilizamos la instrucción ecall. Desde el menú de ayuda (Tecla F1) tenemos acceso a todas las llamadas al sistema disponibles en el simulador RARs. Están en la pestaña syscalls
Cada servicio proporcionado por el sistema operativo tiene asignado un número, que debemos introducir en el registro a7. Además, si el servicio necesita de unos parámetros de entrada, se los pasaremos a través de los registros a0, a1 y a2. Los valores devueltos por el servicio estarán en a0 y a1. Todo esta información está disponible en la ayuda. La primera columna indica el Nombre del servicio y la segunda su código
Así, por ejemplo, el primer servicio mostrado en la ayuda es la llamada al sistema prinInt, cuyo código es 1. Nos permite imprimir un número entero en la consola. Como parámetro de entrada hay que introducir el número a imprimir en el registro a0. El servicio no devuelve ningún valor. En las siguientes secciones veremos ejemplos de uso
Puntos de entrada y salida del programa principal
Vamos a entender qué es lo que ocurre en un sistema RISC-V desde que lo encendemos. Al darle alimentación, el contador de programa se inicializa con la dirección de la primera instrucción del sistema operativo. Es decir, que es el sistema operativo el que toma el control
Típicamente realiza la inicialización y se queda esperando. En un momento determinado, le decimos que arranque nuestro programa, por lo que le pasa el control, y comienza su ejecución. Nuestro programa tiene una dirección, llamada punto de entrada, que es donde se encuentra la primera instrucción. De forma genérica, lo esquematizamos así:
Sabemos que en el RARs el punto de entrada está en la dirección 0x00400000. Una vez que el sistema operativo nos pasa el control, nuestro programa realiza los cálculos que sean y termina. Esta operación de "terminación" significa que hay que devolver el control otra vez al sistema operativo. Para ello hay que invocar la llamada al sistema EXIT. En esta figura se muestra el mismo esquema anterior, pero particularizado para el mapa de memoria del RARS
El lugar donde se invoca el servicio exit se denomina punto de salida
Lo siguiente es una regla de buenas prácticas de programación:
- El programa principal deberá tener un único punto de entrada y un único punto de salida
Terminando un programa: llamada al sistema EXIT
Vamos a probar nuestra primera llamada al sistema, que será el servicio de EXIT. Todos nuestros programas, a partir de ahora, acabarán invocando este servicio. La única es excepción será en el sistema RISC-V real en la FPGA, ya que en él no tenemos sistema operativo. Por lo que terminaremos con un bucle infinito (aunque en el laboratorio sólo trabajaremos con el simulador)
Si miramos el servicio de EXIT en la ayuda, vemos que tiene el código 10, y que no hay que pasarle ningún argumento (ni de entrada ni de salida):
En este programa estamos invocando el servicio Exit. Es un programa que no hace nada: en cuanto recibe el control del sistema operativo, lo devuelve otra vez (termina)
#-- Probando el servicio EXIT
.text
#-- Este es el PUNTO DE ENTRADA del programa principal
#-- No se hace nada
#-- Este es el punto de salida del programa principal
#-- se invoca al servicio exit
#-- Primero cargamos en el registro a7 el codigo de servicio
li a7, 10
#-- y ahora realizamos la llamada al sistema
ecall
¿Podríamos mejorar este programa? ¿Ves algo que te llame la atención?
¡¡Si!!! ¡Hay un número mágico! El 10, que se corresponde con el código del servicio de exit. Siguiendo las buenas praćticas de programación, conviene usar constantes para estos números mágicos. Esta es la nueva versión del programa:
#-- Probando el servicio EXIT
#-- Buenas prácticas de programación: definimos las
#-- constantes al comienzo del programa
#-- Código del servicio Exit, para terminar
.eqv EXIT 10
.text
#-- No se hace nada
#-- Terminar
li a7, EXIT
ecall
PrintInt: Imprimiendo un número entero en la consola
Para practicar con las llamadas al sistema, vamos a invocar el servicio de impresión de un número decimal en la consola: PrintInt. Sacaremos un número constante, por ejemplo el 200
Primero miramos los parámetros que tiene el servicio PrintInt, así como su código de servicio:
El código es el 1 (registro a7), y como entrada hay que pasarle le valor a imprimir en el registro a0. Este es el programa que muestra el número 200 en la consola. Utilizamos constantes para los dos servicios utilizados: PRINT_INT y EXIT
#-- Ejemplo de uso del servicio PrintINT para
#-- sacar un entero por la consola
#-- Servicio Print_Int
.eqv PRINT_INT 01
#-- Servicio Exit
.eqv EXIT 10
.text
#-- Imprimir un numero
li a0, 200
#-- Invocar el servicio print_int
li a7, PRINT_INT
ecall
#-- Terminar
li a7, EXIT
ecall
Lo ensamblamos y lo ejecutamos. Vemos que aparece el numero 200 en la consola
ReadInt: Leyendo un número entero de la consola
Vamos a probar ahora el servicio ReadInt, que nos permite leer un número entero desde la consola. Primero miramos la documentación, para ver el código y los argumentos
Su código es 5. No hay que pasarle ningún argumento de entrada. El número introducido por el usuario se devuelve en el registro a0
Para probarlo vamos a hacer un programa que almacene en el registro t0 el valor introducido por el usuario. Igual que antes, definimos la constante READ_INT con el valor del código:
#-- Ejemplo de uso del servicio ReadInt,
#-- para leer un número entero de la consola
#-- El dato leido se deposita en el registro t0
.eqv READ_INT 5
.eqv EXIT 10
.text
#-- Invocar ReadInt
li a7, READ_INT
ecall
#-- El número leido se devuelve por a0
#-- Lo pasamos a t0
mv t0, a0
#-- Terminar
li a7, EXIT
ecall
Lo ensamblamos y lo ejecutamos. Al arrancar el programa se queda esperando a que introduzcamos un número en la consola. Vemos que en la consola aparece el cursor:
Introducimos el número 200 (por ejemplo) y apretamos ENTER. El programa termina. En la derecha vemos que el registro t0 tiene el valor que hemos introducido
En esta animación se muestra el programa completo en funcionamiento:
Activando el diálogo de entrada
Para hacer más sencilla la entrada de datos, se pueden activar las ventanas de diálogo para nos aparezca una ventana nueva cada vez que necesitemos que el usuario introduzca datos de entrada, en vez de hacerlo por la consola
Esta opción se activa en el menú Settings/Pop up dialog for input syscalls (5,6,7,8,12)
Ahora, al ejecutar el programa nos aparecerá la ventana de diálogo pidiéndolos el número entero:
El programa funciona exactamente igual, simplemente que la entrada de datos es más visual. En esta animación se muestra en funcionamiento
PrintChar: Imprimiendo un caracter
Podemos imprimir cualquier carácter ASCII mediante el servicio PrintChar
El código de servicio es el 11. En el registro a0 se introduce el valor ascii del carácter a imprimir. Las constantes ASCII se pueden introducir bien con su número, decimal o hexadecimal, o bien directamente como el carácter entre comillas simples: 'a', '\n', 'Z', etc...
En este ejemplo se imprime el carácter 'A', pasándole su código ASCII hexadecimal (0x41) y luego un salto de línea usando como notación las comillas simples: '\n'. Como son dos caracteres, se invoca dos veces seguidas al servicio PrintChar
#-- Ejemplo de uso del servicio PrintChar
#-- para imprimir un carácter ASCII
#-- Se imprime una 'A' seguida de un salto de línea
.eqv PRINT_CHAR 11
.eqv EXIT 10
.text
#-- Imprimir una A, usando su codigo ASCII
li a0, 0x41
li a7, PRINT_CHAR
ecall
#-- Imprimir un salto de linea, usando '\n'
li a0, '\n'
li a7, PRINT_CHAR
ecall
#-- Terminar
li a7, EXIT
ecall
Lo ensamblamos y lo ejecutamos. En la consola veremos que aparece la A, y dos líneas más abajo la de finalización del programa (porque hay un salto de línea)
Usando el RARs en línea de comandos
El simulador RARs también lo podemos usar para ejecutar nuestros programas RISC-V desde la consola. En el laboratorio siempre lo utilizaremos con la interfaz gráfica, pero es interesante saber que también funciona en la consola
Para ejecutar el ejemplo de prueba de la llamada al sistema print_int, hacemos los siguiente. El fichero .jar con el simulador debe estar en el mismo directorio que el fuente (o bien indicamos en qué ruta está):
java -jar rars1_5.jar print_int.s
El programa se ensambla y se ejecuta. Vemos el resultado: aparece 200 en la consola y el programa termina. No se ha abierto en ningún momento la interfaz gráfica
También podemos ejecutar el ejemplo read_int. En este caso, el programa se queda esperando a que introduzcamos el valor por la consola. Le pasamos el parámetro t0 para que al terminar imprima el valor del registro t0. Y con el parámetro dec le decimos que lo muestre en decimal:
java -jar rars1_5.jar t0 dec read_int.s
Introducimos un valor, por ejemplo 600. Termina y nos imprime el valor del registro t0, que efectivamente vale 600 en decimal:
En esta animación se muestra su funcionamiento
Recopilación de instrucciones hasta el momento
- Instrucciones básicas: Son las que se transforman a código máquina y que ejecuta el procesador
- Pseudo-instrucciones: No existen realmente como instrucciones. El ensamblador las transforma en instrucciones básicas. Una pseudo-instrucción puede dar lugar a 1 ó varias instrucciones básicas
- Directivas: Dar información al programa ensamblador. No generan código máquina
Actividades NO guiadas
Practica. Piensa. Escribe código. Estúdialo. Modifícalo. Observa los errores. Soluciónalos. Esa es la manera en que te conviertas en un/a ingeniero/a excepcional. Recuerda: como ingeniero/a te contratarán para que resuelvas problemas. Problemas nuevos. Para eso recurren a ti
NOTA: A partir de ahora usaremos los registros con sus nombres de la ABI (Salvo que nos indiquen expresamente lo contrario)
Ejercicio 1
Escribe un programa para calcular la expresión f = (2*a - b) + c - 1. Utiliza los registros con sus nombres de la ABI de RISC-V. Evalúa la expresión para a = 10, b = 20 y c = 30, y comprueba que el resultado es correcto
Ejercicio 2
-
Escribe un programa que realice la suma de dos números enteros introducidos por el teclado. Se introducen los dos números y se muestra el resultado de la suma en la consola. Intenta usar buenas prácticas de programación. Activa los diálogos emergentes para la introducción de datos
-
Prueba el programa ejecutándolo desde la línea de comandos directamente
Ejercicio 3
Modifica el programa anterior para que los valores leídos se almacenen primero en las variables a y b. Después se debe realizar la suma leyendo estas variables, depositar el resultado en la variable f y finalmente imprimirlo por la consola. Usa como único puntero para acceder a las tres variables el registro s0. Ejecutar el programa y comprobar que las variables contienen los valores correctos, y que la suma es la que aparece en la consola
Ejercicio 4
Escribe un programa para pedir al usuario las 3 variables a,b y c, y calcular la expresión f = (2*a - b) + c - 1 (es la misma expresión del ejercicio 1). El resultado se imprime en la consola. No hace falta almacenar a,b, y f en memoria. Usar registros temporales
Ejercicio 5
Escribe un programa para pedir un número entero (0 - 255) y escribirlo en el puerto de salida del display de 7 segmentos derecho. Comprueba que funciona bien introduciendo diferentes valores. Por ejemplo, con el 255 (0xFF) se deben encender todos los segmentos (NOTA: No se está pidiendo que se visualice el número que introduce el usuario, sino que este número se envíe al Display de 7 segmentos directamente)
Ejercicio 6
Escribe un programa que pida al usuario cuatro valores enteros y los deposite consecutivamente en el segmento de datos. Luego los debe leer de memoria e imprimirlos en la consola. Tras cada número se debe sacar una coma "," por la consola. Todavía no sabemos hacer bucles, así que las escrituras y las lecturas se hacen para las 4 posiciones de memoria, una detrás de otra
Para probarlo, bajar la velocidad a 29 instrucciones por segundo. De esta forma podrás ver de manera interactiva cómo se van situando los valores en sus posiciones de memoria correspondientes
Ejercicio 7
Escribe un programa que recorra el segmento de datos (con bucle infinito) leyendo cada palabra e imprimiéndola en la consola. Inicializar el segmento de datos con los valores 1,2,3,4,5,6,7,8,9 y 10. Se debe usar el carácter de salto de línea para separar los valores impresos en la consola. Para probar el programa baja la velocidad a 29 instrucciones/segundo o bien utiliza un Breakpoint
Ejercicio 8
Escribe el programa del cálculo de la secuencia de Fibonacci , que ya has hecho en otros ejercicios, pero usando los registros con nombres ABI e imprimiendo cada término por la consola, separados por el carácter ','. No hay que imprimir los dos términos iniciales de Fibonacci
Ejercicio 9
Escribe un programa para leer un carácter usando el servicio ReadChar. El programa se ejecuta en un bucle infinito. Lee el carácter, lo incrementa en una unidad , lo imprime en la consola (es un cifrado muy simple) e imprime un salto de línea
Ejercicio 10
Modifica el programa del ejercicio 7 para que se impriman los valores del segmento de datos en hexadecimal usando el servicio PrintHex. Inicializa el segmento de datos con las palabras: 10,11,12,13,14,15,127,255,0x00FABADA, 0xCAFEBACA
Notas para el profesor
- Título informal de la clase: "No estamos solos..."
- A partir de ahora ya no podemos hacer lo que queramos... hay que seguir unas REGLAS, para convivir con otros programas
- El primer "programa" ajeno con el que convivimos es el Sistema Operativo
- Cuando estamos solos en casa, hacemos lo que queremos... es la anarquía... Si compartimos piso la cosa cambiar...