ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C#工业自动化通用框架:机器人视觉抓取与PLC多任务调度实战

2026/10/1 12:03:27 拓冰建站 浏览量
C#工业自动化通用框架:机器人视觉抓取与PLC多任务调度实战 搞工业自动化的朋友应该都有同感一个项目从头到尾最费时间的往往不是业务逻辑而是和五花八门的设备做对接。我去年接手了一套机器人视觉抓取项目设备清单里有AUBO六轴机器人、外部行走轴、西门子PLC还有三台工业USB相机。项目要做的就是相机定位零件系统换算坐标机器人带着外部轴去抓取不管多忙还得把每次检测的图片、位置、OK/NG结果全部存库。听起来不复杂真做起来通信、调度、视觉、运动控制全搅在一起代码写到最后自己都看不懂。后来我把这些共性需求收敛成一套C#通用框架把机器人控制、多任务调度、机器视觉这些模块沉淀下来前后花了三个月打磨最终稳定跑进了产线。这篇文章就把这套框架从设计、落地到踩坑的完整过程剥开来说。1. 项目整体思路与框架设计1.1 先看清痛点为什么非要搞一套通用框架我早期做上位机项目习惯是“一个项目一套代码”。PLC通信写在窗体里机器人调用直接new一个类相机SDK的回调事件满天飞。头两个项目勉强能跑到第三个项目就出问题了——设备越来越多协议越来越杂现场调试时改一处逻辑牵出一串Bug。最典型的一幕是客户临时加了一台相机我复制粘贴了原来的采集类结果两路视频回调互相干扰图像数据串得乱七八糟排查了一整天才发现是回调事件没有隔离。这个经历让我意识到工业上位机开发的核心问题不是“写功能”而是“管复杂度”。设备协议千差万别但业务需求高度相似采集数据、分析数据、控制执行、记录结果。与其每次推倒重来不如把“通信”“调度”“视觉”“控制”这些能力抽象成公共模块做成一套可以复用的框架。这才有了后面这套东西。1.2 框架设计的五个核心目标在设计框架之前我先列出了几个必须满足的目标后面所有代码都是照着这几个目标来约束的目标具体含义落地方式统一设备接口PLC、相机、机器人、仪表对外暴露一致的操作方式抽象接口 驱动适配任务可编排视觉检测、运动控制、数据存储这些动作能灵活组合任务队列 状态机模块可插拔设备换型号、相机换品牌不影响业务层依赖注入 配置文件状态可监控每个设备的运行状态、每个任务的执行进度实时可见事件通知 全局状态表数据可追溯产品检测数据、报警信息、设备日志完整落库统一日志 SQLServer存储这里特别想强调“统一设备接口”这件事。很多新人写代码PLC就直接引用S7的库相机就直接new一个Camera对象短期看没问题但项目一换设备这些代码全部要改。我的做法是给每一类设备定义一个最小接口比如通信设备有Connect、Disconnect、Read、Write相机有Open、Grab、Close机器人有Move、GetPose、SetSpeed。业务层只跟接口打交道具体设备由配置驱动加载这样换设备时只需要新写一个驱动类业务代码一行不用动。1.3 整体分层五层架构框架整体分成五层从下往上依次是驱动层、设备抽象层、业务调度层、应用服务层、UI层。最底层是驱动层也就是具体设备的SDK封装比如S7.NET、相机厂商SDK、机器人TCP协议。这一层允许“脏乱差”只要把功能实现就行。上面一层是设备抽象层通过接口把驱动层的功能包装成统一调用。再往上是业务调度层这是整套框架的核心负责把“读数据—算结果—发指令”这种流程编排起来。应用服务层处理具体的业务逻辑比如视觉定位、缺陷检测、数据报表。最上面是UI层只负责展示和接收用户操作不允许直接调用驱动。这种分层最大的好处是“向后兼容”。设备驱动更新了换掉最底层业务流程变了改调度层界面想重新设计UI层随便折腾。我实测下来三层以上的项目必须分层不然后期维护成本会拖垮整个项目。特别是机器人加视觉这种多设备协同的场景不分层的话连排查问题都无从下手。2. 通信层的设计与实践2.1 设备通信的抽象建模通信层是框架的地基。工业现场常用的通信方式就那么几种TCP/IP、串口、Modbus、OPC UA偶尔还有UDP广播。我的框架里给通信设备定义了一个统一接口核心成员大概是这样的public interface IDevice : IDisposable { string DeviceName { get; set; } bool IsConnected { get; } event EventHandlerDeviceDataEventArgs DataReceived; Taskbool ConnectAsync(CancellationToken ct); Task DisconnectAsync(); Task WriteAsync(string block, object value, CancellationToken ct); Taskobject ReadAsync(string block, CancellationToken ct); }所有具体设备都实现这个接口。比如PLC驱动内部用的是S7协议机器人驱动内部走的是TCP但业务层看它们都是“一个设备”调用ConnectAsync、ReadAsync就行。这样写还有一个好处写单元测试的时候可以Mock一个假设备不用真去连硬件。2.2 西门子PLC通信S7与OPC UA两种方案项目里的PLC是西门子S7-1200我对比了两种通信方式一是直接用S7.NET开源库走S7协议二是走OPC UA。S7协议的特点是实时性好、开销小适合高频读写DB块和I/O点。OPC UA的好处是跨平台、信息模型丰富而且对网络环境更宽容适合需要和MES系统对接的场合。我最后选了S7.NET做主要数据通道因为机器人视觉定位对实时性要求很高。一个典型的读写场景是这样的PLC把“拍照触发信号”写到DB1.DBX0.0上位机读到这个信号后触发相机拍照视觉计算完成后把零件坐标写到DB1的浮点数组里再把“定位完成”标志位置为TRUE。这个流程用S7.NET实现起来很直接public class S7PlcDriver : IDevice { private Plc _plc; public async Taskbool ConnectAsync(CancellationToken ct) { _plc new Plc(CpuType.S71200, 192.168.0.1, 0, 1); _plc.Open(); return _plc.IsConnected; } public async Task WriteAsync(string block, object value, CancellationToken ct) { // 传入的block形如 DB1.DBX0.0 或 DB1.DBD4 _plc.Write(block, value); } public async Taskobject ReadAsync(string block, CancellationToken ct) { return _plc.Read(block); } }2.3 OPC UA的接入与重连处理后来客户要求把设备数据同步给车间MES系统我又加了一个OPC UA客户端模块。用的是OPCFoundation的开源库服务端是西门子S7-1500自带的OPC UA Server。OPC UA的地址空间是树形的节点用NodeId来标识。读一个变量的代码大概是这样using OPCFoundation; using Opc.Ua; using Opc.Ua.Client; public class OpcUaClient { private Session _session; private readonly string _endpointUrl opc.tcp://192.168.0.10:4840; public async Task ConnectAsync() { var config new ApplicationConfiguration(); await config.LoadApplicationConfiguration(); _session await Session.Create(config, new EndpointDescription(_endpointUrl), false); } public DataValue ReadNode(string nodeId) { var id new NodeId(nodeId); return _session.ReadValue(id); } }OPC UA踩坑最多的就是连接稳定性。现场网络偶尔抖动Session会静默断开业务层还以为是数据没更新。我后来在框架里加了一个“心跳看门狗”每隔5秒读一次ServerStatus节点连续3次失败就触发重连重连失败超过5次则把设备状态置为Error同时在UI上弹报警。这套机制上线后MES数据中断的问题基本绝迹了。3. 多任务调度的实现细节3.1 为什么不用Thread硬怼机器人视觉项目天生就是多任务的相机要不停地采集图像视觉算法要跑识别PLC要轮询IO信号机器人要执行运动指令数据库写入还得抽空做。我早期习惯用Thread创建后台线程后来发现两个硬伤一是线程数量不好控制开多了上下文切换开销大开少了任务互相排队二是线程间的通信和同步全靠手工搬砖锁写多了容易死锁写少了数据错乱。后来我换成Task Channel的模式。Task是线程池上的逻辑任务比Thread轻量得多Channel则是.NET内置的生产者消费者队列非常适合做任务流转。整个调度层我设计成“流水线”结构数据采集任务把原始数据丢进Channel处理任务从Channel里取数据做分析分析完的结果再丢给下一个Channel由控制任务去执行。3.2 基于Channel的任务调度器这里贴一段简化版的任务调度器核心代码。它负责从队列里拿任务把任务交给线程池执行同时支持取消和超时public class TaskDispatcher { private readonly ChannelFuncCancellationToken, Task _channel; private readonly CancellationTokenSource _cts new(); private readonly SemaphoreSlim _semaphore; public TaskDispatcher(int maxConcurrency 4) { _semaphore new SemaphoreSlim(maxConcurrency); _channel Channel.CreateBoundedFuncCancellationToken, Task( new BoundedChannelOptions(100) { FullMode BoundedChannelFullMode.Wait }); } public void Start() { for (int i 0; i Environment.ProcessorCount; i) { Task.Run(ProcessQueue); } } public async Task EnqueueAsync(FuncCancellationToken, Task task) { await _channel.Writer.WriteAsync(task); } private async Task ProcessQueue() { await foreach (var task in _channel.Reader.ReadAllAsync(_cts.Token)) { await _semaphore.WaitAsync(_cts.Token); try { await task(_cts.Token); } catch (Exception ex) { Logger.Error(ex); } finally { _semaphore.Release(); } } } }这个调度器我用了很长时间最大感受是“并发度可控”。SemaphoreSlim限制了同时执行的任务数量Channel的Bounded模式又天然做了背压处理——如果任务积压超过队列容量生产者会被阻塞不会出现内存暴涨。产线上偶尔会出现视觉算法卡顿的情况但因为有背压机制通信任务不会堆积PLC那边始终能维持稳定的读写周期。3.3 多任务协同的时序编排框架里最难的不是单个任务怎么写而是多个任务怎么“排队握手”。项目要求视觉检测完成后机器人才能开始抓取但PLC的数据采集不能停数据库写入也不能被运动控制阻塞。我的方案是把整个流程拆成几个状态用状态机控制跳转每台设备都是一个独立状态空闲Idle等待启动命令运行Running按顺序执行流程等待视觉WaitingVisionPLC已触发拍照等待图像结果执行运动Moving机器人正在运动异常Fault任意环节出错进入暂停或复位流程这个状态机我用一个枚举加一个抽象基类来实现。每个设备驱动都继承自基类基类内部有一个状态流转的骨架方法子类只需重写业务响应逻辑。这样有一个很实际的好处UI上可以统一展示所有设备的状态不用为每一台设备写单独的判断逻辑。多任务这块还有一个我印象很深的教训。一开始我把“等待PLC信号”直接写成一个While循环加Thread.Sleep(50)结果在UI线程里执行的时候把界面卡成了“未响应”。后来统一改成基于Channel的事件驱动再配合CancellationToken做超时控制问题才解决。记住一条准则UI线程里永远不要做同步等待所有I/O和长耗时操作都要丢给TaskDispatcher。4. 机器视觉模块的关键环节4.1 相机枚举与多路视频区分项目用了三台USB工业相机型号还完全一样。DirectShow下枚举设备时如果只按名称找三台设备返回的名字完全相同根本无法区分哪台对应哪个工位。这个问题折腾了我两天后来发现正确的做法是同时读取设备的路径或硬件ID用硬件ID做唯一标识。我用AForge.NET的FilterInfoCollection来枚举摄像头再通过注册表读取每个设备的DevicePath。代码类似这样public class CameraManager { private readonly Dictionarystring, string _cameraMap new(); public void EnumerateCameras() { var videoDevices new FilterInfoCollection(FilterCategory.VideoInputDevice); foreach (FilterInfo device in videoDevices) { string devicePath GetDevicePath(device.MonikerString); // 用设备名路径作为唯一key工位号从配置文件映射 _cameraMap[device.Name | devicePath] device.MonikerString; Console.WriteLine($发现相机: {device.Name} - {devicePath}); // 将工位号、设备名、路径写入配置文件 } } }生产环境里相机固定安装后硬件ID基本不会变化所以我把“工位号—设备名—硬件ID”的映射关系做成了配置文件。这样相机更换、重插USB后只要硬件ID一致程序就能自动识别不用改代码。4.2 图像处理流程思路视觉这块我用的OpenCvSharp这个开源库它直接封装了OpenCV的C接口用C#调用非常顺手。项目里的零件定位处理流程是采集到彩色图后先转成灰度图然后做中值滤波去噪再用阈值分割找到零件区域最后用轮廓查找和最小外接矩形求出几何中心点。核心代码就这么十几行public Point2f FindPartCenter(Mat colorImage) { using var gray new Mat(); Cv2.CvtColor(colorImage, gray, ColorConversionCodes.BGR2GRAY); using var filtered new Mat(); Cv2.MedianBlur(gray, filtered, 5); using var binary new Mat(); Cv2.Threshold(filtered, binary, 120, 255, ThresholdTypes.BinaryInv); Cv2.FindContours(binary, out Point[][] contours, out HierarchyIndex[] hierarchy, RetrievalModes.External, ContourApproximationModes.ApproxSimple); var partContour contours .OrderByDescending(c Cv2.ContourArea(c)) .FirstOrDefault(); if (partContour null || partContour.Length 5) return Point2f.Zero; var rotatedRect Cv2.MinAreaRect(partContour); return rotatedRect.Center; }这段代码看起来不难但现场调优花了我不少时间。核心问题是光照变化对阈值的干扰。我后来加了“动态阈值”的策略在画面固定区域取一块背景实时计算背景平均灰度然后以这个值作为自适应阈值的基础。这样一来白天的自然光变化、车间灯光开关都不会再导致阈值失效。4.3 坐标系转换与手眼标定视觉算出的是像素坐标机器人用的是关节坐标和笛卡尔坐标两者之间必须建立转换关系。这个过程叫手眼标定。项目是“眼在手上”相机安装在固定支架上机械手在下方工作用到的就是二维平面上的仿射变换把一个坐标系里的点映射到另一个坐标系。我的做法是做一个9点标定让机器人末端依次走到9个已知位置记录每个位置的机器人坐标同时相机拍摄这些位置提取对应的像素坐标。然后用OpenCvSharp的GetAffineTransform或者自己写最小二乘法求解变换矩阵。公式很简单public Matrixdouble Calibrate(Point2d[] robotPts, Point2f[] imagePts) { int n robotPts.Length; // 构造线性方程组: imagePts * M robotPts using var A new Mat(n, 4, MatType.CV_64F); using var B new Mat(n, 2, MatType.CV_64F); for (int i 0; i n; i) { A.Atdouble(i, 0) imagePts[i].X; A.Atdouble(i, 1) imagePts[i].Y; A.Atdouble(i, 2) 1; A.Atdouble(i, 3) 0; B.Atdouble(i, 0) robotPts[i].X; B.Atdouble(i, 1) robotPts[i].Y; } var solution Cv2.Solve(A, B, DecompTypes.SVD); return solution; }实际上这是一个超定方程组用SVD求解稳定而且精度高。标定做完以后我验证过整个视野范围内重复定位精度在0.3毫米以内对零件抓取来说足够了。提醒一句标定过程里机器人末端走的点一定要覆盖相机的整个工作区域否则照片边缘区域的坐标转换误差会非常大。5. 机器人控制模块与外部轴联动5.1 与AUBO机器人的通信方式项目里用的是AUBO的六轴机器人官方提供了C SDK和TCP网络协议两种控制方式。C#这边对接最方便的是直接通过TCP协议调用机器人服务。AUBO机器人在控制器上开放了一个Socket服务上位机通过发送JSON格式的指令来控制机器人的运动、查询状态。一条运动指令大概长这样{ command: move_l, pose: [0.321, -0.145, 0.420, 3.141, 0.0, 1.571], speed: 0.2, acc: 0.1 }我在框架里封装了一个RobotService类里面提供MoveLineAsync、MoveJointAsync、GetCurrentPoseAsync这些方法。底层统一走TCP发送请求后等待响应响应包里有成功标志和当前机器人状态。这里有一个经验机器人TCP响应有时会延迟异步等待时一定要设置超时时间不然一个指令卡住整个流水线都停滞。5.2 外部轴与六轴协调的关键点外部轴是这台AUBO机器人通过扩展模块带的一个直线行走轴。从控制器的角度看外部轴相当于第七个关节可以和六轴本体一起走插补运动。项目里机器人本体负责上下料工位之间的搬运动作外部轴负责扩大机器人的工作范围。这里最需要注意的是“坐标系统一”。外部轴移动后机器人底座的世界坐标就跟着变了工件坐标系、视觉坐标系、工具坐标系全都要重新换算。我在框架里维护了一张坐标系映射表包含机器人基座坐标、外部轴当前位置、视觉基准点坐标。每次外部轴运动到位后都会触发一次坐标偏移量的重算再下发运动指令。如果不做这一步就会出现“视觉说零件在左边机器人却往右抓”的尴尬场面。5.3 运动指令的封装与状态机运动控制模块我做了两级封装。底层是RobotDriver负责和机器人通信、数据解析上层是RobotService负责业务闭环。一个完整的抓取动作被拆成“移动到位—张爪—下降—抓取—抬升—搬运—放置”一系列子动作。每个子动作执行完都要等机器人回复实际到达位置再校验位置偏差是否在允许范围内而不是简单等指令发送成功。这个校验逻辑很重要。机器人虽然会反馈“指令接收成功”但如果是外部轴超限、关节角不可达这类问题状态反馈可能已经在报错了。框架里对每个运动都加了一个状态跟踪器状态随时写入全局状态表UI上能看到“等待运动”“运动中”“已到位”“超时”“报警”这样清晰的状态切换。现场调试时凭这个状态表就能快速定位是上位机没发指令、还是机器人没执行或者命令下了但外部轴没有动。6. 常见问题与排查实录6.1 C#调用C组件时的Access Violation项目中有一段C写的点云处理算法通过P/Invoke或C/CLI封装给C#调用。有一次运行程序总是随机报出“AccessViolationException: 尝试读取或写入受保护的内存。这通常指示其他内存已损坏”的错。排查了很久发现根因是C端返回的数据缓冲区大小在特定条件下比预期大C#端申请数组时长度不够导致越界写入。这个问题的教训是调用C非托管代码时一定要提前确认缓冲区容量、字符串编码和回调方式。能传长度参数就传长度能用安全代码就用安全代码。后来我在所有P/Invoke入口统一加了“数据长度校验”和“异常封层”出问题时会捕获到更友好的错误信息而不是直接崩进程。6.2 DirectShow多路摄像头回调串数据前面提到三台相同型号的相机之前遇到的最大问题就是回调混乱。AForge的NewFrame事件经常会出现两路摄像头图像互相串。排查后确认原因是摄像头设备名相同程序里按设备名区分路数结果两路都绑到了同一台设备上。解决办法就是前面讲的用设备路径而非设备名来区分。另外要注意回调线程上下文问题NewFrame事件触发在采集线程不能直接更新UI控件必须通过同步上下文封送或者把图像放入消息队列再由UI线程取出来显示。6.3 任务队列阻塞导致界面卡死还有一次界面突然全部“假死”按钮怎么点都没反应。查了一圈发现是任务调度器里某个视觉处理任务陷入了死循环占用了所有并发槽位后续任务全部排队UI的刷新任务也排不进去。修复方法有两步第一步是在TaskDispatcher内部给每个任务加上超时控制超过30秒强制取消第二步是UI刷新任务放到一个独立的Channel优先级高于其他业务任务即使业务任务全部阻塞界面也能保持响应。这两步改完后再也没有复现过假死。6.4 SQLBulkCopy批量入库时的诡异丢数据数据库这边遇到过很隐蔽的一个问题使用SqlBulkCopy批量写入检测结果时只要生产运行时间长了就会发现部分批次的数据没有写进去。排查后发现根因是目标表结构在运行期间被备份或维护操作重建SqlBulkCopy持有的Schema映射失效写入时静默跳过了一些列。解决方案是在每次批量写入前重新读取目标表的Schema并且给批量写入加“事务保护”和“失败重试”机制。6.5 OPC UA连接掉线不自动恢复这个在前面提过。网络偶尔波动Session就断了而且不会自动重建。后来加了看门狗心跳检测定时读取ServerStatus节点发现连续3次失败就触发重建Session。重建的时候要先把旧Session Dispose否则端口不释放新连接也建不起来。这个细节卡了我小半天最后用netstat查端口占用才发现的。下面把几个典型问题整理成一个速查表方便大家遇到类似麻烦时快速定位。现象根因解决方法程序突然报内存访问违例C端缓冲区长度不匹配统一校验长度P/Invoke入出口加保护多摄像头图像互相串设备名相同未按硬件ID区分读取设备路径做唯一标识界面卡死无响应任务队列被长任务阻塞设置任务超时UI任务独立高优先级数据库批量写入部分丢失表结构变动导致映射失效每次写入前重新读取SchemaOPC UA连接失效无数据网络抖动后Session未重建心跳检测加自动重连7. 项目之外的沉淀与思考框架上线后稳定跑了两个月最大的收获反而不是代码本身而是“项目沉淀”的价值。以前做设备调试花一个星期解决的问题换一个项目又要重来一遍。现在有了这套框架新项目从搭建到跑通核心流程只需要两三天。后续我还把设备通信、视觉处理、调度器这些模块整理成了内部的通用组件包团队其他同事接手项目时不用再去理清每个设备协议的细节直接面向接口编程。如果让我总结这套框架最有价值的设计决策我会选“任务调度和状态管理”这部分。机器人、多任务、机器视觉三者融合的项目难点其实不在于单点功能而在于并发协同。谁先谁后、谁等谁、谁超时了该怎么处理这些问题才是真正决定项目能否稳定跑起来的关键。把状态机、超时控制、优先级队列这些基础设施做好后期调试会轻松十倍。这只是一个起点。框架里还有很多可以优化的地方比如把视觉标定做成全自动流程把任务调度扩展成可视化编辑的流程引擎把报警管理接上微信通知。工业上位机这个领域永远不缺少新的设备协议和新的业务需求但好的框架能让你在面对这些变化的时候心里不慌。最后分享一个小技巧吧如果你也打算做类似的通用框架不要一上来就想着“大而全”先把当前项目里最容易出问题的那一两个模块抽象出来做成通用接口跑完一个项目再迭代一次。代码不是一次写完美的框架也一样边用边磨才是常态。