UNIX Network Programming The Sockets Networking API学习:
对应《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 过滤器示例
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 数据库。
检测方法:
- 自己构建 UDP 数据包(DNS 查询),通过原始套接字发送
- 用 libpcap 读取服务器的 UDP 回复(因为普通 UDP 套接字看不到校验和字段)
- 检查回复包里
ui_sum字段是否为 0(0 表示未启用校验和)
为什么不能用普通 UDP 套接字收: 内核在把 UDP 包交给应用前会剥掉 UDP 头,应用看不到校验和字段。
程序架构
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=∼(i∑wordi)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 一个子进程。
问题: 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传描述符- 所有子进程的流管道:子进程完成一个客户时写一字节通知父进程
为什么比互斥锁慢(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 ← 通知工作线程有新任务
工作线程等待条件变量:
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 总结与选择建议
关键结论:
- 高负载下,进程池/线程池比"每客户创建"快 10 倍以上
- 用线程通常比用进程快(行6=0.99s < 行2=1.80s)
- 让子进程/线程直接调用
accept比父进程传描述符更简单更快 - 多个进程/线程阻塞在
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 中的实现
三个主要服务接口:
| 接口名 | 全称 | 层次 |
|---|---|---|
| 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 消息交互流程
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) |
将模块从流中弹出 |
三章总结对比
网络编程附录详解: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 = 65535216−1=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}2128≈3.4×1038
相比 IPv4 的 232≈4.3×1092^{32} \approx 4.3 \times 10^9232≈4.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 = 3025−2=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=0x0c106=0x6a32=0x20254=0xfe- 所以 IPv4 地址十六进制为
0c6a:20fe
A.6 ICMP 协议(ICMPv4 和 ICMPv6)
ICMP 是 IP 网络中的"信使"协议,用于在网络节点间传递错误和信息消息。ping 和 traceroute 都基于 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 年,是早期的多播虚拟网络。
工作流程
关键思路: 把多播数据报"套"在单播 IPv4 数据报里穿越不支持多播的网络,到达另一端再"剥壳"还原。
B.3 6bone(IPv6 测试骨干网)
6bone 建立于 1996 年,用途类似 MBone,但是为 IPv6 服务,使用 IPv6-in-IPv4 隧道(协议字段 = 41)。
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) # 正常退出
程序执行流程:
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:进程 IDFD:文件描述符号(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
程序正常结束(无系统错误关联)
总结:各附录核心要点
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)