ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

C#智能仓储上位机开发:通信协议、数据采集与任务调度实战

C#智能仓储上位机开发:通信协议、数据采集与任务调度实战 简介这是一个基于C#与WPF技术实现的智能仓储上位机项目面向物流、制造业中需要部署仓库管理系统或研究RFID应用的工程师与.NET开发者。程序通过串行接口控制RFID-UHF读写器完成电子标签的数据读取、写入与校验并针对触摸屏场景优化了操作反馈与布局。资源包共173个文件约5.25MB主体包括38个C#源文件、38个图片资源、5个XAML界面文件同时附有可执行文件、动态链接库、配置文件和数据库文件UI与业务逻辑完整可直接运行或二次修改。项目内主窗口、管理窗口、操作窗口等模块划分清晰覆盖登录校验、库存查询、标签读写、数据存储等典型环节适合学习WPF数据绑定、串口通信、RFID指令交互和模块化分层设计。已有462人学习对需要快速搭建上位机原型或深入理解UHF读写器集成方式的开发人员具备较强的参考与复用价值。1. 智能仓储上位机程序到底在解决什么问题很多刚接触智能仓储项目的人第一步就卡在“上位机”三个字上以为它只是给操作员看库存数量的界面。真正的上位机是仓储系统的调度中枢它要接住PLC、扫码枪、RFID读写器、提升机等设备上报的状态把任务下发给对应设备再把设备应答和库存变化写进数据库同时要让人能看得到实时动作。用C#做这件事优势不是语法新而是Windows桌面生态成熟串口、Socket、ODBC、WPF这些零散学过的类库组合起来就是一套能落地的仓储控制系统。接下来按通信层、采集刷新、业务数据三条线往下讲最后落到稳定性验证的几个关键点。2. C#上位机的通信层怎么选Modbus、S7、TCP与串口的取舍2.1 先确认设备侧支持什么协议再谈驱动我在仓储项目里见过最浪费时间的做法是开发前不找设备商要协议文档直接在代码里套某个库。智能仓储的现场设备大致分三类PLC西门子1200、倍福、三菱、总线型传感器RFID、移位传感器、串口设备扫码枪、电子秤。每类设备的通信方式不同选型时先列一个表设备类型常见协议C#侧选库方向通信载体西门子PLCS7协议S7.Net / Sharp7工业以太网第三方PLCModbus TCP / RTUNModbus4以太网或串口RFID读写器厂家私有TCPSocket直接实现以太网扫码枪串口/UDPSerialPort / UdpClientRS232/网络这张表的重点不是库名而是先确认现场设备支持哪种载体。有些项目里PLC的厂家库只支持以太网但扫码枪又是RS232上位机就得同时维护两套通信通道。我一般会在架构里定义两个接口IDeviceGateway和IEquipmentDriver前者管理连接生命周期后者解析具体协议。这样换设备时只需要替换驱动类不用改动业务层。选型时先问三个问题第一个PLC的Program块里有没有预留给上位机的数据区有些方案直接把堆垛机当前位置、报警字、任务队列放在DB块这时用S7协议读写最直接。第二个产线有没有第三方设备要求统一用Modbus有的话即使PLC是西门子也要做协议转换。第三个设备商能否提供帧格式文档样例拿不到文档的项目我会把通信层抽象出来先写一个模拟器驱动让业务开发不受设备缺席影响。2.2 用Modbus TCP还是S7协议连西门子1200时怎么定有一点大多数新手不清楚连西门子1200时Modbus TCP和S7协议都能通但边界不一样。Modbus TCP在C#里用NModbus4这类库封装的是寄存器读写地址以保持寄存器为横轴需要把设备数据映射到Modbus地址适合第三方系统做数据交换。但寄存器一个地址只有16位浮点数和字符串还要拆成多寄存器处理业务代码里全是地址偏移改一个地址就要重新读PLC文档。S7协议直接操作PLC的DB块、M区、I/Q区和TIA Portal里的符号名挂钩。连1200时我一般用S7.Net.Plus里的工具类先测试读单个DBW确认字节序。下面是一段最小测试代码using System; using S7.Net; class S7ReadTest { public static void Test(Plc plc) { // 第一步设置读超时避免PLC掉线时界面假死 plc.ReadTimeout 2000; bool connected plc.IsConnected; if (!connected) { plc.Open(); } // DB1.DBW0设备商通常把任务状态放在这里 ushort status (ushort)plc.Read(DB1.DBW0); Console.WriteLine($当前状态字: {status}); // DB1.DBD2是一个Real浮点读取时按4字节处理 float position (float)plc.Read(DB1.DBD2); Console.WriteLine($当前位置: {position:F2} mm); plc.Close(); } }这段代码里ReadTimeout设为2000毫秒很关键S7协议底层是TCP如果不设超时PLC断电或交换机关闭时读操作会一直卡在socket等待上上位机会表现为“假死”。Read方法的重载中传字符串地址比传数字块号更直观但要求程序集的工艺地址与PLC固件匹配。实际项目里我建议只在联调环境这样简写正式代码里把地址放到配置文件中因为仓储项目的PLC程序会随着工艺改动地址表必须能动态加载。如果现场不止一家PLC或者电气工程师只给了一份Modbus寄存器表那就不要强行上S7。用Modbus TCP时先做寄存器映射表把线圈、离散输入、保持寄存器、输入寄存器分开。连1200时PLC侧IP通常是192.168.0.1端口502请求帧里unit ID要匹配PLC的从站地址。下面是一段用NModbus4读保持寄存器的代码using System; using System.Net.Sockets; using Modbus.Device; class ModbusTcpRead { public static void ReadHoldingRegisters(string ip, int port, byte unitId) { using var client new TcpClient(ip, port); var master ModbusIpMaster.CreateIp(client); ushort startAddress 0; // 从0号寄存器开始 ushort numberOfPoints 10; // 连续读10个寄存器 ushort[] values master.ReadHoldingRegisters(unitId, startAddress, numberOfPoints); for (int i 0; i values.Length; i) { Console.WriteLine($寄存器0x{startAddress i:X2} {values[i]}); } } }需要注意NModbus4的ReadHoldingRegisters方法返回的每个元素是ushort默认是大端字节序PLC里如果用了CDAB等乱序格式要在上位机侧手动交换高低字节。调试时最好先读一个已知的常数地址比如设备型号寄存器确认字节序后再处理浮点。很多项目最后查出来的“数据不对”都不是库的问题而是数据的字节序和格式没对齐。2.3 串口与扫码枪/RFID的数据帧怎么解析串口设备在仓储项目里不会消失原因是扫码枪和部分电子秤仍然以RS232为主流。C#的SerialPort是最直接的类但串口通信不等于一行行读数据。扫码枪上报的条形码不是每帧一行而是完全看你从设备商拿到的帧协议可能有STX/ETX包裹也可能CRC校验在末尾。错误做法是从事件里取一个字节就立即解析正确做法是把接收到的字节追加到缓存区然后按帧头找帧头按长度截取。下面是我常用来拼包解析的模式public class SerialPortFrameParser { private readonly Listbyte _buffer new Listbyte(4096); private readonly byte _frameHeader 0xAA; private readonly byte _frameEnd 0x55; public void Feed(byte[] data, int offset, int count) { lock (_buffer) { for (int i offset; i offset count; i) { _buffer.Add(data[i]); } TryExtractFrames(); } } private void TryExtractFrames() { while (true) { int startIndex _buffer.IndexOf(_frameHeader); if (startIndex 0) { _buffer.Clear(); return; } int endIndex _buffer.IndexOf(_frameEnd, startIndex 1); if (endIndex 0) { _buffer.RemoveRange(0, startIndex); return; } byte[] frame _buffer.GetRange(startIndex, endIndex - startIndex 1).ToArray(); _buffer.RemoveRange(0, endIndex 1); ProcessFrame(frame); } } private void ProcessFrame(byte[] frame) { // 此处解析CRC并交给业务层 Console.WriteLine(BitConverter.ToString(frame)); } }参数说明_buffer容量4096是经验值一般扫码枪一帧不超过256字节留大一点是为了防止半包。临界情况是数据流里帧头0xAA丢失IndexOf会返回-1此时清空buffer是保守做法但如果现场干扰严重导致连续乱码我会改成只裁剪最近256字节避免无效数据长期占用内存。lock是必须的因为DataReceived事件在线程池线程触发同时UI可能也访问同一份缓存。3. 循环数据采集与UI刷新卡顿的C#处理模式3.1 UI线程为什么卡为什么StatusStrip会假死如果你在按钮点击事件里写了一个while循环去读PLC的状态程序一秒内就无响应。原因是C# Windows窗体程序有一个UI线程消息循环按钮点击、Paint、Timer回调都在它里面排队。你在UI线程写死循环消息泵就停了整个窗口会进入“正在响应”的等待状态。仓储项目里最典型的卡顿就是把PLC采集、扫码枪接收、数据库保存全部塞进同一个Timer里。我见过一个项目操作员点“开始”后程序每200ms轮询一次PLC同时把100个库位的状态刷新到DataGridView结果界面先卡然后鼠标拖窗都是白边。问题不在数据量而在采集线程和UI更新线程合在了一起。解决思路只有一条把采集线程和显示线程拆开。3.2 用BackgroundWorker还是Channel异步循环拆开之后怎么选我先说结论能用ChannelT就尽量别用BackgroundWorker。两者在仓储采集场景的对比维度BackgroundWorkerChannel async自身缓冲无需要另建队列内置有界/无界Channel取消协作CancelAsync配合事件CancellationToken贯穿所有异步API背压处理自己写信号量FullMode自定义丢弃/等待与UI联动RunWorkerCompleted事件用Timer或Dispatcher转回UI线程BackgroundWorker在单任务后台处理时很顺滑但仓储采集是典型的生产者-消费者模型设备数据采集循环是生产者UI刷新和数据库写入是消费者。Channel天然把两个循环解耦所以在新的WPF或WinForms项目里我会优先选Channel。下面是一个最小采集循环using System.Threading.Channels; using System.Threading.Tasks; public class PlcCollector { private readonly ChannelPlcSnapshot _channel; private readonly CancellationTokenSource _cts new CancellationTokenSource(); private DateTime _lastRefresh; public PlcCollector(int capacity 1024) { _channel Channel.CreateBoundedPlcSnapshot( new BoundedChannelOptions(capacity) { SingleReader true, FullMode BoundedChannelFullMode.DropWrite }); } public async Task StartCollectingAsync(FuncPlcSnapshot readPlc) { while (!_cts.IsCancellationRequested) { PlcSnapshot snapshot await Task.Run(readPlc); await _channel.Writer.WriteAsync(snapshot, _cts.Token); await Task.Delay(100, _cts.Token); } } public async Task ProcessAsync(ActionPlcSnapshot onSnapshot, TimeSpan refreshInterval) { await foreach (var item in _channel.Reader.ReadAllAsync(_cts.Token)) { if (DateTime.UtcNow - _lastRefresh refreshInterval) { onSnapshot(item); _lastRefresh DateTime.UtcNow; } } } }参数说明capacity设为1024意味着如果数据库写入慢采集不会无限增长。FullMode设为DropWrite表示采集过快时丢弃最新包这是仓储监控里相对合理的损耗如果数据不能丢改成Wait模式即可但写入慢时采集循环会被阻塞。StartCollectingAsync里用Task.Run(readPlc)把同步PLC驱动放到线程池避免异步循环被阻塞。ProcessAsync里用refreshInterval控制UI刷新频率100ms采集一次UI只刷新10次/秒人眼无差别却能避免DataGridView频繁重绘。3.3 UI里的可控列表更新不要每来一条就刷新一行即使有了Channel如果onSnapshot里直接去修改UI控件仍然会在高频写入时卡顿。原因是WinForms的DataGridView每次更新一行都会触发重绘。我一般不在onSnapshot里逐行Update而是维护一个最新快照缓存用System.Windows.Forms.Timer定时把整个缓存赋值给DataSource。System.Windows.Forms.Timer的Tick回调本身就运行在UI线程所以不需要再BeginInvoke真正要控制的是消费者线程只更新_latest字典。private readonly Dictionarystring, PlcSnapshot _latest new Dictionarystring, PlcSnapshot(); private void UpdateLatest(PlcSnapshot s) { lock (_latest) { _latest[s.Key] s; } } private void RefreshUiTimer_Tick(object sender, EventArgs e) { ListPlcSnapshot rows; lock (_latest) { rows _latest.Values.ToList(); } dataGridView1.DataSource null; dataGridView1.DataSource rows; }注意DataGridView.DataSource先置null再赋值是为了让网格在点位集合变化时重新生成列这个操作有代价所以只适合整页刷新模式。如果点位超过200个或者操作员正在拖滚动条会被重置我建议改成用BindingListPlcSnapshot做数据源或者只更新记录变化的单元格。定时器Interval我习惯设500ms也就是2次/秒太快没有视觉意义太慢又会让操作员觉得数据不跟手。提示System.Windows.Forms.Timer的精度大约只有55ms不能作为PLC状态采集的时基只适合UI刷新。要精确采集用后台循环里的Stopwatch或设备的DispatcherTimer。4. 智能仓储的业务核心任务队列、库存与数据库写入4.1 上位机不是只有通信还要调度任务很多刚入门的上位机程序把焦点放在“读数据、画曲线”上但智能仓储项目里上位机必须能下达任务。比如入库任务从PDA发起上位机收到后要判断目标货位是否空、堆垛机是否有空闲、输送线是否有冲突然后生成一条任务记录发送给PLC等待PLC返回执行结果。如果只做通信不做任务调度那只是个数据看板。任务状态机要简单化别过度设计。我常用的是五个状态New、Dispatched、Executing、Finished、Error。每个任务实体都有唯一ID这个ID会同时写进PLC的数据块和数据库PLC执行完后把ID回传上位机靠ID完成整个闭环。下面是典型的C#任务实体public class WarehouseTask { public long TaskId { get; set; } public string TaskType { get; set; } // Inbound / Outbound / Move public string SourceLocation { get; set; } public string TargetLocation { get; set; } public DateTime CreatedAt { get; set; } public int Status { get; set; } // 0New 1Dispatched 2Executing 3Finished 4Error public int Priority { get; set; } // 0 最高数字越大优先级越低 }TaskType和Priority是调度器真正消费的字段。我见过一个项目试图用十几个类表达任务差异结果每个类都要匹配PLC状态字维护成本直线上升。反过来把差异收窄到TaskType的字符串值调度器只依赖这个字段决定写哪个PLC地址后续扩展新任务类型时只需增加一个枚举值和对应的设备驱动方法。4.2 库存表用SQLite还是SQL Server怎么设计仓储数据的落点一般有两种选择小库位单机用SQLite多客户端并发用SQL Server。两者都能在C#上位机里用Entity Framework Core或ADO.NET操作。但如果项目要求同时有三台PDA并发入库SQLite的写锁会成为瓶颈所以我会优先选SQL Server。以下是边界维度SQLiteSQL Server部署单文件免维护需要装服务并发写单写线程易锁库行级锁事务安全适合场景单机演示、工具型正式仓储、多人PDAC#接入Microsoft.Data.SqliteMicrosoft.Data.SqlClient库存表至少要拆成两个表Location货位和Stock库存不要把所有信息堆在一张表里。货位是物理位置库存是sku与数量的关系。下面是常用建表SQLCREATE TABLE Location ( LocationCode NVARCHAR(20) PRIMARY KEY, IsOccupied BIT NOT NULL DEFAULT 0, ZoneCode NVARCHAR(10) NULL, UpdatedAt DATETIME2 NOT NULL DEFAULT GETDATE() ); CREATE TABLE Stock ( StockId BIGINT IDENTITY PRIMARY KEY, LocationCode NVARCHAR(20) NOT NULL, SkuCode NVARCHAR(50) NOT NULL, Quantity DECIMAL(10,2) NOT NULL DEFAULT 0, BatchNo NVARCHAR(50) NULL, UpdatedAt DATETIME2 NOT NULL DEFAULT GETDATE(), CONSTRAINT FK_Stock_Location FOREIGN KEY (LocationCode) REFERENCES Location(LocationCode) );LocationCode做主键能避免同一个货位被重复维护。Stock的LocationCode是外键保证库存永远属于一个真实存在的货位。DECIMAL(10,2)用于重量型物料整数备料型项目也可以但碰到按米或公斤计量的料箱整数不够用。BatchNo字段是智能仓储最容易漏的上游物料批次不一样即使SKU相同也不能混放同一货位。4.3 并发扣库存用UPDATE条件代替先查后写上位机程序在多线程下扣库存时很多刚转型的开发者会写“先SELECT再UPDATE”这个模式在并发下一定会出错。原因是多个出库任务同时处理时两个线程读到同一个库存量10各自扣掉3最后却都写回7库存凭空少了3。解决方法是把查询和更新放在一条带条件的UPDATE语句里。UPDATE Stock SET Quantity Quantity - 3, UpdatedAt GETDATE() WHERE SkuCode SKU-1001 AND Quantity 3;这条SQL即使同时有多个进程执行数据库也会为行加锁保证后一个UPDATE看到的是前一个已经写完的Quantity。执行完毕后读取ROWCOUNT如果等于0说明库存不足之前的操作要回滚。C#里用ADO.NET执行时要配事务把库存扣减和目标货位占用一起提交否则可能库存扣了但货位没写形成脏数据。using Microsoft.Data.SqlClient; using (var conn new SqlConnection(connString)) { conn.Open(); using var tx conn.BeginTransaction(); using var cmd new SqlCommand( UPDATE Stock SET Quantity Quantity - qty, UpdatedAt GETDATE() WHERE SkuCode sku AND Quantity qty, conn, tx); cmd.Parameters.AddWithValue(qty, 3); cmd.Parameters.AddWithValue(sku, SKU-1001); int affected cmd.ExecuteNonQuery(); if (affected 0) { tx.Rollback(); return; // 库存不足结束本次出库 } // 继续更新货位占用、生成出库记录最后提交 tx.Commit(); }注意AddWithValue在SQL Server中会有NVARCHAR类型的隐式转换隐患正式项目建议显式指定SqlDbType尤其是SkuCode这类字符串字段。qty的类型要和表的DECIMAL(10,2)保持一致用decimal避免浮点精度问题。事务的提交时机是整个任务完成之后而不是每次更新之后否则一个任务拆成两步时中途崩溃会导致货位和库存不一致。提示如果把这段参数化SQL放在循环里执行连接字符串要开启Poolingtrue并且不要每次都新建连接字符串对象否则高频入库时会出现连接池耗尽。5. 验证上位机稳定性的三个检查点和一条调试技巧5.1 检查点通信超时和自动重连仓储设备不是一直在线的操作员可能关掉PLC也可能交换机闪断。上位机必须能在断线后自动恢复。我一般把每次读写PLC的操作包一层try/catch捕获异常后用独立的重连线程反复Open。不要在主循环里重连否则主循环被阻塞任务队列停止。S7协议里Plc.IsConnected属性并不能实时反映物理断线只有下一次读抛异常时才知道。重连逻辑要有退避间隔第一次1秒第二次2秒最多10秒防止PLC恢复过程中高频重连造成网络拥堵。5.2 检查点关键流程要落日志没有日志的上位机上线一周后出现“货位实际有货但数据库显示空”时基本没法定位。用Serilog写结构化日志是常见做法日志至少要记录三类信息通信收发原始报文、任务状态流转包括任务ID和PLC回传ID、数据库事务提交结果。日志文件按天滚动保留30天。不要只在catch里写日志正常任务完成也要写否则看不到异常流程的前因。仓储项目里的异常往往不是突发而是某一步状态没有正确流转把状态变更顺序打印出来问题能缩小到单个方法。5.3 调试技巧用模拟PLC设备来跑全流程如果设备还没进场或者设备商只给了协议文档直接在真实PLC上联调会拖慢进度。我通常在C#里写一个TcpListener模拟S7服务器的数据区周期性地把模拟位置变化写入堆栈然后用上位机连接这个模拟器跑通“任务下发—状态变化—数据库更新”的闭环。下面是一个轻量的模拟器启动逻辑using System.Net; using System.Net.Sockets; var listener new TcpListener(IPAddress.Any, 502); listener.Start(); while (true) { var client await listener.AcceptTcpClientAsync(); _ Task.Run(() HandleClient(client)); }模拟器的关键是只实现设备端需要响应的读写协议格式。对于S7协议直接模拟完整握手会比较复杂这时可以让模拟器返回固定字节验证上位机的超时和重连时序而不需要真正和西门子1200握手。等协议调试通过再把连接IP切到真实PLC。这种技巧把上位机的业务开发和现场物联分离前端界面、数据库、调度逻辑都能提前测试。每次改上位机代码后先用模拟器跑2小时看是否有内存泄漏或任务卡死再拿真实PLC做最后验证。本文还有配套的精品资源点击获取
返回列表