ucontext 底层实现原理
1️⃣ 概述:从”上下文切换“ 到 ”用户态线程“
最近在开发服务器相关项目时,接触到了 协程(Coroutine) 这个概念。协程在高并发服务器、网络框架、异步编程中都很常见,而在 Linux 平台上,很多早期的用户态协程实现都与 ucontext 这组 API 有关。
虽然在项目里经常使用 ucontext,但一直没有系统地理解过:
- 到底解决了什么问题?
- 它和线程有什么区别?
- 它又是如何在底层完成 “切换执行流” 的?
所以就想借这个机会,对 ucontext 的原理做一次梳理。这篇文章不会从业务层面讨论 “协程框架怎么设计”,而是聚焦于一个更底层的问题:
ucontext到底是如何实现用户态上下文切换的?
在进入正式主题之前,先不急着看 API 或源码,因为如果一开始就直接面对 getcontext、makecontext、swapcontext 这些接口,很容易只记住 “怎么用”,却不明白 “为什么它能这样用”。
所以先从一个生活中的例子来理解什么叫做 “上下文切换”。
生活案例
我们每个人在日常生活中,其实都在频繁地做 “上下文切换”。
比如,某个周末的下午,你正在家里认真写博客。
这时手机突然响了,有人给你打电话。于是你停下写作,去接电话。
电话结束后,你又重新坐回电脑前,继续把刚才那一段内容写完。
这个过程看起来很普通,但如果从程序执行的角度看,它其实就是一次非常典型的 “上下文切换”:
- 原本你处在 “写博客” 这个状态中;
- 电话到来后,你切换到了 “接电话” 这个状态中;
- 电话结束后,你又恢复到 “写博客” 的状态中,继续从之前中断的位置往下做。
这里最关键的不是 “你做了两件事”,而是:
你能够在中途暂停一件事,并在之后准确地回到之前的位置继续执行。
程序也是一样:
一个执行流如果想在中途暂停,然后未来还能从原来的位置继续运行,就必须把它当时的状态保存下来。这个 “状态”,就可以理解为它的 上下文(context)。
2️⃣ 寄存器相关知识
现在要理解 ucontext,先补一下 x86-64 寄存器 的基础非常有必要,因为所谓 “上下文切换”,本质上就是:
把当前执行流的寄存器状态保存下来,再恢复另一个执行流的寄存器状态。
所以先把寄存器搞清楚,后面再看 swapcontext 就会顺很多。
什么是寄存器?
寄存器(Register)是 CPU 内部的一小块高速存储空间,速度比内存快得多。CPU 执行指令时,很多数据不会直接在内存里反复读写,而是先放到寄存器里参与运算。
你可以把寄存器理解成:
- 内存像仓库
- 寄存器像手边的工具箱
正在处理的数据,通常都先放在寄存器里。
x86-64 常见的 16 个通用寄存器
在 x86-64 架构下,最常见的是这 16 个 64 位通用寄存器:
1 | %rax, %rbx, %rcx, %rdx |
接下来介绍,每个寄存器的作用:
%rax作用:
- 函数返回值寄存器
- 某些运算的默认寄存器
用途:
- 函数执行完后,返回值通常放在
%rax里
- 函数执行完后,返回值通常放在
案例:
1
2
3int add() {
return 5; // 最后返回给调用者时,5 通常会放到 %rax
}
%rbx- 作用:
- 通常用于保存数据;
- 属于
callee-saved(被调用者保存)寄存器;
- 用途:
- 如果一个函数要用
%rbx,那它在修改之前,通常要先保存原值,返回前再恢复。
- 如果一个函数要用
- 作用:
%rcx作用:
- 函数参数寄存器之一;
- 某些指令的计数寄存器;
用途:
- 它通常作为:第
4个整数/指针参数
- 它通常作为:第
案例:
1
foo(a, b, c, d) // 这里 d 放在 %rcx,c 放在 %rdx,b 放在 %rsi,a 放在 %rdi
%rdx- 作用:
- 函数参数寄存器之一;
- 某些乘除法指令中有特殊用途;
- 用途:
- 它通常作为:第
3个整数/指针参数;
- 它通常作为:第
- 作用:
%rsi- 作用: 函数参数寄存器之一;
- 用途: 通常作为第
2个整数/指针参数;
%rdi- 作用: 函数参数寄存器之一;
- 用途: 通常作为第
1个整数/指针参数;
%rbp作用:
- 栈帧基址寄存器;
- 也是
callee-saved寄存器;
用途: 在传统函数调用里,它常用来作为 当前函数栈帧的基准位置
案例:
1
2
3
4
5
6
7
8高地址
------------
函数参数
返回地址
旧 rbp
局部变量
------------
低地址
%rsp- 作用: 栈指针寄存器,始终指向当前栈顶附近
- 用途:
- 函数调用、局部变量、返回地址,本质上都和栈有关;
- 协程切换时,只要把
%rsp换掉,CPU实际上就切到了另一块栈空间;
%r8- 作用: 函数参数寄存器之一,通常作为第 5 个整数/指针参数
%r9- 作用: 函数参数寄存器之一,通常作为第 6 个整数/指针参数
%r10- 作用: 临时寄存器,
caller-saved(调用者保存)
- 作用: 临时寄存器,
%r11- 作用: 临时寄存器,
caller-saved(调用者保存)
- 作用: 临时寄存器,
%r12- 作用: 临时寄存器,
caller-saved(调用者保存)
- 作用: 临时寄存器,
%r13- 作用: 临时寄存器,
caller-saved(调用者保存)
- 作用: 临时寄存器,
%r14- 作用: 临时寄存器,
caller-saved(调用者保存)
- 作用: 临时寄存器,
%r15- 作用: 临时寄存器,
caller-saved(调用者保存)
- 作用: 临时寄存器,
函数参数是如何放入到寄存器的?
在 Linux x86-64 下,最常见的 System V ABI 规定:如果参数超过 6 个,多出来的通常放栈上
1 | 第1个参数 -> %rdi |
caller-save 和 callee-save
caller-save定义: 调用其他函数之前,如果这些寄存器里的值当前函数还想保留,那么这些寄存器里的值必须调用者自己先保存,因为针对这些寄存器,被调用的函数通常可以直接修改。
常见的
caller-save1
%rax, %rcx, %rdx, %rsi, %rdi, %r8, %r9, %r10, %r11
callee-saved定义: 被调用的函数如果要使用这些寄存器,必须先保存这些寄存器原来的值,返回前恢复这些寄存器原本的值。这些寄存器存放 “需要跨函数保持不变” 的数据。
常见的
callee-save1
%rbx, %rbp, %r12, %r13, %r14, %r15
3️⃣ 源码讲解
ucontext
ucontext 是一组用于实现用户态上下文切换的底层 API,其核心作用是 保存、恢复和切换程序的执行现场。
“执行现场” 主要包括 当前的寄存器状态、栈信息 以及 后续要执行的上下文 等内容。借助 ucontext,程序可以在用户空间主动控制执行流:
- 先保存当前上下文的运行状态
- 再恢复另一个上下文之前保存好的状态
- 从而实现类似 “暂停—切换—恢复” 的效果。
代码
1 | typedef struct ucontext |
getcontext()
- 函数:
int getcontext(ucontext_t* ucp) - 作用:
- 获取当前执行流的上下文信息,并将其保存到一个
ucontext_t结构体中。 - 这里保存的内容可以理解为 程序当前时刻的 “运行现场”,主要包括 寄存器状态、栈信息 以及 当前代码执行到的位置 等。
- 获取当前执行流的上下文信息,并将其保存到一个
整体代码
1 | ENTRY(__getcontext) |
核心作用
第一部分:保存“被调用者保存寄存器”
1
2
3
4
5
6movq %rbx, oRBX(%rdi) # 把当前 rbx 寄存器的值保存到 ucontext 中的 RBX 字段
movq %rbp, oRBP(%rdi) # 把当前 rbp 寄存器的值保存到 ucontext 中的 RBP 字段
movq %r12, oR12(%rdi) # 把当前 r12 寄存器的值保存到 ucontext 中的 R12 字段
movq %r13, oR13(%rdi) # 把当前 r13 寄存器的值保存到 ucontext 中的 R13 字段
movq %r14, oR14(%rdi) # 把当前 r14 寄存器的值保存到 ucontext 中的 R14 字段
movq %r15, oR15(%rdi) # 把当前 r15 寄存器的值保存到 ucontext 中的 R15 字段- 上述寄存器都是
callee-saved寄存器,也就是通常认为 “跨函数调用应该保持不变” 的寄存器。如果以后要恢复到这个上下文,那么这些值必须原样拿回来。
- 上述寄存器都是
第二部分:保存参数寄存器
1
2
3
4
5
6
7movq %rdi, oRDI(%rdi) # 把当前 rdi 寄存器的值保存到 ucontext 中的 RDI 字段;
# 此时 rdi 本身还存着 ucontext_t* 的地址
movq %rsi, oRSI(%rdi) # 把当前 rsi 寄存器的值保存到 ucontext 中的 RSI 字段
movq %rdx, oRDX(%rdi) # 把当前 rdx 寄存器的值保存到 ucontext 中的 RDX 字段
movq %rcx, oRCX(%rdi) # 把当前 rcx 寄存器的值保存到 ucontext 中的 RCX 字段
movq %r8, oR8(%rdi) # 把当前 r8 寄存器的值保存到 ucontext 中的 R8 字段
movq %r9, oR9(%rdi) # 把当前 r9 寄存器的值保存到 ucontext 中的 R9 字段- 在
x86-64下,这几个寄存器通常用来传函数前 6 个参数
- 在
第三部分:保存返回地址,也就是未来恢复后该从哪里继续执行
1
2movq (%rsp), %rcx
movq %rcx, oRIP(%rdi)%rsp是当前栈指针:函数刚被调用进来时,栈顶通常放着:调用者的返回地址;(%rsp)取出来的就是:当前这个函数执行完后,本来要返回到哪里去;getcontext把它读出来放到%rcx;RIP表示指令指针,表示CPU下一条该执行哪条指令,也就是说:把 “当前函数将来返回后要继续执行的位置” 保存起来;- 所以这里保存的不是 “当前正在执行的
getcontext内部某一行”,而是:调用getcontext之后程序应该继续往下走的位置;
第四部分:保存栈指针,但要跳过返回地址
1
2leaq 8(%rsp), %rcx /* Exclude the return address. */
movq %rcx, oRSP(%rdi)(%rsp)存的是返回地址,8(%rsp)就是 “返回地址上面的位置”;- 如果将来恢复上下文,我们希望恢复时的栈状态应该是:
- 返回地址已经被 “弹出”
- 栈顶已经回到调用者自己的栈框架里
makecontext()
- 函数:
void makecontext(ucontext_t* ucp, void (*func)(), int argc, ...) - 作用:
- 基于一个已有的
ucontext_t上下文,构造一个 “新的可执行上下文”,使其在后续被恢复时,能够从指定函数func开始执行。 - 它可以理解为:手动为一个新的执行流搭建运行入口,主要会设置该上下文的入口函数、栈信息以及函数参数等内容。
- 调用
makecontext后,这个上下文就不再表示 “当前程序运行到哪里”,而是表示:当该上下文将来被setcontext()或swapcontext()恢复时,程序应该从func开始执行。 - 因此,
makecontext的本质不是 “切换上下文”,而是初始化一个新的上下文执行现场,为后续的用户态调度和协程切换做好准备。
- 基于一个已有的
整体代码
1 | void __makecontext (ucontext_t *ucp, void (*func) (void), int argc, ...) |
代码逻辑
- 首先声明外部函数
__start_context,用来执行协程上下文的收尾工作; - 创建一个指向栈空间的栈指针
sp,并且让sp指向程序员在堆区提前分配好的内存; sp额外预留指定大小的空间:- 函数返回地址(指定函数
__start_context) - 多余函数参数(
ABI规定当函数参数超过 6 个,之后的参数需要被保存在 栈) uc_link(需要被切换的上下文)
- 函数返回地址(指定函数
ucontext中的寄存器uc_mcontext指定保存内容RIP:恢复新上下文后,需要执行的指令地址func;RBX:保存指向uc_link所在位置的地址;RSP:保存新上下文的栈指针;- 函数的前 6 个参数保存在寄存器中,后续的函数参数保存在栈中;
swapcontext
- 函数:
int swapcontext(ucontext_t* oucp, const ucontext_t* ucp) - 作用:
- 先保存当前执行流的上下文信息 到
oucp中,再恢复并切换到ucp所表示的上下文。 - 它可以理解为一次完整的 “保存当前现场 + 切换到新现场” 的操作,因此常用于用户态协程或
Fiber之间的切换。 - 这里保存和恢复的内容同样包括 当前执行流 的 寄存器状态、栈信息 以及 后续代码执行位置 等关键现场。
- 当未来再次切回
oucp时,程序会表现得像swapcontext()这次调用刚刚返回一样,从原来中断的位置继续往下执行。 - 因此,
swapcontext的本质不是单纯调用另一个函数,而是在用户态主动完成两个执行流之间的上下文切换。
- 先保存当前执行流的上下文信息 到
整体代码
1 | ENTRY(__swapcontext) |
代码逻辑
- 首先将当前执行流的上下文信息保存到旧上下文
oucp中,保存内容主要包括:- 被调用者保存寄存器:
RBX、RBP、R12 ~ R15; - 参数寄存器:
RDI、RSI、RDX、RCX、R8、R9; - 当前函数返回后应该继续执行的位置,即保存到
RIP中; - 当前函数返回后的栈顶位置,即保存到
RSP中;
- 被调用者保存寄存器:
- 其中,
(%rsp)取出的并不是swapcontext内部当前正在执行的指令地址,而是调用swapcontext结束后应该返回的位置,因此它被保存到旧上下文的RIP中; - 同时,
8(%rsp)表示弹出返回地址后的栈顶位置,因此它被保存到旧上下文的RSP中,用来保证未来恢复该上下文时,程序能够像swapcontext已经正常返回一样继续执行; - 完成旧上下文保存后,开始从新上下文
ucp中恢复执行现场,主要包括:- 恢复新上下文的栈指针
RSP,切换到新上下文自己的栈; - 恢复被调用者保存寄存器:
RBX、RBP、R12 ~ R15; - 恢复参数寄存器:
RDI、RSI、RDX、RCX、R8、R9;
- 恢复新上下文的栈指针
- 随后将新上下文中保存的
RIP压入当前新栈中,作为接下来ret指令要跳转到的目标地址; - 最终通过
ret完成控制流跳转,使CPU从新上下文原来保存的位置继续执行;
因此,swapcontext 的本质就是:
- 先保存当前执行流的运行现场
- 再恢复另一个执行流的运行现场
- 从而在用户态完成一次完整的上下文切换。
