Linux网络编程

发布时间 2023-07-31 12:14:18作者: 白日梦想家-c

1 Socket

在linux网络编程中我们主要使用套接字Socke进行不同主机上进程间的通信,该套接字提供了透明传输接口使得我们不需要根据协议栈进行手动封装数据包,我们不必在意协议栈上下层之间的具体服务,而是仅需调用提供的api即可

套接字通信的一般流程为:

  • 创建套接字:在应用程序中使用网络库创建一个套接字,并指定通信类型和协议(TCP 或 UDP)。
  • 绑定地址:对于服务器端,需要将套接字与一个特定的 IP 地址和端口号绑定。对于客户端,通常不需要绑定地址,而是在连接服务器时动态分配一个本地端口号。
  • 监听(仅服务器端):服务器端套接字需要开始监听来自客户端的连接请求。
  • 连接(仅客户端):客户端套接字需要连接到服务器端的套接字。
  • 发送和接收数据:通过套接字发送和接收数据。

2 字节序

在不同计算机上数据的存储方式不同,主要分为两种:

  • 大端存储:低字节放在高地址处,高字节放在低地址处
  • 小端存储:低字节放在低地址处,高字节放在高地址处

由于不同的机器存储方式不同,读取地址方式也不同,因此socke规定所有发送的数据要转成大端的格式来传送,并且提供了封装好的接口

#include <arpa/inet.h>
/*
h:host,表示主机
to
n:net,表示网络
l:表示long,一般用于转换ip地址
s:表示short,一般用于转换端口号
*/
转端口
uint32_t htonl(uint32_t hostlong);//主机字节序->网络字节序 
uint32_t ntohl(uint32_t netlong);//网络字节序->主机对应字节序
转ip
uint16_t htons(uint16_t hostshort);
uint16_t ntohs(uint16_t netshort);

使用示例:

#include <stdio.h>
#include <arpa/inet.h>

int main()
{
    //转端口
    unsigned short a = 0x0102;
    unsigned short b = htons(a);
    printf("%x\n", a);
    printf("%x\n", b);
    printf("=======================\n");
    //转ip
    char buf[4] = {192,168,4,3};
    //char在内存中占4个字节,将其首地址转换为int类型指针,再按int类型取值,
    //由于int类型占4个字节,取出来以后正好是我们要的数
    int num = *(int *)buf;
    int res = htonl(num);
    unsigned char *p = (char *)&res; 
    printf("%d %d %d %d", *p, *(p+1), *(p+2), *(p+3));

    return 0;
}

3 Socket地址

客户端在访问服务端的时候至少要知道ip和端口号,而Socket地址就是一种结构体,用来封装端口号和ip信息,方便我们使用,这里主要介绍存放ipv4地址的结构体

struct sockaddr_in {
    sa_family_t sin_family; // 其值用来表示套接字地址的协议族,通常为 AF_INET(IPv4)
    in_port_t sin_port;     // 16位端口号
    struct in_addr sin_addr;// 这个结构体里只有一个int类型的成员用于放32位ip地址
    char sin_zero[8];       // 用于填充,通常设置为0
};

typedef unsigned int sa_family_t;
typedef uint16_t in_port_t;
typedef unsigned short uint16_t;
typedef unsigned int uint32_t;

4 ip地址转换函数

示例:

#include <stdio.h>
#include <arpa/inet.h>

int main()
{
    //点分十进制转网络序
    char buf[] = "192.168.1.4";
    unsigned int num = 0;
    inet_pton(AF_INET, buf, &num);
    unsigned char *p = (unsigned char *)&num;
    printf("%d %d %d %d\n", *p, *(p+1), *(p+2), *(p+3));
    
    //网络序转点分十进制
    char ip[16] = "";
    const char *str = inet_ntop(AF_INET, &num, ip, sizeof(ip));
    printf("str:%s\n", str);
    return 0;
}

5 TCP通信流程

服务端:
1.创建一个专门用于监听的套接字

  • 监听:监听有无客户端的链接
  • 套接字:实际上就是一个文件描述符

2.将这个监听文件的文件描述符与本地的IP和端口用bind绑定(ip和端口就是服务器的地址信息)
3.设置监听listen,监听的fd开始工作,实际上就是关注读缓冲区的写入情况
4.receive可以阻塞等待,直到有客户端发起连接,接收连接,并再产生一个通信的套接字。该套接字fd负责和客户端通信,它的读缓冲区用于接收消息,是不同于监听fd的
5.通信
6.通信结束,断开连接
 
客户端:
1.创建一个用于通信的套接字(fd)
2.连接服务器,指定要连接的服务器的ip和端口
3.连接成功后,开始通信
4.通信结束,断开连接

6 Socket函数

可以用man 2 函数名来查看函数的具体信息

#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
            如果前面用STREAM,这里默认为tcp
            如果前面用DGRAM,这里默认为udp
    - 返回值:
            成功:返回一个文件描述符
            失败:-1
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen)
- 功能:将fd与本地ip和端口进行绑定
- 参数:
    - socked: 通过socket得到的文件描述符
    - addr:需要绑定的socket地址,这个地址封装了ip和端口号信息
    - addrlen:第二个参数结构体占的内存大小
- 返回:成功返回0,失败返回-1

int listen(int sockfd, int backlog);
- 功能:监听这个socket的连接
- 参数:
    - sockfd:socket创建的文件描述符
    - backlog:表示允许请求连接的最大数量,包括已连接和未连接的数量
              一般设置为5就可以了,它在底层维护两个队列,连接成功和请求连接,
              连接成功的会很快就被取走进行通信使用的,但不要超过4096
- 返回值:成功返回0,失败返回-1

int accept(int sockfd, struct sockaddr *addr, socklen_t addrlen)
- 功能:接收客户端连接,默认为一个阻塞函数,阻塞等待客户端的连接
- 参数:
    - sockfd:用于监听的文件描述符,要从它的读缓冲区读出客户端信息
    - addr:传出参数,记录连接成功后客户端的地址信息(ip+端口号)
    - sddrlen:指定第二个参数对应的内存大小
- 返回:成功就返回一个用于通信的文件描述符fd,失败返回-1

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen)
- 功能: 客户端连接服务器
- 参数:
    - sockfd:用于通信的文件描述符
    - addr:客户端的要连接的服务器地址信息
    - addrlen:第二个参数的地址大小

ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);

7 TCP三次握手

三次握手发生在客户端连接的时候,当调用connect时,底层会通过TCP进行三次握手,用于确保收发双方的通信建立成功。主要是为了确保通信双方均具有收发能力,且时许不会乱

第一次握手:

  • 客户端将同步标志位置1
  • 生成一个碎纸机的32位序号seq=J

第二次握手:

  • 服务器端接收客户端的连接,确认位置1,ACK=1
  • 返回一个确认序号:ack=客户端序号J+SYN/FIN(按一个字节算)
  • 服务端向客户端发起连接请求:SYN置1
  • 自己的序号为K

第三次握手

  • 客户端确认服务器的连接请求:ACK = 1;
  • 客户端回复收到了服务器端的数据:确认序号ack=服务端序号+SYN/FIN(按一个字节算)

8 四次挥手

发生在close的时候,用于释放连接,否则会继续占用资源。双方都可以主动断开,这取决于谁的close先执行

以下是四次挥手的过程:

  • 第一次挥手(FIN-1):
    主动关闭方(通常是客户端)发送一个FIN(关闭连接)报文段给被动关闭方(通常是服务器)。这样,主动关闭方就不再发送数据,但仍可以接收数据。

  • 第二次挥手(ACK-1):
    被动关闭方收到了FIN报文段后,会对其进行确认,发送一个ACK(确认)报文段给主动关闭方,表示已经收到了关闭请求。

  • 第三次挥手(FIN-2):
    被动关闭方也希望关闭连接,因此会发送一个自己的FIN报文段给主动关闭方。

  • 第四次挥手(ACK-2):
    主动关闭方收到被动关闭方的FIN后,会发送一个确认报文段ACK给被动关闭方。此时,主动关闭方必须等待一段时间(称为TIME_WAIT状态),以确保被动关闭方收到了最后的确认。这个等待时间是为了处理网络中可能延迟的报文,防止被动关闭方收到错误的重复报文。

要注意,TCP是全双工的通信模式,要先后关闭两个通道,每一次都有请求关闭和确认关闭的操作,当发送FIN报文时,发送方就不再能够主动发送数据了,但是应答还是可以的。

9 端口复用

通常的用途为:

  • 防止服务器重启时之前绑定的端口还未释放
  • 程序突然退出而系统没有释放端口

在四次挥手的断开连接操作中,最后一次握手报文会等待2MSL的时间以确保它的确认断开报文可以到达,在Linux中这个时间约为1min,因此我们若想要断开后立即重启服务器时,服务器端很有可能在这个2MSL中,那么它会报错无法绑定端口。

#include <sys/types.h>
#include <sys/socket.h>
//设置套接字属性(不仅仅能设置端口复用)
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
- 参数:
    sockfd: 要操作的文件描述符
    level: 级别,SOL_SOCKET
    optname: 选项名称
          - SO_REUSEADDR
          - SO_RESEPORT
    optval:端口复用的值(整形)
          - 1:可以复用
          - 0:不可以复用
    optlen:optval参数的大小

需要注意的是,端口复用发生在绑定端口之前,即bind之前

10 I/O多路复用技术

首先明确什么是I/O,其并不是键盘输入和显示屏的输出,而是文件向内存中写入为输入,内存向文件中写出为输出。

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

正常情况下,我们想要知道哪些文件发送过来了数据,就只能去遍历每一个用于通信的文件描述符,查看哪些的读缓冲区里有数据,而多路复用技术可以自动的帮我们实现监听这些文件描述符

几种I/O模型:

  • 阻塞模型(BIO)
    read在没有数据写入读缓冲区时是阻塞在那里等待输入的,直到有数据输入,这种模型的好处是不占用CPU时间,坏处是程序效率低。可以用多线程或者多进程来处理,但资源会占用,且开销大。

  • 非阻塞,忙轮询(NIO)
    需要我们不断的循环去看有没有数据的到来
    优点:提高了程序执行效率,程序可以去干别的事情
    缺点;CPU和系统资源有浪费,每次几乎要进行O(n)的调用。

  • I/O多路转接技术(select/poll)
    select/poll相当于一个带接收站点,它接收我们指定的数据,再根据需要返回
    优点:这个操作是由内核执行的,速度很快
    缺点:它只告诉我们有多少个读缓冲区有数据到达,但我们并不知道是哪几个文件描述符,还需要去遍历,select/poll底层用一串二进制掩码来表示这些文件描述符,当置1时就表示对应的文件描述符有数据读入

  • epoll
    epoll相对于select/poll就很人性化,体现在它会告诉我们有哪些文件描述符读到了数据

而I/O多路复用也就明白了,原来的时候有多个文件描述符,我们需要一个一个的去检测有无数据读入,而现在只需要委托内核去获取并返回信息即可,即只查询一次就行

11 select

1.首先构造一个文件描述符的列表,将要监听的文件描述符添加到该列表
2.调用一个系统函数来监听,直到有一个或多个进行I/O操作时,再返回

  • 函数是阻塞执行的
  • 对文件描述符的监测操作由内核完成
#include <sys/time.h>
#include <sys/type.h>
#include <unistd.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
          fd_set *exceptfds, struct timeval *timeout);
- 参数:
    - nfds:委托内核检测的最大文件数+1;
    - readfds:要检测的文件描述符的读集合,委托内核检测哪些文件描述符的读属性
            - fd_set是一个128字节大小的数组类型,即1024位,我们利用函数将文件   
              描述符对应的位置置1,表示要检测的,检测后返回数据也在这个列表中,
              置1的表示有读入,否则为0.
    - writefds:要检测的文件描述符写集合,这里不同于读检测,读检测是检测有无
                数据,写检测是检测缓冲区是否还有空位能够写入。这个一般不用。
    - excefds:检测异常的文件描述符的集合。一般不用
    - timeout:设置的超时时间,因为我们在检测的时候是阻塞进行的,如果设置为
               Null就是永久阻塞,直到检测到文件描述符有变化,有一个专用结构体
               struct timeval{
                    long tv_sec    //秒
                    long tv_usec   //毫秒
               };
               都设置为0时就不阻塞
- 返回值:-1表示调用失败,大于0的值表示检测中有n个描述符发生了变化

//将fd对应的标志位设为0
void FD_CLR(int fd,fd_set *set);
//判断fd对应的标志位为0还是1
int FD_ISSET(int fd, fd_set *set);
//将参数的文件描述符fd对应的标志位设为1;
void FD_SET(int fd, fd_set *set);
//全部初始化为0
void FD_ZERO(fd_set *set);

select的缺点:

  • 由于监听工作是交给内核进行的,因此每次需要将文件描述符表拷贝到内核中,监听结束后再拷贝回来,一来一往实际造成了资源浪费和效率低
  • fds数量大时,内核遍历操作很浪费时间
  • fds集合不能重用,每次需要定义另外一个相同的集合往里传
  • fds数组只有1024

12 poll

相比于select,它的可监听文件描述符数量没有限制,且改进了数组不可重用性

#include <poll.h>
struct pollfd{
  int fd;
  short events;
  short revents;
};
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
- 参数:
    fds:是一个struct pollfd的结构体数组,这是一个需要检测的文件描述符的集合
    nfds:第一个参数数组中最后一个有效元素的下标+1
    timeout:阻塞时长
          0:不阻塞
          -1,阻塞
          >0:阻塞时长
- 返回值:-1表示失败,n表示有n个文件描述符发生了变化

13 epoll

它直接在用户态中开辟一片区域来存放我们的监听列表,没有了从用户态到内核态的拷贝过程,返回的时候虽然有拷贝,但是它只返回发生变化的文件描述符,开销小。其次它采用红黑树存放监听的文件描述符,遍历效率高。

多路复用技术还意味着我们不再需要多线程去监听了

#include <sys/epoll.h>
//创建一个新的epoll实例,在内核中创建了一个数数据(结构体),包含一个存放要检
测的文件描述符结构体(红黑树实现),一个存放发生改变的fd的结构体(双向链表实现)
int epoll_create(int size);
- 参数:没啥意义,随便给一个值好了,只要大于0就行
- 返回值,失败返回-1,成功返回一个文件描述符,用于操作这个epoll实例,有了它才
         能在内核中找到这个epoll实例

//对epoll实例进行管理:添加文件描述符信息,删除,修改
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
- 参数:
    epfd:epoll实例对应的文件描述符
    op:要进行的操作
        - EPOLL_CTR_ADD
        - EPOLL_CTR_MOD
        - EPOLL_CTR_DEL
    fd:要检测的文件描述符
    event:检测的文件描述符是读还是写
struct epoll_event{
    uint32_t  events;
    epoll_data_t data;

};
常见的epoll检测事件:
      - EPOLLIN
      - EPOLLOUT
      - EPOLLET //表示边沿触发模式,默认是水平触发
      如果要读写同时监听,events的值就应该是EPOLLIN | EPOLLOUT
typedef union epoll_data{
    void  *ptr;
    int  fd;//一般只用这个,用来存要检测的文件描述符
    uint32_  u32;
    uint64_t  u64;
} epoll_data_t;

//检测函数
int epoll_wait(int epfd,struct epoll_event *events, int maxevents,
               int timeout)
- 参数:
    epfd:epoll实例对应的文件描述符
    events:传出参数,保存发生变化的文件描述符的信息
    maxevents:第二个参数结构体数组的最大值
    timeout:阻塞时间
- 返回值:
    成功:返回发生变化的文件描述符个数
    失败:返回-1

14 epoll的工作模式

LT模式下(水平触发)

  • 缓冲区中只要有数据就会不断通过epoll进行通知,知道缓冲区数据没有了
  • 有阻塞和非阻塞两种工作模式

ET模式(边沿触发)

  • 缓冲区中只有在新读入数据的时候才会通知一次,只能以while循环的方式去读数据,因此它的模式是非阻塞的,所使用的API也应该是非阻塞的,读完了数据以后直接跳出,否则在读完就会一直阻塞下去
  • ET模式减少了epoll事件被重复触发的次数,因此效率比LT模式要高。epoll工作在ET模式的时候,必须使用非阻塞接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死
  • 使用时主要是在建立连接之后,对要添加的fd设置上EPOLLET