硬件SPI+DMA驱动ST7789芯片(超详细讲解)(合宙AIR001)

        前段时间用软件SPI的方式驱动ST7789芯片的显示屏,主控的主频本来就不高,加上软件SPI刷全屏就特别呆,大概2s才能刷一次全屏,帧率只有0.5帧左右,于是就打算改用硬件SPI+DMA的方式,在配置和移植过程中就遇到不少问题,想把过程分享给大家,希望对大家有所帮助。

        首先是先简单讲一下SPI驱动ST7789的通信原理,一共要接单片机控制的有4根线,SCL、SDA、RES、DC。(注:显示屏模块应该还有CS片选跟BL背光,看原理图接vcc或者悬空即可,不同模块的外围电路可以不一样)SCL是提供时钟信号,SDA是对应时钟信号传递数据的数据线,RES是复位引脚,DC是告诉ST7789芯片主机发送的是数据还是命令。下面是我用逻辑分析仪抓取出来的SPI通信时候的高低电平情况。

        第一个是SCL,第二个是SDA,第三个是RES,第四个是DC。RES最简单,默认是高电平,如果拉低了就是复位信号,屏幕就会复位。SCL和SDA要配合起来看。SPI通信一共有4种模式,采集时间有两种:第一个脉冲采集数据、第二个脉冲采集数据。采集类型也有两种:上升沿采集、下降沿采集。这两种方式直接随机组合就是对应的四种模式

        下面演示的是我用的一种模式,第一个脉冲采集上升沿采集,即第一个上升沿开始采集数据

        因为我的是第一个上升沿开始采集数据,所以从第一个SCL的上升沿开始,对应下来看SDA的高低电平情况,如果是高电平即为1,反之低电平即为0。我这里配置的SPI一次传输8位,即是一个字节,图片这里表示的就是传输了00010001,即是0x11,再看第四个线DC的高低电平情况,传输0x11时DC是低电平所以传输的是命令,所以这里就是传输一个一个内容为0x11的命令。(如果DC是高电平就是在传输数据,所以最好默认将DC拉高,防止有时候不小心发送命令导致屏幕显示错误而找不到问题所在,顺便在这里提醒一下大家在编写各种函数的时候最好就是顺便解决一下这些微小的细节,这样写程序遇到BUG的概率就会大大减少)

        通过芯片手册我们可以看到0x11这个命令是Sleep out 意思就是退出睡眠模式,这个也是驱动屏幕的第一个命令,这也说明我配置的硬件SPI没有问题,传输数据是正常的。

        下面开始讲硬件SPI的配置,不同芯片的配置都不一样,我这里用的是合宙的AIR001芯片,你们可以直接去找对应芯片的SPI+DMA的历程即可,可以忽略下面几个步骤

/*反初始化SPI配置*/
  Spi1Handle.Instance               = SPI1;                       /* SPI1 */
  Spi1Handle.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_2;    /* 2分频 */
  Spi1Handle.Init.Direction         = SPI_DIRECTION_2LINES;       /* 全双工 */
  Spi1Handle.Init.CLKPolarity       = SPI_POLARITY_HIGH;           /* 时钟极性低 */
  Spi1Handle.Init.CLKPhase          = SPI_PHASE_1EDGE ;           /* 数据采样从第一个时钟边沿开始 */
  Spi1Handle.Init.DataSize          = SPI_DATASIZE_8BIT;          /* SPI数据长度为8bit */
  Spi1Handle.Init.FirstBit          = SPI_FIRSTBIT_MSB;           /* 先发送MSB */
  Spi1Handle.Init.NSS               = SPI_NSS_HARD_OUTPUT;        /* NSS软件模式(硬件模式) */
  Spi1Handle.Init.Mode = SPI_MODE_MASTER;                         /* 配置为主机 */
  if (HAL_SPI_DeInit(&Spi1Handle) != HAL_OK)
  {
    Error_Handler();
  }
  
  /*SPI初始化*/
  if (HAL_SPI_Init(&Spi1Handle) != HAL_OK)
  {
    Error_Handler();
  }

        配置SPI的时候要注意几个地方,一个是数据采样从第一个时钟边沿开始,还是从第二个时钟边沿开始。具体的可以看芯片的库函数手册,下面是合宙AIR001的库函数手册介绍。

        还有一个要注意的地方就是空闲时SCK是保持高电平还是低电平。之前就是因为这个问题屏幕一直点不亮,后面就找的问题所在。SCL保持高电平还是低电平主要取决于要通信的设备的设计,常见的SPI设备有两种时钟架构,架空式和推挽式。

        架空式设备中:SCL信号的默认电平是高电平,在通信的过程中,时钟线的低电平表示时钟信号的第一个周期的开始,高电平表示一个周期的结束

        推挽式设备中:SCL信号默认为低电平,通信过程中,时钟线的高电平表示时钟信号的第一个周期的开始,低电平表示一个周期的结束

        下面是两种不同情况的对比。

        这两种方式发送的内容都是0x11,但是对于两种不同时钟架构的设备的方式是不一样的。所以在软件模拟SPI的时候要注意SCL初始化后是需要拉高还是拉低,否则即使移植了别人正确的代码也无法显示。

        我在这里顺便看了一下硬件SPI的速率大概能到4Mhz(对于我的配置来说),确实比软件快了很多,软件的速率大概是us级别,而硬件是ns级别。

        配置完SPI还需要配置引脚初始化,引脚映射,使能引脚复用时钟,使能引脚时钟,DMA配置等等,不同芯片配置函数不一样,这里不做过多的介绍。下面是合宙air001的配置。

void HAL_SPI_MspInit(SPI_HandleTypeDef *hspi)
{
  GPIO_InitTypeDef  GPIO_InitStruct;
  /* SPI1 初始化 */
  if (hspi->Instance == SPI1)
  {
    __HAL_RCC_GPIOB_CLK_ENABLE();                   /* GPIOB时钟使能 */
    __HAL_RCC_GPIOA_CLK_ENABLE();                   /* GPIOA时钟使能 */
    __HAL_RCC_SYSCFG_CLK_ENABLE();                  /* 使能SYSCFG时钟 */
    __HAL_RCC_SPI1_CLK_ENABLE();                    /* SPI1时钟使能 */
    __HAL_RCC_DMA_CLK_ENABLE();                     /* DMA时钟使能 */
    HAL_SYSCFG_DMA_Req(1);                          /* SPI1_TX DMA_CH1 */
    HAL_SYSCFG_DMA_Req(0x200);                      /* SPI1_RX DMA_CH2 */
    /*
      PA5-SCK  (AF0)
      PA6-MISO(AF0)
      PA7-MOSI(AF0)
      PA4-NSS(AF0)
    */
    /* SPI CS*/
    GPIO_InitStruct.Pin = GPIO_PIN_4;
    GPIO_InitStruct.Mode = GPIO_MODE_AF_OD;
    if (hspi->Init.CLKPolarity == SPI_POLARITY_LOW)
    {
      GPIO_InitStruct.Pull = GPIO_PULLDOWN;
    }
    else
    {
      GPIO_InitStruct.Pull = GPIO_PULLUP;
    }
    GPIO_InitStruct.Alternate = GPIO_AF0_SPI1;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_VERY_HIGH;
    HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
    HAL_GPIO_WritePin(GPIOA, GPIO_PIN_15, GPIO_PIN_SET);
    /*GPIO配置为SPI:SCK/MISO/MOSI*/
    GPIO_InitStruct.Pin       = GPIO_PIN_5 | GPIO_PIN_7;
    GPIO_InitStruct.Mode      = GPIO_MODE_AF_PP;
    GPIO_InitStruct.Speed     = GPIO_SPEED_FREQ_HIGH;
    GPIO_InitStruct.Alternate = GPIO_AF0_SPI1;
    HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
    /*中断配置*/
    HAL_NVIC_SetPriority(SPI1_IRQn, 1, 0);
    HAL_NVIC_EnableIRQ(SPI1_IRQn);
    /*DMA_CH1配置*/
    HdmaCh1.Instance                 = DMA1_Channel1;
    HdmaCh1.Init.Direction           = DMA_MEMORY_TO_PERIPH;
    HdmaCh1.Init.PeriphInc           = DMA_PINC_DISABLE;
    HdmaCh1.Init.MemInc              = DMA_MINC_ENABLE;
    if (hspi->Init.DataSize <= SPI_DATASIZE_8BIT)
    {
      HdmaCh1.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
      HdmaCh1.Init.MemDataAlignment    = DMA_MDATAALIGN_BYTE;
    }
    else
    {
      HdmaCh1.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD;
      HdmaCh1.Init.MemDataAlignment    = DMA_MDATAALIGN_HALFWORD;
    }
    HdmaCh1.Init.Mode                = DMA_NORMAL;
    HdmaCh1.Init.Priority            = DMA_PRIORITY_VERY_HIGH;
    /*DMA初始化*/
    HAL_DMA_Init(&HdmaCh1);
    /*DMA句柄关联到SPI句柄*/
    __HAL_LINKDMA(hspi, hdmatx, HdmaCh1);

    /*DMA_CH2配置*/
    HdmaCh2.Instance                 = DMA1_Channel2;
    HdmaCh2.Init.Direction           = DMA_PERIPH_TO_MEMORY;
    HdmaCh2.Init.PeriphInc           = DMA_PINC_DISABLE;
    HdmaCh2.Init.MemInc              = DMA_MINC_ENABLE;
    if (hspi->Init.DataSize <= SPI_DATASIZE_8BIT)
    {
      HdmaCh2.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
      HdmaCh2.Init.MemDataAlignment    = DMA_MDATAALIGN_BYTE;
    }
    else
    {
      HdmaCh2.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD;
      HdmaCh2.Init.MemDataAlignment    = DMA_MDATAALIGN_HALFWORD;
    }
    HdmaCh2.Init.Mode                = DMA_NORMAL;
    HdmaCh2.Init.Priority            = DMA_PRIORITY_LOW;
    /*DMA初始化*/
    HAL_DMA_Init(&HdmaCh2);
    /*DMA句柄关联到SPI句柄*/
    __HAL_LINKDMA(hspi, hdmarx, HdmaCh2);
    /*DMA中断设置*/
    HAL_NVIC_SetPriority(DMA1_Channel1_IRQn,1,0);
    HAL_NVIC_EnableIRQ(DMA1_Channel1_IRQn);
    HAL_NVIC_SetPriority(DMA1_Channel2_3_IRQn,1,0);
    HAL_NVIC_EnableIRQ(DMA1_Channel2_3_IRQn);
  }

}

void HAL_ADC_MspInit(ADC_HandleTypeDef *hadc)
{
  GPIO_InitTypeDef          GPIO_InitStruct;
  __HAL_RCC_SYSCFG_CLK_ENABLE();                              /* SYSCFG时钟使能 */
  __HAL_RCC_DMA_CLK_ENABLE();                                 /* DMA时钟使能 */
  __HAL_RCC_GPIOA_CLK_ENABLE();                               /* 使能GPIOA时钟 */
 
  /* ---------------- */
  /* ADC通道配置PA0   */
  /* ---------------- */
  GPIO_InitStruct.Pin = GPIO_PIN_0;
  GPIO_InitStruct.Mode = GPIO_MODE_ANALOG;
  GPIO_InitStruct.Pull = GPIO_NOPULL;
  HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

  HAL_SYSCFG_DMA_Req(0);                                      /* DMA1_MAP选择为ADC */
  /* ------------ */
  /* DMA配置      */
  /* ------------ */
  HdmaCh1.Instance                 = DMA1_Channel1;           /* 选择DMA通道1 */
  HdmaCh1.Init.Direction           = DMA_PERIPH_TO_MEMORY;    /* 方向为从外设到存储器 */
  HdmaCh1.Init.PeriphInc           = DMA_PINC_DISABLE;        /* 禁止外设地址增量 */
  HdmaCh1.Init.MemInc              = DMA_MINC_DISABLE;        /* 禁止存储器地址增量 */
  HdmaCh1.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD; /* 外设数据宽度为16位 */
  HdmaCh1.Init.MemDataAlignment    = DMA_MDATAALIGN_HALFWORD; /* 存储器数据宽度位16位 */
  HdmaCh1.Init.Mode                = DMA_CIRCULAR;            /* 循环模式 */
  HdmaCh1.Init.Priority            = DMA_PRIORITY_VERY_HIGH;  /* 通道优先级为很高 */

  HAL_DMA_DeInit(&HdmaCh1);                                   /* DMA反初始化 */
  HAL_DMA_Init(&HdmaCh1);                                     /* 初始化DMA通道1 */
  __HAL_LINKDMA(hadc, DMA_Handle, HdmaCh1);                   /* 连接DMA句柄 */
}

        下面是最重要的发送函数封装,避免大家看不懂,我先介绍一下这款芯片的spi+dma发送函数。第一个参数是spi的句柄,第二个参数是要发送的内容的地址,第三个参数是要发送的字节数。下面代码中这个函数大家自行替换成自己使用的HAL库的函数即可,具体的可以去spi的头文件中找,用法都是大差不差的。

HAL_SPI_Transmit_DMA(&Spi1Handle, dat, size)

        先封装第一个发送一个字节的函数,Error_Handler();是死循环,就是等待dma发送成功,实测可以不用,直接用dma发送函数也可以。

void LCD_Writ_Bus(uint8_t *dat,uint16_t size) 
{	
	 if( HAL_SPI_Transmit_DMA(&Spi1Handle, dat, size) != HAL_OK)
  {
    Error_Handler();
  }
	// HAL_SPI_Transmit_DMA(&Spi1Handle,dat,size);
}

        然后再封装一个发送8位数据的函数(区别于发送命令)。这里其实就是控制DC引脚为高电平然后调用上面封装好的发送函数,封装的意义是让代码的逻辑性和实用性增加,维护和移植的时候只需要修改最初定义时的内容,这样会特别方便,但是也会影响可读性,第一次阅读代码时可以会比较痛苦,我自己也是从憎恨封装到理解封装再到现在的使用封装,维护起来真的真的真的特别方便,跟宏定义是一样的方便。(LCD_DC_Set();这个是我的一个宏定义,你们可以自己根据自己的引脚定义,就是给DC引脚高电平)

void LCD_WR_DATA8(u8 dat)
{
	LCD_DC_Set();//写数据
	LCD_Writ_Bus(&dat,1);
}

        然后再封装一个发命令的函数,区别是将DC引脚拉低了。(LCD_DC_Clr();这个是我的一个宏定义,你们可以自己根据自己的引脚定义,就是给DC引脚低电平)

void LCD_WR_REG(u8 dat)
{
	LCD_DC_Clr();//写命令
	LCD_Writ_Bus(&dat,1);
}

        然后封装一个16位的数据发送函数,原理就是先发高八位再发低八位。

void LCD_WR_DATA(u16 dat)
{
	LCD_WR_DATA8(dat>>8);
	LCD_WR_DATA8(dat);
}

        有了上面的基础底层发送函数,我们就可以写一些复杂的功能函数了

        下面是设置屏幕的起置始和结束位,就是告诉屏幕的芯片,你接下来要修改哪个位置的像素点,然后依次发送每一个像素点颜色的数据,屏幕就会依次点亮像素点为你设置的颜色。

        如果你的屏幕显示方向需要改变,那也是改变这个函数。

void LCD_Address_Set(u16 x1,u16 y1,u16 x2,u16 y2)
{
	
		LCD_WR_REG(0x2a);//列地址设置
		LCD_WR_DATA(x1);
		LCD_WR_DATA(x2);
		LCD_WR_REG(0x2b);//行地址设置
		LCD_WR_DATA(y1);
		LCD_WR_DATA(y2);
		LCD_WR_REG(0x2c);//储存器写
	
}

       下面是颜色的数据,可以看到颜色是16位的,所以一个像素点需要发送2次8位的数据才可以点亮。

#define WHITE         	 0xFFFF
#define BLACK         	 0x0000	  
#define BLUE           	 0x001F  
#define BRED             0XF81F
#define GRED 			       0XFFE0
#define GBLUE			       0X07FF
#define RED           	 0xF800
#define MAGENTA       	 0xF81F
#define GREEN         	 0x07E0
#define CYAN          	 0x7FFF
#define YELLOW        	 0xFFE0
#define BROWN 			     0XBC40 //棕色
#define BRRED 			     0XFC07 //棕红色
#define GRAY  			     0X8430 //灰色
#define DARKBLUE      	 0X01CF	//深蓝色
#define LIGHTBLUE      	 0X7D7C	//浅蓝色  
#define GRAYBLUE       	 0X5458 //灰蓝色
#define LIGHTGREEN     	 0X841F //浅绿色
#define LGRAY 			     0XC618 //浅灰色(PANNEL),窗体背景色
#define LGRAYBLUE        0XA651 //浅灰蓝色(中间层颜色)
#define LBBLUE           0X2B12 //浅棕蓝色(选择条目的反色)

        下面是全屏清除函数,将屏幕显示为一个颜色

void LCD_Clear(uint16_t color)
{
    uint16_t i, j;
		u16 k;
    uint8_t data[2] = {0};  //color是16bit的,每个像素点需要两个字节的显存

    /* 将16bit的color值分开为两个单独的字节 */
    data[0] = color >> 8;
    data[1] = color;

    /* 显存的值需要逐字节写入 */
    for(j = 0;j < LCD_Buf_Size/2; j++)
    {
        lcd_buf[j * 2] =  data[0];
        lcd_buf[j * 2 + 1] =  data[1];
    }
    /* 指定显存操作地址为全屏幕 */
    LCD_Address_Set(0,0,240-1,240-1);
    /* 指定接下来的数据为数据 */
    LCD_DC_Set();
    /* 将显存缓冲区的数据全部写入缓冲区 */
    for(i = 0;i <(LCD_TOTAL_BUF_SIZE/LCD_Buf_Size); i++)
    {
				//HAL_Delay(1);
			for(k=0;k<10;k++){}
        LCD_Writ_Bus(lcd_buf, (uint16_t)LCD_Buf_Size);
    }
}

        原理就是将颜色的16位存进两个8位的地址data[0]和data[1],然后再挨个的放入lcd_buf数组里面,lcd_buf作为一个缓存区,我定义的大小是1152,因为我的屏幕是240*240,所以全屏一共有240*240=5760个像素,每个像素要发送一个16位的颜色值即2个字节,所以一共有240*240*2=115200个字节,所以我定义一个大小为1152的数组作为缓存区,我需要发送100次就可以点亮整个屏幕。

        /* 指定显存操作地址为全屏幕 */
    LCD_Address_Set(0,0,240-1,240-1);

        这里是根据我的屏幕来设置的,你们要根据自己的屏幕大小来修改。

        屏幕点亮的原理就是告诉芯片要显示的像素点的开始位置和结束位置,然后再依次发送每个像素点的颜色,便可以点亮每个像素点。

        还需要一个屏幕初始化函数,初始化GPIO的函数需要你们自己根据芯片的HAL库编写,这里就不展示了。

void LCD_Init(void)
{
	LCD_GPIO_Init();//初始化GPIO
	
	LCD_RES_Clr();//复位
	HAL_Delay(100);
	LCD_RES_Set();
	HAL_Delay(100);
	
	
	//************* Start Initial Sequence **********//
	LCD_WR_REG(0x11); //Sleep out 
	HAL_Delay(120);              //Delay 120ms 
	//************* Start Initial Sequence **********// 
	LCD_WR_REG(0x36);
	if(USE_HORIZONTAL==0)LCD_WR_DATA8(0x00);
	else if(USE_HORIZONTAL==1)LCD_WR_DATA8(0xC0);
	else if(USE_HORIZONTAL==2)LCD_WR_DATA8(0x70);
	else LCD_WR_DATA8(0xA0);

	LCD_WR_REG(0x3A);     
	LCD_WR_DATA8(0x05);   

	LCD_WR_REG(0xB2);     
	LCD_WR_DATA8(0x0c);   
	LCD_WR_DATA8(0x0c);   
	LCD_WR_DATA8(0x00);   
	LCD_WR_DATA8(0x33);   
	LCD_WR_DATA8(0x33);   

	LCD_WR_REG(0xB7);     
	LCD_WR_DATA8(0x72);   

	LCD_WR_REG(0xBB);     
	LCD_WR_DATA8(0x3d);   //2b

	LCD_WR_REG(0xC0);     
	LCD_WR_DATA8(0x2C);   

	LCD_WR_REG(0xC2);     
	LCD_WR_DATA8(0x01);   

	LCD_WR_REG(0xC3);     
	LCD_WR_DATA8(0x19);   

	LCD_WR_REG(0xC4);     
	LCD_WR_DATA8(0x20);   //VDV, 0x20:0v

	LCD_WR_REG(0xC6);     
	LCD_WR_DATA8(0x0f);   //0x13:60Hz   

	LCD_WR_REG(0xD0);     
	LCD_WR_DATA8(0xA4);   
	LCD_WR_DATA8(0xA1);   



//	LCD_WR_REG(0xD6);     
//	LCD_WR_DATA8(0xA1);   //sleep in后,gate输出为GND

	 LCD_WR_REG(0xE0);
    LCD_WR_DATA8(0xD0);
    LCD_WR_DATA8(0x04);
    LCD_WR_DATA8(0x0D);
    LCD_WR_DATA8(0x11);
    LCD_WR_DATA8(0x13);
    LCD_WR_DATA8(0x2B);
    LCD_WR_DATA8(0x3F);
    LCD_WR_DATA8(0x54);
    LCD_WR_DATA8(0x4C);
    LCD_WR_DATA8(0x18);
    LCD_WR_DATA8(0x0D);
    LCD_WR_DATA8(0x0B);
    LCD_WR_DATA8(0x1F);
    LCD_WR_DATA8(0x23);
    /* 电压设置 */
    LCD_WR_REG(0xE1);
    LCD_WR_DATA8(0xD0);
    LCD_WR_DATA8(0x04);
    LCD_WR_DATA8(0x0C);
    LCD_WR_DATA8(0x11);
    LCD_WR_DATA8(0x13);
    LCD_WR_DATA8(0x2C);
    LCD_WR_DATA8(0x3F);
    LCD_WR_DATA8(0x44);
    LCD_WR_DATA8(0x51);
    LCD_WR_DATA8(0x2F);
    LCD_WR_DATA8(0x1F);
    LCD_WR_DATA8(0x1F);
    LCD_WR_DATA8(0x20);
    LCD_WR_DATA8(0x23);
    /* 显示开 */
    LCD_WR_REG(0x21);
    LCD_WR_REG(0x29);
} 

        查看芯片手册可以看到0xC6这个命令是设置屏幕的刷新率,有需要可以修改,我设置的是60HZ

        LCD_WR_REG(0xC6); 

        LCD_WR_DATA8(0x0f);

        

        目前我只移植了刷屏函数,后面的显示字符、显示文字、显示数字、显示小数等等还没有移植使用,大家可以尝试自行编写,上面的原理介绍已经说的挺清楚的了,也是我自己从零开始慢慢摸索上来的,上面轻飘飘的一句话都有可能是我琢磨了好久,翻看了很多资料才总结得出的。希望对大家有所帮助,有错误之处欢迎大家批评指正。

         我最近建了一个嵌入式的QQ交流群,感兴趣的可以进群了解一下,我会在群里分享一些常用的代码封装,以及一些项目的源码。QQ群讨论也是完全开放,只要不打广告大家可以就嵌入式尽情的沟通和交流,大家对文章中的内容有疑问也可以在群中提出,有空会尽我所能给大家一些帮助。QQ群号:643408467

  • 19
    点赞
  • 56
    收藏
    觉得还不错? 一键收藏
  • 8
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值