在 Linux 操作系統中,文件 I/O(輸入/輸出)是程序與文件系統交互的基礎。理解文件 I/O 的工作原理對于編寫高效、可靠的程序至關重要。本文將深入探討系統文件 I/O 的機制。
一種傳遞標志位的方法
在 Linux 中,文件的打開操作通常使用標志位來指定文件的訪問模式。open()
系統調用用于打開文件,其原型如下:
int open(const char *pathname, int flags, mode_t mode);
pathname
:要打開的文件路徑。flags
:打開文件時的標志位,指定文件的訪問模式和行為。mode
:文件權限,僅在創建新文件時使用。
常見的標志位包括:
參數必須包括以下三個訪問方式之一。
- `O_RDONLY`:只讀模式。
- `O_WRONLY`:只寫模式。
- `O_RDWR`:讀寫模式。
其他的訪問模式。
- `O_CREAT`:如果文件不存在,則創建文件。
- `O_TRUNC`:如果文件存在,則將其長度截斷為零。
- `O_APPEND`:每次寫入都追加到文件末尾。
標志位的原理:
原理就是位圖。不同的訪問模式位圖上的標記位置不同,傳參是通過或操作( | )即可得到需要訪問模式的位圖所有標記位置。然后再打開或操作文件時就會按照傳入的訪問模式進行。
文件權限mode
:
新創建文件的最終權限 = mode & ~umask
例如,以下代碼以讀寫模式打開文件 example.txt
,如果文件不存在則創建:
int fd = open("example.txt", O_RDWR | O_CREAT, 0666);
在此,0666
是文件的權限掩碼,表示文件所有者、所屬組和其他用戶均具有讀寫權限。
hello.c 寫文件
在 C 語言中,使用 open()
打開文件后,可以使用 write()
系統調用向文件寫入數據。以下是一個示例:
#include <fcntl.h>
#include <unistd.h>
#include <string.h>int main() {int fd = open("example.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);if (fd == -1) {// 錯誤處理return 1;}const char *text = "Hello, Linux!";ssize_t bytes_written = write(fd, text, strlen(text));if (bytes_written == -1) {// 錯誤處理close(fd);return 1;}close(fd);return 0;
}
向fd
中寫入buf
,一次最多count
個。
在此示例中:
open()
以寫入模式打開文件example.txt
,如果文件不存在則創建,權限為0666
。write()
將字符串"Hello, Linux!"
寫入文件。close()
關閉文件描述符,釋放資源。
每次寫入字符串不用留
'\0'
的位置,文件本身可以看做數組,如果中間存在'\0'
,則在讀取文件時會造成錯誤。
當向文件內寫入內容時,可以進行文本寫入和二進制寫入,兩者的區別寫入是語言層面的概念,系統不會關心類型,只要寫入內容就會直接寫入。
hello.c 讀文件
讀取文件的過程與寫入類似,使用 read()
系統調用從文件中讀取數據。示例如下:
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>int main() {int fd = open("example.txt", O_RDONLY);if (fd == -1) {// 錯誤處理return 1;}char buffer[128];ssize_t bytes_read = read(fd, buffer, sizeof(buffer) - 1);if (bytes_read == -1) {// 錯誤處理close(fd);return 1;}buffer[bytes_read] = '\0'; // 確保字符串以 null 結尾printf("File content: %s\n", buffer);close(fd);return 0;
}
從fd
中讀取,拷貝到buf
中,最多讀取count
bytes(sizeof(buf) - 1
( - 1是為了在buf
的末尾存貯'\0'
))。
在此示例中:
open()
以只讀模式打開文件example.txt
。read()
從文件中讀取數據到緩沖區buffer
。close()
關閉文件描述符。
open 函數返回值
區分兩個概念:**系統調用**
和**庫函數**
。
- 向
fopen``fclose``fread``fwrite
等都是C標準庫中的函數,稱之為庫函數(libc)。open``close``read``write``lseek
等屬于系統提供的接口,稱之為系統調用接口。
通過上圖可以理解庫函數和系統調用之間的關系。可以認為f*
系列的函數是對系統調用的封裝,方便二次開發。
open()
函數的返回值是一個文件描述符(fd),用于標識打開的文件。成功時返回非負整數,失敗時返回 -1
,并設置 errno
以指示錯誤類型。常見的錯誤包括:
EACCES
:權限不足。ENOENT
:文件不存在。EINVAL
:無效的標志位。
例如:
int fd = open("example.txt", O_RDONLY);
if (fd == -1) {perror("Error opening file");return 1;
}
文件描述符 fd
文件描述符(fd)是一個非負整數,用于標識進程打開的文件。標準輸入、標準輸出和標準錯誤分別對應文件描述符 0、1 和 2。文件描述符的分配規則如下:
- 默認情況下,標準輸入、標準輸出和標準錯誤分別占用 0、1 和 2。
- 通過
open()
打開的文件從 3 開始分配。
所以當我們查看在程序中打開的文件的fd
時發現都是3之后的,就是因為在程序運行前就有自動升層的代碼在開頭打開了三個標準流文件,已經占據了0,1,2。
0 & 1 & 2
0
:標準輸入(stdin),通常對應鍵盤輸入。1
:標準輸出(stdout),通常對應屏幕輸出。2
:標準錯誤(stderr),用于輸出錯誤信息。
通過6
可知
通過6
中關于FILE
的講解,當向open
等函數返回值fd
實際上就是進程內管理文件的數組的下標。所以當傳入close
等函數調用時就會通過下標來找尋這個文件,然后進行文件操作。
而對于庫函數來說,返回值為FILE
,作為將fd
包裝好的結構體,在函數內部使用系統調用的時候會自行進行處理。
FILE
FILE
是什么呢?
在 C 語言標準庫中,FILE
是一個用于描述文件的結構體,通常由 stdio.h
提供。它提供了一種便捷的接口,讓我們可以操作文件而無需直接涉及底層的文件描述符。
FILE
結構體的內部實現
FILE
結構體并不是操作系統原生的,而是由 C 標準庫(如 GNU C 庫)定義的,它封裝了文件的元數據,并提供了緩沖機制以提高 I/O 操作的效率。雖然不同的系統和編譯器可能有不同的實現,以下是 FILE
結構體的一種典型實現:
struct _iobuf {char *_ptr; // 指向緩沖區的指針int _cnt; // 緩沖區的剩余字節數char *_base; // 緩沖區的起始位置int _flag; // 文件狀態標志(如是否可讀、是否可寫)int _file; // 文件描述符int _charbuf; // 讀緩存區的狀態int _bufsiz; // 緩沖區大小char *_tmpfname; // 臨時文件名
};
typedef struct _iobuf FILE;
重要字段解釋:
_ptr
:指向當前緩沖區位置的指針,文件數據會存儲在這里。_cnt
:緩沖區中剩余的可用空間字節數。_base
:緩沖區的起始位置。_flag
:存儲文件的狀態標志,如文件是否處于讀寫模式等。_file
:該文件對應的系統級文件描述符,這是最直接的文件標識。_bufsiz
:緩沖區的大小。_tmpfname
:如果文件是臨時的,存儲其文件名。
FILE
結構體內部使用緩沖機制,這使得每次文件 I/O 操作時,程序并不直接與磁盤交互,而是將數據存入內存中的緩沖區,等緩沖區滿時才將數據批量寫入磁盤,從而提高 I/O 性能。
緩沖機制具體本文不做解釋,之后文章會講解。
task_struct
和 file_struct
Linux 中的進程是由 task_struct
結構體來描述的。每個進程的 task_struct
中都包含一個 *file
指向一個file_struct
,這個結構體管理著該進程打開的文件。
task_struct
和文件操作的聯系
task_struct
結構體代表一個進程。每個進程有自己的文件描述符表,文件描述符表由一個 file_struct
來表示。file_struct
存儲了進程打開的所有文件的描述符、文件指針等信息。
struct task_struct {...struct files_struct *files; // 文件描述符表...
};
files_struct
結構體
files_struct
是與 task_struct
相關聯的結構體,存儲了該進程的文件描述符表(fd_table[]
)。它提供了一個對文件描述符的索引和文件操作的抽象管理。每個進程的 files_struct
都有一個 fd_table[]
數組,這個數組的索引即為文件描述符(fd)。
struct files_struct {atomic_t count; // 引用計數,表示該文件描述符表被多少個進程共享struct fdtable *fdt; // 文件描述符表(fd_table[])spinlock_t file_lock; // 保護文件描述符表的鎖
};
fd_table[]
數組與 file_struct
fd_table[]
是一個數組,可以被看做文件描述符表,每個元素對應一個 file
結構體,表示一個文件。文件描述符(fd)就是 fd_table[]
數組的索引值。例如,文件描述符 0 對應標準輸入(stdin),文件描述符 1 對應標準輸出(stdout),文件描述符 2 對應標準錯誤(stderr)。
struct fdtable {unsigned int max_fds; // 最大文件描述符數struct file **fd; // 文件描述符數組,fd[i] 為進程打開的文件
};
fd[i]
表示索引為i
的文件描述符指向的文件。max_fds
表示文件描述符表的最大文件描述符數。- 不同的
fd
可以打開同一個文件,引用計數來維護,形成1 : n。
file
結構體
在 Linux 中,file
結構體表示一個打開的文件。它不僅包含了文件的數據指針和操作,還包含了與文件操作相關的狀態信息。file
結構體的關鍵部分包括:
struct file
{屬性mode讀寫位置讀寫選項緩沖區操作方法struct file *next; // 指向下一個fd的file結構體
}
f_op
:文件操作結構體,包含了對文件的操作方法(如讀取、寫入、關閉等)。f_pos
:文件的當前偏移量,表示文件指針的位置。f_mode
:文件的訪問模式(如只讀、只寫、讀寫)。f_count
:引用計數,表示有多少進程引用了這個文件,所以真正的文件關閉指的是引用計數為0的時候。- 文件屬性存儲于結構體中,文件的內容存在緩沖區中。
文件操作的實質
從文件描述符到內核實現,文件操作的核心機制依賴于 fd_array[]
和 file_struct
。
文件描述符的使用流程
每當一個進程打開文件時,內核會為文件分配一個文件描述符(fd)。這個文件描述符將作為 fd_array[]
數組的索引,指向一個 file
結構體。具體的流程如下:
- 文件打開:進程通過
open()
系統調用請求打開一個磁盤中的文件文件。內核會分配一個新的文件描述符(fd
),并在fd_table[]
中為該進程創建一個指向該文件的file
結構體,屬性存于結構體,內容存于結構體指向的緩沖區中。
馮諾依曼體系中,CPU不直接與硬件交互,所以需要通過內存來交互,緩沖區在內存中形成。對文件內容做任何操作,都必須先把文件加載到內核對應的文件緩沖區內,從磁盤到內存的拷貝。
- 文件讀寫:通過
read()
或write()
系統調用,進程會通過文件描述符訪問file
結構體中的數據,并對文件進行操作。read()
本質就是內核到用戶空間的拷貝函數。 - 文件關閉:當文件操作完成時,進程通過
close()
系統調用關閉文件。內核會減少文件描述符表中file
結構體的引用計數,若引用計數為 0,則釋放該文件描述符的資源。
通過文件描述符與 file
結構體的映射
文件描述符實際上是一個索引,它將用戶空間的文件 I/O 操作映射到內核空間的 file
結構體。進程每次對文件進行讀寫操作時,都會通過文件描述符查找對應的 file
結構體,然后通過 file
中的操作指針(f_op
)調用具體的文件操作函數,如 read()
, write()
或 flush()
。
文件操作的效率
- 緩沖機制:Linux 內核使用緩沖區來提升文件 I/O 的效率。文件數據首先被寫入內核緩沖區,只有緩沖區滿了或程序顯式調用
flush
操作時,數據才會寫入磁盤。這樣可以減少磁盤 I/O 的頻率。 - 文件操作鎖:內核使用鎖來同步文件操作,確保多個進程對同一文件的訪問不會引發沖突。
結論
通過深入分析 FILE
結構體、task_struct
中的 file_struct
以及 fd_array[]
數組的關系,我們能夠更清晰地理解 Linux 系統中文件操作的底層機制。文件描述符作為用戶空間與內核空間的橋梁,file
結構體封裝了對文件的訪問接口,而內核通過文件描述符表、緩沖區機制和文件操作鎖等技術,保證了高效且可靠的文件 I/O 操作。
編程語言的可移植性
編程語言的可移植性指的是程序能否在不同的平臺或操作系統上順利運行。語言的設計、標準庫的實現以及對底層硬件的抽象都直接影響著程序的可移植性。
C 語言的可移植性
C 語言作為一種接近硬件的低級編程語言,直接與操作系統的底層交互。由于各個操作系統有不同的系統調用,C 語言的標準庫為不同平臺提供了相對一致的接口,使得 C 語言具備一定的可移植性。
不過,C 語言標準庫的實現也可能因操作系統而異。比如,Windows 和 Linux 都有 C 語言的實現,但它們的文件 I/O 操作部分會有所不同,Windows 可能使用 CreateFile()
,而 Linux 使用 open()
。為了增強 C 語言的可移植性,開發者常常通過條件編譯來區分不同操作系統下的實現。
例如,在 Windows 和 Linux 上都需要實現文件操作的代碼:
#ifdef _WIN32
#include <windows.h>
HANDLE hFile = CreateFile("log.txt", GENERIC_WRITE, 0, NULL, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
#else
#include <fcntl.h>
#include <unistd.h>
int fd = open("log.txt", O_CREAT | O_WRONLY | O_APPEND, 0666);
#endif
通過使用預處理指令 #ifdef
和 #endif
,程序可以根據不同操作系統選擇不同的文件打開方式,從而增加跨平臺的可移植性。
語言的可移植性?
除了 C 語言,其他高級編程語言(如 C++、Java、Python、Go、PHP)也通過各自的標準庫和虛擬機來增強跨平臺的可移植性。
- C++:C++ 通過標準庫(如 STL)提供了一套跨平臺的接口,使得程序能在不同操作系統上編譯和運行。然而,當涉及到直接與操作系統底層交互時,C++ 仍然需要依賴平臺特定的系統調用和 API。
- Java:Java 提供了 Java 虛擬機(JVM),使得 Java 程序可以在不同的操作系統上運行。JVM 會屏蔽底層系統的差異,使得 Java 代碼具有良好的可移植性。Java 的字節碼可以在任何實現了 JVM 的操作系統上運行。
- Python:Python 通過封裝了平臺特定的調用接口,提供了跨平臺的標準庫,如
os
、sys
等。Python 程序員通常不需要關心底層操作系統的細節,Python 會處理這些差異。 - Go:Go 語言內置對多平臺的支持,編譯器可以直接生成不同操作系統和架構的二進制文件,從而確保 Go 程序具有較高的可移植性。
- PHP:PHP 是一種主要用于 Web 開發的語言,它通過 Web 服務器(如 Apache、Nginx)和平臺無關的接口(如數據庫驅動)使得 PHP 程序具有一定的可移植性。
所以語言的移植性可以總結為:語言在底層庫中的使用系統調用的函數針對不同的系統會將系統調用部分更改,更換為不同操作系統的系統調用(條件編譯來解決)。
如此在上層使用語言的時候不會感受到差異,因為只是使用語言的語法,底層庫的差異在語言層面進行屏蔽,增加了語言的可移植性。
語言增加可移植性讓更多人愿意去使用,增加市場占有率。
不可移植性的原因?
- 操作系統依賴:
不同的操作系統有不同的API和系統調用。例如,Linux和windows的文件操作、內存管理、線程處理等API不同。如果現在有一個程序,在編寫的時候直接調用了某個操作系統特有的API,它在其他操作系統上就無法工作。必須將調用特有API更換為要在上面執行的操作系統的API才可以正常運行。
- 硬件依賴:
不同平臺使用的編譯器可能會有不同的行為,或者某些編輯器不支持某些特性。例如,C++中某些編譯器特性只在特定的編譯器中有效,導致代碼在其他平臺或編輯器中無法運行。
重定向
文件描述符的分配規則
當進程打開文件時,操作系統會分配一個最小的未使用文件描述符。例如:
int fd = open("example.txt", O_RDONLY);
如果文件描述符 3 未被占用,則 fd
將被賦值為 3。
重定向
重定向的核心原理在于操作文件描述符。文件描述符在file_struct
中的數組中存放管理,通過改變文件描述符的指向,我們可以將輸入或輸出流重定向到文件、設備或其他流。
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
#include <unistd.h> // 包含close函數的聲明int main() {// 關閉標準輸出文件描述符1close(1);// 打開(或創建)一個名為"myfile"的文件,以只寫方式打開// 如果文件不存在則創建,權限設置為644int fd = open("myfile", O_WRONLY | O_CREAT, 00644);if (fd < 0) {// 如果打開文件失敗,輸出錯誤信息并返回1perror("open");return 1;}// 輸出文件描述符printf("fd: %d\n", fd);// 刷新標準輸出緩沖區,確保輸出立即顯示fflush(stdout);// 關閉文件描述符close(fd);// 程序正常退出exit(0);
}
已知文件描述符的分配規則和重定向的原理,那么通過以上代碼理解。先關閉fd = 1
的文件,也就是標準輸出流文件。此時再打開文件時就會按照文件描述符的分配規則,將新打開的文件描述符設置為按照順序最小的下標,也就是剛關閉fd = 1
。然后當使用printf
進行打印的時候,該函數默認的拷貝到的文件fd
為1
,本來是向顯示屏進行打印,實際上因為新文件的占用,將內容拷貝進行新文件中。
這就是重定向,數組的下標不變,更改文件描述符的指針指向。
使用 dup2()
系統調用
在 Linux 中,dup2()
系統調用用于復制一個文件描述符,并將其指向另一個指定的文件描述符。這對于實現輸入輸出的重定向非常有用。
函數原型:
int dup2(int oldfd, int newfd);
oldfd
:現有的文件描述符。newfd
:目標文件描述符。
功能:
- 將
oldfd
指向的文件復制到newfd
。 - 如果
newfd
已經打開,則先關閉它。 - 返回新的文件描述符
newfd
,如果出錯則返回-1
。
示例代碼:
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>int main() {// 打開文件,獲取文件描述符int fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);if (fd == -1) {perror("打開文件失敗");return 1;}// 將標準輸出重定向到文件if (dup2(fd, STDOUT_FILENO) == -1) {perror("重定向標準輸出失敗");close(fd);return 1;}// 關閉原始文件描述符close(fd);// 現在 printf 的輸出將寫入 output.txtprintf("這行文本將被寫入到 output.txt 文件中。\n");return 0;
}
在上述示例中:
- 我們首先使用
open()
打開output.txt
文件,并獲取文件描述符fd
。 - 然后,使用
dup2()
將標準輸出(STDOUT_FILENO
)重定向到output.txt
文件。 - 關閉原始的文件描述符
fd
。 - 之后,所有通過
printf()
輸出的內容都會寫入output.txt
文件,而不是顯示器。
在 minishell 中添加重定向功能
#include <iostream>
#include <ctype.h>
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <cstring>
#include <unordered_map>
#include <sys/stat.h>
#include <fcntl.h>#define COMMAND_SIZE 1024 // 命令行最大長度
#define FORMAT "[%s@%s %s]# " // 提示符格式// ================== 全局數據結構聲明 ==================
// 1. 命令行參數表
#define MAXARGC 128
char *g_argv[MAXARGC]; // 存儲解析后的命令行參數
int g_argc = 0; // 參數個數// 2. 環境變量表
#define MAX_ENVS 100
char *g_env[MAX_ENVS]; // 存儲環境變量
int g_envs = 0; // 環境變量數量// 3. 別名映射表(當前代碼未完整實現)
std::unordered_map<std::string, std::string> alias_list;// 4. 重定向相關配置
#define NONE_REDIR 0 // 無重定向
#define INPUT_REDIR 1 // 輸入重定向 <
#define OUTPUT_REDIR 2 // 輸出重定向 >
#define APPEND_REDIR 3 // 追加重定向 >>int redir = NONE_REDIR; // 記錄當前重定向類型
std::string filename; // 重定向文件名// ================== 輔助函數聲明 ==================
// [省略部分環境獲取函數...]// ================== 環境初始化 ==================
void InitEnv() {extern char **environ;// 從父進程復制環境變量到g_env數組for(int i = 0; environ[i]; i++) {g_env[i] = strdup(environ[i]); // 使用strdup復制字符串g_envs++;}// 設置新環境變量(示例)g_env[g_envs++] = strdup("HAHA=for_test");g_env[g_envs] = NULL;// 更新進程環境變量for(int i = 0; g_env[i]; i++) {putenv(g_env[i]);}environ = g_env; // 替換全局environ指針
}// ================== 重定向處理核心函數 ==================
void TrimSpace(char cmd[], int &end) {// 跳過連續空白字符while(isspace(cmd[end])) end++;
}void RedirCheck(char cmd[]) {// 開始前先將文件操作的信息初始化redir = NONE_REDIR;filename.clear();int start = 0;int end = strlen(cmd)-1;// 從命令末尾向前掃描尋找重定向符號while(end > start) {if(cmd[end] == '<') { // 輸入重定向cmd[end] = '\0'; // 截斷命令字符串end++;TrimSpace(cmd, end); // 跳過空格redir = INPUT_REDIR;filename = cmd + end;break;}else if(cmd[end] == '>') {// 判斷是>>還是>if(end > 0 && cmd[end-1] == '>') { // 追加重定向cmd[end-1] = '\0'; // 截斷命令字符串end++; // 移動到>后的位置redir = APPEND_REDIR;} else { // 普通輸出重定向cmd[end] = '\0';end++;redir = OUTPUT_REDIR;}// 這時end在最后的運算符后面,然后用TrimSpace向后查找文件開頭字母TrimSpace(cmd, end);filename = cmd + end; // end為文件名開頭字母位置,直接cmd定位到文件名部分break;}else {end--; // 繼續向前掃描}}
}// ================== 命令執行 ==================
int Execute() {pid_t id = fork();if(id == 0) { // 子進程int fd = -1;switch(redir) {case INPUT_REDIR:fd = open(filename.c_str(), O_RDONLY);dup2(fd, STDIN_FILENO); // 重定向標準輸入break;case OUTPUT_REDIR:fd = open(filename.c_str(), O_CREAT|O_WRONLY|O_TRUNC, 0666);dup2(fd, STDOUT_FILENO); // 重定向標準輸出break;case APPEND_REDIR:fd = open(filename.c_str(), O_CREAT|O_WRONLY|O_APPEND, 0666);dup2(fd, STDOUT_FILENO);break;default: // 無重定向不做處理break;}if(fd != -1) close(fd); // 關閉不再需要的文件描述符execvp(g_argv[0], g_argv); // 執行程序exit(EXIT_FAILURE); // exec失敗時退出}// 父進程等待子進程int status = 0;waitpid(id, &status, 0);lastcode = WEXITSTATUS(status); // 記錄退出狀態return 0;
}// ================== 主循環 ==================
int main() {InitEnv(); // 初始化環境變量while(true) {PrintCommandPrompt(); // 打印提示符char commandline[COMMAND_SIZE];if(!GetCommandLine(commandline, sizeof(commandline))) continue;RedirCheck(commandline); // 重定向解析if(!CommandParse(commandline)) continue; // 命令解析if(CheckAndExecBuiltin()) continue; // 內建命令Execute(); // 執行外部命令}return 0;
}
總結
通過深入探討文件描述符(fd)的使用,以及如何在 C 語言中實現文件的重定向功能,我們可以更好地理解 Linux 系統文件 I/O 的工作原理。掌握這些概念和技術,對于編寫高效、可靠的系統級程序具有重要意義。