目录
前言:
期末设计预期的效果是整个系统能对环境温度、环境的光照情况进行测量及显示,并且能对时间及日期进行显示。时间与日期的控制不再打算用按键进行修改,取而代之用蓝牙模块实现手机与蓝牙模块之间单向通信,即手机向蓝牙模块发送当前的时间或日期,蓝牙模块接受数据,通过内部代码对数据进行筛选与解析,最后更新芯片自身数据为手机发送的数据。
1.接线&效果&功能:
1、接线:
1、光敏传感器AO端口接GPIO_PA_0
2、热敏传感器AO端口接GPIO_PA_1
3、蓝牙模块端口RXD接GPIO_PA_9
4、蓝牙模块端口TXD接GPIO_PA_10
2、效果:
3、功能:
1、实时监控当前环境的温度范围为[- 20 ℃,+ 99 ℃]
2、实时监控当前环境的光照强度,光照强度由低到高为[0 , 100]
3、显示时间与日期
4、能接收手机传输的信息并整合更新自己的日期与时间
2. 实现:
1、手机与芯片单向通信:
主要利用了Stm32自带的串口通信功能,这个跟着网上大佬学就可以了
除此之外手机与芯片之间的单向通信还要解决以下几个问题:
1、如何确保接收数据的完整性?
2、如何判断传输数据的合法性?
3、如何对数据合理的分割整合?
解答如下:
—— 2023/10/16
(1) 确保接收数据的完整性:
首先需要注意的就是数据之所以会出现丢失,主要原因在于手机向接受端发送信息时,接收端不能及时接受信息,以至于前一个数据还未来的及接受就被后来的数据覆盖
数据不能被及时接收的原因:
网上常用数据接收代码:
void USART1_IRQHandler(void)
{
if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET)
{
Serial_RxData = USART_ReceiveData(USART1); //一个一个接收
Serial_RxFlag = 1;
USART_ClearITPendingBit(USART1, USART_IT_RXNE);
}
}
由上述串口接收数据中断函数可以看出,触发中断后数据总是一个字节逐个被Serial_RxData
变量接收
接着标记标志位Serial_RxFlag
,主函数发现标志位变化,才会接受并储存Serial_RxData
上的数据
这个逻辑看似没有问题,但漏洞在于,我们的主函数不可能时刻判断标志位的值是否变化吧?朴素的说主函数不可能就执行判断标志位是否变化这一个语句吧?正是由于主函数在执行其他语句的时候导致没能时刻观察标志位的变化,而导致数据不能被立即接收,最终导致了数据丢失
那么解决方向就显而易见了,即如何做到数据能在中断发生的时候就立即接收呢?
答案就是数组
优化代码:
void USART1_IRQHandler(void)//中断函数
{
if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET)
{
news[I] = USART_ReceiveData(USART1);//读数据
Serial_RxFlag = 1;//至标志位为有数据
I ++;
USART_ClearITPendingBit(USART1, USART_IT_RXNE);
}
}
在中断产生的时候就立即用数组进行接收,同时标记标志位
主函数发现标志位变化了,等待一会就可以获取数组,取出传输方发送的全部信息了
(2) 判断传输数据的合法性:
传输方发送的信息不可能都是符合标准的,那么如何对数据进行筛选就显得及其重要
首先对我们需要的正确信息进行分析其特点:
例如需要获取的正确信息如下:
日期:2023/10/17
时间:21:57:55
分析上述两个典型数据,可以发现日期的特点是数据里存在/
,数据时间特点是有:
,那么第一次筛选条件就是判断接收数据是否存在上述任意一种符号。
这个在数据传输阶段就可以用两个标志位判断:
if(news[i] == '/') flagDate = 1;
if(news[i] == ':') flagTime = 1;
接下来摆在面前的就是假若筛选后的数据里出现超过两个/
或:
符号的时候
再进一步讲上若传入的数据符号为两个,但是传入的时间为25:15:80
阁下又应该如何应对?
要解决上述问题还是要对传入数据的特点进行细节分析:
拿日期 2023/10/17 进行分析,其中/
符号出现了两次我们完全可以用一个cnt
在拆解数据的过程中进行计数,若最后cnt
的大小不等于2的时候那么这个数据就会被判断为不合法这样符号问题就解决了
—— 2023/10/17
(3) 对数据合理的分割整合:
那么又如何对年份,月份,天数、进行判别呢?
这就需要将数据进行切割了拿2023/10/17
示范,分析数据,巧妙的是在2023出现的时候cnt = 0
,也就是在2023
之前没有出现/
而到10
出现的时候cnt = 1
,最后17
出现的时候自然cnt = 2
。恍然大悟用一个updateDate[cnt]
分别对数据进行储存即可updateDate[0] = 2023、updateDate[1] = 10、updateDate[2] = 17
再对这三个数据分别分析就可以最终判断传入的数据是否合法了!
实现代码:
uint8_t Function_DateState(unsigned int *updateDate, char *News){
uint8_t cnt = 0;
uint8_t i = 0;
uint8_t end = Serial_GetI();
char month[] = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
while(i < end){
if(News[i] == '/') {
cnt ++;
i ++;
if(cnt >= 3) {
return 0;
}
continue;
}
updateDate[cnt] = updateDate[cnt]* 10 + (News[i] - '0');
i ++;
}
if(cnt != 2) return 0;
if(Function_Numlength(updateDate[0]) >= 5 || updateDate[0] > 9999 || updateDate[0] == 0) return 0;
if((updateDate[0] % 4 == 0 && updateDate[0] % 100 != 0 )|| updateDate[0] % 400 == 0) month[2] = 29;
else month[2] = 28;
if(Function_Numlength(updateDate[1]) >= 3 || updateDate[1] > 12 || updateDate[1] == 0) return 0;
if(Function_Numlength(updateDate[2]) >= 3 || updateDate[2] > month[updateDate[1]]|| updateDate[2] == 0) return 0;
Time_GetArrayMonth()[2] = month[2];//这里年份也会发生变化,要修改原有数据
return 1;
}
(4) 实现过程产生的重大BUG及解决方法:
上述理论成立,接下来就是动手实践了。以下是在对功能以及细节实现过程中产生重大BUG原因的解答以及解决方法的揭秘:
为了让主函数main函数更加简洁,我将数据传输的侦测,处理以及更新都封装在主函数以外的文件中,主函数引用其头文件即可调用我提前封装好的函数,也就是因为封装这些函数,所以产生一些小问题以下列函数为例:
返回数据函数:
char * Serial_returnNews(void){//返还一个数组
char * array;
uint8_t i = 0;
array = (char *) malloc(sizeof(char) * 100);
Delay_ms(300);//等待数据传输完
while(i < I){//复制
if(news[i] == '/') flagDate = 1;
if(news[i] == ':') flagTime = 1;
array[i] = news[i];
i ++;
}
return array;
}
1、多次传输数据芯片出现卡机现象:
这个函数目的是将接收端存放在数组内的数据,传送至数据处理端,这里使用malloc
创建了一个char数组将news数值的数据copy过来并作为返地址,由于此函数是个返还类型的函数,所以没办法及时清理掉这个新创建的array
数组,但这不代表不需要清理
若不采取情理,运行程序,手机端在向芯片连续发送几次信息后,显示屏上的就会卡住,任凭怎么输入都没有反应。
究其原因就是因为大量因创建数组产生的地址泄露,导致最后芯片地址耗尽,停止工作
解决方法:
free(News);//释放空间必须释放否者发生地址紊乱,直接卡机
2、返回数据函数被离奇执行两次:
解决上述问题后,数据传输会卡机的现象就没有了,但是再多测试几次的时候,却惊奇的发现传输的数据和接收的数据还是会有误差,查看news
数组的时候发现数据并没有丢失?!但传递到数据处理端就有问题?然而我的返还数组函数并没有问题啊,这个bug就很玄学了。
经过不懈排查体调试最终发现传输数据的时候,char * Serial_returnNews(void)
被执行了两次?!这是我用cnt变量计数得来的,这个函数又没和别的中断函数重名,为啥调用一次执行两次?再说偏偏还执行两次,真是邪门…
重新更改调试方向,我将目标放在了主函数:
if(Serial_GetRxFlag() == 1) //时刻监控标志位
这条语句是监控数据是否传输完成的,若传输完成则Serial_GetRxFlag()
会返回1值并自动清0,主函数进入if语句内并处理数据
uint8_t Serial_GetRxFlag(void)//读取标志位后自动青除
{
if (Serial_RxFlag == 1)
{
Serial_RxFlag = 0;
return 1;
}
return 0;
}
再结合着中断传输数据函数判断:
void USART1_IRQHandler(void)//中断函数
{
if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET)
{
news[I] = USART_ReceiveData(USART1);//读数据
Serial_RxFlag = 1;//至标志位为有数据
I ++;
USART_ClearITPendingBit(USART1, USART_IT_RXNE);
}
}
看似没有问题,但要知道中断函数在接受数据的时候是一字节一字节的接受,当传输一串数据的时候Serial_RxFlag = 1
语句就是一个持续的过程,也就是当传输一串数据的时候主函数if虽然获取了标志位,并将其制0,但后续数据继续传输的时候中断函数内的Serial_RxFlag = 1
,仍会继续将标志位制1,这就是为什么if语句还会执行一次
解决方法在if语句结束的时候再次调用制0函数:
Serial_GetRxFlag();//制零否则if循环将会被执行两次
3、数据显示乱码:
这个问题单纯是因为使用中间变量接收数据完毕后没有将其恢复初始化,内部存有上次传输的数值,导致数据叠加乱码
解决方法:
unsigned int updateTime[] = {0, 0, 0};
unsigned int updateDate[] = {0, 0, 0};
2、时间&日期的实现:
时间、日期主要分别存入对应数组中,需要更改的话直接更改对应数组即可
unsigned int time[] = {22, 59, 30};
unsigned int date[] = {2023, 12, 31};
char month[] = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
主函数需要提取对应数组的话,直接调用提取数组函数即可:
unsigned int * Time_GetArrayTime(void){
return time;
}
unsigned int * Time_GetArrayDate(void){
return date;
}
char * Time_GetArrayMonth(void){
return month;
}
时间计数,采用的是芯片计数器功能,在记满相应数值,达到1s触发中断函数,对时间日期进行更新
中断函数:
void TIM2_IRQHandler(void){//定时器2
//主要运用时间更新
if(TIM_GetITStatus(TIM2, TIM_IT_Update) == SET){
TIM_ClearITPendingBit(TIM2, TIM_IT_Update);//清除标志位
Time_Control(time, month, date);
}
}
注意:时间显示中断抢占优先级要设置的比,串口输入中断等级低一点,否者串口传入数据会由于时间中断函数与其抢占CPU导致接受数据丢失,中断函数的名字及类型最好不要更改避免产生相应不必要的BUG
时间中断等级设置:
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;
—— 2023/10/19
3、温度&光照的测量:
主要用到了Stm32的数据转换功器(ADC)
通俗来说,正常的GPIO口只能读取0或1即低电平和高电平,而在低电平与高电平这一区间内的电平却无法描述,Stm32的ADC正可以解决这个问题,朴素的来讲ADC能让GPIO口成为一个电压表
而温度传感器,光敏传感器就是因为温度或光照的变化导致内部电阻阻值发生变化,最后导致其输出电压发生变化,GPIO口通过读取这些电压的具体量,再由人为对这些数据量做一些数据处理就能转换成我们想要的摄氏度、光照强度,以下是数据转换公式:
1.光照模拟量转实际光照强度:
uint16_t Function_RealityADLight(uint16_t ADCnum){//获取光照强度
return 100 - ADCnum / 40;
}
2.温度模拟量转实际摄氏度:
这个网上找的公式不能用,问了人家说只知道成线性关系,所以带入两组数据自己测了一个函数关系用起来还不错凑活着用:
int32_t Function_RealityTmperature(uint16_t Vout){//获取实际温度
double T = 0;//获取的实际温度
T = -0.0423 * Vout + 105 + 0.5;//模拟电压转实际温度公式
return (int32_t) T;
}
这个温度可以测出负温度,为了严谨,用了double变量转换温度
4、OLED显示优化:
OLED:
我对OLED的理解就是它一行有128个点,显示一个字母需要消耗这一行其中8个点,且字符是分两部分打印,上一半和下一半,一行最多打印16个字符,最多能打印四列
OLED程序源中提供了输出字符、清屏、以及输出符号类数字等功能,还是相当丰富的
但是为了让显示屏显示光照、温度的效果达到最佳,还是迫切需要对OLED库中扩充一些功能
1.解决输出无符号数字不足规定长度自动补零的缺陷:
OLED提供的源显示数据函数:
void OLED_ShowNum(uint8_t Line, uint8_t Column, uint32_t Number, uint8_t Length)
这个函数需要提前写入你要显示的数字长度,后续数字长度不足会在前面补零
如上述显示及其影响美观,所以添加一个能实时监控输入数字长度的函数:
uint8_t Function_Length(uint16_t num){
uint8_t length = 0;
if(num == 0) return (uint8_t) 1;
while(num > 0){
num = num / 10;
length = length + 1;
}
return length;
}
这不学的算法就用上了,注意0的长度是1
由于温度有正有负,所以需要一个检测有符号数字长度的函数:
uint8_t Function_SignedNum_Length(int32_t num){
uint8_t length = 1;//符号位占1位
if(num == 0) return (uint8_t) 1;
if(num < 0) num = - num;
while(num > 0){
num = num / 10;
length = length + 1;
}
return length;
}
由于OLED库函数的输出有符号数字事先未考虑计算符号长度,而是提前添加正负号,所以其也得修改
void OLED_ShowSignedNum(uint8_t Line, uint8_t Column, int32_t Number, uint8_t Length)
{
uint8_t i;
uint32_t Number1;
Length = Length - 1;
if (Number >= 0)
{
OLED_ShowChar(Line, Column, '+');
Number1 = Number;
}
else
{
OLED_ShowChar(Line, Column, '-');
Number1 = -Number;
}
for (i = 0; i < Length; i++)
{
OLED_ShowChar(Line, Column + i + 1, Number1 / OLED_Pow(10, Length - i - 1) % 10 + '0');
}
}
2.解决数据刷新造成的数据显示不连贯效果:
OLED数据刷新是采用覆盖式的例如上一次数据显示19
这一次要写入9
的时候不足位会补0
也就是显示09
,但由于上述操作让其位数不足不会补0,也就是说显示的将不会是09
而是显示99
即1被这次要写入的9覆盖掉了而后面的数据却没有被覆盖了,这涉及的就不是美不美观的问题了,直接就显示错了数据
所以必须要将后面的数清理掉,OLED库函数提供的只有清屏函数,简单粗暴全部清理掉,再全部写入
这样做有很多不便之处,原因在于整体清除刷新需要大量时间OLED显示起来整体就不连贯影响美观,还有就例如日期数据就没必要一直刷新,只有天数变了才需要刷新,没必要放在while循环内。所以迫切需要一个能清除某片区域的函数,以便能为美化OLED显示以及后续代码的优化提供功能支持
清除特定区域函数:
void OLED_ClearLocation(uint8_t row, uint8_t begin, uint8_t end)
{
uint8_t i, j;
for (j = row * 2 - 2; j < row * 2; j++)
{
OLED_SetCursor(j, begin * 8);
for(i = begin * 8; i < end * 8; i ++)
{
OLED_WriteData(0x00);
}
}
}
根据清屏函数重写一个清除某区域的函数,参数分别表示要删除的行,以及从哪里清除,到哪里结束。
3.添加OLED显示汉字功能:
利用取模软件对想要输出汉字进行取模,根据已有输出字符函数原理创建输出汉字函数
封装汉字数组:
const uint8_t OLED_F10x16[][32] ={
0x10,0x60,0x02,0x8C,0x00,0x00,0xFE,0x92,0x92,0x92,0x92,0x92,0xFE,0x00,0x00,0x00,
0x04,0x04,0x7E,0x01,0x40,0x7E,0x42,0x42,0x7E,0x42,0x7E,0x42,0x42,0x7E,0x40,0x00,/*"温",0*/
0x00,0x00,0xFC,0x24,0x24,0x24,0xFC,0x25,0x26,0x24,0xFC,0x24,0x24,0x24,0x04,0x00,
0x40,0x30,0x8F,0x80,0x84,0x4C,0x55,0x25,0x25,0x25,0x55,0x4C,0x80,0x80,0x80,0x00,/*"度",1*/
0x40,0x40,0x42,0x44,0x58,0xC0,0x40,0x7F,0x40,0xC0,0x50,0x48,0x46,0x40,0x40,0x00,
0x80,0x80,0x40,0x20,0x18,0x07,0x00,0x00,0x00,0x3F,0x40,0x40,0x40,0x40,0x78,0x00,/*光,2*/
0x00,0xFE,0x42,0x42,0x42,0xFE,0x00,0x42,0xA2,0x9E,0x82,0xA2,0xC2,0xBE,0x00,0x00,
0x80,0x6F,0x08,0x08,0x28,0xCF,0x00,0x00,0x2F,0xC8,0x08,0x08,0x28,0xCF,0x00,0x00,/*"照",3*/
0x00,0x08,0x30,0x00,0xFF,0x20,0x20,0x20,0x20,0xFF,0x20,0x20,0x22,0x2C,0x20,0x00,
0x04,0x04,0x02,0x01,0xFF,0x80,0x40,0x30,0x0E,0x01,0x06,0x18,0x20,0x40,0x80,0x00,/*"状",4*/
0x00,0x04,0x84,0x84,0x44,0x24,0x54,0x8F,0x14,0x24,0x44,0x84,0x84,0x04,0x00,0x00,
0x41,0x39,0x00,0x00,0x3C,0x40,0x40,0x42,0x4C,0x40,0x40,0x70,0x04,0x09,0x31,0x00,/*"态",5*/
};
显示汉字函数:
void OLED_ShowCHINESE(uint8_t Line, uint8_t Column, uint8_t Num)
{
uint8_t i;
uint8_t wide = 16;//字宽
OLED_SetCursor(( Line - 1 ) * 2, ( Column - 1 )* wide); //参数1:把光标设置在第几页. 参数2:把光标设置在第几列
for (i = 0; i < wide; i++)
{
OLED_WriteData(OLED_F10x16[Num][i]); //显示上半部分内容
}
OLED_SetCursor(( Line - 1 ) * 2 + 1,( Column - 1) * wide);
for (i = 0; i < wide ; i++)
{
OLED_WriteData(OLED_F10x16[Num][i+wide]); //显示下半部分内容
}
}
参考来源:OLED显示汉字及屏幕滚动
5、主函数的设计与布局:
为了使主函数布局清晰,所以对主函数的代码进行了最大可能的优化,采用函数嵌套函数的方式,反正Stm32函数已经够多了我再添加一点也很核理,这样做的优点就是思路清晰,后续添加新功能的时候也很方便明了,缺点就是查bug的时候得来回跳转,不过我是确认没有大bug的情况下才开始优化的
主函数:
#include "ADC.h"
#include "OLED.h"
#include "Time.h"
#include <stdio.h>
#include "Delay.h"
#include <stdlib.h>
#include "Serial.h"
#include "Function.h"
#include "stm32f10x.h"
char *News = NULL; //存数据
int main(void){
AD_Init(); //开启ADC
OLED_Init(); //开启显示器
Time_Init(); //开启计时器
Serial_Init(); //开启串口允许接收数据
Function_ShowTransmit(); //显示接收数据的状态
Time_ShowDate(Time_GetArrayDate()); //显示日期
while(1){
Time_Show(Time_GetArrayTime()); //时刻显示温度
ADC_printf(ADC_Channel_0, ADC_Channel_1); //时刻获取温度&光照强度并显示
if(Serial_GetRxFlag() == 1){ //时刻监控标志位
News = Serial_returnNews(); //获取传输的数据
Function_DP(Serial_GetFlagTime(), Serial_GetFlagDate(), Time_GetArrayTime(), Time_GetArrayDate(), News); //对获取的数据进行筛选处理并自我更新
Serial_StateRecovery(News); //数据恢复初始化
}
}
}
与主函数搭配的库函数:
3. 总结:
明白了学习嵌入的过程就是理解并能灵活使用其提供的库函数的过程
要有自己的创新与解决问题的能力
将功能尽可能封装,能使代码思路更加清晰
4.程序源码:
链接:https://pan.baidu.com/s/1WsZ7dTRUMHRa8jaXDtmrpA?pwd=ws6f
提取码:ws6f
—— 2023/10/18