Applying the principles, practices and patterns from the book
Intro : Purpose of this writeup
I’ve been reading the excellent book, Fluent C(Todo:add amazon link) from Christopher Preschern(Todo:add twitter). I recommend this book to embedded/firmware devs looking to level up their C programming skills to a professional grade. In this post I’ll start with C code, that looks like something that I would have written when I graduated from college(many years ago 😉 ), and will systematically apply the principles suggested in the book one by one, making it more robust and maintainable. We will do it in a fashion such that it builds upon the previous knowledge.
This post serves the purpose of being summary of the book-bookmark now, return when your next embedded project demands production grade C rigor.
All credits go to Christopher Preschern for writing the book.
Chapter 1 : Error Handling
Let’s say we have the task of processing bytes stored in a file that is stored in SD card. After thinking for a while, we can come up with a basic version like the following
void init_sd_and_process_file(const char *filename) {
SDHandle *sd = sd_init();
if (sd) {
FILE* fp = sd_open(filename);
if (fp) {
uint8_t buffer[256];
size_t bytes_read;
while ((bytes_read = fread(buffer, 1, sizeof(buffer), fp)) > 0) {
for (int i = 0; i < bytes_read; i++) {
if (buffer[i] == 0xFF && SD_CARD_TYPE == CARD_SDHC) {
processSpecialByte(buffer[i]);
} else {
processNormalByte(buffer[i]);
}
}
}
fclose(fp);
} else {
printf("File open failed");
}
sd_free(sd);
} else {
printf("SD init failed");
}
}
CNow, let’s identify the issues at hand and try to resolve them. On close observation you can probably tell that this function would not scale well, if, let’s say, in future the file is present in remote FTP server or if the file is changed to JSON. The key reason is that this function has several responsibilities and hence will be tied down to only these responsibilities and hence not flexible. Let’s look into the first principle and apply to the above code.
1.a. Function Split
Split the functions into many parts, each part being useful on its own.
static void process_file(FILE *fp) {
uint8_t buffer[256];
size_t bytes_read;
while ((bytes_read = fread(buffer, 1, sizeof(buffer), fp)) > 0) {
for (int i = 0; i < bytes_read; i++) {
if (buffer[i] == 0xFF && SD_CARD_TYPE == CARD_SDHC) {
processSpecialByte(buffer[i]);
} else {
processNormalByte(buffer[i]);
}
}
}
}
void init_sd_and_process_file(const char *filename) {
SDHandle *sd = sd_init();
if (sd) {
FILE* fp = sd_open(filename);
if (fp) {
process_file(fp);
fclose(fp);
} else {
printf("File open failed");
}
sd_free(sd);
} else {
printf("SD init failed");
}
}
CNow the depth of if-else has reduced and the file specific processing is separated into its own function, making it independent and modular. Our main code is much smaller than before.
Still, the nested if-else structure makes the code harder to maintain, and the error handling doesn’t ensure proper resource cleanup. This could lead to resource leaks if initialization fails partway. Let’s see how to tackle this
1.b. Guard Clause
Check for all the mandatory pre-conditions and return immediately if it is not met
static void process_file(FILE *fp) {
uint8_t buffer[256];
size_t bytes_read;
while ((bytes_read = fread(buffer, 1, sizeof(buffer), fp)) > 0) {
for (int i = 0; i < bytes_read; i++) {
if (buffer[i] == 0xFF && SD_CARD_TYPE == CARD_SDHC) {
processSpecialByte(buffer[i]);
} else {
processNormalByte(buffer[i]);
}
}
}
}
void init_sd_and_process_file(const char *filename) {
SDHandle *sd = sd_init();
if (!sd) {
return;
}
FILE* fp = sd_open(filename);
if (!fp) {
sd_free(sd);
return;
}
process_file(fp);
fclose(fp);
sd_free(sd);
}
CNow our main code is readable. We could return more descriptive error information instead of just returning(We dive deep into this topic in Chapter 2(Todo:add link)).
In case of complex code logic, we may still end up with nested if-else and/or duplicated error handling and cleanup code, making code unmaintainable again. Then in this case, use the next principle.
1.c Samurai Principle [skipped]
Return from a function victorious or do not return at all. Abort the function when error is encountered.
I personally found this principle not really useful for embedded systems where high availability, safety is required.
1.d. Goto Error Handling
Put all the resource cleanup and error handling at the end of the function. If some functions errors out, use goto to jump to the error handling/cleanup code
static void process_file(FILE *fp) {
uint8_t buffer[256];
size_t bytes_read;
while ((bytes_read = fread(buffer, 1, sizeof(buffer), fp)) > 0) {
for (int i = 0; i < bytes_read; i++) {
if (buffer[i] == 0xFF && SD_CARD_TYPE == CARD_SDHC) {
processSpecialByte(buffer[i]);
} else {
processNormalByte(buffer[i]);
}
}
}
}
void init_sd_and_process_file(const char *filename) {
SDHandle *sd = NULL;
FILE *fp = NULL;
sd = sd_init();
if (!sd) {
return;
}
fp = sd_open(filename);
if (!fp) {
goto cleanup_sd;
}
process_file(fp);
cleanup:
if (fp) {
fclose(fp);
}
cleanup_sd:
if (sd) {
sd_free(sd);
}
}
CThe function may look longer but ensures all allocated resources are freed before exiting. It makes it more maintainable by avoiding duplicated cleanup logic.
The if-else cascade still feels like it’s begging for further refinement—it’s not quite as clean or elegant as it could be. There’s room to simplify and make it even more streamlined. Let’s see how we can achieve that.
1.d. Object Based Error Handling
Instead of putting multiple responsibilities in one function, put initialization and cleanup in separate functions, similar to constructors/destructors in OOP.
/* Initialization with Built-in Validation */
static SDHandle* init_sd_card(void) {
// Guard against initialization failure
SDHandle* sd = sd_init();
if (!sd) {
log_error("SD card initialization failed");
return NULL;
}
return sd;
}
static FILE* open_sd_file(SDHandle* sd, const char* filename) {
// Guard against invalid parameters
if (!sd || !filename) {
log_error("Invalid parameters for file open");
return NULL;
}
// Guard against open failure
FILE* fp = sd_open(filename);
if (!fp) {
log_error("Failed to open file: %s", filename);
return NULL;
}
return fp;
}
/* Processing with Built-in Safeguards */
static void process_file(FILE* fp) {
// Guard against invalid file pointer
if (!fp) {
log_error("Invalid file handle for processing");
return;
}
uint8_t buffer[256];
size_t bytes_read;
while ((bytes_read = fread(buffer, 1, sizeof(buffer), fp)) > 0) {
for (int i = 0; i < bytes_read; i++) {
if (buffer[i] == 0xFF && SD_CARD_TYPE == CARD_SDHC) {
processSpecialByte(buffer[i]);
} else {
processNormalByte(buffer[i]);
}
}
}
}
/* Cleanup Functions (NULL-safe) */
static void cleanup_file(FILE* fp) {
if (fp) fclose(fp);
}
static void cleanup_sd_card(SDHandle* sd) {
if (sd) sd_free(sd);
}
/* Simplified Main Flow */
void init_sd_and_process_file(const char* filename) {
SDHandle* sd = init_sd_card();
FILE* fp = open_sd_file(sd, filename);
process_file(fp);
cleanup_file(fp);
cleanup_sd_card(sd);
}
CJust look at the final function, the intent is clear and will be clear even after you see it after many months. The code is modular, split into clean and working individual units, and is maintainable.
Looking at the first version of the code and the final one, it’s amazing how much better it got just by taking things step by step and applying good practices. The difference is huge—it’s like going from a messy draft to a clean, polished final version. Night and day, really!
Chapter 2 : Returning Error Information
2.a. Return Status Codes
Return all the possible errors to the caller.
Let’s say the task at hand is to parse a file. The most basic way of returning error information is to return all the status codes.
// Enumerate every possible error (clutters code)
typedef enum {
PARSER_OK,
FILE_NOT_FOUND,
INVALID_HEADER,
DATA_CORRUPTED,
MEMORY_ERROR
} ParserStatus;
ParserStatus parse_file(const char* filename, int** data_out) {
FILE* file = fopen(filename, "r");
if (!file) return FILE_NOT_FOUND;
char header[4];
if (fread(header, 1, 4, file) != 4 || header[0] != 'M') {
fclose(file);
return INVALID_HEADER;
}
*data_out = malloc(sizeof(int) * 100);
if (!*data_out) {
fclose(file);
return MEMORY_ERROR;
}
// ... (parse data)
fclose(file);
return PARSER_OK;
}
CThe caller code will look something like this
int* data;
ParserStatus status = parse_file("data.bin", &data);
switch (status) { // Long, repetitive handling
case FILE_NOT_FOUND: printf("File missing!\n"); break;
case INVALID_HEADER: printf("Invalid format!\n"); break;
case MEMORY_ERROR: printf("Out of memory!\n"); break;
case DATA_CORRUPTED: printf("Data broken!\n"); break;
case PARSER_OK: printf("Success!\n"); break;
}
CThe issue with this approach is that it bloats the callee code, since it returns all the possible status codes, as well as the caller code, as it has to check for each kind of possible statuses. A better way would be to return only those error that the caller can do something about.
2.b. Return Relevant Errors
Return only those error codes that the caller can take actionable action, else return a common error.
// Only return errors the caller can fix (e.g., file issues)
typedef enum { PARSER_OK, FILE_ERROR, MEMORY_ERROR } ParserStatus;
ParserStatus parse_file(const char* filename, int** data_out) {
FILE* file = fopen(filename, "r");
if (!file) return FILE_ERROR;
// Assume header is valid for simplicity
*data_out = malloc(sizeof(int) * 100);
if (!*data_out) {
fclose(file);
return MEMORY_ERROR;
}
fclose(file);
return PARSER_OK;
}
CThe caller code in this case will look something like this
int* data;
ParserStatus status = parse_file("data.bin", &data);
if (status == FILE_ERROR) printf("Check file!\n");
if (status == MEMORY_ERROR) printf("Free memory!\n");
if (status == PARSER_OK) printf("Success!\n");
CThe obvious benefits are smaller caller code. Any other error that happens should be considered implementation specific, and a common error code can be used to indicate that. This kind of grouping works fine during the development phase of the code.
Using standard C return values for errors complicates returning other data, often requiring passing variables by reference. A better approach is to return a special value (e.g., NULL
or -1
) for errors and the actual data on success. This simplifies the design and avoids unnecessary complexity.
2.c. Return Special Values
Use return value to return the data computed by function and reserve special values for errors
// Return data directly; use NULL/-1 for errors
int* parse_file(const char* filename) {
FILE* file = fopen(filename, "r");
if (!file) return NULL;
int* data = malloc(sizeof(int) * 100);
if (!data) {
fclose(file);
return NULL;
}
// ... (parse data)
fclose(file);
return data;
}
Cint* data = parse_file("data.bin");
if (!data) {
printf("Error: File or memory issue!\n"); // Ambiguous!
return 1;
}
printf("Success!\n");
free(data);
CThis simplifies the caller’s code but sacrifices error details. A balanced approach is to log errors and use assert
for irrecoverable issues. This is especially useful during debugging and development, providing clarity without overcomplicating the design.
2.d. Log Errors
Log the errors using relevant channels(
printf
over UART, saving in SD card, displaying QR code etc. )
// Use asserts for unrecoverable errors during development
int* parse_file(const char* filename) {
FILE* file = fopen(filename, "r");
if (file == NULL) {
fprintf(stderr, "Error (%s:%d): File not found\n", __FILE__, __LINE__);
assert(file != NULL && "File must exist");
}
int* data = malloc(sizeof(int) * 100);
if (data == NULL) {
fprintf(stderr, "Error (%s:%d): Out of memory\n", __FILE__, __LINE__);
assert(data != NULL && "Out of memory");
}
// ... (parse data)
fclose(file);
return data;
}
Cint* data = parse_file("data.bin");
if (data == NULL) {
fprintf(stderr, "Error (%s:%d): Parse failed\n", __FILE__,__LINE__);
return 1;
}
printf("Success!\n");
free(data);
CNow asserts tell you exactly what happened and you can always choose to disable them in release binary, and replace them with a reboot in case of embedded target, after making sure that such reboot does not cause the target to be stuck in boot loop.
This summarizes few of the ways one can return useful error information to the user.
Chapter 3 : Memory Management [skipped]
Chapter 4 : Returning Data from C Functions
4.a. Return Value
When writing functions in C, returning data efficiently and safely is a common challenge. While the return
statement works for simple cases, real-world scenarios often demand more flexibility. In this post, we’ll explore six techniques to return data from C functions, their use cases, and trade-offs.
Return single value from function, ideal for atomic operations.
int add(int a, int b) {
return a + b;
}
int result = add(3, 5); // result = 8
CThis is the most basic way of returning data from functions. As the callers gets it’s own copy of the data, the function is re-entrant and this suitable for multithreaded environment.
However, C only supports returning only single type of object via this method. Let’s see how we can return many values from function.
4.b. Out-parameters
Use pointers to “return” multiple related values through function parameters.
void calculate_ops(int a, int b, int *sum, int *product) {
*sum = a + b;
*product = a * b;
}
int s, p;
calculate_ops(4, 5, &s, &p); // s=9, p=20
CUsing pointers as function arguments, we can emulate the by-reference arguments. Now all the related values can be copied to the function arguments. In a multi-threaded environment, use synchronization primitives to make sure the data is not changed during the copying.
This issues with this approach is, after a point returning many values like this makes the function signature long, and is not clear in first look that they are out-parameters. A better and clean way to returning related data would be packing them into a structure and returning them.
4.c. Aggregate Instance
Bundle all the related data into a single structure and return it
typedef struct {
int sum;
int product;
} MathResult;
MathResult calculate_result(int a, int b) {
return (MathResult){a + b, a * b};
}
MathResult result = calculate_result(2, 3);
CAs C supports returning object of single type, you can create a custom type using struct and bundle all the related data in to this struct and return it.
The structs live in stack if passed like this, so will consume large amounts of stack or if passed onto nested functions. We still can use pointer to struct and treat it as out-parameter, but then we would have to be clear in the functions API as to who is responsible for allocating and cleaning up the memory pointed by the pointers. Let’s say we have lot of data that needs to returned, but the data does not change in between invocation, then we can use the immutable instance to save the copying overhead.
4.d. Immutable Instance
Keep all the immutable data or the data that you want to share but want to make sure the caller does not modify into static memory and pass
const
pointer to the caller
typedef struct module_info {
const char *module_name; // Immutable string pointer
const char *author; // Immutable string pointer
} module_info;
// Returns read-only module info from static memory
const module_info *get_module_info() {
static const module_info info = { // Static const storage
.module_name = "SecurityModule",
.author = "SecureDevTeam"
};
return &info;
}
void print_module_info() {
const module_info *info = get_module_info();
printf("Module Name: %s\nAuthor: %s\n",
info->module_name,
info->author);
}
CThis approach of using static memory with const
pointers offers robust data protection through compile-time enforcement of immutability, eliminating memory leaks and allocation overhead while ensuring thread-safe initialization. It’s ideal for fixed configurations, constants, or shared metadata (like module names or author info) that must remain unmodified.
However, it sacrifices runtime flexibility—data cannot be updated dynamically, occupies permanent memory, and relies on compile-time initialization. While const
prevents accidental changes, determined misuse via unsafe casts can bypass protections. Choose this for simple, stable data; avoid it for dynamic or large datasets needing frequent updates.
Large and changing datasets are best dealt with caller allocated buffers.
4.e. Caller Owned Buffer
The caller passes the pointer to the buffer and the size, the callee fills in the buffer after checking for overflows.
void fill_buffer(int a, int b, int *buffer) {
buffer[0] = a + b;
buffer[1] = a * b;
}
int buffer[2];
fill_buffer(4, 5, buffer); // buffer = [9, 20]
CThe callee can return large data that is changing at runtime. The caller can access the data in safe and re-entrant manner as it is the sole owner of the data.
The caller has to know the size of the buffer beforehand. In some cases, this may not be possible, so the callee handles the responsibility of allocating the buffer whose size is known at runtime.
4.f. Callee Allocated Buffer
Allocate the buffer in the callee code and copy the data and return pointer to the caller.
int* create_dynamic_result(int a, int b) {
int *result = malloc(2 * sizeof(int));
if (result) {
result[0] = a + b;
result[1] = a * b;
}
return result;
}
int *dynamic = create_dynamic_result(2, 3);
free(dynamic); // Responsibility lies with the caller
CThis approach is suitable for dynamic and variable-sized data. The pointer and the size could be returned as aggregate instance also.
However, the caller is responsible for freeing of resource, forgetting to do so will result in memory leak. One way of dealing with this is to document in function APIs and/or to have a dedicated function for cleanup, making it evident that the pointer needs to be freed.
Chapter 5 : Data Lifetimes and Ownership
This chapter is about structuring the C program around OOP-like objects, which are basically instances of data structures. In C such instances are nothing more than named region of storage. Hence the focus will be on who will be responsible for creating and destroying the instance.
5.a. Stateless Software Module
Keep the functions simple and do not build state info, so that the functions can be called and the result does not depend on the previous function calls
Let’s start with the most basic example of adding two numbers and see how we can build on top of this. A simple implementation will not build up state information. The caller and callee will share info using return values.
// 1. Stateless Module - No retained state
void MathUtil_add(int a, int b) {
printf("Stateless: %d + %d = %d\n", a, b, a + b);
}
CIt is not easy to provide all the required functionality using such simple interface. You would have to branch to other patterns in order to share some sort of state information.
5.b. Software Module with Global State
If there is no caller-dependent state information, then have a file global static instance, which is common for the callee module to operate on.
In such implementation, the global state is hidden from the caller and is managed transparently. File-global instances are protected using synchronization primitives for multithreading in the callee module.
// 2. Global State Module
static int sum= 0;
void MathUtil_addThis(int val) {
printf("Sum: %d -> %d\n", sum, sum+val);
}
CThis is a form of anti-pattern called Singleton and should be generally avoided. But in some cases like global unique resources(e.g.. SysTick timer), this can be used with precaution. There may be some initialization for the global instance that needs to be handled during boot/first call. Also race the static variables are prone to race conditions if used without mutual exclusion.
Again this may not be sufficient for complex situations where you would have caller specific state information that needs to be passed along. In this case we can use the next pattern.
5.c. Caller Owned Instance
Have the caller pass an instance, that will build up the required state information.
By doing so, multiple callers/threads can call the required function as each caller will have it’s own instance to be operated on. The callee will not have any information about the lifetime of the instance, so the allocation/cleanup has to be done by the caller. Applied to our toy example, will lead to the following
// 3. Caller-owned Instance
typedef struct {
int value;
} Adder;
void Adder_init(Adder* a, int init_val) { a->value = init_val; }
void Adder_add(Adder* a, int x) {
a->value += x;
printf("Caller-Owned: New value: %d\n", a->value);
}
CA more practical example for embedded systems would be using multiple UARTs for getting GNSS data and sending network data. In this case we can have different caller handles for the UART.
This deals with multiple instances for multiple callers. There could be a case when the same instance has to be used by multiple callers, each caller might add/remove state information. In this case we use the next pattern.
5.d. Shared Instance
Let the software module have the ownership of instances, and it needs to handled as different callers operate on same instance.
In this paradigm, the software module retains ownership of instances while allowing multiple callers to operate on shared resources. Unlike caller-owned patterns, initialization and cleanup logic leverages the module’s internal state to reuse existing resources.
// 4. Shared Instance
typedef struct {
int id;
} Resource;
static Resource* shared = NULL;
Resource* Resource_get() {
if (!shared) {
shared = malloc(sizeof(Resource));
shared->id = 42;
printf("Shared: Instance created\n");
}
return shared;
}
void Resource_free() {
free(shared);
shared = NULL;
}
C
Leave a Reply