资讯动态

C# 驱动 Zebra 打印机:ZPL 指令直通、状态回读与批量打印实战

发布时间:2026/10/25 17:01:41 来源:尧图企业网站定制
简介这是一份面向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 是大多数人第一次接触打印的方式PrintDocumentGraphics.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(pDataTypeRAW)好无驱动名不对静默失败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 字符串当字节流灌进 Spoolerusing 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 字节流适合^CI17new UTF8Encoding(false)得到无 BOM 的 UTF-8 流适合^CI28。无论哪种都不要带 BOM那三个字节会被打印机当作指令前缀导致整张标签错位。2.3 一段 ZPL 从字符串到标签的落地验证先别急着接业务数据用小段指令验证链路。下面这段是最小闭环包含标签尺寸、原点、一段文字和一个 Code128using 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 dot300 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,0QR 的^FD数据要带两个字符前缀格式是「纠错等级字母 模式字母 逗号 内容」例如^FDLA,HTTPS://X.CN/1^FSL是纠错等级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^CI28new UTF8Encoding(false)理由是整个 .NET 侧不用做代码页转换跨语言调用时也不会因为系统区域设置变化而崩。^CI17的老机器上还有一个隐坑CodePagesEncodingProvider在 .NET Core/.NET 5 里默认不注册直接Encoding.GetEncoding(936)会抛ArgumentException。解决办法是在程序启动时执行一次Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);并确保项目引用了System.Text.Encoding.CodePages包。3.3 标签尺寸、浓度、速度与偏移的标定流程排版偏了、打一半走纸、浓度太浅扫不出这三类问题九成出在参数上。下面是一套我固定的标定顺序新机器上手一次后面基本不用再动先打一张机器自检配置标签关机按住走纸键开机或面板菜单里找「打印配置」确认 dpi、介质类型、当前^PW/^LL。用卷尺量实际标签的宽和高换算成 dot写进^PW和^LL跟机器内的值对齐。设置介质类型间隙纸用^MNY连续纸用^MNN黑标纸用^MNW。设错的表现是每打一张空走一张或者内容压在两标签接缝上。做一次手动校准面板上 Feed Pause 组合或菜单里的 Calibration让机器重新学习标签间距。浓度先用^MD10、速度用^PR4起步扫不出来再往上加浓度、往下减速度。整体偏移用^LH平移原点单方向微调用^LT范围 -120 到 120 dot。参数表指令作用常用值说明^PW打印宽度按标签实际宽度换算 dot设小了右侧被裁^LL标签长度按标签实际高度换算 dot设错会跨标签打印^MD打印浓度-30 ~ 30常用 8–15太高会糊、堵头^PR打印速度1 ~ 14 ips越低越清晰^LH原点偏移x,ydot整体平移^LT纵向微调-120 ~ 120只动 Y 方向一开始别把浓度和速度一起调两个变量同时动最后你不知道是哪一项起的作用。4. 状态回读与批量打印把「打完没」变成可判断的信号单张打印谁都能跑通真正让人头疼的是批量一百张里少了两张、卡纸了没人知道、重发了三次同一张。这些问题靠WritePrinter的返回值判断不了它只告诉你「数据交给 Spooler 了」不告诉你「纸上打出来了」。4.1 ~HS 状态查询与双向通讯ZPL 有一条主机状态查询指令~HS打印机收到后会在同一条 TCP 连接上回三行文本每行以0x02STX开头、0x03ETX结尾字段用逗号分隔。这条路只能走网络或串口走 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, IDictionarystring, 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 值得留着的地方也在这它把通道、指令、状态回读三条线都摊开给你看你照着改比从零试要快得多把模板层加进去就能直接进项目。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑