Avoiding Callback Hell in C Programming

Recently, I came across a very interesting term “callback hell”, which refers to the endless nesting of callback functions that ultimately leads to stack overflow and deadlock. The manifestation of this is multiple layers of nested function pointer callbacks, resulting in poor code readability and maintenance difficulties.Below is a simple example simulating asynchronous file reading followed by three consecutive data processing steps (each step depends on the result of the previous one):

#include <stdio.h>
#include <stdlib.h>

/* Step 1: Read file content */
void read_file(const char* filename, void (*process1)(char*, void*), void* user_data);

/* Step 2: Preliminary data processing */
void process_data1(char* data, void (*process2)(char*, void*), void* user_data);

/* Step 3: Deep data processing */
void process_data2(char* data, void (*process3)(char*, void*), void* user_data);

/* Final processing result */
void final_process(char* result, void* user_data);

/* User data */
struct Context {
    int retry_count;
    char* output_path;
};

int main() {
    struct Context ctx = {3, "/path/to/output.txt"};
    
    /* Callback hell begins (three layers of nesting)*/
    read_file("input.txt", 
        (void(*)(char*, void*))process_data1,  // Type cast to resolve C89 type checking
        (void*)&ctx
    );
    
    return 0;
}

/* Simulate file reading */
void read_file(const char* filename, void (*process1)(char*, void*), void* user_data) {
    char* mock_data = "Hello, Callback!";
    printf("Read complete\n");
    
    /* First layer callback */
    process1(mock_data, user_data);
}

/* First data processing */
void process_data1(char* data, void (*process2)(char*, void*), void* user_data) {
    /* Simulate processing */
    char* processed = (char*)malloc(50);
    sprintf(processed, "[1]%s", data);
    printf("Process1 done: %s\n", processed);
    
    /* Second layer callback */
    process2(processed, user_data);
    free(processed); // Note: In actual development, consider memory lifecycle
}

/* Second data processing */
void process_data2(char* data, void (*process3)(char*, void*), void* user_data) {
    /* Simulate deep processing */
    char* final = (char*)malloc(50);
    sprintf(final, "[2]%s", data);
    printf("Process2 done: %s\n", final);
    
    /* Third layer callback */
    process3(final, user_data);
    free(final);
}

/* Final processing */
void final_process(char* result, void* user_data) {
    struct Context* ctx = (struct Context*)user_data;
    printf("Final output (%d retries): %s -> %s\n", 
           ctx->retry_count, result, ctx->output_path);
}

/* Need to manually link callbacks (in actual use) */
void process_data1(char*, void*, void*);  // Forward declaration adjustment
void process_data2(char*, void*, void*);   // Resolve dependencies

/* Actual callback binding (must match strictly) */
void process_data1(char* data, void (*next)(char*, void*), void* ctx) {
    /* Wrap as unified parameter */
    void callback_adapter(char* d, void* c) {
        next(d, c);
    }
    process_data1(data, callback_adapter, ctx); // Call actual process_data1
}

Callback nesting structure:<span><span>read_file</span></span><span><span>process_data1</span></span><span><span>process_data2</span></span><span><span>final_process</span></span>

  • Each layer function needs to pass the next layer’s callback pointer and user data
  • Deep nesting leads to confusion in parentheses and indentation
  • Need to manually manage callback function parameter matching (via<span><span>void*</span></span> type casting)
  • Cannot handle errors uniformly, each layer must handle separately
  • Complex memory management: Cross-callback memory release is prone to errors
  • The most dangerous aspect is that a large call depth consumes a lot of stack, leading to overflow.

How can we avoid this in our daily development?We can convert multiple layers of nested callbacks into a linear state transition process, the most common being the use of a state machine. By managing the processing flow with a state machine, the code structure becomes clearer and easier to maintain:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/* User data structure */
struct Context {
    int retry_count;
    char* output_path;
};

/* State enumeration defining processing stages */
typedef enum {
    STATE_READ_FILE,   // Reading file stage
    STATE_PROCESS1,    // Preliminary processing stage
    STATE_PROCESS2,    // Deep processing stage
    STATE_FINAL,       // Final processing stage
    STATE_DONE         // Completion state
} State;

/* State machine structure, maintaining processing state and context */
typedef struct {
    State current_state;  // Current state
    struct Context* ctx;  // User context
    char* current_data;   // Current data being processed
} StateMachine;

/* Initialize state machine */
StateMachine* state_machine_init(struct Context* ctx) {
    StateMachine* sm = (StateMachine*)malloc(sizeof(StateMachine));
    if (!sm) return NULL;
    sm->current_state = STATE_READ_FILE;
    sm->ctx = ctx;
    sm->current_data = NULL;
    return sm;
}

/* Clean up state machine resources */
void state_machine_cleanup(StateMachine* sm) {
    if (sm) {
        free(sm->current_data);  // Free current data memory
        free(sm);                // Free state machine itself
    }
}

/* State handling function: Read file */
static void handle_read_file(StateMachine* sm) {
    // Simulate file reading (can be replaced with real file reading logic)
    const char* mock_data = "Hello, Callback!";
    printf("Read complete\n");

    // Copy data to state machine context
    sm->current_data = strdup(mock_data);
    if (!sm->current_data) {
        fprintf(stderr, "Memory allocation failed in read phase\n");
        sm->current_state = STATE_DONE;  // Terminate on allocation failure
        return;
    }

    sm->current_state = STATE_PROCESS1;  // Transition to preliminary processing stage
}

/* State handling function: Preliminary data processing */
static void handle_process1(StateMachine* sm) {
    if (!sm->current_data) {
        fprintf(stderr, "No data to process in process1\n");
        sm->current_state = STATE_DONE;
        return;
    }

    // Simulate preliminary processing (add marker)
    char* processed = (char*)malloc(50);
    if (!processed) {
        fprintf(stderr, "Memory allocation failed in process1\n");
        sm->current_state = STATE_DONE;
        return;
    }
    snprintf(processed, 50, "[1]%s", sm->current_data);
    printf("Process1 done: %s\n", processed);

    free(sm->current_data);     // Free old data
    sm->current_data = processed; // Update to new data
    sm->current_state = STATE_PROCESS2;  // Transition to deep processing stage
}

/* State handling function: Deep data processing */
static void handle_process2(StateMachine* sm) {
    if (!sm->current_data) {
        fprintf(stderr, "No data to process in process2\n");
        sm->current_state = STATE_DONE;
        return;
    }

    // Simulate deep processing (second marker)
    char* final = (char*)malloc(50);
    if (!final) {
        fprintf(stderr, "Memory allocation failed in process2\n");
        sm->current_state = STATE_DONE;
        return;
    }
    snprintf(final, 50, "[2]%s", sm->current_data);
    printf("Process2 done: %s\n", final);

    free(sm->current_data);     // Free old data
    sm->current_data = final;   // Update to new data
    sm->current_state = STATE_FINAL;  // Transition to final processing stage
}

/* State handling function: Final processing result */
static void handle_final(StateMachine* sm) {
    if (!sm->current_data) {
        fprintf(stderr, "No data to finalize\n");
        sm->current_state = STATE_DONE;
        return;
    }

    // Call final processing function (using user context)
    final_process(sm->current_data, sm->ctx);

    free(sm->current_data);  // Free final data
    sm->current_data = NULL;
    sm->current_state = STATE_DONE;  // Mark as complete
}

/* Drive state machine run */
void state_machine_run(StateMachine* sm) {
    while (sm->current_state != STATE_DONE) {
        switch (sm->current_state) {
            case STATE_READ_FILE:   handle_read_file(sm);   break;
            case STATE_PROCESS1:    handle_process1(sm);    break;
            case STATE_PROCESS2:    handle_process2(sm);    break;
            case STATE_FINAL:       handle_final(sm);       break;
            default: 
                fprintf(stderr, "Invalid state!\n");
                sm->current_state = STATE_DONE;
                break;
        }
    }
}

/* Simulate file reading (original logic retained) */
void read_file(const char* filename, void (*process)(char*, void*), void* user_data) {
    // This should be replaced with file reading logic, example uses mock data
    char* mock_data = "Hello, Callback!";
    printf("Read complete\n");
    process(mock_data, user_data);
}

/* Original processing function (adjusted to independent functionality) */
void process_data1(char* data, void* user_data) {
    char* processed = (char*)malloc(50);
    snprintf(processed, 50, "[1]%s", data);
    printf("Process1 done: %s\n", processed);
    free(processed);  // Directly free in example (actual needs adjustment based on requirements)
}

void process_data2(char* data, void* user_data) {
    char* final = (char*)malloc(50);
    snprintf(final, 50, "[2]%s", data);
    printf("Process2 done: %s\n", final);
    free(final);
}

/* Final processing result (original logic retained) */
void final_process(char* result, void* user_data) {
    struct Context* ctx = (struct Context*)user_data;
    printf("Final output (%d retries): %s -> %s\n", 
           ctx->retry_count, result, ctx->output_path);
}

int main() {
    struct Context ctx = {3, "/path/to/output.txt"};
    
    // Use state machine instead of callback nesting
    StateMachine* sm = state_machine_init(&ctx);
    if (!sm) {
        fprintf(stderr, "Failed to initialize state machine\n");
        return 1;
    }

    state_machine_run(sm);  // Run state machine processing full flow

    state_machine_cleanup(sm);  // Clean up resources
    return 0;
}

Transforming complex callback nesting into linear state transitions, explicitly managing memory allocation and release, avoiding memory leaks caused by unclear scopes in callback hell. Each state handling function checks the return values of critical operations (such as memory allocation), terminating the state machine early in case of exceptions to avoid invalid state transitions.

Finally

That’s all for today. If you found this helpful, be sure to give it alike~

Recommended for you:

Singleton Pattern: The Guardian of Global State Consistency in Embedded Systems

Embedded Programming Model | Observer Pattern

What are some useful compression libraries in Embedded Applications?

Embedded Programming Model | Abstract Factory Pattern

Embedded Programming Model | Simple Factory Pattern

Embedded Field: The Ultimate Showdown between Linux and RTOS!

Embedded Software Advanced Guide, let’s advance together!

Leave a Comment