目录
一、需求分析
建立一套适用于尽量多的蓝桥杯嵌入式项目的方法,包括从建立项目开始、使用开发板上的全部外设到完成比赛。由于比赛利用了CubeMX开发方式,那么我们的思维也要符合CubeMX。按照CubeMX的思想,我们只要记住整体步骤,到比赛时进行剪裁即可。
省赛涉及的外设和可能的考点:
GPIO | 8个LED、4个按键 |
会提供驱动程序 | LCD屏幕、I2C通信和可编程电阻 |
通用定时器的PWM输入和输出 | 2个频率输入 |
ADC | 2个电压采集、读可编程电阻电压值 |
RTC | 时间显示和闹钟 |
USART1 | 与上位机通信 |
定时器 | 融合在其他考点中 |
NVIC | 融合在其他考点中 |
二、通用步骤
设计流程:
- 浏览一遍题目,有一个整体印象。
- 仔细读题目,综合考虑如何配置外设和实现功能。
- 确定需要的外设和功能。约定圆形为外设分析、三角型为功能分析,图形中的数字来区分不同的模块。
- 设计流程,主循环前做什么、主循环中做什么。
- 确定主循环前打开需要的外设
- 根据题目一步一步的设计流程,不要遗漏,字不要写的太大,而且要留有足够的空间用来修改。
- 流程包括一条主线任务,调用函数就是支线任务。
- 同时在另一张草稿纸上写下需要的变量,按照外设或题目中的信息命名,要有特征性和区分度。
- 这样设计下来最多1小时,按照设计配置外设和写程序最多2小时,检查和优化1小时,最多4个小时就完成了。
-
全局变量定义:
经验规则:
标志变量:uint8_t ,只取个位数或0,1
与计算有关或者需要显示:uint32_t
浮点型:float
-
检查和优化:
-
备份项目
-
仔细根据题目,检查功能是否符合题目描述,然后备份项目
-
将代码中有警告部分想办法修复,将某些变量的类型缩小,减小内存占用。
-
建立项目的步骤:
注意:
TIM、ADC、USART有多个通道,需要选择引脚做配置!
打开MDK后,先编译一下,建立文件之间的关联
include操作:
在main.h中的对应代码段进行引入文件。
/* USER CODE BEGIN Includes */
#include "lcd.h"
#include <stdio.h> //LCD用
#include <string.h> //USART用
/* USER CODE END Includes */
定义:
变量:
建议全部定义为全局变量,因为用到的变量不多,而且大多数都需要全局使用,这样便于管理。
方法:在main.c中定义变量,然后在main.h中使用extern语句将其改为全局变量。
宏:
在main.h中定义宏。
函数:
在main.c或stm32g4xx_it.c中的对应代码段中实现函数,需要全局使用的函数需要在main.h中定义。
关于中断:
系统时钟中断要设置为0,所有自己打开的中断优先级不能为0(一般设置为1),防止冲突。
所有的中断相关的函数和操作都在stm32g4xx_it.c中进行,中断服务函数的框架在该文件的最下面的区域。
中断回调函数全部在stm32g4xx_it.c中最下面的用户代码段重新实现。
三、可能用到的配置和函数:
LED:
根据原理图,选择PC8~PC15引脚控制LED,PD2引脚控制锁存器。
- LED控制引脚配置为:推挽输出。
- PD2引脚配置为:推挽输出,低电平。
- 添加用户标签。
直接在main.c的用户代码段实现下面的函数,可以用 " | " ,连接多个LED_Pin实现多个灯一起操作。
void LED_switch(uint16_t GPIO_Pin,GPIO_PinState PinState)
{
HAL_GPIO_WritePin(LED_LE_GPIO_Port,LED_LE_Pin,GPIO_PIN_SET);
HAL_GPIO_WritePin(GPIOC,LED1_Pin|LED2_Pin|LED3_Pin|LED4_Pin|LED5_Pin|LED6_Pin|LED7_Pin|LED8_Pin,GPIO_PIN_SET);
HAL_GPIO_WritePin(GPIOC,GPIO_Pin,PinState);
HAL_GPIO_WritePin(LED_LE_GPIO_Port,LED_LE_Pin,GPIO_PIN_RESET);
}
KEY:
PB0~PB2、PA0
驱动函数:可以直接在main.c里写。
软件去抖动延时90。
//按键1响应函数
void B1Fun()
{
}
//按键2响应函数
void B2Fun()
{
}
//按键3响应函数
void B3Fun()
{
}
//按键4响应函数
void B4Fun()
{
}
//按键扫描
void KeyScan()
{
uint32_t keyDelay=90;
if(HAL_GPIO_ReadPin(B1_GPIO_Port,B1_Pin)==GPIO_PIN_RESET)
{
HAL_Delay(keyDelay);
if(HAL_GPIO_ReadPin(B1_GPIO_Port,B1_Pin)==GPIO_PIN_RESET)
{
B1Fun();
}
}
if(HAL_GPIO_ReadPin(B2_GPIO_Port,B2_Pin)==GPIO_PIN_RESET)
{
HAL_Delay(keyDelay);
if(HAL_GPIO_ReadPin(B2_GPIO_Port,B2_Pin)==GPIO_PIN_RESET)
{
B2Fun();
}
}
if(HAL_GPIO_ReadPin(B3_GPIO_Port,B3_Pin)==GPIO_PIN_RESET)
{
HAL_Delay(keyDelay);
if(HAL_GPIO_ReadPin(B3_GPIO_Port,B3_Pin)==GPIO_PIN_RESET)
{
B3Fun();
}
}
if(HAL_GPIO_ReadPin(B4_GPIO_Port,B4_Pin)==GPIO_PIN_RESET)
{
HAL_Delay(keyDelay);
if(HAL_GPIO_ReadPin(B4_GPIO_Port,B4_Pin)==GPIO_PIN_RESET)
{
B4Fun();
}
}
}
LCD:
在LCD上显示其他格式的数据或者需要格式化字符串,要用到<stdio.h>库中的 sprintf 函数:
char LCD_Str[20];
sprintf(LCD_Str,"Num = %d",666);
在main函数中初始化HAL库之后,就初始化LCD、设置颜色、用背景色清屏。因为中断会干扰LCD初始化。
/* USER CODE BEGIN Init */
LCD_Init();
LCD_SetBackColor(Black);
LCD_SetTextColor(White);
LCD_Clear(Black);
/* USER CODE END Init */
最常用的就是写字符串函数和清屏:(注意:需要手动添加空格)
void LCD_DisplayStringLine(u8 Line, u8 *ptr);
void LCD_Clear(u16 Color);
I2C:
【蓝桥杯嵌入式】四、各种外设驱动(十)I2C通信和可编程电阻的操作-天狼IoT
I2C驱动函数:(有些提供的函数名不同,但功能相同)
操作方法:
//初始化代码段
I2CInit();
char wBuf[5]={0}; //写数据缓冲区、有时需要定义为全局变量
char rBuf[5]={0}; //读数据缓冲区、有时需要定义为全局变量
//初始化代码段
//读取芯片值
iic_24c02_read(rBuf,1,5); //在芯片的1区,读5个单位数据rBuf
//读电阻设置值
RES_4017=read_resistor();
//写入芯片的操作
sprintf(wBuf,"%d",num); //复制数据,并将类型转换为字符串
iic_24c02_write(wBuf,1,5); //在芯片的1区,写5个单位数据wBuf
HAL_Delay(5); //存储芯片的写入操作需要时间,这里必须延时
//写电阻设置值
write_resistor(num);
Base TIM:
【蓝桥杯嵌入式】四、各种外设驱动(五)基础定时器-天狼IoT
周期定时模式:
- Prescaler 要设置为X((X+1)=APB1 Timer/(Counter Period+1) ,由于从0开始,所以这里实际上是X+1)
- Counter Period ,根据需要计算。
- 如果用到定时器中断服务函数,则需要在NVIC中打开中断,否则不用打开中断,如果不清楚就打开中断。
MDK代码:以TIM6为例
两种常用的进入主循环前的函数:
//以中断方式启动定时器,一般在独立使用定时器时使用
HAL_TIM_Base_Start_IT(&htim6);
//软件启动定时器,一般在用于触发其他中断时使用
HAL_TIM_Base_Start(&htim6);
中断服务函数名称:
void TIM6_DAC_IRQHandler(void)
PWM输入:
【蓝桥杯嵌入式】四、各种外设驱动(六)通用定时器(2):利用定时器输入捕获模式读取PWM波(常考)_天狼IoT
引脚对应:
- TIM3_CH1/TIM16_CH1——PB4——频率输出1
- TIM2_CH1/TIM8_CH1——PA15——频率输出2
CubeMX设置:
将Combined Channels(结合渠道)设置为PWM Input on CH1模式
定时器基本参数:
- 预分频系数:Prescaler
- 定时器周期:Counter Period
定时器模式参数:
- 捕获极性:Polarity Selection (根据要读取的PWM波形选择,一般习惯是:如果PWM波以上跳沿开始那么CH1就设置为上跳沿有效)
- 打开对应的定时器中断,优先级为1。
MDK代码:
进入主循环前函数:
HAL_TIM_Base_Start(&htim3);
HAL_TIM_IC_Start_IT(&htim3,TIM_CHANNEL_1);
HAL_TIM_IC_Start_IT(&htim3,TIM_CHANNEL_2);
中断服务函数名称:
void TIM3_IRQHandler(void)
读取比较寄存器的值:
//读取周期值
uint16_t IC1_width = __HAL_TIM_GET_COMPARE(&htim3,TIM_CHANNEL_1);
//读取占空值
uint16_t IC2_pluse = __HAL_TIM_GET_COMPARE(&htim3,TIM_CHANNEL_2);
频率计算公式:
PWM波的频率 = 定时器频率/CH1读取的值
定时器频率 = APB1 Timer/(预分频系数+1);
PWM输出:
CubeMX设置:
PWM输出很简单,只需要记住CubeMX中4个具体量的设置,以及使用三个函数就可以玩转PWM输出。
配置:
- 通过设置 Prescaler 来设置定时器频率
- 通过设置 Counter Period 来设置周期值
- 通过设置 Pulse 来设置占空值
- 通过设置 CH Polarity 来设置极性
- 根据题目确定配置,如果要配置%的占空比,周期就设置为100的倍数。然后根据题目和便于计算的原则,设置预分频系数。
MDK代码:
进入主循环前函数:
//启动对应定时器和通道
HAL_TIM_Base_Start_IT(&htim16);
HAL_TIM_PWM_Start_IT(&htim16,TIM_CHANNEL_1);
在程序中修改预分频系数、周期、占空比的函数如下,注意差1性。
__HAL_TIM_SET_PRESCALER(设备句柄地址,预分频系数) //设置预分频系数0~65535
__HAL_TIM_SetAutoreload(设备句柄地址,计数值) //设置溢出值,即周期
__HAL_TIM_SET_COMPARE(设备句柄地址,通道号,脉宽值); //设置占空比
PWM波的频率设置计算公式:
周期 =( 定时器频率/PWM波的频率)-1;
定时器频率 = APB1 Timer/ (预分频系数+1);
如:APB1 Timer为80MHz时,设置PWM的频率为8KHz:
由于APB1 Timer=80MHz,周期设置为99
所以
99 =( 定时器频率/8KHz)-1; →定时器频率=800KHz
800KHz =80MHz/(预分频系数+1);→预分频系数=99
ADC:
【蓝桥杯嵌入式】四、各种外设驱动(九)ADC(1):软件触发与中断触发方式-天狼IoT
【蓝桥杯嵌入式】四、各种外设驱动(九)ADC(2):双ADC同步转换方式-天狼IoT
引脚对应:
- ADC1_IN11——PB12——R38(电压采集2)
- ADC2_IN15——PB15——R37(电压采集1)
CubeMX设置:
- ADC Mode:选择需要的通道,选择single_ended
- Resolution:转换精度,一般保存默认值12
- External Trigger Conversion Source:中断触发方式
- External Trigger Conversion Edge:外部触发时使用的信号沿
- Rank里的Sampling Time:采样时间,不设置也行,建议比赛时为提高准确性设置为640.5。
- 打开ADC中断,优先级为1,无需打开对应的TIM的中断。
同步转换模式需额外设置:
先打开ADC2_IN15和ADC1_IN11,均选择single_ended模式。
在ADC1_IN11的配置中
- ADCs_Common_Settings设置为Dual regular simultaneous mode only(双规则同步转换模式)
- DMA Access Mode(DMA接入方式)设置为DMA access mode enable
- DMA Continuous Requests:是否连续产生DMA请求
- External Trigger Conversion Source:设置为Timer x Trigger Out event,使用定时器TIMx产生的UEV中断触发ADC转换。
- DMA Settings:
ADC1只有一个DMA请求,为这个DMA请求配置DMA 通道(任意通道都行),设置DMA 传输属性参数,在DMA Request Settings组中将Mode(工作模式)设置为Circular(循环模式),将外设和存储器的数据宽度都设置为Word。
- 打开对应定时器
- ADC2一般不用再做设置,但要注意:
ADC2除了Rank通道号外的其他可配置参数参赛都要和ADC1一致,以保证两个ADC能同步采集。
MDK代码:
进入主循环前函数:
//独立模式
HAL_ADC_Start_IT(&hadc1);//以中断方式启动ADC
HAL_ADC_Start(&hadc2);//以软件方式启动ADC
//同步模式
HAL_ADCEx_MultiModeStart_DMA(&hadc1,dmaDataBuffer,DATA_LEN);//以DMA方式启动ADC1
HAL_ADCEx_MultiModeStart_DMA(&hadc2,dmaDataBuffer,DATA_LEN);//以DMA方式启动ADC2
中断回调函数:
//同步模式下可以使用中断服务函数,也可以使用回调函数
void DMA1_Channel1_IRQHandler(void)
//独立模式下必须区分是哪个ADC引发中断,必须使用回调函数
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc);
ADC功能函数:
HAL_ADC_PollForConversion(&hadc1,200);//查看ADC的转换是否完成
HAL_ADC_GetValue(&hadc1);//读取ADC转换后的结果并返回
转换结果电压计算公式:
实际电压值(mV)=ADC读取值*VREF(mV)/2^(转换精度)
VREF是ADC转换的参考电压,开发板上理论是3300mV;转换精度可以在CubeMX中设置,默认为12;
在代码中描述如下:
uint32_t Volt=3300*HAL_ADC_GetValue(&hadc2);
Volt = Volt>>12;
同步模式下的数据读取:
双ADC同步转换方式中低16位是ADC1的数据,高16位是ADC2 的数据。为得到各自的数据需要做如下处理:
uint32_t adcValue=dmaDataBuffer[0]; //自定义变量dmaDataBuffer是DMA读取数据的缓冲区
uint32_t ADC1_val=adcValue&0x0000FFFF;
uint32_t ADC2_val=(adcValue&0xFFFF0000)>>16;
常用方式:
if(HAL_ADC_PollForConversion(&hadc1,200)==HAL_OK)//查看ADC的转换是否完成
{
uint32_t Volt=3300*HAL_ADC_GetValue(&hadc2);
Volt = Volt>>12;
}
RTC:
【蓝桥杯嵌入式】四、各种外设驱动(七)RTC实时时钟--时间显示、周期唤醒和闹钟-天狼IoT
周期唤醒与闹钟是两个独立的功能,可以分开使用。
CubeMX设置:
时钟配置界面的左上角有RTC的时钟配置,使用LSI时钟。
1、RTC基本参数设置:
- Hour Format,小时格式,可以选择12 小时制或24小时制。
- Output Polarity,输出极性。闹钟A、闹钟B、周期唤醒中断事件信号有效时的输出极性,可设置为高电平或低电平。
Calendar Time 和 Calendar Data 分组里有用于设置日历的时间参数和初始化数据的如下参数:
- Data Format,数据格式,可选择二进制格式或BCD格式,这里选择Binary data forms
- 初始化时间数据,包括时、分、秒的数据。
- 初始化日期数据,包括年、月、日的数据。
2、Alarm 闹钟设置:
- 设置闹钟时间数据,包括时、分、秒的数据。
- Mask项:设置为Enable表示屏蔽某时间值对闹钟的影响。
3、周期唤醒设置
- Wake Up Clock 可以设置为各种分频信号,一般设置为1Hz,即每秒一次。
- Wake Up Counter,周期唤醒重载值,有差一性,设置为0表示值为1。
4、中断设置
RTC的中断唤醒和闹钟的中断的中断优先级都设置为1。
MDK代码:
中断服务函数/回调函数名称:
RTC_WKUP_IRQHandler(void);
HAL_RTC_AlarmAEventCallback(hrtc);//对应Alarm A
HAL_RTCEx_AlarmBEventCallback(hrtc);//对应Alarm B
RTC函数名称:
注意:这两个函数必须一起使用,不管你用不用的着,因为必须取出全部结果才可以重新写入。
HAL_RTC_GetTime(&hrtc,&sTime,RTC_FORMAT_BIN);//获取当前时间
HAL_RTC_GetDate(&hrtc,&sData,RTC_FORMAT_BIN);//获取当前日期
时间显示常用框架:
/* USER CODE END RTC_WKUP_IRQn 0 */
HAL_RTCEx_WakeUpTimerIRQHandler(&hrtc);
/* USER CODE BEGIN RTC_WKUP_IRQn 1 */
RTC_TimeTypeDef sTime;
RTC_DateTypeDef sData;
if(HAL_RTC_GetTime(&hrtc,&sTime,RTC_FORMAT_BIN) == HAL_OK)
{
HAL_RTC_GetDate(&hrtc,&sData,RTC_FORMAT_BIN);
char str[20];
sprintf(str,"Data:20%2d-%2d-%2d",sData.Year,sData.Month,sData.Date);
LCD_DisplayStringLine(Line3,str);
sprintf(str,"Time:%2d:%2d:%2d",sTime.Hours,sTime.Minutes,sTime.Seconds);
LCD_DisplayStringLine(Line5,str);
}
/* USER CODE END RTC_WKUP_IRQn 1 */
USART1:
【蓝桥杯嵌入式】四、各种外设驱动(八)USART+DMA通信方式和串口通信协议的设计与使用-天狼IoT
注意:
!!容易漏掉的步骤:选择PA9和PA10作为USART1的信号引脚
使用串口的时候有时要关闭MDK,因为会产生干扰。
CubeMX设置:
USART:
- Mode:工作模式。一般设置为Asynchronous(异步);
- Baud Rate:波特率。CubeMX默认为115200bit/s,一般题目要求设置成9600bit/s。
- Data Direction:数据传输方向,保持默认的双向传输。
DMA:
- Add两个DMA通道,会自动设置通道号和传输方向;将RX的优先级设置为中优先级,模式设置为循环模式;TX保持默认。
打开USART1的中断设置优先级为1,两个DMA通道的中断会自动打开,优先级设置为1。
MDK代码:
进入主循环前函数:
//rxBuf:接收数据缓冲区; cmdlen:接收数据的大小
HAL_UART_Receive_DMA(&huart1,rxBuf ,cmdlen);
中断服务函数:
//RX对应的通道的中断服务函数,这里是通道1
DMA1_Channel1_IRQHandler(void)
USART函数:
//txBuffer:要发送的数据指针
HAL_UART_Transmit_DMA(&huart1 ,txBuf ,strlen(txBuffer));
注意:
向上位机发送数据时,有时需要在最后加上 \n 来显示换行。
自定义的接收数据解析函数:
根据题目要求,这里提供一个参考例子:
先简单列个表:
上位机发送的指令字符串 | 指令功能 |
---|---|
#Hxx; | 设置小时,将RTC时间的小时改为xx |
#Mxx; | 设置分钟,将RTC时间的分钟改为xx |
#Sxx; | 设置秒,将RTC时间的秒改为xx |
#U01;或#U00; | 设置上传数据,或不上传数据 |
然后分解指令,数字类型要减去字符 '0' :
/* USER CODE BEGIN 1 */
void updataRTCTime(void)
{
if(proBuffer[0] != '#')
return;
uint8_t timeSection=proBuffer[1];
uint8_t tmp10=proBuffer[2]-'0';
uint8_t tmp1=proBuffer[3]-'0';
uint8_t val=10*tmp10+tmp1;
if(timeSection == 'U')
{
isUploadTime=val;
return ;
}
RTC_TimeTypeDef sTime;
RTC_DateTypeDef sDate;
if(HAL_RTC_GetTime(&hrtc ,&sTime ,RTC_FORMAT_BIN)==HAL_OK )
{
HAL_RTC_GetDate(&hrtc ,&sDate ,RTC_FORMAT_BIN );
if(timeSection == 'H')
sTime.Hours = val;
else if (timeSection == 'M')
sTime.Minutes = val;
else if (timeSection == 'S')
sTime.Seconds = val;
HAL_RTC_SetTime(&hrtc ,&sTime ,RTC_FORMAT_BIN );
}
}
/* USER CODE END 1 */
四、容易出错的点
- 不能总是清屏,应该改成在按下按键或方式特定事件后清屏;
- 按键延时就设置为90就行,不快也不慢;
- 如果开发板反复无常,就是硬件有问题,要求更换!
- 在会引起值减小的位置添加清屏,或者在写LCD显示函数的时候格式化数字的位数。
- 计算的步骤要从简
- 关于时间的操作可以在TIM中断服务函数中进行的,就不要拿出来
- 要使用定时器中断服务函数,就必须用中断方式打开或关闭定时器!
- PWM输出功能,需要在进入主循环前打开对应定时器、打开PWM输出!
- PWM输入功能,需要在进入主循环前打开对应定时器,还有对应的两个通道!
- 我发现题目没有让我们交.ioc文件,所以我们应该把所有的Src和Inc中的文件都提交。
- 串口
- 接收缓冲区的大小需要用sizeof()来确定,一般题目设置为定值即可。
- 发送一般需要加换行符,所以发送缓冲区的大小需要用strlen()来确定。
- 发送缓冲区要设置为一个有足够且大小的确定的数组。
- 串口接收函数要在处理前延时50ms。
- LED的闪烁问题,不可以使用Toggle函数;因为LCD会对引脚的状态产生影响。应该使用标志变量,或利用奇偶数来实现状态切换。
- 不知道为什么,我的开发板上一打开TIM就会产生一次超时中断;解决方法是设置一个计数变量为0,只要调用中断服务函数就自增1,当计数为2时再实现计划的功能。
- 在LED驱动函数中包含关闭所有LED灯的操作,只需要在进入while前关闭一次LED即可。
- 有时需要在中断中清屏,但是中断的位置不确定;所以要设置一个全局标志,在中断改变标志,然后在需要清屏的地方判断标志位来清屏。
- LCD驱动函数的Line是从0开始的而题目要求中写的行是从1开开始的。
- 发送的字符较长时,发现实际字符长度和理论有区别,比赛时要注意,接收缓冲区要略大于理论长度,可以根据串口调试助手的接收结果开进行调整。
- 做题过程中被卡住又无法解决,就果断跳过!要仔细检查,其实不少小而简单的问题可能没有被解决。