1. 概念
局域网和广域网
- 局域网:局域网将一定区域内的各种计算机、外部设备和数据库连接起来形成计算机通信的私有网络。
- 广域网:又称广域网、外网、公网。是连接不同地区局域网或城域网计算机通信的远程公共网络。
IP(Internet Protocol):本质是一个整形数,用于表示计算机在网络中的地址。IP协议版本有两个:IPv4和IPv6
- IPv4(Internet Protocol version4):使用一个32位的整形数描述一个IP地址,4个字节,int型,也可以使用一个点分十进制字符串描述这个IP地址: 192.168.247.135。分成了4份,每份1字节,8bit(char),最大值为 255;0.0.0.0 是最小的IP地址;255.255.255.255是最大的IP地址;按照IPv4协议计算,可以使用的IP地址共有 232 个
- IPv6(Internet Protocol version6):使用一个128位的整形数描述一个IP地址,16个字节,也可以使用一个字符串描述这个IP地址:2001:0db8:3c4d:0015:0000:0000:1a2f:1a2b;分成了8份,每份2字节,每一部分以16进制的方式表示;按照IPv6协议计算,可以使用的IP地址共有 2128 个
查看IP地址
# linux
$ ifconfig
# windows
$ ipconfig
# 测试网络是否畅通
# 主机a: 192.168.1.11
# 当前主机: 192.168.1.12
$ ping 192.168.1.11 # 测试是否可用连接局域网
$ ping www.baidu.com # 测试是否可用连接外网
# 特殊的IP地址: 127.0.0.1 ==> 和本地的IP地址是等价的
# 假设当前电脑没有联网, 就没有IP地址, 又要做网络测试, 可用使用 127.0.0.1 进行本地测试
端口:端口的作用是定位到主机上的某一个进程,通过这个端口进程就可以接受到对应的网络数据了。
比如: 在电脑上运行了微信和QQ, 小明通过客户端给我的的微信发消息, 电脑上的微信就收到了消息, 为什么?
- 运行在电脑上的微信和QQ都绑定了不同的端口
- 通过IP地址可以定位到某一台主机,通过端口就可以定位到主机上的某一个进程
- 通过指定的IP和端口,发送数据的时候对端就能接受到数据了
端口也是一个整形数 unsigned short ,一个16位整形数,有效端口的取值范围是:0 ~ 65535(0 ~ 216-1)
提问:计算机中所有的进程都需要关联一个端口吗,一个端口可以被重复使用吗?
- 不需要,如果这个进程不需要网络通信,那么这个进程就不需要绑定端口的
- 一个端口只能给某一个进程使用,多个进程不能同时使用同一个端口
OSI/ISO 网络分层模型:OSI(Open System Interconnect),即开放式系统互联。 一般都叫OSI参考模型,是ISO(国际标准化组织组织)在1985年研究的网络互联模型。
- 1. 物理层(Physical layer):负责传输比特流,将数据转化为适合在物理媒介上传输的电信号,如网线、光纤等。
- 2. 数据链路层(Data Link layer):提供可靠的数据传输,将物理层传输的比特流组织为帧(Frame),并通过检验和等方式进行错误检测和纠正。
- 3. 网络层(Network layer):实现网络互连和数据路由,负责确定数据的传输路径,并将数据包(Packet)从源节点传输到目标节点。
- 4. 传输层(Transport layer):负责为应用程序提供端到端的通信服务,包括可靠的数据传输和数据分段重组,常见的协议如TCP和UDP。
- 5. 会话层(Session layer):负责建立、管理和终止应用程序之间的会话连接,包括控制会话的建立、同步和终止等操作。
- 6. 表示层(Presentation layer):负责数据的格式转换、加密解密和压缩解压缩等操作,以确保应用程序能够正确解释和处理接收到的数据。
- 7. 应用层(Application layer):提供用户应用程序与网络之间的接口,包括文件传输、电子邮件、网页浏览等各种应用服务。
这七层协议组成了一个通用的网络模型,每一层都具有不同的功能和责任,通过协同工作实现了数据在计算机网络中的可靠传输和应用程序之间的交互。请注意,实际的网络协议栈(如TCP/IP)并不完全符合OSI模型,但是OSI模型仍然被广泛用于理解和教学网络概念。
2. 网络协议
网络协议是计算机网络中用于在不同计算机之间进行数据传输和交换的规则和约定。它定义了数据如何在网络中传输、如何组织和解释数据以及如何处理错误和冲突等情况。常见的网络协议包括:
- 1. TCP/IP协议:是互联网的核心协议,包括传输控制协议(TCP)和Internet协议(IP)。TCP提供可靠的数据传输,确保数据的完整性和有序性;IP负责数据包的路由和寻址。
- 2. HTTP协议:是用于在Web上进行超文本传输的协议,定义了客户端和服务器之间的通信规则。常用于浏览器和服务器之间传输网页、图片和其他资源。
- 3. FTP协议:是文件传输协议,用于在客户端和服务器之间进行文件传输。它支持上传、下载、删除和重命名等操作。
- 4. SMTP协议:是简单邮件传输协议,用于在邮件服务器之间传输电子邮件。它定义了邮件的格式和传输规则。
- 5. POP协议和IMAP协议:是接收邮件的协议,用于从邮件服务器中接收电子邮件。POP协议通常会将邮件下载到本地设备,而IMAP协议则保留邮件在服务器上,以便在不同设备上同步访问邮件。
- 6. DNS协议:是域名系统协议,用于将域名(如www.example.com)转换为对应的IP地址。它提供了域名解析和IP地址管理的功能。
- 7. DHCP协议:是动态主机配置协议,用于在局域网中自动分配IP地址给计算机和其他设备。它还提供了其他网络配置信息,如子网掩码、网关和DNS服务器等。
这些协议在计算机网络中发挥着重要的作用,实现了可靠的数据传输、资源共享、电子邮件通信和互联网的基本功能。不同的协议在不同层次上工作,并相互配合以实现全面的网络通信。
3. socket编程
Socket编程是一种用于在计算机网络之间进行通信的编程技术。它基于TCP/IP协议栈,允许不同计算机之间的进程通过网络进行数据传输和交互。
通过Socket编程,可以实现客户端和服务器之间的通信。客户端可以向服务器发送请求,服务器则可以对这些请求进行响应。Socket编程使用网络套接字(socket)作为通信的端点,允许进程在网络上发送和接收数据。
在Socket编程中,存在两种常见类型的套接字:客户端套接字和服务器套接字。客户端套接字用于连接服务器并发送请求,而服务器套接字用于侦听客户端连接并处理请求。
Socket编程可以用于构建各种网络应用程序,例如网络聊天程序、文件传输程序、Web服务器等。编程语言如Python、Java和C++都提供了Socket编程的库和API,使开发者能够方便地使用Socket进行网络通信。
需要注意的是,Socket编程涉及到网络通信和数据传输,因此在实际使用中需要考虑网络连接的稳定性、数据的完整性和安全性等方面的问题。
3.1 字节序
在各种计算机体系结构中,对于字节、字等的存储机制有所不同,因而引发了计算机通信领域中一个很重要的问题,即通信双方交流的信息单元(比特、字节、字、双字等等)应该以什么样的顺序进行传送。如果不达成一致的规则,通信双方将无法进行正确的编/译码从而导致通信失败。
字节序,顾名思义字节的顺序,就是大于一个字节类型的数据在内存中的存放顺序,也就是说对于单字符来说是没有字节序问题的,字符串是单字符的集合,因此字符串也没有字节序问题。
目前在各种体系的计算机中通常采用的字节存储机制主要有两种:Big-Endian 和 Little-Endian,下面先从字节序说起。
- Little-Endian -> 主机字节序 (小端):数据的低位字节存储到内存的低地址位, 数据的高位字节存储到内存的高地址位,我们使用的PC机,数据的存储默认使用的是小端
- Big-Endian -> 网络字节序 (大端):据的低位字节存储到内存的高地址位, 数据的高位字节存储到内存的低地址位,套接字通信过程中操作的数据都是大端存储的,包括:接收/发送的数据、IP地址、端口。
字节序举例
// 有一个16进制的数, 有32位 (int): 0xab5c01ff
// 字节序, 最小的单位: char 字节, int 有4个字节, 需要将其拆分为4份
// 一个字节 unsigned char, 最大值是 255(十进制) ==> ff(16进制)
内存低地址位 内存的高地址位
--------------------------------------------------------------------------->
小端: 0xff 0x01 0x5c 0xab
大端: 0xab 0x5c 0x01 0xff
函数:
BSD Socket提供了封装好的转换接口,方便程序员使用。包括从主机字节序到网络字节序的转换函数:htons、htonl;从网络字节序到主机字节序的转换函数:ntohs、ntohl。
#include <arpa/inet.h>
// u:unsigned
// 16: 16位, 32:32位
// h: host, 主机字节序
// n: net, 网络字节序
// s: short
// l: int
// 这套api主要用于 网络通信过程中 IP 和 端口 的 转换
// 将一个短整形从主机字节序 -> 网络字节序
uint16_t htons(uint16_t hostshort);
// 将一个整形从主机字节序 -> 网络字节序
uint32_t htonl(uint32_t hostlong);
// 将一个短整形从网络字节序 -> 主机字节序
uint16_t ntohs(uint16_t netshort)
// 将一个整形从网络字节序 -> 主机字节序
uint32_t ntohl(uint32_t netlong);
3.2 IP地址转换
虽然IP地址本质是一个整形数,但是在使用的过程中都是通过一个字符串来描述,下面的函数描述了如何将一个字符串类型的IP地址进行大小端转换:
// 主机字节序的IP地址转换为网络字节序
// 主机字节序的IP地址是字符串, 网络字节序IP地址是整形
int inet_pton(int af, const char *src, void *dst);
参数:
- af: 地址族(IP地址的家族包括ipv4和ipv6)协议
- AF_INET: ipv4格式的ip地址
- AF_INET6: ipv6格式的ip地址
- src: 传入参数, 对应要转换的点分十进制的ip地址: 192.168.1.100
- dst: 传出参数, 函数调用完成, 转换得到的大端整形IP被写入到这块内存中
返回值:成功返回1,失败返回0或者-1
#include <arpa/inet.h>
// 将大端的整形数, 转换为小端的点分十进制的IP地址
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
参数:
- af: 地址族协议
- AF_INET: ipv4格式的ip地址
- AF_INET6: ipv6格式的ip地址
- src: 传入参数, 这个指针指向的内存中存储了大端的整形IP地址
- dst: 传出参数, 存储转换得到的小端的点分十进制的IP地址
- size: 修饰dst参数的, 标记dst指向的内存中最多可以存储多少个字节
返回值:
- 成功: 指针指向第三个参数对应的内存地址, 通过返回值也可以直接取出转换得到的IP字符串
- 失败: NULL
还有一组函数也能进程IP地址大小端的转换,但是只能处理ipv4的ip地址:
// 点分十进制IP -> 大端整形
in_addr_t inet_addr (const char *cp);
// 大端整形 -> 点分十进制IP
char* inet_ntoa(struct in_addr in);
3.3 sockaddr 数据结构
// 在写数据的时候不好用
struct sockaddr {
sa_family_t sa_family; // 地址族协议, ipv4
char sa_data[14]; // 端口(2字节) + IP地址(4字节) + 填充(8字节)
}
typedef unsigned short uint16_t;
typedef unsigned int uint32_t;
typedef uint16_t in_port_t;
typedef uint32_t in_addr_t;
typedef unsigned short int sa_family_t;
#define __SOCKADDR_COMMON_SIZE (sizeof (unsigned short int))
struct in_addr
{
in_addr_t s_addr;
};
// sizeof(struct sockaddr) == sizeof(struct sockaddr_in)
struct sockaddr_in
{
sa_family_t sin_family; /* 地址族协议: AF_INET */
in_port_t sin_port; /* 端口, 2字节-> 大端 */
struct in_addr sin_addr; /* IP地址, 4字节 -> 大端 */
/* 填充 8字节 */
unsigned char sin_zero[sizeof (struct sockaddr) - sizeof(sin_family) -
sizeof (in_port_t) - sizeof (struct in_addr)];
};
3.4 套接字函数
使用套接字通信函数需要包含头文件<arpa/inet.h>,包含了这个头文件<sys/socket.h>就不用在包含了。
// 创建一个套接字
int socket(int domain, int type, int protocol);
参数:
domain: 使用的地址族协议
- AF_INET: 使用IPv4格式的ip地址
- AF_INET6: 使用IPv4格式的ip地址
type:
- SOCK_STREAM: 使用流式的传输协议
- SOCK_DGRAM: 使用报式(报文)的传输协议
protocol: 一般写0即可, 使用默认的协议
- SOCK_STREAM: 流式传输默认使用的是tcp
- SOCK_DGRAM: 报式传输默认使用的udp
返回值:
- 成功: 可用于套接字通信的文件描述符
- 失败: -1
函数的返回值是一个文件描述符,通过这个文件描述符可以操作内核中的某一块内存,网络通信是基于这个文件描述符来完成的。
// 将文件描述符和本地的IP与端口进行绑定
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数:
- sockfd: 监听的文件描述符, 通过socket()调用得到的返回值
- addr: 传入参数, 要绑定的IP和端口信息需要初始化到这个结构体中,IP和端口要转换为网络字节序
- addrlen: 参数addr指向的内存大小, sizeof(struct sockaddr)
返回值:成功返回0,失败返回-1
// 给监听的套接字设置监听
int listen(int sockfd, int backlog);
参数:
- sockfd: 文件描述符, 可以通过调用socket()得到,在监听之前必须要绑定 bind()
- backlog: 同时能处理的最大连接要求,最大值为128
返回值:函数调用成功返回0,调用失败返回 -1
// 等待并接受客户端的连接请求, 建立新的连接, 会得到一个新的文件描述符(通信的)
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
参数:
- sockfd: 监听的文件描述符
- addr: 传出参数, 里边存储了建立连接的客户端的地址信息
- addrlen: 传入传出参数,用于存储addr指向的内存大小
返回值:函数调用成功,得到一个文件描述符, 用于和建立连接的这个客户端通信,调用失败返回 -1
这个函数是一个阻塞函数,当没有新的客户端连接请求的时候,该函数阻塞;当检测到有新的客户端连接请求时,阻塞解除,新连接就建立了,得到的返回值也是一个文件描述符,基于这个文件描述符就可以和客户端通信了。
// 接收数据
ssize_t read(int sockfd, void *buf, size_t size);
ssize_t recv(int sockfd, void *buf, size_t size, int flags);
参数:
- sockfd: 用于通信的文件描述符, accept() 函数的返回值
- buf: 指向一块有效内存, 用于存储接收是数据
- size: 参数buf指向的内存的容量
- flags: 特殊的属性, 一般不使用, 指定为 0
返回值:
- 大于0:实际接收的字节数
- 等于0:对方断开了连接
- -1:接收数据失败了
如果连接没有断开,接收端接收不到数据,接收数据的函数会阻塞等待数据到达,数据到达后函数解除阻塞,开始接收数据,当发送端断开连接,接收端无法接收到任何数据,但是这时候就不会阻塞了,函数直接返回0。
// 发送数据的函数
ssize_t write(int fd, const void *buf, size_t len);
ssize_t send(int fd, const void *buf, size_t len, int flags);
参数:
- fd: 通信的文件描述符, accept() 函数的返回值
- buf: 传入参数, 要发送的字符串
- len: 要发送的字符串的长度
- flags: 特殊的属性, 一般不使用, 指定为 0
返回值:
- 大于0:实际发送的字节数,和参数len是相等的
- -1:发送数据失败了
// 成功连接服务器之后, 客户端会自动随机绑定一个端口
// 服务器端调用accept()的函数, 第二个参数存储的就是客户端的IP和端口信息
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数:
- sockfd: 通信的文件描述符, 通过调用socket()函数就得到了
- addr: 存储了要连接的服务器端的地址信息: iP 和 端口,这个IP和端口也需要转换为大端然后再赋值
- addrlen: addr指针指向的内存的大小 sizeof(struct sockaddr)
返回值:连接成功返回0,连接失败返回-1
4. TCP通信流程
TCP是一个面向连接的,安全的,流式传输协议,这个协议是一个传输层协议。
- 面向连接:是一个双向连接,通过三次握手完成,断开连接需要通过四次挥手完成。
- 安全:tcp通信过程中,会对发送的每一数据包都会进行校验, 如果发现数据丢失, 会自动重传
- 流式传输:发送端和接收端处理数据的速度,数据的量都可以不一致
4.1 服务器端通信流程
1. 创建用于监听的套接字, 这个套接字是一个文件描述符
int lfd = socket();
2. 将得到的监听的文件描述符和本地的IP 端口进行绑定
bind();
3. 设置监听(成功之后开始监听, 监听的是客户端的连接)
listen();
4. 等待并接受客户端的连接请求, 建立新的连接, 会得到一个新的文件描述符(通信的),没有新连接请求就阻塞
int cfd = accept();
5. 通信,读写操作默认都是阻塞的
// 接收数据
read(); / recv();
// 发送数据
write(); / send();
6. 断开连接, 关闭套接字
close();
在tcp的服务器端, 有两类文件描述符
监听的文件描述符
- 只需要有一个
- 不负责和客户端通信, 负责检测客户端的连接请求, 检测到之后调用accept就可以建立新的连接
通信的文件描述符
- 负责和建立连接的客户端通信
- 如果有N个客户端和服务器建立了新的连接, 通信的文件描述符就有N个,每个客户端和服务器都对应一个通信的文件描述符
文件描述符对应的内存结构:
- 一个文件文件描述符对应两块内存, 一块内存是读缓冲区, 一块内存是写缓冲区
- 读数据: 通过文件描述符将内存中的数据读出, 这块内存称之为读缓冲区
- 写数据: 通过文件描述符将数据写入到某块内存中, 这块内存称之为写缓冲区
监听的文件描述符:
- 客户端的连接请求会发送到服务器端监听的文件描述符的读缓冲区中
- 读缓冲区中有数据, 说明有新的客户端连接
- 调用accept()函数, 这个函数会检测监听文件描述符的读缓冲区
- 检测不到数据, 该函数阻塞
- 如果检测到数据, 解除阻塞, 新的连接建立
通信的文件描述符:
- 客户端和服务器端都有通信的文件描述符
- 发送数据:调用函数 write() / send(),数据进入到内核中
- 数据并没有被发送出去, 而是将数据写入到了通信的文件描述符对应的写缓冲区中
- 内核检测到通信的文件描述符写缓冲区中有数据, 内核会将数据发送到网络中
- 接收数据: 调用的函数 read() / recv(), 从内核读数据
- 数据如何进入到内核程序猿不需要处理, 数据进入到通信的文件描述符的读缓冲区中
- 数据进入到内核, 必须使用通信的文件描述符, 将数据从读缓冲区中读出即可
基于tcp的服务器端通信代码:
// server.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>
int main()
{
// 1. 创建监听的套接字
int lfd = socket(AF_INET, SOCK_STREAM, 0);
if(lfd == -1)
{
perror("socket");
exit(0);
}
// 2. 将socket()返回值和本地的IP端口绑定到一起
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(10000); // 大端端口
// INADDR_ANY代表本机的所有IP, 假设有三个网卡就有三个IP地址
// 这个宏可以代表任意一个IP地址
// 这个宏一般用于本地的绑定操作
addr.sin_addr.s_addr = INADDR_ANY; // 这个宏的值为0 == 0.0.0.0
// inet_pton(AF_INET, "192.168.237.131", &addr.sin_addr.s_addr);
int ret = bind(lfd, (struct sockaddr*)&addr, sizeof(addr));
if(ret == -1)
{
perror("bind");
exit(0);
}
// 3. 设置监听
ret = listen(lfd, 128);
if(ret == -1)
{
perror("listen");
exit(0);
}
// 4. 阻塞等待并接受客户端连接
struct sockaddr_in cliaddr;
int clilen = sizeof(cliaddr);
int cfd = accept(lfd, (struct sockaddr*)&cliaddr, &clilen);
if(cfd == -1)
{
perror("accept");
exit(0);
}
// 打印客户端的地址信息
char ip[24] = {0};
printf("客户端的IP地址: %s, 端口: %d\n",
inet_ntop(AF_INET, &cliaddr.sin_addr.s_addr, ip, sizeof(ip)),
ntohs(cliaddr.sin_port));
// 5. 和客户端通信
while(1)
{
// 接收数据
char buf[1024];
memset(buf, 0, sizeof(buf));
int len = read(cfd, buf, sizeof(buf));
if(len > 0)
{
printf("客户端say: %s\n", buf);
write(cfd, buf, len);
}
else if(len == 0)
{
printf("客户端断开了连接...\n");
break;
}
else
{
perror("read");
break;
}
}
close(cfd);
close(lfd);
return 0;
}
4.2 客户端通信流程
在单线程的情况下客户端通信的文件描述符有一个, 没有监听的文件描述符
1. 创建一个通信的套接字
int cfd = socket();
2. 连接服务器, 需要知道服务器绑定的IP和端口
connect();
3. 通信
// 接收数据
read(); / recv();
// 发送数据
write(); / send();
4. 断开连接, 关闭文件描述符(套接字)
close();
基于tcp通信的客户端通信代码:
// client.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>
int main()
{
// 1. 创建通信的套接字
int fd = socket(AF_INET, SOCK_STREAM, 0);
if(fd == -1)
{
perror("socket");
exit(0);
}
// 2. 连接服务器
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(10000); // 大端端口
inet_pton(AF_INET, "192.168.237.131", &addr.sin_addr.s_addr);
int ret = connect(fd, (struct sockaddr*)&addr, sizeof(addr));
if(ret == -1)
{
perror("connect");
exit(0);
}
// 3. 和服务器端通信
int number = 0;
while(1)
{
// 发送数据
char buf[1024];
sprintf(buf, "你好, 服务器...%d\n", number++);
write(fd, buf, strlen(buf)+1);
// 接收数据
memset(buf, 0, sizeof(buf));
int len = read(fd, buf, sizeof(buf));
if(len > 0)
{
printf("服务器say: %s\n", buf);
}
else if(len == 0)
{
printf("服务器断开了连接...\n");
break;
}
else
{
perror("read");
break;
}
sleep(1); // 每隔1s发送一条数据
}
close(fd);
return 0;
}