2. 嵌入式事件驱动架构基础

说到协议栈,很多嵌入式工程师第一反应就是「太庞大、太复杂」。确实,完整的OSI七层模型在PC上跑没问题,但放到资源受限的MCU上,那就是灾难。我这些年经手的项目,从BLE Mesh到工业以太网,没有哪个是直接拿完整协议栈往上怼的。

说白了,嵌入式协议栈的核心就一句话:按需裁剪,只留够用的。你想想看,一个跑在Cortex-M4上的Zigbee网关,需要完整的TCP/IP协议栈吗?不需要。但你要不要UDP?要不要ARP?嗯,这里就要仔细掂量了。

2.1 OSI模型在嵌入式场景下的「瘦身」逻辑

OSI七层模型(物理层、数据链路层、网络层、传输层、会话层、表示层、应用层)是教科书上的标准。但在嵌入式世界里,我们通常只保留四层,甚至三层:

OSI层 嵌入式裁剪建议 典型场景
应用层 保留,但协议需精简(如CoAP替代HTTP) 传感器数据上报
表示层 通常合并到应用层 自定义序列化格式
会话层 直接砍掉,由应用层管理连接 无状态通信
传输层 保留UDP或精简TCP 实时控制、遥测
网络层 保留IP或6LoWPAN 无线Mesh网络
数据链路层 保留MAC子层,LLC可简化 802.15.4、CAN
物理层 保留,但驱动需适配硬件 SPI、UART、射频前端

我在做一款工业无线传感器节点时,MCU只有64KB Flash。完整lwIP栈塞进去就占了40KB,剩下给应用代码的空间捉襟见肘。后来我把会话层和表示层直接砍掉,传输层只保留UDP,网络层用6LoWPAN的头压缩——最终协议栈只占18KB。你看,这就是裁剪的价值。

核心原则:嵌入式协议栈的每一层都必须回答一个问题——「没有这一层,系统还能不能正常工作?」如果答案是「能」,那就砍掉它。

2.2 适配层接口:为什么需要它?

适配层,说白了就是协议栈和底层硬件之间的「翻译官」。我见过太多项目,协议栈代码和硬件驱动揉在一起,换一个MCU就得重写整个协议栈。这太痛苦了。

适配层的职责很清晰:

  • 屏蔽硬件差异:不管底层是SPI、UART还是I2C,适配层提供统一的收发接口
  • 管理缓冲区:协议栈不直接操作硬件FIFO,而是通过适配层申请/释放内存
  • 提供时间基准:协议栈需要定时器、超时管理,适配层封装系统Tick
  • 中断与事件转发:硬件中断发生后,适配层把事件转换成协议栈能理解的消息

我的习惯:适配层接口文件通常命名为 hal_adapt.h 和 hal_adapt.c。这样换平台时,只需要重写这两个文件,协议栈核心代码一行不动。

2.3 如何定义适配层接口?

接口定义要「小而精」。我一般只暴露5~8个函数,多了反而耦合重。下面是我在多个项目中验证过的一套接口:

/* hal_adapt.h - 硬件适配层接口 */

/* 初始化/去初始化 */
int32_t hal_init(void);
int32_t hal_deinit(void);

/* 数据收发 */
int32_t hal_send(const uint8_t *data, uint32_t len, uint32_t timeout_ms);
int32_t hal_recv(uint8_t *buf, uint32_t *len, uint32_t timeout_ms);

/* 缓冲区管理 */
void*  hal_buf_alloc(uint32_t size);
void   hal_buf_free(void *ptr);

/* 时间管理 */
uint64_t hal_get_tick_ms(void);
void     hal_delay_ms(uint32_t ms);

/* 中断回调注册 */
typedef void (*hal_rx_callback_t)(uint8_t *data, uint32_t len);
int32_t hal_register_rx_callback(hal_rx_callback_t cb);

你可能会问:为什么不用标准POSIX接口?嗯,这里有个坑。POSIX的 read()/write() 是阻塞的,而嵌入式协议栈大部分场景需要非阻塞或超时机制。所以我直接用 timeout_ms 参数来控制阻塞行为,0表示非阻塞,0xFFFFFFFF表示一直等。

我曾经踩过的坑:一开始我把 hal_send 设计成同步阻塞,结果在中断上下文里调用时直接死锁。后来改成支持 timeout_ms=0 的非阻塞模式,并在适配层内部用环形缓冲区做异步转发,问题才解决。

2.4 适配层的分层结构

适配层内部也不是铁板一块。我习惯把它拆成两层:

  1. 接口层:就是上面那套 hal_xxx 函数,对协议栈可见
  2. 驱动抽象层:封装具体硬件操作,比如SPI的片选、DMA配置、GPIO中断

这样做的好处是:接口层保持稳定,驱动抽象层可以灵活替换。比如从SPI Flash换成QSPI Flash,只需要改驱动抽象层,接口层和协议栈完全不受影响。

下面这张图展示了适配层在整个架构中的位置:

嵌入式协议栈适配层架构 应用层(用户代码) 协议栈核心(裁剪后) 适配层 - 接口层 hal_init / hal_send / hal_recv / hal_buf_alloc / hal_get_tick_ms 适配层 - 驱动抽象层 SPI驱动 / UART驱动 / DMA管理 / GPIO中断 硬件层(MCU外设 + 射频芯片) 关键设计原则 接口层对协议栈暴露统一API,驱动抽象层封装硬件差异。换平台只改驱动抽象层,接口层和协议栈不动。

2.5 接口设计的几个关键决策

定义接口时,有几个点特别容易翻车。我列出来,你写代码时多留个心眼:

决策点 推荐做法 反面案例
缓冲区所有权 协议栈申请,适配层使用后归还 适配层内部静态数组,协议栈直接访问
超时处理 统一用毫秒,0表示非阻塞 用Tick数,不同平台Tick周期不同
中断上下文 适配层只做数据搬运,不处理协议 在中断里直接调用协议栈API
错误码 统一负数表示错误,0表示成功 正数、负数混用,或返回布尔值
重入性 接口层加锁,驱动层不加锁 每层都加锁,死锁风险高

避坑指南:我曾经在一个项目里,适配层的 hal_send 返回了实际发送的字节数(正数),而协议栈期望的是0表示成功。结果协议栈一直认为发送失败,疯狂重传。从那以后,我统一规定:所有适配层接口,0表示成功,负数表示错误码。简单,不容易出错。

2.6 适配层的测试策略

适配层写完了怎么测?我的做法是写一个「模拟硬件层」:

/* test_hal_mock.c - 模拟硬件层,用于单元测试 */

static uint8_t mock_rx_buffer[256];
static uint32_t mock_rx_len = 0;
static uint32_t mock_tick = 0;

int32_t hal_init(void) {
    mock_rx_len = 0;
    mock_tick = 0;
    return 0;
}

int32_t hal_send(const uint8_t *data, uint32_t len, uint32_t timeout_ms) {
    /* 模拟发送:把数据打印出来,或者存到测试日志 */
    printf("[MOCK] send %d bytes\n", len);
    return 0;
}

int32_t hal_recv(uint8_t *buf, uint32_t *len, uint32_t timeout_ms) {
    if (mock_rx_len == 0) {
        return -1; /* 无数据 */
    }
    memcpy(buf, mock_rx_buffer, mock_rx_len);
    *len = mock_rx_len;
    mock_rx_len = 0;
    return 0;
}

/* 测试辅助函数:注入接收数据 */
void test_inject_rx_data(const uint8_t *data, uint32_t len) {
    memcpy(mock_rx_buffer, data, len);
    mock_rx_len = len;
}

用这套模拟层,我可以在PC上跑完整的协议栈单元测试,不需要硬件。等硬件回来了,把 test_hal_mock.c 换成真正的 hal_adapt.c,协议栈代码一行不改。

我的习惯:适配层接口定义好后,先写模拟层,再写协议栈核心代码。这样协议栈的开发可以和硬件开发并行,至少能提前两周出活。

2.7 小结

协议栈分层设计,说白了就是「砍掉不需要的,抽象出通用的」。适配层是协议栈和硬件之间的防火墙,定义得好,换平台就像换衣服一样简单。定义得不好,那就是牵一发而动全身,改一个驱动要重测整个协议栈。

记住三个要点:

  • 接口要少:5~8个函数足够,多了就是过度设计
  • 语义要清:0成功、负数错误、超时用毫秒
  • 测试要早:模拟层先写好,等硬件不如等代码