上次,我们一起学习了如何使用IIC的普通轮询模式,与AHT20模块进行通信,测量了房间的温湿度。这次,我们一起来看看IIC的中断与DMA。
我们不妨来回顾一下,上次的学习中,我们使用HAL_I2C_Master_Transmit向AHT20发送了触发测量的指令。就像我们介绍的那样,主机在向IIC从机发送(写)指令的时候,设备地址的最后一位为0,而从机AHT20接收到指令后,也不能回复任何数据,只能回复ACK信号表明自己已经接收到指令,随后我们又使用HAL_I2C_Master_Receive请求AHT20的测量数据,按照IIC协议,主机向从机请求(读)数据时,只能发送设备地址,而且设备地址的最后一位为1,当然0x70到0x71的转换由HAL库帮我们完成,读取请求中只有地址,不能携带任何数据,随后从机回复ACK后,就开始回复固定字节数量的数据,至于多少字节数,通常与上次发送的指令有关,具体的工程中需要查询具体模块的手册。从机每发送一个字节后,都需要主机发送一次ACK信号,代表已经接收到这一字节数据。

与我们之前详细讲解过的串口轮询模式的发送流程类似,IIC轮询模式的发送也需要由软件逐个字节去控制相关寄存器,发完一个字节在发送下一个,直到全部完成才能继续执行接下来的代码。

轮询模式的接收也是类似的过程,整个发送或者接收过程中,一直堵塞执行,占用着CPU资源。为了解决此问题,IIC也有中断模式与DMA模式。
与串口一样,IIC的中断模式发送也是将一字节数据塞入寄存器后,CPU继续执行正常任务,直到此字节数据发送完成后产生中断,CPU再来塞入下一字节数据,IIC中断模式接收也是类似。搬运完一字节数据后就去执行其他任务,直到下一字节数据到来,继续搬运。而DMA模式下CPU更加省力,整个发送或者接收的数据都交给小助手DMA去搬运,等数据发送或者接收完成,再由DMA通过中断通知CPU前来处理。
使用起来IIC的中断与DMA模式其实也是跟串口的这两种模式用法一样。来到我们上次创建的iic工程,首先在Cube MX中打开IIC1的两个中断向量,一个是IIC事件(event)中断,一个是IIC错误(error)中断。


我们这次主要用的是这个事件中断向量,Ctrl+S保存并生成代码,main.c里依旧是我们上次写的代码。while循环里是调用AHT20_Read获取温度与湿度,然后用串口输出出来。我们就一起试试用IIC的中断模式改造一下AHT20_Read函数吧。
为了防止内存上的旧数据影响一会的实验效果,我们先给readBuffer初始化一下,然后开始改造。首先是发送函数Transmit,仅需在Transmit后添加_IT即可。另外由于是非阻塞函数,运行瞬间就能完成,后续的步骤都交给了中断机制,因而也就不存在等待时间这么一个参数了。接收函数Receive的改造也是类似,添加_IT以及删掉等待时间参数,这就改造好了,但是总感觉哪里不对。先编译下载看一下吧。打开串口调试助手看一下效果。

可以看到数据出现了错误,这是怎么回事呢?其实,这时由于非阻塞函数的特性造成的,早在学习串口时我们就了解过轮询模式是阻塞模式。程序会等到所有的数据发送/接收完成才会接着向下执行,而中断与DMA模式则是非阻塞模式,他们将任务交给外设后就会接着向下执行,并不会等待数据的发送/接收完成 ,整个通信流程都交给外设与中断进行控制,通信完成后,再通过中断来通知我们,所以我们在此对readBuffer进行解析时,上面这行Receive读取的数据只是通知外设进行了读取,但尚未读取完成。readBuffer里的数据还都是我们初始化的0,解析出来的数据当然也就不正确了。
void AHT20_Read(float *Temperatuer, float *Humidity)
{
uint8_t sendBuffer[3] = {0xAC, 0x33, 0x00 };
uint8_t readBuffer[6] = {0};
HAL_I2C_Master_Transmit_IT(&hi2c1, AHT20_ADDRESS, sendBuffer, 3);
HAL_Delay(75);
HAL_I2C_Master_Receive_IT(&hi2c1, AHT20_ADDRESS, readBuffer, 6);
if((readBuffer[0] & 0x80) == 0x00)
{
uint32_t data = 0;
data = ((uint32_t)readBuffer[3] >> 4) + ((uint32_t)readBuffer[2] << 4) + ((uint32_t)readBuffer[1] << 12);
*Humidity = data * 100.0f /(1 << 20);
data = ((uint32_t)readBuffer[3] << 16) + ((uint32_t)readBuffer[4] << 8) + (uint32_t)readBuffer[5] ;
*Temperatuer = data * 200.0f / (1 << 20) - 50;
}
}
所以,我们得来进行一波改造,搞一个简单的状态机出来,不同的状态下做不同的事。那么首先我们把发送测量指令与数据读取、数据解析都拆分开,第一个就叫做AHT20_Measure只管发送测量指令,需要注意的是,我们将sendBuffer数组的指针传入了中断发送函数,中断发送函数回在后续的执行流程中使用它,但是sendBuffer变量在AHT20_Measure函数执行完成后就不存在了,占用的内存地址也会被回收,分配给其他变量使用,这样的话,这块内存地址上存的数据也可能会被修改,造成发送的数据有误,所以我们应该将sendBuffer设置为static,这样sendBuffer就会一直占用这块内存,即使是AHT20_Measure函数执行结束,也不会被释放回收了。
void AHT20_Measure()
{
static uint8_t sendBuffer[3] = {0xAC, 0x33, 0x00 };
HAL_I2C_Master_Transmit_IT(&hi2c1, AHT20_ADDRESS, sendBuffer, 3);
}
还有一个函数AHT20_Get,此函数用于读取AHT20的测量数据,由于我们接下来的另一个函数也需要使用这个readBuffer数组,所以我们将其提升为全局变量,全局变量最好放在所有函数的上面。
uint8_t readBuffer[6] = {0};
void AHT20_Get()
{
HAL_I2C_Master_Receive_IT(&hi2c1, AHT20_ADDRESS, readBuffer, 6);
}
然后我们来写解析数据的函数AHT20_Analysis,要有两个用于向外传递数据的float指针入参,然后还是复制已经完成的解析。
void AHT20_Analysis(float *Temperature, float *Humidity)
{
if((readBuffer[0] & 0x80) == 0x00)
{
uint32_t data = 0;
data = ((uint32_t)readBuffer[3] >> 4) + ((uint32_t)readBuffer[2] << 4) + ((uint32_t)readBuffer[1] << 12);
*Humidity = data * 100.0f /(1 << 20);
data = ((uint32_t)readBuffer[3] << 16) + ((uint32_t)readBuffer[4] << 8) + (uint32_t)readBuffer[5] ;
*Temperatuer = data * 200.0f / (1 << 20) - 50;
}
}
OK,已经全部分开了。 为了能在别的文件中使用,我们将它们在aht20.h在进行声明。

随后我们来到main.c中,修改一下整个aht20驱动的使用流程,首先在用来写我们自己变量的PV注释对中,定义一个aht20State变量,用来记录状态机的状态,可以写个注释来规定一下各个状态值,0是初始状态,这个状态下发送测量命令,1是正在发送测量命令,2是测量命令发送完成,这个状态下要等待75ms,然后进行IIC读取,3是IIC读取中,4是读取完成了,那么就要进行数据的解析与输出。如此进行简单状态循环,就是我们本次要实现的状态机。

对了,这里我们使用的是中文注释,在Cube IDE里使用中文有一点是一定要注意的,就是使用Cube IDE重新生成代码后,中文会乱码。这是由于编码不一致造成的。我们需要搜索打开Windows的环境变量,新建一个系统变量,变量名为JAVA_TOOL_OPTIONS,变量值设置为-Dfile.encoding=UTF-8,确定之后重新启动Cube IDE就不会出现这种情况了。


回到代码,来到while死循环中,将原来的代码注释掉,我们来写状态机的代码。首先我们不妨先把所有状态情况的判断写出来
while(1)
{
if(aht20State == 0)
{
}
else if(aht20State == 1)
{
}
else if(aht20State == 2)
{
}
else if(aht20State == 3)
{
}
else if(aht20State == 4)
{
}
}
然后我们挨个补充每个状态机下的动作,首先是aht20State为0时的初始状态,此状态下我们使用AHT20_Measure触发测量,然后将状态机设置为正在发生测量命令,也就是1,而正在发送测量命令时,我们不需要做什么,所以我们跳过,直接来写测量指令发送完成时的动作,首先延迟75ms,随后调用AHT20_Get函数去读取AHT20数据。然后将状态设置为正在读取中3,读取中也不需要进行任何操作,所以跳过。接下来是状态值为4时,数据读取完成,进行数据的分析。将我们的温湿度变量传入AHT20数据分析函数,并且依旧使用串口发送出来,随后延迟个1秒钟,把状态值再恢复为初始状态0。OK好像是写完了。
while(1)
{
if(aht20State == 0)
{
AHT20_Measure();
aht20State = 1;
}
else if(aht20State == 2)
{
HAL_Delay(75);
HAT20_Get();
aht20State = 3;
}
else if(aht20State == 4)
{
AHT20_Analysis(&temperature, &humidity);
sprintf(message,"温度:%.1f ℃, 湿度:%.1f %%\r\n",temperature, humidity);
HAL_UART_Transmit(&huart2, (uint8_t)message,strlen(message),HAL_MAX_Delay );
HAL_Delay(1000);
aht20State = 0;
}
}
不过我们会发现,并没有将状态值从1设置为2和从3设置为4的语句。这是因为在整个流程中我们好像并不知道IIC何时完成了发送,也不知道何时完成了接收。回想我们在学习串口的中断模式时,HAL库是通过调用我们重新实现的HAL_UART_RxCpltCallback函数来通知我们串口接收完成的,那么IIC是否也有类似的函数呢?
答案是有的,我们找到stm32f1xx_hal_i2c.c文件打开,出现这个提示,是因为此文件比较大,点击yes,

然后把这个数字改大一点,这个勾勾去掉就好了

我们从大纲视图中仔细找找,就可以找到HAL_I2C_MasterTxCpltCallback以及HAL_I2C_MasterRxCpltCallback,全部带有__weak标记顾名思义TxCpltCallback就是发送完成回调,RxCpltCallback就是接收(读取)完成回调,我们把它们在i2c.c文件中重新实现一下。
因为接下来我们需要在i2c.c中调用main.c中定义的aht20State变量,所以我们将此变量在main.h中进行extern声明,随后回到i2c.c。首先是TxCpltCallback,此函数会在通过HAL_I2C_Master_Transmit_IT函数发送的命令数据全部送达从机后,由I2C中断调用。首先我们先象征性地判断一下进入这个中断回调的是不是i2c1,说是象征性的,是因为我们其实也没有使用其他i2c,这样写只是为了保持一个好习惯。
然后,由于我们只会通过中断发送测量指令,所以直接将状态机状态设置为发送完成,也就是2。RxCpltCallback也是同理。它会在我们通过HAL_I2C_Master_Receive_IT向从机请求的数据全部接受完成后,由I2C中断调用。我们也是先判断一下是不是i2c1进入的中断,然后将状态设置为接收完成,这下一个状态机的完整循环就搭建好啦。
void HAL_I2C_MasterTxCpltCallback(I2C_HandleTypeDef *hi2c)
{
if(hi2c == &hi2c1)
{
aht20State = 2;
}
}
void HAL_I2C_MasterRxCpltCallback(I2C_HandleTypeDef *hi2c)
{
if(hi2c == &hi2c1)
{
aht20State = 4;
}
}
最后编译下载一气呵成。来到串口助手,又能准确地测量温湿度数据了

那么刚刚我们使用的是IIC的中断模式,虽然已经不在阻塞执行,省出来不少CPU时间,但是还记得我们的小助手DMA吗?它可以替CPU搬运数据,省出更多的CPU时间,而且使用起来也非常的简单。
回到Cube MX界面,来到I2C1 DMA Settings,跟串口一样,添加上发送和接收的DMA通道,默认配置就是常用配置,所以也不需要进行改动。

来到NVIC Settings可以看到多了DMA相关的中断

保存并生成代码。DMA模式也跟中断模式一样,也是非阻塞模式,而且也是通过中断调用这两个Callback函数通知我们,只是多了个帮忙搬运数据的小助手而已,所以代码上不需要做多少修改,只需要到aht20.c中,把Transmit和Receive函数后的_IT改成_DMA,编译下载看一下效果。
江湖传言:"STM32的硬件IIC有Bug,所以最好使用软件模拟IIC"
所谓软件模拟IIC,其实就是自己通过代码控制GPIO口,去生产和读取SDA与SCL的电平变化。其实,自己动手实现一遍模拟IIC,对大家加深对IIC的理解非常有帮助,不过针对此江湖传言大家也不必太过在意,此问题是ST公司在STM32F10系列芯片中,硬件IIC的一些设计缺陷与特性产生的,而早些年的标准库比较简陋,并未进行太多优化。如今HAL一直在不断优化更新,尽最大可能地在软件层面上规避了这些问题。
本次我们不仅学习使用了IIC的中断与DMA模式,更重要的是,我们感受到了单片机中非常非常常见的一种编程方式,状态机编程。后续大家也会发现,在稍微复杂的项目中,处处都是状态机的身影.