- 协议
- B/S、C/S
- 分层模型:七层 四层
- 协议格式:
数据包基本格式
以太网帧格式
ARP数据包格式
IP段格式
TCP/UDP - NAT映射、打洞机制
- 套接字
- TCP C/S模型
client.c
server.c - readline,出错处理
网络应用程序设计模式
- C/S模型:client/server
优点:协议选用灵活;可缓存数据
缺点:对用户的安全构成威胁;开发工作量较大,调试困难
使用场景:对数据量要求大,稳定性要求高 - B/S模型:browser/server
优点:安全;开发量较C/S模型减小一半;跨平台
缺点:必须使用HTTP协议;不能缓存数据,软件规模受限
使用场景:开发数据量小,数据访问量不大
网络信息传递过程
- 路由器寻路:
路由器在转发数据包的寻路过程中,不仅要依据其内部的目标ip地址,还要依赖数据包内的以太网帧。 - 以太网帧:(数据链路层)
目的地址(6字节) | 源地址(6字节) | 类型(2字节) | 数据(46-1500字节) | CRC(4字节) |
---|
注意:其中的源地址和目的地址是硬件地址(MAC地址),即网卡编号,是唯一的。(ifconfig
命令可查看)
路由器也有自己的MAC地址,因为它和电脑一样也是网络终端设备。那么从电脑源地址发出的以太网帧是怎么找到下一个路由器的呢?借助ARP请求。
- ARP请求\应答:
上述以太网帧的2字节类型数据若是0800,则该帧表示普通的以太网数据包;若是0806,则该帧表示ARP数据报,用来请求下一跳的(路由器的)MAC地址。
类型(2字节):0800 | IP数据报(46-1500字节) |
---|
类型(2字节):0806 | ARP请求(28字节) | PAD(18字节) |
---|
此时数据部分使用了最少字节数46字节,其中18字节PAD表示填充,为了凑够最少的46字节要求。
28字节的ARP数据报内容:
硬件类型(2字节) | 协议类型(2字节) | 硬件地址长度(1字节) | 协议地址长度(1字节) | op(2字节) | 发送端MAC地址(6字节) | 发送端IP地址(4字节) | 目的端MAC地址(6字节) | 目的端IP地址(4字节) |
---|
其中,重要的是后面的20字节的发送端和目的端地址,目的端IP地址指的是下一跳的路由终端IP地址,而非最终目的端IP地址(最终的目的端IP地址封装在网络层的IP协议中)。
注意:
- 以太网帧在网络中传递的过程中,并非不再解包。当它在每一跳寻路时,会在路由器内不完全解包,目的是获得MAC地址和IP地址,然后重新打包,继续寻路。直至到达目的端,完全解包。
- 路由器内的不完全解包包括拆掉最外层的以太网帧的帧头帧尾以及次外层的IP包头,获得最终的目的端IP地址。然后该路由器依据自己的路由表寻找下一跳的路由器IP地址,然后经过广播获得下一跳路由器的MAC地址,借此将该以太网帧重新打包发送到下一个路由器。
- TTL:数据包在网络中传输的最长生命周期,即设定一个数据包经过的路由器个数的上限,防止网络拥塞。
- IP段:
目的端IP地址存放在IP段中,位于网络层。
IP段格式中最重要的是32位源IP地址、32位目的IP地址(这里的源和目的端正是发送数据的源端和目的端,与上述ARP数据报中的源和目的端含义不同!)
另外,还有4位版本号(区分是IPv4还是IPv6)、4位首部长度(即IP段中数据部分之前的比特位数)、8位生存时间(TTL)
注意:IP段数据是外层以太网帧的数据部分(即那个46-1500字节的数据段部分)。 - 传输层数据报:
传输层协议有TCP和UDP两种,相应的数据报格式也有两种。
- UDP数据报中,重点是16位源端口号和16位目的端口号,端口号即进程号。
- TCP数据报中,除了也有上述的各16位源和目的端口号外,还有其他重要成员。
- 传输层数据报中封装的端口号不是用户指定的,而是操作系统的内核指定的。
- 更内层的用户层数据报则没有固定格式,因为没有固定的协议,可由用户自行指定。而除此之外的下层各层,包括传输层、网络层、网络接口层,则均有固定协议,因此也有相应的固定数据格式。在这些层里的数据帧的打包封装是由内核完成的,用户无权干涉。
- 但,用户层下面的这些层的数据传输协议也并非仅此单一。比如,网络层除了有IP协议,还有ICMP、IGMP协议。协议不同,对应的数据格式也不同。另外,也并非上层的数据报就一定完整地封装为下一层数据报中的数据部分。例如IP段格式中包含16位总长度,也即IP数据最长可达65535字节,而以太网帧的数据部分最长仅达1500字节。因此,要么IP层继续支持下一层的以太网传输协议及对应的帧格式(这样就得将IP数据报拆分后打包发送,再在另一端解包组装成完整IP数据),要么IP层自行设定以太网传输协议以及相应帧格式,使得不必拆分IP数据报而可完整一次性传输。
- 注:可在
/etc/services
文件中查看常见的网络协议对应的默认的服务器的IP地址和端口号。
-
NAT映射:
每个路由器除了维护一个路由表外,还有一个NAT映射表。NAT映射表的作用是将与某个路由器相连接的局域网中的若干终端的私有IP地址加端口号映射为该路由器的公网IP地址并对应它的一个端口号,以此作为公共可见的IP地址和端口号。例如,192.168.开头的IP地址就是私有IP,非公网IP;各大门户网站的服务器的IP地址就是公网IP。 -
打洞机制:
两个终端借助同一个公网IP实现直接互联的机制。例如,两个分属不同局域网的终端上的微信用户请求实时语音通话,他们的各自私有IP首先经由各自所属的交换机(或路由器)由NAT映射得到一个公网IP和端口号,然后它们各自与微信服务器连接,这个服务器的IP是一个大家都熟悉的IP,因此为了提高通话效率,该服务器建立一条不必经过自己的路由通路,实现两个微信终端的实时连接。
注意:这里有一个路由器防止网络恶意攻击的防护机制。当有一个陌生的IP地址第一次来请求连接时,它会拒绝。因此,如果一个微信终端IP(经NAT映射后的)第一次请求与目的微信终端IP建立连接时,它会被拒绝。而作为大家都熟悉的公网IP的微信服务器,则不会被拒绝。这是它打洞的基础。
套接字(socket)
基础知识
- socket有两端,即成对出现,一个发送数据,一个接收数据。
- IP地址在网络环境中唯一标识一台主机,端口号(port)在主机中唯一标识一个进程。IP地址加上端口号(port)在网络环境中唯一标识一个进程,即为socket。socket通信一定要绑定IP地址和port。
- socket通信原理:
socket是linux中的一种文件类型(伪文件),有一个文件描述符,对应指向两个缓冲区,一个存发送数据,一个存接收数据。它能实现双向全双工通信。
C\S模型
- 网络字节序:
TCP/IP协议规定,网络数据流应采用大端字节序,即低地址存数据的高位字节。 - IP地址转换:
点分十进制字符串类型的IP地址与网络字节序的IP地址之间的相互转换。 struct sockaddr
:
描述IPv4地址协议。该结构体现已被废弃,取而代之的是struct sockaddr_in
,但某些函数中用到该结构体时,参数类型仍为struct sockaddr *
,因此实际传参时需要一步强制类型转换。例如:bind
、accept
、connect
函数:
struct sockaddr_in addr;
bind( , (struct sockaddr *)&addr, ); //bind函数第二个参数类型为struct sockaddr *
查看该结构体的定义:
sudo grep -r "struct sockaddr_in {" /usr
或者
man 7 ip
- 网络套接字函数:
1)socket
:创建
2)bind
:往socket上绑定IP和port
3)listen
:指定监听上限数,即允许同时建立连接的客户端上限数
4)accept
:详见下文。
5)connect
:与服务器建立连接(由客户端调用)
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
//第2个参数是传出参数,第3个是传入传出参数
//返回值是一个新的socket文件描述符,用于和客户端通信
//该函数由服务器调用,阻塞等待客户端发起连接
- TCP\IP协议下的C\S模型的socketAPI:
其中,客户端没有调用bind
函数,则默认地系统会自动为客户端socket文件描述符绑定一个随机的IP地址和port号。而服务器端原则上也可免去bind
函数而由系统指定一个IP地址和port号,但在实际中是不行的,因为要与服务器建立连接的客户端不止一个,因此服务器的IP地址和port号一定要固定。 - 以下是一个最简单的C/S通信模型的client/server程序示例:
//server.c
#include <stdio.h>
#include <string.h>
//#include <strings.h>
#include <unistd.h>
#include <stdlib.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <ctype.h>
#define SERV_PORT 6666
#define SERV_IP "127.0.0.1"
int main() {
int serv_fd, serv_clie_fd;
socklen_t clie_len;
int ret;
int i;
char buf[BUFSIZ], client_ip[BUFSIZ];
struct sockaddr_in serv_addr, clie_addr;
serv_fd = socket(AF_INET, SOCK_STREAM, 0);
memset(&serv_addr, 0, sizeof(serv_addr));
//bzero(&serv_addr, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(SERV_PORT); //transform to net bytes order
inet_pton(AF_INET, (const char *)&SERV_IP, &serv_addr.sin_addr.s_addr);
//serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); //INADDR_ANY: random local ip
bind(serv_fd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));
listen(serv_fd, 128);
clie_len = sizeof(clie_addr);
serv_clie_fd = accept(serv_fd, (struct sockaddr *)&clie_addr, &clie_len);
printf("client IP: %s, client port: %d\n",
inet_ntop(AF_INET, &clie_addr.sin_addr.s_addr, client_ip, sizeof(client_ip)),
ntohs(clie_addr.sin_port));
while(1) {
ret = read(serv_clie_fd, buf, sizeof(buf));
for(i = 0; i < ret; i++) {
buf[i] = toupper(buf[i]);
}
write(serv_clie_fd, buf, ret);
}
close(serv_fd);
close(serv_clie_fd);
return 0;
}
//client.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>
#include <string.h>
#include <arpa/inet.h>
//must same as in server.c
#define SERV_PORT 6666
#define SERV_IP "127.0.0.1"
int main() {
int clie_fd;
int ret;
char buf[BUFSIZ];
clie_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_port = htons(SERV_PORT);
serv_addr.sin_family = AF_INET;
inet_pton(AF_INET, (const char *)&SERV_IP, &serv_addr.sin_addr.s_addr);
connect(clie_fd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));
while(1) {
fgets(buf, sizeof(buf), stdin);
//fgets add a '\0' automatically
// to the end of input string(which usually is '\n')
write(clie_fd, buf, strlen(buf));
ret = read(clie_fd, buf, sizeof(buf));
write(STDOUT_FILENO, buf, ret);
}
close(clie_fd);
return 0;
}
read
函数返回值总结:
- 大于0:返回实际读到的字节数
- 等于0:数据读完(读到文件、管道、socket末尾—对端关闭)
- -1:异常
errno == EINTR
: 被信号中断—重启\quiterrno == EAGAIN(EWOULDBLOCK)
:非阻塞方式读,并且没有数据- 其他值:出现错误—
perror、exit
- 错误处理
- readn函数:
ssize_t Readn(int fd, void *vptr, size_t n); //read n bytes from fd to vptr
- writen函数:
ssize_t Writen(int fd, const void *vptr, size_t n); //write n bytes from vptr to fd
- readline函数:
ssize_t Readline(int fd, void *vptr, size_t maxlen); //replace fgets(read from stdin), read a line from fd to vptr
错误处理一般思想:
考虑系统调用的返回值和出错处理后,可自定义用户函数。但应注意,自定义函数须和封装的系统调用同名且接口相同,唯独不需区分大小写(因为使用K
转到man page时系统不区分大小写)。相较于系统调用,使用这样的自定义函数的优点是程序更加健壮,不需将安全检查代码包含在程序中,这样可突出程序逻辑,又不影响安全性。
例如:
//int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
//经过封装后得到:
int Connect(int fd, const struct sockaddr *sa, socklen_t salen);