Tratamento de erros em Linguagem C
- por Sergio Prado
Tratamento de erros (ou tratamento de exceções) é algo essencial em qualquer software, e principalmente em sistemas embarcados, onde precisamos ter a garantia de um software robusto, confiável e a prova de falhas.
Em C++ temos embutido na linguagem um mecanismo de tratamento de exceções. Já na linguagem C não temos este mesmo mecanismo. Fica sob responsabilidade do programador desenvolver uma forma de identificar e tratar os erros da aplicação.
Existem várias formas de realizar esta tarefa, dependendo da situação, cada uma com seus prós e contras.
Vamos então ver aqui algumas das técnicas de identificação e tratamento de erros em linguagem C.
1. Usando o código de retorno das funções
Este é o mecanismo mais comum de tratamento de erros em linguagem C. Neste método, uma função tem duas responsabilidades extras:
- Retornar um código indicando o resultado da sua execução.
- Verificar o retorno de todas as funções que ela chama.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
int sendCmd(char *buf, int tam) { if (tam < 5) return ERR_TAM; if ((buf = (char *)malloc(tam)) == NULL) return ERR_MALLOC; if (formatCmd(buf, tam)) return ERR_FORMAT; if (txCmd(buf, tam)) return ERR_TX; free(buf); return OK; } int send() { char buf[10]; return(sendCmd(buf, sizeof(buf))); } |
O que cada função deve retornar fica a cargo do desenvolvedor. Normalmente, eu tenho o costume de retornar “0” em caso de sucesso e um inteiro positivo ou negativo em caso de erro. Você pode também retornar sempre “1” para sucesso e um inteiro negativo em caso de erros. De qualquer forma, o ideal é implementar um arquivo de cabeçalho (Ex: errors.h) e definir os códigos de erro lá dentro, assim evitamos ambiguidades nos códigos de retorno entre as funções.
Este mecanismo é relativamente simples e até elegante, porém tem alguns pontos negativos. Dependendo da complexidade e do tamanho do código, fica difícil de ler e dar manutenção. Imagine uma função que chama outra função, que chama outra, e assim por diante. Pense na complexidade que é gerenciar todas estas chamadas e retornos. Dependendo do caso, o melhor é partir para outras soluções.
2. Retornando erros em variáveis globais
Sou totalmente contra o uso de variáveis globais, e acredito que a qualidade de um software em C é inversamente proporcional à quantidade de variáveis globais declaradas.
De qualquer forma, este é um mecanismo muito usado na GLIBC, biblioteca padrão do sistema GNU/Linux, e que acaba sendo herdado para outras bibliotecas, como a uclibc, usada bastante em sistemas embarcados.
Neste mecanismo, as funções retornam “-1” em caso de erro, e a variável global “errno” é setada com o código do erro. Pode-se usar a função strerror() para imprimir a mensagem de erro.
1 2 3 |
if (read(fd, buf, sizeof(buf)) == -1) { printf("Read error [%d]: %s", errno, strerror(errno)); } |
Esta solução tem alguns problemas, dentre eles é que por usar uma variável global, qualquer função que tentar acessar a variavel “errno” não poderá ser reentrante, a não ser que você proteja o acesso através de um MUTEX.
3. Usando a função assert()
A função assert(), disponível na maioria das bibliotecas C, é um tema que poderia ocupar um post inteiro. Mas basicamente, esta função é uma macro cujo objetivo é ajudar o programador a encontrar bugs quando a aplicação ainda está em desenvolvimento. Ela testa uma determinada condição, e em caso de falha no teste, alerta o programador. Exemplo:
1 2 3 4 5 6 |
int cfgE2PROM(int reg, char data) { assert(reg > 0); setE2PROM(E2P_BASE_ADDR + reg, data); } |
No exemplo acima, a função assert() garante que a variável reg seja sempre maior que zero, caso contrário a aplicação aborta e uma mensagem de erro é exibida em stderr (saída padrão de erro). Esta função pode ser utilizada em sistemas que possuem um mecanismo de gerenciamento de streams de I/O, como consoles normalmente presentes em sistemas com Linux Embarcado. Em sistemas mais simples, esta função não tem muita utilidade, apesar de possibilitar a implementação de algo para identificar erros de tempo de compilação.
4. Tratando sinais do sistema operacional
Outra forma de tratamento de erros é através de sinais, que nada mais é do que um mecanismo de comunicaçao entre o sistema operacional e a aplicação. Exemplo:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
#include #include void FPHandler(int signum) { printf("FP error!\n"); abort(); } main() { int a; signal(SIGFPE, FPHandler); a = 10 / 0; return 0; } |
Configuramos o sinal que queremos capturar com a função signal(). Neste caso queremos que o sistema operacional execute a função FPHandler() em caso de erros de operações aritméticas e ponto flutuante. E quando executamos uma divisão por zero, o erro é capturado pelo sistema operacional e a função FPHandler() é executada, exibindo a mensagem de erro e abortando a aplicação.
5. Usando a palavra-chave “goto”
Já deixei claro aqui no blog que sou totalmente contra o uso de goto em linguagem C. Mas se existe alguma utilidade para a palavra-chave goto, esta utilidade está no tratamento de erros.
Deixei propositalmente um erro no nosso primeiro exemplo, onde faço um malloc() mas em situação de erro retorno da função e não faço um free(). Vamos corrigir este problema agora:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
int sendCmd(char *buf, int tam) { if (tam < 5) return ERR_TAM; if ((buf = (char *)malloc(tam)) == NULL) return ERR_MALLOC; if (formatCmd(buf, tam)) { free(buf); return ERR_FORMAT; } if (txCmd(buf, tam)) { free(buf); return ERR_TX; } free(buf); return OK; } |
Veja um problema aqui: precisamos chamar 3 vezes a função free(), uma para cada ponto de retorno. Se tivessemos mais pontos de retorno, teríamos que chamá-la mais vezes. E o risco de alguem esquecer de fazer isso é muito grande. Memory leak na certa!
Olhe agora uma solução para este problema usando “goto”:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
int sendCmd(char *buf, int tam) { int ret = OK; if (tam < 5) return ERR_TAM; if ((buf = (char *)malloc(tam)) == NULL) return ERR_MALLOC; if (formatCmd(buf, tam)) { ret = ERR_FORMAT; goto fim; } if (txCmd(buf, tam)) { ret = ERR_TX; goto fim; } fim: free(buf); return ret; } |
6. Usando as funções setjmp() and longjmp()
Citei no começo do post que a linguagem C++ já possui um mecanismo de tratamento de exceções, através das palavras chave try/catch. Tem um artigo sobre tratamento de exceções em C++ aqui. Porém, este mecanismo consome CPU e normalmente trabalha com alocação dinâmica de memória, que pode ser um problema para o desenvolvimento de sistemas embarcados.
E se pudessemos implementar este mesmo mecanismo em C, porém otimizado e sem o uso de malloc? A boa notícia é que isso já foi feito, e existem algumas soluções disponíveis. Uma delas é o cexcept.
Esta implementação usa as funções setjmp() e longjmp(), e define algumas macros para que possamos usar simular as palavras-chave Throw, Try e Catch. Nosso exemplo ficaria assim:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
void sendCmd(char *buf, int tam) { int e; if (tam < 5) Throw ERR_TAM; if ((buf = (char *)malloc(tam)) == NULL) Throw ERR_MALLOC; Try { formatCmd(buf, tam); txCmd(buf, tam); free(buf); } Catch (e) { free(buf); Throw e; } } |
Os erros são retornados ou “lançados” para a função chamadora com Throw, e capturados com Try e Catch, evitando o uso excessivo de if’s e return’s na aplicação. O código fica bem mais legível e fácil de dar manutenção.
E então, o que vocês acham destas técnicas? Vocês utilizam algum outro método diferente dos apresentados aqui?
Um abraço,
Sergio Prado