首页 / 资讯中心 / 文章详情

C#串口扫描枪开发实战:参数配置、数据接收与粘包处理

C#串口扫描枪开发实战:参数配置、数据接收与粘包处理 ★ FEATURED ARTICLE
1. 从收银台到产线串口扫描枪到底在解决什么问题很多人第一次接触串口扫描枪是在收银台或者仓库出入库的场景里。设备往那一摆线一插扫一下条码电脑上就自动跳出一串数字。看起来简单得不能再简单但真到自己动手写代码去接的时候问题就来了为什么我用键盘钩子能收到数据换成串口就读不出来为什么扫出来的条码前面多了几个莫名其妙的字符为什么连续扫的时候偶尔会丢数据这些问题的根源在于串口扫描枪和普通USB键盘模拟扫描枪的工作方式完全不同。USB键盘模拟的扫描枪本质上就是把自己伪装成一个键盘扫到条码之后直接把字符敲进当前焦点窗口。而串口扫描枪走的是RS-232或者USB转串口的通信链路数据是通过串口缓冲区一个字节一个字节传过来的你的程序必须主动去听这个端口才能拿到完整的数据。这个区别带来的直接影响就是串口扫描枪不依赖焦点窗口你可以在后台服务里收数据也可以同时接多把枪还能做数据校验和二次处理。代价就是你需要自己处理串口打开、参数配置、数据缓冲、粘包拆包、异常重连这一整套逻辑。C#在这方面其实挺有优势的。.NET Framework时代就有System.IO.Ports.SerialPort这个类到了.NET Core和.NET 5/6/7/8之后通过System.IO.Ports这个NuGet包也能跨平台使用。对于Windows桌面应用、工控上位机、WPF管理端这些场景来说用C#写串口通信是最顺手的选择之一。这篇文章面向的是有一定C#基础、但没怎么碰过串口通信的开发者或者之前用键盘钩子方案踩过坑、想换成串口方案的人。我会从串口参数配置讲起把数据接收的几种模式掰开揉碎再讲粘包处理、多枪并发、异常恢复这些实际项目中一定会遇到的东西。代码都是可以直接复制到项目里跑的不是伪代码。2. 串口参数配置为什么你的扫描枪扫出来是乱码2.1 波特率、数据位、停止位、校验位这四个参数到底怎么定串口通信最基础也最容易出错的地方就是参数配置。四个核心参数波特率、数据位、停止位、校验位。只要有一个对不上收到的数据就是乱码或者干脆收不到。波特率是最关键的。常见的扫描枪出厂默认是9600但也有19200、38400、115200的。这个参数必须和扫描枪的实际配置一致。问题是很多扫描枪的说明书丢了或者根本没写怎么办我的经验是先试9600如果收到乱码依次试19200、38400、115200。乱码的特征是能收到数据但内容不对完全收不到则是波特率差太远或者端口号选错了。数据位一般是8位停止位一般是1位校验位一般是None。这三个参数在扫描枪上几乎不会改除非是某些工业场景的特殊设备。但我在一个产线项目里遇到过校验位被设成Even的情况排查了半天才发现是设备出厂配置被改过。using System.IO.Ports; SerialPort port new SerialPort(); port.PortName COM3; port.BaudRate 9600; port.DataBits 8; port.StopBits StopBits.One; port.Parity Parity.None; port.ReadTimeout 500; port.WriteTimeout 500;这段代码看起来简单但有几个细节值得说。ReadTimeout和WriteTimeout建议设成500毫秒左右不要设成默认的-1无限等待否则一旦设备掉线读操作会永久阻塞。另外PortName在Windows上是COM加数字在Linux上是/dev/ttyUSB0这种形式跨平台部署的时候要注意。2.2 用虚拟串口工具在没有硬件的情况下先把代码跑通实际开发中最尴尬的情况是代码写完了但手头没有扫描枪。这时候可以用虚拟串口工具来模拟。Windows上可以用com0com这类工具创建一对虚拟串口一端当作扫描枪另一端当作你的程序。然后用串口调试助手往虚拟串口发数据就能测试你的接收逻辑。这个做法还有一个好处可以精确控制发送的数据内容和时序。比如你想测试粘包场景就可以让调试助手连续快速发送多条条码数据看你的程序能不能正确拆分。真实扫描枪很难复现这种极端情况虚拟串口就方便多了。注意虚拟串口工具创建的端口对在设备管理器里看到的是两个配对的COM口。你的程序打开其中一个调试助手打开另一个数据才能互通。如果打开同一个端口会报端口被占用的错误。2.3 打开端口时最容易忽略的异常处理port.Open()这行代码看起来人畜无害但它能抛出好几种异常端口不存在抛ArgumentException端口被占用抛UnauthorizedAccessException参数不合法抛InvalidOperationException。如果不做处理程序直接崩。我的做法是封装一个带重试的打开方法。第一次打开失败后等待500毫秒再试最多重试3次。这个在产线环境里特别有用因为设备上电顺序不确定有时候程序先启动扫描枪还没初始化完这时候打开就会失败等一会儿再试就好了。public bool OpenWithRetry(SerialPort port, int maxRetry 3) { for (int i 0; i maxRetry; i) { try { if (!port.IsOpen) port.Open(); return true; } catch (UnauthorizedAccessException) { Thread.Sleep(500); } catch (IOException) { Thread.Sleep(500); } } return false; }这段代码里捕获了UnauthorizedAccessException和IOException两种最常见的异常。前者通常是端口被别的程序占用了后者可能是设备被拔掉了。重试间隔500毫秒是个经验值太短了没意义太长了用户体验差。3. 数据接收的三种模式DataReceived事件、轮询读取、异步流3.1 DataReceived事件的坑它不在UI线程上执行SerialPort类提供了一个DataReceived事件很多人第一反应就是用这个事件来收数据。写法大概是这样port.DataReceived (sender, e) { string data port.ReadExisting(); // 更新UI textBox.Text data; };然后运行起来就报错了跨线程操作无效。因为DataReceived事件是在一个后台线程上触发的不是UI线程。直接在里面更新控件会抛异常。正确的做法是用Invoke或者BeginInvoke切回UI线程port.DataReceived (sender, e) { string data port.ReadExisting(); this.Invoke(new Action(() { textBox.Text data; })); };但这里还有一个更深的问题DataReceived事件的触发时机是不确定的。它不是在每个字节到达时都触发而是当接收缓冲区里有数据时触发一次。如果你在事件处理函数里读得不够快或者数据来得太密集可能会一次收到多条条码拼在一起也可能一条条码被拆成两次收到。这就是粘包和拆包问题后面会专门讲。3.2 轮询读取简单粗暴但可控如果你不想跟DataReceived事件的线程问题纠缠可以用轮询的方式。开一个后台线程每隔几十毫秒去读一次串口缓冲区private CancellationTokenSource _cts; private void StartPolling() { _cts new CancellationTokenSource(); Task.Run(async () { var buffer new byte[1024]; while (!_cts.IsCancellationRequested) { try { if (port.IsOpen port.BytesToRead 0) { int count port.Read(buffer, 0, buffer.Length); string data Encoding.ASCII.GetString(buffer, 0, count); ProcessData(data); } } catch (Exception ex) { // 记录日志 } await Task.Delay(50); } }, _cts.Token); }轮询的优点是逻辑清晰所有数据处理都在你自己的线程里不用担心事件触发的时机问题。缺点是有一个最大50毫秒的延迟对于扫描枪这种场景来说完全可以接受因为人扫码的动作本身就有几百毫秒。50毫秒这个间隔是我反复测试后觉得比较合适的值。太短了CPU占用高太长了延迟明显。当然如果你的场景对实时性要求极高可以降到10毫秒但要注意Task.Delay的精度问题Windows上默认的计时器精度大概是15毫秒左右。3.3 异步流读取.NET Core之后的优雅方案如果你用的是.NET Core 3.0及以上版本SerialPort的BaseStream支持异步读取。这种方式结合了事件驱动的效率和轮询的可控性private async Task ReadLoopAsync(CancellationToken token) { var buffer new byte[1024]; while (!token.IsCancellationRequested) { try { int count await port.BaseStream.ReadAsync(buffer, 0, buffer.Length, token); if (count 0) { string data Encoding.ASCII.GetString(buffer, 0, count); ProcessData(data); } } catch (OperationCanceledException) { break; } catch (Exception ex) { // 串口异常尝试重连 await Task.Delay(1000); } } }这种写法的好处是不会有忙等待ReadAsync会一直挂起直到有数据到达或者被取消。CPU占用几乎为零。缺点是在某些USB转串口芯片上ReadAsync的取消可能不会立即生效需要等驱动层超时。这个问题在FTDI芯片上比较常见CH340和CP2102相对好一些。三种方式的对比方式线程模型延迟CPU占用适用场景DataReceived事件后台线程回调低低简单场景能处理好Invoke轮询读取自建线程50ms左右中需要完全控制读取节奏异步流读取async/await低极低.NET Core推荐方案我个人的选择是新项目一律用异步流读取老项目如果已经在用DataReceived事件且没出问题也不用特意改。4. 粘包与拆包连续扫码时数据为什么会串4.1 扫描枪的数据格式前缀、条码内容、后缀要理解粘包问题先要搞清楚扫描枪发出来的数据长什么样。大多数扫描枪出厂默认的格式是前缀可选 条码内容 后缀通常是回车换行\r\n。前缀一般用来标识扫描枪的编号或者数据类型后缀用来标识一条数据的结束。问题在于串口是字节流不是消息流。扫描枪连续扫两个条码发出来的数据可能是这样的1234567890\r\n0987654321\r\n但如果你的程序读取时机不巧可能第一次读到1234567890\r\n0987第二次读到654321\r\n。这就是拆包。反过来如果两条数据间隔很短你可能一次读到1234567890\r\n0987654321\r\n这就是粘包。4.2 用缓冲区加分隔符的经典解法解决粘包拆包最常用的方法是维护一个字符串缓冲区每次收到数据就追加进去然后按分隔符拆分private StringBuilder _buffer new StringBuilder(); private void ProcessData(string data) { _buffer.Append(data); string content _buffer.ToString(); while (content.Contains(\r\n)) { int index content.IndexOf(\r\n); string barcode content.Substring(0, index); content content.Substring(index 2); if (!string.IsNullOrEmpty(barcode)) { OnBarcodeScanned(barcode); } } _buffer.Clear(); _buffer.Append(content); }这段代码的逻辑是把新收到的数据追加到缓冲区然后循环查找分隔符。每找到一个分隔符就取出一段完整的条码剩下的继续留在缓冲区里等下次数据。这里有个细节要注意_buffer必须是实例级别的字段不能是局部变量。因为拆包的情况下第一次收到的数据不完整需要留到下一次处理。如果每次都用新的StringBuilder拆包的数据就丢了。4.3 超时兜底没有分隔符怎么办有些扫描枪的后缀被配置成了其他字符或者干脆没有后缀。这种情况下就不能靠分隔符来拆包了需要用超时机制如果两次收到数据的时间间隔超过一定阈值比如100毫秒就认为上一条数据已经结束。private DateTime _lastReceiveTime DateTime.MinValue; private StringBuilder _buffer new StringBuilder(); private void ProcessDataWithTimeout(string data) { var now DateTime.Now; if ((now - _lastReceiveTime).TotalMilliseconds 100) { // 超时了把缓冲区里已有的数据当作一条完整条码 if (_buffer.Length 0) { OnBarcodeScanned(_buffer.ToString()); _buffer.Clear(); } } _buffer.Append(data); _lastReceiveTime now; }这种方式的缺点是会引入至少100毫秒的延迟因为要等超时才能确认一条数据结束。对于扫码场景来说可以接受但如果你的扫描枪支持配置后缀强烈建议配上用分隔符方案更可靠。提示大多数扫描枪都可以通过扫描配置条码来设置后缀。通常说明书里会有一页是各种配置条码扫一下添加CR LF后缀的条码就能开启。如果说明书丢了可以去设备厂商的官网找对应型号的配置手册。5. 多把扫描枪同时工作端口管理与数据路由5.1 每把枪一个SerialPort实例互不干扰在仓库或者产线场景里经常需要同时接多把扫描枪。比如一个工位扫 incoming 的物料另一个工位扫 outgoing 的成品。这时候就需要多个SerialPort实例每个实例绑定一个COM口。public class ScannerManager { private Dictionarystring, SerialPort _ports new Dictionarystring, SerialPort(); private Dictionarystring, StringBuilder _buffers new Dictionarystring, StringBuilder(); public void AddScanner(string scannerId, string portName, int baudRate) { var port new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One); port.ReadTimeout 500; port.Open(); _ports[scannerId] port; _buffers[scannerId] new StringBuilder(); StartReadLoop(scannerId, port); } }关键点是每个扫描枪要有独立的缓冲区和独立的读取循环。不能共用一个缓冲区否则两把枪的数据会混在一起根本分不清哪条数据是哪把枪扫的。5.2 数据路由怎么知道这条条码是哪把枪扫的数据路由的核心是在回调里带上扫描枪的标识。上面代码里用scannerId作为字典的key读取循环里把scannerId一起传给处理函数private async Task ReadLoopAsync(string scannerId, SerialPort port, CancellationToken token) { var buffer new byte[1024]; while (!token.IsCancellationRequested) { try { int count await port.BaseStream.ReadAsync(buffer, 0, buffer.Length, token); if (count 0) { string data Encoding.ASCII.GetString(buffer, 0, count); ProcessData(scannerId, data); } } catch (Exception ex) { // 处理异常 } } }这样在最终的业务处理函数里就能根据scannerId做不同的逻辑。比如工位A的扫描枪扫到的条码写入数据库表A工位B的写入表B。5.3 端口热插拔USB转串口设备被拔掉之后怎么恢复USB转串口设备最大的问题就是不稳定尤其是产线环境里震动大、电磁干扰强设备偶尔会掉线。掉线之后SerialPort对象会进入一个不可用的状态IsOpen可能还是true但读写都会抛异常。我的处理方式是在读取循环里捕获异常一旦发现连续多次读取失败就关闭当前端口然后启动一个重连定时器每隔2秒尝试重新打开直到成功为止。private async Task ReconnectLoopAsync(string scannerId, string portName, int baudRate) { while (true) { try { var port new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One); port.Open(); // 重连成功重新启动读取循环 StartReadLoop(scannerId, port); break; } catch { await Task.Delay(2000); } } }这里有个坑USB设备被拔掉再插上之后COM口号可能会变。比如原来是COM3插上之后变成了COM5。如果代码里写死了COM3重连就会一直失败。解决办法是记录设备的VID和PID通过WMI查询当前对应的COM口。不过这个实现比较复杂如果项目允许建议在部署时固定USB设备的COM口号Windows设备管理器里可以设置。6. 从扫码到业务数据校验、去重与异常条码处理6.1 条码格式校验不是所有扫出来的东西都是有效条码扫描枪扫出来的数据不一定都是你想要的。操作员可能不小心扫到了旁边的二维码或者扫到了破损的条码导致数据不完整。所以在业务处理之前一定要做格式校验。常见的校验包括长度校验比如你的条码固定是13位、字符集校验比如只允许数字和大写字母、校验位校验比如EAN-13的最后一位是校验位。public bool ValidateBarcode(string barcode) { if (string.IsNullOrEmpty(barcode)) return false; if (barcode.Length ! 13) return false; if (!barcode.All(c char.IsDigit(c))) return false; // EAN-13校验位计算 int sum 0; for (int i 0; i 12; i) { int digit barcode[i] - 0; sum (i % 2 0) ? digit : digit * 3; } int checkDigit (10 - (sum % 10)) % 10; return checkDigit (barcode[12] - 0); }校验不通过的条码直接丢弃并记录日志。不要试图去修复一个不合法的条码那只会让问题更难排查。6.2 重复扫码去重同一个条码连续扫了两次怎么处理操作员手抖连续扫两次同一个条码这是很常见的情况。如果不做去重业务系统里就会出现两条重复记录。去重的逻辑很简单记录上一次成功处理的条码和时间如果新条码和上一次相同且时间间隔小于某个阈值比如2秒就认为是重复扫码直接忽略。private string _lastBarcode ; private DateTime _lastBarcodeTime DateTime.MinValue; public bool IsDuplicate(string barcode) { var now DateTime.Now; if (barcode _lastBarcode (now - _lastBarcodeTime).TotalSeconds 2) { return true; } _lastBarcode barcode; _lastBarcodeTime now; return false; }2秒这个阈值可以根据实际场景调整。如果是高速产线可能0.5秒就够了如果是人工扫码2秒比较合适。6.3 异常条码的记录与告警有些异常条码不能简单丢弃需要记录并告警。比如连续出现多个校验失败的条码可能是扫描枪的配置被改了或者条码打印质量出了问题。这时候应该触发一个告警通知相关人员检查。我的做法是在内存里维护一个最近N次扫码结果的队列如果失败率超过某个阈值比如10次里超过3次失败就触发告警。告警方式可以是写日志、发邮件、或者调用外部系统的接口。private Queuebool _recentResults new Queuebool(); private const int WindowSize 10; private const double AlertThreshold 0.3; public void RecordResult(bool success) { _recentResults.Enqueue(success); if (_recentResults.Count WindowSize) _recentResults.Dequeue(); if (_recentResults.Count WindowSize) { double failRate _recentResults.Count(r !r) / (double)WindowSize; if (failRate AlertThreshold) { TriggerAlert($最近{WindowSize}次扫码失败率{failRate:P0}); } } }这个滑动窗口的思路在工控场景里很常用比简单的计数器更稳定不会因为一次偶发失败就误报。7. 实测中踩过的几个坑和对应的解法7.1 关闭程序时串口没有正确释放这是最常见的问题。程序关闭的时候如果没有调用port.Close()串口会一直处于占用状态下次打开程序就会报端口被占用。更麻烦的是有时候进程已经退出了但串口资源还没释放需要等操作系统回收。解决办法是在窗体的FormClosing事件里显式关闭所有串口private void MainForm_FormClosing(object sender, FormClosingEventArgs e) { _cts?.Cancel(); foreach (var port in _ports.Values) { try { if (port.IsOpen) port.Close(); port.Dispose(); } catch { } } }注意Close()和Dispose()都要调用。Close()释放串口资源Dispose()释放对象本身占用的资源。另外在取消读取循环之后最好等一小会儿再关闭端口避免读取线程还在访问已经关闭的端口。7.2 中文条码的编码问题如果条码内容包含中文比如某些二维码用Encoding.ASCII解码会得到乱码。这时候需要根据扫描枪的实际编码来设置。大多数国产扫描枪默认是UTF-8也有部分老设备是GBK。port.Encoding Encoding.UTF8;SerialPort类有一个Encoding属性设置之后ReadExisting()和ReadLine()会按照指定的编码来解码。但如果用Read(byte[], int, int)自己读字节再解码就需要手动指定编码。我遇到过一个案例扫描枪发的是UTF-8编码的中文但程序用GBK解码结果全是问号。排查的时候用串口调试助手看原始字节发现是三个字节一组的中文UTF-8编码才定位到问题。7.3 高波特率下的数据丢失当波特率设到115200甚至更高的时候如果读取循环处理不及时串口缓冲区可能会溢出导致数据丢失。SerialPort的默认读缓冲区是4096字节对于条码数据来说通常够用但如果连续快速扫码可能会不够。可以通过ReadBufferSize属性增大缓冲区port.ReadBufferSize 8192;另外在高波特率下建议用异步流读取而不是轮询因为异步读取能更快地响应数据到达。轮询的50毫秒间隔在115200波特率下可能已经积累了上百字节的数据。7.4 不同品牌扫描枪的默认配置差异不同品牌的扫描枪出厂配置不一样。有的默认加回车后缀有的不加有的默认9600波特率有的默认115200。如果项目里会混用不同品牌的扫描枪最好在代码里做一个自动探测依次尝试几种常见的波特率组合看哪个能收到有效数据。不过自动探测实现起来比较麻烦更实际的做法是在部署时统一配置扫描枪把所有设备的参数设成一致。大多数扫描枪都支持通过扫描配置条码来修改参数花几分钟统一配置一下能省掉后面很多麻烦。8. 写在最后的一点个人体会串口扫描枪这个东西单看技术难度并不高无非就是打开端口、读数据、处理数据。但实际项目里真正花时间的是那些边界情况和异常处理设备掉线了怎么办、数据粘包了怎么办、操作员连续扫同一个条码怎么办、程序崩溃了串口没释放怎么办。我的经验是在写第一行代码之前先把这些异常场景列出来每个场景想清楚处理策略然后再动手。这样写出来的代码可能一开始看起来有点啰嗦但上线之后会省掉大量的排查时间。另外串口调试助手是必备工具。不管是用虚拟串口还是真实设备先用调试助手确认数据能正常收发再去写代码。这样能把硬件问题和代码问题分开排查起来快很多。我见过不少开发者一上来就写代码结果调了半天发现是扫描枪的波特率不对白白浪费时间。最后说一个实际项目里的小技巧在日志里记录每次扫码的原始字节和解析后的条码。这样一旦出现异常条码可以对照原始数据判断是扫描枪的问题还是解析逻辑的问题。这个日志在排查阶段非常有用上线稳定之后可以关掉或者降低级别。
阅读完成 · 觉得有帮助?
咨询建站