文章

Stm32使用笔记

Stm32使用笔记

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 的开发环境有很多,包括 IAR、Keil、STM32CubeIDE 等。新手推荐 Keil,无需额外配置步骤,开箱即可使用。

Keil

Keil 是历史悠久的 IDE,专门用于嵌入式开发,适用各种芯片,基于 ARM Cortex 的 STM32 芯片也是其支持的范围之一。Keil 的优点是开箱即用,缺点是功能较为单一,尤其是代码编辑较弱,且 Keil 的免费版本限制了代码大小(32KB),对于 STM32F103C8T6 来说,这个限制就有点小了,更别说更高端的芯片。关于代码限制问题可参考 解决方法

Keil 有一些使用技巧:

  • 可在菜单栏中的Edit->Configuration->Shortcut Keys查看所有快捷键,或自定义快捷键。常用的快捷键包括:
    • Ctrl+S: 保存文件
    • Ctrl+F: 查找
    • Ctrl+H: 替换
    • Ctrl+Shift+F: 在整个工程中查找
    • F7: 保存所有文件并编译工程
    • F8: 下载程序到板子
  • 查找某个 Symbol 的所有引用:在菜单栏中点击View,然后点击Source Browser Window,然后在Symbol中输入要查找的 Symbol,回车后即可看到所有结果,一个比较好的地方是,它还会显示是定义还是引用(左边中括号中的D表示是定义,R表示是引用),如果是引用的话还会标明是读还是写(右边中括号中的r表示读,w表示写)。相关快捷键是Ctrl+Shift+F12

  • 解决中文乱码问题:某些工程打开后常常出现中文乱码或编辑中文时出现乱码的现象,通常可通过以下方法解决:在菜单栏中的Edit->Configuration->Editor->Encoding处选择Chinese GB2312
  • 代码补全导致的卡顿问题:在 Keil 中,输入几个字符后就会触发代码补全,然后就会卡半天。解决方法是关闭自动代码补全:在菜单栏中点击Edit -> Configuration -> Editor -> Code Completion,然后将“Symbol after 2 Character”前的勾去掉,这样就可以关闭代码补全功能了。

Qt Creator

虽然我目前(2025-03-08)用得最多的是 Keil,但 Keil 的调试功能可能还行,其他的实在一言难尽。于是尝试替代方案,正巧最近有使用 Qt Creator 开发 Qt 项目,那么能否使用 Qt Creator 来开发 STM32 程序呢?答案是可以的,但使用起来没有那么方便,尤其是下载程序和调试程序。下面简单总结下相关配置步骤。

准备工作

  1. 安装最新版本的 Qt Creator(我安装的是 14.0.2)
  2. 安装 STM32CubeMX
  3. 安装 Arm 裸机工具链:winget install Arm.ArmGnuToolchain,注意放置到PATH中。也可去官网下载最新版本: https://developer.arm.com/downloads/-/arm-gnu-toolchain-downloads
  4. 安装 ninja:winget install Ninja-build.Ninja
  5. 安装 cmake: winget install Kitware.CMake
  6. 安装 stlink utility: https://github.com/stlink-org/stlink/releases/download/v1.7.0/stlink-1.7.0-x86_64-w64-mingw32.zip,注意放置到PATH中。注意,最新版本运行会报错,所以选择的是1.7.0版本

使用步骤

  1. STM32CubeMX 生成代码时,工程类型选择 cmake
  2. 修改生成后的 CMakeLists.txt
  3. 使用 Qt Creator 打开 CMakeLists.txt,勾选所有 Kits
  4. 修改 Kits,设置 Run device type 为“裸机设备”,设置 Run device 为“f103 芯片”,设置调试器为“arm-none-abi-gdb”,这三个均需要手动配置或添加:
    1. “裸机设备”:在“设备-裸机”处添加“ST-LINK实用工具”,版本要设置为“保持未指定”,详细级别最高可到99
    2. “f103芯片”:在“设备-设备”处添加裸机设备,“调试服务器提供方”选择刚刚的“ST-LINK实用工具”,将其“名称”设置为“f103芯片”(这个名称其实不科学)
    3. 调试器“arm-none-abi-gdb”:在 Kit-Debuggers 处添加调试器,名称输入 arm-none-abi-gdb, 路径输入相应路径,通常为C:\Program Files (x86)\Arm GNU Toolchain arm-none-eabi\12.2 mpacbti-rel1\bin\arm-none-eabi-gdb.exe
  5. 此时就可以使用 Qt Creator 编译了,但运行会报错:gdb doesnt support python scripting,cannot be used in qt creator。运行时 Qt Creator 首先会后台执行st-util.exe "--listen_port=4242" "--verbose=0",然后启动 gdb,启动 gdb 时报的这个错。这时可以不用 qt creator 了,直接在 pwsh 中使用 arm-none-eabi-gdb 命令进行调试:

    arm-none-eabi-gdb .\test2.elf
    target extended localhost:4242
    load
    continue
    monitor reset
    

    如果只是下载运行的话也可以使用一行命令:arm-none-eabi-gdb --batch -ex "target extended localhost:4242" -ex load -ex "monitor reset" .\test2.elf 如果在 cmd 中需要转义双引号:pwsh -Command "arm-none-eabi-gdb --batch -ex \"target extended localhost:4242\" -ex load -ex \"monitor reset\" .\test2.elf"。该命令可以添加到 Qt Creator 的项目部署功能中去,然后设置部署的快捷键为 CTRL+ALT+D,就可在 Qt Creator 中快速部署了。

总结

虽然实现了 Qt Creator 中编辑代码,部署代码到板子并运行,且可在 Qt Creator 中使用 pwsh Terminal, 在其中使用 gdb 调试代码,但部署和调试还不够方便,另可尝试 STM32 官方的 STM32CubeIDE。还可尝试 OpenOCD 完善下载和调试功能

参考链接:

nvim

详情参见 使用 NVIM 打造多平台通用的 IDE

启动过程

STM32 的启动过程可以简述如下(以 STM32F103C8T6 为例):

  1. 读取 BOOT1 和 BOOT0 引脚的值,决定启动方式:
    • X0: 从内部 Flash 启动,启动地址为 0x800 0000。
    • 01: 从 System Memory 启动地址为 0x1FFF F000。
    • 11:从 SRAM 启动,启动地址为 0x2000 0000。

    详情参见官方 Reference Manual (如 RM0008)的 Boot Configuration 章节(如 3.4 章节)。

  2. 将启动地址开始的前 4 字节的值作为栈顶指针,之后 4 字节的值为 Reset_Handler 函数的地址,即复位中断向量,也是中断向量表的起始地址。
  3. 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 */
    
  4. 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 Programming)

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 Programming)

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 Programming)

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 即可。

另外也可参考 Aladdin-Wang/MicroBoot

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_tuint8_tuint32_t即可。
  • 擦除通常支持全片擦除和连续页擦除:全片擦除即官方文档中提到的 Mass Erase,可以一次性擦除用户存储区;连续页擦除即 Pages Erase,通过指定起始页和页数量进行连续多页的擦除。
  • 写入之前必须擦除相关区域,否则写入可能不成功,即写入后的数据不是预期数据,写入后通常需要回读以验证。STM32F10xxx 只能按 Half-Word 写入,即一次写入 16 bit,其他系列可能支持 Word 写入。

ST 官方提供了详细的 Flash 擦除、读取、写入说明及 Flash 相关寄存器说明,详情参见 PM0075(STM32F10xxx Flash memory microcontrollers)

注意:部分 STM32 系列芯片(如 STM32H743)在擦除 Flash 时需要配置正确的电压范围(Voltage Range)

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 将发送:00101010110101010101。其中 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_RxCpltCallbackHAL_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. 全局变量声明:

    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;
    
  2. 回调函数的实现:

    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;
             }
         }
     }
    
  3. 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

TIM

复位

如果要在程序中复位(即重启),可调用HAL_NVIC_SystemReset()函数。

编译器

Keil 官方使用两个版本的编译器:AC5 和 AC6,即 ARMCC 和 ARMCLANG。而其他许多 IDE(包括 ST 官方的 STM32CubeIDE)使用的是 GCC,例如arm-none-abi-gcc,这是 ARM 官方的工具链,也是可用的,其中通常还会包括gdbobjcopy等,这就和主机上的 C 开发统一起来了,这样也方便在各种环境下交叉编译,包括 Windows、Linux、MacOS 等。

因此,GCC 才是更好的编译器方案。

调试

半主机模式(semihosting)

半主机是一种机制,它使 ARM 设备(包括 STM32)上运行的代码能够与运行调试器的主机进行通信并使用主机上的输入/输出功能。

printf

重写fputc还是_write?根据使用编译决定,如果是 GCC,则是_write,如果 Keil 中的默认的编译器,则是fputc

参考:

其实个人感觉最好还是自行实现一个和printf类似的函数,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#define DBG_UART usart1
#define DP_TXBUFF_LEN 256
void _dbg_printf(const char *format,...)
{
    char _dbg_tx_buff[DP_TXBUFF_LEN];
    uint32_t length;
    va_list args;

    va_start(args, format);
    length = vsnprintf((char*)_dbg_tx_buff, DP_TXBUFF_LEN, (char*)format, args);
    va_end(args);

    length = (length>DP_TXBUFF_LEN? DP_TXBUFF_LEN:length);

    HAL_UART_Transmit(&DBG_UART, (const uint8_t*)_dbg_tx_buff,length, HAL_MAX_DELAY);
}

这样做有几个好处:

  • 无需使用具有缺点的 Micro Lib 或者加一堆业务无关的代码(否则由于半主机模式,你下载后程序会无法正常运行,除非进入调试模式)
  • 可以自定义_dbg_printf的细节,优化其性能等。甚至可以采用 DMA 的方式进一步减少对 CPU 的占用。

添加了 DMA 方式打印的代码可参考 _dbg_printf

无需额外配置串口的情况下,如何使用stlink v2 仿真器进行 printf 调试打印?答案即是SWO

硬件要求:

  1. 芯片上需要有 SWO 接口,仿真器上要有 SWO 接口,二者要相连。需要注意的是,前者容易满足(STM32F10X 等芯片上均有该接口),而后者不一定满足:首先官方 stlink v2 是有 SWO 接口的,即 13 号脚,也即 TDO 脚,而 USB 小型仿真器没有,需要自行从内部的 MCU 跳线,比较麻烦。

软件要求:

  1. STM32CubeMX 中需要在 Debug 处(或者 SYS 处)选择 Trace Asynchronous Sw
  2. 如果使用 Keil,则 Keil 中要配置仿真器,假设使用的是 stlink v2,则需要在 Trace 标签页点击 Trace Enable,并正确设置 Core Clock 时钟(通常为 STM32CubeMX 软件中时钟树下的 SYSCLK 的值),并在 ITM Stimulus Ports 下的 Enable 处输入1(即仅启用 0 号 port),Privilege 可以不管,然后点击确定
  3. 程序中需要重新实现fputc函数,例如:

    1
    2
    3
    4
    5
    
    int fputc(int ch, FILE *f)
    {
      ITM_SendChar(ch);
      return ch;
    }
    

    并包含头文件stdio.h,即可使用printf进行打印了。打印结果可通过 Debug(printf) Viewer 查看(需要进入 调试界面,因为printf依赖半主机模式)。

    但如果不进入调试状态怎么看呢?首先不进行调试的话必需勾选 Micro Lib 才能正常运行程序,或者在main.c中加入以下代码(这是我测试的最小代码,网上有更全的可以尝试):

    1
    
    FILE __stdout;
    

    注意:后面发现加这行代码会报错,不知道为啥之前没报错。arm-none-abi-gcc不存在非 Debug 下无法运行的问题。

    不进入调试状态可以使用 ST 官方的 STM32 ST-LINK Utility 中的 ST-LINK 菜单栏中的 Printf via SWO viewer 查看。

另可参考以下链接:

  1. [printf系列教程03_SWO打印输出配置,基于Keil『Debug(printf)Viewer』EmbeddedDevelop](https://www.strongerhuang.com/printf/printf%E7%B3%BB%E5%88%97%E6%95%99%E7%A8%8B03_SWO%E6%89%93%E5%8D%B0%E8%BE%93%E5%87%BA%E9%85%8D%E7%BD%AE%EF%BC%8C%E5%9F%BA%E4%BA%8EKeil%E3%80%8EDebug%EF%BC%88printf%EF%BC%89Viewer%E3%80%8F.html)
  2. Debug an STM32 with printf using only an ST-Link - Phipps Electronics

相关硬件工具

仿真器

STM32 的仿真器(也叫下载器或调试器)有很多种,包括 ARM 仿真器、ST-LINK 仿真器、DAP 仿真器等。对于 STM32 来说,最常用的仿真器是 ST-LINK,它是 ST 官方提供的仿真器,支持 JTAG 和 SWD 两种调试接口。ST-LINK 有多种型号,包括 ST-LINK/V1、ST-LINK/V2、ST-LINK/V3 等,其中 ST-LINK/V2 是最常用的型号。其相关软件如下:

需要注意的是许多软件中的所谓 stlink utility 不是前述的 2,而是开源的工具 stlink-org/stlink,且主要是指其中的st-util命令,该命令可以搭建 gdb server,方便 gdb 远程调试。另外两个命令是:st-infost-flash,前者用于查询仿真器信息,后者用于烧录 flash,但仅支持 bin 文件,使用前需要使用objcopy等工具将 ELF 文件转换为 BIN 文件。

下面推荐几个个人用的仿真器,建议不同类别的购买两到三个,在出问题时好排查是否是仿真器的问题:

其他

应用案例

光耦/MOS隔离下 GPIO 的用法

控制 LED

接收按钮数据

蓝牙模块

超声波测距传感器

基于官方示例的 IAP bootloader

基于前述的 AN4657 示例很容易实现一个具备特定功能的 bootloader,比如实现一个具备如下功能的 bootloader:

  • 用户程序通过向指定 Flash 地址写入一个标志位,并立即重启,从而进入 bootloader,进行用户程序升级,升级完成后将该标志位清除。
  • 启动进入 bootloader 后,如果发现标志位没有设置,则直接跳转到用户程序执行。
  • 标志位设置并进入 bootloader 后,可输入a取消升级,从而手动选择执行菜单中的命令。

关键代码如下(menu.c):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
void Main_Menu(void)
{
  uint32_t tmp = *(__IO uint32_t*) (FLASH_UPGRADE_FLAG_ADDR);
    int skip_key = 0;
  uint8_t key = 0;
    _dbg_printf("enter bootloader, flash_upgrade_flag_value: %X\n", tmp);
  if(tmp != FLASH_UPGRADE_FLAG_VALUE) {
      modify_stack_pointer_and_start_app(APPLICATION_ADDRESS, APPLICATION_ADDRESS+4);
      return;
  } else {
      skip_key = 1;
      key = '1';
  }
  //此处省略了部分代码
    if(!skip_key) {
        /* Receive key */
        HAL_UART_Receive(&UartHandle, &key, 1, RX_TIMEOUT);
    } else {
        skip_key = 0;
    }

   switch (key)
    {
    case '1' :
      /* Download user application in the Flash */
        {
            int ret = SerialDownload();
            if(!ret) {
                uint32_t tmp[FLASH_NB_32BITWORD_IN_FLASHWORD]={0};
                HAL_FLASH_Unlock();
                uint32_t ok = HAL_FLASH_Program(FLASH_TYPEPROGRAM_FLASHWORD, FLASH_UPGRADE_FLAG_ADDR, (uint32_t)(tmp));
                HAL_FLASH_Lock();
                if(ok != HAL_OK) {
                    _dbg_printf("write to upgrade flag failed: %d\n", ok);
                } else {
                    HAL_NVIC_SystemReset(); //reboot
                }
            }
            break;
        }

完整代码参见 wsxq2/iap_bootloader

注意: 需要根据使用的板子调整flash_if.c文件,不同系列的 STM32 芯片的 Flash 擦除、写入的方法不同,重新实现此文件中的相关函数即可。其中尤其要注意有的 STM32 芯片 Flash 的擦除需要给定正确的电压范围(Voltage Range),这也是我耗时很久才发现的问题,通过此次事件,也深切感受到了嵌入式开发中,软件代码不正确的配置也会导致类似硬件问题的玄学现象。下面简单回溯如下,以备忘(使用的 STM32 芯片为 STM32H743):

为方便调试,我加入了全局变量,用以保存某个函数的返回值,此后发现通过此 bootloader 更新(下载)用户程序时总是失败,这是为什么呢?开始分析并解决:

  1. 首先定位到具体的代码位置,发现是在第一步擦除时失败
  2. 然后去掉添加的全局变量及相关代码,发现没有了上述问题,因此怀疑是全局变量导致的,搜索可能的原因,包括询问 deepseek,给出的答案是内存分布冲突等可能,逐个检查,未发现可疑的地方
  3. 恢复添加全局变量后的相关代码,试图 DEBUG 分析,但由于 Keil 默认的编译器优化是-O3,导致调试时代码经常乱跳,不方便调试,故将编译器优化改为 -O0,结果发现没有了上述问题
  4. 在 3 的基础上打断点分析,发现很多时候未执行到FLASH_Erase_Sector函数(stm32h7xx_hal_flash_ex.c文件)中的第一个if处,即如下位置:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    
    void FLASH_Erase_Sector(uint32_t Sector, uint32_t Banks, uint32_t VoltageRange)
    {
      assert_param(IS_FLASH_SECTOR(Sector));
      assert_param(IS_FLASH_BANK_EXCLUSIVE(Banks));
    #if defined (FLASH_CR_PSIZE)
      assert_param(IS_VOLTAGERANGE(VoltageRange));
    #else
      UNUSED(VoltageRange);
    #endif /* FLASH_CR_PSIZE */
       
      if((Banks & FLASH_BANK_1) == FLASH_BANK_1) //此处
      {
    #if defined (FLASH_CR_PSIZE)
        /* Reset Program/erase VoltageRange and Sector Number for Bank1 */
        FLASH->CR1 &= ~(FLASH_CR_PSIZE | FLASH_CR_SNB);
       
        FLASH->CR1 |= (FLASH_CR_SER | VoltageRange | (Sector << FLASH_CR_SNB_Pos) | FLASH_CR_START);
    
  5. 尝试在 Flash 擦除前后分别调用__disable_irq()__enable_irq(),发现并无效果
  6. 使用 STM32CubeProgrammer 读取数据,发现出问题后读出来的 0X800000 处全为 0XFF,即意味着 SECTOR0 被擦除了,但打断点的时候显示变量发现擦除的是 SECTOR1。即 bootloader 的区域也被意外擦除了(目标本是只擦除用户程序所在区域)
  7. 在测试板 STM32F103C8T6 上进行测试,没有此问题
  8. 一番资料搜索和尝试,无果
  9. 翻看官方手册,尤其是HAL_FLASHEx_Erase的使用手册,仔细查看后终于想起我少配置了一个参数,即“电压范围”,后面配置后再测试,发现问题果然解决了。

此外,为了更加友好的用户体验,最好实现一个上位机软件,而非使用串口工具 Tera Term 进行程序更新,可参考的链接如下:

遇到过的问题

在一直收数据时下载程序后HAL用法串口不好使(单字节读取)?

原因是报错了 ORE,即 Over Run Error,意即缓冲区溢出,该错误出现后就只会进入HAL_UART_ErrorCallback函数,而不会再进入HAL_UART_RxCpltCallback函数。这就会导致HAL_UART_RxCpltCallback函数中的HAL_UART_Receive_IT不会被执行,而该函数中做了清除 ORE 标志的动作。

所以只需要在HAL_UART_ErrorCallback中执行一下HAL_UART_Receive_IT即可,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart)
{
 if(huart->Instance == UART5) {
  _dbg_printf("uart recv error: errorCode=%d\n", huart->ErrorCode);
  HAL_UART_Receive_IT(huart, &uart5_ch, 1);
 }
}
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
 if(huart->Instance == UART5) {
  uwb_recv_data_callback(uart5_ch);
    HAL_UART_Receive_IT(huart, &uart5_ch, 1);
 }
}
...
main()
{
    // 启动 UART5 接收中断
    HAL_UART_Receive_IT(&huart5, &uart5_ch, 1); // uart5_ch 是接收缓冲区
    while(1) {
    ...
    }
}

相关资料:

Keil 中设置编译器优化为 O0 时会导致下载程序后不能正常启动,即使重新上电

使用调试运行则正常。设置为 O1 也正常。STM32H750 会出现此问题,F103未出现此问题。出问题的 Keil 版本较老。推测可能是老版 Keil 的 bug

HAL_UART_Receive 在接收超长数据后无法再接收数据?

正常现象,因为触发了 ORE 错误,并且后续调用该函数前没有手动清除该错误,所以HAL_UART_Receive无法再接收数据。

参见Stm32 HAL_UART_Receive读取不到数据的问题_haluartreceivedma没有数据-CSDN博客

使用printf会导致直接下载程序无法运行?

使用 printf 函数通常需要依赖半主机模式(semihosting),这会导致在没有调试器连接的情况下,程序无法正常运行。因为半主机模式需要调试器来处理输入输出,而直接下载的程序没有调试器连接时就会卡在等待输入输出的地方。

在 Keil 仿真器设置启用 Pack 标签页中的 Debug Description 会导致直接下载程序无法运行?

进一步发现勾选使用 Micro Lib 可以解决此问题。为啥?

在 Keil 中设置 stlink 仿真器时,如果启用 Pack 标签页中的 Debug Description,则可能会启用半主机模式,导致下载程序后无法启动。

而勾选 Micro Lib 后会禁用半主机模式,从而使程序可以在没有调试器连接的情况下正常运行。Micro Lib 是 Keil 提供的一种轻量级的 C 库,它不依赖于半主机模式,因此可以在没有调试器的情况下正常运行。

STM32 Modbus RTU Master Lib?

我原以为 modbus rtu 作为工业场景下常用的协议,应该有现成的库可以使用,但经过一番搜索和尝试,发现 STM32 官方并没有提供现成的 Modbus RTU Master 库。

所以我自己实现了一个,但依然有待完善:modbus.c

另外需要注意的是如果需要的不是 Master 而是 Slave 的话,则有很多现成库可供选择,例如:

Modbus RTU HAL_UART_Transmit后马上HAL_UART_Receive有时会超时?

Modbus RTU,MCU 做 master, 模拟软件 Modbus Slave 作 slave,代码中HAL_UART_Transmit后马上HAL_UART_Receive有时会超时?而且出现一次超时后一直超时?

超时的原因是ORE(Over run error,溢出),后续一直超时是因为没有清除ORE标志,所以无法正常接收。

为什么会ORE呢?最后发现是编译器优化太低(-O0)导致,改成-O3就好了。

推测是编译优化太低导致编译出的代码比较低效,而 Modbus Slave 处理请求很快,在较短时间内就回复了,MCU 处理不过来就会导致 ORE。

详情参见 UART Overrun error - STMicroelectronics Community

HAL 库 HAL_UARTEx_ReceiveToIdle 相关函数找不到?

STM32CubeMX 版本过低,导致生成的代码中没有该函数。请升级到最新版本的 STM32CubeMX,并重新生成代码。HAL_UARTEx_ReceiveToIdle 函数是 HAL 库的扩展函数,只有在较新的 HAL 库版本中才提供支持。

HAL 库 HAL_UARTEx_ReceiveToIdle 相关函数无法使用?

需要实现HAL_UARTEx_RxEventCallback而非HAL_UART_RxCpltCallback

本文由作者按照 CC BY 4.0 进行授权