☰
VC++通过RAW模式向GT800发送ZPL的USB打印方案
2026/10/21 8:41:23 网站建设 项目流程

简介:VC++调用ZPL打印条形码的实战源码包,面向需要通过USB接口控制Zebra GT800等斑马打印机的Windows开发者,解决条码标签打印中的设备枚举、驱动通信与ZPL指令下发等关键问题。包内含完整的Visual Studio工程,共24个文件,涵盖cpp/h源码、vcxproj工程配置、调试日志、数据库及tlog构建记录等,既可直接编译运行,也能依据日志定位通信异常;整体压缩包仅5.73MB,轻量实用。已有675人学习过这份内容。源码以MyTest为工程名,围绕SetupDiGetClassDevs枚举USB打印机设备、SetupDiOpenDevRegKey或CreateFile打开接口、WriteFile发送ZPL文本、CloseHandle释放句柄四个环节展开,并演示了^XA、^BCN、^FD等条码控制指令的实际拼装方式,适合需要绕过C#依赖、实现底层打印机控制的场景。工程代码结构清晰,对物流、仓储等需要定制标签的项目可作为二次开发起点,帮助快速验证打印效果并沉淀为可复用的ZebraPrinter类。

1. 直接把 ZPL 扔给 GT800:VC++ 与 USB 打印的前置认知

很多研发把“VC++ 打印 ZPL 条形码”想复杂了。GT800 是一条 USB 连接的工业条码打印机,它自己就能解释 ZPL 指令,根本不用在业务系统里画条码、转图片。我在几个内部工具里都是调用 Windows 打印 API,把纯文本的 ZPL 数据以 RAW 方式发给打印机,GT800 自动出条码。这样做代码量最小,换标签模板只改字符串,不用重新编译。适合的场景是:现场工控机、系统用 C++ 开发、打印机只有 USB 口、不想装额外运行时,或者不想被某个厂商 SDK 绑住。下面会从原理讲到代码,再到 USB 直连下的几个典型坑,最后给一套可复现的完整过程。

2. 选型与通信协议:为什么 RAW 模式是 USB 打印机的终解

2.1 三种打印落地方式

我最早接触这类项目时用的是 GDI 打印,做法是在内存里生成一张条码位图,然后拿到打印机 DC,把图片缩放输出到纸张上。这个流程在普通办公打印机上没问题,但放到 GT800 这种热敏条码打印机上就显得笨重。条码打印质量取决于位图的像素密度和打印头的 203dpi 是否对齐,GDI 渲染时只要做了一次平滑缩放,条码的窄条宽度就可能偏离标准,扫描器识别率立刻下降。再加上每次打印都要走一遍绘制、填充、位图转换,CPU 占用高,标签数量一多,工控机上其他操作都会被拖慢。

第二种是厂商 SDK。SDK 的好处是封装了标签模板、通信和状态查询,但坏处也明显:一套 SDK 只对应某个产品线,现场如果混用了不同厂商的打印机,业务代码里就要同时维护多套接口。SDK 版本升级时还需要重新部署运行库,在离线工控环境里非常被动。我后来在几个项目里都放弃了这条路,因为打印需求其实就是“发一段文本”,为此引入几十兆的依赖并不划算。

第三种就是 RAW 模式。所谓 RAW,是 Windows 打印后台里的一种作业数据类型,它不经过 GDI 渲染,也不做字符集转换,把调用者给的字节流原样交给打印机端口。GT800 收到的是完整的 ZPL 指令文本,由打印机内部解释器去生成条码。这种方式绕开了所有图形处理,打印质量最接近打印头物理分辨率,代码量又小,所以成了我在 USB 连接场景下的首选。

方式打印质量代码量额外依赖适用场景
GDI 图像受渲染影响中条码图像库普通办公文档
厂商 SDK高多单独运行库复杂状态管理、批量任务
RAW+ZPL高少无标签和条码批量打印

2.2 ZPL II 命令结构

ZPL II 是一套基于文本的打印指令,所有内容必须包在^XA和^XZ之间。^XA表示一个标签开始,^XZ表示标签结束并触发打印。中间是一系列字段,每个字段从定位开始,到^FS结束。例如:

^XA ^FO50,50^BCN,80,Y,N,N ^FD1234567890^FS ^XZ

这里的^FO50,50是字段起点坐标,x=50dot,y=50dot;^BCN,80,Y,N,N画一个 Code128 条码,高度 80dot,并显示可读文本;^FD后面是条码内容,^FS结束字段。还有几个命令在 GT800 上很常用:^PW设置标签宽度,^LL设置标签高度,^BY设置条码模块宽度和比例,^A0选择内置字体,^MD调节打印浓度。

要特别注意,ZPL 是严格顺序解释的。字段定义顺序就是渲染顺序,如果两个字段坐标重叠,后面定义的会覆盖前面。另外,^FD到^FS之间的数据如果包含命令字符,比如^或~,解释器会误认为是新命令,这一点在后面处理业务数据时是重点。

2.3 RAW 模式的实现原理

Windows 打印系统里,作业从应用进入打印池后,会经过打印处理器。数据类型为RAW时,打印处理器不做任何转换,直接把数据交给端口监视器。如果打印机是 USB 接口,端口监视器会把字节流写到系统分配的 USB 虚拟端口上。整个过程中,应用层不需要知道端口细节,也不用关心 USB 设备被枚举成 USB001 还是 USB002。

有人会问,为什么不直接用CreateFile打开\\.\USB001然后写数据。这个方案在部分场景能跑通,但我实际试下来有几个问题:第一,打开这个虚拟端口通常需要管理员权限,普通业务程序跑不起来;第二,USB 打印机插拔后端口号可能变化,程序需要重新探测;第三,如果打印机驱动或后台监控服务已经占用了设备通道,CreateFile会失败。相比之下,OpenPrinter打开的是打印机队列,队列由打印后台管理,端口变化时后台会尝试重新映射,程序稳定性高很多。

3. VC++ 发送 ZPL:从 OpenPrinter 到 WritePrinter 的完整通路

3.1 最小实现代码

下面这段代码使用 Win32 打印 API 把 ZPL 字符串发送给 GT800。为了在 MBCS 和 Unicode 工程下都能编译,这里统一使用 A 版本函数和char字符串。

#include <windows.h> #include <winspool.h> #include <string> // 发送 ZPL 到打印机,成功返回 true bool SendZpl(const std::string& printerName, const std::string& zplData) { HANDLE hPrinter = NULL; DOC_INFO_1A docInfo = {0}; // 作业名和打印数据类型都必须是 RAW docInfo.pDocName = (LPSTR)"GT800 ZPL Job"; docInfo.pDatatype = (LPSTR)"RAW"; docInfo.pOutputFile = NULL; if (!OpenPrinterA((LPSTR)printerName.c_str(), &hPrinter, NULL)) { return false; } if (!StartDocPrinterA(hPrinter, 1, (LPBYTE)&docInfo)) { ClosePrinter(hPrinter); return false; } if (!StartPagePrinter(hPrinter)) { EndDocPrinter(hPrinter); ClosePrinter(hPrinter); return false; } DWORD written = 0; BOOL ok = WritePrinter(hPrinter, (LPBYTE)zplData.c_str(), (DWORD)zplData.size(), &written); EndPagePrinter(hPrinter); EndDocPrinter(hPrinter); ClosePrinter(hPrinter); return ok && written == zplData.size(); }

执行顺序是:OpenPrinterA拿到打印机队列句柄;StartDocPrinterA开启一个打印文档,并告诉后台这个文档的数据类型是 RAW;StartPagePrinter开始第一页;WritePrinter把 ZPL 字节写进去;EndPagePrinter和EndDocPrinter依次收尾;最后ClosePrinter释放句柄。每一步失败都不应该继续后面的操作,所以返回值检查不能省略。

参数说明:printerName是 Windows 打印机名字,大小写不敏感,也可以是共享路径,比如\\printserver\gt800。zplData必须以^XA开头,以^XZ结尾,建议尾部再补一个\r\n,部分固件需要换行才能触发立即打印。written是实际写入的字节数,如果小于zplData.size(),说明打印队列或端口没有完全接收,需要重试。

3.2 获取打印机名与错误日志

打印机名是这块最容易翻车的点。系统里显示的名字可能是“GT800 (副本 2)”,也可能带(USB001)后缀,和安装时看到的完全不一样。我在代码里不会让用户手输,而是先枚举一遍本机和网络打印机:

#include <windows.h> #include <winspool.h> #include <vector> #include <string> bool ListLocalPrinters(std::vector<std::string>& names) { DWORD needed = 0, returned = 0; EnumPrintersA(PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS, NULL, 1, NULL, 0, &needed, &returned); if (needed == 0) return false; std::vector<BYTE> buffer(needed); if (!EnumPrintersA(PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS, NULL, 1, buffer.data(), needed, &needed, &returned)) return false; PRINTER_INFO_1A* info = reinterpret_cast<PRINTER_INFO_1A*>(buffer.data()); for (DWORD i = 0; i < returned; ++i) { names.push_back(info[i].pName); } return true; }

EnumPrintersA第一次调用只用来拿缓冲区大小,第二次调用才真正填充数据。pName就是可以直接传给OpenPrinterA的名字。调试时我还会用GetLastError配合FormatMessageA把错误码转成可读文本,这样现场人员不用猜“1801”是什么。

3.3 中文和特殊字符处理

如果标签内容只有字母和数字,上面的代码直接可用。一旦混入中文,问题就来了。GT800 出厂固件对中文字体支持有限,ZPL 默认字符集是 ASCII。常见做法是把中文数据转换成打印机支持的编码,并用^CI指令切换字符集:

^XA ^CI28 ^FO30,30^A0N,32,32^FD中文文本^FS ^XZ

实际验证时,如果打印出来是方块或空白,说明打印机没有对应字库。这时候最稳妥的办法是放弃文本指令,把中文渲染成黑白位图,再用^GF图形指令发给打印机。这种办法代码量会大一些,但兼容性最好。我在内场工具里,只有标签必须含中文供应商名时才用图形方式,普通英文条码一律走纯文本。

特殊字符方面,ZPL 用^开头表示命令,所以业务数据里如果有^或~,直接放进^FD会导致解释器误判。可以用^FH配合十六进制转义。比如数据“A^B”要写成:

^XA ^FH ^FD41 5E 42^FS ^XZ

前端业务数据进入模板前,我会先做一轮转义检测,把所有^和~替换成对应的十六进制序列,避免打印出的条码数据和预期不符。

3.4 调用方式与参数表

调用SendZpl时,我最关心三个参数:打印机名、ZPL 数据、作业数据类型。打印机名必须和系统枚举结果一致;ZPL 数据必须完整;pDatatype必须是RAW,写成TEXT会出事。

参数含义建议值
pDatatype打印数据类型RAW
pDocName作业显示名任意英文串
pOutputFile输出文件NULL
printerName打印机名/共享路径系统“打印机”列表中的准确名称
zplData完整 ZPL 文本以^XA开头,^XZ结尾,尾部加换行

还有一个潜在问题:如果打印机队列里同时排了多个 RAW 作业,WritePrinter会线性写入,批量打印时数据必须按顺序提交。如果前一个作业因为超时卡住,后面的作业都会堵在队列里。所以批量打印前最好先做一次空作业测试,确认队列通畅。

4. GT800 条码模板:字段坐标与分辨率换算

4.1 一个 Code128 标签模板

下面是我在 GT800 上常用的标签模板,标签宽度 60mm,高度 30mm,内容是一个 Code128 条码和一行文字。

^XA ^LL,600 ^PW,480 ^FO20,20^A0N,24,24^FDORDER-1001^FS ^FO20,60^BCN,100,Y,N,N ^FDORDER-1001^FS ^FO20,180^A0N,20,20^FD2026-05-01^FS ^XZ

^PW,480设置标签宽度为 480dot,对应 203dpi 下的 60mm。^LL,600设置标签高度为 600dot,对应 30mm。^FO20,20是所有字段的起点,离左边约 2.5mm,离上边约 2.5mm,避免靠近边缘。^BC后面的 100 是条码高度,Y表示显示可读字符,两个N分别表示不显示上方可读文本和不强制显示校验位。Code128 本身有校验逻辑,即使不显示校验位,扫描器也能正常读。

4.2 二维码和 EAN-13 条码

GT800 支持 QR Code。二维码模板更短:

^XA ^FO40,40^BQN,2,8 ^FDQA,SN20260501001^FS ^XZ

^BQN,2,8表示二维码方向 N,模型 2,放大倍数 8。放大倍数决定了模块大小,倍数太小扫描器很难识别,倍数太大又占用太多标签面积。我一般先在测试纸上试打,再确定最终值。

零售商品常用 EAN-13,用^BE指令:

^XA ^FO30,30^BEN,80,Y,N ^FD6901234567890^FS ^XZ

EAN-13 数据必须是 12 位或 13 位数字,最后一位是校验位。GT800 会根据前 12 位自动计算第 13 位,也可以由业务系统自己提供完整位。要注意 EAN 符号有固定模块宽度,不要随意改变放大倍数,否则扫描器识别率下降。我把比例参数固定写在模板里,不放进可配置区,避免误改。

不同条码类型的选择可以看下面这个表:

条码类型ZPL 命令适合内容限制
Code128^BC字母数字、长度可变无固定长度限制
EAN-13^BE零售商品编码仅 12 或 13 位数字
Code39^B3大写字母和数字通常需要加校验位
QR Code^BQ网址、长文本、序列号需要放大倍数足够

4.3 坐标换算和模板复用

GT800 打印头是 203dpi,1mm 约等于 8dot。我的习惯是先把标签设计稿上的毫米坐标乘 8,再写进 ZPL。比如离左边距 3mm 就是 x=24,离上边距 5mm 就是 y=40。这个换算关系看着简单,但改模板时很容易算错,尤其是标签尺寸不是整数毫米时,误差会累积。

标签宽度也需要注意。GT800 最大打印宽度通常不到 104mm,也就是 832dot。如果^FO的 x 坐标加上条码自身宽度超过标签宽度,打印内容会被裁掉,而且 ZPL 不报错。我一般会先根据条码数据类型估算一个最大宽度,再检查x + 估算宽度 < 标签宽度,避免现场打出来才发现少了半个条码。

模板复用方面,我用简单的字符串拼接实现“填充模板”。把变化的数据如订单号、日期放在占位符里,发送前做替换:

std::string BuildLabelZpl(const std::string& orderNo, const std::string& date) { return "^XA\n" "^LL,600\n^PW,480\n" "^FO20,20^A0N,24,24^FD" + orderNo + "^FS\n" "^FO20,60^BCN,100,Y,N,N\n" "^FD" + orderNo + "^FS\n" "^FO20,180^A0N,20,20^FD" + date + "^FS\n" "^XZ\n"; }

这里需要提醒,任何用户输入都可能包含影响 ZPL 解释的字符。我会对orderNo先做转义,再拼进模板。如果之前已经做过移动端提交,字符串里混入换行或^,打印出来的条码就会和预期差一截。

5. 避坑与排查:USB 打印打不出来时的四个典型点

5.1 OpenPrinter 失败,报 1801 或 1802

现象:SendZpl在OpenPrinterA处返回 false,GetLastError是 1801(无效打印机名)或 1802(打印机不可用)。程序在开发电脑上正常,换到现场工控机就失败。

原因:打印机名和系统里显示的不完全一致,最常见的是多了“副本 2”或“(USB001)”后缀;另一种是打印机驱动处于脱机状态,队列句柄拿不到。

解决:先用EnumPrintersA把所有打印机名打印出来,对比目标名称。代码里不要直接硬编码中文打印机名,最好做一个下拉选择。如果是脱机,先在系统设置里取消“暂停打印”,再用一个空作业测试OpenPrinter是否成功。我通常在程序启动时先调用一次OpenPrinter预检,不通过就直接提示,省得业务跑到一半才发现。

5.2 打印乱码,ZPL 明文被打出来了

现象:打印机出纸,但纸上内容是^XA^FO...这种指令文本,不是条码,有时前面还有一页空白。

原因:pDatatype被设成了 TEXT,或者驱动把 RAW 内容当作文本处理。Windows 默认的 TEXT 处理程序会对字节做 CR/LF 和编码转换,把^原样输出。另一个常见原因是打印机安装了厂商的文档驱动,默认把任务路由到 GDI 渲染,根本不认识 ZPL。

解决:确认代码里docInfo.pDatatype是"RAW";在打印机属性里安装或改用“Generic / Text Only”驱动,或者厂商提供的 ZPL 专用驱动。注意驱动名称不要看图标,要看打印测试页时是否把 ZPL 文本原样输出。如果打印机有自动语言识别,还要临时关闭自动切换,避免打印机把 ZPL 当文本打印。

5.3 条码能出,但扫描器读不出来

现象:纸张上条码印刷清晰,但扫码枪扫描没反应,或者只有少数几台能扫。

原因:常见是条码高度不够、密度太低、坐标重叠或打印浓度不足。GT800 是热敏打印,如果打印头温度过低,条和空白对比度不够,扫描器识别会失败。另外,^BY默认模块宽度为 2,如果标签宽度太窄,条码被强制压缩,会出现窄条过细的情况。

解决:在标签上把条码高度保持在 15mm 以上;用^BY调整比例,标准 Code128 建议模块宽度 2~3dot,不要用 1。二维码用^BQN,2,8这类放大 8 倍以上的参数。浓度方面可以加^MD增强打印深度,但要边调边看,过浓反而糊成一团。我在批量打印前会打印一页自检测试页,扫通了再放量。

5.4 USB 打印机插拔后,软件里打印机名变了

现象:早上打印正常,下午换了一条 USB 线或插到旁边 USB 口后,OpenPrinter失败。去系统设置一看,打印机自动变成了另一个名字。

原因:USB 打印机重新枚举后,系统有可能把新端口识别为 USB002,同时为打印机创建一个“副本”队列,原队列被脱机或隐藏。

解决:不要依赖安装时自动生成的默认名称。在业务系统里维护打印机名时,可以用共享打印机的方式固定 UNC 名,或者用GetDefaultPrinter读取当前默认打印机。如果现场不允许网络共享,就给打印机驱动起一个固定名称,并在端口配置里把 USB 端口固定为 USB001。这个过程不复杂,但必须在部署文档里写清楚,否则换接口后又是一场排查。

5.5 打印了旧数据或重复上一张

现象:标签打印出来内容是上一次的,或者多出一张空白。

原因:上一个作业的数据还留在打印机缓冲区;或者作业名相同导致打印队列合并;也可能是EndDocPrinter调用过早,spooler 还没把数据完全交给端口。常见的是 ZPL 尾部少了换行,打印机在收到^XZ后没有立即触发分页。

解决:在zplData末尾补\r\n;同一个作业里只放一张标签,不要拼接多个^XA^XZ;发送前用SetPrinter清掉队列中的旧作业。批量打印时,每张之间稍微停顿,等上一张的written == size,再发下一张。

6. 进阶:批量打印与状态确认

6.1 批量打印队列

当需要一次打印几十张标签时,我在内存里先构造好 ZPL 字符串列表,再循环调用SendZpl。批次中间可以复用打印机句柄,减少重复打开造成的延迟。每张写完检查返回值,如果连续失败 3 次就停止批次,避免坏标签不断进入队列:

for (const auto& zpl : labels) { if (SendZpl(printerName, zpl)) continue; if (++failCount >= 3) break; Sleep(200); }

这里Sleep(200)是给打印机缓冲区一个消化时间,尤其是旧款打印机在连续作业时容易丢尾部数据。不要把这个时间写得太长,否则批量效率受影响。

6.2 打印机状态确认

RAW 模式不擅长读取打印机状态,这一点要和调用方讲清楚。如果必须知道缺纸或卡纸,我一般通过系统打印队列的PRINTER_INFO_2状态位做粗判。要精确读取热敏头温度和标签用完状态,ZPL 的^HS返回状态需要双向通道,USB 下容易因为驱动差异收不到。通常打印成功就是最大的状态反馈,状态检测属于增强项,不是第一优先级。

6.3 我的固定检查顺序

从那以后,我每次布置一套新打印机都强制走一遍:先打印一张空的^XA^XZ,确认指令通路;再打印一张 Code128 测试条码,确认格式和驱动;最后才接业务数据。这样能把驱动问题、模板问题和业务数据问题逐层隔离。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询