29、协程与 C++ 互操作:在 C++ 项目中调用 C 协程库

说实话,做嵌入式开发这么多年,我见过太多人在 C 和 C++ 的边界上栽跟头。尤其是协程这种带状态、带上下文的玩意儿,一旦跨语言调用,坑就特别多。今天咱们就聊聊,怎么在 C++ 项目里优雅地调用 C 协程库。

为什么需要互操作?

你可能会问:既然 C++ 有 std::coroutine,为什么还要用 C 写的协程库?

原因其实很现实。我在项目中遇到过好几次:底层驱动、RTOS 调度器、或者某些硬件抽象层,都是用纯 C 写的。这些模块里可能已经有一套成熟的协程机制,比如 protothreads、libco 或者自己手写的状态机协程。上层业务逻辑却是 C++ 写的,总不能为了用协程把整个底层重写一遍吧?

说白了,C 协程库的优势在于轻量、可控、无异常开销。而 C++ 的优势在于 RAII、模板、类型安全。两者结合,才是实战中的常态。

核心挑战:ABI 兼容性

C 和 C++ 的 ABI(应用程序二进制接口)不完全一样。C++ 有名字修饰(name mangling)、有异常处理、有 this 指针传递规则。而 C 的 ABI 简单直接。

要让 C++ 调用 C 协程库,第一件事就是:用 extern "C" 包裹所有 C 接口

关键点:协程上下文(如 jmp_buf、ucontext_t、或者自定义的协程结构体)在 C 和 C++ 中的内存布局必须一致。C++ 的类如果带有虚函数表或 RTTI,不能直接当作 C 协程上下文传递。

实战:在 C++ 中封装 C 协程

假设我们有一个 C 协程库,核心接口如下:

// c_coroutine.h
#ifdef __cplusplus
extern "C" {
#endif

typedef struct coroutine coroutine_t;

coroutine_t* co_create(void (*entry)(void*), void* arg);
void co_resume(coroutine_t* co);
void co_yield(void);
int co_finished(coroutine_t* co);
void co_destroy(coroutine_t* co);

#ifdef __cplusplus
}
#endif

嗯,这里要注意:co_yield 是全局函数,它需要知道当前正在运行的协程是谁。通常 C 协程库会用线程局部存储(TLS)来保存当前协程指针。

在 C++ 中,我们想把它包装成一个类,用 RAII 管理生命周期:

// cpp_coroutine.hpp
#include "c_coroutine.h"
#include <functional>
#include <memory>

class Coroutine {
public:
    using Func = std::function<void()>;

    explicit Coroutine(Func f) : func_(std::move(f)) {
        // 注意:这里把 C++ 的 lambda 包装成 C 函数指针
        auto wrapper = [](void* arg) {
            auto* self = static_cast<Coroutine*>(arg);
            self->func_();
        };
        co_ = co_create(wrapper, this);
    }

    ~Coroutine() {
        if (co_) co_destroy(co_);
    }

    // 禁止拷贝,允许移动
    Coroutine(const Coroutine&) = delete;
    Coroutine& operator=(const Coroutine&) = delete;

    Coroutine(Coroutine&& other) noexcept : co_(other.co_), func_(std::move(other.func_)) {
        other.co_ = nullptr;
    }

    void resume() {
        if (!co_finished(co_)) {
            co_resume(co_);
        }
    }

    bool finished() const {
        return co_finished(co_);
    }

    // 静态方法:让 C 协程库调用
    static void yield() {
        co_yield();
    }

private:
    coroutine_t* co_ = nullptr;
    Func func_;
};

个人经验:这里有个坑——C 协程的 entry 函数是 void (*)(void*),而 C++ lambda 捕获了 this 指针。如果 lambda 的生命周期比协程短,协程恢复执行时就会访问野指针。我建议用 shared_ptr 管理协程对象,或者确保协程在对象销毁前已经结束。

避坑指南:异常安全

C 协程库通常不处理 C++ 异常。如果协程函数内部抛出了异常,而 C 的协程调度器没有对应的栈展开机制,后果就是——程序直接崩溃。

我曾经在一个项目中,C++ 业务层用协程做异步 I/O,结果网络库抛了个 std::system_error,协程栈没被正确展开,资源泄漏了一堆。从那以后,我定了个规矩:在 C 协程入口处必须捕获所有异常

// 安全的包装方式
auto wrapper = [](void* arg) {
    auto* self = static_cast<Coroutine*>(arg);
    try {
        self->func_();
    } catch (const std::exception& e) {
        // 记录日志,或者通知上层
        fprintf(stderr, "协程异常: %s\n", e.what());
    } catch (...) {
        fprintf(stderr, "协程未知异常\n");
    }
};

内存布局一致性

C 协程的上下文通常包含寄存器保存区、栈指针、程序计数器等。C++ 对象如果带有虚函数,其内存布局中会多一个 vptr(虚函数表指针)。如果你试图把 C++ 对象直接当作协程上下文传给 C 库,vptr 可能会被 C 代码当作普通数据覆盖,导致后续调用虚函数时崩溃。

场景 风险等级 建议
C 协程上下文是 struct(无 vptr) 可直接使用,注意对齐
C 协程上下文是 union 或 void* 用 static_cast 转换,确保类型匹配
C++ 对象含虚函数 不要直接传递,用 C 结构体包裹

SVG:C 协程与 C++ 互操作架构图

C++ 业务层 Coroutine 类(RAII 封装) std::function 回调 异常捕获包装 移动语义 / 禁止拷贝 C 协程库 co_create / co_resume co_yield / co_finished 上下文切换(汇编) TLS 当前协程指针 extern "C" 函数指针包装 异常安全边界 C 协程入口处 try-catch 捕获所有 C++ 异常 防止异常穿越 C 栈帧导致未定义行为 内存布局一致性 C++ 对象含 vptr 时不可直接作为协程上下文传递 使用 C 结构体包裹,或确保 POD 类型

更高级的用法:C++ 模板封装

如果你觉得上面那种封装还不够 C++ 化,可以用模板把协程做成一个通用的异步任务。我个人比较喜欢这种写法:

template <typename T>
class AsyncTask {
public:
    using Callback = std::function<void(T)>;

    AsyncTask(Callback cb) : cb_(std::move(cb)) {
        co_ = co_create(
            [](void* arg) {
                auto* self = static_cast<AsyncTask*>(arg);
                T result = self->run_impl();
                self->cb_(result);
            },
            this
        );
    }

    void start() { co_resume(co_); }

private:
    T run_impl() {
        // 这里可以 yield 等待 I/O
        Coroutine::yield();
        return T{};
    }

    coroutine_t* co_;
    Callback cb_;
};

注意:模板实例化时,T 的类型必须完整定义。如果 T 是不完整类型(比如前向声明的类),编译器无法生成正确的析构代码。我在一个跨模块的项目里踩过这个坑,最后不得不把 T 限制为可拷贝的 POD 类型。

性能考量

C 协程的上下文切换通常比 C++ 的 std::coroutine 更快,因为 C 版本往往直接用汇编保存/恢复寄存器,没有额外的异常处理开销。但代价是:你失去了 C++ 的 RAII 自动清理能力。

我的建议是:在性能敏感的路径上用 C 协程,在逻辑复杂的业务层用 C++ 封装。两者通过 extern "C" 接口隔离,各取所长。

嗯,说到底,互操作不是技术问题,而是设计问题。只要把边界划清楚,C 和 C++ 就能配合得很好。

总结一下:

  • 用 extern "C" 保证 C 接口在 C++ 中可见
  • 在 C 协程入口处捕获所有 C++ 异常
  • 注意内存布局一致性,避免 vptr 被破坏
  • 用 RAII 封装 C 协程的生命周期
  • 性能敏感路径用 C,复杂逻辑用 C++
公众号:蓝海资料掘金营,微信deep3321