『7x24小时有问必答』
     作为电气工程师,我们熟悉 PLC 编程、设备接线和现场调试,但面对 “上位机” 常常觉得陌生 —— 其实 C# 上位机没那么难,它本质是 “连接现场设备与操作人员的桥梁”,比如做个界面监控 PLC 的脉冲数、控制电机启停,甚至生成生产报表。这篇内容会避开复杂的编程理论,从电气工程师的视角出发,用 “PLC 通信”“设备控制” 等熟悉场景当切入点,教你快速上手,本文内的代码是实测一步步能实现的源代码。
一、先搞懂:C# 上位机能帮我们做什么?
    不用先纠结 “面向对象”“继承多态” 这些概念,先看看实际工作中能用到的功能,你会发现需求很熟悉:
PLC 数据读写:比如读取三菱 FX3U 的 D 寄存器数据(定位脉冲数、温度值),往 PLC 写参数(设定转速、目标坐标),替代 HMI 的部分功能;
设备状态监控:用指示灯、进度条显示电机运行状态(正转 / 反转 / 停止)、传感器检测结果(有无工件),比看 PLC 指示灯更直观;
数据记录与报表:自动记录每次设备运行的参数(如每次定位的脉冲数、运行时间),生成 Excel 报表,不用手动抄数据;
远程控制:通过以太网给 PLC 发指令,实现电机启停、生产线工序切换,不用跑到现场按按钮。
举个实际例子:之前在车间做步进电机定位项目,用 C# 做了个简单上位机 —— 界面上能输入目标脉冲数(比如 K10000),点击 “启动” 就把参数发给 PLC,同时实时显示当前脉冲数(读 D 寄存器),定位完成后自动记录这次运行的时间,还能保存到本地操作日志,比用普通 HMI 灵活多了。
二、入门准备:3 个核心工具,简单易上手
不用装复杂的环境,电气工程师常用的工具就能衔接:
编程软件:Visual Studio(简称 VS),推荐 2022 社区版(免费),官网直接下载,安装时选 “桌面应用开发” 模块(专门做 Windows 界面程序),不用选其他多余组件;
PLC软件:PLC 编程软件(如 GX Works2),用来确认数据能正常收发。
举个安装 VS 的小技巧:安装时勾选 “.NET Framework 4.5”(兼容性好,大部分工业软件都用这个框架),其他组件暂时不选,避免占太多硬盘空间,后续需要再补装。
三、实战第一步:用 C# 读 / 写三菱 PLC 数据(最常用场景)
电气工程师学上位机,先从 “和 PLC 通信” 入手最容易,因为 PLC 是我们熟悉的设备,数据读写逻辑和 PLC 编程思路相通。这里以三菱 FX3U 为例,讲具体步骤:
1. 先搭好硬件与通信环境
硬件连接:PLC 加上串口模块(如 FX3U-232-BD),直接与电脑使用USB转232串口线连接;
PLC 程序准备:确保 PLC 里没有禁止上位机读写的程序(比如没锁 D 寄存器),比如要读 D0 的脉冲数、写 D1 的设定值,PLC 里不用额外写通信程序,C# 上位机直接通过协议访问。
PLC通讯参数:如下图设置,9600,N,8,1。专用协议通讯,普通RS-232C,勾选和校验,传送控制步骤选格式4有CR+LF。
注意:下载程序到PLC勾选PLC参数,下载后重新上电生效!!!
2. 打开 VS,新建第一个项目
打开 VS,选 “创建新项目”,搜索 “Windows 窗体应用(.NET Framework)”,给项目起个名字(比如 “PLC_Control”),保存路径选简单的(比如 E盘根目录);
点击创建
新建后会看到一个空白的 “Form1”(就是上位机的界面),左边导航栏找到 “工具箱”,里面有按钮、文本框、指示灯(PictureBox)等控件,拖过来就能用。
Label控件:3个Label。在它的属性里面修改Text为“三菱PLC通讯上位机软件”、“D0读取值”、“D0写入值”。
Button控件:3个Button。在它的属性里面修改Text为“读取”-(Name)为Button2、“写入”-(Name)为Button3、“连接”-(Name)为Button1
TextBox控件:2个TextBox。读取后面的-(Name)为TextBox1,写入的为-(Name)为TextBox2。
这里的Name为它们的ID,靠这个ID控制它们的功能。

3. 添加 PLC 通信代码
右键项目名称-->添加-->类,给类起一个名称"FXPLC",确定后进入下面界面

using System;
using System.Collections.Generic;
using System.IO.Ports;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace PLC_Control
{
    internal class FXPLC
    {

        private SerialPort _serialPort;
        private bool _isConnected = false;
        private const string D0_ADDRESS = "D0000"// D0寄存器固定地址

        /// 
        /// 初始化通讯对象
        /// 
        /// 串口号(如"COM3")
        /// 波特率(默认9600)
        /// PLC站号(默认0)
        public FXPLC(string portName, int baudRate = 9600byte stationNumber = 0)
        {
            _serialPort = new SerialPort
            {
                PortName = portName,
                BaudRate = baudRate,
                DataBits = 8,
                StopBits = StopBits.One,
                Parity = Parity.None,
                Encoding = Encoding.ASCII,
                ReadTimeout = 1000,
                WriteTimeout = 1000
            };

            StationNumber = stationNumber;
        }

        /// 
        /// PLC站号
        /// 
        public byte StationNumber { getset; }

        /// 
        /// 连接PLC
        /// 
        /// 是否连接成功
        public bool Connect()
        {
            try
            {
                if (!_serialPort.IsOpen)
                {
                    _serialPort.Open();
                    _isConnected = true;
                    return true;
                }
                return false;
            }
            catch
            {
                return false;
            }
        }

        /// 
        /// 断开连接
        /// 
        public void Disconnect()
        {
            if (_serialPort.IsOpen)
            {
                _serialPort.Close();
            }
            _isConnected = false;
        }

        /// 
        /// 读取D0寄存器的值(16位)
        /// 
        /// 返回的16进制字符串(4位)
        /// 返回的十进制数值
        /// 是否读取成功
        public bool ReadD0(out string hexValue, out int decValue)
        {
            hexValue = "";
            decValue = 0;

            if (!_isConnected)
                return false;

            try
            {
                // 构建RR指令帧(读取D0)
                byte[] requestFrame = BuildReadFrame();
                if (requestFrame == null)
                    return false;

                // 发送并接收响应
                byte[] response = SendAndReceive(requestFrame);
                if (response == null)
                    return false;

                // 解析响应
                if (ParseReadResponse(response, out hexValue))
                {
                    // 转换为十进制
                    decValue = int.Parse(hexValue, System.Globalization.NumberStyles.HexNumber);
                    return true;
                }
                return false;
            }
            catch
            {
                return false;
            }
        }

        /// 
        /// 写入值到D0寄存器
        /// 
        /// 16进制字符串(4位,如"1234")
        /// 是否写入成功
        public bool WriteD0(string hexValue)
        {
            if (!_isConnected)
                return false;

            // 验证输入格式
            if (string.IsNullOrEmpty(hexValue) || hexValue.Length != 4 || !IsHexString(hexValue))
                return false;

            try
            {
                // 构建WR指令帧(写入D0)
                byte[] requestFrame = BuildWriteFrame(hexValue.ToUpper());
                if (requestFrame == null)
                    return false;

                // 发送并接收响应
                byte[] response = SendAndReceive(requestFrame);
                if (response == null)
                    return false;

                // 解析响应
                return ParseWriteResponse(response);
            }
            catch
            {
                return false;
            }
        }

        /// 
        /// 构建读取D0的请求帧(RR指令)
        /// 
        private byte[] BuildReadFrame()
        {
            try
            {
                // 帧结构:ENQ(0x05) + 站号(ASCII) + "0" + "WR" + "0" + "D0000" + "01" + 校验和 + CR+LF
                StringBuilder frame = new StringBuilder();
                frame.Append((char)0x05);                              // ENQ
                frame.Append((char)0x30);
                frame.Append((char)(StationNumber + 0x30));             // 站号(ASCII)
                frame.Append("FF");                                      // PC号
                frame.Append("WR");                                     // 指令码(WR=读取)
                frame.Append("0");                                      // 报文等待
                frame.Append(D0_ADDRESS);                               // D0地址
                frame.Append("01");                                     // 读取数量(1个)
                frame.Append(CalculateChecksum(frame.ToString().Substring(1)));      // 校验和
                frame.Append("\r\n");                                   // 帧尾

                return Encoding.ASCII.GetBytes(frame.ToString());
            }
            catch
            {
                return null;
            }
        }

        /// 
        /// 构建写入D0的请求帧(WW指令)
        /// 
        private byte[] BuildWriteFrame(string hexValue)
        {
            try
            {
                // 帧结构:ENQ(0x05) + 站号(ASCII) + "0" + "WW" + "0" + "D0000" + "01" + 数据 + 校验和 + CR+LF
                StringBuilder frame = new StringBuilder();
                frame.Append((char)0x05);                              // ENQ
                frame.Append((char)0x30);
                frame.Append((char)(StationNumber + 0x30));             // 站号(ASCII)
                frame.Append("FF");                                      // PC号
                frame.Append("WW");                                     // 指令码(WW=写入)
                frame.Append("0");                                      // 报文等待
                frame.Append(D0_ADDRESS);                               // D0地址
                frame.Append("01");                                     // 写入数量(1个)
                frame.Append(hexValue);                                 // 写入的数据
                frame.Append(CalculateChecksum(frame.ToString().Substring(1)));      // 校验和
                frame.Append("\r\n");                                   // 帧尾

                return Encoding.ASCII.GetBytes(frame.ToString());
            }
            catch
            {
                return null;
            }
        }

        /// 
        /// 发送数据并接收响应
        /// 
        private byte[] SendAndReceive(byte[] data)
        {
            try
            {
                _serialPort.DiscardInBuffer();
                _serialPort.DiscardOutBuffer();

                // 发送数据
                _serialPort.Write(data, 0, data.Length);

                // 读取完整响应(替换原有的 Read 逻辑)
                byte[] response = ReadCompleteResponse();

                if (response == null || response.Length == 0)
                {
                    Console.WriteLine("未收到响应或响应超时");
                    return null;
                }

                return response;
            }
            catch
            {
                return null;
            }
        }

        private byte[] ReadCompleteResponse()
        {
            List<byte> responseBuffer = new List<byte>();
            DateTime startTime = DateTime.Now;
            byte[] tempBuffer = new byte[128];

            try
            {
                // 循环读取,直到收到完整帧或超时(3秒)
                while ((DateTime.Now - startTime).TotalMilliseconds < 3000)
                {
                    // 检查是否有数据可读取
                    if (_serialPort.BytesToRead > 0)
                    {
                        int bytesRead = _serialPort.Read(tempBuffer, 0, tempBuffer.Length);
                        responseBuffer.AddRange(tempBuffer.Take(bytesRead).ToArray());

                        // 检查是否包含帧尾 0D 0A(CR+LF),判断帧是否完整
                        if (responseBuffer.Count >= 2 &&
                            responseBuffer[responseBuffer.Count - 2] == 0x0D &&
                            responseBuffer[responseBuffer.Count - 1] == 0x0A)
                        {
                            break// 已获取完整帧,退出循环
                        }
                    }
                    else
                    {
                        // 无数据时短暂休眠,避免CPU占用过高
                        System.Threading.Thread.Sleep(10);
                    }
                }

                return responseBuffer.ToArray();
            }
            catch (Exception ex)
            {
                Console.WriteLine($"读取响应失败:{ex.Message}");
                return null;
            }
        }

        /// 
        /// 解析读取响应
        /// 
        private bool ParseReadResponse(byte[] response, out string hexValue)
        {
            hexValue = "";

            // 有效响应至少包含:ACK(1) + 站号(1) + PC号(1) + 数据长度(2) + 数据(4) + 校验(2) + CR(1) + LF(1)
            if (response.Length < 12)
                return false;

            // 检查响应头(ACK)
            if (response[0] != 0x02)
                return false;

            // 提取数据(4位十六进制)
            hexValue = Encoding.ASCII.GetString(response, 54);
            return true;
        }

        /// 
        /// 解析写入响应
        /// 
        private bool ParseWriteResponse(byte[] response)
        {
            // 有效响应至少包含:ACK(1) + 站号(1) + PC号(1) + CR(1) + LF(1)
            if (response.Length < 5)
                return false;

            // 检查响应头(ACK)
            if (response[0] == 0x06)
                return true;

            return false;
        }

        /// 
        /// 计算校验和
        /// 
        private string CalculateChecksum(string data)
        {
            int sum = 0;
            foreach (char c in data)
            {
                sum += (int)c;
            }
            // 取低8位,转换为2位十六进制
            return (sum & 0xFF).ToString("X2");
        }

        /// 
        /// 验证是否为十六进制字符串
        /// 
        private bool IsHexString(string hex)
        {
            foreach (char c in hex)
            {
                if (!((c >= '0' && c <= '9') ||
                     (c >= 'A' && c <= 'F') ||
                     (c >= 'a' && c <= 'f')))
                {
                    return false;
                }
            }
            return true;
        }

        /// 
        /// 释放资源
        /// 
        public void Dispose()
        {
            Disconnect();
            _serialPort?.Dispose();
        }
    }
}

双击“连接”按钮,进入它的响应事件button1_Click,在Form1.cs里面,就是按了这个按钮它会执行哪个程序。
  private void button1_Click(object sender, EventArgs e)
 {
     fxPLC = new FXPLC("COM16"); //COM口在设备管理器里面查看
     if (fxPLC.Connect())
     {
         MessageBox.Show("PLC连接成功");
     }
     else
     {
         MessageBox.Show("PLC连接失败");
     }
 }
4. 写代码:读 PLC 的 D0 数据
比如我们要读 D0 里的数值,然后显示在界面的文本框(TextBox1)里,双击读取按钮进入它的响应事件button2_Click,就可以编写它的代码了。
  private void button2_Click(object sender, EventArgs e)
 {
     // 读取D0
     if (fxPLC.ReadD0(out string hexVal, out int decVal))
     {
         textBox1.Text = decVal.ToString();
         MessageBox.Show($"读取D0成功:十六进制={hexVal},十进制={decVal}");   
     }
     else
     {
         MessageBox.Show("读取D0失败");
     }
 }
5. 写代码:往 PLC 的 D0 写设定值
比如在界面的文本框(TextBox2)里输入1234,双击写入按钮进入它的响应事件button3_Click,同上面读取一个道理。
private void button3_Click(object sender, EventArgs e)
{
    string strValue = textBox2.Text;
    // 写入D0
    if (fxPLC.WriteD0(strValue))
    {
        MessageBox.Show($"写入D0成功:十六进制" + strValue);
    }
    else
    {
        MessageBox.Show("写入D0失败");
    }

6. 测试:看数据是否通
先在 GX Works2 里给 D0 设个值(比如 H234,这里为了区分,只设置了16进制),运行上位机,点击 “读 D0” 按钮,文本框里能显示 4660(这里显示10进制),说明通信成功;再用 GX Works2 监控 D0,能看到 D0变成H1234,,说明读写都没问题了。

四、进阶练习:做个简单的电机控制界面
学会数据读写后,再做控制功能就很简单,比如做个 “电机启停 + 状态监控” 界面:
界面设计,拖 3 个控件到 Form1 上:
两个按钮:Button1(文字 “电机正转”)、Button2(文字 “电机停止”);
一个指示灯:用 PictureBox,默认设为灰色(代表停止),正转时变绿色;
PLC 逻辑配合:PLC 里用 M0 控制电机正转(M0=1 正转,M0=0 停止),所以上位机只要控制 M0 的通断就行;
写控制代码:
“电机正转” 按钮点击事件:往 PLC 写 M0=1,同时把 PictureBox 设为绿色;
“电机停止” 按钮点击事件:往 PLC 写 M0=0,把 PictureBox 设为灰色;
还能加个定时器(Timer),每隔 1 秒读一次 M0 的状态,自动更新指示灯颜色,不用手动刷新。
五、电气工程师学 C# 的 3 个优势与避坑点
1. 我们的优势:不用从零学
    懂 PLC 逻辑,就能快速理解 “上位机 - PLC” 的通信逻辑(比如知道 D 寄存器存什么、M 继电器控制什么),比纯软件工程师入门快;
    熟悉工业现场需求,知道界面该放哪些控件(比如电机控制需要启停按钮,监控需要数值显示),不用做无用功能。
2. 避坑点:别一开始就钻理论
    不用先学 “C# 基础语法大全”,用到什么学什么(比如要读数据,就学 “如何读取 PLC 设备”;要做报表,再学 “Excel 操作”);
    通信时先确认 PLC能连通,再查代码(很多时候通信失败不是代码错,是通讯线没对或通讯参数设错);
    别追求 “完美界面”,先实现功能(比如先用文本框显示数据,后续再美化界面)。
3. 推荐学习路径:3 步从入门到实用
第 1 周:搞定 PLC 通信(读 / 写 D 寄存器、M 继电器),能实现 “上位机 - PLC” 数据交互;
第 2 周:做个简单控制界面(比如电机启停、参数设定),加入指示灯、按钮等控件;
第 3 周:学数据记录(把 PLC 数据写入 Excel),满足生产报表需求。
六、最后:用起来才是关键
    其实很多电气工程师都能学会 C# 上位机,关键是别被 “编程” 吓住,至于为什么我们要学上位机,之前有一篇文章专门讲解过,大家可以看一下。
关于M的控制可以查询通讯手册
串口通讯文章我前面也有写
今天就介绍到这里了,大家如果在学习过程中遇到什么问题可在评论区进行留言讨论,祝大家学习顺利。

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

上一主题上一主题         下一主题下一主题
QQ手机版小黑屋粤ICP备17165530号

关于我们·投诉举报· 用户帮助· 联系我们 · 本站服务 · 版权声明· 隐私政策 · 投搞指南

法律保护:PLC技术网,plcjs.com,plcjs.net等字样
Copyright 2010-2030. All rights reserved. 


微信公众号二维码 抖音二维码 百家号二维码 今日头条二维码哔哩哔哩二维码