包装工位是追溯链路的最后一关:箱条码扫不出来,货发出去就是"黑户";中文品名打成问号,仓库只能贴手写条;用 Windows 驱动打印,每出一张标签弹一次打印任务、服务还没登录就打不出来。给工业现场做标签打印,正确姿势是模板与数据分离、ZPL 原语直发、编号与打印状态全程可查。这篇按选型、排版、中文、发送、防错五个层面给完整方案和 C# 代码。

一、三条打印路线先选型,别一上来就拖 PrintDocument

路线原理适合场景主要坑
Windows 驱动 + GDI
(PrintDocument)
走打印机驱动,Graphics 画图版式复杂、有现成字体、打印机常换品牌每弹打印任务、依赖登录会话、条码靠画图尺寸易变形、服务/无人值守场景不可用
Bartender / CodeSoft 模板专业软件排版,SDK 或命令行传数据标签版式多、客户指定 Bartender、可变数据复杂按工位收授权费;SDK 版本与部署麻烦;长期成本高
ZPL / TSPL 原语直打拼指令字符串,经网口 9100 或 USB Raw 直发打印机产线固定工位、无人值守、同品牌打印机批量部署中文需要额外处理(见第四节);版式改版要改模板字符串

▲ 追溯标签版式一旦定型很少改,产线场景优先 ZPL 直打:无弹窗、无授权、响应快(从发指令到出纸通常 100ms 内),还能查打印机状态。

先确认指令集再动手。斑马(Zebra)用 ZPL/ZPL II;TSC、立象、科诚等兼容机多用 TSPL/TSPL2,指令不通用。买打印机时问清楚"支持不支持 ZPL 仿真",很多机型可在菜单里切换仿真语言。下文以斑马 ZPL 为例。

二、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,hCode 128 条码^BCN,70,Y,N,N
^BQN,2,sQR 二维码(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 点。排版时把标签画成格子:

品名(中文,位图/字体) 日期 / 批号 Code128 条码 ^BC QR ^BQ 序列号 SN(^A0N 大字) 数量 / 工位

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        标签结束,收到即出纸
条码内容只给原文。Code128 的起始符、校验位、终止符由打印机固件自动生成。自己用库画好条码图再发,反而会因为缩放、像素对齐导致"人眼看着像、扫码枪不认"——这是条码扫不出的第一大原因。第二大原因是 ^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();
}
位图必须是纯白底纯黑字的 1bpp。彩色/抗锯齿灰阶直接映射成点阵会糊成一团;GDI 渲染时关掉 ClearType、字号与物理点阵匹配(28px ≈ 3.5mm 高),再在标签纸上实测扫码和肉眼清晰度。

五、发送:网口走 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 状态串,解析其中的错误标志位即可。

① 预占号段从库表取流水号并置"占用",唯一约束防并发重号
② 发送 ZPL9100/RAW 发送,失败立即告警并释放号段
③ 查状态延时读 ~HS/~HQES,纸尽/碳带尽/卡纸报错
④ 确认闭环无错误 → 置"已打印";异常 → 任务挂起人工处理

▲ 注意 ~HS 的响应从你发送指令的同一条 TCP 连接异步返回,必须在读循环里收;用"发完即关"的短连接查不到状态。多数项目维持一条长连接 + 后台收状态线程。

如果工位有出纸光电/贴标机反馈信号,更可靠的做法是把这个 IO 点接进 PLC 或采集模块:固件状态 + 物理出纸信号双确认。只有软件确认而没有物理信号的标签,记"存疑",装箱工位扫码复核时会再卡一次,双保险。

七、防重号:追溯体系的底线

重号意味着两个箱子一个身份证,后续所有追溯数据全部错位。三层防护:

  1. 数据库唯一约束兜底。标签表 serial_no 建 UNIQUE 索引,插入撞号直接失败。不要把取号写成"先 SELECT MAX 再 +1"——两个工位并发必撞;用独立序列表(UPDATE seq SET last_no=last_no+step OUTPUT ...)或号段预分配;
  2. 重打必须留痕,不能复用旧号。标签打皱、碳带打花需要重打时,新号关联原号生成一条"重打记录"(原因、操作人、时间),原号置"作废",扫到作废号时系统报警。绝不能把同一个 SN 再打一张;
  3. 打印与扫码闭环。装箱工位扫码枪扫箱标作为"已使用"确认,未在有效期内扫码的标签标记"已打未使用",班次结束输出差异清单。
// 号段预占:一条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;   // 本工位拿到的起始流水号
}

八、上线前的现场检查清单

  1. 先打一张量尺寸:用斑马自带的校准(Feed 键长按或 ~JC)让机器认纸缝,再量实际出纸位置,微调 ^FO 坐标和 ^LS(左偏移),别在代码里盲调;
  2. 碳带与纸型匹配:铜版纸配蜡基碳带、PET/PVC 配树脂基;用错碳带浓度(^MD)加多少都掉粉,条码边缘发虚扫不出;
  3. 固定 IP、关 DHCP:9100 直打靠 IP 寻址,打印机 IP 漂了整条线停摆,交换机上做 MAC 绑定;
  4. 扫码枪实测三个角度:正扫、45 度、隔 30cm 各扫 50 张,一次通过率 100% 才算排版合格;
  5. 断网/断电演练:打印中途拔网线、断电,验证号段不重不丢、恢复后待打任务与实际标签张数一致。

标签打印在项目里常常只排半天工期,却决定追溯系统最后一公里能不能闭合。把这套东西做扎实:ZPL 模板化排版、网口 Raw 直发、状态物理双确认、流水号库层兜底、重打必留痕,包装工位就不会再成为整条产线数据断链的地方。