linux調度器源碼分析 - 運行(四)


本文為原創,轉載請注明:http://www.cnblogs.com/tolimit/

 

引言

  之前的文章已經將調度器的數據結構、初始化、加入進程都進行了分析,這篇文章將主要說明調度器是如何在程序穩定運行的情況下進行進程調度的。

 

系統定時器

  因為我們主要講解的是調度器,而會涉及到一些系統定時器的知識,這里我們簡單講解一下內核中定時器是如何組織,又是如何通過通過定時器實現了調度器的間隔調度。首先我們先看一下內核定時器的框架

  在內核中,會使用strut clock_event_device結構描述硬件上的定時器,每個硬件定時器都有其自己的精度,會根據精度每隔一段時間產生一個時鍾中斷。而系統會讓每個CPU使用一個tick_device描述系統當前使用的硬件定時器(因為每個CPU都有其自己的運行隊列),通過tick_device所使用的硬件時鍾中斷進行時鍾滴答(jiffies)的累加(只會有一個CPU負責這件事),並且在中斷中也會調用調度器,而我們在驅動中常用的低精度定時器就是通過判斷jiffies實現的。而當使用高精度定時器(hrtimer)時,情況則不一樣,hrtimer會生成一個普通的高精度定時器,在這個定時器中回調函數是調度器,其設置的間隔時間同時鍾滴答一樣。

  所以在系統中,每一次時鍾滴答都會使調度器判斷一次是否需要進行調度。

 

時鍾中斷

  當時鍾發生中斷時,首先會調用的是tick_handle_periodic()函數,在此函數中又主要執行tick_periodic()函數進行操作。我們先看一下tick_handle_periodic()函數:

 1 void tick_handle_periodic(struct clock_event_device *dev)
 2 {
 3     /* 獲取當前CPU */
 4     int cpu = smp_processor_id();
 5     /* 獲取下次時鍾中斷執行時間 */
 6     ktime_t next = dev->next_event;
 7 
 8     tick_periodic(cpu);
 9     
10     /* 如果是周期觸發模式,直接返回 */
11     if (dev->mode != CLOCK_EVT_MODE_ONESHOT)
12         return;
13 
14     /* 為了防止當該函數被調用時,clock_event_device中的計時實際上已經經過了不止一個tick周期,這時候,tick_periodic可能被多次調用,使得jiffies和時間可以被正確地更新。 */
15     for (;;) {
16         /*
17          * Setup the next period for devices, which do not have
18          * periodic mode:
19          */
20         /* 計算下一次觸發時間 */
21         next = ktime_add(next, tick_period);
22 
23         /* 設置下一次觸發時間,返回0表示成功 */
24         if (!clockevents_program_event(dev, next, false))
25             return;
26         /*
27          * Have to be careful here. If we're in oneshot mode,
28          * before we call tick_periodic() in a loop, we need
29          * to be sure we're using a real hardware clocksource.
30          * Otherwise we could get trapped in an infinite(無限的)
31          * loop, as the tick_periodic() increments jiffies,
32          * which then will increment time, possibly causing
33          * the loop to trigger again and again.
34          */
35         if (timekeeping_valid_for_hres())
36             tick_periodic(cpu);
37     }
38 }

  此函數主要工作是執行tick_periodic()函數,然后判斷時鍾中斷是單觸發模式還是循環觸發模式,如果是循環觸發模式,則直接返回,如果是單觸發模式,則執行如下操作:

  • 計算下一次觸發時間
  • 設置下次觸發時間
  • 如果設置下次觸發時間失敗,則根據timekeeper等待下次tick_periodic()函數執行時間。
  • 返回第一步

 

  而在tick_periodic()函數中,程序主要執行路線為tick_periodic()->update_process_times()->scheduler_tick()。最后的scheduler_tick()函數則是跟調度相關的主要函數。我們在這具體先看看tick_periodic()函數和update_process_times()函數:

 1 /* tick_device 周期性調用此函數
 2  * 更新jffies和當前進程
 3  * 只有一個CPU是負責更新jffies的,其他的CPU只會更新當前自己的進程
 4  */
 5 static void tick_periodic(int cpu)
 6 {
 7 
 8     if (tick_do_timer_cpu == cpu) {
 9         /* 當前CPU負責更新時間 */
10         write_seqlock(&jiffies_lock);
11 
12         /* Keep track of the next tick event */
13         tick_next_period = ktime_add(tick_next_period, tick_period);
14 
15         /* 更新 jiffies計數,jiffies += 1 */
16         do_timer(1);
17         write_sequnlock(&jiffies_lock);
18         /* 更新牆上時間,就是我們生活中的時間 */
19         update_wall_time();
20     }
21     /* 更新當前進程信息,調度器主要函數 */
22     update_process_times(user_mode(get_irq_regs()));
23     profile_tick(CPU_PROFILING);
24 }
25 
26 
27 
28 
29 void update_process_times(int user_tick)
30 {
31     struct task_struct *p = current;
32     int cpu = smp_processor_id();
33 
34     /* Note: this timer irq context must be accounted for as well. */
35     /* 更新當前進程的內核態和用戶態占用率 */
36     account_process_tick(p, user_tick);
37     /* 檢查有沒有定時器到期,有就運行到期定時器的處理 */
38     run_local_timers();
39     rcu_check_callbacks(cpu, user_tick);
40 #ifdef CONFIG_IRQ_WORK
41     if (in_irq())
42         irq_work_tick();
43 #endif
44     /* 調度器的tick */
45     scheduler_tick();
46     run_posix_cpu_timers(p);
47 }

  這兩個函數主要工作為將jiffies加1、更新系統的牆上時間、更新當前進程的內核態和用戶態的CPU占用率、檢查是否有定時器到期,運行到期的定時器。當執行完這些操作后,就到了最重要的scheduler_tick()函數,而scheduler_tick()函數主要做什么呢,就是更新CPU和當前進行的一些數據,然后根據當前進程的調度類,調用task_tick()函數。這里普通進程調度類的task_tick()是task_tick_fair()函數。

 1 void scheduler_tick(void)
 2 {
 3     /* 獲取當前CPU的ID */
 4     int cpu = smp_processor_id();
 5     /* 獲取當前CPU的rq隊列 */
 6     struct rq *rq = cpu_rq(cpu);
 7     /* 獲取當前CPU的當前運行程序,實際上就是current */
 8     struct task_struct *curr = rq->curr;
 9     /* 更新CPU調度統計中的本次調度時間 */
10     sched_clock_tick();
11 
12     raw_spin_lock(&rq->lock);
13     /* 更新該CPU的rq運行時間 */
14     update_rq_clock(rq);
15     curr->sched_class->task_tick(rq, curr, 0);
16     /* 更新CPU的負載 */
17     update_cpu_load_active(rq);
18     raw_spin_unlock(&rq->lock);
19 
20     perf_event_task_tick();
21 
22 #ifdef CONFIG_SMP
23     rq->idle_balance = idle_cpu(cpu);
24     trigger_load_balance(rq);
25 #endif
26     /* rq->last_sched_tick = jiffies; */
27     rq_last_tick_reset(rq);
28 }
29 
30 
31 
32 
33 /*
34  * CFS調度類的task_tick()
35  */
36 static void task_tick_fair(struct rq *rq, struct task_struct *curr, int queued)
37 {
38     struct cfs_rq *cfs_rq;
39     struct sched_entity *se = &curr->se;
40     /* 向上更新進程組時間片 */
41     for_each_sched_entity(se) {
42         cfs_rq = cfs_rq_of(se);
43         /* 更新當前進程運行時間,並判斷是否需要調度此進程 */
44         entity_tick(cfs_rq, se, queued);
45     }
46 
47     if (numabalancing_enabled)
48         task_tick_numa(rq, curr);
49 
50     update_rq_runnable_avg(rq, 1);
51 }

  顯然,到這里最重要的函數應該是entity_tick(),因為是這個函數決定了當前進程是否需要調度出去。我們必須先明確一點就是,CFS調度策略是使用紅黑樹以進程的vruntime為鍵值進行組織的,進程的vruntime越小越在紅黑樹的左邊,而每次調度的下一個目標就是紅黑樹最左邊的結點上的進程。而當進行運行時,其vruntime是隨着實際運行時間而增加的,但是不同權重的進程其vruntime增加的速率不同,正在運行的進程的權重約大(優先級越高),其vruntime增加的速率越慢,所以其所占用的CPU時間越多。而每次時鍾中斷的時候,在entity_tick()函數中都會更新當前進程的vruntime值。當進程沒有處於CPU上運行時,其vruntime是保持不變的。

 1 static void
 2 entity_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr, int queued)
 3 {
 4     /*
 5      * Update run-time statistics of the 'current'.
 6      */
 7     /* 更新當前進程運行時間,包括虛擬運行時間 */
 8     update_curr(cfs_rq);
 9 
10     /*
11      * Ensure that runnable average is periodically updated.
12      */
13     update_entity_load_avg(curr, 1);
14     update_cfs_rq_blocked_load(cfs_rq, 1);
15     update_cfs_shares(cfs_rq);
16 
17 #ifdef CONFIG_SCHED_HRTICK
18     /*
19      * queued ticks are scheduled to match the slice, so don't bother
20      * validating it and just reschedule.
21      */
22     /* 若queued為1,則當前運行隊列的運行進程需要調度 */
23     if (queued) {
24         /* 標記當前進程需要被調度出去 */
25         resched_curr(rq_of(cfs_rq));
26         return;
27     }
28     /*
29      * don't let the period tick interfere with the hrtick preemption
30      */
31     if (!sched_feat(DOUBLE_TICK) && hrtimer_active(&rq_of(cfs_rq)->hrtick_timer))
32         return;
33 #endif
34     /* 檢查是否需要調度 */
35     if (cfs_rq->nr_running > 1)
36         check_preempt_tick(cfs_rq, curr);
37 }

  之后的文章會詳細說說CFS關於進程的vruntime的處理,現在只需要知道是這樣就好,在entity_tick()中,首先會更新當前進程的實際運行時間和虛擬運行時間,這里很重要,因為要使用更新后的這些數據去判斷是否需要被調度。在entity_tick()函數中最后面的check_preempt_tick()函數就是用來判斷進程是否需要被調度的,其判斷的標准有兩個:

  • 先判斷當前進程的實際運行時間是否超過CPU分配給這個進程的CPU時間,如果超過,則需要調度。
  • 再判斷當前進程的vruntime是否大於下個進程的vruntime,如果大於,則需要調度。

  清楚了這兩個標准,check_preempt_tick()的代碼則很好理解了。

 1 /*
 2  * 檢查當前進程是否需要被搶占
 3  * 判斷方法有兩種,一種就是判斷當前進程是否超過了CPU分配給它的實際運行時間
 4  * 另一種就是判斷當前進程的虛擬運行時間是否大於下個進程的虛擬運行時間
 5  */
 6 static void
 7 check_preempt_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr)
 8 {
 9     /* ideal_runtime為進程應該運行的時間
10      * delta_exec為進程增加的實際運行時間
11      * 如果delta_exec超過了ideal_runtime,表示該進程應該讓出CPU給其他進程
12      */
13     unsigned long ideal_runtime, delta_exec;
14     struct sched_entity *se;
15     s64 delta;
16 
17 
18     /* slice為CFS隊列中所有進程運行一遍需要的實際時間 */
19     /* ideal_runtime保存的是CPU分配給當前進程一個周期內實際的運行時間,計算公式為:  一個周期內進程應當運行的時間 = 一個周期內隊列中所有進程運行一遍需要的時間 * 當前進程權重 / 隊列總權重
20      * delta_exec保存的是當前進程增加使用的實際運行時間
21      */
22     ideal_runtime = sched_slice(cfs_rq, curr);
23     delta_exec = curr->sum_exec_runtime - curr->prev_sum_exec_runtime;
24     if (delta_exec > ideal_runtime) {
25         /* 增加的實際運行實際 > 應該運行實際,說明需要調度出去 */
26         resched_curr(rq_of(cfs_rq));
27         /*
28          * The current task ran long enough, ensure it doesn't get
29          * re-elected due to buddy favours.
30          */
31         /* 如果cfs_rq隊列的last,next,skip指針中的某個等於當前進程,則清空cfs_rq隊列中的相應指針 */
32         clear_buddies(cfs_rq, curr);
33         return;
34     }
35 
36     /*
37      * Ensure that a task that missed wakeup preemption by a
38      * narrow margin doesn't have to wait for a full slice.
39      * This also mitigates buddy induced latencies under load.
40      */
41     if (delta_exec < sysctl_sched_min_granularity)
42         return;
43     /* 獲取下一個調度進程的se */
44     se = __pick_first_entity(cfs_rq);
45     /* 當前進程的虛擬運行時間 - 下個進程的虛擬運行時間 */
46     delta = curr->vruntime - se->vruntime;
47 
48     /* 當前進程的虛擬運行時間 大於 下個進程的虛擬運行時間,說明這個進程還可以繼續運行 */
49     if (delta < 0)
50         return;
51 
52     if (delta > ideal_runtime)
53         /* 當前進程的虛擬運行時間 小於 下個進程的虛擬運行時間,說明下個進程比當前進程更應該被CPU使用,resched_curr()函數用於標記當前進程需要被調度出去 */
54         resched_curr(rq_of(cfs_rq));
55 }
56 
57 
58 
59 
60 /*
61  * resched_curr - mark rq's current task 'to be rescheduled now'.
62  *
63  * On UP this means the setting of the need_resched flag, on SMP it
64  * might also involve a cross-CPU call to trigger the scheduler on
65  * the target CPU.
66  */
67 /* 標記當前進程需要調度,將當前進程的thread_info->flags設置TIF_NEED_RESCHED標記 */
68 void resched_curr(struct rq *rq)
69 {
70     struct task_struct *curr = rq->curr;
71     int cpu;
72 
73     lockdep_assert_held(&rq->lock);
74 
75     /* 檢查當前進程是否已經設置了調度標志,如果是,則不用再設置一遍,直接返回 */
76     if (test_tsk_need_resched(curr))
77         return;
78 
79     /* 根據rq獲取CPU */
80     cpu = cpu_of(rq);
81     /* 如果CPU = 當前CPU,則設置當前進程需要調度標志 */
82     if (cpu == smp_processor_id()) {
83         /* 設置當前進程需要被調度出去的標志,這個標志保存在進程的thread_info結構上 */
84         set_tsk_need_resched(curr);
85         /* 設置CPU的內核搶占 */
86         set_preempt_need_resched();
87         return;
88     }
89 
90     /* 如果不是處於當前CPU上,則設置當前進程需要調度,並通知其他CPU */
91     if (set_nr_and_not_polling(curr))
92         smp_send_reschedule(cpu);
93     else
94         trace_sched_wake_idle_without_ipi(cpu);
95 }

  好了,到這里實際上如果進程需要被調度,則已經被標記,如果進程不需要被調度,則繼續執行。這里大家或許有疑問,只標記了進程需要被調度,但是為什么並沒有真正處理它?其實根據我的博文linux調度器源碼分析 - 概述(一)所說,進程調度的發生時機之一就是發生在中斷返回時,這里是在匯編代碼中實現的,而我們知道這里我們是時鍾中斷執行上述的這些操作的,當執行完這些后,從時鍾中斷返回去的時候,會調用到匯編函數ret_from_sys_call,在這個函數中會先檢查調度標志被置位,如果被置位,則跳轉至schedule(),而schedule()最后調用到__schedule()這個函數進行處理。

  1 static void __sched __schedule(void)
  2 {
  3     /* prev保存換出進程(也就是當前進程),next保存換進進程 */
  4     struct task_struct *prev, *next;
  5     unsigned long *switch_count;
  6     struct rq *rq;
  7     int cpu;
  8 
  9 need_resched:
 10     /* 禁止搶占 */
 11     preempt_disable();
 12     /* 獲取當前CPU ID */
 13     cpu = smp_processor_id();
 14     /* 獲取當前CPU運行隊列 */
 15     rq = cpu_rq(cpu);
 16     rcu_note_context_switch(cpu);
 17     prev = rq->curr;
 18 
 19     schedule_debug(prev);
 20 
 21     if (sched_feat(HRTICK))
 22         hrtick_clear(rq);
 23 
 24     /*
 25      * Make sure that signal_pending_state()->signal_pending() below
 26      * can't be reordered with __set_current_state(TASK_INTERRUPTIBLE)
 27      * done by the caller to avoid the race with signal_wake_up().
 28      */
 29     smp_mb__before_spinlock();
 30     /* 隊列上鎖 */
 31     raw_spin_lock_irq(&rq->lock);
 32     /* 當前進程非自願切換次數 */
 33     switch_count = &prev->nivcsw;
 34     
 35     /*
 36      * 當內核搶占時會置位thread_info的preempt_count的PREEMPT_ACTIVE位,調用schedule()之后會清除,PREEMPT_ACTIVE置位表明是從內核搶占進入到此的
 37      * preempt_count()是判斷thread_info的preempt_count整體是否為0
 38      * prev->state大於0表明不是TASK_RUNNING狀態
 39      *
 40      */
 41     if (prev->state && !(preempt_count() & PREEMPT_ACTIVE)) {    
 42         /* 當前進程不為TASK_RUNNING狀態並且不是通過內核態搶占進入調度 */
 43         if (unlikely(signal_pending_state(prev->state, prev))) {
 44             /* 有信號需要處理,置為TASK_RUNNING */
 45             prev->state = TASK_RUNNING;
 46         } else {
 47             /* 沒有信號掛起需要處理,會將此進程移除運行隊列 */
 48             /* 如果代碼執行到此,說明當前進程要么准備退出,要么是處於即將睡眠狀態 */
 49             deactivate_task(rq, prev, DEQUEUE_SLEEP);
 50             prev->on_rq = 0;
 51 
 52             /*
 53              * If a worker went to sleep, notify and ask workqueue
 54              * whether it wants to wake up a task to maintain
 55              * concurrency.
 56              */
 57             if (prev->flags & PF_WQ_WORKER) {
 58                 /* 如果當前進程處於一個工作隊列中 */
 59                 struct task_struct *to_wakeup;
 60 
 61                 to_wakeup = wq_worker_sleeping(prev, cpu);
 62                 if (to_wakeup)
 63                     try_to_wake_up_local(to_wakeup);
 64             }
 65         }
 66         switch_count = &prev->nvcsw;
 67     }
 68 
 69     /* 更新rq運行隊列時間 */
 70     if (task_on_rq_queued(prev) || rq->skip_clock_update < 0)
 71         update_rq_clock(rq);
 72 
 73     /* 獲取下一個調度實體,這里的next的值會是一個進程,而不是一個調度組,在pick_next_task會遞歸選出一個進程 */
 74     next = pick_next_task(rq, prev);
 75     /* 清除當前進程的thread_info結構中的flags的TIF_NEED_RESCHED和PREEMPT_NEED_RESCHED標志位,這兩個位表明其可以被調度調出(因為這里已經調出了,所以這兩個位就沒必要了) */
 76     clear_tsk_need_resched(prev);
 77     clear_preempt_need_resched();
 78     rq->skip_clock_update = 0;
 79 
 80     if (likely(prev != next)) {
 81         /* 該CPU進程切換次數加1 */
 82         rq->nr_switches++;
 83         /* 該CPU當前執行進程為新進程 */
 84         rq->curr = next;
 85         
 86         ++*switch_count;
 87         
 88         /* 這里進行了進程上下文的切換 */
 89         context_switch(rq, prev, next); /* unlocks the rq */
 90         /*
 91          * The context switch have flipped the stack from under us
 92          * and restored the local variables which were saved when
 93          * this task called schedule() in the past. prev == current
 94          * is still correct, but it can be moved to another cpu/rq.
 95          */
 96         /* 新的進程有可能在其他CPU上運行,重新獲取一次CPU和rq */
 97         cpu = smp_processor_id();
 98         rq = cpu_rq(cpu);
 99     }
100     else
101         raw_spin_unlock_irq(&rq->lock);        /* 這里意味着下個調度的進程就是當前進程,釋放鎖不做任何處理 */
102     /* 上下文切換后的處理 */
103     post_schedule(rq);
104 
105     /* 重新打開搶占使能但不立即執行重新調度 */
106     sched_preempt_enable_no_resched();
107     if (need_resched())
108         goto need_resched;
109 }

  在__schedule()中,每一步的作用注釋已經寫得很詳細了,選取下一個進程的任務在__schedule()中交給了pick_next_task()函數,而進程切換則交給了context_switch()函數。我們先看看pick_next_task()函數是如何選取下一個進程的:

 1 static inline struct task_struct *
 2 pick_next_task(struct rq *rq, struct task_struct *prev)
 3 {
 4     const struct sched_class *class = &fair_sched_class;
 5     struct task_struct *p;
 6 
 7     /*
 8      * Optimization: we know that if all tasks are in
 9      * the fair class we can call that function directly:
10      */
11 
12     if (likely(prev->sched_class == class && rq->nr_running == rq->cfs.h_nr_running)) {
13         /* 所有進程都處於CFS運行隊列中,所以就直接使用cfs的調度類 */
14         p = fair_sched_class.pick_next_task(rq, prev);
15         if (unlikely(p == RETRY_TASK))
16             goto again;
17 
18         /* assumes fair_sched_class->next == idle_sched_class */
19         if (unlikely(!p))
20             p = idle_sched_class.pick_next_task(rq, prev);
21 
22         return p;
23     }
24 
25 again:
26     /* 在其他調度類中包含有其他進程,從最高優先級的調度類迭代到最低優先級的調度類,並選擇最優的進程運行 */
27     for_each_class(class) {
28         p = class->pick_next_task(rq, prev);
29         if (p) {
30             if (unlikely(p == RETRY_TASK))
31                 goto again;
32             return p;
33         }
34     }
35 
36     BUG(); /* the idle class will always have a runnable task */
37 }

  在pick_next_task()中完全體現了進程優先級的概念,首先會先判斷是否所有進程都處於cfs隊列中,如果不是,則表明有比普通進程更高優先級的進程(包括實時進程)。內核中是將調度類重優先級高到低進行排列,然后選擇時從最高優先級的調度類開始找是否有進程需要調度,如果沒有會轉到下一優先級調度類,在代碼27行所體現,27行展開是

#define for_each_class(class) \
   for (class = sched_class_highest; class; class = class->next)

  而調度類的優先級順序為

調度類優先級順序: stop_sched_class -> dl_sched_class -> rt_sched_class -> fair_sched_class -> idle_sched_class

  在pick_next_task()函數中返回了選定的進程的進程描述符,接下來就會調用context_switch()進行進程切換了。

 1 static inline void
 2 context_switch(struct rq *rq, struct task_struct *prev,
 3            struct task_struct *next)
 4 {
 5     struct mm_struct *mm, *oldmm;
 6 
 7     prepare_task_switch(rq, prev, next);
 8 
 9     mm = next->mm;
10     oldmm = prev->active_mm;
11     /*
12      * For paravirt, this is coupled with an exit in switch_to to
13      * combine the page table reload and the switch backend into
14      * one hypercall.
15      */
16     arch_start_context_switch(prev);
17 
18     if (!mm) {
19         /* 如果新進程的內存描述符為空,說明新進程為內核線程 */
20         next->active_mm = oldmm;
21         atomic_inc(&oldmm->mm_count);
22         /* 通知底層不需要切換虛擬地址空間
23          *     if (this_cpu_read(cpu_tlbstate.state) == TLBSTATE_OK)
24          *        this_cpu_write(cpu_tlbstate.state, TLBSTATE_LAZY);
25          */
26         enter_lazy_tlb(oldmm, next);
27     } else
28         /* 切換虛擬地址空間 */
29         switch_mm(oldmm, mm, next);
30 
31     if (!prev->mm) {
32         /* 如果被切換出去的進程是內核線程 */
33         prev->active_mm = NULL;
34         /* 歸還借用的oldmm  */
35         rq->prev_mm = oldmm;
36     }
37     /*
38      * Since the runqueue lock will be released by the next
39      * task (which is an invalid locking op but in the case
40      * of the scheduler it's an obvious special-case), so we
41      * do an early lockdep release here:
42      */
43     spin_release(&rq->lock.dep_map, 1, _THIS_IP_);
44 
45     context_tracking_task_switch(prev, next);
46     
47     /* 切換寄存器和內核棧,還會重新設置current為切換進去的進程 */
48     switch_to(prev, next, prev);
49 
50     /* 同步 */
51     barrier();
52     /*
53      * this_rq must be evaluated again because prev may have moved
54      * CPUs since it called schedule(), thus the 'rq' on its stack
55      * frame will be invalid.
56      */
57     finish_task_switch(this_rq(), prev);
58 }

  到這里整個進程的選擇和切換就已經完成了。

 

總結

  整個調度器大概原理和源碼已經分析完成,其他更多細節,如CFS的一些計算和處理,實時進程的處理等,將在其他文章進行詳細解釋。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM