简介:面向具备一定C#基础、希望把Zebra工业打印机接入业务系统的开发者,这份压缩包提供了一套可直接在Visual Studio中打开调试的打印示例工程,覆盖标签、条码、二维码及收据等常见打印场景,适合物流、零售、医疗等需要现场打印的行业应用参考。包内共88个文件,以C#源码、解决方案与项目配置、窗体资源、应用程序配置及说明文档为主,另有编译生成的程序与依赖库,压缩包约348KB,体量轻便但结构完整。已有1147人学习,说明其作为入门与集成参考具有一定认可度。读者可从中了解C#与Zebra打印机通信的基本组织方式,观察打印任务如何封装、界面如何触发、配置如何读取,并借助说明文档快速跑通示例,再按自身业务改造标签模板与输出参数,减少从零搭建demo的试错成本。
1. 从一张贴歪的合格证说起:Zebra 打印 demo 到底解决什么问题
去年帮一个做小家电的朋友收拾仓库,他们出货标签是三个人用 Excel 排版、激光打印机打、手工裁,结果贴出去十张里有三张条码扫不出来,退货单里的“条码无法识别”堆成了小山。后来换成 Zebra 标签机,问题反而更多了——驱动装好之后,Word 里拖位置,一换分辨率整张标签就飘。这个 C# demo 就是冲着这件事去的:不跟驱动排版较劲,直接用 ZPL 指令把标签“画”出来,通过打印队列或者 9100 端口把一段原始字节流丢给打印机。它适合两类人,一类是要在 WinForm 里快速加个打印按钮的业务开发,另一类是手上有一堆标签模板、被偏移和乱码折腾过的实施同学。看完你至少能做到三件事:跑通第一张标签、看懂 ZPL 每一段在干什么、把这套东西接进自己的系统。
2. 项目结构拆解:一个 C# demo 里塞了哪几层东西
2.1 目录与文件角色:从解决方案到资源文件
这类打印 demo 的骨架基本跑不出下面这个形状,压缩包解出来大致是这么多东西,多一个少一个不影响你理解主链路:
ZebraPrintDemo/ ├── ZebraPrintDemo.sln ├── ZebraPrintDemo/ │ ├── Program.cs 程序入口 │ ├── Forms/ │ │ ├── MainForm.cs 界面逻辑:选打印机、填数据、点打印 │ │ └── MainForm.Designer.cs │ ├── Printing/ │ │ ├── RawPrinterHelper.cs 走 Windows 队列的 P/Invoke 封装 │ │ ├── NetworkPrinterClient.cs 走网口 9100 的 Socket 封装 │ │ ├── PrinterDiscovery.cs 枚举本机打印机列表 │ │ └── PrintJob.cs 一次打印任务的上下文(目标、数据、重试) │ ├── Zpl/ │ │ ├── ZplBuilder.cs 指令拼装 │ │ ├── ZplCommand.cs 单条指令的封装 │ │ └── Templates/ 外置模板 │ │ ├── product_label.zpl │ │ └── qrcode_label.zpl │ ├── Models/ │ │ └── LabelData.cs 标签字段模型 │ ├── Config/ │ │ └── appsettings.json 打印机名、IP、端口、DPI、标签尺寸 │ └── Utils/ │ └── EncodingHelper.cs 编码与特殊字符转义 └── README.md分层的意图很清楚:Models只描述业务字段,Zpl只负责把字段翻译成指令串,Printing只管把字节送到设备。这条边界划得好不好,决定了你后面改模板时会不会把打印逻辑一起撬翻。很多项目最后烂掉,就是因为在 Form 里直接拼 ZPL 字符串,客户加一个字段要从几百行事件处理里找。
LabelData一般长这样,字段设计要贴合业务而不是贴合 ZPL:
public class LabelData { public string Title { get; set; } // 品名 public string Spec { get; set; } // 规格 public string Code { get; set; } // 条码内容 public string QrContent { get; set; } // 二维码内容 public int Copies { get; set; } = 1; // 打印份数 }我一般会在这里多留一个Dictionary<string,string> Extras,客户临时加个“生产批次”字段就不用改类结构,模板里用占位符取就行。这是血泪经验,字段模型改一次,编译、回归、发布全得走一遍,能拖就拖成字典。
2.2 两种打印通路:RawPrinterHelper 和 NetworkPrinterClient 的分工
demo 里通常会同时存在两条通路,很多人第一次看会迷糊,其实场景完全不同。
第一条是RawPrinterHelper,走 Windows 打印后台处理程序。前提是本机已经装了厂商驱动、有打印机队列,你调用winspool.drv里的接口,把RAW类型的数据直接塞进队列,驱动的渲染环节被绕过,指令原样进设备。好处是能复用驱动的端口配置、纸张设置,坏处是依赖这台机器的驱动状态,换个环境就得重装。
第二条是NetworkPrinterClient,直接TcpClient连打印机 IP 的 9100 端口,把字节流丢过去。不依赖驱动、不依赖 Windows 队列,跑在服务端、跑在 Linux 容器里都行。代价是你要自己管连接、自己管超时、自己管并发,而且拿不到驱动层那套“排队中/已打印”的状态。
选型上我的习惯是:桌面端单机应用走第一条,省事;后台服务、批量打标、多台设备集中管理走第二条,可控。项目里如果两条都有,通常会在PrintJob里做一个枚举开关,调用方不用关心底下走的哪条。
3. 核心代码逐段拆解:ZPL 拼装、P/Invoke 发送、状态回读
3.1 ZPL 模板拼装:从字段模型到一段字符串
ZPL 是纯文本指令,以^XA开始、^XZ结束,中间每一段都是“先定位、再定样式、再填数据”。核心指令就那么几条,记住就能改任意标签:
| 指令 | 作用 | 常见写法 |
|---|---|---|
^XA/^XZ | 标签开始 / 结束 | 一对出现,缺一个就整张不出来 |
^PW | 打印宽度(点) | 203dpi 下 4 英寸 = 812 |
^LL | 标签长度(点) | 要和实际标签纸对得上 |
^FO | 字段原点 | ^FO40,180 |
^A0N | 内置字体 | ^A0N,40,40高 40、宽 40 |
^BY | 条码参数 | ^BY2,2.5,120 |
^BCN | Code128 条码 | ^BCN,120,Y,N,N |
^BQN | 二维码 | ^BQN,2,8 |
^FD/^FS | 字段数据 / 结束 | 数据里不能出现^~ |
^PQ | 打印份数 | ^PQ1,0,1,N |
^CI28 | 声明 UTF-8 | 中文场景必加 |
按这张表,拼装方法能写成这样:
public static string BuildLabel(LabelData d) { var sb = new StringBuilder(); sb.Append("^XA"); // 标签开始 sb.Append("^CI28"); // 声明输入为 UTF-8 sb.Append("^PW812"); // 4 英寸宽,203dpi sb.Append("^LL1218"); // 标签长度 152mm 左右 sb.Append("^LH0,0"); // 原点归零,避免继承上次偏移 // 第一行:品名,40 号字 sb.Append("^FO40,40^A0N,40,40^FD" + Esc(d.Title) + "^FS"); // 第二行:规格,小一号 sb.Append("^FO40,110^A0N,28,28^FD" + Esc(d.Spec) + "^FS"); // 条码:模块宽 2,宽窄比 2.5,高 120,显示可读字符 sb.Append("^FO40,180^BY2,2.5,120^BCN,120,Y,N,N^FD" + Esc(d.Code) + "^FS"); // 二维码:右侧,纠错等级 L,输入模式 A sb.Append("^FO480,180^BQN,2,8^FDLA," + Esc(d.QrContent) + "^FS"); sb.Append("^PQ" + Math.Max(1, d.Copies) + ",0,1,N"); // 份数 sb.Append("^XZ"); // 标签结束 return sb.ToString(); } // 字段数据里出现 ^ 或 ~ 会把指令流打断,统一替换成空格最省事 private static string Esc(string s) => (s ?? string.Empty).Replace("^", " ").Replace("~", " ");参数上最容易翻车的是^PW和^LL。^PW单位是点,203dpi 的机器 4 英寸就是 812 点,300dpi 的机器同样 4 英寸是 1218 点,换机器不改这两个值,标签会横向截断或者整片空白。^LL跟着标签纸的实际高度走,纸是 152mm 就填约 1218,填大了会多走纸,填小了会跨张。
Esc那个方法看着土,但真的很关键。业务数据里带个^或者~,后面整段指令全部错位,打出来是一堆看不懂的字符,排查时你根本想不到是客户在规格里写了型号A^B。指令里^FS之后再无内容,打印机不会报错,只会默默吞掉。
注意:
^CI28只是告诉打印机“输入是 UTF-8”,并不代表它一定能显示中文。中文字形要么走内置中文字库(部分机型有),要么提前用^CW下载字体到打印机,否则打出来是问号或方块,这个坑后面单独说。
3.2 走 Windows 打印队列:winspool 的四个 P/Invoke
这条通路的本质是向打印队列写一个RAW类型的文档。顺序是OpenPrinter→StartDocPrinter→StartPagePrinter→WritePrinter→EndPagePrinter→EndDocPrinter→ClosePrinter,少一步就可能卡队列。
using System; using System.Runtime.InteropServices; public class RawPrinterHelper { [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] public class DOC_INFO_1 { [MarshalAs(UnmanagedType.LPWStr)] public string pDocName; [MarshalAs(UnmanagedType.LPWStr)] public string pOutputFile; [MarshalAs(UnmanagedType.LPWStr)] public string pDataType; // 必须是 "RAW" } [DllImport("winspool.Drv", EntryPoint = "OpenPrinterW", SetLastError = true, CharSet = CharSet.Unicode)] public static extern bool OpenPrinter(string src, out IntPtr hPrinter, IntPtr pd); [DllImport("winspool.Drv", EntryPoint = "ClosePrinter", SetLastError = true)] public static extern bool ClosePrinter(IntPtr hPrinter); [DllImport("winspool.Drv", EntryPoint = "StartDocPrinterW", SetLastError = true, CharSet = CharSet.Unicode)] public static extern bool StartDocPrinter(IntPtr hPrinter, int level, [In, MarshalAs(UnmanagedType.LPStruct)] DOC_INFO_1 di); [DllImport("winspool.Drv", EntryPoint = "EndDocPrinter", SetLastError = true)] public static extern bool EndDocPrinter(IntPtr hPrinter); [DllImport("winspool.Drv", EntryPoint = "StartPagePrinter", SetLastError = true)] public static extern bool StartPagePrinter(IntPtr hPrinter); [DllImport("winspool.Drv", EntryPoint = "EndPagePrinter", SetLastError = true)] public static extern bool EndPagePrinter(IntPtr hPrinter); [DllImport("winspool.Drv", EntryPoint = "WritePrinter", SetLastError = true)] public static extern bool WritePrinter(IntPtr hPrinter, IntPtr pBytes, int dwCount, out int dwWritten); public static bool SendBytes(string printerName, byte[] bytes) { IntPtr hPrinter = IntPtr.Zero; IntPtr buf = IntPtr.Zero; try { if (!OpenPrinter(printerName, out hPrinter, IntPtr.Zero)) throw new InvalidOperationException("打开打印机失败,检查队列名是否精确匹配"); var di = new DOC_INFO_1 { pDocName = "ZPL Label", pDataType = "RAW", pOutputFile = null }; if (!StartDocPrinter(hPrinter, 1, di)) throw new InvalidOperationException("StartDocPrinter 失败"); if (!StartPagePrinter(hPrinter)) throw new InvalidOperationException("StartPagePrinter 失败"); buf = Marshal.AllocCoTaskMem(bytes.Length); Marshal.Copy(bytes, 0, buf, bytes.Length); bool ok = WritePrinter(hPrinter, buf, bytes.Length, out int written); if (!ok || written != bytes.Length) throw new InvalidOperationException($"写入不完整 {written}/{bytes.Length}"); EndPagePrinter(hPrinter); EndDocPrinter(hPrinter); return true; } finally { if (buf != IntPtr.Zero) Marshal.FreeCoTaskMem(buf); if (hPrinter != IntPtr.Zero) ClosePrinter(hPrinter); // 不关会泄漏句柄,打多了系统卡死 } } }逐点解释。EntryPoint用的是W后缀(宽字符)版本,打印机名里有中文或空格时,A 版本会直接返回 false,而且GetLastError也看不出所以然。pDataType必须是"RAW",写成"TEXT"后台会试图做编码转换,结果是打出来一堆乱码。WritePrinter必须传非托管内存指针,所以中间有AllocCoTaskMem+Marshal.Copy这一段,直接传byte[]编译能过但运行会崩。最后written要校验,写入长度对不上说明队列出问题,这比事后去打印机面板上看灯靠谱得多。
调用侧就一行:
byte[] payload = Encoding.UTF8.GetBytes(ZplBuilder.BuildLabel(data)); RawPrinterHelper.SendBytes("ZDesigner ZD420-203dpi ZPL", payload);打印机名建议从PrinterSettings.InstalledPrinters里选,别写死在代码里。
3.3 走网口 9100:TcpClient 直发的超时与分包
网口直发的坑集中在两处:连接不设超时,网络不通时主线程卡到界面假死;数据一次性写完但打印机缓冲区吃不下,超过几十 KB 的批量标签会丢内容。
public static void SendToNetwork(string ip, int port, byte[] data, int timeoutMs = 3000) { using var client = new TcpClient(); var ar = client.BeginConnect(ip, port, null, null); // 关键:BeginConnect + WaitOne 才能控制连接超时 if (!ar.AsyncWaitHandle.WaitOne(timeoutMs)) { client.Close(); throw new TimeoutException($"连接 {ip}:{port} 超过 {timeoutMs}ms"); } client.EndConnect(ar); using var ns = client.GetStream(); ns.WriteTimeout = timeoutMs; ns.ReadTimeout = timeoutMs; // 按 4KB 分片发送,给打印机留出解析时间 const int chunk = 4096; for (int offset = 0; offset < data.Length; offset += chunk) { int len = Math.Min(chunk, data.Length - offset); ns.Write(data, offset, len); ns.Flush(); Thread.Sleep(30); // 批量标签时这 30ms 能救回一大半丢数据 } }Thread.Sleep(30)看着很土,但网口打印机没有流控反馈,你发得比它解析得快,缓冲区一满,后面的指令就被丢弃了,表现出来就是“一次打 100 张只出来 60 张,最后几张内容缺失”。这是玄学问题的典型来源,调半天代码找不到原因,其实是设备端吃不下。
3.4 状态回读:用 ~HS 换掉“靠猜”
打印这事儿最难受的是没有反馈。你的代码返回 true,本质上只代表“字节送出去了”,设备有没有打、卡没卡纸、缺不缺纸,一概不知道。ZPL 提供了回读指令,~HS返回一串以\x02和\x03包起来的状态文本,里面包含纸尽、暂停、队列条数。
public static string QueryStatus(string ip, int port = 9100) { using var client = new TcpClient(ip, port); using var ns = client.GetStream(); ns.ReadTimeout = 2000; byte[] cmd = Encoding.ASCII.GetBytes("~HS"); ns.Write(cmd, 0, cmd.Length); ns.Flush(); var buf = new byte[1024]; int n = ns.Read(buf, 0, buf.Length); // 返回文本以 STX/ETX 包裹 return Encoding.ASCII.GetString(buf, 0, n); }返回值按行拆,第二行第三段是纸尽标志、第三行第一段是暂停标志。这里不建议做太复杂的解析,实际项目里我的做法是只判断“有没有回包”,有回包就说明设备在线、端口通;真要判断缺纸,交给打印机的指示灯和人工处理。把状态机做太细,客户换个机型固件版本不同,解析全废,维护成本比收益高。
4. 跑起来:环境准备、配置项与第一张标签
4.1 运行环境与依赖清单
环境这块没什么玄的,照着装就行:
- Visual Studio 2019 及以上,工作负载勾“.NET 桌面开发”
- 老项目通常是 .NET Framework 4.6.1~4.8,新一点的可能是 .NET 6/8 WinForms
- 打印机驱动必须装,装完在“设备和打印机”里能看到队列名
- 平台目标建议显式指定
x86或x64,别用AnyCPU。P/Invoke 结构体里有指针,AnyCPU在 64 位机器上跑 32 位驱动会出BadImageFormatException之类的怪东西
配置项一般放appsettings.json或者App.config,含义如下:
| 配置键 | 示例值 | 说明 |
|---|---|---|
Printer.Name | ZDesigner ZD420-203dpi ZPL | Windows 队列名,必须逐字符一致 |
Printer.Mode | Driver/Network | 决定走哪条通路 |
Printer.Ip | 192.168.1.50 | 网口模式必填 |
Printer.Port | 9100 | 绝大多数机型默认 9100 |
Label.WidthDots | 812 | 4 英寸 @203dpi |
Label.LengthDots | 1218 | 6 英寸,跟着实际纸走 |
Label.Dpi | 203 | 换机器时先改这个再改宽高 |
WidthDots和Dpi一定要分开配。我见过有人直接把宽度写成“4 英寸”,代码里再换算,结果换个 300dpi 机器全部错位。配置里存点值,换算逻辑集中在一个DpiHelper里,这是唯一不会出错的做法。
4.2 调试顺序:先打测试页,再打业务标签
新手最容易犯的错是环境还没通就先拼业务模板,然后对着空白标签找半天问题。正确的顺序是这样:
第一步,打印机自检页。按住走纸键开机,或者面板菜单里点“打印配置”,能出来一张带参数的自检页,说明设备本身、纸、碳带都没问题。
第二步,纯文本 ZPL 打进队列。用记事本写两行:
^XA ^FO50,50^A0N,60,60^FDHELLO^FS ^XZ存成.txt,用copy /b test.txt \\localhost\队列名发过去。能打出 HELLO,通路就通了。
第三步,再跑 demo 里的BuildLabel。这一步如果空白,问题一定在拼装逻辑,不在环境;如果位置飘,问题在^PW^LL^LH三个参数上,别去怀疑驱动。
第四步,才轮到中文和二维码。顺序错了,你会在错误的方向上浪费一整天。
4.3 打印预览:没有纸的时候怎么验证
现场调试经常遇到“打印机还没到货”或者“纸用完了”的情况,靠肉眼验证不现实。常见做法是写一个把 ZPL 渲染成图片的本地预览,或者用摄像头对着打印机拍。我在项目里的土办法是维护一个debug_preview开关,打开后把生成的 ZPL 原文按行打到日志里,比对坐标是否落在0~812范围内,超出就是定位写错。这个方法不能看字形,但能拦住八成的位置问题,比凭空猜强。
5. 避坑与排查:打印空白、中文乱码、标签偏移的现场记录
5.1 五条踩坑记录
现象一:代码返回成功,打印机毫无反应,队列里也看不到任务。原因多半是队列名不对。OpenPrinter传的是 Windows 里的显示名,和驱动里的设备型号不是一回事,带空格、带(副本 1)后缀都很常见。 解决:在“设备和打印机”里右键 → 打印机属性,复制完整名称;或者代码里枚举PrinterSettings.InstalledPrinters让用户下拉选,别硬编码。
现象二:标签打出来是一堆乱码字符,像^XA^FO40,40被原样吐出来。原因有两个可能,一是pDataType没写成"RAW",后台把数据当文本处理了;二是打印机本身不支持 ZPL,收到的是 EPL 或其它语言,指令不认识就原样打。 解决:确认DOC_INFO_1.pDataType = "RAW";再看自检页上标的语言,非 ZPL 机型要在驱动里切换或者换机型。
现象三:中文全部变成问号或方块。原因:^CI28只是声明输入编码,字形还是要靠打印机字体。内置西文字库没有中文字形,自然显示不出来。 解决:三条路,装带中文的字体模块;用^CW把 TTF 字体下载到打印机再用^A@引用;或者干脆把中文那段渲染成单色位图,用^GF打进标签。最后一种最稳,代价是数据不可搜索,看你的业务能不能接受。
现象四:标签内容整体偏右上角,或者每打一张飘一点。原因:^LH(标签原点)沿用了上一张的偏移,或者是介质校准没做,打印机不知道标签间隙在哪。 解决:每段 ZPL 开头强制加^LH0,0;然后做一次介质校准(面板里执行,或发送~JC),让它自己测出标签长度。这两个动作能治好九成的“飘”。
现象五:批量打印时中间几张内容缺失,前后都正常。原因:网口直发不等缓冲,一次性写几 MB 过去,设备解析不过来丢包。 解决:按 4KB 分片、每片之间Sleep(30),或者干脆把多张标签拆成多次独立连接发送。别图省事一次全塞。
5.2 最小排查清单
出问题时按这个顺序过一遍,比乱改代码快得多:
- 自检页能打吗?不能就是硬件或耗材问题,别碰代码。
^XA和^XZ成对了吗?用日志把 ZPL 原文打出来看。- 打印机名和代码里的一模一样吗?复制粘贴,别手敲。
^PW对应的 dpi 和实际机器一致吗?203 和 300 差一倍。- 中文那段单独拆出来打,能出字再往模板里合。
提示:调试期把生成的 ZPL 完整写进日志文件,比在打印机面板上按半天按键有用。出问题直接拿日志里的字符串去比对,一眼就能看出哪条指令漏了。
6. 二次开发:把 demo 改造成能接业务的标签服务
demo 本身是个单机工具,接进业务系统要动三处,改动量不大但收益明显。
第一处,模板外置。把 ZPL 从 C# 字符串里挪到.zpl文件,用{Title}、{Code}这类占位符,加载后做一次简单替换:
public static string Render(string templatePath, LabelData d) { string tpl = File.ReadAllText(templatePath, Encoding.UTF8); return tpl.Replace("{Title}", Esc(d.Title)) .Replace("{Spec}", Esc(d.Spec)) .Replace("{Code}", Esc(d.Code)) .Replace("{Qr}", Esc(d.QrContent)) .Replace("{Copies}", Math.Max(1, d.Copies).ToString()); }换模板不用重编译,运营改个文件就生效,客户加一版新标签也不用走发版流程。占位符替换要配一个“模板里还剩没替换的{”校验,漏字段打出来就是字面的{Code},测试不仔细很容易漏到生产环境。
第二处,把打印动作从 UI 线程里摘出来,做成队列。PrintJob进BlockingCollection,后台一个消费者线程按顺序打,失败进重试队列,重试三次还不行就落库标记失败。这一步的意义是让“点打印”这个动作秒返回,同时避免两台客户端同时抢一台打印机导致内容交叉。如果部署规模再大一点,直接包一层最小 Web API,暴露POST /api/print,多台设备按 IP 路由过去,前端不需要知道打印机在哪。
第三处,输出验证。改造完之后别只看日志里的true,要验证实际结果。我的做法是给每张标签加一个唯一流水号,人工抽检时扫码核对;批量场景下用回读~HS判断设备在线状态,再配合打印机侧的已打印计数做对账。这套组合拳比单看返回值可靠得多。
从那以后我每次接手涉及硬件输出的项目,都会先把“发出去”和“打出来”这两件事在代码里拆成两个明确的返回状态,绝不合并成一个 bool。希望帮到你。
本文还有配套的精品资源,点击获取