网桥驱动与二层数据转发

1 概念

1.1 网桥
网桥为了实现不同端口转发而建立的设备,数据包通过网桥实现在不同端口间复制,达到转发目的。
所以,网桥对数据包的mac地址可以读懂解析,故他在端口转发的时候就可以有记忆功能,进而避免向所有端口转发这一效率低下的行为。端口与mac地址的关系不能已成不变,所以也有“老化更新功能”。
网桥功能:
1,MAC学习:建立地址-端口的对照表(CAM表)
2.报文转发:每发送一个数据包,网桥都会提取其目的MAC地址,从自己的地址-端口对照表(CAM表)中查找由哪个端口把数据包发送出去。
1.2 ptype_base和ptype_all变量
这两个变量属于packet_type结构体类型,该结构是对应于具体协议的实例。系统根据该结构体,将不同的协议类型及对应的处理函数关联。在网络驱动接受到网络包,调用netif_receive_skb函数处理时,这两个变量发挥较大的作用。

1.	struct packet_type {  
2.	    __be16          type;   /* 以太网协议类型,如ETH_P_IP  ETH_P_ARP. */  
3.	    bool            ignore_outgoing;  
4.	    struct net_device   *dev;   /* NULL is wildcarded here       */  
5.	    int         (*func) (struct sk_buff *,  //钩子函数,如 ip_rcv()、arp_rcv()  
6.	                     struct net_device *,  
7.	                     struct packet_type *,  
8.	                     struct net_device *);  
9.	    void            (*list_func) (struct list_head *,  
10.	                          struct packet_type *,  
11.	                          struct net_device *);  
12.	    bool            (*id_match)(struct packet_type *ptype,  
13.	                        struct sock *sk);  
14.	    struct net      *af_packet_net;  
15.	    void            *af_packet_priv;  
16.	    struct list_head    list;  
17.	};

type为协议类型,下图为一部分,具体定义在user_headers/include/linux/if_ether.h
在这里插入图片描述

packet_type的挂载:
将packet_type类型变量挂载在对应type链表中。调用函数dev_add_pack:
 获取packet_type的链表头节点,pt->type协议类型是否为ALL,对于ALL类型,会选择专门的ptype_all链表,其他类型会选择ptype_base链表。同时还会根据pt->dev是否定义选择全局类型的链表或是pt->dev中的链表。具体逻辑见下图
 将pt挂载在获取到的head节点

在这里插入图片描述

移除则使用接口dev_remove_pack。

2 Linux网桥的实现

linux内核实现虚拟的网桥设备,并绑定若干个以太网口(目前网桥只支持以太网接口)。
交换机对收到的数据包,只能丢弃或转发。但是linux内核设备不一样,他可能本身就是数据包的目的地。所以除了丢弃转发,他还会发往协议层自己消化。
网桥是在数据链路层实现的,他的上面是邻居子系统和网络层。
2.1 数据结构
在这里插入图片描述

虚拟网桥是一个网络设备,基本结构体为net_device,net_device –> priv下存储了网桥数据结构的私有变量net_bridge。上图中一些重要的数据结构如下:
 net_bridge:网桥私有数据
 net_bridge_port:网桥要绑定的以太网端口的结构体
 net_bridge_fdb_entry:单播转发数据库条目
 net_bridge_mdb_entry:组播转发数据库条目

2.1.1 网桥私有数据net_bridge
网桥相关信息保存在这里:

1.	struct net_bridge {  
2.	    spinlock_t          lock;  
3.	    spinlock_t          hash_lock;  
4.	    struct list_head        port_list;  // net_bridge_port{}链表  
5.	    struct net_device       *dev;  // 指向网桥设备的net_device{}  
6.	    struct pcpu_sw_netstats     __percpu *stats;    // 统计值,TX/Rx Packet Byte之类  
7.	    unsigned long           options;  //网桥的配置字段,包括对多播,vlan,stp等相应的配置
8.	    /* These fields are accessed on each packet */  
9.	#ifdef CONFIG_BRIDGE_VLAN_FILTERING  
10.	    __be16              vlan_proto;  //vlan端口类型  
11.	    u16             default_pvid;  //默认vid  
12.	    struct net_bridge_vlan_group    __rcu *vlgrp;  //vlan组,保存所有vlan信息  
13.	#endif  
14.	  
15.	    struct rhashtable       fdb_hash_tbl;  // 单播转发数据库(FDB)哈希表  
16.	#if IS_ENABLED(CONFIG_BRIDGE_NETFILTER)  
17.	    union {  
18.	        struct rtable       fake_rtable;  
19.	        struct rt6_info     fake_rt6_info;  
20.	    };  
21.	#endif  
22.	    u16             group_fwd_mask;  
23.	    u16             group_fwd_mask_required;  
24.	  
25.	    /* 网桥中STP信息 */  
26.	    bridge_id           designated_root;  
27.	    bridge_id           bridge_id;  
28.	    unsigned char           topology_change;  
29.	    unsigned char           topology_change_detected;  
30.	    u16             root_port;  
31.	    unsigned long           max_age;  
32.	    unsigned long           hello_time;  
33.	    unsigned long           forward_delay;  
34.	    unsigned long           ageing_time;  
35.	    unsigned long           bridge_max_age;  
36.	    unsigned long           bridge_hello_time;  
37.	    unsigned long           bridge_forward_delay;  
38.	    unsigned long           bridge_ageing_time;  
39.	    u32             root_path_cost;  
40.	  
41.	    u8              group_addr[ETH_ALEN];  
42.	  
43.	    enum {  
44.	        BR_NO_STP,      /* no spanning tree */  
45.	        BR_KERNEL_STP,      /* old STP in kernel */  
46.	        BR_USER_STP,        /* new RSTP in userspace */  
47.	    } stp_enabled;  
48.	  
49.	#ifdef CONFIG_BRIDGE_IGMP_SNOOPING  
50.	  //对多播信息的维护
51.	    u32             hash_max;  
52.	  
53.	    u32             multicast_last_member_count;  
54.	    u32             multicast_startup_query_count;  
55.	  
56.	    u8              multicast_igmp_version;  
57.	    u8              multicast_router;  //Bridge multicast_router默认为1,表示该网桥如持续收到igmp query包的话,它将成为临时router ports;如multicast_router设置为2,表示它将成为永久router ports。一旦物理端口成为router ports,该端口总是接收所有multicast traffic而不受multicast_snooping限制,全部发往上层协议。网桥端口也有该变量,不同的是,网桥端口成为router ports将把数据发往外部设备。参考
58.	#if IS_ENABLED(CONFIG_IPV6)  
59.	    u8              multicast_mld_version;  
60.	#endif  
61.	    spinlock_t          multicast_lock;  
62.	    unsigned long           multicast_last_member_interval;  
63.	    unsigned long           multicast_membership_interval;  
64.	    unsigned long           multicast_querier_interval;  
65.	    unsigned long           multicast_query_interval;  
66.	    unsigned long           multicast_query_response_interval;  
67.	    unsigned long           multicast_startup_query_interval;  
68.	  
69.	    struct rhashtable       mdb_hash_tbl;  //多播数据转发表条目
70.	    struct rhashtable       sg_port_tbl;  
71.	  
72.	    struct hlist_head       mcast_gc_list;  
73.	    struct hlist_head       mdb_list;  
74.	    struct hlist_head       router_list;  
75.	  
76.	    struct timer_list       multicast_router_timer;  
77.	    struct bridge_mcast_other_query ip4_other_query;  
78.	    struct bridge_mcast_own_query   ip4_own_query;  
79.	    struct bridge_mcast_querier ip4_querier;  
80.	    struct bridge_mcast_stats   __percpu *mcast_stats;  
81.	#if IS_ENABLED(CONFIG_IPV6)  
82.	    struct bridge_mcast_other_query ip6_other_query;  
83.	    struct bridge_mcast_own_query   ip6_own_query;  
84.	    struct bridge_mcast_querier ip6_querier;  
85.	#endif /* IS_ENABLED(CONFIG_IPV6) */  
86.	    struct work_struct      mcast_gc_work;  
87.	#endif  
88.	  
89.	    struct timer_list       hello_timer;  
90.	    struct timer_list       tcn_timer;  
91.	    struct timer_list       topology_change_timer;  
92.	    struct delayed_work     gc_work;  
93.	    struct kobject          *ifobj;  
94.	    u32             auto_cnt;  
95.	  
96.	    u32             offload_cache_size;  
97.	    u32             offload_cache_reserved;  
98.	  
99.	#ifdef CONFIG_NET_SWITCHDEV  
100.	    int offload_fwd_mark;  
101.	#endif  
102.	    struct hlist_head       fdb_list;  
103.	  
104.	#if IS_ENABLED(CONFIG_BRIDGE_MRP)  
105.	    struct list_head        mrp_list;  
106.	#endif  
107.	};  

网桥设备和网桥端口设备一样,也可视为一个(对L3的)端口,也需要VLAN信息
FDB是网桥的属性,因此保存在net_bridge{}中,保存的方式是一个Hash表。
2.1.2 网桥端口:net_bridge_port
一个网桥可以和若干以太网端口绑定,每个端口信息保存在net_bridge_port结构中,并被net_bridge{} –>port_list链接。

1.	struct net_bridge_port {  
2.	    struct net_bridge       *br;  // 所属网桥(反向引用)  
3.	    struct net_device       *dev;  // 网桥端口自己的net_device{}结构。  
4.	    struct list_head        list;  // 同一个Bridge的各个Port组织在链表dev.port_list中  
5.	  
6.	    unsigned long           flags;  
7.	#ifdef CONFIG_BRIDGE_VLAN_FILTERING  
8.	    struct net_bridge_vlan_group    __rcu *vlgrp;  
9.	#endif  
10.	    struct net_bridge_port      __rcu *backup_port;  //备份端口是桥接设备中用于提供冗余路径的一种机制。当桥接设备中的主端口(Designated Port)故障或失效时,备份端口可以接管数据的转发,从而保持网络的连通性。备份端口通常是通过生成树协议(如 Spanning Tree Protocol,STP)计算得出的。
11.	  
12.	    /* STP */  
13.	    u8              priority;  // 端口优先级  
14.	    u8              state;  // 端口STP状态:Disabled,Blocking,Learning,Forwarding  
15.	    u16             port_no;  // 端口号,每个Bridge上各个端口的端口号不能改变(不能配置)  
16.	    unsigned char           topology_change_ack;  
17.	    unsigned char           config_pending;  
18.	    port_id             port_id;  // 端口ID:Prio+端口号  
19.	    port_id             designated_port;    
20.	    bridge_id           designated_root;  //根节点网桥id  
21.	    bridge_id           designated_bridge;  
22.	    u32             path_cost;  
23.	    u32             designated_cost;  
24.	    unsigned long           designated_age;  
25.	  
26.	    struct timer_list       forward_delay_timer;  // 转发延迟定时器,默认15s  
27.	    struct timer_list       hold_timer;  // 控制BPDU发送最大速率的定时器  
28.	    struct timer_list       message_age_timer;  //BPDU老化定时器  
29.	    struct kobject          kobj;  
30.	    struct rcu_head         rcu; 

BPDU理解为网桥间交换桥接协议信息的数据单元,他是记录STP信息的主要数据结构。
2.1.3 单播转发数据库条目:net_bridge_fdb_entry

1.	struct net_bridge_fdb_entry {  
2.	    struct rhash_head       rhnode;  
3.	    struct net_bridge_port      *dst;// 条目对应的网桥端口  
4.	    struct net_bridge_fdb_key   key; //保存MAC地址addr与VLAN ID  
5.	    struct hlist_node       fdb_node;  
6.	    unsigned long           flags; //标示条目属性,比如是否是本地端口的MAC,是否是静态配置的MAC  
7.	    /* write-heavy members should not affect lookups */  
8.	    unsigned long           updated ____cacheline_aligned_in_smp; // 最后一次更新的时间,会与Bridge的老化定时器比较。  
9.	    unsigned long           used;// 引用计数  
10.	    union {  
11.	        struct {  
12.	            struct hlist_head       offload_in;  
13.	            struct hlist_head       offload_out;  
14.	        };  
15.	        struct rcu_head         rcu;  
16.	    };  
17.	}; 

net_bridge中有两个字段与该结构体相关,fdb_hash_tbl与fdb_list,函数fdb_create()可以看到net_bridge_fdb_entry与这连个字段的关系。每一条新建的fdb表项会加入br->fdb_hash_tbl这个哈希表中,准确的说是br->fdb_hash_tbl->tbl,对应的键值是fdb表项的key字段(addr与vlan)。插入成功,则将该fdb表项的fdb_node插入br->fdb_hash_tbl中。
也就说fdb表项分别以哈希表与链表的形式存储在br中。
fdb相关操作函数实现:linux-5.10/net/bridge/br_fdb.c
在这里插入图片描述

2.1.4 组播转发数据库条目:net_bridge_mdb_entry
该条目描述一个多播组转发项,将多播形式的mac地址与一组端口对应,timer链表为这些端口的失效定时器链表,某一个端口定时器超时则将其移除。

1.	struct net_bridge_mdb_entry {  
2.	    struct rhash_head       rhnode;  
3.	    struct net_bridge       *br;  
4.	    struct net_bridge_port_group __rcu *ports;   //端口组链表,这里保存端口、端口所属网桥id,端口add,端口过滤模式,端口flag等
5.	    struct br_ip            addr;  //多播格式的mac地址
6.	    bool                host_joined;  //本地端是否加入该组,多播中通过该字段判断是否要给自己的上层协议转发一份数据
7.	    struct timer_list       timer;  //组播组数据库项失效定时器,若超时,则会将该组播端口从组播组数据库项的组播端口列表中删除
8.	    struct hlist_node       mdb_node;  
9.	  
10.	    struct net_bridge_mcast_gc  mcast_gc;  //用于条目数据的回收
11.	    struct rcu_head         rcu;  
12.	};  

net_bridge_port_group结构体记录了加入一个组播组的组播端口信息结构体,保存端口、端口所属网桥id,端口add,端口过滤模式,端口flag等详细信息。
mdb相关操作函数实现:linux-5.10/net/bridge/br_mdb.c

2.2 代码实现
2.2.1 初始化
桥接部分初始化代码定义在net/bridge/br.c,函数br_init().

在这里插入图片描述
在这里插入图片描述

2.2.2 网桥设备添加
br_add_bridge
–>alloc_netdev //创建net_device设备,指定初始化函数 br_dev_setup并调用
–> br_dev_setup //对net_device变量进行必要初始化,指定br_netdev_ops和br_ethtool_ops操作集,初始化私有变量priv(即net_bridge)
–>register_netdev(dev) //注册

br_dev_setup是重点,网桥设备私有变量net_bridge结构主要在这里初始化(之前看的网卡设备,这个变量叫macb,不同的网络设备结构体都叫这个名字net_device,区分每个设备特有功能属性靠的就是这个私有变量)

1.	void br_dev_setup(struct net_device *dev)  
2.	{  
3.	    struct net_bridge *br = netdev_priv(dev);  
4.	    eth_hw_addr_random(dev); //生成一个随机的MAC地址  
5.	    ether_setup(dev);// 虚拟的Bridge是Ethernet类型,进行ethernet初始化(type, MTU,broadcast等)。  
6.	    dev->netdev_ops = &br_netdev_ops;   // 网桥设备的netdev_ops  
7.	    dev->destructor = br_dev_free;  
8.	    dev->ethtool_ops = &br_ethtool_ops;  
9.	    SET_NETDEV_DEVTYPE(dev, &br_type);// br_type.name = "bridge"  
10.	    dev->tx_queue_len = 0;  
11.	    dev->priv_flags = IFF_EBRIDGE;// 标识此设备为Bridge  
12.	    dev->features = COMMON_FEATURES | NETIF_F_LLTX | NETIF_F_NETNS_LOCAL |  
13.	            NETIF_F_HW_VLAN_CTAG_TX | NETIF_F_HW_VLAN_STAG_TX;  
14.	    dev->hw_features = COMMON_FEATURES | NETIF_F_HW_VLAN_CTAG_TX |  
15.	               NETIF_F_HW_VLAN_STAG_TX;        
16.	    dev->vlan_features = COMMON_FEATURES;  
17.	    br->dev = dev;  
18.	    spin_lock_init(&br->lock);  
19.	    INIT_LIST_HEAD(&br->port_list);//初始化网桥端口链表和锁  
20.	    spin_lock_init(&br->hash_lock);  
21.	    br->bridge_id.prio[0] = 0x80;   // 默认优先级  
22.	    br->bridge_id.prio[1] = 0x00;   
23.	    // STP相关初始化  
24.	    ether_addr_copy(br->group_addr, eth_reserved_addr_base);// 802.1D(STP)多播01:80:C2:00:00:00  
25.	    br->stp_enabled = BR_NO_STP;// 默认没有打开STP,不阻塞任何多播包。  
26.	    br->group_fwd_mask = BR_GROUPFWD_DEFAULT;  
27.	    br->group_fwd_mask_required = BR_GROUPFWD_DEFAULT;  
28.	    br->designated_root = br->bridge_id;  
29.	    br->bridge_max_age = br->max_age = 20 * HZ; // 20sec BPDU老化时间  
30.	    br->bridge_hello_time = br->hello_time = 2 * HZ;// 2sec HELLO定时器时间  
31.	    br->bridge_forward_delay = br->forward_delay = 15 * HZ;// 15sec 转发延时(用于Block->Learning->Forwardnig)  
32.	    br->ageing_time = 300 * HZ;// FDB 中保存的MAC地址的老化时间(5分钟)  
33.	    br_netfilter_rtable_init(br);    // Netfilter (ebtables)  
34.	    br_stp_timer_init(br);  
35.	    br_multicast_init(br);// 多播转发相关初始化  
36.	}  

先为网桥设备生成一个随机的MAC地址,当bridge的第一个接口被binding的时候,bridge的MAC字段自动转为第一个接口的地址。所以网桥在没有绑定端口时,它的mac地址是随机数,而有端口进行绑定后,它的mac地址会等于它下面的一个端口地址。
STP是一个用于局域网中消除环路的协议。运行该协议的设备通过彼此交互信息而发现网络中的环路,并对某些端口进行阻塞以消除环路。

2.2.3 向网桥添加端口设备
和创建网桥设备一样,为网桥设备添加端口设备,也可以使用ioctl和netlink两种方式。两种方式最终会调用br_add_if()。(netlink是什么方式?)
在这里插入图片描述

br_add_if开始阶段会先端口资格检查,有几类设备不能作为网桥端口:
• loopback设备
• 非Ethernet设备
• 网桥设备,即不支持“网桥的网桥”
• 本身是另一个网桥设备端口。每个设备只能有一个Master,否则数据去哪里呢
• 配置为IFF_DONT_BRIDGE的设备

3 网桥设备对数据包处理

以收包为例。
网络中有数据包到来,当网卡接受到数据时,网卡向cpu发中断,cpu调用驱动程序,将数据包放到对应的cpu输入队列。接着唤醒软中断,调用netif_receive_skb函数,在这里判断数据包需要转到上层协议栈,还是向网桥接口处理。数据流向如下,这里默认网桥设备为非disable状态。
在这里插入图片描述

具体流程:
  1. netif_receive_skb
  2. –>netif_receive_skb_internal /* 记录收包时间, rps机制,将报文在多个cpu之间做负载均衡以及提高报文处理的缓存命中率 */
  3.  -->__netif_receive_skb  
    
  4.      -->__netif_receive_skb_core  
    
  5.      	 --> skb_reset_network_header //重置network_header字段
    
  6.      	 --> skb_vlan_untag //802.1Q、802.1AD,剥除vxlan头
    
  7.       	 --> paket_type.func() //处理 ptype_all 上所有的 packet_type->func()
    
  8.       	 --> vlan_do_receive
    
  9.       	 --> dev->rx_handler () //实际执行函数br_handle_frame(),数据包逻辑分发,网桥处理转发就在这里进行
    
  10.      	 --> deliver_ptype_list_skb //向L3分发
    

3.1 __netif_receive_skb_core
函数流程图如下:
在这里插入图片描述

函数执行关键步骤注释如下:

1.	static int __netif_receive_skb_core(struct sk_buff **pskb, bool pfmemalloc,  
2.	                    struct packet_type **ppt_prev)  
3.	{  
4.	             。。。  
5.	    //记录收包时间,netdev_tstamp_prequeue为0,表示可能有包延迟  
6.	    net_timestamp_check(!READ_ONCE(netdev_tstamp_prequeue), skb);  
7.	    trace_netif_receive_skb(skb); //用于跟踪调试  
8.	    orig_dev = skb->dev;  //记录收包设备  
9.	    /* 重置network_header字段 */  
10.	    skb_reset_network_header(skb);  
11.	    if (!skb_transport_header_was_set(skb))  
12.	        skb_reset_transport_header(skb);/* 重置transport_header字段 */  
13.	    skb_reset_mac_len(skb); /* 重置skb->mac_len字段 */  
14.	    pt_prev = NULL;  
15.	another_round:  
16.	    skb->skb_iif = skb->dev->ifindex;  //设置接收设备索引号  
17.	    __this_cpu_inc(softnet_data.processed);  //处理包数统计   
18.	      // do_xdp_generic作用??  
19.	    if (static_branch_unlikely(&generic_xdp_needed_key)) {  
20.	        preempt_disable();  
21.	        ret2 = do_xdp_generic(rcu_dereference(skb->dev->xdp_prog), skb);  
22.	    }  
23.	    // vxlan报文处理,剥除vxlan头  
24.	    if (skb->protocol == cpu_to_be16(ETH_P_8021Q) ||  
25.	        skb->protocol == cpu_to_be16(ETH_P_8021AD)) {  
26.	        skb = skb_vlan_untag(skb);  
27.	        if (unlikely(!skb))  
28.	            goto out;  
29.	    }  
30.	    //tc分类?  
31.	    if (skb_skip_tc_classify(skb))  
32.	        goto skip_classify;  
33.	    // 此类报文不允许ptype_all处理,即tcpdump也抓不到  
34.	    if (pfmemalloc)  
35.	        goto skip_taps;  
36.	    //这里是对netfilter的处理。先处理全局ptype_all 上所有的 packet_type->func(),该变量在系统初始化时添加进去的       
37.	    //典型场景:tcpdump抓包所使用的协议  
38.	    list_for_each_entry_rcu(ptype, &ptype_all, list) {//遍历ptye_all链表  
39.	        if (pt_prev)  
40.	            ret = deliver_skb(skb, pt_prev, orig_dev);//此函数最终调用paket_type.func()  
41.	        pt_prev = ptype;  
42.	    }  
43.	    // 这里处理skb->dev私有的ptype_all  
44.		list_for_each_entry_rcu(ptype, &skb->dev->ptype_all, list) {
45.			if (pt_prev)
46.				ret = deliver_skb(skb, pt_prev, orig_dev);
47.			pt_prev = ptype;
48.		}
49.	    。。。。。。。。
50.	skip_taps:  
51.	  //入口流量处理逻辑  
52.	#ifdef CONFIG_NET_INGRESS  
53.	    if (static_branch_unlikely(&ingress_needed_key)) {  
54.	    。。。。  
55.	        skb = sch_handle_ingress(skb, &pt_prev, &ret, orig_dev,  
56.	                     &another);  
57.	    }  
58.	#endif  
59.	    skb_reset_redirect(skb); //skb->redirected=0,作用??  
60.	skip_classify:  
61.	    //没有支持的协议类型,包直接丢弃  
62.	    if (pfmemalloc && !skb_pfmemalloc_protocol(skb))  
63.	        goto drop;  
64.	    // 对vlan包的处理  
65.	  if (skb_vlan_tag_present(skb)) {//返回skb->vlan_present,该字段代表网络帧是否包含 VLAN 标签  
66.	        if (pt_prev) {  
67.	            ret = deliver_skb(skb, pt_prev, orig_dev);  
68.	            pt_prev = NULL;  
69.	        }  
70.	        if (vlan_do_receive(&skb))  
71.	            goto another_round;  
72.	        else if (unlikely(!skb))  
73.	            goto out;  
74.	    }  
75.	/*如果一个dev被添加到一个bridge(做为bridge的一个接口),这个接口设备的rx_handler将被设置为br_handle_frame函数, 
76.	这是在br_add_if函数中设置的,而br_add_if (net/bridge/br_if.c)是在向网桥设备上添加接口时设置的。 
77.	进入br_handle_frame也就进入了bridge的逻辑代码。*/  
78.	    rx_handler = rcu_dereference(skb->dev->rx_handler);  
79.	    if (rx_handler) {  
80.	        if (pt_prev) {  
81.	            ret = deliver_skb(skb, pt_prev, orig_dev);  
82.	            pt_prev = NULL;  
83.	        }  
84.	        // 调用rx_handler处理函数,从这里出来互  
85.	        switch (rx_handler(&skb)) {  
86.	        case RX_HANDLER_CONSUMED: /* 数据包已处理消化,无需进一步处理 */  
87.	            ret = NET_RX_SUCCESS;  
88.	            goto out;  
89.	        case RX_HANDLER_ANOTHER:  /* 修改了skb->dev,在处理一次 */  
90.	            goto another_round;  
91.	        case RX_HANDLER_EXACT:  /* 精确传递到ptype->dev == skb->dev */  
92.	            deliver_exact = true;  
93.	        case RX_HANDLER_PASS:  
94.	            break;  
95.	        default:  
96.	            BUG();  
97.	        }  
98.	    }  
99.	    /* 还有vlan标记,说明找不到vlanid对应的设备 */  
100.	    if (unlikely(skb_vlan_tag_present(skb)) && !netdev_uses_dsa(skb->dev)) {  
101.	check_vlan_id:  
102.	        /* 存在vlanid,则判定是到其他设备的包 */  
103.	        if (skb_vlan_tag_get_id(skb)) {  
104.	            /* Vlan id is non 0 and vlan_do_receive() above couldn't 
105.	             * find vlan device. 
106.	             */  
107.	            skb->pkt_type = PACKET_OTHERHOST;  
108.	            //都不是,再处理一遍vlan  
109.	        } else if (skb->protocol == cpu_to_be16(ETH_P_8021Q) ||  
110.	               skb->protocol == cpu_to_be16(ETH_P_8021AD)) {  
111.	            。。。。。。。  
112.	        }  
113.	        __vlan_hwaccel_clear_tag(skb);  
114.	    }  
115.	    /* 设置三层协议,下面按照三层协议提交 */  
116.	    type = skb->protocol;  
117.	    /* deliver only exact match when indicated */  
118.	    if (likely(!deliver_exact)) {  
119.	        deliver_ptype_list_skb(skb, &pt_prev, orig_dev, type,  
120.	                       &ptype_base[ntohs(type) &  
121.	                           PTYPE_HASH_MASK]);  
122.	    }  
123.	    deliver_ptype_list_skb(skb, &pt_prev, orig_dev, type,  
124.	                   &orig_dev->ptype_specific);  
125.	  
126.	    if (unlikely(skb->dev != orig_dev)) {  
127.	        deliver_ptype_list_skb(skb, &pt_prev, orig_dev, type,  
128.	                       &skb->dev->ptype_specific);  
129.	    }  
130.	    。。。。  
131.	drop:  
132.	            atomic_long_inc(&skb->dev->rx_nohandler);  
133.	out:  
134.	    *pskb = skb;  
135.	    return ret;  
136.	}  

CONFIG_NET_INGRESS 是 Linux 内核的一个配置选项,用于启用或禁用网络设备的 Ingress 功能。启用 CONFIG_NET_INGRESS 配置选项后,Linux 内核将支持对网络设备的入口流量进行控制和处理。
网络设备的 Ingress 功能可以用于实现以下功能:
 数据包过滤:根据预先设置的过滤规则,判断数据包是否符合要求。如果数据包被过滤,可以根据策略进行丢弃或处理。
 数据包处理:根据网络设备的配置,对数据包进行处理。例如,进行 QoS 处理、修改数据包头部、更改目标端口等操作。
 数据包转发:根据策略和路由表,决定数据包的转发目的地,并将数据包发送到相应的网络接口。
上述功能通过函数sch_handle_ingress实现。

对vlan的处理 :
判断skb协议是否为8021Q 8021AD,若是vlan包,则调用skb_vlan_untag()函数,该函数读出数据流中的vlan_id,并填写入skb->vlan_tci中,然后删除vlan_head,从而实现对上层的透明。注意这里的skb->vlan_tci标志仅是为了保存skb数据的vlan头信息,而skb中的数据是透明的以太网包.

if (skb->protocol == cpu_to_be16(ETH_P_8021Q) ||
    skb->protocol == cpu_to_be16(ETH_P_8021AD)) {
    skb = skb_vlan_untag(skb);
skb_vlan_untag(skb)

函数skb_vlan_untag执行流程如下:
在这里插入图片描述

vlan信息转移到skb结构中后,会获取数据包真实协议类型(三层协议类型),更新到skb的protocol字段,替换了之前的ETH_P_8021Q或者ETH_P_8021AD。
将vlan信息存在skb字段后,调用lan_do_receive,该函数由skb->vlan_tci得到该skb包所要发往的vlan_dev,并且重定向skb->dev为该vlan_dev,最后消除skb中的vlan_tci标志。

if (skb_vlan_tag_present(skb)) {//判断vlan标记是否为1,在剥离vlan tag时已设置过了
        if (vlan_do_receive(&skb))
            goto another_round;
        else if (unlikely(!skb))
            goto out;
}

在这里插入图片描述

这样之后vlan_do_receive()返回,不过此时的skb已经是一个普通的数据包了(实现了对上层的透明),且它看起来就像是由vlan_dev接收的数据包。

接下来是关键处理dev->rx_handler,这个函数在之前将网口添加到网桥设备的过程,调用br_add_if函数中定义:

1.	br_add_if  ()
2.	    -->netdev_rx_handler_register  
3.	        -->netdev_rx_handler_register(dev, br_get_rx_handler(dev), p); //  
4.	            {rcu_assign_pointer(dev->rx_handler_data, p)  
5.	            rcu_assign_pointer(dev->rx_handler, br_get_rx_handler)}  

看到在netdev_rx_handler_register函数:
 将dev->rx_handle与br_get_rx_handler绑定
 将dev->rx_handler_data与以太网端口p绑定

所以这里网桥操作skb包,主要在br_get_rx_handler函数中进行。
经过dev->rx_handler函数处理,返回值有四种类型:
 RX_HANDLER_CONSUMED:数据包已处理消化,无需进一步处理。(什么场景) 直接跳转到out标签退出。
 RX_HANDLER_ANOTHER:修改了skb->dev,再处理一次,比如数据包设备是网桥设备,但经过判断,需要经过网桥设备转发到另一个网桥关联的端口,这时就从bridge_dev转换到了port_dev
 RX_HANDLER_EXACT:精确指定要传递到ptype->dev == skb->dev(什么场景)
 RX_HANDLER_PASS:1.数据包类型是回环类型PACKET_LOOPBACK
后两种情况会继续往下执行,最终将数据包提交到L3层协议栈。
在这里插入图片描述

3.2 网桥设备处理skb: br_handle_frame
从这里开始才是对数据包的处理分流转发,之前可以看作netif_receive_skb函数对数据包的预处理。br_get_rx_handler被设置为br_handle_frame,具体路径:net/bridge/br_input.c。
br_handle_frame函数根据数据包的类型,对数据的处理进行分流.
 网桥处于disable状态:
1.数据包目的地址是网桥端口地址:将pkt_type设为PACKET_HOST

if (ether_addr_equal(p->br->dev->dev_addr, dest))
    skb->pkt_type = PACKET_HOST;

2.否则,br_handle_local_finish函数设置到网桥预处理节点,该函数在网桥状态部位disable时,会学习mac与端口信息并记录fdb表中

if (NF_HOOK(NFPROTO_BRIDGE, NF_BR_PRE_ROUTING,
    dev_net(skb->dev), NULL, skb, skb->dev, NULL,
    br_handle_local_finish) == 1) {
    return RX_HANDLER_PASS;
}

 网桥处于learning或forwarding状态:
调用函数nf_hook_bridge_pre-> br_handle_frame_finish

3.3 br_handle_frame_finish
br_handle_frame_finish:决策将不同类别的数据包做不同的分发路径。具体处理逻辑如下图.

首先这里有一个比较重要的特性,arp proxy,接口如果是能了此功能,对于收到的arp request,通过查询本地的arp表构造arp reply报文回应。
根据目标地址skb->hdr->h_dest判断是单播、广播、多播的哪一种:
 单播:调用br_fdb_find_rcu查找fdb表项,
(1)若命中,调用br_forward()将数据包转发,
(2)若表项为空,调用函数br_flood()
br_flood(br, skb, pkt_type, local_rcv, false);
注意,如果发现目标地址是本地地址(判断方法目标表项dst->flags为BR_FDB_LOCAL),则直接调用br_pass_frame_up()发往本地,该函数会再次进入netif_receive_skb函数。
 广播:由于广播目标地址包含本机,所以会再次调用br_pass_frame_up(),并调用br_flood()
 多播:因为当目的mac地址是0x01开头时,既可以是igmp类型的多播协议控制报文,也可以是多播数据流报文。先调用br_multicast_rcv处理igmp类型数据包,多播数据表也通过该类型数据表维护。接着查看多播数据表项,
(1)如果命中,调用br_multicast_flood(),
(2)如果未命中也会调用br_flood()。
最后多播也会判断本机是否加入多播组,加入的话也会调用函数br_pass_frame_up()将数据发往本地。

这里看到单播,多播,广播都会调用br_flood()函数,分别在以下情况:
1. 单播,fdb表项未命中
2. 广播下都会调用
3. 多播,mdb表未命中
不同情况,通过函数参数pkt_type进行的区分。
单播:pkt_type = BR_PKT_UNICAST
广播:pkt_type = BR_PKT_BROADCAST
多播:pkt_type = BR_PKT_MULTICAST

在这里插入图片描述

3.4 数据包发往本地:br_pass_frame_up
数据进入br_pass_frame_up,是打算经由Bridge设备,输入到本地Host的。数据包从网桥端口设备进入,经过网桥设备,然后再进入协议栈,其实是“两次经过net_device”,一次是端口设备,另一次是网桥设备。现在数据包离开网桥端口进入网桥设备,需要修改skb->dev字段。

indev = skb->dev;
skb->dev = brdev

递交的最后一步是经过NF_BR_LOCAL_IN钩子点,然后是我们熟悉的netif_receive_skb,只不过这次进入该函数的时候skb->dev已经被换成了Bridge设备。这可以理解为进入了Bridge设备的处理。它的skb->dev->rx_handler 为空,所以不会再次进入br_handler_frame,而是会进上层协议栈。

return NF_HOOK(NFPROTO_BRIDGE, NF_BR_LOCAL_IN,
               dev_net(indev), NULL, skb, indev, NULL,
               br_netif_receive_skb);

数据被修改skb->dev后再次进入netif_receive_skb,上次执行的netif_receive_skb因为rx_handler返回CONSUMED而结束。
3.5 数据包单端口转发:br_forward
将数据包转发到具体的一个端口中。
在这里插入图片描述

br_forward经过一系列的检查与前期准备,最终调用dev_queue_xmit(skb)将数据包放到网卡输出队列中发送出去。之后对应具体的网卡驱动函数。
3.6 数据包多播:br_multicast_flood
桥的多播功能需要使能igmp snooping功能(CONFIG_BRIDGE_IGMP_SNOOPING)。
转发端口获取:

  1. 从mdb中获取多播组的端口p1,
  2. 从br->router_list获取桥接端口p2
  3. p = p1>p2? p1: p2 (为什么这样比较)
  4. 若p=p1时,如果该端口支持多播对单播转发, 则以单播形式转发(也是调用br_forward,与普通多播貌似没有不同)
  5. 复制skb数据,调用br_forward从p端口转发数据
    下面br_flood一样每次转发的端口都是上次遍历得到的端口,最后一次端口使用原始skb数据,这样减少一次clone操作
    3.7 flood到各端口br_flood
    如果没有查找到对应的表项(单播、组播),或者要进行广播模式,系统调用br_flood,向网桥的每个端口转发。
    br_flood函数遍历网桥下的每个端口,根据允许的flags条件,调用deliver_clone或__br_forward将sbk从该端口转发出去。
1.	list_for_each_entry_rcu(p, &br->port_list, list) {  
2.	    switch (pkt_type) {  
3.	    case BR_PKT_UNICAST:  
4.	        if (!(p->flags & BR_FLOOD))  
5.	            continue;  
6.	        break;  
7.	    case BR_PKT_MULTICAST:  
8.	        if (!(p->flags & BR_MCAST_FLOOD) && skb->dev != br->dev)  
9.	            continue;  
10.	        if ((p->flags & BR_BPDU_FILTER) &&  
11.	            unlikely(is_link_local_ether_addr(dest) &&  
12.	                 dest[5] == 0))  
13.	            continue;  
14.	        break;  
15.	    case BR_PKT_BROADCAST:  
16.	        if (!(p->flags & BR_BCAST_FLOOD) && skb->dev != br->dev)  
17.	            continue;  
18.	        break;  
19.	    }  
20.	  
21.	    /* Do not flood to ports that enable proxy ARP */  
22.	    if (p->flags & BR_PROXYARP)  
23.	        continue;  
24.	    if ((p->flags & (BR_PROXYARP_WIFI | BR_NEIGH_SUPPRESS)) &&  
25.	        BR_INPUT_SKB_CB(skb)->proxyarp_replied)  
26.	        continue;  
27.	  	//向上个端口发送数据,而不是本次便利得到的端口
28.	    prev = maybe_deliver(prev, p, skb, local_orig);  
29.	    if (IS_ERR(prev))  
30.	        goto out;  
31.	}  
32.	  
33.	if (!prev)  
34.	    goto out;  
35.	  
36.	if (local_rcv)  
37.	    deliver_clone(prev, skb, local_orig);  
38.	else  
39.	    __br_forward(prev, skb, local_orig);  
40.	return;

在想每个端口转发时,都会复制一份新的skb数据。
maybe_deliver是deliver_clone的包装,每次遍历端口后,会在下一个遍历周期,将数据从本次遍历端口发送,最后一个端口的数据则是直接使用原始的skb,这样可以少一次数据复制开销。

4 二层对多播的处理

4.1 一些概念
IGMP 是Internet Group Management Protocol(互联网组管理协议)的简称。 它是TCP/IP 协议族中负责IP 组播成员管理的协议,用来在IP主机和与其直接相邻的组播路由器之间建立、维护组播组成员关系。
IGMP协议到目前已经有三个版本:
 IGMPv1 :主要基于查询和响应机制来完成对组播组成员的管理。 没有专门定义离开组播组的报文(leave messages)。 路由器使用基于超时的机制去发现其成员不关注的组并删除。
 IGMPv2:增加了查询器选举机制和离开组机制。允许迅速向路由协议报告组成员终止情况。在查询器选举机制上,当某共享网段上存在多个组播路由器时,组播路由协议选举IP地址最小的路由器成为查询器。
 IGMPv3:在兼容和继承IGMPv1和IGMPv2的基础上,进一步增强了主机的控制能力,并增强了查询和报告报文的功能。在增强主机的控制能力上,当某共享网段上存在多个组播路由器时,主机可选择性接收其中某一个组播路由器的消息,而屏蔽掉其他组播路由器的信息。在查询和报告报文的功能上,IGMPv3不仅支持IGMPv1的普遍组查询和IGMPv2的特定组查询,而且还增加了对特定源组查询的支持。

组播的IP地址:
 组播使用了D类IP地址,IP地址的范围为224.0.0.0-239.255.255.255。
 其中224.0.0.1代表子网上的所有计算机,224.0.0.2代表子网上的所有路由器。
 多播ip地址只可作为目的地址,而且不能生成关于多播地址的差错报文。

组播的mac地址:
 01-00-5e-xx-xx-xx,其中后23bits是ip地址的低23bits。以此将组播的IP地址与mac地址关联,知道多播的ip地址,不需经过ARP请求也能活得mac地址。

IGMP Snooping
监听IGMP协议包,形成组播成员关系表。通过侦听三层的IGMP协议报文来建立和维护二层组播功能,从而避免组播报文在二层设备中进行广播。若启用IGMP Snooping协议,则设备开始进行路由器端口和组播组的学习和老化操作,而后相应的组播组业务只在组播组内进行组播;若关闭IGMP Snooping协议,则设备停止对路由器端口和组播组的学习和老化操作,并删除所有已学习到的动态组播组。
4.2 组播组成员的管理
IGMP协议要求路由器定时向主机发送IGMP Query报文,用于获取主机是否仍在组播组内的信息。设备在学习到路由器端口之后,为此端口启动一老化定时器。当路由器端口老化定时器超时而在此端口上没有再收到查询报文时,则IGMP Snooping子系统认为此路由器端口失效,将其老化掉,依附路由器端口存在的组播组也会被删除。否则,重置该老化定时器。
组播路由器向下端的主机转发请求,主机收到查询请求后,会发送IGMP Report报文,报告该主机仍在该组播组中。当设备某一端口第一次接收到主机发出的IGMP响应报文后,首先将其加入相应的组播组,然后为此组播成员启动查询不响应次数的计数。当向某组播成员发送查询报文且在要求的响应周期内未收到应答报文,则对此组播成员的查询不响应次数计数,当计数值达到某一门限值时认为此组播成员已离开组播组,并将此组播成员删除,与此端口相连的设备将接收不到组播业务。如果在组成员端口达到最大不响应次数前收到了应答报文,则复位计数值。
若组播组成员主动离开组播组,则回想组播路由器发送IGMP离开报文,通知路由器自己离开了某个组播组。

组播组成员加入与移除的时机

主机从组播组加入的时机主机从组播组移除的时机
路由器端口向主机发送IGMP Query报文,主机第一次发送IGMP report报文确认自己在该组中1.路由器端在老化时间内未发送IGMP Query报文 2. 组播组成员未按要求发送IGMP report报文,认为该成员已离开 3.组播组成员主动发送离开报文

IGMP报文类型

报文类型说明
IGMP通用查询报文组播路由器向组播组成员发送的报文,用于查询哪些组播组存在成员。
IGMP特定组查询报文组播路由器向组播组成员发送的报文,用于查询特定组播组是否存在成员
IGMP报告报文组播组成员向组播路由器发送的报告报文,用于申请加入某个组播组或者应答IGMP查询报文。
IGMP离开报文组播组成员主动离开组播组时向组播路由器发送的报文,用于通知路由器自己离开了某个组播组。

4.3 代码实现
4.3.1 IGMP snooping初始化
有网桥设备添加到系统时,调用了br_dev_setup () -> br_multicast_init()函数,这里完成了关于IGMP snooping功能字段的初始化

1.	void br_multicast_init(struct net_bridge *br)  
2.	{  
3.	    br->hash_max = BR_MULTICAST_DEFAULT_HASH_MAX;  //mdb中hash数组的最大值  
4.	    br->multicast_router = MDB_RTR_TYPE_TEMP_QUERY;  
5.	    br->multicast_last_member_count = 2;  
6.	    br->multicast_startup_query_count = 2;  //发送查询数据包计数
7.	    br->multicast_last_member_interval = HZ;  
8.	    br->multicast_query_response_interval = 10 * HZ; //组播查询最大回复时间  
9.	    br->multicast_startup_query_interval = 125 * HZ / 4;  //开启发送查询报文的间隔时间  
10.	    br->multicast_query_interval = 125 * HZ; //查询包的发送间隔时间  
11.	    br->multicast_querier_interval = 255 * HZ;  
12.	    br->multicast_membership_interval = 260 * HZ;  
13.	  
14.	    br->ip4_other_query.delay_time = 0;  
15.	    br->ip4_querier.port = NULL;  
16.	    br->multicast_igmp_version = 2;  
17.	#if IS_ENABLED(CONFIG_IPV6)  
18.	    br->multicast_mld_version = 1;  
19.	    br->ip6_other_query.delay_time = 0;  
20.	    br->ip6_querier.port = NULL;  
21.	#endif  
22.	    br_opt_toggle(br, BROPT_MULTICAST_ENABLED, true);//设置br->options字段,表示多播使能  
23.	    br_opt_toggle(br, BROPT_HAS_IPV6_ADDR, true);  
24.	  
25.	    spin_lock_init(&br->multicast_lock);  
26.	    // 超时函数的注册  
27.	    timer_setup(&br->multicast_router_timer,  
28.	            br_multicast_local_router_expired, 0);  
29.	    timer_setup(&br->ip4_other_query.timer,  
30.	            br_ip4_multicast_querier_expired, 0);  
31.	    timer_setup(&br->ip4_own_query.timer,  
32.	            br_ip4_multicast_query_expired, 0);  
33.	#if IS_ENABLED(CONFIG_IPV6)  
34.	    timer_setup(&br->ip6_other_query.timer,  
35.	            br_ip6_multicast_querier_expired, 0);  
36.	    timer_setup(&br->ip6_own_query.timer,  
37.	            br_ip6_multicast_query_expired, 0);  
38.	#endif  
39.	    INIT_HLIST_HEAD(&br->mdb_list); //初始化mdb_list,用于快速遍历mdb表项  
40.	    INIT_HLIST_HEAD(&br->mcast_gc_list);   
41.	    INIT_WORK(&br->mcast_gc_work, br_multicast_gc_work); //多播组成员的垃圾回收队列,用于移除主机条目  
42.	} 

比较重要的是几个时间变量的初始化,以及定时器超时后的回调函数的绑定
 br_multicast_local_router_expired函数:补充
 br_ip4_multicast_querier_expired函数:补充
 br_ip4_multicast_query_expired函数:对于桥设备,该函数会发送一个组播通用查询包给上层进行处理,这时如果上层协议栈开启了igmp proxy功能,就会触发上层协议栈对lan侧的设备通用组播查询功能。对于上层开启igmp proxy功能,通过桥的查询定时器就能对lan侧设备进行周期的通用查询,然后就可以间接实现igmp snooping组播转发数据库的更新。
4.3.2 IGMP 查询报文发送
桥的igmp snooping功能开启之后,br->ip4_own_query.timer定时器就会打开计时,这块代码在函数br_multicast_open中完成。这部分是桥的定时查询功能。
而对于桥的端口设备来说,也有一个查询功能。
如果上层协议栈不支持igmp proxy功能,上层协议栈收到通用查询报文就会丢弃掉,就不会触发上层协议栈对lan侧设备的通用查询。这时桥设备的端口设备的查询功能就会发挥作用维护组播组。
2.2.3章讲了在一个端口加入网桥设备时会调用函数br_add_if,当该端口处于up状态且开启igmp snooping时,会调用函数__br_multicast_enable_port,启动一个立即到期的桥端口的查询定时器。

1.	static void __br_multicast_enable_port(struct net_bridge_port *port)  
2.	{  
3.	    struct net_bridge *br = port->br;  
4.	    if (!br_opt_get(br, BROPT_MULTICAST_ENABLED) || !netif_running(br->dev))
5.	        return;  
6.	    br_multicast_enable(&port->ip4_own_query);  //启用port定时器
7.	#if IS_ENABLED(CONFIG_IPV6)  
8.	    br_multicast_enable(&port->ip6_own_query);  
9.	#endif  
10.	    if (port->multicast_router == MDB_RTR_TYPE_PERM &&  
11.	        hlist_unhashed(&port->rlist))  
12.	        br_multicast_add_router(br, port);  
13.	}

定时器超时后调用超时处理函数br_ip4_multicast_port_query_expired,
在这里插入图片描述

该函数主要完成以下功能:

  1. 会构造一个通用组播组查询报文,
  2. 对于桥端口不为空时,则将该查询报文从相应的端口发送出去
  3. 对于桥端口为空时,则将该查询报文发送给上层协议栈
  4. 更新桥或者桥端口的查询定时器。
    4.3.3 IGMP报文接受处理
    以上是igmp查询数据包的定时发送过程。根据第三章的数据流在二层分发过程的分析,,对igmp协议包处理这一行为发生在函数br_handle_frame_finish中:
    br_handle_frame_finish
    –>br_multicast_rcv
    –>br_multicast_rcv
    –>br_multicast_ipv4_rcv
    1.对数据包的ip头部进行合理性检测,只有检查通过的数据包才会进行后续操作
    2.根据skb的igmp类型进行分发:
     对于非igmp报文的数据包,则直接返回
     对于v1、v2的igmp report报文,调用br_ip4_multicast_add_group进行处理
     对于v3的igmp report报文,调用br_ip4_multicast_igmp3_report进行处理
     对于igmp leave报文,调用br_ip4_multicast_leave_group进行处理
     对于igmp查询报文,调用br_ip4_multicast_query进行处理

5 参考


Linux下VLAN功能的实现:https://blog.csdn.net/lucien_cc/article/details/9381155

https://blog.csdn.net/hzj_001/article/details/104327771
https://blog.csdn.net/nw_nw_nw/category_6961218.html
https://zhuanlan.zhihu.com/p/550273312
https://zhuanlan.zhihu.com/p/550276410

igmp报文处理:
组播学习系列:https://www.twblogs.net/u/5c0ac8dfbd9eee6fb37bf925
对各种网络报文格式解析:https://support.huawei.com/enterprise/zh/doc/EDOC1100174722?section=j003

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值