
1. 项目概述为什么一个叫 Nimmake 的工具能让 MCU 固件构建从“烧脑”变“顺手”我第一次在嵌入式团队的 Slack 频道里看到nimmake这个词是在凌晨两点——一位同事发了一条消息“刚用 nimmake 把 STM32F407 的 bootloader app 两个固件用一条命令全编译、链接、校验、烧录进 Flash连 IDE 都没开。不是 demo是生产环境跑的。”底下跟着一串“”和“这玩意儿真不是 Python 脚本套壳”的回复。我当时正卡在 Keil 工程里改第 7 个 scatter 文件手边还摊着三份不同芯片厂商的 linker script 对照表听到这个消息的第一反应不是惊喜而是怀疑MCU 固件构建这件事真的能“简单”吗答案是肯定的——但前提是你得先理解“简单”在这里不是指“功能缩水”而是指把原本分散在 5~8 个独立工具链、3 种配置文件格式、2 套环境变量管理逻辑里的重复劳动压缩成一套语义清晰、可复用、可审计的声明式定义。Nimmake 正是这样一种工具它不替代 GCC、LLVM 或 armclang也不取代 OpenOCD、pyocd 或 J-Link Commander它像一个精密的“构建协作者”站在编译器、链接器、烧录器之上用 Nim 语言写成的 DSL领域特定语言把“我要为 Cortex-M4 架构的 GD32E503 编译一个带 CRC 校验的 OTA 升级包同时生成 SREC 和 BIN 两种格式并自动注入时间戳和 Git 提交哈希”这种自然语言需求翻译成机器可执行、人可读可维护的构建流程。它解决的不是“能不能编译”的问题而是“每次改一行代码就得手动同步修改 4 个地方”的工程熵增问题。关键词Nimmake、MCU、固件构建、Python、ARM、RISC-V其实揭示了一个现实矛盾Python 在嵌入式开发中承担了大量胶水脚本工作比如自动生成寄存器头文件、解析 SVD、打包固件但它不是为构建系统设计的语言——没有原生并发控制、缺乏编译期类型检查、启动慢、二进制体积大而传统 Makefile 又过于底层写错一个 tab 就失败跨平台路径处理反人类CMake 功能强大但学习曲线陡峭对单个 MCU 工程而言配置成本远超收益。Nimmake 的出现恰恰卡在这个缝隙里用 Nim 语言的表达力、零成本抽象、可静态编译、跨平台原生支持等特性专为 MCU 构建场景定制了一套轻量但完整的解决方案。它适合谁不是给刚学点亮 LED 的新手而是给已经用过 Keil/IDEA/VSCode CMake 搭过至少 3 种不同 MCU 平台工程、开始被重复配置折磨到想重学硬件设计的中级以上嵌入式工程师。如果你还在用 Excel 管理不同型号的 Flash 分区表或者靠复制粘贴修改 linker script那 Nimmake 不是锦上添花而是及时止损。2. 核心设计思路为什么选 Nim 而不是 Python 或 Rust这不是炫技而是工程权衡2.1 为什么不是 Python——胶水脚本的天然局限很多人第一反应是“既然都用 Python 写构建脚本了为啥还要换”这个问题我问过自己不下二十遍也带着团队实测对比过。我们曾用 Python invoke pydantic 写了一套完整的构建系统支持 ARM Cortex-M3/M4/M7 和 RISC-V RV32IMAC功能上完全覆盖 Nimmake 的当前能力多目标生成、依赖追踪、交叉编译切换、Flash 分区校验。但上线三个月后它成了 CI 流水线里最不稳定的环节。根本原因不在功能而在 Python 本身的运行时特性启动延迟不可忽视一个空的import sys; print(ok)脚本在 ARM A57 开发板上冷启动耗时 120ms而 Nim 编译出的静态二进制同一块板子上./nimmake --version是 3.2ms。对于需要高频触发的 pre-commit hook 或 watch 模式监听源码变化自动 rebuild这点延迟会直接拉长反馈循环。我们统计过团队平均每天执行构建操作 17 次光启动开销就浪费了 34 秒——一年下来就是近 2 小时。并发模型与嵌入式场景错配Python 的 GIL 让它无法真正并行编译多个模块。而现代 MCU 工程越来越倾向模块化设计如 BLE 协议栈、USB CDC 类、传感器驱动分属不同 git submodule理想状态是nimmake build --jobs 4同时编译四个子模块。Python 方案只能靠subprocess.Popen模拟但进程间通信、错误聚合、资源清理极其脆弱。我们遇到过一次 USB CDC 模块编译失败后子进程残留导致后续烧录被串口占用必须手动kill -9。部署即拷贝的奢望Python 脚本要运行必须有对应版本的解释器、pip 包、甚至特定 libc 版本。CI 服务器升级 glibc 后某次构建突然报ImportError: /lib64/libc.so.6: version GLIBC_2.28 not found排查了 6 小时才发现是某个第三方包悄悄升级了依赖。而 Nim 编译出的nimmake二进制是纯静态链接的ldd nimmake输出not a dynamic executable扔进任何 Linux/Windows/macOS 环境都能直接跑。提示这不是贬低 Python。它在生成 SVD 解析器、做 OTA 差分算法、写自动化测试脚本时依然无可替代。但构建系统是基础设施它的稳定性、启动速度、部署简易性权重远高于语法糖的丰富度。2.2 为什么不是 Rust——重量与轻量的边界在哪里Rust 是当下构建系统的新宠Cargo 本身已是事实标准。但我们做过 Rust 版 Nimmake PoC用cargo-makebuild.rs 自定义build.rs宏也能实现类似功能。问题在于Rust 的工程惯性太强。Cargo 默认要求src/lib.rs、Cargo.toml、target/目录而 MCU 工程的典型结构是project/ ├── firmware/ # 主固件源码 ├── bootloader/ # 引导程序 ├── tools/ # 自定义工具如 flasher.py ├── config/ # 不同板卡的配置stm32f4_discovery.nim, gd32e503_eval.nim └── Makefile # 传统入口强行套用 Cargo 结构要么把整个 firmware 目录塞进src/违背嵌入式习惯要么写一堆path ../firmware的硬编码路径破坏可读性。更关键的是Rust 的编译时间——一个空的cargo build --release在 M1 Mac 上要 8.2 秒而 Nim 的nim c -d:release -o:nimmake src/nimmake.nim只需 1.4 秒。对于需要频繁迭代构建逻辑的工具开发者这个差距意味着每天多出 20 分钟等待时间。Nim 的优势在于它精准踩在“足够表达力”和“足够轻量”之间它有宏系统macro能写出target stm32f407 do:这样接近自然语言的 DSL比 Rust 的build.rs函数式 API 更易读它支持零成本抽象zero-cost abstractionproc build_flash_image*(cfg: Config) 编译后就是裸函数调用无 runtime 开销它的包管理器nimble极简nimble install nimmake本质就是git clone nim c没有 lockfile、nohoist、monorepo 等概念负担最重要的是Nim 的语法对 C/C/Python 工程师几乎零学习成本let x 5、for i in 0..10:、if x y: echo ok写起来像 Python跑起来像 C。2.3 Nimmake 的三层架构DSL → Runtime → Backend每一层都为 MCU 服务Nimmake 不是一个单体程序而是一个分层设计的构建引擎每层都针对 MCU 场景做了深度优化DSL 层.nimk 文件这是用户接触的唯一接口。一个典型的build.nimk长这样import nimmake target gd32e503 do: toolchain gcc-arm-none-eabi-10.3 mcu GD32E503VCT6 flash_layout config/gd32e503_flash.nim sources: firmware/src/main.c firmware/src/gpio.c bootloader/src/boot.c defines: DEBUG1 BOARD_GD32E503_EVAL post_build do: run arm-none-eabi-objcopy -O binary $BUILD_DIR/firmware.elf $BUILD_DIR/firmware.bin run python3 tools/crc32_inject.py --input $BUILD_DIR/firmware.bin --output $BUILD_DIR/firmware_crc.bin run openocd -f interface/stlink.cfg -f target/gd32e503.cfg -c program $BUILD_DIR/firmware_crc.bin verify reset exit注意几个关键点flash_layout不是字符串而是导入一个 Nim 模块里面可以写const FLASH_SECTOR_SIZES [0x4000, 0x4000, 0x10000]这样的编译期常量post_build是闭包可以调用任意外部命令但run函数会自动捕获 stdout/stderr 并集成到构建日志$BUILD_DIR是内置变量不是 shell 环境变量不会受用户.bashrc干扰。Runtime 层nimmake 可执行文件它负责解析.nimk构建依赖图调度任务。这里的关键创新是增量构建的粒度控制。传统 Makefile 以文件为单位但 MCU 工程里一个#define DEBUG1的变更理论上只影响main.c的编译不该触发bootloader/src/boot.c重编。Nimmake 通过分析 C 源码中的#include和#define依赖构建出 AST 级别的依赖图。我们实测过当只修改main.c中一行printfNimmake 的 rebuild 时间是 1.8s而 Makefile 是 4.3s因为bootloader目标也被强制重做。Backend 层工具链适配器Nimmake 自身不调用 GCC而是通过Toolchain抽象统一接口。目前内置支持gcc-arm-none-eabi、armclang、riscv64-unknown-elf-gcc每个都封装了编译器路径探测自动扫描/opt/arm/gcc/、$HOME/.local/bin/、PATH标准库路径计算--sysroot参数自动生成Linker script 注入逻辑根据flash_layout模块动态生成-T参数错误信息标准化把arm-none-eabi-gcc: error: unrecognized command line option -mcpucortex-m4f统一转成ERROR: Unsupported CPU for GD32E503. Valid: cortex-m3, cortex-m4。这套分层让 Nimmake 既保持了 DSL 的简洁又具备了工业级构建系统的鲁棒性。它不是为了取代 CMake而是为那些“CMake 太重Makefile 太脆”的中小规模 MCU 项目提供一个恰到好处的中间解。3. 核心细节解析从一个 .nimk 文件到烧录成功的完整链路3.1 .nimk 文件的语法精要比 Makefile 更直白比 CMake 更专注.nimk文件不是配置文件而是可执行的 Nim 代码。这意味着它天然支持条件逻辑、循环、函数定义——这些在传统构建系统中需要 hack 才能实现的功能在 Nimmake 中是第一公民。我们来看一个真实案例为同一套固件代码同时生成 ARM 和 RISC-V 两个版本用于双核异构 MCU如 NXP i.MX RT1170Cortex-M7 Cortex-M4。import nimmake import strutils # 从环境变量或命令行参数获取目标架构 let arch getEnv(TARGET_ARCH, arm) target imxrt1170_ arch do: case arch of arm: toolchain gcc-arm-none-eabi-10.3 mcu IMXRT1176DVMAA cpu cortex-m7 fpu vfpv3-d16 of riscv: toolchain riscv64-unknown-elf-gcc-12.2 mcu IMXRT1176DVMAA cpu rv32imac fpu none # 共享配置 flash_layout config/imxrt1170_flash.nim sources: firmware/src/core.c firmware/src/periph.c firmware/src/ arch _specific.c # 动态路径 defines: ARCH_ arch.toUpper() SOC_IMXRT1170 # 条件编译ARM 版本启用 FPURISC-V 版本禁用 if arch arm: cflags: -mfloat-abihard -mfpuvfpv3-d16 else: cflags: -marchrv32imac -mabiilp32 post_build do: let bin_name firmware_ arch .bin run arm-none-eabi-objcopy -O binary $BUILD_DIR/firmware.elf $BUILD_DIR/ bin_name # RISC-V 版本额外生成 ELF 供调试 if arch riscv: run cp $BUILD_DIR/firmware.elf $BUILD_DIR/firmware_riscv.elf这段代码展示了三个核心能力动态目标名target imxrt1170_ arch让nimmake build imxrt1170_arm和nimmake build imxrt1170_riscv成为合法命令无需写两个重复 target条件分支case arch和if arch arm直接用 Nim 语法比 Makefile 的$(if $(filter arm,$(ARCH)),...)清晰十倍字符串插值firmware/src/ arch _specific.c在编译期确定路径Nimmake 会自动将其加入依赖追踪一旦arm_specific.c修改只重编该文件。注意所有字符串拼接都在编译期完成不是运行时。Nimmake 的 DSL 解析器会在加载.nimk时把整个文件编译成内存中的 AST再执行。所以getEnv(TARGET_ARCH)的值在构建开始前就已确定不会出现“构建中途环境变量突变”的诡异问题。3.2 Flash 分区布局的声明式定义告别手写 linker script 的噩梦MCU 的 Flash 分区Bootloader、App、Config、OTA Slot是固件构建中最易出错的部分。传统做法是维护一个STM32F407VGT6.ld文件里面充斥着FLASH (rx) : ORIGIN 0x08000000, LENGTH 1024K这样的硬编码。一旦芯片 Flash 容量变更比如从 512KB 升级到 1MB就要手动改LENGTH还要同步更新bootloader的起始地址稍有不慎就导致 App 覆盖 Bootloader。Nimmake 的解法是把 Flash 布局写成一个 Nim 模块例如config/stm32f407_flash.nim# config/stm32f407_flash.nim import nimmake const FLASH_BASE 0x08000000u32 FLASH_SIZE 1024 * 1024 # 1MB SECTOR_SIZES [16*1024, 16*1024, 16*1024, 16*1024, 64*1024, 128*1024, 128*1024, 128*1024] # 分区定义名称、起始偏移、大小、属性 let partitions [ Partition(bootloader, 0, 32*1024, {readable, executable}), Partition(app_primary, 32*1024, 512*1024, {readable, executable}), Partition(app_secondary, 544*1024, 512*1024, {readable, executable}), Partition(config, 1056*1024, 4*1024, {readable, writable}), Partition(ota_metadata, 1060*1024, 4*1024, {readable, writable}) ] # 自动生成 linker script 的函数 proc generateLinkerScript*(outFile: string) var f open(outFile, fmWrite) defer: f.close() f.writeLine(/* Auto-generated by Nimmake */) f.writeLine(MEMORY) f.writeLine({) for p in partitions: let addr FLASH_BASE p.offset f.writeLine( p.name (rx) : ORIGIN 0x $addr, , LENGTH $p.size) f.writeLine(}) # ... 后续生成 SECTIONS在.nimk中只需flash_layout config/stm32f407_flash.nimNimmake 就会在构建前调用generateLinkerScript(build/stm32f407.ld)将生成的.ld文件作为-T参数传给链接器同时partitions数据结构会被注入到构建上下文供post_build脚本使用比如post_build do: # 自动校验 App 分区是否溢出 let appSize getFileSize($BUILD_DIR/app_primary.bin) if appSize partitions[1].size: fatal App size $appSize B exceeds partition size $partitions[1].size B这种“数据驱动布局”的方式让 Flash 管理从“手工编辑文本”升级为“编程式定义”。我们团队曾用此方法将一款支持 7 种不同 Flash 容量的 MCU 产品线其 linker script 维护工作量从每月 8 小时降至 0.5 小时。3.3 时间戳与 Git 元数据注入让每一版固件都可追溯标题中提到的“mcu 时间戳”热词背后是固件可追溯性的刚需。客户报障时说“固件版本 2.1.0 有问题”但你仓库里可能有 3 个 commit 都打了v2.1.0tag。真正的解法是把构建时刻的精确时间、Git 提交哈希、分支名固化进固件二进制。Nimmake 内置timestamp和git_info模块用法极简target my_mcu do: # ... 其他配置 # 在编译时注入时间戳UTC cflags: -DTIMESTAMP quote($getTime()) # 注入 Git 信息 cflags: -DGIT_COMMIT quote(gitInfo().commit) cflags: -DGIT_BRANCH quote(gitInfo().branch) cflags: -DGIT_DIRTY $(gitInfo().isDirty) # 生成版本字符串如 2.1.0-20231015-abc123-dirty post_build do: let verStr getVersionString(2.1.0) run python3 tools/inject_version.py --input $BUILD_DIR/firmware.elf --output $BUILD_DIR/firmware_with_ver.elf --version quote(verStr)其中getVersionString是一个 Nim 函数定义在tools/version.nimproc getVersionString*(baseVer: string): string result baseVer let git gitInfo() if git.commit.len 0: result.add(- $git.time.epochTime) # Unix timestamp result.add(- git.commit[0..6]) if git.isDirty: result.add(-dirty)最终生成的固件C 代码中可通过extern const char BUILD_VERSION[];直接访问。我们在量产设备上启用了此功能售后人员用串口发送ATVER?设备返回2.1.0-1697385600-abc123-dirty后台系统自动关联到 Git commit 页面故障定位时间从平均 4 小时缩短至 12 分钟。实操心得时间戳必须用getTime()而非now()因为now()返回本地时区时间而getTime()是 UTC避免跨国团队因时区差异产生歧义。我们曾因now()导致美国和中国团队构建的固件版本号相同但二进制不同引发过一次小范围 OTA 回滚事故。4. 实操过程从零开始搭建一个 GD32E503 工程的完整记录4.1 环境准备三步到位不碰任何全局安装Nimmake 的设计理念是“最小侵入”它不要求你卸载现有工具链也不修改系统 PATH。整个搭建过程如下第一步安装 Nim 编译器仅需 2 分钟从 https://nim-lang.org/install.html 下载对应平台的安装包。Linux 用户推荐用choosenimcurl https://nim-lang.org/choosenim/init.sh -sSf | sh source ~/.nimble/bin/nimble验证nim --version应输出Nim Compiler Version 2.0.0或更高。第二步安装 Nimmake无依赖纯静态nimble install nimmake1.2.0 # 或者从源码编译更可控 git clone https://github.com/nim-lang/nimmake.git cd nimmake nim c -d:release -o:bin/nimmake src/nimmake.nim生成的bin/nimmake是单个二进制文件可直接拷贝到项目目录或加入PATH。我们团队的做法是在项目根目录放一个tools/nimmakeCI 脚本里直接调用./tools/nimmake彻底隔离环境。第三步准备工具链按需下载不污染系统Nimmake 会自动探测工具链但为确保一致性我们显式指定ARM下载gcc-arm-none-eabi-10.3-2021.10-x86_64-linux.tar.bz2解压到tools/gcc-arm/RISC-V下载riscv64-unknown-elf-gcc-12.2.0-2022.06-x86_64-linux-ubuntu14.tar.bz2解压到tools/riscv/OpenOCD下载openocd-0.12.0.tar.gz编译安装到tools/openocd/。然后在.nimk中用toolchain tools/gcc-arm/bin显式指向避免与系统已装的arm-none-eabi-gcc冲突。这一步的关键是所有工具链路径都是相对于项目根目录的可随代码库一起 Git 管理新人git clone nimmake build即可运行无需文档说明“请先安装 XXX”。4.2 创建第一个 .nimk 文件5 分钟跑通 Hello World新建项目目录gd32e503_demo结构如下gd32e503_demo/ ├── build.nimk # 主构建文件 ├── firmware/ │ ├── src/ │ │ └── main.c # 点亮 LED 的最简代码 │ └── include/ ├── config/ │ └── gd32e503_flash.nim └── tools/ └── led_blink.cbuild.nimk内容import nimmake target gd32e503_demo do: toolchain tools/gcc-arm/bin mcu GD32E503VCT6 flash_layout config/gd32e503_flash.nim sources: firmware/src/main.c cflags: -mcpucortex-m34 -mfloat-abisoft -ffunction-sections -fdata-sections ldflags: -Wl,--gc-sections -Wl,--print-memory-usage # 生成 HEX 和 BIN 两种格式 post_build do: run arm-none-eabi-objcopy -O ihex $BUILD_DIR/firmware.elf $BUILD_DIR/firmware.hex run arm-none-eabi-objcopy -O binary $BUILD_DIR/firmware.elf $BUILD_DIR/firmware.bin echo ✅ Build completed. Output in $BUILD_DIRconfig/gd32e503_flash.nim简化版import nimmake const FLASH_BASE 0x08000000u32 FLASH_SIZE 512 * 1024 let partitions [ Partition(app, 0, FLASH_SIZE, {readable, executable}) ]firmware/src/main.c标准 GD32 初始化#include gd32e50x.h int main(void) { rcu_periph_clock_enable(RCU_GPIOA); gpio_init(GPIOA, GPIO_MODE_OUT_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_0); while(1) { gpio_bit_set(GPIOA, GPIO_PIN_0); for(volatile int i0; i1000000; i); gpio_bit_reset(GPIOA, GPIO_PIN_0); for(volatile int i0; i1000000; i); } }执行构建# 第一次运行会创建 build/ 目录编译所有源码 nimmake build gd32e503_demo # 后续修改 main.c 后只重新编译 main.o链接 nimmake build gd32e503_demo首次构建耗时约 8.2 秒含编译、链接、生成 HEX/BIN比同等配置的 Makefile 快 2.3 秒。生成的build/firmware.bin可直接用 OpenOCD 烧录openocd -f interface/stlink.cfg -f target/gd32e503.cfg -c program build/firmware.bin verify reset exit4.3 进阶实战添加 OTA 升级支持与 CRC 校验真实项目不可能只有main.c。我们为 GD32E503 添加 OTA 功能需要Bootloader 占用前 32KBApp 分区从 0x00008000 开始OTA Slot 从 0x00080000 开始预留 512KB每个固件镜像末尾追加 4 字节 CRC32。修改config/gd32e503_flash.nimconst FLASH_BASE 0x08000000u32 BOOTLOADER_SIZE 32 * 1024 APP_SIZE 480 * 1024 OTA_SLOT_SIZE 512 * 1024 let partitions [ Partition(bootloader, 0, BOOTLOADER_SIZE, {readable, executable}), Partition(app_primary, BOOTLOADER_SIZE, APP_SIZE, {readable, executable}), Partition(ota_slot, FLASH_BASE 0x00080000, OTA_SLOT_SIZE, {readable, writable}) ]build.nimk新增 targettarget gd32e503_ota do: # ... 同上但 sources 包含 bootloader/ sources: bootloader/src/boot.c firmware/src/main.c # 链接时指定 bootloader 的入口 ldflags: -Ttext0x08000000 post_build do: # 1. 生成 bootloader.bin不含 CRC run arm-none-eabi-objcopy -O binary $BUILD_DIR/bootloader.elf $BUILD_DIR/bootloader.bin # 2. 生成 app.bin不含 CRC run arm-none-eabi-objcopy -O binary $BUILD_DIR/firmware.elf $BUILD_DIR/app.bin # 3. 为 app.bin 追加 CRC run python3 tools/crc32_append.py --input $BUILD_DIR/app.bin --output $BUILD_DIR/app_crc.bin # 4. 合并 bootloader app_crc 到最终固件 run cat $BUILD_DIR/bootloader.bin $BUILD_DIR/app_crc.bin $BUILD_DIR/firmware_ota.bin echo OTA firmware ready: $BUILD_DIR /firmware_ota.bintools/crc32_append.pyPython 脚本Nimmake 允许混合使用#!/usr/bin/env python3 import sys import zlib def append_crc(input_file, output_file): with open(input_file, rb) as f: data f.read() crc zlib.crc32(data) 0xffffffff with open(output_file, wb) as f: f.write(data) f.write(crc.to_bytes(4, little)) if __name__ __main__: append_crc(sys.argv[2], sys.argv[4])执行nimmake build gd32e503_ota全程自动化无需人工干预。我们实测过这套流程在 CI 中稳定运行 18 个月构建成功率 99.997%失败原因全是硬件问题如 ST-Link 连接断开而非构建逻辑错误。5. 常见问题与排查技巧实录那些官网不会写的坑5.1 典型问题速查表问题现象可能原因排查命令解决方案nimmake: command not foundNim 编译器未安装或未加入 PATHwhich nim运行choosenim安装或手动将~/.nimble/bin加入 PATHError: cannot find toolchain gcc-arm-none-eabi工具链路径配置错误或权限不足ls -l tools/gcc-arm/bin/arm-none-eabi-gcc检查路径是否正确Linux 下确认arm-none-eabi-gcc有执行权限chmod xundefined reference to SystemInit启动文件未包含在 sources 中nimmake build --verbose在.nimk的sources列表里添加firmware/src/startup_gd32e503.sBuild failed: arm-none-eabi-gcc returned exit code 1编译器报错被 Nimmake 截断nimmake build --verbose加--verbose查看完整 GCC 输出常见于头文件路径缺失检查includes配置post_build command python3... failedPython 脚本路径错误或缺少依赖cd build python3 ../tools/crc32_append.py ...在post_build中用绝对路径run python3 getCurrentDir() /tools/crc32_append.py5.2 独家避坑技巧来自 37 次现场救火的经验技巧一用nimmake clean比rm -rf build/更安全Nimmake 的clean命令不只是删目录它会先执行pre_clean钩子可自定义如关闭正在运行的 OpenOCD只删除由 Nimmake 创建的文件避免误删build/logs/等人工存放的文件记录 clean 日志便于审计。我们曾因rm -rf build/删除了 CI 生成的build/artifacts/导致发布包丢失从此全员改用 nimmake