STM32 作为单片机开发的代表之一,其众多理论和方法值得学习,本文是其学习/使用笔记。本文以 STM32F103C8T6 为例,大量参考了其官方手册,其它型号的芯片也有类似的手册。
官方手册
阅读官方手册是个好习惯,如果对官方手册比较熟悉,理解比较透彻,则通常会较少遇到问题,遇到问题也有大概的定位思路,尤其是所谓的“玄学”问题,常常能在其中找到答案,避免浪费大量时间在网上检索。
STM32 官方的手册做得非常优秀,这是国外厂商的特色。其手册分门别类、事无巨细、目录清晰,非常容易阅读,唯一的问题是全英文的,读起来有点费劲,好在国内出了不少翻译版本,但需要注意的是,翻译版本不一定准确,所以尽量还是读原文。
官方手册通常格外长,而此时其目录就显得非常重要了,我们只需要挑选我们感兴趣的章节来阅读,但最好还是通读一遍,因为它前面可能教给了你阅读的方法,包括缩略语、该手册适用于哪些芯片、哪些芯片具备哪些功能、对于某个功能哪些是必读章节等。
官方手册主要分为几大类:
- 数据手册(Data Sheet):这类手册准确描述了特定型号的功能、性能指标,如 Flash、SRAM 大小等,比如对于 STM32F103C8T6 而言,其数据手册中就指出了 Flash 大小为 64KB,SRAM 大小为 20KB。
- 参考手册(Reference Manual):以 RM 开头,后面接 4 位编号,如 STM32F103C8T6 的手册为 RM0008。这类手册说明了某个系列芯片的系统架构、内存组织、总线结构以及各种外设的使用方法,非常详细,通常上千页。
- 编程手册(Program Manual):以 PM 开头,后面接 4 位编号,如 PM0075。这类手册说明了如何操作内部 Flash。
- 应用笔记(Application Note):以 AN 开头,后面接 4 位编号,如 AN4657 等。这类手册指出了特定应用如何实现,比如 AN4657 说明了如何使用 USART 实现 IAP。
- 用户手册(User Manual):以 UM 开头,后面接 4 位编号,如 UM1850 等。这类手册是相关组件的用户手册,说明此组件的使用方法,如 UM1850 就是对 STM32F1 系列 HAL 库和 LL 库的详细说明。
官方库
对于 STM32 芯片的驱动,官方提供了三种库:标准库 SPL(Standard Peripheral Library)、HAL(Hardware Abstraction Layer)、LL(Low-Layer Library),下面列表总结:
特性 | 标准库 (SPL) | HAL 库 | LL 库 |
---|---|---|---|
抽象层次 | 低层次,直接操作寄存器 | 高层次,硬件抽象 | 中层次,轻量级寄存器封装 |
代码量 | 小 | 大 | 较小 |
执行效率 | 高 | 较低 | 高 |
开发效率 | 低(需手动配置寄存器) | 高(提供高级 API,简化开发) | 中(比标准库简单,比 HAL 库更接近硬件) |
可移植性 | 低(不同型号需修改代码) | 高(支持跨 STM32 系列移植) | 中(比标准库更易移植) |
维护和支持 | 已停止更新 | 官方推荐,持续更新 | 官方支持,持续更新 |
适用场景 | 对性能要求高,且熟悉寄存器的开发者 | 快速开发、跨平台移植、使用中间件的场景 | 对性能有要求,同时希望简化开发的场景 |
中间件支持 | 无 | 丰富(USB、文件系统、RTOS 等) | 无 |
学习曲线 | 高(需熟悉寄存器操作) | 低(提供高级 API,易于上手) | 中(需了解寄存器,但比标准库简单) |
代码示例 | 较少(官方已停止支持) | 丰富(官方提供大量示例和文档) | 较多(官方提供支持) |
选择建议:
- 标准库 (SPL)(Standard Peripheral Library): 适合对性能要求极高且熟悉寄存器的开发者,但已逐渐被淘汰。
- HAL 库(Hardware Abstraction Layer): 适合初学者、快速开发、跨平台移植或需要使用中间件的场景。
- LL 库(Low-Layer Library): 适合对性能有要求,同时希望代码具备一定可移植性和简洁性的场景。
我主要使用 HAL 库,本文也以 HAL 库为主,LL 库为辅。
启动过程
STM32 的启动过程可以简述如下(以 STM32F103C8T6 为例):
- 读取 BOOT1 和 BOOT0 引脚的值,决定启动方式:
- X0: 从内部 Flash 启动,启动地址为 0x800 0000。
- 01: 从 System Memory 启动地址为 0x1FFF F000。
- 11:从 SRAM 启动,启动地址为 0x2000 0000。
详情参见官方 Reference Manual (如 RM0008)的 Boot Configuration 章节(如 3.4 章节)。
- 将启动地址开始的前 4 字节的值作为栈顶指针,之后 4 字节的值为 Reset_Handler 函数的地址,即复位中断向量,也是中断向量表的起始地址。
- Reset_Handler 函数中默认会先调用 SystemInit 函数,该函数中可以调整中断向量表的位置,相关代码片段如下:
1 2 3 4
/* Configure the Vector Table location -------------------------------------*/ #if defined(USER_VECT_TAB_ADDRESS) SCB->VTOR = VECT_TAB_BASE_ADDRESS | VECT_TAB_OFFSET; /* Vector Table Relocation in Internal SRAM. */ #endif /* USER_VECT_TAB_ADDRESS */
- Reset_Handler 在调用完 SystemInit 后会调用 __main 函数,该函数最终调用 main 函数,进入用户代码。
进一步的深入分析可结合 startup_stm32f103xb.s、stm32f1xx.c 等文件。
程序下载方式
STM32 的程序主要存储在其内部 Flash 中,Flash 是非易性存储,重新上电后不会清除,类似个人电脑中的硬盘,有时也称之为 ROM(Read Only Memory),ROM 原本指那种一旦写入一次就只能读不可写,但后来也常常用来指代储存执行代码的储存器。与之相反的是 SRAM(Static Random Access Memory),它是易失性存储,重新上电后其数据会被全部清除,类似个人电脑中的内存,与之不同的是,个人电脑中的内存是 DDRAM,即动态内存,而 STM32 中的内存是静态内存,静态内存的读写速率通常高于动态内存。
虽然 STM32 的程序主要储存在其内部 Flash 中,但也可以将其存储在 SRAM 中,从而从 SRAM 中启动而非 FLASH 中启动。
ICP (In-Circuit Programing)
ICP 即电路内编程,即使用 JTAG/SWD 调试器下载程序,这也是我们最常用的程序下载/更新方式。ICP 方式可以将程序下载到 SRAM 或者 FLASH 中,这可以通过配置 STM32 芯片的 BOOT0、BOOT1 等引脚实现,注意这是针对部分 STM32 芯片的情况,比如 STM32F1 系列,其他芯片并不一定有 BOOT1 引脚,如 STM32H7 系列就没有。
官方将从 System Memory 启动这一启动方式也称之为 ICP,这样官方支持的三种启动方式均统一称之为 ICP,但由于 System Memory 具备一定的特殊性,因为它涉及官方的 Bootloader,该 Bootloader 可以通过多种通信接口下载程序,如 UART、USB、ETH 等,因此我们通常将 System Memory 启动方式单独拿出来,称之为 ISP,这正是接下来需要说明的。
ISP (In-System Programing)
ISP 即系统内编程,实质对应 STM32 三种启动方式中的 System Memory 启动方式,对于此名称我没有找到比较好的解读,但许多串口工具确实这样称呼这种启动方式,如 sscom、flymcu 等,所以我也将它单独说明。
System Memory 实质是 STM32 内部 Flash 中的一块储存区,不同芯片具有不同的地址和大小,对于 STM32F103C8T6 而言,其地址为 0x1FFF F000,大小为 2KB。这块区域中存储了 ST 官方烧录的 bootloader,该 bootloader 在官方手册 AN2606(STM32 microcontroller system memory boot mode) 中有详细说明,对于从各种通信接口,还有对应的手册作协议上的详细说明,如 AN3155(USART protocol used in the STM32 bootloader)。这两个手册我都通读了下,下面简单总结下。
AN2606 说明了对于各个系列的 STM32 芯片,如何进入该启动模式、bootloader标识、硬件连接要求、串口波特率检测、bootloader models、可使用的硬件资源、通信接口选择流程、bootloader 版本等信息;AN3155 说明了使用 USART 作为通信接口时,其代码执行流程、如何自动选择串口波特率、命令集、协议版本等信息。
ST 官方只提供了使用说明,未提供 bootloader 源码。但其实现了一个 OpenBL,它是以 IAP 方式下载程序,但支持的通信接口和协议与 System Memory 中内置的 bootloader 完全相同。
事实上,对于 System Memory 中内置的 bootloader,我们没有必要深入其实现细节,许多串口工具均提供了相应的功能,如 sscom 和 flymcu 等,使用起来非常方便。
一个疑问是:在 AN3155 中,明确说明了支持的最大波特率是 115200,但 sscom 等串口工具中的 ISP 下载功能可以以 460800 的波特率下载程序,这是如何实现的?
IAP (In-Application Programing)
IAP 即应用内编程。此功能意味着可以通过用户程序进行用户程序的升级,即“我升级我自己”,有点类似于在手机 APP 中升级该 APP 本身。该功能的意义是重大的,当产品发布后,程序必须会不断更新迭代,以修复 BUG 和添加新功能,而前述的 ICP 和 ISP 烧录方式此时就行不通了,ICP 需要在产品上提前预留调试接口(JTAG 或 SWD),ISP 需要修改 BOOT0 和 BOOT1 引脚的值(STM32H7等系列例外,它们可以在用户程序中修改启动方式),且用户很难学会相应操作,用户更希望有个简单的上位机即可完成升级,而非自行接线且执行复杂的操作步骤来更新程序。
实现 IAP 的逻辑很简单,在原本的启动地址处(对于 Flash 启动方式而言通常为 0x800 0000)不直接放置用户程序,而是放置用户自定义的 bootloader 程序(注意区别于 ST 官方 System Memory 中内置的 bootloader),然后在该 bootloader 中跳转到用户程序或者执行用户程序更新动作。详情可参考 基于蓝牙的STM32 IAP在线升级_stm32基于蓝牙模块远程升级-CSDN博客
该 bootloader 的实现有两个官方参考:ST 官方的 OpenBL 和 ST 官方例程 X-CUBE-IAP-USART - STM32Cube in-application programming using the USART embedded software (AN4657) - STMicroelectronics。我对后者的代码作了较为深入的分析,下面具体说明。
AN4657 中给了一个如何实现用户自定义 bootloader 的例子,该例子实现一个简单的菜单界面,支持 4 个功能:
- 下载/更新用户程序:将用户程序通过 YMODEM 协议下载到内部 Flash 的用户程序区
- 上传用户程序二进制文件:将内部 Flash 的用户程序区上传到用户 PC 上
- 执行更新后的程序:立即运行更新后的程序。
- 配置写保护:对内部 Flash 的用户程序区进行写保护或取消写保护。
在该例子中,进入 bootloader 的条件是上电时一直按住开发板的特定按钮。其核心模块就 3 个:
- flash_if.c: Flash 读写
- ymodem.c: YMODEM 协议的实现
- menu.c: 菜单界面的实现。
该例子代码逻辑清晰、模块化较好,简单调整后即可移植到自己的板子上:对于 STM32F103C8T6 而言,修改 flash_if.h 中的 APPLICATION_ADDRESS、USER_FLASH_END_ADDRESS、USER_FLASH_SIZE、FLASH_PAGE_TO_BE_PROTECTED 即可。
GPIO & AFIO
GPIO 即 General Purpose Input Output 的缩写,意即通用目的输入输出。GPIO 可以实现各种功能,比如用 GPIO 控制 LED 灯、接收按键输入、输出特定时序等,因为它本质就是可由程序控制的高低电平。STM32 芯片几乎所有的引脚都是 GPIO。
AFIO 是 Alternate Function Input Output 的缩写,意即可选功能输入输出。AFIO 是 STM32 最重要的特性之一,AFIO 可以让其特定引脚实现特定通信功能,如串口通信、SPI 通信、I2C 通信等,它和 GPIO 的引脚是复用的,即对于某个 STM32 芯片管脚,你可以将它用作简单且通用的 GPIO,也可以将它用作 AFIO,用作 AFIO 时,你需要明确指定具备是什么功能,如串口通信等。
详情参见 ST 官方参考手册(如RM0008) 中的 General-purpose and alternate-function I/Os (GPIOs and AFIOs) 章节部分。
Flash
STM32 的 Flash 通常指其内置 Flash,即用于保存代码的存储区。此外,自行设计电路时可添加外部 Flash,用于储存数据,外部 Flash 的操作和内部 Flash 类似。
Flash 主要涉及读取、擦除、写入操作:
- 读取非常简单,直接读取 Flash 所在地址的值即可,通过使用
value = *(uint16_t *) address
的方式即可读取address
处的数据,这里假设的是按 Half-Word 读取,也可按 Byte 或 Word 读取,只需调整uint16_t
为uint8_t
或uint32_t
即可。 - 擦除通常支持全片擦除和连续页擦除:全片擦除即官方文档中提到的 Mass Erase,可以一次性擦除用户存储区;连续页擦除即 Pages Erase,通过指定起始页和页数量进行连续多页的擦除。
- 写入之前必须擦除相关区域,否则写入可能不成功,即写入后的数据不是预期数据,写入后通常需要回读以验证。STM32F10xxx 只能按 Half-Word 写入,即一次写入 16 bit,其他系列可能支持 Word 写入。
ST 官方提供了详细的 Flash 擦除、读取、写入说明及 Flash 相关寄存器说明,详情参见 PM0075(STM32F10xxx Flash memory microcontrollers)。
UART/USART
USART 是 Universal Synchronous Asynchronous Receiver Transmitter 的缩写,意为“通用同步异步收发器”,UART 少了个 S,即 少了个 Synchronous,说明 UART 只支持异步,不支持同步。我们通常将其称之为串口通信,UART 是仅支持同步的串口,USART 是既支持同步又支持异步的串口。UART 和 USART 都是全双工的,这意味着收发可以同时进行。
UART 仅包含两个信号线:TX 和 RX。顾名思义,TX 即是发送,RX 即为接收。所谓异步,即指通信过程中没有同步时钟。TX 和 RX 是独立的,二者互不影响,从而实现全双工。UART 的时序图非常简单,其默认为高电平,当要发送数据时,先发一个起始位(即发送低电平一段时间),然后发送数据本身,最后结合配置决定是否发送停止位(停止位为高电平)和奇偶校验位。假如配置为停止位为 1 位,奇偶校验位为 0 位,则发送数据 0X55AA 将发送:0010101011
、0101010101
。其中 0 代表低电平,1 代表高电平。
UART 的相关配置主要包括:
- 波特率:指示通信速率,通常支持 9600、115200、460800、921600 bps 等,一般设置为 115200 bps。bps 是 bit per second 的缩写。
- 数据位:指示单次通信的数据宽度,通常支持 5 bit、6 bit、7 bit、8 bit,一般设置为 8 bit。
- 停止位:指示停止位宽度,通常支持 1 bit、1.5 bit、2 bit,一般设置为 1 bit。
- 校验位:指示校验方式,通常支持“无校验”、“奇校验”、“偶校验”等,一般设置为“无校验”。
USART 的信号线相比于 UART 要多些,在 TX 和 RX 的基础上添加了 CK 信号,从而实现同步,具体细节参考手册。
此外还有流量控制功能,此时会添加 RTS 和 CTS 信号,实现流控,详见手册
也可参考 UART协议就应该这么理解_uart是全双工还是半双工-CSDN博客。
实际使用中,ST 的 HAL 库提供了多种方式收发串口的数据,相关函数都在 stm32fxxx_hal_uart.c 和 stm32fxxx_hal_uart_ex.c 中:
-
阻塞式:程序会在执行这些函数时卡住,直到执行完成,当然这些函数都可以设置超时时间以避免卡死。
- HAL_UART_Transmit:向指定串口发送指定数据,并设置超时时间。
- HAL_UART_Receive:当未接收到指定大小的数据时将一直阻塞,直到到达超时时间。
-
非阻塞式:立即返回,具体数据处理在其它地方进行。通常有中断式和 DMA 式两种,中断式在对应的中断函数中进行处理,DMA 式在对应的 DMA 中断处理函数进行基本处理,实质数据收发是由 DMA 控制器完成的。两种方式都应实现
HAL_UART_RxCpltCallback
和HAL_UART_ErrorCallback
回调函数。- 中断式:
- HAL_UART_Transmit_IT
- HAL_UART_Receive_IT
- DMA方式:
- HAL_UART_Transmit_DMA
- HAL_UART_Receive_DMA
- 中断式:
此外,为了应对不定长数据接收,ST 官方还提供了拓展函数 HAL_UARTEx_ReceiveToIdle* 系列:
- HAL_UARTEx_ReceiveToIdle
- HAL_UARTEx_ReceiveToIdle_IT
- HAL_UARTEx_ReceiveToIdle_DMA
注意:使用拓展函数系列时,应实现HAL_UARTEx_RxEventCallback
回调函数而非HAL_UART_RxCpltCallback
函数。
使用案例(DMA方式,这也是比较推荐的方式):
- 全局变量声明:
1 2 3 4
#define UART1_BUF_SIZE 128 static uint8_t uart1_buf[UART1_BUF_SIZE]; static bool recv_data = false; static int recv_len = 0;
- 回调函数的实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if(huart->Instance == USART1) { //_dbg_printf("uart recv error: errorCode=%d\n", huart->ErrorCode); HAL_UARTEx_ReceiveToIdle_DMA(huart, uart1_buf, UART1_BUF_SIZE); } } void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if(huart->Instance == USART1) { HAL_UART_RxEventTypeTypeDef type = HAL_UARTEx_GetRxEventType(huart); if(type == HAL_UART_RXEVENT_IDLE || type == HAL_UART_RXEVENT_TC) { recv_data = true; recv_len = Size; } } }
- main 函数中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, uart1_buf, UART1_BUF_SIZE); while (1) { /* USER CODE END WHILE */ /* USER CODE BEGIN 3 */ if(HAL_GetTick() - led_time > 500) { led_time = HAL_GetTick(); HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13); } if(recv_data) { recv_data = false; _dbg_printf("uart1_buf: %s\n", uart1_buf); cm_print_buf(uart1_buf, recv_len); HAL_UARTEx_ReceiveToIdle_DMA(&huart1, uart1_buf, UART1_BUF_SIZE); } } /* USER CODE END 3 */
具体使用方法可参考官方手册 UM1850。