4、协议栈软件架构设计:分层设计思想

好,咱们进入正题。协议栈的软件架构,说白了就是「怎么把一堆复杂的功能,拆成好管理的小块」。我做了这么多年嵌入式通信,见过太多人一上来就写代码,写到后面自己都理不清了。

分层设计,是解决这个问题的金钥匙。你想想看,TCP/IP 协议栈为什么能统治世界?就是因为它分得清楚。每一层只管自己的事,不越界。

4.1 为什么必须分层?

我刚开始做工业通信时,也犯过傻。那时候赶项目,把物理层收发、数据帧解析、应用逻辑全写在一个文件里。结果呢?换一个 PHY 芯片,改了三天还没改完。后来我学乖了——分层,是为了隔离变化

工业通信协议栈,我习惯分成三层:

  • 物理层:管硬件收发,跟 GPIO、SPI、UART、CAN 收发器打交道
  • 数据链路层:管帧格式、地址过滤、CRC 校验、重传机制
  • 应用层:管用户数据、命令解析、状态机

每一层都有明确的职责。物理层坏了,只改物理层。应用层要加功能,不动下面两层。这就是分层的好处。

核心原则:上层调用下层,下层不依赖上层。数据从下往上走,命令从上往下传。

4.2 物理层设计:跟硬件打交道

物理层,说白了就是「怎么把 0 和 1 发出去,再收回来」。我做过 RS-485、CAN、EtherCAT,每种物理层都不一样,但抽象出来的接口却可以统一。

我个人习惯,物理层暴露这么几个接口:

/* 物理层接口定义 */
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);      // 使能/禁能中断
    void    (*reset)(void);                  // 复位硬件
} phy_layer_t;

嗯,这里要注意:物理层不解析任何帧结构。它只管收发裸字节。我在项目中遇到过有人把地址过滤写在物理层,结果换了个芯片,地址格式变了,物理层代码全得重写。这就是典型的「分层没分干净」。

避坑指南:我曾经在物理层里偷偷加了一个字节的帧头检测,想着「反正就加一点点」。后来数据链路层改了帧格式,物理层也得跟着改。从那以后,我严格规定:物理层只做电平转换和字节流收发,多一个字节都不处理。

4.3 数据链路层:帧的组装与拆解

数据链路层,是协议栈的「腰部」。它负责把物理层的字节流,组装成有意义的帧。为什么需要这一层?因为物理层只管发字节,它不知道哪些字节是一帧的开始,哪些是结束。

数据链路层要干的事:

  • 帧定界:找到帧头、帧尾
  • 地址过滤:判断是不是发给自己的
  • 差错检测:CRC 校验,扔掉坏帧
  • 流量控制:防止接收方被撑死

我常用的数据链路层接口:

/* 数据链路层接口 */
typedef struct {
    int32_t (*send_frame)(uint8_t *data, uint16_t len, uint16_t dst_addr);
    int32_t (*recv_frame)(uint8_t *data, uint16_t *len, uint16_t *src_addr);
    void    (*set_addr)(uint16_t addr);
    uint16_t (*get_addr)(void);
} dll_layer_t;

你想想看,数据链路层从物理层拿到的是「一堆字节」,它要从中找出完整的帧。如果帧不完整或者 CRC 错了,直接扔掉,不给上层添乱。这就是分层的意义——错误在底层消化,上层只处理干净的数据

注意:数据链路层不要做应用层的决策。比如「收到这个命令要执行什么动作」,那是应用层的事。数据链路层只负责「把帧完整地交给上层」,至于上层怎么处理,它不管。

4.4 应用层:业务逻辑的归宿

应用层,是用户直接打交道的层。它从数据链路层拿到完整的帧,然后解析命令、执行操作、返回结果。

应用层设计,我建议遵循一个原则:一个命令一个处理函数。别搞什么 switch-case 写几百行,维护起来想哭。

/* 应用层命令表 */
typedef struct {
    uint8_t cmd_id;
    int32_t (*handler)(uint8_t *req, uint16_t req_len, 
                       uint8_t *rsp, uint16_t *rsp_len);
} app_cmd_entry_t;

/* 示例:读寄存器命令 */
static int32_t handle_read_reg(uint8_t *req, uint16_t req_len,
                                uint8_t *rsp, uint16_t *rsp_len) {
    // 解析请求,读取寄存器,填充响应
    return 0;
}

static const app_cmd_entry_t cmd_table[] = {
    {0x01, handle_read_reg},
    {0x02, handle_write_reg},
    {0x03, handle_diag},
    // ...
};

这样做的好处是:加一个新命令,只需要加一个 handler 函数,然后在表里注册一下。不用改任何框架代码。我在项目中用这个模式,客户要求加功能时,我只需要写一个新文件,编译进去就行。

4.5 接口定义:层与层之间的契约

分层设计的关键,是层与层之间的接口要稳定。我习惯用结构体指针来传递数据,而不是全局变量。为什么?因为全局变量会让层与层之间「耦合」在一起,改一个地方,到处受影响。

我定义的数据流方向:

方向 数据路径 说明
接收 物理层 → 数据链路层 → 应用层 字节流 → 帧 → 命令
发送 应用层 → 数据链路层 → 物理层 命令 → 帧 → 字节流

每一层只调用下一层的接口,不跨层调用。比如应用层不能直接调用物理层的 send(),必须通过数据链路层。这样做的好处是:如果你想换物理层芯片,只需要重写物理层的 send()recv(),上面两层完全不用动。

我的经验:接口定义时,参数尽量用指针和长度,不要用固定大小的数组。比如 send(uint8_t *data, uint16_t len)send(uint8_t data[256]) 灵活得多。前者可以传任意长度的数据,后者只能传 256 字节。你永远不知道未来协议会不会变长。

4.6 模块划分:代码怎么组织

模块划分,我习惯按层来建文件夹。每个层一个目录,里面放源文件和头文件。比如:

protocol_stack/
├── phy/
│   ├── phy_rs485.c
│   ├── phy_can.c
│   └── phy_interface.h    // 物理层接口声明
├── dll/
│   ├── dll_crc.c
│   ├── dll_frame.c
│   └── dll_interface.h    // 数据链路层接口声明
├── app/
│   ├── app_cmd_table.c
│   ├── app_handler.c
│   └── app_interface.h    // 应用层接口声明
└── main.c

每个模块只暴露必要的接口,内部实现对外不可见。比如 dll_crc.c 里的 CRC 计算函数,只在数据链路层内部使用,不暴露给应用层。这样做的好处是:你改 CRC 算法,应用层完全不知道,也不应该知道

嗯,这里有个小技巧:每个模块的头文件里,只放其他模块需要调用的函数声明。内部函数用 static 修饰,或者放在单独的 _internal.h 文件里。这样别人看你的代码,一眼就知道哪些是公开接口,哪些是内部实现。

总结一下:分层设计不是理论,是实践。物理层管收发,数据链路层管帧,应用层管业务。接口要稳定,模块要独立。你按这个思路做,后期维护会轻松很多。我见过太多项目死在「架构混乱」上,而不是死在「功能实现」上。

下一章,我会讲具体怎么实现一个轻量级的帧收发器,包括环形缓冲区的设计和中断处理。到时候咱们再细聊。