第3章:Linux网络基础——Socket编程入门(TCP/UDP)、网络字节序与主机字节序转换、IP地址与端口管理
各位同学,欢迎来到第三章。这一章我们聊聊Linux网络编程的基石——Socket。说实话,我在车载项目里调试网络通信时,十次有八次的问题都出在Socket基础没打牢。字节序搞反了、端口被占用了、TCP和UDP选错了……这些坑我几乎都踩过一遍。今天咱们就把这些基础彻底理清楚。
3.1 什么是Socket?说白了就是“网络文件描述符”
Socket这个词,直译是“插座”。嗯,挺形象的。一端插在应用层,另一端插在传输层。你往Socket里写数据,内核帮你发出去;别人发来的数据,你从Socket里读出来。
在Linux里,一切皆文件。Socket也不例外。它本质上就是一个文件描述符。你操作Socket,就像操作一个普通文件一样——用read()、write()、close()。只不过这个“文件”的另一端,连接着网络。
我个人习惯把Socket比作“电话机”。你买一部电话(创建Socket),拨号(connect),通话(send/recv),挂断(close)。TCP就是这种面向连接的方式。而UDP呢?更像是对讲机——你不需要拨号,直接喊话就行,但对方能不能收到,你管不着。
3.2 TCP Socket编程:三步走,稳如老狗
TCP是面向连接的、可靠的、基于字节流的传输协议。在车载场景下,像OTA升级、诊断服务(UDS over DoIP)这些对可靠性要求高的通信,基本都用TCP。
TCP Socket编程的流程,我总结为“服务端三步,客户端三步”。
3.2.1 服务端流程
- socket():创建Socket,指定IPv4和TCP类型。
- bind():绑定IP地址和端口。这一步容易忘,但服务端必须做。
- listen():进入监听状态,等待客户端连接。
- accept():阻塞等待,直到有客户端连上来。返回一个新的Socket用于通信。
- read()/write():和客户端收发数据。
- close():关闭连接。
这里有个细节:accept()返回的是一个新的文件描述符。原来的监听Socket还在,可以继续accept新的连接。我在项目中见过有人把监听Socket和通信Socket搞混,结果数据发不出去,排查了半天。
3.2.2 客户端流程
- socket():创建Socket。
- connect():主动连接服务端的IP和端口。
- read()/write():收发数据。
- close():关闭连接。
客户端不需要bind(),因为内核会自动分配一个临时端口。当然,如果你非要指定源端口,也可以bind,但一般没必要。
核心代码示例:TCP服务端(简化版)
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in servaddr;
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 绑定所有本地地址
servaddr.sin_port = htons(8888); // 端口8888
bind(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
listen(sockfd, 5); // 最大等待队列长度5
int connfd = accept(sockfd, NULL, NULL);
char buf[1024];
read(connfd, buf, sizeof(buf));
printf("收到: %s\n", buf);
close(connfd);
close(sockfd);
避坑指南:我曾经在bind()之前忘了检查端口是否被占用。结果服务端启动失败,但没有任何错误提示(因为没检查返回值)。后来我养成了一个习惯:每次bind()后都检查返回值,并用setsockopt()设置SO_REUSEADDR选项,允许端口重用。
3.3 UDP Socket编程:简单粗暴,但别丢包
UDP是无连接的、不可靠的、基于数据报的协议。在座舱里,像音视频流、传感器广播数据,经常用UDP。为什么?因为实时性要求高,偶尔丢一帧画面,用户可能感觉不到。但要是用TCP重传,卡顿反而更明显。
UDP编程比TCP简单得多。服务端和客户端几乎一样,不需要listen()和accept()。
3.3.1 UDP服务端
- socket():创建UDP Socket。
- bind():绑定端口。
- recvfrom():接收数据,同时获取发送方的地址。
- sendto():回复数据(如果需要)。
3.3.2 UDP客户端
- socket():创建UDP Socket。
- sendto():直接发送数据,指定目标地址。
- recvfrom():接收回复(可选)。
核心代码示例:UDP通信
// 服务端
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in servaddr, cliaddr;
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(9999);
bind(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
char buf[1024];
socklen_t len = sizeof(cliaddr);
recvfrom(sockfd, buf, sizeof(buf), 0, (struct sockaddr *)&cliaddr, &len);
printf("收到来自 %s:%d 的消息: %s\n",
inet_ntoa(cliaddr.sin_addr), ntohs(cliaddr.sin_port), buf);
// 客户端
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in servaddr;
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = inet_addr("192.168.1.100");
servaddr.sin_port = htons(9999);
sendto(sockfd, "Hello", 5, 0, (struct sockaddr *)&servaddr, sizeof(servaddr));
注意:UDP没有连接状态。你sendto()之后,内核不会帮你确认对方是否收到。在车载环境中,如果网络不稳定,UDP丢包率可能高达10%以上。所以,如果你用UDP传输关键数据(比如车辆控制指令),一定要在应用层自己实现确认和重传机制。
3.4 网络字节序与主机字节序:大端小端,傻傻分不清
这个问题,我当年刚入行时栽过大跟头。两台设备通信,明明代码逻辑都对,但数据就是解析不对。最后发现,一台是大端(Big-Endian),一台是小端(Little-Endian),端口号解析出来完全是乱的。
简单说:网络字节序统一使用大端模式(高位字节在低地址)。而我们的x86、ARM处理器,多数是小端模式(低位字节在低地址)。所以,在发送多字节数据(如端口号、IP地址)之前,必须转换。
Linux提供了四个转换函数,我建议你背下来:
| 函数 | 作用 | 示例 |
|---|---|---|
| htons() | Host to Network Short(16位) | 端口号转换 |
| htonl() | Host to Network Long(32位) | IP地址转换 |
| ntohs() | Network to Host Short | 接收端口号时用 |
| ntohl() | Network to Host Long | 接收IP地址时用 |
我的习惯:不管目标平台是大端还是小端,我统一用htons()/htonl()转换。因为如果平台本身就是大端,这些函数会被定义为空操作,不会影响性能。但如果你不转换,一旦移植到小端平台,代码就崩了。这叫“防御性编程”。
3.5 IP地址与端口管理:别让地址冲突毁了你的调试
IP地址和端口,是网络通信的“门牌号”和“房间号”。IP地址找到设备,端口找到设备上的具体服务。
3.5.1 IP地址的表示与转换
我们平时看到的“192.168.1.1”是点分十进制,但内核里用的是32位二进制。转换函数有两个:
- inet_aton():把字符串转成二进制(已过时,但很多老代码还在用)。
- inet_pton():推荐使用,支持IPv4和IPv6。
- inet_ntop():把二进制转回字符串。
struct in_addr addr;
inet_pton(AF_INET, "192.168.1.100", &addr);
// 现在addr.s_addr就是网络字节序的二进制IP
char str[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &addr, str, sizeof(str));
printf("IP: %s\n", str); // 输出 "192.168.1.100"
3.5.2 端口管理:避开“雷区”
端口号范围是0~65535。但要注意:
- 0~1023:知名端口,需要root权限才能绑定。比如HTTP用80,HTTPS用443。
- 1024~49151:注册端口,一般应用程序用这个范围。
- 49152~65535:动态/私有端口,客户端临时使用。
在座舱开发中,我建议你避开常见端口。比如不要用8080(Tomcat默认)、3306(MySQL默认)。我曾经在调试时,发现服务端bind()总是失败,查了半天才发现是端口被另一个调试工具占用了。
避坑指南:我曾经在车载项目中,把服务端端口写成了6666。结果在某个车型上,这个端口被系统保留用于诊断服务,导致bind()失败。后来我改成8888,问题解决。所以,建议使用50000以上的端口,冲突概率低。
3.6 实战经验:TCP还是UDP?我这样选
很多新手会问:到底用TCP还是UDP?我的经验是:
- 选TCP:数据必须完整、顺序必须正确。比如OTA升级包、诊断请求/响应、车辆状态上报。
- 选UDP:实时性优先,可以容忍少量丢包。比如音视频流、GPS位置广播、传感器数据流。
但现实往往更复杂。比如在座舱里,蓝牙音乐播放用的是A2DP协议,它底层是UDP,但上层做了重传和缓冲。说白了,就是“用UDP的壳,做TCP的事”。
嗯,这一章的内容就到这里。Socket编程是网络通信的基石,字节序转换是跨平台通信的“必修课”,IP和端口管理是日常调试的“基本功”。下一章,我们会深入CAN总线与以太网的融合,看看在座舱里,这些协议是怎么协同工作的。
课后练习:写一个简单的TCP回射服务器(Echo Server),客户端发送什么,服务端原样返回。然后用Wireshark抓包,观察三次握手和四次挥手的过程。相信我,抓包看一遍,比读十遍书都管用。