医院智能药房实战:C#上位机对接Modbus TCP实现药品库存精准管理

医院智能药房实战:C#上位机对接Modbus TCP实现药品库存精准管理 前言当“发药”变成一道数学题在很多人印象里医院药房还是药师拿着处方在货架间穿梭、人工核对、手工录入的场景。但在三甲医院的智慧化改造浪潮下智能发药机已经成为标配。设备负责机械臂抓取和传送而真正让这台钢铁巨兽“懂业务”的是背后那套C#上位机系统。去年我们承接了某省级医院的智能药房改造项目核心难点不在于机械控制而在于如何通过Modbus TCP协议在药品高频出入库的同时保证库存数据的绝对准确。医疗场景容错率为零——少发一盒药是医疗事故多发一盒是合规风险库存对不上则直接影响医保结算。这篇文章不讲智能发药机的机械原理只聚焦一个命题如何用C#把Modbus TCP这个“古老”的工业协议用出医疗级的可靠性。所有方案均经过7×24小时现场验证代码可直接复用。一、 为什么智能药房选Modbus TCP而不是OPC UA这是项目评审时被问得最多的问题。答案很务实设备生态决定协议国产智能发药机如苏州艾隆、深圳瑞驰等90%以上原生支持Modbus TCPPLC多为汇川或信捷。强行上OPC UA需要额外部署网关增加故障点数据模型简单药房交互本质是“读写寄存器”——货位状态、药品编码、数量、批次号全是结构化数值不需要OPC UA的信息建模能力实时性要求适中单次发药指令下发到确认的窗口在200-500ms之间Modbus TCP的8-15ms响应完全够用运维成本医院信息科熟悉TCP/IP网络排查但对OPC UA的证书管理和节点浏览普遍陌生⚠️适用边界如果你的项目涉及多台异构设备联动发药机贴标机分拣线AGV且需要统一数据语义请考虑OPC UA。单台发药机的库存管理Modbus TCP是最小可行方案。二、 系统架构三层隔离各司其职┌──────────────────────────────────────────────────────┐ │ HIS/药房管理系统 (业务层) │ │ 处方解析 | 库存同步 | 效期预警 | 报表统计 │ └───────────────────────┬──────────────────────────────┘ │ gRPC / REST API ┌───────────────────────▼──────────────────────────────┐ │ C#上位机中间件 (编排层) │ │ 发药任务调度 | 库存映射引擎 | 异常处理 | 日志审计 │ └──────┬──────────────────┬────────────────────────────┘ │ │ ┌──────▼──────┐ ┌──────▼──────────────────────────┐ │ 本地SQLite │ │ Modbus TCP通信管理器 │ │ (缓存离线) │ │ (串行队列心跳重连校验) │ └─────────────┘ └──────────────┬──────────────────┘ │ TCP/IP ┌──────▼──────┐ │ 发药机PLC │ │ (汇川H5U) │ └─────────────┘核心设计原则业务层不碰协议协议层不懂药品。中间件是唯一知道“D100阿莫西林库存”这一映射关系的地方。换PLC品牌只改通信管理器换HIS接口只改业务适配层。三、 Modbus寄存器规划药房专属地址表这是整个项目的基石。地址规划混乱会导致后期维护噩梦必须在开发前与PLC工程师联合评审并冻结。地址范围类型用途读写备注D0-D99保持寄存器设备状态字R运行/故障/待机/急停等位标志D100-D499保持寄存器货位库存表R/W每货位占2个D[药品ID][数量]D500-D599保持寄存器发药指令区W上位机写入PLC执行后回写确认D600-D699保持寄存器入库指令区W补药时写入PLC确认后清除D700-D799保持寄存器报警信息R错误码货位号时间戳D800-D899保持寄存器批次/效期R/W每货位2个D[批号CRC][剩余天数]M0-M31线圈握手信号R/W触发/确认/完成/复位等布尔量关键设计药品ID不使用字符串传输而是在上位机维护一张Dictionaryint, string映射表。PLC侧只存整型ID节省75%的寄存器占用。这张映射表启动时从数据库加载变更时热重载无需重启。四、 通信管理器医疗级可靠性实现4.1 串行请求队列 优先级调度药房场景有个特殊需求紧急处方如抢救用药必须插队。普通FIFO队列无法满足这一点。publicenumRequestPriority{Normal0,Urgent1,Critical2}publicclassPharmacyPlcClient:IAsyncDisposable{// 三级优先队列Critical Urgent NormalprivatereadonlyPriorityQueuePlcRequest,int_queuenew();privatereadonlySemaphoreSlim_semaphorenew(1,1);privateIModbusMaster?_master;privateConnectionState_stateConnectionState.Disconnected;publicasyncTaskTExecuteAsyncT(FuncIModbusMaster,TaskToperation,RequestPrioritypriorityRequestPriority.Normal,CancellationTokenctdefault){varrequestnewPlcRequestT(operation,ct);_queue.Enqueue(request,-(int)priority);// 负数实现高优先级先出await_semaphore.WaitAsync(ct);try{// 等待连接就绪while(_state!ConnectionState.Connected)awaitTask.Delay(50,ct);returnawaitrequest.Operation(_master!);}finally{_semaphore.Release();}}}⚠️注意PriorityQueue不是线程安全的但我们的串行化由Semaphore保证所以无需额外加锁。如果未来需要多消费者并行需改用ConcurrentPriorityQueue或Channel排序。4.2 药品库存读取分包校验双保险一次读取200个货位的库存400个寄存器直接读必然丢包。采用分包应用层校验publicclassInventoryReader{privateconstintBatchSize64;// 汇川H5U安全上限privateconstintBatchIntervalMs3;publicasyncTaskDictionaryint,InventoryItemReadFullInventoryAsync(PharmacyPlcClientclient,CancellationTokenct){varresultnewDictionaryint,InventoryItem(200);for(intoffset0;offset400;offsetBatchSize){ct.ThrowIfCancellationRequested();intcountMath.Min(BatchSize,400-offset);varbatchawaitclient.ExecuteAsync(mm.ReadHoldingRegistersAsync(1,(ushort)(100offset),(ushort)count),RequestPriority.Normal,ct);// 每2个寄存器解析为一个货位for(inti0;ibatch.Length;i2){intslotIndex(offseti)/2;result[slotIndex]newInventoryItem{DrugIdbatch[i],Quantitybatch[i1]};}if(offsetBatchSize400)awaitTask.Delay(BatchIntervalMs,ct);}// 应用层校验总数与PLC侧汇总寄存器比对varplcTotal(awaitclient.ExecuteAsync(mm.ReadHoldingRegistersAsync(1,99,1),ct))[0];varlocalTotalresult.Values.Sum(vv.Quantity);if(localTotal!plcTotal)thrownewInventoryChecksumException($库存校验失败: PLC汇总{plcTotal}, 本地计算{localTotal});returnresult;}}为什么要在PLC侧维护一个汇总寄存器因为Modbus没有事务概念读取过程中如果有入库操作穿插分批读到的数据可能不一致。PLC侧的汇总值是在扫描周期内原子更新的作为“快照校验和”使用。如果校验失败上位机应丢弃本次结果并重试而不是使用脏数据。4.3 发药指令下发握手时序防错发药是最关键的操作必须保证“指令-执行-确认”三步严格有序上位机 PLC │ │ │── 写入D500[药品ID,数量] ──► │ │── 置M01(触发) ────────────► │ │ │── 机械臂取药 │◄── M11(执行中) ─────────── │ │◄── M21(完成)D510实际数量 │ │── 清M00 ──────────────────► │ │◄── 清M1/M20 ────────────── │ │ │对应C#实现publicclassDispenseExecutor{publicasyncTaskDispenseResultDispenseAsync(PharmacyPlcClientclient,intdrugId,intquantity,CancellationTokenct){usingvartimeoutCtsCancellationTokenSource.CreateLinkedTokenSource(ct);timeoutCts.CancelAfter(TimeSpan.FromSeconds(30));// 单次发药最长30秒// Step 1: 写入指令awaitclient.ExecuteAsync(mm.WriteMultipleRegistersAsync(1,500,newushort[]{(ushort)drugId,(ushort)quantity}),RequestPriority.Critical,timeoutCts.Token);// Step 2: 触发awaitclient.ExecuteAsync(mm.WriteSingleCoilAsync(1,0,true),RequestPriority.Critical,timeoutCts.Token);// Step 3: 轮询等待完成带超时boolcompletedfalse;ushortactualQty0;while(!completed){timeoutCts.Token.ThrowIfCancellationRequested();varstatusawaitclient.ExecuteAsync(mm.ReadCoilsAsync(1,1,2),// M1M2RequestPriority.Critical,timeoutCts.Token);if(status[1])// M21 完成{actualQty(awaitclient.ExecuteAsync(mm.ReadHoldingRegistersAsync(1,510,1),RequestPriority.Critical,timeoutCts.Token))[0];completedtrue;}elseif(!status[0])// M10 且 M20 → 异常中断{thrownewDispenseException(PLC未响应执行确认可能硬件故障);}awaitTask.Delay(50,timeoutCts.Token);// 50ms轮询间隔}// Step 4: 清除触发信号awaitclient.ExecuteAsync(mm.WriteSingleCoilAsync(1,0,false),RequestPriority.Critical,timeoutCts.Token);returnnewDispenseResult(drugId,quantity,actualQty);}}防御性设计要点轮询间隔50ms而非10ms给PLC扫描周期留余量避免无效请求堆积检测M10且M20的异常态正常流程中M1应先于M2置位如果两者同时为0说明PLC侧发生了复位或故障超时后必须清除M0否则PLC会认为指令仍在执行阻塞后续所有发药请求五、 断线重连医院场景的特殊考量医院网络和工厂不同交换机可能被保洁误触、WiFi干扰频繁、夜间会有网络设备维护窗口。重连策略需要更“耐心”privateasyncTaskReconnectLoop(){while(_stateConnectionState.Reconnecting){// 医院场景退避上限放宽到60秒避免维护窗口期间疯狂重连intdelayMath.Min(1000*(1_retryCount),60_000);Log.Warning(PLC断线{Delay}ms后第{Count}次重连,delay,_retryCount1);awaitTask.Delay(delay);try{awaitConnectInternal();_retryCount0;_stateConnectionState.Connected;// 重连成功后立即做一次库存校验// 防止断线期间有未同步的出入库操作await_inventoryService.VerifyAndSyncAsync();Log.Information(PLC重连成功库存已校验);return;}catch(Exceptionex){_retryCount;Log.Error(ex,重连失败 #{Count},_retryCount);Cleanup();}}}关键差异工厂场景重连后通常直接恢复业务但药房场景必须先校验库存一致性。断线期间如果药师手动发了药应急模式上位机恢复后不知道这件事会导致账实不符。校验逻辑是对比PLC侧汇总寄存器和数据库记录不一致时触发告警并暂停自动发药等待人工确认。六、 数据持久化SQLite做本地“黑匣子”医院信息系统不允许“内存里有、库里没有”的状态。我们用SQLite做本地事务日志确保每一笔操作都可追溯publicclassTransactionLogger{privatereadonlySqliteConnection_conn;publicasyncTaskLogDispenseAsync(DispenseRecordrecord){awaitusingvarcmd_conn.CreateCommand();cmd.CommandText INSERT INTO dispense_log (timestamp, drug_id, requested_qty, actual_qty, operator_id, prescription_no, status) VALUES (ts, did, rq, aq, op, rx, st);cmd.Parameters.AddWithValue(ts,DateTime.Now.ToString(o));cmd.Parameters.AddWithValue(did,record.DrugId);cmd.Parameters.AddWithValue(rq,record.RequestedQty);cmd.Parameters.AddWithValue(aq,record.ActualQty);cmd.Parameters.AddWithValue(op,record.OperatorId);cmd.Parameters.AddWithValue(rx,record.PrescriptionNo);cmd.Parameters.AddWithValue(st,record.Status);awaitcmd.ExecuteNonQueryAsync();}}为什么不用SQL Server直连因为网络抖动时数据库写入失败不应阻塞发药流程。正确做法是先发药→写本地SQLite→异步同步到中心数据库。即使HIS宕机发药机仍能独立工作事后通过日志补录。七、 现场调试与合规要点7.1 审计日志是GSP合规的硬要求《药品经营质量管理规范》要求智能药房的所有操作必须有不可篡改的记录。我们的日志策略事件类型记录内容保留期限发药/入库时间操作员处方号药品数量结果5年库存变更变更前/后值变更原因操作人5年通信异常断线/重连/校验失败/重试次数3年配置变更参数修改前后值修改人审批号5年系统启停启动/关闭时间版本号自检结果3年⚠️合规红线日志文件必须启用完整性校验SHA-256哈希链禁止覆盖写入只允许追加。审计时如果发现日志有时间断层或哈希不连续视为违规。7.2 常见坑位速查现象根因解法发药数量偶发为0读取确认寄存器时PLC尚未写入轮询等待M21后再读D510库存校验总是失败读取过程中有并发入库PLC侧提供原子汇总寄存器重连后首批发药超时旧TCP连接TIME_WAIT未释放Cleanup后Delay(200ms)再Connect紧急处方排队过久普通请求占满队列三级优先队列Critical插队夜间批量同步慢全量读取400寄存器耗时过长增量标记位仅读变化货位HIS断网时无法发药强依赖在线验证本地白名单离线模式事后补录八、 性能实测测试环境汇川H5U PLC千兆以太网200货位智能发药机操作耗时备注单货位读取8-12ms2个寄存器全量库存读取(200货位)85-110ms分7批校验发药指令下发确认150-300ms含机械动作等待断线检测≤3s心跳探针周期重连恢复2-8s指数退避库存校验72小时通信成功率99.97%剩余0.03%为计划内维护总结医院智能药房的Modbus TCP对接技术难度不高但责任权重极高。每一个设计决策都要回答一个问题“如果这一步失败了会不会影响患者用药安全”回顾整个方案核心思想可以浓缩为三点协议层做减法Modbus只做搬运工校验、调度、容错全部在上位机完成数据层做加法本地SQLite哈希链异步同步确保任何异常状态下数据可追溯业务层做隔离药品语义与寄存器地址彻底解耦让系统能适应设备迭代和政策变化工程的价值不在于用了多先进的技术而在于把不确定性关进笼子里。希望这篇文章能帮你在下一个医疗信息化项目中少走一些弯路多一份安心。参考资料《药品经营质量管理规范》(GSP) 附录计算机系统汇川H5U系列Modbus TCP通信手册NModbus4 / FluentModbus GitHub仓库YY/T 0664-2020 医疗器械软件生存周期过程