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

前言:当“发药”变成一道数学题

在很多人印象里,医院药房还是药师拿着处方在货架间穿梭、人工核对、手工录入的场景。但在三甲医院的智慧化改造浪潮下,智能发药机已经成为标配。设备负责机械臂抓取和传送,而真正让这台钢铁巨兽“懂业务”的,是背后那套C#上位机系统。

去年我们承接了某省级医院的智能药房改造项目,核心难点不在于机械控制,而在于如何通过Modbus TCP协议,在药品高频出入库的同时,保证库存数据的绝对准确。医疗场景容错率为零——少发一盒药是医疗事故,多发一盒是合规风险,库存对不上则直接影响医保结算。

这篇文章不讲智能发药机的机械原理,只聚焦一个命题:如何用C#把Modbus TCP这个“古老”的工业协议,用出医疗级的可靠性。所有方案均经过7×24小时现场验证,代码可直接复用。

一、 为什么智能药房选Modbus TCP而不是OPC UA?

这是项目评审时被问得最多的问题。答案很务实:

  1. 设备生态决定协议:国产智能发药机(如苏州艾隆、深圳瑞驰等)90%以上原生支持Modbus TCP,PLC多为汇川或信捷。强行上OPC UA需要额外部署网关,增加故障点
  2. 数据模型简单:药房交互本质是“读写寄存器”——货位状态、药品编码、数量、批次号,全是结构化数值,不需要OPC UA的信息建模能力
  3. 实时性要求适中:单次发药指令下发到确认的窗口在200-500ms之间,Modbus TCP的8-15ms响应完全够用
  4. 运维成本:医院信息科熟悉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不使用字符串传输,而是在上位机维护一张Dictionary<int, string>映射表。PLC侧只存整型ID,节省75%的寄存器占用。这张映射表启动时从数据库加载,变更时热重载,无需重启。

四、 通信管理器:医疗级可靠性实现

4.1 串行请求队列 + 优先级调度

药房场景有个特殊需求:紧急处方(如抢救用药)必须插队。普通FIFO队列无法满足这一点。

publicenumRequestPriority{Normal=0,Urgent=1,Critical=2}publicclassPharmacyPlcClient:IAsyncDisposable{// 三级优先队列:Critical > Urgent > NormalprivatereadonlyPriorityQueue<PlcRequest,int>_queue=new();privatereadonlySemaphoreSlim_semaphore=new(1,1);privateIModbusMaster?_master;privateConnectionState_state=ConnectionState.Disconnected;publicasyncTask<T>ExecuteAsync<T>(Func<IModbusMaster,Task<T>>operation,RequestPrioritypriority=RequestPriority.Normal,CancellationTokenct=default){varrequest=newPlcRequest<T>(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{privateconstintBatchSize=64;// 汇川H5U安全上限privateconstintBatchIntervalMs=3;publicasyncTask<Dictionary<int,InventoryItem>>ReadFullInventoryAsync(PharmacyPlcClientclient,CancellationTokenct){varresult=newDictionary<int,InventoryItem>(200);for(intoffset=0;offset<400;offset+=BatchSize){ct.ThrowIfCancellationRequested();intcount=Math.Min(BatchSize,400-offset);varbatch=awaitclient.ExecuteAsync(m=>m.ReadHoldingRegistersAsync(1,(ushort)(100+offset),(ushort)count),RequestPriority.Normal,ct);// 每2个寄存器解析为一个货位for(inti=0;i<batch.Length;i+=2){intslotIndex=(offset+i)/2;result[slotIndex]=newInventoryItem{DrugId=batch[i],Quantity=batch[i+1]};}if(offset+BatchSize<400)awaitTask.Delay(BatchIntervalMs,ct);}// 应用层校验:总数与PLC侧汇总寄存器比对varplcTotal=(awaitclient.ExecuteAsync(m=>m.ReadHoldingRegistersAsync(1,99,1),ct))[0];varlocalTotal=result.Values.Sum(v=>v.Quantity);if(localTotal!=plcTotal)thrownewInventoryChecksumException($"库存校验失败: PLC汇总={plcTotal}, 本地计算={localTotal}");returnresult;}}

🔑为什么要在PLC侧维护一个汇总寄存器?因为Modbus没有事务概念,读取过程中如果有入库操作穿插,分批读到的数据可能不一致。PLC侧的汇总值是在扫描周期内原子更新的,作为“快照校验和”使用。如果校验失败,上位机应丢弃本次结果并重试,而不是使用脏数据。

4.3 发药指令下发:握手时序防错

发药是最关键的操作,必须保证“指令-执行-确认”三步严格有序:

上位机 PLC │ │ │── 写入D500=[药品ID,数量] ──► │ │── 置M0=1(触发) ────────────► │ │ │── 机械臂取药 │◄── M1=1(执行中) ─────────── │ │◄── M2=1(完成)+D510=实际数量 │ │── 清M0=0 ──────────────────► │ │◄── 清M1/M2=0 ────────────── │ │ │

对应C#实现:

publicclassDispenseExecutor{publicasyncTask<DispenseResult>DispenseAsync(PharmacyPlcClientclient,intdrugId,intquantity,CancellationTokenct){usingvartimeoutCts=CancellationTokenSource.CreateLinkedTokenSource(ct);timeoutCts.CancelAfter(TimeSpan.FromSeconds(30));// 单次发药最长30秒// Step 1: 写入指令awaitclient.ExecuteAsync(m=>m.WriteMultipleRegistersAsync(1,500,newushort[]{(ushort)drugId,(ushort)quantity}),RequestPriority.Critical,timeoutCts.Token);// Step 2: 触发awaitclient.ExecuteAsync(m=>m.WriteSingleCoilAsync(1,0,true),RequestPriority.Critical,timeoutCts.Token);// Step 3: 轮询等待完成(带超时)boolcompleted=false;ushortactualQty=0;while(!completed){timeoutCts.Token.ThrowIfCancellationRequested();varstatus=awaitclient.ExecuteAsync(m=>m.ReadCoilsAsync(1,1,2),// M1+M2RequestPriority.Critical,timeoutCts.Token);if(status[1])// M2=1 完成{actualQty=(awaitclient.ExecuteAsync(m=>m.ReadHoldingRegistersAsync(1,510,1),RequestPriority.Critical,timeoutCts.Token))[0];completed=true;}elseif(!status[0])// M1=0 且 M2=0 → 异常中断{thrownewDispenseException("PLC未响应执行确认,可能硬件故障");}awaitTask.Delay(50,timeoutCts.Token);// 50ms轮询间隔}// Step 4: 清除触发信号awaitclient.ExecuteAsync(m=>m.WriteSingleCoilAsync(1,0,false),RequestPriority.Critical,timeoutCts.Token);returnnewDispenseResult(drugId,quantity,actualQty);}}

💡防御性设计要点

  • 轮询间隔50ms而非10ms:给PLC扫描周期留余量,避免无效请求堆积
  • 检测M1=0且M2=0的异常态:正常流程中M1应先于M2置位,如果两者同时为0说明PLC侧发生了复位或故障
  • 超时后必须清除M0:否则PLC会认为指令仍在执行,阻塞后续所有发药请求

五、 断线重连:医院场景的特殊考量

医院网络和工厂不同,交换机可能被保洁误触、WiFi干扰频繁、夜间会有网络设备维护窗口。重连策略需要更“耐心”:

privateasyncTaskReconnectLoop(){while(_state==ConnectionState.Reconnecting){// 医院场景退避上限放宽到60秒,避免维护窗口期间疯狂重连intdelay=Math.Min(1000*(1<<_retryCount),60_000);Log.Warning("PLC断线,{Delay}ms后第{Count}次重连",delay,_retryCount+1);awaitTask.Delay(delay);try{awaitConnectInternal();_retryCount=0;_state=ConnectionState.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尚未写入轮询等待M2=1后再读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对接,技术难度不高,但责任权重极高。每一个设计决策都要回答一个问题:“如果这一步失败了,会不会影响患者用药安全?”

回顾整个方案,核心思想可以浓缩为三点:

  1. 协议层做减法:Modbus只做搬运工,校验、调度、容错全部在上位机完成
  2. 数据层做加法:本地SQLite+哈希链+异步同步,确保任何异常状态下数据可追溯
  3. 业务层做隔离:药品语义与寄存器地址彻底解耦,让系统能适应设备迭代和政策变化

工程的价值不在于用了多先进的技术,而在于把不确定性关进笼子里。希望这篇文章能帮你在下一个医疗信息化项目中,少走一些弯路,多一份安心。


参考资料

  • 《药品经营质量管理规范》(GSP) 附录:计算机系统
  • 汇川H5U系列Modbus TCP通信手册
  • NModbus4 / FluentModbus GitHub仓库
  • YY/T 0664-2020 医疗器械软件生存周期过程