为什么需要跨系统构建

Mill 项目主控是 STM32F103ZE,固件基于 Zephyr RTOS v4.4。开发环境天然分成三块:

  • 写代码在 Windows:VS Code、Git、串口工具、原理图都在这边
  • 构建在 WSL2 Ubuntu:Zephyr 官方工具链(west / CMake / Python venv / zephyr-sdk)在 Linux 下更稳定,west workspace 也少踩坑
  • 烧录回 Windows:调试器物理接在 Windows 主机上

直接在 Windows 跑 Zephyr 也能用,但 west workspace 经常因为路径含空格、CRLF 换行、符号链接这些问题出毛病。WSL2 给的是原生 ext4 文件系统,构建速度快,问题少。

唯一麻烦的是同步:代码在 Windows 改,构建在 WSL 跑,产物又要回 Windows 烧。手动搞三遍容易出错,所以我们写了一套 PowerShell 脚本把三步串成流水线。

四步工作流

一次完整构建分成四个阶段,对应 Zephyr_Build.ps1 里的 [1/4][4/4] 标记:

flowchart LR
    A["Windows<br/>git push"] --> B["WSL<br/>git pull --ff-only"]
    B --> C["WSL<br/>west sysbuild"]
    C --> D["WSL → Windows<br/>cp 产物到 build-from-wsl/"]
    D --> E["Windows<br/>J-Link 烧录"]

第一步在 Windows 侧 git push,把刚提交的代码推到 bare 仓库;第二步 WSL 侧 git pull --ff-only 拉过来;第三步激活 Zephyr 的 venv 跑 west sysbuild;第四步把产物 cp 到挂载点 /mnt/d/... 下的 build-from-wsl/ 目录,Windows 直接可见。

烧录是独立的一步,由 Zephyr_Flash.ps1 单独完成,或者由 Zephyr_Auto.ps1 串联。

build-config.psd1:单文件配置

所有跨系统参数集中在一个 build-config.psd1 里,PowerShell 用 Import-PowerShellDataFile 直接读:

@{
    WslDistro     = "Ubuntu-26.04"
    WslProjectDir = "/home/mydei/repos/Mill/Zephyr"
    WslZephyrWs   = "/home/mydei/zephyr"
    WslBuildDir   = "/home/mydei/zephyr/build-mill"
    Board         = "stm32f103ze"
    BoardRoot     = "/home/mydei/repos/Mill/Zephyr"
    PrivateConf   = "/home/mydei/.config/mill/device-auth.conf"
    JLinkExe      = "C:/Program Files/SEGGER/JLink_V954/JLink.exe"
    JLinkDevice   = "STM32F103ZE"
    JLinkSpeed    = 4000
    Artifacts     = @(
        "mcuboot/zephyr/zephyr.hex",
        "Zephyr/zephyr/zephyr.signed.hex",
        "Zephyr/zephyr/zephyr.signed.bin",
        "Zephyr/zephyr/zephyr.elf"
    )
    FirmwareFiles = @(
        "build-from-wsl/mcuboot.hex",
        "build-from-wsl/zephyr.signed.hex"
    )
}

几个关键字段:

  • WslDistro 指定用哪个 WSL 发行版,wsl.exe -d 会带上
  • WslProjectDir 是 WSL 侧的代码目录,git pull 在这里跑
  • WslZephyrWs 是 Zephyr 工作区,里面有 .venvzephyr 源码
  • WslBuildDir 是构建目录,留空默认 WslZephyrWs/build
  • Board / BoardRoot 传给 west 的 -b-DBOARD_ROOT
  • PrivateConf 是 WSL 侧的私有配置文件,注入 HMAC 密钥
  • Artifacts 是构建产物相对路径列表
  • FirmwareFiles 是烧录时实际要写进芯片的 HEX 文件

一份配置管两个系统,不用在脚本里写死路径。

Windows 路径转 WSL 路径

WSL 通过 /mnt/x/ 访问 Windows 盘符,所以 D:\Project\Mill\Zephyr 对应 /mnt/d/Project/Mill/Zephyr。脚本里这样转:

$drive = $script:Root.Substring(0,1).ToLower()
$relPath = $script:Root.Substring(3) -replace '\\','/'
$WslArtifactsDir = "/mnt/$drive/$relPath/build-from-wsl"

取盘符首字母小写、去掉 X:\ 前缀、反斜杠替换成正斜杠,拼出 WSL 路径。构建产物就 cp 到这个目录,Windows 侧直接能读。

Zephyr 项目特征检测

脚本不能假设任何目录都能构建。进构建流程前先检查四个特征:

$isZephyr = $true
if (-not (Test-Path 'CMakeLists.txt')) { $isZephyr = $false }
elseif (-not (Test-Path 'prj.conf')) { $isZephyr = $false }
elseif (-not (Test-Path 'boards' -PathType Container)) { $isZephyr = $false }
elseif (-not ((Get-Content CMakeLists.txt -Raw) -match 'find_package\(Zephyr')) { $isZephyr = $false }

四个条件:CMakeLists.txt 存在、prj.conf 存在、boards/ 目录存在、CMakeLists 里有 find_package(Zephyr)。任一不满足就打印警告、5 秒倒计时退出,不进入构建。

这个检测在 Build、Auto、Clean 三个脚本里都有,避免在非 Zephyr 目录误操作。

注入设备 HMAC 密钥

每台设备有独立的 HMAC 密钥,用于远程命令鉴权。密钥不能进 Git,所以放在 WSL 私有目录:

/home/mydei/.config/mill/device-auth.conf

文件内容是一行 Kconfig:

CONFIG_DR154_DEVICE_AUTH_KEY_HEX="<64 个十六进制字符>"

权限设 600。构建时通过 Zephyr_EXTRA_CONF_FILE 注入:

west build -b stm32f103ze -d /home/mydei/zephyr/build-mill \
  /home/mydei/repos/Mill/Zephyr --sysbuild --pristine -- \
  -DBOARD_ROOT=/home/mydei/repos/Mill/Zephyr \
  -DZephyr_EXTRA_CONF_FILE=/home/mydei/.config/mill/device-auth.conf

ZEPHYR_EXTRA_CONF_FILE 是 Zephyr 的镜像级附加配置机制,会在最终 prj.conf 拼接时附加进去。密钥不进项目目录、不进 Git 历史,只存在于 WSL 本地。

如果配置缺失或不是 64 位十六进制,固件照样能构建,但会拒绝所有需要鉴权的远程命令,降级而非报错。

MCUboot 签名私钥

MCUboot 给应用镜像签名用的是 ECDSA-P256 私钥,同样放在 WSL 私有目录:

/home/mydei/.config/mill/keys/root-ecdsa-p256.pem

目录权限 700,私钥文件 600sysbuild.conf 里只引用这个 Linux 路径,imgtool 构建时自动读取并给 zephyr.signed.bin 签名。

私钥不进 Git、不复制到项目目录、不外发。一旦泄露,所有已烧录固件的 OTA 升级链路都得换。

构建产物

--sysbuild 会同时构建 MCUboot 和应用,产物在 sysbuild 构建目录下的两个子目录:

build-mill/mcuboot/zephyr/zephyr.hex        # MCUboot bootloader
build-mill/Zephyr/zephyr/zephyr.signed.hex  # 已签名应用镜像(HEX,烧录用)
build-mill/Zephyr/zephyr/zephyr.signed.bin  # 已签名应用镜像(BIN,OTA 用)
build-mill/Zephyr/zephyr/zephyr.elf          # 调试符号

Artifacts 数组里列的就是这四个。复制时 mcuboot/zephyr/zephyr.hex 重命名成 mcuboot.hex,其余保留文件名:

$copyBlock = ($artifacts | ForEach-Object {
    $source = "$WslBuildDir/$_"
    $destination = if ($_ -eq 'mcuboot/zephyr/zephyr.hex') { 'mcuboot.hex' }
                    else { Split-Path -Leaf $_ }
    "test -f `"$source`" || { echo `"Missing artifact: $source`"; exit 1; }`ncp -v `"$source`" `"$WslArtifactsDir/$destination`""
}) -join "`n"

每个产物先 test -f 确认存在,再 cp -v 复制。任何一个缺失就 exit 1,整个构建失败。

产物复制回 Windows

WSL 里的 cp 直接写到 /mnt/d/Project/Mill/Zephyr/build-from-wsl/,Windows 侧立刻可见:

echo '=== [4/4] copy artifacts to Windows ==='
mkdir -p '/mnt/d/Project/Mill/Zephyr/build-from-wsl'
test -f '.../mcuboot/zephyr/zephyr.hex' || { echo 'Missing'; exit 1; }
cp -v '.../mcuboot/zephyr/zephyr.hex' '/mnt/d/Project/Mill/Zephyr/build-from-wsl/mcuboot.hex'
# ... 其余产物
echo '=== DONE ==='

/mntwsl.exe 之间的拷贝更直接,没有跨进程开销。build-from-wsl/ 这个名字也表明产物来自 WSL,避免和 Windows 本地构建混淆。

避免多层转义:写临时 bash 脚本

WSL 命令里有单引号、变量、heredoc,如果直接 wsl.exe -d Ubuntu -- bash -c "..." 传进去,PowerShell、wsl.exe、bash 三层转义会互相打架。脚本的做法是先把整段 bash 写到 /tmp/zephyr_build_<guid>.sh,再 bash 执行:

$tmpScriptName = "zephyr_build_$([Guid]::NewGuid()).sh"
$wslTmpScript = "/tmp/$tmpScriptName"
$writeScriptCmd = @"
cat > '$wslTmpScript' <<'EOF'
$wslScript
EOF
chmod +x '$wslTmpScript'
"@
& wsl.exe -d $WslDistro -- bash -c $writeScriptCmd
& wsl.exe -d $WslDistro -- bash $wslTmpScript

heredoc 用 'EOF'(带引号)禁止变量展开,PowerShell 里拼好的 $wslScript 原样写入。执行完 rm -f 删掉。调试时也能先 cat 出来看脚本内容,定位问题方便。

烧录在 Windows 侧完成,用 J-Link Commander 通过 SWD 写入。Zephyr_Flash.ps1 先校验 J-Link 可执行文件和固件文件存在,再生成一个临时 .jlink 命令文件:

$commands = @('ExitOnError 1', 'r', 'h')
foreach ($firmwareFile in $FirmwareFiles) {
    $commands += "loadfile `"$firmwareFile`""
}
$commands += @('r', 'g', 'sleep 1500', 'q')
[System.IO.File]::WriteAllLines($commandFile, $commands, [System.Text.UTF8Encoding]::new($false))

命令序列:r 复位、h halt,然后对每个 HEX 文件 loadfile,最后 r 复位、g 运行、sleep 1500 等启动、q 退出。

HEX 文件自带目标地址,J-Link 只擦写对应扇区,保留片上 storage 分区。烧录顺序由 FirmwareFiles 决定:先 mcuboot.hex,再 zephyr.signed.hex

调用 J-Link Commander:

& $JLinkExe -NoGui 1 -Device $JLinkDevice -If SWD -Speed $JLinkSpeed `
    -AutoConnect 1 -CommanderScript $commandFile

输出流式写日志。烧录完硬件复位并继续运行,保证 Auto 流程结束时应用已经启动。

Auto 脚本:构建 + 烧录一条龙

Zephyr_Auto.ps1 把 Build 和 Flash 串起来:

# [1/2] WSL 构建(构建脚本内部负责 git push)
& pwsh.exe -File $BuildScript -ProjectRoot $script:Root
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }

# [2/2] 烧录
& pwsh.exe -File $FlashScript -ProjectRoot $script:Root

构建失败直接退出,不烧录旧固件。两个子脚本各自独立,也能单独跑。

Release 构建:注入生产配置

日常开发和 Release 构建走同一个 Zephyr_Build.ps1,靠 -Production 开关区分:

$confFiles = @($PrivateConf)
if ($Production) {
    $confFiles = @("$WslProjectDir/prj_production.conf", $PrivateConf)
}
$extraConf = $confFiles -join ';'

-Production 时在 ZEPHYR_EXTRA_CONF_FILE 前面加上 prj_production.conf,注入生产配置(比如关闭调试日志、锁定功能开关)。多个配置文件用分号拼接,Zephyr 按顺序合并。

Zephyr_Build-Release.ps1Zephyr_Auto-Release.ps1 是双击入口,固定带 -Production -Pristine

$buildArgs = @('-File', $buildScript, '-Production', '-Pristine')
& pwsh.exe @buildArgs

-Pristine 对应 west 的 --pristine,全量重新配置,不增量。生产构建必须 pristine,避免缓存污染。

构建日志

每次构建在项目根目录的 build-log/ 下生成一个时间戳日志:

$Timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$LogFile = Join-Path $LogDir "build_$Timestamp.log"

WSL 输出通过 StreamWriter 流式写入,同时打印到控制台:

& wsl.exe -d $WslDistro -- bash $wslTmpScript 2>&1 | ForEach-Object {
    $logWriter.WriteLine($_); $logWriter.Flush(); Write-Host $_
}

最后追加一行汇总:

====== 构建成功  耗时:00:03:42 ======

烧录脚本同样有 flash_<timestamp>.log。出问题翻日志就行,不用复现。

Clean 脚本

Zephyr_Clean.ps1 清理两边的构建目录:

  • Windows 侧的 build/build-* 变体目录移入回收站(不是直接删)
  • WSL 侧的 WslBuildDirrm -rf 删除

Windows 侧用 Microsoft.VisualBasic.FileIO.FileSystem 走回收站,避免误删没法恢复。WSL 侧加了路径白名单校验:

if (-not $wslBuildDir.StartsWith("$wslZephyrWs/build", [System.StringComparison]::Ordinal)) {
    throw "拒绝清理不安全的 WSL 路径:$wslBuildDir"
}

只有 WslZephyrWs/build 开头的路径才允许 rm -rf,配置错了也不会删错目录。

脚本入口一览

脚本作用典型用法
Zephyr_Build.ps1WSL 构建pwsh -File Zephyr_Build.ps1
Zephyr_Flash.ps1J-Link 烧录pwsh -File Zephyr_Flash.ps1
Zephyr_Auto.ps1构建 + 烧录pwsh -File Zephyr_Auto.ps1
Zephyr_Clean.ps1清理构建目录pwsh -File Zephyr_Clean.ps1
Zephyr_Build-Release.ps1生产构建(pristine)双击
Zephyr_Auto-Release.ps1生产构建 + 烧录双击

小结

这套脚本的核心思路是把跨系统同步交给 Git:Windows 提交、WSL 拉取,不在文件系统层面共享代码。构建产物通过 /mnt 回流 Windows。私有密钥(HMAC、MCUboot 签名私钥)始终留在 WSL,不进 Git。

日常开发用 Zephyr_Auto.ps1 一键搞定;出生产固件双击 Zephyr_Auto-Release.ps1;调试单步用 Build 或 Flash。配置全在 build-config.psd1 里,换板子、换发行版改一处就行。