第二章 协议栈架构解析:分层模型与核心组件

各位工程师朋友,咱们今天来聊聊协议栈的架构。说实话,我见过太多人一上来就埋头写代码,结果写到一半发现结构乱成一锅粥。协议栈这东西,架构设计好了,后面移植和优化就顺风顺水。要是架构没想清楚,后面全是坑。

我个人习惯,拿到一个工业协议栈,先看它的分层模型。为什么?因为分层决定了你后续怎么裁剪、怎么移植。你想想看,物理层换了,难道要把整个协议栈重写一遍吗?当然不是。好的分层设计,让你只改一层就行。

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。如果你需要微秒级的定时,那就得用硬件定时器的捕获比较功能了。

好了,分层模型和核心组件就讲到这里。记住一句话:架构设计好了,移植和优化就是水到渠成的事。下一章咱们聊聊具体的移植实战,到时候我会拿一个真实的协议栈来演示。