计算机网络

网络基础

重要概念

[!TIP]

  • 区分局域网与广域网

局域网(LAN):有限地理范围(如办公室、校园),常见技术有以太网,路由器,交换机,Wi-Fi。

广域网(WAN):跨越地理区域(城市/国家),常见技术有 VPN 等。

  • 区分 IP 地址与 MAC 地址

MAC地址用于数据链路层的通信,主要用于局域网内部的设备识别和数据帧传输。

IP地址用于网络层的通信,可以在局域网内部或跨越多个网络(如互联网)进行通信。

IP/端口号

[!NOTE]

  • 端口号是一个2字节16位的整数;

  • 端口号用来标识一个进程, 告诉操作系统, 当前的这个数据要交给哪一个进程来处理;

  • IP地址 + 端口号能够标识网络上的某一台主机的某一个进程;

  • 一个端口号只能被一个进程占用

基于IP + port的通信方式我们称作** socket**。思考为什么不用 pid 而使用port

  1. 不是所有进程都需要通信
  2. 系统和网络功能解耦

进程和端口号的对应关系:一个进程可以对应多个端口号

TCP和UDP

TCP(传输控制协议)面向字节流(Stream-oriented)。保证数据的可靠传输。发送方和接收方通过TCP协议建立连接后,数据会被封装成多个有序的报文段(Segment),并在传输过程中进行错误检测、确认、重传和流量控制。TCP确保数据的完整性、顺序性和可靠性。适用于对数据可靠性要求较高的场景,如文件传输(FTP)、网页浏览(HTTP/HTTPS)、电子邮件(SMTP/POP3)等。

UDP(用户数据报协议):面向数据报(Datagram-oriented)。UDP将数据封装成独立的数据报(Datagram),每个数据报独立传输,不保证顺序、可靠性或完整性。UDP不建立连接,也不进行错误检测和重传机制,因此传输速度快,但可靠性较低。适用于对实时性要求较高且可以容忍一定数据丢失的场景,如视频流媒体(RTP)、语音通话(VoIP)。

网络字节序

TCP/IP协议决定网络数据流采用大端字节序,即低地址高字节。

[!IMPORTANT]
复习一下大小端

假设有一个16位的整数 0x1234,在大端字节序中,0x12(高字节)会被存储在低地址位置,0x34(低字节)会被存储在高地址位置。

OSI与TCP/IP模型

网络层与传输层是Linux操作系统中模块的一部分。

网络通信的本质:就是贯穿协议栈的过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
graph TD
A[OSI七层模型] -->|实际工程实现| B[TCP/IP四层模型]
B --> C[Linux协议栈实现]

subgraph OSI模型
L7[应用层] --> L6[表示层]
L6 --> L5[会话层]
L5 --> L4[传输层]
L4 --> L3[网络层]
L3 --> L2[数据链路层]
L2 --> L1[物理层]
end

subgraph TCP/IP模型
T4[应用层] --> T3[传输层]
T3 --> T2[网络层]
T2 --> T1[链路层]
end

[!IMPORTANT]

TCP/IP 四层模型

  • 应用层:这是最接近用户的一层,负责处理特定的应用程序需求,比如网页浏览(HTTP)、邮件(SMTP)等。
  • 传输层:这一层负责端到端的数据传输,TCP和UDP就在这里工作。它们决定数据如何从一台设备传输到另一台设备。
  • 网络层:这一层负责将数据从源设备传输到目标设备,主要协议是IP(Internet Protocol)。
  • 链路层:这一层负责在物理设备之间传输数据,比如以太网(Ethernet)。

以太网:以太网是一种广泛应用于局域网(LAN)的网络技术,用于实现同一网络环境内设备之间的数据传输和通信。在以太网中,同一局域网内的任意两台主机可以通过物理链路(如双绞线、光纤)直接通信,无需经过外部网络的中转。

数据从上层协议(如IP协议)传递到以太网时,会在每一层添加相应的报头(Header)。

报文的结构:报文=报头+有效载荷

数据的封装与解包:网络通信过程中,数据在发送方从应用层向下传递到链路层,每经过一层都会被封装上该层的协议头(Header)。例如:

  • 应用层数据被封装为TCP或UDP数据报。
  • TCP/UDP数据报被封装为IP数据包。
  • IP数据包被封装为以太网帧。

在计算机网络的每一层协议中,通常需要具备将报头(Header)与有效载荷(Payload)分离的能力。每一层协议通常需要在其报头中提供一种机制,用于决定将有效载荷传递给上层的哪一个协议。(在TCP/IP协议栈中,IP报头中的“协议”字段(Protocol Field)用于标识上层协议(如TCP、UDP等))

交换机:以太网发生数据碰撞问题(碰撞避免算法),现代网络设计中广泛采用交换机来有效降低碰撞概率。交换机通过划分碰撞域(Collision Domain),将每个端口划分为独立的冲突域,从而显著减少数据碰撞的可能性

IP协议通过屏蔽底层网络的差异化,实现了不同网络之间的无缝互连。 这一功能的实现主要依赖于工作在IP层的路由器。路由器作为网络层的核心设备,通过解析IP地址,实现数据包在不同网络之间的转发和路由选择,从而支持跨网络的通信。

img

网络编程套接字

网络通信的本质:进程的通信

网络套接字(Socket)是计算机网络中不同主机之间通信的端点,是实现进程间通信的关键技术。

常见 API 分析

  1. socket()
1
int socket(int domain, int type, int protocol);
  • 作用:创建通信端点

  • 参数

    • domain:地址族(AF_INET表示IPv4)
    • type:通信语义(SOCK_DGRAM表示UDP)
    • protocol:协议类型(0表示自动选择)
  • 格式

    1
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
  1. bind()
1
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • 作用:将套接字与地址/端口绑定

  • 关键数据结构

    1
    2
    3
    4
    5
    struct sockaddr_in {
    sa_family_t sin_family; // 地址族(AF_INET)
    in_port_t sin_port; // 端口号(网络字节序)
    struct in_addr sin_addr; // IP地址
    };
  • 格式

    1
    2
    server.sin_port = htons(serverport); // 端口转为网络字节序
    server.sin_addr.s_addr = inet_addr(serverip.c_str()); // IP字符串转网络格式
  1. sendto()/recvfrom()
1
2
3
4
5
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);

ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
  • 特点:面向无连接的收发接口
  • 参数
    • buf:数据缓冲区
    • src_addr/dest_addr:自动获取/指定通信方地址
    • addrlen:地址结构体长度指针
  1. 字节序转换函数
函数 作用 示例
htons() 主机字节序→网络字节序(端口) htons(8080)
ntohs() 网络字节序→主机字节序(端口) ntohs(server.sin_port)
inet_addr() 字符串IP→网络字节序 inet_addr("192.168.1.1")
inet_ntoa() 网络字节序→字符串IP inet_ntoa(client.sin_addr)

[!TIP]

  1. IP地址选择

    • 服务器绑定0.0.0.0表示监听所有网络接口
    • 客户端连接时使用服务器内网IP(如192.168.x.x
  2. 端口管理

    • 服务器需要指定固定端口(>1024)
    • 客户端端口由系统自动分配

实验:搭建UDP服务器并在客户端打印处理结果

调试事项

需要注意的是:在同一台电脑上进行测试时,连接主机时候记得使用内网 IP 而非公网 IP

[!TIP]

netstat -nlup 是一个用于显示网络连接、路由表、接口统计信息、伪装连接和多播成员的命令。具体来说:

  • -n:显示数字地址和端 口号,而不是尝试解析主机名和服务名。
  • -l:显示监听中的套接字。
  • -u:显示 UDP 协议的连接。
  • -p:显示使用套接字的进程 ID 和进程名称。
1
2
3
4
# 查看UDP端口监听情况
netstat -nlup
# 查看TCP连接状态
netstat -ant

image-20250216151520027

服务器端udpServer):绑定指定端口持续监听;接收客户端数据并打印;添加”server echo#”前缀后返回给客户端。

客户端udpClient):连接指定IP和端口的服务器;交互式发送用户输入的消息;接收并打印服务器响应

运行结果:

image-20250216180446271

  1. 启动服务器:

    1
    ./udpserver 8080
  2. 启动客户端:

    1
    2
    ./udpclient 127.0.0.1 8080      # 本地测试
    ./udpclient 192.168.1.100 8080 # 局域网测试
  3. 交互过程:

    1
    2
    client say: Hello
    server echo# Hello

实现代码

Main.cc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# include "udpServer.hpp"
# include <memory>

void Usage(std::string proc)
{
std::cout << "Usage: " << proc << " port[1024+]" << std::endl;
}

/* 启动服务器:。/udpserver port */
int main(int argc, char *argv[])
{
if(argc != 2){
Usage(argv[0]);
exit(1);
}
uint16_t port = atoi(argv[1]);

std::unique_ptr<udpServer> svr(new udpServer(port));

svr -> Init();
svr -> Run();
return 0;
}

udpClient.cc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
# include <iostream>
# include <sys/types.h>
# include <sys/socket.h>
# include <netinet/in.h>
# include <arpa/inet.h> //大小端
# include <unistd.h>
# include <cstdlib>
# include <strings.h>
# include <cstring> // for strerror

using namespace std;

void Usage(std::string proc)
{
std::cout << "Usage: " << proc << " ServerIP + ServerPort" << std::endl;
}

/*客户端*/
// 运行格式:./udpClient serverip serverport
int main(int argc, char *argv[])
{
if (argc != 3){
Usage(argv[0]);
exit(1);
}

std::string serverip = argv[1];
uint16_t serverport = atoi(argv[2]);

// 服务器信息
struct sockaddr_in server;
bzero(&server, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(serverport); // 端口号要转为网络序列
server.sin_addr.s_addr = inet_addr(serverip.c_str()); // 确保发送到指定的服务器IP地址
socklen_t len = sizeof(server);

// 创建套接字
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(sockfd < 0){
cerr << "socket error: " << strerror(errno) << endl;
exit(1);
}
else{
cout << "socket success" << endl;
}

// client 需要bind吗? 不需要,因为客户端的端口号是由操作系统自动分配的 不需要用户显示绑定,从而避免端口冲突。
// 系统在首次发送信息时bind
string message;
char buffer[1024];
while(true)
{
cout << "client say: ";
getline(cin, message);

// 1. 发送数据 2.发给谁?
ssize_t sent_bytes = sendto(sockfd, message.c_str(), message.size(), 0, (struct sockaddr*)&server, len);
if (sent_bytes < 0) {
cerr << "sendto error: " << strerror(errno) << endl;
continue;
} else {
cout << "sendto success, sent " << sent_bytes << " bytes to "
<< inet_ntoa(server.sin_addr) << ":" << ntohs(server.sin_port) << endl;
}

// 收到消息
struct sockaddr_in tmp;
socklen_t len = sizeof(tmp);

size_t s = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr*)&tmp, &len);
if(s > 0){
buffer[s] = 0;
cout << buffer << endl; // 确保输出服务器响应
} else {
cerr << "recvfrom error: " << strerror(errno) << endl;
}
}

close (sockfd);
return 0;
}

udpServer.hpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# pragma once
# include <iostream>
# include <sys/types.h>
# include <sys/socket.h>
# include <netinet/in.h>
# include <arpa/inet.h> //大小端
# include <cstring>
# include <unistd.h>

uint16_t defaultPort = 8080;
std::string defaultIP = "0.0.0.0";

class udpServer{
public:
udpServer(const uint16_t &port = defaultPort, const std::string &ip = defaultIP)
: _sockfd(-1), _port(port), _ip(ip), _isrunning(false) {
}

void Init(){
/* 1. 套接字创建初始化? */
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(_sockfd < 0){
std::cerr << "socket error" << std::endl;
exit(1);
}
else{
std::cout << "socket success" << std::endl;
}
/* 2. 绑定端口信息 */
struct sockaddr_in local; // 填充信息到这个结构体中(定义在栈上——用户区)
bzero(&local, sizeof(local));
local.sin_family = AF_INET; // 地址族 ipv4
// local.sin_port = _port; //端口号: 需要将端口号也发送给对方,所以要保证端口号是网络字节序列(确保是大端)
local.sin_port = htons(_port); // 将主机字节序转换为网络字节序
local.sin_addr.s_addr = inet_addr(_ip.c_str()); // 确保绑定到指定的IP地址
// 这里的 IP 地址必须是网络字节序,所以需要将字符串转换为网络字节序uint32_t

int n = bind(_sockfd, (const struct sockaddr*)&local, sizeof(local));
if(n < 0){
std::cerr << "bind error: " << strerror(errno) << std::endl;
exit(2);
}
else{
std::cout << "bind success" << std::endl;
}
}

void Run(){
// 服务器
_isrunning = true;
while(_isrunning)
{
struct sockaddr_in client;
socklen_t len = sizeof(client);

// 从客户端接收数据
char inbuffer[1024];
ssize_t n = recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr*)&client, &len);
if (n < 0){
std::cerr << "recvfrom error: " << strerror(errno) << std::endl;
continue;
}
else{
std::cout << "recvfrom success, received " << n << " bytes from "
<< inet_ntoa(client.sin_addr) << ":" << ntohs(client.sin_port) << std::endl;
}
inbuffer[n] = 0;

// 充当一次数据的处理
std::string info = inbuffer;
std::string echo_string = "server echo# " + info; // 确保处理后的数据正确
std::cout << "server received: " << info << std::endl;
// std::cout << "server sending: " << echo_string << std::endl;
// 处理后的数据再发回到客户端
ssize_t sent_bytes = sendto(_sockfd, echo_string.c_str(), echo_string.size(), 0, (struct sockaddr*)&client, len);
if (sent_bytes < 0) {
std::cerr << "sendto error: " << strerror(errno) << std::endl;
} else {
std::cout << "sendto success, sent " << sent_bytes << " bytes to "
<< inet_ntoa(client.sin_addr) << ":" << ntohs(client.sin_port) << std::endl;
}
}

}

~udpServer(){
if(_sockfd != 0){
close(_sockfd);
}
}
private:
int _sockfd; //网络文件描述符
std::string _ip; // 任意地址bind IP地址
uint16_t _port; //端口号
bool _isrunning;
};

makefile

1
2
3
4
5
6
7
8
9
10
.PHONY:all
all: udpserver udpclient
udpclient: udpClient.cc
g++ -o $@ $^ -std=c++11
udpserver: Main.cc
g++ -o $@ $^ -std=c++11

.PHONY:clean
clean:
rm -f udpserver udpclient