1.网络通信概述
网络通信:两台或多台设备,按约定好的规则,把数据从一方送到另一方。
1.为什么要分层
网线物理层,电压,数据分包,怎么处理丢包是网页还是视频等,一次性想清楚太难捕捉和处理。
所以网络按层拆开,每一层只管自己的事,上层用下层提供的服务。
常见两套模型:
| 模型 | 层数 | 谁在用 |
|---|---|---|
| OSI 参考模型 | 7 层 | 教学、理解概念 |
| TCP/IP 模型 | 4 层 | 实际互联网、工程里常说 |
1 应用层 ┐
2 表示层 ├──→ TCP/IP [应用层] (HTTP、MQTT、DNS...)
3 会话层 ┘
4 传输层 ──→ TCP/IP [传输层] (TCP、UDP)
5 网络层 ──→ TCP/IP [网络层] (IP)
6 数据链路层 ┐
7 物理层 ┴──→ TCP/IP [网络接口层] (以太网、Wi-Fi)

2.各层在什么
物理层
- 干什么:比特 0/1 怎么在介质上传——电平、光、无线电。
- 例子:网线里的电压、Wi-Fi 射频、RS485 差分信号(严格说 RS485 常归在「物理/链路」交界,入门可先当「线缆层」理解)。
数据链路层
- 干什么:同一局域网里,设备之间怎么组帧、怎么寻址(MAC 地址)、简单差错检测。
- 例子:以太网帧、Wi-Fi 数据链路、交换机按 MAC 转发。
MAC 地址:网卡出厂带的 48 位地址,形如AA:BB:CC:DD:EE:FF,局域网内标识「哪块网卡」。
网络层
- 干什么:跨网段、跨路由器,把包送到「目标 IP」。
- 核心:IP 地址(如
192.168.1.10)、路由(下一跳往哪走)。 - 协议:主要是 IPv4 / IPv6。
IP 只负责「尽量送到」,不保证一定送到、不保证顺序(那是上层的事)。
传输层
- 干什么:在两台主机的 进程/端口 之间传数据。
- 两个最重要的协议:
| 协议 | 特点 | 典型用途 |
|---|---|---|
| TCP | 面向连接、可靠、有序、有流量控制 | 网页 HTTP、文件、邮件 |
| UDP | 无连接、不保证可靠、开销小、可广播 | 视频流、DNS 查询、物联网上报、游戏 |
端口:一台电脑有很多网络程序,用端口号区分,如 80(HTTP)、443(HTTPS)、1883(MQTT)。
通信常写成:192.168.1.10:8080 → IP 找机器,端口找程序。
应用层
- 干什么:具体业务——网页、邮件、文件、物联网消息格式。
- 例子:HTTP/HTTPS、FTP、SMTP、MQTT、CoAP、DNS。
程序员日常打交道最多的是这一层 + 传输层(调 API 发 TCP/UDP 或 HTTP)。
3.一次通信怎么走
在浏览器输入http://192.168.1.1:
- 应用层:浏览器按HTTP组请求报文(GET / …)。
- 传输层:用TCP,连接对方80端口,保证数据可靠到达
- 网络层:包上写上源IP,目标IP,
192.168.1.1,交给路由。 - 链路层:封装成以太网帧,目标MAC可能是网关或目标设备。
- 物理层:网口/Wi-Fi发比特流。
对方设备层层拆包,HTTP服务器返回网页,再沿原路回来。
简化图:
1 [浏览器 HTTP]应用层
2 ↓
3 [TCP 端口 80]传输层
4 ↓
5 [IP 192.168.1.x -> 192.168.1.1]网络层
6 ↓
7 [以太网帧 + MAC]链路层
8 ↓
9 [网线 / Wi-Fi]物理层
4. 几个必懂的概念
IP 地址与子网
- IPv4:32 位,常写成四段,如
192.168.1.100。 - 子网掩码:如
255.255.255.0,用来划分「哪些位是网络号、哪些是主机号」。 - 同一子网:如
192.168.1.0/24里,192.168.1.2和192.168.1.100可直接二层通信;不同网段要经过网关(路由器)。
网关、DNS
- 默认网关:本机访问「外网或其他网段」时,先把包发给网关(通常是路由器
192.168.1.1)。 - DNS:把域名
www.example.com解析成 IP,否则你只能记 IP 访问。
MAC 与 IP 的分工
- IP:端到端逻辑地址,路由用。
- MAC:局域网一跳一跳的硬件地址。
- 局域网内常通过 ARP:「这个 IP 对应哪个 MAC?」
客户端与服务器
- 服务器:一直监听某端口,等服务(如 Web 服务器听 80)。
- 客户端:主动发起连接(浏览器、手机 App、单片机上报)。
也有 P2P、设备互连,但入门先掌握 C/S 即可。
带宽、延迟、丢包
- 带宽:单位时间能传多少数据(Mbps)。
- 延迟(RTT):包往返时间,影响「点一下要等多久」。
- 丢包:无线、拥塞时会发生;TCP 会重传,UDP 要自己处理或容忍。
5. TCP 与 UDP 再对比(入门重点)
TCP:像打电话
- 三次握手建立连接
- 双方确认收到(可靠)
- 四次挥手断开连接
适合:不能丢,要按序的数据(网页,下载,多数API)。
代价:连接开销大,速度慢于UDP一点点。
UDP:像寄明信片
- 不先[建立连接],发出去就完事
- 可能丢,可能乱序
适合:实时性要求高,能容忍丢包,或小数据很小很频繁(传感器每秒一条,语音/数据常配合应用层处理)
怎么选(简单原则)
| 场景 | 倾向 |
|---|---|
| 网页、REST API、文件 | TCP(上常跑 HTTP) |
| 物联网周期上报、局域网发现 | UDP 或 MQTT(MQTT 底层常用 TCP) |
| 视频直播、语音 | 常 UDP + 专用协议 |
6. 常见应用层协议
| 协议 | 作用 | 传输层常用 |
|---|---|---|
| HTTP/HTTPS | 网页、REST 接口 | TCP |
| MQTT | 物联网发布/订阅、省流量 | TCP |
| CoAP | 轻量物联网,类似简化 HTTP | UDP |
| DNS | 域名解析 | UDP/TCP |
| DHCP | 自动分配 IP | UDP |
| FTP | 文件传输 | TCP |
| WebSocket | 浏览器长连接、实时推送 | TCP |
HTTPS = HTTP + TLS 加密,端口 443。
7.嵌入式相关网络的一点点延伸
做MCU/工控时,经常碰到:
- 以太网 +
lwIP:在单片机里实现 TCP/IP协议栈,再跑HTTP/MQTT客户端。 - Wi-Fi / 4G 模块:模块内部已做好协议栈,MCU用AT指令 或 串口透传 发数据,相当于模块帮你扛网络层以上。
- RS485 / CAN:多是 现场总线,不算总线互联网 TCP/IP,但在[设备联网]课程里常和以太网对比——距离近,协议简单,无IP
- Modbus TCP:把Modbus 跑在以太网TCP上,端口常502。
入门路径可以是:先懂IP,端口,TCP/UDP——>再学 Socket 编程或MQTT——再在板子上用模块或lwIP实践。
8.局域网 vs 广域网
| 类型 | 范围 | 例子 |
|---|---|---|
| LAN 局域网 | 家里、办公室、车间 | 192.168.x.x、10.x.x.x |
| WAN 广域网 | 跨城市、互联网 | 公网 IP、运营商网络 |
家里路由器:对内做 DHCP 分 192.168.1.x,对外一个公网 IP(NAT),多台设备共享上网。
NAT:内网多台设备共用一个公网 IP 出去,外部要主动连进来较麻烦(要做端口映射或云中转)。
2.TCP协议详解
TCP(Transmission Control Protocol,传输控制协议)工作在 传输层,和 IP 配合使用:
IP负责把包送到目标主机,TCP负责在 两台主机的两个端口之间,提供可靠,有序,按字节流的数据传输。
可以理解为:先建立一条逻辑上的“管道”,数据从一端写进去,另一端按顺序读出来,丢了重传,满了会等一等。

1.TCP解决什么问题
网络层IP的特点:
- 不保证一定能送到
- 不保证按发送顺序到达
- 不保证不重复
对网页,文件下载,API接口来说,这不够可信。TCP补上:
| 能力 | 说明 |
|---|---|
| 可靠 | 丢了重传,直到对方确认收到 |
| 有序 | 每个字节有编号,接收端按序交给应用 |
| 面向连接 | 传数据前先建立连接,传完再断开 |
| 流量控制 | 发太快时,接收方会让发送方慢下来 |
| 拥塞控制 | 网络堵了时,主动减小发送速度 |
代价是:比 UDP 慢一些、实现更复杂、占更多内存(要维护连接状态)。
2. TCP 和 UDP 对比
| 项目 | TCP | UDP |
|---|---|---|
| 连接 | 要先建立连接 | 无连接,发完即走 |
| 可靠性 | 可靠 | 不保证 |
| 顺序 | 保证 | 不保证 |
| 首部开销 | 一般 20 字节起 | 8 字节 |
| 速度 | 相对慢 | 相对快 |
| 典型用途 | HTTP、HTTPS、邮件、MQTT、数据库 | DNS 查询、视频、语音、简单传感器上报 |
选型:数据不能丢、要完整顺序 → TCP;能容忍丢包、要实时或极简 → UDP。
3. 端口:TCP 怎么找到“哪个程序”
IP 地址定位 哪台设备,端口号(16 位,0~65535)定位 哪条连接/哪个应用。
- 知名端口(0~1023):如 80(HTTP)、443(HTTPS)、22(SSH)
- 注册端口(1024~49151)
- 动态端口(49152~65535):客户端临时分配用
一条 TCP 连接由 四元组 唯一确定:
(源 IP, 源端口, 目标 IP, 目标端口)
例如:手机 192.168.1.100:52341 访问服务器 93.184.216.34:443。
- 源 IP:
192.168.1.100 - 源端口:
52341 - 目标 IP:
93.184.216.34(服务器的 IP) - 目标端口:
443(HTTPS 服务的默认端口)
4. TCP 报文段 (Segment) 长什么样
应用层交给TCP的是字节流;TCP切成一块块 段发出去。每段前面有一个TCP首部,后面是 数据。
4.1 首部主要字段(必记)
1 0 1 2 3
2 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
3 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
4 | 源端口 (16) | 目的端口 (16) |
5 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
6 | 序号 Seq (32) |
7 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
8 | 确认号 Ack (32) |
9 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
10 | 数据偏移 | 保留 | 标志位 | 窗口大小 Window (16) |
11 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
12 | 校验和 | 紧急指针 | 选项 (可选) |
13 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
14 | 数据 |
15 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 字段 | 作用 |
|---|---|
| 源/目的端口 | 标识两端应用 |
| 序号 Seq | 本段数据在字节流中的位置(第几个字节开始) |
| 确认号 Ack | 期望对方下一个字节的序号(表示此前都已收到) |
| 标志位 | SYN、ACK、FIN、RST、PSH等,控制连接状态 |
| 窗口 Window | 流量控制:接收方还能收多少字节 |
| 校验和(16位) | 检测首部+数据是否出错 |
| 紧急指针(16位) | 指示报文段中“紧急数据”的结束位置(或范围) |
TCP 首部通常 20 字节;带选项时更长。IP 层再包一层 IP 首部,下面是以太网帧等。
4.2 常见标志位
| 标志 | 含义 |
|---|---|
| SYN | 建立连接用,同步序号(TCP 规定:SYN 和 FIN 标志位必须各消耗一个序列号) |
| ACK | 确认号有效 |
| FIN | 发送方不再发数据,要关闭连接 |
| RST | 复位,强制断开或拒绝连接 |
| PSH | 尽快交给应用层(如 telnet 按键) |
| URG | 紧急数据(很少用) |
5. 序号与确认:可靠性的核心
序号 (Sequence Number)
TCP 把应用数据看成连续的字节,每个字节一个编号。
- 建立连接时,双方各选一个 初始序号 ISN(随机,防伪造)。
- 每发一段数据,序号增加这段的长度。
例:初始序号 1000,发 100 字节,则本段 Seq=1000,下一段 Seq=1100。
确认号 (Acknowledgement Number)
接收方回 ACK:“我已经按序收到 Ack-1 之前的所有字节,请从 Ack 开始发”。
- ACK 段可以 不带数据,只带确认号。
- 累积确认:Ack=n 表示 n 之前的都收到了,减少 ACK 数量。
超时重传
发出去一段时间内没收到 ACK,认为丢失,重传同一段(或重传该序号起的字节,实现细节与算法有关)。
接收方收到重复段会 丢弃但可再发 ACK,帮助对方确认。
乱序与重复
- 乱序:先到的段可先缓存,等前面段到了再拼成有序流交给应用。
- 重复:靠序号识别,重复则丢弃
6.三次握手:建立连接
客户端要主动访问服务器,需先完成三次握手。(3-way handshake):

- 第1次:客户端发 SYN=1,序号 Seq=x(x为客户端ISN)
- 第2次:服务器回 SYN=1,ACK=1,Seq=y,Ack=x+1(同意连接,自己的ISN是y)
- 第3次:客户端发 ACK=1,Ack=y+1,此后双方都认为连接建立。
为什么是三次。不是两次? - 两次无法让服务器确认:客户端确实收到自己的 SYN-ACK(避免历史重复SYN导致服务器单方面认为连接已建立)
- 三次握手同时 协商双方的初始序号。
7.四次挥手:关闭连接
TCP是全双工:两个方向各有一条字节流,关闭时 每个方向都要单独关,所以通常要 四次挥手:

第1次:主动关闭方发 FIN:“我这边没有数据要发了”
第2次:对方 ACK:“知道了。”此时对方仍可以发数据(半关闭)
第3次:对方也发 FIN:“我这边也不发了”
第4次:主动关闭方 ACK,然后进入 TIME_WAIT (通常等待2MSL,几十秒到几分钟),防止最后的ACK丢失导致对方重发FIN时无法正确响应。
TIME_WAIT原因:
- 原因一:防止最后的 ACK 丢失(为了保证对方能正常关闭)
假设主动方发的第4次 ACK 在网络中丢了。对方没收到确认,就会触发超时重传,再次发送第3次的 FIN。
如果主动方发完 ACK 就立刻关闭跑路了,对方重传的 FIN 过来时,主动方已经不存在了,就会回一个 RST(强制复位)错误。
所以,主动方进入TIME_WAIT等一会儿,如果对方重传了 FIN,主动方还能再回一个 ACK。这就保证了“接完对方可能重发的 FIN 并回复”。 - 原因二:防止“历史幽灵包”污染新连接
等待 2MSL 的时间,足以让本次连接产生的所有数据包在网络中彻底“死掉”。这样下次再用同样的端口建立新连接时,绝对不会收到上一次连接的残渣。
为什么是四次?
因为TCP允许一端先关发送,另一端继续发(半关闭),FIN 和 ACK往往分开发,所以比握手多一次。
同时关闭:双方都发FIN,可能合并成类似三次的包,逻辑仍是两边都关闭。
8.连接状态(看懂netstat)
TCP 用状态机管理连接,常见状态:
| 状态 | 含义 |
|---|---|
| CLOSED | 无连接 |
| LISTEN | 服务器监听端口,等连接 |
| SYN_SENT | 客户端已发 SYN,等回复 |
| SYN_RCVD | 服务器收到 SYN,已回 SYN+ACK |
| ESTABLISHED | 连接已建立,可传数据 |
| FIN_WAIT_1 / FIN_WAIT_2 | 主动关闭,等对方 FIN 或 ACK |
| CLOSE_WAIT | 收到对方 FIN,应用还未 close |
| LAST_ACK | 被动关闭方发完 FIN,等最后 ACK |
| TIME_WAIT | 主动关闭方发完最后 ACK,等待一段时间 |
| CLOSED | 完全结束 |
开发常见问题:
- 服务器大量 CLOSE_WAIT:应用收到连接关闭后 没有调用 close,要查代码是否漏关 socket。
- 主动关闭方大量 TIME_WAIT:短连接太多,可考虑复用连接、调参或让服务器主动关(视场景而定)。
9. 流量控制(滑动窗口)
接收方缓冲区有限,不能无限制收。TCP 首部 窗口字段 告诉对方:我还能接收多少字节。
- 发送方维护 发送窗口:未确认且未超窗口的数据才能发。
- 接收方处理慢 → 窗口变小 → 发送方减速。
- 窗口为 0 时,发送方暂停;接收方后来用 窗口更新 通知可以再发。
这叫 滑动窗口,既保证可靠,又尽量把链路跑满。
零窗口与死锁:窗口长期为 0 时,用 持续计时器 定期发探测段,避免永远等不到窗口更新。
10. 拥塞控制(简要)
流量控制解决的是“接收方吃不下”;拥塞控制解决的是“网络中间堵了”。
发送方维护 拥塞窗口 cwnd,实际发送量 ≈ min(拥塞窗口, 对方通告窗口)。
经典阶段(了解概念即可,现代实现会更复杂):
- 慢启动:cwnd 从小指数增长,试探网络容量。
- 拥塞避免:快到阈值后线性增长。
- 快重传 / 快恢复:收到 3 个重复 ACK,认为丢包,快速重传并调整 cwnd,不必总是超时后才重传。
你看到下载速度时快时慢,partly 就是拥塞控制在调节。
11. TCP 对应用层呈现什么
应用调用 socket API 时,看到的是 无消息边界的字节流:
- 发 100 字节 + 200 字节,对方可能一次 read 收到 300 字节,也可能分多次收到。
- 若需要“一条一条消息”,要在应用层自己加长度头、分隔符或用专门协议(如 HTTP Content-Length 固定头)。
粘包(一次接完分不清哪个是哪个) / 拆包(包较长几次接):TCP 本身不保留“包”的边界,这是初学者最常踩的坑。
12. 常见应用与端口
| 应用 | 端口 | 说明 |
|---|---|---|
| HTTP | 80 | 网页(明文) |
| HTTPS | 443 | HTTP + TLS 加密 |
| SSH | 22 | 远程登录 |
| FTP 控制 | 21 | 文件(数据通道另说) |
| SMTP | 25 | 发邮件 |
| MQTT | 1883 | 物联网,基于 TCP |
| MySQL | 3306 | 数据库 |
浏览器访问 https://example.com 底层就是:DNS 查 IP → TCP 连 443 → TLS 握手 → HTTP 请求/响应
13. 编程时你要会的几件事
服务器典型流程
socket() —> bind(端口)—>listen()—> accept() 得到新连接—>read()/write()与客户端通信—>close()
socket()- 作用:创建套接字(买电话机)。
- 状态:
CLOSED(无连接)。
bind(端口)- 作用:绑定IP和端口。
- 状态:
CLOSED。
listen()- 作用:开启监听,等待客户端连接。
- 状态:
LISTEN(服务器监听端口,等连接)。
accept()- 作用:从已完成握手的队列中取出一个连接,返回一个新的Socket用于通信。(底层三次握手已由内核自动完成)。
- 状态:原Socket保持
LISTEN;新Socket进入ESTABLISHED(连接已建立,可传数据)。
read()/write()- 作用:读写(收发)字节流数据。
- 状态:
ESTABLISHED。
close()- 作用:关闭连接。
- 状态:若主动关闭,进入
FIN_WAIT_1->FIN_WAIT_2->TIME_WAIT->CLOSED。(若是收到对方FIN后被动关闭,会先进入CLOSE_WAIT,调用close后进入LAST_ACK)。
客户端典型流程
socket() —> connect(IP,端口)—>read()/write()—>close()
socket()- 作用:创建套接字。
- 状态:
CLOSED。
connect(IP,端口)- 作用:发起连接(底层直接触发三次握手)。
- 状态:发送SYN后进入
SYN_SENT;握手成功后进入ESTABLISHED。
read()/write()- 作用:读写数据。
- 状态:
ESTABLISHED。
close()- 作用:关闭连接(通常由客户端主动发起四次挥手)。
- 状态:
FIN_WAIT_1->FIN_WAIT_2->TIME_WAIT(等待2MSL) ->CLOSED。
注意点
- 每个 accept 的连接要单独 close,否则 CLOSE_WAIT 堆积。
- 短连接 会产生大量 TIME_WAIT,高并发时要考虑连接池、长连接。
- 阻塞 / 非阻塞 / 多路复用 (select、poll、epoll) 决定单线程能扛多少连接。
- 嵌入式用 lwIP 时,同样有 TCP 状态机,RAM 要留给每连接的发送/接收缓冲。
14. TCP 与 IP 的关系 (串起来)
应用数据
→ TCP 加首部 (端口、序号、ACK、窗口…) 变成 TCP 段
→ IP 加首部 (源/目标 IP) 变成 IP 包
→ 以太网加 MAC 首部变成帧
→ 物理线发送
对端逐层剥开,TCP 按序号拼回字节流,交给应用。
15. 小结
| 要点 | 一句话 |
|---|---|
| 定位 | 传输层,端口到端口,可靠字节流 |
| 连接 | 三次握手建连,四次挥手断连 |
| 可靠 | 序号 + 累积 ACK + 超时重传 |
| 流控 | 接收窗口,滑动窗口 |
| 拥塞 | 拥塞窗口,慢启动、快重传等 |
| 应用 | 不保留消息边界,要自己处理粘包 |
把三次握手、序号/ACK、四次挥手、窗口 四块吃透,再看 Wireshark 抓包(过滤 tcp ),对照 Seq/Ack 标志位,会比纯背概念快很多。
3.TCP Socket编程
1. Socket 是什么
Socket (套接字) 是应用层和传输层之间的接口,在 Windows 上通常指 Winsock(Windows Sockets)提供的个“句柄”。
可以这样理解:
你的程序 ↔ socket API ↔ 系统 TCP 协议栈 ↔ 网卡
- 服务器:在本机某个端口上
listen,等别人连进来,每连上一个客户accept得到一个新的 socket 专门和该客户通信。 - 客户端:主动
connect到对方的 IP + 端口。
一条 TCP 连接 = 本地的 (IP, 端口) 和远端的 (IP, 端口) 四元组唯一确定。
2.Windows上的Socket:Winsock
在Linux上,Socket返回的是一个文件描述符(int);在Windows上,Winsock返回的是 SOCKET 类型 (本质 UINT_PTR,可理解为一个“网络句柄”)。
使用前必须先初始化 Winsock,用完后再清理。
1 #include <winsock2.h>
2 #include <ws2tcpip.h> // getaddrinfo 等
3 #pragma comment(lib, "ws2_32.lib") // MSVC 自动链接; MinGW 需在编译参数里加 -lws2_32
4 WSADATA wsa;
5 if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) {
6 printf("WSAStartup failed: %d\n", WSAGetLastError());
7 return 1;
8 }
9 // ... 所有 socket 操作 ...
10 WSACleanup();
要点:
| 概念 | 说明 |
|---|---|
| WSAStartup | 加载 ws2_32.dll,协商 Winsock 版本 |
| WSACleanup | 与 WSAStartup 成对调用 |
| SOCKET | Windows 专用类型,不要和 Linux 的 int fd 混用 |
| INVALID_SOCKET | 创建失败时的返回值(不是 -1) |
| closesocket() | 关闭 socket(不是 close()) |
| WSAGetLastError() | 获取最后一次 Winsock 错误码 |
3. 地址结构: sockaddr_in
IPv4 通信最常用 sockaddr_in (“in” = internet):
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET; // IPv4
addr.sin_port = htons(8080); // 端口:必须网络字节序
addr.sin_addr.s_addr = INADDR_ANY; // 监听本机所有网卡;客户端填对方 IP
几个容易踩坑的点:
- 端口要用
htons()(host to network short)。大端/小端不同机器上端口字节序必须统一。 INADDR_ANY:服务器"监听所有本地 IP";客户端要填目标 IP。- 字符串 IP(如
"127.0.0.1")用inet_pton()转换,比老式inet_addr()更安全:
inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
bind/connect的参数类型是通用的struct sockaddr *,所以要强转:
bind(sock, (struct sockaddr*)&addr, sizeof(addr));
4. Winsock 初始化 API
4.1 WSAStartup
加载 Winsock 库,任何 socket 操作之前必须先调用。
int WSAStartup(
WORD wVersionRequested, // 请求的版本,通常 MAKEWORD(2, 2)
LPWSADATA lpWSAData // 输出:实际协商到的版本信息
);
| 返回值 | 含义 |
|---|---|
| 0 | 成功 |
| 非 0 | 失败,可配合 WSAGetLastError() 查原因 |
WSADATA wsa;
int ret = WSAStartup(MAKEWORD(2, 2), &wsa);
if (ret != 0) {
printf("WSAStartup failed: %d\n", ret);
return 1;
}
4.2 WSACleanup
与 WSAStartup 成对,进程结束前调用,释放 Winsock 资源。
int WSACleanup(void);
| 返回值 | 含义 |
|---|---|
| 0 | 成功 |
| SOCKET_ERROR | 失败 |
4.3 WSAGetLastError
获取最近一次 Winsock 调用的错误码(类似 Linux 的 errno)。
int WSAGetLastError(void);
常见错误码(后面排错会反复见到):
| 错误码 | 宏名 | 常见原因 |
|---|---|---|
| 10048 | WSAEADDRINUSE | 端口已被占用 |
| 10061 | WSAECONNREFUSED | 对方没监听 / 防火墙拦截 |
| 10054 | WSAECONNRESET | 对端异常断开 |
| 10060 | WSAETIMEDOUT | 连接超时 |
| 10035 | WSAEWOULDBLOCK | 非阻塞模式下暂时无数据 |
if (bind(sock, (struct sockaddr*)&addr, sizeof(addr)) == SOCKET_ERROR) {
printf("bind failed: %d\n", WSAGetLastError());
}
5.Socket生命周期API
5.1 socket — 创建套接字
SOCKET socket(
int af, // 地址族: AF_INET(IPv4) / AF_INET6(IPv6)
int type, // 类型: SOCK_STREAM(TCP) / SOCK_DGRAM(UDP)
int protocol // 协议: TCP 填 IPPROTO_TCP,可填 0 让系统自动选
);
| 返回值 | 含义 |
|---|---|
| 有效 SOCKET | 成功 |
| INVALID_SOCKET | 失败,用 WSAGetLastError() 查原因 |
// 创建一个 IPv4 + TCP 的 socket
SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (sock == INVALID_SOCKET) {
printf("socket failed: %d\n", WSAGetLastError());
}
5.2 bind — 绑定本机地址 (服务器必用)
把 socket 和本机的 (IP, 端口) 绑在一起。
只有服务器需要 bind;客户端一般由系统在 connect 时自动分配本地端口。
int bind(
SOCKET s,
const struct sockaddr *name, // 指向 sockaddr_in 等地址结构
int namelen // 结构体大小
);
| 返回值 | 含义 |
|---|---|
| 0 | 成功 |
| SOCKET_ERROR | 失败 |
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);
addr.sin_addr.s_addr = INADDR_ANY; // 0.0.0.0,监听所有网卡
bind(sock, (struct sockaddr*)&addr, sizeof(addr));
5.3 listen — 进入监听状态 (服务器必用)
声明此 socket 用于接受连接,内核开始维护“已完成三次握手的连接队列”。
int listen(
SOCKET s,
int backlog // 等待 accept 的连接队列最大长度
);
| 返回值 | 含义 |
|---|---|
| 0 | 成功 |
| SOCKET_ERROR | 失败 |
listen(sock, SOMAXCONN); // SOMAXCONN 让系统选一个合理上限
// 或
listen(sock, 5);
backlog 不是“最多 5 个客户端在线”,而是已完成握手、还没被 accept 取走的连接最多排队几个。
5.4 accept — 接受一个客户端连接 (服务器)
从 listen 的队列里取出一个已建立的 TCP 连接,返回新的 socket,专门和该客户端通信。
SOCKET accept(
SOCKET s,
struct sockaddr *addr, // 输出:客户端地址,可传 NULL
int *addrlen // 输入/输出:地址结构长度
);
| 返回值 | 含义 |
|---|---|
| 新的 SOCKET | 成功,用于和该客户端 send/recv |
| INVALID_SOCKET | 失败 |
struct sockaddr_in client_addr;
int len = sizeof(client_addr);
SOCKET client_sock = accept(listen_sock,
(struct sockaddr*)&client_addr,
&len);
char ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &client_addr.sin_addr, ip, sizeof(ip));
printf("Client: %s:%d\n", ip, ntohs(client_addr.sin_port));
要点:
listen_sock:只负责接客,一般不拿收发业务数据。client_sock:和某个客户端的专用通道,有几个客户端就可能 accept 出几个。
5.5 connect — 主动连接服务器 (客户端)
向指定 (IP, 端口) 发起 TCP 三次握手,成功返回时连接已建立。
int connect(
SOCKET s,
const struct sockaddr *name,
int namelen
);
| 返回值 | 含义 |
|---|---|
| 0 | 成功 |
| SOCKET_ERROR | 失败 |
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(8080);
inet_pton(AF_INET, "192.168.1.100", &server.sin_addr);
if (connect(sock, (struct sockaddr*)&server, sizeof(server)) == SOCKET_ERROR) {
printf("connect failed: %d\n", WSAGetLastError());
}
5.6 closesocket — 关闭并释放 socket
int closesocket(SOCKET s);
| 返回值 | 含义 |
|---|---|
| 0 | 成功 |
| SOCKET_ERROR | 失败 |
Windows 用 closesocket(),不是 Linux 的 close()。
closesocket(client_sock);
closesocket(listen_sock);
5.7 shutdown — 半关闭连接
只关闭连接的某一方向,比直接 closesocket 更“礼貌”。
int shutdown(
SOCKET s,
int how // SD_RECEIVE / SD_SEND / SD_BOTH
);
| how | 含义 |
|---|---|
| SD_RECEIVE | 不再接收 (对端再发会收到 RST 等) |
| SD_SEND | 不再发送 (会发 FIN,通知对端“我发完了”) |
| SD_BOTH | 双向都关 |
shutdown(sock, SD_SEND); // 告诉对方:我的数据发完了
// 还可以继续 recv 读对方剩余数据
closesocket(sock);
6. 数据收发 API
6.1 send — 发送数据 (TCP)
int send(
SOCKET s,
const char *buf,
int len,
int flags // 通常填 0
);
| 返回值 | 含义 |
|---|---|
| > 0 | 本次实际发送的字节数 (可能小于 len) |
| 0 | 一般不会在 TCP 上返回 0 |
| SOCKET_ERROR | 失败 |
const char *msg = "Hello";
int n = send(sock, msg, (int)strlen(msg), 0);
if (n == SOCKET_ERROR) {
printf("send failed: %d\n", WSAGetLastError());
}
6.2 recv — 接收数据 (TCP)
int recv(
SOCKET s,
char *buf,
int len,
int flags // 通常填 0
);
| 返回值 | 含义 |
|---|---|
| > 0 | 本次实际收到的字节数 |
| 0 | 对端已正常关闭连接 (收到 FIN) |
| SOCKET_ERROR | 失败 |
char buf[1024];
int n = recv(sock, buf, sizeof(buf), 0);
if (n > 0) {
// 收到 n 字节,TCP 不保证一次收齐一条“消息”
} else if (n == 0) {
printf("Peer closed connection\n");
以下是图片中提取的文字和代码内容,已按原图结构排版:
6.3 send / recv 的 flags (了解即可)
| 宏 | 作用 |
|---|---|
| 0 | 默认阻塞收发 |
| MSG_PEEK | 只读取缓冲区数据,不从队列移除 |
| MSG_DONTROUTE | 不查路由表 (很少用) |
入门阶段 flags 一律填 0 即可。
7. 地址与字节序 API
7.1 字节序转换
网络传输统一用大端 (网络字节序),本机可能是小端,所以端口/IP 要转换。
u_short htons(u_short hostshort); // host to network short (端口)
u_short ntohs(u_short netshort); // network to host short
u_long htonl(u_long hostlong); // host to network long (IPv4 地址)
u_long ntohl(u_long netlong); // network to host long
addr.sin_port = htons(8080); // 端口: 主机 -> 网络
printf("%u\n", ntohs(addr.sin_port)); // 打印时: 网络 -> 主机
7.2 IP 字符串 <-> 二进制
inet_pton (推荐,字符串 -> 二进制)
int inet_pton(
int af, // AF_INET 或 AF_INET6
const char *src, // 如 "192.168.1.1"
void *dst // 输出到 sin_addr 等
);
| 返回值 | 含义 |
|---|---|
| 1 | 转换成功 |
| 0 | 字符串格式无效 |
| -1 | 出错 |
struct sockaddr_in addr;
inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
inet_ntop (推荐,二进制 -> 字符串)
const char *inet_ntop(
int af,
const void *src,
char *dst,
socklen_t size
);
char ip_str[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &client_addr.sin_addr, ip_str, sizeof(ip_str));
老式 API (了解,不推荐新项目用)
unsigned long inet_addr(const char *cp); // "127.0.0.1" -> sin_addr.s_addr
char *inet_ntoa(struct in_addr in); // 静态缓冲区,非线程安全
特殊地址常量
INADDR_ANY; // 0.0.0.0,绑定所有本地网卡
INADDR_LOOPBACK; // 127.0.0.1
8. 地址结构体 (和 API 配套理解)
8.1 sockaddr_in (IPv4 最常用)
struct sockaddr_in {
short sin_family; // AF_INET
u_short sin_port; // 端口,需 htons
struct in_addr sin_addr; // IPv4 地址
char sin_zero[8];// 填充,填 0
};
调用 bind / connect / accept 时要转成通用类型:
(struct sockaddr*)&addr
8.2 通用 sockaddr
struct sockaddr {
u_short sa_family; // 地址族
char sa_data[14];// 地址数据
};
Winsock 很多函数参数类型是 struct sockaddr *,实际传入的往往是 sockaddr_in,所以需要强制转换。
9. 常用扩展 API
9.1 setsockopt — 设置 socket 选项
开发中最常用:地址重用,避免服务器重启后 WSAEADDRINUSE。
int setsockopt(
SOCKET s,
int level, // SOL_SOCKET
int optname, // SO_REUSEADDR 等
const char *optval,
int optlen
);
BOOL reuse = TRUE;
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR,
(const char*)&reuse, sizeof(reuse));
| 常用选项 | 作用 |
|---|---|
| SO_REUSEADDR | 允许重用本地地址 (开发服务器几乎必开) |
| SO_RCVTIMEO | 接收超时 |
| SO_SNDTIMEO | 发送超时 |
9.2 getsockname / getpeername — 查本端/对端地址
连接建立后,若想知道“实际连的是哪”:
int getsockname(SOCKET s, struct sockaddr *name, int *namelen); // 本端
int getpeername(SOCKET s, struct sockaddr *name, int *namelen); // 对端
struct sockaddr_in local, peer;
int llen = sizeof(local), plen = sizeof(peer);
getsockname(sock, (struct sockaddr*)&local, &llen);
getpeername(sock, (struct sockaddr*)&peer, &plen);
9.3 getaddrinfo / freeaddrinfo — 现代地址解析 (推荐)
比手写 sockaddr_in 更通用,支持域名、IPv4/IPv6。
int getaddrinfo(
const char *nodename, // 主机名或 IP,如 "www.example.com"
const char *servname, // 端口字符串,如 "8080"
const struct addrinfo *hints,
struct addrinfo **result
);
void freeaddrinfo(struct addrinfo *ai);
struct addrinfo hints, *res = NULL;
memset(&hints, 0, sizeof(hints));
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
if (getaddrinfo("127.0.0.1", "8080", &hints, &res) == 0) {
connect(sock, res->ai_addr, (int)res->ai_addrlen);
freeaddrinfo(res);
}
10. API 与角色对照表
| API | 服务器 | 客户端 | 一句话 |
|---|---|---|---|
| WSAStartup | ✓ | ✓ | 初始化 Winsock |
| socket | ✓ | ✓ | 创建套接字 |
| bind | ✓ | — | 绑定本机端口 |
| listen | ✓ | — | 开始监听 |
| accept | ✓ | — | 接受连接,得新 socket |
| connect | — | ✓ | 连接服务器 |
| send/recv | ✓ | ✓ | 收发数据 |
| shutdown | ✓ | ✓ | 半关闭 |
| closesocket | ✓ | ✓ | 释放句柄 |
| WSACleanup | ✓ | ✓ | 清理 Winsock |
4. TCP客户端 服务器代码编写
1. 什么是服务器 (Server)
服务器是网络里被动等待连接、提供服务的一方。
可以把它理解成一家商店:
- 商店有个固定地址 (IP + 端口),比如 192.168.1.10:8080
- 商店开门营业 (
listen),等顾客上门 - 每来一个顾客,店员单独接待 (
accept得到一个专门对话的 socket) - 顾客问什么,店员答什么 (
recv/send)
在程序里,服务器通常做这些事:
- 在本机某个端口上监听
- 接受客户端的连接请求
- 和某个客户端收发数据
- 结束后关闭连接
典型场景:Web服务器,游戏服务器,工控上位机,你的Echo测试程序

2. 什么是客户端 (Client)
客户端是网络里主动发起连接、请求服务的一方。
继续用商店比喻:
- 顾客知道商店的地址(知道服务器的 IP + 端口)
- 顾客主动走过去敲门(connect)
- 门开了之后,顾客和店员对话(send / recv)
- 聊完离开(closesocket)
在程序里,客户端通常做这些事:
- 知道服务器的 IP 和端口
- 主动连接服务器
- 收发数据
- 关闭连接
典型场景:浏览器、手机 App、你的测试客户端、嵌入式设备上报数据。

3. 客户端 vs 服务器:一张表看懂
| 对比项 | 服务器 (Server) | 客户端 (Client) |
|---|---|---|
| 角色 | 提供服务 | 请求服务 |
| 主动性 | 被动等待 | 主动连接 |
| 是否 bind | 必须,绑定固定端口 | 一般不需要,系统自动分配本地端口 |
| 是否 listen | 必须 | 不需要 |
| 建立连接 | accept() 接受 | connect() 发起 |
| 要知道什么 | 自己监听哪个端口 | 对方的 IP + 端口 |
| 数量关系 | 1 个服务器可服务多个客户端 | 多个客户端可连同一服务器 |
记住一句话:
服务器 = 开店等人来;客户端 = 知道地址主动上门。
4.一条TCP连接的完整流程
4.1逻辑流程(从开始到结束)

4.2TCP三次握手(建立连接时系统自动完成)
客户端调用 connect()、服务器调用 accept() 时,操作系统在底层完成三次握手:
客户端 服务器
| |
| ——— SYN (我想连接) ————————> | 第 1 次
| |
| <—— SYN+ACK (好的,我也准备好了) ——| 第 2 次
| |
| ——— ACK (收到,连接建立) —————> | 第 3 次
| |
| 连接进入 ESTABLISHED |
应用层不需要自己发 SYN,只要调用 connect / accept 即可。
4.3 服务器上的两个 socket (非常重要)
很多初学者这里会混,服务器上其实有两种 socket:
listen_sock —— 监听 socket,绑定 8080 端口,只负责“接客”
|
accept()
|
client_sock —— 连接 socket,专门和某一个客户端收发数据
listen_sock:一直存在,负责监听 8080client_sock:每accept一个客户端,就新建一个
类比:前台负责接待(listen_sock),每来一个客人就分配一个专属客服(client_sock)。
4.4 四元组:一条连接如何唯一确定
一条 TCP 连接由四个信息唯一确定:
(源 IP, 源端口, 目标 IP, 目标端口)
例如客户端 192.168.1.5:54321 连服务器 192.168.1.10:8080:
客户端视角:192.168.1.5:54321 -> 192.168.1.10:8080
服务器视角:192.168.1.10:8080 <- 192.168.1.5:54321
所以:
- 服务器只需监听一个端口(如 8080)
- 多个客户端连进来时,靠不同的源 IP/源端口区分
5. 服务器代码编写 (逐步讲解)
5.1 第一步:初始化 Winsock
WSADATA wsa;
if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) {
printf("WSAStartup failed\n");
return 1;
}
5.2 第二步:创建监听 socket
SOCKET listen_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (listen_sock == INVALID_SOCKET) {
printf("socket failed: %d\n", WSAGetLastError());
WSACleanup();
return 1;
}
5.3 第三步:设置端口重用 (推荐)
服务器刚关闭立刻重启时,端口可能还处于 TIME_WAIT,不加这句会 bind 失败。
BOOL reuse = TRUE;
setsockopt(listen_sock, SOL_SOCKET, SO_REUSEADDR,
(const char*)&reuse, sizeof(reuse));
5.4 第四步:bind 绑定本机地址
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(8080); // 监听 8080 端口
addr.sin_addr.s_addr = INADDR_ANY; // 0.0.0.0,所有网卡
if (bind(listen_sock, (struct sockaddr*)&addr, sizeof(addr)) == SOCKET_ERROR) {
printf("bind failed: %d\n", WSAGetLastError());
closesocket(listen_sock);
WSACleanup();
return 1;
}
5.5 第五步:listen 开始监听
if (listen(listen_sock, 5) == SOCKET_ERROR) {
printf("listen failed: %d\n", WSAGetLastError());
closesocket(listen_sock);
WSACleanup();
return 1;
}
printf("Server listening on port 8080...\n");
5.6 第六步:accept 等待客户端
struct sockaddr_in client_addr;
int client_len = sizeof(client_addr);
SOCKET client_sock = accept(listen_sock,
(struct sockaddr*)&client_addr,
&client_len);
if (client_sock == INVALID_SOCKET) {
printf("accept failed: %d\n", WSAGetLastError());
closesocket(listen_sock);
WSACleanup();
return 1;
}
5.7 第七步:recv / send 收发数据
char buf[1024];
int n = recv(client_sock, buf, sizeof(buf) - 1, 0);
if (n > 0) {
buf[n] = '\0';
printf("Received: %s\n", buf);
send(client_sock, buf, n, 0); // Echo 原样发回
} else if (n == 0) {
printf("Client disconnected\n");
}
5.8 第八步:关闭 socket,清理资源
closesocket(client_sock); // 关闭和该客户端的连接
closesocket(listen_sock); // 关闭监听
WSACleanup();
6. 客户端代码编写 (逐步讲解)
客户端流程比服务器短,没有 bind / listen / accept。
6.1 初始化 Winsock
WSADATA wsa;
if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) {
printf("WSAStartup failed\n");
return 1;
}
6.2 创建 socket
SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (sock == INVALID_SOCKET) {
printf("socket failed: %d\n", WSAGetLastError());
WSACleanup();
return 1;
}
6.3 填写服务器地址,connect 连接
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(8080);
inet_pton(AF_INET, "127.0.0.1", &server.sin_addr); // 本机测试用回环地址
if (connect(sock, (struct sockaddr*)&server, sizeof(server)) == SOCKET_ERROR) {
printf("connect failed: %d\n", WSAGetLastError());
closesocket(sock);
WSACleanup();
return 1;
}
printf("Connected to server\n");
connect() 返回成功时,三次握手已完成,可以开始 send / recv。
6.4 发送数据,接收回复
const char *msg = "Hello, Server!";
send(sock, msg, (int)strlen(msg), 0);
char buf[1024];
int n = recv(sock, buf, sizeof(buf) - 1, 0);
if (n > 0) {
buf[n] = '\0';
printf("Server replied: %s\n", buf);
}
6.5 关闭连接
closesocket(sock);
WSACleanup();
7. 完整可编译代码
7.1 服务器完整代码 tcp_server.c
#define _WIN32_WINNT 0x0601
#include <stdio.h>
#include <string.h>
#include <winsock2.h>
#include <ws2tcpip.h>
#pragma comment(lib, "ws2_32.lib")
#define SERVER_PORT 8080
#define BUF_SIZE 1024
int main(void)
{
WSADATA wsa;
if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) {
printf("[Server] WSAStartup failed\n");
return 1;
}
/* 1. 创建监听 socket */
SOCKET listen_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (listen_sock == INVALID_SOCKET) {
printf("[Server] listen failed: %d\n", WSAGetLastError());
closesocket(listen_sock);
WSACleanup();
return 1;
}
printf("[Server] Listening on port %d ...\n", SERVER_PORT);
/* 5. accept: 阻塞等待客户端连接 */
struct sockaddr_in client_addr;
int client_len = sizeof(client_addr);
SOCKET client_sock = accept(listen_sock,
(struct sockaddr*)&client_addr,
&client_len);
if (client_sock == INVALID_SOCKET) {
printf("[Server] accept failed: %d\n", WSAGetLastError());
closesocket(listen_sock);
WSACleanup();
return 1;
}
char client_ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &client_addr.sin_addr, client_ip, sizeof(client_ip));
printf("[Server] Client connected: %s:%d\n",
client_ip, ntohs(client_addr.sin_port));
/* 6. 收发数据: 收到什么就 Echo 回去 */
char buf[BUF_SIZE];
int n = recv(client_sock, buf, BUF_SIZE - 1, 0);
if (n > 0) {
buf[n] = '\0';
printf("[Server] Received: %s\n", buf);
send(client_sock, buf, n, 0);
printf("[Server] Echo sent\n");
} else if (n == 0) {
printf("[Server] Client closed connection\n");
} else {
printf("[Server] recv failed: %d\n", WSAGetLastError());
}
/* 7. 清理 */
closesocket(client_sock);
closesocket(listen_sock);
WSACleanup();
printf("[Server] Exit\n");
return 0;
}
7.2 客户端完整代码 tcp_client.c
#define _WIN32_WINNT 0x0601
#include <stdio.h>
#include <string.h>
#include <winsock2.h>
#include <ws2tcpip.h>
#pragma comment(lib, "ws2_32.lib")
#define SERVER_PORT 8080
#define SERVER_IP "127.0.0.1"
#define BUF_SIZE 1024
int main(void)
{
WSADATA wsa;
if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) {
printf("[Client] WSAStartup failed\n");
return 1;
}
/* 1. 创建 socket */
SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (sock == INVALID_SOCKET) {
printf("[Client] socket failed: %d\n", WSAGetLastError());
WSACleanup();
return 1;
}
/* 2. 填写服务器地址 */
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(SERVER_PORT);
inet_pton(AF_INET, SERVER_IP, &server.sin_addr);
/* 3. connect: 连接服务器 (内部完成三次握手) */
if (connect(sock, (struct sockaddr*)&server, sizeof(server)) == SOCKET_ERROR) {
printf("[Client] connect failed: %d\n", WSAGetLastError());
closesocket(sock);
WSACleanup();
return 1;
}
printf("[Client] Connected to %s:%d\n", SERVER_IP, SERVER_PORT);
/* 4. 发送消息 */
const char *msg = "Hello, TCP Server!";
if (send(sock, msg, (int)strlen(msg), 0) == SOCKET_ERROR) {
printf("[Client] send failed: %d\n", WSAGetLastError());
closesocket(sock);
WSACleanup();
return 1;
}
printf("[Client] Sent: %s\n", msg);
/* 5. 接收服务器回复 */
char buf[BUF_SIZE];
int n = recv(sock, buf, BUF_SIZE - 1, 0);
if (n > 0) {
buf[n] = '\0';
printf("[Client] Received: %s\n", buf);
} else if (n == 0) {
printf("[Client] Server closed connection\n");
} else {
printf("[Client] recv failed: %d\n", WSAGetLastError());
}
/* 6. 清理 */
closesocket(sock);
WSACleanup();
printf("[Client] Exit\n");
return 0;
}
5.UDP协议详解
一、UDP是什么
UDP(User Datagram Protocol,用户数据报协议)是 TCP/IP 协议栈中传输层的一个核心协议,与 TCP 并列,位于 IP 层之上、应用层之下。
UDP 的设计哲学非常明确:尽可能快地交付数据报,不保证可靠性、不保证顺序、不建立连接。
可以把 TCP 理解为「打电话」——先拨号建立连接,确认对方在听,再说内容,漏了会重说;UDP 则是「寄明信片」——写好就扔出去,不管对方有没有收到,也不保证按顺序到达。
![![[Pasted image 20261107210410.png]]](https://i-blog.csdnimg.cn/direct/deaf5c1a95664b349ad2781e1c574af2.png)
一、UDP在协议栈中的位置
![![[Pasted image 20261107210434.png]]](https://i-blog.csdnimg.cn/direct/4860512b11a5434184adb002c1fd6fac.png)
应用层数据交给 UDP 后,UDP 加上自己的头部,再交给 IP 层封装成 IP 数据包发送到网络。接收端 IP 层剥掉 IP 头后,将 UDP 数据报交给对应端口的应用程序处理。
三、UDP数据报格式
UDP头部固定8字节,结构简洁。
![![[Pasted image 20261107210756.png]]](https://i-blog.csdnimg.cn/direct/d3ddc8fd367e4dc6b7e115c39484b7d2.png)
| 字段 | 长度 | 含义 |
|---|---|---|
| 源端口 | 16 bit | 发送方端口号(0~65535),标识本端应用 |
| 目的端口 | 16 bit | 接收方端口号,标识目标应用 |
| 长度 | 16 bit | UDP 头部 + 数据的总字节数(最小 8) |
| 校验和 | 16 bit | 可选的差错检测,IPv4 下可为 0(表示不校验) |
与 TCP 头部对比:TCP 头部至少 20 字节,还包含序列号、确认号、窗口、标志位等复杂字段;UDP 仅 8 字节,开销极低,适合对实时性敏感的场景。
四、UDP 的核心特性
4.1 无连接 (Connectionless)
UDP 通信前不需要三次握手,发送方知道目标 IP 和端口后即可直接 sendto(),接收方通过 bind() 绑定端口后 recvfrom() 即可收包。
没有连接建立、维护、释放的过程,延迟低,适合高频小包场景。
4.2 不可靠 (Unreliable)
UDP 不提供以下保证:
- 数据一定送达
- 数据按发送顺序到达
- 数据不重复
- 数据内容不出错(校验和可检测错误,但 UDP 本身不重传)
丢包、乱序、重复均由应用层自行处理,或干脆接受丢失。
4.3 面向数据报 (Datagram-Oriented)
UDP 以数据报为最小传输单位。发送 100 字节,接收端收到的就是 100 字节的一个完整报文,不会出现 TCP 那种「粘包 / 半包」问题。
但这也意味着:单个 UDP 数据报不能超过底层 MTU 限制(以太网通常 1500 字节,扣除 IP 头 20 字节和 UDP 头 8 字节,有效载荷约 1472 字节)。更大的数据需在应用层分包。
4.4 支持一对一、一对多、多对一、多对多
UDP 天然支持:
- 单播 (Unicast) :一对一
- 广播 (Broadcast) :一对子网内所有主机
- 组播 (Multicast) :一对订阅了特定组播地址的主机组
TCP 只支持点对点连接,这是 UDP 在音视频、设备发现等场景的重要优势。
4.5 无流量控制与拥塞控制
UDP 发送速率完全由应用决定,不会因为对端缓冲区满而自动降速。这在实时场景是优点(不被阻塞),在网络拥塞时也可能加剧丢包。
![![[Pasted image 20261107212752.png]]](https://i-blog.csdnimg.cn/direct/4206397baf654fdd8c9263d220187f0f.png)
五、UDP vs TCP 对比
| 特性 | UDP | TCP |
|---|---|---|
| 连接 | 无连接 | 面向连接(三次握手) |
| 可靠性 | 不保证送达 | 保证送达、有序、不重复 |
| 头部开销 | 8 字节 | 20~60 字节 |
| 传输单位 | 数据报(有边界) | 字节流(无边界,需处理粘包) |
| 速度 | 快,延迟低 | 相对慢,有握手和确认开销 |
| 流量控制 | 无 | 有(滑动窗口) |
| 拥塞控制 | 无 | 有 |
| 广播/组播 | 支持 | 不支持 |
| 适用场景 | 实时、高频、可容忍丢包 | 文件传输、网页、可靠消息 |
选型原则:
- 数据 不能丢、必须完整 → TCP(或应用层在 UDP 上自建可靠机制)
- 数据 丢了可以接受、但要快 → UDP
- 需要 广播 / 组播发现设备 → UDP
- 数据报 有明确边界、小包高频 → UDP
六、UDP 的典型应用场景
6.1 DNS 域名解析
DNS 查询通常使用 UDP 端口 53。一次查询、一次响应,数据量小,偶发丢包可超时重发,无需维持长连接。
6.2 DHCP 动态地址分配
DHCP 基于 UDP(客户端 68 端口,服务端 67 端口),利用 UDP 广播在局域网内发现 DHCP 服务器。
6.3 实时音视频 (RTP/RTSP)
视频通话、直播推流对延迟极度敏感。丢一两帧画面可以容忍,但等 TCP 重传造成的卡顿不可接受,因此 RTP 通常跑在 UDP 上。
6.4 物联网与工业控制
- 设备发现:局域网广播 UDP 包,设备应答
- 传感器高频上报:温湿度、GPS 定位等周期性数据,丢一两个点不影响整体趋势
- Modbus UDP:Modbus 协议除 RTU/TCP 外还有 Modbus UDP 变体,用于以太网工控场景
- OTA 固件分包传输:大文件分块经 UDP 发送,应用层做序号、ACK 和重传
6.5 嵌入式 / MCU 场景
资源受限的 MCU 上,UDP 栈比 TCP 栈占用更少 RAM 和 Flash,且无连接状态机,适合:
- 心跳包上报
- 远程命令下发(配合应用层 ACK)
- 日志批量上报
6.6 游戏与 QUIC
传统在线游戏状态同步大量使用 UDP;更新的 QUIC 协议(HTTP/3 底层)也在 UDP 之上实现了可靠传输,兼顾低延迟与可靠性。
七、UDP 编程模型 (以 Berkeley Socket 为例)
7.1 服务端
int sock = socket(AF_INET, SOCK_DGRAM, 0); // SOCK_DGRAM = UDP
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);
addr.sin_addr.s_addr = INADDR_ANY;
bind(sock, (struct sockaddr *)&addr, sizeof(addr));
while (1) {
char buf[1024];
struct sockaddr_in client;
socklen_t len = sizeof(client);
int n = recvfrom(sock, buf, sizeof(buf), 0,
(struct sockaddr *)&client, &len); /* 等客户端发送数据,并获取客户端地址 */
// 处理 buf[0..n-1]
sendto(sock, reply, reply_len, 0,
(struct sockaddr *)&client, len);
}
7.2 客户端
int sock = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in server;
server.sin_family = AF_INET;
server.sin_port = htons(8080);
inet_pton(AF_INET, "192.168.1.100", &server.sin_addr);/* 解析服务端IP */
sendto(sock, data, data_len, 0,
(struct sockaddr *)&server, sizeof(server));
关键 API:
| 函数 | 作用 |
|---|---|
socket(AF_INET, SOCK_DGRAM, 0) | 创建 UDP 套接字 |
bind() | 绑定本地 IP/端口 (服务端必须,客户端可选) |
sendto() | 发送数据报到指定地址 |
recvfrom() | 接收数据报,同时获取发送方地址 |
connect() | 可选,绑定默认目标地址,之后可用 send() / recv() |
注意:UDP 的 connect() 不建立 TCP 式连接,只是让内核记住默认目标,并只接收来自该地址的包。
6. UDP Socket编程
UDP 服务端一般负责监听某个端口,等待客户端发送数据。
基本流程:
![![[Pasted image 20261107213515.png]]](https://i-blog.csdnimg.cn/direct/f123323876ce44f6a90f19a48e8b37f5.png)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define SERVER_PORT 8080
#define BUFFER_SIZE 1024
int main(void)
{
int sockfd;
struct sockaddr_in server_addr;
struct sockaddr_in client_addr;
socklen_t client_addr_len;
char buffer[BUFFER_SIZE];
// 1. 创建 UDP socket
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0)
{
perror("socket failed");
return -1;
}
// 2. 设置服务端地址
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET; // IPv4
server_addr.sin_addr.s_addr = INADDR_ANY; // 监听本机所有网卡
server_addr.sin_port = htons(SERVER_PORT); // 端口号,转为网络字节序
// 3. 绑定 IP 和端口
if (bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0)
{
perror("bind failed");
close(sockfd);
return -1;
}
printf("UDP server started, listening on port %d...\n", SERVER_PORT);
// 4. 循环接收客户端数据
while (1)
{
memset(buffer, 0, BUFFER_SIZE);
client_addr_len = sizeof(client_addr);
int recv_len = recvfrom(sockfd,
buffer,
BUFFER_SIZE - 1,
0,
(struct sockaddr *)&client_addr,
&client_addr_len);
if (recv_len < 0)
{
perror("recvfrom failed");
continue;
}
buffer[recv_len] = '\0';
printf("Received from %s:%d -> %s\n",
inet_ntoa(client_addr.sin_addr),
ntohs(client_addr.sin_port),
buffer);
// 5. 回复客户端
const char *reply = "Hello, UDP client. Message received.";
sendto(sockfd,
reply,
strlen(reply),
0,
(struct sockaddr *)&client_addr,
client_addr_len);
}
// 6. 关闭 socket
close(sockfd);
return 0;
}
UDP 客户端一般主动向服务端发送数据。
基本流程:
![![[Pasted image 20261107213603.png]]](https://i-blog.csdnimg.cn/direct/5a3a857686a2457eaefb01e163b37422.png)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define SERVER_IP "127.0.0.1"
#define SERVER_PORT 8080
#define BUFFER_SIZE 1024
int main(void)
{
int sockfd;
struct sockaddr_in server_addr;
socklen_t server_addr_len;
char buffer[BUFFER_SIZE];
// 1. 创建 UDP socket
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0)
{
perror("socket failed");
return -1;
}
// 2. 设置服务端地址
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(SERVER_PORT);
if (inet_pton(AF_INET, SERVER_IP, &server_addr.sin_addr) <= 0)
{
perror("inet_pton failed");
close(sockfd);
return -1;
}
server_addr_len = sizeof(server_addr);
// 3. 发送数据到服务端
const char *message = "Hello, UDP server.";
int send_len = sendto(sockfd,
message,
strlen(message),
0,
(struct sockaddr *)&server_addr,
server_addr_len);
if (send_len < 0)
{
perror("sendto failed");
close(sockfd);
return -1;
}
printf("Message sent to server: %s\n", message);
// 4. 接收服务端回复
memset(buffer, 0, BUFFER_SIZE);
int recv_len = recvfrom(sockfd,
buffer,
BUFFER_SIZE - 1,
0,
(struct sockaddr *)&server_addr,
&server_addr_len);
if (recv_len < 0)
{
perror("recvfrom failed");
close(sockfd);
return -1;
}
buffer[recv_len] = '\0';
printf("Received from server: %s\n", buffer);
// 5. 关闭 socket
close(sockfd);
return 0;
}
转载自 CSDN-专业IT技术社区



