美文网首页
Go 函数调用约定与栈

Go 函数调用约定与栈

作者: WqyJh | 来源:发表于2022-12-08 00:21 被阅读0次

    调用约定 (calling convention) 是调用方 (caller) 与被调用方 (callee) 对于函数调用的参数与返回值的传递方式、传递顺序的约定,只有双方遵守相同的约定,才能保证参数的正确传递,以及保证函数栈的平衡。

    Go 1.17 版本之前,采用基于栈的调用约定,即函数的参数与返回值都通过栈来传递,这种方式的优点是实现简单,无需考虑不同架构下cpu寄存器的差异,更容易实现跨平台。然而由于内存的访问时延远高于寄存器,导致 Golang 的函数调用性能要低于 C/C++ 等使用寄存器传递参数的编程语言。

    Golang 在 1.17 版本中引入了基于寄存器的函数调用约定,本文将简要介绍该调用约定,并通过具体的实验来验证并加深理解。

    Go internal ABI specification

    Go internal ABI specification 详细介绍了 Go 1.17 之后的应用程序二进制接口 (Application Binary Interface, ABI)。

    首先是各种基础类型的大小与内存对齐约定,例如 int 类型在 64 位架构下的大小为 8 字节,并且需要对齐到 8 字节。栈上的变量如果不对齐,编译器会插入 padding 使之对齐。

    Type 64-bit 32-bit
    Size Align Size Align
    bool, uint8, int8 1 1 1 1
    uint16, int16 2 2 2 2
    uint32, int32 4 4 4 4
    uint64, int64 8 8 8 4
    int, uint 8 8 4 4
    float32 4 4 4 4
    float64 8 8 8 4
    complex64 8 4 8 4
    complex128 16 8 16 4
    uintptr, *T, unsafe.Pointer 8 8 4 4

    栈帧结构如下:栈帧由高地址向低地址增长,依次是上一个函数的程序计数器 (Program Counter, PC)、上一个函数的基址寄存器 (Base Pointer, BP)、函数的局部变量、调用函数的返回值、调用函数的参数。

    +------------------------------+
    | return PC                    |
    | RBP on entry                 |
    | ... locals ...               |
    | ... outgoing arguments ...   |
    +------------------------------+ ↓ lower addresses
    

    寄存器约定 (amd64):

    1. 用于整型参数和返回值的通用寄存器:RAX、RBX、RCX、RDI、RSI、R8、R9、R10、R11
    2. 用于浮点参数和返回值的浮点寄存器:X0~X14
    3. 其他是用于特殊目的的寄存器:
    寄存器 名称含义 值含义
    RSP 栈顶指针 (Stack pointer) 固定
    RBP 栈帧指针(基址)(Frame pointer) 固定
    RDX 闭包上下文指针 临时
    R12 临时
    R13 临时
    R14 当前 go routine 临时
    R15 GOT reference temporary 动态链接时固定
    X15 零值寄存器 固定为0

    变量在栈上的排列顺序:从低地址到高地址依次为通过栈传递的 receiver 参数、通过栈传递的若干个参数、通过栈传递的函数返回值、寄存器参数溢出空间。其中所有参数都会根据其类型处理内存对齐,不对齐的情况下编译器计算对齐所需的 padding 空间使之对齐。

    +------------------------------+
    |             . . .            |
    | 2nd reg argument spill space |
    | 1st reg argument spill space |
    | <pointer-sized alignment>    |
    |             . . .            |
    | 2nd stack-assigned result    |
    | 1st stack-assigned result    |
    | <pointer-sized alignment>    |
    |             . . .            |
    | 2nd stack-assigned argument  |
    | 1st stack-assigned argument  |
    | stack-assigned receiver      |
    +------------------------------+ ↓ lower addresses
    

    函数的调用者负责保存寄存器,被调用者无需保存寄存器,因此可以会覆盖任何没有固定含义的寄存器,包括参数寄存器。

    代码示例

    示例代码如下,保存为 main.go

    package main
    
    func main() {
        r0, r1, r2, r3, r4, r5, r6, r7, r8, r9, r10, r11, r12 := test(71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83)
        ret := r0 + r1 + r2 + r3 + r4 + r5 + r6 + r7 + r8 + r9 + r10 + r11 + r12
        println(ret)
    }
    
    func test(a0 int, a1 int, a2 int, a3 int, a4 int, a5 int, a6 int, a7 int, a8 int, a9 int, a10 int, a11 int, a12 int) (int, int, int, int, int, int, int, int, int, int, int, int, int) {
        var arr [13]int
        arr[0] = a0 + 31
        arr[1] = a1 + 32
        arr[2] = a1 + 33
        arr[3] = a1 + 34
        arr[4] = a1 + 35
        arr[5] = a1 + 36
        arr[6] = a0 + 37
        arr[7] = a1 + 38
        arr[8] = a1 + 39
        arr[9] = a1 + 40
        arr[10] = a1 + 41
        arr[11] = a1 + 42
        arr[12] = a1 + 43
    
        arr[2] = arr[0] + arr[1]
        arr[4] = arr[2] + arr[3]
        arr[6] = arr[4] + arr[5]
        arr[8] = arr[6] + arr[7]
        arr[10] = arr[8] + arr[9]
        arr[12] = arr[10] + arr[11]
    
        arr[1] = arr[1] * 3
        arr[1] = arr[1] + 11
    
        return arr[0], arr[1], arr[2], arr[3], arr[4], arr[5], arr[6], arr[7], arr[8], arr[9], arr[10], arr[11], arr[12]
    }
    

    编译并输出到 main.s 文件中。go 版本为 go1.18.7 linux/amd64

    go tool compile -S main.go > main.s
    

    在 go 代码中,main 函数首先调用 test 函数,传递了 13 个 int 参数,分别为 71~83 之间依次递增的数字。
    在汇编代码中,main 函数通过 SUBQ $152, SP 开辟了 152 字节的栈空间,然后将函数调用方的 BP 保存到栈底即 144(SP) 处,接着将 144(SP) 处设置为新的 BPFUNCDATA 指令和 PCDATA 指令与垃圾回收有关,这里不讨论。
    main 函数的主要逻辑中,首先将 80 移动到栈顶,然后将 81 移动到栈顶的下一个 slot,将 82 和 83 移动到接下来的 slot 中,接着依次将 71~79 移动到 AX~R11 寄存器中,然后调用 test 函数。在 test 函数返回之后,从 AX~R1132(SP)~56(SP) 中取出返回值,累加到 DX 寄存器上,随机将 DX 中的值写入 136(SP) 也就是 ret 变量,后面的一段代码是内联的 println 代码,printlockprintunlock 表示加锁与解锁,加锁后将 136(SP) 写入 AX 作为参数,调用 runtime.printnl 函数输出,忽略返回值。最后将 144(SP) 即调用方 BP 写回 BP,通过 ADDQ $152, SP 释放 main 函数的栈空间,然后执行 RET 指令恢复 PC

    "".main STEXT size=235 args=0x0 locals=0x98 funcid=0x0 align=0x0
        0x0000 00000 (main.go:3)    TEXT    "".main(SB), ABIInternal, $152-0
        0x0000 00000 (main.go:3)    LEAQ    -24(SP), R12
        0x0005 00005 (main.go:3)    CMPQ    R12, 16(R14)
        0x0009 00009 (main.go:3)    PCDATA  $0, $-2
        0x0009 00009 (main.go:3)    JLS 225
        0x000f 00015 (main.go:3)    PCDATA  $0, $-1
        0x000f 00015 (main.go:3)    SUBQ    $152, SP
        0x0016 00022 (main.go:3)    MOVQ    BP, 144(SP)
        0x001e 00030 (main.go:3)    LEAQ    144(SP), BP
        0x0026 00038 (main.go:3)    FUNCDATA    $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0026 00038 (main.go:3)    FUNCDATA    $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0026 00038 (main.go:4)    MOVQ    $80, (SP)
        0x002e 00046 (main.go:4)    MOVQ    $81, 8(SP)
        0x0037 00055 (main.go:4)    MOVQ    $82, 16(SP)
        0x0040 00064 (main.go:4)    MOVQ    $83, 24(SP)
        0x0049 00073 (main.go:4)    MOVL    $71, AX
        0x004e 00078 (main.go:4)    MOVL    $72, BX
        0x0053 00083 (main.go:4)    MOVL    $73, CX
        0x0058 00088 (main.go:4)    MOVL    $74, DI
        0x005d 00093 (main.go:4)    MOVL    $75, SI
        0x0062 00098 (main.go:4)    MOVL    $76, R8
        0x0068 00104 (main.go:4)    MOVL    $77, R9
        0x006e 00110 (main.go:4)    MOVL    $78, R10
        0x0074 00116 (main.go:4)    MOVL    $79, R11
        0x007a 00122 (main.go:4)    PCDATA  $1, $0
        0x007a 00122 (main.go:4)    CALL    "".test(SB)
        0x007f 00127 (main.go:5)    LEAQ    (BX)(AX*1), DX
        0x0083 00131 (main.go:5)    ADDQ    CX, DX
        0x0086 00134 (main.go:5)    ADDQ    DI, DX
        0x0089 00137 (main.go:5)    ADDQ    SI, DX
        0x008c 00140 (main.go:5)    ADDQ    R8, DX
        0x008f 00143 (main.go:5)    ADDQ    R9, DX
        0x0092 00146 (main.go:5)    ADDQ    R10, DX
        0x0095 00149 (main.go:5)    ADDQ    R11, DX
        0x0098 00152 (main.go:5)    ADDQ    32(SP), DX
        0x009d 00157 (main.go:5)    ADDQ    40(SP), DX
        0x00a2 00162 (main.go:5)    ADDQ    48(SP), DX
        0x00a7 00167 (main.go:5)    ADDQ    56(SP), DX
        0x00ac 00172 (main.go:5)    MOVQ    DX, "".ret+136(SP)
        0x00b4 00180 (main.go:6)    CALL    runtime.printlock(SB)
        0x00b9 00185 (main.go:6)    MOVQ    "".ret+136(SP), AX
        0x00c1 00193 (main.go:6)    CALL    runtime.printint(SB)
        0x00c6 00198 (main.go:6)    CALL    runtime.printnl(SB)
        0x00cb 00203 (main.go:6)    CALL    runtime.printunlock(SB)
        0x00d0 00208 (main.go:7)    MOVQ    144(SP), BP
        0x00d8 00216 (main.go:7)    ADDQ    $152, SP
        0x00df 00223 (main.go:7)    NOP
        0x00e0 00224 (main.go:7)    RET
        0x00e1 00225 (main.go:7)    NOP
        0x00e1 00225 (main.go:3)    PCDATA  $1, $-1
        0x00e1 00225 (main.go:3)    PCDATA  $0, $-2
        0x00e1 00225 (main.go:3)    CALL    runtime.morestack_noctxt(SB)
        0x00e6 00230 (main.go:3)    PCDATA  $0, $-1
        0x00e6 00230 (main.go:3)    JMP 0
    

    test 函数中申请了一个长度为 13 的数组,首先将参数依次加上一个不同的值,复制给数组中对应顺序的元素。

    test 函数的汇编代码如下,首先通过 SUBQ $112, SP 开辟了 112 字节的栈空间,然后将调用方也就是 main 函数的 BP 放入栈底 104(SP) 的位置,然后将 104(SP) 的地址设置为新的 BP

    接着开始计算 ,LEAQ 31(AX), R12 可以理解为 R12 = AX + 31,随后 MOVQ R12, "".arr(SP)R12 保存到 (SP) 处,即 arr[0],故这两条汇编指令实现了 arr[0] = a0 + 31。接下来的汇编指令以此类推,使用 R12DX 作为数据寄存器,存放中间结果,接着将结果保存到栈中。有一个例外是 DI,由于数据清零指令 DUFFZERO 会修改 DI,因此先将 DI 保存到 DX,并用 DX 完成对应的计算。从 arr[9] = a9 + 40 开始,变成先 MOVQ "".a9+120(SP), DX 从栈中取出参数存到 DX,然后 ADDQ $40, DXDX 做加法,最后 MOVQ DX, "".arr+72(SP) 将结果写入数组。

    接下来是将数组中的两个元素相加并赋值给另一个元素,例如 arr[2] = arr[0] + arr[1]。汇编代码中,首先 MOVQ "".arr(SP), DXarr[0] 存入 DX,然后 ADDQ "".arr+8(SP), DX8(SP) 添加到 DX 上,最后 MOVQ DX, "".arr+16(SP)DX 写入 16(SP)

    对于 arr[1] = arr[1] * 3,首先 MOVQ "".arr+8(SP), R13arr[1] 存入 R13,接着 LEAQ (R13)(R13*2), R15 相当于 R15=R13+R13*2,从而实现 3 倍的乘法,然后 MOVQ R15, "".arr+8(SP)R15 写回栈中。

    计算完成后依次将 a[0] ~ a[12] 写入 AX`R15`、`152(SP)`176(SP)。最后 MOVQ 104(SP), BP 恢复 BPADDQ $112, SP 恢复 SPRET 恢复 PC

    注1:RET 指令与 CALL 指令配对使用,每一个函数都应该以 RET 结尾。CALL 指令首先将 CALL 指令的下一条指令入栈(等价于PUSH,会修改 SP),然后修改 PC 跳转到被调函数处执行,被调函数返回时调用 RET 指令,从栈中弹出 PC (等价于 POP,会修改SP)从而执行 CALL 指令的下一条指令。

    注2:MOVUPS X15, "".arr(SP) 指令表示 arr 位于栈顶,X15 是零值寄存器,MOVUPSX15 中取出 0,赋值到 arr 的前 16 个字节中。DUFFZERO 用于清空内存地址,从而实现栈上变量的 0 值初始化。参数没看懂。

    "".test STEXT nosplit size=395 args=0x88 locals=0x70 funcid=0x0 align=0x0
        0x0000 00000 (main.go:9)    TEXT    "".test(SB), NOSPLIT|ABIInternal, $112-136
        0x0000 00000 (main.go:9)    SUBQ    $112, SP
        0x0004 00004 (main.go:9)    MOVQ    BP, 104(SP)
        0x0009 00009 (main.go:9)    LEAQ    104(SP), BP
        0x000e 00014 (main.go:9)    FUNCDATA    $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x000e 00014 (main.go:9)    FUNCDATA    $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x000e 00014 (main.go:9)    FUNCDATA    $5, "".test.arginfo1(SB)
        0x000e 00014 (main.go:9)    FUNCDATA    $6, "".test.argliveinfo(SB)
        0x000e 00014 (main.go:9)    PCDATA  $3, $1
        0x000e 00014 (main.go:10)   MOVUPS  X15, "".arr(SP)
        0x0013 00019 (main.go:9)    MOVQ    DI, DX
        0x0016 00022 (main.go:10)   LEAQ    "".arr+8(SP), DI
        0x001b 00027 (main.go:10)   PCDATA  $0, $-2
        0x001b 00027 (main.go:10)   LEAQ    -32(DI), DI
        0x001f 00031 (main.go:10)   NOP
        0x0020 00032 (main.go:10)   DUFFZERO    $331
        0x0033 00051 (main.go:11)   PCDATA  $0, $-1
        0x0033 00051 (main.go:11)   LEAQ    31(AX), R12
        0x0037 00055 (main.go:11)   MOVQ    R12, "".arr(SP)
        0x003b 00059 (main.go:12)   LEAQ    32(BX), R12
        0x003f 00063 (main.go:12)   MOVQ    R12, "".arr+8(SP)
        0x0044 00068 (main.go:13)   LEAQ    33(CX), R12
        0x0048 00072 (main.go:13)   MOVQ    R12, "".arr+16(SP)
        0x004d 00077 (main.go:14)   ADDQ    $34, DX
        0x0051 00081 (main.go:14)   MOVQ    DX, "".arr+24(SP)
        0x0056 00086 (main.go:15)   LEAQ    35(SI), DX
        0x005a 00090 (main.go:15)   MOVQ    DX, "".arr+32(SP)
        0x005f 00095 (main.go:16)   LEAQ    36(R8), DX
        0x0063 00099 (main.go:16)   MOVQ    DX, "".arr+40(SP)
        0x0068 00104 (main.go:17)   LEAQ    37(R9), DX
        0x006c 00108 (main.go:17)   MOVQ    DX, "".arr+48(SP)
        0x0071 00113 (main.go:18)   LEAQ    38(R10), DX
        0x0075 00117 (main.go:18)   MOVQ    DX, "".arr+56(SP)
        0x007a 00122 (main.go:19)   LEAQ    39(R11), DX
        0x007e 00126 (main.go:19)   MOVQ    DX, "".arr+64(SP)
        0x0083 00131 (main.go:20)   MOVQ    "".a9+120(SP), DX
        0x0088 00136 (main.go:20)   ADDQ    $40, DX
        0x008c 00140 (main.go:20)   MOVQ    DX, "".arr+72(SP)
        0x0091 00145 (main.go:21)   MOVQ    "".a10+128(SP), DX
        0x0099 00153 (main.go:21)   ADDQ    $41, DX
        0x009d 00157 (main.go:21)   MOVQ    DX, "".arr+80(SP)
        0x00a2 00162 (main.go:22)   MOVQ    "".a11+136(SP), DX
        0x00aa 00170 (main.go:22)   ADDQ    $42, DX
        0x00ae 00174 (main.go:22)   MOVQ    DX, "".arr+88(SP)
        0x00b3 00179 (main.go:23)   MOVQ    "".a12+144(SP), DX
        0x00bb 00187 (main.go:23)   ADDQ    $43, DX
        0x00bf 00191 (main.go:23)   MOVQ    DX, "".arr+96(SP)
        0x00c4 00196 (main.go:25)   MOVQ    "".arr(SP), DX
        0x00c8 00200 (main.go:25)   ADDQ    "".arr+8(SP), DX
        0x00cd 00205 (main.go:25)   MOVQ    DX, "".arr+16(SP)
        0x00d2 00210 (main.go:26)   MOVQ    "".arr+24(SP), R12
        0x00d7 00215 (main.go:26)   ADDQ    R12, DX
        0x00da 00218 (main.go:26)   MOVQ    DX, "".arr+32(SP)
        0x00df 00223 (main.go:27)   MOVQ    "".arr+40(SP), R12
        0x00e4 00228 (main.go:27)   ADDQ    R12, DX
        0x00e7 00231 (main.go:27)   MOVQ    DX, "".arr+48(SP)
        0x00ec 00236 (main.go:28)   MOVQ    "".arr+56(SP), R12
        0x00f1 00241 (main.go:28)   ADDQ    R12, DX
        0x00f4 00244 (main.go:28)   MOVQ    DX, "".arr+64(SP)
        0x00f9 00249 (main.go:29)   MOVQ    "".arr+72(SP), R12
        0x00fe 00254 (main.go:29)   ADDQ    R12, DX
        0x0101 00257 (main.go:29)   MOVQ    DX, "".arr+80(SP)
        0x0106 00262 (main.go:30)   MOVQ    "".arr+88(SP), R12
        0x010b 00267 (main.go:30)   ADDQ    DX, R12
        0x010e 00270 (main.go:30)   MOVQ    R12, "".arr+96(SP)
        0x0113 00275 (main.go:32)   MOVQ    "".arr+8(SP), R13
        0x0118 00280 (main.go:32)   LEAQ    (R13)(R13*2), R15
        0x011d 00285 (main.go:32)   MOVQ    R15, "".arr+8(SP)
        0x0122 00290 (main.go:33)   LEAQ    (R13)(R13*2), BX
        0x0127 00295 (main.go:33)   LEAQ    11(BX), BX
        0x012b 00299 (main.go:33)   MOVQ    BX, "".arr+8(SP)
        0x0130 00304 (main.go:35)   MOVQ    "".arr(SP), AX
        0x0134 00308 (main.go:35)   MOVQ    "".arr+16(SP), CX
        0x0139 00313 (main.go:35)   MOVQ    "".arr+24(SP), DI
        0x013e 00318 (main.go:35)   MOVQ    "".arr+32(SP), SI
        0x0143 00323 (main.go:35)   MOVQ    "".arr+40(SP), R8
        0x0148 00328 (main.go:35)   MOVQ    "".arr+48(SP), R9
        0x014d 00333 (main.go:35)   MOVQ    "".arr+56(SP), R10
        0x0152 00338 (main.go:35)   MOVQ    "".arr+64(SP), R11
        0x0157 00343 (main.go:35)   MOVQ    "".arr+72(SP), R13
        0x015c 00348 (main.go:35)   MOVQ    "".arr+88(SP), R15
        0x0161 00353 (main.go:35)   MOVQ    R13, "".~r9+152(SP)
        0x0169 00361 (main.go:35)   MOVQ    DX, "".~r10+160(SP)
        0x0171 00369 (main.go:35)   MOVQ    R15, "".~r11+168(SP)
        0x0179 00377 (main.go:35)   MOVQ    R12, "".~r12+176(SP)
        0x0181 00385 (main.go:35)   MOVQ    104(SP), BP
        0x0186 00390 (main.go:35)   ADDQ    $112, SP
        0x018a 00394 (main.go:35)   RET
    

    栈帧图示

    上述代码中两个函数对应的栈帧可以由下图表示。

    左边为 main 函数的栈帧,从高地址到低地址依次为:

    1. main 函数的调用者的 PC:这是由 CALL 指令自动压入栈的,并因此导致调用者的 SP 增加了 8 字节。
    2. main 函数的调用者的 BP:main 函数在申请栈空间后,将上一个函数的 BP 放到栈底,并且调整 BP 指向该位置。
    3. main 函数的局部变量 ret:占 8 个字节的 int 类型。
    4. 寄存器参数的溢出区域:如果被调函数中参数非常多,寄存器不够用了,寄存器参数就存到这些位置。
    5. 栈上返回值:存放在栈上的 test 函数的返回值。
    6. 栈上入参:存放在栈上的 test 函数的入参。
    stask.png

    右边为 test 函数的栈帧,从高地址到低地址依次为:

    1. main 函数的 PC:这是由 CALL 指令自动压入栈的,并因此导致 SP 增加了 8 字节。
    2. main 函数的 BP:test 函数在申请栈空间后,将 main 函数的 BP 放到栈底,并且调整 BP 指向该位置。
    3. test 函数的局部变量 arr:一共 13 个 int 类型的数组。

    由于没有调用其他函数,因此 test 函数的栈帧中没有参数、返回值、寄存器溢出区。

    值得注意的是,这两个函数的栈帧在内存中是连续的,即途中虚线连接的部分,在内存中是完全相同的区域,只是由于两个函数的 SP 值不同,相对的偏移量也不同。

    函数的参数与返回值存放在调用方的栈帧中,栈上变量的访问通常不是使用 PUSH/POP 而是使用 SP 加上偏移量访问,类似于数组。

    栈的平衡由被调方负责,编译器计算函数所需的栈空间,函数首先将 SP 减去所需栈空间用于调整栈顶,实现“批量入栈”的效果,随后将调用方的 BP 保存到栈底。函数返回之前先从栈中恢复 BP,然后将 SP 加上最初减去的值,实现“批量出栈”的效果。

    函数的调用与返回过程中的跳转由 CALL/RET 指令负责。CALL 指令首先将 CALL 指令的下一条指令的地址通过 PUSH 入栈,然后 JUMP 到被调函数的地址(修改 PC)运行。被调函数返回之前最后一条指令是 RET,它将栈中存放的指令地址 POPPC 寄存器中,跳转到 CALL 指令的下一条指令开始执行。

    小结

    Golang 函数调用过程中同时使用寄存器与栈进行参数传递,优先使用寄存器,寄存器不够用了才使用栈。函数的第 1~9 个参数依次使用 AX、BX、CX、DI、SI、R8、R9、R10、R11 寄存器。从第 10 个参数开始,使用栈传递,并且是从栈顶向栈低依次排列。执行顺序方面,入栈操作先执行,然后再对寄存器赋值。由于寄存器溢出区域的存在,寄存器传参并不会减小栈内存占用。

    使用寄存器传递参数指令数量比使用栈传递参数更少。在上面的例子中,使用寄存器传递的参数在计算时可以直接计算,然后存到栈上,而使用栈传递的参数则需要先从栈中加载到寄存器上,然后计算,最后存到栈上。更重要的是,寄存器的读写速度远高于 CPU Cache 与内存,而栈则是存放在内存中。一组具有代表性的 Go 包和程序的基准显示性能提高了约 5%,二进制文件大小通常减少了约 2%。

    相关文章

      网友评论

          本文标题:Go 函数调用约定与栈

          本文链接:https://www.haomeiwen.com/subject/nfvofdtx.html