-
-
[原创]从 C++20 协程到 Unity IL2CPP 协程
-
发表于: 2026-10-12 21:55 315
-
一篇"刨根问底"的协程探索实践笔记
不满足于"会用协程",而是一路往下挖:协程编出来长什么样? 从 C++20 协程的汇编与内存开始,一步步探索到 Unity 的 C# 协程——用 il2cpp 工具
Convert to CPP到底做了什么相信看完之后,你会对协程有更深刻的理解
0. 什么是协程
协程不是库、不是线程、也不是魔法栈——它是编译器生成的"状态机 + 一个状态号"。
C++20 协程和 Unity 的 C# 协程,本质是同一回事:编译器把 co_yield / yield return 拆成"状态机",每个挂起点一个 case/goto,并记住"挂在哪了"。
第一部分 · C++20 协程
1. 它是"语言机制",不是库
C++20 只给了三个关键字:
co_await:等待 / 挂起co_yield:产出一个值并挂起co_return:结束
至于"挂起时保存什么、谁来恢复、值放哪",全部由你自己提供的类型(promise_type、awaiter)决定
一个最朴素的生成器(用 clang++ -std=c++20 编译):
#include <coroutine>
#include <cstdio>
#include <exception>
struct Generator {
struct promise_type {
int current_value;
Generator get_return_object() {
return Generator{std::coroutine_handle<promise_type>::from_promise(*this)};
}
std::suspend_always initial_suspend() noexcept { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() { std::terminate(); }
std::suspend_always yield_value(int value) { current_value = value; return {}; }
};
std::coroutine_handle<promise_type> handle;
explicit Generator(std::coroutine_handle<promise_type> h) : handle(h) {}
Generator(const Generator&) = delete;
Generator& operator=(const Generator&) = delete;
Generator(Generator&& o) noexcept : handle(o.handle) { o.handle = {}; }
~Generator() { if (handle) handle.destroy(); }
bool next() { handle.resume(); return !handle.done(); } // resume + !done
int value() const { return handle.promise().current_value; }
};
Generator counter(int n) { for (int i = 1; i <= n; ++i) co_yield i; }
int main() {
auto g = counter(5);
while (g.next()) std::printf("value = %d\n", g.value());
}
名字规则:外层类型名 Generator 随便改;但嵌套类型必须叫 promise_type(除非特化 std::coroutine_traits)。
成员规则:get_return_object / initial_suspend / final_suspend / unhandled_exception 永远必须;yield_value / return_void / return_value 看函数体。
2. 协程的"魔力"就三层:帧 + 状态机 + yield=goto
上面的 C++ 写法会用到很多编译器内建函数(详见 libc++ 的 c++/v1/__coroutine/coroutine_handle.h)。
下面这个是更直观的让大家理解协程的写法
全部手工展开成普通 C++(无协程关键字),行为和原版逐字节一致:
struct CounterFrame { // ① 帧:存 promise、形参、局部变量、状态号
Promise promise;
SuspendAlways aw;
int n = 0, i = 0;
int state = 0;
bool done = false;
void resume() { // ② resume:switch(state) + goto 状态机
switch (state) { case 0: goto start; case 1: goto resume_yield; default: return; }
start:
i = 1;
loop:
if (!(i <= n)) goto end;
aw = promise.yield_value(i); // ③ co_yield i ⇒ promise.yield_value(i)
if (!aw.await_ready()) { state = 1; aw.await_suspend(); return; } // 挂起
resume_yield:
aw.await_resume(); ++i; goto loop;
end:
promise.return_void(); done = true; state = 2; return;
}
};
没有线程、没有魔法栈、没有运行时调度——就是一台状态机。co_yield i 展开后就是"存值 + 记录 state + return"
一个小规律:几个挂起点(yield),就有几个 case [ 当然是在不考虑编译器优化的情况下 ]
for (int i = 1; i <= n; ++i) co_yield i; // 源码里只有 1 个 co_yield
虽然运行时 yield 了 5 次,状态机只需要 1 个 case——靠 goto loop 反复回到同一个 state = 1 复用。连续写两个 co_yield 才会有两个 case。 这条规律后面在 Unity 的探索中里再次重现
3. 汇编层面:帧里的指针是什么
上边这个伪代码只是向大家说明状态机这样一个基本的概念,现在继续使用最开始写法的那部分代码来编译 反汇编
用 clang++ -std=c++20 -O2 -S 生成汇编。counter(5) 被编译成三个函数:
_Z7counteri:ramp(分配帧)_Z7counteri.resume:状态机_Z7counteri.destroy:销毁
ramp —— 分配帧、填两个函数指针(用 r2 反编译;该函数只有 72 字节 / 15 条指令):
; r2: sym.counter_int_ (counter(int)) 0x15e00, 共 72 字节
_Z7counteri:
stp x29, x30, [sp, -0x20]! ; 序言
stp x20, x19, [sp, 0x10]
mov x29, sp
mov w19, w0 ; w19 = n
mov w0, 0x20 ; 帧大小 = 32 字节
mov x20, x8 ;
bl operator new(unsigned long) ; 分配 32 字节的协程帧
adrp x8, 0x15000
add x8, x8, 0xea8 ; x8 = 0x15ea8 = _Z7counteri.resume
adrp x9, 0x15000
add x9, x9, 0xef4 ; x9 = 0x15ef4 = _Z7counteri.destroy
stp w19, w19, [x0, 0x10] ; 帧[+0x10] = n
stp x8, x9, [x0] ; ★ 帧[+0]=resume, 帧[+8]=destroy
str x0, [x20] ; 把 handle(= 帧地址) 返回给调用者
strb wzr, [x0, 0x1c] ; 帧[+0x1c(=28)] = 状态号 = 0
ldp x20, x19, [sp, 0x10] ; 尾声
ldp x29, x30, [sp], 0x20
ret
r2 把
adrp + add解析成符号地址后,x8 = 0x15ea8正好是_Z7counteri.resume、x9 = 0x15ef4正好是_Z7counteri.destroy——stp x8, x9, [x0]当场坐实"帧[0]/帧[1] 是两根函数指针"。
resume —— "挂起"就是 strb 状态号 + ret(同样很短,76 字节,完整贴出):
; r2: sym.counter_int_ [clone .resume] 0x15ea8, 共 76 字节
_Z7counteri.resume:
ldrb w8, [x0, 0x1c] ; 读状态号 (+0x1c = +28)
cbz w8, 0x15ec4 ; 状态==0 → 首次进入
ldp w9, w8, [x0, 0x14] ; 读 n(+0x14) 与 i(+0x18)
cmp w8, w9
b.eq 0x15ee8 ; i == n → 结束
add w8, w8, 1 ; ++i
b 0x15ed4
ldr w8, [x0, 0x14] ; (0x15ec4) 首次:读 n
cmp w8, 1
b.lt 0x15ee8
mov w8, 1
str w8, [x0, 0x18] ; (0x15ed4) 存 i
str w8, [x0, 0x10] ; 存 current_value(promise)
mov w8, 1
strb w8, [x0, 0x1c] ; 状态=1
ret ; ← 挂起
str w8, [x0, 0x10] ; (0x15ee8)
str xzr, [x0] ; ★ 帧[+0] = 0 → 标记 done
ret
.destroy 同样很短,就是 operator delete(帧, 32):
; r2: sym.counter_int_ [clone .destroy] 0x15ef4, 共 8 字节
_Z7counteri.destroy:
mov w1, 0x20 ; size = 32
b operator delete(void*, unsigned long) ; 释放协程帧
帧的真实布局(Itanium C++ ABI)
frame →
+0 (*resume)(handle) ← 固定
+8 (*destroy)(handle) ← 固定
+16 promise 对象(内联) ← 偏移固定(promise() 汇编:add x0, x8, #16)
+16+S 形参 / 局部变量 / awaiter / 状态号 ← 偏移由编译器定
或者是这样看:
| 位置 | 内容 | 说明 |
|---|---|---|
frame[0] |
resume 函数指针 | ABI 固定;结束时被清成 null(str xzr,[x0]) |
frame[1] |
destroy 函数指针 | ABI 固定;done 之后仍有效 |
frame[+16] |
promise 对象(内联,不是指针) | 存 current_value 等 |
一句话:
g(Generator)只是栈上一个 8 字节指针,指向堆上的帧;帧的头两根是函数指针,第三格才是数据(promise class instance)
动手验证:把帧读出来
用内嵌汇编绕过 API 从 g 抠帧:
void* frame = nullptr;
asm volatile("ldr %0, [%1]" : "=r"(frame) : "r"(&g) : "memory"); // 读 &g 的第一个 8 字节
void* resume = ((void**)frame)[0]; // frame+0
void* destroy = ((void**)frame)[1]; // frame+8
int* promise = (int*)((char*)frame + 16); // frame+16
操作日志(每次 next() 后 dump 三格):
before frame[0] resume=0x..0aa0 frame[1] destroy=0x..0b54 frame[2] promise=0x3 done=0
after next#1 frame[0] resume=0x..0aa0 frame[1] destroy=0x..0b54 frame[2] promise=0x1 done=0
after next#3 frame[0] resume=0x..0aa0 frame[1] destroy=0x..0b54 frame[2] promise=0x3 done=0
after done frame[0] resume=0x0 ←null frame[1] destroy=0x..0b54 frame[2] promise=0x3 done=1
frame[0](resume)结束时变 null —— 与汇编str xzr,[x0]对上;frame[1](destroy)始终有效;frame[2]就是promise.current_value。
两条推论
done()的实现:frame[0] == nullptr(结束时协程把自己头指针清空)。用handle.resume()汇编印证——它就是"取frame[0]→blr间接调用"。next()=resume()+!done():直接调resume指针只是"前半步"
帧大小会随 promise 变大
counter 的帧是 0x20。如果在 promise_type 里再加成员,帧会不会变大?我们改 promise_type、用 -O2 重编、读 ramp 里 operator new 的立即数:
promise_type 内容 |
operator new 大小 |
增量 |
|---|---|---|
int current_value(baseline) |
0x20(32) | — |
… ; int extra_int; |
0x28(40) | +8 |
… ; void* extra_ptr; |
0x30(48) | +16 |
… ; long extra_long; |
0x30(48) | +16 |
… ; int extra_int; void* extra_ptr; long extra_long; |
0x38(56) | +24 |
结论:帧大小 = 16(两根函数指针)+ sizeof(promise) + 形参/局部变量/状态号,再按对齐向上取整。所以 promise 一变大,帧就跟着变大。
- 加
int涨 8:4 字节本身 + 对齐补齐; - 加 8 字节类型(指针 /
long)涨 16:前面的int被 padding 到 8 字节对齐后再放 8 字节; - 一次加
int+指针+long涨 24(4+4 +8 +8)。
4. 自定义 awaiter(协程的核心用法)
co_yield/co_return 只是产出/结束;协程真正的核心威力在 co_await + 自定义 awaiter——挂起时干什么、谁来恢复、恢复后拿什么,全由你定义。一个 awaiter 就三个成员:
| 成员 | 作用 |
|---|---|
await_ready() |
能否不挂起就完成?(返回 true 就跳过挂起) |
await_suspend(h) |
挂起时做什么(注册回调 / 起线程 / 排队) |
await_resume() |
恢复后产出什么(= co_await 的值) |
举一个例——等 N 秒的 awaiter:
struct WaitForSeconds {
double seconds;
bool await_ready() const noexcept { return seconds <= 0.0; } // <=0 秒不用挂
void await_suspend(std::coroutine_handle<> h) const {
std::thread([h, sec = seconds] { // 挂起时:起个计时器线程
std::this_thread::sleep_for(std::chrono::duration<double>(sec));
h.resume(); // 时间到,恢复协程
}).detach();
}
void await_resume() const noexcept {}
};
一个完整的最小程序——连等 3 次(1、2、3 秒),这样就能看到"每个 co_await 一个 case":
#include <coroutine>
#include <cstdio>
#include <chrono>
#include <thread>
#include <atomic>
std::atomic<bool> g_finished{false};
std::chrono::steady_clock::time_point g_start;
double elapsed() { return std::chrono::duration<double>(std::chrono::steady_clock::now() - g_start).count(); }
struct Task {
struct promise_type {
Task get_return_object() { return {}; }
std::suspend_never initial_suspend() noexcept { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() { std::terminate(); }
};
};
Task timer_demo() {
printf("[t=%.1fs] 开始\n", elapsed());
co_await WaitForSeconds{1.0}; // 挂起点 ①
printf("[t=%.1fs] 过了 1 秒\n", elapsed());
co_await WaitForSeconds{2.0}; // 挂起点 ②
printf("[t=%.1fs] 又过了 2 秒\n", elapsed());
co_await WaitForSeconds{3.0}; // 挂起点 ③
printf("[t=%.1fs] 再过了 3 秒\n", elapsed());
g_finished.store(true);
co_return;
}
int main() {
g_start = std::chrono::steady_clock::now();
timer_demo(); // 启动:跑到第一个 co_await 挂起
while (!g_finished.load()) std::this_thread::sleep_for(std::chrono::milliseconds(50)); // 锁住 避免主线程退出
printf("[t=%.1fs] main: 完成\n", elapsed());
}
运行(1 秒、再 2 秒、再 3 秒):
[t=0.0s] 开始
[t=1.0s] 过了 1 秒
[t=3.0s] 又过了 2 秒
[t=6.0s] 再过了 3 秒
[t=6.0s] main: 完成
r2 反编译。ramp 跑到第一个 co_await 就挂起;此后每次进来都走 .resume,靠状态号分派:
; ───────── ramp:跑到第一个 co_await 就挂起 ─────────
0x2fd8 mov w0, 0x20 ; 帧大小 0x20
0x2fdc bl operator new ; 分配协程帧
0x2ff4 stp x8, x9, [x0] ; 帧[0]=.resume(0x3110) 帧[1]=.destroy(0x322c)
0x3024 bl printf ; printf("[t=..] 开始")
0x302c strb wzr, [x19, 0x11] ; ★ 状态号 = 0
0x3030 str x8, [x19, 0x18] ; awaiter.seconds = 1.0
0x3040 bl std::thread::thread<lambda> ; await_suspend:起线程,等 1 秒
0x3060 ret ; → 挂起,交回调用者
; ───────── .resume:被计时器线程 resume 进来,靠状态号“分派” ─────────
_Z10timer_demov.resume (0x3110):
0x3120 ldrb w20, [x0, 0x11] ; ★ 读状态号
0x3130 cmp w20, 2 ; ┐
0x3150 b.eq 0x3194 ; │ 状态分派
0x3154 cmp w20, 1 ; │ (= switch(state) 的形状)
0x3158 b.ne 0x31b8 ; ┘
; ---- case 1(状态号==1:刚过第 2 个 co_await)----
0x3164 bl printf ; printf("[t=..] 又过了 2 秒")
0x3168 mov x8, 0x4008000000000000 ; 3.0
0x316c mov w9, 2
0x3170 str x8, [x19, 0x18] ; awaiter.seconds = 3.0
0x3174 strb w9, [x19, 0x11] ; 状态号 = 2
0x3184 bl std::thread::thread<lambda> ; await_suspend:起线程,等 3 秒
0x3190 b 0x31ec ; → 收尾(dtor) + ret(挂起)
; ---- case 2(状态号==2:刚过第 3 个 co_await)----
0x319c bl printf ; printf("[t=..] 再过了 3 秒")
0x31a0 mov w8, 1
0x31ac stlrb w8, [x9] ; g_finished = true
0x31b0 str xzr, [x19] ; 帧[0] = 0 → done
0x31b4 b 0x31f4 ; → ret(结束)
; ---- case 0(状态号==0:刚过第 1 个 co_await)----
0x31c0 bl printf ; printf("[t=..] 过了 1 秒")
0x31c4 mov x8, 0x4000000000000000 ; 2.0
0x31c8 mov w9, 1
0x31cc str x8, [x19, 0x18] ; awaiter.seconds = 2.0
0x31d0 strb w9, [x19, 0x11] ; 状态号 = 1
0x31e0 bl std::thread::thread<lambda> ; await_suspend:起线程,等 2 秒
0x31f4 ret ; → 挂起
由此可以看出:
- 每个
co_await对应一个状态号(0/1/2)和一个 case——ldrb 状态号后面跟着cmp / b.eq / b.ne,这就是switch(state)的形状; - 每个 case 块里都是一次完整的 awaiter 三件套(
str存seconds→bl std::thread起线程 =await_suspend;ret= 挂起); - 挂起前
strb 状态号、恢复时ldrb 状态号——这就是"记住挂在哪"。
小坑:
await_ready()返回true(seconds<=0)时会跳过await_suspend,所以"无论挂不挂都要发生"的逻辑别写进await_suspend。
第二部分 · Unity 的 C# 协程
前面看 C++ 协程走的是"源码 → 汇编 → 帧内存"。现在分析 Unity 的 C# 协程,用同一套路、三段编译 + 一段动态:
① C# → IL(Roslyn;这时
switch就已经显现)② IL → C++(il2cpp.exe
--convert-to-cpp)③ C++ →
.so(NDK clang 编译 + 链接)④ 装上真机 · 动态验证(IDA + Frida)
5. 源码:一个 UI + Action 回调 + 协程
真实 Unity 工程里的 CoroutineUIDemo.cs:界面上一个按钮,点一下启动"等 2 秒后调回调"的协程。
public class CoroutineUIDemo : MonoBehaviour
{
public Text statusText;
public Button startButton;
void Start() {
statusText.text = "点击按钮开始";
startButton.onClick.AddListener(OnButtonClick); // 按钮 → 回调
}
void OnButtonClick() {
Debug.Log("[UI] 按钮被点击");
StartCoroutine(DoWork(2f, () => { // Action 回调
statusText.text = "完成!";
Debug.Log("[UI] 完成回调被调用");
}));
}
IEnumerator DoWork(float seconds, Action onDone) {
statusText.text = "处理中...";
Debug.Log("[Co] 开始,等待 " + seconds + " 秒");
yield return new WaitForSeconds(seconds); // 挂起 ①
Debug.Log("[Co] 等待结束");
yield return null; // 挂起 ②
onDone?.Invoke(); // 调回调
}
}
两个 yield return。下面一步步看它被编译成了什么。
6. 第一步编译:C# → IL(switch 已经显现)
C# 编译器先把 DoWork 编成 IL。为了好读,去掉 Unity 依赖、写一个等价的最小版本(结构一致):
public class CoroutineDemo {
public static IEnumerator DoWork(float seconds) {
Console.WriteLine("处理中...");
yield return new WaitForSeconds(seconds); // 挂起 ①
Console.WriteLine("等待结束");
yield return null; // 挂起 ②
Console.WriteLine("结束");
}
}
用 ildasm(ildasm /text /out:ildemo.il ildemo.dll)看它的 IL。DoWork 消失了,取而代之的是一个编译器生成的嵌套类 <DoWork>d__0:
.class auto ansi sealed nested private beforefieldinit '<DoWork>d__0' // ← C# 编译器生成的状态机类
.field private int32 '<>1__state' // 状态号
.field private object '<>2__current' // 每一个 yield 出去的值
.field public float32 seconds // 捕获形参
.method instance bool MoveNext() cil managed {
IL_0001: ldfld int32 CoroutineDemo/'<DoWork>d__0'::'<>1__state'
IL_0006: stloc.0
IL_0007: ldloc.0
IL_0008: switch (IL_001b, IL_0046, IL_0067) // !!! 三个 case
IL_0019: ldc.i4.0
IL_001a: ret // 默认分支:return false(结束)
IL_001b: // ---- case 0 ----
ldarg.0 ; ldc.i4.m1 ; stfld <>1__state // state = -1
ldstr "处理中..." ; call Console::WriteLine
ldarg.0 ; ldfld seconds ; newobj WaitForSeconds ; stfld <>2__current
ldarg.0 ; ldc.i4.1 ; stfld <>1__state // state = 1
ldc.i4.1 ; ret // return true(挂起)
IL_0046: // ---- case 1 ----
...
}
在 IL 中:<>1__state(int 状态号)+ switch (IL_001b, IL_0046, IL_0067) 三个分支 + 每段结尾 stfld <>1__state; ldc.i4.1; ret(存状态、返回 true = 挂起)。源码中 2 个 yield return 对应 2 个 case(另一个是初始)
7. 第二步:C# (IL) → C++(IL2CPP)
7.1 构建
IL2CPP 逐条把 IL 翻译成 C++。在装了 Android IL2CPP 后端的 Unity 上批处理构建:
编译链路:
C# 源码
│ Roslyn
▼
迭代器方法 → 编译器生成的嵌套状态机类 <Xxx>d__N
│ IL2CPP(il2cpp.exe --convert-to-cpp --dotnetruntime=Il2Cpp --platform=Android)
▼
C++(Library/Bee/artifacts/Android/il2cppOutput/cpp/Assembly-CSharp*.cpp)
│ NDK clang 编译 + 链接
▼
libil2cpp.so → APK
7.2 先看一眼成品 —— 符号为什么又长又怪?
一打开 Assembly-CSharp.cpp,随便摘一段 满屏都是这种名字
struct U3CDoWorkU3Ed__6_t001EAA1C1E8F52701D4761ED06B1BCDCF5E892C8 : public RuntimeObject { ... };
IL2CPP_EXTERN_C IL2CPP_METHOD_ATTR RuntimeObject* CoroutineUIDemo_DoWork_m075247933123D01E5AEA6F192324DAC00A03991C (...);
U3CDoWorkU3Ed__6_t001EAA…、_m0752…——又长又怪。原因就两条:
① <> 这种字符 C++ 不认,按 ASCII 编码:<=0x3C→U3C、>=0x3E→U3E。所以 IL 里的 <DoWork>d__6 变成 C++ 的 U3CDoWorkU3Ed__6:
| IL(C# 生成) | C++(IL2CPP 生成) |
|---|---|
<DoWork>d__6(状态机类) |
U3CDoWorkU3Ed__6 |
<>1__state |
___U3CU3E1__state |
<>2__current |
___U3CU3E2__current |
② 每个类型/方法再拼一段 SHA-1(40 位十六进制),保证全局唯一(重载、泛型、编译器生成名都不会出现重复):
U3CDoWorkU3Ed__6 _t001EAA1C1E8F52701D4761ED06B1BCDCF5E892C8
CoroutineUIDemo_DoWork _m075247933123D01E5AEA6F192324DAC00A03991C
└ 类名_方法名 ┘ └ _m ┘ └———— 该方法声明的 SHA-1 ————┘
类型是 <名字>_t<SHA1>;方法是 <类名>_<方法名>_m<SHA1>;字符串是 _stringLiteral<SHA1>。
所以"符号变怪" = <>→U3C/U3E 的字符转义 + _t/_m<SHA1> 的唯一化后缀
7.3 成品一:状态机类(= 协程"帧")
struct U3CDoWorkU3Ed__6_t001EAA1C1E8F52701D4761ED06B1BCDCF5E892C8 : public RuntimeObject
{
int32_t ___U3CU3E1__state; // <>1__state —— 状态号(那个 int)
RuntimeObject* ___U3CU3E2__current; // <>2__current —— 当前 yield 值
CoroutineUIDemo_t1320...* ___U3CU3E4__this; // 捕获 this
float ___seconds; // 捕获形参
Action_tD00B0...* ___onDone; // 捕获 Action 回调
};
对照源码 DoWork(float seconds, Action onDone):它的形参、this、状态号、当前值…全部变成了这个类的字段——这就是"协程帧"。
7.4 成品二:DoWork(ramp,只造对象)
RuntimeObject* CoroutineUIDemo_DoWork_m0752... (CoroutineUIDemo_t* __this, float ___0_seconds, Action_t* ___1_onDone, ...) {
U3CDoWorkU3Ed__6_t* L_0 = il2cpp_codegen_object_new(U3CDoWorkU3Ed__6_il2cpp_TypeInfo_var); // new <DoWork>d__6
U3CDoWorkU3Ed__6__ctor(L_0, 0, NULL); // state = 0
L_0->___U3CU3E4__this = __this; // 填捕获变量
L_0->___seconds = ___0_seconds;
L_0->___onDone = ___1_onDone;
return L_0; // 返回 IEnumerator
}
和 C++ 的 ramp 一样:不跑逻辑,只把状态机对象和捕获变量准备好。
7.5 成品三:MoveNext(就是 IL 里那个 switch)
bool U3CDoWorkU3Ed__6_MoveNext_m5E19... (U3CDoWorkU3Ed__6_t* __this, ...) {
int32_t L_0 = __this->___U3CU3E1__state; // 读状态号
switch (V_0) { // ← 就是 IL 里的 switch
case 0: goto IL_0022;
case 1: goto IL_0080;
case 2: goto IL_00a1;
}
return (bool)0; // 结束
IL_0022: // case 0
__this->___U3CU3E1__state = (-1);
statusText.text = "处理中...";
Debug.Log("[Co] 开始,等待 " + seconds + " 秒");
__this->___U3CU3E2__current = new WaitForSeconds(seconds); // ★ 交给引擎
__this->___U3CU3E1__state = 1;
return (bool)1; // ★ 挂起
IL_0080: // case 1
Debug.Log("[Co] 等待结束");
__this->___U3CU3E2__current = NULL; // ★ 等下一帧
__this->___U3CU3E1__state = 2;
return (bool)1; // ★ 挂起
IL_00a1: // case 2
if (__this->___onDone) Action_Invoke(__this->___onDone); // ★ 调回调
return (bool)0; // ★ 结束
}
IL 里的 switch (IL_001b, IL_0046, IL_0067) 原封不动地变成了 C++ 里的 switch (state):源码 2 个 yield return → case 0/1/2 + 一个 int 状态号。
8. 反汇编与动态分析
成品只是"纸面",把它装到真机,动态探讨一下 (当然你也可以编译的时候就加debug参数 不去符号)
8.1 IDA:stripped 的 .so 里怎么定位
IDA 载入的 libil2cpp.so 是 strip 版——方法名是 LOCAL HIDDEN,按名字找不到。手法:从"未 strip"的 libil2cpp.sym.so 用 llvm-readelf --syms 取地址,再到 stripped 库里补名、加注释:
| 函数 | 地址 |
|---|---|
CoroutineUIDemo_Start |
0x99ADB8 |
CoroutineUIDemo_OnButtonClick |
0x99BA74 |
<OnButtonClick>b__5_0(lambda) |
0x99BC14 |
CoroutineUIDemo_DoWork(ramp) |
0x99BB4C |
<DoWork>d__6::.ctor |
0x99BBE4 |
<DoWork>d__6::MoveNext |
0x99BCF4 |
<DoWork>d__6::Dispose / get_Current / Reset |
0x99BCF0 / 0x99BF14,0x99BF54 / 0x99BF1C |
8.2 IDA:反编译 DoWork / MoveNext / OnButtonClick
DoWork(0x99BB4C)= ramp:只 new <DoWork>d__6 + 填捕获变量。
; C# IEnumerator DoWork(float seconds, Action onDone) ABI: x0=this, s0=seconds, x1=onDone
99bb6c MOV X19,X1 ; X19 = onDone
99bb70 MOV V8.16B,V0.16B ; V8 = seconds (float)
99bb74 MOV X20,X0 ; X20 = this
99bb94 BL ...object_new(<DoWork>d__6) ; new <DoWork>d__6 = 分配协程帧对象
99bba0 BL <DoWork>d__6::.ctor(v6, 0) ; 状态机构造
99bba8 STR WZR,[X21,#0x10] ; v6-><>1__state = 0 (状态号在 +0x10)
99bbac STR X20,[X0,#0x20]! ; v6-><>4__this = this (+0x20)
99bbbc STR S8,[X21,#0x28] ; v6->seconds = seconds (+0x28)
99bbc0 STR X19,[X0,#0x30]! ; v6->onDone = onDone (+0x30)
99bbe0 RET ; return v6
MoveNext(0x99BCF4)= 状态机(= IL 里的 switch):
U3CDoWorkU3Ed__6_MoveNext:
99bd6c LDR W8,[X19,#0x10] ; v3 = this-><>1__state (读状态号)
99bd70 CMP W8,#2
99bd74 B.EQ loc_99BE88 ; case 2 -> 调 onDone + 结束
99bd7c B.EQ loc_99BEB0 (CMP#1) ; case 1 -> 发 null (下一帧)
99bd80 CBNZ W8,loc_99BEA8 ; 其它 -> return 0
; ---- case 0 ----
99bde4 BLR X9 ; statusText.text = "处理中..."
99be18 BL String.Concat ; "[Co] 开始,等待 " + seconds + " 秒"
99be58 BL new WaitForSeconds(...)
99be6c STR X20,[X19,#0x18]! ; this-><>2__current = waitForSeconds (+0x18)
99be7c MOV W0,#1 ; return 1 // 挂起
99be80 STUR W0,[X19,#-8] ; this-><>1__state = 1
; ---- case 2 ----
99be98 LDR X9,[X8,#0x18]; BLR X9 ; onDone.Invoke() (委托间接调用)
99bea8 MOV W0,WZR ; return 0 // 结束
; ---- case 1 ----
99bee4 STR XZR,[X19,#0x18]! ; this-><>2__current = null
99bef8 ... (state=2)
99befc MOV W0,#1 ; return 1 // 挂起
OnButtonClick(0x99BA74):按钮点 → 建委托 → 起协程:
99bb00 BL sub_11138F0 ; Debug.Log("[UI] 按钮被点击")
99bb08 BL il2cpp_object_new ; new Action
99bb1c BL Action::.ctor(this, <OnButtonClick>b__5_0_RuntimeMethod_var)
99bb2c BL CoroutineUIDemo_DoWork ; DoWork(this, 2.0f, action) 返回 IEnumerator
99bb48 B StartCoroutine ; 交给引擎(native icall)
8.3 这里注意一点<OnButtonClick>b__5_0_RuntimeMethod_var 或者说 RuntimeMethod_var 是什么?
构造 Action 委托时,C++ 里传了一个 ..._RuntimeMethod_var。IDA 里它在 .data 长这样:
0x142ADB0 OnButtonClick_RuntimeMethod_var DCQ 0x6000BAED
**这个 0x6000BAED 不是指针 ** 它是 打包的元数据 token:
GetEncodedIndexType(idx) = (idx & 0xE0000000) >> 29 // 高 3 位 = 种类
GetDecodedMethodIndex(idx) = (idx & 0x1FFFFFFE) >> 1 // 其余 = 索引
高 3 位 0b011=3 = kIl2CppMetadataUsageMethodDef,索引 = 23926(全局方法定义)。
运行时它才变成真指针——Frida 实测 base.add(0x142ADB0).readPointer():
文件里 .data 处存的是 token,运行时才被解析成指针
var p = Process.findModuleByName("libil2cpp.so").base.add(0x142ADB0).readPointer();
p.readCString(); // 不是字符串,是 MethodInfo*
p.add(24).readPointer().readCString(); // ★ MethodInfo+24 = name
base + 0x142ADB0 的 16 进制 dump:
... 7b 07 00 60 | 20 f0 2a a1 70 | 18 ef 2a a1 70 | 31 28 00 60 ...
0x6000077B 0x70A12AF020 0x70A12AEF18 0x60002831
(token) (已解析指针) (已解析指针) (token)
即:_RuntimeMethod_var 是 RuntimeMethod*(= MethodInfo*);文件里是 token、运行时被 il2cpp_codegen_initialize_runtime_metadata 解析成真指针
文中这些地址、SHA-1 后缀、以及这里的 token / 索引,都是我在多次写代码、多次编译运行的过程中陆续记下来的。所以你看到上面
0x6000BAED和下面 dump 里的0x6000077B、以及各节的函数地址对不齐,这是正常的,看"形状 / 规律"就行,不必逐字对齐
而 RuntimeMethod 到底啥结构 -> 在 codegen/il2cpp-codegen-metadata.h il2cpp-class-internals.h:
typedef MethodInfo RuntimeMethod;
typedef struct MethodInfo {
Il2CppMethodPointer methodPointer;
Il2CppMethodPointer virtualMethodPointer;
InvokerMethod invoker_method;
const char* name;
Il2CppClass* klass;
...
} MethodInfo;
8.4 MoveNext 是谁调的?
在 MoveNext 上打断点打印调用栈(breakWithStack(0x99bcf4)):
Start → OnButtonClick → new Action(lambda) → DoWork(ramp) → new <DoWork>d__6
→ MoveNext(state 0/1/2) → onDone.Invoke() → lambda
// 再结合运行时堆栈情况
0x716737a334 libil2cpp.so!0x112f334 SetupCoroutine::InvokeMoveNext(IEnumerator, IntPtr) ← 直接调用者
0x7166b49f3c libil2cpp.so!0x8fef3c il2cpp::vm::Runtime::Invoke
0x716be9e6b8 libunity.so!0x8396b8 ← 原生 Unity 引擎
0x7166b49e88 libil2cpp.so!0x8fee88 il2cpp_runtime_invoke
0x716ba858f4 libunity.so!0x4208f4 ← 原生 Unity 引擎
0x716ba9519c libunity.so!0x43019c ← 原生 Unity 引擎(coroutine update / player loop)
IDA 反编译栈顶 0x112F334 确认它就是托管辅助方法:
// UnityEngine.SetupCoroutine.InvokeMoveNext(IEnumerator enumerator, IntPtr returnValueAddress)
if (!enumerator) throw NRE;
result = enumerator.MoveNext(); // 调协程
*(uint8_t*)returnValueAddress = result & 1; // 把 bool 写回引擎给的地址
驱动链:libunity.so(原生引擎)→ il2cpp_runtime_invoke → Runtime::Invoke → SetupCoroutine.InvokeMoveNext → MoveNext。 全在主线程。
8.5 派生出结论 InvokeMoveNext = 抓所有协程
BF("InvokeMoveNext") 匹配到两个同名但不同的方法:
0xfa053c mscorlib: MoveNextRunner.InvokeMoveNext(Object) ← C# async/await(另一套)
0x112f27c UnityEngine: SetupCoroutine.InvokeMoveNext(IEnumerator, IntPtr) ← Unity 协程
对后者下断后,同一时刻连续命中很多 其中就有我们自己写的协程调用:
...
enumerator = 0x74aa230770 UnityEngine.UI.CoroutineTween.TweenRunner`1+<Start>d__2[ColorTween] ← Unity 内部协程
enumerator = 0x74aa23ca40 CoroutineUIDemo+<DoWork>d__6 ← ★ 我们的协程
...
两个参数:enumerator(入参)状态机对象;returnValueAddress(出参)地址引用
8.6 状态机对象里存了什么
结合运行时查看情况 lfs(0x74aa23ca40):
<>1__state = -1 (0..N = 挂在第 N 个 yield; -1 = 已结束)
<>2__current = null
<>4__this = Runner (CoroutineUIDemo)
seconds = 2
onDone = <OnButtonClick>b__5_0 (MP:0x99bc14)
字段偏移:state +0x10 / current +0x18 / __this +0x20 / seconds +0x28 / onDone +0x30(对象头 0x10)
其实就是我们静态分析看到的那些东西
第三部分 · 对照与总结
9. 结论
9.1 yield return = 编译器生成的状态机(N 个 yield = N 个 case + 一个 int)
从 C# 到 IL 到 C++ 全程,yield return 方法都被编成一个隐藏类(IL 里叫 <DoWork>d__0,C++ 里叫 U3CDoWorkU3Ed__6),逻辑在 MoveNext 的 switch(<>1__state) 里。源码 2 个 yield return → IL switch(3 个目标) → C++ switch(state) 三个 case——和 C++20 的 co_yield 同源,<>1__state 就是那个"挂在哪"的状态号。(注意的一点就是c++协程的状态不能说是像il2cpp一样这么规矩,它的状态不是一个单纯的变量记录的,更详细的分析可以自行去仔细看看汇编)
9.2 谁在驱动:C++ 靠你自己,Unity 靠引擎
- C++20:没有驱动者,
await_suspend里"谁来resume"完全由你(或你的 awaiter)决定 - Unity:
StartCoroutine走 native icall 把状态机交给引擎,引擎每帧经il2cpp_runtime_invoke → Runtime::Invoke → SetupCoroutine.InvokeMoveNext调MoveNext,再按返回值和Current(null=下一帧、WaitForSeconds=计时)决定何时再调,简单来说就是在update主线程中调用
9.3 那 6 个接口方法
| # | 方法 | 作用 | 类比 C++20 |
|---|---|---|---|
| 1 | .ctor(int <>1__state) |
设状态号初值(被 ramp 以 0 调用) | ramp 里构造状态机对象那一步 |
| 2 | IDisposable.Dispose() |
提前终止时的清理钩子(跑 finally/using);本协程为空实现 |
≈ destroy() 的"清理"部分 |
| 3 | MoveNext() |
推进到下一 yield/结束,返回 bool | resume() |
| 4 | IEnumerator<object>.get_Current() |
返回当前 yield 值(<>2__current) |
读 promise.current_value |
| 5 | IEnumerator.Reset() |
throw NotSupportedException |
无 |
| 6 | IEnumerator.get_Current() |
同 4(非泛型版) | 读 promise.current_value |
10. C++20 协程 vs Unity 协程
| 概念 | C++20 协程 | Unity C# 协程 |
|---|---|---|
| 状态存储 | 栈上的指针引用堆上的协程帧 | 编译器生成的状态机对象(一个普通 C# 类实例) |
| 状态号("挂在哪") | 按需有 | <>1__state(对象 +0x10) |
| 挂起的值 | promise.current_value |
<>2__current(对象 +0x18) |
| 单步执行 | resume() 走到下一挂起点 |
MoveNext() 返回 true/false |
| 结束判据 | handle.done()(= frame[0]==null) |
MoveNext() 返回 false(state=-1) |
| 谁驱动 | 你的调度器调 handle.resume() |
Unity 引擎每帧调 MoveNext(主线程) |
| 等待语义 | 自定义 awaiter 的 await_ready/suspend/resume |
yield return 出去的对象类型 |
| 销毁/清理 | handle.destroy()(跑析构 + 释放帧) |
Dispose()(跑 finally;释放归 GC) |
| 编译产物 | 帧 + .resume/.destroy 状态机 |
IL2CPP 翻译成 C++ 状态机类 |
附:一些发散性想法
① 一个通用"Unity 协程跟踪器"
hook SetupCoroutine.InvokeMoveNext(IEnumerator enumerator, IntPtr retAddr):
klass = *(void**)enumerator // 读对象头 → 类名(过滤出想要的协程)
state = *(int*)(enumerator + 0x10) // 状态号
cur = *(void**)(enumerator + 0x18) // 本次 yield 的值
log(className, state, cur)
// 函数返回后:读 *retAddr 一个字节 → 1=未结束 / 0=结束
把每个协程每次 MoveNext 的 状态迁移 + yield 值 实时打印出来——这就是"协程版调用跟踪器"。
② 靠特征在"无符号二进制"里定位协程帧(C++ 协程帧)
帧头两根连续的函数指针:[+0]=resume、[+8]=destroy。汇编里表现为"new 之后立刻往堆块头塞两个代码指针"(像只有两个槽的 vtable);其中 destroy 的函数体通常就是 delete(frame, size):
bl operator new(size) ; 分配帧
... ; 拿到帧指针 frame
adrp/add x8, <某函数> ; = resume
adrp/add x9, <某函数> ; = destroy
stp x8, x9, [frame] ; frame[0]=resume, frame[1]=destroy
或者说我们定位到这样的一个栈上指针引用堆协程帧以后
我们就可以直接通过 frame_ptr ? ((void(*)())((void**)frame_ptr)[0])() : (void)0;控制协程的调用
③ NDK 里"谁来 resume"?——ALooper
C++20 把"谁来驱动"完全留白给你;Unity 用 PlayerLoop / Update 每帧替你调 MoveNext。那 Android NDK 里有没有这个角色?有的,兄弟,有的——ALooper:android_main 里 android_app::looper 那个事件循环,就是 native 版的 PlayerLoop。
while (!app->destroyRequested) {
ALooper_pollOnce(-1, nullptr, &events, &data); // 你的 "Update"
drain_ready_coroutines(); // 就绪的 handle 挨个 resume
}
await_suspend 只负责"挂号"(入队 + 用 eventfd / ALooper_wake 把 loop 唤醒),回调负责 h.resume()——无非是把 Unity 的"每帧 poll → MoveNext"换个底座自己搭一遍。一句话对照:ALooper ≈ PlayerLoop,h.resume() ≈ MoveNext()。而 looper 是线程本地的,恢复天然发生在 loop 线程,和 Unity 一样是单线程分帧。
以上就是我自己的一点发散思考,兄弟们要是觉得哪里歪了、或者有更骚的思路,欢迎评论区发表自己的看法
赞赏
- [原创]从 C++20 协程到 Unity IL2CPP 协程 315
- [原创]逆向角度看C++部分特性 22601
- [原创]__builtin_return_address()引发的思考 7719
- [原创]静态InlineHook的脚本实现 31231
- [原创]记录一下YAHFA相关 10409