STM32IIC通信详解(硬件实现IIC通信详解II 基于HAL库编程)

STM32硬件框架介绍

首先我们来看IIC通信的硬件架构

可以看出,可以分为以上4部分。
第一部分:通信接口
SDA信号和SCL信号由此产生或输入
第二部分:时钟部分
时钟信号由此产生或由此读取
第三部分:数据部分
通信时,数据从缓冲区放入DR寄存器,再由SR寄存器将其一位一位移出到SDA发送。这个过程持续直到数据发送完毕。
第四部分:控制部分
控制部分和初始化密切相关,控制部分设置IIC的工作模式和使能等各种内部配置

我们主要需要理解的部分为1、3部分。内部的时钟和控制由HAL库帮我们管理好了。接下来我们就由此展开讲解如何用硬件方式实现IIC通信,利用IIC接口和STM32中的片内外设,而不是软件模拟的方式来实现。

在这里我也稍微提及一下软件模拟方式实现IIC通信与硬件方式的差别和利弊。软件模拟时,需要优化IO口类型,由于数据经常发生变化,这种类型的数据尽可能的需要放入CPU的寄存器中(__IO关键字),方便使用,这无形降低了CPU寄存器的数量,从而拖慢了运算速度。另外,软件模拟方式,需要CPU实时改变电平,CPU运算负担较大,不合算。再者,使用软件控制电平变化,需要我们仔细理解协议。这个协议是简单的IIC协议,若是比较复杂的协议,我们也要如此模拟产生电平吗?

显然,这体现了一个道理——封装。我们使用协议,一般而言,不需要了解协议内部的具体规定,只要我们用符合规格的设备,就可以实现数据通信。若我们使用协议,还要了解协议内部如何实现,增大了使用协议通信的成本,也浪费了时间。是一种“重复发明轮子”的行为。正如我们使用电子元器件不需要了解内部的物理实现,使用芯片时不需要了解内部电路一样。不同的部件由不同的人管理,这样才是将生产力最大化的方式,也是封装的意义所在。

软件模拟方式的益处在于代码简单明了,干净整洁。但是!笔者十分不推荐这种破坏封装性的编程方式,这种简洁,是牺牲了CPU运算效率换来的。以往,51单片机的内部并没有集成IIC接口,所以需要用软件模拟,前几年,据网上的文献资料表示,STM32的库为了规避IIC的专利,用了较为复杂的方式去控制STM32的IIC接口和协议,所以错误较多。但如今这种错误已经没有了,IIC接口可以正常使用,而且HAL库的封装度极高,在各种配置都较为充裕的STM32中,我十分推荐使用HAL库内部封装的IIC接口。

此举增加的代码的模块性。在移植方面,HAL库抽象出了MSP层,而移植只需要修改MSP层的代码即可(改一下管脚)和软件模拟的移植难度一样低。也降低了CPU的资源占用。长时间模拟电平变化消耗的运算资源还是十分多的。其实笔者也认为,之所以串口通信不是电平模拟,而IIC通信是依赖电平模拟,很大的原因不是硬件实现的方式不行,而是前几年STM32的代码有问题,再早一些的51又不含IIC的接口,所以导致大家基本都使用软件模拟的方式,使用硬件实现的文献也很少,查阅资料也很不方便。但既然现在STM32内部封装好的库已经可以正常使用,我们没有理由占用CPU资源。这也是我要介绍硬件实现的原因。

若读者对软件模拟的方式感兴趣,可以自行查阅网络文献和书籍资料,软件模拟的教程在网络上和书籍中都十分常见,这里不再赘述。即使不用软件模拟,了解算法加深对IIC通信协议的理解,也是很好的行为,所以笔者也推荐去了解一下软件实现IIC通信。

IIC初始化

首先将stm32fxxx_hal_i2c.c,stm32fxxx_hal_i2c_ex.c加入文件中。再在main.h中的stm32fxxx_hal.h中找到stm32fxxx_hal_conf.h,将其中对#define HAL_I2C_MODULE_ENABLED的注释取消。若不知道如何加入文件和清除注释,请参考笔者上一篇文章STM32串口初始化与使用详解(基于HAL库实现)串口初始化部分,此处不再赘述。

初始化使用的函数介绍:

HAL库函数具体功能
HAL_StatusTypeDef HAL_I2C_Init(I2C_HandleTypeDef *hi2c)初始化函数
HAL_StatusTypeDef HAL_I2C_DeInit(I2C_HandleTypeDef *hi2c)取消初始化函数
__weak void HAL_I2C_MspInit(I2C_HandleTypeDef *hi2c)初始化MSP层
__weak void HAL_I2C_MspDeInit(I2C_HandleTypeDef *hi2c)取消对MSP层的初始化

__weak关键字:

这个关键字告诉编译器,这个定义是弱定义,若在编译时,有一处函数定义不含此关键字,则所有该函数的调用都指向那一个非weak型的函数定义。这个操作叫函数重定向。因为在系统头文件里,MSP层是没有声明的,MSP层是具体对应每一个MCU的物理状态,系统文件不会声明也不能声明,但在单片机做上电初始化时,有一些函数又必须调用,所以系统文件采取了这种用户可以重定向的写法。我们在初始化串口时,需要自己写一个MSPInit,而且不含weak。

这是笔者在上一篇文章介绍__weak关键字时的描述,放在此处供参考用。

HAL_I2C_Init()函数做的事情使将用户传入的I2C句柄初始化配置到具体的I2C设备中,并且调用MSP层初始化。

只要我们初始化了IIC和它的MSP层,这个设备就将变得可用。

初始化:

void I2C1Init(void)
{
  hi2c1.Instance = I2C1;
  hi2c1.Init.ClockSpeed = 100000;
  hi2c1.Init.DutyCycle = I2C_DUTYCYCLE_2;
  hi2c1.Init.OwnAddress1 = 0;
  hi2c1.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT;
  hi2c1.Init.DualAddressMode = I2C_DUALADDRESS_DISABLE;
  hi2c1.Init.OwnAddress2 = 0;
  hi2c1.Init.GeneralCallMode = I2C_GENERALCALL_DISABLE;
  hi2c1.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE;
  if (HAL_I2C_Init(&hi2c1) != HAL_OK)		//按要求配置IIC并初始化
  {
    Error_Handler();
  }
}

初始化MSP层:

void HAL_I2C_MspInit(I2C_HandleTypeDef* hi2c)
{
  GPIO_InitTypeDef GPIO_InitStruct = {0};
  if(hi2c->Instance==I2C1)
  {
    __HAL_RCC_GPIOB_CLK_ENABLE();						//初始化对应GPIO时钟
    GPIO_InitStruct.Pin = GPIO_PIN_6|GPIO_PIN_7;
    GPIO_InitStruct.Mode = GPIO_MODE_AF_OD;
    GPIO_InitStruct.Pull = GPIO_PULLUP;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_VERY_HIGH;
    GPIO_InitStruct.Alternate = GPIO_AF4_I2C1;
    HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);				//按上述要求初始化GPIO口
		
    __HAL_RCC_I2C1_CLK_ENABLE();
		
    HAL_NVIC_SetPriority(I2C1_EV_IRQn, 0, 0);			//分别配置EV和ER的中断优先级且使能中断
    HAL_NVIC_EnableIRQ(I2C1_EV_IRQn);
    HAL_NVIC_SetPriority(I2C1_ER_IRQn, 0, 0);
    HAL_NVIC_EnableIRQ(I2C1_ER_IRQn);	
  }
}

在MAIN函数中调用后,IIC硬件就实现了初始化。

如何实现通信?通信过程是如何实现的?

从缓冲区中将数据放入DR寄存器,再由SR寄存器指挥发送,循环这个过程直到缓冲区内无数据。收数据则同理。
接下来介绍函数(一般而言我们都将MCU做主设备,控制从设备的芯片,所以接下来介绍的收发方式都是主机模式下的,从机模式下同理)

HAL库函数具体功能
HAL_I2C_Master_Transmit阻塞模式下的IIC发送函数
HAL_I2C_Master_Receive阻塞模式下的IIC接收函数
HAL_I2C_Master_Transmit_IT非阻塞模式下的IIC发送配置函数
HAL_I2C_Master_Receive_IT非阻塞模式下的IIC接收配置函数
HAL_I2C_Mem_Write阻塞模式下的IIC寄存器写函数
HAL_I2C_Mem_Read阻塞模式下的IIC寄存器读函数

不论是读写寄存器还是iic设备,都有轮询、中断和DMA方式,篇幅原因,这里就介绍这些抛砖引玉

阻塞模式下的发送代码如下,笔者将笔者认为比较主要的部分留了下来方便阅读。

HAL_StatusTypeDef HAL_I2C_Master_Transmit(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint8_t *pData, uint16_t Size, uint32_t Timeout)
{
	//…
	//…
  if (hi2c->State == HAL_I2C_STATE_READY)
  {
    if (I2C_WaitOnFlagUntilTimeout(hi2c, I2C_FLAG_BUSY, SET, I2C_TIMEOUT_BUSY_FLAG, tickstart) != HAL_OK)
    {
      return HAL_BUSY;
    }
    /* Check if the I2C is already enabled */
    if ((hi2c->Instance->CR1 & I2C_CR1_PE) != I2C_CR1_PE)
    {
      __HAL_I2C_ENABLE(hi2c);
    }
	
	//发送时关于总线和其他标志位的配置	

    hi2c->State       = HAL_I2C_STATE_BUSY_TX;
    hi2c->Mode        = HAL_I2C_MODE_MASTER;
    hi2c->ErrorCode   = HAL_I2C_ERROR_NONE;

    hi2c->pBuffPtr    = pData;
    hi2c->XferCount   = Size;
    hi2c->XferSize    = hi2c->XferCount;
    hi2c->XferOptions = I2C_NO_OPTION_FRAME;

    /* 发送从机地址 */
    if (I2C_MasterRequestWrite(hi2c, DevAddress, Timeout, tickstart) != HAL_OK)
    {
      return HAL_ERROR;
    }

    /* 检验地址标志 */
    __HAL_I2C_CLEAR_ADDRFLAG(hi2c);

    while (hi2c->XferSize > 0U)
	{
	//…
	//… 循环发送过程。直到发送完毕
    }

    /* Generate Stop */
    SET_BIT(hi2c->Instance->CR1, I2C_CR1_STOP);

	//释放对总线的控制和其他标志位配置
    hi2c->State = HAL_I2C_STATE_READY;
    hi2c->Mode = HAL_I2C_MODE_NONE;
    return HAL_OK;
  }
  else
  {
    return HAL_BUSY;
  }
}

可以发现,阻塞模式下的发送函数。若配置完毕且各种状态正常,声明占用总线,标志各种标志位,循环发送数据直到数据全部发送出去,发送完毕后取消对总线占用的声明,并返回HAL_OK。

阻塞模式下的接收也同理,这里不再赘述。

中断模式下,函数只配置了相关的中断标志位,并没有真正的实现数据发送。真正的数据发送在中断服务函数中
函数代码如下:

HAL_StatusTypeDef HAL_I2C_Master_Transmit_IT(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint8_t *pData, uint16_t Size)
{
  __IO uint32_t count = 0U;

  if (hi2c->State == HAL_I2C_STATE_READY)
  {
    /* Wait until BUSY flag is reset */
    count = I2C_TIMEOUT_BUSY_FLAG * (SystemCoreClock / 25U / 1000U);
    do
    {
      count--;
      if (count == 0U)
      {
        hi2c->PreviousState       = I2C_STATE_NONE;
        hi2c->State               = HAL_I2C_STATE_READY;
        hi2c->Mode                = HAL_I2C_MODE_NONE;
        hi2c->ErrorCode           |= HAL_I2C_ERROR_TIMEOUT;

        /* Process Unlocked */
        __HAL_UNLOCK(hi2c);

        return HAL_ERROR;
      }
    }
    while (__HAL_I2C_GET_FLAG(hi2c, I2C_FLAG_BUSY) != RESET);

    /* Process Locked */
    __HAL_LOCK(hi2c);

    /* Check if the I2C is already enabled */
    if ((hi2c->Instance->CR1 & I2C_CR1_PE) != I2C_CR1_PE)
    {
      /* Enable I2C peripheral */
      __HAL_I2C_ENABLE(hi2c);
    }

    /* Disable Pos */
    CLEAR_BIT(hi2c->Instance->CR1, I2C_CR1_POS);

    hi2c->State     = HAL_I2C_STATE_BUSY_TX;
    hi2c->Mode      = HAL_I2C_MODE_MASTER;
    hi2c->ErrorCode = HAL_I2C_ERROR_NONE;

    /* Prepare transfer parameters */
    hi2c->pBuffPtr    = pData;
    hi2c->XferCount   = Size;
    hi2c->XferSize    = hi2c->XferCount;
    hi2c->XferOptions = I2C_NO_OPTION_FRAME;
    hi2c->Devaddress  = DevAddress;

    /* Generate Start */
    SET_BIT(hi2c->Instance->CR1, I2C_CR1_START);

    /* Process Unlocked */
    __HAL_UNLOCK(hi2c);

    /* Note : The I2C interrupts must be enabled after unlocking current process
              to avoid the risk of I2C interrupt handle execution before current
              process unlock */
    /* Enable EVT, BUF and ERR interrupt */
    __HAL_I2C_ENABLE_IT(hi2c, I2C_IT_EVT | I2C_IT_BUF | I2C_IT_ERR);

    return HAL_OK;
  }
  else
  {
    return HAL_BUSY;
  }
}

可以发现,函数和阻塞模式一样配置了各种配置,但没有发送的部分,只有对中断使能的一行函数__HAL_I2C_ENABLE_IT(hi2c, I2C_IT_EVT | I2C_IT_BUF | I2C_IT_ERR);,真正的发送在中断执行进入的中断服务函数中。接收中断配置函数也同理。

与串口收发不同,这里很常见的需要对其中的寄存器进行读写,从而读出/写入数据,或者实现对设备配置和控制。所以存在寄存器读写的函数。这里也只介绍读取的函数,写入函数完全一样,只是R/W标志位不同。

HAL_StatusTypeDef HAL_I2C_Mem_Write(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint16_t MemAddress, uint16_t MemAddSize, uint8_t *pData, uint16_t Size, uint32_t Timeout)
函数需要输入IIC句柄,设备地址,寄存器地址,寄存器地址位数,输出缓冲区,输出大小,超时返回时间。从而实现对内部寄存器的读写。这个函数的功能很明确,具体实现与普通的写函数其实差不多,所以不把函数列出来了。有需要可以自己去阅读内部的代码。

了解了IIC硬件初始化和收发函数,就可以具体实践了。笔者使用LM75A进行温度采集,IIC通信传给MCU。MCU可对采集到的数据进行其他处理。相关代码和步骤如下

I2C.h。在这里声明了LM75A的地址和相关的寄存器地址,不同芯片不同,需要得知这些信息请阅读相关芯片的芯片手册。

#ifndef __I2C_H_
#define __I2C_H_
#include "main.h"

#define LM75ADDR 0x90												//LM75I2C地址
#define LM75TEMP 0x00												//内部温度寄存器地址,一共11位,高5位无效,高位在前,低位在后。读温度时需要读两次
#define LM75CONF 0x01												//配置寄存器地址。可用来配置相关OS模式(具体参考芯片手册)
#define LM75LAGR 0x02												//滞后寄存器地址。滞后寄存器可以用来配置温度下限相关的数据,具体参考芯片手册
#define LM75OVER 0x03												//过温寄存器地址。可用来配置温度上限的相关数据,具体参考芯片手册

extern I2C_HandleTypeDef hi2c1;

	int GetTemp(void);																																			//从温度寄存器地址中获取温度并返回
	void GetDataFromReg(uint8_t regAddress,uint8_t data);																		//从某一寄存器中读取1字节的数值
	void PutDataForReg(uint8_t regAddress,uint8_t data);																		//往温度传感器的某一寄存器中写入1字节的数值
	uint8_t PutMulDataForReg(uint8_t regAddress,uint8_t * data, uint8_t len);									//从温度传感器的某一寄存器中读取len个字节的数值
	uint8_t GetMulDataFromReg(uint8_t regAddress,uint8_t * data,uint8_t len);
	HAL_StatusTypeDef IICGetDataFromReg(uint8_t I2CADDR,uint8_t regAddress,uint8_t data);			
	HAL_StatusTypeDef IICPutDataForReg(uint8_t I2CADDR,uint8_t regAddress,uint8_t data);
	HAL_StatusTypeDef IICGetMulDataFromReg(uint8_t I2CADDR,uint8_t regAddress,uint8_t len,uint8_t * data);
	HAL_StatusTypeDef IICPutMulDataForReg(uint8_t I2CADDR,uint8_t regAddress,uint8_t len,uint8_t * data);

#endif

I2C.c

#include "I2C.h"
#include "usart.h"

//#define I2CDEBUG																														//定义这行宏定义,关于I2C的debug信息就会输出到串口

I2C_HandleTypeDef hi2c1;
uint8_t I2C1RxHandle[200];

void I2C1Init(void)
{
  hi2c1.Instance = I2C1;
  hi2c1.Init.ClockSpeed = 100000;
  hi2c1.Init.DutyCycle = I2C_DUTYCYCLE_2;
  hi2c1.Init.OwnAddress1 = 0;
  hi2c1.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT;
  hi2c1.Init.DualAddressMode = I2C_DUALADDRESS_DISABLE;
  hi2c1.Init.OwnAddress2 = 0;
  hi2c1.Init.GeneralCallMode = I2C_GENERALCALL_DISABLE;
  hi2c1.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE;
  if (HAL_I2C_Init(&hi2c1) != HAL_OK)
  {
    Error_Handler();
  }
}





int GetTemp()
{
	int i = 0;
	uint8_t tempData[2];
	unsigned int thisTemp;
	unsigned int thisTempH;																														//以移位形式存储的高位
	unsigned int thisTempR;																														//以移位形式存储的低位
	tempData[0] = 0;
	tempData[1] = 0;
	GetMulDataFromReg(LM75TEMP,tempData,2);
	
	thisTempH = tempData[0] * 256;
	thisTempR = tempData[1];
	
	thisTemp = (thisTempH + thisTempR) / 32 / 8;
#ifdef I2CDEBUG	
		u1_printf("%d",thisTemp);
#endif
	return thisTemp;
}



void GetDataFromReg(uint8_t regAddress,uint8_t data)
{
	while(IICGetDataFromReg(LM75ADDR,regAddress,data) != HAL_OK)
	{
#ifdef I2CDEBUG
		u1_printf("ERR:GetData is error,func is GetDataFromReg\r\n");
#endif
		continue;
	}
}

void PutDataForReg(uint8_t regAddress, uint8_t data)
{
	while(IICPutDataForReg(LM75ADDR,regAddress,data) != HAL_OK)
	{
#ifdef I2CDEBUG		
		u1_printf("ERR:PutData is error, func is PutDataForReg\r\n");
#endif
		continue;
	}
}

uint8_t PutMulDataForReg(uint8_t regAddress,uint8_t * data, uint8_t len)
{
	if(IICPutMulDataForReg(LM75ADDR,regAddress,len,data) == HAL_OK)
	{
#ifdef I2CDEBUG		
		u1_printf("send data success, data is:");
		u1_printf("%s\r\n",data);
#endif
		return 1;
	}
	else
	{
#ifdef I2CDEBUG		
		u1_printf("ERR:MUL SENDdata is error,func is PutMulDataForReg\r\n");
#endif
		return 0;
	}
}

uint8_t GetMulDataFromReg(uint8_t regAddress,uint8_t * data,uint8_t len)
{
	if(IICGetMulDataFromReg(LM75ADDR,regAddress,len,data) == HAL_OK)
	{
#ifdef I2CDEBUG
		u1_printf("get data success");
#endif
		return 1;
	}
	else
	{
#ifdef I2CDEBUG
		u1_printf("ERR:MUL GETdata is error,func is PutMulDataForReg\r\n");
#endif
		return 0;
	}
}


HAL_StatusTypeDef IICGetDataFromReg(uint8_t I2CADDR,uint8_t regAddress,uint8_t data)
{
	return HAL_I2C_Mem_Read(&hi2c1,I2CADDR,regAddress,8,&data,1,100);
}

HAL_StatusTypeDef IICPutDataForReg(uint8_t I2CADDR,uint8_t regAddress,uint8_t data)
{
	return HAL_I2C_Mem_Write(&hi2c1,I2CADDR,regAddress,8,&data,1,100);
}

HAL_StatusTypeDef IICGetMulDataFromReg(uint8_t I2CADDR,uint8_t regAddress,uint8_t len,uint8_t * data)
{
	return HAL_I2C_Mem_Read(&hi2c1,I2CADDR,regAddress,8,data,len,200); 
}

HAL_StatusTypeDef IICPutMulDataForReg(uint8_t I2CADDR,uint8_t regAddress,uint8_t len,uint8_t * data)
{
	return HAL_I2C_Mem_Write(&hi2c1,I2CADDR,regAddress,8,data,len,200);
}

void HAL_I2C_MspInit(I2C_HandleTypeDef* hi2c)
{
  GPIO_InitTypeDef GPIO_InitStruct = {0};
  if(hi2c->Instance==I2C1)
  {
    __HAL_RCC_GPIOB_CLK_ENABLE();
    GPIO_InitStruct.Pin = GPIO_PIN_6|GPIO_PIN_7;
    GPIO_InitStruct.Mode = GPIO_MODE_AF_OD;
    GPIO_InitStruct.Pull = GPIO_PULLUP;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_VERY_HIGH;
    GPIO_InitStruct.Alternate = GPIO_AF4_I2C1;
    HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);
		
    __HAL_RCC_I2C1_CLK_ENABLE();
		
    HAL_NVIC_SetPriority(I2C1_EV_IRQn, 0, 0);
    HAL_NVIC_EnableIRQ(I2C1_EV_IRQn);
    HAL_NVIC_SetPriority(I2C1_ER_IRQn, 0, 0);
    HAL_NVIC_EnableIRQ(I2C1_ER_IRQn);
		
		
  }

}

void HAL_I2C_MspDeInit(I2C_HandleTypeDef* hi2c)
{
  if(hi2c->Instance==I2C1)
  {
  /* USER CODE BEGIN I2C1_MspDeInit 0 */

  /* USER CODE END I2C1_MspDeInit 0 */
    /* Peripheral clock disable */
    __HAL_RCC_I2C1_CLK_DISABLE();
  
    /**I2C1 GPIO Configuration    
    PB6     ------> I2C1_SCL
    PB7     ------> I2C1_SDA 
    */
    HAL_GPIO_DeInit(GPIOB, GPIO_PIN_6|GPIO_PIN_7);

    /* I2C1 interrupt DeInit */
    HAL_NVIC_DisableIRQ(I2C1_EV_IRQn);
    HAL_NVIC_DisableIRQ(I2C1_ER_IRQn);
  /* USER CODE BEGIN I2C1_MspDeInit 1 */

  /* USER CODE END I2C1_MspDeInit 1 */
  }

}

void I2C1_EV_IRQHandler(void)
{
  HAL_I2C_EV_IRQHandler(&hi2c1);
}
void I2C1_ER_IRQHandler(void)
{
  HAL_I2C_ER_IRQHandler(&hi2c1);
}

其中u1_printf();是笔者封装的串口1的发送函数,如果需要将获得的数据打印到串口,自己实现即可

函数逻辑十分简单,层层封装,最高层的是GetTemp();函数从LM75A中读取温度并返回,供上级调用。内部运算可以采取浮点型减小误差,这种运算简单但不能应对所有情形,有更高要求请重新设计。因为这不在IIC介绍的范围内,也不做赘述。

由于没有其他的需求,主逻辑也十分简单

int main(void)
{
  HAL_Init();
  SystemClock_Config();
  MX_GPIO_Init();
  I2C1Init();
  Usart1Init(115200);
  LedInit();
	
  while (1)
  {
	 GetTemp();
  }
}

综上所述,使用硬件方式的IIC十分简单,只需要初始化IIC,MSP层,调用相关的读写函数即可。而且不占用CPU的资源。是一种比较有优势的通信方式

前篇:IIC通信协议

因为本人水平实在有限,文章内容若有错误,敬请谅解


版权声明:本文为qq_44993565原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。