C Programming Refresher: Pointers, Casts and Byte Parsing

C refresher for embedded work: casting void pointers, passing structs by pointer, typedef enums, string literals, parsing little-endian integers from byte arrays and the callback pattern, each with the gotcha explained.
C Programming Refresher: Pointers, Casts and Byte Parsing

Most of my C is written for microcontrollers, often inside Zephyr RTOS applications, and months can pass between projects. These are the idioms I look up every time I come back: casting opaque buffers, parsing integers out of byte arrays and wiring callbacks. Each snippet is short, compiles on its own and is annotated with the detail that usually trips me up.

Casting a void pointer to a byte array

Driver callbacks often hand you const void *data and a length. To read individual bytes, cast the pointer to uint8_t * and index it:

static uint8_t notify_func(const void *data)
{
    uint8_t value = ((uint8_t *)data)[7];
    return value;
}

The cast drops const, which compilers allow but warn about with -Wcast-qual. Casting to const uint8_t * instead keeps the promise that you will not write through the pointer. Always check the length before indexing; data[7] on a 4-byte buffer is undefined behavior, not an error.

Passing a struct by pointer

Pass structs to functions by pointer, not by value. A pointer is one machine word regardless of struct size, and the -> operator reads members through it:

static uint8_t notify_func(struct bt_gatt_subscribe_params *params)
{
    printk("value_handle : %d\n", params->value_handle);
    return 0;
}

If the function should not modify the struct, declare the parameter const struct bt_gatt_subscribe_params *params. The compiler then rejects accidental writes.

Enums as types

Wrap an enum in a typedef so you can use it as a type name without repeating enum everywhere. Adding a _COUNT member as the last real value gives you the number of entries for free, which is handy for sizing lookup tables:

typedef enum {
    STRING,
    NUMBERS,
    COMPLEX,
    DATATYPE_COUNT,
    INVALID_SENSOR
} sensor_datatype_t;

The _t suffix is the common convention for typedef names in embedded code. Note that POSIX reserves _t for its own types, so some house styles avoid it; Zephyr uses it freely.

String literals and char pointers

A string literal lives in read-only memory. Point at it with const char * so the compiler stops you from writing to it:

const char *p_mac_addr = "0C:8C:DC:41:E1:EF";

static void print_mac_addr(void)
{
    printk("p_mac_addr : %s\n", p_mac_addr);
}

Returning a string from a function is safe as long as the memory outlives the call. A literal or a static buffer is fine; a local array is not, because it is destroyed when the function returns:

const char *get_sensor_mac_addr(void)
{
    return p_mac_addr;
}

Parsing integers from a byte array

Sensors and radios deliver multi-byte integers as raw bytes, usually little-endian (least significant byte first). Reassemble them with shifts and ORs:

uint32_t get_uint_32(const uint8_t *array, size_t start_index)
{
    uint32_t value = 0;
    value |= (uint32_t)array[start_index];
    value |= (uint32_t)array[start_index + 1] << 8;
    value |= (uint32_t)array[start_index + 2] << 16;
    value |= (uint32_t)array[start_index + 3] << 24;
    return value;
}

The (uint32_t) casts matter. Without them, array[i] << 24 is computed as int, and shifting a byte of 0x80 or higher into the sign bit is undefined behavior on 32-bit targets.

For a signed value, parse the unsigned version first and reinterpret it. On every mainstream compiler this produces the two’s-complement result you expect:

int32_t get_int_32(const uint8_t *array, size_t start_index)
{
    return (int32_t)get_uint_32(array, start_index);
}

For big-endian data, reverse the shift order. Zephyr also ships sys_get_le32() and sys_get_be32() in <zephyr/sys/byteorder.h>, which do exactly this and are worth using when available.

Function pointers and the callback pattern

A callback lets a module report results without knowing who is listening. First, give the function pointer type a name in the header so callers and the implementation agree on the signature:

// calculation.h
#pragma once

typedef void (*calculation_result_cb_t)(float a, float b, float result);

int init_calculation(calculation_result_cb_t result_callback);
int calculate_sum(float a, float b);

The implementation stores the pointer in a static variable and calls it when there is something to report. Checking for NULL first means the module works even if nobody registered a callback:

// calculation.c
#include <stdio.h>
#include "calculation.h"

static calculation_result_cb_t calc_result_cb = NULL;

int init_calculation(calculation_result_cb_t result_callback)
{
    calc_result_cb = result_callback;
    return 0;
}

int calculate_sum(float a, float b)
{
    if (calc_result_cb != NULL) {
        calc_result_cb(a, b, a + b);
    }
    return 0;
}

The caller registers a function with a matching signature and then triggers the work:

// main.c
#include <stdio.h>
#include "calculations/calculation.h"

static void result_cb(float a, float b, float result)
{
    printf("%.2f + %.2f = %.2f\n", a, b, result);
}

int main(void)
{
    printf("Callback functions!\n");
    init_calculation(result_cb);

    if (calculate_sum(10, 20)) {
        printf("error\n");
    }
    return 0;
}

In firmware, the same shape appears everywhere: a driver takes a callback at init and invokes it from an interrupt or a work queue when data arrives. Keep callbacks short, and never block inside one that runs in interrupt context.

Summary

Cast void * to const uint8_t * and check lengths. Pass structs by pointer and mark them const when read-only. Typedef your enums and add a _COUNT sentinel. Point at string literals with const char *. Cast each byte to the target width before shifting. Store callbacks in a static pointer and check for NULL. None of this is advanced C, which is exactly why it is easy to get slightly wrong after a few months away.