4、Socket编程入门:Socket类型、地址结构与常用API

Socket是嵌入式Linux网络编程的基石,它屏蔽了TCP/IP协议栈的复杂细节,向应用层提供了一套统一的文件描述符操作接口。本章将围绕Socket的三种核心类型(流式、数据报、原始)、地址结构sockaddr_in、字节序转换规则以及七个核心API(socket、bind、listen、accept、connect、send、recv)展开深入剖析。理解这些基础概念,是后续分析内核协议栈收发路径、调试网络故障的前提。下图展示了Socket编程的核心概念关系与API调用链路:

Socket编程核心概念与API调用链路 Socket类型 SOCK_STREAM (流式/TCP) SOCK_DGRAM (数据报/UDP) SOCK_RAW (原始/ICMP) 地址结构 & 字节序 struct sockaddr_in htonl / htons / ntohl / ntohs INADDR_ANY / inet_pton TCP服务端/客户端API调用流程 socket() bind() listen() accept() recv()/send() socket() connect() send()/recv() TCP三次握手 关键要点: • 流式Socket提供有序、可靠、双向字节流;数据报Socket保留消息边界,不可靠;原始Socket直接操作IP层。 • sockaddr_in中sin_family必须为AF_INET,sin_port和sin_addr需网络字节序。 • 服务端必须bind+listen+accept;客户端只需socket+connect。UDP无需listen/accept。 • send/recv返回值为实际收发字节数,需循环处理;EINTR/EAGAIN需特殊处理。

4.1 Socket类型:流式、数据报与原始

核心原理

Socket类型决定了数据在传输层和网络层的处理方式。在嵌入式Linux中,选择正确的Socket类型直接影响通信的可靠性、实时性和系统开销。

  • SOCK_STREAM(流式Socket):基于TCP协议,提供面向连接的、可靠的、有序的字节流服务。数据无边界,内核负责分段、重传、排序。适用于文件传输、远程登录、HTTP等需要可靠传输的场景。在嵌入式设备中,常用于与云端服务器或上位机进行控制指令和状态数据交换。
  • SOCK_DGRAM(数据报Socket):基于UDP协议,提供无连接的、不可靠的数据报服务。每个send调用对应一个完整数据报,接收端以相同边界接收。开销小、实时性高,但可能丢包、乱序。适用于视频流、传感器数据采集、DNS查询等容忍少量丢失的场景。
  • SOCK_RAW(原始Socket):允许应用直接访问IP层或ICMP层,可以构造自定义IP头、TCP/UDP头。常用于实现Ping(ICMP)、路由协议(OSPF)、网络诊断工具。在嵌入式开发中,原始Socket也用于调试自定义协议或抓取网络报文。

判断方法

维度SOCK_STREAMSOCK_DGRAMSOCK_RAW
底层协议TCPUDPIP/ICMP
连接性面向连接无连接无连接
可靠性可靠,有确认重传不可靠,尽力而为由上层协议决定
数据边界字节流,无边界保留消息边界保留IP包边界
典型应用HTTP、FTP、SSHDNS、SNMP、RTPPing、Traceroute
嵌入式场景设备远程配置实时传感器数据网络自诊断

案例拆解

某嵌入式网关设备需要同时提供三种通信服务:与云端管理平台采用TCP长连接上报设备状态;向局域网内的传感器节点发送UDP广播请求采集数据;使用原始Socket实现ICMP Ping功能检测上行链路连通性。开发人员分别创建三个Socket:socket(AF_INET, SOCK_STREAM, 0)、socket(AF_INET, SOCK_DGRAM, 0)、socket(AF_INET, SOCK_RAW, IPPROTO_ICMP)。注意原始Socket需要root权限,在嵌入式系统中需考虑权限管理。

常见误区

误区1:认为SOCK_DGRAM比SOCK_STREAM更快就盲目使用。实际上UDP在局域网内丢包率极低,但在公网或无线环境下丢包率可能显著上升,必须在上层增加确认机制。

误区2:原始Socket构造IP头时字节序处理错误,导致报文无法被对端识别。建议使用struct iphdr结构体并逐字段转换。

4.2 地址结构sockaddr_in与字节序转换

核心原理

在IPv4网络中,Socket地址由IP地址和端口号唯一确定。Linux内核定义了struct sockaddr_in作为应用层与内核交互的地址载体。其定义如下:

struct sockaddr_in {
    sa_family_t    sin_family;   // 地址族,必须为AF_INET
    uint16_t       sin_port;     // 端口号,网络字节序
    struct in_addr sin_addr;     // IP地址,网络字节序
    char           sin_zero[8];  // 填充,保持与sockaddr大小一致
};

字节序问题源于不同CPU架构对多字节数据的存储顺序差异。x86/ARM小端模式将低字节存低地址,而网络协议规定使用大端(网络字节序)。因此,所有写入sockaddr_in的端口和IP地址必须转换为网络字节序。

操作流程

  1. 定义struct sockaddr_in serv_addr;并清零:memset(&serv_addr, 0, sizeof(serv_addr))。
  2. 设置sin_family = AF_INET。
  3. 端口转换:serv_addr.sin_port = htons(8080)。
  4. IP地址转换:inet_pton(AF_INET, "192.168.1.100", &serv_addr.sin_addr),或使用INADDR_ANY绑定所有本地接口。
  5. 调用bind()或connect()时,将struct sockaddr_in*强转为struct sockaddr*。

案例拆解

在ARM Cortex-A9嵌入式平台上,开发人员编写TCP服务端绑定端口6000。若直接写serv_addr.sin_port = 6000,由于ARM小端模式,实际端口变为0x1770(6000的十六进制)在内存中存储为70 17,而网络传输需要17 70,导致对端连接失败。正确做法是使用htons(6000)。同样,IP地址192.168.1.100需通过inet_pton转换,该函数自动处理点分十进制到二进制网络序的转换。

常见误区

经验提醒:在嵌入式交叉编译环境中,务必确认htons等宏定义正确。部分老旧的BSP可能未正确配置字节序宏,导致转换失效。建议在初始化时打印serv_addr.sin_port的十六进制值验证。

4.3 常用API详解:socket、bind、listen、accept、connect、send、recv

核心原理

这七个API构成了TCP Socket编程的主干。它们的调用顺序和参数含义直接决定了网络应用的正确性。下表总结了各API的核心功能与注意事项:

API函数原型核心作用关键参数
socketint socket(int domain, int type, int protocol)创建Socket文件描述符domain=AF_INET, type=SOCK_STREAM/DGRAM/RAW
bindint bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen)绑定本地地址和端口addr指向sockaddr_in结构
listenint listen(int sockfd, int backlog)将Socket转为被动监听状态backlog为连接队列长度
acceptint accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen)接受客户端连接,返回新fdaddr返回客户端地址
connectint connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen)客户端发起连接请求addr为目标服务器地址
sendssize_t send(int sockfd, const void *buf, size_t len, int flags)发送数据flags通常为0
recvssize_t recv(int sockfd, void *buf, size_t len, int flags)接收数据返回实际接收字节数

操作流程

TCP服务端标准流程:

int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_addr.sin_port = htons(8080);
bind(listen_fd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
listen(listen_fd, 5);
while(1) {
    struct sockaddr_in cli_addr;
    socklen_t cli_len = sizeof(cli_addr);
    int conn_fd = accept(listen_fd, (struct sockaddr*)&cli_addr, &cli_len);
    // 处理conn_fd...
}

TCP客户端标准流程:

int sock_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(8080);
inet_pton(AF_INET, "192.168.1.100", &serv_addr.sin_addr);
connect(sock_fd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
send(sock_fd, "Hello", 5, 0);
char buf[128];
int n = recv(sock_fd, buf, sizeof(buf), 0);

案例拆解:嵌入式设备远程日志上报

某工业数据采集终端需要将采集到的温度、湿度数据通过TCP上报到中心服务器。终端采用ARM Linux,网络环境为有线以太网。开发过程中遇到以下问题:

  • 初始现象:终端上电后,日志上报偶尔失败,且失败时间集中在网络刚启动的3秒内。
  • 分析过程:通过抓包发现,终端在网卡IP地址尚未配置完成时就发起了connect,导致连接被拒绝。进一步检查代码,发现初始化顺序错误:先创建Socket并connect,后才调用DHCP获取IP。
  • 解决方案:在connect之前增加网络就绪检测,使用getifaddrs()检查eth0是否已获得有效IP地址;同时增加重连机制,connect失败后延时2秒重试,最多重试5次。
  • 结果对比:修改后,日志上报成功率从92%提升至99.8%,剩余0.2%的失败发生在服务器重启期间,通过重连机制自动恢复。
  • 复盘经验:嵌入式设备启动阶段网络栈初始化需要时间,应用层必须考虑网络未就绪的场景。此外,send/recv返回-1时需检查errno,若为EINTR(被信号中断)应重试,若为EAGAIN(非阻塞模式下缓冲区满)应稍后重试。

常见误区与总结

关键总结:

  • accept返回的conn_fd是新的Socket描述符,监听fd继续监听新连接;务必区分两者。
  • send/recv返回0表示对端关闭连接,返回-1需检查errno。嵌入式系统中信号处理(如SIGCHLD)可能导致EINTR,必须处理。
  • listen的backlog参数在Linux 2.4之后表示已完成三次握手但尚未accept的连接队列长度,而非未完成握手队列。嵌入式设备并发连接数少,backlog设为5-10即可。
  • UDP编程无需listen/accept,直接使用sendto/recvfrom,地址参数在每次调用时指定。

掌握Socket类型选择、地址结构填充和API调用流程,是嵌入式Linux网络编程的第一道门槛。下一阶段将深入探讨Socket在内核中的文件系统抽象、协议栈注册机制以及数据包在内核中的收发路径,帮助读者建立从应用到内核的完整视图。