为什么需要跨系统构建
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 工作区,里面有.venv和zephyr源码WslBuildDir是构建目录,留空默认WslZephyrWs/buildBoard/BoardRoot传给 west 的-b和-DBOARD_ROOTPrivateConf是 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,私钥文件 600。sysbuild.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 ==='
走 /mnt 比 wsl.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 出来看脚本内容,定位问题方便。
Flash 脚本:J-Link Commander 烧录
烧录在 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.ps1 和 Zephyr_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 侧的
WslBuildDir用rm -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.ps1 | WSL 构建 | pwsh -File Zephyr_Build.ps1 |
Zephyr_Flash.ps1 | J-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 里,换板子、换发行版改一处就行。