一、实验环境
硬件: STC-B 学习板、 STM32 开发板、 PC 机
软件: Keil、 STC-ISP、 STM32CubeMX、 STM32 开发环境 、(可选)HMIware组态软件等
二、实验目标
针对“STC-B 学习板”和“STM32 学习板”现有硬件资源自选设计主题,各位同学通过一个自选主题设计展现个人的课程学习成效和能力水平。
三、实验内容
(一)我的实际应用案例:智能农业监控系统
在温室或农场环境中,通过环境监控系统监测温度、光照强度等因素,确保作物在最佳条件下生长。
有两个开发板:
一个作为监测板:对所处的环境(如温度,光照强度)进行实时检测展示显示屏上,也有实时时钟显示,作为从机,其从机设备特定寄存器中存着环境实时数据,所以可以与主机进行双机通信,将温度或光照传输给主机,也配有FM收音机实时播放,也可以记录出现异常环境的时间,能够校验时间。
一个作为控制板:可以发送指令向从机获取环境实时数据,能与PC进行通信给它提供环境数据,获取监测板记录的环境数据发生异常的时间。
那么我首先对每个模块的实现进行讲解,分为动态数码管显示、定时器中断、按键消抖**、ADC(温度/光照强度)**、实时时钟、ROM、FM收音机、Uart通信(RS232/RS485)、MODBUS封装实现STM32与PC/STM32与STM32/STM32与STC-B之间通信这九大模块,然后将这些模块整合起来实现上述模拟智能农业监控系统的案例
(二)九大模块具体实现
1.动态数码管显示
根据STM32原理图可知,每一时刻只能显示一个数码管,而要想实现8个数码管都显示,采取的思想是每隔一段很微小的时间循环显示这8个数码管,使人不能分辨出数码管在动态显示即可
第一步配置相关GPIO引脚,输出模式均为GPIO_Output
由于需要一段很小的时间进行切换,所以这里使用Sys Tick的中断服务例程。
注意,在STM32cubeMX中将Sys Tick作为Timebase Source生成代码时不会启动Sys Tick的中断服务例程,需要自己添加:
然后配置多长时间进行中断:
参数为上述公式的Reload Value = 72000-1,就可实现1ms中断一次,而我的Sys Tick ClockFrequency设置为72MHz,所以计算过程:T = 72000Hz/72MHz = 1ms。
接下来就是编写数码管显示的函数封装:
unsigned char displayLed[8] = {0x1,0x2,0x4,0x8,0x10,0x20,0x40,0x80};//led哪一位亮
int displayAllDigit = 0;//数码管显示的内容
//数码管显示的数字0~9或不显示
unsigned char displayDigit[] = {0x3f,0x06,0x5b,0x4f,0x66,0x6d,0x7d,0x07,0x7f,0x6f,0x00,0x40};
/* 0 1 2 3 4 5 6 7 8 9 无 - */
//设置哪一位数码管显示数字
//由于SEL0是3-8译码器的选择位的高位,所以逻辑上反过来
//即sel为二进制000代表让最左边数码管选中,111代表让最右边的数码管选中
void setSEL(int sel)
{
GPIOB->ODR &= ~0x7;
GPIOB->ODR |= sel;
}
//因为Led灯的引脚是PE8~PE15(即16位的高8位),所以对高8位操作
//对LED灯“赋值”
void setLedValue(unsigned char value)
{
GPIOE->ODR &= ~(0xff<<8);
GPIOE->ODR |= value<<8;
}
void setDigitValue(int digit)
{
//当digit为10时-->不显示
setLedValue(displayDigit[digit]);
}
根据我编写的API函数,只需调用setDigitValue(int digit)即可显示0~9之间的任意数字,若想显示其它字符,只需在display数组中拓展就行。
2.定时器中断
定时器即TIM1~4,与SysTick类似,均可实现特定时间间隔溢出中断。
在配置参数设置这里,第一个时预分频器频率、第二个是重装载寄存器,所以频率先要除以预分频器的频率,再根据Reload Value的值计算多长时间溢出中断。如图中的T = 200/(72000000/7200) = 20ms
最后开启中断使能:
生成代码后,可以重新定义对应的回调函数实现想要在溢出中断时实现的功能。
不过要使用得先开启定时器HAL_TIM_Base_Start_IT(&htim3);
3.按键消抖
由于按键在闭合和断开时,触点均会出现抖动现象:
但一般抖动在20ms以后就会趋于稳定状态,所以我们只需要在第一次判断有按键按下时,过20ms后再进行判断是否还是按键按下状态才可判断其确实有按键按下。
首先将三个按键的GPIO引脚模式配置为EXTI(即外部触发中断)
且根据原理图可知,其都接了上拉电阻,所以有按键按下时电平有1–>0,为下降沿触发中断
然后在外部中断回调函数重新定义,我这里用到的按键消抖实现是通过定时器实现的,在触发外部中断时则开启定时器(将溢出中断时间设置成20ms),最后在定时器中断回调函数里实现按键的功能实现,具体框架如下:
int KeyMode = 0; //记录现在是哪个按键按下
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if(GPIO_Pin == KEY1_Pin)
{
KeyMode = 1;
HAL_TIM_Base_Start_IT(&htim2);//启动Tim2中断进行计时消抖
}
else if(GPIO_Pin == KEY2_Pin)
{
KeyMode = 2;
HAL_TIM_Base_Start_IT(&htim2);//启动Tim2进行计时消抖
}
else if(GPIO_Pin == KEY3_Pin)
{
KeyMode = 3;
HAL_TIM_Base_Start_IT(&htim2);//启动Tim2进行计时消抖
}
}
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if(htim == &htim2)//Tim2实现按键消抖(20ms溢出中断)
{
HAL_TIM_Base_Stop_IT(&htim2); //关闭Tim2中断
switch(KeyMode){
case 1:
if(HAL_GPIO_ReadPin(KEY1_GPIO_Port,KEY1_Pin)==0)
{//Key1按下后实现的功能
}
break;
case 2:
if(HAL_GPIO_ReadPin(KEY2_GPIO_Port,KEY2_Pin)==0)
{//Key2按下后实现的功能
}
break;
case 3:
if(HAL_GPIO_ReadPin(KEY3_GPIO_Port,KEY3_Pin)==0)
{//Key3按下后实现的功能
}
break;
}
}
}
4.ADC(温度/光照强度)
ADC模拟数字转换器,是将模拟信号转换成数字信号的一种外设。比如某一个电阻两端的是一个模拟信号,单片机无法直接采集,此时需要ADC先将短租两端的电压这个模拟信号转化成数字信号,单片机才能够进行处理
要采集哪个引脚的信号,就在STM32cubeMX中配置ADC选择对应的通道,例如这里ADC1配置多通道(IN10、IN14、IN15),分别是导航键、光敏电阻、热敏电阻。
初始化配置默认即可:
正常获取采集的ADC值都需要:
1.开启ADC --> HAL_ADC_Start(ADC_HandleTypeDef* hadc);
2.等待ADC采集 -->
HAL_ADC_PollForEvent(ADC_HandleTypeDef* hadc, uint32_t EventType, uint32_t Timeout);
( STM32 HAL 库中用于轮询方式等待 ADC 转换完成的函数)
3.获取ADC的值 --> HAL_ADC_GetValue(ADC_HandleTypeDef* hadc);
而如果获取多通道的ADC值,则需要更换采集的通道
所以编写一个通用函数API供用户调用:
uint32_t ADC_Get_Average(uint8_t ch,uint8_t times)
{
ADC_ChannelConfTypeDef sConfig; //通道初始化
uint32_t value_sum=0;
uint8_t i;
switch(ch) //选择ADC通道
{
case 0:sConfig.Channel = ADC_CHANNEL_0;break;
case 1:sConfig.Channel = ADC_CHANNEL_1;break;
case 2:sConfig.Channel = ADC_CHANNEL_2;break;
case 3:sConfig.Channel = ADC_CHANNEL_3;break;
case 4:sConfig.Channel = ADC_CHANNEL_4;break;
case 5:sConfig.Channel = ADC_CHANNEL_5;break;
case 6:sConfig.Channel = ADC_CHANNEL_6;break;
case 7:sConfig.Channel = ADC_CHANNEL_7;break;
case 8:sConfig.Channel = ADC_CHANNEL_8;break;
case 9:sConfig.Channel = ADC_CHANNEL_9;break;
case 10:sConfig.Channel = ADC_CHANNEL_10;break;
case 11:sConfig.Channel = ADC_CHANNEL_11;break;
case 12:sConfig.Channel = ADC_CHANNEL_12;break;
case 13:sConfig.Channel = ADC_CHANNEL_13;break;
case 14:sConfig.Channel = ADC_CHANNEL_14;break;
case 15:sConfig.Channel = ADC_CHANNEL_15;break;
}
sConfig.SamplingTime = ADC_SAMPLETIME_239CYCLES_5; //采用周期239.5周期
sConfig.Rank = 1;
HAL_ADC_ConfigChannel(&hadc1,&sConfig);
for(i=0;i<times;i++)
{
HAL_ADC_Start(&hadc1); //启动转换
HAL_ADC_PollForConversion(&hadc1,30); //等待转化结束
value_sum += HAL_ADC_GetValue(&hadc1); //求和
HAL_ADC_Stop(&hadc1); //停止转换
}
return value_sum/times; //返回平均值
}
测试代码:
打开串口助手,通过uart1进行打印调试
(当然,我输出的是ADC采集到的原始值,并没有根据与温度之间的关系进行转换)
注意:ADC的输入时钟不得超过14MHz,它是由PCLK2经分频产生。
5.实时时钟
在STM32cubeMX配置RTC即可操作:
在RTC配置参数如上电的初始时间(时、分、秒)
这里我配置的RTC时钟源是LSI。
这里我遇到了一个问题,之前我配置RTC的时钟源是LSE,重新编译进行下板时发现之前实现的功能都没有现象了,不知道是不是与定时器Tim的时钟源冲突了,而单独只配置RTC是可以运行的。
这是RTC时间和日期的结构体定义:
然后只需调用HAL函数对RTC的时间和日期进行读取,例如读取时间:
6.ROM
STM32中对ROM(AT24C02)的读写操作是通过I2C进行的
而这个板子是通过I2C1与上述原理图的SCL和SDA引脚进行相连的,所以我们首先在STM32cubeMX中配置I2C1:
上图下面的参数配置默认即可,然后在GPIO引脚处可以看到它给我们自动配置了PB6和PB7,分别对应SCL和SDA:
我们主要用的是HAL的I2C的读写函数:HAL_I2C_Mem_Write和HAL_I2C_Mem_Read
而我参考了优快云上的某篇文章,对ROM的读写进行函数封装:
写操作比较麻烦,需要判断要写入的地址在页的哪个位置,以及要写入的大小与页关系,判断逻辑如下
- 写操作首先判断数据的大小是否在当前页面内,如果数据量小于等于当前页面剩余的空闲空间,则直接写入。
- 如果数据大于当前页面剩余空间,则分两步执行:首先写入当前页面的剩余部分,然后分多个页面写入剩余数据。
int
AT24C02_write(uint8_t addr, uint8_t* dataPtr, uint16_t dataSize)
{
if (0 == dataSize) { return -1; }
int res = HAL_OK;
int selectPage_idx = addr % AT24CXX_PAGE_SIZE;
int selectPage_rest = AT24CXX_PAGE_SIZE - selectPage_idx;
//写操作首先判断数据的大小是否在当前页面内,如果数据量小于等于当前页面剩余的空闲空间,则直接写入。
if (dataSize <= selectPage_rest) {
res = HAL_I2C_Mem_Write(&hi2c1,
AT24CXX_Write_ADDR,
addr,
I2C_MEMADD_SIZE_8BIT,
dataPtr,
dataSize,
0xFF);
if (HAL_OK != res) { return -1; }
//HAL_Delay(10);
} else {//
//如果数据大于当前页面剩余空间,则分两步执行:首先写入当前页面的剩余部分,然后分多个页面写入剩余数据。
//写入完一页后,程序会等待一段时间(sysDelay_ms(5))来确保数据已经写入 EEPROM。
/*! 1 write selectPage rest*/
res = HAL_I2C_Mem_Write(&hi2c1,
AT24CXX_Write_ADDR,
addr,
I2C_MEMADD_SIZE_8BIT,
dataPtr,
selectPage_rest,
0xFF);
if (HAL_OK != res) { return -1; }
addr += selectPage_rest;
dataSize -= selectPage_rest;
dataPtr += selectPage_rest;
//HAL_Delay(5);
/*! 2 write nextPage full */
int fullPage = dataSize/AT24CXX_PAGE_SIZE;
for (int iPage = 0; iPage < fullPage; ++iPage) {
res = HAL_I2C_Mem_Write(&hi2c1,
AT24CXX_Write_ADDR,
addr,
I2C_MEMADD_SIZE_8BIT,
dataPtr,
AT24CXX_PAGE_SIZE,
0xFF);
if (HAL_OK != res) { return -1; }
//HAL_Delay(5);
addr += AT24CXX_PAGE_SIZE;
dataSize -= AT24CXX_PAGE_SIZE;
dataPtr += AT24CXX_PAGE_SIZE;
}
/*! 3 write rest */
if (0 != dataSize) {
res = HAL_I2C_Mem_Write(&hi2c1,
AT24CXX_Write_ADDR,
addr,
I2C_MEMADD_SIZE_8BIT,
dataPtr,
dataSize,
0xFF);
if (HAL_OK != res) { return -1; }
//HAL_Delay(5);
}
}
return 0;
}
读操作就比较简单,具体如下:
int
AT24C02_read(uint8_t addr, uint8_t* dataPtr, uint16_t dataSize)
{
int res = HAL_I2C_Mem_Read(&hi2c1,
AT24CXX_Read_ADDR,
addr,
I2C_MEMADD_SIZE_8BIT,
dataPtr,
dataSize,
0xFF);
if (HAL_OK != res) { return -1; }
return 0;
}
7.FM收音机
其中STM32有两个GPIO引脚与RDA5807FP模块相连,分别是FM_CLK和FM_DATA,对应于STM32引脚的是PB10和PB11,这个板子使用的是I2C2来与RDA5807FP进行通信的
所以我们先配置STM32cubeMX来配置I2C2生成代码:
配置好后,可以发现右边的GPIO引脚已经帮我们自动配置好了:
我们需要参考RDA5807FP的数据手册,可以发现它只给出了连续读写的方式,连续读写方式的器件地址是0010000B,加上读写标志,即0x20(写操作)和0x21(读操作)。
连续读写的方式不可以直接操作寄存器的地址,只有一个固定的开始寄存器地址,(写0x02H,读0x0AH),内部有一个增量地址计数器。每个寄存器都是16bit的,写寄存器默认从0x02H开始,按字节算,写进去的数据依次为0x02H的高字节,0x02H低字节,0x03H高字节…,读寄存器时,默认从0x0AH开始读,所以读出来的数据依次是0x0AH的高字节,0x0AH的低字节…
且需要研读数据手册中设备的寄存器功能(具体每一位是干嘛的)
而RDA5807FP的寄存器是从02H开始的,有02H、03H、…… 、0FH
比如02H是设置enable的、03H是设置频率的
02H的第0位为使能enable【1–>启用FM 0–>关闭FM】
第14位为是否静音 【1–>不静音 0–>静音】
可知05H寄存器的低4位为音量的设置大小从0000 --> 1111
我基本上只用到了这几些点。而对寄存器的写入用的则是
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)
其中:
DevAddress为设备地址,RDA5807FP即为0x20;
MemAddress为要写入外设的寄存器地址
MemAddSize为外设寄存器地址存储大小,为8
pDat为你要写入寄存器的数据的地址
Size为写入数据大小
Timeout为超时时间
先构造要写入寄存器的数据,从02H开始。
(由于我们的STM32有个Bug,调用HAL_StatusTypeDef HAL_I2C_Mem_Write会将MemAddress作为第一个数据写入02H寄存器的高位,所以我们填写函数参数时就将错就错,将要写入的02H高位的内容作为参数填入,所以我们只需要从02H的低位开始构造要写入的数据):
unsigned char WriteBufferFM[7]={
0x11, //02H低位:音频输出,静音禁用,12MHZ,启用状态
0x1a,0x50, //03H:97500KHZ,频率使能87-108M(US/Europe),步进100KHZ
0x40,0x02, //04H:1-0为GPIO1(10为低,灯亮;11为高,灯灭),...
0x88,0xa5 }; //a5中的5为初始音量
封装启动FM的函数:将02H寄存器的第0位置1,将我们构造的数据通过I2C写入:
//将数据写入对应 RDA5807FP 中时,其会将写入的寄存器地址当作写入寄存器 02H 高位的数据,由此导致错乱
//所以如下的HAL_I2C_Mem_Write(&hi2c2, 0x20, 0xc0, I2C_MEMADD_SIZE_8BIT,WriteBufferFM,7, 1000);
//会将0xc0写入到02H寄存器的高位! 那我们就将错就错
void FM_Start(void){
//要开启FM,则将02H寄存器的第0位置1,该位代表enable使能
HAL_I2C_Mem_Write(&hi2c2, 0x20, 0xc0, I2C_MEMADD_SIZE_8BIT,WriteBufferFM,7, 1000);
}
关闭FM:只需将将02H寄存器的第0位置0:
void FM_Stop(void){
//要关闭FM,则将02H寄存器的第0位置0,该位代表enable使能
WriteBufferFM[1] = 0x10;
HAL_I2C_Mem_Write(&hi2c2, 0xc0, 0xc0, I2C_MEMADD_SIZE_8BIT,WriteBufferFM,2, 1000);
}
然后,编写一些用户可以调整的FM参数(如设置频率、音量):
//设置频率
void RDA5807SetChannel(float freq) {
uint16_t g_nRDA5807Channel = (int)((freq - 87.0) * 10.0 + 0.5); // 计算频道值,频率以 0.1 MHz 为步进
WriteBufferFM[1] = (uint8_t)(g_nRDA5807Channel >> 2); // 频道值高 8 位
WriteBufferFM[2] = (uint8_t)(((g_nRDA5807Channel & 0x3) << 6) | 0x10); // 频道值低 2 位 + 启用 TUNE
}
//设置音量
void RDA5807SetVolume(int volume){
// 确保音量值在0到15之间
if (volume < 0) volume = 0;
if (volume > 15) volume = 15;
WriteBufferFM[6] = (WriteBufferFM[6]&0xF0) | (volume & 0x0F);
}
最后编写初始化函数:
void RDA5807FP_Init(void){
RDA5807SetChannel(97.5);
RDA5807SetVolume(7);
}
最后我自己测试了这个STM32的FM功能,并且对不同的频率均一一播了一遍,记录了在长沙地区的电台有哪些是存在且清晰的O(∩_∩)O。:
8.Uart通信(RS232/RS485)
(1)Uart1(RS232)
STM32cubeMX配置Uart1:
这里的波特率通信时双方要一致,我这里都用115200Bits/s
然后我们就可以调用HAL的发送中断函数和接收中断函数:
我们可以自己重新定义这发送中断和接收中断回调函数:
例如发送:
例如接收:
Uart1可以与PC的串口助手进行通信,所以我们可以重新改写以下fputc函数,调用printf时用Uart1进行发送:
#include "stdio.h"
//串口1重定向
#if 1
#pragma import(__use_no_semihosting)
//标准库需要的支持函数
struct __FILE
{
int handle;
};
FILE __stdout;
//定义_sys_exit()以避免使用半主机模式
void _sys_exit(int x)
{
x = x;
}
//重定义fputc函数
int fputc(int ch, FILE *f)
{
/* Place your implementation of fputc here */
/* e.g. write a character to the EVAL_COM1 and Loop until the end of transmission */
HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xfff);
return ch;
}
#endif
这样用printf打印到串口助手进行调试就方便多了
(2)Uart2(RS485)
STM32cubeMX配置(与Uart1相似):
与Uart1一样,都是调用HAL_UART_Transmit_IT与HAL_UART_Receive_IT进行发送和接收,只是第一个参数不一样,Uart1为&huart1,而Uart2为&huart2。
但是,RS485时半双工通信,一个时间段内,要么发送、要么接收,不能同时发送和接收,所以有一个使能(485_D_R)进行控制发送和接收,原理图如下:
所以需要配置该使能引脚,查看原理图可知其引脚为PE6:
所以PE6为1时代表发送,PE6为0时代表接收。
例如发送:
例如接收:
9.MODBUS封装
ModBus 是一种应用层协议,它定义了如何在不同的物理层(如 UART、RS-485、CAN、甚至 以太网)上传输数据。ModBus 协议本身并不依赖于某种特定的物理层,它只是对数据帧的格式、传输规则、功能码等进行定义,以实现不同设备之间的通信。
ModBus 协议可以在多种不同的物理介质上运行。
总之,ModBus 协议定义了如何封装和解释数据,而 物理层(如 UART、RS-485、CAN、以太网)只是它传输的载体。不同的物理层决定了数据的传输方式、速度、距离、通信方式(如半双工或全双工)等。
定义MODBUS数据结构类型:
MODBUS封装分为作为主机部分还是作为从机部分:
那么首先定义一个结构体(MODBUS会用到的一些变量)
typedef struct
{
uint8_t mode; //模式是主机(1)还是从机(0)
//作为从机时使用
uint8_t myadd; //本设备从机地址
uint8_t rcbuf[100]; //modbus接受缓冲区
uint8_t timout; //modbus数据持续时间
uint8_t recount; //modbus端口接收到的数据个数
uint8_t timrun; //modbus定时器是否计时标志
uint8_t reflag; //modbus一帧数据接受完成标志位
uint8_t sendbuf[100]; //modbus接发送缓冲区
//作为主机添加部分
uint8_t Host_Txbuf[8]; //modbus发送数组
uint8_t slave_add; //要匹配的从机设备地址(做主机实验时使用)
uint8_t Host_send_flag;//主机设备发送数据完毕标志位
int Host_Sendtime;//发送完一帧数据后时间计数
uint8_t Host_time_flag;//发送时间到标志位,=1表示到发送数据时间了
uint8_t Host_End;//接收数据后处理完毕
}MODBUS;
主机部分:
- 先要构造主机要发的请求帧,如读取从机某些寄存器、向从机地址某个寄存器写入数据等等。我这里就以03功能码为例:
//参数1从机地址,参数2起始地址,参数3寄存器个数
void Host_Read03_slave(uint8_t slave,uint16_t StartAddr,uint16_t num)
{
modbus.reflag = 1;
uint16_t crc;//计算的CRC校验位
modbus.slave_add=slave;//这是先把从机地址存储下来,后面接收数据处理时会用到
modbus.Host_Txbuf[0]=slave;//这是要匹配的从机地址
modbus.Host_Txbuf[1]=0x03;//功能码
modbus.Host_Txbuf[2]=StartAddr/256;//起始地址高位
modbus.Host_Txbuf[3]=StartAddr%256;//起始地址低位
modbus.Host_Txbuf[4]=num/256;//寄存器个数高位
modbus.Host_Txbuf[5]=num%256;//寄存器个数低位
crc=CRC16(modbus.Host_Txbuf,6); //获取CRC校验位
modbus.Host_Txbuf[6]=crc/256;//寄存器个数高位
modbus.Host_Txbuf[7]=crc%256;//寄存器个数低位
modbus.recount=num*2+5;
//发送数据包装完毕(共8个字节)
//开始发送数据
if(MODBUS_RS == 1) //RS485发送
{
HAL_GPIO_WritePin(RS485_D_R_GPIO_Port,RS485_D_R_Pin,1);
HAL_UART_Transmit_IT(&huart2, modbus.Host_Txbuf, 8);
}
else if(MODBUS_RS == 0) //RS232发送
HAL_UART_Transmit_IT(&huart1, modbus.Host_Txbuf, 8);
}
- 然后等待从机的响应帧,收到后,要对其进行解析。
这是计算校验码CRC:
//计算效验码CRC
uint16_t CRC16(uint8_t *buffer, uint16_t length)
{
uint16_t crc = 0xFFFF;
uint16_t i, j;
for (i = 0; i < length; i++) {
crc ^= buffer[i];
for (j = 8; j; j--) {
if (crc & 0x0001)
crc = (crc >> 1) ^ 0xA001;
else
crc >>= 1;
}
}
return crc;
}
编写void HOST_ModbusRX(void)进行处理 :
void HOST_ModbusRX()
{
uint16_t crc,rccrc;//计算crc和接收到的crc
//(数组中除了最后两位CRC校验位其余全算)
// for (int i = 0; i < 9; i++) {
// printf("0x%02X ", modbus.rcbuf[i]);
// }
//
// printf("recount: %d\n",modbus.recount);
crc = CRC16(modbus.rcbuf,modbus.recount-2); //获取CRC校验位
rccrc = modbus.rcbuf[modbus.recount-2]*256+modbus.rcbuf[modbus.recount-1];//计算读取的CRC校验位
// printf("crc: %d\n",crc);
// printf("rccrc: %d\n",rccrc);
if(crc == rccrc) //CRC检验成功 开始分析包
{
if(modbus.rcbuf[0] == modbus.slave_add) // 检查地址是是对应从机发过来的
{
if(modbus.rcbuf[1]==3)//功能码时03
{
Host_Func3();//这是读取寄存器的有效数据位进行计算
}
else if(modbus.rcbuf[1]==6)
{
Host_Func6();
}
}
}
modbus.recount = 0;//接收计数清零
modbus.reflag = 0; //接收标志清零
}
首先判断校验位是否正确,其次判断从机地址是否一致,最后根据功能码来执行特定功能,我这里就是打印输出看看:
void Host_Func3()//主机接收从机的消息进行处理功能码0x03
{
int i;
int count=(int)modbus.rcbuf[2];//这是数据个数
printf("从机返回 %d 个寄存器数据:\r\n",count/2);
for(i=0;i<count;i=i+2)
{
printf("Temp_Hbit= %d Temp_Lbit= %d temp= %d\r\n",(int)modbus.rcbuf[3+i],(int)modbus.rcbuf[4+i],(int)modbus.rcbuf[4+i]+((int)modbus.rcbuf[3+i])*256);
}
}
从机部分:
需要一直等待主机发来的请求帧,如果收到并判断从机地址是自己,那么就解析它,并满足主机的请求,给予回复帧。
- 需要有自己的从机寄存器映射:
//作为从机部分
uint16_t Reg[] ={ 0x0001, //当前时间的秒
0x0012, //当前时间的分
0x0013, //当前时间的时
0x0004, //温度
0x0025, //光照
0x0036, //ROM的秒
0x0007, //ROM的分
0X0008, //ROM的时
};//reg是提前定义好的寄存器和寄存器数据,要读取和改写的部分内容
- 编写一个对请求帧解析的函数void Modbus_Event():
// Modbus事件处理函数
void Modbus_Event()
{
modbus.reflag = 1;
uint16_t crc,rccrc;//crc和接收到的crc
//收到数据包(接收完成)
//通过读到的数据帧计算CRC
//参数1是数组首地址,参数2是要计算的长度(除了CRC校验位其余全算)
// for (int i = 0; i < 8; i++) {
// printf("0x%02X ", modbus.rcbuf[i]);
// }
crc = CRC16(modbus.rcbuf,6); //获取CRC校验位
// 读取数据帧的CRC
rccrc = modbus.rcbuf[6]*256+modbus.rcbuf[7];//计算读取的CRC校验位
// printf("crc: %d\n",crc);
// printf("rccrc: %d\n",rccrc);
if(crc == rccrc) //CRC检验成功 开始分析包
{
if(modbus.rcbuf[0] == modbus.myadd) // 检查地址是否时自己的地址
{
switch(modbus.rcbuf[1]) //分析modbus功能码
{
case 0: break;
case 1: break;
case 2: break;
case 3: Modbus_Func3(); break;//这是读取寄存器的数据
case 4: break;
case 5: break;
case 6: Modbus_Func6(); break;//这是写入单个寄存器数据
case 7: break;
case 8: break;
case 9: break;
case 16: Modbus_Func16(); break;//写入多个寄存器数据
}
}
else if(modbus.rcbuf[0] == 0) //广播地址不予回应
{
}
}
modbus.recount = 0;//接收计数清零
modbus.reflag = 0; //接收标志清零
}
- 完成的请求并构造响应帧给予回复,还是以03功能码为例 :
// Modbus 3号功能码函数
// Modbus 主机读取寄存器值
void Modbus_Func3()
{
uint16_t Regadd,Reglen,crc;
uint8_t i,j;
//得到要读取寄存器的首地址
Regadd = modbus.rcbuf[2]*256+modbus.rcbuf[3];//读取的首地址
//得到要读取寄存器的数据长度
Reglen = modbus.rcbuf[4]*256+modbus.rcbuf[5];//读取的寄存器个数
//发送回应数据包
i = 0;
modbus.sendbuf[i++] = modbus.myadd; //ID号:发送本机设备地址
modbus.sendbuf[i++] = 0x03; //发送功能码
modbus.sendbuf[i++] = ((Reglen*2)%256); //返回字节个数
for(j=0;j<Reglen;j++) //返回数据
{
//reg是提前定义好的16位数组(模仿寄存器)
modbus.sendbuf[i++] = Reg[Regadd+j]/256;//高位数据
modbus.sendbuf[i++] = Reg[Regadd+j]%256;//低位数据
}
crc = CRC16(modbus.sendbuf,i); //计算要返回数据的CRC
modbus.sendbuf[i++] = crc/256;//校验位低位
modbus.sendbuf[i++] = crc%256;//校验位高位
//数据包打包完成
// 开始返回Modbus数据
if(MODBUS_RS == 1) //RS485发送
{
HAL_GPIO_WritePin(RS485_D_R_GPIO_Port,RS485_D_R_Pin,1);
HAL_UART_Transmit_IT(&huart2, modbus.sendbuf, i);
}
else if(MODBUS_RS == 0) //RS232发送
HAL_UART_Transmit_IT(&huart1,modbus.sendbuf,i);
}
(三)智能农业监控系统
用以下按键功能模拟:
那么就从监测板、控制板以及STC-B实现的功能来一一展开:
1.监测板
将前面各个模块的功能进行模块化:
(1) Key1切换数码管显示的数据(时间/环境数据)
用定时器Tim3中断(每1ms中断)对时间/温度/光照进行采集,且放到Reg固定位置:
所以我们需要一个全局变量来记录现在应该显示哪个数据
然后在1ms事件里进行逻辑判断,决定现在是什么模式, 最后进行段选:
int count = 0;
void Func_1mS(void)
{
if(Mode_Display == 0) //数码管展示时间
{
displayAllDigit = (Reg[0]&0xff)+((Reg[1])&0xff)*1000 + (Reg[2]&0xff)*1000000;
}
else if(Mode_Display == 1) //数码管展示温度/光照
{
displayAllDigit = Reg[3]*10000 + Reg[4];
}
if(count == -1)
count = 7;
switch(count--)
{
case(7):
setDigitValue(displayAllDigit%10);
setSEL(7);
break;
case(6):
setDigitValue((displayAllDigit/10)%10);
setSEL(6);
break;
case(5):
if(Mode_Display == 0)
setDigitValue(11);
else
setDigitValue((displayAllDigit/100)%10);
setSEL(5);
break;
case(4):
setDigitValue((displayAllDigit/1000)%10);
setSEL(4);
break;
case(3):
setDigitValue((displayAllDigit/10000)%10);
setSEL(3);
break;
case(2):
if(Mode_Display == 0)
setDigitValue(11);
else
setDigitValue((displayAllDigit/100000)%10);
setSEL(2);
break;
case(1):
setDigitValue((displayAllDigit/1000000)%10);
setSEL(1);
break;
case(0):
setDigitValue((displayAllDigit/10000000)%10);
setSEL(0);
break;
}
}
按下Key1进行mode模式切换:
(2) Key2记录当前时间到ROM中,可在环境出现异常时按下
这个就只需要调用前面AT48C02模块写好的读写函数进行调用并存储到Reg对应位置即可:
(3) Key3开启/关闭FM
也是需要一个FM_state全局变量来记录FM的工作状态(开/关),然后在Key3按下时进行切换即可:
(4) MODBUS为从机模式,且只需要与控制板通信(RS485)
作为从机需要一直等待主机的请求帧,所以在20ms事件里调用HAL_UART_Receive_IT,但这里有个小细节,需要一个全局变量(MODBUS_Init_flag)来记录是否正在等待请求帧,如果是,那么下一个20ms就不需要再调用HAL_UART_Receive_IT进行等待了:
void Func_20mS(void)
{
if(MODBUS_Init_flag == 0)
{
MODBUS_Init_flag = 1;
HAL_GPIO_WritePin(RS485_D_R_GPIO_Port,RS485_D_R_Pin,0);
HAL_UART_Receive_IT(&huart2,modbus.rcbuf,8);
}
}
如果等到了请求帧,则在接收中断回调函数里调用Modbus_Event()进行解析,解析完成后就将MODBUS_Init_flag置为0:
2.控制板
Key1和Key2键按下后时分别获取读取从机寄存器的温度/光照和异常时间:
而在接收到响应帧后,将读取到的数据存储到Reg对应位置中:
而Key3是切换通信方式(双机通信还是与PC通信):
并且将模式显示在数码管上。
3.STC-B
主要是将实验一的功能模块的文件拿来用,唯一添加的功能是RS485双机通信
/*---------串口2中断处理程序---------*/
void Uart2_Process( void ) interrupt 8 using 1
{
if( S2CON & cstUart2Ri )
{
ucGetdata0Tmp = S2BUF ;
ucPutdata0Tmp = ucGetdata0Tmp ;
S2CON &= ~cstUart2Ri; //接收中断标志位清0
}
if( S2CON & cstUart2Ti )
{
btSendBusy = 0 ; //清除忙信号
S2CON &= ~cstUart2Ti ; //发送中断标志位清0
}
}
/*---------RS485发送数据函数---------*/
void RS485_Senddata0(uchar *data0, uchar length)
{
uchar i;
sbtM485_TRN = 1; // 切换到发送模式(确保 MAX485 发送)
for (i = 0; i < length; i++) {
S2BUF = data0[i]; // 将数据字节写入串口缓冲区
while (btSendBusy); // 等待当前字节发送完成
btSendBusy = 1; // 设置为忙状态,防止发送下一个字节
}
sbtM485_TRN = 0; // 切换到接收模式(确保 MAX485 处于接收模式)
}
/*---------RS485接收数据函数---------*/
void RS485_Receivedata0(uchar *data0, uchar length)
{
uchar i;
for (i = 0; i < length; i++) {
while (!(S2CON & cstUart2Ri)); // 等待接收完成
data0[i] = S2BUF; // 读取接收到的数据
S2CON &= ~cstUart2Ri; // 清除接收中断标志位
}
}
封装了RS485发送数据函数和接收数据函数,直接调用即可使用。
参考文章
1.STM32CubeMx HAL库使用硬件IIC读写AT24C02
https://blog.youkuaiyun.com/u010058695/article/details/116522020
2.STM32+RS485+Modbus-RTU(主机模式+从机模式)-标准库/HAL库开发
https://blog.youkuaiyun.com/qq_37281984/article/details/122739968?ops_request_misc=&request_id=&biz_id=102&utm_term=stm32%E5%AE%9E%E7%8E%B0modbus%20rtu%E5%8D%8F%E8%AE%AE&utm_medium=distribute.pc_search_result.none-task-blog-2allsobaiduweb~default-9-122739968.142v100pc_search_result_base4&spm=1018.2226.3001.4187
3.光敏电阻ADC采集+STM32CubeMx配置ADC多通道读取
https://blog.youkuaiyun.com/weixin_50257954/article/details/133049628?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522a2244e1c39c9402ec77ba53d46e38ed6%2522%252C%2522scm%2522%253A%252220140713.130102334.pc%255Fall.%2522%257D&request_id=a2244e1c39c9402ec77ba53d46e38ed6&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2allfirst_rank_ecpm_v1~rank_v31_ecpm-4-133049628-null-null.142v100pc_search_result_base4&utm_term=stn32cubemx%E9%85%8D%E7%BD%AEADC%E4%B8%8D%E5%90%8C%E9%80%9A%E9%81%93&spm=1018.2226.3001.4187