【STM32F103】I2C通信协议&SHT20温湿度传感器

I2C简介

I2C是Inter IC BUS=IIC=I²C=I2C,一般我们读作“挨方C”。

简述一下I2C,是只需要两根通信线就能实现多主多从半双工的串行通信协议。

传输速度会偏慢一点点,一般是100Kbps,是属于标准模式。另外还有快速模式,400Kbps;高速模式3.4Mbps;超快速模式5Mbps(后两种没接触过)。

两根通信线

分别是SCL和SDA。

SCL是Serial Clock,也就是统一时间的。

SDA是Serial Data,也就是传输数据的。

多主多从

任何支持I2C通信协议的设备都可以挂载在同一个总线上,像下图一样。需要补充一点下图没有表现出来的东西,总线上的SCL和SDA都是各有一个上拉电阻的,也就是说SCL和SDA默认都是高电平的,所以可以说是我们只能拉低SCL和SDA使它们变为低电平,而我们释放SCL和SDA的时候,它们就是高电平。

任何设备都可以是主机,而其他设备都是从机。

理论上主机数可以是无限多个,只要是从不作为从机设备。

而从机理论上是只能有128个(2的七次方)。因为从机地址是7位+1位读写位。而一般设备都有出厂就固定的7位从机地址,这个需要我们去手册里面找,以我们这次会涉及到的STH20为例,STH20的7位从机地址是1000000。如果是要对STH20进行写操作的话,那么从机地址的第八位给0,也就是说从机地址为10000000(0x80),而进行读操作的话,那么从机地址的第八位给1,从机地址就是10000001(0x81)。

半双工

这个很好理解,因为所有设备都在同一个总线上,所以同一个时刻,只能有一个设备发送数据。而所有设备都可以发送数据,这就是半双工了。

I2C时序

基本上I2C的通信过程是这样的。

拿起电话(起始时序)

主机:老弟你在不,我要发数据了(发送从机地址)

从机:诶老哥我在(发送应答)

主机:嗯嗯(接收应答)

主机:阿巴阿巴(发送数据)

从机:诶好嘞收到了(发送应答)

主机:嗯嗯(接收应答)

主机:阿巴阿巴(发送数据)

从机:诶好嘞收到了(发送应答)

主机:嗯嗯(接收应答)

放下电话(终止时序)

那么目前我们就大致可以知道I2C这个通信协议该有哪些时序了。

起始时序

在I2C通信开始的时候,我们需要先发送一个时序表示开始,也就是开始时序。

如上图,开始时序是在SCL高电平的时候,SDA从高电平切换到低电平。

也就是我们都释放SCL和SDA的时候,我们拉低SDA,这时候就算是发出了一个开始时序,并且我们最后还需要把SCL拉低,这样表示我们占用了I2C总线。

结束时序

当我们要结束I2C通信的时候,我们就发送结束时序。

如上图,结束时序就是在SCL高电平的时候,SDA从低电平切换到高电平。

也就是在我们释放SCL后,拉低SDA再释放,这时候我们就是结束了一段I2C通信。

发送/接收数据

发送数据的时候就如同上图,在SCL低电平的时候,主机将数据放置到SDA(1为高电平,0为低电平) 主机拉高SCL的时候,在SCL高电平时,从机读取SDA的数据。

也就是我们先拉低SCL,然后再通过要发送的bit来修改SDA的电平状态,修改完之后我们再释放SCL。

反过来,当我们要读取数据的时候,就先释放SCL,然后再读取数据,接着再拉低SCL。

每次发送数据的时候,我们一般是发送一个字节,也就是八位的数据,而我们数据的高位是在前面的,也就是大端格式。

发送应答

在接收完一个字节后,我们需要发送一个bit表示应答。 1(高电平)为不应答,0(低电平)为应答。如果要接着接收数据的话我们就发送0为应答,不接收的话就发送1为应答(或者直接发出结束时序也可以)。

接收应答

在发送完一个字节后,我们会接收一个bit,这是来自从机的应答。在接收前需要将SDA置高电平。我们可以通过接收应答位来判断我们给从机发送的数据是否有误。

例如我们一开始需要在总线上发送从机地址,如果这时候有总线上有对应从机地址的设备在,那么从机就会发送一个应答位,我们也就可以通过接收这个应答位来判断是否有对应从机地址的设备存在。如果没有对应从机的话,由于SCL和SDA各自有一个上拉电阻,默认就是高电平,所以我们按照上面接收应答的时序来接收的话收到的会是1,也就是不应答。

STM32的I2C

硬件I2C

因为I2C使用的范围比较广,因此STM32自带I2C的硬件,使用STM32自带的I2C硬件能够发出非常标准的I2C数据,但是使用起来比较繁琐,并且也只有两个I2C硬件,因此我一般情况下会使用软件去模拟I2C通信,这样理论上所有GPIO口我都可以用来进行I2C通信。

硬件I2C的使用在官方手册里有介绍,以主机视角发送数据为例,下图是官方手册中截出的。

可以看出基本上每发出一个时序,我们都需要去处理所谓的事件,与软件模拟I2C相比繁琐的多,但是发出的I2C信号会标准的多。

软件I2C

事实上我们知道了I2C的时序之后,我们只需要把每个时序给模拟出来就可以了,I2C也没有那么神秘,说到底也就是人们规定的一种协议而已。

我们要模拟I2C,那么实际上我们需要操纵两根线SCL和SDA的高低电平即可。

所以我们首先要做的是初始化两个GPIO口,让它们来作为SCL和SDA。

接下来的代码我会分时序来编写函数,最后会汇总成一份代码。

函数的名称以Z_I2C开头,因为STM32固件库中有硬件I2C的函数,因此我们最好是不要以I2C开头为函数名,这里是折途,因此我加了个Z_,所以我的函数名称就以Z_I2C开头了,大家按照自己的喜好去命名即可。

初始化SCL和SDA

为了使我们的软件I2C的代码更容易修改和移植,所以我们对于SCL和SDA的选择,可以使用宏定义来代替。

#define SCL_Pin GPIO_Pin_0
#define SDA_Pin GPIO_Pin_1

这样子,就算我们移植到其他项目上,我们要修改SCL和SDA所在的GPIO口,也只需要修改宏定义即可。

初始化SCL和SDA也只需要当初初始化为普通的GPIO口而已,GPIO口的模式我们需要配置为开漏输出。

因为官方的参考手册里,I2C是建议配置为复用开漏输出,但是我们没有用到硬件,所以我们不需要复用,直接配置为开漏输出即可。

void Z_I2C_Init(void){
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA,ENABLE);
    
    GPIO_InitTypeDef itd;
    itd.GPIO_Mode=GPIO_Mode_Out_OD;        
    itd.GPIO_Pin=SCL_Pin|SDA_Pin;    
    itd.GPIO_Speed=GPIO_Speed_50MHz;                   
    GPIO_Init(GPIOA,&itd);

    GPIO_WriteBit(GPIOA,SCL_Pin,Bit_SET);       //SCL和SDA默认都是高电平
    GPIO_WriteBit(GPIOA,SDA_Pin,Bit_SET);       //因此初始化后设为高电平
}

为了更直观地去操纵SCL和SDA,我们把拉低拉高这两根线的操作也封装一下。

void Z_I2C_SetSCL(uint8_t signal){
    if(signal==1) GPIO_WriteBit(GPIOA,SCL_Pin,Bit_SET);
    else GPIO_WriteBit(GPIOA,SCL_Pin,Bit_RESET);
    Delay_us(5);                    //防止电平翻转过快,因此加上延时
}

void Z_I2C_SetSDA(uint8_t signal){
    if(signal==1) GPIO_WriteBit(GPIOA,SDA_Pin,Bit_SET);
    else GPIO_WriteBit(GPIOA,SDA_Pin,Bit_RESET);
    Delay_us(5);
}

因为I2C的速率一般来说会低一些,因此我们进行拉低拉高的操作之后进行小小的延时一下,因为I2C通信是依靠同一根时钟线(SCL)的,所以就算是支持很高速率的I2C设备,我们也是可以使用这同一套低速率的代码去进行通信的。

 还有就是我们接收数据的时候是需要获取SDA的高低电平状态的,因此我们还需要封装一个获取SDA电平状态的函数。

uint8_t Z_I2C_GetSDA(void){
    return GPIO_ReadInputDataBit(GPIOA,SDA_Pin);
}

开始时序

开始时序是在SCL高电平的时候,SDA从高电平切换到低电平,最后还需要把SCL切换为低电平表示占用I2C总线,因此开始时序我们这样写。

void Z_I2C_Start(void){
    Z_I2C_SetSDA(1);
    Z_I2C_SetSCL(1);
    Z_I2C_SetSDA(0);
    Z_I2C_SetSCL(0);
}

为了保证SDA是在SCL高电平的时候被拉低,所以我们需要先把SDA拉高,再拉高SCL,接着拉低SDA,最后拉低SCL。

结束时序

结束时序就是在SCL高电平的时候,SDA从低电平切换到高电平。

void Z_I2C_End(){
    Z_I2C_SetSDA(0);
    Z_I2C_SetSCL(1);
    Z_I2C_SetSDA(1);
}

发送时序

发送数据的时候我们先拉低SCL,然后再通过要发送的bit来修改SDA的电平状态,修改完之后我们再释放SCL。发送一个字节就重复上述八次。

void Z_I2C_SendByte(uint8_t byte){
    Z_I2C_SetSCL(0);
    for(int i=0;i<8;++i){
        if((byte&0x80)==0) Z_I2C_SetSDA(0);
        else Z_I2C_SetSDA(1);
        byte<<=1;
        Z_I2C_SetSCL(1);
        Z_I2C_SetSCL(0);
    }
}

上述代码在发送完字节之后,SCL仍处于拉低状态表示占用I2C总线。

因为我们发送字节是高位在前,因此将要发送的字节&0x80,这样就可以取出最高位,接着将发送的字节左移1位,下一次取出的就是一开始的次高位了,这样重复八次就可以将字节的每一位取出发送出去了。

接收时序

上面发送时序反过来,当我们要读取数据的时候,就先释放SCL,然后再读取数据,接着再拉低SCL。

uint8_t Z_I2C_ReveiceByte(){
    uint8_t data=0x00;
    Z_I2C_SetSDA(1);
    for(int i=0;i<8;++i){
        Z_I2C_SetSCL(1);
        if(Z_I2C_GetSDA()==1) data|=(0x80>>i);
        Z_I2C_SetSCL(0);
    }
    return data;
}

发送应答

发送应答实际上就是上面发送字节的八分之一而已,就是发送一个bit。

void Z_I2C_SendACK(uint8_t ack){
    if(ack==0) Z_I2C_SetSDA(0);
    else Z_I2C_SetSDA(1);
    Z_I2C_SetSCL(1);
    Z_I2C_SetSCL(0);
}

接送应答

接收应答也是一样的,也是接收数据的八分之一,接收一个bit。

uint8_t Z_I2C_ReveiceACK(){
    Z_I2C_SetSDA(1);
    Z_I2C_SetSCL(1);
    uint8_t ack=Z_I2C_GetSDA();
    Z_I2C_SetSCL(0);
    return ack;
}

完整软件模拟I2C代码

#define SCL_Pin GPIO_Pin_0
#define SDA_Pin GPIO_Pin_1

void Z_I2C_Init(void){
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA,ENABLE);
    
    GPIO_InitTypeDef itd;
    itd.GPIO_Mode=GPIO_Mode_Out_OD;        
    itd.GPIO_Pin=SCL_Pin|SDA_Pin;    
    itd.GPIO_Speed=GPIO_Speed_50MHz;                   
    GPIO_Init(GPIOA,&itd);

    GPIO_WriteBit(GPIOA,SCL_Pin,Bit_SET);       //SCL和SDA默认都是高电平
    GPIO_WriteBit(GPIOA,SDA_Pin,Bit_SET);       //因此初始化后设为高电平
}
    
void Z_I2C_SetSCL(uint8_t signal){
    if(signal==1) GPIO_WriteBit(GPIOA,SCL_Pin,Bit_SET);
    else GPIO_WriteBit(GPIOA,SCL_Pin,Bit_RESET);
    Delay_us(5);                    //防止电平翻转过快,因此加上延时
}

void Z_I2C_SetSDA(uint8_t signal){
    if(signal==1) GPIO_WriteBit(GPIOA,SDA_Pin,Bit_SET);
    else GPIO_WriteBit(GPIOA,SDA_Pin,Bit_RESET);
    Delay_us(5);
}

uint8_t Z_I2C_GetSDA(void){
    return GPIO_ReadInputDataBit(GPIOA,SDA_Pin);
}

void Z_I2C_Start(void){
    Z_I2C_SetSDA(1);
    Z_I2C_SetSCL(1);
    Z_I2C_SetSDA(0);
    Z_I2C_SetSCL(0);
}

void Z_I2C_End(){
    Z_I2C_SetSDA(0);
    Z_I2C_SetSCL(1);
    Z_I2C_SetSDA(1);
}

void Z_I2C_SendByte(uint8_t byte){
    Z_I2C_SetSCL(0);
    for(int i=0;i<8;++i){
        if((byte&0x80)==0) Z_I2C_SetSDA(0);
        else Z_I2C_SetSDA(1);
        byte<<=1;
        Z_I2C_SetSCL(1);
        Z_I2C_SetSCL(0);
    }
}

uint8_t Z_I2C_ReveiceByte(){
    uint8_t data=0x00;
    Z_I2C_SetSDA(1);
    for(int i=0;i<8;++i){
        Z_I2C_SetSCL(1);
        if(Z_I2C_GetSDA()==1) data|=(0x80>>i);
        Z_I2C_SetSCL(0);
    }
    return data;
}

void Z_I2C_SendACK(uint8_t ack){
    if(ack==0) Z_I2C_SetSDA(0);
    else Z_I2C_SetSDA(1);
    Z_I2C_SetSCL(1);
    Z_I2C_SetSCL(0);
}

uint8_t Z_I2C_ReveiceACK(){
    Z_I2C_SetSDA(1);
    Z_I2C_SetSCL(1);
    uint8_t ack=Z_I2C_GetSDA();
    Z_I2C_SetSCL(0);
    return ack;
}

以后有需要使用I2C的项目的话,只需要把这边的代码复制一下再改改GPIO口相关的代码即可。顺带一提,代码里的延时函数不属于固件库,需要自己去写。或者去复制我之前的文章,里面有详细说明怎么使用SysTick去写一个延时函数。

【STM32F103】SysTick系统定时器&延时函数-CSDN博客文章浏览阅读911次,点赞23次,收藏23次。SysTick是Cortex-M3内核中的一个外设,内嵌在NVIC中,叫系统定时器。当处理器在调试期间被喊停时,SysTick也将暂停运作。一共有四个寄存器,不过我们通常用前三个,不需要校准。下图出自《STM32F10xxx Cortex-M3编程手册》第237页。https://blog.csdn.net/m0_63235356/article/details/135116207?spm=1001.2014.3001.5501

SHT20温湿度传感器

SHT20,一款使用I2C进行通信的温湿度传感器,我们可以通过它来获取当前采集到的温湿度。

电气标准,接口规格什么的就自己去看文档吧,这里直接讲如何使用它,通过I2C通信来得到我们想要的数据。

我们现在已经拥有了I2C的时序图,我们还需要SHT20的时序图,不过此时序图非彼时序图。我们需要的是SHT20规定的要跟它通信的基于I2C通信的时序图。

由于文档是英文的,所以把四级都没过的我整得非常狼狈,又是去找中文版又是去找翻译PDF的网址,但是好说歹说是找到了STH20的通信时序图。

通过上面图我们可以知道(我来翻译一下):首先我们发出I2C起始时序,接着发送STH20的7位从机+1位写(0),然后接收应答,如果收不到应答的话可能是哪里出问题了,可以开始排查。

能收到应答,那么一切顺序,接着我们发送8位命令,STH20中,有两种命令,0xF3是读取温度的命令,0xF5是读取湿度的命令,也就是说一次我们只能获取到一种数据,也就是温度或者是湿度。

发送完之后接收应答,等待20us之后我们发出I2C的结束时序。

接下来上图就有点迷糊了,我第一次看也不太清楚,后面结合着网上找的资料搞明白了。

我们就是不断重复发送STH20的7位从机+1位读(1),直到STH20有应答,因为STH20需要时间采样数据。

当收到应答之后,我们开始接收一个数据,这是采集到的数据的高位,接着我们需要发送一个应答位表示我们还要接收数据。然后我们再接收一个数据,这是采集到的数据的低位,也就是说STH20采集到的数据是一共16位的。

在收到16位数据之后我们可以直接发送I2C结束时序来结束I2C通信,也可以再发送一个应答然后接收最后一个字节再结束I2C通信。我们收到的第三个字节也就是最后一个字节,实际上是CRC的校验码,我们可以通过这个CRC校验码去对我们接收到的数据进行校验。

还有一个问题,就是实际上我们采集的16位数据,分辨率只有14位,因为最后的两位不能算进去,这并不是说明我们需要把采集的16位数据当成14位数据来用,而是我们需要把最后两位给清0,也就是将16位数据&0xFFFC(1111 1111 1111 1100)

那么知道了STH20的时序之后,我们就可以开始动手写代码来采集STH20的数据了。

采集到数据之后我们还需要处理数据。温度和湿度采集到的数据分别使用下面的截取自STH20官方文档里的公式即可。

 完整的代码

需要注意的是Delay.h是延时函数的库文件,OLED.h是显示OLED屏幕的库文件,这个是需要大家另外自己去写的。

同上文所说,延时函数可以去我之前的博文里找。而OLED大家没有的话也可以通过USART把采集的数据打印到电脑的串口助手上去,甚至还不需要像我在while(1)里面处理小数数据那样麻烦地显示到OLED里。

#include "stm32f10x.h"                  // Device header
#include "Delay.h"
#include "OLED.h"

#define SCL_Pin GPIO_Pin_0
#define SDA_Pin GPIO_Pin_1

void Z_I2C_Init(void){
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA,ENABLE);
    
    GPIO_InitTypeDef itd;
    itd.GPIO_Mode=GPIO_Mode_Out_OD;        
    itd.GPIO_Pin=SCL_Pin|SDA_Pin;    
    itd.GPIO_Speed=GPIO_Speed_50MHz;                   
    GPIO_Init(GPIOA,&itd);

    GPIO_WriteBit(GPIOA,SCL_Pin,Bit_SET);       //SCL和SDA默认都是高电平
    GPIO_WriteBit(GPIOA,SDA_Pin,Bit_SET);       //因此初始化后设为高电平
}
    
void Z_I2C_SetSCL(uint8_t signal){
    if(signal==1) GPIO_WriteBit(GPIOA,SCL_Pin,Bit_SET);
    else GPIO_WriteBit(GPIOA,SCL_Pin,Bit_RESET);
    Delay_us(5);                    //防止电平翻转过快,因此加上延时
}

void Z_I2C_SetSDA(uint8_t signal){
    if(signal==1) GPIO_WriteBit(GPIOA,SDA_Pin,Bit_SET);
    else GPIO_WriteBit(GPIOA,SDA_Pin,Bit_RESET);
    Delay_us(5);
}

uint8_t Z_I2C_GetSDA(void){
    return GPIO_ReadInputDataBit(GPIOA,SDA_Pin);
}

void Z_I2C_Start(void){
    Z_I2C_SetSDA(1);
    Z_I2C_SetSCL(1);
    Z_I2C_SetSDA(0);
    Z_I2C_SetSCL(0);
}

void Z_I2C_End(){
    Z_I2C_SetSDA(0);
    Z_I2C_SetSCL(1);
    Z_I2C_SetSDA(1);
}

void Z_I2C_SendByte(uint8_t byte){
    Z_I2C_SetSCL(0);
    for(int i=0;i<8;++i){
        if((byte&0x80)==0) Z_I2C_SetSDA(0);
        else Z_I2C_SetSDA(1);
        byte<<=1;
        Z_I2C_SetSCL(1);
        Z_I2C_SetSCL(0);
    }
}

uint8_t Z_I2C_ReveiceByte(){
    uint8_t data=0x00;
    Z_I2C_SetSDA(1);
    for(int i=0;i<8;++i){
        Z_I2C_SetSCL(1);
        if(Z_I2C_GetSDA()==1) data|=(0x80>>i);
        Z_I2C_SetSCL(0);
    }
    return data;
}

void Z_I2C_SendACK(uint8_t ack){
    if(ack==0) Z_I2C_SetSDA(0);
    else Z_I2C_SetSDA(1);
    Z_I2C_SetSCL(1);
    Z_I2C_SetSCL(0);
}

uint8_t Z_I2C_ReveiceACK(){
    Z_I2C_SetSDA(1);
    Z_I2C_SetSCL(1);
    uint8_t ack=Z_I2C_GetSDA();
    Z_I2C_SetSCL(0);
    return ack;
}

#define WENDU_COMMAND 0xF3
#define SHIDU_COMMAND 0xF5

uint16_t STH20_WData=0;
uint16_t STH20_SData=0;

void Z_STH20_GetData(char command){
    Z_I2C_Start();
    Z_I2C_SendByte(0x80);
    
    if(Z_I2C_ReveiceACK()!=0) return;
    
    if(command=='w') Z_I2C_SendByte(WENDU_COMMAND);   //发送命令
    else Z_I2C_SendByte(SHIDU_COMMAND);
    
    if(Z_I2C_ReveiceACK()!=0) return;
    
    int count=0;
    do{
        Z_I2C_Start();
        Z_I2C_SendByte(0x81);
        Delay_ms(10);
        if(++count>=10) return;
    }while(Z_I2C_ReveiceACK()!=0);
    
    if(command=='w'){
        STH20_WData=0;                                  //数据清零
        STH20_WData|=Z_I2C_ReveiceByte();               //获取数据高位
        Z_I2C_SendACK(0);
        STH20_WData<<=8;
        STH20_WData|=Z_I2C_ReveiceByte();               //获取数据低位
        Z_I2C_SendACK(0);
        uint8_t check=Z_I2C_ReveiceByte();              //获取CRC校验位
        Z_I2C_End();
        STH20_WData&=0xFFFC;                            //清除最后两位
        return;
    }else{
        STH20_SData=0;                                  //数据清零
        STH20_SData|=Z_I2C_ReveiceByte();               //获取数据高位
        Z_I2C_SendACK(0);
        STH20_SData<<=8;
        STH20_SData|=Z_I2C_ReveiceByte();               //获取数据低位
        Z_I2C_SendACK(0);
        uint8_t check=Z_I2C_ReveiceByte();              //获取CRC校验位
        Z_I2C_End();
        STH20_SData&=0xFFFC;                            //清除最后两位
        return ;
    }
}


int main(void){
    OLED_Init();
    Z_I2C_Init();
    
    while(1){
        Z_STH20_GetData('w');
        Z_STH20_GetData('s');
        double wendu=STH20_WData;
        wendu=(wendu/65536.0)*175.72-46.85;
        OLED_ShowNum(1,1,(int)wendu%100,2);
        OLED_ShowChar(1,3,'.');
        OLED_ShowNum(1,4,((int)(wendu*100)%100),2);
        OLED_ShowNum(2,1,STH20_WData,6);
        
        double shidu=STH20_SData;
        shidu=(shidu/65536.0)*125-6;
        OLED_ShowNum(3,1,(int)shidu%100,2);
        OLED_ShowChar(3,3,'.');
        OLED_ShowNum(3,4,((int)(shidu*100)%100),2);
        OLED_ShowNum(4,1,STH20_SData,6);
        Delay_ms(500);
    }
}

突然发现我代码里的SHT顺手写成STH了,包括我已经上传到网盘的代码也是,懒得改了,有强迫症的小伙伴自己改一下叭。 

效果展示

OLED第一行是处理过的温度数据,带小数,单位是℃。

第二行是没处理的温度数据。

第三行是处理过的湿度数据,带小数,单位是%。

第四行是没处理的湿度数据。

资料获取

大家也可以关注我的公众号"折途想要敲代码"回复关键词"SHT20"(字母全大写)获取完整的工程文件,以及SHT20的官方文档,含中文翻译版(我用搜狗翻译的,有点拉说实话)。

参考

[10-1] I2C通信协议_哔哩哔哩_bilibili

《STM32F10xxx参考手册(中文)》

《SHT20-Datasheet》

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值