嵌入式C++教程实战之Linux下的单片机编程:从零搭建 STM32 开发工具链(4)从零构建 STM32 构建系统

嵌入式C++教程实战之Linux下的单片机编程:从零搭建 STM32 开发工具链(4)从零构建 STM32 构建系统

项目和教程已经开源:https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_AwesomeModernCPP,欢迎访问,随意提Issue或者改进意见!

我现在正盯着屏幕上的 CMakeLists.txt,手里的咖啡已经凉了。如果你跟着前两篇文章一路折腾过来,现在应该已经有了交叉编译工具链,也把 STM32 的固件库下载好了。但真正的问题才刚刚开始:怎么让这一切东西乖乖地编译链接成一个能烧进芯片的 .bin 文件?我第一次做这件事的时候,光是让 CMake 理解"这是一个裸机 ARM 项目,不要尝试运行测试程序"就花了半个下午。今天我们就来把这个构建系统从头到尾理清楚。

先看完整的 CMakeLists.txt

不废话,先把完整的配置放出来,我们再逐段拆解。这个文件放在项目根目录下,和 build.sh 在同一个位置:

cmake 复制代码
cmake_minimum_required(VERSION 3.20)

project(STM32F103C8T6_Project C CXX ASM)

# ========== 交叉编译设置 ==========
set(CMAKE_SYSTEM_NAME Generic)
set(CMAKE_SYSTEM_PROCESSOR ARM)

# 指定交叉编译工具链前缀
set(CROSS_COMPILE arm-none-eabi-)
set(CMAKE_C_COMPILER ${CROSS_COMPILE}gcc)
set(CMAKE_CXX_COMPILER ${CROSS_COMPILE}g++)
set(CMAKE_ASM_COMPILER ${CROSS_COMPILE}gcc)
set(CMAKE_OBJCOPY ${CROSS_COMPILE}objcopy)
set(CMAKE_SIZE ${CROSS_COMPILE}size)

# 防止 CMake 尝试运行测试程序(裸机环境无法运行)
set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)

# 导出 compile_commands.json 给 clangd/VSCode 用
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

# ========== 项目路径设置 ==========
set(PROJECT_ROOT ${CMAKE_CURRENT_SOURCE_DIR})
set(STM32_HAL_ROOT ${PROJECT_ROOT}/third_party/STM32F1/Drivers)
set(STM32_CMSIS_ROOT ${STM32_HAL_ROOT}/CMSIS)
set(STM32_HAL_DRIVER_ROOT ${STM32_HAL_ROOT}/STM32F1xx_HAL_Driver)

# ========== 源文件收集 ==========
# 启动文件
file(GLOB STARTUP_SRC
    ${STM32_CMSIS_ROOT}/Device/ST/STM32F1xx/Source/Templates/gcc/startup_stm32f103xb.s
)

# system_stm32f1xx.c(系统初始化,包含 SystemInit 函数)
list(APPEND STARTUP_SRC
    ${STM32_CMSIS_ROOT}/Device/ST/STM32F1xx/Source/Templates/system_stm32f1xx.c
)

# HAL 库源文件(全量加入,稍后排除 template 文件)
file(GLOB HAL_SRC
    ${STM32_HAL_DRIVER_ROOT}/Src/*.c
)

# 排除所有 _template.c 文件(会导致 multiple definition 错误)
list(FILTER HAL_SRC EXCLUDE REGEX ".*_template\\.c$")

# 用户代码(目前先放一个占位文件)
set(USER_SRC
    ${PROJECT_ROOT}/src/main.cpp
)

# ========== 编译选项(公共部分) ==========
add_compile_options(
    -mcpu=cortex-m3          # STM32F103 的核心是 Cortex-M3
    -mthumb                  # 使用 Thumb 指令集(更省空间)
    -O2                      # 优化级别
    -g3                      # 生成详细的调试信息
    -Wall                    # 开启所有警告
    -Wextra                  # 开启额外警告
    -ffunction-sections      # 每个函数放一个段(便于链接时 GC)
    -fdata-sections          # 每个数据对象放一个段
)

# ========== 编译选项(语言特定)==========
# 使用 generator expression 区分 C 和 C++ 选项
add_compile_options(
    "$<$<COMPILE_LANGUAGE:C>:-std=c11>"
    "$<$<COMPILE_LANGUAGE:CXX>:-std=c++17>"
    "$<$<COMPILE_LANGUAGE:CXX>:-fno-exceptions>"  # 裸机环境没有异常支持
    "$<$<COMPILE_LANGUAGE:CXX>:-fno-rtti>"        # 不需要 RTTI
)

# ========== 宏定义 ==========
add_definitions(
    -DSTM32F103xB            # 芯片型号(很重要!)
    -DUSE_HAL_DRIVER         # 使用 HAL 库
    -DHSE_VALUE=8000000      # 外部晶振频率(8MHz)
)

# ========== 包含路径 ==========
include_directories(
    ${STM32_CMSIS_ROOT}/Include
    ${STM32_CMSIS_ROOT}/Device/ST/STM32F1xx/Include
    ${STM32_HAL_DRIVER_ROOT}/Inc
    ${PROJECT_ROOT}/include
)

# ========== 链接选项 ==========
add_link_options(
    -mcpu=cortex-m3
    -mthumb
    -nostartfiles            # 不使用标准库的启动文件
    -specs=nano.specs        # 使用 newlib-nano(精简版 C 库)
    -specs=nosys.specs       # 不提供系统调用实现(我们需要自己提供)
    -Wl,--gc-sections        # 链接时删除未使用的段
    -Wl,-Map=${CMAKE_BINARY_DIR}/output.map  # 生成 map 文件
    -T${PROJECT_ROOT}/ld/STM32F103XB_FLASH.ld  # 指定链接脚本
)

# ========== 可执行文件 ==========
add_executable(${PROJECT_NAME}
    ${STARTUP_SRC}
    ${HAL_SRC}
    ${USER_SRC}
)

# ========== 后处理步骤 ==========
# 生成 .bin 文件
add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD
    COMMAND ${CMAKE_OBJCOPY} -O binary $<TARGET_FILE:${PROJECT_NAME}> ${CMAKE_BINARY_DIR}/${PROJECT_NAME}.bin
    COMMENT "Generating ${PROJECT_NAME}.bin"
)

# 显示固件大小信息
add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD
    COMMAND ${CMAKE_SIZE} $<TARGET_FILE:${PROJECT_NAME}>
    COMMENT "Firmware size:"
)

# ========== 自定义目标 ==========
# 烧录目标(调用 flash.sh)
add_custom_target(flash
    COMMAND ${PROJECT_ROOT}/scripts/flash.sh ${CMAKE_BINARY_DIR}/${PROJECT_NAME}.bin
    DEPENDS ${PROJECT_NAME}
    COMMENT "Flashing firmware to STM32..."
)

# 擦除目标
add_custom_target(erase
    COMMAND ${PROJECT_ROOT}/scripts/erase.sh
    COMMENT "Erasing STM32 flash..."
)

好了,我知道这个文件看起来有点吓人。我第一次写的时候也是对着 STM32CubeIDE 生成的 Makefile 一行一行"翻译"过来的。我们把它拆开来看,你会发现每个部分都有它存在的道理。

交叉编译基础设置

最前面这几行是 CMake 交叉编译的"标准姿势":

cmake 复制代码
set(CMAKE_SYSTEM_NAME Generic)
set(CMAKE_SYSTEM_PROCESSOR ARM)

CMAKE_SYSTEM_NAME 设置为 Generic 是告诉 CMake:这不是一个 Linux/Windows/macOS 程序,而是一个裸机环境。如果你把它设成 Linux,CMake 会尝试去找 Linux 头文件,然后你就得一整排红色波浪线等着你。

CMAKE_SYSTEM_PROCESSOR = ARM 主要是给一些检测 CPU 架构的脚本看的,我们的场景下不设也行,但设上总没错。

接下来是指定工具链。注意这里的 ${CROSS_COMPILE} 前缀,加上 arm-none-eabi- 之后,CMake 会自动推导出完整的工具链路径。如果你用 wherewhich 命令能看到 arm-none-eabi-gcc,那这里就能工作。

最关键的是这一行:

cmake 复制代码
set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)

这个设置救了我一命。默认情况下,CMake 在配置项目时会编译一个小程序并尝试运行它,用来测试工具链是否正常工作。但问题是:我们编译的是 ARM 程序,在 x86_64 的开发机上根本跑不起来!如果不加这一行,CMake 会报 try_compile 失败的错误。把它设成 STATIC_LIBRARY 后,CMake 只会编译测试程序但不尝试链接运行,问题就解决了。

最后一行的 CMAKE_EXPORT_COMPILE_COMMANDS 虽然不是必需的,但强烈建议开启。它会生成一个 compile_commands.json 文件,clangd 和 VSCode 的 C++ 插件会读取这个文件来获取正确的编译选项。没有它,你的 IDE 会找不到 STM32 的头文件,所有 HAL_GPIO_WritePin 这种调用都会被标成"未定义符号"。

源文件收集 ------ 那个该死的 template 问题

接下来我们来把所有需要的源文件收集起来。首先是启动文件:

cmake 复制代码
file(GLOB STARTUP_SRC
    ${STM32_CMSIS_ROOT}/Device/ST/STM32F1xx/Source/Templates/gcc/startup_stm32f103xb.s
)

注意这里的文件名:startup_stm32f103xb.s。如果你用 Blue Pill,那芯片型号是 STM32F103C8T6,对应的启动文件就是 xb 后缀(表示 medium-density devices,64KB~128KB Flash)。我第一次手滑写成了 startup_stm32f103x8.s,结果 CMake 找不到文件,报了一个很晦涩的错误。记住:C8T6 用 xb

除了启动文件,我们还需要 system_stm32f1xx.c。这个文件包含了 SystemInit() 函数,在启动文件里会被调用,用来设置系统时钟和 Flash 配置。如果不加这个文件,链接器会报 undefined reference to SystemInit,然后你会花一个小时去找这个函数到底在哪里。

然后是 HAL 库的源文件。我一开始很天真,以为直接 GLOB 所有 .c 文件就行:

cmake 复制代码
file(GLOB HAL_SRC
    ${STM32_HAL_DRIVER_ROOT}/Src/*.c
)

如果你也这么写,编译到一半会看到这么一个错误:

text 复制代码
multiple definition of 'HAL_InitTick'
hal/src/hal_timebase_tim.c:123: first defined here
hal/src/hal_timebase_tim_template.c:98: also defined here

问题出在 STM32 HAL 库里有一堆 _template.c 文件,比如 stm32f1xx_hal_timebase_tim_template.c。这些模板文件提供了某些函数的默认实现,但它们不应该和普通的 HAL 文件一起被编译进去。解决方案是加一个过滤器:

cmake 复制代码
list(FILTER HAL_SRC EXCLUDE REGEX ".*_template\\.c$")

这行代码会把所有匹配 *_template.c 的文件从 HAL_SRC 列表里踢出去。那个正则表达式的 \\.c 需要转义点号,否则 . 会匹配任意字符,可能会误删正常文件。我第一次写的时候忘了转义,结果连 stm32f1xx_hal.c 都被排除了,链接器报了几百个 undefined reference

最后是用户代码的源文件。目前我们只有一个空的 main.cpp,但你可以用 GLOB 或者手动添加更多文件。

编译选项 ------ 小心 C++ 专属选项

公共编译选项部分没什么好说的,主要是一些 ARM 特定的选项:

cmake 复制代码
add_compile_options(
    -mcpu=cortex-m3
    -mthumb
    -O2
    -g3
    -Wall
    -Wextra
    -ffunction-sections
    -fdata-sections
)

-mthumb 是很重要的。Thumb 指令集是 ARM 的 16 位精简指令集,生成的代码更小,对于 Flash 只有 64KB 的 Blue Pill 来说能省一点是一点。-ffunction-sections-fdata-sections 会把每个函数和数据对象放到独立的段里,配合链接时的 --gc-sections 选项,可以删除所有没被用到的代码。如果你不加这两个选项,最终的固件可能会大得离谱。

接下来是语言特定的选项,这里是新手最容易踩坑的地方:

cmake 复制代码
add_compile_options(
    "$<$<COMPILE_LANGUAGE:C>:-std=c11>"
    "$<$<COMPILE_LANGUAGE:CXX>:-std=c++17>"
    "$<$<COMPILE_LANGUAGE:CXX>:-fno-exceptions>"
    "$<$<COMPILE_LANGUAGE:CXX>:-fno-rtti>"
)

这个 $<$<COMPILE_LANGUAGE:CXX>:...> 语法叫做 generator expression,是 CMake 的条件表达式,表示"只有在编译 C++ 文件时才应用这些选项"。你可能会问:为什么不直接把这些选项和公共选项放一起?

问题在于:-fno-exceptions-fno-rtti 是 C++ 特定的选项,GCC 在编译 C 文件时会警告这些选项对 C 语言无效。虽然只是 warning 不会导致编译失败,但看满屏的黄色警告会强迫症发作。更严重的是,某些工具链(比如某些版本的 ARM GCC)会在遇到这些选项时直接报错。

我一开始图省事,把 -fno-exceptions 直接加到公共选项里,结果编译 HAL 库的 C 文件时每个文件都报 warning。足足有五十多个 warning,把真正的错误信息淹没了。后来才知道可以用 generator expression 来按语言区分选项,这才清静了。

链接选项 ------ 为什么需要 nosys.specs

链接选项部分有几个关键点需要解释:

cmake 复制代码
add_link_options(
    -mcpu=cortex-m3
    -mthumb
    -nostartfiles
    -specs=nano.specs
    -specs=nosys.specs
    -Wl,--gc-sections
    -Wl,-Map=${CMAKE_BINARY_DIR}/output.map
    -T${PROJECT_ROOT}/ld/STM32F103XB_FLASH.ld
)

-nostartfiles 告诉链接器不要使用标准库的启动文件(比如 crt0.o)。我们有自己专门为 STM32 写的启动文件,标准库的那个会用错内存布局。

-specs=nano.specs 会链接 newlib-nano,这是 newlib C 标准库的精简版本。它去掉了浮点数格式化支持、线程安全等在嵌入式场景用不到的功能,能显著减小代码体积。如果你不加这个选项,最终固件可能会大上好几 KB。

-specs=nosys.specs 比较有意思。它告诉链接器:"不要提供系统调用的实现"。在 Linux 上,C 标准库的函数比如 printf 会通过系统调用来操作文件描述符。但在裸机环境下没有操作系统,所以我们需要自己实现这些系统调用(比如 write()read() 等)。nosys.specs 提供了一套空的系统调用存根,避免链接器报 undefined reference。我们稍后会在 syscalls.c 文件里提供自己的实现(这部分内容在下一篇文章里会详细讲)。

-Wl,--gc-sections 是链接时垃圾回收。配合编译时的 -ffunction-sections-fdata-sections,它会删除所有没被引用的段。如果你只用了 GPIO 和 UART,那 SPI、I2C、ADC 的代码都会被丢掉,最终固件会小很多。

最后一行的 -T 指定了链接脚本文件。这个文件定义了 Flash 和 RAM 的布局,我们稍后会详细分析。

链接脚本详解

链接脚本是个很多工程师都搞不明白的东西,我第一次接触的时候也是一脸懵。简单来说,它告诉链接器:哪些代码放 Flash 里,哪些变量放 RAM 里,堆栈多大,从哪个地址开始执行。下面是一个简化版的 STM32F103C8T6 链接脚本,我们把关键部分拆开来看。

首先是 MEMORY 定义:

ld 复制代码
MEMORY
{
  FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 128K
  RAM (rwx)   : ORIGIN = 0x20000000, LENGTH = 20K
}

这里的 rxrwx 是权限标志:r = 可读,w = 可写,x = 可执行。Flash 是只读的(烧进去后就不能改),所以只有 rx;RAM 是可读可写可执行的,所以是 rwxORIGIN 是起始地址,LENGTH 是大小。STM32F103C8T6 有 128KB Flash 和 20KB RAM,这些数据可以在芯片的 datasheet 里找到。

接下来是 SECTIONS 定义,这是最关键的部分:

ld 复制代码
ENTRY(Reset_Handler)

SECTIONS
{
  .isr_vector :
  {
    KEEP(*( .isr_vector ))
  } > FLASH

  .text :
  {
    *(.text*)
    *(.rodata*)
  } > FLASH

  .data :
  {
    *(.data*)
  } > RAM AT > FLASH
}

ENTRY(Reset_Handler) 指定了程序的入口点。Reset_Handler 是启动文件里的一个函数,它会在芯片复位时被执行。

.isr_vector 段存放中断向量表,这是 STM32 启动时第一件要读的东西。注意这里用了 KEEP(...) 指令。如果你不加 KEEP,链接器可能会认为向量表没有被引用(因为代码里没有直接访问它),然后在 --gc-sections 时把它删掉。结果就是芯片复位后找不到向量表,程序直接跑飞。我第一次编译时就忘了加 KEEP,烧进去后芯片一点反应都没有,排查了一整晚。

.text 段存放所有代码和只读数据(比如字符串字面量)。它们都放在 Flash 里。

.data 段存放已初始化的全局变量和静态变量,比如 int count = 0;。这里有个很关键的语法:> RAM AT > FLASH。它的意思是:这些变量最终要放在 RAM 里(因为运行时需要修改),但它们的初始值存放在 Flash 里。为什么?因为 Flash 里的内容断电后不会丢失,而 RAM 断电后数据就没了。启动代码会在 Reset_Handler 里把 Flash 里的初始值复制到 RAM 里,这个过程叫"data 段初始化"。

如果忘记加 AT > FLASH,链接器会认为初始值就放在 RAM 里,但 RAM 里断电后是空的,结果就是所有变量初始值都是错的。我见过有人在调试时发现全局变量总是随机值,最后查出来是链接脚本写错了。

最后是堆栈设置:

ld 复制代码
_stack_start = ORIGIN(RAM) + LENGTH(RAM);
_stack_end = _stack_start - 0x400;  /* 1KB stack */

_heap_start = _ebss;
_heap_end = _stack_start;

栈从 RAM 的末尾开始向下生长,堆从 BSS 段的末尾开始向上生长。这里留了 1KB 给栈,如果你的函数调用层次很深或者用了大量局部数组,可能需要增大这个值。如果栈溢出了,程序行为会完全不可预测,可能死机,可能跳到随机地址执行。

后处理和自定义目标

编译链接完成后,我们需要把 ELF 文件转换成原始二进制格式,这样才能用 st-flash 或者 OpenOCD 烧录:

cmake 复制代码
add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD
    COMMAND ${CMAKE_OBJCOPY} -O binary $<TARGET_FILE:${PROJECT_NAME}> ${CMAKE_BINARY_DIR}/${PROJECT_NAME}.bin
    COMMENT "Generating ${PROJECT_NAME}.bin"
)

objcopy 会把 ELF 文件的所有段(包括 .text、.data、.rodata 等)按地址顺序拼接成一个纯二进制文件,去掉所有 ELF 元数据。最终得到的 .bin 文件可以直接烧进 Flash 里。

size 命令会显示各个段的大小,帮助你判断固件有没有超出 Flash 容量:

text 复制代码
text    data     bss     dec     hex filename
 4512     124    1024    5660    161c stm32f103c8t6_project.elf

这里的 text 是代码段,data 是已初始化数据段(初始值在 Flash 里),bss 是未初始化数据段(直接在 RAM 里分配)。你可以用 text + data 来估算占用的 Flash 空间。

最后是两个自定义目标:flasherase。它们会调用我们之前写的 flash.sherase.sh 脚本,让你可以用 make flashcmake --build build --target flash 来直接烧录固件,不用手动敲 st-flash 命令。

常见编译错误速查

即使你照着上面一步步来,也还是可能遇到各种问题。这里列出几个我踩过的坑和对应的解决方法。

错误:startup_stm32f103x8.s: No such file or directory

你把启动文件名写错了。Blue Pill 用的是 startup_stm32f103xb.s(medium-density),不是 x8。去 CMSIS 目录下 ls 一下,确认文件名正确。

错误:'LSI_VALUE' undeclared here

你缺少 stm32f1xx_hal_conf.h 文件,或者这个文件里没有定义必要的宏。确保你的 include 路径包含了 HAL 驱动的 Inc 目录,并且 stm32f1xx_hal_conf.h 存在。通常这个文件在 STM32F1xx_HAL_Driver/Inc/ 里有模板版本,需要复制到你的项目里并修改。

错误:multiple definition of 'HAL_InitTick'

你把 *_template.c 文件也编进去了。检查你的 HAL_SRC 列表,确保用 list(FILTER ... EXCLUDE REGEX ".*_template\\.c$") 过滤掉了这些模板文件。

错误:undefined reference to '_init'undefined reference to '__libc_init_array'

这是 newlib 的问题。_init 是 C++ 全局对象构造时会被调用的函数,但裸机环境没有提供实现。你需要创建一个 syscalls.c 文件提供 _init 的空实现。这个问题我们会在下一篇详细讲解如何实现自己的系统调用存根。

警告:ignoring option '-fno-rtti' because it is not a valid option for C language

你把 C++ 专属选项加到了公共编译选项里,导致编译 C 文件时 GCC 警告。用 generator expression 把这些选项包起来:"$<$<COMPILE_LANGUAGE:CXX>:-fno-rtti>"

现在你可以试着运行 ./build.sh,如果一切顺利,你应该能在 build/ 目录下看到 .elf.bin 文件,终端里会显示固件的大小信息。如果有报错,对照上面的错误列表一个个排查。

下一篇文章我们会讲解如何实现 syscalls.c 来解决 _init 的 undefined reference 问题,以及如何用 C++ 重写启动代码,让全局对象的构造和析构正确执行。到那时,你就可以在 main 函数里直接写 C++ 代码,用 std::vectorstd::string 等标准库容器了。

相关推荐
AI成长日志2 小时前
【笔面试算法学习专栏】双指针专题:简单难度三题精讲(167.两数之和II、283.移动零、344.反转字符串)
学习·算法·面试
钰fly2 小时前
Halcon联合编程适应图像的方法(picture)
开发语言·前端·javascript
束尘2 小时前
Vue3一键复制图片到剪贴板
开发语言·javascript·vue.js
老王熬夜敲代码2 小时前
LangGraph的状态
开发语言·langchain
2401_827499992 小时前
python核心语法03-数据存储容器
开发语言·python
AC赳赳老秦2 小时前
自媒体博主:OpenClaw多Agent协同,实现选题-创作-审核全流程自动化
运维·服务器·开发语言·人工智能·自动化·媒体·openclaw
酉鬼女又兒2 小时前
零基础快速入门前端DOM 节点操作核心知识点及蓝桥杯 Web 应用开发考点解析(可用于备赛蓝桥杯Web应用开发)
开发语言·前端·javascript·职场和发展·蓝桥杯
T1an-12 小时前
最右IOS开发A卷笔试题3.31
c++·ios
paeamecium2 小时前
【PAT甲级真题】- Linked List Sorting (25)
数据结构·c++·算法·pat考试·pat