Android 10 啟動Init進(jìn)程解析
按下電源鍵時(shí),android做了啥?
當(dāng)我們按下電源鍵時(shí),手機(jī)開始上電,并從地址0x00000000處開始執(zhí)行,而這個(gè)地址通常是Bootloader程序的首地址。
bootloader是一段裸機(jī)程序,是直接與硬件打交道的,其最終目的是“初始化并檢測硬件設(shè)備,準(zhǔn)備好軟件環(huán)境,最后調(diào)用操作系統(tǒng)內(nèi)核”。除此之外,bootloader還有保護(hù)功能,部分品牌的手機(jī)對bootloader做了加鎖操作,防止boot分區(qū)和recovery分區(qū)被寫入。
或許有人會問了,什么是boot分區(qū),什么又是recovery分區(qū)?
我們先來認(rèn)識一下Android系統(tǒng)的常見分區(qū):
/boot
這個(gè)分區(qū)上有Android的引導(dǎo)程序,包括內(nèi)核和內(nèi)存操作程序。沒有這個(gè)分區(qū)設(shè)備就不能被引導(dǎo)?;謴?fù)系統(tǒng)的時(shí)候會擦除這個(gè)分區(qū),并且必須重新安裝引導(dǎo)程序和ROM才能重啟系統(tǒng)。
/recovery
recovery分區(qū)被認(rèn)為是另一個(gè)啟動分區(qū),你可以啟動設(shè)備進(jìn)入recovery控制臺去執(zhí)行高級的系統(tǒng)恢復(fù)和管理操作。
/data
這個(gè)分區(qū)保存著用戶數(shù)據(jù)。通訊錄、短信、設(shè)置和你安裝的apps都在這個(gè)分區(qū)上。擦除這個(gè)分區(qū)相當(dāng)于恢復(fù)出廠設(shè)置,當(dāng)你第一次啟動設(shè)備的時(shí)候或者在安裝了官方或者客戶的ROM之后系統(tǒng)會自動重建這個(gè)分區(qū)。當(dāng)你執(zhí)行恢復(fù)出廠設(shè)置時(shí),就是在擦除這個(gè)分區(qū)。
/cache
這個(gè)分區(qū)是Android系統(tǒng)存儲頻繁訪問的數(shù)據(jù)和app的地方。擦除這個(gè)分區(qū)不影響你的個(gè)人數(shù)據(jù),當(dāng)你繼續(xù)使用設(shè)備時(shí),被擦除的數(shù)據(jù)就會自動被創(chuàng)建。
/apex
Android Q新增特性,將系統(tǒng)功能模塊化,允許系統(tǒng)按模塊來獨(dú)立升級。此分區(qū)用于存放apex 相關(guān)的內(nèi)容。
為什么需要bootloader去拉起linux內(nèi)核,而不把bootloader這些功能直接內(nèi)置在linux內(nèi)核中呢?這個(gè)問題不在此做出回答,留給大家自行去思考。
bootloader完成初始化工作后,會載入 /boot 目錄下面的 kernel,此時(shí)控制權(quán)轉(zhuǎn)交給操作系統(tǒng)。操作系統(tǒng)將要完成的存儲管理、設(shè)備管理、文件管理、進(jìn)程管理、加載驅(qū)動等任務(wù)的初始化工作,以便進(jìn)入用戶態(tài)。
內(nèi)核啟動完成后,將會尋找init文件(init文件位于/system/bin/init),啟動init進(jìn)程,也就是android的第一個(gè)進(jìn)程。
我們來關(guān)注一下內(nèi)核的common/init/main.c中的kernel_init方法。
static int __ref kernel_init(void *unused) { ... if (execute_command) { ret = run_init_process(execute_command); if (!ret) return 0; } if (CONFIG_DEFAULT_INIT[0] != '\0') { ret = run_init_process(CONFIG_DEFAULT_INIT); if (ret) pr_err("Default init %s failed (error %d)\n",CONFIG_DEFAULT_INIT, ret); else return 0; } if (!try_to_run_init_process("/sbin/init") ||!try_to_run_init_process("/etc/init") || !try_to_run_init_process("/bin/init") ||!try_to_run_init_process("/bin/sh")) return 0; }
可以看到,在init_kernel的最后,會調(diào)用run_init_process方法來啟動init進(jìn)程。
static int run_init_process(const char *init_filename){ const char *const *p; argv_init[0] = init_filename; return kernel_execve(init_filename, argv_init, envp_init); }
kernel_execve是內(nèi)核空間調(diào)用用戶空間的應(yīng)用程序的函數(shù)。
接下來我們來重點(diǎn)分析init進(jìn)程。
init進(jìn)程解析
我們從system/core/init/main.cpp 這個(gè)文件開始看起。
int main(int argc, char** argv) { #if __has_feature(address_sanitizer) __asan_set_error_report_callback(AsanReportCallback); #endif if (!strcmp(basename(argv[0]), "ueventd")) { return ueventd_main(argc, argv); } if (argc > 1) { if (!strcmp(argv[1], "subcontext")) { android::base::InitLogging(argv, &android::base::KernelLogger); const BuiltinFunctionMap function_map; return SubcontextMain(argc, argv, &function_map); } if (!strcmp(argv[1], "selinux_setup")) { return SetupSelinux(argv); } if (!strcmp(argv[1], "second_stage")) { return SecondStageMain(argc, argv); } } return FirstStageMain(argc, argv); }
第一個(gè)參數(shù)argc表示參數(shù)個(gè)數(shù),第二個(gè)參數(shù)是參數(shù)列表,也就是具體的參數(shù)。
main函數(shù)有四個(gè)參數(shù)入口:
- 一是參數(shù)中有ueventd,進(jìn)入ueventd_main
- 二是參數(shù)中有subcontext,進(jìn)入InitLogging 和SubcontextMain
- 三是參數(shù)中有selinux_setup,進(jìn)入SetupSelinux
- 四是參數(shù)中有second_stage,進(jìn)入SecondStageMain
main的執(zhí)行順序如下:
- FirstStageMain 啟動第一階段
- SetupSelinux 加載selinux規(guī)則,并設(shè)置selinux日志,完成SELinux相關(guān)工作
- SecondStageMain 啟動第二階段
- ueventd_main init進(jìn)程創(chuàng)建子進(jìn)程ueventd,并將創(chuàng)建設(shè)備節(jié)點(diǎn)文件的工作托付給ueventd。
FirstStageMain
我們來從FirstStageMain的源碼看起,源碼位于/system/core/init/first_stage_init.cpp
int FirstStageMain(int argc, char** argv) { boot_clock::time_point start_time = boot_clock::now(); #define CHECKCALL(x) \ if (x != 0) errors.emplace_back(#x " failed", errno); // Clear the umask. umask(0); //初始化系統(tǒng)環(huán)境變量 CHECKCALL(clearenv()); CHECKCALL(setenv("PATH", _PATH_DEFPATH, 1)); // 掛載及創(chuàng)建基本的文件系統(tǒng),并設(shè)置合適的訪問權(quán)限 CHECKCALL(mount("tmpfs", "/dev", "tmpfs", MS_NOSUID, "mode=0755")); CHECKCALL(mkdir("/dev/pts", 0755)); CHECKCALL(mkdir("/dev/socket", 0755)); CHECKCALL(mount("devpts", "/dev/pts", "devpts", 0, NULL)); #define MAKE_STR(x) __STRING(x) CHECKCALL(mount("proc", "/proc", "proc", 0, "hidepid=2,gid=" MAKE_STR(AID_READPROC))); #undef MAKE_STR // 不要將原始命令行公開給非特權(quán)進(jìn)程 CHECKCALL(chmod("/proc/cmdline", 0440)); gid_t groups[] = {AID_READPROC}; CHECKCALL(setgroups(arraysize(groups), groups)); CHECKCALL(mount("sysfs", "/sys", "sysfs", 0, NULL)); CHECKCALL(mount("selinuxfs", "/sys/fs/selinux", "selinuxfs", 0, NULL)); CHECKCALL(mknod("/dev/kmsg", S_IFCHR | 0600, makedev(1, 11))); if constexpr (WORLD_WRITABLE_KMSG) { CHECKCALL(mknod("/dev/kmsg_debug", S_IFCHR | 0622, makedev(1, 11))); } //創(chuàng)建linux隨機(jī)偽設(shè)備文件 CHECKCALL(mknod("/dev/random", S_IFCHR | 0666, makedev(1, 8))); CHECKCALL(mknod("/dev/urandom", S_IFCHR | 0666, makedev(1, 9))); //log wrapper所必須的,需要在ueventd運(yùn)行之前被調(diào)用 CHECKCALL(mknod("/dev/ptmx", S_IFCHR | 0666, makedev(5, 2))); CHECKCALL(mknod("/dev/null", S_IFCHR | 0666, makedev(1, 3))); ... //將內(nèi)核的stdin/stdout/stderr 全都重定向/dev/null,關(guān)閉默認(rèn)控制臺輸出 SetStdioToDevNull(argv); // tmpfs已經(jīng)掛載到/dev上,同時(shí)我們也掛載了/dev/kmsg,我們能夠與外界開始溝通了 //初始化內(nèi)核log InitKernelLogging(argv); //檢測上面的操作是否發(fā)生了錯(cuò)誤 if (!errors.empty()) { for (const auto& [error_string, error_errno] : errors) { LOG(ERROR) << error_string << " " << strerror(error_errno); } LOG(FATAL) << "Init encountered errors starting first stage, aborting"; } LOG(INFO) << "init first stage started!"; auto old_root_dir = std::unique_ptr<DIR, decltype(&closedir)>{opendir("/"), closedir}; if (!old_root_dir) { PLOG(ERROR) << "Could not opendir("/"), not freeing ramdisk"; } struct stat old_root_info; ... //掛載 system、cache、data 等系統(tǒng)分區(qū) if (!DoFirstStageMount()) { LOG(FATAL) << "Failed to mount required partitions early ..."; } ... //進(jìn)入下一步,SetupSelinux const char* path = "/system/bin/init"; const char* args[] = {path, "selinux_setup", nullptr}; execv(path, const_cast<char**>(args)); return 1; }
我們來總結(jié)一下,F(xiàn)irstStageMain到底做了哪些重要的事情:
- 掛載及創(chuàng)建基本的文件系統(tǒng),并設(shè)置合適的訪問權(quán)限
- 關(guān)閉默認(rèn)控制臺輸出,并初始化內(nèi)核級log。
- 掛載 system、cache、data 等系統(tǒng)分區(qū)
SetupSelinux
這個(gè)模塊主要的工作是設(shè)置SELinux安全策略,本章內(nèi)容主要聚焦于android的啟動流程,selinux的內(nèi)容在此不做展開。
int SetupSelinux(char** argv) { ... const char* path = "/system/bin/init"; const char* args[] = {path, "second_stage", nullptr}; execv(path, const_cast<char**>(args)); return 1; }
SetupSelinux的最后,進(jìn)入了init的第二階段SecondStageMain。
SecondStageMain
不多說,先上代碼。
int SecondStageMain(int argc, char** argv) { // 禁止OOM killer 結(jié)束該進(jìn)程以及它的子進(jìn)程 if (auto result = WriteFile("/proc/1/oom_score_adj", "-1000"); !result) { LOG(ERROR) << "Unable to write -1000 to /proc/1/oom_score_adj: " << result.error(); } // 啟用全局Seccomp,Seccomp是什么請自行查閱資料 GlobalSeccomp(); // 設(shè)置所有進(jìn)程都能訪問的會話密鑰 keyctl_get_keyring_ID(KEY_SPEC_SESSION_KEYRING, 1); // 創(chuàng)建 /dev/.booting 文件,就是個(gè)標(biāo)記,表示booting進(jìn)行中 close(open("/dev/.booting", O_WRONLY | O_CREAT | O_CLOEXEC, 0000)); //初始化屬性的服務(wù),并從指定文件讀取屬性 property_init(); ... // 進(jìn)行SELinux第二階段并恢復(fù)一些文件安全上下文 SelinuxSetupKernelLogging(); SelabelInitialize(); SelinuxRestoreContext(); //初始化Epoll,android這里對epoll做了一層封裝 Epoll epoll; if (auto result = epoll.Open(); !result) { PLOG(FATAL) << result.error(); } //epoll 中注冊signalfd,主要是為了創(chuàng)建handler處理子進(jìn)程終止信號 InstallSignalFdHandler(&epoll); ... //epoll 中注冊property_set_fd,設(shè)置其他系統(tǒng)屬性并開啟系統(tǒng)屬性的服務(wù) StartPropertyService(&epoll); MountHandler mount_handler(&epoll); ... ActionManager& am = ActionManager::GetInstance(); ServiceList& sm = ServiceList::GetInstance(); //解析init.rc等文件,建立rc文件的action 、service,啟動其他進(jìn)程,十分關(guān)鍵的一步 LoadBootScripts(am, sm); ... am.QueueBuiltinAction(SetupCgroupsAction, "SetupCgroups"); //執(zhí)行rc文件中觸發(fā)器為 on early-init 的語句 am.QueueEventTrigger("early-init"); // 等冷插拔設(shè)備初始化完成 am.QueueBuiltinAction(wait_for_coldboot_done_action, "wait_for_coldboot_done"); am.QueueBuiltinAction(MixHwrngIntoLinuxRngAction, "MixHwrngIntoLinuxRng"); am.QueueBuiltinAction(SetMmapRndBitsAction, "SetMmapRndBits"); am.QueueBuiltinAction(SetKptrRestrictAction, "SetKptrRestrict"); // 設(shè)備組合鍵的初始化操作 Keychords keychords; am.QueueBuiltinAction( [&epoll, &keychords](const BuiltinArguments& args) -> Result<Success> { for (const auto& svc : ServiceList::GetInstance()) { keychords.Register(svc->keycodes()); } keychords.Start(&epoll, HandleKeychord); return Success(); }, "KeychordInit"); am.QueueBuiltinAction(console_init_action, "console_init"); // 執(zhí)行rc文件中觸發(fā)器為on init的語句 am.QueueEventTrigger("init"); // Starting the BoringSSL self test, for NIAP certification compliance. am.QueueBuiltinAction(StartBoringSslSelfTest, "StartBoringSslSelfTest"); // Repeat mix_hwrng_into_linux_rng in case /dev/hw_random or /dev/random // wasn't ready immediately after wait_for_coldboot_done am.QueueBuiltinAction(MixHwrngIntoLinuxRngAction, "MixHwrngIntoLinuxRng"); am.QueueBuiltinAction(InitBinder, "InitBinder"); // 當(dāng)設(shè)備處于充電模式時(shí),不需要mount文件系統(tǒng)或者啟動系統(tǒng)服務(wù),充電模式下,將charger設(shè)為執(zhí)行隊(duì)列,否則把late-init設(shè)為執(zhí)行隊(duì)列 std::string bootmode = GetProperty("ro.bootmode", ""); if (bootmode == "charger") { am.QueueEventTrigger("charger"); } else { am.QueueEventTrigger("late-init"); } // 基于屬性當(dāng)前狀態(tài) 運(yùn)行所有的屬性觸發(fā)器. am.QueueBuiltinAction(queue_property_triggers_action, "queue_property_triggers"); while (true) { //開始進(jìn)入死循環(huán)狀態(tài) auto epoll_timeout = std::optional<std::chrono::milliseconds>{}; //執(zhí)行關(guān)機(jī)重啟流程 if (do_shutdown && !shutting_down) { do_shutdown = false; if (HandlePowerctlMessage(shutdown_command)) { shutting_down = true; } } if (!(waiting_for_prop || Service::is_exec_service_running())) { am.ExecuteOneCommand(); } if (!(waiting_for_prop || Service::is_exec_service_running())) { if (!shutting_down) { auto next_process_action_time = HandleProcessActions(); // If there's a process that needs restarting, wake up in time for that. if (next_process_action_time) { epoll_timeout = std::chrono::ceil<std::chrono::milliseconds>( *next_process_action_time - boot_clock::now()); if (*epoll_timeout < 0ms) epoll_timeout = 0ms; } } // If there's more work to do, wake up again immediately. if (am.HasMoreCommands()) epoll_timeout = 0ms; } // 循環(huán)等待事件發(fā)生 if (auto result = epoll.Wait(epoll_timeout); !result) { LOG(ERROR) << result.error(); } } return 0; }
總結(jié)一下,第二階段做了以下這些比較重要的事情:
- 初始化屬性的服務(wù),并從指定文件讀取屬性
- 初始化epoll,并注冊signalfd和property_set_fd,建立和init的子進(jìn)程以及部分服務(wù)的通訊橋梁
- 初始化設(shè)備組合鍵,使系統(tǒng)能夠?qū)M合鍵信號做出響應(yīng)
- 解析init.rc文件,并按rc里的定義去啟動服務(wù)
- 開啟死循環(huán),用于接收epoll的事件
在第二階段,我們需要重點(diǎn)關(guān)注以下問題:
init進(jìn)程是如何通過init.rc配置文件去啟動其他的進(jìn)程的呢?
init.rc 解析
我們從 LoadBootScripts(am, sm)這個(gè)方法開始看起,一步一部來挖掘init.rc 的解析流程。
static void LoadBootScripts(ActionManager& action_manager, ServiceList& service_list) { //初始化ServiceParse、ActionParser、ImportParser三個(gè)解析器 Parser parser = CreateParser(action_manager, service_list); std::string bootscript = GetProperty("ro.boot.init_rc", ""); if (bootscript.empty()) { //bootscript為空,進(jìn)入此分支 parser.ParseConfig("/init.rc"); if (!parser.ParseConfig("/system/etc/init")) { late_import_paths.emplace_back("/system/etc/init"); } if (!parser.ParseConfig("/product/etc/init")) { late_import_paths.emplace_back("/product/etc/init"); } if (!parser.ParseConfig("/product_services/etc/init")) { late_import_paths.emplace_back("/product_services/etc/init"); } if (!parser.ParseConfig("/odm/etc/init")) { late_import_paths.emplace_back("/odm/etc/init"); } if (!parser.ParseConfig("/vendor/etc/init")) { late_import_paths.emplace_back("/vendor/etc/init"); } } else { parser.ParseConfig(bootscript); } }
我們可以看到這句話,Parse開始解析init.rc文件,在深入下去之前,讓我們先來認(rèn)識一下init.rc。
parser.ParseConfig("/init.rc")
init.rc是一個(gè)可配置的初始化文件,負(fù)責(zé)系統(tǒng)的初步建立。它的源文件的路徑為 /system/core/rootdir/init.rc
。
init.rc文件有著固定的語法,由于內(nèi)容過多,限制于篇幅的原因,在此另外單獨(dú)開了一篇文章進(jìn)行講解:
了解了init.rc的語法后,我們來看看init.rc文件里的內(nèi)容。
import /init.environ.rc //導(dǎo)入全局環(huán)境變量 import /init.usb.rc //adb 服務(wù)、USB相關(guān)內(nèi)容的定義 import /init.${ro.hardware}.rc //硬件相關(guān)的初始化,一般是廠商定制 import /vendor/etc/init/hw/init.${ro.hardware}.rc import /init.usb.configfs.rc import /init.${ro.zygote}.rc //定義Zygote服務(wù)
我們可以看到,在/system/core/init目錄下,存在以下四個(gè)zygote相關(guān)的文件
怎樣才能知道我們當(dāng)前的手機(jī)用的是哪個(gè)配置文件呢?
答案是通過adb shell getprop | findstr ro.zygote
命令,看看${ro.zygote}
這個(gè)環(huán)境變量具體的值是什么,筆者所使用的華為手機(jī)的ro.zygote
值如下所示:
什么是Zygote,Zygote的啟動過程是怎樣的,它的啟動配置文件里又做了啥,在這里我們不再做進(jìn)一步探討, 只需要知道init在一開始在這個(gè)文件中對Zygote服務(wù)做了定義,而上述的這些問題將留到 啟動分析之Zygote篇
再去說明。
on early-init # Disable sysrq from keyboard write /proc/sys/kernel/sysrq 0 # Set the security context of /adb_keys if present. restorecon /adb_keys # Set the security context of /postinstall if present. restorecon /postinstall mkdir /acct/uid # memory.pressure_level used by lmkd chown root system /dev/memcg/memory.pressure_level chmod 0040 /dev/memcg/memory.pressure_level # app mem cgroups, used by activity manager, lmkd and zygote mkdir /dev/memcg/apps/ 0755 system system # cgroup for system_server and surfaceflinger mkdir /dev/memcg/system 0550 system system start ueventd # Run apexd-bootstrap so that APEXes that provide critical libraries # become available. Note that this is executed as exec_start to ensure that # the libraries are available to the processes started after this statement. exec_start apexd-bootstrap
緊接著是一個(gè)Action,Action的Trigger 為early-init,在這個(gè) Action中,我們需要關(guān)注最后兩行,它啟動了ueventd服務(wù)和apex相關(guān)服務(wù)。還記得什么是ueventd和apex嗎?不記得的讀者請往上翻越再自行回顧一下。
ueventd服務(wù)的定義也可以在init.rc文件的結(jié)尾找到,具體代碼及含義如下:
service ueventd //ueventd服務(wù)的可執(zhí)行文件的路徑為 /system/bin/ueventd class core //ueventd 歸屬于 core class,同樣歸屬于core class的還有adbd 、console等服務(wù) critical //表明這個(gè)Service對設(shè)備至關(guān)重要,如果Service在四分鐘內(nèi)退出超過4次,則設(shè)備將重啟進(jìn)入恢復(fù)模式。 seclabel u:r:ueventd:s0 //selinux相關(guān)的配置 shutdown critical //ueventd服務(wù)關(guān)閉行為
然而,early-init 這個(gè)Trigger到底什么時(shí)候觸發(fā)呢?
答案是通過init.cpp代碼調(diào)用觸發(fā)。
我們可以在init.cpp 代碼中找到如下代碼片段:
am.QueueEventTrigger("early-init");
QueueEventTrigger這個(gè)方法的實(shí)現(xiàn)機(jī)制我們稍后再進(jìn)行探討,目前我們只需要了解, ActionManager
這個(gè)類中的 QueueEventTrigger
方法,負(fù)責(zé)觸發(fā)init.rc中的Action。
我們繼續(xù)往下看init.rc的內(nèi)容。
on init ... # Start logd before any other services run to ensure we capture all of their logs. start logd # Start essential services. start servicemanager ...
在Trigger 為init的Action中,我們只需要關(guān)注以上的關(guān)鍵內(nèi)容。在init的action中啟動了一些核心的系統(tǒng)服務(wù),這些服務(wù)具體的含義為 :
服務(wù)名 | 含義 |
---|---|
logd | Android L加入的服務(wù),用于保存Android運(yùn)行期間的日志 |
servicemanager | android系統(tǒng)服務(wù)管理者,負(fù)責(zé)查詢和注冊服務(wù) |
接下來是late-init Action:
on late-init //啟動vold服務(wù)(管理和控制Android平臺外部存儲設(shè)備,包括SD插撥、掛載、卸載、格式化等) trigger early-fs trigger fs trigger post-fs trigger late-fs //掛載/data , 啟動 apexd 服務(wù) trigger post-fs-data # 讀取持久化屬性或者從/data 中讀取并覆蓋屬性 trigger load_persist_props_action //啟動zygote服務(wù)??!在啟動zygote服務(wù)前會先啟動netd服務(wù)(專門負(fù)責(zé)網(wǎng)絡(luò)管理和控制的后臺守護(hù)進(jìn)程) trigger zygote-start //移除/dev/.booting 文件 trigger firmware_mounts_complete trigger early-boot trigger boot //初始化網(wǎng)絡(luò)環(huán)境,設(shè)置系統(tǒng)環(huán)境和守護(hù)進(jìn)程的權(quán)限
最后,我們用流程圖來總結(jié)一下上述的啟動過程:
以上就是Android 10 啟動Init進(jìn)程解析的詳細(xì)內(nèi)容,更多關(guān)于Android 10 啟動Init進(jìn)程的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Android解析json數(shù)組對象的方法及Apply和數(shù)組的三個(gè)技巧
這篇文章主要介紹了Android解析json數(shù)組對象的方法及Apply和數(shù)組的三個(gè)技巧的相關(guān)資料,需要的朋友可以參考下2015-12-12Android短信備份及數(shù)據(jù)插入實(shí)現(xiàn)代碼解析
這篇文章主要介紹了Android短信備份及數(shù)據(jù)插入實(shí)現(xiàn)代碼解析,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下2020-11-11Android淺析viewBinding和DataBinding
這篇文章主要介紹了Android淺析viewBinding和DataBinding,文章圍繞主題展開詳細(xì)的內(nèi)容介紹,具有一定的參考價(jià)值,需要的小伙伴可以參考一下2022-09-09