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 查询工具。核心步骤就三步:

  1. 构造查询报文
  2. 通过 UDP 发送到 DNS 服务器(比如 8.8.8.8:53)
  3. 接收并解析响应报文

先看构造查询报文的部分。我们需要把域名编码成长度前缀格式,然后填充 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 协议与编程知识体系 DNS 报文格式 resolver 库函数 简易 DNS 查询 Header · Question · Answer 域名编码 · 指针压缩 res_init · res_query res_search · res_send 构造报文 · UDP 发送 接收响应 · 解析 RR 核心能力:理解二进制协议 · 掌握系统 API · 动手实现

DNS 协议看起来复杂,但拆开来看,无非就是「问」和「答」两个动作。你问的时候,把域名编码好,塞进 UDP 包发出去。对方答的时候,把 IP 地址从报文里解析出来。中间那些标志位、指针压缩、TTL,都是为了让这个问答更高效、更可靠。

我在实际项目中,很少直接手写 DNS 报文——毕竟有现成的库。但一旦遇到诡异的网络问题,比如某些域名解析慢、解析结果不对,能看懂报文就是救命稻草。你想想看,当 wireshark 抓包结果摆在你面前,你能一眼看出 RCODE 是不是 0,指针有没有跳对,这种掌控感,是调用库函数给不了的。

好了,这一章的内容就到这里。代码示例我都放在配套资源里了,建议你亲手编译运行一下。把报文打印出来,一行一行对照着看,比读十遍文档都管用。


公众号:蓝海资料掘金营,微信 deep3321