Uso del lenguaje en aplicaciones de bajo nivel
El mismo C con el que se escribe un programa de escritorio maneja las patas de un microcontrolador: alcanza con los operadores de bits, un puntero a una dirección fija y saber por qué hace falta volatile.
01Qué quiere decir «bajo nivel»
Un programa de bajo nivel es el que habla directamente con el hardware: prende un LED escribiendo un bit en un registro, lee un sensor esperando que un bit de estado cambie, arma una trama bit por bit para mandarla por un cable. En la pila de capas del primer tema, es trabajar justo arriba de la arquitectura del repertorio, sin sistema operativo en el medio.
C sirve para eso porque tiene tres cosas que los lenguajes de alto nivel esconden: operadores de bits, punteros a direcciones fijas y tipos de tamaño conocido. Con ellas, el mismo lenguaje con el que se escribe una planilla de cálculo manda sobre las patas de un chip.
02Operadores de bits y máscaras
| Operador | Qué hace | Para qué se usa |
|---|---|---|
a & b | Y bit a bit | Poner bits en 0 y preguntar por un bit |
a | b | O bit a bit | Poner bits en 1 |
a ^ b | O exclusivo bit a bit | Invertir bits |
~a | Complemento de todos los bits | Armar la máscara para borrar |
a << n, a >> n | Desplazamiento | Armar máscaras y extraer campos |
Una máscara es un número que tiene en 1 los bits sobre los que se quiere actuar. (1 << 5)
es 0010 0000: sólo el bit 5. Con ella salen las cuatro operaciones que se usan todo el tiempo:
reg |= (1 << n); // poner en 1 el bit n
reg &= ~(1 << n); // poner en 0 el bit n
reg ^= (1 << n); // invertir el bit n
if (reg & (1 << n)) // ¿está en 1 el bit n?
Las cuatro tocan sólo el bit elegido y dejan los demás como estaban. Es lo que importa cuando cada bit de un registro controla una pata distinta: prender un LED no puede apagar el de al lado.
El registro PORTB maneja ocho LEDs. Elegí la operación y el bit, y mirá la línea de C, la cuenta en binario y el resultado.
Para leer un campo, se corre y se enmascara: los bits 4 a 6 de reg son
(reg >> 4) & 0x07. Para escribirlo sin tocar el resto, primero se borra y después se
pone: reg = (reg & ~(0x07 << 4)) | ((v & 0x07) << 4). Así se configuran,
por ejemplo, el divisor de reloj de un temporizador o el canal de un conversor A/D.
03Registros en direcciones fijas
En un microcontrolador, los periféricos se manejan con registros que están en direcciones fijas de
memoria, publicadas en la hoja de datos. En el ATmega328P (el del Arduino Uno), el registro de salida del
puerto B está en la dirección de datos 0x25. En C se lo alcanza con un puntero a esa dirección:
#include <stdint.h>
#define PORTB (*(volatile uint8_t *)0x25)
#define DDRB (*(volatile uint8_t *)0x24)
DDRB |= (1 << 5); // PB5 como salida (el LED de la placa)
PORTB |= (1 << 5); // y encendido
Leído de adentro hacia afuera: 0x25 es un número; (volatile uint8_t *) lo convierte en
la dirección de un byte; el * de afuera accede al byte que está ahí. A partir de eso,
PORTB se usa como una variable más. Los archivos de cabecera del fabricante
(<avr/io.h>, los de CMSIS en ARM) están hechos exactamente así.
El compilador optimiza suponiendo que una variable sólo cambia cuando el programa la escribe. Un registro
de hardware cambia solo: el bit de «dato recibido» de una UART se pone en 1 cuando llega un byte. Sin
volatile, un ciclo while (!(UCSR0A & (1 << RXC0))); puede compilarse leyendo
el registro una sola vez y quedar colgado para siempre. volatile obliga a leer y escribir la
memoria real en cada acceso. Hace falta en los registros de hardware y en las variables que modifica una
rutina de interrupción.
04Tipos de tamaño exacto
El estándar de C sólo garantiza mínimos: un int tiene al menos 16 bits. En un AVR tiene 16; en
una PC, 32. Un programa que supone 32 bits se rompe al llevarlo a un microcontrolador. Para el trabajo con
hardware se usan los tipos de <stdint.h>, que dicen su tamaño en el nombre:
| Tipo | Bits | Rango |
|---|---|---|
uint8_t / int8_t | 8 | 0 … 255 / −128 … 127 |
uint16_t / int16_t | 16 | 0 … 65 535 / −32 768 … 32 767 |
uint32_t / int32_t | 32 | 0 … 4 294 967 295 / ±2 147 483 647 |
Un conversor de 10 bits con referencia de 5 V entrega de 0 a 1023. Para pasarlo a milivoltios:
uint32_t mv = (uint32_t)lectura * 5000 / 1023;. Sin la conversión a 32 bits, en un AVR la
multiplicación se haría en 16 bits: 1023 · 5000 = 5 115 000 no entra en 65 535 y el resultado sale mal
sin ningún aviso. Con la conversión, 1023 da exactamente 5000 mV. Los conversores se ven en
conversores A/D y D/A.
05Orden de los bytes y campos de bits
Un uint32_t ocupa cuatro bytes, pero ¿en qué orden? En un procesador little-endian
(x86, casi todos los ARM, AVR) el byte menos significativo va en la dirección más baja; en uno
big-endian, al revés. El valor 0x12345678 guardado en 0x100 queda así:
| Dirección | 0x100 | 0x101 | 0x102 | 0x103 |
|---|---|---|---|---|
| Little-endian | 78 | 56 | 34 | 12 |
| Big-endian | 12 | 34 | 56 | 78 |
Dentro de un programa no se nota. Se nota al mandar datos a otra máquina: los protocolos de red usan
big-endian, y por eso existen htons y ntohl. Lo portable es armar los bytes con
desplazamientos, b[0] = x >> 24; b[1] = x >> 16; …, que da el mismo resultado en
cualquier procesador. Aplica a cualquier trama de
comunicación serie.
C también permite declarar campos de bits dentro de una estructura, unsigned modo : 3;.
Son cómodos, pero el estándar deja al compilador el orden de los bits dentro del byte. Para describir
registros de hardware que tienen que coincidir bit a bit con la hoja de datos, las máscaras son más seguras.
06El programa frente al hardware
Un programa para un microcontrolador no termina nunca: inicializa los periféricos y entra en un ciclo
infinito. Dentro del ciclo consulta entradas y actualiza salidas; lo urgente lo atiende una
rutina de interrupción, que se comunica con el ciclo principal a través de variables
volatile:
volatile uint8_t hay_dato = 0;
volatile uint8_t dato;
ISR(USART_RX_vect) // la llama el hardware al llegar un byte
{
dato = UDR0;
hay_dato = 1;
}
int main(void)
{
inicializar();
for (;;) {
if (hay_dato) {
hay_dato = 0;
procesar(dato);
}
/* resto del trabajo, sin demoras largas */
}
}
En un procesador de 8 bits, leer un uint16_t lleva dos instrucciones. Si la interrupción lo
modifica entre las dos, el programa lee la mitad vieja y la mitad nueva. Esas lecturas se hacen con las
interrupciones deshabilitadas por un instante (una sección crítica). Es un error que aparece una vez
cada mil ejecuciones y no se encuentra probando: se evita sabiendo que existe.
Las interrupciones y los periféricos se desarrollan en interrupciones y en periféricos del microcontrolador.
07Qué hace el compilador con esto
La mejor manera de convencerse de que C está cerca de la máquina es mirar lo que genera. Para el AVR, con optimización:
| C | Ensamblador AVR | Comentario |
|---|---|---|
PORTB |= (1 << 5); | sbi 0x05, 5 | Una instrucción que pone en 1 un bit de un registro de E/S |
PORTB &= ~(1 << 5); | cbi 0x05, 5 | Y otra que lo pone en 0 |
if (PINB & (1 << 0)) | sbis 0x03, 0 | Salta la instrucción siguiente si el bit está en 1 |
El 0x05 es la dirección de PORTB en el espacio de E/S, que en el espacio de datos es la
0x25 del ejemplo anterior: hay 0x20 de diferencia porque los primeros 32 bytes del espacio de
datos son los registros de trabajo. Una línea de C, una instrucción de máquina: por eso no hace falta
programar en ensamblador para tener control fino. Los microcontroladores se ven en
arquitectura de
microprocesadores y los buses que se manejan con estas técnicas, en
buses serie.
08En el laboratorio
En un Arduino Uno, hacer parpadear el LED de PB5 escribiendo directamente en DDRB y PORTB, sin
pinMode ni digitalWrite. Medir con el osciloscopio la frecuencia máxima que se
logra con PORTB ^= (1 << 5) en un ciclo vacío y compararla con la de
digitalWrite. Se puede practicar el osciloscopio antes en el
simulador de instrumental.
Escribir y probar en la PC set_bit, clr_bit, toggle_bit,
get_field y set_field sobre un uint8_t, imprimiendo en binario cada
resultado. Probar con los casos extremos: bit 0, bit 7, campo que ocupa el byte entero.
Guardar 0x12345678 en un uint32_t, apuntarlo con un uint8_t * e imprimir
los cuatro bytes. Deducir si la máquina es little o big-endian.
09Errores frecuentes
reg = (1 << n)en lugar dereg |= (1 << n): prende un bit y apaga todos los demás.reg &= (1 << n)para apagar: sin el~borra todos los bits menos el n.- Olvidar
volatileen un registro o en una variable compartida con una interrupción. - Desplazar más bits de los que tiene el tipo:
1 << 31conintde 16 bits no está definido; se escribe1UL << 31. - Confundir
&&con&:4 && 2es 1;4 & 2es 0. - Escribir los números mágicos sueltos:
PORTB |= 0x20funciona, pero(1 << PB5)se entiende.
10Autoevaluación
¿Qué línea pone en 0 los bits 2 y 3 de reg sin tocar los demás?
reg &= ~((1 << 2) | (1 << 3));, o sea reg &= ~0x0C;
Si reg = 0xB4, ¿qué valen los bits 4 a 6?
0xB4 = 1011 0100. (0xB4 >> 4) & 0x07 = 1011 & 111 = 011 = 3.
¿Por qué el ciclo que espera un bit de estado necesita volatile?
Porque sin él el compilador puede leer el registro una sola vez, suponer que no cambia y convertir la espera en un ciclo infinito.
¿Cómo queda 0xA1B2 en memoria en una máquina little-endian?
Primero B2 y después A1.
¿Por qué lectura * 5000 puede dar mal en un AVR si lectura es uint16_t?
Porque la cuenta se hace en int, que en AVR tiene 16 bits, y el producto supera
65 535. Hay que convertir a 32 bits antes de multiplicar.
¿Qué es una sección crítica?
Un tramo de código que no puede ser interrumpido a la mitad sin dejar los datos inconsistentes. En un microcontrolador se protege deshabilitando las interrupciones durante ese tramo.
11Para ampliar
- Michael Barr y Anthony Massa. Programming Embedded Systems: With C and GNU Development Tools. 2.ª ed., O'Reilly, 2006 (en inglés). Registros, interrupciones y periféricos en C, con ejemplos sobre hardware real.
- Elecia White. Making Embedded Systems. 2.ª ed., O'Reilly, 2024 (en inglés). Cómo se diseña el software de un sistema embebido de punta a punta, con criterio de ingeniería.
- Randal E. Bryant y David R. O'Hallaron. Computer Systems: A Programmer's Perspective. 3.ª ed., Pearson, 2016 (en inglés). El capítulo 3 muestra, línea por línea, qué genera el compilador a partir de C.
- Microchip Technology. ATmega328P Datasheet. Hoja de datos oficial, en el sitio del fabricante. El mapa de registros de E/S y el repertorio de instrucciones que se usaron en este tema.