c#串口操作系列

来源:互联网 发布:试述关系数据库的特点 编辑:程序博客网 时间:2024/05/16 11:36

我假设读者已经了解了c#的语法,本文是针对刚打算解除串口编程的朋友阅读的,作为串口编程的入门范例,也是我这个系列的基础。

我们的开发环境假定为vs2005(虽然我在用vs2010,但避免有些网友用2005,不支持lambda,避免不兼容,就用2005来做例子)

一个基本的串口程序,既然是个程序了。我们就先从功能说起,包含

串口选择

波特率选择

打开

关闭

接受数据显示

发送数据输入

发送数据

数据量提示以及归零

好吧,有了这些功能,我们就先画出界面。例如:

这里,波特率就定死几种好了。直接界面上添加2400,4800,9600,19200,38400,57600,115200

comboPortName这里,为了我们的软件能通用所有电脑避免每次查询的效率损失,我们使用微软提供的枚举方式,代码如下:

  1. string [] ports = SerialPort.GetPortNames();   
  2. Array.Sort(ports);   
  3. comboPortName.Items.AddRange(ports);  

显然,我们需要定义一个SerialPort对象。添加DataReceived事件响应收到数据,还有一个重点,我们需要记得设置NewLine属性哦。好想有的版本不设置的时候,WriteLine和Write效果一样。

所以,我们需要初始化SerialPort对象,例如:

  1. //初始化SerialPort对象   
  2. comm.NewLine =  "/r/n" ;   
  3. comm.RtsEnable =  true ; //根据实际情况吧。   
  4. //添加事件注册   
  5. comm.DataReceived += comm_DataReceived;  

 初始化好串口,简单的编写打开,关闭方法,编写界面响应的是否自动换行,如何复位计数器,发送方法。以及数据处理。因为我已经写了完整注视,我就直接贴代码了。

  1. using  System;   
  2. using  System.Collections.Generic;   
  3. using  System.ComponentModel;   
  4. using  System.Data;   
  5. using  System.Drawing;   
  6. using  System.Linq;   
  7. using  System.Text;   
  8. using  System.Windows.Forms;   
  9. using  System.IO.Ports;   
  10. using  System.Text.RegularExpressions;   
  11.   
  12. namespace  SerialportSample   
  13. {   
  14.      public  partial  class  SerialportSampleForm : Form   
  15.     {   
  16.          private  SerialPort comm =  new  SerialPort();   
  17.          private  StringBuilder builder =  new  StringBuilder(); //避免在事件处理方法中反复的创建,定义到外面。      
  18.          private   long  received_count = 0; //接收计数      
  19.          private   long  send_count = 0; //发送计数      
  20.   
  21.          public  SerialportSampleForm()   
  22.         {   
  23.             InitializeComponent();   
  24.         }   
  25.   
  26.          //窗体初始化      
  27.          private   void  Form1_Load( object  sender, EventArgs e)   
  28.         {   
  29.              //初始化下拉串口名称列表框      
  30.              string [] ports = SerialPort.GetPortNames();   
  31.             Array.Sort(ports);   
  32.             comboPortName.Items.AddRange(ports);   
  33.             comboPortName.SelectedIndex = comboPortName.Items.Count > 0 ? 0 : -1;   
  34.             comboBaudrate.SelectedIndex = comboBaudrate.Items.IndexOf( "9600" );   
  35.   
  36.              //初始化SerialPort对象      
  37.             comm.NewLine =  "/r/n" ;   
  38.             comm.RtsEnable =  true ; //根据实际情况吧。      
  39.   
  40.              //添加事件注册      
  41.             comm.DataReceived += comm_DataReceived;   
  42.         }   
  43.   
  44.          void  comm_DataReceived( object  sender, SerialDataReceivedEventArgs e)   
  45.         {   
  46.              int  n = comm.BytesToRead; //先记录下来,避免某种原因,人为的原因,操作几次之间时间长,缓存不一致      
  47.              byte [] buf =  new   byte [n]; //声明一个临时数组存储当前来的串口数据      
  48.             received_count += n; //增加接收计数      
  49.             comm.Read(buf, 0, n); //读取缓冲数据      
  50.             builder.Clear(); //清除字符串构造器的内容      
  51.              //因为要访问ui资源,所以需要使用invoke方式同步ui。      
  52.              this .Invoke((EventHandler)( delegate   
  53.             {   
  54.                  //判断是否是显示为16禁止      
  55.                  if  (checkBoxHexView.Checked)   
  56.                 {   
  57.                      //依次的拼接出16进制字符串      
  58.                      foreach  ( byte  b  in  buf)   
  59.                     {   
  60.                         builder.Append(b.ToString( "X2" ) +  " " );   
  61.                     }   
  62.                 }   
  63.                  else   
  64.                 {   
  65.                      //直接按ASCII规则转换成字符串      
  66.                     builder.Append(Encoding.ASCII.GetString(buf));   
  67.                 }   
  68.                  //追加的形式添加到文本框末端,并滚动到最后。      
  69.                  this .txGet.AppendText(builder.ToString());   
  70.                  //修改接收计数      
  71.                 labelGetCount.Text =  "Get:"  + received_count.ToString();   
  72.             }));   
  73.         }   
  74.   
  75.          private   void  buttonOpenClose_Click( object  sender, EventArgs e)   
  76.         {   
  77.              //根据当前串口对象,来判断操作      
  78.              if  (comm.IsOpen)   
  79.             {   
  80.                  //打开时点击,则关闭串口      
  81.                 comm.Close();   
  82.             }   
  83.              else   
  84.             {   
  85.                  //关闭时点击,则设置好端口,波特率后打开      
  86.                 comm.PortName = comboPortName.Text;   
  87.                 comm.BaudRate =  int .Parse(comboBaudrate.Text);   
  88.                  try   
  89.                 {   
  90.                     comm.Open();   
  91.                 }   
  92.                  catch  (Exception ex)   
  93.                 {   
  94.                      //捕获到异常信息,创建一个新的comm对象,之前的不能用了。      
  95.                     comm =  new  SerialPort();   
  96.                      //现实异常信息给客户。      
  97.                     MessageBox.Show(ex.Message);   
  98.                 }   
  99.             }   
  100.              //设置按钮的状态      
  101.             buttonOpenClose.Text = comm.IsOpen ?  "Close"  :  "Open" ;   
  102.             buttonSend.Enabled = comm.IsOpen;   
  103.         }   
  104.   
  105.          //动态的修改获取文本框是否支持自动换行。      
  106.          private   void  checkBoxNewlineGet_CheckedChanged( object  sender, EventArgs e)   
  107.         {   
  108.             txGet.WordWrap = checkBoxNewlineGet.Checked;   
  109.         }   
  110.   
  111.          private   void  buttonSend_Click( object  sender, EventArgs e)   
  112.         {   
  113.              //定义一个变量,记录发送了几个字节      
  114.              int  n = 0;   
  115.              //16进制发送      
  116.              if  (checkBoxHexSend.Checked)   
  117.             {   
  118.                  //我们不管规则了。如果写错了一些,我们允许的,只用正则得到有效的十六进制数      
  119.                 MatchCollection mc = Regex.Matches(txSend.Text, @ "(?i)[/da-f]{2}" );   
  120.                 List< byte > buf =  new  List< byte >(); //填充到这个临时列表中      
  121.                  //依次添加到列表中      
  122.                  foreach  (Match m  in  mc)   
  123.                 {   
  124.                     buf.Add( byte .Parse(m.Value));   
  125.                 }   
  126.                  //转换列表为数组后发送      
  127.                 comm.Write(buf.ToArray(), 0, buf.Count);   
  128.                  //记录发送的字节数      
  129.                 n = buf.Count;   
  130.             }   
  131.              else //ascii编码直接发送      
  132.             {   
  133.                  //包含换行符      
  134.                  if  (checkBoxNewlineSend.Checked)   
  135.                 {   
  136.                     comm.WriteLine(txSend.Text);   
  137.                     n = txSend.Text.Length + 2;   
  138.                 }   
  139.                  else //不包含换行符      
  140.                 {   
  141.                     comm.Write(txSend.Text);   
  142.                     n = txSend.Text.Length;   
  143.                 }   
  144.             }   
  145.             send_count += n; //累加发送字节数      
  146.             labelSendCount.Text =  "Send:"  + send_count.ToString(); //更新界面      
  147.         }   
  148.   
  149.          private   void  buttonReset_Click( object  sender, EventArgs e)   
  150.         {   
  151.              //复位接受和发送的字节数计数器并更新界面。      
  152.             send_count = received_count = 0;   
  153.             labelGetCount.Text =  "Get:0" ;   
  154.             labelSendCount.Text =  "Send:0" ;   
  155.         }   
  156.     }   
  157. }  

至此,一个标准的串口调试助手就完成了。留下一个思考题,如果接收数据后,更新界面的时候,尚未操作完成,此时并发了关闭串口的操作。程序会如何呢?敬请阅读《C# 串口操作系列(2) -- 如何避免关闭串口偶尔软件死锁》。

VS2010项目范例下载

VS2008项目范例下载

回复 jack66wong:这个例子还是比较简单的。如果用串口模拟软件部正确。可能是软件问题。实际的用一个串口线,2,3短接可以看到效果。或是两个串口对接护发。监视串口,硬件的话,用bushound5.0,如果串口模拟,可以用Eterlogic VSPE。
if (checkBoxHexSend.Checked) { MatchCollection mc = Regex.Matches(txSend.Text, @"(?i)[/da-f]{2}"); List<byte> buf = new List<byte>(); foreach (Match m in mc) { buf.Add(byte.Parse(m.Value)); } comm.Write(buf.ToArray(), 0, buf.Count); n = buf.Count; 当我发送十六进制时,为什么提示m.Value的输入字符串的格式不正确?请问这是为什么?还有就是正则表达式的(?i)是什么意思。一直没看懂。 
回复 caijianfa:当时疏忽了。这个应该加上一个参数:System.Globalization.NumberStyles
第一篇文章我相信很多人不看都能做的出来,但是,用过微软SerialPort类的人,都遇到过这个尴尬,关闭串口的时候会让软件死锁。天哪,我可不是武 断,算了。不要太绝对了。99.9%的人吧,都遇到过这个问题。我想只有一半的人真的解决了。另外一半的人就睁只眼闭只眼阿弥佗佛希望不要在客户那里出现 这问题了。

    你看到我的文章,就放心吧,这问题有救了。我们先回顾一下上一篇中的代码

  1. void  comm_DataReceived( object  sender, SerialDataReceivedEventArgs e)   
  2. {   
  3.      //先记录下来,避免某种原因,人为的原因,操作几次之间时间长,缓存不一致   
  4.      int  n = comm.BytesToRead;   
  5.      //声明一个临时数组存储当前来的串口数据   
  6.      byte [] buf =  new   byte [n];   
  7.      //增加接收计数      
  8.     received_count += n;   
  9.      //读取缓冲数据        
  10.     comm.Read(buf, 0, n);   
  11.      //清除字符串构造器的内容      
  12.     builder.Clear();   
  13.      //因为要访问ui资源,所以需要使用invoke方式同步ui。         
  14.      this .Invoke((EventHandler)( delegate {...界面更新,略}));         
  15. }   
  16.   
  17. private   void  buttonOpenClose_Click( object  sender, EventArgs e)   
  18. {   
  19.      //根据当前串口对象,来判断操作         
  20.      if  (comm.IsOpen)   
  21.     {   
  22.          //打开时点击,则关闭串口         
  23.         comm.Close(); //这里就是可能导致软件死掉的地方      
  24.     }   
  25.      else         
  26.     {...}        
  27. }  

    为什么会死锁呢,并发冲突。

    我们要了解一下SerialPort的实现和串口通讯机制,在你打开串口的时候,SerialPort会创建一个监听线程ListenThread,在这 个线程中,等待注册的串口中断,当收到中断后,会调用DataReceived事件。调用完成后,继续进入循环等待,直到串口被关闭退出线程。

    我们的UI主线程如何做的呢,首先创建一个窗体,然后执行了Application.Run(窗体实例)。是这样把,这里的Application.Run就是创建了一个消息循环,循环的处理相关的消息。

    这里我们就有了2个线程,UI主线程、串口监听线程。那么你在DataReceived处理数据的时候,就需要线程同步,避免并发冲突,什么是并发冲突? 并发冲突就是2个或多个并行(至少看上去像)的线程运行的时候,多个线程共同的操作某一线程的资源,在时序上同时或没有按我们的预计顺序操作,这样就可能 导致数据混乱无序或是彼此等待完成死锁软件。

    而串口程序大多是后者。为什么呢,看看我们的例子中DataReceived做了什么?首先读取数据,然后就是调用this.Invoke方法更新UI 了。这里Invoke的时候,监听线程将等待UI线程的标志,等到后,开始操作UI的资源,当操作完成之前,监听线程也就停在DataReceived方 法的调用这里,如果这个时候。并发了关闭串口的操作会如何呢?SerialPort的Close方法,会首先尝试等待和监听线程一样的一个互斥体、临界 区、或是事件(不确定.net用的哪种)。那这个同步对象什么时候释放呢?每次循环结束就释放,哦。循环为什么不结束呢?因为这一次的循环操作执行到 DataReceived之后,执行了Invoke去更新界面了,那Invoke怎么又没有执行完成呢?看上去很简单的几行代码。虽然我没仔细研读 过.net的Invoke原理,但我猜测是通过消息的方式来同步的,这也是为什么这么多的类,只有控件(窗体也是控件的一种,.net在概念上,颠覆了微 软自己的概念,传统的win32编程,是说所有的控件都是个window,只是父窗体不同,表现形式不同,但都是基于系统消息队列的,.net出于更高的 抽象,正好反过来了。呵呵)才有Invoke方法了。(委托自己的Invoke和这个不同)

    我猜测控件/窗体的Invoke是SendMessage方式实现的,那么发送消息后就会等待消息循环来处理消息了。如果你直接去关闭串口了。你点击按钮 本身也会被转换成消息WM_CLICK,消息循环在处理按钮的WM_CLICK时候,调用你按钮的OnClick方法,进而触发调用你的 ButtonClose_Click事件,这都是同步调用的,你的主线程,处理消息的过程,停在了这个Click事件,而你的Click事件又去调用了 SerialPort的Close方法,Close方法又因为和串口监听线程的同步信号量关联在一起需要等待一次的while结束,而这个while循环 中调用了DataReceived方法,这个方法中调用了Invoke,也就是发送了消息到消息队列等待结果,但消息循环正在处理你的关闭按钮事件等待退 出。

    实在太复杂了,这个情况下,你想要真的关闭串口成功,就需要while中的DataReceived方法调用结束释放同步信号,就需要执行完 Invoke,就需要执行消息循环,幸运的是,我们真的有办法执行消息循环来打破僵局。Application.DoEvents()。还好,不幸中的万 幸。可是问题又来了,你能让Invoke结束,但你无法确定是否在你调用消息循环后,你的某一时刻不会再次并发,可能由于单cpu的串行操作模拟并行中, 又把时间片先分给了优先级高的串口监听线程呢?是有可能的。所以,我们就需要一点方法来避免再次invoke窗体。优化后不会司机的例子如下,我们修改 DataReceived方法,关闭方法,并定义2个标记Listening和Closing。

  1. namespace  SerialportSample   
  2. {   
  3.      public  partial  class  SerialportSampleForm : Form   
  4.     {   
  5.          private  SerialPort comm =  new  SerialPort();   
  6.          private  StringBuilder builder =  new  StringBuilder(); //避免在事件处理方法中反复的创建,定义到外面。      
  7.          private   long  received_count = 0; //接收计数      
  8.          private   long  send_count = 0; //发送计数      
  9.          private   bool  Listening =  false ; //是否没有执行完invoke相关操作      
  10.          private   bool  Closing =  false ; //是否正在关闭串口,执行Application.DoEvents,并阻止再次invoke      
  11.   
  12.          public  SerialportSampleForm()   
  13.         {   
  14.             InitializeComponent();   
  15.         }   
  16.   
  17.          //窗体初始化      
  18.          private   void  Form1_Load( object  sender, EventArgs e)   
  19.         {   
  20.              //初始化下拉串口名称列表框      
  21.              string [] ports = SerialPort.GetPortNames();   
  22.             Array.Sort(ports);   
  23.             comboPortName.Items.AddRange(ports);   
  24.             comboPortName.SelectedIndex = comboPortName.Items.Count > 0 ? 0 : -1;   
  25.             comboBaudrate.SelectedIndex = comboBaudrate.Items.IndexOf( "9600" );   
  26.   
  27.              //初始化SerialPort对象      
  28.             comm.NewLine =  "/r/n" ;   
  29.             comm.RtsEnable =  true ; //根据实际情况吧。      
  30.   
  31.              //添加事件注册      
  32.             comm.DataReceived += comm_DataReceived;   
  33.         }   
  34.   
  35.          void  comm_DataReceived( object  sender, SerialDataReceivedEventArgs e)   
  36.         {   
  37.              if  (Closing)  return ; //如果正在关闭,忽略操作,直接返回,尽快的完成串口监听线程的一次循环      
  38.              try   
  39.             {   
  40.                 Listening =  true ; //设置标记,说明我已经开始处理数据,一会儿要使用系统UI的。      
  41.                  int  n = comm.BytesToRead; //先记录下来,避免某种原因,人为的原因,操作几次之间时间长,缓存不一致      
  42.                  byte [] buf =  new   byte [n]; //声明一个临时数组存储当前来的串口数据      
  43.                 received_count += n; //增加接收计数      
  44.                 comm.Read(buf, 0, n); //读取缓冲数据      
  45.                 builder.Clear(); //清除字符串构造器的内容      
  46.                  //因为要访问ui资源,所以需要使用invoke方式同步ui。      
  47.                  this .Invoke((EventHandler)( delegate   
  48.                 {   
  49.                      //判断是否是显示为16禁止      
  50.                      if  (checkBoxHexView.Checked)   
  51.                     {   
  52.                          //依次的拼接出16进制字符串      
  53.                          foreach  ( byte  b  in  buf)   
  54.                         {   
  55.                             builder.Append(b.ToString( "X2" ) +  " " );   
  56.                         }   
  57.                     }   
  58.                      else   
  59.                     {   
  60.                          //直接按ASCII规则转换成字符串      
  61.                         builder.Append(Encoding.ASCII.GetString(buf));   
  62.                     }   
  63.                      //追加的形式添加到文本框末端,并滚动到最后。      
  64.                      this .txGet.AppendText(builder.ToString());   
  65.                      //修改接收计数      
  66.                     labelGetCount.Text =  "Get:"  + received_count.ToString();   
  67.                 }));   
  68.             }   
  69.              finally   
  70.             {   
  71.                 Listening =  false ; //我用完了,ui可以关闭串口了。      
  72.             }   
  73.         }   
  74.   
  75.          private   void  buttonOpenClose_Click( object  sender, EventArgs e)   
  76.         {   
  77.              //根据当前串口对象,来判断操作      
  78.              if  (comm.IsOpen)   
  79.             {   
  80.                 Closing =  true ;   
  81.                  while  (Listening) Application.DoEvents();   
  82.                  //打开时点击,则关闭串口      
  83.                 comm.Close();   
  84.                 Closing =  false ;   
  85.             }   
  86.              else   
  87.             {   
  88.                  //关闭时点击,则设置好端口,波特率后打开      
  89.                 comm.PortName = comboPortName.Text;   
  90.                 comm.BaudRate =  int .Parse(comboBaudrate.Text);   
  91.                  try   
  92.                 {   
  93.                     comm.Open();   
  94.                 }   
  95.                  catch  (Exception ex)   
  96.                 {   
  97.                      //捕获到异常信息,创建一个新的comm对象,之前的不能用了。      
  98.                     comm =  new  SerialPort();   
  99.                      //现实异常信息给客户。      
  100.                     MessageBox.Show(ex.Message);   
  101.                 }   
  102.             }   
  103.              //设置按钮的状态      
  104.             buttonOpenClose.Text = comm.IsOpen ?  "Close"  :  "Open" ;   
  105.             buttonSend.Enabled = comm.IsOpen;   
  106.         }   
  107.   
  108.          //动态的修改获取文本框是否支持自动换行。      
  109.          private   void  checkBoxNewlineGet_CheckedChanged( object  sender, EventArgs e)   
  110.         {   
  111.             txGet.WordWrap = checkBoxNewlineGet.Checked;   
  112.         }   
  113.   
  114.          private   void  buttonSend_Click( object  sender, EventArgs e)   
  115.         {   
  116.              //定义一个变量,记录发送了几个字节      
  117.              int  n = 0;   
  118.              //16进制发送      
  119.              if  (checkBoxHexSend.Checked)   
  120.             {   
  121.                  //我们不管规则了。如果写错了一些,我们允许的,只用正则得到有效的十六进制数      
  122.                 MatchCollection mc = Regex.Matches(txSend.Text, @ "(?i)[/da-f]{2}" );   
  123.                 List< byte > buf =  new  List< byte >(); //填充到这个临时列表中      
  124.                  //依次添加到列表中      
  125.                  foreach  (Match m  in  mc)   
  126.                 {   
  127.                     buf.Add( byte .Parse(m.Value));   
  128.                 }   
  129.                  //转换列表为数组后发送      
  130.                 comm.Write(buf.ToArray(), 0, buf.Count);   
  131.                  //记录发送的字节数      
  132.                 n = buf.Count;   
  133.             }   
  134.              else //ascii编码直接发送      
  135.             {   
  136.                  //包含换行符      
  137.                  if  (checkBoxNewlineSend.Checked)   
  138.                 {   
  139.                     comm.WriteLine(txSend.Text);   
  140.                     n = txSend.Text.Length + 2;   
  141.                 }   
  142.                  else //不包含换行符      
  143.                 {   
  144.                     comm.Write(txSend.Text);   
  145.                     n = txSend.Text.Length;   
  146.                 }   
  147.             }   
  148.             send_count += n; //累加发送字节数      
  149.             labelSendCount.Text =  "Send:"  + send_count.ToString(); //更新界面      
  150.         }   
  151.   
  152.          private   void  buttonReset_Click( object  sender, EventArgs e)   
  153.         {   
  154.              //复位接受和发送的字节数计数器并更新界面。      
  155.             send_count = received_count = 0;   
  156.             labelGetCount.Text =  "Get:0" ;   
  157.             labelSendCount.Text =  "Send:0" ;   
  158.         }   
  159.     }   
  160. }  

至此,不会再出现关闭死锁问题了。

希望这篇文章能解你的燃眉之急,非常高兴能与读者分享我层遇到,大多数人都遇到的这个问题。如果说的不明白,欢迎讨论。

后续的有关通讯程序底层设计的文章会讲述一个具有丰富扩展性,但有设计简介的万能通讯库,支持网络、蓝牙、串口通讯、并口通讯。但不要指望我都实现出来了,我只是设计出这个框架。

示例代码

我们的串口程序,除了通用的,进行串口监听收发的简单工具,大多都和下位机有关,这就需要关心我们的通讯协议如何缓存,分析,以及通知界面。

    我们先说一下通讯协议。通讯协议就是通讯双方共同遵循的一套规则,定义协议的原则是尽可能的简单以提高传输率,尽可能的具有安全性保证数据传输完整正确。基于这2点规则,我们一个通讯协议应该是这样的: +数据长度 +数据正文 +校验

    例如:AA 44 05 01 02 03 04 05 EA

    这里我假设的一条数据,协议如下:

    数据头:     AA 44

    数据长度: 05

    数据正文: 01 02 03 04 05

    校验:       EA

    一般数据的校验,都会采用常用的方式,CRC16,CRC32,Xor。

    有的数据安全要求高的,不允许丢包的,可能还要加入重发机制或是加入数据恢复算法,在校验后根据前面数据添加恢复字节流以恢复数据。我这里采用的是简单的异或校验,包含数据头的所有字节,依次异或得到的。

    协议很简单,我也认为分析协议是很简单的事情,下面我们就如何分析协议来实际的结合c#看一下。

    er…再等等,在我们实际开始编码之前,还有一个规则需要了解,我们有了通讯协议,如何结合串口的协议来分析,需要关心什么呢?哦。一般就是4个问题:缓存收到的所有数据 ,找到一条完整数据 ,分析数据 ,界面通知 。

    如果分的更详细一点,缓存收到的所有数据,我们想到最高效的办法就是顺序表,也就是数组,但数组的操作比较复杂,当你使用完一条数据后,用过的需要移除; 新数据如果过多的时候,缓存过大需要清理;数据搬移等等,很有可能一个不小心就会丢数据导致软件出些莫名其妙的小问题。个人建议,使用 List<byte>,内部是数组方式实现,每次数据不足够的时候会扩容1倍,数据的增删改都已经做的很完善了。不会出现什么小问题。

    找到一条完整数据,如何找到完整数据呢?就我们例子的这个协议,首先在缓存的数据中找AA 44,当我们找到后,探测后面的字节,发现是05,然后看缓存剩下的数据是否足够,不足够就不用判断,减少时间消耗,如果剩余数据>=6个(包含1 个字节的校验),我们就算一个校验,看和最后的校验是否一致。

    分析数据:鉴于网络的开放性,我无法确定读者对c#的了解程度,介绍一下,常用的方式就是BitConvert.ToInt32这一系列的方法,把连续的 字节(和变量长度一样)读取并转换为对应的变量。c++下使用memcpy,或直接类型转换后进行值拷贝,vb6下使用CopyMemory这个api。

    校验:前面说过了。完整性判断的时候需要和校验对比,大多系统都不太严格,不支持重发,所以数据错误就直接丢弃。导致数据错误的原因很多,比如电磁干扰导 致数据不完整或错误、硬件驱动效率不够导致数据丢失、我们的软件缓存出错等。这些软件因素数据系统错误,需要修改,但是电磁干扰么,有这个可能的。虽然很 少。

    其实我知道,就算是我,看别人的博客也是,喜欢看图片,看代码,文字性的东西,一看就头大。那我接下来贴出基于上一篇文章的改进版本,支持协议分析(协议不能配置,可配置的协议不是我们讨论的范畴。可以看看有DFA(确定性有限状态机))

    我们修改一下界面,以便能显示收到后分析的数据

    红色部分是新增的

代码如下:

  1. namespace  SerialportSample   
  2. {   
  3.      public  partial  class  SerialportSampleForm : Form   
  4.     {   
  5.          private  SerialPort comm =  new  SerialPort();   
  6.          private  StringBuilder builder =  new  StringBuilder(); //避免在事件处理方法中反复的创建,定义到外面。      
  7.          private   long  received_count = 0; //接收计数      
  8.          private   long  send_count = 0; //发送计数      
  9.          private   bool  Listening =  false ; //是否没有执行完invoke相关操作      
  10.          private   bool  Closing =  false ; //是否正在关闭串口,执行Application.DoEvents,并阻止再次invoke      
  11.          private  List< byte > buffer =  new  List< byte >(4096); //默认分配1页内存,并始终限制不允许超过      
  12.          private   byte [] binary_data_1 =  new   byte [9]; //AA 44 05 01 02 03 04 05 EA      
  13.   
  14.          public  SerialportSampleForm()   
  15.         {   
  16.             InitializeComponent();   
  17.         }   
  18.   
  19.          //窗体初始化      
  20.          private   void  Form1_Load( object  sender, EventArgs e)   
  21.         {   
  22.              //初始化下拉串口名称列表框      
  23.              string [] ports = SerialPort.GetPortNames();   
  24.             Array.Sort(ports);   
  25.             comboPortName.Items.AddRange(ports);   
  26.             comboPortName.SelectedIndex = comboPortName.Items.Count > 0 ? 0 : -1;   
  27.             comboBaudrate.SelectedIndex = comboBaudrate.Items.IndexOf( "19200" );   
  28.   
  29.              //初始化SerialPort对象      
  30.             comm.NewLine =  "/r/n" ;   
  31.             comm.RtsEnable =  true ; //根据实际情况吧。      
  32.   
  33.              //添加事件注册      
  34.             comm.DataReceived += comm_DataReceived;   
  35.         }   
  36.   
  37.          void  comm_DataReceived( object  sender, SerialDataReceivedEventArgs e)   
  38.         {   
  39.              if  (Closing)  return ; //如果正在关闭,忽略操作,直接返回,尽快的完成串口监听线程的一次循环      
  40.              try   
  41.             {   
  42.                 Listening =  true ; //设置标记,说明我已经开始处理数据,一会儿要使用系统UI的。      
  43.                  int  n = comm.BytesToRead; //先记录下来,避免某种原因,人为的原因,操作几次之间时间长,缓存不一致      
  44.                  byte [] buf =  new   byte [n]; //声明一个临时数组存储当前来的串口数据      
  45.                 received_count += n; //增加接收计数      
  46.                 comm.Read(buf, 0, n); //读取缓冲数据      
  47.   
  48.                 /////////////////////////////////////////////////////////////////////////////////////////////////////////////     
  49.                  //<协议解析>      
  50.                  bool  data_1_catched =  false ; //缓存记录数据是否捕获到      
  51.                  //1.缓存数据      
  52.                 buffer.AddRange(buf);   
  53.                  //2.完整性判断      
  54.                  while  (buffer.Count >= 4) //至少要包含头(2字节)+长度(1字节)+校验(1字节)      
  55.                 {   
  56.                      //请不要担心使用>=,因为>=已经和>,<,=一样,是独立操作符,并不是解析成>和=2个符号     
  57.                      //2.1 查找数据头      
  58.                      if  (buffer[0] == 0xAA && buffer[1] == 0x44)   
  59.                     {   
  60.                          //2.2 探测缓存数据是否有一条数据的字节,如果不够,就不用费劲的做其他验证了      
  61.                          //前面已经限定了剩余长度>=4,那我们这里一定能访问到buffer[2]这个长度      
  62.                          int  len = buffer[2]; //数据长度      
  63.                          //数据完整判断第一步,长度是否足够      
  64.                          //len是数据段长度,4个字节是while行注释的3部分长度      
  65.                          if  (buffer.Count < len + 4)  break ; //数据不够的时候什么都不做      
  66.                          //这里确保数据长度足够,数据头标志找到,我们开始计算校验      
  67.                          //2.3 校验数据,确认数据正确      
  68.                          //异或校验,逐个字节异或得到校验码      
  69.                          byte  checksum = 0;   
  70.                          for  ( int  i = 0; i < len + 3; i++) //len+3表示校验之前的位置      
  71.                         {   
  72.                             checksum ^= buffer[i];   
  73.                         }   
  74.                          if  (checksum != buffer[len + 3])  //如果数据校验失败,丢弃这一包数据      
  75.                         {   
  76.                             buffer.RemoveRange(0, len + 4); //从缓存中删除错误数据      
  77.                              continue ; //继续下一次循环      
  78.                         }   
  79.                          //至此,已经被找到了一条完整数据。我们将数据直接分析,或是缓存起来一起分析      
  80.                          //我们这里采用的办法是缓存一次,好处就是如果你某种原因,数据堆积在缓存buffer中     
  81.                          //已经很多了,那你需要循环的找到最后一组,只分析最新数据,过往数据你已经处理不及时      
  82.                          //了,就不要浪费更多时间了,这也是考虑到系统负载能够降低。      
  83.                         buffer.CopyTo(0, binary_data_1, 0, len + 4); //复制一条完整数据到具体的数据缓存      
  84.                         data_1_catched =  true ;   
  85.                         buffer.RemoveRange(0, len + 4); //正确分析一条数据,从缓存中移除数据。      
  86.                     }   
  87.                      else   
  88.                     {   
  89.                          //这里是很重要的,如果数据开始不是头,则删除数据      
  90.                         buffer.RemoveAt(0);   
  91.                     }   
  92.                 }   
  93.                  //分析数据      
  94.                  if  (data_1_catched)   
  95.                 {   
  96.                      //我们的数据都是定好格式的,所以当我们找到分析出的数据1,就知道固定位置一定是这些数据,我们只要显示就可以了      
  97.                      string  data = binary_data_1[3].ToString( "X2" ) +  " " + binary_data_1[4].ToString( "X2" ) +  " "  +   
  98.                         binary_data_1[5].ToString( "X2" ) +  " "  + binary_data_1[6].ToString( "X2") +  " "  +   
  99.                         binary_data_1[7].ToString( "X2" );   
  100.                      //更新界面      
  101.                      this .Invoke((EventHandler)( delegate  { txData.Text = data; }));   
  102.                 }   
  103.                  //如果需要别的协议,只要扩展这个data_n_catched就可以了。往往我们协议多的情况下,还会包含数据编号,给来的数据进行      
  104.                  //编号,协议优化后就是: 头+编号+长度+数据+校验      
  105.                  //</协议解析>      
  106.                 /////////////////////////////////////////////////////////////////////////////////////////////////////////////     
  107.   
  108.                 builder.Clear(); //清除字符串构造器的内容      
  109.                  //因为要访问ui资源,所以需要使用invoke方式同步ui。      
  110.                  this .Invoke((EventHandler)( delegate   
  111.                 {   
  112.                      //判断是否是显示为16禁止      
  113.                      if  (checkBoxHexView.Checked)   
  114.                     {   
  115.                          //依次的拼接出16进制字符串      
  116.                          foreach  ( byte  b  in  buf)   
  117.                         {   
  118.                             builder.Append(b.ToString( "X2" ) +  " " );   
  119.                         }   
  120.                     }   
  121.                      else   
  122.                     {   
  123.                          //直接按ASCII规则转换成字符串      
  124.                         builder.Append(Encoding.ASCII.GetString(buf));   
  125.                     }   
  126.                      //追加的形式添加到文本框末端,并滚动到最后。      
  127.                      this .txGet.AppendText(builder.ToString());   
  128.                      //修改接收计数      
  129.                     labelGetCount.Text =  "Get:"  + received_count.ToString();   
  130.                 }));   
  131.             }   
  132.              finally   
  133.             {   
  134.                 Listening =  false ; //我用完了,ui可以关闭串口了。      
  135.             }   
  136.         }   
  137.   
  138.          private   void  buttonOpenClose_Click( object  sender, EventArgs e)   
  139.         {   
  140.              //根据当前串口对象,来判断操作      
  141.              if  (comm.IsOpen)   
  142.             {   
  143.                 Closing =  true ;   
  144.                  while  (Listening) Application.DoEvents();   
  145.                  //打开时点击,则关闭串口      
  146.                 comm.Close();   
  147.             }   
  148.              else   
  149.             {   
  150.                  //关闭时点击,则设置好端口,波特率后打开      
  151.                 comm.PortName = comboPortName.Text;   
  152.                 comm.BaudRate =  int .Parse(comboBaudrate.Text);   
  153.                  try   
  154.                 {   
  155.                     comm.Open();   
  156.                 }   
  157.                  catch  (Exception ex)   
  158.                 {   
  159.                      //捕获到异常信息,创建一个新的comm对象,之前的不能用了。      
  160.                     comm =  new  SerialPort();   
  161.                      //现实异常信息给客户。      
  162.                     MessageBox.Show(ex.Message);   
  163.                 }   
  164.             }   
  165.              //设置按钮的状态      
  166.             buttonOpenClose.Text = comm.IsOpen ?  "Close"  :  "Open" ;   
  167.             buttonSend.Enabled = comm.IsOpen;   
  168.         }   
  169.   
  170.          //动态的修改获取文本框是否支持自动换行。      
  171.          private   void  checkBoxNewlineGet_CheckedChanged( object  sender, EventArgs e)   
  172.         {   
  173.             txGet.WordWrap = checkBoxNewlineGet.Checked;   
  174.         }   
  175.   
  176.          private   void  buttonSend_Click( object  sender, EventArgs e)   
  177.         {   
  178.              //定义一个变量,记录发送了几个字节      
  179.              int  n = 0;   
  180.              //16进制发送      
  181.              if  (checkBoxHexSend.Checked)   
  182.             {   
  183.                  //我们不管规则了。如果写错了一些,我们允许的,只用正则得到有效的十六进制数      
  184.                 MatchCollection mc = Regex.Matches(txSend.Text, @ "(?i)[/da-f]{2}" );   
  185.                 List< byte > buf =  new  List< byte >(); //填充到这个临时列表中      
  186.                  //依次添加到列表中      
  187.                  foreach  (Match m  in  mc)   
  188.                 {   
  189.                     buf.Add( byte .Parse(m.Value, System.Globalization.NumberStyles.HexNumber));   
  190.                 }   
  191.                  //转换列表为数组后发送      
  192.                 comm.Write(buf.ToArray(), 0, buf.Count);   
  193.                  //记录发送的字节数      
  194.                 n = buf.Count;   
  195.             }   
  196.              else //ascii编码直接发送      
  197.             {   
  198.                  //包含换行符      
  199.                  if  (checkBoxNewlineSend.Checked)   
  200.                 {   
  201.                     comm.WriteLine(txSend.Text);   
  202.                     n = txSend.Text.Length + 2;   
  203.                 }   
  204.                  else //不包含换行符      
  205.                 {   
  206.                     comm.Write(txSend.Text);   
  207.                     n = txSend.Text.Length;   
  208.                 }   
  209.             }   
  210.             send_count += n; //累加发送字节数      
  211.             labelSendCount.Text =  "Send:"  + send_count.ToString(); //更新界面      
  212.         }   
  213.   
  214.          private   void  buttonReset_Click( object  sender, EventArgs e)   
  215.         {   
  216.              //复位接受和发送的字节数计数器并更新界面。      
  217.             send_count = received_count = 0;   
  218.             labelGetCount.Text =  "Get:0" ;   
  219.             labelSendCount.Text =  "Send:0" ;   
  220.         }   
  221.     }   
  222. }  

至此,你只要按这个协议格式发送数据到软件打开的串口。就能在数据的data标签显示出你的数据内容,我们现在是直接显示为:

01 02 03 04 05

也就是数据段内容。

运行截图:

请注意红色部分

发送模拟数据的界面,使用通用工具SSCOMM32.exe

sscomm32.exe 发送模拟数据的界面

我们在回顾一下,一般二进制格式数据就是这样分析,分析数据长度是否足够,找到数据头,数据长度,校验,然后分析。

分析方式很多。结合各自实际情况操作,可以使用序列化方式,但是wince不支持,也可以用BitConvert方式将连续的字节读取为某个类型的变量。

希望看到这里,能给你带来帮助,欢迎大家和我讨论,希望经验丰富的朋友不吝赐教。上一篇中,有朋友说用BeginInvoke可以避免死锁问题,我暂时没有线,没有测试成功,改天测试后再公布结果。

谢谢观赏,通讯协议分析系列,未完待续……

2010示例代码

2008示例代码

上一篇已经介绍了协议的组成,一个协议,一般具有 :协议头 +长度 +数据 +校验 , 文本格式可以直观的定义回车换行是协议的结尾,所以我们可以省略数据长度,增加协议尾。即: 协议头 + 数据 + 校验 + 数据尾 。

文本方式的数据比较容易分析。如果数据缓存,可以考虑用StringBuilder。或是不缓存也可以。文本格式数据大多有换行结尾。稍微修改即可。例如分析常见的NMEA 0183格式的卫星坐标数据GGA。

$GPGGA,121252.000,3937.3032,N,11611.6046,E,1,05,2.0,45.9,M,-5.7,M,,0000*77

$              开始

GPGGA     命令字

*              结尾

77            校验

对上一篇代码稍作修改就可以了。例子不贴了。文本格式比较简单,只是为了内容完整。贴来做参考。只有分析的地方简化很多。

  1. void  comm_DataReceived( object  sender, SerialDataReceivedEventArgs e)   
  2.         {   
  3.              if  (Closing)  return ; //如果正在关闭,忽略操作,直接返回,尽快的完成串口监听线程的一次循环     
  4.              try   
  5.             {   
  6.                 Listening =  true ; //设置标记,说明我已经开始处理数据,一会儿要使用系统UI的。      
  7.                  //文本格式比较简单,你可以死等。      
  8.                  string  line = comm.ReadLine(); //这就得到回车换行结尾的了。但是不是从头开始的就要检查了      
  9.                 /////////////////////////////////////////////////////////////////////////////////////////////////////////////     
  10.                  //<协议解析>      
  11.                  //因为恢复的代码在finally中。你可以直接的return      
  12.                  if  (line[0] != '$')  return ; //虽然可能有点垃圾,但是数据不重要。直接丢弃就可以了。后续的都是对的      
  13.                  int  star = line.IndexOf( "*" , 1);   
  14.                  if  (star == -1)  return ;   
  15.                  //根据$后面数据计算异或校验,并和*后面的数字对比。如果不同,也不进行分析。因为校验错误      
  16.   
  17.                  //当确定头尾存在,校验正确。就可以分析数据了。      
  18.                  //分析数据      
  19.                  //略      
  20.   
  21.                  //因为要访问ui资源,所以需要使用invoke方式同步ui。      
  22.                  this .Invoke((EventHandler)( delegate   
  23.                 {   
  24.                      //判断是否是显示为16禁止      
  25.                      if  (checkBoxHexView.Checked)   
  26.                     {   
  27.                          //依次的拼接出16进制字符串      
  28.                          foreach  ( byte  b  in  buf)   
  29.                         {   
  30.                             builder.Append(b.ToString( "X2" ) +  " " );   
  31.                         }   
  32.                     }   
  33.                      else   
  34.                     {   
  35.                          //直接按ASCII规则转换成字符串      
  36.                         builder.Append(Encoding.ASCII.GetString(buf));   
  37.                     }   
  38.                      //追加的形式添加到文本框末端,并滚动到最后。      
  39.                      this .txGet.AppendText(builder.ToString());   
  40.                      //修改接收计数      
  41.                     labelGetCount.Text =  "Get:"  + received_count.ToString();   
  42.                 }));   
  43.             }   
  44.              finally   
  45.             {   
  46.                 Listening =  false ; //我用完了,ui可以关闭串口了。      
  47.             }   
  48.         }  
  49. 串口是很简单的,编写基于串口的程序也很容易。新手们除了要面对一堆的生僻概念,以及跨线程访问的细节,还有一个需要跨越的难题,就是协议解析,上一篇已经说明了:

    一个二进制格式的协议 一般包含: 协议头 + 数据段长度 + 数据   + 校验

    一个Ascii格式的文本协议 ,一般包含: 数据头 + 正文 + 数据结束标识

    类似的命令可能很多,类似的代码也会重复写很多次。对于我,并不觉得这个有任何难度,但是,很多时候,需要写点类似东西的时候呢,我往往不想写,不是别的,要搭建一个这样的框架,这绝对是个体力活 ,而且还需要耐心 和细心 。

    从我上一次带项目,我就开始考虑编写通用的一个通讯库,支持很多功能,不过和公司内容结合紧密,不适合开源,更不适合推广。我重新组织、抽象了各个概念。希望能让新人朋友减少学习难度,更快的投入到其他方面。

    请注意,此文章我也不知道如何归纳,不算科普,不能算类库的介绍,我是在介绍如何设计一个这样的通讯库 。

    通讯库 ,并非串口库 , 所以,我希望有一个基类,可以描述各种通讯方法的基类或接口,微软已经这么做了,他把这个叫做Stream。我认为不好的理由是,提供了Length属 性、peek方法、seek方法却无法使用,很多方法和属性是不支持的,如果使用这个类操作硬件,就像一颗地雷,不小心就会写一个不支持的操作,而且会在 运行时报错。所以,我希望能针对流设备的硬件,重新设计,我抽象出了一个接口:ICommunication 。提供基本的打开、关闭、读写、字符集和有效数据长度等流设备的特性和操作。

    为了能有一个通用的配置类 ,我定义了一个接口:ICommunicationSetting 。

    当你实现一个设备的时候,你需要实现ICommunication,还需要编写一个设置的类,去实现ICommunicationSetting接 口。别觉得麻烦,这是为了能抽象的好,编写一个一劳永逸不用经常重写的通用代码。有了2个接口,我甚至可以开始编写依赖此接口的功能或软件了。当然,我还 有需要写有关协议的分析。

    既然协议是分2种 ,那自然要编写BinaryXXX 和TextXXX ,没错,有这样2个类。

    考虑的更详细一点,任何数据,都不是无限期有效的,比如你获取下位机发来的电压,过了几秒了,应该就无效了,所以要考虑定时失效,于是我实现了有效性检查。数据要在字节数组中查找,分析,通知。所以这些公共的部分,我抽出来了,我写了一个接口,叫做:IAnalyzer ,并编写了默认的实现,于是有了AnalyzeResult 类,同时,区分2种协议方式,创建了子类:BinaryAnalyzeResult 和TextAnalyzeResult 。

    那么,谁来使用ICommunication,IAnalyzer 呢?放心,联系有点紧密,我不会撒手扔给外面的,这样做反而更复杂了,不是么。所以我写了一个带有分析功能的类:WyzComm 。

    使用通讯库的

    这个类实现了数据的采集、缓存、分析器的调用,以及事件调用的通知。数据死锁的控制,所有你认为的麻烦事情,都在这里做了。那么,我编写这个类的时候,我肯定不知道未来有多少种协议是不是?那怎么办呢?我无法写死分析器,所以,我编写了接口:IAnalyzerCollection ,因为文章从串口说起,我首先提供了串口的实现:

    SerialPort (此类和微软的那个名字一样而已,但不是同一个),实现了ICommunication接口,我定义了一个SerialPortSetting类,实现了ICommunicationSetting。

    至此。通讯库的框架就完成了。而这也就是使用通讯库所需要关注的所有内容。下面,为了能进行实际的演示,我编写了简单的实现。来演示一种功能,假设 我有个程序,需要同时分析二进制数据格式和ASCII的文本数据格式,数据各不相同,使用了通讯库之后,我不需要重写数据的缓存、关闭的死锁处理、数据对 界面的通知。我只需要编写2个协议类,和1个协议集合类。我的数据分析工作就完成了。

    首先是一个文本协议,协议头是WYZ,协议尾是回车换行,中间是一个整形数字。我只需要设置好头、尾,编写数据分析。

    1. public   class  MyData1 : TextAnalyzeResult< int >   
    2. {   
    3.      public  MyData1()   
    4.     {   
    5.          this .BeginOfLine =  "WYZ" ;   
    6.          this .EndOfLine =  "/r/n" ;   
    7.     }   
    8.   
    9.      public   override   void  Analyze()   
    10.     {   
    11.          string  s = Encoding.GetString(Raw);   
    12.         Match m = Regex.Match(s,  "//d+" );   
    13.          if  (m.Success)   
    14.         {   
    15.              this .Data =  int .Parse(m.Value);   
    16.              this .Valid =  true ;   
    17.         }   
    18.     }   
    19. }  

    然后我定义了一个二进制协议,分析一条数据包含2个子项。

    我首先定义这个数据的具体类型

    1. public   class  SampleData   
    2. {   
    3.      public   int  Version {  get  set ; }   
    4.      public   float  Voltage {  get  set ; }   
    5.      public  SampleData()   
    6.     {   
    7.         Version = 0;   
    8.         Voltage = 0;   
    9.     }   
    10.      public   override   string  ToString()   
    11.     {   
    12.          return   string .Format( "{0},{1}" , Version.ToString(), Voltage.ToString());   
    13.     }   
    14. }  
    然后我编写协议分析类
    1. public   class  MyData2 : BinaryAnalyzeResult<SampleData>   
    2. {   
    3.      public  MyData2()      
    4.     {      
    5.          this ._mask =  new   byte [] { 0xAA, 0xBB, 0xCC };      
    6.          this .TimeOut = 5; //超过5秒,收不到数据,则此数据无效。      
    7.          //自定义校验方法,演示为逐个相加和随便一个数字取模,我选择的是42      
    8.          this .checksum = (buf, offset, count) =>      
    9.             {      
    10.                  byte  checksum = 0;      
    11.                  for  ( int  i = offset; i < offset + count; i++)      
    12.                 {      
    13.                     checksum = ( byte )((checksum + buf[i]) % 42);      
    14.                 }      
    15.                  return  checksum;      
    16.             };      
    17.     }   
    18.   
    19.      public   override   void  Analyze()   
    20.     {   
    21.          int  offset = _mask.Length + LenLength; //_mask.Length表示标记后的一个字节,_mask.Length+1表示标记后的第二个字节,有一个字节表示长度。      
    22.          this .Data.Version = BitConverter.ToInt32(Raw, offset + 0);   
    23.          this .Data.Voltage = BitConverter.ToSingle(Raw, offset + 4);   
    24.          this .Valid =  true ; //注意要设置数据有效状态      
    25.     }   
    26. }  

    完成了。一个基于串口的,同时分析2种数据的,数据具有有效性判断,支持独立数据通知界面,整体原始数据缓存显示的功能。完成了。

    为了演示功能,我写了新的校验方式,当然,你不用管,默认已经支持了异或校验,后续还会把常用校验都添加进去,crc16,crc32,奇偶校验等。

    模拟发送数据为:

    文本格式发送:WYZ123<CR><LF>

    二进制格式发送:AA BB CC 08 0A 00 00 00 FA 3E F7 42 05

    vs2008工程项目源码范例

原创粉丝点击
热门问题 老师的惩罚 人脸识别 我在镇武司摸鱼那些年 重生之率土为王 我在大康的咸鱼生活 盘龙之生命进化 天生仙种 凡人之先天五行 春回大明朝 姑娘不必设防,我是瞎子 干百合冷水泡了怎么办 牡丹籽油过期了怎么办 ps画布建小了怎么办 腰突然疼的受不了了怎么办 微信里面打不开表格怎么办? 浏览器未正常加载相关控件怎么办 猫的眼睛发炎了怎么办 橡胶手机壳松了怎么办 橡胶手机壳小了怎么办 苹果手机下载不了软件了怎么办 苹果手机浏览器下载不了软件怎么办 小狗吃了硬的棉花怎么办 小狗吃了隔尿垫里的棉花怎么办 眼睫毛掉进眼睛里怎么办 爱掉头发怎么办吃什么 头皮屑多头发干燥脱发怎么办 剪了头发后悔了怎么办 2岁宝宝发际线高怎么办 脱发怎么办吃什么能长发 后面头发睡平了怎么办 鸟羽毛长得不好怎么办 吃激素药头发掉怎么办 吃了药头发掉怎么办 头皮痒头发掉的厉害怎么办 西昌学院被记过了怎么办? 初三了数学太差怎么办 板绘线条不流畅怎么办 嘴被风吹歪了怎么办 被风吹的嘴歪了怎么办 怀孕一个多月见红了怎么办 b本扣了6分怎么办 b牌驾驶证扣6分怎么办 忘记了谁考证的密码怎么办 专升本学校有课怎么办 跨境额度超了怎么办 微商代购被骗了怎么办 减肥到了瓶颈期该怎么办 大润发超市把一件商品打两件怎么办 小红书上买到假货怎么办 主动退市股票钱怎么办 老板卷款逃跑财务怎么办