← Índice del curso

Microcontroladores · Tema 7

Conjunto de instrucciones y ensamblador

El lenguaje de la CPU es corto y regular. Aprender a leerlo no es para escribir programas enteros en ensamblador, sino para entender qué cuesta cada línea de C, por qué una operación es rápida y otra lenta, y qué ocurre en realidad cuando escribís PORTB |= 0x20.

01 · Punto de partida

Leer lo que hace el compilador

En el tema 6 vimos que la CPU ejecuta instrucciones de 16 bits. Tu programa en C no llega así al chip: el compilador lo traduce a una secuencia de esas instrucciones. En una computadora de escritorio rara vez importa cuántas instrucciones genera una línea; en un microcontrolador de 16 MHz y 2 KB de SRAM, sí.

El lenguaje ensamblador es la forma legible de esas instrucciones: un nombre corto (mnemónico) por cada patrón de bits. Existe una correspondencia casi uno a uno entre una línea de ensamblador y una instrucción de máquina. Si sabés programar, aprender a leerlo lleva horas, no meses: el conjunto de instrucciones es pequeño y muy regular.

Retomamos la CPU del tema 6 (registros, banderas, ciclo de instrucción) y el código PORTB ^= (1 << PB5) del tema 5.

02 · El vocabulario

El conjunto de instrucciones

El conjunto de instrucciones (ISA, instruction set architecture) es el contrato entre el software y la CPU: define qué operaciones existen, cómo se codifican y qué registros ven. El AVR tiene alrededor de 130 instrucciones y pertenece al estilo RISC (reduced instruction set computer): instrucciones simples, de formato regular y, en su mayoría, de un ciclo.

Una característica importante es que es una arquitectura de carga y almacenamiento (load/store). Las operaciones aritméticas y lógicas trabajan solo con registros; para operar con una variable guardada en SRAM hay que traerla primero a un registro, operar y guardar el resultado.

Familias de instrucciones del AVR, con ejemplos.
FamiliaQué haceEjemplos
Transferencia de datosMueve valores entre registros, memoria y puertosLDI, MOV, LDS, STS, LD, ST, IN, OUT, PUSH, POP
AritméticasSuman, restan, multiplican, comparanADD, ADC, SUB, SBC, SUBI, INC, DEC, MUL, CP, CPI
LógicasOperan bit a bitAND, ANDI, OR, ORI, EOR, COM
BitsDesplazan o prueban y modifican bits sueltosLSL, LSR, ROL, ROR, SBI, CBI, SBIS, SBIC
Control de flujoSaltan, llaman a funciones y retornanRJMP, JMP, RCALL, CALL, RET, RETI, BREQ, BRNE, BRCS, BRCC
Control del microcontroladorHabilitan interrupciones, duermen, reinician el vigilanteSEI, CLI, SLEEP, WDR, NOP

No todos los AVR tienen las mismas instrucciones. Los modelos más pequeños (algunos ATtiny) carecen de MUL, o de JMP y CALL de 32 bits. El ATmega328P es de los más completos.

03 · La sintaxis

Anatomía de una línea de ensamblador

Una línea de ensamblador tiene hasta cuatro partes, todas opcionales:

etiqueta: mnemónico operando1, operando2 ; comentario
bucle:  add  r17, r16      ; acumulador += contador
        dec  r16           ; contador -= 1
        brne bucle         ; si no llegó a 0, volver a «bucle»
  • La etiqueta es un nombre para una dirección. Permite escribir brne bucle sin calcular a mano cuántas posiciones hay que retroceder.
  • El mnemónico identifica la operación. En AVR el destino va primero: add r17, r16 significa R17 ← R17 + R16.
  • Los operandos pueden ser registros (r16), constantes (5, 0x20), direcciones de E/S o etiquetas.
  • El comentario empieza con ;. En ensamblador los comentarios son imprescindibles: el código, por sí solo, dice «cómo» pero no «para qué».

El ensamblador (assembler) es el programa que convierte este texto en los bits de cada instrucción. Es mucho más simple que un compilador porque no interpreta intenciones: traduce. Para resolver las etiquetas hace dos pasadas: primero recorre el texto y anota la dirección de cada etiqueta, y luego codifica las instrucciones, ahora que conoce todos los destinos de los saltos. El laboratorio de este tema incluye un ensamblador funcional.

Además de instrucciones hay directivas, órdenes dirigidas al ensamblador y no a la CPU, como .equ (definir una constante), .org (fijar una dirección) o .section (elegir dónde va el código). No generan instrucciones.

04 · Las que más se usan

Las instrucciones esenciales

Instrucciones de uso diario y su costo, según el conjunto de instrucciones del AVR.
InstrucciónEfectoCiclos
LDI Rd, KCarga una constante en R16 a R311
MOV Rd, RrCopia un registro en otro1
LDS Rd, dir / STS dir, RrLee o escribe un byte de memoria en una dirección fija (instrucción de 32 bits)2
LD Rd, X / ST X, RrLee o escribe usando un puntero (X, Y o Z)2
IN Rd, A / OUT A, RrLee o escribe un registro de E/S (direcciones 0 a 63)1
ADD, ADC, SUB, SBCAritmética de 8 bits, con o sin acarreo1
AND, OR, EOROperaciones lógicas bit a bit1
SBI A, b / CBI A, bPone en 1 o en 0 el bit b de un registro de E/S (direcciones 0 a 31)2
CP, CPIResta sin guardar el resultado: solo actualiza banderas1
BRNE, BREQ, BRCS, BRCCSalto condicional según Z o C1 / 2
RJMP, JMPSalto incondicional (corto o largo)2 / 3
RCALL, CALL, RETLlamar a una subrutina y volver3 / 4 / 4
PUSH, POPGuardar o recuperar un registro en la pila2

Dos detalles prácticos. Primero, las operaciones con constantes (LDI, SUBI, CPI, ANDI, ORI) solo funcionan con los registros R16 a R31: es la razón por la que el compilador los prefiere para sus cuentas. Segundo, IN, OUT, SBI y CBI no usan la dirección de datos de los registros (0x25 para PORTB), sino la dirección de E/S (0x05), que es la anterior menos 0x20.

05 · La traducción

De C a ensamblador

La tabla siguiente muestra, para operaciones frecuentes, el ensamblador equivalente típico con optimización por tamaño (-Os). Son formas habituales; el compilador real puede elegir otros registros o reordenar las instrucciones.

Equivalentes típicos. El conteo de ciclos supone operandos ya en registros.
En CEn ensambladorCiclos
PORTB |= (1 << PB5);sbi 0x05, 52
PORTB &= ~(1 << PB5);cbi 0x05, 52
PORTB ^= (1 << PB5);in r24, 0x05
ldi r25, 0x20
eor r24, r25
out 0x05, r24
4
PINB = (1 << PB5);sbi 0x03, 52
c = a + b; (uint8_t)add r24, r221
c = a + b; (uint16_t)add r24, r22
adc r25, r23
2
c = a / b; (uint8_t)call __udivmodqi4 (rutina de biblioteca)decenas

Esta tabla enseña varias lecciones. Escribir un bit con |= o &= se reduce a una sola instrucción, SBI o CBI, porque el compilador reconoce el patrón. Invertir un bit con ^= requiere leer, modificar y escribir: cuatro instrucciones. El ATmega328P ofrece un atajo documentado: escribir un 1 en un bit de PINx invierte el bit correspondiente de PORTx, con una sola instrucción. Sumar 16 o 32 bits cuesta una instrucción por byte, encadenadas por el acarreo, como vimos en el tema 2. Y dividir no tiene hardware, de modo que el compilador llama a una rutina de software mucho más lenta que una suma.

Los tipos importan. Un int en avr-gcc ocupa 16 bits y una suma ya necesita dos instrucciones; un uint8_t cabe en un registro. Elegir el tipo más pequeño que alcance reduce Flash, SRAM y tiempo.

06 · Subrutinas

La pila y las llamadas

Las funciones de C se traducen a subrutinas. Para volver al punto de llamada, la CPU necesita recordar la dirección de retorno, y para eso usa la pila (stack), una zona de la SRAM manejada por el puntero SP.

  1. CALL o RCALL guardan en la pila la dirección de la instrucción siguiente (2 bytes en el ATmega328P) y cargan el PC con la dirección de la subrutina.
  2. La subrutina hace su trabajo. Si necesita registros que la función que llamó está usando, los guarda con PUSH y los restaura con POP.
  3. RET saca de la pila la dirección de retorno y la carga en el PC.

La pila del AVR crece hacia direcciones menores. Al arrancar, el código de inicio coloca el SP en el final de la SRAM (0x08FF, el valor llamado RAMEND), y cada PUSH o CALL lo decrementa. Las variables globales ocupan la parte baja de la SRAM; la pila baja desde el extremo opuesto. Si se encuentran, el programa se corrompe sin avisar.

Las rutinas de interrupción (tema 19) terminan con RETI en lugar de RET: además de retornar, vuelve a habilitar las interrupciones. Y el hardware que las dispara también guarda la dirección de retorno en la pila.

07 · De mnemónico a bits

Cómo se codifica una instrucción

Cada instrucción es un patrón de 16 bits (algunas, de 32). Los bits se reparten en campos fijos: código de operación y operandos. Estos son los formatos de las instrucciones usadas en este tema:

Formatos de codificación. Cada letra representa un bit de un campo.
InstrucciónFormato (bits 15 a 0)Campos
LDI Rd, K1110 KKKK dddd KKKKd = Rd − 16 (4 bits); K de 8 bits partido en dos mitades
ADD Rd, Rr0000 11rd dddd rrrrd y r de 5 bits; sus bits altos están en las posiciones 8 y 9
DEC Rd1001 010d dddd 1010d de 5 bits
OUT A, Rr1011 1AAr rrrr AAAAA de 6 bits (dirección de E/S) partido en dos; r de 5 bits
SBI A, b1001 1010 AAAA AbbbA de 5 bits; b de 3 bits (número de bit)
BRNE k1111 01kk kkkk k001k de 7 bits con signo, relativo a la instrucción siguiente
RJMP k1100 kkkk kkkk kkkkk de 12 bits con signo

Veamos sbi 0x05, 5. La dirección 0x05 es 00101 y el bit 5 es 101. Rellenando el formato: 1001 1010 0010 1101, es decir, 0x9A2D. Eso es todo lo que el chip necesita saber para encender PB5 en dos ciclos.

Del formato salen límites que parecen caprichosos hasta que se entienden. SBI solo llega a las direcciones de E/S 0 a 31, porque su campo A tiene 5 bits. BRNE solo salta de −64 a +63 palabras, porque su desplazamiento tiene 7 bits. LDI solo admite R16 a R31 porque su campo de registro tiene 4 bits. Cuando una instrucción no alcanza, el compilador usa una más larga o combina varias.

08 · Analogía con programación

Un ensamblador en cuarenta líneas

Como el ensamblador solo traduce, se puede escribir uno de juguete. Este programa en Python implementa las dos pasadas: primero anota las direcciones de las etiquetas y luego codifica cada instrucción con los formatos de la sección anterior. El programa de prueba es el del tema 6, y deberías obtener exactamente las mismas palabras.

Python en tu navegador. Los bloques son editables: cambiá los valores y presioná «Ejecutar Python». La primera vez tarda unos segundos en cargar.

FORMATOS = {                     # operación -> código base (formato Rd, Rr)
    "add": 0x0C00, "adc": 0x1C00, "sub": 0x1800, "eor": 0x2400, "mov": 0x2C00,
}


def registro(texto):
    return int(texto.strip().lower().lstrip("r"))


def numero(texto):
    return int(texto.strip(), 0)


def ensamblar(fuente):
    # Pasada 1: separar etiquetas e instrucciones, y anotar las direcciones.
    lineas, etiquetas = [], {}
    for crudo in fuente.splitlines():
        texto = crudo.split(";")[0].strip()
        if ":" in texto:
            nombre, texto = texto.split(":", 1)
            etiquetas[nombre.strip()] = len(lineas)
            texto = texto.strip()
        if texto:
            lineas.append(texto)

    # Pasada 2: codificar, ahora que se conocen todas las etiquetas.
    palabras = []
    for direccion, texto in enumerate(lineas):
        mnemonico, _, resto = texto.partition(" ")
        ops = [o.strip() for o in resto.split(",")] if resto else []
        if mnemonico in FORMATOS:
            d, r = registro(ops[0]), registro(ops[1])
            palabra = FORMATOS[mnemonico] | ((r & 0x10) << 5) | (d << 4) | (r & 0xF)
        elif mnemonico == "ldi":
            d, k = registro(ops[0]), numero(ops[1])
            palabra = 0xE000 | ((k & 0xF0) << 4) | ((d - 16) << 4) | (k & 0xF)
        elif mnemonico == "dec":
            palabra = 0x940A | (registro(ops[0]) << 4)
        elif mnemonico == "out":
            a, r = numero(ops[0]), registro(ops[1])
            palabra = 0xB800 | ((a & 0x30) << 5) | (r << 4) | (a & 0xF)
        elif mnemonico == "brne":
            k = etiquetas[ops[0]] - (direccion + 1)      # relativo a la siguiente
            palabra = 0xF401 | ((k & 0x7F) << 3)
        elif mnemonico == "rjmp":
            k = etiquetas[ops[0]] - (direccion + 1)
            palabra = 0xC000 | (k & 0xFFF)
        else:
            raise ValueError(f"instrucción desconocida: {mnemonico}")
        palabras.append(palabra)
    return palabras


programa = """
        ldi r16, 5        ; contador
        ldi r17, 0        ; acumulador
bucle:  add r17, r16
        dec r16
        brne bucle
        out 0x05, r17     ; PORTB
fin:    rjmp fin
"""

for direccion, palabra in enumerate(ensamblar(programa)):
    print(f"{direccion}: {palabra:#06x}  {palabra:016b}")

Resultado: siete palabras, de 0xE005 a 0xCFFF, idénticas a las que ejecutó la CPU del tema 6. Probá agregar una etiqueta nueva o cambiar brne bucle por rjmp bucle y observá cómo cambia el desplazamiento.

El código ilustra un punto de fondo: un ensamblador es una tabla de formatos más un cálculo de desplazamientos. No tiene misterio. La analogía con un compilador de lenguajes de alto nivel termina ahí: el compilador decide qué instrucciones usar; el ensamblador solo traduce las que le dan.

09 · Exploración interactiva

Ensamblador y costo en ciclos

La primera herramienta es un ensamblador real para un subconjunto del AVR. Escribí o editá el programa: se ensambla mientras tipeás y la última columna vuelve a decodificar cada palabra, de modo que ves el viaje completo de ida y vuelta. La segunda muestra el costo típico de operaciones de C.

Taller de ensamblador

1. Ensamblar

Resultado: dirección, instrucción original, palabra, binario y desensamblado.
DirecciónFuentePalabraBinarioDesensamblado

2. Desensamblar una palabra

Instrucción—
Binario—

3. ¿Cuánto cuesta esta línea de C?

Tamaño en Flash—
Ciclos—
Tiempo a 16 MHz—

El ensamblador del laboratorio soporta unas 25 instrucciones y no incluye directivas ni instrucciones de 32 bits. Los conteos de la tercera herramienta son los equivalentes típicos de la sección 5, no la salida de un compilador concreto: la salida real depende de la versión de avr-gcc, del nivel de optimización y del contexto.

Recorrido guiado

  1. Con «Suma con bucle» comprobá que las siete palabras coinciden con las del tema 6. Fijate en BRNE bucle: el ensamblador calculó −3 por vos.
  2. Cargá «Encender y apagar un LED» y observá que SBI PORTB, PB5 se ensambla como 0x9A2D, el mismo valor que calculamos a mano.
  3. Con «Invertir un bit» contá cuatro instrucciones y compará con el atajo SBI PINB, PB5 de la sección 5: escribilo en una línea nueva.
  4. Cargá «Con errores»: el ensamblador informa la línea y la causa. Corregí primero el registro (cambiá r5 por r20) y luego definí la etiqueta.
  5. En la herramienta 2 probá 9A2D, 9508 y 0000. En la 3 recorré la lista y mirá cuánto cambian los ciclos entre una suma de 8 bits y una de 32.
Desafío: un salto demasiado largo

Escribí un BRNE hacia una etiqueta y poné entre ambos más de 64 instrucciones (por ejemplo, 70 líneas nop). El ensamblador informa que el salto condicional no alcanza. Es la limitación de los 7 bits del desplazamiento. El compilador resuelve estos casos invirtiendo la condición y usando un RJMP o JMP para el salto largo.

10 · Método de trabajo

Cómo mirar el código generado

  1. Compilá con la optimización que vas a usar (normalmente -Os). Con -O0 el código es muy distinto y poco representativo.
  2. Pedí el listado en ensamblador. avr-gcc -S -Os -mmcu=atmega328p programa.c genera programa.s.
  3. O desensamblá el resultado final. avr-objdump -d -S programa.elf muestra las instrucciones reales mezcladas con las líneas de C que las originaron.
  4. Buscá primero lo que importa: un bucle caliente, una rutina de interrupción o una operación sospechosa (__udivmodqi4, __mulsf3).
  5. Contá ciclos con la tabla del fabricante y compará con una medición real en el osciloscopio.
  6. Optimizá solo lo medido. Cambiá tipos, evitá divisiones y recién entonces, si hace falta, escribí ensamblador en línea para un fragmento crítico.

Existen además herramientas en línea que muestran el ensamblador de un fragmento de C para distintos compiladores, incluido avr-gcc, lo que permite experimentar sin instalar nada.

11 · Comprensión

Confusiones frecuentes

«Ensamblar y compilar son lo mismo»

El compilador decide qué instrucciones usar a partir de un lenguaje de alto nivel. El ensamblador solo traduce cada mnemónico a su patrón de bits y resuelve etiquetas. El primero toma decisiones; el segundo no.

«Escribir en ensamblador siempre da un programa más rápido»

Los compiladores modernos generan código muy bueno, a veces mejor que el escrito a mano. El ensamblador se justifica en fragmentos pequeños y críticos, no en programas enteros: es difícil de mantener y de portar a otro chip.

«Cada instrucción ocupa 2 bytes»

La mayoría ocupa 16 bits, pero JMP, CALL, LDS y STS ocupan 32. Para saber cuánto ocupa un programa se mide con avr-size.

«Una línea de C es una instrucción de máquina»

Puede ser ninguna (si se optimiza), una o muchas. Una suma de 32 bits son cuatro instrucciones y una división, una llamada a una rutina de decenas de ciclos.

«El ensamblador es el mismo para todos los microcontroladores»

Cada familia tiene su propio conjunto de instrucciones. El de un AVR, el de un ARM Cortex-M y el de un ESP32 son distintos. Los conceptos (registros, saltos, pila) se repiten, pero no los mnemónicos ni la codificación.

12 · Práctica guiada

Predecí antes de comprobar

1. Codificar a mano

Con el formato 1001 1010 AAAA Abbb, codificá SBI 0x05, 5. ¿Cuántas palabras ocupa y cuántos ciclos tarda?

Ver solución

A = 0x05 = 00101; b = 5 = 101. La instrucción es 1001 1010 0010 1101 = 0x9A2D. Ocupa una palabra (2 bytes) y tarda 2 ciclos.

2. Costo de invertir un bit

Compará PORTB ^= (1 << PB5) con PINB = (1 << PB5) en tamaño y en tiempo a 16 MHz.

Ver solución

La primera usa cuatro instrucciones (8 bytes) y 4 ciclos = 250 ns. La segunda, una instrucción SBI (2 bytes) y 2 ciclos = 125 ns. La segunda es el doble de rápida y ocupa un cuarto del espacio.

3. Suma de 32 bits

¿Cuántas instrucciones y cuánto tiempo, a 16 MHz, necesita sumar dos enteros de 32 bits que ya están en registros?

Ver solución

Un ADD y tres ADC: 4 instrucciones, 4 ciclos = 250 ns (4 × 62,5 ns).

4. Calcular un desplazamiento

Un RJMP está en la dirección 4 y la etiqueta destino en la dirección 10. ¿Cuál es el desplazamiento y la palabra codificada?

Ver solución

k = destino − (dirección + 1) = 10 − 5 = 5. Se codifica 1100 0000 0000 0101 = 0xC005.

5. La pila y las funciones anidadas

Una función usa 6 bytes de pila por nivel (2 de retorno y 4 de registros guardados). Si se permiten 300 bytes de pila, ¿cuál es la profundidad máxima de llamadas anidadas?

Ver solución razonada

300 / 6 = 50 niveles como máximo. En la práctica hay que dejar margen: las interrupciones también usan la pila, y las variables globales reducen el espacio disponible. Un diseño seguro limita la profundidad a mucho menos.

13 · Síntesis

Ideas para recordar

  • El conjunto de instrucciones es el contrato entre el software y la CPU; el AVR es RISC y de tipo carga y almacenamiento.
  • Una línea de ensamblador tiene etiqueta, mnemónico, operandos y comentario; el ensamblador la convierte en bits en dos pasadas.
  • Las operaciones aritméticas trabajan con registros; LDI y similares solo con R16 a R31.
  • SBI y CBI cambian un bit de un puerto en una sola instrucción de dos ciclos.
  • El costo de una línea de C depende del tipo y de la operación: sumar 8 bits es barato; dividir o usar float es caro.
  • Las llamadas guardan la dirección de retorno en la pila, que crece hacia abajo desde el final de la SRAM.
  • Los formatos de codificación explican los límites de cada instrucción: alcance de SBI, rango de BRNE, registros de LDI.

¿Listo para el tema 8?

Antes de avanzar, explicá qué hace el ensamblador con una etiqueta, codificá a mano una instrucción con su formato, estimá el costo de PORTB ^= 0x20 y describí qué ocurre en la pila durante un CALL y un RET. Si podés justificar las cuatro respuestas, podés leer el código que genera el compilador.

En el próximo tema nos ocupamos de las memorias del microcontrolador: dónde viven el programa, las variables y los datos persistentes, y cómo se usan la Flash, la SRAM y la EEPROM.