DSP_TMS320F28377D_最简洁最高效的IPC核间通信代码

1. 前言

既然是多核编程,核间的数据交互是避免不了的,因此常常会使用到IPC通信技术。掌握TMS320F28377D芯片的IPC通信技术是在2021年年底,距今已半年了。只怪当初没有好好做个记录,现在想单独捡起来写成blog还需要好好回忆回忆。首先给一个参考代码路径:

C:\ti\controlSUITE\device_support\F2837xD\v210\F2837xD_examples_Dual\cpu02_to_cpu01_ipcdrivers

在之前讲到的28335和28377D的编程入门都有提到要安装ti提供的controlSUITE软件,这个软件不仅提供芯片的源文件、库文件、cmd文件等,同样还提供一些example便于dsp的使用者借鉴。

下面我慢慢回忆,断断续续谈一谈我对IPC的理解

在IPC中,我认为最关键的是: 有一块cpu1和cpu2都可以访问的共享内存空间

而IPC做了什么事情呢。比如cpu1要给cpu2传输数据,cpu1就先把数据拷贝到共享内存空间,然后再设置一个标志, cpu2检测到这个标志就会触发中断,并且在中断中读取共享内存空间的数据。核间通信应该比什么串口啊 网口啊都要快,核间只传送标志,数据都在共享内存里面。

Tips: 这个共享内存空间,是有固定的地址段的,会操作cmd的大佬一定要注意,在修改cmd文件的时候,别占用这块共享内存空间,特别是程序大时,要扩充全局变量的地址空间,可千万别把共享内存的地址空间给扩充进去了。【血泪史】 在某篇我的周报中写道: IPC通信问题已解决,问题原因是CPU1的全局变量占了IPC通信使用的共享内存的地址,解决办法修改cmd的.ebss(管全局变量的)段使用的内存区。】

当时觉得要做IPC核间通信,觉得这个怕是有点难,但掌握了以后,觉得也还好。

下面我将针对我实际开发的dsp项目的代码进行一个ipc代码剖析。把所有的零撒代码组合在一起,就可以形成完整ipc通信代码了。本例中,主要以cpu2向cpu1发起ipc通信请求。

2. CPU1代码剖析

2.1 ipcconfig

volatile tIpcController g_sIpcController1;
volatile tIpcController g_sIpcController2;

void ipcconfig(void){
    EALLOW;
    PieVectTable.IPC0_INT = &CPU02toCPU01IPC0IntHandler;
    PieVectTable.IPC1_INT = &CPU02toCPU01IPC1IntHandler;
    EDIS;

    IPCInitialize(&g_sIpcController1, IPC_INT0, IPC_INT0);
    IPCInitialize(&g_sIpcController2, IPC_INT1, IPC_INT1);

    IER |= M_INT1;

    PieCtrlRegs.PIEIER1.bit.INTx13 = 1;   // CPU1 to CPU2 INT0
    PieCtrlRegs.PIEIER1.bit.INTx14 = 1;   // CPU1 to CPU2 INT0

}

提前统一说明:本文提到的函数,都是经过封装的,便于使用时模块化调用。

从ipcconfig函数的代码中,可看到 ipc使用到了两个中断处理函数 CPU02toCPU01IPC0IntHandler和CPU02toCPU01IPC1IntHandler。都是CPU02toCPU01,也就是CPU02可能在CPU01中触发两种类型的中断。

g_sIpcController1和g_sIpcController2则是在使用IPC相较于其他中断 额外需要定义的两个IPC中断会用到的结构体。结构体的定义是ti官方提供的,命名是完全参考ti官方提供的example程序。

2.2 ipcinit

void ipcinit(void){
    pulMsgRam = (void *)CPU01TOCPU02_PASSMSG;
    pulMsgRam[0] = (uint32_t)&cpu1tocpu2.data[0].s[0];
    pulMsgRam[1] = (uint32_t)&cpu2tocpu1.data[0].s[0];
    IpcRegs.IPCSET.bit.IPC17 = 1;

    cpu1tocpu2.data[0].f[0] = 0.1234567;
    cpu1tocpu2.data[63].f[1] = 3.14;

}

其中

#define CPU01TOCPU02_PASSMSG  0x0003FFF4

 这个地址,是从example上借鉴过来的,应该是指定的。  此地址应该是 CPU1和CPU2都能访问的地址,在后续CPU2代码剖析中会谈到。

下面在看看ipcinit其他用到的相关变量定义

uint32_t *pulMsgRam;

struct  ipcstruct  cpu2tocpu1;
struct  ipcstruct  cpu1tocpu2;

union data_type{
    Uint16  s[4];
    int32   i[2];
    float32 f[2];
    float64 d;
};

struct  ipcstruct{
    union   data_type   data[64];
};

pulMsgRam是一个uint32_t类型的指针,作用是用来使用CPU01TOCPU02_PASSMSG这个地址。代码可以使用pulMsgRam来访问以CPU01TOCPU02_PASSMSG为起始地址的 数据空间。然后定义了有两个结构体变量cpu2tocpu1和cpu1tocpu2,该结构是作者本人声明的结构体类型。其作用是用于存储,cpu2 传到 cpu1这边来的数据、cpu1要发送到cpu2那边去的数据。

注意以下两句赋值语句

pulMsgRam[0] = (uint32_t)&cpu1tocpu2.data[0].s[0];
pulMsgRam[1] = (uint32_t)&cpu2tocpu1.data[0].s[0];

这两行程序的作用是把这两个结构体的首地址赋值到以CPU01TOCPU02_PASSMSG为起始地址的内存空间中去。 这个操作其特殊性应该是在于 你用于传输数据的结构体的地址,被赋值到指定的地址空间去了,而不是一个随便指定空间。这两句可太关键了。

关于下面这一句,其作用是标志位置位,其作用是当cpu2请求读取cpu1的一个数据块的内容的时候,会指定一个特定的标志位,当cpu1完成了cpu2的请求之后,这个标志位会被清零。而现在初始化时,先把它置位,其目的就是避免cpu2误判cpu1已经完成了cpu1的请求,导致程序出现错误。

IpcRegs.IPCSET.bit.IPC17 = 1;

2.3 CPU1的IPC中断处理函数

__interrupt void CPU02toCPU01IPC0IntHandler(void){
    tIpcMessage sMessage;

    while(IpcGet(&g_sIpcController1, &sMessage,
                 DISABLE_BLOCKING)!= STATUS_FAIL)
    {
        switch (sMessage.ulcommand)
        {
            case IPC_SET_BITS:
                 IPCRtoLSetBits(&sMessage);
                 break;
            case IPC_CLEAR_BITS:
                IPCRtoLClearBits(&sMessage);
                break;
            case IPC_DATA_WRITE:
                IPCRtoLDataWrite(&sMessage);
                break;
            case IPC_DATA_READ:
                IPCRtoLDataRead(&g_sIpcController1, &sMessage,
                                ENABLE_BLOCKING);
                break;
            case IPC_FUNC_CALL:
                IPCRtoLFunctionCall(&sMessage);
                break;
            default:
                break;
        }
    }

    IpcRegs.IPCACK.bit.IPC0 = 1;
    PieCtrlRegs.PIEACK.all = PIEACK_GROUP1;
}

__interrupt void CPU02toCPU01IPC1IntHandler (void) {
    tIpcMessage sMessage;
    while(IpcGet(&g_sIpcController2, &sMessage,DISABLE_BLOCKING)!= STATUS_FAIL){
        switch (sMessage.ulcommand)
        {
            case IPC_SET_BITS_PROTECTED:
                IPCRtoLSetBits_Protected(&sMessage);       // Processes
                                                           // IPCReqMemAccess()
                                                           // function
                break;
            case IPC_CLEAR_BITS_PROTECTED:
                IPCRtoLClearBits_Protected(&sMessage);     // Processes
                                                           // IPCReqMemAccess()
                                                           // function
                break;
            case IPC_BLOCK_WRITE:	// ▲▲▲ 重点关注
                IPCRtoLBlockWrite(&sMessage);
                fObsVar1 = cpu2tocpu1.data[0].f[0];
                fObsVar2 = cpu2tocpu1.data[63].f[1];

                fFtvx    = cpu2tocpu1.data[4].f[0];
                fFtvy    = cpu2tocpu1.data[4].f[1];

                if(cpu2tocpu1.data[3].s[0] == 0x01 ){
                    FSM_WorkMode = cpu2tocpu1.data[3].s[1];
                }

                fEncoderE   = cpu2tocpu1.data[1].f[0];
                fEncoderA   = cpu2tocpu1.data[1].f[1];
                fGyroE      = cpu2tocpu1.data[2].f[0];
                fGyroA      = cpu2tocpu1.data[2].f[1];
                fRtvE       = cpu2tocpu1.data[3].f[0];
                fRtvA       = cpu2tocpu1.data[3].f[1];
                break;
            case IPC_BLOCK_READ:    // ▲▲▲ 重点关注  
                IPCRtoLBlockRead(&sMessage);
                if(cpu1tocpu2.data[0].s[0]==0x01){ cpu1tocpu2.data[0].s[0] = 0x00; }
                break;
            default:
                break;
        }
    }

    IpcRegs.IPCACK.bit.IPC1 = 1;
    PieCtrlRegs.PIEACK.all = PIEACK_GROUP1;
}

两个中断处理函数各司其职, CPU02toCPU01IPC0IntHandler里面是主要是数据位操作、单个数据(最多32位)操作、函数远程调用这三个功能;而CPU02toCPU01IPC1IntHandler 最主要的,则是 一块(BLOCK)数据的操作。而作者主要用的就是这个一块数据的操作,前面的代码也提到了,我们用两个结构体,分别存储 cpu2 传到 cpu1的数据,还有就是cpu1要往cpu2送的数据。这两个结构体是肯定是Block操作。

注意代码中 // ▲▲▲ 重点关注的注释。

            case IPC_BLOCK_WRITE:	// ▲▲▲ 重点关注
                IPCRtoLBlockWrite(&sMessage);
                fObsVar1 = cpu2tocpu1.data[0].f[0];
                fObsVar2 = cpu2tocpu1.data[63].f[1];

                fFtvx    = cpu2tocpu1.data[4].f[0];
                fFtvy    = cpu2tocpu1.data[4].f[1];

                if(cpu2tocpu1.data[3].s[0] == 0x01 ){
                    FSM_WorkMode = cpu2tocpu1.data[3].s[1];
                }

                fEncoderE   = cpu2tocpu1.data[1].f[0];
                fEncoderA   = cpu2tocpu1.data[1].f[1];
                fGyroE      = cpu2tocpu1.data[2].f[0];
                fGyroA      = cpu2tocpu1.data[2].f[1];
                fRtvE       = cpu2tocpu1.data[3].f[0];
                fRtvA       = cpu2tocpu1.data[3].f[1];
                break;
            case IPC_BLOCK_READ:    // ▲▲▲ 重点关注
                IPCRtoLBlockRead(&sMessage);
                if(cpu1tocpu2.data[0].s[0]==0x01){ cpu1tocpu2.data[0].s[0] = 0x00; }
                break;

当cpu1判断cpu2的请求是 往cpu1 IPC_BLOCK_WRITE的时候,它调用了IPCRtoLBlockWrite(&sMessage);这个函数,这个函数已经把cpu2放在共享内存的数据拷贝到cpu1指定的当中去了(cpu2也可以访问地址CPU01TOCPU02_PASSMSG的存储内容,cpu1在ipcinit的时候已经在里面指定了cpu1中两个结构体cpu1tocpu2、cpu2tocpu1的地址)。所以在调用IPCRtoLBlockWrite(&sMessage);函数之后,你可以看到下面的代码在直接使用cpu2tocpu1这个结构体。 这个时候cpu2的数据已经发过来了。

同理,当cpu1判断cpu2的请求是 从cpu1 IPC_BLOCK_READ的时候,它调用了IPCRtoLBlockRead(&sMessage);这个函数,这个函数的功能是把cpu2指定要读取地址中的数据拷贝共享内存当中去(cpu2也可以访问地址CPU01TOCPU02_PASSMSG的存储内容,cpu1在ipcinit的时候已经在里面指定了cpu1中两个结构体cpu1tocpu2、cpu2tocpu1的地址),它指定的地址,说白了就是cpu1tocpu2的地址。在调用了IPCRtoLBlockRead(&sMessage);这个函数之后,cpu1已经把cpu1tocpu2这个结构体的数据拷贝到了共享内存当中,并且把IPC17这个位清零,告诉cpu2,你的请求我已经帮你完成了。cpu2根据IPC17是否被清零,判断是否进入后续的处理/等待cpu1响应结束。

3. CPU2代码剖析

3.1 ipcconfig

void ipcconfig(void){
    EALLOW;
    PieVectTable.IPC0_INT = &CPU01toCPU02IPC0IntHandler;
    PieVectTable.IPC1_INT = &CPU01toCPU02IPC1IntHandler;
    EDIS;

    IPCInitialize(&g_sIpcController1, IPC_INT0, IPC_INT0);
    IPCInitialize(&g_sIpcController2, IPC_INT1, IPC_INT1);

    IER |= M_INT1;

    PieCtrlRegs.PIEIER1.bit.INTx13 = 1;    // CPU2 to CPU1 INT0
    PieCtrlRegs.PIEIER1.bit.INTx14 = 1;    // CPU2 to CPU1 INT1

    while(IpcRegs.IPCSTS.bit.IPC17 != 1){};
    IpcRegs.IPCACK.bit.IPC17 = 1;
}

可以看到除了 最后两行代码,其他的都是和cpu1一样的。 cpu1配置的中断处理函数是说响应cpu2发起的操作的中断。而cpu2对于中断的配置,说白了是为了响应cpu1对cpu2发起的操作,而在我的程序中,我都是从cpu2向cpu1发起操作,所以等会看这两个中断处理函数,会很简单。因为正常情况下,是不会被触发的。

    while(IpcRegs.IPCSTS.bit.IPC17 != 1){};
    IpcRegs.IPCACK.bit.IPC17 = 1;

这个IPC17在第二章CPU1代码剖析中也讲了,主要的作用是用于给cpu2读取cpu1的数据的时候使用的,用于cpu1通知cpu2,我完成了你的操作。

3.2 ipcinit

void ipcinit(void){
    pulMsgRam = (void *)CPU01TOCPU02_PASSMSG;
    pusCPU01BufferPt = (void *)GS0SARAM_START;
    pusCPU02BufferPt = (void *)(GS0SARAM_START + 256);
}

与cpu1的不同之处是 cpu2又引入了两个地址GS0SARAM_START 和 GS0SARAM_START + 256而GS0SARAM_START , 应该就是共享内存的起始地址了。

#define GS0SARAM_START        0xC000

3.3 ipcsend

void ipcsend(void){
    // CPU2 obtains access to shared memory
    IPCReqMemAccess(&g_sIpcController2, GS0_ACCESS, IPC_GSX_CPU2_MASTER, ENABLE_BLOCKING);
    // Waiting for access
    while(MemCfgRegs.GSxMSEL.bit.MSEL_GS0 != 1U){};
    // Copy the data sent by CPU2 to CPU1 to the shared memory
    memcpy((void *)pusCPU02BufferPt,(void *)&cpu2tocpu1.data[0].s[0],sizeof(struct  ipcstruct));
    //  Inform CPU1 that I have sent data
    IPCLtoRBlockWrite(&g_sIpcController2, pulMsgRam[1],(uint32_t)pusCPU02BufferPt,256, IPC_LENGTH_16_BITS,ENABLE_BLOCKING);

}

只有4行程序,下面进行逐行解析

IPCReqMemAccess(&g_sIpcController2, GS0_ACCESS, IPC_GSX_CPU2_MASTER, ENABLE_BLOCKING);

第一行的作用是类似于发送一个申请,去获取共享内存的使用权限(数据块操作的权限)。

while(MemCfgRegs.GSxMSEL.bit.MSEL_GS0 != 1U){};

第二行的作用是等待获得共享内存的使用权限,直到可以访问才结束这个while

memcpy((void *)pusCPU02BufferPt,(void *)&cpu2tocpu1.data[0].s[0],sizeof(struct  ipcstruct));

第三行的作用是把cpu2要发送给cpu1的结构体数据, 以数据块的方式,拷贝到共享内存中去。

注意: 这里使用的目标地址是 pusCPU02BufferPt, 而pusCPU02BufferPt是(void *)(GS0SARAM_START + 256);也就是说,我们cpu2的用的共享内存地址是 共享内存起始地址 + 256的偏移地址。

显然, 那cpu1用的共享内存地址,就是 共享内存的起始地址。 而cpu1和cpu2的结构体的大小 就是256个dsp数据单元(16位)。

IPCLtoRBlockWrite(&g_sIpcController2, pulMsgRam[1],(uint32_t)pusCPU02BufferPt,256, IPC_LENGTH_16_BITS,ENABLE_BLOCKING);

第四行的这个函数,是ti官方提供的,可以在example程序里面找到,其作用就是 通知cpu1, 我已经把东西放在共享内存的某个地址( pusCPU02BufferPt)里面了, 我是发的是一个数据块,这个数据块共有256个数据单元(16位)。你把读的时候把我发的数据放在(pulMsgRam[1])这个地址里面吧。

当我们运行这个函数的时候, CPU1的程序会进入中断处理函数 CPU02toCPU01IPC1IntHandler中。然后把共享内存的数据, 拷贝的 “ pulMsgRam[1] ”这个空间所指定的地址中去 pulMsgRam[1]是什么呢?在cpu1的ipcconfig程序中已经赋值了,是 pulMsgRam[1] = (uint32_t)&cpu2tocpu1.data[0].s[0];也就是说, cpu2的这个结构体(cpu2tocpu1),就拷贝到了cpu1的同名结构体(cpu2tocpu1)中了。

注意到一个小问题, 在cpu2中是没有给pulMsgRam[0]和pulMsgRam[1]赋值的。 我猜测,这个地址应该是cpu1和cpu2都能够访问的。 所以cpu1做了赋值,cpu2就不用再赋值了。而且cpu2它并不知道在cpu1的程序中给结构体cpu2tocpu1分配的地址是多少。

3.4 ipcget

void ipcget(void){
    // Give the shared memory access permission to CPU1
    IPCReqMemAccess(&g_sIpcController2, GS0_ACCESS, IPC_GSX_CPU1_MASTER, ENABLE_BLOCKING);
    // Inform CPU1 that I want to read the data
    IPCLtoRBlockRead(&g_sIpcController2, pulMsgRam[0], (uint32_t)pusCPU01BufferPt, 256, ENABLE_BLOCKING,IPC_FLAG17);
    // Wait until read data is ready (by checking IPC Response Flag is cleared).
    while(IpcRegs.IPCFLG.bit.IPC17){};
    // Transfer the read data from the shared memory to the local structure
    memcpy((void *)&cpu1tocpu2.data[0].s[0],(void *)pusCPU01BufferPt,sizeof(struct  ipcstruct));

}

ipcget也只有4行程序,下面进行逐行解析

IPCReqMemAccess(&g_sIpcController2, GS0_ACCESS, IPC_GSX_CPU1_MASTER, ENABLE_BLOCKING);

第一行的作用是把共享内存的访问权限 释放给 cpu1 (支持数据块操作的访问权限),因为我们发送的时候占用这个权限,所以也得我们来释放。cpu1也不知道你cpu2什么时候不用共享内存的。

IPCLtoRBlockRead(&g_sIpcController2, pulMsgRam[0], (uint32_t)pusCPU01BufferPt, 256, ENABLE_BLOCKING,IPC_FLAG17);

第二行的这个函数,也是ti官方提供的,也是可以在example程序里面找到的,其作用就是 通知cpu1,我要读取你 pulMsgRam[0] 这个地址里面存放的数据块, 我要读256个数据单元(16位),你收到我的消息后,你把数据,给我放在共享内存的地址 pusCPU01BufferPt(共享内存起始地址)中。你如果搞定了,你就通过IPC_FLAG17来通知我,你搞定了。

这个函数运行结束后,cpu1就会触发中断函数CPU02toCPU01IPC1IntHandler,里面的

            case IPC_BLOCK_READ:
                IPCRtoLBlockRead(&sMessage);
                break;

我们不需要在这个地方添加其他的操作,IPCRtoLBlockRead这个函数,会去完成cpu2想要的操作(就是把cpu2要从cpu1读取的数据,放到对应的共享内存里面去)。这个case执行完后,IPC_FLAG17会被清零。

while(IpcRegs.IPCFLG.bit.IPC17){};

第三行作用是等待cpu1完成cpu2的读取数据请求,cpu1完成操作后,IpcRegs.IPCFLG.bit.IPC17会被清零,也就会跳出循环。

memcpy((void *)&cpu1tocpu2.data[0].s[0],(void *)pusCPU01BufferPt,sizeof(struct  ipcstruct));

第四行是不是似曾相识, 刚刚ipcsend发送是把结构体的数据拷贝到共享内存。 而现在是把共享内存的数据拷给的结构体中。这个操作结束之后,我们就可以直接使用cpu1tocpu2这个结构体了。

3.5 CPU2的IPC中断处理函数

// Normally, it will never be called
__interrupt void CPU01toCPU02IPC0IntHandler (void){
    tIpcMessage sMessage;

    while(IpcGet(&g_sIpcController1, &sMessage,DISABLE_BLOCKING) != STATUS_FAIL){
        switch (sMessage.ulcommand)
        {
            case IPC_DATA_WRITE:
                IPCRtoLDataWrite(&sMessage);
                break;
            default:
                break;
        }
    }

    IpcRegs.IPCACK.bit.IPC0 = 1;
    PieCtrlRegs.PIEACK.all = PIEACK_GROUP1;
}

// Normally, it will never be called
__interrupt void CPU01toCPU02IPC1IntHandler(void){
    IpcRegs.IPCACK.bit.IPC1 = 1;
    PieCtrlRegs.PIEACK.all = PIEACK_GROUP1;
}

可以看到,CPU2的IPC中断处理函数基本上啥都没写。 为什么? 因为所有的操作都是从cpu2向cpu1发起的,只会在cpu1触发中断。而cpu1没有向cpu2发送中断请求,所以不会在cpu2触发中断。因此cpu2的中断处理函数可以草率一点,不用写什么。

那么至此,TMS320F28377D最简单最高效的IPC核间通信已经讲完了。 把所有提及的内容组合到一起就是完成的IPC核间通信了,值得注意的是,ipcconfig和ipcinit只会被执行一次。  CPU1应该以某种固定的频率去给cpu1tocpu2结构体赋值。 CPU2也得也固定的频率去调用ipcsend和ipcget函数。

  • 16
    点赞
  • 95
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 4
    评论
好的,下面是在CCS中创建新工程并移植到TMS320F28377S的步骤: 步骤一:创建新工程 1. 打开CCS软件,选择File -> New -> CCS Project,弹出新建工程的窗口。 2. 选择合适的工作空,输入工程名称和描述,选择TMS320F28377S芯片型号和编译器版本。 3. 点击Finish按钮,创建工程。 步骤二:设置工程属性 1. 右键点击工程名称,选择Properties,打开工程属性设置窗口。 2. 在左侧菜单栏中选择General -> Project References,添加需要的库文件,如DSP库、BIOS库等。 3. 在左侧菜单栏中选择CCS Build -> ARM Compiler -> Include Options,添加头文件路径,如DSP库、BIOS库等的头文件路径。 4. 在左侧菜单栏中选择CCS Build -> ARM Linker -> File Search Path,添加库文件路径,如DSP库、BIOS库等的库文件路径。 步骤三:添加源文件 1. 右键点击工程名称,在弹出的菜单中选择New -> Source File,创建新的源文件。 2. 将需要的源文件添加到工程中。 步骤四:配置连接器文件 1. 在工程目录下创建cmd文件夹,将TMS320F28377S芯片的连接器文件放入其中。 2. 在工程属性设置窗口中,选择CCS Build -> ARM Linker -> Basic Options,将cmd文件夹路径添加到Command file search path中。 步骤五:编译工程 1. 右键点击工程名称,选择Build Project,编译工程。 2. 检查编译过程中是否有错误或者警告信息。 步骤六:下载程序 1. 将开发板与PC机连接,选择合适的下载方式。 2. 点击Debug按钮,下载程序到开发板中。 以上就是在CCS中创建新工程并移植到TMS320F28377S的步骤,希望对你有所帮助。如果你有其他问题,可以继续问我。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

江湖上都叫我秋博

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值