Go 語(yǔ)言 函數(shù)

2023-03-22 15:02 更新

原文鏈接:https://chai2010.cn/advanced-go-programming-book/ch3-asm/ch3-04-func.html


3.4 函數(shù)

終于到函數(shù)了!因?yàn)?Go 匯編語(yǔ)言中,可以也建議通過(guò) Go 語(yǔ)言來(lái)定義全局變量,那么剩下的也就是函數(shù)了。只有掌握了匯編函數(shù)的基本用法,才能真正算是 Go 匯編語(yǔ)言入門。本章將簡(jiǎn)單討論 Go 匯編中函數(shù)的定義和用法。

3.4.1 基本語(yǔ)法

函數(shù)標(biāo)識(shí)符通過(guò) TEXT 匯編指令定義,表示該行開(kāi)始的指令定義在 TEXT 內(nèi)存段。TEXT 語(yǔ)句后的指令一般對(duì)應(yīng)函數(shù)的實(shí)現(xiàn),但是對(duì)于 TEXT 指令本身來(lái)說(shuō)并不關(guān)心后面是否有指令。因此 TEXT 和 LABEL 定義的符號(hào)是類似的,區(qū)別只是 LABEL 是用于跳轉(zhuǎn)標(biāo)號(hào),但是本質(zhì)上他們都是通過(guò)標(biāo)識(shí)符映射一個(gè)內(nèi)存地址。

函數(shù)的定義的語(yǔ)法如下:

TEXT symbol(SB), [flags,] $framesize[-argsize]

函數(shù)的定義部分由 5 個(gè)部分組成:TEXT 指令、函數(shù)名、可選的 flags 標(biāo)志、函數(shù)幀大小和可選的函數(shù)參數(shù)大小。

其中 TEXT 用于定義函數(shù)符號(hào),函數(shù)名中當(dāng)前包的路徑可以省略。函數(shù)的名字后面是 (SB),表示是函數(shù)名符號(hào)相對(duì)于 SB 偽寄存器的偏移量,二者組合在一起最終是絕對(duì)地址。作為全局的標(biāo)識(shí)符的全局變量和全局函數(shù)的名字一般都是基于 SB 偽寄存器的相對(duì)地址。標(biāo)志部分用于指示函數(shù)的一些特殊行為,標(biāo)志在 textflag.h 文件中定義,常見(jiàn)的 NOSPLIT 主要用于指示葉子函數(shù)不進(jìn)行棧分裂。framesize 部分表示函數(shù)的局部變量需要多少??臻g,其中包含調(diào)用其它函數(shù)時(shí)準(zhǔn)備調(diào)用參數(shù)的隱式棧空間。最后是可以省略的參數(shù)大小,之所以可以省略是因?yàn)榫幾g器可以從 Go 語(yǔ)言的函數(shù)聲明中推導(dǎo)出函數(shù)參數(shù)的大小。

我們首先從一個(gè)簡(jiǎn)單的 Swap 函數(shù)開(kāi)始。Swap 函數(shù)用于交互輸入的兩個(gè)參數(shù)的順序,然后通過(guò)返回值返回交換了順序的結(jié)果。如果用 Go 語(yǔ)言中聲明 Swap 函數(shù),大概這樣的:

package main

//go:nosplit
func Swap(a, b int) (int, int)

下面是 main 包中 Swap 函數(shù)在匯編中兩種定義方式:

// func Swap(a, b int) (int, int)
TEXT ·Swap(SB), NOSPLIT, $0-32

// func Swap(a, b int) (int, int)
TEXT ·Swap(SB), NOSPLIT, $0

下圖是 Swap 函數(shù)幾種不同寫法的對(duì)比關(guān)系圖:


圖 3-8 函數(shù)定義

第一種是最完整的寫法:函數(shù)名部分包含了當(dāng)前包的路徑,同時(shí)指明了函數(shù)的參數(shù)大小為 32 個(gè)字節(jié)(對(duì)應(yīng)參數(shù)和返回值的 4 個(gè) int 類型)。第二種寫法則比較簡(jiǎn)潔,省略了當(dāng)前包的路徑和參數(shù)的大小。如果有 NOSPLIT 標(biāo)注,會(huì)禁止匯編器為匯編函數(shù)插入棧分裂的代碼。NOSPLIT 對(duì)應(yīng) Go 語(yǔ)言中的 //go:nosplit 注釋。

目前可能遇到的函數(shù)標(biāo)志有 NOSPLIT、WRAPPER 和 NEEDCTXT 幾個(gè)。其中 NOSPLIT 不會(huì)生成或包含棧分裂代碼,這一般用于沒(méi)有任何其它函數(shù)調(diào)用的葉子函數(shù),這樣可以適當(dāng)提高性能。WRAPPER 標(biāo)志則表示這個(gè)是一個(gè)包裝函數(shù),在 panic 或 runtime.caller 等某些處理函數(shù)幀的地方不會(huì)增加函數(shù)幀計(jì)數(shù)。最后的 NEEDCTXT 表示需要一個(gè)上下文參數(shù),一般用于閉包函數(shù)。

需要注意的是函數(shù)也沒(méi)有類型,上面定義的 Swap 函數(shù)簽名可以下面任意一種格式:

func Swap(a, b, c int) int
func Swap(a, b, c, d int)
func Swap() (a, b, c, d int)
func Swap() (a []int, d int)
// ...

對(duì)于匯編函數(shù)來(lái)說(shuō),只要是函數(shù)的名字和參數(shù)大小一致就可以是相同的函數(shù)了。而且在 Go 匯編語(yǔ)言中,輸入?yún)?shù)和返回值參數(shù)是沒(méi)有任何的區(qū)別的。

3.4.2 函數(shù)參數(shù)和返回值

對(duì)于函數(shù)來(lái)說(shuō),最重要的是函數(shù)對(duì)外提供的 API 約定,包含函數(shù)的名稱、參數(shù)和返回值。當(dāng)這些都確定之后,如何精確計(jì)算參數(shù)和返回值的大小是第一個(gè)需要解決的問(wèn)題。

比如有一個(gè) Swap 函數(shù)的簽名如下:

func Swap(a, b int) (ret0, ret1 int)

對(duì)于這個(gè)函數(shù),我們可以輕易看出它需要 4 個(gè) int 類型的空間,參數(shù)和返回值的大小也就是 32 個(gè)字節(jié):

TEXT ·Swap(SB), $0-32

那么如何在匯編中引用這 4 個(gè)參數(shù)呢?為此 Go 匯編中引入了一個(gè) FP 偽寄存器,表示函數(shù)當(dāng)前幀的地址,也就是第一個(gè)參數(shù)的地址。因此我們以通過(guò) +0(FP)、+8(FP)、+16(FP) 和 +24(FP) 來(lái)分別引用 a、b、ret0 和 ret1 四個(gè)參數(shù)。

但是在匯編代碼中,我們并不能直接以 +0(FP) 的方式來(lái)使用參數(shù)。為了編寫易于維護(hù)的匯編代碼,Go 匯編語(yǔ)言要求,任何通過(guò) FP 偽寄存器訪問(wèn)的變量必和一個(gè)臨時(shí)標(biāo)識(shí)符前綴組合后才能有效,一般使用參數(shù)對(duì)應(yīng)的變量名作為前綴。

下圖是 Swap 函數(shù)中參數(shù)和返回值在內(nèi)存中的布局圖:


圖 3-9 函數(shù)定義

下面的代碼演示了如何在匯編函數(shù)中使用參數(shù)和返回值:

TEXT ·Swap(SB), $0
    MOVQ a+0(FP), AX     // AX = a
    MOVQ b+8(FP), BX     // BX = b
    MOVQ BX, ret0+16(FP) // ret0 = BX
    MOVQ AX, ret1+24(FP) // ret1 = AX
    RET

從代碼可以看出 a、b、ret0 和 ret1 的內(nèi)存地址是依次遞增的,F(xiàn)P 偽寄存器是第一個(gè)變量的開(kāi)始地址。

3.4.3 參數(shù)和返回值的內(nèi)存布局

如果是參數(shù)和返回值類型比較復(fù)雜的情況該如何處理呢?下面我們?cè)賴L試一個(gè)更復(fù)雜的函數(shù)參數(shù)和返回值的計(jì)算。比如有以下一個(gè)函數(shù):

func Foo(a bool, b int16) (c []byte)

函數(shù)的參數(shù)有不同的類型,而且返回值中含有更復(fù)雜的切片類型。我們?cè)撊绾斡?jì)算每個(gè)參數(shù)的位置和總的大小呢?

其實(shí)函數(shù)參數(shù)和返回值的大小以及對(duì)齊問(wèn)題和結(jié)構(gòu)體的大小和成員對(duì)齊問(wèn)題是一致的,函數(shù)的第一個(gè)參數(shù)和第一個(gè)返回值會(huì)分別進(jìn)行一次地址對(duì)齊。我們可以用類比思路將全部的參數(shù)和返回值以同樣的順序分別放到兩個(gè)結(jié)構(gòu)體中,將 FP 偽寄存器作為唯一的一個(gè)指針參數(shù),而每個(gè)成員的地址也就是對(duì)應(yīng)原來(lái)參數(shù)的地址。

用這樣的策略可以很容易計(jì)算前面的 Foo 函數(shù)的參數(shù)和返回值的地址和總大小。為了便于描述我們定義一個(gè) Foo_args_and_returns 臨時(shí)結(jié)構(gòu)體類型用于類比原始的參數(shù)和返回值:

type Foo_args struct {
    a bool
    b int16
    c []byte
}
type Foo_returns struct {
    c []byte
}

然后將 Foo 原來(lái)的參數(shù)替換為結(jié)構(gòu)體形式,并且只保留唯一的 FP 作為參數(shù):

func Foo(FP *SomeFunc_args, FP_ret *SomeFunc_returns) {
    // a = FP + offsetof(&args.a)
    _ = unsafe.Offsetof(FP.a) + uintptr(FP) // a
    // b = FP + offsetof(&args.b)

    // argsize = sizeof(args)
    argsize = unsafe.Offsetof(FP)

    // c = FP + argsize + offsetof(&return.c)
    _ = uintptr(FP) + argsize + unsafe.Offsetof(FP_ret.c)

    // framesize = sizeof(args) + sizeof(returns)
    _ = unsafe.Offsetof(FP) + unsafe.Offsetof(FP_ret)

    return
}

代碼完全和 Foo 函數(shù)參數(shù)的方式類似。唯一的差異是每個(gè)函數(shù)的偏移量,通過(guò) unsafe.Offsetof 函數(shù)自動(dòng)計(jì)算生成。因?yàn)?Go 結(jié)構(gòu)體中的每個(gè)成員已經(jīng)滿足了對(duì)齊要求,因此采用通用方式得到每個(gè)參數(shù)的偏移量也是滿足對(duì)齊要求的。需要注意的是第一個(gè)返回值地址需要重新對(duì)齊機(jī)器字大小的倍數(shù)。

Foo 函數(shù)的參數(shù)和返回值的大小和內(nèi)存布局:


圖 3-10 函數(shù)的參數(shù)

下面的代碼演示了 Foo 匯編函數(shù)參數(shù)和返回值的定位:

TEXT ·Foo(SB), $0
    MOVQ a+0(FP),       AX // a
    MOVQ b+2(FP),       BX // b
    MOVQ c_dat+8*1(FP), CX // c.Data
    MOVQ c_len+8*2(FP), DX // c.Len
    MOVQ c_cap+8*3(FP), DI // c.Cap
    RET

其中 a 和 b 參數(shù)之間出現(xiàn)了一個(gè)字節(jié)的空洞,b 和 c 之間出現(xiàn)了 4 個(gè)字節(jié)的空洞。出現(xiàn)空洞的原因是要保證每個(gè)參數(shù)變量地址都要對(duì)齊到相應(yīng)的倍數(shù)。

3.4.4 函數(shù)中的局部變量

從 Go 語(yǔ)言函數(shù)角度講,局部變量是函數(shù)內(nèi)明確定義的變量,同時(shí)也包含函數(shù)的參數(shù)和返回值變量。但是從 Go 匯編角度看,局部變量是指函數(shù)運(yùn)行時(shí),在當(dāng)前函數(shù)棧幀所對(duì)應(yīng)的內(nèi)存內(nèi)的變量,不包含函數(shù)的參數(shù)和返回值(因?yàn)樵L問(wèn)方式有差異)。函數(shù)棧幀的空間主要由函數(shù)參數(shù)和返回值、局部變量和被調(diào)用其它函數(shù)的參數(shù)和返回值空間組成。為了便于理解,我們可以將匯編函數(shù)的局部變量類比為 Go 語(yǔ)言函數(shù)中顯式定義的變量,不包含參數(shù)和返回值部分。

為了便于訪問(wèn)局部變量,Go 匯編語(yǔ)言引入了偽 SP 寄存器,對(duì)應(yīng)當(dāng)前棧幀的底部。因?yàn)樵诋?dāng)前棧幀時(shí)棧的底部是固定不變的,因此局部變量的相對(duì)于偽 SP 的偏移量也就是固定的,這可以簡(jiǎn)化局部變量的維護(hù)工作。SP 真?zhèn)渭拇嫫鞯膮^(qū)分只有一個(gè)原則:如果使用 SP 時(shí)有一個(gè)臨時(shí)標(biāo)識(shí)符前綴就是偽 SP,否則就是真 SP 寄存器。比如 a(SP) 和 b+8(SP) 有 a 和 b 臨時(shí)前綴,這里都是偽 SP,而前綴部分一般用于表示局部變量的名字。而 (SP) 和 +8(SP) 沒(méi)有臨時(shí)標(biāo)識(shí)符作為前綴,它們都是真 SP 寄存器。

在 X86 平臺(tái),函數(shù)的調(diào)用棧是從高地址向低地址增長(zhǎng)的,因此偽 SP 寄存器對(duì)應(yīng)棧幀的底部其實(shí)是對(duì)應(yīng)更大的地址。當(dāng)前棧的頂部對(duì)應(yīng)真實(shí)存在的 SP 寄存器,對(duì)應(yīng)當(dāng)前函數(shù)棧幀的棧頂,對(duì)應(yīng)更小的地址。如果整個(gè)內(nèi)存用 Memory 數(shù)組表示,那么 Memory[0(SP):end-0(SP)] 就是對(duì)應(yīng)當(dāng)前棧幀的切片,其中開(kāi)始位置是真 SP 寄存器,結(jié)尾部分是偽 SP 寄存器。真 SP 寄存器一般用于表示調(diào)用其它函數(shù)時(shí)的參數(shù)和返回值,真 SP 寄存器對(duì)應(yīng)內(nèi)存較低的地址,所以被訪問(wèn)變量的偏移量是正數(shù);而偽 SP 寄存器對(duì)應(yīng)高地址,對(duì)應(yīng)的局部變量的偏移量都是負(fù)數(shù)。

為了便于對(duì)比,我們將前面 Foo 函數(shù)的參數(shù)和返回值變量改成局部變量:

func Foo() {
    var c []byte
    var b int16
    var a bool
}

然后通過(guò)匯編語(yǔ)言重新實(shí)現(xiàn) Foo 函數(shù),并通過(guò)偽 SP 來(lái)定位局部變量:

TEXT ·Foo(SB), $32-0
    MOVQ a-32(SP),      AX // a
    MOVQ b-30(SP),      BX // b
    MOVQ c_data-24(SP), CX // c.Data
    MOVQ c_len-16(SP),  DX // c.Len
    MOVQ c_cap-8(SP),   DI // c.Cap
    RET

Foo 函數(shù)有 3 個(gè)局部變量,但是沒(méi)有調(diào)用其它的函數(shù),因?yàn)閷?duì)齊和填充的問(wèn)題導(dǎo)致函數(shù)的棧幀大小為 32 個(gè)字節(jié)。因?yàn)?Foo 函數(shù)沒(méi)有參數(shù)和返回值,因此參數(shù)和返回值大小為 0 個(gè)字節(jié),當(dāng)然這個(gè)部分可以省略不寫。而局部變量中先定義的變量 c 離偽 SP 寄存器對(duì)應(yīng)的地址最近,最后定義的變量 a 離偽 SP 寄存器最遠(yuǎn)。有兩個(gè)因素導(dǎo)致出現(xiàn)這種逆序的結(jié)果:一個(gè)從 Go 語(yǔ)言函數(shù)角度理解,先定義的 c 變量地址要比后定義的變量的地址更大;另一個(gè)是偽 SP 寄存器對(duì)應(yīng)棧幀的底部,而 X86 中棧是從高向低生長(zhǎng)的,所以最先定義有著更大地址的 c 變量離棧的底部偽 SP 更近。

我們同樣可以通過(guò)結(jié)構(gòu)體來(lái)模擬局部變量的布局:

func Foo() {
    var local [1]struct{
        a bool
        b int16
        c []byte
    }
    var SP = &local[1];

    _ = -(unsafe.Sizeof(local)-unsafe.Offsetof(local.a)) + uintptr(&SP) // a
    _ = -(unsafe.Sizeof(local)-unsafe.Offsetof(local.b)) + uintptr(&SP) // b
    _ = -(unsafe.Sizeof(local)-unsafe.Offsetof(local.c)) + uintptr(&SP) // c
}

我們將之前的三個(gè)局部變量挪到一個(gè)結(jié)構(gòu)體中。然后構(gòu)造一個(gè) SP 變量對(duì)應(yīng)偽 SP 寄存器,對(duì)應(yīng)局部變量結(jié)構(gòu)體的頂部。然后根據(jù)局部變量總大小和每個(gè)變量對(duì)應(yīng)成員的偏移量計(jì)算相對(duì)于偽 SP 的距離,最終偏移量是一個(gè)負(fù)數(shù)。

通過(guò)這種方式可以處理復(fù)雜的局部變量的偏移,同時(shí)也能保證每個(gè)變量地址的對(duì)齊要求。當(dāng)然,除了地址對(duì)齊外,局部變量的布局并沒(méi)有順序要求。對(duì)于匯編比較熟悉同學(xué)可以根據(jù)自己的習(xí)慣組織變量的布局。

下面是 Foo 函數(shù)的局部變量的大小和內(nèi)存布局:


圖 3-11 函數(shù)的局部變量

從圖中可以看出 Foo 函數(shù)局部變量和前一個(gè)例子中參數(shù)和返回值的內(nèi)存布局是完全一樣的,這也是我們故意設(shè)計(jì)的結(jié)果。但是參數(shù)和返回值是通過(guò)偽 FP 寄存器定位的,F(xiàn)P 寄存器對(duì)應(yīng)第一個(gè)參數(shù)的開(kāi)始地址(第一個(gè)參數(shù)地址較低),因此每個(gè)變量的偏移量是正數(shù)。而局部變量是通過(guò)偽 SP 寄存器定位的,而偽 SP 寄存器對(duì)應(yīng)的是第一個(gè)局部變量的結(jié)束地址(第一個(gè)局部變量地址較大),因此每個(gè)局部變量的偏移量都是負(fù)數(shù)。

3.4.5 調(diào)用其它函數(shù)

常見(jiàn)的用 Go 匯編實(shí)現(xiàn)的函數(shù)都是葉子函數(shù),也就是被其它函數(shù)調(diào)用的函數(shù),但是很少調(diào)用其它函數(shù)。這主要是因?yàn)槿~子函數(shù)比較簡(jiǎn)單,可以簡(jiǎn)化匯編函數(shù)的編寫;同時(shí)一般性能或特性的瓶頸也處于葉子函數(shù)。但是能夠調(diào)用其它函數(shù)和能夠被其它函數(shù)調(diào)用同樣重要,否則 Go 匯編就不是一個(gè)完整的匯編語(yǔ)言。

在前文中我們已經(jīng)學(xué)習(xí)了一些匯編實(shí)現(xiàn)的函數(shù)參數(shù)和返回值處理的規(guī)則。那么一個(gè)顯然的問(wèn)題是,匯編函數(shù)的參數(shù)是從哪里來(lái)的?答案同樣明顯,被調(diào)用函數(shù)的參數(shù)是由調(diào)用方準(zhǔn)備的:調(diào)用方在棧上設(shè)置好空間和數(shù)據(jù)后調(diào)用函數(shù),被調(diào)用方在返回前將返回值放在對(duì)應(yīng)的位置,函數(shù)通過(guò) RET 指令返回調(diào)用方函數(shù)之后,調(diào)用方再?gòu)姆祷刂祵?duì)應(yīng)的棧內(nèi)存位置取出結(jié)果。Go 語(yǔ)言函數(shù)的調(diào)用參數(shù)和返回值均是通過(guò)棧傳輸?shù)?,這樣做的優(yōu)點(diǎn)是函數(shù)調(diào)用棧比較清晰,缺點(diǎn)是函數(shù)調(diào)用有一定的性能損耗(Go 編譯器是通過(guò)函數(shù)內(nèi)聯(lián)來(lái)緩解這個(gè)問(wèn)題的影響)。

為了便于展示,我們先使用 Go 語(yǔ)言來(lái)構(gòu)造三個(gè)逐級(jí)調(diào)用的函數(shù):

func main() {
    printsum(1, 2)
}

func printsum(a, b int) {
    var ret = sum(a, b)
    println(ret)
}

func sum(a, b int) int {
    return a+b
}

其中 main 函數(shù)通過(guò)字面值常量直接調(diào)用 printsum 函數(shù),printsum 函數(shù)輸出兩個(gè)整數(shù)的和。而 printsum 函數(shù)內(nèi)部又通過(guò)調(diào)用 sum 函數(shù)計(jì)算兩個(gè)數(shù)的和,并最終調(diào)用打印函數(shù)進(jìn)行輸出。因?yàn)?printsum 既是被調(diào)用函數(shù)又是調(diào)用函數(shù),所以它是我們要重點(diǎn)分析的函數(shù)。

下圖展示了三個(gè)函數(shù)逐級(jí)調(diào)用時(shí)內(nèi)存中函數(shù)參數(shù)和返回值的布局:


圖 3-12 函數(shù)幀

為了便于理解,我們對(duì)真實(shí)的內(nèi)存布局進(jìn)行了簡(jiǎn)化。要記住的是調(diào)用函數(shù)時(shí),被調(diào)用函數(shù)的參數(shù)和返回值內(nèi)存空間都必須由調(diào)用者提供。因此函數(shù)的局部變量和為調(diào)用其它函數(shù)準(zhǔn)備的??臻g總和就確定了函數(shù)幀的大小。調(diào)用其它函數(shù)前調(diào)用方要選擇保存相關(guān)寄存器到棧中,并在調(diào)用函數(shù)返回后選擇要恢復(fù)的寄存器進(jìn)行保存。最終通過(guò) CALL 指令調(diào)用函數(shù)的過(guò)程和調(diào)用我們熟悉的調(diào)用 println 函數(shù)輸出的過(guò)程類似。

Go 語(yǔ)言中函數(shù)調(diào)用是一個(gè)復(fù)雜的問(wèn)題,因?yàn)?Go 函數(shù)不僅僅要了解函數(shù)調(diào)用參數(shù)的布局,還會(huì)涉及到棧的跳轉(zhuǎn),棧上局部變量的生命周期管理。本節(jié)只是簡(jiǎn)單了解函數(shù)調(diào)用參數(shù)的布局規(guī)則,在后續(xù)的章節(jié)中會(huì)更詳細(xì)的討論函數(shù)的細(xì)節(jié)。

3.4.6 宏函數(shù)

宏函數(shù)并不是 Go 匯編語(yǔ)言所定義,而是 Go 匯編引入的預(yù)處理特性自帶的特性。

在 C 語(yǔ)言中我們可以通過(guò)帶參數(shù)的宏定義一個(gè)交換 2 個(gè)數(shù)的宏函數(shù):

#define SWAP(x, y) do{ int t = x; x = y; y = t; }while(0)

我們可以用類似的方式定義一個(gè)交換兩個(gè)寄存器的宏:

#define SWAP(x, y, t) MOVQ x, t; MOVQ y, x; MOVQ t, y

因?yàn)閰R編語(yǔ)言中無(wú)法定義臨時(shí)變量,我們?cè)黾右粋€(gè)參數(shù)用于臨時(shí)寄存器。下面是通過(guò) SWAP 宏函數(shù)交換 AX 和 BX 寄存器的值,然后返回結(jié)果:

// func Swap(a, b int) (int, int)
TEXT ·Swap(SB), $0-32
    MOVQ a+0(FP), AX // AX = a
    MOVQ b+8(FP), BX // BX = b

    SWAP(AX, BX, CX)     // AX, BX = b, a

    MOVQ AX, ret0+16(FP) // return
    MOVQ BX, ret1+24(FP) //
    RET

因?yàn)轭A(yù)處理器可以通過(guò)條件編譯針對(duì)不同的平臺(tái)定義宏的實(shí)現(xiàn),這樣可以簡(jiǎn)化平臺(tái)帶來(lái)的差異。



以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)