為什麼 Inno Setup 安裝程式的附加工作頁面一片空白?深入追查 Windows Global Atom Table 耗盡問題

最近我發佈了 Carnac v2.6.0,下載安裝檔 Carnac-2.6.0-Setup.exe 準備將本機的 Carnac 2.5.0 升級上去。沒想到執行安裝程式之後,畫面直接跳到「Select Additional Tasks」頁面,中間原本應該顯示「建立桌面捷徑」與「開機自動啟動」核取方塊的區域卻一片空白,左上角只留下一個細小的虛線焦點框。

第一時間我以為是 Inno Setup 腳本 Carnac.iss 的 [Tasks] 區段寫錯,或是高 DPI 縮放導致控制項座標跑掉。但實際透過 Win32 API 檢查執行中的安裝視窗,卻發現一個非常詭異的現象:清單控制項 TNewCheckListBox 不但存在、位置正確,裡面也確實載入了 4 個項目;唯獨負責計算高度的 WM_MEASUREITEM 與負責繪製畫面的 WM_DRAWITEM 訊息,完全沒有送到控制項手上。

順著 Win32 訊息分派一路追進 Delphi VCL 的 FindControl 原始碼,最後終於揪出真正的元兇:Windows 的 Global Atom Table 已經耗盡(GlobalAddAtomW 回傳 ERROR_NOT_ENOUGH_MEMORY),而且 Delphi VCL 在處理 Atom 註冊失敗時有一個 0 = 0 的邏輯漏洞,導致備援查詢機制完全失效。這篇文章就來完整記錄這次從表面 UI 異常一路挖到 Windows 核心資源洩漏的除錯過程,並分享兩個用來診斷與清理孤兒 Global Atoms 的 PowerShell 腳本。

Inno Setup 安裝程式因 Windows Global Atom Table 耗盡導致核取方塊空白,透過放大鏡診斷記憶體表格並以 PowerShell 清理孤兒 Atom 恢復正常顯示的示意圖

本文的操作與除錯環境為 Windows 11、Inno Setup 6.7.1、Carnac 2.6.0 與 Windows PowerShell 5.1 / PowerShell 7。

問題現象:安裝程式的附加工作頁面一片空白

執行 Carnac-2.6.0-Setup.exe 時,安裝精靈第一個出現的畫面長這樣:

Carnac 2.6.0 安裝程式的 Select Additional Tasks 頁面中間區域一片空白,僅在左上角顯示一個細小的虛線焦點框

看到這個畫面,通常會先冒出兩個疑問:

  1. 為什麼安裝程式沒有詢問安裝路徑,一打開就停在 Select Additional Tasks?
  2. 為什麼這個頁面中間一個選項都沒有?

第一個疑問很容易從 Inno Setup 的設定檔 installer/Carnac.iss 找到答案。在 [Setup] 區段中,腳本設定了 DisableProgramGroupPage=yes,而 Inno Setup 6 預設的行為則是:

  • DisableWelcomePage=yes:略過歡迎頁面。
  • DisableDirPage=auto:若系統已經安裝過相同 AppId 的舊版程式,升級安裝時會直接沿用先前的安裝目錄,並自動略過選擇安裝目錄的頁面。

因為我的電腦先前已經裝過 Carnac 2.5.0(登錄檔 HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\{DB5C24A2-1558-4272-8C3C-676D68A1E405}_is1 有紀錄),安裝程式便依序跳過了歡迎頁、安裝目錄頁與開始功能表資料夾頁,直接停在 wpSelectTasks(Select Additional Tasks)。

但第二個疑問就不合理了。檢視 Carnac.iss 的 [Tasks] 區段,裡面明明定義了兩個附加工作:

[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
Name: "startup"; Description: "Start {#MyAppName} when I sign in to Windows"; GroupDescription: "Startup:"

照理說,畫面上應該要出現兩個群組標題與兩個核取方塊。為什麼整個區域會變成空白?

第一階段排查:控制項與項目其實都在記憶體裡

為了確認到底是 Inno Setup 沒有建立控制項,還是控制項被隱藏或繪製失敗,我在安裝視窗開著的狀態下,用 PowerShell 呼叫 Win32 API 列舉 Setup - Carnac 2.6.0(類別名稱 TWizardForm)底下的所有子視窗,並對清單控制項發送 Win32 LISTBOX 訊息。

檢查結果出乎意料:

檢查項目 實際回傳值 說明
視窗類別名稱 TNewCheckListBox Inno Setup 用來呈現核取方塊清單的自訂控制項
IsWindowVisible True 控制項處於顯示狀態,並未被隱藏
視窗座標範圍 (60, 195) 至 (682, 416) 寬 622、高 221,正好位於對話方塊中央區域
Window Style 0x50010152 包含 WS_CHILD | WS_VISIBLE | WS_TABSTOP | LBS_NOTIFY | LBS_OWNERDRAWVARIABLE | LBS_HASSTRINGS | LBS_NOINTEGRALHEIGHT
LB_GETCOUNT 4 清單裡面確實有 4 個項目
LB_GETITEMHEIGHT 全部皆為 20 在 150% DPI 下,Inno Setup 正確計算出的 MinItemHeight 應該是 37

透過記憶體讀取清單內部的字串,4 個項目完好無缺:

  • 項目 0:Additional shortcuts:
  • 項目 1:Create a &desktop shortcut
  • 項目 2:Startup:
  • 項目 3:Start Carnac when I sign in to Windows

而且當我在視窗上按方向鍵 Down 時,LB_GETCURSEL 的選取索引會從 1 變成 3(自動跳過索引 0 與索引 2 這兩個群組標題),畫面上的虛線焦點框也會跟著往下移動。換句話說,這個清單控制項的功能完全正常,按 Next 甚至能正常安裝,只是畫面上完全畫不出任何文字與核取方塊。

為什麼 TNewCheckListBox 的項目高度會維持在 Win32 預設的 20 像素,而且只畫出系統預設的虛線焦點框?關鍵就在它的 Window Style 帶有 LBS_OWNERDRAWVARIABLE:這是一個由應用程式自行負責測量高度與繪製內容的自繪控制項(Owner-Draw Control)。

第二階段追查:Delphi VCL 的 FindControl 為什麼找不到控制項

Inno Setup 是使用 Delphi 開發的,其底層 GUI 框架為 VCL(Visual Component Library)。在 Win32 架構下,當系統建立或重繪一個 LBS_OWNERDRAWVARIABLE 清單控制項時,user32.dll 並不會把 WM_MEASUREITEM(0x002C)與 WM_DRAWITEM(0x002B)直接寄給清單控制項本身,而是寄給它的父視窗(在這裡是 SelectTasksPage,類別為 TNewNotebookPage)。

接著,VCL 的父視窗會在 TWinControl.WMMeasureItem 與 TWinControl.WMDrawItem 裡面,根據訊息結構中的子控制項代號(hwndItem)呼叫 FindControl(hwndItem) 找出對應的 Delphi 物件指標,再將訊息轉換為 VCL 內部的 CN_MEASUREITEM(0xBC2C)與 CN_DRAWITEM(0xBC2B)轉發給子控制項。

問題就出在 FindControl 是怎麼從一個 Win32 HWND 反查出 Delphi 的 TWinControl 物件指標。

VCL 啟動時的 Atom 註冊機制

在 Delphi VCL 的 Vcl.Controls.pas 中,程式啟動初始化(InitControls)時會組出三個帶有 Process ID 或 Thread ID 的字串,並呼叫 Win32 API GlobalAddAtom 註冊到系統的 Global Atom Table:

WindowAtomString := Format('Delphi%.8X', [GetCurrentProcessID]);
WindowAtom := GlobalAddAtom(PChar(WindowAtomString));

ControlAtomString := Format('ControlOfs%.8X%.8X', [HInstance, GetCurrentThreadId]);
ControlAtom := GlobalAddAtom(PChar(ControlAtomString));

每當 VCL 建立一個視窗控制項(TWinControl.CreateWnd),就會呼叫 SetProp,以 ControlAtom 作為屬性識別碼,把 Delphi 物件指標 Self 掛載到該 HWND 的 Window Property 上:

SetProp(FHandle, MakeIntAtom(ControlAtom), THandle(Self));

FindControl 裡面致命的 0 = 0 邏輯漏洞

當父視窗收到 WM_MEASUREITEM 或 WM_DRAWITEM 並呼叫 FindControl(Handle) 時,VCL 的原始碼是這樣寫的:

function FindControl(Handle: HWnd): TWinControl;
var
  OwningProcess: DWORD;
begin
  Result := nil;
  if (Handle <> 0) and (GetWindowThreadProcessId(Handle, OwningProcess) <> 0) and
     (OwningProcess = GetCurrentProcessId) then
  begin
    if GlobalFindAtom(PChar(ControlAtomString)) = ControlAtom then
      Result := Pointer(GetProp(Handle, MakeIntAtom(ControlAtom)))
    else
      Result := ObjectFromHWnd(Handle);
  end;
end;

請仔細看中間那個 if 判斷式:

if GlobalFindAtom(PChar(ControlAtomString)) = ControlAtom then

當年撰寫這段程式碼的 VCL 開發者,原本的用意其實很貼心:他擔心程式執行到一半時,系統上有其他不守規矩的程式把 Global Atom Table 裡面的 ControlAtom 誤刪了。萬一被誤刪,GlobalFindAtom 就會因為找不到字串而回傳 0,這時因為 0 <> ControlAtom(正常的 Atom 值介於 0xC000 到 0xFFFF),程式就會進入 else 分支,改用較慢但安全的備援機制 ObjectFromHWnd(Handle)(發送 RM_GetObjectInstance 註冊訊息去向視窗詢問物件指標)。

但是,如果程式剛啟動呼叫 GlobalAddAtom 時就失敗了呢?

  1. 在 InitControls 中,GlobalAddAtom 因為空間不足而失敗回傳 0,導致全域變數 ControlAtom 的值變成 0。
  2. 建立控制項時,SetProp(FHandle, MakeIntAtom(0), THandle(Self)) 因為 Atom 為 0 而直接失敗,視窗上沒有掛載任何 Property。
  3. 到了 FindControl 執行時,GlobalFindAtom(PChar(ControlAtomString)) 在表格裡找不到這個字串,同樣回傳 0。
  4. 此時 GlobalFindAtom(...) = ControlAtom 變成了 0 = 0,條件竟然成立了!
  5. VCL 誤以為 ControlAtom 一切正常,於是呼叫 GetProp(Handle, MakeIntAtom(0)),永遠拿到 0(nil),而且永遠不會進入 else 的 ObjectFromHWnd 備援分支。

為了驗證這個推論,我直接對執行中的 Carnac-2.6.0-Setup.exe 呼叫 EnumPropsExW 與 RM_GetObjectInstance:結果安裝程式裡面每一個視窗的 Window Property 數量真的都是 0,而發送 RM_GetObjectInstance 卻能精準拿回所有控制項的記憶體指標。一旦我從外部手動幫 TNewCheckListBox 送出 CN_MEASUREITEM,它的項目高度立刻從 20 變成正確的 37!

第三階段追查:揪出 Global Atom Table 耗盡的真兇

既然問題出在 GlobalAddAtom 回傳 0,我在 PowerShell 直接測試呼叫一次 GlobalAddAtomW:

Add-Atom = 0, LastError = 8

Win32 錯誤碼 8 就是 ERROR_NOT_ENOUGH_MEMORY(底層對應 NTSTATUS 0xC0000017 = STATUS_NO_MEMORY)。這代表我目前登入的 Windows 互動式工作階段(Session)中,Global Atom Table 已經被塞滿或耗盡配額了。

什麼是 Global Atom Table

在 Windows 的視窗子系統(win32k)中,Atom Table 是系統用來儲存短字串並將其對應為 16 位元整數代號(Atom,範圍從 0xC000 即 49152 到 0xFFFF 即 65535,最多 16,384 個槽位)的雜湊表。在同一個互動式視窗工作站(WinSta0)中,主要有兩張表格共用這段代號空間:

  • Global Atom Table:透過 GlobalAddAtom、GlobalFindAtom、GlobalDeleteAtom 與 GlobalGetAtomName 操作,供同一個 Session 內的所有應用程式共享(早期常用於 DDE 動態資料交換與跨程序通訊,Delphi VCL 則拿它來存視窗屬性名稱)。
  • User Atom Table:透過 RegisterWindowMessage、RegisterClass 與 RegisterClipboardFormat 操作,用來存放自訂視窗訊息、視窗類別名稱與剪貼簿格式。

Global Atom Table 有一個非常致命的設計特性:它不像檔案 Handle、GDI 物件或 Mutex 那樣綁定在特定 Process 身上。當一個應用程式呼叫 GlobalAddAtom 新增了字串,如果該程式在結束前沒有呼叫 GlobalDeleteAtom(例如程式崩潰、被強制終止,或是程式碼本身有 Bug 忘記釋放),Windows 核心並不會在 Process 結束時自動幫它回收這個 Atom。這些沒有人認領的「孤兒 Atom」就會一直殘留在工作階段的記憶體裡,直到使用者登出或重開機為止。

盤點系統裡殘留了哪些 Global Atoms

透過 PowerShell 從 0xC000 到 0xFFFF 逐一呼叫 GlobalGetAtomNameW 掃描後,我在電腦上發現了三大類型的殘留項目:

殘留類型 字串格式範例 成因說明
Delphi / VCL 孤兒 Atom Delphi00007C14、ControlOfs0040000000004EF8、WndProcPtr012C000000008FE4 Delphi、C++Builder 或 Inno Setup 程式在異常結束或崩潰時,未能執行 DoneControls 呼叫 GlobalDeleteAtom,導致舊 PID 與 TID 的字串永久殘留
DDE 參數封裝洩漏 -%D4#!0E900000050E00004628B8C8(共 32 字元) 當 64 位元 Windows 程式呼叫 user32!PackDDElParam 封裝 DDE 訊息(如 WM_DDE_ACK 或 WM_DDE_EXECUTE)時,user32.dll 會在 Global Atom Table 建立以 -%D4#! 開頭的暫存 Atom 存放兩個 64 位元指標;若接收端未呼叫 FreeDDElParam 或訊息被丟棄,該 Atom 就會永久洩漏
瀏覽器測試或自動化殘留 ChromeForTestin 等 自動化測試工具或常駐工具註冊後未釋放的的全域字串

此外,使用 GetClipboardFormatNameW 檢查同一工作階段的 User Atom Table 時,也發現裡面累積了超過 1,400 個視窗類別與剪貼簿格式 Atom。

解決方案一:自動掃描與清除孤兒 Global Atoms

如果不想立刻關閉手邊所有工作並重新開機,第一個急救方法是撰寫 PowerShell 腳本,掃描整個 Global Atom Table(0xC000..0xFFFF),比對目前系統上真正還活著的 Process ID(PID)與 Thread ID(TID),將已經不存在的孤兒 Atom 強制呼叫 GlobalDeleteAtom 刪除。

這裡有一個實作細節需要注意:同一個 Global Atom 若被重複呼叫多次 GlobalAddAtom,核心內部的參考計數(Reference Count)會累加;刪除時必須在迴圈中反覆呼叫 GlobalDeleteAtom,直到參考計數歸零、GlobalGetAtomNameW 再也查不到該名稱為止。

以下是完整的 Clean-GlobalAtoms.ps1 腳本:

[CmdletBinding()]
param(
    [Parameter(HelpMessage = "同時清除洩漏的 DDE PackDDElParam (-%D4#!...) 與 ChromeForTestin Atoms")]
    [switch]$IncludeDdeAndChrome,

    [Parameter(HelpMessage = "僅預覽將被刪除的孤兒 Atoms,不實際執行刪除")]
    [switch]$DryRun
)

$ErrorActionPreference = 'Stop'

# 載入 Win32 API
if (-not ([System.Management.Automation.PSTypeName]'Win32AtomCleaner').Type) {
    Add-Type -TypeDefinition @"
using System;
using System.Text;
using System.Runtime.InteropServices;

public class Win32AtomCleaner {
    [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
    public static extern uint GlobalGetAtomNameW(ushort nAtom, StringBuilder lpBuffer, int nSize);

    [DllImport("kernel32.dll", SetLastError = true)]
    public static extern ushort GlobalDeleteAtom(ushort nAtom);

    [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
    public static extern ushort GlobalAddAtomW(string lpString);

    [DllImport("kernel32.dll", SetLastError = true)]
    public static extern void SetLastError(uint dwErrCode);
}
"@
}

Write-Host "[1/3] 正在收集目前系統中執行中的 Process ID 與 Thread ID..." -ForegroundColor Cyan
$livePids = [System.Collections.Generic.HashSet[int]]::new()
$liveTids = [System.Collections.Generic.HashSet[int]]::new()

foreach ($proc in [System.Diagnostics.Process]::GetProcesses()) {
    [void]$livePids.Add($proc.Id)
    try {
        foreach ($thread in $proc.Threads) {
            [void]$liveTids.Add($thread.Id)
        }
    } catch {
        # 部分系統保護程序可能無法列舉執行緒,直接略過
    }
}
Write-Host ("      已收集 {0} 個執行中處理序、{1} 個執行緒。" -f $livePids.Count, $liveTids.Count)

Write-Host "[2/3] 正在掃描 Global Atom Table (0xC000 .. 0xFFFF)..." -ForegroundColor Cyan
$sb = [System.Text.StringBuilder]::new(512)
$totalAtoms = 0
$candidates = [System.Collections.Generic.List[pscustomobject]]::new()

for ($atom = 0xC000; $atom -le 0xFFFF; $atom++) {
    $len = [Win32AtomCleaner]::GlobalGetAtomNameW([uint16]$atom, $sb, $sb.Capacity)
    if ($len -le 0) { continue }

    $totalAtoms++
    $name = $sb.ToString(0, [int]$len)
    $reason = $null

    # 1. 檢查 DelphiXXXXXXXX (後 8 碼為 16 進位 Process ID)
    if ($name -match '^Delphi([0-9A-Fa-f]{8})$') {
        $targetPid = [Convert]::ToInt32($matches[1], 16)
        if (-not $livePids.Contains($targetPid)) {
            $reason = "Dead Delphi PID ($targetPid)"
        }
    }
    # 2. 檢查 WndProcPtrXXXXXXXXYYYYYYYY / ControlOfsXXXXXXXXYYYYYYYY (後 8 碼為 16 進位 Thread ID)
    elseif ($name -match '^(WndProcPtr|ControlOfs)([0-9A-Fa-f]{8})([0-9A-Fa-f]{8})$') {
        $targetTid = [Convert]::ToInt32($matches[3], 16)
        if (-not $liveTids.Contains($targetTid)) {
            $reason = "Dead Delphi TID ($targetTid)"
        }
    }
    # 3. 選用:檢查 DDE PackDDElParam 洩漏 (-%D4#!...) 與 ChromeForTestin
    elseif ($IncludeDdeAndChrome) {
        if ($name -match '^-%D4#![0-9A-Fa-f]+$') {
            $reason = "Leaked DDE PackDDElParam Atom"
        } elseif ($name -eq 'ChromeForTestin') {
            $reason = "Orphaned ChromeForTesting Atom"
        }
    }

    if ($reason) {
        $candidates.Add([pscustomobject]@{
            AtomHex = ('0x{0:X4}' -f $atom)
            AtomId  = [uint16]$atom
            Name    = $name
            Reason  = $reason
        })
    }
}

Write-Host ("      目前共有 {0} 個 Global Atoms,發現 {1} 個可清理的孤兒項目。" -f $totalAtoms, $candidates.Count)

if ($candidates.Count -eq 0) {
    Write-Host "[3/3] 沒有發現需要清理的孤兒 Global Atoms。" -ForegroundColor Green
    return
}

if ($DryRun) {
    Write-Host "[3/3] DryRun 模式:以下為預計刪除的項目(未實際刪除):" -ForegroundColor Yellow
    $candidates | Format-Table -AutoSize
    return
}

Write-Host "[3/3] 正在強制遞減 Reference Count 並釋放孤兒 Global Atoms..." -ForegroundColor Cyan
$freedCount = 0

foreach ($item in $candidates) {
    # 針對可能被多次 AddAtom 的項目,迴圈呼叫 GlobalDeleteAtom 直到名稱無法再被查出(上限 4096 次避免無窮迴圈)
    for ($i = 0; $i -lt 4096; $i++) {
        [Win32AtomCleaner]::SetLastError(0)
        [void][Win32AtomCleaner]::GlobalDeleteAtom($item.AtomId)
        $checkLen = [Win32AtomCleaner]::GlobalGetAtomNameW($item.AtomId, $sb, $sb.Capacity)
        if ($checkLen -eq 0) {
            $freedCount++
            break
        }
    }
}

# 測試是否已可成功建立新的 Global Atom
$testName = "AtomCleanTest_" + [Guid]::NewGuid().ToString("N").Substring(0, 8)
$testAtom = [Win32AtomCleaner]::GlobalAddAtomW($testName)
$lastErr = [System.Runtime.InteropServices.Marshal]::GetLastWin32Error()
if ($testAtom -ne 0) {
    [void][Win32AtomCleaner]::GlobalDeleteAtom($testAtom)
    $statusMsg = "成功 (GlobalAddAtomW 測試通過)"
    $color = "Green"
} else {
    $statusMsg = "仍失敗 (Win32 Error = $lastErr,建議搭配 -IncludeDdeAndChrome 或登出重登)"
    $color = "Yellow"
}

Write-Host ""
Write-Host ("清理完成:成功釋放 {0} / {1} 個孤兒 Global Atoms。" -f $freedCount, $candidates.Count) -ForegroundColor Green
Write-Host ("可用性測試:{0}" -f $statusMsg) -ForegroundColor $color

$candidates | Format-Table -AutoSize

使用方式很簡單,可以先加上 -DryRun 預覽有哪些孤兒項目,確認無誤後再實際清理:

# 1. 僅預覽可清理的孤兒 Atoms(不實際刪除)
.\Clean-GlobalAtoms.ps1 -IncludeDdeAndChrome -DryRun

# 2. 清理已結束程序的 Delphi / Inno Setup 孤兒 Atoms
.\Clean-GlobalAtoms.ps1

# 3. 一併清理殘留的 DDE PackDDElParam (-%D4#!...) 與 ChromeForTestin Atoms
.\Clean-GlobalAtoms.ps1 -IncludeDdeAndChrome

解決方案二:找出高用量 GUI 程序與檢測 Atom Table 健康狀態

在我的電腦上執行 Clean-GlobalAtoms.ps1 -IncludeDdeAndChrome 雖然立刻釋放了 23 個槽位,但過沒幾秒鐘再次測試 GlobalAddAtomW,卻發現剛釋放出來的空間又馬上被背景某個正在持續洩漏資源的程序吃光了。

由於 Windows 並沒有內建 API 可以直接查詢「某一個 Global Atom 是由哪一個 PID 建立的」,實務上要揪出幕後洩漏資源的怪獸程序,最有效的方法是查詢每個處理序的 USER Objects 數量(GetGuiResources 的 GR_USEROBJECTS,包含視窗、選單、游標、DWP 與相關表項)、GDI Objects 數量與 Handle 數量。凡是不斷註冊視窗類別、發送 DDE 訊息或洩漏控制項的背景工具,它的 UserObjects 或 Handles 通常都會名列前茅。

以下這份 Get-TopGuiProcesses.ps1 腳本會同時檢查 Global Atom Table 與 User Atom Table 的使用量、測試 GlobalAddAtomW 是否健康,並列出系統上消耗最多 USER 物件與 Handle 的前 N 名處理序:

[CmdletBinding()]
param(
    [Parameter(HelpMessage = "顯示前 N 名高用量處理序(預設前 15 名)")]
    [int]$Top = 15,

    [Parameter(HelpMessage = "排序依據:UserObjects、UserObjectsPeak、GdiObjects、GdiObjectsPeak 或 Handles")]
    [ValidateSet('UserObjects', 'UserObjectsPeak', 'GdiObjects', 'GdiObjectsPeak', 'Handles')]
    [string]$SortBy = 'UserObjects'
)

$ErrorActionPreference = 'Stop'

# 載入 Win32 API (GetGuiResources + Atom Table 檢測)
if (-not ([System.Management.Automation.PSTypeName]'Win32GuiInspector').Type) {
    Add-Type -TypeDefinition @"
using System;
using System.Text;
using System.Runtime.InteropServices;

public class Win32GuiInspector {
    // uiFlags: 0 = GR_GDIOBJECTS, 1 = GR_USEROBJECTS, 2 = GR_GDIOBJECTS_PEAK, 4 = GR_USEROBJECTS_PEAK
    [DllImport("user32.dll", SetLastError = true)]
    public static extern uint GetGuiResources(IntPtr hProcess, uint uiFlags);

    [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
    public static extern uint GlobalGetAtomNameW(ushort nAtom, StringBuilder lpBuffer, int nSize);

    [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
    public static extern int GetClipboardFormatNameW(uint format, StringBuilder lpszFormatName, int cchMaxCount);

    [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
    public static extern ushort GlobalAddAtomW(string lpString);

    [DllImport("kernel32.dll", SetLastError = true)]
    public static extern ushort GlobalDeleteAtom(ushort nAtom);
}
"@
}

Write-Host "=== [1/2] Atom Table 狀態檢測 ===" -ForegroundColor Cyan
$sb = [System.Text.StringBuilder]::new(512)
$globalAtomCount = 0
$userAtomCount = 0

for ($id = 0xC000; $id -le 0xFFFF; $id++) {
    if ([Win32GuiInspector]::GlobalGetAtomNameW([uint16]$id, $sb, $sb.Capacity) -gt 0) {
        $globalAtomCount++
    }
    if ([Win32GuiInspector]::GetClipboardFormatNameW([uint32]$id, $sb, $sb.Capacity) -gt 0) {
        $userAtomCount++
    }
}

$testName = "GuiInspectTest_" + [Guid]::NewGuid().ToString("N").Substring(0, 8)
$testAtom = [Win32GuiInspector]::GlobalAddAtomW($testName)
$lastErr = [System.Runtime.InteropServices.Marshal]::GetLastWin32Error()
if ($testAtom -ne 0) {
    [void][Win32GuiInspector]::GlobalDeleteAtom($testAtom)
    $atomStatus = "正常 (GlobalAddAtomW 可成功新增)"
    $statusColor = "Green"
} else {
    $atomStatus = "已耗盡! (GlobalAddAtomW 回傳 0, Win32 Error = $lastErr)"
    $statusColor = "Red"
}

Write-Host ("  Global Atoms 數量 : {0,5} / 16384" -f $globalAtomCount)
Write-Host ("  User Atoms 數量   : {0,5} / 16384 (Window Classes / Messages / Clipboard Formats)" -f $userAtomCount)
Write-Host ("  GlobalAddAtom 測試: {0}" -f $atomStatus) -ForegroundColor $statusColor
Write-Host ""

Write-Host ("=== [2/2] 前 {0} 名高用量 GUI / Handle 處理序 (依 {1} 排序) ===" -f $Top, $SortBy) -ForegroundColor Cyan

$results = [System.Collections.Generic.List[pscustomobject]]::new()

foreach ($proc in [System.Diagnostics.Process]::GetProcesses()) {
    try {
        $hProc = $proc.Handle
        $userObj     = [Win32GuiInspector]::GetGuiResources($hProc, 1)
        $userObjPeak = [Win32GuiInspector]::GetGuiResources($hProc, 4)
        $gdiObj      = [Win32GuiInspector]::GetGuiResources($hProc, 0)
        $gdiObjPeak  = [Win32GuiInspector]::GetGuiResources($hProc, 2)

        if ($userObj -gt 0 -or $gdiObj -gt 0 -or $proc.HandleCount -gt 500) {
            $results.Add([pscustomobject]@{
                PID             = $proc.Id
                ProcessName     = $proc.ProcessName
                UserObjects     = $userObj
                UserObjectsPeak = $userObjPeak
                GdiObjects      = $gdiObj
                GdiObjectsPeak  = $gdiObjPeak
                Handles         = $proc.HandleCount
                WorkingSetMB    = [math]::Round($proc.WorkingSet64 / 1MB, 1)
                MainWindowTitle = if ($proc.MainWindowTitle) {
                    if ($proc.MainWindowTitle.Length -gt 36) { $proc.MainWindowTitle.Substring(0, 36) + "..." } else { $proc.MainWindowTitle }
                } else { "" }
            })
        }
    } catch {
        # 無權限存取的系統處理序直接略過
    }
}

$sorted = $results | Sort-Object -Property $SortBy, Handles -Descending | Select-Object -First $Top
$sorted | Format-Table -AutoSize

Write-Host "提示:若特定常駐程式(如自動化工具、剪貼簿工具、瀏覽器或 Electron 應用)的 UserObjects / Handles 異常偏高,可嘗試將其重啟;若 GlobalAddAtom 仍顯示已耗盡,請執行 Windows「登出再重新登入」以彻底重置 Session Atom Table。" -ForegroundColor DarkGray

執行後會輸出清楚的檢測報表:

# 1. 預設依 UserObjects 排序列出前 15 名處理序
.\Get-TopGuiProcesses.ps1

# 2. 改依 Handles 排序列出前 25 名處理序
.\Get-TopGuiProcesses.ps1 -Top 25 -SortBy Handles

如果你關閉了可疑的高用量常駐工具並執行 Clean-GlobalAtoms.ps1 之後,GlobalAddAtom 測試 就恢復為綠色的「正常」,那麼重新打開 Inno Setup 安裝程式,所有核取方塊就會立刻恢復顯示;如果核心底層的工作階段 Atom 配額仍然處於耗盡狀態,只要執行一次 Windows 登出(Sign out)再重新登入(不需要完整重開機),讓 win32k 銷毀並重建目前 Session 的 WinSta0 Atom Table,所有問題就會徹底解決。

結語

這次的除錯經驗相當有意思。表面上看起來只是「Carnac 2.6.0 安裝程式少畫了兩個核取方塊」,一般遇到這種狀況大概會直接猜測是安裝包損毀或 Inno Setup 版本不相容;但實際用 Win32 API 往下一層一層追,卻串起了好幾個平時鮮少注意到的 Windows 底層細節:

  • Inno Setup 的 TNewCheckListBox 依賴 Win32 LBS_OWNERDRAWVARIABLE 自繪機制,當 WM_MEASUREITEM 與 WM_DRAWITEM 沒有被處理時,清單項目依然存在且可透過鍵盤選取,但高度會退回預設的 20px 且畫不出任何文字。
  • Delphi VCL 在 InitControls 使用 GlobalAddAtom 註冊 ControlOfs...,而 FindControl 裡面的 GlobalFindAtom(...) = ControlAtom 在 ControlAtom = 0 時會因為 0 = 0 誤判成功,導致 ObjectFromHWnd 備援機制形同虛設。
  • Windows 的 Global Atom Table 屬於整個互動式工作階段共用,且不會隨處理序結束自動回收;包含未正常結束的 Delphi 程式與未呼叫 FreeDDElParam 的 DDE 訊息(-%D4#!...),長時間累積下來就可能把 Atom Table 塞爆,引發各種看似毫無關聯的 GUI 怪病。

下次如果在長時間未登出的 Windows 電腦上,遇到 Delphi 或 Inno Setup 程式出現畫面局部空白、視窗無法切換或剪貼簿異常,不妨先跑一下本文的檢測腳本,看看是不是 Global Atom Table 又悄悄見底了。

相關連結

留言評論