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++ 模板封装
如果你觉得上面那种封装还不够 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++