简介:面向需要以VC++控制Zebra GT800打印机的Windows开发者,这份7z资源包提供了一套完整的USB通信与ZPL条形码打印工程示例,内含MyTest解决方案文件,可直接用Visual Studio打开编译。包内共24个文件,以cpp/h源文件、sln/vcxproj项目文件为主,附带tlog、log调试记录以及db、suo等工程缓存,既可还原开发环境,也能参考当时的关键调试信息。示例使用SetupDiGetClassDevs枚举打印机设备,借助CreateFile打开端口,再通过WriteFile发送ZPL指令,涵盖从初始化、配置到关闭句柄的完整链路;代码被封装成ZebraPrinter类,可快速迁移到物流、仓储、零售等需要打印自定义标签的场景。已有672人学习,适合希望避开.NET依赖、在C++层面实现底层打印机控制的开发者,作为从零接入GT800及类似型号的参考实现。 单位货架上的斑马 GT800 用了快五年,打印头换过两次,驱动版本换过三代。最后我干脆用 VC++ 写了一个小模块,直接把 ZPL 指令通过 USB 通道发给打印机,从点击打印到标签出纸不到两秒,还绕开了 Windows 图形打印那套黑盒。今天把这条链路完整拆开:GT800 为什么适合跑 ZPL、ZPL 指令的基本骨架、USB 连接到底怎么选驱动、VC++ 里用哪些 API 发送、以及我在实测中踩过的一堆坑。文章适合正在用 VC++/MFC 做上位机、MES、仓储扫码打印功能的朋友,看完可以直接把代码搬到自己的工程里。
1. 为什么是 GT800 + ZPL + USB 这套组合
1.1 这套组合在解决什么问题
很多工厂和仓库里的条码打印需求,并不是打开一个打印软件手动输入,而是由后台程序在出入库、工序流转、包装扫码的时候自动触发。这个时候程序需要把动态生成的条码内容(比如订单号、序列号、日期)实时传给打印机。
我用 VC++ 的原因很直接:老项目是 MFC 写的,维护了很多年,不能因为一个打印需求就整个推翻重来。新模块只要能往打印机里塞数据就行。而 GT800 恰好是市面上存量非常大的桌面级条码打印机,耗材便宜,驱动成熟,接口也有 USB,非常适合做这种程序化的打印终端。
1.2 GT800 的硬件底细
GT800 是 Zebra 的入门桌面打印机,关键参数大概是这些:
- 打印分辨率:203 dpi,也就是 8 个点每毫米
- 最大打印宽度:104 mm
- 指令集:ZPL II 和 EPL2 双模式
- 连接方式:前面板标准方口 USB,部分版本带串口和并口
- 打印方式:热敏或热转印(取决于具体型号和装没装碳带)
203 dpi 听起来不高,但打印码制简单的 Code 128、EAN-13 完全够用。GT800 真正值钱的地方是对 ZPL II 的支持非常稳定,指令解析干净利落,不像某些贴牌打印机对 ZPL 命令支持得七零八落。
1.3 为什么用 ZPL 而不是 GDI 绘图打印
用 Windows 原生的 GDI 方式打印条码也可以,就是拿到打印机 DC,调用TextOut、DrawText之类的函数把内容画到页面上,让打印机驱动解析成图形输出。这个方案最大的问题在于:条码的模块宽度、宽窄比、静区这些参数被 Windows 打印系统“翻译”过一次之后,经常走样,打出来的条码普通激光打印机能扫,但低端条码打印机上就容易出现扫不出来的情况。
ZPL 则完全不同。它是一套接近“裸指令”的文本协议,坐标、高度、模块宽度全都不经过驱动解析,直接以 1 dot 为最小单位发给打印机固件。打印出来的条码是否合格,只取决于你指令里写的参数,跟 Windows 驱动、分辨率缩放毫无关系。所以只要标签模板验证过一次,后面几千几万张都不会跑偏。
2. 先搞懂 ZPL 指令骨架,再动手写代码
2.1 最小可用的 ZPL 标签结构
ZPL 的一条完整指令被^XA开始、^XZ结束包起来。^XA可以理解成“新建一个标签文档”,^XZ就是“文档结束,开始打印”。
最简单的 GT800 标签长这样:
^XA ^PW640 ^LL400 ^FO40,30 ^A0N,32,36 ^FDHELLO GT800 ^FS ^XZ其中:
^PW640表示标签宽度 640 dot^LL400表示标签长度 400 dot^FO40,30是字段起点坐标,x=40 dot,y=30 dot^A0N,32,36是字体命令,A0 是内置字体,N 表示不旋转,32 是字体高度,36 是字体宽度^FDHELLO GT800是实际要打印的内容,遇到^FS表示这个字段结束
^XA和^XZ中间可以放任意多个字段,打印机会依次解析。
2.2 条码字段常用的几条指令
如果只是打印文本,ZPL 学起来很快。真正重要的是条码字段,因为条码参数一旦写错,浪费的是整卷不干胶标签纸。条码字段通常会配合^BY和^BC一起使用:
^BY2,2,100 ^BCN,100,Y,N,N ^FD202406050001 ^FS^BY2,2,100:条码模块宽度 2 dot,宽窄比 2:1,条码高度 100 dot^BCN,100,Y,N,N:Code 128 条码,方向不旋转,高度 100,在条码下方打印可读字符,不打印上方可读字符,不启用 UCC 校验位^FD202406050001:条码内容
如果条码内容是中文,还需要在前面加^CI28让打印机按 UTF-8 字符集解析后续字段:
^CI28 ^FO40,150 ^A0N,32,36 ^FD中文字段测试 ^FS^CI28会一直生效到当前标签结束,建议放在^XA后面作为全局设置。
2.3 DPI 和坐标换算:不要凭感觉填坐标
GT800 是 203 dpi,也就是 8 dot/mm。这是一个非常关键的换算基数。比如你要在 x=12mm、y=8mm 的位置打印内容,那么:
- x 方向:12 mm * 8 dot/mm = 96 dot
- y 方向:8 mm * 8 dot/mm = 64 dot
所以^FO96,64就对了。千万不要把 mm 直接当 dot 用,否则打出来的标签位置会整体向右下偏移一大截。我见过不少同事犯这个错,不是在 ZPL 里把坐标写大了,就是写小了,总之和标签纸实际尺寸对不上。
如果标签宽度是 60 mm,则^PW480就是很稳妥的值。高度同理,连续标签还要注意^LL必须小于或等于实际标签纸长度,否则每次打印完会多走一段纸。
3. USB 连接的真正痛点:驱动模式选不对,代码怎么写都白搭
3.1 GT800 上的 USB 到底是什么形态
GT800 的 USB 口默认是标准的 USB Printer Class 设备,Windows 安装驱动后,在“打印机和传真/设置”里会出现一个打印机队列,端口一般是 USB001、USB002 这种名字,而不是 COM 口。
有一种特殊情况要注意:如果你用的是带串口配件的 GT800,插上的是一个 USB 转串口线,那么设备管理器里会出现类似“USB Serial Port (COM3)”的节点,这种要走串口指令,不在本文讨论的打印机通道范围。判断方法很简单:看系统里有没有出现打印机队列。有队列,就走打印机 API;只有 COM 口,那就得配置串口参数再发数据。
3.2 为什么我推荐 Generic/Text Only 驱动
开发初期我直接用 Zebra 官方驱动 ZDesigner GT800。结果发现一个问题:直接用WritePrinter往这个打印机队列写原始 ZPL 字节,有时正常,有时莫名其妙把^XA当作文本打出来。
原因不复杂:官方驱动默认的打印机语言模式不一定是“原始透传”。Windows 打印系统会先经过打印处理器,如果数据被识别为图形作业或者被驱动改写了打印作业格式,原始 ZPL 就不再是原始 ZPL 了。
最省心的做法是在系统里添加一个“Generic / Text Only”打印机,指向 GT800 所在的 USB001 端口。这个驱动本质上是“哑驱动”,你往里写什么字节流,它就往端口上送什么字节流,不做任何解释和处理。对纯 ZPL 方案来说,这就是最理想的通道。
3.3 安装步骤和端口确认
添加这个驱动不需要下载任何东西,Windows 自带:
- 控制面板 -> “设备和打印机” -> “添加打印机”
- 选择“添加本地打印机”
- 创建新端口时选择“USB001”(或当前 GT800 实际占用的 USB 端口号)
- 厂商选“Generic”,型号选“Generic / Text Only”
- 完成后,把打印机名称改成容易识别的名字,比如
GT800_Raw
然后到打印机属性 -> 端口页签,确认勾选的是正确的 USB 端口。如果拔插过 USB,端口可能变成 USB002,要手动改回来。
3.4 网络共享场景下的打印机名
如果 GT800 接在 A 电脑上,你的 VC++ 程序跑在 B 电脑,通过网络共享访问时,OpenPrinter的入参就不能填GT800_Raw,而要填\\A电脑名\GT800_Raw这种 UNC 路径。
网络共享还有一个坑:共享选项里的“在客户端计算机上渲染打印作业”不要勾选,否则客户端会把原始数据先转成 EMF 再传过去,ZPL 也会被污染。
4. VC++ 核心代码:用 WritePrinter 直接灌 ZPL
4.1 两种打开打印通道的方式
VC++ 里发送原始数据给打印机,常见有两种做法:
第一种是用CreateFile打开\\.\USB001这种设备路径,然后WriteFile。这个方式看起来底层、直接,但实际体验很糟糕。打印机端口设备不是随便就能打开的,而且如果打印队列里已经有任务,或者驱动没有正确绑定,CreateFile会返回无权限之类的错误,排查起来麻烦。
第二种是走 Windows 打印系统的OpenPrinter+WritePrinter。这是正规途径,端口管理、任务排队、错误状态都由系统处理,程序只需要把字节流交给打印机队列即可。推荐这种方法。
4.2 最小可运行代码
下面这段代码是纯 Win32 API,不依赖 MFC,VC++ 6.0 到 VS2022 都能直接编译:
#include <windows.h> #include <winspool.h> #include <stdio.h> #pragma comment(lib, "winspool.lib") BOOL SendRawDataToPrinter(LPCTSTR lpszPrinterName, LPBYTE lpData, DWORD dwDataSize) { HANDLE hPrinter = NULL; DWORD dwWritten = 0; BOOL bRet = FALSE; // 打开打印机队列,失败可以拿 GetLastError() 判断原因 if (!OpenPrinter((LPTSTR)lpszPrinterName, &hPrinter, NULL)) { return FALSE; } // 把原始字节流写入打印机队列 bRet = WritePrinter(hPrinter, lpData, dwDataSize, &dwWritten); if (bRet) { // 只要系统认为写完了,并且字节数一致,基本就算发送成功 if (dwWritten != dwDataSize) bRet = FALSE; } ClosePrinter(hPrinter); return bRet; } void PrintDemoLabels() { const char* pZpl = "^XA\r\n" "^CI28\r\n" "^PW640\r\n" "^LL400\r\n" "^FO40,30\r\n" "^A0N,32,36\r\n" "^FDHELLO GT800\r\n" "^FS\r\n" "^FO40,90\r\n" "^BY2,2,100\r\n" "^BCN,100,Y,N,N\r\n" "^FD202406050001\r\n" "^FS\r\n" "^XZ\r\n"; BOOL bOk = SendRawDataToPrinter(_T("GT800_Raw"), (LPBYTE)pZpl, (DWORD)strlen(pZpl)); if (!bOk) { DWORD dwErr = GetLastError(); printf("send failed, error code: %lu\n", dwErr); } }这里有两个细节值得注意:
pZpl是const char*,发出去的字节长度用strlen计算,千万不要用sizeof,否则会把结尾的\0也当成打印数据发出去。字符串里用\r\n换行,这是 ZPL 的常见换行格式,实际测试只发\n也能用,但还是建议保持一致。
4.3 如何确认到底有没有发送成功
WritePrinter返回 TRUE 通常只表示数据进入了 Windows 打印队列,不代表打印机已经出纸。如果队列里任务积压、打印机处于暂停状态,数据会一直排队。
判断是否真正打完,实用办法是发送完数据后检查打印机状态。最简单的做法是在 ZPL 末尾增加一个“同步点”,比如发送完一批标签后主动读取打印机状态字符串。不过这个对新手太复杂,更常见的是程序之外人工观察。生产环境下如果担心漏打,可以在发送前把数据写到本地日志,再用打印机的“打印测试页”来验证队列是否正常。
4.4 错误码排查速查
OpenPrinter 或 WritePrinter 失败时,用 GetLastError() 拿到的错误码基本就是下面几个:
| 错误码 | 含义 | 常见原因与处理 |
|---|---|---|
| 5 | 拒绝访问 | 打印机被暂停、脱机,或者进程权限不够;恢复打印机状态,或换管理员权限运行 |
| 6 | 无效句柄 | 打印机连接断开,拔插 USB 后重新确认端口,再次 OpenPrinter |
| 87 | 参数错误 | 数据指针为空或者长度参数写错,检查 lpData 和 dwDataSize |
| 1801 | 打印机名无效 | 打印队列名写错;用 EnumPrinters 枚举一下实际打印机名 |
5. 实测中绕不开的五个坑
5.1 中文乱码:GT800 没有中文字库怎么办
这是使用 ZPL 打印中文时最经典的问题。GT800 默认内置字体里通常没有完整的简体中文字库,你就算在^FD里塞了中文,打印机解析出来也只是一排小方块或者干脆乱码。
我尝试过三种方案:
第一种是加^CI28加 UTF-8 编码的中文字节流。前提是打印机固件内置了中文字体,否则还是不行。GT800 不同批次固件对中文的支持差异很大,不能赌。
第二种是把中文字体下载到打印机 Flash 里,然后通过^A@命令调用。这个方案适合打印内容相对固定的场景,但下载字体本身是个麻烦事,而且 GT800 的 Flash 空间有限。
第三种是把中文内容在 PC 端先渲染成单色位图,再用 ZPL 的^GF图像命令把像素数据发给打印机。这个最稳,也是我最终采用的方式。虽然代码量多一些,但彻底绕开了打印机中文字库问题。核心思路就是:打印内容先调到内存 DC,画成一个 1bit BMP,然后把 BMP 数据转换成^GF指令。对生产系统来说,稳定的价值远大于代码的优雅。
5.2 标签走纸过多或内容偏移
如果你发完一张标签,打印机要“吐”出两三张空白标签再停,大概率是^LL和实际标签纸长度不一致。比如 50mm 高的标签,你^LL400(50mm 正好是 400 dot,如果实际纸是 40mm,就会多走 10mm),每次打印都多走一段,积累起来就特别浪费。
另外,如果打印内容上下位置漂移,先不要动代码,先把打印机做一次介质校准。GT800 前面板上通常有走纸按钮,长按几秒会校准间隙传感器。也可以在 ZPL 里发校验指令:
~JC这个命令是校准介质传感器的,发一次之后打印机自动重新学习标签纸的间隙和黑标位置,多走纸的问题往往立刻就好。
5.3 USB 端口漂移导致任务卡住
GT800 用 USB 连电脑,拔插一次后,Windows 经常把打印机从 USB001 挪到 USB002。如果“Generic / Text Only”驱动还指向 USB001,那打印任务就会一直卡在队列里,WritePrinter返回也正常,但就是不出纸。
这个问题的排查方法很简单:打印任务卡住时,去打印机属性里看一下端口,如果驱动指向的端口已经不是打印机实际占用的端口,改回来即可。为了避免频繁漂移,建议生产环境固定 USB 口,不要来回换,同时程序里对打印机名字不要写死,用EnumPrinters动态枚举一下,减少维护成本。
5.4 打印队列里的“幽灵任务”
“Generic / Text Only”驱动本身不渲染内容,理论上不会产生 EMF 之类的中间文件,但实际使用中还是会遇到队列里堆着若干失败任务的情况。最典型的是上一次打印数据没有^XZ闭合,打印机固件一直处于等待状态,而 Windows 队列又没清掉。
从代码层面规避的办法是:每次发送的 ZPL 字节流一定要以^XZ结束,而且程序中每条打印任务之间不要留太多空行。如果队列里已经卡了任务,最简单的处理是右键打印机队列,取消所有文档,或者重启一下 Print Spooler 服务。
5.5 条码打印出来扫不出来
条码扫不出来的原因通常不是打印机坏了,而是条码参数不适合纸面条件。
GT800 是 203 dpi,^BY的模块宽度取 2 dot 时,窄条宽度大约 0.25mm,这种精度在光滑铜版纸上可能没问题,但在哑面或者表面纹路比较粗的标签纸上就会糊掉。建议起步用 3 dot 模块宽度,也就是约 0.375mm。另外条码左右一定要留出静区,Code 128 静区至少 10 倍窄条宽度,否则扫码枪会认为条码不完整。
如果条码左边是文字、右边是文字,就把条码框放大一点,或者把^FO的 x 坐标往中间挪一挪。打印浓度过高也会导致相邻条粘连,ZPL 里可以用^MD调整介质浓度,默认 0,出现发闷、发黑的情况就往负值调,比如^MD-5。
6. 进阶经验:批量打印、模板化、日常维护
6.1 批量打印的性能要点
批量打印几百上千张标签时,不要在每张标签前都打开一次打印机、发送完再关闭。我试过这个做法,性能损失很明显,而且队列在频繁开关时更容易出现错乱。
更合理的做法是把多条标签的 ZPL 拼接成一个大文本,一次WritePrinter发送。ZPL 天然支持一个数据流里包含多个^XA...^XZ段。如果数据量太大,几千张标签一次性发过去会长时间占用内存,可以分批,每批 50 到 100 张。实测下来,这种“连接保持、分批发送”的方式最稳定,也不容易出现打印错位。
6.2 打印模板的替换思路
ZPL 本质上是一串文本,所以模板化非常方便。我在工程里维护了一个标签模板文件,里面留好占位符:
^XA ^CI28 ^PW640 ^LL400 ^FO40,30 ^A0N,32,36 ^FD%s ^FS ^FO40,90 ^BY3,2,100 ^BCN,100,Y,N,N ^FD%s ^FS ^XZ用 C 语言的sprintf或者 C++ 的字符串替换,把实际内容填进去。这样客户要改字段位置、字体大小,只需要改模板,不需要改编译好的程序。这个设计对维护老项目帮助很大。
6.3 日常维护的小建议
热敏打印机的打印头温度比较高,连续高强度打印后要注意散热,有些型号打几十张会自动休眠,这是正常现象。打印头如果出现白色条纹,多半是脏了,拿无尘布蘸无水酒精擦拭即可,不要用硬物刮。标签纸的保存也需要注意,热敏纸遇热遇潮容易发黑发脆,耗材仓库不要太潮湿。
这套 Winspool 发送逻辑不止适用于 GT800,换到其它支持 ZPL 的 Zebra 打印机也完全通用。万一哪天项目换成了支持 TSPL 或 EPL 的机器,代码里的 API 层完全不用动,只需要换指令文本。这也是我把打印模块设计成“只负责发送字节流”的最大好处。
本文还有配套的精品资源,点击获取