C como el estándar de interoperabilidad y FFI

C es la lingua franca de la computación porque su ABI (Application Binary Interface) es el estándar implícito de la industria. Mientras que el lenguaje define la sintaxis, la ABI define la mecánica: cómo se pasan los argumentos en los registros o en la pila, cómo se alinean los datos en memoria y cómo se gestionan las llamadas a funciones.

Cualquier lenguaje moderno —ya sea Rust, Python o Go— implementa un FFI (Foreign Function Interface) para comunicarse con C, porque es mucho más sencillo implementar un llamador para C que intentar que C entienda los formatos internos de un lenguaje de alto nivel. Si estás escribiendo una biblioteca de alto rendimiento en C, la estás escribiendo para que el resto del mundo la use.

Si decides usar C++ para implementar esa biblioteca, te encontrarás con el name mangling. Para soportar la sobrecarga de funciones, el compilador de C++ transforma el nombre de la función en algo irreconocible (por ejemplo, void foo(int) podría convertirse en _Z3fooi). Para que un lenguaje externo pueda encontrar la función mediante su nombre original, debes envolver tus declaraciones en un bloque extern "C", lo que le indica al compilador que desactive esa transformación y use la convención de nombres de C.

Sin embargo, no basta con que el enlazador encuentre la función. El diseño de la interfaz debe ser robusto. Para ello, empleamos handles opacos (opaque handles): punteros a estructuras cuya definición real se mantiene oculta en el archivo de implementación (.c), exponiendo al usuario solo un tipo de puntero. Esto garantiza el encapsulamiento y evita que el llamante manipule la memoria interna de tu librería.

Al cruzar la frontera de la biblioteca, el manejo de errores y la propiedad de la memoria (ownership) son críticos. No utilices errno ni setjmp, ya que son estados globales o de ejecución que no se propagan de forma fiable a través de diferentes entornos de ejecución (como el intérprete de Python). En su lugar, usa códigos de error explícitos devueltos por la función. La regla de oro es: “el que aloca, libera”. Si tu biblioteca reserva memoria con malloc, debe proporcionar su propia función de liberación para asegurar que se use el mismo asignador.

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

/* 
 * Definimos un tipo de error explícito para FFI.
 * Es vital evitar estados globales para que la API sea thread-safe.
 */
typedef enum {
    LIB_OK = 0,
    LIB_ERR_MALLOC = 1,
    LIB_ERR_INVALID_ARG = 2,
    LIB_ERR_BUFFER_TOO_SMALL = 3,
    LIB_ERR_INTERNAL = 4
} lib_status_t;

/* 
 * El "Opaque Handle". 
 * Al consumidor no le importa qué hay dentro de 'EngineContext'.
 * Solo sabe que es un puntero a algo que el motor utiliza.
 */
typedef struct EngineContext* engine_handle_t;

/* 
 * Implementación interna (solo visible en el .c)
 */
struct EngineContext {
    int32_t id;
    char* internal_buffer;
    uint32_t buffer_size;
};

/* 
 * API de la biblioteca
 */

/* Crea una nueva instancia del motor. Devuelve el handle mediante un puntero a puntero. */
lib_status_t engine_create(int32_t id, engine_handle_t* out_handle) {
    if (id < 0) return LIB_ERR_INVALID_ARG;

    engine_handle_t new_engine = malloc(sizeof(struct EngineContext));
    if (!new_engine) return LIB_ERR_MALLOC;

    new_engine->id = id;
    new_engine->buffer_size = 64;
    new_engine->internal_buffer = malloc(new_engine->buffer_size);
    
    if (!new_engine->internal_buffer) {
        free(new_engine);
        return LIB_ERR_MALLOC;
    }

    *out_handle = new_engine;
    return LIB_OK;
}

/* Procesa una cadena. El buffer de salida lo gestiona el llamante. */
lib_status_t engine_process(engine_handle_t handle, const char* input, char* output, size_t out_size) {
    if (!handle || !input || !output) return LIB_ERR_INVALID_ARG;

    size_t len = strlen(input);
    if (out_size < len + 1) return LIB_ERR_BUFFER_TOO_SMALL;

    // Operación trivial: copiar con un prefijo
    snprintf(output, out_size, "ID:%d-%s", handle->id, input);
    return LIB_OK;
}

/* Libera la memoria interna. Es el único modo de destruir el objeto. */
void engine_destroy(engine_handle_t handle) {
    if (handle) {
        if (handle->internal_buffer) {
            free(handle->internal_buffer);
        }
        free(handle);
    }
}

/* --- Ejemplo de uso (simulando un cliente externo como Python o Rust) --- */

int main(void) {
    engine_handle_t my_engine = NULL;
    char result_buffer[128];
    lib_status_t status;

    // 1. Inicialización
    status = engine_create(42, &my_engine);
    if (status != LIB_OK) {
        fprintf(stderr, "Error al crear el motor: %d\n", status);
        return 1;
    }

    // 2. Operación
    const char* input_data = "Hello FFI";
    status = engine_process(my_engine, input_data, result_buffer, sizeof(result_buffer));

    if (status == LIB_OK) {
        printf("Resultado procesado: %s\n", result_buffer);
    } else {
        fprintf(stderr, "Error en el procesamiento: %d\n", status);
    }

    // 3. Limpieza
    engine_destroy(my_engine);

    return 0;
}

Desglose de la implementación

En el ejemplo, hemos establecido una frontera clara entre la implementación y el consumidor. El tipo engine_handle_t es un puntero opaco; aunque en el código vemos que apunta a struct EngineContext, el usuario final (en un main o desde Python) solo ve un puntero genérico. Esto impide que el cliente intente acceder a handle->id directamente, lo que rompería la encapsulación y la estabilidad de la ABI.

Observa el uso de int32_t de <stdint.h>. En FFI, usar int es peligroso porque su tamaño varía entre arquitecturas (16, 32 o 64 bits). Al usar tipos de ancho fijo, garantizamos que el layout de memoria sea idéntico tanto en el motor como en el lenguaje que lo llama.

En engine_create, utilizamos el patrón de retorno lib_status_t junto con un puntero de salida engine_handle_t* out_handle. Esto es una práctica estándar porque permite que la función devuelva un estado de error sin necesidad de lanzar excepciones (que no existen en C) o usar valores de retorno ambiguos.

Finalmente, la gestión de la memoria es explícita. engine_create asigna memoria en el heap, y engine_destroy es la única vía para liberarla. Esto evita confusiones sobre quién es el dueño de los datos.

El error frecuente

El error más crítico en el desarrollo de FFI es el desajuste de la propiedad de la memoria (ownership mismatch).

Imagina que modificas engine_process para que devuelva un nuevo string recién asignado con malloc:

// ¡MAL DISEÑO PARA FFI!
char* engine_get_name_bad(engine_handle_t handle) {
    return strdup(handle->internal_buffer); // El llamante no sabe que debe liberar esto
}

Si un programador de Python llama a esta función mediante ctypes y simplemente recibe el puntero, es probable que olvide liberarlo, causando un leak. Peor aún, si el programador de C++ intenta liberar ese puntero usando delete en lugar de free, o si el programador de Rust intenta gestionarlo con su propio asignador, corromperás el heap. La regla es inamovible: el lado que reserva la memoria debe proveer la función para liberarla.

Un error también común es no usar extern "C" al compilar la librería en un entorno de C++. Si olvidas extern "C", el enlazador lanzará un error de tipo undefined reference, porque buscará el nombre “mangled” de C++ y no el nombre plano de C. Herramientas como AddressSanitizer detectarán rápidamente los leaks si no sigues la convención de liberación.

137

Dejar un comentario

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

Scroll al inicio