wandres.dev
FFI CON C · interoperar

Tipos compatibles con C: repr(C), los escalares de core::ffi y las cadenas

Una firma es una promesa sobre el layout de la memoria. El repr(Rust) por defecto reordena y empaqueta campos a su antojo: excelente para optimizar, inservible para la FFI. Compartir un struct con C exige un layout byte a byte idéntico, y eso lo garantiza #[repr(C)]. Los escalares también deben coincidir: no i32 a ciegas sino c_int, cuyo ancho sigue al del compilador de C. Y las cadenas chocan de frente: CStr y CString reconcilian el terminador nulo.

⏱ 19 min

Cuando declaras que una función de C recibe un struct, estás prometiendo algo mucho más fuerte que “un tipo con estos campos”: prometes que los bytes están dispuestos exactamente donde C los espera. Y aquí surge un conflicto silencioso. El repr(Rust) por defecto se reserva el derecho de reordenar los campos, insertar relleno donde le convenga y empaquetar dos tipos distintos de forma distinta entre versiones del compilador. Esa libertad es la que le permite optimizar el tamaño y la alineación, pero convierte cualquier struct de Rust en un layout impredecible desde fuera. Para hablar con C hay que renunciar a esa libertad con #[repr(C)], que congela la disposición a las reglas de C. Y no solo los agregados: cada escalar debe ser el que C entiende —c_int, no i32— y las cadenas, que en los dos lenguajes son cosas radicalmente distintas, hay que traducirlas a mano.

🎯 Al terminar esta lección sabrás
  • Fijar el layout de un struct o enum con #[repr(C)] para que coincida con C.
  • Usar los alias de core::ffi (c_int, c_char, c_void) en vez de tipos de ancho fijo.
  • Manejar cadenas con CStr (prestada) y CString (poseída) y su terminador nulo.
  • Reconocer qué tipos de Rust no son compatibles con C y por qué cruzarlos es UB.

repr(C): un layout que C puede predecir

Por defecto Rust usa repr(Rust): el orden de los campos en memoria no tiene por qué ser el de la declaración, y el compilador puede reordenarlos para minimizar el relleno. #[repr(C)] desactiva esa libertad y aplica las reglas de C: campos en orden de declaración, con la alineación y el relleno que usaría un compilador de C en esa plataforma.

use core::ffi::c_int;

#[repr(C)]
struct Punto {
    x: c_int,
    y: c_int,
}

unsafe extern "C" {
    fn distancia_origen(p: Punto) -> f64;
}

Ahora Punto tiene un layout que C reconoce byte a byte, y puede pasarse por valor o por puntero a través de la frontera. Los enum también admiten repr: #[repr(C)] o #[repr(i32)] sobre un enum sin datos fija el tipo entero del discriminante, imprescindible para que C lo lea como el enum que es.

⚠️
Sin repr(C), pasar el struct a C es comportamiento indefinido

No es que “podría fallar en algún compilador raro”: pasar un struct con repr(Rust) a través de la FFI es UB por definición, porque su layout no está especificado. Puede funcionar hoy, cambiar al subir de versión de rustc, o diferir entre debug y release. La regla es mecánica: todo tipo que cruce la frontera —por valor o tras un puntero que C desreferencie— lleva #[repr(C)].

Los escalares de core::ffi

La segunda promesa es sobre el ancho de los escalares. En C, int no siempre son 32 bits y char puede ser con o sin signo según la plataforma. Escribir i32 a ciegas es correcto en tu máquina y una bomba en otra. Los alias de core::ffi resuelven esto: cada uno equivale al tipo de C correspondiente en la plataforma de destino.

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

unsafe extern "C" {
    fn procesar(datos: *mut c_void, longitud: c_uint) -> c_int;
    fn nombre_byte(indice: c_int) -> c_char;
}
  • c_int, c_uint, c_long, c_double: los escalares numéricos, con el ancho de C.
  • c_char: es i8 o u8 según la plataforma; por eso existe el alias en vez de fijar uno.
  • c_void: el tipo opaco tras un puntero (*mut c_void), el equivalente de void* de C.

Desde Rust 1.64 estos alias viven en core::ffi —disponibles incluso sin std— y se re-exportan en std::ffi y en el antiguo std::os::raw. Usarlos no es solo corrección: documenta la intención, porque un c_int en una firma grita “esto habla con C”.

Cadenas: el terminador nulo frente a la longitud

Aquí el choque es de modelos de memoria. Un str de Rust es un puntero más una longitud, codificado en UTF-8, y no termina en nulo. Una cadena de C es un puntero a char que termina en un byte nulo y no lleva longitud: hay que recorrerla hasta el \0 para saber dónde acaba. Dos tipos tienden el puente:

use core::ffi::{c_char, CStr};
use std::ffi::CString;

// Rust -> C: CString posee una cadena en el heap, con nulo final garantizado.
fn hacia_c(s: &str) -> CString {
    CString::new(s).expect("no debe haber un nulo interior")
}

// C -> Rust: CStr toma prestada una cadena terminada en nulo que ya existe.
unsafe fn desde_c(p: *const c_char) -> String {
    let cstr = unsafe { CStr::from_ptr(p) };  // recorre hasta el nulo
    cstr.to_string_lossy().into_owned()
}

CString es a CStr lo que String a str: el primero posee y asigna en el heap; el segundo es una vista prestada. CString::new falla si la cadena contiene un nulo interior, porque eso rompería la invariante de C. Y desde Rust 1.77 existen los literales c"...", que producen un &CStr con el nulo puesto en tiempo de compilación:

use core::ffi::CStr;
let saludo: &CStr = c"hola";   // terminado en nulo, sin asignacion en runtime
flowchart TB
subgraph Rust
  A[str y String son puntero mas longitud en UTF-8 sin nulo final]
end
subgraph C
  B[char asterisco es un puntero terminado en byte nulo sin longitud]
end
A -->|CString new asigna y anade el nulo| B
B -->|CStr from_ptr recorre hasta el nulo| A
style A fill:#89b4fa,color:#11111b
style B fill:#fab387,color:#11111b

repr(transparent): el newtype que no cuesta ABI

Hay un repr más al servicio de la FFI, y resuelve un problema muy concreto: envolver un tipo en un struct de un solo campo para ganar seguridad en Rust sin alterar el layout que ve C. #[repr(transparent)] garantiza que el struct tiene exactamente la misma representación que su único campo no vacío.

use core::ffi::c_int;

#[repr(transparent)]
struct Descriptor(c_int);   // identico a c_int para C, distinto para Rust

unsafe extern "C" {
    fn cerrar(fd: Descriptor) -> c_int;
}

Para C, Descriptor es un c_int; para Rust, es un tipo aparte que no puedes confundir con cualquier otro entero. Ganas el patrón newtype —imposible pasar un entero cualquiera donde se espera un descriptor— al precio de cero bytes y cero indirección. Es la manera idiomática de dar nombres con tipo a los enteros crudos que pululan por las APIs de C.

La ABI, no el tipo, es la verdadera interfaz entre dos lenguajes

Es tentador pensar que dos lenguajes se comunican a través de tipos: yo declaro Punto, C declara struct Punto, y de algún modo se entienden. Pero eso es una ilusión cómoda. C y Rust no comparten ningún sistema de tipos; sus compiladores no se hablan, no negocian nada, ni siquiera existen a la vez. Lo único que de verdad comparten es un acuerdo sobre bytes: cuántos ocupa un int, en qué desplazamiento vive el segundo campo, cómo se alinea la estructura, en qué registro se devuelve un f64. Ese acuerdo es la ABI, y es la interfaz real. El tipo Punto que escribes en Rust es solo una notación tuya para producir el layout correcto; podrías haber usado un nombre distinto, otros campos con el mismo tamaño, o un arreglo de bytes crudos, y mientras los bytes cayeran donde C los espera, funcionaría igual. Por eso #[repr(C)] no es “una anotación de compatibilidad”: es la instrucción de abandonar tu sistema de tipos y adoptar el layout de otro. Y por eso la coincidencia de nombres entre tu struct y el de C no significa absolutamente nada —el enlazador no compara nombres de campos, compara direcciones de memoria—. Esta es la lección incómoda de la FFI: la seguridad de tipos, esa red que Rust tiende bajo todo tu código, no cruza la frontera, porque al otro lado no hay tipos, solo memoria. Cuando entiendes que la interfaz es la ABI y no el tipo, dejas de confiar en que “los nombres cuadren” y empiezas a razonar sobre lo único que importa: que los bytes cuadren. repr(C), c_int, CString no son tres trucos sueltos; son tres formas del mismo acto, el de renunciar a la abstracción de Rust y hablar el idioma llano de los bytes que C nunca dejó de hablar.

📝
Lo esencial de los tipos compatibles

#[repr(C)] congela el layout de un struct o enum a las reglas de C; sin él, cruzarlo por la FFI es UB. Los escalares se escriben con los alias de core::ffic_int, c_char, c_void— cuyo ancho sigue al compilador de C de la plataforma, no con tipos de ancho fijo. Las cadenas chocan: str/String son puntero más longitud en UTF-8 sin nulo; C usa punteros terminados en nulo. CString (poseída, con nulo garantizado, falla ante un nulo interior) traduce de Rust a C; CStr (prestada) envuelve lo que viene de C; y c"..." da un &CStr en compilación.

⚔️ Haz que los bytes cuadren
  1. Define un #[repr(C)] struct Punto { x: c_int, y: c_int } y compara con std::mem::size_of y align_of frente a la versión sin repr. Explica qué garantiza cada una.
  2. Reescribe una firma que use i32 y u8 con los alias c_int y c_char. Argumenta qué portabilidad ganas y por qué c_char no fija el signo.
  3. Convierte un &str a CString y pásalo por *const c_char. Luego intenta convertir una cadena con un \0 en medio y explica el error de CString::new.
  4. Recibe un *const c_char (por ejemplo de strerror) y conviértelo con CStr::from_ptr a un String. Razona por qué la operación es unsafe.
  5. Enumera cuatro tipos de Rust que no deban cruzar la FFI por valor (String, Vec<T>, &str, un enum sin repr) y explica en cada caso qué presuposición de C se viola.