Skip to content

Latest commit

 

History

History
952 lines (722 loc) · 31.6 KB

File metadata and controls

952 lines (722 loc) · 31.6 KB

🔝 Retour au Sommaire

43.3.1 — extern "C" et liaison manuelle

Module 15 : Interopérabilité · Niveau Expert


Introduction

La FFI manuelle (Foreign Function Interface) est le mécanisme le plus fondamental pour faire communiquer C++ et Rust. Le principe est identique à ce qui a été décrit en section 43.1 pour l'interopérabilité C++/C : les deux langages se mettent d'accord sur une frontière définie par l'ABI C — des fonctions extern "C" avec des types C purs, sans mangling, sans exceptions, sans templates.

Cette approche est verbeuse et exigeante en rigueur. Chaque fonction doit être déclarée des deux côtés. Chaque type traversant la frontière doit être un type C. La gestion de la mémoire et des erreurs doit être explicitement coordonnée. Mais c'est aussi l'approche la plus transparente — il n'y a aucune magie, aucune génération de code, aucune dépendance tierce. Comprendre ce mécanisme en profondeur est un prérequis pour utiliser ensuite cxx ou autocxx de manière éclairée, et c'est le fallback quand ces outils de plus haut niveau atteignent leurs limites.


43.3.1.1 — L'ABI C comme terrain d'entente

Côté C++

Le mécanisme est celui de la section 43.1 : extern "C" désactive le name mangling et impose la convention d'appel C.

// engine.h — Header C-compatible (identique à la section 43.1)
#ifndef ENGINE_H
#define ENGINE_H

#include <stdint.h>
#include <stddef.h>

#ifdef __cplusplus
extern "C" {
#endif

typedef struct engine engine_t;

engine_t*   engine_create(const char* config_path);  
void        engine_destroy(engine_t* e);  
int32_t     engine_process(engine_t* e, const uint8_t* data, size_t len);  
double      engine_get_result(const engine_t* e);  
const char* engine_last_error(const engine_t* e);  

#ifdef __cplusplus
}
#endif

#endif // ENGINE_H

L'implémentation C++ est libre d'utiliser toute la richesse du langage à l'intérieur des fonctions :

// engine.cpp
#include "engine.h"
#include <string>
#include <vector>
#include <numeric>
#include <cstring>

struct engine {
    std::string              config_path;
    std::vector<uint8_t>     buffer;
    double                   result = 0.0;
    std::string              last_error;
};

extern "C" engine_t* engine_create(const char* config_path) {
    try {
        auto* e = new engine();
        e->config_path = config_path ? config_path : "";
        return e;
    } catch (const std::exception& ex) {
        return nullptr;
    }
}

extern "C" void engine_destroy(engine_t* e) {
    delete e;
}

extern "C" int32_t engine_process(engine_t* e, const uint8_t* data, size_t len) {
    if (!e || !data || len == 0) return -1;
    try {
        e->buffer.assign(data, data + len);
        double sum = std::accumulate(e->buffer.begin(), e->buffer.end(), 0.0);
        e->result = sum / static_cast<double>(len);
        return 0;
    } catch (const std::exception& ex) {
        e->last_error = ex.what();
        return -1;
    }
}

extern "C" double engine_get_result(const engine_t* e) {
    return e ? e->result : 0.0;
}

extern "C" const char* engine_last_error(const engine_t* e) {
    if (!e) return "null engine";
    return e->last_error.empty() ? nullptr : e->last_error.c_str();
}

Jusqu'ici, rien de nouveau par rapport à la section 43.1. La différence commence côté consommateur : au lieu d'un programme C, c'est du Rust.

Côté Rust

Rust déclare les fonctions C externes dans un bloc extern "C" — la même syntaxe qu'en C++, avec la même signification (linkage C, pas de mangling). L'appel de ces fonctions est unsafe car Rust ne peut pas vérifier les garanties mémoire du code étranger.

// src/ffi.rs — Déclarations FFI manuelles
use std::os::raw::c_char;  
use std::ffi::{CStr, CString};  

// Type opaque — Rust ne connaît pas la structure interne
#[repr(C)]
pub struct Engine {
    _opaque: [u8; 0],  // Type de taille zéro — Rust ne peut pas l'instancier
}

extern "C" {
    pub fn engine_create(config_path: *const c_char) -> *mut Engine;
    pub fn engine_destroy(e: *mut Engine);
    pub fn engine_process(e: *mut Engine, data: *const u8, len: usize) -> i32;
    pub fn engine_get_result(e: *const Engine) -> f64;
    pub fn engine_last_error(e: *const Engine) -> *const c_char;
}

Les correspondances de types entre les deux langages méritent une attention particulière.


43.3.1.2 — Correspondance des types à la frontière

Types scalaires

Les types C utilisés à la frontière FFI ont des équivalents exacts en Rust :

Type C/C++ Type Rust Taille garantie
int8_t i8 1 octet
uint8_t u8 1 octet
int16_t i16 2 octets
uint16_t u16 2 octets
int32_t i32 4 octets
uint32_t u32 4 octets
int64_t i64 8 octets
uint64_t u64 8 octets
float f32 4 octets
double f64 8 octets
size_t usize Taille du pointeur
bool bool 1 octet (avec précautions)

Attention au bool : la représentation de bool n'est pas standardisée de manière identique entre C, C++ et Rust. En pratique, les trois utilisent 1 octet avec 0 = false et 1 = true sur les plateformes courantes, mais le standard C autorise d'autres valeurs non nulles. À la frontière FFI, préférer int32_t / i32 pour les booléens si la portabilité absolue est requise.

Types non scalaires : int et long

Les types C int et long n'ont pas de taille garantie. int est généralement 32 bits mais pas toujours. long est 32 bits sur Windows 64-bit et 64 bits sur Linux 64-bit. Rust fournit std::os::raw::c_int et std::os::raw::c_long pour ces types, mais la recommandation en interopérabilité C++/Rust est d'utiliser exclusivement les types à taille fixe (int32_t / i32, int64_t / i64) dans les signatures FFI.

Pointeurs

Type C/C++ Type Rust Sémantique
T* *mut T Pointeur mutable
const T* *const T Pointeur constant
void* *mut std::ffi::c_void Pointeur opaque
nullptr std::ptr::null() / std::ptr::null_mut() Pointeur nul

Les pointeurs bruts en Rust (*const T, *mut T) ont la même sémantique et la même représentation mémoire que les pointeurs C. Leur déréférencement est unsafe.

Chaînes de caractères

Les chaînes de caractères sont le type le plus délicat à la frontière, car les deux langages ont des représentations fondamentalement différentes :

Propriété C/C++ (const char*) Rust (String / &str)
Terminaison Null-terminated (\0) Non null-terminated (longueur stockée)
Encodage Non garanti (souvent UTF-8 ou ASCII) UTF-8 garanti
Mutabilité Via char* String est mutable, &str est immuable

Rust fournit deux types dédiés pour l'interopérabilité avec les C strings :

  • CString — chaîne Rust allouée, null-terminated, à passer à C/C++.
  • CStr — emprunt (borrow) d'une chaîne C null-terminated reçue de C/C++.
use std::ffi::{CStr, CString};  
use std::os::raw::c_char;  

// Rust → C++ : créer une CString à passer au code C++
fn call_cpp_function(config: &str) {
    let c_config = CString::new(config)
        .expect("config contains null byte");

    unsafe {
        let engine = engine_create(c_config.as_ptr());
        // c_config doit rester vivante tant que engine_create utilise le pointeur
        // ...
        engine_destroy(engine);
    }
}

// C++ → Rust : recevoir un const char* du code C++
fn get_error_message(engine: *const Engine) -> Option<String> {
    unsafe {
        let ptr = engine_last_error(engine);
        if ptr.is_null() {
            return None;
        }
        // CStr emprunte les données pointées par ptr — pas de copie
        let c_str = CStr::from_ptr(ptr);
        // to_str() vérifie la validité UTF-8
        Some(c_str.to_str().unwrap_or("invalid UTF-8").to_owned())
    }
}

Piège critique : la durée de vie de CString. Une erreur fréquente est de créer un CString dans une expression temporaire :

// ERREUR — dangling pointer
unsafe {
    // CString est détruit à la fin de l'expression → pointeur invalide
    let ptr = CString::new("config").unwrap().as_ptr();
    engine_create(ptr);  // ptr pointe vers de la mémoire libérée !
}

// CORRECT — CString reste vivante pendant l'utilisation du pointeur
unsafe {
    let config = CString::new("config").unwrap();
    engine_create(config.as_ptr());  // OK — config est encore vivante
}

Structures

Pour qu'une structure traverse la frontière, elle doit avoir un layout mémoire compatible. En Rust, cela nécessite l'annotation #[repr(C)] qui force le compilateur à utiliser le layout C (même ordre des champs, même règles d'alignement et de padding) :

// C++
struct Point {
    double x;
    double y;
    int32_t id;
};
// Rust — #[repr(C)] garantit un layout identique au C++
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct Point {
    pub x: f64,
    pub y: f64,
    pub id: i32,
}

Sans #[repr(C)], le compilateur Rust est libre de réordonner les champs et d'ajuster le padding pour des raisons d'optimisation. Le layout ne serait alors pas compatible avec celui du C++.

Types opaques

Pour les types C++ complexes (classes avec vtable, conteneurs STL, templates instanciés), la stratégie est le handle opaque — exactement comme en section 43.1. Le code Rust ne voit qu'un pointeur vers un type de taille inconnue :

// Type opaque — Rust ne peut ni l'instancier ni lire ses champs
#[repr(C)]
pub struct Engine {
    _opaque: [u8; 0],
}

La convention [u8; 0] (tableau de taille zéro) est l'idiome Rust standard pour déclarer un type opaque FFI. Il ne peut pas être instancié directement par Rust et n'est manipulable que via des pointeurs.


43.3.1.3 — Wrapper safe en Rust

Le pattern RAII Rust (Drop)

Le code FFI brut (unsafe, pointeurs nus) ne doit pas fuiter dans le code applicatif Rust. La pratique standard est d'encapsuler les appels FFI dans un wrapper safe qui gère automatiquement la durée de vie :

// src/engine.rs — Wrapper safe autour de l'API C
use crate::ffi;  
use std::ffi::{CStr, CString};  
use std::fmt;  

/// Wrapper safe autour de l'engine C++.
/// L'engine est détruite automatiquement quand ce wrapper est droppé.
pub struct Engine {
    ptr: *mut ffi::Engine,
}

// Le pointeur brut n'est pas Send/Sync par défaut.
// On l'affirme manuellement si l'engine C++ est thread-safe.
// unsafe impl Send for Engine {}

impl Engine {
    /// Crée un nouvel engine à partir d'un chemin de configuration.
    pub fn new(config_path: &str) -> Result<Self, String> {
        let c_path = CString::new(config_path)
            .map_err(|e| format!("Invalid config path: {}", e))?;

        let ptr = unsafe { ffi::engine_create(c_path.as_ptr()) };
        if ptr.is_null() {
            return Err("engine_create returned null".into());
        }

        Ok(Engine { ptr })
    }

    /// Traite un bloc de données.
    pub fn process(&mut self, data: &[u8]) -> Result<(), String> {
        let rc = unsafe {
            ffi::engine_process(self.ptr, data.as_ptr(), data.len())
        };

        if rc != 0 {
            return Err(self.last_error().unwrap_or_else(|| "unknown error".into()));
        }
        Ok(())
    }

    /// Récupère le résultat du dernier traitement.
    pub fn result(&self) -> f64 {
        unsafe { ffi::engine_get_result(self.ptr) }
    }

    /// Récupère le dernier message d'erreur.
    fn last_error(&self) -> Option<String> {
        unsafe {
            let ptr = ffi::engine_last_error(self.ptr);
            if ptr.is_null() {
                return None;
            }
            CStr::from_ptr(ptr)
                .to_str()
                .ok()
                .map(|s| s.to_owned())
        }
    }
}

impl Drop for Engine {
    fn drop(&mut self) {
        unsafe {
            ffi::engine_destroy(self.ptr);
        }
    }
}

impl fmt::Debug for Engine {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "Engine({:p})", self.ptr)
    }
}

Utilisation depuis le code applicatif

Le code applicatif Rust n'a aucun contact avec unsafe, les pointeurs bruts ou les types FFI :

// src/main.rs
mod ffi;  
mod engine;  

use engine::Engine;

fn main() -> Result<(), String> {
    let mut eng = Engine::new("/etc/myapp/config.toml")?;

    let data = vec![10u8, 20, 30, 40, 50];
    eng.process(&data)?;

    println!("Result: {}", eng.result());

    Ok(())
    // eng est automatiquement droppé ici → engine_destroy appelé
}

Ce pattern est l'exact analogue Rust du RAII C++ (section 6.3). Le Drop trait joue le rôle du destructeur, et le Result<T, E> joue le rôle de la conversion exception → code d'erreur décrite en section 43.1.5 — mais dans l'autre sens : les codes d'erreur C sont convertis en Result Rust.


43.3.1.4 — Appeler du Rust depuis C++

Le sens inverse — appeler des fonctions Rust depuis C++ — suit exactement le même principe. Le code Rust expose des fonctions extern "C" que le C++ peut appeler via des déclarations extern "C" dans un header.

Côté Rust

// lib.rs — Bibliothèque Rust exposant une API C
use std::ffi::{CStr, CString};  
use std::os::raw::c_char;  
use std::ptr;  

/// Structure Rust interne — invisible depuis C++
struct Hasher {
    state: u64,
    error: Option<CString>,
}

/// Handle opaque pour le C++
pub struct HasherHandle(Hasher);

#[no_mangle]
pub extern "C" fn hasher_create() -> *mut HasherHandle {
    let hasher = Hasher {
        state: 0,
        error: None,
    };
    Box::into_raw(Box::new(HasherHandle(hasher)))
}

#[no_mangle]
pub extern "C" fn hasher_destroy(h: *mut HasherHandle) {
    if !h.is_null() {
        unsafe {
            drop(Box::from_raw(h));  // Reprend la propriété et libère
        }
    }
}

#[no_mangle]
pub extern "C" fn hasher_update(h: *mut HasherHandle, data: *const u8, len: usize) -> i32 {
    if h.is_null() || data.is_null() {
        return -1;
    }

    let hasher = unsafe { &mut (*h).0 };
    let slice = unsafe { std::slice::from_raw_parts(data, len) };

    // Logique métier Rust — ici un hash simplifié
    for &byte in slice {
        hasher.state = hasher.state.wrapping_mul(31).wrapping_add(byte as u64);
    }

    0  // Succès
}

#[no_mangle]
pub extern "C" fn hasher_finalize(h: *const HasherHandle) -> u64 {
    if h.is_null() {
        return 0;
    }
    unsafe { (*h).0.state }
}

#[no_mangle]
pub extern "C" fn hasher_last_error(h: *const HasherHandle) -> *const c_char {
    if h.is_null() {
        return ptr::null();
    }
    unsafe {
        match &(*h).0.error {
            Some(err) => err.as_ptr(),
            None => ptr::null(),
        }
    }
}

Trois éléments clés dans ce code :

#[no_mangle] — désactive le name mangling Rust. Sans cet attribut, le symbole exporté aurait un nom modifié incompréhensible pour le linker C++.

extern "C" — impose la convention d'appel C, exactement comme en C++.

Box::into_raw / Box::from_raw — le pattern d'allocation/désallocation pour les handles opaques en Rust. Box::into_raw alloue sur le heap et retourne un pointeur brut sans libérer la mémoire (transfert de propriété vers C++). Box::from_raw reprend la propriété du pointeur et libère la mémoire quand la Box est droppée.

Configuration Cargo

Le Cargo.toml doit spécifier le type de crate comme bibliothèque C :

[package]
name = "myhasher"  
version = "0.1.0"  
edition = "2021"  

[lib]
crate-type = ["staticlib"]  # Produit un .a (bibliothèque statique)
# Ou :
# crate-type = ["cdylib"]   # Produit un .so (bibliothèque dynamique)
  • staticlib — produit un fichier .a (archive statique) contenant le code Rust et le runtime Rust. Le binaire final est autonome.
  • cdylib — produit un fichier .so (bibliothèque dynamique) avec linkage C. Plus léger, mais nécessite que les bibliothèques Rust soient disponibles à l'exécution.

Pour les projets embarquant Rust dans du C++, staticlib est généralement préféré : il élimine les dépendances dynamiques au runtime Rust.

Compilation

cargo build --release
# Produit : target/release/libmyhasher.a (staticlib)
# Ou :     target/release/libmyhasher.so (cdylib)

Côté C++

Le C++ déclare les fonctions Rust comme des fonctions C externes :

// hasher.h — Déclarations de l'API Rust
#ifndef HASHER_H
#define HASHER_H

#include <stdint.h>
#include <stddef.h>

#ifdef __cplusplus
extern "C" {
#endif

typedef struct HasherHandle HasherHandle;

HasherHandle* hasher_create(void);  
void          hasher_destroy(HasherHandle* h);  
int32_t       hasher_update(HasherHandle* h, const uint8_t* data, size_t len);  
uint64_t      hasher_finalize(const HasherHandle* h);  
const char*   hasher_last_error(const HasherHandle* h);  

#ifdef __cplusplus
}
#endif

#endif // HASHER_H
// main.cpp — Utilisation depuis C++
#include "hasher.h"
#include <cstdio>
#include <cstring>

int main() {
    HasherHandle* h = hasher_create();
    if (!h) {
        std::fprintf(stderr, "Failed to create hasher\n");
        return 1;
    }

    const char* message = "Hello from C++";
    int32_t rc = hasher_update(h,
        reinterpret_cast<const uint8_t*>(message),
        std::strlen(message));

    if (rc != 0) {
        const char* err = hasher_last_error(h);
        std::fprintf(stderr, "Error: %s\n", err ? err : "unknown");
        hasher_destroy(h);
        return 1;
    }

    uint64_t hash = hasher_finalize(h);
    std::printf("Hash: %lu\n", hash);

    hasher_destroy(h);
    return 0;
}

Compilation et linkage

# Compiler la bibliothèque Rust
cargo build --release

# Compiler le C++ et lier avec la bibliothèque Rust statique
g++ -std=c++20 -o main main.cpp \
    -L target/release \
    -lmyhasher \
    -lpthread -ldl -lm  # Dépendances du runtime Rust (staticlib)

./main
# Hash: 2997812530867451

Les flags -lpthread -ldl -lm sont nécessaires quand on lie avec un staticlib Rust : le runtime Rust dépend de ces bibliothèques système. Pour un cdylib, ces dépendances sont résolues dans le .so lui-même.


43.3.1.5 — Gestion des erreurs à la frontière

La règle : ni exceptions ni panics

Comme pour la frontière C++/C (section 43.1.5), les exceptions C++ ne doivent jamais traverser la frontière vers Rust. Symétriquement, un panic Rust ne doit jamais traverser la frontière vers C++. Dans les deux cas, le comportement est indéfini.

Côté C++ → Rust : exceptions → codes d'erreur

C'est le pattern déjà vu en section 43.1 : chaque fonction extern "C" côté C++ contient un try/catch qui convertit les exceptions en codes de retour.

Côté Rust → C++ : panics → codes d'erreur

Rust utilise std::panic::catch_unwind pour intercepter les panics avant la frontière FFI :

use std::panic;

#[no_mangle]
pub extern "C" fn hasher_update(h: *mut HasherHandle, data: *const u8, len: usize) -> i32 {
    if h.is_null() || data.is_null() {
        return -1;
    }

    let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
        let hasher = unsafe { &mut (*h).0 };
        let slice = unsafe { std::slice::from_raw_parts(data, len) };

        for &byte in slice {
            hasher.state = hasher.state.wrapping_mul(31).wrapping_add(byte as u64);
        }
    }));

    match result {
        Ok(()) => 0,
        Err(_) => {
            // Le panic a été intercepté — stocker un message d'erreur
            let hasher = unsafe { &mut (*h).0 };
            hasher.error = CString::new("internal panic in hasher_update").ok();
            -1
        }
    }
}

Précaution : catch_unwind ne capture que les panics de type unwind (le mode par défaut). Si le profil de compilation Rust est configuré avec panic = "abort", les panics provoquent un arrêt immédiat du programme et catch_unwind est inopérant. Pour les bibliothèques FFI, s'assurer que le profil utilise panic = "unwind" :

# Cargo.toml
[profile.release]
panic = "unwind"  # Nécessaire pour catch_unwind

Convention de codes d'erreur

Une convention claire et documentée est indispensable :

// Convention partagée entre C++ et Rust
// 0     = succès
// -1    = erreur de paramètre (null pointer, taille invalide)
// -2    = erreur de traitement interne
// -3    = erreur d'allocation mémoire
// -100  = panic Rust intercepté / exception C++ non standard

43.3.1.6 — Callbacks : passer des fonctions entre les deux langages

Passer un callback C++ à Rust

Le mécanisme standard est le pointeur de fonction C :

// C++ — Type du callback et API
extern "C" {
    typedef void (*log_callback_t)(int32_t level, const char* message);
    void engine_set_logger(engine_t* e, log_callback_t callback);
}
// Rust — Recevoir et stocker le callback
type LogCallback = extern "C" fn(level: i32, message: *const c_char);

struct Engine {
    // ...
    logger: Option<LogCallback>,
}

#[no_mangle]
pub extern "C" fn engine_set_logger(e: *mut Engine, callback: LogCallback) {
    if !e.is_null() {
        unsafe { (*e).logger = Some(callback); }
    }
}

// Appeler le callback depuis Rust
fn log_message(engine: &Engine, level: i32, msg: &str) {
    if let Some(callback) = engine.logger {
        if let Ok(c_msg) = CString::new(msg) {
            callback(level, c_msg.as_ptr());
        }
    }
}

Passer une closure C++ (avec contexte)

Un pointeur de fonction C ne peut pas capturer de contexte (pas de closure). Pour passer un callback avec état, on utilise le pattern classique C : un pointeur de fonction + un void* (user data) :

// C++ — Callback avec contexte
extern "C" {
    typedef void (*log_callback_t)(void* user_data, int32_t level, const char* message);
    void engine_set_logger(engine_t* e, log_callback_t callback, void* user_data);
}

// Utilisation côté C++
class MyLogger {  
public:  
    static void log_handler(void* ctx, int32_t level, const char* msg) {
        auto* self = static_cast<MyLogger*>(ctx);
        self->handle_log(level, msg);
    }

    void handle_log(int32_t level, const char* msg) {
        // ... logique avec état interne ...
    }
};

MyLogger logger;  
engine_set_logger(engine, MyLogger::log_handler, &logger);  
// Rust — Recevoir et invoquer le callback avec contexte
type LogCallback = extern "C" fn(user_data: *mut c_void, level: i32, msg: *const c_char);

struct Engine {
    logger: Option<LogCallback>,
    logger_ctx: *mut c_void,
}

fn log_message(engine: &Engine, level: i32, msg: &str) {
    if let Some(callback) = engine.logger {
        if let Ok(c_msg) = CString::new(msg) {
            // unsafe : on fait confiance au C++ pour la validité de logger_ctx
            unsafe {
                callback(engine.logger_ctx, level, c_msg.as_ptr());
            }
        }
    }
}

43.3.1.7 — bindgen : générer les déclarations Rust automatiquement

Le problème de la maintenance

Pour une API avec 5 fonctions, écrire les déclarations extern "C" manuellement des deux côtés est gérable. Pour une API avec 50 ou 200 fonctions, c'est une source d'erreurs et un cauchemar de maintenance : chaque modification du header C doit être répercutée manuellement dans le code Rust.

bindgen

bindgen est un outil qui parse un header C/C++ et génère automatiquement les déclarations Rust correspondantes. Il utilise libclang pour comprendre les types, les structures, les enums et les signatures de fonctions.

# Installation
cargo install bindgen-cli
# Générer les bindings Rust depuis le header C
bindgen engine.h -o src/ffi_generated.rs

Le fichier généré contient automatiquement les déclarations extern "C", les structures #[repr(C)], les enums, les constantes et les types :

// src/ffi_generated.rs — Généré automatiquement par bindgen
/* automatically generated by rust-bindgen */

#[repr(C)]
pub struct engine {
    _unused: [u8; 0],
}
pub type engine_t = engine;

extern "C" {
    pub fn engine_create(config_path: *const ::std::os::raw::c_char) -> *mut engine_t;
    pub fn engine_destroy(e: *mut engine_t);
    pub fn engine_process(
        e: *mut engine_t,
        data: *const u8,
        len: usize,
    ) -> i32;
    pub fn engine_get_result(e: *const engine_t) -> f64;
    pub fn engine_last_error(e: *const engine_t) -> *const ::std::os::raw::c_char;
}

Intégration dans le build (build.rs)

La méthode recommandée est d'exécuter bindgen automatiquement pendant le build Rust, via un script build.rs :

// build.rs
fn main() {
    // Générer les bindings FFI
    let bindings = bindgen::Builder::default()
        .header("engine.h")
        .allowlist_function("engine_.*")     // Ne générer que les fonctions engine_*
        .allowlist_type("engine_t")
        .parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
        .generate()
        .expect("Unable to generate bindings");

    let out_path = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap());
    bindings
        .write_to_file(out_path.join("bindings.rs"))
        .expect("Couldn't write bindings");

    // Indiquer à Cargo où trouver la bibliothèque C++ à lier
    println!("cargo:rustc-link-search=native=build/");
    println!("cargo:rustc-link-lib=static=engine");
    println!("cargo:rerun-if-changed=engine.h");
}
# Cargo.toml
[build-dependencies]
bindgen = "0.71"
// src/ffi.rs — Inclure les bindings générés
#![allow(non_upper_case_globals)]
#![allow(non_camel_case_types)]
#![allow(non_snake_case)]

include!(concat!(env!("OUT_DIR"), "/bindings.rs"));

Avec cette configuration, chaque modification du header C déclenche automatiquement une régénération des bindings Rust lors du cargo build. Les erreurs de concordance entre le header C et le code Rust sont détectées au compile-time.

Limites de bindgen

bindgen fonctionne très bien pour les headers C purs. Pour les headers C++, le support est partiel :

  • Les fonctions et structures extern "C" sont bien gérées.
  • Les classes C++ avec méthodes, héritage et vtable ne sont pas supportées directement — bindgen ne génère que les déclarations des fonctions et types C.
  • Les templates ne sont pas instanciés par bindgen.
  • Les surcharges de fonctions ne sont pas supportées (elles n'existent pas en C).

C'est pourquoi le pattern recommandé reste de créer un header C-compatible (le pattern #ifdef __cplusplus de la section 43.1.3) devant le code C++, et d'utiliser bindgen sur ce header C.


43.3.1.8 — Intégration CMake + Cargo

Le défi du build system mixte

Un projet C++/Rust implique deux systèmes de build : CMake pour le C++ et Cargo pour le Rust. Les deux doivent se coordonner pour que les bibliothèques de l'un soient accessibles au linkage de l'autre.

Approche 1 : CMake invoque Cargo

CMake pilote le build global et invoque cargo build comme une commande externe :

cmake_minimum_required(VERSION 3.24)  
project(hybrid LANGUAGES CXX)  

# ─── Bibliothèque Rust (compilée via Cargo) ──────────────────
set(RUST_LIB_DIR "${CMAKE_SOURCE_DIR}/rust-lib")  
set(RUST_TARGET_DIR "${RUST_LIB_DIR}/target/release")  

add_custom_command(
    OUTPUT ${RUST_TARGET_DIR}/libmyhasher.a
    COMMAND cargo build --release --manifest-path ${RUST_LIB_DIR}/Cargo.toml
    WORKING_DIRECTORY ${RUST_LIB_DIR}
    COMMENT "Building Rust library..."
    DEPENDS ${RUST_LIB_DIR}/src/lib.rs ${RUST_LIB_DIR}/Cargo.toml
)

add_custom_target(rust_lib DEPENDS ${RUST_TARGET_DIR}/libmyhasher.a)

# Créer une target CMake importée
add_library(myhasher STATIC IMPORTED)  
set_target_properties(myhasher PROPERTIES  
    IMPORTED_LOCATION ${RUST_TARGET_DIR}/libmyhasher.a
)
add_dependencies(myhasher rust_lib)

# ─── Exécutable C++ ──────────────────────────────────────────
add_executable(main src/main.cpp)  
target_link_libraries(main PRIVATE myhasher pthread dl m)  
target_include_directories(main PRIVATE ${RUST_LIB_DIR}/include)  

Approche 2 : le crate corrosion

Le projet corrosion fournit une intégration CMake/Cargo plus propre :

cmake_minimum_required(VERSION 3.24)  
project(hybrid LANGUAGES CXX)  

include(FetchContent)  
FetchContent_Declare(  
    Corrosion
    GIT_REPOSITORY https://github.com/corrosion-rs/corrosion.git
    GIT_TAG v0.5
    GIT_SHALLOW TRUE
)
FetchContent_MakeAvailable(Corrosion)

# Importer la bibliothèque Rust comme une target CMake
corrosion_import_crate(MANIFEST_PATH rust-lib/Cargo.toml)

# L'exécutable C++ lie directement avec la target Rust
add_executable(main src/main.cpp)  
target_link_libraries(main PRIVATE myhasher)  

Corrosion gère automatiquement l'invocation de Cargo, la détection du type de bibliothèque (staticlib / cdylib), les dépendances système et les chemins de recherche. C'est la méthode recommandée pour les projets professionnels.


43.3.1.9 — Limites de la FFI manuelle

La FFI manuelle fonctionne et produit un code à l'overhead minimal. Mais ses limites deviennent évidentes dès que l'interface grandit :

Verbosité. Chaque fonction doit être déclarée trois fois : dans le header C, dans l'implémentation C++, et dans les bindings Rust (ou générée par bindgen). L'ajout d'un seul paramètre nécessite des modifications synchronisées dans trois fichiers.

Types limités. Seuls les types C traversent la frontière. Passer un std::string ou un Vec<u8> nécessite une conversion manuelle en const char* / *const u8 + taille. Les types riches des deux langages sont inaccessibles.

Sécurité de type inexistante à la frontière. Le compilateur ne vérifie pas la cohérence entre les déclarations C++ et Rust. Si le C++ déclare int32_t et le Rust déclare i64, le code compile sans erreur et échoue silencieusement à l'exécution.

Pas de support direct pour les types C++ complexes. Les classes, les templates, les enums C++ avec méthodes, les smart pointers, les conteneurs STL — rien de tout cela ne traverse la frontière sans un wrapping C manuel.

C'est exactement le problème que cxx résout : fournir une vérification de type à la compilation entre C++ et Rust, avec un support direct pour des types plus riches que les seuls types C.


Résumé

Aspect Mécanisme
Frontière extern "C" des deux côtés (C++ et Rust)
Types Types C uniquement (i32, f64, *const T, *mut T)
Ownership Conventions manuelles (create/destroy, Box::into_raw/from_raw)
Erreurs Codes de retour — jamais d'exceptions ni de panics
Strings CString / CStr côté Rust, const char* côté C++
Structures #[repr(C)] obligatoire côté Rust
Génération bindgen (C headers → Rust declarations)
Build system CMake + Cargo (via corrosion ou custom_command)

La FFI manuelle est le socle. Elle est nécessaire pour comprendre les outils de plus haut niveau, et elle reste le fallback quand ces outils ne couvrent pas un cas particulier. Mais pour une interface de taille réelle, les outils présentés dans les sections suivantes — cxx et autocxx — offrent une productivité et une sécurité incomparables.

📎 La section suivante (43.3.2) présente cxx, le bridge C++ ↔ Rust qui élimine la majorité des problèmes décrits ici en fournissant une vérification de type bidirectionnelle au compile-time.

⏭️ cxx : Le bridge Rust↔C++ de référence