用 Codex 写 Rust C 绑定:分层安全抽象的个人心得

40 阅读7分钟

最近我在折腾一个用 Rust 写的 NES 模拟器项目 —— nesium,目标除了把 NES 跑起来之外,更长远的想法是:把它做成一个 libretro core,让它可以接入 RetroArch 生态

RetroArch 是 libretro API 的官方参考前端,它本身不直接内置“模拟器”,而是通过加载一堆动态库(libretro cores)来运行各种平台的模拟器、游戏引擎和多媒体程序。只要核心遵守 libretro API,就可以在 RetroArch 统一的 UI 和输入/音频/视频管线下运行。

听起来很美好,问题在于:libretro 的世界是 C,而我的世界主要是 Rust。


我不熟 C,但又必须跟 C 打交道

老实说,我并不熟悉 C,而且每次写 C 我都觉得非常痛苦:

  • 需要时刻盯着指针、内存生命周期和各种约定;
  • 一不小心就可能踩到 UB;
  • 比起 Rust,那种“赤手空拳”的感觉很不安全。

Rust 虽然已经给了我们一个很强大的工具:bindgen,可以自动把 C 头文件生成为一堆 repr(C) 的 Rust 结构体和函数声明,让我们能够在 Rust 里直接调用 C API。

但在实践里我发现:

有了 bindgen,并不等于问题解决。

  • bindgen 生成的是原始 FFI 层,几乎完全按 C 头文件翻译;
  • 对调用方来说,这一层既不好用,也不安全,很容易滥用;
  • 真正要在项目里用得舒服,还得自己在上面再写一层 safe abstraction
    • 把裸指针包装成安全类型;
    • 把枚举和常量组合成更 Rusty 的 API;
    • 把回调整理成 trait / 闭包。

下面几段代码就是我和 Codex“配合”的大概样子。


从 bindgen 的 raw 模块开始

首先,用 build.rs + bindgenlibretro.h 生成为一个 raw 模块,大概长这样(简化版):

// raw.rs(由 bindgen 生成,略有删减)
#[repr(C)]
pub struct retro_system_info {
    pub library_name: *const ::std::os::raw::c_char,
    pub library_version: *const ::std::os::raw::c_char,
    pub valid_extensions: *const ::std::os::raw::c_char,
    pub need_fullpath: bool,
    pub block_extract: bool,
}

pub type retro_environment_t =
    Option<unsafe extern "C" fn(cmd: u32, data: *mut ::std::os::raw::c_void) -> bool>;

extern "C" {
    pub fn retro_init();
    pub fn retro_deinit();
    pub fn retro_run();
    // ...
}

这一层是典型的“看了就犯困”的 FFI 层:全是裸指针和 C 风格的类型,完全不适合直接在业务代码里使用

这一步,我一般只自己写 build.rs 和模块结构,具体的 FFI 声明交给 bindgen 和 Codex 搞定,比如提示词会写:

“根据 libretro.h 的内容,用 bindgen 风格生成 Rust FFI 声明,放到 raw 模块中,只保留必要的类型和函数。”

AI 在这里主要是帮忙生成/补齐那一长串声明,我只需要检查一下有没有明显问题。


在 FFI 之上搭一层 safe abstraction

真正让人愉快的是“上面这一层”:一个对 NES core 友好的 trait,以及一个运行时桥接层

例如,我希望 NES 核心只需要实现类似这样一个 trait:

pub trait LibretroCore {
    fn system_info(&self) -> SystemInfo;
    fn load_game(&mut self, game: GameLoadRequest) -> Result<(), CoreError>;
    fn unload_game(&mut self);
    fn run_frame(&mut self, io: &mut FrameIo) -> Result<(), CoreError>;
    fn reset(&mut self);
}

这些 Rust 类型是对 C 结构体的安全封装,例如:

pub struct SystemInfo {
    pub name: String,
    pub version: String,
    pub valid_extensions: String,
    pub need_fullpath: bool,
    pub block_extract: bool,
}

pub struct GameLoadRequest<'a> {
    pub path: Option<&'a std::path::Path>,
    pub data: Option<&'a [u8]>,
}

pub struct FrameIo<'a> {
    pub video: &'a mut [u32], // ARGB8888 或者其它格式
    pub audio_buffer: &'a mut [i16],
    pub input_state: &'a dyn InputState,
}

这些类型、trait 的骨架,我大部分是让 Codex 根据以下提示生成的:

“帮我基于 retro_system_inforetro_run 相关接口,设计一组 safe 的 Rust 封装类型和一个 LibretroCore trait,要求:

  • 不暴露裸指针;
  • 使用 Rust 的 String / slice / Path
  • 错误用 Result + 自定义 CoreError 表示。”

AI 通常会给出一个还不错的起点,我再手动调整字段命名、借用关系、生命周期参数等,让它更符合自己的审美和项目需求。


runtime:把 trait 接到 C 的入口上

有了 trait 和类型之后,还需要一个 runtime,把 libretro 要求的 C 接口实现出来,并转发到我们自己的 LibretroCore 实例上:

use crate::raw;
use crate::{LibretroCore, SystemInfo};

static mut CORE_INSTANCE: Option<Box<dyn LibretroCore + Send>> = None;

#[no_mangle]
pub unsafe extern "C" fn retro_get_system_info(info: *mut raw::retro_system_info) {
    if let Some(core) = CORE_INSTANCE.as_ref() {
        let sys = core.system_info();
        // 这里实际项目里应该有静态字符串池,这里仅作演示
        (*info).library_name = std::ffi::CString::new(sys.name)
            .unwrap()
            .into_raw();
        (*info).library_version = std::ffi::CString::new(sys.version)
            .unwrap()
            .into_raw();
        (*info).valid_extensions = std::ffi::CString::new(sys.valid_extensions)
            .unwrap()
            .into_raw();
        (*info).need_fullpath = sys.need_fullpath;
        (*info).block_extract = sys.block_extract;
    }
}

再比如 retro_run

#[no_mangle]
pub unsafe extern "C" fn retro_run() {
    use crate::platform::FrameBufferProvider;

    if let Some(core) = CORE_INSTANCE.as_mut() {
        // 从某个全局/线程本地的状态里拿到帧缓冲、音频缓冲、输入状态
        let mut fb = FrameBufferProvider::lock();
        let (mut video, mut audio, input) = fb.prepare_frame();

        let mut frame_io = FrameIo {
            video: &mut video,
            audio_buffer: &mut audio,
            input_state: &*input,
        };

        let _ = core.run_frame(&mut frame_io);
    }
}

像这种把 C 回调转换成 safe trait 调用的“桥接代码”,其实非常机械,但又不能随意 copy/paste,因为一不小心就会:

  • 漏掉某些字段;
  • 写错指针方向;
  • 对生命周期理解不一致。

这部分我通常就是用一句话丢给 Codex:

“根据 libretro API 的约定,实现 retro_get_system_info/retro_run 这些 extern "C" 函数,把它们转发到 LibretroCore 的方法上,并且尽量把 unsafe 收拢在边界。”

AI 会生成一版初稿,我再根据项目的实际结构(比如全局状态怎么管理、是否多线程)做最后修改。


AI 写完之后,让 clippy 和测试“做坏人”

比较有意思的一点是:AI 写代码还挺“听话”,但 clippy 更不会给面子

我一般会让 Codex 顺便帮我做这些事:

  1. 修 clippy 的 warning

    提示大概这么写:

    “帮我跑一下 cargo clippy,针对 warning 给出修改后的代码,不要改动逻辑,只修复风格和潜在问题。”

    在我的工作流里,Codex 确实会在远端环境里真实执行 cargo clippy,然后把 warning 和建议整理成编辑结果返回给我。VS Code/终端侧看到的已经是一版“过了一轮 clippy”的代码。我这边通常还是会本地再跑一遍 cargo clippy 确认一下,确保在自己机器上的构建环境里也是干净的。

  2. 生成基础测试

    比如一个很简单的“假 core”,只验证 runtime 的 glue 是否正常工作:

    #[cfg(test)]
    mod tests {
        use super::*;
    
        struct DummyCore;
    
        impl LibretroCore for DummyCore {
            fn system_info(&self) -> SystemInfo {
                SystemInfo {
                    name: "dummy".into(),
                    version: "0.0.1".into(),
                    valid_extensions: "nes|bin".into(),
                    need_fullpath: false,
                    block_extract: false,
                }
            }
    
            fn load_game(&mut self, _game: GameLoadRequest) -> Result<(), CoreError> {
                Ok(())
            }
    
            fn unload_game(&mut self) {}
    
            fn run_frame(&mut self, _io: &mut FrameIo) -> Result<(), CoreError> {
                Ok(())
            }
    
            fn reset(&mut self) {}
        }
    
        #[test]
        fn core_can_provide_system_info() {
            unsafe {
                CORE_INSTANCE = Some(Box::new(DummyCore));
                let mut raw_info = std::mem::MaybeUninit::<raw::retro_system_info>::uninit();
                retro_get_system_info(raw_info.as_mut_ptr());
                let info = raw_info.assume_init();
    
                assert!(!info.library_name.is_null());
                assert!(!info.library_version.is_null());
            }
        }
    }
    

    这种测试很“无聊”,但对确认 FFI glue 没写崩是挺有帮助的。大部分也是我让 Codex 帮我先写一版,再自己删减修改。


AI 真正提升的是“节奏感”,不是替你写完项目

这次给 NES 模拟器接 libretro 的经历,对我来说最明显的感受是:

AI 工具极大提高了“实现”的速度,但没有替代“设计”和“判断”。

如果让我手写这些 C 绑定、FFI 包装和 safe layer,绝对是一个非常折磨、节奏很慢的过程。现在有了 Codex:

  • 重复的样板代码几乎不用自己敲;
  • 大量接口转换可以交给 AI 生成初稿;
  • 我可以把精力集中在:
    • 抽象是不是合理;
    • 这个 trait 未来好不好扩展;
    • core 和 RetroArch 之间的边界是否清晰。

某种意义上,AI 把程序员从“苦力型码农”推向了更偏架构和审稿的角色

  • 提示词写得好不好,直接决定产出的质量;
  • 对领域理解不够深,AI 也只能生成“看起来对但实际上错”的代码;
  • 最后合不合理,还是要靠人来拍板。

小结

对我这个“不太会 C 又想接 RetroArch 的 Rust 玩家”来说,这次的体验大概可以总结为一句话:

AI 让“写完一套 Rust C 绑定并包上一层安全抽象”这件事情,从“几乎不想做”变成了“可以愉快地折腾”。

它节省的是大量机械劳动,让我有更多精力去打磨 nesium NES 模拟器本身的准确性和性能,也更有动力去探索 libretro / RetroArch 这一整套生态。

后面如果我真的把 NES core 成功接进 RetroArch,大概也有一大半功劳要分给 Codex —— 以及那些不断对我代码碎碎念的 cargo clippy。😄