网络编程:OSI 七层模型、TCP 协议、UDP 协议、三次握手、四次挥手、socket编程及编程实战
//
掌握网络编程,TCP、UDP等,socket函数编程
前置知识:
网络通信
网络通信本质上是一种进程间通信,是位于网络中不同主机上的进程之间的通信,属于 IPC (InterProcess Communication)的一种,通常称为 socket IPC,网络通信是为了解决在网络环境中,不同主机上的应用程序之间的通信问题。
大概可以分为三个层次,如下所示:
(1)、硬件层:网卡设备,收发网络数据
(2)、驱动层:网卡驱动(Linux 内核网卡驱动代码)
(3)、应用层:上层应用程序(调用 socket 接口或更高级别接口实现网络相关应用程序)
在硬件上,两台主机都提供了网卡设备,也就满足了进行网络通信最基本的要求,网卡设备是实现网络数据收发的硬件基础。并且通信的两台主机之间需要建立网络连接,这样两台主机之间才可以进行数据传输,譬如通过网线进行数据传输。网络数据的传输媒介有很多种,大体上分为有线传输(譬如双绞线网线、光纤等)和无线传输(譬如 WIFI、蓝牙、ZigBee、4G/5G/GPRS 等),PC 机通常使用有线网络,而手机等移动设备通常使用无线网络
在内核层,提供了网卡驱动程序,可以驱动底层网卡硬件设备,同时向应用层提供 socket 接口。
在应用层,应用程序基于内核提供的 socket 接口进行应用编程,实现自己的网络应用程序。需要注意的是,socket 接口是内核向应用层提供的一套网络编程接口,所以我们学习网络编程其实就是学习 socket 编程,如何基于 socket 接口编写应用程序。
除了 socket 接口之外,在应用层通常还会使用一些更为高级的编程接口,譬如 http、网络控件等,那么这些接口实际上是对 socket 接口的一种更高级别的封装。在正式学习 socket 编程之前,需要先了解一些网络基础知识:
1、OSI 七层模型
网络互连模型:OSI 七层模型,七层模型,亦称 OSI(Open System Interconnection)。OSI 七层参考模型是国际标准化组织(ISO)制定的一个用于计算机或通信系统间网络互联的标准体系,一般称为 OSI 参考模型或七层模型。OSI 七层模型是一个网络互连模型,此图片来自正点原子:
OSI 模型与 TCP/IP 模型
四层模型包括:应用层、传输层、网络层以及网络接口层。而在实际的应用中还是使用TCP/IP 四层模型,五层模型是专门为介绍网络原理而设计的
所有的数据传输,都有三个要素 :源、目的、长度
在网络传输中需要使用“IP 和 端口”来表示源或目的
互联网中的每一台主机都需要一个唯一的 IP 地址以标识自己的身份,网络中传输的数据包通过 IP 地址找到对应的目标主机;一台主机通常只有一个IP 地址,但主机上运行的网络进程却通常不止一个,譬如 Windows 电脑上运行着QQ、微信、钉钉、网页浏览器等,这些进程都需要进行网络连接,它们都可通过网络发送/接收数据,那么这里就有一个问题?主机接收到网络数据之后,如何确定该数据是哪个进程对应的接收数据呢?其实就是通常端口号来确定的
端口号本质上就是一个数字编号,用来在一台主机中唯一标识一个能上网(能够进行网络通信)的进程,端口号的取值范围为 0~65535。一台主机通常只有一个 IP 地址,但是可能有多个端口号,每个端口号表示一个能上网的进程。一台拥有 IP地址的主机可以提供许多服务,比如 Web 服务、FTP 服务、SMTP 服务等,这些服务都是能够进行网络通信的进程,IP地址只能区分网络中不同的主机,并不能区分主机中的这些进程,显然不能只靠 IP 地址,因此才有了端口号。通过“IP地址+端口号”来区分主机不同的进程
网络传输中的 2 个对象:server 和 client
访问网站,这涉及 2 个对象:网站服务器,浏览器。网站服务器平时安静地呆着,浏览器主动发起数据请求。网站服务器、浏览器可以抽象成 2 个软件的概念:server 程序、client 程序
2、两种传输方式:TCP/UDP :
传输控制协议 TCP(Transmission Control Protocol):面向连接的,数据传输的单位是报文段,能够提供可靠的交付。
- 工作在传输层,对上服务 socket 接口,对下调用 IP 层;
- 面向连接的传输协议,通信之前必须通过三次握手与客户端建立连接关系后才可通信;
- 提供可靠传输,不怕丢包、乱序。
(面向连接、确认与重传、全双工通信、基于字节流而非报文、流量控制-滑动窗口协议、差错控制、拥塞控制)
用户数据包协议 UDP(User Datagram Protocol):无连接的,数据传输的单位是用户数据报,不保证提供可靠的交付,只能提供“尽最大努力交付”。
TCP 向它的应用程序提供了面向连接的服务。这种服务有 2 个特点:可靠传输、流量控制(即发送方/接收方速率匹配)。它包括了应用层报文划分为短报文,并提供拥塞控制机制。
UDP 协议向它的应用程序提供无连接服务。它没有可靠性,没有流量控制,也没有拥塞控制。
既然 TCP 提供了可靠数据传输服务,而 UDP 不能提供,那么 TCP 是否总是首选呢?
答案是否定的,因为有许多应用更适合用 UDP,举个例子:视频通话时,使用 UDP,偶尔的丢包、偶尔的花屏时可以忍受的;如果使用 TCP,每个数据包都要确保可靠传输,当它出错时就重传,这会导致后续的数据包被阻滞,视频效果反而不好。
使用 UDP 时,有如下特点:
关于何时发送什么数据控制的更为精细,采用 UDP 时只要应用进程将数据传递给 UDP,UDP 就会立即将其传递给网络层。而 TCP 有重传机制,而不管可靠交付需要多长时间。但是实时应用通常不希望过分的延迟报文段的传送,且能容忍一部分数据丢失。无需建立连接,不会引入建立连接时的延迟。无连接状态,能支持更多的活跃客户。分组首部开销较小。
- 网络层:负责将被称为数据包(datagram)的网络层分组从一台主机移动到另一台主机。
- 链路层:因特网的网络层通过源和目的地之间的一系列路由器路由数据报。
- 物理层:在物理层上所传数据的单位是比特。物理层的任务就是透明地传送比特流。
PS:TCP/IP 协议它其实是一个协议族,包含了众多的协议,譬如应用层协议 HTTP、FTP、MQTT…以及传输层协议 TCP、UDP 等这些都属于 TCP/IP 协议
3、三次握手、四次挥手**
3.1 建立 TCP 连接:三次握手
“三次握手”其实是指建立 TCP 连接的一个过程
三次握手示例图:
比如打电话:
甲:“喂,你能听到我的声音吗?”
乙:“我听得到呀,你能听到我的声音吗?”
甲:“我能听到你,………”
经过三次的互相确认,大家就会认为对方对听的到自己说话,才开始接下来的沟通交流,否则,如果不进行确认,那么你在说话的时候,对方不一定能听到你的声音。所以,TCP 的三次握手是为了保证传输的安全、可靠。
3.2 关闭 TCP 连接:四次挥手
四次挥手即终止 TCP 连接,就是指断开一个 TCP 连接时,需要客户端和服务端总共发送 4 个包以确认连接的断开。(在 socket 编程中,这一过程由客户端或服务端任一方执行 close 来触发)
由于 TCP 连接是全双工的,因此,每个方向都必须要单独进行关闭,这一原则是当一方完成数据发送任务后,发送一个 FIN 来终止这一方向的连接,收到一个 FIN 只是意味着这一方向上没有数据流动了,即不会再收到数据了,但是在这个 TCP 连接上仍然能够发送数据,直到这一方向也发送了 FIN。
4、socket编程
Linux 下的网络编程,我们一般称为 socket 编程,socket是内核向应用层提供的一套网络编程接口,用户基于 socket 接口可开发自己的网络相关应用程序
/
socket 函数
int socket(int domain, int type,int protocol);
参数 domain 用于指定一个通信域,对于 TCP/IP 协议来说,通常选择 AF_INET 就可以了
参数 protocol 通常设置为 0,表示为给定的通信域和套接字类型选择默认协议
调用 socket()与调用 open()函数很类似,调用成功情况下,均会返回用于文件 I/O 的文件描述符,只不过对于 socket()来说,其返回的文件描述符一般称为 socket 描述符。当不再需要该文件描述符时,可调用close()函数来关闭套接字,释放相应的资源;
调用失败,则会返回-1,并且会设置 errno 变量以指示错误类型;
///
bind()函数
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
bind()函数用于将一个 IP 地址或端口号与一个套接字进行绑定(将套接字与地址进行关联)。将一个客户端的套接字关联上一个地址没有多少新意,可以让系统选一个默认的地址。一般来讲,会将一个服务器的套接字绑定到一个众所周知的地址—即一个固定的与服务器进行通信的客户端应用程序提前就知道的地址(注意这里说的地址包括 IP 地址和端口号)。因为对于客户端来说,它与服务器进行通信,首先需要知道服务器的 IP 地址以及对应的端口号,所以通常服务器的 IP 地址以及端口号都是众所周知的。
调用 bind()函数将参数 sockfd 指定的套接字与一个地址 addr 进行绑定,成功返回 0,失败情况下返回1,并设置 errno 以提示错误原因
参数 addr 是一个指针,指向一个 struct sockaddr 类型变量:
struct sockaddr {
sa_family_t sa_family;
char sa_data[14];
}
sa_data 是一个 char 类型数组,一共 14 个字节,在这 14 个字节中就包括了 IP 地址、端口
号等信息,这个结构对用户并不友好,它把这些信息都封装在了 sa_data 数组中,这样使得用户是无法对sa_data 数组进行赋值。事实上,这是一个通用的 socket 地址结构体
一般我们在使用的时候都会使用 struct sockaddr_in 结构体,sockaddr_in 和 sockaddr 是并列的结构(占用的空间是一样的),指向 sockaddr_in 的结构体的指针也可以指向 sockaddr 的结构体,并代替它,而且sockaddr_in 结构对用户将更加友好,在使用的时候进行类型转换就可以了。该结构体内容如下所示:
struct sockaddr_in {
sa_family_t sin_family; /* 协议族 */
in_port_t sin_port; /* 端口号 */
struct in_addr sin_addr; /* IP 地址 */
unsigned char sin_zero[8];
};
这个结构体的第一个字段是与 sockaddr 结构体是一致的,而剩下的字段就是 sa_data 数组连续的 14字节信息里面的内容,只不过从新定义了成员变量而已,sin_port 字段是我们需要填写的端口号信息,sin_addr字段是我们需要填写的 IP 地址信息,剩下 sin_zero 区域的 8 字节保留未用。
最后一个参数 addrlen 指定了 addr 所指向的结构体对应的字节长度。
bind使用示例:
struct sockaddr_in socket_addr;
memset(&socket_addr, 0x0, sizeof(socket_addr)); //清零
//填充变量
socket_addr.sin_family = AF_INET;
socket_addr.sin_addr.s_addr = htonl(INADDR_ANY);
socket_addr.sin_port = htons(5555);
//将地址与套接字进行关联、绑定
bind(socket_fd, (struct sockaddr *)&socket_addr, sizeof(socket_addr));
bind()函数并不是总是需要调用的,只有用户进程想与一个具体的 IP地址或端口号相关联的时候才需要调用这个函数。如果用户进程没有这个必要,那么程序可以依赖内核的自动的选址机制来完成自动地址选择,通常在客户端应用程序中会这样做
/
listen()函数
listen()函数只能在服务器进程中使用,让服务器进程进入监听状态,等待客户端的连接请求,listen()函数在一般在 bind()函数之后调用,在 accept()函数之前调用
int listen(int sockfd, int backlog);
accept()函数
服务器调用 listen()函数之后,就会进入到监听状态,等待客户端的连接请求,使用 accept()函数获取客户端的连接请求并建立连接
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
connect()函数
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
该函数用于客户端应用程序中,客户端调用 connect()函数将套接字 sockfd 与远程服务器进行连接
客户端通过 connect()函数请求与服务器建立连接,对于 TCP 连接来说,调用该函数将发生 TCP 连接的握手过程,并最终建立一个 TCP 连接,而对于 UDP 协议来说,调用这个函数只是在 sockfd 中记录服务器IP 地址与端口号,而不发送任何数据
补充: IP 地址格式转换函数
inet_ntop()、inet_pton()与 inet_ntoa()、inet_aton() 类似,但它们还支持 IPv6 地址。它们将二进制 Ipv4 或Ipv6 地址转换成以点分十进制表示的字符串形式,或将点分十进制表示的字符串形式转换成二进制 Ipv4 或Ipv6 地址。
#include <stdio.h>
#include <stdlib.h>
#include <arpa/inet.h>
#define IPV4_ADDR "192.168.1.222"
int main(void)
{
struct in_addr addr;
inet_pton(AF_INET, IPV4_ADDR, &addr);
printf("ip addr: 0x%x\n", addr.s_addr);
exit(0);
}
示例:
#include <stdio.h>
#include <stdlib.h>
#include <arpa/inet.h>
int main(void)
{
struct in_addr addr;
char buf[20] = {0};
addr.s_addr = 0xde01a8c0;
inet_ntop(AF_INET, &addr, buf, sizeof(buf));
printf("ip addr: %s\n", buf);
exit(0);
}
示例:
//
一个简单的 TCP 服务器应用程序示例代码
编写服务器应用程序的流程如下:
①、调用 socket()函数打开套接字,得到套接字描述符;
②、调用 bind()函数将套接字与 IP 地址、端口号进行绑定;
③、调用 listen()函数让服务器进程进入监听状态;
④、调用 accept()函数获取客户端的连接请求并建立连接;
⑤、调用 read/recv、write/send 与客户端进行通信;
⑥、调用 close()关闭套接字。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#define SERVER_PORT 8888 //端口号不能发生冲突,不常用的端口号通常大于 5000
int main(void)
{
struct sockaddr_in server_addr = {0};
struct sockaddr_in client_addr = {0};
char ip_str[20] = {0};
int sockfd, connfd;
int addrlen = sizeof(client_addr);
char recvbuf[512];
int ret;
/* 打开套接字,得到套接字描述符 */
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (0 > sockfd) {
perror("socket error");
exit(EXIT_FAILURE);
}
/* 将套接字与指定端口号进行绑定 */
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(SERVER_PORT);
ret = bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));
if (0 > ret) {
perror("bind error");
close(sockfd);
exit(EXIT_FAILURE);
}
/* 使服务器进入监听状态 */
ret = listen(sockfd, 50);
if (0 > ret) {
perror("listen error");
close(sockfd);
exit(EXIT_FAILURE);
}
/* 阻塞等待客户端连接 */
connfd = accept(sockfd, (struct sockaddr *)&client_addr, &addrlen);
if (0 > connfd) {
perror("accept error");
close(sockfd);
exit(EXIT_FAILURE);
}
printf("有客户端接入...\n");
inet_ntop(AF_INET, &client_addr.sin_addr.s_addr, ip_str, sizeof(ip_str));
//第一个.sin_addr是为了从struct sockaddr_in结构体中获取struct in_addr类型的成员,这
//个成员专门用于存储 IP 地址相关的信息。第二个.s_addr是为了从struct in_addr结构体中获
//取实际存储 IP 地址的 32 位整数成员,这样才能将正确的地址数据传递给inet_ntop函数进行转换
printf("客户端主机的 IP 地址: %s\n", ip_str);
printf("客户端进程的端口号: %d\n", client_addr.sin_port);
/* 接收客户端发送过来的数据 */
for ( ; ; ) {
// 接收缓冲区清零
memset(recvbuf, 0x0, sizeof(recvbuf));
// 读数据
ret = recv(connfd, recvbuf, sizeof(recvbuf), 0);
if(0 >= ret) {
perror("recv error");
close(connfd);
break;
}
// 将读取到的数据以字符串形式打印出来
printf("from client: %s\n", recvbuf);
// 如果读取到"exit"则关闭套接字退出程序
if (0 == strncmp("exit", recvbuf, 4)) {
printf("server exit...\n");
close(connfd);
break;
}
}
/* 关闭套接字 */
close(sockfd);
exit(EXIT_SUCCESS);
}
编写客户端程序:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#define SERVER_PORT 8888 //服务器的端口号
#define SERVER_IP "输入自己服务器的IP地址" //服务器的 IP 地址
int main(void)
{
struct sockaddr_in server_addr = {0};
char buf[512];
int sockfd;
int ret;
/* 打开套接字,得到套接字描述符 */
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (0 > sockfd) {
perror("socket error");
exit(EXIT_FAILURE);
}
/* 调用 connect 连接远端服务器 */
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(SERVER_PORT); //端口号
inet_pton(AF_INET, SERVER_IP, &server_addr.sin_addr);//IP 地址
ret = connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));
if (0 > ret) {
perror("connect error");
close(sockfd);
exit(EXIT_FAILURE);
}
printf("服务器连接成功...\n\n");
/* 向服务器发送数据 */
for ( ; ; ) {
// 清理缓冲区
memset(buf, 0x0, sizeof(buf));
// 接收用户输入的字符串数据
printf("Please enter a string: ");
fgets(buf, sizeof(buf), stdin);
// 将用户输入的数据发送给服务器
ret = send(sockfd, buf, strlen(buf), 0);
if(0 > ret){
perror("send error");
break;
}
//输入了"exit",退出循环
if(0 == strncmp(buf, "exit", 4))
break;
}
close(sockfd);
exit(EXIT_SUCCESS);
}
编译服务器和客户端程序
客户端运行之后将会去连接远端服务器,连接成功便会打印出信息“服务器连接成功…”,此时服务器也会监测到客户端连接,会打印相应的信息
接下来我们便可以在客户端处输入字符串,客户端程序会将我们输入的字符串信息发送给服务器,服务器接收到之后将其打印出来
PS:
见到了这种编译方式 :¥{CC} -o … … ,之前一直用gcc -o … …