Julia 調(diào)用 C 和 Fortran 代碼

2018-08-12 21:26 更新

調(diào)用 C 和 Fortran 代碼

Julia 調(diào)用 C 和 Fortran 的函數(shù),既簡(jiǎn)單又高效。

被調(diào)用的代碼應(yīng)該是共享庫(kù)的格式。大多數(shù) C 和 Fortran 庫(kù)都已經(jīng)被編譯為共享庫(kù)。如果自己使用 GCC (或 Clang )編譯代碼,需要添加 -shared-fPIC 選項(xiàng)。Julia 調(diào)用這些庫(kù)的開(kāi)銷(xiāo)與本地 C 語(yǔ)言相同。

調(diào)用共享庫(kù)和函數(shù)時(shí)使用多元組形式: (:function, "library")("function", "library") ,其中 function 是 C 的導(dǎo)出函數(shù)名, library 是共享庫(kù)名。共享庫(kù)依據(jù)名字來(lái)解析,路徑由環(huán)境變量來(lái)確定,有時(shí)需要直接指明。

多元組內(nèi)有時(shí)僅有函數(shù)名(僅 :function"function" )。此時(shí),函數(shù)名由當(dāng)前進(jìn)程解析。這種形式可以用來(lái)調(diào)用 C 庫(kù)函數(shù), Julia 運(yùn)行時(shí)函數(shù),及鏈接到 Julia 的應(yīng)用中的函數(shù)。

使用 ccall 來(lái)生成庫(kù)函數(shù)調(diào)用。 ccall 的參數(shù)如下:

  1. (:function, "library") 多元組對(duì)兒(必須為常量,詳見(jiàn)下面)
  2. 返回類(lèi)型,可以為任意的位類(lèi)型,包括 Int32 , Int64Float64 ,或者指向任意類(lèi)型參數(shù) T 的指針 Ptr{T} ,或者僅僅是指向無(wú)類(lèi)型指針 void*Ptr
  3. 輸入的類(lèi)型的多元組,與上述的返回類(lèi)型的要求類(lèi)似。輸入必須是多元組,而不是值為多元組的變量或表達(dá)式
  4. 后面的參數(shù),如果有的話(huà),都是被調(diào)用函數(shù)的實(shí)參

下例調(diào)用標(biāo)準(zhǔn) C 庫(kù)中的 clock

    julia> t = ccall( (:clock, "libc"), Int32, ())
    2292761

    julia> t
    2292761

    julia> typeof(ans)
    Int32

clock 函數(shù)沒(méi)有參數(shù),返回 Int32 類(lèi)型。輸入的類(lèi)型如果只有一個(gè),常寫(xiě)成一元多元組,在后面跟一逗號(hào)。例如要調(diào)用 getenv 函數(shù)取得指向一個(gè)環(huán)境變量的指針,應(yīng)這樣調(diào)用:

    julia> path = ccall( (:getenv, "libc"), Ptr{Uint8}, (Ptr{Uint8},), "SHELL")
    Ptr{Uint8} @0x00007fff5fbffc45

    julia> bytestring(path)
    "/bin/bash"

注意,類(lèi)型多元組的參數(shù)必須寫(xiě)成 (Ptr{Uint8},) ,而不是 (Ptr{Uint8}) 。這是因?yàn)?(Ptr{Uint8}) 等價(jià)于 Ptr{Uint8} ,它并不是一個(gè)包含 Ptr{Uint8} 的一元多元組:

    julia> (Ptr{Uint8})
    Ptr{Uint8}

    julia> (Ptr{Uint8},)
    (Ptr{Uint8},)

實(shí)際中要提供可復(fù)用代碼時(shí),通常要使用 Julia 的函數(shù)來(lái)封裝 ccall ,設(shè)置參數(shù),然后檢查 C 或 Fortran 函數(shù)中可能出現(xiàn)的任何錯(cuò)誤,將其作為異常傳遞給 Julia 的函數(shù)調(diào)用者。下例中, getenv C 庫(kù)函數(shù)被封裝在 env.jl 里的 Julia 函數(shù)中:

    function getenv(var::String)
      val = ccall( (:getenv, "libc"),
                  Ptr{Uint8}, (Ptr{Uint8},), var)
      if val == C_NULL
        error("getenv: undefined variable: ", var)
      end
      bytestring(val)
    end

上例中,如果函數(shù)調(diào)用者試圖讀取一個(gè)不存在的環(huán)境變量,封裝將拋出異常:

    julia> getenv("SHELL")
    "/bin/bash"

    julia> getenv("FOOBAR")
    getenv: undefined variable: FOOBAR

下例稍復(fù)雜些,顯示本地機(jī)器的主機(jī)名:

    function gethostname()
      hostname = Array(Uint8, 128)
      ccall( (:gethostname, "libc"), Int32,
            (Ptr{Uint8}, Uint),
            hostname, length(hostname))
      return bytestring(convert(Ptr{Uint8}, hostname))
    end

此例先分配出一個(gè)字節(jié)數(shù)組,然后調(diào)用 C 庫(kù)函數(shù) gethostname 向數(shù)組中填充主機(jī)名,取得指向主機(jī)名緩沖區(qū)的指針,在默認(rèn)其為空結(jié)尾 C 字符串的前提下,將其轉(zhuǎn)換為 Julia 字符串。 C 庫(kù)函數(shù)一般都用這種方式從函數(shù)調(diào)用者那兒,將申請(qǐng)的內(nèi)存?zhèn)鬟f給被調(diào)用者,然后填充。在 Julia 中分配內(nèi)存,通常都需要通過(guò)構(gòu)建非初始化數(shù)組,然后將指向數(shù)據(jù)的指針傳遞給 C 函數(shù)。

調(diào)用 Fortran 函數(shù)時(shí),所有的輸入都必須通過(guò)引用來(lái)傳遞。

& 前綴說(shuō)明傳遞的是指向標(biāo)量參數(shù)的指針,而不是標(biāo)量值本身。下例使用 BLAS 函數(shù)計(jì)算點(diǎn)積:

    function compute_dot(DX::Vector{Float64}, DY::Vector{Float64})
      assert(length(DX) == length(DY))
      n = length(DX)
      incx = incy = 1
      product = ccall( (:ddot_, "libLAPACK"),
                      Float64,
                      (Ptr{Int32}, Ptr{Float64}, Ptr{Int32}, Ptr{Float64}, Ptr{Int32}),
                      &n, DX, &incx, DY, &incy)
      return product
    end

前綴 & 的意思與 C 中的不同。對(duì)引用的變量的任何更改,都是對(duì) Julia 不可見(jiàn)的。 & 并不是真正的地址運(yùn)算符,可以在任何語(yǔ)法中使用它,例如 &0&f(x) 。

注意在處理過(guò)程中,C 的頭文件可以放在任何地方。目前還不能將 Julia 的結(jié)構(gòu)和其他非基礎(chǔ)類(lèi)型傳遞給 C 庫(kù)。通過(guò)傳遞指針來(lái)生成、使用非透明結(jié)構(gòu)類(lèi)型的 C 函數(shù),可以向 Julia 返回 Ptr{Void} 類(lèi)型的值,這個(gè)值以 Ptr{Void} 的形式被其它 C 函數(shù)調(diào)用。可以像任何 C 程序一樣,通過(guò)調(diào)用庫(kù)中對(duì)應(yīng)的程序,對(duì)對(duì)象進(jìn)行內(nèi)存分配和釋放。

把 C 類(lèi)型映射到 Julia

Julia 自動(dòng)調(diào)用 convert 函數(shù),將參數(shù)轉(zhuǎn)換為指定類(lèi)型。例如:

    ccall( (:foo, "libfoo"), Void, (Int32, Float64),
          x, y)

會(huì)按如下操作:

    ccall( (:foo, "libfoo"), Void, (Int32, Float64),
          convert(Int32, x), convert(Float64, y))

如果標(biāo)量值與 & 一起被傳遞作為 Ptr{T} 類(lèi)型的參數(shù)時(shí),值首先會(huì)被轉(zhuǎn)換為 T 類(lèi)型。

數(shù)組轉(zhuǎn)換

把數(shù)組作為一個(gè) Ptr{T} 參數(shù)傳遞給 C 時(shí),它不進(jìn)行轉(zhuǎn)換。Julia 僅檢查元素類(lèi)型是否為 T ,然后傳遞首元素的地址。這樣做可以避免不必要的復(fù)制整個(gè)數(shù)組。

因此,如果 Array 中的數(shù)據(jù)格式不對(duì)時(shí),要使用顯式轉(zhuǎn)換,如 int32(a) 。

如果想把數(shù)組 不經(jīng)轉(zhuǎn)換 而作為一個(gè)不同類(lèi)型的指針傳遞時(shí),要么聲明參數(shù)為 Ptr{Void} 類(lèi)型,要么顯式調(diào)用 convert(Ptr{T}, pointer(A)) 。

類(lèi)型相關(guān)

基礎(chǔ)的 C/C++ 類(lèi)型和 Julia 類(lèi)型對(duì)照如下。每個(gè) C 類(lèi)型也有一個(gè)對(duì)應(yīng)名稱(chēng)的 Julia 類(lèi)型,不過(guò)冠以了前綴 C 。這有助于編寫(xiě)簡(jiǎn)便的代碼(但 C 中的 int 與 Julia 中的 Int 不同)。

與系統(tǒng)無(wú)關(guān):

unsigned char Cuchar Uint8
short Cshort Int16
unsigned short Cushort Uint16
int Cint Int32
unsigned int Cuint Uint32
long long Clonglong Int64
unsigned long long Culonglong Uint64
intmax_t Cintmax_t Int64
uintmax_t Cuintmax_t Uint64
float Cfloat Float32
double Cdouble Float64
ptrdiff_t Cptrdiff_t Int
ssize_t Cssize_t Int
size_t Csize_t Uint
void Void
void* Ptr{Void}
char* (or char[], e.g. a string) Ptr{Uint8}
char* (or char[]) Ptr{Ptr{Uint8}}
struct T* (T 正確表示一個(gè)定義好的 bit 類(lèi)型) Ptr{T} (在參數(shù)列表中使用 &variable_name 調(diào)用)
struct T (T 正確表示一個(gè)定義好的 bit 類(lèi)型) T (在參數(shù)列表中使用 &variable_name 調(diào)用)
jl_value_t* (任何 Julia 類(lèi)型) Ptr{Any}

對(duì)應(yīng)于字符串參數(shù)( char* )的 Julia 類(lèi)型為 Ptr{Uint8} ,而不是 ASCIIString 。參數(shù)中有 char** 類(lèi)型的 C 函數(shù),在 Julia 中調(diào)用時(shí)應(yīng)使用 Ptr{Ptr{Uint8}} 類(lèi)型。例如,C 函數(shù):

    int main(int argc, char **argv);

在 Julia 中應(yīng)該這樣調(diào)用:

    argv = [ "a.out", "arg1", "arg2" ]
    ccall(:main, Int32, (Int32, Ptr{Ptr{Uint8}}), length(argv), argv)

對(duì)于 wchar_t* 參數(shù),Julia 類(lèi)型為 Ptr{Wchar_t},并且數(shù)據(jù)可以通過(guò) wstring(s) 方法轉(zhuǎn)換為原始的 Julia 字符串(等同于 utf16(s)utf32(s) ,這取決于 Cwchar_t 的寬度)。還要注意 ASCII, UTF-8, UTF-16, 和UTF-32 字符串?dāng)?shù)據(jù)在 Julia 內(nèi)部是以 NUL 結(jié)尾的,所以它能夠傳遞到 C 函數(shù)中以 NUL 為結(jié)尾的數(shù)據(jù),而不用再做一個(gè)拷貝。

通過(guò)指針讀取數(shù)據(jù)

下列方法是“不安全”的,因?yàn)閴闹羔樆蝾?lèi)型聲明可能會(huì)導(dǎo)致意外終止或損壞任意進(jìn)程內(nèi)存。

指定 Ptr{T} ,常使用 unsafe_ref(ptr, [index]) 方法,將類(lèi)型為 T 的內(nèi)容從所引用的內(nèi)存復(fù)制到 Julia 對(duì)象中。 index 參數(shù)是可選的(默認(rèn)為 1 ),它是從 1 開(kāi)始的索引值。此函數(shù)類(lèi)似于 getindex()setindex!() 的行為(如 [] 語(yǔ)法)。

返回值是一個(gè)被初始化的新對(duì)象,它包含被引用內(nèi)存內(nèi)容的淺拷貝。被引用的內(nèi)存可安全釋放。

如果 TAny 類(lèi)型,被引用的內(nèi)存會(huì)被認(rèn)為包含對(duì) Julia 對(duì)象 jl_value_t* 的引用,結(jié)果為這個(gè)對(duì)象的引用,且此對(duì)象不會(huì)被拷貝。需要謹(jǐn)慎確保對(duì)象始終對(duì)垃圾回收機(jī)制可見(jiàn)(指針不重要,重要的是新的引用),來(lái)確保內(nèi)存不會(huì)過(guò)早釋放。注意,如果內(nèi)存原本不是由 Julia 申請(qǐng)的,新對(duì)象將永遠(yuǎn)不會(huì)被 Julia 的垃圾回收機(jī)制釋放。如果 Ptr 本身就是 jl_value_t* ,可使用 unsafe_pointer_to_objref(ptr) 將其轉(zhuǎn)換回 Julia 對(duì)象引用。(可通過(guò)調(diào)用 pointer_from_objref(v) 將Julia 值 v 轉(zhuǎn)換為 jl_value_t* 指針 Ptr{Void} 。)

逆操作(向 Ptr{T} 寫(xiě)數(shù)據(jù))可通過(guò) unsafe_store!(ptr, value, [index]) 來(lái)實(shí)現(xiàn)。目前,僅支持位類(lèi)型和其它無(wú)指針( isbits )不可變類(lèi)型。

現(xiàn)在任何拋出異常的操作,估摸著都是還沒(méi)實(shí)現(xiàn)完呢。來(lái)寫(xiě)個(gè)帖子上報(bào) bug 吧,就會(huì)有人來(lái)解決啦。

如果所關(guān)注的指針是(位類(lèi)型或不可變)的目標(biāo)數(shù)據(jù)數(shù)組, pointer_to_array(ptr,dims,[own]) 函數(shù)就非常有用啦。如果想要 Julia “控制”底層緩沖區(qū)并在返回的 Array 被釋放時(shí)調(diào)用 free(ptr) ,最后一個(gè)參數(shù)應(yīng)該為真。如果省略 own 參數(shù)或它為假,則調(diào)用者需確保緩沖區(qū)一直存在,直至所有的讀取都結(jié)束。

Ptr 的算術(shù)(比如 +) 和 C 的指針?biāo)阈g(shù)不同, 對(duì) Ptr 加一個(gè)整數(shù)會(huì)將指針移動(dòng)一段距離的 字節(jié) , 而不是元素。這樣從指針運(yùn)算上得到的地址不會(huì)依賴(lài)指針類(lèi)型。

用指針傳遞修改值

因?yàn)?C 不支持多返回值, 所以通常 C 函數(shù)會(huì)用指針來(lái)修改值。 在 ccall 里完成這些需要把值放在適當(dāng)類(lèi)型的數(shù)組里。當(dāng)你用 Ptr 傳遞整個(gè)數(shù)組時(shí), Julia 會(huì)自動(dòng)傳遞一個(gè) C 指針到被這個(gè)值:

    width = Cint[0]
    range = Cfloat[0]
    ccall(:foo, Void, (Ptr{Cint}, Ptr{Cfloat}), width, range)

這被廣泛用在了 Julia 的 LAPACK 接口上, 其中整數(shù)類(lèi)型的 info 被以引用的方式傳到 LAPACK, 再返回是否成功。

垃圾回收機(jī)制的安全

給 ccall 傳遞數(shù)據(jù)時(shí),最好避免使用 pointer() 函數(shù)。應(yīng)當(dāng)定義一個(gè)轉(zhuǎn)換方法,將變量直接傳遞給 ccall 。ccall 會(huì)自動(dòng)安排,使得在調(diào)用返回前,它的所有參數(shù)都不會(huì)被垃圾回收機(jī)制處理。如果 C API 要存儲(chǔ)一個(gè)由 Julia 分配好的內(nèi)存的引用,當(dāng) ccall 返回后,需要自己設(shè)置,使對(duì)象對(duì)垃圾回收機(jī)制保持可見(jiàn)。推薦的方法為,在一個(gè)類(lèi)型為 Array{Any,1} 的全局變量中保存這些值,直到 C 接口通知它已經(jīng)處理完了。

只要構(gòu)造了指向 Julia 數(shù)據(jù)的指針,就必須保證原始數(shù)據(jù)直至指針使用完之前一直存在。Julia 中的許多方法,如 unsafe_ref()bytestring() ,都復(fù)制數(shù)據(jù)而不是控制緩沖區(qū),因此可以安全釋放(或修改)原始數(shù)據(jù),不會(huì)影響到 Julia 。有一個(gè)例外需要注意,由于性能的原因, pointer_to_array() 會(huì)共享(或控制)底層緩沖區(qū)。

垃圾回收并不能保證回收的順序。例如,當(dāng) a 包含對(duì) b 的引用,且兩者都要被垃圾回收時(shí),不能保證 ba 之后被回收。這需要用其它方式來(lái)處理。

非常量函數(shù)說(shuō)明

(name, library) 函數(shù)說(shuō)明應(yīng)為常量表達(dá)式??梢酝ㄟ^(guò) eval ,將計(jì)算結(jié)果作為函數(shù)名:

    @eval ccall(($(string("a","b")),"lib"), ...

表達(dá)式用 string 構(gòu)造名字,然后將名字代入 ccall 表達(dá)式進(jìn)行計(jì)算。注意 eval 僅在頂層運(yùn)行,因此在表達(dá)式之內(nèi),不能使用本地變量(除非本地變量的值使用 $ 進(jìn)行過(guò)內(nèi)插)。 eval 通常用來(lái)作為頂層定義,例如,將包含多個(gè)相似函數(shù)的庫(kù)封裝在一起。

間接調(diào)用

ccall 的第一個(gè)參數(shù)可以是運(yùn)行時(shí)求值的表達(dá)式。此時(shí),表達(dá)式的值應(yīng)為 Ptr 類(lèi)型,指向要調(diào)用的原生函數(shù)的地址。這個(gè)特性用于 ccall 的第一參數(shù)包含對(duì)非常量(本地變量或函數(shù)參數(shù))的引用時(shí)。

調(diào)用方式

ccall 的第二個(gè)(可選)參數(shù)指定調(diào)用方式(在返回值之前)。如果沒(méi)指定,將會(huì)使用操作系統(tǒng)的默認(rèn) C 調(diào)用方式。其它支持的調(diào)用方式為: stdcall , cdecl , fastcallthiscall 。例如 (來(lái)自 base/libc.jl):

    hn = Array(Uint8, 256)
    err=ccall(:gethostname, stdcall, Int32, (Ptr{Uint8}, Uint32), hn, length(hn))

更多信息請(qǐng)參考 LLVM Language Reference

訪問(wèn)全局變量

當(dāng)全局變量導(dǎo)出到本地庫(kù)時(shí)可以使用 cglobal 方法,通過(guò)名稱(chēng)進(jìn)行訪問(wèn)。cglobal的參數(shù)和 ccall 的指定參數(shù)是相同的符號(hào),并且其表述了存儲(chǔ)在變量中的值類(lèi)型:

    julia> cglobal((:errno,:libc), Int32)
    Ptr{Int32} @0x00007f418d0816b8

該結(jié)果是一個(gè)該值的地址的指針??梢酝ㄟ^(guò)這個(gè)指針對(duì)這個(gè)值進(jìn)行操作,但需要使用 unsafe_loadunsafe_store。

將 Julia 的回調(diào)函數(shù)傳遞給 C

可以將 Julia 函數(shù)傳遞給本地的函數(shù),只要該函數(shù)有指針參數(shù)。一個(gè)典型的例子為標(biāo)準(zhǔn) C 庫(kù) qsort 函數(shù),描述如下:

    void qsort(void *base, size_t nmemb, size_t size,
               int(*compare)(const void *a, const void *b));

base 參數(shù)是一個(gè)數(shù)組長(zhǎng)度 nmemb 的指針,每個(gè)元素大小為 size 字節(jié)。compare 是一個(gè)回調(diào)函數(shù),帶有兩個(gè)元素 ab 的指針,并且如果 ab 之前或之后出現(xiàn),則返回一個(gè)大于或者小于 0 的整數(shù)(如果允許任意順序的話(huà),結(jié)果為 0)?,F(xiàn)在假設(shè)我們?cè)?Julia 值中有一個(gè)一維數(shù)組 A,我們想給這個(gè)數(shù)組進(jìn)行排序,使用 qsort 函數(shù)(不用 Julia 的內(nèi)置函數(shù))。在我們調(diào)用 qsort 和傳遞參數(shù)之前,我們需要寫(xiě)一個(gè)比較函數(shù),來(lái)適應(yīng)任意類(lèi)型 T:

    function mycompare{T}(a_::Ptr{T}, b_::Ptr{T})
        a = unsafe_load(a_)
        b = unsafe_load(b_)
        return convert(Cint, a < b ? -1 : a > b ? +1 : 0)
    end

請(qǐng)注意,我們必須注意返回值類(lèi)型:qsort 需要的是 C 語(yǔ)言的 int 類(lèi)型變量作為返回值,所以我們必須通過(guò)調(diào)用 convert 來(lái)確保返回 Cint。

為了能夠傳遞這個(gè)函數(shù)給 C,我們要通過(guò) cfunction 來(lái)得到它的地址:

    const mycompare_c = cfunction(mycompare, Cint, (Ptr{Cdouble}, Ptr{Cdouble}))

cfunction 接受三個(gè)參數(shù):Julia 函數(shù)(mycompare),返回值類(lèi)型 (Cint),和一個(gè)參數(shù)類(lèi)型的元組,在這種情況下對(duì) cdouble(Float64)元素 的數(shù)組進(jìn)行排序。

最終對(duì) qsort 的調(diào)用如下:

    A = [1.3, -2.7, 4.4, 3.1]
    ccall(:qsort, Void, (Ptr{Cdouble}, Csize_t, Csize_t, Ptr{Void}),
          A, length(A), sizeof(eltype(A)), mycompare_c)

執(zhí)行該操作之后, A 會(huì)更改為排序數(shù)組 [ -2.7, 1.3, 3.1, 4.4]。注意 Julia 知道如何去將數(shù)組轉(zhuǎn)換為 Ptr{Cdouble},如何計(jì)算字節(jié)大?。ㄅc C 的 sizeof 是相同的)等等。如果你有興趣,你可以嘗試在 mycompare 插入一個(gè) println("mycompare($a,$b)"),這將允許你以比較的方式去查看 qsort
(并且確認(rèn)它的確調(diào)用了 你傳遞的 Julia 函數(shù))。

線程安全

一些 C 從不同的線程中執(zhí)行他們的回調(diào)函數(shù),并且 Julia 不含有線程安全,你需要做一些額外的預(yù)防措施。特別是,你需要設(shè)置兩層系統(tǒng):C 的回調(diào)應(yīng)該只調(diào)度(通過(guò) Julia 的時(shí)間循環(huán))你“真正”的回調(diào)函數(shù)的執(zhí)行。你的回調(diào)需要兩個(gè)輸入(你很可能會(huì)忘記)并且通過(guò) SingleAsyncWork 進(jìn)行包裝::

  cb = Base.SingleAsyncWork(data -> my_real_callback(args))

你傳遞給 C 的回調(diào)應(yīng)該僅僅執(zhí)行 ccall:uv_async_send,傳遞 cb.handle 作為參數(shù)。

關(guān)于回調(diào)更多的內(nèi)容

對(duì)于更多的如何傳遞回調(diào)到 C 庫(kù)的細(xì)節(jié),請(qǐng)參考 blog post。

C++

CppClang 擴(kuò)展包提供了有限的 C++ 支持。

處理不同平臺(tái)

當(dāng)處理不同的平臺(tái)庫(kù)的時(shí)候,經(jīng)常要針對(duì)特殊平臺(tái)提供特殊函數(shù)。這時(shí)常用到變量 OS_NAME 。此外,還有一些常用的宏: @windows, @unix, @linux, 及 @osx 。注意, linux 和 osx 是 unix 的不相交的子集。宏的用法類(lèi)似于三元條件運(yùn)算符。

簡(jiǎn)單的調(diào)用:

    ccall( (@windows? :_fopen : :fopen), ...)

復(fù)雜的調(diào)用:

    @linux? (
             begin
                 some_complicated_thing(a)
             end
           : begin
                 some_different_thing(a)
             end
           )

鏈?zhǔn)秸{(diào)用(圓括號(hào)可以省略,但為了可讀性,最好加上):

    @windows? :a : (@osx? :b : :c)
以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

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

編程獅公眾號(hào)