网络协议栈(4)路由及网关选择

一、路由选择
   路由选择是IP层最为重要也是最为基本的一个功能,可以说是因特网实现报文交通的基础,所以这个东西还是比较重要的,大部分的网络设备供应商都会提供自己的路由器产品。当然我这里就无缘领教这些东西了,只是从Linux内核的协议栈中看一下一个PC的简单路由功能及实现方法。
   之前说过当一个TCP客户端执行connect系统调用的时候,可能需要内核帮助为这个报文选择一个本地网络地址、端口号以及初始序列号。当时对于本地网络地址的选择并没有详细的追究本地的网络地址是怎么来选择的。当然这个选择的前提是系统中有多个网络设备,如果系统中只有一个网络设备,那你想选也没得选,倒也省事。现在假设一个系统中有多个网卡,它们可能连接进入了不同的网路,比如一个内网的192.168地址,一个内网的10.9地址,还有一些WAN地址,此时发送一个报文的时候就涉及到了本地端口的选择了,另外还有一个需要考虑的东西就是网关。因为一个系统通过网口发送的时候需要确定接受者的MAC地址,这MAC地址可以通过IPV4的ARP协议来得到,只要你给出网关的IP地址。但是对于IP层来说,它只能提供网关的IP地址,并且需要保证这个IP地址是和主机的发送网口在同一个广播域中,也就是说,当网口发送以太网广播报文的时候,这个IP的主机能够收到这个报文从而进行回应并答复自己的MAC,这样这个端口中就可以把一个以太网报文的目的MAC填充好并发送出去。
    这个网关地址在内核中有时候也称为下一条地址(nexthop),ARP协议唯一需要的就是这个地址(此时本地网口已经确定),它是一个IP地址,ARP协议可以通过广播来获得这个IP地址对应的MAC地址。然后设备将以太网报文目的MAC地址填充为ARP从IP地址转换出来的MAC地址,然后将这个报文发送出去。至于这个下一条是如何获得的,那同样是网络层路由的工作了。
二、路由的内容及由来
路由的由来主要有两种,一种是当用户为一个网络设备添加一个网络地址的时候由内核代劳添加的,另一个就是用户主动通过route(或者ip route add 之类的命令)添加的,对于一个系统,最为简单的可以通过
[tsecer@Harry ~]$ cat /proc/net/route 
Iface    Destination    Gateway     Flags    RefCnt    Use    Metric    MaskMTU    Window    IRTT                                                       
br0    00CBA8C0    00000000    0001    0    0    0    00FFFFFF    0    0    0                                                                                
eth0    00CBA8C0    00000000    0001    0    0    1    00FFFFFF    0    0    0                                                                               
eth0    00000000    02CBA8C0    0003    0    0    0    00000000    0    0    0                                                                               
来查看系统的路由配置,当然这个结果比较原始,对于结合内核源代码看这个问题比较有帮助,可读性更好的是通过route或者ip route show之类的命令来获得可读性更好的输出。这些变量和内核中数据结构的对应关系在linux-2.6.21\net\ipv4\fib_hash.c:fib_seq_show()函数中找到。从这个表中可以看到,其中不可缺少的就是目的地址和本地的网卡设备,而其中的网关大部分为零,而最后一个目的地址为零的就是路由表中的默认路由了。
1、网络设备配置地址之后自动添加路由
这个又分为两个,一个是当网络设备启动之后进行一次检测,一个是为网口设备添加新的IP地址的时候进行一次检测。在linux-2.6.21\net\ipv4\fib_frontend.c:ip_fib_init函数中,其中注册了两个事件回调(事件关注)函数,
    register_netdevice_notifier(&fib_netdev_notifier);
    register_inetaddr_notifier(&fib_inetaddr_notifier);
一个是网络设备操作事件,一个是网络地址操作事件。前者的典型事件为添加或者删除一个地址,后者则是网络设备的使能和关闭。现在看的是网口启动时候添加的路由内容。当一个网络设备使能之后,它调用
fib_netdev_event--->>>fib_add_ifaddr
 fib_magic(RTM_NEWROUTE, RTN_LOCAL, addr, 32, prim);
……
 fib_magic(RTM_NEWROUTE, dev->flags&IFF_LOOPBACK ? RTN_LOCAL :RTN_UNICAST, prefix, ifa->ifa_prefixlen, prim);
 fib_magic(RTM_NEWROUTE, RTN_BROADCAST, prefix, 32, prim);
fib_magic(RTM_NEWROUTE, RTN_BROADCAST, prefix|~mask, 32, prim);
这里可以看到,如果网络设备启动之后,这里会添加大量的内容到路由表中(其中的每个fib_magic都是一个路由表项添加操作),但是我们通过proc/net/route文件只能看到很少一部分内容,并且没有其中添加的完整本地地址路由。
在fib_magic函数中有下面的代码
    if (type == RTN_UNICAST)
        tb = fib_new_table(RT_TABLE_MAIN);
    else
        tb = fib_new_table(RT_TABLE_LOCAL);
……
static inline struct fib_table *fib_get_table(u32 id)
{
    if (id != RT_TABLE_LOCAL)
        return ip_fib_main_table;
    return ip_fib_local_table;
}

static inline struct fib_table *fib_new_table(u32 id)
{
    return fib_get_table(id);
}
可以看到,系统中默认有两个路由表,只有那些类型为RTN_UNICAST的路由才会被添加到ip_fib_main_table路由表中,通俗的说,这个里面路由表项的目的地址都不是本机所有的地址,反过来就是local表中的目的IP地址都是本机应该接受并消化掉得报文。在/proc/net/route文件的处理代码中
static void *fib_seq_start(struct seq_file *seq, loff_t *pos)
{
    void *v = NULL;

    read_lock(&fib_hash_lock);
    if (ip_fib_main_table)
        v = *pos ? fib_get_idx(seq, *pos - 1) : SEQ_START_TOKEN;
    return v;
}
也就是该文件只会显示系统中主路由表的内容,而没有显示local表的内容。如果希望看一下local表的内容,可以把fib_seq_start和fib_get_first函数中的ip_fib_main_table替换为ip_fib_local_table变量,再次打印这个文件将会看到系统中所有的本地路由表项。
一个值得注意的问题就是这种方式添加的路由表项中的网关都是0,因为在fib_magic函数中并没有初始化fib_config结构的fc_gw成员,所以向路由表中注册的时候该值也为空。这里就遗留一个问题,如果网关为空,那么当选中了这个路由表项的时候如何给ARP协议网关地址?
但是值得庆幸的是,这些路由表的操作都提供了本地的网络设备。因为我们对一个网卡进行使能或者配置地址的时候,一定是可以知道这个网卡的具体名称(进而得到该网卡在系统中的编号)。
2、手动添加
这些一般是通过route命令或者新的ip命令来添加的,前者使用的是传统的socket + ioctl的方式操作内核路由表,后者则是使用了相对比较新颖的netlink机制,不过这些都是用户态的实现问题,可以暂时忽略。依然看一下传统的路由添加过程linux-2.6.21\net\ipv4\af_inet.c
inet_ioctl(case SIOCADDRT)--->>>ip_rt_ioctl--->>tb->tb_insert
这个流程和fib_magic的流程大致相似,只是用户态代码提供了更为详细的控制,这一点在rtentry_to_fib_config函数中可以看到,用户态可以配置路由表中的所有内容。这个网关通常就需要用户自动配置了,如果没有配置,那这个设备就只能在局域网中通讯了。但事实上很多的主机都不用配置网关,无论是windows还是Linux的发行版本,然后大家到路由表里一看,网关是存在的,而且还是正确的,那我想可能就是用户态的DHCP协议的功劳吧。
3、一些现象
从路由表的查询函数fn_hash_lookup来看,其中最为核心的比较就是
        __be32 k = fz_key(flp->fl4_dst, fz);
…………
        hlist_for_each_entry(f, node, head, fn_hash) {
            if (f->fn_key != k)
这里最为重要的比较就是比较用户给出的目的IP地址,只是这里的IP地址并不是用户提供的IP地址本身,而是用户提供的32位IP地址和该路由表项中的MASK取逻辑与之后的一个结果。例如假设要查询19.168.203.111为目的地址,然后当前使用的是
eth0    00CBA8C0    00000000    0001    0    0    1    00FFFFFF    0    0    0    
路由表项,那么上面的逻辑判断就等价于下面的判断,(其中00FFFFFF是路由表中netmask的值, 00CBA8C0未destination的值)
(19.168.203.111)&00FFFFFF == 00CBA8C0
的判断。
现在再回到之前的TCP客户端connect一个服务器的时候如何确定本地地址的问题。此时客户端通过向路由表中查询,给出将要连接的服务器的IP地址作为这里的flp->fl4_dst值,从而从该网卡中返回了这个路由表项,此时发送时使用的网卡算是确定了,这个应该是向成功发送迈出了一大步。但是一个网卡是可以配置多个IP的,也就是Primary 及secondary系列的IP,所以此时的地址选择并没有功德圆满。还是结合代码看一下这个问题
tcp_v4_connect--->>>ip_route_connect--->>>>__ip_route_output_key ---->>>ip_route_output_slow
    if (!fl.fl4_src)
        fl.fl4_src = FIB_RES_PREFSRC(res);
这里就是看FIB_RES_PREFSRC来选择这些IP中的源地址了(对路由表的查询在ip_route_output_slow函数中if (fib_lookup(&fl, &res))处完成)。最终比较有意义的就是下面的比较了,所以大致看来,还是 网卡地址 & 地址掩码 要和目标地址在同一个网段中的获胜。所以说,代码本身的逻辑还是很人性化的,只是代码的实现比较绕而已,而绕的原因就是在于场景还是比价复杂的。
static __inline__ int inet_ifa_match(__be32 addr, struct in_ifaddr *ifa)
{
    return !((addr^ifa->ifa_address)&ifa->ifa_mask);
}
三、下一跳(nexthop)的选择
前面说过,对于链路层来说,它需要的是一个确定的下一跳地址,这个地址可以使网关(当目的IP和主机不在同一个局域网或者说碰撞域中时),也可以直接是局域网的地址(此时不需要网关转发而直达目的主机)。对于IP层来说,它只要确定下一跳的IP地址就好(事实上它也只能确定IP地址),至于这个IP地址如何转换为MAC地址,则可以由ARP协议来完成。
现在看一下典型的路由中对于下一跳的确定。
在__mkroute_output函数中,有下面一条指令    
rth->rt_gateway = fl->fl4_dst;
这里设置了rtable中的路由的值,这个设置是一个默认赋值,或者说是一个初始值。也即是假设向192.168.100.200发送地址,那么这个rt_gateway就设置为这个地址。明显地,如果这个地址和网卡地址不在同一个网段中,如果链路层直接向该地址发送报文是无法成功的,所以就需要通过网关,反过来说,如果目的地址和网卡地址在同一网段中,这个发送是合理的,而且是最优的,也就是点对点的直达方式。
总之,这个值可能需要被修正,也可能不需要。所以在这个默认值初始化之后,还会在__mkroute_output函数中执行
rt_set_nexthop(rth, res, 0);
来修正这个网关,这函数中的关键代码为
    if (fi) {
        if (FIB_RES_GW(*res) &&
            FIB_RES_NH(*res).nh_scope == RT_SCOPE_LINK)
            rt->rt_gateway = FIB_RES_GW(*res);
也就是说,如果路由表项中设置了网关的地址(FIB_RES_GW(*res) != 0 )并且这个网关和自己在同一碰撞域(FIB_RES_NH(*res).nh_scope == RT_SCOPE_LINK),那么就修正这个地址为网关的地址。当然了,这个网关的配置就需要根据网络的具体拓扑结构来设置了。例如,主机必须是和网关直连的,并且最为重要的是网关要打开转发功能(PC默认是不打开这个功能的)。
四、dst_entry及neighbour
本地地址和网关地址之后,就可以进行链路层的操作了。这两个结构还是比较容易混淆的。这里大致说一下它们之间的关系和逻辑。
所谓的一个dst_entry就是表示了一个逻辑发送目的。也就是说,是IP层看到的一个概念,例如,我本机要访问tsecer.blog.163.com这个网站,这个dst_entry中就应该体现这个网址的IP地址,本地端口、发送地址、网关等信息,但是它并不关心这个网关的ARP地址是多少,也不关心这个设备如何把这个报文发送出去,只是说IP层已经为这个发送建立了一个路线路。
而neighbour结构则是一个链路层概念。因为假设我向tsecer.blog.163.com这个地址通讯,此时链路层不可能一下子飞到这个地址,正所谓不积跬步无以至千里就是这个道理。链路层此时只能在自己的射程范围之内找到自己的一个neighbour,然后把这个报文交给他,让它来转发。当然对于网关来说,它不仅是一个neighbour,我们还可以认为他是一个工作为邮差的neighbour。
所以,如果说如果要进行一次发送,那么就要为这个dst_entry表示的目的关联一个neighbour结构,从而让它可以进行正行的信息传递。对于dst_entry来说,系统可以天马行空的连接很多目的地址,例如我可以连接tsecer.blog.163.com,还可以连接www.google.com.hk以及www.tianya.cn等,它们每个都对应一个dst_entry项,但是由于这些网址都在那遥远的地方,所以它们可能需要绑定的neighbour都是同一个,那就是网关的地址。
一个dst_entry的分配和初始化在__mkroute_output函数中完成
    rth = dst_alloc(&ipv4_dst_ops);
……
    rth->fl.fl4_dst    = oldflp->fl4_dst;
……
    rth->rt_gateway = fl->fl4_dst;
    rth->rt_spec_dst= fl->fl4_src;
而它的neighbour的分配和绑定的执行路径为
ip_mkroute_output_def--->>>rt_intern_hash--->>>arp_bind_neighbour
if (n == NULL) {
        __be32 nexthop = ((struct rtable*)dst)->rt_gateway;
        if (dev->flags&(IFF_LOOPBACK|IFF_POINTOPOINT))
            nexthop = 0;
        n = __neigh_lookup_errno(
#if defined(CONFIG_ATM_CLIP) || defined(CONFIG_ATM_CLIP_MODULE)
            dev->type == ARPHRD_ATM ? clip_tbl_hook :
#endif
            &arp_tbl, &nexthop, dev);
其中的__neigh_lookup_errno在邻居不存在的时候会创建该项,代码很简单,就不再分析了。这里可以看到,路由中rt_gateway是作为neighbour结构的key来创建的,这一点非常重要,它一方面体现了网关的作用,另一方面也说明了neighbour表的组织结构及存在意义。
在__neigh_lookup_errno--->>>neigh_create
/* Protocol specific setup. */
    if (tbl->constructor &&    (error = tbl->constructor(n)) < 0) {
        rc = ERR_PTR(error);
        goto out_neigh_release;
    }
中,会调用neighbour表结构的构造函数,相当于C++中实例化一个对象。对于我们这里的分析,他执行的函数为arp_tbl-->>arp_constructor
这里最为重要的就是初始化了neighbour结构的output 函数指针
    if (dev->hard_header_cache)
            neigh->ops = &arp_hh_ops;
        else
            neigh->ops = &arp_generic_ops;
        if (neigh->nud_state&NUD_VALID)
            neigh->output = neigh->ops->connected_output;
        else
            neigh->output = neigh->ops->output;
初始化了之后就等报文发送了,发送的流程为
tcp_v4_connect--->>>tcp_connect tcp_transmit_skb--->>>tcsk->icsk_af_ops->queue_xmit(skb, 0)(ip_queue_xmit)--->>>tdst_output ip_output--->>>tip_finish_output--->>>tip_finish_output2 
    if (dst->hh)
        return neigh_hh_output(dst->hh, skb);
    else if (dst->neighbour)
        return dst->neighbour->output(skb);
可见ip报文的发送还是要最终转换为neighbour的操作。
这里说明网络转发的精髓:
发送报文的目的MAC地址是网关的MAC地址,而目的IP地址则是原始目的地址
五、真正发送
之后的流程就比较简单了,至少说对软件来说是如此:
dev_queue_xmit--->>>dev_hard_start_xmit
        return dev->hard_start_xmit(skb, dev);
从而总算是转到了具体网络设备的发送上了。当然这里发送也不一定就真的出了主机,因为系统中可能存在大量的非物理网络设备,例如loopback设备,当调用loopback设备的发送接口是,这个报文将会掉头重新返回本机的网络协议栈;例如tun/tap设备的发送至少把他放在一个设备文件中,并等待用户态的程序来读取,之后再通过具体的网卡进入网络,例如bridge虚拟网络设备需要再次在链路层路由等功能都还只是刚刚开始。
六、TODO
ARP的具体协议实现流程及细节。

posted on 2019-03-06 20:56  tsecer  阅读(1275)  评论(0编辑  收藏  举报

导航