DHCP
要解决的问题
- 如果每台新接入网络的设备,都要人工手动配置
IP地址、子网掩码、默认网关、DNS服务器地址,在一个有几百上千台设备的网络里(比如公司、学校),这个工作量会非常大 - 而且容易出现
IP地址冲突(两台设备手动配置了相同的IP)
DHCP(Dynamic Host Configuration Protocol,动态主机配置协议)
- 就是用来自动化解决这个问题的
- 一台设备刚接入网络时,自己没有任何
IP地址,通过DHCP协议,向网络里的DHCP服务器"要"一个可用的IP配置,服务器自动分配,不需要人工干预
- 一台设备刚接入网络时,自己没有任何
经典的DORA四步交互过程
Discover(发现)- 客户端广播一个"有没有
DHCP服务器"的请求包,发给255.255.255.255(广播地址),局域网内所有设备都会收到,但只有DHCP服务器会响应
- 客户端广播一个"有没有
Offer(提供)DHCP服务器收到请求后,从自己维护的地址池里挑一个尚未分配的IP,连同子网掩码、租约时间、网关地址、DNS服务器地址这些信息,打包成一个Offer广播回去- 此时客户端还没有正式的
IP,服务端理论上可以尝试单播到客户端提供的临时标识上,但很多实现里仍然选择广播,以保证兼容性和简单性 - 如果局域网里有多个
DHCP服务器,客户端可能会收到多份Offer,客户端通常会选择最先收到的那一份
Request(请求)- 客户端从收到的一个或多个
Offer里,选定其中一个,再次用广播的方式,明确告诉网络"我确认要接受这个IP地址" - 这次广播还有一个重要作用:让局域网内其他没被选中的
DHCP服务器知道'这次不用你了',它们可以把之前为这次请求预留的IP地址释放回地址池
- 客户端从收到的一个或多个
- Ack(确认)
- 被选中的那台
DHCP服务器,正式确认这次分配,客户端收到Ack后,才会真正把这个IP地址、子网掩码、网关等信息应用到自己的网卡配置上 - 这一步完成之后,客户端才第一次真正拥有了一个可用的
IP地址,可以开始正常的网络通信
- 被选中的那台
DHCP用的是UDP,不是TCP
DHCP基于UDP,服务器监听67端口,客户端监听68端口- 为什么不用
TCPTCP需要先建立连接(三次握手),而建立TCP连接的前提是双方都得有IP地址
DHCP具体分配了哪些配置信息
Offer/Ack里携带的信息,通常包括:IP地址:- 分配给这台设备的具体
IP
- 分配给这台设备的具体
- 子网掩码:
- 决定这个
IP所在网段的范围
- 决定这个
- 默认网关地址:
- 默认路由的下一跳
DHCP会把这个信息下发给客户端,客户端收到后,自动在本机路由表里加上一条默认路由(0.0.0.0/0指向这个网关)
DNS服务器地址:- 告诉客户端,以后要把域名解析成
IP,该去问哪个DNS服务器
- 告诉客户端,以后要把域名解析成
- 租约时间(
Lease Time):- 这个
IP地址的"使用期限",不是永久分配的
- 这个
租约机制——为什么IP地址有"到期时间"
DHCP分配的IP地址不是永久的,而是有一个租约期(常见配置是几小时到几天不等)- 这个设计的意义在于
IP地址池是有限的资源,如果一台设备离开了网络(比如关机、拔网线),它原来占用的IP应该能被收回,重新分配给别的设备,而不是永久占着
续租机制
- 客户端在租约到期之前(通常是租约时间过半时),会主动向
DHCP服务器发一个续租请求,如果服务器同意,租约时间重新计时 - 如果客户端在租约到期前一直没有成功续租(比如网络断开了一段时间),这个
IP地址会被服务器收回,重新放回地址池- 客户端如果之后重新连上网络,可能会被分配到一个完全不同的
IP地址
- 客户端如果之后重新连上网络,可能会被分配到一个完全不同的
DHCP中继(DHCP Relay)
DHCP高度依赖广播,但广播包默认情况下不会被路由器转发到其他网段(这是广播的固有特性,避免广播风暴扩散到整个大网络)- 这就带来一个问题
- 如果
DHCP服务器和客户端不在同一个局域网/网段,客户端的广播请求根本传不到服务器那里
- 如果
- 解决方案是
DHCP中继代理(DHCP Relay Agent)- 通常由路由器或者交换机充当,它工作在客户端所在的网段,监听到
DHCP广播请求后,把这个请求转换成单播,直接转发给远端的DHCP服务器,再把服务器的响应转发回客户端所在网段并重新广播出去
- 通常由路由器或者交换机充当,它工作在客户端所在的网段,监听到
为什么每一步都要"广播"
- 这是理解
DHCP最反直觉的一点- 整个过程几乎全程使用广播,而不是像正常通信那样"发给某个具体的
IP"
- 整个过程几乎全程使用广播,而不是像正常通信那样"发给某个具体的
- 原因很简单
- 客户端此时根本没有IP地址(源IP是
0.0.0.0),自然也不知道DHCP服务器的IP地址是什么,没法"点对点"通信,只能靠广播,让同一个局域网内所有设备都收到这个包,由DHCP服务器自己识别并响应
- 客户端此时根本没有IP地址(源IP是
子网掩码
概述
- 子网掩码本身不是直接"决定"网段范围的独立信息,而是用来把一个
IP地址,拆分成"网络部分"和"主机部分"两段,而"网络部分相同"就是"处于同一网段"的判定标准
本质
- 一个用来做"按位与"运算的过滤器
- 子网掩码(比如
255.255.255.0)和IP地址一样,是一个32位的二进制数,写成二进制是:
|
1 |
255.255.255.0 = 11111111.11111111.11111111.00000000 |
它的作用是
- 把
IP地址按位分成两部分——掩码里"1"对应的位,是IP地址的"网络部分" - 掩码里"
0"对应的位,是IP地址的"主机部分"
具体怎么算出"网段"
- 拿一个具体的IP举例:
192.168.1.10,子网掩码255.255.255.0
算出网段之后,具体怎么用
-
本机要判断"我要访问的目标
IP,和我是不是同一网段",具体算法是: -
用本机
IPAND子网掩码,算出本机所在的网络号 -
用目标
IPAND同一个子网掩码,算出目标所在的网络号 -
两个网络号一样,就是同网段(可以直接
ARP解析目标MAC)
不一样,就要走网关(ARP解析网关MAC,让网关帮忙转发) -
掩码越长,网段越小(可用
IP越少)255.255.255.0这种写法,更常见的是用CIDR记法表示为/24——意思是"前24位是网络部分"
默认网关
概述
- 默认网关准确来说是"一个和你在同一网段内、负责帮你转发跨网段流量的设备的
IP地址"
家庭场景下
- 这个设备确实通常就是你的路由器
- 你电脑上配置的"默认网关地址",填的正是路由器
LAN口(局域网这一侧)的IP地址 - 常见的是
192.168.1.1或192.168.0.1这类地址
|
1 |
你的电脑(192.168.1.10) → 路由器(192.168.1.1,LAN口地址) → 互联网 |
更严谨的定义
-
网关是"角色",不是特指某种设备类型
-
一个和你的设备处于同一网段、且开启了
IP转发功能的设备,它的IP地址就是你的默认网关 -
这个"角色"通常由路由器承担,但本质上,任何一台配置正确、开启了转发功能的机器,理论上都能充当网关,不一定非得是一台"路由器"这个专用硬件产品
-
比如
- 这里你电脑上配置的默认网关,可能是一台三层交换机上配置的网关地址,而不是一台传统意义上的"路由器"设备
|
1 2 |
你的办公电脑(10.0.1.50) → 部门交换机的三层网关(10.0.1.1,可能是核心交换机上配置的一个虚拟接口) → 企业防火墙 → 出口路由器 → 互联网 |
网关有自己的公网IP吗
- 经典情况:路由器
WAN口拥有真正的公网IP- 在这种(曾经是主流的)情况下:
- 路由器的
WAN口,通过PPPoE拨号或者DHCP从你的宽带运营商那里,获得一个真正的、全球唯一、可被公网直接路由的IP地址
|
1 |
你的电脑(私有IP,NAT之后) → 路由器LAN口(网关,私有IP) → 路由器WAN口(真正的公网IP) → 互联网 |
- 越来越常见的例外:
CGNAT(运营商级NAT)- 由于
IPv4地址枯竭日益严重,现在很多宽带运营商(尤其是移动宽带、部分地区的家庭宽带),不再给每个家庭用户分配一个真正的公网IP,而是: - 这意味着,你的路由器的
WAN口,拿到的其实是一个"运营商内部使用的私有地址"
- 由于
|
1 2 |
你的电脑 → 你的路由器(私有IP) → 路由器WAN口(运营商分配的地址,但这个地址本身也是私有的!) → 运营商的NAT设备(CGNAT) → 真正的公网IP → 互联网 |
- 怎么判断自己是不是处于
CGNAT之下- 登录路由器的管理后台,看它
WAN口显示的IP地址,访问ip.sb这类网站查到的公网IP对比 - 如果两者不一致,就是处于
CGNAT之下
- 登录路由器的管理后台,看它
LAN口
LAN(Local Area Network,局域网)口- 连接家里内部的设备
- 私有地址
WAN口
WAN(Wide Area Network,广域网)口- 连接家以外的网络——通常是一根从光猫插过来的网线,连接的是宽带运营商网络
- 可能是真正的公网
IP,也可能是CGNAT给你分配的运营商内部私有地址
IP转发功能
IP转发功能的本质
- 一个内核层面的开关
IP转发(IP Forwarding)是操作系统内核的一个配置项,决定这台机器,收到一个"目的IP不是自己"的数据包时,应该怎么处理:- 关闭(默认状态,普通主机):直接丢弃这个包,不做任何转发
- 开启(路由器/网关角色的机器):根据本机的路由表,把这个包重新转发出去,发给正确的下一跳
为什么普通电脑默认关闭这个功能
- 如果所有设备(手机、电脑)默认都开启了
IP转发功能会怎样- 任何一台设备,只要收到了一个不是发给自己的包,都会尝试帮忙转发
- 这不仅没有实际意义(普通设备通常只有一个网络接口,能转发到哪里去?
- 还存在明显的安全隐患:一台被入侵的普通电脑,如果开启了
IP转发,可能被利用成一个跳板,在你完全不知情的情况下帮攻击者转发恶意流量、绕过网络边界的安全策略
具体怎么查看和开启
linux
|
1 2 3 4 5 6 7 8 |
# 查看当前状态(1=开启,0=关闭) cat /proc/sys/net/ipv4/ip_forward # 临时开启(重启后失效) sudo sysctl -w net.ipv4.ip_forward=1 # 永久开启:编辑 /etc/sysctl.conf,加一行 net.ipv4.ip_forward = 1 |
windows
|
1 2 3 |
HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters 键名: IPEnableRouter 值: 1(开启) / 0(关闭,默认) |
和NAT(网络地址转换)的关系
NAT是"转发+改写"的一种特殊形式NAT功能的正常工作,必须建立在"IP转发功能已经开启"这个基础之上
NAT的完整流程- 路由器收到一个不是发给自己的包
- 判定要转发
- 在转发之前顺便做地址改写(
NAT) - 重新封装、发出去
- "判定要转发"这一步,正是
IP转发功能在起作用- 如果这个开关是关闭的,路由器收到这种包会直接丢弃,连"要不要做
NAT"这个问题都不会被问到,NAT规则根本没有机会被执行
- 如果这个开关是关闭的,路由器收到这种包会直接丢弃,连"要不要做
NAT(网络地址转换)
NAT要解决的根本问题
- 私有
IP地址不能在公网上被直接路由,全世界无数个局域网都在复用192.168.x.x这类地址段,私有IP地址不能在公网上被直接路由,全世界无数个局域网都在复用192.168.x.x这类地址段NAT的作用,就是在私有网络和公网的边界(通常是你家的路由器),把内网设备的私有IP,转换成一个外部可路由的公网IP
- 还有一个同样重要的原因:
IPv4地址枯竭IPv4总共只有大约43亿个地址,远远不够给全球每一台联网设备都分配一个独立的公网IPNAT让一个公网IP,可以被一整个内网(可能几十上百台设备)共享使用,这是缓解IPv4地址不够用这个问题的一个关键技术手段
NAT的三种类型
- 静态
NAT(Static NAT)- 一对一、固定的映射关系:一个内网私有IP,永久对应一个公网IP
- 这种方式不节省公网
IP地址(有多少台内网设备要联网,就要占用多少个公网IP),所以很少用于普通场景 - 常见于企业需要把内部某台服务器,永久暴露给公网访问的场景
- 动态
NAT(Dynamic NAT)- 路由器维护一个公网
IP地址池(比如有10个公网IP),内网设备访问外网时,从池子里动态挑一个空闲的公网IP临时分配给它用,用完(连接结束)释放回池子给别的设备用 - 这种方式比静态
NAT稍微节省一点,但如果同时上网的设备数超过了地址池大小,还是会不够用 - 这也不是现在最常见的方案
- 路由器维护一个公网
NAPT / PAT(端口地址转换)- 这是目前绝对主流的
NAT方案,因为它能做到"一个公网IP,同时支撑成千上万个内网设备的并发连接" - 原理是利用
TCP/UDP的端口号这个维度,做进一步的区分
- 这是目前绝对主流的
NAPT内部维护的映射表——具体怎么区分成大量连接
映射表条目的完整生命周期
- 创建:
- 内网设备第一次发起一个连接(比如
TCP的SYN包,或者UDP的第一个数据包)经过路由器时,路由器会创建一条新的映射记录,内容大致是:
- 内网设备第一次发起一个连接(比如
|
1 |
内网IP:内网端口 ↔ 公网IP:公网端口 ↔ 目标IP:目标端口 |
- 使用:
- 后续这条连接的所有后续包(不管是发出去的,还是对方回复回来的),路由器都根据这条记录做相应的地址/端口改写和转发
- 发出去的包,把源地址改成公网
IP:公网端口 - 回来的包,路由器看到目标是"公网
IP:公网端口"这个组合,反查这张表,找到对应的内网IP:内网端口,把目标地址改回内网地址,再转发进内网
- 销毁:
TCP连接正常四次挥手关闭后,这条映射记录通常会保留一小段时间(应对可能的重传包),之后被清理释放UDP因为没有连接的概念(无状态),路由器通常靠"一段时间没有新流量经过就超时清理"这种方式,来判断这条NAT映射是否还需要保留- 这个超时机制,也是很多依赖
UDP的实时应用(包括游戏)需要"心跳保活"的另一层原因:
如果长时间不发包,NAT映射条目可能被路由器清理掉,即使应用层连接逻辑上还"活着",底层的NAT穿透路径也已经失效了
NAT带来的一个核心问题
- 外部主动发起的连接无法直接进来
- 这是
NAT机制的一个副作用: NAT映射表的创建,依赖"内网设备率先发起连接"这个动作- 如果没有这个"先发出去"的动作,路由器根本不知道该往哪个内网设备转发,所以外部主动发起的连接,默认情况下无法穿透
NAT,直接找到内网某台设备
- 这是
NAT穿透(NAT Traversal)技术
- :两个客户端都处于各自的
NAT之后,双方都没有公网IP,都不能"被动"接受对方发起的连接- 这时候需要用到专门的
NAT穿透技术
- 这时候需要用到专门的
UDP打洞(UDP Hole Punching):- 利用一个第三方公网服务器(通常叫
STUN服务器)辅助,让两个客户端几乎同时向对方发送UDP包,"骗"过双方的NAT,让各自的NAT认为"是我这边先发起的",从而各自在NAT上打开一个"洞",使得对方的包能够穿透进来
- 利用一个第三方公网服务器(通常叫
TURN服务器:- 如果打洞失败(某些类型的
NAT,比如对称型NAT,打洞成功率很低),退而求其次,通过一台公网服务器中转双方的流量,牺牲一些延迟,换取连接的可靠性
- 如果打洞失败(某些类型的
UDP打洞(UDP Hole Punching)
UDP打洞的核心巧思在于
- 利用
NAT"看到内网设备主动发出去过东西,就允许对应方向的回复包进来"这条通用规则 - 让两台设备互相"骗"对方的
NAT,以为对方发来的包是"我方主动请求后的正常回复",而不是一次全新的、外部主动发起的连接
前提:NAT的一条通用行为规则
- 当内网设备
A,主动向外网某个地址发送过一个包之后,NAT会在映射表里记住这条"通道",并且在一段时间内,允许从那个外部地址返回的包,顺着这条通道进入内网,转发给A- 这是
NAT为了让"正常的请求-响应"通信能工作,必须具备的基本行为
- 这是
UDP打洞的关键漏洞利用点在于NAT在判断"要不要放行这个入站包"时,通常只看这个包的来源IP和端口,和之前记录的映射是否匹配,并不会去验证这个包在内容上是不是真的是'对之前那次请求的回复'它只是机械地检查"地址对不对得上",对不上就照放行
完整的打洞步骤
信令服务器怎么保证AB拿到对方的公网地址
- 前提:
A和B各自与信令服务器建立的是两条独立的TCP连接- 每一条
TCP连接,在服务端这边,都对应一个独立的socket对象(accept()返回全新socket、五元组唯一标识这条连接) - 服务端完全能够准确区分"这条消息是从
A的连接发来的,那条消息是从B的连接发来的"
- 每一条
- 信令服务器内部,靠什么把"消息"和"具体是谁"对应起来
- 在于应用层的业务逻辑设计:
- 服务器需要维护一张"用户身份"到"这个用户对应的那条
TCP连接(socket)"的映射表
- 具体流程通常是这样:
A连接到信令服务器后,第一件事通常是发一条"我是谁"的消息(比如带上一个用户ID、或者游戏里的玩家ID,这个ID通常是A之前登录游戏账号系统时就已经确定好的)
服务器收到后,在自己维护的连接表里,记录一条用户ID → 这条TCP连接对应的socket/ConnectionContext的映射
B连接上来后,做同样的事情- 第二步:
A明确指定要和"用户B"建立连接 - 第三步:服务器根据这个身份标识,精确转发
为什么这个过程能"骗"过NAT
- 第一步:借助
STUN服务器,发现自己在NAT外的"公网映射地址"- 客户端
A并不知道自己经过NAT转换后,在公网上呈现出来的地址是什么 STUN服务器的作用很简单:A向STUN服务器发一个UDP包,STUN服务器看这个包到达自己这里时,源地址显示的是什么,把这个信息回复告诉A</li> <li>B也用同样的方式,得知自己是公网IP:端口Y
- 客户端
- 双方交换彼此的公网映射地址
- 这一步需要一个信令服务器(可以和
STUN是同一台,也可以是专门的业务服务器) A和B双方,把各自从STUN那里得知的公网映射地址,通过这台服务器互相告知对方
- 这一步需要一个信令服务器(可以和
- 第三步(核心):双方几乎同时,主动向对方的公网映射地址发送
UDP包- 这是打洞能成功的关键动作
A主动向B的公网IP:端口Y发一个UDP包,B也主动向A的公网IP:端口X发一个UDP包
- 为什么这个动作能"骗"过
NAT?A发包给B时,A的NAT会创建一条映射:"允许来自B的公网IP:端口Y的包,进入到A的内网设备"
这本来是为了让B将来的回复包能正常返回给A而设置的规则B发包给A时,B的NAT同样会创建一条对称的映射:"允许来自A的公网IP:端口X的包,进入到B的内网设备"- 关键在于:
B主动发给A的这个包,虽然B自己的NAT认为"这是我方发起的新请求",但A的NAT收到之后,发现来源地址正好匹配自己刚刚因为'A发给B'这个动作而创建的映射记录A的NAT会误以为这是"B对A之前那次请求的正常回复",从而放行- 同样地,
A发给B的包,也会被B的NAT用同样的逻辑误判为"回复",而放行
为什么必须"几乎同时"
- 如果
A先发包给B,但B这边还没来得及发包给A(比如B的动作慢了几百毫秒),这个时候A发的包到达B的NAT时,B的NAT里还没有为A创建任何映射记录 NAT会认为这是一个"外部主动发起的、之前没见过的连接",大概率会直接丢弃- 所以实际实现里
- 双方通常会收到对方的公网地址后,立刻开始发送,而且往往会连续发送多个包、并配合一定的重试机制,而不是只发一次
怎么做到几乎同时
- 其实不需要做到毫秒级别精确的"同时",这是一个常见的误解
NAT映射有一个"有效期窗口",不是发出瞬间就失效NAT创建的映射记录,通常会保留几十秒到几分钟- 这意味着"同时"这个词,不需要理解成"精确到毫秒级的物理同步",只需要做到"双方各自发起打洞动作的时间点,落在同一个大致的时间窗口内(比如几秒钟之内)",就完全够用了
- 具体的实现技巧
1:借助信令服务器,统一发出"开始"信号- 因为
A和B都通过TCP连接在线连着信令服务器
- 因为
|
1 2 3 4 5 |
1. A、B各自把自己的公网映射地址发给信令服务器 2. 信令服务器等两边地址都收集齐了 3. 信令服务器几乎同一时刻,分别通过A、B各自的TCP连接, 推送一条"开始打洞,对方地址是xxx"的消息给双方 4. A、B各自收到这条消息后,立刻发起UDP打洞包 |
- 具体的实现技巧
2(更关键、更实用):不是只发一次,而是连续重试发送- 这是实际生产系统里真正用来解决"时序不确定性"的核心手段,比单纯依赖"服务器同步发令"更可靠
- 不指望"一次性精确同步",而是收到对方地址后,立即开始,连续、重复地发送
UDP包(比如每隔100-200毫秒发一个,持续几秒钟),而不是只发一次就等待
|
1 2 3 4 5 6 7 8 9 10 |
// 伪代码示意:收到对方地址后,持续尝试打洞,而不是只发一次 void StartHolePunching(sockaddr_in peerAddr) { for (int i = 0; i < 20; ++i) { // 尝试20次 sendto(udpSocket, punchPacket, len, 0, (sockaddr*)&peerAddr, sizeof(peerAddr)); std::this_thread::sleep_for(std::chrono::milliseconds(150)); // 同时持续检查:是否已经收到了对方发来的包(说明打洞成功了) if (holePunchSucceeded) break; } } |
并不是所有NAT类型都能打洞成功
- 完全锥形
NAT(Full Cone NAT)- 最容易打洞成功
- 只要内网设备发过一次包出去,这个映射对任何外部地址都开放,不限制"必须是之前发过去的那个目标"
- 受限锥形
NAT(Restricted Cone NAT)- 限制稍微严格一点,只允许"之前发送目标的IP"发回来的包,但不限制端口
- 端口受限锥形
NAT(Port Restricted Cone NAT)- 更严格,必须
IP和端口都匹配之前发送的目标,才允许放行
- 更严格,必须
- 对称型
NAT(Symmetric NAT)- 最难打洞
- 这种
NAT的特点是,同一个内网设备,访问不同的外部目标时,NAT会分配不同的公网映射端口 - 这意味着,
A为了给STUN服务器发包而暴露出来的那个"公网端口",和A实际发给B时用的端口可能完全不一样 B从STUN那里拿到的A的地址信息,根本不是A实际会用来跟B通信的那个端口,打洞会直接失败
- 如果双方任意一方是对称型
NAT(或者更糟,双方都是),UDP打洞的成功率会大幅下降,这时候通常只能退而求其次,使用TURN服务器做纯中转- 这也是为什么实际生产的实时通信系统(包括
WebRTC),都会同时准备"STUN(尝试直连)+TURN(中转兜底)"两套方案,根据实际网络环境自动选择,不能只依赖打洞
- 这也是为什么实际生产的实时通信系统(包括
TURN服务器
和STUN的根本区别
STUN- 只负责告诉你,你自己在
NAT外面看起来是什么地址
- 只负责告诉你,你自己在
TURN- 当
A和B之间怎么打洞都打不通时 TURN服务器会站出来,直接充当一个"数据转发中转站",A发的所有数据,先发给TURN服务器,再由TURN服务器转发给B,反之亦然
- 当
为什么TURN一定能成功,而打洞不一定
TURN服务器本身部署在公网上,拥有真正的公网IP,而且没有NAT这一层障碍A和B连接TURN服务器,都是"内网设备主动发起连接到一台公网服务器"这种最普通、最不会失败的连接模式
TURN协议内部的基本工作方式
Allocate(分配)请求A首先向TURN服务器发一个"Allocate"请求,意思是"请在你这台服务器上,给我分配一个专属的中转地址(IP+端口)"TURN服务器同意后,会在自己身上开辟一个专属于A的中转端口,并把这个地址告诉A
- 交换中转地址(通过信令服务器)
A、B各自从TURN服务器拿到自己的"中转地址"后,依然需要通过信令服务器,把这个地址告诉对方(和交换STUN发现的地址是同一个流程,只是这次交换的是TURN分配的中转地址)
Permission(权限)机制TURN服务器出于安全考虑,不会无条件地把任何发到这个中转端口的数据都转发出去——A需要显式告诉TURN服务器"允许B的地址往这里转发数据"- 这是一个额外的权限声明步骤,防止
TURN服务器被滥用成一个任意转发的开放代理
Send/Data传输- 之后
A要发数据给B,实际操作是: A把数据包装在一个TURN协议的"Send"消息里,发给TURN服务器,注明"这是要转发给B的"TURN服务器收到后,剥离TURN协议的外层包装,把原始数据通过一个"Data"消息,转发给B- 反向也是同样的流程
- 之后
ICE框架
- 把
STUN、打洞、TURN这几种手段,统一整合起来的标准做法
DNS服务器地址
DNS要解决的问题
DNS(Domain Name System,域名系统)的作用,就是把这种人类好记的域名,翻译成机器真正需要的IP地址
DNS服务器地址是什么
- 常见的
DNS服务器地址,比如:
|
1 2 3 4 5 |
8.8.8.8 / 8.8.4.4:Google Public DNS 1.1.1.1:Cloudflare DNS 或者你的运营商自己提供的DNS服务器地址(DHCP通常会自动分配这个) |
DNS用的是UDP(通常),端口53
DNS查询默认用UDP协议,原因和我们之前讲DHCP用UDP类似- 但如果返回结果的数据量超过
UDP报文的合理大小限制(比如DNSSEC签名信息、或者一个域名对应很多条记录),会退化使用TCP,这也是DNS服务器同时要监听UDP和TCP这两种协议、都用53端口的原因
TCP包大小
1500是标准以太网的MTU(最大传输单元)
|
1 2 3 |
MSS = MTU - IP头 - TCP头 = 1500 - 20 - 20 = 1460 |
UDP包大小
UDP头只有8字节(源端口2字节+目的端口2字节+长度2字节+校验和2字节,是一个非常精简的头部,不像TCP头有序号、确认号、窗口这些字段)
|
1 2 3 |
UDP最大payload = MTU - IP头 - UDP头 = 1500 - 20 - 8 = 1472 |
应用层协议设计
消息类型区分
请求-响应的关联
- 在真实场景里,客户端可能会连续发出好几个不同类型的请求,这些请求的响应,不一定按发送顺序原样返回
- 解决方案是加一个"请求
ID"字段- 客户端每发一次请求,自己生成一个唯一的
ID(比如自增计数器),服务端处理完,把这个ID原样带在响应里返回 - 客户端收到响应后,根据这个
ID,找到自己之前对应哪一次请求,而不是依赖"先发的先回"这种脆弱的顺序假设
- 客户端每发一次请求,自己生成一个唯一的
- 消息类型(
Type)从1字节扩展成2字节- 现在的
MsgType只有3种(DATA/HEARTBEAT/HEARTBEAT_ACK),1字节(最多256种类型)看起来够用 - 但真实协议,消息类型可能有几百上千种
- 现在的
版本兼容性
- 如果没有版本号,新旧客户端和服务端之间,一旦协议格式变了,就会互相解析出错(比如老客户端不认识新加的字段,直接解析崩溃)
序列化格式的选择
- 目前的
DATA消息,payload就是原始字符串 - 方案
1:手写二进制格式- 优点:体积最小、解析速度最快,没有任何冗余开销
- 缺点:每加/改一个字段,发送方和接收方的代码都要同步手动修改,维护成本高,容易出错
- 方案
2:文本格式,比如JSON- 优点:可读性极好,调试方便
- 缺点:体积比二进制大很多(字段名本身也要占用字节),解析速度比二进制慢(需要做字符串解析),对带宽和延迟敏感的游戏场景来说,通常不是首选
|
1 |
{"type": "move", "x": 123.5, "y": 67.8, "angle": 90} |
- 方案
3:Protobuf(Google Protocol Buffers)这类二进制序列化框架- 先用一种描述语言(
.proto文件)定义好消息结构 - 然后用
Protobuf的代码生成工具,自动生成对应语言(C++/C#/Java等)的序列化/反序列化代码 - 兼顾了体积小(接近手写二进制的效率)和维护性好(不用手动管理字段偏移量,加字段不容易破坏兼容性,
Protobuf设计上天然支持"新增字段、老代码忽略未知字段"这种向后兼容
- 先用一种描述语言(
|
1 2 3 4 5 |
message MoveRequest { float x = 1; float y = 2; float angle = 3; } |
错误处理机制
- 目前的协议里,如果服务端处理某个请求失败了,没有任何机制能告诉客户端"为什么失败"
- 加一个错误码字段(比如
0表示成功,非0表示各种具体错误原因)
消息类型的路由——大型项目里常见的"分发表"模式
- 现在
ProcessIncomingData里用的是switch语句处理不同MsgType,消息类型一多(几百种),这个switch会变得又臭又长 - 实际生产项目常见的做法是用一张"消息类型→处理函数"的映射表(分发表)
- 这种设计的好处是每种消息类型的处理逻辑,可以独立写在单独的函数/文件里,新增消息类型不需要改动核心分发代码
- 更符合"开闭原则"(对扩展开放,对修改关闭)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
std::unordered_map<uint16_t, std::function<void(ConnectionContext*, const char*, uint32_t)>> g_handlers; // 注册阶段(程序启动时,把每种消息类型和对应处理函数关联起来) g_handlers[MSG_MOVE] = HandleMoveRequest; g_handlers[MSG_ATTACK] = HandleAttackRequest; g_handlers[MSG_CHAT] = HandleChatRequest; // ... // 解析出消息类型后,直接查表调用,不再用一长串switch auto it = g_handlers.find(msgType); if (it != g_handlers.end()) { it->second(connCtx, payloadPtr, payloadLen); } |
安全性校验
- 当前代码没有对
payloadLen做上限校验,这是一个需要在真实协议设计里明确补上的点
|
1 2 3 4 5 6 7 8 |
constexpr uint32_t MAX_PAYLOAD_SIZE = 64 * 1024; // 根据业务实际情况设定合理上限 if (payloadLen > MAX_PAYLOAD_SIZE) { // 判定为异常/恶意数据,直接断开这个连接,而不是继续等待攒够这么多字节 std::cerr << "异常的payload长度: " << payloadLen << ",断开连接" << std::endl; closesocket(connCtx->clientSocket); return; } |
字节序
字节序要解决的问题(同一个数字,不同机器存储方式不同)
- 一个多字节的数字(比如
uint32_t这种4字节整数),在计算机内存里,到底是"高位字节存在前面"还是"低位字节存在前面",不同CPU架构的约定是不一样的——这就是字节序(Endianness)
大端 vs 小端——两种存储方式
为什么会有这两种不同的约定
- 大端(
Big-Endian)- 数字的"高位"(大头)存在内存地址较低的位置
- 这种存储方式,和我们人类日常读写数字的习惯是一致的(比如读
123,从左到右,先读"百位"这个高位)
- 小端(
Little-Endian)- 数字的"低位"(小头)存在内存地址较低的位置
- 这种方式,虽然反直觉,但在
CPU硬件层面有一些计算上的便利性(比如做加法运算时,从低位字节开始处理,正好符合内存地址递增的访问顺序),历史上被Intel x86架构采用,并沿用至今
- 现在用的
Windows电脑(x86/x64架构),内部用的是小端存储
如果不做转换,具体会出什么错
- 在你的小端机器上,内存里
payloadLen这4个字节实际排列是03 00 00 00(低位在前) - 这段数据被发到网络上,原样到达接收方
- 如果接收方是同样架构的小端机器,并且也没做字节序处理,直接把收到的
03 00 00 00当成本机的uint32_t来读,恰好还能读出3,不会出错 - 但只要接收方按照"网络字节序标准(大端)"来解析这
4个字节(这是所有正确实现的网络程序都应该做的事,不管它自己是什么架构),它会把03 00 00 00理解成:最高位字节是0x03,也就是把这个数字解析成0x03000000
- 如果接收方是同样架构的小端机器,并且也没做字节序处理,直接把收到的
|
1 2 |
uint32_t netLen = htonl(payloadLen); memcpy(frame.data() + 1, &netLen, 4); |
四个转换函数
|
1 2 3 4 |
htons(x) // Host TO Network Short -- 本机字节序 → 网络字节序,16位(short) htonl(x) // Host TO Network Long -- 本机字节序 → 网络字节序,32位(long) ntohs(x) // Network TO Host Short -- 网络字节序 → 本机字节序,16位 ntohl(x) // Network TO Host Long -- 网络字节序 → 本机字节序,32位 |
大端被选为"网络字节序标准"的原因
- 早期定义
TCP/IP协议标准的时候(RFC文档里),就规定了所有网络协议里的多字节字段,统一使用大端表示,这个规定被称为"网络字节序" - 选择大端而不是小端,更多是历史上的约定(和人类读数习惯一致,便于协议文档描述和调试),不是因为大端在技术上有什么性能优势
非阻塞IO设置
阻塞 vs 非阻塞
windows
|
1 2 3 4 5 |
u_long mode = 1; // 1 = 开启非阻塞模式,0 = 恢复阻塞模式 int result = ioctlsocket(sock, FIONBIO, &mode); if (result != NO_ERROR) { std::cerr << "设置非阻塞失败: " << WSAGetLastError() << std::endl; } |
linuxLinux下的写法是"先读取现有标志,再用按位或加上O_NONBLOCK",而不是直接覆盖设置- 因为
fcntl的标志位可能同时存在好几个(比如还有O_APPEND这类其他标志),直接覆盖会不小心把其他已设置的标志清掉
|
1 2 3 4 |
#include <fcntl.h> int flags = fcntl(sock, F_GETFL, 0); // 先取出当前的文件状态标志 fcntl(sock, F_SETFL, flags | O_NONBLOCK); // 在原有标志基础上,加上非阻塞标志 |
非阻塞模式下,recv/send的返回值变化
windows
|
1 2 3 4 5 6 7 8 9 10 11 |
int n = recv(sock, buf, sizeof(buf), 0); if (n > 0) { // 正常收到数据 } else if (n == SOCKET_ERROR) { int err = WSAGetLastError(); if (err == WSAEWOULDBLOCK) { // 现在没有数据,不是真正的错误,稍后再试 } else { // 真正的错误 } } |
linux
|
1 2 3 4 5 6 7 8 9 10 |
int n = recv(sock, buf, sizeof(buf), 0); if (n > 0) { // 正常收到数据 } else if (n == -1) { if (errno == EWOULDBLOCK || errno == EAGAIN) { // 现在没有数据,稍后再试(EWOULDBLOCK和EAGAIN在Linux下通常是同一个值) } else { // 真正的错误 } } |
IOCP场景下,还需要手动设置非阻塞吗
IOCP用的是完全不同的一套异步机制,不依赖"把socket设成非阻塞"这个传统手段来实现异步行为- 具体原理是:
- 当你调用
WSARecv/WSASend,并且传入了一个OVERLAPPED结构体指针时,不管这个socket本身是阻塞还是非阻塞模式,这次调用都会立即返回(通常返回WSA_IO_PENDING,表示"操作已经在后台异步进行,还没完成"),不会卡住等待 - 重叠
I/O(Overlapped I/O)"这个机制本身,已经内建了异步行为,不需要额外依赖socket的阻塞/非阻塞这个属性来实现"不卡住"这个效果
- 当你调用
非阻塞+重试的组合模式
- 在
select/epoll这类场景下,非阻塞模式配合"重试"逻辑,是标准的编程套路
|
1 2 3 4 5 6 7 8 9 10 11 |
while (true) { int n = recv(sock, buf, sizeof(buf), 0); if (n > 0) { // 处理数据 } else if (n == SOCKET_ERROR && WSAGetLastError() == WSAEWOULDBLOCK) { break; // 现在没有更多数据了,正常退出,等下一次事件通知再来读 } else { // 处理连接关闭或真正的错误 break; } } |
滑动窗口
滑动窗口要解决的问题——流量控制
- 如果发送方"没头没脑"地把数据一股脑全发出去,而接收方处理数据的速度跟不上(比如接收方
CPU繁忙、应用层没有及时调用recv把数据取走),接收方的接收缓冲区会被迅速填满,后续到达的数据会被直接丢弃 - 滑动窗口机制,就是让接收方能够动态地告诉发送方"我这边还能接收多少数据,你别发太多",从而实现流量控制,避免这种情况
窗口的构成——发送方视角的四个区域
已发送已确认
- 这部分数据,已经被对方确认收到了,可以安全地从发送缓冲区里清除,不需要再保留用于重传
已发送未确认
- 数据已经发出去了,但还没收到对方的
ACK确认 - 这部分数据必须继续保留在发送缓冲区里(以备重传定时器超时后重新发送)
窗口内可以发送(但还没发)
- 这部分数据还没有被发出去,但根据当前的窗口大小,允许继续发送
- 一旦被发出去,就会从③区域转移到②区域
窗口外,暂时不能发
- 即使这部分数据在发送缓冲区里已经准备好了,协议不允许现在发出去,必须等窗口"滑动"之后才能发送
这四个区域的边界
- 对应的正是内核
tcp_sock结构体时提到的字段 snd_una(send unacknowledged):- ①和②的分界点,"最早一个还没被确认的字节序号"
snd_nxt:- ②和③的分界点,"下一个要发送的字节序号"
snd_una + snd_wnd:- ③和④的分界点,窗口的右边界,
snd_wnd就是对方通告给你的发送窗口大小
- ③和④的分界点,窗口的右边界,
窗口是怎么"滑动"的
- 当发送方收到一个新的
ACK(确认号比之前更大了),说明对方确实收到了一部分数据,这时候会发生两件事:snd_una往右移动,移动到这个新的确认号
这意味着①区域(已确认)扩大了,原本②区域(已发送未确认)里被确认的部分,转移进了①区域- 窗口的右边界(
snd_una + snd_wnd)也跟着往右移动
这意味着原本在④区域(窗口外,不能发)的一部分数据,现在进入了③区域(窗口内,可以发送了)
- 整个窗口(②+③这段范围)会随着
ACK的不断到来,像一个滑块一样,持续向右移动,允许发送方不断把新的数据纳入"可以发送"的范围内
窗口大小从哪来
- 接收方每次发送
ACK时,会在TCP头里的"窗口(Window)"字段,填上自己当前接收缓冲区还剩多少可用空间- 这个数字,就是发送方看到的
snd_wnd
- 这个数字,就是发送方看到的
流量控制
- 如果接收方处理数据很慢,导致接收缓冲区快满了,它会在下一次
ACK里,通告一个更小的窗口值,发送方看到窗口变小,会相应地减慢发送速度,避免继续压垮接收方
极端情况——零窗口(Zero Window)
- 如果接收方缓冲区完全满了,会通告一个窗口值
0,告诉发送方"你先别发了,我这边处理不过来" - 发送方收到零窗口通知后,会暂停发送,但会启动一个持续定时器(
Persist Timer),定期发送一个很小的"窗口探测"包,试探接收方的窗口是否已经恢复- 因为如果接收方后续那个"窗口变大了"的通知包本身丢失了,双方可能会陷入死锁
- 发送方一直等通知,接收方以为自己已经通知过了
窗口大小的字段限制
TCP头里表示窗口大小的字段,只有16位,最大只能表示65535字节- 在现代高带宽网络下(尤其是长距离、高延迟的链路,俗称"长肥网络"),这个窗口上限会严重限制吞吐量
- 因为窗口大小,本质上限制了"发送方在收到确认之前,最多能有多少数据同时在传输路径上飞着"
- 如果带宽很大但窗口太小,发送方发完这一窗口的数据后,必须停下来等确认,无法充分利用带宽
- 窗口缩放选项(
Window Scale,WS)- 就是双方在握手阶段协商的一个"放大倍数"
- 实际的窗口大小 =
TCP头里的窗口字段值 × 这个缩放因子 - 突破了16位字段
65535字节的硬性限制,让窗口可以支持到几MB甚至更大,更好地适配高带宽高延迟的网络环境
拥塞控制
滑动窗口(流量控制) ≠ 拥塞窗口(拥塞控制)
- 滑动窗口(
snd_wnd/rwnd):由接收方决定,目的是保护接收方自己,不被发送方发来的数据压垮 - 拥塞窗口(
cwnd):由发送方根据网络的拥塞状况自己估算调整(慢启动、拥塞避免这些算法),目的是保护整个网络,避免发送方发太快导致网络中间节点(路由器)过载丢包
发送方实际能发送的数据量
- 发送方实际能发送的数据量,取这两个窗口中较小的那个
实际可发送量 = min(snd_wnd, cwnd)
拥塞控制要解决的问题
- 网络中间的路由器,处理能力和缓冲区都是有限的
- 如果所有发送方都毫无节制地拼命发数据,路由器的缓冲区会被打满,导致大量丢包,进而触发大量重传,重传又进一步加重网络负担
- 这是一个恶性循环,专业术语叫拥塞崩溃
核心思路
- 发送方需要有一种机制,自己去感知网络的拥堵程度,网络通畅时逐步提速,一旦感知到拥堵迹象(丢包),立刻大幅降速,给网络喘息的空间
- 这个"自己估算出来的、能发多快"的值,就是拥塞窗口(
cwnd)
cwnd随时间变化的经典"锯齿图"
阶段①:慢启动(Slow Start)——指数增长
- 连接刚建立时,发送方对网络状况一无所知,不敢贸然发送大量数据,所以从一个很小的
cwnd开始- 通常是
1-10个MSS大小,具体数值在不同版本的TCP实现里有调整
- 通常是
- 每收到一个
ACK,cwnd就增加1个MSS- 这个简单规则造成的实际效果是:
- 每经过一个完整的RTT(往返时延),
cwnd大约翻一倍 - 因为一个
RTT内能收到的ACK数量,大约等于当前发送的数据段数量,每个ACK都让窗口+1,总共加起来就约等于窗口翻倍)
- 这是指数级增长,增长速度很快
- 名字叫"慢启动"其实有点反直觉
ssthresh——什么时候从"指数增长"切换到"线性增长"ssthresh(慢启动阈值,Slow Start Threshold),是一个动态调整的分界线- 当
cwnd还小于ssthresh时,处于慢启动阶段,指数增长 - 一旦
cwnd达到或超过ssthresh,就切换进入下一个阶段——拥塞避免(线性增长),不再那么激进
阶段②:拥塞避免(Congestion Avoidance)——线性增长
- 进入这个阶段后,增长速度明显放缓
- 每经过一个完整的
RTT,cwnd只增加1个MSS(而不是慢启动阶段的"每个ACK都涨、翻倍式增长")
- 每经过一个完整的
- 这是一种更谨慎的探测方式,持续、缓慢地试探网络的承载上限,直到真正遇到丢包为止
阶段③:检测到拥塞——两种不同的应对方式
- 方式1:超时重传(
Timeout)- 判定网络状况非常糟糕,处理最激烈
- 如果发送方等了很久,重传定时器都超时了还没收到确认,说明网络状况可能非常差(比如严重拥塞、甚至链路暂时中断)
- 这种情况下的处理最保守:
ssthresh设置为当前cwnd的一半cwnd直接重置为初始的很小值,重新开始一轮慢启动
- 快速重传(
Fast Retransmit)——收到3个重复ACK,判定为轻度丢包- 如果发送方连续收到
3个内容相同的重复ACK,通常意味着"只是某一个包碰巧丢了,但后续的包依然正常到达" - 网络状况没有那么糟糕,不需要像超时那样"推倒重来"
- 这种情况下,处理方式更温和:
- 不用等超时定时器,直接重传那个被判定丢失的包
ssthresh同样设置为当前cwnd的一半cwnd不会像超时那样打回最小值,而是直接设置成新的ssthresh值,重新从这个中等水平开始,而不是从零开始
- 如果发送方连续收到
现代TCP的演进
- 现代操作系统默认使用的拥塞控制算法已经演进了很多:
CUBIC(Linux长期默认算法)BBR(Google研发,近年来越来越流行)
声明:本文为原创文章,版权归Aet所有,欢迎分享本文,转载请保留出处!
你可能也喜欢
- ♥ 51CTO:Linux C++网络编程三08/16
- ♥ Windows网络编程二07/16
- ♥ Windows网络编程一07/13
- ♥ Linux高性能服务器编程:TCP/IP协议族09/02
- ♥ 网络相关11/21
- ♥ Linux 高性能服务器编程:网络基础编程一11/27











