网络里每台设备想上网第一件事不是打开浏览器而是先拿到一个合法的IP地址。这个动作背后站着的就是DHCP服务器一个平时几乎没人注意、一旦挂掉整个局域网立刻瘫痪的基础设施。我用Python写过好几个版本的DHCP服务端从最初只能应付实验环境到后来能在小型办公网里稳定跑上几个月中间踩的坑比想象中多得多。这个标题看着像教科书章节实际上手才知道协议字段的位运算、租约状态机、广播包的收发方式每一项都能让人卡半天。这篇内容适合两类人一是正在学网络编程、想搞懂DHCP报文到底长什么样的开发者二是手头有个小网络、想用Python搭个轻量DHCP服务做实验或临时顶替的运维。我会把协议结构、报文构造、地址池管理、租约机制、抓包排查这些环节全部拆开讲代码可以直接抄参数计算过程也写清楚读完你至少能自己跑起一个能分配地址的DHCP服务端。1. 为什么用Python手写DHCP服务端1.1 现成方案那么多自己写图什么先说清楚一件事生产环境里没人会拿Python手写的DHCP服务去替代正规的DHCP服务软件。真正让我动手写的原因有三个。第一是学习价值DHCP报文是典型的定长头部加变长选项Options结构把它的二进制布局搞清楚等于把网络协议里“大端序、位域、TLV编码”这几个概念一次性打通后面再看DNS、SNMP这些协议会轻松很多。第二是可控性实验环境里经常需要精确控制分配行为比如按MAC前缀分不同网段、给特定设备固定地址、模拟地址池耗尽现成软件要么配置繁琐要么根本不支持自己写几十行就能改。第三是排查辅助线上DHCP出问题时抓包看到的是十六进制流如果自己构造过一遍报文看字段位置会快很多。这里要区分两个概念DHCP服务端和DHCP中继。服务端负责从地址池里挑地址、发租约中继负责把跨网段的请求转发过去。本文只讲服务端而且是最核心的四个报文交互DISCOVER、OFFER、REQUEST、ACK。至于RELEASE、DECLINE、INFORM这些属于补充流程先把主干跑通再说。1.2 整体架构一个UDP服务加一个地址池DHCP跑在UDP之上服务端监听67端口客户端用68端口。整个服务端的骨架其实很朴素一个UDP socket循环收包收到包后解析出消息类型根据类型决定回什么包回包前从地址池里查或分配一个地址最后把租约记下来。地址池可以用字典或SQLite存租约表至少要有IP、MAC、分配时间、过期时间四个字段。我倾向把代码拆成四层协议层负责报文的打包和解包纯函数不碰网络地址池层负责分配、续租、回收纯逻辑不碰socket会话层维护“这个MAC当前处于哪个状态”网络层就是那个收发的循环。这样拆的好处是协议层可以单独写单元测试拿一段抓包数据喂进去验证解析对不对不用真的起网络。很多人一上来就把所有逻辑塞进一个while循环调试时痛苦不堪改一个字段要重启整个服务得不偿失。提示DHCP服务端必须绑定在0.0.0.0的67端口且需要管理员权限因为67是特权端口。实验时如果不想动系统权限可以把监听端口改成1067客户端侧相应调整但真实环境必须用67。1.3 报文交互的完整时序一次完整的地址获取是四步握手。客户端广播DISCOVER里面带自己的MAC和可选的主机名服务端回OFFER携带一个候选IP和租约时长客户端如果接受就广播REQUEST明确说“我要这个IP”服务端回ACK确认租约正式生效。注意REQUEST是广播的因为客户端此时还没有确认的IP而且要让网段内其他DHCP服务端知道“这个地址被别的服务端许出去了”它们会收回自己的OFFER。续租是另外两条路径。租约过半T1默认50%时客户端发单播REQUEST给原服务端请求续租服务端回ACK租约时间重置如果到T2默认87.5%还没续上客户端转为广播REQUEST任何服务端都可以回应。这个状态机是后面写地址池回收逻辑的依据不搞清楚就会出现“地址明明过期了却还占着”的问题。2. DHCP报文结构逐字段拆解2.1 固定头部236字节的布局DHCP报文基于BOOTP扩展而来固定头部236字节后面跟一个4字节的magic cookie0x63825363再往后才是变长的Options。固定头部字段顺序和长度是死的写代码时按偏移量切就行。我把关键字段列成表方便对照偏移长度字段说明01op1请求2应答11htype硬件类型以太网为121hlenMAC长度以太网为631hops跳数服务端回044xid事务ID客户端随机生成应答必须原样带回82secs客户端已等待秒数102flags最高位为广播标志124ciaddr客户端已有IP续租时填164yiaddr服务端分配给客户端的IP204siaddr下一个引导服务器地址一般0244giaddr中继代理地址直连时为02816chaddr客户端MAC前6字节有效4464sname服务器名可全0108128file引导文件名可全0用Python的struct模块打包时格式串要特别注意字节序。网络协议统一大端序struct里用!前缀。比如xid是4字节无符号写成!Iflags是2字节写成!H。chaddr是16字节但实际MAC只有6字节剩下10字节补0。我见过有人直接把6字节MAC塞进去导致后面字段全部错位抓包一看yiaddr变成了MAC的一部分这种错误排查起来很费时间。2.2 Options字段TLV编码的灵活区Options是DHCP的灵魂采用TLVType-Length-Value编码1字节类型、1字节长度、N字节值。几个必选项必须会构造Option 53消息类型1字节值1DISCOVER2OFFER3REQUEST4DECLINE5ACK6NAK7RELEASE8INFORM。这是服务端判断“该回什么”的第一依据。Option 54服务器标识4字节IP告诉客户端是哪个服务端在回应续租时客户端就单播到这个地址。Option 51租约时间4字节秒数比如86400表示一天。Option 1子网掩码4字节。Option 3路由器4字节可多个。Option 6DNS服务器4字节可多个。Option 55参数请求列表客户端告诉服务端“我想要哪些选项”服务端按需回。Options区以2550xFF结束。构造时有个细节长度字段是值的字节数不是字符数。比如主机名“test”是4字节长度写4IP地址是4字节长度写4。写错长度会导致解析端把后面的选项全读乱。我建议封装一个build_option(code, value_bytes)函数内部自动算长度避免手写。2.3 用struct和bytes手工拼一个OFFER包理论说完上代码。下面这段是构造OFFER报文的核心参数是xid、客户端MAC、要分配的IP、服务端IP、租约秒数import struct import socket def build_offer(xid, client_mac, offered_ip, server_ip, lease86400, subnet_mask255.255.255.0, routerNone, dnsNone): # 固定头部 op 2 htype 1 hlen 6 hops 0 secs 0 flags 0x8000 # 广播标志置位确保客户端能收到 ciaddr 0.0.0.0 siaddr 0.0.0.0 giaddr 0.0.0.0 chaddr client_mac b\x00 * 10 header struct.pack( !BBBBIHH4s4s4s4s16s64s128s, op, htype, hlen, hops, xid, secs, flags, socket.inet_aton(ciaddr), socket.inet_aton(offered_ip), socket.inet_aton(siaddr), socket.inet_aton(giaddr), chaddr, b\x00 * 64, b\x00 * 128 ) # Options options b options bytes([53, 1, 2]) # OFFER options bytes([54, 4]) socket.inet_aton(server_ip) options bytes([51, 4]) struct.pack(!I, lease) options bytes([1, 4]) socket.inet_aton(subnet_mask) if router: options bytes([3, 4]) socket.inet_aton(router) if dns: options bytes([6, 4]) socket.inet_aton(dns) options bytes([255]) return header b\x63\x82\x53\x63 options几个容易翻车的点。第一flags我直接置了0x8000强制广播回应。原因是很多客户端在DISCOVER阶段还没有IP如果服务端单播回OFFER客户端可能收不到。真实环境里服务端会看客户端flags位决定单播还是广播实验环境一律广播最省事。第二chaddr必须补齐16字节不能只放6字节。第三magic cookie是固定的四字节63 82 53 63漏了它客户端会认为这不是DHCP包。第四struct.pack的格式串里4s对应4字节传inet_aton的结果正好别传字符串。2.4 解析客户端请求从字节流还原字段收包后第一件事是校验长度和cookie然后按偏移切。解析DISCOVER和REQUEST的逻辑基本一样区别在Option 53的值。下面这段把关键字段和常用选项都提出来def parse_packet(data): if len(data) 240: return None if data[236:240] ! b\x63\x82\x53\x63: return None (op, htype, hlen, hops, xid, secs, flags, ciaddr, yiaddr, siaddr, giaddr, chaddr, sname, file) struct.unpack(!BBBBIHH4s4s4s4s16s64s128s, data[:236]) result { op: op, xid: xid, flags: flags, ciaddr: socket.inet_ntoa(ciaddr), chaddr: chaddr[:6], mac: :.join(f{b:02x} for b in chaddr[:6]), options: {} } i 240 while i len(data): code data[i] if code 255: break if code 0: # pad i 1 continue length data[i 1] value data[i 2:i 2 length] result[options][code] value i 2 length msg_type result[options].get(53) result[msg_type] msg_type[0] if msg_type else None return result解析时有两个坑。一是Option 0是填充字节pad没有长度字段遇到要单独跳过否则会把后面的数据读错位。二是有些客户端会在Options里塞很长的厂商自定义选项长度字段可能超过剩余数据切片时要防越界。我一般加个if i 2 length len(data): break兜底。3. 地址池与租约管理3.1 地址池的数据结构选择地址池本质是“一批可用IP 每个IP的占用状态”。小规模实验用字典最直接key是IP字符串value是租约对象包含MAC、分配时间、过期时间、状态OFFERED/ACKED/FREE。规模大了比如上千地址字典也扛得住Python的dict查找是O(1)没必要上数据库。但如果服务重启后要保留租约就得持久化SQLite是轻量选择一张表搞定。我推荐先用内存字典跑通逻辑再考虑持久化。租约对象用dataclass写清楚字段比裸字典可读性强from dataclasses import dataclass, field import time dataclass class Lease: ip: str mac: str state: str FREE # FREE / OFFERED / ACKED allocated_at: float 0.0 expires_at: float 0.0 offered_at: float 0.0 def is_expired(self, nowNone): now now or time.time() return self.state ACKED and now self.expires_at这里有个设计决策OFFERED状态要不要设超时要。服务端发出OFFER后客户端可能因为选了别的服务端而不发REQUEST这个地址如果一直挂着OFFERED就浪费了。我给OFFERED设一个短超时比如60秒超时后自动回到FREE。这个细节很多简易实现会漏掉导致地址池“假性耗尽”。3.2 分配策略顺序、哈希还是随机分配策略直接影响用户体验和排查难度。顺序分配从池头往后找第一个FREE最简单地址连续抓包时一眼能看出规律适合实验。哈希分配按MAC算哈希取模能让同一设备大概率拿到同一地址减少变动但冲突处理麻烦。随机分配能打散地址使用但日志难看。我实际用的是“优先复用 顺序兜底”先查这个MAC有没有历史租约且未过期有就直接续没有就从池里顺序找第一个FREE。这样常用设备地址稳定新设备也能快速拿到。代码大概长这样def allocate(self, mac): now time.time() # 1. 查该MAC的现有租约 for lease in self.pool.values(): if lease.mac mac and lease.state ACKED and not lease.is_expired(now): return lease # 2. 回收过期租约 for lease in self.pool.values(): if lease.is_expired(now): lease.state FREE lease.mac # 3. 顺序找空闲 for lease in self.pool.values(): if lease.state FREE: lease.state OFFERED lease.mac mac lease.offered_at now return lease return None # 池耗尽注意第2步回收过期租约要在分配前做否则过期地址永远占着。回收时把mac清空但保留IP键方便复用。3.3 租约时间的计算与续租处理租约时间不是随便填的。太短会导致频繁续租增加广播流量太长会导致地址回收慢设备换得勤的网段容易耗尽。办公网一般8小时到1天实验环境可以设短一点比如600秒方便观察续租行为。续租请求REQUEST且ciaddr非0的处理逻辑和首次申请不同客户端已经知道自己要哪个IP服务端只需确认这个IP还在这个MAC名下且未过期是就回ACK并重置过期时间不是就回NAK让客户端重新走DISCOVER。判断“还在名下”要同时比对IP和MAC只比IP不够防止地址被回收后分给了别人、原设备又来续租造成冲突。def handle_renew(self, mac, requested_ip): lease self.pool.get(requested_ip) if lease and lease.mac mac and lease.state ACKED: lease.expires_at time.time() self.lease_time return True return False这里有个隐蔽的坑客户端续租时REQUEST里的requested_ip在Option 50里不是yiaddr因为yiaddr是服务端填的。解析时要读Option 50读错字段会导致续租永远失败。4. 服务端主循环与完整实现4.1 socket收发的正确姿势DHCP服务端用UDP收包用recvfrom回包用sendto。关键点是回包的目标地址如果客户端flags广播位置位或giaddr为0且ciaddr为0就广播到255.255.255.255:68否则单播到客户端。实验环境统一广播最稳但要注意广播包需要socket开启SO_BROADCAST。import socket sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) sock.bind((0.0.0.0, 67)) while True: data, addr sock.recvfrom(2048) pkt parse_packet(data) if not pkt: continue if pkt[msg_type] 1: # DISCOVER lease pool.allocate(pkt[mac]) if lease: reply build_offer(pkt[xid], pkt[chaddr], lease.ip, SERVER_IP) sock.sendto(reply, (255.255.255.255, 68)) elif pkt[msg_type] 3: # REQUEST # 区分首次确认和续租 ...SO_REUSEADDR要开否则服务重启时端口可能还处于占用状态。recvfrom的缓冲区给2048足够DHCP包一般不超过600字节但有些客户端会带很多选项给大点保险。4.2 REQUEST的两种场景要分开处理REQUEST报文有两种来源必须区分。第一种是首次四步握手的第三步客户端在回应某个OFFER此时ciaddr为0requested_ip在Option 50里server_id在Option 54里。第二种是续租ciaddr非0没有Option 50。处理逻辑不同首次REQUEST检查Option 54里的server_id是不是自己不是就忽略说明客户端选了别的服务端是自己就把对应租约从OFFERED转ACKED回ACK。续租REQUEST用ciaddr作为requested_ip走前面说的续租判断。def handle_request(pkt): mac pkt[mac] if pkt[ciaddr] ! 0.0.0.0: # 续租 ok pool.handle_renew(mac, pkt[ciaddr]) return build_ack(pkt[xid], pkt[chaddr], pkt[ciaddr]) if ok else None # 首次 server_id pkt[options].get(54) if server_id and socket.inet_ntoa(server_id) ! SERVER_IP: return None req_ip pkt[options].get(50) if not req_ip: return None req_ip socket.inet_ntoa(req_ip) lease pool.pool.get(req_ip) if lease and lease.mac mac and lease.state OFFERED: lease.state ACKED lease.expires_at time.time() pool.lease_time return build_ack(pkt[xid], pkt[chaddr], req_ip) return build_nak(pkt[xid], pkt[chaddr])NAK报文比较特殊它没有yiaddr客户端收到后要重新走DISCOVER。构造NAK时Option 53填6其他选项尽量少。4.3 一个能跑的最小完整版本把前面的片段拼起来加上地址池初始化和配置就是一个能分配地址的服务端。配置部分我习惯用常量放顶部网段、掩码、网关、DNS、地址池起止、租约秒数。地址池初始化时把起止IP之间的所有地址建成Lease对象塞进字典。import ipaddress def init_pool(start, end, lease_time): pool {} s int(ipaddress.IPv4Address(start)) e int(ipaddress.IPv4Address(end)) for i in range(s, e 1): ip str(ipaddress.IPv4Address(i)) pool[ip] Lease(ipip) return pool用ipaddress模块做IP和整数的互转比手写位运算清晰。地址池范围要避开网关、服务端自身、静态设备占用的地址这些在初始化时直接标成非FREE状态或者干脆不放进池里。跑起来后拿一台设备连到同一网段或者用另一台机器发构造好的DISCOVER包测试。我一般先用Python写个客户端脚本发DISCOVER看服务端回的OFFER字段对不对确认无误再上真实设备。5. 抓包验证与常见问题排查5.1 用抓包工具确认报文正确性写完服务端第一件事不是接设备是抓包看自己发的包对不对。在服务端机器上抓67和68端口重点看几个字段xid是否和请求一致、yiaddr是否是池里的地址、Option 53是否是2、magic cookie在不在。我见过最常见的问题是xid没带回客户端直接丢弃OFFER表现为“服务端明明回了包但设备拿不到地址”。抓包时如果看到OFFER发出去了但客户端没反应先查flags。有些客户端要求广播回应服务端单播了它收不到。再查chaddr如果MAC字节序搞反了比如把aa:bb:cc写成了cc:bb:aa客户端会认为不是给自己的包。5.2 典型故障速查表现象可能原因排查方法客户端一直DISCOVER无OFFER服务端没收到包/端口不对抓包看67端口有无入包收到OFFER但不发REQUESTxid不匹配/chaddr错误对比OFFER和DISCOVER的xid、MACREQUEST后无ACKserver_id判断错误检查Option 54是否等于本机IP地址分配重复租约未正确标记打印池状态查ACKED记录续租失败读了yiaddr而非Option 50确认续租分支读ciaddr地址池很快耗尽OFFERED未超时回收加OFFERED超时逻辑这张表是我实际调试时攒下来的大部分问题集中在字段读取位置和状态流转上。遇到问题先抓包把请求和回应并排对比八成能定位。5.3 几个容易忽略的实操细节第一个是权限。67端口是特权端口Linux下要sudoWindows下要管理员。实验时嫌麻烦可以改端口但记得客户端也要改否则收不到。第二个是防火墙。服务端机器的防火墙可能拦UDP 67尤其是云主机默认安全组不放行。本地实验关掉防火墙或者加规则。第三个是多网卡。服务端如果有多块网卡广播包从哪块出去取决于路由表可能出现“包从错误网卡发出”的情况。绑定0.0.0.0时尤其要注意必要时用SO_BINDTODEVICE指定网卡或者干脆只监听特定网卡的IP。第四个是并发。单线程循环在实验环境够用但如果同时来几十个请求处理慢会导致客户端超时重发。简单办法是把回包逻辑丢进线程池但要注意地址池的线程安全加锁。我一般先用单线程跑通确认逻辑无误再考虑并发。注意自己写的DHCP服务端绝对不要接入有正规DHCP服务的生产网段两个DHCP服务端同时响应会造成地址冲突轻则设备上不了网重则整个网段地址混乱。实验一定用隔离环境。6. 从实验到可用的几个增强方向6.1 静态绑定与按需分配实验跑通后最想加的功能是静态绑定某些MAC固定拿某个IP。实现很简单维护一个static_map {mac: ip}分配时先查这个表命中就直接用对应IP并在池里把该IP标记为保留不参与动态分配。这个功能在打印机、服务器这类需要固定地址的设备上很实用比在设备上手工配静态IP好管理。再进一步可以按MAC前缀分池比如某段MAC分到A网段另一段分到B网段。实现上就是维护多个池分配时按MAC前缀选池。这个在实验环境模拟多网段时很有用。6.2 日志与租约持久化内存字典一重启就丢租约全没了所有设备要重新申请。加个SQLite持久化每次状态变更写库启动时读回来。表结构就四个字段ip、mac、state、expires_at。写库用INSERT OR REPLACE简单可靠。日志方面至少记录“谁在什么时间拿了哪个地址、租约多久”。排查地址冲突时这份日志就是证据。我用的是标准logging模块按天切文件格式带上时间戳和MAC。6.3 边界情况地址池耗尽与异常报文地址池耗尽时服务端应该静默丢弃DISCOVER还是回NAK标准做法是不回让客户端继续等或换服务端。回NAK反而会让客户端困惑。代码里allocate返回None时直接continue就行。异常报文要防。长度不足240的、cookie不对的、Option长度越界的全部丢弃并记日志。有些扫描工具会往67端口发乱七八糟的包不做校验容易崩。我加了一层try/except包住解析和构造异常时打日志继续循环保证服务不退出。这套东西我从最初两百行跑通四步握手到后来加上持久化和静态绑定前后改了好几版。最大的体会是DHCP的难点不在代码量在字段位置和状态流转的精确性一个字节错位整个流程就断。建议你写的时候每构造一个包就抓包核对一次别等全写完再调那样定位问题会非常痛苦。另外地址池的回收逻辑一定要在分配前跑这个顺序错了会出现“明明有空地址却分配失败”的怪现象我在这上面浪费过整整一个下午。