1UNIX网络编程(第3版)

简介

网络编程基础

TCP/IP套接字客户端-服务器模型

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第1章 简介

网络编程基础

一、导读

1.1 本章学习目标

第1章是整本《UNIX网络编程》的开篇之作,它为读者建立起网络编程的全局视图。在深入学习每一个具体的API和协议细节之前,你需要先理解网络通信的基本模型、核心概念和整体架构。本章以TCP/IP协议族为基础,从宏观层面介绍了网络编程所需的基础知识,为后续章节的深入学习铺平道路。

本章的核心学习目标包括:

  • 理解TCP/IP协议族的四层模型:链路层、网络层、传输层和应用层各自的功能是什么?各层之间如何协作?
  • 掌握套接字(socket)的基本概念:什么是套接字?它如何标识网络通信的端点?
  • 理解客户端-服务器模型:网络应用的基本交互模式是什么?客户端和服务器各自扮演什么角色?
  • 区分TCP和UDP的核心差异:可靠字节流与不可靠数据报,各自适用于什么场景?
  • 理解字节序问题:为什么网络编程需要关注大端序和小端序?如何进行转换?
  • 了解POSIX套接字API的整体框架:网络编程涉及哪些核心系统调用?

1.2 为什么第一章如此重要

很多初学者急于跳入具体的代码编写,觉得第一章"太基础"或"太概念化"。这种想法是极其危险的。W. Richard Stevens在本书开篇就强调:网络编程的正确理解始于对整体架构的把握。没有全局观,后续学习就容易犯方向性错误——比如在不该用TCP的场景使用TCP,或者忽视字节序问题导致跨平台兼容性灾难。

网络编程与普通的文件I/O有着本质的区别。文件I/O是本地操作,延迟可预测,错误模式简单;而网络I/O涉及远程通信,延迟不确定,错误模式复杂多变。理解这些根本差异,是编写正确、健壮的网络程序的前提。

此外,本章建立的概念框架将贯穿全书。后续每一章都会引用本章引入的概念,如果你对这些基础概念理解不透彻,后续学习将会事倍功半。

1.3 核心问题引导

在阅读本章时,请带着以下问题思考:

当你在浏览器中输入一个URL时,从按下回车到页面显示,网络层面到底发生了什么?

为什么TCP需要"三次握手"建立连接,而UDP不需要?

套接字和文件描述符是什么关系?为什么说"一切皆文件"在UNIX中如此重要?

为什么网络协议要定义统一的字节序?如果两台机器字节序相同,是否还需要转换?

TCP的"可靠"具体意味着什么?它保证了哪些事情,又不保证哪些事情?

一个端口号能否同时被TCP和UDP使用?为什么?


二、核心概念详解

2.1 TCP/IP协议族与分层模型

2.1.1 为什么要分层

网络通信是一个极其复杂的系统工程。如果将所有问题混在一起解决,任何修改都会牵一发而动全身。分层(layering)是处理复杂系统的经典方法论——每一层只关注自己的职责,通过定义良好的接口为上层提供服务,同时隐藏底层的实现细节。

OSI(Open Systems Interconnection)模型定义了七层协议栈,但这是一个理论模型。实际互联网使用的是更为精简的TCP/IP四层模型:

┌─────────────────────────────────────────┐
│            应用层 (Application)          │  HTTP, FTP, SMTP, DNS, SSH
├─────────────────────────────────────────┤
│            传输层 (Transport)            │  TCP, UDP
├─────────────────────────────────────────┤
│            网络层 (Internet)             │  IP, ICMP, ARP
├─────────────────────────────────────────┤
│          链路层 (Link/Data Link)         │  Ethernet, Wi-Fi, PPP
└─────────────────────────────────────────┘

每一层的核心职责:

  • 应用层:处理特定的应用细节,定义应用进程间通信和交互的规则。HTTP定义了Web浏览器和服务器之间的通信格式,DNS定义了域名到IP地址的映射规则。
  • 传输层:为应用进程提供端到端的通信服务。TCP提供可靠的、面向连接的字节流服务;UDP提供不可靠的、无连接的数据报服务。
  • 网络层:负责将数据报从源主机路由到目的主机。IP协议是核心,它处理寻址和路由。ICMP用于传递控制信息和错误报告。ARP将IP地址映射到物理地址。
  • 链路层:负责在相邻节点之间传输数据帧。它处理与物理介质的接口,包括驱动程序、帧的组装和差错检测。

2.1.2 数据封装与解封装

当数据从应用层传递到物理介质时,每一层都会在自己的头部(有时还有尾部)添加控制信息,这个过程称为封装(encapsulation)。

应用层数据
    ↓
传输层:添加TCP/UDP头部 → [TCP头 | 应用层数据]
    ↓
网络层:添加IP头部 → [IP头 | TCP头 | 应用层数据]
    ↓
链路层:添加帧头和帧尾 → [帧头 | IP头 | TCP头 | 应用层数据 | 帧尾]

接收端则执行相反的过程——解封装(decapsulation):每一层剥掉自己的头部,将剩余部分交给上一层。

c
// 理解封装:一个TCP段在内存中的布局
// 假设应用层发送 "Hello" (5字节)
// TCP头部通常20字节,IP头部通常20字节,以太网帧头14字节+帧尾4字节

// 以太网帧总长度:14 + 20 + 20 + 5 + 4 = 63字节
// 以太网帧最小长度为64字节,所以需要填充到64字节

// 使用tcpdump观察封装过程:
// sudo tcpdump -i eth0 -X -c 1 'tcp port 80'
// -X 选项同时显示十六进制和ASCII

常见误区:很多初学者认为"分层意味着效率低下"。实际上,每层的头部开销相对于MTU(最大传输单元,通常1500字节)来说非常小。以太网MTU为1500字节,TCP+IP头部共40字节,头部开销仅约2.7%。分层带来的模块化优势远远超过这点开销。

2.1.3 协议族 vs 单一协议

TCP/IP不是一个单独的协议,而是一个协议族(protocol family)。除了TCP和IP,它还包括数百个相关协议:

层级主要协议功能
应用层HTTP, HTTPS, FTP, SMTP, DNS, SSH, Telnet应用特定的通信规则
传输层TCP, UDP, SCTP端到端数据传输
网络层IP, ICMP, IGMP, ARP, RARP寻址、路由、控制
链路层Ethernet, Wi-Fi, PPP, SLIP物理链路数据传输

2.2 套接字(Socket)的概念

2.2.1 什么是套接字

套接字是网络通信的端点(endpoint)。在TCP/IP世界中,一个网络连接由一个四元组唯一标识:

(本地IP地址, 本地端口号, 远程IP地址, 远程端口号)

套接字就是这个四元组的一半——(IP地址, 端口号)。一个套接字标识了通信的一端。当两个套接字建立连接后,它们之间就可以进行数据传输。

在UNIX系统中,套接字通过文件描述符(file descriptor)来访问。这就是著名的"一切皆文件"哲学在网络领域的体现。你可以使用 read() 和 write() 来操作套接字,就像操作普通文件一样。但套接字也提供了专用的系统调用(如 send()、recv()、connect()、accept())来获得更精细的控制。

c
// 套接字地址结构

// IPv4套接字地址
struct sockaddr_in {
    sa_family_t    sin_family;    // AF_INET (IPv4)
    in_port_t      sin_port;      // 端口号 (16位)
    struct in_addr sin_addr;      // IPv4地址 (32位)
    char           sin_zero[8];   // 填充字段
};

// 通用套接字地址(用于函数参数的通用指针)
struct sockaddr {
    sa_family_t sa_family;        // 地址族
    char        sa_data[14];      // 协议地址
};

// IPv6套接字地址
struct sockaddr_in6 {
    sa_family_t     sin6_family;  // AF_INET6 (IPv6)
    in_port_t       sin6_port;    // 端口号
    uint32_t        sin6_flowinfo;// 流量信息
    struct in6_addr sin6_addr;    // IPv6地址 (128位)
    uint32_t        sin6_scope_id;// 作用域ID
};

为什么需要通用的 `struct sockaddr`? 因为 bind()、connect()、accept() 等函数需要支持多种地址族(IPv4、IPv6、UNIX域等),它们接受 struct sockaddr * 作为参数,然后在函数内部根据 sa_family 字段判断实际的地址类型。这就是C语言中实现"多态"的经典方式。

2.2.2 端口号

端口号是一个16位的整数,范围从0到65535。它用于标识同一台主机上的不同应用进程。

端口号分为三类:

  • 知名端口(Well-Known Ports):0-1023,由IANA分配,用于标准服务。例如:HTTP使用80,HTTPS使用443,SSH使用22,FTP使用21。
  • 注册端口(Registered Ports):1024-49151,分配给特定的用户服务。
  • 动态/私有端口(Dynamic/Private Ports):49152-65535,通常由操作系统自动分配给客户端作为临时端口。
c
// 端口号需要使用 htons() 进行字节序转换
// htons: host to network short (主机字节序 → 网络字节序)
// ntohs: network to host short (网络字节序 → 主机字节序)

struct sockaddr_in addr;
addr.sin_port = htons(8080);  // 正确:使用htons转换
// addr.sin_port = 8080;      // 错误:直接使用可能导致端口号错误

常见误区:端口号0是保留的,不能用作目标端口。当你在 bind() 中将端口号设为0时,系统会自动分配一个可用的临时端口。这在某些特殊场景(如被动模式FTP的数据连接)中很有用。

2.3 客户端-服务器模型

2.3.1 基本交互模式

网络应用的基本模型是客户端-服务器模型(Client-Server Model):

  • 服务器(Server):被动等待请求。它创建一个监听套接字,然后阻塞在 accept() 调用上,等待客户端的连接请求。
  • 客户端(Client):主动发起请求。它创建一个套接字,然后通过 connect() 向服务器发起连接。

这种模型的核心特征是不对称性:服务器必须一直运行,等待不确定的客户请求;客户则是临时的,只在需要时发起连接。

客户端                          服务器
      │                              │
      │  1. socket()                 │  1. socket()
      │                              │  2. bind()
      │                              │  3. listen()
      │                              │  4. accept() ← 阻塞等待
      │  2. connect() ──────────────→│
      │         SYN                  │  4. accept() 返回
      │         ←──────────────────  │  5. 新的connfd
      │         SYN+ACK              │
      │         ──────────────────→  │  6. read()/write()
      │         ACK                  │     处理请求
      │  3. connect() 返回           │
      │  4. write()/read()           │
      │  ─────────────────────────→  │
      │         数据                  │
      │  5. close()                  │  7. close()
      │                              │

2.3.2 连接的本质

TCP连接是全双工(full-duplex)的,意味着数据可以在两个方向上同时流动。一旦连接建立,客户端和服务器在地位上是对等的——双方都可以发送和接收数据。所谓的"客户端"和"服务器"只是角色区分,不是技术限制。

c
// TCP连接建立后,双方是对等的
// 以下代码在连接建立后可以互换角色

// "客户端"发送请求
write(sockfd, "GET / HTTP/1.1\r\n", 16);

// "服务器"发送响应
write(connfd, "HTTP/1.1 200 OK\r\n", 19);

// 但"客户端"也可以发送大量数据给"服务器"
// 例如FTP上传、POST请求体等

2.3.3 服务器的分类

根据处理客户请求的方式,服务器可以分为:

  • 迭代服务器(Iterative Server):一次只处理一个客户请求,处理完一个才处理下一个。简单但效率低,适合请求处理时间很短的场景(如DNS服务器)。
  • 并发服务器(Concurrent Server):可以同时处理多个客户请求。实现方式包括:

- 每连接一个子进程(fork-per-connection)

- 每连接一个线程

- I/O多路复用(select/poll/epoll)

- 线程池/连接池

2.4 TCP与UDP的核心差异

2.4.1 TCP:可靠的字节流

TCP(Transmission Control Protocol)提供的是面向连接的、可靠的字节流服务。它的核心特征:

  • 面向连接:通信前必须通过三次握手建立连接,通信后通过四次挥手释放连接。
  • 可靠传输:保证数据无差错、不丢失、不重复、按序到达。通过确认(ACK)、超时重传、序列号等机制实现。
  • 字节流服务:TCP不保留消息边界。发送端调用 write("Hello") 和 write("World"),接收端可能一次 read 读到 "HelloWorld",也可能分两次读到 "Hel" 和 "loWorld"。
  • 流量控制:通过滑动窗口机制,防止发送方发送过快导致接收方缓冲区溢出。
  • 拥塞控制:根据网络状况动态调整发送速率,避免网络过载。
c
// TCP字节流无边界 —— 这是初学者最常犯的错误
// 发送端
write(sockfd, "Hello", 5);
write(sockfd, "World", 5);

// 接收端可能读到:
// 第1次read: "HelloWorld" (10字节)
// 或第1次read: "Hel" (3字节), 第2次read: "loWorld" (7字节)
// 或第1次read: "H" (1字节), 第2次read: "elloWorld" (9字节)
// 具体结果取决于TCP的实现和网络状况

// 解决方案:在应用层定义消息边界
// 方案1:固定长度消息
// 方案2:特殊分隔符(如HTTP的\r\n)
// 方案3:消息头中包含长度字段

2.4.2 UDP:不可靠的数据报

UDP(User Datagram Protocol)提供的是无连接的、不可靠的数据报服务。它的核心特征:

  • 无连接:不需要建立连接,每个数据报独立处理。
  • 不可靠:不保证数据报到达、不保证按序到达、不保证不重复。
  • 数据报服务:保留消息边界。发送端调用 sendto("Hello", 5),接收端 recvfrom 要么读到完整的 "Hello",要么什么都读不到。
  • 开销小:UDP头部只有8字节(TCP头部最少20字节),没有连接建立/释放的开销。
  • 支持多播和广播:UDP可以向多个目标同时发送数据。
c
// UDP数据报保留消息边界
// 发送端
sendto(sockfd, "Hello", 5, 0, (struct sockaddr *)&addr1, sizeof(addr1));
sendto(sockfd, "World", 5, 0, (struct sockaddr *)&addr2, sizeof(addr2));

// 接收端
// recvfrom 要么读到完整的 "Hello",要么完整的 "World"
// 不会出现 "Hel" 或 "loWorld" 这样的部分读取

2.4.3 选择TCP还是UDP

特性TCPUDP
连接面向连接无连接
可靠性可靠不可靠
有序性保证有序不保证
消息边界无(字节流)有(数据报)
头部开销20字节起8字节
速度较慢较快
拥塞控制有无
适用场景Web、文件传输、邮件DNS、视频流、游戏

选择原则:如果你的应用不能容忍数据丢失或乱序,使用TCP;如果你的应用对延迟敏感,可以容忍少量丢失,使用UDP。

2.5 字节序问题

2.5.1 大端序与小端序

字节序(byte order)是指多字节数据在内存中的存储顺序。

  • 大端序(Big-Endian):高字节在低地址。例如 0x12345678 存储为 12 34 56 78。
  • 小端序(Little-Endian):低字节在低地址。例如 0x12345678 存储为 78 56 34 12。

不同的处理器架构使用不同的字节序。x86/x86-64使用小端序,而ARM可以配置为大端或小端。

网络字节序被定义为大端序。这是因为TCP/IP协议设计时,需要一种统一的字节序来确保不同架构的机器之间能正确交换数据。

c
// 字节序转换函数
#include <arpa/inet.h>

// 主机字节序 → 网络字节序
uint16_t htons(uint16_t hostshort);  // short (16位),用于端口号
uint32_t htonl(uint32_t hostlong);   // long (32位),用于IP地址

// 网络字节序 → 主机字节序
uint16_t ntohs(uint16_t netshort);
uint32_t ntohl(uint32_t netlong);

// 使用示例
struct sockaddr_in addr;
addr.sin_port = htons(8080);                    // 端口号必须转换
inet_pton(AF_INET, "192.168.1.1", &addr.sin_addr); // inet_pton自动处理字节序

// 检测当前主机的字节序
#include <stdio.h>
int check_byte_order(void) {
    union {
        uint32_t i;
        char c[4];
    } test;
    test.i = 0x12345678;
    if (test.c[0] == 0x12) {
        printf("Big-Endian\n");
        return 1;
    } else {
        printf("Little-Endian\n");
        return 0;
    }
}

2.5.2 为什么需要统一字节序

假设一台小端序机器(如x86)向一台大端序机器发送整数 0x12345678。如果不做字节序转换,发送方在内存中存储为 78 56 34 12,接收方按大端序解释为 0x78563412——完全不同的值!

网络字节序(大端序)作为统一标准,确保了无论通信双方的主机字节序如何,数据都能被正确解释。

常见误区:

  • "我的机器是小端序,对方也是小端序,所以不需要转换。"——这是错误的。代码应该在任何字节序的机器上都能正确运行,不能依赖特定硬件。
  • "单字节数据不需要考虑字节序。"——这是正确的。字节序只影响多字节数据。

2.6 POSIX套接字API概览

POSIX(Portable Operating System Interface)定义了一套标准的套接字API,使得网络程序可以在不同的UNIX系统上移植。以下是核心API的分类:

2.6.1 套接字创建与地址操作

c
// 创建套接字
int socket(int domain, int type, int protocol);
// domain: AF_INET (IPv4), AF_INET6 (IPv6), AF_UNIX (UNIX域)
// type: SOCK_STREAM (TCP), SOCK_DGRAM (UDP)
// protocol: 通常为0,让系统自动选择

// 绑定地址
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

// 地址转换
int inet_pton(int af, const char *src, void *dst);  // 点分十进制 → 二进制
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size); // 二进制 → 点分十进制

2.6.2 TCP专用函数

c
// 客户端发起连接
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

// 服务器监听
int listen(int sockfd, int backlog);

// 服务器接受连接
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
// 返回新的已连接套接字描述符

2.6.3 数据传输函数

c
// 基本读写(适用于所有套接字)
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);

// TCP专用
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
ssize_t send(int sockfd, const void *buf, size_t len, int flags);

// UDP专用(需要指定/获取对端地址)
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
                 struct sockaddr *src_addr, socklen_t *addrlen);
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
               const struct sockaddr *dest_addr, socklen_t addrlen);

2.6.4 连接关闭

c
int close(int sockfd);           // 关闭套接字,触发TCP四次挥手
int shutdown(int sockfd, int how); // 更精细的控制
// how: SHUT_RD (关闭读), SHUT_WR (关闭写), SHUT_RDWR (关闭读写)

三、实战案例

3.1 一个简单的TCP客户端

下面是一个完整的TCP客户端示例,它连接到本地的echo服务器,发送一条消息并接收回显。

c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define SERV_PORT 8080
#define MAXLINE 1024

int main(int argc, char **argv) {
    int sockfd, n;
    struct sockaddr_in servaddr;
    char sendline[MAXLINE], recvline[MAXLINE + 1];

    if (argc != 2) {
        fprintf(stderr, "usage: %s <server-ip>\n", argv[0]);
        exit(1);
    }

    // 1. 创建TCP套接字
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0) {
        perror("socket error");
        exit(1);
    }

    // 2. 设置服务器地址
    memset(&servaddr, 0, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_port = htons(SERV_PORT);
    if (inet_pton(AF_INET, argv[1], &servaddr.sin_addr) <= 0) {
        fprintf(stderr, "inet_pton error for %s\n", argv[1]);
        exit(1);
    }

    // 3. 连接到服务器
    if (connect(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
        perror("connect error");
        exit(1);
    }

    // 4. 从标准输入读取,发送到服务器,接收回显
    while (fgets(sendline, MAXLINE, stdin) != NULL) {
        n = strlen(sendline);
        if (write(sockfd, sendline, n) != n) {
            perror("write error");
            exit(1);
        }
        n = read(sockfd, recvline, MAXLINE);
        if (n < 0) {
            perror("read error");
            exit(1);
        } else if (n == 0) {
            printf("server terminated prematurely\n");
            exit(1);
        }
        recvline[n] = '\0';
        fputs(recvline, stdout);
    }

    // 5. 关闭连接
    close(sockfd);
    return 0;
}

代码解析:

socket(AF_INET, SOCK_STREAM, 0) 创建一个IPv4的TCP套接字。SOCK_STREAM 指定了字节流服务(TCP)。

inet_pton 将命令行参数中的点分十进制IP地址转换为二进制格式。这个函数是线程安全的,优于旧版的 inet_addr。

connect 触发TCP三次握手。如果服务器不可达,connect 会阻塞直到超时(通常75秒到几分钟)。

主循环从标准输入读取数据,发送到服务器,然后读取服务器的回显。注意 read 返回0表示对端正常关闭连接(发送了FIN)。

close 触发TCP四次挥手。

编译和运行:

bash
gcc -o tcpcli tcp_cli.c
./tcpcli 127.0.0.1
# 输入一行文字,按回车,应该看到服务器回显相同内容

3.2 一个简单的TCP回显服务器

c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define SERV_PORT 8080
#define MAXLINE 1024
#define LISTENQ 128

int main(void) {
    int listenfd, connfd;
    struct sockaddr_in servaddr;
    char buf[MAXLINE];
    ssize_t n;

    // 1. 创建TCP套接字
    listenfd = socket(AF_INET, SOCK_STREAM, 0);
    if (listenfd < 0) {
        perror("socket error");
        exit(1);
    }

    // 2. 绑定地址和端口
    memset(&servaddr, 0, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);  // 监听所有接口
    servaddr.sin_port = htons(SERV_PORT);

    if (bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
        perror("bind error");
        exit(1);
    }

    // 3. 开始监听
    if (listen(listenfd, LISTENQ) < 0) {
        perror("listen error");
        exit(1);
    }

    printf("Echo server listening on port %d...\n", SERV_PORT);

    // 4. 迭代处理客户连接
    for (;;) {
        struct sockaddr_in cliaddr;
        socklen_t clilen = sizeof(cliaddr);

        connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &clilen);
        if (connfd < 0) {
            perror("accept error");
            continue;
        }

        char cli_ip[INET_ADDRSTRLEN];
        inet_ntop(AF_INET, &cliaddr.sin_addr, cli_ip, sizeof(cli_ip));
        printf("Connection from %s, port %d\n", cli_ip, ntohs(cliaddr.sin_port));

        // 回显循环
        while ((n = read(connfd, buf, MAXLINE)) > 0) {
            write(connfd, buf, n);
        }

        if (n < 0) {
            perror("read error");
        }

        close(connfd);
        printf("Connection closed\n");
    }

    close(listenfd);
    return 0;
}

代码解析:

INADDR_ANY(即 0.0.0.0)表示绑定所有网络接口。这样服务器可以接收来自任何网卡的连接。

listen 的第二个参数 LISTENQ(128)指定了监听队列的最大长度。当队列满时,新的连接请求会被拒绝(客户端收到RST)。

accept 从监听队列中取出一个已完成的连接,返回一个新的文件描述符 connfd。这个新描述符代表与特定客户的连接,而 listenfd 继续用于监听新的连接。

这是一个迭代服务器——一次只处理一个客户。当 read 返回0(客户关闭连接)或负数(出错)时,关闭 connfd 并回到 accept 等待下一个客户。

运行测试:

bash
# 终端1:启动服务器
gcc -o tcpser tcp_ser.c
./tcpser

# 终端2:启动客户端
./tcpcli 127.0.0.1
Hello, World!        ← 输入
Hello, World!        ← 服务器回显

# 终端3:使用nc测试
nc 127.0.0.1 8080
test message         ← 输入
test message         ← 回显

3.3 字节序检测工具

c
#include <stdio.h>
#include <stdint.h>
#include <arpa/inet.h>

int main(void) {
    // 方法1:通过union检测
    union {
        uint32_t i;
        char c[4];
    } u;
    u.i = 0x01020304;

    printf("Method 1: Union test\n");
    printf("  0x01020304 stored as: %02x %02x %02x %02x\n",
           (unsigned char)u.c[0], (unsigned char)u.c[1],
           (unsigned char)u.c[2], (unsigned char)u.c[3]);

    if (u.c[0] == 0x01) {
        printf("  → Big-Endian (Network Byte Order)\n");
    } else {
        printf("  → Little-Endian\n");
    }

    // 方法2:通过htons检测
    printf("\nMethod 2: htons test\n");
    uint16_t host_val = 0x0102;
    uint16_t net_val = htons(host_val);

    printf("  Host value:  0x%04x\n", host_val);
    printf("  Network value: 0x%04x\n", net_val);

    if (host_val == net_val) {
        printf("  → Host is Big-Endian (same as network)\n");
    } else {
        printf("  → Host is Little-Endian (different from network)\n");
    }

    // 方法3:实际端口转换演示
    printf("\nMethod 3: Port conversion demo\n");
    uint16_t port = 8080;
    printf("  Port %u in host order: 0x%04x\n", port, port);
    printf("  Port %u in network order: 0x%04x\n", port, htons(port));
    printf("  Converted back: %u\n", ntohs(htons(port)));

    return 0;
}

运行结果(在x86-64 Linux上):

Method 1: Union test
  0x01020304 stored as: 04 03 02 01
  → Little-Endian

Method 2: htons test
  Host value:  0x0102
  Network value: 0x0201
  → Host is Little-Endian (different from network)

Method 3: Port conversion demo
  Port 8080 in host order: 0x1f90
  Port 8080 in network order: 0x901f
  Converted back: 8080

这个工具清晰地展示了主机字节序与网络字节序的差异。在x86-64(小端序)上,htons(8080) 将 0x1F90 转换为 0x901F——字节顺序被反转了。


四、本章小结

本章作为全书的开篇,建立了网络编程的基础知识框架。核心要点回顾:

TCP/IP四层模型:应用层、传输层、网络层、链路层。数据从上到下逐层封装,从下到上逐层解封装。每一层为上层提供服务,同时隐藏底层细节。

套接字是通信端点:由IP地址和端口号唯一标识。在UNIX中,套接字通过文件描述符访问,可以使用标准的 read/write 操作,也可以使用专用的套接字函数。

客户端-服务器模型:服务器被动等待,客户主动连接。TCP连接是全双工的,建立连接后双方地位对等。

TCP vs UDP:TCP提供可靠字节流(无消息边界),UDP提供不可靠数据报(有消息边界)。选择取决于应用需求——可靠性优先选TCP,延迟优先选UDP。

字节序:网络字节序为大端序。所有在网络上传输的多字节整数都需要在主机字节序和网络字节序之间转换(htons/htonl/ntohs/ntohl)。

POSIX套接字API:socket→bind→listen→accept(服务器端),socket→connect(客户端)。这些API是跨平台的网络编程标准接口。

这些概念将贯穿全书。后续章节会在此基础上深入每一个主题,从基本的TCP编程到高级的I/O复用、非阻塞I/O和多线程编程。


五、思考与练习

思考题

为什么TCP/IP模型只有四层而OSI模型有七层?OSI模型的会话层和表示层在TCP/IP中是如何处理的?

一个TCP连接的四元组中,如果两个客户端使用相同的源端口连接到同一服务器的同一端口,会发生什么?

为什么UDP需要保留消息边界而TCP不需要?这与它们各自的实现机制有什么关系?

如果网络中所有机器都是小端序的,是否可以去掉字节序转换?这样做有什么潜在问题?

accept() 返回的文件描述符和 listenfd 有什么区别?为什么需要两个不同的描述符?

编程练习

修改echo客户端:在客户端添加超时处理,如果服务器在5秒内没有响应,打印错误信息并退出。提示:使用 setsockopt 设置 SO_RCVTIMEO。

实现一个简单的HTTP客户端:连接到 www.example.com:80,发送 GET / HTTP/1.0\r\nHost: www.example.com\r\n\r\n,打印服务器的完整响应。观察HTTP响应的结构。

编写字节序可视化程序:接收一个32位整数作为命令行参数,分别以大端序和小端序输出其在内存中的字节排列,并验证 htonl 的转换结果。

实现一个多客户echo服务器:使用fork为每个客户创建子进程,使服务器能同时处理多个客户连接。使用 waitpid 和 SIGCHLD 信号处理子进程的退出。

1/10简介
0%