23. DNS协议与编程:从报文到查询实现
DNS,域名系统。说白了,就是把人类能记住的域名,比如 www.example.com,翻译成机器能理解的 IP 地址。没有它,你每次上网都得背一串数字——想想就头疼。
我在做网络编程的头几年,其实没太关注 DNS 内部。直到有一次,我写的一个高并发服务频繁出现域名解析超时,排查了三天才发现是 DNS 报文交互出了问题。嗯,从那以后,我老老实实把 RFC 1035 翻了一遍。
这一章,我们就来拆解 DNS 协议的核心。我会带你从报文格式入手,再聊聊 Linux 下的 resolver 库函数,最后手写一个简易的 DNS 查询工具。相信我,搞懂这些,你对网络的理解会上一个台阶。
23.1 DNS 报文格式:二进制里的玄机
DNS 报文分为查询报文和响应报文。它们的结构完全一样,只是某些标志位不同。报文由 5 部分组成:
| 部分 | 长度 | 说明 |
|---|---|---|
| Header | 12 字节 | 固定长度,包含会话 ID、标志位、计数等 |
| Question | 可变 | 查询的问题,比如域名 + 类型 |
| Answer | 可变 | 响应中的资源记录(RR) |
| Authority | 可变 | 权威域名服务器记录 |
| Additional | 可变 | 附加信息,比如额外的 A 记录 |
Header 是最关键的。它的 12 个字节里,藏着整个会话的命脉。我习惯用结构体来理解它:
struct dns_header {
uint16_t id; // 会话标识,客户端自己生成,响应里原样返回
uint16_t flags; // 标志位:QR、Opcode、AA、TC、RD、RA、RCODE
uint16_t qdcount; // 问题数
uint16_t ancount; // 回答数
uint16_t nscount; // 权威记录数
uint16_t arcount; // 附加记录数
};
flags 字段是位运算的经典应用。QR 位为 0 表示查询,为 1 表示响应。RCODE 字段在响应中才有意义,0 表示成功,3 表示域名不存在——也就是我们常说的 NXDOMAIN。
核心要点:DNS 报文是二进制协议,没有文本分隔符。解析时必须严格按照字节偏移来读取。尤其是域名部分,采用长度前缀 + 标签的方式,遇到 0xC0 开头的字节表示指针压缩。
域名编码这块,我当年踩过坑。比如 www.example.com,在报文里不是直接写字符串,而是:
03 77 77 77 07 65 78 61 6d 70 6c 65 03 63 6f 6d 00
// 03 表示 "www" 长度3,后面跟3个字节
// 07 表示 "example" 长度7
// 03 表示 "com" 长度3
// 00 表示根标签,结束
指针压缩更巧妙。如果某个字节的高两位是 11(即 0xC0),那么它和下一个字节组成一个 14 位的偏移量,指向报文中的另一个位置。这样重复的域名就不用重复存储了。
我的习惯:解析 DNS 报文时,先读 Header,再根据 qdcount 循环解析 Question 部分。Question 解析完后,根据 ancount 解析 Answer。每一步都要小心字节序——网络字节序是大端,x86 是小端,记得用 ntohs() 转换。
23.2 resolver 库函数:系统自带的 DNS 工具
Linux 下,我们不需要自己拼报文。glibc 提供了 resolver 库,封装了 DNS 查询的底层细节。常用的函数有:
res_init()— 读取 /etc/resolv.conf,初始化 resolver 状态res_query()— 发送查询并接收原始响应报文res_search()— 类似 res_query,但会尝试搜索域名(比如自动补全域名后缀)res_mkquery()— 手动构造查询报文res_send()— 发送已构造的报文并接收响应
我个人最常用的是 res_query()。它直接返回完整的 DNS 响应报文,你可以自己解析。来看一个例子:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <resolv.h>
#include <netinet/in.h>
#include <arpa/nameser.h>
int main() {
unsigned char response[1024];
int len;
// 初始化 resolver
res_init();
// 查询 www.example.com 的 A 记录
len = res_query("www.example.com", C_IN, T_A, response, sizeof(response));
if (len < 0) {
perror("res_query failed");
return 1;
}
printf("Received %d bytes\n", len);
// 解析 Header
HEADER *hdr = (HEADER *)response;
printf("ID: %d\n", ntohs(hdr->id));
printf("QR: %d, Opcode: %d, AA: %d, TC: %d, RD: %d, RA: %d, RCODE: %d\n",
(hdr->qr >> 15) & 1,
(hdr->opcode >> 11) & 0xF,
(hdr->aa >> 10) & 1,
(hdr->tc >> 9) & 1,
(hdr->rd >> 8) & 1,
(hdr->ra >> 7) & 1,
hdr->rcode & 0xF);
// 跳过 Question 部分(这里简化处理)
// 实际需要解析域名和类型
unsigned char *ptr = response + sizeof(HEADER);
// ... 解析 Question 和 Answer 的代码省略
return 0;
}
注意:res_query 返回的是原始报文,包含 Header、Question、Answer 等所有部分。解析时一定要按字节偏移来。另外,res_init 不是线程安全的,多线程环境下建议每个线程单独调用。
res_search 和 res_query 的区别?res_search 会尝试在域名后面追加 /etc/resolv.conf 中定义的 search 域。比如你配置了 search example.com,那么查询 www 时,它会先试 www.example.com。这个行为有时候会带来意想不到的结果——我曾经因为 search 域配置错误,导致查询走了弯路,排查了半天。
23.3 实现简易 DNS 查询:从零开始拼报文
光用库函数不过瘾。我们来手写一个简易的 DNS 查询工具。核心步骤就三步:
- 构造查询报文
- 通过 UDP 发送到 DNS 服务器(比如 8.8.8.8:53)
- 接收并解析响应报文
先看构造查询报文的部分。我们需要把域名编码成长度前缀格式,然后填充 Header 和 Question 部分。
// 将域名转换为 DNS 报文格式
// 输入: "www.example.com"
// 输出: 03 77 77 77 07 65 78 61 6d 70 6c 65 03 63 6f 6d 00
void dns_encode_domain(unsigned char *dst, const char *domain) {
const char *p = domain;
const char *start = domain;
int len = 0;
while (*p) {
if (*p == '.') {
*dst++ = (unsigned char)(p - start);
memcpy(dst, start, p - start);
dst += (p - start);
start = p + 1;
}
p++;
}
// 最后一个标签
*dst++ = (unsigned char)(p - start);
memcpy(dst, start, p - start);
dst += (p - start);
// 根标签
*dst++ = 0;
}
构造完整的查询报文:
int build_dns_query(unsigned char *buf, int buflen, const char *domain, int type) {
struct dns_header *hdr = (struct dns_header *)buf;
memset(hdr, 0, sizeof(struct dns_header));
// 随机生成会话 ID
hdr->id = htons(getpid() & 0xFFFF);
// 设置标志位:标准查询,递归期望
hdr->flags = htons(0x0100); // RD=1
hdr->qdcount = htons(1); // 一个问题
// 编码域名
unsigned char *qname = buf + sizeof(struct dns_header);
dns_encode_domain(qname, domain);
// 填充 Question 的 type 和 class
int qname_len = strlen(domain) + 2; // 粗略计算,实际需要精确
unsigned char *qinfo = qname + qname_len;
*((uint16_t *)qinfo) = htons(type); // 查询类型,如 T_A
*((uint16_t *)(qinfo + 2)) = htons(1); // class IN
return sizeof(struct dns_header) + qname_len + 4;
}
发送和接收部分,就是标准的 UDP socket 编程。我直接贴核心代码:
int sock = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in server;
server.sin_family = AF_INET;
server.sin_port = htons(53);
inet_pton(AF_INET, "8.8.8.8", &server.sin_addr);
sendto(sock, query, query_len, 0, (struct sockaddr *)&server, sizeof(server));
unsigned char response[1024];
struct sockaddr_in from;
socklen_t fromlen = sizeof(from);
int recv_len = recvfrom(sock, response, sizeof(response), 0,
(struct sockaddr *)&from, &fromlen);
解析响应时,先读 Header,检查 RCODE。如果为 0,再根据 ancount 循环解析 Answer 部分。每个 Answer 记录包含:
- NAME(可能是指针压缩)
- TYPE(2 字节)
- CLASS(2 字节)
- TTL(4 字节)
- RDLENGTH(2 字节)
- RDATA(可变,A 记录是 4 字节 IP)
解析 A 记录的 RDATA 很简单:
if (type == T_A && rdlength == 4) {
struct in_addr addr;
memcpy(&addr, rdata, 4);
printf("IP: %s\n", inet_ntoa(addr));
}
避坑指南:我曾经在解析 CNAME 记录时,忘记处理指针压缩,结果读出来的域名全是乱码。记住,遇到 0xC0 开头的字节,一定要做指针跳转。另外,TTL 是 32 位无符号整数,单位是秒,记得用 ntohl 转换。
23.4 知识体系总览
下面这张图,帮你把 DNS 协议和编程的核心脉络串起来。从报文结构到库函数,再到手写查询,每一步都是环环相扣的。
DNS 协议看起来复杂,但拆开来看,无非就是「问」和「答」两个动作。你问的时候,把域名编码好,塞进 UDP 包发出去。对方答的时候,把 IP 地址从报文里解析出来。中间那些标志位、指针压缩、TTL,都是为了让这个问答更高效、更可靠。
我在实际项目中,很少直接手写 DNS 报文——毕竟有现成的库。但一旦遇到诡异的网络问题,比如某些域名解析慢、解析结果不对,能看懂报文就是救命稻草。你想想看,当 wireshark 抓包结果摆在你面前,你能一眼看出 RCODE 是不是 0,指针有没有跳对,这种掌控感,是调用库函数给不了的。
好了,这一章的内容就到这里。代码示例我都放在配套资源里了,建议你亲手编译运行一下。把报文打印出来,一行一行对照着看,比读十遍文档都管用。