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系統編程之進程通信》,如果覺得有用,歡迎您一鍵三連!


免責聲明!

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



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