1.开场白

  • 环境:

  • 处理器架构:arm64

  • 内核源码:linux-5.11

  • ubuntu版本:20.04.1

  • 代码阅读工具:vim+ctags+cscope

在linux系统中, 我们接触最多的莫过于用户空间的任务,像用户线程或用户进程,因为他们太活跃了,也太耀眼了以至于我们感受不到内核线程的存在,但是内核线程却在背后默默地付出着,如内存回收,脏页回写,处理大量的软中断等,如果没有内核线程那么linux世界是那么的可怕!本文力求与完整介绍完内核线程的整个生命周期,如内核线程的创建、调度等等,当然本文还是主要从内存管理和进程调度两个维度来解析,且不会涉及到具体的内核线程如kswapd的实现,最后我们会以一个简单的内核模块来说明如何在驱动代码中来创建使用内核线程。

在进入我们真正的主题之前,我们需要知道一下事实:

1. 内核线程永远运行于内核态绝不会跑到用户态去执行。2.由于内核线程运行于内核态,所有它的权限很高,请注意这里说的是权限很高并不意味着它的优先级高,所有他可以直接做到操作页表,维护cache, 读写系统寄存器等操作。3.内核线性是没有地址空间的概念,准确的来说是没有用户地址空间的概念,使用的是所有进程共享的内核地址空间,但是调度的时候会借用前一个进程的地址空间。4.内核线程并没有什么特别神秘的地方,他和普通的用户任务一样参与系统调度,也可以被迁移到任何cpu上运行。5.每个cpu都有自己的idle进程,实质上也是内核线程,但是他们比较特殊,一来是被静态创建,二来他们的优先级最低,cpu上没有其他进程运行的时候idle进程才运行。6.除了初始化阶段0号内核线程和kthreadd本身,其他所有的内核线程都是被kthreadd内核线程来间接创建。

2.kthreadd的诞生

盘古开天辟地,我们知道linux所有任务的祖先是0号进程,然后0号进程创建了天字第一号的1号init进程,init进程是所有用户任务的祖先,而内核线程同样也有自己的祖先那就是kthreadd内核线程他的pid是2,我们通过top命令可以观察到:红色方框都是父进程为2号进程的内核线程,绿色方框为kthreadd,他的父进程为0号进程。


下面我们来看内核线程的祖先线程kthreadd如何创建的:

start_kernel   //init/main.c
->arch_call_rest_init->rest_init->pid = kernel_thread(kthreadd, NULL, CLONE_FS | CLONE_FILES)

可以看的在rest_init中调用kernel_thread来创建kthreadd内核线程,实际上初始化阶段有两个内核线程比较特殊一个是0号的idle(唯一一个没有通过fork创建的任务),一个是被idle创建的kthreadd内核线程(内核初始化阶段可以看成idle进程在做初始化)。

我们再来看看kernel_thread是如何实现的:

/*      * Create a kernel thread.*/
pid_t kernel_thread(int (*fn)(void *), void *arg, unsigned long flags)
{struct kernel_clone_args args = {.flags          = ((lower_32_bits(flags) | CLONE_VM |        ¦   CLONE_UNTRACED) & ~CSIGNAL),.exit_signal    = (lower_32_bits(flags) & CSIGNAL),.stack          = (unsigned long)fn,                         .stack_size     = (unsigned long)arg,                        };return kernel_clone(&args);
}       

这里需要注意两点:1.fork时传递了CLONE_VM标志  2.如何标识要创建出来的是内核线程不是普通的用户任务

我们先来看看CLONE_VM标志对fork的影响:

kernel_clone
->copy_process  ->copy_mm->dup_mm->....1394         tsk->mm = NULL;                                   1395         tsk->active_mm = NULL;                            1396                                                           1397         /*                                                1398         ¦* Are we cloning a kernel thread?                1399         ¦*                                                1400         ¦* We need to steal a active VM for that..        1401         ¦*/                                               1402         oldmm = current->mm;                              1403         if (!oldmm)                                       1404                 return 0;                                 1405                                                           1406         /* initialize the new vmacache entries */         1407         vmacache_flush(tsk);                              1408                                                           1409         if (clone_flags & CLONE_VM) {                     1410                 mmget(oldmm);                             1411                 mm = oldmm;                               1412                 goto good_mm;                             1413         }                                                 1414                                                           1415         retval = -ENOMEM;                                 1416         mm = dup_mm(tsk, current->mm);                    1417         if (!mm)                                          1418                 goto fail_nomem;                          1419                                                           1420 good_mm:                                                  1421         tsk->mm = mm;                                     1422         tsk->active_mm = mm;                              1423         return 0;                                         

可以看的当我们传递了CLONE_VM标志之后,本来应该走到1409 行进程处理的,但是我们需要知道的是1403 行可能判断为空,因为这里父进程为idle为内核线程,凭直觉我们知道代码应该从 1404 返回了,但是不能光凭直觉要拿出证据,那就需要看看idle进程长啥样了:

64 struct task_struct init_task                  //init/init_task.c
69 = {                                                       ...
85         .mm             = NULL,
86         .active_mm      = &init_mm,                       

上面是静态创建的idle进程,可以看的他的进程控制块的 .mm 为空, .active_mm   为&init_mm,所有啊,我们的kthreadd内核线程的tsk->mm =  tsk->active_mm =NULL;所以我们上面的猜想是对的代码直接从 1404 返回了,这里也是他应该拥有的属性,因为我们知道内核线程没有用户地址空间(使用tsk->mm来描述),所以所有的内核线程的tsk->mm都为空,这也是判断任务是否为内核线程的一个条件,但是tsk->active_mm 就不一定了,内核线程在每次进程切换的时候都会借用前一个进程的tsk->active_mm 赋值到自己tsk->active_mm 上,后面会讲到。这里需要注意的是,有一个内核线程很特殊,特殊到他的tsk->active_mm 不是在进程切换的时候被赋值而是静态初始化号,他就是上面的idle线程 .active_mm      = &init_mm。

我们来看下init_mm是什么内容,有什么猫腻:

mm/init-mm.cstruct mm_struct init_mm = {.mm_rb          = RB_ROOT,.pgd            = swapper_pg_dir,     ...

可以看到他的特殊之处在于它的tsk->active_mm->pgd为swapper_pg_dir,我们知道这是主内核页表,我们知道系统初始化的时候,会出现3个特殊的任务0,1,2号,这几个任务刚开始都是内核线程,他们之间进行切换的时候使用的都是swapper_pg_dir这个页表,也很合理,因为都访问内核空间,一旦有用户进程介入参与调度了就不一样了,就可以借用用户的tsk->active_mm->pgd(这个时候不再是swapper_pg_dir,但是没有关系,通过ttbr1_el1同样可以访问到swapper_pg_dir页表来访问内核空间)。

再来看看如何标识要创建的是内核线程的?

kernel_clone
->copy_process->copy_thread   //arch/arm64/kernel/process.c->  ...if (likely(!(p->flags & PF_KTHREAD))) {   //创建用户任务的情况...} else {   //创建内核线程的情况memset(childregs, 0, sizeof(struct pt_regs));        childregs->pstate = PSR_MODE_EL1h | PSR_IL_BIT;      p->thread.cpu_context.x19 = stack_start;             p->thread.cpu_context.x20 = stk_sz;                  }                                                            

以上路径是为创建任务准备调度上下文和异常返回现场,调度上下文由 p->thread.cpu_context来描述,异常返回现场由保存在内核栈的struct pt_regs来描述,在这里判断p->flags & PF_KTHREAD))是否成立,也就是如果p->flags设置了PF_KTHREAD标志则是创建内核线程,但是我们找了一圈貌似没有找到在哪个位置设置这个标志的,那究竟在哪设置的呢?我们还是首先回到它的父进程也就是idle进程:

struct task_struct init_task
= {   ....flags          = PF_KTHREAD, ...
}

凭直觉,应该是父进程设置了然后赋值给了子进程,那我们就要看看合适赋值的:

copy_process
->dup_task_struct->arch_dup_task_struct->*dst = *src;

我们看的会把父进程的的task的内容赋值给子进程,然后后面在进程一些个性化设置,.flags = PF_KTHREAD也被设置给了子进程。

ok, 分析到这里idle就创建好了kthreadd内核线程,通过wake_up_new_task唤醒kthreadd运行:当它唤醒被调度后,就会恢复调度上下文,就是上面说的 p->thread.cpu_context,具体如何执行到内核线程指定的执行函数后面我们会讲解!

但是我们需要知道的是,kthreadd被调度执行后执行kthreadd这个函数!!!这个函数实现在:kernel/kthread.c中。

3. kthreadd内核线程处理流程

上面我们介绍了kthreadd内核线程的创建过程,接下来看一下kthreadd做了哪些事情:

代码路径为:kernel/kthread.c

kthreadd函数中设置了线程名字和亲和性属性之后 进入下面给出的循环处理流程:


它首先将自己的状态设置为TASK_INTERRUPTIBLE,然后判断kthread_create_list链表是否为空,这个链表存放其他内核路径的创建内核线程的请求结构struct kthread_create_info:

kernel/kthread.c
struct kthread_create_info
{               /* Information passed to kthread() from kthreadd. */          int (*threadfn)(void *data);          //请求创建的内核线程处理函数                      void *data;    //传递给请求创建的内核线程的参数int node;                                                     /* Result passed back to kthread_create() from kthreadd. */   struct task_struct *result;  //请求创建的内核线程的tsk结构struct completion *done;                                      struct list_head list;   //用于加 入kthread_create_list链表
};

有创建内核线程时,会封装kthread_create_info结构然后加入到kthread_create_list链表中。

如果kthread_create_list链表为空,说明没有创建内核线程的请求,则直接调用schedule进行睡眠。当某个内核路径有kthread_create_info结构加入到kthread_create_list链表中并唤醒kthreadd后,kthreadd从__set_current_state(TASK_RUNNING)开始执行,设置状态为运行状态,然后进入一个循环,不断的从kthread_create_list.next取出kthread_create_info结构,并从链表中删除,调用create_kthread创建一个内核线程来执行剩余的工作。

create_kthread很简单,就是创建内核线程,然后执行kthread函数,将取到的kthread_create_info结构传递给这个函数:

kernel/kthread.c
create_kthread
-> pid = kernel_thread(kthread, create, CLONE_FS | CLONE_FILES | SIGCHLD)

4.kthread处理流程

当kthreadd内核线程创建内核线程之后就完成了它的使命,开始处理kthread_create_list链表上的下一个内核线程创建请求,主要工作交给了kthread函数来处理。实际上,kthreadd创建的内核线程就是请求创建的内核线程的外壳,只不过创建完成之后并没有马上执行线程的执行函数,这和用户空间执行程序很相似:一般在shell中执行程序,首先shell进程通过fork创建一个子进程,然后子进程中调用exec来加载新的程序。而创建内核线程也必须首先要创建一个子进程,这是kthreadd通过kernel_thread来完成的,然后在kthread执行函数中在合适的时机来执行所请求的内核线程执行函数。这说起来有点绕,因为这里涉及到了三个任务:kthreadd内核线程,kthreadd内核线程通过kernel_thread创建的内核线程,往kthread_create_list链表加入创建请求的那个任务

注:执行kthread函数处于新创建的内核线程上下文!

下面我们来看下kthreadd内核线程创建的内核线程的执行函数kthread:这里传递给kthread的参数就是从kthread_create_list链表摘取的创建结构kthread_create_info,函数中又出现了一个新的结构struct kthread:

kernel/kthread.c
struct kthread {unsigned long flags;unsigned int cpu;int (*threadfn)(void *);     //线程执行函数void *data;    //线程执行函数传递的参数mm_segment_t oldfs;struct completion parked;struct completion exited;
#ifdef CONFIG_BLK_CGROUPstruct cgroup_subsys_state *blkcg_css;
#endif
};

其中比较重要的是threadfn和data。kthread函数并不长,我们把代码都罗列如下:

244 static int kthread(void *_create)
245 {
246         /* Copy data: it's on kthread's stack */
247         struct kthread_create_info *create = _create;      //获取传递过来的线程创建信息
248         int (*threadfn)(void *data) = create->threadfn;     //取出 线程执行函数
249         void *data = create->data;   //取出 传递给 线程执行函数的参数
250         struct completion *done;
251         struct kthread *self;
252         int ret;
253
254         self = kzalloc(sizeof(*self), GFP_KERNEL);    //分配  kthread   结构
255         set_kthread_struct(self);       //current->set_child_tid = (__force void __user *)kthread
256
257         /* If user was SIGKILLed, I release the structure. */
258         done = xchg(&create->done, NULL);  //获得 done完成量
259         if (!done) {
260                 kfree(create);
261                 do_exit(-EINTR);
262         }
263
264         if (!self) {
265                 create->result = ERR_PTR(-ENOMEM);
266                 complete(done);
267                 do_exit(-ENOMEM);
268         }
269
270         self->threadfn = threadfn;   //  赋值   self->threadfn 为  线程执行函数
271         self->data = data;             //  赋值          self->data    线程执行函数的参数
272         init_completion(&self->exited);
273         init_completion(&self->parked);
274         current->vfork_done = &self->exited;
276         /* OK, tell user we're spawned, wait for stop or wakeup */
277         __set_current_state(TASK_UNINTERRUPTIBLE);  //设置内核线程状态为 TASK_UNINTERRUPTIBLE   但是此时还没又睡眠
278         create->result = current;      //用于返回 当前任务的tsk
279         /*
280         ¦* Thread is going to call schedule(), do not preempt it,
281         ¦* or the creator may spend more time in wait_task_inactive().
282         ¦*/
283         preempt_disable();
284         complete(done);     //唤醒等待done完成量的任务
285         schedule_preempt_disabled();  //睡眠
286         preempt_enable();           //唤醒的时候从此开始执行
287
288         ret = -EINTR;
289         if (!test_bit(KTHREAD_SHOULD_STOP, &self->flags)) {     //判断    self->flags是否为   KTHREAD_SHOULD_STOP(kthread_stop会设置)
290                 cgroup_kthread_ready();
291                 __kthread_parkme(self);
292                 ret = threadfn(data);        //执行  真正的线程执行函数
293         }
294         do_exit(ret);     //当前任务退出
295 }

可以看到,kthread函数用到了一些完成量和睡眠函数,如果单独看这个函数肯定会一头雾水,要理解这个函数需要回答一下几个问题:

1.284行的complete(done) 是唤醒哪个任务的?

2.当前内核线程在285 行睡眠后 谁来唤醒我?

5.kthread_run函数

这里我们以kthread_run为例来解答这两个问题:

kthread_run这个内核api用来创建内核线程并唤醒执行传递的执行函数。调用路径如下:

include/linux/kthread.h
#define kthread_run(threadfn, data, namefmt, ...)                          \
({                                                                         \    struct task_struct *__k                                            \    = kthread_create(threadfn, data, namefmt, ## __VA_ARGS__); \    //创建内核线程 if (!IS_ERR(__k))                                                  \    wake_up_process(__k);                                      \     //唤醒创建的内核线程__k;                                                               \
})                                                                              

kthread_run这个宏传递三个参数:执行函数,执行函数传递的参数,格式化线程名字

我们先来看下kthread_create函数:

4.1 kthread_create函数

kthread_create
->kthread_create_on_node->__kthread_create_on_node

__kthread_create_on_node函数并不长我们全部罗列:

330 struct task_struct *__kthread_create_on_node(int (*threadfn)(void *data),
331                                                 ¦   void *data, int node,
332                                                 ¦   const char namefmt[],
333                                                 ¦   va_list args)
334 {
335         DECLARE_COMPLETION_ONSTACK(done);
336         struct task_struct *task;
337         struct kthread_create_info *create = kmalloc(sizeof(*create),
338                                                 ¦    GFP_KERNEL);   //分配    kthread_create_info结构
339
340         if (!create)
341                 return ERR_PTR(-ENOMEM);
342         create->threadfn = threadfn;      //填充kthread_create_info结构  如执行函数等
343         create->data = data;
344         create->node = node;
345         create->done = &done;
346
347         spin_lock(&kthread_create_lock);
348         list_add_tail(&create->list, &kthread_create_list);    //kthread_create_info结构添加到  kthread_create_list 链表
349         spin_unlock(&kthread_create_lock);
350
351         wake_up_process(kthreadd_task);     //唤醒 kthreadd来处理创建内核线程请求
352         /*
353         ¦* Wait for completion in killable state, for I might be chosen by
354         ¦* the OOM killer while kthreadd is trying to allocate memory for
355         ¦* new kernel thread.
356         ¦*/
357         if (unlikely(wait_for_completion_killable(&done))) {   //等待请求的内核线程创建完成
358                 /*
359                 ¦* If I was SIGKILLed before kthreadd (or new kernel thread)
360                 ¦* calls complete(), leave the cleanup of this structure to
361                 ¦* that thread.
362                 ¦*/
363                 if (xchg(&create->done, NULL))
364                         return ERR_PTR(-EINTR);
365                 /*
366                 ¦* kthreadd (or new kernel thread) will call complete()
367                 ¦* shortly.
368                 ¦*/
369                 wait_for_completion(&done);
370         }
371         task = create->result;    //获得 创建完成的   内核线程的tsk
372         if (!IS_ERR(task)) {  //  内核线程创建成功后 进行后续的处理
373                 static const struct sched_param param = { .sched_priority = 0 };
374                 char name[TASK_COMM_LEN];
375
376                 /*
377                 ¦* task is already visible to other tasks, so updating
378                 ¦* COMM must be protected.
379                 ¦*/
380                 vsnprintf(name, sizeof(name), namefmt, args);
381                 set_task_comm(task, name);  //设置   内核线程的名字
382                 /*
383                 ¦* root may have changed our (kthreadd's) priority or CPU mask.
384                 ¦* The kernel thread should not inherit these properties.
385                 ¦*/
386                 sched_setscheduler_nocheck(task, SCHED_NORMAL, &param);    //设置 调度策略和优先级
387                 set_cpus_allowed_ptr(task,
388                                 ¦    housekeeping_cpumask(HK_FLAG_KTHREAD));     //设置cpu亲和性
389         }
390         kfree(create);
391         return task;
392 }

关于__kthread_create_on_node函数需要明白以下几点:1.__kthread_create_on_node函数处于一个进程上下文如insmod进程 2.__kthread_create_on_node函数需要与两个任务交互,一个是kthreadd,一个是kthreadd的创建的内核线程(执行函数为kthread)

函数中已经做了详细的注释,这里在说明一下:首先函数将需要在内核线程中执行的函数等信息封装在kthread_create_info结构中,然后加入到kthreadd的kthread_create_list链表,接着去唤醒kthreadd去处理创建内核线程请求,上面kthreadd函数我们分析过kthreadd函数会创建一个内核线程来执行kthread函数,并将kthread_create_info结构传递过去,在kthread函数中会通过complete(done)来唤醒357的完成等待(这就回答了第一个问题),  然后__kthread_create_on_node接着进行初始化,但是需要明白的是新创建的内核线程现在处于睡眠状态,等待被唤醒。

4.2 wake_up_process唤醒

上面通过kthread_create创建完成内核线程之后,内核线程处于TASK_UNINTERRUPTIBLE状态,等待被唤醒,这个时候kthread_run调用wake_up_process唤醒新创建的内核线程,内核线程愉快的执行,走到了kthread函数的threadfn(data)处,执行真正的线程处理,至此,新创建的内核线程开始完成实质性的工作。

6. kthread_stop函数

一般通过kthread_create创建的内核线程可以通过kthread_stop来停止:

609 int kthread_stop(struct task_struct *k)
610 {
611         struct kthread *kthread;
612         int ret;
613
614         trace_sched_kthread_stop(k);
615
616         get_task_struct(k);
617         kthread = to_kthread(k);    //tsk中获得kthread 结构
618         set_bit(KTHREAD_SHOULD_STOP, &kthread->flags); //设置KTHREAD_SHOULD_STOP标志
619         kthread_unpark(k);
620         wake_up_process(k);   //唤醒
621         wait_for_completion(&kthread->exited); //等待退出完成
622         ret = k->exit_code;   //获得退出码
623         put_task_struct(k);
624
625         trace_sched_kthread_stop_ret(ret);
626         return ret;
627 }

一般内核线程会循环执行一些事务,每次循环开始会调用kthread_should_stop来判断线程是否应该停止:

bool kthread_should_stop(void)
{return test_bit(KTHREAD_SHOULD_STOP, &to_kthread(current)->flags);  //判断KTHREAD_SHOULD_STOP标志是否置位
}

在某个内核路径调用kthread_stop,内核线程每次循环开始的时候,如果检查到KTHREAD_SHOULD_STOP标志置位,就会退出,然后调用do_exit完成退出操作。

上面讲解到很多函数也涉及到很多任务,下面总结一下:1.涉及到的函数有:kthreadd, kthread,kthread_run,kthread_create, wake_up_process, kthread_stop, kthread_should_stop kthreadd:为kthreadd内核线程执行函数,处理内核线程创建任务。kthread:每次kthreadd创建新的内核线程都会执行kthread,里面会涉及到睡眠和唤醒后执行线程执行函数操作。kthread_run:创建并唤醒一个内核线程 kthread_create:创建一个内核线程,创建之后处于TASK_UNINTERRUPTIBLE状态 wake_up_process:唤醒一个任务 kthread_stop:停止一个内核线程 kthread_should_stop:判断一个内核线程是否应该停止2.涉及到的kthreadd内核线程,新创建的内核线程,发起创建内核线程请求的任务,他们直接通过完成量进行同步 3.睡眠唤醒流程:先设置进程状态为TASK_UNINTERRUPTIBLE这样的状态,然后调度出去,唤醒的时候在调度回来

好了,下面给出精心制作的调用图示:

上面已经讲解完了,内核线程是如何被创建的,又是如何执行处理函数的,涉及到多个任务直接同步问题,看代码的时候需要多个窗口配合之看才行。

5T技术资源大放送!包括但不限于:C/C++,Arm, Linux,Android,人工智能,单片机,树莓派,等等。在公众号内回复「peter」,即可免费获取!!

 记得点击分享在看,给我充点儿电吧

深入理解Linux内核之内核线程(上)相关推荐

  1. 理解Linux和其他UNIX-Like系统上的平均负载

    理解Linux和其他UNIX-Like系统上的平均负载      Linux,Mac以及其他UNIX-like系统都能显示出"load average"信息.这些数字告诉你,你系统 ...

  2. 理解Linux的进程,线程相关的各类ID:PID,LWP,TID,TGID

    最近实验室遇到了一个关于PID的问题,让我也跟着学习一下,查看了一下相关资料,找到一篇关于Linux进程和线程的各种ID的介绍,所以转载了过来. 作者:wipan 来源:cnblogs 地址:http ...

  3. 深入理解Linux文件系统之文件系统挂载(上)

    1.开场白 环境: 处理器架构:arm64 内核源码:linux-5.11 ubuntu版本:20.04.1 代码阅读工具:vim+ctags+cscope 我们知道,Linux系统中我们经常将一个块 ...

  4. linux下查看进程的线程数,linux查看进程的线程数

    top -H -p $PID  #查看对应进程的那个线程占用CPU过高 1.top -H 手册中说:-H : Threads toggle 加上这个选项启动top,top一行显示一个线程.否则,它一行 ...

  5. 深入理解Linux文件系统之文件系统挂载(下)

    接着: 深入理解Linux文件系统之文件系统挂载(上) 本文为文件系统挂载专题文章的第二篇,主要介绍如何通过挂载实例关联挂载点和超级块并添加到全局文件系统树. 4. 添加到全局文件系统树 4.1 do ...

  6. 深入理解Linux内核01:内存寻址

    目录 1. 内存地址 1.1 三种地址 1.1.1 逻辑地址(logical address) 1.1.2 线性地址(linear address) 1.1.3 物理地址(physical addre ...

  7. 《深入理解Linux内核》 读书笔记

    深入理解Linux内核 读书笔记 一.概论 操作系统基本概念 多用户系统 允许多个用户登录系统,不同用户之间的有私有的空间 用户和组 每个用于属于一个组,组的权限和其他人的权限,和拥有者的权限不一样. ...

  8. 深入理解Linux内核-第3版 译者序、前言、目录 内核2.6.11

    一.译者序 Linux是一个全新的世界,世界意味着博大精深,而新或许代表对旧的割舍和扬弃,加在一起,就是要我们在割舍和扬弃的同时还要积累知识到博大精深的地步,这容易做到吗?是的,这不容易做到.Gera ...

  9. 深入理解Linux内核之主调度器(下)

    4.进程上下文切换 接前文:深入理解Linux内核之主调度器(上) 前面选择了一个合适进程作为下一个进程,接下来做重要的上下文切换动作,来保存上一个进程的"上下文"恢复下一个进程的 ...

  10. 深入理解LINUX内核(影印版第3版)》的笔记

    书名: 深入理解LINUX内核(影印版第3版) 作者: Daniel P.Bovet/Marco Cesati 副标题: Understanding the Linux Kernel 页数: 923 ...

最新文章

  1. 使用PlantText画时序图分析业务流程
  2. 深度学习之PyTorch物体检测
  3. 修改weblogic(10.3)域的启动JDK
  4. 时隔 15 年,苹果的自研 ARM 芯片为何能取代 Intel 处理器?
  5. ChemDraw 15支持哪些输入格式
  6. android安全攻防实践_网络攻防小组招新,等待优秀的你!
  7. SpringCloud - 2. 服务注册 和 发现
  8. ERP源码 跨境电商ERP源码 Java电商ERP源码
  9. 【SpringBoot 】 组件管理 ,java工程师面试突击中华石杉
  10. TCP三次握手、四次握手过程,以及原因分析
  11. iOS 高级工程师是怎么进阶
  12. 三分钟集成连连支付方法(以认证支付为例)
  13. adobe acrobat pro dc 无法打开PDF_PDF怎么破?你一定不想错过这些软件
  14. 【积水成渊-逐步定制自己的Emacs神器】6:首次变身IDE,Emacs C++ IDE
  15. Keras Input Layer
  16. var 和int定义变量的问题【C#】
  17. 2021年新媒体运营不可缺少的24大类工具!
  18. C语言变量类型及其表示范围
  19. 什么是HTML+CSS?
  20. iframe 边框去除,使用大全

热门文章

  1. LeetCode680.验证回文字符串(二)
  2. 【记录3】小程序账号冻结之十分钟内解决(忘记原始ID或者公众号名称的解决方法)
  3. java int...的含义
  4. corex9服务器组装攻略,TT CoreX9首测!安静地做个黑胖子决不当保险柜、烤箱、麻将桌、仓鼠笼子!...
  5. 苦海无边,回头是岸。学会运维自动化,带你脱离无边的运维苦海
  6. mds、mds_stores、mdworker 占用大量 cpu 和内存
  7. 查询手机所在地理位置的简单方法
  8. Cocos2dx-Lua与C++混合使用
  9. CIO40: 数字化中心运营管理
  10. 【初探篇】十分钟快速建站之在线论坛Discuz部署实战