Linux 网络编程基础:Socket、TCP、UDP 通信原理
一、引言
前面的文章都在讲单个设备内部的事情——文件 IO、多进程、多线程、进程间通信。但嵌入式设备不能永远活在孤岛上:传感器采集了数据,总要通过网络上报给服务器;远程控制指令,自然也得通过网络下发到设备。
这就引出了 Linux 网络编程的核心——Socket(套接字)。本文从基本概念讲起,梳理 TCP 和 UDP 的通信流程差异,再给出服务端和客户端的可运行代码。读完这篇,你就迈入了"联网嵌入式开发"的大门。
二、什么是 Socket?
Socket 可以理解为一个网络通信的端点。一台设备有多个网络应用程序,操作系统通过 IP 地址 + 端口号来唯一标识一个 Socket,形式为IP:Port。
在 Linux 中,Socket 也是文件描述符——你可以像读写文件一样read/write一个 Socket。这种统一的设计哲学,是 Unix/Linux 最大的优雅之处。
Socket 通信的基本流程
Server Client | | socket() ─── 创建套接字 socket() ─── 创建套接字 | | bind() ─── 绑定地址端口 | | | listen() ── 开始监听 connect() ── 请求连接 | | accept() ── 接受连接(阻塞等待) ──────→ | | | read/write ←─── 数据交换 ────→ read/write | | close() close()三、TCP vs UDP:一张表看懂
| 特性 | TCP | UDP |
|---|---|---|
| 连接方式 | 面向连接(三次握手) | 无连接 |
| 可靠性 | 可靠,保证数据不丢不乱序 | 不可靠,数据可能丢失 |
| 传输效率 | 较慢,有流量控制 | 快,直接发包 |
| 应用场景 | HTTP、文件传输、远程登录 | 视频流、语音通话、传感器高频采集 |
| 头部开销 | 20 字节 | 8 字节 |
| 代码复杂度 | 较复杂(有 accept/connect) | 简单 |
一句话总结:TCP 像打电话——先拨号接通,再通话,确保每句话都听清楚;UDP 像发短信——直接丢出去,对方收到没有你不管。
四、TCP 服务端与客户端实战
4.1 TCP 服务端
#include<stdio.h>#include<stdlib.h>#include<string.h>#include<unistd.h>#include<arpa/inet.h>#definePORT8888#defineBACKLOG5intmain(void){intserver_fd,client_fd;structsockaddr_inserver_addr,client_addr;socklen_taddr_len=sizeof(client_addr);charbuf[1024];// 1. 创建 socketserver_fd=socket(AF_INET,SOCK_STREAM,0);if(server_fd<0){perror("socket");exit(1);}// 设置端口复用 (避免 Address already in use)intopt=1;setsockopt(server_fd,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));// 2. 绑定地址和端口memset(&server_addr,0,sizeof(server_addr));server_addr.sin_family=AF_INET;server_addr.sin_addr.s_addr=INADDR_ANY;// 监听所有网卡server_addr.sin_port=htons(PORT);// 主机字节序转网络字节序if(bind(server_fd,(structsockaddr*)&server_addr,sizeof(server_addr))<0){perror("bind");exit(1);}// 3. 监听if(listen(server_fd,BACKLOG)<0){perror("listen");exit(1);}printf("TCP 服务端启动,监听端口 %d...\n",PORT);// 4. 接受客户端连接client_fd=accept(server_fd,(structsockaddr*)&client_addr,&addr_len);if(client_fd<0){perror("accept");exit(1);}printf("客户端已连接: %s:%d\n",inet_ntoa(client_addr.sin_addr),ntohs(client_addr.sin_port));// 5. 收发数据while(1){memset(buf,0,sizeof(buf));ssize_tn=read(client_fd,buf,sizeof(buf)-1);if(n<=0)break;printf("[收到] %s\n",buf);// 回显给客户端charreply[1024];snprintf(reply,sizeof(reply),"服务端已收到: %s",buf);write(client_fd,reply,strlen(reply));}printf("客户端断开\n");close(client_fd);close(server_fd);return0;}4.2 TCP 客户端
#include<stdio.h>#include<stdlib.h>#include<string.h>#include<unistd.h>#include<arpa/inet.h>#defineSERVER_IP"127.0.0.1"#definePORT8888intmain(void){intsock_fd;structsockaddr_inserver_addr;charbuf[1024];// 1. 创建 socketsock_fd=socket(AF_INET,SOCK_STREAM,0);// 2. 连接服务器memset(&server_addr,0,sizeof(server_addr));server_addr.sin_family=AF_INET;server_addr.sin_port=htons(PORT);inet_pton(AF_INET,SERVER_IP,&server_addr.sin_addr);if(connect(sock_fd,(structsockaddr*)&server_addr,sizeof(server_addr))<0){perror("connect");exit(1);}printf("已连接到服务器\n");// 3. 发送数据constchar*msg="温度=26.5°C, 湿度=65%";write(sock_fd,msg,strlen(msg));// 4. 接收回复memset(buf,0,sizeof(buf));read(sock_fd,buf,sizeof(buf)-1);printf("[服务器回复] %s\n",buf);close(sock_fd);return0;}4.3 编译运行
# 终端1:启动服务端gcc-otcp_server tcp_server.c&&./tcp_server# 终端2:启动客户端gcc-otcp_client tcp_client.c&&./tcp_client新手常见错误:
bind: Address already in use:上一个服务端没正常退出,端口被占用。解决方案:加上SO_REUSEADDR选项connect: Connection refused:服务端没启动,或防火墙拦截了端口- 字节序搞反:IP 地址用
inet_pton/inet_ntop,端口用htons/ntohs
五、UDP 通信实战
UDP 不需要listen/accept/connect(当然也可以 connect 来固定对端),服务端和客户端的区别很模糊。
UDP 收发端(通用)
#include<stdio.h>#include<stdlib.h>#include<string.h>#include<unistd.h>#include<arpa/inet.h>#definePORT9999intmain(void){intsock_fd;structsockaddr_inlocal_addr,peer_addr;socklen_taddr_len=sizeof(peer_addr);charbuf[1024];sock_fd=socket(AF_INET,SOCK_DGRAM,0);memset(&local_addr,0,sizeof(local_addr));local_addr.sin_family=AF_INET;local_addr.sin_addr.s_addr=INADDR_ANY;local_addr.sin_port=htons(PORT);bind(sock_fd,(structsockaddr*)&local_addr,sizeof(local_addr));printf("UDP 端口 %d 就绪,等待数据...\n",PORT);while(1){memset(buf,0,sizeof(buf));ssize_tn=recvfrom(sock_fd,buf,sizeof(buf)-1,0,(structsockaddr*)&peer_addr,&addr_len);if(n>0){printf("[来自 %s:%d] %s\n",inet_ntoa(peer_addr.sin_addr),ntohs(peer_addr.sin_port),buf);// 回复constchar*reply="ACK";sendto(sock_fd,reply,strlen(reply),0,(structsockaddr*)&peer_addr,addr_len);}}close(sock_fd);return0;}UDP 客户端几乎一样,只是把recvfrom换成主动sendto:
constchar*msg="传感器心跳: OK";sendto(sock_fd,msg,strlen(msg),0,(structsockaddr*)&server_addr,sizeof(server_addr));六、字节序问题:必须绕开的坑
网络字节序是大端(Big Endian),而 x86 和 ARM 处理器通常是小端(Little Endian)。如果直接传原始数据的多字节字段(比如uint32_t),接收端会得到"镜像"数据。
// 错误示范:直接发 uint32_tuint32_tval=0x12345678;write(fd,&val,sizeof(val));// 小端机器上先发 0x78 0x56 0x34 0x12// 正确做法:转换后再发uint32_tnet_val=htonl(val);// → 大端write(fd,&net_val,sizeof(net_val));转换函数速查:
| 函数 | 含义 |
|---|---|
| htons | Host to Network Short(16位) |
| htonl | Host to Network Long(32位) |
| ntohs | Network to Host Short |
| ntohl | Network to Host Long |
七、总结
Socket 是 Linux 网络编程的基础,核心记住这几条:
- Socket 也是文件描述符——可读可写可关闭
- TCP 三步走:socket→bind→listen→accept,数据用 read/write
- UDP 两步走:socket→bind,数据用 sendto/recvfrom
- 字节序转换:
htons/htonl送出去,ntohs/ntohl接回来 - 端口复用:
SO_REUSEADDR是服务端标配选项
掌握了这套基础,后面搞高并发服务端(epoll)才算有了坚实的底子。