Chapter 91: Error Handling in C Applications

Chapter Objectives

Upon completing this chapter, you will be ableto:

  • Understand the fundamental principles of error handling in C-based Linux applications and their critical importance in embedded systems.
  • Implement robust error-checking mechanisms by correctly interpreting function return codes to detect failures.
  • Utilize the errno variable in conjunction with perror() and strerror() to diagnose the specific cause of system call and library function errors.
  • Develop C applications that gracefully handle runtime errors, preventing crashes and ensuring system stability on an embedded platform like the Raspberry Pi 5.
  • Debug common error-handling pitfalls, such as incorrect errno usage and ignoring function return values.
  • Apply best practices for writing reliable and maintainable code that anticipates and manages potential failures in a resource-constrained environment.

Introduction

In the world of embedded systems, failure is not merely an inconvenience; it can be catastrophic. An unhandled error in a medical device, an automotive control unit, or an industrial automation system can lead to equipment damage, financial loss, or even endanger human life. Unlike desktop applications, where a crash might simply require a restart, embedded systems often operate autonomously in mission-critical roles where reliability is paramount. This unforgiving operational context elevates error handling from a mere “best practice” to a foundational pillar of system design. For developers working with Embedded Linux on platforms like the Raspberry Pi 5, mastering the art of anticipating, detecting, and responding to errors is a non-negotiable skill.

This chapter delves into the primary mechanisms for error handling in C programs running on a POSIX-compliant operating system like Linux. We will move beyond the simplistic “happy path” of programming, where every function call is assumed to succeed, and confront the reality of runtime complications. You will learn that the C standard library and the underlying Linux system calls have a well-defined, albeit sometimes subtle, contract for communicating failure. The two core components of this contract are function return codes and the global integer variable, errno. By meticulously checking the values returned by functions, we get the first signal that something has gone wrong. Subsequently, by inspecting errno, we can uncover the specific reason for the failure, much like a detective using an initial clue to uncover the detailed story of a crime. Throughout this chapter, we will use the Raspberry Pi 5 as our practical development platform to write, compile, and test code that is resilient, robust, and ready for the rigors of the real world.

Technical Background

At the heart of system programming in a C and Linux environment lies a simple yet powerful convention for communicating success or failure. Most system calls and a vast number of C library functions are designed to report their status through their return value. This mechanism serves as the primary and most immediate signal to the calling program about the outcome of its request. Ignoring this signal is one of the most common and dangerous mistakes a programmer can make.

The Contract of Return Codes

The convention, while not universally rigid, is remarkably consistent across the POSIX API. For a great many functions, a return value of 0 indicates success, while a return value of -1 signals an error. This is particularly true for functions that perform I/O operations or interact with the kernel to manage resources. Consider the close() system call, used to terminate a connection to a file descriptor. If it successfully closes the file, it returns 0. If it fails for some reason—perhaps the file descriptor was invalid to begin with—it returns -1.

However, this is not the only pattern. Another common paradigm is used by functions that are expected to return a non-negative value on success. For instance, the open() system call, which establishes a connection to a file, returns a file descriptor—a small, non-negative integer—on success. Here, the error condition is still signaled by a return value of -1. Similarly, the read() and write() functions return the number of bytes successfully read or written. Since it’s impossible to read or write a negative number of bytes, -1 is again reserved as the universal signal for an error.

Pointers also play a crucial role in this error-reporting scheme. Functions that allocate memory or create complex data structures, such as malloc() from the standard library or mmap() for memory-mapping files, return a pointer to the newly allocated resource on success. If the allocation fails, they return a NULL pointer. In the context of pointers, NULL is the equivalent of -1 for integers; it is a reserved value that unambiguously indicates failure.

The critical takeaway is that the return value is the gatekeeper. Before your program proceeds, it must inspect this value. If you call open() and immediately attempt to use the returned file descriptor without checking if it’s -1, you are making a dangerous assumption. If open() failed, you will be passing an invalid file descriptor (-1) to subsequent functions like read() or write(), which will in turn fail, cascading the error through your application and leading to undefined behavior.

errno: The Reason for the Failure

Detecting that an error has occurred is only half the battle. A return code of -1 tells you that something went wrong, but it doesn’t tell you what went wrong. Did open() fail because the file does not exist? Or did it fail because your program lacks the necessary permissions to read it? Or perhaps the path pointed to a directory, not a regular file? To answer these “why” questions, we turn to the second piece of the error-handling puzzle: the errno variable.

errno is a global integer variable defined in the <errno.h> header. When a system call or a C library function fails, it not only returns -1 (or NULL) but also sets errno to a positive integer value that corresponds to the specific error. The Linux kernel maintains a standard set of these error codes, each with a symbolic name defined in <errno.h>. For example, if open() fails because the file doesn’t exist, errno will be set to the value represented by the macro ENOENT (“Error, No Entry”). If it fails due to a permissions issue, errno will be set to EACCES (“Error, Access Denied”).

graph TD
    subgraph "User Space (C Application)"
        A[<b>Start:</b> C Program] --> B{"Call <b>open(file.txt, O_RDONLY)</b>"};
        B --> C[Execution Pauses, <br>Waits for Kernel];
        G --> H{"Check Return Value<br><b>if (fd == -1)</b>"};
        H -- Yes (Error) --> I[Read <b>errno</b> value <br>to determine cause];
        I --> J["<b>Handle Error:</b><br>Call perror(open failed)<br>or use strerror(errno)"];
        J --> K[End: Exit or Recover];
        H -- No (Success) --> L[Use valid file descriptor 'fd'];
        L --> M[<b>End:</b> Continue Program];
    end

    subgraph "Kernel Space (Linux OS)"
        C --> D{Kernel attempts to open file};
        D -- Failure <br>(e.g., file not found) --> E[1. Set Process's <b>errno</b><br>e.g., errno = ENOENT];
        E --> F[2. Prepare return value<br><b>-1</b>];
        F --> G[Return to User Space];
        D -- Success --> P[1. Allocate File Descriptor<br>e.g., fd = 3];
        P --> Q[2. Prepare return value<br><b>3</b>];
        Q --> G;
    end
    
    linkStyle 0 stroke-width:2px,fill:none,stroke:gray;
    linkStyle 1 stroke-width:2px,fill:none,stroke:gray;
    linkStyle 2 stroke-width:2px,fill:none,stroke:gray;
    linkStyle 3 stroke-width:2px,fill:none,stroke:gray;
    linkStyle 4 stroke-width:2px,fill:none,stroke:red;
    linkStyle 5 stroke-width:2px,fill:none,stroke:red;
    linkStyle 6 stroke-width:2px,fill:none,stroke:red;
    linkStyle 7 stroke-width:2px,fill:none,stroke:green;
    linkStyle 8 stroke-width:2px,fill:none,stroke:green;
    linkStyle 9 stroke-width:2px,fill:none,stroke:gray;
    linkStyle 10 stroke-width:2px,fill:none,stroke:red;
    linkStyle 11 stroke-width:2px,fill:none,stroke:red;
    linkStyle 12 stroke-width:2px,fill:none,stroke:red;
    linkStyle 13 stroke-width:2px,fill:none,stroke:green;
    linkStyle 14 stroke-width:2px,fill:none,stroke:green;


    style A fill:#1e3a8a,stroke:#1e3a8a,stroke-width:2px,color:#ffffff
    style B fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff
    style C fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff
    style D fill:#8b5cf6,stroke:#8b5cf6,stroke-width:1px,color:#ffffff
    style E fill:#8b5cf6,stroke:#8b5cf6,stroke-width:1px,color:#ffffff
    style F fill:#8b5cf6,stroke:#8b5cf6,stroke-width:1px,color:#ffffff
    style P fill:#8b5cf6,stroke:#8b5cf6,stroke-width:1px,color:#ffffff
    style Q fill:#8b5cf6,stroke:#8b5cf6,stroke-width:1px,color:#ffffff
    style G fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff
    style H fill:#f59e0b,stroke:#f59e0b,stroke-width:1px,color:#ffffff
    style I fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff
    style J fill:#ef4444,stroke:#ef4444,stroke-width:1px,color:#ffffff
    style L fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff
    style K fill:#10b981,stroke:#10b981,stroke-width:2px,color:#ffffff
    style M fill:#10b981,stroke:#10b981,stroke-width:2px,color:#ffffff

It is crucial to understand the precise lifecycle of errno. Its value is only meaningful immediately after a function has returned an error indicator. A successful function call is not required to leave errno unchanged. Therefore, the correct programming pattern is always to check the return value first. Only if the return value indicates an error should you then inspect errno.

C
// Correct pattern for checking errno
int fd = open("myfile.txt", O_RDONLY);
if (fd == -1) {
    // An error occurred. NOW it is safe and meaningful to check errno.
    // ... handle the error based on the value of errno ...
}

Attempting to check errno without this gatekeeping check is a recipe for bugs. A previous, unrelated function call might have failed and set errno, and your program would be incorrectly diagnosing an error that never happened in the current operation.

Furthermore, errno is defined by the C standard as a thread-local variable. This is a critical feature for modern multi-threaded applications. If it were a true global variable, one thread could fail a system call and set errno, only to have that value overwritten by another thread’s system call before the first thread has a chance to read it. By making it thread-local, each thread of execution gets its own private copy of errno, preventing these race conditions and ensuring that error diagnosis remains reliable in concurrent programs.

Translating errno into Human-Readable Messages

While macros like ENOENT and EACCES are perfect for programmatic checks (e.g., if (errno == ENOENT)), they are not helpful for logging or displaying messages to a user. A message like “Error code 2” is far less informative than “No such file or directory.” To bridge this gap, the C library provides two essential functions: strerror() and perror().

The strerror() function, defined in <string.h>, takes an error number (like the value of errno) as an argument and returns a pointer to a human-readable string describing that error.

C
// Using strerror()
#include <string.h>
#include <errno.h>
#include <stdio.h>

// ... inside a function after an error ...
if (fd == -1) {
    // Get the descriptive string for the current errno value
    char *error_message = strerror(errno);
    fprintf(stderr, "Failed to open file: %s\n", error_message);
}

This allows you to build custom, detailed error messages that are invaluable for debugging and logging.

The perror() function, defined in <stdio.h>, provides an even more convenient shortcut. It takes a single string argument, which it prints to the standard error stream (stderr), followed by a colon, a space, and then the human-readable error message corresponding to the current value of errno.

C
// Using perror()
#include <stdio.h>
#include <errno.h>

// ... inside a function after an error ...
if (fd == -1) {
    // perror does the work of printing our prefix and the system error message
    perror("Failed to open file");
}

If errno was ENOENT, this call to perror() would print the following to stderr:

C
Failed to open file: No such file or directory

This is often the quickest and most effective way to report a system error, as it combines a programmer-defined context (“Failed to open file”) with the system’s specific diagnosis (“No such file or directory”). Using stderr is also a critical practice. Standard output (stdout) is for the program’s primary output, while stderr is the designated channel for error messages and diagnostics. This allows a user to redirect the normal output to a file while still seeing error messages on the console, a common and powerful technique in the command-line world.

graph TD
    subgraph Application
        A(<b>errno</b> variable<br><i>Value: 2</i>);
        A --> B("<b>perror(Failed to open)</b>");
        A --> C("<b>strerror(errno)</b>");
        B --> D{Prints directly to <b>stderr</b>};
        C --> E{Returns <b>char*</b>};
        E --> F["<b>fprintf(stderr, Error: %s, ptr)</b>"];
        F --> G{Prints formatted string to <b>stderr</b>};
    end
    
    subgraph "C Library (libc)"
        B --> L1["Reads <b>errno</b> value (2)"];
        C --> L2["Reads error num argument (2)"];
        L1 & L2 --> M{Lookup in internal<br>error string table};
        M -- "2" --> N["No such file or directory"];
    end

    N --> E;
    N -- Message --> B;
    
    style A fill:#eab308,stroke:#eab308,stroke-width:1px,color:#1f2937
    style B fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff
    style C fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff
    style D fill:#10b981,stroke:#10b981,stroke-width:2px,color:#ffffff
    style E fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff
    style F fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff
    style G fill:#10b981,stroke:#10b981,stroke-width:2px,color:#ffffff
    style L1 fill:#8b5cf6,stroke:#8b5cf6,stroke-width:1px,color:#ffffff
    style L2 fill:#8b5cf6,stroke:#8b5cf6,stroke-width:1px,color:#ffffff
    style M fill:#8b5cf6,stroke:#8b5cf6,stroke-width:1px,color:#ffffff
    style N fill:#8b5cf6,stroke:#8b5cf6,stroke-width:1px,color:#ffffff

In summary, the combination of return codes and errno forms a robust, two-stage error-handling system. The return code is the “if,” and errno is the “why.” A disciplined programmer on the Linux platform internalizes this pattern: check the return value on every single system call and library function that can fail. If and only if it indicates an error, use errno with perror() or strerror() to report a precise, informative diagnostic message. This discipline is the bedrock of reliable system software.

Macro Message (from strerror) Common Cause
ENOENT No such file or directory Attempting to open() a file or path that does not exist.
EACCES Permission denied Attempting an operation (e.g., read, write) on a file without the necessary permissions.
ENOMEM Cannot allocate memory malloc() failed to allocate the requested amount of memory because the system is out of memory.
EINVAL Invalid argument A function was called with an invalid or unsupported parameter (e.g., an invalid flag to open()).
EISDIR Is a directory Trying to open a directory for writing, or performing a file-only operation on a directory.
EIO Input/output error A low-level hardware error occurred (e.g., physical disk read/write failure). Common in embedded systems with flash storage.

Practical Examples

Theory provides the foundation, but skill is built through practice. In this section, we will apply our understanding of return codes and errno to practical scenarios on the Raspberry Pi 5. We will write, compile, and run C code that interacts with the filesystem, deliberately provoking errors to see how the system reports them.

For these examples, you will need access to your Raspberry Pi 5’s command line, either directly or via an SSH connection. We will use the standard gcc compiler, which is part of the Raspberry Pi OS toolchain.

Example 1: Handling a Non-Existent File

Our first task is to write a program that attempts to open a file for reading. We will first try with a file that doesn’t exist to see the ENOENT error in action.

File Structure and Code

Create a directory for our chapter work and create a C file named open_test.c.

Bash
mkdir ~/ch91_examples
cd ~/ch91_examples
nano open_test.c

Enter the following C code into the open_test.c file. This program takes a single command-line argument—the name of the file to open—and attempts to open it.

C
// open_test.c: Demonstrates basic error handling for the open() system call.

#include <stdio.h>      // For perror(), fprintf()
#include <stdlib.h>     // For exit()
#include <fcntl.h>      // For open() and O_RDONLY
#include <unistd.h>     // For close()
#include <errno.h>      // For errno

int main(int argc, char *argv[]) {
    // A C program's name is always the first argument.
    // We expect one additional argument: the filename.
    if (argc != 2) {
        fprintf(stderr, "Usage: %s <filename>\n", argv[0]);
        exit(EXIT_FAILURE);
    }

    char *filepath = argv[1];
    int fd; // File descriptor

    printf("Attempting to open file: %s\n", filepath);

    // The core operation: attempt to open the file for reading.
    // O_RDONLY is a flag that specifies read-only access.
    fd = open(filepath, O_RDONLY);

    // THE CRITICAL CHECK: Is the return value -1?
    if (fd == -1) {
        // If we are here, open() failed.
        // Now, and only now, we can reliably inspect errno.
        
        // perror() is the easiest way to report the error.
        // It prints our custom message, followed by the system's error description.
        perror("Error opening file");

        // We can also check errno directly for specific error codes.
        if (errno == ENOENT) {
            fprintf(stderr, "Diagnostic: The file does not appear to exist.\n");
        } else if (errno == EACCES) {
            fprintf(stderr, "Diagnostic: Permission denied. Cannot read the file.\n");
        }

        // It's critical to exit with a failure status.
        exit(EXIT_FAILURE);
    }

    // If we reach this point, open() was successful.
    printf("File opened successfully. File descriptor is: %d\n", fd);

    // Good practice: always close the file descriptor when done.
    if (close(fd) == -1) {
        perror("Error closing file");
        exit(EXIT_FAILURE);
    }

    printf("File closed successfully.\n");

    return EXIT_SUCCESS;
}

Build and Execution Steps

1. Compile the Code: Use gcc to compile the program. The -o flag specifies the name of the output executable.

Bash
gcc open_test.c -o open_test

2. Run with a Non-Existent File: Execute the program, passing the name of a file you know does not exist.

Bash
./open_test no_such_file.txt

Expected Output and Analysis

You should see the following output on your console:

Plaintext
Attempting to open file: no_such_file.txt
Error opening file: No such file or directory
Diagnostic: The file does not appear to exist.

Let’s break this down:

  • Attempting to open file...: This is our program’s own status message from printf.
  • Error opening file: No such file or directory: This is the output from perror(). Our custom prefix “Error opening file” is printed, followed by the system’s description for the errno value, which was ENOENT.
  • Diagnostic: The file does not appear to exist.: This is the output from our if (errno == ENOENT) block, confirming our programmatic check worked as expected.
  • The program then exits with a failure status, as it should.
  1. Run with an Existing File: Now, let’s try the “happy path.” First, create an empty file.touch existing_file.txt
    Then, run the program again, this time pointing to the file you just created../open_test existing_file.txt

Expected Output (Success Case)

Plaintext
Attempting to open file: existing_file.txt
File opened successfully. File descriptor is: 3
File closed successfully.

Here, open() returned a valid file descriptor (likely 3, since 0, 1, and 2 are reserved for stdinstdout, and stderr). The if (fd == -1) block was skipped, and the program proceeded to a successful close and exit.

Example 2: Handling a Permissions Error

Next, we will demonstrate the EACCES error by creating a file that our program is not allowed to read.

Configuration and Execution Steps

1. Create a Protected File: We will create a file and then use the chmod command to remove its read permissions for everyone.

Bash
touch protected_file.txt chmod 000 protected_file.txt


The chmod 000 command revokes all read, write, and execute permissions for the owner, group, and others.

Run the Test Program: Use the same compiled open_test executable from Example 1, but point it at our new protected file.

Bash
./open_test protected_file.txt

Expected Output and Analysis

Plaintext
Attempting to open file: protected_file.txt
Error opening file: Permission denied
Diagnostic: Permission denied. Cannot read the file.

The output is similar to our first test, but the crucial difference is the message. This time, perror() reports “Permission denied,” which corresponds to the errno value EACCES. Our specific check for EACCES also triggered, printing the corresponding diagnostic. This demonstrates how checking errno allows your program to distinguish between different failure modes and potentially react to them in different ways.

Tip: After you are finished with this example, you can restore the file’s permissions with chmod 644 protected_file.txt or simply remove it with rm protected_file.txt.

Example 3: A Robust File-Writing Wrapper Function

In professional code, you often encapsulate operations in helper or “wrapper” functions to make the main logic cleaner and ensure error handling is applied consistently. Let’s create a C program with a function that safely writes a string to a file.

File Structure and Code

Create a new file named write_wrapper.c.

Bash
nano write_wrapper.c

Enter the following code. This program defines a function, write_string_to_file, which handles opening, writing, and closing a file, with robust error checks at every step.

C
// write_wrapper.c: Demonstrates a robust wrapper function for writing to a file.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>     // For strlen()
#include <fcntl.h>      // For open() and flags
#include <unistd.h>     // For write(), close()
#include <errno.h>

// A wrapper function to demonstrate robust, multi-step error handling.
// Returns 0 on success, -1 on failure.
int write_string_to_file(const char *filepath, const char *data) {
    int fd = -1;
    ssize_t bytes_to_write;
    ssize_t bytes_written;

    bytes_to_write = strlen(data);
    if (bytes_to_write == 0) {
        printf("Warning: Data to write is empty.\n");
        // Not necessarily an error, but good to note.
    }

    // Open the file for writing. Create it if it doesn't exist. Truncate it if it does.
    // Set permissions to 0644 (owner can read/write, group/others can read).
    fd = open(filepath, O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd == -1) {
        perror("Failed to open file for writing");
        return -1; // Failure
    }

    // Now, attempt to write the data.
    bytes_written = write(fd, data, bytes_to_write);
    if (bytes_written == -1) {
        perror("Failed to write data to file");
        // Cleanup: attempt to close the file descriptor even on write failure.
        close(fd);
        return -1; // Failure
    }

    // A subtle but important check: did write() write fewer bytes than requested?
    // This can happen if the disk fills up, for example.
    if (bytes_written < bytes_to_write) {
        fprintf(stderr, "Error: Incomplete write. Wrote %ld of %ld bytes.\n", bytes_written, bytes_to_write);
        close(fd);
        return -1; // Failure
    }

    // Finally, close the file.
    if (close(fd) == -1) {
        perror("Failed to close file after writing");
        return -1; // Failure
    }

    printf("Successfully wrote %ld bytes to %s\n", bytes_written, filepath);
    return 0; // Success!
}

int main(int argc, char *argv[]) {
    if (argc != 3) {
        fprintf(stderr, "Usage: %s <filename> \"<text to write>\"\n", argv[0]);
        exit(EXIT_FAILURE);
    }
    
    char *filepath = argv[1];
    char *text = argv[2];

    printf("--- Calling write_string_to_file ---\n");
    int result = write_string_to_file(filepath, text);
    printf("--- Returned from write_string_to_file ---\n");

    if (result == 0) {
        printf("Main: Operation completed successfully.\n");
        return EXIT_SUCCESS;
    } else {
        fprintf(stderr, "Main: Operation failed.\n");
        return EXIT_FAILURE;
    }
}

Build, Run, and Verify

1. Compile the Code:

Bash
gcc write_wrapper.c -o write_wrapper

2. Run a Successful Test:

Bash
./write_wrapper output.txt "Hello Embedded Linux!"

Expected Output:

Plaintext
--- Calling write_string_to_file ---
Successfully wrote 21 bytes to output.txt
--- Returned from write_string_to_file ---
Main: Operation completed successfully.

3. Verify the File Contents: Use the cat command to check that the file was written correctly.

Bash
cat output.txt


You should see: Hello Embedded Linux!

4. Run a Failing Test (Permission Denied): Let’s try to write into a directory that we don’t have permissions for, like the root directory (/).

Bash
./write_wrapper /new_file.txt "This will fail"

Expected Output (Failure):

Plaintext
--- Calling write_string_to_file ---
Failed to open file for writing: Permission denied
--- Returned from write_string_to_file ---
Main: Operation failed.

This example showcases a more realistic development pattern. The main function’s logic is clean and high-level, delegating the messy details of I/O and error checking to a dedicated, reusable function. Each step within the wrapper function—openwriteclose—is followed by a mandatory error check, ensuring the operation is aborted safely at the first sign of trouble.

Common Mistakes & Troubleshooting

Even with an understanding of errno and return codes, developers new to system programming often fall into a few common traps. Being aware of these pitfalls can save you hours of frustrating debugging.

Mistake / Issue Symptom(s) Troubleshooting / Solution
Ignoring Return Values Program crashes with a “Segmentation fault” or other memory error, often far from the actual failed call. Unpredictable behavior. Solution: Adopt a strict discipline. For any function that can fail (e.g., open(), malloc(), write()), immediately check its return value with an if statement.
Checking errno Unconditionally The program reports an error that didn’t actually happen. It acts on a stale errno value from a previous, unrelated failure. Solution: Follow the two-step pattern. 1) Check the function’s return value for an error (-1 or NULL). 2) Only inside the error-handling block should you inspect errno.
Mishandling Partial read()/write() Data is silently truncated or corrupted. A file copy results in a smaller file; a network transfer is incomplete. The program doesn’t report an error because -1 was not returned. Solution: Never assume a single read() or write() call will transfer all requested bytes. Place these calls inside a loop that continues until all bytes are processed, while also checking for -1 (error) and 0 (EOF for read).
Using printf for Error Reporting When a user redirects output (e.g., ./my_app > log.txt), error messages disappear from the console and are mixed into the log file, making them easy to miss. Solution: Use the correct output stream for errors. The easiest way is perror("Descriptive prefix"). For custom messages, use fprintf(stderr, "Error: ...").
Race Conditions with errno (Historical/Rare) In very old or non-POSIX systems, one thread’s error value could be overwritten by another thread’s before it could be read. Solution: This is solved on modern Linux systems (like Raspberry Pi OS) where errno is thread-local. The solution is to use a modern toolchain. Awareness of the history is good for context.

Exercises

These exercises are designed to be completed on your Raspberry Pi 5. They will reinforce the concepts of checking return codes and using errno to diagnose specific failures.

  1. Memory Allocation Failure:
    • Objective: Write a program that demonstrates how to handle a memory allocation failure from malloc().
    • Guidance: malloc() returns NULL on failure and sets errno to ENOMEM (“Error, No Memory”). Write a C program that attempts to allocate an impossibly large amount of memory (e.g., 20 gigabytes).
    • Steps:
      1. Inside main, declare a pointer (e.g., char *huge_buffer;).
      2. Call malloc() with a very large size: malloc(20L * 1024 * 1024 * 1024);. The L makes the constant a long to avoid overflow.
      3. Check if the returned pointer is NULL.
      4. If it is NULL, use perror() to print a descriptive message.
      5. The program should exit gracefully with a failure status. If allocation succeeds (highly unlikely), it should free() the memory and report success.
    • Verification: When you run the program, it should print an error message to stderr that includes the text “Cannot allocate memory” and exit.
  2. Filesystem Error Exploration:
    • Objective: Modify the open_test.c program to specifically identify and report on at least three more error types besides ENOENT and EACCES.
    • Guidance: Use the man 2 open command to see a full list of possible errno values for the open() system call. Interesting ones to test include EISDIR (Is a directory), ENOTDIR (Not a directory), and EROFS (Read-only file system).
    • Steps:
      1. Copy open_test.c to a new file, open_explorer.c.
      2. Add else if clauses to the error handling block to check for EISDIRENOTDIR, and EROFS.
      3. For each case, print a custom diagnostic message explaining what that error means.
      4. To test EISDIR, run ./open_explorer /home/pi.
      5. To test ENOTDIR, run ./open_explorer /home/pi/not_a_dir/some_file.
      6. Testing EROFS is more advanced, but you could try to open a file for writing in a system directory like /proc/.
    • Verification: Your program should correctly identify and report the specific reason for failure in each test case.
  3. A Safe strdup Implementation:
    • Objective: The standard C library has a strdup() function that duplicates a string, but it’s a POSIX extension, not standard C. It works by calling malloc() and strcpy(). Write your own version, safe_strdup(), that includes proper error handling.
    • Guidance: Your function should take one argument (const char *s) and return a char *. It needs to allocate memory, and if the allocation succeeds, copy the string.
    • Steps:
      1. Calculate the required memory: strlen(s) + 1 (for the null terminator).
      2. Call malloc() to allocate that much memory.
      3. Crucially, check if malloc() returned NULL. If so, your safe_strdup function should immediately return NULL to signal the failure to its caller. errno will have already been set by malloc.
      4. If allocation succeeds, use strcpy() to copy the source string into your new buffer.
      5. Return the pointer to the new buffer.
      6. Write a main function to test your safe_strdup, including a call that the main function can check for NULL.
    • Verification: Your test program should be able to duplicate a string successfully. You can’t easily force malloc to fail here, but the important part is having the if (ptr == NULL) check in your implementation.
  4. Error-Checking cat Utility:
    • Objective: Write a simple version of the cat command-line utility that reads a file specified on the command line and prints its contents to standard output. It must include robust error handling for every step.
    • Guidance: The program flow is: open file, loop with read() until end-of-file, then close(). Every function call (openreadwriteclose) must be checked.
    • Steps:
      1. Check argc to ensure a filename was provided.
      2. Call open() and check for -1. Use perror() on failure.
      3. Create a loop. Inside the loop, call read() into a buffer (e.g., char buffer[4096];).
      4. After read(), check the return value (ssize_t bytes_read).
      5. If bytes_read is -1, an error occurred. Call perror(), close the file, and exit.
      6. If bytes_read is 0, you’ve reached the end of the file. Break the loop.
      7. If bytes_read > 0, call write() to print the buffer’s contents to standard output (STDOUT_FILENO, which is file descriptor 1). Check the return of write() for -1.
      8. After the loop, call close() and check its return value.
    • Verification: Your program should successfully print the contents of a text file. It should also report meaningful errors if you give it a non-existent file or a file you don’t have permission to read.

Summary

This chapter established the fundamental patterns for robust error handling in C programs on Embedded Linux. By adhering to these practices, you can build applications that are significantly more reliable, stable, and easier to debug.

  • Error Indication is a Contract: System and library functions use their return value (-1 for integers, NULL for pointers) as the primary signal that an operation has failed.
  • Always Check Return Values: Ignoring the return value of a function that can fail is a critical programming error that leads to unpredictable behavior and difficult bugs.
  • errno Provides the Details: When a function signals an error, it also sets the global, thread-local integer errno to a code that specifies the reason for the failure.
  • Check errno Conditionally: The value of errno is only meaningful immediately after a function has indicated failure. The correct pattern is to check the return value first, then check errno.
  • Use perror and strerror for Diagnostics: These functions translate the integer errno codes into human-readable strings, which are essential for creating useful log and console messages.
  • Report Errors to stderr: Diagnostic messages should always be sent to the standard error stream (stderr), not standard output (stdout), to separate normal program output from error reporting.
  • Discipline is Key: Building robust systems requires the discipline to apply these error-checking patterns consistently to every relevant function call.

Further Reading

For a deeper and more authoritative understanding of the concepts discussed in this chapter, consult the following resources.

  1. The GNU C Library (glibc) Manual: The official documentation for the C library used on most Linux systems. The section on “Error Reporting” is particularly relevant.
  2. Linux man-pages: The official manual pages are an indispensable resource. Use the man command on your Raspberry Pi (e.g., man 2 openman 3 perrorman 3 strerrorman errno) for the most precise information.
  3. The Single UNIX Specification (POSIX.1-2017): This is the official standard that defines the behavior of errnoperror, and the system calls we have discussed. It is the definitive source for portable programming.
  4. “The Linux Programming Interface” by Michael Kerrisk: This book is widely regarded as the definitive guide to Linux system programming. Its chapters on file I/O and error handling are exceptionally detailed.
  5. “Advanced Programming in the UNIX Environment” by W. Richard Stevens and Stephen A. Rago: A classic and foundational text on UNIX/Linux system programming that covers error handling with great clarity.
  6. Robert C. Seacord, “The CERT C Coding Standard, Second Edition”: This book provides prescriptive rules for secure and reliable C programming, with extensive coverage of error handling rules (see the “ERR” chapter).
  7. Beej’s Guide to Network Programming: While focused on networking, this guide has one of the clearest and most practical introductions to checking for errors in a C/Linux environment.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top