第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 服务端流程

  1. socket():创建Socket,指定IPv4和TCP类型。
  2. bind():绑定IP地址和端口。这一步容易忘,但服务端必须做。
  3. listen():进入监听状态,等待客户端连接。
  4. accept():阻塞等待,直到有客户端连上来。返回一个新的Socket用于通信。
  5. read()/write():和客户端收发数据。
  6. close():关闭连接。

这里有个细节:accept()返回的是一个新的文件描述符。原来的监听Socket还在,可以继续accept新的连接。我在项目中见过有人把监听Socket和通信Socket搞混,结果数据发不出去,排查了半天。

3.2.2 客户端流程

  1. socket():创建Socket。
  2. connect():主动连接服务端的IP和端口。
  3. read()/write():收发数据。
  4. 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服务端

  1. socket():创建UDP Socket。
  2. bind():绑定端口。
  3. recvfrom():接收数据,同时获取发送方的地址。
  4. sendto():回复数据(如果需要)。

3.3.2 UDP客户端

  1. socket():创建UDP Socket。
  2. sendto():直接发送数据,指定目标地址。
  3. 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抓包,观察三次握手和四次挥手的过程。相信我,抓包看一遍,比读十遍书都管用。