对应《Unix网络编程》第29、30、31章,用通俗语言讲解数据链路层编程、服务器并发设计与STREAMS系统。

第29章 数据链路层访问(Datalink Access)

29.1 为什么需要数据链路层访问?

普通套接字(TCP/UDP)只能看到"自己的"数据。但有些程序需要看到网卡上流过的所有数据包,甚至需要发送非 IP 的帧。数据链路层访问提供两大能力:

能力一:监听(嗅探)所有经过网卡的数据包
  → tcpdump 就是这样工作的
  → 配合混杂模式(promiscuous mode),可以看到局域网上所有流量
能力二:发送非 IP 的数据帧
  → RARP 服务器需要直接收发 RARP 帧(不是 IP 包)
  → 可以作为普通用户程序运行,无需修改内核

注意: 在交换机网络中,交换机只把帧发给目标端口,所以嗅探其他端口的流量需要配置"端口镜像(port mirroring)"。

29.2 BSD 包过滤器(BPF)

BPF 是 BSD 系统提供数据链路层访问的机制,像一个"过滤网"挂在网卡驱动上。

BPF 工作位置
数据流向(接收方向):
网络 → [网卡驱动] → BPF(拷贝一份给应用)→ IP层 → TCP/UDP → 应用
数据流向(发送方向):
应用 → IP层 → [网卡驱动] → BPF(拷贝一份给应用)→ 网络

BPF 在"尽量靠近"收发时机的位置工作,目的是让时间戳尽可能准确。
用 Mermaid 展示:

网络

网卡驱动

BPF
(过滤+缓冲)

IP层

应用程序1
(如tcpdump)

应用程序2

TCP/UDP套接字

普通应用

BPF 过滤器示例

BPF 支持强大的过滤语法,编译为"伪机器码"在内核里高效执行:

只看 UDP 或 TCP 包:
  udp or tcp
只看到端口80的、带SYN/FIN/RST标志的TCP包:
  tcp and port 80 and tcp[13:1] & 0x7 != 0
  (tcp[13:1] 表示从TCP头第13字节取1字节,即flags字段)
BPF 的三大性能优化
优化1:内核内过滤
  过滤在内核里做,不合条件的包根本不拷贝到用户空间
  → 减少昂贵的内核↔用户空间数据拷贝
优化2:快照长度(snaplen)
  只拷贝每个包的前N字节(默认96字节)
  → 大多数情况只需要包头,不需要完整数据
  → 14(以太网头)+40(IPv6头)+20(TCP头)+22(数据) = 96字节
优化3:双缓冲+超时
  BPF维护两个缓冲区:一个在填,一个在被应用读
  → 凑满一个缓冲区再一次性拷贝,减少系统调用次数
  → 类似文件I/O的块缓冲思想

29.3 SVR4 的 DLPI

DLPI(数据链路提供者接口)是 AT&T 设计的协议无关接口,SVR4 系统(如 Solaris)使用它。
通常需要在流上压入两个模块:

应用程序
    ↕  read/write
[bufmod]   ← 缓冲模块:类似 BPF 的缓冲,减少系统调用
    ↕
[pfmod]    ← 过滤模块:类似 BPF 的过滤,在内核里过滤
    ↕
网卡驱动

BPF vs DLPI 的关键区别:
BPF 使用**有向无环图(CFG)**实现过滤,性能是 DLPI 的 pfmod(布尔表达树)的 3~20 倍

29.4 Linux 的 SOCK_PACKET 和 PF_PACKET

Linux 提供两种方式访问数据链路层:

// 新方式(推荐):接收所有以太网帧
int fd = socket(PF_PACKET, SOCK_RAW, htons(ETH_P_ALL));
// 老方式:效果相同
int fd = socket(AF_INET, SOCK_PACKET, htons(ETH_P_ALL));
// 只接收 IPv4 帧
int fd = socket(PF_PACKET, SOCK_RAW, htons(ETH_P_IP));

Linux 方式的缺点

  • 没有内核缓冲:每个包都要单独拷贝给应用,效率低
  • SOCK_PACKET 不能按网卡过滤(PF_PACKET 可以用 bind 绑定网卡)

29.5 libpcap:包捕获库

libpcap 是一个跨平台的包捕获库,屏蔽了 BPF/DLPI/Linux 的差异,让程序可以在不同系统上运行。

  • tcpdump 就是基于 libpcap 实现的
  • 所有函数以 pcap_ 开头
  • 官网:http://www.tcpdump.org/

29.6 libnet:包构造与注入库

libnet 提供构造和发送任意数据包的能力,隐藏了手动构建 IP/UDP/TCP 头部的细节。

  • 所有函数以 libnet_ 前缀开头
  • 支持原始套接字和数据链路层两种发送方式

29.7 综合示例:检测 DNS 服务器是否启用 UDP 校验和

问题背景

IPv4 的 UDP 校验和是可选的。虽然现代系统都启用了,但老系统(如 SunOS 4.1.x)默认关闭。如果 DNS 服务器关闭了 UDP 校验和,损坏的数据可能污染 DNS 数据库。
检测方法:

  1. 自己构建 UDP 数据包(DNS 查询),通过原始套接字发送
  2. libpcap 读取服务器的 UDP 回复(因为普通 UDP 套接字看不到校验和字段)
  3. 检查回复包里 ui_sum 字段是否为 0(0 表示未启用校验和)
    为什么不能用普通 UDP 套接字收: 内核在把 UDP 包交给应用前会剥掉 UDP 头,应用看不到校验和字段。
程序架构

main
解析参数
确定源/目标地址

open_output
创建原始套接字
开启IP_HDRINCL

open_pcap
打开包捕获设备
设置过滤器

test_udp
发送DNS查询
读取并检查回复

send_dns_query
构建DNS查询
调用udp_write

udp_write
构建UDP+IP头
计算校验和
发送到原始套接字

udp_read
从libpcap读包
剥掉链路层头

udp_check
验证IP/UDP头
返回udpiphdr指针

UDP 伪首部与校验和

UDP 校验和不只覆盖 UDP 头和数据,还包含 IP 头中的几个字段(伪首部),这样可以验证数据包到达了正确的主机和协议:

UDP 伪首部结构:
+------------------+------------------+
|     源 IP 地址(4字节)              |
+------------------+------------------+
|     目标 IP 地址(4字节)            |
+-------+----------+------------------+
|  0x00 | 协议=17  |   UDP长度(2字节)|
+-------+----------+------------------+
|         UDP头(8字节)               |
+---------------------------------------+
|         UDP数据                       |
+---------------------------------------+

校验和计算公式(一补码求和后取反):
checksum=∼(∑iwordi)16bit\text{checksum} = \sim \left( \sum_i \text{word}_i \right)_{16\text{bit}}checksum=∼(iwordi)16bit
如果计算结果为 0,存入 0xFFFF(在一补码中两者等价,但 UDP 用 0 表示"未计算校验和"):
stored={0xFFFF若计算结果=0计算结果否则\text{stored} = \begin{cases} 0\text{xFFFF} & \text{若计算结果} = 0 \\ \text{计算结果} & \text{否则} \end{cases}stored={0xFFFF计算结果若计算结果=0否则

数据包过滤器

程序只想接收来自 DNS 服务器的回复,过滤字符串为:

udp and src host <服务器IP> and src port 53

这个字符串通过 pcap_compile 编译成 BPF 伪机器码,然后 pcap_setfilter 安装到内核。

不同链路层头部偏移

从 libpcap 读到的数据包开头是链路层头部,需要根据链路类型跳过不同字节数才能找到 IP 头:

DLT_NULL(回环接口):跳过  4 字节
DLT_EN10MB(以太网):跳过 14 字节(6字节目标MAC + 6字节源MAC + 2字节类型)
DLT_SLIP(SLIP链路):跳过 24 字节
DLT_PPP(PPP链路) :跳过 24 字节
完整示例代码(C++,检测 UDP 校验和)
/*
 * 简化版 UDP 校验和检测程序
 * 原理:构建 DNS 查询发送,用 libpcap 读回应,检查校验和字段
 *
 * 编译(需要 libpcap):
 *   g++ -o udpcksum_demo udpcksum_demo.cpp -lpcap
 * 运行(需要 root):
 *   sudo ./udpcksum_demo <DNS服务器IP>
 */
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/ip.h>
#include <netinet/udp.h>
#include <arpa/inet.h>
#include <pcap.h>
// ===== UDP 伪首部(用于计算校验和)=====
// 这个结构体把 IP 头里的几个字段 + UDP 头合在一起
// 用于校验和计算时的"伪首部"
struct udpiphdr {
    uint32_t ui_src;    // 源 IP
    uint32_t ui_dst;    // 目标 IP
    uint8_t  ui_zero;   // 填充0
    uint8_t  ui_pr;     // 协议号(17=UDP)
    uint16_t ui_len;    // UDP 长度
    uint16_t ui_sport;  // UDP 源端口
    uint16_t ui_dport;  // UDP 目标端口
    uint16_t ui_ulen;   // UDP 长度(重复,UDP头里的字段)
    uint16_t ui_sum;    // UDP 校验和
};
// ===== Internet 校验和计算 =====
// 算法:把数据当作 16 位整数序列求和,进位回卷,最后取反
static uint16_t in_cksum(const uint16_t *addr, int len)
{
    uint32_t sum = 0;
    while (len > 1) {
        sum += *addr++;
        len -= 2;
    }
    // 奇数字节:最后一个字节补0
    if (len == 1) {
        uint16_t last = 0;
        *(uint8_t *)&last = *(const uint8_t *)addr;
        sum += last;
    }
    // 把高16位进位加回低16位
    sum = (sum >> 16) + (sum & 0xffff);
    sum += (sum >> 16);
    return (uint16_t)(~sum);
}
// ===== 构建并发送 DNS 查询(使用原始套接字)=====
/*
 * 构建一个查询 a.root-servers.net A记录的 DNS 查询包
 * 通过设置了 IP_HDRINCL 的原始套接字发送
 *
 * 包结构:[IP头20B][UDP头8B][DNS查询36B]
 */
static void send_dns_query(int rawfd,
                           const struct sockaddr_in *src,
                           const struct sockaddr_in *dst)
{
    // 分配缓冲区:IP头 + UDP伪首部 + DNS数据
    const int dns_datalen = 36; // DNS查询数据长度
    const int total = sizeof(struct ip) + sizeof(struct udphdr) + dns_datalen;
    char buf[256] = {};
    // ---- 构建 DNS 查询数据 ----
    char *dnsptr = buf + sizeof(struct ip) + sizeof(struct udphdr);
    uint16_t val;
    val = htons(1234);    memcpy(dnsptr, &val, 2); dnsptr += 2; // identification
    val = htons(0x0100);  memcpy(dnsptr, &val, 2); dnsptr += 2; // flags: recursion desired
    val = htons(1);       memcpy(dnsptr, &val, 2); dnsptr += 2; // # questions = 1
    val = 0;              memcpy(dnsptr, &val, 2); dnsptr += 2; // # answer RRs = 0
    val = 0;              memcpy(dnsptr, &val, 2); dnsptr += 2; // # authority RRs = 0
    val = 0;              memcpy(dnsptr, &val, 2); dnsptr += 2; // # additional RRs = 0
    // 域名:a.root-servers.net(DNS标签格式)
    memcpy(dnsptr, "\001a\014root-servers\003net\000", 20); dnsptr += 20;
    val = htons(1);       memcpy(dnsptr, &val, 2); dnsptr += 2; // query type = A
    val = htons(1);       memcpy(dnsptr, &val, 2); dnsptr += 2; // query class = 1
    // ---- 构建 UDP 头 + 计算 UDP 校验和 ----
    // 用伪首部结构覆盖 IP 头位置来计算校验和
    struct udpiphdr *ui = (struct udpiphdr *)buf;
    memset(ui, 0, sizeof(*ui));
    // 填入伪首部字段
    ui->ui_src   = src->sin_addr.s_addr;
    ui->ui_dst   = dst->sin_addr.s_addr;
    ui->ui_zero  = 0;
    ui->ui_pr    = IPPROTO_UDP;
    ui->ui_len   = htons(sizeof(struct udphdr) + dns_datalen);
    // 填入 UDP 头字段
    ui->ui_sport = src->sin_port;
    ui->ui_dport = dst->sin_port;
    ui->ui_ulen  = ui->ui_len;
    ui->ui_sum   = 0; // 先置0再计算
    // 计算校验和(覆盖伪首部+UDP头+数据)
    ui->ui_sum = in_cksum((const uint16_t *)ui,
                          sizeof(struct udpiphdr) + dns_datalen);
    if (ui->ui_sum == 0)
        ui->ui_sum = 0xffff; // 0 在UDP中表示"未校验",改为等价的0xffff
    // ---- 构建真正的 IP 头 ----
    struct ip *ip = (struct ip *)buf;
    ip->ip_v   = IPVERSION;          // IPv4
    ip->ip_hl  = sizeof(struct ip) >> 2; // 头部长度(以4字节为单位)
    ip->ip_tos = 0;
    ip->ip_len = total;              // 注意:部分系统需要网络字节序
    ip->ip_id  = 0;                  // 0 = 让内核填写
    ip->ip_off = 0;
    ip->ip_ttl = 64;
    ip->ip_p   = IPPROTO_UDP;
    ip->ip_src = src->sin_addr;
    ip->ip_dst = dst->sin_addr;
    // ip_sum 由内核计算(IP_HDRINCL时内核仍会重算IP校验和)
    // 发送
    ssize_t n = sendto(rawfd, buf, total, 0,
                       (const struct sockaddr *)dst, sizeof(*dst));
    if (n < 0) perror("sendto");
    else printf("已发送 DNS 查询 %zd 字节\n", n);
}
// ===== 全局 pcap 句柄 =====
static pcap_t *g_pd = NULL;
static int g_datalink = 0; // 链路层类型
// ===== 从 libpcap 读取下一个包并跳过链路层头 =====
static const char *read_packet(int *iplen)
{
    struct pcap_pkthdr hdr;
    const u_char *ptr;
    // pcap_next 返回下一个满足过滤器的包
    // 第二个参数 hdr 包含时间戳、捕获长度、实际长度
    while ((ptr = pcap_next(g_pd, &hdr)) == NULL) ; // 阻塞等待
    // 根据链路层类型跳过对应字节数,找到 IP 头
    int offset = 0;
    switch (g_datalink) {
        case DLT_NULL:    offset = 4;  break; // 回环接口:4字节
        case DLT_EN10MB:  offset = 14; break; // 以太网:14字节
        case DLT_SLIP:
        case DLT_PPP:     offset = 24; break; // SLIP/PPP:24字节
        default:
            fprintf(stderr, "不支持的链路类型: %d\n", g_datalink);
            return NULL;
    }
    *iplen = (int)(hdr.caplen - offset);
    return (const char *)(ptr + offset); // 返回指向 IP 头的指针
}
int main(int argc, char *argv[])
{
    if (argc < 2) {
        fprintf(stderr, "用法: %s <DNS服务器IP>\n", argv[0]);
        return 1;
    }
    // ===== 1. 确定源地址(连接一个 UDP 套接字,让内核选源IP)=====
    struct sockaddr_in dst, src;
    memset(&dst, 0, sizeof(dst));
    dst.sin_family = AF_INET;
    dst.sin_port   = htons(53); // DNS 端口
    inet_pton(AF_INET, argv[1], &dst.sin_addr);
    int tmpfd = socket(AF_INET, SOCK_DGRAM, 0);
    connect(tmpfd, (struct sockaddr *)&dst, sizeof(dst));
    socklen_t srclen = sizeof(src);
    getsockname(tmpfd, (struct sockaddr *)&src, &srclen);
    close(tmpfd);
    src.sin_port = htons(12345); // 随便选一个源端口
    char srcip[INET_ADDRSTRLEN];
    inet_ntop(AF_INET, &src.sin_addr, srcip, sizeof(srcip));
    printf("源地址: %s:%d\n", srcip, ntohs(src.sin_port));
    printf("目标地址: %s:%d\n", argv[1], 53);
    // ===== 2. 打开 libpcap 设备 =====
    char errbuf[PCAP_ERRBUF_SIZE];
    char *dev = pcap_lookupdev(errbuf); // 自动选择第一个可用网卡
    if (!dev) { fprintf(stderr, "pcap_lookupdev: %s\n", errbuf); return 1; }
    printf("使用网卡: %s\n", dev);
    // pcap_open_live 参数:网卡名、捕获字节数、是否混杂模式、超时ms、错误buf
    g_pd = pcap_open_live(dev, 200, 0, 500, errbuf);
    if (!g_pd) { fprintf(stderr, "pcap_open_live: %s\n", errbuf); return 1; }
    // 获取链路层类型(用于后面跳过链路层头)
    g_datalink = pcap_datalink(g_pd);
    // 设置过滤器:只接收来自 DNS 服务器端口53的 UDP 包
    char filter_cmd[256];
    snprintf(filter_cmd, sizeof(filter_cmd),
             "udp and src host %s and src port 53", argv[1]);
    printf("过滤器: %s\n", filter_cmd);
    bpf_u_int32 localnet, netmask;
    pcap_lookupnet(dev, &localnet, &netmask, errbuf);
    struct bpf_program fcode;
    if (pcap_compile(g_pd, &fcode, filter_cmd, 0, netmask) < 0) {
        fprintf(stderr, "pcap_compile: %s\n", pcap_geterr(g_pd));
        return 1;
    }
    if (pcap_setfilter(g_pd, &fcode) < 0) {
        fprintf(stderr, "pcap_setfilter: %s\n", pcap_geterr(g_pd));
        return 1;
    }
    // ===== 3. 创建原始套接字发送 DNS 查询 =====
    int rawfd = socket(AF_INET, SOCK_RAW, 0);
    if (rawfd < 0) { perror("socket(需要root权限)"); return 1; }
    int on = 1;
    setsockopt(rawfd, IPPROTO_IP, IP_HDRINCL, &on, sizeof(on));
    // 降权
    setuid(getuid());
    // ===== 4. 发送查询 =====
    send_dns_query(rawfd, &src, &dst);
    // ===== 5. 用 libpcap 读取回复,检查 UDP 校验和 =====
    printf("等待 DNS 回复...\n");
    int iplen;
    const char *ippkt = read_packet(&iplen);
    if (!ippkt) { fprintf(stderr, "读取包失败\n"); return 1; }
    // 解析 IP 头
    const struct ip *ip = (const struct ip *)ippkt;
    int hlen = ip->ip_hl << 2; // IP头长度
    // 找到 UDP 头(紧跟 IP 头)
    const struct udphdr *udp = (const struct udphdr *)(ippkt + hlen);
    // 检查 UDP 校验和字段
    uint16_t cksum = ntohs(udp->uh_sum);
    if (cksum == 0) {
        printf("结论:该 DNS 服务器【关闭】了 UDP 校验和\n");
    } else {
        printf("结论:该 DNS 服务器【开启】了 UDP 校验和(校验和=0x%04x)\n", cksum);
    }
    // 打印统计信息
    struct pcap_stat stat;
    if (pcap_stats(g_pd, &stat) == 0) {
        printf("过滤器接收包数: %u\n", stat.ps_recv);
        printf("内核丢包数: %u\n", stat.ps_drop);
    }
    close(rawfd);
    pcap_close(g_pd);
    return 0;
}

第30章 客户/服务器设计方案

30.1 九种服务器设计方案概览

这章通过测试"类 Web"场景(客户端发小请求,服务器回大数据)来比较不同服务器设计的性能。
测试参数:5000 个 TCP 连接,每个连接服务器回送 4000 字节,最多 10 个并发连接。
性能数据(进程控制 CPU 时间,已减去迭代服务器基准):

行号 服务器设计 进程控制CPU时间(秒)
0 迭代服务器(基准,无进程控制) 0.0
1 并发服务器,每客户一个 fork 20.90
2 预分叉,每个子进程直接调用 accept 1.80
3 预分叉,文件锁保护 accept 2.07
4 预分叉,线程互斥锁保护 accept 1.75
5 预分叉,父进程传递套接字描述符 2.58
6 并发服务器,每客户一个线程 0.99
7 预创建线程,互斥锁保护 accept 1.93
8 预创建线程,主线程调用 accept 2.05

结论一眼看出: 每客户一个 fork(行1)最慢,预分叉或预创建线程快很多(行2~8)。

30.2~30.3 客户端设计与测试客户端

测试客户端的运行方式:

./client 192.168.1.20 8888 5 500 4000
    ↑        ↑        ↑   ↑   ↑    ↑
  程序名   服务器IP  端口  5个子进程  每子进程500个连接  每次请求4000字节

每个子进程:建连 → 发请求行(“4000\n”)→ 读4000字节 → 关连接(TIME_WAIT在客户端)。

30.4 迭代服务器(基准)

迭代服务器一次只处理一个客户,没有任何进程控制开销,是性能基准:

while (真) {
    connfd = accept(...)
    处理这个客户(期间不接受其他连接)
    close(connfd)
}

实际应用中几乎不用(太慢),但用来建立比较基线。

30.5 并发服务器:每客户 fork 一次(行1)

传统并发服务器:来一个客户 fork 一个子进程。

accept()

accept()

accept()

exit

exit

父进程
listenfd

子进程1
处理客户1

子进程2
处理客户2

子进程3
处理客户3

问题: fork 本身很重,5000 个连接要 fork 5000 次,CPU 时间高达 20.9 秒。

打印 CPU 时间
#include <sys/resource.h>
void pr_cpu_time(void)
{
    struct rusage myusage, childusage;
    // RUSAGE_SELF:当前进程自身的资源使用
    getrusage(RUSAGE_SELF, &myusage);
    // RUSAGE_CHILDREN:所有已终止子进程的资源使用
    getrusage(RUSAGE_CHILDREN, &childusage);
    // 用户态时间 = 程序自己执行的时间
    double user = myusage.ru_utime.tv_sec + myusage.ru_utime.tv_usec / 1e6
                + childusage.ru_utime.tv_sec + childusage.ru_utime.tv_usec / 1e6;
    // 系统态时间 = 代表程序在内核里执行的时间(如 fork、read 等系统调用)
    double sys  = myusage.ru_stime.tv_sec + myusage.ru_stime.tv_usec / 1e6
                + childusage.ru_stime.tv_sec + childusage.ru_stime.tv_usec / 1e6;
    printf("user time = %g, sys time = %g\n", user, sys);
}

30.6 预分叉服务器:子进程直接 accept(行2)

核心思想: 服务器启动时就预先创建 N 个子进程,全部阻塞在 accept,来客户了直接有子进程接手。

启动时:父进程 fork 出 N 个子进程,每个子进程都调用 accept 等待
来客户了:
  N 个子进程全部被唤醒("惊群问题")
  第一个运行的子进程得到连接,其余 N-1 个发现连接已被取走,继续等待

惊群问题(Thundering Herd): 每来一个连接,所有 N 个子进程都被唤醒,但只有一个能服务,其余白醒。子进程越多,浪费越大。
连接分布: 操作系统的调度算法会把连接均匀分配给各子进程(round-robin)。

子进程主循环
void child_main(int i, int listenfd, int addrlen)
{
    int connfd;
    struct sockaddr *cliaddr = malloc(addrlen);
    printf("子进程 %ld 启动\n", (long)getpid());
    for (;;) {
        socklen_t clilen = addrlen;
        // 所有子进程都阻塞在这里等待连接
        // 内核把连接给第一个能运行的子进程
        connfd = accept(listenfd, cliaddr, &clilen);
        web_child(connfd);  // 处理请求
        close(connfd);
    }
}

30.7 预分叉服务器:文件锁保护 accept(行3)

为什么需要锁: SVR4 系统(非 BSD)的 accept 是库函数实现,不是原子操作,多进程同时调用可能出错(EPROTO)。
解决方案:accept 前后加文件锁(POSIX fcntl 锁),保证同一时刻只有一个子进程在 accept

子进程循环:
  my_lock_wait()      ← 获取文件锁(其他子进程阻塞在这里)
  connfd = accept(...)
  my_lock_release()   ← 释放锁
  web_child(connfd)
  close(connfd)

文件锁初始化:创建一个临时文件,立即 unlink(路径删除但文件还在),用 fcntl(F_SETLKW) 加写锁:

// 初始化:创建临时锁文件
void my_lock_init(char *pathname)
{
    char lock_file[1024];
    strncpy(lock_file, pathname, sizeof(lock_file));
    // mkstemp 根据模板创建唯一临时文件(如 /tmp/lock.XXXXXX)
    int lock_fd = mkstemp(lock_file);
    // 立即删除路径名:程序崩溃时文件自动消失
    // 但 lock_fd 还打开着,文件内容还在(引用计数>0)
    unlink(lock_file);
    // ... 初始化 flock 结构 ...
}
// 等待锁(阻塞)
void my_lock_wait() {
    // F_SETLKW:设置锁,如果锁被占用则等待(W = Wait)
    fcntl(lock_fd, F_SETLKW, &lock_it);
}
// 释放锁
void my_lock_release() {
    fcntl(lock_fd, F_SETLKW, &unlock_it);
}

30.8 预分叉服务器:线程互斥锁保护 accept(行4)

用 Pthread 互斥锁代替文件锁,性能更好(不涉及文件系统操作)。
关键:互斥锁必须放在共享内存里,并设置 PTHREAD_PROCESS_SHARED 属性:

#include <sys/mman.h>
#include <pthread.h>
static pthread_mutex_t *mptr; // 指向共享内存中的互斥锁
void my_lock_init(char *pathname)
{
    // 用 mmap 映射 /dev/zero 获得匿名共享内存
    // 这块内存父进程和所有子进程都能访问
    int fd = open("/dev/zero", O_RDWR, 0);
    mptr = (pthread_mutex_t *)mmap(0, sizeof(pthread_mutex_t),
                                   PROT_READ | PROT_WRITE,
                                   MAP_SHARED, fd, 0);
    close(fd); // 关闭fd,但映射仍有效
    pthread_mutexattr_t mattr;
    pthread_mutexattr_init(&mattr);
    // 关键:告诉线程库这个互斥锁要在多个进程间共享
    // 默认是 PTHREAD_PROCESS_PRIVATE(只能在单个进程内用)
    pthread_mutexattr_setpshared(&mattr, PTHREAD_PROCESS_SHARED);
    pthread_mutex_init(mptr, &mattr);
}
void my_lock_wait()    { pthread_mutex_lock(mptr); }
void my_lock_release() { pthread_mutex_unlock(mptr); }

比较行3和行4:线程互斥锁(1.75s)比文件锁(2.07s)快,因为避免了文件系统操作。

30.9 预分叉服务器:父进程传递描述符(行5)

设计思路: 只有父进程调用 accept,获得连接后通过 Unix 域流式套接字(流管道) 把描述符传给某个空闲子进程。
通信结构:

父进程 ←── 流管道 ──→ 子进程1  (双向,子进程用完后写一字节通知父进程)
父进程 ←── 流管道 ──→ 子进程2
...
父进程 ←── 流管道 ──→ 子进程N

父进程用 select 同时监听:

  • listenfd:有新连接时,accept 后找空闲子进程,write_fd 传描述符
  • 所有子进程的流管道:子进程完成一个客户时写一字节通知父进程
子进程 父进程 客户端 子进程 父进程 客户端 TCP 连接(SYN) accept() 得到 connfd write_fd(pipe, "", 1, connfd) 描述符传递 close(connfd) web_child(connfd) 处理请求 write(pipe, "", 1) 通知父进程我空闲了

为什么比互斥锁慢(2.58s vs 1.75s): 每个连接都要两次管道通信(传描述符+回通知),比一次锁/解锁更耗时。
连接分布不均: 父进程总是从数组第0个开始找空闲子进程,所以靠前的子进程处理更多连接。

30.10 并发服务器:每客户一个线程(行6)

用线程代替进程,创建线程比 fork 快得多:

// 主线程:接受连接,创建线程
for (;;) {
    connfd = accept(listenfd, ...);
    pthread_t tid;
    // 创建新线程处理这个连接(比fork快很多!)
    pthread_create(&tid, NULL, doit, (void*)connfd);
}
// 每个线程执行的函数
void *doit(void *arg) {
    int connfd = (int)arg;
    pthread_detach(pthread_self()); // 自己管理自己,主线程不必等待
    web_child(connfd);              // 处理请求
    close(connfd);
    return NULL;
}

结果:0.99s,比每客户 fork(20.9s)快 21 倍

30.11 预创建线程:互斥锁保护 accept(行7)

结合预创建(避免每次创建线程的开销)和互斥锁(保护 accept):

// 启动时创建 N 个线程
pthread_mutex_t mlock = PTHREAD_MUTEX_INITIALIZER;
void *thread_main(void *arg) {
    for (;;) {
        socklen_t clilen = addrlen;
        // 互斥锁保护:同一时刻只有一个线程在 accept
        pthread_mutex_lock(&mlock);
        int connfd = accept(listenfd, cliaddr, &clilen);
        pthread_mutex_unlock(&mlock);
        web_child(connfd);
        close(connfd);
    }
}

结果:1.93s,比行6(每客户创建线程0.99s)慢一些,原因是加了锁的开销。
注意: 在 BSD 内核上,多线程直接 accept 不需要加锁(内核 accept 是原子的),但如果不加锁会出现惊群问题,整体反而慢。

30.12 预创建线程:主线程 accept(行8)

主线程负责 accept,得到连接后放入共享数组,工作线程从数组取任务:

共享结构:
int clifd[MAXNCLI]  ← 已接受的连接描述符数组
int iput            ← 主线程下一个存入位置
int iget            ← 工作线程下一个取出位置
pthread_mutex_t clifd_mutex  ← 保护数组的互斥锁
pthread_cond_t  clifd_cond   ← 通知工作线程有新任务

存入clifd[iput++]
发送条件信号

取出clifd[iget++]

取出clifd[iget++]

取出clifd[iget++]

主线程
accept()

共享数组
clifd[]

工作线程1

工作线程2

工作线程N

工作线程等待条件变量:

void *thread_main(void *arg) {
    for (;;) {
        pthread_mutex_lock(&clifd_mutex);
        // 如果没有任务,等待(释放锁 + 睡眠,被唤醒时重新获取锁)
        while (iget == iput)
            pthread_cond_wait(&clifd_cond, &clifd_mutex);
        int connfd = clifd[iget]; // 取出任务
        if (++iget == MAXNCLI) iget = 0; // 循环数组
        pthread_mutex_unlock(&clifd_mutex);
        web_child(connfd);
        close(connfd);
    }
}

结果:2.05s,比行7(1.93s)慢,因为需要互斥锁+条件变量两个同步原语。

30.13 总结与选择建议

否,高负载

进程

是(BSD)

否(SVR4)

可移植

性能优先

线程

每线程

主线程

服务器负载轻吗?

传统并发:每客户fork
简单可靠,配合inetd

用进程还是线程?

实现支持多进程accept?

预分叉+直接accept
行2,最简单

选哪种锁?

文件锁 fcntl
行3

线程互斥锁
行4

主线程还是每线程accept?

预创建线程+互斥锁
行7,最快

主线程accept+条件变量
行8,稍慢

关键结论:

  1. 高负载下,进程池/线程池比"每客户创建"快 10 倍以上
  2. 用线程通常比用进程快(行6=0.99s < 行2=1.80s)
  3. 让子进程/线程直接调用 accept 比父进程传描述符更简单更快
  4. 多个进程/线程阻塞在 accept 优于阻塞在 select(避免 select 冲突)

第31章 STREAMS

31.1 什么是 STREAMS?

STREAMS 是 Dennis Ritchie 设计的全双工 I/O 框架,提供进程与驱动程序之间的通信通道。
关键概念: 和标准 I/O “流”(fopen/fgets/printf 的那种)不同,STREAMS 是内核 I/O 框架。

31.2 STREAMS 总体结构

最简单的流:

进程
  ↕ (read/write/putmsg/getmsg)
流头(stream head)← 内核中,处理系统调用
  ↕
驱动程序(driver)

可以动态插入处理模块:

进程
  ↕
流头
  ↕
处理模块(module)← 可以多个,每次插入都在流头下面
  ↕
驱动程序
TCP/IP 在 STREAMS 中的实现

进程1
使用Socket

Socket库

流头

sockmod

进程2
使用XTI

XTI库

流头

timod

TCP 多路复用器

IP 多路复用器

以太网驱动

令牌环驱动

三个主要服务接口:

接口名 全称 层次
TPI 传输提供者接口 传输层(TCP/UDP)
NPI 网络提供者接口 网络层(IP)
DLPI 数据链路提供者接口 数据链路层

消息优先级

每个队列中消息按优先级排列:

队首 → [高优先级消息(不受流控影响)]
      → [优先带255消息]
      → [优先带254消息]
      → ...
      → [优先带1消息(通常用于紧急数据)]
      → [普通消息(优先带0)] ← 队尾

31.3 getmsg 和 putmsg

这两个函数用于读写包含控制信息+数据的 STREAMS 消息(普通的 read/write 只能传数据)。

// 发送消息(控制+数据)
int putmsg(int fd,
           const struct strbuf *ctlptr,  // 控制信息(可为NULL)
           const struct strbuf *dataptr, // 数据(可为NULL)
           int flags);                   // 0=普通, RS_HIPRI=高优先级
// 接收消息
int getmsg(int fd,
           struct strbuf *ctlptr,   // 接收控制信息的缓冲区
           struct strbuf *dataptr,  // 接收数据的缓冲区
           int *flagsp);            // 值-结果参数:指定/返回消息类型

strbuf 结构:

struct strbuf {
    int   maxlen; // 缓冲区最大容量
    int   len;    // 实际数据长度(-1 表示无此部分)
    char *buf;    // 数据指针
};

putmsg 的 flags 生成的消息类型
0,无控制信息 M_DATA
0,有控制信息 M_PROTO
RS_HIPRI M_PCPROTO(高优先级)

getmsg 返回值:

  • 0:完整消息返回
  • MORECTL:控制缓冲区太小
  • MOREDATA:数据缓冲区太小
  • MORECTL | MOREDATA:两者都太小

31.4 getpmsg 和 putpmsg

SVR4 新增支持 256 个优先带(0~255):

// 发送:可以指定优先带
int putpmsg(int fd, const struct strbuf *ctl, const struct strbuf *data,
            int band,   // 优先带 0~255
            int flags); // MSG_BAND 或 MSG_HIPRI
// 接收:可以按优先带过滤
int getpmsg(int fd, struct strbuf *ctl, struct strbuf *data,
            int *bandp,  // 值-结果:指定最小优先带/返回实际优先带
            int *flagsp);// 值-结果:MSG_ANY/MSG_HIPRI/MSG_BAND

31.5 ioctl

STREAMS 大量使用 ioctl(约30种请求),请求名以 I_ 开头,记录在 streamio 手册页。

31.6 传输提供者接口(TPI)——直接用 TPI 写 TCP 客户端

TPI 是消息式接口,sockets 库和 XTI 库都是在 TPI 之上封装的。直接使用 TPI 就像"用汇编语言写程序"——很底层,了解它有助于理解 sockets 的底层实现。

对比:sockets API vs TPI
sockets connect()  ≈  TPI T_CONN_REQ → 等待 T_OK_ACK → 等待 T_CONN_CON
sockets bind()     ≈  TPI T_BIND_REQ → 等待 T_BIND_ACK
sockets read()     ≈  TPI getmsg() 处理 T_DATA_IND 或 M_DATA
sockets close()    ≈  TPI T_ORDREL_REQ → close(fd)

系统调用次数对比:

操作 TPI 系统调用次数 内核sockets 系统调用次数
绑定本地地址 2(putmsg + getmsg) 1(bind)
建立连接(阻塞) 3(putmsg + getmsg + getmsg) 1(connect)

TPI 消息交互流程
TCP提供者(内核) 应用程序 TCP提供者(内核) 应用程序 绑定本地地址 建立连接 读数据 关闭连接 putmsg(T_BIND_REQ) getmsg(T_BIND_ACK 或 T_ERROR_ACK) putmsg(T_CONN_REQ, 目标地址) getmsg(T_OK_ACK) ← 高优先级,表示请求已接受 getmsg(T_CONN_CON) ← 普通优先级,表示连接已建立 getmsg(T_DATA_IND + 数据) 或 M_DATA getmsg(T_ORDREL_IND) ← 对端关闭(FIN) putmsg(T_ORDREL_REQ) close(fd)
TPI 客户端完整 C++ 实现
/*
 * 使用 TPI(传输提供者接口)直接与 TCP 通信的 daytime 客户端
 * 只在 SVR4/Solaris 系统上可用
 *
 * 编译(Solaris):
 *   g++ -o tpi_daytime tpi_daytime.cpp
 * 运行:
 *   ./tpi_daytime 127.0.0.1
 *
 * 注意:此代码演示 TPI 概念,实际运行需要 SVR4 兼容环境
 */
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <unistd.h>
#include <fcntl.h>
#include <sys/types.h>
// SVR4 STREAMS 相关头文件(仅 SVR4/Solaris 上可用)
// #include <sys/stream.h>
// #include <sys/stropts.h>
// #include <sys/tihdr.h>    // TPI 消息结构定义
// ===== 以下是概念性演示,展示 TPI 消息格式 =====
// TPI T_BIND_REQ 消息结构(来自 <sys/tihdr.h>)
struct T_bind_req_demo {
    long PRIM_type;     // = T_BIND_REQ (值 101)
    long ADDR_length;   // 地址长度
    long ADDR_offset;   // 地址在消息中的偏移(紧随结构体)
    long CONIND_number; // 可排队的连接请求数(服务器用,客户端=0)
    // 紧随其后是 sockaddr_in 地址数据
};
// TPI T_CONN_REQ 消息结构
struct T_conn_req_demo {
    long PRIM_type;     // = T_CONN_REQ (值 100)
    long DEST_length;   // 目标地址长度
    long DEST_offset;   // 目标地址在消息中的偏移
    long OPT_length;    // 选项长度(0 = 无选项)
    long OPT_offset;    // 选项偏移
    // 紧随其后是 sockaddr_in 地址数据
};
// T_ORDREL_REQ 消息(关闭连接)
struct T_ordrel_req_demo {
    long PRIM_type;     // = T_ORDREL_REQ (值 112)
};
/*
 * 概念说明:
 *
 * 在真实 SVR4 系统上,TPI 客户端的工作流程如下:
 *
 * 1. 打开 TCP 提供者设备:
 *    fd = open("/dev/tcp", O_RDWR, 0);
 *
 * 2. 绑定本地地址:
 *    构造 T_bind_req 消息 + sockaddr_in
 *    putmsg(fd, &ctlbuf_with_bind_req, NULL, 0);
 *    getmsg(fd, &ctlbuf, NULL, &flags_HIPRI);  ← 等高优先级确认
 *    检查返回的是 T_BIND_ACK 还是 T_ERROR_ACK
 *
 * 3. 连接服务器:
 *    构造 T_conn_req 消息 + 目标 sockaddr_in
 *    putmsg(fd, &ctlbuf_with_conn_req, NULL, 0);
 *    getmsg(fd, &ctlbuf, NULL, &flags_HIPRI);  ← 等 T_OK_ACK(高优先级)
 *    getmsg(fd, &ctlbuf, NULL, &flags_0);      ← 等 T_CONN_CON(普通优先级)
 *
 * 4. 读数据:
 *    getmsg(fd, &ctlbuf, &databuf, &flags);
 *    如果 ctlbuf.len == -1:M_DATA 消息,直接用 databuf
 *    如果 PRIM_type == T_DATA_IND:数据在 databuf 中
 *    如果 PRIM_type == T_ORDREL_IND:对端关闭(EOF)
 *
 * 5. 关闭:
 *    构造 T_ordrel_req 消息
 *    putmsg(fd, &ctlbuf_with_ordrel, NULL, 0);
 *    close(fd);
 */
// ===== 用 sockets 实现的等效 daytime 客户端(可实际运行)=====
/*
 * 这是用普通 sockets API 实现的相同功能,展示 TPI 和 sockets 的对应关系
 */
int main_sockets_version(int argc, char *argv[])
{
    if (argc != 2) {
        fprintf(stderr, "用法: %s <IP地址>\n", argv[0]);
        return 1;
    }
    // socket() 在 SVR4 内部对应:open("/dev/tcp") + 初始化
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (fd < 0) { perror("socket"); return 1; }
    // bind() 在 SVR4 内部对应:putmsg(T_BIND_REQ) + getmsg(T_BIND_ACK)
    struct sockaddr_in myaddr = {};
    myaddr.sin_family = AF_INET;
    myaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    myaddr.sin_port = htons(0); // 让内核分配端口
    bind(fd, (struct sockaddr *)&myaddr, sizeof(myaddr));
    // connect() 在 SVR4 内部对应:
    //   putmsg(T_CONN_REQ) → getmsg(T_OK_ACK) → getmsg(T_CONN_CON)
    struct sockaddr_in servaddr = {};
    servaddr.sin_family = AF_INET;
    servaddr.sin_port = htons(13); // daytime 服务端口
    inet_pton(AF_INET, argv[1], &servaddr.sin_addr);
    if (connect(fd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
        perror("connect"); close(fd); return 1;
    }
    // read() 在 SVR4 内部对应:getmsg() 处理 T_DATA_IND 或 M_DATA
    char buf[256];
    ssize_t n;
    while ((n = read(fd, buf, sizeof(buf)-1)) > 0) {
        buf[n] = '\0';
        fputs(buf, stdout);
    }
    // close() 在 SVR4 内部对应:putmsg(T_ORDREL_REQ) + close(fd)
    close(fd);
    return 0;
}
/*
 * 选择运行哪个版本:
 * - SVR4/Solaris 真实环境:使用 TPI 版本(直接操作消息)
 * - 其他 Unix:使用 sockets 版本(标准接口)
 */
int main(int argc, char *argv[])
{
    printf("TPI 概念演示程序\n");
    printf("TPI 是 SVR4 的底层接口,sockets 库在其之上封装\n\n");
    printf("对应关系:\n");
    printf("  socket(AF_INET,SOCK_STREAM,0)  <=>  open(\"/dev/tcp\", O_RDWR)\n");
    printf("  bind()                          <=>  putmsg(T_BIND_REQ)  + getmsg(T_BIND_ACK)\n");
    printf("  connect()                       <=>  putmsg(T_CONN_REQ)  + getmsg(T_OK_ACK) + getmsg(T_CONN_CON)\n");
    printf("  read()                          <=>  getmsg() [T_DATA_IND or M_DATA]\n");
    printf("  close()                         <=>  putmsg(T_ORDREL_REQ) + close(fd)\n\n");
    // 实际运行 sockets 版本作为演示
    if (argc == 2) {
        return main_sockets_version(argc, argv);
    } else {
        fprintf(stderr, "用法: %s <服务器IP>  (连接daytime服务端口13)\n", argv[0]);
        return 1;
    }
}

31.7 STREAMS 总结

STREAMS 提供了一个模块化的 I/O 框架:

应用程序
   ↕ 标准系统调用(read/write/ioctl)和 STREAMS 扩展(getmsg/putmsg)
流头(stream head)
   ↕ 可动态插入/移除处理模块
[模块1] [模块2] ... (如 sockmod, timod, tirdwr, pfmod, bufmod)
   ↕ 定义好的服务接口(TPI/NPI/DLPI)
驱动程序(可以是硬件驱动或软件伪驱动)

核心 API 总结:

函数 说明
putmsg 发送普通优先级消息(控制+数据)
getmsg 接收普通优先级消息
putpmsg 发送指定优先带消息(SVR4新增)
getpmsg 接收指定优先带消息
ioctl(I_PUSH) 将模块压入流
ioctl(I_POP) 将模块从流中弹出

三章总结对比

普通网络通信

监听所有数据包

发送自定义帧

高性能服务器

低负载

高负载+进程

高负载+线程

SVR4底层接口

应用程序

需要什么?

TCP/UDP套接字
最简单

数据链路层访问
libpcap/BPF/DLPI

原始套接字
或直接写BPF

选哪种模型?

传统并发
每客户fork

预分叉进程池
+ 互斥锁

预创建线程池
行7最快

STREAMS + TPI
理解sockets实现

网络编程附录详解:IPv4、IPv6、ICMP 与调试技术

附录 A:IPv4、IPv6、ICMPv4 和 ICMPv6

A.1 概述

这部分内容是对 IP 协议族的基础介绍,是理解 TCP/UDP 的重要背景知识。

A.2 IPv4 头部详解

IP 层提供的是"尽力而为"的无连接数据报传送服务。什么叫"尽力而为"?就是说 IP 会尽量把数据送到目的地,但不保证

  • 数据一定能到达
  • 数据按顺序到达
  • 数据只到达一次
    这些保障需要上层协议(如 TCP)来提供。
IPv4 头部结构图
 0       4       8              16              24             31
 +-------+-------+---------------+---------------+---------------+
 |版本(4)|头部长度| DSCP (6位)   |ECN(2位)|       总长度(16位)       |
 +-------+-------+---------------+---------------+---------------+
 |          标识符(16位)          |0|DF|MF|  片偏移(13位)           |
 +---------------+---------------+---------------+---------------+
 |  生存时间TTL  |    协议字段   |           头部校验和             |
 +---------------+---------------+---------------+---------------+
 |                     源 IPv4 地址(32位)                        |
 +---------------------------------------------------------------+
 |                    目的 IPv4 地址(32位)                       |
 +---------------------------------------------------------------+
 |                     选项字段(如果有)                          |
 +---------------------------------------------------------------+
 |                         数    据                               |
 +---------------------------------------------------------------+
各字段详解

字段名 大小 说明
版本 4位 固定为 4,表示 IPv4
头部长度 4位 以 32 位字为单位,最大值 15,即最大头部 15×4=6015 \times 4 = 6015×4=60 字节
DSCP 6位 差分服务代码点,用于 QoS 流量分类
ECN 2位 显式拥塞通知
总长度 16位 整个数据报的字节数(含头部),最大 216−1=655352^{16}-1 = 655352161=65535 字节
标识符 16位 用于分片重组,每个数据报唯一
DF 位 1位 Don’t Fragment,禁止分片
MF 位 1位 More Fragments,还有更多分片
片偏移 13位 该分片在原数据报中的位置,单位为 8 字节
TTL 8位 生存时间,每经过一个路由器减 1,减到 0 则丢弃
协议 8位 上层协议:1=ICMP, 6=TCP, 17=UDP
头部校验和 16位 只校验 IP 头部
源地址 32位 发送方 IP 地址
目的地址 32位 接收方 IP 地址

数据大小的计算公式:
数据大小=总长度−头部长度×4数据大小 = 总长度 - 头部长度 \times 4数据大小=总长度头部长度×4
其中头部长度字段的单位是 32 位字(即 4 字节),所以要乘以 4。
TTL 的实际意义:
TTL 字段最大值是 255(8 位),常用默认值是 64。每过一个路由器减 1,防止数据包在网络中永远循环。

A.3 IPv6 头部详解

IPv6 是为了解决 IPv4 地址耗尽问题而设计的下一代协议。

IPv6 头部结构图
 0       4   6  8  10              16              24            31
 +-------+---+--+--+---------------+---------------+-------------+
 |版本(6)| DSCP |ECN|          流标签(20位)                        |
 +-------+-------+--+---------------+---------------+-------------+
 |         有效载荷长度(16位)        |下一头部(8位)  |跳数限制(8位) |
 +-----------------------------------+---------------+-------------+
 |                                                                 |
 |                    源 IPv6 地址(128位)                        |
 |                                                                 |
 +---------------------------------------------------------------+
 |                                                                 |
 |                   目的 IPv6 地址(128位)                      |
 |                                                                 |
 +---------------------------------------------------------------+
IPv6 vs IPv4 关键差异对比

对比项 IPv4 IPv6
地址长度 32位 128位
头部大小 可变(20~60字节) 固定 40 字节
头部校验和 无(由上层负责)
分片字段 在头部中 单独的扩展头部
TTL/跳数 TTL 跳数限制(Hop Limit)
广播 支持 不支持(用多播代替)
分片操作者 路由器和源主机 只有源主机

为什么 IPv6 去掉了头部校验和?
因为 TCP、UDP、ICMPv6 的校验和已经覆盖了 IP 地址等关键字段,路由器转发时不再需要重新计算头部校验和,这样可以加快路由器的处理速度。
IPv6 地址空间大小:
2128≈3.4×10382^{128} \approx 3.4 \times 10^{38}21283.4×1038
相比 IPv4 的 232≈4.3×1092^{32} \approx 4.3 \times 10^92324.3×109,IPv6 的地址空间是 IPv4 的大约 7.9×10287.9 \times 10^{28}7.9×1028 倍。

A.4 IPv4 地址详解

IPv4 地址是 32 位的,写成点分十进制,例如 192.168.1.1

地址分类

类型 用途 地址范围
单播(A/B/C类) 一对一通信 0.0.0.0 ~ 223.255.255.255
多播(D类) 一对多通信 224.0.0.0 ~ 239.255.255.255
实验保留(E类) 保留未用 240.0.0.0 ~ 255.255.255.255

子网划分举例

假设分配到网络地址 192.168.42.0/24,选择 3位子网ID + 5位主机ID

  |<------ 24位网络地址 ------>|<3位>|<-- 5位 -->|
  192      .168      .42      .  subnet  host
子网掩码:255.255.255.224  (/27)

每个子网可容纳的有效主机数:25−2=302^5 - 2 = 30252=30 台(减去全0的网络地址和全1的广播地址)
子网列表:

子网ID 子网前缀 可用主机范围
0 192.168.42.0/27 保留(不建议用)
1 192.168.42.32/27 .33 ~ .62
2 192.168.42.64/27 .65 ~ .94
3 192.168.42.96/27 .97 ~ .126
4 192.168.42.128/27 .129 ~ .158
5 192.168.42.160/27 .161 ~ .190
6 192.168.42.192/27 .193 ~ .222
7 192.168.42.224/27 保留(不建议用)

特殊地址

地址 用途
127.0.0.1 回环地址(loopback),数据不离开本机
0.0.0.0 未指定地址,套接字 API 中表示"任意地址"
10.0.0.0/8 私有地址,约 1677 万个
172.16.0.0/12 私有地址,约 104 万个
192.168.0.0/16 私有地址,约 6.5 万个

CIDR(无类域间路由)

CIDR 使用前缀长度(/n)代替传统地址类别,写法为:
网络地址/前缀长度网络地址/前缀长度网络地址/前缀长度
例如 192.168.42.0/24 表示前 24 位是网络部分,后 8 位是主机部分。

A.5 IPv6 地址详解

IPv6 地址是 128 位,写成 8 组 16 进制数,用冒号分隔,例如:
3ffe:b80:1f8d:1:a00:20ff:fea7:686b

IPv6 地址类型(按高位前缀区分)

前缀 地址类型
全0(128位) 未指定地址 ::
127个0 + 1个1 回环地址 ::1
001 开头 全局单播地址
FE80 开头 链路本地地址
FEC0 开头 站点本地地址(已废弃)
FF 开头 多播地址

IPv4 映射的 IPv6 地址

用于 IPv4/IPv6 双栈过渡期,格式如下:

|<---------- 80位0 ---------->|<16位1>|<--- 32位IPv4地址 --->|
 0000:0000:0000:0000:0000      :FFFF  : 12.106.32.254
简写:::FFFF:12.106.32.254
6to4 地址

自动隧道机制,格式:

|  2002  |  IPv4地址(32位)  | 子网ID(16位) | 接口ID(64位) |

例如:IPv4 地址 12.106.32.254 对应的 6to4 前缀为 2002:c6a:20fe/48
换算过程:

  • 12 = 0x0c
  • 106 = 0x6a
  • 32 = 0x20
  • 254 = 0xfe
  • 所以 IPv4 地址十六进制为 0c6a:20fe

A.6 ICMP 协议(ICMPv4 和 ICMPv6)

ICMP 是 IP 网络中的"信使"协议,用于在网络节点间传递错误和信息消息。pingtraceroute 都基于 ICMP 实现。

ICMP 消息通用头部(前 32 位)
 0               8              16              24             31
 +---------------+---------------+-------------------------------+
 |   类型(type)  |   代码(code)  |        校验和(checksum)       |
 +---------------+---------------+-------------------------------+
 |                  (剩余内容取决于类型和代码)                  |
 +---------------------------------------------------------------+
重要的 ICMPv4 消息

类型 代码 含义 错误码
0 0 Echo 应答(Ping 回复) 用户进程处理
3 0 目标网络不可达 EHOSTUNREACH
3 1 目标主机不可达 EHOSTUNREACH
3 3 端口不可达 ECONNREFUSED
3 4 需要分片但 DF 置位 EMSGSIZE
8 0 Echo 请求(Ping) 内核自动回复
11 0 TTL 超时(traceroute 原理) 用户进程处理

ICMP 与应用程序的关系
                  ICMP 错误消息
                        |
          +-------------+-------------+
          |                           |
        TCP 应用                   UDP 应用
          |                           |
   记录错误,等待超时           下次 send/recv 时返回错误
   后再报告给应用层             (仅对已连接的 socket)

附录 B:虚拟网络

B.1 背景

当 IP 层新特性(如 IPv6、多播)需要推广时,无法等所有路由器都升级,于是通过隧道技术在现有 IPv4 网络上建立虚拟网络。

B.2 MBone(多播骨干网)

MBone 约创建于 1992 年,是早期的多播虚拟网络。

工作流程

1. 发送多播包

2. MR2 收到

3. 封装成单播
IPv4-in-IPv4
协议字段=4

4. 转发

5. 送达

6. 去掉外层头部
恢复多播包

多播源主机 MH1

上层以太网

多播路由器 MR2
运行 mrouted

单播路由器 UR3

单播路由器 UR4

多播路由器 MR5
隧道终点

下层以太网

多播目标主机

关键思路: 把多播数据报"套"在单播 IPv4 数据报里穿越不支持多播的网络,到达另一端再"剥壳"还原。

B.3 6bone(IPv6 测试骨干网)

6bone 建立于 1996 年,用途类似 MBone,但是为 IPv6 服务,使用 IPv6-in-IPv4 隧道(协议字段 = 41)。

1. 发送 IPv6 包

2. 封装成 IPv4
协议=41

3. 传输

4. 剥去 IPv4 头
还原 IPv6 包

IPv6 主机 H1

IPv4/IPv6 双栈
路由器 HR2

IPv4 互联网

IPv4/IPv6 双栈
路由器 HR3

IPv6 主机 H4

B.4 6to4(自动 IPv6 过渡机制)

6to4 是 6bone 的改进版,隧道自动建立,无需手动配置,且只需路由器参与,不需要终端主机知道隧道存在。

  • 使用地址前缀 2002/16
  • IPv4 地址嵌入 IPv6 地址中(第 17~48 位)
  • 通过 IPv4 任播地址 192.88.99.1 寻找最近的 6to4 网关

附录 C:调试技术

C.1 系统调用跟踪

理解系统调用和库函数的区别:

  • 系统调用:直接进入内核的入口点
  • 库函数:用户态代码,可能内部调用多个系统调用
FreeBSD 上的 ktrace 跟踪示例
3211 daytimetcpcli CALL socket(0x2,0x1,0)   # 调用 socket(),AF_INET, SOCK_STREAM
3211 daytimetcpcli RET  socket 3            # 返回文件描述符 3
3211 daytimetcpcli CALL connect(0x3,...,0x10)  # 连接到服务器
3211 daytimetcpcli RET  connect 0            # 连接成功
3211 daytimetcpcli CALL read(0x3,...,0x1000) # 从 fd=3 读数据
3211 daytimetcpcli GIO  fd 3 read 26 bytes   # 读到 26 字节
  "Tue Aug 19 23:35:10 2003"
3211 daytimetcpcli CALL read(0x3,...,0x1000) # 再次读
3211 daytimetcpcli GIO  fd 3 read 0 bytes    # 读到 0 字节 = EOF
3211 daytimetcpcli CALL exit(0)              # 正常退出

程序执行流程:

socket 创建套接字
返回 fd=3

connect 连接服务器

read 读取数据
26字节

write 写到标准输出

read 再次读取
返回 0=EOF

exit 退出

C.2 标准网络服务


服务名 端口 用途
daytime 13 返回当前时间字符串
discard 9 丢弃所有收到的数据
echo 7 回显收到的数据

C.3 sock 调试工具

sock 程序是一个多功能网络调试工具,支持四种工作模式:

模式1(标准客户端):
  stdin ----> [sock] ----> 服务器
              [sock] <---- 服务器
              stdout <---/
模式2(标准服务端):
  类似模式1,但 sock 绑定端口,被动等待连接
模式3(源客户端):
  [sock] ===大量数据===> 服务器
  (固定次数的写操作,用于测试吞吐量)
模式4(接收服务端):
  客户端 ===大量数据===> [sock]
  (固定次数的读操作,配合模式3使用)

C.4 小型测试程序

调试思路:遇到不确定的特性,写一个最小化的测试程序来验证。配合封装好的工具函数(如 readline、错误处理函数),可以快速构建测试用例。

C.5 tcpdump 抓包工具

tcpdump 可以从网卡抓取并分析网络包,支持复杂的过滤表达式:

# 只显示 UDP daytime 服务的包或 ICMP 包
tcpdump '(udp and port daytime) or icmp'
# 只显示带 SYN 标志的 HTTP 包
# TCP 头部偏移 13 字节处是标志位,SYN=2
tcpdump 'tcp and port 80 and tcp[13:1] & 2 != 0'
# 只显示源端口在 7001-7005 之间的 TCP 包
# TCP 头部偏移 0 处是源端口,占 2 字节
tcpdump 'tcp and tcp[0:2] > 7000 and tcp[0:2] <= 7005'

过滤表达式中,tcp[偏移:长度] 的语法可以访问 TCP 头部的任意字节。

C.6 netstat 工具

netstat 是一个多功能网络状态查看工具:

常用选项 作用
(无选项) 显示活跃的网络连接
-ia 显示网络接口和多播组
-s 显示各协议的统计信息
-r 显示路由表
-i 显示接口信息

C.7 lsof 工具

lsof(List Open Files)可以查看哪个进程占用了某个端口:

# 查找谁在监听 daytime 端口(13)
freebsd % lsof -i TCP:daytime
COMMAND  PID  USER  FD  TYPE  ...  NAME
inetd    561  root  5u  IPv4  ...  TCP *:daytime (LISTEN)
inetd    561  root  7u  IPv6  ...  TCP *:daytime (LISTEN)

输出含义:

  • COMMAND:进程名(inetd 是超级服务器)
  • PID:进程 ID
  • FD:文件描述符号(u = 读写)
  • TYPE:IPv4 或 IPv6
    注意: lsof 无法显示处于 TIME_WAIT 状态的 TCP 连接,因为它们没有对应的打开文件。

附录 D:标准错误处理函数

D.1 unp.h 头文件

这是贯穿全书的统一头文件,包含了所有常用的系统头文件和常量定义。

// 文件:unp.h(精简注释版)
#ifndef __unp_h
#define __unp_h
// 基础系统头文件
#include <sys/types.h>      // 基本系统数据类型
#include <sys/socket.h>     // 套接字相关定义
#include <sys/time.h>       // timeval 结构(用于 select)
#include <time.h>           // timespec 结构(用于 pselect)
#include <netinet/in.h>     // sockaddr_in 及 Internet 相关定义
#include <arpa/inet.h>      // inet 系列函数
#include <errno.h>          // 错误码定义
#include <netdb.h>          // 主机名查询相关
#include <stdio.h>          // 标准输入输出
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
// 常用常量定义
#define LISTENQ  1024       // listen() 第二参数:等待队列最大长度
#define MAXLINE  4096       // 文本行最大长度(字节)
#define BUFFSIZE 8192       // 读写缓冲区大小(字节)
#define SERV_PORT 9877      // 示例程序使用的端口号(TCP/UDP 通用)
// 简化强制类型转换的宏
// 很多函数要求传入 struct sockaddr*,用 SA 简化写法
#define SA struct sockaddr
// IPv4 地址字符串最大长度:"ddd.ddd.ddd.ddd\0" = 16 字节
#define INET_ADDRSTRLEN  16
// IPv6 地址字符串最大长度
// "xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx\0" = 46 字节
#define INET6_ADDRSTRLEN 46
#endif // __unp_h

D.3 标准错误函数

为了简化错误处理代码,定义了一组统一的错误输出函数:

不用这些函数时(繁琐):
if (error_condition) {
    char buff[200];
    snprintf(buff, sizeof(buff), "格式化错误信息");
    perror(buff);    // 打印系统错误字符串
    exit(1);
}
用这些函数后(简洁):
if (error_condition)
    err_sys("连接失败,地址 %s", addr_str);
五个错误函数对比

函数名 打印 errno 终止程序 syslog 级别
err_ret 否(返回) LOG_INFO
err_sys 是(exit) LOG_ERR
err_dump 是(abort+core) LOG_ERR
err_msg 否(返回) LOG_INFO
err_quit 是(exit) LOG_ERR

完整实现代码(C++版,可直接运行)
/*
 * 网络编程错误处理函数实现
 * 对应原书 lib/error.c
 * 编译:g++ -o demo error_demo.cpp
 */
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <cerrno>
#include <cstdarg>
#include <syslog.h>
// 守护进程标志:非零时,错误输出到 syslog,否则输出到 stderr
int daemon_proc = 0;
// 内部通用函数:格式化并输出错误信息
// errnoflag: 是否附加 errno 对应的系统错误字符串
// level:     syslog 的日志级别(LOG_INFO 或 LOG_ERR)
// fmt:       printf 风格的格式字符串
// ap:        可变参数列表
static void err_doit(int errnoflag, int level, const char* fmt, va_list ap)
{
    int errno_save = errno;     // 先保存 errno,因为后续操作可能改变它
    char buf[4097];             // 对应 MAXLINE + 1
    // 安全地格式化字符串(防止缓冲区溢出)
    vsnprintf(buf, 4096, fmt, ap);
    int n = strlen(buf);
    // 如果需要打印系统错误信息,拼接到末尾
    if (errnoflag) {
        snprintf(buf + n, 4096 - n, ": %s", strerror(errno_save));
    }
    strcat(buf, "\n");
    if (daemon_proc) {
        // 守护进程模式:通过 syslog 记录日志
        syslog(level, "%s", buf);
    } else {
        // 普通模式:先刷新 stdout 防止输出乱序,再写到 stderr
        fflush(stdout);
        fputs(buf, stderr);
        fflush(stderr);
    }
}
// 非致命错误(与系统调用相关):打印错误后返回
void err_ret(const char* fmt, ...)
{
    va_list ap;
    va_start(ap, fmt);
    err_doit(1, LOG_INFO, fmt, ap);  // 打印 errno
    va_end(ap);
    // 直接返回,不退出
}
// 致命错误(与系统调用相关):打印错误后退出
void err_sys(const char* fmt, ...)
{
    va_list ap;
    va_start(ap, fmt);
    err_doit(1, LOG_ERR, fmt, ap);   // 打印 errno
    va_end(ap);
    exit(1);   // 终止程序
}
// 致命错误:打印错误,生成 core dump,然后退出
void err_dump(const char* fmt, ...)
{
    va_list ap;
    va_start(ap, fmt);
    err_doit(1, LOG_ERR, fmt, ap);   // 打印 errno
    va_end(ap);
    abort();   // 生成 core dump,方便用调试器分析
    exit(1);   // 理论上不会执行到这里
}
// 非致命错误(与系统调用无关):打印消息后返回
void err_msg(const char* fmt, ...)
{
    va_list ap;
    va_start(ap, fmt);
    err_doit(0, LOG_INFO, fmt, ap);  // 不打印 errno
    va_end(ap);
}
// 致命错误(与系统调用无关):打印消息后退出
void err_quit(const char* fmt, ...)
{
    va_list ap;
    va_start(ap, fmt);
    err_doit(0, LOG_ERR, fmt, ap);   // 不打印 errno
    va_end(ap);
    exit(1);
}
// ============ 使用示例 ============
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int main()
{
    // 创建一个 TCP 套接字
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0)
        err_sys("socket 创建失败");  // 自动打印 errno,然后 exit(1)
    // 尝试连接一个不存在的服务(演示错误处理)
    struct sockaddr_in servaddr;
    memset(&servaddr, 0, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_port   = htons(9999);  // 目标端口
    inet_pton(AF_INET, "127.0.0.1", &servaddr.sin_addr);
    int ret = connect(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr));
    if (ret < 0) {
        // 使用 err_ret(非致命):打印错误但继续运行
        err_ret("connect 到 127.0.0.1:9999 失败");
        // 程序继续执行...
    }
    err_msg("程序正常结束(无系统错误关联)");
    return 0;
}

运行示例输出:

connect 到 127.0.0.1:9999 失败: Connection refused
程序正常结束(无系统错误关联)

总结:各附录核心要点

网络编程基础

附录A: IP协议

附录B: 虚拟网络

附录C: 调试工具

附录D: 辅助代码

IPv4头部
20字节固定+选项

IPv6头部
40字节固定

ICMP
错误/信息消息

MBone
IPv4-in-IPv4隧道
协议字段=4

6bone
IPv6-in-IPv4隧道
协议字段=41

6to4
自动隧道
2002:/16前缀

ktrace/truss
系统调用跟踪

tcpdump
网络抓包

netstat
网络状态

lsof
端口占用查询

unp.h
统一头文件

error函数族
err_sys/err_ret等

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐