一个适用于嵌入式领域的轻量级协议库
在嵌入式开发中,设备间的可靠通信一直是个难题。
今天我们来分享一个优秀的开源项目——LwPKT,看看它如何用不到1000行代码解决复杂的通信协议问题。
1. 嵌入式通信的痛点
在嵌入式开发中,我们常常遇到这些问题:
- 内存受限:MCU只有几KB RAM,复杂协议栈根本跑不起来
- 实时性要求高:工业控制场景下,毫秒级延迟都不能容忍
- 可靠性要求:数据传输错误可能导致设备故障甚至安全事故
- 多设备组网:RS-485总线上挂载十几个设备,需要地址管理
- 开发效率:从零写通信协议,调试周期长,bug多
今天介绍的LwPKT(Lightweight Packet Protocol)就是为了解决这些痛点而生的。
- 工业自动化:RS-485网络设备通信
- MCU间通信:STM32、ESP32等微控制器互联
- 物联网设备:传感器数据采集与控制指令下发
- 无线通信:LoRa、WiFi、蓝牙模块数据封装
2. LwPKT简介
LwPKT由知名嵌入式开发者Tilen MAJERLE开发,它是一个用C语言编写的轻量级数据包协议库,应用于嵌入式领域。目前已发布v1.4.1版本。
https://github.com/MaJerle/lwpkt
让我们先看看它的核心特性:
2.1 核心特性
- 超轻量级:核心代码不到1000行,最小内存占用<100字节
- 平台无关:纯C语言实现,支持所有主流MCU平台
- 可配置:功能模块化,按需启用,避免资源浪费
- 可靠传输:支持CRC校验,超时检测,多层错误处理
- 变长编码:理论支持无限长数据包,传输效率高
- 事件驱动:异步处理模式,不阻塞主程序执行
2.2 应用场景
- 工业自动化:RS-485网络设备通信
- MCU间通信:STM32、ESP32等微控制器互联
- 物联网设备:传感器数据采集与控制指令下发
- 无线通信:LoRa、WiFi、蓝牙模块数据封装
3. 核心原理学习
3.1 核心文件结构
lwpkt/
├── lwpkt/src/
│ ├── include/lwpkt/
│ │ ├── lwpkt.h # 核心API接口
│ │ └── lwpkt_opt.h # 配置选项
│ └── lwpkt/
│ └── lwpkt.c # 核心实现
├── examples/
│ └── example_lwpkt.c # 使用示例
├── tests/
│ └── test_main.c # 测试用例
└── docs/ # 文档
3.2 数据包格式
LwPKT的数据包格式经过精心设计,既保证了功能完整性,又兼顾了资源消耗:
字段说明:
- START (0xAA):起始标志,帮助接收端同步
- FROM/TO(可选):发送方和接收方地址,支持8位或32位
- FLAGS(可选):用户自定义标志位,可用于优先级、类型标识等
- CMD(可选):命令字段,支持8位或多字节命令
- LEN(变长编码):数据长度,采用变长编码,节省传输带宽
- DATA:实际数据载荷
- CRC(可选):校验码,支持CRC-8或CRC-32
- STOP (0x55):结束标志
3.3 核心数据结构
LwPKT的核心是lwpkt_t结构体:
部分成员是可配置的,功能模块化,按需启用,避免资源浪费。
3.4 环形缓冲区
LwPKT依赖LwRB(Lightweight Ring Buffer)库管理数据缓冲,这是一个经典的生产者-消费者模式应用:
LwRB我们上一篇已经分享:适用于嵌入式的轻量级环缓冲区管理库!
数据发送流程:应用 -> LwPKT -> TX缓冲区 -> 硬件接口。
数据接收流程:硬件接口 -> RX缓冲区 -> LwPKT -> 应用。
3.5 协议解析
协议数据接受解析采用有限状态机(FSM)设计。
状态枚举:
状态转换函数:
状态转换函数,利用C语言的fallthrough特性,根据配置自动跳过禁用的字段。
在 C 语言中,fallthrough 指的是 switch 语句中,某个 case 分支执行完毕后,未使用break语句,导致程序流程自动 “穿透” 到下一个 case 分支继续执行 的特性。这是 C 语言中switch结构的默认行为,既是灵活特性,也可能因误用导致逻辑错误。
为了区分 “有意的 fallthrough” 和 “无意的遗漏”,C17 标准引入了[[fallthrough]]属性,用于显式标记 “此处的 fallthrough 是故意的”,消除编译器警告。
状态机的优势在于:
- 逻辑清晰:每个状态职责单一,便于理解和调试
- 处理高效:O(1)时间复杂度,适合实时系统
- 错误恢复:任何状态下的异常都能快速恢复到初始状态
3.6 变长编、解码
LwPKT使用MSB(最高位)编码来实现数据编码。它的核心思想就是"按需分配"——小数字用少字节,大数字用多字节,从而在大多数情况下节省传输带宽。
在LwPKT协议中,协议中的关键字段都设计为支持32位(4字节)数据:
// 地址字段 - 支持扩展32位地址
#if LWPKT_CFG_ADDR_EXTENDED
typedef uint32_t lwpkt_addr_t; // 32位地址
#else
typedef uint8_t lwpkt_addr_t; // 8位地址
#endif
// 命令字段
uint32_t cmd;
// 标志字段
uint32_t flags;
// 数据长度 - size_t类型(通常32位或64位)
size_t len;
使用变长编码可以显著减少协议开销,提高传输效率。
定长编码 vs 变长编码:
例如,uint32_t类型的命令字段,假如赋值为1。按照定长编码得传输4字节,按照MSB(最高位)变长编码只需传输1字节。
LwPKT编解码流程:
MSB变长编码要点:
- MSB位控制:最高位=1表示继续,=0表示结束
- 7位数据:每字节只用7位存储数据,1位用于控制
- 小端序组装:低位字节在前,高位字节在后
- 按需扩展:小数字用少字节,大数字用多字节
编码、解码相关代码:
3.7 可配置机制
LwPKT采用了三层配置系统,实现了从编译时到运行时的完整可配置性:
第一层:编译时全局配置
// lwpkt_opt.h - 默认配置
#define LWPKT_OFF 0 // 功能完全禁用
#define LWPKT_ON_STATIC 1 // 功能静态启用
#define LWPKT_ON_DYNAMIC 2 // 功能动态控制
// 用户可在 lwpkt_opts.h 中重写
#ifndef LWPKT_CFG_USE_CRC
#define LWPKT_CFG_USE_CRC LWPKT_ON_STATIC
#endif
第二层:条件编译控制
// 根据配置值决定代码是否编译
#if LWPKT_CFG_USE_CRC
// CRC相关代码
static void prv_crc_init(lwpkt_t* pkt, lwpkt_crc_t* crcobj);
static uint32_t prv_crc_in(lwpkt_t* pkt, lwpkt_crc_t* crcobj, const void* inp, const size_t len);
#endif
// 函数参数也受条件编译影响
static uint8_t prv_write_bytes_var_encoded(
lwpkt_t* pkt,
uint32_t var_num
#if LWPKT_CFG_USE_CRC
, lwpkt_crc_t* crc // 只有启用CRC时才有此参数
#endif
);
第三层:运行时动态控制
// 实例级别的标志位控制
typedef struct lwpkt {
uint8_t flags; // 运行时控制标志
// ...
} lwpkt_t;
#define LWPKT_FLAG_USE_CRC ((uint8_t)0x01)
#define LWPKT_FLAG_CRC32 ((uint8_t)0x02)
#define LWPKT_FLAG_USE_ADDR ((uint8_t)0x04)
#define LWPKT_FLAG_ADDR_EXTENDED ((uint8_t)0x08)
#define LWPKT_FLAG_USE_CMD ((uint8_t)0x10)
#define LWPKT_FLAG_CMD_EXTENDED ((uint8_t)0x20)
#define LWPKT_FLAG_USE_FLAGS ((uint8_t)0x40)
// 动态控制函数
void lwpkt_set_crc_enabled(lwpkt_t* pkt, uint8_t enable) {
if (enable) {
pkt->flags |= LWPKT_FLAG_USE_CRC;
} else {
pkt->flags &= ~LWPKT_FLAG_USE_CRC;
}
}
3.8 事件机制
LwPKT的事件机制实现了一个轻量级的观察者模式,允许应用程序监听协议栈的各种状态变化和操作事件。
相关文章:嵌入式编程模型 | 观察者模式
3.8.1 核心事件类型
typedef enum {
LWPKT_EVT_PKT, // 数据包就绪事件
LWPKT_EVT_TIMEOUT, // 超时事件
LWPKT_EVT_READ, // 读操作事件
LWPKT_EVT_WRITE, // 写操作事件
LWPKT_EVT_PRE_WRITE, // 写前事件
LWPKT_EVT_POST_WRITE, // 写后事件
LWPKT_EVT_PRE_READ, // 读前事件
LWPKT_EVT_POST_READ, // 读后事件
} lwpkt_evt_type_t;
3.8.2 事件机制及触发时机
// 回调函数定义
typedef void (*lwpkt_evt_fn)(struct lwpkt* pkt, lwpkt_evt_type_t evt_type);
// 事件注册接口
lwpktr_t lwpkt_set_evt_fn(lwpkt_t* pkt, lwpkt_evt_fn evt_fn) {
pkt->evt_fn = evt_fn;
return lwpktOK;
}
// 事件发送宏
#if LWPKT_CFG_USE_EVT
#define SEND_EVT(pkt, event) \
do { \
if ((pkt)->evt_fn != NULL) { \
(pkt)->evt_fn((pkt), (event)); \
} \
} while (0)
#else
#define SEND_EVT(pkt, event) // 空实现,零开销
#endif
触发时机:
// lwpkt_read接口触发
lwpktr_t lwpkt_read(lwpkt_t* pkt) {
lwpktr_t res = lwpktOK;
uint8_t b, e = 0;
// 1. 读操作开始前事件
SEND_EVT(pkt, LWPKT_EVT_PRE_READ);
// 处理接收数据的主循环
// ...
retpre:
// 2. 读操作完成后事件
SEND_EVT(pkt, LWPKT_EVT_POST_READ);
// 3. 如果处理了数据,发送读事件
if (e) {
SEND_EVT(pkt, LWPKT_EVT_READ);
}
return res;
}
// lwpkt_write接口触发
lwpktr_t lwpkt_write(lwpkt_t* pkt, /* 参数列表 */) {
lwpktr_t res = lwpktOK;
// 1. 写操作开始前事件
SEND_EVT(pkt, LWPKT_EVT_PRE_WRITE);
// 数据包构建过程...
// 写入START, 地址, 命令, 长度, 数据, CRC, STOP
fast_return:
// 2. 写操作完成后事件
SEND_EVT(pkt, LWPKT_EVT_POST_WRITE);
// 3. 如果写入成功,发送写事件
if (res == lwpktOK) {
SEND_EVT(pkt, LWPKT_EVT_WRITE);
}
return res;
}
// lwpkt_process接口触发
lwpktr_t lwpkt_process(lwpkt_t* pkt, uint32_t time) {
lwpktr_t pktres = lwpkt_read(pkt);
if (pktres == lwpktVALID) {
pkt->last_rx_time = time;
// 1. 数据包就绪事件
SEND_EVT(pkt, LWPKT_EVT_PKT);
} else if (pktres == lwpktINPROG) {
if ((time - pkt->last_rx_time) >= LWPKT_CFG_PROCESS_INPROG_TIMEOUT) {
lwpkt_reset(pkt);
pkt->last_rx_time = time;
// 2. 超时事件
SEND_EVT(pkt, LWPKT_EVT_TIMEOUT);
}
} else {
pkt->last_rx_time = time;
}
return pktres;
}
3.9 代码实战
让我们实现一个LwPKT最小例子:
#include <stdio.h>
#include <string.h>
#include "lwpkt/lwpkt.h"
/* 定义缓冲区大小 */
#define BUFFER_SIZE 64
/* LwPKT实例和环形缓冲区 */
static lwpkt_t pkt;
static lwrb_t tx_rb, rx_rb;
static uint8_t tx_data[BUFFER_SIZE], rx_data[BUFFER_SIZE];
void simulate_transfer(void) {
uint8_t byte = 0;
while (lwrb_read(&tx_rb, &byte, 1) == 1) {
lwrb_write(&rx_rb, &byte, 1);
}
}
int main(void) {
lwpktr_t result;
const char* message = "Hello LwPKT!";
printf("============ LwPKT Test ============\n");
/* 初始化环形缓冲区 */
lwrb_init(&tx_rb, tx_data, sizeof(tx_data));
lwrb_init(&rx_rb, rx_data, sizeof(rx_data));
/* 初始化LwPKT实例 */
if (lwpkt_init(&pkt, &tx_rb, &rx_rb) != lwpktOK) {
printf("LwPKT初始化失败!\n");
return -1;
}
/* 发送数据包 */
printf("\n====== 发送 ======\n");
printf("发送数据包: %s\n", message);
result = lwpkt_write(&pkt,
0x01, /* 目标地址 */
0x12345678, /* 标志位 */
0x01, /* 命令 */
message, strlen(message)); /* 数据 */
if (result != lwpktOK) {
printf("发送失败: %d\n", result);
return -1;
}
/* 模拟网络传输 */
simulate_transfer();
/* 接收并解析数据包 */
result = lwpkt_read(&pkt);
if (result == lwpktVALID) {
printf("\n====== 接收 ======\n");
/* 打印数据包信息 */
printf("发送方地址: 0x%08X\n", (unsigned)lwpkt_get_from_addr(&pkt));
printf("接收方地址: 0x%08X\n", (unsigned)lwpkt_get_to_addr(&pkt));
printf("标志位: 0x%08X\n", (unsigned)lwpkt_get_flags(&pkt));
printf("命令: 0x%02X\n", (unsigned)lwpkt_get_cmd(&pkt));
size_t data_len = lwpkt_get_data_len(&pkt);
printf("数据长度: %zu字节\n", data_len);
if (data_len > 0) {
uint8_t* data = lwpkt_get_data(&pkt);
printf("数据内容: ");
for (size_t i = 0; i < data_len; i++) {
printf("%c", data[i]);
}
printf("\n");
}
}
return 0;
}
3.10 与其他协议的对比
特性 LwPKT Modbus RTU JSON over UART 自定义协议 代码大小 <1KB ~10KB ~50KB 不定 RAM占用 <100B ~1KB ~5KB 不定 解析速度 极快 快 慢 不定 配置灵活性 极高 低 中 高 学习成本 低 中 低 高 生态支持 中 高 高 低
选择建议:
- 资源极度受限:选择LwPKT最小配置
- 需要标准化:优先考虑Modbus RTU
- 快速原型开发:JSON方案开发效率高
- 特殊需求:基于LwPKT定制开发
4. 总结
通过学习LwPKT项目,我们可以看到一个优秀的嵌入式通信协议应该具备的特质:
- 极简设计哲学:不到1000行代码实现完整协议栈
- 高度可配置性:按需启用功能,避免资源浪费
- 可靠性保证:多层错误检测,适应恶劣环境
- 性能优化:状态机驱动,O(1)复杂度处理
- 易于集成:纯C实现,无外部依赖