1.网络基础

1.1 网络分层模型

02.png

1.2 数据包封装

​ 传输层及其以下的机制由内核提供,应用层由用户进程提供(后面将介绍如何使用socket API编写应用程序),应用程序对通讯数据的含义进行解释,而传输层及其以下处理通讯的细节,将数据从一台计算机通过一定的路径发送到另一台计算机。应用层数据通过协议栈发到网络上时,每层协议都要加上一个数据首部(header),称为封装(Encapsulation),如下图所示:

03.png

​ 不同的协议层对数据包有不同的称谓,在传输层叫做段(segment),在网络层叫做数据报(datagram),在链路层叫做帧(frame)。数据封装成帧后发到传输介质上,到达目的主机后每层协议再剥掉相应的首部,最后将应用层数据交给应用程序处理。

1.3 以太网帧格式

04.png

其中的源地址和目的地址是指网卡的硬件地址(也叫MAC地址),长度是48位,是在网卡出厂时固化的。可在shell中使用ifconfig命令查看。协议字段有三种值,分别对应IP、ARP、RARP。帧尾是CRC校验码。

1.4 ARP数据报格式

在网络通讯时,源主机的应用程序知道目的主机的IP地址和端口号,却不知道目的主机的硬件地址,而数据包首先是被网卡接收到再去处理上层协议的,如果接收到的数据包的硬件地址与本机不符,则直接丢弃。因此在通讯前必须获得目的主机的硬件地址。ARP协议就起到这个作用。

05.png

1.5 IP段格式

06.png

1.6 UDP数据报格式

07.png

1.7 TCP数据报格式

08.png

2.Socket编程

2.1 套接字概念

​ Socket本身有“插座”的意思,在Linux环境下,用于表示进程间网络通信的特殊文件类型。本质为内核借助缓冲区形成的伪文件。

​ 既然是文件,那么理所当然的,我们可以使用文件描述符引用套接字。与管道类似的,Linux系统将其封装成文件的目的是为了统一接口,使得读写套接字和读写文件的操作一致。区别是管道主要应用于本地进程间通信,而套接字多应用于网络进程间数据的传递。

​ 套接字的内核实现较为复杂,不宜在学习初期深入学习。

​ 在TCP/IP协议中,“IP地址+TCP或UDP端口号”唯一标识网络通讯中的一个进程。“IP地址+端口号”就对应一个socket。欲建立连接的两个进程各自有一个socket来标识,那么这两个socket组成的socket pair就唯一标识一个连接。因此可以用Socket来描述网络连接的一对一关系。

​ 套接字通信原理如下图所示:

01.png

在网络通信中,套接字一定是成对出现的。一端的发送缓冲区对应对端的接收缓冲区。我们使用同一个文件描述符索发送缓冲区和接收缓冲区。

09.png

2.2 网络字节序

​ 我们已经知道,内存中的多字节数据相对于内存地址有大端和小端之分,磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分。网络数据流同样有大端小端之分,那么如何定义网络数据流的地址呢?发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出,接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存,因此,网络数据流的地址应这样规定:先发出的数据是低地址,后发出的数据是高地址。

  • 主机字节序 (小端)

    • 数据的低位字节存储到内存的低地址位 , 数据的高位字节存储到内存的高地址位
    • 我们使用的 PC 机,数据的存储默认使用的是小端
  • 网络字节序 (大端)

    • 数据的低位字节存储到内存的高地址位 , 数据的高位字节存储到内存的低地址位
    • 套接字通信过程中操作的数据都是大端存储的,包括:接收/发送的数据、IP地址、端口。

为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络字节序和主机字节序的转换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#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);

2.3 IP 地址转换函数

虽然 IP 地址本质是一个整形数,但是在使用的过程中都是通过一个字符串来描述,下面的函数描述了如何将一个字符串类型的 IP 地址进行大小端转换:

1
2
3
// 主机字节序的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, 说明src指向的不是一个有效的ip地址。失败返回- 1
1
2
3
#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

2.4 sockaddr数据结构

strcut sockaddr 很多网络编程函数诞生早于IPv4协议,那时候都使用的是sockaddr结构体,为了向前兼容,现在sockaddr退化成了(void *)的作用,传递一个地址给函数,至于这个函数是sockaddr_in还是sockaddr_in6,由地址族确定,然后函数内部再强制类型转化为所需的地址类型。

10.png

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
// 在写数据的时候不好用
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)];
};

2.5 网络套接字函数

2.5.1 socket模型创建流程图

11.png

2.5.2 socket()函数

使用套接字通信函数需要包含头文件 <arpa/inet.h>,包含了这个头文件 <sys/socket.h> 就不用在包含了。

1
2
// 创建一个套接字
int socket(int domain, int type, int protocol);
  • 参数:
    • domain: 使用的地址族协议
      • AF_INET: 使用 IPv4 格式的 ip 地址
      • AF_INET6: 使用 IPv4 格式的 ip 地址
    • type:
      • SOCK_STREAM: 使用流式的传输协议(通常对于TCP协议)
      • SOCK_DGRAM: 使用报式 (报文) 的传输协议(通常对于UDP协议)
    • protocol: 一般写 0 即可,使用默认的协议
      • SOCK_STREAM: 流式传输默认使用的是 tcp
      • SOCK_DGRAM: 报式传输默认使用的 udp
  • 返回值:
    • 成功:可用于套接字通信的文件描述符
    • 失败: -1,设置errno

socket()打开一个网络通讯端口,如果成功的话,就像open()一样返回一个文件描述符,通过这个文件描述符可以操作内核中的某一块内存,网络通信是基于这个文件描述符来完成的,应用程序可以像读写文件一样用read/write在网络上收发数据。

2.5.3 bind()函数

1
2
// 将文件描述符和本地的IP与端口进行绑定   
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • 参数:
    • sockfd: 监听的文件描述符,通过 socket () 调用得到的返回值
    • addr: 传入参数,要绑定的 IP 和端口信息需要初始化到这个结构体中,IP和端口要转换为网络字节序
    • addrlen: 参数 addr 指向的内存大小,sizeof (struct sockaddr)
  • 返回值:成功返回 0,失败返回 - 1,设置errno

服务器程序所监听的网络地址和端口号通常是固定不变的,客户端程序得知服务器程序的地址和端口号后就可以向服务器发起连接,因此服务器需要调用bind绑定一个固定的网络地址和端口号。

bind()的作用是将参数sockfd和addr绑定在一起,使sockfd这个用于网络通讯的文件描述符监听addr所描述的地址和端口号。前面讲过,struct sockaddr *是一个通用指针类型,addr参数实际上可以接受多种协议的sockaddr结构体,而它们的长度各不相同,所以需要第三个参数addrlen指定结构体的长度。如:

1
2
3
4
5
struct sockaddr_in servaddr;
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(6666);

首先将整个结构体清零,然后设置地址类型为AF_INET,网络地址为INADDR_ANY,这个宏表示本地的任意IP地址,因为服务器可能有多个网卡,每个网卡也可能绑定多个IP地址,这样设置可以在所有的IP地址上监听,直到与某个客户端建立了连接时才确定下来到底用哪个IP地址,端口号为6666。

2.5.4 listen()函数

1
2
// 给监听的套接字设置监听
int listen(int sockfd, int backlog);
  • 参数:
    • sockfd: 文件描述符,可以通过调用 socket () 得到,在监听之前必须要绑定 bind ()
    • backlog: 同时能处理的最大连接要求,最大值为 128
  • 返回值:函数调用成功返回 0,调用失败返回 -1

典型的服务器程序可以同时服务于多个客户端,当有客户端发起连接时,服务器调用的accept()返回并接受这个连接,如果有大量的客户端发起连接而服务器来不及处理,尚未accept的客户端就处于连接等待状态,listen()声明sockfd处于监听状态,并且最多允许有backlog个客户端处于连接待状态,如果接收到更多的连接请求就忽略。listen()成功返回0,失败返回-1。

2.5.5 accept()函数

1
2
// 等待并接受客户端的连接请求, 建立新的连接, 会得到一个新的文件描述符(通信的)		
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
  • 参数:
    • sockfd: 监听的文件描述符
    • addr: 传出参数,里边存储了建立连接的客户端的地址信息
    • addrlen: 传入传出参数,用于存储 addr 指向的内存大小
  • 返回值:函数调用成功,得到一个文件描述符,用于和建立连接的这个客户端通信,调用失败返回 -1

这个函数是一个阻塞函数,当没有新的客户端连接请求的时候,该函数阻塞;当检测到有新的客户端连接请求时,阻塞解除,新连接就建立了,得到的返回值也是一个文件描述符,基于这个文件描述符就可以和客户端通信了。

2.5.6 connect()函数

1
2
3
// 成功连接服务器之后, 客户端会自动随机绑定一个端口
// 服务器端调用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

客户端需要调用connect()连接服务器,connect和bind的参数形式一致,区别在于bind的参数是自己的地址,而connect的参数是对方的地址。connect()成功返回0,出错返回-1。

2.6 C/S模型-TCP

下图是基于TCP协议的客户端/服务器程序的一般流程:

12.png

基于 tcp 的服务器端通信代码:

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
/*************************************************************************
# > File Name:server.c
# > Author: Jay
# > Mail: billysturate@gmail.com
# > Created Time: Sun 11 Sep 2022 05:21:57 PM CST
************************************************************************/

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <errno.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <ctype.h>

#define SERV_PORT 9527

void sys_err(const char *str)
{
perror(str);
exit(1);
}
int main(int argc, char *argv[])
{
int lfd = 0, cfd = 0;
int ret;
char buf[BUFSIZ], client_IP[1024];

struct sockaddr_in serv_addr, clit_addr;
socklen_t clit_addr_len;

serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(SERV_PORT);
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);

lfd = socket(AF_INET, SOCK_STREAM, 0);
if(lfd == -1)
{
sys_err("socket error");
}
ret = bind(lfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));
if (ret == -1)
{
sys_err("bind error");
}
ret = listen(lfd, 128);
if(ret == -1)
{
sys_err("listen error");
}
clit_addr_len = sizeof(clit_addr);
cfd = accept(lfd, (struct sockaddr *)&clit_addr, &clit_addr_len);
if(cfd == -1)
{
sys_err("accept error");
}
printf("client ip:%s port:%d\n", inet_ntop(AF_INET, &clit_addr.sin_addr.s_addr, client_IP, sizeof(client_IP)), ntohs(clit_addr.sin_port));
while(1){
ret = read(cfd, buf, sizeof(buf));
write(STDOUT_FILENO, buf, ret);
int i;
for (i = 0; i < ret; i++)
{
buf[i] = toupper(buf[i]);
}
write(cfd, buf, ret);
}
close(lfd);
close(cfd);
return 0;
}

基于 tcp 通信的客户端通信代码:

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
/*************************************************************************
# > File Name:client.c
# > Author: Jay
# > Mail: billysturate@gmail.com
# > Created Time: Sun 11 Sep 2022 07:58:17 PM CST
************************************************************************/

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <errno.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <ctype.h>

#define SERV_PORT 9527
void sys_err(const char *str)
{
perror(str);
exit(1);
}
int main(int argc, char *argv[])
{
int cfd, ret;
char buf[BUFSIZ];
struct sockaddr_in serv_addr;
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(SERV_PORT);
ret = inet_pton(AF_INET, "10.0.12.16", (void *)&serv_addr.sin_addr.s_addr);
if(ret == -1)
{
sys_err("tansform error");
}
cfd = socket(AF_INET, SOCK_STREAM, 0);
if(cfd == -1)
{
sys_err("create error");
}
ret = connect(cfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));
if(ret == -1)
{
sys_err("connect error");
}
while (1)
{
write(cfd, "hello\n", 6);
ret = read(cfd, buf, sizeof(buf));
write(STDOUT_FILENO, buf, ret);
sleep(2);
}


return 0;
}

编译运行,结果如下

14.png

13.png