开源鸿蒙内核源码分析系列 | 进程回收 | 临终托孤的短命娃(转载)

开源鸿蒙内核源码分析系列 | 进程回收 | 临终托孤的短命娃(转载)

原文来自鸿蒙研究站:http://weharmonyos.com/blog/05.html

鸿蒙研究站网址:http://weharmonyos.com 

进程关系链

进程是家族式管理的,父子关系,兄弟关系,朋友关系,子女关系,甚至陌生人关系(等待你消亡)在一个进程的生命周期中都会记录下来。用什么来记录呢?当然是内核最重要的胶水结构体LOS_DL_LIST,进程控制块(以下简称PCB)用了8个双向链表来记录进程家族的基因关系和运行时关系。如下:


typedef struct ProcessCB {
    //...此处省略其他变量
    LOS_DL_LIST          pendList;                     /**< Block list to which the process belongs */ //进程所属的阻塞列表,如果因拿锁失败,就由此节点挂到等锁链表上
    LOS_DL_LIST          childrenList;                 /**< my children process list */  //孩子进程都挂到这里,形成双循环链表
    LOS_DL_LIST          exitChildList;                /**< my exit children process list */  //那些要退出孩子进程挂到这里,白发人送黑发人。
    LOS_DL_LIST          siblingList;                  /**< linkage in my parent's children list */ //兄弟进程链表, 56个民族是一家,来自同一个父进程。
    LOS_DL_LIST          subordinateGroupList;         /**< linkage in my group list */ //进程是组长时,有哪些组员进程
    LOS_DL_LIST          threadSiblingList;            /**< List of threads under this process *///进程的线程(任务)列表
    LOS_DL_LIST          threadPriQueueList[OS_PRIORITY_QUEUE_NUM]; /**< The process's thread group schedules thepriority hash table */  //进程的线程组调度优先级哈希表
    LOS_DL_LIST          waitList;     /**< The process holds the waitLits to support wait/waitpid *///进程持有等待链表以支持wait/waitpid
} LosProcessCB;

解读:

  • pendList 个人认为它是开源鸿蒙内核功能最多的一个链表,它远不止字面意思阻塞链表这么简单,只有深入解读源码后才能体会它真的是太会来事了,一般把它理解为阻塞链表就行。上面挂的是处于阻塞状态的进程。
  • childrenList孩子链表,所有由它fork出来的进程都挂到这个链表上。上面的孩子进程在死亡前会将自己从上面摘出去,转而挂到exitChildList链表上。
  • exitChildList退出孩子链表,进入死亡程序的进程要挂到这个链表上,一个进程的死亡是件挺麻烦的事,进程池的数量有限,需要及时回收进程资源,但家族管理关系复杂,要去很多地方消除痕迹。尤其还有其他进程在看你笑话,等你死亡(wait/waitpid)了通知它们一声。
  • siblingList兄弟链表,和你同一个父亲的进程都挂到了这个链表上。
  • subordinateGroupList 朋友圈链表,里面是因为兴趣爱好(进程组)而挂在一起的进程,它们可以不是一个父亲,不是一个祖父,但一定是同一个老祖宗(用户态和内核态根进程)。
  • threadSiblingList线程链表,上面挂的是进程ID都是这个进程的线程(任务),进程和线程的关系是1:N的关系,一个线程只能属于一个进程。这里要注意任务在其生命周期中是不能改所属进程的。
  • threadPriQueueList线程的调度队列数组,一共32个,任务和进程一样有32个优先级,调度算法的过程是先找到优先级最高的进程,在从该进程的任务队列里去最高的优先级任务运行。
  • waitList 是等待子进程消亡的任务链表,注意上面挂的是任务。任务是通过系统调用。

pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int options);

      将任务挂到waitList上。开源鸿蒙waitpid系统调用为SysWait,稍后会讲。

进程正常死亡过程

一个进程的自然消亡过程如下:


//一个进程的自然消亡过程,参数是当前运行的任务
STATIC VOID OsProcessNaturalExit(LosTaskCB *runTask, UINT32 status)
{
    LosProcessCB *processCB = OS_PCB_FROM_PID(runTask->processID);//通过task找到所属PCB
    LosProcessCB *parentCB = NULL;
    LOS_ASSERT(!(processCB->threadScheduleMap != 0));//断言没有任务需要调度了,当前task是最后一个了
    LOS_ASSERT(processCB->processStatus & OS_PROCESS_STATUS_RUNNING);//断言必须为正在运行的进程
    OsChildProcessResourcesFree(processCB);//释放孩子进程的资源
#ifdef LOSCFG_KERNEL_CPUP 
    OsCpupClean(processCB->processID);
#endif
    /* is a child process */
    if (processCB->parentProcessID != OS_INVALID_VALUE) {//判断是否有父进程
        parentCB = OS_PCB_FROM_PID(processCB->parentProcessID);//获取父进程实体
        LOS_ListDelete(&processCB->siblingList);//将自己从兄弟链表中摘除,家人们,永别了!
        if (!OsProcessExitCodeSignalIsSet(processCB)) {//是否设置了退出码?
            OsProcessExitCodeSet(processCB, status);//将进程状态设为退出码
        }
        LOS_ListTailInsert(&parentCB->exitChildList, &processCB->siblingList);//挂到父进程的孩子消亡链表,家人中,永别的可不止我一个。
        LOS_ListDelete(&processCB->subordinateGroupList);//和志同道合的朋友们永别了,注意家里可不一定是朋友的,所有各有链表。
        LOS_ListTailInsert(&processCB->group->exitProcessList, &processCB->subordinateGroupList);//挂到进程组消亡链表,朋友中,永别的可不止我一个。
        OsWaitCheckAndWakeParentProcess(parentCB, processCB);//检查父进程的等待任务并唤醒任务,此处将会切换到其他任务运行。
        OsDealAliveChildProcess(processCB);//老父亲临终向各自的祖宗托孤
        processCB->processStatus |= OS_PROCESS_STATUS_ZOMBIES;//贴上僵死进程的标签
        (VOID)OsKill(processCB->parentProcessID, SIGCHLD, OS_KERNEL_KILL_PERMISSION);//以内核权限发送SIGCHLD(子进程退出)信号。
        LOS_ListHeadInsert(&g_processRecyleList, &processCB->pendList);//将进程通过其阻塞节点挂入全局进程回收链表
        OsRunTaskToDelete(runTask);//删除正在运行的任务
        return;
    }
    LOS_Panic("pid : %u is the root process exit!\n", processCB->processID);
    return;
}

解读:

  • 退群,向兄弟姐妹siblingList告别,向朋友圈(进程组)告别subordinateGroupList。
  • 留下你的死亡记录,老父亲记录到exitChildList,朋友圈记录到exitProcessList中。
  • 告诉后人死亡原因OsProcessExitCodeSet,因为waitList上挂的任务在等待你的死亡信息。
  • 向老祖宗托孤,用户态和内核态进程都有自己的祖宗进程(1和2号进程),老祖宗身子硬朗,最后死。所有的短命鬼进程都可以把自己的孩子委托给老祖宗照顾,老祖宗会一视同仁。
  • 将自己变成了OS_PROCESS_STATUS_ZOMBIES僵尸进程。
  • 老父亲跑到村口广播这个孩子已经死亡的信号OsKill。
  • 将自己挂入进程回收链表,等待回收任务ResourcesTask回收资源。
  • 最后删除这个正在运行的任务,很明显其中一定会发生一次调度OsSchedResched。

//删除一个正在运行的任务
  LITE_OS_SEC_TEXT VOID OsRunTaskToDelete(LosTaskCB *taskCB)
  {
      LosProcessCB *processCB = OS_PCB_FROM_PID(taskCB->processID);//拿到task所属进程
      OsTaskReleaseHoldLock(processCB, taskCB);//task还锁
      OsTaskStatusUnusedSet(taskCB);//task重置为未使用状态,等待回收
      LOS_ListDelete(&taskCB->threadList);//从进程的线程链表中将自己摘除
      processCB->threadNumber--;//进程的活动task --,注意进程还有一个记录总task的变量 processCB->threadCount
      LOS_ListTailInsert(&g_taskRecyleList, &taskCB->pendList);//将task插入回收链表,等待回收资源再利用
      OsEventWriteUnsafe(&g_resourceEvent, OS_RESOURCE_EVENT_FREE, FALSE, NULL);//发送释放资源的事件,事件由 OsResourceRecoveryTask 消费
      OsSchedResched();//申请调度
      return;
  }

但这是一个自然死亡的进程,还有很多非正常死亡在其他篇幅中已有说明。请自行翻看。非正常死亡的会产生僵尸进程。这种进程需要别的进程通过 waitpid来回收。

孤儿进程

The Orphan Girl at the Cemetery by Eugène Delacroix Giclee

一般情况下往往是白发人送黑发人,子进程的生命周期是要短于父进程。但因为fork之后,进程之间相互独立,调度算法一视同仁,父子之间是弱的关系力,就什么情况都可能发生了。内核是允许老父亲先走的,如果父进程退出而它的一个或多个子进程还在运行,那么这些子进程就被称为孤儿进程,孤儿进程最终将被两位老祖宗(用户态和内核态)所收养,并由老祖宗完成对它们的状态收集工作。


//当一个进程自然退出的时候,它的孩子进程由两位老祖宗收养
STATIC VOID OsDealAliveChildProcess(LosProcessCB *processCB)
{
    UINT32 parentID;
    LosProcessCB *childCB = NULL;
    LosProcessCB *parentCB = NULL;
    LOS_DL_LIST *nextList = NULL;
    LOS_DL_LIST *childHead = NULL;
    if (!LOS_ListEmpty(&processCB->childrenList)) {//如果存在孩子进程
        childHead = processCB->childrenList.pstNext;//获取孩子链表
        LOS_ListDelete(&(processCB->childrenList));//清空自己的孩子链表
        if (OsProcessIsUserMode(processCB)) {//是用户态进程
            parentID = g_userInitProcess;//用户态进程老祖宗
        } else {
            parentID = g_kernelInitProcess;//内核态进程老祖宗
        }
        for (nextList = childHead; ;) {//遍历孩子链表
            childCB = OS_PCB_FROM_SIBLIST(nextList);//找到孩子的真身
            childCB->parentProcessID = parentID;//孩子磕头认老祖宗为爸爸
            nextList = nextList->pstNext;//找下一个孩子进程
            if (nextList == childHead) {//一圈下来,孩子们都磕完头了
                break;
            }
        }
        parentCB = OS_PCB_FROM_PID(parentID);//找个老祖宗的真身
        LOS_ListTailInsertList(&parentCB->childrenList, childHead);//挂到老祖宗的孩子链表上
    }

    return;
}

解读:

  • 函数很简单,都一一注释了,老父亲临终托付后事,请各自的老祖宗照顾孩子。
  • 从这里也可以看出进程的家族管理模式,两个家族从进程的出生到死亡负责到底。

僵尸进程

一个进程在终止时会关闭所有文件描述符,释放在用户空间分配的内存,但它的PCB还保留着,内核在其中保存了一些信息:如果是正常终止则保存着退出状态,如果是异常终止则保存着导致该进程终止的信号是哪个。这个进程的父进程可以调用wait或waitpid获取这些信息,然后彻底清除掉这个进程。

如果一个进程已经终止,但是它的父进程尚未调用wait或waitpid对它进行清理,这时的进程状态称为僵尸(Zombie)进程,即 Z 进程。任何进程在刚终止时都是僵尸进程,正常情况下,僵尸进程都立刻被父进程清理了。不正常情况下就需要手动waitpid清理了。

waitpid

在开源鸿蒙系统中,一个进程结束了,但是它的父进程没有等待(调用wait waitpid)它,那么它将变成一个僵尸进程。通过系统调用 waitpid可以彻底的清理掉子进程。归还pcb。最终调用到SysWait。


#include <sys/wait.h>
#include "syscall.h"
pid_t waitpid(pid_t pid, int *status, int options)
{
  return syscall_cp(SYS_wait4, pid, status, options, 0);
}


//等待子进程结束
int SysWait(int pid, USER int *status, int options, void *rusage)
{
    (void)rusage;
    return LOS_Wait(pid, status, (unsigned int)options, NULL);
}
//返回已经终止的子进程的进程ID号,并清除僵死进程.
LITE_OS_SEC_TEXT INT32 LOS_Wait(INT32 pid, USER INT32 *status, UINT32 options, VOID *rusage)
{
    (VOID)rusage;
    UINT32 ret;
    UINT32 intSave;
    LosProcessCB *childCB = NULL;
    LosProcessCB *processCB = NULL;
    LosTaskCB *runTask = NULL;
    ret = OsWaitOptionsCheck(options);//参数检查,只支持LOS_WAIT_WNOHANG
    if (ret != LOS_OK) {
        return -ret;
    }
    SCHEDULER_LOCK(intSave);
    processCB = OsCurrProcessGet();  //获取当前进程
    runTask = OsCurrTaskGet();    //获取当前任务
    ret = OsWaitChildProcessCheck(processCB, pid, &childCB);//先检查下看能不能找到参数要求的退出子进程
    if (ret != LOS_OK) {
        pid = -ret;
        goto ERROR;
    }
    if (childCB != NULL) {//找到了进程
        return OsWaitRecycleChildPorcess(childCB, intSave, status);//回收进程
    }
  //没有找到,看是否要返回还是去做个登记
    if ((options & LOS_WAIT_WNOHANG) != 0) {//有LOS_WAIT_WNOHANG标签
        runTask->waitFlag = 0;//等待标识置0
        pid = 0;//这里置0,是为了 return 0
        goto ERROR;
    }
  //等待孩子进程退出
    OsWaitInsertWaitListInOrder(runTask, processCB);//将当前任务挂入进程waitList链表
  //发起调度的目的是为了让出CPU,让其他进程/任务运行
    OsSchedResched();//发起调度
    runTask->waitFlag = 0;
    if (runTask->waitID == OS_INVALID_VALUE) {
        pid = -LOS_ECHILD;//没有此子进程
        goto ERROR;
    }
    childCB = OS_PCB_FROM_PID(runTask->waitID);//获取当前任务的等待子进程ID
    if (!(childCB->processStatus & OS_PROCESS_STATUS_ZOMBIES)) {//子进程非僵死进程
        pid = -LOS_ESRCH;//没有此进程
        goto ERROR;
    }
  //回收僵死进程
    return OsWaitRecycleChildPorcess(childCB, intSave, status);
ERROR:
    SCHEDULER_UNLOCK(intSave);
    return pid;
}

解读:

  • pid是数据参数,根据不同的参数代表不同的含义,含义如下:

|参数值  |说明                                                                                |
|pid<-1|等待进程组号为pid绝对值的任何子进程。                                                     |
| pid=-1|等待任何子进程,此时的waitpid()函数就退化成了普通的wait()函数。                             |
| pid=0 |等待进程组号与目前进程相同的任何子进程,也就是说任何和调用waitpid()函数的进程在同一个进程组的进程。|
| pid>0 |等待进程号为pid的子进程。               

pid不同值代表的真正含义可以看这个函数OsWaitSetFlag。


//设置等待子进程退出方式方法
    STATIC UINT32 OsWaitSetFlag(const LosProcessCB *processCB, INT32 pid, LosProcessCB **child)
    {
        LosProcessCB *childCB = NULL;
        ProcessGroup *group = NULL;
        LosTaskCB *runTask = OsCurrTaskGet();
        UINT32 ret;
        if (pid > 0) {//等待进程号为pid的子进程结束
            /* Wait for the child process whose process number is pid。*/
            childCB = OsFindExitChildProcess(processCB, pid);//看能否从退出的孩子链表中找到PID
            if (childCB != NULL) {//找到了,确实有一个已经退出的PID,注意一个进程退出时会挂到父进程的exitChildList上
                goto WAIT_BACK;//直接成功返回
            }
            ret = OsFindChildProcess(processCB, pid);//看能否从现有的孩子链表中找到PID
            if (ret != LOS_OK) {
                return LOS_ECHILD;//参数进程并没有这个PID孩子,返回孩子进程失败。
            }
            runTask->waitFlag = OS_PROCESS_WAIT_PRO;//设置当前任务的等待类型
            runTask->waitID = pid;  //当前任务要等待进程ID结束
        } else if (pid == 0) {//等待同一进程组中的任何子进程
            /* Wait for any child process in the same process group */
            childCB = OsFindGroupExitProcess(processCB->group, OS_INVALID_VALUE);//看能否从退出的孩子链表中找到PID
            if (childCB != NULL) {//找到了,确实有一个已经退出的PID
                goto WAIT_BACK;//直接成功返回
            }
            runTask->waitID = processCB->group->groupID;//等待进程组的任意一个子进程结束
            runTask->waitFlag = OS_PROCESS_WAIT_GID;//设置当前任务的等待类型
        } else if (pid == -1) {//等待任意子进程
            /* Wait for any child process */
            childCB = OsFindExitChildProcess(processCB, OS_INVALID_VALUE);//看能否从退出的孩子链表中找到PID
            if (childCB != NULL) {//找到了,确实有一个已经退出的PID
                goto WAIT_BACK;
            }
            runTask->waitID = pid;//等待PID,这个PID可以和当前进程没有任何关系
            runTask->waitFlag = OS_PROCESS_WAIT_ANY;//设置当前任务的等待类型
        } else { /* pid < -1 */ //等待指定进程组内为|pid|的所有子进程
            /* Wait for any child process whose group number is the pid absolute value。*/
            group = OsFindProcessGroup(-pid);//先通过PID找到进程组
            if (group == NULL) {
                return LOS_ECHILD;
            }
            childCB = OsFindGroupExitProcess(group, OS_INVALID_VALUE);//在进程组里任意一个已经退出的子进程
            if (childCB != NULL) {
                goto WAIT_BACK;
            }
            runTask->waitID = -pid;//此处用负数是为了和(pid == 0)以示区别,因为二者的waitFlag都一样。
            runTask->waitFlag = OS_PROCESS_WAIT_GID;//设置当前任务的等待类型
        }
    WAIT_BACK:
        *child = childCB;
        return LOS_OK;
    }
  • status带走进程退出码,exitCode分成了三个部分格式如下:

/*
  * Process exit code
  * 31    15           8           7        0
  * |     | exit code  | core dump | signal |
  */
  #define OS_PRO_EXIT_OK 0 //进程正常退出
  //置进程退出码第七位为1
  STATIC INLINE VOID OsProcessExitCodeCoreDumpSet(LosProcessCB *processCB)
  {
      processCB->exitCode |= 0x80U;// 0b10000000 
  }
  //设置进程退出信号(0 ~ 7)
  STATIC INLINE VOID OsProcessExitCodeSignalSet(LosProcessCB *processCB, UINT32 signal)
  {
      processCB->exitCode |= signal & 0x7FU;//0b01111111
  }
  //清除进程退出信号(0 ~ 7)
  STATIC INLINE VOID OsProcessExitCodeSignalClear(LosProcessCB *processCB)
  {
      processCB->exitCode &= (~0x7FU);//低7位全部清0
  }
  //进程退出码是否被设置过,默认是 0 ,如果 & 0x7FU 还是 0 ,说明没有被设置过。
  STATIC INLINE BOOL OsProcessExitCodeSignalIsSet(LosProcessCB *processCB)
  {
      return (processCB->exitCode) & 0x7FU;
  }
  //设置进程退出号(8 ~ 15)
  STATIC INLINE VOID OsProcessExitCodeSet(LosProcessCB *processCB, UINT32 code)
  {
      processCB->exitCode |= ((code & 0x000000FFU) << 8U) & 0x0000FF00U; /* 8: Move 8 bits to the left, exitCode */
  }

0 – 7为信号位,信号处理有专门的篇幅,此处不做详细介绍,请自行翻看,这里仅列出部分信号含义。


  #define SIGHUP    1  //终端挂起或者控制进程终止
  #define SIGINT    2  //键盘中断(如break键被按下)
  #define SIGQUIT   3  //键盘的退出键被按下
  #define SIGILL    4  //非法指令
  #define SIGTRAP   5  //跟踪陷阱(trace trap),启动进程,跟踪代码的执行
  #define SIGABRT   6  //由abort(3)发出的退出指令
  #define SIGIOT    SIGABRT //abort发出的信号
  #define SIGBUS    7  //总线错误 
  #define SIGFPE    8  //浮点异常
  #define SIGKILL   9    //常用的命令 kill 9 123 | 不能被忽略、处理和阻塞
  #define SIGUSR1   10  //用户自定义信号1 
  #define SIGSEGV   11  //无效的内存引用, 段违例(segmentation     violation),进程试图去访问其虚地址空间以外的位置 
  #define SIGUSR2   12  //用户自定义信号2
  #define SIGPIPE   13  //向某个非读管道中写入数据 
  #define SIGALRM   14  //由alarm(2)发出的信号,默认行为为进程终止
  #define SIGTERM   15  //终止信号
  #define SIGSTKFLT 16  //栈溢出
  #define SIGCHLD   17  //子进程结束信号
  #define SIGCONT   18  //进程继续(曾被停止的进程)
  #define SIGSTOP   19  //终止进程     | 不能被忽略、处理和阻塞
  #define SIGTSTP   20  //控制终端(tty)上 按下停止键
  #define SIGTTIN   21  //进程停止,后台进程企图从控制终端读
  #define SIGTTOU   22  //进程停止,后台进程企图从控制终端写
  #define SIGURG    23  //I/O有紧急数据到达当前进程
  #define SIGXCPU   24  //进程的CPU时间片到期
  #define SIGXFSZ   25  //文件大小的超出上限
  #define SIGVTALRM 26  //虚拟时钟超时
  #define SIGPROF   27  //profile时钟超时
  #define SIGWINCH  28  //窗口大小改变
  #define SIGIO     29  //I/O相关
  #define SIGPOLL   29  //
  #define SIGPWR    30  //电源故障,关机
  #define SIGSYS    31  //系统调用中参数错,如系统调用号非法 
  #define SIGUNUSED SIGSYS    //系统调用异常
  • options是行为参数,提供了一些另外的选项来控制waitpid()函数的行为。

|参数值              |开源鸿蒙支持|说明                                                                              |
|LOS_WAIT_WNOHANG   |支持       |如果没有孩子进程退出,则立即返回,而不是阻塞在这个函数上等待;如果结束了,则返回该子进程的进程号。|
|LOS_WAIT_WUNTRACED |不支持     |报告终止或停止的子进程的状态                                                          |
|LOS_WAIT_WCONTINUED|不支持     |                                                                                 |

开源鸿蒙目前只支持了LOS_WAIT_WNOHANG模式,内核源码中虽有LOS_WAIT_WUNTRACED和LOS_WAIT_WCONTINUED的实现痕迹,但是整体阅读下来比较乱,应该是没有写好。

百文说内核 | 抓住主脉络

子曰:“诗三百,一言以蔽之,曰‘思无邪’。”——《论语》:为政篇。

百文相当于摸出内核的肌肉和器官系统,让人开始丰满有立体感,因是直接从注释源码起步,在开源鸿蒙内核源码加注释过程中,每每有心得处就整理,慢慢形成了以下文章。内容立足源码,常以生活场景打比方尽可能多的将内核知识点置入某种场景,具有画面感,容易理解记忆。说别人能听得懂的话很重要! 百篇博客绝不是百度教条式的在说一堆诘屈聱牙的概念,那没什么意思。更希望让内核变得栩栩如生,倍感亲切.确实有难度,自不量力,但已经出发,回头已是不可能的了。
百万汉字注解内核目的是要看清楚其毛细血管,细胞结构,等于在拿放大镜看内核。内核并不神秘,带着问题去源码中找答案是很容易上瘾的,你会发现很多文章对一些问题的解读是错误的,或者说不深刻难以自圆其说,你会慢慢形成自己新的解读,而新的解读又会碰到新的问题,如此层层递进,滚滚向前,拿着放大镜根本不愿意放手。

与代码有bug需不断debug一样,文章和注解内容会存在不少错漏之处,请多包涵,但会反复修正,持续更新,v**.xx 代表文章序号和修改的次数,精雕细琢,言简意赅,力求打造精品内容。百篇博客系列思维导图结构如下:

根据上图的思维导图,我们未来将要和大家一一分享以上大部分关键技术点的博客文章。

百万汉字注解.精读内核源码

如果大家觉得看文章不过瘾,想直接撸代码的话,可以去下面四大码仓围观同步注释内核源码:

gitee仓

https://gitee.com/weharmony/kernel_liteos_a_note

github仓 :

https://github.com/kuangyufei/kernel_liteos_a_note

codechina仓

https://codechina.csdn.net/kuangyufei/kernel_liteos_a_note

coding仓

https://weharmony.coding.net/public/harmony/kernel_liteos_a_note/git/files

写在最后

我们最近正带着大家玩嗨OpenHarmony。如果你有用OpenHarmony开发的好玩的东东,或者有对OpenHarmony的深度技术剖析,想通过我们平台让更多的小伙伴知道和分享的,欢迎投稿,让我们一起嗨起来!有点子,有想法,有Demo,立刻联系我们:

合作邮箱:zzliang@atomsource.org