美文网首页
从源码角度剖析tcp/ip---ip协议(1)

从源码角度剖析tcp/ip---ip协议(1)

作者: 小胖子善轩 | 来源:发表于2017-05-19 03:11 被阅读0次

    庖丁解牛,从源码角度来深入tcp/ip。----《TCP/IP详解 卷2:实现》。

    一、简介和介绍

    ip协议的是tcp和udp的根本,一般我们只需要了解子网划分,路由转发机制即可。但是底层是怎么实现的呢?毫无疑问,一个顶级的服务端工程师应该从根本从把知识点剖析清楚。下面就让我们从源码的角度解剖ip协议。(关于netinet中的函数,现在的linux代码已经重构过了,所以采用4.4BSD版本的代码。)
    (我找了很久)Github地址:https://github.com/neilss/4.4BSD-Lite

    image.png

    《TCP/IP详解 卷2:实现》在第四章中阐述了一个数据包通过网络接口发生硬件中断时,数据包就会放进iprinrq队列中,如上图。而文章主要讲述的是在路由器中,ip层的函数是如何实现的,大体的组织形式如下。

    image.png

    如果是路由器,当分组来到ipintrq并且发生软件中断时

    1. iprintrq中的分组数据就会传递到ipintr函数让其进行分组验证,转发等等的操作。
    2. ip_forward会根据ip分段和路由表来定位下一条。
    3. 最后在ip_ouput就是构造首部,选择路由和分片。

    如果是主机的话,数据来到iprintr的时候就会包ip报文传送都网络层。熟悉osi7层结构的应该很清楚,这里就不再阐述了。


    一、iprintr

    我们可以知道当软中断发生的时候,内核就会调用iprintr把数据包从队列中获取出来。这个函数比较复杂。首先我们先看一下iprintr函数的开头,代码如下。

    void
    ipintr()
    {
        register struct ip *ip;
        register struct mbuf *m;
        register struct ipq *fp;
        register struct in_ifaddr *ia;
        int hlen, s;
    
    next:
        /*
         * Get next datagram off input queue and get IP header
         * in first mbuf.
         */
        s = splimp();
        IF_DEQUEUE(&ipintrq, m);
        splx(s);
        if (m == 0)
            return;
    ...
    

    我们可以看到iprintr调用IF_DEQUEUE从队列中获取分组数据,iprintr从iprintrq中移走分组,并对以处理直到整个队列为空为止。然后接下来就是分别对分组进行验证,选项处理和转发,重装和分用。

    1.分组验证

    把分组从ipintrq中取出,验证它们对内容之后。损坏可有差错的分组会被自动丢弃。
    1.1. 验证ip版本

        if (in_ifaddr == NULL)
            goto bad;
        ipstat.ips_total++;
        if (m->m_len < sizeof (struct ip) &&
            (m = m_pullup(m, sizeof (struct ip))) == 0) {
            ipstat.ips_toosmall++;
            goto next;
        }
        ip = mtod(m, struct ip *);
        if (ip->ip_v != IPVERSION) {
            ipstat.ips_badvers++;
            goto bad;
        }
    
    

    当网关接口没有配置好的时候,ip地址会为空。所以分组来到这里的时候就会被中断,跳到bad。可以看到在4.4的BSD实现中,ip版本必须是ipv4的。不过现在已经支持ipv6了。

    1.2. IP校验和

        if (ip->ip_sum = in_cksum(m, hlen)) {
            ipstat.ips_badsum++;
            goto bad;
        }
    

    一个完整的IP数据包必须要有完整的校验和,我们可以看到内核已经封装了一个in_cksum来进行校验。
    校验和检验是一个很耗时的操作,关于这个in_cksum函数的实现其实有很多优化的地方。这里有很多论文才研究这个算法。这里就不展开了。

    1.3. 字节顺序

    个人感觉底层最无趣,也是最麻烦的地方就是字节问题。因为操蛋的主机有大端跟小端之分,网络字节顺序跟主机字节顺序不一致的问题贼麻烦。不过操作系统已经帮我们解决了,感谢stevens,以及各个位linux贡献的大神们。

        NTOHS(ip->ip_len);
        if (ip->ip_len < hlen) {
            ipstat.ips_badlen++;
            goto bad;
        }
        NTOHS(ip->ip_id);
        NTOHS(ip->ip_off);
    
    

    首先把几个16bit的值先转为主机顺序,内核封装了一个宏NTOHS来进行转换。如果首部长度不满足要求,那么就会跳到bad分支。

    1.4 分组长度

        if (m->m_pkthdr.len < ip->ip_len) {
            ipstat.ips_tooshort++;
            goto bad;
        }
        if (m->m_pkthdr.len > ip->ip_len) {
            if (m->m_len == m->m_pkthdr.len) {
                m->m_len = ip->ip_len;
                m->m_pkthdr.len = ip->ip_len;
            } else
                m_adj(m, ip->ip_len - m->m_pkthdr.len);
        }
    

    分组的长度是由链路的最小mtu决定,所以是有可能出现分组逻辑长度大于mbuf的数据量的(mbuf是tcp/ip底层存储数据的数据结构,参考《TCP/IP 详解卷2 实现》第一章)

    2.选项处理与转发

    选项处理实在是太过复杂,这里是介绍不了这么多的。IP数据包有40个字节存储选项,仅仅是RFC定义的IP选项就有8个。我实在是没有精力去看这里了,除非我要做协议栈。至于转发就比较好理解,就是根据根据Internet地址表,决定是否有分组目的地匹配的地址。

        /*
         * Check our list of addresses, to see if the packet is for us.
         */
        for (ia = in_ifaddr; ia; ia = ia->ia_next) {
    #define satosin(sa) ((struct sockaddr_in *)(sa))
    
            if (IA_SIN(ia)->sin_addr.s_addr == ip->ip_dst.s_addr)
                goto ours;
            if (
    #ifdef  DIRECTED_BROADCAST
                ia->ia_ifp == m->m_pkthdr.rcvif &&
    #endif
                (ia->ia_ifp->if_flags & IFF_BROADCAST)) {
                u_long t;
    
                if (satosin(&ia->ia_broadaddr)->sin_addr.s_addr ==
                    ip->ip_dst.s_addr)
                    goto ours;
                if (ip->ip_dst.s_addr == ia->ia_netbroadcast.s_addr)
                    goto ours;
                t = ntohl(ip->ip_dst.s_addr);
                if (t == ia->ia_subnet)
                    goto ours;
                if (t == ia->ia_net)
                    goto ours;
            }
        }
        if (IN_MULTICAST(ntohl(ip->ip_dst.s_addr))) {
            struct in_multi *inm;
    #ifdef MROUTING
            extern struct socket *ip_mrouter;
            if (ip_mrouter) {
                ip->ip_id = htons(ip->ip_id);
                if (ip_mforward(m, m->m_pkthdr.rcvif) != 0) {
                    ipstat.ips_cantforward++;
                    m_freem(m);
                    goto next;
                }
                ip->ip_id = ntohs(ip->ip_id);
                if (ip->ip_p == IPPROTO_IGMP)
                    goto ours;
                ipstat.ips_forward++;
            }
    #endif
    
            IN_LOOKUP_MULTI(ip->ip_dst, m->m_pkthdr.rcvif, inm);
            if (inm == NULL) {
                ipstat.ips_cantforward++;
                m_freem(m);
                goto next;
            }
            goto ours;
        }
    

    当然了,这里选取下一跳的代码在卷1中是有比较详细的说明的。

    2.重装代码

    我们可以知道,从网关得到的数据包是已经被分片了的。所以iprintr函数最后是需要把代码重装的。要理解如何重装,首先要理解分片后的ip数据包。如下图。

    感觉说再多也不够上图直观,IP分片就是把原来的IP报文分割成若干个更小的IP报文,但是每一个小报文需要重新添加IP首部。然而要对分片重装远比对IP分片复杂得多。再下一篇文章再总结。

    回到第一个函数,iprintr函数主要是做验证,处理,重装和分用等等功能。其中转发和重装逻辑很复杂,日后再详细总结。


    二、ip_forward函数

    这个函数主要是用来对重装后的代码进行重装,不过我不知道为啥在iprintr函数中还要查一遍地址表。=。=#。这个函数主要有三个用途:
    1)判断分组转发的合法性
    2)减少TTL
    3)定位下一跳

    void
    ip_forward(m, srcrt)
        struct mbuf *m;
        int srcrt;
    {
        register struct ip *ip = mtod(m, struct ip *);
        register struct sockaddr_in *sin;
        register struct rtentry *rt;
        int error, type = 0, code;
        struct mbuf *mcopy;
        n_long dest;
        struct ifnet *destifp;
    
        dest = 0;
    #ifdef DIAGNOSTIC
        if (ipprintfs)
            printf("forward: src %x dst %x ttl %x\n", ip->ip_src,
                ip->ip_dst, ip->ip_ttl);
    #endif
        if (m->m_flags & M_BCAST || in_canforward(ip->ip_dst) == 0) {
            ipstat.ips_cantforward++;
            m_freem(m);
            return;
        }
        HTONS(ip->ip_id);
        if (ip->ip_ttl <= IPTTLDEC) {
            icmp_error(m, ICMP_TIMXCEED, ICMP_TIMXCEED_INTRANS, dest, 0);
            return;
        }
        ip->ip_ttl -= IPTTLDEC;
    
    

    第一个用途不用解释了,简而言之就是对“链路层广播,环回广播或者其他寻址查询参数是否正确“。
    至于第二点,我们看下面代码。

        if (ip->ip_ttl <= IPTTLDEC) {
            icmp_error(m, ICMP_TIMXCEED, ICMP_TIMXCEED_INTRANS, dest, 0);
            return;
        }
        ip->ip_ttl -= IPTTLDEC;
    

    系统是不接受TTL为0的数据包的,因为每一跳都约定了TTL要减少至少1s,所以实现中就减少IPTTLDEC(宏为1)。如果小于1,那么就向源地址发送ICMP超时报文。
    第三点,定位下一跳。
    我们看下面代码。

        sin = (struct sockaddr_in *)&ipforward_rt.ro_dst;
        if ((rt = ipforward_rt.ro_rt) == 0 ||
            ip->ip_dst.s_addr != sin->sin_addr.s_addr) {
            if (ipforward_rt.ro_rt) {
                RTFREE(ipforward_rt.ro_rt);
                ipforward_rt.ro_rt = 0;
            }
            sin->sin_family = AF_INET;
            sin->sin_len = sizeof(*sin);
            sin->sin_addr = ip->ip_dst;
    
            rtalloc(&ipforward_rt);
            if (ipforward_rt.ro_rt == 0) {
                icmp_error(m, ICMP_UNREACH, ICMP_UNREACH_HOST, dest, 0);
                return;
            }
            rt = ipforward_rt.ro_rt;
        }
    

    上面那段代码是检查是否是需要发送重定向报文。出现重定向的原因是上一台主机的路由表太久了,产生了错误的转发。接下来就是选择合适路由器来发送差错报文。

    三、ip_output(略)


    四、总结

    ip协议的处理中,首先是ipintr函数对分组报文进行验证和重装;然后ip_forward对数据包进行转发和定位;最后ip_output对数据包进行分片发送。

    源码我花了不少时间去找,最后意识到源码是4.4BSD标准的,跟linux版本无关。本来想说清楚的,但是限于篇幅和自己的理解问题,比较难展开。个人感觉要理解tcp/ip,卷1就可以了。但是要深入tcp/ip,要把socket用好,用透,还是需要从源码上去阅读和理解。


    如果说C++是一个骄傲的信仰,那么就让我执着一次自己的信仰吧。

    image.png

    相关文章

      网友评论

          本文标题:从源码角度剖析tcp/ip---ip协议(1)

          本文链接:https://www.haomeiwen.com/subject/rmxjxxtx.html