嵌入式C++教程实战之Linux下的单片机编程:从零搭建 STM32 开发工具链(2) —— HAL 库获取、启动文件坑位与目录搭建 嵌入式C教程实战之Linux下的单片机编程从零搭建 STM32 开发工具链2 —— HAL 库获取、启动文件坑位与目录搭建上一篇我们把工具链装好了现在来搭项目骨架。这篇记录我获取 STM32 HAL 库的全过程包括那个让人摸不着头脑的嵌套 submodule 问题、启动文件命名规则背后的玄学以及stm32f1xx_hal_conf.h里那些让你编译到一半报错的隐藏坑。教程仓库已经开源到Github上了https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_AwesomeModernCPP笔者最近出差所以发的是存货GUI编程今天暂时不更新明天更新GUI的。为什么这一步很重要你可能会问不就是个项目结构吗随便建几个文件夹把 HAL 库扔进去不就完了还真不是。STM32 的 HAL 库有一套自己的生态系统 —— CMSIS 核心层、HAL 驱动层、启动文件、链接脚本这些东西必须按照特定的方式组织否则编译器根本不知道从哪找头文件链接器也不知道要把代码放到内存的哪个位置。更麻烦的是ST 官方的 HAL 库是通过 Git 仓库发布的而且它内部还有嵌套的 submodule。如果你用常规方式克隆十有八九会漏掉关键文件等你编译到一半报错说找不到某个头文件时再回头排查就非常痛苦。我在这上面栽过跟头所以这一篇我会把所有坑都提前标出来让你一次就把项目骨架搭对。先搞清楚 HAL 库的三层架构在我们开始下载代码之前有必要先理解一下 ST 的 HAL 库是怎么分层设计的。这能帮助你理解为什么要建立那些目录、每个文件是干嘛的。最底层是CMSIS-CoreCortex Microcontroller Software Interface Standard。这是 ARM 制定的一套标准定义了 Cortex-M 系列内核的寄存器访问接口。简单来说CMSIS-Core 告诉你这个芯片有一个叫做 SCB 的寄存器地址是 0xE000ED00这样你写代码时就可以用SCB-VTOR 0x00这样的方式操作寄存器而不是去记那些魔法数字。CMSIS-Core 是 ARM 官方维护的对所有 Cortex-M 芯片都通用。中间层是CMSIS-Device。这部分是 ST 针对 STM32F1 系列芯片做的特殊化。它定义了 F103C8T6 这个具体芯片有什么外设、每个外设有多少个、寄存器地址在哪里。比如GPIOA的基地址是0x40010800这种信息就写在 CMSIS-Device 的头文件里。你以后会看到一堆stm32f103xb.h这种文件它们就属于这一层。最上层才是HAL 驱动层。这是 ST 用 C 语言写的一套外设驱动 API比如HAL_GPIO_TogglePin()、HAL_UART_Transmit()这些函数。它们的作用是屏蔽底层寄存器操作让你用统一的方式操作不同系列的 STM32。理论上你用 HAL 写的代码移植到 STM32F4 上应该只需要改少量配置。再往上就是你的应用代码了。应用代码调用 HAL 的 APIHAL 调用 CMSIS-Device 的定义CMSIS-Device 再依赖 CMSIS-Core 的内核接口。理解这个分层之后你就会知道为什么需要建立这么多目录 —— 每一层都有自己专属的文件夹。获取 HAL 库 submodule 的陷阱好了现在我们来获取代码。ST 官方的 STM32F1 HAL 库托管在 GitHub 上仓库地址是https://github.com/STMicroelectronics/STM32CubeF1。你可能第一时间会想到直接git clone但这里有个坑让我一步步演示。首先创建我们的项目根目录。我习惯把所有依赖都放在third_party目录下这样项目结构清晰mkdir-p~/stm32-f103-project/third_partycd~/stm32-f103-project/third_party现在我们来克隆 HAL 库。这里有个新手最容易犯的错误 —— 用--depth1做浅克隆# 错误做法不要这样做gitsubmoduleadd--depth1https://github.com/STMicroelectronics/STM32CubeF1.git STM32F1这个命令看起来很合理用 submodule 把库加进来--depth1只拉取最新版本节省时间。但问题是STM32CubeF1 这个仓库内部还有自己的 submoduleCMSIS 库是作为 submodule 引入的而--depth1会阻止嵌套的 submodule 被正确初始化。当你以后去检查目录结构时你会发现一个诡异的现象lsthird_party/STM32F1/Drivers/CMSIS/Device/ST/STM32F1xx/Source/Templates/gcc/正常情况下这个目录应该有一堆启动文件startup_stm32f103xb.s之类但如果你用了浅克隆这里会是空的。编译时你会看到类似这样的报错error: cannot find startup_stm32f103xb.s那时候你再去查为什么文件缺失会一头雾水 —— 明明 submodule 已经加进来了为什么文件还是缺失原因在于 Git 的 submodule 机制。当你 clone 一个包含 submodule 的仓库时Git 只会拉取外层仓库的内容里面的 submodule 目录只是一个指针指向另一个仓库的某个 commit。你需要额外运行git submodule update --init --recursive才能让 Git 真正去拉取那些嵌套的 submodule 内容。而--depth1浅克隆会破坏这个机制因为嵌套 submodule 的历史记录没有被完整拉取。正确的做法是完整克隆然后递归初始化所有 submodulegitclone--recursivehttps://github.com/STMicroelectronics/STM32CubeF1.git STM32F1如果你已经把 submodule 加到项目里了但忘记用--recursive可以补救一下cdthird_party/STM32F1gitsubmodule update--init--recursive这个命令会递归地拉取所有嵌套的 submodule确保 CMSIS Device 目录的文件都齐全。你可以用刚才的 ls 命令验证一下启动文件是不是都出现了lsthird_party/STM32F1/Drivers/CMSIS/Device/ST/STM32F1xx/Source/Templates/gcc/你应该能看到类似这样的输出startup_stm32f100xb.s startup_stm32f103x6.s startup_stm32f103xb.s startup_stm32f103xe.s startup_stm32f100xe.s startup_stm32f101x6.s startup_stm32f101xb.s ...还有很多看到这些.s文件就说明 submodule 拉取成功了。顺便一提如果用 Arch Linux你的系统可能没有预装git需要先pacman -S gitUbuntu 用户通常默认就有 git。启动文件的命名玄学现在我们有了启动文件但新问题来了 —— 到底该用哪一个这里有个让无数新手踩坑的细节。网上很多教程写的是startup_stm32f103x8.s但你仔细看一下刚才 ls 的输出会发现根本没有这个文件ST 官方的文件名是startup_stm32f103xb.s。这个差异背后是 ST 的芯片命名规则。让我解释一下F103C8T6 这个型号里的C8代表什么C 是小容量Low-density8 代表 64KB Flash。但 ST 的启动文件命名规则不是按照 Flash 大小来的而是按照密度等级density categoryx6 Low-density devices小容量16-32KB FlashxB Medium-density devices中等容量64-128KB FlashxE High-density devices大容量256-512KB FlashxG XL-density devices超大容量768KB-1MB FlashF103C8T6 有 64KB Flash属于中等容量所以对应的启动文件是startup_stm32f103xb.s。这里的B不是 8 的十六进制而是 ST 内部的一个密度代码。对应到编译时的宏定义你需要传递-DSTM32F103xB注意是大写 B。很多教程错误地写成了-DSTM32F103x8结果会导致头文件里的条件编译选错分支编译出的代码可能和你的硬件不匹配。你可能会问为什么 ST 要搞这么复杂的命名历史原因。STM32F1 系列是 ST 最早推出的 Cortex-M3 产品线当时他们按照 Flash 容量分了好几个档次。F103xB 覆盖了 64KB 和 128KB 两个版本硬件上除了 Flash 大小之外几乎一模一样所以用同一套启动文件和头文件。那启动文件到底是干嘛的简单来说它是芯片复位后执行的第一段代码。STM32 上电或者复位时CPU 会从地址 0x00000000 读取初始堆栈指针然后从 0x00000004 读取复位向量Reset Handler跳转到那里执行。启动文件就是定义了这个向量表Vector Table里面包含所有中断和异常的入口地址。它还负责初始化.data段把 Flash 里的初始值复制到 RAM和清零.bss段最后跳转到你的main()函数。没有启动文件芯片复位后不知道该干什么程序就没法运行。项目目录结构现在我们把 HAL 库拿到了启动文件也搞明白了接下来要搭一个清晰的项目结构。我推荐这样的布局stm32-f103-project/ ├── third_party/ │ └── STM32F1/ # HAL 库刚才克隆的 │ ├── Drivers/ │ │ ├── CMSIS/ │ │ │ ├── Core/ # CMSIS-CoreARM 标准 │ │ │ └── Device/ST/STM32F1xx/ # CMSIS-DeviceF1 系列 │ │ └── STM32F1xx_HAL_Driver/ # HAL 驱动层 │ └── ... ├── src/ # 你的源代码 │ ├── main.cpp │ ├── stm32f1xx_hal_conf.h # HAL 配置文件从模板复制 │ ├── stm32f1xx_it.c # 中断服务函数HAL 需要 │ └── stm32f1xx_it.h ├── build/ # CMake 构建目录生成后 ├── CMakeLists.txt # 构建配置 └── linker/ # 链接脚本 └── STM32F103xC8.ld让我解释一下每个目录的作用third_party/STM32F1是我们刚才克隆的 HAL 库这个目录不需要你手动修改只管引用就行。它里面的 CMSIS 和 HAL_Driver 会通过 CMake 的target_include_directories加入到编译路径里。src/存放你的应用代码。main.cpp是程序入口stm32f1xx_hal_conf.h是 HAL 库的配置文件下面会详细讲这个坑stm32f1xx_it.c/h是中断服务函数。HAL 库的某些外设比如 UART需要用户定义中断处理函数这些函数就写在_it.c里。build/是 CMake 的输出目录。我们用out-of-source构建方式不把生成的文件污染到源码目录里。编译产物.o、.elf、.bin都会放在这里。linker/存放链接脚本。我们下一篇会详细讲怎么写这个文件现在先知道它定义了内存布局就行。你可能注意到我用了STM32F103xC8.ld作为链接脚本名。这个命名没有硬性规定但我习惯把芯片型号写进文件名这样一眼就知道是给哪个芯片用的。F103C8 和 F103CB128KB 版本的区别只在于 Flash 大小链接脚本里改一下LENGTH参数就行其他都一样。stm32f1xx_hal_conf.h那些隐藏的坑现在我们来到第一个重灾区 —— HAL 配置文件。ST 官方的 HAL 库并不包含一个现成的stm32f1xx_hal_conf.h只有一个stm32f1xx_hal_conf_template.h模板。你需要把模板复制到项目里重命名然后修改。为什么不用 CubeMX如果你用 ST 的 STM32CubeMX 图形化工具生成项目它会自动帮你生成这个文件。但我们走纯手写 CMake路线就必须手动搞定。首先把模板复制过来cpthird_party/STM32F1/Drivers/STM32F1xx_HAL_Driver/Inc/stm32f1xx_hal_conf_template.h\src/stm32f1xx_hal_conf.h然后用编辑器打开这个文件开始修改。第一个坑是模块选择。文件开头有一大堆#define HAL_XXX_MODULE_ENABLED默认所有模块都被启用了。这会导致编译时把所有 HAL 驱动都编译进去固件体积膨胀得厉害。对于我们的 LED 闪烁程序只需要启用这几个模块#defineHAL_MODULE_ENABLED// HAL 核心#defineHAL_GPIO_MODULE_ENABLED// GPIO控制 LED#defineHAL_RCC_MODULE_ENABLED// 时钟配置#defineHAL_CORTEX_MODULE_ENABLED// Cortex-M3 内核函数其他模块的#define都注释掉。这样编译器只会把你需要的 HAL 函数编译进去链接器也能更好地做死代码消除dead code elimination。第二个坑是时钟宏定义。往下翻几行你会看到一堆HSE_VALUE、HSI_VALUE、LSI_VALUE之类的宏。这些是外部/内部晶振频率HAL 库的 RCC 模块需要知道这些频率才能计算系统时钟。最关键的是LSI_VALUE这个宏在模板文件里是#if !defined (LSI_VALUE)条件定义的。如果你没有定义这个宏编译 HAL 的某些模块比如 RTC 或看门狗时会报错error: LSI_VALUE undeclared解决方案很简单在stm32f1xx_hal_conf.h里确保所有时钟宏都有定义。Blue Pill 开发板通常用 8MHz 外部晶振HSE内部高速振荡器HSI是 8MHz内部低速振荡器LSI大约 40kHz外部低速晶振LSE通常是 32.768kHz如果板子上有的话。把这些都写上#defineHSE_VALUE8000000U// 8MHz 外部晶振#defineHSI_VALUE8000000U// 8MHz 内部高速振荡器#defineLSI_VALUE40000U// 40kHz 内部低速振荡器#defineLSE_VALUE32768U// 32.768kHz 外部低速晶振如果没有就用这个默认值注意单位是赫兹用大写U后缀表示无符号整数。这里的值对不对影响很大 —— 如果 HSE_VALUE 写错RCC 计算出的系统时钟频率就会错UART 的波特率也会跟着错串口输出就是乱码。第三个坑是assert_param 宏。文件快结尾的地方有这样一个宏定义#ifdefUSE_FULL_ASSERT#defineassert_param(expr)((expr)?(void)0U:assert_failed((uint8_t*)__FILE__,__LINE__))#else#defineassert_param(expr)((void)0U)#endifHAL 库里到处都在用assert_param()来检查函数参数是否合法。比如你调用HAL_GPIO_Init()时传入了一个无效的引脚号assert 会捕获这个错误。如果你定义了USE_FULL_ASSERTassert 失败时会跳转到assert_failed()函数这个函数需要你自己实现否则就什么都不做空宏。很多新手忘记定义assert_param导致编译时报错说undefined macro。解决办法要么在stm32f1xx_hal_conf.h里把上面那段代码加上模板里已经有了确认没被注释掉要么在 CMake 里加-DUSE_FULL_ASSERT0。第四个坑是模块的 callback 宏。文件后半部分有一大堆USE_HAL_XXX_REGISTER_CALLBACKS这些是为了启用 HAL 的回调函数注册功能一种更灵活的中断处理方式。默认值是 0对于简单应用保持 0 就行。如果你改成 1就需要为每个外设实现回调函数代码复杂度会上升。最后还有一个细节stm32f1xx_hal_conf.h必须能被 HAL 库的头文件找到。通常的做法是把它放到src/目录然后通过 CMake 的target_include_directories把src/加到包含路径里。或者你可以直接放到项目根目录编译时用-I.指定。HAL 库的头文件会通过#include stm32f1xx_hal_conf.h来引用它注意是引号不是尖括号所以它必须在搜索路径里。template 文件的坑预告在结束之前我要提前预警一个 CMake 篇才会遇到的坑。如果你直接把整个 HAL 库的Src/目录都扔给 CMake 编译会报类似这样的错误multiple definition of HAL_MspInit这是因为 HAL 库里有几个*_template.c文件比如stm32f1xx_hal_msp_template.c。这些模板文件不是用来直接编译的而是让你复制到项目里修改成自己的实现。如果你把它们也编译进去就会和你的实现冲突两个文件都定义了HAL_MspInit()。解决办法是在 CMake 里用list(FILTER)把这些 template 文件从源文件列表里排除掉。具体的 CMake 写法留到下一篇讲现在你只需要知道不要盲目地把 HAL 库的所有.c文件都加进来编译那些带template后缀的要剔除出去。到哪一步了这篇我们完成了项目结构的搭建。你现在应该有一个正确克隆的 HAL 库submodule 都初始化了知道 F103C8T6 要用startup_stm32f103xb.s启动文件和-DSTM32F103xB宏一个清晰的项目目录布局一个配置好的stm32f1xx_hal_conf.h时钟宏、模块选择都没问题但还没完。下一篇我们会讲链接脚本和 CMake 配置这才是让代码真正能编译出来的关键。链接脚本要告诉链接器 STM32F103C8T6 的 Flash 起始地址是 0x08000000、大小 64KB、RAM 从 0x20000000 开始、大小 20KB。写错了这个文件程序能编译通过但运行不起来因为代码被放到了错误的内存地址。在那之前你可以先把项目结构建起来把stm32f1xx_hal_conf.h复制并修改好。下一篇文章我们开始写 CMakeLists.txt 和链接脚本争取让你编译出第一个.bin固件文件。