C/C++ union类型实现单片机串口定长数据帧协议

union类型介绍与数据帧格式

C/C++均支持名为union(联合)的数据类型。union类型的成员将共用内存空间。就我们制定通讯协议的目的而言,可以把union理解为看待同一内存空间的不同视角。

例如以下数据帧定义:

union FrameRcv
{ 
    struct 
    { 
        uint16_t checksum; 
        uint16_t spd_input; 
        int16_t steer_angle; 
        uint16_t odometry_spd; 
        uint8_t reserved[2]; 
    }values; 
    uint8_t raw_bytes[sizeof(values)]; 
    uint16_t raw_uint16[sizeof(values)/2]; 
};

这是笔者为一辆小车进行控制时使用的串口协议帧定义。利用union类型,我们实现了三种不同的“视角”,也就提供了三种操作数据的方法。其中一者被程序修改,另外两种“视角”所见到的数据也就一并改变了。

成员 含义
values 按照具体含义理解数据帧。例如,checksum是校验和,spd_input是提供给小车的给定速度等。这里可以加入不同的数据类型,包括复杂的结构体和对象。这样一来,程序逻辑可以直接获取各项数据,而无需进行字节到整数等类型的转换。
raw_bytes 字节数组,长度使用sizeof(values)即可。串口是以字节为单位的进行收发的。
raw_uint16 16位无符号整数数组。由于我们规定了长度为16位的校验位,计算校验和需要以2字节为单位进行累加。校验和的计算方法可见我的另一篇文章。

注意:

  • 此协议中,帧的总长度必须是2字节的倍数,否则无法计算校验和。可以使用保留位填充对齐。
  • 此协议不包括帧头和帧尾,但是它们是必要的,可以由收发数据的函数进行处理。一旦添加了帧头帧尾,便可以应用所谓“恐慌模式”的错误恢复策略:一旦校验失败,则不断向后丢弃字符直至找到下个帧起始符。诚然,这个起始符完全可能是数据的一部分,但是只要重复这一过程,就必定能找到一个正确的帧。

数据帧收发函数

template <typename Frame> bool GetFrame(Frame&amp; rcv_frame_container)内存
{//返回值代表接收是否成功
 //假设此函数是某实现循环缓冲区的类的成员函数
    static_assert(std::is_union<Frame>::value,"Template argument is not an union.");
    static_assert(sizeof(Frame)%2 == 0,"The length of the frame must be multiple of 2");
    //下面检查接收缓冲区内是否已接收到足够字节
    if(this->GetCountInRxBuffer() < sizeof(Frame) + 2)//+2字节对应起始符和终止符
    	return false;
    //恐慌模式的错误恢复:不断向下读取,直到找到下一个帧起始字符
    while(*this->rxb_head_ptr != FRAME_START)
    {
    	++rxb_head_ptr;//找到下一个起始符
    	if(rxb_head_ptr - rxbuffer >= rxbufferlen )//头指针超出缓冲区末尾
    	    rxb_head_ptr = rxbuffer;
    	if(rxb_head_ptr == rxb_tail_ptr)
    	    return false;
    }
    //下面是具体的接收数据帧算法(例如从循环缓冲区) 
    Frame frame;
    ++rxb_head_ptr;//缓冲区头指针+1,跳过起始符0XFF(例如)
    for(uint8_t i = 0 ;i<sizeof(Frame);i++)
    {
    	if(rxb_head_ptr == rxb_tail_ptr)
    	    return false;//缓冲区为空时
    	frame.raw_bytes[i] = *rxb_head_ptr;//使用1字节的角度看待数据
    	++rxb_head_ptr;
    	if(rxb_head_ptr - rxbuffer >= rxbufferlen )//头指针超出缓冲区末尾
    	    rxb_head_ptr = rxbuffer;
    }
    //下面计算校验和
    for(uint8_t i = 0 ;i<sizeof(Frame)/2;i++)
    {
    	sum+=frame.raw_uint16[i];//于是使用2字节的角度看待数据
    }
    if((sum>>16) + static_cast<uint16_t>(sum) != 0xFFFF)
    //将溢出高位加至低位然后按位求反,不为0则校验失败
    	return false;
    rcv_frame_container = frame;
    return true;
}

如果使用C++编写程序,我们可以使用模板函数写出更具可复用性的代码,如上所示。只要所定义的union类型满足要求,我们就可以在不修改收发函数的情况下任意更改数据帧类型。函数中使用的循环和判断需要用到数据帧长度的,都可以利用sizeof(Frame)来静态地替换。

上述代码中接收数据使用了raw_bytes,计算校验和使用了raw_uint16。当数据帧被发给函数的调用者时,调用者就又能够以具体含义的方式访问数据,避免了另写程序对帧含义进行解释。

附录:校验和计算方法

以本文中使用的16位校验和为例:

  1. 以16位为单位进行求和(设校验位为0)
  2. 将溢出的进位加到最低位上
  3. 按位取反得校验位
  4. 接收方对含校验位的数据帧执行相同过程,应得结果为0

举例:
数据帧: 00 00 11 11 FF FF
                 ^^^^^16位校验位
求和:0+1111+FFFF=11110
溢出高位加至低位: 1110+1=1111
按位求反 ~1111 = EEEE

接收方收到的数据帧 : EE EE 11 11 FF FF
                                        ^^^^^校验位
求和:EEEE+1111+FFFF = 1FFFE
溢出高位加至低位: FFFE + 1 = FFFF
按位求反:~FFFF = 0000
于是校验成功。

此种方法校验比较机械,程序实现也很容易。

发表评论

电子邮件地址不会被公开。 必填项已用*标注