
简介WCS-HY 仓库控制系统是一套基于 C# 开发的完整工程代码面向从事仓储自动化、物流调度及 WMS/WCS 系统集成的开发人员与实施工程师。系统围绕设备协调、任务调度与系统对接展开可帮助理解 WCS 在自动化仓库中的中间层控制逻辑以及 C# 与 .NET 框架下的界面、驱动与通信实现。压缩包共 2000 个文件以 C# 源码、XAML 界面定义、DLL 类库、EXE 可执行程序及 config/xml/json 配置文件为主体另含 PLC 配置、版本说明等多种工程文件整包约 251.76 MB适合作为二次开发或项目参考。目前已有 282 人学习下载。资料不仅包含可编译运行的代码框架还涵盖多类设备驱动与通信配置、界面控件资源、运行日志与缓存定义等细节便于对照研究系统模块划分和关键接口。通过分析源码结构与配置文件可以较快上手 WCS 与 WMS 对接、搬运设备调度及异常处理等典型场景为实际项目落地提供有价值的参考。1. WCS 控制系统代码 C#一份能跑的仓储调度骨架接手过一个电商分拨仓库的 WCSWarehouse Control System控制系统改造甲方现场的中控电脑上跑着一套十年前的老系统界面是灰底黑字任务积压的时候调度员得拿笔抄单号。说白了WCS 就是仓储里接 WMS 和底层设备之间的那层“翻译官兼调度员”——上游仓储管理系统告诉你哪个订单要出库WCS 转成堆垛机、输送线、RGV 能听懂的动作指令然后把设备状态和任务结果反馈回去。这套用 C# 写的 WCS-HY 控制系统代码拆开看就是一套标准的仓储设备调度骨架设备通信、任务队列、状态管理、数据落库。适合正在做上位机、做仓储物流控制系统的工程师直接改来用也适合刚接触 WCS 的人拿它当活教材读。下面按我实际拆解的路径,把这套代码值得看的部分和必须避开的坑一起讲清楚。2. 设备通信层OPC-UA 与 Socket 双通道怎么选型2.1 选型逻辑为什么 WCS 通常同时保留两条通信链路WCS 控制系统里最容易被低估的就是设备通信层。很多人以为对接设备就是写个 TCP 客户端连上就完事实际现场根本不是这么回事。你面对的设备五花八门堆垛机控制器可能是西门子输送线电控箱里可能是欧姆龙或三菱RGV 小车可能是某个小厂定制的控制板只开放了自定义协议。一套成熟的 WCS 代码不会押注某一种通信方式而是同时保留两条链路一条走 OPC-UA用来对接支持标准协议的 PLC另一条走 Socket 自定义报文用来对接只给你一份文档的设备。我见过不少初版 WCS 只实现了 Socket结果现场新增的两台堆垛机控制器的 PLC 只开放了 OPC-UA 接口临时加驱动的代价就是三周工期延误。反过来也有团队只做了 OPC-UA结果对接一台老式输送线时发现对方根本不支持 OPC 协议。所以这套代码里“双通道”的设计思路值得直接抄——统一的设备接口抽象层上层调度逻辑不关心下面走的是 OPC 还是 Socket只有通信适配器不同。这也是我判断一份 WCS 代码是否合格的第一道分水岭。2.2 OPC-UA 对接西门子 PLC一个稳定的读取循环如果设备端是西门子 S7-1200/1500 这类 PLC常见做法是用 OPC-UA 把 PLC 里的 DB 块映射成节点WCS 定时读取这些节点获取设备状态写入节点下发动作指令。下面这个读取循环是这类代码的骨架逻辑不复杂关键在超时和重连处理。public class OpcUaDeviceClient { private readonly string _endpointUrl; private readonly string _nodePrefix ns2;sDevice1.; private Session _session; private const int ReadTimeoutMs 2000; public bool Connect() { try { var config new ApplicationConfiguration { ApplicationName WCS-HY Client, ApplicationUri urn:localhost:WCSHY:Client, SecurityMode ApplicationSecurityMode.None }; config.CertificateValidator new CertificateValidator(); var endpoint CoreClientUtils.SelectEndpoint(_endpointUrl, useSecurity: false); _session Session.Create(config, endpoint, endpointUrl: _endpointUrl, sessionName: WCS Session); _session.KeepAlive (s, e) { if (e.Status ServiceResult.Good) return; // 保活失败时触发重连不能在这里直接建连接只做标记 _needReconnect true; }; return _session ! null; } catch (Exception ex) { _needReconnect true; return false; } } public int ReadInt32(string tagName) { var nodeId new NodeId(${_nodePrefix}{tagName}); var value _session.ReadValue(nodeId, TransportDefaults.DefaultOperationTimeout); return Convert.ToInt32(value); } public void WriteBoolean(string tagName, bool value) { var nodeId new NodeId(${_nodePrefix}{tagName}); _session.WriteValue(nodeId, value); } }这段代码里最有价值的是 KeepAlive 事件里的处理方式只标记断连状态不在回调里直接重连。回调线程里做网络重连会造成连接状态竞争两个线程同时创建 Session 的结果就是控制台疯狂报错。我一般会在主调度循环里检查_needReconnect标记统一在业务线程里执行Connect()。另外注意TransportDefaults.DefaultOperationTimeout现场 PLC 响应慢的时候这个值要适当放大默认值在一些老 PLC 上会频繁触发超时。参数说明_nodePrefix前缀要和 PLC 的程序块命名对应ns2;s是 OPC-UA 标准写法SecurityMode.None是为了兼容现场各种老设备如果甲方要求加密得改配证书那也是 WCS 上线前就该做完的事别拖到联调阶段。2.3 Socket 直连输送线控制器指令与心跳的约定对只开放 TCP 接口的设备通信协议通常是你和电控工程师一起定的。最常见的坑是协议里只有指令没有心跳或者心跳超时时间设置得太短导致设备被误判离线。下面是我常用的一个 Socket 客户端模板里面把心跳和业务报文合在一起处理。public class SocketDeviceClient { private TcpClient _client; private readonly object _sendLock new object(); private readonly CancellationTokenSource _cts new CancellationTokenSource(); public async Task RunAsync(string ip, int port, int heartbeatIntervalMs 5000) { while (!_cts.IsCancellationRequested) { if (_client null || !_client.Connected) { await ReconnectAsync(ip, port); } var heartbeat Encoding.UTF8.GetBytes({\type\:\heartbeat\,\ts\:\ DateTime.Now.ToString(HH:mm:ss.fff) \}\n); lock (_sendLock) { _client.GetStream().Write(heartbeat, 0, heartbeat.Length); } var response await ReadLineAsync(); // 读取一行业务响应 if (string.IsNullOrEmpty(response)) { // 连续三次无响应才判定离线避免网络抖动误判 _noResponseCount; if (_noResponseCount 3) _client.Close(); } else { _noResponseCount 0; OnMessageReceived?.Invoke(response); } await Task.Delay(heartbeatIntervalMs); } } private async Task ReconnectAsync(string ip, int port) { await Task.Delay(3000); // 等待设备端恢复 _client new TcpClient(); await _client.ConnectAsync(ip, port); } }心跳间隔我会给 30005000 毫秒具体取决于设备控制器的扫描周期。如果电控工程师告诉你 PLC 扫描周期是 50 毫秒心跳给短一点没问题如果对方是国产一体机扫描周期可能不稳定心跳给到 8 秒也不丢人。这里有个玄学判断标准心跳间隔至少要是设备扫描周期的 30 倍。另外字符串报文末尾一定带换行符不然 ReadLineAsync 会一直等下去。很多联调翻车就是协议里没写清楚报文结束符双方各自理解一挂一整天。3. 任务调度与队列从 WMS 下发到设备执行的中间态设计3.1 任务状态机的六个状态与流转条件WCS 代码的核心不是通信是任务状态机。WMS 下发一个出库任务WCS 接收后要经历一系列状态才能变成设备动作。一套清晰的 C# 任务状态机代码会让后续所有排错都变简单。状态机设计差的话现场会出现任务“卡在中间态”这种最难查的故障。public enum WcsTaskStatus { Pending 0, // 已接收等待分配设备 Assigned 1, // 已绑定设备等待执行 Transit 2, // 设备正在执行 Completed 3, // 执行完成 Failed 4, // 执行失败 Cancelled 5 // 已取消 } public class WcsTask { public string TaskId { get; set; } public string WmsOrderId { get; set; } public string SourceLocation { get; set; } public string TargetLocation { get; set; } public WcsTaskStatus Status { get; set; } public DateTime CreateTime { get; set; } public DateTime? StartTime { get; set; } public DateTime? FinishTime { get; set; } public string BoundDeviceId { get; set; } public string ErrorMessage { get; set; } public bool CanTransitionTo(WcsTaskStatus target) { // 只允许单向流转 失败/取消可以到终态 return target Status 1 || (Status WcsTaskStatus.Pending target WcsTaskStatus.Cancelled) || (Status is WcsTaskStatus.Assigned or WcsTaskStatus.Transit target WcsTaskStatus.Failed); } }状态流转的关键设计是“单向递增”Pending → Assigned → Transit → Completed中间任何一步都能进 Failed 或 Cancelled。禁止任务从 Completed 往回走这是很多电商仓储系统的死穴——有一次现场退货任务被误触发重跑同一托盘从 Completed 变成 TransitWMS 那边的库存已经扣减了两边数据对不上差出 12 个托。从那以后我要求所有 WCS 状态机代码必须写CanTransitionTo任何非法流转直接抛异常并记录日志。3.2 核心调度循环先到先服务与优先级抢占的折中WCS 调度循环的代码往往是最有争议的部分。先到先服务实现简单但现实情况是紧急订单不断插入没有优先级机制的话紧急订单要等 40 分钟。优先级抢占设计得好能提升效率设计得不好就是任务饿死、设备空跑。public class TaskScheduler { private readonly ConcurrentQueueWcsTask _pendingQueue new ConcurrentQueueWcsTask(); private readonly object _deviceLock new object(); private readonly Dictionarystring, string _deviceState new Dictionarystring, string(); public WcsTask PickNextTask(string deviceId) { // 高优先级任务优先挑选 var urgentTasks _pendingQueue .Where(t t.Priority TaskPriority.High t.Status WcsTaskStatus.Pending) .OrderBy(t 0) // 高优先级内部按创建时间排 .ThenBy(t t.CreateTime) .ToList(); if (urgentTasks.Any()) { return urgentTasks.First(); } // 普通任务按创建时间排先到先服务 return _pendingQueue .Where(t t.Status WcsTaskStatus.Pending) .OrderBy(t t.CreateTime) .FirstOrDefault(); } }这个实现有两个隐藏问题。第一Priority TaskPriority.High的常量比较写死了现场如果调整优先级策略得改代码重新编译。我会把优先级做成配置项从数据库里读。第二 0这种写法虽然能让高优先级任务内部按创建时间排但如果高优先级任务持续涌入普通任务可能一整班都得不到执行。常见的折中方案是“防饿死”同一设备上执行的连续 N 个任务里至少保留一个普通优先级任务。这个 N 一般设置成 5也就是说 4 个紧急任务后必插 1 个普通任务。这个逻辑我会放在调度循环外面一个独立的计数器里避免污染核心调度代码。3.3 任务与设备绑定设备占用表怎么写WCS 的调度核心除了队列还有设备占用表。任务要绑定到具体设备这个绑定关系如果处理不好会出现两台设备同时抢同一个输送段的情况。实际项目里我用一张内存字典维护设备占用关系。public class DeviceOccupancyManager { private readonly Dictionarystring, string _deviceOwner new Dictionarystring, string(); private readonly object _lock new object(); public bool TryOccupyDevice(string deviceId, string taskId) { lock (_lock) { if (_deviceOwner.ContainsKey(deviceId)) { return false; // 设备已被占用 } _deviceOwner[deviceId] taskId; return true; } } public void ReleaseDevice(string deviceId, string taskId) { lock (_lock) { if (_deviceOwner.TryGetValue(deviceId, out var owner) owner taskId) { _deviceOwner.Remove(deviceId); } } } }这里必须用lock包裹不能依赖 ConcurrentDictionary 的原子性方法——TryAdd虽然原子但 “检查占用者是否为当前任务” 这个复合操作不是原子的。占用表还涉及一个特别容易踩的坑任务失败后的设备释放。有些团队在catch块里只记录错误日志而忘记释放设备结果设备被一个失败任务锁到下班。我现在的习惯是任务状态机进入 Failed 或 Cancelled 时统一在finally里调用ReleaseDevice不放任何特例。4. SQL Server 数据落库与日志回滚数据库设计与状态一致性4.1 四张核心表任务、设备、日志、配置WCS 数据落库如果用 Entity Framework 或者 Dapper 都行但表结构设计才是核心。看过不少初版代码把设备状态、任务、日志全塞一张表里后来查询和归档全都痛苦。一套标准的 WCS 数据库至少四张表我拆解这套代码时发现它的表设计也是这个思路。-- 1. 任务主表一任务一条记录 CREATE TABLE wcs_task ( task_id VARCHAR(50) PRIMARY KEY, -- 任务号WMS 下发或 WCS 自动生成 wms_order_id VARCHAR(50) NOT NULL, -- WMS 订单号 source_location VARCHAR(30) NOT NULL, -- 起点库位 target_location VARCHAR(30) NOT NULL, -- 目标库位 status INT NOT NULL DEFAULT 0, -- 状态枚举0待分配 1已分配 2执行中 3完成 4失败 5取消 priority INT NOT NULL DEFAULT 5, -- 优先级1最低 10最高 bound_device_id VARCHAR(30), -- 绑定的设备编号 create_time DATETIME2 NOT NULL, start_time DATETIME2, finish_time DATETIME2, error_msg NVARCHAR(500) ); CREATE INDEX idx_task_status ON wcs_task(status); CREATE INDEX idx_task_createtime ON wcs_task(create_time); -- 2. 设备实时状态表 CREATE TABLE wcs_device_status ( device_id VARCHAR(30) PRIMARY KEY, device_type VARCHAR(20), -- STACKER/CONVEYOR/RGV current_location VARCHAR(30), task_id VARCHAR(50), -- 当前执行任务 status INT NOT NULL DEFAULT 0, -- 0离线 1空闲 2忙碌 3故障 last_heartbeat DATETIME2 NOT NULL );priority字段在 WMS 下发任务里经常是空的WCS 要做默认值兜底。我在实际项目里习惯把优先级默认设为 5然后通过一个配置项控制“当 WMS 传了空值时使用什么默认值”而不是写死在代码里。任务表用task_id做主键wms_order_id不是主键——同一个 WMS 订单可能拆成 WCS 的多个子任务这事在拆包出库的场景里特别常见。4.2 批量写入与事务边界避免把数据库当缓存WCS 的调度性能瓶颈经常不在设备而在数据库。状态每次变化都写一条日志设备心跳每 2 秒写一次如果再用低效的逐条 INSERT数据库很快就成了瓶颈。这套 WCS 代码里用了 SqlBulkCopy 批量写日志这个做法值得抄。public class WcsLogWriter { private readonly ListWcsLogEntry _buffer new ListWcsLogEntry(); private readonly object _lock new object(); private readonly int _flushThreshold 500; private readonly Timer _flushTimer; public WcsLogWriter(int flushIntervalSeconds 5) { _flushTimer new Timer(_ Flush(), null, flushIntervalSeconds * 1000, flushIntervalSeconds * 1000); } public void Add(WcsLogEntry entry) { lock (_lock) { _buffer.Add(entry); if (_buffer.Count _flushThreshold) { Flush(); } } } public void Flush() { ListWcsLogEntry snapshot; lock (_lock) { if (_buffer.Count 0) return; snapshot new ListWcsLogEntry(_buffer); _buffer.Clear(); } using var conn new SqlConnection(_connectionString); using var bulkCopy new SqlBulkCopy(conn); bulkCopy.DestinationTableName wcs_task_log; bulkCopy.ColumnMappings.Add(task_id, task_id); bulkCopy.ColumnMappings.Add(log_time, log_time); bulkCopy.ColumnMappings.Add(log_type, log_type); bulkCopy.ColumnMappings.Add(content, content); var dataTable new DataTable(); dataTable.Columns.Add(task_id, typeof(string)); dataTable.Columns.Add(log_time, typeof(DateTime)); dataTable.Columns.Add(log_type, typeof(string)); dataTable.Columns.Add(content, typeof(string)); foreach (var entry in snapshot) { dataTable.Rows.Add(entry.TaskId, entry.LogTime, entry.LogType, entry.Content); } conn.Open(); bulkCopy.WriteToServer(dataTable); } }批量写的核心是“先攒后写”攒 500 条或者满 5 秒就刷一次数据库压力小一个量级。这个模式的坑在于进程退出时缓冲区里的日志会丢所以 WCS 程序退出前必须手动调一次Flush()。4.3 状态不一致时的回滚与补偿WCS 和 WMS 之间的数据一致性是仓库现场最头疼的问题。设备已经执行完动作但任务状态没更新到数据库WMS 那边任务卡在“执行中”不释放。常见的补偿机制是“定时对账”而不是“实时强一致”这套代码里用了一个状态补偿任务。public void Reconciliation(int timeoutMinutes 30) { // 找出所有卡在执行中但设备已经空闲/离线的任务 var staleTasks _db.Query( SELECT task_id, task_id AS taskid, status FROM wcs_task WHERE status 2 -- Transit AND bound_device_id IN ( SELECT device_id FROM wcs_device_status WHERE status IN (0, 1) -- 离线或空闲 ) AND start_time DATEADD(MINUTE, -timeout, GETDATE()), new { timeout timeoutMinutes }); foreach (var task in staleTasks) { // 先查设备反馈确认任务是否已实际完成 var deviceReportedDone _deviceService.ConfirmTaskFinished(task.taskid); if (deviceReportedDone) { _db.Execute(UPDATE wcs_task SET status 3, finish_time GETDATE() WHERE task_id id, new { id task.taskid }); } else { _db.Execute(UPDATE wcs_task SET status 4, error_msg 超时未完成补偿置为失败 WHERE task_id id, new { id task.taskid }); } } }这个补偿逻辑不追求实时每 5 分钟跑一次就行。关键是start_time DATEADD(MINUTE, -timeout, GETDATE())这个条件只处理超时任务避免刚下发的正常任务被误判。我在现场被这个问题坑过一次输送线上有一个传感器接触不良任务实际已走了 70%但状态一直停在“执行中”。WMS 不让发新任务仓库那条线就瘫痪着直到我手动把这个任务改成“完成”才恢复。从那以后我所有 WCS 项目里对账脚本是第一批交付物不是最后一版才补的。5. WCS 调试避坑五条高频故障与排查记录5.1 任务卡住不动是设备的 ACK 丢了还是调度循环被阻塞现象WCS 中控上看任务状态是“已分配”但现场设备纹丝不动日志里也没有任何设备动作记录。原因排查时我们通常先看三个点一是调度循环是否被某个耗时操作阻塞比如误把数据库查询写进了调度线程里并且没有超时二是指令是否发出去了但设备端没回 ACK三是指令发出去了设备也执行了但 WCS 没收到完成回执状态没有继续往下走。这套代码里有一个很常见的低级失误就是调度循环里调用了同步的Task.Delay而不是await Task.Delay导致整个调度线程被卡住现场表现就是“界面活着任务不走”。解决办法是把所有设备写入操作放进独立的任务队列调度循环本身只负责挑选任务不直接写通信。5.2 心跳超时误判设备被反复踢下线现象设备状态表里一台堆垛机在“在线/离线”之间反复横跳每 5 分钟跳一次值班同事以为设备坏了实际设备运行得好好的。原因心跳超时时间设置太短或者心跳报文和业务报文的处理是同一个管道业务量大时心跳被排在后面WCS 端等不到就判定离线。解决这两个问题要从两端下手设备端把心跳独立成一个发送线程业务报文走另一个线程WCS 端把“超时未收到心跳”和“连续 N 次超时未收到心跳”区分开后者才判定为离线。N 我一般设 3 或 5。5.3 线程内异常一个设备线程崩掉整条分拨线现象有段时间整个 WCS 每隔 40 分钟自动卡死必须重启程序才能恢复。查了 Windows 事件日志发现是某个设备的通信线程抛了异常异常没有被捕获导致进程崩溃。更隐蔽的是这种崩溃往往不在主线程表现出来就是死循环 / 卡死而不是直接闪退。原因在一个设备通信线程的try-catch里嵌套了另一个对象的调用被调对象内部有自己的线程内部异常逃逸出来把整个进程带崩。解决给所有常驻线程套上全局异常处理线上环境配置TaskScheduler.UnobservedTaskException和AppDomain.CurrentDomain.UnhandledException两个事件记录完整堆栈。同时给设备的接收循环加一个兜底catch(Exception)不允许任何线程因未捕获异常死亡。5.4 数据库连接池耗尽频繁开关连接的后果现象WCS 跑了 3 小时后日志里开始大量报“连接池已满”全部任务停顿。重启后又能撑 3 小时。原因一个常见的翻车现场是using块里只释放了命令对象没释放连接对象另一个是日志写入用了独立线程这个线程里每次循环都new SqlConnection用完没 Close连接池里的连接被耗尽。解决统一封装数据库访问基类所有数据库操作走同一个入口连接对象的释放放在finally里不做任何例外。另外把连接池的最大连接数从默认的 100 调小到 30逼迫代码尽早发现泄漏而不是延迟爆发。顺带提一句SQL Server 连接字符串里加PoolingTrue;Min Pool Size5能降低冷启动时的首次连接延迟这也是提升 WCS 启动速度的小技巧。5.5 时间戳精度毫秒级重复导致排序失效现象任务按创建时间排序时同一毫秒创建的多个任务顺序错乱导致高优先级任务不一定是先创建的。这个坑看起来小但会引出一系列更隐蔽的问题。原因DateTime.Now的底层精度是毫秒级高并发场景下两个任务的时间戳可能完全相同然后排序就没法保证 FIFO。解决创建时间字段用DateTime.UtcNow而不是DateTime.Now同时给任务实体增加一个自增序号SequenceId排序时先按优先级再按SequenceId完全避免依赖时间戳。另外 SQL 里的排序也要写双字段ORDER BY priority DESC, sequence_id ASC。这个改法成本极低但能根治这种隐蔽的乱序问题。6. 从单机到多客户端TCP Listener 与线程模型进阶如果现场不止一台设备需要 TCP 接入前面那个单设备客户端就不够用了。WCS 要能同时监听多台设备连接这就要用 TCP Listener 加每客户端一个接收线程的模式。下面这段代码是我在新项目里常用的一种结构比单纯AcceptTcpClientAsync套循环要稳得多。public class TcpDeviceServer { private TcpListener _listener; private readonly Dictionarystring, ClientConnection _clients new Dictionarystring, ClientConnection(); private readonly object _lock new object(); public async Task StartAsync(int port) { _listener new TcpListener(IPAddress.Any, port); _listener.Start(); while (true) { var tcpClient await _listener.AcceptTcpClientAsync(); var connection new ClientConnection(tcpClient, this); lock (_lock) { _clients[connection.ClientId] connection; } _ Task.Run(connection.ReceiveLoopAsync); // 抛出式后台运行异常由全局处理器接管 } } public void SendToAll(string message) { lock (_lock) { foreach (var client in _clients.Values.ToList()) { try { client.Send(message); } catch (IOException) { // 写入失败说明对端已断开下次心跳会触发清理 } } } } }这个实现的关键点有三个。第一_ Task.Run(...)后面不需要保存返回值异常统一走全局UnobservedTaskException不会裸奔。第二SendToAll里对每个客户端独立try-catch一个客户端断线不会影响其他客户端的发送。第三我用Dictionarystring, ClientConnection管理连接而不是ListTcpClient因为要支持后续把任务只发送给指定设备。这里我要多说一句TCP 设备管理在 WCS 里属于“表面简单、实际复杂”的部分我踩过的最大的坑是退出时没有清理监听导致端口被占用重启 WCS 要等 30 秒。从那以后我强制所有服务类实现一个StopAsync()方法把释放 listener、关闭所有客户端连接、等待线程退出的逻辑写全每次发布前走一遍Start → 运行 → Stop → Start的验证流程希望帮到你。本文还有配套的精品资源点击获取