STM32单片机学习教程
@
先可以看一个简单的入门教程:STM32新手入门教程
后面就是整理于:https://stm32-tech.readthedocs.io/en/latest/base/00%E5%89%8D%E8%A8%80.html
1. 前言
欢迎大家参加学习本教程。
先跟大家说明做本教程的原因。
目前在网络上,基于STM32的学习教程非常丰富,也非常好。
那为什么还要浪费时间再做一个教程呢?
如果熟悉这些教程的朋友应该了解,这些教程对于入门了解概念,非常合适。
比如,我们要使用一个新外设,根本不知道这个外设是怎么工作的。通过网络上的这些教程,可以快速学会如何使用这个设备。
但是这些教程基本都有一个缺点:没有考虑实际项目工程开发的软件设计!
很多教程,只是官方例程的补充说明。没有考虑软件工程的事、没有考虑系统流程、没有考虑模块化设计、没有考虑接口设计、没有考虑分层设计。
基于这样的考虑,我设计了一套精巧的STM32F407开发板。这个开发板的外设、接口,都是基于上述问题设计:如何模块化、如何设计接口、如何设计软件分层。
在这套硬件上,最后形成了一个可复用的模块:PetiteDrv
。
但是这套软件对于入门者非常不友善。因此决定再设计一个入门的开发板,做一些必要的入门教学。
目的是引导大家走向更专业的软件开发。
因此开发板的性价比极高,整体的设计也非常适合自学和实验室做实验。
编写的入门文档,有以下特点:
1 根据开发板,做25个教程。只为引导入门,更多的教程大家可以从其他地方学习。
2 从实用的目的,用实用的方法,教大家如何进行嵌入式开发,不照本宣科。
3 只挑有用的知识点讲解,不做大而全。没有几千页,只有几十页。因此很多知识点并不会涉及。
4 在讲入门知识之外,同时讲一些软件工程问题,引导大家软件设计意识。
1.1. 学习说明
-
要不要学51
不是必须,学过当然好,有基础,入门更快。
-
要不要学汇编
不是必须,学过当然后,如果有汇编知识,说明你对芯片底层的操作更加清楚。当遇到一些疑难问题时,你考虑问题就更加全面,也就更容易解决问题。
-
基础要求
那0基础也能学吗?
我不敢说学不会,但是建议最好有以下基础:
模电:你总要知道电源、电压、电流、电阻,这些概念吧。
数电:清楚1和0是啥、清楚一些常用的逻辑器件、清楚一些常规逻辑操作(与或非等)
C语言:程序使用C语言编写。
在<W108_F103_Tech\ref\0 C语言>目录中有一些我收集的C语言资料
C语言推荐下面这本书:
[外链图片转存中…(img-Imo1CsGD-1645022852644)]
C语言进阶可读下面这本
[外链图片转存中…(img-NnJtAG4C-1645022852646)]
1.2. 教程说明
本教程分两部分
- base
- Advanced
当前教程如下:
名称 | 说明 | 名称 | 说明 | ||
---|---|---|---|---|---|
00 | 前言 | 介绍本教程 | 13 | PWM蜂鸣器 | 简单PWM使用,驱动蜂鸣器 |
01 | 资料介绍 | 说明教程资料都有些什么内容 | 14 | 串口与调试信息 | 串口的概念,如何使用串口输出调试信息 |
02 | 最小系统说明 | 介绍单片机最小系统的组成 | 15 | ADC | 模拟电压转换为数字 |
03 | 建立工程 | 教大家如何用MDK建立一个项目工程 | 16 | DAC | 数字转换为模拟量 |
04 | 点亮LED | 点亮LED,实现流水灯 | 17 | RTC | RTC初始化和基本应用 |
05 | 开发工具 | 介绍可提高效率的开发工具: Source Insight 4.0 Beyond compare git | 18 | I2C EEPROM | 学习I2C通信,学习EEPROM的使用。 |
06 | 点亮数码管 | 使用一位8段数码管显示数字 | 19 | SPI FLASH | 学习SPI 通信,学习SPI FLASH的特点和基本操作。 |
07 | 程序各种要素 | 一个程序有多少东西:变量、函数、流程、宏定义、堆和栈、库函数 | 20 | I2C OLED | 学习显示屏的一些基本概念,使用I2C接口控制OLED |
08 | 动态扫描数码管 | 使用动态扫描方法在8位数码管上显示数字。 | 21 | SPI COG | 使用SPI口控制COG LCD |
09 | 源码结构整理 | 一个工程的源文件如何管理? | 22 | FSMC TFT | 使用FSMC控制TFT LCD |
10 | 按键扫描 | 如何检测按键?如何防抖? | 23 | VSPI Tslib TP | 用IO口模拟SPI控制触摸芯片,使用Tslib库进行触摸屏校准。 |
11 | 外部中断 | 中断的原理,外部中断的使用 | 24 | SDIO | 学习如何从官方库移植代码 |
12 | 定时器 | 定时器的使用 | 25 | USB |
提高教程
名称 | 说明 | 名称 | 说明 | ||
---|---|---|---|---|---|
1 | 串口中断和环形缓冲 | 学习环形缓冲,学习前后台程序的设计,学习数据分层的概念 | 2 | 矩阵按键扫描 | 学习如何扫描矩阵按键,学习软件接口设计和数据分层理念,学习模块化设计 |
20200109
2. 资料介绍
本章节对本教程的文档进行必要说明。
2.1. 开发板模块介绍
硬件介绍请参考<W108_F103_Tech\doc\source\spec>、或者是<W108_F103_Tech\data\SPEC>目录中的pdf文档。
2.2. 资料介绍
|
|--code
|--data
| |--HDK
| |--REF
| |--SDK
| |--SPEC
|--doc
| |--build
| |--source
| |--spec
| |--base
| |--Advanced
|--ref
|--readme.md
-
Code目录
这个目录保存了教程的相关代码,全部为压缩包形式,在代码中有丰富的注释。
-
data目录
这个目录是产品说明文档,包含4个子目录:
HDK是硬件原理图和丝印说明。
REF是主控相关资料。
SDK为ST提供的相关库。
开发板产品说明书 -
doc目录
教程文档。
build
是md转换为html的目录。source
是md文档目录。其中spec
是产品说明书,base
是基础教程,Advanced
是提高教程。本教程文档使用markdown格式,使用Typora编写。
所有文档使用Sphinx管理。
md文件经过sphinx处理后,生成html文件。文件入口在
doc\build\html
中的index.html
,使用浏览器打开即可浏览所有文档。sphinx可以将文档处理为pdf,如有需要,可自行转换。
Typora也可以将md文档转换为pdf。
可参考:
《使用ReadtheDocs托管文档》
https://www.xncoding.com/2017/01/22/fullstack/readthedoc.html
-
ref目录
本目录包含一些Code目录相关,或者教程相关的文档和资料。例如一些软件工具、一些从网络获取的文档资料等。
2.3. STM32库说明
库有多重要?
当你有一定基础时,学习一款新芯片,最好的渠道就是库,就是库中包含的例程。
当你用一款新芯片做项目开发时,最好的资料就是库和例程,对于硬件的底层操作,完全可以拷贝官方例程的代码。我们专心做上层驱动和应用就行了。
可以说,ST推出库的这个创意,大大方便了开发人员。
我们不用再关心一个外设寄存器的bit0是什么功能。我们只需要知道,这个外设有这样的功能,用库接口操作即可。
下面我们看看STM32的库到底是如何组成的。
- ST有很多库,我们现在说的是标准外设库:
STM32F10x_StdPeriph_Lib_V3.5.0
|--_htmresc
|--Libraries
| |--CMSIS
| |--STM32F10x_StdPeriph_Driver
| |--inc
| |--src
|--Project
| |--STM32F10x_StdPeriph_Examples
| |--STM32F10x_StdPeriph_Template
|--Utilities
| |--STM32_EVAL
| |--Common
| |--STM32L152_EVAL
| |--STM3210B_EVAL
| |--STM3210C_EVAL
| ...
| |--stm32_eval.c
| |--stm32_eval.h
-
Libraries
库文件目录,所谓的库就是用c语言将底层操作封装,用户不用再关心寄存器级别的操作。
包含两部分:
CMSIS
, 这是内核相关的。STM32F10x_StdPeriph_Driver
, 这是芯片相关的。在文档中包含了所有的外设操作。src是源文件,Inc是include的缩写,文件夹中的文件是头文件。
-
Project
例程和模板。
Examples中的就是官方例程,对于我们开发程序有非常重要的参考意义。
-
Utilities
不同官方开发板的接口封装。什么意思呢?
在Examples中有很多例程,官方有很多开发板,那么是不是每个开发板都写一个例程呢?
当然不是,例如,一个操作SPI FLASH的例程,操作SPI FLASH的程序,在不同的开发板都是一样的,那么这些代码就放在Examples目录下。
操作FLASH需要SPI接口,需要IO口,不同的芯片,不同的开发板,代码是有区别的。比如F103和STM32Lxx .
这些不同的操作,就放在Utilities目录下。
我们参考一个例程,除了要看Examples中的代码,还要看STM32_EVAL目录下对应平台的代码。
我们的教学板是STM32103VET,我们要参考的代码就在STM3210E_EVAL目录下。
当然,还有一些是所有平台都通用的,那么就放在stm32_eval.c源文件中
3. 最小系统
在将单片机的最小系统之前,我们先来认识认识单片机。
3.1. 为什么叫单片机?
纵观计算机的发展,最开始的计算机是一台巨无霸,后面技术更新,变小了,我们称之为微机。
微机,也就是最开始的电脑,发展到目前,电脑通常包含以下几个部件:
- CPU
- 主板
- 硬盘
- 显示器
- 键盘鼠标
- 内存
其中主板有包含了一堆外设:
那么单片机,也就是所谓的MCU,其实就是跟电脑相比。
因为在一颗芯片上,包含了CPU,硬盘、内存、一堆外设,就像一台小电脑一样。所以叫单片机。
我们看看STM32F103VET的框图。在文档中。
如下:
[外链图片转存中…(img-UeH0zWU8-1645022852647)]
- 红框1中就是内核Cortex-M3,功能相当于电脑上的CPU。
- 红框2中是Flash,我们选的这款芯片片上包含512K。相当于电脑的硬盘,用于保存代码和数据。
- 红框3中的是SRAM,片上含64K。相当于电脑的内存。
有些同学可能觉得这个比喻不是很恰当,确实,现在的芯片有很多种类,有些芯片内部没有FLASH,有些芯片不含RAM。
不过大家可以搜索一下苹果最开始的电脑,这些早起电脑就是用6052内核的芯片做的,现在在看功能其实相当简单,就像一个单片机一样。
6052的内核,在很多台系的单片机上还在使用,就像8051内核一样,持久不衰。
3.2. 最小系统说明
单片机能工作的最小外围电路就叫做最小系统。
通常包含:
- 时钟,也就是晶振。时钟相当于芯片的心脏,只有时钟,芯片才能工作。
- 工作电源,这个不用解释了,所有电子设备都需要电源才能工作。不同的芯片工作电源不一样,有5V系统,3.3V系统,1.8V系统。
- 其他一些必要的外部设置,例如启动模式选择。
3.2.1. 原理图
我们打开原理图,第一页,就是最小系统。居中的就是STM32F103VET6。
[外链图片转存中…(img-lVkK6zBN-1645022852648)]
细分的话,整个最小系统分一下部分:
-
电源
电源又分3部分,主电源,也就是上图中框图1,实际就是一个3.3V电源和一些电容。
模拟电源,红框4中的电源就是,模拟电源由主电源通过磁珠等手段隔离而得。
备份电源,红框8就是,用处是,低功耗下维持CPU的RTC和备份区功能。使用一个二极管并联,有外电时使用外电3.3V,没有外电时才使用纽扣电池电源。
-
主时钟,也就是CPU大部分外设工作的时钟。红框5的8M晶振电路就是主时钟。晶振电路通常都是由一个晶体和2个电容组成。晶体的频率就是主频。电容容值根据芯片设计,要匹配,否则晶体不起振。
-
RTC时钟,红框2处即是,频率是32.768M。
-
复位电路,红框3处即是。用于复位CPU,通常是低电平复位。
上电时,阻容电路没充电,复位脚是低电平,CPU进行复位,电容充满电后,复位脚是高电平,CPU复位完成。这就是常说的上电复位过程。
正常工作中,按下按键,将复位脚拉低到低电平,进行复位。
-
启动选择电路,红框6就是STM32的启动选择电路。
-
调试口,红框7处就是调试口。什么是调试?通过电脑上的工具调试芯片。也就是我们常说的用JLINK通过JTAG口调试。现在,已经慢慢换成Daplink+SW口了,因为SW口用的线更少,更方便。
实际上,一个单片机,只需要:主电源
、主时钟
、复位电路
,驱动选择电路
, 芯片就能运行了。
很多芯片甚至不需要主时钟,使用芯片内部的时钟就可以运行代码。STM32也有内部时钟,只不过,相对外部晶振来说,内部时钟的精度会差很多。
3.2.2. 硬件最小系统
我们看下硬件实物的最小系统。
[外链图片转存中…(img-Z74LTEIC-1645022852648)]
- 红框1,是复位电路,按下复位按键系统就复位了。
- 红框2,是电源电路,将外部的5V转换为3.3V。
- 红框3,是启动选择电路。
- 红框4,是纽扣电池电路。
- 红框5,是主芯片和电源电路,4个电容要靠近芯片电源管脚。
- 红框6,是实时时钟。
- 红框7,是主时钟,8M频率。
- 红框8,是模拟电源。
4. 建立工程
本章节教大家创建一个STM32的工程。
开发环境使用MDK,也即是Keil。
本文档只做概要说明,详细操作请参考视频教程
4.1. 安装MDK
自己网上寻找安装方法。
4.2. 安装芯片支持包
在创建工程之前,需要先安装芯片支持包,否则在芯片中找不到我们需要的型号。
按照下图打开Pack安装页面
在左边的Devices 中找到我们要的芯片系列,左键点击选中。
右边就会显示对应的支持包,我们只需要安装基础包。也就是第二行的,截图是我的电脑,已经安装好了。
没安装时和其他PACK一样显示Install,点击Install按钮就可以下载安装。
[外链图片转存中…(img-vBn9V3Ws-1645022852649)]
在底部的左边有安装信息,右边有安装进度。‘
[外链图片转存中…(img-AJcYnA7K-1645022852650)]
直接下载速度较慢,而且经常会断开。
我们可以直接导入下载好的pack文件, 在我们共享的资料中,ref\4 mdk
目录下的Keil.STM32F1xx_DFP.2.3.0.pack
文件就是F103的支持包。
点击File
菜单中的Import
,选择pack文件即可导入。
[外链图片转存中…(img-jnAgIgbW-1645022852651)]
4.3. 创建工程
安装完成后,双击图标打开主界面如下。
[外链图片转存中…(img-sbVC3lc8-1645022852651)]
点击菜单栏的Help
->About uVision
,可以查看到版本信息,我使用的是V5.24.2.0。
[外链图片转存中…(img-sXANFKa1-1645022852652)]
创建工程之前我们先配置IDE环境,
[外链图片转存中…(img-cLaFNN97-1645022852652)]
在Configuration
的Editor
页中,把Automatic reload of externally modified files
[外链图片转存中…(img-9uuGwgLB-1645022852652)]
点击Project
中的New uVision Project
创建新工程
[外链图片转存中…(img-sQwsdC5K-1645022852653)]
选择芯片STM32F103VE
弹出界面让我们选择软件包,我们都不是使用这些软件库。点击OK跳过。
[外链图片转存中…(img-Q6FrKvUV-1645022852653)]
创建完成,现在得到一个空的工程。
[外链图片转存中…(img-pS75i7Wl-1645022852654)]
查看目录,现在只有工程文件,没有代码文件。
[外链图片转存中…(img-A8SYVzdz-1645022852654)]
在目录中创建一个mcu
文件夹。
把STM32F10x_StdPeriph_Lib_V3.5.0\Libraries下的STM32F10x_StdPeriph_Driver
文件夹拷贝到mcu
目录 名字太长,改为stdlib
拷贝STM32F10x_StdPeriph_Lib_V3.5.0\Project\STM32F10x_StdPeriph_Examples\GPIO\IOToggle
中的文件到mcu
目录
拷贝STM32F10x_StdPeriph_Lib_V3.5.0\Libraries\CMSIS\CM3\DeviceSupport\ST\STM32F10x
中的源文件 拷贝启动代码,注意选对编译工具和型号 STM32F10x_StdPeriph_Lib_V3.5.0\Libraries\CMSIS\CM3\DeviceSupport\ST\STM32F10x\startup\arm
待补充
[外链图片转存中…(img-2JYhuShL-1645022852654)]
回到MDK
先选中目标名,再单击,修改为stm32_tech
同样修改源文件目录名
然后右键点击mcu
,将刚刚拷贝到mcu
目录的文件添加到工程的mcu
下。
添加文件,添加启动代码的时候要注意,文件类型要选为ALL,才能看到汇编文件。
[外链图片转存中…(img-w3xMZEuA-1645022852655)]
[外链图片转存中…(img-6VoS9Bt3-1645022852655)]
新建一个Stlib的文件夹,把库文件全部添加
[外链图片转存中…(img-kO8xq86x-1645022852656)]
点击编译,在下方框中输出编译过程的信息,编译结果显示24个错误。错误都是提示找不到.h文件。
原因是我们没有添加头文件存放的路径。
点击魔术棒进入Option,开始配置工程。
首先将晶振修改为8M
[外链图片转存中…(img-vc1V7AXc-1645022852656)]
在Output页中勾选如下,选择生成hex文件。
在C/C++页中,勾选上C99,并且将头文件存在的路径添加到Include Paths中。
[外链图片转存中…(img-ldiVYAwi-1645022852657)]
点击工程目录树中,找到stm32f10x.h,在65开始的地方,默认没有定义任何类型的芯片,我们要定义一个芯片。
有些库文件时只读,在图标上面有一个钥匙标志,需要先修改文件属性才能修改。
在这个文件的105行,打开这个宏,选择使用标准库。
[外链图片转存中…(img-OEDcF9mY-1645022852658)]
编译,错误,原因是我们没有忘记添加启动文件了。
[外链图片转存中…(img-psw78Jsd-1645022852658)]
添加启动文件,文件类型要选择为ALL files才能选中汇编文件。
[外链图片转存中…(img-pQtMs7Vx-1645022852658)]
编译通过。
[外链图片转存中…(img-wGD9opIK-1645022852658)]
4.4. 下载
在工程Option中,Debug页,右上角,选择调试器,默认是ULINK2……,我们选在CMSIS-DAP。
硬件连接正确的情况下,点击Settings,可以读到芯片。
回到主界面,点击Debug,开始调试。
进入调试后界面如下。
左键点击行号旁边可以添加断点,黄色三角符号是当前执行位置。
[外链图片转存中…(img-w0vZpFlg-1645022852660)]
各种调试按钮在菜单栏。
5. IO&点亮LED
5.1. 概述
芯片如何控制外部电路?
从本课开始,让我们一点一点把单片机系统的知识框架搭建起来。
本课,学习最简单的、也是最基本的:GPIO口。
本课讲以下几个问题:
- IO是什么?GPIO是什么?
- STM32的IO有什么特点?
- LED如何使用?
- 编码和调试过程(这段看视频更好)
5.2. IO 是什么?
IO是Input&Output,也就是输入和输出。
我们选的STM32F103VET6这款芯片的,管脚总共有100根。
但是《STM32F103数据手册.pdf》中有写:IO口只有80,为什么?
[外链图片转存中…(img-IdmDlxFt-1645022852660)]
在数据手册的第14页有管脚定义图,如下:
[外链图片转存中…(img-hdMxFjjm-1645022852661)]
从管脚名称看到,P前缀的的管脚, 就是IO口。100脚的STM32,有PA、PB、PC、PD、PE,一共5组IO。
除了IO,还有电源和地、晶振输入、BOOT配置等其他功能的管脚。
- 普通电源有5组。
- 模拟电源有1组。
- 备份电源一组。
- 参考电压VREF有一组。
- 复位脚和启动配置BOOT0各一根管脚。
- 晶振两组,其中RTC晶振可做普通IO使用。
- 73脚是空的,没有连接。
有些朋友可能有疑问,怎么都没看到SPI、I2C等功能的管脚?
通常IO口都能复用作其他功能,比如SPI、I2C等。在数据手册的管脚定义中有几页说明管脚可以复用做什么功能。
比如:
[外链图片转存中…(img-ZykRaYfG-1645022852661)]
PA0,主功能是PA0,可用作串口的CTS、ADC、TIM6、TIM2、TIM8等功能。
从此可见,我们说IO功能,通常只是一个IO口的基本功能:GPIO, 通用输入输出的意思。
5.2.1. GPIO功能
GPIO能用来做什么呢?
要理解这个GPIO,先要定下一个概念:除了DA转换和AD转换, 其他的IO口都是数字逻辑功能。
对GPIO来说,功能很简单,分两个:
- 输出-在管脚上输出数字逻辑电平。
- 输入-检测管脚的逻辑电平
芯片用的是TTL电平:
数字电平有两种,高电平和低电平。
高电平是逻辑1,低电平是逻辑0.
高电平是芯片的IO电压,STM32没有独立IO电压,IO电压就是是芯片工作电压,低电平是就地电平。
实际上呢,高电平和低电平是有一个范围的,并不仅仅是3.3V和0V
5.2.2. GPIO驱动能力
一个IO口输出电流或输入电流的能力。
在中文版数据手册 第31页中,有下面这个表格:
[外链图片转存中…(img-Xk53TSCj-1645022852661)]
灌电流和拉电流都是25mA
在设计外围电路时,电流不能超过这个值,否则会烧芯片。比如大电流的LCD背光,就需要在外部添加三极管驱动电路。
还有一个需要注意的,有些芯片,灌电流和拉电流的最大值不一样,灌电流可能只有5ma。
5.3. STM32 IO特点
以前的单片机,比如8051,GPIO口非常简单,只要设置GPIO口的方向是输出还是输入,就可以工作了。用起来虽然简单,却无法满足各种应用场景。因此,高级的芯片,IO口通常有很多功能可以配置。
STM32的GPIO就是这样,如果操作寄存器操作的话,相当复杂。在《STM32F10x微控制器参考手册.pdf》中,第七章就是讲GPIO功能的。
- 首先要知道,IO口有8种模式:
其中GPIO用到的有5种。模拟输入是AD转换使用。推挽复用和开漏复用模式用于GPIO外的其他外设功能,比如SPI。
GPIO五种模式,要区分输入和输出:浮空、上拉、下拉,都是说输入。推挽、开漏,是输出。
驱动LED用输出功能。那选推挽还是开漏模式呢?要先搞清楚推挽和开漏输出的区别。
推挽 | 开漏 | |
---|---|---|
高电平驱动能力 | 强 | 外部上拉电阻提供 |
低电平驱动能力 | 强 | 强 |
电平转换速度 | 快 | 外部上拉电阻决定,电阻越小,反应越快,功耗越大 |
线与功能 | 不支持 | 支持 |
电平转换 | 不支持 | 支持 |
开漏模式,IO口内部没有上拉电阻,没有接MOS管,所以开漏电路不能输出高电平;要输出高电平,需要外部接上拉电阻,如果外部上拉电阻接的电压不是芯片IO电压,就相当于实现了IO口电平转换功能。
比如,推挽模式下,IO口输出高电平就是3.3V,用开漏模式,外部接电阻上拉到5V,那么输出高电平就是5V。
电平转换速度指芯片0/1翻转的速度。
线与,两根IO直接连接在一起,电平按照与逻辑。通常用在一些可挂载多设备的总线上,比如I2C。
原理参考:https://www.cnblogs.com/lweleven/p/mcuioout.html
因此,驱动LED用推挽还是开漏?除了必须用开漏的场合,我们都习惯用推挽输出
- 第二,SMT32的IO有IO速度需要配置。
[外链图片转存中…(img-VKheACVn-1645022852662)]
如果不知道如何选,全部用50M,功能肯定正常。但是可能会增加电流,增加EMC辐射。
经验:
普通功能的IO,通常2M就可以了。
如果一个IO用作I2C通信,速度通常就10K到400K,选10M就好了。
如果是用作SPI功能,可能会到20M速度,那就要选50M了。
到此,我们基本了解了STM32 GPIO的功能。下面看看ST的库都提供了什么函数给我们用。
5.4. ST库函数
打开上一节我们创建的工程。在库函数中找到stm32f10x_gpio.c和stm32f10x_gpio.h
函数有下面这些:
void GPIO_DeInit(GPIO_TypeDef* GPIOx);
void GPIO_AFIODeInit(void);
void GPIO_Init(GPIO_TypeDef* GPIOx, GPIO_InitTypeDef* GPIO_InitStruct);
void GPIO_StructInit(GPIO_InitTypeDef* GPIO_InitStruct);
uint8_t GPIO_ReadInputDataBit(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin);
uint16_t GPIO_ReadInputData(GPIO_TypeDef* GPIOx);
uint8_t GPIO_ReadOutputDataBit(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin);
uint16_t GPIO_ReadOutputData(GPIO_TypeDef* GPIOx);
void GPIO_SetBits(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin);
void GPIO_ResetBits(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin);
void GPIO_WriteBit(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin, BitAction BitVal);
void GPIO_Write(GPIO_TypeDef* GPIOx, uint16_t PortVal);
void GPIO_PinLockConfig(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin);
void GPIO_EventOutputConfig(uint8_t GPIO_PortSource, uint8_t GPIO_PinSource);
void GPIO_EventOutputCmd(FunctionalState NewState);
void GPIO_PinRemapConfig(uint32_t GPIO_Remap, FunctionalState NewState);
void GPIO_EXTILineConfig(uint8_t GPIO_PortSource, uint8_t GPIO_PinSource);
void GPIO_ETH_MediaInterfaceConfig(uint32_t GPIO_ETH_MediaInterface);
GPIO_Init:初始化IO口,
GPIO_SetBits:IO口输出1
GPIO_ResetBits:IO口输出0
GPIO_WriteBit:IO口输出状态,相当于GPIO_SetBits和GPIO_ResetBits组合。
GPIO_Write:输出IO口状态。
GPIO_WriteBit是在指定的IO口上输出相同的状态,GPIO_Write是在一组IO上输出需要的状态,。
我们看参数:
GPIO_SetBits(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin);
将GPIOx这组IO口中,GPIO_Pin指定的IO口,输出高电平。
GPIO_ResetBits功能和GPIO_SetBits相反。
GPIO_WriteBit(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin, BitAction BitVal);
将GPIOx这组IO口中GPIO_Pin指定的IO口设置为BitVal的状态。
GPIO_Write(GPIO_TypeDef* GPIOx, uint16_t PortVal);
将GPIOx这组IO口设置为PortVal的状态。注意,是一次设置一组IO。
我们在来看看初始化的接口
void GPIO_Init(GPIO_TypeDef* GPIOx, GPIO_InitTypeDef* GPIO_InitStruct)
关键是第二个参数,这是一个结构体,定义如下:
typedef struct
{
uint16_t GPIO_Pin; /*!< Specifies the GPIO pins to be configured.
This parameter can be any value of @ref GPIO_pins_define */
GPIOSpeed_TypeDef GPIO_Speed; /*!< Specifies the speed for the selected pins.
This parameter can be a value of @ref GPIOSpeed_TypeDef */
GPIOMode_TypeDef GPIO_Mode; /*!< Specifies the operating mode for the selected pins.
This parameter can be a value of @ref GPIOMode_TypeDef */
}GPIO_InitTypeDef;
第1个参数GPIO_Pin指定要配置的IO口。
第2个参数GPIO_Speed配置IO口速度。
第3个参数GPIO_Mode配置IO口模式。
其中GPIO_Speed和GPIO_Mode类型是枚举,如下:
- 速度
typedef enum
{
GPIO_Speed_10MHz = 1,
GPIO_Speed_2MHz,
GPIO_Speed_50MHz
}GPIOSpeed_TypeDef;
- 模式
typedef enum
{ GPIO_Mode_AIN = 0x0,
GPIO_Mode_IN_FLOATING = 0x04,
GPIO_Mode_IPD = 0x28,
GPIO_Mode_IPU = 0x48,
GPIO_Mode_Out_OD = 0x14,
GPIO_Mode_Out_PP = 0x10,
GPIO_Mode_AF_OD = 0x1C,
GPIO_Mode_AF_PP = 0x18
}GPIOMode_TypeDef;
配置IO的时候,选用这里的定义即可。
我们看下例程,在目录STM32F10x_StdPeriph_Lib_V3.5.0\Project\STM32F10x_StdPeriph_Examples\GPIO\IOToggle
中的main函数,初始化IO口的代码如下:
int main(void)
{
/*!< At this stage the microcontroller clock setting is already configured,
this is done through SystemInit() function which is called from startup
file (startup_stm32f10x_xx.s) before to branch to application main.
To reconfigure the default setting of SystemInit() function, refer to
system_stm32f10x.c file
*/
/* GPIOD Periph clock enable */
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOD, ENABLE);
/* Configure PD0 and PD2 in output pushpull mode */
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_2;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_Init(GPIOD, &GPIO_InitStructure);
- 调用RCC_APB2PeriphClockCmd函数打开GPIOD时钟。
这个是需要注意的,ST的芯片每个设备都有时钟,使用之前都需要打开。
- GPIO_Pin设置为GPIO_Pin_0 | GPIO_Pin_2, 说明过一次配置两个IO口。
- 速度配置为GPIO_Speed_50MHz, 也就是50M
- GPIO_Mode设置为GPIO_Mode_Out_PP, 也就是输出推挽模式。
- 调用函数GPIO_Init进行配置。
5.5. 如何使用LED
LED是什么?
发光二极管简称为LED。因化学性质又分有机发光二极管OLED和无机发光二极管LED。
它本质上是一个二极管,所以就有正负极。
加上电压就会亮,但是LED其实是一个电流器件,有电流了才会亮。
电流越大,亮度越大,但是不能超过规格。
在资料文件夹内有一个发光二极管的规格书。LED的规格书中有一个很重要的参数。 《黄绿 0603 (33_40mcd)_PDF_C2289_2015-07-23.pdf》
[外链图片转存中…(img-F2AKdT1W-1645022852663)]
第1行,顺向电流,就是LED的工作电流,不能超过20mA。
LED驱动有两种方式:低电平(灌电流)和高电平(拉电流),如下图
[外链图片转存中…(img-8BqsnSrL-1645022852663)]
我们的板子选用灌电流模式,4位LED电路原理图如下:
[外链图片转存中…(img-NPD26xgg-1645022852663)]
LED正极通过一个限流电阻接到VCC3V3,也就是高电平。负极接到IO口。
当IO口输出低电平,电流从电阻流过,流过LED,流入IO口。LED就会发光。
限流电阻是防止流过LED的电流大于LED的顺向电流最大值。
电流的计算可以粗略如下:
I=(3.3-0.6)/1K = 2.7mA
这个电路的接法,电流是流入IO口的,就是灌电流。
5.6. 编码与调试
请看视频,文档待补充
- 对原理图 找到对应的IO, PE15 PD8 PD9 PD10
- 拷贝例程初始化代码,修改为我们的IO口。
- 单步运行,发现 还没设置IO口,只是初始化 就亮了, 为什么?
- 修改,先设置输入输出的值,再初始化IO口。
- 写流水灯,无延时, 单步运行,流水灯功能正常。全速,没有出现流水,但是亮度变暗了。为什么?
讲程序,简一点C语言的编码知识。
十进制 十六进制的数值定义。
加延时 关键字 volatile
C 语言知识点: 宏定义
5.7. 作业问题
问题, 为什么代码能控制IO口?
看库函数到底做了什么
6. 提高效率的工具
si
beyondcompare
参考百度网盘,目录W108_F103_Tech\ref\5 tool 目录中的文档。
7. 点亮数码管
本节课利用已经学习的LED知识去控制一个8位数码管。
本节的原理比较简单。不需要多少时间讲。
更多时间是跟大家一起编码调试,从中学习一些编码思路和学习方法。
7.1. 什么是数码管
数码管是什么?下图就是一个数码管
从硬件上个看,其实就是8个LED组合在一起。8个LED应该有16个引脚,但是数码管上只有10个引脚。为什么呢?
请看下图:
[外链图片转存中…(img-8wLAw7QT-1645022852664)]
1个LED有两个引脚,要控制LED,1个引脚接控制信号,另外一个引脚接电源或者地(高驱动或低驱动,下同)。
那么,当有8个LED,只需要8根IO口控制状态,其他IO全部接到地或者电源即可。
当用高驱动时,LED负极全部接到地,这种数码管就叫做共阴极数码管。
当用低电平驱动时,LED正极全部接到电源,这种数码管就叫做共阳极数码管。
数码管实物中,小数点LED通常单独引出两个引脚,由我们在电路图上连接在一起。
7.2. 原理图
从原理可知,控制数码管需要8根IO口。下图就是原理图。
IO口选择PE7—PE14,这8个IO口是连续的,方便代码控制。
共阴极数码管,所有负极接到地。正极通过1个限流电阻接到控制IO口。
7.3. 接口设计
什么是接口?
- 两件事物之间的交互通道叫做接口。
- 软件中,应用程序控制硬件用的函数,就是接口。
- 从上往下看,即用户角度看硬件,用户想要什么功能?
- 从下往上看,硬件有什么功能?能提供什么功能?(注意二者区别)
刚刚开始学编程,这些设计理念可以了解,慢慢实践
一位数码管有什么功能?
- 首先,有8个LED可以点亮。
- 然后,8个数码管可以组成数字。
从用户角度看,我们用数码管做什么呢?通常我们需要的功能是显示数字,而不是点亮某个段。
所以,我们就定义数码管的功能是:显示数字
函数接口如下:
/*
定义一个seg_display
输入参数有2个,分别是char型的num,char型的dot
没有返回值。
*/
void seg_display(char num, char dot)
num就是要显示的数字:0~9
dot表示要不要点亮小数点
到此,我们编码前的学习和设计就完成了,下面开始实现功能。
7.4. 编码调试
-
第一步,点亮LED
这一步在上一节调试LED时已经学过,代码如下:
/* 调用库函数RCC_APB2PeriphClockCmd 传入两个参数RCC_APB2Periph_GPIOD,ENABLE RCC_APB2Periph_GPIOD是一个宏定义, ENABLE是一个新定义的枚举类型FunctionalState */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOD, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOE, ENABLE); GPIO_ResetBits(GPIOE, GPIO_Pin_7|GPIO_Pin_8|GPIO_Pin_9|GPIO_Pin_10|GPIO_Pin_11|GPIO_Pin_12|GPIO_Pin_13|GPIO_Pin_14); /* Configure PD0 and PD2 in output pushpull mode */ /* Configure PD0 and PD2 in output pushpull mode */ /*赋值给结构体变量GPIO_InitStructure的成员, 注意,GPIO_InitStructure是实体,所以用点, 如果是一个结构体指针,就用-> GPIO_InitStructure->GPIO_Pin */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_7|GPIO_Pin_8|GPIO_Pin_9|GPIO_Pin_10|GPIO_Pin_11|GPIO_Pin_12|GPIO_Pin_13|GPIO_Pin_14; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_Init(GPIOE, &GPIO_InitStructure); GPIO_SetBits(GPIOE, GPIO_Pin_7); GPIO_SetBits(GPIOE, GPIO_Pin_8); GPIO_SetBits(GPIOE, GPIO_Pin_9); GPIO_SetBits(GPIOE, GPIO_Pin_10); GPIO_SetBits(GPIOE, GPIO_Pin_11); GPIO_SetBits(GPIOE, GPIO_Pin_12); GPIO_SetBits(GPIOE, GPIO_Pin_13); GPIO_SetBits(GPIOE, GPIO_Pin_14);
几个关键点:
-
记得打开IO口时钟。
-
先设置状态,再配置IO口为输出。防止IO口配置完后LED闪一下。
(特别是在控制电机时,一定要配置为确定状态后再将GPIO外设连接到IO口)
-
然后调用GPIO_SetBits控制IO口。
-
用调试器一个一个LED数码管轮流测试,看是不是能点亮、熄灭。
为什么要一个一个调试?因为这样测试可以要测试出硬件上IO口短路的情况。
如果8个IO口一起控制亮灭,就无法知道IO口有没有短路。
-
-
用直接控制IO的方法实现接口
接口函数原型我们已经定义好:
void seg_display(char num, char dot)
既然我们都会控制LED了,那么,就用控制LED的方法实现这个函数。
GPIO_ResetBits(GPIOE, GPIO_Pin_7|GPIO_Pin_8|GPIO_Pin_9|GPIO_Pin_10|GPIO_Pin_11|GPIO_Pin_12|GPIO_Pin_13|GPIO_Pin_14); if(dot == 1) { GPIO_SetBits(GPIOE, GPIO_Pin_7); } switch(num) { case 0: GPIO_SetBits(GPIOE, GPIO_Pin_8|GPIO_Pin_9|GPIO_Pin_10|GPIO_Pin_11|GPIO_Pin_12|GPIO_Pin_13); break; case 1: GPIO_SetBits(GPIOE, GPIO_Pin_9|GPIO_Pin_10); break; case 2: GPIO_SetBits(GPIOE, GPIO_Pin_8|GPIO_Pin_9|GPIO_Pin_11|GPIO_Pin_12|GPIO_Pin_14); break; case 3: GPIO_SetBits(GPIOE,GPIO_Pin_8|GPIO_Pin_9|GPIO_Pin_10|GPIO_Pin_11|GPIO_Pin_14); break; case 4: GPIO_SetBits(GPIOE, GPIO_Pin_9|GPIO_Pin_10|GPIO_Pin_13|GPIO_Pin_14); break; case 5: GPIO_SetBits(GPIOE, GPIO_Pin_8|GPIO_Pin_10|GPIO_Pin_11|GPIO_Pin_13|GPIO_Pin_14); break; case 6: GPIO_SetBits(GPIOE, GPIO_Pin_8|GPIO_Pin_10|GPIO_Pin_11|GPIO_Pin_12|GPIO_Pin_13|GPIO_Pin_14); break; case 7: GPIO_SetBits(GPIOE, GPIO_Pin_8|GPIO_Pin_9|GPIO_Pin_10); break; case 8: GPIO_SetBits(GPIOE, GPIO_Pin_8|GPIO_Pin_9|GPIO_Pin_10|GPIO_Pin_11|GPIO_Pin_12|GPIO_Pin_13|GPIO_Pin_14); break; case 9: GPIO_SetBits(GPIOE, GPIO_Pin_8|GPIO_Pin_9|GPIO_Pin_10|GPIO_Pin_13|GPIO_Pin_14); break; default: break; }
进入函数后,先把所有的LED都熄灭。
然后用if语句判断dot参数,如果等于1,就点亮小数点。
再用switch语句,根据num的值,点亮不同的LED,组成num指定的数字。
如此,就是实现了功能,在main函数中调用这个函数,就能显示指定的数字了。(先用调试器加断点运行查看执行结果)
-
用同时操作一组IO口的方法
上面的方法尽管实现了功能,但是你有没有觉得,这么简单的功能用了这么复杂的代码,是不是很不美?代码也很啰嗦。
其实我们有更简洁的方法。很多同学可能想到直接把GPIO_SetBits函数的第二个参数定义为一个数字,看起来就更简洁了。
我们说的不是这种优化,我们能把代码优化得很简单。(用GPIO_SetBits也可以优化,大家自己实验)
先看下GPIO_SetBits和GPIO_ResetBits函数。这两个函数操作不同的寄存器,对指定的IO进行置位或清零。
如果我们去查规格书,可以发现我们可以直接设置一个寄存器输出0或1,而不是置位或清零操作。不用看寄存器,直接看库函数也能看到。
注意额,不是GPIO_WriteBit,这个函数只是将GPIO_SetBits和GPIO_ResetBits组合使用。
我们说的是GPIO_Write,这个函数直接写ODR寄存器,你写入什么值,IO口就输出什么值。
用这个函数还有一个好处:将8个LED当做一个整体。
代码如下:
if(dot == 1) { GPIO_SetBits(GPIOE, GPIO_Pin_7); } switch(num) { case 0: GPIO_Write(GPIOE, 0x3f00); break; case 1: GPIO_Write(GPIOE, 0x0600); break; case 2: GPIO_Write(GPIOE, 0x5b00); break; case 3: GPIO_Write(GPIOE,0x4f00); break; case 4: GPIO_Write(GPIOE, 0x6600); break; case 5: GPIO_Write(GPIOE, 0x6d00); break; case 6: GPIO_Write(GPIOE, 0x7d00); break; case 7: GPIO_Write(GPIOE, 0x0700); break; case 8: GPIO_Write(GPIOE,0x7f00); break; case 9: GPIO_Write(GPIOE, 0x6700); break; default: break; }
这种方法跟置位方法的区别是:
置位需要两步,先清所有IO。
再置位要点亮的IO。
GPIO_Write一步就可以将所有IO设置为指定值。
-
用查表法
表是什么?表,就是数组。
从上面的switch我们可以看出,不同数字对应不同的值。而且:
switch的参数num是连续的值0~9
因此我们可以用表获取要写到IO口的值,然后,抛弃switch。
/* 定义一个全局数组SegTab,数组成员类型是uint16_t 并初始化数组。 这个数组是数码管显示0-9的段定义。 请看seg_display函数, 例如,第一个值是0x3f00 在seg_display,取这个数,输出到IO口,LED就能显示0。 */ uint16_t SegTab[10]={0x3f00, 0x0600, 0x5b00, 0x4f00, 0x6600, 0x6d00, 0x7d00, 0x0700, 0x7f00, 0x6700}; .......... if(dot == 1) { GPIO_SetBits(GPIOE, GPIO_Pin_7); } if(num >= 10) return; GPIO_Write(GPIOE, SegTab[num]);
用了表,SegTab表中的值就是对应的数码管点亮值。
很长的switch变为一行代码。
-
修复同时控制一组IOBUG
用上面的函数做实验,我们会发现,其他IO口也被我们控制了,不应该这样。
原因是GPIO_Write一次性写一组IO,但是我们只是用了其中的8个IO。另外8根IO也被我们输出为0了。
解决这个问题的方法就是:
读回–>修改–>写进
记住,这是一个重要方法。
代码如下:
uint16_t tmp; if(dot == 1) { GPIO_SetBits(GPIOE, GPIO_Pin_7); } if(num >= 10) return; tmp = GPIO_ReadOutputData(GPIOE); tmp = tmp&0x80ff; tmp = tmp | SegTab[num]; GPIO_Write(GPIOE, tmp);
GPIO_ReadOutputData读回当前GPIOE的输出值,注意,是读输出值,而不是输入值。
位与上0x80ff,意思是将为0的位清零,这些位就是我们准备要设置的IO口。
位或上要写的值SegTab[num],或的功能是有1为1。
再输出。
如此,GPIO_Write操作就只会改变数码管的IO口。
请先理解位与和位或。
和逻辑与逻辑或是不一样的。
-
小数点也合进来。
小数点的IO正好也在GPIOE,同一组IO口,可以合并进来。
最终代码
/* 写一个IO口,回读--写模式 为什么呢?因为我们只是使用了一个IO口中的几个管脚 比如GPIOE,一共有16个脚, 我们只是用了8个脚。 GPIO_Write函数是一次性设置16个脚。 如果不回读直接设置,那么,除了我们使用的8个脚之外的脚就会被意外改变。 */ tmp = GPIO_ReadOutputData(GPIOE); /*清空我们使用的几个管脚对应的位*/ tmp = tmp&0x807f;//位与,注意和&&的区别,&&是逻辑比较 /* 将我们要使用的几个管脚设置为我们需要的值, 比如,显示0,那么值就是 SegTab[0], 也就是0x3f00, 或操作是有1为1. 那么,经过下面的或操作, 我们的管脚,需要设置为1的位,就会是1, 我们不使用的管脚,原来是1的,现在也不会被改变,还是1. */ tmp = tmp | SegTab[num];//位或,注意和||的区别 if(dot == 1) { /* 如果需要显示数码管的小数点,就将对应位设置为1 0x0080, 为1的位是bit7,因为数码管的小数点接在GPIOE.7上。 */ tmp = tmp | 0x0080; } GPIO_Write(GPIOE, tmp);
本文档没列出所有代码,请查看例程代码获取完整版本。
7.5. 结束
SegTab这个数组,就是在数码管这个现实设备上显示数字的点阵字库。
8. 程序各种要素说明
这节课我们用一个最简单的程序跟大家讲清楚程序的构成。(请看视频)
8.1. 概述
- 硬件
首先要知道硬件的组成。
在前面章节我们说过,芯片包含Flash和RAM。
他们虽然不是相同的东西,但是都属于同一个地址空间,32位芯片的地址空间大小是4G。
比如ST32,FLASH通常从0X8000000开始,而RAM就从0x20000000开始。
高级点的芯片,可能会有外部SDRAM,内核也会为这SDRAM分配一段地址。
地址,就是地址,比如你们家的门牌号,酒店的房间号。
TODO添加STM32芯片地址映射图。
- 程序
程序包含什么?
写代码的时候包含函数过程和变量。
编译得到的目标文件包含函数过程和变量的初始化值。
- 变量
变量有很多种:全局变量,局部变量、静态变量。。。
变量保存在哪里?
下面我们就从一个简单的程序来分析上面问题。
8.2. 包罗万象的小程序
8.2.1. 程序入口
程序入口,程序启动执行的第一条代码就叫程序入口。
或者说,芯片上电开始执行的第1条用户代码。
这条代码在哪?
我们写代码,通常都是从main函数开始写,我们也会把main函数叫做函数入口。
那么main函数是芯片复位的第一条代码吗?
实际不是,在执行main函数之前,已经执行了很多代码了。
其中最早执行的,也就是芯片复位的第一条代码,就是我们经常说的启动代码。
在我们的STM32工程中,启动代码就是startup_stm32f10x_hd.s。
这是一个汇编文件。我们一起来看看这个启动代码。这个文件是一个汇编文件。
; 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
DCD MemManage_Handler ; MPU Fault Handler
DCD BusFault_Handler ; Bus Fault Handler
DCD UsageFault_Handler ; Usage Fault Handler
DCD 0 ; Reserved
这就是启动代码的入口。但是这里放的并不是代码,而是函数指针,这些函数指针就是中断向量。
DCD的意思是分配一个空间来保存后面的值。
__Vectors是一个标号,等下在分散加载文件中会提到。
现在我们只要知道这里保存的是中断向量,并且,复位也是一个中断。
当芯片复位时,芯片从这里找到对应的函数指针Reset_Handler,然后跳到这个函数执行。
这个函数同样在启动文件中,如下:
; Reset handler
Reset_Handler PROC
EXPORT Reset_Handler [WEAK]
IMPORT __main
IMPORT SystemInit
LDR R0, =SystemInit
BLX R0
LDR R0, =__main
BX R0
ENDP
复位后芯片做了什么呢?
- 调用SystemInit函数。
- 调用__main。
SystemInit在system_stm32f10x.c文件中,这个函数完成芯片的时钟配置。
__main函数在哪呢?在工程中找不到的。是不是main?不是。
这是一个编译系统根据不同芯片生成的一个库函数。
在这个库函数中完成变量(RAM)的初始化,居然后跳到真正的main函数执行。
8.2.2. 函数
int main(void)是我们接触的第一个函数。
函数的定义包含名称、参数、返回值。
我们可以定义一些子函数。
8.2.3. 变量
- 全局变量
在函数外定义的叫做全局变量。
比如main函数中,SegTab就是一个全局变量,这个变量的类型是一个uint16_t数组。
/*
定义一个全局数组SegTab,数组成员类型是uint16_t
并初始化数组。
这个数组是数码管显示0-9的段定义。
请看seg_display函数,
例如,第一个值是0x3f00
在seg_display,取这个数,输出到IO口,LED就能显示0。
*/
uint16_t SegTab[10]={0x3f00, 0x0600, 0x5b00, 0x4f00, 0x6600, 0x6d00, 0x7d00, 0x0700, 0x7f00, 0x6700};
变量是保存在RAM中的,我们都知道RAM是易失性存储,掉电数据就没了,那数组的些值是如何赋值给数组的呢?
这问题有两个方面:
- 编译的时候,这些值会保存在代码中。同时还保存这些值和变量的关系。(细节暂时不研究)
- 在启动代码中,执行__main函数时,会根据这些关系执行初始化变量的过程,然后才执行用户的main函数。
- 这个过程就是编译器生成的,如果你用一些很便宜的单片机,比如台湾的一些小单片机,这个过程就需要自己写代码实现,通常是用汇编写。
- 局部变量
在函数内定义的变量就是局部变量,例如seg_display函数中的tmp就是一个局部变量。
/*
定义一个seg_display
输入参数有2个,分别是char型的num,char型的dot
没有返回值。
*/
void seg_display(char num, char dot)
{
uint16_t tmp;
局部变量同样也是在RAM上。但是具体在哪呢?地址是哪里?
局部变量的地址是不固定的。当调用函数时,从栈上分配。函数退出后就释放了。
-
变量有效域
局部变量只在函数中有效。
全局变量呢?
这个不是芯片的知识,是C语言的知识。和编译系统也有关系,在MDK中,全局变量在声明之后的C代码中都可以调用。
还可以通过EXTERN在外部文件中声明后调用。
局部变量可以通过static定义成类似全局变量,但仅限本函数使用。
static还可以限制全局变量只在本文件有效。
8.3. 分散加载文件
为什么启动代码就是上电执行的第一条指令呢?
因为我们用分散加载文件(链接文件)指定启动代码保存在芯片复位时指向的位置。
分析分散加载文件
; ************************************************************* ; *** Scatter-Loading Description File generated by uVision *** ; *************************************************************
LR_IROM1 0x08000000 0x00080000 { ; load region size_region ER_IROM1 0x08000000 0x00080000 { ; load address = execution address *.o (RESET, +First) *(InRoot$$Sections) .ANY (+RO) } RW_IRAM1 0x20000000 0x00010000 { ; RW data .ANY (+RW +ZI) } }
定义了IROM1,地址和范围就是芯片Flash的定义。
其中,放在最全面的是reset,也就是启动代码中定义的AREA RESET, DATA, READONLY
。
紧接则放置的是InRoot$$Sections,这些代码是编译器链接时根据芯片和内核自动添加的。
我们可以认为这就是__main。
最后放其他代码,也就是RO段。
定义IRAM1,就是RAM,内存。
所有的RW段和ZI段都放在RAM中。
8.4. 编译结果如何看?
- 在MDK IDE界面有编译过程和最终结果:
compiling stm32f10x_sdio.c...
compiling stm32f10x_rcc.c...
compiling stm32f10x_usart.c...
compiling stm32f10x_spi.c...
compiling stm32f10x_tim.c...
compiling main.c...
compiling stm32f10x_wwdg.c...
linking...
Program Size: Code=1336 RO-data=336 RW-data=24 ZI-data=1632
FromELF: creating hex file...
".\Objects\stm32_tech.axf" - 0 Error(s), 0 Warning(s).
Build Time Elapsed: 00:00:19
Program Size: Code=1336 RO-data=336 RW-data=24 ZI-data=1632
这一句说明生成的目标文件大小,代码1336字节,RO(只读变量)336字节,RW(读写变量)24字节,ZI数据1632字节。
-
更细的情况,可以通过map文件查看。map文件在
Listings\
目录下,名字叫stm32_tech.map
。用文件编辑器打开就能看到内容。
map文件最开始是最细的地方,最后是整体情况。拖到最后,就能看到下面内容:
============================================================================== Code (inc. data) RO Data RW Data ZI Data Debug 1336 96 336 24 1632 236688 Grand Totals 1336 96 336 24 1632 236688 ELF Image Totals 1336 96 336 24 0 0 ROM Totals ============================================================================== Total RO Size (Code + RO Data) 1672 ( 1.63kB) Total RW Size (RW Data + ZI Data) 1656 ( 1.62kB) Total ROM Size (Code + RO Data + RW Data) 1696 ( 1.66kB) ==============================================================================
这些内容跟IDE中看到的基本类似。这是程序的总体情况。有一个地方需要注意:
Total RO Size (Code + RO Data):
Total RW Size (RW Data + ZI Data)
Total ROM Size (Code + RO Data + RW Data)
Total ROM Size就是最终的目标文件,也就是写到FLASH上的内容,请问,为什么包含RW Data的大小?
因为RW数据需要一个初始化值,这个值并不是凭空而来,而是代码中定义了,编译后保存在ROM中。
所以ROM会包含RW。
往回看,则是Image component sizes。map文件每个大段之间用等号分开。
============================================================================== Image component sizes Code (inc. data) RO Data RW Data ZI Data Debug Object Name 304 20 0 24 0 1705 main.o 0 0 0 0 0 203136 misc.o 64 26 304 0 1536 792 startup_stm32f10x_hd.o 298 0 0 0 0 12407 stm32f10x_gpio.o 26 0 0 0 0 16706 stm32f10x_it.o 32 6 0 0 0 557 stm32f10x_rcc.o 328 28 0 0 0 1845 system_stm32f10x.o ---------------------------------------------------------------------- 1058 80 336 24 1536 237148 Object Totals 0 0 32 0 0 0 (incl. Generated) 6 0 0 0 0 0 (incl. Padding) ---------------------------------------------------------------------- Code (inc. data) RO Data RW Data ZI Data Debug Library Member Name 8 0 0 0 0 68 __main.o
本段说明了组成程序的各个文件的信息,每一个.o文件对应一个.c文件。
从这我们还能看到程序暗地里使用了多少个函数库。
再往上:
Memory Map of the image
,说明各文件使用的RAM分类情况。Image Symbol Table
,这是个文件中使用的函数和RAM情况。在往上的内容我们基本也不会看了。
-
这里我们关键看下函数入口的情况。
RESET 0x08000000 Section 304 startup_stm32f10x_hd.o(RESET) !!!main 0x08000130 Section 8 __main.o(!!!main) !!!scatter 0x08000138 Section 52 __scatter.o(!!!scatter) !!handler_copy 0x0800016c Section 26 __scatter_copy.o(!!handler_copy) !!handler_zi 0x08000188 Section 28 __scatter_zi.o(!!handler_zi)
在0x08000000,放的确实是向量表。芯片复位时就会从这里开始执行代码。
除了__main,还有一些我们不知道是什么东西的代码放在启动代码后面。
8.5. 为什么能控制外设?
因为有外设寄存器。
外设寄存器是跟RAM一样的存在。(RAM是可以读写的,外设寄存器有些不能写)。
这些寄存器链接到对应的硬件。
TOTO请看规格书地址空间map图。
我们只要写这些寄存器,就能实现对应外设的功能。
我们看ST提供的库,比如下面函数
void GPIO_SetBits(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)
{
/* Check the parameters */
assert_param(IS_GPIO_ALL_PERIPH(GPIOx));
assert_param(IS_GPIO_PIN(GPIO_Pin));
GPIOx->BSRR = GPIO_Pin;
}
要设置一个GPIO的输出,就是配置GPIOx->BSRR = GPIO_Pin;
这时什么意思呢?
typedef struct
{
__IO uint32_t CRL;
__IO uint32_t CRH;
__IO uint32_t IDR;
__IO uint32_t ODR;
__IO uint32_t BSRR;
__IO uint32_t BRR;
__IO uint32_t LCKR;
} GPIO_TypeDef;
我们可以看到,BSRR 是结构体GPIO_TypeDef的内容。
GPIO_SetBits(GPIOE, GPIO_Pin_7);
使用这个函数的时候我们会传入一个GPIOE,这是一个GPIO_TypeDef结构体指针。
而GPIOE的定义是下面这些宏定义:
#define PERIPH_BASE ((uint32_t)0x40000000) /*!< Peripheral base address in the alias region */
#define APB2PERIPH_BASE (PERIPH_BASE + 0x10000)
#define GPIOE_BASE (APB2PERIPH_BASE + 0x1800)
#define GPIOE ((GPIO_TypeDef *) GPIOE_BASE)
意思是:
在
0x40000000
这个地址上,是PERIPH_BASE
,也就是PERIPH
外设的基地址,起始地址。在
PERIPH_BASE
偏移0x10000
的地方,放的是APB2PERIPH
,也就是APB2
总线上的外设。在
APB2PERIPH_BASE
偏移0x1800
的地方,是GPIOE
外设寄存器地址。第四行则是把一个
uint32_t
的值强行类型转换为GPIO_TypeDef
指针。如此,就能通过
GPIOE
这个宏定义找到GPIOE
外设的相关寄存器。
9. 动态扫描数码管
前面我们学习了如何使用一位LED显示数字,很简单是吧?
现在我们加点难度。一位数码管只能显示一位数字,现在我们要显示8位数字(或者显示时间)。
那么我们就需要8位数码管,如果按照1位数码管的硬件接法,8位数码管就需要64根IO。
相当于1个LED使用1根IO口控制。
大家觉得可行吗?当然可行,我们芯片有100根管脚,80多根IO。
但是你只打算用芯片控制8位数码管吗?肯定不是嘛!这样的方案肯定是非常浪费IO的。
那怎么办嗯?要解决这个问题,要用到一个原理
,两个芯片
。
9.1. 一个原理
不知道大家是否了解过以前的胶片电影,一张一张的画片,连续播放就能看到活生生的,会动的人。
这是为什么呢?
原理是“视觉暂留”。
科学实验证明,人眼在某个视像消失后,仍可使该物像在视网膜上滞留0.1-0.4秒左右。电影胶片以每秒24格画面匀速转动,一系列静态画面就会因视觉暂留作用而造成一种连续的视觉印象,产生逼真的动感。
我们在数码管上能不能用这个原理呢?
8个数码管都用同样的IO控制亮灭,轮流显示。
只要同一个LED的点亮间隔不大于0.4秒(实际要比这个小),那么我们就会一直看到这个数码管是亮着的。(和真正一直亮会有什么差别?)
这样我们就只要8根IO了?
如何选择8个数码管该点亮哪个?
前面我们用共阴极数码管,阴极是接到地线的。
我们可以用IO口控制阴极,只有对应的IO是低电平,这个数码管才有亮。如果阴极是高电平,数码管就不会亮。
如此,我们需要8+8根IO就够了。省去了48根IO,太有成就了。
9.2. 两个芯片
我们都是高兴太早,通常IO口还是不够, 使用16根IO也是很浪费的。
那这么办呢?利用数字电路,有两个芯片能帮上我们的忙。
74HC138和74HC595。
138是三八译码器,595是8位串行输入、并行输出的位移缓存器。
74是一系列数字功能芯片,注意中间字母的区别。我们选用的是HC类型,HC表示是CMOS电平,或者简单说就是3.3V电压。
-
三八译码器
三八译码器是什么?我们从数据手册一探究竟。
打开数据手册,标题:SNx4HC138 3-Line To 8-Line Decoders/Demultiplexers
翻译为中文就是:SNx4HC138 3线转8线译码器/多路分配器,怎么转呢?往下看。
我们选用的型号是SN74HC138PWR。型号这些数字和字母都是什么意思呢?
SN74是芯片系列。
HC是芯片种类。
138是芯片具体型号。
PW是封装,TSSOP16。
R包装形式,编带。
138的功能:用3根线的电平,选择8根线中的一根线输出低电平,其他输出高电平。
芯片电气信号
真值表如下:
[外链图片转存中…(img-mJ1o0ZfK-1645022852665)]
左边是输入,右边是输出。
ENABLE信号通常我们输出H-L-L,也就是默认使能,不进行控制。
C/B/A,38译码器3根输入线,一共有8种组合。
输出信号8根,根据3根输入线的状态,选择其中1根输出低电平,其他线输出高电平。
因此,选中的数码管是低电平,那么就只能用共阴极数码管。
-
595功能
打开595手册
标题:8-Bit Shift Registers With 3-State Output Registers
意思:8位移位寄存器,具有3态输出。
我们选用的型号是:SN74HC595PWR, 名称含义与138类似。
芯片电气信号
时序图
[外链图片转存中…(img-3d3ygTeg-1645022852666)]
14脚SER输入,11 脚SRCLK上升沿,从14脚输入1位数据。8次之后,就有一个BYTE的数据保存在595中。当时钟继续输出,数据将从9脚输出,因此,可以通过多个595串联实现更多的移位位数。两个595就可以组成16位移位寄存器。
12脚RCLK上升沿,保存在595中的8位数据,从595的8个并行输出引脚输出(OE需要低电平)
10脚SRCLR是复位脚,低电平有效 ,上电后输出高即可。
更多细节可参考:https://baike.baidu.com/item/74HC595/9886491
我们用三八译码器控制刷管的共阴极,595控制数码管的正极。三八译码器决定哪个数码管亮,595决定亮的内容。如此,我们就只需要7个IO口就搞定了。
9.3. 硬件原理
节省IO是一种共识,所以要用8位数码管时,我们不需要用8位单独的数码管组成。
而是用2个内部连接好信号的4位数码管。如下图:
[外链图片转存中…(img-AmISNISi-1645022852666)]
这种数码管内部已经将共用的信号连在一起。同样,也有共阴极和共阳极数码管之分。
内部连接信号如下:
[外链图片转存中…(img-o9LPVyVM-1645022852667)]
[外链图片转存中…(img-8PLVK4bm-1645022852667)]
电路图根据前面分析的原理设计,如下图:
9.4. 调试
9.4.1. 第一步
静态显示,38译码器设定一个固定输出,选中一个数码管,控制595输出,让数码管显示不同数字。
-
初始化硬件
/* 595_SDI--- ADC-TPX---PB0---数据输入 595_LCLK---ADC-TPY---PB1---数据锁存---上升沿锁存 595_SCLK---TP-S0---PC5---数据移位---上升沿移位 595_RST---TP-S1---PC4---芯片复位--低电平复位 A138_A0---FSMC_D2---PD0 A138_A1---FSMC_D1---PD15 A138_A2---FSMC_D0---PD14 */ void seg_init(void) { /* GPIOD Periph clock enable */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOD, ENABLE); /* 38译码器输入0 ,选中第4个数码管*/ GPIO_ResetBits(GPIOD, GPIO_Pin_0|GPIO_Pin_14|GPIO_Pin_15); /* Configure PD0 and PD2 in output pushpull mode */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0|GPIO_Pin_14|GPIO_Pin_15; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_Init(GPIOD, &GPIO_InitStructure); GPIO_ResetBits(GPIOB, GPIO_Pin_0|GPIO_Pin_1); /* Configure PD0 and PD2 in output pushpull mode */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0|GPIO_Pin_1; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_Init(GPIOB, &GPIO_InitStructure); GPIO_ResetBits(GPIOC, GPIO_Pin_4|GPIO_Pin_5); /* Configure PD0 and PD2 in output pushpull mode */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4|GPIO_Pin_5; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_Init(GPIOC, &GPIO_InitStructure); /* 拉高复位信号 */ GPIO_SetBits(GPIOC, GPIO_Pin_4); }
-
138驱动
/* 选择数码管,控制138选中对应数码管 pos参数就是位置 */ void seg_select(uint8_t pos) { if (pos == 1) { GPIO_SetBits(GPIOD, GPIO_Pin_14); GPIO_ResetBits(GPIOD, GPIO_Pin_0|GPIO_Pin_15); } else if (pos == 2) { GPIO_SetBits(GPIOD, GPIO_Pin_0|GPIO_Pin_14); GPIO_ResetBits(GPIOD, GPIO_Pin_15); } else if(pos == 3) { GPIO_SetBits(GPIOD, GPIO_Pin_15|GPIO_Pin_14); GPIO_ResetBits(GPIOD, GPIO_Pin_0); } else if(pos == 4) { GPIO_SetBits(GPIOD, GPIO_Pin_0|GPIO_Pin_15|GPIO_Pin_14); } else if (pos == 5) { GPIO_ResetBits(GPIOD, GPIO_Pin_0|GPIO_Pin_15|GPIO_Pin_14); } else if(pos == 6) { GPIO_ResetBits(GPIOD, GPIO_Pin_15|GPIO_Pin_14); GPIO_SetBits(GPIOD, GPIO_Pin_0); } else if(pos == 7) { GPIO_ResetBits(GPIOD, GPIO_Pin_0|GPIO_Pin_14); GPIO_SetBits(GPIOD, GPIO_Pin_15); } else if(pos == 8) { GPIO_ResetBits(GPIOD, GPIO_Pin_14); GPIO_SetBits(GPIOD, GPIO_Pin_0|GPIO_Pin_15); } }
-
595驱动
/* 输出一位数码管显示数据 */ void seg_display_1seg(uint8_t segbit) { uint8_t tmp; uint8_t cnt = 0; tmp = segbit; cnt = 0; /* 拉低 595_LCLK*/ GPIO_ResetBits(GPIOB, GPIO_Pin_1); while(1) { /* 拉低 595_SCLK*/ GPIO_ResetBits(GPIOC, GPIO_Pin_5); /* 将数据从 SDI发出去*/ if((tmp & 0x80)== 0x00)//注意操作符的优先级 { GPIO_ResetBits(GPIOB, GPIO_Pin_0); } else { GPIO_SetBits(GPIOB, GPIO_Pin_0); } tmp = tmp<<1; //移位 delay(100); /* 拉高 595_SCLK 移位数据 */ GPIO_SetBits(GPIOC, GPIO_Pin_5); delay(100); cnt++; if(cnt >= 8) break; } GPIO_SetBits(GPIOB, GPIO_Pin_1); delay(100); }
-
应用
在main中初始化数码管,138固定输出值,调用595驱动函数输出各种数字。
seg_init(); /* 第一步,调试595和138功能 在第1个数码管显示0-9 */ seg_select(1); seg_display_1seg(0x3f); seg_display_1seg(0x06); seg_display_1seg(0x5b); seg_display_1seg(0x4f); seg_display_1seg(0x66); seg_display_1seg(0x6d); seg_display_1seg(0x7d); seg_display_1seg(0x07); seg_display_1seg(0x7f); seg_display_1seg(0x67); seg_display_1seg(0x3f|0x80);
输出数字对应的数码管段值,列入一个数组,索引就是数字,比如显示数字1,输出的数码管段值就是SegTab1, 也就是0x06。
uint8_t SegTab[10]={0x3f, 0x06, 0x5b, 0x4f, 0x66, 0x6d, 0x7d, 0x07, 0x7f, 0x67};
单步运行看效果。
9.4.2. 第二步
固定显示,595输出固定值,调试38译码器,让数字在数码管上轮流显示相同的数字。
代码和第一步类似。
经过第一第二步调试后,138和595驱动就完成了。
9.4.3. 第三步
第一第二步只是实现了单个数码管显示,属于静态显示。
前面讲原理时讲过,8位数码管使用动态显示方法。需要将38译码器和595配合,才能动态刷8位数据,我们现在尝试固定显示12345678。
动态刷新是一个循环,因此放在while循环中实现。
代码如下:
seg_select(1);
seg_display_1seg(0x3f);
seg_select(2);
seg_display_1seg(0x06);
seg_select(3);
seg_display_1seg(0x5b);
seg_select(4);
seg_display_1seg(0x4f);
seg_select(5);
seg_display_1seg(0x66);
seg_select(6);
seg_display_1seg(0x6d);
seg_select(7);
seg_display_1seg(0x7d);
seg_select(8);
seg_display_1seg(0x07);
单步运行,看效果。发现一个问题,在调用138切换数码管时,会将前面显示的内容显示到下一个位置。
比如,数码管1显示1,调用数码管138切换显示位置到数码管2,这时,数码管2会显示1。这是个问题。
我们全速运行程序看看效果。显示的内容并不是87654321,而是76543218,而且有重影,影子隐隐约约是我们要的效果:87654321。
如何解决这个问题呢?
方法是:在切换显示位置前,将显示内容清零,也就是将595的输出内容输出为0。
增加一个seg_clear函数实现这个功能。实现如下:
void seg_clear(void)
{
uint8_t cnt = 0;
cnt = 0;
/* 拉低 595_LCLK*/
GPIO_ResetBits(GPIOB, GPIO_Pin_1);
while(1) {
/* 拉低 595_SCLK*/
GPIO_ResetBits(GPIOC, GPIO_Pin_5);
/* 将数据从 SDI*/
GPIO_ResetBits(GPIOB, GPIO_Pin_0);
delay(100);
/* 拉高 595_SCLK 移位数据 */
GPIO_SetBits(GPIOC, GPIO_Pin_5);
delay(100);
cnt++;
if(cnt >= 8)
break;
}
GPIO_SetBits(GPIOB, GPIO_Pin_1);
delay(100);
}
在前面的测试函数中所有seg_select函数之前都添加本函数。
编译下载全速运行,效果正常。
9.4.4. 第四步
经过第三步调试,8位数码管的功能已经实现了。
那,驱动算完成了吗?没有。为什么?
先介绍一个很重要的概念:时间片。
什么是时间片呢?拿LED和8位数码管进行对比。
LED,只要将IO置位,就能点亮,之后如果不改变LED状态,不需要再管它。
8位数码管呢?因为我们用动态扫描方法,不能仅仅将8位数码管输出一次内容之后就不管了,要一直刷新。
前面原理也说过,每个数码管刷一次的时间间隔不能小于24ms。
这种需要定时操作的,我们通常就说这个功能需要时间片。
好,了解了时间片。那么应用程序要如何使用呢?
应用程序只是想在数码管上显示一些数字而已,数码管怎么显示的,它是不管的。
为了显示数字,让应用程序间隔24ms就调用你的程序刷新显示,这明显不合理,专业术语叫强耦合,本来不相关的。
讲到这,不知道大家是否明白。不了解也没关系,后面再慢慢理解。
总之,矛盾就是:应用只是想显示一数字,数码管驱动要时间片维持显示。
怎么实现呢?用缓冲。缓冲就是一个组数,这个数组是应用和驱动之间的联系。
应用程序将要显示的内容放到缓冲。驱动将缓冲中的内容显示到数码管。
如此,就达到了最简单的模块分离。
程序设计中有一个理论:生产者和消费者。
数码管驱动和应用虽然不是真正的生产者和消费者,但是使用缓冲的逻辑是相似的。
-
有8位数码管,就定义包含8个空间的数组。
/* 动态扫描 添加缓冲功能 */ /* 8位数码管的显示内容 */ char BufIndex = 0; /* 缓冲,保存的是对应数码管段值 */ char Seg8DisBuf[8]={0x7f,0x07,0x7d,0x6d,0x66,0x4f,0x5b,0x06};
-
定义一个函数,用于动态刷新数码管。这个函数最好放在定时或者RTOS的定时任务中执行。现在我们还没学会,可以放在main函数中的while运行。
/* 动态刷新 定时调用本函数, 本函数对应用层屏蔽,意思是:应用层不知道我是通过动态刷新实现8位数码管功能。 */ void seg_display_task(void ) { seg_clear(); seg_select(BufIndex+1); seg_display_1seg(Seg8DisBuf[BufIndex]); BufIndex++; if(BufIndex >=8) BufIndex = 0; }
-
定义一个函数,给应用程序调用,改变数码管缓冲的值。
/* segbit 数码管段值,为1的bit点亮 seg 数码管位置,1~8 */ void seg_fill_disbuf(uint8_t segbit, uint8_t seg) { Seg8DisBuf[seg-1] = segbit; return; }
-
在main函数中调用数码管刷新功能。
/*-----------------驱动--------------*/ /* 使用显示缓冲方法,要改变显示内容, 调用函数seg_fill_disbuf改变Seg8DisBuf中的内容即可 */ seg_display_task(); /*-----------------应用-----------------*/ cnt++; if(cnt >= 1000) { cnt=0; disnum ++; if(disnum > 9) disnum = 0; seg_fill_disbuf(SegTab[disnum], 1); } /*----------------------------------*/ delay(1000);
驱动是数码管的内容,while循环最后delay 1000,也就是刷新间隔。现在没定时器,暂时定一个值,数码管不闪烁即可。
应用就是延时1000次个delay(1000)后,改变数码管1显示的数字,从0显示到9。
编译下载看效果。
10. 代码结构调整
传说有人写的程序只有一个main.c,一万行代码,这是一个神奇的故事
本节主要通过代码讲解如何模块化代码。
10.1. 概述
代码结构调整有很多方式,今天只说最简单的。
- 源码模块化—-接口标准化
- 硬件相关宏定义
10.2. 源码模块化
模块化步奏:
- 将一个功能、一个模块、一种设备的相关代码封装在同一个.c源文件中。
- 内部使用的函数用static宏控制,不允许外部使用。
- 内部的定义,比如宏、结构体,定义在c文件。
- 这个源文件有一个相同名字的头文件。
- 对外的定义,宏、结构体等,定义在头文件。
- 变量只能定义在源文件,不对外直接暴露变量。
我们就拿上一节的代码整理。
数码管,就是一个设备。这个设备提供的功能是什么呢?点亮对应的段?显示数字和小数点?
我认为:点亮对应的段,才是八段数码管的根本功能。
显示数字属于应用层功能。
为什么这样划分呢?有以下原因:
设备驱动尽量只提供自己能实现的功能本质,数码管的功能本质就是每个段点亮。
不同的段点亮后,组成的到底是数字还是字母,最好不要放在设备驱动中。
当项目越大,程序越复杂,参与开发的工程师多时,越能感觉到这样划分的合理性。
假如你数码管驱动只提供显示数字的接口,但是新项目需要显示一些自定义的字符,
那到底是改驱动还是改应用呢?
不过在实践上,数码管是一个小驱动,将显示数字接口放在设备驱动中,也并不是不可。
但设备较复杂,例如LCD,可以在驱动和应用间封装一层pannel层,用于实现各种应用需要的接口。
-
建立一个dev目录
在dev目录下建立两个文件:seg.c、seg.h
把
seg_init
,seg_display_1seg
、seg_select
、seg_clear
、seg_display_task
、seg_fill_disbuf
这几个函数拷贝到seg.c文件。同时拷贝
BufIndex
、Seg8DisBuf
这两个变量。 -
对外的函数是:
seg_display_task
、seg_fill_disbuf
、seg_init
,这三个函数拷声明贝到头文件seg.h,并且加上extern前缀。如下:#ifndef __SEG_H__ #define __SEG_H__ extern void seg_init(void); extern void seg_display_task(void ); extern void seg_fill_disbuf(uint8_t segbit, uint8_t seg); #endif
其中
#ifndef
等三个宏定义是为了解决重复包含的问题。每个头文件都会有这三行指令,后面的宏不一样而已。 -
为了防止外部函数调用内部函数,对没有在头文件声明的函数加上
static
,在外部调用此函数时,编译会出错。 -
把调试过程的函数剪切到seg.c,定义一个函数seg_test。
-
把变量
BufIndex
、Seg8DisBuf
也加上static前缀,seg.c文件外部就不能直接操作这两个变量了。 -
在main.c头部增加
#include "seg.h"
,包含seg驱动的头文件。 -
打开MDK工程,在工程目录新建一个目录,并添加seg.c到目录,并修改工程头文件路径。
-
重新编译下载。
10.3. 宏定义
宏定义的第一个好处:将某个定义在一个地方定义,后续要修改这个定义的时候,只要改一个地方即可。
在数码管的驱动中,硬件IO口的定义在seg_init
、seg_display_1seg
、seg_select
、seg_clear
四个函数中都有使用,如果我们要修改某个IO的硬件连接,可能就需要修改这四个函数。
如果我们将这些函数中硬件相关的定义统一到一个宏定义,只要修改一个地方,就能改变整个数码管驱动的硬件连接。
#define SEG_138_A0_PIN GPIO_Pin_0
#define SEG_138_A1_PIN GPIO_Pin_15
#define SEG_138_A2_PIN GPIO_Pin_14
#define SEG_138_A_PORT GPIOD
#define SEG_595_SDI_PIN GPIO_Pin_0
#define SEG_595_SDI_PORT GPIOB
#define SEG_595_LCLK_PIN GPIO_Pin_1
#define SEG_595_LCLK_PORT GPIOB
#define SEG_595_SCLK_PIN GPIO_Pin_5
#define SEG_595_SCLK_PORT GPIOC
#define SEG_595_RST_PIN GPIO_Pin_4
#define SEG_595_RST_PORT GPIOC
宏定义如上,相关函数修改为宏即可,具体见代码。
编译下载验证
10.4. 推荐
《林锐 高质量C-C++编程指南》
《嵌入式C精华》
11. IO输入与按键扫描
输出输入是GPIO的两种功能,前面我们点亮LED、控制数码管,用的是IO口输出。现在我们开始学习IO口输入功能。
11.1. 概述
IO口输入的意思就是:读取IO口上的电平:高电平为1,低电平为0。
通常我们默认默认高电平就是CPU工作电压,比如STM32就是3.3V。低电平就是GND电压,也就是0V。
但是其实这并不严格,在CPU的规格书中标有输入电压电平范围,比如00.6V,就认为是低电平,2.7V3.3V就是高电平。这些细节除非特殊应用场合,平常不用特别注意。
还有一个要点,一些复杂的芯片,会有多种输入电压,比如一些ARM9芯片,会有内核电压、内存电压、IO电压,GPIO的高低电平对应的是IO电压。
11.2. IO口配置
一个IO口做为输入,通常有哪些可以选择的配置呢?
不同的CPU会有差异,也有共同点。现在我们看看STM32配置一个IO口为输出,有哪些配置。
不知道大家还是否记得GPIOMode_TypeDef
枚举定义。请看下面:
typedef enum
{ GPIO_Mode_AIN = 0x0,
GPIO_Mode_IN_FLOATING = 0x04,
GPIO_Mode_IPD = 0x28,
GPIO_Mode_IPU = 0x48,
GPIO_Mode_Out_OD = 0x14,
GPIO_Mode_Out_PP = 0x10,
GPIO_Mode_AF_OD = 0x1C,
GPIO_Mode_AF_PP = 0x18
}GPIOMode_TypeDef;
上面就是STM32 IO的模式,输入的模式我们讲过了。哪些是输入模式呢?
GPIO_Mode_IN_FLOATING
、GPIO_Mode_IPD
、GPIO_Mode_IPU
这三种模式就是输入模式。从名字看,三种模式的区别仅仅是上下拉电阻配置不一样。分别是:FLOATING-浮空、IPD-下拉、IPU-上拉。
所以,如果用ST的库函数配置一个IO位输入,是非常简单的。
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_12;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_Init(GPIOB, &GPIO_InitStructure);
第1行代码选择要配置的IO,可以同时配置多个。
第2行选择速度。
第3行关键,输入模式。
然后调用GPIO_Init函数初始化即可。
那么如何获取输入状态呢?看库函数头文件都提供了哪些功能函数就可以知道了。
只有两个函数:
uint16_t GPIO_ReadInputData(GPIO_TypeDef* GPIOx)
...
uint8_t GPIO_ReadInputDataBit(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)
这两个函数的功能,注释说的很清楚。
第1个函数是读整组IO的状态,所以返回值是一个uint16_t,也就是一个16位的数,正好对应一组IO口。
第2个函数是读一个Bit,也就是一个指定的IO口的值。返回值是一个uint8_t,但是并不是会返回8位。看函数:返回值有两种,Bit_SET和Bit_RESET,高电平,返回Bit_SET,低电平返回Bit_RESET。
11.3. 按键原理
理解了IO输入原理,按键原理就简单了。
11.3.1. 按键硬件接法
一个IO只能输入两种状态,接在上面的按键,当然也只会有两种状态。
我们教学板上有4个按键接在IO口上,请看原理图:
[外链图片转存中…(img-6HTiwh1e-1645022852670)]
[外链图片转存中…(img-4TaB0F4f-1645022852671)]
4个按键的原理图是一样的。
按键一端接到地,另外一端接到IO口。
在IO口这端,通过一个电阻接到VCC,这个电阻就是我们常说的上拉电阻。
按键按下,IO口接到地,输入低电平。按键松开,IO口通过电阻接到VCC,输入高电平。
上拉电阻
如果我们把IO口配置为上拉模式,内部已经有一个上拉电阻。但是这个电阻阻值通常是固定的。
在某些情况,我们需要灵活配置上拉电阻。
上拉电阻作用是当按键没有按下时,把IO口的状态维持在文档的高电平,防止程序读到意外状态。
但是,这个电阻还有一个重要的要点,就是电流。当按键按住,VCC将通过这个电阻连接到地线。
流过的电流=VCC/R。
所以这个电阻不能选太小的,太小电流大。比如在一些低功耗设备,按键会一直按住,进入睡眠,如果电阻很小,电流就很大。有时我们会用1M的电阻,这样一个按键的电流就只有3uA。
但是电阻也不能选太大,越大的电阻本身寄生的电容电感就很大,很容有受到外部干扰。
通常,如果不是要求超低功耗的设备,用几K几十K的电阻。
超低功耗设备选择1M电阻,最大不超过3M。
11.4. 调试
11.4.1. 第一步
使用单步调试,确定IO口输入有反应。
初始化代码:
/* 按键接在 PB12 PB13 PB14 PB15*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_12|GPIO_Pin_13|GPIO_Pin_14|GPIO_Pin_15;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_Init(GPIOB, &GPIO_InitStructure);
测试代码
while(1)
{
/* 读一组IO口输入 */
sta = GPIO_ReadInputData(GPIOB);
/* 16位IO,按键用高四位,通过与操作 屏蔽其他低位
如果高四位不等于0xf,说明有按键按下*/
if ((sta & 0xf000) != 0xf000) {
/*没用的语句,用来测试是否进入这里*/
sta = 1;
}
}
编译后下载,进入debug模式。
在sta=1的地方加上断点,然后全速运行。
分别按下四个按键,看是否在断点处停止。
测试正常进入下一步。
11.4.2. 第二步
IO口能检测到按键按下了,但是还不能确认按键能用。
我们现在是检测到按下就停住了,正常程序是不会的吧?
如果全速运行,会发生什么呢?
前面我们调试了8位数码管,我们现在可以将IO口的状态显示在数码管上,看看全速运行时按键是什么状态。
在8位数码管的应用中,将定时刷新数码管应用改为下面
io_sta = GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_12);
if (io_sta == Bit_RESET) {
seg_fill_disbuf(SegTab[0], 1);
} else {
seg_fill_disbuf(SegTab[1], 1);
}
io_sta = GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_13);
if (io_sta == Bit_RESET) {
seg_fill_disbuf(SegTab[0], 2);
} else {
seg_fill_disbuf(SegTab[1], 2);
}
io_sta = GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_14);
if (io_sta == Bit_RESET) {
seg_fill_disbuf(SegTab[0], 3);
} else {
seg_fill_disbuf(SegTab[1], 3);
}
io_sta = GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_15);
if (io_sta == Bit_RESET) {
seg_fill_disbuf(SegTab[0], 4);
} else {
seg_fill_disbuf(SegTab[1], 4);
}
按下对应按键时将对应数码管显示为1,松开按键,数码管显示0。
下载测试,看起来还很好,能反应IO口状态变化。那是不是真的呢?
11.4.3. 第三步
上一步测试,数码管能正常显示IO口状态,但是其实是不稳定的。
我们改一个程序试下:把第一个按键的程序改为下面:
io_sta = GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_12);
if (io_sta == Bit_RESET) {
cnt++;
if(cnt > 9)
cnt = 0;
seg_fill_disbuf(SegTab[cnt], 1);
} else {
}
按键按下一次,显示数字加1,到9后返回0,循环显示。
编译下载测试。什么效果?
数码管1数字确实在加,但是并不是我们要的效果,我们想象的效果是按下一次,数码管加1。现在按下一次,数码管变化几个数字。
为什么?
11.4.4. 第四步
第三步的效果跟我们想象不一样。两个原因:
- 按键抖动。
- CPU跑的很快,读IO口的状态也很快。
-
按键抖动
按键,是机械结构,按下时接触点之间会弹动。我们认为按键按下,电平就立刻从高电平转为低电平,实际并不是这样。
实际的图如下:
[外链图片转存中…(img-jnb3Bjug-1645022852671)]
无论按键按下还是松开,IO口的状态都不是立刻、稳定变化。电平状态会来回抖动。
当CPU读IO口的速度很快时,将会读都多个低电平。
这个图只是说明了按键造成的抖动问题
实际上,还有一个问题:IO口的状态变化也是要时间的。
IO口从1变为0,并不是立刻变化,0变为1也不是立刻变化。是要时间的,尽管这个时间很快很快,那还是要时间的。
普通的IO口速度只有10M,高速的可以做到100M以上。
这个知识点在这里暂时不会有影响,记住就好。
如何去抖动呢?
原理很简单:
重复读,当连续多次都是低电平时,按键就是低电平了。
这是很多例程去抖动的方法。方法是对的,但是状态抽象不对,状态抽象不对,程序写的逻辑就不好。
我认为按键去抖动,或者说按键扫描的逻辑是:
当状态连续多次变化到另外一种状态,说明按键状态变化了。
确定变化后,再根据状态判断是按下还是松开。
如此一来,松开和按下都有防抖动,而程序只有一个防抖动流程。
代码如下:
/*-----------------应用-----------------*/
new_sta = GPIO_ReadInputData(GPIOB);
new_sta = new_sta&0xf000;
/* 这里其实有一个提高可靠性的问题,
old_sta是稳定状态,
sta是本次读到的状态,
如果本次读到的状态跟上次读到的状态不一样呢? */
if (sta != new_sta) {
/* 不相等,说明状态变化*/
debcnt++;
if (debcnt >= 10) {
/* 已经连续10次状态变化,说明变化已经稳定*/
debcnt = 0;
/* 求出变化的位 */
chg_bit = sta ^ new_sta;
/* 检测变化IO现在的状态 */
if ((new_sta & chg_bit) != 0) {
/*按键松开*/
} else {
/* 按键按下 */
/*根据变化的位,判断是哪个按键按下 */
if(chg_bit == 0x8000) {
/* 应用 */
cnt++;
if(cnt > 9)
cnt = 0;
seg_fill_disbuf(SegTab[cnt], 1);
}
}
/*更新状态*/
sta = new_sta;
}
} else {
debcnt = 0;
}
new_sta是当前读到的IO口状态。
两个状态比较,是否有变化?
有变化,debcnt自加,去抖动。(这有个隐患,大家能想到吗?)
通过异或求出变化的bit
用变化的bit和new_sta比较,判断到底是按键按下还是按键松开。
根据 cha_bit,确定是哪个按键按下。(用相等比较,有隐患?能想到吗?)
如果是最高位的按键按下,显示数字就自加。
编译下载测试,效果杠杠的,按下按键数字就加1,没有误按键。
11.4.5. 总结
这种扫描方法,有一个很容易理解的切入点:滑窗法
11.4.6. 最后
那,现在按键驱动算完成了吗?
还差点,差什么呢?差模块化。
上面的程序,应用功能是数码管数字自加。这几行代码属于应用,却镶嵌在按键驱动中。
所以我们要把按键驱动整理为一个模块。按键驱动扫描到按键后,放在缓冲。
应用从缓冲中提取按键。
按键就是生产者,应用就是消费者。
12. 外部中断与按键触发
12.1. 概述
- 中断是啥?
中断是指芯片运行过程中,发生特殊事件,需芯片干预时,芯片自动停止正在运行的程序,并转入处理刚刚中断发生的事件,处理完后返回被打断暂停的程序继续运行。
在前面的程序中,程序没有使用中断,执行流程就只有main函数中的while(1)循环执行。
比如前面一章节按键扫描:
while(1) {
/*-----------------驱动--------------*/
/* 使用显示缓冲方法,要改变显示内容,
调用函数seg_fill_disbuf改变Seg8DisBuf中的内容即可 */
seg_display_task();
/*-----------------应用-----------------*/
new_sta = GPIO_ReadInputData(GPIOB);
new_sta = new_sta&0xf000;
......
/*----------------------------------*/
delay(1000);
}
这就是程序的全部流程,驱动和应用都放在这个流程中,循环执行。
- 为什么要中断?
在按键的while循环中,不断查询IO口的状态,判断是否有按键按下。有按键按下时,就进行去抖动。
这里有两个事情是要记住的:
- 循环查询,速度再快,也是有间隔的。
- 连续不断查询,是要使用CPU时间片的。
那么,查询的越频繁,需要的CPU时间越多,那就不能去干其他事情了。
查询的不频繁呢?间隔就大了,响应的速度就不及时了。
现在我们拿一个按键输入和你在家等快递对比。
按键按下,就相当于快递敲门。
在while循环中查询IO口,就相当于你想知道有没有人敲门,跑到门口看看有没有人。
如果你频繁的去门口查看是否有人来,甚至一直坐在门口等,你就没时间干其他事情。
如果你一个小时才去看一次,那很可能就会让快递员等一个小时,或者,快递员都不等你,没人开门就走了。这样,你就会丢掉一个快递。
如果使用中断呢
相当于在门口装个门铃,你在家里做其他事情,快递来了,按下门铃,然后你出去开门,拿了快递后,回家接着做刚才做的事。
12.2. STM32中断管理
我们常说某某芯片的中断管理,就像本节标题。
实际上,中断是内核在管理。所以,要搞懂芯片的中断,需要学习两部分知识。
- 内核如何管理中断。(中断响应)
- 芯片中断如何连接到内核。
12.2.1. NVIC
NVIC的全称是Nested vectoredinterrupt controller,即嵌套向量中断控制器。
NVIC属于内核的一部分,这东西怎么实现的我们不需要管。只需要知道:
- 要在NVIC中使能对应中断,才会产生中断
- 通过NVIC可以配置中断的优先级,优先级有两种:响应优先级和嵌套优先级。
[外链图片转存中…(img-IO7AuitJ-1645022852672)]
内核还决定了中断向量表。
什么是中断向量?这个问题在知乎有一个讨论。
我的观点是何必浪费时间。
12.2.2. 中断向量表
在startup_stm32f10x_hd.
s文件中,有以下这段代码:
; 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
DCD MemManage_Handler ; MPU Fault Handler
DCD BusFault_Handler ; Bus Fault Handler
DCD UsageFault_Handler ; Usage Fault Handler
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD SVC_Handler ; SVCall Handler
DCD DebugMon_Handler ; Debug Monitor Handler
DCD 0 ; Reserved
DCD PendSV_Handler ; PendSV Handler
DCD SysTick_Handler ; SysTick Handler
; External Interrupts
DCD WWDG_IRQHandler ; Window Watchdog
DCD PVD_IRQHandler ; PVD through EXTI Line detect
DCD TAMPER_IRQHandler ; Tamper
DCD RTC_IRQHandler ; RTC
DCD FLASH_IRQHandler ; Flash
DCD RCC_IRQHandler ; RCC
DCD EXTI0_IRQHandler ; EXTI Line 0
DCD EXTI1_IRQHandler ; EXTI Line 1
DCD EXTI2_IRQHandler ; EXTI Line 2
DCD EXTI3_IRQHandler ; EXTI Line 3
DCD EXTI4_IRQHandler ; EXTI Line 4
这就是中断向量表,表内是什么呢?
第2行,AREA RESET, DATA, READONLY,指定了这段代码放在RESET 段,这个RESET会在分散加载文件中指定在芯片启动的位置0x8000000。所以芯片能在复位时执行Reset_Handler函数。从这里可见,复位也是一个中断。
后面一堆DCD,DCD的意思是,分配一个4个字节,内容是后面跟着的标志,通常是一个函数名,也就是一个函数指针,一个函数的地址。比如:DCD EXTI4_IRQHandler,意思就是把EXTI4_IRQHandler这个函数的地址放在这个地方。如此一来,当产生EXTI4中断时,芯片就会自动到这个位置取这个函数地址,然后执行这个函数。
12.2.3. 外设中断
一个芯片有很多外设,每个外设都有各种中断。NVIC决定了芯片能响应多少中断,但是,芯片能在一个中断入口中实现多种中断。
比如,串口5在中断向量表中只有一个中断入口,但是串口5有很多中断,接收到数据中断,发送数据完成中断,接收溢出中断等。
有个更特别的,EXTI15_10_IRQHandler,从名字就知道,这个中断入口时外部中断15~外不中断10,总共6个外不中断的入口。
所以,要正确配置一个中断,除了NVIC之外,对应外设的中断配置相当重要。
下面我们看看STM32F103这款芯片的IO口外部中断。
12.2.4. STM32 EXIT中断
- 所有IO都支持中断。
- 中断方式有电平、边沿(上升沿、下降沿)
- 中断线只有16根,所有PORT的相同编号的IO,共用中断线,只能用同时有一个IO具有中断功能。
- 中断入口并没有16个,EXTI15_10_IRQHandler,EXTI9_5_IRQHandler。
12.3. 外部中断例程
参考官方例程:STM32F10x_StdPeriph_Lib_V3.5.0\Project\STM32F10x_StdPeriph_Examples\EXTI\EXTI_Config
配置中断如下,分4部分:
void EXTI0_Config(void)
{
/* Enable GPIOA clock */
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
/* Configure PA.00 pin as input floating */
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(GPIOA, &GPIO_InitStructure);
/* Enable AFIO clock */
RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE);
/* Connect EXTI0 Line to PA.00 pin */
GPIO_EXTILineConfig(GPIO_PortSourceGPIOA, GPIO_PinSource0);
/* Configure EXTI0 line */
EXTI_InitStructure.EXTI_Line = EXTI_Line0;
EXTI_InitStructure.EXTI_Mode = EXTI_Mode_Interrupt;
EXTI_InitStructure.EXTI_Trigger = EXTI_Trigger_Rising;
EXTI_InitStructure.EXTI_LineCmd = ENABLE;
EXTI_Init(&EXTI_InitStructure);
/* Enable and set EXTI0 Interrupt to the lowest priority */
NVIC_InitStructure.NVIC_IRQChannel = EXTI0_IRQn;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0x0F;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0x0F;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_InitStructure);
}
-
IO口初始化,将IO配置为浮空输入。(纯粹的浮空是不行的,外部应该带上下拉)
-
使能AFIO时钟,并用函数GPIO_EXTILineConfig将GPIOA的GPIO_Pin_0连接到中断源GPIO_PinSource0。
-
配置外部中断,调用EXTI_Init函数配置EXTI_Line0为中断模式,Rising上升沿触发,ENABLE。
-
配置NVIC,调用函数NVIC_Init配置EXTI0_IRQn, Pre优先级()为0x0f,Sub优先级()也配置为0x0f,使能(ENABLE)
对于NVIC的配置,特别是优先级的配置,需要先设置优先级分组。不同的分组支持不同的pre和sub优先级。
此处我们暂时不管优先级问题。
中断配置好后,还需要编写中断服务函数。通常我们统一将中断函数放在stm32f10x_it.c
void EXTI0_IRQHandler(void)
{
if(EXTI_GetITStatus(EXTI_Line0) != RESET)
{
/* Toggle LED1 */
STM_EVAL_LEDToggle(LED1);
/* Clear the EXTI line 0 pending bit */
EXTI_ClearITPendingBit(EXTI_Line0);
}
}
函数名EXTI0_IRQHandler跟中断向量表中的名字一致。
进入中断后,先判断是不是EXTI_Line0中断,是才进入处理,STM_EVAL_LEDToggle 转换LED状态,这就是用户功能。然后EXTI_ClearITPendingBit,清楚中断标志,如果不清楚,中断就会重复进入。
由此可知一个中断的响应流程:
IO状态变化–>产生EXIT事件标志–>如果使能了中断,就会产生设备中断标志,并产生信号发送给NVIC–>NVIC使能了中断,芯片自动操作,从当前执行位置跳转到中断向量指定的函数–>执行中断服务程序–>执行完之后就返回原来的位置继续执行。
12.4. 按键中断
板子的4个按键连接在GPIOB的12/13/14/15四个IO。
参考例程配置即可,代码如下:
void EXTI15_10_Config(void)
{
EXTI_InitTypeDef EXTI_InitStructure;
GPIO_InitTypeDef GPIO_InitStructure;
NVIC_InitTypeDef NVIC_InitStructure;
/* Configure PG.08 pin as input floating */
/* 外部中断的IO,属于输入IO,先配置未输入
输入IO有3中模式,浮空,上拉,下拉。做为按键通常是上拉,按下按键就是接地。
*/
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_12|GPIO_Pin_13|GPIO_Pin_14|GPIO_Pin_15;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(GPIOB, &GPIO_InitStructure);
/* Enable AFIO clock */
RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE);
/* Connect EXTI8 Line to PG.08 pin */
/* 配置中断线,中中断源接到制定的IO口*/
GPIO_EXTILineConfig(GPIO_PortSourceGPIOB, GPIO_PinSource12);
GPIO_EXTILineConfig(GPIO_PortSourceGPIOB, GPIO_PinSource13);
GPIO_EXTILineConfig(GPIO_PortSourceGPIOB, GPIO_PinSource14);
GPIO_EXTILineConfig(GPIO_PortSourceGPIOB, GPIO_PinSource15);
/* Configure EXTI8 line */
/* 使能外部中断*/
EXTI_InitStructure.EXTI_Line = EXTI_Line12|EXTI_Line13|EXTI_Line14|EXTI_Line15;
EXTI_InitStructure.EXTI_Mode = EXTI_Mode_Interrupt;
EXTI_InitStructure.EXTI_Trigger = EXTI_Trigger_Falling;
EXTI_InitStructure.EXTI_LineCmd = ENABLE;
EXTI_Init(&EXTI_InitStructure);
/* Enable and set EXTI9_5 Interrupt to the lowest priority */
/* 配置NVIC */
NVIC_InitStructure.NVIC_IRQChannel = EXTI15_10_IRQn;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0x0F;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0x0F;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_InitStructure);
}
中断服务函数也是一样的。
/*
EXTI15_10_IRQHandler 中断服务函数名,要跟中断限量表一致。
*/
void EXTI15_10_IRQHandler(void)
{
/* EXTI15_10_IRQHandler 是10~15中断线的入口。
产生中断后,只能通过查询EXTI状态来判断到底是那根IO产生了中断。
*/
if(EXTI_GetITStatus(EXTI_Line12) != RESET)
{
app_display_num(4, 0, 1);
/* Clear the EXTI line 8 pending bit */
/* 请中断标志 */
EXTI_ClearITPendingBit(EXTI_Line12);
}
......
响应中断后,调用app_display_num在数码管上显示数字。
编译下载,按4个按键,对应在最后一位数码管上显示1~4四个数字。
12.5. 使用中断的按键处理
- 中断作为启动,不需要一直查询IO状态。
- IO口产生中断后,禁止中断,开始用原来的方式扫描按键。
- 扫描结束后,开启中断。
13. 定时器
在前面调试数码管时,我们使用了delay函数进行延时,延时后更新数码管上的数字。
通过delay的延时有两个特点:
-
如果要延时比较准确,需要根据主频计算芯片机器指令执行时间,根据指令周期安排delay函数中的代码。
还要考虑中断的影响。无法灵活定时,代码安排也很麻烦,吃力不讨好。
-
使用delay,是一种硬延时,也就是常说的死等。通常我们需要的是定时,而不是死等延时。
因此,只会在一些很短的死等延时才会用delay的方式。比如某个外部芯片需要一个1us的低电平复位信号,我们就可以在代码中拉低IO,硬延时1us,再拉高IO。
定时功能在程序中基本是不可避免的。例如:
- 间隔扫描按键。
- 数码管动态刷新。
- RTOS任务调度。
- 等等
定时功能是程序的基本功能,因此芯片基本都标配了定时器,奢侈的芯片甚至配套了十几个。
程序本质是流程,是时间流,是时间线。
扩展
定时,实际也可以说是计时/计数。
拥有计时能力后,我们就可以做很多事情,为了方便我们实现功能,减少开发难度,芯片进一步将我们要实现的功能集成到定时器。
比如:
- 将IO口输入的信号做位时钟,用定时器统计IO口的脉冲数,这叫做输入计数器
- 用定时器统计IO口上的脉冲宽度,这是输入捕获
- 用定时器配套IO,输出PWM。比如输出1K方波推动蜂鸣器;比如更复杂的PWM控制电机。这是定时器PWM输出
定时器的基本功能组成
STM32的定时器框图
定时器中断
中断中不能进行太多延时
定时器的应用
计算定时, 定时功能其实就是一个闹钟。
讲清楚定时器框图
我们只配置定时1S,刷新数码管
说清楚中断不能执行太多程序。
先用调试器测试能跑到定时中断, 再详细计算定时时间。
引出系统时钟设置问题。
测试增加单独的LED灯显示
14. PWM输出与蜂鸣器
PWM输出是定时器的一个功能。
什么是PWM?
配合一根IO才能输出。
查原理图和datasheet,看IO是哪个定时的的哪个通道。
PC6 TIM8 CH1
官方例程 STM32F10x_StdPeriph_Lib_V3.5.0\Project\STM32F10x_StdPeriph_Examples\TIM\PWM_Output
除了设置定时器,还需要配置PWM,还需要配置IO口。
查蜂鸣器响应频率,4K
调试时,可以用示波器测试是否有PWM输出。
15. 串口与调试信息输出
串口 UART 通用异步, USART 增加了同步功能。 TX RX 讲时序, 提流控。 串口有附加功能,红外,IC卡协议。 在ST的例程,有IrDA, Smartcard,这就是串口的附件例程。
讲框图
学习,最开始通常先移植polling,也就是通过查询方式收发数据。 然后使用串口中断Interrupt, 还可以考虑使用DMA方式
那我们先移植POLLING,查看例程,是一个双串口收发的,
我们改一下,只用单串口,连接电脑收发。 宏定义在platform_config.h
先移植初始化, 时钟要记得开,IO初始化, 读例程,然后修改
讲sizeof,讲宏
讲硬件
试验多输出一个\0,讲字符串结束符的知识。
开始讲接收,
加上发送,,加断点,发4个,有断点就收不到了。
门铃与来客,中断,的对比说明。
中断
调试信息 参考printf例程,不成功。需要把microlib勾上。
16. ADC模数转换
ADC是什么? 有什么关键参数?
STM32的ADC硬件上又是什么样子的? 讲框图。
分析例程ADC1_DMA
PA4 ADC12_IN4
ADC操作过程: 1 配置
如何转换为电压?
17. DAC数模转换
波形输出
18. 时钟和日历
讲解掉电保存区域 RTC
例程 STM32F10x_StdPeriph_Examples\RTC\Calendar
19. I2C与EEPROM
为了讲清楚I2C的时序, 我们先用IO口模拟I2C
1 讲时序图,讲如何看时序图
2 讲EPROM的功能。
讲I2C流程怎么写,地址要定义为7位,定义传输函数。
讲24C的接口函数怎么写。接口函数是面对用户的,要对用户屏蔽特性,提供共性。 屏蔽细节,提供功能。
地址要说明,不要定义为8位,按照惯例定义为8位。 24C02的地址有点不一样,最低位要用来当地址位了。 因为我们这个是512字节的,一个地址字节只能寻址到256,所有要一个A8当寻址。
先降低速度调试。
解决3个BUG ,测试程序成功。
如何验证? 1 在写之前先读全部的出来。 2 写多种数据。 3 断电
芯片支持page模式,请自己实现。
ST例程STM32F10x_StdPeriph_Lib_V3.5.0\Utilities\STM32_EVAL\Common
20. SPI与SPI FLASH
官方有例程
STM32F10x_StdPeriph_Lib_V3.5.0\Project\STM32F10x_StdPeriph_Examples\SPI\SPI_FLASH STM32F10x_StdPeriph_Lib_V3.5.0\Utilities\STM32_EVAL\Common
21. OLED
i2c控制OLED
22. SPI控制COG LCD
COG和OLED在硬件上完全不用。
但是COG和OLED的显示方式很像。
驱动,除了初始化,没什么大区别。
当然,硬件控制比较复杂一点。
这里有一个问题需要思考,CS由谁控制
通过对比代码,说明和OLED的区别。
23. FSMC与TFT LCD
FSMC是啥? TFT LCD是啥? LCD的关键功能是啥?
24. 触摸屏与Tslib
用IO模拟SPI控制XTP2046,触摸屏校准用tslib
25. SDIO通信
如何移植官方SD卡例程
要改大栈到0x2000
26. USB
不做讲解,只讲如何移植官方例程。