简介:一套基于Java实现的DHCP协议完整源代码,面向需要理解网络协议原理或搭建自定义网络管理工具的Java开发者。代码覆盖DHCP交互的发现、提供、请求、确认四个阶段,包含UDP广播通信、BOOTP报文编解码、IP地址池动态分配、多线程并发处理等核心模块。资源包共84个文件,主体为16个Java源文件,另含64个HTML格式javadoc文档、CSS、配置等,整体186KB,便于对照文档快速查阅类结构与核心算法。已有632人学习下载,适合具备Java网络编程基础、希望实战掌握DHCP协议细节的开发者,可学到基于DatagramSocket的UDP收发包、DHCP报文解析构造、租约管理与地址分配等实用技巧,是网络协议学习的高性价比参考。
1. 自己写Java版DHCP:为什么源码不是“重复造轮子”,而是深度适配的内网刚需
网络不通时,多数人先怀疑IP地址,而IP通常由一台DHCP服务器自动发下来。用Java实现DHCP的源代码,看起来像重复造轮子,但内网里经常是刚需:设备入网先认证、按MAC打标签、租约写进资产库,这些现成dhcpd给不了。把这条协议链路在Java里跑通,等于你拿到了地址分配的最后一块控制权。
比如账号资产系统是Java写的,所有设备台账都在库里,默认DHCP却只能靠脚本捞日志,两边对不上。自己做一套轻量服务,分配动作就是一次数据库事务。这个项目核心技术点不复杂:socket、报文解析、租约表,难的是边界条件。
文章从最小可运行的客户端和服务端拆开讲,适合准备动手的工程师,也适合正在背Java面试题的人——与其反复背八股文,不如把UDP 67/68这条链路真跑通。广播权限、xid匹配、中继转发这几个最容易翻车的地方,后面会单独点名。
2. DHCP协议先遣队:UDP 67/68、四步握手与报文里不能省的8个字段
2.1 先搞懂状态机:Discover、Offer、Request、Ack不是顺序调用
客户端启动时向 255.255.255.255 的 67 端口发 DHCPDISCOVER,服务器回 DHCPOFFER,客户端再挑一个 OFFER 发 DHCPREQUEST,服务器最后回 DHCPACK。很多人把这个过程当成一次 HTTP 的四次握手,这是最耽误事儿的理解。DHCP 底层是无连接的 UDP,服务端不保存 HTTP 式会话,每一次报文都要靠事务 ID(xid)来认亲。
服务端的真正状态机是空白的:收到 DISCOVER 就准备一个可用 IP 放进 OFFER;收到 REQUEST 就检查这个 IP 还能不能给,能就给 ACK,不能就给 NAK。客户端才有完整的 INIT、SELECTING、REQUESTING、BOUND、RENEWING 状态。加上“续租”,事情会更明确:客户端到了租约一半时间会直接发 REQUEST,不再走 DISCOVER,这时报文里的 ciaddr 会填上当前 IP,服务端看到 ciaddr 非 0,就明白这是在续租。
还有一点面试也常问:OFFER 不是预约。两个服务器都能回 OFFER,客户端最终只认其中一个,所以 REQUEST 里必须带 Server Identifier(option 54),告诉服务器“我选你”。否则服务端就算做出 ACK,客户端也可能因为不认这个身份而扔掉。理解了这一点,后面代码就不会写歪。
2.2 字节级报文:从op到options,Java里用byte[]还是ByteBuffer
DHCP 复用 BOOTP 报文,固定头加上4字节 Magic Cookie,再加可选选项。先看最小字段表,字段顺序不能错,否则抓包软件都会提示 malformed。
| 字段 | 字节数 | 说明 |
|---|---|---|
| op | 1 | 1 表示请求,2 表示响应 |
| htype | 1 | 硬件类型,以太网填 1 |
| hlen | 1 | 硬件地址长度,MAC 填 6 |
| hops | 1 | 经过中继的跳数,单网段填 0 |
| xid | 4 | 事务ID,客户端生成,每次会话唯一 |
| secs | 2 | 客户端启动后经过秒数,通常填 0 |
| flags | 2 | 高字节广播位,0x8000 表示强制广播回复 |
| ciaddr | 4 | 客户端已知IP,首次请求时是 0.0.0.0 |
| yiaddr | 4 | 服务端分配给客户端的IP |
| siaddr | 4 | 下一台服务器IP,常用于 PXE |
| giaddr | 4 | 中继代理IP,跨网段时由交换机填入 |
| chaddr | 16 | 客户端MAC,前6字节有效 |
| sname | 64 | 服务器名,通常全0 |
| file | 128 | 启动文件,通常全0 |
| options | 变长 | Magic Cookie 加 DHCP 选项 |
固定部分正好 236 字节,从第 236 字节开始是 4 字节 Magic Cookie(0x63825363),第 240 字节起才是选项区。Java 里处理这类二进制,有人喜欢 ByteBuffer,有人喜欢 ByteArrayOutputStream;我一般只用 byte[] 加 System.arraycopy,因为 DHCP 报文小且字段固定,ByteBuffer 的 position/limit 概念反而容易把初学者绕晕。
解析代码最关键的是处理无符号数。Java 的 byte 是有符号的,直接转 int 会带符号扩展。
public static DhcpPacket fromBytes(byte[] data, int len) { if (len < 240) throw new IllegalArgumentException("bad dhcp length"); DhcpPacket p = new DhcpPacket(); p.op = data[0]; p.htype = data[1]; p.hlen = data[2]; p.hops = data[3]; p.xid = ((data[4] & 0xff) << 24) | ((data[5] & 0xff) << 16) | ((data[6] & 0xff) << 8) | (data[7] & 0xff); p.secs = (short) (((data[8] & 0xff) << 8) | (data[9] & 0xff)); p.flags = (short) (((data[10] & 0xff) << 8) | (data[11] & 0xff)); p.ciaddr = new byte[4]; p.yiaddr = new byte[4]; p.siaddr = new byte[4]; p.giaddr = new byte[4]; p.chaddr = new byte[16]; System.arraycopy(data, 12, p.ciaddr, 0, 4); System.arraycopy(data, 16, p.yiaddr, 0, 4); System.arraycopy(data, 20, p.siaddr, 0, 4); System.arraycopy(data, 24, p.giaddr, 0, 4); System.arraycopy(data, 28, p.chaddr, 0, 16); if ((data[236] & 0xff) != 0x63 || (data[237] & 0xff) != 0x82 || (data[238] & 0xff) != 0x53 || (data[239] & 0xff) != 0x63) { throw new IllegalArgumentException("bad magic cookie"); } int pos = 240; while (pos < len) { int code = data[pos++] & 0xff; if (code == 0) { continue; // PAD } if (code == 255) { break; // END } int optLen = data[pos++] & 0xff; if (pos + optLen > len) { break; } byte[] value = new byte[optLen]; System.arraycopy(data, pos, value, 0, optLen); pos += optLen; p.options.put(code, value); } return p; }xid 是 32 位无符号整数,所以解析时每取一个字节都要& 0xff,再拼成 int。如果你把data[4] << 24直接写,遇到第 24 位是 1 的情况就会变成负数,虽然 int 的负数在字节层面仍然一致,但后续如果拿它和别的字段做逻辑判断,很容易踩坑。secs 和 flags 是 16 位,用 short 接收,实际使用时建议再& 0xffff。
2.3 最小选项集:Magic Cookie、53、50、51、1 这几个数字的意义
选项区是 DHCP 的灵魂。最小能跑通的选项只有几个:53 告诉对端报文类型,54 标明服务器身份,50 请求指定IP,51 宣告租约时长,1 给出子网掩码。再补一个 255 表示选项区结束。
| 选项 | 值 | 长度 | 使用场景 |
|---|---|---|---|
| DHCP Message Type | 53 | 1 | 所有报文 |
| Server Identifier | 54 | 4 | OFFER / ACK / NAK |
| Requested IP Address | 50 | 4 | REQUEST / DECLINE 时携带 |
| IP Address Lease Time | 51 | 4 | OFFER / ACK |
| Subnet Mask | 1 | 4 | OFFER / ACK |
有个规范容易被忽略:RFC 2131 要求 DHCP Message Type 必须放在选项区第一个。平时在华为交换机上查看 dhcp 配置时看到的 IP 池参数,最后落到协议里就是 option 51、option 1、option 54 这一串值。如果不动手抓包,很难意识到顺序错一字节,客户端会直接丢弃整个报文。
3. 用Java实现DHCP客户端:最小代码跑通一次租约申请
3.1 建立socket:端口68必须绑定,setBroadcast(true) 别漏
客户端要监听 68 端口,这样才能收到服务端从 67 端口发来的 OFFER 和 ACK。socket 必须绑定 0.0.0.0,而不是某个具体 IP,否则收不到发到 wildcard 地址的广播包。在多数操作系统上,往 255.255.255.255 发包前还必须显式打开广播权限。
DatagramSocket socket = new DatagramSocket(68, InetAddress.getByName("0.0.0.0")); socket.setBroadcast(true); socket.setSoTimeout(3000);三个参数里setSoTimeout(3000)最容易忽略。DHCP 客户端不能无限阻塞在 receive 上,3 秒没等到 OFFER 就要重新发 DISCOVER,一共重试 4 次左右。这个超时不是服务端要求的,而是客户端自己控制的重试节奏。如果把超时设成 0,系统就当成无限等待,客户端一旦错过一个包,可能整个获取流程就卡死。
3.2 组装Discover报文:xid、chaddr与option 53
组装报文用 ByteArrayOutputStream 比较顺手,因为里面全是顺序写入的字节。下面这个方法可以复用给多种报文:传入 msgType 决定是 DISCOVER 还是 REQUEST,requestedIp 和 serverId 按场景决定是否携带。
public byte[] buildMessage(int msgType, byte[] mac, byte[] requestedIp, byte[] serverId, int xid) { ByteArrayOutputStream os = new ByteArrayOutputStream(300); os.write(new byte[]{1, 1, 6, 0}); // op=1, htype=1, hlen=6, hops=0 writeInt(os, xid); // 4字节事务ID os.write(new byte[]{0, 0, (byte) 0x80, 0}); // secs=0, flags=0x8000 广播 os.write(new byte[16]); // ciaddr yiaddr siaddr giaddr byte[] chaddr = new byte[16]; System.arraycopy(mac, 0, chaddr, 0, 6); os.write(chaddr); // 客户端MAC os.write(new byte[64]); // sname os.write(new byte[128]); // file os.write(new byte[]{(byte) 0x63, (byte) 0x82, (byte) 0x53, (byte) 0x63}); os.write(new byte[]{53, 1, msgType}); // option 53 必须在第一个 if (serverId != null) { os.write(54); os.write(4); os.write(serverId); } if (requestedIp != null) { os.write(50); os.write(4); os.write(requestedIp); } os.write(255); // END return os.toByteArray(); } private void writeInt(ByteArrayOutputStream os, int v) { os.write((v >>> 24) & 0xff); os.write((v >>> 16) & 0xff); os.write((v >>> 8) & 0xff); os.write(v & 0xff); }writeInt是这段代码的细节核心:先把高位右移到低字节,再& 0xff截断。如果你直接把 int 写出去,一个 xid 会变成 4 字节没问题,但如果是负的,第二个字节是全部 1?实际上os.write(int)只写低8位,循环四次仍然能写出完整的 32 位。我习惯写成上面的形式,逻辑更直白,也避免看代码的人误以为写的是 4 个一样的内容。
组装完 DISCOVER 后,发送目标地址是 255.255.255.255 的 67 端口。有些系统上全广播可能无法穿越所有网段,更稳的做法是枚举本机所有网卡,向每个子网广播地址都发一份。对于最小实现,先发 255.255.255.255 足够。
3.3 接收Offer再回Request:解析Server Identifier的坑
发送完 DISCOVER,客户端进入等待。收到包后不能直接当 OFFER 用,必须校验两件事:op 字段等于 2,且 xid 与刚才发出的一致。否则就可能被网络里其他 DHCP 噪声干扰。
byte[] disc = buildMessage(1, mac, null, null, xid); socket.send(new DatagramPacket(disc, disc.length, InetAddress.getByName("255.255.255.255"), 67)); long start = System.currentTimeMillis(); while (System.currentTimeMillis() - start < 60_000) { try { DatagramPacket resp = new DatagramPacket(new byte[1500], 1500); socket.receive(resp); DhcpPacket pkt = DhcpPacket.fromBytes(resp.getData(), resp.getLength()); if (pkt.op != 2 || pkt.xid != xid) { continue; } int msgType = pkt.getOption(53)[0] & 0xff; if (msgType == 2) { byte[] serverId = pkt.getOption(54); byte[] offeredIp = pkt.yiaddr; byte[] req = buildMessage(3, mac, offeredIp, serverId, xid); socket.send(new DatagramPacket(req, req.length, InetAddress.getByName("255.255.255.255"), 67)); } else if (msgType == 5) { long lease = readUnsignedInt(pkt.getOption(51)); System.out.println("Ack, lease=" + lease); break; } } catch (SocketTimeoutException e) { socket.send(new DatagramPacket(disc, disc.length, InetAddress.getByName("255.255.255.255"), 67)); } }这里有个隐蔽问题:pkt.getOption(53)[0]拿到的是 byte,直接拿来当 int 比较,值超过 127 就变负数。ACK 是 5 没这个问题,但 NAK 是 6,DHCPRELEASE 是 7,以后会遇上。所以稳妥写法是[0] & 0xff。解析 Server Identifier 时,不要把它当字符串读,直接保留 4 字节 byte[],回 REQUEST 时原样填回去,这样能避免 IP 字符串和 byte 数组互转带来的格式错误。
4. 用Java实现DHCP服务端:把源码变成能分配地址的内网DHCP服务器
4.1 主循环与线程池:一个DatagramSocket处理并发请求
服务端相对简单:绑定 67 端口,循环 receive,每收到一个报文就丢给线程池处理。DHCP 请求体积小、频率不高,一台内网服务器用 4 个线程足够。不要每来一个包就 new Thread,网卡一出故障风暴,线程数会先打爆。
public class DhcpServer implements Runnable { private final DatagramSocket socket; private final ExecutorService pool; private volatile boolean running = true; public DhcpServer(int port) throws SocketException { this.socket = new DatagramSocket(port); this.pool = Executors.newFixedThreadPool(4); } @Override public void run() { while (running) { byte[] buf = new byte[1500]; DatagramPacket packet = new DatagramPacket(buf, buf.length); try { socket.receive(packet); pool.submit(() -> handle(packet)); } catch (Exception e) { if (running) { e.printStackTrace(); } } } } }线程池的线程数不是越大越好。DHCP 处理主要是内存操作和可能的数据库写入,4 个线程在千级设备的内网绰绰有余。如果后面接了租约持久化,考虑把写库操作改成异步队列,不要让 socket 线程阻塞在 I/O 上。
4.2 IP地址池与租约表:用ConcurrentHashMap做最小实现
服务端最少需要两张表:一张记录“IP 被谁占用”,一张记录“这个 MAC 已分配的 IP”。前者用于分配,后者用于续租和避免一个客户端反复拿新地址。
public class Lease { final String ip; final byte[] mac; final long expireAt; Lease(String ip, byte[] mac, long expireAt) { this.ip = ip; this.mac = mac; this.expireAt = expireAt; } } private final Map<String, Lease> ipLeaseMap = new ConcurrentHashMap<>(); private final Map<String, String> macIpMap = new ConcurrentHashMap<>();分配 IP 时,先查 macIpMap,如果这个 MAC 已有租约且没过期,直接返回原 IP。没有的话,从地址池顺序扫描,找到第一个不在 ipLeaseMap 里的 IP。注意 ConcurrentHashMap 只能保证单次操作的原子性,“检查再写入”这两步仍然要加锁,否则两个请求同时扫描同一段地址池,会分出去同一个 IP。
private synchronized String allocate(byte[] chaddr) { String macKey = new String(chaddr, 0, 6, StandardCharsets.ISO_8859_1); String existing = macIpMap.get(macKey); if (existing != null && !isExpired(existing)) { return existing; } for (String ip : pool) { Lease lease = ipLeaseMap.get(ip); if (lease == null || lease.expireAt < System.currentTimeMillis()) { ipLeaseMap.put(ip, new Lease(ip, chaddr, System.currentTimeMillis() + leaseMs)); macIpMap.put(macKey, ip); return ip; } } return null; }synchronized在这里简单有效,粗暴但够用。初始化地址池时,把网段里所有可用 IP 放进一个 List,启动时打乱顺序,可以减少相邻设备总拿连续 IP 的规律性。但生产环境我不建议完全随机,按网段分段更好排查,哪个交换机下挂了哪段地址,日志里一眼能看出来。
4.3 回复该走广播还是单播:flags位、giaddr和ciaddr的优先级
很多第一次实现 DHCP 服务端的人会想:客户端从哪发来,我就把 OFFER 回给哪。这在 DHCP 里不完全正确,尤其是跨网段场景。回复目标的决策顺序有优先级:
private DatagramPacket wrapReply(DhcpPacket req, DhcpPacket reply) { InetAddress target; int port = 68; if (!isZero(req.giaddr)) { target = toInetAddress(req.giaddr); port = 67; // giaddr 非0,回给中继代理 } else if (!isZero(req.ciaddr)) { target = toInetAddress(req.ciaddr); } else if ((req.flags & 0x8000) != 0) { target = InetAddress.getByName("255.255.255.255"); } else { target = toInetAddress(reply.yiaddr); } byte[] out = reply.toBytes(); return new DatagramPacket(out, out.length, target, port); }giaddr 优先是因为中继场景下,客户端地址对服务器不可见,必须让中继代理转发回客户端所在网段。ciaddr 第二是因为续租阶段客户端已经有 IP,单播即可。广播位第三是因为客户端在刚开机拿地址时,自己还没有 IP,只能靠广播收 OFFER;如果 flags 里 0x8000 没置位,最后才尝试单播到 yiaddr。
实际发送时还要注意,服务端设置 reply.yiaddr 为分配 IP,并且 option 54 填服务端自身 IP。如果这两处是空的,客户端就算收到 OFFER,也不知道该信谁。
4.4 绑定网卡:避免在多网卡机器上回复错口
单网卡开发机跑通后,一上多网卡服务器就翻车是常态。原因在于 Java 的 DatagramSocket 绑定 0.0.0.0 能收到包,但发到 255.255.255.255 时,出网卡由内核路由表决定。如果服务器有两张网卡,一张接办公网、一张接设备网,你的 OFFER 很可能从办公网口发出去,设备网客户端永远等不到。
常见做法有两种。第一种是创建多个 DatagramSocket,每个绑定一个具体网卡 IP,并开启 SO_REUSEADDR,让每个 socket 各管一个网段;第二种是绕开全广播,改为向每个网卡的子网定向广播地址发送。第二种代码更省:
for (NetworkInterface ni : Collections.list(NetworkInterface.getNetworkInterfaces())) { for (InterfaceAddress ia : ni.getInterfaceAddresses()) { InetAddress bcast = ia.getBroadcast(); if (bcast != null) { // 向该网卡的子网广播地址发送 OFFER,而不是 255.255.255.255 } } }我一般优先用第二种,因为它让“哪个请求从哪个网段来”这件事变得可控。缺点是如果网卡频繁变更,枚举结果会不稳定,这时再退回多 socket 方案。手写时把这块逻辑单独抽成BroadcastTargetProvider,会好测试很多。
5. 避坑指南:Java实现DHCP最常见的5个翻车现场
5.1 现象:客户端一直收不到OFFER
抓包能看到客户端在持续发 DISCOVER,服务端也收到了,但 OFFER 就是回不到客户端。最常见原因是广播权限没开。Linux 上如果不调用setBroadcast(true),send 到 255.255.255.255 会直接抛 Network is unreachable。另一个原因是客户端 socket 绑了具体 IP 而不是 0.0.0.0,广播包到了机器,却没有进程监听在 68 端口。还有第三方防火墙会拦 UDP 68,尤其 Windows 自带防火墙偶尔会弹窗,点阻止之后所有 DHCP 包都被丢弃。
解决顺序很固定:先确认服务端监听地址是 0.0.0.0:67,再确认客户端监听 0.0.0.0:68,最后检查防火墙。抓包时不必看太多过滤条件,udp port 67 or udp port 68就够。
5.2 现象:OFFER拿到了,但REQUEST石沉大海
这种问题比收不到 OFFER 更隐蔽,因为第一跳看起来成功了。客户端能收到 OFFER,说明广播路径没问题;REQUEST 发出去没响应,通常是 REQUEST 里没带 option 54。服务端收到一个 REQUEST,如果它不知道客户端是不是在应答自己,最低安全做法就是保持沉默。有些实现会回 NAK,有些直接丢弃。
解决方法是严格对照抓包:OFFER 里必然有 Server Identifier,REQUEST 里的 option 54 必须和它完全一致。还有一点,服务端校验 xid 时,如果解析代码里少了& 0xff,可能把同一个 xid 解析成不同值,握手也会断。
5.3 现象:Windows能通但Linux不行,或反过来
常见原因是 option 53 没有放在选项区第一个。Windows 对顺序容忍度高,dhclient 则更严格,会直接丢弃不符合规范的报文。我早期做过一版,把 option 51 写在 53 前面,Windows 正常,Linux 下dhclient -v一直报 no offer。后来把选项中 53、54、50 的顺序逐字节对了一遍,才找到问题。
解决就是把 option 53 固定为第一个选项,后面写 54、50、51 都不要紧。同时建议测试时用两套客户端:Windows 的ipconfig /release && ipconfig /renew,Linux 的dhclient -r -v和dhclient -v,两个都通才算真的兼容。
5.4 现象:地址池越分越乱,租约到期不回收
只实现了 allocate,没实现回收,跑一天之后池子耗尽或者同一个设备拿了好几个 IP。原因在于 ipLeaseMap 里的 Lease 有 expireAt,但没有定时任务去扫;macIpMap 里也没有过期概念,客户端 release 后映射还在。
解决方法是加一个后台线程,每 30 秒遍历 ipLeaseMap,删除expireAt < now的租约,同时把 macIpMap 对应条目删掉。收到 DHCPRELEASE 报文时立刻删对应 IP。真正生产环境还要处理租约续租:客户端在 T1 时间点会广播 REQUEST,服务端找到已有的 Lease 并延长 expireAt,而不是当新请求再分一个 IP。
5.5 现象:跨网段/中继场景全灭
单网段跑通之后,把服务端放在总部,想让交换机通过 DHCP Relay 给三个新网段发地址,结果全灭。原因基本都出在 giaddr 上。中继把客户端的 DISCOVER 转发到服务器时,会把 giaddr 填成自己所在网段的接口 IP,服务器如果还按客户端 socket 地址回包,包到了中继却不知道往哪个客户端转发,因为中继监听的是 67 端口,不是客户端的 68。
解决方法是严格按照 4.3 的优先级判断:giaddr 非 0 时,回复目标改为 giaddr,端口改为 67。验证方法是在华为交换机上配置dhcp relay后抓包,你会发现服务端发出的 OFFER 不再是广播,而是单播给中继的 giaddr。地址池也要按 giaddr 分区,不能所有网段共用同一个池,否则中继上来的请求永远拿不到本网段地址。
6. 把示例源码做成生产可用的DHCP服务:租约持久化与中继转发的三个改进
如果只是学习,前面五章足够跑通整个协议。真要放到内网用,下面三个改进我认为缺一不可。
第一个改进是租约持久化。纯内存的租约表一重启就丢,客户端那边租约还没到期,服务端却把 IP 分给了别人,地址冲突立刻爆发。最轻量的做法是每次分配时往本地文件追加一行IP,MAC,expireAt,启动时读回内存。写入用 append,不要用覆写;如果想做得更干净,先写临时文件再 rename,避免断电毁文件。不建议第一版就上 JPA,一张租约表没必要引入一套 ORM。
第二个改进是真正支持中继。这也解答了“一个 DHCP 服务器能不能给几个网段发地址”的问题——能,但要按 giaddr 区分地址池。我通常用Map<InetAddress, List<String>>把 giaddr(中继接口 IP)映射到对应网段地址池,OFFER 和 ACK 时根据 req.giaddr 选池。服务端还需要额外处理一个细节:中继场景下,OFFER 的 siaddr 和 option 54 应该是客户端网段里真正能到达的服务器地址,不能随手填一个管理网 IP。
第三个改进是加一层安全控制。内网设备经常要求白名单入网,服务端可以在 handle 入口处查 MAC 是否在预置名单里,不在就忽略或回 NAK。MAC 的 16 字节 chaddr 里只有前 6 字节是有效地址,比对时要Arrays.copyOf(chaddr, 6),否则后面的全 0 会把两个不同厂商的网卡误判成相同设备。把这套源码收进源代码管理之前,记得把租约文件和日志目录加入 ignore 列表,避免每次提交都带上一堆运行期数据。
我第一次把完整服务端跑通时,就是在 option 53 顺序上栽了跟头。后来每次改协议相关代码,我都先抓包,再对照十六进制字节看,绝不凭感觉。DHCP 这个协议并不复杂,但它的容错率低到令人咬牙切齿——一个字节错位,客户端可能连一句报错都不给,只会静默丢弃。希望你动手时,能把“先抓包、再改代码”这个习惯也带走。希望帮到你。
本文还有配套的精品资源,点击获取