干这行的都懂,手里同时维护好几个 Keil MDK 工程是什么体验:改完一版公共驱动,要把五六七八个 .uvprojx 挨个打开、挨个 Build、挨个看编译输出,全过一遍基本半小时没了。更要命的是这种重复劳动特别容易漏——漏编译一个型号、拿到旧的 hex 去烧录、日志没留档导致问题无法追溯,每一条都踩过的人才知道多痛。我后来花了一个晚上,用 Windows 批处理脚本把 Keil MDK 的命令行编译能力串成一条自动化流水线,一键跑完全部工程,日志自动归档,哪个工程报错一眼定位。这篇就把这套方案的思路、具体脚本和踩坑过程完整记录下来,给做嵌入式固件开发、批量出版本包的工程师一个可以直接抄的参考。
1. 为什么要搞自动化批量编译
1.1 手工编译的痛点到自动化思路
先说场景。我这边负责的固件不是一个产品一个工程,而是一个公共代码库拉出来十几套型号,每一套都有自己的 uvprojx 工程文件。公共库改了配置、更新了协议栈,或者 BSP 层做了统一优化,那就得保证所有型号全部重新编译通过才能发版。刚接手那会儿我纯手工操作,流程大概是:打开 A 工程,确认输出路径和 Target,点 Rebuild,盯着状态栏从 0% 爬到 100%,再扫一眼 Build Output 窗口有没有红色报错,然后关工程,开 B 工程……十几套下来,顺利的话一个半小时,不顺利的话中间接个电话回来就忘了自己看到哪儿了。
后来我想到 Keil MDK 本身带命令行编译能力。UV4.exe 支持-b参数执行批处理构建,只要在批处理脚本里把每个工程的路径喂给 UV4.exe,再统一收集日志,就能把这一串手工动作收敛成一条指令。这个思路的核心在于:Keil 的工程文件本质是 XML,构建动作本质是调用编译器驱动程序,IDE 只是套了一层图形壳。批处理脚本要做的就是把壳去掉,直接调底层工具。
1.2 批处理方案的能力边界
在动手写脚本之前,我先把“能做什么”和“不做什么”想清楚,免得后面越搞越复杂。
这套批处理脚本能解决的问题很明确:批量遍历指定工程、调用 UV4.exe 逐个项目编译、每个工程独立输出编译日志、根据返回值判断成功失败、把失败日志里的关键错误行抓出来方便定位。再进一步,还能做到指定 Target 编译、先 Clean 再 Rebuild、并发启动多个编译进程,甚至接入 Jenkins 或 GitLab CI 当构建脚本用。
但它也有天然的边界——批处理脚本不适合做复杂的依赖分析,也不适合管理外部工具链的安装和配置。比如你的工程用了外部算法、许可证、第三方库环境变量,这些还是得依赖 Keil 自己的配置。跨平台、深度并行、可视化进度这类需求,批处理也挺吃力,真要上规模还是得换 PowerShell 或者 Python 来做。但对多数团队来说,批处理已经可以把最痛的“逐个打开、逐个编译、逐个看日志”这一步彻底干掉。
2. 动手前的准备:UV4 命令行参数和返回值
2.1 UV4.exe 常用参数速查
批处理脚本调用 Keil 编译,本质就是执行 UV4.exe 并传参数。我先把用到的参数整理成表,方便后面参照。
| 参数 | 作用 | 说明 |
|---|---|---|
-b | 批处理构建(Batch Build) | 执行当前 Target 的增量编译,不弹出完整 IDE 窗口 |
-c | 清理(Clean) | 清除编译生成的中间文件和输出文件 |
-o | 指定日志输出文件 | 将构建信息输出到指定文本文件,否则直接在窗口输出 |
-j0 | 编译完成后自动关闭 | 适合在脚本中调用,避免 UV4 进程残留 |
-t | 指定 Target 名称 | 如果工程有多个 Target,用这个名字指定编译哪一个 |
| 工程路径 | 位置参数 | 传入.uvprojx或者.uvproj路径,建议用引号包住 |
我实际用的命令长这样:
"C:\Keil_v5\UV4\UV4.exe" -b "D:\Projects\DeviceA\DeviceA.uvprojx" -o "D:\BuildLogs\DeviceA.log" -j0关键点是路径必须用引号括起来,尤其是 Keil 默认安装到C:\Keil_v5\UV4\这种带目录结构的路径,或者项目放在带空格的目录下,不加引号百分之百会拆出莫名其妙的参数。
2.2 返回值怎么读
UV4.exe 执行完会有一个退出码(exit code)。批处理脚本通过%errorlevel%拿这个值。常见的约定是 0 表示编译通过,非 0 表示存在错误或异常。
这里我要多说一句:我在网上和实际项目里都见过把返回值理解成“0=成功,1=警告,2=错误”的说法。但从稳定性角度考虑,我建议不要在脚本里对具体数值做太细的分支,原因有两个。第一,Keil 不同小版本的返回值细节可能有差异;第二,有些工程开了“我严格把警告当错误”之类的编译选项,返回码含义会随之变化。最稳妥的判断逻辑很简单:%errorlevel%非 0 就标记为失败,同时去日志里搜 Error 关键字做二次确认。两条线索一起看,比单看返回值靠谱。
2.3 脚本的模块化设计思路
写批处理和写 C 代码一个道理,别上来就堆一大坨。我把脚本拆成三块:配置区、单工程编译函数、批量主流程。配置区负责定义 UV4 路径、工程列表文件、日志目录这些可能经常改的东西;单工程编译函数负责收一个工程路径,完成调用 UV4、记录返回值、打印结果的工作;主流程负责读取列表、循环调用函数、最后汇总统计。
这样拆的好处是扩展性。以后想加“只编译某个型号”的后缀参数,或者换成递归扫描目录里所有工程,都是改一两行的事,不用动核心逻辑。我在下面第三节会给出完整实现。
3. 批量遍历与编译核心实现
3.1 工程列表的三种获取方式
批处理脚本怎么知道要编译哪些工程?我用过三种方式,各有适用场景。
第一种,文本文件列表。我在脚本目录放一个 projects.txt,每一行写一个 uvprojx 的完整路径,以;开头表示注释。这种方式最直白,也最容易控制编译范围。比如临时只编译两个型号,就临时改一下这个文件。
第二种,递归扫描。用for /r遍历某个根目录下所有子目录,找到所有*.uvprojx文件,自动作为编译对象。这种方式适合“这个目录下所有工程全要编译”的场景,不用维护列表文件,但会把一些不想编译的 demo、参考工程也扫进去。
第三种,命令行参数传入。把工程路径作为参数传给批处理,比如mybuild.bat D:\Proj\A.uvprojx。这种方式适合在 IDE 的 External Tools 里挂脚本,或者配合其他工具做单工程快速编译。
我把它们的特性整理成了表:
| 方式 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| 文本文件列表 | 范围可控、注释方便 | 新工程要手动加一行 | 多产品线、发布包批量编译 |
| 递归扫描 | 零维护、全自动 | 会把不相关工程卷进来 | 单项目根目录统一构建 |
| 参数传入 | 灵活、可组合 | 一次只能指定少量工程 | 配合 IDE 或 CI 做定向编译 |
我的正式版本用的是第一种,因为它最接近真实发版流程——哪些型号进这个批次,由我来定,而不是由文件夹里有什么来定。
3.2 单工程编译函数
核心代码我封装成一个函数,叫BuildOne。它的入参是工程文件路径,职责很单一:处理一个工程,返回成败状态。
:BuildOne set "PRJ=%~1" if not exist "%PRJ%" ( echo [SKIP] project not found: %PRJ% goto :eof ) set "NAME=%~n1" set "LOG=%LOG_ROOT%\%NAME%.log" if exist "%LOG%" del /q "%LOG%" echo [BUILD] %NAME% "%UV4%" -b "%PRJ%" -o "%LOG%" -j0 if %errorlevel% equ 0 ( echo [PASS] %NAME% set /a PASS_COUNT+=1 ) else ( echo [FAIL] %NAME%, return code=%errorlevel% set /a FAIL_COUNT+=1 ) goto :eof这段代码有几个细节我解释一下。
%~1是取出传给函数的第一个参数,并且自动去掉外面包着的引号;%~n1是取这个路径的文件名主干,比如D:\Proj\DeviceA.uvprojx取出来就是DeviceA。所以日志文件名就是工程名加.log,不会撞车。
编译结束后,根据%errorlevel%判断,同时维护两个全局计数器。注意计数器用set /a在批处理函数里修改,必须在脚本开头setlocal enabledelayedexpansion开启延迟变量扩展,否则在 for 循环和函数里取到的%PASS_COUNT%很可能是旧值。这个问题新人经常踩,我放在最后的问题排查里再详细讲。
3.3 批量主流程完整脚本
现在把配置区、函数和主流程拼起来,就是一个可以直接用的完整脚本。我把 name 定为build_all.bat。
@echo off setlocal enabledelayedexpansion rem ============================== rem 配置区,按实际情况修改 rem ============================== set "UV4=C:\Keil_v5\UV4\UV4.exe" set "ROOT=%~dp0" set "LOG_ROOT=%ROOT%BuildLogs" set "PROJECTS_FILE=%ROOT%projects.txt" if not exist "%LOG_ROOT%" mkdir "%LOG_ROOT%" if not exist "%PROJECTS_FILE%" ( echo [ERROR] %PROJECTS_FILE% not found. exit /b 1 ) set /a PASS_COUNT=0 set /a FAIL_COUNT=0 echo Start auto build at %date% %time% echo ================================================ for /f "usebackq eol=; tokens=*" %%p in ("%PROJECTS_FILE%") do ( call :BuildOne "%%p" ) echo ================================================ echo Finish at %date% %time% echo PASS=%PASS_COUNT% FAIL=%FAIL_COUNT% exit /b %FAIL_COUNT% :BuildOne set "PRJ=%~1" if not exist "%PRJ%" ( echo [SKIP] project not found: %PRJ% goto :eof ) set "NAME=%~n1" set "LOG=%LOG_ROOT%\%NAME%.log" if exist "%LOG%" del /q "%LOG%" echo [BUILD] %NAME% "%UV4%" -b "%PRJ%" -o "%LOG%" -j0 if %errorlevel% equ 0 ( echo [PASS] %NAME% set /a PASS_COUNT+=1 ) else ( echo [FAIL] %NAME%, return code=%errorlevel% set /a FAIL_COUNT+=1 ) goto :eof对应projects.txt的内容大概是这样:
; 一行一个 Keil 工程 D:\Projects\DeviceA\DeviceA.uvprojx D:\Projects\DeviceB\DeviceB.uvprojx D:\Projects\DeviceC\DeviceC.uvprojx在 cmd 里执行build_all.bat,就会逐个编译。脚本执行完后,%FAIL_COUNT%作为进程退出码返回,方便外部工具通过 errorlevel 判断整批编译是否成功。
4. 日志归档与错误快速定位
4.1 日志文件按工程分类
脚本编译时自动按工程名生成了独立的日志文件,统一放在 BuildLogs 目录下。这一点我很早就刻意做了,因为 Keil 命令行编译时不加-o,构建信息只会往控制台刷,关了窗口就没了,人工再去翻 Build Output 窗口根本不现实。有了独立日志,每个工程的编译历史都在,出问题之后可以打开对应工程的.log逐行回看。
日志目录我建议按日期再分一层,比如BuildLogs\20240520\。这样时间久了不至于一个目录堆几百个文件。实现也不难,脚本里配置日志根目录时拼一个日期目录:
set "LOG_ROOT=%ROOT%BuildLogs%date:~0,4%%date:~5,2%%date:~8,2%"但这个写法受系统日期格式影响,如果你的机器不是YYYY-MM-DD风格,截出来的子串会不对。我更推荐固定格式来写,比如用wmic os get localdatetime取当前时间,稍微啰嗦一点但不受区域设置影响。不过这个内容不影响核心流程,后面的示例我还是用固定配置目录,重点讲日志分析和错误定位。
4.2 用 findstr 搜索错误和警告
编译日志里最关心的就是 Error 和 Warning。手动打开几十个日志逐个搜太傻了,我在脚本里加了一段二次检查逻辑,用findstr去失败日志里匹配关键字。
for /f "tokens=*" %%i in ('type "%LOG%" ^| findstr /i /n "error:"') do ( echo %%~i )更常见的做法是只看错误行存不存在,并统计数量:
set "ERR_COUNT=0" for /f %%i in ('type "%LOG%" ^| findstr /i /c:"error:"') do set /a ERR_COUNT+=1 if !ERR_COUNT! gtr 0 ( echo [LOG] %ERR_COUNT% error lines found, see %LOG% )注意我用的是error:而不是单独的error。这个细节来源于实战——Keil 日志里很多行会出现Error: L6218E: Undefined symbol,但有些行可能包含0 error(s),如果脚本逻辑再复杂一点,单独用error会把成功提示也算进去,容易误判。加一个冒号能大幅减少误报。
4.3 生成汇总报告
光在控制台打印 PASS/FAIL 还不够,长时间批量编译时,人会走开,回来后屏幕上刷过去的信息早就不可见了。我习惯让脚本额外写一个build_summary.txt,内容是本次编译的汇总表。
实现也很简单,在脚本退出前追加输出:
set "SUMMARY=%LOG_ROOT%build_summary.txt" ( echo Build time: %date% %time% echo PASS=%PASS_COUNT% FAIL=%FAIL_COUNT% echo ------------------------------ ) > "%SUMMARY%" for /f "usebackq eol=; tokens=*" %%p in ("%PROJECTS_FILE%") do ( set "TMP_NAME=%%~np" if exist "%LOG_ROOT%\!TMP_NAME!.log" ( echo !TMP_NAME! - ... ) )不过这个写法需要处理延迟变量和临时状态,我建议把它作为第二次迭代的增强项,第一版先保证日志齐全、控制台有统计,后面再加汇总文件。重点是别让脚本一开始就变得太重。
5. 进阶玩法:Target 切换、全量重编译、并发控制
5.1 指定 Target 编译
一个 Keil 工程文件里可以定义多个 Target,比如 Debug 版和 Release 版,或者 Bootloader 和 App。默认-b只编译当前激活的 Target,但命令行参数-t可以直接指定。
"%UV4%" -b "%PRJ%" -t "Release" -o "%LOG%" -j0我在脚本里增加了一个可选的 Target 参数,默认空,如果通过环境变量或者在配置区指定了TARGET=Release,就自动拼到命令行里:
set "TARGET=" if not "%TARGET%"=="" ( set "TARGET_PARAM=-t %TARGET%" ) "%UV4%" -b "%PRJ%" %TARGET_PARAM% -o "%LOG%" -j0但这里有一个坑:-t指定的 Target 名称必须完全匹配工程文件里写的名字,大小写、空格都要一致。推荐先打开 Keil 工程管理器看一眼 Target 名称长什么样,或者直接在 uvprojx 文件里搜<TargetName>节点,别凭记忆写。
5.2 全量重编译与增量编译的平衡
命令行默认的-b是增量编译,只重编有改动的文件。对日常验证来说增量编译快,但批量发版时我更推荐先 Clean 再 Rebuild。因为公共头文件、全局宏、链接脚本这些改动有时不会被增量逻辑正确感知,会留下一些用了旧规则编译的中间产物,最后烧出来行为不对,排查起来比编译报错痛苦得多。
脚本里做全量重编译很简单,在调用-b之前先调用一次-c:
"%UV4%" -c "%PRJ%" -j0 "%UV4%" -b "%PRJ%" -o "%LOG%" -j0要注意-c也会修改工程设置里的生成文件,所以脚本逻辑里建议把“全量编译”作为一个开关,默认关闭,需要的时候设成FULL_BUILD=1或者加一个参数控制。全量编译时也建议把-c的日志单独存一份,因为如果 Clean 阶段就报错(比如文件被别的程序占用),后面编译再成功一次也可能不是真正干净的。
5.3 并发编译与资源控制
单线程批量编译虽然稳,但十几二十个工程排队,耗时确实长。后来我研究了一下怎么让脚本并发编译。批处理本身没有好用的线程池概念,但可以借助start命令把 UV4.exe 作为独立进程跑。
start "Build_%NAME%" cmd /c ""%UV4%" -b "%PRJ%" -o "%LOG%" -j0"问题是如果循环里每个工程都直接 start,系统可能在 CPU 负载、磁盘 IO、License 占用几个层面同时崩掉。Keil 本身虽然不是严苛的内存许可证模式,但十几个 UV4 一起跑还是会把机器卡死。
我试过一种简单的并发控制:同时最多启动 N 个进程,每个进程结束后释放一个槽位。批处理里可以靠临时锁文件实现信号量,但代码很绕,容易出毛病。实话说,自从我改用 PowerShell 脚本做了ForEach-Object -Parallel,并发逻辑才清爽很多。批处理方案的定位更适合单线程稳定批量,如果资源允许,最多开 3~4 个并发就够了,别贪多。
如果你就是想给批处理加并发,下面这个思路可以参考:用一个目录里的 lock 文件数量做计数器,每次启动前等待lock文件数量小于 N,然后创建自己工程名的锁文件;编译结束删除锁文件。
:WaitForSlot set /a SLOT=0 for /f %%i in ('dir /b "%LOCK_DIR%\*.lock" 2^>nul ^| find /c /v ""') do set /a SLOT=%%i if !SLOT! GEQ 3 ( echo [WAIT] slot full, waiting... timeout /t 2 /nobreak >nul goto WaitForSlot )用type nul > "%LOCK_DIR%\%NAME%.lock"创建锁文件,编译结束后再删掉。思路没问题,但那个goto WaitForSlot在 for 循环里要配合 call 使用,否则会跳出循环逻辑。
6. 踩坑实录:常见问题与排查技巧
6.1 常见失败原因速查表
这套脚本从写出来到稳定跑了一个多月,期间踩了不少坑。我把能想到的问题整理成一个速查表,基本覆盖了新人最常遇到的几类情况。
| 问题 | 常见原因 | 解决办法 |
|---|---|---|
| 提示“系统找不到指定的路径” | UV4.exe 路径错误,或者工程路径包含中文空格 | 检查配置区和 projects.txt,确保引号包裹路径 |
| 编译失败但日志为空 | Keil IDE 正在运行,工程文件被锁定 | 编译前关闭所有 UV4.exe 窗口,或加-j0 |
| 返回值非 0 但没看到具体错误 | 日志文件未生成或-o路径不可写 | 检查 BuildLogs 目录权限,确认日志文件有内容 |
| 计数器一直是 0 | 没有开启延迟变量扩展 | 在脚本开头加setlocal enabledelayedexpansion,用!var!代替循环里的%var% |
| 中文路径或中文工程名乱码 | cmd 默认代码页不一致 | 脚本开头加chcp 65001 >nul,或统一用英文工程名 |
| Clean 一次之后找不到中间文件 | 某些工程的中间文件路径被全局配置覆盖 | 在 Keil 工程 Options 里检查 Output 和 Listing 路径 |
6.2 一个典型的失败排查实录
有段时间我的脚本经常在几个工程上报错,但打开日志只看到最后一行写了个链接错误。我一开始以为是代码问题,反复改了几处无果,后来发现规律是:只要我先手动编译过其中一个工程,再跑批处理,那个工程就失败。
原因其实不复杂。手动编译时 Keil IDE 是打开的,UV4 同时加载同一个 uvprojx 时,工程文件被 IDE 锁定,命令行实例只能读到“上一次 IDE 保存前的配置”,甚至直接拿不到写文件权限。批处理里虽然加了-j0,但如果 Keil 窗口开着,还是会起冲突。
解决方法也很粗暴:脚本开头加了一步强制关闭 Keil IDE 的操作,或者至少在调用编译前提示用户关掉 Keil 窗口。我用的是后者,不强行杀进程,避免没保存的配置被丢:
tasklist | findstr /i "UV4.exe" >nul 2>&1 if not errorlevel 1 ( echo [WARN] UV4 is running, please save your work and close Keil. pause )这个提示比直接taskkill更温和,也更适合在团队里推广。
6.3 我的一些使用建议
第一批脚本跑顺之后,我给自己定了几条规矩。
第一,养成每次编译前把工程列表 dump 一份存档的习惯。我是直接拷贝 projects.txt 到日志目录,只读不删,这样哪怕后面发版出了问题,还能对上当初编译的是哪几套工程。别觉得这是多此一举,真被“这个固件是哪次编译的哪个工程”找上门过就知道存档多重要了。
第二,脚本版本入库。bat 文件虽然不起眼,但也要交给 Git 管理,和项目代码一起走版本。有人改工程名、改输出路径、加新产品型,都要同步更新脚本和工程列表,不管理起来又是一笔糊涂账。
第三,不要在一开始就把自动化目标定得太大。先跑通三五个工程的单线程编译,加日志,加失败定位,后面再逐步上 Target 切换、全量编译、并发控制。这套顺下来之后,你甚至可以把 build_all.bat 挂到 Git 的 pre-commit 钩子上,提交前先本地验证一遍所有工程的编译状态。自动化这件事,永远是越用越顺手,但前提是先把地基打稳。
我在实际使用中还有一个体会:脚本稳定之后,真正省掉的不是那几十分钟点击时间,而是把“编译”这件事从“操作”变成了“记录”。每个工程编过没有、什么时候编的、日志在哪,全部可追溯。最后再分享一个小技巧:给日志里的错误行加个高亮,不用装任何额外工具,直接findstr /n "error:" *.log就能把所有工程日志里的错误一次性列出来,比一个个开文件找快得多。