3、网络层与传输层实战:TCP/IP协议栈裁剪、Socket编程基础、长连接与心跳机制

这一章,我们终于要跟网络协议栈“硬碰硬”了。

很多做嵌入式通信的工程师,一提到TCP/IP就觉得那是Linux或者PC上的事。其实不然。在资源受限的MCU上,我们同样需要TCP/IP,只不过得“裁剪”着用。说白了,就是去掉那些你用不上的功能,把协议栈瘦身到能在几十KB的RAM里跑起来。

我个人习惯把这一章分成三块:协议栈裁剪、Socket编程基础、长连接与心跳机制。这三块是递进关系——你得先有个能跑的协议栈,然后学会用Socket接口收发数据,最后才能保证这条连接在恶劣的网络环境下不掉线。

3.1 TCP/IP协议栈裁剪:给MCU“减负”

先问一个问题:一个完整的TCP/IP协议栈有多大?

Linux内核里的协议栈,光TCP模块就有几万行代码。这在MCU上显然不现实。所以我们需要裁剪。裁剪的核心思路就一句话:用不到的协议层,直接砍掉。

嵌入式协议栈裁剪三原则:

  • 砍掉IPv6:如果你的产品只在国内局域网或4G Cat.1下工作,IPv6暂时用不上。IPv6的地址处理、邻居发现、路由头,加起来能占十几KB的ROM。
  • 砍掉ICMP的冗余功能:保留Ping回复(Echo Reply)就够了。什么时间戳请求、地址掩码请求,统统不要。
  • 砍掉TCP的“高级”选项:选择性确认(SACK)、窗口缩放(Window Scale)、时间戳选项(Timestamps),这些在低速链路上意义不大,关掉能省不少内存。

我遇到过最极端的一个项目,用的是Cortex-M3,RAM只有20KB。我们用的协议栈是lwIP,默认配置下RAM占用接近30KB。怎么办?只能硬着头皮裁剪。

最终我们做了几件事:

  • 把TCP的接收窗口从默认的64KB缩小到2KB;
  • 关闭了TCP的“紧急指针”处理;
  • 把UDP的PCB(协议控制块)数量从默认的16个减到4个;
  • 把ARP表从10条减到3条。

折腾下来,RAM占用降到了12KB左右,总算能跑了。

我的建议:裁剪协议栈时,不要一次性把所有功能都关掉。每关一个功能,就编译一次,看看RAM和ROM的变化。我曾经因为一次性关了太多选项,导致TCP三次握手一直失败,查了整整两天才发现是某个选项影响了SYN包的发送。

3.2 Socket编程基础:从“裸数据”到“连接”

协议栈裁剪好了,接下来就是怎么用它。在嵌入式领域,Socket编程跟PC上差不多,但有几个关键区别。

先看一个最简单的TCP客户端代码(基于lwIP的RAW API):

// 创建一个TCP PCB
struct tcp_pcb *pcb = tcp_new();

// 绑定本地端口(可选)
tcp_bind(pcb, IP_ADDR_ANY, 0);

// 连接到服务器
ip_addr_t server_ip;
IP4_ADDR(&server_ip, 192, 168, 1, 100);
tcp_connect(pcb, &server_ip, 8080, connected_callback);

// 连接成功后的回调
static err_t connected_callback(void *arg, struct tcp_pcb *tpcb, err_t err) {
    // 发送数据
    tcp_write(tpcb, "Hello", 5, TCP_WRITE_FLAG_COPY);
    return ERR_OK;
}

// 接收数据的回调
static err_t recv_callback(void *arg, struct tcp_pcb *tpcb, 
                           struct pbuf *p, err_t err) {
    if (p != NULL) {
        // 处理接收到的数据
        tcp_recved(tpcb, p->len);
        pbuf_free(p);
    }
    return ERR_OK;
}

这段代码看起来简单,但有几个坑:

  • 回调函数是中断上下文吗? 不一定。lwIP的RAW API是在主循环里轮询调用的,所以回调里不能做阻塞操作。
  • tcp_write之后一定要调用tcp_output,否则数据可能一直积压在发送缓冲区里,不会真正发出去。
  • pbuf要记得释放,否则内存泄漏。我见过一个同事,因为忘了调用pbuf_free,跑了三天后设备直接死机。

注意:在嵌入式Socket编程中,千万不要在回调函数里做耗时操作,比如延时、打印大量日志、或者调用其他阻塞API。回调函数应该尽快返回,把数据处理放到主循环里。否则,轻则丢包,重则协议栈崩溃。

3.3 长连接与心跳机制:让连接“活着”

好了,现在你的设备能连上服务器了,也能收发数据了。但问题来了:如果设备连着服务器,但好几分钟都没有数据交互,会发生什么?

答案是:连接可能会被中间的路由器、防火墙或者NAT网关“静默”地断开。这些设备为了节省资源,会定期清理那些长时间没有流量的连接。你想想看,如果设备在野外采集数据,突然发现连接断了,数据发不出去,那可就麻烦了。

解决这个问题的方法就是心跳机制。说白了,就是客户端定期给服务器发一个“我还活着”的小数据包。

心跳机制的设计要点:

  • 心跳间隔:一般设为30秒到2分钟。太短浪费带宽和电量,太长可能被中间设备断开。
  • 心跳包内容:尽量精简。一个字节的“0x00”或者一个固定的魔数就够了。不要带大量数据。
  • 超时重连:如果连续3-5次心跳都没有收到服务器的回复,就认为连接已断开,主动关闭socket并重新连接。

我曾经在一个项目中,把心跳间隔设成了5分钟,结果发现设备经常掉线。后来抓包分析,发现运营商的NAT网关超时时间是3分钟。也就是说,3分钟没有数据交互,网关就把连接给“忘了”。从那以后,我把心跳间隔改成了60秒,问题就解决了。

下面是一个简单的心跳实现伪代码:

// 主循环中的心跳处理
void heartbeat_task(void) {
    static uint32_t last_heartbeat = 0;
    static uint8_t missed_count = 0;
    
    // 检查是否到了发送心跳的时间
    if (get_tick() - last_heartbeat > HEARTBEAT_INTERVAL_MS) {
        // 发送心跳包
        if (send_heartbeat_packet() == SUCCESS) {
            last_heartbeat = get_tick();
            missed_count = 0;
        } else {
            missed_count++;
            if (missed_count >= MAX_MISSED_HEARTBEATS) {
                // 连接已断开,执行重连
                close_socket();
                reconnect();
                missed_count = 0;
            }
        }
    }
    
    // 检查服务器是否回复了心跳
    if (heartbeat_ack_received) {
        missed_count = 0;
        heartbeat_ack_received = false;
    }
}

我的经验:心跳包最好跟业务数据区分开。可以在应用层协议里定义一个特殊的消息类型,比如“0x01”表示心跳请求,“0x02”表示心跳回复。这样服务器收到心跳包后,就知道不需要做业务处理,直接回复一个确认就行了。

另外,还有一个容易被忽略的点:心跳超时时间要留有余量。比如你设置的心跳间隔是60秒,那么超时时间可以设成90秒或者120秒。因为网络可能有瞬时延迟,如果超时时间设得太紧,很容易误判为连接断开,导致不必要的重连。

TCP/IP协议栈裁剪与通信流程 应用层 传输层 (TCP/UDP) 网络层 (IP/ICMP) 链路层 (ARP/MAC) 裁剪:砍掉IPv6、SACK 裁剪:缩小窗口、关紧急指针 裁剪:只保留Ping回复 裁剪:缩小ARP表 Socket编程 tcp_connect() tcp_write() / tcp_output() recv_callback() 心跳机制 间隔60秒 → 超时90秒 → 连续3次无回复 → 重连

最后,我想强调一点:心跳机制不是万能的。它只能检测连接是否断开,但不能保证数据一定到达。如果你需要确保数据可靠到达,那就要在应用层做确认重传机制。不过那是后面章节的内容了,这里先不展开。

避坑指南:我曾经在一个项目里,把心跳包和业务数据混在一起发送。结果服务器那边解析逻辑写错了,把心跳包当成了业务数据,导致业务逻辑混乱。后来我们改了协议,让心跳包使用独立的端口,才彻底解决了这个问题。所以,心跳和业务数据一定要在协议层面严格区分。