- by: Du.Weitao 1024670978@qq.com
实验环境准备
- 开发板选型: 使用正点原子 STM32F1-NANO 开发板
- 从百度云盘获取 STM32F1-NANO 资料包
- 安装 编译开发工具包,MDK5,用于编译代码、从仿真器下载代码、调试
- 安装 ST-LINK驱动,用于从编译环境连接开发板
跑马灯程序实验
实践内容
- 将HAL版本 实验1 跑马灯实验 工程项目拷贝出来到临时文件夹
- 使用uVision V5 打开工程项目,编译,下载,观察开发板上的灯闪烁
- 观察参考代码中的控制LED亮灭的代码段
- 参考代码只控制了2个LED的闪烁
- 查阅开发板的IO分配表,确定其他LED灯和处理器的IO 管脚 对应关系
- 修改代码,控制LED DS0-DS7,实现一下闪烁图样
- DS0 亮 ~ DS0 灭;DS1 亮 ~ DS1 灭......DS7 亮 ~ DS7 灭
- 首先使用“硬编码”的方式实现上述功能,即为每个灯的动作写一行代码
- 然后考虑能否使用循环结构来实现。
调试技能:查看原型定义
- uVision 工具提供了非常有用的功能
- 查看某个被调用函数的原型定义代码
- 具体的操作为:
- 首先编译工程代码,确定没有编译错误
- 在uVision的代码编辑器中
- 把光标定位在感兴趣的函数所在的行, 例如函数 foo()
- 鼠标右键,选择- Go to Defination of ‘foo'
- 则编辑器跳转到 函数 foo()的原型代码所在的文件位置
- 以上操作同样适用于 查看 宏定义, 全局变量。
常用函数:向GPIO的PIN管脚写入1或0
// 向 GPIO C 的 PIN 0 写入 0
HAL_GPIO_WritePin(GPIOC,GPIO_PIN_0,GPIO_PIN_RESET);
// 向 GPIO C 的 PIN 0 写入 1
HAL_GPIO_WritePin(GPIOC,GPIO_PIN_0,GPIO_PIN_SET);
背景知识:时钟结构
- STM32处理器芯片 时钟结构如下图
- 系统时钟,SYSCLK,有多个的源可以选择,包括外部和内部的时钟。
- 有多个的片上外设模块需要提供时钟,这些外设时钟可以从SYSCLK分频得到
背景知识:GPIO 配置和管脚复用
- 管脚复用:数据用于处理器的读写,或者用于其他片上外设模块的读写
- 推挽输出:提供驱动电平
- 开漏输出:不提供驱动电平,用于实现“线与”逻辑,用于器件总线的互联
- 数字输入模式下:用肖特基触发器将模拟信号整理为0-1 方波
- 模拟输入模型下:将信号传递给片上的ADC采样器
背景知识:硬件配置和初始化
- 如上文内容所示:各个硬件模块有多种工作模式
- 比如:时钟源的输入来源,各个片上模块的工作时钟分频
- 比如:GPIO的功能复用;输入、输出;驱动方式等。
- 因此,在使用硬件模块之前,需要对其工作模式进行配置(config)和初始化(initial),然后才能使用
按键输入实验
实践内容
- 将HAL版本 实验3 按键实验 工程项目拷贝出来到临时文件夹
- 使用uVision V5 打开工程项目,编译,下载,根据实验手册内容进行操作,观察LED灯的亮灭和蜂鸣器的声音。
- 修改代码,得到以下效果
- 去掉对其他按键的响应,仅保留对KEY0 的响应
- 系统上电后,全部LED,DS0~7 全部点亮约0.5 秒后,全部熄灭。
- 第一次按下KEY0后,DS0点亮,其余LED熄灭;再次按下, DS1点亮,其余LED熄灭;
- 以此重复至按下键,DS7点亮,其余熄灭。然后再重复至按下键,DS0点亮, ......
背景知识:HAL库函数:读取 GPIO 的PIN管脚数值
- 参考代码中为了简洁,使用宏定义封装了读取GPIO的PIN管脚数值的函数
- 读取GPIO的PIN管脚值的函数如下:
// 读取 GPIO C 的 PIN 8 的值
HAL_GPIO_ReadPin(GPIOC,GPIO_PIN_8)
- 在代码 /HARDWARE/key.h 中,可以找到宏定义如下:
#define KEY0 HAL_GPIO_ReadPin(GPIOC,GPIO_PIN_8) //KEY0按键PC8
#define KEY1 HAL_GPIO_ReadPin(GPIOC,GPIO_PIN_9) //KEY1按键PC9
#define KEY2 HAL_GPIO_ReadPin(GPIOD,GPIO_PIN_2) //KEY1按键PD2
#define WK_UP HAL_GPIO_ReadPin(GPIOA,GPIO_PIN_0) //WKUP按键PA0
背景知识:按键去抖动
- 按键是一个含有机械部件的电路,当按键被按下或者抬起时。会产生多次的导通或断开的动作。
- 即一次按键按下或抬起的动作,从示波器上观察,会产生多次的高低电平的抖动。
- 因此,需要对按键进行去抖,把一次按键动作引起的多次电平高低变化,处理成为一次按键动作。
- 通常来说,抖动电平的时间通常小于10毫秒
- 因此,读取按键值时,通常采用延迟读取的策略
- 即,当按键电平发生变化时,延迟10毫秒(大约),再次读取
- 则延迟后读取的按键信号电平值,是一个稳定的按键值。
UART 串口通信实验
实验操作 1
- 拷贝 开发板参考代码,实验4 串口通信实验, 到临时目录
- 按照 开发板实验指导部分内容, 完成操作, 观察效果
实验操作 2
- 修改 参考代码文件 /SYSTEM/usart.c 中,关于串口中断的数据处理部分的代码,如下:
- 有两处修改,首先 加入全局变量 ,rx_cnt 的定义,用于统计接收的字符数
- 然后 修改串口中断的数据处理部分代码,函数:HAL_UART_RxCpltCallback()
// usart.c 中断完成后数据处理的代码
unsigned int rx_cnt = 0; // 定义全局变量
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if(huart->Instance==USART1)//如果是串口1
{
USART_RX_BUF[rx_cnt]=aRxBuffer[0] ;
rx_cnt ++;
}
}
- 修改 参考代码 /USER/main.c ,有两处修改
- 首先 加入全局变量 ,rx_cnt 的声明
- 然后 修改 main 函数, 打印接收缓冲的字符数量, 全部的接收数据缓冲区内容
extern unsigned int rx_cnt ; // 声明全局变量
int main(void)
{
HAL_Init(); //初始化HAL库
Stm32_Clock_Init(RCC_PLL_MUL9); //设置时钟,72M
delay_init(72); //初始化延时函数
uart_init(115200); //初始化串口115200
LED_Init(); //初始化LED
KEY_Init(); //初始化按键
int i = 0;
while(1)
{
printf("Total RX %d Bytes: ", rx_cnt);
for(i = 0; i < rx_cnt; i ++){
printf("0x%X ", (uint8_t*)USART_RX_BUF[i]);
}
printf("\r\n");
delay_ms(400);
}
}
- 修改完以上代码后,重新编译代码,下载代码。
- 在串口调试助手中,向实验板发送若干字符,从串口调试助手界面,观察打印结果
- 根据ASCII码表,确认发送字符的16进制数据
背景知识 中断向量表和中断服务函数
; Vector Table Mapped to Address 0 at Reset
AREA RESET, DATA, READONLY
EXPORT __Vectors
EXPORT __Vectors_End
EXPORT __Vectors_Size
__Vectors DCD __initial_sp ; Top of Stack
DCD Reset_Handler ; Reset Handler
DCD NMI_Handler ; NMI Handler
DCD HardFault_Handler ; Hard Fault Handler
背景知识 UART数据传输协议
- UART ,U通用 A异步 S串行 T传输
- UART ,是一种重要的通信接口,广泛用于大量的电子模块和器件当中。
- UART 有若干中传输参数,比如波特率,数据位数,停止位的位数,奇偶校验 等。
- 使用UART协议通信的双方需要事先约定好这些配置参数,才能正确传输数据。
- 最简单的UART协议,仅使用2根信号学,TX,RX,和一根地线GND,即可以完成双向通信
- 因为使用的信号数量较少,易于布线,UART得到了广泛应用
- STM32的USART是UART的一个升级版,S(Synchronous)表示 带有可选的时钟信号,使其可以工作在更高的传输速率
- 下图给出了一个带有奇偶校验的,1比特停止位的 UART传输信号时序
- 注意事项,UART是一种低速通信协议, 通常一个数据帧(例如,一个字节)的发送时间,远远于处理器的指令周期
- 因此,编写代码时,如果使用了UART传输数据,需要考虑从启动传输到传输完成的时间消耗。否则代码执行的速度可能会变得及其卡顿
背景知识 用 __weak 修饰符 重载STM32的库函数
- STM32的开发工具链,对C语言进行了扩展,使得用户可以根据需要,重新改写一些库函数的执行代码内容
- 带有__weak修饰符的函数,是一个可以由用户重载(重新编写其执行动作)的函数
- 用户可以重新编写被 _weak 修饰符 描述过的库函数的内容。例如:
- 本实验代码中,/SYSTEM/usart.c 中,有 函数 HAL_UART_MspInit 的原型代码
- 另一方面, 32F1xx_HAL_Driver32f1xx_hal_uart.c 文件的 551行,有以下代码定义:
__weak void HAL_UART_MspInit(UART_HandleTypeDef *huart)
{
/* Prevent unused argument(s) compilation warning */
UNUSED(huart);
/* NOTE: This function should not be modified, when the callback is needed,
the HAL_UART_MspInit could be implemented in the user file
*/
}
- 从中可以看到, HAL_UART_MspInit()函数,实际是一个系统定义的库函数,但是由于在用户的目标系统中,该函数的使用场景不确定。
- 因此,需要用 __weak 关键字把该函数声明为可重载的,让用户根据使用场景,自行编写其代码内容
- 这样做的好处是,首先保证了HAL_UART_MspInit()函数执行的正确流程(由其他系统函数调用),同时,给用户保留了灵活定义其功能的自由度
背景知识 printf 输出重定向
- 本例中:使用了printf()函数打印输出接收到的字符信息
- printf() 函数输出的信息,通过串口调试助手来观察
- 在C语言规范中,printf打印的目标是标准终端,stdout
- 因此,STM32的开发环境中,支持把stdout定向到某个串口上
- 打开参考代码文件 /SYSTEM/usart.c 观察以下代码:
#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) {
while((USART1->SR&0X40)==0);//循环发送,直到发送完毕
USART1->DR = (u8) ch;
return ch;
}
#endif
- 由此可见, 库函数代码通过重新定义了
- 编译器内嵌变量 __FILE
- 标准输出 __stdout
- 以及 printf()函数需要调用的 fputc() 函数
- 把 printf()函数的输出目标,重新定向到USART1 串口
- 因此,需要特别注意的是:
- 由于 USART是一个低速输出设备,从而导致
- printf() 函数,打印字符串的速度远小于处理器指令周期
- 因此在一些时序紧张的代码中,使用printf() 函数会引入大量的延迟时间。
- 通常的策略是:在时序紧张的高速代码中,把关键数据信息保存到内存变量中。
- 然后在时序不紧张的代码段,使用printf()函数统一打印输出这些关键数据信息
GPIO 中断 实验
实验操作1
- 重现实验板提供的参考代码
- 拷贝参考代码, 实验5 外部中断实验, 到临时目录
- 根据实验板配套文档,编译下载代码,重现实验操作
实验操作2
- 按照如下操作,修改代码文件 /USER/main.c
- 将main.c 中 的while 循环的内容修改如下
while(1)
{
printf("KEY0=%d, KEY1=%d, KEY2=%d, WK_UP=%d \r\n",KEY0, KEY1, KEY2, WK_UP);
delay_ms(300); //延迟打印
}
- 编译下载代码,观察串口的打印结果
- 分别按下不同的按键,观察串口打印结果中数据的变化。
背景知识:触发类型
- GPIO 中断的产生,和GPIO的输入电平变化有关
- 当电平信号满足触发条件时,则中断系统认为,产生了触发事件,将中断状态寄存器里面的标志位置为有效。
- 触发条件可以设定为上升沿触发、下降沿触发
背景知识:中断的优先级和中断复用
- 中断优先级用来确定某个中断源产生中断事件时,该中断被处理器响应的次序
- 当两个中断事件同时产生时,处理器首先跳转到优先级高的中断的服务函数。
- 中断复用是指,多个中断源共享一个中断向量(中断服务函数的入口地址)
- 这是由于处理器的中断源太多,片内存储器地址不够,无法为每个中断事件配备一个中断向量。
- 因此,共享中断向量的中断源,需要在中断服务函数里面,分别读一下各个中断源的状态。
- 然后,根据读取状态确定,具体是哪个中断源产生了中断
阅读理解; GPIO中断服务代码
- 阅读 : /HARDWARE/exti.c 文件中的 void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) 函数代码
- 思考 : 该函数执行时,如何区分到底是哪个按键发生的动作?
定时器 中断 实验
实验操作1
- 重现实验板提供的参考代码
- 拷贝参考代码, 实验8 定时器中断实验, 到临时目录
- 根据实验板配套文档,编译下载代码,重现实验操作
实验操作2
- 添加 打印中断次数 计数的功能
- 按照如下方式, 修改代码
- 修改定时器中断函数代码,添加中断次数计数的全局变量
- 代码文件 HARDWARE.c
unsigned int tim_INT_cnt = 0;
//回调函数,定时器中断服务函数调用
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if(htim==(&TIM3_Handler))
{
tim_INT_cnt ++;
LED1=!LED1; //LED1反转
}
}
- 修改主函数 代码 , 文件 /USER/main.c
- 添加 外部全局变量 声明 的代码
- 把串口的速率设定为 9600
- 添加 打印定时器中断次数计数的代码
extern unsigned int tim_INT_cnt ;
int main(void)
{
HAL_Init(); //初始化HAL库
Stm32_Clock_Init(RCC_PLL_MUL9); //设置时钟,72M
delay_init(72); //初始化延时函数
uart_init(9600); //初始化串口
LED_Init(); //初始化LED
TIM3_Init(5000-1,7200-1); //定时器3初始化,定时器时钟为72M,分频系数为7200-1,
//所以定时器3的频率为72M/7200=10K,自动重装载为5000-1,那么定时器周期就是500ms
while(1)
{
printf("Timer INT CNT is %d \r\n", tim_INT_cnt);
}
}
- 打开PC上的串口调试助手
- 把串口速率设定为和单片机一致。
- 观察接收到的打印字符,请留意,同样的内容会重复的次数。
- 记录当前的串口速率和打印内容的重复次数
- 修改串口速率为4800,记录速率和重复次数
- 修改串口速率为2400,记录速率和重复次数
- 继续降低串口速率,直到打印出现乱码为止。
- 请解释串口速率和打印重复次数的关系,以及,为什么串口速率过低之后,打印会出现乱码
背景知识:计数器的溢出与分频
- 计数器是最常用的数字电路之一
- 其工作原理为,对输入的时钟信号进行计数
- 比较常见的工作方式是
- 计数器有一个输入信号CLK,一个多位输出信号Q,一个1比特输出信号 OV
- 计数器的输出Q从0到最大值MAX,加1方式,对CLK的脉冲计数,然后自动回绕到0,加1计数 到MAX
- 当计数器为MAX的CLK周期,OV信号为1
- 下图给出了一个MAX为9,计数值Q为4比特的计数器的工作时序
- 从图中可以看到,该计数器的溢出信号OV,其频率为CLK信号的 10分之1
- 由此,计数器起到了分频的作用。
背景知识:计数器对脉冲计数
- 计数器的另一个变种为对输入的脉冲信号计数
- 例如,即,在输入脉冲IN为高的时钟周期,计数值的输出加1
- 当计数至最大值时,计数值回绕到0
- 下图给出了一个对输入信号 IN的高电平 计数, 最大值 为 3 的计数时序图
- 思考题
- STM32 的计数器,其最大值可以用软件动态配置
- STM32 的定时器,有两个配置参数
- 一个是时钟分频因子
- 一个是自动装载值
- 请思考,STM32的定时是如何使用“对时钟信号计数”和“对脉冲信号计数”的计数器来实现的。
多位 LED 数码管 动态扫描显示实验
实验操作1
- 重现实验板提供的参考代码
- 拷贝参考代码, 实验12 数码管显示实验, 到临时目录
- 根据实验板配套文档,编译下载代码,重现实验操作
实验操作2
- 按照如下方式,修改代码
观察发生的现象,解释一下,为什么会出现这种现象
- 文件:/USER/main.c ,函数 :main()
- 代码: TIM3_Init(19,7199);
- 改成: TIM3_Init(1999,7199);
- 文件:/USER/main.c ,函数 :HAL_TIM_PeriodElapsedCallback()
- 代码: if(t==500)
- 改成: if(t==8)
- 修改代码,达到以下的效果
- 系统启动后,8个数码管上分别显示,1、2、3、4、5、6、7、8
- 每过一秒, 每个数码管的数字加1,最大到9,然后回绕到0.
各位数字之间关系独立,不存在进位。
背景知识:动态扫描结构的数码管显示
- 对于一个数码管而言,共有8个显示段(段选),外加一个选通信号(位选)
- 让一个数码管工作,需要输入9个信号。
- 则N个数码管共需要9 * N个信号
- 为了节省管脚,通常会把全部的数码管的同样编号的段选信号短接在一起。
- 然后独立控制各个数码管的位选信号,让他们轮流点亮。
- 当点亮各个数码管的切换速度足够快时,看上去它们同时在显示信息。
- 本实验的开发板,为了进一步节省管脚,对8个位选信号使用了3-8译码器进行编码
3比特输入 0-0-0, 8比特输出 1-1-1-1-1-1-1-0
3比特输入 0-0-1, 8比特输出 1-1-1-1-1-1-0-1
3比特输入 0-1-0, 8比特输出 1-1-1-1-1-0-1-1
3比特输入 0-1-1, 8比特输出 1-1-1-1-0-1-1-1
3比特输入 1-0-0, 8比特输出 1-1-1-0-1-1-1-1
3比特输入 1-0-1, 8比特输出 1-1-0-1-1-1-1-1
3比特输入 1-1-0, 8比特输出 1-0-1-1-1-1-1-1
3比特输入 1-1-1, 8比特输出 0-1-1-1-1-1-1-1
- 下图给出了一个动态扫描显示的数码管结构
- 由于使用了串行移位寄存器和译码器电路,这种显示方案的优点是节约单片机的IO管脚
- 缺点是消耗较多的处理器计算资源。
- 电路工作原理介绍如下
- 以下信号连接在单片机管脚上
- 译码器的输入信号A2、A1、A0 。
- 移位寄存器的串行数据输入SBIT,串行移位时钟SCK,并行加载时钟UPDATE。
- CPU通过设定3-8译码器,使得只有1个数码管被选通点亮。
- 然后通过IO管脚,根据串行输入的时序规则,使用SCK、SBIT信号,串行写入数据
- 然后用UPDATE信号把移位寄存器的数据锁存到输出缓冲寄存器中
背景知识:使用软件控制PIO管脚实现接口时序
- 本设计中,需要按照串行移位芯片要求的时序,给移位寄存器输入数据
- 如下图所示,给出了移位输入数据,以及并行输出数据的时序
- 需要注意的是,由于处理器是只能串行的逐条执行指令
- 所以,当使用处理器指令,置高或者拉低某个管脚的电平时,会产生指令延迟。
- 即,从一条指令执行,到这条指令产生效果,存在一个延时的时间
- 由此,我们无法通过编程使得处理器的2个管脚电平,在同一个时刻翻转。
- 通常来说,对于使用时钟信号的边沿,进行同步传输的信号时序
- 我们总是先将数据信号加载,待其稳定后,再翻转时钟信号。
- 例如:以下代码过程,示意了使用时钟信号 SCK的上升沿,同步传输1比特数据信号SBIT的过程
SCK = 0;
SBIT = 1;
delay(Half_Period_SCK);
SCK = 1;
delay(Half_Period_SCK);
SCK = 0;
SBIT = 0;
- 下图给出了,理想情况下,SCK和SBIT的时序过程。
- 以及用单片机指令控制管脚电平时,实际生成的带有延时的信号时序过程。
综合实验 之 拆弹指令
故事背景
- 坏老鼠一只耳,安装了一个定时炸弹,企图炸毁森林公安局
- 黑猫警长及时赶到现场,发现这个定时炸弹上有两个按钮
- 只要用这两个按钮正确的输入一串指令,定时炸弹就会停止计时。
设计内容
- 设计实验版的软件,包括以下功能
- 系统上电后,LED数码管上,显示剩余时间 29.9 ,表示 炸弹剩余的爆炸时间
- 每经过0.1秒,数码管上倒计时显示减少0.1
- 当倒计时达到 00.0 秒时,在LED数码管上绘制一个动态图样表示炸弹爆炸
- 在倒计时的过程中,如果用户在KEY0和KEY1上,按下了如下按键序列,则倒计时停止
- 按键序列为,KEY0,KEY0,KEY1,KEY1,KEY0,KEY1,KEY0,KEY1
- 当检测到正确的序列时,倒计时的计数,应该停下来,清晰的显示剩余时间。
- 有能力的同学可以加上闪烁或者其他效果