简介:这套源码基于Java语言实现了DHCP协议服务端与客户端的核心交互流程,完整覆盖发现、提供、请求、确认四个阶段,并处理了广播地址、UDP端口及BOOTP报文格式等关键细节,适合对网络协议和Java网络编程感兴趣的开发者学习。资源共含84个文件,以16个Java源文件为骨干,实现UDP通信、IP地址池管理、配置信息存储与多线程并发处理;另有64个Javadoc生成的HTML文档,按包和类清晰展示接口设计,配套的样式表、属性文件及索引文件让本地阅读体验更加顺畅。整个压缩包仅186KB,轻量而完整,既可作为课程设计或技术研究的起点,也能为自研网络管理工具提供借鉴。通过源码与文档对照,可以掌握DHCP数据包编码与解码、服务器端线程安全设计、异常处理等实用思路,这些正是综合性网络编程项目的核心难点。目前已有632人学习下载,是一份兼顾协议原理与工程实现的参考资源。
1. 为什么需要一份能直接改的 DHCP Java 源代码
在做网络设备管理平台,要给内网设备自动分配 IP,这类人搜“DHCP Java 源代码”时往往带着具体诉求:能看懂、能改、还能跑在 Java 后端,而不是拿个 C 语言的嵌入式实现硬套。这份源码资源把 DHCP 协议从报文字节落到完整状态机,包含报文解析、选项编码、客户端和服务端的核心交互链路。
选 Java 写 DHCP 的一个现实原因是调试透明:不用跟指针较劲,UDP Socket API 直接处理广播包和单播包,抓包和日志一对就能定位问题。同时编译产物可以直接集成进 Spring Boot、Netty 这类工程,不额外引入跨平台编译的负担,非常适合做网络课程设计、物联网网关后台,或者需要内嵌 DHCP 能力的网络管理项目。
下面按报文模型、状态机、选项与租约、排错、验证五段展开,中间穿插源码包里的类设计思路和踩坑记录,能抄的代码直接抄,参数含义我会逐个解释。
2. DHCP 报文模型与 Java 数据载体:先拆清字节再写交互逻辑
2.1 为什么选 Java 写 DHCP:省掉的三类复杂度
DHCP 实现选什么语言,本质是场景问题。如果是嵌进路由器和交换机固件,那 C 语言加原始套接字是绕不开的;但如果是放到 Java 后台工程里,或者只是为了把动态地址分配这套协议吃透,用 Java 反而省掉不少麻烦。
Java 标准库刚好正面覆盖 DHCP 的两项关键能力。第一项是 UDP 通信,java.net.DatagramSocket直接绑定 67、68 两个端口收发字节数组,不需要调用系统原生库。第二项是字节操作,java.nio.ByteBuffer自带边界检查,读指针越界直接抛异常,不像 C 语言那样把内存读穿后才在某个角落炸出来。对 DHCP 这种“固定头 + 可变选项区”的报文,ByteBuffer 天然比“指针 + 长度手动计算”安全。
源码包里的类围绕两个对象展开:DhcpPacket承载报文,DhcpOption承载选项。解析报文、构造应答、记录租约都在这两个对象上操作,后面的例子也沿用这套命名。
2.2 固定头 236 字节:对照表与边界判断
DHCP 报文固定头从 BOOTP 继承而来,总共 236 字节,之后再进入选项区。下面这张表是实现时逐字节拆包的基础,字段顺序不能乱,偏移算错一个,后面全部错位。
| 字段 | 偏移 | 长度 | 含义 |
|---|---|---|---|
| op | 0 | 1 | 1 表示客户端请求,2 表示服务端应答 |
| htype | 1 | 1 | 硬件类型,1 表示以太网 |
| hlen | 2 | 1 | 硬件地址长度,以太网 MAC 是 6,PPPoE 是 8 |
| hops | 3 | 1 | 经过 DHCP 中继时的跳数,直连场景填 0 |
| xid | 4 | 4 | 事务 ID,客户端每次请求随机生成 |
| secs | 8 | 2 | 客户端开始获取地址以来的秒数 |
| flags | 10 | 2 | 第 15 位为 1 时要求服务器广播应答 |
| ciaddr | 12 | 4 | 客户端已知自己的 IP 时才非 0 |
| yiaddr | 16 | 4 | 服务器打算分配给客户端的 IP |
| siaddr | 20 | 4 | 服务器 IP |
| giaddr | 24 | 4 | 中继代理 IP,直连场景为 0 |
| chaddr | 28 | 16 | 客户端 MAC 地址 |
| sname | 44 | 64 | 服务器名,可选,不用则全 0 |
| file | 108 | 128 | 启动文件名,可选,不用则全 0 |
| options | 236 | 可变 | 前 4 字节固定为 magic cookie |
这表里最容易混淆的是ciaddr和yiaddr。ciaddr是客户端在已知自己 IP 时填的,比如续租阶段;yiaddr是服务器在 OFFER 和 ACK 阶段填的分配地址。解析时千万别把两个 4 字节字段读反,读反之后租约表里绑定的 IP 就全是乱的。
2.3 从字节到对象:DhcpPacket 解析方法与无符号陷阱
解析代码放在DhcpPacket.parse(byte[])静态方法里,入参是 UDP 收上来的完整报文数据,返回一个填充好字段的对象,解析失败直接抛异常。
import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.util.ArrayList; import java.util.List; public class DhcpPacket { public static final int FIXED_HEADER_LEN = 236; public static final int MAGIC_COOKIE = 0x63825363; public static final int PORT_SERVER = 67; public static final int PORT_CLIENT = 68; private byte op; private byte htype; private byte hlen; private byte hops; private int xid; private short secs; private short flags; private byte[] ciaddr = new byte[4]; private byte[] yiaddr = new byte[4]; private byte[] siaddr = new byte[4]; private byte[] giaddr = new byte[4]; private byte[] chaddr = new byte[16]; private byte[] sname = new byte[64]; private byte[] file = new byte[128]; private List<DhcpOption> options = new ArrayList<>(); public static DhcpPacket parse(byte[] data) throws Exception { if (data.length < FIXED_HEADER_LEN + 4) { throw new IllegalArgumentException("报文长度不足,固定头和 magic cookie 都不够"); } ByteBuffer bf = ByteBuffer.wrap(data).order(ByteOrder.BIG_ENDIAN); DhcpPacket pkt = new DhcpPacket(); pkt.op = bf.get(); pkt.htype = bf.get(); pkt.hlen = bf.get(); pkt.hops = bf.get(); pkt.xid = bf.getInt(); pkt.secs = bf.getShort(); pkt.flags = bf.getShort(); bf.get(pkt.ciaddr); bf.get(pkt.yiaddr); bf.get(pkt.siaddr); bf.get(pkt.giaddr); bf.get(pkt.chaddr); bf.get(pkt.sname); bf.get(pkt.file); int cookie = bf.getInt(); if (cookie != MAGIC_COOKIE) { throw new IllegalArgumentException("magic cookie 不匹配,收到的不像是 DHCP 报文"); } while (bf.hasRemaining()) { int tag = bf.get() & 0xff; if (tag == 0) { continue; // 填充字节,直接跳过 } if (tag == 255) { break; // 结束标记 } if (bf.remaining() < 1) { break; } int len = bf.get() & 0xff; if (len > bf.remaining()) { break; // 长度越界,按截断处理 } byte[] value = new byte[len]; bf.get(value); pkt.options.add(new DhcpOption(tag, value)); } return pkt; } }这段代码有两个容易写错的位置。第一是ByteBuffer要显式设置ByteOrder.BIG_ENDIAN,DHCP 报文头所有多字节字段都是网络字节序,虽然 Java 默认也是大端,但写明之后换到别的报文格式时不会惯性出错。第二是选项区的tag和len必须按无符号读,因为 Java 的byte是带符号的,如果tag = 255直接拿来判断,在 Java 里它其实是-1,和break的条件对不上,循环就把结束标记当普通选项处理,后面全乱套。
提示:
data.length < 240这种判断别省掉。UDP 收到的不一定是 DHCP,有可能长度只有几十字节的杂包,不先挡掉这一层,后面ByteBuffer.getInt()会抛BufferUnderflowException,而且异常栈不好排查。
3. 客户端与服务端状态机:从 DISCOVER 到 ACK 的核心链路
3.1 四报文交互链路与状态转换
DHCP 分配地址不是一问一答,而是四个报文串起来的完整链路。客户端广播 DISCOVER 寻找网络里的服务器,所有收到广播的服务器各自预留地址并回应 OFFER,客户端挑其中一台,再广播 REQUEST 确认“我要用这台给的地址”,被选中的服务器回 ACK,其余服务器释放预留。
客户端侧状态机用枚举管理最直观:
public enum DhcpClientState { INIT, // 初始,准备发 DISCOVER SELECTING, // 已发 DISCOVER,等待 OFFER REQUESTING, // 已发 REQUEST,等待 ACK BOUND, // 已拿到地址,正常运行 RENEWING, // T1 时间到,单播续租 REBINDING // T2 时间到,广播续租 }每个状态对应一套处理逻辑,状态转换的触发条件是收到某个报文或某个定时器到期。把时序画成代码就是:INIT收到 OFFER 进REQUESTING,REQUESTING收到 ACK 进BOUND,BOUND的 T1 计时器触发进RENEWING,T2 触发进REBINDING,续租失败重新回到INIT。
3.2 服务器端 UDP 监听循环:绑定地址和 XID 匹配
服务器端的主循环是阻塞收包,收到一个请求就按消息类型分发处理。这里有个关键参数:DatagramSocket要绑定0.0.0.0:67,而不是绑到本机某个具体网卡的 IP。服务器经常有多个网卡,绑定具体 IP 会漏掉从其他网卡进来的广播包。
import java.net.DatagramSocket; import java.net.DatagramPacket; import java.net.InetAddress; import java.util.Arrays; import java.util.concurrent.ConcurrentHashMap; import java.util.Map; public class DhcpServer implements Runnable { private final Map<Integer, String> offerCache = new ConcurrentHashMap<>(); private volatile boolean running = true; @Override public void run() { try (DatagramSocket socket = new DatagramSocket(DhcpPacket.PORT_SERVER, InetAddress.getByName("0.0.0.0"))) { System.out.println("DHCP server listening on udp/67"); while (running) { byte[] buf = new byte[576]; DatagramPacket req = new DatagramPacket(buf, buf.length); socket.receive(req); byte[] raw = Arrays.copyOf(req.getData(), req.getLength()); DhcpPacket pkt = DhcpPacket.parse(raw); DhcpOption typeOpt = findOption(pkt.getOptions(), 53); if (typeOpt == null || typeOpt.getValue().length != 1) { continue; } int msgType = typeOpt.getValue()[0] & 0xff; switch (msgType) { case 1: // DISCOVER handleDiscover(socket, pkt); break; case 3: // REQUEST handleRequest(socket, pkt); break; case 7: // RELEASE handleRelease(pkt); break; default: break; } } } catch (Exception e) { e.printStackTrace(); } } private DhcpOption findOption(List<DhcpOption> options, int tag) { for (DhcpOption opt : options) { if (opt.getTag() == tag) { return opt; } } return null; } }单线程同步处理只适合演示和低负载场景,真实部署时socket.receive之后应该丢给线程池处理,但要注意DatagramSocket的receive和send不是线程安全的,要并发就得每个工作线程各自持有独立的 socket,或者加锁,这不是玄学,是网上很多并发版 DHCP 服务器翻车的地方。
XID 匹配是整个交互能对上的基础。客户端发出 DISCOVER 时生成一个随机xid,服务器回 OFFER 时xid原样带回,等客户端广播 REQUEST 时,xid还是同一个。我实际用ConcurrentHashMap<Integer, String> offerCache记录这个关系,key是xid,value是预留的 IP 字符串,服务器收到 REQUEST 后先查这张表,查不到就直接 NAK。
注意:OFFER 只是预留,不是确认。如果客户端广播了 DISCOVER,服务器回了 OFFER,但客户端随后没发 REQUEST,这个预留地址必须能释放。最简单的做法是给
offerCache存一个带过期时间的条目,过期自动删除,否则地址池很快被“跑路”的 OFFER 占满。
4. 选项编解码与租约管理:最容易翻车的两块逻辑
4.1 选项是 TLV 结构:编解码方法别按数组操作写
DHCP 选项区是典型的 TLV 编码,Type-Length-Value,一个接一个排列。选项 53 是消息类型,一个字节;选项 54 是服务器标识,四个字节的 IP;选项 51 是租约时长,四个字节的秒数。写编码的时候最容易犯的错是不按长度算,直接把整个字节数组怼进去。
public class DhcpOption { public static final int OPTION_SUBNET_MASK = 1; public static final int OPTION_ROUTER = 3; public static final int OPTION_DNS = 6; public static final int OPTION_REQUEST_IP = 50; public static final int OPTION_LEASE_TIME = 51; public static final int OPTION_MESSAGE_TYPE = 53; public static final int OPTION_SERVER_ID = 54; public static final int OPTION_CLIENT_ID = 61; private int tag; private byte[] value; public void writeTo(java.nio.ByteBuffer bf) { if (tag == 0 || tag == 255) { bf.put((byte) tag); return; } bf.put((byte) tag); bf.put((byte) value.length); bf.put(value); } public static int readInt(byte[] v) { if (v.length != 4) { return 0; } return ((v[0] & 0xff) << 24) | ((v[1] & 0xff) << 16) | ((v[2] & 0xff) << 8) | (v[3] & 0xff); } }选项编码有一个边界要盯死:value.length强制转成byte的时候,如果长度超过 255 就会被截断。DHCP 选项标准规定单条选项长度最大 255,所以读入时发现长度超大,优先怀疑报文被污染或解析偏移错位,而不是硬着头皮继续处理。
一个实际场景:服务器组装 ACK 报文时,选项区里至少要包含消息类型(53)、服务器标识(54)、子网掩码(1)、租约时长(51),有需要再加默认网关(3)和 DNS(6)。组装顺序通常把 53 放最前面,因为很多 DHCP 客户端实现是顺序解析选项,遇到 53 才知道当前报文应当按哪种行为处理。
4.2 租约仓库:内存表、过期扫描与冲突检测
租约是整个 DHCP 服务器的核心数据,谁在什么时间拿了哪个 IP、什么时候到期,全都记录在案。内存实现用两张ConcurrentHashMap互相索引,一张按 MAC 查租约,一张按 IP 查租约。
public class LeaseRepository { private final Map<String, Lease> byMac = new ConcurrentHashMap<>(); private final Map<String, Lease> byIp = new ConcurrentHashMap<>(); public Lease offer(String mac, String ip) { long now = System.currentTimeMillis(); Lease lease = new Lease(mac, ip, now, now + 3600_000L); byMac.put(mac, lease); byIp.put(ip, lease); return lease; } public Lease getByMac(String mac) { return byMac.get(mac); } public Lease getByIp(String ip) { return byIp.get(ip); } public void release(String mac) { Lease lease = byMac.remove(mac); if (lease != null) { byIp.remove(lease.getIp()); } } public void reapExpired() { long now = System.currentTimeMillis(); byMac.values().removeIf(l -> l.getExpireAt() < now); } }内存租约表最大的坑是双 Map 一致性。只删了byMac不删byIp,地址池里这个 IP 就永远不可用,冲突检测的时候还查得到它。release方法里先移byMac,拿到刚移除的租约再去删byIp,这个顺序不能反,如果先删byIp再删byMac,万一byMac.remove返回空,byIp那条就成了孤儿数据。
地址分配前的冲突检测是另一个绕不开的点。常见做法是分配前先getByIp判断租约表里有没有占用,但只查表不够,还要用 ICMP Echo 探测一下目标 IP 是否有设备响应。注意这个探测要用短超时,一般 500 毫秒以内,探测时间过长会让 DISCOVER 的应答延迟到客户端超时,用户感知就是“分配 IP 慢”。
提示:租约时长按秒为单位存在选项 51 里,
Lease对象里的expireAt用毫秒时间戳,转换时别把单位搞混。我曾经在一个项目里把选项 51 直接当成毫秒写进去,结果客户端拿到 1 秒租约,不到一秒就续租,日志刷屏,排查半天才发现是单位问题。
5. DHCP 实现避坑指南:五个让我熬夜的翻车现场
5.1 报文解析阶段的坑:偏移量、无符号、magic cookie
先说第一个坑,现象是抓包工具里看 DHCP 请求完全正常,代码一解析就抛“magic cookie 不匹配”。原因很朴素:options 区紧跟在固定头之后,固定头是 236 字节,而 magic cookie 占了 options 区的前 4 字节。我最初解析时没跳过那 4 个字节,直接把 offset 236 处当成选项的 tag,取到的当然不是合法的0x63825363,后面自然全乱。解决方式是严格按长度校验,先判断data.length >= 240,再读前 4 字节比对 cookie,之后再进入 TLV 循环。
第二个坑,解析出来的选项 50(请求 IP)值一直和抓包结果对不上。原因是 Java 的byte有符号,直接从字节数组里拿tag和len不转无符号,遇到 128 以上的值就变负数,长度判断和后续的new byte[len]会抛NegativeArraySizeException。解决方式已经写进前面的代码:int tag = bf.get() & 0xff,int len = bf.get() & 0xff,这个& 0xff不是可有可无,是必须要的。
第三个坑在hlen字段上。默认写 6 是标准以太网 MAC 长度,解析时按 6 字节从chaddr里取 MAC 绑定租约表。但如果客户端走的是 PPPoE,hlen是 8,你按 6 取就会把 MAC 取错,租约表里绑定的地址全是残缺的。解决方式是按hlen动态读取,不要写死,读完之后再判断一个合法区间,6 到 16 之间才继续处理。
5.2 交互与租约阶段的坑:广播、并发、重启恢复
第四个坑,服务器端回 OFFER 和 ACK 时目标地址选错。现象是客户端能发 DISCOVER,却永远收不到服务器的回应,Wireshark 里能看到请求包,看不到应答包。原因是响应包被发到了单播地址,而客户端此时还没拿到 IP,根本收不到。正确逻辑要看请求里的flags字段,第 15 位为 1 时响应必须走广播255.255.255.255:68,只有客户端在续租阶段有了ciaddr,才能安全走单播。我一般的做法是统一走广播,牺牲一点网络流量换兼容性,单播优化留到性能测试阶段再做。
第五个坑,服务器重启后租约丢失,全网设备短时间集体断线重连。内存租约表在进程退出那一刻就清空了,重启后地址池认为所有地址空闲,但终端上还保留着旧 IP,直到它们发起续租被服务器 NAK,再重新走完整流程。解决方式是租约持久化,最简单的是把租约记录定期序列化到本地文件,启动时加载回内存。加载时要重新检查一遍过期时间和冲突,不要盲目信任旧数据。
注意:客户端 DHCP 续租失败不会立刻崩,它会等 T2 计时器到期后重新广播 DISCOVER,整个过程持续约 1 分半。所以排查“重启后断网”时,别盯着应用日志猛看,先确认租约持久化是否真的加载进去了。
6. 验证与进阶:用 Wireshark 和虚拟网卡把实现打磨到敢上线
6.1 抓包确认四报文顺序
验证 DHCP 实现最直接的方式就是抓包。Wireshark 里过滤器直接输bootp,DHCP 的协议名在抓包里沿用 BOOTP 的称呼,所有 DISCOVER、OFFER、REQUEST、ACK 报文都会列出来。想只看消息类型,可以用bootp.option.type == 53配合bootp.option.value来过滤。
一次干净的抓包,必须看到四步走的完整链路,顺序不能乱:
| 序号 | 报文方向 | 关键字段 |
|---|---|---|
| 1 | 客户端 → 广播 | op=1,xid=随机值,option 53=1(DISCOVER) |
| 2 | 服务器 → 广播/单播 | op=2,xid 与请求一致,option 53=2(OFFER) |
| 3 | 客户端 → 广播 | op=1,xid 仍一致,option 53=3(REQUEST),带 option 50 请求地址 |
| 4 | 服务器 → 广播/单播 | op=2,xid 不变,option 53=5(ACK) |
看抓包的时候重点核对两件事:第一是 OFFER 和 ACK 的xid是否和 DISCOVER 一致,不一致就是服务器端写死了一个全局固定 xid,这在多客户端环境下会互相串台。第二是 ACK 报文的yiaddr是否和 REQUEST 里 option 50 请求的地址一致,不一致客户端会拒收然后重新走流程,表现就是抓包里有大量重复的 DISCOVER。
6.2 用虚拟环境做回归测试
我用 VirtualBox 的 host-only 网络做回归测试:关闭 VirtualBox 自带 DHCP,把 Java DHCP 服务器跑在宿主机,虚拟机的网卡改成 DHCP 自动获取,然后反复刷新租约验证稳定性。脚本化之后,连续跑 100 次请求,统计 ACK 成功率:
#!/bin/bash # 连续 100 次续租, 统计 DHCPACK 次数, 低于 100 说明有丢包 success=0 for i in $(seq 1 100); do sudo dhclient -r ens38 2>/dev/null sudo dhclient -1 ens38 2>&1 | grep -q "DHCPACK" && success=$((success + 1)) done echo "success: $success/100"这个脚本的意义在于暴露时序类问题:删除旧租约、重新申请、服务器端释放旧租约,这三个动作之间只要有一个没处理好,成功率就达不到 100。我在这条命令上吃过亏,所以从那以后每次实现完 DHCP,都强制走一遍“重启服务 → 清客户端租约 → 连续触发 100 次获取 → 核对租约表里每一行过期时间”的流程,跑通了才敢说这个实现能交付。希望帮到你。
本文还有配套的精品资源,点击获取