4. 自动化脚本框架:Shell脚本基础、Python脚本基础、Makefile集成、参数模板化设计

好,咱们进入第四章。这一章我把它叫做「自动化脚本框架」,说白了就是把你脑子里的那些打包步骤,变成机器能自动跑的东西。

你想想看,一个固件项目,少说几十个参数,多则上百。每次手动改,改完还得检查有没有漏掉。我早年吃过这个亏——有一次发布前忘了改设备型号ID,结果烧到产线板子上全变砖。嗯,从那以后,我下定决心,必须把参数配置这件事彻底自动化。

4.1 Shell脚本基础:快速原型,别小看它

Shell脚本,很多人觉得它「土」。但我要说,在嵌入式打包这个场景里,Shell脚本是最快、最直接的武器。尤其是当你需要调用交叉编译工具链、执行烧录命令、处理文件路径时,Shell天然就是干这个的。

我的习惯: 先用Shell把流程跑通,再考虑要不要用Python重写。很多场景下,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头文件,编译时一起编进去,干净利落。
注意: Shell脚本里做字符串拼接时,变量一定要加双引号。不加的话,路径里有空格就炸了。我曾经在CI服务器上因为这个翻过车,排查了半小时。

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环境迁移成本低。
我的建议: 如果你的打包逻辑超过50行,或者需要处理JSON/YAML/XML,直接上Python。别在Shell里硬撑。

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的核心思路是:

  1. 依赖链:打包依赖固件二进制,固件依赖头文件,头文件依赖配置文件。Make会自动判断哪些需要重新生成。
  2. 增量构建:如果配置文件没变,头文件就不会重新生成,编译也不会重跑。省时间。
  3. 参数透传:BOARD和VERSION通过环境变量传递,Shell脚本和Python脚本都能读到。
一个小技巧: 在Makefile里用 $(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,每次改动都有记录。
避坑指南: 模板里的变量名一定要和YAML里的key严格对应。我曾经因为大小写不一致,生成的固件里参数全是默认值,烧到板子上跑起来才发现。排查了一整天,最后发现是 baudrate 和 baudRate 的区别。建议统一用小写加下划线。

4.5 整体框架图

下面这张图,把整个自动化脚本框架的流程串起来了。你可以看到,从YAML参数定义开始,经过模板引擎生成头文件,然后编译、打包,最终输出固件包。

自动化脚本框架整体流程 YAML参数定义 Jinja2模板引擎 boot_params.h config.json Makefile编译 firmware.tar.gz 核心原则 单一数据源 模板驱动生成 增量构建

从这张图你可以看到,整个框架的核心就是「YAML定义 → 模板生成 → 编译打包」这条主线。每一层都有明确的输入输出,互不耦合。

我个人在实际项目中,还会在YAML里加一个 meta 字段,记录参数版本、修改人、修改日期。这样出了问题,能快速定位是谁改了什么。