星標(biāo)公眾號,讓嵌入式知識(shí) “投喂” 不停歇!
大家好,我是雜燴君。
在嵌入式系統(tǒng)開發(fā)中,自定義通信協(xié)議是板間通信的核心基礎(chǔ)設(shè)施。本文介紹如何設(shè)計(jì)一個(gè)高度實(shí)用且具備良好擴(kuò)展性的自定義協(xié)議。
字節(jié)序一致性:跨平臺(tái)通信必須明確字節(jié)序(本實(shí)現(xiàn)采用小端序)
固定寬度類型:使用uint8_t、uint16_t等固定寬度類型,確保跨平臺(tái)一致性
靜態(tài)內(nèi)存分配:嵌入式環(huán)境避免使用動(dòng)態(tài)內(nèi)存,防止內(nèi)存碎片
流式解析支持:實(shí)際通信中數(shù)據(jù)是流式到達(dá)的,需要狀態(tài)機(jī)處理粘包/斷包
完善的錯(cuò)誤處理:統(tǒng)一的錯(cuò)誤碼體系,便于問題定位
| I | |||
| T | |||
| L | |||
| V |
其中,I、T、L是固定長度字段,在制定協(xié)議前需要評估:項(xiàng)目數(shù)據(jù)種類有多少(決定I的位寬)、單條數(shù)據(jù)最大長度是多少(決定L的位寬)。預(yù)留擴(kuò)展空間可保證協(xié)議的通用性。一般I設(shè)置為1~2字節(jié),T設(shè)置為1字節(jié),L設(shè)置為1~4字節(jié)。
只用ITLV四個(gè)字段夠嗎? 這取決于應(yīng)用場景:
場景一:物聯(lián)網(wǎng)端云通信。基于MQTT/TCP的通信中,只用ITLV四個(gè)字段是可行的。因?yàn)門CP協(xié)議本身提供校驗(yàn)和重傳機(jī)制,保證了傳輸可靠性;平臺(tái)SDK在傳輸用戶數(shù)據(jù)時(shí)會(huì)自動(dòng)添加消息邊界標(biāo)識(shí)。開發(fā)者只需關(guān)注ITLV這一層即可。
場景二:嵌入式板間通信。串口、CAN等通信方式?jīng)]有TCP的可靠性保障,電磁干擾可能導(dǎo)致數(shù)據(jù)出錯(cuò)。此時(shí)必須增加:
包頭(Header):用于幀同步和邊界識(shí)別
校驗(yàn)字段(CRC):用于檢測數(shù)據(jù)錯(cuò)誤
根據(jù)實(shí)際需求還可擴(kuò)展:分包傳輸需要包序號、多板通信需要目標(biāo)地址等。
我們一起設(shè)計(jì)一個(gè)自定義協(xié)議,用于嵌入式板間通信。幀格式:
根據(jù)數(shù)據(jù)來源的不同,協(xié)議庫提供兩種解析方式:

| 輸入數(shù)據(jù) | ||
| 狀態(tài)管理 | ||
| 緩沖區(qū) | ||
| 粘包/斷包 | ||
| 適用場景 |
一次性解析:已經(jīng)拿到完整的數(shù)據(jù)幀時(shí)使用。比如從文件中讀取的協(xié)議數(shù)據(jù)。優(yōu)點(diǎn)是調(diào)用簡單、無狀態(tài),缺點(diǎn)是必須確保輸入數(shù)據(jù)完整。
流式解析:當(dāng)數(shù)據(jù)是"一點(diǎn)一點(diǎn)"到達(dá)時(shí)使用。比如串口中斷每次只收到1個(gè)字節(jié)。狀態(tài)機(jī)會(huì)"記住"已收到的內(nèi)容,自動(dòng)處理粘包(多幀粘連)和斷包(一幀分多次到達(dá))。
跨編譯器的結(jié)構(gòu)體打包屬性定義,確保結(jié)構(gòu)體按1字節(jié)對齊,消除填充字節(jié)。
#if defined(__GNUC__) || defined(__clang__)
#define PACKED_STRUCT __attribute__((packed))
#elif defined(_MSC_VER)
#define PACKED_STRUCT
#pragma pack(push, 1)
#else
#define PACKED_STRUCT
#warning"Unknown compiler, packed attribute may not work correctly"
#endif
TLV數(shù)據(jù)類型定義使用固定寬度類型。使用固定寬度uint8_t確保跨平臺(tái)一致性,避免使用enum,因?yàn)閑num的大小是編譯器相關(guān)的。
typedefuint8_ttlv_type_t;
#define TLV_TYPE_UINT8 ((tlv_type_t)0x00) // 無符號8位整數(shù)
#define TLV_TYPE_INT8 ((tlv_type_t)0x01) // 有符號8位整數(shù)
#define TLV_TYPE_UINT16 ((tlv_type_t)0x02) // 無符號16位整數(shù)
#define TLV_TYPE_INT16 ((tlv_type_t)0x03) // 有符號16位整數(shù)
#define TLV_TYPE_UINT32 ((tlv_type_t)0x04) // 無符號32位整數(shù)
#define TLV_TYPE_INT32 ((tlv_type_t)0x05) // 有符號32位整數(shù)
#define TLV_TYPE_STRING ((tlv_type_t)0x06) // 字符串類型
#define TLV_TYPE_FLOAT ((tlv_type_t)0x07) // 浮點(diǎn)類型
#define TLV_TYPE_BYTES ((tlv_type_t)0x08) // 字節(jié)數(shù)組
typedefstruct
{
protocol_id_t id; // 協(xié)議ID
tlv_type_t type; // 數(shù)據(jù)類型
uint8_t length; // 數(shù)據(jù)長度
uint8_t payload[PROTOCOL_VALUE_MAX_LEN]; // 負(fù)載數(shù)據(jù)
} protocol_data_t;
用于組包和解包的數(shù)據(jù)容器,業(yè)務(wù)層可以通過 payload 訪問原始字節(jié)數(shù)據(jù)。
typedefenum
{
PROTO_OK = 0, // 操作成功
PROTO_ERR_NULL_PTR = -1, // 空指針錯(cuò)誤
PROTO_ERR_BUF_TOO_SMALL = -2, // 緩沖區(qū)太小
PROTO_ERR_INVALID_HEAD = -3, // 無效的包頭
PROTO_ERR_CRC_MISMATCH = -4, // CRC校驗(yàn)失敗
PROTO_ERR_INVALID_ID = -5, // 無效的協(xié)議ID
PROTO_ERR_PAYLOAD_SIZE = -6, // 負(fù)載大小錯(cuò)誤
PROTO_ERR_IN_PROGRESS = -7, // 解析進(jìn)行中
PROTO_ERR_INVALID_LEN = -8, // 無效的數(shù)據(jù)長度
} protocol_err_e;
流式解析的核心是狀態(tài)機(jī)。每收到一個(gè)字節(jié),狀態(tài)機(jī)根據(jù)當(dāng)前狀態(tài)決定下一步動(dòng)作:

當(dāng)遇到錯(cuò)誤數(shù)據(jù)自動(dòng)回到IDLE重新開始,這就實(shí)現(xiàn)了噪聲過濾。
typedefenum
{
PARSE_STATE_IDLE = 0, // 空閑狀態(tài)
PARSE_STATE_HEAD1, // 等待包頭第一字節(jié)
PARSE_STATE_HEAD2, // 等待包頭第二字節(jié)
PARSE_STATE_ID, // 接收ID
PARSE_STATE_TYPE, // 接收Type
PARSE_STATE_LENGTH, // 接收Length
PARSE_STATE_PAYLOAD, // 接收Payload
PARSE_STATE_CRC_LOW, // 接收CRC低字節(jié)
PARSE_STATE_CRC_HIGH, // 接收CRC高字節(jié)
} parse_state_e;
typedefstruct
{
parse_state_e state; // 當(dāng)前解析狀態(tài)
uint8_t buffer[PROTOCOL_MAX_LEN]; // 接收緩沖區(qū)
uint16_t index; // 當(dāng)前接收索引
uint8_t payload_len; // 期望的負(fù)載長度
} protocol_parser_t;
CRC(循環(huán)冗余校驗(yàn))用于檢測數(shù)據(jù)傳輸錯(cuò)誤。本協(xié)議采用CRC16-X25算法,使用查表法提高效率。
校驗(yàn)范圍:從包頭開始到Payload結(jié)束(不含CRC本身)
協(xié)議庫提供3類API:組包、一次性解包、流式解析。

/**
* @brief 協(xié)議數(shù)據(jù)組包
* @param buf 輸出緩沖區(qū)指針
* @param buf_size 緩沖區(qū)大小
* @param data 協(xié)議數(shù)據(jù)結(jié)構(gòu)
* @param out_len 實(shí)際輸出長度(輸出)
* @return PROTO_OK: 成功, 其他: 錯(cuò)誤碼
*/
protocol_err_e protocol_pack(uint8_t *buf,
size_t buf_size,
constprotocol_data_t *data,
size_t *out_len);

/**
* @brief 一次性解包
* @param buf 輸入緩沖區(qū)指針
* @param len 數(shù)據(jù)長度
* @param data 協(xié)議數(shù)據(jù)結(jié)構(gòu)(輸出)
* @return PROTO_OK: 成功, 其他: 錯(cuò)誤碼
*/
protocol_err_e protocol_unpack(constuint8_t *buf,
size_t len,
protocol_data_t *data);
/**
* @brief 初始化解析器
* @param parser 解析器實(shí)例指針
* @return PROTO_OK: 成功, 其他: 錯(cuò)誤碼
*/
protocol_err_e protocol_parser_init(protocol_parser_t *parser);
/**
* @brief 重置解析器狀態(tài)
* @param parser 解析器實(shí)例指針
*/
void protocol_parser_reset(protocol_parser_t *parser);
/**
* @brief 流式解析 - 逐字節(jié)輸入
* @param parser 解析器實(shí)例指針
* @param byte 輸入字節(jié)
* @return PROTO_OK: 幀完成, PROTO_ERR_IN_PROGRESS: 解析中, 其他: 錯(cuò)誤碼
*/
protocol_err_e protocol_parse_byte(protocol_parser_t *parser, uint8_t byte);
/**
* @brief 從解析器提取幀數(shù)據(jù)
* @param parser 解析器實(shí)例指針
* @param data 協(xié)議數(shù)據(jù)結(jié)構(gòu)(輸出)
* @return PROTO_OK: 成功, 其他: 錯(cuò)誤碼
*/
protocol_err_e protocol_parser_get_frame(constprotocol_parser_t *parser,
protocol_data_t *data);
本文demo可在公眾號聊天對話框輸入關(guān)鍵字:簡易嵌入式自定義協(xié)議設(shè)計(jì)思路,即可獲取。
下面通過兩個(gè)典型場景演示協(xié)議的使用。
首先定義業(yè)務(wù)層的數(shù)據(jù)結(jié)構(gòu)。注意使用# pragma pack確保結(jié)構(gòu)體按1字節(jié)對齊:
// 業(yè)務(wù)協(xié)議ID定義
#define CMD_ID_LED_CTRL (protocol_id_t)0x01
#define CMD_ID_DATE_TIME (protocol_id_t)0x02
#pragma pack(push, 1)
typedefstruct
{
uint8_t led_id; // LED編號
uint8_t on_off; // 0=關(guān)閉, 1=打開
} led_ctrl_t;
typedefstruct
{
uint16_t year;
uint8_t month;
uint8_t day;
uint8_t hour;
uint8_t minute;
uint8_t second;
uint8_t reserved;
} datetime_t;
#pragma pack(pop)
核心代碼:
// 準(zhǔn)備數(shù)據(jù)
led_ctrl_t led_cmd = { .led_id = 1, .on_off = 1 };
tx_data.id = CMD_ID_LED_CTRL;
tx_data.type = TLV_TYPE_BYTES;
tx_data.length = sizeof(led_cmd);
memcpy(tx_data.payload, &led_cmd, sizeof(led_cmd));
// 組包
protocol_pack(tx_buf, sizeof(tx_buf), &tx_data, &frame_len);
printf("Pack: ID=0x%02X, LED%d=%s, len=%zu\n",
tx_data.id, led_cmd.led_id,
led_cmd.on_off ? "ON" : "OFF", frame_len);
printf("Pack: ");
protocol_print_hex(tx_buf, frame_len);
// 解包
ret = protocol_unpack(tx_buf, frame_len, &rx_data);
if (ret == PROTO_OK)
{
led_ctrl_t *rx_led = (led_ctrl_t*)rx_data.payload;
printf("Unpack: ID=0x%02X, LED%d=%s\n\n",
rx_data.id, rx_led->led_id,
rx_led->on_off ? "ON" : "OFF");
}
運(yùn)行結(jié)果:

模擬數(shù)據(jù)逐字節(jié)到達(dá),狀態(tài)機(jī)累積處理。
核心代碼:
protocol_parser_t parser;
protocol_parser_init(&parser);
// 模擬逐字節(jié)接收
for (size_t i = 0; i < frame_len; i++)
{
protocol_err_e ret = protocol_parse_byte(&parser, tx_buf[i]);
if (ret == PROTO_OK)
{
// 幀接收完成
protocol_parser_get_frame(&parser, &rx_data);
led_ctrl_t *rx_led = (led_ctrl_t*)rx_data.payload;
printf("Unpack: ID=0x%02X, LED%d=%s (parsed at byte %zu)\n\n",
rx_data.id, rx_led->led_id,
rx_led->on_off ? "ON" : "OFF", i + 1);
break;
}
}
運(yùn)行結(jié)果:

本文demo可在公眾號聊天對話框輸入關(guān)鍵字:簡易嵌入式自定義協(xié)議設(shè)計(jì)思路,即可獲取。
本ITLV協(xié)議是個(gè)最小實(shí)現(xiàn),輕量簡潔,可以適用于短距離、低誤碼率的嵌入式板間通信。如果要應(yīng)用于其它更廣泛的場景,會(huì)有一些局限性。
下面討論一些局限性及可優(yōu)化點(diǎn):
| ID | |||
| Length | |||
| Type |
對于數(shù)據(jù)種類<256、單條數(shù)據(jù)<255字節(jié)的系統(tǒng),當(dāng)前設(shè)計(jì)完全夠用。超出范圍時(shí)需擴(kuò)展字段位寬。
當(dāng)前協(xié)議:

可優(yōu)化方向:

當(dāng)前狀態(tài)機(jī)無超時(shí)機(jī)制。潛在問題:

本文介紹的ITLV協(xié)議具有以下特點(diǎn):
| 簡潔高效 | |
| 靜態(tài)內(nèi)存 | |
| 流式解析 | |
| CRC校驗(yàn) | |
| 跨平臺(tái) |
適用場景:短距離、低誤碼率的嵌入式板間通信(串口、SPI、I2C等)。
不適用場景:高可靠性要求、大數(shù)據(jù)傳輸、多設(shè)備組網(wǎng)、安全敏感的場合。
我是雜燴君,專注嵌入式軟件開發(fā),分享實(shí)用的技術(shù)干貨。
猜你喜歡:
收藏!一個(gè)不錯(cuò)的嵌入式Linux知識(shí)庫!
簡易嵌入式優(yōu)先級消息隊(duì)列設(shè)計(jì)思路!
這幾個(gè)嵌入式軟件開源項(xiàng)目值得深度復(fù)刻!
常見嵌入式軟件崩潰類型:12種典型場景!
點(diǎn)擊閱讀原文,查看我給大家篩選的熱門嵌入式書籍!