工业上位机开发技术手册

C# WPF + PLC 通信 + MES 对接 完整学习指南

目录

第一章 Modbus 通信

1.1 重点知识

1.2 注意事项

1.3 常见问题

1.4 Demo: Modbus RTU 读取保持寄存器

using System.IO.Ports;

public class ModbusRtuClient
{
    private SerialPort _port;

    public void Connect(string portName, int baudRate)
    {
        _port = new SerialPort(portName, baudRate,
            Parity.None, 8, StopBits.One);
        _port.ReadTimeout = 3000;
        _port.Open();
    }

    // 读保持寄存器(功能码03)
    public ushort[] ReadHoldingRegisters(
        byte slaveId, ushort startAddr, ushort count)
    {
        // 构造请求报文
        byte[] request = new byte[8];
        request[0] = slaveId;          // 从站地址
        request[1] = 0x03;             // 功能码
        request[2] = (byte)(startAddr >> 8);  // 起始地址高字节
        request[3] = (byte)(startAddr & 0xFF); // 起始地址低字节
        request[4] = (byte)(count >> 8);
        request[5] = (byte)(count & 0xFF);
        // CRC校验
        var crc = CalcCRC16(request, 6);
        request[6] = crc[0];
        request[7] = crc[1];

        _port.DiscardInBuffer();
        _port.Write(request, 0, request.Length);

        // 读取响应
        int dataLen = 3 + count * 2 + 2; // 地址+功能+字节数+数据+CRC
        byte[] response = new byte[dataLen];
        int read = 0;
        while (read < dataLen)
            read += _port.Read(response, read, dataLen - read);

        // 校验CRC
        var checkCrc = CalcCRC16(response, dataLen - 2);
        if (checkCrc[0] != response[dataLen-2]
            || checkCrc[1] != response[dataLen-1])
            throw new Exception("CRC校验失败");

        // 解析寄存器值
        ushort[] values = new ushort[count];
        for (int i = 0; i < count; i++)
        {
            int offset = 3 + i * 2;
            values[i] = (ushort)((response[offset] << 8)
                               | response[offset + 1]);
        }
        return values;
    }

    // CRC16 计算(Modbus标准)
    private byte[] CalcCRC16(byte[] data, int length)
    {
        ushort crc = 0xFFFF;
        for (int i = 0; i < length; i++)
        {
            crc ^= data[i];
            for (int j = 0; j < 8; j++)
            {
                if ((crc & 1) != 0)
                    crc = (ushort)((crc >> 1) ^ 0xA001);
                else
                    crc = (ushort)(crc >> 1);
            }
        }
        return new byte[] { (byte)(crc & 0xFF),
                            (byte)(crc >> 8) };
    }

    public void Close() => _port?.Close();
}

1.5 Demo: 两个寄存器拼成 Float

// 两个16位寄存器拼成32位浮点数
// 寄存器 D100=0x4228, D101=0x4148 → 42.13
public float RegistersToFloat(ushort high, ushort low)
{
    // 大端模式(ABCD):高寄存器高字节在前
    byte[] bytes = new byte[4];
    bytes[0] = (byte)(high >> 8);
    bytes[1] = (byte)(high & 0xFF);
    bytes[2] = (byte)(low >> 8);
    bytes[3] = (byte)(low & 0xFF);
    if (BitConverter.IsLittleEndian)
        Array.Reverse(bytes);
    return BitConverter.ToSingle(bytes, 0);
}

// 小端模式(CDAB):字节序交换
public float RegistersToFloatCDAB(ushort high, ushort low)
{
    byte[] bytes = new byte[4];
    bytes[0] = (byte)(low & 0xFF);
    bytes[1] = (byte)(low >> 8);
    bytes[2] = (byte)(high & 0xFF);
    bytes[3] = (byte)(high >> 8);
    return BitConverter.ToSingle(bytes, 0);
}

第二章 串口通信编程

2.1 重点知识

2.2 注意事项

2.3 常见问题

2.4 Demo: 完整串口通信类

public class SerialComm : IDisposable
{
    private SerialPort _port;
    private readonly object _lock = new();

    public event Action<byte[]> DataReceived;
    public event Action<string> ErrorOccurred;

    public bool IsOpen => _port?.IsOpen ?? false;

    public void Open(string portName, int baudRate)
    {
        _port = new SerialPort(portName, baudRate,
            Parity.None, 8, StopBits.One);
        _port.ReadTimeout = 3000;
        _port.WriteTimeout = 3000;
        _port.DataReceived += OnDataReceived;
        _port.ErrorReceived += OnErrorReceived;
        _port.Open();
    }

    private void OnDataReceived(object sender,
        SerialDataReceivedEventArgs e)
    {
        try
        {
            int count = _port.BytesToRead;
            byte[] buffer = new byte[count];
            _port.Read(buffer, 0, count);
            DataReceived?.Invoke(buffer);
        }
        catch (Exception ex)
        {
            ErrorOccurred?.Invoke(ex.Message);
        }
    }

    private void OnErrorReceived(object sender,
        SerialErrorReceivedEventArgs e)
    {
        ErrorOccurred?.Invoke($"串口错误: {e.EventType}");
    }

    public void Send(byte[] data)
    {
        lock (_lock)
        {
            if (_port?.IsOpen == true)
                _port.Write(data, 0, data.Length);
        }
    }

    public void Dispose()
    {
        if (_port != null)
        {
            _port.DataReceived -= OnDataReceived;
            _port.ErrorReceived -= OnErrorReceived;
            if (_port.IsOpen) _port.Close();
            _port.Dispose();
        }
    }
}

2.5 Demo: 帧解析(处理粘包断包)

// 帧格式:帧头(0xAA) + 长度(1字节) + 数据 + 校验(1字节)
private byte[] _buffer = new byte[0];

private void OnDataReceived(byte[] newData)
{
    // 拼接数据到缓冲区
    byte[] combined = new byte[_buffer.Length + newData.Length];
    Array.Copy(_buffer, 0, combined, 0, _buffer.Length);
    Array.Copy(newData, 0, combined, _buffer.Length,
        newData.Length);
    _buffer = combined;

    // 循环解析完整帧
    while (true)
    {
        // 找帧头
        int headerIdx = Array.IndexOf(_buffer, (byte)0xAA);
        if (headerIdx < 0) { _buffer = new byte[0]; break; }
        if (headerIdx > 0) // 丢弃帧头前的垃圾数据
        {
            _buffer = _buffer[headerIdx..];
        }

        // 长度不够,等下次数据
        if (_buffer.Length < 3) break;

        int dataLen = _buffer[1];
        int frameLen = 2 + dataLen + 1; // 头+长度+数据+校验
        if (_buffer.Length < frameLen) break;

        // 取出完整帧
        byte[] frame = new byte[frameLen];
        Array.Copy(_buffer, 0, frame, 0, frameLen);

        // 校验
        byte checkSum = 0;
        for (int i = 1; i < frameLen - 1; i++)
            checkSum ^= frame[i];
        if (checkSum == frame[frameLen - 1])
            ProcessFrame(frame); // 处理有效帧

        // 移除已处理的帧
        byte[] remaining = new byte[_buffer.Length - frameLen];
        Array.Copy(_buffer, frameLen, remaining, 0,
            remaining.Length);
        _buffer = remaining;
    }
}

第三章 TCP/IP 网络通信

3.1 重点知识

3.2 注意事项

3.3 Demo: TCP 客户端(带心跳和重连)

public class TcpClientWithReconnect
{
    private TcpClient? _client;
    private NetworkStream? _stream;
    private CancellationTokenSource _cts = new();
    private readonly string _ip;
    private readonly int _port;

    public event Action<byte[]> DataReceived;
    public event Action<bool> ConnectionChanged;

    public TcpClientWithReconnect(string ip, int port)
    {
        _ip = ip;
        _port = port;
    }

    public async Task StartAsync()
    {
        while (!_cts.Token.IsCancellationRequested)
        {
            try
            {
                _client = new TcpClient();
                await _client.ConnectAsync(_ip, _port);
                _stream = _client.GetStream();
                ConnectionChanged?.Invoke(true);

                // 接收和心跳并行
                var receiveTask = ReceiveLoopAsync();
                var heartbeatTask = HeartbeatLoopAsync();
                await Task.WhenAny(receiveTask, heartbeatTask);
            }
            catch (Exception)
            {
                ConnectionChanged?.Invoke(false);
            }
            finally
            {
                _stream?.Dispose();
                _client?.Dispose();
            }

            // 5秒后重连
            await Task.Delay(5000, _cts.Token);
        }
    }

    private async Task ReceiveLoopAsync()
    {
        byte[] buf = new byte[4096];
        while (!_cts.Token.IsCancellationRequested)
        {
            int len = await _stream!.ReadAsync(buf, _cts.Token);
            if (len == 0) break; // 连接断开
            byte[] data = new byte[len];
            Array.Copy(buf, data, len);
            DataReceived?.Invoke(data);
        }
    }

    private async Task HeartbeatLoopAsync()
    {
        while (!_cts.Token.IsCancellationRequested)
        {
            await Task.Delay(3000, _cts.Token);
            byte[] heartbeat = new byte[] { 0xFF };
            await _stream!.WriteAsync(heartbeat, _cts.Token);
        }
    }

    public void Stop() => _cts.Cancel();
}

第四章 OPC UA 通信

4.1 重点知识

4.2 注意事项

4.3 Demo: OPC UA 读取和订阅

using Opc.Ua;
using Opc.Ua.Client;

public class OpcUaHelper
{
    private Session? _session;

    public async Task ConnectAsync(string serverUrl)
    {
        var config = ApplicationConfiguration.Create()
            .AsClient().Build();
        var endpoint = new ConfiguredEndpoint(null,
            new EndpointDescription(serverUrl));
        _session = await Session.Create(config, endpoint, true);
    }

    // 读取单个节点值
    public object? ReadValue(string nodeId)
    {
        var node = new NodeId(nodeId);
        var value = _session!.ReadValue(0, 0, node);
        return value.Value;
    }

    // 订阅数据变化(推荐,比轮询高效)
    public void Subscribe(string nodeId,
        Action<object?> onDataChange)
    {
        var subscription = new Subscription
        {
            PublishingInterval = 500  // 500ms发布一次
        };

        var item = new MonitoredItem
        {
            StartNodeId = new NodeId(nodeId),
            DisplayName = nodeId
        };
        item.Notification += (mon, args) =>
        {
            var value = args.NotificationValues.First().Value;
            onDataChange(value);
        };

        subscription.AddItem(item);
        _session!.AddSubscription(subscription);
        subscription.Create();
    }
}

第五章 多线程与异步编程

5.1 重点知识

5.2 注意事项

5.3 常见问题

5.4 Demo: 生产者-消费者模式

using System.Threading.Channels;

// 生产者:串口收到数据放入Channel
// 消费者:从Channel取数据解析后更新UI
public class DataPipeline
{
    private readonly Channel<byte[]> _channel
        = Channel.CreateBounded<byte[]>(1000);

    // 生产者调用
    public async Task ProduceAsync(byte[] data)
    {
        await _channel.Writer.WriteAsync(data);
    }

    // 消费者:后台线程持续处理
    public async Task ConsumeAsync(
        Action<byte[]> processData,
        CancellationToken ct)
    {
        await foreach (var data
            in _channel.Reader.ReadAllAsync(ct))
        {
            processData(data);  // 解析数据
        }
    }
}

// 使用示例
var pipeline = new DataPipeline();
var cts = new CancellationTokenSource();

// 启动消费者
_ = Task.Run(() => pipeline.ConsumeAsync(
    data =>
    {
        var result = ParseData(data);
        Application.Current.Dispatcher.BeginInvoke(() =>
        {
            Temperature = result.Temp;
            Pressure = result.Press;
        });
    }, cts.Token));

// 串口收到数据时调用生产者
private void OnDataReceived(byte[] data)
{
    _ = pipeline.ProduceAsync(data);
}

5.5 Demo: CancellationToken 优雅退出

private CancellationTokenSource _cts;

public async Task StartPollingAsync()
{
    _cts = new CancellationTokenSource();
    try
    {
        while (!_cts.Token.IsCancellationRequested)
        {
            var values = await ReadPlcAsync();
            UpdateUI(values);
            await Task.Delay(200, _cts.Token);
        }
    }
    catch (OperationCanceledException)
    {
        // 正常取消,不报错
    }
}

public void StopPolling()
{
    _cts?.Cancel();  // 通知任务停止
    _cts?.Dispose();
}

第六章 数据库操作

6.1 重点知识

6.2 注意事项

6.3 Demo: SQLite + Dapper

using System.Data.SQLite;
using Dapper;

// NuGet: Install-Package System.Data.SQLite
// NuGet: Install-Package Dapper

public class ProductionRepository
{
    private readonly string _connStr;

    public ProductionRepository(string dbPath)
    {
        _connStr = $"Data Source={dbPath};Version=3;";
        InitDatabase();
    }

    private void InitDatabase()
    {
        using var conn = new SQLiteConnection(_connStr);
        conn.Execute(@"
            CREATE TABLE IF NOT EXISTS ProductionRecord (
                Id INTEGER PRIMARY KEY AUTOINCREMENT,
                DeviceId TEXT NOT NULL,
                Temperature REAL,
                Pressure REAL,
                ProductCount INTEGER,
                CreateTime DATETIME DEFAULT CURRENT_TIMESTAMP
            )");
    }

    // 单条插入
    public void Insert(ProductionRecord record)
    {
        using var conn = new SQLiteConnection(_connStr);
        conn.Execute(@"
            INSERT INTO ProductionRecord
                (DeviceId, Temperature, Pressure, ProductCount)
            VALUES
                (@DeviceId, @Temperature, @Pressure, @ProductCount)",
            record);
    }

    // 批量插入(比逐条快100倍)
    public void BatchInsert(IEnumerable<ProductionRecord> records)
    {
        using var conn = new SQLiteConnection(_connStr);
        conn.Execute(@"
            INSERT INTO ProductionRecord
                (DeviceId, Temperature, Pressure, ProductCount)
            VALUES
                (@DeviceId, @Temperature, @Pressure, @ProductCount)",
            records);
    }

    // 查询
    public List<ProductionRecord> GetTodayRecords(string deviceId)
    {
        using var conn = new SQLiteConnection(_connStr);
        return conn.Query<ProductionRecord>(@"
            SELECT * FROM ProductionRecord
            WHERE DeviceId = @DeviceId
              AND date(CreateTime) = date('now')
            ORDER BY CreateTime DESC",
            new { DeviceId = deviceId }).ToList();
    }
}

public class ProductionRecord
{
    public int Id { get; set; }
    public string DeviceId { get; set; } = "";
    public double Temperature { get; set; }
    public double Pressure { get; set; }
    public int ProductCount { get; set; }
    public DateTime CreateTime { get; set; }
}

第七章 WPF 进阶技术

7.1 重点知识

7.2 Demo: 自定义指示灯控件

<!-- StatusLight.xaml - 可复用的设备状态灯 -->
<UserControl x:Class="SCEAST_MES.Views.StatusLight">
    <Grid>
        <Ellipse Width="20" Height="20">
            <Ellipse.Style>
                <Style TargetType="Ellipse">
                    <Setter Property="Fill" Value="Gray"/>
                    <Style.Triggers>
                        <DataTrigger Binding="{Binding
                            RelativeSource={RelativeSource
                                AncestorType=UserControl},
                            Path=Status}" Value="Running">
                            <Setter Property="Fill" Value="#4CAF50"/>
                        </DataTrigger>
                        <DataTrigger Binding="{Binding
                            RelativeSource={RelativeSource
                                AncestorType=UserControl},
                            Path=Status}" Value="Error">
                            <Setter Property="Fill" Value="#F44336"/>
                        </DataTrigger>
                        <DataTrigger Binding="{Binding
                            RelativeSource={RelativeSource
                                AncestorType=UserControl},
                            Path=Status}" Value="Warning">
                            <Setter Property="Fill" Value="#FF9800"/>
                        </DataTrigger>
                    </Style.Triggers>
                </Style>
            </Ellipse.Style>
        </Ellipse>
    </Grid>
</UserControl>

<!-- 使用 -->
<local:StatusLight Status="{Binding DeviceStatus}" />

7.3 Demo: 导出 Excel 报表

using ClosedXML.Excel;
// NuGet: Install-Package ClosedXML

public void ExportProductionReport(
    List<ProductionRecord> records, string filePath)
{
    using var workbook = new XLWorkbook();
    var ws = workbook.Worksheets.Add("生产报表");

    // 表头
    ws.Cell(1, 1).Value = "设备编号";
    ws.Cell(1, 2).Value = "温度";
    ws.Cell(1, 3).Value = "压力";
    ws.Cell(1, 4).Value = "产量";
    ws.Cell(1, 5).Value = "时间";
    ws.Row(1).Style.Font.Bold = true;

    // 数据
    for (int i = 0; i < records.Count; i++)
    {
        var r = records[i];
        ws.Cell(i + 2, 1).Value = r.DeviceId;
        ws.Cell(i + 2, 2).Value = r.Temperature;
        ws.Cell(i + 2, 3).Value = r.Pressure;
        ws.Cell(i + 2, 4).Value = r.ProductCount;
        ws.Cell(i + 2, 5).Value = r.CreateTime;
    }

    ws.Columns().AdjustToContents();
    workbook.SaveAs(filePath);
}

第八章 日志与异常处理

8.1 重点知识

8.2 Demo: Serilog 日志配置

// NuGet: Install-Package Serilog
// Install-Package Serilog.Sinks.File

using Serilog;

// App.xaml.cs 中初始化
Log.Logger = new LoggerConfiguration()
    .MinimumLevel.Information()
    .WriteTo.File("logs/log-.txt",
        rollingInterval: RollingInterval.Day,
        retainedFileCountLimit: 30,  // 保留30天
        outputTemplate:
            "{Timestamp:HH:mm:ss} [{Level}] {Message}{NewLine}{Exception}")
    .WriteTo.Console()
    .CreateLogger();

// 使用
Log.Information("串口 {Port} 已连接", "COM1");
Log.Warning("设备 {Id} 通信超时", "DEV001");
Log.Error(ex, "读取PLC数据失败");

// 全局异常捕获(App.xaml.cs)
protected override void OnStartup(StartupEventArgs e)
{
    base.OnStartup(e);
    AppDomain.CurrentDomain.UnhandledException
        += (s, args) =>
        {
            Log.Fatal(args.ExceptionObject as Exception,
                "未处理的异常");
        };
    DispatcherUnhandledException
        += (s, args) =>
        {
            Log.Fatal(args.Exception, "UI线程异常");
            args.Handled = true; // 防止程序崩溃
        };
}

第九章 MES 系统对接

9.1 重点知识

9.2 Demo: Web API 对接 MES

using System.Net.Http.Json;

public class MesApiClient
{
    private readonly HttpClient _http;

    public MesApiClient(string baseUrl)
    {
        _http = new HttpClient { BaseAddress =
            new Uri(baseUrl) };
        _http.Timeout = TimeSpan.FromSeconds(10);
    }

    // 获取工单
    public async Task<WorkOrder?> GetWorkOrderAsync(
        string orderNo)
    {
        return await _http.GetFromJsonAsync<WorkOrder>(
            $"/api/orders/{orderNo}");
    }

    // 报工
    public async Task<bool> ReportProductionAsync(
        ProductionReport report)
    {
        var resp = await _http.PostAsJsonAsync(
            "/api/production/report", report);
        return resp.IsSuccessStatusCode;
    }
}

public class WorkOrder
{
    public string OrderNo { get; set; } = "";
    public string ProductCode { get; set; } = "";
    public int TargetQty { get; set; }
}

public class ProductionReport
{
    public string OrderNo { get; set; } = "";
    public string DeviceId { get; set; } = "";
    public int GoodQty { get; set; }
    public int BadQty { get; set; }
    public DateTime ReportTime { get; set; }
}

第十章 配置文件管理

10.1 重点知识

10.2 Demo: JSON 配置文件读写

using System.Text.Json;

// appsettings.json
// {
//   "Devices": [
//     { "Id": "DEV001", "Type": "ModbusTcp",
//       "Ip": "192.168.1.10", "Port": 502 },
//     { "Id": "DEV002", "Type": "Serial",
//       "ComPort": "COM3", "BaudRate": 9600 }
//   ],
//   "PollInterval": 200,
//   "DatabasePath": "data/production.db"
// }

public class AppConfig
{
    public List<DeviceConfig> Devices { get; set; } = new();
    public int PollInterval { get; set; } = 200;
    public string DatabasePath { get; set; } = "";

    public static AppConfig Load(string path = "appsettings.json")
    {
        if (!File.Exists(path))
            return new AppConfig();
        var json = File.ReadAllText(path);
        return JsonSerializer.Deserialize<AppConfig>(json)
            ?? new AppConfig();
    }

    public void Save(string path = "appsettings.json")
    {
        var options = new JsonSerializerOptions
        {
            WriteIndented = true  // 格式化输出
        };
        var json = JsonSerializer.Serialize(this, options);
        File.WriteAllText(path, json);
    }
}

public class DeviceConfig
{
    public string Id { get; set; } = "";
    public string Type { get; set; } = "";
    public string? Ip { get; set; }
    public int Port { get; set; }
    public string? ComPort { get; set; }
    public int BaudRate { get; set; }
}

附录:学习路线图

第一阶段(1-2个月)

第二阶段(3-4个月)

第三阶段(5-6个月)

核心建议: 通信协议 + 多线程 + 数据库 是上位机开发的三根支柱。先把这三样吃透,其他技术都是在这些基础上叠加。每个技术点都要动手写Demo,光看不练等于没学。