第二讲:网络协议基础——TCP/IP协议栈、UDP协议、Socket编程基础

各位同学,咱们今天聊点实在的。网络协议这东西,说白了就是设备之间沟通的“普通话”。你想想看,如果我说中文你说英文,那还怎么玩?嵌入式系统里,两个板子要通信,没有协议就是鸡同鸭讲。

我个人习惯把网络协议分成两个层面来理解:一个是“怎么传”,也就是数据怎么打包、怎么路由;另一个是“传什么”,也就是应用层的数据格式。今天咱们重点讲前者——TCP/IP协议栈和UDP协议,顺便把Socket编程的基础打牢。

核心观点:嵌入式网络通信,90%的问题出在协议理解不透彻。你只要把TCP/IP的四层模型吃透,后面写代码就是套模板的事。

2.1 TCP/IP协议栈:四层模型,别搞混了

很多教材喜欢讲七层OSI模型,但我建议你直接忘掉它。实际工程中,我们只认TCP/IP的四层模型:应用层、传输层、网络层、网络接口层

为什么?因为嵌入式系统资源有限,你不可能像PC那样跑完整的协议栈。我在做工业网关项目时,就遇到过裁剪协议栈导致兼容性出问题的情况——嗯,这个坑后面再细说。

先看一张图,把四层模型和对应的协议理清楚:

TCP/IP 四层协议栈模型 应用层 HTTP, FTP, MQTT, CoAP, WebSocket 用户数据、业务逻辑 传输层 TCP(可靠)、UDP(不可靠) 端口号、分段、重传、流量控制 网络层 IP(IPv4/IPv6)、ICMP、ARP 路由选择、IP地址、分片 网络接口层 以太网、Wi-Fi、PPP、CAN MAC地址、帧封装、物理介质 数据封装 → 逐层添加头部 发送方:应用层 → 传输层 → 网络层 → 接口层

这张图我建议你存下来。每次写网络代码前看一眼,心里就有底了。数据从应用层往下走,每层加一个头部,这叫封装;接收方从下往上走,每层剥一个头部,这叫解封装

实战小技巧:在嵌入式调试时,我经常用Wireshark抓包,然后对照四层模型看每一层的头部信息。比如TCP的SYN、ACK标志位,IP的源地址和目标地址——一眼就能看出问题出在哪一层。

2.2 TCP vs UDP:一个像快递,一个像明信片

传输层就两个主角:TCP和UDP。很多初学者纠结“到底用哪个”,其实没那么复杂。

TCP(传输控制协议)——可靠、有序、面向连接。就像你寄快递:要填单子、要确认收货、丢了要重发。代价是什么?慢,占用资源多。

UDP(用户数据报协议)——不可靠、无序、无连接。就像你寄明信片:扔进邮筒就不管了,丢了就丢了。好处是什么?快,开销小。

我在项目中遇到过这样一个场景:一个数据采集系统,传感器每秒上报1000个数据点。如果用TCP,三次握手、确认重传这些机制会把CPU吃满,而且延迟会飙升。后来改成UDP,配合应用层的简单校验,问题就解决了。

特性 TCP UDP
连接方式 面向连接(三次握手) 无连接
可靠性 可靠(确认重传) 不可靠(尽力而为)
数据顺序 保证有序 不保证有序
传输速度 较慢 较快
头部开销 20~60字节 8字节
典型应用 HTTP、FTP、MQTT DNS、DHCP、视频流

注意:千万不要以为UDP不可靠就完全不能用。很多工业协议(如Modbus TCP、EtherCAT)底层用的就是UDP,但通过应用层做了可靠性保障。关键看你的业务场景能不能容忍丢包。

2.3 Socket编程基础:说白了就是“文件描述符”

Socket是什么?我换个说法你就懂了:它是操作系统提供的一个接口,让应用程序可以像读写文件一样读写网络数据

在Linux/嵌入式系统中,一切皆文件。Socket也不例外——它就是一个文件描述符。你打开一个Socket,得到一个整数(比如3),然后对这个整数做read/write操作,数据就通过网络发出去了。

我刚开始学Socket时,总觉得它很神秘。后来在ARM Cortex-M上移植了LwIP协议栈,亲手调通了TCP Server,才明白:Socket编程就是三板斧——创建、绑定、收发

2.3.1 TCP Socket编程流程

TCP是面向连接的,所以流程稍微复杂一点。我习惯把它分成服务端客户端两条线来记:

服务端(Server):

  1. socket() —— 创建Socket,指定协议族(AF_INET)、类型(SOCK_STREAM)
  2. bind() —— 绑定IP地址和端口号
  3. listen() —— 开始监听,等待客户端连接
  4. accept() —— 接受客户端连接,返回一个新的Socket用于通信
  5. recv()/send() —— 收发数据
  6. close() —— 关闭连接

客户端(Client):

  1. socket() —— 创建Socket
  2. connect() —— 连接服务端(指定IP和端口)
  3. send()/recv() —— 收发数据
  4. close() —— 关闭连接

看个最简单的TCP服务端代码(Linux环境):

#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <string.h>

int main() {
    int server_fd, client_fd;
    struct sockaddr_in address;
    int addrlen = sizeof(address);
    char buffer[1024] = {0};
    
    // 1. 创建Socket
    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    
    // 2. 绑定端口8080
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(8080);
    bind(server_fd, (struct sockaddr *)&address, sizeof(address));
    
    // 3. 开始监听
    listen(server_fd, 3);
    
    printf("等待客户端连接...\n");
    
    // 4. 接受连接
    client_fd = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen);
    
    // 5. 接收数据
    recv(client_fd, buffer, 1024, 0);
    printf("收到消息: %s\n", buffer);
    
    // 6. 关闭
    close(client_fd);
    close(server_fd);
    return 0;
}

避坑指南:我曾经在嵌入式板子上调试TCP服务端,发现客户端连上来后,服务端收不到数据。查了半天,原来是htons()忘了调用——端口号字节序不对。记住:网络字节序是大端,主机字节序可能是小端,一定要用htons()htonl()转换。

2.3.2 UDP Socket编程流程

UDP就简单多了,不需要连接,直接发:

服务端:

  1. socket() —— 创建Socket,类型用SOCK_DGRAM
  2. bind() —— 绑定端口
  3. recvfrom() —— 接收数据(同时获取发送方地址)
  4. sendto() —— 发送数据(指定目标地址)

客户端:

  1. socket() —— 创建Socket
  2. sendto() —— 直接发数据给服务端
  3. recvfrom() —— 接收服务端回复

看代码更直观:

// UDP服务端示例
int sockfd;
struct sockaddr_in server_addr, client_addr;
char buffer[1024];

sockfd = socket(AF_INET, SOCK_DGRAM, 0);

server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(8080);
bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));

// 接收数据(不需要accept)
int len = sizeof(client_addr);
recvfrom(sockfd, buffer, 1024, 0, (struct sockaddr *)&client_addr, &len);
printf("收到: %s\n", buffer);

// 回复数据
sendto(sockfd, "OK", 2, 0, (struct sockaddr *)&client_addr, len);

close(sockfd);

关键区别:TCP的recv()/send()不需要指定对方地址,因为连接已经建立;UDP的recvfrom()/sendto()必须带地址参数,因为UDP是无连接的,每次都要告诉内核“发给谁”。

2.4 嵌入式环境下的Socket注意事项

在PC上写Socket程序,你基本不用操心资源问题。但在嵌入式系统里,每一字节内存、每一个CPU周期都要精打细算。

我总结了几条实战经验:

  • 缓冲区大小要合理:不要用1024字节的固定缓冲区,要根据MTU(最大传输单元,通常1500字节)来设定。我一般用1460字节,刚好一个TCP分段。
  • 非阻塞模式是必须的:嵌入式主循环里不能用阻塞的recv(),否则整个系统会卡死。用fcntl()设置O_NONBLOCK,或者用select()/poll()做超时处理。
  • 注意内存对齐:有些MCU(比如STM32)对非对齐访问会触发硬件异常。网络协议头部的结构体定义,记得加__attribute__((packed))
  • 心跳机制:TCP长连接在嵌入式环境里容易断(比如Wi-Fi信号不好),我习惯在应用层加心跳包,30秒发一次,连续3次没回复就重连。

曾经踩过的坑:有一次做4G DTU项目,设备连上服务器后,过几个小时就自动断开。查了三天,发现是运营商NAT超时时间只有5分钟,而我们的心跳包间隔是10分钟。改成3分钟发一次心跳,问题解决。记住:嵌入式网络通信,不仅要懂协议,还要懂网络环境

好了,这一讲的内容就到这里。TCP/IP协议栈是基础中的基础,Socket编程是嵌入式网络开发的必修课。你只要把四层模型刻在脑子里,把TCP和UDP的适用场景分清楚,再把Socket的创建-绑定-收发流程练熟,后面写网络应用就是水到渠成的事。


公众号:蓝海资料掘金营,微信deep3321