【问题标题】:Serial Communication Packet Organization for an Embedded System嵌入式系统的串行通信包组织
【发布时间】:2021-01-13 16:16:50
【问题描述】:

我有一个嵌入式项目,我正在实现一个库来处理 2 个微处理器之间的串行通信。我有一个结构,可以为随着时间的推移可能会添加的数据包提供一种可扩展的方法。

需要有不同的数据包携带可变长度的信息。每种类型的数据包由接收它的处理器以不同的方式解释。

只要有一个字节进入,就会执行读取。当一个完整的数据包到来时,调用消费者函数来尽快处理它。

我想解释我的方法,并就其弱点以及如何改进它获得一些反馈。

目前的方法:

  • 创建一个显示数据成员和长度的结构。
#define PASSWORD_PACKET_BYTES   5
typedef union
{
    uint8_t PasswordBuffer[PASSWORD_PACKET_BYTES];
    struct
    {
        uint32_t Password;
        uint8_t  CRC;
    };
}password_packet_t;
  • 创建一个包含所有类型数据包的联合和一个 uint_8 缓冲区。
  • 创建一个结构,用于保存读取的字节、正在读取的当前数据包类型(联合标记)、转义状态。
#define DISPLAY_PACKET_BYTES    16
typedef struct
{
    union
    {
        uint8_t SerialBuffer[DISPLAY_PACKET_BYTES];
        password_packet_t PasswordPacket;
    };
    struct
    {
        uint8_t CurrentOperation;
        uint8_t BytesRead;
        bool EscapeNext;
    };
}disp_receive_packet_t;
  • 创建一个显示不同类型数据包值的枚举(还包含转义字符的值)。
enum
{
    RECEIVE_NO_OPERATION = 0xA0,
    ACK, 
    PASSWORD,
    VERSION_REQUEST,
    RECEIVE_ESCAPE,
}DISP_RECEIVE_OPERATIONS;

功能的实现: - 每当读取一个字节时,检查它是否是一个新操作,如果是,则更改联合标记。 -Else 如果转义字符值设置转义标志。 -Else 将数据推送到缓冲区,增加读取的字节数。 还为当前操作调用检查完成函数(switch 语句)。 如果完成,则处理数据包并重置控制结构。 如果没有,什么都不会发生。

void DispRead1B(void)
{
    static uint8_t ReadByte;

    ReadByte = (uint8_t)UARTCharGet(DISP_BASE);

    if(DisplayPacket.EscapeNext)
    {
        DisplayPacketPushByte(ReadByte);
        DisplayPacket.EscapeNext = false;
    }
    else
    {
        if(ReadByte == RECEIVE_ESCAPE)
        {
            DisplayPacket.EscapeNext = true;
        }
        else if((ReadByte < RECEIVE_ESCAPE) && (ReadByte > RECEIVE_NO_OPERATION))
        {
            DisplayPacketChangeOperation(ReadByte);
        }
        else
        {
            DisplayPacketPushByte(ReadByte);
        }
    }
}

void DisplayPacketChangeOperation(uint8_t Operation)
{
    DisplayPacket.CurrentOperation = Operation;
    DisplayPacketResetBytesRead();
    DisplayPacket.EscapeNext = false;
    DisplayPacketCheckOperationFinished();
}

void DisplayPacketPushByte(uint8_t Value)
{
    DisplayPacket.SerialBuffer[DisplayPacket.BytesRead] = Value;
    DisplayPacketIncrementBytesRead();
    DisplayPacketCheckOperationFinished();
}

void DisplayPacketIncrementBytesRead(void)
{
    DisplayPacket.BytesRead += 1;
    if(DisplayPacket.CurrentOperation == RECEIVE_NO_OPERATION)
    {
        DisplayPacket.BytesRead = 0;
    }
}

bool DisplayPacketCheckOperationFinished(void)
{
  bool PacketFinished = false;
  switch (DisplayPacket.CurrentOperation)
  {
      case ACK:
          PacketFinished = DisplayPacketCheckAckFinished(); break;
      case VERSION_REQUEST:
          PacketFinished = DisplayPacketCheckVersionRequestFinished(); break;
      case PASSWORD:
          PacketFinished = DisplayPacketCheckPasswordFinished(); break;
      default:
          return false;
  }
  if(PacketFinished)
  {
      DisplayPacketChangeOperation(RECEIVE_NO_OPERATION);
  }
  return PacketFinished;
}

bool DisplayPacketCheckAckFinished(void)
{
    if(DisplayPacket.BytesRead == ACK_PACKET_BYTES)
    {
        if(CheckSysFlagNotSet(KeepAliveAckReceived))
        {
          UpdateSysFlag(KeepAliveAckReceived,true);
          StartAliveTimer();
        }
        else
        {
          UpdateSysFlag(ConnectionError,false);
          ReloadAliveTimer();
        }
        return true;
    }
    return false;
}

bool DisplayPacketCheckVersionRequestFinished(void)
{

    if(DisplayPacket.BytesRead == VER_REQ_PACKET_BYTES)
    {
      UpdateSysFlag(FirmwareVerRequested,true);
      return true;
    }
    return false;
}

bool DisplayPacketCheckPasswordFinished(void)
{
    if(DisplayPacket.BytesRead == PASSWORD_PACKET_BYTES)
    {
        PasswordPacket = DisplayPacket.PasswordPacket;
        UpdateSysFlag(PasswordReceived,true);
        return true;
    }
    return false;
}

使用这种方法不需要序列化,因为联合会处理它。此外,如果需要备份数据副本,复制数据包的语法也很简单。 IE。 PacketType = CommUnion.PacketType;

添加新数据包时。创建一个结构并将其添加到联合中,添加新的枚举字段,并添加一个 case 到 checkfinished switch 语句中,该语句调用该数据包的函数。

这需要库的源代码在我不喜欢的多行上进行更改。

有没有办法以更简单的方式实现类似的功能,需要外部更改而编译的通信结构可以保持不变?

第二种方法(使用函数指针):

  • 摆脱联合。控制结构仍然存在,因为我们需要跟踪当前的数据包类型和读取的字节数、转义等。
  • 摆脱枚举方法来创建数据包值。创建函数来为不同的数据包注册检查完成的函数,并像枚举方法一样为它们分配值。 register 函数是一个函数指针数组,用于检查函数。
  • 在启动时,所有数据包结构都被初始化。(主程序或项目特定模块的更改不在库中。)
  • 然后使用注册功能注册所有数据包。这样,数据包的数量和转义字符的值可以在运行时计算(如果编译器很智能,则可以在编译时计算)。
  • 当您压入一个数据字节时,检查完成函数指针用于调用该函数。该函数序列化(或调用此类函数)并在数据包完全接收时处理数据包。

这种方法的好处是创建了数据包的实例(无论哪种方式都需要这样做)并调用寄存器。我觉得需要改变的东西更少,这应该减少人为错误(理想情况下)。

如果有人愿意与我讨论这两种方法的优缺点,或者向我指出一种更精简的做事方式,我将不胜感激。

【问题讨论】:

  • 总的来说,结构和联合是危险的,因为填充。如果您希望使用 struct/union 来反映数据协议,则必须确保没有填充/对齐问题。在较大的 MCU(32 位)上,这通常意味着使用 #pragma pack 或类似的非标准扩展名禁用结构的填充。这样的代码不能在编译器之间移植。如果需要 100% 可移植的标准 C,那么就没有办法编写序列化/反序列化例程。
  • 至于您的 struct/union/enum 问题,如果没有具体的代码示例,很难理解您的意思。或者至少是结构/联合声明。请不要描述代码,而是发布它。或者它的简化版本。不编译但类似于 C 的伪代码也可以。
  • 我们使用的是 8 位和 32 位处理器。所有的数据包都被设计成首先在它们最大的数据成员中进行数据包,然后再变小。一切都是 32,16 或 8 位变量。结构以确认没有填充的方式组织。最后的填充是可以的,因为每种类型的数据包都有 1 或 2 个实例。我将尝试添加代码示例,但哪一部分令人困惑?第一种还是第二种方法?
  • 我认为结构/联合声明本身可能解释了很多。可能还有选择数据包类型的代码。
  • 我为第一个实现添加了代码。第二个处于构思阶段,所以我实际上还没有它的代码。希望这有助于解释它。

标签: c serialization embedded uart


【解决方案1】:

数据结构设计

使用联合,您必须强制它按位(#pragma pack)以确保数据正确。有了这个修复,我认为联合方法工作得很好,因为我从事过类似的实现

但还有其他事情我会担心。

可变长度数据包

需要有不同的数据包携带可变长度的信息。每种类型的数据包由接收它的处理器以不同的方式解释。

在您忽略尾随位的情况下实现固定长度的数据包会容易得多。如果您使用可变数据包长度,具体取决于您使用的外围设备,但我假设为 UART,您将配置驱动程序以确定何时完全接收数据包(我假设您的 EscapeNext 标志)。否则,您将无法知道收到的数据是否有效。

使用转义标志,您必须确保您的主要有效负载永远不会包含相同的字节数据,否则您的驱动程序会错误地认为数据包已被完全接收。

您可以使用某种类型的外设接收超时来处理可变长度,如果位之间经过了太多时间,则会触发该超时,但是仍然有许多变量可以使用这种类型的实现

** 编辑 如果您仍打算使用可变长度,那么您肯定需要让数据包的一部分表示数据包的长度。

使用 CRC 与使用奇偶校验

要确认数据包的整体完整性,您需要使用 CRC。 CRC 可以确认您的数据包的整体有效性,因为您可能不知道您是否在整个数据包中丢失了一个字节。

奇偶校验只在字节级别使用,由于外围设备不关心它需要发送的数据是否是垃圾,它不会知道它是否把整个数据包搞砸了。

Protip:固定长度的数据包 = 更少的调试时间

使用更快的波特率和固定长度的数据包的好处是不那么复杂,因此调试时间更短。就数据包传输时间而言,这很可能会超过使用可变长度数据包所带来的好处。 在 921k 波特率下,您应该能够每 8 或 9 微秒传输一个字节。 如果您处理的字节数少于 10,那么每个数据包将节省 0.1 毫秒或更短的时间。

Protip:DMA

我不知道您使用的是什么 MCU 或库,但理想情况下,您可以使用固定长度的数据包设置 DMA 并配置 DMA Rx Complete 中断。 DMA 将允许您将字节移动到另一个内存位置,并在它已满时通知您(再次取决于 MCU)。因此需要监控的内容更少,但需要更多的前期配置。

【讨论】:

  • 从长度检查了数据包末端,但我从这里得到的反馈和 reddit 中意识到如果我收到损坏的数据包,很难弄清楚它是在哪里丢失的。我要做的是向 UART 添加奇偶校验,并添加一个与正在发送的类型 ID 相同的结束字符。我可以为 uart 添加 crc 而不是奇偶校验,但是数据包太短(1 到 5 个字节,包括大多数的 id)我不想有太多的开销。如果某个 id 字段损坏或缺少转义字符,它将被尾随的 id 字符捕获。
  • 我忘记提到的一件事是我使用静态断言来确保打包正确。还有其他一些小技巧,比如从最大的数据成员开始等等。我也不会采用固定长度,因为大多数数据包都是 1 字节,但我最长的数据包是 12 字节(可能只发送 100 个字节中的 1 个)。因此,该变量的效果要好得多。通过下面提到的修复,它应该表现良好。对于这个数据包长度,DMA 可能是多余的。如果我发送更长的数据包,我可能会使用 DMA 和 COBS 进行编码。感谢您的回复。
  • @TunaBicim - 我已经编辑了我的答案,并提供了关于 CRC、奇偶校验位和总体工作时间成本的详细信息。
  • 数据包的长度被硬编码到系统中。这就是检测丢弃字节并丢弃数据包的方式。我理解波特率参数,但并非每个处理器都支持这些速度。我们对双方都进行编码,这样我就可以确保数据包彼此兼容。奇偶校验仍然有帮助,因为如果是单个位损坏,则该字节将被丢弃,这意味着长度检查将捕获错误。我可能仍然会切换数据包以包含长度,以保持向后兼容性。
  • 我能否指出您设计的优缺点,@TunaBicim?希望我能提供帮助,并且我的回答内容丰富,至少提供了一些见解。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2012-05-13
  • 1970-01-01
  • 2011-11-21
  • 2014-04-20
  • 2020-10-05
  • 2017-01-07
  • 1970-01-01
相关资源
最近更新 更多