HLS / Chisel 实现CORDIC算法计算除法(线性坐标系向量模式LV)

CORDIC(坐标旋转数字算法)是一种计算三角、双曲和其他数学函数的有效方法。它是一种数字算法,每次运算均产生一次结果输出。这使我们能够根据应用需求调整算法精度;增加运算迭代次数可以得到更精确的结果。

CORDIC是只使用加法、减法、移位和查找表实现的简单算法,这种算法在FPGA中实现效率高,在硬件算法实现中经常用到。

CORDIC LV模式 原理

在这里CORDIC算法LV模式从输入的 ( x , y ) (x,y) (x,y)向量为起始点进行线性伪旋转,即横坐标x不变,纵坐标不断向x轴伪旋转角度 θ \theta θ,直到y变为0。如下所示从 ( x i , y i ) (x_i,y_i) (xi,yi)伪旋转到 ( x i + 1 , y i + 1 ) (x_{i+1},y_{i+1}) (xi+1,yi+1)

x i + 1 = x i = x 0 x_{i+1} = x_i = x_0 xi+1=xi=x0

y i + 1 = y i + x i ∗ t a n ( θ i ) y_{i+1} = y_i + x_i*tan(θ_i) yi+1=yi+xitan(θi)

注意到由于 x x x是不变的,那么就有 x i ∗ t a n ( θ i ) = x i ∗ △ y x 0 = △ y x_i*tan(θ_i)= x_i * \frac {△y} {x_0} = △y xitan(θi)=xix0y=y,即下图伪旋转变化。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-D6ugzb39-1649583912254)(https://d37hhp00kf.feishu.cn/space/api/box/stream/download/asynccode/?code=MDRlNTE3ZjJiNGQ4NzA3NjRmZmNiMzk0YzEwNTFiYzhfQk1CUUY4NE1NRUxjZ2hOWGJqRU5NVEY3OFJYT1p5ZEJfVG9rZW46Ym94Y25Qd0UwVzZaUnBMV2RHZHFIY0dhd1NjXzE2NDk1ODM4NjU6MTY0OTU4NzQ2NV9WNA)]

在CORDIC算法的LV模式下我们希望求除法计算,即对输入的点 ( x , y ) (x,y) x,y除法 y x \frac y x xy,即 t a n θ tan\theta tanθ。注意到如上推导的 x i ∗ t a n ( θ i ) = x i ∗ △ y x 0 = △ y x_i*tan(θ_i) = x_i * \frac {△y} {x_0} = △y xitan(θi)=xix0y=y,当 y 0 y_0 y0伪旋转到0时,累计了一系列了的 θ i \theta_i θi旋转,他们的和如下推导:

∑ 0 < i < n tan ⁡ θ i = ∑ 0 < i < n y i x 0 = y 0 x 0 \sum_{0<i<n} \tan \theta_{i}=\sum_{0<i<n} \frac{y_{i}}{x_{0}}=\frac{y_{0}}{x_{0}} 0<i<ntanθi=0<i<nx0yi=x0y0

注意伪旋转的角度和 θ \theta θ取指范围为

∑ 0 < i < n θ i = θ ∈ [ − π 2 , π 2 ] \sum_{0<i<n} \theta_{i}=\theta \in\left[-\frac{\pi}{2}, \frac{\pi}{2}\right] 0<i<nθi=θ[2π,2π]

则可以总结推导的公式如下:

x = { x i + 1 = x 0 y i + 1 = y i + σ i x i t a n ( θ i ) z i + 1 = z i − σ i t a n ( θ i ) x = \begin{cases} x_{i+1} = x_0 \\ y_{i+1} = y_i + \sigma_i x_i tan(θ_i) \\ z_{i+1} = z_i - \sigma_i tan(θ_i) \end{cases} x=xi+1=x0yi+1=yi+σixitan(θi)zi+1=ziσitan(θi)

σ i = { + 1 , y i < 0 − 1 , y i > = 0 \sigma_i = \begin{cases} +1 ,y_i<0 \\ -1 , y_i>=0 \\ \end{cases} σi={+1,yi<01,yi>=0

最终 y n y_n yn趋近与0,可得到如下的结果:

x = { x n = x 0 y n = 0 z n = ∑ 0 < i < n − σ i tan ⁡ ( θ i ) x=\left\{\begin{array}{l} x_{n}=x_{0} \\ y_{n}=0 \\ z_{n}=\sum_{0<i<n}-\sigma_{i} \tan \left(\theta_{i}\right) \end{array}\right. x=xn=x0yn=0zn=0<i<nσitan(θi)

z n z_n zn是输出的结果 y x \frac y x xy

硬件计算优化

如上可以发现还是有两个乘法在其中,对于实现硬件代价较高。可以考虑**乘以2的任意幂可以转变为移位操作。**如果我们将旋转矩阵中的常量设置为2的幂,我们可以非常容易地执行旋转而不需要乘法。

如果我们限制 t a n ( θ i ) tan(θ_i) tan(θi) 的值是2的幂次,那么旋转运算可以简化为数据移位(乘法)和加法。具体为,我们设 t a n ( θ i ) = 2 − i tan(θ_i)=2^{−i} tan(θi)=2i ,则变为

x = { x i + 1 = x 0 y i + 1 = y i + σ i x i 2 − i z i + 1 = z i − σ i 2 − i x = \begin{cases} x_{i+1} = x_0 \\ y_{i+1} = y_i + \sigma_i x_i 2^{-i}\\ z_{i+1} = z_i - \sigma_i 2^{-i} \end{cases} x=xi+1=x0yi+1=yi+σixi2izi+1=ziσi2i

在每次迭代中,我们需要知道刚刚执行的旋转角 θ i θ_i θi,其中 θ i = a r c t a n ( 2 − i ) θ_i=arctan(2^{−i}) θi=arctan(2i)。我们可以提前计算每一个i对应的 θ i θ_i θi 值,然后把它们存储在片上内存中,之后我们可以像用查找表一样用这些值.
在这里插入图片描述

输入输出范围优化

由上述硬件计算优化小节分析,我们可以推导出 y n y_n yn y 0 y_0 y0的跌到的过程如下:

∑ 0 < i < n y i = ∑ 0 < i < n σ i x i 2 − i \sum_{0<i<n} y_{i}=\sum_{0<i<n} \sigma_{i} x_{i} 2^{-i} 0<i<nyi=0<i<nσixi2i

取极限使得 σ i \sigma_i σi始终为正,即向量与x轴夹角为一个较大的正数度数;同时由于 x i = x 0 x_i=x_0 xi=x0;且线性推导最终 y i y_i yi趋近于0,其和为 y 0 y_0 y0,则有如下的式子:

y 0 = x 0 ∑ 0 < i < n 2 − i y_{0}=x_{0} \sum_{0<i<n} 2^{-i} y0=x00<i<n2i

对式子中后者取极限,n趋近于正无穷,可得到

lim ⁡ n → > ∞ ∑ 0 < i < n 2 − i = 2 \lim _{n \rightarrow>\infty} \sum_{0<i<n} 2^{-i}=2 n>lim0<i<n2i=2

所以可以得到输入输出的范围为:

− 2 < = y x < = 2 − 2 < = z < = 2 \begin{aligned} &-2<=\frac{y}{x}<=2 \\ &-2<=z<=2 \end{aligned} 2<=xy<=22<=z<=2
当然我们的除法肯定不能只实现如上范围的输入,为了扩充除法的范围到实数的限制,我们可以将 x , y x,y x,y都通过二进制位移(硬件成本较低)与提出正负号,将范围移动到 [ 0.5 , 1 ] [0.5,1] [0.5,1]得到 x ′ , y ′ x',y' x,y,将 x ′ , y ′ x',y' x,y的除法计算出来后,再二进制移位与添加正负号还原即可。

HLS代码思路

  • 它将原坐标系下的向量表示 ( x 0 , y 0 ) (x_0,y_0) (x0,y0)作为输入,输出这除法 y x \frac y x xy结果 z z z
  • 我们假设cordic.h文件定义不同的数据类型(例如,COS_SIN_TYPE和THETA_TYPE)并设置NUM_ITERATIONS为某个常数。
  • 数据类型可以更改为不同的定点或浮点类型,设置NUM_ITERATIONS值要同时考虑我们期望的精度、资源和吞吐量。
  • 单独定义移位到 [ 0.5 , 1 ] [0.5,1] [0.5,1]范围的模块,返回移位后的值、移位数量(左移为正,右移为负)和是否变换了符号。模块通过移位直到进入范围确定结果,移位的最高移位数一定大于等于可以表示的二进制位数。
#define THETA_TYPE float
#define COS_SIN_TYPE float
#define NUM_ITERATIONS 50

void shift_2_range(COS_SIN_TYPE x, COS_SIN_TYPE &out, int &cnt, int &flag){
    // change signed number symbol
    if(x<0){
        x = -x;
        flag = -1;
    }else{
        flag = 1;
    }
    if(x<0.5){        
        for(int i = 32;i>1;i++){ // 此左右位移数目可以依照定点数位数改变
            if((x<<i) >= 0.5) out = x,cnt = i;
        }
    }else if(x>1){
        for(int i = 1;i<32;i++){ // 此左右位移数目可以依照定点数位数改变
            if((x>>i) <= 1) out = x,cnt = -i;
        }
    }else{
        out = x;
        cnt = 0;
    }
}

void cordic_cv(COS_SIN_TYPE x_in, COS_SIN_TYPE y_in, THETA_TYPE &theta, THETA_TYPE &r)
{
    // Handle the input range to [0.5,1] 
    COS_SIN_TYPE x, y;
    int x_cnt=0, x_flag=0, y_cnt=0, y_flag=0;
    shift_2_range(x_in,x,x_cnt,x_flag);
    shift_2_range(y_in,y,y_cnt,y_flag);
    
    // Set the initial vector that we wil rorate
    COS_SIN_TYPE z = 0;
    
    // This loop iteratively rotates the initial vector to find the
    // sine and cosine vlaue corresponding to the input theta angle
    for(int j = 0;j < NUM_ITERATIONS; j++){
        // Determine if we are rotating by a positive or negative angle      
        int sigma = (y < 0) ? 1 : -1;
        
        if(y<0){
            y = (x >> j) + y;
            z = z - 1>>j;
        }else{
            y = -(x >> j) + y;
            z = z + 1>>j;
        }
    }
    
    // shift z and sign z to real value
    if((x_flag == -1 and y_flag == 1) or (x_flag == 1 and y_flag == -1)){
        z = -z;
    }
    int shift_cnt = y_cnt-x_cnt;
    if(shift_cnt <0) z = z << shift_cnt;
    else z = z >> -shift_cnt;
}

Chisel 代码实现

定点数定义

首先定义定点数,注意在chisel.experimental包里有定点数的类

import chisel3._
import chisel3.util._
import chisel3.experimental._
import scala.collection.immutable
import scala.math._

/* 定点数的位宽定义 */ 
trait HasDataConfig {
  val DataWidth = 24
  val BinaryPoint = 10
}

源码实现

首先定义全局上使用的一些常量

/* Cordic 的全局常数定义 */
trait Cordic_Const_Data extends HasDataConfig{
  /* 迭代次数配置 */
  val NUM_ITERATIONS = 20
}

实现codic_lv算法,直接按照公式迭代流水线寄存器计算即可

class CORDIC_LV_ORIGIN extends Module with HasDataConfig with Cordic_Const_Data{
  /*
   * @x : 输入的向量横轴坐标 定点数类型 取值范围[0.5,1]
   * @y : 输如的向量横轴坐标 定点数类型 取值范围[0.5,1]
   * @z : 输出的除法结果
   * details: 利用cordic圆坐标系的迭代得到除法的近似值,建议迭代次数不超过30,
               在25次时,K值的变化已经超过了float的范围
  **/
  val io = IO(new Bundle {
    val x: FixedPoint = Input(FixedPoint(DataWidth.W, BinaryPoint.BP))
    val y: FixedPoint = Input(FixedPoint(DataWidth.W, BinaryPoint.BP))
    val z: FixedPoint = Output(FixedPoint(DataWidth.W, BinaryPoint.BP))
    /* Debug */
    //    val sin_o: Vec[FixedPoint] = Output(Vec(NUM_ITERATIONS, FixedPoint(DataWidth.W, BinaryPoint.BP)))
    //    val cos_o: Vec[FixedPoint] = Output(Vec(NUM_ITERATIONS, FixedPoint(DataWidth.W, BinaryPoint.BP)))
    //    val z: Vec[FixedPoint] = Output(Vec(NUM_ITERATIONS, FixedPoint(DataWidth.W, BinaryPoint.BP)))
  })

  /* 初始化计算的寄存器数组,形成NUM_ITERATIONS级流水 */
  val current_x: Vec[FixedPoint] = RegInit(VecInit(Seq.fill(NUM_ITERATIONS)(FixedPoint.fromDouble(0.0, DataWidth.W, BinaryPoint.BP)))) // cos
  val current_y: Vec[FixedPoint] = RegInit(VecInit(Seq.fill(NUM_ITERATIONS)(FixedPoint.fromDouble(0.0, DataWidth.W, BinaryPoint.BP)))) // sin
  val current_z: Vec[FixedPoint] = RegInit(VecInit(Seq.fill(NUM_ITERATIONS)(FixedPoint.fromDouble(0.0, DataWidth.W, BinaryPoint.BP)))) // 目标角度

  for (i <- 0 until NUM_ITERATIONS) {
    /*
     * x[i] = K(x[i-1] - sigma * 2^(-i) * y[i-1])
     * y[i] = K(y[i-1] + sigma * 2^(-i) * x[i-1])
    **/
    if (i == 0) {
      /* 流水线第一级直接对(io.x,io.y)点做运算,注意输入限制了范围 */
      current_x(0) := io.x
      current_y(i) := io.y - (io.x >> i)
      current_z(i) := FixedPoint.fromDouble(1.0, DataWidth.W, BinaryPoint.BP)
    } else {
      current_x(i) := current_x(i - 1)
      when(current_y(i - 1) < FixedPoint.fromDouble(0.0, DataWidth.W, BinaryPoint.BP)) {
        current_y(i) := current_y(i - 1) + (current_x(i - 1) >> i)
        current_z(i) := current_z(i - 1) - (FixedPoint.fromDouble(1.0, DataWidth.W, BinaryPoint.BP) >> i)
      }.otherwise {
        current_y(i) := current_y(i - 1) - (current_x(i - 1) >> i)
        current_z(i) := current_z(i - 1) + (FixedPoint.fromDouble(1.0, DataWidth.W, BinaryPoint.BP) >> i)
      }
    }

    /* Debug */
    //    io.cos_o(i) := current_x(i)
    //    io.sin_o(i) := current_y(i)
    //    io.z(i) := current_theta(i)
  }

  io.z := current_z(NUM_ITERATIONS - 1)
}

实现实数除法

按照如上讲解的二进制移位方法

首先单独定义移位到 [ 0.5 , 1 ] [0.5,1] [0.5,1]范围的模块,返回移位后的值、移位数量(左移为正,右移为负)和是否变换了符号。模块通过移位直到进入范围确定结果,移位的最高移位数一定大于等于可以表示的二进制位数。

其中使用的chisel自带的硬件优先编码器得到移位的数值。

class shift_2_range extends Module with HasDataConfig{
  /*
   * @x : 输入的待移位数 定点数类型 取值范围R
   * @out : 输出移位后的结果 范围[0.5,1]
   * @cnt : 输出的移位数量,左移为正,右移为负
   * @flag : 输出x的正负符号分别为1,0
   * details: 将x通过左右移位至范围[0.5,1],并输出移位数目和是否改变符号
  **/
  val io = IO(new Bundle {
    val x: FixedPoint = Input(FixedPoint(DataWidth.W, BinaryPoint.BP))
    val out: FixedPoint = Output(FixedPoint(DataWidth.W, BinaryPoint.BP))
    val cnt: SInt = Output(SInt(log2Ceil(DataWidth).W))
    val flag: UInt = Output(UInt(1.W))
  })

  val temp_x: FixedPoint = Wire(FixedPoint(DataWidth.W, BinaryPoint.BP))
  when(io.x < FixedPoint.fromDouble(0.0, DataWidth.W, BinaryPoint.BP)){
    temp_x := -io.x
    io.flag := 0.U
  }.otherwise{
    temp_x := io.x
    io.flag := 1.U
  }

  when(temp_x < FixedPoint.fromDouble(0.5, DataWidth.W, BinaryPoint.BP)){
    /* 比0.5小,需要左移 */
    val index = VecInit(Seq.fill(BinaryPoint)(0.B))
    for(i <- 0 until BinaryPoint){
      when((temp_x << i) < FixedPoint.fromDouble(0.5, DataWidth.W, BinaryPoint.BP)){
        index(i) := 0.B
      }.otherwise{
        index(i) := 1.B
      }
    }
    /* 优先编码器获得首先大于等于0.5的位置,即cnt的值 */
    val temp_cnt = PriorityEncoder(index.asUInt())
    io.cnt := temp_cnt.asSInt()
    io.out := (temp_x << temp_cnt)
  }.elsewhen(temp_x > FixedPoint.fromDouble(0.5, DataWidth.W, BinaryPoint.BP)){
    /* 比1大,需要右移 */
    val index = VecInit(Seq.fill(DataWidth)(0.B))
    for(i <- 0 until DataWidth){
      when((temp_x >> i) > FixedPoint.fromDouble(1, DataWidth.W, BinaryPoint.BP)){
        index(i) := 0.B
      }.otherwise{
        index(i) := 1.B
      }
    }
    /* 优先编码器获得首先小于等于1的位置,即cnt的值 */
    val temp_cnt = PriorityEncoder(index.asUInt())
    io.cnt := -(temp_cnt.asSInt())
    io.out := (temp_x >> temp_cnt)
  }.otherwise{
    io.cnt := 0.S
    io.out := temp_x
  }
}

/*
 * 定义这个类的伴生对象,并定义一个工厂方法来简化模块的例化和连线。
**/
object shift_2_range {
  def apply(x: FixedPoint): (FixedPoint, SInt, UInt) = {
    /*
     * @x : 输入的待移位数 定点数类型 取值范围R
     * @out : 输出移位后的结果 范围[0.5,1]
     * @cnt : 输出的移位数量,左移为正,右移为负
     * @flag : 输出x的正负符号分别为1,0
     * details: 将x通过左右移位至范围[0.5,1],并输出移位数目和是否改变符号
    **/
    val unit = Module(new shift_2_range)
    unit.io.x := x
    (unit.io.out, unit.io.cnt, unit.io.flag)
  }
}

最后调用各个模块组合成实数除法

class cordic_divide extends Module with HasDataConfig {
  /*
   * @x : 输入的向量横轴坐标 定点数类型 取值范围R
   * @y : 输如的向量横轴坐标 定点数类型 取值范围R
   * @z : 输出的除法结果
   * details: 利用cordic圆坐标系的迭代得到除法的近似值,通过二进制移位将x、y
              范围转移到[0.5,1],再记录符号,最后调用cordic_lv并处理输出值,
              建议迭代次数不超过30,在25次时,K值的变化已经超过了float的范围
  **/
  val io = IO(new Bundle {
    val x: FixedPoint = Input(FixedPoint(DataWidth.W, BinaryPoint.BP))
    val y: FixedPoint = Input(FixedPoint(DataWidth.W, BinaryPoint.BP))
    val z: FixedPoint = Output(FixedPoint(DataWidth.W, BinaryPoint.BP))
  })

  /* 使用二进制移位规范x,y的值到[0.5,1]区间 */
  val (norm_x,x_cnt,x_flag) = shift_2_range(io.x)
  val (norm_y,y_cnt,y_flag) = shift_2_range(io.y)

  val cordic_lv: CORDIC_LV_ORIGIN = Module(new CORDIC_LV_ORIGIN)
  cordic_lv.io.x := norm_x
  cordic_lv.io.y := norm_y
  val unnormed_z: FixedPoint = Wire(FixedPoint(DataWidth.W, BinaryPoint.BP))

  /* 将z的值二进制移位回正确区间 */
  when(((x_flag === 1.U) && (y_flag === 0.U)) || ((x_flag === 0.U) && (y_flag === 1.U))){
    unnormed_z := -cordic_lv.io.z;
  }.otherwise{
    unnormed_z := cordic_lv.io.z;
  }
  val shift_cnt = y_cnt - x_cnt
  when(shift_cnt < 0.S){
    io.z := unnormed_z << (-shift_cnt).asUInt()
  }.otherwise{
    io.z := unnormed_z >> shift_cnt.asUInt()
  }
}

测试

对实现的三个类都进行测试,包括二进制移位。

import org.scalatest._
import chisel3._
import chiseltest._
import chisel3.experimental._

class Cordic_LV_Tester extends FlatSpec with HasDataConfig with ChiselScalatestTester with Matchers {
  behavior of "mytest2"
  it should "do something" in{
    val NUM_ITERATIONS = 30
    test(new CORDIC_LV_ORIGIN){ c =>
      c.io.x.poke(FixedPoint.fromDouble(0.75,32.W, 20.BP))
      c.io.y.poke(FixedPoint.fromDouble(0.79,32.W, 20.BP))
      for(i <- 0 until NUM_ITERATIONS*2){
        println(s"*****************${i}******************")
        println(s"theta ${c.io.z.peek}")
        //         for(j <- 0 until NUM_ITERATIONS){
        //            println(s"sin_o  (${j}) ${c.io.sin_o(j).peek}")
        //            println(s"cos_o  (${j}) ${c.io.cos_o(j).peek}")
        //            println(s"theta_o(${j}) ${c.io.theta_o(j).peek}")
        //         }

        c.clock.step(1)
      }
    }

    test(new shift_2_range){
      c =>
        c.io.x.poke(FixedPoint.fromDouble(-5,32.W, 20.BP))
        println(s"out ${c.io.out.peek}")
        println(s"cnt ${c.io.cnt.peek}")
        println(s"flag ${c.io.flag.peek}")
        c.io.x.poke(FixedPoint.fromDouble(-25,32.W, 20.BP))
        println(s"out ${c.io.out.peek}")
        println(s"cnt ${c.io.cnt.peek}")
        println(s"flag ${c.io.flag.peek}")
        c.io.x.poke(FixedPoint.fromDouble(-0.00631,32.W, 20.BP))
        println(s"out ${c.io.out.peek}")
        println(s"cnt ${c.io.cnt.peek}")
        println(s"flag ${c.io.flag.peek}")
        c.io.x.poke(FixedPoint.fromDouble(-0.00013545,32.W, 20.BP))
        println(s"out ${FixedPoint.fromDouble(-0.00013545,32.W, 20.BP)}")
        println(s"out ${c.io.out.peek}")
        println(s"cnt ${c.io.cnt.peek}")
        println(s"flag ${c.io.flag.peek}")
    }

    test(new cordic_divide){ c =>
      c.io.x.poke(FixedPoint.fromDouble(-0.00631,32.W, 20.BP))
      c.io.y.poke(FixedPoint.fromDouble(0.00013545,32.W, 20.BP))
      for(i <- 0 until NUM_ITERATIONS*2){
        println(s"*****************${i}******************")
        println(s"theta ${c.io.z.peek}")
        //         for(j <- 0 until NUM_ITERATIONS){
        //            println(s"sin_o  (${j}) ${c.io.sin_o(j).peek}")
        //            println(s"cos_o  (${j}) ${c.io.cos_o(j).peek}")
        //            println(s"theta_o(${j}) ${c.io.theta_o(j).peek}")
        //         }

        c.clock.step(1)
      }
    }
  }
}

封装伴生对象

/*
 * 定义这个类的伴生对象,并定义一个工厂方法来简化模块的例化和连线。
**/
object cordic_divide{
  def apply(x: FixedPoint, y:FixedPoint): FixedPoint = {
    /*
     * @x : 输入的向量横轴坐标 定点数类型 取值范围R
     * @y : 输如的向量横轴坐标 定点数类型 取值范围R
     * @z : 输出的除法结果
     * details: 利用cordic圆坐标系的迭代得到除法的近似值,通过二进制移位将x、y
                范围转移到[0.5,1],再记录符号,最后调用cordic_lv并处理输出值,
                建议迭代次数不超过30,在25次时,K值的变化已经超过了float的范围
    **/
    val divide = Module(new cordic_divide)
    divide.io.x := x
    divide.io.y := y
    divide.io.z
  }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

AlwaysDayOne

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

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

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

打赏作者

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

抵扣说明:

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

余额充值