网络编程

137 阅读11分钟

字节序

字节在内存中存储的顺序。

  • 小端字节序:数据的高位字节存储在内存的高位地址,低位字节存储在内存的低位地址
  • 大端字节序:数据的低位字节存储在内存的高位地址,高位字节存储在内存的低位地址

网络字节序:大端排序

字节序转换函数

h - host 主机,主机字节序
to - 转换
n - network 网络字节序
s - short : unsigned short
l - long : unsigned int

#include <arpa/inet.h>  
// 转换端口  
uint16_t htons(uint16_t hostshort); // 主机字节序 - 网络字节序  
uint16_t ntohs(uint16_t netshort); // 主机字节序 - 网络字节序  
// 转IP  
uint32_t htonl(uint32_t hostlong); // 主机字节序 - 网络字节序  
uint32_t ntohl(uint32_t netlong); // 主机字节序 - 网络字节序

socket 地址

通用 socket 地址:结构体 sockaddr

#include <bits/socket.h>  
struct sockaddr {  
sa_family_t sa_family;  
char sa_data[14];  
};  
typedef unsigned short int sa_family_t;
  • sa_family 成员是地址族类型(sa_family_t)的变量:PF_INET,AF_INET
  • sa_data 成员用于存放 socket 地址值

专用 socket 地址: 结构体 sockaddr_in

#include <netinet/in.h>  
struct sockaddr_in  
{  
sa_family_t sin_family; /* __SOCKADDR_COMMON(sin_) */  
in_port_t sin_port; /* Port number. */  
struct in_addr sin_addr; /* Internet address. */  
/* Pad to size of `struct sockaddr'. */  
unsigned char sin_zero[sizeof (struct sockaddr) - __SOCKADDR_COMMON_SIZE -  
sizeof (in_port_t) - sizeof (struct in_addr)];  
};  
struct in_addr  
{  
in_addr_t s_addr;  
};
typedef unsigned short uint16_t;  
typedef unsigned int uint32_t;  
typedef uint16_t in_port_t;  
typedef uint32_t in_addr_t;

所有专用 socket 地址(以及 sockaddr_storage)类型的变量在实际使用时都需要转化为通用 socket 地址类型 sockaddr(强制转化即可),因为所有 socket 编程接口使用的地址参数类型都是 sockaddr。

IP地址转换函数

#include <arpa/inet.h>  
// p:点分十进制的IP字符串,n:表示network,网络字节序的整数  
int inet_pton(int af, const char *src, void *dst);  
af:地址族: AF_INET AF_INET6  
src:需要转换的点分十进制的IP字符串  
dst:转换后的结果保存在这个里面  
// 将网络字节序的整数,转换成点分十进制的IP地址字符串  
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);  
af:地址族: AF_INET AF_INET6  
src: 要转换的ip的整数的地址  
dst: 转换成IP地址字符串保存的地方  
size:第三个参数的大小(数组的大小)  
返回值:返回转换后的数据的地址(字符串),和 dst 是一样

TCP通信流程

image.png

服务器端 (被动接受连接的角色)

  1. 创建一个用于监听的套接字
    • 监听:监听有客户端的连接
    • 套接字:这个套接字其实就是一个文件描述符
  2. 将这个监听文件描述符和本地的IP和端口绑定(IP和端口就是服务器的地址信息)
    • 客户端连接服务器的时候使用的就是这个IP和端口
  3. 设置监听,监听的fd开始工作
  4. 阻塞等待,当有客户端发起连接,解除阻塞,接受客户端的连接,会得到一个和客户端通信的套接字 (fd)
  5. 通信
    • 接收数据
    • 发送数据
  6. 通信结束,断开连接

客户端

  1. 创建一个用于通信的套接字(fd)
  2. 连接服务器,需要指定连接的服务器的 IP 和 端口
  3. 连接成功了,客户端可以直接和服务器通信
    • 接收数据
    • 发送数据
  4. 通信结束,断开连接

套接字函数

#include <sys/types.h>  
#include <sys/socket.h>  
#include <arpa/inet.h> // 包含了这个头文件,上面两个就可以省略  
int socket(int domain, int type, int protocol);  
    - 功能:创建一个套接字  
    - 参数:  
        - domain: 协议族  
            AF_INET : ipv4  
            AF_INET6 : ipv6  
            AF_UNIX, AF_LOCAL : 本地套接字通信(进程间通信)  
        - type: 通信过程中使用的协议类型  
            SOCK_STREAM : 流式协议  
            SOCK_DGRAM : 报式协议  
        - protocol : 具体的一个协议。一般写0  
            - SOCK_STREAM : 流式协议默认使用 TCP  
            - SOCK_DGRAM : 报式协议默认使用 UDP  
    - 返回值:  
        - 成功:返回文件描述符,操作的就是内核缓冲区。  
        - 失败:-1    
        
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen); // socket命  
名  
- 功能:绑定,将fd 和本地的IP + 端口进行绑定  
- 参数:  
    - sockfd : 通过socket函数得到的文件描述符  
    - addr : 需要绑定的socket地址,这个地址封装了ip和端口号的信息  
    - addrlen : 第二个参数结构体占的内存大小  
  
int listen(int sockfd, int backlog); // /proc/sys/net/core/somaxconn  
- 功能:监听这个socket上的连接  
- 参数:  
    - sockfd : 通过socket()函数得到的文件描述符  
    - backlog : 未连接的和已经连接的和的最大值, 5    
    
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);  
- 功能:接收客户端连接,默认是一个阻塞的函数,阻塞等待客户端连接  
- 参数:  
    - sockfd : 用于监听的文件描述符  
    - addr : 传出参数,记录了连接成功后客户端的地址信息(ip,port)  
    - addrlen : 指定第二个参数的对应的内存大小  
- 返回值:  
    - 成功 :用于通信的文件描述符  
    - -1 : 失败  
  
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- 功能: 客户端连接服务器  
- 参数:  
    - sockfd : 用于通信的文件描述符  
    - addr : 客户端要连接的服务器的地址信息  
    - addrlen : 第二个参数的内存大小  
- 返回值:
    成功 0, 失败 -1    
    
ssize_t write(int fd, const void *buf, size_t count); // 写数据  
ssize_t read(int fd, void *buf, size_t count); // 读数据

TCP 状态转换

image.png

使用 API 来控制实现半连接状态

  • 半连接状态:当 TCP 链接中 A 向 B 发送 FIN 请求关闭,另一端 B 回应 ACK 之后(A 端进入 FIN_WAIT_2 状态),并没有立即发送 FIN 给 A,A 方处于半连接状态(半开关),此时 A 可以接收 B 发送的数据,但是 A 已经不能再向 B 发送数据。

API

#include <sys/socket.h>  
int shutdown(int sockfd, int how);  
    sockfd: 需要关闭的socket的描述符  
    how: 允许为shutdown操作选择以下几种方式:  
        SHUT_RD(0): 关闭sockfd上的读功能,此选项将不允许sockfd进行读操作。该套接字不再接收数据,任何当前在套接字接受缓冲区的数据将被无声的丢弃掉。  
        SHUT_WR(1): 关闭sockfd的写功能,此选项将不允许sockfd进行写操作。进程不能在对此套接字发出写操作。  
        SHUT_RDWR(2):关闭sockfd的读写功能。相当于调用shutdown两次:首先是以SHUT_RD,然后以HUT_WR。相当于 close() .

close 与 shutdown 说明

使用 close 中止一个连接,但它只是减少描述符的引用计数,并不直接关闭连接,只有当描述符的引用
计数为 0 时才关闭连接。shutdown 不考虑描述符的引用计数,直接关闭描述符。也可选择中止一个方
向的连接,只中止读或只中止写。
注意:

  1. 如果有多个进程共享一个套接字,close 每被调用一次,计数减 1 ,直到计数为 0 时,也就是所用
    进程都调用了 close,套接字将被释放。
  2. 在多进程中如果一个进程调用了 shutdown(sfd, SHUT_RDWR) 后,其它的进程将无法进行通信。
    但如果一个进程 close(sfd) 将不会影响到其它进程。

端口复用

截屏2023-03-25 10.26.54.png

I/O多路复用

  • I/O 多路复用使得程序能同时监听多个文件描述符,能够提高程序的性能,Linux 下实现 I/O 多路复用的系统调用主要有 select、poll 和 epoll

select

  • 主旨思想:
  1. 首先要构造一个关于文件描述符的列表,将要监听的文件描述符添加到该列表中。
  2. 调用一个系统函数,监听该列表中的文件描述符,直到这些描述符中的一个或者多个进行 I/O 操作时,该函数才返回。
    a.这个函数是阻塞
    b.函数对文件描述符的检测的操作是由内核完成的
  3. 在返回时,它会告诉进程有多少(哪些)描述符要进行I/O操作。
// sizeof(fd_set) = 128B = 1024bit  
#include <sys/time.h>  
#include <sys/types.h>  
#include <unistd.h>  
#include <sys/select.h>  
int select(int nfds, fd_set *readfds, fd_set *writefds,  
fd_set *exceptfds, struct timeval *timeout);  
- 参数:  
   - nfds : 委托内核检测的最大文件描述符的值 + 1  
   - readfds : 要检测的文件描述符的读的集合,委托内核检测哪些文件描述符的读的属性  
       - 一般检测读操作  
       - 对应的是对方发送过来的数据,因为读是被动的接收数据,检测的就是读缓冲  
       区  
       - 是一个传入传出参数  
   - writefds : 要检测的文件描述符的写的集合,委托内核检测哪些文件描述符的写的属性  
       - 委托内核检测写缓冲区是不是还可以写数据(不满的就可以写)  
   - exceptfds : 检测发生异常的文件描述符的集合  
   - timeout : 设置的超时时间  
       struct timeval {  
       long tv_sec; /* seconds */  
       long tv_usec; /* microseconds */  
       };  
       - NULL : 永久阻塞,直到检测到了文件描述符有变化  
       - tv_sec = 0 tv_usec = 0, 不阻塞  
       - tv_sec > 0 tv_usec > 0, 阻塞对应的时间  
- 返回值 :  
   - -1 : 失败  
   - >0(n) : 检测的集合中有n个文件描述符发生了变化  
// 将参数文件描述符fd对应的标志位设置为0  
void FD_CLR(int fd, fd_set *set);  
// 判断fd对应的标志位是0还是1, 返回值 : fd对应的标志位的值,0,返回0; 1,返回1  
int FD_ISSET(int fd, fd_set *set);  
// 将参数文件描述符fd 对应的标志位,设置为1  
void FD_SET(int fd, fd_set *set);
// fd_set一共有1024 bit, 全部初始化为0  
void FD_ZERO(fd_set *set);

poll

epoll

#include <sys/epoll.h>  
// 创建一个新的epoll实例。在内核中创建了一个数据,这个数据中有两个比较重要的数据,一个是需要检测的文件描述符的信息(红黑树);还有一个是就绪列表,存放检测到数据发送改变的文件描述符信息(双向链表)。  
int epoll_create(int size);  
- 参数:  
    size : 目前没有意义了。随便写一个数,必须大于0  
- 返回值:  
    -1 : 失败  
    > 0 : 文件描述符,操作epoll实例的

typedef union epoll_data {  
    void *ptr;  
    int fd;  
    uint32_t u32;  
    uint64_t u64;  
} epoll_data_t;  
struct epoll_event {  
    uint32_t events; /* Epoll events */  
    epoll_data_t data; /* User data variable */  
};  
常见的Epoll检测事件:  
    - EPOLLIN  
    - EPOLLOUT  
    - EPOLLERR  
// 对epoll实例进行管理:添加文件描述符信息,删除信息,修改信息  
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);  
- 参数:  
    - epfd : epoll实例对应的文件描述符  
    - op : 要进行什么操作  
        EPOLL_CTL_ADD: 添加  
        EPOLL_CTL_MOD: 修改  
        EPOLL_CTL_DEL: 删除  
    - fd : 要检测的文件描述符  
    - event : 检测文件描述符什么事情  
// 检测函数  
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int  
timeout);  
- 参数:  
    - epfd : epoll实例对应的文件描述符  
    - events : 传出参数,保存了发送了变化的文件描述符的信息  
    - maxevents : 第二个参数结构体数组的大小  
    - timeout : 阻塞时间  
        - 0 : 不阻塞  
        - -1 : 阻塞,直到检测到fd数据发生变化,解除阻塞  
        - > 0 : 阻塞的时长(毫秒)  
- 返回值:  
    - 成功,返回发送变化的文件描述符的个数 > 0  
    - 失败 -1



Epoll 的工作模式:

LT 模式 (水平触发)

假设委托内核检测读事件 -> 检测fd的读缓冲区
读缓冲区有数据 - > epoll检测到了会给用户通知 

  • a.用户不读数据,数据一直在缓冲区,epoll 会一直通知 
  • b.用户只读了一部分数据,epoll会通知 
  • c.缓冲区的数据读完了,不通知

LT(level - triggered)是缺省的工作方式,并且同时支持 block 和 no-block socket。在这 种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的 fd 进行 IO 操 作。如果你不作任何操作,内核还是会继续通知你的。

ET 模式(边沿触发)

假设委托内核检测读事件 -> 检测fd的读缓冲区
读缓冲区有数据 - > epoll检测到了会给用户通知 

  • a.用户不读数据,数据一致在缓冲区中,epoll下次检测的时候就不通知了 
  • b.用户只读了一部分数据,epoll不通知
  • c.缓冲区的数据读完了,不通知

ET(edge - triggered)是高速工作方式,只支持 no-block socket。在这种模式下,当描述 符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪, 并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述 符不再为就绪状态了。但是请注意,如果一直不对这个 fd 作 IO 操作(从而导致它再次变成 未就绪),内核不会发送更多的通知(only once)。

ET 模式在很大程度上减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。epoll 工作在 ET 模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写 操作把处理多个文件描述符的任务饿死。