MIT6.s081/6.828 lectrue5/6:System call entry/exit 以及 Lab4 心得
阅读原文时间:2023年08月20日阅读:8

这篇博客主要复习 lecture05:GDB calling conentions 和 lecture06:System call entry/exit 的内容,外加 Lab4:traps 的心得

前置知识

这里的前置知识是指 lecture05:GDB calling conentions 的内容,是由 TA 来上的,是作为 lecture06 的前置知识,主要讲解了以下三点内容:

  1. 指令集架构的概念
  2. Caller saved 和 Callee saved 寄存器区别
  3. 函数栈帧的结构

我们常说的 x86、ARM、乃至 RISC-V 都是 ISA(Instruction Sets Architecture 指令集架构),即处理器能够理解的指令集,所以当我们说到一个RISC-V处理器时,意味着这个处理器能够理解RISC-V的指令集

指令集就是为每一条指令对应一个二进制编码或者叫 Opcode。当处理器在运行时,如果遇到这些编码,就执行相应的操作。

要让 C 语言能够运行在你的处理器之上。我们首先要写出C程序,之后编译器将C程序编译成汇编语言(这个过程中有一些链接和其他的步骤),汇编器再将汇编语言翻译成二进制文件也就是.obj或者.o文件,汇编器会将每个指令翻译成对应的机器码

由于有不同的指令集架构,所以汇编语言也有不同的种类,如 arm 汇编、x86 汇编、RISC-V 汇编等等

所以可以这么理解:一种架构就是一种指令集,一种指令集就对应一种汇编

RISC-V中的 RISC 是精简指令集(Reduced Instruction Set Computer)的意思,而x86通常被称为 CISC,复杂指令集(Complex Instruction Set Computer)。这两者之间有一些关键的区别:

  • 指令的数量。在RISC-V中,有更少的指令数量。而 x86-64是在1970年代发布的,现在有多于15000条指令。
  • RISC-V指令也更加简单。在x86-64中,很多指令都做了不止一件事情。这些指令中的每一条都执行了一系列复杂的操作并返回结果。RISC-V的指令趋向于完成更简单的工作,相应的也消耗更少的CPU执行时间。
  • 相比x86来说,RISC-V 是开源的。这是市场上唯一的一款开源指令集。

日常生活中接触到的指令集:

Android 手机使用的高通 Snapdragon 处理器基于 ARM 指令集 ,ARM也是一个精简指令集,

RISC-V 的另一个特殊之处在于模块化的思想:它区分了Base Integer Instruction Set(基本指令集)和Standard Extension Instruction Set(扩展指令集)。处理器可以选择性的支持扩展指令集,这种模式使得RISC-V更容易支持向后兼容。 每一个RISC-V处理器可以声明支持了哪些扩展指令集,然后编译器可以根据支持的指令集来编译代码。

寄存器位于 CPU 中,用来存储数据。详见我的博客:[从逻辑门到 CPU](从逻辑门到 CPU - byFMH - 博客园 (cnblogs.com)),寄存器之所以重要是因为汇编代码并不是在内存上执行,而是在CPU 和寄存器上执行: 做add,sub时,运算是 CPU 负责,各种中间结果以及最终结果都会对寄存器进行操作。

RISCV中的寄存器见下表,一共有 64 个寄存器:

通常我们在谈到寄存器的时候,我们会用它们的ABI(application binary interface)名字。不仅是因为这样描述更清晰和标准,同时也因为在写汇编代码的时候使用的也是ABI(应用程序二进制接口)名字

这些寄存器的作用下面会一一介绍,现在只需要了解 a0 到 a7 寄存器是用来作为函数的参数。如果一个函数有超过 8 个参数,我们就需要用内存了。

当可以使用寄存器的时候,我们不会使用内存,我们只在不得不使用内存的场景才使用它

这是很容易混淆的概念,表单中的第4列,Saver列,有两个可能的值Caller,Callee。区别是:

  • Caller Saved 寄存器的值由调用者负责保存和恢复。保存方法通常是把寄存器的值压入堆栈中,调用者保存完成后,在被调用者(子函数)中就可以随意覆盖这些寄存器的值了。最典型的就是 ra (Return address)寄存器,用来保存返回地址(a 调用 b,b 的下一行就是返回地址,这个地址由 a 复制保存和恢复)。
  • Callee Saved 寄存器的值由被调用函数负责保存和恢复。调用者就不必保存这些寄存器的值,直接进行子函数调用,子函数在覆盖这些寄存器之前,需要先保存这些寄存器的值,并在返回前恢复他们。

Stack 属于内存的一部分,对于Stack来说,是从高地址开始向低地址使用。所以栈总是向下增长。

每次调用一个函数,函数都会为自己创建一个 Stack Frame,并且只给自己用。函数通过移动Stack Pointer来完成Stack Frame的空间分配。由于是向下增长,所以“移动”是指对当前的 Stack Pointer做减法

Stack Frame 中包含以下数据:

  • Return address 总是会出现在 Stack Frame 的第一位
  • 指向前一个 Stack Frame 的指针也会出现在栈中的固定位置
  • 一些寄存器的值(Caller saved 寄存器,由调用者保存)
  • 函数的本地变量
  • 如果函数的参数多于8个,额外的参数会出现在Stack中。

CSAPP 中描述了 x86 架构下栈帧的结构:

可以看到并没有 To prev Frame 的设计,而是依靠将返回地址放到栈帧的末尾,来实现被调函数返回后自动找到返回地址,x86的 call 和 return 是入栈出栈,感觉是一种更巧妙的设计

Trap 机制

前面我们已经讲过,操作系统的一个重要特点是隔离性,所以有了用户空间内核空间的设计,但是进程如何由用户空间切换到内核空间,涉及到了很多细节。这是本节的讨论重点。

所谓 trap,就是一个进程从用户空间到内核空间的切换,这里要强调一句,trap 不是一个瞬间完成的,而是一个过程,这个过程主要包括以下内容:

  • 保存用户寄存器中的内容
  • 将 user page table 切换为 kernel page table
  • 切换堆栈空间

只有完成了这一系列步骤,才能说进程由用户空间切换到了内核空间。

在前置知识中,已经知道 RISC-V 有 64 个寄存器,其中有 32 个通用寄存器,之所以叫通用,是因为这些寄存器用户程序也可以使用。

trap 的大致步骤如下:

  1. 进程运行在用户空间,发生了系统调用,于是准备 trap 到内核空间
  2. 首先,保存 32 个用户寄存器的值,以便内核代码运行结束后恢复用户空间的上下文
  3. 然后,将 program counter (以下简称 pc)的值保存下来,以便内核代码运行结束后从中断的位置继续执行用户程序,这个值将会保存在 SEPC 寄存器中
  4. 然后,将 mode 标志位由 user mode 改为 kernel mode
  5. 然后,切换页表,将 user page table 切换为 kernlel page table,这里主要是修改 SATP 寄存器的值,这个寄存器保存了页表指针
  6. 然后,为内核代码设置堆栈空间,主要是修改堆栈寄存器的值,因为这里保存了堆栈的指针
  7. 完成 trap,开始运行内核的 C 代码

SEPC 寄存器:

SEPC 寄存器是 RISC-V 架构中的一个特殊寄存器,全称为 Supervisor Exception Program Counter。当发生异常时,处理器会将异常的下一条指令的地址存储到 SEPC 寄存器中,然后根据异常类型跳转到相应的异常处理程序。需要注意的是,SEPC 寄存器不仅在异常处理时使用,还可以用于一些特权指令(如 SRET 和 MRET)的返回地址保存。这些指令用于从异常或中断处理程序返回到正常的程序执行流程中。

STVEC 寄存器:

STVEC 寄存器是 RISC-V 架构中的一个特殊寄存器,全称为 Supervisor Trap Vector Base Address Register。这个寄存器里存的是一段异常处理程序的地址,需要处理异常时,只需要从这个寄存器中读取出程序地址,加载到 pc 寄存器即可

在 lecture01 的学习中我们已经了解到 ecall 是RISC-V架构中的一条特权指令,系统调用时我们就是使用这条指令将进程从用户态切换到内核态,但是并没有详细介绍 ecall 指令的细节,其实这也是 trap 的关键。

1. ecall 指令干了三件事

ecall 指令是 RISC-V 中的指令,所以这三件事都是由 RISC-V 的 harware 完成的,无需 OS 介入

  1. 将 mode flag 由 user mode 改为 kernel mode
  2. 将 pc 寄存器的值保存到 SEPC 寄存器中
  3. 将 STVEC 寄存器中的值加载到 pc 寄存器中

打印出 STVEC 寄存器的值,见下图,发现是 0x3ffffff000,这个地址是 RISC-V 的 sv39 模式下的最高页的首地址1<<39 - PAGESIZE

虽然现在 mode flags 已经改为 kernel mode,但是页表还没有切换,所以依旧查询 user mode 的虚拟地址空间,可以看出0x3ffffff000正是 Tampoline page:

而程序总是遵循 pc 寄存器的指示,所以 ecall 指令之后,就会执行 trampoline page 的指令。

2.执行 Tampoline page 中的代码:uservec

trampoline page 中的代码如下,位于文件 trampoline.S 中,是用汇编语言写的,代码如下:

uservec:
    #
        # trap.c sets stvec to point here, so
        # traps from user space start here,
        # in supervisor mode, but with a
        # user page table.
        #

        # save user a0 in sscratch so
        # a0 can be used to get at TRAPFRAME.
        csrw sscratch, a0

        # each process has a separate p->trapframe memory area,
        # but it's mapped to the same virtual address
        # (TRAPFRAME) in every process's user page table.
        li a0, TRAPFRAME

        # save the user registers in TRAPFRAME
        sd ra, 40(a0)
        sd sp, 48(a0)
        sd gp, 56(a0)
        sd tp, 64(a0)
        sd t0, 72(a0)
        sd t1, 80(a0)
        sd t2, 88(a0)
        sd s0, 96(a0)
        sd s1, 104(a0)
        sd a1, 120(a0)
        sd a2, 128(a0)
        sd a3, 136(a0)
        sd a4, 144(a0)
        sd a5, 152(a0)
        sd a6, 160(a0)
        sd a7, 168(a0)
        sd s2, 176(a0)
        sd s3, 184(a0)
        sd s4, 192(a0)
        sd s5, 200(a0)
        sd s6, 208(a0)
        sd s7, 216(a0)
        sd s8, 224(a0)
        sd s9, 232(a0)
        sd s10, 240(a0)
        sd s11, 248(a0)
        sd t3, 256(a0)
        sd t4, 264(a0)
        sd t5, 272(a0)
        sd t6, 280(a0)

    # save the user a0 in p->trapframe->a0
        csrr t0, sscratch
        sd t0, 112(a0)

        # initialize kernel stack pointer, from p->trapframe->kernel_sp
        ld sp, 8(a0)

        # make tp hold the current hartid, from p->trapframe->kernel_hartid
        ld tp, 32(a0)

        # load the address of usertrap(), from p->trapframe->kernel_trap
        ld t0, 16(a0)

        # fetch the kernel page table address, from p->trapframe->kernel_satp.
        ld t1, 0(a0)

        # wait for any previous memory operations to complete, so that
        # they use the user page table.
        sfence.vma zero, zero

        # install the kernel page table.
        csrw satp, t1

        # flush now-stale user entries from the TLB.
        sfence.vma zero, zero

        # jump to usertrap(), which does not return
        jr t0

这里的代码也很好懂,我就开门见山,直接总结 trampoline page 中的代码做了些什么事:

  1. csrw sscratch, a0 ,这个指令交换了 a0 和 sscratch 两个寄存器的内容,交换之后,a0 的值是0x3fffffe000,这是 trapframe page 的虚拟地址。它之前保存在SSCRATCH寄存器中;SSCRATCH寄存器的内容是 2,这是 a0 寄存器之前的值。a0 寄存器保存的是系统调用的第一个参数;所以通过这次交换,即保存了 a0 寄存器的旧值,又有了指向 trapframe page 的指针。

  2. 将 32 个用户寄存器的值保存到 trapframe page 中(应该是 31 个 ,zero 寄存器的值不用保存,但这里为了方便记忆),XV6 在每个 user page table 中都映射了 trapframe page(就在 trampoline page 的下面),这样每个进程都有自己的 trapframe page。(特别注意,这两个 page 对应的PTE并没有设置PTE_u标志位,所以用户程序不可以写这两个 page,trap 依旧是安全的)

  3. 从 trapframe page 中将以下数据读取出来:(因为这 4 个寄存器总是一起行动,所以我给他们起了个中二的名字: 4 虎将

    • 将 kernel 的栈顶指针加载到寄存器 sp 寄存器中中
    • 将 kernel 的 hartid (CPU 核编号)加载到 tp 寄存器中
    • 将 usertrap() 函数的地址加载到 t0 寄存器中(打印出来是 0x800027a0,属于虚拟地址空间中的 kernel text 区域)
    • 将 kernel 的 page table 的指针加载到 t1 寄存器中

  4. 切换 page table,将 kernel page table 的指针加载到 satp 寄存器中

  5. 通过 jump 指令跳转到函数:usertrap(),这是内核的C代码

总结一下,Trampoline page 中的代码主要完成了以下三件事:

  1. 保存用户寄存器数据
  2. 为内核代码设置好堆栈空间
  3. 切换页表

一个有趣的思考就是,为什么切换页表后程序没有崩溃?

答案:因为目前程序还在 trampoline page 中执行,这个 page 在两个地址空间中的 va 相同,在两个页表中的映射相同(这两个page table 中其他所有的映射都是不同的,只有trampoline page的映射是一样的),所以最后得到的物理地址也相同,所以不管使用那个 page table,都在相同的物理地址上,做到了无缝切换

之所以叫trampoline page,是因为某种程度在它上面“弹跳”了一下,然后从用户空间走到了内核空间。

3.usertrap()中的代码

usertrap 函数属于内核代码了,内存中位于 kernel 虚拟地址空间的 kernel text 段,这里的代码细节很多,我就不一一讲解了,细节在图中都说明了,我只从宏观上讲,usertrap 负责判断触发 trap 的原因,并执行响应的处理:

  • 若是 syscall,则调用对应的系统调用
  • 若是设备中断,则跳转到响应的处理代码
  • 若是 page fault,则杀死进程
  • 最后执行 usertrapret() 函数

提一个特别的细节:uservec 中切换了页表,在切换之前使用的是 user page table,所以 trapframe page 在 user page table 中映射到了物理内存的某个 page 中;切换页表后,到了 usrtrap() 函数中,使用到了 trapframe page,那么在 kernel page table 中,也可以使用 trapframe page 吗?

答案:kalloc() 函数分配的 trapframe page 在 kernel space 中的 va 和 pa 是一样的,在每个用户进程创建(我习惯叫出厂)时,已经为这个进程设置好了页表,这个页表只配置了 trampoline page 和 trapframe page,而 trapframe page 的虚拟地址是次高页,物理地址是 kalloc() 函数分配的,而内核维护了所有进程的信息,每个进程的 trapframe page 在 kernel space 中的 va/pa 都保存在 p->trapframe 字段中,所以 kernel 可以访问任意进程的 trapframe page

4.usertrapret()中的代码

这个函数也是细节颇多,我都写在图中了,总结如下:

  • 恢复 STVEC 寄存器中的值,指向 trampoline page 中的 uservec,以便下次 trap 时,ecall 指令跳转到 trampoline page
  • 填充 trapframe page 中的 4 虎将寄存器,以便下次 trap 时,trampoline page 中的 uservec 使用
  • 恢复 SEPC 寄存器的值,指向 ecall 的下一条指令
  • 跳转到 trampoline page 中的 userret 汇编代码,在那里切换回 user page table,因为trampoline page 在两个页表中的物理地址相同,所以在那里才可以安全切换页表,程序不会崩溃

5.再次执行 Tampoline page 中的代码:userret

这里的代码和 uservec 一样,也位于 Tampoline page ,回忆 uservec 中的代码主要完成了以下三件事:

  1. 保存用户寄存器数据
  2. 为内核代码设置好堆栈空间
  3. 切换页表,切换为 kernel page table

对应的,userret 的代码主要完成 3 件事:

  1. 切换页表,切换为 user page table
  2. 恢复用户寄存器中的值
  3. 调用 RISC-V 中的 sret 指令,跳转到用户空间,这样 SEPC 寄存器中的值(目前是 ecall 的下一条指令)就会加载到 pc 寄存器中,从而完成整个 trap 的返回

总结

上一张全家福,从这个图中也可以知道 trap 的流程 细节多到爆炸:(大图看不清没关系,每个部分都已经都在前面有详细描述,这里只是给个 trap 整体概念)

Lab 4 心得

所有代码见:我的GitHub实现(记得切换到相应分支)

这个任务主要是读 RISC-V 的汇编代码,回答一些问题。

首先看 C 源码:

#include "kernel/param.h"
#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"

int g(int x) {
  return x+3;
}

int f(int x) {
  return g(x);
}

void main(void) {
  printf("%d %d\n", f(8)+1, 13);
  exit(0);
}

使用命令 make fs.img 编译 user/call.c ,生成一个可读的汇编代码文件 user/call.asm

问题 1:Which registers contain arguments to functions? For example, which register holds 13 in main’s call to printf?

哪个寄存器存储了函数的参数?比如 main 函数中调用了 printf 函数,那个寄存器存储了 printf 函数的参数 13?

回答:这个汇编代码的可读性非常好,有汇编和 C 的对比,从汇编代码中可以找到以下语句:

  printf("%d %d\n", f(8)+1, 13);
  24:    4635                    li  a2,13
  26:    45b1                    li  a1,12

可以清楚地看到执行 printf("%d %d\n", f(8)+1, 13);,先将 13 和 12 分别加载到寄存器 a2 和 a3 中,而第一个参数"%d %d\n"则会加载到 a1 寄存器中,所以寄存器参数就是 a0~a7,超过 8 个参数会借用栈空间

问题 2:Where is the call to function f in the assembly code for main? Where is the call to g? (Hint: the compiler may inline functions.

main 函数的汇编代码中,在哪里调用 f?在哪里调用 g?

回答:在 C 代码中,printf("%d %d\n", f(8)+1, 13);显然一次性调用了 f 和 g,但是对应的汇编只有:

  26:    45b1                    li  a1,12

说明编译器进行了优化,直接将 f(8)+1 替换为 12,所以main 的汇编代码没有调用 fg 函数,而是进行了内联优化。

问题 3:At what address is the function printf located?

函数 printf 的地址在哪里?

回答:文件中全局搜索一下即可:课件地址在 000000000000065a

000000000000065a <printf>:

void
printf(const char *fmt, ...)
{
    ...
}

问题 4:What value is in the register ra just after the jalr to printf in main?

jalr命令后,ra寄存器的值是多少?

回答:仔细阅读 [reference](RISC-V assembly - build a OS --- RISC-V组装-构建操作系统 (gitbook.io)),有如下总结描述:

When your program is calling a function, it prepares the return address first before jump to the function. So that when the function about to return, it knows where it should return to. jalr saves return address for current PC+4, jump to the calling function. ra contains the return address.

从中我们可以知道在函数调用时,跳转之前会先准备返回地址,以便在返回时继续,jalr 会保存返回地址为 PC+4, 从下面可知跳转之前的地址是 0x34,所以返回地址就是0x38

  printf("%d %d\n", f(8)+1, 13);
  24:    4635                    li  a2,13
  26:    45b1                    li  a1,12
  28:    00000517            auipc   a0,0x0
  2c:    7d850513            addi    a0,a0,2008 # 800 <malloc+0xe8>
  30:    00000097            auipc   ra,0x0
  34:    62a080e7            jalr    1578(ra) # 65a <printf>

问题 5:Run the following code.

unsigned int i = 0x00646c72;
printf("H%x Wo%s", 57616, &i);

What is the output? Here’s an ASCII table that maps bytes to characters.

The output depends on that fact that the RISC-V is little-endian. If the RISC-V were instead big-endian what would you set i to in order to yield the same output? Would you need to change 57616 to a different value?

Here’s a description of little- and big-endian and a more whimsical description.

回答:输出结果为:He110 World

解释:(参考博客:xv6-labs-2022 Lab4 traps 答案与解析 (chens.life)

  1. 57616 转换为 16 进制为 e110,格式化描述符 %x 打印出了它的 16 进制值。所以是He110

  2. 在小端(little-endian)处理器中,数据0x00646c72高字节存储在内存的高位,那么从内存低位,也就是低字节开始读取,对应的 ASCII 字符为 rld;在 大端(big-endian)处理器中,数据 0x00646c72高字节存储在内存的低位,那么从内存低位,也就是高字节开始读取其 ASCII 码为 dlr

    所以如果要求大端序和小端序输出相同的内容 ,那么在其为大端序的时候,i 的值应该为 0x726c64,这样才能保证从内存低位读取时的输出为 rld

    无论 57616 在大端序还是小端序,它的二进制值都为 e110 。大端序和小端序只是改变了多字节数据在内存中的存放方式,并不改变其真正的值的大小,所以 57616 始终打印为二进制 e110

问题 6:In the following code, what is going to be printed after 'y='? (note: the answer is not a specific value.) Why does this happen?

printf("x=%d y=%d", 3);

回答:这里明显看到少传了一个 y 的参数,但是系统调用依旧会去 a3 寄存器中找参数,所以会打印出一个垃圾值

这个任务比较简单,要求打印出函数的调用栈,代码见:我的GitHub实现,这里只说几点注意:

  1. 要想完成这个任务,需要明白打印函数的调用栈其实就是遍历进程的栈帧,这需要了解栈帧的结构:

    起始地址,也就是距离栈顶的栈帧地址保存在 fp 寄存器中,所以返回地址在栈帧指针的 -8 偏移量处;前一个帧指针位于当前栈帧指针 -16 偏移量处

  2. 在 xv6 中,所有的进程的内核栈空间只有 1 个 page,该进程的所有的栈帧都在同一个页上面,所以遍历栈帧的停止条件就是使用PGROUNDUP(fp) 来定位帧指针所在的页面的最高地址,当 fp 一直向上,和 PGROUNDUP(fp) 一致时,就说明到栈底了,也即遍历完毕。

这个任务比较有趣,给 OS 添加一个新特性,使得在一个进程运行时,可以周期性执行其他进程(即便只有一个 CPU 核)

具体实现就是添加一个新的系统调用 sigalarm(interval, handler)。如果一个应用调用了 sigalarm(n, fn)那么这个进程每消耗 n 个 ticks,内核就要调用函数 fn

这里的核心解决方案就是:正常用户程序A trap 到内核空间执行完任务后,会回到用户空间,而 p->trapframe->epc 则保存了用户空间的返回地址,所以我们只需在这里“做一点手脚”,我们把 p->trapframe->epc 修改为另一个用户程序 B 的地址,这样就会从内核空间直接返回到用户空间 B,从而完成调用 B 的实现。

至于何如做到“周期性”,这利用了 XV6 已实现的特性:每一次 tick,硬件时钟都会强制执行一个中断,无需我们介入,该中断在 kernel/trap.c 中处理:

  if(which_dev == 2)
  {
      // printf("p->ticks: %d, and check p->alarm_interval: %d\n", p->pticks,p->alarm_interval);
      if(p->alarm_interval == 0) { //sigalarm(0,0)
        //ok 直接 usertrapret();
      } else if(ticks - p->last_tick >= p->alarm_interval) {//到达指定时间间隔
        // printf("time hit!: %d\n", p->alarm_interval);
        p->last_tick = ticks;
        if(p->alarm_trapframe == 0){//没有handler在运行
          // printf("change epc, run halder!\n");
          p->alarm_trapframe = (struct trapframe *)kalloc();//分配一个新page
          memmove(p->alarm_trapframe, p->trapframe, PGSIZE);//用来保存原trapframe
          p->trapframe->epc = p->alarm_handler;//将epc字段改为handler的地址,这样经过(p->trapframe->epc)-> SEPC -> PC, trap返回用户空间后就直接进入了handler函数
        } else {
          // printf("please wait,there is a handler is running......\n");
        }
      }else{
        yield();
      }

这个方案要小心翼翼地实现,因为害怕破坏用户空间 A 的任何状态,从而不能正确返回,所以上面的代码会有一个 p->alarm_trapframe 用来存储 A 程序的trapframe page,然后在 sigreturn 中恢复,所以这种机制的调用过程如下:

说实话,这种调用方式还是比较丑陋的,尤其是 funcB 中对于 sigreturn 的调用。

OK,以上就是 lecture05:GDB calling conentions 和 lecture06:System call entry/exit 的内容,外加 Lab4:traps 的心得 的所有内容了,这一节总结来看就是细节多到爆炸,但是仔细理清的话也不是不可接受,尤其是 trampoline page 的设计非常巧妙,在这里实现了页表的切换。

所有代码见:我的GitHub实现(记得切换到相应分支)

手机扫一扫

移动阅读更方便

阿里云服务器
腾讯云服务器
七牛云服务器