PYNQ 框架 - VDMA驱动 - 帧缓存

目录

1. 简介

2. 代码分析

2.1 _FrameCache 类定义

2.1.1 xlnk.cma_array()

2.1.2 pointer=None

2.1.3 PynqBuffer

2.2 _FrameCache 例化与调用

2.3 _FrameList 类定义

2.4 _FrameList 例化与调用

3. 帧的使用

3.1 读取帧

3.2 读取内部帧

3.3 总的读取逻辑

4. Jupyter 中的异步编程

4.1 协程与线程

4.2 嵌套循环

4.3 语法与示例

4.4 while - await 循环

5. 总结


1. 简介

本文分享在 PYNQ 框架下,AXI VDMA 驱动的部分实现细节,重点分析帧缓存的管理和使用。

重点分析了 _FrameCache 和 _FrameList 类的实现与功能。这些类用于管理帧缓存,包括内存分配、帧获取、所有权管理等操作,确保高效的视频数据处理和传输。

代码的主要结构框架如下:

class _FrameCache:
    _xlnk = None
    ...
    def getframe(self):
    # 从缓存中检索一个帧,或者创建一个新帧CMA。

class AxiVDMA(DefaultIP):
"""Xilinx VideoDMA IP核的驱动类
该驱动程序分为输入和输出通道,并通过 readchannel 和 writechannel 属性公开。
每个通道都有 start 和 stop 方法来控制数据传输。
"""
    def __init__(self, description, framecount=None):
        ...
        super().__init__(description)
        if "parameters" in description:
            parameters = description["parameters"]
            has_s2mm = parameters["C_INCLUDE_S2MM"] == "1"
            has_mm2s = parameters["C_INCLUDE_MM2S"] == "1"
        ...
        if has_s2mm: # 由IP设置确定是否包含属性
            self.readchannel = AxiVDMA.S2MMChannel(self, self.s2mm_introut, memory)
        if has_mm2s:
            self.writechannel = AxiVDMA.MM2SChannel(self, self.mm2s_introut, memory)
        ...

    bindto = ["xilinx.com:ip:axi_vdma:6.2", "xilinx.com:ip:axi_vdma:6.3"]

    class _FrameList:
    """用于处理与 DMA 通道关联的帧列表的内部辅助类。除非通过 takeownership
    显式移除,否则假定其包含的所有帧的所有权。
    """
        def __init__(self, parent, offset, count):
            self._frames = [None] * count
            ...

    class S2MMChannel:
        ...
        def start(self):
            ...
            self._cache = _FrameCache(
                    self._mode, cacheable=self.cacheable_frames)
            for i in range(len(self._frames)):
                self._frames[i] = self._cache.getframe()
            ...

        def _readframe_internal(self):
            ...
            nextframe = self._cache.getframe()
            previous_frame = (self.activeframe + 2) % len(self._frames)
            captured = self._frames[previous_frame]
            self._frames.takeownership(previous_frame)
            self._frames[previous_frame] = nextframe
            captured.invalidate()
            return captured

    class MM2SChannel:
        ...
        def start(self):
            ...
            self._cache = _FrameCache(
                    self._mode, cacheable=self.cacheable_frames)
            self._frames[0] = self._cache.getframe()
            ...

2. 代码分析

2.1 _FrameCache 类定义

核心功能:申请并管理 CMA。

import asyncio
import numpy as np
from pynq.xlnk import ContiguousArray
from pynq import DefaultIP, Xlnk

class _FrameCache:
    _xlnk = None # 类变量(静态变量)

    def __init__(self, mode, capacity=5, cacheable=0):
        self._cache = [] # 空列表,帧缓存的指针
        self._mode = mode
        self._capacity = capacity
        self._cacheable = cacheable

    def getframe(self):
        if self._cache: # 缓存有数据
            frame = _FrameCache._xlnk.cma_array(
                shape=self._mode.shape, dtype='u1', cacheable=self._cacheable,
                pointer=self._cache.pop(), cache=self)
        else: # 缓存为空
            if _FrameCache._xlnk is None:
                _FrameCache._xlnk = Xlnk() # 执行延迟初始化
            # 创建连续内存
            frame = _FrameCache._xlnk.cma_array(
                shape=self._mode.shape, dtype=np.uint8,
                cacheable=self._cacheable, cache=self)
        return frame

    # 添加到缓存列表
    def return_pointer(self, pointer):
        if len(self._cache) < self._capacity:
            self._cache.append(pointer)

    def clear(self):
        self._cache.clear() # 清空列表(帧CMA地址)

1)功能详解

  • 初始化(__init__):缓存列表_cache、模式_mode、缓存容量_capacity和是否可缓存的标志_cacheable。
  • 获取帧(getframe):
    • 如果缓存中有可用的帧,则从缓存中取出一个帧并返回。
    • 如果缓存为空,则创建一个新的帧。
    • 使用 Xlnk 库的 cma_array 方法来分配连续内存的数组。
    • 返回的数组对象的 freebuffer 方法被重写,以便在被释放时将其返回到缓存,而不是直接释放掉。
  • 返回指针(return_pointer方法):
    • 将一个帧的指针返回到缓存中,如果缓存未达到容量限制,则将指针添加到缓存列表中。
  • 清空缓存(clear方法):
    • 清空缓存中的所有帧指针。

2)_xlnk = None

  • _xlnk 是一个类变量(静态变量),为了实现 Xlnk 实例的延迟初始化和共享。
  • 通过将 _xlnk 设为类变量,所有的 _FrameCache 实例都可以共享同一个 Xlnk 实例。常用于管理比较紧张的资源,避免了重复的实例化和资源浪费。

3)cma_array() 函数的最后一个参数:cache=self,有特殊用意。

在 cma_array 创建的对象中保留对 _FrameCache 的引用,以便在对象不再使用时,可以通过 _FrameCache 实例执行特定的资源管理操作。

4)dtype='u1'

指定数据类型为无符号8位整数(等同于 np.uint8),表示每个元素占用1个字节。

2.1.1 xlnk.cma_array()

def cma_array(self, shape, dtype=np.uint32, cacheable=0,
              pointer=None, cache=None):
    if isinstance(shape, numbers.Integral):
        shape = [shape]
    dtype = np.dtype(dtype)
    elements = functools.reduce(lambda value, total: value * total, shape)
    length = elements * dtype.itemsize
    if pointer is None:
        raw_pointer = self.cma_alloc(length, cacheable=cacheable)
        pointer = self.ffi.gc(raw_pointer, self.cma_free, size=length)
    buffer = self.cma_get_buffer(pointer, length)
    physical_address = self.cma_get_phy_addr(pointer)
    view = PynqBuffer(shape=shape, dtype=dtype, buffer=buffer,
                      device_address=physical_address,
                      coherent=not cacheable,
                      bo=physical_address, device=self)
    view.pointer = pointer
    view.return_to = cache
    return view

功能:创建一个物理上连续的 numpy 数组。可以通过返回对象的 physical_address 属性找到该数组的物理地址。当不再需要数组时,应该使用 array.freebuffer() 或 array.close() 来释放数组。此外,cma_array 可以在 with 语句中使用,以便在代码块结束时自动释放内存。

参数:

  • shape(int 或 int 的元组)—— 要构建的数组的维度
  • dtype(numpy.dtype 或 str)—— 要构建的数据类型,默认为32位无符号整数
  • cacheable(int)—— 缓冲区是否可缓存,默认值为0

返回:numpy 数组;返回类型:numpy.ndarray

2.1.2 pointer=None

pointer 默认值 None。

1)内存分配

当 pointer 是 None 时,代码会执行 self.cma_alloc(length, cacheable=cacheable) 来分配一块所需大小 (length) 的内存,并返回一个指向这块内存的原始指针 raw_pointer。

2)自定义 pointer

如果调用者提供了一个自定义的 pointer,则意味着内存已经在函数外部分配好了,函数只需使用该指针而不再需要自行分配内存。

2.1.3 PynqBuffer

view = PynqBuffer(shape=shape, dtype=dtype, buffer=buffer,
                  device_address=physical_address,
                  coherent=not cacheable,
                  bo=physical_address, device=self)

PynqBuffer 是一个类,用于创建一个物理上连续的内存缓冲区,继承自 numpy.ndarray,并添加了一些额外的属性和方法来处理物理地址和缓存一致性。

此处代码是创建一个 PynqBuffer 对象,并初始化其属性:

  • shape 和 dtype 定义了缓冲区的形状和数据类型。
  • buffer 是实际的数据缓冲区。
  • device_address 是缓冲区的物理地址。
  • coherent 表示缓冲区是否是一致的(即是否需要缓存一致性)。
  • bo 是缓冲区对象的标识符。
  • device 是与缓冲区关联的设备。

缓存一致性(Cache Coherence)

当多个处理器核心共享同一块内存区域时,如果一个核心修改了这块内存中的数据,其他核心的缓存中也必须反映这一变化,以避免数据不一致的问题。

cacheable 参数

  • cacheable=0(不可缓存):缓冲区的数据不会被缓存,直接从主内存读取和写入。这种情况下,数据的一致性由硬件保证,适用于需要频繁访问和修改的缓冲区。
  • cacheable=1(可缓存):缓冲区的数据会被缓存到处理器的缓存中,以提高访问速度。这种情况下,需要手动处理缓存一致性问题,例如在数据修改后刷新缓存,以确保其他处理器核心看到的数据是最新的。

2.2 _FrameCache 例化与调用

_FrameCache 类的实例化,是在 AxiVDMA 类的嵌套两个子类 S2MMChannel 和 MM2SChannel 类中 start 方法中进行的,代码如下:

class AxiVDMA(DefaultIP):
    """Driver class for the Xilinx VideoDMA IP core
    """
    class S2MMChannel:
        ...
        def start(self):
            """Start the DMA. The mode must be set prior to this being called

            """
            if not self._mode:
                raise RuntimeError("Video mode not set, channel not started")
            self.desiredframe = 0
            # 创建 _FrameCache 对象(初始化)
            self._cache = _FrameCache(
                    self._mode, cacheable=self.cacheable_frames)
            # 依据_frames数量,申请若干个CMA区域
            for i in range(len(self._frames)):
                self._frames[i] = self._cache.getframe()

            self._writemode()
            self.reload()
            self._mmio.write(0x30, 0x108b)#0x00011083)  # Start DMA
            self.irqframecount = 4  # Ensure all frames are written to
            self._mmio.write(0x34, 0x1000)  # Clear any interrupts
            while not self.running:
                pass
            self.reload()
            self.desiredframe = 1

    class MM2SChannel:
        ...
        def start(self):
            """Start the DMA channel with a blank screen. The mode must
            be set prior to calling or a RuntimeError will result.

            """
            if not self._mode:
                raise RuntimeError("Video mode not set, channel not started")
            self._cache = _FrameCache(
                    self._mode, cacheable=self.cacheable_frames)
            self._frames[0] = self._cache.getframe()
            self._writemode()
            self.reload()
            self._mmio.write(0x00, 0x008b)#0x00011089)
            while not self.running:
                pass
            self.reload()
            self.desiredframe = 0
            pass

_FrameCache 类会根据 _mode 中的形状申请内存区域,另一个参数是 cacheable_frames,标志是否可缓存。

self._cache = _FrameCache(self._mode, cacheable=self.cacheable_frames)

2.3 _FrameList 类定义

_FrameList 是一个内部辅助类,用于处理与 VDMA 通道关联的帧列表,负责管理帧的存储、访问和所有权转移。

class AxiVDMA(DefaultIP):
    class _FrameList:

        def __init__(self, parent, offset, count=3):
            self._frames = [None] * count # 创建列表[None, None, None]
            self._mmio = parent._mmio
            self._offset = offset # 即通道 Start Address
            self._slaves = set() # 集合
            self.count = count
            self.reload = parent.reload # 写入VSize,重启 VDMA 通道

        def __getitem__(self, index):
            frame = self._frames[index]
            return frame

        def takeownership(self, index):
            self._frames[index] = None

        def __len__(self):
            return self.count

        def __setitem__(self, index, frame):
            self._frames[index] = frame
            if frame is not None:
                self._mmio.write(self._offset + 4 * index, frame.physical_address)
            else:
                self._mmio.write(self._offset + 4 * index, 0)
            self.reload()
            for s in self._slaves:
                s[index] = frame
                s.takeownership(index)

        def addslave(self, slave):
            self._slaves.add(slave)
            for i in range(len(self._frames)):
                slave[i] = self[i]
                slave.takeownership(i)
            slave.reload()

        def removeslave(self, slave):
            self._slaves.remove(slave)

主要功能:

  • 初始化
  • 索引访问 __getitem__(self, index):通过索引访问帧列表中的某个帧
  • 长度访问 __len__(self)
  • 赋值行为:_FrameList[i] = value 
    • 设置或更新帧列表中指定索引位置的帧。
    • 如果帧非空,将帧的物理地址写入对应 Start Address。
    • 如果帧为空,则在对应的内存地址写入0。
    • 调用 reload() 方法来启动 VDMA。
    • 更新所有从属对象中对应的帧信息,并转移所有权。
  • 添加从属对象 addslave(self, slave)
  • 移除从属对象 removeslave(self, slave)

这个类的设计允许对帧进行集中管理,并通过内存映射输入输出与硬件设备进行交互,同时能够同步更新多个从属对象的帧信息。

2.4 _FrameList 例化与调用

class AxiVDMA(DefaultIP):
    class _FrameList:
        def __init__(self, parent, offset, count):
            self._frames = [None] * count
            self._mmio = parent._mmio
            self._offset = offset
            self._slaves = set()
            self.count = count
            self.reload = parent.reload
        ...

    class S2MMChannel:

        def __init__(self, parent, interrupt):
            self._mmio = parent.mmio
            self._frames = AxiVDMA._FrameList(self, 0xAC, parent.framecount)
            self._interrupt = interrupt
            self._sinkchannel = None
            self._mode = None
            self.cacheable_frames = True
        ...

1)def __init__(self, parent, offset, count)

_FrameList 的构造函数,包含3个参数:

  • parent:想要引用父对象的方法,parent._mmio,parent.reloa
  • offset:存放 S2MM 或者 MM2S 通道的 Start Address
  • count:存放 Frame Buffers 变量,是在 Vivado IDE 中设定的

注意混淆,第一个参数 self 是不需要传递的

2)self._frames = AxiVDMA._FrameList(self, 0xAC, parent.framecount)

创建一个 _FrameList 对象,并将其赋值给 self._frames。

  • self 是 S2MMChannel 的实例,作为 parent 参数传入
  • 0xAC 是第二个参数
  • parent.framecount 是第三个参数

3)__setitem__(self, index, frame) 赋值操作

调用 __setitem__ 方法在通道 start 函数中:

首先,self._cache.getframe() 语句执行了内存申请的操作,并且返回物理地址。

其次,此循环次数由 self._frames 列表长度确定,而列表长度由 count 参数决定(即 Frame Buffers),如果帧缓存设置为三重缓存,则申请3个连续地址。

最重要的,理解 self._frames[i] = self._cache.getframe() 赋值操作,执行了 def __setitem__(self, index, frame) 操作,核心功能是:

  • 将申请的 DDR 连续内存的地址传递给 VDMA 寄存器的 Start Address。
  • 执行 reload(写入 Vsize),重启 VMDA 传输。
  • 清除 self._frames 中的 CMA 地址,即不能通过 _FrameList 访问到这些 CMA 帧缓存。

3. 帧的使用

3.1 读取帧

def readframe(self):
    """从通道读取一帧并返回给用户

    此函数可能会阻塞,直到读取完整帧为止。单帧缓冲区会被保留,
    因此在长时间暂停读取后读取的第一帧可能会返回过时的帧。
    为了确保在开始处理视频时获取最新的帧,请在开始处理循环之前
    额外读取一次。

    返回值
    -------
    视频帧的 numpy.ndarray

    """
    if not self.running:
        raise RuntimeError('DMA channel not started')
    while self._mmio.read(0x34) & 0x1000 == 0:
        loop = asyncio.get_event_loop()
        loop.run_until_complete(
            # 创建协程,从Py3.7,推荐用asyncio.create_task()来代替ensure_future
            asyncio.ensure_future(self._interrupt.wait())) 
        pass # 此处无用,可删除
    self._mmio.write(0x34, 0x1000)
    return self._readframe_internal()
  • 等待帧数据可用:
    • while self._mmio.read(0x34) & 0x1000 == 0: 持续检查状态寄存器,直到数据就绪。
    • 循环内部,使用 asyncio 库来异步等待一个名为 _interrupt 的事件。这通常是因为需要等待硬件事件或中断,表明新的数据帧已经准备好读取。
    • loop = asyncio.get_event_loop() 获取当前的事件循环。
    • loop.run_until_complete(asyncio.ensure_future(self._interrupt.wait())) 等待 _interrupt 事件的触发。 
  • 读取帧数据:
    • self._mmio.write(0x34, 0x1000) 清除中断。
    • return self._readframe_internal() 调用 _readframe_internal() 方法。

3.2 读取内部帧

def _readframe_internal(self):
    if self._mmio.read(0x34) & 0x8980:
        # Some spurious errors can occur at the start of transfers
        # let's ignore them for now
        self._mmio.write(0x34, 0x8980)
    self.irqframecount = 1
    nextframe = self._cache.getframe() # 用 nextframe 记录新申请的 CMA 地址
    previous_frame = (self.activeframe + 2) % len(self._frames) # 计算前一帧的编号
    captured = self._frames[previous_frame] # 用 captured 记录前一帧的 CMA 地址
    self._frames.takeownership(previous_frame) # 转移所有权
    self._frames[previous_frame] = nextframe # previous_frame 对应的地址将由新的 CMA 覆盖
    post_frame = (self.activeframe + 2) % len(self._frames) # 无需此变量,可以删除。
    captured.invalidate() # 刷新缓存
    return captured

1)self.activeframe

def activeframe(self):
    """The frame index currently being processed by the DMA

    This process requires clearing any error bits in the DMA channel

    """
    self._mmio.write(0x34, 0x4090)
    return (self._mmio.read(0x28) >> 24) & 0x1F

返回正在操作的帧的编号,最大范围是0-31,这个值就是 Vivado IDE 中的 VDMA IP 中参数缓存帧数量,本例中这个值为3。

2)previous_frame = (self.activeframe + 2) % len(self._frames)

activeframe 为当前操作帧的编号,经过以上计算,previous_frame 是上一帧的编号。

activeframe    len(_frames)   previous_frame
---------------------------------------------
(0     +    2)   % 3        = 2
(1     +    2)   % 3        = 0
(2     +    2)   % 3        = 1

3.3 总的读取逻辑

假定 SA1 是正在被操作的帧(activeframe),那么前一帧(SA3,previous_frame)已经保存完整,我们就将其截取下来(captured),并申请一个新的 CMA 内存区域(nextframe),将其重新赋值给 SA3。

特别注意:nextframe 不是 VDMA 要操作的下一个帧,不要由名字误解。

4. Jupyter 中的异步编程

4.1 协程与线程

Python 中,协程用于并发编程,适合处理 I/O 并发的任务,通过 async 和 await 语法构建。

协程与线程对比:

1)执行控制:

  • 线程/进程:操作系统控制线程或进程的执行,包括它们的调度和中断。
  • 协程:在用户空间内执行,调度由应用程序(通常是通过库,如 asyncio)控制,不涉及操作系统的线程调度。协程的切换由程序员或库显式控制,通常在等待非阻塞操作时发生。

2)资源消耗:

  • 线程/进程:每个线程/进程通常需要较多的内存和系统资源。
  • 协程:协程更轻量级,创建和销毁的成本较低,因为它们只是特定函数的执行状态。

3)使用场景:

  • 线程/进程:适合计算密集型任务,因为它们可以真正并行执行在多核CPU上。
  • 协程:适合 I/O 密集型任务(如网络请求、文件读写等),因为它们可以在等待 I/O 操作完成时让出 CPU,处理其他任务。

4.2 嵌套循环

在 Jupyter Notebook 中使用异步编程,需要留意:Jupyter Notebook 自身已经使用了一个事件循环来处理其内部操作

所以,应当使用 nest_asyncio.apply() 启用嵌套事件循环,来避免事件循环冲突问题,并且不需要手动关闭事件循环。 否则,会出现如下报错:

1)事件循环已在运行中

RuntimeError: This event loop is already running

2)试图关闭正在运行的事件循环

RuntimeError: Cannot close a running event loop

3)无法启动事件

RuntimeError: asyncio.run() cannot be called from a running event loop

4.3 语法与示例

1)async 关键字:

  • 当你在函数定义前加上 async 关键字时,这个函数就变成了一个异步函数(协程)。
  • 异步函数可以使用 await 关键字来暂停执行,等待某个异步操作完成。

2)await 关键字:

  • await 只能在 async 定义的异步函数中使用。
  • 它用于等待一个 awaitable 对象(如协程、任务或 Future 对象)完成。
  • 当 await 等待的操作完成后,函数会继续执行后续代码。

3)代码示例

import asyncio
import nest_asyncio

# 允许嵌套的事件循环
nest_asyncio.apply()

async def event1():
    print("Event1 start")
    await asyncio.sleep(2)  # 模拟I/O操作,如网络请求
    print("Event2 end")
    return {'data': 123}

async def event2():
    for i in range(10):
        print(i)
        await asyncio.sleep(0.5)  # 模拟执行其他操作的等待

async def main():
    task1 = asyncio.create_task(event1())
    task2 = asyncio.create_task(event2())

    data = await task1
    await task2
    print("Event1 result:", data)

asyncio.run(main())

4)单个异步函数内部顺序执行

如 async def event1(),代码的执行是顺序的,但是这种顺序执行可以通过 await 语句进行“暂停”。一旦 await 后面的表达式完成,控制权就会返回到这个异步函数,并从 await 语句之后继续按顺序执行。

5)多个异步函数可以并发执行

data = await task1
await task2
print("Event1 result:", data)

关键字 await 会将 task1 与 task2 加入协程,那么 task1 与 task2 可以并行操作,而 print("Event1 result:", data) 将按照顺序执行。

6)以下两种方式等价:

# 1. 直接运行异步函数
asyncio.run(main())

# 2. 获取循环事件后运行
loop = asyncio.get_event_loop()
loop.run_until_complete(main())

4.4 while - await 循环

import asyncio
import nest_asyncio

# 允许嵌套的事件循环
nest_asyncio.apply()

async def event1(sig1):
    await asyncio.sleep(3)  # # 模拟I/O操作
    sig1.set()
    print("Sig1 set")

async def event2(sig2):
    await asyncio.sleep(5)  # # 模拟I/O操作
    sig2.set()
    print("Sig2 set")

async def event3(sig1, sig2):
    while not sig1.is_set():
        print("Event3 is running")
        await sig2.wait()
    print("Event3 end")

async def main():
    sig1 = asyncio.Event()
    sig2 = asyncio.Event()
    task1 = asyncio.create_task(event1(sig1))
    task2 = asyncio.create_task(event2(sig2))
    task3 = asyncio.create_task(event3(sig1, sig2))

    # 等待两个任务完成
    await task1
    await task2
    await task3

# 运行主函数
asyncio.run(main())

使用 while - await 方法,event3 只在 event1 和 event2 都完成后才开始执行,用于执行依赖于多个前置条件的任务。

有更简单的执行方法:

import asyncio
import nest_asyncio

# 允许嵌套的事件循环
nest_asyncio.apply()

async def event1(sig1):
    await asyncio.sleep(3)  # 模拟I/O操作
    sig1.set()
    print("Sig1 set")

async def event2(sig2):
    await asyncio.sleep(5)  # 模拟I/O操作
    sig2.set()
    print("Sig2 set")

async def event3(sig1, sig2):
    await sig1.wait()  # 等待sig1被设置
    await sig2.wait()  # 等待sig2被设置
    print("Event3 starts after both events")

async def main():
    sig1 = asyncio.Event()
    sig2 = asyncio.Event()
    task1 = asyncio.create_task(event1(sig1))
    task2 = asyncio.create_task(event2(sig2))
    task3 = asyncio.create_task(event3(sig1, sig2))

    await task1
    await task2
    await task3

# 运行主函数
asyncio.run(main())

5. 总结

本文详细解析了在 PYNQ 框架下,AXI VDMA 驱动中帧缓存管理的实现细节,重点介绍了 _FrameCache 和 _FrameList 类,以及帧的使用方法。

  • _FrameCache 类通过管理帧缓存的内存分配和回收,确保了视频数据处理的高效性。其实现中,通过共享的 Xlnk 实例进行连续内存分配,并采用单一所有权模型来管理帧的使用和释放,避免了资源浪费和竞争。
  • _FrameList 类则负责管理帧的存储、访问与所有权转移,支持多从属对象的同步更新。

9a08a5fe3711436393de6ebd11549afd.png

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值