☰
C# 驱动 Zebra 打印机:ZPL 指令直通、状态回读与批量打印实战
2026/10/24 4:19:12 网站建设 项目流程

简介:这是一份面向C#开发者的Zebra打印机打印示例源码包,适合需要为物流、零售、医疗等场景的标签、收据或条形码打印功能做技术验证与集成参考的初、中级开发者。压缩包内共88个文件,以cs源码、sample示例、resx资源、config配置、sln解决方案、md说明及少量exe、dll为主,整体约348KB;其中包含可直接在Visual Studio中打开编译的解决方案、窗体与打印逻辑代码、打印机交互封装、项目说明和版本控制信息,便于读者快速理解C#调用Zebra打印机完成打印任务的实现路径。已有1147人学习,适合用来对照调试、复用基础代码并梳理从界面触发到打印输出的组织方式。

1. 一张标签扫不出码,问题多半不在条码库

产线打样那天,标签从打印机里吐出来,肉眼看着规整,扫码枪却死活不识别,一箱货原地返工。事后定位到的原因很无趣:既不是条码算法选错,也不是数据算错,而是走了 GDI 驱动渲染,驱动把条码当成图片缩放了一把,模块宽度落到非整数 dot 上。之所以翻出这份「完整版 Zebra 打印 demo C#」,就是因为这类问题靠改业务代码解决不了,必须回到指令层。它是一套可直接跑的 C# 示例集,覆盖驱动 RAW 直通、TCP 9100 直连、ZPL II 指令拼装、状态回读这几条主线,适合手上有一台 ZPL 兼容机、要在 WinForms 或工控机上做标签打印的开发者。下面按能复现的顺序拆。

2. ZPL 与 C# 的三种通道:先选路再写代码

同一台打印机,从 C# 把数据送进去至少有三条路,选错通道后面全是白工。这一层不搞清楚,代码写得多漂亮都白搭,因为决定条码精度的不是你的 C# 代码,而是数据在到达打印头之前有没有被中间层重新栅格化。

2.1 驱动 GDI、驱动 RAW 与 9100 直连的差别

驱动 GDI 是大多数人第一次接触打印的方式:PrintDocument+Graphics.DrawString,排版所见即所得。问题是整张标签最终被驱动渲染成位图,打印机只负责点阵输出。条码模块宽度、细线条、反白文字在这种模式下都会受缩放影响,300 dpi 机上 1 dot 的偏差扫不出来是常态。另外一个隐性代价是速度,一张 100mm×80mm 的标签渲染加传输常常要 800ms 以上,批量场景扛不住。

驱动 RAW 是在已安装打印驱动的前提下,用winspool.drv的WritePrinter把pDataType指定为RAW,字节流原封不动穿过 Spooler 送到打印机。这条路的好处是 USB 连接的机器也能发 ZPL,不需要额外开端口;坏处是如果驱动型号选错,或者端口指向的是虚拟打印机,数据会被静默吞掉,WritePrinter照样返回 true。

TCP 9100 直连是绕开 Windows 打印栈,直接对打印机内嵌的打印服务发 socket。速度快、可并发、能回读状态(后面第 4 章要用的~HS必须走这条路),代价是打印机必须固定 IP 且局域网可达。产线、仓储这类已经有网络布线的场景,我一般直接上这条。

通道关键 API条码精度状态回读典型翻车点
驱动 GDIPrintDocument差,受缩放影响无条码扫不出、速度慢
驱动 RAWWritePrinter(pDataType="RAW")好无驱动名不对,静默失败
TCP 9100TcpClient最好支持IP 漂移、超时无响应

提示:判断一台机器到底吃不吃 ZPL,最快的办法不是查手册,而是用记事本写一段^XA^FO50,50^A0N,60,60^FD TEST^FS^XZ,用 RAW 通道发过去。打出来是文字说明通道通了;打出来是这串字符本身,说明机器不认 ZPL 或者你走的是 GDI。

2.2 环境准备与最小可运行工程

工程目标先定死:.NET Framework 4.7.2 或 .NET 6/8 都可以,但平台目标必须显式指定 x86 或 x64,不要留 AnyCPU。winspool.drv的 P/Invoke 在 AnyCPU 下于 64 位系统运行、于 32 位加载时会出现句柄宽度不一致,表现是OpenPrinter成功但WritePrinter写出 0 字节,或者直接抛MarshalDirectiveException。这是血泪经验,改一次平台目标能省半天排查。

新建一个控制台或 WinForms 工程,把下面的帮助类放进去。这段代码是整份 demo 里复用率最高的部分,直接把 ZPL 字符串当字节流灌进 Spooler:

using System; using System.Runtime.InteropServices; public static class RawPrinter { [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] private class DOCINFOA { [MarshalAs(UnmanagedType.LPWStr)] public string pDocName; [MarshalAs(UnmanagedType.LPWStr)] public string pOutputFile; [MarshalAs(UnmanagedType.LPWStr)] public string pDataType; // 必须为 "RAW" } [DllImport("winspool.drv", EntryPoint = "OpenPrinterA", SetLastError = true, CharSet = CharSet.Ansi)] private static extern bool OpenPrinter(string szPrinter, out IntPtr hPrinter, IntPtr pd); [DllImport("winspool.drv", EntryPoint = "ClosePrinter", SetLastError = true)] private static extern bool ClosePrinter(IntPtr hPrinter); [DllImport("winspool.drv", EntryPoint = "StartDocPrinterA", SetLastError = true, CharSet = CharSet.Ansi)] private static extern bool StartDocPrinter(IntPtr hPrinter, int level, [In, MarshalAs(UnmanagedType.LPStruct)] DOCINFOA di); [DllImport("winspool.drv", EntryPoint = "EndDocPrinter", SetLastError = true)] private static extern bool EndDocPrinter(IntPtr hPrinter); [DllImport("winspool.drv", EntryPoint = "StartPagePrinter", SetLastError = true)] private static extern bool StartPagePrinter(IntPtr hPrinter); [DllImport("winspool.drv", EntryPoint = "EndPagePrinter", SetLastError = true)] private static extern bool EndPagePrinter(IntPtr hPrinter); [DllImport("winspool.drv", EntryPoint = "WritePrinter", SetLastError = true)] private static extern bool WritePrinter(IntPtr hPrinter, IntPtr pBytes, int dwCount, out int dwWritten); /// <summary>把原始 ZPL 字节流送入指定打印机</summary> public static bool Send(string printerName, byte[] bytes) { if (!OpenPrinter(printerName, out IntPtr h, IntPtr.Zero)) throw new InvalidOperationException($"OpenPrinter 失败,错误码 {Marshal.GetLastWin32Error()}"); var di = new DOCINFOA { pDocName = "ZPL Label", pOutputFile = null, pDataType = "RAW" }; if (!StartDocPrinter(h, 1, di)) { ClosePrinter(h); return false; } StartPagePrinter(h); IntPtr p = Marshal.AllocCoTaskMem(bytes.Length); try { Marshal.Copy(bytes, 0, p, bytes.Length); bool ok = WritePrinter(h, p, bytes.Length, out int written); if (!ok || written != bytes.Length) throw new InvalidOperationException($"WritePrinter 只写出 {written}/{bytes.Length} 字节"); return true; } finally { Marshal.FreeCoTaskMem(p); EndPagePrinter(h); EndDocPrinter(h); ClosePrinter(h); } } }

逻辑上分四步:OpenPrinter拿到句柄、StartDocPrinter声明这是一份 RAW 文档、WritePrinter逐字节写、最后按页、文档、句柄的逆序释放。参数里最要紧的是pDataType = "RAW",改成"TEXT"或留空,打印机会把 ZPL 当普通文本渲染,打出来就是一整页指令字符。printerName必须和「设备和打印机」里显示的名称完全一致,包括括号和空格,写成共享名或者 IP 都不行。

字节编码这里要留意:Encoding.GetEncoding(936)得到的是 GBK 字节流,适合^CI17;new UTF8Encoding(false)得到无 BOM 的 UTF-8 流,适合^CI28。无论哪种,都不要带 BOM,那三个字节会被打印机当作指令前缀,导致整张标签错位。

2.3 一段 ZPL 从字符串到标签的落地验证

先别急着接业务数据,用小段指令验证链路。下面这段是最小闭环,包含标签尺寸、原点、一段文字和一个 Code128:

using System.Text; string zpl = "^XA" + // 标签开始 "^PW600^LL400" + // 打印宽度 600 dot,标签长度 400 dot "^LH0,0" + // 原点归零 "^CI28" + // 后续文本按 UTF-8 解释 "^FO50,40^A0N,40,40^FDHELLO ZPL^FS" + "^BY3,2,100" + // 模块宽 3 dot,宽窄比 2,条码高 100 dot "^FO50,120^BCN,100,Y,N,N^FD1234567890^FS" + "^XZ"; // 标签结束 byte[] payload = new UTF8Encoding(false).GetBytes(zpl); RawPrinter.Send("ZDesigner ZD420-203dpi ZPL", payload);

^XA/^XZ必须成对出现,缺一个打印机就停在收数据状态不出纸。^PW和^LL的单位是 dot,不是毫米,203 dpi 机型上 1mm ≈ 8 dot,300 dpi 上是 1mm ≈ 11.8 dot,换算时别用同一个系数。^BY里的模块宽度直接决定扫码率:203 dpi 下低于 2 dot 的码,普通扫码枪在 30cm 外就吃力,我一般固定 3。

跑通这段之后,再把它换成实际标签内容,顺序就不会乱。如果这一步打出来是空白页,先别怀疑代码,去看打印队列里那份文档的状态是不是「已打印」,是的话问题在打印机端介质设置,跳到第 5 章。

3. 用 C# 拼 ZPL:条码、中文与排版参数怎么定

指令层通了之后,真正的活是让 C# 稳定生成 ZPL。这块最容易出问题的地方不是语法,而是三件事:条码参数和实际扫码距离不匹配、中文字体没落到打印机里、标签尺寸和机器内保存的校准值打架。

3.1 条码与二维码指令的参数映射

ZPL 的条码指令都是「方向 + 高度 + 解释行 + 校验」这几段参数,记不住没关系,记住每一段影响什么就行。下面把常用的三个整理成表,参数顺序和含义按 ZPL II 通用约定:

指令参数序列关键参数含义常见取值
^BCCode128^BCo,h,f,g,e,mo 方向、h 条码高、f 是否打解释行、g 解释行位置、e UCC 校验、m 模式^BCN,100,Y,N,N
^BEEAN-13^BEo,h,f,g数据必须是 12 位,第 13 位由机器算^BEN,100,Y,N
^BQQR Code^BQa,b,c,d,eb 模型(1/2)、c 模块大小 1–10、d 纠错等级^BQN,2,6,Q,0

QR 的^FD数据要带两个字符前缀,格式是「纠错等级字母 + 模式字母 + 逗号 + 内容」,例如^FDLA,HTTPS://X.CN/1^FS,L是纠错等级,A是自动字符模式。前缀漏了,打出来是空框或者干脆不出码,这个坑我踩过不止一次。

EAN-13 特别提醒:^BE只吃 12 位数字,第 13 位校验位由打印机生成,你传 13 位进去反而会错位或者直接报错。如果业务库里存的是 13 位完整码,发送前截掉最后一位。

3.2 中文编码:GBK、^CI 与字体下载

中文是这套 demo 里最值得看的部分,因为它把两条路都摆出来了。第一条是编码路线:打印机固件内置了简体中文字库的机型,只要在标签开头用^CI17声明 GBK/GB18030,然后用Encoding.GetEncoding(936)发字节,^A0N选内置字体就能出中文。第二条是字体下载路线:机器没内置中文字库时,需要先用工具把 TTF 转成 ZPL 字体文件下载进打印机(写到R:或E:闪存),再用^A@引用字体文件名。

编码这块有个硬约束:声明和实际字节必须一致。^CI28配 GBK 字节,结果是方块;^CI17配 UTF-8 字节,结果是问号或乱码。我一般统一走 UTF-8:^CI28+new UTF8Encoding(false),理由是整个 .NET 侧不用做代码页转换,跨语言调用时也不会因为系统区域设置变化而崩。

^CI17的老机器上还有一个隐坑:CodePagesEncodingProvider在 .NET Core/.NET 5+ 里默认不注册,直接Encoding.GetEncoding(936)会抛ArgumentException。解决办法是在程序启动时执行一次Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);,并确保项目引用了System.Text.Encoding.CodePages包。

3.3 标签尺寸、浓度、速度与偏移的标定流程

排版偏了、打一半走纸、浓度太浅扫不出,这三类问题九成出在参数上。下面是一套我固定的标定顺序,新机器上手一次,后面基本不用再动:

  1. 先打一张机器自检配置标签(关机按住走纸键开机,或面板菜单里找「打印配置」),确认 dpi、介质类型、当前^PW/^LL。
  2. 用卷尺量实际标签的宽和高,换算成 dot,写进^PW和^LL,跟机器内的值对齐。
  3. 设置介质类型:间隙纸用^MNY,连续纸用^MNN,黑标纸用^MNW。设错的表现是每打一张空走一张,或者内容压在两标签接缝上。
  4. 做一次手动校准(面板上 Feed + Pause 组合或菜单里的 Calibration),让机器重新学习标签间距。
  5. 浓度先用^MD10、速度用^PR4起步,扫不出来再往上加浓度、往下减速度。
  6. 整体偏移用^LH平移原点,单方向微调用^LT(范围 -120 到 120 dot)。

参数表:

指令作用常用值说明
^PW打印宽度按标签实际宽度换算 dot设小了右侧被裁
^LL标签长度按标签实际高度换算 dot设错会跨标签打印
^MD打印浓度-30 ~ 30,常用 8–15太高会糊、堵头
^PR打印速度1 ~ 14 ips越低越清晰
^LH原点偏移x,y(dot)整体平移
^LT纵向微调-120 ~ 120只动 Y 方向

一开始别把浓度和速度一起调,两个变量同时动,最后你不知道是哪一项起的作用。

4. 状态回读与批量打印:把「打完没」变成可判断的信号

单张打印谁都能跑通,真正让人头疼的是批量:一百张里少了两张、卡纸了没人知道、重发了三次同一张。这些问题靠WritePrinter的返回值判断不了,它只告诉你「数据交给 Spooler 了」,不告诉你「纸上打出来了」。

4.1 ~HS 状态查询与双向通讯

ZPL 有一条主机状态查询指令~HS,打印机收到后会在同一条 TCP 连接上回三行文本,每行以0x02(STX)开头、0x03(ETX)结尾,字段用逗号分隔。这条路只能走网络或串口,走 Windows 驱动是拿不到回包的。

using System; using System.Net.Sockets; using System.Text; public static class ZplChannel { /// <summary>发送 ZPL 并可选读取应答,超时必须显式设置</summary> public static string Send(string ip, int port, string zpl, int timeoutMs = 3000, bool readReply = false) { using var client = new TcpClient(); // 关键:不给超时,网络抖动时线程会挂死在这里 if (!client.ConnectAsync(ip, port).Wait(timeoutMs)) throw new TimeoutException($"连接 {ip}:{port} 超时"); client.SendTimeout = timeoutMs; client.ReceiveTimeout = timeoutMs; byte[] data = new UTF8Encoding(false).GetBytes(zpl); using NetworkStream ns = client.GetStream(); ns.Write(data, 0, data.Length); ns.Flush(); if (!readReply) return string.Empty; // ~HS 回三行,每行 STX ... ETX var sb = new StringBuilder(); byte[] buf = new byte[1024]; int lines = 0; while (lines < 3) { int n = ns.Read(buf, 0, buf.Length); if (n <= 0) break; string chunk = Encoding.ASCII.GetString(buf, 0, n); sb.Append(chunk); lines += CountEt x(chunk); } return sb.ToString(); } private static int CountEtx(string s) { int c = 0; foreach (char ch in s) if (ch == '\u0003') c++; return c; } }

这段代码里有两个必须显式设置的参数:连接超时和读写超时。TcpClient默认的连接超时接近 20 秒,接收超时是无限,打印机断电或者网线松了,UI 线程会一直卡着不动,用户以为程序死了。生产环境我一般把连接超时压到 1.5 秒、读写压到 3 秒,失败就走重连。

拿到回包后,按逗号切分再做判断:

string raw = ZplChannel.Send("192.168.1.50", 9100, "~HS", 3000, readReply: true); // 三行分别对应:状态位、纸张参数、主机配置 string[] lines = raw.Split('\u0003', StringSplitOptions.RemoveEmptyEntries); foreach (string line in lines) { string body = line.TrimStart('\u0002'); string[] f = body.Split(','); if (f.Length < 4) continue; // 位序随固件不同会有差异,先抓一次原文再定下标 bool paperOut = f[1] == "1"; // 缺纸 bool paused = f[2] == "1"; // 暂停 Console.WriteLine($"缺纸={paperOut} 暂停={paused}"); }

这里我做了个保守处理:注释里写清「位序随固件不同会有差异」。不同型号、不同固件版本~HS返回的字段数不完全一致,直接照抄网上的下标,换台机器就可能把「暂停」读成「缺纸」。稳妥做法是先发一次~HS,把原始字节 dump 成十六进制看一眼,再确定下标。

4.2 批量队列、重打与幂等

批量打印别开一堆线程一起往 9100 灌,打印机是串行设备,并发发过去只会在它的缓冲区里排队,前端反而更难判断谁成功了。我一般的结构是单消费者队列:

// 伪代码骨架:单线程消费 + 每张确认 foreach (var job in pendingJobs) { try { ZplChannel.Send(ip, 9100, job.Zpl, 3000); string raw = ZplChannel.Send(ip, 9100, "~HS", 3000, readReply: true); if (IsError(raw)) { job.Status = "ERROR"; job.Retry++; } else { job.Status = "PRINTED"; job.PrintedAt = DateTime.Now; } } catch (TimeoutException) { job.Status = "TIMEOUT"; job.Retry++; } if (job.Retry > 3) job.Status = "MANUAL"; // 超过三次进人工队列 repo.Save(job); // 每张都落库,别攒到最后 }

结构上有三点值得说。第一,每张打完立刻落库,不要十张攒一批提交,中途崩溃时你根本不知道打到第几张。第二,重试上限设在 3 次,超过就进人工队列,不然一张坏标签会被反复打印成堆。第三,幂等键用业务号(订单号 + 行号)而不是自增 ID,同一个业务号重复调用时先查库里状态,已经 PRINTED 的直接跳过。

还有一种情况要预防:网络超时但打印机其实已经打出来了。这时候重发就会重复。缓解办法是在标签上带流水号和打印时间,人工核对时能快速识别;更彻底的做法是把~HS查询挪到发送之前,先确认上一张确实打完了再发下一张,用吞吐换准确率。产线上这个取舍基本都会选后者。

5. 常见问题排查:五条踩过的坑

这一章单独列,因为下面五条现象几乎每次上项目都会遇到至少两条,每条都按「看到什么 → 为什么 → 怎么办」写。

一、打出来是一整页^XA^FO50,50...这样的字符

现象:纸上原样印出指令文本,格式还挺整齐。原因:数据走了 GDI 通道,打印机把它当普通文本渲染了,或者pDataType没设成RAW。解决:检查DOCINFOA.pDataType是不是精确的"RAW"(大小写敏感);如果用的是PrintDocument,那这条路本身就不对,换WritePrinter或 9100。

二、中文变成方块、问号或者整行消失

现象:英文数字正常,中文位置是方块或者干脆空白。原因:编码声明和字节流不一致,或者这台机器根本没装中文字库。解决:先确认指令里的^CI和Encoding是否配对(^CI28+ UTF-8、^CI17+ GBK),排除这一点后再查字体——^A0N用的是内置字体,机器没有中文字库时无论怎么编码都出不来,必须下载字体文件后用^A@引用。判断方法:发一句纯 ASCII 的^FDHELLO^FS,能出就说明通道没问题,问题在字体。

三、每打一张空走一张,或者内容压在两标签的接缝上

现象:标签内容整体上下漂移,间距固定。原因:介质类型设错。间隙纸被当成连续纸,机器就不做间隙定位。解决:^MNY间隙纸、^MNN连续纸、^MNW黑标纸,三选一对准实际耗材;改完必须手动做一次校准,否则机器里存的旧间距值还在生效。这一条属于玄学重灾区,改完指令不出效果多半就是忘了校准。

四、WritePrinter返回 true,但打印机毫无反应

现象:代码不报错,队列里也看不到任务,纸一张不出。原因:printerName匹配到了一台虚拟打印机(OneNote、PDF 之类),或者驱动端口指向 FILE:。解决:先用System.Drawing.Printing.PrinterSettings.InstalledPrinters把机器上所有打印机名打出来核对,再确认选中那台的端口是 USB00x 而不是 FILE: 或者共享路径。名字差一个空格就匹配到别的设备,这个坑很隐蔽。

五、跑一段时间后偶发超时,重启程序又好了

现象:连续打印几小时后开始报超时,程序重启恢复。原因:TcpClient和NetworkStream没有正确释放,或者异常分支里return提前退出导致using没走到;也可能是打印机侧连接数被占满。解决:所有网络对象都放进using,异常路径也要保证释放;连接改成短连接(每次发完就关),别为了省那点握手开销挂长连接——打印机固件的 socket 管理没你想的那么稳。另外把ConnectAsync包一层超时,别用同步Connect。

补充一条不算坑但经常被忽略的:^FD字段里的^、~、_三个字符是保留的,业务数据里出现(比如商品名带^)会截断整行。统一在发送前做转义,字段前加^FH,把_替换成_5F、^替换成_5E、~替换成_7E,注意替换顺序必须是先_后其他两个,否则会把刚生成的转义序列再转一次。

6. 进阶:把 ZPL 做成模板层,以及上线前怎么验

demo 里的拼装是硬编码字符串,接两三个标签就撑不住了。我习惯抽一层极薄的模板:ZPL 原文存成.zpl文件,里面用{{key}}占位,运行时替换。好处是排版调整交给懂标签的人改文本,不用重新编译,也方便做版本管理。

using System.Collections.Generic; using System.IO; using System.Text; public static class ZplTemplate { public static string Render(string templatePath, IDictionary<string, string> values) { string tpl = File.ReadAllText(templatePath, Encoding.UTF8); foreach (var kv in values) tpl = tpl.Replace("{{" + kv.Key + "}}", Escape(kv.Value)); return tpl; } // 顺序不能反:先转义下划线,否则后面生成的 _5E 会被二次转义 private static string Escape(string s) => s .Replace("_", "_5F") .Replace("^", "_5E") .Replace("~", "_7E"); }

模板文件长这样,顶部^FH打开十六进制转义,字段里的特殊字符就不会截断:

^XA ^PW600^LL400 ^LH0,0 ^CI28 ^FO40,30^A0N,34,34^FH^FD{{name}}^FS ^FO40,90^A0N,28,28^FD{{sku}}^FS ^BY3,2,90 ^FO40,140^BCN,90,Y,N,N^FH^FD{{barcode}}^FS ^FO40,270^BQN,2,6,Q,0^FDLA,{{url}}^FS ^XZ

参数上只有一条要守:占位符的值必须全部经过Escape,包括看起来「肯定安全」的数字和 URL。URL 里带~的情况不少见,漏转义就是整行消失,而且不报错。

验证环节我固定走三步,顺序不能换。第一步是文本比对:渲染后的 ZPL 存成.zpl文本,用编辑器数一遍^FO和^FS的数量是否相等,不相等必然错位。第二步是离线预览:把 ZPL 送到一台装了纯文本驱动的普通打印机,或者干脆发到文件,看指令结构是否闭合,这一步能抓出九成的语法错误,且不浪费标签纸。第三步才是实机首件:打一张,用扫码枪在 10cm、30cm、50cm 三个距离各扫三次,全部通过才放量。中间跳过第二步直接上机,废标能堆半箱。

从那以后我每次上新标签模板都强制走完这三步,尤其是第二步——它花的时间不到一分钟,却能挡掉最难查的那类「语法没错但排版错位」的问题。这套 demo 值得留着的地方也在这:它把通道、指令、状态回读三条线都摊开给你看,你照着改比从零试要快得多,把模板层加进去就能直接进项目。希望帮到你。

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

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

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

立即咨询