
最近在给一个打印运维工具做功能增强客户提了一个看起来非常常规的需求基于 VC 开发一个模块通过打印机驱动直接获取打印机型号用来做资产登记和设备状态联动。这个需求乍一听很容易翻一翻 Windows SDK 文档就能搞定但实际操作下来我发现坑不少不同版本的 Windows 打印驱动架构不同本地打印机和网络打印机的驱动信息存放位置不一样甚至同一个驱动在 32 位和 64 位系统上拿到的名称都可能带后缀。这篇文章就把我踩过的路整理一下重点讲清楚三种做法——EnumPrinters 枚举驱动信息、OpenPrinter/DocumentProperties 读取 DEVMODE、注册表加 WMI 兜底用 VC 具体怎么实现以及每种方案的适用场景和坑。先说一句结论如果你要的只是界面展示级别的“打印机型号”用 EnumPrinters 拿驱动名就够了如果要做资产盘点、耗材联动这种需要和实物对上号的场景就必须结合端口信息、DEVMODE 甚至 WMI 物理设备信息一起判断。下面我从最基础的 API 调用开始拆解。1. 先搞清楚“打印机型号”在 Windows 打印体系里是什么1.1 打印队列、打印驱动和物理设备的三层关系Windows 中用户在“设备和打印机”里看到的“打印机”其实是一个逻辑打印队列并不直接对应物理设备。可以把它理解为“系统里注册的一个打印任务入口”用户向这个入口提交文档打印后台处理程序Spooler再把任务交给打印驱动最后通过端口发送到物理设备。打印驱动是真正和硬件打交道的组件它负责把 GDI 指令翻译成设备能理解的页面描述语言PCL、PostScript、ESC/P 等。驱动安装时会注册一系列信息驱动名称、驱动版本、默认 DEVMODE、关联端口、打印机处理器等。所以“通过打印机驱动获取打印机型号”本质上就是去读这些注册信息。但这里有个容易混淆的点Windows 打印体系里的“型号”在不同层级含义完全不同。数据层面典型内容是否能代表物理型号打印机队列名“HP LaserJet P1108 (副本 1)”不一定可能是自定义名称驱动名 DriverName“HP LaserJet P1108”基本都是品牌型号可作为型号参考DEVMODE 的 dmDeviceName“HP LaserJet P1108”同上注册表驱动键值驱动文件、厂商信息可以间接推断WMI/PNP 设备名“USB Printing Support” 或真实设备描述可能直接对应物理型号实际开发中很多同事第一次拿 EnumPrinters 枚举出来的pPrinterName当型号用结果用户把打印机改名为“前台激光”UI 上就显示成“前台激光”完全对不上号。这是最常见的错误队列名是用户可以随便改的驱动名才是安装驱动时写死的一般情况下驱动名里会带上完整型号这才是你首要的“型号数据源”。1.2 不同业务场景下的“型号”含义做这个功能之前你得确认客户到底要哪一个“型号”。我遇到过三类不同需求对应不同的取数路径第一类是纯展示需求比如在 OA 系统里列出每台电脑装了什么打印机告诉用户“你有一台 HP LaserJet P1108”。这种场景下用驱动名就够了不需要管物理端口。第二类是运维监控需求需要知道打印机的连接状态、是否离线、端口是否正常。此时除了驱动名还要拿端口名、驱动版本、打印机状态。对应的方法是OpenPrinterGetPrinter读取PRINTER_INFO_2里面字段齐全。第三类是资产盘点、耗材预测这种要和实物对应的场景。这时候驱动名只能作为参考因为同一个型号可能使用不同端口或者一台电脑上装了两个同名打印机。必须用端口名去匹配物理设备甚至用 WMI 的Win32_PnPEntity枚举 USB 设备来反向关联。我建议任何项目都先把这三层拆清楚再动手写代码否则后面返工成本很高。2. 方案一EnumPrinters 枚举队列时直接读取驱动名2.1 为什么先选 EnumPrintersEnumPrinters是 Windows 打印 API 里最基础的枚举函数支持枚举本地打印机、网络打印机、域共享打印机等。它的优势是调用简单、跨平台版本稳定从 Windows 2000 到 Windows 11 行为基本一致。另一个原因是它一次可以拿回完整的PRINTER_INFO_2结构里面包含pPrinterName、pDriverName、pPortName、pLocation等关键信息省得逐个再开句柄查询。函数原型很简单BOOL EnumPrintersW( DWORD Flags, LPWSTR Name, DWORD Level, LPBYTE pPrinterEnum, DWORD cbBuf, LPDWORD pcbNeeded, LPDWORD pcReturned );关键参数是Flags。常用组合是PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS | PRINTER_ENUM_NETWORK。PRINTER_ENUM_LOCAL枚举本机安装的打印机包括 USB 和并口PRINTER_ENUM_CONNECTIONS枚举通过 UNC 路径连接的网络共享打印机PRINTER_ENUM_NETWORK枚举当前域/工作组里可用的网络打印机需要一定权限速度也可能慢。2.2 枚举代码一次拿到驱动名和端口名下面这段代码我在 VC 6.0 和 VS2008 时代就一直用后来升级到 VS2019 也没改过核心逻辑。它遍历所有本地和网络打印机把pDriverName和pPortName打出来#include windows.h #include winspool.h #include vector #include string #include iostream #pragma comment(lib, winspool.lib) void EnumPrintersWithDriver() { DWORD flags PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS; DWORD needed 0; DWORD returned 0; // 第一次调用获取缓冲区大小 EnumPrintersW(flags, NULL, 2, NULL, 0, needed, returned); if (needed 0) return; std::vectorBYTE buffer(needed); if (!EnumPrintersW(flags, NULL, 2, buffer.data(), needed, needed, returned)) return; PRINTER_INFO_2W* info reinterpret_castPRINTER_INFO_2W*(buffer.data()); for (DWORD i 0; i returned; i) { std::wstring printerName info[i].pPrinterName ? info[i].pPrinterName : L; std::wstring driverName info[i].pDriverName ? info[i].pDriverName : L; std::wstring portName info[i].pPortName ? info[i].pPortName : L; std::wcout L队列名: printerName std::endl; std::wcout L驱动名: driverName std::endl; std::wcout L端口: portName std::endl; std::wcout L--- std::endl; } }注意PRINTER_INFO_2W结构里所有字符串都是LPWSTR所以在宽字符工程下直接用std::wstring接收最稳。如果你是 ANSI 工程可以把EnumPrintersW换成EnumPrintersA或者干脆把工程字符集改成 Unicode后面所有打印 API 都用 W 版本避免中文打印机名乱码。2.3 网络打印机的驱动信息归属问题这里有个特别容易踩的坑用PRINTER_ENUM_CONNECTIONS枚举到的网络共享打印机拿到的pDriverName是本机解释器里的驱动名但并不一定和服务器上完全一致。比如服务器端装的是“HP Universal Printing PCL 6”客户端连接共享时可能自动装了一个精简驱动驱动名就变成“HP LaserJet P1108”或者“HP LaserJet Professional P1100 series”同一个物理设备在不同机器上枚举结果会不同。所以做资产统计时网络打印机的驱动名只能参考不能完全当真。如果你需要知道网络打印机的真实驱动名更准确的做法是直接访问打印服务器的注册表或使用 SNMP 查询设备硬件信息。但这两个方案在客户环境里往往受限于权限不一定能打通。我的经验是先拿到pPortName如果端口名是WSD-xxxx或者IP_xxx.xxx.xxx.xxx说明是网络打印机可以尝试连接 9100 端口走 PJL 指令问设备型号如果端口名是USB001、USB002这种就按本地设备处理。2.4 这个方案的局限EnumPrinters能给你驱动名但它拿不到 DEVMODE 里的自定义属性也拿不到驱动文件的版本详细信息。也就是说你只能知道“这台机器上装了什么驱动模型的打印机”没法进一步确认当前用户的打印配置纸张大小、颜色管理、双面设置等。而如果你需要判断驱动是否异常、是否需要更新或者需要读取驱动支持的附加能力就得用到方案二。3. 方案二OpenPrinter DocumentProperties 读取 DEVMODE3.1 DEVMODE 是什么DEVMODE 是打印机驱动的“配置存档”里面保存了打印机当前使用的所有可打印参数纸张尺寸、打印分辨率、色彩模式、双面打印、进纸盒、打印质量等。驱动会在用户修改打印首选项时更新这份结构并在按打印作业时把它嵌到任务数据里。获取 DEVMODE 的标准姿势是先OpenPrinter打开打印机句柄再调用DocumentProperties。这个函数的返回值很特别第一次调用时你传NULL作为pDevModeOutput它会返回所需的缓冲区大小第二次用这个大小分配内存后再调用它才会真正填充 DEVMODE。很多人第一次写就栽在这里没做两阶段调用就直接填 NULL 缓冲区导致函数返回 0 或报错。3.2 获取 DEVMODE 的完整代码这是我在项目里实际使用的函数兼容 Windows 7 到 Windows 11。核心点是fMode参数DM_OUT_BUFFER表示让系统从打印机驱动读取当前默认配置如果你后续想修改配置并写回再加DM_IN_BUFFERbool GetPrinterDevMode(const std::wstring printerName, std::vectorBYTE devModeBuffer) { HANDLE hPrinter NULL; if (!OpenPrinterW(const_castLPWSTR(printerName.c_str()), hPrinter, NULL)) { return false; } // 第一次调用获取 DEVMODE 缓冲区大小 DWORD devModeSize DocumentPropertiesW(NULL, hPrinter, const_castLPWSTR(printerName.c_str()), NULL, NULL, 0); if (devModeSize 0) { ClosePrinter(hPrinter); return false; } devModeBuffer.resize(devModeSize); DEVMODEW* pDevMode reinterpret_castDEVMODEW*(devModeBuffer.data()); // 第二次调用填充实际内容 LONG result DocumentPropertiesW(NULL, hPrinter, const_castLPWSTR(printerName.c_str()), pDevMode, pDevMode, DM_OUT_BUFFER); ClosePrinter(hPrinter); return (result IDOK); }然后解析结构体里的字段void ReadDevMode(const std::vectorBYTE devModeBuffer, std::wstring deviceName, DWORD version, DWORD color, short paperWidth, short paperHeight) { DEVMODEW* pDevMode reinterpret_castDEVMODEW*( const_castBYTE*(devModeBuffer.data())); // dmDeviceName 是固定的 32 字节 ANSI 数组 // 在宽字符工程里要先转成宽字符再 wcout deviceName reinterpret_castconst char*(pDevMode-dmDeviceName); version pDevMode-dmSpecVersion; color pDevMode-dmColor; paperWidth pDevMode-dmPaperWidth; // 单位是毫米的1/10 paperHeight pDevMode-dmPaperLength; }这里有个细节DEVMODEW的dmDeviceName字段定义是WCHAR dmDeviceName[CCHDEVICENAME]也就是固定 32 个宽字符它包含的是驱动名称不是常规意义上的“型号”。但因为它其实就是驱动名快照所以拿到它等价于拿到了型号参考。3.3 什么时候必须用 DEVMODE如果项目里不仅要型号还要判断当前打印机是否处于“可打印”状态、纸张大小是否异常、或者要修改打印首选项DEVMODE 就是绕不开的中间载体。比如我做耗材预测时需要通过 DEVMODE 里的dmDuplex判断这台打印机是否支持自动双面再结合硬件型号决定是否给出双面打印建议。另外DEVMODE 还有一个隐藏用法部分驱动会把自定义扩展数据放在dmDriverExtra指向的额外区域这些附加数据里可能包含驱动独有的属性比如惠普驱动的“Print Quality”扩展项、爱普生驱动的“MicroWeave”设置。解析这些扩展需要参考厂商的 OEM 文档水比较深通常只有做打印驱动兼容测试的人才需要普通应用开发不用碰。3.4 容易犯的初始化错误我见过不少人直接这么写DEVMODEW devMode; DocumentPropertiesW(NULL, hPrinter, name, devMode, devMode, DM_OUT_BUFFER);看起来没问题对吧但DEVMODEW前面有dmDriverExtra可变长度部分结构体本身的大小是固定的驱动如果需要额外字节来存放私有数据这个固定大小的栈变量就装不下。轻则数据被截断重则驱动代码越界写内存导致崩溃。正确做法永远是先用一次返回 0 参数调用来获取完整大小再分配堆内存。这是 DEVMODE 系列 API 的经典坑不要省那一次调用。3.5 权限与返回值排查DocumentPropertiesW返回IDOK表示成功返回0表示出错。最常见的原因是OpenPrinter时参数pPrinterName传了中文队列名但用的是 ANSI 版本在 Unicode 工程下没有加L前缀导致编码信息丢失。还有一种是打印机驱动损坏DocumentProperties拿不到默认配置这时候它会返回0但不是ERROR_INVALID_PRINTER_NAME那种明确错误。我的处理思路是先查后台打印服务是否运行、再检查驱动服务是否注册最后用GetLastError看具体错误码。4. 方案三注册表驱动节点与 WMI 物理设备兜底4.1 打印驱动在注册表里的分布规律Windows 打印驱动信息的根路径有两个打印机配置在HKLM\SYSTEM\CurrentControlSet\Control\Print\Printers\PrinterName驱动文件注册在HKLM\SYSTEM\CurrentControlSet\Control\Print\Environments\Windows x64\Drivers\Version-x\DriverName。这里的Version-x在不同系统版本上数字不同比如 Windows 7 是Version-3Windows 10/11 是Version-4。别写死要用枚举或查找目录的方式遍历。读注册表的好处是当EnumPrinters因为后台打印服务异常拿不到数据时注册表里的驱动信息可能还在可以作为兜底。但坏处是注册表的路径和打印机名称大小写敏感中文名和特殊字符转义容易出问题而且 64 位系统下还要小心 WOW64 注册表重定向。我不建议把它作为首选方案可以作为诊断工具。4.2 用注册表读驱动版本的代码下面的代码读取指定打印机注册表项里的Printer Driver值再根据驱动名去驱动目录里找版本信息#include winreg.h bool ReadPrinterDriverFromRegistry(const std::wstring printerName, std::wstring driverName) { std::wstring regPath LSYSTEM\\CurrentControlSet\\Control\\Print\\Printers\\ printerName; HKEY hKey NULL; LONG result RegOpenKeyExW(HKEY_LOCAL_MACHINE, regPath.c_str(), 0, KEY_READ | KEY_WOW64_64KEY, hKey); if (result ! ERROR_SUCCESS) return false; WCHAR value[256] {0}; DWORD size sizeof(value); result RegQueryValueExW(hKey, LPrinter Driver, NULL, NULL, reinterpret_castLPBYTE(value), size); if (result ERROR_SUCCESS) driverName value; RegCloseKey(hKey); return (result ERROR_SUCCESS); }注意KEY_WOW64_64KEY这个标志很关键。如果你的程序是 32 位编译在 64 位 Windows 上不指定这个标志注册表会被重定向到WOW6432Node很可能读不到真实的 64 位驱动信息。反过来如果你写的是 64 位工具要读 32 位驱动信息反而需要KEY_WOW64_32KEY。这个细节在开发工具类软件时特别容易出问题建议统一用KEY_WOW64_64KEY。4.3 用 WMI 拿物理设备型号当驱动名不够准确时最后一道兜底是 WMI。Win32_Printer类能拿到驱动名和端口名Win32_PnPEntity能拿到 USB、网络接口等物理设备描述。两者通过端口名或者 DeviceID 关联通常能得到比驱动名更精确的硬件信息。核心调用逻辑如下为了简洁省略了 COM 初始化和错误校验实际代码需要加上#include comdef.h #include Wbemidl.h #pragma comment(lib, wbemuuid.lib) void QueryPrinterViaWMI() { CoInitializeEx(NULL, COINIT_MULTITHREADED); IWbemLocator* pLocator NULL; IWbemServices* pService NULL; CoCreateInstance(CLSID_WbemLocator, NULL, CLSCTX_INPROC_SERVER, IID_IWbemLocator, (void**)pLocator); pLocator-ConnectServer(_bstr_t(LROOT\\CIMV2), NULL, NULL, 0, NULL, 0, 0, pService); IEnumWbemClassObject* pEnum NULL; pService-ExecQuery(LWQL, LSELECT DriverName, PortName, DeviceID FROM Win32_Printer, WBEM_FLAG_FORWARD_ONLY, NULL, pEnum); // 循环枚举对象从 IWbemClassObject 取字段值 // DriverName - 驱动名 // PortName - 端口名如 USB001、IP_192.168.1.10 // DeviceID - 打印机队列名 // 再用 Win32_PnPEntity 查询 USB 设备名称 // SELECT Name, DeviceID, PNPClass FROM Win32_PnPEntity WHERE PNPClass Printer pEnum-Release(); pService-Release(); pLocator-Release(); CoUninitialize(); }实际使用时你可能发现Win32_Printer查出来的DriverName和EnumPrinters一致额外有价值的是PortName和DeviceID。再配合Win32_PnPEntity能拿到类似“HP LaserJet Professional P1100 Series”这样的物理设备名称这个名称通常比驱动名更接近机器铭牌上的型号。4.4 三种方案如何组合我项目里的最终策略是先跑EnumPrinters拿到队列集合 → 对每个队列调用GetPrinter拿驱动名和端口名 → 再对本地端口USB、LPT用 WMI 关联物理设备 → 如果 WMI 查不到最后才读注册表把驱动版本和文件厂商补齐。这样既能保证型号信息完整也能应对大多数客户机器上驱动损坏、后台打印服务未启动的异常情况。方案获取内容可靠性权限要求复杂度EnumPrinters队列名、驱动名、端口名高普通用户可读低OpenPrinterDEVMODE驱动名详细配置中高普通用户可读中注册表驱动注册信息中受 WOW64 影响中WMI物理设备名、端口关联中高普通用户部分需提权高5. 常见问题与排查实录5.1 OpenPrinter 返回失败GetLastError 是 5错误码 5 是“拒绝访问”。最常见的原因是目标打印机属于其他用户会话的会话打印机或者打印机队列权限被改过。排查思路先用Net Use确认是否有连接权限如果是服务程序要看服务账号是否加入了Print Operators组如果只是普通桌面应用大概率是用户对那台打印机没有PRINTER_ACCESS_USE权限。还有一种隐蔽情况你传入的打印机名末尾有空格OpenPrinter不自动 trim 字符串拼接队列名时注意去掉首尾空白。我上次排查了整整半天最后发现是配置文件的换行符带进了打印机名里。5.2 枚举出来的驱动名全是英文但实物是中文型号这是正常的。驱动名是厂商在 INF 里定义的全球统一的驱动安装名比如“HP LaserJet 1020”就是英文。中文型号只是经销商给的营销名称或者用户自己改的队列名。如果你一定要显示中文名只能建一张“驱动名 → 中文型号”映射表或者让用户在配置界面里手动维护。不要试图从系统层自动推导Windows 没有这个映射关系。5.3 拿到驱动名后想再拿驱动版本号驱动版本可以通过GetPrinter第二个级别PRINTER_INFO_2拿pDriverPath然后用GetFileVersionInfo查文件的版本信息。但注意打印机的pDriverPath往往指向微软默认的UNIDRV.DLL真正的厂商模块在pDriverName对应的驱动目录下。要拿到厂商驱动版本更稳的是读注册表驱动目录里的V3或V4子键或者直接在驱动目录下找*.dll的版本资源。5.4 怎么判断打印机驱动是不是有问题这个问题我很早之前写过一段脚本思路用EnumPrinters枚举到的打印机分别调用OpenPrinter、GetPrinter、DocumentProperties。如果OpenPrinter成功但GetPrinter失败大概率是后台打印服务 Spooler 的缓存文件损坏如果GetPrinter成功但DocumentProperties失败多半是驱动本身的配置节PrinterDriver键没写完整。把这三步的返回码记录下来就能定位驱动问题出在哪个环节。5.5 64 位系统下枚举结果和 32 位工具不一致这是我在交付完整版工具时真正踩过的一个大坑。客户 IT 用 64 位系统装了一台 32 位驱动共享的打印机我的 32 位诊断程序枚举出来只有一个半截驱动名64 位进程看到的却完全正常。后来确认是 32 位进程在 64 位系统上访问HKLM\SYSTEM\CurrentControlSet\Control\Print\Printers时被 WOW64 重定向打印驱动目录也发生了重定向。解决方式是枚举和注册表读取都统一走 64 位代码或者编译两个版本避免交叉读取。6. 工程配置与跨版本移植要点从 VC 6.0 到 VS20226.1 字符集选择是第一关如果你是老项目从 VC 6.0 / VS2008VC 9.0升级上来第一件事就是把工程字符集从“使用多字节字符集”改成“使用 Unicode 字符集”。打印 API 的 A 版本在中文系统上有历史遗留 bug某些打印机名的 ANSI 转换会丢字符。改 Unicode 之后统一用W后缀函数TCHAR宏可以保留但内部字符串一律std::wstring。VS2008 时代没有std::vectorBYTE.data()这种写法老代码里通常写buffer[0]或者buffer.begin()来拿指针。升级到 VS2017 之后可以逐步换成.data()不过我这里给出的示例代码为了兼容性使用了在较新标准下也通用的写法。如果你还在 VC 6.0 上跑用buffer[0]代替buffer.data()即可数组内存连续性是一样的。6.2 链接库和头文件打印 API 的主头文件是winspool.h函数实现在winspool.lib或者winspool.drv。你可以在工程里加#pragma comment(lib, winspool.lib)也可以直接在项目设置里链接。如果用到DocumentProperties它在头文件wingdi.h里有声明但打印相关的定义还是在winspool.h。老工程如果找不到DocumentPropertiesW检查是否包含了wingdi.h并且定义了WINVER。WMI 相关的查询需要链接wbemuuid.lib头文件Wbemidl.h。这部分代码晦涩建议封装成独立模块不要和打印枚举逻辑混在一起。6.3 管理员权限与 UAC很多打印信息读取并不需要管理员权限普通用户也能枚举本机打印机。但如果你要读取其他用户安装的会话打印机Session-level printer就需要 SYSTEM 或管理员权限。实际交付的时候我给客户端的主程序加了 manifest 请求asInvoker只有诊断功能才通过提权进程执行避免每次运行都弹 UAC。很多人想在普通权限服务里枚举所有用户的打印机这是做不到的打印队列是分 Session 隔离的。6.4 从 VS2008 工程升级到新版本时的常见编译错误升级时最常见的是DWORD_PTR和DWORD混淆特别是PRINTER_INFO_2W里的pServerName在 64 位编译时是LPWSTR不是你想象的两个 32 位字段。另外sizeof(DEVMODEW)在不同编译器版本下可能有 padding 差异直接把它写入文件做持久化存储会导致版本不兼容跨机器传输 DEVMODE 时最好用DocumentProperties返回的动态大小不要用sizeof。还有个容易忽视的点新版本 Windows SDK 增加了PRINTER_INFO_3W、PRINTER_INFO_4W等扩展结构有些老代码把PRINTER_INFO_2W强行转换成别的高级结构遇到驱动返回异常就会越界。升级时别为了“优化”乱改结构体类型保持原有逻辑最稳。6.5 多线程调用打印 API 的注意事项打印 API 里除了OpenPrinter、EnumPrinters这种句柄型操作相对安全DocumentProperties内部会访问驱动配置不是严格线程安全的。我遇到过两个线程同时枚举打印机时偶发崩溃后来给整个打印模块加了一个全局锁所有打印 API 调用串行化问题立刻消失。如果你的工具要同时反射多个打印机最好用线程池加互斥量而不是无脑并行。最后再分享一个实用技巧如果只是想临时确认本机驱动是否正常完全不用写代码PowerShell 一条命令就够Get-Printer | Select-Object Name, DriverName, PortName但如果你想做的是批量采集、远程调用或者嵌入到运维平台里那上面这套 C 方案才是可以落地的。真让我给代码复杂度排个名EnumPrintersGetPrinter是基础必会DEVMODE 是进阶加分项WMI 是疑难问题兜底工具。把这几个 API 组合起来应付绝大多数“获取打印机型号”的需求都够用了。