OHOS3.0启动流程分析丨init阶段 原创 精华

发布于 2021-10-17 23:25
浏览
12收藏

【本文正在参与优质创作者激励】
@toc

init阶段

内核启动完是init阶段,源码的路径在 base\startup\init_lite\services\src\main.c,虽然文件夹命名为init_lite,但是init部分的代码是小型系统(small system)和标准系统(standard system)通用的。相关码仓启动模块init进程 (gitee.com)

接下来参考linux内核梳理下启动流程,

Liteos-A Linux
INIT_CHECK_ONLY_ELOG(setenv(“UV_THREADPOOL_SIZE”, “1”, 1) == 0, “set UV_THREADPOOL_SIZE error : %d.”, errno);
CloseStdio(); // 1.关闭输入输出
OpenLogDevice(); // 2.在串口打印调试信息
PrintSysInfo(); // PrintSysInfo();
MountBasicFs(); // 3.挂载目录,建立索引节点
CreateDeviceNode();
EnableDevKmsg(); // 4.开启DevKmsg
MakeSocketDir(“/dev/unix/socket/”…) // 5.建立Socket文件夹
SignalInitModule(); SignalInitModule(); // 6.Singnal初始化
ExecuteRcs(); ExecuteRcs(); // 7.执行命令脚本文件
InitReadCfg(); InitReadCfg(); // 8.【重要】解析并执行*.cfg文件。
StartParamService(); // 9.启动参数服务
(void)pause(); (void)pause();

1.关闭输入输出

/dev/null,空设备,特殊的设备文件,丢弃一切写入其中的数据(但报告写入操作成功),读取它则会立即得到一个EOF。其作用是对stdin/stdout/stderr进行保护,把文件描述符0,1,2分配出去,以后再分配的时候就不会将stdin/stdout/stderr打开,以达到保护目的。

// base\startup\init_lite\services\src\device.c
void CloseStdio(void)
{
    int fd = open("/dev/null", O_RDWR | O_CLOEXEC);
    if (fd < 0) {
        return;
    }
    dup2(fd, 0);
    dup2(fd, 1);
    dup2(fd, 2);
    close(fd);
}

2.在串口打印调试信息

写入/dev/kmsg的信息,可以在dmesg(开机信息)中查看。

// base\startup\init_lite\services\log\init_log.c
void OpenLogDevice(void)
{
    int fd = open("/dev/kmsg", O_WRONLY | O_CLOEXEC, S_IRUSR | S_IWUSR | S_IRGRP | S_IRGRP);
    if (fd >= 0) {
        g_fd = fd;
    }
    return;
}

标准系统空实现,不深究了。

// base\startup\init_lite\services\src\main.c
static void PrintSysInfo()
{
#ifdef OHOS_LITE
    const char* sysInfo = GetVersionId();
    if (sysInfo != NULL) {
        INIT_LOGE("%s", sysInfo);
        return;
    }
    INIT_LOGE("main, GetVersionId failed!");
#endif
}

3.挂载目录,建立索引节点

// base\startup\init_lite\services\src\device.c
void MountBasicFs(void)
{
    mount("tmpfs", "/dev", "tmpfs", MS_NOSUID, "mode=0755"
    mkdir("/dev/pts", S_IRWXU | S_IRGRP | S_IXGRP | S_IROTH | S_IXOTH)
    mount("devpts", "/dev/pts", "devpts", 0, NULL)
    mount("proc", "/proc", "proc", 0, "hidepid=2")
    mount("sysfs", "/sys", "sysfs", 0, NULL)
    mount("selinuxfs", "/sys/fs/selinux", "selinuxfs", 0, NULL)
}
void CreateDeviceNode(void)
{
    mknod("/dev/kmsg", S_IFCHR | DEFAULT_NO_AUTHORITY_MODE, makedev(MEM_MAJOR, DEV_KMSG_MINOR)
    mknod("/dev/null", S_IFCHR | DEFAULT_RW_MODE, makedev(MEM_MAJOR, DEV_NULL_MINOR)
    mknod("/dev/random", S_IFCHR | DEFAULT_RW_MODE, makedev(MEM_MAJOR, DEV_RANDOM_MINOR)
	mknod("/dev/urandom", S_IFCHR | DEFAULT_RW_MODE, makedev(MEM_MAJOR, DEV_URANDOM_MINOR)
}

4.开启DevKmsg

注释很清楚了,printk_devkmsg默认是流控的,设置为on取消流控。

// base\startup\init_lite\services\log\init_log.c
void EnableDevKmsg(void)
{
    /* printk_devkmsg default value is ratelimit, We need to set "on" and remove the restrictions */
    int fd = open("/proc/sys/kernel/printk_devkmsg", O_WRONLY | O_CLOEXEC, S_IRUSR | S_IWUSR | S_IRGRP | S_IRGRP);
    ...        
    write(fd, "on", strlen("on") + 1);
    close(fd);
    fd = -1;
    return;
}

5.建立Socket文件夹

/dev/unix/socket/不过这个干啥用的,还没搞清楚。

MakeSocketDir("/dev/unix/socket/", S_IRWXU | S_IRGRP | S_IXGRP | S_IROTH | S_IXOTH);
// base\startup\init_lite\services\src\device.c
int MakeSocketDir(const char *path, mode_t mode)
{
    int rc = mkdir("/dev/unix/", mode);
	... 
    rc = mkdir("/dev/unix/socket/", mode);
    ...
}

6.Singnal初始化

// base\startup\init_lite\services\src\init_signal_handler.c
void SignalInitModule()
{
    int ret = uv_signal_init(uv_default_loop(), &g_sigchldHandler);
    int ret1 = uv_signal_init(uv_default_loop(), &g_sigtermHandler);
    if (ret != 0 && ret1 != 0) {
        INIT_LOGW("initialize signal handler failed");
        return;
    }

    if (uv_signal_start(&g_sigchldHandler, UVSignalHandler, SIGCHLD) != 0) {
        INIT_LOGW("start SIGCHLD handler failed");
    }
    if (uv_signal_start(&g_sigtermHandler, UVSignalHandler, SIGTERM) != 0) {
        INIT_LOGW("start SIGTERM handler failed");
    }
}

7.执行命令脚本文件

兼容常规的*.rc文件,是执行linux运行命令的脚本文件。

// base\startup\init_lite\services\src\init_adapter.c
void ExecuteRcs()
{
#if (defined __LINUX__) && (defined NEED_EXEC_RCS_LINUX)
    pid_t retPid = fork();
    if (retPid < 0) {
        INIT_LOGE("ExecuteRcs, fork failed! err %d.", errno);
        return;
    }

    // child process
    if (retPid == 0) {
        INIT_LOGI("ExecuteRcs, child process id %d.", getpid());
        if (execle("/bin/sh", "sh", "/etc/init.d/rcS", NULL, NULL) != 0) {
            INIT_LOGE("ExecuteRcs, execle failed! err %d.", errno);
        }
        _exit(0x7f); // 0x7f: user specified
    }

    // init process
    sem_t sem;
    if (sem_init(&sem, 0, 0) != 0) {
        INIT_LOGE("ExecuteRcs, sem_init failed, err %d.", errno);
        return;
    }
    SignalRegWaitSem(retPid, &sem);

    // wait until rcs process exited
    if (sem_wait(&sem) != 0) {
        INIT_LOGE("ExecuteRcs, sem_wait failed, err %d.", errno);
    }
#endif
}

8.【重要】解析并执行*.cfg文件。

OHOS3.0中的命令脚本文件是*.cfg,采用JSON格式,存储的信息更多一些。前面都是启动系统的一些准备工作,而接下来才是重要部分。还是用个表格来分析

base\startup\init_lite\services\src\init_read_cfg.c

Liteos-A Linux
InitParamService(); // a.初始化服务参数的工作区
LoadDefaultParams(“/system/etc/ohos.para”); // 这个文件存放了OHOS版本信息等参数。
ParseInitCfg(“/etc/init.cfg”); ParseInitCfg(“/etc/init.cfg”); // b.解析init.cfg文件
ParseOtherCfgs(); ParseOtherCfgs(); // c.解析执行/system/etc/init/*.cfg文件
DoJob(“pre-init”);
TriggerStage(EVENT1, EVENT1_WAITTIME, QS_STAGE1);
DoJob(“init”);
TriggerStage(EVENT2, EVENT2_WAITTIME, QS_STAGE2);
DoJob(“post-init”);
TriggerStage(EVENT3, EVENT3_WAITTIME, QS_STAGE3);
InitStageFinished();
ReleaseAllJobs();
PostTrigger(EVENT_BOOT, “pre-init”, strlen(“pre-init”)); // d.PostTrigger
PostTrigger(EVENT_BOOT, “init”, strlen(“init”));
PostTrigger(EVENT_BOOT, “post-init”, strlen(“post-init”));

a.初始化服务参数的工作区

// base\startup\init_lite\services\param\service\param_service.c
void InitParamService()
{
    int ret = InitParamWorkSpace(&g_paramWorkSpace, 0, g_initContext);
    PARAM_CHECK(ret == 0, return, "Init parameter workspace fail");
}

b.解析init.cfg文件

./base/startup/init_lite/services/etc/init.cfg
./base/update/updater/services/etc/init.cfg
./out/ohos-arm-release/packages/phone/system/etc/init.cfg
./out/ohos-arm-release/packages/phone/updater/etc/init.cfg
./out/ohos-arm-release/obj/base/startup/init_lite/services/base/startup/init_lite/services/etc/init.cfg
./out/ohos-arm-release/obj/base/update/updater/services/base/update/updater/services/etc/init.cfg
./device/hisilicon/hi3516dv300/updater/init.cfg

编译框架使用的哪个init.cfg还有待确定。

// base\startup\init_lite\services\src\init_read_cfg.c
void ParseInitCfg(const char *configFile) // 文件路径"/etc/init.cfg"
{
    ...
    char *fileBuf = ReadFileToBuf(configFile);  // 读取*.cfg文件
    cJSON* fileRoot = cJSON_Parse(fileBuf);		// 解析成JSON
    ...
    ParseInitCfgContents(fileRoot);
    ...
}
----------------------------------------------------------------------
static void ParseInitCfgContents(const cJSON *root) // JSON格式的init.cfg
{
	...
    ParseAllServices(root);  	// ①.解析"services"部分,并执行
	...
    ParseAllJobs(root);			// Liteos走这边
    ParseTriggerConfig(root);	// Linux走这边,②.解析"jobs"部分,并执行
	...
    ParseAllImports(root);		// ③.提取init.cfg中"import"的部分,并执行
}

①.解析"services"部分,并执行

// base\startup\init_lite\services\src\init_service_manager.c
void ParseAllServices(const cJSON* fileRoot)
{
    int servArrSize = 0;
    cJSON* serviceArr = GetArrItem(fileRoot, &servArrSize, "services");
    ... // 截取 init.cfg 中的"services"部分
    ///////////////////////////////// 参考格式
        "services" : [{
            "name" : "ueventd",
            "path" : ["/system/bin/ueventd"],
            "critical" : 1
        	}, {
            "name" : "console",
            "path" : ["/system/bin/sh"],
            "disabled" : 1,
            "console" : 1,
            "uid" : "root",
            "gid" : ["shell", "log", "readproc"]
        	}]
    ///////////////////////////////// 参考格式
    ... // 默认服务数量不能超过100
    Service* retServices = (Service*)realloc(g_services, sizeof(Service) * (g_servicesCnt + servArrSize));
    ... 
    // Skip already saved services,
    Service* tmp = retServices + g_servicesCnt;
    if (memset_s(tmp, sizeof(Service) * servArrSize, 0, sizeof(Service) * servArrSize) != EOK) {
        free(retServices);
        retServices = NULL;
        return;
    }
	// 【重要】然后使用一个for循环遍历服务数组
    for (int i = 0; i < servArrSize; ++i) {
        cJSON* curItem = cJSON_GetArrayItem(serviceArr, i);
        if (CheckServiceKeyName(curItem) != SERVICE_SUCCESS) {
            ReleaseServiceMem(&tmp[i]);
            tmp[i].attribute |= SERVICE_ATTR_INVALID;
            continue;
        }
        int ret = ParseOneService(curItem, &tmp[i]);
        if (ret != SERVICE_SUCCESS) { 		// 如果服务启动失败
            // release resources if it fails
            ReleaseServiceMem(&tmp[i]);
            tmp[i].attribute |= SERVICE_ATTR_INVALID;
            INIT_LOGE("Parse information for service %s failed. ", tmp[i].name);
            continue;
        } else {							// 如果服务启动成功
            INIT_LOGD("service[%d] name=%s, uid=%d, critical=%d, disabled=%d",
                i, tmp[i].name, tmp[i].servPerm.uID, (tmp[i].attribute & SERVICE_ATTR_CRITICAL) ? 1 : 0,
                (tmp[i].attribute & SERVICE_ATTR_DISABLED) ? 1 : 0);
        }
        if (GetServiceSocket(curItem, &tmp[i]) != SERVICE_SUCCESS) {
            if (tmp[i].socketCfg != NULL) {
                FreeServiceSocket(tmp[i].socketCfg);
                tmp[i].socketCfg = NULL;
            }
        }
        if (GetServiceOnRestart(curItem, &tmp[i]) == SERVICE_FAILURE) {
            INIT_LOGE("Failed Get Service OnRestart service");
        }
    }
    // Increase service counter.
    RegisterServices(retServices, servArrSize);  // 最后注册服务
}
----------------------------------------------------------------------
void RegisterServices(Service* services, int servicesCnt)
{
    if (services == NULL) {
        return;
    }
    g_services = services;
    g_servicesCnt += servicesCnt;
    // 到这里init.cfg中的"services"部分就已经解析并执行完毕了。
}

②.解析"jobs"部分,并执行

// base\startup\init_lite\services\param\trigger\trigger_processor.c
int ParseTriggerConfig(const cJSON *fileRoot)
{
    ...
    int ret = InitTriggerWorkSpace(&g_triggerWorkSpace);	// 初始化触发器的工作空间
    ...
    cJSON *triggers = cJSON_GetObjectItemCaseSensitive(fileRoot, "jobs");	// 提取init.cfg中"jobs"的部分
    ...
    int size = cJSON_GetArraySize(triggers);
    ...
    for (int i = 0; i < size; ++i) {
        cJSON *item = cJSON_GetArrayItem(triggers, i);
        ParseTrigger(&g_triggerWorkSpace, item);
    }
    return 0;
}
----------------------------------------------------------------------
// 截取init.cfg中jobs段的部分代码,格式如下
"jobs" : [{
            "name" : "pre-init",
            "cmds" : [
                "write /proc/sys/kernel/sysrq 0",
                ...
                "mkdir /data",
            ]
        }, {
            "name" : "init",
            "cmds" : [
                "copy /proc/cmdline /dev/urandom",
				...
                "domainname localdomain"
            ]
        }, {
            "name" : "param:sys.boot_from_charger_mode=1",
            "condition" : "sys.boot_from_charger_mode=1",
            "cmds" : [
                "trigger post-init"
            ]
        },
          ...
    ],
// base\startup\init_lite\services\param\trigger\trigger_manager.c
int ParseTrigger(TriggerWorkSpace *workSpace, const cJSON *triggerItem)
{
    ...	// 提取init.cfg中jobs段的"name"的部分
    char *name = cJSON_GetStringValue(cJSON_GetObjectItem(triggerItem, "name"));
    ... // 提取init.cfg中jobs段的"condition"的部分
    char *condition = cJSON_GetStringValue(cJSON_GetObjectItem(triggerItem, "condition"));	
    int index = GetTriggerIndex(name);
    ...
    u_int32_t offset = 0;
    TriggerNode *trigger = GetTriggerByName(workSpace, name, &offset);
    if (trigger == NULL) {
        offset = AddTrigger(workSpace, index, name, condition);
        PARAM_CHECK(offset > 0, return -1, "Failed to create trigger %s", name);
        trigger = GetTriggerByIndex(workSpace, offset);
    } else {
        if (condition != NULL) {
            PARAM_LOGE("Warning parseTrigger %s %s", name, condition);
        }
    }
    PARAM_LOGD("ParseTrigger %s %u", name, offset);

    // 添加命令行
    cJSON* cmdItems = cJSON_GetObjectItem(triggerItem, "cmds"); // 提取init.cfg中jobs段的"cmds"的部分
    ...
    int cmdLinesCnt = cJSON_GetArraySize(cmdItems); // 获取命令数量
    ...
    for (int i = 0; i < cmdLinesCnt; ++i) { // 循环执行
        char *cmdLineStr = cJSON_GetStringValue(cJSON_GetArrayItem(cmdItems, i));
        ...
        size_t cmdLineLen = strlen(cmdLineStr);
        const char *matchCmd = GetMatchCmd(cmdLineStr);
        if (matchCmd == NULL && strncmp(cmdLineStr, "trigger ", strlen("trigger ")) == 0) {
            matchCmd = "trigger ";
        }
        ...
        size_t matchLen = strlen(matchCmd);
        if (matchLen == cmdLineLen) {
            offset = AddCommand(workSpace, trigger, matchCmd, NULL);
        } else {
            offset = AddCommand(workSpace, trigger, matchCmd, cmdLineStr + matchLen);
        }
        PARAM_CHECK(offset > 0, continue, "Failed to add command %s", cmdLineStr);
    }
    return 0;
}

③.提取init.cfg中"import"的部分,并执行

// base\startup\init_lite\services\src\init_import.c
void ParseAllImports(const cJSON *root)
{    
    ///////////////////////////////// 参考格式
        "import" : [
            "/etc/init.usb.cfg",
            "/etc/init.usb.configfs.cfg",
            "/etc/init.usb.cfg",
            "/etc/init.Hi3516DV300.usb.cfg",
            "/etc/init.Hi3516DV300.cfg"
    ],    
    /////////////////////////////////
    cJSON *importAttr = cJSON_GetObjectItemCaseSensitive(root, "import");	// 提取init.cfg中"import"的部分
    ...
    int importAttrSize = cJSON_GetArraySize(importAttr);
    for (int i = 0; i < importAttrSize; i++) {	// 循环取出每一项
        cJSON *importItem = cJSON_GetArrayItem(importAttr, i);
        ...
        char *importContent = cJSON_GetStringValue(importItem);
        ...
// Only OHOS L2 support parameter.
#ifndef OHOS_LITE //这里有啥意义,前面都已经判断过了
        if (ExtractCfgFile(&cfgFile, importContent) < 0) {
            INIT_LOGW("Failed to import from %s", importContent);
            if (cfgFile != NULL) {
                free(cfgFile);
                cfgFile = NULL;
            }
            continue;
        }
#else
        cfgFile = importContent;
#endif
        INIT_LOGI("Import %s...", cfgFile);
        ParseInitCfg(cfgFile);	// 取出"import"中的路径,解析方法和init.cfg解析方式一致。
        ...
    }
    INIT_LOGD("parse import file done");
    return;
}

到这里init.cfg就解析并执行完毕了,需要注意的是,import导入的cfg文件是最后才执行的。

c.解析执行/system/etc/init/*.cfg文件

和import的原理类似,遍历system/etc/init文件夹下的*.cfg文件,并执行。我感觉写在init.cfg的import中应该也是可以的。

// base\startup\init_lite\services\src\init_read_cfg.c
static void ParseOtherCfgs()
{
    ReadCfgs("/system/etc/init");
    return;
}
------------------------------------
static void ReadCfgs(const char *dirPath)
{
    DIR *pDir = opendir(dirPath);
    ...
    struct dirent *dp;
    while ((dp = readdir(pDir)) != NULL) {
        char fileName[FILE_NAME_MAX_SIZE];
        if (snprintf_s(fileName, FILE_NAME_MAX_SIZE, FILE_NAME_MAX_SIZE - 1, "%s/%s", dirPath, dp->d_name) == -1) {
            INIT_LOGE("ParseCfgs snprintf_s failed.");
            closedir(pDir);
            return;
        }
        struct stat st;
        if (stat(fileName, &st) == 0) {
            if (strstr(dp->d_name, ".cfg") == NULL) {
                continue;
            }
            INIT_LOGI("ReadCfgs :%s from %s success.", fileName, dirPath);
            ParseInitCfg(fileName);  // 和init.cfg同样的解析方式
        }
    }
    closedir(pDir);
    return;
}

d.PostTrigger

PostTrigger(EVENT_BOOT, “pre-init”, strlen(“pre-init”));

// base\startup\init_lite\services\param\trigger\trigger_processor.c
void PostTrigger(EventType type, const char *content, u_int32_t contentLen)
{    
    ///////////////////////////////////// 参考TriggerDataEvent
    typedef struct {
        uv_work_t request;
        EventType type;
        u_int32_t contentSize;
        char content[0];
    } TriggerDataEvent;
    /////////////////////////////////////
    ...
    TriggerDataEvent *event = (TriggerDataEvent *)malloc(sizeof(TriggerDataEvent) + contentLen + 1);
    ...
    event->type = type;  // = EVENT_BOOT
    event->request.data = (void *)((char*)event + sizeof(uv_work_t));
    event->contentSize = contentLen;  // = strlen("pre-init")
    event->content[contentLen] = '\0'; // "pre-init"[strlen("pre-init")],设置结束符'\0'
    SendTriggerEvent(event);
    ...
}
----------------------------------------------------------------------------
static void SendTriggerEvent(TriggerDataEvent *event)
{
    ....
    int ctrlSize = strlen("sys.powerctrl=");
    if (strncmp(event->content, "sys.powerctrl=", ctrlSize) == 0) {  // 如果event->content为"sys.powerctrl="
        char *cmdParam = NULL;
        const char *matchCmd = GetCmdInfo(event->content + ctrlSize, event->contentSize - ctrlSize, &cmdParam);
        if (matchCmd != NULL) {
            DoCmdByName(matchCmd, cmdParam);
        } else {
            PARAM_LOGE("SendTriggerEvent cmd %s not found", event->content);
        }
    } else if (strncmp(event->content, "ohos.ctl.start=", strlen("ohos.ctl.start=")) == 0) { // 如果是"ohos.ctl.start="
        DoCmdByName("start ", event->content + strlen("ohos.ctl.start="));
    } else if (strncmp(event->content, "ohos.ctl.stop=", strlen("ohos.ctl.stop=")) == 0) {  // 如果是"ohos.ctl.stop="
        DoCmdByName("stop ", event->content + strlen("ohos.ctl.stop="));
    } else {  // 否则执行uv_queue_work(),uv_queue_work是将ProcessEvent提交给子线程执行,完成后通知主线程,防止阻塞
        uv_queue_work(uv_default_loop(), &event->request, ProcessEvent, ProcessAfterEvent);
        event = NULL;
    }
    ...
}
-----------------------------------------------------------------------
// base\startup\init_lite\services\src\init_cmds.c
void DoCmdByName(const char *name, const char *.cmdContent)
{
    ...
    size_t cmdCnt = sizeof(CMD_TABLE) / sizeof(CMD_TABLE[0]);
    unsigned int i = 0;
    for (; i < cmdCnt; ++i) {
        if (strncmp(name, CMD_TABLE[i].name, strlen(CMD_TABLE[i].name)) == 0) {
            CMD_TABLE[i].DoFuncion(cmdContent, CMD_TABLE[i].maxArg);
            break;
        }
    }
    if (i == cmdCnt) {
        INIT_LOGE("DoCmd, unknown cmd name %s.", name);
    }
}

9.启动参数服务

使用Libuv库,官网地址。作为Nodejs的底层。

相关的API可以参考网址uv_run

// base\startup\init_lite\services\param\service\param_service.c
int StartParamService()
{
    PARAM_LOGI("StartParamService.");
    uv_fs_t req;
    uv_fs_unlink(uv_default_loop(), &req, PIPE_NAME, NULL);

    uv_pipe_t pipeServer;
    int ret = uv_pipe_init(uv_default_loop(), &pipeServer, 0);
    ...
    ret = uv_pipe_bind(&pipeServer, PIPE_NAME);
    ...
    ret = chmod(PIPE_NAME, S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP | S_IROTH | S_IWOTH);
    ...
    ret = uv_listen((uv_stream_t*)&pipeServer, SOMAXCONN, OnConnection);
    ...
    uv_run(uv_default_loop(), UV_RUN_DEFAULT);  // 运行事件循环,直到不再有活动和引用的句柄或请求。
    ...
}

至此,init启动结束,进入pause()。总结下来init首先会执行一些通用的准备操作,同时兼容常规linux内核启动脚本,之后在执行鸿蒙init.cfg和单板相关*.cfg。以上分析都是我个人见解,如有错误欢迎指正。
【本文正在参与优质创作者激励】

©著作权归作者和HarmonyOS技术社区共同所有,如需转载,请注明出处,否则将追究法律责任
已于2021-10-18 12:47:36修改
12
收藏 12
回复
举报
回复
添加资源
添加资源将有机会获得更多曝光,你也可以直接关联已上传资源 去关联
    相关推荐