正点原子B站视频地址:https://www.bilibili.com/video/BV1bv4y1R7dp?p=1&vd_source=cc0e43b449de7e8663ca1f89dd5fea7d
STM32芯片分类
ST中文社区网:https://www.stmcu.org.cn/
ST官网:https://www.st.com/content/st_com/en.html
了解了STM32 的系列和命名以后,我们再进行STM32 选型就会比较容易了,这里我们只要遵循:由高到低,由大到小的原则,就可以很方便的完成设计选型了,具体如下:
由高到低原则:在不能评估项目所需性能的时候,可以考虑先选择高性能的STM32 型号进行开发,比如选择F4/F7/H7 等,在高性能STM32 上面完成关键性能(即最需要性能的代码)开发验证,如果满足使用要求,则可以降档,如从H7→F7→F4→F1,如不满足要求,则可以升档,如从F4→F7→H7,通过此方法找到最佳性价比的STM32 系列。
由大到小原则:在不能评估项目所需FLASH 大小、SRAM 大小、GPIO 数量、定时器等资源需求的时候,可以考虑先选择容量较大的型号进行开发,比如选择512K/甚至1M FLASH 的型号进行开发,等到开发完成大部分功能之后,就对项目所需资源有了定论,从而可以根据实际情况进行降档选择(当然极少数情况可能还需要升档),通过此方法,找到最合适的STM32
型号。
整个选型工作大家可以在正点原子开发板上进行验证,一般我们开发板都是选择容量比较大/资源比较多的型号进行设计的,这样可以免去大家自己设计焊接验证板的麻烦,加快项目开发进度。一些资深工程师,对项目要求认识比较深入的话,甚至都不需要验证了,直接就可以选出最合适的型号,这个效率更高。当然这个需要长期积累和多实践,相信只要大家多学习,多实践,总有一天也能达到这个级别。
这里我们简单给大家介绍一下STM32 的原理图设计,上一节我们通过选型原则可以确定项目所需的STM32 具体型号,但是在选择型号以后,需要先设计原理图,然后再画PCB、打样、焊接、调试等步骤。这里我们重点介绍如何设计STM32F103 的原理图。
任何MCU 部分的原理图设计,其实都遵循:最小系统+ IO 分配的设计原则。在开始设计原理图之前,我们要通读一遍对STM32F103 原理图设计非常有用的手册:STM32F103 的数据手册,可以说不看这个数据手册,我们就无法设计STM32F103 原理图。
在设计STM32F103 原理图的时候,我们需要用到一个非常重要的文档:STM32F103 数据
手册,里面对STM32F103 的IO 定义和说明有非常详细的描述,是我们设计原理图的基础。战舰开发板所使用的STM32F103ZET6 芯片数据手册,存放在:A 盘→ 7,硬件资料→2,芯片资料→ STM32F103ZET6.pdf / STM32F103ZET6(中文版).pdf,接下来我们简单介绍一下如何使用该文档。
STM32F103ZET6.pdf 是最新的英文版(V13)STM32 数据手册
STM32F103ZET6(中文版).pdf 是中文版(V5)STM32 数据手册
大家可以根据自己的喜欢来选择合适的版本进行阅读,内容上基本大同小异,从准确性全面性的角度来说,看V13 英文版是最好的,从简单,易懂来说,看V5 中文版也是可以的。
STM32F103ZET6.pdf 数据手册是针对大容量系列(FLASH 容量在256KB~512KB 之间),主要包括8 个章节,如表2.3.4.1 所示:
章节 | 概要说明 |
---|---|
介绍 | 简单说明数据手册作用:介绍大容量增强型F103xC/D/E 产品的订购信息和机械特性 |
规格说明 | 简单介绍STM32F103 内部所有资源及外设特点 |
引脚定义 | 介绍不同封装的引脚分布、引脚定义等,含引脚特性、复用功能、脚位等 |
存储器映像 | 介绍STM32F103 整个4GB 存储空间和外设的地址映射关系 |
电气特性 | 介绍STM32F103 的详细电气特性,包括工作电压、电流(自己设计电路的时候超过额定值就要额外加电阻限流)、温度、各外设资源的电气性能等 |
封装特性 | 介绍了STM32F103 不同封装的封装机械数据(脚距、长短等)、热特性等 |
订货代码 | 和2.3.2 节内容类似,介绍STM32 具体型号所代表的意义,方便选型订货 |
版本历史 | 介绍数据手册不同版本之间的差异和修订内容 |
整个STM32F103 数据手册,对我们开发学习STM32 来说都比较重要,因此建议大家可以简单的通读一遍这个文档,以加深印象。对于原理图设计,最重要的莫过于引脚定义这一章节了,只有知道了STM32 的引脚定义,才能开始设计原理图。
STM32F103ZET6 引脚分布如图2.3.4.1 所示:
STM32F103ZET6 引脚定义如表2.3.4.2 所示:
引脚定义表的具体说明如表2.3.4.3 所示:
序号 | 名称 | 说明 |
---|---|---|
① | 脚位 | 对应芯片的引脚,LQFP封装使用纯数字表示,BGA 使用字母+数字表示 这里列出了6 种封装的脚位描述,根据实际型号选择合适的封装查阅 |
② | 管脚名称 | 即对应引脚的名字,PD0~5 表示GPIO 引脚,VSS_10/VDD_10 表示第10 组电源引脚,其他类似 |
③ | 类型 | I/O :表示输入/输出引脚 I :表示输入引脚 S :表示电源引脚 |
④ | IO 电平 | FT :表示5V 兼容的引脚(可以接5V/3.3V) 空:表示5V 不兼容引脚(仅可以接3.3V) |
⑤ | 主功能(复位后) | 复位后,该引脚的默认功能 |
⑥ | 可选的复用功能 | 默认复用功能:是指开启复用功能后,该引脚默认的复用功能;重定义功能:是指可以通过重映射的复用功能,需设置重映射寄存器 |
了解引脚分布和引脚定义以后,我们就可以开始设计STM32F103 的原理图了。
最小系统就是保证MCU 正常运行的最低要求,一般是指MCU 的供电、复位、晶振、BOOT等部分。STM32F103 的最小系统需求如表2.3.4.4 所示:
完成以上引脚的设计以后,STM32F103 的最小系统就完成了,关于这些引脚的实际原理图,大家可以参考我们战舰开发板的原理图。接下来就可以开始进行IO 分配了。
IO 分配就是在完成最小系统设计以后,根据项目需要对MCU 的IO 口进行分配,连接不同的器件,从而实现整体功能。比如:GPIO、IIC、SPI、SDIO、FSMC、USB、中断等。遵循:先分配特定外设IO,再分配通用IO,最后微调的原则,见表2.3.4.5 所示:
分配 | 外设 | 说明 |
---|---|---|
特定外设 | IIC | IIC 一般用到2 根线:IIC_SCL 和IIC_SDA(ST 叫I2C)数据手册有I2C_SCL、I2C_SDA 复用功能的GPIO 都可选用 |
特定外设 | SPI | SPI 用到4 根线:SPI_CS/MOSI/MISO/SCK 一般SPI_CS 我们使用通用GPIO 即可,方便挂多个SPI 器件;数据手册有SPI_MOSI/MISO/SCK 复用功能的GPIO 都可选用 |
特定外设 | TIM | 根据需要可选:TIM_CH1/2/3/4/ETR/1N/2N/3N/BKIN 等;数据手册有TIM_CH1/2/3/4/ETR/1N/2N/3N/BKIN 复用功能的GPIO 都可选用 |
特定外设 | USART UART | USART 有USART_TX/RX/CTS/RTS/CK 信号 UART 仅有UART_TX/RX 两个信号 一般用到2 根线:U(S)ART_TX 和U(S)ART_RX ;数据手册有U(S)ART_TX/RX 复用功能的GPIO 都可选用 |
特定外设 | USB | USB 用到2 根线:USB_DP 和USB_DM;数据手册有USB_DP、USB_DM 复用功能的GPIO 都可选用 |
特定外设 | CAN | CAN 用到2 根线:CAN_RX 和CAN_TX;数据手册有USB_DP、USB_DM 复用功能的GPIO 都可选用 |
特定外设 | ADC | ADC 根据需要可选:ADC_IN0 ~ ADC_IN15;数据手册有ADC_IN0 ~ ADC_IN15 复用功能的GPIO 都可选用 |
特定外设 | DAC | DAC 根据需要可选:DAC_OUT1 / DAC_OUT2;DAC 固定为:DAC_OUT1 使用PA4、DAC_OUT2 使用PA5 |
特定外设 | SDIO | SDIO 一般用到6 根线:SDIO_D0/1/2/3/SCK/CMD;数据手册有SDIO_D0/1/2/3/SCK/CMD 复用功能的GPIO 都可选用 |
特定外设 | FSMC | 根据需要可选:FSMC_D0 ~ 15 /A0 ~ 25/ NBL0 ~ 1/NE1~ 4/NCE2~ 3/ NOE/NWE/NWAIT/CLK 等;数据手册有FSMC_D0~ 15/A0~ 25/ NBL0~ 1/NE1~ 4/NCE2~3/NOE/;NWE/NWAIT/CLK 复用功能的GPIO 都可选用 |
通用 | GPIO | 在完成特定外设的IO 分配以后,就可以进行GPIO 分配了比如将按键、LED、蜂鸣器等仅需要高低电平读取/输出的外设连接到空闲的普通GPIO 即可 |
微调 | IO | 微调主要包括两部分:1,当IO不够用的时候,通用GPIO 和特定外设可能要共用IO 口;2,为了方便布线,可能要调整某些IO 口的位置。这两点,根据实际情况进行调整设置,做到:尽可能多的可以同时使用所有功能,尽可能方便布线。 |
经过以上几个步骤,我们就可以完成STM32F103 的原理图设计了。
STM32F103 是ST 公司基于ARM 授权Cortex M3 内核而设计的一款芯片,而Cortex M 内核使用的是ARM v7-M 架构,是为了替代老旧的单片机而量身定做的一个内核,具有低成本、低功耗、实时性好、中断响应快、处理效率高等特点。
ARM公司提供内核(如Cortex M3,简称CM3,下同)授权,完整的MCU还需要很多其他组件。芯片公司(ST、NXP、TI、GD、华大等)在得到CM3内核授权后,就可以把CM3内核用在自己的硅片设计中,添加:存储器,外设,I/O以及其它功能块。不同厂家设计出的单片机会有不同的配置,包括存储器容量、类型、外设等都各具特色,因此才会有市面上各种不同应用的ARM芯片。Cortex M3内核和芯片的关系如图5.3.1.1所示:
可以看到,ARM公司提供CM3内核和调试系统,其他的东西(外设(IIC、SPI、UART、TIM等)、存储器(SRAM、FLASH等)、I/O等)由芯片制造商设计开发。这里ST公司就是STM32F103芯片的制造商。
STM32F103ZET6 内部系统结构如图5.3.2.1:
STM32F103的系统主要由:四个驱动单元(可以主动发起通信,图中①区域)和四个被动单元(只能被驱动工作,图中②区域)组成,如表5.3.2.1所示:
这里的驱动/被动单元都是指连接了总线矩阵的部分,未连接总线矩阵的部分,则不算作驱动/被驱动单元。接下来我们介绍一下这些单元。
1. I Code 总线(I - Bus)
这是Cortex M3内核的指令总线,连接闪存指令接口(如:FLASH),用于获取指令。由于该总线功能单一,并没有直接连接到总线矩阵(优点:稳定,避免总线冲突导致的等待),因此被排除在驱动单元之外。
2. D Code 总线(D - Bus)
这是Cortex M3内核的数据总线,连接闪存存储器数据接口(如:SRAM、FLASH等),用于各种数据访问,如常量、变量等。
3. 系统总线(S - Bus)
这是Cortex M3内核的系统总线,连接所有外设(如:GPIO、SPI、IIC、TIM等),用于控制各种外设工作,如配置各种外设相关寄存器等。
4. DMA 总线
DMA是直接存储访问控制器,可以实现数据的自动搬运,整个过程不需要CPU处理。如可以实现DMA传输内存数据到DAC,输出任意波形,传输过程不需要CPU参与,可以大大节省CPU支,从而更高效的处理事务。STM32F103ZET6内部有2个DMA控制器,可以实现内存到外设、外设到内存、内存到内存的数据传输。
5. 内部FLASH
内部FLASH即单片机的硬盘,用于代码/数据存储,CPU通过ICode总线经FLASH接口访问内部FLASH,FLASH最高访问速度是24Mhz,因此以72M速度访问时,需要插入2个时钟周期延迟。
6. 内部SRAM
内部SRAM即单片机的内存,用于数据存储,直接挂载在总线矩阵上面,CPU通过DCode总线实现0等待延时访问SRAM,最快总线频率可达72Mhz,从而保证高效高速的访问内存。
7. FSMC
FSMC即灵活的静态存储控制器,实际上就是一个外部总线接口,可以用来访问外部SRAM、NAND/NOR FLASH、LCD等。它也是直接挂在总线矩阵上面的,以方便CPU快速访问外挂器件。
8. AHB/APB 桥
AHB总线连接总线矩阵,同时通过2个APB桥连接APB1和APB2,AHB总线速度最大为72Mhz,APB2总线速度最大也是72Mhz,但是APB1总线速度最大只能是36Mhz。这三个总线上面挂载了STM32内部绝大部分外设。
9. 总线矩阵
总线矩阵协调内核系统总线和DMA主控总线之间的访问仲裁,仲裁利用轮换算法,保证各个总线之间的有序访问,从而确保工作正常。
STM32是一个32位单片机,他可以很方便的访问4GB以内的存储空间(2^32 = 4GB字节),因此Cortex M3内核将图5.3.2.1中的所有结构,包括:FLASH、SRAM、外设及相关寄存器等全部组织在同一个4GB的线性地址空间内,我们可以通过C语言来访问这些地址空间,从而操作相关外设(读/写)。数据字节以小端格式(小端模式)存放在存储器中,数据的高字节保存在内存的高地址中,而数据的低字节保存在内存的低地址中。
存储器本身是没有地址信息的,我们对存储器分配地址的过程就叫存储器映射(比如把一个内存条插到主机上,这个行为就是存储器映射)。这个分配一般由芯片厂商做好了,ST将所有的存储器及外设资源都映射在一个4GB的地址空间上(8个块),从而可以通过访问对应的地址,访问具体的外设。其映射关系如图5.3.3.1所示:
存储块功能介绍
ST将4GB空间分成8个块,每个块512MB,如上图所示,从图中我们可以看出有很多保留区域(Reserved),这是因为一般的芯片制造厂家是不可能把4GB空间用完的,同时,为了方便后续型号升级,会将一些空间预留(Reserved)。8个存储块的功能如表5.3.3.1所示:
这里我们重点挑前面3个存储块给大家介绍。第一个块是Block 0,用于存储代码,即FLASH空间,其功能划分如表5.3.3.2所示:
可以看到,我们用户FLASH大小是512KB,这是对于我们使用的STM32F103ZET6来说,如果是其他型号,可能FLASH会更小,当然,如果ST喜欢,也是可以随时推出更大容量的STM32F103单片机的,因为这里保留了一大块地址空间。还有,STM32的出厂固化BootLoader非常精简,整个BootLoder只占了2KB FLASH空间。
第二个块是Block 1,用于存储数据,即SRAM空间,其功能划分如表5.3.3.3所示:
这个块仅使用了64KB 大小(仅大容量STM32F103 型号才有这么多SRAM ,比如STM32F103ZET6等),用于SRAM访问,同时也有大量保留地址用于扩展(类似电脑再插个内存条)。
第三个块是Block 2,用于外设访问,STM32内部大部分的外设都是放在这个块里面的,该存储块里面包括了AHB、APB1和APB2三个总线相关的外设,其中AHB和APB2是高速总线(72Mhz max),APB1是低速总线(36M max)。其功能划分如表5.3.3.4所示:
同样可以看到,各个总线之间,都有预留地址空间,方便后续扩展。关于STM32各个外设具体挂在哪个总线上面,大家可以参考前面的STM32F103系统结构图和STM32F103存储器映射图进行查找对应。
寄存器(Register)是单片机内部一种特殊的内存,它可以实现对单片机各个功能的控制,简单的来说可以把寄存器当成一些控制开关,控制包括内核及外设的各种状态。所以无论是51单片机还是STM32,都需要用寄存器来实现各种控制,以完成不同的功能。
由于寄存器资源非常宝贵,一般都是一个位或者几个位控制一个功能,对于STM32 来说,其寄存器是32 位的,一个32 位的寄存器,可能会有32 个控制功能,相当于32 个开关,由于STM32 的复杂性,它内部有几百个寄存器,所以整体来说STM32 的寄存器还是比较复杂的。
不过,我们不要被其吓到了,实际上STM32 是由于内部有很多外设,所以导致寄存器很多,实际上我们把它分好类,每个外设也就那么几个或者几十个寄存器,学起来就不难了。
从大方向来区分,STM32 寄存器分为两类,如表5.2.1 所示:
说了这么多,给大家举个简单的例子,我们知道寄存器的本质是一个特殊的内存,对于STM32 来说,以GPIOB 的ODR 寄存器为例,其寄存器地址为:0X40010C0C,所以我们对其赋值可以写成:
(*(unsigned int *))(0X40010C0C) = 0XFFFF;
这样我们就完成了对GPIOB->ODR 寄存器的赋值,全部0XFFFF,表示GPIOB 所有IO 口(16 个IO 口)都输出高电平。对于我们来说,0X40010C0C 就是一个寄存器的特殊地址,至于它是怎么来的,我们后续再给大家介绍。
虽然上面的代码实现了我们需要的功能,但是从实用的角度来说,这么写肯定是不好的,可读性极差,可维护性也很差,所以一般我们使用结构体来访问,比如改写成这样:
GPIOB->ODR = 0XFFFF;
这样可读性就比之前的代码好多了,可维护性也相对好一点。至于GPIOB 结构体怎么来的,我们也会在后续给大家介绍。
给存储器分配地址的过程叫存储器映射,寄存器是一类特殊的存储器,它的每个位都有特定的功能,可以实现对外设/功能的控制,给寄存器的地址命名的过程就叫寄存器映射。
举个简单的例子,大家家里面的纸张就好比通用存储器,用来记录数据是没问题的,但是不会有具体的动作,只能做记录,而你家里面的电灯开关,就好比寄存器了,假设你家有8个灯,就有8个开关(相当于一个8位寄存器),这些开关也可以记录状态,同时还能让电灯点亮/关闭,是会产生具体动作的。为了方便区分和使用,我们会给每个开关命名,比如厨房开关、大厅开关、卧室开关等,给开关命名的过程,就是寄存器映射。
当然STM32内部的寄存器有非常多,远远不止8个开关这么简单,但是原理是差不多的,每个寄存器的每一个位,一般都有特定的作用,涉及到寄存器描述,大家可以参考《STM32F10XXX参考手册(中文版)_V10.pdf》相关章节的寄存器描述部分,有详细的描述。
1. 寄存器描述解读
我们以GPIO的ODR寄存器为例,其参考手册的描述如图5.3.4.1所示:
其他寄存器描述,参照以上方法解读接口。
2. 寄存器映射举例
从前面的学习我们知道GPIOB_ODR 寄存器的地址为:0x4001 0C0C,假设我们要控制GPIOB的16个IO口都输出1,则可以写成:
*(unsigned int *)(0x40010C0C) = 0XFFFF;
这里我们先要将0x4001 0C0C 强制转换成unsigned int 类型指针,然后用*对这个指针的值进行设置,从而完成对GPIOB_ODR 寄存器的写入。
这样写代码功能是没问题,但是可读性和可维护性都很差,使用起来极其不便,因此我们将代码改为:
#define GPIOB_ODR *(unsigned int *)(0x40010C0C)
GPIOB_ODR = 0XFFFF;
这样,我们就定义了一个GPIOB_ODR 的宏,来替代数值操作,很明显,GPIOB_ODR 的可读性和可维护性,比直接使用数值操作来的直观和方便。这个宏定义过程就可以称之为寄存器的映射。
当然,为了简单,我们只举了一个简单实例,实际上大量寄存器的映射,使用结构体是最方便的方式,stm32f103xe.h 里面使用结构体方式对STM32F103 的寄存器做了详细映射,等下我们再介绍。
3. 寄存器地址计算
STM32F103大部分外设寄存器地址都是在存储块2上面的,见图5.3.3.1。具体某个寄存器地址,由三个参数决定:1、总线基地址(BUS_BASE_ADDR);2,外设基于总线基地址的偏移量(PERIPH_OFFSET);3,寄存器相对外设基地址的偏移量(REG_OFFSET)。可以表示为:
寄存器地址= BUS_BASE_ADDR + PERIPH_OFFSET + REG_OFFSET
总线基地址(BUS_BASE_ADDR),STM32F103内部有三个总线(APB1、APB2和AHB),对应的总线基地址如表5.3.4.1所示:
上表中APB1的基地址,也叫外设基地址,表中的偏移量就是相对于外设基地址的偏移量。
注意:AHB的总线基地址是0X4001 8000,从该基地址到0X4002 0000,只挂了SDIO一个外设,后续的AHB外设基地址都大于等于0X4002 0000。为了方便计算,我们可以将AHB的总线基地址改成:0X4002 0000,而SDIO则单独定义一个基地址给他即可。
外设基于总线基地址的偏移量(PERIPH_OFFSET),这个不同外设偏移量不一样,我们可以在STM32F103存储器映射图(图5.3.3.1)里面找到具体的偏移量,以GPIO为例,其偏移量如表5.3.4.2所示:
上表的偏移量,就是外设基于APB2总线基地址的偏移量(PERIPH_OFFSET)。
知道了外设基地址,再在参考手册里面找到具体某个寄存器相对外设基地址的偏移量就可以知道该寄存器的实际地址了,以GPIOB的相关寄存器为例,如表5.3.4.3所示:
上表的偏移量,就是寄存器基于外设基地址的偏移量(REG_OFFSET)。
因此,我们根据前面的公式,很容易可以计算出GPIOB_ODR的地址:
GPIOB_ODR地址= APB2总线基地址+ GPIOB外设偏移量+ 寄存器偏移量
所以得到:GPIOB_ODR 地址= 0X4001 0000 + 0XC00 + 0X0C = 0X4001 0C0C。
4. stm32f103xe.h 寄存器映射说明
STM32F103所有寄存器映射都在stm32f103xe.h里面完成,包括各种基地址定义、结构体定义、外设寄存器映射、寄存器位定义(占了绝大部分)等,整个文件有1W多行,非常庞大。我们没有必要对该文件进行全面分析,因为很多内容都是相似的,我们只需要知道寄存器是如何被映射的,就可以了,至于寄存器位定义这些内容,知道是怎么回事就可以了。
我们还是以GPIO为例进行说明,看看stm32f103xe.h是如何对GPIO的寄存器进行映射的,通过对GPIO寄存器映射,了解stm32f103xe.h的映射规则。
stm32f103xe.h文件主要包含五个部分内容,如表5.3.4.4所示:
寄存器映射主要涉及到表5.3.4.4中加粗的两个组成部分:外设寄存器结构体类型定义和寄存器映射,总结起来,包括3个步骤:
以GPIO为例,其寄存器结构体类型定义如下:
typedef struct
{
__IO uint32_t CRL; /* GPIO_CRL 寄存器,相对外设基地址偏移量:0X00 */
__IO uint32_t CRH; /* GPIO_CRH 寄存器,相对外设基地址偏移量:0X04 */
__IO uint32_t IDR; /* GPIO_IDR 寄存器,相对外设基地址偏移量:0X08 */
__IO uint32_t ODR; /* GPIO_ODR 寄存器,相对外设基地址偏移量:0X0C */
__IO uint32_t BSRR; /* GPIO_BSRR寄存器,相对外设基地址偏移量:0X10 */
__IO uint32_t BRR; /* GPIO_BRR 寄存器,相对外设基地址偏移量:0X14 */
__IO uint32_t LCKR; /* GPIO_LCKR寄存器,相对外设基地址偏移量:0X18 */
} GPIO_TypeDef;
GPIO外设基地址定义如下:
#define PERIPH_BASE 0x40000000UL /* 外设基地址*/
#define APB1PERIPH_BASE PERIPH_BASE /* APB1总线基地址*/
#define APB2PERIPH_BASE (PERIPH_BASE + 0x00010000UL) /* APB2总线基地址*/
#define AHBPERIPH_BASE (PERIPH_BASE + 0x00020000UL) /* AHB总线基地址*/
#define GPIOA_BASE (APB2PERIPH_BASE + 0x00000800UL) /* GPIOA基地址*/
#define GPIOB_BASE (APB2PERIPH_BASE + 0x00000C00UL) /* GPIOB基地址*/
#define GPIOC_BASE (APB2PERIPH_BASE + 0x00001000UL) /* GPIOC基地址*/
#define GPIOD_BASE (APB2PERIPH_BASE + 0x00001400UL) /* GPIOD基地址*/
#define GPIOE_BASE (APB2PERIPH_BASE + 0x00001800UL) /* GPIOE基地址*/
#define GPIOF_BASE (APB2PERIPH_BASE + 0x00001C00UL) /* GPIOF基地址*/
#define GPIOG_BASE (APB2PERIPH_BASE + 0x00002000UL) /* GPIOG基地址*/
GPIO外设寄存器映射定义如下:
#define GPIOA ((GPIO_TypeDef *)GPIOA_BASE) /* GPIOA寄存器地址映射*/
#define GPIOB ((GPIO_TypeDef *)GPIOB_BASE) /* GPIOB寄存器地址映射*/
#define GPIOC ((GPIO_TypeDef *)GPIOC_BASE) /* GPIOC寄存器地址映射*/
#define GPIOD ((GPIO_TypeDef *)GPIOD_BASE) /* GPIOD寄存器地址映射*/
#define GPIOE ((GPIO_TypeDef *)GPIOE_BASE) /* GPIOE寄存器地址映射*/
#define GPIOF ((GPIO_TypeDef *)GPIOF_BASE) /* GPIOF寄存器地址映射*/
#define GPIOG ((GPIO_TypeDef *)GPIOG_BASE) /* GPIOG寄存器地址映射*/
以上三部分代码,就完成了STM32F103内部GPIOA~GPIOG的寄存器映射,其原理其实是比较简单的,包括两个核心知识点:1,结构体地址自增;2,地址强制转换;
结构体地址自增,我们第一步就定义了GPIO_TypeDef结构体类型,其成员包括:CRL、CRH、IDR、ODR、BSRR、BRR和LCKR,每个成员是uint32_t类型,也就是4个字节,这样假设:CRL地址是0的话,CRH就是0X04,IDR就是0X08,ODR就是0X0C,以此类推。
地址强制转换,以GPIOB为例,GPIOB外设的基地址为:GPIOB_BASE(0X4001 0C00),我们使用GPIO_TypeDef将该地址强制转换为GPIO结构体类型指针:GPIOB,这样GPIOB->CRL的地址就是:GPIOB_BASE(0X4001 0C00),GPIOB->CRH的地址就是:GPIOB_BASE + 0X04(0X4001 0C04),GPIOB->IDR的地址就是:GPIOB_BASE + 0X08(0X4001 0C08),以此类推。
这样我们就使用结构体方式完成了对GPIO寄存器的映射,其他外设的寄存器映射也都是这个方法。
本章给大家分析STM32F1 的启动过程,这里的启动过程是指从STM32 芯片上电复位执行的第一条指令开始,到执行用户编写的main 函数这之间的过程。我们编写程序,基本都是用C 语言编写,并且以main 函数作为程序的入口。但是事实上,main 函数并非最先执行的,在此之前需要做一些准备工作,准备工作通过启动文件的程序来完成。理解STM32 启动过程,对今后的学习和分析STM32 程序有很大的帮助。
注意:学习本章内容之前,请大家最好先阅读由正点原子团队编写的《STM32 启动文件浅析》和《STM32 MAP 文件浅析》这两份文档(路径:A 盘→ 1,入门资料)。
我们知道的复位方式有三种:上电复位,硬件复位和软件复位。当产生复位,并且离开复
位状态后,CM3 内核做的第一件事就是读取下列两个32 位整数的值:
为何地址加4?32位单片机每次取4个字节(32位)
下面用示意图表示,如图9.1.1 所示。
上述过程中,内核是从0x0000 0000 和0x0000 0004 两个的地址获取堆栈指针SP 和程序计
数器指针PC。事实上,0x0000 0000 和0x0000 0004 两个的地址可以被重映射到其他的地址空间。例如:我们将0x0800 0000 映射到0x0000 0000,即从内部FLASH 启动,那么内核会从地址0x0800 0000 处取出堆栈指针MSP 的初始值,从地址0x0800 0004 处取出程序计数器指针PC 的初始值。
CPU 会从PC 寄存器指向的地址空间取出的第1 条指令开始执行程序,就是开始执行复位中断服务程序Reset_Handler。
将0x0000 0000 和0x0000 0004 两个地址重映射到其他的地址空间,就是启动模式选择。
对于STM32F1 的启动模式(也称自举模式),我们看表9.1.1 进行分析。
注:启动引脚的电平:0:低电平;1:高电平;x:任意电平,即高低电平均可
由表9.1.1 可以看到,STM32F1 根据BOOT 引脚的电平选择启动模式,这两个BOOT 引脚
根据外部施加的电平来决定芯片的启动地址。(0 和1 的准确电平范围可以查看F103 系列数据手册I/O 特性表,但我们最好是设置成Gnd 和VDD 的电平值)
(1)内部FLASH 启动方式(主闪存存储器,用的最多)
当芯片上电后采样到BOOT0 引脚为低电平时(系统复位后,SYSCLK的第4个上升沿,BOOT引脚的值将被锁存),0x00000000 和0x00000004 地址被映射到
内部FLASH 的首地址0x08000000 和0x08000004。因此,内核离开复位状态后,读取内部FLASH的0x08000000 地址空间存储的内容,赋值给栈指针MSP,作为栈顶地址,再读取内部FL ASH的0x08000004 地址空间存储的内容,赋值给程序指针PC,作为将要执行的第一条指令所在的地址。完成这两个操作后,内核就可以开始从PC 指向的地址中读取指令执行了。
(2)内部SRAM 启动方式
类似于内部Flash,当芯片上电后采样到BOOT0 和BOOT1 引脚均为高电平时,地址
0x00000000 和0x00000004 被映射到内部SRAM 的首地址0x20000000 和0x20000004,内核从SRAM 空间获取内容进行自举。在实际应用中,由启动文件starttup_stm32f103xe.s 决定了0x00000000 和0x00000004 地址存储什么内容,链接时,由分散加载文件(sct)决定这些内容的绝对地址,即分配到内部FLASH 还是内部SRAM。
(3)系统存储器启动方式
当芯片上电后采样到BOOT0 =1,BOOT1=0 的组合时,内核将从系统存储器的0x1FFFF000及0x1FFFF004 获取MSP 及PC 值进行自举。系统存储器是一段特殊的空间,用户不能访问,ST 公司在芯片出厂前就在系统存储器中固化了一段代码(bootloader)。因而使用系统存储器启动方式时,内核会执行该代码,该代码运行时,会为ISP(In System Program)提供支持x,在STM32F1 上最常见的是检测USART1 传输过来的信息,并根据这些信息更新自己内部FLASH 的内容,达到升级产品应用程序的目的,因此这种启动方式也称为ISP 启动方式。
STM32 启动文件由ST 官方提供,在官方的STM32Cube 固件包里,对于STM32F103 系列芯片的启动文件,我们选用的是startup_STM32F103xe.s 这个文件。启动文件用汇编编写,是系统上电复位后第一个执行的程序。
启动文件主要做了以下工作:
1、初始化堆栈指针MSP = _initial_sp,从0X0800 0000获取
2、初始化程序计数器指针PC = Reset_Handler,从0X0800 0004获取
3、设置堆和栈的大小
4、初始化中断向量表,_Vectors定义
5、配置外部SRAM 作为数据存储器(可选)
6、配置系统时钟,通过调用SystemInit 函数(可选)
7、调用C库中的_main 函数初始化用户堆栈,最终调用main 函数
指令名称 | 作用 |
---|---|
EQU | 给数字常量取一个符号名,相当于C 语言中的define |
AREA | 汇编一个新的代码段或者数据段 |
ALIGN | 编译器对指令或者数据的存放地址进行对齐,一般需要跟一个立即数,缺省表示4 字节对齐。要注意的是,这个不是ARM 的指令,是编译器的,这里放到一起为了方便。 |
SPACE | 分配内存空间 |
PRESERVE8 | 当前文件堆栈需要按照8 字节对齐 |
THUMB | 表示后面指令兼容THUMB 指令。在ARM 以前的指令集中有16 位的THUMBM 指令,现在Cortex-M 系列使用的都是THUMB-2 指令集,THUMB-2 是32 位的,兼容16 位和32 位的指令,是THUMB 的超级版。 |
EXPORT | 声明一个标号具有全局属性,可被外部的文件使用 |
DCD | 以字节为单位分配内存,要求4 字节对齐,并要求初始化这些内存 |
PROC | 定义子程序,与ENDP 成对使用,表示子程序结束 |
WEAK | 弱定义,如果外部文件声明了一个标号,则优先使用外部文件定义的标号,如果外部文件没有定义也不会出错。要注意的是,这个不是ARM 的指令,是编译器的,这里放到一起为了方便。 |
IMPORT | 声明标号来自外部文件,跟C 语言中的extern 关键字类似 |
LDR | 从存储器中加载字到一个存储器中 |
BLX | 跳转到由寄存器给出的地址,并根据寄存器的LSE 确定处理器的状态,还要把跳转前的下条指令地址保存到LR |
BX | 跳转到由寄存器/标号给出的地址,不用返回 |
B | 跳转到一个标号 |
IF,ELSE,EN-DIF | 汇编条件分支语句,跟C 语言的类似 |
END | 到达文件的末尾,文件结束 |
上表,列举了STM32 启动文件的一些汇编和编译器指令,关于其他更多的ARM 汇编指令,我们可以通过MDK 的索引搜索工具中搜索找到。打开索引搜索工具的方法:
MDK->Help->uVision Help,如图9.2.1.1 所示。
打开之后,我们以EQU 为例,演示一下怎么使用,如图9.2.1.2 所示。
图9.2.1.2 搜索EQU 汇编指令
搜索到的结果有很多,我们只需要看位置为Assembler User Guide 这部分即可。
(1)栈空间的开辟
栈空间的开辟,源码如图9.2.2.1 所示:
源码含义:开辟一段大小为0x0000 0400(1KB)的栈空间,段名为STACK,NOINIT 表示不初始化;READWRITE 表示可读可写;ALIGN=3,表示按照2^3 对齐,即8 字节对齐。
AREA 汇编一个新的代码段或者数据段。
SPACE 分配内存指令,分配大小为Stack_Size字节连续的存储单元给栈空间。
__initial_sp 紧挨着SPACE 放置,表示栈的结束地址,栈是从高往低生长,所以结束地址就是栈顶地址。
栈主要用于存放局部变量,函数形参等,属于编译器自动分配和释放的内存,栈的大小不能超过内部SRAM 的大小。如果工程的程序量比较大,定义的局部变量比较多,那么就需要在启动代码中修改栈的大小,即修改Stack_Size 的值。如果程序出现了莫名其妙的错误,并进入了HardFault 的时候,你就要考虑下是不是栈空间不够大,溢出了的问题。
(2)堆空间的开辟
堆空间的开辟,源码如图9.2.2.2 所示:
源码含义:开辟一段大小为0x0000 0200(512 字节)的堆空间,段名为HEAP,不初始化,可读可写,8 字节对齐。
__heap_base 表示堆的起始地址,__heap_limit 表示堆的结束地址。堆和栈的生长方向相反的,堆是由低向高生长,而栈是从高往低生长。
堆主要用于动态内存的分配,像malloc()、calloc()和realloc()等函数申请的内存就在堆上面,如果程序里面没有用到malloc等这些函数可以将堆空间设置成0,节省空间。堆中的内存一般由程序员分配和释放,若程序员不释放,程序结束时可能由操作系统回收。
接下来是PRESERVE8 和THUMB 指令两行代码。如图9.2.2.3 所示。
PRESERVE8:指示编译器按照8 字节对齐。
THUMB:指示编译器之后的指令为THUMB 指令。
注意:由于正点原子提供了独立的内存管理实现方式(mymalloc,myfree 等),并不需要使用C库的malloc 和free 等函数,也就用不到堆空间,因此我们可以设置Heap_Size 的大小为0,以节省内存空间。
(3)中断向量表定义(简称:向量表)
为中断向量表定义一个数据段,如图9.2.2.4 所示
源码含义:定义一个数据段,名字为RESET, READONLY 表示只读。EXPORT 表示声明一个标号具有全局属性,可被外部的文件使用。这里是声明了__Vectors、__Vectors_End 和__Vectors_Size 三个标号具有全局性,可被外部的文件使用。
STM32F103 的中断向量表定义代码,如图9.2.2.5 所示。
__Vectors 为向量表起始地址,__Vectors_End 为向量表结束地址,__Vectors_Size 为向量表大小,__Vectors_Size = __Vectors_End - __Vectors。
DCD:分配一个或者多个以字为单位的内存,以四字节对齐,并要求初始化这些内存。
中断向量表被放置在代码段的最前面。例如:当我们的程序在FLASH 运行时,那么向量表的起始地址是:0x0800 0000。结合图9.2.2.5 可以知道,地址0x0800 0000 存放的是栈顶地址。
DCD 以四字节对齐分配内存,也就是下个地址是0x0800 0004,存放的是Reset_Handler中断函数入口地址。
从代码上看,向量表中存放的都是中断服务函数的函数名,所以C 语言中的函数名对芯片来说实际上就是一个地址。
STM32F103 的中断向量表可以在《STM32F10xxx 参考手册_V10(中文版).pdf》的第9 章的9.1.2 小节找到,与中断向量表定义代码是对应的。
(4)复位程序
接下来是定义只读代码段,如图9.2.2.6 所示:
定义一个段命为.text,只读的代码段,在CODE 区。
复位子程序代码,如图9.2.2.7 所示:
如果外部没有:SystemInit函数则会报错!可以把它屏蔽掉,或者在外面定义一个空的函数
EXPORT 声明复位中断向量Reset_Handler 为全局属性,这样外部文件就可以调用此复位中断服务。
WEAK:表示弱定义,如果外部文件优先定义了该标号则首先引用外部定义的标号,如果外部文件没有声明也不会出错。这里表示复位子程序可以由用户在其他文件重新实现,这里并不是唯一的。
IMPORT 表示该标号来自外部文件。这里表示SystemInit 和__main 这两个函数均来自外部的文件。
LDR、BLX、BX 是内核指令,可在《Cortex-M3 权威指南》第四章-指令集里面查询到。
LDR 表示从存储器中加载字到一个存储器中。
BLX 表示跳转到由寄存器给出的地址,并根据寄存器的LSE 确定处理器的状态,还要把跳转前的下条指令地址保存到LR。
BX 表示跳转到由寄存器/标号给出的地址,不用返回。这里表示切换到__main 地址,最终调用main 函数,不返回,进入C 的世界。
单独有个文档专门讲解启动文件,里面有关于_main函数的,百度网盘里目前没有文档,后面找到再补充到博文里面。
(5)中断服务程序
可以看到这些中断服务函数都被[WEAK]声明为弱定义函数,如果外部文件声明了一个标号,则优先使用外部文件定义的标号,如果外部文件没有定义也不会出错。
这些中断函数分为系统异常中断和外部中断,外部中断根据不同芯片有所变化。B 指令是跳转到一个标号,这里跳转到一个‘.’,表示无限循环。
在启动文件代码中,已经把我们所有中断的中断服务函数写好了,但都是声明为弱定义,所以真正的中断服务函数需要我们在外部实现。
如果我们开启了某个中断,但是忘记写对应的中断服务程序函数又或者把中断服务函数名写错,那么中断发生时,程序就会跳转到启动文件预先写好的弱定义的中断服务程序中,并且在B 指令作用下跳转到一个‘.’中,无限循环。
这里的系统异常中断部分是内核的,外部中断部分是外设的。
(6)用户堆栈初始化
ALIGN 指令,如图9.2.2.9 所示:
ALIGN 表示对指令或者数据的存放地址进行对齐,一般需要跟一个立即数,缺省表示4 字节对齐。要注意的是,这个不是ARM 的指令,是编译器的。
接下就是启动文件最后一部分代码,用户堆栈初始化代码,如图9.2.2.10 所示:
IF, ELSE, ENDIF 是汇编的条件分支语句。
588 行判断是否定义了__MICROLIB。关于__MICROLIB 这个宏定义,我们是在KEIL 里面配置,具体方法如图9.2.2.11 所示。
勾选了Use MicroLIB 就代表定义了__MICROLIB 这个宏。
如果定义__MICROLIB,声明__initial_sp、__heap_base 和__heap_limit 这三个标号具有全局属性,可被外部的文件使用。__initial_sp 表示栈顶地址,__heap_base 表示堆起始地址,
__heap_limit 表示堆结束地址。
如果没有定义__MICROLIB,实际的情况就是我们没有定义__MICROLIB,所以使用默认的C 库运行。那么堆栈的初始化由C 库函数__main 来完成。
IMPORT 声明__use_two_region_memory 标号来自外部文件。
EXPORT 声明__user_initial_stackheap 具有全局属性,可被外部的文件使用。
340 行标号__user_initial_stackheap,表示用户堆栈初始化程序入口。
接下来进行堆栈空间初始化,堆是从低到高生长,栈是从高到低生长,是两个互相独立的数据段,并且不能交叉使用。
344 行保存堆起始地址。345 行保存栈大小。346 行保存堆大小。347 行保存栈顶指针。348行跳转到LR 标号给出的地址,不用返回。354 行END 表示到达文件的末尾,文件结束。
Use MicroLIB
MicroLIB 是MDK 自带的微库,是缺省C 库的备选库,MicroLIB 进行了高度优化使得其代码变得很小,功能比缺省C 库少。MicroLIB 是没有源码的,只有库。
关于MicroLIB 更多知识可以看官方介绍http://www.keil.com/arm/microlib.asp 。
我们知道启动模式不同,启动的起始地址是不一样的,下面我们以代码下载到内部FLASH的情况举例,即代码从地址0x0800 0000 开始被执行。
当产生复位,并且离开复位状态后,CM3 内核做的第一件事就是读取下列两个32 位整数的值:
(1)从地址0x0800 0000 处取出堆栈指针MSP 的初始值,该值就是栈顶地址。
(2)从地址0x0800 0004 处取出程序计数器指针PC 的初始值,该值指向中断服务程序Reset_Handler。下面用示意图表示,如图9.2.3.1 所示。
我们看看STM32F103 开发板HAL 库例程的实验1 跑马灯实验中,取出的MSP 和PC 的值是多少,方法如图9.2.3.2 所示。
由图9.2.3.2 可以知道地址0x0800 0000 的值是0x2000 0788,地址0x0800 0004 的值是0x0800 01CD,即堆栈指针SP =0x2000 0788,程序计数器指针PC = 0x0800 01CD(即复位中断服务程序Reset_Handler 的入口地址)。因为CM3 内核是小端模式,所以倒着读。
请注意,这与传统的ARM 架构不同——其实也和绝大多数的其它单片机不同。传统的ARM架构总是从0 地址开始执行第一条指令。它们的0 地址处总是一条跳转指令。而在CM3 内核中,0 地址处提供MSP 的初始值,然后就是向量表(向量表在以后还可以被移至其它位置)。
向量表中的数值是32 位的地址,而不是跳转指令。向量表的第一个条目指向复位后应执行的第一条指令,就是Reset_Handler 这个函数。下面继续以MINI 开发板HAL 库例程实验1 跑马灯实验为例,代码从地址0x0800 0000 开始被执行,讲解一下系统启动,初始化堆栈、MSP 和PC后的内存情况。
前面定义了堆大小0x200,MSP指针会自动加1,所以上图堆大小是0x201。
因为CM3 使用的是向下生长的满栈,所以MSP 的初始值必须是堆栈内存的末地址加1。
举例来说,如果你的栈区域在0x2000 0388‐0x2000 0787(1KB 大小)之间,那么MSP 的初始值就必须是0x2000 0788。
向量表跟随在MSP 的初始值之后——也就是第2 个表目。
R15 是程序计数器,在汇编代码中,可以使用名字“PC”来访问它。ARM 规定:PC 最低两位并不表示真实地址,最低位LSB 用于表示是ARM 指令(0)还是Thumb 指令(1),因为CM3 主要执行Thumb 指令,所以这些指令的最低位都是1(都是奇数)。因为CM3 内部使用了指令流水线,读PC 时返回的值是当前指令的地址+4。比如说:
0x1000: MOV R0, PC ; R0 = 0x1004
如果向PC 写数据,就会引起一次程序的分支(但是不更新LR 寄存器)。CM3 中的指令至少是半字对齐的,所以PC 的LSB 总是读回0。然而,在分支时,无论是直接写PC 的值还是使用分支指令,都必须保证加载到PC 的数值是奇数(即LSB=1),表明是在Thumb 状态下执行。倘若写了0,则视为转入ARM 模式,CM3 将产生一个fault 异常。
正因为上述原因,图9.2.3.3 中使用0x0800 01CD 来表达地址0x0800 01CC。当0x0800 01CD处的指令得到执行后,就正式开始了程序的执行(即去到C 的世界)。所以在此之前初始化MSP是必需的,因为可能第1 条指令还没执行就会被NMI 或是其它fault 打断。MSP 初始化好后就已经为它们的服务例程准备好了堆栈。
STM32 启动文件分析就给大家介绍到这里,更多内容请看《STM32 启动文件浅析》。
MDK 编译工程,会生成一些中间文件(如.o、.axf、.map 等),最终生成hex 文件,以便下载到MCU 上面执行,以STM32F103 开发板HAL 库例程的实验1 跑马灯实验为例(其他开发板类似),编译过程产生的所有文件,都存放在Output 文件夹下,如图9.3.1.1 所示:
这里总共生成了43 个文件,共11 个类型,分别是:.axf、.crf、.d、.dep、.hex、.lnp、.lst、.o、.htm、
bulild_log.htm 和.map。43 个文件(勾选Browse informatio-n 时为59 个)看着不是很多,但是随着工程的增大,这些文件也会越来越多,大项目编译一次,可以生成几百甚至上千个这种文件,不过文件类型基本就是上面这些。
对于MDK 工程来说,基本上任何工程在编译过程中都会有这11 类文件,常见的MDK编译过程生产文件类型如表9.3.1.1 所示:
文件类型 | 说明 |
---|---|
.o | 可重定向(注1)对象文件,每个源文件(.c/.s 等)编译都会生成一个.o 文件 |
.axf | 由ARMCC 编译生产的可执行对象文件,不可重定向(注2)(绝对地址);多个.o 文件链接生成.axf 文件,我们在仿真的时候,需要用到该文件 |
.hex | Intel Hex 格式文件,可用于下载到MCU,.hex 文件由.axf 文件转换而来 |
.crf | 交叉引用文件,包含浏览信息(定义、标识符、引用) |
.d | 由ARMCC/GCC 编译生产的依赖文件(.o 文件所对应的依赖文件)每个.o 文件,都有一个对应的.d 文件 |
.dep | 整个工程的依赖文件 |
.lnp | MDK 生成的链接输入文件,用于命令输入 |
.lst | C 语言或汇编编译器生成的列表文件 |
.htm | 链接生成的列表文件 |
.build_log.htm | 最近一次编译工程时的日志记录文件 |
.map | 连接器生成的列表文件,对分析程序存储占用情况非常有用 |
注1,可重定向是指该文件包含数据/代码,但是并没有指定地址,它的地址可由后续链接的时候进行指定。
注2,不可重定向是指该文件所包含的数据/代码都已经指定地址了,不能再改变。
.map 文件是编译器链接时生成的一个文件,它主要包含了交叉链接信息。通过.map 文件,我们可以知道整个工程的函数调用关系、FLASH 和RAM 占用情况及其详细汇总信息,能具体到单个源文件(.c/.s)的占用情况,根据这些信息,我们可以对代码进行优化。.map文件可以分为以下5 个组成部分:
要生成map 文件,我们需要在MDK 的魔术棒→Listing 选项卡里面,进行相关设置,如图9.3.2.1.1 所示:
图9.3.2.1.1 中红框框出的部分就是我们需要设置的,默认情况下,MDK 这部分设置就是全勾选的,如果我们想取消掉一些信息的输出,则取消相关勾选即可(一般不建议)。
如图9.3.2.1.1 设置好MDK 以后,我全编译当前工程,当编译完成后(无错误),就会生成.map 文件。在MDK 里面打开.map 文件的方法如图9.3.2.1.2 所示:
1,先确保工程编译成功(无错误)。
2,双击LED,打开.map 文件。
3,map 文件打开成功。
为了更好的分析map 文件,我们先对需要用到的一些基础概念进行一个简单介绍,相关概念如下:
⚫ Section:描述映像文件的代码或数据块,我们简称程序段
⚫ RO:Read Only 的缩写,包括只读数据(RO data)和代码(RO code)两部分内容,占用FLASH 空间
⚫ RW:Read Write 的缩写,包含可读写数据(RW data,有初值,且不为0),占用FLASH(存储初值)和RAM(读写操作)
⚫ ZI:Zero initialized 的缩写,包含初始化为0 的数据(ZI data),占用RAM 空间。
⚫ .text:相当于RO code
⚫ .constdata:相当于RO data
⚫ .bss:相当于ZI data
⚫ .data:相当于RW data
我们前面说map 文件分为5 个部分组成,下面以STM32F103 开发板HAL 库例程的实验1 跑马灯实验为例,简要讲解一下。
1.程序段交叉引用关系(S S ection Cross References s )
这部分内容描述了各个文件(.c/.s 等)之间函数(程序段)的调用关系,举个例子如图9.3.2.3.1 所示:
上图中,框出部分:main.o(i.main) refers to sys.o(i.sys_stm32_clock_init) for sys_stm32_
clock_init 表示:main.c 文件中的main 函数,调用了sys.c 中的sys_stm32_clock_init 函数。其中:i.main 表示main 函数的入口地址,同理i.sys_stm32_clock_init 表示sys_stm32_clock_init 函数的入口地址。
2. 删除映像未使用的程序段(Removing Unused input sections from the image)
这部分内容描述了工程中由于未被调用而被删除的冗余程序段(函数/数据),如图9.3.2.3.2 所示:
上图中,列出了所有被移除的程序段,比如usart.c 里面的usart_init 函数就被移除了,因为该例程没用到usart_init 函数。
另外,在最后还有一个统计信息:216 unused section(s) (total 15556bytes) removed from the image. 表示总共移除了216 个程序段(函数/数据),大小为15556 字节。即给我们的MCU 节省了15556 字节的程序空间。
为了更好的节省空间,我们一般在MDK→魔术棒→C/C++选项卡里面勾选:One ELFSection per Function,如图9.3.2.3.3 所示:
3. 映像符号表(Image Symbol Table)
映像符号表(Image Symbol Table)描述了被引用的各个符号(程序段/数据)在存储器中的存储地址、类型、大小等信息。映像符号表分为两类:本地符号(Local Symbols)和全局符号(Global Symbols)。
本地符号(Local Symbols)记录了用static 声明的全局变量地址和大小,c 文件中函数的地址和用static 声明的函数代码大小,汇编文件中的标号地址(作用域:限本文件)。
全局符号(Global Symbols)记录了全局变量的地址和大小,C 文件中函数的地址及其代码大小,汇编文件中的标号地址(作用域:全工程)。
4. 映像内存分布图(Memory Map of the image)
映像文件分为加载域(Load Region)和运行域(Execution Region)。一个加载域必须有至少一个运行域(可以有多个运行域),而一个程序又可以有多个加载域。加载域为映像程序的实际存储区域,而运行域则是MCU 上电后的运行状态。加载域和运行域的简化关系(这里仅表示一个加载域的情况)图,如图9.3.2.3.4 所示:
由图可知,RW 区也是存放在ROM(FLASH)里面的,在执行main 函数之前,RW(有初值且不为0 的变量)数据会被拷贝到RAM 区,同时还会在RAM 里面创建ZI 区(初始化为0 的变量)。
5. 映像组件大小(Image component sizes)
映像组件大小(Image component sizes)给出了整个映像所有代码(.o)占用空间的汇总信息。这部分是程序实际功能可执行代码的存储空间。
由于篇幅较长,更多内容请大家查阅《STM32 MAP 文件浅析》文档的内容。
数字电路的知识告诉我们:任意复杂的电路控制系统都可以经由门电路组成的组合电路实现。回顾《第五章STM32 基础知识入门》的知识点,我们知道STM32 内部也是由多种多样的电路模块组合在一起实现的。当一个电路越复杂,在达到正确的输出结果前,它可能因为延时会有一些短暂的中间状态,而这些中间状态有时会导致输出结果会有一个短暂的错误,这叫做电路中的“毛刺现象”,如果电路需要运行得足够快,那么这些错误状态会被其它电路作为输入采样,最终形成一系列的系统错误。为了解决这个问题,在单片机系统中,设计时以时序电路控制替代纯粹的组合电路,在每一级输出结果前对各个信号进行采样,从而使得电路中某些信号即使出现延时也可以保证各个信号的同步,可以避免电路中发生的“毛刺现象”,达到精确控制输出的效果。
由于时序电路的重要性,因此在MCU 设计时就设计了专门用于控制时序的电路,在芯片设计中称为时钟树设计。由此设计出来的时钟,可以精确控制我们的单片机系统,这也是我们这节要展开分析的时钟分析。为什么是时钟树而不是时钟呢?一个MCU 越复杂,时钟系统也会相应地变得复杂,如STM32F1 的时钟系统比较复杂,不像简单的51 单片机一个系统时钟就可以解决一切。对于STM32F1 系列的芯片,正常工作的主频可以达到72Mhz,但并不是所有外设都需要系统时钟这么高的频率,比如看门狗以及RTC 只需要几十kHZ 的时钟即可。同一个电路,时钟越快功耗越大,同时抗电磁干扰能力也会越弱,所以对于较为复杂的MCU 一般都是采取多时钟源的方法来解决这些问题。
STM32 本身非常复杂,外设非常的多,为了保持低功耗工作,STM32 的主控默认不开启这些外设功能。用户可以根据自己的需要决定STM32 芯片要使用的功能,这个功能开关在STM32主控中也就是各个外设的时钟。
如图11.1.1 为一个简化的STM32F1 时钟系统。
对于STM32F1,输入时钟源(Input Clock)主要包括HSI,HSE,LSI,LSE。其中,从时钟频率来分可以分为高速时钟源和低速时钟源,其中HSI、HSE 高速时钟,LSI 和LSE 是低速时钟。从来源可分为外部时钟源和内部时钟源,外部时钟源就是从外部通过接晶振的方式获取时钟源,其中HSE 和LSE 是外部时钟源;其他是内部时钟源,芯片上电即可产生,不需要借助外部电路。下面我们看看STM32 的时钟源。
(1)2 个外部时钟源:
⚫ 高速外部振荡器HSE (High Speed External Clock signal)
外接石英/陶瓷谐振器,频率为4MHz~16MHz。本开发板使用的是8MHz。
⚫ 低速外部振荡器LSE (Low Speed External Clock signal)
外接32.768kHz 石英晶体,主要作用于RTC 的时钟源。
(2)2 个内部时钟源:
⚫ 高速内部振荡器HSI(High Speed Internal Clock signal)
由内部RC(电阻电容构成) 振荡器产生,频率为8MHz。
⚫ 低速内部振荡器LSI(Low Speed Internal Clock signal)
由内部RC 振荡器产生,频率为40kHz,可作为独立看门狗的时钟源。
芯片上电时默认由内部的HSI 时钟启动,如果用户进行了硬件和软件的配置,芯片才会根据用户配置调试尝试切换到对应的外部时钟源,所以同时了解这几个时钟源信号还是很有必要的。如何设置时钟的方法我们会在后文提到。
锁相环是自动控制系统中常用的一个反馈电路,在STM32 主控中,锁相环的作用主要有两个部分:输入时钟净化和倍频。前者是利用锁相环电路的反馈机制实现,后者我们用于使芯片在更高且频率稳定的时钟下工作。
在STM32 中,锁相环的输出也可以作为芯片系统的时钟源。根据图11.1.1 的时钟结构,使用锁相环时只需要进行三个部分的配置。为了方便查看,截取了使用PLL 作为系统时钟源的配置部分,如图11.1.2.1 所示。
图11.1.2.1 借用了在CubeMX 下用锁相环配置72MHz 时钟的一个示例:
◆ PLLXTPRE:HSE 分频器作为PLL 输入(HSE divider for PLL entry)即图11.1.2.1 在标注为①的地方,它专门用于HSE,ST 设计它有两种方式,并把它的控制功能放在RCC_CFGR 寄存器中,我们引用如图11.1.2.2。
从F103 参考手册可知它的值有两个:一是2 分频,另一种是1 分频(不分频)。
经过HSE 分频器处理后的输出振荡时钟信号比直接输入的时钟信号更稳定。
◆ PLLSRC:PLL 输入时钟源(PLL entry clock source)
图中②表示的是PLL 时钟源的选择器,同样的,参考F103 参考手册:
它有两种可选择的输入源:设计为HSI 的二分频时钟,另一个是A 处的PLLXTPRE 处理后的HSE 信号。
◆ PLLMUL:PLL 倍频系数(PLL multiplication factor)
图中③所表示的配置锁相环倍频系数,同样地可以查到在STM32F1 系列中,ST 设置它的有效倍频范围为2~16 倍。
结合图11.1.2.1,要实现72MHz 的主频率,我们通过选择HSE 不分频作为PLL 输入的时钟信号,即输入8Mhz,通过标号③选择倍频因子,可选择2-16 倍频,我们选择9 倍频,这样可以得到时钟信号为8*9=72MHz。
STM32 的系统时钟SYSCLK 为整个芯片提供了时序信号。我们已经大致知道STM32 主控是时序电路链接起来的。对于相同的稳定运行的电路,时钟频率越高,指令的执行速度越快,单位时间能处理的功能越多。STM32 的系统时钟是可配置的,在STM32F1 系列中,它可以为HSI、PLLCLK、HSE 中的一个,通过CFGR 的位SW[1:0]设置。
讲解PLL 作为系统时钟时,根据我们开发板的资源,可以把主频通过PLL 设置为72MHz。仍使用PLL 作为系统时钟源,如果使用HSI/2,那么可以得到最高主频8MHz/2*16=64MHz。从上面的图11.2.1 时钟树图可知,AHB、APB1、APB2、内核时钟等时钟通过系统时钟分频得到。
根据得到的这个系统时钟,下面我们结合外设来看一看各个外设时钟源。
大家看图11.2.3.1 STM32F103 系统时钟,标号C 为系统时钟输入选择,可选时钟信号有外部高速时钟HSE(8M)、内部高速时钟HSI(8M)和经过倍频的PLL CLK(72M),选择PLL CLK 作为系统时钟,此时系统时钟的频率为72MHz。系统时钟来到标号D 的AHB 预分频器,其中可选择的分频系数为1,2,4,8,16,32,64,128,256,我们选择不分频,所以AHB 总线时钟达到最大的72MHz。
下面介绍一下由AHB 总线时钟得到的时钟:
APB1 总线时钟,由HCLK 经过标号E 的低速APB1 预分频器得到,分频因子可以选择1,2,4,8,16,这里我们选择的是2 分频,所以APB1 总线时钟为36M。由于APB1 是低速总线时钟,所以APB1 总线最高频率为36MHz,片上低速的外设就挂载在该总线上,例如有看门狗定时器、定时器2/3/4/5/6/7、RTC 时钟、USART2/3/4/5、SPI2(I2S2)与SPI3(I2S3)、I2C1 与I2C2、CAN、USB 设备和2 个DAC。
APB2 总线时钟,由HCLK 经过标号F 的高速APB2 预分频器得到,分频因子可以选择1,2,4,8,16,这里我们选择的是1 即不分频,所以APB2 总线时钟频率为72M。与APB2 高速总线链接的外设有外部中断与唤醒控制、7 个通用目的输入/输出口(PA、PB、PC、PD、PE、PF和PG)、定时器1、定时器8、SPI1、USART1、3 个ADC 和内部温度传感器。其中标号G 是ADC 的预分频器在后面ADC 实验中详细说明。
此外,AHB 总线时钟直接作为SDIO、FSMC、AHB 总线、Cortex 内核、存储器和DMA 的HCLK 时钟,并作为Cortex 内核自由运行时钟FCLK。
标号H 是USBCLK,是一个通用串行接口时钟,时钟来源于PLLCLK。STM32F103 内置全速功能的USB 外设,其串行接口引擎需要一个频率为48MHz 的时钟源。该时钟源只能从PLL 输出端获取,可以选择为1.5 分频或者1 分频,也就是,当需要使用USB 模块时,PLL 必须使能,并且时钟频率配置为48MHz 或72MHz。
标号I 是MCO 输出内部时钟,STM32 的一个时钟输出IO(PA8),它可以选择一个时钟信号输出,可以选择为PLL 输出的2 分频、HSI、HSE、或者系统时钟。这个时钟可以用来给外部其他系统提供时钟源。
标号J 是RTC 定时器,其时钟源为HSE/128、LSE 或LSI。
STM32 允许通过设置,通过MCO 引脚输出一个稳定的时钟信号。在图11.1.1 中标注为“G”的部分。以下四个时钟信号可被选作MCO 时钟:
●SYSCLK
●HSI
●HSE
●除2 的PLL 时钟
时钟的选择由时钟配置寄存器(RCC_CFGR)中的MCO[2:0]位控制。
我们可以通过MCO 引脚来输出时钟信号,测试输出时钟的频率,或作为其它需要时钟信号的外部电路的时钟。
STM32F103 默认的情况下(比如:串口IAP 时或者是未初始化时钟时),使用的是内部8M的HSI 作为时钟源,所以不需要外部晶振也可以下载和运行代码的。
下面我们来讲解如何让STM32F103 芯片在72MHZ 的频率下工作,72MHz 是官方推荐使用的最高的稳定时钟频率。而正点原子的STM32F103 战舰开发板的外部高速晶振的频率就是8MHz,我们就是在这个晶振频率的基础上,通过各种倍频和分频得到72MHZ 的系统工作频率。
第1 步:配置HSE_VALUE
讲解STM32F1xx_hal_conf.h 文件的时候,我们知道需要宏定义HSE_VALUE 匹配我们实际硬件的高速晶振频率(这里是8MHZ),代码中通过使用宏定义的方式来选择HSE_VALUE 的值是25M 或者8M,这里我们不去定义USE_STM3210C_EVAL 这个宏或者全局变量即可,选择定义HSE_VALUE 的值为8M。代码如下:
#if !defined (HSE_VALUE)
#if defined(USE_STM3210C_EVAL)
#define HSE_VALUE 25000000U /*!< Value of the External oscillator in Hz */
#else
#define HSE_VALUE 8000000U /*!< Value of the External oscillator in Hz */
#endif
#endif /* HSE_VALUE */
第2 步:调用SystemInit 函数
我们介绍启动文件的时候就知道,在系统启动之后,程序会先执行SystemInit 函数,进行系统一些初始化配置。启动代码调用SystemInit 函数如下:
Reset_Handler PROC
EXPORT Reset_Handler [WEAK]
IMPORT SystemInit
IMPORT __main
LDR R0, =SystemInit
BLX R0
LDR R0, =__main
BX R0
ENDP
下面我们来看看system_stm32f1xx.c 文件下定义的SystemInit 程序,源码在176 行到188行,简化函数如下。
void SystemInit (void)
{
#if defined(STM32F100xE) || defined(STM32F101xE) || defined(STM32F101xG) || de-fined(STM32F103xE) || defined(STM32F103xG)
#ifdef DATA_IN_ExtSRAM
SystemInit_ExtMemCtl();
#endif /* 配置扩展SRAM */
#endif
/* 配置中断向量表*/
#if defined(USER_VECT_TAB_ADDRESS)
SCB->VTOR = VECT_TAB_BASE_ADDRESS | VECT_TAB_OFFSET; /* Vector Table Reloca-tion in Internal SRAM. */
#endif /* USER_VECT_TAB_ADDRESS */
}
从上面代码可以看出,SystemInit 主要做了如下两个方面工作:
然而我们的代码中实际并没有定义DATA_IN_ExtSRAM 和USER_VECT_TAB_ADDRESS这两个宏,实际上SystemInit 对于正点原子的例程并没有起作用,但我们保留了这个接口。从而避免了去修改启动文件。另外,是可以把一些重要的初始化放到SystemInit 这里,在main 函数运行前就把重要的一些初始化配置好(如ST 这里是在运行main 函数前先把外部的SRAM 初始化),这个我们一般用不到,直接到main 函数中处理即可,但也有厂商(如RT-Thread)就采取了这样的做法,使得main 函数更加简单,但对于初学者,我们暂时不建议这种用法。
HAL 库的SystemInit 函数并没有任何时钟相关配置,所以后续的初始化步骤,我们还必须编写自己的时钟配置函数。
第3步:在main 函数里调用用户编写的时钟设置函数
我们打开HAL 库例程实验1 跑马灯实验,看看我们在工程目录Drivers\SYSTEM 分组下面定义的sys.c 文件中的时钟设置函数sys_stm32_clock_init 的内容:
/**
* @brief 系统时钟初始化函数
* @param plln: PLL倍频系数(PLL倍频), 取值范围: 2~16
中断向量表位置在启动时已经在SystemInit()中初始化
* @retval 无
*/
void sys_stm32_clock_init(uint32_t plln)
{
HAL_StatusTypeDef ret = HAL_ERROR;
RCC_OscInitTypeDef rcc_osc_init = {0};
RCC_ClkInitTypeDef rcc_clk_init = {0};
rcc_osc_init.OscillatorType = RCC_OSCILLATORTYPE_HSE; /* 选择要配置HSE */
rcc_osc_init.HSEState = RCC_HSE_ON; /* 打开HSE */
rcc_osc_init.HSEPredivValue = RCC_HSE_PREDIV_DIV1; /* HSE预分频系数*/
rcc_osc_init.PLL.PLLState = RCC_PLL_ON; /* 打开PLL */
rcc_osc_init.PLL.PLLSource = RCC_PLLSOURCE_HSE; /* PLL时钟源选择HSE */
rcc_osc_init.PLL.PLLMUL = plln; /* PLL倍频系数*/
ret = HAL_RCC_OscConfig(&rcc_osc_init); /* 初始化*/
if (ret != HAL_OK)
{
while (1); /* 时钟初始化失败后,程序将可能无法正常执行,可以在这里加入自己的处理*/
}
/* 选中PLL作为系统时钟源并且配置HCLK,PCLK1和PCLK2*/
rcc_clk_init.ClockType = (RCC_CLOCKTYPE_SYSCLK | RCC_CLOCKTYPE_HCLK
| RCC_CLOCKTYPE_PCLK1 | RCC_CLOCKTYPE_PCLK2);
rcc_clk_init.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK;/* 设置系统时钟来自PLL */
rcc_clk_init.AHBCLKDivider = RCC_SYSCLK_DIV1; /* AHB分频系数为1 */
rcc_clk_init.APB1CLKDivider = RCC_HCLK_DIV2; /* APB1分频系数为2 */
rcc_clk_init.APB2CLKDivider = RCC_HCLK_DIV1; /* APB2分频系数为1 */
/* 同时设置FLASH延时周期为2WS,也就是3个CPU周期。*/
ret = HAL_RCC_ClockConfig(&rcc_clk_init, FLASH_LATENCY_2);
if (ret != HAL_OK)
{
while (1); /* 时钟初始化失败后,程序将可能无法正常执行,可以在这里加入自己的处理*/
}
}
函数sys_stm32_clock_init 就是用户的时钟系统配置函数,除了配置PLL 相关参数确定SYSCLK 值之外,还配置了AHB、APB1 和APB2 的分频系数,也就是确定了HCLK,PCLK1和PCLK2 的时钟值。
我们首先来看看使用HAL 库配置STM32F1 时钟系统的一般步骤:
下面我们详细讲解这个2 个步骤。
步骤1:配置时钟源相关参数,使能并选择HSE 作为PLL 时钟源,配置PLL1,我们调用的函数为HAL_RCC_OscConfig(),该函数在HAL 库头文件STM32F1xx_hal_rcc.h 中声明,在文件STM32F1xx_hal_rcc.c 中定义。首先我们来看看该函数声明:
HAL_StatusTypeDef HAL_RCC_OscConfig(RCC_OscInitTypeDef *RCC_OscInitStruct);
该函数只有一个形参,就是结构体RCC_OscInitTypeDef 类型指针。接下来我们看看结构体RCC_OscInitTypeDef 的定义:
typedef struct
{
uint32_t OscillatorType; /* 需要选择配置的振荡器类型*/
uint32_t HSEState; /* HSE状态*/
uint32_t HSEPredivValue; /* HSE预分频值*/
uint32_t LSEState; /* LSE状态*/
uint32_t HSIState; /* HIS状态*/
uint32_t HSICalibrationValue; /* HIS校准值*/
uint32_t LSIState; /* LSI状态*/
RCC_PLLInitTypeDef PLL; /* PLL配置*/
}RCC_OscInitTypeDef;
该结构体前面几个参数主要是用来选择配置的振荡器类型。比如我们要开启HSE,那么我们会设置OscillatorType 的值为RCC_OSCILLATORTYPE_HSE,然后设置HSEState 的值为
RCC_HSE_ON 开启HSE。对于其他时钟源:HIS、LSI、LSE,配置方法类似。
RCC_OscInitTypeDef 这个结构体还有一个很重要的成员变量是PLL,它是结构体RCC_PLLInitTypeDef 类型。它的作用是配置PLL 相关参数,我们来看看它的定义:
typedef struct
{
uint32_t PLLState; /* PLL状态*/
uint32_t PLLSource; /* PLL时钟源*/
uint32_t PLLMUL; /* PLL倍频系数M */
}RCC_PLLInitTypeDef;
从RCC_PLLInitTypeDef;结构体的定义很容易看出该结构体主要用来设置PLL 时钟源以及相关分频倍频参数。这个结构体的定义的相关内容请结合时钟树中红色框的内容一起理解。
接下来我们看看我们的时钟初始化函数sys_stm32_clock_init 中的配置内容:
/* 使能HSE,并选择HSE作为PLL时钟源,配置PLLMUL */
RCC_OscInitTypeDef rcc_osc_init = {0};
rcc_osc_init.OscillatorType = RCC_OSCILLATORTYPE_HSE; /* 使能HSE */
rcc_osc_init.HSEState = RCC_HSE_ON; /* 打开HSE */
rcc_osc_init.HSEPredivValue = RCC_HSE_PREDIV_DIV1; /* HSE预分频*/
rcc_osc_init.PLL.PLLState = RCC_PLL_ON; /* 打开PLL */
rcc_osc_init.PLL.PLLSource = RCC_PLLSOURCE_HSE; /* PLL时钟源为HSE */
rcc_osc_init.PLL.PLLMUL = plln; /* 主PLL倍频因子*/
ret = HAL_RCC_OscConfig(&rcc_osc_init); /* 初始化*/
通过函数的该段程序,我们开启了HSE 时钟源,同时选择PLL 时钟源为HSE,然后把sys_stm32_clock_init 的形参直接设置作为PLL 的参数M 的值,这样就达到了设置PLL 时钟源相关参数的目的。
设置好PLL 时钟源参数之后,也就是确定了PLL 的时钟频率,然后到我们的步骤2。
步骤2:配置系统时钟源,以及SYSCLK、AHB、APB1 和APB2 相关参数,用函数HAL_RCC_ClockConfig(),声明如下:
HAL_StatusTypeDef HAL_RCC_ClockConfig(RCC_ClkInitTypeDef *RCC_ClkInitStruct,
uint32_t FLatency);
该函数有两个形参,第一个形参RCC_ClkInitStruct 是结构体RCC_ClkInitTypeDef 类型指针变量,用于设置SYSCLK 时钟源以及SYSCLK、AHB、APB1 和APB2 的分频系数。第二个形参FLatency 用于设置FLASH 延迟。
RCC_ClkInitTypeDef 结构体类型定义比较简单,我们来看看其定义:
typedef struct
{
uint32_t ClockType; /* 要配置的时钟*/
uint32_t SYSCLKSource; /* 系统时钟源*/
uint32_t AHBCLKDivider; /* AHB分频系数*/
uint32_t APB1CLKDivider; /* APB1分频系数*/
uint32_t APB2CLKDivider; /* APB2分频系数*/
}RCC_ClkInitTypeDef;
我们在sys_stm32_clock_init 函数中的实际应用配置内容如下:
/****************** 具体配置*************************/
/*选中PLL作为系统时钟源并且配置HCLK,PCLK1(APB1)和 PCLK2(APB2)*/
/*设置系统时钟时钟源为PLL*/
/*AHB分频系数为1*/
/*APB1分频系数为2*/
/*APB2分频系数为1*/
/*同时设置FLASH延时周期为2WS,也就是3个CPU周期。*/
/***************************************************/
rcc_clk_init.ClockType = (RCC_CLOCKTYPE_SYSCLK |
RCC_CLOCKTYPE_HCLK |
RCC_CLOCKTYPE_PCLK1 |
RCC_CLOCKTYPE_PCLK2);
rcc_clk_init.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK;
rcc_clk_init.AHBCLKDivider = RCC_SYSCLK_DIV1;
rcc_clk_init.APB1CLKDivider = RCC_HCLK_DIV2;
rcc_clk_init.APB2CLKDivider = RCC_HCLK_DIV1;
ret = HAL_RCC_ClockConfig(&rcc_clk_init, FLASH_LATENCY_2);
sys_stm32_clock_init 函数中的RCC_ClkInitTypeDef 结构体配置内容:
第一个参数ClockType 配置表示我们要配置的是SYSCLK、HCLK、PCLK1 和PCLK 四个时钟。
第二个参数SYSCLKSource 配置选择系统时钟源为PLL。
第三个参数AHBCLKDivider 配置AHB 分频系数为1。
第四个参数APB1CLKDivider 配置APB1 分频系数为2。
第五个参数APB2CLKDivider 配置APB2 分频系数为1。
根据我们在mian 函数中调用sys_stm32_clock_init(RCC_PLL_MUL9)时设置的形参数值,我们可以计算出,PLL 时钟为PLLCLK = HSE * 9 = 8MHz * 9 = 72MHz。
同时我们选择系统时钟源为PLL,所以系统时钟SYSCLK=72MHz。AHB 分频系数为1,故频率为HCLK=SYSCLK/1=72MHz。APB1 分频系数为2,故其频率为PCLK1=HCLK/2=36MHz。
APB2 分频系数为1,故其频率为PCLK2=HCLK/1=72MHz。我们总结一下通过调用函数sys_stm32_clock_init(RCC_PLL_MUL9)之后的关键时钟频率值:
SYSCLK(系统时钟) =72MHz
PLL 主时钟 =72MHz
AHB 总线时钟(HCLK=SYSCLK/1)=72MHz
APB1 总线时钟(PCLK1=HCLK/2)=36MHz
APB2 总线时钟(PCLK2=HCLK/1)=72MHz
最后我们来看看函数HAL_RCC_ClockConfig 第二个入口参数FLatency 的含义,为了使FLASH 读写正确(因为72Mhz 的时钟比Flash 的操作速度24Mhz 要快得多,操作速度不匹配容易导致Flash 操作失败),所以需要设置延时时间。对于STM32F1 系列,FLASH 延迟配置参数值是通过下表11.2.1.1 来确定的,具体可以参考《STM32F10xxx 闪存编程参考手册》3 寄存器说明/3.1 闪存访问控制寄存器。
从上可以看出,我们设置值为FLASH_LATENCY_2,也就是2WS,也就是3 个CPU 周期,为什么呢?因为经过上面的配置之后,系统时钟频率达到了最高的72MHz,对应的就是两个等待状态,所以选择FLASH_LATENCY_2。
上一节我们讲解了时钟系统配置步骤。在配置好时钟系统之后,如果我们要使用某些外设,例如GPIO,ADC 等,我们还要使能这些外设时钟。这里大家必须注意,如果在使用外设之前没有使能外设时钟,这个外设是不可能正常运行的。STM32 的外设时钟使能是在RCC 相关寄存器中配置的。因为RCC 相关寄存器非常多,有兴趣的同学可以直接打开《STM32F10xxx 参考手册_V10(中文版).pdf》6.3 小节查看所有RCC 相关寄存器的配置。接下来我们来讲解通过STM32F1 的HAL 库使能外设时钟的方法。
在STM32F1 的HAL 库中,外设时钟使能操作都是在RCC 相关固件库文件头文件STM32F1xx_hal_rcc.h 定义的。大家打开STM32F1xx_hal_rcc.h 头文件可以看到文件中除了少数几个函数声明之外大部分都是宏定义标识符。外设时钟使能在HAL 库中都是通过宏定义标识符来实现的。首先,我们来看看GPIOA 的外设时钟使能宏定义标识符:
#define __HAL_RCC_GPIOA_CLK_ENABLE() do { \
__IO uint32_t tmpreg; \
SET_BIT(RCC->APB2ENR, RCC_APB2ENR_IOPAEN);\
tmpreg = READ_BIT(RCC->APB2ENR, RCC_APB2ENR_IOPAEN);\
UNUSED(tmpreg); \
} while(0U)
这段代码主要是定义了一个宏定义标识符__HAL_RCC_GPIOA_CLK_ENABLE(),它的核
心操作是通过下面这行代码实现的:
SET_BIT(RCC->APB2ENR, RCC_APB2ENR_IOPAEN);
这行代码的作用是,设置寄存器RCC->APB2ENR 的相关位为1,至于是哪个位,是由宏定义标识符RCC_APB2ENR_IOPAEN 的值决定的,而它的值为:
#define RCC_APB2ENR_IOPAEN_Pos (0U)
#define RCC_APB2ENR_IOPAEN_Msk (0x1UL << RCC_APB2ENR_IOPAEN_Pos)
#define RCC_APB2ENR_IOPAEN RCC_APB2ENR_IOPAEN_Msk
上面三行代码很容易计算出来RCC_APB2ENR_IOPAEN= (0x00000001<<2),因此上面代码的作用是设置寄存器RCC->APB2ENR 寄存器的位2 为1。我们可以从STM32F1 的参考手册中搜索APB2ENR 寄存器定义,位2 的作用是用来使用GPIOA 时钟。APB2ENR 寄存器的位2 描述如下:
位0 IOPAEN:IO 端A 时钟使能(I/O port A clock enable)
由软件置‘1’或清‘0’
0:IO 端口A 时钟关闭
1:IO 端口A 时钟开启
那么我们只需要在我们的用户程序中调用宏定义标识符就可以实现GPIOA 时钟使能。使用方法为:
__HAL_RCC_GPIOA_CLK_ENABLE(); /* 使能GPIOA时钟*/
对于其他外设,同样都是在STM32F1xx_hal_rcc.h 头文件中定义,大家只需要找到相关宏定义标识符即可,这里我们列出几个常用使能外设时钟的宏定义标识符使用方法:
__HAL_RCC_DMA1_CLK_ENABLE(); /* 使能DMA1时钟*/
__HAL_RCC_USART2_CLK_ENABLE(); /* 使能串口2时钟*/
__HAL_RCC_TIM1_CLK_ENABLE(); /* 使能TIM1时钟*/
我们使用外设的时候需要使能外设时钟,如果我们不需要使用某个外设,同样我们可以禁止某个外设时钟。禁止外设时钟使用方法和使能外设时钟非常类似,同样是头文件中定义的宏定义标识符。我们同样以GPIOA 为例,宏定义标识符为:
#define __HAL_RCC_GPIOA_CLK_DISABLE() (RCC->APB2ENR) &= ~ (RCC_APB2ENR_GPIOAEN)
同样,宏定义标识符__HAL_RCC_GPIOA_CLK_DISABLE()的作用是设置RCC->APB2ENR寄存器的位2 为0,也就是禁止GPIOA 时钟。具体使用方法我们这里就不做过多讲解,我们这里同样列出几个常用的禁止外设时钟的宏定义标识符使用方法:
__HAL_RCC_DMA1_CLK_DISABLE(); /* 禁止DMA1时钟*/
__HAL_RCC_USART2_CLK_DISABLE(); /* 禁止串口2时钟*/
__HAL_RCC_TIM1_CLK_DISABLE(); /* 禁止TIM1时钟*/
关于STM32F1 的外设时钟使能和禁止方法我们就给大家讲解到这里。
SYSTEM 文件夹里面的代码由正点原子提供,是STM32F1xx 系列的底层核心驱动函数,可以用在STM32F1xx 系列的各个型号上面,方便大家快速构建自己的工程。本章,我们将向大家介绍这些代码的由来及其功能,也希望大家可以灵活使用SYSTEM 文件夹提供的函数,来快速构建工程,并实际应用到自己的项目中去。
SYSTEM 文件夹下包含了delay、sys、usart 等三个文件夹。分别包含了delay.c、sys.c、usart.c及其头文件。这3 个c 文件提供了系统时钟设置、延时和串口1 调试功能,任何一款STM32F1都具备这几个基本外设,所以可以快速地将这些设置应用到任意一款STM32F1 产品上,通过这些驱动文件实现快速移植和辅助开发的效果。
sys 文件夹内包含了sys.c 和sys.h 两个文件,在sys.c 主要实现下面的几个函数,以及一些汇编函数。
/* 函数声明*/
void sys_nvic_set_vector_table(uint32_t baseaddr, uint32_t offset);
void sys_standby(void);
void sys_soft_reset(void);
uint8_t sys_stm32_clock_init(uint32_t plln);
/* 汇编函数*/
void sys_wfi_set(void);
void sys_intx_disable(void);
void sys_intx_enable(void);
void sys_msr_msp(uint32_t addr);
下面讲一下函数的功能,sys_nvic_set_vector_table 函数主要是设置中断向量表偏移地址,sys_standby 函数用于进入待机模式,sys_soft_reset 函数用于系统软复位,sys_stm32_clock_init函数是系统时钟初始化函数,在11.2.1 小节STM32F1 时钟系统配置章节已经有说明了,大家可以复习这部分知识点。
在sys.h 文件中只是对于sys.c 的函数进行声明。
delay 文件夹内包含了delay.c 和delay.h 两个文件,这两个文件用来实现系统的延时功能,其中包含7 个函数:
void delay_osschedlock(void);
void delay_osschedunlock(void);
void delay_ostimedly(uint32_t ticks);
void SysTick_Handler(void);
void delay_init(uint16_t sysclk);
void delay_us(uint32_t nus);
void delay_ms(uint16_t nms);
前面4 个函数,仅在支持操作系统(OS)的时候,需要用到,而后面3 个函数,则不论是否支持OS 都需要用到。
在介绍这些函数之前,我们先了解一下delay 延时的编程思想:CM3 内核处理器内部包含了一个SysTick (系统滴答定时器)定时器,SysTick 是一个24 位的向下递减的计数定时器,当计数值减到0 时,将从RELOAD 寄存器中自动重装载定时初值,开始新一轮计数。只要不把它在SysTick 控制及状态寄存器中的使能位清除,就永不停息。SysTick 在《STM32F10xxx 参考手册_V10(中文版).pdf》里面介绍的很简单,其详细介绍,请参阅《Cortex-M3 权威指南》第133 页。我们就是利用STM32 的内部SysTick 来实现延时的,这样既不占用中断,也不占用系统定时器。
这里我们将介绍的是正点原子提供的最新版本的延时函数,该版本的延时函数支持在任意操作系统(OS)下面使用,它可以和操作系统共用SysTick 定时器。
这里,我们以UCOSII 为例,介绍如何实现操作系统和我们的delay 函数共用SysTick 定时器。首先,我们简单介绍下UCOSII 的时钟:ucos 运行需要一个系统时钟节拍(类似“心跳”),而这个节拍是固定的(由OS_TICKS_PER_SEC 宏定义设置),比如要求5ms 一次(即可设置:OS_TICKS_PER_SEC=200),在STM32 上面,一般是由SysTick 来提供这个节拍,也就是SysTick要设置为5ms 中断一次,为ucos 提供时钟节拍,而且这个时钟一般是不能被打断的(否则就不准了)。
因为在ucos 下systick 不能再被随意更改,如果我们还想利用systick 来做delay_us 或者delay_ms 的延时,就必须想点办法了,这里我们利用的是时钟摘取法。以delay_us 为例,比如delay_us(50),在刚进入delay_us 的时候先计算好这段延时需要等待的systick 计数次数,这里为 50 * 72(假设系统时钟为72Mhz,在经过8 分频之后,systick 的频率等于1/8 系统时钟频率,那么systick 每增加1,就是1/9us),然后我们就一直统计systick 的计数变化,直到这个值变化了50*9,一旦检测到变化达到或者超过这个值,就说明延时50us 时间到了。这样,我们只是抓取SysTick 计数器的变化,并不需要修改SysTick 的任何状态,完全不影响SysTick 作为UCOS时钟节拍的功能,这就是实现delay 和操作系统共用SysTick 定时器的原理。
下面我们开始介绍这几个函数。
当需要delay_ms 和delay_us 支持操作系统(OS)的时候,我们需要用到3 个宏定义和4 个函数,宏定义及函数代码如下:
/*
* 当delay_us/delay_ms需要支持OS的时候需要三个与OS相关的宏定义和函数来支持
* 首先是3个宏定义:
* delay_osrunning :用于表示OS当前是否正在运行,以决定是否可以使用相关函数
* delay_ostickspersec:用于表示OS设定的时钟节拍,delay_init将根据这个参数来初始化
systick
* delay_osintnesting :用于表示OS中断嵌套级别,因为中断里面不可以调度,delay_ms使用该参数来决定如何运行
* 然后是3个函数:
* delay_osschedlock :用于锁定OS任务调度,禁止调度
* delay_osschedunlock:用于解锁OS任务调度,重新开启调度
* delay_ostimedly :用于OS延时,可以引起任务调度.
*
* 本例程仅作UCOSII和UCOSIII的支持,其他OS,请自行参考着移植
*/
/* 支持UCOSII */
#ifdef OS_CRITICAL_METHOD /* OS_CRITICAL_METHOD定义了,说明要支UCOSII */
#define delay_osrunning OSRunning /* OS是否运行标记,0,不运行;1,在运行*/
#define delay_ostickspersec OS_TICKS_PER_SEC /* OS时钟节拍,即每秒调度次数*/
#define delay_osintnesting OSIntNesting /* 中断嵌套级别,即中断嵌套次数*/
#endif
/* 支持UCOSIII */
#ifdef CPU_CFG_CRITICAL_METHOD /*CPU_CFG_CRITICAL_METHOD定义了,说明要支UCOSIII*/
#define delay_osrunning OSRunning /* OS是否运行标记,0,不运行;1,在运行*/
#define delay_ostickspersec OSCfg_TickRate_Hz /* OS时钟节拍,即每秒调度次数*/
#define delay_osintnesting OSIntNestingCtr /* 中断嵌套级别,即中断嵌套次数*/
#endif
/**
* @brief us级延时时,关闭任务调度(防止打断us级延迟)
* @param 无
* @retval 无
*/
static void delay_osschedlock(void)
{
#ifdef CPU_CFG_CRITICAL_METHOD /* 使用UCOSIII */
OS_ERR err;
OSSchedLock(&err); /* UCOSIII的方式,禁止调度,防止打断us延时*/
#else /* 否则UCOSII */
OSSchedLock(); /* UCOSII的方式,禁止调度,防止打断us延时*/
#endif
}
/**
* @brief us级延时时,恢复任务调度
* @param 无
* @retval 无
*/
static void delay_osschedunlock(void)
{
#ifdef CPU_CFG_CRITICAL_METHOD /* 使用UCOSIII */
OS_ERR err;
OSSchedUnlock(&err); /* UCOSIII的方式,恢复调度*/
#else /* 否则UCOSII */
OSSchedUnlock(); /* UCOSII的方式,恢复调度*/
#endif
}
/**
* @brief us级延时时,恢复任务调度
* @param ticks: 延时的节拍数
* @retval 无
*/
static void delay_ostimedly(uint32_t ticks)
{
#ifdef CPU_CFG_CRITICAL_METHOD
OS_ERR err;
OSTimeDly(ticks, OS_OPT_TIME_PERIODIC, &err); /* UCOSIII延时采用周期模式*/
#else
OSTimeDly(ticks); /* UCOSII延时*/
#endif
}
/**
* @brief systick中断服务函数,使用OS时用到
* @param ticks: 延时的节拍数
* @retval 无
*/
void SysTick_Handler(void)
{
if (delay_osrunning == 1) /* OS开始跑了,才执行正常的调度处理*/
{
OSIntEnter(); /* 进入中断*/
OSTimeTick(); /* 调用ucos的时钟服务程序*/
OSIntExit(); /* 触发任务切换软中断*/
}
HAL_IncTick();
}
#endif
以上代码,仅支持UCOSII 和UCOSIII,不过,对于其他OS 的支持,也只需要对以上代码进行简单修改即可实现。
支持OS 需要用到的三个宏定义(以UCOSII 为例)即:
#define delay_osrunning OSRunning /* OS是否运行标记,0,不运行;1,在运行*/
#define delay_ostickspersec OS_TICKS_PER_SEC/* OS时钟节拍,即每秒调度次数*/
#define delay_osintnesting OSIntNesting /* 中断嵌套级别,即中断嵌套次数*/
宏定义:delay_osrunning,用于标记OS 是否正在运行,当OS 已经开始运行时,该宏定义值为1,当OS 还未运行时,该宏定义值为0。
宏定义:delay_ostickspersec,用于表示OS 的时钟节拍,即OS 每秒钟任务调度次数。
宏定义:delay_osintnesting,用于表示OS 中断嵌套级别,即中断嵌套次数,每进入一个中断,该值加1,每退出一个中断,该值减1。
支持OS 需要用到的4 个函数,即:
函数:delay_osschedlock,用于delay_us 延时,作用是禁止OS 进行调度,以防打断us 级延时,导致延时时间不准。
函数:delay_osschedunlock,同样用于delay_us 延时,作用是在延时结束后恢复OS 的调度,继续正常的OS 任务调度。
函数:delay_ostimedly,调用OS 自带的延时函数,实现延时。该函数的参数为时钟节拍数。
函数:SysTick_Handler,则是systick 的中断服务函数,该函数为OS 提供时钟节拍,同时可以引起任务调度。
以上就是delay_ms 和delay_us 支持操作系统时,需要实现的3 个宏定义和4 个函数。
该函数用来初始化2 个重要参数:g_fac_us 以及g_fac_ms;同时把SysTick 的时钟源选择为外部时钟,如果需要支持操作系统(OS),只需要在sys.h 里面,设置SYS_SUPPORT_OS 宏的值为1 即可,然后,该函数会根据delay_ostickspersec 宏的设置,来配置SysTick 的中断时间,并开启SysTick 中断。具体代码如下:
/**
* @brief 初始化延迟函数
* @param sysclk: 系统时钟频率, 即CPU频率(HCLK)
* @retval 无
*/
void delay_init(uint16_t sysclk)
{
#if SYS_SUPPORT_OS /* 如果需要支持OS. */
uint32_t reload;
#endif
SysTick->CTRL = 0; /*清除Systick状态,以便下一步重设,hal库里面默认会设置成其他的值*/
/* SYSTICK使用内核时钟源8分频, 因systick的计数器最大值只有2^24 */
HAL_SYSTICK_CLKSourceConfig(SYSTICK_CLKSOURCE_HCLK_DIV8);
g_fac_us = sysclk / 8; /* 不论是否使用OS,g_fac_us都需要使用,作为1us的基础时基 72/8=9MHz 1us = 9/9000000*/
#if SYS_SUPPORT_OS /* 如果需要支持OS. */
reload = sysclk / 8; /* 每秒钟的计数次数单位为M */
reload *= 1000000/delay_ostickspersec;/* 根据delay_ostickspersec设定溢出时间*/
g_fac_ms = 1000 / delay_ostickspersec;/* 代表OS可以延时的最少单位*/
SysTick->CTRL |= 1 << 1; /* 开启SYSTICK中断*/
SysTick->LOAD = reload; /* 每1/delay_ostickspersec秒中断一次*/
SysTick->CTRL |= 1 << 0; /* 开启SYSTICK */
#endif
}
可以看到,delay_init 函数使用了条件编译,来选择不同的初始化过程,如果不使用OS 的时候,只是设置一下SysTick 的时钟源以及确定g_fac_us 值。而如果使用OS 的时候,则会进行一些不同的配置,这里的条件编译是根据SYS_SUPPORT_OS 这个宏来确定的,该宏在sys.h里面定义。
SysTick 是MDK 定义了的一个结构体(在core_m3.h 里面),里面包含CTRL、LOAD、VAL、CALIB 等4 个寄存器,SysTick->CTRL(地址:0xE000_E010)的各位定义如图12.1.2.1 所示:
SysTick->LOAD(地址:0xE000_E014)的定义如图12.1.2.2 所示,这里要注意这个的最大可重装值只有24 位:
SysTick->VAL(地址:0xE000_E018)的定义如图12.1.2.3 所示:
SysTick->CALIB(地址:0xE000_E01C)不常用,在这里我们也用不到,故不介绍了。
HAL_SYSTICK_CLKSourceConfig(SYSTICK_CLKSOURCE_HCLK_DIV8);这句代码把SysTick的时钟设置为内核时钟的1/8,这里需要注意的是:SysTick 的时钟源自HCLK,假设配置系统时钟为72MHZ,经过分频器8 分频后,那么SysTick 的时钟即为9Mhz,也就是SysTick 的计数器VAL 每减1,就代表时间过了1/9us。
在不使用OS 的时候:fac_us,为us 延时的基数,也就是延时1us,Systick 定时器需要走过的时钟周期数。
当使用OS 的时候,fac_us 还是us 延时的基数,不过这个值不会被写到SysTick->LOAD 寄存器来实现延时,而是通过时钟摘取的办法实现的(前面已经介绍了)。而g_fac_ms 则代表ucos自带的延时函数所能实现的最小延时时间(如delay_ostickspersec=200,那么g_fac_ms 就是5ms)。
该函数用来延时指定的us,其参数nus 为要延时的微秒数。该函数有使用OS 和不使用OS两个版本,这里我们首先介绍不使用OS 的时候,实现函数如下:
/**
* @brief 延时nus
* @param nus: 要延时的us数.
* @note 注意: nus的值,不要大于1864135us(最大值即2^24/g_fac_us @g_fac_us = 9)
* @retval 无
*/
void delay_us(uint32_t nus)
{
uint32_t temp;
SysTick->LOAD = nus * g_fac_us; /* 重装载寄存器赋值*/
SysTick->VAL = 0x00; /* 清空当前计数器的值*/
SysTick->CTRL |= 1 << 0 ; /* 开始倒数 使能位0滴答定时器开始计数*/
do//while循环死等的方式计时
{
temp = SysTick->CTRL;
} while ((temp & 0x01) &&
!(temp & (1 << 16))); /* CTRL.ENABLE位必须为1, 并等待时间到达*/
SysTick->CTRL &= ~(1 << 0) ; /* 关闭SYSTICK */
SysTick->VAL = 0X00; /* 清空计数器*/
}
不使用OS 的delay_us 函数,首先结合需要延时的时间nus 与us 延时的基数,得到SysTick计数次数,赋值给SysTick 重装载数值寄存器。对计数器进行清空操作SysTick->VAL=0x00。然后对SysTick->CTRL 寄存器的位0 赋1 操作,即使能SysTick 定时器。利用当重装载寄存器的值递减到0 的时候,清除SysTick->CTRL 寄存器COUNTFLAG 标志这个特性作为判断的条件,等待计数值变为零,即计时时间到了。
对于使用OS 的时候,delay_us 的实现函数是使用的时钟摘取法,只不过使用delay_osschedlock 和delay_osschedunlock 两个函数,用于调度上锁和解锁,这是为了防止OS 在delay_us 的时候打断延时,可能导致的延时不准,所以我们利用这两个函数来实现免打断,从而保证延时精度。
再来看看使用OS 的时候,delay_us 的实现函数如下:
/**
* @brief 延时nus
* @param nus: 要延时的us数.
* @note nus取值范围: 0 ~ 477218588(最大值即2^32 / g_fac_us @g_fac_us = 9)
* @retval 无
*/
void delay_us(uint32_t nus)
{
uint32_t ticks;
uint32_t told, tnow, tcnt = 0;
uint32_t reload;
reload = SysTick->LOAD; /* LOAD的值*/
ticks = nus * g_fac_us; /* 需要的节拍数*/
delay_osschedlock(); /* 阻止OS调度,防止打断us延时*/
told = SysTick->VAL; /* 刚进入时的计数器值*/
while (1)
{
tnow = SysTick->VAL;
if (tnow != told)
{
if (tnow < told)
{
tcnt += told - tnow; /* 这里注意一下SYSTICK是一个递减的计数器就可以了*/
}
else
{
tcnt += reload - tnow + told;
}
told = tnow;
if (tcnt >= ticks) break; /* 时间超过/等于要延迟的时间,则退出. */
}
};
delay_osschedunlock(); /* 恢复OS调度*/
}
这里就正是利用了我们前面提到的时钟摘取法,ticks 是延时nus 需要等待的SysTick 计数次数(也就是延时时间),told 用于记录最近一次的SysTick->VAL 值,然后tnow 则是当前的SysTick->VAL 值,通过他们的对比累加,实现SysTick 计数次数的统计,统计值存放在tcnt 里面,然后通过对比tcnt 和ticks,来判断延时是否到达,从而达到不修改SysTick 实现nus 的延时,从而可以和OS 共用一个SysTick。
上面的delay_osschedlock 和delay_osschedunlock 是OS 提供的两个函数,用于调度上锁和解锁,这里为了防止OS 在delay_us 的时候打断延时,可能导致的延时不准,所以我们利用这两个函数来实现免打断,从而保证延时精度!
该函数是用来延时指定的ms 的,其参数nms 为要延时的毫秒数。该函数有使用OS 和不使用OS 两个版本,这里我们分别介绍,首先是不使用OS 的时候,实现函数如下:
/**
* @brief 延时nms
* @param nms: 要延时的ms数(0< nms <= 65535)
* @retval 无
*/
void delay_ms(uint16_t nms)
{
/*这里用1000,是考虑到可能有超频应用,如128Mhz时,delay_us最大只能延时1048576us左右*/
uint32_t repeat = nms / 1000;
uint32_t remain = nms % 1000;
while (repeat)
{
delay_us(1000 * 1000); /* 利用delay_us 实现1000ms 延时*/
repeat--;
}
if (remain)
{
delay_us(remain * 1000); /* 利用delay_us, 把尾数延时(remain ms)给做了*/
}
}
该函数其实就是多次调用delay_us 函数,来实现毫秒级延时的。我们做了一些处理,使得调用delay_us 函数的次数减少,这样时间会更加精准。再来看看使用OS 的时候,delay_ms 的实现函数如下:
/**
* @brief 延时nms
* @param nms: 要延时的ms数(0< nms <= 65535)
* @retval 无
*/
void delay_ms(uint16_t nms)
{
/* 如果OS已经在跑了,并且不是在中断里面(中断里面不能任务调度) */
if (delay_osrunning && delay_osintnesting == 0)
{
if (nms >= g_fac_ms) /* 延时的时间大于OS的最少时间周期*/
{
delay_ostimedly(nms / g_fac_ms); /* OS延时*/
}
nms %= g_fac_ms; /* OS已经无法提供这么小的延时了,采用普通方式延时*/
}
delay_us((uint32_t)(nms * 1000)); /* 普通方式延时*/
}
该函数中,delay_osrunning 是OS 正在运行的标志,delay_osintnesting 则是OS 中断嵌套次数,必须delay_osrunning 为真,且delay_osintnesting 为0 的时候,才可以调用OS 自带的延时函数进行延时(可以进行任务调度),delay_ostimedly 函数就是利用OS 自带的延时函数,实现任务级延时的,其参数代表延时的时钟节拍数(假设delay_ostickspersec=200 ,那么delay_ostimedly(1),就代表延时5ms)。
当OS 还未运行的时候,我们的delay_ms 就是直接由delay_us 实现的,OS 下的delay_us可以实现很长的延时(达到53 秒)而不溢出!,所以放心的使用delay_us 来实现delay_ms,不过由于delay_us 的时候,任务调度被上锁了,所以还是建议不要用delay_us 来延时很长的时间,否则影响整个系统的性能。
当OS 运行的时候,我们的delay_ms 函数将先判断延时时长是否大于等于1 个OS 时钟节拍(g_fac_ms),当大于这个值的时候,我们就通过调用OS 的延时函数来实现(此时任务可以调度),不足1 个时钟节拍的时候,直接调用delay_us 函数实现(此时任务无法调度)。
前面我们在7.4.2 章节介绍STM32F1xx_hal.c 文件时,已经讲解过Systick 实现延时相关函数。实际上,HAL 库提供的延时函数,只能实现简单的毫秒级别延时,没有实现us 级别延时。
我们看看HAL 库的HAL_Delay 函数原定义:
__weak void HAL_Delay(uint32_t Delay)
{
uint32_t tickstart = HAL_GetTick();
uint32_t wait = Delay;
/* Add a freq to guarantee minimum wait */
if (wait < HAL_MAX_DELAY)
{
wait += (uint32_t)(uwTickFreq);
}
while ((HAL_GetTick() - tickstart) < wait)
{
}
}
HAL 库实现延时功能非常简单,首先定义了一个32 位全局变量uwTick,在Systick 中断服务函数SysTick_Handler 中通过调用HAL_IncTick 实现uwTick 值不断增加,也就是每隔1ms增加uwTickFreq,而uwTickFreq 默认是1。而HAL_Delay 函数在进入函数之后先记录当前uwTick 的值,然后不断在循环中读取uwTick 当前值,进行减运算,得出的就是延时的毫秒数,整个逻辑非常简单也非常清晰。
但是,HAL 库的延时函数在中断服务函数中使用HAL_Delay 会引起混乱(虽然一般禁止在中断中使用延时函数),因为它是通过中断方式实现,而Systick 的中断优先级是最低的,所以在中断中运行HAL_Delay 会导致延时出现严重误差。所以教程中推荐大家使用正点原子提供的延时函数库,但我们在第七章介绍HAL 库时,也提到过,不使用操作系统(OS)的情况下,我们禁用了Systick 中断,会导致部分HAL 库函数无法超时退出,读者需要特别留意。
HAL 库的ms 级别的延时函数__weak void HAL_Delay(uint32_t Delay);它是弱定义函数,所以用户可以自己重新定义该函数。例如:我们在deley.c 文件可以这样重新定义该函数:
/**
* @brief HAL库延时函数重定义
* @param Delay 要延时的毫秒数
* @retval None
*/
void HAL_Delay(uint32_t Delay)
{
delay_ms(Delay);
}
该文件夹下面有usart.c 和usart.h 两个文件。在我们的工程使用串口1 和串口调试助手来实现调试功能,可以把单片机的信息通过串口助手显示到电脑屏幕。串口相关知识,我们将在第十七章讲解串口实验的时候给大家详细讲解。本节只给大家讲解printf 函数支持相关的知识。
在我们学习C 语言时,可以通过printf 函数把需要的参数显示到屏幕上,可以做一些简单的调试信息,但对于单片机来说,如果想实现类似的功能来用printf 辅助调试的话,是否有办法呢?有,这就是这一节要讲的内容。
标准库下的printf 为调试属性的函数,如果直接使用,会使单片机进入半主机模式(semihosting),这是一种调试模式,直接下载代码后出现程序无法运行,但是在连接调试器进行Debug 时程序反而能正常工作的情况。半主机是ARM 目标的一种机制,用于将输入/输出请求从应用程序代码通信到运行调试器的主机。例如,此机制可用于允许C 库中的函数(如printf()和scanf())使用主机的屏幕和键盘,而不是在目标系统上设置屏幕和键盘。这很有用,因为开发硬件通常不具有最终系统的所有输入和输出设备,如屏幕、键盘等。半主机是通过一组定义好的软件指令(如SVC)SVC 指令(以前称为SWI 指令)来实现的,这些指令通过程序控制生成异常。应用程序调用相应的半主机调用,然后调试代理处理该异常。调试代理(这里的调试代理是仿真器)提供与主机之间的必需通信。也就是说使用半主机模式必须使用仿真器调试。
![在这里插入图片描述
如果想在独立环境下运行调试功能的函数,我们这里是printf,printf 对字符ch 处理后写入
文件f,最后使用fputc 将文件f 输出到显示设备。对于PC 端的设备,fputc 通过复杂的源码,最终把字符显示到屏幕上。那我们需要做的,就是把printf 调用的fputc 函数重新实现,重定向fputc 的输出,同时避免进入半主模式。
要避免半主机模式,现在主要有两种方式:一是使用MicroLib,即微库;另一种方法是确
保ARM 应用程序中没有链接MicroLib 的半主机相关函数,我们要取消ARM 的半主机工作模
式,这可以通过代码实现。
先说微库,ARM 的C 微库MicroLib 是为嵌入式设备开发的一套类似于标准C 接口函数的
精简代码库,用于替代默认C 库,是专门针对专业嵌入式应用开发而设计的,特别适合那些对存储空间有特别要求的嵌入式应用程序,这些程序一般不在操作系统下运行。使用微库编写程序要注意其与默认C 库之间存在的一些差异,如main()函数不能声明带参数,也无须返回;不支持stdio,除了无缓冲的stdin、stdout 和syderr;微库不支持操作系统函数;微库不支持可选的单或两区存储模式;微库只提供分离的堆和栈两区存储模式等等,它裁减了很多函数,而且还有很多东西不支持。如果原来用标准库可以跑,选择MicroLib 后却突然不行了,是很常见的。
与标准的C 库不一样,微库重新实现了printf,使用微库的情况下就不会进入半主机模式了。
Keil 下使用微库的方法很简单,在“Target”下勾选“Use MicroLib”即可。
在keil5 中,不管是否使用半主机模式,使用printf,scanf,fopen,fread 等都需要自己填充底层
函数,以printf 为例,需要补充定义fputc,启用微库后,在我们初始化和使能串口1 之后,我们只需要重新实现fputc 的功能即可将每个传给fputc 函数的字符ch 重定向到串口1,如果这时接上串口调试助手的话,可以看到串口的数据。实现的代码如下:
#define USART_UX USART1
/* 重定义fputc函数, printf函数最终会通过调用fputc输出字符串到串口*/
int fputc(int ch, FILE *f)
{
while ((USART_UX->SR & 0X40) == 0); /* 等待上一个字符发送完成*/
USART_UX->DR = (uint8_t)ch; /* 将要发送的字符ch 写入到DR寄存器*/
return ch;
}
上面说到了微库的一些限制,使用时注意某些函数与标准库的区别就不会影响到我们代码
的正常功能。
如果不想使用微库,那就要用到我们提到的第二种方法:
取消ARM 的半主机工作模式;只需在代码中添加不使用半主机的声明即可,对于AC5 和AC6 编译器版本,声明半主机的语法不同,为了同时兼容这两种语法,我们在利用编译器自带的宏__ARMCC_VERSION判定编译器版本,并根据版本不同选择不同的语法声明不使用半主机模式,具体代码如下:
#if (__ARMCC_VERSION >= 6010050) /* 使用AC6编译器时*/
__asm(".global __use_no_semihosting\n\t"); /* 声明不使用半主机模式*/
__asm(".global __ARM_use_no_argv \n\t"); /* AC6下需要声明main函数为无参数格式,否则部分例程可能出现半主机模式*/
#else
/* 使用AC5编译器时, 要在这里定义__FILE 和不使用半主机模式*/
#pragma import(__use_no_semihosting)
/* 解决HAL库使用时, 某些情况可能报错的bug */
struct __FILE
{
int handle;
};
#endif
使用的上面的代码,Keil 的编译器就不会把标准库的这部分函数链接到我们的代码里。
如果用到原来半主机模式下的调试函数,需要重新实现它的一些依赖函数接口,对于printf
函数需要实现的接口,我们的代码中将它们实现如下:
/* 不使用半主机模式,至少需要重定义_ttywrch\_sys_exit\_sys_command_string函数,以同时兼容AC6和AC5模式*/
int _ttywrch(int ch)
{
ch = ch;
return ch;
}
/* 定义_sys_exit()以避免使用半主机模式*/
void _sys_exit(int x)
{
x = x;
}
char *_sys_command_string(char *cmd, int len)
{
return NULL;
}
fputc 的重定向和之前一样,重定向到串口1 即可,如果硬件资源允许,读者有特殊需求,
也可以重定向到LCD 或者其它串口。
/* 重定义fputc函数, printf函数最终会通过调用fputc输出字符串到串口*/
int fputc(int ch, FILE *f)
{ //位6
while ((USART_UX->SR & 0X40) == 0); /* 等待上一个字符发送完成*/
USART_UX->DR = (uint8_t)ch; /* 将要发送的字符ch 写入到DR寄存器*/
return ch;
}
本章,我们将介绍如何使用STM32F103 的SPI 功能,并实现对外部NOR FLASH 的读写并把结果显示在TFTLCD 模块上。
SPI 是英语Serial Peripheral interface 缩写,顾名思义就是串行外围设备接口。SPI 通信协议是Motorola 公司首先在其MC68HCXX 系列处理器上定义的。SPI 接口是一种高速的全双工同步的通信总线,已经广泛应用在众多MCU、存储芯片、AD 转换器和LCD 之间。大部分STM32 是有有3 个SPI 接口,本实验使用的是SPI2。
我们先看SPI 的结构框图,了解它的大致功能,如图36.1.1.1.1 所示。
围绕框图,我们展开介绍一下SPI 的引脚信息、工作原理以及传输方式,把SPI 的4 种工作方式放在后面讲解。
SPI 的引脚信息:
MISO(Master In / Slave Out)主设备数据输入,从设备数据输出。
MOSI(Master Out / Slave In)主设备数据输出,从设备数据输入。
SCLK(Serial Clock)时钟信号,由主设备产生。
CS(Chip Select)从设备片选信号,由主设备产生。
SPI 的工作原理:在主机和从机都有一个串行移位寄存器,主机通过向它的SPI 串行寄存器写入一个字节来发起一次传输。串行移位寄存器通过MOSI 信号线将字节传送给从机,从机也将自己的串行移位寄存器中的内容通过MISO 信号线返回给主机。这样,两个移位寄存器中的内容就被交换。外设的写操作和读操作是同步完成的。如果只是进行写操作,主机只需忽略接收到的字节。反之,若主机要读取从机的一个字节,就必须发送一个空字节引发从机传输。
SPI 的传输方式:SPI 总线具有三种传输方式:全双工、单工以及半双工传输方式。
全双工通信,就是在任何时刻,主机与从机之间都可以同时进行数据的发送和接收。
单工通信,就是在同一时刻,只有一个传输的方向,发送或者是接收。
半双工通信,就是在同一时刻,只能为一个方向传输数据。
STM32 要与具有SPI 接口的器件进行通信,就必须遵循SPI 的通信协议。每一种通信协议都有各自的读写数据时序,当然SPI 也不例外。SPI 通信协议就具备4 种工作模式,在讲这4 种工作模式前,首先先知道两个单词CPOL 和CPHA。
CPOL,详称Clock Polarity,就是时钟极性,当主从机没有数据传输的时候即空闲状态,SCL 线的电平状态,假如空闲状态是高电平,CPOL=1;若空闲状态时低电平,那么CPOL = 0。
CPHA,详称Clock Phase,就是时钟相位。在这里先科普一下数据传输的常识:同步通信时,数据的变化和采样都是在时钟边沿上进行的,每一个时钟周期都会有上升沿和下降沿两个边沿,那么数据的变化和采样就分别安排在两个不同的边沿,由于数据在产生和到它稳定是需要一定的时间,那么假如我们在第1 个边沿信号把数据输出了,从机只能从第2 个边沿信号去采样这个数据。
CPHA 实质指的是数据的采样时刻,CPHA = 0 的情况就表示数据的采样是从第1 个边沿信号上即奇数边沿,具体是上升沿还是下降沿的问题,是由CPOL 决定的。这里就存在一个问题:
当开始传输第一个bit 的时候,第1 个时钟边沿就采集该数据了,那数据是什么时候输出来的呢?那么就有两种情况:一是CS 使能的边沿,二是上一帧数据的最后一个时钟沿。
CPHA = 1 的情况就是表示数据采样是从第2 个边沿即偶数边沿,它的边沿极性要注意一点,不是和上面CPHA=0 一样的边沿情况。前面的是奇数边沿采样数据,从SCL 空闲状态的直接跳变,空闲状态是高电平,那么它就是下降沿,反之就是上升沿。由于CPHA=1 是偶数边沿采样,所以需要根据偶数边沿判断,假如第一个边沿即奇数边沿是下降沿,那么偶数边沿的边沿极性就是上升沿。不理解的,可以看一下下面4 种SPI 工作模式的图。
由于CPOL 和CPHA 都有两种不同状态,所以SPI 分成了4 种模式。我们在开发的时候,使用比较多的是模式0 和模式3。下面请看表36.1.1.2.1 SPI 工作模式表。
SPI 工作模式 | CPOL | CPHA | SCL 空闲状态 | 采样边沿 | 采样时刻 |
---|---|---|---|---|---|
0 | 0 | 0 | 低电平 | 上升沿 | 奇数边沿 |
1 | 0 | 1 | 低电平 | 下降沿 | 偶数边沿 |
2 | 1 | 0 | 高电平 | 下降沿 | 奇数边沿 |
3 | 1 | 1 | 高电平 | 上升沿 | 偶数边沿 |
下面分别对SPI 的4 种工作模式进行分析:
我们分析一下CPOL=0&&CPHA=0 的时序,图36.1.1.2.1 就是串行时钟的奇数边沿上升沿采样的情况,首先由于配置了CPOL=0,可以看到当数据未发送或者发送完毕,SCL 的状态是低电平,再者CPHA=0 即是奇数边沿采集。所以传输的数据会在奇数边沿上升沿被采集,MOSI和MISO 数据的有效信号需要在SCK 奇数边沿保持稳定且被采样,在非采样时刻,MOSI 和MISO 的有效信号才发生变化。
现在分析一下CPOL=0&CPHA=1 的时序,图36.1.1.2.2 是串行时钟的偶数边沿下降沿采样的情况。由于CPOL=0,所以SCL 的空闲状态依然是低电平,CPHA=1 数据就从偶数边沿采样,至于是上升沿还是下降沿,从上图就可以知道,是下降沿。这里有一个误区,空闲状态是低电平的情况下,不是应该上升沿吗,为什么这里是下降沿?首先我们先明确这里是偶数边沿采样,那么看图就很清晰,SCL 低电平空闲状态下,上升沿是在奇数边沿上,下降沿是在偶数边沿上。
图36.1.1.2.3 这种情况和第一种情况相似,只是这里是CPOL=1,即SCL 空闲状态为高电平,在CPHA=0,奇数边沿采样的情况下,数据在奇数边沿下降沿要保持稳定并等待采样。
图36.1.1.2.4 是CPOL=1&&CPHA=1 的情形,可以看到未发送数据和发送数据完毕,SCL的状态是高电平,奇数边沿的边沿极性是上升沿,偶数边沿的边沿极性是下降沿。因为CPHA=1,所以数据在偶数边沿上升沿被采样。在奇数边沿的时候MOSI 和MISO 会发生变化,在偶数边沿时候是稳定的。
在这里我们简单介绍一下本实验用到的寄存器。
⚫ SPI 控制寄存器1(SPI_CR1)
SPI 控制寄存器1描述如图36.1.1.3.1 所示:
该寄存器控制着SPI 很多相关信息,包括主设备模式选择,传输方向,数据格式,时钟极性、时钟相位和使能等。下面讲解一下本实验配置的位:
⚫ SPI 状态寄存器(SPI_SR)
SPI 状态寄存器描述如图36.1.1.3.2 所示:
该寄存器是查询当前SPI 的状态的,我们在实验中用到的是TXE 位和RXNE 位,即发送完成和接收完成是否的标记。
⚫ SPI 数据寄存器(SPI_DR)
SPI 数据寄存器描述如图36.1.4.3 所示:
该寄存器是SPI 数据寄存器,是一个双寄存器,包括了发送缓存和接收缓存。当向该寄存器写数据的时候,SPI 就会自动发送,当收到数据的时候,也是存在该寄存器内。
SPI 在HAL 库中的驱动代码在stm32f1xx_hal_spi.c 文件(及其头文件)中。
1. HAL_SPI_Init 函数
SPI 的初始化函数,其声明如下:
HAL_StatusTypeDef HAL_SPI_Init(SPI_HandleTypeDef *hspi);
⚫ 函数描述:
用于初始化SPI。
⚫ 函数形参:
形参1 是SPI_HandleTypeDef 结构体类型指针变量,其定义如下:
typedef struct __SPI_HandleTypeDef
{
SPI_TypeDef *Instance; /* SPI寄存器基地址*/
SPI_InitTypeDef Init; /* SPI通信参数*/
uint8_t *pTxBuffPtr; /* SPI的发送缓存*/
uint16_t TxXferSize; /* SPI的发送数据大小*/
__IO uint16_t TxXferCount; /* SPI发送端计数器*/
uint8_t *pRxBuffPtr; /* SPI的接收缓存*/
uint16_t RxXferSize; /* SPI的接收数据大小*/
__IO uint16_t RxXferCount; /* SPI接收端计数器*/
void (*RxISR)(struct __SPI_HandleTypeDef *hspi); /* SPI的接收端中断服务函数*/
void (*TxISR)(struct __SPI_HandleTypeDef *hspi); /* SPI的发送端中断服务函数*/
DMA_HandleTypeDef *hdmatx; /* SPI发送参数设置(DMA) */
DMA_HandleTypeDef *hdmarx; /* SPI接收参数设置(DMA) */
HAL_LockTypeDef Lock; /* SPI锁对象*/
__IO HAL_SPI_StateTypeDef State; /* SPI传输状态*/
__IO uint32_t ErrorCode; /* SPI操作错误代码*/
} SPI_HandleTypeDef;
我们这里主要讲解第二个成员变量Init,它是SPI_InitTypeDef 结构体类型,该结构体定义如下:
typedef struct
{
uint32_t Mode; /* 模式:主:SPI_MODE_MASTER 从:SPI_MODE_SLAVE */
uint32_t Direction; /* 方向:只接收模式 单线双向通信数据模式 全双工*/
uint32_t DataSize; /* 数据帧格式:8位/16位*/
uint32_t CLKPolarity; /* 时钟极性CPOL 高/低电平*/
uint32_t CLKPhase; /* 时钟相位奇/偶数边沿采集*/
uint32_t NSS; /* SS信号由硬件(NSS)管脚控制还是软件控制*/
uint32_t BaudRatePrescaler; /* 设置SPI波特率预分频值*/
uint32_t FirstBit; /* 起始位是MSB还是LSB */
uint32_t TIMode; /* 帧格式SPI motorola模式还是TI模式*/
uint32_t CRCCalculation; /* 硬件CRC是否使能*/
uint32_t CRCPolynomial; /* 设置CRC多项式*/
} SPI_InitTypeDef;
⚫ 函数返回值:
HAL_StatusTypeDef 枚举类型的值。
使用SPI 传输数据的配置步骤
1)SPI 参数初始化(工作模式、数据时钟极性、时钟相位等)。
HAL 库通过调用SPI 初始化函数HAL_SPI_Init 完成对SPI 参数初始化,详见例程源码。
注意:该函数会调用:HAL_SPI_MspInit 函数来完成对SPI 底层的初始化,包括:SPI 及GPIO 时钟使能、GPIO 模式设置等。
2)使能SPI 时钟和配置相关引脚的复用功能。
本实验用到SPI2,使用PB13、PB14 和PB15 作为SPI_SCK、SPI_MISO 和SPI_MOSI,因此需要先使能SPI2 和GPIOB 时钟。参考代码如下:
__HAL_RCC_SPI2_CLK_ENABLE();
__HAL_RCC_GPIOB_CLK_ENABLE();
IO 口复用功能是通过函数HAL_GPIO_Init 来配置的。
3)使能SPI
通过__HAL_SPI_ENABLE 函数使能SPI,便可进行数据传输。
4)SPI 传输数据
通过HAL_SPI_Transmit 函数进行发送数据。
通过HAL_SPI_Receive 函数进行接收数据。
也可以通过HAL_SPI_TransmitReceive 函数进行发送与接收操作。
5)设置SPI 传输速度
SPI 初始化结构体SPI_InitTypeDef 有一个成员变量是BaudRatePrescaler,该成员变量用来设置SPI 的预分频系数,从而决定了SPI 的传输速度。但是HAL 库并没有提供单独的SPI 分频系数修改函数,如果我们需要在程序中偶尔修改速度,那么我们就要通过设置SPI_CR1 寄存器来修改,具体实现方法请参考后面软件设计小节相关函数。
FLASH 是常见的用于存储数据的半导体器件,它具有容量大、可重复擦写、按“扇区/块”擦除、掉电后数据可继续保存的特性。
常见的FLASH 主要有NOR FLASH 和NAND FLASH 两种类型,它们的特性如表36.1.2.1.1 所示。NOR 和NAND 是两种数字门电路,可以简单地认为Flash 内部存储单元使用哪种门作存储单元就是哪类型的FLASH。U 盘,SSD,eMMC 等为NAND 型,而NOR FLASH 则根据设计需要灵活应用于各类PCB 上,如BIOS,手机等。
特性 | NOR FLASH | NAND FLASH |
---|---|---|
容量 | 较小 | 很大 |
同容量存储器成本 | 较贵 | 较便宜 |
擦除单元 | 以“扇区/块”擦除 | 以“扇区/块”擦除 |
读写单元 | 可以基于字节读写 | 必须以“块”为单位读写 |
读取速度 | 较高 | 较低 |
写入速度 | 较低 | 较高 |
集成度 | 较低 | 较高 |
介质类型 | 随机存储 | 连续存储 |
地址线和数据线 | 独立分开 | 共用 |
坏块 | 较少 | 较多 |
是否支持XIP | 支持 | 不支持 |
NOR 与NAND 在数据写入前都需要有擦除操作。
SLASH具有一个物理特性:只能写0,不能写1,写1靠擦除。
擦除操作的最小单位为“扇区/块”,这意味着有时候即使只写一字节的数据,则这个“扇区/块”上之前的数据都可能会被擦除。
https://blog.csdn.net/ffdia/article/details/87437872
NOR 的地址线和数据线分开,它可以按“字节”读写数据,符合CPU 的指令译码执行要求,所以假如NOR 上存储了代码指令,CPU 给NOR 一个地址,NOR 就能向CPU 返回一个数据让CPU 执行,中间不需要额外的处理操作,这体现于表35.1.2.1.1 中的支持XIP 特性(eXecute In Place)。因此可以用NOR FLASH 直接作为嵌入式MCU 的程序存储空间。
NAND 的数据和地址线共用,只能按“块”来读写数据,假如NAND 上存储了代码指令,CPU 给NAND 地址后,它无法直接返回该地址的数据,所以不符合指令译码要求。
若代码存储在NAND 上,可以把它先加载到RAM 存储器上,再由CPU 执行。所以在功能上可以认为NOR 是一种断电后数据不丢失的RAM,但它的擦除单位与RAM 有区别,且读写速度比RAM 要慢得多。
FLASH 也有对应的缺点,我们在使用过程中需要尽量去规避这些问题:一是FLASH 的使用寿命,另一个是可能的位反转。
使用寿命体现在:读写上是FLASH 的擦除次数都是有限的(NOR FLASH 普遍是10 万次左右),当它的使用接近寿命的时候,可能会出现写操作失败。由于NAND 通常是整块擦写,块内有一位失效整个块就会失效,这被称为坏块。使用NAND Flash 最好通过算法扫描介质找出坏块并标记为不可用,因为坏块上的数据是不准确的。
位反转是数据位写入时为1,但经过一定时间的环境变化后可能实际变为0 的情况,反之亦然。位反转的原因很多,可能是器件特性也可能与环境、干扰有关,由于位反转的的问题可能存在,所以FLASH 存储器需要“探测/错误更正(EDC/ECC)”算法来确保数据的正确性。
FLASH 芯片有很多种芯片型号,在我们的norflash.h 头文件中有定义芯片ID 的宏定义,对应的就是不同型号的NOR FLASH 芯片,比如有:W25Q128、BY25Q128、NM25Q128,它们是来自不同的厂商的同种规格的NOR FLASH 芯片,内存空间都是128M 字,即16M 字节。它们的很多参数、操作都是一样的,所以我们的实验都是兼容它们的。
由于这么多的芯片,我们就不一一进行介绍了,就拿其中一款型号进行介绍即可,其他的型号都是类似的。
下面我们以NM25Q128 为例,认识一下具体的NOR Flash 的特性。
NM25Q128 是一款大容量SPI FLASH 产品,其容量为16M。它将16M 字节的容量分为256个块(Block),每个块大小为64K 字节,每个块又分为16 个扇区(Sector),每一个扇区16 页,每页256 个字节,即每个扇区4K 个字节。
NM25Q128 的最小擦除单位为一个扇区,也就是每次必须擦除4K 个字节。这样我们需要给NM25Q128 开辟一个至少4K的缓存区,这样对SRAM要求比较高,要求芯片必须有4K 以上SRAM 才能很好的操作。
NM25Q128 的擦写周期多达10W 次,具有20 年的数据保存期限,支持电压为2.7~3.6V,NM25Q128 支持标准的SPI,还支持双输出/四输出的SPI,最大SPI 时钟可以到104Mhz(双输出时相当于208Mhz,四输出时相当于416Mhz)。
下面我们看一下NM25Q128 芯片的管脚图,如图36.1.2.1.2 所示。
芯片引脚连接如下:
STM32F103 通过SPI 总线连接到NM25Q128 对应的引脚即可启动数据传输。
前面对于NM25Q128 的介绍中也提及其存储的体系,NM25Q128 有写入、读取还有擦除的功能,下面就对这三种操作的时序进行分析,在后面通过代码的形式驱动它。
下面先让我们看一下读操作时序,如图36.1.2.1 所示:
从上图可知读数据指令是03H,可以读出一个字节或者多个字节。发起读操作时,先把CS片选管脚拉低,然后通过MOSI 引脚把03H 发送芯片,之后再发送要读取的24 位地址,这些数据在CLK 上升沿时采样。芯片接收完24 位地址之后,就会把相对应地址的数据在CLK 引脚下降沿从MISO 引脚发送出去。从图中可以看出只要CLK 一直在工作,那么通过一条读指令就可以把整个芯片存储区的数据读出来。当主机把CS 引脚拉高,数据传输停止。
接着我们看一下写时序,这里我们先看页写时序,如图36.1.2.2.2 所示:
在发送页写指令之前,需要先发送写使能指令。然后主机拉低CS 引脚,然后通过MOSI引脚把02H 发送到芯片,接着发送24 位地址,最后你就可以发送你需要写的字节数据到芯片。
完成数据写入之后,需要拉高CS 引脚,停止数据传输。
下面介绍一下扇区擦除时序,如图36.1.2.2.3 所示:
扇区擦除指的是将一个扇区擦除,通过前面的介绍也知道,NM25Q128 的扇区大小是4K字节。擦除扇区后,扇区的位全置1,即扇区字节为FFh。同样的,在执行扇区擦除之前,需要先执行写使能指令。这里需要注意的是当前SPI 总线的状态,假如总线状态是BUSY,那么这个扇区擦除是无效的,所以在拉低CS 引脚准备发送数据前,需要先要确定SPI 总线的状态,这就需要执行读状态寄存器指令,读取状态寄存器的BUSY 位,需要等待BUSY 位为0,才可以执行擦除工作。
接着按时序图分析,主机先拉低CS 引脚,然后通过MOSI 引脚发送指令代码20h 到芯片,然后接着把24 位扇区地址发送到芯片,然后需要拉高CS 引脚,通过读取寄存器状态等待扇区擦除操作完成。
此外还有对整个芯片进行擦除的操作,时序比扇区擦除更加简单,不用发送24bit 地址,只需要发送指令代码C7h 到芯片即可实现芯片的擦除。
在NM25Q128 手册中还有许多种方式的读/写/擦除操作,我们这里只分析本实验用到的,其他大家可以参考NM25Q128 手册。
1. 例程功能
通过KEY1 按键来控制norflash 的写入,通过按键KEY0 来控制norflash 的读取。并在LCD模块上显示相关信息。我们还可以通过USMART 控制读取norflash 的ID、擦除某个扇区或整片擦除。LED0 闪烁用于提示程序正在运行。
2. 硬件资源
1)LED 灯
LED0 – PB5
2)独立按键
KEY0 – PE4
KEY1 – PE3
3)NOR FLASH NM25Q128
4)正点原子2.8/3.5/4.3/7/10 寸TFTLCD 模块(仅限MCU 屏,16 位8080 并口驱动)
5)串口1(PA9/PA10 连接在板载USB 转串口芯片CH340 上面)(USMART 使用)
3. 原理图
我们主要来看看norflash 和开发板的连接,如下图所示:
通过上图可知,NM25Q128 的CS、SCK、MISO 和MOSI 分别连接在PB12、PB13、PB14和PB15 上。本实验还支持多种型号的SPI FLASH 芯片,比如:BY25Q128/NM25Q128/W25Q128等等,具体请看norflash.h 文件的宏定义,在程序上只需要稍微修改一下,后面讲解程序的时候会提到。
spi.h 文件,其定义如下:
#ifndef __SPI_H
#define __SPI_H
#include "./SYSTEM/sys/sys.h"
/******************************************************************************************/
/* SPI2 引脚 定义 */
#define SPI2_SCK_GPIO_PORT GPIOB
#define SPI2_SCK_GPIO_PIN GPIO_PIN_13
#define SPI2_SCK_GPIO_CLK_ENABLE() do{ __HAL_RCC_GPIOB_CLK_ENABLE(); }while(0) /* PB口时钟使能 */
#define SPI2_MISO_GPIO_PORT GPIOB
#define SPI2_MISO_GPIO_PIN GPIO_PIN_14
#define SPI2_MISO_GPIO_CLK_ENABLE() do{ __HAL_RCC_GPIOB_CLK_ENABLE(); }while(0) /* PB口时钟使能 */
#define SPI2_MOSI_GPIO_PORT GPIOB
#define SPI2_MOSI_GPIO_PIN GPIO_PIN_15
#define SPI2_MOSI_GPIO_CLK_ENABLE() do{ __HAL_RCC_GPIOB_CLK_ENABLE(); }while(0) /* PB口时钟使能 */
/* SPI2相关定义 */
#define SPI2_SPI SPI2
#define SPI2_SPI_CLK_ENABLE() do{ __HAL_RCC_SPI2_CLK_ENABLE(); }while(0) /* SPI2时钟使能 */
/******************************************************************************************/
void spi2_init(void);
uint8_t spi2_read_write_byte(uint8_t data);
#endif
spi.c文件
#include "./BSP/SPI/spi.h"
SPI_HandleTypeDef g_spi2_handler; /* SPI2句柄结构体 */
void spi2_init(void)
{
g_spi2_handler.Instance = SPI2_SPI; /* SPI2 */
g_spi2_handler.Init.Mode = SPI_MODE_MASTER; /* 设置SPI模式(主机模式) */
g_spi2_handler.Init.Direction = SPI_DIRECTION_2LINES; /* 设置SPI工作方式(全双工) */
g_spi2_handler.Init.DataSize = SPI_DATASIZE_8BIT; /* 设置数据格式(8bit长度) */
g_spi2_handler.Init.CLKPolarity = SPI_POLARITY_HIGH; /* 设置时钟极性(CPOL = 1) 工作模式3*/
g_spi2_handler.Init.CLKPhase = SPI_PHASE_2EDGE; /* 设置时钟相位(CPHA = 1) */
g_spi2_handler.Init.NSS = SPI_NSS_SOFT; /* 设置片选方式(软件片选,自定义GPIO) */
g_spi2_handler.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_256; /* 设置SPI时钟波特率分频(256分频) */
g_spi2_handler.Init.FirstBit = SPI_FIRSTBIT_MSB; /* 设置大小端模式(MSB高位在前) */
g_spi2_handler.Init.TIMode = SPI_TIMODE_DISABLE; /* 设置帧格式(关闭TI模式) */
g_spi2_handler.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE; /* 设置CRC校验(关闭CRC校验) */
g_spi2_handler.Init.CRCPolynomial = 7; /* 设置CRC校验多项式(范围:1~65535) 默认值为7*/
HAL_SPI_Init(&g_spi2_handler);
}
/**
* @brief SPI2底层驱动,时钟使能,引脚配置
* @note 此函数会被HAL_SPI_Init()调用
* @param hspi:SPI句柄
* @retval 无
*/
void HAL_SPI_MspInit(SPI_HandleTypeDef *hspi)
{
SPI2_SPI_CLK_ENABLE();
GPIO_InitTypeDef gpio_init_struct;
if (hspi->Instance == SPI2_SPI)
{
SPI2_SCK_GPIO_CLK_ENABLE(); /* SPI2_SCK脚时钟使能 */
SPI2_MISO_GPIO_CLK_ENABLE(); /* SPI2_MISO脚时钟使能 */
SPI2_MOSI_GPIO_CLK_ENABLE(); /* SPI2_MOSI脚时钟使能 */
/* SCK引脚模式设置(复用输出) */
gpio_init_struct.Pin = SPI2_SCK_GPIO_PIN;
gpio_init_struct.Mode = GPIO_MODE_AF_PP;//复用推挽输出
gpio_init_struct.Pull = GPIO_PULLUP;
gpio_init_struct.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(SPI2_SCK_GPIO_PORT, &gpio_init_struct);
/* MISO引脚模式设置(复用输出) */
gpio_init_struct.Pin = SPI2_MISO_GPIO_PIN;
HAL_GPIO_Init(SPI2_MISO_GPIO_PORT, &gpio_init_struct);
/* MOSI引脚模式设置(复用输出) */
gpio_init_struct.Pin = SPI2_MOSI_GPIO_PIN;
HAL_GPIO_Init(SPI2_MOSI_GPIO_PORT, &gpio_init_struct);
}
}
//接收发送一体的
uint8_t spi2_read_write_byte(uint8_t data)
{
uint8_t rec_data = 0;
//1字节
HAL_SPI_TransmitReceive(&g_spi2_handler, &data, &rec_data, 1, 1000);
//超时时间
return rec_data;
}
norflash.h
#ifndef __norflash_H
#define __norflash_H
#include "./SYSTEM/sys/sys.h"
/******************************************************************************************/
/* NORFLASH 片选 引脚 定义 */
#define NORFLASH_CS_GPIO_PORT GPIOB
#define NORFLASH_CS_GPIO_PIN GPIO_PIN_12
#define NORFLASH_CS_GPIO_CLK_ENABLE() do{ __HAL_RCC_GPIOB_CLK_ENABLE(); }while(0) /* PB口时钟使能 */
/******************************************************************************************/
/* NORFLASH 片选信号 */ //0是拉低 1是拉高
#define NORFLASH_CS(x) do{ x ? \
HAL_GPIO_WritePin(NORFLASH_CS_GPIO_PORT, NORFLASH_CS_GPIO_PIN, GPIO_PIN_SET) : \
HAL_GPIO_WritePin(NORFLASH_CS_GPIO_PORT, NORFLASH_CS_GPIO_PIN, GPIO_PIN_RESET); \
}while(0)
void norflash_init(void);
uint8_t norflash_rd_sr1(void);
uint8_t norflash_read_data(uint32_t addr);
void norflash_erase_sector(uint32_t addr);
void norflash_write_page(uint8_t data, uint32_t addr);
#endif
norflash.c
#include "./BSP/SPI/spi.h"
#include "./SYSTEM/delay/delay.h"
#include "./SYSTEM/usart/usart.h"
#include "./BSP/NORFLASH/norflash.h"
void norflash_init(void)
{
NORFLASH_CS_GPIO_CLK_ENABLE(); /* NORFLASH CS脚 时钟使能 */
GPIO_InitTypeDef gpio_init_struct;
gpio_init_struct.Pin = NORFLASH_CS_GPIO_PIN;
gpio_init_struct.Mode = GPIO_MODE_OUTPUT_PP;
gpio_init_struct.Pull = GPIO_PULLUP;
gpio_init_struct.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(NORFLASH_CS_GPIO_PORT, &gpio_init_struct); /* SCK引脚模式设置(复用输出) */
spi2_init();
spi2_read_write_byte(0xFF); /* 前面有介绍 发送空字节数据 为了产生8个时钟脉冲 清除DR数据寄存器的作用 */
NORFLASH_CS(1);//片选拉高
}
uint8_t norflash_rd_sr1(void)
{
uint8_t rec_data = 0;
NORFLASH_CS(0);
spi2_read_write_byte(0x05); /* 读状态寄存器1 */
rec_data = spi2_read_write_byte(0xFF);
NORFLASH_CS(1);
return rec_data;
}
//读一个字节
uint8_t norflash_read_data(uint32_t addr)
{
uint8_t rec_data = 0;
NORFLASH_CS(0);//拉低片选
/* 1 发送读命令 */
spi2_read_write_byte(0x03);
/* 2 发送地址 24位分开3次发送 一次只发一字节*/
spi2_read_write_byte(addr >> 16);
spi2_read_write_byte(addr >> 8);
spi2_read_write_byte(addr);
/* 3 读取数据 */
rec_data = spi2_read_write_byte(0xFF);
NORFLASH_CS(1);//拉高
return rec_data;
}
//扇区擦除
void norflash_erase_sector(uint32_t addr)
{
/* 1 写使能 */
NORFLASH_CS(0);
spi2_read_write_byte(0x06);
NORFLASH_CS(1);
/* 2 等待空闲 循环调用*/
while(norflash_rd_sr1()&0x01);
/* 3 发送扇区擦除指令 */
NORFLASH_CS(0);
spi2_read_write_byte(0x20);
/* 4 发送地址 */
spi2_read_write_byte(addr >> 16);
spi2_read_write_byte(addr >> 8);
spi2_read_write_byte(addr);
NORFLASH_CS(1);
/* 5 等待空闲 循环调用*/
while(norflash_rd_sr1()&0x01);
}
//页写 要写的数据 地址
void norflash_write_page(uint8_t data, uint32_t addr)
{
/* 1 擦除扇区 擦除该地址所在扇区 这里没考虑0xff的情况,直接擦除*/
norflash_erase_sector(addr);
/* 2 写使能 */
NORFLASH_CS(0);
spi2_read_write_byte(0x06);
NORFLASH_CS(1);
/* 3 发送页写指令 */
NORFLASH_CS(0);
spi2_read_write_byte(0x02);
/* 4 发送地址 */
spi2_read_write_byte(addr >> 16);
spi2_read_write_byte(addr >> 8);
spi2_read_write_byte(addr);
/* 5 要写入的数据 */
spi2_read_write_byte(data);
NORFLASH_CS(1);
/* 6 等待写入完成(等待空闲) */
while(norflash_rd_sr1()&0x01);
}
本实验中,我们通过调用HAL 库的函数去驱动SPI 进行通信,所以需要在工程中的FWLIB分组下添加stm32f1xx_hal_spi.c 文件去支持。实验工程中,我们新增了spi.c 存放spi 底层驱动代码,norflash.c 文件存放W25Q128/NM25Q128 驱动。
1. SPI 驱动代码
这里我们只讲解核心代码,详细的源码请大家参考光盘本实验对应源码。SPI 驱动源码包括两个文件:spi.c 和spi.h。
spi.h 文件,其定义如下:
#ifndef __SPI_H
#define __SPI_H
#include "./SYSTEM/sys/sys.h"
/******************************************************************************************/
/* SPI2 引脚 定义 */
#define SPI2_SCK_GPIO_PORT GPIOB
#define SPI2_SCK_GPIO_PIN GPIO_PIN_13
#define SPI2_SCK_GPIO_CLK_ENABLE() do{ __HAL_RCC_GPIOB_CLK_ENABLE(); }while(0) /* PB口时钟使能 */
#define SPI2_MISO_GPIO_PORT GPIOB
#define SPI2_MISO_GPIO_PIN GPIO_PIN_14
#define SPI2_MISO_GPIO_CLK_ENABLE() do{ __HAL_RCC_GPIOB_CLK_ENABLE(); }while(0) /* PB口时钟使能 */
#define SPI2_MOSI_GPIO_PORT GPIOB
#define SPI2_MOSI_GPIO_PIN GPIO_PIN_15
#define SPI2_MOSI_GPIO_CLK_ENABLE() do{ __HAL_RCC_GPIOB_CLK_ENABLE(); }while(0) /* PB口时钟使能 */
/* SPI2相关定义 */
#define SPI2_SPI SPI2
#define SPI2_SPI_CLK_ENABLE() do{ __HAL_RCC_SPI2_CLK_ENABLE(); }while(0) /* SPI2时钟使能 */
/******************************************************************************************/
/* SPI总线速度设置 */
#define SPI_SPEED_2 0
#define SPI_SPEED_4 1
#define SPI_SPEED_8 2
#define SPI_SPEED_16 3
#define SPI_SPEED_32 4
#define SPI_SPEED_64 5
#define SPI_SPEED_128 6
#define SPI_SPEED_256 7
void spi2_init(void);
void spi2_set_speed(uint8_t speed);
uint8_t spi2_read_write_byte(uint8_t txdata);
#endif
spi.c文件
#include "./BSP/SPI/spi.h"
SPI_HandleTypeDef g_spi2_handler; /* SPI2句柄 */
/**
* @brief SPI初始化代码
* @note 主机模式,8位数据,禁止硬件片选
* @param 无
* @retval 无
*/
void spi2_init(void)
{
SPI2_SPI_CLK_ENABLE(); /* SPI2时钟使能 */
g_spi2_handler.Instance = SPI2_SPI; /* SPI2 */
g_spi2_handler.Init.Mode = SPI_MODE_MASTER; /* 设置SPI工作模式,设置为主模式 */
g_spi2_handler.Init.Direction = SPI_DIRECTION_2LINES; /* 设置SPI单向或者双向的数据模式:SPI设置为双线模式 */
g_spi2_handler.Init.DataSize = SPI_DATASIZE_8BIT; /* 设置SPI的数据大小:SPI发送接收8位帧结构 */
g_spi2_handler.Init.CLKPolarity = SPI_POLARITY_HIGH; /* 串行同步时钟的空闲状态为高电平 */
g_spi2_handler.Init.CLKPhase = SPI_PHASE_2EDGE; /* 串行同步时钟的第二个跳变沿(上升或下降)数据被采样 */
g_spi2_handler.Init.NSS = SPI_NSS_SOFT; /* NSS信号由硬件(NSS管脚)还是软件(使用SSI位)管理:内部NSS信号有SSI位控制 */
g_spi2_handler.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_256; /* 定义波特率预分频的值:波特率预分频值为256 */
g_spi2_handler.Init.FirstBit = SPI_FIRSTBIT_MSB; /* 指定数据传输从MSB位还是LSB位开始:数据传输从MSB位开始 */
g_spi2_handler.Init.TIMode = SPI_TIMODE_DISABLE; /* 关闭TI模式 */
g_spi2_handler.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE; /* 关闭硬件CRC校验 */
g_spi2_handler.Init.CRCPolynomial = 7; /* CRC值计算的多项式 */
HAL_SPI_Init(&g_spi2_handler); /* 初始化 */
__HAL_SPI_ENABLE(&g_spi2_handler); /* 使能SPI2 */
spi2_read_write_byte(0Xff); /* 启动传输, 实际上就是产生8个时钟脉冲, 达到清空DR的作用, 非必需 */
}
/**
* @brief SPI2底层驱动,时钟使能,引脚配置
* @note 此函数会被HAL_SPI_Init()调用
* @param hspi:SPI句柄
* @retval 无
*/
void HAL_SPI_MspInit(SPI_HandleTypeDef *hspi)
{
GPIO_InitTypeDef GPIO_Initure;
if (hspi->Instance == SPI2_SPI)
{
SPI2_SCK_GPIO_CLK_ENABLE(); /* SPI2_SCK脚时钟使能 */
SPI2_MISO_GPIO_CLK_ENABLE(); /* SPI2_MISO脚时钟使能 */
SPI2_MOSI_GPIO_CLK_ENABLE(); /* SPI2_MOSI脚时钟使能 */
/* SCK引脚模式设置(复用输出) */
GPIO_Initure.Pin = SPI2_SCK_GPIO_PIN;
GPIO_Initure.Mode = GPIO_MODE_AF_PP;
GPIO_Initure.Pull = GPIO_PULLUP;
GPIO_Initure.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(SPI2_SCK_GPIO_PORT, &GPIO_Initure);
/* MISO引脚模式设置(复用输出) */
GPIO_Initure.Pin = SPI2_MISO_GPIO_PIN;
HAL_GPIO_Init(SPI2_MISO_GPIO_PORT, &GPIO_Initure);
/* MOSI引脚模式设置(复用输出) */
GPIO_Initure.Pin = SPI2_MOSI_GPIO_PIN;
HAL_GPIO_Init(SPI2_MOSI_GPIO_PORT, &GPIO_Initure);
}
}
/**
* @brief SPI2速度设置函数
* @note SPI2时钟选择来自APB1, 即PCLK1, 为36Mhz
* SPI速度 = PCLK1 / 2^(speed + 1)
* @param speed : SPI2时钟分频系数
取值为SPI_BAUDRATEPRESCALER_2~SPI_BAUDRATEPRESCALER_2 256
* @retval 无
*/
void spi2_set_speed(uint8_t speed)
{
assert_param(IS_SPI_BAUDRATE_PRESCALER(speed)); /* 判断有效性 */
__HAL_SPI_DISABLE(&g_spi2_handler); /* 关闭SPI */
g_spi2_handler.Instance->CR1 &= 0XFFC7; /* 位3-5清零,用来设置波特率 */
g_spi2_handler.Instance->CR1 |= speed << 3; /* 设置SPI速度 */
__HAL_SPI_ENABLE(&g_spi2_handler); /* 使能SPI */
}
/**
* @brief SPI2读写一个字节数据
* @param txdata : 要发送的数据(1字节)
* @retval 接收到的数据(1字节)
*/
uint8_t spi2_read_write_byte(uint8_t txdata)
{
uint8_t rxdata;
HAL_SPI_TransmitReceive(&g_spi2_handler, &txdata, &rxdata, 1, 1000);
return rxdata; /* 返回收到的数据 */
}
在spi_init 函数中主要工作就是对于SPI 参数的配置,这里包括工作模式、数据模式、数据大小、时钟极性、时钟相位、波特率预分频值等。关于SPI 的管脚配置就放在了HAL_SPI_MspInit函数里。
通过以上两个函数的作用就可以完成SPI 初始化。接下来介绍SPI 的发送和接收函数。
这里的spi_read_write_byte 函数直接调用了HAL 库内置的函数进行接收发送操作。前面已经有介绍了,这里就不展开对HAL_SPI_TransmitReceive 函数的解析。
由于不同的外设需要的通信速度不一样,所以这里我们定义了一个速度设置函数,通过操作寄存器的方式去实现。
2. NOR FLASH 驱动代码
这里我们只讲解核心代码,详细的源码请大家参考光盘本实验对应源码。NOR FLASH 驱
动源码包括两个文件:norflash.c 和norflash.h。
在上一小节已经对SPI 协议需要用到的东西都封装好了。那么现在就要在SPI 通信的基础
上,通过前面分析的NM25Q128 的工作时序拟定通信代码。
由于这部分的代码量比较多,这里就不一一贴出来介绍。介绍几个重点,其余的请自行查
看源码。
首先是norflash.h 头文件,我们做了一个FLASH 芯片列表(宏定义),这些宏定义是一些支持的FLASH 芯片的ID。接下来是FLASH 芯片指令表的宏定义,这个请参考FLASH 芯片手册比对得到。
norflash.h
#ifndef __norflash_H
#define __norflash_H
#include "./SYSTEM/sys/sys.h"
/******************************************************************************************/
/* NORFLASH 片选 引脚 定义 */
#define NORFLASH_CS_GPIO_PORT GPIOB
#define NORFLASH_CS_GPIO_PIN GPIO_PIN_12
#define NORFLASH_CS_GPIO_CLK_ENABLE() do{ __HAL_RCC_GPIOB_CLK_ENABLE(); }while(0) /* PB口时钟使能 */
/******************************************************************************************/
/* NORFLASH 片选信号 */
#define NORFLASH_CS(x) do{ x ? \
HAL_GPIO_WritePin(NORFLASH_CS_GPIO_PORT, NORFLASH_CS_GPIO_PIN, GPIO_PIN_SET) : \
HAL_GPIO_WritePin(NORFLASH_CS_GPIO_PORT, NORFLASH_CS_GPIO_PIN, GPIO_PIN_RESET); \
}while(0)
/* FLASH芯片列表 */
#define W25Q80 0XEF13 /* W25Q80 芯片ID */
#define W25Q16 0XEF14 /* W25Q16 芯片ID */
#define W25Q32 0XEF15 /* W25Q32 芯片ID */
#define W25Q64 0XEF16 /* W25Q64 芯片ID */
#define W25Q128 0XEF17 /* W25Q128 芯片ID */
#define W25Q256 0XEF18 /* W25Q256 芯片ID */
#define BY25Q64 0X6816 /* BY25Q64 芯片ID */
#define BY25Q128 0X6817 /* BY25Q128 芯片ID */
#define NM25Q64 0X5216 /* NM25Q64 芯片ID */
#define NM25Q128 0X5217 /* NM25Q128 芯片ID */
extern uint16_t norflash_TYPE; /* 定义FLASH芯片型号 */
/* 指令表 */
#define FLASH_WriteEnable 0x06
#define FLASH_WriteDisable 0x04
#define FLASH_ReadStatusReg1 0x05
#define FLASH_ReadStatusReg2 0x35
#define FLASH_ReadStatusReg3 0x15
#define FLASH_WriteStatusReg1 0x01
#define FLASH_WriteStatusReg2 0x31
#define FLASH_WriteStatusReg3 0x11
#define FLASH_ReadData 0x03
#define FLASH_FastReadData 0x0B
#define FLASH_FastReadDual 0x3B
#define FLASH_FastReadQuad 0xEB
#define FLASH_PageProgram 0x02
#define FLASH_PageProgramQuad 0x32
#define FLASH_BlockErase 0xD8
#define FLASH_SectorErase 0x20
#define FLASH_ChipErase 0xC7
#define FLASH_PowerDown 0xB9
#define FLASH_ReleasePowerDown 0xAB
#define FLASH_DeviceID 0xAB
#define FLASH_ManufactDeviceID 0x90
#define FLASH_JedecDeviceID 0x9F
#define FLASH_Enable4ByteAddr 0xB7
#define FLASH_Exit4ByteAddr 0xE9
#define FLASH_SetReadParam 0xC0
#define FLASH_EnterQPIMode 0x38
#define FLASH_ExitQPIMode 0xFF
/* 静态函数 */
static void norflash_wait_busy(void); /* 等待空闲 */
static void norflash_send_address(uint32_t address);/* 发送地址 */
static void norflash_write_page(uint8_t *pbuf, uint32_t addr, uint16_t datalen); /* 写入page */
static void norflash_write_nocheck(uint8_t *pbuf, uint32_t addr, uint16_t datalen); /* 写flash,不带擦除 */
/* 普通函数 */
void norflash_init(void); /* 初始化25QXX */
uint16_t norflash_read_id(void); /* 读取FLASH ID */
void norflash_write_enable(void); /* 写使能 */
uint8_t norflash_read_sr(uint8_t regno); /* 读取状态寄存器 */
void norflash_write_sr(uint8_t regno,uint8_t sr); /* 写状态寄存器 */
void norflash_erase_chip(void); /* 整片擦除 */
void norflash_erase_sector(uint32_t saddr); /* 扇区擦除 */
void norflash_read(uint8_t *pbuf, uint32_t addr, uint16_t datalen); /* 读取flash */
void norflash_write(uint8_t *pbuf, uint32_t addr, uint16_t datalen); /* 写入flash */
#endif
norflash.c
#include "./BSP/SPI/spi.h"
#include "./SYSTEM/delay/delay.h"
#include "./SYSTEM/usart/usart.h"
#include "./BSP/NORFLASH/norflash.h"
uint16_t g_norflash_type = NM25Q128; /* 默认是NM25Q128 */
/**
* @brief 初始化SPI NOR FLASH
* @param 无
* @retval 无
*/
void norflash_init(void)
{
uint8_t temp;
NORFLASH_CS_GPIO_CLK_ENABLE(); /* NORFLASH CS脚 时钟使能 */
GPIO_InitTypeDef gpio_init_struct;
gpio_init_struct.Pin = NORFLASH_CS_GPIO_PIN;
gpio_init_struct.Mode = GPIO_MODE_OUTPUT_PP;
gpio_init_struct.Pull = GPIO_PULLUP;
gpio_init_struct.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(NORFLASH_CS_GPIO_PORT, &gpio_init_struct); /* CS引脚模式设置(复用输出) */
NORFLASH_CS(1); /* 取消片选 */
spi2_init(); /* 初始化SPI2 */
spi2_set_speed(SPI_SPEED_2); /* SPI2 切换到高速状态 18Mhz */
g_norflash_type = norflash_read_id(); /* 读取FLASH ID. */
if (g_norflash_type == W25Q256) /* SPI FLASH为W25Q256, 必须使能4字节地址模式 */
{
temp = norflash_read_sr(3); /* 读取状态寄存器3,判断地址模式 */
if ((temp & 0X01) == 0) /* 如果不是4字节地址模式,则进入4字节地址模式 */
{
norflash_write_enable(); /* 写使能 */
temp |= 1 << 1; /* ADP=1, 上电使用 4 字节地址模式 */
norflash_write_sr(3, temp); /* 写到状态寄存器SR3 */
NORFLASH_CS(0);
spi2_read_write_byte(FLASH_Enable4ByteAddr); /* 使能4字节地址指令 */
NORFLASH_CS(1);
}
}
//printf("ID:%x\r\n", g_norflash_type);
}
/**
* @brief 等待空闲
* @param 无
* @retval 无
*/
static void norflash_wait_busy(void)
{
while ((norflash_read_sr(1) & 0x01) == 0x01); /* 死等 等待BUSY位清空 */
}
/**
* @brief 25QXX写使能
* @note 将S1寄存器的WEL置位
* @param 无
* @retval 无
*/
void norflash_write_enable(void)
{
NORFLASH_CS(0);
spi2_read_write_byte(FLASH_WriteEnable); /* 发送写使能 */
NORFLASH_CS(1);
}
/**
* @brief 25QXX发送地址
* @note 根据芯片型号的不同, 发送24ibt / 32bit地址
* @param address : 要发送的地址
* @retval 无
*/
static void norflash_send_address(uint32_t address)
{
if (g_norflash_type == W25Q256) /* 只有W25Q256支持4字节地址模式 */
{
spi2_read_write_byte((uint8_t)((address)>>24)); /* 发送 bit31 ~ bit24 地址 */
}
spi2_read_write_byte((uint8_t)((address)>>16)); /* 发送 bit23 ~ bit16 地址 */
spi2_read_write_byte((uint8_t)((address)>>8)); /* 发送 bit15 ~ bit8 地址 */
spi2_read_write_byte((uint8_t)address); /* 发送 bit7 ~ bit0 地址 */
}
/**
* @brief 读取25QXX的状态寄存器,25QXX一共有3个状态寄存器
* @note 状态寄存器1:
* BIT7 6 5 4 3 2 1 0
* SPR RV TB BP2 BP1 BP0 WEL BUSY
* SPR:默认0,状态寄存器保护位,配合WP使用
* TB,BP2,BP1,BP0:FLASH区域写保护设置
* WEL:写使能锁定
* BUSY:忙标记位(1,忙;0,空闲)
* 默认:0x00
*
* 状态寄存器2:
* BIT7 6 5 4 3 2 1 0
* SUS CMP LB3 LB2 LB1 (R) QE SRP1
*
* 状态寄存器3:
* BIT7 6 5 4 3 2 1 0
* HOLD/RST DRV1 DRV0 (R) (R) WPS ADP ADS
*
* @param regno: 状态寄存器号,范:1~3
* @retval 状态寄存器值
*/
uint8_t norflash_read_sr(uint8_t regno)
{
uint8_t byte = 0, command = 0;
switch (regno)
{
case 1:
command = FLASH_ReadStatusReg1; /* 读状态寄存器1指令 */
break;
case 2:
command = FLASH_ReadStatusReg2; /* 读状态寄存器2指令 */
break;
case 3:
command = FLASH_ReadStatusReg3; /* 读状态寄存器3指令 */
break;
default:
command = FLASH_ReadStatusReg1;
break;
}
NORFLASH_CS(0);
spi2_read_write_byte(command); /* 发送读寄存器命令 */
byte = spi2_read_write_byte(0Xff); /* 读取一个字节 */
NORFLASH_CS(1);
return byte;
}
/**
* @brief 写25QXX状态寄存器
* @note 寄存器说明见norflash_read_sr函数说明
* @param regno: 状态寄存器号,范:1~3
* @param sr : 要写入状态寄存器的值
* @retval 无
*/
void norflash_write_sr(uint8_t regno, uint8_t sr)
{
uint8_t command = 0;
switch (regno)
{
case 1:
command = FLASH_WriteStatusReg1; /* 写状态寄存器1指令 */
break;
case 2:
command = FLASH_WriteStatusReg2; /* 写状态寄存器2指令 */
break;
case 3:
command = FLASH_WriteStatusReg3; /* 写状态寄存器3指令 */
break;
default:
command = FLASH_WriteStatusReg1;
break;
}
NORFLASH_CS(0);
spi2_read_write_byte(command); /* 发送读寄存器命令 */
spi2_read_write_byte(sr); /* 写入一个字节 */
NORFLASH_CS(1);
}
/**
* @brief 读取芯片ID
* @param 无
* @retval FLASH芯片ID
* @note 芯片ID列表见: norflash.h, 芯片列表部分
*/
uint16_t norflash_read_id(void)//详细步骤读取芯片手册 操作时序图
{
uint16_t deviceid;
NORFLASH_CS(0);
spi2_read_write_byte(FLASH_ManufactDeviceID); /* 发送读 ID 命令 */
spi2_read_write_byte(0); /* 写入一个字节 */
spi2_read_write_byte(0);
spi2_read_write_byte(0);
deviceid = spi2_read_write_byte(0xFF) << 8; /* 读取高8位字节 */
deviceid |= spi2_read_write_byte(0xFF); /* 读取低8位字节 */
NORFLASH_CS(1);
return deviceid;
}
/**
* @brief 读取SPI FLASH
* @note 在指定地址开始读取指定长度的数据
* @param pbuf : 数据存储区
* @param addr : 开始读取的地址(最大32bit)
* @param datalen : 要读取的字节数(最大65535)
* @retval 无
*/
void norflash_read(uint8_t *pbuf, uint32_t addr, uint16_t datalen)
{
uint16_t i;
NORFLASH_CS(0);
spi2_read_write_byte(FLASH_ReadData); /* 发送读取命令 */
norflash_send_address(addr); /* 发送地址 */
for(i = 0; i < datalen; i++)
{
pbuf[i] = spi2_read_write_byte(0XFF); /* 循环读取 */
}
NORFLASH_CS(1);
}
/**
* @brief SPI在一页(0~65535)内写入少于256个字节的数据
* @note 在指定地址开始写入最大256字节的数据
* @param pbuf : 数据存储区
* @param addr : 开始写入的地址(最大32bit)
* @param datalen : 要写入的字节数(最大256),该数不应该超过该页的剩余字节数!!!
* @retval 无
*/
static void norflash_write_page(uint8_t *pbuf, uint32_t addr, uint16_t datalen)
{
uint16_t i;
norflash_write_enable(); /* 写使能 */
NORFLASH_CS(0);
spi2_read_write_byte(FLASH_PageProgram); /* 发送写页命令 */
norflash_send_address(addr); /* 发送地址 */
for(i = 0; i < datalen; i++)
{
spi2_read_write_byte(pbuf[i]); /* 循环写入数据 */
}
NORFLASH_CS(1);
norflash_wait_busy(); /* 等待写入结束 */
}
/**
* @brief 无检验写SPI FLASH
* @note 必须确保所写的地址范围内的数据全部为0XFF,否则在非0XFF处写入的数据将失败!
* 具有自动换页功能
* 在指定地址开始写入指定长度的数据,但是要确保地址不越界!
*
* @param pbuf : 数据存储区
* @param addr : 开始写入的地址(最大32bit)
* @param datalen : 要写入的字节数(最大65535)
* @retval 无
*/
static void norflash_write_nocheck(uint8_t *pbuf, uint32_t addr, uint16_t datalen)
{
uint16_t pageremain;
pageremain = 256 - addr % 256; /* 单页剩余的字节数 */
if (datalen <= pageremain) /* 不大于256个字节 */
{
pageremain = datalen;
}
while (1)
{
/* 当写入字节比页内剩余地址还少的时候, 一次性写完
* 当写入直接比页内剩余地址还多的时候, 先写完整个页内剩余地址, 然后根据剩余长度进行不同处理
*/
norflash_write_page(pbuf, addr, pageremain);
if (datalen == pageremain) /* 写入结束了 */
{
break;
}
else /* datalen > pageremain */
{
pbuf += pageremain; /* pbuf指针地址偏移,前面已经写了pageremain字节 */
addr += pageremain; /* 写地址偏移,前面已经写了pageremain字节 */
datalen -= pageremain; /* 写入总长度减去已经写入了的字节数 */
if (datalen > 256) /* 剩余数据还大于一页,可以一次写一页 */
{
pageremain = 256; /* 一次可以写入256个字节 */
}
else /* 剩余数据小于一页,可以一次写完 */
{
pageremain = datalen; /* 不够256个字节了 */
}
}
}
}
/**
* @brief 写SPI FLASH
* @note 在指定地址开始写入指定长度的数据 , 该函数带擦除操作!
* SPI FLASH 一般是: 256个字节为一个Page, 4Kbytes为一个Sector, 16个扇区为1个Block
* 擦除的最小单位为Sector.
*
* @param pbuf : 数据存储区
* @param addr : 开始写入的地址(最大32bit)
* @param datalen : 要写入的字节数(最大65535)
* @retval 无
*/
uint8_t g_norflash_buf[4096]; /* 扇区缓存 */
void norflash_write(uint8_t *pbuf, uint32_t addr, uint16_t datalen)
{
uint32_t secpos;
uint16_t secoff;
uint16_t secremain;
uint16_t i;
uint8_t *norflash_buf;
norflash_buf = g_norflash_buf;
secpos = addr / 4096; /* 扇区地址 */
secoff = addr % 4096; /* 在扇区内的偏移 */
secremain = 4096 - secoff; /* 扇区剩余空间大小 */
//printf("ad:%X,nb:%X\r\n", addr, datalen); /* 测试用 */
if (datalen <= secremain)
{
secremain = datalen; /* 不大于4096个字节 */
}
while (1)
{
norflash_read(norflash_buf, secpos * 4096, 4096); /* 读出整个扇区的内容 */
for (i = 0; i < secremain; i++) /* 校验数据 */
{
if (norflash_buf[secoff + i] != 0XFF)
{
break; /* 需要擦除, 直接退出for循环 */
}
}
if (i < secremain) /* 需要擦除 */
{
norflash_erase_sector(secpos); /* 擦除这个扇区 */
for (i = 0; i < secremain; i++) /* 复制 */
{
norflash_buf[i + secoff] = pbuf[i];
}
norflash_write_nocheck(norflash_buf, secpos * 4096, 4096); /* 写入整个扇区 */
}
else /* 写已经擦除了的,直接写入扇区剩余区间. */
{
norflash_write_nocheck(pbuf, addr, secremain); /* 直接写扇区 */
}
if (datalen == secremain)
{
break; /* 写入结束了 */
}
else /* 写入未结束 */
{
secpos++; /* 扇区地址增1 */
secoff = 0; /* 偏移位置为0 */
pbuf += secremain; /* 指针偏移 */
addr += secremain; /* 写地址偏移 */
datalen -= secremain; /* 字节数递减 */
if (datalen > 4096)
{
secremain = 4096; /* 下一个扇区还是写不完 */
}
else
{
secremain = datalen;/* 下一个扇区可以写完了 */
}
}
}
}
/**
* @brief 擦除整个芯片
* @note 等待时间超长...
* @param 无
* @retval 无
*/
void norflash_erase_chip(void)
{
norflash_write_enable(); /* 写使能 */
norflash_wait_busy(); /* 等待空闲 */
NORFLASH_CS(0);
spi2_read_write_byte(FLASH_ChipErase); /* 发送读寄存器命令 */
NORFLASH_CS(1);
norflash_wait_busy(); /* 等待芯片擦除结束 */
}
/**
* @brief 擦除一个扇区
* @note 注意,这里是扇区地址,不是字节地址!!
* 擦除一个扇区的最少时间:150ms
*
* @param saddr : 扇区地址 根据实际容量设置
* @retval 无
*/
void norflash_erase_sector(uint32_t saddr)
{
//printf("fe:%x\r\n", saddr); /* 监视falsh擦除情况,测试用 */
saddr *= 4096;
norflash_write_enable(); /* 写使能 */
norflash_wait_busy(); /* 等待空闲 */
NORFLASH_CS(0);
spi2_read_write_byte(FLASH_SectorErase); /* 发送写页命令 */
norflash_send_address(saddr); /* 发送地址 */
NORFLASH_CS(1);
norflash_wait_busy(); /* 等待扇区擦除完成 */
}
首先是NOR FLASH 初始化函数。
在初始化函数中,将SPI 通信协议用到的CS 引脚配置好,同时根据FLASH 的通信要求,
通过调用spi2_set_speed 函数把SPI2 切换到高速状态。然后尝试读取flash 的ID,由于W25Q256的容量比较大,通信的时候需要4 个字节,为了函数的兼容性,我们这里做了判断处理。当然,我们使用的NM25Q128 是3 字节地址模式的。如果能读到ID 则说明我们的SPI 时序能正常操作FLASH,便可以通过SPI 接口读写NOR FLASH 的数据了。
进行其它数据操作时,由于每一次读写操作的时候都需要发送地址,所以这里我们把这个
板块封装成函数,函数名是norflash_send_address,实质上就是通过SPI 的发送接收函数
spi2_read_write_byte 实现的,这里就不列出来了,大家可以查看光盘源码。
下面介绍一下FLASH 读取函数,这里可以根据前面的时序图对照理解。
该函数用于从NOR FLASH 的指定位置读出指定长度的数据,由于NOR FLASH 支持以任
意地址(但是不能超过NOR FLASH 的地址范围)开始读取数据,所以,这个代码相对来说比较简单。首先拉低片选信号,发送读取命令,接着发送24 位地址之后,程序就可以开始循环读数据,其地址就会自动增加,读取完数据后,需要拉高片选信号,结束通信。
有读函数,那肯定就有写函数,接下来我们介绍一下NOR FLASH 写函数。
该函数可以在NOR FLASH 的任意地址开始写入任意长度(必须不超过NOR FLASH 的容
量)的数据。
我们这里简单介绍一下思路:先获得首地址(addr)所在的扇区,并计算在扇区内的偏移,
然后判断要写入的数据长度是否超过本扇区所剩下的长度,如果不超过,再先看看是否要擦除,如果不要,则直接写入数据即可,如果要则读出整个扇区,在偏移处开始写入指定长度的数据,然后擦除这个扇区,再一次性写入。当所需要写入的数据长度超过一个扇区的长度的时候,我们先按照前面的步骤把扇区剩余部分写完,再在新扇区内执行同样的操作,如此循环,直到写入结束。这里我们还定义了一个g_norflash_buf 的全局变量,用于擦除时缓存扇区内的数据。
简单介绍一下写函数的实质调用,它用到的是通过无检验写SPI_FLASH 函数实现的,而
最终是用到页写函数norflash_write_page,在前面也对页写时序进行了分析,
在页写功能的代码中,先发送写使能命令,才发送页写命令,然后发送写入的地址,再把写入的内容通过一个for 循环写入,发送完后拉高片选CS 引脚结束通信,等待flash 内部写入结束。检测flash 内部的状态可以通过查看NM25Qxx 状态寄存器1 的位0。在这里科普一下
NM25Qxx 的状态寄存器,可以通过寄存器相关位判断NM25Qxx 的状态,下面是NM25Qxx 状态寄存器表:
我们也定义了一个函数norflash_read_sr,去读取NM25Qxx 状态寄存器的值,这里就不列出来了,主要实现的方式也是老套路:根据传参判断需要获取的是哪个状态寄存器,然后拉低片选线,调用spi2_read_write_byte 函数发送该寄存器的命令,然后通过发送一字节空数据获取读取到的数据,最后拉高片选线,函数返回读取到的值。
在norflash_write_page 函数的基础上,增加了norflash_write_nocheck 函数进行封装解决写入字节可能大于该页剩下的字节数问题,方便解决写入错误问题。
上面函数的实现主要是逻辑处理,通过判断传参中的写入字节的长度与单页剩余的字节数,来决定是否是需要在新页写入剩下的字节。这里需要大家自行理解一下。通过调用该函数实现了norflash_write 的功能。
下面简单介绍一下擦除函数norflash_erase_sector,前面工作时序中也有对此描述。
该代码也是老套路,通过发送擦除指令实现擦除功能,要注意的是使用扇区擦除指令前,
需要先发送写使能指令,拉低片选线,发送扇区擦除指令之后,发送擦除的扇区地址,实现擦除,最后拉高片选线结束通信。在函数最后通过读取寄存器状态的函数,等待扇区擦除完成。
3. main.c 代码
在main.c 里面编写如下代码:
#include "./SYSTEM/sys/sys.h"
#include "./SYSTEM/usart/usart.h"
#include "./SYSTEM/delay/delay.h"
#include "./USMART/usmart.h"
#include "./BSP/LED/led.h"
#include "./BSP/LCD/lcd.h"
#include "./BSP/KEY/key.h"
#include "./BSP/NORFLASH/norflash.h"
/* 要写入到FLASH的字符串数组 */
const uint8_t g_text_buf[] = {"STM32 SPI TEST"};
#define TEXT_SIZE sizeof(g_text_buf) /* TEXT字符串长度 */
int main(void)
{
uint8_t key;
uint16_t i = 0;
uint8_t datatemp[TEXT_SIZE];
uint32_t flashsize;
uint16_t id = 0;
HAL_Init(); /* 初始化HAL库 */
sys_stm32_clock_init(RCC_PLL_MUL9); /* 设置时钟, 72Mhz */
delay_init(72); /* 延时初始化 */
usart_init(115200); /* 串口初始化为115200 */
usmart_dev.init(72); /* 初始化USMART */
led_init(); /* 初始化LED */
lcd_init(); /* 初始化LCD */
key_init(); /* 初始化按键 */
norflash_init(); /* 初始化NORFLASH */
lcd_show_string(30, 50, 200, 16, 16, "STM32", RED);
lcd_show_string(30, 70, 200, 16, 16, "SPI TEST", RED);
lcd_show_string(30, 90, 200, 16, 16, "ATOM@ALIENTEK", RED);
lcd_show_string(30, 110, 200, 16, 16, "KEY1:Write KEY0:Read", RED); /* 显示提示信息 */
id = norflash_read_id(); /* 读取FLASH ID */
while ((id == 0) || (id == 0XFFFF)) /* 检测不到FLASH芯片 */
{
lcd_show_string(30, 130, 200, 16, 16, "FLASH Check Failed!", RED);
delay_ms(500);
lcd_show_string(30, 130, 200, 16, 16, "Please Check! ", RED);
delay_ms(500);
LED0_TOGGLE(); /* LED0闪烁 */
}
lcd_show_string(30, 130, 200, 16, 16, "SPI FLASH Ready!", BLUE);
flashsize = 16 * 1024 * 1024; /* FLASH 大小为16M字节 */
while (1)
{
key = key_scan(0);
if (key == KEY1_PRES) /* KEY1按下,写入 */
{
lcd_fill(0, 150, 239, 319, WHITE); /* 清除半屏 */
lcd_show_string(30, 150, 200, 16, 16, "Start Write FLASH....", BLUE);
sprintf((char *)datatemp, "%s%d", (char *)g_text_buf, i);
norflash_write((uint8_t *)datatemp, flashsize - 100, TEXT_SIZE); /* 从倒数第100个地址处开始,写入SIZE长度的数据 */
lcd_show_string(30, 150, 200, 16, 16, "FLASH Write Finished!", BLUE); /* 提示传送完成 */
}
if (key == KEY0_PRES) /* KEY0按下,读取字符串并显示 */
{
lcd_show_string(30, 150, 200, 16, 16, "Start Read FLASH... . ", BLUE);
norflash_read(datatemp, flashsize - 100, TEXT_SIZE); /* 从倒数第100个地址处开始,读出SIZE个字节 */
lcd_show_string(30, 150, 200, 16, 16, "The Data Readed Is: ", BLUE); /* 提示传送完成 */
lcd_show_string(30, 170, 200, 16, 16, (char *)datatemp, BLUE); /* 显示读到的字符串 */
}
i++;
if (i == 20)
{
LED0_TOGGLE(); /* LED0闪烁 */
i = 0;
}
delay_ms(10);
}
}
在main 函数前面,我们定义了g_text_buf 数组,用于存放要写入到FLASH 的字符串。main
函数代码和IIC 实验那部分代码大同小异,具体流程大致是:在完成系统级和用户级初始化工作后,读取FLASH 的ID,然后通过KEY0 去读取倒数第100 个地址处开始的数据并把数据显示在LCD 上;另外还可以通过KEY1 去倒数第100 个地址处写入g_text_buf 数据并在LCD 界面中显示传输中,完成后并显示“FLASH Write Finished!”。
将程序下载到开发板后,可以看到LED0 不停的闪烁,提示程序已经在运行了。LCD 显示
的内容如图36.4.1 所示:
通过先按下KEY1 写入数据,然后再按KEY0 读取数据,得到如图36.4.2 所示:
程序在开机的时候会检测NOR FLASH 是否存在,如果不存在则会在LCD 模块上显示错误信息,同时LED0 慢闪。大家可以通过跳线帽把PB14 和PB15 短接就可以看到报错了。
该实验还支持USMART,在这里我们加入了norflash_read_id 和norflash_erase_chip 以及
norflash_erase_sector 函数。可以通过USMART 调用norflash_read_id 函数去读取SPI_FLASH 的ID,也可以调用另外两个擦除函数。需要注意的是假如调用了norflash_erase_chip 函数将会对整个SPI_FLASH 进行擦除,一般情况不建议对整个SPI_FLASH 进行擦除,因为会导致字库和综合例程所需要的系统文件全部丢失。
本章我们将向大家介绍如何使用STM32F1 的串口实现485 通信(半双工)。在本章中,我们将使用STM32F1 的串口2 来实现两块开发板之间的485 通信,并将结果显示在TFTLCD 模块上。
485(一般称作RS485/EIA-485)隶属于OSI 模型物理层,是串行通讯的一种。电气特性规定为2 线,半双工,多点通信的类型。它的电气特性和RS-232 大不一样。用缆线两端的电压差值来表示传递信号。RS485 仅仅规定了接受端和发送端的电气特性。它没有规定或推荐任何数据协议。
RS485 的特点包括:
1,接口电平低,不易损坏芯片。RS485 的电气特性:逻辑“1”以两线间的电压差为+(2~ 6)V 表示;逻辑“0”以两线间的电压差为-(2~6)V 表示。接口信号电平比RS232 降低了,不易损坏接口电路的芯片,且该电平与TTL 电平兼容,可方便与TTL 电路连接。
2,传输速率高。10 米时,RS485 的数据最高传输速率可达35Mbps,在1200m 时,传输速度可达100Kbps。
3,抗干扰能力强。RS485 接口是采用平衡驱动器和差分接收器的组合,抗共模干扰能力增强,即抗噪声干扰性好。
4,传输距离远,支持节点多。RS485 总线最长可以传输1200m 左右,更远的距离则需要中继传输设备支持但这时(速率≤100Kbps)才能稳定传输,一般最大支持32 个节点,如果使用特制的485 芯片,可以达到128 个或者256 个节点,最大的可以支持到400 个节点。
RS485 推荐使用在点对点网络中,比如:线型,总线型网络等,而不能是星型,环型网络。
理想情况下RS485 需要2 个终端匹配电阻,其阻值要求等于传输电缆的特性阻抗(一般为120Ω)。没有特性阻抗的话,当所有的设备都静止或者没有能量的时候就会产生噪声,而且线移需要双端的电压差。没有终接电阻的话,会使得较快速的发送端产生多个数据信号的边缘,导致数据传输出错。485 推荐的一主多从连接方式如图37.1.1 所示:
在上面的连接中,如果需要添加匹配电阻,我们一般在总线的起止端加入,也就是主机和设备4 上面各加一个120Ω的匹配电阻。
由于RS485 具有传输距离远、传输速度快、支持节点多和抗干扰能力更强等特点,所以RS485 有很广泛的应用。实际多设备时收发器有范围为-7V 到+12V 的共模电压,为了稳定传输,也有使用3 线的布线方式,即在原有的A、B 两线上多增加一条地线。(4 线制只能实现点对点的全双工通讯方式,这种也叫RS422,由于布线的难度和通讯局限,相对使用得比较少)。
TP8485E/SP3485 可作为RS485 的收发器,该芯片支持3.3V~5.5V 供电,最大传输速度可达250Kbps,支持多达256 个节点(单位负载为1/8 的条件下),并且支持输出短路保护。该芯片的框图如图37.1.2 所示:
图中A、B 总线接口,用于连接485 总线。RO 是接收输出端,DI 是发送数据收入端,RE是接收使能信号(低电平有效),DE 是发送使能信号(高电平有效)。
1. 例程功能
经过前面的学习我们知道实际的RS485 仍是串行通讯的一种电平传输方式,那么我们实际通讯时可以使用串口进行实际数据的收发处理,使用485 转换芯片将串口信号转换为485 的电平信号进行传输,本章,我们只需要配置好串口2,就可以实现正常的485 通信了,串口2 的配置和串口1 基本类似,只是串口2 的时钟来自APB1,最大频率为36Mhz。
本章将实现这样的功能:通过连接两个战舰STM32F103 的RS485 接口,然后由KEY0 控制发送,当按下一个开发板的KEY0 的时候,就发送5 个数据给另外一个开发板,并在两个开发板上分别显示发送的值和接收到的值。
2. 硬件资源
1)LED 灯
LED0 –PB5
2)USART2,用于实际的485 信号串行通讯。
3)正点原子2.8/3.5/4.3/7/10 寸TFTLCD 模块(仅限MCU 屏,16 位8080 并口驱动)
4)RS485 收发芯片TP8485/SP3485
5)开发板两块(485 半双式模式无法自收发,我们需要用两个开发板或者USB 转485 调试器+串口助手来帮助我们完成测试,大家根据自己的实际条件选择)
4. 原理图
根据我们需要实现的程序功能,我们设计电路原理如下:
从上图可以看出:开发板的串口2 和TP8485 上的引脚连接到P7 端上的端子,但不直接相连,所以测试485 功能时我们需要用跳线帽短接P7 上的两组排针使之连通。STM32F1 的PD7(U7的引脚2)控制RS485 的收发模式:
当PD7=0 的时候,为接收模式;当PD7=1 的时候,为发送模式。
这里需要注意,RS485_RE 信号和CH395Q_RST 共用PD7,所以对于我们的战舰开发板来说他们也不可以同时使用,只能分时复用。
图中的R19 和R22 是两个偏置电阻,用来保证总线空闲时,A、B 之间的电压差都会大于200mV(逻辑1)。从而避免因总线空闲时因A、B 压差不稳定,可能出现的乱码。
最后,我们用2 根导线将两个开发板RS485 端子的A 和A,B 和B 连接起来。这里注意不要接反了(A 接B),接反了会导致通讯异常。
由于485 实际上是串口通讯,我们参照串口实验一节使用类似的HAL 库驱动即可,在这里分析一下RS485 配置步骤。
1)使能串口和GPIO 口时钟
本实验用到USART2 串口,使用PA2 和PA3 作为串口的TX 和RX 脚,因此需要先使能USART2 和GPIOA 时钟。参考代码如下:
__HAL_RCC_USART2_CLK_ENABLE(); /* 使能USART2时钟*/
__HAL_RCC_GPIOA_CLK_ENABLE(); /* 使能GPIOA时钟*/
2) 串口参数初始化(波特率、字长、奇偶校验等)
HAL 库通过调用串口初始化函数HAL_UART_Init 完成对串口参数初始化,详见例程源码。
该函数通常会调用:HAL_UART_MspInit 函数来完成对串口底层的初始化,包括:串口及GPIO 时钟使能、GPIO 模式设置、中断设置等。但是本实验避免与USART1 冲突,所以把串口底层初始化没有放在HAL_UART_MspInit 函数里。
3)GPIO 模式设置(速度,上下拉,复用功能等)
GPIO 模式设置通过调用HAL_GPIO_Init 函数实现,详见本例程源码。
4)开启串口相关中断,配置串口中断优先级
本实验我们使用串口中断来接收数据。我们使用HAL _UART_Receive_IT 函数开启串口中断接收,并设置接收buffer 及其长度。通过HAL_NVIC_EnableIRQ 函数使能串口中断,通过HAL_NVIC_SetPriority 函数设置中断优先级。
5)编写中断服务函数
串口2 中断服务函数为:USART2_IRQHandler,当发生中断的时候,程序就会执行中断服务函数,在这里就可以对接收到的数据进行处理,详见本例程源码。
6)串口数据接收和发送
最后我们可以通过读写USART_DR 寄存器,完成串口数据的接收和发送,HAL 库也给我们提供了:HAL_UART_Receive 和HAL_UART_Transmit 两个函数用于串口数据的接收和发送。
大家可以根据实际情况选择使用哪种方式来收发串口数据。
1. RS485 驱动
这里我们只讲解核心代码,详细的源码请大家参考光盘本实验对应源码。RS485 驱动相关源码包括两个文件:rs485.c 和rs485.h。
为方便修改,我们在rs485.h 中使用宏定义485 相关的控制引脚和串口编号,如果需要使用其它的引脚或者串口,修改宏和串口的定义即可,它们在rs485.h 中定义,它们列出如下:
#ifndef __RS485_H
#define __RS485_H
#include "./SYSTEM/sys/sys.h"
/******************************************************************************************/
/* RS485 引脚 和 串口 定义
* 默认是针对RS485的.
*/
#define RS485_RE_GPIO_PORT GPIOD //控制发送/接收模式引脚
#define RS485_RE_GPIO_PIN GPIO_PIN_7
#define RS485_RE_GPIO_CLK_ENABLE() do{ __HAL_RCC_GPIOD_CLK_ENABLE(); }while(0) /* PD口时钟使能 */
#define RS485_TX_GPIO_PORT GPIOA //发送引脚
#define RS485_TX_GPIO_PIN GPIO_PIN_2
#define RS485_TX_GPIO_CLK_ENABLE() do{ __HAL_RCC_GPIOA_CLK_ENABLE(); }while(0) /* PA口时钟使能 */
#define RS485_RX_GPIO_PORT GPIOA//接收引脚
#define RS485_RX_GPIO_PIN GPIO_PIN_3
#define RS485_RX_GPIO_CLK_ENABLE() do{ __HAL_RCC_GPIOA_CLK_ENABLE(); }while(0) /* PA口时钟使能 */
#define RS485_UX USART2
#define RS485_UX_IRQn USART2_IRQn
#define RS485_UX_IRQHandler USART2_IRQHandler
#define RS485_UX_CLK_ENABLE() do{ __HAL_RCC_USART2_CLK_ENABLE(); }while(0) /* USART2 时钟使能 */
/******************************************************************************************/
/* 控制RS485_RE脚, 控制RS485发送/接收状态
* RS485_RE = 0, 进入接收模式
* RS485_RE = 1, 进入发送模式
*/
#define RS485_RE(x) do{ x ? \
HAL_GPIO_WritePin(RS485_RE_GPIO_PORT, RS485_RE_GPIO_PIN, GPIO_PIN_SET) : \
HAL_GPIO_WritePin(RS485_RE_GPIO_PORT, RS485_RE_GPIO_PIN, GPIO_PIN_RESET); \
}while(0)
#define RS485_REC_LEN 64 /* 定义最大接收字节数 64 */
#define RS485_EN_RX 1 /* 使能(1)/禁止(0)RS485接收 */
extern uint8_t g_RS485_rx_buf[RS485_REC_LEN]; /* 接收缓冲,最大RS485_REC_LEN个字节 */
extern uint8_t g_RS485_rx_cnt; /* 接收数据长度 */
void rs485_init( uint32_t baudrate); /* RS485初始化 */
void rs485_send_data(uint8_t *buf, uint8_t len); /* RS485发送数据 */
void rs485_receive_data(uint8_t *buf, uint8_t *len);/* RS485接收数据 */
#endif
rs485.c
#include "./BSP/RS485/rs485.h"
#include "./SYSTEM/delay/delay.h"
UART_HandleTypeDef g_rs458_handler; /* RS485控制句柄(串口) */
#ifdef RS485_EN_RX /* 如果使能了接收 */
uint8_t g_RS485_rx_buf[RS485_REC_LEN]; /* 接收缓冲, 最大 RS485_REC_LEN 个字节. */
uint8_t g_RS485_rx_cnt = 0; /* 接收到的数据长度 */
void RS485_UX_IRQHandler(void)
{
uint8_t res;
if ((__HAL_UART_GET_FLAG(&g_rs458_handler, UART_FLAG_RXNE) != RESET)) /* 接收到数据 */
{
HAL_UART_Receive(&g_rs458_handler, &res, 1, 1000);
if (g_RS485_rx_cnt < RS485_REC_LEN) /* 缓冲区未满,最大接收64字节,大于了就不管了 */
{
g_RS485_rx_buf[g_RS485_rx_cnt] = res; /* 记录接收到的值 */
g_RS485_rx_cnt++; /* 接收数据增加1 */
}
}
}
#endif
/**
* @brief RS485初始化函数
* @note 该函数主要是初始化串口
* @param baudrate: 波特率, 根据自己需要设置波特率值
* @retval 无
*/
void rs485_init(uint32_t baudrate)
{
/* IO 及 时钟配置 */
RS485_RE_GPIO_CLK_ENABLE(); /* 使能 RS485_RE 脚时钟 */
RS485_TX_GPIO_CLK_ENABLE(); /* 使能 串口TX脚 时钟 */
RS485_RX_GPIO_CLK_ENABLE(); /* 使能 串口RX脚 时钟 */
RS485_UX_CLK_ENABLE(); /* 使能 串口 时钟 */
GPIO_InitTypeDef gpio_initure;
gpio_initure.Pin = RS485_TX_GPIO_PIN;
gpio_initure.Mode = GPIO_MODE_AF_PP; //复用推挽输出
gpio_initure.Pull = GPIO_PULLUP;
gpio_initure.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(RS485_TX_GPIO_PORT, &gpio_initure); /* 串口TX 脚 模式设置 */
gpio_initure.Pin = RS485_RX_GPIO_PIN;
gpio_initure.Mode = GPIO_MODE_AF_INPUT;
HAL_GPIO_Init(RS485_RX_GPIO_PORT, &gpio_initure); /* 串口RX 脚 必须设置成输入模式 */
gpio_initure.Pin = RS485_RE_GPIO_PIN;
gpio_initure.Mode = GPIO_MODE_OUTPUT_PP;
gpio_initure.Pull = GPIO_PULLUP;
gpio_initure.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(RS485_RE_GPIO_PORT, &gpio_initure); /* RS485_RE 脚 模式设置 */
/* USART 初始化设置 */
g_rs458_handler.Instance = RS485_UX; /* 选择485对应的串口 */
g_rs458_handler.Init.BaudRate = baudrate; /* 波特率 */
g_rs458_handler.Init.WordLength = UART_WORDLENGTH_8B; /* 字长为8位数据格式 */
g_rs458_handler.Init.StopBits = UART_STOPBITS_1; /* 一个停止位 */
g_rs458_handler.Init.Parity = UART_PARITY_NONE; /* 无奇偶校验位 */
g_rs458_handler.Init.HwFlowCtl = UART_HWCONTROL_NONE; /* 无硬件流控 */
g_rs458_handler.Init.Mode = UART_MODE_TX_RX; /* 收发模式 */
HAL_UART_Init(&g_rs458_handler); /* HAL_UART_Init()会使能UART2 */
#if RS485_EN_RX /* 如果使能了接收 */
/* 使能接收中断 */
__HAL_UART_ENABLE_IT(&g_rs458_handler, UART_IT_RXNE); /* 开启接收中断 */
HAL_NVIC_EnableIRQ(RS485_UX_IRQn); /* 使能USART2中断 */
HAL_NVIC_SetPriority(RS485_UX_IRQn, 3, 3); /* 抢占优先级3,子优先级3 */
#endif
RS485_RE(0); /* 默认为接收模式 */
}
/**
* @brief RS485发送len个字节
* @param buf : 发送缓存区首地址
* @param len : 发送的字节数(为了和本代码的接收匹配,这里建议不要超过 RS485_REC_LEN 个字节)
* @retval 无
*/
void rs485_send_data(uint8_t *buf, uint8_t len)
{
RS485_RE(1); /* 进入发送模式 */
HAL_UART_Transmit(&g_rs458_handler, buf, len, 1000); /* 串口2发送数据 */
g_RS485_rx_cnt = 0;
RS485_RE(0); /* 进入接收模式 */
}
/**
* @brief RS485查询接收到的数据
* @param buf : 接收缓冲区首地址
* @param len : 接收到的数据长度
* @arg 0 , 表示没有接收到任何数据
* @arg 其他, 表示接收到的数据长度
* @retval 无
*/
void rs485_receive_data(uint8_t *buf, uint8_t *len)
{
uint8_t rxlen = g_RS485_rx_cnt;
uint8_t i = 0;
*len = 0; /* 默认为0 */
delay_ms(10); /* 等待10ms,连续超过10ms没有接收到一个数据,则认为接收结束 */
if (rxlen == g_RS485_rx_cnt && rxlen) /* 接收到了数据,且接收完成了 */
{
for (i = 0; i < rxlen; i++)
{
buf[i] = g_RS485_rx_buf[i];
}
*len = g_RS485_rx_cnt; /* 记录本次数据长度 */
g_RS485_rx_cnt = 0; /* 清零 */
}
}
1)rs485_init 函数
rs485_inti 的配置与串口类似,也需要设置波特率等参数,另外还需要配置收发模式的驱动引脚。
可以看到代码基本跟串口的配置一样,只是多了收发控制引脚的配置。
2)发送函数
发送函数用于输出485 信号到485 总线上,我的默认的485 方式一般空闲时为接收状态,只有发送数据时我们才控制485 芯片进入发送状态,发送完成后马上回到空闲接收状态,这样可以保证操作过程中485 的数据丢失最小。
3)485 接收中断函数
RS485 的接收就与串口中断一样了,不过要注意空闲时要切换回接收状态,否则会收不到数据。我们定义了一个全局的缓冲区g_RS485_rx_buf 进行接收测试,通过串口中断接收数据。
4)485 查询接收数据函数
该函数用于查询485 总线上接收到的数据,主要实现的逻辑是:一开始进入函数时,先记录下当前接收计数器的值,再来一个延时去判断接收是否结束(即该期间有无接收到数据),假如说接收计数器的值没有改变,就证明接收结束,我们就可以把当前接收缓冲区传递出去。
RS485 的代码就讲到这里,基本是串口的知识,大家不明白的配置可以翻看之前串口章节的知识。
2. main.c 代码
在main.c 中编写如下代码:
#include "./SYSTEM/sys/sys.h"
#include "./SYSTEM/usart/usart.h"
#include "./SYSTEM/delay/delay.h"
#include "./USMART/usmart.h"
#include "./BSP/LED/led.h"
#include "./BSP/LCD/lcd.h"
#include "./BSP/KEY/key.h"
#include "./BSP/RS485/rs485.h"
int main(void)
{
uint8_t key;
uint8_t i = 0, t = 0;
uint8_t cnt = 0;
uint8_t rs485buf[5];
HAL_Init(); /* 初始化HAL库 */
sys_stm32_clock_init(RCC_PLL_MUL9); /* 设置时钟, 72Mhz */
delay_init(72); /* 延时初始化 */
usart_init(115200); /* 串口初始化为115200 */
usmart_dev.init(72); /* 初始化USMART */
led_init(); /* 初始化LED */
lcd_init(); /* 初始化LCD */
key_init(); /* 初始化按键 */
rs485_init(9600); /* 初始化RS485 */
lcd_show_string(30, 50, 200, 16, 16, "STM32", RED);
lcd_show_string(30, 70, 200, 16, 16, "RS485 TEST", RED);
lcd_show_string(30, 90, 200, 16, 16, "ATOM@ALIENTEK", RED);
lcd_show_string(30, 110, 200, 16, 16, "KEY0:Send", RED); /* 显示提示信息 */
lcd_show_string(30, 130, 200, 16, 16, "Count:", RED); /* 显示当前计数值 */
lcd_show_string(30, 150, 200, 16, 16, "Send Data:", RED); /* 提示发送的数据 */
lcd_show_string(30, 190, 200, 16, 16, "Receive Data:", RED);/* 提示接收到的数据 */
while (1)
{
key = key_scan(0);
if (key == KEY0_PRES) /* KEY0按下,发送一次数据 */
{
for (i = 0; i < 5; i++)
{
rs485buf[i] = cnt + i; /* 填充发送缓冲区 */
lcd_show_xnum(30 + i * 32, 170, rs485buf[i], 3, 16, 0X80, BLUE); /* 显示数据 */
}
rs485_send_data(rs485buf, 5); /* 发送5个字节 */
}
rs485_receive_data(rs485buf, &key);
if (key) /* 接收到有数据 */
{
if (key > 5) key = 5; /* 最大是5个数据. */
for (i = 0; i < key; i++)
{
lcd_show_xnum(30 + i * 32, 210, rs485buf[i], 3, 16, 0X80, BLUE); /* 显示数据 */
}
}
t++;
delay_ms(10);
if (t == 20)
{
LED0_TOGGLE(); /* LED0闪烁, 提示系统正在运行 */
t = 0;
cnt++;
lcd_show_xnum(30 + 48, 130, cnt, 3, 16, 0X80, BLUE); /* 显示数据 */
}
}
}
我们是通过按键控制数据的发送。在此部分代码中,cnt 是一个累加数,一旦KEY0 按下,就以这个数位基准连续发送5 个数据。当485 总线收到数据得时候,就将收到的数据直接显示在LCD 屏幕上。
在代码编译成功之后,我们通过下载代码到正点原子战舰STM32F103 上(注意要2 个开发板都下载这个代码),得到如图37.4.1 所示:
伴随DS0 的不停闪烁,提示程序在运行。此时,我们按下KEY0 就可以在另外一个开发板上面收到这个开发板发送的数据了。如图37.4.2 和图37.4.3 所示:
图37.4.2 来自开发板A,发送了5 个数据,图37.4.3 来自开发板B,接收到了来自开发板A 的5 个数据。
本章介绍的485 总线时通过串口控制收发的,我们只需要将P7 的跳线帽稍作改变(将PA2/PA3 连接COM2_RX/COM2_TX),该实验就变成了一个RS232 串口通信实验了,通过对接两个开发板的RS232 接口,即可得到同样的实验现象,不过RS232 不需要使能脚,有兴趣的读者可以实验一下。
另外,利用USMART 测试的部分,我们这里就不做介绍了,大家可自行验证下。
IAP,即在应用编程,通俗地说法就是“程序升级”。产品阶段设计完成后,在脱离实验室
的调试环境下,如果想对产品做功能升级或BUG 修复会十分麻烦,如果硬件支持,在出厂时预留一套升级固件的流程,就可以很好解决这个问题,IAP 技术就是为此而生的。在之前的FLASH模拟EEPROM 实验里面,我们学习了STM32F103 的FLASH 自编程,本章我们将结合FLASH自编程的知识,通过STM32F103 的串口实现一个简单的IAP 功能。
IAP(In Application Programming)即在应用编程。在讲解STM32 的启动模式时我们已经知道STM32 可以通过设置MSP 的方式从不同的地址启动:包括Flash 地址、RAM 地址等,在默认方式下,我们的嵌入式程序是以连续二进制的方式烧录到STM32 的可寻址Flash 区域上的。
如果我们用的Flash 容量大到可以存储两个或多个的完整程序,在保证每个程序完整的情况下,上电后的程序通过修改MSP 的方式,就可以保证一个单片机上有多个有功能差异的嵌入式软件,这就是我们要讲解的IAP 的设计思路。
IAP 是用户自己的程序在运行过程中对User Flash 的部分区域进行烧写,目的是为了在产
品发布后可以方便地通过预留的通信口对产品中的固件程序进行更新升级,由于用户可以自定义通讯方式和自定义加密,使得IAP 在使用上非常灵活。通常实现IAP 功能时,即用户程序运行中作自身的更新操作,需要在设计固件程序时编写两个项目代码,第一个程序检查有无升级需求,并通过某种通信方式(如USB、USART)接收程序或数据,执行对第二部分代码的更新;
第二个项目代码才是真正的功能代码。这两部分项目代码都同时烧录在User Flash 中,当芯片上电后,首先是第一部分代码开始运行,它做如下操作:
第一部分代码必须通过其它手段,如JTAG、ISP 等方式烧录,常常是烧录后就不再进行更
改;第二部分代码可以使用第一部分代码IAP 功能烧入,也可以和第一部分代码一起烧入,以后需要程序更新时再通过第一部分IAP 代码更新。
我们将第一个项目代码称之为Bootloader 程序,第二个项目代码称之为APP 程序,他们存
放在STM32F103 内部FLASH 的不同地址范围,一般从最低地址区开始存放Bootloader,紧跟其后的就是APP 程序(注意,如果FLASH 容量足够,是可以设计很多APP 程序的,本章我们只讨论一个APP 程序的情况)。这样我们就是要实现两个程序:Bootloader 和APP。
STM32F1的APP 程序不仅可以放到FLASH 里面运行,也可以放到SRAM 里面运行,本
章,我们将制作两个APP,一个用于FLASH 运行,一个用于内部SRAM 运行。
我们先来看看STM32F1 正常的程序运行流程(为了方便说明IAP 过程,我们先仅考虑代
码全部存放在内部FLASH 的情况),如图59.1.1 所示:
STM32F1 的内部闪存(FLASH)地址起始于0X0800 0000,一般情况下,程序文件就从此
地址开始写入。此外STM32F103 是基于Cortex-M3 内核的微控制器,其内部通过一张“中断向量表”来响应中断,程序启动后,将首先从“中断向量表”取出复位中断向量执行复位中断程
序完成启动,而这张“中断向量表”的起始地址是0x08000004,当中断来临,STM32F103 的内部硬件机制亦会自动将PC 指针定位到“中断向量表”处,并根据中断源取出对应的中断向量执行中断服务程序。
在图59.1.1 中,STM32F103 在复位后,先从0X08000004 地址取出复位中断向量的地址,
并跳转到复位中断服务程序,如图标号①所示;在复位中断服务程序执行完之后,会跳转到我们的main 函数,如图标号②所示;而我们的main 函数一般都是一个死循环,在main 函数执行过程中,如果收到中断请求(发生了中断),此时STM32F103 强制将PC 指针指回中断向量表处,如图标号③所示;然后,根据中断源进入相应的中断服务程序,如图标号④所示;在执行完中断服务程序以后,程序再次返回main 函数执行,如图标号⑤所示。
当加入IAP 程序之后,程序运行流程如图59.1.2 所示:
在图59.1.2 所示流程中,STM32F103 复位后,还是从0X08000004 地址取出复位中断向量
的地址,并跳转到复位中断服务程序,在运行完复位中断服务程序之后跳转到IAP 的main 函数,如图标号①所示,此部分同图59.1.1 一样;在执行完IAP 以后(即将新的APP 代码写入STM32F103 的FLASH,灰底部分。新程序的复位中断向量起始地址为0X08000004+N+M),跳转至新写入程序的复位向量表,取出新程序的复位中断向量的地址,并跳转执行新程序的复位中断服务程序,随后跳转至新程序的main 函数,如图标号②和③所示,同样main 函数为一个死循环,并且注意到此时STM32F103 的FLASH,在不同位置上,共有两个中断向量表。
在main 函数执行过程中,如果CPU 得到一个中断请求,PC 指针仍然会强制跳转到地址
0X08000004 中断向量表处,而不是新程序的中断向量表,如图标号④所示;程序再根据我们设置的中断向量表偏移量,跳转到对应中断源新的中断服务程序中,如图标号⑤所示;在执行完中断服务程序后,程序返回main 函数继续运行,如图标号⑥所示。
通过以上两个过程的分析,我们知道IAP 程序必须满足两个要求:
对STM32F1 系列来说,闪存编程一次可以写入16 位(半字)。闪存擦除操作可以按页面擦
除或完全擦除(全擦除)。全擦除不影响信息块。根据类别的不同,Flash 有如下区别:
⚫ 小容量产品主存储块最大为4K×64 位,每个存储块划分为32 个1K 字节的页。
⚫ 中容量产品主存储块最大为16K×64 位,每个存储块划分为128 个1K 字节的页。
⚫ 大容量产品主存储块最大为64K×64 位,每个存储块划分为256 个2K 字节的页。
⚫ 互联型产品主存储块最大为32K×64 位,每个存储块划分为128 个2K 字节的页
使用时我们需要根据自己的芯片型号来选择,设计IAP 程序时需要严格避免不同的程序占
用相同Flash 扇区的情形。
本章,我们有2 个APP 程序:
1,FLASH APP 程序,即只运行在内部FLASH 的APP 程序。
2,SRAM APP 程序,即只运行在内部SRAM 的APP 程序,其运行过程和图59.1.2 相似,
不过需要设置向量表的地址为SRAM 的地址。
APP 我们使用以前的例程即可,不过需要对程序进行修改,
默认的条件下,图中IROM1 的起始地址(Start)一般为0x08000000,大小(Size)为0x80000,即从0x08000000 开始的512K 空间为我们的程序存储区。
图59.1.3 中,我们设置起始地址(Start)为0X08010000,即偏移量为0x10000(64K 字节,即留给BootLoader 的空间),因而,留给APP 用的FLASH 空间(Size)为0x80000-0x10000=0x70000(448K 字节)大小了。设置好Start 和Szie,就完成APP 程序的起始地址设置。IRAM 是内存的地址,APP 可以独占这些内存,我们不需要修改。
注意:需要确保APP 起始地址在Bootloader 程序结束位置之后,并且偏移量为0X200 的倍
数即可(相关知识,请参考:http://www.openedv.com/posts/list/392.htm)。
这是针对FLASH APP 的起始地址设置,如果是SRAM APP,那么起始地址设置如图59.1.4
所示:
这里我们将IROM1 的起始地址(Start)定义为:0X20001000,大小为0XD000(52K 字节),即从地址0X20000000 偏移0X1000 开始,存放SRAM APP 代码。这个分配关系大家可以根据自己的实际情况修改,由于STM32F103ZE 只有一个64K 的片内SRAM,存放程序的位置与变量的加载位置不能重复,所以我们需要设置IRAM1 中的地址到SRAM 程序空间之外。
关于APP 起始地址的设置方法,我们就介绍到这里,大家可以根据自己项目的实际需求进
行修改。
VTOR 寄存器存放的是中断向量表的起始地址。默认的情况它由BOOT 的启动模式决定,
对于F103 来说就是指向0x0800 0000 这个位置,也就是从默认的启动位置加载中断向量等信息,不过ST 允许重定向这个位置,这样就可以从Flash 区域的任意位置启动我们的代码了。我们可以通过调用sys.c 里面的sys_nvic_set_vector_table 函数实现,该函数定义如下:
/**
* @brief 设置中断向量表偏移地址
* @param baseaddr: 基址
* @param offset: 偏移量
* @retval 无
*/
void sys_nvic_set_vector_table(uint32_t baseaddr, uint32_t offset)
{
/* 设置NVIC的向量表偏移寄存器,VTOR低9位保留,即[8:0]保留*/
SCB->VTOR = baseaddr | (offset & (uint32_t)0xFFFFFE00);
}
该函数用于设置中断向量偏移,baseaddr 为基地址(即APP 程序首地址),Offset 为偏移量,需要根据自己的实际情况进行设置。比如FLASH APP 设置中断向量表偏移量为0x10000,调用情况如下:
/* 设置中断向量表偏移量为0X10000 */
sys_nvic_set_vector_table(FLASH_BASE,0x10000);
这是设置FLASH APP 的情况,SRAM APP 的情况可以参考触摸屏实验_SRAM APP 版本,
其具体的调用情况请看到main 函数。
通过以上两个步骤的设置,我们就可以生成APP 程序了,只要APP 程序的FLASH 和SRAM
大小不超过我们的设置即可。不过MDK 默认生成的文件是.hex 文件,并不方便我们用作IAP
更新,我们希望生成的文件是.bin 文件,这样可以方便进行IAP 升级(至于为什么,请大家自行百度HEX 和BIN 文件的区别!)。这里我们通过MDK 自带的格式转换工具fromelf.exe,如果安装在C 盘的默认路径,它的位置是C:\Keil_v5\ARM\ARMCC\bin\fromelf.exe,来实现.axf 文件
到.bin 文件的转换。该工具在MDK 的安装目录\ARM\ARMCC\bin 文件夹里面。
fromelf.exe 转换工具的语法格式为:fromelf [options] input_file。其中options 有很多选项可
以设置,详细使用请参考光盘《mdk 如何生成bin 文件.doc》。
本实验,我们可以通过在MDK 点击Options for Target→User 选项卡,在After Build/Rebuild
一栏中,勾选Run #1,我们推荐使用相对地址,在勾选的同一行后的输入框并写入命令行:
fromelf --bin -o …\[email protected] …\Output%L,如图59.1.7 所示:
通过这一步设置,我们就可以在MDK 编译成功之后,调用fromelf.exe,…\Output%L 表
示当前编译的链接文件(…\是相对路径,表示上级目录,编译器默认从工程文件*.uvprojx 开始查找,根据我的工程文件Output 的位置就能明白路径的含义),指令–bin –o …\[email protected]表示在Output 目录下生成一个.bin 文件,@L 在Keil 的下表示Output 选项卡下的Name of Executable 后面的字符串,即在Output 文件夹下生成一个atk_f103.bin 文件。在得到.bin 文件之后,我们只需要将这个bin 文件传送给单片机,即可执行IAP 升级。
最后来看看APP 程序的生成步骤:
1)设置APP 程序的起始地址和存储空间大小
对于在FLASH 里面运行的APP 程序,我们只需要设置APP 程序的起始地址,和存储空间大小即可。而对于在SRAM 里面运行的APP 程序,我们还需要设置SRAM 的起始地址和大小。
无论哪种APP 程序,都需要确保APP 程序的大小和所占SRAM 大小不超过我们的设置范围。
2)设置中断向量表偏移量
此步,通过调用sys_nvic_set_vector_table 函数,实现对中断向量表偏移量的设置。这个偏
移量的大小,其实就等于程序起始地址相对于0X08000000 或者0X24000000 的偏移。
3)设置编译后运行fromelf.exe,生成.bin 文件
通过在User 选项卡,设置编译后调用fromelf.exe,根据.axf 文件生成.bin 文件,用于IAP
更新。
以上3 个步骤,就可以得到一个.bin 的APP 程序,通过Bootlader 程序即可实现更新。
1. 例程功能
本章实验(Bootloader 部分)功能简介:开机的时候先显示提示信息,然后等待串口输入接
收APP 程序(无校验,一次性接收),在串口接收到APP 程序之后,即可执行IAP。如果是
SRAM APP,通过按下KEY0 即可执行这个收到的SRAM APP 程序。如果是FLASH APP,则需要先按下KEY_UP 按键,将串口接收到的APP 程序存放到STM32F1 的FLASH,之后再按KEY1 即可以执行这个FLASH APP 程序。通过KEY2 按键,可以手动清除串口接收到的APP程序。DS0 用于指示程序运行状态。
2. 硬件资源
1)LED 灯
DS0 :LED0 –PB5
2)串口1(PA9/PA10 连接在板载USB 转串口芯片CH340 上面)
3)正点原子2.8/3.5/4.3/7/10 寸TFTLCD 模块(仅限MCU 屏,16 位8080 并口驱动)
4)独立按键:KEY0 –PE4、KEY1 –PE3、WK_UP - PA0
IAP 我们设置为有按键才跳转的方式,可以用串口接收不同的APP,再根据按键选择跳转
到具体的APP(Flash APP 或者SRAM APP),方便我们进行验证和记忆。
本实验,我们总共需要3 个程序(1 个IAP,2 个APP):
1、FLASH IAP Bootloader,起始地址为0X08000000,设置为我们用于升级的跳转的程序,我们将用串口1 来作数据接收程序,通过按键功能手动跳转到指定APP。
2、FLASH APP,仅使用STM32 内部FLASH,大小为112KB。本程序使用:实验16 USMART调试实验,作为FLASH APP 程序(起始地址为0X08010000)
3、SRAM APP,使用STM32 内部SRAM,我们使用-O2 优化,生成的bin 大小为49KB。
本程序使用:实验25 触摸屏实验,作为SRAM APP 程序(起始地址为0X20001000)。
本章关于APP 程序的生成和修改比较简单,我们就不细说,请大家结合光盘源码以及59.1
节的介绍,自行理解。本章程序解析小节仅针对Bootloader 程序。
这里我们只讲解核心代码,详细的源码请大家参考光盘本实验对应源码,IAP 的驱动主要
包括两个文件:iap.c 和iap.h。
由于STM32 芯片的Flash 的容量一般要比SRAM 要大,所以我们只编写对Flash 的写功能
和对MSP 的设置功能以实现程序的跳转。写STM32 内部Flash 的功能我们用到STM32 的Flash操作,通过封装之前《实验33 FLASH 模拟EEPROM 实验》的驱动,我们实现IAP 的写Flash操作如下:
/**
* @brief IAP写入APP BIN
* @param appxaddr : 应用程序的起始地址
* @param appbuf : 应用程序CODE
* @param appsize : 应用程序大小(字节)
* @retval 无
*/
void iap_write_appbin(uint32_t appxaddr, uint8_t *appbuf, uint32_t appsize)
{
uint16_t t;
uint16_t i = 0;
uint16_t temp;
uint32_t fwaddr = appxaddr; /* 当前写入的地址*/
uint8_t *dfu = appbuf;
for (t = 0; t < appsize; t += 2)
{
temp = (uint16_t)dfu[1] << 8;
temp |= (uint16_t)dfu[0];
dfu += 2; /* 偏移2个字节*/
g_iapbuf[i++] = temp;
if (i == 1024)
{
i = 0;
stmflash_write(fwaddr, g_iapbuf, 1024);
fwaddr += 2048; /* 偏移2048 16 = 2 * 8 所以要乘以2 */
}
}
if (i)
{
stmflash_write(fwaddr, g_iapbuf, i); /* 将最后的一些内容字节写进去*/
}
}
在保存了一个完整的APP 到了对应的位置后,我们需要对栈顶进行检查操作,初步检查程
序设置正确再进行跳转。我们以Flash APP 为例,用bin 文件查看工具(A 盘→6,软件资料→1,软件→winhex),可以看到bin 的内容默认为小端结构,如图59.3.2.1 所示。
我们利用stm32 的bin 文件的特性,按32 位取的数据,开始的第一个字为SP 的地址,第二
个为Reset_Handler 的地址,我们利用这个特性在跳转前做一个初步的判定,然后设置主堆栈,这部分我们用到sys.c 下的嵌入汇编函数sys_msr_msp(),我们实现代码如下:
/**
* @brief 跳转到应用程序段(执行APP)
* @param appxaddr : 应用程序的起始地址
* @retval 无
*/
void iap_load_app(uint32_t appxaddr)
{
if (((*(volatile uint32_t *)appxaddr) & 0x2FFE0000) == 0x20000000)
{/* 检查栈顶地址是否合法.可以放在内部SRAM共64KB(0x20000000) */
/* 用户代码区第二个字为程序开始地址(复位地址) */
jump2app = (iapfun) * (volatile uint32_t *)(appxaddr + 4);
/* 初始化APP堆栈指针(用户代码区的第一个字用于存放栈顶地址) */
sys_msr_msp(*(volatile uint32_t *)appxaddr);
/* 跳转到APP */
jump2app();
}
}
根据我们流程图的设想,所以我们需要用到LCD、串口、按键和stm32 内部Flash 的操作,
所以我们通过复制以前的《FLASH 模拟EEPROM 实验》来修改,重命名为《串口IAP 实验》,工程内的组重命名为IAP。
我们需要修改串口接收部分的程序,为了便于测试,我们定义一个大的接收数组
g_usart_rx_buf[USART_REC_LEN],并保证这个数组能接收并缓存一个完整的bin 文件,程序中我们定了了这个大小为55KB,因为我们有SRAM 程序(优化后为49KB),所以把这部分的数组放用__attribute__ ((at(0X20001000)))直接放到SRAM 程序的位置,这样接收完成的SRAM 程序后我们直接跳转就可以了。
uint8_t g_usart_rx_buf[USART_REC_LEN] __attribute__ ((at(0X20001000)));
接收的数据处理方法与我们之前的串口处理方式类似,大家查看串口接收处理的源码即可。
我们把接收标记的处理放在main.c 中处理,具体如下:
int main(void)
{
uint8_t t;
uint8_t key;
uint32_t oldcount = 0; /* 老的串口接收数据值*/
uint32_t applenth = 0; /* 接收到的app代码长度*/
uint8_t clearflag = 0;
HAL_Init(); /* 初始化HAL库*/
sys_stm32_clock_init(RCC_PLL_MUL9); /* 设置时钟, 72Mhz */
delay_init(72); /* 延时初始化*/
usart_init(115200); /* 串口初始化为115200 */
led_init(); /* 初始化LED */
lcd_init(); /* 初始化LCD */
key_init(); /* 初始化按键*/
lcd_show_string(30, 50, 200, 16, 16, "STM32", RED);
lcd_show_string(30, 70, 200, 16, 16, "IAP TEST", RED);
lcd_show_string(30, 90, 200, 16, 16, "ATOM@ALIENTEK", RED);
lcd_show_string(30, 110, 200, 16, 16, "KEY_UP: Copy APP2FLASH!", RED);
lcd_show_string(30, 130, 200, 16, 16, "KEY1: Run FLASH APP", RED);
lcd_show_string(30, 150, 200, 16, 16, "KEY0: Run SRAM APP", RED);
while (1)
{
if (g_usart_rx_cnt)
{
if (oldcount == g_usart_rx_cnt)
{ /* 新周期内,没有收到任何数据,认为本次数据接收完成*/
applenth = g_usart_rx_cnt;
oldcount = 0;
g_usart_rx_cnt = 0;
printf("用户程序接收完成!\r\n");
printf("代码长度:%dBytes\r\n", applenth);
}
else oldcount = g_usart_rx_cnt;
}
t++;
delay_ms(100);
if (t == 3)
{
LED0_TOGGLE();
t = 0;
if (clearflag)
{
clearflag--;
if (clearflag == 0)
{
lcd_fill(30, 190, 240, 210 + 16, WHITE); /* 清除显示*/
}
}
}
key = key_scan(0);
if (key == WKUP_PRES) /* WKUP按下,更新固件到FLASH */
{
if (applenth)
{
printf("开始更新固件...\r\n");
lcd_show_string(30, 190, 200, 16, 16, "Copying APP2FLASH...", BLUE);
if (((*(volatile uint32_t *)(0X20001000 + 4)) & 0xFF000000) == 0x08000000) /* 判断是否为0X08XXXXXX */
{/* 更新FLASH代码*/
iap_write_appbin(FLASH_APP1_ADDR, g_usart_rx_buf, applenth);
lcd_show_string(30,190,200,16,16,"Copy APP Successed!!", BLUE);
printf("固件更新完成!\r\n");
}
else
{
lcd_show_string(30,190,200,16,16,"Illegal FLASH APP! ", BLUE);
printf("非FLASH应用程序!\r\n");
}
}
else
{
printf("没有可以更新的固件!\r\n");
lcd_show_string(30, 190, 200, 16, 16, "No APP!", BLUE);
}
clearflag = 7; /* 标志更新了显示,并且设置7*300ms后清除显示*/
}
if (key == KEY1_PRES) /* KEY1按键按下, 运行FLASH APP代码*/
{
if (((*(volatile uint32_t *)(FLASH_APP1_ADDR + 4)) & 0xFF000000) ==
0x08000000) /* 判断FLASH里面是否有APP,有的话执行*/
{
printf("开始执行FLASH用户代码!!\r\n\r\n");
delay_ms(10);
iap_load_app(FLASH_APP1_ADDR);/* 执行FLASH APP代码*/
}
else
{
printf("没有可以运行的固件!\r\n");
lcd_show_string(30, 190, 200, 16, 16, "No APP!", BLUE);
}
clearflag = 7; /* 标志更新了显示,并且设置7*300ms后清除显示*/
}
if (key == KEY0_PRES) /* KEY0按下*/
{
printf("开始执行SRAM用户代码!!\r\n\r\n");
delay_ms(10);
if (((*(volatile uint32_t *)(0x20001000 + 4)) & 0xFF000000) ==
0x20000000) /* 判断是否为0X20XXXXXX */
{
iap_load_app(0x20001000); /* SRAM地址*/
}
else
{
printf("非SRAM应用程序,无法执行!\r\n");
lcd_show_string(30, 190, 200, 16, 16, "Illegal SRAM APP!", BLUE);
}
clearflag = 7; /* 标志更新了显示,并且设置7*300ms后清除显示*/
}
}
}
APP 代码我们在这里就不做介绍了,大家可以参考本例程提供的源代码,注意在mian 函
数起始处重新设置中断向量表(寄存器SCB-> VTOR)的偏移量,否则APP 无法正常运行,仍以Flash APP 为例,我们编译通过后执行了fromelf.exe 生成bin 文件,如图59.3.2.6 所示:
将程序下载到开发板后,可以看到LCD 首先显示一些实验相关的信息,如图59.4.1 所示:
此时,我们可以通过XCOM,发送FLASH APP、SRAM APP 到开发板,我们以FLASH APP
为例进行演示,如图59.4.2 所示:
首先找到开发板USB 转串口的串口号,打开串口(我电脑是COM15),然后设置波特率为
115200 并打开串口,然后,点击打开文件按钮(图中标号3 所示),找到APP 程序生成的.bin文件(注意:文件类型得选择所有文件!默认是只打开txt 文件的),最后点击发送文件(图中标号4 所示),将.bin 文件发送给STM32 开发板,发送完成后,XCOM 会提示文件发送完毕(图中标号5 所示)。
开发板收到APP 程序之后会打印提示信息,我们可以根据发送的数据与开发板的提示信息
确认开发板接收到的bin 文件是否完整,我们就可以通过KEY0/KEY1 运行这个APP 程序了
(如果是FLASH APP,需要通过KEY1 将其存入对应FLASH 区域),此时我们根据程序设计,按下KEY1 即可执行FLASH APP 程序,更新SRAM APP 的过程类似,大家自行测试即可。