wandres.dev
FFI CON C · interoperar

Llamar a C desde Rust: declarar extern C, enlazar y por qué es unsafe

Medio siglo de código C existe y funciona: libc, SQLite, OpenSSL, las llamadas al sistema. Rust las alcanza a través de la FFI. Se declara la firma foránea en un bloque unsafe extern C, se enlaza la biblioteca nativa con #[link] o un build.rs, y cada llamada es unsafe porque el compilador no puede ver el cuerpo de C ni verificar el contrato que asumes al invocarlo.

⏱ 18 min

Rust no vive solo. Debajo de casi cualquier sistema real hay una capa de C: la biblioteca estándar del sistema operativo, libc, los drivers, motores como SQLite o OpenSSL, décadas de código probado que nadie va a reescribir. La interfaz de función foránea —la FFI— es el puente por el que Rust llama a ese mundo. Pero cruzar el puente tiene un precio exacto: al otro lado terminan las garantías. El compilador que demostró que tu programa no tenía use-after-free ni data races no puede ver el cuerpo de una función de C. Solo tiene tu palabra sobre qué firma tiene y qué contrato exige. Por eso la sintaxis para declararla se llama unsafe extern "C" y por eso toda llamada va envuelta en unsafe: es el punto donde la prueba del compilador se detiene y empieza tu responsabilidad.

🎯 Al terminar esta lección sabrás
  • Declarar funciones foráneas en un bloque unsafe extern "C" con la ABI correcta.
  • Enlazar la biblioteca nativa con el atributo #[link] o con un build.rs.
  • Comprender por qué toda llamada a C es unsafe y qué contrato asumes al hacerla.
  • Distinguir safe fn de unsafe fn dentro del bloque en la edición 2024.

El bloque extern: declarar sin definir

Un bloque extern no define funciones: declara sus firmas y promete que el símbolo existirá en tiempo de enlazado. La cadena "C" fija la ABI —la convención de llamada— con la que se pasan argumentos y se devuelve el resultado. En la edición 2024 el bloque entero debe marcarse unsafe extern, porque declarar una firma foránea es ya una afirmación que nadie puede comprobar por ti:

use core::ffi::c_int;

unsafe extern "C" {
    fn abs(input: c_int) -> c_int;
}

fn main() {
    let x: c_int = -42;
    let y = unsafe { abs(x) };   // la llamada exige unsafe
    println!("|{x}| = {y}");
}

Aquí no hay cuerpo tras abs: solo la firma. El compilador la toma como un contrato —“existe un símbolo llamado abs que recibe un c_int y devuelve otro”— y deja un hueco que el enlazador rellenará. abs vive en libc, que Rust enlaza automáticamente, así que este ejemplo compila sin más ceremonia.

Por qué unsafe: el contrato que el compilador no ve

La razón de unsafe no es ritual, es literal. El compilador no tiene el cuerpo de abs. No puede verificar que la firma que escribiste coincida con la real, ni que los punteros que pases sean válidos, ni que C no provoque una carrera de datos, ni que respete los lifetimes que Rust presupone. Toda esa carga de prueba, que dentro de Rust lleva el compilador, cruza la frontera y cae sobre ti. Cada llamada a C es un lugar donde le prometes al compilador lo que él no puede demostrar.

La edición 2024 refina esto: dentro del bloque puedes anotar cada declaración como safe o unsafe. Una función safe afirma que llamarla no exige ninguna precondición del llamador; una unsafe traslada esa exigencia a quien la invoca:

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

unsafe extern "C" {
    // Asegurar que la firma es correcta sigue siendo cosa tuya,
    // pero llamarla no exige nada mas: es segura de invocar.
    safe fn abs(input: c_int) -> c_int;

    // El llamador DEBE garantizar que s apunta a bytes validos
    // terminados en un nulo. Por eso invocarla es unsafe.
    unsafe fn strlen(s: *const c_char) -> usize;
}
ℹ️
safe no significa que C sea seguro

Marcar una función safe no audita su cuerpo: sigues afirmando a ciegas que la firma es correcta. Lo que safe documenta es que, dada una firma correcta, no hay precondición que el llamador pueda violar —como en abs, que acepta cualquier c_int—. Reserva safe para funciones puras sobre escalares; en cuanto haya un puntero de por medio, casi siempre es unsafe.

Enlazar la biblioteca

Declarar la firma no basta: el símbolo tiene que resolverse contra código máquina real. libc se enlaza sola, pero cualquier otra biblioteca hay que nombrarla. La forma directa es el atributo #[link] sobre el bloque:

#[link(name = "m")]   // libm, la biblioteca matematica del sistema
unsafe extern "C" {
    safe fn cbrt(x: f64) -> f64;   // raiz cubica
}

La forma flexible, y la que usan las crates serias, es un build script. El fichero build.rs se ejecuta antes de compilar y emite directivas para Cargo:

// build.rs
fn main() {
    println!("cargo:rustc-link-lib=m");                       // enlaza libm
    println!("cargo:rustc-link-search=native=/usr/local/lib"); // donde buscarla
}

El build script gana cuando la ruta o el nombre de la biblioteca dependen de la plataforma, o cuando primero hay que compilar código C con una crate como cc. El atributo #[link] gana por su brevedad cuando la biblioteca es estándar y siempre está.

flowchart LR
A[Codigo Rust con bloque unsafe extern C] --> B[El compilador emite un objeto con el simbolo sin resolver]
B --> C[El enlazador busca el simbolo]
D[Biblioteca nativa libm o libc] --> C
C --> E[Ejecutable final que llama a C]
style A fill:#89b4fa,color:#11111b
style C fill:#cba6f7,color:#11111b
style D fill:#fab387,color:#11111b
style E fill:#a6e3a1,color:#11111b

Más allá de la ABI C: variádicas y otras convenciones

La cadena "C" es la ABI más común, pero no la única. Rust admite otras convenciones cuando el símbolo foráneo las exige: "system" —que en Windows selecciona stdcall para las APIs del sistema y equivale a "C" en el resto—, o "C-unwind", que permite que un unwinding cruce la frontera de forma controlada en lugar de abortar. Y muchas funciones de C son variádicas, como printf: reciben un número variable de argumentos, declarados con ... tras los parámetros fijos.

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

unsafe extern "C" {
    fn printf(formato: *const c_char, ...) -> c_int;
}

Invocar una variádica es especialmente delicado: el compilador no comprueba que los argumentos casen con la cadena de formato, así que un tipo equivocado es UB inmediato. Es el recordatorio más crudo de que en la FFI la firma es una promesa que solo tú sostienes.

La FFI es el punto exacto donde la demostración cede el paso a la promesa

Todo el modelo de Rust descansa sobre una idea: que las propiedades de seguridad no se comprueban en ejecución ni se confían al programador, sino que se demuestran en compilación. El borrow checker no te pide que tengas cuidado; prueba que no puedes equivocarte. Esa es la diferencia entre Rust y C, y es toda la diferencia. La FFI es el lugar donde esa maquinaria se topa con su límite absoluto. Cuando escribes unsafe extern "C" { fn abs(input: c_int) -> c_int; }, el compilador no está leyendo el código de abs y verificando tu declaración contra él: no tiene ese código, vive en un .so compilado hace años por otro compilador de otro lenguaje. Lo único que tiene es tu afirmación. Si mientes —si el abs real recibe un long y tú declaraste c_int, o devuelve por un registro distinto— el compilador te creerá y generará código que corrompe la pila, y ninguna de las garantías de Rust te protegerá, porque todas presuponían que la firma era verdad. Por eso la palabra clave no es decorativa. unsafe no significa “código peligroso”; significa “aquí el compilador ha dejado de demostrar y ha empezado a confiar en ti”. Es una frontera epistemológica antes que técnica: marca dónde termina el conocimiento del sistema y empieza tu palabra. Entender esto reordena toda la FFI. No estás “desactivando la seguridad”; estás asumiendo, de forma explícita y localizada, la obligación de prueba que en el resto del programa lleva el compilador. Y como esa obligación es tuya, la ingeniería consiste en hacer la región de confianza tan pequeña, tan revisable y tan encapsulada como sea posible —para que la superficie donde puedes mentirte a ti mismo sea mínima—. El resto de este nivel es, en el fondo, el arte de reducir esa superficie.

📝
Lo esencial de llamar a C

Se declaran funciones foráneas en un bloque unsafe extern "C" (obligatorio en la edición 2024): solo la firma, sin cuerpo, con la ABI "C" fijando la convención de llamada. Cada declaración puede ser safe (sin precondiciones para el llamador) o unsafe (el llamador asume un contrato). La llamada va en unsafe porque el compilador no puede ver el cuerpo de C ni verificar la firma. El símbolo se resuelve al enlazar: libc es automática, el resto se nombra con #[link(name = "...")] o con directivas cargo:rustc-link-lib desde un build.rs.

⚔️ Cruza el puente hacia C
  1. Declara abs de libc en un unsafe extern "C" y llámala. Explica por qué la llamada exige unsafe aunque abs sea trivial.
  2. Marca abs como safe fn y comprueba que ahora la llamas sin unsafe. Argumenta qué garantías afirmas y cuáles no al hacerlo.
  3. Enlaza libm con #[link(name = "m")] y llama a cbrt o sqrt. Luego mueve el enlazado a un build.rs con cargo:rustc-link-lib.
  4. Declara strlen con la firma equivocada (por ejemplo devolviendo c_int en vez de usize) y pásale una cadena. Observa que compila y razona qué acabas de romper.
  5. Explica con tus palabras la frase “la FFI es donde la demostración cede el paso a la promesa” y relaciónala con por qué conviene minimizar la superficie unsafe.