自定义通信协议

在嵌入式开发中,自定义通信协议是指开发者根据具体应用的需求,设计并实现一个专门的协议,用于在嵌入式系统与外部设备(如上位机、传感器、其他微控制器等)之间进行数据交换。这种协议通常定义了数据的格式、传输规则、错误检测机制等,确保通信的正确性与可靠性。

自定义通信的目的

简化通信:通过明确的数据格式和规则,确保嵌入式系统与其他设备之间能够顺利、准确地交换数据。

提高效率:根据实际需求设计协议,使通信更加高效、低功耗、节省带宽。

解决兼容性问题:避免使用标准协议时可能出现的兼容性问题,特别是当设备数量、数据类型等不确定时。

增加可控性:在嵌入式开发中,通过自定义协议可以精确控制数据的传输方式和格式。

自定义hex协议

我们在使用 USART 与上位机通信时,有时标准的 USART 协议不能满足我们的功能需求,故我们需要在 USART 的基础上定义我们自己的通信协议;

我们首先规定,PC 与 MCU 通信过程中,先发送高位的字节(0x1213456 先发送 12),以小端方式存储(具体需要验证当前环境的存储方式);

以平衡小球为例,我们需要定义如下规则:

设置小球位置

帧头 命令位 数据长度 数据位 校验位 帧尾
字节数 2 1 1 2 1 1
默认值 0xAA 0xAA 0xB0 0x02 上位机传入 Add(8) 0xBB

小球的位置可以从 0ms ~ 2000+ms,所以我们这里用两个字节存放小球返回的位置;

Add(8):对命令位、数据长度和数据位进行累加后取低 8 位。

设置 PID 相关参数

帧头 命令位 数据长度 数据位 校验位 帧尾
字节数 2 1 1 12 1 1
默认值 0xAA 0xAA 0xB1 0x0C 上位机传入 Add(8) 0xBB

数据位:数据位包含P、I、D三个参数,每个参数 4 个字节所以一共 12 个字节

  • P: 4 个字节,float 类型。P 值
  • I: 4 个字节,float 类型。I 值
  • D: 4 个字节,float 类型。D 值

获取当前小球位置

帧头 命令位 数据长度 数据 校验位 帧尾
字节数 2 1 1 2 1 1
默认值 0xAA 0xAA 0xF0 0x02 MPU 返回 Add(8) 0xBB

获取当前 PID 相关参数

帧头 命令位 数据长度 数据位 校验位 帧尾
字节数 2 1 1 type(1) | P/I/D(4) 1 1
默认值 0xAA 0xAA 0xE0 0x05 MPU 返回 Add(8) 0xBB

数据位包含一个 type 用于区分参数(P、I、D三个参数),后 4 个字节是前面 type 所返回的具体数值

  • 0x00 : Kp

  • 0x01:Ki

  • 0x02:Kd

float 的内存表示

因为 PC 在与 MCU 通信时会涉及到小数的传输,所以这里需要先了解下 float 在内存中是如何表示的:在 C 语言中,float 通常以 IEEE 754 单精度浮点数格式存储,占 4字节(32位)

PC 在与 MCU 通信时我们如果直接传输一个小数比如 12.1231,MCU 就需要通过字符串来接收这个指令,虽然简单但是会造成数据位的大小不固定,协议是需要规定数据位大小的。(12.1 是 4 个字节,12.1213 是 7 个字节)

所以我们这里采取将 float 转为 十六进制码进行发送,这样 MCU 只需要通过 char[4] 大小的数组接收并组装为 float 即可。(12.1 是 4 个字节,12.1213 还是 4 个字节)

浮点数十六进制转换器

实现

1
2
3
4
5
6
7
8
9
10
11
// USART 接收处理
// 上位机发送后我们要按照定义的协议进行解析


// USART 发送处理
// 我们要发送给上位机进行实时显示

// 何时将数据搬运到环形队列中
// 这里我们就简单来一个字节搬运一次
// 搬运应该是实时的

环形队列

PC 通过 USART 与 MCU 通信过程中通常会产生如下一些问题:

MCU 的接收处理速度跟不上 PC 的发送速度,未完整处理接收到的数据,从而导致丢包或者粘包的问题;因此我们引入了环形队列这一数据结构进行处理;

环形队列是一种特殊的队列数据结构,它使用固定长度的数组实现队列的功能,并通过逻辑上的“首尾相接”形成一个环形的结构。

特点

  • 队列的最后一个元素的下一个位置可以与第一个元素相连,形成一个循环结构。

  • 当队尾指针到达数组末尾时,如果还有空余空间,可以绕回到数组的开头继续存储数据。

  • 为了区分队列是“空”还是“满”,通常会保留一个空闲位置或者使用额外的标志位。

实现方法

假设用一个固定长度为 N 的数组来实现环形队列:

  • 队头指针(head):指向队列中第一个元素的位置。
  • 队尾指针(tail):指向下一个可以插入的位置(注意,不是最后一个元素)。

队列的基本操作

  1. 入队(enqueue)

    • 检查队列是否已满: (tail + 1) % N == head 若成立,则队列已满。
    • 插入数据后,将 tail 指针向前移动: tail = (tail + 1) % N
  2. 出队(dequeue)

    • 检查队列是否为空: tail == head 若成立,则队列为空。
    • 删除数据后,将 head 指针向前移动: head = (head + 1) % N
  3. 判断队列是否为空

    tail == head

  4. 判断队列是否已满

    (tail + 1) % N == head

  5. 计算队列中元素个数

    size = (tail − head + N) % N

实现

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
#ifndef __CIRCULAR_QUEUE_H__
#define __CIRCULAR_QUEUE_H__

#include <stdint.h>
// 循环队列
typedef struct {
uint32_t head; // 队头指针
uint32_t tail; // 队尾指针
uint32_t size; // 队列大小
uint8_t *buffer; // 队列缓冲区
} CircularQueue_t;

typedef enum {
QUEUE_OK = 0, // 成功
QUEUE_FULL, // 队列满
QUEUE_EMPTY, // 队列空
QUEUE_ERROR // 错误

} CircularQueueStatus_t;

/**
* @brief 初始化环形队列
* \param queue 队列结构体变量指针
* \param buffer 队列缓存区地址
* \param buffer_size 队列最大大小
*/
void CircularQueue_Init(CircularQueue_t *queue, uint8_t *buffer, uint32_t buffer_size) {
queue->head = 0;
queue->tail = 0;
queue->size = buffer_size;
queue->buffer = buffer;
}

/**
* @brief 数据入队(向队列尾部插入数据)
*
* \param queue 队列结构体变量指针
* \param dat 一个字节数据
* \return QueueStatus_t 入队结果 QUEUE_OK 成功
*/
CircularQueueStatus_t CircularQueue_push(CircularQueue_t *queue, uint8_t dat) {
// 计算下一个元素的索引
uint32_t next_index = (queue->tail + 1) % queue->size;
// 队列满(保留一个空位)
if (next_index == queue->head) {
return QUEUE_FULL;
}
// 写入数据
queue->buffer[queue->tail] = dat;
// 更新队尾指针
queue->tail = next_index;
return QUEUE_OK;
}

/**
* @brief 数据出队(从队首弹出数据)
*
* \param queue 队列结构体变量指针
* \param pdat 出队数据指针
* \return QueueStatus_t
*/
CircularQueueStatus_t circularQueue_pop(CircularQueue_t *queue, uint8_t *p_dat) {
// 如果head与tail相等,说明队列为空
if (queue->head == queue->tail) {
return QUEUE_EMPTY;
}
// 取head的数据
*p_dat = queue->buffer[queue->head];
// 更新队头指针
queue->head = (queue->head + 1) % queue->size;
return QUEUE_OK;
}

/**
* @brief 获取队列数据个数
*
* \param queue 队列指针
* \return uint32_t 队列有效数据个数
*/
uint32_t CircularQueue_DataCount(CircularQueue_t *queue) {
if (queue->tail >= queue->head) {
// 队尾指针在队头指针后边
return queue->tail - queue->head;
}
// 队尾指针在队头指针前边(转了一圈到了队头指针之前)
return queue->size + queue->tail - queue->head;
}

/**
* @brief 压入一组数据
*
* \param queue 队列结构体变量指针
* \param p_arr 待入队数组指针
* \param len 待入队数组长度
* \return uint32_t 实际写入的数据个数
*/
uint32_t circularQueue_pushArray(CircularQueue_t *queue, uint8_t *p_arr, uint32_t len){
uint32_t i;
for(i = 0; i < len; i++){
if (circularQueue_push(queue, p_arr[i]) == QUEUE_FULL){
break;
}
}
return i;
}

/**
* @brief 出队一组数据
*
* \param queue 队列指针
* \param p_arr 待出队数组指针
* \param len 待出队数组长度
* \return QueueStatus_t
*/
uint32_t circularQueue_popArray(CircularQueue_t *queue, uint8_t *p_arr, uint32_t len){
uint32_t i;
for(i = 0; i < len; i++){
if (circularQueue_pop(queue, &p_arr[i]) == QUEUE_EMPTY){
break;
}
}
return i;
}

F407yihuofeimen

问题

一、环形队列中为什么要预留一个空位

环形队列中的数据是通过头指针(head)和尾指针(tail)来管理的:

  • 空队列:当 head == tail 时,队列为空。
  • 满队列:如果不预留空位,当所有缓冲区都填满时,head 和 tail 也会变成相等,这会导致无法区分“队列满”和“队列空”这两种状态。

因此,为了简化逻辑并明确状态,环形队列中通常会留出一个空位作为缓冲,这样就可以通过以下规则来判断队列是否满或空:

  • 队列:head == tail
  • 队列:(tail + 1) % size == head

二、同时使用 DMA 和 RBEN 中断时的数据竞争问题