首页 / 资讯中心 / 文章详情

使用C语言打造独立DHCP服务器:从协议到实现完全指南

使用C语言打造独立DHCP服务器:从协议到实现完全指南 ★ FEATURED ARTICLE
简介这是一份用C语言编写的独立DHCP服务器实现面向网络协议学习者和C服务端开发者尤其适合想了解DHCP报文交互与地址分配逻辑的读者。项目采用纯C编写、无外部依赖通过Shell脚本整合系统相关调用兼顾可移植性既可作为入门范例也可作为二次开发基础。压缩包共21个文件大小仅63KB包含7个头文件、4个C源文件和2个Shell脚本另有抓包样本与RFC标准文档。头文件与源文件覆盖参数解析、选项处理、地址绑定、日志、队列等模块脚本负责配置网卡、获取MAC/IP等环境信息抓包文件与RFC文本可辅助验证协议行为。服务器虽处于测试阶段但结构清晰可借此学习DHCPDiscover、DHCPRequest等报文的处理流程、租约状态机与数据库设计。已有876人浏览学习对于想掌握Linux/Unix下网络服务编程或自建轻量DHCP服务的读者是一份紧凑而完整的参考实现。1. 独立DHCP服务器为什么要用C写一个而不是直接用系统自带服务用过Linux自带的dhcpd也用过dnsmasq大多数场景都够用。但当你要把DHCP分配能力嵌进一个工业网关、一个离线测试环境、或者一台需要静默运行数年的嵌入式设备时系统服务的体积、配置复杂度、甚至它私自读取其他网卡的行为都会变成一个需要反复绕开的障碍。用C写独立DHCP服务器dhcpserver并不意味着你去重新发明协议而是把地址池、租约表、应答行为全部收拢到一个可裁剪的二进制里让这个服务跟着主进程启动、退出、被监控所有状态你都能一手掌握。这篇文章围绕这个方向从协议要点、实现骨架、配置参数到实际运行中的故障排查写一份可以直接照着动手的笔记。2. DHCP协议与报文结构动手前必须理清的几个关键点2.1 DHCP Discover与Offer四步握手到底在交换什么DHCP四步握手是每次地址分配都要走的会话第一步客户端广播一个Discover报文源地址是0.0.0.0目的地址是255.255.255.255从UDP 68端口发出服务器收到后在67端口看到这个报文选一个空闲地址通过Offer报文回给客户端。客户端收到Offer后会从多个Offer里挑一个通常选第一个然后再次通过广播发出Request报文这次会带上请求的IP地址、服务器标识Option 54和请求参数列表Option 55。服务器收到Request后确认这个地址还能给就回复Ack同时记录租约如果地址不可用回复Nak。最后客户端配置好网络再发一个广播声明自己开机不再需要。对于服务器实现来说真正要处理的只有Discover和RequestOffer和Ack是对它们的应答所以主循环逻辑并不复杂核心反而是正确区分状态和记录租约。事务IDxid是串联这一侧会话的关键Discover的xid会被原样带进OfferRequest的xid也会原样带进Ack。客户端靠这个字段把四步报文对应到同一次申请服务器代码里不当回事的转发逻辑客户端其实会严格校验。早期我写过一版把xid回填的时候写错了整型大小端结果抓包看全都没有问题但客户端就是忽略应答后来对着字节逐个比对才发现问题。所以这部分宁可按字段结构一行行解析也不要图省事做内存拷贝然后跳过细节。2.2 报文格式与Option最关键的三类字段DHCP报文在UDP之上固定头部长度是236字节从op开始经由htype、hlen、hops、xid、secs、flags、ciaddr、yiaddr、siaddr、giaddr、chaddr16字节、sname64字节、file128字节一直到magic cookie后面才是option区域。固定头部里服务器在应答时需要回填的是xid、yiaddr分配的IP地址、siaddr如果要指定TFTP服务器就是它、giaddr中继代理地址如果越过三层就必须保留原值。chaddr是客户端硬件地址Offer和Ack阶段必须原样拷贝回去这是客户端确认“这包是给我的”的重要标志。真正让DHCP扩展出各种能力的是Option段最常见的关键Option有这么三类Option 53DHCP消息类型用来告诉接收方这个包是Offer还是AckOption 50请求地址在Request包里出现Option 54服务器标识用于指定响应的服务器。除此之外一个满足基本需求的服务器至少还要处理Option 55参数请求列表并回填Option 1子网掩码、Option 3网关、Option 6DNS。客户端会在Request包里用Option 55告诉服务器“我支持并需要哪些配置”这也是为什么Ack包里不直接复制Offer的option而要根据请求列表重新组装。这里还有一个容易让新手犯迷糊的点offer报文里也带Option 50预分配地址告诉客户端你被我选中了但最终客户端确认的是Request里的Option 50服务器需要以Request为准。如果两者不一致常见策略是拒绝或重新offer写代码时不要把两者当成同一回事。2.3 广播与单播67/68端口的玄机实现上最容易踩的坑就是听到“DHCP全是广播”的理解。实际上只有Discover和Request是全程广播Offer、Ack、Nak在某些阶段是单播到客户端网卡的。比如客户端在刚开始没有IP时Offer会广播到255.255.255.255但当客户端已经有IP并且继续续租时Request可能是单播的这时服务器如果只监听广播包或者只处理源地址为0.0.0.0的报文就会把续租请求漏掉。我一般会直接在代码里统一处理recvfrom收到的所有UDP包再做源地址和ciaddr判断而不是用socket选项过滤。同样发送Offer时广播还是单播取决于Request报文里的flags字段如果flags的广播位最高位是0服务器可以单播从68端口返回如果广播位是1则只能从67端口向255.255.255.255发广播。很多现成代码图简单一律广播这在小型实验环境没问题但在有中继或者交换机启了dhcp snooping的骨干里可能引发广播风暴或转向问题。所以代码里至少要保留一个flag判断分支而不是一路sendto广播地址。下面的表总结了四步中报文源端口和目的端口的关键走向实现socket时可以直接对着这张表设计。报文客户端源客户端目的处理方式Discover6867必须处理Offer6768或广播响应DiscoverRequest6867必须处理Ack/Nak6768或广播响应Request3. 用C实现DHCP服务器从UDP socket到完整应答的最小设计3.1 主循环设计与socket绑定固定接口还是INADDR_ANY写实现之前先把socket层的边界定好。创建UDP socket后需要bind到67端口这是DHCP服务器的固定服务端口。常见做法是bind到具体的接口IP比如192.168.10.1:67另一个做法是bind到INADDR_ANY。我倾向于绑定到具体接口原因很简单多接口设备上INADDR_ANY会让服务器同时收到来自所有内网口的Discover如果你设计的是按接口划分独立地址池那么收到报文后还必须再查一次“这个报文从哪个接口进来”而Linux不提供常规的recvfrom接口名查询要额外做recvmsgpktinfo麻烦得多。直接绑定具体接口IP代码里就能用源地址确定所属的池逻辑清晰得多。主循环一般是一个无限循环recvfrom阻塞等待收到包后用单独的函数处理处理完继续循环。不少服务器会为每个待处理的请求创建工作线程但DHCP包只有几百字节处理逻辑是简单的查表和填包单线程加非阻塞处理完全够用。线程模型复杂化反而会带来租约表的并发保护问题所以这里选择单线程。一个最小主循环的代码大致长这样#include stdio.h #include string.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #include unistd.h #define DHCP_SERVER_PORT 67 #define DHCP_CLIENT_PORT 68 #define MAX_PKT_SIZE 1024 // 协议处理函数在下方实现这里先声明 int handle_dhcp_packet(int sock, unsigned char *buf, int n, struct sockaddr_in *cli_addr); int main(int argc, char *argv[]) { int sock; struct sockaddr_in srv_addr, cli_addr; socklen_t cli_len sizeof(cli_addr); char buf[MAX_PKT_SIZE]; int n; sock socket(AF_INET, SOCK_DGRAM, 0); if (sock 0) { perror(socket); return 1; } int on 1; setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, on, sizeof(on)); // SO_BROADCAST 必须开启否则后续向 255.255.255.255 发送广播会被内核拒绝 setsockopt(sock, SOL_SOCKET, SO_BROADCAST, on, sizeof(on)); memset(srv_addr, 0, sizeof(srv_addr)); srv_addr.sin_family AF_INET; srv_addr.sin_addr.s_addr inet_addr(192.168.10.1); // 按实际网卡地址调整 srv_addr.sin_port htons(DHCP_SERVER_PORT); if (bind(sock, (struct sockaddr *)srv_addr, sizeof(srv_addr)) 0) { perror(bind); return 1; } while (1) { n recvfrom(sock, buf, sizeof(buf), 0, (struct sockaddr *)cli_addr, cli_len); if (n 0) { perror(recvfrom); continue; } // 这里继续把 buf、n、cli_addr 交给协议解析函数 handle_dhcp_packet(sock, buf, n, cli_addr); } close(sock); return 0; }这个循环有两个值得注意的点第一是SO_REUSEADDR这个socket选项在UDP上的作用没有TCP那么明显但能让服务器重启之后立即重新bind。第二是continue而不是break或exit因为DHCP服务通常是长驻进程单个坏包不应该导致服务退出。recvfrom的cli_addr保存了客户端地址解析源IP可以用来判断是哪个子网的请求也可以用来区分该请求是中继代理转发来的还是直连客户端。3.2 包解析与选项回填校验消息类型、事务ID接下来要写handle_dhcp_packet函数第一步是把DHCP固定头各字段按顺序取出来。DHCP报文是网络字节序xid是32位chaddr是16字节数组其它字段按RFC 2131的顺序排列。需要特别小心的是不要直接拿一个struct去强转并用偏移量读因为编译器的字段对齐会和实际网络字节不一致。正确做法是逐字节拷贝或者用__attribute__((packed))但后者在不同编译器上行为仍有差异我建议直接解析。处理过程一般是先验证缓冲区长度是否大于等于236字节再检查magic cookie前四个字节是0x63825363然后确认op字段是1BOOTREQUEST之后遍历option区。遍历option时需要跳过padOption 0遇到endOption 255就停止。每个option的格式是一个字节类型、一个字节长度、后面跟着长度字节的数据。按这个规则往后走把遇到的消息类型、请求地址、服务器标识、参数请求列表分别存下来。代码可以这样写#include stdint.h #include arpa/inet.h struct dhcp_packet { unsigned char op; unsigned char htype; unsigned char hlen; unsigned char hops; uint32_t xid; unsigned char chaddr[16]; // ... 其余字段 }; static int parse_dhcp_options(const unsigned char *buf, int buf_len, unsigned char *msgtype, int *msgtype_len) { if (buf_len 240) return -1; // 至少固定头magic cookie if (memcmp(buf 236, \x63\x82\x53\x63, 4) ! 0) return -1; int offset 240; int type_count 0; while (offset buf_len type_count 4) { int opt_type buf[offset]; if (opt_type 0) { offset; continue; } if (opt_type 255) break; if (offset 1 buf_len) break; int opt_len buf[offset 1]; if (offset 2 opt_len buf_len) break; if (opt_type 53) { // DHCP Message Type msgtype[type_count] buf[offset 2]; type_count; } offset 2 opt_len; } *msgtype_len type_count; return 0; } int handle_dhcp_packet(int sock, unsigned char *buf, int n, struct sockaddr_in *cli_addr) { // 固定头解析操作在调用前完成此处直接读取已解出的 xid 与 chaddr unsigned char msgtype[4]; int msgtype_len 0; if (parse_dhcp_options(buf, n, msgtype, msgtype_len) 0) { printf(packet magic cookie invalid, drop\n); return -1; } // 根据 msgtype[0] 进入 Discover / Request 分支 return 0; }解析逻辑本身不难容易被忽略的是边界每个option读取前都要先判断剩余长度否则恶意构造的超短报文会引发越界。对UDP服务来说攻击者可以用任意源端口发包过来把服务器打崩所以在代码里长度检查是安全必需项不是风格问题。3.3 地址池与租约管理简单的线性池就够了地址池和租约表是这个服务器的核心状态。最小实现可以维护两个数据结构一个包含所有可分配IP的数组一个保存租约记录的表每个记录保存客户端硬件地址chaddr、分配的IP、租约到期时刻时间戳、状态空闲/已分配/预留。Discover时从池里找最早到期且未分配给该客户端的IP先生成一个临时Offer记录Request到来时把临时记录转成正式租约。下面是一个简单的租约表示例结构#define LEASE_MAX 256 struct lease { unsigned char chaddr[16]; uint32_t addr; // 网络字节序的IP地址 time_t expire; // 到期绝对时间 int state; // 0free, 1offered, 2active int from_offer; // 该租约是否由offer临时占位 }; struct lease leases[LEASE_MAX]; static uint32_t pool_start; // 池起始IP static uint32_t pool_end; // 池结束IP static uint32_t pool_mask; // 子网掩码用于排除网络地址和广播地址池设计的取舍在于容量和开销。对嵌入式设备或小规模网段线性扫描足够地址池通常只有几十到几百个但如果目标是管理一个/16的段那就要换成按IP排序的分配表或者哈希表。选择池边界时要排除网络地址子网首地址和广播地址子网末地址这部分不要试图靠算法自作聪明直接在配置里写池的起止IP更直白。租约到期的处理方式有两个常见做法一个是在每次请求时顺便清理到期项另一个是后台定时器扫描。单线程服务器用前者就行因为DHCP请求量通常很小在正常请求时顺带回收几个到期IP不会破坏主流程。同时要注意IP分配与租约不同步的问题比如同一台客户端发起多次Discover应该把旧offer覆盖掉而不是再分配一个新地址否则客户端重试两次后池里可能被同一台机器的多个offer占满。3.4 构建应答包的关键代码构建Offer和Ack包时基本思路是复制请求包的固定头然后修改需要变化的字段。先把请求头整段memcpy到应答缓冲再把op改成2BOOTREPLY填入yiaddr保持xid和chaddr不变。然后从options区起始位置开始写magic cookie接着写Option 53应答类型、Option 54服务器标识、Option 1子网掩码等必要选项最后写Option 255结束。以下是一个创建Ack包的简化函数static void build_ack_packet(const unsigned char *req, int req_len, unsigned char *resp, int *resp_len, uint32_t assigned_ip, uint32_t server_ip, uint32_t netmask, uint32_t gateway, uint32_t dns_server, uint32_t lease_time) { // 复制固定头 memcpy(resp, req, 236); resp[0] 2; // BOOTREPLY // yiaddr 位于固定头偏移16处大小4字节在 /bootp 字段之后 uint32_t ip_n htonl(assigned_ip); memcpy(resp 16, ip_n, 4); // siaddr 本方案不需要 // magic cookie memcpy(resp 236, \x63\x82\x53\x63, 4); int off 240; // Option 53: DHCP Ack 5 resp[off] 53; resp[off] 1; resp[off] 5; // Option 54: Server Identifier uint32_t srv htonl(server_ip); resp[off] 54; resp[off] 4; memcpy(resp off, srv, 4); off 4; // Option 1: Subnet Mask uint32_t mask htonl(netmask); resp[off] 1; resp[off] 4; memcpy(resp off, mask, 4); off 4; // Option 3: Router uint32_t gw htonl(gateway); resp[off] 3; resp[off] 4; memcpy(resp off, gw, 4); off 4; // Option 6: DNS uint32_t dn1 htonl(dns_server); resp[off] 6; resp[off] 4; memcpy(resp off, dn1, 4); off 4; // Option 51: IP Address Lease Time秒 uint32_t lease htonl(lease_time); resp[off] 51; resp[off] 4; memcpy(resp off, lease, 4); off 4; resp[off] 255; // END *resp_len off; }这里要注意几个细节所有IP和租约时间在写入报文时都必须用htonl转成网络字节序如果在4字节字段上遗漏这个转换在x86上直接跑看似正常但只要拿到ARM开发板或对端做严格校验就会立刻出问题。还有Option 54服务器标识一定要和发送socket绑定的IP保持一致很多客户端只认这个标识来匹配应答。4. 编译、配置与本地验证让服务先跑起来4.1 源码组织与编译选项源码组织上我一般拆成三个文件main.c放socket循环dhcp.c放协议处理和租约管理lease.c放租约持久化对外导出几个结构体和函数。单独拆lease.c的原因是为了后面加租约日志和持久化的时候不需要碰协议文件的改动范围。编译时用gcc直接编译三个.c文件加一个-Wall和-g就够了不需要引入额外的库因为只用到了POSIX socket接口和时间函数。编译命令和最小配置文件的内容大致如下gcc -Wall -g -o dhcpserver main.c dhcp.c lease.c ./dhcpserver -c dhcpserver.conf配置文件的格式我习惯用简单keyvalue不引入JSON库。DHCP服务器常年在无人值守环境运行配置格式越简单越容易校验。# dhcpserver.conf interface_ip192.168.10.1 start_ip192.168.10.100 end_ip192.168.10.200 netmask255.255.255.0 gateway192.168.10.1 dns_server192.168.10.1 lease_time72004.2 用Linux客户端测试与tcpdump抓包本地验证的经典做法是拿一台Linux机器和服务器直连客户端网卡通过dhclient获取地址。先启动抓包再让客户端去获取地址这样能同时看到服务器行为和客户端的反应tcpdump -i eth0 -vv -nn port 67 or port 68 dhclient eth0正常的抓包结果能看到四步报文且每个报文的xid一致。如果客户端收到Offer后没有发Request多半是客户端认为这个Offer不合法回查点集中在chaddr、xid、yiaddr的合法性以及Option 53是否为2Offer。如果连Discover都没有那问题一般不在服务端而是客户端网卡没有设置为DHCP或网线没通。服务器端自身的日志也要配合上调到能打印每个包的关键字段。常见的做法是把收到的每个Discover和Request连同源接口地址、客户端mac、选中的IP打一行到stdout或syslog再配合心跳日志。这样做之后等到出问题去查才有足够的信息判断是“没收到包”还是“收到但没处理对”。4.3 参数表租约时间、池大小、网关与DNS选项上表里的几个配置值直接影响内网行为我按照实际运维经验整理成参数表参数作用常见取值注意事项interface_ip服务端口绑定的地址网卡实际IP必须与网关/服务器标识保持一致start_ip/end_ip静态分配池区间避开路由器和服务器占用段不能跨越子网边界lease_time租约有效期秒数7200或86400太短导致广播流量剧增太长导致地址回收慢dns_serverDHCP下发的DNS内网DNS或公网DNS客户端会把多个DNS按顺序写入netmask子网掩码255.255.255.0必须与网络规划一致DHCP服务不是配置完就能放任不管有几个参数和现实环境的耦合要专门处理。比如lease_time如果设为3600自动以WiFi为主的办公网没问题但工业设备的传感器会频繁续租产生额外流量反过来一些客户端对租约到期时间有最大上限超过就会忽略这个option所以不建议设置超过7天的数值。5. DHCP服务器常见运行冲突与避坑排查5.1 抓包看到Offer但客户端没有发送Request现象tcpdump里已经看到服务器发出Offer但客户端没有任何动静没有Request广播发出来。原因最常见的是Offer包不满足客户端校验。客户端会核对chaddr是否等于自己的网卡地址、xid是否和Discover一致、Option 53是不是2。还要注意客户端通常要求Offer里的siaddr或yiaddr与自己在同一个子网如果分配了一个跨网段地址客户端可能直接丢弃。解决先在tcpdump里用-vv查看Offer的chaddr和yiaddr再核对配置。如果客户端在收到Offer后卡住不动可以临时在服务器端把Offer的广播位强制置1排除是单播选项导致的不可达。经过一次实际排查我甚至遇到过是客户端防火墙拦截了非广播的UDP响应强制广播后就恢复正常了。5.2 启动后bind失败或者服务被系统抢占现象进程启动报错bind: Address already in use或者换上自己的服务后客户端拿不到地址。原因Linux发行版默认带dnsmasq或者NetworkManager的DHCP服务这些服务可能已经绑定UDP 67端口。即便你自己bind了INADDR_ANY只要有一个配置文件让系统服务监听同端口就会冲突。解决先执行ss -ulpn查看67端口被哪个进程占用占用的是dnsmasq就停掉或改配置占用的是NetworkManager就在目标网卡上禁用自动DHCP管理。再把服务器bind到具体接口。值得注意的是在容器里如果网络是host模式且宿主上有多个服务这种冲突尤其常见。5.3 应答包校验和错误现象服务器发出去的包看着格式完整但客户端或交换机丢弃抓包工具显示UDP校验和错误。原因IPv4的UDP校验和是可选操作发送方可以不填置0但多数协议栈和抓包分析器能正常处理实际网络中某些设备会丢弃。另一种原因是在构建应答包时复制了整个请求的固定头但没重新计算UDP校验和。解决为每个发出的报文调用一次计算UDP checksum的函数。IPv4 UDP校验和覆盖伪头部源IP、目的IP、UDP长度、协议号和整个UDP段。对于广播包目的IP要填写255.255.255.255但真正的网卡里的二层目的地址会由内核处理服务器代码不需要处理。5.4 重启后所有客户端重新获取IP现象服务器重启后局域网内设备IP地址没有按原租约保留全部重新Discover导致所有连接中断。原因租约表只存在内存里进程退出时没有持久化。客户端虽然也有缓存但客户端重启或租约状态异常的设备会选择重新申请而不是直接沿用之前的IP。解决在进程收到SIGTERM或SIGINT时把当前租约表写入本地文件启动时读取该文件并恢复状态。写入的格式可以用一行一条记录IP、mac、到期时间。要注意恢复的时候校验到期时间过期记录直接丢弃。这一步是独立服务器和系统自带服务最明显的能力差距很多现成项目也是因为这个才被换掉。5.5 多网卡环境下发到了错误的子网现象服务器有两块网卡分别连接两个网段但客户端请求总是得不到正确地址抓包发现服务器在应答时明明从A口收到的请求却把B网的地址发出去。原因绑定INADDR_ANY时程序不知道报从哪个网卡进来选池逻辑自然错乱。解决方式开头已经说过bind到具体接口或者在Linux下用recvmsg获取IP_PKTINFO来识别来源接口。另外还有一个隐蔽问题收包网卡跟发送网卡不一致导致非对称路由。解决方式是在构建应答时明确设置源IP为收到的对应接口IP而不是默认选取主接口地址。6. 进阶技巧日志与调试是独立服务生命周期中最值得先做的事把日志系统提前做好这件事值得我在每一篇讲网络服务的文章里重复一次。DHCP服务故障最难的地方在于“网络里瞬间发生的事情”不会像HTTP一样有迹可循客户端这边可能只在系统日志里留下一行“No DHCPOFFERS received”。所以服务端日志至少要记录两件事收包摘要和租约变更。我通常给日志设计三级开关quiet模式只记录错误和租约分配normal模式记录每个Request/Offer的处理debug模式逐字节dump收发的包。生产环境默认开到normal出问题时远程切换到debug抓包能省掉很多来回。另一个值得加的技巧是把租约导出做成一个独立的SIGUSR1信号处理而不是只有退出时写一次文件。排查问题时在线导出租约表能直接看出哪台设备占了哪个地址、什么时候到期比猜要高效得多。这个信号处理函数里要小心并发问题好在我们是单线程循环写文件之前不需要加锁直接用write把格式化后的快照写到指定路径即可。最后提一个我自己踩过的教训早期我把所有日志都输出到stdout部署时用systemd托管结果服务崩溃后日志就丢了设备报故障根本查不出原因。后来改成日志直接落盘并自带轮转才让事后分析变成可能。这也是这篇文章想表达的核心理念独立服务不只是把协议实现完它的可维护性才是能不能长期留在生产环境里的决定因素。希望这些细节能帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站