一、Keil 很好,但不够
每一位嵌入式工程师的起点,大概率都始于 Keil。
打开 Keil → 新建工程 → 选芯片 → 加源文件 → 点编译 → 下载。五个步骤,一个下午就能让开发板上的 LED 闪烁起来。对初学者来说,这种"开箱即用"的体验无可挑剔。
但当你从"点亮一颗 LED"走到"维护一个 50 万行代码的产品"时,Keil 的问题就逐渐暴露了:
1. IDE的诸多限制
界面出自于上一个世纪, 看起来比较丑陋,远远比不上像VS Code这样的现代化UI。项目目录与实际代码路径不一致, IDE配置起来也比较死板和麻烦。
2. 团队协作困难
Keil 的工程文件(.uvprojx)是二进制+XML 混合格式,Git diff 几乎不可读。两个人同时改工程配置,合并冲突是常事,而且你根本没法从 diff 里看出谁改了什么。
3. 自动化构建/CI 集成痛苦
现代软件开发离不开 CI/CD------每次提交代码,自动编译、自动测试、自动打包。但 Keil 是 GUI 工具,命令行支持有限,脚本化构建体验极差。你想把编译流程放到 Jenkins 或 GitHub Actions 上?先准备好踩坑。
4. 扩展性差
当项目需要支持多个芯片型号、多种编译配置(Debug/Release/Factory),或者需要定制编译流程(比如编译后自动签名、自动生成 CRC 校验、自动打包 OTA 固件),在 Keil 里你需要手动维护多套工程文件,极易出错。
5. 平台限制 + 闭源 + 收费
Keil-MDK目前仅限在Windows操作系统使用,而且商业项目需要购买 License,且你完全无法了解编译过程的内部机制。
二、换个思路:用脚本控制编译
上述痛点的根源在于:编译配置和 GUI 工具强绑定。
如果我们把"工程是怎么编译的"写成一段脚本,让脚本来控制编译过程,这些痛点就不存在了。
编译脚本的优势:
- 纯文本,Git diff 一目了然
- 脚本化,CI/CD 直接调用命令行
- 灵活扩展,想加什么编译步骤直接写代码
- 跨平台,只要有编译器和 Python,就能构建
在嵌入式领域,最常见的脚本化编译方案有三个:
| 方案 | 简介 | 适用场景 |
|---|---|---|
| Makefile | 经典 GNU Make,历史悠久 | 小型项目,简单编译 |
| CMake | 跨平台构建系统生成器 | 中大型 C/C++ 项目 |
| SCons | Python 脚本驱动的构建系统 | 需要高度定制编译流程的项目 |
今天我们重点介绍 SCons------一个用 Python 写的构建框架。
三、SCons 是什么
SCons 是一个基于 Python 的构建工具(类似 Make,但比 Make 强大得多)。它最初由 Google 的工程师在 2001 年发起,后来成为独立的开源项目,至今仍在活跃维护。
它的核心思想很简单:构建文件就是 Python 脚本 。你在 SConstruct 文件里用 Python 语法描述"怎么编译这个工程",然后运行 scons 命令即可。
类比:Make 的语法是你自己发明的"Makefile 语言",而 SCons 用的是你本来就熟悉的 Python。
SCons 在开源社区有着广泛的应用。国内知名的 RT-Thread 操作系统、国外的 Godot 游戏引擎、MongoDB 数据库等项目,都用 SCons 来管理构建流程。能在这些大型项目中经受住考验,足以说明 SCons 的成熟度和可靠性。
SCons 的核心能力:
- 自动依赖分析 --- 修改哪个文件就重新编译哪个文件,不需要手动维护依赖关系
- Python 的完整能力 --- 条件判断、循环、函数、字典、文件操作......Python 能做的你都能在构建脚本里做
- 跨平台 --- Windows/Linux/macOS 通用
- 可扩展 --- 可以自定义 Builder、Scanner、Action,满足嵌入式领域的各种奇怪需求
- 内置缓存 --- 支持增量编译,不改动的文件不会重新编译
四、实战:一个 STM32 工程的 SCons 化
光说不练假把式。我们来看一个真实的 STM32 Cortex-M4 项目,如何用 SCons 搭建构建系统,代码的仓库地址:gitcode.com/qingshan120...。
4.1 目录结构
先看整体布局,红色文件是 SCons 构建脚本,蓝色是目录,黑色是源码文件:

整个工程的组织逻辑是:每个模块独立维护自己的 SConscript,顶层 SConstruct 统一调度。
4.2 架构设计
SCons 的核心就是 SConstruct(顶层)→ SConscript(子模块) 的层级关系。顶层负责全局配置和调度,子模块各自声明"我有哪些源文件"和"我需要哪些头文件路径"。

各模块职责一览:
| 文件 | 职责 |
|---|---|
SConstruct |
全局入口:工具链配置、编译选项、宏定义、调用子脚本、链接、后处理 |
CMSIS/SConscript |
声明 CMSIS 头文件路径(纯 header,无源文件) |
App/SConscript |
声明应用层源文件 main.c |
Device/SConscript |
声明芯片启动文件 startup_xxx.s + 系统初始化文件 |
Driver/SConscript |
声明 STM32 HAL 驱动源文件(hal, cortex, rcc, gpio, uart, dma, pwr) |
每个 SConscript 通过字典返回两类信息:
python
# Device/SConscript 示例
sources = [
File('Src/startup_stm32l475xx.s'),
File('Src/system_stm32l4xx.c'),
]
includes = [
'Device/Inc',
]
result = {'sources': sources, 'includes': includes}
Return('result')
顶层 SConstruct 统一收集:
python
cmsis = SConscript('CMSIS/SConscript')
device = SConscript('Device/SConscript', variant_dir='build/Device', duplicate=0)
all_sources = device['sources'] + ...
env.Append(CPPPATH = cmsis['includes'] + device['includes'] + ...)
这种设计的优势:新增模块只需加一个 SConscript(返回 sources + includes),SConstruct 几乎不动。
4.3 构建流程
从输入 scons 命令到输出固件,整个过程如下:

关键节点说明:
- 读取 SConstruct --- SCons 解析 Python 构建脚本,确定工具链和编译参数
- 调用 SConscripts --- 逐个子模块收集源文件和头文件路径
- 编译 ---
arm-none-eabi-gcc将.c编译为.o,.s汇编为.o - 链接 --- 所有
.o文件链接为firmware.elf - 后处理 ---
objcopy生成.bin和.hex,arm-none-eabi-size输出段大小报告
所有中间产物(.o 文件)输出到 build/ 目录,与源码完全隔离。
五、SConstruct 核心函数详解
整个构建的逻辑全部浓缩在 SConstruct 文件中,我们来逐段拆解每个函数的作用和参数。
5.1 Environment() --- 创建构建环境
python
env = Environment(
CC = 'arm-none-eabi-gcc', # C 编译器
AS = 'arm-none-eabi-gcc', # 汇编器(GCC 内置汇编器)
LINK = 'arm-none-eabi-gcc', # 链接器
OBJCOPY = 'arm-none-eabi-objcopy', # 格式转换工具
SIZE = 'arm-none-eabi-size', # 段大小分析工具
PROGSUFFIX = '.elf', # 可执行文件后缀
ENV = {'PATH': os.environ['PATH']}, # 继承系统 PATH
)
Environment() 创建一个 SCons 构建环境对象 env,后续所有操作(编译、链接、后处理)都在这个环境中完成。
各参数的含义:
| 参数 | 对应命令行 | 说明 |
|---|---|---|
CC |
gcc | 编译 .c 文件的工具。这里指定 ARM 交叉编译器前缀 |
AS |
as | 汇编 .s 文件的工具。同样用 GCC,它内置 GNU 汇编器 |
LINK |
ld | 链接器。用 GCC 做链接可以自动处理启动文件、库路径等 |
OBJCOPY |
objcopy | 用于从 ELF 转换成 BIN/HEX 格式 |
SIZE |
size | 打印固件各段(text/data/bss)的占用大小 |
PROGSUFFIX |
- | 链接产物的后缀。设为 .elf 则生成 firmware.elf |
ENV |
- | 传给子进程的环境变量。这里只传 PATH,确保能找到编译器 |
5.2 env.Append() --- 追加编译参数
python
env.Append(
CCFLAGS = common_flags + ['-Wall', '-g', '-O0', ...], # C/C++ 通用编译选项
ASFLAGS = common_flags + ['-g', '-c'], # 汇编器选项
LINKFLAGS = common_flags + ['-Txxx.ld', '--specs=...'], # 链接器选项
CPPDEFINES = ['USE_HAL_DRIVER', 'STM32L475xx'], # 预处理器宏
CPPPATH = [...], # 头文件搜索路径
)
Append() 向环境中追加 (而非覆盖)参数。它与 Environment() 中的 = 赋值的区别:Environment(CC=...) 是初始化时的赋值,Append(CCFLAGS=...) 是在已有基础上追加。
| 参数 | 对应命令行标志 | 作用 |
|---|---|---|
CCFLAGS |
gcc 通用选项 | 对 C 和 C++ 都生效。-mcpu=cortex-m4 指定 CPU 架构,-mthumb Thumb 指令集,-mfloat-abi=hard 硬浮点 ABI,-Wall 开启所有警告,-g 调试信息,-O0 不优化 |
ASFLAGS |
汇编器选项 | 仅对 .s 汇编文件生效。-c 表示只汇编不链接 |
LINKFLAGS |
链接器选项 | 仅链接阶段生效。-T 指定链接脚本,--specs=nosys.specs 用无系统调用版 newlib,-Wl,--gc-sections 移除未用代码段以减小固件体积 |
CPPDEFINES |
-D 宏定义 |
等价于 -DUSE_HAL_DRIVER,告诉 HAL 库启用哪些功能 |
CPPPATH |
-I 头文件路径 |
等价于 -I,编译器在这些目录中查找 #include 的头文件 |
5.3 Export() / Import() --- 跨脚本共享对象
在 SConstruct 中:
python
Export('env')
在 SConscript 中:
python
Import('env')
作用: 将 SConstruct 中的 env 对象导出,所有子 SConscript 可以通过 Import('env') 获取同一个构建环境,保证编译参数一致。
5.4 SConscript() --- 加载子脚本
python
cmsis = SConscript('CMSIS/SConscript')
device = SConscript('Device/SConscript', variant_dir='build/Device', duplicate=0)
| 参数 | 说明 |
|---|---|
| 第一个参数(位置) | SConscript 文件的路径 |
variant_dir |
中间产物输出目录。例如 variant_dir='build/Device' 会将 Device 模块的 .o 文件输出到 build/Device/Src/,源码目录保持干净 |
duplicate=0 |
不复制源文件到 variant_dir,只在那里生成 .o,节省磁盘空间 |
返回值: SConscript 中 Return('result') 的返回值。本例中每个 SConscript 返回 {'sources': [...], 'includes': [...]} 字典,SConstruct 收集后统一使用。
5.5 env.Program() --- 编译 + 链接
python
elf = env.Program('build/firmware', all_sources)
| 参数 | 说明 |
|---|---|
'build/firmware' |
目标文件名(不含后缀)。结合 PROGSUFFIX='.elf',最终生成 build/firmware.elf |
all_sources |
所有源文件节点列表。SCons 自动根据扩展名选择编译器:.c → CC,.s → AS |
此函数将"编译每个源文件"和"链接所有目标文件"两步合一。SCons 自动处理依赖关系------只有修改过的文件才会重新编译。
5.6 env.Command() --- 自定义构建命令
python
bin_target = env.Command(
'build/firmware.bin', # 目标文件(输出)
elf, # 源文件(依赖)
'$OBJCOPY -O binary $SOURCE $TARGET', # 要执行的命令
)
| 参数 | 说明 |
|---|---|
| 第一参数 | 目标文件名。命令执行后生成的产物 |
| 第二参数 | 依赖项。只有当依赖比目标更新时才执行命令 |
| 第三参数 | Shell 命令字符串。$OBJCOPY 展开为 arm-none-eabi-objcopy,$SOURCE 展开为源文件路径,$TARGET 展开为目标文件路径 |
这里调用了两次,分别生成 .bin(二进制镜像)和 .hex(Intel HEX 格式)。
5.7 env.AddPostAction() --- 构建后钩子
python
env.AddPostAction(elf, print_size_report)
作用: 在指定目标构建成功后,自动执行一个回调函数。
函数签名必须为 func(target, source, env),三个参数由 SCons 自动传入:
| 参数 | 来源 | 内容 |
|---|---|---|
target |
SCons 自动传入 | 刚生成的目标文件列表,即 [build/firmware.elf] |
source |
SCons 自动传入 | 所有参与链接的 .o 文件列表 |
env |
SCons 自动传入 | 当前构建环境对象 |
本例中的回调函数调用 arm-none-eabi-size,打印固件的 text/data/bss 段大小:
python
def print_size_report(target, source, env):
import subprocess
result = subprocess.run(
['arm-none-eabi-size', str(source[0])],
capture_output=True, text=True,
)
print(result.stdout)
5.8 env.Default() --- 设置默认构建目标
python
env.Default([elf, bin_target, hex_target])
作用: 指定用户直接运行 scons(不带参数)时默认构建哪些目标。这里设为 [elf, bin, hex],即一次 scons 就生成全部三种格式的固件。
六、如何编译与最终产物
编译本工程需要提前安装好arm交叉编译工具链(arm-none-eabi-xxx),下载链接:github.com/xpack-dev-t...,切记安装完成后需要设置环境变量。
使用SCons前,需要安装Python3环境和SCons工具,Python环境可以自行安装,环境没问题后,SCons的安装方式为:
Windows(PowerShell 或 CMD)
py -m pip install scons
Linux / macOS
python3 -m pip install scons
进入 scons_demo 目录,只需一条命令:
bash
cd scons_demo
scons
SCons 会自动完成以下全部步骤:
bash
scons
├── 读取 SConstruct,配置 ARM GCC 工具链
├── 调用各 SConscript,收集 sources 和 includes
├── 编译 .c → .o(输出到 build/App/、build/Device/Src/、build/Driver/Src/)
├── 汇编 .s → .o
├── 链接所有 .o → build/firmware.elf
├── objcopy -O binary → build/firmware.bin
├── objcopy -O ihex → build/firmware.hex
└── size 分析 → 打印 text / data / bss 段大小
最终产物一览:
| 产物 | 路径 | 说明 |
|---|---|---|
firmware.elf |
build/firmware.elf |
带调试符号的 ELF 文件,可用于 GDB 调试 |
firmware.bin |
build/firmware.bin |
原始二进制镜像,可直接烧录到 Flash |
firmware.hex |
build/firmware.hex |
Intel HEX 格式,ST-Link / J-Link 等工具兼容 |
| Size Report | 终端输出 | 显示 text(代码段)、data(已初始化数据)、bss(未初始化数据)的占用大小 |
清理命令:
bash
scons -c # 删除所有 build/ 目录下的中间产物和最终产物
一次 scons,四种产物全部到位。源码目录保持干净,所有生成物都在 build/ 下。
关于嵌入式开发的文章,欢迎微 信关注青衫嵌入式,订阅FreeRTOS、蓝牙等免费合集文章。