Error Handling in Embedded C Programming

Source: https://www.cnblogs.com/clover-toeic/p/3919857.html

Introduction

This article summarizes the main error handling methods in embedded C programming. The code execution environment involved in this article is as follows:

Error Handling in Embedded C Programming

1. Error Concepts

1.1 Error Classification

In terms of severity, program errors can be classified into fatal and non-fatal errors. For fatal errors, recovery actions cannot be executed; at most, an error message can be printed on the user screen or written to a log file, and then the program is terminated. For non-fatal errors, most are essentially temporary (e.g., resource shortages), and the general recovery action is to retry after a delay.

In terms of interactivity, program errors can be classified into user errors and internal errors. User errors are presented to the user, usually indicating mistakes in user operations; while internal errors are presented to the programmer (possibly carrying user-inaccessible data details) for debugging and troubleshooting.

Application developers can decide which errors to recover from and how to recover. For example, if the disk is full, consider deleting non-essential or expired data; if the network connection fails, consider rebuilding the connection after a short delay. Choosing a reasonable error recovery strategy can prevent abnormal termination of the application, thereby improving its robustness.

1.2 Handling Steps

Error handling refers to dealing with any unexpected or exceptional situations that occur during program execution. Typical error handling consists of five steps:

  1. A software error occurs during program execution. This error may arise from hardware response events mapped as software errors by the underlying driver or kernel (e.g., division by zero).

  2. Record the cause of the error and related information with an error indicator (such as an integer or structure).

  3. The program detects the error (by reading the error indicator or being actively reported by it);

  4. The program decides how to handle the error (ignore, partially handle, or fully handle);

  5. Recover or terminate the program’s execution.

The above steps are expressed in C language code as follows:

int func()
{
    int bIsErrOccur = 0;
    //do something that might invoke errors
    if(bIsErrOccur)  //Stage 1: error occurred
        return -1;   //Stage 2: generate error indicator
    //...
    return 0;
}

int main(void)
{
    if(func() != 0)  //Stage 3: detect error
    {
        //Stage 4: handle error
    }
    //Stage 5: recover or abort
    return 0;
}

The caller may wish for the function to indicate complete success when it returns successfully, and to restore the program to its state before the call when it fails (but this is difficult for the called function to guarantee).

2. Error Propagation

2.1 Return Values and Back Parameters

C language typically uses return values to indicate whether a function executed successfully, and the caller checks this return value using if statements to determine the execution status of the function. Common calling forms are as follows:

if((p = malloc(100)) == NULL)
   //...

if((c = getchar()) == EOF)
   //...

if((ticks = clock()) < 0)
   //...

Unix system call-level functions (and some older Posix functions) sometimes return both error codes and useful results. Therefore, the above calling forms can receive return values and check for errors in the same statement (returning valid data values when executed successfully).

The advantage of return value methods is simplicity and efficiency, but there are still many issues:

  1. Reduced code readability

Functions without return values are unreliable. However, if every function has a return value, to maintain program robustness, each function must be validated for correctness, meaning the return value must be checked at the time of the call. Thus, a large portion of the code may be spent on error handling, and the error handling code is mixed with normal flow code, making it quite chaotic.

  1. Quality degradation

Conditional statements hide more errors compared to other types of statements. Unnecessary conditional statements increase the workload for troubleshooting and white-box testing.

  1. Limited information

Return values can only return one value, so they generally can only simply indicate success or failure, and cannot serve as a means to obtain specific error information. Bitwise encoding can be used to return multiple values, but it is not commonly used.

String processing functions can refer to IntToAscii() to return specific error reasons and support chaining:

char *IntToAscii(int dwVal, char *pszRes, int dwRadix)
{
    if(NULL == pszRes)
        return "Arg2Null";

    if((dwRadix < 2) || (dwRadix > 36))
        return "Arg3OutOfRange";

    //...
    return pszRes;
}
  1. Definition conflicts

Different functions may have different value rules for return values on success and failure. For example, Unix system call-level functions return 0 for success and -1 for failure; new Posix functions return 0 for success and non-zero for failure; standard C library isxxx functions return 1 for success and 0 for failure.

  1. Unconstrained

Callers can ignore and discard return values. When return values are not checked and handled, the program can still run, but the results are unpredictable.

New Posix functions return values that only carry status and exception information, and useful results are returned through pointer parameters in the parameter list. Back parameters are bound to the corresponding actual parameters, so callers cannot completely ignore them. Multiple values can be returned through back parameters (such as structure pointers), and more information can be carried.

Combining the advantages of return values and back parameters, return values (containing useful results) can be used for Get-type functions, while return values + back parameters can be used for Set-type functions.

For pure return values, the following parsing interface can be provided as needed:

typedef enum{
    S_OK,                   //Success
    S_ERROR,                //Failure (reason unclear), general status

    S_NULL_POINTER,         //Input parameter pointer is NULL
    S_ILLEGAL_PARAM,        //Parameter value illegal, general
    S_OUT_OF_RANGE,         //Parameter value out of range
    S_MAX_STATUS            //Cannot be used as return value status, only for enumeration of extreme values
}FUNC_STATUS;

#define RC_NAME(eRetCode) \
    ((eRetCode) == S_OK                   ?    "Success"             : \
    ((eRetCode) == S_ERROR                ?    "Failure"             : \
    ((eRetCode) == S_NULL_POINTER         ?    "NullPointer"         : \
    ((eRetCode) == S_ILLEGAL_PARAM        ?    "IllegalParas"        : \
    ((eRetCode) == S_OUT_OF_RANGE         ?    "OutOfRange"          : \
      "Unknown")))))

When return value error codes come from downstream modules, they may conflict with the error codes of this module. In this case, it is recommended not to pass downstream error codes directly upstream to avoid confusion. If error information is allowed to be output to the terminal or file, detailed error scenes (such as function names, error descriptions, parameter values, etc.) can be recorded, and converted to error codes defined by this module before passing upstream.

2.2 Global Status Flag (errno)

When Unix system calls or certain C standard library functions fail, they typically return a negative value and set the global integer variable errno to a value containing error information. For example, when the open function fails, it returns -1 and sets errno to EACESS (permission denied) and other values.

The C standard library header file <errno.h> defines errno and its possible non-zero constant values (starting with the character ‘E’). Some basic errno constants have been defined in ANSI C, and the operating system will also extend some (but its error descriptions are still quite lacking). In Linux systems, error constants are listed in the errno(3) manual page, which can be viewed using the command man 3 errno. Except for EAGAIN and EWOULDBLOCK having the same value, all error number values specified by POSIX.1 are different.

Posix and ISO C define errno as a modifiable integer lvalue (lvalue), which can be an integer containing an error number or a function that returns a pointer to an error number. The previously used definition was:

extern int errno;

However, in a multi-threaded environment, multiple threads share the process address space, and each thread has its own local errno (thread-local) to avoid one thread interfering with another. For example, Linux supports multi-threaded access to errno, defining it as:

extern int *__errno_location(void);
#define errno (*__errno_location())

The function __errno_location has different definitions under different library versions. In the single-threaded version, it directly returns the address of the global variable errno; while in the multi-threaded version, the addresses returned by __errno_location for different threads are different.

In the C runtime library, errno is mainly used in functions declared in the math.h (mathematical operations) and stdio.h (I/O operations) header files.

When using errno, the following points should be noted:

  1. When a function returns successfully, it is allowed to modify errno.

For example, when calling the fopen function to create a file, it may internally call other library functions to check if a file with the same name exists. The library function used to check the file may fail and set errno when the file does not exist. Thus, every time fopen successfully creates a file that did not previously exist, errno may still be set even if no program error occurs (fopen itself returns successfully).

Therefore, when calling library functions, the return value as an error indicator should be checked first. Only when the function return value indicates an error should the errno value be checked:

//Call library function
if(return error value)
    //Check errno
  1. Library functions may not necessarily set errno when they return failure, depending on the specific library function.

  2. errno is set to 0 at the start of the program, and no library function will reset errno again.

Therefore, it is best to set errno to 0 before calling runtime library functions that may set errno. After a failed call, check the value of errno.

  1. Before using errno, avoid calling other library functions that may set errno. For example:
if (somecall() == -1)
{
    printf("somecall() failed\n");
    if(errno == ...) { ... }
}

The somecall() function sets errno when it returns an error. However, when checking errno, its value may have already been changed by the printf() function.

To correctly use the errno set by the somecall() function, its value must be saved before calling the printf() function:

if (somecall() == -1)
{
    int dwErrSaved = errno;
    printf("somecall() failed\n");
    if(dwErrSaved == ...) { ... }
}

Similarly, when calling reentrant functions in signal handlers, the errno value should be saved before and restored after.

  1. When using modern versions of the C library, include the <errno.h> header file; in very old Unix systems, this header file may not exist, in which case errno can be manually declared (e.g., extern int errno).

The C standard defines two functions, strerror and perror, to help print error messages.

#include <string.h>
char *strerror(int errnum);

This function maps errnum (i.e., the errno value) to an error message string and returns a pointer to that string. The error string can be combined with other information to output to the user interface or saved to a log file, such as printing the error message to the file pointed to by fp using fprintf(fp, “somecall failed(%s)”, strerror(errno)).

The perror function outputs the string corresponding to the current errno error message to standard error (i.e., stderr or 2).

#include <stdio.h>
void perror(const char *msg);

This function first outputs the string pointed to by msg (user-defined information), followed by a colon and space, then the description of the current errno value, and finally a newline character. When not using redirection, this function outputs to the console; if standard error output is redirected to /dev/null, no output will be seen.

Note that the error message set corresponding to errno in the perror() function is the same as that in strerror(). However, the latter can provide more location information and output methods.

Examples of usage for both functions are as follows:

int main(int argc, char** argv)
{
    errno = 0;
    FILE *pFile = fopen(argv[1], "r");
    if(NULL == pFile)
    {
        printf("Cannot open file '%s'(%s)!\n", argv[1], strerror(errno));
        perror("Open file failed");
    }
    else
    {
        printf("Open file '%s'(%s)!\n", argv[1], strerror(errno));
        perror("Open file");
        fclose(pFile);
    }

    return 0;
}

The execution results are:

[wangxiaoyuan_@localhost test1]$ ./GlbErr /sdb1/wangxiaoyuan/linux_test/test1/test.c
Open file '/sdb1/wangxiaoyuan/linux_test/test1/test.c'(Success)!
Open file: Success
[wangxiaoyuan_@localhost test1]$ ./GlbErr NonexistentFile.h
Cannot open file 'NonexistentFile.h'(No such file or directory)!
Open file failed: No such file or directory
[wangxiaoyuan_@localhost test1]$ ./GlbErr NonexistentFile.h > test
Open file failed: No such file or directory
[wangxiaoyuan_@localhost test1]$ ./GlbErr NonexistentFile.h 2> test
Cannot open file 'NonexistentFile.h'(No such file or directory)!

Custom error codes can also be defined and handled similarly to errno:

int *_fpErrNo(void)
{
   static int dwLocalErrNo = 0;
   return &amp;dwLocalErrNo;
}

#define ErrNo (*_fpErrNo())
#define EOUTOFRANGE  1
//define other error macros...

int Callee(void)
{
    ErrNo = 1;
    return -1;
}

int main(void)
{
    ErrNo = 0;
    if((-1 == Callee()) && (EOUTOFRANGE == ErrNo))
        printf("Callee failed(ErrNo:%d)!\n", ErrNo);
    return 0;
}

With the global status flag, the function interfaces (return values and parameter tables) can be fully utilized. However, like return values, it implicitly requires the caller to check this flag after calling the function, and this constraint is also fragile.

Moreover, global status flags carry the risk of reuse and overwriting. In contrast, function return values are unnamed temporary variables produced by the function and can only be accessed by the caller. After the call, the return value can be checked or copied, and the original return object will disappear and cannot be reused. Also, because they are unnamed, return values cannot be overwritten.

2.3 Local Jumps (goto)

The goto statement can be used to jump directly to the error handling code within a function. For example, in the case of a division by zero error:

double Division(double fDividend, double fDivisor)
{
    return fDividend/fDivisor;
}
int main(void)
{
    int dwFlag = 0;
    if(1 == dwFlag)
    {
    RaiseException:
        printf("The divisor cannot be 0!\n");
        exit(1);
    }
    dwFlag = 1;

    double fDividend = 0.0, fDivisor = 0.0;
    printf("Enter the dividend: ");
    scanf("%lf", &amp;fDividend);
    printf("Enter the divisor : ");
    scanf("%lf", &amp;fDivisor);
    if(0 == fDivisor) //Not very rigorous floating-point comparison for zero
        goto RaiseException;
    printf("The quotient is %.2lf\n", Division(fDividend, fDivisor));

    return 0;
}

The execution results are as follows:

[wangxiaoyuan_@localhost test1]$ ./test
Enter the dividend: 10
Enter the divisor : 0
The divisor cannot be 0!
[wangxiaoyuan_@localhost test1]$ ./test
Enter the dividend: 10
Enter the divisor : 2
The quotient is 5.00

Although the goto statement can disrupt code structure, it is very suitable for centralized error handling. A pseudocode example is as follows:

CallerFunc()
{
    if((ret = CalleeFunc1()) < 0);
        goto ErrHandle;
    if((ret = CalleeFunc2()) < 0);
        goto ErrHandle;
    if((ret = CalleeFunc3()) < 0);
        goto ErrHandle;
    //...

    return;

ErrHandle:
    //Handle Error (e.g., printf)
    return;
}

2.4 Non-local Jumps (setjmp/longjmp)

Local goto statements can only jump to labels within the same function. To jump across functions, the standard C library provides non-local jump functions setjmp() and longjmp(). They serve as non-local labels and goto, and are very suitable for handling errors that occur in deeply nested function calls. “Non-local jumps” skip several call frames on the stack, returning to a certain function in the current function call path.

#include <setjmp.h>
int setjmp(jmp_buf env);
void longjmp(jmp_buf env,int val);

The setjmp() function saves the current system stack environment at runtime in the buffer env structure. The first time this function is called, the return value is 0. The longjmp() function restores the previously saved stack environment based on the env structure saved by setjmp(), effectively “jumping back” to the program execution point where setjmp was called.

At this point, the setjmp() function returns the parameter val set by longjmp(), and the program continues executing the next statement after the setjmp call (as if it had never left setjmp). If the parameter val is a non-zero value, it returns 1 if set to 0.

It can be seen that setjmp() has two types of return values, used to distinguish between the first direct call (returning 0) and the return from another place (returning a non-zero value). A single setjmp can have multiple longjmp calls, so these longjmp calls can be distinguished by different non-zero return values.

Here is a simple example illustrating the non-local jump of setjmp/longjmp:

jmp_buf gJmpBuf;
void Func1(){
    printf("Enter Func1\n");
    if(0)longjmp(gJmpBuf, 1);
}
void Func2(){
    printf("Enter Func2\n");
    if(0)longjmp(gJmpBuf, 2);
}
void Func3(){
    printf("Enter Func3\n");
    if(1)longjmp(gJmpBuf, 3);
}

int main(void)
{
    int dwJmpRet = setjmp(gJmpBuf);
    printf("dwJmpRet = %d\n", dwJmpRet);
    if(0 == dwJmpRet)
    {
        Func1();
        Func2();
        Func3();
    }
    else
    {
        switch(dwJmpRet)
        {
            case 1:
                printf("Jump back from Func1\n");
            break;
            case 2:
                printf("Jump back from Func2\n");
            break;
            case 3:
                printf("Jump back from Func3\n");
            break;
            default:
                printf("Unknown Func!\n");
            break;
        }
    }
    return 0;
}

The execution results are:

dwJmpRet = 0
Enter Func1
Enter Func2
Enter Func3
dwJmpRet = 3
Jump back from Func3

When setjmp/longjmp is used within a single function, it can simulate nested function definitions in PASCAL (i.e., defining a local function within a function). When setjmp/longjmp is used across functions, it can simulate exception mechanisms in object-oriented languages.

When simulating exception mechanisms, a jump point is first set using the setjmp() function, and the return site is saved. Then, the try block contains the code that may produce errors. Within the try block code or in the functions it calls, exceptions can be thrown using the longjmp() function.

After throwing an exception, it jumps back to the jump point set by the setjmp() function and executes the exception handling code contained in the catch block.

For example, in the case of a division by zero error:

jmp_buf gJmpBuf;
void RaiseException(void)
{
   printf("Exception is raised: ");
   longjmp(gJmpBuf, 1);  //throw, jump to exception handling code
   printf("This line should never get printed!\n");
}
double Division(double fDividend, double fDivisor)
{
    return fDividend/fDivisor;
}
int main(void)
{
    double fDividend = 0.0, fDivisor = 0.0;
    printf("Enter the dividend: ");
    scanf("%lf", &amp;fDividend);
    printf("Enter the divisor : ");
    if(0 == setjmp(gJmpBuf))  //try block
    {
        scanf("%lf", &amp;fDivisor);
        if(0 == fDivisor) //This check can also be placed in Division
            RaiseException();
        printf("The quotient is %.2lf\n", Division(fDividend, fDivisor));
    }
    else  //catch block (exception handling code)
    {
        printf("The divisor cannot be 0!\n");
    }

    return 0;
}

The execution results are:

Enter the dividend: 10
Enter the divisor : 0
Exception is raised: The divisor cannot be 0!

By combining the use of setjmp/longjmp functions, centralized handling of exceptions that may occur in complex programs can be achieved. Different exceptions can be handled based on the return values passed by the longjmp() function.

When using setjmp/longjmp functions, the following points should be noted:

  1. setjmp() must be called before longjmp() to restore to the previously saved program execution point. If the order is reversed, the program’s execution flow becomes unpredictable, easily leading to program crashes.

  2. longjmp() must be within the scope of the setjmp() function. The program execution environment saved by setjmp() is only valid within (or after) the current calling function’s scope. If the calling function returns or exits to a higher (or even higher) function environment, the environment saved by setjmp() also becomes invalid (the stack memory becomes invalid when the function returns). This requires that setjmp() cannot be encapsulated in a function; if encapsulation is needed, macros must be used (see “Chapter 4: Exceptions and Assertions” in “C Language Interfaces and Implementations”).

  3. Typically, jmp_buf variables are defined as global variables to allow longjmp to be called across functions.

  4. Generally, variables stored in memory will have the value at the time of longjmp, while variables in the CPU and floating-point registers will be restored to the value at the time of calling setjmp. Therefore, if automatic variables or register variables are modified between calling setjmp and longjmp, when setjmp returns from the longjmp call, the variables will retain the modified values. To write portable programs using non-local jumps, the volatile attribute must be used.

  5. Using exception mechanisms does not require checking return values every time, but since exceptions can be thrown from any part of the program, one must always consider whether to catch exceptions. In large programs, determining whether to catch exceptions can be a significant cognitive burden, affecting development efficiency.

In contrast, indicating errors through return values is beneficial for callers to check at the most recent error location. Additionally, the execution order of programs in the return value pattern is clear, making it more readable for maintainers. Therefore, the use of setjmp/longjmp “exception handling” mechanisms is not recommended in application programs (unless in libraries or frameworks).

2.5 Signals (signal/raise)

In some cases, the host environment or operating system may issue signal events indicating specific programming errors or serious events (such as division by zero or interrupts). These signals are not intended for error capture but indicate external events that are not in sync with normal program flow.

To handle signals, the following signal-related functions need to be used:

#include <signal.h>
typedef void (*fpSigFunc)(int);
fpSigFunc signal(int signo, fpSigFunc fpHandler);
int raise(int signo);

Here, the parameter signo is the signal number defined by the Unix system (a positive integer), and user-defined signals are not allowed. The parameter fpHandler is the address of the signal handler to be called when this signal is received, which can be the constant SIG_DFL, the constant SIG_IGN, or a user-defined function. If SIG_DFL is specified, the system’s default handler is called when this signal is received; if SIG_IGN is specified, it indicates to the kernel to ignore this signal (SIGKILL and SIGSTOP cannot be ignored).

Some exceptional signals (such as division by zero) are unlikely to be recoverable; in this case, the signal handler can correctly clean up certain resources before the program terminates. The information received by the signal handler is only an integer (the pending signal event), which is similar to the setjmp() function.

The signal() function returns the address of the previously attached handler on success, and returns SIG_ERR on failure. Signals are generated by calling the raise() function and captured by the handler.

For example, in the case of a division by zero error:

void fphandler(int dwSigNo)
{
    printf("Exception is raised, dwSigNo=%d!\n", dwSigNo);
}
int main(void)
{
    if(SIG_ERR == signal(SIGFPE, fphandler))
    {
        fprintf(stderr, "Fail to set SIGFPE handler!\n");
        exit(EXIT_FAILURE);
    }

    double fDividend = 10.0, fDivisor = 0.0;
    if(0 == fDivisor)
    {
        raise(SIGFPE);
        exit(EXIT_FAILURE);
    }
    printf("The quotient is %.2lf\n", fDividend/fDivisor);

    return 0;
}

The execution result is “Exception is raised, dwSigNo=8!” (0.0 is not equal to 0, so the system does not detect a floating-point exception).

If the dividend (Dividend) and divisor (Divisor) are changed to integer variables:

int main(void)
{
    if(SIG_ERR == signal(SIGFPE, fphandler))
    {
        fprintf(stderr, "Fail to set SIGFPE handler!\n");
        exit(EXIT_FAILURE);
    }

    int dwDividend = 10, dwDivisor = 0;
    double fQuotient = dwDividend/dwDivisor;
    printf("The quotient is %.2lf\n", fQuotient);

    return 0;
}

The execution will repeatedly output “Exception is raised, dwSigNo=8!”. This is because when the process captures the signal and processes it, the sequence of instructions being executed by the process is temporarily interrupted by the signal handler, which first executes the instructions in the signal handler. If returning from the signal handler (without calling exit or longjmp), the normal instruction sequence being executed at the time of capturing the signal will continue to execute.

Thus, every time the system calls the signal handler, the exception control flow will return to the division by zero instruction and continue executing. Since division by zero exceptions are unrecoverable, this leads to repeated output of the exception.

There are two ways to avoid this:

  1. Make the SIGFPE signal use the system’s default handling, i.e., signal(SIGFPE, SIG_DFL).

In this case, the output will be “Floating point exception”.

  1. Use setjmp/longjmp to skip the instruction that causes the exception:
jmp_buf gJmpBuf;
void fphandler(int dwSigNo)
{
    printf("Exception is raised, dwSigNo=%d!\n", dwSigNo);
    longjmp(gJmpBuf, 1);
}
int main(void)
{
    if(SIG_ERR == signal(SIGFPE, SIG_DFL))
    {
        fprintf(stderr, "Fail to set SIGFPE handler!\n");
        exit(EXIT_FAILURE);
    }

    int dwDividend = 10, dwDivisor = 0;
    if(0 == setjmp(gJmpBuf))
    {
        double fQuotient = dwDividend/dwDivisor;
        printf("The quotient is %.2lf\n", fQuotient);
    }
    else
    {
        printf("The divisor cannot be 0!\n");
    }

    return 0;
}

Note that in the signal handler, sigsetjmp/siglongjmp functions can also be used for non-local jumps. Compared to setjmp, sigsetjmp adds a signal mask parameter.

3. Error Handling

3.1 Termination (abort/exit)

Fatal errors that cannot be recovered can only terminate the program. For example, when the free heap manager cannot provide available contiguous space (malloc returns NULL), the robustness of the user program will be severely compromised. If recovery is unlikely, it is best to terminate or restart the program.

The standard C library provides exit() and abort() functions, which are used for normal and abnormal termination of the program, respectively. Both do not return to the caller and force the program to end.

The prototypes for exit() and similar functions are as follows:

#include <stdlib.h>
void exit(int status);
void _Exit(int status);
#include <unistd.h>
void _exit(int status);

Among them, exit and _Exit are specified by ISO C, while _exit is specified by Posix.1, hence the use of different header files.

ISO C defines _Exit to provide a method for terminating a process without running termination handlers or signal handlers, whether to flush standard I/O streams depends on the implementation. In Unix systems, _Exit and _exit are synonymous, both directly enter the kernel without flushing standard I/O streams. The _exit function is called by exit, handling Unix-specific details.

The exit() function first calls and executes all termination handlers, then calls fclose multiple times to close all opened standard I/O streams (flushing all buffered output data to files), and finally calls _exit to enter the kernel.

The standard library has a “buffered I/O” mechanism. This mechanism maintains a buffer in memory for each opened file. Each time a file is read, several records are read continuously, and the next time the file is read, it can be read directly from the memory buffer; each time a file is written, it only writes to the memory buffer, and when certain conditions are met (such as the buffer being full or encountering a newline character), the buffer content is written to the file all at once.

This mechanism can significantly improve file read and write speeds by minimizing the number of read and write calls, but it also brings some troubles to programming. For example, when writing some data to a file, if the specific conditions are not met, the data will temporarily reside in the buffer. Developers may not be aware of this and call the __exit() function directly to close the process, leading to data loss in the buffer.

Therefore, to ensure data integrity, the exit() function must be called, or the buffer content must be written to the specified file using the fflush() function before calling __exit().

For example, after calling the printf function (which automatically reads the content in the buffer when encountering a newline character ‘\n’), then calling exit:

int main(void)
{
    printf("Using exit...\n");
    printf("This is the content in buffer");
    exit(0);
    printf("This line will never be reached\n");
}

The execution output is:

Using exit...
This is the content in buffer(ending without newline)

Calling printf and then calling _exit:

int main(void)
{
    printf("Using _exit...\n");
    printf("This is the content in buffer");
    fprintf(stdout, "Standard output stream");
    fprintf(stderr, "Standard error stream");
    //fflush(stdout);
    _exit(0);
}

The execution output is:

Using _exit...
Standard error stream(ending without newline)

If the fflush statement is uncommented, the execution output will be:

Using _exit...
Standard error streamThis is the content in bufferStandard output stream(ending without newline)

Typically, standard error is unbuffered, streams opened to terminal devices (such as standard input and standard output) are line-buffered (I/O operations are executed when encountering newline characters), while all other streams are fully buffered (I/O operations are executed only after filling the standard I/O buffer).

All three exit functions take an integer parameter status, known as the termination status (or exit status). The value of this parameter is usually two macros, EXIT_SUCCESS (0) and EXIT_FAILURE (1). Most Unix shells can check the termination status of the process.

If (a) these functions are called without a termination status, or (b) the main function executes a return statement without a return value, or (c) the main function does not declare the return type as integer, then the termination status of the process is undefined. However, if the return type of the main function is integer, and it returns at the last statement (implicit return), then the termination status of the process is 0.

The exit series of functions is the simplest and most direct error handling method, but when the program terminates due to an error, exception information cannot be captured. ISO C specifies that a process can register up to 32 termination handler functions. These functions can be written as custom cleanup code, which will be automatically called by the exit() function and can be registered using the atexit() function.

#include <stdlib.h>
int atexit(void (*func)(void));

The parameter of this function is a termination handler function with no parameters and no return value. The exit() function calls these functions in the reverse order of registration. If the same function is registered multiple times, it will be called multiple times. Even if the exit function is not called, the functions registered with atexit will still be executed when the program exits.

By combining the exit() and atexit() functions, exception information can be thrown when the program terminates due to an error. For example, in the case of a division by zero error:

double Division(double fDividend, double fDivisor)
{
    return fDividend/fDivisor;
}
void RaiseException1(void)
{
    printf("Exception is raised: \n");
}
void RaiseException2(void)
{
    printf("The divisor cannot be 0!\n");
}

int main(void)
{
    double fDividend = 0.0, fDivisor = 0.0;
    printf("Enter the dividend: ");
    scanf("%lf", &amp;fDividend);
    printf("Enter the divisor : ");
    scanf("%lf", &amp;fDivisor);
    if(0 == fDivisor)
    {
        atexit(RaiseException2);
        atexit(RaiseException1);
        exit(EXIT_FAILURE);
    }
    printf("The quotient is %.2lf\n", Division(fDividend, fDivisor));

    return 0;
}

The execution result is:

Enter the dividend: 10
Enter the divisor : 0
Exception is raised: 
The divisor cannot be 0!

Note that termination handler functions registered via atexit() must return normally (using a return statement) explicitly or implicitly, and cannot terminate through other means such as calling exit() or longjmp(), otherwise it will lead to undefined behavior.

For example, in the GCC4.1.2 compilation environment, calling exit() will still be equivalent to a normal return; while in the VC6.0 compilation environment, calling exit() will prevent other registered handler functions from being called and may lead to abnormal termination or even crash of the program.

Nesting calls to the exit() function will lead to undefined behavior, so it is best not to call exit() in termination handler functions or signal handler functions.

The prototype for the abort() function is as follows:

#include <stdlib.h>
void abort(void);

This function sends the SIGABRT signal to the calling process (the process should not ignore this signal).

ISO C specifies that calling abort will deliver a notification of unsuccessful termination to the host environment, which is done by calling the raise(SIGABRT) function. Therefore, the theoretical implementation of the abort() function is:

void abort(void)
{
    raise(SIGABRT);
    exit(EXIT_FAILURE);
}

It can be seen that even if the SIGABRT signal is caught and the corresponding signal handler returns, the abort() function still terminates the program. Posix.1 also states that the abort() function does not consider the process’s blocking and ignoring of this signal.

When the process captures the SIGABRT signal, it can perform the necessary cleanup operations (such as calling exit) before terminating itself. If the process does not terminate itself in the signal handler, Posix.1 states that when the signal handler returns, the abort() function terminates the process.

ISO C specifies that whether the abort() function flushes output streams, closes opened files, and deletes temporary files is implementation-defined. Posix.1 requires that if the abort() function terminates the process, its effect on all opened standard I/O streams should be the same as calling fclose on each stream before the process terminates. To improve portability, if flushing standard I/O streams is desired, this operation should be performed before calling abort().

3.2 Assertions (assert)

Both abort() and exit() functions unconditionally terminate the program. Assertions (assert) can also be used to conditionally terminate the program.

assert is a macro frequently used for debugging programs, defined in <assert.h>. The typical implementation of this macro is as follows:

#ifdef    NDEBUG
    #define assert(expr)        ((void) 0)
#else
    extern void __assert((const char *, const char *, int, const char *));
    #define assert(expr) \
        ((void) ((expr) || \
         (__assert(#expr, __FILE__, __LINE__, __FUNCTION__), 0)))
#endif

It can be seen that the assert macro is only effective in debug versions (when NDEBUG is not defined) and calls the __assert() function. This function outputs the filename, line number, function name, and condition expression where the error occurred:

void __assert(const char *assertion, const char * filename,
              int linenumber, register const char * function)
{
    fprintf(stderr, " [%s(%d)%s] Assertion '%s' failed.\n",
            filename, linenumber,
            ((function == NULL) ? "UnknownFunc" : function),
            assertion);
    abort();
}

Thus, the assert macro is essentially an abort() with error description information and performs precondition checks. If the check fails (the assertion expression is logically false), it reports the error and terminates the program; otherwise, it continues executing the subsequent statements.

Users can also customize the assert macro as needed. For example, another implementation version is:

#undef assert
#ifdef NDEBUG
    #define assert(expr)        ((void) 0)
#else
    #define assert(expr)        ((void) ((expr) || \
         (fprintf(stderr, "[%s(%d)] Assertion '%s' failed.\n", \
         __FILE__, __LINE__, #expr), abort(), 0)))
#endif

Note that the expr1||expr2 expression appearing as a standalone statement is equivalent to the conditional statement if(!(expr1))expr2. Thus, the assert macro can be extended to an expression rather than a statement. The comma expression expr2 returns the value of the last expression (i.e., 0) to meet the requirements of the || operator.

When using assertions, the following points should be noted:

  1. Assertions are used to detect situations that theoretically should never occur, such as null input pointers, division by zero, etc.

Compare the following two situations:

char *Strcpy(char *pszDst, const char *pszSrc)
{
    char *pszDstOrig = pszDst;
    assert((pszDst != NULL) &amp;&amp; (pszSrc != NULL));
    while((*pszDst++ = *pszSrc++) != '\0');
        return pszDstOrig;
}
FILE *OpenFile(const char *pszName, const char *pszMode)
{
    FILE *pFile = fopen(pszName, pszMode);
    assert(pFile != NULL);
    if(NULL == pFile)
        return NULL;

    //...
    return pFile;
}

The assertion in the Strcpy() function is used correctly because the input string pointer should not be null. However, in the OpenFile() function, assertions should not be used because users may need to check if a file exists, which is not an error or exception.

2) assert is a macro, not a function, and behaves differently in debug and non-debug versions. Therefore, it must be ensured that the evaluation of the assertion expression does not produce side effects, such as modifying variables and changing method return values. However, one can test whether assertions are enabled based on this side effect:

int main(void)
{
    int dwChg = 0;
    assert(dwChg = 1);
    if(0 == dwChg)
        printf("Assertion should be enabled!\n");
    return 0;
}
  1. Assertions should not be used to check parameters of public methods (parameter validation code should be used), but can be used to check parameters passed to private methods.

  2. Assertions can be used to test preconditions and postconditions of method execution, as well as invariants before and after execution.

  3. When the assertion condition is not satisfied, the abort() function is called to terminate the program, and the application does not have a chance to perform cleanup work (such as closing files and databases).

3.3 Encapsulation

To reduce the redundancy of error checking and handling code, function calls or error outputs can be encapsulated.

  1. Encapsulating functions with error return values

This is usually done for frequently called basic system functions, such as memory and kernel object operations. An example is as follows:

pid_t Fork(void) //Capitalized to distinguish from the system function fork()
{
    pid_t pid;
    if((pid = fork())<0)
    {
        fprintf(stderr, "Fork error: %s\n", strerror(errno));
        exit(0);
    }
    return pid;
}

The Fork() function relies on the system to clean up resources when an error occurs. If other resources (such as created temporary files) also need to be cleaned up, a callback function responsible for cleanup can be added.

Note that not all system functions can be encapsulated; this should be determined based on specific business logic.

  1. Encapsulating error output

This usually requires using the ISO C variable-length parameter feature. For example, the code outputting to the standard error file in “Unix Network Programming” is encapsulated as follows:

#include <stdarg.h>
#include <syslog.h>
#define HAVE_VSNPRINTF  1
#define MAXLINE         4096  /* max text line length */
int daemon_proc;  /* set nonzero by daemon_init() */
static void err_doit(int errnoflag, int level, const char * fmt, va_list ap)
{
    int errno_save, n;
    char buf[MAXLINE + 1];

    errno_save = errno;    /* Value caller might want printed. */
#ifdef HAVE_VSNPRINTF
    vsnprintf(buf, MAXLINE, fmt, ap);
#else
    vsprintf(buf, fmt, ap);    /* This is not safe */
#endif
    n = strlen(buf);
    if (errnoflag) {
        snprintf(buf + n, MAXLINE - n, ": %s", strerror(errno_save));
    }
    strcat(buf, "\n");

    if (daemon_proc) {
        syslog(level, buf);
    } else {
        fflush(stdout);    /* In case stdout and stderr are the same */
        fputs(buf, stderr);
        fflush(stderr);
    }

    return;
}

void err_ret(const char * fmt, ...)
{
    va_list ap;

    va_start(ap, fmt);
    err_doit(1, LOG_INFO, fmt, ap);
    va_end(ap);

    return;
}

This article is sourced from the internet, freely conveying knowledge, and the copyright belongs to the original author. If there are any copyright issues, please contact me for deletion.

Error Handling in Embedded C ProgrammingC Language Programming: LCD Driver Writing IdeasEncapsulation of Alternative 32.768kHz Crystal OscillatorKnowledge Points of PCB Layout, Explained in Detail!In Technology, One Must Have a Sense of Crisis, Never Be a Boiled Frog

Leave a Comment