简介:这是一份基于C#的小票打印功能源码包,面向需要在零售、餐饮等场景中实现票据输出的.NET开发者。资源以PrintDocument类为核心,完整呈现了页面初始化、PrintPage事件绘制、打印对话框调用、数据格式化及布局自定义等关键流程,并涉及条形码/二维码生成、多线程与错误处理等进阶内容,适合有一定WinForms基础的开发者参考。压缩包共27个文件,以cs源码、exe可执行程序、txt说明与配置文件为主,另有sln工程文件、resx资源文件及dependencies等,便于直接打开项目对照学习。资源整体大小约73KB,配合源码注释可快速理解打印机通信与排版实现思路,已有220人学习下载。解压后可依据文件结构逐一梳理窗体逻辑、票据样式和打印输出链路,既能用于日常小票模板改造,也可作为C#打印功能开发时的起步脚手架。
1. 小票打印源码:先搞清楚它是哪种打印方案再下手
做 C# 小票打印,最怕的不是代码写不出来,而是你抄了一套源码,却不知道它是靠驱动、靠指令、还是靠 GDI 绘图实现的。我见过太多人拿着网口指令版代码去接 USB 直连打印机,结果发了一堆字节机器纹丝不动;也见过有人用 PrintDocument 画小票,结果切纸和钱箱控制完全没法做。这份「小票打印源码」覆盖了串口、网口、USB 三种连接方式,核心是用 ESC/POS 指令直接控制打印机,适合餐饮、零售、排队叫号这类需要稳定出票、控制切纸和打开钱箱的场景。新手照着跑能出票,熟手可以按里面的参数调节奏、做重打校验。读这篇之前,你最好先想清楚一个问题:你的打印机是哪种接口,是热敏还是针式,这决定了你要用源码里的哪一段。
2. 小票打印的底层机制:从 GDI 到 ESC/POS,选型才是第一步
2.1 三种常见打印实现:直接绘图、指令模式、驱动打印
C# 里做小票打印,绕不开三种路子。第一种是 GDI 绘图,也就是用 Graphics 对象把小票内容画到打印机的设备上下文里,最后通过 PrintDocument 触发打印。这种方式的好处是所见即所得,字体大小、对齐、图片位置都能在 Windows 打印预览里看到,调试直观。代价是它依赖打印机驱动,换一台驱动不同的打印机,布局就乱掉;而且切纸、开钱箱这类控制命令得通过驱动透传,成功率玄学。
第二种是 ESC/POS 指令模式,这是绝大多数小票打印机支持的裸协议。代码里直接拼接十六进制字节,比如0x1B 0x40初始化打印机、0x1D 0x56 0x42 0x00切纸,然后通过串口、网口或 USB 的 Raw 通道发送过去。这种方式的优势是跨驱动、跨平台,只要打印机支持 ESC/POS,行为一致;缺点是布局完全靠自己算字节偏移,中文字符宽度、每行字节数都得小心。
第三种是驱动打印,也就是直接调用厂商提供的 SDK 或 Windows 打印 API,用打印机厂商封装好的方法。这种方式最省事,功能也最全——比如打印二维码、设置黑标定位。但它绑定厂商,换机器就得换 SDK,而且很多厂商 SDK 是 VB 或 C++ 写的,在 C# 里调用的坑不少。
这份源码走的是第二种,也就是 ESC/POS 指令直发。我一般会推荐这种方案,原因很实际:小票打印机的驱动经常不被维护,尤其在一些收银机上装驱动还会被写保护;而串口或网口发指令是工厂测试时就在用的,可靠性高得多。
2.2 ESC/POS 指令与字符编码:中文小票最容易翻车的地方
ESC/POS 指令本身并不复杂,麻烦的是指令之外的编码。小票打印机接受的数据是字节流,英文和数字走 ASCII,中文则要按打印机的编码表来。市面上的热敏打印机常见支持 GBK 或 GB2312,也有部分走 UTF-8 转码。如果你在 C# 里直接Console.WriteLine或者直接Encoding.Default.GetBytes,八成会打出乱码。
正确的做法是显式按打印机的编码格式做字节转换。以中文热敏打印机为例,指令里有一个选择国际字符集的命令0x1B 0x52 0x0D,而中文编码通常由打印机固件内部决定,我们只需要把字符串转成 GBK 字节流发过去就行。C# 里用Encoding.GetEncoding("GBK")或者在 .NET Core 里先注册CodePagesEncodingProvider。
另一个容易翻车的是每行字节数的计算。小票纸宽常见 58mm 和 80mm,58mm 纸通常每行能打印 32 个 ASCII 字符或 16 个中文汉字;80mm 纸则对应 48 个 ASCII 或 24 个汉字。这是一个硬参数,很多源码里写了一个LineWidth常量,但没告诉你它依赖打印机的0x1D 0x4C(左边距)和0x1D 0x57(打印区域宽度)设置。我在调试时见过一个案例:打印机设置里把打印区域宽度调成了默认最大值,结果每行实际能塞 50 个 ASCII,但源码按 48 个字符去切行,最后内容反而偏移了。
这段源码里都有相关定义,值得先读明白再改。
// 使用 CodePagesEncodingProvider 注册编码页 // 这是 .NET Core / .NET 5+ 里读取 GBK 的前置条件 Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); var gbk = Encoding.GetEncoding("GBK"); // 小票内容字符串,例如一行中文标题 string line = "欢迎光临 美味快餐店"; byte[] lineBytes = gbk.GetBytes(line); // 实际发送给打印机的字节序列前面要加初始化指令 byte[] init = new byte[] { 0x1B, 0x40 }; // ESC @ 初始化打印机 byte[] toSend = new byte[init.Length + lineBytes.Length]; Buffer.BlockCopy(init, 0, toSend, 0, init.Length); Buffer.BlockCopy(lineBytes, 0, toSend, init.Length, lineBytes.Length);这里的0x1B 0x40是 ESC/POS 的初始化指令,也就是把打印机内部的状态恢复到默认值,包括行距、字符大小、对齐方式。逻辑上很简单:先把内容转字节,再在前面拼上初始化指令,一次性发送。注意不要每行都发初始化指令,那样会把对齐设置清掉,正确的顺序是开头初始化一次,之后按段设置对齐、字号。
2.3 实际代码:一个最小可用的 TicketPrinter 类
源码里有一个TicketPrinter类,封装了最核心的发送逻辑。这个类不依赖任何第三方库,就是一个串口封装加指令拼接。你拿到之后可以直接用,也可以基于它继续扩展。
public class TicketPrinter { private SerialPort _port; public TicketPrinter(string portName, int baudRate = 9600) { _port = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One); _port.Handshake = Handshake.None; } public void Open() { if (!_port.IsOpen) _port.Open(); } public void Close() { if (_port.IsOpen) _port.Close(); } public void Write(byte[] data) { if (_port == null || !_port.IsOpen) throw new InvalidOperationException("串口未打开"); _port.Write(data, 0, data.Length); } public void PrintLine(string text, bool isChinese = true) { var encoding = Encoding.GetEncoding("GBK"); var bytes = encoding.GetBytes(text); Write(bytes); Write(new byte[] { 0x0A }); // LF 换行 } public void CutPaper() { // ESC i 全切纸,部分打印机支持 ESC d 切纸 Write(new byte[] { 0x1B, 0x69 }); } }这个类的设计点在于:串口参数里波特率取了 9600,这是小票打印机的常见默认值,但 58mm 热敏机也有用 38400 的。如果打出来是乱码或者花点,先检查波特率匹配。Handshake.None是为了兼容大多数打印机,少数老机器需要 RTS/CTS,如果发送大数据量丢字节,改成Handshake.RequestToSendXOnXOff试试。PrintLine里的换行用的是0x0A,也就是 LF 字符,大部分打印机认这个,但如果你用的打印机是 CR 换行,就要把0x0D加进去。这些参数细节,在源码注释里都有标注,跟着注释走基本不会错。
3. 把源码跑起来:串口、网口、USB 三路连接与参数设置
3.1 串口打印:COM 口参数与初始化序列
拿到源码之后,第一步是接上打印机串口。小票打印机背后一般是一个 DB9 或 RJ45 串口座。C# 里操作串口不复杂,关键在参数是否对齐打印机的拨码开关。大多数串口小票机的出厂设置是 9600、8、N、1,但也不排除有人改过。我第一次接一台二手打印机时,一直出乱码,后来查了背后的贴纸才发现波特率是 19200。
源码里定义了一个PrinterConfig结构体,里面包含串口名、波特率、数据位、停止位、校验位。你可以通过命令行参数传进去,也可以直接改常量。我建议把串口名提出来做参数,因为不同机器上 COM 口编号不同。
public class PrinterConfig { public string PortName { get; set; } = "COM3"; public int BaudRate { get; set; } = 9600; public int DataBits { get; set; } = 8; public Parity Parity { get; set; } = Parity.None; public StopBits StopBits { get; set; } = StopBits.One; } // 按配置初始化并打开打印机 var config = new PrinterConfig(); var printer = new TicketPrinter(config.PortName, config.BaudRate); printer.Open(); printer.Write(new byte[] { 0x1B, 0x40 }); // 初始化 printer.PrintLine("串口打印测试"); printer.CutPaper(); printer.Close();初始化序列这里有个细节:新打印机上电后,内部状态是随机的,必须先发0x1B 0x40,否则后面的对齐、加粗设置全部不生效。另外,Close()之前最好确认数据已经发完,因为串口写操作是异步到底层缓冲区的,立刻关闭可能丢弃数据。常见做法是Thread.Sleep(50)缓冲一下,或者调用_port.BaseStream.Flush()。
连接串口还有一个经常被忽略的坑:电源问题。USB 转串口线如果供电不足,打印机会频繁重置或者打印中途停止。你看源码注释会发现里面特别标注了「外接电源优先于 USB 供电」。这不是代码问题,是硬件问题,但很多新手会把它当成源码 bug 来排查,非常浪费时间。
3.2 网口打印:TCP 连接与 9100 端口习惯
现在越来越多的收银一体机不带串口,但打印机支持网口。网口打印的本质是通过 TCP 协议连到打印机的 9100 端口(这是行业事实标准,大多数厂商固件直接监听该端口),然后裸写数据。C# 里用TcpClient或Socket都能做。
public class NetworkTicketPrinter { private TcpClient _client; private NetworkStream _stream; public bool Connect(string ip, int port = 9100) { _client = new TcpClient(); _client.Connect(ip, port); _stream = _client.GetStream(); return _stream.CanWrite; } public void Write(byte[] data) { _stream.Write(data, 0, data.Length); _stream.Flush(); } public void Disconnect() { _stream?.Close(); _client?.Close(); } }你可能会想:串口写法里没有Flush,网口这里为什么加了。因为NetworkStream有自己的缓冲,不Flush可能有一部分指令还留在本地没发出去。另一个注意点是超时设置:打印机如果关机或者 IP 变了,Connect会卡住很久。所以我一般会在Connect之前设置TcpClient.SendTimeout = 3000和ReceiveTimeout = 3000,然后用async/await做异步连接,避免界面假死。
网口小票机还有一个特点,就是它收到的数据会原样交给打印头,不管你是分几次发送。因此,你可以把一张小票的完整数据拼成大字节数组一次性发送,也可以逐行发送。但注意,如果网络拥堵,逐行发送可能导致打印头换行和内容交错。更稳妥的做法是先把整个小票的字节流拼好,再Write一次。源码里有一个BuildTicketBuffer的方法,就是干这个事的。
3.3 USB 打印:Windows 下用 Raw 写入
USB 小票打印机在 Windows 下一般会被识别为一个打印机驱动设备,但直接写PrintDocument走驱动不是我推荐的方式。我更习惯用 USB 原始设备接口,也就是通过CreateFile打开 "USB" 设备的GUID_DEVINTERFACE_USB_DEVICE路径,再调用WriteFile发送 ESC/POS 指令。这个思路在源码里已经封装好了,叫UsbRawPrinter。
// 通过设备路径获取 USB 打印机的句柄 IntPtr handle = CreateFile( @"\\.\USB#Vid_0416&Pid_5011#5&1b2d3c4e&0&1#{28d78f49-...}", GENERIC_WRITE, FILE_SHARE_WRITE, IntPtr.Zero, OPEN_EXISTING, 0, IntPtr.Zero); if (handle.ToInt32() == -1) { throw new Exception("无法打开USB打印机,请检查设备是否正常连接"); } // 发送原始数据 uint bytesWritten; WriteFile(handle, data, (uint)data.Length, out bytesWritten, IntPtr.Zero); CloseHandle(handle);这段代码的难点不在WriteFile,而在于设备实例路径从哪来。源码里有一段枚举 USB 设备的辅助函数,它会遍历系统里的GUID_DEVINTERFACE_USB_DEVICE设备接口,匹配Vid_和Pid_前缀。Vendor ID 和 Product ID 每个打印机厂家都不一样,你第一次用时需要先看打印机属性里的「硬件 ID」,找出对应的值填进去。
USB 直写的好处是绕开了驱动,不存在驱动版本不兼容的问题。但坑也不少:部分打印机有 USB 供电保护,热插拔后需要重新枚举设备路径;还有些机器在待机后会休眠,休眠状态下WriteFile会失败。我在源码的注释里看到作者特意提醒了:每次打印前先尝试发送一个空查询指令,如果返回超时就重新打开设备。这个思路很实用。
4. 避坑指南:切纸、钱箱、二维码、重打这些现场问题
4.1 切纸后第一行丢失
现象:每次切完纸之后,下一张小票的第一行会缺半个字或者完全空掉。
原因:切纸之后打印机内部缓冲区还有残留,或者你发送的换行指令和切纸指令竞争打印头位置。更常见的原因是你切完纸直接发初始化指令0x1B 0x40,这个指令把缓冲区内容和打印位置全部清了,但机械切刀的动作还没完成完全归位,下一行数据被机械动作吃掉。
解决:切纸后加一个延迟,等待切刀动作完成,然后再发数据。一般延迟 100ms 到 200ms 足够。如果你用的打印是半切,动作更快,50ms 也行,但 58mm 打印机切刀比较慢,我给客户写的代码里统一用了 150ms。另外,切纸动作最好单独发一次,不要和最后一行内容拼在一个字节数组里。
4.2 中文乱码
现象:打印出来的中文变成「锟斤拷」或者乱码符号。
原因:字符串转成的字节编码和打印机固件内置的中文字库编码不一致。最常见是Encoding.Default在 .NET Core 下返回的是 UTF-8,而打印机只认 GBK。还有一种情况是源文件本身没保存对编码,导致字符串已经不是正确的 Unicode 了。
解决:强制使用 GBK 编码。在 .NET Core 里先注册 provider,再Encoding.GetEncoding("GBK")。如果还是乱码,就检查打印机的 DIP 开关或配置工具,看它是不是被设成了 UTF-8 模式。部分新打印机支持通过指令切换中文编码:0x1B 0x74 0x0F选择 GBK,0x1B 0x74 0x11选择 UTF-8,按需发送。我一般会在初始化序列后面多加一句0x1B 0x74 0x0F,保证大多数打印机切到 GBK。
4.3 钱箱打不开
现象:收银时开钱箱指令发了,但钱箱纹丝不动。
原因:钱箱不是用串口指令直接控制的,它是通过打印机上 RJ11 接口输出的瞬间高电平来触发。不同打印机开钱箱的指令有两种:0x1B 0x70 0x00(ESC p 0)和0x1B 0x70 0x01(ESC p 1),后面跟着的脉冲时间参数也有区别。很多源码只写了固定一种,遇到不支持的打印机就开不了。
解决:确认打印机和钱箱的 RJ11 线序匹配,然后试着把脉冲时间改长一点。指令格式是0x1B 0x70 0x00 0x19 0xFA,后两个字节是脉冲时间,0x19 0xFA 代表 100ms。有些钱箱需要 200ms,可以写成0x32 0xF4。如果开不了,就换个脉冲脚:把0x00改成0x01。这里没有银弹,只能现场试。
4.4 二维码打印超时
现象:打印二维码时一直转圈,或者等了很久才出纸。
原因:二维码生成算法耗时在 CPU 上,尤其是高版本纠错级别和较大内容时。而打印指令里面二维码绘制命令本身也有超时参数。很多源码用ZXing.Net生成二维码图片,再把图片转成点阵打印。这个转换过程非常慢,特别是把 Bitmap 按像素遍历去画点,一张 200x200 的二维码能卡几百毫秒;打印机还会因为数据停顿暂停打印,让你误以为打印机死机了。
解决:改用打印机内置的二维码指令。ESC/POS 标准里有一条GS ( k指令,直接在打印机内部生成二维码,速度极快。源码里有一段PrintQrCode方法就是直接构造GS ( k数据序列。内容不超过 100 字节时,单张二维码打印只需要 50ms。如果一定要用图片,就把图片缩小到 180x180 以下,并按 1 位像素打包成点阵字节流再发。
4.5 重复打印残留
现象:同一张小票打印了两遍,或者第二次打印内容夹带着第一次末尾的数据。
原因:上位机发送数据后立即断开连接,但打印机缓存还没吃完。尤其是网口打印,TCP 断开后还有未读取的数据,下一次连接恢复时又继续输出。串口也存在类似问题,但表现是缓冲区溢出,会多出一行空白。
解决:每次打印任务结束时发一个0x0A 0x0A两个换行,表示结束符。然后在关闭连接之前等 100ms 再做下一步。如果用的是网口,Disconnect之前先调用_stream.Flush(),再_client.Client.Shutdown(SocketShutdown.Both),确保数据全部到达打印机。
5. 进阶:多订单连续打印的缓冲设计与掉单重打校验
小票打印在高峰期经常是连续多单,如果单线程一把梭发完,打印机会出现排队抖动,后面的订单可能会因为缓存溢出丢内容。我习惯在源码基础上加一个打印任务队列,每个订单生成一个完整的字节缓冲块,统一由后台线程发送。
ConcurrentQueue<byte[]> _printQueue = new ConcurrentQueue<byte[]>(); SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1); public void Enqueue(byte[] ticketBuffer) { _printQueue.Enqueue(ticketBuffer); Task.Run(() => ProcessQueue()); } private async Task ProcessQueue() { await _semaphore.WaitAsync(); try { while (_printQueue.TryDequeue(out var buffer)) { _printer.Write(buffer); await Task.Delay(120); // 等待打印机物理输出完,避免切纸冲突 } } finally { _semaphore.Release(); } }这个队列的意义在于,它把「生成小票数据」和「发送小票数据」解耦了。收银界面点完下单就可以立刻返回响应,不用等打印机慢慢吐纸。队列里每个缓冲块都带一个订单号,如果发送时抛异常,就把订单号存到失败列表里,等打印机恢复后重新执行加入队列。
掉单重打校验我一般这么设计:每个小票末尾打印一行编号和校验码,校验码是用订单号加内容哈希生成的四位数字。人工检查时如果发现某张编号跳过去了,直接按编号重新打印。这个校验码不需要太复杂,能挡住大并发下偶发丢单就够了。
源码里还留了黑标定位打印的参数。带黑标的纸常用于后厨出单,打印机通过光电传感器寻找黑标来确定切纸位置。关键参数是「黑标偏移量」,也就是黑标传感器到切刀刀刃的距离。这个值在不同机型上不一样,有的是 27mm,有的是 30mm。你只能拿尺子量一次,然后写进配置,不会再变。我碰到过一个棘手的项目,后厨出单一直多出半截空白纸,换了传感器类型才解决:那台打印机既有反射式传感器又有穿透式传感器,黑标纸要用反射式,但默认配置是穿透式,所以总检测不到。
从那以后,我每次拿到一台新打印机,都会先做三件事:打印自检页确认分辨率、量一遍黑标偏移量、把所有能查到的 ESC/POS 指令版本号写进日志里。这些平时看着没用,一旦现场出问题,就是最快的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取