zoukankan      html  css  js  c++  java
  • Netfilter&iptables:如何理解连接跟踪机制?

    如何理解Netfilter中的连接跟踪机制?

        本篇我打算以一个问句开头,因为在知识探索的道路上只有多问然后充分调动起思考的机器才能让自己走得更远。连接跟踪定义很简单:用来记录和跟踪连接的状态。

    问:为什么又需要连接跟踪功能呢?

    答:因为它是状态防火墙NAT的实现基础。

    OK,算是明白了。Neftiler为了实现基于数据连接状态侦测的状态防火墙功能和NAT地址转换功能才开发出了连接跟踪这套机制。那就意思是说:如果编译内核时开启了连接跟踪选项,那么Linux系统就会为它收到的每个数据包维持一个连接状态用于记录这条数据连接的状态。接下来我们就来研究一下Netfilter的连接跟踪的设计思想和实现方式。

        之前有一副图,我们可以很明确的看到:用于实现连接跟踪入口的hook函数以较高的优先级分别被注册到了netfitler的NF_IP_PRE_ROUTING和NF_IP_LOCAL_OUT两个hook点上;用于实现连接跟踪出口的hook函数以非常低的优先级分别被注册到了netfilter的NF_IP_LOCAL_IN和NF_IP_POST_ROUTING两个hook点上。

     

    其实PRE_ROUTING和LOCAL_OUT点可以看作是整个netfilter的入口,而POST_ROUTING和LOCAL_IN可以看作是其出口。在只考虑连接跟踪的情况下,一个数据包无外乎有以下三种流程可以走:

    一、发送给本机的数据包

    流程:PRE_ROUTING----LOCAL_IN---本地进程

    二、需要本机转发的数据包

    流程:PRE_ROUTING---FORWARD---POST_ROUTING---外出

    三、从本机发出的数据包

    流程:LOCAL_OUT----POST_ROUTING---外出

     

    我们都知道在INET层用于表示数据包的结构是大名鼎鼎的sk_buff{}(后面简称skb),如果你不幸的没听说过这个东东,那么我强烈的建议你先补一下网络协议栈的基础知识再继续阅读这篇文章。在skb中有个成员指针nfct,类型是struct nf_conntrack{},该结构定义在include/linux/skbuff.h文件中。该结构记录了连接记录被公开应用的计数,也方便其他地方对连接跟踪的引用。连接跟踪在实际应用中一般都通过强制类型转换将nfct转换成指向ip_conntrack{}类型(定义在include/linux/netfilter_ipv4/ip_conntrack.h里)来获取一个数据包所属连接跟踪的状态信息的。即:Neftilter框架用ip_conntrack{}来记录一个数据包与其连接的状态关系

    同时在include/linux/netfilter_ipv4/ip_conntrack.h文件中还提供了一个非常有用的接口:struct ip_conntrack *ip_conntrack_get(skb, ctinfo)用于获取一个skb的nfct指针,从而得知该数据包的连接状态和该连接状态的相关信息ctinfo。从连接跟踪的角度来看,这个ctinfo表示了每个数据包的几种连接状态:

    l  IP_CT_ESTABLISHED

    Packet是一个已建连接的一部分,在其初始方向。

    l  IP_CT_RELATED

    Packet属于一个已建连接的相关连接,在其初始方向。

    l  IP_CT_NEW

    Packet试图建立新的连接

    l  IP_CT_ESTABLISHED+IP_CT_IS_REPLY

    Packet是一个已建连接的一部分,在其响应方向。

    l  IP_CT_RELATED+IP_CT_IS_REPLY

    Packet属于一个已建连接的相关连接,在其响应方向。

        在连接跟踪内部,收到的每个skb首先被转换成一个ip_conntrack_tuple{}结构,也就是说ip_conntrack_tuple{}结构才是连接跟踪系统所“认识”的数据包。那么skb和ip_conntrack_tuple{}结构之间是如何转换的呢?这个问题没有一个统一的答案,与具体的协议息息相关。例如,对于TCP/UDP协议,根据“源、目的IP+源、目的端口”再加序列号就可以唯一的标识一个数据包了;对于ICMP协议,根据“源、目的IP+类型+代号”再加序列号才可以唯一确定一个ICMP报文等等。对于诸如像FTP这种应用层的“活动”协议来说情况就更复杂了。本文不试图去分析某种具体协议的连接跟踪实现,而是探究连接跟踪的设计原理和其工作流程,使大家掌握连接跟踪的精髓。因为现在Linux内核更新的太快的都到3.4.x,变化之大啊。就算是2.6.22和2.6.21在连接跟踪这块还是有些区别呢。一旦大家理解了连接跟踪的设计思想,掌握了其神韵,它再怎么也万变不离其宗,再看具体的代码实现时就不会犯迷糊了。俗话说“授人一鱼,不如授人一渔”,我们教给大家的是方法。有了方法再加上自己的勤学苦练,那就成了技能,最后可以使得大家在为自己的协议开发连接跟踪功能时心里有数。这也是我写这个系列博文的初衷和目的。与君共勉。

    在开始分析连接跟踪之前,我们还是站在统帅的角度来俯视一下整个连接跟踪的布局。这里我先用比较粗略的精简流程图为大家做个展示,目的是方便大家理解,好入门。当然,我的理解可能还有不太准确的地方,还请大牛们帮小弟指正。

        我还是重申一下:连接跟踪分入口和出口两个点。谨记:入口时创建连接跟踪记录,出口时将该记录加入到连接跟踪表中。我们分别来看看。

    入口:

    整个入口的流程简述如下:对于每个到来的skb,连接跟踪都将其转换成一个tuple结构,然后用该tuple去查连接跟踪表。如果该类型的数据包没有被跟踪过,将为其在连接跟踪的hash表里建立一个连接记录项,对于已经跟踪过了的数据包则不用此操作。紧接着,调用该报文所属协议的连接跟踪模块的所提供的packet()回调函数,最后根据状态改变连接跟踪记录的状态。

    出口:

    整个出口的流程简述如下:对于每个即将离开Netfilter框架的数据包,如果用于处理该协议类型报文的连接跟踪模块提供了helper函数,那么该数据包首先会被helper函数处理,然后才去判断,如果该报文已经被跟踪过了,那么其所属连接的状态,决定该包是该被丢弃、或是返回协议栈继续传输,又或者将其加入到连接跟踪表中。

    连接跟踪的协议管理:

        我们前面曾说过,不同协议其连接跟踪的实现是不相同的。每种协议如果要开发自己的连接跟踪模块,那么它首先必须实例化一个ip_conntrack_protocol{}结构体类型的变量,对其进行必要的填充,然后调用ip_conntrack_protocol_register()函数将该结构进行注册,其实就是根据协议类型将其设置到全局数组ip_ct_protos[]中的相应位置上。

         ip_ct_protos变量里保存连接跟踪系统当前可以处理的所有协议,协议号作为数组唯一的下标,如下图所示。

        结构体ip_conntrack_protocol{}中的每个成员,内核源码已经做了很详细的注释了,这里我就不一一解释了,在实际开发过程中我们用到了哪些函数再具体分析。

      连接跟踪的辅助模块:

        Netfilter的连接跟踪为我们提供了一个非常有用的功能模块:helper。该模块可以使我们以很小的代价来完成对连接跟踪功能的扩展。这种应用场景需求一般是,当一个数据包即将离开Netfilter框架之前,我们可以对数据包再做一些最后的处理。从前面的图我们也可以看出来,helper模块以较低优先级被注册到了Netfilter的LOCAL_OUT和POST_ROUTING两个hook点上。

    每一个辅助模块都是一个ip_conntrack_helper{}结构体类型的对象。也就是说,如果你所开发的协议需要连接跟踪辅助模块来完成一些工作的话,那么你必须也去实例化一个ip_conntrack_helper{}对象,对其进行填充,最后调用ip_conntrack_helper_register{}函数将你的辅助模块注册到全局变量helpers里,该结构是个双向链表,里面保存了当前已经注册到连接跟踪系统里的所有协议的辅助模块。

    全局helpers变量的定义和初始化在net/netfilter/nf_conntrack_helper.c文件中完成的。

    最后,我们的helpers变量所表示的双向链表一般都是像下图所示的这样子:

    由此我们基本上就可以知道,注册在Netfilter框架里LOCAL_OUT和POST_ROUTING两个hook点上ip_conntrack_help()回调函数所做的事情基本也就很清晰了:那就是通过依次遍历helpers链表,然后调用每个ip_conntrack_helper{}对象的help()函数。

    期望连接:

        Netfilter的连接跟踪为支持诸如FTP这样的“活动”连接提供了一个叫做“期望连接”的机制。我们都知道FTP协议服务端用21端口做命令传输通道,主动模式下服务器用20端口做数据传输通道;被动模式下服务器随机开一个高于1024的端口,然后客户端来连接这个端口开始数据传输。也就是说无论主、被动,都需要两条连接:命令通道的连接和数据通道的连接。连接跟踪在处理这种应用场景时提出了一个“期望连接”的概念,即一条数据连接和另外一条数据连接是相关的,然后对于这种有“相关性”的连接给出自己的解决方案。我们说过,本文不打算分析某种具体协议连接跟踪的实现。接下来我们就来谈谈期望连接。

        每条期望连接都用一个ip_conntrack_expect{}结构体类型的对象来表示,所有的期望连接存储在由全局变量ip_conntrack_expect_list所指向的双向链表中,该链表的结构一般如下:

             结构体ip_conntrack_expect{}中的成员及其意义在内核源码中也做了充分的注释,这里我就不逐一介绍了,等到需要的时候再详细探讨。

    连接跟踪表:

        说了半天终于到我们连接跟踪表抛头露面的时候了。连接跟踪表是一个用于记录所有数据包连接信息的hash散列表,其实连接跟踪表就是一个以数据包的hash值组成的一个双向循环链表数组,每条链表中的每个节点都是ip_conntrack_tuple_hash{}类型的一个对象。连接跟踪表是由一个全局的双向链表指针变量ip_conntrack_hash[]来表示。为了使我们更容易理解ip_conntrack_hash[]这个双向循环链表的数组,我们将前面提到的几个重要的目前还未介绍的结构ip_conntrack_tuple{}ip_conntrack{}ip_conntrack_tuple_hash{}分别介绍一下。

        我们可以看到ip_conntrack_tuple_hash{}仅仅是对ip_conntrack_tuple{}的封装而已,将其组织成了一个双向链表结构。因此,在理解层面上我们可以认为它们是同一个东西。

    在分析ip_conntrack{}结构时,我们将前面所有和其相关的数据结构都列出来,方便大家对其理解和记忆。

    该图可是说是连接跟踪部分的数据核心,接下来我们来详细说说ip_conntrack{}结构中相关成员的意义。

    l  ct_general:该结构记录了连接记录被公开应用的计数,也方便其他地方对连接跟踪的引用。

    l  status:数据包连接的状态,是一个比特位图。

    l  timeout:不同协议的每条连接都有默认超时时间,如果在超过了该时间且没有属于某条连接的数据包来刷新该连接跟踪记录,那么会调用这种协议类型提供的超时函数。

    l  counters:该成员只有在编译内核时打开了CONFIG_IP_NF_CT_ACCT开完才会存在,代表某条连接所记录的字节数和包数。

    l  master:该成员指向另外一个ip_conntrack{}。一般用于期望连接场景。即如果当前连接是另外某条连接的期望连接的话,那么该成员就指向那条我们所属的主连接。

    l  helper:如果某种协议提供了扩展模块,就通过该成员来调用扩展模块的功能函数。

    l  proto:该结构是ip_conntrack_proto{}类型,和我们前面曾介绍过的用于存储不同协议连接跟踪的ip_conntrack_protocol{}结构不要混淆了。前者是个枚举类型,后者是个结构体类型。这里的proto表示不同协议为了实现其自身的连接跟踪功能而需要的一些额外参数信息。目前这个枚举类型如下:

       如果将来你的协议在实现连接跟踪时也需要一些额外数据,那么可以对该结构进行扩充。

    l  help:该成员代表不同的应用为了实现其自身的连接跟踪功能而需要的一些额外参数信息,也是个枚举类型的ip_conntrack_help{}结构,和我们前面刚介绍过的结构体类型ip_conntrack_helpers{}容易混淆。ip_conntrack_proto{}是为协议层需要而存在的,而ip_conntrack_help{}是为应用层需要而存在。

    l  tuplehash:该结构是个ip_conntrack_tuple_hash{}类型的数组,大小为2。tuplehash[0]表示一条数据流“初始”方向上的连接情况,tuplehash[1]表示该数据流“应答”方向的响应情况,见上图所示。

        到目前为止,我们已经了解了连接跟踪设计思想和其工作机制:连接跟踪是Netfilter提供的一套基础框架,不同的协议可以根据其自身协议的特殊性在连接跟踪机制的指导和约束下来开发本协议的连接跟踪功能,最后将其交给连接跟踪机制来统一管理。

    本篇我们着重分析一下,数据包在连接跟踪系统里的旅程,以达到对连接跟踪运行原理深入理解的目的。

        连接跟踪机制在Netfilter框架里所注册的hook函数一共就五个:ip_conntrack_defrag()、ip_conntrack_in()、ip_conntrack_local()、ip_conntrack_help()

    和ip_confirm()。前几篇博文中我们知道ip_conntrack_local()最终还是调用了ip_conntrack_in()。这五个hook函数及其挂载点,想必现在大家应该也已经烂熟于心了,如果记不起来请看【上】篇博文。

        在连接跟踪的入口处主要有三个函数在工作:ip_conntrack_defrag()、ip_conntrack_in()、ip_conntrack_local();在出口处就两个:ip_conntrack_help()和ip_confirm()。

    接下来的事情就变得非常奇妙,请大家将自己当作一个需要转发的数据包,且是一条新的连接。然后跟随我去连接跟踪里耍一圈吧。在进入连接跟踪之前,我需要警告大家:连接跟踪虽然不会改变数据包本身,但是它可能会将数据包丢弃。

    我们的旅行的线路图已经有了:

     
     

    ip_conntrack_defrag()

        当我们初到连接跟踪门口的时候,是这位小生来招待我们。这个函数主要是完成IP报文分片的重新组装,将属于一个IP报文的多个分片重组成一个真正的报文。关于IP分片,大家可以去阅读《TCP/IP详解卷1》了解一点基础,至于IP分片是如何被重新组装一个完整的IP报文也不是我们的重心,这里不展开讲。该函数也向我们透露了一个秘密,那就是连接跟踪只跟踪完整的IP报文,不对IP分片进行跟踪,所有的IP分片都必须被还原成原始报文,才能进入连接跟踪系统。

    ip_conntrack_in()

        该函数的核心是resolve_normal_ct()函数所做的事情,其执行流程如下所示:
     

    在接下来的分析中,需要大家对上一篇文章提到的几个数据结构:

    ip_conntrack{}、ip_conntrack_tuple{}、ip_conntrack_tuple_hash{}和ip_conntrack_protocol{}以及它们的关系必须弄得很清楚,你才能彻底地读懂resolve_normal_ct()函数是干什么。最好手头再有一份2.6.21的内核源码,然后打开source insight来对照着阅读效果会更棒!

    第一步:ip_conntrack_in()函数首先根据数据包skb的协议号,在全局数组ip_ct_protos[]中查找某种协议(如TCP,UDP或ICMP等)所注册的连接跟踪处理模块ip_conntrack_protocol{},如下所示。

    在结构中,具体的协议必须提供将属于它自己的数据包skb转换成ip_conntrack_tuple{}结构的回调函数pkt_to_tuple()和invert_tuple(),用于处理新连接的new()函数等等。

    第二步:找到对应的协议的处理单元proto后,便调用该协议提供的错误校验函数(如果该协议提供的话)error来对skb进行合法性校验。

        第三步:调用resolve_normal_ct()函数。该函数的重要性不言而喻,它承担连接跟踪入口处剩下的所有工作。该函数根据skb中相关信息,调用协议提供的pkt_to_tuple()函数生成一个ip_conntrack_tuple{}结构体对象tuple。然后用该tuple去查找连接跟踪表,看它是否属于某个tuple_hash{}链。请注意,一条连接跟踪由两条ip_conntrack_tuple_hash{}链构成,一“去”一“回”,参见上一篇博文末尾部分的讲解。为了使大家更直观地理解连接跟踪表,我将画出来,如下图,就是个双向链表的数组而已。
     

    如果找到了该tuple所属于的tuple_hash链表,则返回该链表的地址;如果没找到,表明该类型的数据包没有被跟踪,那么我们首先必须建立一个ip_conntrack{}结构的实例,即创建一个连接记录项。

    然后,计算tuple的应答repl_tuple,对这个ip_conntrack{}对象做一番必要的初始化后,其中还包括,将我们计算出来的tuple和其反向tuple的地址赋给连接跟踪ip_conntrack里的tuplehash[IP_CT_DIR_ORIGINAL]和tuplehash[IP_CT_DIR_REPLY]。

    最后,把ip_conntrack->tuplehash[IP_CT_DIR_ORIGINAL]的地址返回。这恰恰是一条连接跟踪记录初始方向链表的地址。Netfilter中有一条链表unconfirmed,里面保存了所有目前还没有收到过确认报文的连接跟踪记录,然后我们的ip_conntrack->tuplehash[IP_CT_DIR_ORIGINAL]就会被添加到unconfirmed链表中。

    第四步:调用协议所提供的packet()函数,该函数承担着最后向Netfilter框架返回值的使命,如果数据包不是连接中有效的部分,返回-1,否则返回NF_ACCEPT。也就是说,如果你要为自己的协议开发连接跟踪功能,那么在实例化一个ip_conntrack_protocol{}对象时必须对该结构中的packet()函数做仔细设计。

    虽然我不逐行解释代码,只分析原理,但有一句代码还是要提一下。

    resolve_normal_ct()函数中有一行ct = tuplehash_to_ctrack(h)的代码,参见源代码。其中h是已存在的或新建立的ip_conntrack_tuple_hash{}对象,ct是ip_conntrack{}类型的指针。不要误以为这一句代码的是在创建ct对象,因为创建的工作在init_conntrack()函数中已经完成。本行代码的意思是根据ip_conntrack{}结构体中tuplehash[IP_CT_DIR_ORIGINAL]成员的地址,反过来计算其所在的结构体ip_conntrack{}对象的首地址,请大家注意。

    大家也看到ip_conntrack_in()函数只是创建了用于保存连接跟踪记录的ip_conntrack{}对象而已,并完成了对其相关属性的填充和状态的设置等工作。简单来说,我们这个数据包目前已经拿到连接跟踪系统办法的“绿卡”ip_conntrack{}了,但是还没有盖章生效。

    ip_conntrack_help()

    大家只要把我前面关于钩子函数在五个HOOK点所挂载情况的那张图记住,就明白ip_conntrack_help()函数在其所注册的hook点的位置了。当我们这个数据包所属的协议在其提供的连接跟踪模块时已经提供了ip_conntrack_helper{}模块,或是别人针对我们这种协议类型的数据包提供了扩展的功能模块,那么接下来的事儿就很简单了:

    首先,判断数据包是否拿到“绿卡”,即连接跟踪是否为该类型协议的包生成了连接跟踪记录项ip_conntrack{};

    其次,该数据包所属的连接状态不属于一个已建连接的相关连接,在其响应方向。

    两个条件都成立,就用该helper模块提供的help()函数去处理我们这个数据包skb。最后,这个help()函数也必须向Netfilter框架返回NF_ACCEPT或NF_DROP等值。任意一个条件不成立则ip_conntrack_help()函数直接返回NF_ACCEPT,我们这个数据包继续传输。

    ip_confirm()

        该函数是我们离开Netfilter时遇到的最后一个家伙了,如果我们这个数据包已经拿到了“绿卡”ip_conntrack{},并且我们这个数据包所属的连接还没收到过确认报文,并且该连接还未失效。然后,我们这个ip_confirm()函数要做的事就是:

        拿到连接跟踪为该数据包生成ip_conntrack{}对象,根据连接“来”、“去”方向tuple计算其hash值,然后在连接跟踪表ip_conntrack_hash[]见上图中查找是否已存在该tuple。如果已存在,该函数最后返回NF_DROP;如果不存在,则将该连接“来”、“去”方向tuple插入到连接跟踪表ip_conntrack_hash[]里,并向Netfilter框架返回NF_ACCEPT。之所以要再最后才将连接跟踪记录加入连接跟踪表是考虑到数据包可能被过滤掉。

        至此,我们本次旅行就圆满结束了。这里我们只分析了转发报文的情况。发送给本机的报文流程与此一致,而对于所有从本机发送出去的报文,其流程上唯一的区别就是在调用ip_conntrack_in()的地方换成了ip_conntrack_local()函数。前面说过,ip_conntrack_local()里面其实调用的还是ip_conntrack_in()。ip_conntrack_local()里只是增加了一个特性:那就是对于从本机发出的小数据包不进行连接跟踪。

    连接跟踪系统的初始化流程分析

        有了前面的知识,我们再分析连接跟踪系统的初始化ip_conntrack_standalone_init()函数就太容易不过了。还是先上ip_conntrack_standalone_init()函数的流程图:

    该函数的核心上图已经标出来了“初始化连接跟踪系统”和“注册连接跟踪的hook函数”。其他两块这里简单做个普及,不展开讲。至少让大家明白连接跟踪为什么需要两中文件系统。

    1、  procfs(/proc文件系统)

    这是一个虚拟的文件系统,通常挂载在/proc,允许内核以文件的形式向用户空间输出内部信息。该目录下的所有文件并没有实际存在在磁盘里,但可以通过cat、more或>shell重定向予以写入,这些文件甚至可以像普通文件那样指定其读写权限。创建这些文件的内核组件可以说明任何一个文件可以由谁读取或写入。但是:用户不能在/proc目录下新增,移除文件或目录

    2、  sysctl(/proc/sys目录)

    此接口允许用户空间读取或修改内核变量的值。不能用此接口对每个内核变量进行操作:内核应该明确指出哪些变量从此接口对用户空间是可见的。从用户空间,你可以用两种方式访问sysctl输出的变量:sysctl系统调用接口;procfs。当内核支持procfs文件系统时,会在/proc中增加一个特殊目录(/proc/sys),为每个由sysctl所输出的内核变量引入一个文件,我们通过对这些文件的读写操作就可以影响到内核里该变量的值了。

        除此之外还有一种sysfs文件系统,这里就不介绍了,如果你感兴趣可以去研读《Linux设备驱动程序》一书的详细讲解。

        那么回到我们连接跟踪系统里来,由此我们可以知道:连接跟踪系统向用户空间输出一些内核变量,方便用户对连接跟踪的某些特性加以灵活控制,如改变最大连接跟踪数、修改TCP、UDP或ICMP协议的连接跟踪超时是时限等等。

        注意一点:/proc/sys目录下的任何一个文件名,对应着内核中有一个一模一样同名的内核变量。例如,我的系统中该目录是如下这个样子:

    ip_conntrack_init()函数

        该函数承担了连接跟踪系统初始化的绝大部分工作,其流程我们也画出来了,大家可以对照源码来一步一步分析。

        第一步:连接跟踪的表大小跟系统内存相关,而最大连接跟踪数和连接跟踪表容量的关系是:最大连接跟踪数=8×连接跟踪表容量。代码中是这样的:

    ip_conntrack_max = 8 × ip_conntrack_htable_size;那么从上面的图我们可以看出来,我们可以通过手工修改/proc/sys/net/ipv4/netfilter目录下同名的ip_conntrack_max文件即可动态修改连接系统的最大连接跟踪数了。

        第二步:注册Netfilter所用的sockopt,先不讲,以后再说。只要知道是这里注册的就行了。

        第三步:为连接跟踪hash表ip_conntrack_hash分配内存并进行初始化。并创建连接跟踪和期望连接跟踪的高速缓存。

        第四步:将TCP、UDP和ICMP协议的连接跟踪协议体,根据不同协议的协议号,注册到全局数组ip_ct_protos[]中,如下所示:

     

        最后再做一些善后工作,例如注册DROP这个target所需的功能函数,为其他诸如NAT这样的模块所需的参数ip_conntrack_untracked做初始化,关于这个参数我们在NAT模块中再详细讨论它。

        这样,我们连接跟踪系统的初始化工作就算彻底完成了。有了前几篇关于连接跟踪的基础知识,再看代码是不是有种神清气爽,豁然开朗的感觉。

        至于连接跟踪系统所提供的那五个hook函数的注册,我想现在的你应该连都不用看就知道它所做的事情了吧。

  • 相关阅读:
    idea安装好python后显示无SDK问题
    使用idea在windows上连接远程hadoop开发_配置环境
    最小二乘法估计----MATLAB最小二乘法求一元线性回归
    MATLAB最小二乘法求线性回归
    MATLAB求解线性规划(含整数规划和0-1规划)问题
    蒙特卡洛方法蒙特卡洛方法 matlab 实现 matlab 实现
    MATLAB神经网络实例及训练结果各参数解释
    单元格添加斜线
    ppt的高级设计法——虚实结合
    word中插入六角括号的方法﹝﹞
  • 原文地址:https://www.cnblogs.com/timssd/p/4271445.html
Copyright © 2011-2022 走看看