标签 网络编程 下的文章

背景:我们的设备上有个链路探测的功能,会定时请求公网的某个IP地址,以探测网络是不是连通的。具体的做法是会使用icmp或dns探测远端服务器,看请求能否正常响应,如果有响应,则认为链路正常,否则则认为不正常,需要采取对应的措施。但是问题的现象是每隔一段时间后,探测包就收不到回复了,导致我们认为线路异常。而实际上网络还是通的,使用系统自带的ping和nslookup工具也是没问题的。

最后抓包分析,怀疑是IP数据包中的identify字段为0导致的,因为不回复的都是为0的id:

因此,我们就打算先把这id改掉试试。本身的实现上,我们使用的是原始套接字来构造icmp和dns请求,没办法控制ip.id。要想修改ip.id,必须让内核放弃自动填充ip头的操作。要想做到这一点,需要用到socket选项中的IP_HDRINCL选项,它的作用就是告诉内核不要填充头部:

val = 1;
setsockopt(sock, IPPROTO_IP, IP_HDRINCL, &val, sizeof(val));

注意事项:

  1. IP报文中,如果校验和设置为0,内核会帮我们自动填充。但在ICMP报文中,内核不会自动填充。
  2. 如果不填写源地址,内核也会自动帮我们填充。

以下是一个自己修改的PING包示例代码:

#include <arpa/inet.h>
#include <netdb.h>
#include <netinet/in.h>
#include <netinet/in_systm.h>
#include <netinet/ip.h>
#include <netinet/ip_icmp.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>

/*
 * 计算校验和的函数
 * @ptr 待计算校验和的数据
 * @nbytes 数据长度
 * @return 返回校验和
 */
unsigned short check_sum(unsigned short *ptr, int nbytes) {
    register long sum;
    unsigned short oddbyte;
    register short answer;

    sum = 0;
    while (nbytes > 1) {
        sum += *ptr++;
        nbytes -= 2;
    }

    if (nbytes == 1) {
        oddbyte = 0;
        *((unsigned char *)&oddbyte) = *(unsigned char *)ptr;
        sum += oddbyte;
    }

    sum = (sum >> 16) + (sum & 0xffff);
    sum = sum + (sum >> 16);
    answer = (short)~sum;

    return (answer);
}

int main(int argc, char *argv[]) {
    int sock, val;
    char buf[1024];
    // IP包头
    struct iphdr *iph = (struct ip *)buf;
    // ICMP包头
    struct icmphdr *icmph = (struct icmphdr *)(iph + 1);

    socklen_t addr_len;
    struct sockaddr_in dst;
    struct sockaddr_in src_addr, dst_addr;

    if (argc < 3) {
        printf("\nUsage: %s <saddress> <dstaddress>\n", argv[0]);
        return 0;
    }

    bzero(buf, sizeof(buf));

    // 创建原始套接字
    if ((sock = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP)) < 0) {
        perror("socket() error");
        /* If something wrong, just exit */
        return -1;
    }

    val = 1;
    // 告诉内核我们自己填充IP头部
    if (setsockopt(sock, IPPROTO_IP, IP_HDRINCL, &val, sizeof(val)) < 0) {
        perror("setsockopt() for IP_HDRINCL error");
        return -1;
    }

    // 填充IP头部
    iph->ihl = 5; // ip头部的长度/4
    iph->version = 4; // 版本信息
    iph->tos = 0;
    iph->tot_len = sizeof(struct iphdr) +
                   sizeof(struct icmphdr);  // 总长度等于ip头部+icmp总长度
    iph->id = htons(4321);
    iph->frag_off = 0;
    iph->ttl = 128;
    iph->protocol = IPPROTO_ICMP;
    iph->check = 0; // 让内核自己去计算校验和
    iph->saddr = inet_addr(argv[1]);
    iph->daddr = inet_addr(argv[2]);
    // check sum
    // iph->check = check_sum((unsigned short *)buf, iph->tot_len);

    dst.sin_addr.s_addr = iph->daddr;
    dst.sin_family = AF_INET;

    // 添加ICMP包头
    icmph->type = ICMP_ECHO;
    icmph->code = 0;
    icmph->checksum = 0;
    icmph->un.echo.id = htons(9987);
    icmph->un.echo.sequence = htons(9988);

    // 首部检验和
    icmph->checksum = check_sum((void *)icmph, sizeof(struct icmphdr));

    addr_len = sizeof(dst);

    // 发数据
    val = sendto(sock, buf, iph->tot_len, 0, (struct sockaddr *)&dst, addr_len);
    if (val < 0) {
        perror("sendto() error\n");
    } else {
        printf("sendto() is OK\n");
    }

    // 收数据
    val = recvfrom(sock, buf + 30, sizeof(buf) - 30, 0, NULL, NULL);
    if (val < 0) {
        perror("recv from error");
    } else {
        printf("recv %d bytes data\n", val);
        iph = (void *)(buf + 30);
        icmph = (struct icmphdr *)(iph + 1);
        printf("icmp type: %d, icmp code = %d, seq = %u, id = %u\n",
               icmph->type, icmph->code, ntohs(icmph->un.echo.sequence),
               ntohs(icmph->un.echo.id));
    }

    // 关闭socket
    close(sock);

    return 0;
};

问题场景:服务器有多个网卡,分别绑定了地址A、地址B和地址C,当客户端连接请求过来的时候,如何知道是从哪个IP地址请求过来的?

解决方案:服务端accept客户端的连接后,对这个新接受的socket进行getsockname就可以了。

示例代码:

fd = accept(listen_fd, (struct sockaddr *)&addr, &addr_len);
if (fd < 0) {
    // xxxx
} else {
    getsockname(fd, (struct sockaddr *)&fd_addr, &addr_len);
    // fd_addr此时就保存了客户端连接过来的目的地址
}

假设客户端地址是1.1.1.1,通过connect(2.2.2.2)过来,此时拿到的就是2.2.2.2这个地址。

拓展

getsockname和getpeername:

  • getsockname:获取当前socket绑定的地址,执行bind后可以获取当前socket的地址信息,执行accept后可以获取对端连入的地址。
  • getpeername:获取对端socket绑定的地址,connect或者accept后可以得到对方的地址。

客户端的socket不需要手动执行bind绑定地址,但这不意味着客户端socket真的不需要绑定端口,实际上是内核它帮我们做了这个操作,在执行connect时,内核发现没有绑定端口,就会自动选择一个合适的端口绑定到socket。

当然这不说明我们不能对客户端socket执行bind操作,对于客户端socket,依旧可以执行bind操作把socket绑定到一个地址。

为什么客户端的socket会自动分配端口呢?

  1. 客户端的socket很多,每产生一个连接,就会创建一个socket,我们无法准确得知有哪些端口没有被使用。
  2. 客户端的socket并不关心自己端口是多少,更多的关注是服务端的端口号,因此客户端socket可以任意指定,只要能够通信即可。

自动分配端口这一操作在内核中的体现在net/inet_connection_sock.c:inet_csk_get_port()

do {
    head = &hashinfo->bhash[inet_bhashfn(net, rover,
            hashinfo->bhash_size)];
    spin_lock(&head->lock);
    // 遍历所有的连接哈希表
    inet_bind_bucket_for_each(tb, node, &head->chain)
        // 端口已经被使用了
        if (ib_net(tb) == net && tb->port == rover) {
            // 判断是否开启了端口快速回收以及端口重用
            if (((tb->fastreuse > 0 &&
                sk->sk_reuse &&
                sk->sk_state != TCP_LISTEN) || 
                (tb->fastreuseport > 0 &&
                  sk->sk_reuseport &&
                  tb->fastuid == uid))&&
                (tb->num_owners < smallest_size || smallest_size == -1)) {
                // 处理端口重用逻辑
                smallest_size = tb->num_owners;
                smallest_rover = rover;
                if (atomic_read(&hashinfo->bsockets) > (high - low) + 1) {
                    spin_unlock(&head->lock);
                    snum = smallest_rover;
                    // 找到了一个合适的端口
                    goto have_snum;
                }
            }
            goto next;
        }
    break;
next:
    // 继续往下遍历端口号,直到找到一个可用的端口
    spin_unlock(&head->lock);
    if (++rover > high)
        rover = low;
} while (--remaining > 0);

就像上面所描述的,大多数场景,我们无法确切知道当前环境还有哪些端口可用,因此也无需绑定地址到socket。但是对于特定场景,还是需要给socket手动绑定地址。如代理程序对UDP代理时,要先在本地创建一个socket作为客户端发送数据的隧道,这个时候就要先指定好端口,然后再返回给客户端。

那么现在问题就来了,我怎么知道需要绑定哪个端口呢?上面也说了,我不知道有哪些可用的端口。同时,因为也没有执行connect,此时也没有得到一个随机的端口,这种场景应该怎么处理?可以采取的做法是:给socket绑定一个0端口,让系统随机分配一个。

代码中赋值add.sin_port = 0,就是告诉内核,我需要一个可用的随机的端口,请给我分配一个。然后内核就会分配了。

执行完后,再通过getsockname函数就可以得到系统分配的端口号了。示例代码:

int main() {
    int sock_fd;
    struct sockaddr_in addr;
    socklen_t socklen;

    sock_fd = socket(AF_INET, SOCK_STREAM, 0);

    addr.sin_family = AF_INET;
    addr.sin_port = 0; // 绑定随机端口
    addr.sin_addr.s_addr = INADDR_ANY;

    socklen = sizeof(addr);
    if (bind(sock_fd, (struct sockaddr *)&addr, socklen)) {
        perror("bind error");
        return -1;
    }

    // 获取系统随机分配的端口号
    getsockname(sock_fd, (void *)&addr, &socklen);
    printf("%u\n", ntohs(addr.sin_port));
    return 0;
}

一、I/O模型分类

unix环境下有5中IO模型:

  • 阻塞式I/O
  • 非阻塞式I/O
  • I/O多路复用
  • 信号驱动I/O
  • 异步I/O(POSIX中的aio_系列函数)

常用的是前三种方式,特别是多路I/O复用是目前使用最广泛的I/O模型。它不仅包含了阻塞和非阻塞,同时也包含了异步调用。非阻塞+异步是效率最高的I/O方式。

二、阻塞式I/O

阻塞I/O的意思是:调用读写函数时,系统会卡在当前函数,直到有数据可读或者可写才返回。

工作流程图:

三、非阻塞式I/O

非阻塞I/O的意思是:调用函数时如果没有数据可读,立马返回。然后开始轮询,直到有数据返回为止。

工作流程图:

四、多路I/O复用

多路I/O复用:通过多路IO复用模型(select/poll/epoll)同时监听多个套接字,等待某个套接字有数据到达时再执行系统调用。

工作流程图:

epoll中的触发模式有两种,边缘触发和水平触发,默认情况下使用的是水平触发。

边缘触发(ET)的意思是当电平出现变化的时候才触发事件,如果设置了边缘触发,执行epoll_wait时,内核检测到数据到达后立马返回到应用层。但是这仅仅只返回这一次,如果缓冲区中的数据没有读取完,再次执行epoll_wait时不会继续触发,需要下一次来数据了才能触发。也就是说,一次数据不会重复发送到应用层,不管你是否读完了。

而水平触发(LT)的意思是只要存在高电平就一直触发事件,执行epoll_wait时,只要检测到有数据就返回。如果缓冲区中存在没有读完的数据,下一次执行epoll_wait还会继续触发事件,无需等到下一次数据来。

两者的触发时间点:

相比之下,ET的效率高于LT模式,因为产生的事件数更少,可以减少内核往应用层空间复制数据的次数。在进行高性能网络编程的时候,往往都是选择非阻塞IO+ET触发模式,这种模式下可以做到想读数据的时候就读,不想读就不读。同时,读不到也不阻塞,大大增加了程序的灵活性。而不是说不管是否想读数据,都强制要求读。

项目中使用ET模式时遇到了一个问题:我们开发了一个socks5代理程序,可以用来代理上网。正常的程序代理都没有问题,就是网页中的实时视频使用代理会出现延时,延时能达到5-10秒。最后查了很久之后发现是ET触发模式导致的, 发送数据的时候内核不会立马发出去,改成LT模式之后就好了。很神奇!