
做桌面运维或者给企业写打印管理工具的时候十个人里有八个会被同一个问题卡住怎么用代码判断一台打印机是不是脱机了查了无数资料翻到的基本都是调用 GetPrinter 读 PRINTER_INFO_2 的 Status 字段以为判断一下 PRINTER_STATUS_OFFLINE 就完事了结果拔了 USB 线它照样返回在线或者打印机明明断网了还显示准备就绪。这个事我折腾过好几轮踩了不少坑才理清楚。它不像查个文件是否存在那么简单打印机状态牵涉到系统打印服务、驱动、端口类型、网络链路好几个层面脱机在不同的场景下含义完全不同。Corporation 的 API 文档里EnumPrinters、GetPrinter 这些函数看起来人畜无害但真正组合起来用的时候到处都是细节。这篇就把我实际写过的检测逻辑、踩过的坑和最终能用于生产环境的完整方案一起端出来给卡在 VC 打印机状态获取上的朋友做个参考。1. 问题拆解与实现路径选型1.1 先搞明白脱机到底是谁说的好多人在这一步就栽了。你以为脱机是一个客观物理状态实际上它是多个层各自维护的一套布尔值。打印机本体有物理状态通电、断电、缺纸、卡纸驱动有状态正常、暂停、出错Windows 后台打印程序 Spooler 还有一套状态离线、忙碌、正在打印。代码里能拿到的状态是驱动和 Spooler 向应用层暴露出来的跟打印机本体的物理状态中间隔着一层翻译和缓存。比如最常见的场景一台 USB 打印机用户把它关机了。你调用 GetPrinter 去读 Status很多时候返回的仍然是 0也就是就绪。原因是 USB 打印机的驱动层会缓存最后的状态或者干脆把设备断开当成打印机不在而没更新脱机位。反过来如果用户在打印机属性里勾选了脱机使用打印机无论物理设备是否连接Status 里都会置上 PRINTER_STATUS_OFFLINE 位。这就是为什么单一状态标志根本不够用。所以拿到需求第一件事不是写代码而是先界定业务场景你要监控的是本地 USB 打印机、网络共享打印机还是 TCP/IP 直连打印机你的监控目的是判断设备物理上还能不能打还是判断Windows 是否认为它离线这两个目标对应的技术方案完全不同。我在实际项目里两个都要因为它们服务不同的告警逻辑。1.2 API 选型EnumPrinters GetPrinter 才是正路确定好目标之后再来选 API。Windows 打印体系里和状态查询相关的 API 其实就那几组EnumPrinters、GetPrinter 是最直接的。其他方案比如直接读注册表里的打印队列信息或者调 WMI 的 Win32_Printer我都试过各有各的问题。EnumPrinters 负责枚举打印机列表GetPrinter 负责获取某台打印机的详细信息。两者配合就能拿到系统当前有哪些打印机以及每台打印机声称自己是什么状态。核心数据结构是 PRINTER_INFO_2里面的 Attributes 字段和 Status 字段承载了和脱机状态相关的关键信息。为什么不用注册表因为 PrinterPorts、Printer 这些键值经常和实际状态不同步而且管理员权限不够读注册表还可能失败。WMI 呢Win32_Printer 的 WorkOffline 属性其实就是对 Attributes 里 WORK_OFFLINE 位的复述PrinterStatus 也是从 Status 换算出来的绕了一圈还是回到同一个源头性能反而更差。所以老老实实用原生 API 是最稳的路径兼容性也最好从 Windows XP 到 Windows 11 都没有问题。1.3 Attributes 和 Status两个标志要分清这是整个方案里最核心的概念必须掰开揉碎讲清楚。PRINTER_INFO_2 里有两个字段名字长得像含义天差地别。Attributes 是打印机的持久属性表示打印机的身份特征Status 是打印机的即时状态表示此刻发生了什么。在脱机这件事上Attributes 里的 PRINTER_ATTRIBUTE_WORK_OFFLINE值为 0x00000001表示这台打印机被标记为脱机工作。这个标志的典型触发场景是用户在打印队列窗口手动勾选了脱机使用打印机或者网络打印机的连接被系统判定为不可用。这个位的特点是一旦置上就比较稳定不会频繁翻转。Status 里的 PRINTER_STATUS_OFFLINE值为 0x00000001表示后台打印程序认为打印机当前处于脱机状态。它跟 Attributes 中的 WORK_OFFLINE 经常会同步出现但逻辑上并不完全等价。从 Windows 文档角度解读这两个标志的更新源不同。WORK_OFFLINE 偏向于用户/系统把打印机设为离线的意图而 STATUS_OFFLINE 偏向于驱动上报的物理离线状态。实际操作中USB 打印机断电时部分驱动会置 STATUS_OFFLINE 而不管 WORK_OFFLINE网络打印机断开时系统往往两个都置上。所以我的判断逻辑干脆两个都读任何一个置位都算系统认为离线再结合附加检测来增强置信度。具体代码在后面给出。2. 核心代码实现与逐段解析2.1 一个能直接上手的检测函数先给出一段我用于生产环境的完整函数。这段代码基于 Win32 API使用 C/CLI 也可以直接转成 C# 的 P/Invoke但至少在我测过的从 VC6 到 VS2022 的环境里原样编译没有问题。#include windows.h #include winspool.h #include vector #include string // 定义打印机脱机状态返回值 enum PrintOfflineState { OFFLINE_STATE_NONE 0, // 正常 OFFLINE_STATE_ATTRIBUTE 1, // Attributes 标记脱机 OFFLINE_STATE_STATUS 2, // Status 标记脱机 OFFLINE_STATE_BOTH 3 // 两个标志都标记脱机 }; BOOL GetAllPrinters(std::vectorPRINTER_INFO_2 printers) { DWORD dwNeeded 0; DWORD dwReturned 0; // 第一次调用获取需要的缓冲区大小 EnumPrinters(PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS, NULL, 2, NULL, 0, dwNeeded, dwReturned); if (dwNeeded 0) { return FALSE; } std::vectorBYTE buffer(dwNeeded); PRINTER_INFO_2* pInfo reinterpret_castPRINTER_INFO_2*(buffer.data()); if (!EnumPrinters(PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS, NULL, 2, buffer.data(), dwNeeded, dwNeeded, dwReturned)) { return FALSE; } for (DWORD i 0; i dwReturned; i) { printers.push_back(pInfo[i]); } return TRUE; } BOOL GetPrinterOfflineState(const std::wstring printerName, DWORD outState) { HANDLE hPrinter NULL; if (!OpenPrinter(const_castLPWSTR(printerName.c_str()), hPrinter, NULL)) { // OpenPrinter 失败通常意味着打印机不存在或权限不足 return FALSE; } DWORD dwNeeded 0; GetPrinter(hPrinter, 2, NULL, 0, dwNeeded); std::vectorBYTE buffer(dwNeeded); PRINTER_INFO_2* pInfo reinterpret_castPRINTER_INFO_2*(buffer.data()); BOOL bResult GetPrinter(hPrinter, 2, buffer.data(), dwNeeded, dwNeeded); ClosePrinter(hPrinter); if (!bResult) { return FALSE; } outState OFFLINE_STATE_NONE; if (pInfo-Attributes PRINTER_ATTRIBUTE_WORK_OFFLINE) { outState | OFFLINE_STATE_ATTRIBUTE; } if (pInfo-Status PRINTER_STATUS_OFFLINE) { outState | OFFLINE_STATE_STATUS; } return TRUE; }这段代码有两个故意设计的点值得说明。一个是用 PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS 作为枚举范围前者找到本机安装的打印机包括 USB 和 TCP/IP 端口后者找到用户会话中映射的网络共享打印机。两个都要枚举才能覆盖日常办公环境的全部打印资源。另一个是 GetPrinter 的两次调用模式第一次传 NULL 缓冲拿大小第二次填充数据这是 Windows API 处理变长结构体的标准写法。2.2 打开打印机句柄的权限细节OpenPrinter 的第三参数是 PRINTER_DEFAULTS 结构体我直接传了 NULL。这意味着系统使用默认的安全描述符和访问权限够读 PRINTER_INFO_2 吗我实测下来对绝大多数打印机是够的因为读取状态不是高权限操作。但还是建议把访问权限显式设置成 PRINTER_ACCESS_USE 而不是 PRINTER_ALL_ACCESS。为什么在意这个我在一个客户环境里踩过权限坑。当时打印管理服务运行在普通用户态下用 PRINTER_ALL_ACCESS 去 OpenPrinter结果共享打印机一律打不开返回 ERROR_ACCESS_DENIED。原因很明显PRINTER_ALL_ACCESS 请求的权限范围太大普通账户没有授予这么高的访问权。改成 PRINTER_ACCESS_USE 之后立刻全通了。PRINTER_ACCESS_USE 的语义是只请求使用权限不涉及管理操作这在状态轮询场景下是最合适的选择。PRINTER_DEFAULTS pd; ZeroMemory(pd, sizeof(pd)); pd.DesiredAccess PRINTER_ACCESS_USE; HANDLE hPrinter NULL; if (!OpenPrinter(const_castLPWSTR(printerName.c_str()), hPrinter, pd)) { return FALSE; }有个小细节打印机名的形式会影响打开结果。传打印机名和传\Server\PrinterName这两种写法OpenPrinter 都能识别但前者在后台会先搜索默认服务器。如果你拿到的打印机名是带 UNC 路径的建议原样传递不要自己拆字符串拼接有些老的打印服务器对主机名的解析方式很挑剔。2.3 网络打印机状态的链路判断增强单靠 GetPrinter 的返回值并不能覆盖所有真实场景。比如打印机物理上被断电了但它的驱动和 Spooler 之间的连接没有超时状态可能仍然是就绪。或者打印机跟电脑在同一个局域网里但网线被拔了系统的状态刷新有延迟。我在实战里做了一件事来缓解这个问题对网络端口的打印机补充一次链路可达性探测。具体做法是先拿到打印机的端口名。PRINTER_INFO_2 的 pPortName 字段里有端口信息常见的有USB001、LPT1:、192.168.1.100_9100这类。如果端口名是 IP 加端口号的格式说明是 TCP/IP 直连打印机这时可以用一个简单的 ICMP Ping 来测主机通不通。对共享打印机端口名一般是WINSPOOL这种情况下可以检查打印服务器是否可访问。BOOL IsTcpIpPort(const std::wstring portName) { // 常见的 TCP/IP 端口名格式如 192.168.1.100_9100 // 如果端口名包含 IP 地址段特征则认为是网络打印机 return portName.find(L_9100) ! std::wstring::npos || portName.find(L_9101) ! std::wstring::npos; }Ping 这个动作要谨慎。每轮询一次就 Ping 一次不仅增加网络流量某些安全软件还会对高频 ICMP 弹告警。我的策略是只有 GetPrinter 状态显示正常时才隔几轮做一次链路探测如果链路不通且状态又显示正常再降级处理。这样既不影响正常判断又能在驱动撒谎的时候兜底。3. 实操验证与状态判断逻辑3.1 完整判断状态的条件组合与降级策略把上面的函数整理一下完整的脱机判断应该走这样一套逻辑先调用 GetAllPrinters 拿到系统当前所有打印机列表。对每一台打印机调用 GetPrinterOfflineState 获取两个标志位。只要 Attributes 或 Status 任何一个置位了脱机位就判定为脱机。如果两个标志都没置位再检查是不是网络端口打印机。如果是隔 N 轮做一次链路探测链路不通则判定为疑似脱机。只有当所有检查都通过才最终返回在线。这里说一个很重要但很容易被忽略的点不要试图用一个返回值完全替代用户的肉眼判断。Windows 的打印机状态本身就有很强的滞后性比如打印队列里有一堆打印任务卡住了或者驱动进入假死状态状态字段可能一直停在某个旧值上。代码能做的只是把 API 暴露的信息最大程度地榨干再叠加补充探测提高判断准确率而不是追求百分之百的精确。实际生产环境中我把这个检测逻辑封装成了一个线程函数每 10 秒轮询一次检测到状态变化就通过回调通知上层 UI 刷新。轮询频率不要设得太高因为 EnumPrinters 和 GetPrinter 都是同步调用在高打印机数量的机器上频繁调用会拖慢系统。3.2 实测场景记录USB、网络直连、共享打印机我在测试环境里用三台典型打印机验证过这套逻辑的逻辑分支覆盖情况。测试一USB 接口的惠普 LaserJet P1108。正常工作时Attributes 是 0x4000PRINTER_ATTRIBUTE_LOCAL 和 PRINTER_ATTRIBUTE_ENABLE_BIDI 的组合Status 是 0。拔掉 USB 线后工作大概几十秒内Status 变成了 PRINTER_STATUS_OFFLINEAttributes 有时候置位有时候不置位取决于驱动版本。所以两个标志都查真的很有必要。测试二TCP/IP 直连的爱普生 L4168端口是 192.168.1.50_9100。断网后Status 并不立即变化有时要等 1 到 2 分钟系统才反应过来把 Status 置成 PRINTER_STATUS_OFFLINE。但 IP 地址已经 ping 不通了。这时候链路探测就能提前发现异常比等系统刷新快得多。测试三网络共享的佳能 iR 系列通过 \PrintServer\Canon 访问。打印服务器宕机之后本机的 Attributes 会很快置上 PRINTER_ATTRIBUTE_WORK_OFFLINEStatus 也会跟着变。这说明共享打印机对打印服务器的依赖很直接服务器不可达时状态立即翻转反而比 TCP/IP 直连更敏感。三种典型场景放在一起看结论是不能只靠单一标志也不能只靠单一手段。Attributes、Status、链路探测三者互补才能覆盖绝大多真实故障。3.3 关于 WMI 的补充方案能用但别作为主判断很多同事问我为什么不用 Win32_Printer 的 WorkOffline 和 PrinterStatus 属性那个不是更直观吗WMI 方案确实能拿到类似信息代码写起来也简单尤其是用 C# 的时候几行就搞定了。但在 VC 里用 WMI 需要初始化 COM、创建 WMI 连接、执行 WQL 查询代码量反而比直接调 API 还多而且 WMI 查询的开销远高于 GetPrinter。更大的问题是Win32_Printer 的 PrnterStatus 返回值并不是 1:1 对应 PRINTER_STATUS_OFFLINE。它的取值 1 表示 Other2 表示 Unknown3 表示 Idle4 表示 Printing5 表示 Warming Up 等等语义和打印机驱动上报的原始 Status 已经隔了一层。所以我只在需要拿到打印队列里的作业数量、打印总页数这些额外信息时才用 WMI 做一次补充查询。判断脱机/连接状态还是原生 API 最可靠。4. 常见问题与排查技巧实录4.1 OpenPrinter 失败错误码 5 拒绝访问现象是函数返回 FALSEGetLastError 是 5。多半是三个原因我来逐个排。第一个是前面提到的权限过大把 DesiredAccess 设置成 PRINTER_ALL_ACCESS在权限受限账户下必然被拒换成 PRINTER_ACCESS_USE 解决。第二个是打印机名传得不对比如名字带了尾部空格或者换行符OpenPrinter 对字符串很敏感拿到名字先 Trim 再传。第三个是打印机属于其他用户会话的映射打印机当前会话看不到这种情况下 EnumPrinters 根本枚举不到那台打印机也就到不了 OpenPrinter 这一步。4.2 轮询导致打印服务无响应或延迟这个问题最隐蔽。我曾经写过一个监控程序每 2 秒遍历一次所有打印机结果跑了一个月之后1 台机器上打印任务提交偶尔卡住几十秒查来查去定位到是轮询太频繁Spooler 的响应被拖慢了。这里涉及一个 Windows 打印服务的内部机制EnumPrinters 和 GetPrinter 会跟 Spooler 通信频繁调用会让 Spooler 忙于响应查询而延迟处理打印作业。这也是很多打印监控软件默认轮询间隔在 10 秒以上的原因。另外还要注意GetPrinter 返回的缓冲区如果太小函数会返回 FALSE 并设置 ERROR_INSUFFICIENT_BUFFER这其实是正常的再来一次信号不是错误。千万不要一遇到失败就重试阻塞调用万一 Spooler 卡住了你的线程也会跟着卡住。我最后是把状态读取放到一个独立线程用事件通知而不是 sleep 死等这样主 UI 永远不卡。4.3 驱动不上报状态怎么办有些老打印机的驱动根本不维护 PRINTER_STATUS_OFFLINE 位或者驱动出错时会返回异常状态甚至直接崩溃。遇到这种情况API 层面的标志几乎是静默的。我处理过一台打印机的驱动被其他程序改写了之后的 Status 永远返回 0但打印任务积压在队列里根本出不去。排查思路是这样先用 UI 层的打印队列里有多少个文档做辅助判断如果一个打印任务挂起超过 N 分钟没有完成那么即使状态字段说是就绪也应该把打印机标记为异常。同时可以检查驱动当前打印的文档计数这需要 GetJob 遍历打印队列如果一项作业一直处于 Spooling 或 Deleted 状态就说明输出通道有问题脱机可能性大增。这种启发式判断虽然不完美但胜在通用不依赖驱动的配合。4.4 共享打印机 0x000011b、0x00000012 这类错误的连带影响最近共享打印机相关的报错热搜特别多0x000011b 和 0x00000012 算是最常见的两个。它们的共同点是打印机是共享的客户端访问时发生了无法识别的问题可能是打印驱动版本不匹配也可能是打印服务器的配置被安全策略限制。在这些情况下客户端本机枚举到的打印机状态往往是脱机或者正在连接然后超时。我们在开发打印监控工具时要注意不要因为系统状态显示脱机就一刀切报故障因为共享打印机的脱机有可能是服务器端驱动不兼容造成的临时状态。正确的做法是把本机状态和服务器端的共享状态分开看或者至少把错误码信息记录下来。有一回我们处理一个共享打印机时好时坏的工单排查到最后发现是打印服务器上某个文件的权限被误改客户端的 OpenPrinter 在特定操作下失败错误码是 0x00000057状态字段完全没变化。这说明状态判断只是第一步真正定位问题还是需要结合错误码日志和队列状态做综合分析。5. 采坑记录与最终建议最后聊几个我长期做打印管理工具攒下来的经验。第一把脱机/连接的判断逻辑独立成一个模块不要在业务代码里到处散落。这个模块应该负责枚举打印机、查询状态、解析错误码、维护一个打印机状态缓存、以及对外提供事件回调。以后不管你是给公司内部写一个打印监控小工具还是做成服务端上报都能直接复用。第二要接受状态查询结果有延迟。Windows 打印状态不是实时数据库驱动上报频率、网络断链检测时间、打印机的自动休眠策略都会影响数据新鲜度。我见过有客户要求打印机一断电解锁 UI这其实超过了普通 API 能承诺的范围。真要达到这个效果就得配合物理层检测设备或者打印机的 Web 接口轮询而不是只靠 GetPrinter 硬冲。第三多观察驱动的脾气。同一台打印机换了驱动版本状态位的行为可能完全不同。准备上线新工具之前最好在真实环境里跑一周的兼容性测试重点观察 USB 和网络两种端口下的状态位翻转是否符合预期。如果发现有异常优先查驱动更新和打印机固件版本。第四处理打印机字符串字段时要特别小心。PRINTER_INFO_2 里 pPrinterName、pPortName、pDriverName 这些都是 LPTSTR不同的系统代码页下可能是 ANSI 或 Unicode 字符串。编译的时候建议统一使用 Unicode 字符集否则在中文打印机名的环境下很可能出现乱码导致 OpenPrinter 找不到打印机。我见过在简体中文系统上用 ANSI 版本的 EnumPrinters 拿回来的打印机名直接乱掉后面所有操作全部跟着乱。第五如果你只是临时排查一台打印机为什么脱机最快的办法其实是命令行的方式打开 cmd输入 wmic printer get name,workoffline,status几秒钟就知道答案了。我在给客户做远程支持的时候经常先甩这条命令过去让用户把输出发给我快速判断是驱动问题还是系统层面的标记问题然后才决定要不要升级工具去查更细的链路。命令行方式虽然不适合做成产品功能但作为排查手段效率是第一位的。这套代码和判断逻辑我现在还维护着一份每次接到打印机状态读不准的工单我都会先把日志里记录的 Attributes 和 Status 原始数值要过来用这两个字段的值判断是哪一层出了问题。如果想扩展可以在状态发生变化时把变化前后的数值、打印机名、端口类型、当前用户名写进日志对排查共享打印机的疑难杂症帮助非常大。希望这篇文章能让你少走几圈弯路。