本帖最后由 pophu640 于 2026-9-15 19:41 编辑
使用 LLVM/Clang 工具链编译 MCU 程序
本文档以 STM32F407 (Cortex-M4) 为例,详细讲解如何使用 Nimmake 配合 LLVM/Clang 工具链编译嵌入式 MCU 程序。
示例代码位于 https://github.com/pophu/nimmake samples/09_arm_llvm/Nimmake.py。
---
## 目录
1. [为什么选择 Clang 编译 MCU](#1-为什么选择-clang-编译-mcu)
2. [环境准备](#2-环境准备)
3. [Nimmake.py 逐行解析](#3-nimmakepy-逐行解析)
- [3.1 导入与路径配置](#31-导入与路径配置)
- [3.2 Helper 初始化与配置](#32-helper-初始化与配置)
- [3.3 工具链设置](#33-工具链设置)
- [3.4 库依赖配置](#34-库依赖配置)
- [3.5 第三方组件系统(Parties)](#35-第三方组件系统parties)
- [3.6 构建目标](#36-构建目标)
- [3.7 后处理命令](#37-后处理命令)
- [3.8 Phony 目标](#38-phony-目标)
4. [Clang vs GCC 的关键差异](#4-clang-vs-gcc-的关键差异)
5. [运行构建](#5-运行构建)
6. [完整脚本一览](#6-完整脚本一览)
---
## 1. 为什么选择 Clang 编译 MCU
相比于传统的 `arm-none-eabi-gcc`,使用 LLVM/Clang 工具链编译嵌入式程序具有以下优势:
下面是 Clang 与 GCC 在嵌入式开发中的整体对比流程图:
```mermaid
flowchart LR
subgraph A["传统方案:GCC"]
A1["arm-none-eabi-gcc"]
A2["libgcc 隐式链接"]
A3["GPL 许可证"]
end
subgraph B["现代方案:LLVM/Clang"]
B1["clang + LLVMARMBackend"]
B2["clang_rt.builtins 显式链接"]
B3["Apache 2.0 许可证"]
end
A --> C["编译 MCU 程序"]
B --> C
C --> D["生成 ELF 文件"]
D --> E["llvm-objcopy 转换"]
E --> F["烧录到 STM32F407"]
```
| 特性 | Clang/LLVM | GCC |
| -------------- | ------------------------------------- | -------------------- |
| **错误信息** | 更清晰、更易读,带颜色高亮 | 较冗长 |
| **编译速度** | 通常更快,尤其在增量编译场景 | 中等 |
| **代码生成** | 支持 LTO 链接时优化,生成更紧凑的代码 | 同样支持 LTO |
| **工具链统一** | 同一套 LLVM 基础架构,跨架构体验一致 | 不同架构需要不同 GCC |
| **静态分析** | 内置 `clang-tidy`, `clang-analyzer` | 需要第三方工具 |
| **许可证** | Apache 2.0(更宽松) | GPL(传染性) |
| **ARM 支持** | 通过 `arm-arm-none-eabi` target 支持 | 原生 ARM 支持 |
---
## 2. 环境准备
### 2.1 安装 LLVM/Clang 工具链
你需要一个带 ARM 后端的 Clang 工具链。推荐以下方案:
**方案 A:官方 LLVM + ARM 运行时**
- 下载 [LLVM 官方发布版](https://releases.llvm.org/)
- 安装 ARM 运行时库
**方案 B:ARM 官方 LLVM 工具链**
- 从 ARM 官网下载 [ARM LLVM 工具链](https://developer.arm.com/Tools%20and%20Software/LLVM%20toolchain)
**方案 C:自制或第三方 LLVM 分发版**
- 例如示例中的 `D:\LLVM\ETArm\bin` 路径
### 2.2 安装 Nimmake
```bash
pip install nimmake
```
### 2.3 验证工具链
```bash
# 验证 clang 可用
clang --version
# 验证 ARM 交叉编译支持
clang --target=arm-arm-none-eabi -print-targets
```
环境准备的整体流程如下:
```mermaid
flowchart TD
A["开始"] --> B{"选择工具链方案"}
B -->|"方案 A"| C["官方 LLVM + ARM 运行时"]
B -->|"方案 B"| D["ARM 官方 LLVM 工具链"]
B -->|"方案 C"| E["自制或第三方 LLVM 分发版"]
C --> F["安装 Nimmake"]
D --> F
E --> F
F --> G["验证工具链"]
G --> H{"clang --version 通过?"}
H -->|"是"| I["环境准备完成"]
H -->|"否"| J["检查路径配置"]
J --> G
```
---
## 3. Nimmake.py 逐行解析
### 3.1 导入与路径配置
```python
from nimmake.configs import BuildType
from nimmake.datasets import CORTEX_M4_CLANG_CFG
from nimmake.Helper import Helper, Path
```
- `BuildType`:构建类型枚举(TARGET、STATIC、SHARED 等)
- `CORTEX_M4_CLANG_CFG`:Nimmake 内置的 Cortex-M4 Clang 配置预设,包含 CPU、FPU、优化级别等默认参数
- `Helper`:Nimmake 的核心 API 类,提供所有构建配置接口
- `Path`:来自 `pathlib` 的路径处理类,用于跨平台路径操作
```python
# 工具链路径配置
toolpath_armgcc = r"D:\LLVM\arm-none-eabi-gcc14\bin" # GCC 工具链(可选备用)
toolpath_armllvm = r"D:\LLVM\ETArm\bin" # LLVM ARM 工具链(当前使用)
toolpath_riscv = r"D:\LLVM\riscv\bin" # RISC-V 工具链(可选备用)
toolpath = toolpath_armllvm # 选择使用的工具链路径
tool = "clang" # 工具类型
prefix = "arm" # 前缀标识,用于选择 LLVMARMBackend
```
> **关键点**:这里 `tool = "clang"`, `prefix = "arm"`。在 Nimmake 的 Backend 注册表中,`("clang", "arm")` 映射到 `LLVMARMBackend`,它会自动生成 ARM Clang 的编译标志(如 `--target=arm-arm-none-eabi`)。
Nimmake 的配置初始化流程如下:
```mermaid
flowchart TD
A["导入 Nimmake 模块"] --> B["配置工具链路径"]
B --> C["创建 Helper 实例"]
C --> D["加载 CORTEX_M4_CLANG_CFG 预设"]
D --> E["覆盖配置参数"]
E --> F["设置 linkscript"]
E --> G["设置 sysroot"]
E --> H["设置 nostdlib"]
F --> I["配置完成"]
G --> I
H --> I
```
### 3.2 Helper 初始化与配置
```python
hlp = Helper()
# 使用 Cortex-M4 Clang 配置预设
CFG = CORTEX_M4_CLANG_CFG.clone()
hlp.Config(CFG)
```
`CORTEX_M4_CLANG_CFG` 的定义如下(来自 `presets_dct.py`):
```python
CORTEX_M4_CLANG_DCT = {
"toolchain": "clang",
"cpu": "cortex-m4",
"fpu": "fpv4-sp-d16", # Cortex-M4 单精度浮点
"opt": "O2", # 优化级别
"dbg": True, # 生成调试信息
"warn": "Wall",
"std_c": "c99",
"std_cxx": "c++11",
"nostdlib": True, # 不使用标准库
"freestanding": False,
"data_sections": "-fdata-sections",
"func_sections": "-ffunction-sections",
"linkscript": "stm32f4.ld",
"gc_sections": True,
}
```
然后通过 `set_cfg` 方法覆盖默认配置中的部分参数:
```python
# 设置链接脚本
hlp.set_cfg("linkscript", "STM32F407XX_FLASH.ld")
# 设置 sysroot 路径(指向 clang 运行时库)
sysroot = Path(toolpath).parent / "lib/clang-runtimes/arm-none-eabi/armv7m_hard_fpv4_sp_d16"
hlp.set_cfg("sysroot", sysroot.as_posix())
# 不使用标准库
hlp.set_cfg("nostdlib", True)
# 清空库路径(因为使用 sysroot)
hlp.set_cfg("library_path", "")
```
> **sysroot 详解**:`--sysroot` 告诉 Clang 在指定路径下搜索头文件和库文件。这里指向 Clang 内置的 `armv7m_hard_fpv4_sp_d16` 运行时,该目录包含 `libclang_rt.builtins-armv7m.a` 等内置函数实现。
### 3.3 工具链设置
```python
hlp.Update({
"TOOLPATH": toolpath, # 工具链路径
"TOOL": tool, # "clang"
"TOOL_PREFIX": prefix, # "arm"
})
```
`Update` 方法将配置写入 Helper 的环境变量。此时 `TOOL="clang"`, `TOOL_PREFIX="arm"`,Nimmake 会根据这两个值查找合适的 Backend:
```python
# Backends.py 中的注册表
BACKENDS = {
("clang", "arm"): LLVMARMBackend, # ← 匹配当前配置
("clang", ""): ClangBackend,
("gcc", "arm"): ARMGCCBackend,
# ...
}
```
调用 `hlp.Refresh()` 时发生的完整流程:
1. 根据 `TOOL` 和 `TOOL_PREFIX` 查找 Backend 类 → `LLVMARMBackend`
2. 创建 Backend 实例,验证工具链是否存在(执行 `clang --version`)
3. 从 `CFG` 读取 `cpu`、`arch`、`fpu`、`abi` 等参数传递给 Backend
4. Backend 自动生成 CFLAGS、CXXFLAGS、ASFLAGS、ARFLAGS、LINKFLAGS、DEFINES
5. 将生成的 flags 写回 Helper 的环境变量
`LLVMARMBackend` 生成的关键编译标志:
| 标志 | 说明 |
| ------------------------------------- | -------------------- |
| `--target=arm-arm-none-eabi` | 目标三元组 |
| `-mcpu=cortex-m4` | CPU 类型 |
| `-mfpu=fpv4-sp-d16` | 浮点单元 |
| `-mfloat-abi=hard` | 浮点 ABI(自动推导) |
| `-mthumb` | Thumb 指令集 |
| `-O2` | 优化级别 |
| `-g` | 调试信息 |
| `-ffunction-sections -fdata-sections` | 函数/数据段分离 |
| `--sysroot=...` | 系统根路径 |
| `-T STM32F407XX_FLASH.ld` | 链接脚本 |
| `-nostdlib` | 不使用标准库 |
| `-Wl,--gc-sections` | 链接时删除未使用段 |
`hlp.Refresh()` 的完整执行流程如下:
```mermaid
flowchart TD
A["调用 hlp.Refresh()"] --> B["根据 TOOL 和 TOOL_PREFIX 查找 Backend"]
B --> C{"匹配 (clang, arm)?"}
C -->|"是"| D["创建 LLVMARMBackend 实例"]
C -->|"否"| E["查找其他 Backend"]
D --> F["验证工具链存在"]
F --> G["执行 clang --version"]
G --> H["读取 CFG 参数"]
H --> I["生成编译标志"]
I --> J["CFLAGS / CXXFLAGS"]
I --> K["ASFLAGS / ARFLAGS"]
I --> L["LINKFLAGS / DEFINES"]
J --> M["写回 Helper 环境变量"]
K --> M
L --> M
M --> N["Refresh 完成"]
```
### 3.4 库依赖配置
```python
hlp.Append(LIBS="clang_rt.builtins")
hlp.Append(LIBS="m")
hlp.Append(LIBS="c")
```
- `clang_rt.builtins`:Clang 运行时内置函数库,提供 `__aeabi_*` 等函数实现
- `m`:数学库
- `c`:C 标准库
这些库最终会出现在链接命令中,形如 `-lclang_rt.builtins -lm -lc`。
> **注意**:因为设置了 `nostdlib=True`,链接器不会自动搜索标准库,需要显式指定需要的库。
### 3.5 第三方组件系统(Parties)
```python
PARTY_PARAM = {
# "CPU": hlp._cfg.cpu,
# "ABI": hlp._cfg.abi,
# "FPU": hlp._cfg.fpu,
# "MODEL": hlp._cfg.model,
}
```
`PARTY_PARAM` 是传递给第三方组件的参数字典,这里被注释掉了。如果传递,这些参数会用于在组件内部生成编译标志。
```python
# 创建 Core 组件
core = hlp.Parties("CORE", "Core", params=PARTY_PARAM)
# 创建 Driver 组件(HAL 库)
driver = hlp.Parties(
"Driver",
"Drivers",
third_party="HAL", # 指定第三方类型
build_type=BuildType.STATIC.name, # 构建为静态库
defines={"STM32F407xx": "", "USE_HAL_DRIVER": ""},
params=PARTY_PARAM,
)
```
`Parties` 是 Nimmake 的核心抽象,它将源码组织为"组件/模块":
- 每个 Party 是一个独立的代码模块,有自己的源文件、头文件、宏定义
- `build_type=BuildType.STATIC.name` 表示 Driver 会被编译为静态库(`libdriver.a`)
- `defines` 为该组件额外添加全局宏定义
```python
# 双向依赖(解决 HAL 库内部的交叉引用)
core.DependOn(driver)
driver.DependOn(core)
```
`DependOn` 方法声明组件间的依赖关系。**双向依赖**在 HAL 库中很常见,因为 Core(启动文件、系统初始化)与 Drivers(外设驱动)之间存在相互引用。
Nimmake 的组件依赖关系如下:
```mermaid
flowchart TD
subgraph CORE["Core 组件"]
C1["启动文件"]
C2["系统初始化"]
end
subgraph DRIVER["Driver 组件 (HAL)"]
D1["外设驱动"]
D2["HAL 库"]
end
C1 --> C2
C2 <-->|"双向依赖"| D1
D1 --> D2
subgraph BUILD["构建产物"]
B1["libDriver.a 静态库"]
B2["test ELF 可执行文件"]
end
DRIVER -->|"编译为"| B1
CORE -->|"链接"| B2
DRIVER -->|"链接"| B2
```
### 3.6 构建目标
#### Program 目标
```python
srcs = [
"startup_stm32f407xx.s", # 启动汇编文件
]
t = hlp.Program("test", sources=srcs)
hlp.DefaultTarget(t)
```
- `hlp.Program("test", sources=srcs)`:创建一个名为 `test` 的可执行文件目标,编译指定的源文件
- `hlp.DefaultTarget(t)`:将 `test` 设置为默认构建目标(运行 `nimmake` 时默认构建)
#### Library 目标
```python
mylib = hlp.Library_STATIC("Driver")
```
- `hlp.Library_STATIC("Driver")`:创建一个名为 `Driver` 的静态库目标,名称与之前创建的 Party 名称对应
- 这会将之前的 Driver 组件编译为静态库 `libDriver.a`
### 3.7 后处理命令
```python
target_out = f"{hlp['BUILDDIR']}/test{hlp['TARGET_SUFFIX']}"
bin = hlp.Command(
"BIN",
[
f"{hlp['OBJCOPY']} -O binary {target_out} build/test.bin",
f"{hlp['OBJCOPY']} -O ihex {target_out} build/test.hex",
],
)
```
`Command` 创建自定义命令:
1. **BIN 命令**:使用 `llvm-objcopy`(Clang 工具链的 objcopy)将 ELF 文件转换为:
- `build/test.bin` — 纯二进制格式(用于直接烧录)
- `build/test.hex` — Intel HEX 格式(用于烧录器)
```python
flash_openocd = (
'openocd -f interface/cmsis-dap.cfg -f target/stm32f4x.cfg '
'-c init -c reset -c halt '
'-c "program build/test.bin exit 0x08000000" '
'-c reset -c shutdown'
)
flash = hlp.Command("FLASH", [f"{flash_openocd}"])
```
2. **FLASH 命令**:使用 OpenOCD 通过 CMSIS-DAP 调试器将程序烧录到 STM32F407 的 Flash 中(起始地址 `0x08000000`)。
### 3.8 Phony 目标
```python
# 组合目标:"my" 包含编译 + 生成BIN/HEX + 烧录
hlp.Phony("my", ["test", "BIN", "FLASH"])
# 仅烧录目标
hlp.Phony("flash", ["FLASH"])
```
Phony 目标类似于 Makefile 中的 `.PHONY`:
- 执行 `nimmake my` 等同于依次执行:编译 test → 生成 BIN/HEX → 烧录到开发板
- 执行 `nimmake flash` 仅执行烧录步骤
---
## 4. Clang vs GCC 的关键差异
在 Nimmake 中,从 GCC 切换到 Clang 只需修改几行配置:
| 配置项 | GCC (`05_arm32`) | Clang (`09_arm_llvm`) |
| -------- | -------------------------- | ------------------------------- |
| 配置预设 | `CORTEX_M4_CFG` | `CORTEX_M4_CLANG_CFG` |
| Tool | `gcc` | `clang` |
| Prefix | `arm-none-eabi-` | `arm` |
| FPU | `hard`(GCC 风格) | `fpv4-sp-d16`(LLVM 风格) |
| sysroot | 不需要(GCC 内置搜索路径) | 需要显式指定 |
| 运行时库 | `libgcc`(隐式链接) | `clang_rt.builtins`(显式链接) |
| objcopy | `arm-none-eabi-objcopy` | `llvm-objcopy` |
### 关键差异详解
**1. FPU 标志差异**
- GCC:`-mfpu=fpv4-sp-d16 -mfloat-abi=hard`
- Clang:`--target=arm-arm-none-eabi -mcpu=cortex-m4 -mfpu=fpv4-sp-d16 -mfloat-abi=hard`
LLVMARMBackend 会自动生成 `--target=arm-arm-none-eabi`,这是 Clang 交叉编译的关键参数。
**2. sysroot 的作用**
Clang 不像 GCC 那样内置了 ARM 运行时库的搜索路径。通过 `--sysroot` 告诉 Clang 去哪里找:
```
lib/clang-runtimes/arm-none-eabi/armv7m_hard_fpv4_sp_d16/
├── libclang_rt.builtins-armv7m.a # 内置函数(如 memset, memcpy 的 ARM 实现)
├── libc.a # C 标准库
└── libm.a # 数学库
```
**3. 运行时库**
GCC 隐式链接 `libgcc`(包含 `__aeabi_*` 等函数),而 Clang 需要显式链接 `clang_rt.builtins`。
---
## 5. 运行构建
### 基本构建
```bash
cd samples/09_arm_llvm
nimmake
```
这会执行默认目标 `test`,编译 `startup_stm32f407xx.s` 并链接生成 ELF 文件。
### 完整流程(编译 + 转换 + 烧录)
```bash
nimmake my
```
### 仅烧录
```bash
nimmake flash
```
### 调试选项
```bash
# 查看详细的编译标志
nimmake --verbose
# 预览构建计划但不实际执行
nimmake --dry-run
# 生成 compile_commands.json(IDE 智能提示用)
nimmake --compiledb
# 使用 Ninja 后端加速构建
nimmake --ninja
```
### 查看生成的编译标志
脚本末尾的 `print(hlp.Flags)` 会输出最终的编译标志,类似:
```
========================================
CFLAGS: ['--target=arm-arm-none-eabi', '-mcpu=cortex-m4', '-mfpu=fpv4-sp-d16',
'-mfloat-abi=hard', '-mthumb', '-O2', '-g', '-Wall',
'-ffunction-sections', '-fdata-sections', '-std=c99']
LINKFLAGS: ['--target=arm-arm-none-eabi', '-mcpu=cortex-m4', '-mfpu=fpv4-sp-d16',
'-mfloat-abi=hard', '-mthumb', '-Wl,--gc-sections',
'-TSTM32F407XX_FLASH.ld', '--sysroot=...', '-nostdlib']
========================================
```
---
## 6. 完整脚本一览
```python
from nimmake.configs import BuildType
from nimmake.datasets import CORTEX_M4_CLANG_CFG
from nimmake.Helper import Helper, Path
# ── 工具链路径 ──
toolpath_armllvm = r"D:\LLVM\ETArm\bin"
toolpath = toolpath_armllvm
tool = "clang"
prefix = "arm"
hlp = Helper()
# ── 1. 配置预设 ──
CFG = CORTEX_M4_CLANG_CFG.clone()
hlp.Config(CFG)
hlp.set_cfg("linkscript", "STM32F407XX_FLASH.ld")
sysroot = Path(toolpath).parent / "lib/clang-runtimes/arm-none-eabi/armv7m_hard_fpv4_sp_d16"
hlp.set_cfg("sysroot", sysroot.as_posix())
hlp.set_cfg("nostdlib", True)
hlp.set_cfg("library_path", "")
# ── 2. 工具链 ──
hlp.Update({"TOOLPATH": toolpath, "TOOL": tool, "TOOL_PREFIX": prefix})
# ── 3. 库 ──
hlp.Append(LIBS="clang_rt.builtins")
hlp.Append(LIBS="m")
hlp.Append(LIBS="c")
hlp.Refresh()
# ── 4. 组件 ──
core = hlp.Parties("CORE", "Core")
driver = hlp.Parties(
"Driver", "Drivers",
third_party="HAL",
build_type=BuildType.STATIC.name,
defines={"STM32F407xx": "", "USE_HAL_DRIVER": ""},
)
core.DependOn(driver)
driver.DependOn(core)
# ── 5. 目标 ──
t = hlp.Program("test", sources=["startup_stm32f407xx.s"])
hlp.DefaultTarget(t)
mylib = hlp.Library_STATIC("Driver")
# ── 6. 后处理命令 ──
target_out = f"{hlp['BUILDDIR']}/test{hlp['TARGET_SUFFIX']}"
hlp.Command("BIN", [
f"{hlp['OBJCOPY']} -O binary {target_out} build/test.bin",
f"{hlp['OBJCOPY']} -O ihex {target_out} build/test.hex",
])
flash_openocd = (
'openocd -f interface/cmsis-dap.cfg -f target/stm32f4x.cfg '
'-c init -c reset -c halt '
'-c "program build/test.bin exit 0x08000000" -c reset -c shutdown'
)
hlp.Command("FLASH", [flash_openocd])
# ── 7. Phony ──
hlp.Phony("my", ["test", "BIN", "FLASH"])
hlp.Phony("flash", ["FLASH"])
print(hlp.Flags)
```
---
## 总结
通过 Nimmake 使用 Clang 工具链编译 MCU 程序的完整流程:
```
配置预设 → 设置工具链 → Refresh 生成 Flags → 定义组件 → 定义目标 → 后处理命令 → 构建
```
核心要点:
1. **选择正确的配置预设**:`CORTEX_M4_CLANG_CFG` 而非 `CORTEX_M4_CFG`
2. **正确设置工具链参数**:`TOOL="clang"`, `TOOL_PREFIX="arm"` 触发 `LLVMARMBackend`
3. **配置 sysroot**:Clang 需要显式指定运行时库路径
4. **显式链接运行时库**:`clang_rt.builtins` 替代 GCC 的 `libgcc`
5. **使用 LLVM 工具链的配套工具**:`llvm-objcopy` 替代 `arm-none-eabi-objcopy`
这种配置方式同样适用于 RISC-V 架构,只需更换对应的配置预设和工具链路径即可。
|
|