0%

c# 串口调试助手

因工作需求 , 暂时从java web 转 c# 开发了, 2333.

花了两三天看了下c#的基础语法 , 感觉和java差别不是很大. 然后接到了第一份需求 ,需要用winform写一个串口通信的程序。

这可难倒我了,虽然感觉上c# 和 java差别不大,但是我用c#来写的程序是我用java从来没写过的呀 , 这就相当于我得重新学一遍了。

由于串口学习上 ,需要两端开启串口来调试收发数据 , 这里找我师傅要了一个还不错的串口调试助手,但是发现这个串口调试助手 , 不知道为什么 , 扫描不出来我添加的虚拟串口(在我自己的本子上扫描不出来 , 在公司的电脑没有问题) , 这有点气人 。 本着学习的态度 , 想着直接写一个自用的串口调试助手好了 , 也当串口通信的练手Demo好了。

下面把编写时碰到的一些需要注意的地方给讲一下 , 也当作给自己巩固一下

串口

先把串口通信的一些基础给讲一下

一个串口对象 ,必须有的几个属性 :端口号波特率,数据位,停止位,校验位

如果不知道这些是什么 , 可以自己打开设备管理器查看 , 有串口的电脑可以查看到当前串口的一些属性

有设置过这些属性后 , 端口才能进行正常的数据传输功能 , 当然也只能进行数据的发送:
数据的发送比较简单 , 直接调用串口对象的Write()方法即可 , Write方法有多个重载方法

1
2
3
4
5
6
7
8
9
// 1. Write(String data);
// 传参为一个字符串 , 比较适用于16进制字符串传输或不带中文的字符串传输
// 我在测试中发现如果带上中文 , 那么接收到的一定是 ?
port.Write(data);

// 2. Write(byte[] data, int start, int count);
// 传参为字节数组, 传输字节数组起始位置, 传输长度
// 一般用于带中文的传输吧 , 当然其他也可以用 , 这种比较通用
port.Write(data, start, count);

如果需要接收数据的话 , 我们还需要给串口对象挂载上数据接收事件, 在c# 中串口对象有一个DataREceived属性用来挂载数据接收事件的

1
port.DataReceived += new SerialDataReceivedEventHandler(functionName);

ps :不要给这个属性添加多个(多个指>1)触发事件 , 不然 , 当串口接收数据后 , 所有的挂载事件都会被触发一遍

(是的没错 , 接收一条消息触发了四次,主要原因是我在接收数据前测试串口开关时候正常 , 然后开关了四次 , 这个事件添加我写在了串口初始化里面…).

当然 ,如果没有在触发事件中调用窗口控件同步的委托的话 , 还没有啥太大的问题(前提是你的挂载事件中没有对线程 ,数据进行预处理)

我在这个串口调试助手的初期编写阶段 ,以为这个小Demo不会有太多的代码量, 于是把所有的东西都写在主窗口里面

然后可想而知 , 测试接收数据的时候 , 事件触发四次 -> 窗口控件同步四次 , 最后的显示结果就是:除了第一条数据接收正常, 后面的三条全是空数据 , 不过这里没有图片了 , 没有用git来管理 , 那个有问题的版本已经刷过去了 QWQ

串口部分的知识到这里应该就差不多结束了 .

Winform

PortAssistant

如图就是我编写的串口调试助手的窗体界面, 这个界面没啥好说的 , 都是直接拖控件来完成的 ,而且都是一些比较常用的控件.

这里讲一下一个比较重要的点 , 由于代码有点多(上面也提到了 ,就把串口相关的部分给提了出来单独写了一个PortHelp类) , 对应的 , 串口的收发数据的方法也提了过来 , 这个时候出现问题了 , 我把显示数据区的控件ui更新是写在了接收事件里

调用委托更新ui大家都知道 ,直接 this.invoke(delegateObj)或者this.BegainInvoke(delegateObj)

但是 , 我把这个方法移动到另外一个类的时候提示 , this未定义invoke函数或属性 , 我想起来 , 窗体类中可以调用是因为 , 窗体类继承自线程 , 肯定可以调用 , 可是串口辅助类需要继承这东西干啥

这个时候困扰我的第二个问题诞生了(第一个就上面那个触发多次接收的问题…当时看了老半天找不到原因 , 也想过是多次触发 , 但不知道为啥就是四次, 扯远了…回归正题)
咋样才能让接收区ui能更新接收到的数据
其实在串口辅助类不能调用的时候就想到了用线程来处理 , 但是我对线程不是很熟悉(基本不咋懂的阶段) , 于是到查阅了相关资料了解到了线程的基本操作(创建启动销毁)

然后打算在主窗体里写一个线程 , 在开启串口的时候创建对象并开启线程进行循环接收数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 定义成员变量
Thread recv_thread;

/**
* 接收数据线程体
*/
private void Recv_Thread() {
while (true) {
// 存在发送数据同步控件
if (! String.IsNullOrEmpty(portHelp.Recv_data)) {
// 调用更新ui的委托
this.BeginInvoke(Recv_Show, portHelp.Recv_data);
// 暂时忽略这个 , 这个是用来统计接收数据字节数的
this.BeginInvoke(Dock_CountR, portHelp.Recv_data);
// 重置接收数据
portHelp.Recv_data = "";
}
Thread.Sleep(50);
}
}

// 然后就是在打开/关闭 串口按钮里面做判断
// 在打开串口的时候new出实例并启动线程
recv_thread = new Thread(Recv_Thread);
recv_thread.IsBackground = true;
recv_thread.Start();

// 在关闭串口时关闭线程
try {
if (recv_thread.IsAlive) {
recv_thread.Abort();
}
}
catch { }

这里只放出了线程具体操作部分的代码 ,其他的委托事件 , 按钮事件就没有具体写出来 , 毕竟太多了 , 文末会附上github地址

其实到这里 , 就已经差不多了 , 一个最最简单的串口调试助手 (拥有基础的收发,开关, 串口设置)就算是写完了。
但本着写都写了不如优化完全的想法,开始了功能完善的路。

进阶 - 功能完善

那既然是作为串口通信,避免不了的肯定需要进行16进制的数据发送,同时也需要进行16进制的数据接收和普通接收数据的16进制显示功能
好的, 一上来就是个麻烦问题(虽然不难,但是挺繁琐的),想了下如果要这么多的(16进制收发 , 16进制显示,再加上原本的字符串和字节数组的发送方式)就要写6个重载了 , 很是麻烦,而且复用性太差了 , 如果后面又在别的地方需要进行数据转换,岂不是又要重写? 于是这里我直接写了个字符处理的工具类
想了下还是不附上代码了,需要的去github看叭。

字符处理这块搞完了然后是对具体的业务逻辑的完善

  • 首先是16进制发送
    这里主要在点击发送的时候做了一个判断,时候勾选16进制发送,贴上代码:
    1
    2
    3
    4
    5
    6
    // 判断是否勾选16进制发送
    if (this.ckb_Send_Hex.Checked) {
    portHelp.writeByHex(content);
    } else {
    portHelp.write(content);
    }
  • 然后是接收数据以16进制显示
    emm,还是用了一个Checkbox来的,这里我在更新ui的委托方法体里做的判断:
    1
    2
    // 判断是否勾选为16进制显示
    data = this.ckb_Recv_Hex.Checked ? charUtils.string2Hex(data) : data;

字符功能这块完善完毕, 然后给数据显示区的功能也给完善了一点

既然需要进行调试 , 那么肯定是需要给显示区接收到的数据进行分包显示的(添加时间戳), 而且也需要对接收到的数据进行16进制转码显示

  • 添加时间戳显示
    逻辑也还是和上面一样 , 用了一个checkbox来做的 , 在更新ui的委托方法里判断是否勾选:
    1
    2
    3
    4
    5
    // 获取时间戳
    string date = this.ckb_Recv_Time.Checked? DateTime.Now.ToString("HH:mm:ss"): null;
    date = String.IsNullOrEmpty(date)? "": "[" + date + "]";
    // 拼接
    data = date + " : " + data;
    加上时间戳显示后 , 数据看起来跟舒服了, 但是, 我们毕竟还有要发送的数据, 如果只在接收端才看得到数据的话也不是很好进行调试, 于是也把显示发送这个功能给加了上来:
  1. 由于数据接收的ui更新已经是有了的 , 所以这里我们并不需要再去写这个实现 , 主要需要考虑的是在什么条件下进行调用.
  2. 调用这一块好解决,接收那边由于是写了新线程死循环来更新接收到的数据(接收毕竟需要进行判断), 而发送不需要进行判断什么时候才会触发,我们手动点击发送按钮才会执行发送操作, 所以我们只要在发送成功后调用委托事件即可:
    1
    2
    3
    4
    5
    // 判断是否勾选显示发送
    if (ckb_Send_show.Checked)
    {
    this.Invoke(Recv_Show, content);
    }
  3. 添加完了之后再运行, 发送的数据和接收的数据倒是都能显示了 , 不过这两没区分啊 ,都分不出来哪个是发送的哪个是接收的。我在这里就加了一个开关来判断, 如果开关为true则为发送的消息 ,给这条消息拼接上→→, 为false则为接受收的消息 , 拼接上←←, 这样来用以区分。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    // 在更新ui里进行判断和拼接 每次判断更新ui后都进行开关重置
    txt_Port_Recv.Text += date + (isSend? " →→ ": " ←← ") + data + "\r\n";
    // 修改控件显示判断
    isSend = false;

    // 由于发送的触发都是在我们进行串口的写入操作 , 所以我们只需要在串口写入后进行开关的开启操作即可
    // 判断是否勾选显示发送
    if (ckb_Send_show.Checked)
    {
    isSend = true;
    this.Invoke(Recv_Show, content);
    }
  4. 修改完后看起来可就舒服多了,不过还有个问题还是让我很难受,显示区的数据全部都挤在一块(有点强迫症,密密麻麻的看着真难受),于是又加了了个发送新行的功能 , 这样看起来数据之间就有空行了:(逻辑还是一样的,添加个勾选判断)
    1
    2
    // 判断是否勾选发送新行
    content = this.ckb_Port_Send_newLine.Checked? content + "\r\n": content;
    发送显示这一块写的差不多了,接着还有一个16进制显示,毕竟我们有16进制发送 , 没有接收数据解码到16进制显示可不行。
    16进制显示这个好搞的很,由于之前因为强迫症写了一个转码功能巨全的工具类 , 所以这里只需要添加一个判断然后调用解码就可以了:
    1
    2
    // 判断是否勾选为16进制显示
    data = this.ckb_Recv_Hex.Checked ? charUtils.string2Hex(data) : data;
    数据显示这一块的功能应该就修改的差不多了 。

另外给显示区域加了一个双击事件(主要是一次调试如果发送数据过多 , 数据会显得超级乱 ,清空一下会比较舒服):

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 接收数据区双击清空
*/
public void txt_Port_recv_DBClick(object sender, EventArgs e)
{
DialogResult result = MessageBox.Show("是否清空接收区内容?", "提示", MessageBoxButtons.OKCancel, MessageBoxIcon.Question);
if (result == DialogResult.OK)
{
TextBox box = sender as TextBox;
box.Text = "\r\n";
}
}

接着就是最后的两个功能 ,定时发送和循环发送(至于为啥会想到有这两个功能 , 也是因为我师傅丢给我的任务有类似的需求,就想着干脆也给熟悉一下)

定时发送

定时发送功能还是比较简单(为啥这么说,主要定时发送比较常规的操作,在循环发送里碰到了一个大坑,这个后面说)

定时发送的话 , 其实就用一个Timer来处理:

  1. 首先定义好成员变量和定时器的方法体:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    // 定时器
    System.Windows.Forms.Timer sendTimer = new System.Windows.Forms.Timer();

    // 在窗口的构造方法里挂载上定时器事件
    // 定时器挂载事件
    sendTimer.Tick += new EventHandler(Timer_Writer);

    /**
    * 定时器方法体
    */
    private void Timer_Writer(object sender, EventArgs e) {
    Write(getSendContent());
    }

    其实在这里也困扰了一会 , 因为定时器需要循环执行挂载事件, 我最开始想的是直接调用写好的Write(String data)方法,但是这个方法需要传参,而定时器传参这一块我又没有头绪有点懵逼,然后看了下之前写的点击发送的地方 , 把里面获取发送内容的地方给提取出来做了一个函数处理

  2. 然后就是逻辑处理, 这里依然是使用了勾选判断来获取时候需要开启定时发送, 然后用了一个文本框来获取定时间隔:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    if (this.ckb_Send_Timer.Checked) {
    // 读取定时参数
    int times = 100;
    try {
    times = int.Parse(this.txt_Send_Timer.Text.ToString().Trim());
    } catch (Exception ex) {
    MessageBox.Show("定时器参数设置错误 , 将使用默认值 : 1000 ms");
    times = 1000;
    }
    // 设定定时器参数
    sendTimer.Interval = times;
    sendTimer.Enabled = true;
    // 定时器启动
    sendTimer.Start();
    }
  3. 定时发送的雏形写好了 , 运行一遍, 没问题,可以正常发送, 然后打算关掉来写别的功能. 嗯???? 关闭呢 , 咋这定时发送开了就管不了…好的现在我们需要再完善一下, 需要再加个开关来判断当前是否开启定时器. 我们需要关闭定时器就肯定需要一个按钮来触发, 但再加按钮又不好看(主要拖控件搞布局也麻烦), 于是打算再按钮点击事件里加入判断:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    // 判断当前是否为定时器开启状态
    if (TimerOpen) {
    try
    {
    sendTimer.Stop();
    sendTimer.Enabled = false;
    }
    catch (Exception ex)
    {
    MessageBox.Show(ex.Message);
    }
    finally {
    this.btn_Port_send.BackColor = Color.White;
    this.btn_Port_send.Text = "点击发送";
    TimerOpen = false;
    }
    return;
    }

    // 然后再开启定时器的后面添加上按钮的修改
    // 修改按钮状态
    this.btn_Port_send.BackColor = Color.Red;
    this.btn_Port_send.Text = "停止发送";
    TimerOpen = true;

定时发送的功能就实现了 , 下面再把循环发送给实现一下

循环发送

  1. 循环发送和定时器相比其实还更简单一点(如果不写延迟的话), 一个勾选判断, 一个获取输入的问题:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    // 判断是否勾选循环发送
    if (this.ckb_Send_Each.Checked) {
    int count = 1;
    try {
    count = int.Parse(this.txt_Send_Each.Text.ToString().Trim());
    } catch (Exception ex) {
    MessageBox.Show("循环次数输入错误 ,将执行默认循环次数:5");
    count = 5;
    }
    if (count > 20) {
    MessageBox.Show("循环次数过大 , 请填写小于 20 的值");
    count = 20;
    }
    for (int i = 0; i < count; i++) {
    Write(getSendContent());
    }
    return;
    }
  2. 但是想着直接全部循环发送掉应该会出点问题, 打算再发送的时候加个延迟:

    1
    2
    // 循环默认延时500ms
    Thread.sleep(500);
  3. 这不加不要紧, 加了之后进行调试 , 发现主窗口ui会在循环发送的时候卡死(主要是这里的sleep操作, 没想到直接卡到ui线程了), 查了点资料找到了解决办法Application.DoEvents()这个方法可以用来执行当前窗口的其他事件(比如ui更新事件), 所以可以在sleep的时间里进行一个循环, 每循环一次执行一次更新事件:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    /**
    * 用于循环发送时更新ui
    */
    public static void Delay(int mm)
    {
    DateTime current = DateTime.Now;
    while (current.AddMilliseconds(mm) > DateTime.Now)
    {
    Application.DoEvents();
    }
    return;
    }
    // 然后把之前循环发送的sleep替换成这个方法
    // 循环默认延时500ms
    Delay(500);

完善

在写上面两个功能的时候有个小问题需要解决一下

就是如果显示区的长度超过了可显示的区域, 每次新加一条数据显示出来, 马上又会焦点到第一行, 这对调试起来是十分不友好的

于是我又给显示区加了个文本改变事件:

1
2
3
4
5
6
7
8
9
10
11
/**
* 数据显示区焦点最后一行
*/
private void Txt_Content_Changed(object sender, EventArgs e)
{
TextBox box = sender as TextBox;
//文本框选中的起始点在最后
box.SelectionStart = box.TextLength;
//将控件内容滚动到当前插入符号位置
box.ScrollToCaret();
}

这样一来数据就可以一直焦点在最后一行 , 然后还有个小bug…

就是, 定时和循环的勾选问题, 其他功能同时勾选都可以 , 因为不冲突 , 但是这两个是肯定不行的(我在发送按钮事件中两个的判断都写了return 不会往下再执行, 而且这两同时操作肯定会直接崩掉)

于是还需要给这两加上一个事件用来处理, 基础逻辑就是在点击或者切换选中状态的时候判断一下另一个的选中状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 勾选判定
* 定时器与循环只可选其一
*/
private void Send_checked(object sender, EventArgs e) {
CheckBox box = sender as CheckBox;
switch (box.Text) {
case "定时发送":
if (this.ckb_Send_Each.Checked)
{
box.Checked = false;
}
break;
case "循环发送":
if (this.ckb_Send_Timer.Checked)
{
box.Checked = false;
}
break;
}
}

功能方面就都完善了, 然后是对实际使用的方便程度上进行了一些逻辑上的优化


在底部添加了一个dock来显示当前串口的信息和写/读的总字节数.

前面串口信息的状态都很方便, 写一个委托来更新即可 , 然后判断是开启还是关闭只需要写在开启按钮里面(有写好的串口状态判断)

然后是计算读写总字节:

  • 写了两个委托来更新控件(有点偷懒 , 其实可以把控件当作参数传入然后写一个方法的):
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    /**
    * 统计发送字节数
    */
    private void countWriteBytes(String data) {
    int count = 0;
    try {
    count = int.Parse(this.Dock_lbl_WBytes.Text.ToString().Trim());
    } catch { } // 基本只有第一次发送会出现转换错误 , 但是有赋予初始值0 , 这里就不做异常处理
    count += charUtils.string2ByteArr(data).Length;
    this.Dock_lbl_WBytes.Text = count.ToString();
    }

    /**
    * 统计接收字节数
    */
    private void countReadBytes(String data)
    {
    int count = 0;
    try {
    count = int.Parse(this.Dock_lbl_RBytes.Text.ToString().Trim());
    }
    catch { }
    count += charUtils.string2ByteArr(data).Length;
    this.Dock_lbl_RBytes.Text = count.ToString();
    }
  • 然后就是调用判断了, 发送的计算调用很简单 , 调用一次写入也调用一次计算即可, 然后是接收判断, 其实也很简单, 在接收线程那里我们做的就是更新ui也就获得到了接收的数据, 我们就只要在更新ui后也计算一下数据字节即可.

结束

最后贴上一下我写的串口助手的链接点击下载 .
再附上一下项目的git地址点击跳转 .

更新

2/14 修复开启串口CPU占用率过高的问题 (其实是接收数据的线程忘了写延时 , 然后调用率太高了)