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

0 阅读10分钟

一、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++ 项目
SConsPython 脚本驱动的构建系统需要高度定制编译流程的项目

今天我们重点介绍 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 通过字典返回两类信息:

# Device/SConscript 示例
sources = [
    File('Src/startup_stm32l475xx.s'),
    File('Src/system_stm32l4xx.c'),
]

includes = [
    'Device/Inc',
]

result = {'sources': sources, 'includes': includes}
Return('result')

顶层 SConstruct 统一收集:

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() — 创建构建环境

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,后续所有操作(编译、链接、后处理)都在这个环境中完成。

各参数的含义:

参数对应命令行说明
CCgcc编译 .c 文件的工具。这里指定 ARM 交叉编译器前缀
ASas汇编 .s 文件的工具。同样用 GCC,它内置 GNU 汇编器
LINKld链接器。用 GCC 做链接可以自动处理启动文件、库路径等
OBJCOPYobjcopy用于从 ELF 转换成 BIN/HEX 格式
SIZEsize打印固件各段(text/data/bss)的占用大小
PROGSUFFIX-链接产物的后缀。设为 .elf 则生成 firmware.elf
ENV-传给子进程的环境变量。这里只传 PATH,确保能找到编译器

5.2 env.Append() — 追加编译参数

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=...) 是在已有基础上追加。

参数对应命令行标志作用
CCFLAGSgcc 通用选项对 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 中:

Export('env')

在 SConscript 中:

Import('env')

作用: 将 SConstruct 中的 env 对象导出,所有子 SConscript 可以通过 Import('env') 获取同一个构建环境,保证编译参数一致。

5.4 SConscript() — 加载子脚本

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() — 编译 + 链接

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() — 自定义构建命令

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() — 构建后钩子

env.AddPostAction(elf, print_size_report)

作用: 在指定目标构建成功后,自动执行一个回调函数。

函数签名必须为 func(target, source, env),三个参数由 SCons 自动传入

参数来源内容
targetSCons 自动传入刚生成的目标文件列表,即 [build/firmware.elf]
sourceSCons 自动传入所有参与链接的 .o 文件列表
envSCons 自动传入当前构建环境对象

本例中的回调函数调用 arm-none-eabi-size,打印固件的 text/data/bss 段大小:

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() — 设置默认构建目标

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 目录,只需一条命令:

cd scons_demo
scons

SCons 会自动完成以下全部步骤:

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.elfbuild/firmware.elf带调试符号的 ELF 文件,可用于 GDB 调试
firmware.binbuild/firmware.bin原始二进制镜像,可直接烧录到 Flash
firmware.hexbuild/firmware.hexIntel HEX 格式,ST-Link / J-Link 等工具兼容
Size Report终端输出显示 text(代码段)、data(已初始化数据)、bss(未初始化数据)的占用大小

清理命令:

scons -c      # 删除所有 build/ 目录下的中间产物和最终产物

一次 scons,四种产物全部到位。源码目录保持干净,所有生成物都在 build/ 下。


关于嵌入式开发的文章,欢迎微 信关注青衫嵌入式,订阅FreeRTOS、蓝牙等免费合集文章。