iptables用户空间和内核空间的交互

iptables目前已经支持IPv4和IPv6两个版本了,因此它在实现上也需要同时兼容这两个版本。iptables-1.4.0在这方面做了很好的设计,主要是由libiptc库来实现。libiptc是iptables control library的简称,是Netfilter的一个编程接口,通常被用来显示、操作(查询、修改、添加和删除)netfilter的规则和策略等。使用libipq库和ip_queue模块,几乎可以实现任何在内核中所实现的功能。

libiptc库位于iptables源码包里的libiptc目录下,共六个文件还是比较容易理解。我们都知道,运行在用户上下文环境中的代码是可以阻塞的,这样,便可以使用消息队列和 UNIX 域套接字来实现内核态与用户态的通信。但这些方法的数据传输效率较低,Linux 内核提供 copy_from_user()/copy_to_user() 函数来实现内核态与用户态数据的拷贝,但这两个函数会引发阻塞,所以不能用在硬、软中断中。一般将这两个特殊拷贝函数用在类似于系统调用一类的函数中,此类函数在使用中往往"穿梭"于内核态与用户态。此类方法的工作原理为:

其中相关的系统调用是需要用户自行编写并载入内核。一般情况都是,内核模块注册一组设置套接字选项的函数使得用户空间进程可以调用此组函数对内核态数据进行读写。我们的libiptc库正是基于这种方式实现了用户空间和内核空间数据的交换。

为了后面便于理解,这里我们简单了解一下在socket编程中经常要接触的两个函数:

int setsockopt(int sockfd, int proto, int cmd, void *data, int datalen)

int getsockopt(int sockfd, int proto, int cmd, void *data, int datalen)

这个两个函数用来控制相关socket文件描述符的一些选项值,如设置(获取)接受或发送缓冲区的大小、设置(获取)接受或发送超时值、允许(禁止)重用本地端口和地址等等。

参数说明:

sockfd:为socket的文件描述符;

proto:sock协议,IP RAW的就用SOL_SOCKET/SOL_IP等,TCP/UDP socket的可用SOL_SOCKET/SOL_IP/SOL_TCP/SOL_UDP等,即高层的socket是都可以使用低层socket的命令字 的;

cmd:操作命令字,由自己定义,一般用于扩充;

data:数据缓冲区起始位置指针,set操作时是将缓冲区数据写入内核,get的时候是将内核中的数据读入该缓冲区;

datalen:参数data中的数据长度。

我们可以通过扩充新的命令字(即前面的cmd字段)来实现特殊应用程序的内核与用户空间的数据交换,内核实现新的sockopt命令字有两类:一类是添加完整的新的协议后引入;一类是在原有协议命令集的基础上增加新的命令字。以netfilter为例,它就是在原有的基础上扩展命令字,实现了内核与用户空间的数据交换。Netfilter新定义的命令字如下:

setsockopt新增命令字:

#define IPT_SO_SET_REPLACE //设置规则

#define IPT_SO_SET_ADD_COUNTERS   //加入计数器

getsockopt新增命令字;

#define IPT_SO_GET_INFO               //获取ipt_info

#define IPT_SO_GET_ENTRIES         //获取规则

#define IPT_SO_GET_REVISION_MATCH //获取match

#define IPT_SO_GET_REVISION_TARGET      //获取target

一个标准的setsockopt()操作的调用流程如下:

在ip_setsockopt调用时,如果发现是一个没有定义的协议,并且判断现在这个optname是否为netfilter所设置,如果是则调用netfilter所设置的特殊处理函数,于是加入netfilter对sockopt特殊处理后,新的流程如下:

netfitler对于会实例化一些struct nf_sockopt_ops{}对象,然后通过nf_register_sockopt()将其注册到全局链表nf_sockopts里。

struct nf_sockopt_ops

{

struct list_head list;

int pf;

/* Non-inclusive ranges: use 0/0/NULL to never get called. */

int set_optmin;

int set_optmax;

int (*set)(struct sock *sk, int optval, void __user *user, unsigned int len);

int (*compat_set)(struct sock *sk, int optval,void __user *user, unsigned int len);

int get_optmin;

int get_optmax;

int (*get)(struct sock *sk, int optval, void __user *user, int *len);

int (*compat_get)(struct sock *sk, int optval,void __user *user, int *len);

/* Number of users inside set() or get(). */

unsigned int use;

struct task_struct *cleanup_task;

};

继续回到libiptc中。libiptc库中的所有函数均以“iptc_”开头,主要有下面一些接口(节选自libiptc.h):

typedef struct iptc_handle *iptc_handle_t;

/* Does this chain exist? */

int iptc_is_chain(const char *chain, const iptc_handle_t handle);

/* Take a snapshot of the rules.  Returns NULL on error. */

iptc_handle_t iptc_init(const char *tablename);

/* Cleanup after iptc_init(). */

void iptc_free(iptc_handle_t *h);

/* Iterator functions to run through the chains.  Returns NULL at end. */

const char *iptc_first_chain(iptc_handle_t *handle);

const char *iptc_next_chain(iptc_handle_t *handle);

/* Get first rule in the given chain: NULL for empty chain. */

const struct ipt_entry *iptc_first_rule(const char *chain,iptc_handle_t *handle);

/* Returns NULL when rules run out. */

const struct ipt_entry *iptc_next_rule(const struct ipt_entry *prev,iptc_handle_t *handle);

/* Returns a pointer to the target name of this entry. */

const char *iptc_get_target(const struct ipt_entry *e,iptc_handle_t *handle);

/* Is this a built-in chain? */

int iptc_builtin(const char *chain, const iptc_handle_t handle);

int iptc_append_entry(const ipt_chainlabel chain,

const struct ipt_entry *e,

iptc_handle_t *handle);

/* Zeroes the counters in a chain. */

int iptc_zero_entries(const ipt_chainlabel chain,iptc_handle_t *handle);

/* Creates a new chain. */

int iptc_create_chain(const ipt_chainlabel chain,iptc_handle_t *handle);

/* Makes the actual changes. */

int iptc_commit(iptc_handle_t *handle);

上面这些接口都是为IPv4定义了,同样的IPv6的接口均定义在libip6tc.h头文件中,都以“ip6tc_”开头(如此说来,IPv4的头文件应该叫libip4tc.h才比较合适)。然后在libip4tc.c和libip6tc.c文件中分别通过宏定义的形式将IPv4和IPv6对外的接口均统一成“TC_”开头的宏,并在libiptc.c中实现这些宏即可。如下图所示:

这里我们看到iptables-v4和iptables-v6都和谐地统一到了libiptc.c中,后面我们分析的时候只要分析这些相关的宏定义的实现即可。

在继续往下分析之前我们先看一下STRUCT_TC_HANDLE这个比较拉风的结构体,它用于存储我们和内核所需要交换的数据。说的通俗一些,就是从内核中取出的表的信息会存储到该结构体类型的变量中;当我们向内核提交iptables变更时,也需要一个该结构体类型的变量用于存储我们所要提交的数据。(定义在ip_tables.h头文件中)

适用于当getsockopt的参数为IPT_SO_GET_INFO,用于从内核读取表信息

struct ipt_getinfo               #define STRUCT_GETINFO struct ipt_getinfo

{

/* Which table: caller fills this in. */    #从内核取出的表信息会存储在该结构体中

char name[IPT_TABLE_MAXNAMELEN];

/* Kernel fills these in. */

unsigned int valid_hooks; /* Which hook entry points are valid: bitmask */

unsigned int hook_entry[NF_IP_NUMHOOKS]; // Hook entry points: one per netfilter hook.

unsigned int underflow[NF_IP_NUMHOOKS]; /* Underflow points. */

unsigned int num_entries; /* Number of entries */

unsigned int size; /* Size of entries. */

};

还有一个成员entries用保存表中的所有规则信息,每条规则都是一个ipt_entry的实例:

/* The argument to IPT_SO_GET_ENTRIES. */

struct ipt_get_entries

{

/* Which table: user fills this in. */

char name[IPT_TABLE_MAXNAMELEN];

unsigned int size; /* User fills this in: total entry size. */

struct ipt_entry entrytable[0]; /*内核里表示规则的结构,参见博文三. */

};

(一)、从内核获取数据:iptc_init()

都说“磨刀不误砍柴工”,接下来我们继续上一篇中do_command()函数里剩下的部分。*handle = iptc_init(*table); 这里即根据表名table去从内核中获取该表的自身信息和表中的所有规则。关于表自身的一些信息存储在handle->info成员里;表中所有规则的信息保存在handle->entries成员里。

如果handle获取失败,则尝试加载完内核中相应的ko模块后再次执行iptc_init()函数。

然后,针对“ADRI”操作需要做一些合法性检查,诸如-o选项不能用在PREROUTING和INPUT链中、-i选项不能用在POSTROUTING和OUTPUT链中。

if (target && iptc_is_chain(jumpto, *handle)) {

fprintf(stderr,"Warning: using chain %s, not extension\n",jumpto);

if (target->t)

free(target->t);

printf("Target is a chain,but we have gotten a target,then free it!\n");

target = NULL;

}

如果-j XXX 后面的XXX是一条用户自定义规则链,但是之前却解析出了标准target,那么需要将target的空间释放掉。很明显,目前我们的-j ACCEPT不会执行到这里。

if (!target  #如果没有指定target。同样,我们的规则也不会执行到这里

&& (strlen(jumpto) == 0|| iptc_is_chain(jumpto, *handle)) #或者target是一条链或为空

)

{

size_t size;

… …

}

因为我们的target为ACCEPT,已经被完全正确解析,即target!=NULL。后面我们会执行else条件分子如下的代码:

e = generate_entry(&fw, matches, target->t);

用于生成一条iptables的规则,它首先会为e去申请一块大小n*match+target的空间,其中n为用户输入的命令行中的match个数,target为最后的动作。这里很明显,我们的命令只有一个tcp的match,target是标准target,即ACCEPT。将已经解析的fw赋给e,并对结构体e中其他的成员进行初始化,然后将相应的match和target的数据拷贝到e中对应的成员中。

size = sizeof(struct ipt_entry);

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

size += matchp->match->m->u.match_size;

e = fw_malloc(size + target->u.target_size);

*e = *fw;

e->target_offset = size;

e->next_offset = size + target->u.target_size;

size = 0;

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

memcpy(e->elems + size, matchp->match->m, matchp->match->m->u.match_size);

size += matchp->match->m->u.match_size;

}

memcpy(e->elems + size, target, target->u.target_size);

最后所生成的规则e,其内存结构如下图所示:

这里再联系我们对内核中netfilter的分析就很容易理解了,一旦我们获取一条规则ipt_entry的首地址,那么我们能通过target_offset很快获得这条规则的target地址,同时也可以通过next_offset获得下一条ipt_entry规则的起始地址,很方便我们到时候做数据包匹配的操作。

紧接着就是对解析出来的command命令进行具体操作,这里我们是-A命令,因此最后command命令就是CMD_APPEND,这里则执行append_entry()函数。

ret = append_entry(chain,  #链名,这里为INPUT

e,      #将用户的命令解析出来的最终的规则对象

nsaddrs,  #-s 后面源地址的个数

saddrs,   #用于保存源地址的数组

ndaddrs,  #-d 后面的目的地址的个数

daddrs,   #用于保存目的地址的数组

options&OPT_VERBOSE, #iptables命令是否有-v参数

handle   #从内核中取出来的规则表信息

);

在append_entry内部调用了iptc_append_entry(chain, fw, handle),其实就是由宏即TC_APPEND_ENTRY所表示的那个函数。该函数内部有两个值得注意的结构体类型struct chain_head{}和struct rule_head{},分别用于保存我们所要操作的链以及链中的规则:

struct chain_head

{

struct list_head list;

char name[TABLE_MAXNAMELEN];

unsigned int hooknum;             /* hook number+1 if builtin */

unsigned int references; /* 有多少-j 指定了我们的名字 */

int verdict;                          /* verdict if builtin */

STRUCT_COUNTERS counters;        /* per-chain counters */

struct counter_map counter_map;

unsigned int num_rules;           /* 本链中的规则数*/

struct list_head rules;             /* 本链中所有规则的入口点 */

unsigned int index;           /* index (needed for jump resolval) */

unsigned int head_offset;        /* offset in rule blob */

unsigned int foot_index; /* index (needed for counter_map) */

unsigned int foot_offset;          /* offset in rule blob */

};

struct rule_head

{

struct list_head list;

struct chain_head *chain;

struct counter_map counter_map;

unsigned int index;           /* index (needed for counter_map) */

unsigned int offset;          /* offset in rule blob */

enum iptcc_rule_type type;

struct chain_head *jump;      /* jump target, if IPTCC_R_JUMP */

unsigned int size;              /* size of entry data */

STRUCT_ENTRY entry[0];   #真正的规则入口点 sizeof计算时不会包含这个字段

};

TC_APPEND_ENTRY的函数实现:

int

TC_APPEND_ENTRY(const IPT_CHAINLABEL chain,

const STRUCT_ENTRY *e,

TC_HANDLE_T *handle)    #注意:这里的handle是个二级指针

{

struct chain_head *c;

struct rule_head *r;

iptc_fn = TC_APPEND_ENTRY;

if (!(c = iptcc_find_label(chain, *handle))) {

#根据链名查找真正的链地址赋给c,此时c就指向了INPUT链的内存,

#包括INPUT中的所有规则和它的policy等

DEBUGP("unable to find chain `%s'\n", chain);

errno = ENOENT;

return 0;

}

if (!(r = iptcc_alloc_rule(c, e->next_offset))) {

#ipt_entry的next_offset即指明了下一条规则的起始地址,同时这个值也说明了本条规则所占了存储空间的大小。这里所申请的空间大小=sizeof(rule_head)+当前规则所占的空间大小。

DEBUGP("unable to allocate rule for chain `%s'\n", chain);

errno = ENOMEM;

return 0;

}

memcpy(r->entry, e, e->next_offset);       #把规则拷贝到柔性数组entry中去

r->counter_map.maptype = COUNTER_MAP_SET;

if (!iptcc_map_target(*handle, r)) {   #主要是设置规则r的target,后面分析。

DEBUGP("unable to map target of rule for chain `%s'\n", chain);

free(r);

return 0;

}

list_add_tail(&r->list, &c->rules); #将新规则r添加在链c的末尾

c->num_rules++;              #同时将链中的规则计数增加

set_changed(*handle);    #因为INPUT链中的规则已经被改变,则handle->changed=1;

return 1;

}

接下来分析一下设置target时其函数内部流程:

static int

iptcc_map_target(const TC_HANDLE_T handle,

struct rule_head *r)

{

STRUCT_ENTRY *e = r->entry;                 #取规则的起始地址

STRUCT_ENTRY_TARGET *t = GET_TARGET(e);    #取规则的target

/* Maybe it's empty (=> fall through) */

if (strcmp(t->u.user.name, "") == 0) { #如果没有指定target,则将规则类型设为“全放行”

r->type = IPTCC_R_FALLTHROUGH;

return 1;

}

/* Maybe it's a standard target name... */

#因为都是标准target,因此将target中用户空间的user.name都置为空,设置verdict,

#并将rule_head中的type字段为IPTCC_R_STANDARD

else if (strcmp(t->u.user.name, LABEL_ACCEPT) == 0)

return iptcc_standard_map(r, -NF_ACCEPT - 1);

else if (strcmp(t->u.user.name, LABEL_DROP) == 0)

return iptcc_standard_map(r, -NF_DROP - 1);

else if (strcmp(t->u.user.name, LABEL_QUEUE) == 0)

return iptcc_standard_map(r, -NF_QUEUE - 1);

else if (strcmp(t->u.user.name, LABEL_RETURN) == 0)

return iptcc_standard_map(r, RETURN);

else if (TC_BUILTIN(t->u.user.name, handle)) {

/* Can't jump to builtins. */

errno = EINVAL;

return 0;

} else {

/* 如果跳转的目标是一条用户自定义链,则执行下列操作*/

struct chain_head *c;

DEBUGP("trying to find chain `%s': ", t->u.user.name);

c = iptcc_find_label(t->u.user.name, handle); #找到要跳转的目的链的入口地址

if (c) {

DEBUGP_C("found!\n");

r->type = IPTCC_R_JUMP;  #将rule_head结构的type字段置为“跳转”

r->jump = c;             #跳转的目标为t->u.user.name所指示的链

c->references++;         #跳转到的目的链因此而被引用了一次,则计数器++

return 1;

}

DEBUGP_C("not found :(\n");

}

/* 如果不是用户自定义链,它一定一个用户自定义开发的target模块,比如SNAT、LOG等。If not, kernel will reject... */

/* memset to all 0 for your memcmp convenience: don't clear version */

memset(t->u.user.name + strlen(t->u.user.name),

0,

FUNCTION_MAXNAMELEN - 1 - strlen(t->u.user.name));

r->type = IPTCC_R_MODULE;  #比如SNAT,LOG等会执行到这里

set_changed(handle);

return 1;

}

在append_entry()函数最后,将执行的执行结果返回给ret,1表示成功;0表示失败。然后在做一下善后清理工作,如果命令行中有-v则将内核中表的快照dump一份详细信息出来显示给用户看:

if (verbose > 1)

dump_entries(*handle);

clear_rule_matches(&matches); //释放matches所占的存储空间

由struct ipt_entry e;所存储的规则信息已经被提交给了handle对象对应的成员,因此将e所占的存储空间也释放:

if (e != NULL) {

free(e);

e = NULL;

}

将全局变量opts复位,初始化时opts=original_opts。因为在解析--syn时tcp的解析参数被加进来了:

static struct option original_opts[] = {

{ "append", 1, NULL, 'A' },

{ "delete", 1, NULL,  'D' },

… …

}

至此,do_command()函数的执行就算全部完成了。

(二)、向内核提交变更:iptc_commit()

执行完do_command()解析完命令行参数后,用户所作的变更仅被提交给了handle这个结构体变量,这个变量里的所有数据在执行iptc_commit()函数前都驻留在内存里。因此,在iptables-standalone.c里有如下的代码语句:

ret = do_command(argc, argv, &table, &handle);

if (ret)

ret = iptc_commit(&handle);

当do_command()执行成功后才会去执行iptc_commit()函数,将handle里的数据提交给Netfilter内核。

iptc_commit()的实现函数为int TC_COMMIT(TC_HANDLE_T *handle),我们只分析IPv4的情形,因此专注于libiptc.c文件中该函数的实现。

TC_COMMIT()函数中,又出现了我们在分析Netfilter中filter表时所见到的一些重要结构体STRUCT_REPLACE *repl;STRUCT_COUNTERS_INFO *newcounters;还有前面出现的struct chain_head *c;结构体。

new_number = iptcc_compile_table_prep(*handle, &new_size);

iptcc_compile_table_prep()该函数主要做的工作包含几个方面:

a.初始化handle里每个struct chain_head{}结构体成员中的head_offset、foot_index和foot_offset。

b.对每个链(struct chain_head{})中的每条规则,再分别计算它们的offset和index。

c.计算handle所指示的表中所有规则所占的存储空间的大小new_size,以及规则的总条数new_number

接下来,为指针repl;申请存储空间,所申请的大小为sizeof(struct ipt_replace)+new_size。因为struct ipt_replace{}结构的末尾有一个柔性数组struct ipt_entry entries[0]; 它是不计入sizeof的计算结果的。因此,iptables的所有规则实际上是存储在struct ipt_entry entries[0]柔性数组中的,这里所有规则所占大小已经得到:new_size

因为,每条规则entry都一个计数器,用来记录该规则处理了多少数据包,注意结构体STRUCT_COUNTERS_INFO{}的末尾也有一个柔性数组struct xt_counters counters[0];其中struct xt_counters{}才是真正的用于统计数据包的计数器。

然后开始初始化repl结构:

strcpy(repl->name, (*handle)->info.name);

repl->num_entries = new_number;

repl->size = new_size;

repl->num_counters = (*handle)->info.num_entries;

repl->valid_hooks = (*handle)->info.valid_hooks;

紧接着对repl结构体中剩下的成员进行初始化,hook_entry[]、underflow[]等。对于用户自定义链,其末尾的target.verdict=RETURN。

setsockopt(sockfd, TC_IPPROTO, SO_SET_REPLACErepl,sizeof(*repl) + repl->size);

会触发内核去执行前面我们看到的do_ipt_set_ctl()函数,如下:

static struct nf_sockopt_ops ipt_sockopts = {

.pf              = PF_INET,

.set_optmin     = IPT_BASE_CTL,

.set_optmax    = IPT_SO_SET_MAX+1,

.set           = do_ipt_set_ctl,

.get_optmin     = IPT_BASE_CTL,

.get_optmax    = IPT_SO_GET_MAX+1,

.get           = do_ipt_get_ctl,

};

在do_ipt_set_ctl()中其核心还是执行do_replace()函数:

static int do_replace(void __user *user, unsigned int len)

{

int ret;

struct ipt_replace tmp;

struct xt_table_info *newinfo;

void *loc_cpu_entry;

if (copy_from_user(&tmp, user, sizeof(tmp)) != 0)

return -EFAULT;

/* Hack: Causes ipchains to give correct error msg --RR */

if (len != sizeof(tmp) + tmp.size)

return -ENOPROTOOPT;

… …

}

其中copy_from_user()负责将用户空间的repl变量中的内容拷贝到内核中的tmp中去。然后设置规则计数器newcounters,通过setsockopt系统调用将newcounters设置到内核:

setsockopt(sockfd, TC_IPPROTO, SO_SET_ADD_COUNTERSnewcounters, counterlen);

此时,在do_ipt_set_ctl()中执行的是do_add_counters()函数。至此,iptables用户空间的所有代码流程就算分析完了。命令:

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

即被设置到内核的Netfilter规则中去了。

未完,待续…

转载于:https://www.cnblogs.com/masterpanda/p/5700491.html

(十二)洞悉linux下的Netfilteramp;iptables:iptables命令行工具源码解析【下】相关推荐

  1. erlang下lists模块sort(排序)方法源码解析(二)

    上接erlang下lists模块sort(排序)方法源码解析(一),到目前为止,list列表已经被分割成N个列表,而且每个列表的元素是有序的(从大到小) 下面我们重点来看看mergel和rmergel ...

  2. windows使用linux命令行工具,替代Windows系统下cmd的10款命令行工具

    喜欢用Linux系统的或者从事开发编程的朋友可能会经常用到命令行工具,下面会整理一些Windows下命令行工具. 1.powershell 系统自带 powershell 它可以说cmd的升级版.补充 ...

  3. linux Ubuntu之make:make命令行工具的简介、安装、使用方法之详细攻略

    Ubuntu之make:make命令行工具的简介.安装.使用方法之详细攻略 目录 make命令行工具的简介 make命令行工具的安装 make命令行工具的使用方法 make命令行工具的简介 Ubunt ...

  4. FreeRTOS 之二 Tracealyzer for FreeRTOS(FreeRTOS+Trace) 详解(源码解析+移植)

    2020/5/19 更新了在使用 4.3.8 时遇到的一些问题说明 2018/5/16 大约一个月之前,Tracealyzer for FreeRTOS目前更新到了4.x,新版本不在区分针对哪个系统, ...

  5. dos命令行设置网络优先级_替代windows系统下cmd的10款命令行工具

    喜欢用linux系统的或者从事开发编程的朋友可能会经常用到命令行工具,下面会整理一些windows下命令行工具. 1.powershell 系统自带 powershell 它可以说cmd的升级版.补充 ...

  6. Laravel5.2之Filesystem源码解析(下)

    2019独角兽企业重金招聘Python工程师标准>>> 说明:本文主要学习下\League\Flysystem这个Filesystem Abstract Layer,学习下这个pac ...

  7. spring 源码深度解析_spring源码解析之SpringIOC源码解析(下)

    前言:本篇文章接SpringIOC源码解析(上),上一篇文章介绍了使用XML的方式启动Spring,介绍了refresh 方法中的一些方法基本作用,但是并没有展开具体分析.今天就和大家一起撸一下ref ...

  8. 仿酷狗音乐播放器开发日志二十二 动态调色板控件第二版(性能大幅提升附源码)...

    转载请说明原出处,谢谢~~ 在上次写的博客<仿酷狗音乐播放器开发日志二十一 开发动态调色板控件(附源码)>发布后,我在群里和网友讨论这个控件的性能和优 缺点,发现了他很多不足,还有很多提升 ...

  9. Python数据分析实战【十二】:机器学习决策树算法案例实战【文末源码地址】

    文章目录 构造数据 决策树解决 报错解决 源码地址 构造数据 我们用pandas生成20条数据,其中标签为bad的数据有6条,标签为good的数据有14条,代码如下: import pandas as ...

最新文章

  1. ROS学习(十三):time 和 Timer
  2. python语法速成方法_30分钟学完Python基础语法
  3. 细胞培养中出现黑胶虫污染处理方法
  4. dosbox 伪指令dd为什么会报错_什么是SQL函数?为什么使用SQL函数可能会带来问题?...
  5. session mysql java_PHP自定义session处理方法,保存到MySQL数据库中
  6. 战斗民族的Yandex开始首次雪地无人车路测 | 视频
  7. [Stage3D]入门讲稿
  8. 浙大计算机学院 数字媒体处理与企业智能计算实验室在哪个校区,浙大计算机学院各大实验室介绍.pdf...
  9. 遗传算法C语言实现以及思路详解简单易懂
  10. OFFICE2007 自编宏使用 以及 文件未找到 VBA6.DLL 错误处理
  11. MySql字符串拼接
  12. 模糊数学(一):模糊集及其表示
  13. dm7达梦7Linux安装包,【达梦】DM7安装部署 2 安装达梦7数据库软件
  14. 优秀关卡设计的十个原则
  15. 重磅发布:《AI产品经理的实操手册(2021版)——AI产品经理大本营的4年1000篇干货合辑》(PDF)...
  16. 春节晚报 | 2月1日 星期二 | 快手推出首届“新春招工会”;罗永浩称“不做VR和元宇宙”;戴姆勒正式更名为梅赛德斯-奔驰...
  17. 网易云信七鱼市场总监姜菡钰:实战解读增长黑客在B端业务的运用
  18. 第34次中国互联网络发展状况统计报告
  19. MAC OSX stdio.h或iostream等头文件无法找到的解决办法
  20. AI智能曲谱识别|乐谱识别识音SDK|人声数拍SDK|智能钢琴、MIDI音乐、打谱、曲谱乐谱播放识别SDK、音序器、合成器、播放器软件

热门文章

  1. 【算法竞赛学习】学术前沿趋势-论文代码统计
  2. tp5支持啥数据库_MS Access数据库是被严重低估的一款优秀软件
  3. Spring Security-- 验证码功能的实现
  4. python 队列与栈的实现
  5. LIBSVM使用方法
  6. 机器学习中倒三角符号_机器学习的三角误差
  7. 深入浅出SQL(2)——select、update…
  8. packETH发包工具使用教程
  9. 油价创6个月新高,石油石化板块还能追吗?
  10. java里面运行js_在java中利用rhino执行javascript