Linux系統編程之文件IO

前言

在學習C語言時,我們接觸過如fopen、fclose、fseek、fgets、fputs、fread、fwrite等函數,實際上,這些函數是對於底層系統調用的封裝。C默認會打開三個輸入輸出流,分別是stdin,stdout,stderr。執行man stdin後,會展示如下描述:

   #include <stdio.h>
   extern FILE *stdin;
   extern FILE *stdout;
   extern FILE *stderr;

可以看到,這三個流類型都是FILE*,也就是說指向了某個文件。實際上,以上三者分別對應的文件為鍵盤、顯示器、顯示器。

那麼,作業系統是如何管理文件,並進行文件IO呢?

1. 文件描述符及基本IO介面介紹

1.1 什麼是文件描述符

在第一講中,我們知道了當進程被創建後,系統會給該進程分配對應的PCB,在Linux中,進程的PCB是task_stuct,裡面有一項files_struct——打開文件表。打開文件表的源碼如下:

struct files_struct {

    atomic_t count; /* 共享該表的進程數 */

    rwlock_t file_lock; /* 保護以下的所有域,以免在tsk->alloc_lock中的嵌套*/

    int max_fds; /*當前文件對象的最大數*/

    int max_fdset; /*當前文件描述符的最大數*/

    int next_fd; /*已分配的文件描述符加1*/

    struct file ** fd; /* 指向文件對象指針數組的指針 */

    fd_set *close_on_exec; /*指向執行exec( )時需要關閉的文件描述符*/

    fd_set *open_fds; /*指向打開文件描述符的指針*/

    fd_set close_on_exec_init;/* 執行exec( )時需要關閉的文件描述符的初值集合*/

    fd_set open_fds_init; /*文件描述符的初值集合*/

    struct file * fd_array[32];/* 文件對象指針的初始化數組*/

};

進程是通過文件描述符(file descriptors,簡稱fd)而不是文件名來訪問文件的,文件描述符是一個整數。

在打開文件表中,最重要的一項是fd_array[32],這是一個指針數組,通常,fd_array包括32個文件對象指針,如果進程打開的文件數目多於32,內核就分配一個新的、更大的文件指針數組,並將其地址存放在fd域中,內核同時也更新max_fds域的值。

每當打開一個文件時,系統就會分配fd_array中的某項,將其指向打開的文件結構體。從下圖我們可以看出,fd實際上就是fd_array的索引。只要有fd,就可以找到對應文件的位置。

image-20210820120104359

1.2 基本IO介面

在認識返回值之前,需要先區分兩個概念: 系統調用和庫函數。 在用戶程式中,凡是與資源有關的操作(如存儲分配、進行I/O傳輸及管理文件等),都通過系統調用方式向作業系統提出服務請求,並由作業系統代為完成。在執行系統 調用的過程中,作業系統會由用戶態進入到內核態。系統為了防止應用程式能隨意修改系統數據,只給用戶提供介面,用戶要使用,那就通過提供的介面來調用。

fopen、fclose、fread、fwrite 都是C標準庫當中的函數,我們稱之為庫函數(libc),而open close read write lseek 都屬於系統提供的介面,稱之為系統調用介面。實際上,庫函數往往是對系統調用介面的進一步封裝,方便程式設計師進行二次開發。

1.2.1 open/close介面

函數原型:

頭文件:

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

介面1:int open(const char *pathname, int flags); :

介面2:int open(const char *pathname, int flags, mode_t mode);

介面3:int close(int fd);

如果打開的文件存在,則使用介面1,如果打開的文件不存在,則使用介面2。

pathname:待打開或創建的文件

flags:以何種方式打開。打開文件時,可以傳入多個常量進行「或」運算。這些常量有:

​ 0_RDONLY:只讀打開;O_WRONLY:只寫打開;O_RDWR:讀寫打開。這三個常量,必須指定且只能指定一個。

​ O_CREAT:若文件不存在,則創建該文件,需要使用mode選項,來指明新文件的訪問許可權。

​ O_APPEND:追加寫入。

​ O_TRUNC:截斷文件(清空文件內容)

O_NONBLOCK :使用非阻塞方式讀寫設備文件,如果不添加,默認情況下讀寫為阻塞方式。

以上的選項是按照按位或的方式進行組合的,O_RDWR|O_CREAT|O_APPEND 意思是以讀寫的方式打開文件,如果文件不存在則創建文件,打開文件後寫入方式為追加寫入。

mode:當創建一個新文件時,需要給文件設置許可權,一般通過傳遞一個8進位的數字。關於文件許可權,請讀者自行查閱相關文章。

返回值:

​ 創建成功返回一個文件描述符

​ 創建失敗返回-1。

1.2.2 read/wirte介面

ssize_t read(int fd, void *buf, size_t count);

fd:文件描述符

buf:將文件讀到buf指向的空間中

count:期望讀取的位元組數

ssize_t write(int fd, const void *buf, size_t count)

fd:文件描述符

buf:將buf中的內容寫到文件當中去

count:期望寫入的位元組數,size_t被定義為unsigned long

返回值:返回讀出或者寫入的位元組數。需要注意的是read和write的返回值都是有符號數,ssize_t被定義為long,出錯的時候返回-1。有趣的是返回一個-1的可能性使得讀到或者寫入的最大值減小了一半。

通過下例來感受一下上面幾個介面的使用:

#include <stdio.h>    
#include <sys/types.h>    
#include <sys/stat.h>    
#include <fcntl.h>    
#include <unistd.h>    
#include <string.h>    
char buff[1024];    
int main()    
{    
  int fd = open("wrfile",O_RDWR|O_CREAT|O_APPEND,0644);    
  if(fd == -1)    
  {    
    return 1;    
  }    
  else{    
    const char* str = "Hello world\n";                                                                                                                  
    strcpy(buff,str);    
    write(fd,buff,strlen(buff));    
    printf("%d\n",fd);    
    close(fd);    
  }    
    
  return 0;    
} 

執行該段程式碼後,可以看到如下輸出結果

image-20210820191541316

第一,打開文件後,將返回wrfile的fd,執行write函數,會將buff中的內容寫入到wrfile中。

第二,輸出wrfile的內容,發現如果只執行一次,輸出一行「Hello world」,如果執行兩次,輸出兩行「Hello world」,這是因為我們是以追加的方式打開的文件。

第三,打開文件後,返回的fd號碼是3。為什麼會是3?這就與文件描述符的分配規則有關。

1.3 文件描述符的分配規則

根據我們在1.1中知道的,fd是fd_array[ ]的索引。通常,數組的第一個元素(索引為0)是進程的標準輸入文件,數組的第二個元素(索引為1)是進程的標準輸出文件,數組的第三個元素(索引為2)是進程的標準錯誤文件,分別對應的鍵盤,顯示器,顯示器。在Linux中,萬物皆文件,因此各種外設也會當做文件進行處理。

是不是瞬間明白了為什麼用戶自己打開第一個文件的時候分配的fd是3而不是0?是的,這是因為對於任何進程,標準輸入文件、標準輸出文件和標準錯誤文件會被默認打開。當再次分配的時候,作業系統會採用最小未分配原則——即先分配當前fd_array中未被使用的最小索引。

如果我們先關閉了fd為1的文件,會是什麼情況呢?請看下例:

#include <stdio.h>    
#include <sys/types.h>    
#include <sys/stat.h>    
#include <fcntl.h>    
#include <unistd.h>    
#include <string.h>    
    
int main()    
{    
  close(1);    
  int fd = open("./myfile",O_RDWR|O_CREAT,0644);    
  if(-1 == fd)    
  {    
    return -1;    
  }else{    
      printf("The fd is : %d\n",fd);    
      fflush(stdout);                                                                                                                   
      close(fd);    
  }    
  return 0;    
} 

執行該段程式碼後,結果如下:

image-20210821114016940

執行./test後,並沒有在螢幕上列印出結果。但是當我們查看myfile文件中的內容時發現,原來是內容都輸出在了myfile文件中。我們可以看到,打開myfile的fd為1,這是因為我們之前關閉了fd為1的文件,當打開新的文件時,系統會分配最小未使用的fd——1。

為什麼執行printf後內容會輸出到myfile中而不是螢幕上,這就需要提到輸出重定向。

1.4 輸入重定向與輸出重定向的本質

1.4.1重定向的原理

printf是格式化輸出函數,當調用printf時,數據會被默認輸出到緩衝區中,當換行符或者緩衝區滿時,會將數據刷新到stdout中。stdout是標準輸出設備,其fd默認為1。

在上例中,關閉fd為1的文件後,當打開新的文件,會給其分配最小未使用的fd。此時執行printf函數,會將數據輸出到磁碟文件myfile中,我們將這種現象稱為輸出重定向。常見的重定向有:>, >>, <,分別為輸出重定向,追加重定向,輸入重定向。重定向的原理如下圖所示:

image-20210821121610540

需要注意的是,我們在執行完printf後,使用了fflush函數來刷新緩衝區。如果不使用這個函數,當執行./test後,我們會發現數據並未輸出到myfile中,這就需要提到緩衝區。

在默認情況下,stdout是行緩衝的,他的輸出會放在一個buffer裡面,只有到換行的時候,才會輸出到螢幕,stderro是無緩衝,會直接進行io操作。而平時使用的磁碟文件是全緩衝(或稱滿緩衝)的,只有緩衝區滿的時候才會將緩衝區裡面的內容刷新。當關閉stdout,打開myfile後,會變成全緩衝,因此需要我們執行fflush強制刷新緩衝區。緩衝區的類型如下:

img

需要注意的是,這裡的緩衝區指的是c程式中的用戶緩衝區,而不是內核緩衝區!

1.4.2 重定向在命令行的應用

如下圖所示,當執行cat指令後,myfile中的內容會輸出到螢幕上。當我們再次執行cat myfile,並使用>進行輸出重定向後,可以看出myfile中的內容輸出到了pfile中,當使用>>後,會發現pfile中有兩行數據,這就是追加重定向,即再原來文件的末尾繼續輸出。

image-20210821164318098

1.4.3 dup2系統調用

如果我們想在IO時進行重定向操作,難道每次都需要先close一個文件,再申請對應的fd嗎?這樣無疑增加了編碼的複雜程度,因此,如果想進行重定向操作,推薦使用dup2系統調用。

介面描述:

int dup2(int oldfd, int newfd);

該介面用來複制新的文件描述符。通俗地說,fd_array[ ]中存放著指向若干打開的文件結構體file,比如此時某個文件的fd為3,我們想對這個文件進行輸出重定向,讓本應該輸出到fd為3的文件中的數據輸出到螢幕上,就可以通過調用dup2(3,1)。原理是把fd為3的文件結構體指針複製到fd為1的單元中,這樣3和1都指向了同一個文件結構體。

示例如下:

#include <stdio.h>    
#include <sys/types.h>    
#include <sys/stat.h>    
#include <fcntl.h>    
#include <unistd.h>    
#include <string.h>    
    
int main()    
{    
  int fd = open("./myfile",O_RDWR|O_CREAT,0644);    
  if(-1 == fd)    
  {    
    return -1;    
  }else{    
      printf("Hello world!\n");    
      dup2(fd,1);    
      close(fd);    
      printf("The fd is : %d\n",fd);    
      fflush(stdout);                                                                                                                                   
  }    
  return 0;    
} 

輸出結果如下:

image-20210822105214810

在程式中,有兩處printf函數,當執行./test後,螢幕上只列印出一行”Hello world!”,而第二行的數據列印到了myfile中。我們明明已經對新打開的文件執行了close(fd),為什麼數據還是會列印到myfile中?

這是因為調用dup2的時候,將oldfd中的值複製到了newfd中。

注意複製的是數組下標為fd中存儲的指針值而不是fd本身!若newfd指向的文件已經被打開,會先將其關閉。若newfd等於oldfd,就不關閉newfd,newfd和oldfd共同指向一份文件。

1.5 fd與C庫中FILE的關係

C庫中的函數本質上是對系統調用的封裝,所以本質上,所有對於文件的操作都是通過文件描述符fd來實現的。那麼,C庫的FILE中一定有對應文件的fd

2. 文件系統

2.1 磁碟簡介

傳統的硬碟盤結構是像下面這個樣子的,它有一個或多個碟片,用於存儲數據。中間有一個主軸,所有的碟片都繞著這個主軸轉動。一個組合臂上面有多個磁頭臂,每個磁頭臂上面都有一個磁頭,負責讀寫數據。

image-20210822121019263

每個磁軌劃分為若干個弧段,每個弧段就是一個扇區 (Sector),是硬碟的最小存儲單位。每個扇區儲存512位元組(相當於0.5KB)。

作業系統讀取硬碟的時候,不會一個個扇區地讀取,因為這樣效率太低,而是一次性連續讀取多個扇區,即一次性讀取一個」塊」(block)。這種由多個扇區組成的」塊」,是文件存取的最小單位。」塊」的大小,最常見的是4KB,即連續八個sector組成一個block。一個block的大小是由格式化的時候確定的,並且不可以更改。

2.2 inode(索引結點)

輸入ls -l指令後,我們能看到如下內容

image-20210822151332933

對於一個文件而言,一個文件= 文件屬性+文件內容。圖中所標識的部分就是文件的各種屬性,那這些屬性是存儲在哪裡?又是怎樣存儲的呢?文件的數據又存放在哪裡呢?

image-20210822153358985

上圖是一個簡易的磁碟系統分區圖,一般來說,在一個文件系統內,一般將磁碟分為如下幾個區域:

超級塊:裡面存放該文件系統本身的結構資訊,如bolok 和 inode(馬上會講)的總量, 未使用的block和inode的數量,一個block和inode的大小等。

block點陣圖:先來看一下點陣圖的結構

image-20210822154331571

block點陣圖有點像是一個超大型數組,每個比特位所在位置可以看成數組下標,而這個「下標」就是對應的block號,0代表該塊未分配,1代表該塊已經被分配。如在上圖中,表示9號塊已經分配。每分配一個block,要將對應位置的Bitmap置為1。

inode表:inode中存放的是一個文件的元資訊,一般來說有以下資訊:

  • 該文件的inode號,用來標識一個inode,而每個inode對應一個文件,Linux系統內部不使用文件名,而使用inode號碼來識別文件。對於系統來說,文件名只是inode號碼便於識別的別稱或者綽號。

    查看inode號碼的指令:ls -i

    image-20210822173031218

  • 文件的位元組數

  • 文件擁有者的User ID ,所屬組的Group ID

  • 文件的讀、寫、執行許可權

  • 文件的時間戳

  • 鏈接數,即有多少文件名指向這個inode 文件數據block的位置

    可以通過stat 指令查看對應文件的inode資訊,如下圖所示:

image-20210822161934940

inode中還會為該文件維護一個索引表,類似於記憶體管理中的頁表記錄了邏輯塊號到物理塊號之間的對應關係。索引表的目錄項中記錄的是每個文件的索引塊地址。一般有直接索引,多層索引或混合索引等。

image-20210822164636231

inode點陣圖:一個文件系統能分配的inode數量是一定的,因此也可以用記錄block的方式來記錄inode的分配情況。原理與block Bitmap的原理一樣,inode Bitmap中的「下標」就是inode號。當分配了某個inode號時,將對應inode Bitmap比特位置為1。

2.3 目錄文件的理解

在Linux中,目錄(directory)也是一種文件。

目錄文件是一系列目錄項(dirent)組成的。每個目錄項,由兩部分組成:所包含文件的文件名,以及該文件名對應的inode號碼。如下圖所示

image-20210822170449438

既然目錄也是文件,那麼目錄本身也一定有其inode,現代作業系統一般採用樹形結構來組織文件。因此要打開一個文件,需要先找到該文件的目錄,並在目錄中找到對應的目錄項,獲取到該文件的inode號碼,再將磁碟中的內容載入到記憶體。

進入一個目錄需要什麼許可權?

顯示目錄下的內容是讀許可權(r) ,由於目錄文件內只有文件名和inode號碼,所以如果只有讀許可權,只能獲取文件名,無法獲取其他資訊,因為其他資訊都儲存在inode節點中。進入是可執行許可權 (x),一個目錄默認需要有可執行許可權。

2.4 軟鏈接與硬鏈接

我們已經知道,文件的唯一標識符是inode而非文件名,文件名僅僅是方便用戶使用的一個「綽號」。那麼,只能通過一個文件名來找到對應的inode,獲取文件的元資訊嗎?在Linux中,為了解決文件共享的問題,提出了鏈接。鏈接分為硬鏈接和軟鏈接:

2.4.1 硬鏈接

讓不同的文件名訪問同樣的內容,這種方式被稱為硬鏈接。

可以使用ln指令創建硬鏈接:ln 源文件 目標文件,如下圖所示

image-20210822180347412

該案例中為test可執行文件創建了一個硬鏈接linktest,通過ls -il指令可以看到,linktest和test具有相同的inode號碼,也就是說實際上是同一個文件,此時鏈接數為2。執行test和linktest後,都會輸出同樣的結果。而當刪除test文件後,由於linktest的存在,該文件實際上並未被刪除,刪除的僅僅是該文件名!此時,鏈接數會變為1。

因此我們可以總結出硬鏈接的如下特點:

  • 不同的文件名訪問同樣的內容。
  • 對文件內容進行修改,會影響到所有文件名。
  • 刪除一個文件名,不影響另一個文件名的訪問。當鏈接數變為0時,該文件才算真正刪除。

這裡需要注意目錄文件的鏈接數!

創建一個目錄文件,輸入ls -il命令後,會看到如下現象:

image-20210822185613313

該目錄文件的鏈接數居然是2!這是為什麼?當我們進入dir,並創建一個目錄文件後,會發現,「.”的inode和dir的inode是一樣的,也就是說dir目錄下的「.”是dir的硬鏈接。這是因為創建目錄時,默認會生成兩個目錄項,”.「和”..”,」.” 代表當前文件,”..” 代表上一級文件。

image-20210822185838208

如果此時我們在dir下再創建一個目錄,會發現dir的鏈接數變為3。這是因為新創建的文件dir/dir1中包含」..” ,該文件名也是dir的硬鏈接。

image-20210822190723350

綜上,任何一個目錄的”硬鏈接”總數,總是等於2加上它的子目錄總數(含隱藏目錄)

2.4.2 軟鏈接

軟鏈接類似於Windows下的快捷方式。Linux下通常會將一些目錄層次較深的文件鏈接到一個更易訪問的目錄中。

用ln -s 命令創建一個文件的軟鏈接:ln -s 源文文件或目錄 目標文件或目錄

image-20210822212327508

可以看到創建test的軟鏈接slinktest後,slinktest的inode號與test的inode號不一樣。這說明軟鏈接本身也是一個文件,且文件類型為l。test的鏈接數始終為1,當刪除test後,執行slinktest會報出No such file or directory的錯誤。也就是說,軟鏈接依賴於原文件存在。

硬鏈接和軟鏈接最大的不同在於:

  • 軟鏈接指向的是原文件的文件名而不是inode,保存了其代表的文件的絕對路徑,不會改變原文件的鏈接數,刪除原文件,軟鏈接將不能使用。
  • 而硬鏈接指向的原文件的inode,只有當鏈接數變為0是,整個文件才能被真正刪除。

3.動靜態庫

在學習動靜態庫前,我們需要先明白什麼是庫。庫(Library)就是一段編譯好的二進位程式碼,加上頭文件後就可以供別人使用。一般有兩種情況會用到庫:

  • 第一種情況是某些程式碼需要給別人使用,但是我們不希望別人看到源碼,就編譯好並以庫的形式進行封裝,只暴露出頭文件。別人要使用,只需要加上頭文件即可。
  • 第二種是在實際工程中,編譯一個大型項目往往要花費很多時間,因為很多文件都需要從源文件編譯,鏈接。對於某些不會進行大的改動的程式碼,我們想減少編譯的時間,就可以把它打包成庫,因為庫是已經編譯好的二進位了,編譯的時候只需要 Link 一下,不會浪費編譯時間。

那麼,我們必須明白一個概念——目標文件。目標文件有三種形式:

  • 可執行目標文件。即我們通常所認識的,可直接運行的二進位文件。
  • 可重定位目標文件。包含了二進位的程式碼和數據,可以與其他可重定位目標文件鏈接,並創建一個可執行目標文件。
  • 共享目標文件。它是一種在載入或者運行時進行鏈接的特殊可重定位目標文件。當程式執行到一定程度,需要調用該目標文件中的某個介面時,才會將該目標文件與運行中的文件鏈接。

通過上面的表述,我們發現鏈接的方式有兩種:一種是提前鏈接好,生成可執行目標文件;另一種是運行過程中才鏈接。前者被稱為靜態鏈接,後者被稱為動態鏈接。於是便產生了兩種庫——靜態庫與動態庫。

靜態庫在Linux中前綴為lib,後綴為.a,因此一個靜態庫的名字為libxxx.a。動態庫在Linux中前綴為lib,後綴為.so,則一個動態庫的名字為libxxx.so。

靜態庫(.a):程式在編譯鏈接的時候把庫的程式碼鏈接到可執行文件中,程式運行的時候將不再需要靜態庫 。

image-20210822220515361

當我們make後,會發現報如下錯誤:

/usr/bin/ld: cannot find -lc
collect2: error: ld returned 1 exit status
make: *** [test_static] Error 1

不要著急,這是因為我們沒有安裝靜態庫!

輸入如下指令:sudo yum install glibc-static即可解決。接下來編譯,會發現出現了我們想要的test_static。發現沒有,通過靜態庫生成的目標文件非常大!還請大家忍一下。

image-20210822221116005

這是因為通過靜態庫生成的可執行文件時,在鏈接的過程中將靜態庫中需要的部分都「拷貝」到了最終的可執行文件中,因此這個可執行文件在一個沒有其需要的庫的linux系統中也能正常運行。

動態庫(.so):程式在運行的時候才去鏈接動態庫的程式碼,多個程式共享使用庫的程式碼。 一般默認生成的可執行程式都是動態的,動態庫體積小,運行時載入,只有一份。可以看到,動態鏈接生成的test的大小只有靜態鏈接生成的test_static的百分之一左右!

image-20210822222452273

  • 一個與動態庫鏈接的可執行文件僅僅包含它用到的函數入口地址的一個表,而不是外部函數所在目標文件的整個機器碼。
  • 在可執行文件開始運行以前,外部函數的機器碼由作業系統從磁碟上的該動態庫中複製到記憶體中,這個過程稱為動態鏈接(dynamic linking)。
  • 動態庫可以在多個程式間共享,所以動態鏈接使得可執行文件更小,節省了磁碟空間。作業系統採用虛擬記憶體機制允許物理記憶體中的一份動態庫被要用到該庫的所有進程共用,節省了記憶體和磁碟空間。

可以通過file命令查看文件的鏈接資訊:

image-20210822223003765

通過以上描述,我們可以看出動態庫與靜態庫有以下區別:

  1. 可執行文件大小不同。動態庫比靜態庫小得多。
  2. 擴展性不同。如果靜態庫中某個函數的實現變了,那麼這個可執行文件必須重新編譯,比較耗時。而動態庫只需要更新動態庫本身,不需要重新編譯可執行文件。
  3. 載入速度不同。由於靜態庫在運行時才鏈接,因此從時間效率上會稍慢一些。不過由於程式運行的局部性原理,時間損失並不會很多。
  4. 依賴性不同。靜態鏈接的可執行文件不需要依賴其他的內容即可運行,而動態鏈接的可執行文件必須依賴動態庫的存在。一般情況下,系統中有大量的動態庫,不會有太大問題。

總結

學習完系統IO後,我們再來思考最後一個問題。

當文件打開載入進記憶體後,該文件在記憶體中的位置為什麼不放在inode中,而是存放在file結構體中?

Linux中的文件是能夠共享的,假如把文件位置存放在索引節點中,則如果有兩個或更多個進程同時打開同一個文件時,它們將去訪問同一個索引節點。如果一個進程對該文件進行寫操作,而另一個同時進行讀操作,顯然,這是不被允許的。

另一方面,打開文件時有如下特點。

  • 一個文件不僅可以被不同的進程分別打開,而且也可以被同一個進程先後多次打開。
  • 一個進程如果先後多次打開同一個文件,則每一次打開都要分配一個新的文件描述符,並且指向一個新的file結構。

引入file結構體有利於文件的共享。當兩個進程共享同一個文件時,兩個進程的fd可以指向同一個file結構體。file結構體中記錄著文件的在記憶體中的偏移量,當一個進程進行寫操作後,文件的偏移量可能發生改變,此時只需要修改file結構體中的偏移量。當該進程寫結束,另一個進程需要進行寫操作時,是在新的偏移量的基礎上進行寫操作,這樣防止了第二個進程重寫第一個進程的輸出內容。

進程可以共享同一個打開的文件,那進程之間是否能夠進行通訊呢?答案是肯定的。下一章我們將會講述《Linux系統編程之進程通訊》,如果覺得有用,歡迎您一鍵三連!