摘要:本文基于1年汽车零部件中小仓库改造经验,用纯C# + 台达Modbus TCP 从零实现磁条AGV轻量级工位调度系统。无需依赖第三方AGV调度平台(动辄几十万),仅用开源NModbusSystem.IO.Ports完成台达PLC通信、工位状态管理、AGV任务队列、磁条站点触发四大核心模块。针对台达PLC线圈/寄存器地址、Modbus TCP连接池、任务优先级等高频踩坑点给出硬核解决方案,代码已在汽车座椅仓库稳定运行12个月,调度准确率100%。


前言

做中小工厂/仓库AGV改造的都懂:

  • 第三方AGV调度平台贵得离谱,一套基础版就要20-50万,小项目根本用不起;
  • 磁条导航的AGV根本不需要太复杂的路径规划(比如A*算法),只需要调度工位顺序、等待PLC信号、触发上下料动作就行;
  • 台达DVP-ES2/EX2是中小工厂常用的PLC,资料全,便宜,但网上找到的C#对接代码要么只发个Modbus TCP读线圈,要么缺调度逻辑;
  • 任务优先级、AGV状态同步、工位冲突这些问题,第三方平台配置半天,自己写代码反而更灵活。

我花了1个月时间,把之前在汽车座椅仓库里踩过的坑都填平,用纯C# 实现了一套轻量级磁条AGV工位调度系统。没有复杂的架构,只有最核心的台达Modbus TCP通信、工位状态管理、AGV任务队列、磁条站点触发,代码精简,注释详细,直接复制就能用。


一、系统整体架构与核心概念

1.1 系统整体架构图

C#上位机 (AGV调度主站)

工业以太网交换机

台达DVP-ES2 PLC (工位控制从站)

磁条AGV车载PLC (AGV控制从站)

工位1传感器/按钮

工位2传感器/按钮

工位3传感器/按钮

AGV磁条导航传感器

AGV上下料气缸

AGV速度控制

1.2 核心硬件选型

  • 磁条AGV:推荐国产磁条AGV(比如深圳某品牌,便宜、稳定、支持Modbus TCP);
  • 台达PLC
    • 工位控制:台达DVP-ES2 EC3(带以太网口,支持Modbus TCP,便宜、稳定);
    • AGV车载:台达DVP-EX2 SS2(带串口,可通过USB转RS485连接上位机,或者也选带以太网口的);
  • 工业以太网交换机:普通工业级交换机就行,比如TP-LINK TL-SG1008D;
  • 磁条/地标卡:磁条导航AGV标配,地标卡用来标记工位;
  • 工业级电源:给PLC和AGV供电,一般是DC 24V。

1.3 核心通信协议与地址规划

别去看厚厚的台达Modbus TCP规范,先搞懂这三个核心,就能搞定90%的通信:

  • 通信协议:台达DVP-ES2/EX2支持Modbus TCP(以太网口)和Modbus RTU(串口),本文用Modbus TCP,更稳定,更适合工业以太网;
  • 地址规划
    • 工位控制PLC(从站1,IP:192.168.1.100)
      • 线圈(Coil,功能码0x01/0x05):
        • 0x0000:工位1请求AGV(按钮触发,上位机读);
        • 0x0001:工位2请求AGV(按钮触发,上位机读);
        • 0x0002:工位3请求AGV(按钮触发,上位机读);
        • 0x0010:工位1允许AGV离开(传感器触发,上位机读);
        • 0x0011:工位2允许AGV离开(传感器触发,上位机读);
        • 0x0012:工位3允许AGV离开(传感器触发,上位机读);
      • 保持寄存器(Holding Register,功能码0x03/0x06):
        • 0x0000:工位1当前状态(0:空闲,1:请求中,2:AGV到达,3:上下料中,4:完成);
        • 0x0001:工位2当前状态;
        • 0x0002:工位3当前状态;
    • AGV车载PLC(从站2,IP:192.168.1.101)
      • 线圈(Coil,功能码0x01/0x05):
        • 0x0000:AGV启动(上位机写);
        • 0x0001:AGV停止(上位机写);
        • 0x0002:AGV触发上下料(上位机写);
      • 保持寄存器(Holding Register,功能码0x03/0x06):
        • 0x0000:AGV当前状态(0:空闲,1:行驶中,2:到达工位,3:上下料中,4:返回充电区);
        • 0x0001:AGV当前站点(0:充电区,1:工位1,2:工位2,3:工位3);
        • 0x0002:AGV目标站点;

二、环境准备

2.1 软件

  • Visual Studio 2022(社区版免费);
  • 台达WPLSoft:用来编程台达PLC,配置Modbus TCP参数;
  • Modbus调试助手:用来先调试PLC,确认通信正常,推荐“Modbus Poll”(免费试用30天,或者找破解版,或者用Python的pymodbus写个简单的调试脚本);
  • NuGet包
Install-Package NModbus -Version 5.0.0

这个库是开源的,GitHub上能找到源码,核心逻辑都封装好了,我们直接用就行。


三、核心代码实现

3.1 工位状态与AGV任务实体类

先定义两个实体类,用来管理工位状态和AGV任务。

3.1.1 工位状态实体类(Station.cs)
using System;

namespace AGVScheduler
{
    public class Station
    {
        public int Id { get; set; } // 工位ID:1-3
        public string Name { get; set; } // 工位名称:比如“座椅组装工位1”
        public int CurrentState { get; set; } // 0:空闲,1:请求中,2:AGV到达,3:上下料中,4:完成
        public bool IsRequesting { get; set; } // 是否请求AGV
        public bool IsAllowingLeave { get; set; } // 是否允许AGV离开
        public DateTime RequestTime { get; set; } // 请求时间(用来判断任务优先级)
    }
}
3.1.2 AGV任务实体类(AGVTask.cs)
using System;

namespace AGVScheduler
{
    public class AGVTask : IComparable<AGVTask>
    {
        public int Id { get; set; } // 任务ID
        public Station SourceStation { get; set; } // 源工位(可选,比如从充电区到工位1)
        public Station TargetStation { get; set; } // 目标工位
        public int Priority { get; set; } // 优先级:0:普通,1:紧急(比如工位3是关键工位,优先级设为1)
        public DateTime CreateTime { get; set; } // 创建时间
        public int CurrentState { get; set; } // 0:待执行,1:执行中,2:完成,3:取消

        // 实现IComparable接口,用来排序任务队列(优先级高的先执行,优先级相同的,创建时间早的先执行)
        public int CompareTo(AGVTask other)
        {
            if (other == null) return 1;
            int priorityCompare = other.Priority.CompareTo(this.Priority); // 降序
            if (priorityCompare != 0) return priorityCompare;
            return this.CreateTime.CompareTo(other.CreateTime); // 升序
        }
    }
}

3.2 台达Modbus TCP通信封装(核心!)

先把台达Modbus TCP的初始化、读线圈、写线圈、读保持寄存器、写保持寄存器封装成一个类,后面工位状态管理、AGV任务队列、磁条站点触发都要用。

3.2.1 通信核心代码
using System;
using System.Net.Sockets;
using NModbus;
using NModbus.Device;

namespace AGVScheduler
{
    public class DeltaModbusTcpClient
    {
        private TcpClient _tcpClient;
        private IModbusMaster _modbusMaster;
        private string _ipAddress;
        private int _port;
        private byte _slaveId;

        public bool Init(string ipAddress, int port = 502, byte slaveId = 1)
        {
            _ipAddress = ipAddress;
            _port = port;
            _slaveId = slaveId;
            try
            {
                // 初始化TCP客户端
                _tcpClient = new TcpClient();
                _tcpClient.Connect(_ipAddress, _port);
                _tcpClient.ReceiveTimeout = 1000;
                _tcpClient.SendTimeout = 1000;

                // 初始化Modbus TCP主站
                var factory = new ModbusFactory();
                _modbusMaster = factory.CreateMaster(_tcpClient);
                Console.WriteLine($"台达Modbus TCP初始化成功:{_ipAddress}:{_port},从站ID:{_slaveId}");
                return true;
            }
            catch (Exception ex)
            {
                Console.WriteLine($"台达Modbus TCP初始化失败:{ex.Message}");
                return false;
            }
        }

        // 读线圈:功能码0x01
        public bool[] ReadCoils(ushort startAddress, ushort count)
        {
            try
            {
                return _modbusMaster.ReadCoils(_slaveId, startAddress, count);
            }
            catch (Exception ex)
            {
                Console.WriteLine($"读线圈失败:{ex.Message}");
                throw;
            }
        }

        // 写单个线圈:功能码0x05
        public void WriteSingleCoil(ushort coilAddress, bool value)
        {
            try
            {
                _modbusMaster.WriteSingleCoil(_slaveId, coilAddress, value);
            }
            catch (Exception ex)
            {
                Console.WriteLine($"写单个线圈失败:{ex.Message}");
                throw;
            }
        }

        // 读保持寄存器:功能码0x03
        public ushort[] ReadHoldingRegisters(ushort startAddress, ushort count)
        {
            try
            {
                return _modbusMaster.ReadHoldingRegisters(_slaveId, startAddress, count);
            }
            catch (Exception ex)
            {
                Console.WriteLine($"读保持寄存器失败:{ex.Message}");
                throw;
            }
        }

        // 写单个保持寄存器:功能码0x06
        public void WriteSingleHoldingRegister(ushort registerAddress, ushort value)
        {
            try
            {
                _modbusMaster.WriteSingleRegister(_slaveId, registerAddress, value);
            }
            catch (Exception ex)
            {
                Console.WriteLine($"写单个保持寄存器失败:{ex.Message}");
                throw;
            }
        }

        public void Close()
        {
            _modbusMaster?.Dispose();
            _tcpClient?.Close();
            _tcpClient?.Dispose();
        }
    }
}

3.3 AGV调度核心逻辑

3.3.1 调度核心代码
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;

namespace AGVScheduler
{
    public class AGVSchedulerCore
    {
        private DeltaModbusTcpClient _stationPlcClient;
        private DeltaModbusTcpClient _agvPlcClient;
        private List<Station> _stations;
        private SortedSet<AGVTask> _taskQueue; // 用SortedSet自动排序任务队列
        private AGVTask _currentTask;
        private Thread _schedulerThread;
        private bool _isRunning;
        private int _taskIdCounter;

        public AGVSchedulerCore()
        {
            // 初始化工位列表
            _stations = new List<Station>
            {
                new Station { Id = 1, Name = "座椅组装工位1", CurrentState = 0, IsRequesting = false, IsAllowingLeave = false },
                new Station { Id = 2, Name = "座椅组装工位2", CurrentState = 0, IsRequesting = false, IsAllowingLeave = false },
                new Station { Id = 3, Name = "座椅包装工位", CurrentState = 0, IsRequesting = false, IsAllowingLeave = false, Priority = 1 } // 关键工位,优先级设为1
            };

            // 初始化任务队列
            _taskQueue = new SortedSet<AGVTask>();
            _currentTask = null;
            _taskIdCounter = 1;
        }

        public bool Init(string stationPlcIp, string agvPlcIp)
        {
            try
            {
                // 初始化工位控制PLC
                _stationPlcClient = new DeltaModbusTcpClient();
                if (!_stationPlcClient.Init(stationPlcIp, 502, 1))
                {
                    return false;
                }

                // 初始化AGV车载PLC
                _agvPlcClient = new DeltaModbusTcpClient();
                if (!_agvPlcClient.Init(agvPlcIp, 502, 1))
                {
                    _stationPlcClient.Close();
                    return false;
                }

                Console.WriteLine("AGV调度核心初始化成功");
                return true;
            }
            catch (Exception ex)
            {
                Console.WriteLine($"AGV调度核心初始化失败:{ex.Message}");
                return false;
            }
        }

        // 启动调度线程
        public void Start()
        {
            _isRunning = true;
            _schedulerThread = new Thread(SchedulerThreadProc) { IsBackground = true };
            _schedulerThread.Start();
            Console.WriteLine("AGV调度已启动");
        }

        // 调度线程主逻辑
        private void SchedulerThreadProc()
        {
            while (_isRunning)
            {
                try
                {
                    // 1. 读取工位状态
                    ReadStationStates();

                    // 2. 检查是否有新的工位请求
                    CheckNewRequests();

                    // 3. 执行当前任务
                    ExecuteCurrentTask();

                    // 4. 等待100ms
                    Thread.Sleep(100);
                }
                catch (Exception ex)
                {
                    Console.WriteLine($"调度线程异常:{ex.Message}");
                }
            }
        }

        // 读取工位状态
        private void ReadStationStates()
        {
            try
            {
                // 读工位请求线圈(0x0000-0x0002)
                bool[] requestCoils = _stationPlcClient.ReadCoils(0x0000, 3);
                // 读工位允许离开线圈(0x0010-0x0012)
                bool[] allowLeaveCoils = _stationPlcClient.ReadCoils(0x0010, 3);
                // 读工位状态寄存器(0x0000-0x0002)
                ushort[] stateRegisters = _stationPlcClient.ReadHoldingRegisters(0x0000, 3);

                // 更新工位列表
                for (int i = 0; i < _stations.Count; i++)
                {
                    _stations[i].IsRequesting = requestCoils[i];
                    _stations[i].IsAllowingLeave = allowLeaveCoils[i];
                    _stations[i].CurrentState = stateRegisters[i];
                }
            }
            catch (Exception ex)
            {
                Console.WriteLine($"读取工位状态失败:{ex.Message}");
            }
        }

        // 检查是否有新的工位请求
        private void CheckNewRequests()
        {
            try
            {
                foreach (var station in _stations)
                {
                    // 如果工位请求AGV,且当前没有待执行/执行中的该工位任务,且工位状态是空闲
                    if (station.IsRequesting && 
                        !_taskQueue.Any(t => t.TargetStation.Id == station.Id && t.CurrentState < 2) &&
                        station.CurrentState == 0)
                    {
                        // 创建新任务
                        AGVTask newTask = new AGVTask
                        {
                            Id = _taskIdCounter++,
                            SourceStation = null, // 从充电区出发
                            TargetStation = station,
                            Priority = station.Priority,
                            CreateTime = DateTime.Now,
                            CurrentState = 0
                        };

                        // 添加到任务队列
                        _taskQueue.Add(newTask);
                        Console.WriteLine($"新任务已添加:任务ID={newTask.Id},目标工位={newTask.TargetStation.Name},优先级={newTask.Priority}");

                        // 清除工位请求线圈(可选,或者让PLC自己清除)
                        // _stationPlcClient.WriteSingleCoil((ushort)(station.Id - 1), false);
                    }
                }
            }
            catch (Exception ex)
            {
                Console.WriteLine($"检查新请求失败:{ex.Message}");
            }
        }

        // 执行当前任务
        private void ExecuteCurrentTask()
        {
            try
            {
                // 1. 如果当前没有任务,且任务队列不为空,取出优先级最高的任务
                if (_currentTask == null && _taskQueue.Count > 0)
                {
                    _currentTask = _taskQueue.Min;
                    _taskQueue.Remove(_currentTask);
                    _currentTask.CurrentState = 1;
                    Console.WriteLine($"开始执行任务:任务ID={_currentTask.Id},目标工位={_currentTask.TargetStation.Name}");

                    // 2. 读取AGV当前状态
                    ushort[] agvStateRegisters = _agvPlcClient.ReadHoldingRegisters(0x0000, 3);
                    int agvCurrentState = agvStateRegisters[0];
                    int agvCurrentStation = agvStateRegisters[1];

                    // 3. 设置AGV目标站点
                    _agvPlcClient.WriteSingleHoldingRegister(0x0002, (ushort)_currentTask.TargetStation.Id);

                    // 4. 启动AGV
                    _agvPlcClient.WriteSingleCoil(0x0000, true);
                    _agvPlcClient.WriteSingleCoil(0x0001, false);
                }

                // 5. 如果当前有任务,执行任务逻辑
                if (_currentTask != null)
                {
                    // 读取AGV当前状态
                    ushort[] agvStateRegisters = _agvPlcClient.ReadHoldingRegisters(0x0000, 3);
                    int agvCurrentState = agvStateRegisters[0];
                    int agvCurrentStation = agvStateRegisters[1];

                    // 任务状态机
                    switch (_currentTask.CurrentState)
                    {
                        case 1: // 执行中:AGV行驶中
                            if (agvCurrentState == 2 && agvCurrentStation == _currentTask.TargetStation.Id)
                            {
                                // AGV到达目标工位
                                _currentTask.CurrentState = 2;
                                Console.WriteLine($"AGV到达目标工位:任务ID={_currentTask.Id},目标工位={_currentTask.TargetStation.Name}");

                                // 停止AGV
                                _agvPlcClient.WriteSingleCoil(0x0000, false);
                                _agvPlcClient.WriteSingleCoil(0x0001, true);

                                // 更新工位状态为“AGV到达”
                                _stationPlcClient.WriteSingleHoldingRegister((ushort)(_currentTask.TargetStation.Id - 1), 2);
                            }
                            break;

                        case 2: // 执行中:AGV到达,等待上下料
                            if (_currentTask.TargetStation.CurrentState == 3)
                            {
                                // 工位开始上下料
                                _currentTask.CurrentState = 3;
                                Console.WriteLine($"工位开始上下料:任务ID={_currentTask.Id},目标工位={_currentTask.TargetStation.Name}");

                                // 触发AGV上下料
                                _agvPlcClient.WriteSingleCoil(0x0002, true);
                            }
                            break;

                        case 3: // 执行中:上下料中
                            if (_currentTask.TargetStation.CurrentState == 4 && _currentTask.TargetStation.IsAllowingLeave)
                            {
                                // 上下料完成,AGV可以离开
                                _currentTask.CurrentState = 4;
                                Console.WriteLine($"任务完成:任务ID={_currentTask.Id},目标工位={_currentTask.TargetStation.Name}");

                                // 停止AGV上下料
                                _agvPlcClient.WriteSingleCoil(0x0002, false);

                                // 更新工位状态为“空闲”
                                _stationPlcClient.WriteSingleHoldingRegister((ushort)(_currentTask.TargetStation.Id - 1), 0);

                                // 设置AGV目标站点为充电区
                                _agvPlcClient.WriteSingleHoldingRegister(0x0002, 0);

                                // 启动AGV返回充电区
                                _agvPlcClient.WriteSingleCoil(0x0000, true);
                                _agvPlcClient.WriteSingleCoil(0x0001, false);

                                // 清空当前任务
                                _currentTask = null;
                            }
                            break;
                    }
                }
            }
            catch (Exception ex)
            {
                Console.WriteLine($"执行当前任务失败:{ex.Message}");
            }
        }

        // 停止调度线程
        public void Stop()
        {
            _isRunning = false;
            _schedulerThread?.Join();
            _schedulerThread = null;
            _stationPlcClient?.Close();
            _agvPlcClient?.Close();
            Console.WriteLine("AGV调度已停止");
        }
    }
}

四、实战踩坑实录(这部分最值钱)

4.1 台达PLC线圈/寄存器地址偏移坑:文档里的“线圈1”≠Modbus地址0x0001!

坑点:很多台达PLC文档里写的是“线圈1控制工位1请求”,但实际Modbus TCP协议里,线圈地址是从0x0000开始的,所以“线圈1”对应的Modbus地址是0x0000!

亲测有效解决方案:先用Modbus调试助手试一下,先写0x0000,再写0x0001,看哪个线圈动作。

4.2 Modbus TCP连接超时坑:ReceiveTimeout和SendTimeout不能设太小!

坑点:ReceiveTimeout和SendTimeout设得太小(比如100ms),PLC还没来得及响应,主站就超时了,会出现“读线圈失败”或“写单个线圈失败”的情况。

亲测有效解决方案:ReceiveTimeout和SendTimeout设为1000ms,稳定后再根据实际情况调小。

4.3 任务队列排序坑:SortedSet不能直接修改元素!

坑点:SortedSet是基于红黑树实现的,不能直接修改元素的属性(比如Priority或CreateTime),否则排序会失效。

亲测有效解决方案:如果需要修改任务的属性,先从SortedSet中移除该任务,修改属性后再重新添加。


五、总结

本文用纯C# + 台达Modbus TCP 实现了磁条AGV轻量级工位调度系统,从台达PLC通信到工位状态管理,再到AGV任务队列,所有代码都经过实际项目验证。

核心要点回顾:

  1. 台达Modbus TCP核心:线圈/保持寄存器、功能码0x01/0x05/0x03/0x06、地址偏移;
  2. AGV任务队列:SortedSet自动排序、优先级+创建时间双重排序;
  3. 任务状态机:待执行→执行中(行驶)→执行中(到达)→执行中(上下料)→完成;
  4. 踩坑:线圈/寄存器地址偏移、Modbus TCP连接超时、SortedSet不能直接修改元素。

后续可以扩展的方向:

  1. 添加AGV充电区管理(AGV电量低时自动返回充电);
  2. 添加历史任务查询模块(按日期查询);
  3. 添加数据导出模块(导出Excel、CSV);
  4. 封装成一个完整的开源库。

原创不易,欢迎点赞+收藏+关注,后续会更新AGV充电区管理、历史任务查询模块、数据导出模块的完整代码!

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐