「在自己电脑的命令提示符里明明能正常运行,一移植到 PowerShell 脚本,外部工具立刻就报错」── 这是把批处理迁移到 PowerShell,或是自动化调用公司内部 EXE、OSS 命令行工具时几乎必然会遇到的现象。原因大多不在逻辑本身,而在于参数抵达程序为止所经过的路径。路径中包含空格、参数中含有双引号、要传递含有 % 或 ( ) 的字符串——只要触发其中之一,本该原样传递的参数就会变成别的样子。
更麻烦的是,这一行为在 PowerShell 7.3 中发生了变化。为了让脚本在 5.1 下能正常运行而写的规避方案,到了 7 就会变成双重转义;反过来在 7 下写好的脚本,放到 5.1 里又会出问题。在日语环境中,输出乱码的问题还会叠加进来。
本文将从规范出发,梳理从 PowerShell 调用外部程序(原生命令)时参数的传递方式,以及 --% 的正确使用场合、需要可靠传参时的 ProcessStartInfo、退出代码与 stderr 的处理,直至乱码的应对方法,均从实务角度加以整理。错误处理本身的设计已经在「PowerShell 的错误处理与重试设计」中讨论过,因此本文聚焦于「与外部进程之间的边界」。
先确认自己的运行环境
本文的主题就是版本差异,因此在开始阅读之前,请先确认自己身处哪一边。
$PSVersionTable.PSVersion
如果是 5.1.x,就是 Windows PowerShell 5.1;如果是 7.x,就是 PowerShell 7。7.3 是参数传递方式的分水岭,因此请注意 7.0〜7.2 尚未纳入第 3 章所说的新方式。如果环境中两者并存,行为会因当前运行的宿主而不同。
1. 先说结论
- PowerShell 也会先自行解析外部程序的参数。命令调用之后的部分会以「参数模式(argument mode)」进行解析,包含空格的值需要用引号括起来。
,(){}|&<>@#等都是元字符,如果要作为字面量传递,需要用反引号转义。1 - PowerShell 7.3 改变了参数的传递方式(破坏性变更)。嵌入的引号和空字符串参数得以保留,并且可以通过
$PSNativeCommandArgumentPassing选择行为模式。Windows 上的默认值是Windows。12 - 在
Windows模式下,只有 cmd.exe、cscript.exe、wscript.exe,以及扩展名为.bat.cmd.js.vbs.wsf的文件,才会沿用旧有的(Legacy)传递方式。这是为了与旧有批处理资产保持兼容而设的例外。1 - 停止解析令牌
--%是「让此后的内容整体按字面量处理」的最终手段。不过唯独%VAR%形式的环境变量仍会被展开,PowerShell 变量完全无法使用,效果范围只到换行符或管道为止,也无法写重定向。1 - 如果想可靠地传递变量的值,首选方案是用数组传递。
& $exe @argArray这种展开写法会让数组的每个元素都成为独立的参数。用ProcessStartInfo.ArgumentList则可以把引号的组装工作交给 .NET 处理,但这是 .NET Core 2.1 及以后的 API,5.1 中无法使用。13 - 不要把不可信的输入传给批处理文件。在 Windows 中,传给批处理的参数会作为原始命令行字符串传给 cmd.exe,官方也警告「不可信的输入应通过其他方式传递」。1
- 成败通过
$LASTEXITCODE判断。非零退出代码默认不会成为 PowerShell 的错误,也不会进入 try/catch。4 - 乱码问题要分别从发送方和接收方两侧来处理。解码外部命令输出的是
[Console]::OutputEncoding,而从 PowerShell 通过管道发送给外部命令的字符串则由$OutputEncoding负责。2 - 如果遇到困难,用
Trace-Command -Name ParameterBinding查看实际传递的参数。PowerShell 7.3 以后,原生命令的参数绑定也可以被追踪。15
2. 为什么参数会走样 ── 参数模式这一前提
PowerShell 会把命令行拆分成一个个 token,并按表达式模式或参数模式之一进行解释。一旦出现命令调用,此后的部分就会按参数模式解析。在参数模式下,输入基本上被当作「可展开的字符串」处理:以 $ 开头表示变量引用,引号表示字符串的开始,( ) 表示表达式的开始……诸如此类,符号具有语法层面的含义。1
也就是说,即便是原样粘贴到命令提示符里就能正常运行的字符串,在 PowerShell 中也可能被解释成完全不同的东西。icacls 就是一个经典的例子。1
# 用 cmd.exe 能正常运行,但在 PowerShell 2.0 时代,括号会被解释成表达式而报错
icacls X:\VMS /grant Dom\HVAdmin:(CI)(OI)F
# 用反引号转义元字符(不易阅读)
icacls X:\VMS /grant Dom\HVAdmin:`(CI`)`(OI`)F
# 用停止解析令牌声明「从这里开始都是字面量」(PowerShell 3.0 以后)
icacls X:\VMS --% /grant Dom\HVAdmin:(CI)(OI)F
另一个前提是,解析后的参数传递给程序所经过的路径。在 Windows PowerShell 5.1 中,解析完的参数会被重新组装成以空格分隔的一整条字符串,然后才传给进程。在这个「重新组装」的过程中,参数中原本包含的引号会丢失,空字符串参数也会消失 ── 这正是 5.1 时代的经典事故。1
3. PowerShell 7.3 的破坏性变更 ── $PSNativeCommandArgumentPassing
PowerShell 7.3 改变了这种组装方式。这正是官方文档中明确写着「相对 Windows PowerShell 5.1 行为的破坏性变更」的地方。1
新的行为可以通过 $PSNativeCommandArgumentPassing 这个环境设置变量切换,取值有 Legacy(旧方式)、Standard、Windows 三种。Windows 平台上的默认值是 Windows,非 Windows 平台则是 Standard。12
Windows 和 Standard 之间只有一点区别:在 Windows 模式下,以下调用会自动采用 Legacy 方式。1
| 自动采用 Legacy 方式的调用 |
|---|
cmd.exe / cscript.exe / wscript.exe |
扩展名为 .bat .cmd .js .vbs .wsf 的文件 |
这是为了防止旧的批处理或 WSH 脚本在「PowerShell 升级到 7 之后,参数接收方式突然改变而崩溃」这类事故而设的例外。反过来说,如果把 $PSNativeCommandArgumentPassing 显式设置为 Standard 或 Legacy,就不会再进行这项判断。1
新方式改善的是以下两点。1
下面例子中出现的 TestExe -echoargs,是一个只负责把接收到的参数逐个以 Arg 0 is <...> 形式打印出来的验证用工具。它包含在 PowerShell 本体的测试资产中,并不是 Windows 自带的命令。在本地重现同样效果的方法整理在第 8 章,阅读时不妨把它当作「能直接看到参数原样的窗口」。
# (1) 嵌入在字符串中的引号会被保留
$a = 'a" "b'
TestExe -echoargs $a 'c" "d' e" "f
# Arg 0 is <a" "b>
# Arg 1 is <c" "d>
# Arg 2 is <e f>
# (2) 空字符串参数不会消失,会被保留下来
TestExe -echoargs '' a b ''
# Arg 0 is <>
# Arg 1 is <a>
# Arg 2 is <b>
# Arg 3 is <>
如果想原样传递像 "C:\Program Files (x86)\Microsoft\" 这样带引号的路径字符串,在 Windows / Standard 模式下可以直接照写。1
# 7.3 以后(Windows / Standard 模式)
TestExe -echoargs '"C:\Program Files (x86)\Microsoft\"'
# 要在 Legacy 模式(相当于 5.1)下得到同样的结果,需要对引号做二重转义
TestExe -echoargs "\""C:\Program Files (x86)\Microsoft\\"""
这里有一点需要注意。反斜杠(\)并不是 PowerShell 的转义字符。上面例子中出现 \",是因为底层的 .NET API(ProcessStartInfo.ArgumentList)把反斜杠当作转义字符处理,而 PowerShell 语法层面的转义字符是反引号(`)。这两种转义规则混在一起,正是这个领域看起来复杂的最大原因。13
实务上的判断很简单。在 5.1 与 7 混用的环境中,不要把带引号的参数写成字面量。只要靠拢到后续章节介绍的「用数组传递」「使用 ProcessStartInfo」,就几乎不会受到版本差异的影响。关于 5.1 与 7 共存的方针本身,请参考「Windows PowerShell 5.1 与 PowerShell 7 的区别」。
4. --%(停止解析令牌)的正确使用场合与局限
--% 是一个让 PowerShell 不再解释其后的字符、而是原样传递下去的令牌(PowerShell 3.0 以后提供)。官方明确写道,「仅设想在 Windows 平台上用于原生命令」。1
PS> cmd /c echo "a|b"
'b' is not recognized as an internal or external command,
operable program or batch file.
PS> cmd /c --% echo "a|b"
"a|b"
虽然强大,但必须准确了解它的限制。1
| 限制 | 内容 |
|---|---|
| 只有环境变量会被展开 | 像 %USERPROFILE% 这样的 %<名称>% 必定会被展开。无法用 %% 转义。未定义的名称会原样通过 |
| PowerShell 变量无法使用 | $path 等不会被展开,而是作为字面字符串原样传递 |
| 生效范围 | 只到下一个换行符或管道符(|)为止。无法用反引号续行来延长,也不能用 ; 结束 |
| 无法重定向 | >file.txt 之类会作为参数原样传给目标命令 |
也就是说,--% 只能用于「要传递的内容完全是固定字符串,且不含 %」的场合。在脚本中拼装变量传递的典型自动化场景里,这个条件基本无法满足。「先加个 --% 再说」这种做法,一旦传递含 % 的密码或通配符,就会立刻崩溃。
5. 可靠地传递变量 ── 数组展开与 ProcessStartInfo
在传递包含变量值的参数时,首选方案是把每个参数逐个放入数组再展开的写法。数组的每个元素都会作为独立的参数传递,因此即便路径中含有空格,也不需要自己书写引号。
$exe = 'C:\Program Files\MyTool\convert.exe'
$args = @(
'--input', 'D:\订单数据\2026年07月.csv' # 即使包含空格或中文也没问题
'--output', 'D:\输出\result.json'
'--mode', 'strict'
)
& $exe @args # 数组展开。每个元素都会成为 1 个参数
if ($LASTEXITCODE -ne 0) { throw "转换失败 (ExitCode=$LASTEXITCODE)" }
调用运算符 & 在执行路径中含有空格的 exe 时也是必需的('C:\Program Files\...' 如果不加 &,只会被当作字符串字面量求值,并不会被执行)。
如果不确定参数是否传递成功,请在凭猜测增加转义之前,先用第 8 章介绍的方法确认实际传递的参数。对比修改写法前后传递到的参数是否一致,是最快的确认方式。
当需要更进一步的可靠性时 ── 想要逐个完全控制参数、想按进程单独指定输出的文字编码 ── 就直接使用 .NET 的 ProcessStartInfo。添加到 ArgumentList 中的值会由 .NET 一侧妥善加上引号,因此既不受 PowerShell 解析的影响,也不受命令行再解析的影响。3
不过 ArgumentList 是 .NET Core 2.1 及以后的 API,运行在 .NET Framework 上的 Windows PowerShell 5.1 的 ProcessStartInfo 中并不存在这个属性。3 在 5.1 中,请自行组装下一段介绍的 Arguments 字符串,或者使用前面提到的数组展开。
# 【PowerShell 7 以后】把参数的组装工作交给 .NET
$psi = [System.Diagnostics.ProcessStartInfo]::new()
$psi.FileName = 'C:\Program Files\MyTool\convert.exe'
foreach ($a in '--input', $inputPath, '--output', $outputPath) {
$psi.ArgumentList.Add($a) # 1 个元素 = 1 个参数。不需要自己加引号(仅限 7)
}
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.UseShellExecute = $false
# 可以显式指定输出的文字编码,这也是 ProcessStartInfo 的优点之一(第 6 章)
$psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8
$psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8
$proc = [System.Diagnostics.Process]::Start($psi)
# 标准输出和标准错误要「同时」读取。如果先同步读完一侧,
# 再去读另一侧,在等待期间另一侧的管道缓冲区会被填满,
# 导致子进程在写入时阻塞,从而造成死锁
$stdoutTask = $proc.StandardOutput.ReadToEndAsync()
$stderrTask = $proc.StandardError.ReadToEndAsync()
$proc.WaitForExit()
$stdout = $stdoutTask.GetAwaiter().GetResult()
$stderr = $stderrTask.GetAwaiter().GetResult()
if ($proc.ExitCode -ne 0) {
throw "转换失败 (ExitCode=$($proc.ExitCode)): $stderr"
}
在重定向输出时最常见的事故就是死锁。管道缓冲区的容量是有上限的,一旦被填满,子进程就会在写入时被阻塞。因此,先调用 WaitForExit() 再读取自不必说,先用 ReadToEnd() 同步读完一侧的流、再读另一侧的写法同样危险(如果未被读取的一侧缓冲区先被填满,子进程就会停住,ReadToEnd() 将永远不会返回)。
把停滞发生的顺序画成一张图,就是下面这样。
flowchart TD
A["父进程<br/>在 StandardError.ReadToEnd 中<br/>等待读完 stderr"]
B["子进程<br/>持续向 stdout 写入"]
C["stdout 侧的管道缓冲区已满<br/>父进程未读取,无法腾出空间"]
D["子进程在写入时阻塞<br/>无法结束,stderr 也不会关闭"]
E["父进程的 ReadToEnd 无法返回"]
F["WaitForExit 也无法返回<br/>= 死锁"]
B --> C --> D --> E
A --> E --> F
图 1:只同步读完一侧的流时导致停滞的流程
请像上面的代码那样,先以异步方式同时开始读取两侧再等待,或者设计成只重定向其中一侧。关于子进程处理的整体内容,也请参考「Windows 应用安全处理子进程的清单」。
在 Windows PowerShell 5.1 中使用 ProcessStartInfo 时,需要向 Arguments 传递一整条自行加好引号的字符串。手写这种组装逻辑很容易出事故,因此在 5.1 中请优先选用数组展开(& $exe @args)。
# 【5.1】没有 ArgumentList,因此需要自己组装含引号的一整条字符串
$quote = {
param([string] $s)
if ($s -eq '') { return '""' } # 空字符串必须变成 "" ,否则整个参数都会消失
if ($s -notmatch '[\s"]') { return $s } # 不需要包裹时原样返回
# 按照 Windows 的命令行解析规则处理:
# (1) 把紧邻 " 前面的反斜杠序列翻倍,并把 " 本身变成 \"
# (2) 末尾的反斜杠序列也要翻倍。因为它紧邻闭合引号,
# 不这样处理就会被解释成 \" ,导致引号无法闭合,进而破坏后续参数
# (例如: 'C:\Program Files\input\' → "C:\Program Files\input\\")
$e = $s -replace '(\\*)"', '$1$1\"'
$e = $e -replace '(\\+)$', '$1$1'
'"' + $e + '"'
}
$psi.Arguments = (@('--input', $inputPath, '--output', $outputPath) |
ForEach-Object { & $quote $_ }) -join ' '
只看正则表达式很难理解它在做什么,这里把输入与输出的对应关系列出来。只要理解了这 6 行,就不需要死记规则本身。
| 想要传递的值(变量的内容) | $quote 返回的字符串 |
生效的规则 |
|---|---|---|
strict |
strict |
既没有空格也没有引号,因此原样返回,不加包裹 |
| 空字符串 | "" |
什么都不写的话整个参数都会消失,因此放置空引号 |
D:\订单数据\2026年07月.csv |
D:\订单数据\2026年07月.csv |
即使含有中文,只要没有空格就不加包裹 |
C:\Program Files\input |
"C:\Program Files\input" |
因为含有空格,只需用引号把整体包起来 |
C:\Program Files\input\ |
"C:\Program Files\input\\" |
规则 (2)。把末尾的 \ 翻倍。如果保持 1 个,就会和闭合引号连在一起被解释成 \",导致引号无法闭合,并牵连到下一个参数 |
say "hi" |
"say \"hi\"" |
规则 (1)。把值中的 " 变成 \",使其作为字符而不是分隔符传递 |
a\"b |
"a\\\"b" |
规则 (1) 的完整过程。先把紧邻 " 前面的 \ 序列翻倍,然后再把 " 变成 \" |
最后一行正是这个函数看起来复杂的原因所在。反斜杠只有在「下一个字符是 " 时」才会作为转义字符起作用,这正是为了配合 Windows 命令行解析规则中的这种非对称性而设计的。
正是这种规则的繁琐,构成了在 5.1 中应避免手工组装的理由。不过,在 Windows PowerShell 5.1 中,数组展开也并非万能。因为传入的值最终还是会按照旧方式重新组装成命令行字符串,空字符串参数会消失,含引号的值也会走样。1 因此在 5.1 中,请按照下面的方式区分使用。
| 5.1 中要传递的参数 | 方法 |
|---|---|
| 含空格或中文的普通值 | 数组展开(& $exe @args)已经足够 |
| 空字符串、含引号的值 | ProcessStartInfo + 上述转义处理,或 --%(仅限固定字符串) |
在 PowerShell 7 中,这两个问题都已经解决,因此不再需要这种区分使用。
此外,官方文档还警告不要把不可信的输入传给批处理文件。因为传给批处理的参数会作为原始命令行字符串传给 cmd.exe。1 把用户输入或文件名拼接后传给批处理的设计,会成为命令注入的温床。请通过临时文件或环境变量来传递这些值,或者干脆把批处理迁移到 PowerShell(「这个批处理文件,应该迁移到 PowerShell 吗?」)。
6. 修复乱码 ── [Console]::OutputEncoding 与 $OutputEncoding
在日语环境中必定会遇到的问题就是乱码。要点在于,方向不同,所使用的设置也不同。
| 方向 | 使用的设置 | 症状 |
|---|---|---|
| PowerShell 接收外部命令的输出 | [Console]::OutputEncoding |
UTF-8 输出的工具的结果会像「譁�喧縺�」一样变成乱码 |
| 从 PowerShell 通过管道向外部命令发送字符串 | $OutputEncoding |
发送的日语文本会在对方那一侧变成乱码 |
把乱码后的结果和原始字符串并排放在一起看,一下子就容易理解多了。把用 UTF-8 输出的日语按 CP932(Shift_JIS)解码,会变成下面这样。
| 原本的字符串 | 把 UTF-8 输出按 CP932 解码后的结果 |
|---|---|
こんにちは |
縺薙s縺ォ縺。縺ッ |
エラー |
繧ィ繝ゥ繝シ |
日本語 |
譌・譛ャ隱 + 无法解码的字节 |
平假名、片假名会变成以 縺 繧 繝 开头的两字符组合,这是一个明显的标志(因为 UTF-8 的平假名、片假名以 E3 81 E3 82 E3 83 开头,而这开头的两个字节在 CP932 中恰好对应这些字符)。像汉字这样混入了无法解码的字节时,就会出现替换字符或字符丢失,导致长度也对不上,就像上表第 3 行那样。
$OutputEncoding 是决定「PowerShell 向原生命令发送字符串时所使用的编码」的环境设置变量。2 另一方面,把外部命令输出的字节序列解码为字符串,则是 [Console]::OutputEncoding 的职责。只改了其中一个却「仍然乱码」的情况,大多是把这两者混淆了。
# 从 Windows PowerShell 5.1 调用以 UTF-8 输出的外部工具时的常规应对方法
$prevOut = [Console]::OutputEncoding
$prevPs = $OutputEncoding
try {
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) # 不带 BOM 的 UTF-8
$OutputEncoding = [System.Text.UTF8Encoding]::new($false)
$result = & $exe --list
}
finally {
# 会影响整个会话,因此务必改回原值
[Console]::OutputEncoding = $prevOut
$OutputEncoding = $prevPs
}
如果使用 ProcessStartInfo,就像前一章所说,可以按进程单独指定 StandardOutputEncoding / StandardErrorEncoding,因此不需要触碰会话级别的设置。副作用较小的正是这种方式。关于 Windows 整体的文字编码情况,已整理在「Windows 的文字编码与换行符」中。
7. 退出代码与 stderr ── 防止「明明成功却被当作失败」
外部程序的成败判断通过 $LASTEXITCODE 进行。非零退出代码默认不会生成 ErrorRecord,也不会进入 try/catch。4 这一基础内容已经在「PowerShell 的错误处理与重试设计」中详细介绍过,这里只补充两点外部进程特有的内容。
(1) 输出到 stderr 并不等于「失败」。许多 CLI 工具会把进度或日志写到 stderr。PowerShell 会把原生命令的 stderr 输出导入错误流,因此屏幕会变红、看起来像是「失败了」,但只要退出代码是 0 就算成功。在 Windows PowerShell 5.1 中,仅仅因为写入了 stderr,$? 就可能变成 $false;而在 PowerShell 7 中已经修正为只有在非零退出代码时才会变成 $false。6
(2) 用 2>&1 合并之后的数据类型因版本而异。在 Windows PowerShell 5.1 中,stderr 的每一行都会作为 ErrorRecord 混入其中,但在 PowerShell 7.4 及以后,原生命令的重定向输出会被当作字节流处理,合并之后就变成了字符串数据。78 也就是说,「按是否为 ErrorRecord 来区分」这种写法在 7.4 及以后不再适用。如果两种输出都需要,不合并、分别接收才是可靠的做法。
# 【推荐】分别接收 stdout 和 stderr(不受版本差异影响)
$errFile = [System.IO.Path]::GetTempFileName()
try {
$stdout = & $exe --import $csvPath 2> $errFile
$stderr = Get-Content -Path $errFile -Raw
# 日志中两者都要保留
$stdout | Add-Content -Path $logPath -Encoding utf8
if ($stderr) { $stderr | Add-Content -Path $logPath -Encoding utf8 }
if ($LASTEXITCODE -ne 0) {
throw "导入失败 (ExitCode=$LASTEXITCODE): $stderr"
}
# 能执行到这里就说明成功了。即使 stderr 有输出,也不当作失败处理
}
finally {
Remove-Item $errFile -ErrorAction SilentlyContinue
}
# 【不需要区分时】如果只是想把全部内容一并落到日志中,合并即可
(& $exe --import $csvPath 2>&1) | ForEach-Object { $_.ToString() } |
Add-Content -Path $logPath -Encoding utf8
8. 确认实际传递了什么
凭猜测增加转义只会让情况变得更糟。查看实际传递的参数才是最快的途径。PowerShell 7.3 以后,可以用 Trace-Command 追踪原生命令的参数绑定。15
Trace-Command -Name ParameterBinding -PSHost -Expression {
& $exe --input 'D:\订单数据\2026年07月.csv' --mode strict
}
# DEBUG: ... BIND cmd line arg [--input] to position [0]
# DEBUG: ... BIND cmd line arg [D:\订单数据\2026年07月.csv] to position [1]
如果调用目标是自己开发的工具,只要提前准备一个把收到的 args 原样输出的验证模式,这类调查就能瞬间完成(这和 PowerShell 测试工具组中的 TestExe -echoargs 是同样的思路)。1 如果想在现有的 5.1 环境中做同样的事,只需准备一个仅仅枚举 $args 的小 .ps1,或者一个只是原样显示参数的小 EXE 就足够了。
9. 实务定式(判断表)
| 情况 | 选项 | 判断依据 |
|---|---|---|
固定字符串参数(不含 %) |
--% / 常规调用 |
若转义太繁琐,--% 最快捷。但无法使用变量1 |
| 传递变量的值 | 数组展开 & $exe @args |
首选方案。即使含空格、中文、符号,也不需要自己写引号 |
| 想让 5.1 和 7 使用同一种写法 | 数组展开 | 写法通用,但在 5.1 中空字符串或含引号的参数会损坏(见下方注记)1 |
| 在 5.1 中传递空字符串・含引号的参数 | ProcessStartInfo + 自行转义 | 不经过 5.1 的命令行重建过程,因此可靠(第 5 章) |
| 想逐个完全控制参数(仅限 7) | ProcessStartInfo + ArgumentList |
可以把引号的组装工作交给 .NET。.NET Core 2.1 及以后的 API3 |
| 需要另开窗口・切换用户・提升权限 | Start-Process | 用 -Wait -PassThru 获取 ExitCode。输出重定向到文件9 |
| 向批处理(.bat)传值 | 通过环境变量・临时文件 | 不要通过参数传递不可信的输入(官方警告)1 |
| 输出出现乱码 | [Console]::OutputEncoding(接收)/ $OutputEncoding(发送) |
方向不同设置也不同。按进程单独设置可用 StandardOutputEncoding2 |
| 成败判断 | $LASTEXITCODE |
非零值默认不会进入 catch。stderr 有输出不代表失败46 |
| 想区分 stdout 与 stderr | 分别重定向接收 | 2>&1 合并后的类型在 5.1 与 7.4 及以后不同78 |
| 不知道实际传递了什么 | Trace-Command -Name ParameterBinding |
在凭猜测增加转义之前先实测5 |
10. 总结
- PowerShell 也会以参数模式解析外部程序的参数。由于符号具有语法层面的含义,在 cmd.exe 中能运行的字符串未必能原样通过。
- PowerShell 7.3 改变了参数的传递方式(破坏性变更),嵌入的引号和空字符串得以保留。Windows 上的默认值是
Windows模式,只有 cmd.exe 和批处理等才会采用 Legacy 方式。 --%是仅限固定字符串使用的最终手段。使用时请理解它的限制:%VAR%一定会被展开、无法使用 PowerShell 变量、效果范围只到换行符或管道为止。- 传递变量时首选数组展开。如果是 PowerShell 7,还可以使用 ProcessStartInfo 的
ArgumentList(5.1 中不存在)。反斜杠不是 PowerShell 的转义字符,这一点正是造成混乱的根源。 - 同时重定向标准输出和标准错误时,务必同时读取两者。只同步读完其中一侧的写法会导致死锁。
- 乱码的处理方式因方向而异。接收用
[Console]::OutputEncoding,发送用$OutputEncoding,按进程单独设置则用StandardOutputEncoding。 - 成败以
$LASTEXITCODE为准。stderr 有输出并不代表失败。如果想区分 stdout 与 stderr,请不要用2>&1合并,而是分别接收(合并后的类型在 5.1 与 7.4 及以后并不相同)。
示例代码下载
本文中涉及的代码已整理成可直接运行的形式发布。其中包含参数引号处理模块,以及基于 ProcessStartInfo 的执行示例。
本文的示例已经在 PowerShell 7.6 中实际运行并验证过(Pester 22 项)。运行 zip 中包含的 Invoke-SampleTests.ps1,您也可以在自己的环境中重现同样的验证。
# 语法解析 + 静态分析 + Pester 测试
./Invoke-SampleTests.ps1
配置值(路径、服务器名、租户 ID 等)均为示例。请勿直接在生产环境中运行,请根据贵公司的实际环境自行替换。
相关文章
- PowerShell 的错误处理与重试设计 ── 从 try/catch 失效的陷阱到 exit code、重试的实务定式
- Windows PowerShell 5.1 与 PowerShell 7 的区别 ── 企业内部脚本迁移实务指南
- 这个批处理文件,应该迁移到 PowerShell 吗?── cmd/bat 资产盘点与迁移判断
- Windows 应用安全处理子进程的清单 - Job Object、退出传播、标准输入输出、watchdog 最佳实践
- Windows 的文字编码与换行符 - 乱码与 CRLF/LF 的基础
- PowerShell 脚本的参数设计与模块化 ── 从「能运行的脚本」到「可以交给别人的脚本」
相关咨询领域
合同会社小村软件承接批处理资产的 PowerShell 化、结合外部工具与公司内部 EXE 的自动化处理设计,以及「因环境不同而时而能跑、时而不能跑」的脚本原因排查。
参考链接
-
Microsoft Learn,about_Parsing。关于表达式模式与参数模式的区分、参数模式下的元字符、用反引号进行转义、传给原生命令的参数在解析后会被合并成以空格分隔的一整条字符串、PowerShell 3.0 以后提供的停止解析令牌
--%的规格(仅展开环境变量、无法用%%转义、效果范围只到换行符或管道为止、无法重定向)、PowerShell 7.3 中原生命令的命令行解析发生的破坏性变更、$PSNativeCommandArgumentPassing的取值(Legacy/Standard/Windows)及其在 Windows 上的默认值、在 Windows 模式下 cmd.exe・cscript.exe・wscript.exe 以及 .bat/.cmd/.js/.vbs/.wsf 会采用 Legacy 方式、反斜杠不是 PowerShell 的转义字符、警告不要把不可信的输入传给批处理文件、以及 7.3 起可以追踪原生命令参数绑定等内容。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 ↩16 ↩17 ↩18 ↩19 ↩20 ↩21 ↩22 ↩23 ↩24 ↩25 ↩26 -
Microsoft Learn,about_Preference_Variables。关于
$PSNativeCommandArgumentPassing是一个具有平台相关默认值的环境设置变量、$OutputEncoding决定从 PowerShell 向其他应用程序发送字符串时所使用的编码等内容。 ↩ ↩2 ↩3 ↩4 ↩5 -
Microsoft Learn,ProcessStartInfo.ArgumentList 属性。关于可以把参数作为集合逐个指定、由运行时负责所需的引号添加与转义、反斜杠被当作转义字符处理,以及该属性适用于 .NET Core 2.1 及以后(.NET Framework 中不存在),因此在运行于 .NET Framework 上的 Windows PowerShell 5.1 中无法使用等内容。同时也参考了Process.StandardOutput 属性中,关于同时重定向标准输出和标准错误并同步读取时可能发生的死锁,以及避免方法(其中一侧改为异步读取)的相关说明。 ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn,about_Error_Handling。关于原生命令的非零退出代码会使
$?变为$false并存入$LASTEXITCODE,但不会生成 ErrorRecord、也不会进入 try/catch 等内容。 ↩ ↩2 ↩3 -
Microsoft Learn,Trace-Command。关于通过
-Name ParameterBinding追踪参数绑定、通过-PSHost输出到宿主、通过-Expression指定追踪对象等内容。 ↩ ↩2 ↩3 -
Microsoft Learn,Differences between Windows PowerShell 5.1 and PowerShell 7.x。关于 PowerShell 7 中,原生命令仅向 stderr 写入内容并不会使
$?变为$false,仅在非零退出代码时才会变为$false这一改动。 ↩ ↩2 -
Microsoft Learn,about_Redirection。关于 PowerShell 输出流的编号体系、
2>&1把错误流合并到成功流、原生命令 stderr 输出的处理方式等内容。 ↩ ↩2 -
Microsoft Learn,What’s New in PowerShell 7.4。关于重定向运算符开始把原生命令的输出保留为字节流、PowerShell 不再对内容进行解释或格式化(破坏性变更),以及由此导致用 2>&1 合并后的 stderr 被当作字符串数据处理等内容。 ↩ ↩2
-
Microsoft Learn,Start-Process。关于默认不等待新进程完成、通过
-Wait进行等待、通过-PassThru获取 Process 对象与ExitCode、通过-RedirectStandardOutput/-RedirectStandardError重定向到文件、通过-Verb RunAs提升权限、通过-Credential以其他用户身份运行等内容。 ↩
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
PowerShell脚本运行慢时该看哪里 ── 数组・管道・匹配的诀窍
整理 PowerShell 脚本变慢的常见原因。从数组 += 导致 O(n^2) 的原理、管道与 foreach 的差异、匹配的哈希表化、文件 I/O 的改善,到正确的测量方法,从实务角度进行讲解。
不再使用 Write-Host ── PowerShell 的输出流与日志设计
本文整理 PowerShell 六个输出流的用法区分、Write-Host 存在的问题与正确的使用场景、函数返回值被污染的原因、通过 -Verbose 与 -InformationVariable 实现调用方控制,以及结构化日志的留存方法。
PowerShell 的并行处理 ── ForEach-Object -Parallel 与作业(Job)的选用之道
本文从实务角度整理 ForEach-Object -Parallel、Start-ThreadJob、Start-Job 的区别与选用之道,$using: 与线程安全性,ThrottleLimit 的确定方法,以及反而会变慢的情形。
PowerShell 中凭据的安全处理方式 ── 把明文密码逐出脚本
本文整理将 PowerShell 脚本中的明文密码迁移到安全存储的步骤。讲解 SecureString 的真实面貌与局限、Export-Clixml 基于 DPAPI 保存的原理,直至 SecretManagement/SecretStore 的适用场景。
PowerShell 脚本的参数设计与模块化 ── 从「能运行的脚本」到「可以交给别人的脚本」
本文整理把 PowerShell 脚本提升到可以交给他人使用的品质的具体步骤,讲解 param 块与 [CmdletBinding()]、输入验证、管道输入、-WhatIf 支持、.psm1 模块化,直至内部共享与 Git 管理的要点。
相关主题
与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。
Windows 技术主题
汇整 KomuraSoft LLC 关于 Windows 开发、故障调查与既有资产活用文章的主题中心。
常见问题
汇总了咨询这一主题时常见的问题。
- 从 PowerShell 调用 exe 时,参数中的双引号会消失,这是为什么?
- 这是因为 PowerShell 会先解析参数,然后再传给外部程序的机制所致。在 Windows PowerShell 5.1 中,解析后的参数会被重新组装成以空格分隔的一整条字符串,这个过程会导致嵌入的引号丢失,或者空字符串参数被消掉。PowerShell 7.3 改变了这一行为,使嵌入的引号和空字符串参数得以保留。需要注意的是,在 5.1 中即使用数组展开(& $exe @args)传参,也同样会经过这次重新组装。如果需要确保带引号的值或空字符串能够被可靠地传递,数组展开并不能解决问题。若参数是固定字符串,可以使用停止解析令牌 --%,但只要包含变量就无法使用。真正可靠的做法是按照 Windows 的命令行规则自行加上引号,作为 ProcessStartInfo 的 Arguments 字符串传递(ArgumentList 是 .NET Core 2.1 及以后的 API,5.1 中无法使用)。正文中给出了这种实现的示例。
- 使用 --%(停止解析令牌)就能安全地传递任何参数吗?
- 不能,它有不少限制,并非万能。--% 之后的内容会被当作字面量处理,但唯独 %USERPROFILE% 这类环境变量引用仍会被展开,因此包含 % 的字符串会被意外替换(也无法用 %% 进行转义)。此外 PowerShell 变量完全无法展开,效果范围只到下一个换行符或管道符为止,也无法写重定向。一旦需要传递变量的值,就已经不能使用 --% 了,这种情况请考虑使用 ProcessStartInfo 或 Start-Process。
- 可以把从外部接收到的字符串传给批处理文件(.bat)吗?
- 请避免把不可信的输入传给批处理文件。在 Windows 中,传给批处理文件的参数会作为原始命令行字符串传给 cmd.exe,官方文档也明确写明「不可信的输入应通过其他方式传递」。如果直接把文件名或用户输入拼接进去,就会留下命令注入的余地。安全的做法是通过临时文件或环境变量来传递这些值,或者把批处理替换成 PowerShell 脚本。
- 外部命令的输出出现了乱码,应该修改哪里?
- 把 PowerShell 用来解码外部命令标准输出的 [Console]::OutputEncoding,调整为该命令实际输出所使用的编码。如果工具以 UTF-8 输出,就在调用前设置 [Console]::OutputEncoding = [System.Text.Encoding]::UTF8。反过来,从 PowerShell 通过管道向外部命令发送字符串时使用的是 $OutputEncoding,因此把「发送方」和「接收方」这两者分开考虑是关键所在。这些设置是按会话生效的,所以如果在脚本中临时修改过,请务必改回原值。
- Start-Process 和直接调用(&)该如何区分使用?
- 在想通过管道接收输出、或只需要查看退出代码这类常规场景下,基本应使用直接调用(& 或简单的命令名)。Start-Process 则适用于希望控制「启动方式」本身的场景,例如想在另一个窗口中启动、以其他用户身份运行、提升为管理员权限(-Verb RunAs)、把标准输出重定向到文件等。不过 Start-Process 默认不会等待进程结束,如果需要退出代码,就必须同时使用 -Wait 和 -PassThru,再查看 ExitCode 属性。