# 工业上位机开发技术手册

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

---

## 目录

- [第一章 Modbus 通信](#第一章-modbus-通信)
- [第二章 串口通信编程](#第二章-串口通信编程)
- [第三章 TCP/IP 网络通信](#第三章-tcpip-网络通信)
- [第四章 OPC UA 通信](#第四章-opc-ua-通信)
- [第五章 多线程与异步编程](#第五章-多线程与异步编程)
- [第六章 数据库操作](#第六章-数据库操作)
- [第七章 WPF 进阶技术](#第七章-wpf-进阶技术)
- [第八章 日志与异常处理](#第八章-日志与异常处理)
- [第九章 MES 系统对接](#第九章-mes-系统对接)
- [第十章 配置文件管理](#第十章-配置文件管理)
- [附录：学习路线图](#附录学习路线图)

---

## 第一章 Modbus 通信

### 1.1 重点知识

- Modbus RTU 走串口（RS485），Modbus TCP 走网口（以太网）
- 主从架构：主站（上位机）发请求，从站（PLC/仪表）回复
- 核心功能码：03读保持寄存器、06写单个寄存器、16写多个寄存器
- 报文结构：从站地址 + 功能码 + 数据 + CRC校验（RTU）或MBAP头（TCP）
- 一个寄存器 = 16位（2字节），32位数据需要2个寄存器拼接

### 1.2 注意事项

- 字节序问题：不同PLC的高低字节顺序不同（大端/小端），必须确认
- 从站地址范围 1~247，0是广播地址
- 串口参数必须匹配：波特率、数据位、停止位、校验位
- RS485 是半双工，同一时刻只能一个设备发送
- 响应超时必须处理，一般设 1~3 秒

### 1.3 常见问题

- CRC校验失败 → 字节序不对或数据被截断
- 收到异常码 02（非法数据地址）→ 寄存器地址超出范围
- 读到的值全是0 → 从站地址错误或接线问题
- 通信不稳定 → 检查屏蔽线、终端电阻、波特率匹配

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

```csharp
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

```csharp
// 两个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 重点知识

- SerialPort 类的核心属性：PortName、BaudRate、DataBits、StopBits、Parity
- DataReceived 事件在后台线程触发，更新UI必须用 Dispatcher
- 粘包问题：多次发送的数据粘在一起收到
- 断包问题：一条完整数据分多次收到
- 关闭程序前必须 Close() 串口，否则下次打不开

### 2.2 注意事项

- 串口被占用时 Open() 会抛异常，必须 try-catch
- 关闭窗体时要在 Closing 事件中关闭串口
- DataReceived 可能一次收到多条数据，也可能一条分多次
- BytesToRead 属性可以判断缓冲区有多少字节
- RS485 通信需要控制收发切换（有些USB转485需要延时）

### 2.3 常见问题

- 串口打开失败 → 设备管理器确认COM口号，其他程序是否占用
- 收到乱码 → 波特率不匹配或编码方式不对
- DataReceived不触发 → 检查是否调用了Open()，事件是否绑定
- 关闭程序后串口还是占用 → 没有正确Close()，用Dispose()

### 2.4 Demo: 完整串口通信类

```csharp
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: 帧解析（处理粘包断包）

```csharp
// 帧格式：帧头(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 重点知识

- TcpClient 用于客户端连接，TcpListener 用于服务端监听
- TCP 是流式协议，没有消息边界，必须自己处理粘包
- 心跳机制检测连接是否断开
- 断线自动重连是工业场景的刚需
- 异步编程（async/await）避免阻塞UI

### 3.2 注意事项

- NetworkStream.Read 可能一次读不到完整消息
- 必须处理 SocketException（断线、超时等）
- 服务端要处理多客户端并发（每个客户端一个Task）
- 发送和接收要在不同的Task中
- 程序退出时必须正确关闭所有连接

### 3.3 Demo: TCP 客户端（带心跳和重连）

```csharp
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 重点知识

- OPC UA 是工业通信的新标准，跨平台、安全、支持复杂数据模型
- 核心概念：节点（Node）、地址空间（Address Space）、订阅（Subscription）
- 每个数据点有 NodeId（如 `ns=2;s=Temperature`）
- 支持读/写/订阅三种操作模式
- NuGet 包：`OPCFoundation.NetStandard.Opc.Ua`

### 4.2 注意事项

- 连接字符串格式：`opc.tcp://192.168.1.100:4840`
- 安全策略：开发时可用 None，生产环境建议 SignAndEncrypt
- 订阅比轮询效率高，数据变化时服务器主动推送
- Session 需要定期 KeepAlive 防止超时断开

### 4.3 Demo: OPC UA 读取和订阅

```csharp
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 重点知识

- async/await 是C#异步编程的核心，不阻塞调用线程
- Task 代表一个异步操作，Task\<T\> 有返回值
- CancellationToken 用于优雅取消长时间运行的任务
- ConcurrentDictionary 是线程安全的字典
- lock 保证同一时刻只有一个线程执行临界区代码
- UI线程只有一个，后台线程不能直接操作UI控件

### 5.2 注意事项

- async void 只用于事件处理，其他情况用 async Task
- 避免 async void，异常无法被 catch
- Task.Run 把同步代码丢到线程池执行
- 多个异步操作用 Task.WhenAll 并行执行
- Dispatcher.Invoke 同步等待UI线程，BeginInvoke 不等待

### 5.3 常见问题

- 死锁 → UI线程用 .Result 或 .Wait() 等待异步任务
- 竞态条件 → 多线程同时修改共享数据，用 lock 或 ConcurrentDictionary
- 内存泄漏 → CancellationToken 没传，Task 无法取消
- UI卡死 → 在UI线程执行了耗时操作（数据库查询、网络请求）

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

```csharp
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 优雅退出

```csharp
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 重点知识

- SQLite 适合本地存储（配置、缓存、日志），无需安装数据库服务
- SQL Server 适合共享数据（多台电脑访问同一数据库）
- Dapper 是轻量 ORM，SQL 手写但自动映射对象，性能极好
- EF Core 是完整 ORM，适合复杂业务模型
- 批量插入比逐条插入快 100 倍以上

### 6.2 注意事项

- 数据库连接用完必须关闭（用 using 语句）
- SQL 拼接必须用参数化查询，防 SQL 注入
- 高频采集数据建议用时序数据库（InfluxDB/TDengine）
- 数据库文件定期备份，防止数据丢失
- 连接字符串不要硬编码，放配置文件

### 6.3 Demo: SQLite + Dapper

```csharp
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 重点知识

- UserControl 做可复用组件（设备卡片、仪表盘、指示灯）
- LiveCharts / OxyPlot 做实时数据曲线图
- DataGrid 做数据表格，支持排序、筛选
- CommunityToolkit.Mvvm 简化 MVVM 开发
- 打印功能用 PrintDialog + FlowDocument 或 FixedDocument

### 7.2 Demo: 自定义指示灯控件

```xml
<!-- 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 报表

```csharp
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 重点知识

- Serilog 是目前最流行的 C# 日志框架
- 日志分级：Verbose < Debug < Information < Warning < Error < Fatal
- 日志按天滚动，自动清理过期日志
- 全局异常捕获防止程序崩溃白屏
- 通信异常必须有重试机制

### 8.2 Demo: Serilog 日志配置

```csharp
// 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 重点知识

- 常见对接方式：Web API（最主流）、数据库中间表、消息队列
- Web API 用 HttpClient 或 RestSharp 调用
- 工单下发：MES 下发生产任务到上位机
- 生产报工：上位机把产量、良率上报 MES
- 物料追溯：扫码绑定产品与物料批次

### 9.2 Demo: Web API 对接 MES

```csharp
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 重点知识

- appsettings.json 是 .NET 推荐的配置方式
- 设备参数（IP、端口、地址）不要硬编码，放配置文件
- 配置文件修改后不用重新编译程序
- 敏感信息（密码）不要明文存储

### 10.2 Demo: JSON 配置文件读写

```csharp
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个月）

- Modbus RTU/TCP 通信 → 能读写PLC寄存器
- 串口编程 → 完整收发、粘包处理
- SQLite + Dapper → 数据存取
- 多线程基础 → async/await、Task、Dispatcher

### 第二阶段（3-4个月）

- OPC UA → 连接主流设备
- TCP/IP Socket → 自定义协议通信
- WPF 进阶 → 自定义控件、实时图表
- 日志框架 → Serilog 集成

### 第三阶段（5-6个月）

- MES 对接 → Web API、工单报工
- 报表导出 → Excel/PDF 生成
- 多设备管理 → 架构设计、性能优化
- 项目实战 → 独立完成完整上位机项目

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