网络基础概念和 socket 编程
学习目标:
- 了解 OSI 七层模型、TCP/IP 四层模型结构
- 了解常见的网络协议格式
- 掌握网络字节序和主机字节序之间的转换
- 理解 TCP 服务器端通信流程
- 理解 TCP 客户端通信流程
- 实现 TCP 服务器端和客户端的代码
推荐一个非常好的学习资料仓库
协议
协议的概念
协议是事先约定好,大家共同遵守的一组规则,如交通信号灯。从应用的角度出发,协议可理解为“规则”,是数据传输和数据的解释的规则,可以简单的理解为各个主机之间进行通信所使用的共同语言。
假设,主机 A、主机 B 双方欲传输文件,规定:
- 第一次,传输文件名,接收方接收到文件名,应答 OK 给传输方
- 第二次,发送文件的尺寸,接收方接收到该数据再次应答一个 OK
- 第三次,传输文件内容,接收方接收数据完成后应答 OK 表示文件内容接收成功
由此,无论 A、B 之间传递何种文件,都是通过三次数据传输来完成。A、B 之间形成了一个最简单的数据传输规则。双方都按此规则发送、接收数据。A、B 之间达成的这个相互遵守的规则即为协议。
这种仅在 A、B 之间被遵守的协议称之为原始协议。当此协议被更多的人采用,不断的增加、改进、维护、完善。最终形成一个稳定的、完整的文件传输协议,被广泛应用于各种文件传输过程中。该协议就成为一个标准协议。最早的 ftp 协议就是由此衍生而来。
典型的协议
实际生活中有以下几种常见的协议:
- 应用层常见的协议有 HTTP 协议、FTP 协议等
- HTTP:超文本传输协议(Hyper Text Transfer Protocol),是互联网上应用最广泛的一种网络协议
- FTP:文件传输协议(File Transfer Protocol)
- 传输层常见的协议有 TCP/UDP 协议
- TCP:传输控制协议(Transmission Control Protocol),是一种面向连接的、可靠的、基于字节流的传输层通信协议
- UDP:用户数据协议(User Datagram Protocol),是 OSI 参考模型中一种无连接的传输层协议,提供面向事务的简单不可靠信息传送服务
- 网络层常见的协议有 IP 协议、ICMP 协议、IGMP 协议
- IP:因特网互联协议(Internet Protocol)
- ICMP:Internet 控制报文协议(Internet Control Message Protocol),是 TCP/IP 协议族的一个子协议,用于在 IP 主机、路由器之间传递控制消息
- IGMP:Internet 组管理协议(Internet Group Management Protocol),是因特网协议家族中的一个组播协议,该协议运行在主机和组播路由器之间
- 网络接口层常见的协议有 ARP 协议、RARP 协议
- ARP:正向地址解析协议(Address Resolution Protocol),通过已知的 IP,寻找对应主机的 MAC 地址
- RARP:反向地址转换协议,通过 MAC 地址确定 IP 地址
网络模型
OSI 七层模型
OSI 七层模型是国际标准组织制定的 OSI 理论模型,该模型定义了不同计算机互联的标准, 是设计和描述计算机网络通信的基本框架。七层模型分别是以下几个(从上向下):
- 应用层:是最靠近用户的 OSI 层,这一层为用户的应用程序(例如电子邮件、文件传输和终端仿真)提供网络服务
- 表示层:可确保一个系统的应用层所发送的信息可以被另一个系统的应用层读取。例如,PC 程序与另一台计算机进行通信,其中一台计算机使用扩展二一十进制交换码(EBCDIC),而另一台则使用美国信息交换标准码(ASCII)来表示相同的字符。如有必要,表示层会通过使用一种通格式来实现多种数据格式之间的转换
- 会话层:通过传输层(端口号:传输端口与接收端口)建立数据传输的通路。主要在你的系统之间发起会话或者接受会话请求(设备之间需要互相认识可以是IP也可以是MAC或者是主机名)
- 传输层:定义了一些传输数据的协议和端口号(WWW 端口 80 等),主要是将从下层接收的数据进行分段和传输,到达目的地址后再进行重组,常常把这一层数据叫做段
- 网络层:在位于不同地理位置的网络中的两个主机系统之间提供连接和路径选择。Internet 的发展使得从世界各站点访问信息的用户数大大增加,而网络层正是管理这种连接的层
- 数据链路层:定义了如何让格式化数据以帧为单位进行传输,以及如何让控制对物理介质的访问。这一层通常还提供错误检测和纠正,以确保数据的可靠传输。如:串口通信中使用到的 115200、8、N、1
- 物理层:主要定义物理设备标准,如网线的接口类型、光纤的接口类型、各种传输介质的传输速率等。它的主要作用是传输比特流(就是由 1、0 转化为电流强弱来进行传输,到达目的地后再转化为 1、0,也就是我们常说的数模转换与模数转换),这一层的数据叫做比特
TCP/IP 四层模型
在实际生产开发中,讨论更多的是 TCP/IP 四层模型,这是对七层模型的简化
TCP/IP 网络协议栈分为应用层(Application)、传输层(Transport)、网络层(Network)和链路层(Link)四层,如下图所示:
数据通信过程
数据的通信过程本质上在发送方是一个层层打包的过程,在接收方是一个层层解包的过程,这种打包的过程是内核帮我们完成。如下图所示,PC 机 A 通过网络向 PC 机 B 发送数据的过程
网络应用程序设计模式
目前使用最多的设计模式就两种 C/S 和 B/S:
- C/S:客户端和服务器模式,需要在通讯两端各自部署客户机和服务器来完成数据通信
- 优点:客户端在本机上可以保证性能,可以将数据缓存到本地,提高数据的传输效率,提高用户体验效果;客户端和服务端程序都是由同一个开发团队开发,协议选择比较灵活
- 缺点:服务器和客户端都需要开发,工作量相对较大,调试困难,开发周期长;从用户的角度看,需要将客户端安装到用户的主机上,对用户主机的安全构成威胁
- B/S:浏览器和服务器模式,只需在一端部署服务器,而另一端使用浏览器即可完成数据传输
- 优点:无需安装客户端,可以使用标注你的浏览器作为客户端;只需要开发服务器,工作量相对较小;由于采用标准的客户端,所以移植性好,不受平台限制;相对安全,不用安装软件
- 缺点:由于没有客户端,数据缓冲不尽人意,数据传输有限制,用户体验较差;通信协议选择只能使用 HTTP 协议,协议选择不够灵活
以太网帧
以太网帧格式就是包装在网络接口层(数据链路层)的协议,具体格式如下:
以 ARP 为例,其协议格式具体如下:
源 MAC 地址、目的 MAC 地址在以太网首部和 ARP 请求中各出现一次,对于链路层为以太网的情况是多余的,但如果链路层是其它类型的网络则有可能是必要的。硬件类型指链路层网络类型,1 为以太网,协议类型指要转换的地址类型,0x0800 为 IP 地址,后面两个地址长度对于以太网地址和IP地址分别为 6 和 4(字节),op 字段为 1 表示 ARP 请求,op 字段为 2 表示 ARP 应答。
假设现在 PC 机 A 向 PC 机 B 发送请求,简单的流程如下:
- PC 机 A 将本机的 MAC 地址填入源地址,以广播的方式发送 ARP 报文,因此数据报文的格式为
- 以太网首部(14字节):0000: ff ff ff ff ff ff 00 05 5d 61 58 a8 08 06 —— 目的主机采用广播地址,源主机的 MAC 地址是00:05:5d:61:58:a8,上层协议类型 0x0806 表示 ARP
- ARP 帧(28 字节):
- 0000: 00 01 —— 硬件类型 0x0001 表示以太网
- 0010: 08 00 06 04 00 01 00 05 5d 61 58 a8 c0 a8 00 37 —— 协议类型 0x0800 表示 IP 协议,硬件地址(MAC地址)长度为 6,协议地址(IP地址)长度为 4,op 为 0x0001 表示请求目的主机的 MAC 地址,源主机 MAC 地址为 00:05:5d:61:58:a8,源主机 IP 地址为 c0 a8 00 37(192.168.0.55)
- 0020: 00 00 00 00 00 00 c0 a8 00 02 —— 目的主机 MAC 地址全 0 待填写,目的主机 IP 地址为 c0 a8 00 02(192.168.0.2)
- 填充位(18 字节):由于以太网规定最小数据长度为46字节,ARP帧长度只有28字节,因此有18字节填充位,填充位的内容没有定义,与具体实现相关
- 0020: 00 77 31 d2 50 10
- 0030: fd 78 41 d3 00 00 00 00 00 00 00 00
- PC 机 B 收到 ARP 数据报文后,发送应答
- 以太网首部(14字节):0000: 00 05 5d 61 58 a8 00 05 5d a1 b8 40 08 06 —— 目的主机的 MAC 地址是00:05:5d:61:58:a8,源主机的 MAC 地址是 00:05:5d:a1:b8:40,上层协议类型 0x0806 表示 ARP
- ARP 帧(28 字节):
- 0000: 00 01 —— 硬件类型 0x0001 表示以太网
- 0010: 08 00 06 04 00 02 00 05 5d a1 b8 40 c0 a8 00 02 —— 协议类型 0x0800 表示 IP 协议,硬件地址(MAC地址)长度为 6,协议地址(IP地址)长度为 4,op 为 0x0002 表示应答,源主机 MAC 地址为 00:05:5d:a1:b8:40,源主机 IP 地址为 c0 a8 00 02(192.168.0.2)
- 0020: 00 05 5d 61 58 a8 c0 a8 00 37 —— 目的主机MAC地址为 00:05:5d:61:58:a8,目的主机 IP 地址为c0 a8 00 37(192.168.0.55)
- 填充位(18 字节):
- 0020: 00 77 31 d2 50 10
- 0030: fd 78 41 d3 00 00 00 00 00 00 00 00
其他的数据包格式也以差不多的方式进行发送和应答。
注意:通过 IP 地址可以确定同一网段中唯一的一台主机,主机使用端口号来区分不同的应用程序。
socket 编程
传统的进程间通信借助内核提供的IPC机制进行,但是只能限于本机通信,若要跨机通信,就必须使用网络通信(本质上借助内核-内核提供了 socket
伪文件的机制实现通信——实际上是使用文件描述符),这就需要用到内核提供给用户的 socket API 函数库。
网络字节序
在进行网络通信时,一定要注意数据的字节序问题,如果不使用同一的字节序,发送/接收的数据可能是错误的。网络字节序分为两种:
- 大端字节序:低地址存放高位数据,高地址存放低位数据
- 小段字节序:低地址存放低位数据,高地址存放高位数据
如何确定本机上是大端还是小段,代码测试实例如下:
#include <stdio.h>
union {
short sval;
char cval[sizeof(short)];
} u1;
union {
int ival;
char cval[sizeof(int)];
} u2;
int main() {
u1.sval = 0x0102;
// cval[0] 中存放的是 0x01 则是大端字节序,否则则是小端字节序
printf("u1.sval = %#x, u1.cval[0] = %#x, u1.cval[1] = %#x\n", u1.sval, u1.cval[0], u1.cval[1]);
u2.ival = 0x01020304;
printf("u2.ival = %#x, u2.cval[0] = %#x, u2.cval[1] = %#x, u2.cval[2] = %#x, u2.cval[3] = %#x\n", u2.ival, u2.cval[0], u2.cval[1], u2.cval[2], u2.cval[3]);
return 0;
}
在网络传输中使用的是大端字节序,如果机器用的是小端法,则需要进行大小端的转换。一般使用以下 4 个函数:
#include <arpa/inet.h>
// 将主机字节序转换为网络字节序
uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
// 将网络字节序转换为主机字节序
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);
其中 h
表示主机 host,n
表示网络 network,s
表示 short
,l
表示 long
。
socket 编程的 API 函数
socket
函数
socket
函数用来创建一个网络套接字
#include <sys/types.h>
#include <sys/socket.h>
/**
* @param:
* domain: 协议版本,有 AF_INET 表示 IPV4,AF_INET6 表示 IPV6 等
* type: 协议类型,主要有 SOCK_STREAM 和 SOCK_DGRAM,分别表示 TCP 和 UDP
* protocol: 一般填 0, 表示使用对应类型的默认协议
* @return: 成功返回大于 0 的文件描述符,失败返回 -1
*/
int socket(int domain, int type, int protocol);
当调用 socket
函数以后,返回一个文件描述符,内核会提供与该文件描述符相对应的读和写缓冲区,同时还有两个队列,分别是请求连接队列和已连接队列。
bind
函数
bind
函数将 socket
创建的文件描述符与 IP,port 绑定
#include <sys/types.h>
#include <sys/socket.h>
/**
* @param:
* sockfd: 调用 socket 函数返回的文件描述符
* addr: 本地服务器的 IP 地址和 PORT
* addrlen: addr 变量的占用的内存大小
* @return: 成功返回 0,失败返回 -1
*/
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
sockaddr
是一个保存 IP 和 PORT 的结构体,其原型如下:
struct sockaddr {
sa_family_t sa_family;
char sa_data[14];
}
由于 sockaddr
在保存地址数据的时候比较繁琐,有了 sockaddr_in
结构,其结构原型如下:
struct sockaddr_in {
sa_family_t sin_family; /* address family: AF_INET */
in_port_t sin_port; /* port in network byte order */
struct in_addr sin_addr; /* internet address */
};
/* Internet address. */
struct in_addr {
uint32_t s_addr; /* address in network byte order */
};
在实际的使用中,使用更多的是 sockaddr_in
结构类型,传参时进行类型转换即可,这两个结构体的大小是一样的。
listen
函数
listen
函数将套接字由主动改为被动
#include <sys/types.h>
#include <sys/socket.h>
/**
* @param:
* sockfd: 调用 socket 函数返回的文件描述符
* backlog: 同时请求连接的最大来凝结个数(进入连接队列)
* @return: 成功返回 0,失败返回 -1
*/
int listen(int sockfd, int backlog);
accept
函数
accept
函数从连接队列中获取一个连接,如何连接队列中没有连接则会阻塞等待
#include <sys/types.h>
#include <sys/socket.h>
/**
* @param:
* sockfd: 调用 socket 函数返回的文件描述符
* addr: 传出参数,保存客户端的地址信息
* addrlen: 传入传出参数,addr 变量所占内存空间大小
* @return: 成功返回获取连接客户端的文件描述符,失败返回 -1
*/
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
内核会负责将请求队列中的连接拿到已连接队列中。
connect
函数
connect
函数是主动向指定的 IP 和 PORT 地址发送连接请求。
#include <sys/types.h>
#include <sys/socket.h>
/**
* @param:
* sockfd: 调用 socket 函数返回的文件描述符
* addr: 服务端的地址信息
* addrlen: addr 变量所占内存空间大小
* @return: 成功返回 0,失败返回 -1
*/
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
在发送请求连接前,需要确定服务器的地址信息,其中包括以点分十进制表示的 IP 地址,将其转换成网络字节序的 IP 地址方式使用以下的函数:
#include <arpa/inet.h>
/**
* @description: 将 IPV4 或 IPV6 的地址从点分十进制的 IP 转换为网络字节序
* @param:
* af: AF_INET 或 AF_INET6
* src: 字符串形式的点分十进制的 IP 地址
* dst: 存放转换后的变量的地址
* @return: 成功返回指向 dst 的指针,失败返回 NULL
*/
int inet_pton(int af, const char *src, void *dst);
/**
* @description: 将 IPV4 或 IPV6 的地址从网络字节序转换为点分十进制的 IP
* @param:
* af: AF_INET 或 AF_INET6
* src: 网络的整形的 IP 地址
* dst: 转换后的 IP 地址,一般为字符串数组
* size: dst 的长度
* @return: 成功返回指向 dst 的指针,失败返回 NULL
*/
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
服务器端和客户端的开发流程
使用 socket 的 API 函数编写服务端和客户端程序的步骤图示
服务器端的实现
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <arpa/inet.h>
#define BUFFERSIZE 1024
int main(int argc, char *argv[]) {
if (2 != argc) {
fprintf(stderr, "Usage: %s <port>\n", argv[0]);
exit(EXIT_FAILURE);
}
// 创建套接字
int sfd = socket(AF_INET, SOCK_STREAM, 0);
if (-1 == sfd) {
perror("socket() error");
exit(EXIT_FAILURE);
}
// 绑定 IP 和 PORT
struct sockaddr_in serv_addr;
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_addr.sin_port = htons(atoi(argv[1]));
if (-1 == bind(sfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr))) {
perror("bind() error");
close(sfd);
exit(EXIT_FAILURE);
}
// 将套接字由主动态变为被动态
if (-1 == listen(sfd, 2)) {
close(sfd);
perror("listen() error");
exit(EXIT_FAILURE);
}
// 从连接获得一个连接,没有连接则阻塞等待,连接队列取完则退出
struct sockaddr_in clnt_addr;
socklen_t addr_len = sizeof(clnt_addr);
int cfd = accept(sfd, (struct sockaddr *)&clnt_addr, &addr_len);
if (-1 == cfd) {
close(sfd);
perror("accept() error");
exit(EXIT_FAILURE);
}
int rlen;
char message[BUFFERSIZE] = {0};
// 开始读取和发送数据
while (1) {
memset(message, 0, BUFFERSIZE);
rlen = read(cfd, message, BUFFERSIZE);
if (0 == rlen) {
printf("client %d is disconnected\n", cfd);
break;
} else if (0 > rlen) {
perror("read() error");
break;
}
printf("READ: %s", message);
write(cfd, message, rlen);
}
close(cfd);
close(sfd);
return 0;
}
客户端的实现
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <arpa/inet.h>
#define BUFFERSIZE 1024
int main(int argc, char *argv[]) {
if (3 != argc) {
fprintf(stderr, "Usage: %s <ip> <port>\n", argv[0]);
exit(EXIT_FAILURE);
}
// 创建套接字
int cfd = socket(AF_INET, SOCK_STREAM, 0);
if (-1 == cfd) {
perror("socket() error");
exit(EXIT_FAILURE);
}
// 向服务器端发送连接请求
struct sockaddr_in clnt_addr;
clnt_addr.sin_family = AF_INET;
inet_pton(AF_INET, argv[1], &clnt_addr.sin_addr.s_addr);
clnt_addr.sin_port = htons(atoi(argv[2]));
if (-1 == connect(cfd, (struct sockaddr *)&clnt_addr, sizeof(clnt_addr))) {
close(cfd);
perror("connect() error");
exit(EXIT_FAILURE);
}
char message[BUFFERSIZE] = {0};
while (1) {
memset(message, 0, BUFFERSIZE);
printf("Please input message(q/Q to quit): ");
fgets(message, BUFFERSIZE-1, stdin);
if (!strcmp(message, "Q\n") || !strcmp(message, "q\n"))
break;
int wlen = write(cfd, message, sizeof(message));
printf("WRITE: %s", message);
int rlen = read(cfd, message, BUFFERSIZE);
if (rlen < 0) {
perror("read() error");
break;
}
printf("READ: %s", message);
}
close(cfd);
return 0;
}