使用C#编写WinForm实现上位机功能

前言

相关代码在github和gitee上放出
gitee的地址:点此
GitHub的地址:点此
起名是fruit,是因为以前入门c#的时候第一个学的例子就是fruit命名的。
现在程序中fruit工程对应的是winform版本,目前winform版本基本完成;ocean工程对应的是wpf版本,由于学业紧张,wpf版本的迁移初步完成,因学业考虑暂停,相关的介绍在另一篇博文(点此)。
这是一个串口通讯软件,基础功能是读取数据存入数据库,同时将数据发给下层,修改下层的扩展变量。附加功能是对针对微网控制,传送的数据进行数据处理,采用过粒子群等算法,期间经历多次改版。
设计它的原因是,在嵌入式开发或是其他学术研究时,有一个自己设计的上位机可以方便自己的研究,比如嵌入想要的算法,或是发送特定格式的通讯数据。

设计思路

首先,一个好的上位机,交互界面也应当尽可能友好,由于以前学过c#,加上它和c语言接近,平时开发也以c语言为主,因此选择了c#作为设计语言。目前能力有限,主要使用的是winform作上位机界面,做的较为简陋,正在考虑改用wpf或者qt重新设计界面,但是时间紧张。
其次,之所以我设计的是上位机,而不是称为串口调试助手,我想是因为软件做到了以下几点:
1.有一个相对完整的通信协议在执行,这里我的上位机有我们团队的协议FE,和modbus协议;
2.有数据字典在内,这里使用的是access数据库,记录了每一个通信变量对应的ID、值、单位等,虽然简单,但是可以起到一一对应的作用;
因此,这样的上位机,用于调试的不止是串口通信,还可以对通信协议进行调试,与最终产品使用的软件可以独立开来,专用于调试自己的通信模块。
另外,如果有可能,还应把考虑的算法处理部分也加入这里进行测试,这样开发终端用户软件的人,就只需要设计漂亮的UI界面,你的调试用的上位机,可以测试完成大部分内容。

开发环境配置

这里上位机用微软的Visual Studio开发而成,该软件可以通过微软的在线安装工具来安装,一般选择VS社区版就可以满足大部分的开发需要,只是需要注册微软账户来激活开发环境。
前期采用VS2017,后面微软出了新版本的VS我就更换,升级的时候顺带升级.net框架,安装配置过程差不多的。

开发组件只需要选择.net桌面开发即可,C++那个只要C#没必要选。

这里提下VS的编辑器里面多行注释。注释时候先CTRL+K,然后CTRL+C;而取消注释: 先CTRL+K,然后CTRL+U

上位机界面设计

目前,winform技术设计出的调试界面是:

按钮button控件

界面设计中,最常用的控件是button和timer

对于任何一个控件,其控制分为了属性和事件两部分,点击任意一个控件,右下方就会出现属性管理面板。
button是按钮的意思,该控件与上位机的操作使用十分相关。对于button的属性管理面板如下

双击控件,可以进入到button的事件函数。

假如在该函数里面写上button1.Text=“hello”,那么双击button1时,button1的Text这个属性就会发生修改。

Text里面只是那个框里的显示内容,用来显示在外的函数改好了

点那个button,其名字就会发生变化。

button1有其他属性的修改是一样的写法,如修改button1的颜色,只需假如如下的语句

同理,当修改button1的大小时,只需要对其Width和Height属性进行修改。

定时器timer控件

这一控件的作用是起到定时作用,点中timer4,可以观测其属性。根据属性面板可以看到timer的计时以毫秒为单位,也就是其最短间隔时间为1ms。

双击timer1,可以进入其对应的事件函数。这一事件函数将在定时到达1ms后发生。算法的编写主要在这个事件中完成。

  private void timer1_Tick(object sender, EventArgs e)
  {
     function();   
  }

界面窗口设计

由于是winform架构,所有的控件都需要放在窗口的控件上。
如想要增加一个按钮,只需要拖拽左侧的button到右侧的窗口上即可。这一部分是上位机设计最简单部分。
我的上位机主要采用了这几个按钮:
串口设置,用于配置串口的信息。
打开串口:用以打开串口。
数据核验:用以和下位机的参数进行核对校验。
运行:发送启动命令。

串口设置的按钮在点击后,产生一个窗口来配置串口。

对应代码可以这么写:

        private void button1_Click(object sender, EventArgs e)
        {
            string[] ports = SerialPort.GetPortNames();
            if (ports.Length != 0)
            {
                Form2 f2 = new Form2();
                f2.Show();
            }
            else
            {
                MessageBox.Show("请插入串口设备!!");
            }
        }

利用产生一个new form的建立新的窗口,这里要考虑的问题是新的窗口如何将数据写入原本的窗口的串口之中。
曾经考虑的写法是新建一个cs文件,定义一个公共类的串口,然后两个文件都可以调用。但是这样串口的属性事件配置不方便。
考虑用set函数,但是这是已经有的对象,不适合这么写。
于是窗口2中,我考虑让它直接寻找到form1

            Form1 f1 = null;
            Parity Ser_Parity;
            foreach (Form1 f in Application.OpenForms)
            {
                if (f.Name == "Form1")
                {
                    f1 = f;
                    break;
                }
            }

这样,f1就和form1绑定起来,在form2就可以直接操作form1的串口

            f1.Serial_Port = comboBox1.SelectedItem.ToString();
            f1.Serial_Baud = Convert.ToInt32(comboBox2.SelectedItem.ToString());

其余几个按键,如打开串口,只是一个事件

        private void button2_Click(object sender, EventArgs e)
        {

            if (!sopen)
            {
                try
                {
                    serialPort1.Open();
                    button2.Text = "关闭串口";
                    button3.Enabled = true;
                    button1.Enabled = false;
                    sopen = true;

                }
                catch
                {
                    MessageBox.Show("请插入串口设备!!");
                    sopen = false;
                }
                
            }
            else
            {
                serialPort1.Close();
                button2.Text = "打开串口";
                button3.Enabled = false;
                button1.Enabled = true;
                sopen = false;               
            }

        }

这里设计上用了很多try-catch,这样有助于处理异常,如串口不存在等问题。

通信协议

实现通信协议时,需要根据通信的载体,如串口、CAN、以太网和协议的本身来编写对应程序。

串口接收

上位机的编程方式为面向事件编程,类似于dsp、单片机的中断机制,任何状态的改变,都会触发一个事件,比如串口接到数据,就会产生一个数据接收的事件。每个事件都有对应的hook函数,类似中断函数,可以编写相应的程序。当然需要注意的是,事件信号不等于中断信号,因为中断发生了以后,只能执行中断函数,而事件触发后,未必会只进行该事件的hook函数,因为CPU是多核的,可以并行执行的,因此,在程序中设置断点看到的结果,会与连续运行的有区别。
由于需要串口通讯,设计上需要拖入串口的控件,这里用的是

上位机的串口数据解析函数就是在串口接收事件的函数中编写完成的。
对应的事件是

根据所制定的通讯协议标准完成,modbus的实现可以查看博文《Modbus协议学习笔记

        private void SerialPort1_DataReceived(object sender, SerialDataReceivedEventArgs e)
        {
            Thread.Sleep(10);
            //该事件函数在新的线程执行
            //没使用数据绑定前,此代码不可注释
            Control.CheckForIllegalCrossThreadCalls = false;
            //throw new NotImplementedException();
            
            byte[] buffer = new byte[80];
            int i = 0;
            int j = 0;
            
            string str = "";
            int n_dsp = 0;
            int check_result=0;

            try
            {
                j = serialPort1.Read(buffer, 0, buffer.Length);
            }
            catch
            {
                return;
            }

            for (i = 0; i < j; i++)
            {
                str += Convert.ToString(buffer[i], 16) + ' ';
            }
            str += '\r';
            //richTextBox1.Text += str;
            model.Name += str;


            if (j < buffer[4] + 5) //数据区尚未接收完整
            {
                return;
            }

            check_result=NYS_com.monitor_check(buffer);
            function(check_result);

        }

其中

Control.CheckForIllegalCrossThreadCalls = false;

主要是因为这个接收事件开辟了新的线程,要调用原本界面线程上定义的变量,会报错,所以加入这句话。
后面的语句主要是对于通讯协议的校验,这些函数取决于你设计的或者参照的通讯协议。

下位机数据更新策略

以DSP28335作为下位机为例。
通常,更新数据的办法是建立一个数组,然后把相应的变量定时赋值给该数组。
如下面的程序在频率为5K的中断中执行

n_pso++
if(n_pso==250)
{
	n_pso=0;
	function();
}

对于1和2两台逆变器来讲,更新数据的频率就是50ms一个周期,那么时序图如下:

这一更新策略,在计算好所有消耗时间的情况下是大致可行的,但是如果一旦采样频率等发生改变,那么相应的时间需要重新计算。
为此,新的更新策略是直接将数据和数组进行绑定,例如,准备传递变量Idnout,对应数组pso_t[0]。则:

#define Idnout pso_t[0]

这一更新策略下,无需单独赋值给数组,下位机侧的更新速度达到最快,理论上最快的刷新频率等于采样频率。
且该策略下,可以减少DSP变量的定义数量,对于减少DSP的内存使用具有一定的帮助。
当然,对于结构体成员,不适合进行这样的操作时,需要单独赋值。

上位机对下位机请求数据


这一控件在以1ms为基准,间隔触发定时器的事件时,由于该控件的事件的处理函数是在程序主线程上执行的,因此时间可能会不准。
最直接的例子是算法计数为50ms时,期望是50ms的周期读取一次下位机的数据,然而测量显示:

因此,为保证通讯的时序,且暂时无法使用多线程模式的情况下,将定时器的时间间隔调整

调整间隔时间后,对计数值也修改为4,目标是60ms发送一次,实际的显示时间也变为了约64ms一次。

以下是几个通讯的传输时间测试:
485的字节传输时间
传输7个浮点数,115200波特率

上位机发送间隔
定时器采用的计数控件timer3的间隔为1ms,,计数到55重发,发送间隔为800ms


上位机发送间隔
定时器采用的计数控件timer3的间隔为15ms,,计数到6重发,发送间隔为90ms


上位机发送间隔
定时器采用的计数控件timer3的间隔为15ms,,计数到4重发,发送间隔为40ms

这边定时器计数最准确的时间是15ms为单位,可相对准确的计时。这里后期考虑改进。

算法处理

对于数据解析完成后,需要考虑进行算法的执行。这里算法的调用,早期采用的是定时器模式实现,后面改为了多线程来实现。

定时器模式

在设置的软件定时器里面,当计数到一定值后,产生相应的事件,在该事件中编写完成相应的算法程序。
首先,预计算法程序是执行一次可控制在4ms内。开启了定时器4

对应的事件为timer4_Tick事件,如果以后想更换算法,那么只需要修改该事件的程序。

        private void timer4_Tick(object sender, EventArgs e)
        {
        	if(flag)
        	{
        		flag=false;
        		function()
        	}
        }

整个程序是基于控件实现的,一段程序的触发也是通过对界面上控件的响应来实现的,因此,这里选择了使用定时器4,间隔4ms触发一次算法对应的程序。为防止算法程序在没有接收到两台逆变器的数据时就执行,对逆变器编号进行了判断,只有当接收到2号机的数据后,才会进行算法处理。
这样的话,假设数据刚好接收完毕,算法程序所在的定时器中断也刚好发生过一次,则算法可以立刻进行。相反,若刚好定时器的计数从零开始,那么算法相隔4ms后就会进行,相当于延迟了4ms。
当然,这一机制除了存在延时4ms的问题外,还存在的问题是CPU在没有接收到数据时,4ms进一次事件处理函数,所进行的是无意义的判断。
为此,改进的程序是在接收完两个DSP数据后,使能定时器,从而4ms后开始定时器的处理函数。
但是这样就造成了每次接收完成数据后,都要有一定延迟后才会进行算法。
因此,根本上的解决办法是,在数据判断接收完成后,直接触发算法的对应函数。从程序来看,算法本身无需使用定时器等控件的响应事件,因此,考虑的是开辟一个线程,该线程中执行算法程序,最后算法执行完毕后,该线程被回收,同时程序也执行完毕。
由于执行算法的线程是独立的,这样执行程序的过程中,通讯和界面程序不受影响。
从这一点引申出来,如果把界面显示和数据库操作、通讯等阻塞型I/O操作程序分离出来,建立相应的独立线程,那么上位机的软件设计能够更好地协调这些工作,并且这样可以将程序模块化,有利于后期程序的维护升级。

多线程实现

实际上,完全可以开辟一个新的线程来执行,无需利用定时器,以免产生额外的调用消耗。
Thread类可以帮助处理这个问题。

                if (flag)
                {
                	flag=false;
                    Thread th = new Thread(new ThreadStart(PSO_v.cale_pso)); //创建PSO线程
                    th.Start(); //启动线程                          
                    Thread th1 = new Thread(new ThreadStart(update_UI_PSO)); //创建UI线程
                    th1.Start(); //启动线程
                  

                }

建立新的线程时,将要执行的算法封装为一个函数function,然后用来初始化线程

Thread th = new Thread(new ThreadStart(function)); //创建PSO线程

之后只需要start就行了。

通讯周期的计算

程序向上位机上传的各种信息,波特率为115200.115200/10为1s能传送的字节数,再除以1000为1ms能传送的字节数为11.52.
提高波特率后的流程如下图所示。通过计算,上位机发送和接收数据的时间都得到了大幅度的提高,考虑裕量和传送需要的时间,设定数据的请求周期为50ms,数据上下发送的顺序等维持之前的状态不变。

数据词典-数据库实现

数据库部分则是采用了微软的access数据库开发,学校一般会购买office套件,含有该数据库。其他的数据库不太熟悉,以后有机会改用。
数据库连接方式,参照的是文章《C#连接Access数据库(详解)》学习。
采用的是SQL语言进行数据的存储、读取。该部分程序在上位机参数中处于初始化的作用。比如上位机起动时的调试参数,是读取自mdb文件,mdb文件为微软的数据库文件类型。同时,每次实验中,修改过调试参数后,该部分参数会被存入到数据库之中,下次重新打开上位机时,会载入最后一次调试的设定参数。



该部分程序执行的位置是窗口控件Form1的Form1_Load事件,该事件中,可以执行许多初始化的程序。

比如,我考虑用mdb的文件数据读取后,存入dataGridView控件。

我将数据库相关函数封装于类对象DB_Com之中,然后调用相关函数。

        private void Form1_Load(object sender, EventArgs e)
        {

            //基本存储、参数设置初始化
            try
            {
                DB_Com.DataBase_PARAMETER_RUN_Init();


                for (int i = DB_Com.u; i <= DB_Com.j; i++)
                {
                    int index = this.dataGridView1.Rows.Add();
                    this.dataGridView1.Rows[index].Cells[0].Value = DB_Com.data[i].SN;
                    this.dataGridView1.Rows[index].Cells[1].Value = DB_Com.data[i].NAME;
                    this.dataGridView1.Rows[index].Cells[2].Value = DB_Com.data[i].VALUE;
                    if (DB_Com.data[i].FACTOR > 0.001)
                    {
                        //dataGridView1.Rows[index].Cells[3].Value = format('%.2f',[datas[j].FACTOR]) + data[j].UNITor;      //名称
                        dataGridView1.Rows[index].Cells[3].Value = Math.Round(DB_Com.data[i].FACTOR, 2) + DB_Com.data[i].UNITor;
                    }
                    else
                    {
                        dataGridView1.Rows[index].Cells[3].Value = DB_Com.data[i].UNITor;
                    }
                    DB_Com.runnum++;
                }

                DB_Com.DataBase_PARAMETER_SET_Init();

                for (int i = DB_Com.u; i <=DB_Com.j; i++)
                {
                    if (DB_Com.data[i].SN != 0)
                    {
                        int index = this.dataGridView2.Rows.Add();
                        this.dataGridView2.Rows[index].Cells[0].Value = DB_Com.data[i].SN;
                        this.dataGridView2.Rows[index].Cells[1].Value = DB_Com.data[i].NAME;
                        this.dataGridView2.Rows[index].Cells[2].Value = DB_Com.data[i].VALUE;
                        if (DB_Com.data[i].FACTOR > 0.001)
                        {

                            //dataGridView2.Rows[index].Cells[3].Value = format('%.2f',[datas[j].FACTOR]) + data[j].UNITor;      //名称
                            dataGridView2.Rows[index].Cells[3].Value = Math.Round(DB_Com.data[i].FACTOR, 2) + DB_Com.data[i].UNITor;
                        }
                        else
                        {
                            dataGridView2.Rows[index].Cells[3].Value = DB_Com.data[i].UNITor;
                        }
                    }
                }


                DB_Com.DataBase_PARAMETER_FACTOR_Init();

                for (int i = DB_Com.u; i < DB_Com.j; i++)
                {
                    int index = this.dataGridView3.Rows.Add();
                    this.dataGridView3.Rows[index].Cells[0].Value = DB_Com.data[i].SN;
                    this.dataGridView3.Rows[index].Cells[1].Value = DB_Com.data[i].NAME;
                    this.dataGridView3.Rows[index].Cells[2].Value = DB_Com.data[i].VALUE;
                }


                DB_Com.DataBase_ERROR_Table_Init();

            }
            catch
            {
                MessageBox.Show("缺少mdb");
            }
  }

而数据库部分的连接,具体实现时,采用是SQL语言和Ole组件连接,这种方式后期考虑改进。

        public void DataBase_PARAMETER_RUN_Init()
        {

            //OleDbConnection conn = new OleDbConnection("Provider=Microsoft.Jet.OLEDB.4.0;Data Source=MOON.mdb"); //Jet OLEDB:Database Password
            //OleDbCommand cmd = conn.CreateCommand();
            
            conn = new OleDbConnection("Provider=Microsoft.Jet.OLEDB.4.0;Data Source=MOON.mdb"); //Jet OLEDB:Database Password
            cmd = conn.CreateCommand();

            cmd.CommandText = "select * from PARAMETER_RUN";
            conn.Open();
            u = j;
            //OleDbDataReader dr = cmd.ExecuteReader();
            dr = cmd.ExecuteReader();
            while (dr.Read())
            {
                j = dr.GetInt32(dr.GetOrdinal("SN"));
                data[j].SN = j;
                try
                {
                    data[j].NAME = dr.GetString(dr.GetOrdinal("NAME"));
                }
                catch
                {
                    data[j].NAME = "";
                }
                data[j].LENG = dr.GetInt32(dr.GetOrdinal("leng"));
                try
                {
                    //data[j].VALUE = dr.GetInt16(dr.GetOrdinal("value"));
                    data[j].VALUE = dr.GetFloat(dr.GetOrdinal("value"));
                }
                catch
                {
                    data[j].VALUE = 0;
                }
                data[j].COMMAND = dr.GetInt32(dr.GetOrdinal("COMMAND"));
                data[j].NO = dr.GetInt32(dr.GetOrdinal("NO"));
                try
                {
                    data[j].UNITor = dr.GetString(dr.GetOrdinal("unit"));
                }
                catch
                {
                    data[j].UNITor = "";
                }
                try
                {
                    data[j].FACTOR = dr.GetFloat(dr.GetOrdinal("factor"));
                }
                catch
                {
                    data[j].FACTOR = 0;
                }
            }

            cmd.Dispose();
            conn.Close();
        }
物联沃分享整理
物联沃-IOTWORD物联网 » 使用C#编写WinForm实现上位机功能

发表评论