C# 基于 RS485 与设备通讯(以照度计为例子)

如何使用C# 获取目标设备上的读数?

步骤一: 找目标设备的通讯手册

步骤二: 获取报文,使用SSCOM进行测试

步骤三:构建C#代码

步骤一:找到通讯手册

步骤二:使用SSCOM进行测试

百度SSCOM很容易找到安装包。

下载后,选择端口号,打开串口。

如何获取报文呢:

根据文档内容,先得到数据格式

根据文档中每个命令的格式,按照规定的字段(受体头编号、命令、参数等)构造出ASCII字符串,然后计算BCC,最后加上STX、ETX和分隔符,转换成十六进制表示。

STX(Start of Text)是一个控制字符,十六进制为02。所以开头是02

例如第54号命令。与PC建立连接。

可以看到 ,pc到CL-200A,要使用字符 0 0 5 4 1 空格 空格 空格

转换过来就是:

30 30 35 34 31 20 20 20

在加上

ETX: 03

BCC: 30 32

分隔符: 0D 0A

打开SSCOM

点击更多串口设置,设置好数据格式

发送报文

根据通讯手册总结出 :

  1. 设置PC连接模式(命令54)

02 30 30 35 34 31 20 20 20 03 31 33 0D 0A

  1. 设置Hold状态(命令55)

02 39 39 35 35 31 20 20 30 03 30 32 0D 0A

  1. 设置EXT模式(命令40)

02 30 30 34 30 31 30 20 20 03 30 36 0D 0A

  1. 触发测量(命令40,参数不同)

02 39 39 34 30 32 31 20 20 03 30 34 0D 0A

  1. 读取Ev、x、y数据(命令02)

02 30 30 30 32 31 32 30 30 03 30 32 0D 0A

然后开始构建C# 程序

步骤三 构建C#

上述报文写成如下格式:

// 命令序列

private readonly byte[] STX = new byte[] { 0x02 };

private readonly byte[] DELIMITER = new byte[] { 0x0D, 0x0A };

// 1. 设置PC连接模式(命令54)

private readonly byte[] setPCModeCmd = new byte[] {

0x30, 0x30, 0x35, 0x34, // "0054"

0x31, 0x20, 0x20, 0x20, // "1 "

0x03 // ETX

};

// 2. 设置Hold状态(命令55)

private readonly byte[] setHoldStatusCmd = new byte[] {

0x39, 0x39, 0x35, 0x35, // "9955"

0x31, 0x20, 0x20, 0x30, // "1 0"

0x03 // ETX

};

// 3. 设置EXT模式(命令40)

private readonly byte[] setEXTModeCmd = new byte[] {

0x30, 0x30, 0x34, 0x30, // "0040"

0x31, 0x30, 0x20, 0x20, // "10 "

0x03 // ETX

};

// 4. 触发测量(命令40,参数不同)

private readonly byte[] triggerMeasurementCmd = new byte[] {

0x39, 0x39, 0x34, 0x30, // "9940"

0x32, 0x31, 0x20, 0x20, // "21 "

0x03 // ETX

};

// 5. 读取Ev、x、y数据(命令02)

private readonly byte[] readDataCmd = new byte[] {

0x30, 0x30, 0x30, 0x32, // "0002"

0x31, 0x32, 0x30, 0x30, // "1200" (CF禁用,校准模式NORM)

0x03 // ETX

};

设置RS485:

public class RS485

{

List<byte> buffer = new List<byte>();

public SerialPort serialPort = null;

public RS485(string comNum, int baudRate)

{

if (serialPort == null)

{

serialPort = new SerialPort();

serialPort.BaudRate = baudRate;

serialPort.DataBits = 8;

serialPort.Parity = Parity.None;

serialPort.PortName = comNum;

serialPort.StopBits = StopBits.One;

serialPort.ReadBufferSize = 1024000;

serialPort.WriteBufferSize = 1024000;

serialPort.ErrorReceived += new SerialErrorReceivedEventHandler(serialPort_ErrorReceived);

serialPort.DataReceived += new SerialDataReceivedEventHandler(serialPort_DataReceived);

serialPort.Open();

}

}

void serialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)

{

int n = serialPort.BytesToRead;

byte[] readByte = new byte[n];

int data = serialPort.Read(readByte, 0, n);

buffer.AddRange(readByte);

//while (serialPort.BytesToRead > 0)

//{

// int data = serialPort.ReadByte();

// buffer.Add((byte)data);

//}

}

void serialPort_ErrorReceived(object sender, SerialErrorReceivedEventArgs e)

{

Console.WriteLine("ErrorReceived:" + e.EventType.ToString());

}

将返回的报文 获取其中的 照度值 :

static double ParseIlluminanceFromMessage(string hexMessage)

{

// 1. 去除空格,转换为字节数组

string[] hexBytes = hexMessage.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);

byte[] messageBytes = hexBytes.Select(h => Convert.ToByte(h, 16)).ToArray();

// 2. 验证基本格式

if (messageBytes.Length < 9)

throw new ArgumentException("报文太短,不符合CL-200A协议格式");

// 检查STX (0x02)

if (messageBytes[0] != 0x02)

throw new ArgumentException("无效的STX字节");

// 查找ETX (0x03)的位置

int etxIndex = -1;

for (int i = 1; i < messageBytes.Length; i++)

{

if (messageBytes[i] == 0x03)

{

etxIndex = i;

break;

}

}

if (etxIndex == -1)

throw new ArgumentException("未找到ETX字节");

// 3. 解析报文各部分

// 受体头号 (2字节): messageBytes[1..2]

// 命令 (2字节): messageBytes[3..4]

string command = $"{Convert.ToChar(messageBytes[3])}{Convert.ToChar(messageBytes[4])}";

// 状态 (4字节): messageBytes[5..8]

// 检查是否为命令02的响应

if (command != "02")

throw new ArgumentException($"不是命令02的响应 (收到命令: {command})");

// 4. 检查是否为长格式报文 (命令02使用长格式)

// 长格式: STX + 头号(2) + 命令(2) + 状态(4) + 数据(6*3=18) + ETX + BCC(2) + CR + LF

int expectedLength = 1 + 2 + 2 + 4 + 18 + 1 + 2 + 1 + 1; // 32字节

if (messageBytes.Length < expectedLength)

throw new ArgumentException($"报文长度不足,长格式应至少{expectedLength}字节");

// 5. 提取Ev数据 (第一个6字节块)

// 数据起始位置: 跳过STX(1) + 头号(2) + 命令(2) + 状态(4) = 9字节

int dataStartIndex = 9;

// Ev数据块: 6个字节

byte[] evDataBytes = new byte[6];

Array.Copy(messageBytes, dataStartIndex, evDataBytes, 0, 6);

// 6. 解析Ev值

// 格式: 符号(1字节) + 4位数值(4字节) + 指数(1字节)

char sign = Convert.ToChar(evDataBytes[0]);

string valueStr = $"{Convert.ToChar(evDataBytes[1])}{Convert.ToChar(evDataBytes[2])}" +

$"{Convert.ToChar(evDataBytes[3])}{Convert.ToChar(evDataBytes[4])}";

char exponentChar = Convert.ToChar(evDataBytes[5]);

// 7. 解析符号

if (sign != '+' && sign != '-' && sign != '=')

throw new ArgumentException($"无效的符号字符: {sign}");

// 8. 解析数值

if (!int.TryParse(valueStr, out int value))

throw new ArgumentException($"无效的数值: {valueStr}");

// 9. 解析指数

if (!int.TryParse(exponentChar.ToString(), out int exponentCode))

throw new ArgumentException($"无效的指数字符: {exponentChar}");

// 指数映射表 (根据协议第34页)

// '0'->10^-4, '1'->10^-3, '2'->10^-2, '3'->10^-1,

// '4'->10^0, '5'->10^1, '6'->10^2, '7'->10^3, '8'->10^4, '9'->10^5

double exponent = Math.Pow(10, exponentCode - 4);

// 10. 计算照度值

double illuminance = value * exponent;

// 如果符号为负,取负值

if (sign == '-')

illuminance = -illuminance;

// '=' 表示 ±,通常为正,这里按正数处理

return illuminance;

}

完整代码如下:

cs 复制代码
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
using System.Threading;
using System.IO;
using System.IO.Ports;

namespace zhaoduji
{
    public partial class Form1 : Form
    {
        static int count = 1;
        RS485 rs485LuminanceMeter;
        // 命令序列
        private readonly byte[] STX = new byte[] { 0x02 };
        private readonly byte[] DELIMITER = new byte[] { 0x0D, 0x0A };

        // 1. 设置PC连接模式(命令54)
        private readonly byte[] setPCModeCmd = new byte[] {
            0x30, 0x30, 0x35, 0x34,  // "0054"
            0x31, 0x20, 0x20, 0x20,  // "1   "
            0x03  // ETX
        };

        // 2. 设置Hold状态(命令55)
        private readonly byte[] setHoldStatusCmd = new byte[] {
            0x39, 0x39, 0x35, 0x35,  // "9955"
            0x31, 0x20, 0x20, 0x30,  // "1  0"
            0x03  // ETX
        };

        // 3. 设置EXT模式(命令40)
        private readonly byte[] setEXTModeCmd = new byte[] {
            0x30, 0x30, 0x34, 0x30,  // "0040"
            0x31, 0x30, 0x20, 0x20,  // "10  "
            0x03  // ETX
        };

        // 4. 触发测量(命令40,参数不同)
        private readonly byte[] triggerMeasurementCmd = new byte[] {
            0x39, 0x39, 0x34, 0x30,  // "9940"
            0x32, 0x31, 0x20, 0x20,  // "21  "
            0x03  // ETX
        };

        // 5. 读取Ev、x、y数据(命令02)
        private readonly byte[] readDataCmd = new byte[] {
            0x30, 0x30, 0x30, 0x32,  // "0002"
            0x31, 0x32, 0x30, 0x30,  // "1200" (CF禁用,校准模式NORM)
            0x03  // ETX
        };

        public Form1()
        {
            InitializeComponent();
            loadzhaoduji();
        }

        void loadzhaoduji()
        {
            try
            {
                rs485LuminanceMeter = new RS485("COM5", 9600);
                rs485LuminanceMeter.serialPort.DataBits = 7;
                rs485LuminanceMeter.serialPort.Parity = Parity.Even;
                rs485LuminanceMeter.serialPort.StopBits = StopBits.One;

                // 发送初始化命令序列
                textBox2.AppendText("开始初始化CL-200A..." + "\r\n");

                // 1. 设置PC连接模式
                textBox2.AppendText("1. 设置PC连接模式..." + "\r\n");
                byte[] cmd1 = rs485LuminanceMeter.SendCommand485BCC(STX, setPCModeCmd, DELIMITER);
                Thread.Sleep(500);

                // 等待并检查响应
                List<byte> response1 = rs485LuminanceMeter.ReadIllData();
                if (response1.Count > 0)
                {
                    string hexResponse = rs485LuminanceMeter.ByteListToStr(response1);
                    textBox2.AppendText("  响应: " + hexResponse + "\r\n");
                }

                // 2. 设置Hold状态
                textBox2.AppendText("2. 设置Hold状态..." + "\r\n");
                byte[] cmd2 = rs485LuminanceMeter.SendCommand485BCC(STX, setHoldStatusCmd, DELIMITER);
                Thread.Sleep(500);

                // 3. 设置EXT模式
                textBox2.AppendText("3. 设置EXT模式..." + "\r\n");
                byte[] cmd3 = rs485LuminanceMeter.SendCommand485BCC(STX, setEXTModeCmd, DELIMITER);
                Thread.Sleep(175);

                // 等待并检查响应
                List<byte> response3 = rs485LuminanceMeter.ReadIllData();
                if (response3.Count > 0)
                {
                    string hexResponse = rs485LuminanceMeter.ByteListToStr(response3);
                    textBox2.AppendText("  响应: " + hexResponse + "\r\n");
                }

                textBox2.AppendText("照度计初始化完成!" + "\r\n");
            }
            catch (Exception ex)
            {
                textBox2.AppendText("照度计初始化失败: " + ex.Message + "\r\n");
            }
        }

        static double ParseIlluminanceFromMessage(string hexMessage)
        {
            // 1. 去除空格,转换为字节数组
            string[] hexBytes = hexMessage.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
            byte[] messageBytes = hexBytes.Select(h => Convert.ToByte(h, 16)).ToArray();

            // 2. 验证基本格式
            if (messageBytes.Length < 9)
                throw new ArgumentException("报文太短,不符合CL-200A协议格式");

            // 检查STX (0x02)
            if (messageBytes[0] != 0x02)
                throw new ArgumentException("无效的STX字节");

            // 查找ETX (0x03)的位置
            int etxIndex = -1;
            for (int i = 1; i < messageBytes.Length; i++)
            {
                if (messageBytes[i] == 0x03)
                {
                    etxIndex = i;
                    break;
                }
            }

            if (etxIndex == -1)
                throw new ArgumentException("未找到ETX字节");

            // 3. 解析报文各部分
            // 受体头号 (2字节): messageBytes[1..2]
            // 命令 (2字节): messageBytes[3..4]
            string command = $"{Convert.ToChar(messageBytes[3])}{Convert.ToChar(messageBytes[4])}";

            // 状态 (4字节): messageBytes[5..8]
            // 检查是否为命令02的响应
            if (command != "02")
                throw new ArgumentException($"不是命令02的响应 (收到命令: {command})");

            // 4. 检查是否为长格式报文 (命令02使用长格式)
            // 长格式: STX + 头号(2) + 命令(2) + 状态(4) + 数据(6*3=18) + ETX + BCC(2) + CR + LF
            int expectedLength = 1 + 2 + 2 + 4 + 18 + 1 + 2 + 1 + 1; // 32字节
            if (messageBytes.Length < expectedLength)
                throw new ArgumentException($"报文长度不足,长格式应至少{expectedLength}字节");

            // 5. 提取Ev数据 (第一个6字节块)
            // 数据起始位置: 跳过STX(1) + 头号(2) + 命令(2) + 状态(4) = 9字节
            int dataStartIndex = 9;

            // Ev数据块: 6个字节
            byte[] evDataBytes = new byte[6];
            Array.Copy(messageBytes, dataStartIndex, evDataBytes, 0, 6);

            // 6. 解析Ev值
            // 格式: 符号(1字节) + 4位数值(4字节) + 指数(1字节)
            char sign = Convert.ToChar(evDataBytes[0]);
            string valueStr = $"{Convert.ToChar(evDataBytes[1])}{Convert.ToChar(evDataBytes[2])}" +
                             $"{Convert.ToChar(evDataBytes[3])}{Convert.ToChar(evDataBytes[4])}";
            char exponentChar = Convert.ToChar(evDataBytes[5]);

            // 7. 解析符号
            if (sign != '+' && sign != '-' && sign != '=')
                throw new ArgumentException($"无效的符号字符: {sign}");

            // 8. 解析数值
            if (!int.TryParse(valueStr, out int value))
                throw new ArgumentException($"无效的数值: {valueStr}");

            // 9. 解析指数
            if (!int.TryParse(exponentChar.ToString(), out int exponentCode))
                throw new ArgumentException($"无效的指数字符: {exponentChar}");

            // 指数映射表 (根据协议第34页)
            // '0'->10^-4, '1'->10^-3, '2'->10^-2, '3'->10^-1, 
            // '4'->10^0, '5'->10^1, '6'->10^2, '7'->10^3, '8'->10^4, '9'->10^5
            double exponent = Math.Pow(10, exponentCode - 4);

            // 10. 计算照度值
            double illuminance = value * exponent;

            // 如果符号为负,取负值
            if (sign == '-')
                illuminance = -illuminance;

            // '=' 表示 ±,通常为正,这里按正数处理
            return illuminance;
        }

        // 用于从字节列表中解析照度值的辅助方法
        private double ParseIlluminanceFromBytes(List<byte> byteList)
        {
            if (byteList == null || byteList.Count < 32)
            {
                throw new ArgumentException("接收到的数据长度不足");
            }

            // 将字节列表转换为十六进制字符串
            string hexMessage = "";
            for (int i = 0; i < byteList.Count; i++)
            {
                hexMessage += byteList[i].ToString("X2");
                if (i < byteList.Count - 1)
                    hexMessage += " ";
            }

            return ParseIlluminanceFromMessage(hexMessage);
        }

        private bool AdjustMeasurementRange()
        {
            try
            {
                textBox2.AppendText("检查并调整测量范围..." + "\r\n");

                // 重新设置EXT模式
                byte[] cmdExt = rs485LuminanceMeter.SendCommand485BCC(STX, setEXTModeCmd, DELIMITER);
                Thread.Sleep(175);

                //触发一次测量
                byte[] cmdTrigger = rs485LuminanceMeter.SendCommand485BCC(STX, triggerMeasurementCmd, DELIMITER);
                Thread.Sleep(500);

                return true;
            }
            catch
            {
                return false;
            }
        }

        private void button_get_Click(object sender, EventArgs e)
        {
            try
            {
                if (rs485LuminanceMeter == null || !rs485LuminanceMeter.serialPort.IsOpen)
                {
                    MessageBox.Show("照度计未连接!");
                    return;
                }

                textBox2.AppendText($"第{count}次测量..." + "\r\n");
                count++;

                // 先调整测量范围
                AdjustMeasurementRange();

                // 然后进行正常测量
                byte[] cmd4 = rs485LuminanceMeter.SendCommand485BCC(STX, triggerMeasurementCmd, DELIMITER);
                Thread.Sleep(500);

                byte[] cmd5 = rs485LuminanceMeter.SendCommand485BCC(STX, readDataCmd, DELIMITER);
                Thread.Sleep(500);

                List<byte> response = rs485LuminanceMeter.ReadIllData();

                if (response.Count > 0)
                {
                    string hexResponse = rs485LuminanceMeter.ByteListToStr(response);
                    textBox2.AppendText("接收到的原始数据: " + hexResponse + "\r\n");

                    try
                    {
                        double illuminance = ParseIlluminanceFromBytes(response);
                        textBox2.AppendText($"照度值 (Ev): {illuminance:F2} lx" + "\r\n");

                        // 检查并显示状态信息
                        if (response.Count >= 8)
                        {
                            byte errByte = response[5]; // ERR字节
                            byte rngByte = response[7]; // RNG字节
                            byte baByte = response[8];  // BA字节

                            char errChar = Convert.ToChar(errByte);
                            char rngChar = Convert.ToChar(rngByte);
                            char baChar = Convert.ToChar(baByte);

                            textBox2.AppendText($"状态: ERR={errChar}, RNG={rngChar}, BA={baChar}" + "\r\n");
                        }
                    }
                    catch (Exception ex)
                    {
                        textBox2.AppendText("解析照度值失败: " + ex.Message + "\r\n");
                    }
                }
                else
                {
                    textBox2.AppendText("未收到响应数据!" + "\r\n");
                }
            }
            catch (Exception ex)
            {
                MessageBox.Show("通讯或解析失败:" + ex.Message);
            }
        }


    }
}
cs 复制代码
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.IO.Ports;
using System.Threading;

namespace zhaoduji
{
    public class RS485
    {
   
        List<byte> buffer = new List<byte>();
        public SerialPort serialPort = null;
        public RS485(string comNum, int baudRate)
        {
            if (serialPort == null)
            {
                serialPort = new SerialPort();
                serialPort.BaudRate = baudRate;
                serialPort.DataBits = 8;
                serialPort.Parity = Parity.None;
                serialPort.PortName = comNum;
                serialPort.StopBits = StopBits.One;
                serialPort.ReadBufferSize = 1024000;
                serialPort.WriteBufferSize = 1024000;
                serialPort.ErrorReceived += new SerialErrorReceivedEventHandler(serialPort_ErrorReceived);
                serialPort.DataReceived += new SerialDataReceivedEventHandler(serialPort_DataReceived);
                serialPort.Open();
            }
        }


        void serialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
        {
            int n = serialPort.BytesToRead;
            byte[] readByte = new byte[n];
            int data = serialPort.Read(readByte, 0, n);
            buffer.AddRange(readByte);
            //while (serialPort.BytesToRead > 0)
            //{
            //   int data =  serialPort.ReadByte();
            //   buffer.Add((byte)data);
            //}
        }
        void serialPort_ErrorReceived(object sender, SerialErrorReceivedEventArgs e)
        {
            Console.WriteLine("ErrorReceived:" + e.EventType.ToString());

        }

       
        public byte Get_CheckXorBCC(byte[] data)
        {
            byte CheckCode = 0;
            int len = data.Length;
            for (int i = 0; i < len; i++)
            {
                CheckCode ^= data[i];
            }
            return CheckCode;
        }

        public byte[] SendCommand485BCC(byte[] STX, byte[] sendData, byte[] DELIMITER)
        {
            buffer.Clear();

            byte BCC = Get_CheckXorBCC(sendData);

            int byteHigh = (BCC & 0xf0) >> 4;
            int byteLow = BCC & 0x0f;

            byte high;
            if (byteHigh > 9)
            {
                high = (byte)(byteHigh + 55);
            }
            else
            {
                high = (byte)(byteHigh + 48);
            }

            byte low;
            if (byteLow > 9)
            {
                low = (byte)(byteLow + 55);
            }
            else
            {
                low = (byte)(byteLow + 48);
            }

            byte[] ARRay = new byte[] { };

            byte[] BCCBCC = new byte[] { high, low };

            //Array = ARRay.Concat(STX, sendData, high, low, DELIMITER);
            ARRay = ARRay.Concat(STX).ToArray();
            ARRay = ARRay.Concat(sendData).ToArray();
            ARRay = ARRay.Concat(BCCBCC).ToArray();
            ARRay = ARRay.Concat(DELIMITER).ToArray();

            //byte s = (byte)high;


            if (serialPort != null)
            {
                serialPort.DiscardInBuffer();

                //比如那一串报文是0x0102012C000239FE
                //我就要设置输入的内容为new byte[] { 0x01, 0x02, 0x01, 0x2c, 0x00, 0x02 }
                //newbuffer 里面的内容为{1,2,1,44,0,2,57,254}//最后两位为自动生成的校验码
                serialPort.Write(ARRay, 0, ARRay.Length);
            }
            return ARRay;



        }

     
        public List<byte> ReadIllData()
        {
            List<byte> getBuffer = new List<byte>();
            int readcount = 0;
            int timeout = 100; // 最大等待次数

            // 等待直到收到完整数据或超时
            while (readcount < timeout)
            {
                readcount++;

                // 检查是否有数据
                if (buffer.Count > 0)
                {
                    // 检查是否收到结束符0x0A
                    int endIndex = buffer.IndexOf(0x0A);
                    if (endIndex >= 0)
                    {
                        // 找到结束符,提取完整报文
                        for (int i = 0; i <= endIndex; i++)
                        {
                            getBuffer.Add(buffer[i]);
                        }
                        // 从buffer中移除已处理的数据
                        buffer.RemoveRange(0, endIndex + 1);
                        break;
                    }
                }

                Thread.Sleep(10); // 每次等待10ms
            }

            // 如果超时,但buffer中有数据,也返回
            if (getBuffer.Count == 0 && buffer.Count > 0)
            {
                getBuffer = new List<byte>(buffer);
                buffer.Clear();
            }

            // 清空串口缓冲区
            if (serialPort != null)
                serialPort.DiscardInBuffer();

            return getBuffer;
        }


        public string ByteListToStr(List<byte> aa)
        {
            string sss = "";
            if (aa.Count > 0)
            {
                for (int i = 0; i < aa.Count; i++)
                {
                    string asa = aa[i].ToString("x");
                    sss += asa + " ";
                }
            }
            return sss;
        }
       
      


    }
}

运行:

相关推荐
贾修行2 小时前
.NET 全栈开发学习路线:从入门到分布式
c#·.net·wpf·asp.net core·web api·winforms·services
无风听海2 小时前
C# 中的 LinkedList
开发语言·c#
chen_2273 小时前
kanzi节点转换插件
c#·kanzi
PfCoder3 小时前
WinForm真入门(22)---定时器控件System.Windows.Forms.Timer
windows·c#·.net·timer
蜡笔小新拯救世界3 小时前
简单rce的ctf题目绕过
linux·c++·web安全·c#
leo__52012 小时前
C#与三菱PLC串口通信源码实现(基于MC协议)
开发语言·c#
墨瑾轩14 小时前
C# PictureBox:5个技巧,从“普通控件“到“图像大师“的蜕变!
开发语言·c#·swift
墨瑾轩14 小时前
WinForm PictureBox控件:3个让图片“活“起来的骚操作,90%的开发者都踩过坑!
开发语言·c#
Jackson@ML18 小时前
2026最新版Visual Studio安装使用指南
ide·c#·visual studio