golang学习笔记-golang调用c实现的dll接口细节(二)

  各种原因需要与c或者c++打交道,之前对cgo有一点的了解,曾经了在了解的过程中记录了学习的过程。仅在使用的角度上讲,但是好多东西确实是模棱两可。一个契机,需要在go的框架下用到c++语言的sdk,顺便就记录一下cgo的学习过程,然后再给自己挖个坑,再深入了解一下cgo的机理和更加广泛的使用。

  本篇文章主要从主调的角度入手,介绍如何在go中使用c的代码,面对工程级的如何模块化,对于小的c代码如何在一个文件中实现;介绍如何在c中使用go的导出函数,作为c函数的回调函数使用。

1. go调用c

1.1 快速入门

1.1.1 cgo初体验

  构造一个超级简单的cgo程序,在这个程序中我们只引入cgo的包。虽然在代码中没有用到cgo相关的代码,但是在编译和链接的阶段启动了gcc编译器。算是一个完整的cgo程序。

package main

import "C"
import "fmt"

func main() {
	fmt.Println("hello world")
}
1.1.2 基于c标准库输出字符

  在go程序中引用c的标准库函数,打印字符串。在下面代码中C.CString("hello world")申请了c的内存,需要手动释放掉,不释放的话会导致内存泄露。但是这个示例不影响,程序退出后系统会自动回收资源。

package main

//#include <stdio.h>
import "C"

func main() {
	C.puts(C.CString("hello world"))
}
1.1.3 使用自己的函数

  在1.1.2中我们使用了c的标准库函数puts输出了一个字符串,在这定义一个函数,打印go输入的字符串到终端上。这个例子调用c的free函数,释放了内存,就不会存在内存泄露的问题(需要引入stdlib.h标准库)。

package main

/*
#include <stdio.h>
#include <stdlib.h>
static void sayHello(const char* s)
{
	puts(s);
}
*/
import "C"
import "unsafe"

func main() {
	cs := C.CString("hello world")
	defer C.free(unsafe.Pointer(cs))
	C.sayHello(cs)
}
1.1.4 模块化自定义的函数

  在1.1.3示例中定义了一个sayHello的函数,实现了打印字符串的功能,但是看起来很乱,没有模块化。我们将c函数剥离为一个.c的函数,放在与main.go同级的目录下,相应的修改main.go函数的部分代码。

//sayHello.c
#ifndef _HELLO_H
#define _HELLO_H
#include <stdio.h>
void sayHello(const char *s)
{
    puts(s);
}
#endif
package main

/*
#include <stdlib.h>
#include "sayHello.c"
*/
import "C"
import "unsafe"

func main() {
	cs := C.CString("hello world")
	defer C.free(unsafe.Pointer(cs))
	C.sayHello(cs)
}
1.1.5 声明和实现分离

  将sayHello模块头文件和实现文件分离,main.go文件只需要引入.h文件即可。

  需要注意的是,这里的编译不能用go build main.go的指定文件方式编译,这种编译会下面的报错。

# command-line-arguments
/tmp/go-build799889451/b001/_x002.o:在函数‘_cgo_3e94971ce40c_Cfunc_sayHello’中:
/tmp/go-build/cgo-gcc-prolog:61:对‘sayHello’未定义的引用
collect2: 错误:ld 返回 1

  在main.go文件的目录下执行go build编译文件,执行在终端上打印hello world字样。

//sayHello.h
#ifndef _HELLO_H
#define _HELLO_H
void sayHello(const char *s);
#endif
//sayHello.c
#include <stdio.h>
#include "sayHello.h"
void sayHello(const char *s)
{
    puts(s);
}
package main

/*
#include "sayHello.h"
#include <stdlib.h>
*/
import "C"
import "unsafe"

func main() {
	cs := C.CString("hello world")
	defer C.free(unsafe.Pointer(cs))
	C.sayHello(cs)
}

1.2 go调用c++的库

1.2.1 c++代码

  和 go 模块化调用 c 代码类似,调用 c++时同样将头文件和实现文件分离,只不过为了满足 go 调用的 c 的
函数范式,需要在 c++的实现文件中以 c 的风格引入头文件。

1.头文件的代码
#define _HELLO_H_
void sayHello(const char* s);
#endif // !_HELLO_H_
2.实现文件的代码

在这个文件中使用 c++的标准输出流输入字符串到终端上。

#include  <iostream>
extern "C"{
    #include "hello.h"
}
void sayHello(const char* s)
{
    std::cout << s << std::endl;
}
3.go 调用

调用部分和之前的一样。

package main

/*
#include"hello.h"
#include<stdlib.h>
*/
import "C"
import "unsafe"

func main() {
   cs := C.CString("hello world")
   defer C.free(unsafe.Pointer(cs))
   C.sayHello(cs)
}
4.编译和运行

编译时不能指定文件编译,执行执行go build即可。

文件结构:

.
├── hello.cpp
├── hello.h
├── main.go
└── README.md

0 directories, 4 files

1.3 go调用c的实例

待续。。。

2.c调用go

  上面小结介绍了go调用c和c++函数的方式和过程,这小结我们看一下如何将go的函数导出,给c语言的函数使用。

2.1 go函数导出入门

  在1.1.3章节实现了一个c函数,用于在终端打印一个字符串。现在我们想用go函数来打印这个字符串,并且将这个函数导出,然后再用go的主程序来调用这个导出函数,实现打印的目的。以下用 go 语言实现hello.h的函数功能,在 go 的函数中实现 c 语言的调用。

2.1.1 go的导出函数

  定一个名为hello.go的文件,在这个文件中实现一个打印字符串到终端的函数,并且使用关键字将这个函数导出。

package main

import "C"

import "fmt"

//export SayHello
func SayHello(s *C.char) {
   fmt.Println(C.GoString(s))
}

上面函数的//export SayHello表示的含义和必须的要求:

  • //export 为导出的关键子,斜杠后面不能用空格,必须挨着
  • SayHello 为导出的函数名,必须功能函数名字一样,且函数的参数需要转换为 c 包中定义的变量
  • 上面两个之间用空格隔开

上面的函数在编译时就导出了一个 go 函数,对应的是hello.h文件中的SayHello函数。

2.1.2 c的头文件

定一个hello.h的头文件,在这个文件中声明一个 c 的函数。

void SayHello(/*const*/ char* s);
2.1.3 go调用函数

  在main.go文件中,直接使用导出的函数SayHello,需要引入hello.h的头文件。我们只是在hello.h文件中声明了SayHello函数,程序在编译和链接时使用的是我们在文件hello.go文件中实现并导出的SayHello函数。

package main

/*
#include<hello.h>
*/
import "C"

func main() {
	C.SayHello(C.CString("hello world"))
}
2.1.4 编译和运行
1. 文件结构
.
├── hello.go
├── hello.h
├── main.go
└── README.md

0 directories, 4 files
2.编译运行

main.go文件下执行go build不指定文件编译即可。

2.2 在一个文件中实现go函数的导出和调用(一)

  在上面 1.1 中使用文件分离的方式实现了 go 函数的导出并且调用。这个示例中将在一个文件中导出 go 的函数并且在 go 的main 函数中调用。

2.2.1 代码和解释
package main

/*
#include<stdlib.h>
void SayHello(char * s);
*/
import "C"
import (
	"fmt"
	"unsafe"
)

func main() {
	cs := C.CString("hello world")
	defer C.free(unsafe.Pointer(cs))
	C.SayHello(cs)
}

//export SayHello
func SayHello(s *C.char) {
	fmt.Println(C.GoString(s))
}

上面的代码分为 3 个部分。

1. c包中函数的声明

  这部分声明了一个 c 风格的函数SayHello,由于在调用时需要释放申请的内存,因此引入了 c 的标准库stdlib.h文件。

*注意*:第一个/*import "C"之间不能有空行。

/*
#include<stdlib.h>
void SayHello(char * s);
*/
import "C"
2. go对应c函数的实现和导出

导出名为SayHello的 go 函数,参数使用 c 包中定义的参数类型。

//export SayHello
func SayHello(s *C.char) {
	fmt.Println(C.GoString(s))
}
3. 对 go 的导出函数的调用

调用导出函数和之前的一样,用 free 释放内存。

func main() {
	cs := C.CString("hello world")
	defer C.free(unsafe.Pointer(cs))
	C.SayHello(cs)
}
2.2.2 编译和运行
1. 文件结构
.
└── main.go

0 directories, 1 file
2. 编译和运行

在 main.go 目录下执行go build直接编译。

2.3 在一个文件中实现go函数的导出和调用(二)

  在 1.2 章节中的导出函数参数仍然使用的是 c 包中的参数,但是我们能不能使用 go 的参数类型呢?毕竟这是在 go 的函数中呀,我们看如何用 go 的参数实现。

2.3.1 代码和解释

  通过分析可以发现 SayHello 函数的参数如果可以直接使用 Go 字符串是最直接的。在 Go1.10 中 CGO 新增加了一个GoString预定义的 C 语言类型,用来表示 Go 语言字符串。下面是改进后的代码:

//build in go1.10
package main

/*
void SayHello(_GoString_ s);
*/
import "C"

import (
	"fmt"
)

func main() {
	C.SayHello("Hello World")
}

//export SayHello
func SayHello(s string) {
	fmt.Println(s)
}

2.4 go导出函数实例

在这个例子中总结了两个方面的知识点:

  • go调用c的函数
  • go函数导出且传递给c的函数作为c的回调函数
2.4.1 文件结构

一共有四个文件,hello.hhello.c分别是c函数的声明和实现,hello.go为go的导出函数的声明和实现文件,main.go调用函数。

.
├── hello.c
├── hello.go
├── hello.h
└── main.go

0 directories, 4 files
2.4.2 go导出函数的声明

在这个名为hello.go的文件中,定义了两个导出函数SayHelloexport_flow

SayHello函数接受一个char的入参,并且将入参打印到终端。

export_flow函数没有入参和返回值,这个函数被调用后会在终端上打印一个字符串。

package main

import "C"

import "fmt"

//export SayHello
func SayHello(s *C.char) {
	fmt.Println(C.GoString(s))
}

//export export_flow
func export_flow() {
	// 这个是测试的go的回调函数,这个函数注入到c的代码中,可以理解为在这个函数中实现了数据的处理
	fmt.Println("this is flow func in go")
}
2.4.3 c函数的声明

  这个文件中声明了在文件hello.go中导出的函数SayHello,只是在这个位置声明,实现是在go文件中实现的。

另一个声明callFolw是c语言的函数。这个还是接受了flow类型的参数fn(指针函数)

//hello.h
#ifndef _HELLO_H_
#define _HELLO_H_
//声明一个回调函数的类型,这个类型名为flow,没有入参,返回值为void
typedef void (*flow)();
//go导出函数的声明
void SayHello(char * s);

// c语言函数的声明
void callFlow(flow fn);
#endif
2.4.4 c的实现函数

根据2.4.2和2.4.3,在这个文件中实现了c语言的函数callFlow,只是调用了一个函数指针fn

#include "hello.h"
#include <stdlib.h>
#include <stdio.h>

void callFlow(flow fn)
{
    fn();
}
2.4.5 main调用函数
1. 调用的代码
package main

/*
#include "hello.h"
#include <stdlib.h>
extern void export_flow();
*/
import "C"
import "unsafe"

func main() {
	cs := C.CString("Hello World")
	defer C.free(unsafe.Pointer(cs))
	C.SayHello(cs)
	C.callFlow(C.flow(C.export_flow))
}

2.解释和说明

上面的主程调用中大致可以分为2个部分,依次说明:

  • c包引入和声明
/*
#include "hello.h"
#include <stdlib.h>
extern void export_flow();
*/
import "C"

第2行:引入了.h头文件声明,声明go的导出函数SayHello、c语言指针函数flow和c语言函数callFolw

第3行:引入c语言标准库free来释放内存

第4行:声明一个c语言的导出函数export_flow,这个函数无入参,返回值为void,对应的是hello.go文件中的export_flow函数

第5、6行:必须无空行,且不能合并import "C"

  • 函数的调用
cs := C.CString("Hello World")
defer C.free(unsafe.Pointer(cs))
C.SayHello(cs)
C.callFlow(C.flow(C.export_flow))

第1、2行:定义一个字符串,并申请空间。延迟释放申请到的空间

第3行:调用go导出的函数SayHello,在终端打印hello world字样

第4行:

C.callFlow调用c语言的函数callFlow

C.export_flow是go的导出函数export_flow

callFlow函数有一个类型为flow的指针函数作为入参,我们把go的导出函数export_flow作为callFlow的入参,此时需要转换一下类型,合并之后就是C.callFlow(C.flow(C.export_flow))

2.4.6 编译和运行

在main函数的目录下运行go build编译,运行结果:

Hello World
this is flow func in go

2.5 go导出函数回调c的普通类型

  将go的函数导出,作为参数传递给c的函数,作为回调函数使用。将c函数中的数据回调到go的代码中做业务逻辑。在这个例子中回调了常见的数据读取方式,一个指针和长度,用于取c函数中的一段内存数据。

2.5.1 文件结构
.
├── hello.c
├── hello.go
├── hello.h
└── main.go

0 directories, 4 files

hello.hhello.c文件实现c函数的逻辑和部分变量的定义,hello.go文件定义go的导出函数,main.go是调用的主函数。

2.5.2 c函数的逻辑
1 hello.h声明

 在这个文件中,定义了一个函数指针类型export_fetchDatas,作为c语言函数的回调参数。引用了go的导出函数export_fetchDatasextern关键字说明函数已经在别的文件中(hello.go)实现,此处只是引用。同时定义了两个c语言的函数loginlogout作为调用入口。

// hello.h
#ifndef _HELLO_H_
#define _HELLO_H_

//定义用户信息的结构体
typedef struct tagUserInfo
{
    unsigned char* username;
    unsigned char* password;
}USERINFO,*LUNSERINFO;

//引用go的导出函数
extern int export_fetchDatas(unsigned char *pBuf, unsigned int RevLen);

//函数指针(用于回调)
typedef int (*fetchDatas)(unsigned char*,unsigned int);

// c语言函数的声明
void login(LUNSERINFO userinfo, fetchDatas fn);
void logout();
#endif

实现文件hello.c, 在实现中使用死循环来一直向入参回调变量中写入数据。

#include "hello.h"
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <stdbool.h>

bool _bExit = false;

void login(LUNSERINFO userinfo, fetchDatas fn)
{
    printf("<< login@:login msg\n");
    if (0 == strcmp(userinfo->username, "admin"))
        printf("<< login@: username=%s, password=%s login success.\n:", userinfo->username, userinfo->password);
    else
        printf("<< login@: username=%s\n", userinfo->username);

    char buffer[512];
    int count = 0;
    while (1)
    {
        if (_bExit == true)
        {
            printf("<< login@:fetch exit\n");
            break;
        }
        int n = sprintf(buffer, "count=%d", count++);
        fn(buffer, strlen(buffer));
        memset(buffer, 0, sizeof(buffer));
        sleep(5);
    }
    printf("<< login@:promt data end.\n");
}

void logout()
{
    printf("<< logout@:rcv logout signal\n");
    _bExit = true;
}

2.5.3 go导出函数

go的导出函数文件中定义了导出函数export_fetchDatas,参数直接使用了"C"包中的定义,这里的参数类型需要与hello.h文件中export_fetchDatasfetchDatas的类型一致,要不然编译报错。

// hello.go
package main

import "C"

import (
	"reflect"
	"unsafe"
)

//export export_fetchDatas
func export_fetchDatas(pBuf *C.uchar, RevLen C.uint) int {
	var buf []byte
	data := (*reflect.SliceHeader)(unsafe.Pointer(&buf))
	data.Data = uintptr(unsafe.Pointer(pBuf))
	data.Len = int(RevLen)
	data.Cap = int(RevLen)

	logger.Printf("## export_fetchWithErr@:this is callback result:%v", string(buf))
	return 0
}
2.5.4 调用和编译

以下是摘录的部分主程序调用代码。

var userinfo = C.struct_tagUserInfo{}
userinfo.username = (*C.uchar)(unsafe.Pointer(username))
userinfo.password = (*C.uchar)(unsafe.Pointer(password))
logger.Printf(">> 调用c的函数login,模拟登陆的操作,并传递一个go的到处函数作为login函数的回调,打印c函数中回调得到信息")
C.login((*C.struct_tagUserInfo)(unsafe.Pointer(&userinfo)), C.fetchDatas(C.export_fetchDatas))

编译时,直接在main.go文件的同级目录下运行go build即可,运行后hello.go的导出函数会一致打印收到的c函数中的数据。
在这里插入图片描述

2.6 go导出函数回调c的结构体

  在这个例子中,go的导出函数接受一个结构体变量,并且将结构体变量打印到终端上。

2.6.1 文件结构

这个示例中同样是4个文件,略有不同的是这次使用的是cpp的文件,用c的方式引入定义,cpp中可以用c++实现。

.
├── hello.cpp
├── hello.go
├── hello.h
└── main.go

0 directories, 4 files
2.6.2 c函数实现

这个c的头文件中,定义了一个结构体tagStudent,这个结构体又包含一个结构体。里面需要注意的一些问题后面集中整理

// hello.h
#ifndef _HELLO_H_
#define _HELLO_H_

//定义用户信息的结构体
typedef struct tagHomeInfo
{
    char addr[255]; //住址
    char code[255]; //邮编
} HOMEINFO, *LHOMEINFO;

typedef struct tagStudent
{
    HOMEINFO homeInfo; //家庭信息
    char name[255];    //姓名
    char gender;       //性别
    int age;           //年龄
} STUDENT, *LSTUDENT;

//函数指针(用于回调)
typedef void (*fetch_student)(STUDENT *);

//go导出函数的声明
extern void export_fetch_student(STUDENT *stu);

// c语言函数的声明
void queryStudent(fetch_student fn);
#endif
2.6.3 go导出函数

这个导出函数和2.5中的导出函数不同的是,在go的文件中又定义了一份结构体。这个结构体和在c函数中定义的结构体在结构和字段的类型上一致。c结构体中的子结构在什么位置定义,go中的结构体必须在同样的位置上定义。
go的导出函数必须用c中函数的真实而定结构体名,在这个里面体现为tagStudent(访问c的结构体需要加struct_前缀)。导出函数收到c的结构体后指针,将结构体指针强制转换成go的结构体指针。然后就可以使用go的结构体对象了。
这个文件中因为使用到了c函数的定义中结构体,因此也引入了C包和hello.h头文件。

package main

/*
#include "hello.h"
*/
import "C"
import (
	"unsafe"
)

type Student struct {
	HomeInfo HomeInfo
	Name     [255]byte
	Gender    byte
	Age      int32
}
type HomeInfo struct {
	Addr [255]byte
	Code [255]byte
}

//export export_fetch_student
func export_fetch_student(st *C.struct_tagStudent) {
	p := (*Student)(unsafe.Pointer(st))
	name := p.Name
	gender := p.Gender
	age := p.Age
	addr := p.HomeInfo.Addr
	code := p.HomeInfo.Code

	logger.Printf("name=%s", string(name[:]))
	logger.Printf("gender=%c", gender)
	logger.Printf("age=%d", age)
	logger.Printf("addr=%s", string(addr[:]))
	logger.Printf("code=%s", string(code[:]))
}

2.6.4 调用和编译

调用的主程摘录代码。在需要引入hello.h头文件。

package main

/*
#include "hello.h"
#include <stdlib.h>
*/
import "C"
import (
	"log"
	"os"
)

var logger *log.Logger = nil

func main() {
	logger = log.New(os.Stdout, "", log.Lshortfile|log.Ltime|log.Ldate)
	logger.Printf(">> main start")
	C.queryStudent(C.fetch_student(C.export_fetch_student))
	logger.Printf(">> main exit")
}

编译时,在main.go文件的同级目录下执行go build即可,运行结果:
在这里插入图片描述

3. 内存管理

3.1 go访问c的内存

c函数的内存稳定,已经申请到的内存在没有人为干预释放时,在go中是可以随便使用的。由于go语言的限制,go中无法申请到大于2GB的切片(参考go的makeslice函数的实现),可以使用c内存的稳定的特征,简介使用c的内存来实现go的大内存的使用。

package main

/*
#include <stdlib.h>

void* makeslice(size_t memsize) {
	return malloc(memsize);
}
*/
import "C"
import "unsafe"

func makeByteSlize(n int) []byte {
	p := C.makeslice(C.size_t(n))
	return ((*[1 << 31]byte)(p))[0:n:n]
}

func freeByteSlice(p []byte) {
	C.free(unsafe.Pointer(&p[0]))
}

func main() {
	s := makeByteSlize(1<<32+1)
	s[len[s]-1] = 1234
	print(s[len[s]-1])
	freeByteSlice(p)
}

例子中我们通过makeByteSlize来创建大于4G内存大小的切片,从而绕过了Go语言实现的限制(需要代码验证)。而freeByteSlice辅助函数则用于释放从C语言函数创建的切片。

因为C语言内存空间是稳定的,基于C语言内存构造的切片也是绝对稳定的,不会因为Go语言栈的变化而被移动。

3.2 c访问呢go的内存

3.2.1 c临时访问传入的go的内存

将go的字符串传递给c函数,c函数打印传入的字符串。假设一个极端的场景:我们将一块位于某goroutinue的栈上的Go语言内存传入了C语言函数后,在此C语言函数执行期间,此goroutinue的栈因为空间不足的原因发生了扩展,也就是导致了原来的Go语言内存被移动到了新的位置。但是此时此刻C语言函数并不知道该Go语言内存已经移动了位置,仍然用之前的地址来操作该内存——这将将导致内存越界。以上是一个推论(真实情况有些差异),也就是说C访问传入的Go内存可能是不安全的!

1 go内存拷贝到c内存

上文中1.1.3就是用到了这种手法,在go中使用C包中的C.CString函数将go的内存拷贝到申请的c的内存中,在这个过程中go和c的内存均由自己的内存管理方式管理,因此需要手动的释放C.CString申请的c内存。当用到比较多的字符时会有很多的繁琐操作,很不方便。
为了简化这种方式,cgo提供了更便捷的方式。

2 避免内存分配的方式
package main

/*
void printString(const char* s) {
	printf("%s", s);
}
*/
import "C"

func printString(s string) {
	C.printString((*C.char)(unsafe.Pointer(&s[0])))
}

func main() {
	s := "hello"
	printString(s)
}

假设调用的C语言函数需要长时间运行,那么将会导致被他引用的Go语言内存在C语言返回前不能被移动,从而可能间接地导致这个Go内存栈对应的goroutine不能动态伸缩栈内存,也就是可能导致这个goroutine被阻塞。因此,在需要长时间运行的C语言函数(特别是在纯CPU运算之外,还可能因为需要等待其它的资源而需要不确定时间才能完成的函数),需要谨慎处理传入的Go语言内存。
取到go的内存指针后需要立刻传入到c中,防止go的内存发生扩展时,导致内存结构变化。

3.2.2 c长期持有go的内存

思路:
go内存由go的内存管理模型管理,c由c的内存管理模型管理,两者中间使用稳定的类型来桥接,达到c长期持有go内存的想法。

实现:

// 假装有代码

3.2.3 go导出函数和内存

go导出函数给c函数用时,不能使用go申请的内存,因为go和c有着截然不同的内存管理模式。

/*
extern int* getGoPtr();

static void Main() {
	int* p = getGoPtr();
	*p = 42;
}
*/
import "C"

func main() {
	C.Main()
}

//export getGoPtr
func getGoPtr() *C.int {
	return new(C.int)
}

在运行时会报错,cgo中函数_cgo_tsan_acquire会扫描内存指针,会检查cgo中是否包含Go的指针,需要说明的是,cgo默认对返回结果的指针的检查是有代价的,特别是cgo函数返回的结果是一个复杂的数据结构时将花费更多的时间。如果已经确保了cgo函数返回的结果是安全的话,可以通过设置环境变量GODEBUG=cgocheck=0来关闭指针检查行为。

$ GODEBUG=cgocheck=0 go run main.go

关闭cgocheck功能后再运行上面的代码就不会出现上面的异常的。但是要注意的是,如果C语言使用期间对应的内存被Go运行时释放了,将会导致更严重的崩溃问题。cgocheck默认的值是1,对应一个简化版本的检测,如果需要完整的检测功能可以将cgocheck设置为2。

  • 2
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值