4. 自动化脚本框架:Shell脚本基础、Python脚本基础、Makefile集成、参数模板化设计
好,咱们进入第四章。这一章我把它叫做「自动化脚本框架」,说白了就是把你脑子里的那些打包步骤,变成机器能自动跑的东西。
你想想看,一个固件项目,少说几十个参数,多则上百。每次手动改,改完还得检查有没有漏掉。我早年吃过这个亏——有一次发布前忘了改设备型号ID,结果烧到产线板子上全变砖。嗯,从那以后,我下定决心,必须把参数配置这件事彻底自动化。
4.1 Shell脚本基础:快速原型,别小看它
Shell脚本,很多人觉得它「土」。但我要说,在嵌入式打包这个场景里,Shell脚本是最快、最直接的武器。尤其是当你需要调用交叉编译工具链、执行烧录命令、处理文件路径时,Shell天然就是干这个的。
一个典型的打包Shell脚本,大概长这样:
#!/bin/bash
# 固件打包脚本 - v2.1
# 用法: ./build_fw.sh <board_type> <version>
set -e # 遇到错误就停,别硬跑
BOARD_TYPE=${1:-"evk"}
VERSION=${2:-"1.0.0"}
OUTPUT_DIR="output/${BOARD_TYPE}_${VERSION}"
echo "[INFO] 开始打包: 板型=${BOARD_TYPE}, 版本=${VERSION}"
# 1. 清理旧产物
rm -rf ${OUTPUT_DIR}
mkdir -p ${OUTPUT_DIR}
# 2. 生成启动参数头文件
cat > ${OUTPUT_DIR}/boot_params.h << EOF
#ifndef __BOOT_PARAMS_H__
#define __BOOT_PARAMS_H__
#define BOARD_TYPE "${BOARD_TYPE}"
#define FW_VERSION "${VERSION}"
#define BUILD_TIME "$(date '+%Y-%m-%d %H:%M:%S')"
#endif
EOF
echo "[INFO] 启动参数头文件已生成"
# 3. 调用编译
make BOARD=${BOARD_TYPE} VERSION=${VERSION}
# 4. 打包
tar -czf ${OUTPUT_DIR}/firmware_${VERSION}.tar.gz build/*.bin
echo "[DONE] 打包完成: ${OUTPUT_DIR}/firmware_${VERSION}.tar.gz"
这里有几个关键点,我提一下:
- set -e:必须加。不加的话,中间某步失败了脚本还会继续跑,最后给你一个「看起来成功」的假象。
- 参数默认值:用
${1:-"evk"}这种写法,防止忘传参数时脚本崩溃。 - heredoc生成头文件:这是我最常用的技巧。把启动参数直接写成C头文件,编译时一起编进去,干净利落。
4.2 Python脚本基础:当逻辑变复杂时
Shell脚本有个硬伤——一旦逻辑复杂起来,条件分支多了,或者需要处理JSON/YAML配置文件,它就变得很难维护。这时候,我建议你上Python。
Python在固件打包里的典型用法,是作为「胶水层」。它负责解析配置、校验参数、调用外部工具、生成最终产物。
看一个例子:
#!/usr/bin/env python3
"""
固件打包脚本 - Python版
支持从YAML配置文件读取启动参数
"""
import os
import sys
import json
import yaml
import subprocess
from datetime import datetime
def load_config(config_path):
"""加载YAML配置文件"""
with open(config_path, 'r') as f:
config = yaml.safe_load(f)
return config
def validate_params(config):
"""校验启动参数合法性"""
required = ['board_type', 'fw_version', 'flash_layout']
for key in required:
if key not in config:
raise ValueError(f"缺少必要参数: {key}")
print(f"[OK] 参数校验通过: {config['board_type']}")
def generate_header(config, output_dir):
"""生成启动参数头文件"""
lines = []
lines.append("#ifndef __BOOT_PARAMS_H__")
lines.append("#define __BOOT_PARAMS_H__")
lines.append("")
lines.append(f'#define BOARD_TYPE "{config["board_type"]}"')
lines.append(f'#define FW_VERSION "{config["fw_version"]}"')
lines.append(f'#define BUILD_TIME "{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}"')
lines.append("")
lines.append("#endif")
header_path = os.path.join(output_dir, "boot_params.h")
with open(header_path, 'w') as f:
f.write("\n".join(lines))
print(f"[OK] 头文件已生成: {header_path}")
def main():
if len(sys.argv) < 2:
print("用法: python3 pack_fw.py <config.yaml>")
sys.exit(1)
config = load_config(sys.argv[1])
validate_params(config)
output_dir = f"output/{config['board_type']}_{config['fw_version']}"
os.makedirs(output_dir, exist_ok=True)
generate_header(config, output_dir)
# 调用make
env = os.environ.copy()
env['BOARD'] = config['board_type']
env['VERSION'] = config['fw_version']
subprocess.run(['make'], env=env, check=True)
if __name__ == '__main__':
main()
我个人觉得,Python最大的优势在于:
- 数据结构丰富:字典、列表、嵌套结构,处理复杂参数配置很顺手。
- 异常处理:try/except 比Shell的 $? 检查优雅得多。
- 跨平台:Windows/Linux/macOS 都能跑,CI环境迁移成本低。
4.3 Makefile集成:把脚本串起来
有了Shell脚本和Python脚本,怎么把它们组织成一个完整的构建流程?答案是Makefile。
Makefile在嵌入式开发里是老朋友了。但很多人只拿它编译代码,忽略了它在「流程编排」上的能力。其实,Makefile天然适合做依赖管理和增量构建。
一个集成了打包流程的Makefile,大概长这样:
# Makefile - 固件构建与打包
BOARD ?= evk
VERSION ?= 1.0.0
CONFIG ?= config/$(BOARD).yaml
OUTPUT_DIR = output/$(BOARD)_$(VERSION)
HEADER_FILE = $(OUTPUT_DIR)/boot_params.h
FW_BIN = build/firmware.bin
FW_PACKAGE = $(OUTPUT_DIR)/firmware_$(VERSION).tar.gz
# 默认目标
all: $(FW_PACKAGE)
# 生成启动参数头文件
$(HEADER_FILE): $(CONFIG)
@mkdir -p $(OUTPUT_DIR)
python3 scripts/gen_params.py $(CONFIG) $(OUTPUT_DIR)
# 编译固件
$(FW_BIN): $(HEADER_FILE) src/*.c
make -C src BOARD=$(BOARD) VERSION=$(VERSION)
# 打包
$(FW_PACKAGE): $(FW_BIN)
tar -czf $@ build/*.bin
@echo "[DONE] 打包完成: $@"
# 清理
clean:
rm -rf output build
.PHONY: all clean
这个Makefile的核心思路是:
- 依赖链:打包依赖固件二进制,固件依赖头文件,头文件依赖配置文件。Make会自动判断哪些需要重新生成。
- 增量构建:如果配置文件没变,头文件就不会重新生成,编译也不会重跑。省时间。
- 参数透传:BOARD和VERSION通过环境变量传递,Shell脚本和Python脚本都能读到。
$(shell date +%s) 生成时间戳,强制某些步骤每次都重新执行。比如版本号里带构建时间,就需要这个。
4.4 参数模板化设计:一次定义,到处使用
前面讲的都是「怎么跑」,现在讲「怎么定义参数」。这是整个自动化框架的灵魂。
参数模板化,说白了就是:把启动参数的定义和它的使用场景解耦。你只需要在一个地方维护参数,脚本自动生成各种格式的输出。
我常用的做法是:用YAML作为参数定义的「源文件」,然后通过模板引擎生成C头文件、JSON配置文件、文档等。
一个典型的参数模板:
# config/evk.yaml
board_type: "evk"
fw_version: "2.3.0"
flash_layout:
bootloader:
offset: 0x08000000
size: 0x00010000
app:
offset: 0x08010000
size: 0x000E0000
params:
offset: 0x080F0000
size: 0x00010000
boot_params:
uart_baudrate: 115200
watchdog_timeout_ms: 5000
log_level: 3
secure_boot: true
然后,用Jinja2模板生成头文件:
# templates/boot_params.h.j2
#ifndef __BOOT_PARAMS_H__
#define __BOOT_PARAMS_H__
#define BOARD_TYPE "{{ board_type }}"
#define FW_VERSION "{{ fw_version }}"
/* Flash Layout */
#define FLASH_BL_OFFSET 0x{{ "%08X" | format(flash_layout.bootloader.offset) }}
#define FLASH_BL_SIZE 0x{{ "%08X" | format(flash_layout.bootloader.size) }}
#define FLASH_APP_OFFSET 0x{{ "%08X" | format(flash_layout.app.offset) }}
#define FLASH_APP_SIZE 0x{{ "%08X" | format(flash_layout.app.size) }}
/* Boot Parameters */
#define UART_BAUDRATE {{ boot_params.uart_baudrate }}
#define WDT_TIMEOUT_MS {{ boot_params.watchdog_timeout_ms }}
#define LOG_LEVEL {{ boot_params.log_level }}
#define SECURE_BOOT_ENABLED {{ boot_params.secure_boot | lower }}
#endif
这样做的好处很明显:
- 单一数据源:改参数只改YAML,不用去翻C代码。
- 多格式输出:同一个YAML,可以生成C头文件、Python配置、JSON报告、PDF文档。
- 版本可控:YAML文件可以进Git,每次改动都有记录。
baudrate 和 baudRate 的区别。建议统一用小写加下划线。
4.5 整体框架图
下面这张图,把整个自动化脚本框架的流程串起来了。你可以看到,从YAML参数定义开始,经过模板引擎生成头文件,然后编译、打包,最终输出固件包。
从这张图你可以看到,整个框架的核心就是「YAML定义 → 模板生成 → 编译打包」这条主线。每一层都有明确的输入输出,互不耦合。
我个人在实际项目中,还会在YAML里加一个 meta 字段,记录参数版本、修改人、修改日期。这样出了问题,能快速定位是谁改了什么。