• skb详细解析【转】


     
    摘自:http://blog.chinaunix.net/uid-30035229-id-4883992.html
     
     
    在自己的模块发送函数中,需要对skb进行重新构造和别的一些操作。在网上看到一个写的还可以的,粘过来,就不自己写了,估计这个哥们也是看<Understanding Linux Network Internals>翻译或者总结的。
     
    ------------------------------------------------

    1.   定义

    Packet:       通过网卡收发的报文,包括链路层、网络层、传输层的协议头和携带的数据
    Data Buffer:用于存储 packet 的内存空间
    SKB:           struct sk_buffer 的简写
     
     

    2.   概述

    Struct sk_buffer 是 linux TCP/IP stack 中,用于管理Data Buffer的结构。Sk_buffer 在数据包的发送和接收中起着重要的作用。
    为了提高网络处理的性能,应尽量避免数据包的拷贝。Linux 内核开发者们在设计 sk_buffer 结构的时候,充分考虑到这一点。目前 Linux 协议栈在接收数据的时候,需要拷贝两次:数据包进入网卡驱动后拷贝一次,从内核空间递交给用户空间的应用时再拷贝一次。
    Sk_buffer结构随着内核版本的升级,也一直在改进。
    学习和理解 sk_buffer 结构,不仅有助于更好的理解内核代码,而且也可以从中学到一些设计技巧。
     
     

    3.   Sk_buffer 定义

    struct sk_buff {
                    struct sk_buff                     *next;
                    struct sk_buff                     *prev;
                    struct sock                          *sk;
                    struct skb_timeval             tstamp;
                    struct net_device         *dev;
                    struct net_device         *input_dev;
     
                    union {
                                    struct tcphdr       *th;
                                    struct udphdr      *uh;
                                    struct icmphdr    *icmph;
                                    struct igmphdr    *igmph;
                                    struct iphdr          *ipiph;
                                    struct ipv6hdr      *ipv6h;
                                    unsigned char     *raw;
                    } h;
                    union {
                                    struct iphdr          *iph;
                                    struct ipv6hdr      *ipv6h;
                                    struct arphdr       *arph;
                                    unsigned char     *raw;
                    } nh;
                    union {
                                    unsigned char     *raw;
                    } mac;
     
                    struct  dst_entry                 *dst;
                    struct     sec_path              *sp;
                    char                                       cb[40];
     
                    unsigned int                         len,
                                                                    data_len,
                                                                    mac_len,
                                                                    csum;
                    __u32                                    priority;
     
                    __u8                                       local_df:1,
                                                                    cloned:1,
                                                                    ip_summed:2,
                                                                    nohdr:1,
                                                                    nfctinfo:3;
                    __u8                                       pkt_type:3,
                                                                    fclone:2;
                    __be16                                  protocol;
                    void                                        (*destructor)(struct sk_buff *skb);
     
                    /* These elements must be at the end, see alloc_skb() for details.  */
                    unsigned int                         truesize;
                    atomic_t                               users;
                    unsigned char                     *head,
                                                    *data,
                                                    *tail,
                                                    *end;
    };
     
     

    4.   成员变量

     
    ·              struct skb_timeval    tstamp;
    此变量用于记录 packet 的到达时间或发送时间。由于计算时间有一定开销,因此只在必要时才使用此变量。需要记录时间时,调用net_enable_timestamp(),不需要时,调用net_disable_timestamp() 。
    tstamp 主要用于包过滤,也用于实现一些特定的 socket 选项,一些 netfilter 的模块也要用到这个域。
    ·              struct net_device      *dev;
    ·              struct net_device      *input_dev;
     
    这几个变量都用于跟踪与 packet 相关的 device。由于 packet 在接收的过程中,可能会经过多个 virtual driver 处理,因此需要几个变量。
    接收数据包的时候, dev 和 input_dev 都指向最初的 interface,此后,如果需要被 virtual driver 处理,那么 dev 会发生变化,而 input_dev 始终不变。
     
     
    (These three members help keep track of the devices assosciated with a packet. The reason we have three different device pointers is that the main 'skb->dev' member can change as we encapsulate and decapsulate via a virtual device.
    So if we are receiving a packet from a device which is part of a bonding device instance, initially 'skb->dev' will be set to point the real underlying bonding slave. When the packet enters the networking (via 'netif_receive_skb()') we save 'skb->dev' away in 'skb->real_dev' and update 'skb->dev' to point to the bonding device.
    Likewise, the physical device receiving a packet always records itself in 'skb->input_dev'. In this way, no matter how many layers of virtual devices end up being decapsulated, 'skb->input_dev' can always be used to find the top-level device that actually received this packet from the network. )
     
    ·              char                               cb[40];
    此数组作为 SKB 的控制块,具体的协议可用它来做一些私有用途,例如 TCP 用这个控制块保存序列号和重传状态。
     
     
    ·              unsigned int               len,
    ·                                                   data_len,
    ·                                                   mac_len,
    ·                                                   csum;
     
    ‘len’ 表示此 SKB 管理的 Data Buffer 中数据的总长度;
    通常,Data Buffer 只是一个简单的线性 buffer,这时候 len 就是线性 buffer 中的数据长度;
    但在有 ‘paged data’ 情况下, Data Buffer 不仅包括第一个线性 buffer ,还包括多个 page buffer;这种情况下, ‘data_len’ 指的是 page buffer 中数据的长度,’len’ 指的是线性 buffer 加上 page buffer 的长度;len – data_len 就是线性 buffer 的长度。
     
    ‘mac_len’ 指 MAC 头的长度。目前,它只在 IPSec 解封装的时候被使用。将来可能从 SKB 结构中
    去掉。
    ‘csum’ 保存 packet 的校验和。
    (Finally, 'csum' holds the checksum of the packet. When building send packets, we copy the data in from userspace and calculate the 16-bit two's complement sum in parallel for performance. This sum is accumulated in 'skb->csum'. This helps us compute the final checksum stored in the protocol packet header checksum field. This field can end up being ignored if, for example, the device will checksum the packet for us.
    On input, the 'csum' field can be used to store a checksum calculated by the device. If the device indicates 'CHECKSUM_HW' in the SKB 'ip_summed' field, this means that 'csum' is the two's complement checksum of the entire packet data area starting at 'skb->data'. This is generic enough such that both IPV4 and IPV6 checksum offloading can be supported. )
     
    ·              __u32                            priority;
     
    “priority”用于实现 QoS,它的值可能取之于 IPv4 头中的 TOS 域。Traffic Control 模块需要根据这个域来对 packet 进行分类,以决定调度策略。
     
     
    ·              __u8                              local_df:1,
    ·                                                   cloned:1,
    ·                                                   ip_summed:2,
    ·                                                   nohdr:1,
    ·                                                   nfctinfo:3;
     
     
    为了能迅速的引用一个 SKB 的数据,
    当 clone 一个已存在的 SKB 时,会产生一个新的 SKB,但是这个 SKB 会共享已有 SKB 的数据区。
    当一个 SKB 被 clone 后,原来的 SKB 和新的 SKB 结构中,”cloned” 都要被设置为1。
     
    (The 'local_df' field is used by the IPV4 protocol, and when set allows us to locally fragment frames which have already been fragmented. This situation can arise, for example, with IPSEC.
    The 'nohdr' field is used in the support of TCP Segmentation Offload ('TSO' for short). Most devices supporting this feature need to make some minor modifications to the TCP and IP headers of an outgoing packet to get it in the right form for the hardware to process. We do not want these modifications to be seen by packet sniffers and the like. So we use this 'nohdr' field and a special bit in the data area reference count to keep track of whether the device needs to replace the data area before making the packet header modifications.
    The type of the packet (basically, who is it for), is stored in the 'pkt_type' field. It takes on one of the 'PACKET_*' values defined in the 'linux/if_packet.h' header file. For example, when an incoming ethernet frame is to a destination MAC address matching the MAC address of the ethernet device it arrived on, this field will be set to 'PACKET_HOST'. When a broadcast frame is received, it will be set to 'PACKET_BROADCAST'. And likewise when a multicast packet is received it will be set to 'PACKET_MULTICAST'.
    The 'ip_summed' field describes what kind of checksumming assistence the card has provided for a receive packet. It takes on one of three values: 'CHECKSUM_NONE' if the card provided no checksum assistence, 'CHECKSUM_HW' if the two's complement checksum over the entire packet has been provides in 'skb->csum', and 'CHECKSUM_UNNECESSARY' if it is not necessary to verify the checksum of this packet. The latter usually occurs when the packet is received over the loopback device. 'CHECKSUM_UNNECESSARY' can also be used when the device only provides a 'checksum OK' indication for receive packet checksum offload. )
     
    ·              void                               (*destructor)(struct sk_buff *skb);
    ·              unsigned int               truesize;
     
    一个 SKB 所消耗的内存包括 SKB 本身和 data buffer。
    truesize 就是 data buffer 的空间加上 SKB 的大小。
    struct sock 结构中,有两个域,用于统计用于发送的内存空间和用于接收的内存空间,它们是:
    rmem_alloc
    wmem_alloc
     
    另外两个域则统计接收到的数据包的总大小和发送的数据包的总大小。
    rcvbuf
    sndbuf
     
    rmem_alloc 和 rcvbuf,wmem_alloc 和sndbuf 用于不同的目的。
     
    当我们收到一个数据包后,需要统计这个 socket 总共消耗的内存,这是通过skb_set_owner_r() 来做的。
     
    static inline void skb_set_owner_r(struct sk_buff *skb, struct sock *sk)
    {
            skb->sk = sk;
            skb->destructor = sock_rfree;
            atomic_add(skb->truesize, &sk->sk_rmem_alloc);
    }
     最后,当释放一个 SKB 后,需要调用 skb->destruction() 来减少rmem_alloc 的值。
     
    同样,在发送一个 SKB 的时候,需要调用skb_set_owner_w() ,
     
    static inline void skb_set_owner_w(struct sk_buff *skb, struct sock *sk)
    {
            sock_hold(sk);
            skb->sk = sk;
            skb->destructor = sock_wfree;
            atomic_add(skb->truesize, &sk->sk_wmem_alloc);
    }
     在释放这样的一个 SKB 的时候,需要 调用 sock_free() 
     
    void sock_wfree(struct sk_buff *skb)
    {
            struct sock *sk = skb->sk;
     
            /* In case it might be waiting for more memory. */
            atomic_sub(skb->truesize, &sk->sk_wmem_alloc);
            if (!sock_flag(sk, SOCK_USE_WRITE_QUEUE))
                   sk->sk_write_space(sk);
            sock_put(sk);
    }
     
     
    (Another subtle issue is worth pointing out here. For receive buffer accounting, we do not grab a reference to the socket (via 'sock_hold()'), because the socket handling code will always make sure to free up any packets in it's receive queue before allowing the socket to be destroyed. Whereas for send packets, we have to do proper accounting with 'sock_hold()' and 'sock_put()'. Send packets can be freed asynchronously at any point in time. For example, a packet could sit in a devices transmit queue for a long time under certain conditions. If, meanwhile, the socket is closed, we have to keep the socket reference around until SKBs referencing that socket are liberated. )
     
    ·              unsigned char                        *head,
    ·                                                   *data,
    ·                                                   *tail,
    ·                                                   *end;
     SKB 对 Data Buffer 的巧妙管理,就是靠这四个指针实现的。
     
    下图展示了这四个指针是如何管理数据 buffer 的:
    Head 指向 buffer 的开始,end 指向 buffer 结束。 Data 指向实际数据的开始,tail 指向实际数据的结束。这四个指针将整个 buffer 分成三个区:
     
    Packet data:这个空间保存的是真正的数据
    Head room:处于 packet data 之上的空间,是一个空闲区域
    Tail room:处于 packet data 之下的空间,也是空闲区域。
     
    由于 TCP/IP 协议族是一种分层的协议,传输层、网络层、链路层,都有自己的协议头,因此 TCP/IP 协议栈对于数据包的处理是比较复杂的。为了提高处理效率,避免数据移动、拷贝,sk_buffer 在对数据 buffer 管理的时候,在 packet data 之上和之下,都预留了空间。如果需要增加协议头,只需要从 head room 中拿出一块空间即可,而如果需要增加数据,则可以从 tail room 中获得空间。这样,整个内存只分配一次空间,此后 协议的处理,只需要挪动指针。

    5.   Sk_buffer 对内存的管理

    我们以构造一个用于发送的数据包的过程,来理解 sk_buffer 是如何管理内存的。
     

    5.1.                    构造Skb_buffer

     
    alloc_skb() 用于构造 skb_buffer,它需要一个参数,指定了存放 packet 的空间的大小。
    构造时,不仅需要创建 skb_buffer 结构本身,还需要分配空间用于保存 packet。
     
    skb = alloc_skb(len, GFP_KERNEL);
      
    上图是在调用完 alloc_skb() 后的情况:
     
    head, data, tail 指向 buffer 开始,end 指向 buffer 结束,整个 buffer 都被当作 tail room。
    Sk_buffer 当前的数据长度是0。
     
     

    5.2.                     protocol header 留出空间

     
    通常,当构造一个用于发送的数据包时,需要留出足够的空间给协议头,包括 TCP/UDP header, IP header 和链路层头。
    对 IPv4 数据包,可以从 sk->sk_prot->max_header 知道协议头的最大长度。
     
    skb_reserve(skb, header_len);
     
     
      
    图是调用 skb_reserver() 后的情况
     
     

    5.3.                    将用户空间数据拷贝到 buffer 

     
    首先通过 skb_put(skb, user_data_len) ,从 tail room 中留出用于保存数据的空间
    然后通过csum_and_copy_from_user() 将数据从用户空间拷贝到这个空间中。
     
     

    5.4.                    构造UDP协议头

     
    通过 skb_push() ,向 head room 中要一块空间
    然后在此空间中构造 UDP 头。
     
     
     
     
     
     
     
     
     
     
     
     
     
     
     
     
     

    5.5.                    构造 IP 

     
    通过 skb_push() ,向 head room 中要一块空间
    然后在此空间中构造 IP 头。

     

     
    6.   Sk_buffer 的秘密
    当调用 alloc_skb() 构造 SKB 和 data buffer时,需要的 buffer 大小是这样计算的:
     
    data = kmalloc(size + sizeof(struct skb_shared_info), gfp_mask);
     
    除了指定的 size 以外,还包括一个 struct skb_shared_info 结构的空间大小。也就是说,当调用 alloc_skb(size) 要求分配 size 大小的 buffer 的时候,同时还创建了一个 skb_shared_info 。
     
    这个结构定义如下:
     
    struct skb_shared_info {
                atomic_t            dataref;
                unsigned int       nr_frags;
                unsigned short   tso_size;
                unsigned short   tso_segs;
                struct sk_buff     *frag_list;
                skb_frag_t         frags[MAX_SKB_FRAGS];
    };
     
     
     
    我们只要把 end 从 char* 转换成skb_shared_info* ,就能访问到这个结构
    Linux 提供一个宏来做这种转换:
     
    #define skb_shinfo(SKB)             ((struct skb_shared_info *)((SKB)->end))
     
     
     
    那么,这个隐藏的结构用意何在?
    它至少有两个目的:
    1、  用于管理 paged data
    2、  用于管理分片
     
    接下来分别研究 sk_buffer 对paged data 和分片的处理。
     

    7.    paged data 的处理

     
    某些情况下,希望能将保存在文件中的数据,通过 socket 直接发送出去,这样,避免了把数据先从文件拷贝到缓冲区,从而提高了效率。
    Linux 采用一种 “paged data” 的技术,来提供这种支持。这种技术将文件中的数据直接被映射为多个 page。
     
    Linux 用 struct skb_frag_strut 来管理这种 page:
     
    typedef struct skb_frag_struct skb_frag_t;
     
    struct skb_frag_struct {
            struct page *page;
            __u16 page_offset;
            __u16 size;
    };
     
    并在shared info 中,用数组 frags[] 来管理这些结构。
     
     
    如此一来,sk_buffer 就不仅管理着一个 buffer 空间的数据了,它还可能通过 share info 结构管理一组保存在 page 中的数据。
     
    在采用 “paged data” 时,data_len 成员派上了用场,它表示有多少数据在 page 中。因此,
    如果 data_len 非0,这个 sk_buffer 管理的数据就是“非线性”的。
    Skb->len – skb->data_len 就是非 paged 数据的长度。
     
    在有 “paged data” 情况下, skb_put()就无法使用了,必须使用 pskb_put() 。。。
     
     
     
     
     
     
     
     
     
     
     
     

    8.   对分片的处理

     

    9.   SKB 的管理函数

     

    9.1.                    Data Buffer 的基本管理函数

     
    ·              unsigned char *skb_put(struct sk_buff *skb, unsigned int len)
     
    “推”入数据
    在 buffer 的结束位置,增加数据,len是要增加的长度。
    这个函数有两个限制,需要调用者自己注意,否则后果由调用者负责
    1)、不能用于 “paged data” 的情况
    这要求调用者自己判断是否为 “paged data” 情况
    2)、增加新数据后,长度不能超过 buffer 的实际大小。
    这要求调用者自己计算能增加的数据大小
     
    ·              unsigned char *skb_push(struct sk_buff *skb, unsigned int len)
    “压”入数据
    从 buffer 起始位置,增加数据,len 是要增加的长度。
    实际就是将新的数据“压”入到 head room 中
     
    ·              unsigned char *skb_pull(struct sk_buff *skb, unsigned int len)
    “拉”走数据
    从 buffer 起始位置,去除数据, len 是要去除的长度。
    如果 len 大于 skb->len,那么,什么也不做。
    在处理接收到的 packet 过程中,通常要通过 skb_pull() 将最外层的协议头去掉;例如当网络层处理完毕后,就需要将网络层的 header 去掉,进一步交给传输层处理。
     
     
    ·              void skb_trim(struct sk_buff *skb, unsigned int len)
    调整 buffer 的大小,len 是调整后的大小。
    如果 len 比 buffer 小,则不做调整。
    因此,实际是将 buffer 底部的数据去掉。
    对于没有 paged data 的情况,很好处理;
    但是有 paged data 情况下,则需要调用 __pskb_trim() 来进行处理。
     
     
     

    9.2.                    “Paged data”  分片的管理函数

     
    ·              char *pskb_pull(struct sk_buff *skb, unsigned int len)
     
    “拉“走数据
    如果 len 大于线性 buffer 中的数据长度,则调用__pskb_pull_tail()  进行处理。
    (Q:最后, return skb->data += len;  是否会导致 skb->data 超出了链头范围?)
     
    ·              int pskb_may_pull(struct sk_buff *skb, unsigned int len)
    在调用 skb_pull() 去掉外层协议头之前,通常先调用此函数判断一下是否有足够的数据用于“pull”。
    如果线性 buffer足够 pull,则返回1;
    如果需要 pull 的数据超过 skb->len,则返回0;
    最后,调用__pskb_pull_tail() 来检查 page buffer 有没有足够的数据用于 pull。
     
     
    ·              int pskb_trim(struct sk_buff *skb, unsigned int len)
    将 Data Buffer 的数据长度调整为 len
    在没有 page buffer 情况下,等同于 skb_trim();
    在有 page buffer 情况下,需要调用___pskb_trim() 进一步处理。
     
    ·              int skb_linearize(struct sk_buff *skb, gfp_t gfp)
     
     
    ·              struct sk_buff *skb_clone(struct sk_buff *skb, gfp_t gfp_mask)
    ‘clone’ 一个新的 SKB。新的 SKB 和原有的 SKB 结构基本一样,区别在于:
    1)、它们共享同一个 Data Buffer
    2)、它们的 cloned 标志都设为1
    3)、新的 SKB 的 sk 设置为空
    (Q:在什么情况下用到克隆技术?)
     
    ·              struct sk_buff *skb_copy(const struct sk_buff *skb, gfp_t gfp_mask)
     
    ·              struct sk_buff *pskb_copy(struct sk_buff *skb, gfp_t gfp_mask)
     
    ·              struct sk_buff *skb_pad(struct sk_buff *skb, int pad)
     
    ·              void skb_clone_fraglist(struct sk_buff *skb)
     
    ·              void skb_drop_fraglist(struct sk_buff *skb)
     
    ·              void copy_skb_header(struct sk_buff *new, const struct sk_buff *old)
     
    ·              pskb_expand_head(struct sk_buff *skb, int nhead, int ntail, gfp_t gfp_mask)
     
    ·              int skb_copy_bits(const struct sk_buff *skb, int offset, void *to, int len)
     
    ·              int skb_store_bits(const struct sk_buff *skb, int offset, void *from, int len)
     
    ·              struct sk_buff *skb_dequeue(struct sk_buff_head *list)
     
    ·              struct sk_buff *skb_dequeue(struct sk_buff_head *list)
     
    ·              void skb_queue_purge(struct sk_buff_head *list)
     
    ·              void skb_queue_purge(struct sk_buff_head *list)
     
    ·              void skb_queue_tail(struct sk_buff_head *list, struct sk_buff *newsk)
     
    ·              void skb_unlink(struct sk_buff *skb, struct sk_buff_head *list)
     
    ·              void skb_append(struct sk_buff *old, struct sk_buff *newsk, struct sk_buff_head *list)
     
    ·              void skb_insert(struct sk_buff *old, struct sk_buff *newsk, struct sk_buff_head *list)
     
    ·              int skb_add_data(struct sk_buff *skb, char __user *from, int copy)
     
    ·              struct sk_buff *skb_padto(struct sk_buff *skb, unsigned int len)
     
    ·              int skb_cow(struct sk_buff *skb, unsigned int headroom)
     
    这个函数要对 SKB 的 header room 调整,调整后的 header room 大小是 headroom.
    如果 headroom 长度超过当前header room 的大小,或者 SKB 被 clone 过,那么需要调整,方法是:
    分配一块新的 data buffer 空间,SKB 使用新的 data buffer 空间,而原有空间的引用计数减1。在没有其它使用者的情况下,原有空间被释放。
     
     
     
    ·              struct sk_buff *dev_alloc_skb(unsigned int length)
     
    ·              void skb_orphan(struct sk_buff *skb)
     
    ·              void skb_reserve(struct sk_buff *skb, unsigned int len)
     
    ·              int skb_tailroom(const struct sk_buff *skb)
     
    ·              int skb_headroom(const struct sk_buff *skb)
     
    ·              int skb_pagelen(const struct sk_buff *skb)
     
    ·              int skb_headlen(const struct sk_buff *skb)
     
    ·              int skb_is_nonlinear(const struct sk_buff *skb)
     
    ·              struct sk_buff *skb_share_check(struct sk_buff *skb, gfp_t pri)
     
    如果skb 只有一个引用者,直接返回 skb
    否则 clone 一个 SKB,将原来的 skb->users 减1,返回新的 SKB
     
     
    需要特别留意 pskb_pull() 和 pskb_may_pull() 是如何被使用的:
     
    1)、在接收数据的时候,大量使用 pskb_may_pull(),其主要目的是判断 SKB 中有没有足够的数据,例如在 ip_rcv() 中:
     
    if (!pskb_may_pull(skb, sizeof(struct iphdr)))
                            goto inhdr_error;
     
    iph = skb->nh.iph;
     
    它的目的是拿到 IP header,但取之前,先通过 pskb_may_pull() 判断一下有没有足够一个 IP header 的数据。
     
     
    2)、当我们构造 IP 分组的时候,对于数据部分,通过 put向下扩展空间(如果一个sk_buffer 不够用怎么分片?);对于 传输层、网络层、链路层的头,通过 push 向上扩展空间;
     
    3)、当我们解析 IP 分组的时候,通过 pull(),从头开始,向下压缩空间。
     
    因此,put 和 push 主要用在发送数据包的时候;而pull 主要用在接收数据包的时候。
     
     
     
     
     
     

    10.                     各种 header

     
    union {
                            struct tcphdr      *th;
                            struct udphdr     *uh;
                            struct icmphdr   *icmph;
                            struct igmphdr   *igmph;
                            struct iphdr        *ipiph;
                            struct ipv6hdr     *ipv6h;
                            unsigned char    *raw;
                } h;
     
                union {
                            struct iphdr        *iph;
                            struct ipv6hdr     *ipv6h;
                            struct arphdr      *arph;
                            unsigned char    *raw;
                } nh;
     
                union {
                            unsigned char    *raw;
                } mac;
     
    --------------------------------------------
     
    其中的第五部分正是我需要的,在此向上面朋友的辛苦表示感谢。
  • 相关阅读:
    解决 git 同步时 Everything up-to-date
    vs2019 git Authentication failed for xxx
    vs2015发布项目到虚拟主机组策略阻止csc.exe程序问题
    vs2017 使用 reportviewer
    var,dynamic的用法
    水晶报表报无法在资源中找到报表,请重新创建项目 错误
    css隐藏元素的方法
    css-浮动与清除浮动的原理详解(清除浮动的原理你知道吗)
    正则并不适合严格查找子串
    浏览器加载、渲染过程总结
  • 原文地址:https://www.cnblogs.com/LiuYanYGZ/p/7566296.html
Copyright © 2020-2023  润新知