C#上位机实现ASTM1394-97协议:串口解析状态机与应答机制
发布时间:2026/9/10 9:03:02来源:尧图网络
简介这是一套基于C#实现ASTM1394-97通信协议的源码演示包重点面向需要开发仪器控制、数据采集、医疗设备通信等串口应用的.NET开发者。工程包含完整可运行的演示项目核心代码覆盖SerialPort串口参数配置、数据帧解析、异步接收、事件驱动和异常处理等关键环节并额外提供ASTMparseDemo示例与配套说明文档便于理解协议格式和调用流程。包体共29个文件以cs源代码、exe可执行程序、config配置文件、pdb调试符号为主附带解决方案和资源文件体积仅81KB结构紧凑。目前已有878人学习适合初学者快速上手也可作为正式项目移植的参考蓝本。通过研读ASTMParser.cs、Form1.cs等核心模块可掌握从底层字节流解析到界面交互的完整实现思路对串口通信与医疗仪器对接开发有直接借鉴价值。1. 从ASTM1394-97到C#上位机先搞清楚这个协议在做什么检验仪器传输数据时设备端抛过来的是带STX和ETX的原始字节流直接按ASCII读文本会得到一堆控制字符和转义前缀。ASTM1394-97就是这套传输格式的标准它定义了帧起始、块结束、校验和以及ACK/NAK应答规则。C#的SerialPort类负责把物理字节搬进内存但分帧、转义、校验这些协议逻辑必须自己实现。ASTMparseDemo里恰好提供了这样一份可参考的代码ASTMParser.cs把串口收到的字节流解析成一条条完整的ASTM记录。对正在做医疗上位机、实验室仪器LIS对接或者工业扫码枪数据采集的.NET开发者来说这份代码能直接帮你省掉踩帧边界和校验的坑。下面从串口参数配置讲起再拆解ASTMParser状态机的核心实现最后给出一套可复现的应答与排错方案。2. SerialPort配置与事件驱动为什么ASTM1394-97的C#实现绕不开DataReceivedSerialPort只是管道ASTM的帧是异步到达的永远不知道一次DataReceived里是一字节还是完整一帧。所以配置参数和接收事件是地基。2.1 串口参数选择的底层逻辑ASTM1394-97标准本身没有规定波特率但绝大多数生化分析仪、血球仪默认使用9600bps、8数据位、无校验、1停止位。C#里配置如下using System.IO.Ports; SerialPort port new SerialPort { PortName COM3, BaudRate 9600, DataBits 8, Parity Parity.None, StopBits StopBits.One, Handshake Handshake.None, ReceivedBytesThreshold 1 }; port.Open();参数说明Parity必须设为None。ASTM帧里的STX、ETX是ASCII控制字符如果设备发送时开了奇偶校验接收端会多出一个校验位字节流就会错位解析器会一直卡在Idle状态。Handshake用None是因为ASTM的应答由软件层ACK/NAK负责不在物理握手层做流控。ReceivedBytesThreshold设为1保证串口缓冲里一有字节就触发DataReceived如果设成16短帧可能会在缓冲区里滞留到凑够16字节才到你手里。实际项目里不同设备差异很大。有的仪器用19200甚至38400还有的用“8E1”。我一般建议先从设备手册抄抄不到就用逻辑分析仪抓波特率抓到位宽之后算出来比一个个试省时间。2.2 用DataReceived做非阻塞接收避免UI卡顿SerialPort的DataReceived在后台线程触发直接在里面更新文本框会卡界面。ASTM协议数据是高频小包更不能同步处理。常见的做法是把原始字节暂存到ConcurrentQueue由专门的消费线程或UI定时器取private ConcurrentQueuebyte _rxQueue new ConcurrentQueuebyte(); private void Port_DataReceived(object sender, SerialDataReceivedEventArgs e) { SerialPort sp (SerialPort)sender; int len sp.BytesToRead; byte[] buf new byte[len]; sp.Read(buf, 0, len); foreach (byte b in buf) { _rxQueue.Enqueue(b); } }这里的逻辑是接收线程只负责搬运不做解析。解析器从队列取字节时可能一次消费几百个字节也有可能等几个毫秒才有下一个字节。如果直接在DataReceived里调用ASTMParser.AppendBlockingCollection会阻塞接收线程严重时设备端会判定超时重发。注意不要在DataReceived里调用sp.ReadLine()ASTM帧内可能包含CR和LF的转义序列ReadLine会错误断行。用字节队列配合状态机才能正确处理可打印字符和控制字符混合的帧。2.3 DataReceived与扫码枪触发事件的差别很多C#开发者是从扫码枪开始接触串口的。扫码枪通常发一段纯文本后以CR结尾DataReceived里读字符串很简单。但ASTM1394-97是结构化协议一帧是一个带有转义和校验的块不能等同处理。同一个上位机程序如果既要接扫码枪又要接ASTM仪器我一般做两层抽象底层是字节队列上层根据不同设备类型挂不同解析器。这样扫码枪触发事件和ASTM帧解析不互相干扰。3. ASTMParser核心实现从字节流到结构化数据的分帧与转义ASTMparseDemo最有价值的地方是ASTMParser.cs。串口给我们的是一串无边界字节解析器要回答三个问题帧从哪里开始、到哪里结束、数据是否完整。3.1 ASTM帧里到底有哪些字节一条ASTM消息的物理帧是STX 记录类型和数据 ETX 校验和 CR中间块用ETB结尾最后一块才用ETX。校验和是STX之后、ETX之前的全部字符ASCII码之和的低字节用两位十六进制表示。帧内的控制字符必须用DLE转义。字符ASCII码作用正文中出现方式STX0x02帧起始转义为DLE STXETX0x03帧结束转义为DLE ETXETB0x17块结束转义为DLE ETBDLE0x10转义前缀转义为DLE DLECR0x0D帧终止不参与校验ACK/NAK0x06/0x15帧应答不出现举例来说如果R记录的字段内容里包含STX发送方发出的实际字节是0x10 0x02接收方解析时遇到0x10就知道下一个字节要原样放入数据而不是作为控制字符处理。3.2 状态机解析器代码用正则切分ASTM帧很容易出错因为转义序列会破坏模式匹配。我采用状态机逐字节驱动。以下代码保留了ASTMparseDemo的思路做成了可独立运行的简化版public class AstmFrameParser { public event Actionbyte[], bool FrameReceived; private enum State { Idle, InFrame, InEscape, CheckSumHigh, CheckSumLow, CheckCR } private State _state State.Idle; private Listbyte _payload new Listbyte(); private int _sum; private char _highToken; private bool _isFinal; public void Append(byte b) { switch (_state) { case State.Idle: if (b 0x02) // STX { _payload.Clear(); _sum 0; _isFinal false; _state State.InFrame; } break; case State.InFrame: if (b 0x10) // DLE 转义前缀 { _state State.InEscape; } else if (b 0x03 || b 0x17) // ETX 或 ETB { _isFinal b 0x03; _state State.CheckSumHigh; } else { _payload.Add(b); _sum b; } break; case State.InEscape: // 转义后的字符还原为数据同时计入累加和 _payload.Add(b); _sum b; _state State.InFrame; break; case State.CheckSumHigh: _highToken (char)b; _state State.CheckSumLow; break; case State.CheckSumLow: string token ${_highToken}{(char)b}; int checksum Convert.ToInt32(token, 16); if (checksum (_sum 0xFF)) { FrameReceived?.Invoke(_payload.ToArray(), _isFinal); } // 校验失败时丢弃帧等待下一个STX _state State.CheckCR; break; case State.CheckCR: _state State.Idle; break; } } }代码逻辑说明Idle状态下只有STX能启动一帧遇到其他字节直接忽略。InFrame状态里DLE会进入InEscape分支把下一个字节当作普通数据。遇到ETX或ETB先把_isFinal标记好再进入校验读取状态。校验和从正文累加得到注意STX本身不参与_sum 0xFF取低字节。最后CheckCR状态把状态复位等待下一帧。这个状态机是单字节驱动意味着你可以在DataReceived里逐字节喂给它也可以在后台消费线程里从队列取字节喂给它。两个入口不会冲突。3.3 从字节流到记录类型和字段解析出的_payload是原始记录文本例如R|1|^||GLU^血糖|||7.8|mmol/L前两个字符是记录类型。ASTM常见记录类型包括H(头记录)、P(患者记录)、O(医嘱记录)、R(结果记录)、L(终止记录)。在FrameReceived事件里按记录类型拆分public class AstmDemoParser : AstmFrameParser { public AstmDemoParser() { FrameReceived OnFrame; } private void OnFrame(byte[] data, bool final) { string frame System.Text.Encoding.ASCII.GetString(data); if (frame.Length 2) return; string recordType frame.Substring(0, 2); string[] fields frame.Substring(2).Split(|); if (recordType H) { Console.WriteLine($ASTM版本: {fields[0]}, 仪器ID: {fields[1]}); } else if (recordType R fields.Length 7) { string testName fields[6]; string value fields[7]; Console.WriteLine(${testName} {value}); } } }这里根据ASTM记录字段位置取数据。注意不同仪器厂商对字段内容会有扩展有的在testName里带^分割的编码和名称所以实际解析需要再对^拆分。ASTMparseDemo里的Form1.cs就是绑定这种事件的UI入口收到完整帧后把关键字段绑定到DataGridView。4. 应答机制与超时重传让ASTM1394-97通信可靠起来串口丢字节很常见ASTM的可靠性靠ACK/NAK和超时重传而不是靠物理层。4.1 握手时序与ACK/NAK状态设备发出一个帧后会等待上位机回单字节应答。校验通过回0x06(ACK)校验失败回0x15(NAK)。收到NAK的设备通常会重发同一帧。这个应答必须在短时间内发出很多仪器设置的窗口是1秒到3秒超时后设备会主动重发或直接故障。因此在解析器校验通过后要立刻调用串口发送ACK。注意不要在UI线程的Timer里稍后应答要用单独的通信线程。参考应答代码private void OnFrame(byte[] data, bool final) { // 校验和已通过先回ACK _port.Write(new byte[] { 0x06 }, 0, 1); // 如果是最后一个块开始处理业务逻辑 if (final) { ProcessFrame(data); } }这里_port是SerialPort对象Write必须在串口打开时调用。如果业务处理耗时较长先写ACK再处理数据不要反过来否则设备已经超时重发你又处理一次就产生重复结果。注意发送ACK和NAK时不要和其他线程的写操作并发建议用lock或者单独的发送队列。4.2 上位机主动发帧时的超时重传队列ASTM不只是设备单向上报有些场景需要上位机请求信息这时上位机是发起方。我习惯维护一个待发送队列和一个重传定时器private ConcurrentQueuebyte[] _sendQueue new ConcurrentQueuebyte[](); private bool _awaitAck; private DateTime _lastSentTime; private int _retryCount; private byte[] _lastFrame; private void SendFrame(byte[] frame) { _port.Write(frame, 0, frame.Length); _awaitAck true; _lastSentTime DateTime.Now; _retryCount 0; } private void Timer_Tick(object sender, EventArgs e) { if (_awaitAck) { if ((DateTime.Now - _lastSentTime).TotalSeconds 5) { if (_retryCount 3) { _retryCount; SendFrame(_lastFrame); } else { _awaitAck false; // 通知界面发送失败 } } } else if (_sendQueue.TryDequeue(out byte[] frame)) { _lastFrame frame; SendFrame(frame); } }这个方案的要点是发送帧后立即进入等待状态定时器每次检查是否超时。重传必须重新计算校验和我这里假设_lastFrame已经包含了正确校验字节。收到对应设备的ACK后要复位_awaitAck。注意设备帧回的是正文帧ACK本身不是完整ASTM帧所以解析器在Idle状态遇到0x06必须单独处理不能丢给ASTMParser。4.3 不同设备的边界情况实际设备对ASTM规范的遵守程度千差万别。有的设备一个块能塞进整条消息有的超过100字节就拆块有的帧尾是CR有的非要带CRLF。ASTMparseDemo的解析器对ETB和ETX都做了处理所以中间块和最后一块都能解析。但如果你只盯着ETX遇到中间块ETB就会丢帧。还有一个常见坑设备发送的校验和可能是小写十六进制Convert.ToInt32(token, 16)能同时识别大小写不需要额外转换。另外有些设备在校验和与CR之间会多一个空格我一般在CheckCR状态里加一个容错判断case State.CheckCR: if (b 0x0D || b 0x20) { _state State.Idle; } break;这样容错性更强但要注意不能把正文里的空格算进来因为这里已经是帧尾部。5. 用虚拟串口模拟器和日志验证ASTM解析器没有设备也能把ASTM解析器调到可用状态关键是把输入变得可控。5.1 创建一对虚拟串口用Virtual Serial Port Driver之类的工具把COM5和COM6配对设备模拟程序打开COM5你的上位机打开COM6。模拟程序循环发送一条ASTM R记录帧这样能反复验证解析器对STX、ETX、校验和的处理。发送端核心代码SerialPort tx new SerialPort(COM5, 9600, Parity.None, 8, StopBits.One); tx.Open(); string payload 1R|1|GLU|7.8|; tx.Write($\u0002{payload}\u0003{CalcChecksum(payload)}\r);这里的payload是去除STX和ETX后的内容。CalcChecksum需要按字符累加取低字节转十六进制。5.2 日志定位帧边界问题在解析器每个状态转换处记录十六进制日志Console.WriteLine($[{DateTime.Now:HH:mm:ss.fff}] {_state} - 0x{b:X2});常见情况是卡在CheckSumLow一直不归位说明校验字符没等到。这时检查发送端是否在ETX后立即写了校验和。还有情况是收到一堆0x10开头的转义序列日志里能看到DLE后的字节被正确还原如果还原不对罪魁祸首往往是ReceivedBytesThreshold设太大导致接收不完整。5.3 无设备时的快速回归测试我习惯把ASTM帧硬编码成测试用例逐字节喂给解析器string frame \u0002H|\\^|||20240101120000\r\u000366\r; var parser new AstmFrameParser(); bool received false; parser.FrameReceived (data, final) received true; foreach (char c in frame) parser.Append((byte)c);这里66是校验值需要在测试前用计算器算好。故意改一位校验值解析器应当不触发事件。通过这种方式解析逻辑在接入真实仪器前就能覆盖正常、转义、校验失败三种路径之后再把同样的解析器挂到SerialPort的DataReceived队列上生产环境的代码就是这套验证过的逻辑。本文还有配套的精品资源点击获取
网站建设高端定制企业官网