Tratamiento de errores
Antes de empezar a escribir programas relativamente complejos en C conviene hacer una pequeña reflexión sobre la gestión de errores en C. La forma habitual de manejar errores es devolver un código de error en las funciones y, dependiendo de su valor actuar de una forma u otra.
Pero ¿qué pasa si no sabemos cómo actuar ante una situación errónea?
Es muy frecuente que en el momento que se produce el error no tengamos
toda la información necesaria para tratarlo debidamente. Es habitual
devolver otro código de error al llamador, pero esto empieza a
oscurecer el código porque cada llamada a función tiene que ir en una
claúsula if
.
Lo correcto hoy en día es emplear un mecanismo soportado por todos los
lenguajes de programación modernos, que se denomina excepciones.
Pero C no tiene excepciones. Afortunadamente hay un conjunto de
bibliotecas que implementan el mecanismo utilizando macros del
preprocesador y dos funciones de la biblioteca estándar de C que no
suelen ser muy conocidas, setjmp
y longjmp
.
No es propósito de este taller explicar el funcionamiento de estas funciones. Si tienes curiosidad mira la página de manual para entender su funcionamiento. Son esenciales para implementar corrutinas, o cualquier otra forma de interrumpir el flujo normal de ejecución de un programa.
En nuestros ejemplos vamos a optar por
cexcept
por su extrema simplicidad. Tiene
un único archivo de cabecera (cexcept.h
) y su uso es muy simple.
Cuando el programa tiene suficiente información para manejar posibles errores debe utilizar una construcción de este estilo:
Try {
/* Código de usuario, sin ´manejo de errores */
}
Catch (ex) {
/* Código para tratar el error notificado en ex */
}
En el momento en que se puede producir una situación excepcional o un error debe notificarse de esta forma:
if (funcion_tradicional() < 0)
Throw ex;
Donde ex
es una excepción, una estructura de datos que recoge toda
la información del error para ser manejada cuando esto sea posible.
El tipo de datos de ex
es definible por el usuario.
En nuestros ejemplos utilizaremos reactor/exception.h
, una
biblioteca auxiliar en la que definimos la excepción como una
estructura similar a esta:
typedef struct {
int error_code;
const char* what;
} exception;
define_exception_type(exception);
Así que si necesitas una descripción textual del error puedes usar el
campo what
de la estructura. Por ejemplo:
exception ex;
Try {
guardar_datos(db);
}
Catch (ex) {
printf("No pudo grabar los datos.\n"
"Motivo: %s\n", ex.what);
}
Para notificar una situación excepcional se dice que se eleva una
excepción. Es decir, se usa una sentencia Throw
con los valores
apropiados de la excepción. Por ejemplo:
f = fopen(fname, "r");
if (f == NULL)
Throw Exception(errno, "")
Cuando se ejecuta la sentencia Throw
el programa pasa el control a
la claúsula Catch
del último Try
abierto, aunque esté en otra
función. Es un mecanismo muy poderoso para desacoplar la lógica del
programa y la lógica de manejo de errores.
Anidamiento de Try/Catch
En algunos casos se sabe cómo manejar algunos errores, pero no todos. En esos casos puede ser útil capturar la posible excepción y volverla a lanzar si no se sabe qué hacer con ella. Por ejemplo:
void f() {
exception e;
Try {
procesamiento_complejo();
mas_procesamiento_complejo();
todavia_mas_procesamiento_complejo();
}
Catch(e) {
if (e.error_code != ERROR_NO_MAS_DATOS)
Throw e;
}
}
void g() {
exception e;
Try {
f();
Catch(e) {
fprintf(stderr, "Error: %s\n", e.what);
}
}
La función f
realiza el procesamiento complejo y captura la
excepción ERROR_NO_MAS_DATOS
porque entiende que es una situación
normal, que no necesita ser tratada de ninguna forma especial. Sin
embargo en cualquier otro caso relanza la excepción para que se trate
más arriba en la cadena de llamadas.
La función g
, que es de mayor nivel de abstracción, utiliza f
pero
en caso de fallo lo notifica al usuario por la salida de error
estándar. Fíjate cómo se reparte la responsabilidad de emitir el
error (e.g. en procesamiento_complejo
) y de tratarlo en el punto
donde se sabe cómo tratarlo (f
o g
). No es necesario devolver
códigos de error en todas las funciones, ni añadir if
cuando no se
sabe cómo actuar con el error.
Prevenir leaks
Las excepciones pueden ser un mecanismo muy efectivo para evitar que
algunos recursos se queden sin liberar (memoria dinámica reservada con
malloc
que no se libera con free
, archivos abiertos con fopen
que no se cierran con fclose
, archivos abiertos con open
que no se
cierran con close
, etc.). Por ejemplo, considera esta función para
contar el número de líneas de un archivo:
int contar_lineas(const char* nombre)
{
FILE* f = fopen(nombre, "r");
int lineas = 0;
char buf[128];
while (NULL != fgets(buf, sizeof(buf), f))
if (buf[strlen(buf)-1] == '\n')
++lineas;
if (buf[0] != '\0' && buf[0] != '\n')
++lineas;
fclose(f);
return lineas;
}
Esta función cuenta correctamente el número de líneas de un archivo
contando los caracteres terminador de línea '\n'
que aparecen y
corrigiendo la cuenta si la última línea no tiene terminador de línea
y no está vacía. Pero ¿qué pasa si hay error?
Podemos aprovechar lo que ahora sabemos para notificar errores:
int contar_lineas(const char* nombre)
{
FILE* f = fopen(nombre, "r");
if (f == NULL)
Throw Exception(errno, "Error al abrir archivo");
int lineas = 0;
char buf[128];
while (NULL != fgets(buf, sizeof(buf), f))
if (buf[strlen(buf)-1] == '\n')
++lineas;
if (ferror(f))
Throw Exception(errno, "Error de lectura");
if (buf[0] != '\0' && buf[0] != '\n')
++lineas;
fclose(f);
return lineas;
}
El problema es que en caso de error de lectura el archivo f
no se
cierra nunca. Nadie llama a fclose
. La solución es poner la
excepción pero no lanzarla hasta el final.
int contar_lineas(const char* nombre)
{
FILE* f = fopen(nombre, "r");
if (f == NULL)
Throw Exception(errno, "Error al abrir archivo");
int lineas = 0;
char buf[128];
while (NULL != fgets(buf, sizeof(buf), f))
if (buf[strlen(buf)-1] == '\n')
++lineas;
exception e = { -1, NULL };
if (ferror(f))
e = Exception(errno, "Error de lectura");
else if (buf[0] != '\0' && buf[0] != '\n')
++lineas;
fclose(f);
if (e.error_code >= 0)
Throw e;
return lineas;
}
Para mayor generalidad, para el caso de que haya funciones anidadas
que usan excepciones, o si el flujo en caso de error no está claro, es
mejor meter el código en un Try/Catch
. Por ejemplo, en el siguiente
fragmento separamos el código en dos funciones, la función
contar_lineas_en_file
ni siquiera sabe que se tenga que liberar
ningún recurso.
int contar_lineas_en_file(FILE* f)
{
int lineas = 0;
char buf[128];
while (NULL != fgets(buf, sizeof(buf), f))
if (buf[strlen(buf)-1] == '\n')
++lineas;
if (ferror(f))
Throw Exception(errno, "Error de lectura");
if (buf[0] != '\0' && buf[0] != '\n')
++lineas;
return lineas;
}
int contar_lineas(const char* nombre)
{
int ret = 0;
exception e = { -1, NULL };
FILE* f = fopen(nombre, "r");
if (f == NULL)
Throw Exception(errno, "Error al abrir archivo");
Try {
ret = contar_lineas_en_file(f);
}
Catch(e) {
}
fclose(f);
if (e.error_code >= 0)
Throw e;
return lineas;
}
Esta construcción es equivalente a lo que en Java se denomina
claúsula finally
. No importa lo que haga el programa dentro del
Try
, en cualquier caso se llamará a close
.
Excepciones no capturadas
En una situación normal si una excepción de cexcept no es capturada
con una sentencia Try/Catch
se produciría un fallo de segmentación.
En reactor
hemos proporcionado un mecanismo para que las excepciones
no capturadas desemboquen en un mensaje de información sobre el error
y un volcado de la traza de llamada. Por ejemplo:
Uncaught exception (error_code 111) at socket_handler.c:167
Error en connect: Connection refused
Current call trace (last 5):
./rpi-src/c/reactor/test/test_connector(connector_init+0x28)[0x12d1c]
./rpi-src/c/reactor/test/test_connector(connector_new+0x3c)[0x12cd4]
./rpi-src/c/reactor/test/test_connector(main__+0x5c)[0x11b0c]
./rpi-src/c/reactor/test/test_connector(main+0xa0)[0x13870]
/lib/arm-linux-gnueabihf/libc.so.6(__libc_start_main+0x114)[0x76d4b294]
Informa de que la excepción se ha producido en la línea 167 del
archivo socket_handler.c
. Para mayor información se notifica la
lista de llamadas no terminadas en el momento de la excepción. En
este ejemplo la excepción se produjo en la función connector_init
que fue llamada desde connector_new
y ésta desde main__
. Nota que
el nombre de main
aparece algo extraño. Eso es por el mecanismo
incorporado para tratar las excepciones no capturadas.
Uso en tus programas
Para usar excepciones en tus programas solo tienes que tener en cuenta algunos detalles.
- Incluye el archivo de cabecera
reactor/exception.h
. - Compila con las opciones
-fasynchronous-unwind-tables
y-pthread
. - Añade al montador las opciones
-pthread
y-rdynamic
.
Realmente solo el primer punto es estrictamente necesario, pero los demás consejos permiten tener información completa de la traza de llamadas, una gran ayuda para depurar.
Simplificar con excepciones
Separar el flujo de control del programa del flujo de control de errores tiene una consecuencia clara a la hora de escribir programas. Se fortalecen las abstracciones y es más fácil escribirlos. Veamos un ejemplo sencillo para ver esto.
Una función muy frecuente en los programas de Raspberry Pi es write
.
Se trata de una llamada al sistema operativo que escribe un conjunto
de datos en un archivo identificado por un descriptor de archivo. Ese
archivo puede representar un puerto serie, un canal de comunicaciones
WiFi con otra Raspberry Pi, o incluso una salida digital. La esencia
de Unix y todos sus derivados e imitaciones es que todo en el sistema
se vea como un archivo desde el punto de vista del sistema operativo.
ssize_t write(int fd, const void* buf, size_t size);
La llamada al sistema write
devuelve un valor que normalmente
corresponde al número de bytes escritos. En general no tiene por qué
coincidir con el número de bytes totales y no por ello implica error.
Para poder mandar todo hay que volver a llamar a write
con el resto.
Por tanto este uso es típico:
int escribir_datos(int fd, const tipo_datos* datos)
{
const void* buf = datos;
int size = sizeof(tipo_datos);
while (size > 0) {
int n = write(fd, buf, size);
if (n < 0)
return -1;
buf += n;
size -= n;
}
return 0;
}
Es innecesariamente largo debido a que write
no devuelve los bytes
escritos, sino que tiene dos posibles significados. Con excepciones
esto no pasa, vamos a envolver write
en una función más amigable:
int write_ex(int fd, const void* buf, size_t size)
{
if (0 > write(fd, buf, size))
Throw Exception(errno, "write error");
}
No ha sido muy complicado pero hemos eliminado la fuente de la
confusión. Ahora si devuelve algo es el número de bytes escritos, no
devuelve ningún error. Si hay errores se trata aparte, en el
Try/Catch
correspondiente. Ahora se puede simplificar
considerablemente:
void escribir_datos(int fd, const tipo_datos* datos)
{
const void *buf = datos, *end = buf + sizeof(tipo_datos);
while ((buf += write_ex(fd, buf, end - buf)) < end)
;
}
No hace falta devolver otro código de error, si hay excepción ya se propagará adecuadamente.
No te quedes en esta breve explicación, mira tranquilamente los
ejemplos que te proporcionamos en el taller, especialmente la
biblioteca reactor
. Aunque las excepciones te parezcan algo
completamente nuevo empieza a usarlas desde ya. Tu código ganará
mucho en legibilidad y en robustez, aunque no tengas muy claro cómo
funciona.
Otros contextos de excepción
En reactor
no nos hemos preocupado demasiado del manejo de
excepciones en los hilos que no son el hilo principal. En la práctica
esta limitación no es tan importante, porque los hilos auxiliares son
habitualmente muy simples. Cuando la cosa se complica suele usarse un
proceso diferente, por ejemplo con un process_handler
.
Sin embargo hemos habilitado un proceso para permitir el uso de excepciones en múltiples hilos de ejecución cambiando el denominado contexto de excepción. Por ejemplo, imagina que tenemos dos hilos que realizan un trabajo importante. Por ejemplo, cada uno con su reactor. El hilo principal tiene el contexto de ejecución 0 (cero). Si queremos tener excepciones en el otro hilo basta asignarle un contexto de excepción diferente al principio del hilo:
void* thread(thread_handler* t)
{
current_exception_context = 1;
...
}
Es así de simple, pero hay que tener cuidado de que todos los hilos
con excepciones tengan un current_exception_context
diferente. El
número de contextos disponible es reducido, pero puede cambiarse
fácilmente en reactor/exception.c
editando el valor de la constante
NUM_EXCEPTION_CONTEXTS
y recompilando.