← Índice del curso

Microcontroladores · Tema 10

Manipulación de bits en C

Programar un microcontrolador es, en buena medida, cambiar bits sueltos dentro de bytes sin alterar los vecinos. Con cuatro patrones de código y dos trampas del lenguaje (los tipos enteros y volatile) se cubre casi todo lo que vas a escribir.

01 · Punto de partida

El vocabulario del hardware

En el tema 9 aprendiste que cada bit de un registro tiene su significado. Para cambiar uno sin alterar los otros siete necesitás los operadores bit a bit de C. Si trabajaste con permisos de archivos, banderas de configuración o máscaras de red, ya usaste esas ideas; aquí las aplicamos con tipos de ancho fijo y registros que cambian por su cuenta.

Este tema se ocupa de tres cosas: las operaciones (qué expresión pone, borra, invierte o prueba un bit), los tipos (por qué ~ sobre un byte da un resultado inesperado en un AVR) y volatile (por qué el compilador puede romper un programa correcto si no se lo avisás).

Parte de los operadores &, |, ^, ~, << y >> que viste en cursos de programación, y del tema 9 (registros, máscaras con (1 << BIT)).

02 · Las herramientas

Los operadores bit a bit

Operadores bit a bit de C.
OperadorNombreRegla por bitUso típico
&AND1 si ambos son 1Aislar o borrar bits; probar un bit
|OR1 si alguno es 1Poner bits en 1
^XOR1 si son distintosInvertir bits
~NOTInvierte cada bitArmar máscaras negativas
<<Desplazamiento a la izquierdaMueve los bits hacia el bit altoUbicar un bit o un campo en su posición
>>Desplazamiento a la derechaMueve los bits hacia el bit bajoExtraer un campo

No los confundas con los operadores lógicos &&, || y !, que tratan cada operando como «verdadero» o «falso» y devuelven 0 o 1. Con 0x01 && 0x02 el resultado es 1 (ambos son distintos de cero); con 0x01 & 0x02 es 0 (no comparten ningún bit en 1).

Las máscaras que se arman con (1 << n) tienen un único bit en 1, en la posición n. Es la pieza básica de todo lo que sigue.

03 · Los cuatro patrones

Poner, borrar, invertir y probar

Las cuatro operaciones sobre un bit. Ejemplo con PORTB = 0010 1010 y mascara = 1 << 5 o 1 << 1.
OperaciónCódigoPor qué funciona
Poner en 1PORTB |= (1 << PB0);OR con 1 deja un 1; OR con 0 no cambia nada
Poner en 0PORTB &= ~(1 << PB1);La máscara invertida tiene un 0 en ese bit y 1 en el resto: AND borra uno y conserva los demás
InvertirPORTB ^= (1 << PB5);XOR con 1 invierte; XOR con 0 no cambia
Probarif (PINB & (1 << PB3)) { … }AND aísla el bit: queda distinto de cero solo si el bit estaba en 1

Veamos PORTB &= ~(1 << PB1) paso a paso, con PORTB = 0010 1010:

1 << PB1 = 0000 0010
~(1 << PB1) = 1111 1101
PORTB & ~… = 0010 1010 & 1111 1101 = 0010 1000

Para poner varios bits a la vez se combinan las máscaras con OR: PORTB |= (1 << PB0) | (1 << PB2);. Para borrar varios: PORTB &= ~((1 << PB0) | (1 << PB2));, con el NOT afuera de los paréntesis que agrupan las máscaras.

Y para probar que un bit esté en 0, negá la prueba: if (!(PINB & (1 << PB3))). Para esperar a que un bit cambie, el patrón típico es un bucle: while (!(UCSR0A & (1 << UDRE0))) { } (esperar a que la UART quede lista).

Como vimos en el tema 7, el compilador reconoce estos patrones y los convierte en una sola instrucción SBI o CBI cuando el registro está en las primeras 32 direcciones de E/S y la máscara es un bit constante. Esa es la razón por la que conviene escribirlos en la forma estándar.

04 · Varios bits a la vez

Campos de varios bits

Muchos registros agrupan bits contiguos que forman un número: el canal del ADC (MUX3:0), el divisor de reloj (CS02:0), el modo de un temporizador. Para cambiar solo ese campo hay que seguir dos pasos: borrarlo con su máscara y escribir el valor nuevo ya desplazado a su posición:

REG = (REG & ~MASCARA) | (valor << POSICION)

Ejemplo: el canal del ADC ocupa los cuatro bits bajos de ADMUX. Para seleccionar el canal 2 sin tocar la referencia de tensión ni la alineación:

// ADMUX = 0100 0101  (referencia AVCC, canal 5)
ADMUX = (ADMUX & 0xF0) | (2 & 0x0F);      // canal 2, el resto igual
// ADMUX = 0100 0010

// Lo mismo, con la máscara explícita:
ADMUX = (ADMUX & ~(0x0F << 0)) | (2 << 0);

El & 0x0F sobre el valor es un seguro: si canal valiera 20, sin la máscara los bits que sobran invadirían la referencia de tensión. Para leer un campo se hace lo inverso: desplazar a la derecha y enmascarar. Por ejemplo, el canal actual es ADMUX & 0x0F, y un campo en los bits 5 a 4 se extrae con (REG >> 4) & 0x03.

El laboratorio incluye un editor de campos que muestra cada paso de esta fórmula.

05 · Código que se lee

Macros y legibilidad

La biblioteca avr-libc ofrece macros que hacen más claro el código:

Macros de avr/io.h (incluidas desde sfr_defs.h).
MacroEquivale aEjemplo
_BV(b)(1 << (b))PORTB |= _BV(PB5);
bit_is_set(reg, b)El bit b de reg vale 1if (bit_is_set(PINB, PB0))
bit_is_clear(reg, b)El bit b de reg vale 0if (bit_is_clear(PIND, PD2))
loop_until_bit_is_set(reg, b)Bucle que espera a que el bit pase a 1loop_until_bit_is_set(UCSR0A, UDRE0);

También podés definir las tuyas, con un cuidado: una macro que evalúa su argumento más de una vez puede ejecutar efectos secundarios dos veces.

#define PONER(reg, bit)    ((reg) |=  (1 << (bit)))
#define BORRAR(reg, bit)   ((reg) &= ~(1 << (bit)))
#define INVERTIR(reg, bit) ((reg) ^=  (1 << (bit)))
#define ESTA_EN_1(reg, bit) (((reg) >> (bit)) & 1)

PONER(PORTB, PB5);       // encender el LED
if (ESTA_EN_1(PIND, PD2)) { BORRAR(PORTB, PB5); }

Con estas macros el código dice qué hace, no solo cómo. Cada paréntesis es necesario: sin ellos, un argumento como a + b cambiaría el orden de las operaciones. Un detalle importante: ESTA_EN_1 devuelve 0 o 1, mientras que la forma reg & (1 << bit) devuelve 0 o la máscara (por ejemplo, 32 para el bit 5).

06 · Cuidado con los tipos

Tipos enteros: la trampa de AVR

En C, antes de operar, los enteros pequeños se promueven a int. En avr-gcc un int tiene 16 bits, no 32 como en una computadora de escritorio. De ahí nacen tres trampas:

  1. ~ sobre un byte da un entero de 16 bits. Con uint8_t a = 0xF0;, la expresión ~a vale 0xFF0F (-241), no 0x0F. Por eso if (~a == 0x0F) es falso. Al asignar a una variable de 8 bits se conservan los bits bajos y todo parece correcto, y por eso el error pasa inadvertido: la comparación directa es la que falla. Solución: convertir, (uint8_t)~a == 0x0F.
  2. 1 << 15 desborda. El literal 1 es un int de 16 bits con signo, y 1 << 15 pone un 1 en el bit de signo. Para máscaras de 16 bits usá 1U << 15, y para 32 bits, 1UL << 31. La macro _BV() sufre el mismo problema.
  3. El desplazamiento a la derecha de valores con signo conserva el signo (el comportamiento lo define la implementación). Con datos que representan bits, usá siempre tipos sin signo.

La recomendación para código de registros es sencilla: usar los tipos de ancho fijo de stdint.h (uint8_t, uint16_t, uint32_t), preferir unsigned para todo lo que sean bits, y agregar sufijos U o UL en las constantes de máscaras grandes.

Los tipos también afectan al rendimiento. Como vimos, un uint8_t se opera en una instrucción; un uint16_t, en dos; un uint32_t, en cuatro. Si una variable solo necesita 8 bits, el tipo más chico es más rápido y ocupa menos SRAM.

07 · Sobre lo que cambia solo

volatile: no dejes que el compilador adivine

Un compilador optimiza suponiendo que el valor de una variable solo cambia cuando el programa lo cambia. Con registros de hardware y con variables compartidas con interrupciones eso es falso: el valor puede cambiar entre dos lecturas sin que el código lo toque.

uint8_t llego_dato = 0;           // ¡sin volatile!

ISR(USART_RX_vect) {
    llego_dato = 1;               // la interrupción lo cambia
}

int main(void) {
    /* … configuración … */
    while (!llego_dato) { }       // el compilador puede leerlo UNA sola vez
    /* … nunca llega aquí … */
}

El compilador ve que dentro del bucle llego_dato no se modifica y, con optimización, puede cargarla una sola vez en un registro y repetir la comparación sin volver a leerla de memoria: el bucle no termina nunca aunque la interrupción ponga la variable en 1. La corrección es declarar volatile uint8_t llego_dato;, que obliga a leer la variable de memoria en cada acceso y a no eliminar ni reordenar esos accesos.

Por qué no hace falta escribirlo para PORTB o UCSR0A: las definiciones de avr/io.h ya son volatile (un registro se define como (*(volatile uint8_t *)(0x25))). Hay tres situaciones en que sí debes escribir volatile:

  • Variables que modifica una rutina de interrupción y lee el programa principal, o al revés.
  • Punteros que apuntan a registros de hardware definidos por vos (por ejemplo, un periférico externo mapeado en memoria).
  • Variables que se usan en bucles de espera de un cambio externo.

08 · Datos compactos

Empaquetar y desempaquetar datos

Los desplazamientos permiten combinar y separar valores. Algunas operaciones frecuentes:

// Armar un valor de 16 bits con dos bytes (cuidado: convertir antes de desplazar)
uint16_t valor = ((uint16_t)alto << 8) | bajo;

// Separar los bytes de un valor de 16 bits
uint8_t bajo = valor & 0xFF;
uint8_t alto = valor >> 8;

// Dos dígitos BCD en un byte (decena y unidad)
uint8_t bcd = (decena << 4) | unidad;
uint8_t unidad2 = bcd & 0x0F;
uint8_t decena2 = bcd >> 4;

// Contar los bits en 1 de un byte
uint8_t n = 0;
for (uint8_t x = byte; x; x >>= 1) n += x & 1;

La conversión (uint16_t)alto antes de desplazar no es decorativa: sin ella, alto << 8 se calcula con enteros de 16 bits con signo, y un byte alto mayor que 127 daría un resultado negativo. Los datos empaquetados ahorran memoria y ancho de banda, y son la base de los protocolos de comunicación: una trama serie suele ser un conjunto de campos de bits colocados en bytes.

Una alternativa son los campos de bits de C (struct { unsigned a:3; unsigned b:5; }), pero el orden de los bits depende del compilador y no son portables; para registros de hardware, las máscaras y los desplazamientos son más seguros.

09 · Analogía con programación

Las cuatro operaciones y un campo, sobre enteros de Python

En Python los enteros no tienen ancho fijo, así que hay que imitar un registro de 8 bits con & 0xFF. Eso mismo revela una diferencia entre los lenguajes: la expresión ~(1 << 5) da un número negativo, y recién al enmascararla se parece a lo que ve el registro.

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

def poner(reg, bit):
    return reg | (1 << bit)

def borrar(reg, bit):
    return reg & ~(1 << bit) & 0xFF        # el & 0xFF imita un registro de 8 bits

def invertir(reg, bit):
    return reg ^ (1 << bit)

def probar(reg, bit):
    return bool(reg & (1 << bit))

def cambiar_campo(reg, desplazamiento, ancho, valor):
    mascara = ((1 << ancho) - 1) << desplazamiento
    return (reg & ~mascara & 0xFF) | ((valor << desplazamiento) & mascara)


portb = 0b00001010
print(f"inicial          {portb:08b}")
portb = poner(portb, 5);     print(f"poner bit 5      {portb:08b}")
portb = borrar(portb, 1);    print(f"borrar bit 1     {portb:08b}")
portb = invertir(portb, 0);  print(f"invertir bit 0   {portb:08b}")
print("bit 3 está en 1:", probar(portb, 3))

# Un campo de 4 bits (como MUX3:0 de ADMUX) sin tocar el resto del registro.
admux = 0b01000101                    # REFS0 = 1, canal 5
admux = cambiar_campo(admux, 0, 4, 2)
print(f"ADMUX canal 2    {admux:08b}")

# Lo que hace C con ~ : en Python un entero no tiene ancho fijo.
print(f"~(1 << 5)        = {~(1 << 5)}   -> con & 0xFF: {~(1 << 5) & 0xFF:#04x}")
print(f"(0x80 << 1)      = {0x80 << 1:#x} -> en 8 bits: {(0x80 << 1) & 0xFF:#04x}  (el bit salió)")

Resultado: las cuatro operaciones modifican solo el bit pedido; el campo de ADMUX pasa de canal 5 a canal 2 sin tocar los bits de referencia (01000010). Probá cambiar_campo(0xFF, 4, 2, 1) y predecí el resultado antes de ejecutar.

El modelo no representa las trampas de promoción de C: en Python un entero es tan ancho como haga falta. En un AVR, ~a sobre un uint8_t es un entero de 16 bits y la comparación con una constante de 8 bits puede fallar.

10 · Exploración interactiva

Calculadora de bits y editor de campos

Con la calculadora podés ver las operaciones bit a bit alineadas en binario, con los bits que cambian resaltados. El editor de campos aplica la fórmula de la sección 4 a un registro de 8 bits, mostrando cada paso intermedio.

Banco de bits

1. Calculadora

2. Cambiar un campo de varios bits

La calculadora trabaja con enteros sin signo del ancho elegido, como lo hace el hardware. No modela la promoción a int de C: sirve para ver el efecto sobre los bits, no la regla de conversión del lenguaje.

Recorrido guiado

  1. Con A = 0x2A y OR con 0x20, nada cambia: el bit 5 ya estaba en 1. Probá con 0x01 y mirá el bit resaltado.
  2. Cambiá a AND con 0x0F sobre 0xFF: borra el nibble alto. Es la máscara que aísla un campo.
  3. Elegí XOR con 0xFF: invierte los ocho bits, igual que ~. Aplicalo dos veces y volvés al valor original.
  4. Elegí << con A = 0x81 y n = 1: el bit alto sale por la izquierda y se pierde, y la calculadora lo advierte.
  5. En el editor de campos, presioná «Ejemplo: canal 2 en ADMUX». Seguí las filas: registro, máscara, tras borrar, valor movido y resultado. Después probá un campo en los bits 5 a 4 (posición 4, ancho 2).
Desafío: cambiar la prescala sin tocar el modo

El registro TCCR0B vale 0x08 (modo con WGM02 en 1) y querés poner CS02:0 en 101 sin modificar WGM02. Es un campo de 3 bits en la posición 0, valor 5: el editor da 0x0D. Con una asignación directa, TCCR0B = 5;, habrías borrado WGM02 sin darte cuenta.

11 · Método de trabajo

Estilo para código de registros

  1. Usá nombres de bit, no números. (1 << ADEN) es verificable contra la hoja de datos; 0x80 no.
  2. Asigná en la inicialización, modificá después. Un registro que se configura una vez se escribe completo con =; uno que se comparte entre partes del programa se cambia con |= y &= ~.
  3. Un comentario por registro: qué se activa y por qué, en lugar de repetir lo que hace el código.
  4. Agrupá las constantes en #define con nombre (por ejemplo, LED_PIN, BOTON_PIN), de modo que cambiar un pin sea editar una línea.
  5. Declará volatile lo que compartan las interrupciones y el programa principal.
  6. Verificá con el simulador o el depurador el valor del registro tras cada paso de la configuración, antes de culpar al hardware.

En la mesa de trabajo, el osciloscopio confirma el efecto de una máscara en el pin: por ejemplo, que invertir un bit con ^= produzca la señal de la frecuencia esperada.

12 · Comprensión

Confusiones frecuentes

«& y && son equivalentes»

No: & opera bit a bit y && es lógico, devuelve 0 o 1 y evalúa en cortocircuito. Usar uno por otro compila sin errores y da resultados distintos.

«PORTB = PORTB | (1 << PB5) y PORTB |= … son diferentes»

Son equivalentes. La forma con |= es más corta, y el compilador la reconoce con más facilidad para generar SBI. Lo que sí es distinto es PORTB = (1 << PB5): escribe todo el registro y borra los otros bits.

«~ invierte el byte y el resultado sigue siendo un byte»

En C, el resultado es un int (16 bits en AVR). Al asignarlo a un registro de 8 bits se descartan los bits altos, pero en una comparación directa el valor completo interviene.

«volatile hace que la operación sea atómica»

Solo garantiza que el acceso se realice. Un acceso de 16 bits sigue siendo dos instrucciones, y una interrupción puede intercalarse.

«Los desplazamientos son lentos»

Un desplazamiento de un bit es una instrucción de un ciclo. Desplazar n posiciones una variable cuesta n instrucciones, pero si n es constante el compilador calcula el resultado al compilar, y no genera código en ejecución.

13 · Práctica guiada

Predecí antes de comprobar

1. Escribir la expresión

Escribí el código que pone en 1 los bits 0 y 3 de PORTD, pone en 0 el bit 6 y no toca los demás.

Ver solución

Con dos sentencias: PORTD |= (1 << PD0) | (1 << PD3); y PORTD &= ~(1 << PD6);. También cabe en una sola, aunque se lee peor: PORTD = (PORTD | 0x09) & ~0x40;. Las dos versiones modifican únicamente esos tres bits.

2. Seguir las máscaras

Con R = 1011 0110, calculá R & 0x0F, R | 0x0F y R ^ 0xFF.

Ver solución

R & 0x0F = 0000 0110 (0x06). R | 0x0F = 1011 1111 (0xBF). R ^ 0xFF = 0100 1001 (0x49).

3. Cambiar un campo

El campo de dos bits en las posiciones 5 y 4 de R = 1111 1111 debe valer 01. ¿Cuál es el resultado y cuál es la expresión?

Ver solución

Máscara = 0x03 << 4 = 0011 0000. Se borra: 1100 1111. Se escribe 01 en su posición: 0001 0000. Resultado: 1101 1111 (0xDF). Expresión: R = (R & ~(0x03 << 4)) | (1 << 4);.

4. El bug del bucle

Un programa espera un dato con while (!hay_dato) {} y una interrupción pone hay_dato = 1. Con -Os se cuelga; con -O0 funciona. ¿Por qué y cómo se arregla?

Ver solución razonada

Con optimización, el compilador lee hay_dato una sola vez y deja de consultarla, porque dentro del bucle no se modifica. Sin optimización, cada vuelta lee la memoria. Se corrige declarándola volatile. Es un fallo que depende del nivel de optimización, lo que lo hace difícil de detectar.

5. Máscara de 16 bits

¿Qué hay de malo en uint16_t m = 1 << 15; en avr-gcc y cómo se corrige?

Ver solución

El literal 1 es un int de 16 bits con signo: 1 << 15 desborda en el bit de signo (resultado -32768, comportamiento no garantizado). Se corrige con 1U << 15 o (uint16_t)1 << 15.

14 · Síntesis

Ideas para recordar

  • Poner = |=; borrar = &= ~; invertir = ^=; probar = &, todos con la máscara (1 << BIT).
  • Un campo se cambia en dos pasos: borrarlo con su máscara y escribir el valor desplazado.
  • Poné paréntesis alrededor de las operaciones bit a bit en comparaciones: == tiene más precedencia que &.
  • En avr-gcc el int tiene 16 bits: ~ sobre un byte da 16 bits, y 1 << 15 desborda; usá tipos sin signo y sufijos U/UL.
  • volatile es obligatorio para variables compartidas con interrupciones y bucles de espera; los registros de avr/io.h ya lo son.
  • volatile no es atómico: un acceso de 16 bits se puede interrumpir.
  • Los desplazamientos y las máscaras permiten empaquetar datos en bytes, base de los protocolos serie.

¿Listo para el tema 11?

Antes de avanzar, escribí de memoria las cuatro expresiones básicas, la fórmula para cambiar un campo de varios bits y explicá cuándo es obligatorio volatile. Si podés justificar las tres respuestas, tenés el vocabulario completo para leer y escribir configuraciones de periféricos.

En el próximo tema aplicamos todo esto en un taller: configurar un periférico con el datasheet, desde la lectura de las tablas hasta el código funcionando.