Try   HackMD

2020q1 Homework1 (lab0)

contributed by < rwe0214 >

tags: Linux, rwe0214, NCKU

作業說明

Homework1 (lab0)

開發過程

在實作過程發現,如果想在 q_insert_tail 達到

O(1) 的時間,不在 queue_t structure 中新增其他的 field 去紀錄額外資訊是很難達到的,所以新增了一個 list_ele_t *tail 指標記錄尾部的位址。

q_size

queue 為空的時候回傳 zero ,其他狀況回傳 queue size,使用 !! 可以確保 queue 不為 NULL 的時候,條件判斷式的值為 1。

int q_size(queue_t *q)
{
    return !!q ? q->size : 0;
}

q_new

需要處理 malloc 失敗的情形。

queue_t *q_new() { queue_t *q = malloc(sizeof(queue_t)); if (!q) return NULL; q->head = NULL; q->tail = NULL; q->size = 0; return q; }

q_free

queue 為空無動作。

void q_free(queue_t *q)
{
    if (!q)
        return;
    for (list_ele_t *del = q->head; q->head; del = q->head) {
        q->head = del->next;
        free(del->value);
        free(del);
    }
    free(q);
}

q_insert_head

一開始在 strncpy 複製字串時使用 sizeof(s) 作為字串大小,後來發現這僅僅會回傳 char * 的大小,而不是字串真正的記憶體大小,另外為了防止字串無法終止,所以額外多一個位元組 (byte) 存放 null terminator

bool q_insert_head(queue_t *q, char *s)
{
    if (!q)
        return false;

    list_ele_t *newh;

    newh = malloc(sizeof(list_ele_t));
    if (!newh)
        return false;
    newh->value = malloc(strlen(s) * sizeof(char) + 1);
    if (!newh->value) {
        free(newh);
        return false;
    }

    newh->next = q->head;
    strncpy(newh->value, s, strlen(s) * sizeof(char));
    (newh->value)[strlen(s) * sizeof(char)] = '\0';
    q->head = newh;
    if (q->size++ == 0)
        q->tail = newh;

    return true;
}

q_insert_tail

在沒有在 queue_t 中新增 tail pointer 前,一直無法在時間複雜度上有所提升,皆為

O(n),新增後可達
O(1)

bool q_insert_tail(queue_t *q, char *s) { if (!q) return false; list_ele_t *newt = malloc(sizeof(list_ele_t)); if (!newt) return false; newt->value = malloc(strlen(s) * sizeof(char) + 1); if (!newt->value) { free(newt); return false; } newt->next = NULL; strncpy(newt->value, s, strlen(s) * sizeof(char) + 1); (newt->value)[strlen(s) * sizeof(char)] = '\0'; /* wrong section is below*/ q->tail->next = newt; q->tail = newt; if (q->size++ == 0) q->head = newt; return true; }

但是上述在跑 complexity test 時會不正常結束,使用 valgrind 分析後會發生 Invalid write of size 8,發現是因為原本的程式碼在新增流程中出錯,

q->tail->next = newt; q->tail = newt; if (q->size++ == 0) q->head = newt; return true;

更改為以下:

if (q->size++ == 0) q->head = newt; else q->tail->next = newt; q->tail = newt; return true;

q_remove_head

依照註解完成,目前還沒搞懂為什麼需要 char *spsize_t bufsize,推測是和實作 q_reverse 有關。

程式碼是提供給你修改用,而非鑑賞的,有疑慮就去做實驗

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
jserv

bool q_remove_head(queue_t *q, char *sp, size_t bufsize)
{
    if (!q || !q->head)
        return false;

    list_ele_t *tmp = q->head;
    q->head = q->head->next;
    if (!!sp) {
        strncpy(sp, tmp->value, bufsize - 1);
        sp[bufsize - 1] = '\0';
    }

    q->size--;
    free(tmp->value);
    free(tmp);

    return true;
}

q_reverse

在實作這個的時候,因為提示的註解暗示可以使用 q_insert_head, q_insert_tail, 和q_remove_head 來完成,但是目前我對如何使用他們還沒有頭緒,所以先暫時實作一個利用 pointer 暫存前後 elements 的方式來完成

void q_reverse(queue_t *q)
{
    if (!q || !q->head)
        return;

    list_ele_t *iter, *nexti, *prei = NULL;
    q->tail = q->head;
    for (iter = q->head; iter->next; iter = nexti) {
        nexti = iter->next;
        iter->next = prei;
        prei = iter;
    }
    iter->next = prei;
    q->head = iter;
}

q_sort

一開始的想法是單純使用隨堂考 quiz1 的排序方法,並把它改成 iterative 的版本,但發現執行時間一樣逾時。

不難發現 quiz1 提供的程式碼存在嚴重缺陷,並非典型的 merge sort,特別在時間複雜度的表現上

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
jserv

分析時間複雜度發現,原本以為 quiz1 是一般的 merge sort,但他的 divide and conquer 並不是切成對等的兩個子問題,說明如下:

  • merge sort:
    T(n)=2T(n2)+n=O(nlogn)
  • quiz1:
    T(n)=T(n1)+n=O(n2)

所以重新撰寫一個針對 linked list 的 merge sort 實作: q_sort

void q_sort(queue_t *q)
{
    if (!q || q->size <= 1)
        return;
    sort(&q->head, q->size, &q->tail);
}

void merge(list_ele_t **llist_head,
           list_ele_t **llist_tail,
           list_ele_t **rlist_head,
           list_ele_t **rlist_tail)
{
    list_ele_t *tmp = NULL;

    if (strcmp((*llist_head)->value, (*rlist_head)->value) > 0) {
        tmp = *llist_head;
        *llist_head = *rlist_head;
        *rlist_head = tmp;
        tmp = *llist_tail;
        *llist_tail = *rlist_tail;
        *rlist_tail = tmp;
    }

    list_ele_t *l_head = *llist_head, *l_tail = *llist_tail;
    list_ele_t *r_head = *rlist_head;
    list_ele_t *r_end_next = (*rlist_tail)->next;
    while (l_head != l_tail && r_head != r_end_next) {
        if (strcmp(l_head->next->value, r_head->value) > 0) {
            tmp = r_head->next;
            r_head->next = l_head->next;
            l_head->next = r_head;
            r_head = tmp;
        }
        l_head = l_head->next;
    }
    /* if rlist is empty but llist in not,
    all of rlist nodes are alreay inserted into llist */
    if (l_head == l_tail)
        // In this case the tail of sorted list is rlist_tail
        l_head->next = r_head;
    else
        // In this case the tail of sorted list is llist_tail
        *rlist_tail = *llist_tail;
}

void sort(list_ele_t **start, int n, list_ele_t **end)
{
    list_ele_t *prev_tail = NULL;
    for (int cmp_t = 1; cmp_t < n; cmp_t *= 2) {
        list_ele_t *iter;
        for (iter = *start; iter;) {
            list_ele_t *llist_head = iter, *llist_tail = iter;
            list_ele_t *rlist_head, *rlist_tail;
            int is_first = (llist_head == *start) ? 1 : 0;

            for (int i = 1; i < cmp_t && llist_tail->next;
                 i++, llist_tail = llist_tail->next)
                ;
            rlist_head = llist_tail->next;
            if (!rlist_head)
                break;
            rlist_tail = rlist_head;
            for (int i = 1; i < cmp_t && rlist_tail->next;
                 i++, rlist_tail = rlist_tail->next)
                ;

            list_ele_t *tmp = rlist_tail->next;

            merge(&llist_head, &llist_tail, &rlist_head, &rlist_tail);
            if (is_first)
                *start = llist_head;
            else
                prev_tail->next = llist_head;
            prev_tail = rlist_tail;
            iter = tmp;
        }
        /*NEED TO INSURE THAT
        prev_tail ALWAYS POINT TO THE TAIL OF WHOLE LIST!*/
        prev_tail->next = iter;
    }
}

TODO:

  1. 思考更精簡的 merge sort 實作;
  2. 用 perf 一類的工具找出執行時間的熱點 (hot spot, 往往是效能的瓶頸);

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
jserv

解釋 select 系統呼叫在本程式的使用方式,並分析 console.c 的實作,說明其中運用 CS:APP RIO 套件 的原理和考量點

我把 CSAPP 中有談到 RIO 的部分整理在 CSAPP Ch10 筆記

  • console.c/cmd_select 中利用 select 去監視 readfd set 中是否有哪個 readfd 是已準備好資料進行讀取的,程式碼片段如下,
int cmd_select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout) { ... /* Add input fd to readset for select */ infd = buf_stack->fd; FD_SET(infd, readfds); int result = select(nfds, readfds, writefds, exceptfds, timeout); if (result <= 0) return result; infd = buf_stack->fd; if (readfds && FD_ISSET(infd, readfds)) { /* Commandline input available */ FD_CLR(infd, readfds); result--; cmdline = readline(); //cmdline = linenoise(""); if (cmdline) interpret_cmd(cmdline); } return result; }

可以看到在

int result = select(nfds, readfds, writefds, exceptfds, timeout);

去對已經加入 readset 的 fd 去做監視,並在有準備好資料輸入的情況下 ( after typing <ENTER>),進入下面的迴圈進行

cmdline = readline();

而在 readline 的實作中,就運用了 RIO package 的原理和方法,
可參考 CSAPP Ch10 筆記 buffered I/O function

TODO: 除了研讀程式碼,能否修改程式碼並搭配 GDB 進行實驗?

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
jserv

挑戰題

說明 antirez/linenoise 運作原理

此 api 透過

char *linenoise(const char *prompt);

作為呼叫入口,之後依照 terminal 輸入環境區分成三個運作方式

  1. tty
    呼叫流程為

    TODO: HackMD 支援 GraphViz 語法,請改寫以下示意圖。

    Image Not Showing Possible Reasons
    • The image file may be corrupted
    • The server hosting the image is unavailable
    • The image path is incorrect
    • The image format is not supported
    Learn More →
    jserv

    ​​​​linenoiseRaw → enableRawMode → linenoiseEdit
    ​​​​               ^               ^
    ​​​​               |               |
    ​​​​    Setting termios of STDIN   |
    ​​​​                               |
    ​​​​                               |
    ​​​​    在此執行額外功能( e.g. History, Completion, Hints... )
    
    enalbeRawMode 中修改 STDIN 的設定,使其能在 raw mode 下輸入,

終端機介面又稱 tty 介面,分成兩種模式,一種為正規模式 ( cooked mode ) ,另一種是非正規模式 ( raw )

  • cooked mode

    • 在這種模式中,終端設備會處理特殊字元,且會以一次一列的方式將輸入傳給應用程式。例如 Linuxshell 指令。
  • raw mode

    • 在這種模式中,終端設備不會處理特殊字元,且會以一次一個字元的方式將輸入傳給應用程式。例如在 Linux 使用 vim 編輯程式。

    termio 可以透過 termios 來設定 ( 如下 )

    ​​​​int tcgetattr(int fd, struct termios *termios_p);
    ​​​​int tcsetattr(int fd, int optional_actions,
    ​​​​                 const struct termios *termios_p);
    

    透過 bits 的調整來達到設定

    這部分讓我聯想到之前在 Microchips 課堂中也是透過 bit 來控制 Microchip 的行為

    ​​​​/* 省略在 linenoise 中的例外錯誤處理 */ ​​​​tcgetattr(fd,&orig_termios); ​​​​raw = orig_termios; ​​​​raw.c_iflag &= ~(BRKINT | ICRNL | INPCK | ISTRIP | IXON); ​​​​raw.c_oflag &= ~(OPOST); ​​​​raw.c_cflag |= (CS8); ​​​​raw.c_lflag &= ~(ECHO | ICANON | IEXTEN | ISIG); ​​​​raw.c_cc[VMIN] = 1; raw.c_cc[VTIME] = 0; ​​​​tcsetattr(fd,TCSAFLUSH,&raw);

tcsetattr(fd,TCSAFLUSH,&raw);

使用 TCSAFLUSH option 將 fd flush 後設定剛剛的 termio

  1. not a tty
    此模式 read from file / pipe,和前者的差別為不需要限制輸入的 line size
  2. Unsupported tty

在 lab0-c 中整合 antirez/linenoise

實作的想法如下

  • lab0-c 直接使用修改後的 linenoise ( i.e., 捨棄 readline,將 readline 替換成 linenoise ,並修改 linenoise.c 使其符合 lab0-c 的環境 )

上面的想法來自於 linenoise 的 README.md 將他的使用方法講的非常簡當,而 example.c 也是,但是目前卡關中xD
[solved]
Thu, Feb 27, 2020 3:53 PM

命令自動補完 ( Completion )

首先了解在 lab-0c/qtest.c 中是如何處理輸入的,

qtest → ...some init... → run_console → push_file → cmd_done → cmd_select
|<--------------qtest.c-------------...
                                      |<------------console.c-----------...

在進入 console.c 之前,會先對需要的設定進行 initailize ( e.g. 處理 option, queue_init, init_cmd, console_init ),並在 run_console 將 input_file 傳入,

run_console → push_file → cmd_done → cmd_select
              ^                      ^
              |                      |
    將 fd 設定成 STDIN 或是 file       |
                                     |
                            在這裡進行 cmd 的讀取

cmd_select 中利用 select 去監視 readfd set 中是否有哪個 readfd 是已準備好資料進行讀取的,程式碼片段如下,

int cmd_select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout) { ... /* Add input fd to readset for select */ infd = buf_stack->fd; FD_SET(infd, readfds); int result = select(nfds, readfds, writefds, exceptfds, timeout); if (result <= 0) return result; infd = buf_stack->fd; if (readfds && FD_ISSET(infd, readfds)) { /* Commandline input available */ FD_CLR(infd, readfds); result--; cmdline = readline(); //cmdline = linenoise(""); if (cmdline) interpret_cmd(cmdline); } return result; }

嘗試將20行替換成以下,目標是先在 STDIN 的輸入下,單純將讀取方式更改為 linenoise

cmdline = linenoise("");

但卻需要重複兩次輸入才能在將 STDIN 寫進 linenoise

cmd> new /* 第一次輸入 */ new /* 第二次輸入 */ q = [] cmd>

進入 GDB 後發現,

(gdb) n
592	        if (infd == STDIN_FILENO && prompt_flag) {
(gdb) n
593	            printf("%s", prompt);
(gdb) n
594	            fflush(stdout);
(gdb) n
cmd> 595	            prompt_flag = true;
(gdb) n
599	            nfds = infd + 1;
(gdb) n
601	    if (nfds == 0)
(gdb) n
604	    int result = select(nfds, readfds, writefds, exceptfds, timeout);
(gdb) n
/* waiting for user input */
...
(gdb) n
612	        result--;
(gdb) n
614	        ln_cmdline = linenoise("", buf_stack->fd);
(gdb) n
/* waiting for user input again*/

在執行到 select 時,select 會如預期的監視 STDIN 有沒有資料輸入,但是輸入後又會再 linenoise 處再停等一次,而不是將 STDIN 上已有的資料讀取。

目前在這裡卡了一陣子,目前的想法是跟 linenoise 會去設定 STDIN 的模式的影響,但是我把 raw mode 關掉後卻只會立即輸出指令

cmd> new /*first input*/ new /*followed up output*/ new /*second input*/ new /*followed up output*/

重新思考 linenoise 的運作模式,是直接對 STDIN 做編輯和修改 ( i.e., 在還沒輸入 <ENTER> 之前即在進行 ),而 select 則是會在輸入 <ENTER> 之後才會認為 STDIN 已經準備好 ( 因為 STDIN 是 line buffered ),而 linenoise.c/enableRawMode 中的

tcsetattr(fd,TCSAFLUSH,&raw);

會將 STDIN flush 掉,所以才會需要重新輸入一次。

但在只使用 STDIN 的情況下,做好在讀取之前的buffer flush ( 這部分 linenoise.c/enableRawMode.c 會幫我們做 ),就可以拿掉 select 後就直接使用 linenoise 了。
但是在讀檔的情況下也能拿掉 select 嗎?如果檔案是一個正在編寫中的文件就無法得知檔案目前是否已經資料準備好,所以仍然需要 select

int cmd_select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout) { ... if (infd != STDIN_FILENO) { /* Not using STDIN as readfd */ int result = select(nfds, readfds, writefds, exceptfds, timeout); if (result <= 0) return result; infd = buf_stack->fd; if (readfds && FD_ISSET(infd, readfds)) { /* Commandline input available */ FD_CLR(infd, readfds); result--; cmdline = readline(); if (cmdline) interpret_cmd(cmdline); } return result; } else { cmdline = linenoise("cmd> "); if (cmdline) { if (cmdline[0] != '\n') { linenoiseHistoryAdd(cmdline); /* Add to the history. */ } interpret_cmd(cmdline); } free(cmdline); return 0; } }

另外在執行時發現 linenoise 不會將 "\n\0" 回傳,但是在 cmd interpret 的時候需要以 "\n\0" 做判斷,再加入

} else { cmdline = linenoise("cmd> "); if (cmdline) { if (cmdline[0] != '\n') { linenoiseHistoryAdd(cmdline); /* Add to the history. */ linenoiseHistorySave("history.txt"); } char *cmdline_terminate = malloc(strlen(cmdline) + 2); strncpy(cmdline_terminate, cmdline, strlen(cmdline)); cmdline_terminate[strlen(cmdline)] = '\n'; cmdline_terminate[strlen(cmdline) + 1] = '\0'; interpret_cmd(cmdline_terminate); free(cmdline_terminate); } free(cmdline); return 0; }

接完後就剩把 linenoise 中的 complete 功能在執行 linenoise 初始化。

先看看 linenoise 怎麼實作 complete 的,可以看到在 completeLine 的實作方式是用一個新的結構體 linenoiseCompletions

typedef struct linenoiseCompletions { size_t len; char **cvec; } linenoiseCompletions;

len 指的是有著同樣開頭的 cmd 數量,而 cvec 則把同樣開頭的 cmd 以 linked list 串起來。所以我們只需要設定這個結構體即可,而在 linenoise 是用 callback 的方式去定義這個設定 function,需要先自行寫好再去 callback

/* 自行撰寫 */ void completion(const char *buf, linenoiseCompletions *lc) { if (buf[0] == 'n') { linenoiseAddCompletion(lc,"new"); } else if (buf[0] == 's') { linenoiseAddCompletion(lc,"sort"); linenoiseAddCompletion(lc,"size"); linenoiseAddCompletion(lc,"show"); } ... }
void linenoiseSetCompletionCallback(linenoiseCompletionCallback *fn) { completionCallback = fn; }

並在初始化 console 的時候呼叫

linenoiseSetCompletionCallback(completion);

如此一來,就可以在 completeLine 中直接使用

static int completeLine(struct linenoiseState *ls) { linenoiseCompletions lc = { 0, NULL }; int nread, nwritten; char c = 0; completionCallback(ls->buf,&lc); if (lc.len == 0) { linenoiseBeep(); } else { size_t stop = 0, i = 0; while(!stop) { /* Show completion or original buffer */ if (i < lc.len) { struct linenoiseState saved = *ls; ls->len = ls->pos = strlen(lc.cvec[i]); ls->buf = lc.cvec[i]; refreshLine(ls); ls->len = saved.len; ls->pos = saved.pos; ls->buf = saved.buf; } else { refreshLine(ls); } nread = read(ls->ifd,&c,1); if (nread <= 0) { freeCompletions(&lc); return -1; } switch(c) { case 9: /* tab */ i = (i+1) % (lc.len+1); if (i == lc.len) linenoiseBeep(); break; case 27: /* escape */ /* Re-show original buffer */ if (i < lc.len) refreshLine(ls); stop = 1; break; default: /* Update buffer and return */ if (i < lc.len) { nwritten = snprintf(ls->buf,ls->buflen,"%s",lc.cvec[i]); ls->len = ls->pos = nwritten; } stop = 1; break; } } } freeCompletions(&lc); return c; /* Return last read character */ }

如此便完成了。而 history 則沒那麼複雜,直接使用 char 陣列紀錄。

指出現有程式的缺陷 (提示: 和 RIO 套件 有關),嘗試強化並提交 pull request

以下的程式碼為 console.c 中片段,

static char *readline() { int cnt; char c; char *lptr = linebuf; if (!buf_stack) return NULL; for (cnt = 0; cnt < RIO_BUFSIZE - 2; cnt++) { if (buf_stack->cnt <= 0) { /* Need to read from input file */ buf_stack->cnt = read(buf_stack->fd, buf_stack->buf, RIO_BUFSIZE); buf_stack->bufptr = buf_stack->buf; if (buf_stack->cnt <= 0) { /* Encountered EOF */ pop_file(); if (cnt > 0) { /* Last line of file did not terminate with newline. */ /* Terminate line & return it */ *lptr++ = '\n'; *lptr++ = '\0'; if (echo) { report_noreturn(1, prompt); report_noreturn(1, linebuf); } return linebuf; } return NULL; } } /* Have text in buffer */ c = *buf_stack->bufptr++; *lptr++ = c; buf_stack->cnt--; if (c == '\n') break; } if (c != '\n') { /* Hit buffer limit. Artificially terminate line */ *lptr++ = '\n'; } *lptr++ = '\0'; if (echo) { report_noreturn(1, prompt); report_noreturn(1, linebuf); } return linebuf; }

上面程式碼的 for-loop 中有關使用 read 的控制是在第 11 行 if

for (cnt = 0; cnt < RIO_BUFSIZE - 2; cnt++) { if (buf_stack->cnt <= 0) { /* Need to read from input file */ buf_stack->cnt = read(buf_stack->fd, buf_stack->buf, RIO_BUFSIZE); ... if (buf_stack->cnt <= 0) { ... if (cnt > 0) { ... } } } /* Have text in buffer */ c = *buf_stack->bufptr++; *lptr++ = c; buf_stack->cnt--; if (c == '\n') break; }

buf_stack->cnt 在最初 init 時會是 0 ,毫無疑問的可以進入 read 的步驟,並將 buf_stack->fd 讀進 buf_stack->buf,之後再透過判斷 read 回傳值更新 stack_buf->cnt,根據 linux manual,read 的 return value <= 0 會有兩種情況:

  1. EOF and return 0
  2. ERROR and return -1

但在上面的程式碼片段中,只對遇到 EOF 的情況做處理,也就是說當 read 發生未預期的錯誤時,只把它當成 EOF 的狀況處理。
根據 CSAPP Ch10 提到 RIO 應該是在 read 的時候如果沒能完成任務 ( short has been happened ) ,應該繼續的嘗試 read,所以在程式中加入判斷是何種情形以及將十一行的 if 替換成 while

if (stack_buf->cnt < 0){ /* Handle with ERROR */ if (errno != EINTR) /* interrupted by sig handler return */ return NULL; /* read again */ }

如下,

static char *readline() { int cnt; char c; char *lptr = linebuf; if (!buf_stack) return NULL; for (cnt = 0; cnt < RIO_BUFSIZE - 2; cnt++) { while (buf_stack->cnt <= 0) { /* Need to read from input file */ buf_stack->cnt = read(buf_stack->fd, buf_stack->buf, RIO_BUFSIZE); buf_stack->bufptr = buf_stack->buf; if (buf_stack->cnt < 0) { /* Check errno to check which error was happened and read it * again */ } else if (buf_stack->cnt == 0) { /* Encountered EOF */ pop_file(); if (cnt > 0) { /* Last line of file did not terminate with newline. */ /* Terminate line & return it */ *lptr++ = '\n'; *lptr++ = '\0'; if (echo) { report_noreturn(1, prompt); report_noreturn(1, linebuf); } return linebuf; } return NULL; } } /* Have text in buffer */ c = *buf_stack->bufptr++; *lptr++ = c; buf_stack->cnt--; if (c == '\n') break; } if (c != '\n') { /* Hit buffer limit. Artificially terminate line */ *lptr++ = '\n'; } *lptr++ = '\0'; if (echo) { report_noreturn(1, prompt); report_noreturn(1, linebuf); } return linebuf; }

TODO: 解釋如此修改的 side effect 以及你如何驗證

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
jserv