Elementos básicos del lenguaje C
C es el idioma de la electrónica. Con él se programan los microcontroladores de 6° y 7° año, los sistemas embebidos de la industria y el núcleo de casi todos los sistemas operativos. Es exigente, pero da control total sobre la máquina.
01Por qué C en una tecnicatura en electrónica
Existen lenguajes más cómodos, pero ninguno reúne estas cuatro cosas a la vez:
- Acceso directo al hardware. Permite escribir un valor en un registro del microcontrolador o leer un puerto de entrada tal como está en la hoja de datos.
- Eficiencia. El código generado es casi tan compacto y rápido como el escrito en assembler, y eso importa cuando hay 2 KB de memoria.
- Portabilidad. El mismo programa, con ajustes mínimos, compila para un PIC, un AVR, un ARM o una PC.
- Universalidad. Todo fabricante de microcontroladores entrega su compilador de C. Es el denominador común de la industria.
El programa fuente (.c) pasa por el preprocesador, después por el
compilador que lo traduce a código objeto (.o), y finalmente por el
enlazador (linker), que une los objetos y las bibliotecas y produce el ejecutable. En
un microcontrolador ese resultado es un archivo .hex que se graba en la memoria flash.
Entender esta cadena es lo que permite interpretar los mensajes de error.
02Estructura de un programa
#include <stdio.h> // directivas al preprocesador
#define PI 3.14159 // constante simbólica
float area(float radio); // prototipo de función
int main(void) // TODO programa tiene un main
{
float r = 2.5; // declaración con inicialización
printf("Area = %.2f\n", area(r));
return 0; // 0 = terminó sin error
}
float area(float radio) // definición de la función
{
return PI * radio * radio;
}
| Elemento | Para qué está |
|---|---|
#include | Incorpora un archivo de cabecera con declaraciones ya hechas. Con <> busca en las bibliotecas del sistema; con "", en el directorio del proyecto. |
#define | Define una constante o macro que el preprocesador reemplaza textualmente antes de compilar. |
| Prototipo | Le anuncia al compilador cómo se llama una función, qué recibe y qué devuelve, antes de que aparezca su código. |
main() | Punto de entrada. En un microcontrolador nunca termina: su cuerpo suele ser un while(1). |
{ } | Delimitan bloques. Todo lo declarado dentro de un bloque sólo existe ahí. |
; | Termina cada sentencia. Olvidarlo es el error de compilación número uno. |
contador, Contador y CONTADOR son tres variables
distintas. Por convención se usan minúsculas para variables y funciones, y MAYÚSCULAS para
constantes definidas con #define.
03Variables, tipos y constantes
Toda variable debe declararse antes de usarse, indicando su tipo. Ese tipo determina cuánta memoria ocupa y qué valores puede guardar.
| Tipo | Tamaño típico (microcontrolador de 8 bits) | Rango | Uso |
|---|---|---|---|
char | 1 byte | −128 a 127 | Un carácter, o un entero chico con signo. |
unsigned char | 1 byte | 0 a 255 | El tipo más usado en microcontroladores: un puerto, un byte. |
int | 2 bytes | −32.768 a 32.767 | Enteros de propósito general. Ojo: en una PC son 4 bytes. |
unsigned int | 2 bytes | 0 a 65.535 | Contadores, valores de un conversor A/D de 10 bits. |
long | 4 bytes | ± 2.147.483.648 | Milisegundos transcurridos, frecuencias. |
float | 4 bytes | ± 3,4 × 1038, 7 dígitos | Cálculos con decimales. Caro en un micro sin unidad de punto flotante. |
double | 8 bytes | 15 dígitos | Precisión doble. Rara vez justificable en embebidos. |
void | — | — | «Nada». Función que no devuelve valor o que no recibe parámetros. |
int depende de la máquinaEl estándar de C sólo garantiza mínimos, no tamaños exactos. Por eso en programación embebida se
usan los tipos de <stdint.h>, que son inequívocos: uint8_t,
int16_t, uint32_t. El número es la cantidad de bits y la u
indica sin signo. Es la práctica profesional recomendada y evita errores al portar código.
Desbordamiento
unsigned char c = 250;
c = c + 10; // NO da 260: da 4, porque 260 no entra en 8 bits
unsigned char i;
for (i = 0; i < 300; i++) { ... } // BUCLE INFINITO: i nunca llega a 300
El desbordamiento no genera ningún error: el valor simplemente «da la vuelta». Es una de las causas más difíciles de encontrar en un programa embebido, y por eso hay que elegir el tipo pensando en el máximo valor posible, no en el habitual.
Constantes
#define LARGO 10 // preprocesador: reemplazo textual, sin tipo
const float PI = 3.14159; // variable de sólo lectura, CON tipo
int a = 255; // decimal
int b = 0xFF; // hexadecimal — el más usado con registros
int c = 0b11111111; // binario (extensión de GCC, muy cómoda para puertos)
int d = 0377; // OCTAL: el 0 inicial cambia la base. Trampa clásica
En electrónica el hexadecimal es el formato natural: cada dígito hex representa exactamente
4 bits, así que 0xF0 se lee de un vistazo como «los cuatro bits altos en 1».
04Operadores
+ − * / % · ++ -- ·
= += -= *= /=
El % es el resto de la división
entera. / entre enteros trunca: 7/2 da 3, no 3,5.
== != < > <= >= ·
&& || !
En C, 0 es falso y cualquier otro valor es verdadero.
Operadores a nivel de bit: los imprescindibles
Son los que hacen a C apto para electrónica. Permiten manipular bits individuales de un registro sin tocar los demás.
| Operador | Función | Uso típico |
|---|---|---|
& | AND bit a bit | Poner bits en 0 (enmascarar) y consultar un bit. |
| | OR bit a bit | Poner bits en 1. |
^ | XOR bit a bit | Invertir bits (toggle). |
~ | Complemento | Invertir todos los bits. |
<< | Desplazar a izquierda | Construir máscaras. Equivale a multiplicar por 2. |
>> | Desplazar a derecha | Extraer campos. Equivale a dividir por 2. |
// Las cuatro operaciones que se usan todo el tiempo con registros:
PORTB |= (1 << 3); // poner en 1 el bit 3, sin tocar los otros
PORTB &= ~(1 << 3); // poner en 0 el bit 3
PORTB ^= (1 << 3); // invertir el bit 3
if (PINB & (1 << 3)) { // ¿el bit 3 está en 1?
// sí: el pulsador está apretado
}
PORTB = 8;Porque eso pondría el bit 3 en 1 y todos los demás en 0, apagando el resto de las salidas
del puerto. Las formas con |= y &= ~ modifican sólo el bit que
interesa. Es la diferencia entre un programa que funciona y uno que apaga el motor cada vez que
prende el LED.
= contra ==if (x = 5) // ASIGNA 5 a x, y como 5 es distinto de 0, SIEMPRE es verdadero
if (x == 5) // COMPARA x con 5. Esto es lo que se quería
No es un error de compilación: el programa compila y anda mal. Los compiladores modernos avisan
con un warning, y por eso hay que compilar siempre con -Wall y leer las
advertencias.
05Control de flujo
if (temp > 30) {
encender_ventilador();
} else if (temp < 18) {
encender_estufa();
} else {
apagar_todo();
}
switch (tecla) {
case 1: subir(); break;
case 2: bajar(); break;
default: parar();
}
for (i = 0; i < 8; i++) {
encender_led(i);
}
while (!dato_listo()) {
// espera
}
do {
v = leer_adc();
} while (v < umbral);
while (1) { ... } // bucle principal
break en un switchSin break, la ejecución continúa cayendo en el caso siguiente. A veces se hace
a propósito (para agrupar casos), pero casi siempre es un olvido y produce un comportamiento
desconcertante.
int main(void)
{
configurar_puertos(); // se ejecuta UNA vez
configurar_adc();
configurar_timers();
while (1) { // se repite PARA SIEMPRE
leer_sensores();
procesar();
actualizar_salidas();
}
return 0; // nunca se llega acá
}
Esta estructura —configuración + bucle infinito— es la de todo programa de microcontrolador,
y es literalmente lo que hacen las funciones setup() y loop() de Arduino,
que es C por debajo.
06Arreglos y cadenas
Un arreglo es un conjunto de elementos del mismo tipo, contiguos en memoria, accesibles por un índice que empieza en 0.
int muestras[10]; // índices válidos: 0 a 9
int tabla[5] = {2, 4, 6, 8, 10};
muestras[0] = 512; // primer elemento
muestras[9] = 128; // ÚLTIMO elemento
muestras[10] = 0; // ¡FUERA DE RANGO! Pisa memoria ajena
// Promediar 10 muestras del conversor A/D
long suma = 0;
for (int i = 0; i < 10; i++) {
muestras[i] = leer_adc();
suma += muestras[i];
}
int promedio = suma / 10;
Escribir muestras[10] en un arreglo de 10 elementos no da error: escribe en la
posición de memoria siguiente, que puede pertenecer a otra variable, a la pila o a un registro. El
programa sigue andando y falla más adelante de forma inexplicable. En una PC esto es un agujero de
seguridad clásico (buffer overflow); en un microcontrolador, un cuelgue aleatorio. Verificar
los índices es responsabilidad del programador.
Cadenas de caracteres
En C una cadena es simplemente un arreglo de char terminado por el carácter nulo
'\0'. No es un tipo especial.
char nombre[20] = "Electronica"; // ocupa 11 letras + 1 del '\0' = 12
#include <string.h>
strlen(nombre); // 11 — no cuenta el '\0'
strcpy(destino, origen); // copiar
strcmp(a, b); // comparar: devuelve 0 si son iguales
strcat(destino, origen); // concatenar
'\0'Un arreglo de 20 char guarda como máximo una cadena de 19 caracteres. Y
strcmp(a, b) devuelve 0 cuando son iguales, lo que es contraintuitivo: se escribe
if (strcmp(a,b) == 0). Nunca comparar cadenas con ==, porque eso compara
direcciones de memoria, no contenidos.
07Funciones
Dividir el programa en funciones no es cosmética: es lo que permite probar cada parte por separado, reutilizar código y entender un programa de mil líneas.
// tipo_devuelto nombre(tipo parám1, tipo parám2)
float celsius_a_fahrenheit(float c)
{
return c * 9.0 / 5.0 + 32.0;
}
void parpadear(unsigned char veces) // void: no devuelve nada
{
for (unsigned char i = 0; i < veces; i++) {
PORTB |= (1 << 0);
retardo_ms(200);
PORTB &= ~(1 << 0);
retardo_ms(200);
}
}
La función recibe una copia del argumento. Si la modifica, el original no cambia. Para que una función pueda modificar una variable del que la llamó hay que pasarle su dirección, con punteros — que es el tema central del eje siguiente.
Ámbito de las variables
| Tipo | Dónde vive | Cuándo usarla |
|---|---|---|
| Local | Declarada dentro de una función. Nace al entrar y muere al salir. | Siempre que se pueda. Es lo más seguro y ahorra memoria. |
| Global | Fuera de toda función. Existe durante todo el programa. | Sólo para datos que comparten varias partes, típicamente entre el bucle principal y una interrupción. |
| Estática | static dentro de una función: conserva su valor entre llamadas. | Contadores internos que no deben ser visibles desde afuera. |
| Volátil | volatile: le avisa al compilador que puede cambiar por fuera del flujo del programa. | Obligatorio en variables modificadas por una interrupción o mapeadas a un registro de hardware. |
volatile, el optimizador rompe el programaSi una variable global se modifica dentro de una interrupción y el bucle principal la lee, el
compilador puede «darse cuenta» de que nadie la cambia dentro del bucle y guardarla en un registro
para siempre. El programa queda esperando eternamente un cambio que no ve. La palabra
volatile le prohíbe esa optimización. Es un error que aparece recién al compilar con
optimización activada, y es un clásico de 6° año.
08El preprocesador y la biblioteca estándar
El preprocesador actúa antes del compilador y hace reemplazos de texto. No entiende de tipos ni de sintaxis de C.
#include <stdio.h> // pegar el contenido de ese archivo acá
#define LED_ON PORTB |= 1
#define MAX(a,b) ((a) > (b) ? (a) : (b)) // macro con parámetros
#ifdef DEBUG // compilación condicional
printf("valor = %d\n", x);
#endif
#ifndef CONFIG_H // guarda de inclusión: evita incluir
#define CONFIG_H // dos veces el mismo archivo
...
#endif
Con #define DOBLE(x) x*2, la expresión DOBLE(3+1) se expande a
3+1*2 = 5, no 8. Por eso toda macro se escribe envolviendo cada parámetro y el
conjunto entre paréntesis: #define DOBLE(x) ((x)*2). Cuando se puede, es preferible una
función inline, que sí respeta los tipos.
Las bibliotecas que se usan
| Cabecera | Contiene | Nota para embebidos |
|---|---|---|
<stdio.h> | printf, scanf, archivos | printf ocupa varios KB: en un micro chico se reemplaza por una función propia de envío por serie. |
<stdlib.h> | atoi, rand, malloc | Evitar malloc en embebidos: fragmenta la memoria y puede fallar sin aviso. |
<string.h> | Manejo de cadenas y de memoria | memset y memcpy son muy útiles con buffers. |
<math.h> | sin, sqrt, pow | Muy pesada. Si alcanza, usar aritmética entera o tablas precalculadas. |
<stdint.h> | uint8_t, int16_t, … | Usar siempre en programación de microcontroladores. |
<stdbool.h> | bool, true, false | Mejora la legibilidad sin costo. |
09En el laboratorio
Con GCC en línea de comandos (no con un entorno gráfico, para ver los pasos):
gcc -Wall -c hola.c -o hola.o # compilar a objeto
gcc hola.o -o hola # enlazar
./hola # ejecutar
gcc -E hola.c > hola.i # ver la salida del PREPROCESADOR
Abrir hola.i y comprobar que el #include se convirtió en cientos de
líneas y que los #define desaparecieron reemplazados por su valor. Es la forma más clara
de entender qué hace cada etapa.
Escribir un programa que declare un unsigned char en 250 y le sume 1 en un bucle,
imprimiendo el valor. Observar cómo salta de 255 a 0. Después imprimir
sizeof(int), sizeof(long), sizeof(float) y comparar con lo que
dicen los apuntes: en la PC del taller int va a ocupar 4 bytes, no 2.
Simular un puerto con una variable unsigned char puerto = 0; y escribir funciones
set_bit(n), clear_bit(n), toggle_bit(n) y
get_bit(n). Imprimir el valor en binario después de cada operación. Es exactamente lo
que se va a hacer con registros reales en Electrónica Digital III.
Escribir una función que reciba el valor crudo de un conversor A/D de 10 bits (0 a 1023) y lo
convierta a tensión con referencia de 5 V (conviene tener a mano el conversor de
prefijos para no perderse entre milivolt y volt). Hacerlo de dos formas: con float y con
aritmética entera en milivolt. Comparar los resultados y reflexionar sobre cuál conviene en un
microcontrolador de 8 bits. Es el cálculo que va a aparecer en todos los proyectos de sensores.
10Errores frecuentes
| Error | Consecuencia |
|---|---|
Falta un ; | Error de compilación, señalado a veces en la línea siguiente. Siempre revisar la línea de arriba de la que marca el compilador. |
= en vez de == en un if | Compila, y la condición siempre resulta verdadera. Compilar con -Wall. |
| Índice de arreglo fuera de rango | No hay error: se pisa memoria ajena y el fallo aparece mucho después, en otro lado. |
| Variable usada sin inicializar | Contiene basura. En una PC suele dar cero por casualidad; en un micro, cualquier cosa. |
| División entera cuando se esperaban decimales | 7/2 da 3. Hay que escribir 7.0/2 o hacer un cast. |
Falta volatile en una variable de interrupción | El optimizador la «congela» y el programa se cuelga esperando un cambio que no ve. |
Falta break en un switch | Se ejecutan también los casos siguientes. |
Comparar cadenas con == | Compara direcciones de memoria, no contenidos. Hay que usar strcmp. |
| Macro sin paréntesis | Resultados aritméticos inesperados por precedencia de operadores. |
11Autoevaluación
¿Cuáles son las tres etapas por las que pasa un programa en C hasta ser ejecutable?
Preprocesador (resuelve #include y #define),
compilador (traduce a código objeto) y enlazador (une los objetos con las bibliotecas y
genera el ejecutable o el .hex).
¿Qué imprime unsigned char c = 200; c = c + 100;?
44. 300 no entra en 8 bits: 300 − 256 = 44. Es un desbordamiento silencioso.
Escribir la instrucción que pone en 1 el bit 5 de PORTC sin alterar los demás.
PORTC |= (1 << 5);
Para ponerlo en 0: PORTC &= ~(1 << 5);
Para invertirlo: PORTC ^= (1 << 5);
¿Cuántos caracteres útiles entran en char texto[16];?
15. El decimosexto lugar lo ocupa el terminador '\0'.
¿Por qué if (strcmp(a, b)) hace lo contrario de lo que parece?
Porque strcmp devuelve 0 cuando las cadenas son iguales, y en C
el 0 es falso. Es decir, esa condición se cumple cuando son distintas. Hay que escribir
if (strcmp(a, b) == 0).
¿Qué diferencia hay entre una variable local y una estática dentro de una función?
La local se crea al entrar a la función y se destruye al salir, perdiendo su
valor. La estática (static) conserva su valor entre llamadas sucesivas, aunque
sigue siendo invisible desde afuera de la función.
¿Cuándo es obligatorio declarar una variable como volatile?
Cuando puede cambiar por fuera del flujo normal del programa: variables modificadas
por una interrupción, o mapeadas a un registro de hardware. Sin volatile,
el optimizador puede asumir que nadie la cambia y usar una copia en un registro.
¿Por qué se recomienda usar uint8_t en vez de unsigned char?
Porque el tamaño de los tipos básicos de C depende de la plataforma. Los
tipos de <stdint.h> declaran explícitamente la cantidad de bits, así que el
programa se comporta igual al portarlo a otro microcontrolador o a una PC.
¿Qué imprime printf("%d", 7/2);? ¿Y cómo se obtiene 3,5?
Imprime 3: la división entre dos enteros es entera y trunca. Para obtener
3,5 hay que forzar punto flotante: 7.0/2 o (float)7/2, e imprimirlo con
%f.