3、固件工程结构:创建第一个STM32CubeIDE工程

好,咱们正式开始动手了。这一章我会带你从零创建一个STM32CubeIDE工程,然后把这个工程的「骨架」拆开给你看。说白了,就是让你知道你的代码到底跑在什么环境里。

我记得我刚入行那会儿,拿到一个现成的工程,打开一看,一堆.s文件、.ld文件、启动文件……完全不知道干嘛用的。后来踩了不少坑,才慢慢摸清楚。今天咱们就把这些坑提前填上。

3.1 创建第一个STM32CubeIDE工程

打开STM32CubeIDE,咱们一步步来。

  1. 选择工作空间:启动时会让你选一个工作目录。我个人习惯把每个项目单独放一个文件夹,比如 D:\workspace\smartwatch_fw
  2. 新建工程:点击 File → New → STM32 Project
  3. 选择芯片型号:在弹窗里搜索你的芯片型号,比如 STM32L4R9ZI。选好后点 Next
  4. 工程命名:给工程起个名字,比如 smartwatch_fw。注意不要用中文路径。
  5. 初始化配置:IDE会问你要不要初始化所有外设到默认状态。我建议选 Yes,后面再按需裁剪。
小提示:第一次创建工程时,IDE会下载芯片的固件包。如果网络慢,可以提前从官网下载好,放到本地仓库里。

创建完成后,你会看到这样一个工程结构:

smartwatch_fw/
├── Core/
│   ├── Inc/          // 头文件
│   ├── Src/          // 源文件
│   └── Startup/      // 启动文件
├── Drivers/
│   ├── CMSIS/        // ARM内核抽象层
│   └── STM32L4xx_HAL_Driver/  // HAL库
├── .cproject         // Eclipse工程配置
├── .project          // Eclipse项目文件
└── *.ioc             // CubeMX图形化配置

嗯,这里要注意:.ioc文件是CubeMX的图形化配置文件。你双击它,就能用图形界面配置引脚、时钟、外设。配置完保存,IDE会自动生成代码。

3.2 工程目录结构解析

咱们把每个文件夹拆开看看,到底装了什么。

3.2.1 Core 目录

这是你写代码的主战场。

  • Inc/:放头文件。比如 main.hgpio.husart.h 等。IDE会自动生成外设的头文件。
  • Src/:放源文件。你的 main.c 就在这里。还有各种外设的驱动文件,比如 gpio.cusart.c
  • Startup/:启动文件。这个很关键,后面单独讲。
重点:Core目录下的文件,是你最常修改的地方。其他目录(比如Drivers)一般不动。

3.2.2 Drivers 目录

这里装的是官方提供的驱动库。

  • CMSIS/:ARM公司搞的一套标准接口。它定义了内核寄存器、中断向量表、系统时钟等底层东西。说白了,就是让不同厂家的ARM芯片能用同一套API。
  • STM32L4xx_HAL_Driver/:ST官方的HAL库。HAL是 Hardware Abstraction Layer 的缩写。它把寄存器操作封装成了函数,比如 HAL_GPIO_WritePin()HAL_UART_Transmit()

我在项目中遇到过一个问题:HAL库虽然方便,但有些场景下效率不够高。比如你要在中断里快速翻转一个GPIO,用HAL函数可能多出几十个时钟周期的开销。这时候我会直接操作寄存器。

3.3 链接脚本详解

链接脚本,后缀是 .ld。它告诉链接器:你的代码该放到Flash的哪个位置,变量该放到RAM的哪个位置。

打开工程里的 STM32L4R9ZITx_FLASH.ld,你会看到类似这样的内容:

MEMORY
{
  FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 2048K
  RAM   (xrw) : ORIGIN = 0x20000000, LENGTH = 640K
}

SECTIONS
{
  .text :
  {
    *(.isr_vector)    // 中断向量表
    *(.text)          // 代码段
    *(.rodata)        // 只读数据
    _etext = .;       // 代码结束地址
  } > FLASH

  .data :
  {
    _sdata = .;       // 数据段起始地址
    *(.data)          // 已初始化的全局变量
    _edata = .;       // 数据段结束地址
  } > RAM AT> FLASH

  .bss :
  {
    _sbss = .;        // BSS段起始地址
    *(.bss)           // 未初始化的全局变量
    _ebss = .;        // BSS段结束地址
  } > RAM
}

我来解释一下关键点:

  • MEMORY:定义了两块物理存储。Flash是只读的,放代码;RAM是可读写的,放变量。
  • .text:代码段。中断向量表、你的函数、常量字符串都放这里。
  • .data:已初始化的全局变量。比如 int a = 10;,这个10就存在Flash里,启动时拷贝到RAM。
  • .bss:未初始化的全局变量。比如 int b;,启动时清零。
注意:链接脚本里有个坑——AT> FLASH。它表示.data段虽然运行在RAM,但初始值存在Flash里。启动文件负责把这个初始值从Flash拷贝到RAM。如果你自己写启动代码,千万别忘了这一步。

我曾经在一个项目里,因为链接脚本写错了,导致全局变量初始值全是乱的。查了两天才发现是 AT> FLASH 这个属性没写对。嗯,从那以后我每次改链接脚本都会反复检查。

3.4 启动文件详解

启动文件,后缀是 .s。它是汇编写的,负责芯片上电后最开始的初始化工作。

打开 startup_stm32l4r9xx.s,你会看到:

; 中断向量表
g_pfnVectors:
  .word _estack            // 栈顶地址
  .word Reset_Handler      // 复位中断
  .word NMI_Handler        // 不可屏蔽中断
  .word HardFault_Handler  // 硬件错误中断
  // ... 其他中断向量

; 复位中断处理函数
Reset_Handler:
  ldr   r0, =_estack
  mov   sp, r0            // 设置栈指针

  ldr   r0, =_sdata
  ldr   r1, =_edata
  ldr   r2, =_sidata
  bl    CopyDataInit       // 拷贝.data段

  ldr   r0, =_sbss
  ldr   r1, =_ebss
  mov   r2, #0
  bl    FillZero           // 清零.bss段

  bl    SystemInit         // 系统时钟初始化
  bl    main               // 跳转到main函数

启动文件干了这几件事:

  1. 定义中断向量表:芯片上电后,硬件会自动从Flash的0地址读取栈顶指针,然后跳转到复位中断。
  2. 设置栈指针:把 _estack 的值赋给SP寄存器。这个 _estack 是在链接脚本里定义的。
  3. 拷贝.data段:把Flash里存着的初始值,拷贝到RAM里。
  4. 清零.bss段:把未初始化的全局变量全部清零。
  5. 调用SystemInit:配置系统时钟、Flash等待周期等。
  6. 跳转到main:终于,你的C代码开始执行了。
小技巧:如果你在调试时发现全局变量初始值不对,先检查启动文件里的 CopyDataInitFillZero 有没有执行。我经常在这两个地方打断点。

你可能会问:为什么不用C语言写启动代码?因为芯片上电时,C语言运行环境还没准备好——RAM没初始化、栈指针没设置。所以必须用汇编来完成这些最底层的操作。

3.5 本章小结

这一章咱们干了三件事:

  • 创建了第一个STM32CubeIDE工程
  • 拆解了工程目录结构,知道每个文件夹是干嘛的
  • 深入分析了链接脚本和启动文件,明白了芯片上电后到底发生了什么

下一章,咱们会开始写真正的固件代码——点亮智能手表的屏幕。到时候你会看到,今天学的这些底层知识,是怎么支撑起上层应用的。

嗯,今天就到这里。有什么问题,欢迎在评论区留言。