El lenguaje C sigue siendo el más utilizado en sistemas empotrados.
1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|
Especificación | Modelado | Verificación | Implementación | Verificación | Aná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, CTL | FSM / xFSM | nuXmv | fsm.h / fsm.c | nuXmv | Aná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.
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:
Tipo | Variante | Rango | Ejemplo |
---|---|---|---|
Entero | 8 bits con signo | -128 a 127 | char c; |
8 bits sin signo | 0 a 255 | unsigned char c; | |
16 bits con signo | -32768 a 32767 | short i; | |
16 bits sin signo | 0 a 65535 | unsigned short i; | |
32 bits con signo | -2147483648 a 2147483647 | int i; | |
32 bits sin signo | 0 a 4294967295 | unsigned int i; | |
64 bits con signo | -9223372036854775808 a 9223372036854775807 | long long i; | |
64 bits sin signo | 0 a 18446744073709551616 | unsigned long long i; | |
Real | 32 bits (simple precisión) | float f; | |
64 bits (doble precisión) | double f; | ||
80 bits (precisión extendida) | long double f; | ||
Puntero | a carácter (cadena) | char* p; | |
a entero | int* p; | ||
a real | float* p; | ||
a cualquier objeto | void* p; | ||
a función | void (*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
.
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.
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}
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.
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);
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}
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.
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.
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}
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
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.
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.
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.
Recomendación: Microsoft Visual Studio Code con la extensión Espressif IDF.