包装工位是追溯链路的最后一关:箱条码扫不出来,货发出去就是"黑户";中文品名打成问号,仓库只能贴手写条;用 Windows 驱动打印,每出一张标签弹一次打印任务、服务还没登录就打不出来。给工业现场做标签打印,正确姿势是模板与数据分离、ZPL 原语直发、编号与打印状态全程可查。这篇按选型、排版、中文、发送、防错五个层面给完整方案和 C# 代码。
一、三条打印路线先选型,别一上来就拖 PrintDocument
| 路线 | 原理 | 适合场景 | 主要坑 |
|---|---|---|---|
| Windows 驱动 + GDI (PrintDocument) | 走打印机驱动,Graphics 画图 | 版式复杂、有现成字体、打印机常换品牌 | 每弹打印任务、依赖登录会话、条码靠画图尺寸易变形、服务/无人值守场景不可用 |
| Bartender / CodeSoft 模板 | 专业软件排版,SDK 或命令行传数据 | 标签版式多、客户指定 Bartender、可变数据复杂 | 按工位收授权费;SDK 版本与部署麻烦;长期成本高 |
| ZPL / TSPL 原语直打 | 拼指令字符串,经网口 9100 或 USB Raw 直发打印机 | 产线固定工位、无人值守、同品牌打印机批量部署 | 中文需要额外处理(见第四节);版式改版要改模板字符串 |
▲ 追溯标签版式一旦定型很少改,产线场景优先 ZPL 直打:无弹窗、无授权、响应快(从发指令到出纸通常 100ms 内),还能查打印机状态。
二、ZPL 最小骨架:一张标签就是一个 ^XA … ^XZ
ZPL 是以 ^(脱字符)开头的纯 ASCII 指令串,指令不区分大小写。一张标签从 ^XA 开始、^XZ 结束,中间所有内容打印在同一张标签上,连续发两张就连续出两张。
| 指令 | 含义 | 典型写法 |
|---|---|---|
| ^XA / ^XZ | 标签开始 / 结束 | 每张标签各一个,成对出现 |
| ^LL | 标签长度(点) | ^LL320(40mm@203dpi) |
| ^FOx,y | 字段起点坐标(Field Origin) | ^FO30,20 |
| ^FD…^FS | 字段数据 / 字段结束 | ^FDSN:20260914-A01-000123^FS |
| ^A0N,h,w | 内置可缩放字体,高/宽(点) | ^A0N,32,32 |
| ^BYw | 条码默认模块宽度 | ^BY2(2点,Code128常用) |
| ^BCN,h | Code 128 条码 | ^BCN,70,Y,N,N |
| ^BQN,2,s | QR 二维码(Model 2) | ^BQN,2,5 |
| ^GBw,h,t | 矩形框/分隔线(t=线粗) | ^GB420,2,2 |
| ^MD / ^MNY | 打印浓度微调 / 撕纸模式 | ^MD10、^MNY |
坐标换算只有一条公式要记住:203dpi 的机器,1mm ≈ 8 个点(203 ÷ 25.4 ≈ 8)。一张 60mm×40mm 的标签,打印宽度 480 点、长度 320 点。排版时把标签画成格子:
60mm × 40mm 标签纸 | 分辨率 203dpi | 可打印区域约 480 × 320 点 | 原点 ^FO0,0 在左上角
对应一张可以直接发给打印机的完整 ZPL:
^XA 标签开始 ^LL320 标签长度320点(约40mm) ^MNY 撕纸模式(打完走到撕纸口) ^FO30,20^A0N,30,30^FD2026-09-14 / LOT 26091401^FS ^FO30,64^BY2^BCN,64,Y,N,N^FD26091401A01000123^FS ^FD 里放条码原文,校验位和起止符打印机自己算,不要自己拼 ^FO330,60^BQN,2,5^FDQA,https://trace.it-ys.com/s/26091401A01000123^FS ^FO30,200^A0N,36,36^FDSN:26091401-A01-000123^FS ^FO30,275^GB420,2,2^FS ^XZ 标签结束,收到即出纸
^BY 模块太细(1点)配碳带浓度不够,建议固定 2 点起步。三、模板与数据分离:C# 只做占位符替换
不要在代码里到处拼 ^FO…^FD…。版式是工艺的事,把 ZPL 存成模板文件(随程序发布,改版式不用重新编译),程序只替换占位符:
// label-box.zpl 模板(实际文件是纯文本,这里示意结构) // ^FO30,64^BY2^BCN,64,Y,N,N^FD{BARCODE}^FS // ^FO30,200^A0N,36,36^FDSN:{SN}^FS public class LabelData { public string Sn; public string Lot; public string Date; public string Model; public int Qty; } public static class ZplLabelBuilder { private static readonly string TemplateRoot = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "labels"); public static string Build(string templateFile, LabelData d) { var zpl = File.ReadAllText(Path.Combine(TemplateRoot, templateFile), Encoding.ASCII); var barcode = d.Lot + d.Sn.Replace("-", ""); return zpl.Replace("{SN}", Escape(d.Sn)) .Replace("{LOT}", Escape(d.Lot)) .Replace("{DATE}", Escape(d.Date)) .Replace("{BARCODE}", Escape(barcode)) .Replace("{QTY}", d.Qty.ToString()); } // ZPL字段里 ^ 是指令前缀,数据内容出现时必须用 ^FH+_ 转义, // 序列号规则上直接禁用特殊字符,从源头杜绝 private static string Escape(string s) => new string(s.Where(c => char.IsLetterOrDigit(c) || c == '-').ToArray()); }
▲ 模板文件建议命名带版本号(label-box-v3.zpl),打印日志里同时记录模板版本——标签格式改版后,能反查历史某张标签是用哪个版式打的。
四、中文怎么打:三条路按现场条件选
ZPL 本体是 ASCII,直接 ^FD中文^FS 打出来一定是乱码或问号。可行方案三种:
| 方案 | 做法 | 评价 |
|---|---|---|
| 打印机装中文字体 | 把宋体点阵/TTF 下载到打印机 Flash(E: 卡),用 ^A@N,40,40,E:SIMSUN.FNT 调用 | 打印速度快、传输量小;但字体要逐台安装/随配置下发,字号受字体限制,换打印机要重装 |
| 上位机渲染位图 ^GFA 下发 | C# 用 GDI+ 把中文按指定字号渲染成单色 1bpp 位图,转成 ZPL 十六进制图形数据,^GFA 发下去 | 推荐:不挑打印机字体,任意字体/LOGO 都能打;代价是单张指令变大(文字多时有几 KB~十几 KB),网口无压力 |
| Bartender 出 ZPL | 用 Bartender 排含中文模板,导出/打印成 ZPL 后抽取模板 | 适合版式复杂、已有 Bartender 授权的产线 |
^GFA 路线的核心是"把汉字当图片画",下面这个方法给出可用的位图转换:
// 把一段中文渲染成 ZPL 单色位图字段,直接插在 ^FO 定位之后 public static string TextToZplImage(string text, int fontSize = 28) { using var bmp = new Bitmap(1, 1); using var g0 = Graphics.FromImage(bmp); using var font = new Font("微软雅黑", fontSize, FontStyle.Regular, GraphicsUnit.Pixel); var size = g0.MeasureString(text, font); using var img = new Bitmap((int)size.Width + 4, (int)size.Height + 2); using (var g = Graphics.FromImage(img)) { g.Clear(Color.White); g.DrawString(text, font, Brushes.Black, 2, 1); g.Flush(); } var data = LockBitsTo1bpp(img); // 转单色位图,黑字=1白点=0 var bytesPerRow = (img.Width + 7) / 8; var sb = new StringBuilder( $"^GFA,{data.Length},{data.Length},{bytesPerRow},"); foreach (var b in data) sb.Append(b.ToString("X2")); return sb.Append("^FS").ToString(); }
五、发送:网口走 9100 Raw,USB 走 winspool,全程不碰驱动
带网口的斑马打印机内置 Raw TCP 服务,端口固定 9100。上位机连上后直接写字节流,打印机收到 ^XZ 立即出一张,不需要任何打印驱动或后台处理程序:
public class ZplPrinter { private readonly string _host; private readonly int _port; public async Task<bool> PrintAsync(string zpl, int timeoutMs = 3000) { using var cts = new CancellationTokenSource(timeoutMs); using var client = new TcpClient(); try { await client.ConnectAsync(_host, _port); using var ns = client.GetStream(); // 老固件按ASCII解释指令;含^GFA十六进制图形时同样用ASCII发(图形已是十六进制文本) var buf = Encoding.ASCII.GetBytes(zpl); await ns.WriteAsync(buf, 0, buf.Length, cts.Token); await ns.FlushAsync(cts.Token); return true; } catch (Exception ex) when (ex is SocketException or TimeoutException) { Log.Warning($"打印机 {_host} 连接失败: {ex.Message}"); return false; // 交上层状态机处理:提示+重试,绝不静默丢单 } } }
USB 直连或只有共享驱动的机器,用 Windows 打印后台的 Raw 通道(winspool.drv),数据类型声明为 RAW,假脱机服务只当搬运工,不做任何渲染:
[DllImport("winspool.drv", SetLastError = true, CharSet = CharSet.Unicode)] static extern bool OpenPrinter(string p, out IntPtr h, IntPtr d); [DllImport("winspool.drv", SetLastError = true)] static extern bool ClosePrinter(IntPtr h); [DllImport("winspool.drv", SetLastError = true, CharSet = CharSet.Unicode)] static extern bool StartDocPrinter(IntPtr h, int l, ref DOC_INFO_1 d); [DllImport("winspool.drv")] static extern bool EndDocPrinter(IntPtr h); [DllImport("winspool.drv")] static extern bool StartPagePrinter(IntPtr h); [DllImport("winspool.drv")] static extern bool EndPagePrinter(IntPtr h); [DllImport("winspool.drv")] static extern bool WritePrinter(IntPtr h, byte[] b, int c, out int w); // printerName 传"斑马 ZD420 (副本2)"这种系统打印机名,RAW 方式下内容必须是打印机认识的ZPL
六、发出去不等于打成功:状态回传与打印状态机
9100 连接写成功,只能说明打印机收了数据。纸尽、碳带尽、仓门打开、卡纸时,打印机会把任务压在缓冲区,产线却以为贴过标了。斑马支持主动状态查询:往同一连接发送 ~HS(主机状态)或 ~HQES(错误状态),打印机回 ASCII 状态串,解析其中的错误标志位即可。
▲ 注意 ~HS 的响应从你发送指令的同一条 TCP 连接异步返回,必须在读循环里收;用"发完即关"的短连接查不到状态。多数项目维持一条长连接 + 后台收状态线程。
如果工位有出纸光电/贴标机反馈信号,更可靠的做法是把这个 IO 点接进 PLC 或采集模块:固件状态 + 物理出纸信号双确认。只有软件确认而没有物理信号的标签,记"存疑",装箱工位扫码复核时会再卡一次,双保险。
七、防重号:追溯体系的底线
重号意味着两个箱子一个身份证,后续所有追溯数据全部错位。三层防护:
- 数据库唯一约束兜底。标签表
serial_no建 UNIQUE 索引,插入撞号直接失败。不要把取号写成"先 SELECT MAX 再 +1"——两个工位并发必撞;用独立序列表(UPDATE seq SET last_no=last_no+step OUTPUT ...)或号段预分配; - 重打必须留痕,不能复用旧号。标签打皱、碳带打花需要重打时,新号关联原号生成一条"重打记录"(原因、操作人、时间),原号置"作废",扫到作废号时系统报警。绝不能把同一个 SN 再打一张;
- 打印与扫码闭环。装箱工位扫码枪扫箱标作为"已使用"确认,未在有效期内扫码的标签标记"已打未使用",班次结束输出差异清单。
// 号段预占:一条UPDATE完成读改写,行锁保证多工位/多线程不撞号 public long OccureSerial(string lineCode, int step = 1) { const string sql = @" UPDATE label_seq SET last_no = last_no + @step OUTPUT INSERTED.last_no WHERE line_code = @line"; using var conn = new SqlConnection(_cs); var end = conn.ExecuteScalar<long>(sql, new { line = lineCode, step }); return end - step + 1; // 本工位拿到的起始流水号 }
八、上线前的现场检查清单
- 先打一张量尺寸:用斑马自带的校准(Feed 键长按或 ~JC)让机器认纸缝,再量实际出纸位置,微调
^FO坐标和^LS(左偏移),别在代码里盲调; - 碳带与纸型匹配:铜版纸配蜡基碳带、PET/PVC 配树脂基;用错碳带浓度(
^MD)加多少都掉粉,条码边缘发虚扫不出; - 固定 IP、关 DHCP:9100 直打靠 IP 寻址,打印机 IP 漂了整条线停摆,交换机上做 MAC 绑定;
- 扫码枪实测三个角度:正扫、45 度、隔 30cm 各扫 50 张,一次通过率 100% 才算排版合格;
- 断网/断电演练:打印中途拔网线、断电,验证号段不重不丢、恢复后待打任务与实际标签张数一致。
标签打印在项目里常常只排半天工期,却决定追溯系统最后一公里能不能闭合。把这套东西做扎实:ZPL 模板化排版、网口 Raw 直发、状态物理双确认、流水号库层兜底、重打必留痕,包装工位就不会再成为整条产线数据断链的地方。
