原文 :http://blog.csdn.net/xuyanbo2008/article/details/7439751

1 zebra线程机制概述

zebra这个软件包整体结构大致可分为两大块:协议模块和守护进程模块。协议模块实现各协议的功能,各协议以子模块的形式加载到zebra中;守护进程模块的功能主要是管理各协议的信令传输、表项操作、系统操作调用等事务,为各协议提供底层信息以及相关的硬件处理等功能支持。Zebra与各协议的交互采用的是C-S模式,在每个协议子模块中均有一个Client端与守护进程模块中的Server端交互,它们所使用的socket为zebra内部使用的socket,不与外部交互。

zebra中的线程是分队列调度的,每个队列以一个链表的方式实现。线程队列可以分成五个队列:event、timer、ready、read、write。队列的优先级由高到低排列。但是,read和write队列并不参与到优先级的排列中,实际操作时,如果read和write队列中的线程就绪,就加入ready队列中,等待调度。调度时,首先进行event队列中线程的调度,其次是timer和ready。

实际上,zebra中的线程是“假线程”,它并没有实现线程中的优先级抢占问题。在zebra的线程管理中,有个虚拟的时间轴,必须等前一时间的线程处理完,才看下一时间的线程是否触发。由于电脑处理速度较快且处理每个线程的时间间隔较小,所以处理其多线程来可以打到类似“并行处理”的效果。

zebra源码中有关线程管理的各个函数放置于zebra-0.95a\lib文件夹的thread.h和thread.c两个文件中。

2 线程管理源码分析

2.1 重要结构体介绍

2.1.1 thread

这是线程队列中每一个单个线程的代码描述,线程队列被描述成双向链表的形式,thread结构体是双向链表的元素。共有六种线程:read、write、timer、event、ready、unused。因此,线程队列也有六种。

struct thread

{

unsigned char type;        /* thread类型,共有六种 */

struct thread *next;        /* 指向下一thread的指针,双向链表 */

struct thread *prev;        /*指向前一thread的指针*/

struct thread_master *master;      /* 指向该thread所属thread_master结构体的指针 */

int (*func) (struct thread *); /* event类型thread的函数指针 */

void *arg;               /* event类型thread的参数 */

union {

int val;                /* event类型thread的第二个参数*/

int fd;                  /* read/write类型thread相应的文件描述符 */

struct timeval sands;   /* 该thread的剩余时间,timeval类型,此结构体定义在time.h中,有两个元素,秒和微秒 */

} u;

RUSAGE_T ru;                    /* 详细用法信息,RUSAGE这个宏在该thread有用法描述时定义为rusage类型,描述其详细进程资源信息,没有用法描述时定义为timeval类型 */

};

2.1.2 thread_list

一个thread_list结构体描述一个thread双向链表,也即一个进程队列。

struct thread_list

{

struct thread *head;/* 该线程队列头指针 */

struct thread *tail; /* 该线程队列尾指针 */

int count; /* 该线程队列元素数目 */

};

2.1.3 thread_master

总的线程管理结构体,里面存有六种线程队列,三种文件描述符以及占用空间信息。

struct thread_master

{

//六种线程队列

struct thread_list read;

struct thread_list write;

struct thread_list timer;

struct thread_list event;

struct thread_list ready;

struct thread_list unuse;

//三种文件描述符

fd_set readfd;

fd_set writefd;

fd_set exceptfd;

//该thread_master所占空间大小

unsigned long alloc;

};

1.1 相关函数简介

下面给出了zebra关于线程管理的相关函数的简要功能介绍。

1.1.1 thread_master_create ()

为创建一个新的thread_master结构体动态开辟一块内存空间。

1.1.2 thread_list_add ()

在list双向链表尾部插入一个新的thread。

1.1.3 thread_list_add_before ()

在函数参数point所指向的thread前面插入一个新的thread。

1.1.4 thread_list_delete ()

删除参数中指定的thread。

1.1.5 thread_add_unuse ()

向指定thead_master中的unused链表尾部插入新thread。

1.1.6 thread_list_free ()

从内存中释放掉指定thread_master中的指定thread链表所占空间。

1.1.7 thread_master_free ()

彻底释放指定thread_master所占内存空间。

1.1.8 thread_trim_head ()

若指定thread链表中非空,删除该链表头指针所指thread,并将其返回,即从线程队列中取出一个线程。

1.1.9 thread_empty ()

判断指定thread链表是否为空。

1.1.10 thread_timer_remain_second ()

得到指定线程的剩余时间。

1.1.11 thread_get ()

若指定thread_master中的unuse链表非空,从该队列中取出一个thread,根据参数初始化并返回之。否则,给该thread_master多开辟一块空间给新的thread,根据参数初始化该thread并返回之。

1.1.12 thread_add_read ()

根据所给参数在指定thread_master中添加并初始化一个read类型的thread并返回之。

1.1.13 thread_add_write ()

根据所给参数在指定thread_master中添加并初始化一个write类型的thread并返回之。

1.1.14 thread_add_timer ()

根据所给参数在指定thread_master中添加并初始化一个timer类型的thread。若timer链表不要求排序,则直接返回新thread,若要求排序,则将新thread插入到队列的相应位置后再返回之。

1.1.15 thread_add_event ()

根据所给参数在指定thread_master中添加并初始化一个event类型的thread并返回之。

1.1.16 thread_cancel ()

删除指定thread,删除后将其类型置为THREAD_UNUSED,并将其插入到该thread_master的unuse链表中。

1.1.17 thread_cancel_event ()

将指定thread_master的event链表中与参数中arg相匹配的thread删除。

1.1.18 thread_timer_wait ()

找出指定thread_master的timer链表中最小的剩余时间并将其返回。

1.1.19 thread_run ()

将指定thread的值赋给thread类型的fetch,然后将其类型置为THREAD_UNUSED,并将其插入unuse链表,返回fetch。

1.1.20 thread_process_fd ()

将指定thread链表中的元素取出插入到该thread_master的ready链表中,返回该链表中插入元素的个数。

1.1.21 thread_fetch ()

若指定thread_master的event队列非空取出其头元素并用run函数处理。取出并用run函数处理timer队列中每一个之前创建的线程。若指定thread_master的ready队列非空取出其头元素并用run函数处理。拷贝该thread_master的文件描述符。将read和write链表插到ready链表中,再从ready链表取头元素用run函数处理。如此无限循环下去直到所有进程都处理完。

1.1.22 thread_consumed_time ()

得到该进程所耗费的时间。

1.1.23 thread_call ()

执行该thread中的功能函数,如果该thread持续时间超过CPU规定的独占时间,发出警告。

1.1.24 thread_execute ()

根据参数创建一个event类型的thread并用thread_call()函数对其进行处理。

1.thread的四种创建方法

一个新的thread可以通过如下三种方式被创建,主要是看你需要创建的thread的类型:

1,  thread_add_read:添加一个thread到read queue,该thread负责通过socket接受和读取从client端来的数据。

2,  thread_add_write:添加一个thread到write queue,该thread负责通过socket向client端填充和写数据。

3,  thread_add_timer function calls:添加一个thread到timer queue,该thread负责定时一个event,例如update和redistribute一个route table.

4,  thread_add_event:添加一个event thread到event queue。

上面这三个函数的处理过程都差不多:

1,  创建thread。首先在unuse queue查找,如果有unuse thread,就使用它,否则重新分配空间。

2,  根据参数,对thread进行赋值。

3,  将该thread加入到相应的queue中。

2. thread的调用

1,bgp daemon不断地从event queue中取出thread并且执行它。一旦该thread被执行了,将该thread的type设置为unuse。并且将该thread添加到unuse queue中。

2,如果event queue为空时,bgp daemon 通过select函数监控读、写、异常三个描述符集。一旦有某个描述符准备就绪,则将该描述符所对应的thread加入ready queue.

而对于timer queue中的thread,只有当select函数超时后才会进入ready queue.

3.zebrad端的thread

zebrad启动后会,在read queue中会出现两个thread,一个是等待来自local client端bgpd的连接,另一个是等待来自vty client端的连接。

第1个thread

zebra_init ( )-> zebra_serv_un ( )中创建一个thread,加入read queue。该thread的处理函数为zebra_accept,监听内部client的socket。

zebra_client_create (client_sock);创建一个新的zebra client

/* Register myself. */

zebra_event (ZEBRA_SERV, accept_sock, NULL);继续监听server socket

puts("<-zebra_accept");

return 0;

}

vty_accept,加入read queue。作为vty server监听internet的vty socket.。

vty_create(vty_sock, &su);

根据vty_sock和ip地址信息su,创建一个新的vty。

puts("<-vty_accept");

return 0;

}

vty_flush。

vty_event (VTY_READ, vty_sock, vty);

根据new client vty的vty_sock创建新的VTY_READ thread,加入read queue,该thread的处理函数为vty_read。

return vty;

}

sockunion_bind(accept_sock, &su, port, NULL);

将accept_socket文件描述符与一个特定的逻辑网络连系起来。服务器端使用su->sin.sin_addr.s_addr = htonl (INADDR_ANY);表示接受任何一个主机网络接口上的连接请求。

if (ret < 0)

{

close (accept_sock);   /* Avoid sd leak. */

return;

}

/* Listen socket under queue 3. */

ret = listen (accept_sock, 3);

将accept_sock套接口设置成被动监听状态,用于接受连接,只能在服务器端使用。

if (ret < 0)

{

zlog (NULL, LOG_WARNING, "can't listen socket");

close (accept_sock);   /* Avoid sd leak. */

return;

}

/* Add vty server event. */

vty_event(VTY_SERV, accept_sock, NULL);

}

sockunion_bind操作

/* Bind socket to specified address. */

int sockunion_bind (int sock, union sockunion *su, unsigned short port,

union sockunion *su_addr)

{

int size = 0;

int ret;

if (su->sa.sa_family == AF_INET)

{

size = sizeof (struct sockaddr_in);

su->sin.sin_port = htons (port);

#ifdef HAVE_SIN_LEN

su->sin.sin_len = size;

#endif /* HAVE_SIN_LEN */

if (su_addr == NULL)

su->sin.sin_addr.s_addr = htonl (INADDR_ANY);

服务器一般将sin_addr.s_addr 字段设置为INADDR_ANY表示套接字应接收任何一个主机网络接口上的连接请求。

客户端将sin_addr.s_addr字段设置为服务器主机的IP地址。

}

#ifdef HAVE_IPV6

else if (su->sa.sa_family == AF_INET6)

{

size = sizeof (struct sockaddr_in6);

su->sin6.sin6_port = htons (port);

#ifdef SIN6_LEN

su->sin6.sin6_len = size;

#endif /* SIN6_LEN */

if (su_addr == NULL)

{

#if defined(LINUX_IPV6) || defined(NRL)

bzero (&su->sin6.sin6_addr, sizeof (struct in6_addr));

#else

su->sin6.sin6_addr = in6addr_any;

#endif /* LINUX_IPV6 */

}

}

#endif /* HAVE_IPV6 */

ret = bind (sock, (struct sockaddr *)su, size);

if (ret < 0)

zlog (NULL, LOG_WARNING, "can't bind socket : %s", strerror (errno));

return ret;

}

vty_event操作

/* struct thread_master *master; */

static void vty_event (enum event event, int sock, struct vty *vty)

{

struct thread *vty_serv_thread;

switch (event)

{

case VTY_SERV:

vty_serv_thread = thread_add_read (master, vty_accept, vty, sock);

vector_set_index (Vvty_serv_thread, sock, vty_serv_thread);

break;

case VTY_READ:

vty->t_read = thread_add_read (master, vty_read, vty, sock);

/* Time out treatment. */

if (vty->v_timeout)

{

if (vty->t_timeout)

thread_cancel (vty->t_timeout);

vty->t_timeout =

thread_add_timer (master, vty_timeout, vty, vty->v_timeout);

}

break;

case VTY_WRITE:

if (! vty->t_write)

vty->t_write = thread_add_write (master, vty_flush, vty, sock);

break;

case VTY_TIMEOUT_RESET:

if (vty->t_timeout)

{

thread_cancel (vty->t_timeout);

vty->t_timeout = NULL;

}

if (vty->v_timeout)

{

vty->t_timeout =

thread_add_timer (master, vty_timeout, vty, vty->v_timeout);

}

break;

}

}

第2 个thread

bgp_serv_sock( )-> bgp_serv_sock_family( )中创建一个thread,不是通过event的方式添加的。该thread的处理函数为bgp_accept。作为bgp_server,接受来自 internet上的bgp连接。

bgp_serv_sock_family操作

port = 179  family = AF_INET

/* Make bgpd's server socket. */

void bgp_serv_sock_family (unsigned short port, int family)

{

int ret;

int bgp_sock;

union sockunion su;

bzero (&su, sizeof (union sockunion));

/* Specify address family. */

su.sa.sa_family = family;

bgp_sock = sockunion_stream_socket (&su);  产生一个BGP socket

sockopt_reuseaddr (bgp_sock);

sockopt_reuseport (bgp_sock);

ret = sockunion_bind (bgp_sock, &su, port, NULL);

ret = listen (bgp_sock, 3);

if (ret < 0)

{

zlog (NULL, LOG_INFO, "Can't listen bgp server socket : %s",

strerror (errno));

return;

}

thread_add_read (master, bgp_accept, NULL, bgp_sock); 添加一个thread 到readlist中。

}

VTY server和BGP server 在使用accept操作的方法如下:

他们均是通过创建一个THREAD_READ 类型的thread,加到Master的readlist的队列后面,thread 的处理函数会执行accept操作。

VTY accept:

vty_serv_thread = thread_add_read (master, vty_accept, vty, sock);

BGP server accept:

thread_add_read (master, bgp_accept, NULL, bgp_sock);

bgpd和zebrad间通信

bgp和zebra是通过zebra message进行通信,格式如下:

报文头3字节 (前两字节length,后1字节为command type)

报文体长度不定。

/* Zebra message types. */

#define ZEBRA_INTERFACE_ADD                1

#define ZEBRA_INTERFACE_DELETE             2

#define ZEBRA_INTERFACE_ADDRESS_ADD        3

#define ZEBRA_INTERFACE_ADDRESS_DELETE     4

#define ZEBRA_INTERFACE_UP                 5

#define ZEBRA_INTERFACE_DOWN               6

#define ZEBRA_IPV4_ROUTE_ADD               7

#define ZEBRA_IPV4_ROUTE_DELETE            8

#define ZEBRA_IPV6_ROUTE_ADD               9

#define ZEBRA_IPV6_ROUTE_DELETE           10

#define ZEBRA_REDISTRIBUTE_ADD            11

#define ZEBRA_REDISTRIBUTE_DELETE         12

#define ZEBRA_REDISTRIBUTE_DEFAULT_ADD    13

#define ZEBRA_REDISTRIBUTE_DEFAULT_DELETE 14

#define ZEBRA_IPV4_NEXTHOP_LOOKUP         15

#define ZEBRA_IPV6_NEXTHOP_LOOKUP         16

bgpd和zebrad之间的api接口

bgp端接受到message后,会执行相应的bgp action:

bgp action func                                         message type

int (*interface_add) (int, struct zclient *, zebra_size_t);        ZEBRA_INTERFACE_ADD

int (*interface_delete) (int, struct zclient *, zebra_size_t);                       ZEBRA_INTERFACE_DELETE

int (*interface_up) (int, struct zclient *, zebra_size_t);                           ZEBRA_INTERFACE_UP

int (*interface_down) (int, struct zclient *, zebra_size_t);                       ZEBRA_INTERFACE_DOWN

int (*interface_address_add) (int, struct zclient *, zebra_size_t);          ZEBRA_INTERFACE_ADDRESS_ADD

int (*interface_address_delete) (int, struct zclient *, zebra_size_t);  ZEBRA_INTERFACE_ADDRESS_DELETE

int (*ipv4_route_add) (int, struct zclient *, zebra_size_t);                   ZEBRA_IPV4_ROUTE_ADD

int (*ipv4_route_delete) (int, struct zclient *, zebra_size_t);                  ZEBRA_IPV4_ROUTE_DELETE

int (*ipv6_route_add) (int, struct zclient *, zebra_size_t);                    ZEBRA_IPV6_ROUTE_ADD

int (*ipv6_route_delete) (int, struct zclient *, zebra_size_t);                 ZEBRA_IPV6_ROUTE_DELETE

zebra 端接受到message后,会执行相应的zebra action:

zebra action func                                                              message type

void zread_interface_add (struct zserv *client, u_short length)                         ZEBRA_INTERFACE_ADD

void zread_interface_delete (struct zserv *client, u_short length)                     ZEBRA_INTERFACE_DELETE

void zread_ipv4_add (struct zserv *client, u_short length)                                    ZEBRA_IPV4_ROUTE_ADD

void zread_ipv4_delete (struct zserv *client, u_short length)                                   ZEBRA_IPV4_ROUTE_DELETE

void zread_ipv6_add (struct zserv *client, u_short length)                              ZEBRA_IPV6_ROUTE_ADD

void zread_ipv6_delete (struct zserv *client, u_short length)                           ZEBRA_IPV6_ROUTE_DELETE

void zebra_redistribute_add (int command, struct zserv *client, int length)              ZEBRA_REDISTRIBUTE_ADD

void zebra_redistribute_delete (int command, struct zserv *client, int length)           ZEBRA_REDISTRIBUTE_DELETE

voidzebra_redistribute_default_add (int command,

struct zserv *client, int length)                                  ZEBRA_REDISTRIBUTE_DEFAULT_ADD

void zebra_redistribute_default_delete (int command,

struct zserv *client, int length)                               ZEBRA_REDISTRIBUTE_DEFAULT_DELETE

void zread_ipv4_nexthop_lookup (struct zserv *client, u_short length)     ZEBRA_IPV4_NEXTHOP_LOOKUP

void zread_ipv6_nexthop_lookup (struct zserv *client, u_short length)        ZEBRA_IPV6_NEXTHOP_LOOKUP

bgp action:将local_client_socket中数据,写入bgp数据库。

zebra action:将zebra数据库中的信息写入local_server_subsocket,让local client端进行读取。

bgp peer间通信

bgp_accept操作

/* Accept bgp connection. */

int bgp_accept (struct thread *thread)

{

int bgp_sock;

int accept_sock;

union sockunion su;

struct peer *peer;

struct peer *peer1;

char buf[SU_ADDRSTRLEN];

/* Regiser accept thread. */

accept_sock = THREAD_FD (thread);

printf("->bgp_accept [%d]\n",accept_sock);

thread_add_read (master, bgp_accept, NULL, accept_sock);

/* Accept client connection. */

bgp_sock = sockunion_accept (accept_sock, &su);

if (bgp_sock < 0)

{

zlog_err ("[Error] BGP socket accept failed (%s)", strerror (errno));

printf("[Error] BGP socket accept failed (%s)", strerror (errno));

puts("<-bgp_accept 2");

return -1;

}

if (BGP_DEBUG (events, EVENTS))

zlog_info ("[Event] BGP connection from host %s", inet_sutop (&su, buf));

printf("[Event] BGP connection from host %s", inet_sutop (&su, buf));

/* Check remote IP address */

peer1 = peer_lookup_by_su (&su);

if (! peer1 || peer1->status == Idle)

{

if (BGP_DEBUG (events, EVENTS))

{

if (! peer1)

zlog_info ("[Event] BGP connection IP address %s is not configured",

inet_sutop (&su, buf));

else

zlog_info ("[Event] BGP connection IP address %s is Idle state",

inet_sutop (&su, buf));

}

close (bgp_sock);

puts("<-bgp_accept 2");

return -1;

}

/* Make dummy peer until read Open packet. */

if (BGP_DEBUG (events, EVENTS))

zlog_info ("[Event] Make dummy peer structure until read Open packet");

printf("[Event] Make dummy peer structure until read Open packet\n");

{

char buf[SU_ADDRSTRLEN + 1];

peer = peer_create_accept ();

SET_FLAG (peer->sflags, PEER_STATUS_ACCEPT_PEER);

peer->su = su;

peer->fd = bgp_sock;

peer->status = Active;

/* Make peer's address string. */

sockunion2str (&su, buf, SU_ADDRSTRLEN);

peer->host = strdup (buf);

}

BGP_EVENT_ADD (peer, TCP_connection_open); 创建一个event thread执行bgp_event函数

puts("<-bgp_accept 0");

return 0;

}

bgp_event操作

/* Execute event process. */

int bgp_event (struct thread *thread)

{

int ret;

int event;

int next;

struct peer *peer;

peer = THREAD_ARG (thread); // get peer

event = THREAD_VAL (thread); // get FSM event  eg.TCP_connection_open

puts("->bgp_event");

/* Logging this event. */

next = FSM [peer->status -1][event - 1].next_state;  next为下一个状态

if (BGP_DEBUG (fsm, FSM))

plog_info (peer->log, "%s [FSM] %s (%s->%s)", peer->host,

bgp_event_str[event],

LOOKUP (bgp_status_msg, peer->status),

LOOKUP (bgp_status_msg, next));

printf("%s [FSM] %s (%s->%s)", peer->host,

bgp_event_str[event],

LOOKUP (bgp_status_msg, peer->status),

LOOKUP (bgp_status_msg, next));

/* Call function. */

ret = (*(FSM [peer->status - 1][event - 1].func))(peer); 执行本状态处理函数

/* When function do not want proceed next job return -1. */

if (ret < 0)

{

puts("<-bgp_event 1");

return ret;

}

/* If status is changed. */

if (next != peer->status)               判断状态是否需要转变

fsm_change_status (peer, next);

/* Make sure timer is set. */

bgp_timer_set (peer);

puts("<-bgp_event 0");

return 0;

}

zebra 线程流具体分析相关推荐

  1. Mocha NTA基于单采集器实现的多种流协议分析

    业内主流的Flow协议技术         网络业界基于流(Flow)的分析技术主要有NetFlow.sFlow.cFlow和NetStreem四种.NetFlow是Cisco公司的独有技术,它既是一 ...

  2. JAVA线程池的分析和使用

    1. 引言 合理利用线程池能够带来三个好处.第一:降低资源消耗.通过重复利用已创建的线程降低线程创建和销毁造成的消耗.第二:提高响应速度.当任务到达时,任务可以不需要等到线程创建就能立即执行.第三:提 ...

  3. H264码流打包分析(精华)

    H264码流打包分析 SODB 数据比特串-->最原始的编码数据 RBSP 原始字节序列载荷-->在SODB的后面填加了结尾比特(RBSP trailing bits 一个bit" ...

  4. java 线程池原理分析

    一.为什么使用线程池 1.降低资源消耗,减少线程创建和销毁次数,每个工作线程可以重复利用,执行多个任务 2.可根据系统承受能力,调整工作线程的数目,防止消耗过多的内存 二.java 线程池使用 Exe ...

  5. 聊聊并发(三)——JAVA线程池的分析和使用

    1. 引言 合理利用线程池能够带来三个好处.第一:降低资源消耗.通过重复利用已创建的线程降低线程创建和销毁造成的消耗.第二:提高响应速度.当任务到达时,任务可以不需要等到线程创建就能立即执行.第三:提 ...

  6. JAVA线程池的分析和使用--笔记

    1. 引言 合理利用线程池能够带来三个好处.第一:降低资源消耗.通过重复利用已创建的线程降低线程创建和销毁造成的消耗.第二:提高响应速度.当任务到达时,任务可以不需要等到线程创建就能立即执行.第三:提 ...

  7. C++11中线程所有权转移分析

    移动特性说明 C++标准库中有很多资源占有(resource-owning)类型,比如std::ifstream,std::unique_ptr还有std::thread都是可移动,但不可拷贝. 移动 ...

  8. H264码流打包分析

    H264码流打包分析 SODB 数据比特串-->最原始的编码数据 RBSP 原始字节序列载荷-->在SODB的后面填加了结尾比特(RBSP trailing bits 一个bit" ...

  9. StreamDM:基于Spark Streaming、支持在线学习的流式分析算法引擎

    StreamDM:基于Spark Streaming.支持在线学习的流式分析算法引擎 streamDM:Data Mining for Spark Streaming,华为诺亚方舟实验室开源了业界第一 ...

最新文章

  1. CV之OpenCV:OpenCV库涉及概念、常见函数、常用案例、HALCON软件简介之详细攻略
  2. ArcSDE服务入门
  3. 2.Prometheus 监控技术与实践 --- Prometheus基本概念及部署
  4. BMC远程管理服务器
  5. 龙骨(Dragonbones)在Unity中换装的实现
  6. 如何“杀”趋势杀毒网络版
  7. Window平台Git-Bash的主题配置
  8. DVWA靶机-反射性XSS漏洞(Reflected)
  9. Gartner:首席信息官能从IT支出中得到哪些收获?
  10. 【人体骨骼点】数据集
  11. 北师大c语言2019在线作业,2019最新C语言考试题库及答案
  12. Spring boot 配置健康检查
  13. 二进制空间权重矩阵_空间权重矩阵(SWM)
  14. 迷你打印机或中小型打印机设置纸张大小,与不规格纸张修改
  15. 职场英语--邮件自动回复模板
  16. STM32 USART TX
  17. 人工智能从入门到精通系列教学视频免费送。。。
  18. Python_文件操作_深入
  19. 案例分享 | 汽车连接器焊锡质量检测
  20. Fiddler 4抓取http请求并修改请求进行调试

热门文章

  1. android开发获取手机屏幕分辨率
  2. 关于移动硬盘在WIN10无法打开解决方案
  3. js添加多marker 高德地图_【高德地图API】从零开始学高德JS API(三)覆盖物——标注|折线|多边形|信息窗口|聚合marker|麻点图|图片覆盖物...
  4. 盘点2021年流行报表开发工具【测评】
  5. 嵌入式数据库开发编程(一)——概述
  6. 多目标跟踪方法 A Baseline for 3D Multi-Object Tracking
  7. 从调整RSRQ测量参数提升4G驻留比
  8. 移动通信网络规划:覆盖场景划分
  9. 《PyInstaller打包实战指南》第二节 PyInstaller的两种打包模式
  10. 【论文代码复现2】Clustered sampling based on sample size