wandres.dev
FFI CON C · interoperar

Memoria y cadenas en la frontera: quién libera qué y el terminador nulo

El problema más hondo de la FFI no es llamar, es la memoria. El ownership de Rust se detiene en la frontera; C no sabe qué es un Drop. Cada puntero que cruza plantea una pregunta: quién lo libera y con qué asignador. Fallar da una fuga (nadie libera) o una doble liberación (ambos, o uno libera lo que asignó el otro). Y las cadenas afilan el problema: el terminador nulo de C y la longitud de Rust son dos modelos que hay que reconciliar a mano. El patrón durable: encapsular en un wrapper.

⏱ 20 min

Llamar a través de la frontera resultó ser lo fácil. El problema profundo de la FFI es la memoria. El ownership —esa disciplina que Rust impone para que cada valor tenga un dueño y se libere una sola vez, en el momento exacto— se detiene en la frontera. C no sabe qué es un Drop, no lleva la cuenta de quién posee qué, no distingue un préstamo de una cesión. Así que cada puntero que cruza el puente arrastra una pregunta que Rust normalmente contesta solo y que aquí tienes que contestar tú: ¿quién libera esto, y con qué asignador? Respóndela mal en un sentido y tendrás una fuga, porque nadie libera. Respóndela mal en el otro y tendrás una doble liberación o una corrupción, porque los dos lados liberan, o uno libera con free lo que el otro asignó con el asignador de Rust. Y las cadenas, otra vez, llevan el problema al extremo: el terminador nulo de C y el par puntero-más-longitud de Rust son dos modelos de memoria que solo se reconcilian a mano.

🎯 Al terminar esta lección sabrás
  • Fijar para cada puntero quién asigna y quién libera, y con qué asignador.
  • Ceder y recuperar la propiedad con Box::into_raw / from_raw y CString::into_raw / from_raw.
  • Respetar el terminador nulo y la validez temporal de las cadenas al cruzar.
  • Reconocer los patrones seguros: emparejar cada asignación con su liberación y envolver (nivel 40).

La regla de oro: un asignador, un liberador

La memoria asignada por un lado debe liberarla ese mismo lado, con su mismo asignador. El Box de Rust usa el asignador global de Rust; el malloc de C usa el del sistema de C; y no tienen por qué ser el mismo. Liberar un Box de Rust con free de C es UB, y liberar un puntero de malloc con el Drop de Rust también. De ahí el patrón: si Rust asigna y entrega un puntero a C, Rust debe exponer también su propia función de liberación para que C se lo devuelva.

// Rust asigna y CEDE la propiedad a C.
#[unsafe(no_mangle)]
pub extern "C" fn crear_buffer(n: usize) -> *mut u8 {
    let bloque = vec![0u8; n].into_boxed_slice();
    Box::into_raw(bloque) as *mut u8   // se olvida el valor: no corre Drop aqui
}

// C DEVUELVE el puntero y Rust lo libera con SU asignador.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn liberar_buffer(p: *mut u8, n: usize) {
    if p.is_null() { return; }
    let bloque = core::ptr::slice_from_raw_parts_mut(p, n);
    drop(unsafe { Box::from_raw(bloque) });   // recupera y libera correctamente
}

Box::into_raw es una fuga deliberada: saca el valor del régimen de Drop, entrega su puntero crudo y Rust se desentiende. Box::from_raw es la operación inversa: reclama la propiedad, y cuando el Box cae, el Drop libera con el asignador correcto. Van siempre en pareja; cada into_raw necesita exactamente un from_raw.

Cadenas: el terminador nulo y la propiedad

Las cadenas combinan las dos dificultades: propiedad y modelo de memoria. Para entregar una cadena Rust a C, construyes una CString y transfieres su propiedad con into_raw, ofreciendo de nuevo un liberador emparejado:

use std::ffi::CString;
use core::ffi::c_char;

#[unsafe(no_mangle)]
pub extern "C" fn saludo() -> *mut c_char {
    CString::new("hola desde Rust").unwrap().into_raw()   // cede la propiedad
}

#[unsafe(no_mangle)]
pub unsafe extern "C" fn liberar_cadena(p: *mut c_char) {
    if p.is_null() { return; }
    drop(unsafe { CString::from_raw(p) });   // reclama y libera con el asignador de Rust
}

Recibir un char* desde C es distinto: CStr::from_ptr solo toma prestado. No posees la cadena, no debes liberarla, y solo es válida mientras C la mantenga viva. El error clásico es guardar el &CStr y seguir usándolo después de que C haya liberado el buffer original: un use-after-free de manual.

🛑
El catálogo del comportamiento indefinido en la frontera

Cinco maneras de romperlo, todas silenciosas hasta que corrompen algo lejos del origen. Fuga: un into_raw sin su from_raw; nadie libera. Doble liberación: from_raw dos veces, o C hace free de lo que Rust cedió con into_raw. Asignador cruzado: free de C sobre un Box de Rust, o Drop de Rust sobre un malloc de C. Puntero colgante: conservar un CStr prestado después de que C libere su fuente. Nulo ausente: CString::new falla ante un nulo interior, y from_ptr sobre un buffer sin terminador lee fuera de rango. Cada una es UB, no un error recuperable.

El patrón durable: envolver el recurso

La respuesta sostenible no es “acordarse de liberar cada vez” —la memoria humana es exactamente el mecanismo que Rust vino a sustituir—. La respuesta es encapsular el unsafe una sola vez: envolver el puntero crudo en un struct de Rust que lo posea y que implemente Drop para llamar al liberador correcto. Así el recurso foráneo recupera la garantía automática de Rust —se libera una vez, exactamente cuando el struct sale de ámbito— y el resto del programa vuelve a ser código seguro.

pub struct Recurso {
    ptr: *mut Ffi,   // puntero crudo de una biblioteca C
}

impl Drop for Recurso {
    fn drop(&mut self) {
        // se ejecuta una sola vez, al salir de ambito: RAII sobre el recurso C
        unsafe { ffi_liberar(self.ptr) }
    }
}

Este es el puente hacia el nivel 40: la abstracción segura que toma un crate -sys crudo y expone tipos con Drop, referencias en vez de punteros y Result en vez de códigos de error. La FFI cruda es el motor; el wrapper es la carrocería que hace que nadie tenga que tocarlo.

flowchart LR
A[Rust asigna con Box o CString] -->|into_raw cede la propiedad| B[Puntero crudo cruza a C]
B --> C[C usa el puntero]
C -->|lo devuelve| D[from_raw reclama la propiedad]
D --> E[Drop libera una vez con el asignador de Rust]
C -->|C hace free directamente| X[UB por asignador cruzado o doble liberacion]
style A fill:#89b4fa,color:#11111b
style B fill:#fab387,color:#11111b
style E fill:#a6e3a1,color:#11111b
style X fill:#f38ba8,color:#11111b

Punteros opacos: el mango que nunca desreferencias

No todo lo que cruza es memoria que tú manipulas. Muchas bibliotecas de C entregan un puntero opaco —un FILE*, un sqlite3*— cuyo contenido no debes inspeccionar: es un mango que creas con una función, pasas a otras y destruyes con una tercera. En Rust se modela como un tipo cuya representación no puedes construir ni desreferenciar por accidente:

use core::ffi::{c_char, c_int};

#[repr(C)]
pub struct Conexion { _priv: [u8; 0] }   // opaco: no se puede desreferenciar

unsafe extern "C" {
    fn abrir(ruta: *const c_char) -> *mut Conexion;
    fn consultar(c: *mut Conexion, sql: *const c_char) -> c_int;
    fn cerrar(c: *mut Conexion);
}

El tipo opaco codifica en el sistema de tipos lo que la regla de oro exige: solo la biblioteca que creó el mango con abrir sabe liberarlo con cerrar, y tú jamás asignas ni liberas esa memoria. Tu única obligación es no perder el puntero y llamar a cerrar una sola vez —justo el trabajo que un struct con Drop hará por ti—.

El ownership no cruza la frontera: hay que reinstaurarlo por convención, y por eso se encapsula

Detente en lo que de verdad ocurre cuando un puntero cruza de Rust a C. Dentro de Rust, la propiedad es una propiedad del sistema de tipos: el compilador sabe, en cada punto del programa, quién posee cada valor, y usa ese saber para insertar exactamente un Drop en el lugar correcto y para prohibir todo uso posterior. Esa contabilidad no es un comentario ni una disciplina: es una estructura que vive en los tipos y que el compilador verifica. Cuando el puntero cruza a C, esa estructura no viaja con él. C recibe una dirección de memoria desnuda —unos bits— sin ninguna anotación sobre quién la posee, cuándo muere o si alguien más la está mirando. La propiedad no se ha transferido: se ha evaporado, porque era una construcción del sistema de tipos de Rust y al otro lado no hay tal sistema. Y aquí está la simetría inquietante de la FFI: reintroduce, exactamente, la disciplina manual que Rust nació para abolir. into_raw y from_raw, malloc y free, “quién libera y con qué asignador” son las preguntas de C de siempre, las que causaban los crashes de las tres de la madrugada, resucitadas en la frontera. Rust no las ha resuelto ahí; no puede, porque la otra mitad del programa no habla su idioma. Lo único que puede hacer —y esta es la enseñanza central del nivel— es dejarte reconstruir la garantía por convención en una región pequeña: un into_raw emparejado con su from_raw, un puntero crudo envuelto en un struct con Drop. Envolver no es cosmética; es el acto de reinstaurar en un punto la contabilidad de propiedad que la frontera destruyó, de modo que el Drop del wrapper vuelva a garantizar la liberación única que el compilador garantizaba antes. Por eso el arte de la FFI se reduce a una consigna geométrica: hacer la región sin propiedad tan pequeña como se pueda, encerrarla tras un tipo que la posea, y devolver al resto del programa al mundo seguro donde el ownership sí vale. La frontera es donde termina la prueba de Rust y empieza el honor de C; la ingeniería consiste en que ese tramo de honor mida lo mínimo.

📝
Lo esencial de la memoria en la frontera

Cada puntero que cruza exige decidir quién libera y con qué asignador: liberar memoria de Rust con free de C, o al revés, es UB. Box::into_raw/from_raw y CString::into_raw/from_raw ceden y reclaman la propiedad, y van siempre en pareja: quien asigna en Rust debe exponer su propio liberador. CStr::from_ptr solo presta —no liberes lo prestado ni lo uses tras su muerte—. CString::new falla ante un nulo interior; leer un buffer sin terminador desborda. Los fallos —fuga, doble liberación, asignador cruzado, puntero colgante, nulo ausente— son silenciosos. El patrón durable: envolver el puntero crudo en un struct con Drop que reinstaure la liberación única. Ese wrapper es el nivel 40.

⚔️ Reinstaura la propiedad en la frontera
  1. Escribe crear_buffer y liberar_buffer con Box::into_raw y from_raw. Explica por qué into_raw no libera y por qué from_raw es unsafe.
  2. Provoca a propósito una fuga (llama a into_raw y nunca a from_raw) y detéctala con una herramienta como valgrind o el sanitizador de direcciones. Luego provoca una doble liberación y observa la diferencia.
  3. Exporta saludo que devuelve un *mut c_char con CString::into_raw y su liberar_cadena. Argumenta qué pasaría si C hiciera free del puntero en vez de devolvértelo.
  4. Recibe un *const c_char con CStr::from_ptr, conviértelo a String, y explica por qué no debes liberarlo y hasta cuándo es válido.
  5. Envuelve un puntero crudo en un struct Recurso con Drop. Explica cómo ese wrapper “reinstaura la contabilidad de propiedad que la frontera destruyó” y por qué eso reduce la superficie unsafe.