Skip to Content
Course content

196: Writing Defensive, Portable C Code

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I want to show you something that usually only happens when you move code from a powerful workstation to an embedded chip or a different OS. We're going to build a simple packet parser—something that takes a raw byte stream and extracts a header. It seems trivial, but this is where most "portable" C code actually falls apart.

The Naive Parser

Let's start with how most people write this when they're in a hurry. I'll define a simple header with a magic number and a length, then write a function to extract it.

typedef struct {
    int magic;
    int length;
} PacketHeader;

void parse_header(char *buffer) {
    PacketHeader *header = (PacketHeader *)buffer;
    printf("Magic: %d, Length: %d\n", header->magic, header->length);
}

I'll run this on my current machine with a buffer, and it works perfectly. The magic number is there, the length is correct. It feels clean. But as a mentor, my job is to tell you that this code is a ticking time bomb.

The Crash I Didn't Expect

First, let's be defensive. What happens if I pass NULL to parse_header? Or what if the buffer is only 2 bytes long, but I'm casting it to a struct that's 8 bytes? The program just segfaults or reads garbage from the stack. In a real system, that's a security vulnerability (an out-of-bounds read).

I need to stop assuming the caller is being honest with me. Let's adjust the function signature to include the buffer size and add some guards.

void parse_header(const char *buffer, size_t size) {
    if (buffer == NULL || size < sizeof(PacketHeader)) {
        fprintf(stderr, "Invalid buffer or insufficient size\n");
        return;
    }
    
    const PacketHeader *header = (const PacketHeader *)buffer;
    printf("Magic: %d, Length: %d\n", header->magic, header->length);
}

Now it's "defensive." It won't crash if the input is garbage. But we're still ignoring the "portable" part of the equation. I've used int. On my machine, int is 32 bits. On some older systems or specific microcontrollers, it could be 16. If I send this packet from a 32-bit system to a 16-bit system, the sizeof(PacketHeader) changes, and the data offsets shift. Everything breaks.

The 'It Works on My Machine' Trap

There's another hidden monster here: padding and alignment. The compiler might decide to stick a few empty bytes between magic and length to ensure they align with the CPU's word boundaries. If the sender's compiler pads and the receiver's doesn't, we're reading the wrong bytes.

To fix this, I can't rely on int or the compiler's default struct layout. I need to use fixed-width types from <stdint.h> and manually deserialize the data. This is the "professional" way to do it—it's slightly more verbose, but it's the only way to ensure the code behaves the same on an ARM chip as it does on an x86 server.

Locking It Down

Here is the revised version. Instead of casting the buffer to a struct (which is a portability nightmare), I'll extract the bytes one by one. I'm also using uint32_t to be explicit about the size.

#include <stdint.h>
#include <stddef.h>
#include <stdio.h>

typedef struct {
    uint32_t magic;
    uint32_t length;
} PacketHeader;

int parse_header_portable(const uint8_t *buffer, size_t size, PacketHeader *out_header) {
    if (buffer == NULL || out_header == NULL) return -1;
    if (size < 8) return -2; // We need at least 8 bytes for two uint32_t

    // Manually assemble the integers from bytes (Little Endian example)
    out_header->magic = (uint32_t)buffer[0] | 
                        ((uint32_t)buffer[1] << 8) | 
                        ((uint32_t)buffer[2] << 16) | 
                        ((uint32_t)buffer[3] << 24);

    out_header->length = (uint32_t)buffer[4] | 
                         ((uint32_t)buffer[5] << 8) | 
                         ((uint32_t)buffer[6] << 16) | 
                         ((uint32_t)buffer[7] << 24);

    return 0;
}

Notice what changed? I used uint8_t for the buffer because we are dealing with raw bytes, not characters. I avoided the struct cast entirely. By shifting bits, I've explicitly defined the byte order (Little Endian), meaning this code will now work regardless of whether the CPU is Big Endian or Little Endian. That's what I mean by portable.




📋 Practical Task

Hardening the Network Packet Header Parser

You have been handed a piece of legacy code that parses a "SensorData" packet. The current implementation is dangerous and non-portable. It uses a struct cast and assumes long is always 64 bits.

The broken code:

struct SensorData {
    long sensor_id;
    long value;
};

void process_sensor(char *data) {
    struct SensorData *s = (struct SensorData *)data;
    printf("ID: %ld, Val: %ld\n", s->sensor_id, s->value);
}

Your Task: Rewrite the process_sensor function (and the SensorData struct) to be defensive and portable. Your solution must:

  • Use <stdint.h> types (e.g., int64_t) to ensure consistent sizing across platforms.
  • Accept a size_t parameter to prevent buffer over-reads.
  • Include NULL pointer checks for the input buffer.
  • Manually assemble the 64-bit integers from the byte buffer to avoid alignment and padding issues (assume Little Endian).
  • Return an integer error code (e.g., 0 for success, -1 for invalid input) instead of returning void.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.