從 PowerShell 正確呼叫外部 exe ── 引數的引號、結束代碼、亂碼陷阱

· · PowerShell, Windows, 自動化, 腳本, 行程, 字元編碼, 維運改善, 批次處理

「在自己的命令提示字元裡明明可以執行,一旦搬到 PowerShell 指令碼裡,外部工具就馬上回傳錯誤」── 這是把批次檔轉換成 PowerShell,或是將公司內部 EXE、OSS 製 CLI 自動化時,幾乎一定會遇到的現象。原因大多不在邏輯本身,而在於引數送達程式為止的路徑上。路徑中包含空白、引數中含有雙引號、傳遞含有 %( ) 的字串 ── 只要觸發其中一項,原本要傳遞的引數就會變成別的樣子。

更麻煩的是,這個行為在 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.07.2 尚未套用第 3 章介紹的新方式。若環境中兩者並存,實際執行的主機是哪一個,行為就會不同。

1. 先講結論

  • PowerShell 對外部程式的引數也會先自行解析一次。命令呼叫之後的部分會以「引數模式(argument mode)」解析,含有空白的值必須用引號括起來。, ( ) { } | & < > @ # 等屬於中繼字元(meta character),若要以字面值傳遞,需用反引號逸出。1
  • PowerShell 7.3 變更了引數的傳遞方式(重大變更)。內嵌的引號與空字串引數現在會被保留,可以透過 $PSNativeCommandArgumentPassing 選擇行為。Windows 上的預設值是 Windows12
  • Windows 模式下,只有 cmd.exe、cscript.exe、wscript.exe 以及副檔名 .bat .cmd .js .vbs .wsf 會沿用舊有(Legacy)的傳遞方式。這是為了與舊有批次資產相容而設的例外。1
  • 停止剖析權杖 --% 是「把後面全部當成字面值處理」的最終手段。不過只有 %VAR% 形式的環境變數會被展開,PowerShell 變數完全無法使用,效果範圍只到換行或管線為止,也無法寫重新導向。1
  • 如果想確實傳遞變數的值,以陣列傳遞是首選。& $exe @argArray 這種展開(splatting)寫法會讓每個元素都成為獨立的引數。若使用 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,並以運算式模式(expression mode)引數模式(argument mode)其中之一來解讀。一旦出現命令呼叫,其後就會以引數模式解析。在引數模式下,輸入基本上會被當成「可展開的字串」處理:以 $ 開頭表示變數參照、引號表示字串的開始、( ) 表示運算式的開始……符號本身帶有語法上的意義1

也就是說,即使是直接貼到命令提示字元就能執行的字串,在 PowerShell 中也可能被解讀成別的東西。經典的例子就是 icacls1

# 若是 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(舊有)、StandardWindows 三種。Windows 平台的預設值是 Windows,非 Windows 平台則是 Standard12

WindowsStandard 的差異只有一點:在 Windows 模式下,以下的呼叫會自動套用 Legacy 方式。1

自動套用 Legacy 方式的呼叫
cmd.exe / cscript.exe / wscript.exe
副檔名為 .bat .cmd .js .vbs .wsf 的檔案

這是為了防止舊的批次檔或 WSH 指令碼「一升級到 PowerShell 7,接收引數的方式就變了而壞掉」的事故所設的例外。反過來說,如果把 $PSNativeCommandArgumentPassing 明確設定成 StandardLegacy,就不會再做這個判定。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

傳遞含有變數值的引數時,首選寫法是把每個引數各自放進陣列裡再展開(splatting)。由於陣列的每個元素都會當成獨立的引數傳遞,即使路徑中含有空白,也不需要自己動手加引號。

$exe  = 'C:\Program Files\MyTool\convert.exe'
$args = @(
    '--input',  'D:\訂單資料\2026年07月.csv'   # 即使含有空白或中文也沒問題
    '--output', 'D:\輸出\result.json'
    '--mode',   'strict'
)

& $exe @args                      # 陣列展開(splatting)。每個元素會變成 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"
}

重新導向輸出時最常見的事故就是死結(deadlock)。管線的緩衝區有上限,一旦填滿,子處理序在寫入時就會被阻塞。因此,不只是先呼叫 WaitForExit() 再讀取有問題,先用 ReadToEnd() 同步讀完其中一個串流、再讀另一個的寫法同樣危險(如果沒被讀取的那一側緩衝區先被填滿,子處理序就會停住,ReadToEnd() 也永遠不會返回)。

把停住的先後順序畫成一張圖,就是這樣。

親處理序用 StandardError.ReadToEnd等著讀完 stderr子處理序持續寫入 stdoutstdout 端的管線緩衝區已滿父處理序沒有讀取,無法清空子處理序在寫入時被阻塞無法結束,stderr 也不會關閉父處理序的 ReadToEnd 不會返回WaitForExit 也不會返回等於死結

圖 1:只同步讀完其中一個串流時導致停住的流程

請採用像上面程式碼一樣,先以非同步方式同時開始讀取兩者、再等待完成的設計,或是只重新導向其中一個。關於子處理序的整體處理方式,也請參閱「Windows 應用安全處理子行程的 checklist - Job Object、結束傳播、標準輸入輸出、watchdog 的最佳實務」。

在 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)。把結尾的 \ 加倍。如果只留一個,就會跟結尾引號黏在一起被讀成 \",引號無法正確關閉,還會連累到下一個引數
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)解碼,結果會如下所示。

原始字串 以 CP932 解碼 UTF-8 輸出的結果
こんにちは 縺薙s縺ォ縺。縺ッ
エラー 繧ィ繝ゥ繝シ
日本語 譌・譛ャ隱 + 無法解碼的位元組

平假名、片假名會亂成以 開頭的兩字組合,這是一個判斷特徵(因為 UTF-8 的平假名、片假名以 E3 81 E3 82 E3 83 開頭,而這開頭 2 個位元組在 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 的字元編碼與換行符 - Shift_JIS / UTF-8 / UTF-16、亂碼、CRLF / LF,為何混亂」。

7. 結束代碼與 stderr ── 避免「明明成功卻被當成失敗」

外部程式的成敗判斷要用 $LASTEXITCODE 進行。非零的結束代碼在預設情況下不會產生 ErrorRecord,也不會進入 try/catch。4 這個基本觀念已經在「PowerShell 的錯誤處理與重試設計」中詳細說明過,這裡只補充外部處理序特有的兩點。

(1) 輸出到 stderr 不代表「失敗」。許多 CLI 工具會把進度或記錄寫到 stderr。由於 PowerShell 會把原生命令的 stderr 輸出導入錯誤串流,畫面會變紅、看起來像「失敗了」,但只要結束代碼是 0 就是成功。在 Windows PowerShell 5.1 中,光是寫到 stderr 就可能讓 $? 變成 $false,但 PowerShell 7 已修正為只有在結束代碼非零時才會變成 $false6

(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 執行的範例。

下載範例程式碼(zip)

本文的範例已經實際在 PowerShell 7.6 上執行並驗證過(Pester 22 項)。執行 zip 中附帶的 Invoke-SampleTests.ps1,即可在您自己的環境重現同樣的驗證。

# 語法解析 + 靜態分析 + Pester 測試
./Invoke-SampleTests.ps1

設定值(路徑、伺服器名稱、租用戶 ID 等)僅為範例。請勿直接在正式環境中執行,請依照貴公司的環境自行調整。

相關文章

相關諮詢領域

合同會社小村軟體處理批次資產的 PowerShell 化、結合外部工具與公司內部 EXE 的自動化處理設計,以及「依環境而時好時壞」的指令碼原因調查。

參考連結

  1. 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

  2. Microsoft Learn, about_Preference_Variables. 關於 $PSNativeCommandArgumentPassing 是依平台而有不同預設值的偏好設定變數、$OutputEncoding 決定 PowerShell 傳送字串給其他應用程式時所用編碼的內容。  2 3 4 5

  3. Microsoft Learn, ProcessStartInfo.ArgumentList 屬性. 關於可以把引數以集合形式個別指定、所需的加引號與逸出由執行環境處理、反斜線會被當成逸出字元處理,以及適用對象是 .NET Core 2.1 以後(.NET Framework 中不存在),因此無法用於執行在 .NET Framework 上的 Windows PowerShell 5.1 的內容。另請一併參閱 Process.StandardOutput 屬性中,關於同時重新導向並同步讀取標準輸出與標準錯誤時可能發生的死結,以及其迴避方法(以非同步方式讀取其中一方)的注意事項。  2 3 4 5

  4. Microsoft Learn, about_Error_Handling. 關於原生命令的非零結束代碼會讓 $? 變成 $false 並存入 $LASTEXITCODE,但不會產生 ErrorRecord 也不會進入 try/catch 的內容。  2 3

  5. Microsoft Learn, Trace-Command. 關於以 -Name ParameterBinding 追蹤參數繫結、以 -PSHost 輸出至主機、以 -Expression 指定追蹤對象的內容。  2 3

  6. Microsoft Learn, Differences between Windows PowerShell 5.1 and PowerShell 7.x. 關於在 PowerShell 7 中,原生命令光是寫入 stderr 並不會讓 $? 變成 $false,已改為只有在結束代碼非零時才會變成 $false 的內容。  2

  7. Microsoft Learn, about_Redirection. 關於 PowerShell 輸出串流的編號體系、以 2>&1 把錯誤串流合流到成功串流、原生命令 stderr 輸出的處理方式的內容。  2

  8. Microsoft Learn, What’s New in PowerShell 7.4. 關於重新導向運算子改為以位元組串流保留原生命令的輸出,PowerShell 不再對內容進行解讀或格式化(重大變更),因此以 2>&1 合流的 stderr 會被當成字串資料處理的內容。  2

  9. Microsoft Learn, Start-Process. 關於預設不會等待新處理序完成、以 -Wait 等待、以 -PassThru 取得 Process 物件與 ExitCode、以 -RedirectStandardOutput / -RedirectStandardError 重新導向至檔案、以 -Verb RunAs 提升權限、以 -Credential 切換使用者執行的內容。 

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

常見問題

整理諮詢這個主題時常見的問題。

從 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` 屬性。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽