网络编程基础学习

29 阅读14分钟

1、计算机网络组成

1.1 网络分层的目的: 把“通信问题”拆分成多个职责清晰、可以独立演进的模块

  • 分层带来的 4 个工程价值:
    • ① 职责隔离:TCP 只管可靠传输、IP 只管寻址 + 路由、应用只管:业务逻辑
    • ② 可替换性(工程扩展能力):TCP 能被 QUIC 替换、IPv4 能升级 IPv6
    • ③ 可调试性
    • ④ 团队协作

1.2 OSI vs TCP/IP

  • OSI:(Open System Interconnect)七层模型是学术界的标准,它将网络通信细分成了 7 个逻辑层。
物理层:传输比特流(0 和 1)。
数据链路层:把比特流封装成“帧”,负责相邻节点间的传输。
网络层:负责路由选择,决定数据包走哪条路。
传输层:负责端到端的可靠性(TCP)或效率(UDP)。
会话层:管理连接。
表示层:数据格式化、加密解密。
应用层:写的 C 代码直接交互的地方。      

几乎不需要区分 5、6、7 层,通常被统一看作“自己应用程序”要处理的事

OSI 是“讲清楚道理的模型”,不是“写代码的模型”

  • TCP/IP
网络接口层(Network Interface) :对应 OSI 的物理层和数据链路层。
网络层(Internet) :核心协议是 IP 协议。
传输层(Transport):核心是 TCP 和 UDP。
应用层(Application) :对应 OSI 的上三层(写的代码)

从“传输层”到“网络接口层”全部由 Linux 内核处理,开发者

  1. 负责应用层
  2. 通过 Socket 套接字与“传输层”打交道
  3. 通过 TCP 协议发这段话给 IP X.X.X.X 的端口号 Y

2、TCP / UDP 协议

TCP 是“字节流协议”,UDP 是“报文协议”

  • TCP 服务端流程
socket():买一部手机。
bind() :办一张手机卡,绑定一个号码(IP+端口)。
listen() :等电话响,设置最大排队人数。
accept() :按接听键。注意:这会产生一个新的 Socket 专门用于通话。
read() / write() :说话与听话。
close():挂断。
  • TCP 客户端流程
socket():买一部手机。
connect() :拨号给对方。三次握手
read() / write() :通话。
close():挂断。
特性TCPUDP
连接性面向连接(必先握手)无连接(直接发)
可靠性极高(丢包自动重传)无(丢包不管)
速度较慢(为了可靠牺牲了速度)极快
数据边界字节流(像自来水,没头没尾,没有消息边界一切靠设计协议)数据包(像包裹,一个是一个)
应用网页(HTTP)、文件传输(FTP)、邮件视频流、语音、在线游戏

TCP 关心的是:

  • 字节是否可靠到达
  • 顺序是否正确

TCP 不关心

  • 你一次 send 了多少
  • 应用想怎么切消息

2.1、 三次握手:建立信任的过程

三次握手的核心目的是:确认双方的接收发送能力都是正常的,并交换初始序列号。

  1. 第一次握手:SYN (Synchronize)

    • 动作:客户端向服务端发送一个 SYN 包,并随机初始化一个序列号 JJ
    • 状态:客户端进入 SYN_SENT 状态。
    • 意义:服务端收到后,知道客户端的发送能力正常。
  2. 第二次握手:SYN + ACK (Acknowledgement)

    • 动作:服务端收到 SYN 包,回一个自己的 SYN(序列号 KK),同时回一个 ACK(值为 J+1J+1)作为确认。
    • 状态:服务端进入 SYN_RCVD 状态。
    • 意义:客户端收到后,知道服务端的发送和接收能力都正常。
  3. 第三次握手:ACK

    • 动作:客户端再发一个确认包 ACK(值为 K+1K+1)。
    • 状态:双方进入 ESTABLISHED(连接建立)状态。
    • 意义:服务端收到后,知道客户端的接收能力也正常。

三次握手(建立通话)

当服务器的网卡收到第三次 ACK后,内核 TCP 协议栈认为:

  • 序列号、ACK 号、窗口都合法
  • 连接进入 ESTABLISHED 状态
  • 把这条连接放进 accept 队列(也叫全连接队列)

连接被放进 accept 队列 → 内核把 listen_fd 标记为“可读”
epoll 返回 EPOLLIN → 你的 events[i].data.fd == listen_fd 触发

accept 只做“取件”

  • 内核从 accept 队列弹出一个已 ESTABLISHED 的连接

  • 为你生成一个新的 socket(conn_fd)

  • 把对端地址填进 client_addr

  • 不再做任何握手包收发——握手阶段已结束

服务器侧)

  1. 用户态 listen(fd, backlog); // 只设铃声,不碰包

  2. 内核态(完全自动)

    • 收到 SYN(第 1 次握手)
    • SYN+ACK(第 2 次握手)
    • 收到 ACK(第 3 次握手)
    • 此时连接 ESTABLISHED
    • 把这条连接放进 accept 队列
  3. 用户态(再次) epoll_wait(...) 返回; // 因为队列非空,listen_fd 可读 new_fd = accept(...); // 从队列里拿出已握好的连接,生成新 socket


accept 内部做了什么? 关键:

为什么不是两次?如果只有两次,服务端发完 SYN+ACK 就认为连接建立了,但如果这个包在网络中丢了,客户端不知道连接已开,服务端却一直空等,会浪费大量系统资源。

2.2、 四次挥手:分手

TCP 是全双工的,这意味着两条线(A 到 B,B 到 A)必须分别关闭。

  1. 第一次挥手:FIN (Finish)

    • 动作:一方(假设是客户端)主动调用 close(),向对方发送 FIN 包。
    • 状态:客户端进入 FIN_WAIT_1
  2. 第二次挥手:ACK

    • 动作:服务端收到 FIN,先回一个 ACK 告诉对方:“我知道你想分了,但我可能还有数据没发完,你等我一下”。
    • 状态:服务端进入 CLOSE_WAIT,客户端进入 FIN_WAIT_2
  3. 第三次挥手:FIN

    • 动作:服务端处理完所有残留数据,也调用 close(),向客户端发 FIN。
    • 状态:服务端进入 LAST_ACK
  4. 第四次挥手:ACK

    • 动作:客户端收到 FIN,回一个 ACK。
    • 状态:客户端进入 TIME_WAIT 状态,等待 2MSL 时间后彻底关闭。服务端收到 ACK 后直接进入 CLOSED 状态。
  • 第四次挥手
客户端(主动关闭)              服务端(被动关闭)
        │                              │
        │──── FIN ──────────────────>  │
        │<─── ACK ───────────────────  │
        │                              │
        │<─── FIN ───────────────────  │
   TIME_WAIT ◄───┐                   LAST_ACK
        │        │                     │
        │──── ACK ──────────────────>  │
        │        │                     │
   (等2MSL)    │                   CLOSED ✅
        │        │
   CLOSED ✅ ◄───┘
客户端服务端
收到 FIN → 立即进 TIME_WAIT发 FIN → 进 LAST_ACK
从 TIME_WAIT 发 ACK收到 ACK → 立即 CLOSED
独自等 2MSL早已结束
最后 CLOSED

四次挥手(挂断通话)

场景:客户端先说“我说完了”,服务器还有话要讲。

  1. 客户端说“我说完了” → close(conn_fd)
    发送 FIN(第一次挥手)
    客户端状态:FIN_WAIT_1
  2. 服务器听见 → read() 返回 0
    ACK(第二次挥手)
    服务器状态:CLOSE_WAIT;客户端状态:FIN_WAIT_2
    客户端→服务器方向已关闭,但服务器仍可对客户端说话。
  3. 服务器也说完了 → close(conn_fd)
    发送 FIN(第三次挥手)
    服务器状态:LAST_ACK;客户端状态:TIME_WAIT
  4. 客户端回最终 ACK(第四次挥手)
    服务器收到后进入 CLOSED —— 通话专线销毁
    客户端维持 TIME_WAIT 2MSL(约 60 秒)防止旧包干扰,之后也 CLOSED

3、 Socket 套接字

Socket 编程就是把“复杂的协议栈操作”变成“简单的文件读写”的过程。

3.1 Socket 简介:网络编程的“把手”

在 Linux 里,“万物皆文件”。

  • 写日志有个 fd(文件描述符),操作网络也有个 fd,就是 Socket
  • Socket 到底是什么? 它其实是应用层与传输层之间的一个接口。你不需要知道 TCP 是怎么重传数据的,你只需要把数据塞进 Socket 这个“把手”里,内核就会帮你把它发到互联网的另一头。

3.2 最小服务器、客户端尝试

  • 服务器
// tcp_server.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>

int main() {
    // 1. 创建 socket(网络 fd)
    int listenfd = socket(AF_INET, SOCK_STREAM, 0);//AF_INET:用 IPv4 协议 SOCK_STREAM:要用面向连接的字节流(即 TCP)
    if (listenfd < 0) {
        perror("socket");
        exit(1);
    }

    // 2. 准备服务器地址
    struct sockaddr_in addr;
    //struct sockaddr:这是“通用地址结构”,它是为了兼容各种协议(IPv4, IPv6, 甚至蓝牙)设计的“老古董”。
    //struct sockaddr_in:这是专门给 IPv4 使用的地址结构
    //在代码里填充数据时,永远用 struct sockaddr_in(因为它有具体的 sin_port 和 sin_addr 字段,好操作);但在调用系统函数(如 bind 或 connect)时,必须强转成 struct sockaddr*。
    memset(&addr, 0, sizeof(addr));       //把结构体 addr 整块内存清零 等价struct sockaddr_in addr = {0};   // 整个结构体清零
    addr.sin_family = AF_INET;            // 协议族:IPv4
    addr.sin_port = htons(8888);          // 端口号:必须转成网络字节序,不同 CPU 存 16 位数的顺序不一样:x86/x64 是小端:低位字节在前(低地址)。网络协议规定“大端”:高位字节在前。
    addr.sin_addr.s_addr = INADDR_ANY;    // 这是一个通配地址,监听所有网卡


    int opt = 1;
    if (setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) {
    perror("setsockopt");
    exit(1);
    }
    
    // 3. 绑定地址和端口
    if (bind(listenfd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
        perror("bind");
        exit(1);
    }

    // 4. 开始监听
    if (listen(listenfd, 128) < 0) {//128 (Backlog):这代表“请求队列”的大小
        perror("listen");
        exit(1);
    }

    printf("server listening on port 8888...\n");

    // 5. 等待客户端连接
    int connfd = accept(listenfd, NULL, NULL);//阻塞等待:程序运行到这里会“卡住”,直到有一个客户端完成了 TCP 三次握手
    //accept 成功后会返回一个全新的文件描述符
    if (connfd < 0) {
        perror("accept");
        exit(1);
    }

    // 6. 接收数据
    char buf[1024];
    int n = recv(connfd, buf, sizeof(buf) - 1, 0);//recv / send:这是专门用于套接字的读写函数。成功返回实际读/写的字节数
    if (n > 0) {
        buf[n] = '\0';
        printf("server received: %s\n", buf);
    }

    // 7. 回发数据
    send(connfd, buf, n, 0);

    // 8. 关闭连接
    close(connfd);//触发 TCP 四次挥手
    close(listenfd);

    return 0;
}

客户端

// tcp_client.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>

int main() {
    // 1. 创建 socket
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);

    // 2. 服务器地址
    struct sockaddr_in serv;
    memset(&serv, 0, sizeof(serv));
    serv.sin_family = AF_INET;
    serv.sin_port = htons(8888);
    inet_pton(AF_INET, "127.0.0.1", &serv.sin_addr);//把人类可读的字符串 IP("127.0.0.1")转成网络字节序的 32 位二进制数
    
    //客户端不需要固定端口。调用 connect 时,内核会自动从系统临时端口范围(通常是 32768-60999)里选一个没被占用的端口分配给这个 sockfd
    
    // 3. 发起连接
    connect(sockfd, (struct sockaddr*)&serv, sizeof(serv));//触发“三次握手”让本地套接字 sockfd 按照地址结构 serv 里给出的 IP+端口,去跟远端服务器建立连接。
                                                           //sizeof(serv):告诉内核“地址结构体到底有多长”,以免它越界访问。
    // 4. 发送数据
    char msg[] = "hello tcp";
    send(sockfd, msg, strlen(msg), 0);//将数据拷贝到内核的 TCP 发送缓冲区。如果缓冲区满了,send 会阻塞

    // 5. 接收回显
    char buf[1024];
    int n = recv(sockfd, buf, sizeof(buf) - 1, 0);//这是一个同步阻塞调用。 如果服务器没回消息,客户端会一直停在这里等
    buf[n] = '\0';
    printf("client received: %s\n", buf);

    close(sockfd);//发起 FIN 包,开始四次挥手。
    return 0;
}

  • 若不打开浏览器在两个终端正常运行,若打开浏览器时运行出现问题及原因
songu@LZD-20241025LBM:~/linux_c$ ./server
server listening on port 8888...
server received: GET /?id=6180dc18-cb10-4bba-a359-141bc8c4d4fa&vscodeBrowserReqId=1768310746711 HTTP/1.1
Host: localhost:8888
Connection: keep-alive
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Code/1.108.0 Chrome/142.0.7444.235 Electron/39.2.7 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: cross-site
Sec-Fetch-Mode: navigate
Sec-Fetch-Dest: iframe
Sec-Fetch-Storage-Access: active
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: zh-CN

原因: server 运行在 8888 端口。现代开发环境(比如 VS Code 或浏览器)有时会自动扫描本地打开的端口,现这个端口有反应,立刻发起了一个 HTTP 请求server 里的 accept 成功接听了浏览器的电话,而不是 client,于是,server 打印出了浏览器发来的 HTTP 报文

流程:

  1. Server 启动:进入 accept 阻塞等待。

  2. 浏览器连接accept 返回了浏览器的 connfd

  3. Server 执行逻辑:收到 HTTP 请求 -> 打印 -> 把 HTTP 请求原样回发给浏览器 -> 执行 close(connfd)close(listenfd)

  4. Server 进程结束:程序跑完了最后一行 return 0服务器关门了

  5. Client 启动:此时 client 试图去连 8888 端口。

    • 此时服务器程序已经退出了,系统内核里没有在监听 8888
    • client 代码里没检查 connect 的返回值。
    • connect 其实失败了,但 client 假装成功,继续往下跑 sendrecv
    • 因为没连上,recv 拿不到数据,所以 client 窗口什么都没打印就直接结束了。
  • 改进

服务器

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <ctype.h>

#define SERVER_PORT 8888
#define BACKLOG 128  // 工业标准排队长度

int main() {
    // 1. 创建监听套接字
    int lfd = socket(AF_INET, SOCK_STREAM, 0);
    if (lfd < 0) { perror("socket"); exit(1); }

    // 设置端口复用:解决 "Address already in use" 报错
    int opt = 1;
    setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    // 2. 绑定地址
    struct sockaddr_in serv_addr;
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(SERVER_PORT); 
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); 

    if (bind(lfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) {
        perror("bind"); exit(1);
    }

    // 3. 监听
    if (listen(lfd, BACKLOG) < 0) { perror("listen"); exit(1); }

    printf("--- 服务器已启动,监听端口: %d ---\n", SERVER_PORT);

/* --- 核心业务 --- */
    while (1) {
        struct sockaddr_in cli_addr;
        socklen_t cli_len = sizeof(cli_addr);

        // 【外层】accept 会阻塞,直到三次握手完成
        int cfd = accept(lfd, (struct sockaddr *)&cli_addr, &cli_len);
        if (cfd < 0) {
            perror("accept error"); 
            continue;
        }

        // 【身份提取】提取客户端信息,方便日志记录
        char cli_ip[INET_ADDRSTRLEN];
        printf(">>> 监听到新连接: %s:%d (分配通信 FD: %d)\n", 
               inet_ntop(AF_INET, &cli_addr.sin_addr, cli_ip, sizeof(cli_ip)),
               ntohs(cli_addr.sin_port), cfd);

        // 【内层:交谈】处理同一个客户端的多次请求
        char buf[1024];
        while (1) {
            // recv 也是阻塞的,没数据传过来就会停在这里
            int n = recv(cfd, buf, sizeof(buf) - 1, 0);

            if (n > 0) {
                buf[n] = '\0'; // 行业标准:手动补齐字符串结束符,防止打印乱码
                printf("[%s:%d]: %s", cli_ip, ntohs(cli_addr.sin_port), buf);

                // 简单的协议处理:转大写
                for (int i = 0; i < n; i++) buf[i] = toupper(buf[i]);
                
                // 将处理结果发回客户端
                send(cfd, buf, n, 0);
            } 
            else if (n == 0) {
                // 收到 FIN 包,说明客户端调用了 close()
                printf("<<< 客户端 %s:%d 已主动断开连接。\n", cli_ip, ntohs(cli_addr.sin_port));
                break; // 退出当前交谈循环
            } 
            else {
                perror("recv error");
                break;
            }
        }

        // 【收尾】一次服务结束,必须释放此 FD,否则会造成文件描述符泄漏
        close(cfd); 
        printf("--- 系统已重置,继续监听下一个请求 ---\n\n");
    }

    close(lfd); //
    return 0;
}

客户端

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>

#define SERVER_IP "127.0.0.1"
#define SERVER_PORT 8888

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

    // 2. 构造服务器地址
    struct sockaddr_in serv_addr;
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(SERVER_PORT);
    inet_pton(AF_INET, SERVER_IP, &serv_addr.sin_addr); // 字符串转网络二进制

    // 3. 连接服务器:触发三次握手
    if (connect(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) {
        perror("connect"); exit(1);
    }
    printf("已成功连接到服务器 %s:%d\n", SERVER_IP, SERVER_PORT);

    // 4. 多轮交互逻辑
    char buf[1024];
    while (1) {
        printf("请输入消息 (输入 quit 退出): ");
        if (fgets(buf, sizeof(buf), stdin) == NULL) break;
        
        if (strncmp(buf, "quit", 4) == 0) break;

        // 发送数据
        send(sockfd, buf, strlen(buf), 0);

        // 接收服务端处理后的回显
        int n = recv(sockfd, buf, sizeof(buf) - 1, 0);
        if (n <= 0) {
            printf("服务器连接已断开。\n");
            break;
        }
        buf[n] = '\0';
        printf("服务器回复: %s", buf);
    }

    // 5. 关闭连接
    close(sockfd);
    printf("通信结束,已关闭。");
    return 0;
}

运行结果显示

image.png

image.png

4、总结

  • 一个模型:TCP/IP 四层架构
  • 两套协议:TCP vs UDP
  • 六个核心函数:Socket 的生命周期
    • socket() :向内核申请一个网络通信的句柄(文件描述符)。

    • bind() :给套接字贴上标签(绑定具体的 IP 和端口)。

    • listen() :让套接字进入“待机”状态,准备接收连接。

    • accept() :从完成握手的队列中取出一个连接(产生用于通信的 cfd)。

    • connect() :客户端主动发起连接,触发三次握手。

    • recv() / send() :在已建立的连接上进行数据读写。

  • 三个其他点
    • 字节序

      • 网络传输必须是大端序。
      • 定律:填充地址用 htons(),打印地址用 ntohs()
    • 地址转换

      • 通过 inet_ptoninet_ntop 在字符串 IP("127.0.0.1")和内核二进制 IP 之间转换。
    • 四次挥手与 TIME_WAIT

      • 主动关闭连接的一方会进入 TIME_WAIT 状态。
      • 使用 setsockopt(SO_REUSEADDR) 允许立即重启服务器,避免“端口被占用”报错。