想进阶为一名中高级嵌入式工程师不能只会用Keil —— 学习一个新的编译框架

一、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 的核心能力:

  1. 自动依赖分析 --- 修改哪个文件就重新编译哪个文件,不需要手动维护依赖关系
  2. Python 的完整能力 --- 条件判断、循环、函数、字典、文件操作......Python 能做的你都能在构建脚本里做
  3. 跨平台 --- Windows/Linux/macOS 通用
  4. 可扩展 --- 可以自定义 Builder、Scanner、Action,满足嵌入式领域的各种奇怪需求
  5. 内置缓存 --- 支持增量编译,不改动的文件不会重新编译

四、实战:一个 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 命令到输出固件,整个过程如下:

关键节点说明:

  1. 读取 SConstruct --- SCons 解析 Python 构建脚本,确定工具链和编译参数
  2. 调用 SConscripts --- 逐个子模块收集源文件和头文件路径
  3. 编译 --- arm-none-eabi-gcc.c 编译为 .o.s 汇编为 .o
  4. 链接 --- 所有 .o 文件链接为 firmware.elf
  5. 后处理 --- objcopy 生成 .bin.hexarm-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、蓝牙等免费合集文章。

相关推荐
SimonKing2 小时前
别再盲目跑测试了,用 JaCoCo 告诉你哪些代码根本没被覆盖
java·后端·程序员
陈随易12 小时前
在 VSCode 里,把项目一键部署到服务器
前端·后端·程序员
Hilaku19 小时前
为什么在 2026 年,MPA(多页面应用)正在悄悄复辟?
前端·javascript·程序员
SamDeepThinking21 小时前
微信支付对接实战:从下单到回调的完整落地过程
后端·程序员·架构
臼犀1 天前
大语言模型响应延迟对软件工程师尿液浓缩程度的影响 —— 一项基于水杯见底速度的观察性研究
程序员·ai编程·vibecoding
程序员cxuan1 天前
OpenAI 把 Hugging Face 打穿了,然后 GLM 5.2 当了救火队长?
人工智能·后端·程序员
爱勇宝1 天前
人选择不了能活多久,但能选择怎么活着
程序员
SimonKing1 天前
别再写 setter 了!MapStruct Plus vs MapStruct,谁才是 Bean 转换的真神?
java·后端·程序员
码栈研说1 天前
Go 语言大白话入门 12 - JSON:工作里最常见的数据格式
后端·程序员