一文看懂 fork 系統調用
前言
Unix 標準的複製進程的系統調用時 fork(即分叉),但是 Linux,BSD 等操作系統並不止實現這一個,確切的說 linux 實現了三個,fork,vfork,clone(確切說 vfork 創造出來的是輕量級進程,也叫線程,是共享資源的進程)
系統調用的參數傳遞
系統調用的實現與 C 庫不同, 普通 C 函數通過將參數的值壓入到進程的棧中進行參數的傳遞。由於系統調用是通過中斷進程從用戶態到內核態的一種特殊的函數調用,沒有用戶態或者內核態的堆棧可以被用來在調用函數和被調函數之間進行參數傳遞。系統調用通過 CPU 的寄存器來進行參數傳遞。在進行系統調用之前,系統調用的參數被寫入 CPU 的寄存器,而在實際調用系統服務例程之前,內核將 CPU 寄存器的內容拷貝到內核堆棧中,實現參數的傳遞。
因此不同的體系結構可能採用不同的方式或者不同的寄存器來傳遞參數,而上面函數的任務就是從處理器的寄存器中提取用戶空間提供的信息, 並調用體系結構無關的 _do_fork(或者早期的 do_fork)函數, 負責進程的複製
即不同的體系結構可能需要採用不同的方式或者寄存器來存儲函數調用的參數, 因此 linux 在設計系統調用的時候, 將其劃分成體系結構相關的層次和體系結構無關的層次, 前者複雜提取出依賴與體系結構的特定的參數,後者則依據參數的設置執行特定的真正操作。
fork, vfork, clone 系統調用的實現
關於 do_fork 和_do_frok
linux2.5.32 以後, 添加了 TLS(Thread Local Storage) 機制, clone 的標識 CLONE_SETTLS 接受一個參數來設置線程的本地存儲區。sys_clone 也因此增加了一個 int 參數來傳入相應的點 tls_val。sys_clone 通過 do_fork 來調用 copy_process 完成進程的複製,它調用特定的 copy_thread 和 copy_thread 把相應的系統調用參數從 pt_regs 寄存器列表中提取出來,但是會導致意外的情況。
only one code path into copy_thread can pass the CLONE_SETTLS flag, and that code path comes from sys_clone with its architecture-specific argument-passing order.
前面我們說了, 在實現函數調用的時候,我 iosys_clone 等將特定體系結構的參數從寄存器中提取出來, 然後到達 do_fork 這步的時候已經應該是體系結構無關了, 但是我們 sys_clone 需要設置的 CLONE_SETTLS 的 tls 仍然是個依賴與體系結構的參數, 這裏就會出現問題。
因此 linux-4.2 之後選擇引入一個新的 CONFIG_HAVE_COPY_THREAD_TLS,和一個新的 COPY_THREAD_TLS 接受 TLS 參數爲 額外的長整型(系統調用參數大小)的爭論。改變 sys_clone 的 TLS 參數 unsigned long,並傳遞到 copy_thread_tls。
/* http://lxr.free-electrons.com/source/include/linux/sched.h?v=4.5#L2646 */
extern long _do_fork(unsigned long, unsigned long, unsigned long, int __user *, int __user *, unsigned long);
extern long do_fork(unsigned long, unsigned long, unsigned long, int __user *, int __user *);
/* linux2.5.32以後, 添加了TLS(Thread Local Storage)機制,
在最新的linux-4.2中添加了對CLONE_SETTLS 的支持
底層的_do_fork實現了對其的支持,
dansh*/
#ifndef CONFIG_HAVE_COPY_THREAD_TLS
/* For compatibility with architectures that call do_fork directly rather than
* using the syscall entry points below. */
long do_fork(unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *parent_tidptr,
int __user *child_tidptr)
{
return _do_fork(clone_flags, stack_start, stack_size,
parent_tidptr, child_tidptr, 0);
}
#endif
我們會發現,新版本的系統中 clone 的 TLS 設置標識會通過 TLS 參數傳遞, 因此_do_fork 替代了老版本的 do_fork。
老版本的 do_fork 只有在如下情況纔會定義
-
只有當系統不支持通過 TLS 參數通過參數傳遞而是使用 pt_regs 寄存器列表傳遞時
-
未定義 CONFIG_HAVE_COPY_THREAD_TLS 宏
其中 clone_flags 如下表所示
CLONE_FLAGS
sys_fork 的實現
不同體系結構下的 fork 實現 sys_fork 主要是通過標誌集合區分, 在大多數體系結構上, 典型的 fork 實現方式與如下
早期實現
asmlinkage long sys_fork(struct pt_regs regs)
{
return do_fork(SIGCHLD, regs.rsp, ®s, 0);
}
新版本
http://lxr.free-electrons.com/source/kernel/fork.c?v=4.5#L1785
#ifdef __ARCH_WANT_SYS_FORK
SYSCALL_DEFINE0(fork)
{
#ifdef CONFIG_MMU
return _do_fork(SIGCHLD, 0, 0, NULL, NULL, 0);
#else
/* can not support in nommu mode */
return -EINVAL;
#endif
}
#endif
我們可以看到唯一使用的標誌是 SIGCHLD。這意味着在子進程終止後將發送信號 SIGCHLD 信號通知父進程,
由於寫時複製 (COW) 技術, 最初父子進程的棧地址相同, 但是如果操作棧地址閉並寫入數據, 則 COW 機制會爲每個進程分別創建一個新的棧副本
如果 do_fork 成功, 則新建進程的 pid 作爲系統調用的結果返回, 否則返回錯誤碼
sys_vfork 的實現
早期實現
asmlinkage long sys_vfork(struct pt_regs regs)
{
return do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, regs.rsp, ®s, 0);
}
新版本
http://lxr.free-electrons.com/source/kernel/fork.c?v=4.5#L1797
#ifdef __ARCH_WANT_SYS_VFORK
SYSCALL_DEFINE0(vfork)
{
return _do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, 0,
0, NULL, NULL, 0);
}
#endif
可以看到 sys_vfork 的實現與 sys_fork 只是略微不同, 前者使用了額外的標誌 CLONE_VFORK | CLONE_VM
sys_clone 的實現
早期實現
sys_clone 的實現方式與上述系統調用類似, 但實際差別在於 do_fork 如下調用
casmlinkage int sys_clone(struct pt_regs regs)
{
/* 註釋中是i385下增加的代碼, 其他體系結構無此定義
unsigned long clone_flags;
unsigned long newsp;
clone_flags = regs.ebx;
newsp = regs.ecx;*/
if (!newsp)
newsp = regs.esp;
return do_fork(clone_flags, newsp, ®s, 0);
}
新版本
http://lxr.free-electrons.com/source/kernel/fork.c?v=4.5#L1805
#ifdef __ARCH_WANT_SYS_CLONE
#ifdef CONFIG_CLONE_BACKWARDS
SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
int __user *, parent_tidptr,
unsigned long, tls,
int __user *, child_tidptr)
#elif defined(CONFIG_CLONE_BACKWARDS2)
SYSCALL_DEFINE5(clone, unsigned long, newsp, unsigned long, clone_flags,
int __user *, parent_tidptr,
int __user *, child_tidptr,
unsigned long, tls)
#elif defined(CONFIG_CLONE_BACKWARDS3)
SYSCALL_DEFINE6(clone, unsigned long, clone_flags, unsigned long, newsp,
int, stack_size,
int __user *, parent_tidptr,
int __user *, child_tidptr,
unsigned long, tls)
#else
SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
int __user *, parent_tidptr,
int __user *, child_tidptr,
unsigned long, tls)
#endif
{
return _do_fork(clone_flags, newsp, 0, parent_tidptr, child_tidptr, tls);
}
#endif
我們可以看到 sys_clone 的標識不再是硬編碼的, 而是通過各個寄存器參數傳遞到系統調用, 因而我們需要提取這些參數。
另外,clone 也不再複製進程的棧, 而是可以指定新的棧地址, 在生成線程時, 可能需要這樣做, 線程可能與父進程共享地址空間, 但是線程自身的棧可能在另外一個地址空間
另外還指令了用戶空間的兩個指針 (parent_tidptr 和 child_tidptr), 用於與線程庫通信
創建子進程的流程
_do_fork 的流程
_do_fork 和 do_fork 在進程的複製的時候並沒有太大的區別, 他們就只是在進程 tls 複製的過程中實現有細微差別
所有進程複製 (創建) 的 fork 機制最終都調用了 kernel/fork.c 中的_do_fork(一個體繫結構無關的函數),
其定義在 http://lxr.free-electrons.com/source/kernel/fork.c?v=4.2#L1679
_do_fork 以調用 copy_process 開始, 後者執行生成新的進程的實際工作, 並根據指定的標誌複製父進程的數據。在子進程生成後, 內核必須執行下列收尾操作:
-
調用 copy_process 爲子進程複製出一份進程信息
-
如果是 vfork(設置了 CLONE_VFORK 和 ptrace 標誌)初始化完成處理信息
-
調用 wake_up_new_task 將子進程加入調度器,爲之分配 CPU
-
如果是 vfork,父進程等待子進程完成 exec 替換自己的地址空間
我們從 <深入 linux'內核架構> 中找到了早期的流程圖,基本一致可以作爲參考
do_fork
long _do_fork(unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *parent_tidptr,
int __user *child_tidptr,
unsigned long tls)
{
struct task_struct *p;
int trace = 0;
long nr;
/*
* Determine whether and which event to report to ptracer. When
* called from kernel_thread or CLONE_UNTRACED is explicitly
* requested, no event is reported; otherwise, report if the event
* for the type of forking is enabled.
*/
if (!(clone_flags & CLONE_UNTRACED)) {
if (clone_flags & CLONE_VFORK)
trace = PTRACE_EVENT_VFORK;
else if ((clone_flags & CSIGNAL) != SIGCHLD)
trace = PTRACE_EVENT_CLONE;
else
trace = PTRACE_EVENT_FORK;
if (likely(!ptrace_event_enabled(current, trace)))
trace = 0;
}
/* 複製進程描述符,copy_process()的返回值是一個 task_struct 指針 */
p = copy_process(clone_flags, stack_start, stack_size,
child_tidptr, NULL, trace, tls);
/*
* Do this prior waking up the new thread - the thread pointer
* might get invalid after that point, if the thread exits quickly.
*/
if (!IS_ERR(p)) {
struct completion vfork;
struct pid *pid;
trace_sched_process_fork(current, p);
/* 得到新創建的進程的pid信息 */
pid = get_task_pid(p, PIDTYPE_PID);
nr = pid_vnr(pid);
if (clone_flags & CLONE_PARENT_SETTID)
put_user(nr, parent_tidptr);
/* 如果調用的 vfork()方法,初始化 vfork 完成處理信息 */
if (clone_flags & CLONE_VFORK) {
p->vfork_done = &vfork;
init_completion(&vfork);
get_task_struct(p);
}
/* 將子進程加入到調度器中,爲其分配 CPU,準備執行 */
wake_up_new_task(p);
/* forking complete and child started to run, tell ptracer */
if (unlikely(trace))
ptrace_event_pid(trace, pid);
/* 如果是 vfork,將父進程加入至等待隊列,等待子進程完成 */
if (clone_flags & CLONE_VFORK) {
if (!wait_for_vfork_done(p, &vfork))
ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid);
}
put_pid(pid);
} else {
nr = PTR_ERR(p);
}
return nr;
}
copy_process 流程
http://lxr.free-electrons.com/source/kernel/fork.c?v=4.5#L1237
-
調用 dup_task_struct 複製當前的 task_struct
-
檢查進程數是否超過限制
-
初始化自旋鎖、掛起信號、CPU 定時器等
-
調用 sched_fork 初始化進程數據結構,並把進程狀態設置爲 TASK_RUNNING
-
複製所有進程信息,包括文件系統、信號處理函數、信號、內存管理等
-
調用 copy_thread_tls 初始化子進程內核棧
-
爲新進程分配並設置新的 pid
我們從 <深入 linux'內核架構> 中找到了早期的流程圖,基本一致可以作爲參考
do_fork
/*
* This creates a new process as a copy of the old one,
* but does not actually start it yet.
*
* It copies the registers, and all the appropriate
* parts of the process environment (as per the clone
* flags). The actual kick-off is left to the caller.
*/
static struct task_struct *copy_process(unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *child_tidptr,
struct pid *pid,
int trace,
unsigned long tls)
{
int retval;
struct task_struct *p;
retval = security_task_create(clone_flags);
if (retval)
goto fork_out;
// 複製當前的 task_struct
retval = -ENOMEM;
p = dup_task_struct(current);
if (!p)
goto fork_out;
ftrace_graph_init_task(p);
//初始化互斥變量
rt_mutex_init_task(p);
#ifdef CONFIG_PROVE_LOCKING
DEBUG_LOCKS_WARN_ON(!p->hardirqs_enabled);
DEBUG_LOCKS_WARN_ON(!p->softirqs_enabled);
#endif
//檢查進程數是否超過限制,由操作系統定義
retval = -EAGAIN;
if (atomic_read(&p->real_cred->user->processes) >=
task_rlimit(p, RLIMIT_NPROC)) {
if (p->real_cred->user != INIT_USER &&
!capable(CAP_SYS_RESOURCE) && !capable(CAP_SYS_ADMIN))
goto bad_fork_free;
}
current->flags &= ~PF_NPROC_EXCEEDED;
retval = copy_creds(p, clone_flags);
if (retval < 0)
goto bad_fork_free;
/*
* If multiple threads are within copy_process(), then this check
* triggers too late. This doesn't hurt, the check is only there
* to stop root fork bombs.
*/
//檢查進程數是否超過 max_threads 由內存大小決定
retval = -EAGAIN;
if (nr_threads >= max_threads)
goto bad_fork_cleanup_count;
delayacct_tsk_init(p); /* Must remain after dup_task_struct() */
p->flags &= ~(PF_SUPERPRIV | PF_WQ_WORKER);
p->flags |= PF_FORKNOEXEC;
INIT_LIST_HEAD(&p->children);
INIT_LIST_HEAD(&p->sibling);
rcu_copy_process(p);
p->vfork_done = NULL;
// 初始化自旋鎖
spin_lock_init(&p->alloc_lock);
// 初始化掛起信號
init_sigpending(&p->pending);
// 初始化 CPU 定時器
posix_cpu_timers_init(p);
// ......
/* Perform scheduler related setup. Assign this task to a CPU.
初始化進程數據結構,並把進程狀態設置爲 TASK_RUNNING
*/
retval = sched_fork(clone_flags, p);
if (retval)
goto bad_fork_cleanup_policy;
retval = perf_event_init_task(p);
/* 複製所有進程信息,包括文件系統、信號處理函數、信號、內存管理等 */
if (retval)
goto bad_fork_cleanup_policy;
retval = audit_alloc(p);
if (retval)
goto bad_fork_cleanup_perf;
/* copy all the process information */
shm_init_task(p);
retval = copy_semundo(clone_flags, p);
if (retval)
goto bad_fork_cleanup_audit;
retval = copy_files(clone_flags, p);
if (retval)
goto bad_fork_cleanup_semundo;
retval = copy_fs(clone_flags, p);
if (retval)
goto bad_fork_cleanup_files;
retval = copy_sighand(clone_flags, p);
if (retval)
goto bad_fork_cleanup_fs;
retval = copy_signal(clone_flags, p);
if (retval)
goto bad_fork_cleanup_sighand;
retval = copy_mm(clone_flags, p);
if (retval)
goto bad_fork_cleanup_signal;
retval = copy_namespaces(clone_flags, p);
if (retval)
goto bad_fork_cleanup_mm;
retval = copy_io(clone_flags, p);
if (retval)
goto bad_fork_cleanup_namespaces;
/* 初始化子進程內核棧
linux-4.2新增處理TLS
之前版本是 retval = copy_thread(clone_flags, stack_start, stack_size, p);
*/
retval = copy_thread_tls(clone_flags, stack_start, stack_size, p, tls);
if (retval)
goto bad_fork_cleanup_io;
/* 爲新進程分配新的pid */
if (pid != &init_struct_pid) {
pid = alloc_pid(p->nsproxy->pid_ns_for_children);
if (IS_ERR(pid)) {
retval = PTR_ERR(pid);
goto bad_fork_cleanup_io;
}
}
/* 設置子進程的pid */
/* ok, now we should be set up.. */
p->pid = pid_nr(pid);
if (clone_flags & CLONE_THREAD) {
p->exit_signal = -1;
p->group_leader = current->group_leader;
p->tgid = current->tgid;
} else {
if (clone_flags & CLONE_PARENT)
p->exit_signal = current->group_leader->exit_signal;
else
p->exit_signal = (clone_flags & CSIGNAL);
p->group_leader = p;
p->tgid = p->pid;
}
p->nr_dirtied = 0;
p->nr_dirtied_pause = 128 >> (PAGE_SHIFT - 10);
p->dirty_paused_when = 0;
p->pdeath_signal = 0;
INIT_LIST_HEAD(&p->thread_group);
p->task_works = NULL;
/*
* Make it visible to the rest of the system, but dont wake it up yet.
* Need tasklist lock for parent etc handling!
*/
write_lock_irq(&tasklist_lock);
/* 調用fork的進程爲其父進程 */
/* CLONE_PARENT re-uses the old parent */
if (clone_flags & (CLONE_PARENT|CLONE_THREAD)) {
p->real_parent = current->real_parent;
p->parent_exec_id = current->parent_exec_id;
} else {
p->real_parent = current;
p->parent_exec_id = current->self_exec_id;
}
spin_lock(¤t->sighand->siglock);
// ......
return p;
}
dup_task_struct 流程
http://lxr.free-electrons.com/source/kernel/fork.c?v=4.5#L334
static struct task_struct *dup_task_struct(struct task_struct *orig)
{
struct task_struct *tsk;
struct thread_info *ti;
int node = tsk_fork_get_node(orig);
int err;
//分配一個 task_struct 節點
tsk = alloc_task_struct_node(node);
if (!tsk)
return NULL;
//分配一個 thread_info 節點,包含進程的內核棧,ti 爲棧底
ti = alloc_thread_info_node(tsk, node);
if (!ti)
goto free_tsk;
//將棧底的值賦給新節點的棧
tsk->stack = ti;
//……
return tsk;
}
-
調用 alloc_task_struct_node 分配一個 task_struct 節點
-
調用 alloc_thread_info_node 分配一個 thread_info 節點,其實是分配了一個 thread_union 聯合體, 將棧底返回給 ti
union thread_union {
struct thread_info thread_info;
unsigned long stack[THREAD_SIZE/sizeof(long)];
};
-
最後將棧底的值 ti 賦值給新節點的棧
-
最終執行完 dup_task_struct 之後,子進程除了 tsk->stack 指針不同之外,全部都一樣!
sched_fork 流程
int sched_fork(unsigned long clone_flags, struct task_struct *p)
{
unsigned long flags;
int cpu = get_cpu();
__sched_fork(clone_flags, p);
// 將子進程狀態設置爲 TASK_RUNNING
p->state = TASK_RUNNING;
// ……
// 爲子進程分配 CPU
set_task_cpu(p, cpu);
put_cpu();
return 0;
}
我們可以看到 sched_fork 大致完成了兩項重要工作,
-
一是將子進程狀態設置爲 TASK_RUNNING,
-
二是爲其分配 CPU
copy_thread 和 copy_thread_tls 流程
我們可以看到 linux-4.2 之後增加了 copy_thread_tls 函數和 CONFIG_HAVE_COPY_THREAD_TLS 宏
但是如果未定義 CONFIG_HAVE_COPY_THREAD_TLS 宏默認則使用 copy_thread 同時將定義 copy_thread_tls 爲 copy_thread
#ifdef CONFIG_HAVE_COPY_THREAD_TLS
extern int copy_thread_tls(unsigned long, unsigned long, unsigned long,
struct task_struct *, unsigned long);
#else
extern int copy_thread(unsigned long, unsigned long, unsigned long,
struct task_struct *);
/* Architectures that haven't opted into copy_thread_tls get the tls argument
* via pt_regs, so ignore the tls argument passed via C. */
static inline int copy_thread_tls(
unsigned long clone_flags, unsigned long sp, unsigned long arg,
struct task_struct *p, unsigned long tls)
{
return copy_thread(clone_flags, sp, arg, p);
}
#endif
下面我們來看 32 位架構的 copy_thread_tls 函數,他與原來的 copy_thread 變動並不大, 只是多了後面 TLS 的設置信息
int copy_thread_tls(unsigned long clone_flags, unsigned long sp,
unsigned long arg, struct task_struct *p, unsigned long tls)
{
struct pt_regs *childregs = task_pt_regs(p);
struct task_struct *tsk;
int err;
/* 獲取寄存器的信息 */
p->thread.sp = (unsigned long) childregs;
p->thread.sp0 = (unsigned long) (childregs+1);
memset(p->thread.ptrace_bps, 0, sizeof(p->thread.ptrace_bps));
if (unlikely(p->flags & PF_KTHREAD)) {
/* kernel thread
內核線程的設置 */
memset(childregs, 0, sizeof(struct pt_regs));
p->thread.ip = (unsigned long) ret_from_kernel_thread;
task_user_gs(p) = __KERNEL_STACK_CANARY;
childregs->ds = __USER_DS;
childregs->es = __USER_DS;
childregs->fs = __KERNEL_PERCPU;
childregs->bx = sp; /* function */
childregs->bp = arg;
childregs->orig_ax = -1;
childregs->cs = __KERNEL_CS | get_kernel_rpl();
childregs->flags = X86_EFLAGS_IF | X86_EFLAGS_FIXED;
p->thread.io_bitmap_ptr = NULL;
return 0;
}
/* 將當前寄存器信息複製給子進程 */
*childregs = *current_pt_regs();
/* 子進程 eax 置 0,因此fork 在子進程返回0 */
childregs->ax = 0;
if (sp)
childregs->sp = sp;
/* 子進程ip 設置爲ret_from_fork,因此子進程從ret_from_fork開始執行 */
p->thread.ip = (unsigned long) ret_from_fork;
task_user_gs(p) = get_user_gs(current_pt_regs());
p->thread.io_bitmap_ptr = NULL;
tsk = current;
err = -ENOMEM;
if (unlikely(test_tsk_thread_flag(tsk, TIF_IO_BITMAP))) {
p->thread.io_bitmap_ptr = kmemdup(tsk->thread.io_bitmap_ptr,
IO_BITMAP_BYTES, GFP_KERNEL);
if (!p->thread.io_bitmap_ptr) {
p->thread.io_bitmap_max = 0;
return -ENOMEM;
}
set_tsk_thread_flag(p, TIF_IO_BITMAP);
}
err = 0;
/*
* Set a new TLS for the child thread?
* 爲進程設置一個新的TLS
*/
if (clone_flags & CLONE_SETTLS)
err = do_set_thread_area(p, -1,
(struct user_desc __user *)tls, 0);
if (err && p->thread.io_bitmap_ptr) {
kfree(p->thread.io_bitmap_ptr);
p->thread.io_bitmap_max = 0;
}
return err;
}
copy_thread 這段代碼爲我們解釋了兩個相當重要的問題!
-
一是,爲什麼 fork 在子進程中返回 0,原因是 childregs->ax = 0; 這段代碼將子進程的 eax 賦值爲 0
-
二是,p->thread.ip = (unsigned long) ret_from_fork; 將子進程的 ip 設置爲 ret_form_fork 的首地址,因此子進程是從 ret_from_fork 開始執行的
總結
fork, vfork 和 clone 的系統調用的入口地址分別是 sys_fork, sys_vfork 和 sys_clone, 而他們的定義是依賴於體系結構的, 而他們最終都調用了_do_fork(linux-4.2 之前的內核中是 do_fork),在_do_fork 中通過 copy_process 複製進程的信息,調用 wake_up_new_task 將子進程加入調度器中
-
dup_task_struct 中爲其分配了新的堆棧
-
調用了 sched_fork,將其置爲 TASK_RUNNING
-
copy_thread(_tls) 中將父進程的寄存器上下文複製給子進程,保證了父子進程的堆棧信息是一致的,
-
將 ret_from_fork 的地址設置爲 eip 寄存器的值
-
爲新進程分配並設置新的 pid
-
最終子進程從 ret_from_fork 開始執行
進程的創建到執行過程如下圖所示
進程的狀態
轉自:https://blog.csdn.net/gatieme/article/details/51569932
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/n0jCa5ePYCj3JHs5_5O3EQ