资讯动态

C#小票打印源码:ESC/POS指令与串口网口USB实现详解

发布时间:2026/10/16 20:41:42 来源:尧图企业网站定制
简介这是一份基于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 和 80mm58mm 纸通常每行能打印 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_0416Pid_5011#51b2d3c4e01#{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选择 GBK0x1B 0x74 0x11选择 UTF-8按需发送。我一般会在初始化序列后面多加一句0x1B 0x74 0x0F保证大多数打印机切到 GBK。4.3 钱箱打不开现象收银时开钱箱指令发了但钱箱纹丝不动。原因钱箱不是用串口指令直接控制的它是通过打印机上 RJ11 接口输出的瞬间高电平来触发。不同打印机开钱箱的指令有两种0x1B 0x70 0x00ESC p 0和0x1B 0x70 0x01ESC 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. 进阶多订单连续打印的缓冲设计与掉单重打校验小票打印在高峰期经常是连续多单如果单线程一把梭发完打印机会出现排队抖动后面的订单可能会因为缓存溢出丢内容。我习惯在源码基础上加一个打印任务队列每个订单生成一个完整的字节缓冲块统一由后台线程发送。ConcurrentQueuebyte[] _printQueue new ConcurrentQueuebyte[](); 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 指令版本号写进日志里。这些平时看着没用一旦现场出问题就是最快的后悔药。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑