Lenguaje C y entorno de trabajo

El lenguaje C sigue siendo el más utilizado en sistemas empotrados.

¿Dónde estamos?

123456
EspecificaciónModeladoVerificaciónImplementaciónVerificaciónAnálisis
PARA QUÉQUÉCÓMO
¿Cuál es el problema?¿Qué tiene que hacer el sistema?¿El modelo cumple la especificación?¿Cómo hace lo que tiene que hacer? (modelo refinado)¿La implementación cumple la especificación?¿Se cumplen los plazos en el caso peor?
LTL, CTLFSM / xFSMnuXmvfsm.h / fsm.cnuXmvAnálisis del tiempo
(model checking)Ejecutivo cíclico / FreeRTOS / Reactor(model checking)de respuesta

Aprende C
Para aprender C hay un excelente libro [Kernighan&Ritchie]. En el primer capítulo A tour on C se aprende lo suficiente como para poder avanzar en este curso.

Transparencias
Transparencias usadas en clase.

Declaración de tipos

En C el tipo de las variables no se infiere a partir de su contenido, sino que es necesario decirlo de forma anticipada.

1int a = 3; // entero
2double d = 3.25; // real
3char c = 'A'; // carácter
4char *s = "Paco"; // cadena

En Python el tipo numérico se adapta de forma automática. Podemos tener enteros arbitrariamente largos y reales con la máxima precisión que soporte la arquitectura. En C sin embargo hay que indicar el tamaño que se desea e incluso detalles como si es o no con signo. En la tabla siguiente se muestran todos los tipos básicos de C:

TipoVarianteRangoEjemplo
Entero8 bits con signo-128 a 127char c;
 8 bits sin signo0 a 255unsigned char c;
 16 bits con signo-32768 a 32767short i;
 16 bits sin signo0 a 65535unsigned short i;
 32 bits con signo-2147483648 a 2147483647int i;
 32 bits sin signo0 a 4294967295unsigned int i;
 64 bits con signo-9223372036854775808 a 9223372036854775807long long i;
 64 bits sin signo0 a 18446744073709551616unsigned long long i;
Real32 bits (simple precisión)±3.4028231038\pm 3.402823\cdot 10^{38}float f;
 64 bits (doble precisión)±1.79769310308\pm 1.797693 \cdot 10^{308}double f;
 80 bits (precisión extendida)±1.189731104932 \pm 1.189731 \cdot 10^{4932}long double f;
Punteroa carácter (cadena)char* p;
 a enteroint* p;
 a realfloat* p;
 a cualquier objetovoid* p;
 a funciónvoid (*f)();

En este curso los más útiles van a ser char, int, float y los punteros.

En C no existe un tipo para representar los booleanos. Sin embargo existen expresiones booleanas, se evalúan como un entero de 8 bits con solo dos posibles valores 0 o 1. Por otro lado, cualquier entero puede usarse en una expresión booleana teniendo en cuenta que 0 se interpreta como FALSE y cualquier otra cosa como TRUE.

Prototipos de funciones

Para poder usar una función previamente hay que declarar los argumentos y el valor de retorno. Esto se puede hacer definiendo la función previamente o bien declarando solamente su forma (prototipo) y definiéndola más adelante.

1char divisible_por(int n, int d) {
2    return n % d == 0;
3}
4
5char es_bisiesto(int año) {
6    return divisible_por(año, 400) 
7        || !divisible_por(año, 100)
8        && divisible_por(año, 4);
9}

Nuestro consejo es seguir esta última opción. Construye tu programa de arriba a abajo (top-down). Primero lo más abstracto, luego se va concretando. Si usas este esquema te surgirá la necesidad de usar funciones más concretas. Simplemente declara el prototipo al principio y sigue con la función más abstracta.

Puede parecerte más largo, pero en programas complejos se agradece leer en el orden lógico. Primero el problema, descompuesto en subproblemas. Después los subproblemas.

Ejecución condicional

El if funciona de forma muy similar a Python.

1if (condicion) {
2    // sentencias...
3}

Una escalera de if, else puede implementarse también de forma muy similar a Python. Pero no las uses, son síntoma de mal diseño.

1if (condicion1) {
2    // sentencias...
3}
4else if (condicion2) {
5    // sentencias...
6}
7else {
8    // sentencias...
9}

Bucles

En C hay tres tipos de bucle, pero solo son variantes de while. El bucle while es prácticamente idéntico a Python:

1while (condicion) {
2    // sentencias...
3}

A veces no queremos evaluar la condición de permanencia del bucle antes de entrar, sino que queremos entrar al menos una vez y evaluar la condición después de esa primera vez. Para eso C proporciona do-while.

1do {
2    // sentencias
3} while (condicion);

En C también hay un for pero no se parece mucho al de Python. Es simplemente una forma elaborada de while.

1for (int i = 0; i < 10; ++i) {
2    // sentencias...
3}

La sentencia for tiene tres expresiones separadas por ;. La primera se ejecuta antes de entrar en el bucle y puede definir variables de control (como i en en ejemplo) que solo son visibles dentro del bucle. La segunda se ejecuta antes de cada iteración del bucle y si se evalúa como FALSE termina inmediatamente. Este comportamiento es similar a while. Por último, la tercera expresión se evalúa después de cada iteración del bucle.

Punteros y arrays

Un puntero no es más que un objeto que contiene la dirección de otro objeto. En C se declaran con el declarador * precediendo al nombre del símbolo. Para obtener la dirección de un objeto puede utilizarse el operador &. Por ejemplo:

1int a = 3;
2int *p = &a;

El puntero p es un puntero a entero. Es decir, contiene la dirección de un entero. En este caso contiene la dirección de a. En una expresión puede accederse al objeto referido por el puntero utilizando el operador *. Aunque se escribe igual que el declarador * es preciso recalcar que son cosas diferentes (la declaración y el uso del puntero). Se usa el mismo carácter por motivos mnemotécnicos. La declaración parece indicar que *p es un int, lo cual es completamente cierto. Por ejemplo, podemos imprimir el valor de a directamente a través de p:

1printf("El valor de a es %d\n", *p);

Tipos agregados

En cualquier programa mínimamente complejo se necesitan tipos agregados. En Python una tupla es suficiente para agregar colecciones homogéneas o inhomogéneas. En C normalmente se usan arrays para agrupaciones homogéneas y structs para agrupaciones inhomogéneas.

Un array en C se define indicando entre corchetes el número de elementos:

1int a[3]; // 3 enteros sin inicializar
2float vec[3] = { 1., 0., 0. }; // 3 reales inicializado con valores
3char s[] = "Saludos"; // array de caracteres (cadena), tamaño implícito

La forma de usarlos es muy similar a las listas de Python usando índices escalares. Se utilizan también los corchetes con índices que empiezan en 0.

1float dot(float v1[3], float v2[3]) // producto escalar
2{
3    return v1[0]*v2[0] + v1[1]*v2[1] + v1[2]*v2[2];
4}

Cuando se usa el nombre de un array en una expresión, éste se evalúa como la dirección del primer elemento. Eso implica que los arrays y los punteros están muy relacionados en C.

1float medidas[] = { 1.0, 1.1, 1.02, 1.101, 0.9, NAN };
2
3float sum = 0.;
4for (float* p = medidas; *p != NAN; ++p)
5    sum += *p;

Están tan relacionados que se pueden usar punteros con el operador de indexación, como si fueran arrays:

1float medidas[] = { -0.1, 0.1, 0.01, 1.0, 1.1, 1.02, 1.101, 0.9 };
2
3float* estables = medidas + 3;
4float sum = 0.
5for (int i = 0; i < 5; ++i)
6    sum += estables[i];

Una struct en C se define indicando entre llaves los elementos:

1struct complejo {
2  float real;
3  float imag;
4};

La nueva estructura se comporta como un tipo definido por el usuario. Para acceder a sus elementos se usa el operador punto (.):

1float magnitud(struct complejo c) {
2    return sqrt(c.real*c.real + c.imag*c.imag);
3}

Tipos de usuario

Con punteros, structs y arrays se pueden definir tipos realmente complejos. Pero cuanto más complejos son los tipos más engorroso resulta definir argumentos o variables de estos tipos. Por eso es extremadamente útil la definición de alias de tipos con typedef.

1typedef int int32_t;
2typedef unsigned char byte_t;
3typedef struct {
4    float real, imag;
5} complex_t;

Es muy frecuente utilizar punteros a estructuras para evitar copiar excesiva cantidad de datos. Para simplificar este caso se puede usar el operador flecha -> como forma abreviada de acceder a los elementos.

1void subir_sueldo(struct empleado* e) {
2    for(; e->nombre != NULL; ++e)
3        if (e->cargo == INGENIERO)
4            e->sueldo *= 1.1;
5}

El paso de argumentos o la copia de variables de tipo struct es como cabría esperar. Se copia exactamente todo el contenido. Eso puede ser ineficiente cuando los objetos son grandes y puede implicar una semántica incorrecta cuando los elementos incluyen punteros.

1void calcular_nomina(struct empleado* e) {
2    return e->base + 
3           e->complemento - 
4           e->contingencias -
5           e->ss -
6           e->irpf;
7}

Los errores en la semántica de copia tienen que ver con subcomponentes que se guardan en forma de punteros por motivos prácticos pero que están relacionados con el objeto padre mediante una relación tiene un o tiene muchos. Vamos a ilustrarlo con un ejemplo. Partamos de un ejemplo relativamente común. Un tipo definido por el usuario moving_average que calcula la media móvil de una señal. La media móvil es la media de las últimas n muestras. Obviamente no hay que calcular la media entera en cada muestra.

1typedef struct {
2    int n;
3    float sum;
4    float* buffer;
5    int current;
6} moving_average;
7
8moving_average*
9moving_average_new (int size) 
10{
11    moving_average* this = (moving_average*)malloc(sizeof(moving_average));
12    moving_average_init(this, size);
13}
14
15void 
16moving_average_init (moving_average* this, int size) 
17{
18    this->n = size;
19    this->sum = 0.;
20    this->buffer = (float*)malloc(size*sizeof(float));
21    for (int i=0; i<size; ++i)
22        this->buffer[i] = 0.;
23    this->current = 0;
24}
25
26float 
27moving_average_next (moving_average* this, float in) 
28{
29    this->sum += in - this->buffer[this->current];
30    this->buffer[this->current] = in;
31    this->current = ++this->current % this->n;
32    return this->sum / this->n;
33}
34
35// ...

El objeto de tipo moving_average lleva la cuenta de la suma de las últimas muestras y almacena temporalmente las últimas n muestras para poder recalcular rápidamente la siguiente suma (restando la que se va y sumando la que llega). Para poder almacenar las muestras se necesita un espacio que depende de n, por lo que hay que reservarlo usando malloc. Además guardamos un índice current que marca cuál es la última posición que se actualizó y por tanto la que debe rellenarse con el siguiente valor. Al principio da igual la posición por la que empiece, porque todas las posiciones contienen 0.0.

En estas condiciones podemos estar tentados de copiar con una simple asignación. Eso haría que se compartiera buffer, lo que obviamente es incorrecto.

1moving_average signal1;
2moving_average_init(&signal1, 10);
3
4moving_average signal2;
5moving_average_init(&signal2, 10);
6
7// ...
8float next1 = moving_average_next(
9                &signal1, 
10                read_signal1());
11float next2 = moving_average_next(
12                &signal2, 
13                read_signal2());

Este tipo de errores es difícil diagnosticarlo en un sistema real, donde puede haber muchas relaciones y los efectos se traducen simplemente en valores extraños. Por si fuera poco, solo se manifiestan cuando hay más de una señal. Sin embargo prevenirlo es relativamente sencillo. No se deben copiar objetos complejos directamente con una asignación. Usa funciones auxiliares para ello:

1float 
2moving_average_copy (moving_average* from, moving_average* to) 
3{
4    to->n = from->n;
5    to->sum = 0.;
6    to->buffer = (float*) malloc(size*sizeof(float));
7    for (int i=0; i<to->n; ++i)
8        to->buffer[i] = from->buffer[i];
9    to->current = from->current;
10}

Es decir, la copia replica todo el estado del objeto origen en el objeto destino.

Cadenas y caracteres

En C un carácter y una cadena son cosas distintas. Una cadena es un array de caracteres terminado con el carácter especial '\0' pero tiene características para simplificar la escritura.

1char c = 'A'; // carácter
2char s = { 'H', 'o', 'l', 'a', '\0'}; // cadena
3char s2[] = "Hola"; // misma cadena que s

Observa que al usar dobles comillas no es necesario indicar el carácter terminador '\0'. Se añade automáticamente.

La expresión "Hola" es también un array, un array constante o literal. Y un array en una expresión se evalúa como la dirección del primer elemento. Eso significa que en lugar de arrays podemos usar punteros como en:

1char* s3 = "Hola"; // misma cadena que s2

Aunque parezca lo mismo no lo es. El caso de s2 es una inicialización. En s2 hay una serie de bytes que se rellenan con los bytes que corresponden a la cadena, incluído el carácter terminador. El array s2 está en la memoria de datos normal del programa, y puede alterarse. Sin embargo, s3 no es un array. Es un simple puntero, contiene la dirección de la cadena "Hola". Lo peculiar es que "Hola" es un literal alfanumérico, un texto inmutable, que habitualmente el compilador guarda junto a las instrucciones del programa. Esto lleva a errores sutiles:

1char s[] = "Hola";
2s[0] = 'J';
3printf("%s\n", s);

Es un error que suele detectar la unidad de gestión de memoria, en tiempo de ejecución. Un error en tiempo de ejecución es mucho más caro de resolver que un error en tiempo de compilación, especialmente en sistemas embarcados. Prestad atención a esto.

Funciones de orden superior

Es muy frecuente utilizar funciones que reciben otras funciones como argumentos o devuelven otras funciones como valor de retorno. Este tipo de funciones se llaman funciones de orden superior. A diferencia de Python, C no permite anidamiento de funciones y cuando se pasa una función como argumento o se devuelve como valor de retorno no se pasa de forma automática el cerramiento, es decir, los valores de las variables locales utilizadas por la función. En C todo es más simple, pero eso implica más trabajo cuando hay que programar con funciones.

En este ejemplo se puede apreciar un pequeño fragmento que usa una tarea de FreeRTOS. La función alarm se le pasa a xTaskCreate para que sea utilizada como la función principal de la nueva tarea. Si esa función necesita datos compartidos entre varias tareas hay que pasar el puntero como cuarto argumento de xTaskCreate. En el ejemplo pasamos un objeto de tipo moving_average.

1void
2alarm (void* params)
3{
4    moving_average* signal = (moving_average*) params;
5
6    // ...
7}
8
9int
10main ()
11{
12    moving_average s;
13    moving_average_init(&s, 10);
14    xTaskHandle task_alarm;
15    xTaskCreate (alarm, "alarm", 2048, &s, 1, &task_alarm);
16}

El preprocesador

C no tiene módulos como tal, pero se apaña con un simple preprocesador. El preprocesador busca directivas precedidas por un carácter almohadilla (#). En este curso usaremos solo las siguientes:

  • #include se usa para incorporar el texto de otro archivo a la unidad de compilación. Nosotros lo usaremos exclusivamente para incorporar archivos de cabecera (extensión .h) de forma que estén declaradas las funciones que vamos a utilizar.
  • #define se usa para definir constantes o macro-funciones. Nosotros lo usaremos exclusivamente en combinación con la siguiente directiva.
  • #ifdef o #ifndef se utilizan para compilación condicional según se encuentre o no definida la constante que se indica a continuación.

La estructura que utilizaremos es la siguiente. Cada módulo se dividirá en dos archivos modulo.h y modulo.c. El archivo de cabecera contendrá todos los tipos que debe conocer el usuario y las declaraciones de todas las funciones que debe conocer el usuario. Todo el archivo estará metido entre guardas del preprocesador:

1#ifndef MODULO_H
2#define MODULO_H
3// tipos y declaraciones ...
4
5#endif

Esta guarda impide que el archivo se incluya varias veces en una misma unidad de compilación. Cuando el programa se complica es fácil que varios módulos incluyan en sus cabeceras otras cabeceras y algunas de ellas pueden estar repetidas. Con estas guardas solo la primera vez se incluirá en nuestro módulo.

Siempre que necesitemos usar el módulo modulo.h bastará incluirlo sin más. Lo haremos siempre después de las cabeceras del sistema (que se incluyen entre ángulos). De esta forma si definimos algo que ya está definido en el sistema generará un error en nuestro módulo, no en el del sistema, como debe ser.

1#include <stdio.h>
2#include "modulo.h"
3
4// ahora se pueden usar las funciones y tipos de modulo.h

Errores frecuentes

La mayoría de los errores en los programas en C tienen que ver con los punteros. Vamos a resumir los problemas que pueden ocurrir para que se preste mucha atención a cada uno de ellos.

Punteros colgantes (dangling pointers)

Un puntero colgante apunta a una dirección de memoria que ha sido liberada, ya sea por destrucción de una variable automática o por llamar a free con un puntero reservado por malloc.

1#include <stdio.h>
2  
3int* f() {
4    int x;
5    // ...
6    return &x;
7}
8  
9int main() {
10    int* p = f();
11      
12    printf("%d\n", *p);
13}

La función f devuelve un puntero a una variable automática. Pero la variable automática se libera en el momento de terminar la ejecución de la función f. Por tanto, la sentencia printf va a imprimir lo apuntado por un puntero colgante.

Es posible solucionar el error simplemente declarando x como static int x para que se almacene como una global en lugar de automática. Pero no hagas esto. Una chapuza no arregla otra chapuza, la empeora.

Lo correcto es vigilar que nunca se devuelva un puntero a una automática. Pasa el puntero desde el llamador, en lugar de devolverlo desde la función. Por ejemplo, el caso anterior sería:

1#include <stdio.h>
2  
3void f(int* x) {
4    // ...
5}
6  
7int main() {
8    int x;
9    f(&x);
10    printf("%d\n", x);
11}

El otro caso es más problemático:

1#include <stdlib.h>
2#include <stdio.h>
3int main() {
4    int* p = (int*) malloc(5*sizeof(int));
5    // ...
6    free(p); 
7    
8    // p es dangling pointer
9    p = NULL;
10    // ya p no es dangling
11}

En un contexto real es muy difícil encontrar este tipo de errores, porque p puede estar copiado en mil sitios y si lo usamos para acceder al array puede haber cualquier cosa. En C no hay una buena solución contra este problema. En C++ sí que la hay pero excede lo que podemos abordar en esta asignatura.

Afortunadamente en esta asignatura vamos a usar sistemas empotrados de tiempo real crítico. Eso hace que usar malloc y free salvajemente esté muy desaconsejado, porque no hay forma de analizar la planificabilidad de ese tipo de código. Normalmente realizaremos una serie de malloc al principio y no volveremos a tocar esa memoria.

Puntero salvaje (wild pointer)

Un puntero salvaje es un puntero sin inicializar. Por tanto contiene un valor completamente indeterminado. Para evitarte errores difíciles de depurar nunca dejes punteros sin inicializar.

1int* p = NULL;
2char* q = g();
3// ...
4*p = &x;

Si no tienes nada mejor que asignar, inicializa los punteros con NULL. Es órdenes de veces más fácil depurar un problema con un puntero nulo que con un puntero salvaje.

Entorno de programación

Recomendación: Microsoft Visual Studio Code con la extensión Espressif IDF.

  1. Instala VSCode en tu ordenador.
  2. Instala Espressif IDF desde dentro de VSCode (Extensiones).
  3. (Recomendado) Adquiere tu propio sistema de desarrollo ESP32. No tiene por qué ser el del enlace, da igual el modelo concreto.

Referencias

Brian Kernighan, Dennis Ritchie. The C Programming Language. Prentice-Hall, 1988.