转载

使用 ARM64 汇编实现共享栈式协程

# 简述  

大约在半年以前,我曾经了解过协程的相关实现,也看过腾讯后台开源的协程库`libco`,对其中实现协程相关的汇编有很深的印象(`libco`适配的是 x86 平台)。接受了这样的思想,我在自己的毕业设计中,写出一套单片机汇编实现的协程。也自此之后,就有种念头在 iOS 端实现相关的代码。手机端使用的 CPU 跟单片机相差甚远,虽然我用的单片机也是 ARM 平台的,但和手机端 CPU 相比两者差距确很大,内核微架构差距太大,无从入手。后续突然想起可以使用`setjmp`和`longjmp`这两个函数间跳转函数,我为何不反汇编它,根据其汇编,来实现协程呢?  

最后,我实现了这个想法,先把 Demo 放上来:https://github.com/suancaiAmour/CoroutineDemo。读者可以根据 Demo 和本篇文章一起了解相关内容。  

# 协程的意义  

协程早在 60 年代提出的一种概念,但在后续的发展中,这种理念得不到发展。原因在于 C 语言大行其道,在C 中很忌讳软件开发中,这种无限制的跳转。类似像`goto`语句,几乎编程教科书中都对`goto`语句进行了评判。但就我个人觉得,其实没有必要,像`goto`语句,有其强大之处,在 C 中,函数有很多地方都会走向结束并释放资源,而`goto`语句提供了一个统一地方去释放资源,这也能看到`goto`的作用,因此至今 linux 内核中还有几万个`goto`语句。协程也是如此,但协程更胆大妄为的跳转,早早的跟不上主流。而编程技术发展到今天,很多脚本语言都开始支持了协程,在 JS,lua 和 go 语言都有明显的使用,协程也在实际应用中进一步使用,例如腾讯微信后台协程库`libco`。  

协程允许了一个函数可以跳转到另一个函数当中,执行另一个函数代码,前一个函数的栈是保存下来的,一定时机后也会从其他函数中跳转回来,恢复栈,继续跑下面的代码。这不是普通函数间的调用,协程是直接跳转到另一个栈的函数中去,让 CPU 跑那个函数,那个函数的栈和之前函数的栈根本无任何联系。如何实现,关键就在于栈的保存和当时函数将要跳转的时候,CPU 相关寄存器内容的保存,而`setjmp`和`longjmp`便实现了 CPU 相关寄存器内容的保存,我反汇编也得以实现这一想法(其实直接采用`setjmp`之类也可以实现协程,但后续扩展协程之间交互等,还是需要自己处理汇编来实现)。共享栈式表示在运行中,函数的栈只有一个,在跳转中,前一个函数栈的内容会被保存在堆中,然后要执行的函数栈的内容会被复制到栈中,保持运行。这其中就涉及到很多问题,我在其中也遇到了很多麻烦。这个 Demo 中并不像`libco`中使用`epoll`之类的函数实现 I/O 模型,我这里只是实现了协程间的跳转和相关上下文的切换。

协程先天性的优势在于处理高 I/O 任务的高效,比线程还轻量级的上下文切换,耗费极小的 CPU 性能,函数栈的保存致使它处理 I/O 任务像是在处理同步任务一样,不必考虑异步编程带来的回调地狱。而它的不足在于它无法处理高 CPU 任务,因为协程任务并不是并发执行的,没有像线程那样的时间片轮转机制。当一条线程执行高计算量的任务时,必然会影响到其他协程任务的执行时间。使用协程也会较占据内存空间,因为协程栈的内容是必须保存在内存中,当成千上万条协程执行时,内存会显的比较有压力,但实际上采用共享栈模式以后,协程的内存耗费量已经大规模下降,至少是可以接受的。`libco`也已经达到千万级别的协程支持了。  

# ARM 相关寄存器保存的实现 

 具体内容在 Demo 的`Coroutine.s`中实现了。

如果要阅读相关汇编代码,可以先了解一下 ARM64 的寄存器,具体可看http://bdxnote.blog.163.com/blog/static/844423520155913829432,要写 ARM64 汇编,必须要了解http://blog.csdn.net/xy010902100449/article/details/51902001。  

.text
.align 4
.globl _pushCoroutineEnv
.globl _popCoroutineEnv
.globl _getSP
.globl _getFP
_pushCoroutineEnv:
    stp    x21, x30, [x0]
    mov    x21, x0
    bl     openSVC
    mov    x0, x21
    ldp    x21, x30, [x0]
    mov    x1, sp
    stp    x19, x20, [x0]
    stp    x21, x22, [x0, #0x10]
    stp    x23, x24, [x0, #0x20]
    stp    x25, x26, [x0, #0x30]
    stp    x27, x28, [x0, #0x40]
    stp    x29, x30, [x0, #0x50]
    stp    x29, x1, [x0, #0x60]
    stp    d8, d9, [x0, #0x70]
    stp    d12, d13, [x0, #0x90]
    stp    d14, d15, [x0, #0xa0]
    mov    x0, #0x0
    ret
_popCoroutineEnv:
    sub    sp, sp, #0x10
    mov    x21, x0
    ldr    x0, [x21, #0xb0]
    str    x0, [sp, #0x8]
    add    x1, sp, #0x8
    orr    w0, wzr, #0x3
    mov    x2, #0x0
    bl     openSVC
    mov    x0, x21
    add    sp, sp, #0x10
    ldp    x19, x20, [x0]
    ldp    x21, x22, [x0, #0x10]
    ldp    x23, x24, [x0, #0x20]
    ldp    x25, x26, [x0, #0x30]
    ldp    x27, x28, [x0, #0x40]
    ldp    x29, x30, [x0, #0x50]
    ldp    x29, x2, [x0, #0x60]
    ldp    d8, d9, [x0, #0x70]
    ldp    d10, d11, [x0, #0x80]
    ldp    d12, d13, [x0, #0x90]
    ldp    d14, d15, [x0, #0xa0]
    mov    sp, x2
    ret
_getSP:
    mov   x0, sp
    ret
_getFP:
    mov   x0, x29
    ret
openSVC:
    mov    x16, #0x30
    svc    #0x80
    stp    x29, x30, [sp, #-0x10]!
    mov    x29, sp
    mov    sp, x29
    ldp    x29, x30, [sp], #0x10
    ret

在`Coroutine.s`实现的内容大体像上面那样(后续版本可能会有迭代,不一定跟上面相似),简单介绍其几个函数的作用:  

 _pushCoroutineEnv: 保存调用此函数时,为了后续执行,把 ARM 相关寄存器保存到内存中。  

  _popCoroutineEnv: 从内存保存过的 ARM 相关寄存器的内容从新赋值到对应的寄存器内,要注意的是,此时`LR`(即`x30`寄存器)寄存器已经改变,所以当执行到`ret`语句时,函数的执行地址会跳转到新的`LR`所保存的地址上去,也其实就是`_pushCoroutineEnv`的下一语句中。`_pushCoroutineEnv`和`_popCoroutineEnv`是两两相对的。  

_getSP: 获取到栈底寄存器的内容,为后续栈内容的拷贝使用。  

_getFP: 获取到栈帧寄存器内容,主要是为了创建新的协程任务,让新的协程任务的栈可以依靠到触发函数的栈中。  

openSVC: 开启 ARM 芯片的 SVC 模式,也就是超级用户模式, ARM 芯片有五种模式,在不同模式有不同的作用。只有开启了 SVC 模式,我们的代码才能访问到一些特定的寄存器,不在此模式访问了那些寄存器,会出现硬件错误。这是 ARM 芯片硬件实现的权限管理,避免非内核访问到不该访问的内容。所以每次保存寄存器内容和恢复寄存器内容必须要开启 SVC 模式。

后续如果要增加协程同步等功能的时候,还会修改这些相关的汇编代码,0.1 版本的协程 Demo 只实现了最基础的功能,连 I/O 模型都没有,所以代码量也并不会很多。

# Demo 中相关 API 的介绍

关键函数有 4 个:

typedef void (*coroutineTask)(void);
void coroutine_switch(void);
void coroutine_release(void);
void coroutine_start(coroutineTask entryTask);
void coroutine_create(coroutineTask task);

coroutine_start: 开启协程,并启动一个入口`entryTask`。注意当执行到`coroutine_start`函数后面下一语句时,这时协程已经结束了,协程环境也被释放了。  

coroutine_create: 创建一个协程,注意,在未使用`coroutine_start`前是无法创建协程的,相关环境并未创建好,因此,`coroutine_create`会在`entryTask`或者其他协程里面使用,只有协程里面才能创建另一个协程。  

coroutine_release: 当一个协程要结束时,必须调用`coroutine_release`函数,来释放此条协程的环境,不然,会跳到此条协程第一条代码语句继续执行。  

coroutine_switch: 协程切换,当这条协程需要等待 I/O 的时候,可以切换到另一条协程中,让 CPU 继续执行另一条协程的代码,具体的跳转机制是链表实现的,开发者不必考虑具体会切换到哪一条协程,都是照链表的顺序执行下去的。

# 相关 API 的解析 

这里就不贴代码,具体可看文件`Coroutine.c`。

## coroutine_start    

1.  初始化一下`pthread`相关的东西,确保每条线程之间的协程环境不会杂在一起,这里就体现出面对对象的重要性了,如果使用面对对象根本不会有这种问题,但这里我一开始并没有这样的打算,因为 C API 显的更简洁。   

2.  获取到栈顶和栈底寄存器,必须在这个函数获取,因为这是所有协程的开始点。  

3.  创建空白协程和入口,空白协程用来检测所有协程是否结束任务,如果结束,释放相关资源,跳回线程中继续执行线程代码。  

4.  开启空白协程,执行协程代码。  

## coroutine_create  

1. 获取到协程起始点的栈顶寄存器和栈帧寄存器。  

2. 将栈顶寄存器,栈帧寄存器和`LR`寄存器(task 的地址)相关内容放在链表中。  

## coroutine_release

在链表中把这个协程的释放标志位打开。  

## coroutine_switch  

这个函数是关键。  

1. 获取到当前执行的协程,将它的栈和相关寄存器的内容更新到链表中。  

2. 从链表中获取到下一协程,如果此协程是要被释放,则释放此协程,再去找寻下一协程,直到找到可执行协程,然后,将可执行协程的栈的内容和相关寄存器内容赋值到栈和寄存器中。

3. 如此便会执行下一可执行协程代码。  

## 空白协程  

检测链表中是否只有自己一个协程,如果是,释放协程环境,否,则切换到下一协程。  

# 总结  

为了实现相关逻辑,实际上也遇到了一些问题,但也让我加深了对 ARM 芯片和栈等的了解。  

比如说,要将堆上的内容复制到栈上去,使用`memcpy`函数是会出问题的,因为`memcpy`也会使用到栈,这样在复制的时候,会把`memcpy`的栈干掉,致使出现问题。后续的解决方案是自己从新实现了一个`memcpy`类似的函数,将要使用的变量放在静态区域,因为栈和堆肯定不会在同一内存区域,不会内存冲突问题,这个函数也好写。但带来的问题是,必须对静态区域加互斥锁,不然在不同线程肯定会出问题,这就造成了性能损耗,当然最好的方法是用汇编实现`memcpy`函数,将相关变量放在寄存器内。  

这个 Demo 我用 Xcode9 编译,在 iPhone6 实现运行。按理来说, Demo 中的代码适合所有 64 位的 ARM 芯片,但不同的编译环境肯定是有所区别的。  

有想法就要实现,看起来还是很完美的。  

正文到此结束
Loading...