中二点 发表于 2021-3-12 10:42:48

Arduino 串口通信 读写优化


在使用arduino uno与多个传感器和上位机进行串口通信时,受制于arduino羸弱的性能,常常无法发挥传感器的全部性能.因此,我将简述一些提高arduino串口效率的技巧.
查阅官网后,我们可以看到,目前(2021/1/1),硬串口Serial共有如下函数:


| Functions       ||
| -------- | -------- |
| if(Serial)   | print()   |
| available()   | println()   |
| availableForWrite()    | read()   |
| begin()   | readBytes()   |
| end() | readBytesUntil() |
| find() | readString() |
| findUntil() |         readStringUntil()|
| flush() | setTimeout() |
| parseFloat | write() |
| parseInt() |         serialEvent() |
| peek() | /|

### 串口函数基础
#### 串口初始化阶段常用函数简介
`if(Serial)`常常被用于在`void setup()`阶段等待串口设置完成.官方示例代码如下:

```
void setup() {
//Initialize serial and wait for port to open:
Serial.begin(9600);
while (!Serial) {
    ; // wait for serial port to connect. Needed for native USB
}
}
void loop() {
//proceed normally
}
```
`Serial.begin()`和`Serial.end()`则分别被用于初始化串口以及关闭串口.注意初始化过程运行开销较大,因此如非特殊情况,请尽量不要在`void setup()`以外的地方使用.
`Serial.available()`可以获取到接收缓冲区Rx还有多少个字符,它返回的实际上是接收缓冲区的头尾指针的差值:

```
int HardwareSerial::available(void)
{
return ((unsigned int)(SERIAL_RX_BUFFER_SIZE + _rx_buffer_head - _rx_buffer_tail)) % SERIAL_RX_BUFFER_SIZE;
}
```

从代码中我们可以看出,当串口接收缓冲区Rx为空,那么`Serial.available()`的返回值为0.

`Serial.availableForWrite()`函数则能够在不影响`write()`这一过程的前提下,获取串口写缓存TX中能够写的字符的数量.

`Serial.flush()`目前的作用是等待串口完成write()这一过程.当然在不考虑具体输出,而纸上谈兵又要确保兼容性的情况下,`write()`函数的优化空间并不算大.

接下来最需要强调的就是`void serialEvent()`,这是串口中断函数,在每次循环之间,如果有数据传入硬串口的Rx端,那么就会调用这个函数.在中断里执行的代码由于不再出现在`void loop()`当中,因此可以省去调用`Serial.available()`这类函数进行判断的时间.以下面的代码为例:

```
String inputString="";
void setup(){
      Serial.begin(9600);
}
void loop(){
    while(Serial.available()){
      inputString=inputString+char(Serial.read());
      delay(2);
    }
    if(inputString.length()>0){
      Serial.println(inputString);
      inputString="";
    }
} //项目使用了 3220 字节,占用了 (9%) 程序存储空间.最大为 32256 字节.
//全局变量使用了204字节,(9%)的动态内存,余留1844字节局部变量.最大为2048字节.
```

```
String inputString = "";         // a String to hold incoming data
bool stringComplete = false;// whether the string is complete
void setup() {
Serial.begin(9600);
}
void loop() {
if (stringComplete) {
    Serial.println(inputString);
    inputString = "";
    stringComplete = false;
}
}
void serialEvent() {
while (Serial.available()) {
    char inChar = (char)Serial.read();
    inputString += inChar;
    if (inChar == '\n') {
      stringComplete = true;
    }
} //项目使用了 3080 字节,占用了 (9%) 程序存储空间.最大为 32256 字节.
}   //全局变量使用了205字节,(10%)的动态内存,余留1843字节局部变量.最大为2048字节.
```

通过把回车符设为字符串的终止符,我们把代码执行的速度和代码量都做到了一定的优化.

#### Serial.read()与Serial.peek()
接下来,我们来对比一下HardwareSerial中的最后两个自带函数`peek()`和`read()`.先放代码:

```
int HardwareSerial::peek(void)
{
if (_rx_buffer_head == _rx_buffer_tail) {
    return -1;
} else {
    return _rx_buffer;
}
}
```

```
int HardwareSerial::read(void)
{
// if the head isn't ahead of the tail, we don't have any characters
if (_rx_buffer_head == _rx_buffer_tail) {
    return -1;
} else {
    unsigned char c = _rx_buffer;
    _rx_buffer_tail = (rx_buffer_index_t)(_rx_buffer_tail + 1) % SERIAL_RX_BUFFER_SIZE;
    return c;
}
}
```

由此可见,`peek()`相比`read()`在C语言代码的层面省去了创建变量和Rx缓存尾指针自增这两个过程.在实际代码中,使用`peek()`会比`read()`更快吗?不同于我们朴素的直觉,实际效率取决于具体的使用场景.

### 串口优化实例
有时串口传输的数据具有多种格式,我们来看一个激光测距传感器的例子:

| D=1.314m,520#<CR><LF>       | 表示距离为1.314米,回光量为520 |
| -------- | -------- |
| E=258<CR><LF>|表示超出量程,错误码为258|

       
让我们暂且放下吐槽这些谐音梗的想法,如果需要让arduino提取出测距得到的距离信息,并且把发生错误时的距离返回值设为-1,我们可以使用如下写法:

```
if(Serial.read()=='D')
distance = GetDistance();//GetDistance指用于获取距离的一段伪代码
else
distance = -1;
```

我们可以把上述代码修改为

```
if(Serial.peek()=='D')
distance = GetDistance();//GetDistance指用于获取距离的一段伪代码
else
distance = -1;
```

这样改动能不能如我们猜测的一样达到性能提升的效果呢?我们需要深入分析一下.`read()`通过移动Rx缓冲区的尾指针,删去了Rx缓冲区中的一个字节 ,而`peek()`则对Rx缓冲区没有影响.在这个激光测距的场景下,我们之后还要继续从Rx缓冲区中读取数据才能获得距离信息.这也就意味着,如果我们在`GetDistance()`伪代码中仍然依次使用`read()`,一个字符一个字符地把剩余的数据从Rx缓冲区读取出来,再进行处理,则在判断部分使用`read()`,能够使`GetDistance()`伪代码部分少执行一次read();而在判断部分使用`peek()`反而浪费了数个时钟周期.因此问题的关键就在于`GetDistance()`伪代码的具体写法上.让我们继续深入到继承自Stream工具类(Utility Class)的函数.

### Stream工具类
先说结论:如果要追求程序运行的速度,那么最好不要使用Stream类中的函数,而应该自己把对应的函数,用我们正在使用的Stream的子类(例如Serial中的`read()`)重构一遍.这样做的原因,以及重构的方法,让我们逐一对比Stream库来解释.

首先,Stream库为了统一函数超时报错的返回值为-1(比如Serial库中,串口缓存失效的情况),引入了`timeread()`和`timepeek()`函数

```
// protected method to read stream with timeout
int Stream::timedRead()
{
int c;
_startMillis = millis();
do {
    c = read();
    if (c >= 0) return c;
} while(millis() - _startMillis < _timeout);
return -1;   // -1 indicates timeout
}
// protected method to peek stream with timeout
int Stream::timedPeek()
{
int c;
_startMillis = millis();
do {
    c = peek();
    if (c >= 0) return c;
} while(millis() - _startMillis < _timeout);
return -1;   // -1 indicates timeout
}
```

这也就意味着一旦出现超时报错,函数执行的时间开销会立刻达到**百毫秒**的量级.如果不得不使用超时报错或调用Stream库函数,最好在初始化阶段使用`setTimeout()`缩短超时的时限.后面介绍的几个函数会多次调用上面两个函数,就不多解释了.

#### 数值的提取与parse
为了从测距模块返回的字符串中提取数值信息,我首先编写了如下代码:

```
if(Serial.read()=='D')//测距信息有效,Rx缓冲区第一个字符为'D'
{
distance = Serial.parseFloat();//获取距离
while(Serial.available())//清空Rx缓冲区,因为我并不需要井号后面的光强数据
    Serial.read();
}
else if(Serial.read()=='E')//我并不需要报错的具体信息
{
while(Serial.available())//清空Rx缓冲区
    Serial.read();
}
```

首先我们来看与激光测距这个案例相关的函数parseInt()和parseFloat():

```
long Stream::parseInt(LookaheadMode lookahead, char ignore)
{
bool isNegative = false;
long value = 0;
int c;

c = peekNextDigit(lookahead, false);
// ignore non numeric leading characters
if(c < 0)
    return 0; // zero returned if timeout

do{
    if(c == ignore)
      ; // ignore this character
    else if(c == '-')
      isNegative = true;
    else if(c >= '0' && c <= '9')      // is c a digit?
      value = value * 10 + c - '0';
    read();// consume the character we got with peek
    c = timedPeek();
}
while( (c >= '0' && c <= '9') || c == ignore );

if(isNegative)
    value = -value;
return value;
}

// as parseInt but returns a floating point value
float Stream::parseFloat(LookaheadMode lookahead, char ignore)
{
bool isNegative = false;
bool isFraction = false;
long value = 0;
int c;
float fraction = 1.0;

c = peekNextDigit(lookahead, true);
    // ignore non numeric leading characters
if(c < 0)
    return 0; // zero returned if timeout

do{
    if(c == ignore)
      ; // ignore
    else if(c == '-')
      isNegative = true;
    else if (c == '.')
      isFraction = true;
    else if(c >= '0' && c <= '9'){      // is c a digit?
      value = value * 10 + c - '0';
      if(isFraction)
         fraction *= 0.1;
    }
    read();// consume the character we got with peek
    c = timedPeek();
}
while( (c >= '0' && c <= '9')|| (c == '.' && !isFraction) || c == ignore );

if(isNegative)
    value = -value;
if(isFraction)
    return value * fraction;
else
    return value;
}
```

`parseInt()`和`parseFloat()`两个函数分别返回Rx缓冲区第一个有效的长整数/浮点数.看上去他们对于提取测距信息很有用.然而需要注意的是,根据`peekNextDigit()`函数,如果一个字符串直接以数字结尾,那么这两个函数都需要等待超时才能返回数值.因此有条件的情况下,应该把传感器或上位机的指令设置为以某个符号结尾(比如#或者!),而不要直接让数字作为结尾,这两个函数的数值提取才不会卡住.根据`peekNextDigit()`的内容可以看出,代码的编写者通过专门编写这一函数实现了在判断数字结尾并提取数值后,把数字后的字符保留在缓冲区这一功能.与此同时,函数还加入了ignore这一参数以适应更多的格式.然而更多情况下,我们仅仅需要获得关键的数值信息,而其他的格式信息我们完全可以抛弃,这时,我们就可以仅使用read()对`parseInt()`进行重构.熟悉读入优化的同学很快就能理解下面代码的含义.

```
template<class T>void getInt(T &x)
{
x=0;int f=0;char ch=Serial.read();
//while(ch<'0'||ch>'9') {f|=(ch=='-');ch=Serial.read();}
//本案例的激光测距测不出负数,如果存在负数作为报错的情况则取消注释
while(ch>='0'&&ch<='9')
{
x=(x<<1)+(x<<3)+(ch^48);
ch=Serial.read();
}
//x=f?-x:x;
return;
}//getInt(a);
```

注意,如果把`parseInt()`修改为上述代码,那么Rx缓冲区将会少一个字符,同时在跳出while循环的时候,这个缺少的字符就已经被赋值给了char变量ch.

举个例子:


| 读取函数| 读取前Rx字符串 | 读取后Rx字符串 |
| -------- | -------- | -------- |
| parseInt()   | D=1.314   | .314   |
| getInt()   | D=1.314    | 314   |
               
必要的时候我们可以在此处添加一个判断ch的代码,来实现更多的功能.

接下来`parseFloat()`的重构就更加简单了.在本案例中,激光测距模块的返回值是一个小数,因此我们完全可以使用上面的`getInt()`函数获取小数点前的数据;返回值的小数部分可能形如0.052,0.52,0.520.如果小数点后的位数是确定的(比如3位),那么就可以再用一次`getInt()`,然后把小数点后的数据乘以0.001后相加,或者干脆把小数点前的数据乘以1000再两者相加,把以米为单位转换成以毫米为单位,这样避免浮点数计算,能使用位运算提高运算速度.我们把定长浮点数提取函数命名为`getFloat_1()`.而不定长浮点数的提取函数就命名为`getFloat_2()`.请看代码:

```
template<class T>void getFlaot_1(T &x)//本函数会把1.314米转化为1314毫米
{                                     //x最好是long或更大的整数类形,防止溢出
x=0;char ch=Serial.read();            //当然激光测距的距离有限,所以本案例中会使用int
while(ch>='0'&&ch<='9')
{
x=(x<<1)+(x<<3)+(ch^48);
ch=Serial.read();
}
x=(x<<10)-(x<<4)-(x<<3);//x = x*1024 - x*16 - x*8
ch = Serial.read();
x+=(ch^48)*100;
ch = Serial.read();
x+=(ch^48)*10;
ch = Serial.read();
x+=ch^48;//小数点后3位
return;
}//getFloat_1(a);
```

```
void getFlaot_2(float &x)//本函数返回浮点数
{                                    
int   a=0;
int Dec=0;
float F = {1,0.1,0.01,0.001,0.0001,0.00001};//打表,激光测距的精度有限
charch=Serial.read();
while(ch>='0'&&ch<='9')
{
    a=(a<<1)+(a<<3)+(ch^48);
    ch=Serial.read();
}
x+=a;
ch=Serial.read();
while(ch>='0'&&ch<='9')
{
    a=(a<<1)+(a<<3)+(ch^48);
    Dec++;
    ch=Serial.read();
}
x += a*F;
}
```

#### 缓冲区清空与相关优化
在完成数据读入的部分之后,我们再来看原先的代码:

```
if(Serial.read()=='D')//测距信息有效,Rx缓冲区第一个字符为'D'
{
distance = Serial.getFloat_2();//获取距离
}
while(Serial.available())//清空Rx缓冲区
    Serial.read();
```

注意到我使用**while循环来清空Rx缓冲区**,从而确保下一次read()读到的缓冲区的首字符就是测距信息的第一个字符.然而在清空缓冲区的过程中,很可能会出现两次测距数据都已经传入Rx缓冲区的情况,这时再用这种粗暴的清空方式可能会导致某次测距的信息被跳过.然而,我又不想把报错的信息(比如E = 258) 当成距离信息,因此,我换用了另一种写法:

```
if(Serial.find('D'))//测距信息有效,Rx缓冲区第一个字符为'D'
{
distance = Serial.getFloat_2();//获取距离
}
```

#### find()与findUntil()
为什么说上面的写法也是可行的呢?接下来我们就来看看`find()`和`findUntil()`相关的函数簇
(下列代码的顺序按照调用的层次自顶向底排版)

```
boolStream::find(char *target)
{
return findUntil(target, strlen(target), NULL, 0);
}
bool Stream::find(char *target, size_t length)
{
return findUntil(target, length, NULL, 0);
}
boolStream::findUntil(char *target, char *terminator)
{
return findUntil(target, strlen(target), terminator, strlen(terminator));
}//根据上面一行代码可以理解findUntil()的参数包括搜索目标和搜索终止符
bool Stream::findUntil(char *target, size_t targetLen, char *terminator, size_t termLen)
{
if (terminator == NULL) {
    MultiTarget t = {{target, targetLen, 0}};
    return findMulti(t, 1) == 0 ? true : false;
} else {
    MultiTarget t = {{target, targetLen, 0}, {terminator, termLen, 0}};
    return findMulti(t, 2) == 0 ? true : false;
}
}

int Stream::findMulti( struct Stream::MultiTarget *targets, int tCount) {
// any zero length target string automatically matches and would make
// a mess of the rest of the algorithm.
for (struct MultiTarget *t = targets; t < targets+tCount; ++t) {
    if (t->len <= 0)
      return t - targets;
}

while (1) {
    int c = timedRead();
    if (c < 0)
      return -1;

    for (struct MultiTarget *t = targets; t < targets+tCount; ++t) {
      // the simple case is if we match, deal with that first.
      if (c == t->str) {
      if (++t->index == t->len)
          return t - targets;
      else
          continue;
      }
      // if not we need to walk back and see if we could have matched further
      // down the stream (ie '1112' doesn't match the first position in '11112'
      // but it will match the second position so we can't just reset the current
      // index to 0 when we find a mismatch.
      if (t->index == 0)
      continue;

      int origIndex = t->index;
      do {
      --t->index;
      // first check if current char works against the new current index
      if (c != t->str)
          continue;

      // if it's the only char then we're good, nothing more to check
      if (t->index == 0) {
          t->index++;
          break;
      }

      // otherwise we need to check the rest of the found string
      int diff = origIndex - t->index;
      size_t i;
      for (i = 0; i < t->index; ++i) {
          if (t->str != t->str)
            break;
      }

      // if we successfully got through the previous loop then our current
      // index is good.
      if (i == t->index) {
          t->index++;
          break;
      }

      // otherwise we just try the next index
      } while (t->index);
    }
}
// unreachable
return -1;
}
```

显然,Serial.find('D');,本质上就是循环执行`timeRead()`,直到遇到’D’,就返回true.当然,如果没有遇到’D’,就会需要等待超时,函数才能返回-1.
那么,`find()`相关的函数应该如何优化呢?首先,为了提高执行`find()`的性能,减少函数一层一层调用时的寄存器和内存开销,最好直接把所有的find()替换为findUntil(target,length,NULL,0)这样的格式.接下来,通过简略地阅读`find()`函数簇下最底层的findMulti()函数,我们可以确定以下几个事实.`findMulti()`的编写思路更加适合不定长、不定内容的字符串的搜索和匹配. 如果实际使用的过程中仅仅需要搜索某个字符,完全可以自行重构.

```
if(Serial.available()){
while(Serial.read()!='D')
    continue;
flag = 1;//有一说一这段代码挺无脑的,但是好用就行
}
1
2
3
4
5
readBytes()与readString()
最后,我们来看看Stream类中与read()相关的函数有哪些优化空间吧.

// read characters from stream into buffer
// terminates if length characters have been read, or timeout (see setTimeout)
// returns the number of characters placed in the buffer
// the buffer is NOT null terminated.
size_t Stream::readBytes(char *buffer, size_t length)
{
size_t count = 0;
while (count < length) {
    int c = timedRead();
    if (c < 0) break;
    *buffer++ = (char)c;
    count++;
}
return count;
}
// as readBytes with terminator character
// terminates if length characters have been read, timeout, or if the terminator characterdetected
// returns the number of characters placed in the buffer (0 means no valid data found)
size_t Stream::readBytesUntil(char terminator, char *buffer, size_t length)
{
size_t index = 0;
while (index < length) {
    int c = timedRead();
    if (c < 0 || c == terminator) break;
    *buffer++ = (char)c;
    index++;
}
return index; // return number of characters, not including null terminator
}
String Stream::readString()
{
String ret;
int c = timedRead();
while (c >= 0)
{
    ret += (char)c;
    c = timedRead();
}
return ret;
}
String Stream::readStringUntil(char terminator)
{
String ret;
int c = timedRead();
while (c >= 0 && c != terminator)
{
    ret += (char)c;
    c = timedRead();
}
return ret;
}
```

根据上述代码,我们可以看出这几个函数(`readBytes()`,`readBytesUntil()`,`readString()`,`readStringUntil()`)的代码效率都已经很高了,因此如果要追求速度的话,我们可以直接复制代码,然后把`timeRead()`改为`read()`来提高效率.更加平常的情况是,我们结合使用这几个函数把串口数据读入某个字符串中再进行处理.这样一来,串口传输造成格式不完整的问题就更容易解决.

### 串口优化方案的选择
总之我们回到一开始的问题,究竟是`peek()`更快还是`read()`更快呢?只要在进行首字符的判断之后,还要再从**Rx缓冲区获取数据**,那么使用`read()`会让整个读取过程的速度更快.但是不排除一种情况,比如在100次测量中,可能只有2次返回有效的数据(比如D=1.314),而剩下的返回值都是报错(比如E=258).那么这时,使用`peek()`,那么100次测量所执行的总时钟周期会少于使用read()的情况.也就是说,当存在极端概率的情况下,数学期望对于代码的选择是会产生影响的.当然这样就会涉及到汇编代码,以及不同汇编指令的周期数,超过了本文所需要涉及的范围.

同时我们也了解到,Stream类的函数为了兼容包括Serial,Wire,Ethernet,SD 等各个子类,同时为了确保通用性,在代码层面牺牲了一定的效率.我们可以直接参考源码,做一些针对性的修改来提高代码执行的效率.

### 总结
综上,在形如**arduino,pyboard**,或者其他**使用库函数对单片机进行编程**的过程中,会缺少一些常见的编译器优化选项.这时,我们如果仍然想要提升代码的执行效率,就必须以肉身对抗-O3优化为目标,对代码和变量进行细致的分析.只有这样,我们才能够接近并超越编译器的领域,达到-O4的层次.

注释:
gcc -O4 emails your code to Jeff Dean for a rewrite.

参考资料:
https://www.arduino.cc/reference/en/language/functions/communication/stream/
https://www.arduino.cc/reference/en/language/functions/communication/serial/
L1和L4激光测距模块说明书2019版.pdf
————————————————
版权声明:本文为CSDN博主「小平友littlePING」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/qq_27133869/article/details/113791903

pATAq 发表于 2021-3-23 22:44:39

mark一下
页: [1]
查看完整版本: Arduino 串口通信 读写优化