第二章 协议栈架构解析:分层模型与核心组件
各位工程师朋友,咱们今天来聊聊协议栈的架构。说实话,我见过太多人一上来就埋头写代码,结果写到一半发现结构乱成一锅粥。协议栈这东西,架构设计好了,后面移植和优化就顺风顺水。要是架构没想清楚,后面全是坑。
我个人习惯,拿到一个工业协议栈,先看它的分层模型。为什么?因为分层决定了你后续怎么裁剪、怎么移植。你想想看,物理层换了,难道要把整个协议栈重写一遍吗?当然不是。好的分层设计,让你只改一层就行。
2.1 分层模型:物理层、数据链路层、应用层
工业协议栈的分层,跟OSI七层模型不太一样。我们实际做嵌入式,一般就三层:物理层、数据链路层、应用层。够用了,再多就冗余了。
2.1.1 物理层
物理层负责最原始的比特流传输。说白了,就是管电压、管电平、管时序。我在项目中遇到过,有人把RS-485的物理层和Modbus的数据链路层混在一起写,结果换了个收发器芯片,整个协议栈都得改。这哪行?
物理层要关注的核心点:
- 电气特性:RS-232、RS-485、CAN的电压范围都不一样
- 时序要求:波特率、采样点、位时序
- 硬件抽象:把UART、SPI、CAN外设的操作封装成统一接口
核心原则:物理层只负责收发原始字节,不解析任何协议内容。数据到了数据链路层,才去判断帧头帧尾。
嗯,这里要注意。物理层的接口设计,我建议用回调函数。比如:
/* 物理层接口定义 */
typedef struct {
int32_t (*init)(void* config);
int32_t (*send)(uint8_t* data, uint16_t len);
int32_t (*recv)(uint8_t* data, uint16_t* len);
void (*enable_irq)(bool enable);
} phy_driver_t;
这样设计的好处是,你换硬件平台,只需要重新实现这四个函数。协议栈上层代码,一行都不用改。我曾经在一个项目里,把STM32的代码移植到GD32上,物理层接口一换,半天就搞定了。
2.1.2 数据链路层
数据链路层,是协议栈里最核心的一层。它负责组帧、拆帧、差错检测、流量控制。说白了,就是保证物理层传过来的数据是完整的、正确的。
数据链路层要处理的事情:
- 帧定界:怎么知道一帧数据从哪里开始、到哪里结束?
- 地址识别:这条消息是发给我的吗?
- 差错校验:CRC、LRC、奇偶校验,选哪个?
- 重传机制:丢包了怎么办?
我见过最典型的坑是什么?有人把帧定界写死在中断里。中断里做大量数据处理,导致系统响应变慢。正确的做法是,中断只收数据,帧解析放到任务里做。
个人经验:数据链路层的缓冲区,我建议用环形缓冲区。为什么?因为工业通信的数据流是连续的,环形缓冲区天然适合这种场景。我曾经用环形缓冲区配合DMA,实现了零拷贝接收,性能提升很明显。
2.1.3 应用层
应用层,就是跟业务逻辑打交道的层。Modbus的读寄存器、写线圈,CANopen的SDO、PDO,都在这一层实现。
应用层设计要注意什么?
- 协议无关性:应用层不应该直接操作数据链路层的缓冲区
- 回调机制:收到一帧数据后,通过回调通知上层
- 超时处理:应用层请求发出去后,多久没收到回复算超时?
举个例子,Modbus RTU的应用层处理流程:
/* 应用层回调示例 */
static int32_t on_modbus_request(uint8_t* data, uint16_t len)
{
modbus_pdu_t pdu;
/* 解析PDU */
pdu.func_code = data[0];
pdu.data = &data[1];
pdu.len = len - 1;
/* 根据功能码分发 */
switch (pdu.func_code) {
case 0x03: /* 读保持寄存器 */
handle_read_holding_registers(&pdu);
break;
case 0x06: /* 写单个寄存器 */
handle_write_single_register(&pdu);
break;
default:
return -1;
}
return 0;
}
2.2 核心组件:状态机、缓冲区管理、定时器
分层模型讲完了,咱们来看看协议栈里的三个核心组件。这三个东西,几乎每个工业协议栈都离不开。
2.2.1 状态机
状态机是协议栈的灵魂。你想想看,一个通信过程,从空闲到接收,从接收到解析,从解析到响应,每一步都是一个状态。状态机设计得好,代码逻辑就清晰;设计得不好,那就是一团乱麻。
我常用的状态机设计模式:
/* 数据链路层接收状态机 */
typedef enum {
STATE_IDLE, /* 空闲状态 */
STATE_RECV_HEAD, /* 接收帧头 */
STATE_RECV_DATA, /* 接收数据 */
STATE_RECV_CRC, /* 接收校验 */
STATE_DONE /* 接收完成 */
} recv_state_t;
/* 状态机处理函数 */
static recv_state_t process_recv_state(recv_state_t state, uint8_t byte)
{
switch (state) {
case STATE_IDLE:
if (byte == FRAME_HEADER) {
return STATE_RECV_HEAD;
}
break;
case STATE_RECV_HEAD:
/* 解析帧头信息 */
frame.len = byte;
frame.crc = 0;
return STATE_RECV_DATA;
case STATE_RECV_DATA:
/* 接收数据体 */
frame.buffer[frame.index++] = byte;
if (frame.index >= frame.len) {
return STATE_RECV_CRC;
}
break;
/* ... 其他状态处理 */
}
return STATE_IDLE;
}
这里有个避坑指南。我曾经在状态机里用了switch-case嵌套,结果状态多了以后,代码可读性极差。后来我改用状态表驱动的方式,每个状态对应一个处理函数,代码清晰多了。
注意:状态机里千万不要做阻塞操作。比如在接收状态里等待数据,那整个系统就卡死了。状态机应该是非阻塞的,每次处理一个字节,处理完就返回。
2.2.2 缓冲区管理
缓冲区管理,说白了就是怎么高效地存数据、取数据。工业通信里,数据是源源不断进来的,缓冲区设计不好,要么丢数据,要么内存浪费。
我推荐两种缓冲区方案:
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 环形缓冲区 | 数据流连续、速率稳定 | 实现简单、无内存碎片 | 大小固定、不能动态扩展 |
| 链表缓冲区 | 数据包大小不定、突发性强 | 灵活、可动态分配 | 有内存碎片、管理复杂 |
我个人习惯,在资源受限的MCU上,优先用环形缓冲区。为什么?因为工业通信的数据流是稳定的,你算好最大帧长,缓冲区大小就定下来了。动态分配反而容易出问题。
环形缓冲区的核心代码:
/* 环形缓冲区结构 */
typedef struct {
uint8_t* buffer;
uint16_t head; /* 写指针 */
uint16_t tail; /* 读指针 */
uint16_t size; /* 缓冲区大小 */
} ring_buffer_t;
/* 写入一个字节 */
int32_t ring_buffer_write(ring_buffer_t* rb, uint8_t data)
{
uint16_t next = (rb->head + 1) % rb->size;
/* 缓冲区满了 */
if (next == rb->tail) {
return -1;
}
rb->buffer[rb->head] = data;
rb->head = next;
return 0;
}
2.2.3 定时器
定时器在协议栈里干什么用?超时重传、帧间隔检测、心跳维护,都离不开它。我见过有人用软件延时来做超时,结果CPU全耗在等待上了。正确的做法是用硬件定时器。
定时器的典型应用场景:
- 帧超时检测:接收一帧数据时,如果两个字节间隔超过3.5个字符时间,认为帧结束
- 请求超时:主站发请求后,从站必须在规定时间内回复
- 心跳定时:周期性发送心跳包,检测链路是否正常
定时器接口设计:
/* 定时器管理结构 */
typedef struct {
uint32_t timeout_ms; /* 超时时间 */
uint32_t start_tick; /* 启动时的系统滴答 */
bool is_running; /* 是否在运行 */
void (*callback)(void* arg); /* 超时回调 */
void* arg; /* 回调参数 */
} timer_t;
/* 启动定时器 */
void timer_start(timer_t* tmr, uint32_t timeout_ms)
{
tmr->timeout_ms = timeout_ms;
tmr->start_tick = get_system_tick();
tmr->is_running = true;
}
/* 检查定时器是否超时 */
bool timer_is_expired(timer_t* tmr)
{
if (!tmr->is_running) {
return false;
}
uint32_t elapsed = get_system_tick() - tmr->start_tick;
if (elapsed >= tmr->timeout_ms) {
tmr->is_running = false;
if (tmr->callback) {
tmr->callback(tmr->arg);
}
return true;
}
return false;
}
小技巧:定时器的精度取决于系统滴答的粒度。我建议系统滴答设为1ms,这样定时精度够用,也不会占用太多CPU。如果你需要微秒级的定时,那就得用硬件定时器的捕获比较功能了。
好了,分层模型和核心组件就讲到这里。记住一句话:架构设计好了,移植和优化就是水到渠成的事。下一章咱们聊聊具体的移植实战,到时候我会拿一个真实的协议栈来演示。