资讯详情

资讯详情

Windows打印开发:用VC++通过驱动信息识别打印机硬件型号

1. 先搞清楚一件事你想要的“型号”到底在哪一层做 Windows 打印相关开发时我第一反应是查 EnumPrinters。可这个函数返回的只是系统里“打印机队列”的列表队列名往往被用户改得乱七八糟比如“前台打票机”“小办公室那台激光的”光看名字根本推不出到底是哪个硬件。这篇文章要解决的问题就是用 VC 通过打印机驱动里暴露出来的字段一步步拿到更接近实体机型的信息。如果你正在写终端巡检工具、打印计数模块或者要给办公网络做设备型号摸底清单这篇内容可以直接当参考。先说结论在 Windows 上普通程序权限下唯一稳定、可靠、跨版本可用的方式就是去读打印驱动相关信息。直接向打印机硬件发指令查询型号当然更准但应用层没有权限、也没有统一协议去访问每一台 USB 或网络打印机。驱动是操作系统已经为每台设备建立好的描述信息读它是最成熟的做法。1.1 用户看到的打印机名、驱动名和硬件型号是三个东西很多人刚上手时很容易混淆这三个概念。用户在“控制面板-设备和打印机”里看到的列表项是打印机名Printer Name它只是一个供人识别的标签系统根本不关心它叫什么。你安装一台打印机时安装向导会默认带出驱动里的名称但用户随手改成“一楼大打印机”之后程序通过 EnumPrinters 拿到的就是这个名字。驱动名Driver Name是安装驱动时驱动包里声明的字符串它通常包含厂商和系列信息比如某个激光打印机驱动会显示成“某品牌 2600 Series”。硬件型号Model是打印机背后铭牌上的标识也是真正需要在统计报表里展示的内容。驱动名经常已经足够接近硬件型号但存在两个问题一是被做成通用驱动时就失去了具体型号二是会被虚拟打印机扰乱。区分这三层信息是后续所有处理的前提。否则你会陷入一个死循环枚举拿到的全是“财务室打印机”注册表里看到的驱动键名又看不懂最终拿不到任何能写进报表的型号文本。1.2 为什么说“通过驱动”是最稳的路线有人会问能不能用 WMI 里的 Win32_Printer可以但它的 DriverName 字段本质上还是驱动名而且 COM 初始化和跨线程调用在 VC 里写起来比直接调打印 API 啰嗦。有人会说用 SNMP 去扫描打印机这在网络打印机上可行但 USB 打印机和驱动未匹配时就不灵了。通过驱动获取型号本质上是在读系统已经维护好的“身份信息”。无论打印机是 USB、并口还是网络共享队列只要驱动安装成功打印后台服务spoolsv就会把逻辑队列、驱动名、端口名这些信息写到系统注册表和打印 API 可查询的缓冲区里。应用层拿不到物理硬件直接通讯的权限但读系统描述信息是合法的、安全的、兼容性最好的一条路。2. 第一手数据源EnumPrinters 与 PRINTER_INFO_2 的组合打法第一步永远是从 EnumPrinters 开始。它枚举当前系统中所有可见的打印机队列包括本机安装的和网络连接的。我的习惯是用 Level 2也就是 PRINTER_INFO_2 结构因为它的字段覆盖最全。2.1 Level 2 还是 Level 4选错结构白折腾EnumPrinters 的 Level 参数直接决定返回哪种结构。Level 1 只有名称、描述、注释拿不到驱动名直接排除。Level 4 也能拿到 driverName但结构里缺少端口和设备名后续做判断时会束手束脚。Level 5 字段更少。所以常规做法就是 Level 2。PRINTER_INFO_2 里最常用的字段包括字段含义备注pPrinterName逻辑打印机名用户可随意修改不是可靠型号来源pDriverName驱动名核心字段通常含厂商或系列信息pPortName端口名如 USB001、LPT1、IP 地址等pDeviceName设备名部分驱动会填部分为空只能兜底用pShareName共享名网络共享打印机才有Attributes属性标志可判断是否默认打印机、是否共享很多人直接用 Level 4 图省事结果发现某些系统上 driverName 确实有但 pPortName 或 pDeviceName 拿不到后面做“打印机类型归类”时少了一个重要证据。如果你只是显示一个名字Level 4 够用要做型号清洗和实体/虚拟判断老老实实用 Level 2。2.2 完整代码枚举本机打印机并解析驱动字段下面这段是我在实际项目里用的底层枚举逻辑。第一次调用只为了获取缓冲区大小第二次拿到真正数据。注意先检查返回值 ERROR_INSUFFICIENT_BUFFER这是 Windows API 的常规套路。#include windows.h #include winspool.h #include vector #include string #include memory #pragma comment(lib, winspool.lib) std::wstring Trim(const std::wstring input) { size_t start input.find_first_not_of(L \t\r\n); if (start std::wstring::npos) return L; size_t end input.find_last_not_of(L \t\r\n); return input.substr(start, end - start 1); } bool EnumeratePrinters(std::vectorPRINTER_INFO_2 outList) { outList.clear(); DWORD needed 0; DWORD returned 0; BOOL ok EnumPrintersW( PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS, nullptr, 2, nullptr, 0, needed, returned); if (!ok) { DWORD err GetLastError(); if (err ! ERROR_INSUFFICIENT_BUFFER) return false; } if (needed 0) return true; std::unique_ptrBYTE[] buffer(new BYTE[needed]); ok EnumPrintersW( PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS, nullptr, 2, buffer.get(), needed, needed, returned); if (!ok) return false; PRINTER_INFO_2* items reinterpret_castPRINTER_INFO_2*(buffer.get()); for (DWORD i 0; i returned; i) { outList.push_back(items[i]); } return true; }拿到 PRINTER_INFO_2 数组之后直接读 pDriverName 和 pDeviceName。注意一些驱动的字段可能是空指针读取前先判空for (const auto info : printers) { std::wstring printerName info.pPrinterName ? info.pPrinterName : L; std::wstring driverName info.pDriverName ? info.pDriverName : L; std::wstring portName info.pPortName ? info.pPortName : L; std::wstring deviceName info.pDeviceName ? info.pDeviceName : L; // 这里就是后续型号判断的原材料 }如果单台打印机需要重新查询最新信息可以用 OpenPrinter GetPrinter 再取一次 PRINTER_INFO_2。我遇到的场景中EnumPrinters 拿到的数据通常已经足够但某些远程共享打印机的字段可能在枚举时未被完整填充这时候单独再拉一次更稳。bool QueryPrinterInfo(const std::wstring printerName, PRINTER_INFO_2 outInfo) { HANDLE hPrinter nullptr; if (!OpenPrinterW(const_castLPWSTR(printerName.c_str()), hPrinter, nullptr)) return false; DWORD needed 0; GetPrinterW(hPrinter, 2, nullptr, 0, needed); std::unique_ptrBYTE[] buffer(new BYTE[needed]); BOOL ok GetPrinterW(hPrinter, 2, buffer.get(), needed, needed); ClosePrinter(hPrinter); if (!ok) return false; outInfo *reinterpret_castPRINTER_INFO_2*(buffer.get()); return true; }OpenPrinter 的权限参数第三个传 nullptr 即可默认使用最小访问权限普通权限下就能读。不需要 PROCESS_READ 之类的额外权限。2.3 宽字符到 UTF-8 的转换细节Windows 下所有打印 API 的字符串都是宽字符UTF-16。如果你的程序要写日志、写数据库、发 JSON最终难免要转 UTF-8。我见过很多同事直接用 WideCharToMultiByte 时把 CodePage 传成 CP_ACP导致中文打印机名在另一台机器上变成乱码。你要传 CP_UTF8。std::string WideToUtf8(const std::wstring input) { if (input.empty()) return std::string(); int len WideCharToMultiByte( CP_UTF8, 0, input.c_str(), static_castint(input.size()), nullptr, 0, nullptr, nullptr); std::string result(len, \0); WideCharToMultiByte( CP_UTF8, 0, input.c_str(), static_castint(input.size()), result[0], len, nullptr, nullptr); return result; }有个细节容易被忽略打印机名里可能出现首尾空格和系统保留字符比如“Printer (副本 2)”这种括号。后面拼注册表路径之前一定要 Trim否则注册表打开必然失败。这个坑我踩过不止一次。3. 再从注册表把驱动信息挖出来核对EnumPrinters 拿到的是系统 API 层的快照而打印后台服务在注册表里还维护了一套完整的驱动配置。如果你想验证某个驱动名到底对应哪个驱动文件或者想区分 Type 3 和 Type 4 驱动就需要去注册表里翻。3.1 打印服务把什么东西写进了注册表打印相关的注册表主要集中在两个位置逻辑打印机配置HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Print\Printers驱动安装信息HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Print\EnvironmentsPrinters 分支下每个子键对应一个打印机队列名键里保存了端口、驱动名、默认 DevMode 等。Environments 分支下则按体系结构和驱动版本存放驱动文件路径、依赖文件、数据文件等信息。普通打印 API 拿不到的部分这里能看到。理论上 EnumPrinters 里的 pDriverName 和注册表里的 PrinterDriver 应指向同一个驱动名但我在项目里遇过注册表键名和显示名不一致的情况。例如用户手动改了打印机队列名注册表子键名跟着变驱动键却还是原来的。所以注册表更适合做“佐证”不适合单独作为唯一数据源。3.2 读取 PrinterDriver 和驱动文件信息的关键代码读取单个打印机的注册表信息推荐用 RegGetValueW。它把 RegOpenKeyEx 和 RegQueryValueEx 合并了代码更短还能防止缓冲区不够时报奇怪的错。#include winreg.h bool ReadPrinterDriverFromRegistry(const std::wstring printerName, std::wstring driverName) { std::wstring subKey LSYSTEM\\CurrentControlSet\\Control\\Print\\Printers\\ Trim(printerName); WCHAR value[MAX_PATH 1] {}; DWORD size sizeof(value); DWORD type 0; LSTATUS status RegGetValueW( HKEY_LOCAL_MACHINE, subKey.c_str(), LPrinterDriver, RRF_RT_REG_SZ, type, value, size); if (status ! ERROR_SUCCESS) { // 有些系统字段叫 Driver而不是 PrinterDriver再试一次 size sizeof(value); status RegGetValueW( HKEY_LOCAL_MACHINE, subKey.c_str(), LDriver, RRF_RT_REG_SZ, type, value, size); if (status ! ERROR_SUCCESS) return false; } driverName Trim(value); return true; }为什么字段名可能不同因为不同品牌驱动在安装时会写入不同的附加键值有些驱动的打印机子键下甚至不保留明文驱动名。所以我的代码里做了两次尝试PrinterDriver 读不到就换 Driver。如果你要读驱动文件信息那需要拼驱动名去 Environments 分支再找一次大致路径是HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Print\Environments\Windows x64\Drivers\Version-3\驱动名这个键下的 Driver 值才是真正的驱动文件比如某个厂商的“vendor_2600.dll”。看到文件名的好处是当驱动名是“通用文本”这种含糊名称时你可以根据文件比较判断这套驱动是否专属于某个 OEM。3.3 注册表数据能解决什么问题不能解决什么问题注册表数据能解决的最典型问题是用户把打印机队列名改得面目全非EnumPrinters 的 pPrinterName 完全没有参考价值但注册表里的驱动键还在。通过驱动键可以还原出“这台打印机用的是什么驱动体系”再结合驱动名判断型号。注册表数据不能解决的是驱动名本身就是通用驱动的情况。比如“通用/纯文本Generic / Text Only”这个驱动安装多少台打印机注册表里都是同一个名字你无法从注册表区分具体机型。这种时候必须换数据源用下一章的 SetupAPI 或者接受人工维护一个映射表。在我看来注册表最值得用的时候是杀入驱动文件细节做交叉验证而不是指望它直接给你一个漂亮的型号字符串。4. 驱动名不等于型号时用 SetupAPI 和启发式规则兜底现实中你总会遇到 pDriverName 和 pDeviceName 都不像正经型号的情况。比如用户装的是一体机驱动名字是“厂商多功能一体机驱动”或者是传真驱动、PDF 虚拟驱动。到这一步方案就需要分层了。4.1 虚拟打印机、共享打印机、通用驱动怎么识别先处理最容易分辨的两类干扰。第一类是虚拟打印机。系统自带的 PDF 虚拟打印机驱动名里通常带着 PDF、XPS、Fax、Image 这类关键词。这些对应的是虚拟驱动不是物理设备。做硬件型号统计时它们必须被过滤掉否则最终报表里会出现大量“虚拟驱动”垃圾条目。判断方法很简单看驱动名的关键词bool IsVirtualPrinter(const std::wstring driverName) { std::wstring lower driverName; for (auto ch : lower) { if (ch LA ch LZ) ch (La - LA); } if (lower.find(Lpdf) ! std::wstring::npos) return true; if (lower.find(Lfax) ! std::wstring::npos) return true; if (lower.find(Lxps) ! std::wstring::npos) return true; if (lower.find(Limage) ! std::wstring::npos) return true; if (lower.find(Lbsod) ! std::wstring::npos) return true; // 某些特殊直通驱动 if (lower.find(Ltext) ! std::wstring::npos) return true; // 通用文本 return false; }这里的判断只是启发式不是绝对准确。开会遇到某个厂商的“传真驱动”其实也能打印物理设备那就看你统计的目标是不是“实体打印机型号”了。我通常做成配置项关键词列表可以由部署人员自行增删。第二类是网络共享打印机。EnumPrinters 拿到的 pPrinterName 可能是“\某台主机\打印机共享名”这种队列的驱动信息在远程电脑上本地注册表读不到。对待它们有两种策略一种是直接跳过在网络打印机设备清单里单独用 SNMP 查询另一种是把共享名当作线索尝试用共享名去匹配设备型号。实际项目中后者的成功率有限因为共享名经常是“HPLJ”这种缩写得不偿失。4.2 SetupAPI 枚举打印机物理设备描述当驱动名是通用驱动或驱动名与型号不一致时还有一个更强的数据源Windows 设备管理器里的“打印机类设备”。SetupAPI 可以枚举出当前系统里所有在设备树中注册的打印机设备SPDRP_DEVICEDESC 属性通常就是“厂商型号”格式。这段代码的关键是打印机类 GUID它在设备管理器中对应“打印机”类别。枚举之后逐个读设备描述即可#include setupapi.h #include devguid.h #pragma comment(lib, setupapi.lib) void EnumeratePrinterDevices() { // 打印机类 GUID const GUID printerClassGuid { 0x4d36e979, 0xe325, 0x11ce, { 0xbf, 0xc1, 0x08, 0x00, 0x2b, 0xe1, 0x03, 0x18 } }; HDEVINFO devInfoSet SetupDiGetClassDevs( printerClassGuid, nullptr, nullptr, DIGCF_PRESENT); if (devInfoSet INVALID_HANDLE_VALUE) return; SP_DEVINFO_DATA devInfoData {}; devInfoData.cbSize sizeof(SP_DEVINFO_DATA); for (DWORD index 0; SetupDiEnumDeviceInfo(devInfoSet, index, devInfoData); index) { WCHAR desc[512] {}; if (SetupDiGetDeviceRegistryPropertyW( devInfoSet, devInfoData, SPDRP_DEVICEDESC, nullptr, reinterpret_castBYTE*(desc), sizeof(desc), nullptr)) { // desc 就是设备管理器里的名称通常比驱动名干净 } WCHAR instanceId[512] {}; if (SetupDiGetDeviceInstanceIdW( devInfoSet, devInfoData, instanceId, ARRAYSIZE(instanceId), nullptr)) { // instanceId 可以用于进一步匹配 USB VID/PID 或网络设备 ID } } SetupDiDestroyDeviceInfoList(devInfoSet); }要注意的是SetupAPI 枚举的是物理设备树里的“打印机类设备”。虚拟打印机和纯软件驱动的在这棵树上往往不出现这正好符合我们过滤虚拟打印机的需求。不过它和打印机队列不是一一对应的你很难直接把某个枚举回来的设备 ID 精确映射到某个队列名上。所以我的用法是先通过队列信息拿到驱动名如果驱动名太笼统再用 SetupAPI 拿到的设备描述列表去做模糊匹配看设备描述里是否有关键型号字符串。4.3 一条通用的“型号提取”兜底流程归纳起来我在项目里用的提取流程是这样的先取 PRINTER_INFO_2.pDriverName清洗一下首尾空格。如果驱动名符合虚拟打印机关键词直接打标“虚拟设备”不再走后面流程。如果驱动名不是“Generic / Text Only”这类通用驱动就优先把驱动名作为型号展示。如果驱动名太通用取 pDeviceName。虽然它不一定准但至少多一个候选。如果 pDeviceName 也没有尝试注册表 PrinterDriver 字段。最后兜底用 SetupAPI 枚举打印机设备描述看能否匹配到更干净的型号。这套流程不能保证 100% 精确但已经能覆盖绝大多数办公场景。真正需要绝对精确时只能绕到 SNMP 或厂商私有协议去查询打印机面板信息那通常需要硬件厂商的协议文档和通讯工具不在轻量级应用能解决的范围内。5. 实测踩坑清单权限、缓存、编码和命名陷阱最后这部分是我觉得最有价值的因为上面那些代码单独看都能跑通但放进真实项目里总会遇到几个意料之外的问题。我按踩坑频率排序写下来你提前预防能省很多事。5.1 最容易被坑的注册表访问与重定向问题读打印信息时EnumPrinters 这类公开 API 普通用户能调用但注册表 HKLM 分支不保证可读。在某个办公网络项目里我用普通域账户跑巡检程序EnumPrinters 正常返回列表RegGetValueW 却一直返回 ERROR_ACCESS_DENIED。原因就是注册表路径下某些子键对非管理员只开放了读取权限而打印机子键还带着用户安全描述符触发了访问检查。解决方案有三个层次最省事的是让程序在管理员权限或 SYSTEM 权限下运行其次是捕获 ERROR_ACCESS_DENIED 后降级只依赖公开 API 字段第三是改用 WMI 的 Win32_PrinterWMI 对普通账户的可见性通常比注册表宽松。但 WMI 引入 COM 初始化代码量会增加我自己只把注册表作为管理员增强项不是必须路径。还有一个 32 位/64 位程序的重定向问题。如果程序以 32 位模式编译访问 HKLM 某些子键时会被重定向到 Wow6432Node。打印相关分支我没有遇到过被重定向的问题但如果你在别的键上踩到找不到数据可以显式使用 KEY_WOW64_64KEY 打开绕开重定向视图。5.2 打印机名变化、服务重启与缓存问题打印机的逻辑名是用户可以随时改的。程序一旦缓存了某个打印机名下次拿这个名字去拼注册表路径很可能已经不存在。所以建议每次统计任务开始时都重新 EnumPrinters不要长时间缓存队列列表。另一个经常被忽略的是打印后台服务spoolsv的状态。如果服务没启动EnumPrinters 返回失败或空列表。你排查程序为什么拿不到打印机时第一件事应该去检查服务状态而不是怀疑代码逻辑。我在调试时用过最简单的一招先命令符执行net start spoolsv服务起来后再跑测试程序确认问题是不是服务环境引起。如果你用 OpenPrinter 打开了句柄但是打印机队列被删除或服务重启句柄会失效。后续调用 GetPrinter 会返回 ERROR_INVALID_HANDLE 或 RPC 错误。代码里要处理这种“句柄可能已失效”的情况重新打开而不是直接退出。5.3 编码问题为什么日志里全是乱码打印 API 返回的都是 UTF-16而大多数日志系统和数据库表用的是 UTF-8。如果你转换时用了 CP_ACP在中文系统上可能碰巧正常但换到英文系统、日文系统转换出来的字符串会出现“锟斤拷”这类经典乱码。统一使用 CP_UTF8 可以规避大部分问题。还有一个隐性坑打印机名里可能包含不可见空格和特殊字符比如“打印机 (1)”里的括号。这些字符在显示时没问题但拼接到注册表路径或 JSON 字符串时会导致解析失败。我的习惯是所有字符串从 API 拿出来后第一件事就是 Trim 并过滤掉换行和制表符。另外如果你要把型号信息写到共享配置文件里建议存两份一份原始驱动名一份清洗后的型号。原始驱动名保留审计意义清洗后的型号用于报表展示。千万不要只存清洗结果否则后面排查问题时根本不知道原始数据长什么样。最后再分享一个我实际项目中的习惯给统计程序增加一个“是否包含虚拟打印机”的开关默认关闭。因为很多终端机器上用户会装各种 PDF 虚拟驱动统计硬件型号时它们不但没有参考价值还会把型号分布拉得特别乱。这个开关看起来只是个布尔值但真正部署后会发现比很多技术细节都省心。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →