Serialización binaria portable en C

La serialización es el proceso de convertir una estructura de datos compleja en una secuencia lineal de bytes para su persistencia en disco o su transmisión por red. El error más crítico que cometes al programar sistemas es intentar volcar un struct directamente a un archivo o socket usando fwrite(&mi_struct, sizeof(mi_struct), 1, fp). Este enfoque asume que el receptor tiene exactamente la misma arquitectura que el emisor, lo cual es una falacia en sistemas distribuidos o embebidos. El layout de memoria de un struct está dictado por el ABI (Application Binary Interface) de la plataforma, que introduce padding (bytes de relleno) para asegurar la alineación de los miembros, y los tamaños de tipos como long o size_t varían entre arquitecturas de 32 y 64 bits. A esto se suma el endianness (el orden de los bytes en memoria), donde un 0x12345678 se almacena de forma distinta en x86 (little-endian) que en una arquitectura MIPS (big-endian).

Para lograr un formato de transmisión (wire format) portable, debes definir un protocolo donde cada campo tenga un tamaño fijo y un orden de bytes estándar. El estándar de facto para comunicación es el network byte order (big-endian). Debes usar tipos de tamaño fijo de <stdint.h> [C99] (como uint32_t o uint16_t) y convertir cada valor antes de guardarlo mediante htonl() (host to network long) o htons() (short). Para leer y escribir estos bytes en el buffer sin violar las reglas de strict aliasing o causar fallos por desalineación (misaligned access), la herramienta correcta es memcpy. Si fallas en este proceso, tus datos serán interpretados erróneamente o tu programa lanzará un SIGBUS en arquitecturas estrictas como ARM o SPARC.

#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <arpa/inet.h> // [POSIX] para htonl, ntohl, htons, ntohs

/* 
 * Representación en memoria: puede tener padding y endianness local.
 */
typedef struct {
    uint32_t id;
    uint16_t version;
    uint8_t  type;
    uint8_t  reserved; // Padding explícito para asegurar consistencia
    float    value;    // Nota: La portabilidad de float requiere cuidado extra
} SensorData;

/*
 * Formato de red definido:
 * [ID: 4b][Version: 2b][Type: 1b][Res: 1b][Value: 4b] = 12 bytes totales.
 */
#define WIRE_SIZE 12

// Serializa la estructura al buffer en big-endian
int serialize_sensor_data(uint8_t *buffer, const SensorData *data) {
    if (!buffer || !data) return -1;

    uint32_t net_id = htonl(data->id);
    uint16_t net_ver = htons(data->version);
    
    // Para el float, una técnica común es copiar su representación bit a bit
    // a un uint32_t para convertir el endianness sin problemas de aliasing.
    uint32_t float_bits;
    memcpy(&float_bits, &data->value, sizeof(uint32_t));
    uint32_t net_val = htonl(float_bits);

    size_t offset = 0;
    memcpy(buffer + offset, &net_id, 4);    offset += 4;
    memcpy(buffer + offset, &net_ver, 2);   offset += 2;
    buffer[offset] = data->type;            offset += 1;
    buffer[offset] = data->reserved;        offset += 1;
    memcpy(buffer + offset, &net_val, 4);   offset += 4;

    return WIRE_SIZE;
}

// Deserializa el buffer a la estructura original
int deserialize_sensor_data(SensorData *data, const uint8_t *buffer) {
    if (!data || !buffer) return -1;

    uint32_t net_id, net_val;
    uint16_t net_ver;
    size_t offset = 0;

    // Usamos memcpy para evitar UB por desalineación al leer de un buffer de bytes
    memcpy(&net_id, buffer + offset, 4);  offset += 4;
    memcpy(&net_ver, buffer + offset, 2); offset += 2;
    data->type = buffer[offset];          offset += 1;
    data->reserved = buffer[offset];      offset += 1;
    memcpy(&net_val, buffer + offset, 4); offset += 4;

    data->id = ntohl(net_id);
    data->version = ntohs(net_ver);
    
    // Convertir de nuevo de red a formato host
    uint32_t host_float_bits = ntohl(net_val);
    memcpy(&data->value, &host_float_bits, sizeof(float));

    return WIRE_SIZE;
}

int main(void) {
    SensorData original = {
        .id = 0xDEADBEEF,
        .version = 2,
        .type = 0x05,
        .reserved = 0,
        .value = 123.45f
    };
    uint8_t buffer[WIRE_SIZE];

    if (serialize_sensor_data(buffer, &original) != WIRE_SIZE) {
        return 1;
    }

    // Simulamos la llegada del buffer por red
    SensorData decoded;
    if (deserialize_sensor_data(&decoded, buffer) != WIRE_SIZE) {
        return 1;
    }

    if (memcmp(&original, &decoded, sizeof(SensorData)) == 0) {
        printf("Éxito: Datos íntegros tras la serialización.\n");
        printf("ID: 0x%X, Ver: %u, Val: %.2f\n", decoded.id, decoded.version, decoded.value);
    } else {
        printf("Error: Datos corruptos.\n");
        return 1;
    }

    return 0;
}

Desglose del mecanismo

En el ejemplo, la función serialize_sensor_data no utiliza el puntero de la estructura SensorData para copiar sus datos directamente. En su lugar, extrae cada campo por separado. Primero, transformamos data->id de su orden nativo (ej. little-endian en x86) a el orden de red mediante htonl. El uso de memcpy(buffer + offset, &net_id, 4) es fundamental: no estamos haciendo un cast de punteros como uint32_t *ptr = (uint32_t *)buffer, lo cual violaría las reglas de strict aliasing y podría causar que el compilador optimice el código de forma que descarte la escritura.

En la deserialización, el proceso es inverso. El buffer uint8_t *buffer no garantiza que los bytes de un entero comiencen en una dirección múltiplo de 4. Si intentaras acceder a *(uint32_t*)(buffer + 1), causarías un error de alineación en procesadores estrictos. Al usar memcpy, el compilador genera instrucciones optimizadas (a menudo un simple mov si la arquitectura lo permite) que gestionan la carga de bytes de forma segura y eficiente. Finalmente, la conversión del float mediante una copia de bits a un uint32_t es el método estándar para manipular la representación binaria de números de punto flotante sin inducir conversiones de tipo no deseadas.

El error frecuente

Un error clásico es intentar “leer” un entero desde un buffer de bytes mediante un cast de puntero para ganar velocidad.

// ERROR: Provoca Undefined Behavior
uint8_t buffer[16];
// ... buffer llenado por red ...
uint32_t val = *(uint32_t *)(buffer + 1); 

Esto es peligroso por tres razones:
1. Misaligned Access: En arquitecturas como ARM, acceder a un uint32_t en una dirección no múltiplo de 4 puede disparar una excepción de hardware (SIGBUS).
2. Strict Aliasing: El estándar de C dicta que un objeto no puede ser accedido a través de un puntero de un tipo incompatible. Esto permite al compilador asumir que *ptr y *(uint32_t*)ptr no apuntan al mismo lugar, rompiendo la lógica en optimizaciones de alto nivel.
3. Endianness: El valor obtenido será totalmente distinto si el emisor y el receptor no comparten el mismo orden de bytes.

AddressSanitizer (-fsanitize=alignment) detectará esto inmediatamente durante el desarrollo.

128

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio