博客
关于我
强烈建议你试试无所不能的chatGPT,快点击我
kernel源码分析
阅读量:5254 次
发布时间:2019-06-14

本文共 13836 字,大约阅读时间需要 46 分钟。

精贴:

 

 

分类: LINUX

2012-05-25 18:05:05

 

预备知识:

part1: 初见getopt_long()   

在分析iptables源码时,作为命令解析的核心函数getopt_long()不得不提。随便百度或google搜索关于该函数的介绍有很多例子和解释,这里我只举一例,目的是让大家了解传递给iptables命令的每个参数是如何被正确识别并处理的。

getopt_long(int argc,char * const argv[],const char *optstring,const struct option *longopts,int *longindex)

    参数说明:

    argcargv来自main函数的输入;

    optstring:表示可以接受的参数。可以是下列值:1.单个字符,表示选项;2.单个字符后接一个冒号“:”表示该选项后必须跟一个参数。参数紧跟在选项后或者以空格隔开,该参数的指针赋给optarg。3.单个字符后跟两个冒号,表示该选项后必须跟一个参数。参数必须紧跟在选项后不能以空格隔开。该参数的指针赋给optarg。(这个特性是GNU的扩展)。例如,"a:b:cd",表示可以接受的参数选项是a,b,c,d,其中a和b参数后面跟有参数值。

    longopts:是一个结构类型,描述如下:

        struct option {
                const char *name;    //name表示的是长参数名
                int has_arg;         //0-无参;1-一定要有;2-可有可无
                int *flag;          //用来决定,getopt_long()的返回值到底是什么。                                                                                         //如果flag是null,则函数会返回与该项option匹配的val值。                    

                int val; //和flag联合决定返回值

        }

    在iptables的do_command()命令解析函数中,见到最多的就是optargoptind

    optarg: 如果字符串optstring中某个选项后面需要跟参数,该参数值一般保存在optarg中;

    optind: 该参数比较费神,输入参数列表不同,其取值也不一样。

    说了半天估计大家都快晕了,还是通过例子来说明这两个值随着输入参数的不同其变化情况吧。

#include

#include

char *l_opt_arg;

char* const short_options = "nb:ls:";

struct option long_options[] = {

     { "name",     0,   NULL,    'n'     },

     { "bf_name",  1,   NULL,    'b'     },

     { "love",     0,   NULL,    'l'     },

     { "speed",     1,   NULL,    's'     },

     {      0,     0,     0,     0},

};

 

int main(int argc, char *argv[])

{

     int c;

    printf("init otpind=%d\n",optind);

     while((c = getopt_long (argc, argv, short_options, long_options, NULL)) != -1)

     {

        printf("option=%c,optind=%d,optarg=%s\n",c,optind,optarg);

        printf("args[%d]=%s\n",optind,argv[optind]);

         switch (c)

         {

         case 'n':

             printf("My name is XL.\n");

             break;

         case 'b':

             printf("His name is ST.\n");

             break;

         case 'l':

             printf("Our love is ok!\n");

             break;

         case 's':

             printf("SHit of son.\n");

             break;

         }

     }

     return 0;

}

   

    该测试程序接受的可选参数为-n -b -l -s,其中b和s选项后面要跟值。

    如果我们执行./test -n -b boy -l -s son

    optind依次取值为1、2、4、5、7。默认值就是1,当解析-n时,因为发现-n不需要值,所以当调用了getopt_long后,optind自动加1,指向-b选项。而-b是需要参数的,那么跟在-b后面的一定是它的值,所以当解析-b时,optind自动跳到下一个选项-l所在位置。同样-s也需要跟参数,那么当解析-l时optind就自动跳到-s所在的位置了,即5。

 

    如果我们执行./test -n --b=boy -l -s son

    这样的格式,optind依次为1、2、3、4、6。大家基本已经可以看出些眉目了吧。因为-b参数用长参格式加等号的赋值方式,所以optind的移动稍微有些变化。但它终归可以正确识别传给它的所有命令行参数及其格式。

 

    如果我们执行./test -nl -b boy -s son

    optind依次取值1、1、2、4、6。第一个选项是nl组合项,而且这两个选项都不需要跟参数。

关于getopt_long函数的更多用法参见man帮助手册。自己再对上面这个程序摸索摸索体会要更深刻些。

 

part2:iptables的自动加载模块原理

       无论是match还是target,在用户空间都有其对应的so库文件,关于动态库大家可以参阅读我的另一篇博文《》。这里我们注意到一点的就是无论是诸如libxt_tcp.c这样的协议模块,还是libxt_limit.c这样的match模块,又抑或libipt_REJECT.c这样的target模块,每个模块中都有一个名为_init()的函数。为什么我们的自己平时开发so库时,怎么没见过这个家伙?大家可能会有这疑问。接下来我们就来跟您抽丝剥茧,步步深入,看看它到底是何方妖孽。

       iptables在加载动态库时用的是dlopen()函数,在这篇博文中我有介绍。_init()定义在xtables.h中,是共享库用来初始化全局变量和对象用的,其定义如下:

    #define _init __attribute__((constructor)) my_init

       用__attribute__((constructor))来定义的函数,表示函数是构造函数,在main执行之前被调用;相应的用__attribute__ ((destructor))析构函数,在main退出时执行。void _init(void)就相当于是__attribute__((constructor)) _INIT(void),其实不管函数名定义成什么都会被执行到。

       在iptables中当我们调用dlopen函数来加载动态库时,率先执行每个动态库里的_init()函数,而该函数要么是将该match注册到全局链表xtables_matches里,或者是将target注册到全局链表xtables_targets中。
 
 
=================================华丽丽的开始=======================================

 

iptables的命令解析流程

      这里我们仅以ipv4协议为例进行分析。iptables-1.4.0.tar.gz源代码中,iptables命令的入口文件为iptables-standalone.c,其中主函数为main或者iptables_main。主函数中,所作的事情也很明了,其流程如下:
 

       当前,用户空间的iptables工具的绝大多数模块都是以动态共享库so的形式。使用动态库的优点也是显而易见的:编译出来的iptables命令比较小,动态库方式使得对于iptables的扩充非常方便。如果你非要去研究一下init_extensions函数的话,那么可以在iptables源码包的extensions/Makefile文件里找点思路。这里,我不会对其进行分析。

 

       命令行参数解析do_command()【位于iptable.c文件中】

该函数是iptables用于解析用户输入参数的核心接口函数,其函数原型为:

int do_command(int argc, char *argv[], char **table, iptc_handle_t *handle);

argc和argv是由用户传递过来的命令行参数;

table所操作的表名,对应命令行就是-t参数后面的值,如果用户没有指定-t参数时,默认为filter表;

       handle这个结构比较重要,它用于保存从内核返回的由table所指定的表的所有信息,后续对表及其其中的规则操作时都用的该变量;

前面我们在分析netfilter的时候提到过,用户空间和内核空间在表示match以及target时采用了不同的结构体定义。用户空间的match结构体定义为:

struct xtables_match                       #define iptables_target xtables_target

{

         struct xtables_match *next;

         …

         void (*help)(void);

         /* Initialize the match. */

         void (*init)(struct xt_entry_match *m);

         …

         /* Ignore these men behind the curtain: */

         unsigned int option_offset;

         struct xt_entry_match *m;  #内核中的match结构

         unsigned int mflags;

    …

};

       该结构是iptables在加载它所支持的所有match模块的时候所用到的结构体,例如time匹配模块、iprange匹配模块等。也就是说,如果你要开发自己的用户空间match的话,那么你必须实例化上面这样一个结构体对象,然后实现它相应的方法,诸如init、help、parse等等。

       真正用在我们所配置的iptables规则里的匹配条件,是由下列类型表示:

struct xtables_rule_match        #define iptables_rule_match xtables_rule_match

{

         struct xtables_rule_match *next;

         struct xtables_match *match;

         unsigned int completed;

};

可以看到,xtables_rule_match是将xtables_match组织成了一个链表而已。这也正和我们的意愿,因为一条规则里有可能会有多个match条件,而在解析的时候我们只要将我们规则里所用的match通过一个指针指向iptables目前所支持的那个模块,在后面的使用过程中就可以直接调用那个match模块里的所有函数了。这样即提高的访问效率,又节约了系统内存空间。

       同样的,用户空间的target也类似,留给大家自己去研究。

 

       iptables最常用的命令格式无非就是显示帮助信息,或者操作规则,例如:

【帮助信息格式】

iptables [-[m|j|p] name ] -h 显示名为name的match模块(m)、target模块(j)或协议(p)的详细帮助信息。

OK,我们以下面的规则为例,和大家探讨一下iptables对其的解析流程。

       iptables –A INPUT –i eth0 –p tcp --syn –s 10.0.0.0/8 –d 10.1.28.184 –j ACCEPT

       在博文三中,我们知道内核中用于表示一条规则的数据结构是struct ipt_entry{}类型,那么iptables对于输入给它的所有参数最终也要变成这样的格式。而我们在阅读iptables源码时发现,它确实在do_command()函数开始部分定义了一个struct ipt_entry fw;后面当iptables解析传递给它的输入参数时,主要做的事情,就是对该结构体相关成员变量的初始化填充。闲话不多说,let's rock。

 

(1)、命令控制解析:-A INPUT

      iptables –A INPUT –i eth0 –p tcp --syn –s 10.0.0.0/8 –d 10.1.28.184 –j ACCEPT

 

 

       对于“ADRILFZNXEP”这些控制命令来说,其核心处理函数为add_command()函数。

       该函数主要将命令行的控制参数解析出来,然后赋值给一个位图变量command,该变量的每一位bit表示一种操作。add_command()的函数原型定义如下(iptables.c):

static void add_command(unsigned int *cmd, const int newcmd, const int othercmds, int invert)

参数说明:

       cmd:用于保存控制参数解析结果的位图标志变量;

       newcmd:用户所输入的控制变量,是一些预定义的宏,定义在iptables.c文件中,如下:

#define CMD_NONE                          0x0000U

#define CMD_INSERT                       0x0001U

#define CMD_DELETE                       0x0002U

#define CMD_DELETE_NUM            0x0004U

#define CMD_REPLACE                0x0008U

#define CMD_APPEND                     0x0010U

#define CMD_LIST                    0x0020U

#define CMD_FLUSH                         0x0040U

#define CMD_ZERO                           0x0080U

#define CMD_NEW_CHAIN                  0x0100U

#define CMD_DELETE_CHAIN             0x0200U

#define CMD_SET_POLICY                    0x0400U

#define CMD_RENAME_CHAIN          0x0800U

       othercmd:在上面这11个控制参数中,只有CMD_ZERO需要辅助额外参数,因为从iptables -Z chainname的执行结果来看,它最后还会输出清空后的链的实际情况。因此,当用户的iptables命令中有-Z参数时,cmd默认的会被附加一个CMD_LIST特性。其他10个控制参数时,othercmd参数均为CMD_NONE。

       invert:表示命令中是否有取反标志“!”。因为这11个控制参数是没有取反操作的,因此,这个值均为FALSE(即0)。

    当解析完iptables -A INPUT … 后,command=0x0010U,chain=“INPUT”。然后将invert=FALSE,重新进入while循环,解析剩下的参数。

 

(2)、解析接口:-i eth0

iptables –A INPUT –i eth0 –p tcp --syn –s 10.0.0.0/8 –d 10.1.28.184 –j ACCEPT

      注意前面讲解的关于getopt_long()函数在执行过程中两个关键参数的值及其变化情况。当解析接口的时候optarg=“eth0”,optind=indexof(-p)。

check_inverse(optarg, &invert, &optind, argc);函数用于判断接口是否有取反标志,如果有取反标志,则将invert=TRUE,同时optind++,然后它指向后面的接口名,并返回TRUE;如果没有,则直接返回FALSE。

在接下来执行set_option(&options, OPT_VIANAMEIN, &fw.ip.invflags,invert);同样的,options也是一个位图标志变量,其取值分别如下(定义在iptables.c文件中):

#define OPT_NONE                      0x00000U

#define OPT_NUMERIC                    0x00001U

#define OPT_SOURCE                           0x00002U

#define OPT_DESTINATION                  0x00004U

#define OPT_PROTOCOL                  0x00008U

#define OPT_JUMP                      0x00010U

#define OPT_VERBOSE                     0x00020U

#define OPT_EXPANDED                  0x00040U

#define OPT_VIANAMEIN                         0x00080U

#define OPT_VIANAMEOUT                 0x00100U

#define OPT_FRAGMENT                   0x00200U

#define OPT_LINENUMBERS                0x00400U

#define OPT_COUNTERS                 0x00800U

#define NUMBER_OF_OPT                      12

       然后根据check_inverse()函数解析出来的invert的值来设置fw.ip.invflags相应的标志位,该值也是个位图标志变量,其可取的值由全局数组inverse_for_options[]来限定(iptables.c):

static int inverse_for_options[NUMBER_OF_OPT] =

{

/* -n */ 0,

/* -s */ IPT_INV_SRCIP,         #这六个宏均定义在ip_tables.h文件中

/* -d */ IPT_INV_DSTIP,

/* -p */ IPT_INV_PROTO,

/* -j */ 0,

/* -v */ 0,

/* -x */ 0,

/* -i */ IPT_INV_VIA_IN,

/* -o */ IPT_INV_VIA_OUT,

/* -f */ IPT_INV_FRAG,

/*--line*/ 0,

/* -c */ 0,

};

执行parse_interface(argv[optind-1],fw.ip.iniface,fw.ip.iniface_mask);将接口名称赋值给fw.ip.iniface,然后再设置该接口的mask。如果接口中没有正则匹配表达式(即“+”),则mask=0xFFFFFFFF。细心的朋友到这里可能就有疑问了:接口名不是保存在optarg中么,为什么要通过argv[optind-1]来获取呢?我们简单分析对比一下:

如果是“-i eth0”,那么optarg和argv[optind-1]的值相同,大家可以通过前面我给的那个demo例子去验证一下;

如果是“-i ! eth0”,情况就不一样了。注意看代码,此时optarg=“!”,而arg[optind-1]才是真正的接口名“eth0”。

 

(3)、解析协议字段:-p tcp

iptables –A INPUT –i eth0 –p tcp --syn –s 10.0.0.0/8 –d 10.1.28.184 –j ACCEPT

check_inverse(optarg, &invert, &optind, argc); 检查协议字段是否有取反标志

       set_option(&options, OPT_PROTOCOL, &fw.ip.invflags,invert); 根据invert的值来设置options和fw.ip.invflags。这和前面的接口解析是类似的。

       然后,将协议名称解析成对应的协议号,例如ICMP=1,TCP=6,UDP=17等等。

fw.ip.proto = parse_protocol(protocol);

       因为iptables在-p参数后面支持数字格式的协议描述,因此parse_protocol()函数首先尝试去解析数字字符串,将其转换成一个0-255之间的整数。如果转换成功,则将转换结果赋值给fw.ip.proto。如果转换失败,首先检查-p后面的参数是不是“all”。如果是则直接返回,否则调用getprotobyname()函数从/etc/protocols中去解析。这里getprotobyname函数主要根据传递给它的协议名返回一个struct protoent{}结构体的对象(详见man手册)。解析成功则返回;否则,在用户自定义的结构体数组chain_protos[]中去解析,其定义如下:

static const struct pprot chain_protos[] = {

         { "tcp", IPPROTO_TCP },

         { "udp", IPPROTO_UDP },

         { "udplite", IPPROTO_UDPLITE },

         { "icmp", IPPROTO_ICMP },

         { "esp", IPPROTO_ESP },

         { "ah", IPPROTO_AH },

         { "sctp", IPPROTO_SCTP },

         { "all", 0 },

};

 

if (fw.ip.proto == 0&& (fw.ip.invflags & IPT_INV_PROTO))

         exit_error(PARAMETER_PROBLEM,"rule would never match protocol");

如果协议类型为“all”并且协议字段-p后面还有取反标志,即-p ! all,表示不匹配任何协议。这样的规则是没有任何意义的,iptables也不允许这样的规则存在,因此会给出错误提示信息并退出。

 

(4)、解析tcp协议模块的具体控制参数:--syn

iptables –A INPUT –i eth0 –p tcp --syn –s 10.0.0.0/8 –d 10.1.28.184 –j ACCEPT

针对于--syn符号,会跳转到switch语句的default处执行。因为目前还没有解析到target,因此target=NULL。命令行中没有-m,因此matches=NULL,matchp=NULL,m=NULL。

if (m == NULL&& protocol&&

(!find_proto(protocol, DONT_LOAD,options&OPT_NUMERIC, NULL)

                     || (find_proto(protocol, DONT_LOAD,options&OPT_NUMERIC, NULL)

                                       && (proto_used == 0))

         )

         && (m = find_proto(protocol, TRY_LOAD,options&OPT_NUMERIC, &matches))) {

       这个逻辑条件判断已经很清晰了:

       如果命令行中没有-m,但是有-p,并且find_proto执行失败或者执行成功且协议本身还没有被用过proto_used=0,最后我们试图去加载so库之后再去执行find_proto。当第三执行find_proto函数时,会运行如下的代码部分,因为我们这次是以TRY_LOAD方式执行的:

#ifndef NO_SHARED_LIBS

         if (!ptr && tryload != DONT_LOAD && tryload != DURING_LOAD) {

                   char path[strlen(lib_dir) + sizeof("/.so")

                              + strlen(afinfo.libprefix) + strlen(name)];

                   sprintf(path, "%s/libxt_%s.so", lib_dir, name);

                   if (dlopen(path, RTLD_NOW) != NULL)

                            /* Found library.  If it didn't register itself,

                               maybe they specified target as match. */

                            ptr = find_match(name, DONT_LOAD, NULL);

以上代码会将我们的…/libxt_tcp.so库加载到当前进程的运行空间中,并导出相关环境变量,此时tcp的模块在执行dlopen时就已经被挂到xtables_matches链表中了。最后再在find_match()函数(find_proto()函数的内部其实就是调的find_match()而已)里递归的调用一次自己。

第二次递归调用自己时,首先会申请一块大小为struct xtables_match{}的内存空间由变量clone来指向,并将tcp.so模块中的信息保存其中,并设置clone->mflags = 0。然后再申请一块大小为struct xtables_rule_match{}大小的内存空间,由变量newentry来保存,将tcp的so模块的信息赋给结构体的相关成员变量。

for (i = matches; *i; i = &(*i)->next) {  #不会执行这个for循环

         printf("i=%s\n",(i==NULL?"NULL":i));

                   if (strcmp(name, (*i)->match->name) == 0)

                            (*i)->completed = 1;

}

newentry->match = ptr;     //就是前面的clone所指向的地址空间。

newentry->completed = 0;

newentry->next = NULL;

*i = newentry;        #因为matches是个二级指针,因此这里的*i即*matches=newentry

return ptr;          #ptr目前就保存了和tcp模块所有相关的内容,ptr最后返回去会赋给

下面的变量m

然后回到do_command()中继续执行:

/* Try loading protocol */

size_t size;

                                              

proto_used = 1;

 

printf("Ready to load %s's match\n",protocol);

 

size = IPT_ALIGN(sizeof(struct ipt_entry_match))+ m->size;

 

m->m = fw_calloc(1, size);         #为内核态的xt_entry_match结构分配存储空间

m->m->u.match_size = size;        #整个tcp_match的大小

strcpy(m->m->u.user.name, m->name);

set_revision(m->m->u.user.name,m->revision);

if (m->init != NULL)

         m->init(m->m);#调用tcp_init函数初始化内核中的match结构,主要是将xt_entry_match尾部的data数组进行初始化。对TCP来说就是将源、目的端口置为0xFFFF。这并不是重点。

 

opts = merge_options(opts,m->extra_opts, &m->option_offset);

#重点是merge_options操作,将tcp_opts中的数据合并到全局变量opts中去

optind--;

continue;

#前面说过optind指向当前参数下一个紧挨着的参数的下标。目前只是完成了解析--syn的初始化工作,还并没有对--syn进行解析,因此需要optind--,然后开始解析--syn

然后程序继续执行while循环,这次依然进入default段进行处理,并进入if (!target||…

只不过这次matches已经不为NULL,因此matchp就可以取到值,matchep即指向了tcp模块。将解析的结果赋给fw结构体的相应成员,并将代表tcp模块的iptables_match赋给m。

if (!target|| !(target->parse(c - target->option_offset,argv, invert,&target->tflags,&fw, &target->t)))

{

    for (matchp = matches; matchp; matchp = matchp->next) {

         if (matchp->completed)

              continue;

        #调用tcp模块的parse函数,即tcp_parse

        if (matchp->match->parse(c - matchp->match->option_offset,argv, invert,

                                    &matchp->match->mflags,&fw,

                                    &matchp->match->m))

              break;

 }

 

m = matchp ? matchp->match : NULL;

 

if(m==NULL &&…)  #就不会再执行这里了

 … …

至此,对--syn的解析就已经完成了。

 

(5)、解析源、目的地址:-s 10.0.0.0/8 -d 10.1.28.184

iptables –A INPUT –i eth0 –p tcp --syn –s 10.0.0.0/8 –d 10.1.28.184 –j ACCEPT

解析源地址:

check_inverse(optarg, &invert, &optind, argc);

set_option(&options, OPT_SOURCE, &fw.ip.invflags,invert);

shostnetworkmask = argv[optind-1]; #暂存源地址,后面要做进一步分析x.x.x.x/xx

 

解析目的地址:

check_inverse(optarg, &invert, &optind, argc);

set_option(&options, OPT_DESTINATION, &fw.ip.invflags,invert);

dhostnetworkmask = argv[optind-1]; #暂存目的地址,后面要做进一步分析x.x.x.x/xx

 

(6)、解析target:-j ACCEPT

iptables –A INPUT –i eth0 –p tcp --syn –s 10.0.0.0/8 –d 10.1.28.184 –j ACCEPT

首先判断target字串是否合法,jumpto = parse_target(optarg);

然后在xtables_targets全局链表里查找相应的target。因为目前只有标准target,因此最后加载libxt_standard.so库,对应的文件为libxt_standard.c。

static struct xtables_target standard_target = {

         .family               = AF_INET,

         .name                = "standard",

         .version    = IPTABLES_VERSION,

         .size          = XT_ALIGN(sizeof(int)),

         .userspacesize         = XT_ALIGN(sizeof(int)),

         .help                   = standard_help,

         .parse                = standard_parse,

};

我们可以看到标准target(诸如ACCEPT、DROP、RETURN、QUEUE等)是没有init函数和extra_opts变量的。因此,要做的操作只有下面几个:

if (target) {

size_t size;

 

size = IPT_ALIGN(sizeof(struct ipt_entry_target))+ target->size;

 

target->t = fw_calloc(1, size);        #为内核中的xt_entry_target分配存储空间

target->t->u.target_size = size;

strcpy(target->t->u.user.name, jumpto);

set_revision(target->t->u.user.name,target->revision);

 

#以下操作均不执行。因为target->init和target->extra_ops都为NULL                            

if (target->init != NULL)

                   target->init(target->t);

opts = merge_options(opts, target->extra_opts, &target->option_offset);

}

    至此,对用户的命令行输入的参数就算全部解析完成了,其中:

 

  • 控制参数的解析结果保存在位图变量command中;
  •  规则参数的解析结果保存在位图变量options中;
  •  源地址保存在临时变量shostnetworkmask中;
  •  目的地址保存在临时变量dhostnetworkmask中;

 

    并完成了对struct ipt_entry{}中struct ipt_ip{}结构体成员的初始化,即对fw.ip的初始化。

 

(7)、参数和合法性检查

如果是“ADRI”操作但是没有指定源目的地址,默认将其置为全网段0.0.0.0/0。然后,设置源目的掩码fw.ip.smsk和fw.ip.dmsk。

      检查command和options的匹配性generic_opt_check(command, options)。它们的相关性由一个二维数组commands_v_options[][]来限定:

至此,所有的解析、校验工作都已完成。接下来我们将要探究,iptables如何与内核交互的问题。

转载于:https://www.cnblogs.com/liuhongru/p/11422620.html

你可能感兴趣的文章
前台JSP页面独立化
查看>>
Meet Solr
查看>>
前端知识——Django
查看>>
cookie、session、sessionid的理解
查看>>
(C/C++) Interview in English - Class
查看>>
UOJ Round #15 [构造 | 计数 | 异或哈希 kmp]
查看>>
Countdown项目UML用例图
查看>>
struts2文件上传大小限制问题小结
查看>>
actor运行报错:java.lang.ClassNotFoundException
查看>>
Eclipse PHPEclipse 配置
查看>>
关于BigDecimal的四舍五入和截断 (转)
查看>>
VB刷网页程序(VB+DOS)
查看>>
LeetCode 153. Find Minimum in Rotated Sorted Array
查看>>
Xdebug在PHP中的安装配置
查看>>
JS实现select去除option的使用注意事项
查看>>
00085_异常
查看>>
ruby 方法(2)
查看>>
Python遍历列表删除多个元素或者重复元素
查看>>
jQuery内部原理和实现方式浅析
查看>>
windows共享虚拟机ubuntu目录
查看>>