惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
The Last Watchdog
The Last Watchdog
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
T
Troy Hunt's Blog
L
LINUX DO - 最新话题
C
Check Point Blog
T
Threat Research - Cisco Blogs
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
罗磊的独立博客
V
Vulnerabilities – Threatpost
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
J
Java Code Geeks
Apple Machine Learning Research
Apple Machine Learning Research
大猫的无限游戏
大猫的无限游戏
S
Security @ Cisco Blogs
IT之家
IT之家
T
The Exploit Database - CXSecurity.com
The GitHub Blog
The GitHub Blog
D
Docker
Engineering at Meta
Engineering at Meta
AWS News Blog
AWS News Blog
S
Security Affairs
U
Unit 42
P
Palo Alto Networks Blog
V
Visual Studio Blog
Y
Y Combinator Blog
D
DataBreaches.Net
Forbes - Security
Forbes - Security
阮一峰的网络日志
阮一峰的网络日志
美团技术团队
Security Latest
Security Latest
aimingoo的专栏
aimingoo的专栏
Simon Willison's Weblog
Simon Willison's Weblog
A
Arctic Wolf
博客园_首页
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
H
Hacker News: Front Page
博客园 - 司徒正美
博客园 - Franky
宝玉的分享
宝玉的分享
TaoSecurity Blog
TaoSecurity Blog
Latest news
Latest news
Scott Helme
Scott Helme
MongoDB | Blog
MongoDB | Blog
量子位
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
C
Cisco Blogs
P
Privacy International News Feed
Application and Cybersecurity Blog
Application and Cybersecurity Blog

一个工匠

AI 发展临界点 - 快上车 流式响应(Chat):HTTP 响应体字节流的读取与解析 iPhone 截屏高效翻译 Flash Open Terminal iPhone 侧载安装 Xcode Symbolic Debug AI 指北 投资风险收益评估 Mac 快速修改系统快捷键 Git Record passkey How to pay apple frameworks Mac - Command Line Tools DNS 的 CNAME 是如何工作的 WebRTC 的一些解释 SSE 指南 1.1.1.1、WARP 和 MASQUE 移动端换端方案与场景还原 设备发现 分辨率 - 像素密度 - HiDPI Mac 字体 ESP32 trump - 川普 币圈 - 虚拟货币 【旅游】富士山 【旅游】sakura(樱花) 【旅游】千岛湖 我的 2023 【Swift】中文词语纠错 计算机字符编码与内存编码 - Unicode、UTF8、String 在 Xcode 中更好的使用 Swift 通过 Swift Package 制作二进制库 Arc 浏览器 - 意外的惊喜 【Swift 三方源码 1】SwiftShell 高效的命令行工具 Swift 脚本开发环境搭建 rime - 小鹤双拼 【旅游】沿海旅游 (威海、青岛) 好记性远远不如烂笔头 高级语言小记 又一年高考 Vision 未来已来 致敬耗子哥 Socket 与 ICMP 对互联网隐私的一些想法 网络代理是如何工作的 (致敬 Surge) 又十年 - 至三十而立 双拼 - 少有的高回报效率技能 Vim 技巧 我的 2022 三年疫情五味杂陈 - 下 老梗 - 浏览器按回车后发生了什么 三年疫情五味杂陈 生活推荐清单 Shell 和进程 有计划的努力 内卷 不确定性 数学可以保障逻辑自洽 这些年我的软件清单 决定做一些改变 函数调用栈之彻底理解 锁 - 共享数据安全指↑ 江苏女有感 碎碎念 - 2022 碎碎念 - 2021 打算记录一些碎碎念 多重性格就是逆天改命 从 Core Foundation 看更大世界 TCP 数据传输过程分析 iOS main 文件中的 @autoreleasepool 的真实用途 多线程的难点在哪里 Objective-C 和 Runtime 早起打卡计划 ing 打比方 IM 和 Socket 的关系及 Heart 的必要性 如果我的人生突然只剩下 8 小时... 胡说八道之雪粉 Array 和 LinkedList 的深刻认知 Swift struct (值类型) 与 class (引用类型) 区别及三种传参机制深刻关系和使用 逃离信用卡和花呗 Swift_let_var_struct_class 网聚人的力量 - 网易 绿皮火车 喝酒十之八九会误事 HKong 胡说八道之云服务 互联网网络传输协议理解坑点 CPU 的制作 我心里一直有一个念想 网络基础协议 为什么 32 位操作系统只能用 4G 内存空间 努力和天赋 洗牙 RAC-Api-Summer iOS 内存原理 Block 闭包简查记录 《白夜行》 谈谈事件驱动 老博新开 祭首
从汇编角度理解 “函数调用栈” 和 “有栈协程”
海驴 · 2023-12-24 · via 一个工匠

函数调用栈虽然表面意思很容易理解,但是说到汇编层面,对于 call、ret 等命令的具体表现,还是有一些理解复杂度。
尤其在协程大放异彩后,通过函数调用栈来表达协程的逻辑,也尤为重要。
本篇文章会从基础汇编的角度理解函数调用过程,并给出有栈协程的汇编实现。这样会对诸多高级语言的协程有进一步理解。

Start

函数执行过程大致如图中所示。其中函数栈的内存布局因操作系统和 cpu 架构的不同或许有差异,但不是重点。图中展示为 Mac + X86 系统。
从图中可以看到,程序执行的流程就是函数调用的过程。当函数调用完毕,程序也就停止了。所以函数调用对程序执行的影响还是非常大的,函数是指令层级的超高度抽象。

这里就先引出一些名词,后面不在特别说明。
内存布局:从上往下依次是内核区、栈区、共享数据区 (动态库)、堆区、数据区、代码区、留置区。这些区域的划分都是虚拟内存,并非物理内存。越往上内存地址越大,越往下内存地址越小。这里研究的函数调用栈就是在栈区。
栈区:从上往下分配空间,即先执行的函数的内部变量的虚拟内存地址一定大于后执行的子函数的内部变量的虚拟内存地址。
call:call 指令用于调用子函数。
push: push 指令用于将寄存器的值入栈保存到栈区内存中。
pop: pop 指令用于将当前栈区指向内存的值保存到寄存器中。
ret: ret 指令用于结束当前子函数调用,回到被调用处继续后续指令的执行。
rbp:栈底指针寄存器,值为栈底内存地址
rsp: 栈顶指针寄存器,值为栈顶内存地址 (下文会说到,rsp 不一定非要指向栈顶)

子函数调用前的准备

函数的作用是给予特定的输入,给出特定的输出。所以对子函数的调用,一个是传给子函数参数,一个是获取子函数的返回值。返回值在后面说,这里先说传参。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

#include <stdio.h>

int sub(int a, int b) {
int t = a + b;
printf("The sub value is:%d\n", t);
return t;
}

int main(void) {
int a = 1;
int b = 2;
int p = sub(a, b);
printf("the return value is:%d\n", p);
return 0;
}

示例代码中,main 对 sub 函数的调用,需要 a 和 b 两个参数,下面通过 main 的汇编,可以看下参数是如何准备起来的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
main:
0x100003f30 <+0>: pushq %rbp
0x100003f31 <+1>: movq %rsp, %rbp
0x100003f34 <+4>: subq $0x10, %rsp # 准备 0x10 大小即 16 字节的留置内存区域,用于存放各种参数
0x100003f38 <+8>: movl $0x0, -0x4(%rbp) # 0 值写入,是默认预留的大小空间,无特别场景,不会使用
0x100003f3f <+15>: movl $0x1, -0x8(%rbp) # 1 值写入变量 a,实际是写到栈内存中 rbp - 8 地址处
0x100003f46 <+22>: movl $0x2, -0xc(%rbp) # 2 值写入变量 b,实际是写到栈内存中 rbp - 12 地址处
0x100003f4d <+29>: movl -0x8(%rbp), %edi # 将 rbp - 8 地址处的内存值写入 edi 寄存器,即 1 写入 edi
0x100003f50 <+32>: movl -0xc(%rbp), %esi # 将 rbp - 12 地址处的内存值写入 esi 寄存器,即 2 写入 edi
0x100003f53 <+35>: callq 0x100003f10 ; sub at main.c:15
0x100003f58 <+40>: movl %eax, -0x10(%rbp)
0x100003f5b <+43>: movl -0x10(%rbp), %esi
0x100003f5e <+46>: leaq 0x31(%rip), %rdi ; "the return value is:%d\n"
0x100003f65 <+53>: movb $0x0, %al
0x100003f67 <+55>: callq 0x100003f74 ; symbol stub for: printf
0x100003f6c <+60>: xorl %eax, %eax
0x100003f6e <+62>: addq $0x10, %rsp
0x100003f72 <+66>: popq %rbp
0x100003f73 <+67>: retq

我们重点讨论 a 和 b 两个入参的准备过程,对于 push,call 等,后面会再说。上面的 <+4> 到 <+32> 行是入参的栈填充过程,到了 <+32> 行,就开始正式调用子函数了。
首先,将 a、b 变量值写入栈的留置内存区域,而后将变量值从内存中读到寄存器中。这里的 edi 和 esi 寄存器就是用来传参的。
这里得到一种结论,即寄存器可以用来传递参数给子函数
但寄存器的数量是有限的,如果需要传递非常多的参数,是无法都通过寄存器来解决的,比如下面的例子:

1
2
3
4
5
6
7
8
9
10
int sub(int a, int b, int c, int d, int e, int f, int g, int h, int i, int j, int k, int l, int m, int n, int o) {
int t = a + b;
printf("The sub value is:%d\n", t);
return t;
}
int main(void) {
int p = sub(1,2,3,4,5,6,7,8,9,10,11,12,13,14,15);
printf("the return value is:%d\n", p);
return 0;
}

汇编如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
main:
0x100003ee0 <+0>: pushq %rbp
0x100003ee1 <+1>: movq %rsp, %rbp
0x100003ee4 <+4>: subq $0x50, %rsp # 预留 80 字节大小的栈内存空间
0x100003ee8 <+8>: movl $0x0, -0x4(%rbp) # 0 值写入,默认预留的大小空间,无特别场景,不会使用

0x100003eef <+15>: movl $0x1, %edi # 参数入 寄存器
0x100003ef4 <+20>: movl $0x2, %esi # 参数入 寄存器
0x100003ef9 <+25>: movl $0x3, %edx # 参数入 寄存器
0x100003efe <+30>: movl $0x4, %ecx # 参数入 寄存器
0x100003f03 <+35>: movl $0x5, %r8d # 参数入 寄存器
0x100003f09 <+41>: movl $0x6, %r9d # 参数入 寄存器

0x100003f0f <+47>: movl $0x7, (%rsp) # 参数入 栈内存
0x100003f16 <+54>: movl $0x8, 0x8(%rsp) # 参数入 栈内存
0x100003f1e <+62>: movl $0x9, 0x10(%rsp) # 参数入 栈内存
0x100003f26 <+70>: movl $0xa, 0x18(%rsp) # 参数入 栈内存
0x100003f2e <+78>: movl $0xb, 0x20(%rsp) # 参数入 栈内存
0x100003f36 <+86>: movl $0xc, 0x28(%rsp) # 参数入 栈内存
0x100003f3e <+94>: movl $0xd, 0x30(%rsp) # 参数入 栈内存
0x100003f46 <+102>: movl $0xe, 0x38(%rsp) # 参数入 栈内存
0x100003f4e <+110>: movl $0xf, 0x40(%rsp) # 参数入 栈内存

0x100003f56 <+118>: callq 0x100003e90 ; sub at main.c:10

0x100003f5b <+123>: movl %eax, -0x8(%rbp)
0x100003f5e <+126>: movl -0x8(%rbp), %esi
0x100003f61 <+129>: leaq 0x32(%rip), %rdi ; "the return value is:%d\n"
0x100003f68 <+136>: movb $0x0, %al
0x100003f6a <+138>: callq 0x100003f78 ; symbol stub for: printf
0x100003f6f <+143>: xorl %eax, %eax
0x100003f71 <+145>: addq $0x50, %rsp
0x100003f75 <+149>: popq %rbp
0x100003f76 <+150>: retq

可以发现,这边测试环境下,传参超过 6 个,就需要写入栈内存。超出的部分,就不能通过寄存器传参了。

在上面有 a 和 b 两个变量的例子中 (int a = 1; int b = 2; int p = sub(a, b);),数据虽然也写到了 -0x8(%rbp)-0xc(%rbp) 栈内存中,但那是因为 a / b 两个变量当前函数需要使用,不得不写入栈内存,如果像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int sub(int a, int b) {
int t = a + b;
return t;
}

int main(void) {
int p = sub(1, 2);
printf("the return value is:%d\n", p);
return 0;
}

############

main:
0x100003f30 <+0>: pushq %rbp
0x100003f31 <+1>: movq %rsp, %rbp
0x100003f34 <+4>: subq $0x10, %rsp
0x100003f38 <+8>: movl $0x0, -0x4(%rbp)
0x100003f3f <+15>: movl $0x1, %edi # 直接写入 edi 寄存器,不在写入栈内存
0x100003f44 <+20>: movl $0x2, %esi # 直接写入 esi 寄存器,不在写入栈内存
0x100003f49 <+25>: callq 0x100003ef0 ; sub at main.c:15

这里就很清晰了,1 和 2 两个参数,都是直接写入寄存器中的,不会再写到栈内存中。

对于 sub(1,2,3,4,5,6,7,8,9,10,11,12,13,14,15) 的子函数调用,栈内存如下:

备注 1:
大家看到栈内存里面,从上往下的内存顺序正好和我们代码里面的顺序是反的,这是 Mac + X86 环境。不同环境下这个规则是不一样的,有些环境下先写的参数会在上面。
备注 2:
当前 7-15 的入参,是在当前函数栈里面存储的。即子函数执行完毕后,当前函数栈依旧保存这些入参。

子函数被调用

通过 call 指令即可完成函数帧的切换,即可以正常调用子函数了。上面例子中callq 0x100003e90 ; sub at main.c:10 即表示子函数跳转。
在调用子函数前,需要完成两个工作:
首先是当前指令的下一行指令地址(即 ip 寄存器的值)需要存入栈内存,为了在子函数执行完毕后可以继续执行下去。
其次是需要将子函数的函数内存地址 (代码区) 放入 ip 寄存器,这样 CPU 才能去执行子函数。

而这两个操作都是在 call 指令中完成的,所以 call 指令实际上等同于下面操作:

1
2
pushq %rip
movl <子函数内存地址> %rip

对于 sub(1,2,3,4,5,6,7,8,9,10,11,12,13,14,15) 的子函数调用,栈内存如下:

这里再说明一下,为什么要先把 ip 程序寄存器的值写入栈内存,然后又写入新的值进去。
因为程序运行的原理就是晶震驱动下的指令不间断的执行。指令执行需要内存地址,就是从 ip 程序寄存器里面读的。
当执行到 call 指令的时候,ip 寄存器里面已经存储了下一条指令的地址,即0x100003f5b。这里因为函数调转,不能执行下一条指令了,所以需要把 ip 寄存器先入栈内存暂时存储,即pushq %rip
入栈后,要去执行子函数,又需要把子函数的内存地址写入 ip 寄存器,这样 CPU 才会去执行子函数。即 movl 0x100003e90, %rip

这里还有一个隐藏的点,即 rsp 寄存器变化了。原先指向入参0x7的,这个时候就指向下一条指令的地址 ,即 0x100003f5b 位置。rsp 之所以能够变化,是因为pushq %rip 指令会改变 rsp 的指向,下面会说到。

子函数执行中

这里就是重点了,rbp 和 rsp 有非常大的用途。它是函数调用栈的核心,也是理解协程的基础。
rsp 是栈顶内存指针,rbp 是栈底内存指针。当栈内存需要更多的空间的时候,rsp 会指向更小的内存区域以时刻指向栈顶。
一般来说 rbp 和 rsp 的差值,就是栈内存空间的实时大小。但这个不是绝对的,因为依靠编译器的实现,为了高效并不会绝对的控制 rsp 寄存器的值,即有可能出现栈内存实际大小大于 rbp 和 rsp 的差值。后面会提到这个 case。
其中 rbp 有三个非常大的作用,是函数调用栈的中流砥柱。

  1. 在 rbp 基础上增加值偏移,可以访问到父函数的栈内存数据(如入参)。
  2. 在 rbp 基础上减少值偏移,可以访问到子函数的栈内存数据(如局部变量)。
  3. rbp 本身指向父函数的 rbp 的存储地址,用于子函数执行完毕后回到父函数时的 rbp 还原。

下面看下 sub 函数的汇编:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
int sub(int a, int b, int c, int d, int e, int f, int g, int h, int i, int j, int k, int l, int m, int n, int o) {
int t = a + b;
printf("The return value is:%d\n", t);
return t;
}

sub:
0x100003e80 <+0>: pushq %rbp
# 以上,将父函数的 rbp 值存入栈底
0x100003e81 <+1>: movq %rsp, %rbp
# 以上,将当前函数的 rsp 值赋予 rbp,此时 rbp 是子函数的栈底
0x100003e84 <+4>: subq $0x20, %rsp
# 以上,将 rsp 值减少 32 字节偏移,开辟栈预留内存空间
0x100003e88 <+8>: movl 0x50(%rbp), %eax
0x100003e8b <+11>: movl 0x48(%rbp), %eax
0x100003e8e <+14>: movl 0x40(%rbp), %eax
0x100003e91 <+17>: movl 0x38(%rbp), %eax
0x100003e94 <+20>: movl 0x30(%rbp), %eax
0x100003e97 <+23>: movl 0x28(%rbp), %eax
0x100003e9a <+26>: movl 0x20(%rbp), %eax
0x100003e9d <+29>: movl 0x18(%rbp), %eax
0x100003ea0 <+32>: movl 0x10(%rbp), %eax
# 以上,根据 栈底 rbp 做增加值偏移,获取父函数的栈内存数据,即入参
0x100003ea3 <+35>: movl %edi, -0x4(%rbp)
0x100003ea6 <+38>: movl %esi, -0x8(%rbp)
0x100003ea9 <+41>: movl %edx, -0xc(%rbp)
0x100003eac <+44>: movl %ecx, -0x10(%rbp)
0x100003eaf <+47>: movl %r8d, -0x14(%rbp)
0x100003eb3 <+51>: movl %r9d, -0x18(%rbp)
# 以上,将入参寄存器的值存入当前栈内存空间,做减小值偏移
0x100003eb7 <+55>: movl -0x4(%rbp), %eax
0x100003eba <+58>: addl -0x8(%rbp), %eax
# 以上,完成 a + b 操作
0x100003ebd <+61>: movl %eax, -0x1c(%rbp)
# 以上,将 a + b 的结果,存入栈内存空间
0x100003ec0 <+64>: movl -0x1c(%rbp), %esi
0x100003ec3 <+67>: leaq 0xd4(%rip), %rdi ; "The return value is:%d\n"
0x100003eca <+74>: movb $0x0, %al
0x100003ecc <+76>: callq 0x100003f7e ; symbol stub for: printf
# 以上,调用 printf 函数开始打印 a + b 的值
0x100003ed1 <+81>: movl -0x1c(%rbp), %eax
0x100003ed4 <+84>: addq $0x20, %rsp
0x100003ed8 <+88>: popq %rbp
0x100003ed9 <+89>: retq
# 以上,是子函数执行结束后的父函数还原,下一趴说

汇编注释做了说明,主要就是 rbp 值和 rsp 值的变化,如下所示:

这里有一个点,从汇编里面不太容易看出来 rsp 的变化。在子函数没有调用之前,rsp 的值是指向 main 函数栈顶的 p: 0x100003F5B。在调用 pushq %rbp将父函数的 rbp 值存入栈底后,rsp 的值就已经变化了。因为 push 实际上是完成了两个工作,如下:

1
2
3
4
5
6
pushq %rbp

=

subq $0x8, %rsp
movl. %rbp, %rsp

这两个步骤,一个将 rsp 减大小偏移 8 个字节,一个将 父函数的 rbp 值写入到偏移后的栈内存里面。

然后执行 movq %rsp, %rbp是为了将 rbp 置为当前子函数栈的栈底,即此时 rbp = rsp。到这里,子函数栈的两个重要参数 rbp 和 rsp 就已经设置好了。
再往后就开始预留栈内存空间了,subq $0x20, %rsp,预留内存空间的方式就是将 rsp 做减大小偏移。

有一个注意点,就是预留大小并不一定非要 rsp 做减大小偏移,其实只要能够持续稳定的访问到一个栈内存地址即可,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
int sub(int a, int b, int c, int d, int e, int f, int g, int h, int i, int j, int k, int l, int m, int n, int o) {
int t = a + b;

return t;
}

`sub:
0x100003e90 <+0>: pushq %rbp
0x100003e91 <+1>: movq %rsp, %rbp
0x100003e94 <+4>: movl 0x50(%rbp), %eax
0x100003e97 <+7>: movl 0x48(%rbp), %eax
0x100003e9a <+10>: movl 0x40(%rbp), %eax
0x100003e9d <+13>: movl 0x38(%rbp), %eax
0x100003ea0 <+16>: movl 0x30(%rbp), %eax
0x100003ea3 <+19>: movl 0x28(%rbp), %eax
0x100003ea6 <+22>: movl 0x20(%rbp), %eax
0x100003ea9 <+25>: movl 0x18(%rbp), %eax
0x100003eac <+28>: movl 0x10(%rbp), %eax
0x100003eaf <+31>: movl %edi, -0x4(%rbp)
0x100003eb2 <+34>: movl %esi, -0x8(%rbp)
0x100003eb5 <+37>: movl %edx, -0xc(%rbp)
0x100003eb8 <+40>: movl %ecx, -0x10(%rbp)
0x100003ebb <+43>: movl %r8d, -0x14(%rbp)
0x100003ebf <+47>: movl %r9d, -0x18(%rbp)
0x100003ec3 <+51>: movl -0x4(%rbp), %eax
0x100003ec6 <+54>: addl -0x8(%rbp), %eax
0x100003ec9 <+57>: movl %eax, -0x1c(%rbp)
0x100003ecc <+60>: movl -0x1c(%rbp), %eax
0x100003ecf <+63>: popq %rbp
0x100003ed0 <+64>: retq

上面的 sub 函数,注释掉 printf 子函数调用。这个时候,就不会有subq $0x20, %rspaddq $0x20, %rsp两个 rsp 减大小偏移的操作了。因为 sub 就是当前函数调用栈的最后一个子函数,它可以保障当前函数执行过程中的栈数据是稳定可靠的,因为不会再有子函数来打扰它。但是 sub 函数依旧可以通过 -0x4(%rbp)方式访问当前栈内存空间。
所以前面说过,rbp 和 rsp 的差值,不一定是栈内存空间的大小。上面的例子里面,rbp = rsp,即差值为 0,但当前栈内存依旧保存有 6 个寄存器的入参。

子函数执行结束后

快要接近尾声了。

子函数需要把返回值返回到父函数,是通过 eax 寄存器实现的。

1
2
3
4
5
6
7
8
9
10
11
12
sub:
0x100003eb7 <+55>: movl -0x4(%rbp), %eax
0x100003eba <+58>: addl -0x8(%rbp), %eax
# 以上,完成 a + b 操作
0x100003ebd <+61>: movl %eax, -0x1c(%rbp)
# 以上,将 a + b 的结果,存入栈内存空间
0x100003ed1 <+81>: movl -0x1c(%rbp), %eax
# 以上,从栈内存空间中取出返回值,存入 eax 寄存器。父函数通过eax 拿到子函数的返回值。

main:
0x100003f5b <+123>: movl %eax, -0x8(%rbp) # 父函数拿到 eax 返回值,目前是写入栈内存。也可以直接使用,看代码逻辑决定。

目前子函数快要执行完毕后,参考上面的汇编,最后还需要执行三个指令:

1
2
3
4
0x100003ed4 <+84>: addq   $0x20, %rsp
0x100003ed8 <+88>: popq %rbp
0x100003ed9 <+89>: retq
# 以上,是子函数执行结束后的父函数还原

其中做了这么几件事情:

  1. 还原父函数的 rbp
  2. 还原父函数的 rsp
  3. 使得父函数继续执行下去

其中 pop 和 ret 也有多个意图,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
popq   %rbp

=

movl %rsp, %rbp
addq $0x8, %rsp

------------

retq

=

movl %rsp, %rip
addq $0x8, %rsp

可以发现,pop 和 ret 都会操作 rsp 寄存器,将 rsp 寄存器的值做增大小偏移,从而还原到父函数的 rsp 状态。

最后将 call 指令存储到栈内存的下一行指令地址写回到 ip 寄存器,使得 CPU 从子函数调用处继续执行下去。

有栈协程如何实现

函数调用的过程,核心在于维护一个子函数的栈,在这个栈里面,会同样使用父函数 (父栈) 已经使用过的寄存器等硬件而互不干扰,还会将父栈必要的信息给予保留,如 rbp。
这样的调用流程,使得子函数完全执行完毕,最后执行 ret 指令后,回到父函数并继续执行下去。

而协程的实现,可以在父子函数之间不停的跳跃,它的实现依旧可以通过函数调用栈来完成。

如果要实现这种效果,需要下面几个点要先解决:

  1. 两个函数不停跳跃,一定有一个地方实现了 ret 指令。这个 ret 指令实现的地方,也就是 yield (一般都通过这个来让出当前协程) 实现的地方。
  2. yield 无法在当前函数中实现 ret 指令。因为 yield 如果直接表达了 ret (yield 汇编结果),那么 yield 就具有和 return 一样的效果,即当前函数意义上执行完毕了。

所以,yield 或许可以被实现为一个中间函数。这个中间函数内部实现 ret 指令,并强制跳转到另一个函数处。
这样的设计,就可以让原先的两个函数不受任何影响,完全被中间函数来间接的完成跳转。像下面这样:

在进入 yield 之前,先回顾一下上面提到的函数栈中使用的汇编指令。因为其中有一个关键寄存器 rsp 需要重点说明下。用最简单的一个例子来看:

1
2
3
4
5
#include <stdio.h>
void test(void) { }
int main(int argc, const char * argv[]) {
test(); return 0;
}

这里 test 函数的汇编如下:

1
2
3
4
5
TestProject`test:
0x100003f70 <+0>: pushq %rbp
0x100003f71 <+1>: movq %rsp, %rbp
0x100003f74 <+4>: popq %rbp
0x100003f75 <+5>: retq

在 main 里面进入 test 函数后,什么都没有执行,汇编非常简洁。这里说一下 rsp 为什么非常重要:

  1. rsp 在当前栈向下增长的过程中,表示当前栈的内存空间。
  2. rsp 代替 rbp。rbp 是栈底寄存器,上面说到可以对它增减偏移以连接父函数和子函数。rbp 也是可以通过 rsp 来间接获取的。上面子函数执行结束后章节中有一个汇编 addq $0x20, %rsp,就是将 rsp 移动到 rbp 的位置。可以在使用 rbp 的时候,通过 0x50(%rbp) 来间接拿到栈底点。
  3. rsp 决定最后的回跳地址。ret 指令执行的过程,是对 rsp 操作的。详见上面的 movl %rsp, %rip & addq $0x8, %rsp

所以,如果在 yield 中间函数的设计中,我们若能够巧妙的设置 rsp,就可以让当前 yield 回跳到任意需要的位置,从而实现上面图中的协程效果。比如下面的伪码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
TestProject`yield:
0x100003f70 <+0>: pushq %rbp
0x100003f71 <+1>: movq %rsp, %rbp
// 上面两个用于将父函数 rbp 存储,当前 rsp 置于栈底,即操作 rsp 和操作 rbp 等价。

mov %rsp, addressM
// 将父函数的 rbp 暂存 M 地址中。通过这个缓存的 M,可以拿到父函数的回跳地址。(rbp - 1 位置存储回跳执行地址,IP 程序计数器的值。)

mov addressN, %rsp
// 将 rsp 修改为新函数的钩子地址(需要特别配制)

0x100003f74 <+4>: popq %rbp
0x100003f75 <+5>: retq
// 这时候,pop %rbp 会将钩子地址处的相关数据设置到 rbp,作为新的栈底。
// ret 会将钩子地址处的相关数据设置为 IP 程序计数器并跳转过去
// rsp 已经在上一步被钩子地址填充好
// 函数跳转的三要素,基本都配置完成了,坐等跳转发生。

如上面注释里面说明的,只需要有一个巧妙的数据,这份数据能够被 pop rbpret 的执行逻辑获取到。那么,只需要控制 rsp 以指向这份巧妙的数据,就可以实现自定义跳转。
在刚进入 yield 的时候,rsp 和 rbp 都在栈底,相互可以等价操作。而我们配置的这份巧妙数据,只需要存储当前 yield 栈中的 rbp,就可以在以后的某个时刻重新设置给 rsp 用于回跳 (rbp-1 即回跳地址)。如下图所示:

这里,找一个全局内存区域 custom_point,用于存放每次 yield 时候 rsp 数据。
每次跳转的时候,yield 先备份当前的 rsp 为 _rsp,然后从 custom_point 中取出老数据并设置给 rsp,然后再将 _rsp 存储进 custom_point,最后坐等 ret 跳转发生。
(第一次跳转的时候,需要预先将待跳转函数的地址放进去,不然没数据)
下面分步介绍一下(假设图中第一次跳转已经完成,那时候需要初始化数据,还需要一些逻辑代码):

  1. 图中第二步,test () yield 到 main () 的时候,yield 拿到 test () 的 rbp 并存储。(rbp -1 即这一次的回跳地址,图中的 ...部分)
  2. 图中第三步,main () yield 到 test () 的时候,yield 拿到先前存储的 test () 的 rbp 并设置到 rsp 里面,ret 的时候直接跳转到 ... 部分

如此,就可以不间断的完成两个函数之间的跳转。这也是有栈协程的方案。


原来,取蚕丝的时候,需要把蚕宝用热水杀死在茧内,以获得完整的丝。
蚕宝不会变成飞蛾了。