Prof. Edgardo Gho, UNLaM

Capítulo 2

Unidad 0: Assembly RISC-V

Ver enYouTube

Video de la cátedra de Arquitectura de Computadoras (UNLaM) sobre programación en assembly RISC-V. El profesor Edgardo Gho recorre la organización de la memoria de un programa, el trabajo con registros e inmediatos, las operaciones aritméticas y lógicas, el acceso a memoria y a vectores, la entrada y salida con ecall, los saltos, las subrutinas y las convenciones de llamada de RISC-V. Para seguir la práctica, el simulador de LYSZT ejecuta el mismo conjunto de instrucciones RV32IM, con lecciones guiadas en español y ejercicios que se corren en el navegador.

Temas

La organización de la memoria de un programa

  • Los compiladores y ensambladores separan el programa en secciones: .text almacena las instrucciones, .data los datos inicializados y .bss los datos no inicializados.
  • El stack es una memoria tipo pila para datos temporales, que se recuperan en el orden inverso al que entraron; por ejemplo, los argumentos que una función en una PC x86 recibe por el stack.
  • El heap es el espacio para la memoria dinámica, por ejemplo cuando se llama a malloc y el tamaño se define en tiempo de ejecución.
  • En Ripes, .data y .bss son equivalentes; el curso trabaja exclusivamente con .text y .data.

Registros e inmediatos

  • Para cargar el valor 128 en t0 se usa addi t0, x0, 128: como x0 siempre vale 0, sumarle 128 deja el 128 en el destino.
  • El formato tipo I soporta inmediatos de 12 bits con signo, así que el rango utilizable es de -2048 a 2047.
  • Para la parte alta de un registro se usa lui (formato tipo U), que carga los 20 bits más significativos y completa el resto con ceros, por ejemplo 0x12345000 en x5.
  • Queda como ejercicio combinar lui y addi para asignar 0x87654321, y comparar el resultado con la pseudoinstrucción li, que carga un número de 32 bits y el ensamblador la traduce a las instrucciones equivalentes.

Operaciones aritméticas y lógicas

  • li se traduce a la instrucción que exista en el set; cargar 2047 solo necesita addi porque cabe en 12 bits con signo, y el decremento se hace sumando -1 con addi.
  • Para negar un número se usa el formato tipo R: sub x4, x0, x5 calcula 0 menos x5, y el resultado queda en complemento a dos.
  • El formato R siempre es rd = rs1 operación rs2: sumas, restas, slt que graba 1 o 0 según qué registro es menor (con su versión sin signo sltu), desplazamientos lógicos y aritméticos, y las lógicas and, or y xor.

Acceso a la memoria de datos

  • Las variables se declaran en .data con nombre, formato y valor inicial: .word para 32 bits, .half para 16 y .byte para 1.
  • la (load address) es una pseudoinstrucción que resuelve la dirección de una variable; el ensamblador acomoda las secciones en rangos consecutivos de direcciones y la traduce a instrucciones relativas al PC.
  • lw lee una palabra con el modo base más desplazamiento, escribiendo el registro destino y entre paréntesis el registro base y el offset (0 para el inicio de una variable).
  • sw escribe en memoria de la misma forma: el ejemplo lee una variable con lw, le suma 1 al valor y la guarda de vuelta con sw; queda como práctica el mismo ciclo con lb y lbu, que leen un solo byte.

Vectores

  • Para acceder al elemento que indica un índice almacenado en memoria, primero se lee el índice con la y lw.
  • Como cada palabra ocupa 4 bytes, el offset se calcula multiplicando el índice por 4; sin instrucción de multiplicar, se usa un desplazamiento a la izquierda de 2 (slli), que duplica dos veces.
  • La dirección final es la base del vector más el offset, y de ahí se lee el elemento con lw.

Entrada y salida con ecall

  • Ripes provee funciones de soporte como si fuera un sistema operativo, que se invocan con la instrucción ecall.
  • El número de operación va en a7 y los argumentos en a0: con 4 se imprime el string cuya dirección está en a0 (terminado en cero), y con 1 se imprime el entero contenido en a0.
  • No existen printf ni scanf; todo el acceso a la consola pasa por estas llamadas.

Saltos y bucles

  • Las etiquetas marcan posiciones del programa para referenciarlas desde las instrucciones de salto.
  • Los saltos condicionales, como bge, saltan si la condición se cumple y si no continúan con la instrucción siguiente.
  • El ejemplo imprime los elementos de un vector hasta encontrar uno negativo, con un bucle que imprime con ecall, avanza el puntero de a 4 bytes y repite mientras el valor leído sea mayor o igual a cero.
  • El ensamblador calcula el offset del salto como la diferencia entre la dirección de la instrucción y la de la etiqueta: negativa hacia atrás, positiva para esquivar código.

Subrutinas

  • Una subrutina es un segmento de código con etiqueta, reutilizable, que recibe datos y devuelve un resultado.
  • jal (jump and link) salta a la etiqueta y guarda en el registro indicado la dirección de retorno; por convención se usa x1 (ra).
  • ret es la pseudoinstrucción que retorna con esa dirección, y el ejemplo min compara a0 y a1 con blt y devuelve el menor en a0.
  • El programa termina con un bucle infinito, un beq de x0 consigo mismo, como un while(1).

Convenciones de llamada

  • Las convenciones de llamada son los acuerdos entre quien escribe una subrutina y quien la usa, sobre cómo se pasan argumentos y se devuelven resultados.
  • RISC-V define los registros a0 a a7 para pasar argumentos (y el stack si hacen falta más) y a0, con a1 para resultados de más de 32 bits, para devolverlos.
  • Registros caller-saved: quien llama debe guardarlos antes porque la subrutina puede destruirlos; son ra, los temporales t0 a t6 y los argumentos a0 a a7.
  • Registros callee-saved: la subrutina debe preservarlos y restaurarlos antes de retornar; son el stack pointer y los s0 a s12.
  • El global pointer y el thread pointer los usan los compiladores y no se definen en la convención, y x0 nunca necesita guardarse porque siempre vale cero.