如果你最近在折腾系统安装、给老主板补NVMe引导、或者想跳过BIOS图形界面调整启动顺序,那你多半已经撞到过UEFI Shell。这玩意儿本质上是UEFI固件自带的一个精简命令行环境,电脑还没进入操作系统之前,它先给你开了一扇“底层操作窗口”。很多人搜索UEFI Shell脚本语法,是因为已经通过某个入口进了这个环境,但发现命令不像Windows里那么顺手,想用一段脚本自动完成批量操作,却被if、for、变量展开这些语法卡住。
这篇就把UEFI Shell脚本语法从底层逻辑到实际写法完整过一遍。我会先用最直白的方式讲清楚它和Bash、CMD到底差在哪,再拆解变量、流程控制、返回码这些核心语法点,最后放几个能直接抄的.nsh脚本和常见报错排查。适合三类人:经常帮人修引导、装系统的运维和装机佬;做固件、硬件调试的工程师;以及想把电脑启动流程完全控制在自己手里的折腾型玩家。
1. UEFI Shell脚本语法到底是个什么——先把背景盘清楚
1.1 它跟你熟悉的Bash、CMD完全不是一回事
很多人第一次进UEFI Shell,第一反应是“这跟DOS挺像”。确实,它界面是黑底白字,命令靠键盘敲,路径用反斜杠,但这种相似很容易误导你。UEFI Shell不是一个操作系统里的终端,它运行在UEFI固件环境中,启动管理器还没把Windows或Linux内核拉起来的时候,Shell就已经在干活了。所以它没有进程这个概念,没有那种“后台挂一个daemon”的玩法,也不存在什么用户权限,你在Shell里干的事基本都能直接摸到硬件和固件。
这里就得说清楚一个最关键的差异:UEFI Shell里的命令分成两类。一类是Shell内置的关键词,比如echo、if、for、goto、set,这类由Shell解析器直接处理;另一类是外部存放在.efi文件里的工具,比如bcfg、dmpstore、load这些,它们其实是独立的EFI应用程序。你在Shell里输入bcfg boot dump,实际上就是让Shell找到bcfg.efi这个文件并运行它,只是bcfg.efi通常被固化在固件里或者放在Shell的安装目录中。理解了这点,你再看很多“命令不存在”的报错就不会慌:不是语法错了,是Shell没找到对应的.efi文件。
1.2 怎么进UEFI Shell,脚本又从哪里开始执行
进入UEFI Shell常见的有三种方式。第一种是主板固件里直接集成,很多工作站主板和专业板卡在BIOS Setup里有个“Launch UEFI Shell”选项,打开后重启就能直接进Shell。第二种是用U盘引导:准备一个FAT32格式的U盘,把Shell.efi放到根目录,启动时选择从U盘启动,固件会把这个efi文件当作引导程序加载。第三种是从类似rEFInd这样的引导管理器里间接进入。
脚本执行的入口也很有意思。UEFI Shell启动后,会在当前文件系统的根目录里找startup.nsh,如果存在就自动执行。这个文件就相当于你在Bash里用的.bashrc,但它是主动执行的,不需要手动source。所以一个很常见的玩法是:把U盘做成FAT32,根目录放一个Shell.efi,再放一个写好的startup.nsh,插到目标机器上开机,所有命令自动跑完,省得在命令行里一条条敲。这个特性在批量装机、给大量机器刷固件的时候特别实用。
1.3 能干什么:列出真实应用场景和适用人群
UEFI Shell最值钱的地方在于,它能在操作系统完全加载之前,直接操作启动链条上的所有关键节点。常见场景包括:查看和修改启动项顺序(bcfg命令)、加载额外的NVMe或RAID驱动、读写UEFI变量、查看内存映射和PCI设备信息、刷新显卡GOP或主板BIOS、在Windows安装报“磁盘布局不受UEFI支持”之类错误时检查ESP分区和启动文件。对于做固件开发的人来说,Shell还能用来跑测试用例、检查ACPI表、调试串口输出,几乎是必备工具。
所以这篇文章的受众人群非常明确:运维工程师、装机维修人员、固件开发者,以及喜欢自己折腾启动过程的硬核玩家。不管你是想修一个进不去的Windows引导,还是想给老显卡刷一个支持UEFI的GOP ROM,UEFI Shell脚本都能把这一堆重复操作压缩成几分钟内自动完成的事。
2. UEFI Shell脚本语法核心规则,一页纸过一遍
2.1 变量、参数和展开方式:%Var% 与 ${Var}
UEFI Shell里定义变量用set命令,格式比Bash宽松一点,两种写法都行:
set MyBootDisk fs0: set MyDir = \EFI\BOOT第一行把MyBootDisk设成字符串“fs0:”,第二行把MyDir设成“\EFI\BOOT”。注意set后面等号两边的空格不会被自动吃掉,所以“set MyDir = xxx”和“set MyDir=xxx”在部分实现里结果可能不一样,最稳妥的写法还是不留空格。
引用变量时用百分号包起来,也可以使用${Var}形式:
echo My boot disk is %MyBootDisk% echo Path is ${MyDir}\BOOTX64.EFI为什么会有两种写法?因为老的%Var%形式在变量后面紧跟字符时容易产生歧义。比如你要输出“fs0:\EFI”,如果写成%MyBootDisk%\EFI,Shell需要自行判断%结束在哪里,部分情况下会把后面的反斜杠或冒号当成变量名的一部分,结果解析错乱。${Var}这种写法就没有这个问题,花括号明确划定了变量名的边界,所以我建议在脚本里统一用${...}。
变量名严格区分大小写。set mybootdisk fs0:和set MyBootDisk fs0:是两个完全不同的变量,echo %MyBootDisk%不会输出你设进mybootdisk里的值。这个坑我踩了不止一次,尤其是从别人脚本里复制一段代码,顺手把变量名大小写改了,结果跑了半天全是空值。
脚本本身还支持命令行参数,$0到%9分别代表脚本名和第一个到第九个参数,比如你写一个MyScript.nsh,执行方式为“MyScript.nsh fs0: test”,那么在脚本内部%0就是MyScript.nsh,%1是fs0:,%2是test。注意在UEFI Shell里没有Bash那种$@或$*这种一次性拿全部参数的写法,参数数量多了就得自己循环拼接。
2.2 流程控制:if、for、goto、调用子脚本
UEFI Shell的if语法和Bash长得不一样,它没有方括号,没有then的变量扩展,就是赤裸裸的“如果满足就执行”:
if %MyBootDisk% == fs0: then echo Boot disk is fs0: else echo Boot disk is not fs0: endif判断条件里,== 前后必须有空格。写成“if %MyBootDisk%==fs0: then”在多数实现里会被解析成一个整体字符串,判断永远不会成立。除了字符串等值比较,还支持!=、<、>这些,但UEFI Shell本质上把一切都当字符串处理,所以数值比较要非常小心。比如“if 10 == 9”这种判断,很多实现会直接返回失败但也可能行为诡异,实际干活时尽量把数字比较转换成字符串判断,或者用外部工具辅助。
文件存在性判断是另一个高频用法,语法是:
if exist fs0:\EFI\Microsoft\Boot\bootmgfw.efi then echo Found Windows Boot Loader endif这个在检查安装环境时非常有用,相当于Bash里的[ -f file ]。注意路径里反斜杠别漏,盘符冒号后面有没有反斜杠也有讲究,fs0:\EFI和fs0:EFI是两回事。
for循环语法介于批处理和现代Shell之间:
for %i in (fs0 fs1 fs2) do echo Check disk %i: endfor循环变量在脚本里写法是“%i”,不是Bash里的$i,也不是批处理里的%%i。括号里是空格分隔的列表,每次循环将列表中的值赋给%i,然后执行do到endfor之间的内容。循环体里可以继续调用if、set、外部命令,逻辑上跟普通语言的for-in没太大区别。
goto和标签也是有的,标签用冒号开头:
:loop echo Looping... goto loop这种写法可以用来做简单的死循环,或者配合if判断实现类似while的效果。UEFI Shell规范里没有完整意义的while,虽然有些内部命令会有条件判断,但从脚本编写角度看,老老实实用“标签+goto+if”组合是最通用、最不会出兼容性问题的方式。
调用子脚本也有专门语法。如果你在一个.nsh里想执行另一个.nsh,直接写脚本文件名,后面带上参数就行,也可以用call关键字:
call SubScript.nsh fs1:子脚本里通过%1访问传入参数。被调脚本结束后,控制权会回到父脚本继续执行下一行。这里有个容易忽略的点:子脚本里的set变量会影响父脚本吗?答案会,Shell环境变量是全局的,子脚本里set foo bar,回父脚本后foo依然是bar。很多人在写模块化脚本时没注意这一点,变量名冲突导致父脚本逻辑出错。
2.3 命令输出、重定向与返回码
UEFI Shell支持标准输出重定向,> 是覆盖写入,>> 是追加写入:
map > mapping.txt bcfg boot dump -v >> bootdump.txt输出重定向在脚本里是审计和调试的命根子。因为Shell环境不是正式操作系统,没有强大的日志系统,你把每次脚本执行的关键输出写进txt,之后用type命令查看,比盯着屏幕靠谱得多。
管道|在UEFI Shell里也能用,但远远没有Linux里那么成熟。常见做法是把命令输出接给另一个命令,比如“ver | echo”这种简单管道可能没问题,但一旦涉及复杂文本处理就非常容易踩坑,因为Shell内置的文本处理工具少得可怜。我更推荐的做法是:先把输出重定向到文件,再用type或外部工具的文本处理能力分析这个文件,这样可控性强很多。
返回码这块,Shell在环境变量里提供了一个类似错误码的东西,叫%lasterror%。执行完一条命令后,这个变量会保存上一条命令的状态码。注意,不同实现里这个值可能是十进制也可能是十六进制,比如0x0,所以在条件判断时别只写“if %lasterror% == 0”,最好用“if %lasterror% == 0x0”或者两个都试。经验做法是:先echo %lasterror%看看你的固件返回什么格式,再写对应的比较语句。
2.4 语法细节最容易踩的坑
UEFI Shell脚本的编码格式是个大坑。脚本文件必须是纯文本,编码建议ASCII或者无BOM的UTF-8。如果你在Windows记事本里把脚本存成了带BOM的UTF-8,或者存成了UTF-16,Shell解析器会因为开头的不可见字节直接把整个文件当成乱码,最常见的表现就是“命令not found”。另一个容易忽略的是换行符,最好统一用LF,有的固件对CRLF处理不好,行尾的\r会被塞进参数里,导致文件路径和变量值莫名其妙多一个不可见字符。
注释只能用整行#开头,不支持行尾注释。也就是说“echo hello # this is comment”里的#会被当成普通字符串传给echo,输出出来还是一个带#号的文本。脚本里也没有Bash那种$(command)命令替换,你想拿一个命令的输出赋给变量,最朴素的方案是先重定向到文件,再用for循环或者set批量读变量。
路径分隔符要统一用反斜杠。UEFI Shell里虽然有些命令也接受正斜杠,但混用容易出问题。盘符切换的写法是fs0:,然后cd \EFI\BOOT,注意盘符和路径之间不要留空格。很多人在Shell里输“fs0: \EFI\BOOT”,多打了一个空格,结果是切换到fs0:后执行了一个名为“\EFI\BOOT”的命令,自然报错。
3. 从零写三个可以直接用的脚本
3.1 脚本A:检查启动模式和磁盘布局,把信息导出到文件
这个脚本特别适合在装机或者修复引导前先跑一遍,相当于给电脑做个体检。完整内容如下:
# BootCheck.nsh echo ====================================== echo UEFI Shell Boot Environment Check echo ====================================== ver echo. echo --- Mapping table --- map echo --- Check common boot files --- for %fs in (fs0 fs1 fs2 fs3) do if exist %fs:\EFI\Microsoft\Boot\bootmgfw.efi then echo Found Windows Boot Manager on %fs: endif if exist %fs:\EFI\BOOT\BOOTX64.EFI then echo Found generic x64 loader on %fs: endif endfor echo --- Save report to boot_report.txt --- map > boot_report.txt echo [done] Run 'type boot_report.txt' to view the report.这个脚本干了三件事:第一,用ver输出当前Shell版本,方便判断固件能力;第二,用map命令列出所有可用的映射盘符,这是定位EFI分区最核心的手段;第三,用for循环在fs0到fs3这几个常见盘符里探测Windows和通用引导文件是否存在。
要注意的是,for循环里%fs展开后不带冒号,所以拼接路径时我直接写成“%fs:\EFI...”,这样%fs展开后后面跟着的冒号和反斜杠就成了字面量,最终得到fs0:\EFI...。这种写法是UEFI Shell脚本里非常典型的一种拼接方式,很多新手在这里懵住,以为要写成%fs%:\EFI,结果变量解析混乱。实际执行时,把脚本放到FAT32的U盘根目录,进入Shell后直接执行BootCheck.nsh就能看到结果。
3.2 脚本B:批量加载驱动并逐个判断返回码
在系统装不进硬盘、找不到NVMe盘或者进入Windows后蓝屏的时候,经常需要手动加载一些.efi驱动。这个脚本可以把几个驱动文件自动load一遍,并逐个判断加载是否成功:
# LoadDrivers.nsh set drvdir fs0:\Drivers echo Driver directory: %drvdir% for %drv in (Nvme.efi UsbMass.efi Dump.efi) do echo Loading %drv ... load %drvdir%\%drv echo LastError = %lasterror% if %lasterror% == 0x0 then echo [OK] %drv loaded else echo [FAILED] %drv, check manually endif endfor脚本里定义了一个变量drvdir指向驱动目录,for循环依次尝试加载Nvme.efi、UsbMass.efi和Dump.efi,加载后立刻输出%lasterror%并判断是否为0x0。这里一定要先针对自己固件的返回码做一次实测,有的机器返回0,有的返回0x0,还有极少数固件对load失败并不更新lasterror,这时候就算用if判断也可能得到错误结论。
我一般会在load成功后加一行记录文件名的输出,把结果追加到日志:
%drv% >> load.log这种方式能方便后续查看哪些驱动加载成功、哪些失败。注意load命令加载的是efi驱动,不是应用程序,加载驱动后内存里会常驻,如果后续要做长时间测试,尽量只加载确实需要的驱动,避免内存被占用。
3.3 脚本C:用bcfg改启动项,不用进BIOS也能设第一启动
bcfg是UEFI Shell里最迷人的命令之一,它可以直接操作NVRAM里的启动项列表,修改、增加、删除启动项。你不需要图形化的BIOS界面,也能把rEFInd或者某个EFI工具设为第一启动。
先看怎么列出当前启动项:
bcfg boot dump -v这个命令会输出当前所有启动项序号、启动项路径和描述信息。假设你想新增一个rEFInd启动项并把它设为第一个启动项,脚本可以这样写:
# SetupRefind.nsh bcfg boot dump -v > boot_dump_before.txt bcfg boot add 0 fs0:\EFI\refind\refind_x64.efi "rEFInd Boot Manager" bcfg boot mv 0 0 bcfg boot dump -v > boot_dump_after.txt echo done. Compare boot_dump_before.txt and boot_dump_after.txtbcfg boot add命令的参数含义是:第一个数字0表示新启动项编号,第二个参数是efi文件路径,双引号里是启动项描述。bcfg boot mv 0 0表示把启动项0移动到顺序位置0,这样它就成了默认第一启动。如果加错了,可以用bcfg boot rm 0删除。
这里特别提醒,bcfg操作的是NVRAM,这是主板固件里最核心的持久化存储区域。改之前一定要先dump一份原始状态到文件,万一改坏了还能根据原始启动项顺序手动恢复。另外,不同主板的bcfg实现有细微差异,有的板子对“bcfg boot add”的启动项编号要求和顺序要求不一样,所以务必先在你自己机器上测试一遍再推广使用。
3.4 脚本编排的通用套路:先探测、再操作、留日志
在上面三个脚本里,有一个共同的编排思路:先探测环境,再执行修改,最后留日志。无论你写多复杂的UEFI Shell脚本,我都会建议你按照这个套路来。
第一步,先map -r刷新磁盘映射。因为很多机器在Shell启动时并没有完整扫描所有的USB设备、NVMe设备、SD卡,导致盘符不存在或者指向错误。map -r会强制重新检测所有块设备,让盘符稳定下来。
第二步,把关键运行信息输出到文件。比如map的结果、bcfg boot dump的结果、当前目录下的文件列表。这些信息既是排障依据,也是你操作前的备份。
第三步才执行真正的修改动作。修改前如果有条件,用pause命令让脚本停一下,给操作者一个确认机会:
echo About to modify boot options. Press any key to continue... pause第四步,把操作后的结果再次输出到文件。这样前后对比一目了然。严格按这个套路写出来的脚本,就算出了问题,你也能清晰地判断是环境探测阶段出错、修改命令出错还是后续验证出错。
4. 常见报错与排查技巧
4.1 “cannot find required map name”这类文件映射错误
如果你在Shell里运行某个.efi,或者访问fs0:、blk0:这些路径时,得到一个类似“cannot find required map name”或者“map not found”的报错,八成不是路径拼错了,而是盘符映射根本不存在。UEFI Shell的盘符不是固定的,它由Shell启动时扫描设备后动态分配,U盘插入顺序、硬盘数量变化都可能改变盘符号。
这种情况下,先执行map -r强制重新扫描所有文件系统,再执行map查看当前盘符映射表。如果map里只有blk开头的块设备,没有fs开头的文件系统,说明设备虽然被识别到了,但没有检测到可识别的文件系统。最常见的原因是U盘不是FAT32格式,或者ESP分区被误删、损坏。还有一种情况是你插的U盘分区表是MBR,但固件在纯UEFI模式下不识别MBR的移动设备,解决方法是把U盘改成分区表为GPT,且分区格式为FAT32。
4.2 脚本能打开但不执行 / 命令not found
脚本文件放在U盘里,但进入Shell后输入脚本名却提示“not found”,这通常分三种情况。第一种是脚本文件不在当前工作目录,你先用ls看看文件在不在,不在就cd到对应目录。第二种是文件编码不对,前面提到过UTF-16或带BOM的UTF-8会让Shell解析器直接把文件名读成乱码,或者文件内容解析失败,表现为执行时连命令都找不到。第三种是文件名输入大小写不一致,虽然FAT文件系统通常不区分大小写,但UEFI Shell的某些实现会对文件名比较严格,建议尽量用小写文件名。
还有一类情况是脚本本身能执行,但脚本里的命令not found。比如你用了bcfg命令,但固件里的Shell没有集成bcfg.efi,就会报命令不存在。这时要么换一个带更多工具的Shell.efi,要么把bcfg.efi放到可访问路径下。Shell内置命令数量在不同固件上是有差异的,不能假设每个命令都有。
4.3 比较和循环不按预期跑
比较操作最经典的问题就是空格。前面提过“if %var%==1 then”不会成立,因为Shell把“%var%==1”当成了一个整体字符串。还有一个问题是变量值本身带了空格,比如“set name ab cd”,然后“if %name% == ab cd then”,这时候条件必须写成带空格的完整匹配。这类问题排查起来非常费眼,建议在if之前先echo %var%确认变量值。
for循环里常见的坑是循环变量引用方式写错。在脚本里循环变量只写“%i”,不需要写“%%i”,也不需要再加结束符%。如果你习惯Bash写$i,那就更不对了,Shell会报“invalid command”。还有循环体里如果调用子脚本,子脚本里又有for循环,循环变量最好换一个字母,避免嵌套时变量名冲突。
4.4 别把PowerShell执行策略、Vim报错算到UEFI Shell头上
搜索UEFI Shell脚本语法时,很容易混入大量其他领域的搜索结果,比如“PowerShell禁止运行脚本”“Vim提示no write since last change”之类。这些跟UEFI Shell完全没关系。前者是Windows系统的执行策略,后者是Vim编辑器退出时的提示,遇到它们不要往UEFI Shell这个方向查。
真正要警惕的是把Linux Shell脚本语法直接搬到UEFI Shell里。比如$()命令替换、$?错误码、export环境变量、#!/bin/bash开头,这些在UEFI Shell里都不存在或含义完全不同。UEFI Shell里没有“子进程环境”的概念,环境变量改了就全局生效,也没有“source”这样的命令,脚本之间共享状态靠call和全局变量,理解不了这一点,写出来的脚本很容易出现你无法解释的诡异行为。
4.5 在真实硬件上测试的心得
不同主板、不同版本的Shell固件,行为真的可以差很远。同一段脚本,在一台Intel NUC上跑得顺顺利利,换到某台国产工控机上可能就在for循环里报语法错误。所以我有一个非常土但很实用的习惯:手头常备一个FAT32的U盘,装上通用的Shell.efi,里面放好常用工具,每次到陌生机器上先跑一遍简单脚本验证兼容性。
另外,bcfg命令在操作NVRAM时,对启动项编号和顺序的处理在不同固件里确实有差异,有些主板还会在修改后自动重置启动顺序。做这类操作前,我会在纸上或文本文件里记下原始启动项顺序,再动手。毕竟进入不了系统还可以进Shell修,但NVRAM彻底乱了,连Shell都可能进不去,那才是最头疼的。
5. 进阶玩法与收尾建议
5.1 用dmpstore读写UEFI变量,脚本之间传递状态
dmpstore是我个人非常喜欢的一条命令,它可以查看、导出、导入UEFI变量。UEFI变量和Shell环境变量不一样,Shell环境变量只存在于当前Shell会话,重启就没了;UEFI变量存在NVRAM里,重启后依然保留。这意味着你可以用dmpstore在两次脚本执行之间传递状态。
比如第一次执行脚本时写下一个标志位,重启后第二次执行的脚本读取这个标志位决定是否跳过某个步骤。这种机制在做跨启动的自动化部署时很有用,相当于给脚本加了一个持久化的记忆功能。
dmpstore -all可以列出所有UEFI变量,dmpstore MyVar -s bak.txt可以把指定变量导出到文件。但要严正提醒,UEFI变量区是固件的核心数据区,很多变量被固件锁定或者设了访问权限,乱写可能导致启动参数异常、安全启动失效等一堆麻烦。我的建议是:永远先-dump备份,永远只在确认安全的自定义变量上做写入实验。
5.2 把Linux/Bash习惯迁移过来的命令对照表
如果你熟悉Linux命令,可以参考下面这个对照表快速上手:
| 操作目的 | Linux/Bash | UEFI Shell |
|---|---|---|
| 列出文件 | ls -l | ls 或 ls -b |
| 切换目录 | cd /efi/boot | cd \EFI\BOOT |
| 查看文件内容 | cat file | type file |
| 修改变量值 | export VAR=value | set VAR value |
| 引用变量 | $VAR | %VAR% 或 ${VAR} |
| 查找字符串 | grep pattern file | 部分固件有grep,建议先输出到文件再分析 |
| 判断文件存在 | [ -f file ] | if exist file then |
| for循环 | for i in list; do ...; done | for %i in (list) do ... endfor |
| 延时 | sleep 1 | stall 1000000 (单位是微秒) |
| 退出Shell | exit | exit |
最大的区别是:UEFI Shell没有$()命令替换,没有$?错误码,没有环境变量导入导出这一套机制。写脚本时别带上Bash的肌肉记忆,不然真的会对着报错发呆半天。
5.3 我踩过几次坑之后留下的三点建议
最后聊点实在的经验。第一,所有会改NVRAM的命令,动手前一定先备份。我之前给人调引导,为了省事没做bcfg boot dump,结果启动项顺序改完,原来的Windows入口消失了,折腾了两个小时才用bcfg boot add手工加回来。备份只是一行命令的事,不要省。
第二,脚本越简单越好。UEFI Shell脚本语法本来就很“古早”,不要试图写那种几十行的复杂逻辑,遇到需求就拆成几个小.nsh脚本,用call串起来。每个脚本只干一件事,出问题了单独调试,整体维护成本低很多。
第三,花几分钟时间把常用工具放到U盘里。很多麻烦的根源就是Shell.efi里没有你想要的工具,比如bcfg、dmpstore、load,如果你U盘里有一个包含完整工具的Shell,这些问题当场就能消掉一大半。
我自己的体会是,UEFI Shell脚本就像是电脑启动之前的一道“暗门钥匙”,语法不算复杂,但每一个细节都有它存在的理由。你只要把变量、流程控制、返回码、文件路径这四件事彻底搞明白了,剩下的就是遇到问题多跑一个map、多留一份日志的事。写第一个.nsh脚本的时候,从上面的检查脚本开始改,比从头写要省力得多。