xenomai訊號
上篇文章講了linux的訊號在核心的傳送與處理流程,現在加入了cobalt核,Cobalt核心為xenomai執行緒提供了訊號機制。下面一一解析xenomai核心的訊號處理機制。
1 雙核下的訊號分類
我們已經知道,每個使用者空間的xenomai執行緒在核心空間都有兩個排程實體,一是在linux核心中的task_struct,另一個是稱為linux空間的一個影子(shadow)的xnthread。它們表示的是同一個執行緒,linux排程的是task_struct,實時核cobalt排程的是xnthread。可通過在兩個排程器間遷移的方式來讓xenomai執行緒在linux核和Cobalt核上執行。
linux程式與執行緒的訊號就讓人頭疼的了,再來一個xenomai訊號豈不更復雜?其實不復雜,只需要分清三種訊號及其作用域就OK,如圖所示,程式A內有兩個實時執行緒和一個普通執行緒,程式B內有一個實時執行緒和一個普通執行緒,它們之間的訊號分為兩類:
- 使用linux訊號機制:程式間訊號、linux執行緒間訊號;
- 使用xenomai訊號機制:只存在於xenomai執行緒間,xenomai內的任何訊號都不會傳播到linux程式空間,也不會導致程式退出;
由於一個xenomai執行緒它既是linux任務也是cobalt任務,不同的訊號產生和處理在不同的核心中。對於linux訊號,由linux排程器(linux核心)傳送和接收處理;xenomai訊號由xenomai執行緒通過cobalt核心介面傳送或接收,可用於同步互斥(可與訊號量semaphore對比),對於一個xenomai應用中的linux訊號:
- xenomai執行緒通過
__STD(kill/pthread_kill)
通過linux傳送的訊號,呼叫時會自動遷移到linux核心(root域)再傳送;(__STD()
巨集表示顯式呼叫Linux標準庫函式) - xenomai執行緒接收linux訊號處理時也是一樣,必須遷移到linux核心才能處理;
對於linux向xenomai傳送訊號,例如我們終端中啟動一個xenomai任務後,通過鍵入ctrl+c結束xenomai任務的操作,linux在查詢處理該訊號的任務時,如果需要處理訊號的是一個實時任務,會把xenomai任務遷移到linux核上,再按linux的處理那套流程去處理就行。下面們看它是怎麼處理的,與上一篇文章linux下的訊號處理流程對比,其中其中不一樣的步驟如下,其餘的與linux處理方式一致。
- linux程式或者 shell 傳送一個訊號給xenomai執行緒A,可以呼叫 kill,tkill,tgkill,rt_sigqueueinfo
- 四個傳送訊號的函式,在核心中最終都是呼叫 do_send_sig_info
do_send_sig_info
呼叫send_signal
給xenomai任務A 傳送一個訊號,其實就是找到 A 的task_struct
,不可靠訊號加入訊號集合,可靠訊號,加入訊號連結串列。然後呼叫complete_signal()
處理訊號。complete_signal()
呼叫signal_wake_up()->signal_wake_up_state()
喚醒A。
雙核下,xenomai在signal_wake_up_state函式中插入了檢測程式碼如下。
void signal_wake_up_state(struct task_struct *t, unsigned int state)
{
set_tsk_thread_flag(t, TIF_SIGPENDING);
/* TIF_SIGPENDING must be prior to reporting.TIF_SIGPENDING */
__ipipe_report_sigwake(t);
if (!wake_up_state(t, state | TASK_INTERRUPTIBLE))
kick_process(t);
}
插入程式碼__ipipe_report_sigwake(t)
,__ipipe_report_sigwake()
呼叫__ipipe_notify_kevent()
發出一個核心間訊號事件IPIPE_KEVT_SIGWAKE
,__ipipe_notify_kevent
呼叫Cobalt核心的ipipe_kevent_hook來接收這些事件。
int ipipe_kevent_hook(int kevent, void *data)
{
int ret;
switch (kevent) {
case IPIPE_KEVT_SCHEDULE:
ret = handle_schedule_event(data);/**/
break;
case IPIPE_KEVT_SIGWAKE:
ret = handle_sigwake_event(data);///IPIPE_KEVT_SIGWAKE
break;
......
default:
ret = KEVENT_PROPAGATE;
}
return ret;
}
ipipe_kevent_hook中根據事件型別執行handle_sigwake_event。
static int handle_sigwake_event(struct task_struct *p)
{
struct xnthread *thread;
sigset_t pending;
spl_t s;
thread = xnthread_from_task(p);
......
xnlock_get_irqsave(&nklock, s);
......
if (xnthread_test_state(thread, XNRELAX)) {
xnlock_put_irqrestore(&nklock, s);
return KEVENT_PROPAGATE;
}
......
if (p->state & (TASK_INTERRUPTIBLE|TASK_UNINTERRUPTIBLE))
cobalt_set_task_state(p, p->state | TASK_NOWAKEUP);
__xnthread_kick(thread);
xnsched_run();
xnlock_put_irqrestore(&nklock, s);
return KEVENT_PROPAGATE;
}
handle_sigwake_event()
中的邏輯很簡單,先看A是執行在root域還是haed域,如果本來就在root域(處於XNRELAX狀態),即在linux核上排程,那麼不用做什麼操作,可直接處理訊號;如果A現在是head域排程,先看看它是不是可中斷睡眠狀態(TASK_INTERRUPTIBLE|TASK_UNINTERRUPTIBLE),然後呼叫__xnthread_kick()
將任務A踢出haed域。最後呼叫xnsched_run
將CPU讓給linux排程器以儘快喚醒任務A進行訊號處理。後面的處理與linux一致。
對於xenomai向linux傳送訊號,需要在xenomai任務程式碼內顯性呼叫函式kill()或pthread_kill()
傳送。且必須通過__STD()
修飾kill()
函式,編譯時才會直接使用glibc的kill函式,對pthread_kill也是一樣。不加修飾的kill()或pthread_kill()函式會在編譯時預設連結到libcobalt定義的函式。
COBALT_IMPL(int, kill, (pid_t pid, int sig))
{
int ret;
if (pid <= 0)
return __STD(kill(pid, sig));
ret = XENOMAI_SYSCALL2(sc_cobalt_kill, pid, sig);
if (ret) {
if (ret == -ESRCH)
return __STD(kill(pid, sig));
....
}
return 0;
}
COBALT_IMPL(int, pthread_kill, (pthread_t thread, int sig))
{
int ret;
ret = -XENOMAI_SYSCALL2(sc_cobalt_thread_kill, thread, sig);
if (ret == ESRCH)
return __STD(pthread_kill(thread, sig));
return ret;
}
兩個函式都是先嚐試讓xenomai核心處理,在xenomai核心最終都會呼叫__cobalt_kill()
;如果該pid不是xenomai執行緒,才會轉而呼叫glibc的kill函式,通過linux核心處理。
總之,使用linux訊號的操作,不管是實時還是非實時都必須在linux排程器上執行才能完成操作。
2 xenomai訊號
xenomai執行緒間的訊號處理機制由xenomai核心實現,與linux執行緒訊號類似,但沒有linux執行緒訊號那麼複雜,它。
既然是xenomai執行緒間的,那就要類似的像linux那樣實現xenomai核心的一套訊號管理機制。首先是每個xenomai執行緒的核心管理結構cobalt_thread裡面關於訊號處理的欄位。
struct cobalt_process {
......
struct list_head sigwaiters;
......
};
struct cobalt_thread {
......
struct xnthread threadbase;
struct cobalt_process *process;
......
/** Signal management. */
sigset_t sigpending;
struct list_head sigqueues[_NSIG]; /* in cobalt_sigpending */
struct xnsynch sigwait;
struct list_head signext;
......
};
sigpending
表示哪些訊號尚等待處理(未決),這裡只是表示某個訊號待處理,該訊號具體有多少個需要看sigqueues[]
;sigqueues[]
訊號佇列,它的大小是_NSIG,也就是說每個訊號都有個佇列。sigwait
一個資源同步物件(xnsynch)表示,我們在13.2 資源同步物件—xnsynch小節解析了xnsynch是幹什麼用的,訊號也是一種資源,所以這裡用來等待一個訊號資源,當使用者呼叫sigwait系統呼叫等待一個訊號的時候,就會在sigwait
上睡眠等待訊號。signext
在sigwait時用來加入cobalt_process中的sigwaiters連結串列。
xenomai的訊號與linux一致,1-31號訊號不支援排隊,31-64號訊號支援排隊。
需要注意的是xenomai內的訊號除與linux一致外還有幾個特有的訊號,這些訊號是不公開的,僅存在於xenomai核心與libcobalt內部;這些訊號無法遮蔽,不能進行排隊,也無法將它們設定為訊號集。
/*\arch\x86\include\uapi\asm\signal.h*/
#define SIGRTMIN 32
#define SIGRTMAX _NSIG
/*\include\xenomai\cobalt\uapi\signal.h*/
#define SIGSUSP (SIGRTMAX + 1)
#define SIGRESM (SIGRTMAX + 2)
#define SIGRELS (SIGRTMAX + 3)
#define SIGKICK (SIGRTMAX + 4)
#define SIGDEMT (SIGRTMAX + 5)
SIGSUSP
對指定執行緒suspend操作;SIGRESM
對指定執行緒resume操作;SIGRELS
對指定執行緒解阻塞;SIGKICK
迫使指定執行緒退出主模式;SIGDEMT
將指定執行緒降級為非實時執行緒,該影子執行緒仍可訪問Xenomai資源,但不再競爭實時排程。
xenomai訊號相關介面,由libcobalt實現。如下
int sigwaitinfo(const sigset_t *set, siginfo_t *si);
int sigwait(const sigset_t *set, int *sig);
int sigtimedwait (const sigset_t *set, siginfo_t *si,
const struct timespec *timeout);
int sigpending(sigset_t *set);
int kill(pid_t pid, int sig);
int sigqueue(pid_t pid, int sig, const union sigval value);
int pthread_kill(pthread_t thread, int sig);
2.1 xenomai傳送訊號
kill、pthread_kill用於傳送xenomai訊號,注意:不使用__STD()
巨集修飾的posix函式預設連結到實時核心庫libcobalt。libcobalt中定義如下:
/*lib\cobalt\signal.c*/
COBALT_IMPL(int, kill, (pid_t pid, int sig))
{
int ret;
if (pid <= 0)
return __STD(kill(pid, sig));
ret = XENOMAI_SYSCALL2(sc_cobalt_kill, pid, sig);
if (ret) {
if (ret == -ESRCH)
return __STD(kill(pid, sig));
....
}
return 0;
}
/*lib\cobalt\thread.c*/
COBALT_IMPL(int, pthread_kill, (pthread_t thread, int sig))
{
int ret;
ret = -XENOMAI_SYSCALL2(sc_cobalt_thread_kill, thread, sig);
if (ret == ESRCH)
return __STD(pthread_kill(thread, sig));
return ret;
}
先通過系統呼叫讓xenomai核心處理,每個使用者執行緒在核心都是一個任務,都具有pid,不管是pid還是pthread_t,最終都會轉換為xenomai執行緒核心結構cobalt_thread,再對cobalt_thread進行訊號相關操作。如果不能轉換說明該pid或pthread_t表示的執行緒不是一個xenomai執行緒,就會返回ESRCH。轉而呼叫glibc的pthread_kill、kill函式進而讓linux去處理。
/*\kernel\xenomai\posix\thread.c*/
COBALT_SYSCALL(thread_kill, conforming,
(unsigned long pth, int sig))
{
struct cobalt_local_hkey hkey;
struct cobalt_thread *thread;
int ret;
spl_t s;
......
hkey.u_pth = pth;
hkey.mm = current->mm;
thread = thread_lookup(&hkey);
if (thread == NULL)
ret = -ESRCH;
else
ret = __cobalt_kill(thread, sig, 0);
.....
return ret;
}
/*\kernel\xenomai\posix\signal.c*/
COBALT_SYSCALL(kill, conforming, (pid_t pid, int sig))
{
struct cobalt_thread *thread;
int ret;
spl_t s;
thread = cobalt_thread_find(pid);/*找到執行緒*/
if (thread == NULL)
ret = -ESRCH;
else
ret = __cobalt_kill(thread, sig, 1);
return ret;
}
sc_cobalt_kill
系統呼叫,則是通過pid來找到對應的cobalt_thread,然後呼叫__cobalt_kill()
。sc_cobalt_thread_kill
系統呼叫內,將pthread_t作為hashkey,找到該執行緒的cobalt_thread,最終呼叫__cobalt_kill()
。
不同的是呼叫__cobalt_kill
的第三個引數。sc_cobalt_kill
系統呼叫傳入的是1,表示給執行緒組傳送訊號,當thread指向的那個執行緒沒有等待任何訊號時會嘗試傳送給同一執行緒組其他等待該訊號的執行緒;sc_cobalt_thread_kill
系統呼叫內傳入的是0.當thread指向的那個執行緒沒有等待任何訊號時就不做任何操作直接返回。
int __cobalt_kill(struct cobalt_thread *thread, int sig, int group) /* nklocked, IRQs off */
{
struct cobalt_sigpending *sigp;
int ret = 0;
switch(sig) {
case 0:
/* Check for existence only. */
break;
case SIGSUSP:
xnthread_suspend(&thread->threadbase, XNSUSP,
XN_INFINITE, XN_RELATIVE, NULL);
if (&thread->threadbase == xnthread_current() &&
xnthread_test_info(&thread->threadbase, XNBREAK))
ret = -EINTR;
break;
case SIGRESM:
xnthread_resume(&thread->threadbase, XNSUSP);
goto resched;
case SIGRELS:
xnthread_unblock(&thread->threadbase);
goto resched;
case SIGKICK:
xnthread_kick(&thread->threadbase);
goto resched;
case SIGDEMT:
xnthread_demote(&thread->threadbase);
goto resched;
case 1 ... _NSIG:
sigp = cobalt_signal_alloc(); /*分配一個訊號結構體*/
if (sigp) {
sigp->si.si_signo = sig;
sigp->si.si_errno = 0;
sigp->si.si_code = SI_USER;
sigp->si.si_pid = task_pid_nr(current);
sigp->si.si_uid = get_current_uuid();
if (cobalt_signal_send(thread, sigp, group) <= 0)
cobalt_signal_free(sigp);
}
resched:
xnsched_run();
break;
default:
ret = -EINVAL;
}
return ret;
}
xenomai核心中POSIX訊號支援排隊,先分配一個cobalt_sigpending。直接分配結構體大小的記憶體是不可取的,這會影響實時性,xenomai採取的辦法是和xnobject類似,不通過動態記憶體分配,核心初始化的時候就申請好_NSIG + (SIGRTMAX - SIGRTMIN) * 2
個cobalt_sigpending的記憶體sigpending_mem,然後將這一個個cobalt_sigpending穿到連結串列sigpending_pool。cobalt_signal_alloc()就是直接從連結串列sigpending_pool上取一個就可以,cobalt_signal_free釋放時再加入連結串列sigpending_pool。
__SIGPOOL_SIZE
大小,為在kernel\xenomai\posix\signal.c
定義如下:
#define __SIGPOOL_SIZE (sizeof(struct cobalt_sigpending) * \
(_NSIG + (SIGRTMAX - SIGRTMIN) * 2))
注意:這說明整個xenomai系統內只有84個cobalt_sigpending,這意味如果濫用xenomai訊號,或隨意向其他xenomai執行緒傳送>31號的訊號,而這個執行緒沒有呼叫sigwait的操作,那麼這些cobalt_sigpending會被永遠的排隊在這個執行緒上,直到執行緒A退出才會被釋放。這會導致cobalt_sigpending枯竭,進而影響其他使用訊號的程式無法正常工作。
上面的cobalt_signal_alloc()是不是BUG?沒有cobalt_signal_alloc()分配失敗的處理邏輯。這樣應用無法知道是否傳送成功了。設定cobalt_sigpending的訊號編號si.si_signo,訊號型別si.si_code,傳送者是誰si.si_pid,傳送者的uid多少si.si_uid。使用cobalt_signal_send進行傳送。
下面看傳送函式cobalt_signal_send。
int cobalt_signal_send(struct cobalt_thread *thread,
struct cobalt_sigpending *sigp,
int group)
{ /* nklocked, IRQs off */
struct list_head *sigq;
int sig, ret;
/* Can we deliver this signal immediately?*/
ret = cobalt_signal_deliver(thread, sigp, group);
if (ret)
return ret; /* Yep, done. */
......
sig = sigp->si.si_signo;
sigq = thread->sigqueues + sig - 1;
if (!list_empty(sigq)) {
/* Queue non-rt signals only once. */
if (sig < SIGRTMIN)
return 0;
/* Queue rt signal source only once (SI_TIMER). */
if (!list_empty(&sigp->next))
return 0;
}
sigaddset(&thread->sigpending, sig);
list_add_tail(&sigp->next, sigq);
return 1;
}
我們傳送訊號給的這個執行緒,有可能正在阻塞等待訊號,先呼叫cobalt_signal_deliver看它是不是在等待,是的話直接就遞送(deliver)了;
如果不能及時遞送,再將這個訊號掛起,以下兩類訊號不支援排隊:1.對於小於SIGRTMIN(32)的訊號,不支援排隊,只掛起一次。2.如果傳送來的sigp是多個SI_TIMER訊號(定時器到期訊號),也只排隊一次(與linux處理方式一致,應該是posix標準)。排隊就是將這個cobalt_sigpending插入thread->sigqueues[signo-1]連結串列尾。
排隊的訊號是不會被核心處理的,直到用呼叫sigwaitinfo、sigwait、sigtimedwait來消耗他們。
static int cobalt_signal_deliver(struct cobalt_thread *thread,
struct cobalt_sigpending *sigp,
int group)
{ /* nklocked, IRQs off */
struct cobalt_sigwait_context *swc;
struct xnthread_wait_context *wc;
int sig, ret;
sig = sigp->si.si_signo;
XENO_BUG_ON(COBALT, sig < 1 || sig > _NSIG);
if (xnsynch_pended_p(&thread->sigwait)) {
wc = xnthread_get_wait_context(&thread->threadbase);
swc = container_of(wc, struct cobalt_sigwait_context, wc);
if (sigismember(swc->set, sig))
goto deliver;
}
/*
* If that does not work out and we are sending to a thread
* group, try to deliver to any thread from the same process
* waiting for that signal.
*/
if (!group || list_empty(&thread->process->sigwaiters))
return 0;
list_for_each_entry(thread, &thread->process->sigwaiters, signext) {
wc = xnthread_get_wait_context(&thread->threadbase);
swc = container_of(wc, struct cobalt_sigwait_context, wc);
if (sigismember(swc->set, sig))
goto deliver;
}
return 0;
deliver:
cobalt_copy_siginfo(sigp->si.si_code, swc->si, &sigp->si);
.....
xnthread_complete_wait(&swc->wc);
xnsynch_wakeup_one_sleeper(&thread->sigwait); /*喚醒執行緒*/
list_del(&thread->signext);
cobalt_signal_free(sigp);
return 1;
}
- 先看這個執行緒是否正在等待訊號,並且等待的訊號集中包含我們傳送的這個訊號,就直接遞送
- 否則的話看這個訊號是通過kill還是pthread_kill傳送的,如果是kill(group 等於1)就在看看執行緒組內有沒有其他下執行緒等待這個訊號。否則的話遞送不成功返回0,回到cobalt_signal_send將這個訊號掛起排隊。
- 遞送過程很簡單,xenomai執行緒等待訊號的時候不是阻塞在sigwait上嗎,直接喚醒它,告訴它哪個訊號來了就行了,接著釋放傳送時分配的cobalt_sigpending。
2.2 xenomai接收處理訊號
執行緒呼叫sigwaitinfo()
、sigtimedwait()
和sigwait()
來接收訊號,都是posix標準,作用與linux執行緒一致。同樣,編譯時會連結到libcobalt,再由libcobalt發起系統呼叫。
COBALT_IMPL(int, sigwait, (const sigset_t *set, int *sig))
{
int ret, oldtype;
pthread_setcanceltype(PTHREAD_CANCEL_ASYNCHRONOUS, &oldtype);
ret = -XENOMAI_SYSCALL2(sc_cobalt_sigwait, set, sig);
pthread_setcanceltype(oldtype, NULL);
return ret;
}
COBALT_IMPL(int, sigwaitinfo, (const sigset_t *set, siginfo_t *si))
{
int ret, oldtype;
pthread_setcanceltype(PTHREAD_CANCEL_ASYNCHRONOUS, &oldtype);
ret = XENOMAI_SYSCALL2(sc_cobalt_sigwaitinfo, set, si);
.....
pthread_setcanceltype(oldtype, NULL);
return ret;
}
COBALT_IMPL(int, sigtimedwait, (const sigset_t *set, siginfo_t *si,
const struct timespec *timeout))
{
int ret, oldtype;
pthread_setcanceltype(PTHREAD_CANCEL_ASYNCHRONOUS, &oldtype);
ret = XENOMAI_SYSCALL3(sc_cobalt_sigtimedwait, set, si, timeout);
......
pthread_setcanceltype(oldtype, NULL);
return ret;
}
發起系統呼叫進入xenomai核心後最終都是執行signal_wait()。
static int signal_wait(sigset_t *set, xnticks_t timeout,
void __user *u_si,
int (*put_siginfo)(void __user *u_si,
const struct siginfo *si,
int overrun))
{
struct cobalt_sigpending *sigp = NULL;
struct cobalt_sigwait_context swc;
struct cobalt_thread *curr;
int ret, sig, n, overrun;
unsigned long *p, *t, m;
struct siginfo si, *sip;
struct list_head *sigq;
spl_t s;
curr = cobalt_current_thread();
check:
if (sigisemptyset(&curr->sigpending))
goto wait;
p = curr->sigpending.sig; /* pending */
t = set->sig; /* tested */
for (n = 0, sig = 0; n < _NSIG_WORDS; ++n) {
m = *p++ & *t++;
if (m == 0)
continue;
sig = ffz(~m) + n *_NSIG_BPW + 1;
break;
}
if (sig) {
sigq = curr->sigqueues + sig - 1;
......
sigp = list_get_entry(sigq, struct cobalt_sigpending, next);
INIT_LIST_HEAD(&sigp->next); /* Mark sigp as unlinked. */
if (list_empty(sigq))
sigdelset(&curr->sigpending, sig);
sip = &sigp->si;
ret = 0;
goto done;
}
wait:
if (timeout == XN_NONBLOCK) {
ret = -EAGAIN;
goto fail;
}
swc.set = set;
swc.si = &si;
xnthread_prepare_wait(&swc.wc);
list_add_tail(&curr->signext, &curr->process->sigwaiters);
ret = xnsynch_sleep_on(&curr->sigwait, timeout, XN_RELATIVE);
.......
sig = si.si_signo;
sip = &si;
done:
switch (sip->si_code) {
case SI_TIMER:
overrun = cobalt_timer_deliver(sip->si_tid);
break;
case SI_USER:
case SI_MESGQ:
case SI_QUEUE:
overrun = 0;
break;
default:
overrun = sip->si_overrun;
if (overrun)
sip->si_overrun = 0;
}
if (u_si == NULL)
goto out; /* Return signo only. */
ret = put_siginfo(u_si, sip, overrun);// signal_put_siginfo
if (ret)
goto out;
......
out:
.....
if (sigp &&
(void *)sigp >= sigpending_mem &&
(void *)sigp < sigpending_mem + __SIGPOOL_SIZE) {
xnlock_get_irqsave(&nklock, s);
list_add_tail(&sigp->next, &sigpending_pool);
xnlock_put_irqrestore(&nklock, s);
/* no more ref. to sigp beyond this point. */
}
return ret ?: sig;
fail:
return ret;
}
- 先檢查curr->sigpending 是否有未決的訊號,如果有的話就直接跳轉到標籤done處理。
- 否則往下進入wait標籤,睡眠到curr->sigwait。直到超時或等待的訊號到來才會繼續往下執行done操作。
- done中處理一下timer超期訊號,如果需要拷貝sifinfo,則呼叫put_siginfo拷貝一下。
- 執行out操作釋放等待到的cobalt_sigpending。
3 雙核訊號總結
-
兩種訊號:xenomai訊號和linux訊號。
-
理清雙核下的訊號需要分清:Linux程式與執行緒、xenomai執行緒三者之間的關係及作用域。
-
linux的程式與執行緒都有訊號遮蔽集,xenomai訊號則沒有。
-
雙核應用程式碼中使用函式傳送linux訊號時,最好使用
__STD()
修飾訊號相關函式。 -
如果不顯式呼叫介面
sigwaitinfo()
、sigtimedwait()
和sigwait()
來接收訊號處理xenomai訊號,那麼永遠不會得到處理。所以不能濫用xenomai訊號,因為訊號有限。 -
xenomai核心將訊號作為一種同步資源(xnsynch)來管理,知道這一點,並理解了13.2 資源同步物件—xnsynch工作原理,就知道xenomai訊號如何工作。
本文為本文為博主原創文章,轉載請註明出處。如有錯誤,歡迎指正。部落格地址:https://www.cnblogs.com/wsg1100/