用C语言从零实现一个嵌入式DHCP服务器:协议、代码与排错实战
2026/9/7 14:08:12 网站建设 项目流程

简介:这是一份用纯C语言实现的独立DHCP服务器源码包,面向网络编程学习者、嵌入式开发者或需要搭建轻量级IP地址分配服务的系统管理员。项目不依赖第三方库,通过launch_server.sh脚本完成网络接口、地址池等配置,适合在Linux、BSD等类Unix环境中编译运行,帮助理解DHCP协议从Discover、Offer到Request、Ack的完整交互流程。资源共21个文件,以C头文件与源文件(7个h、4个c)为主体,配合2个shell配置脚本、2个raw抓包样本、1个pcap数据包文件以及RFC 2131/2132协议文本,便于对照源码、配置与实际报文进行学习;整体仅63KB,结构紧凑。目前已有873人浏览学习,适合作为深入掌握DHCP内部机制和C语言网络编程的参考案例。 做网络设备或者物联网网关的朋友,应该都遇到过这种尴尬:手里的开发板或者定制系统,缺一个能塞进固件里的DHCP服务。市面上的方案要么太庞大,要么不好改,要么许可证和代码风格跟自己的工程合不来。我做一个工业网关项目的时候,干脆用C写了一个独立的DHCP服务器,取名就叫dhcpserver。它没有花哨的特性,但把地址分配、租约管理、冲突检测这些核心功能都包圆了,编译出来才几十KB,丢进busybox里都能跑。如果你也需要在嵌入式环境或者内网工具链里自己搞定地址分配,这篇文章值得看完——我会把从协议拆解、代码结构、到真机排错的完整过程都过一遍。

1. 项目定位:什么时候需要自己写一个 DHCP 服务器

1.1 三条真实的需求场景

第一种典型的场景是路由器和小型网关开发。设备原厂固件里通常已经集成了DHCP功能,但做二次开发的时候,经常需要在模块层面单独控制地址池、租约时间甚至给特定MAC地址固定分配IP。这时候直接改大而全的dnsmasq,反而会因为它的配置系统太复杂而拖慢进度。

第二种场景是测试环境的网络模拟。我在调试自己写的TCP/IP协议栈或者做内网渗透测试、设备自动化入网测试的时候,经常需要临时起一个DHCP服务,还要反复调整分配的地址范围、模拟地址耗尽甚至故意不响应某些请求。现成的服务对这些恶趣味场景支持很差,自己写就非常灵活。

第三种场景是PXE无盘启动引导。常规的DHCP选项67、66在开源实现里虽然都支持,但你要分布式下发启动文件、针对不同客户端返回不同启动参数,就得改源码。独立实现一个,逻辑完全掌控在自己手里,出问题也好查。

1.2 对比现成方案,为什么还要自己造轮子

我列一下我对比过的主流方案,大家可以直接参考。

方案体积配置复杂度二次开发难度适用场景
dnsmasq约300KB以上高,功能多,配置项多中等,改动源码牵一发动全身通用路由、DNS+DHCP一体化
udhcpd约30KB低,但功能少,租约管理简陋较低简单嵌入式设备
ISC DHCP很高,老牌但结构重大型企业级网络
自研dhcpserver约40KB完全自定义完全可控定制固件、学习研究、特殊组网

自己写真正的优势不是省那几百KB的空间,而是可控。我可以精确控制每一个报文的每一个字节,可以在异常情况下打自己的日志,可以把租约表直接接到已有的业务数据库或者文件系统里面。嵌入式场景里,这些可控性往往比功能数量重要得多。

1.3 为什么选C而不是Go、Rust

首先,C在socket编程和地址结构上面几乎零抽象,struct in_addr、struct sockaddr_in就是协议本身的翻译器,写DHCP这种直接操作报文的服务,C的贴合度最高。其次,C编译出来的静态二进制没有任何运行时依赖,glibc都经常静态链接进去,放到精简内核或者RTOS环境里都能运行。最后,这个服务的核心逻辑其实就是收发报文和读写链式结构,GC语言的优势在这里发挥不出来,反而会带来内存占用和调度问题。

2. DHCP协议核心:先搞懂DORA再动手

2.1 DORA四个阶段,一台设备怎么拿到地址

DHCP这个协议看起来很老,但核心过程一点都不复杂。客户端和服务器之间通过四个报文完成地址分配,也就是常说的DORA:Discover、Offer、Request、Ack。

客户端开机只有MAC地址,它先广播一个DHCP Discover报文,意思是“我在线,谁给我分个地址”。服务器收到之后,从自己的地址池里挑一个可用IP,单播或者广播回一个Offer报文,相当于说“你试试这个地址”。客户端可能同时收到多个Offer,它从中选一个,再广播一个DHCP Request报文,确认“我要这个”。服务器检查一下,觉得没问题,就回一个Ack报文,把租约、DNS、网关这些参数一起带过去。如果服务器发现IP已经被占了,就回一个Nak,让客户端重新来过。

这里有一个非常容易踩的坑:Discover和Request都是广播,但Offer和Ack的发送方式取决于报文里的giaddr字段。giaddr非零表示存在DHCP中继代理,这时候应答要发给中继,而不是直接回广播;giaddr为零且ciaddr非零,表示客户端已经在用这个IP了,比如续租场景,这时要单播给ciaddr。我一开始没处理这个分支,导致跨网段的客户端能收到Offer,却一直收不到Ack。

2.2 报文格式中需要关注的字段

DHCP报文基于BOOTP格式,固定部分是240字节,后面跟可变长的选项区。服务器解析的时候,op、htype、hlen、xid、secs、flags这些头字段要按偏移量读,chaddr是客户端MAC地址,ciaddr、yiaddr、siaddr、giaddr四个地址字段各有各的用途。

最关键的是选项区。选项字段是TLV结构(类型-长度-值),服务器至少要解析53号选项来确认报文的类型,解析50号选项拿到客户端期望的IP,处理55号选项里的参数请求列表来决定要不要在应答里带某个参数。我自己构造应答时,至少会塞这三个选项:54号服务器标识、51号租约时间、1号子网掩码,然后再根据请求列表追加3号网关、6号DNS、15号域名、66号TFTP服务器名、67号启动文件名等。

注意:选项区必须是4字节对齐,且以FF作为结束标记。这个细节很多新手会漏,导致部分操作系统客户端(比如Windows)解析失败。

2.3 UDP 67/68,为什么客户端要先广播

DHCP走UDP协议,服务器监听67端口,客户端从68端口发包。我不止一次在面试中问到这个点:客户端连IP都没有,怎么定位服务器?答案就是广播,源地址0.0.0.0,目的地址255.255.255.255。这也是为什么写服务器的时候,socket必须设置SO_BROADCAST选项,否则内核根本不允许你在UDP socket上发广播报文。

3. 核心数据结构与地址分配策略

3.1 地址池和租约表,两个不能省的结构体

地址池本质上是一个区间:起始IP、结束IP、掩码、网关,外加当前分配游标。租约表则是动态增长的数组或者链表,记录哪个IP被哪个MAC以什么时间点租走了。我设计的租约表结构长这样:

typedef struct lease { uint32_t ip; // 已分配的IP地址(网络字节序) uint8_t mac[6]; // 客户端的MAC地址 time_t start_time; // 租约开始时间 time_t end_time; // 租约结束时间 uint8_t state; // 0=FREE, 1=OFFERED, 2=BOUND struct lease *next; } lease_t;

这里state字段非常关键。客户端在发送Discover之后并不会立刻进入BOUND状态,如果服务器在Offer之后立刻把地址标记为占用,客户端后续如果换了一个地址重新Request,这条租约就变成脏数据了。我的策略是:Offer阶段把地址标记为OFFERED,并设置一个很短的心跳超时(比如1分钟),超时没有收到Request就回退成FREE;收到Request确认之后才改成BOUND。

3.2 地址分配算法,顺序分配加冲突检测

地址分配我采用最朴素的顺序游标算法。每次从上次分配的下一个地址开始扫描,跳过已经被占用的、跳过保留地址(比如网关IP、广播地址),找第一个可用的IP。扫描一圈回到游标位置说明池子满了。这个算法在地址池几百个IP的情况下毫秒级完成,完全够用。

但顺序扫描有个问题:一个客户端反复续租不会占满地址池,但如果客户端频繁重连,服务器端最好把同一个MAC的旧租约直接归还复用,这样既避免浪费,也能保持地址稳定。我还是加了一个策略:收到Discover以后,先在整个租约表里查一遍这个MAC,如果有历史租约且没有被别的MAC占用,就优先把这个老地址Offer回去。

地址冲突是个再隐蔽不过的问题。Offer之前,我会对候选IP发一个ICMP Echo请求(就是ping),等上200毫秒,如果有响应说明这个IP被别的主机占了,跳过它。这个过程虽然会让分配变慢一点点,但能极大减少因为地址冲突导致的网络故障。

3.3 租约时长、续租和过期清理

租约时间不是一个可以随便拍脑袋的参数。太短,内网设备隔几分钟就要续租,服务器压力大,而且客户端掉线后地址立刻释放;太长,又无法有效回收闲置地址。我一般把租约设成6到12小时,工业网关内部网络则设为永久租约(无限期),但保留手动释放的接口。

续租的逻辑是:客户端在租约过去一半的时候开始广播Request,这时ciaddr字段会带上当前IP,服务器收到后如果确认这个IP确实是它的,直接返回Ack并刷新租约到期时间即可。如果找不到对应租约,可以返回Nak让客户端重新走DORA流程。

过期清理我用一个后台定时器,每30秒扫描一次租约表,把所有end_time早于当前时间的BOUND租约改成FREE。注意,如果地址还没真正分出去,只是OFFERED状态的oid,该回收就回收,不要手软。

4. 网络编程实现:socket、报文解析与构造

4.1 初始化socket,记住开广播和复用地址

主流程第一步是创建UDP socket并绑定67端口。端口绑定有几个细节:必须用root权限运行,否则无法绑定1024以下端口;socket需要设置SO_REUSEADDR,否则服务重启的时候会报Address already in use;如果要监听多网卡或者按网卡分发,还可以设置SO_BINDTODEVICE。我的初始化代码清清爽爽:

int sockfd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); if (sockfd < 0) { perror("socket"); exit(1); } int broadcast = 1; setsockopt(sockfd, SOL_SOCKET, SO_BROADCAST, &broadcast, sizeof(broadcast)); int reuse = 1; setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_port = htons(67); addr.sin_addr.s_addr = htonl(INADDR_ANY); if (bind(sockfd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("bind"); exit(1); }

4.2 报文解析的顺序,决定你排错快不快

收包以后,解析要按严格顺序来做。第一步读前240字节固定头,校验op字段必须等于1(BOOTREQUEST),htype是1(以太网),hlen是6。第二步读选项区域,从240字节开始往后找,遇到53号选项就停下来。第三步根据53号选项的值分发:1是Discover、3是Request、4是Release、7是Inform,其他的要么忽略要么Nak。

我给每个报文都打一条调试日志,格式统一:时间、源MAC、报文类型、请求IP(如果有)、giaddr、ciaddr。这个习惯帮我在现场排查问题时省了无数时间。日志的详细程度,直接决定你半夜被叫起来修网络的崩溃程度。

4.3 构造应答报文,最容易错的就是选项顺序

构造Offer或者Ack的时候,固定头大部分是把请求报文原样拷贝,再把op改成2(BOOTREPLY),然后把yiaddr填成要分配的地址,把giaddr保留原值,把siaddr填成本服务器地址。选项部分按顺序追加。我踩过的最大的一个坑是:很多客户端要求服务器标识(54号)必须在租约时间(51号)之前,如果顺序反了,部分Linux发行版的dhclient会静默丢弃这个报文。

长度和校验这块也要多说一句。UDP的校验和可以交给内核,但如果你网络环境里恰好有校验和分载(checksum offload)的问题,或者要在raw socket上做实验,就必须自己算UDP校验和。我这里用的是普通UDP socket,所以依赖内核填充,省了不少事。

4.4 主循环、超时管理和多线程取舍

服务器主循环用recvfrom阻塞收包,收到一个处理一个。这个模型应对小型内网完全没问题,一秒钟几十个请求也抗得住。但我还是加了一个100毫秒的poll超时,主要为了让后台定时器有机会执行扫描清理,而不是真的为了并发。

DHCP协议本身没有强并发需求,所以不需要为每个请求开线程。真正要提防的是客户端广播风暴,比如某个网卡驱动异常,一直发Discover。我在入口加了一个简单的计数限速:同一个源MAC每秒超过5个请求就丢弃,保证服务器不会因为某个异常设备被拖垮。

提示:recvfrom调用中,源地址参数一定要保存下来。这个源地址在你决定用单播还是广播方式回复时非常关键,尤其是在dhclient这类客户端上,它期望Offer发回到自己的临时端口。

5. 编译部署与真机验证

5.1 开发环境准备与编译命令

代码不依赖任何第三方库,只需要标准C库和POSIX socket接口。Linux下编译非常舒服,在项目根目录直接执行:

gcc -O2 -Wall -o dhcpserver main.c dhcp.c lease.c -lpthread

如果需要交叉编译到ARM或者MIPS平台,只需要把gcc换成对应的交叉编译器。这个工程我实测过,在Windows下用MinGW也能编译通过,但运行bind(67)端口时会碰一鼻子灰,Windows的权限模型和类Unix不太一样,建议还是放Linux或者WSL里跑。

开发环境本身,我用VS Code配了C/C++扩展,加上基本的ctags和clang-format,够了。不用复杂的IDE,因为整个项目就两个核心源文件加一个头文件,十万分之一的复杂度。

5.2 启动配置,参数尽量少

启动参数我收敛到了四个:监听网卡名、起始IP、结束IP、租约时间。命令行示例:

sudo ./dhcpserver -i eth0 -s 192.168.50.100 -e 192.168.50.200 -t 43200

这个设计是有意的。配置越少,越不会出错,也越适合固化到嵌入式系统的启动脚本里。如果生产环境需要更复杂的配置,完全可以把这些参数再套一层ini或者yaml解析,但核心不要去动。

启动之后,服务器会把自己绑定到eth0,并读取当前网卡的IP和掩码作为网关地址。注意,这里我不会自动把eth0的IP设置成网段内地址,需要你提前用ifconfig或者ip addr配好。这个服务器只管发地址,不管配网卡,职责单一。

5.3 客户端验证,从虚拟机到手机

最简单的测试方法是起一台虚拟机,把网卡设成仅主机模式,然后关掉主机的DHCP服务,这样virt-manager或者VMware里那个虚拟网卡就只能找你刚启动的dhcpserver要地址了。虚拟机里执行:

sudo dhclient -v eth0

如果看到DHCPDISCOVER、DHCPOFFER、DHCPREQUEST、DHCPACK这条完整链路,说明基本功能通了。再用同一台机器来回renew几次,可以测试续租逻辑。

真实物理设备的测试我建议拿一台手机开热点关闭数据的安卓机,或者一台闲置电脑。手机连接Wi-Fi之后,如果能看到分配的IP、网关和DNS都正确,并且能正常上网,说明服务器提供的网关和DNS选项也生效了。综合验证下来,我的客户端兼容性测试矩阵包括:Linux dhclient、Windows 10内置客户端、Android手机、嵌入式Linux开发板、树莓派,覆盖了绝大多数实际使用场景。

6. 常见问题与排查技巧实录

6.1 客户端拿不到地址,第一反应不是查代码

除非你在写这个服务器的第一版,否则一旦客户端拿不到地址,我会建议按这个顺序排查:先用tcpdump抓包,确认是否收到了客户端的Discover报文。

sudo tcpdump -i eth0 port 67 or port 68 -n -vv

如果根本没收到Discover,问题大概率不在服务器,而在客户端、网卡、交换机的DHCP snooping配置里。如果收到了Discover,但客户端没有发出Request,说明Offer没回对地方,重点检查giaddr和siaddr的处理逻辑。如果Request发了,服务器没收到,检查本机是不是有多个网卡,socket是否绑定到了正确的接口。

提示:收到Offer但看不到Ack,十有八九是之前提到的giaddr和ciaddr分支处理有误,把单播包回成了广播,或者把广播包回成了单播。tcpdump上面看得一清二楚,别靠猜。

6.2 地址冲突与重复租约,排查方向要广

地址冲突是最让人头疼的问题之一。常见现象是某台设备连上网却上不了网,抓包发现它收到了一个IP,这个IP又被另一台设备用了。排查思路分两步:先确认服务器是不是分配了重复地址,在租约表里查一遍;再检查是不是局域网里还有第二个DHCP服务器在工作。

第二个DHCP服务器是家常便饭。很多家用路由器的DHCP默认开启,你把自己写的服务器和它放在同一个网段,两边同时响应客户端,就会随机分配两个网段的IP,网络必乱。解决办法是在自己服务器上做一个简单的“异己检测”:收到非本服务器发出的Offer流量时,打一条警告日志,提醒管理员本网段还有别的DHCP服务在跑。

6.3 稳定性优化心得,把代码推上生产环境之前

纯逻辑开发完成到真正跑上生产环境,还有一个鸿沟:稳定性。我总结几点实测出来的优化建议。

第一,所有内存分配都要检查失败,DHCP服务器跑在内存很小的嵌入式设备上,malloc返回NULL是真实会发生的事。第二,租约表要定期压缩和去重,防止长期运行后出现内存碎片和脏数据。第三,日志不要无限制写,简单做一个按大小轮转,否则run几天,/var/log下面那个日志文件能把小存储盘写满。第四,收到陌生类型的DHCP报文时,不要直接崩溃或者回Nak,记录下来然后忽略。

整个项目开发下来,我最深的感受是,写DHCP服务器并不难,难得是把各种协议边界情况和客户端的脾气都磨平。每一次踩坑,都是对DHCP协议细节的一次加深。做这类网络基础服务,脚本调参数永远体会不到协议底层的细节,自己用C完整实现一遍以后,再看dnsmasq的配置选项心里就跟明镜似的。如果手头恰好有闲下来的开发板,强烈建议大家也照这个思路写一遍试试。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询