實驗代碼可以看去年暑假的這篇文章:【Linux】進程間通信:詳解 VSCode使用 | 匿名管道
我們已經知道,進程從 execve 后的初始狀態開始,可以通過 mmap 改變自己的地址空間,通過 fork 創建新的進程,再通過 execve 執行新的程序——我們慢慢已經開始理解 “操作系統上的應用生態“ 并沒有魔法了。
本講內容:操作系統還必須給應用程序提供訪問操作系統對象的機制。當然,我們可以直接以 API 的形式提供,例如 Win32 API 包含 “RegOpenKeyEx” 訪問注冊表。這節課我們學習 UNIX 的 “Everything is a file” 帶來的方便 (和不便)。
上一篇文章當中我們講到過,邊寫代碼邊測試非常的重要,那么我們該如何實現測試呢
明確需求
- 我們需要一個狀態 (Everything is a state machine)
-
- tests 里有全部測試用例且程序還沒結束
- 需要遵守 libc 的約定
-
- tk_add_test 和 tk_register_hook 執行順序不確定
- System test 里還可能調用 atexit()
解決問題
- tk_register_hook 時立即創建一個進程
-
- 等程序 atexit() 的時候把 tests 送過來
- 程序之間交換數據:“serialization”(序列化)
Prompt: 有哪些對象 serialization 的通用機制?
常見的對象序列化機制包括:
- Java 序列化:使用
Serializable
接口。 - JSON:輕量級數據交換格式,廣泛用于 Web 開發。
- XML:可擴展標記語言,適用于復雜數據結構。
- Protocol Buffers:Google 的高效二進制序列化格式。
- Avro:Apache 的數據序列化系統,支持動態模式。
- MessagePack:二進制 JSON 格式,更高效。
- Thrift:Facebook 開發的跨語言序列化框架。
根據需求選擇合適的機制。
在這個例子里,因為是完全相同的進程地址空間
- 我們直接做了一個 “內存復制”,保留了 argv 中指向只讀數據的指針。
- 對于跨應用的對象序列化,則需要做字符串的 deep copy。
我們的做法:利用編程語言機制 hack
- 并不是好的 practice (但有時候需要)
方法一:請開發者主動調用 API
- 在 main 里 run_all_tests()
- 之前學習機 gtest 的時候用的就是這種方法
方法二:提供一個特別的編譯器
- JavaScript: 這個我懂
方法三:更好的編程語言
- JVMTI: Tool Interface
testkit: Writing test cases fearlessly! 這是用于實驗的第一個測試框架:支持單元測試和系統測試,自動注冊測試用例并在程序退出后運行。
最重要的特點是它使用簡單:只需要包含 testkit.h,并且鏈接 testkit.c 即可。
沒有測試過的代碼,都是有可能存在問題的!
操作系統中的對象
進程
- 進程 = 狀態機
- 進程管理 API: fork, execve, exit
連續的內存段
- 我們可以把 “連續的內存段” 看作一個對象
-
- 可以在進程間共享
- 也可以映射文件
- 內存管理 API: mmap, munmap, mprotect, msync
操作系統肯定還有其他對象的!
是如何訪問操作系統對象的呢,那通過文件來訪問操作系統的對象
- 文件像鍵盤顯示器也都可以理解為文件,哦我好像知道了
- 相當于是平時寫的代碼生成了程序和進程,跑在操作系統這個環境上
- 然后通過文件來訪問這些東西,讀取到的結果就是,例如是以我們顯示器也是一個文件,來顯示出來
7.1 文件描述符
文件和設備
文件:有 “名字” 的數據對象
- 字節流 (終端,random)
- 字節序列 (普通文件)
文件描述符
- 指向操作系統對象的 “指針”
-
- Everything is a file
- 通過指針可以訪問 “一切”
- 對象的訪問都需要指針
-
- open, close, read/write (解引用), lseek (指針內賦值/運算), dup (指針間賦值)
文件描述符:訪問文件的 “指針”
- open
-
- p = malloc(sizeof(FileDescriptor));
- close
-
- delete(p);
- read/write
-
- *(p.data++);
- lseek
-
- p.data += offset;
- dup
-
- q = p;
在去年暑假,我們手寫 shell 的時候,有詳細寫過這部分的代碼,感興趣的可以去看一下
訪問操作系統中的對象--文件描述符
- 總是分配最小的未使用描述符
- 0, 1, 2 是標準輸入、輸出和錯誤
- 新打開的文件從 3 開始分配
-
- 文件描述符是進程文件描述符表的索引
- 關閉文件后,該描述符號可以被重新分配
- Linux 下一切皆文件
進程能打開多少文件?
- ulimit -n (進程限制)
- sysctl fs.file-max (系統限制)
文件描述符中的 offset
文件描述符是 “進程狀態的” 的一部分
- 保存在操作系統中;程序只能通過整數編號訪問
- 文件描述符自帶一個 offset
Quiz: fork() 和 dup()(共享) 之后,文件描述符共享 offset 嗎?
- 這就是 fork() 看似優雅,實際復雜的地方
場景 | 是否共享 offset | 原因 |
獨立打開同一文件 | ? 不共享 | 每個 生成獨立文件表項 |
| ? 共享(繼承描述符) | 子進程復制父進程文件表項,所以他們打開同一文件的話,不會實現覆蓋,頂多出現交叉寫入 |
| ? 共享 | 描述符指向同一文件表項 |
文件描述符:文件描述符是指向操作系統對象的 “指針”——系統調用通過這個指針 (fd) 確定進程希望訪問操作系統中的哪個對象。我們有 open, close, read/write, lseek, dup 管理文件描述符。
Windows 中的文件描述符
Handle (把手;握把;把柄)
- 比 file descriptor 更像 “指針”
- 你有一個 “handle” 在我手上,我就可以更好地控制你
Windows 的進程創建
面向工程的設計
- 默認 handle 是不繼承的 (和 UNIX 默認繼承相反)
-
- 可以在創建時設置 bInheritHandles,或者運行時修改
- “最小權限原則”
- lpStartupInfo 用于配置 stdin, stdout, stderr
(參考) Windows進程創建的工程化設計核心要點
1. 默認不繼承句柄
- 安全設計:新進程默認不繼承父進程的資源訪問權限(句柄),防止意外泄露。
- 按需授權:通過參數
bInheritHandles
或運行時調整,顯式指定需共享的資源。2. 集中式配置入口
- 統一管理:
STARTUPINFO
結構體統一配置子進程的標準輸入/輸出/錯誤流,避免參數分散。- 模塊化擴展:通過結構化字段支持未來功能擴展,降低接口變動風險。
3. 安全與功能的工程權衡
- 安全優先:相比UNIX默認繼承的便利性,Windows更強調最小權限原則(僅開放必要權限)。
- 靈活控制:開發者可精準指定共享資源,平衡功能需求與安全風險。
Linux 引入了 O_CLOEXEC
- fcntl(fd, F_SETFD, FD_CLOEXEC)
//對fd進行各種操作,成功返回0,失敗返回-1設errno
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... ); //...表示可變長參數
/*cmd:
Adversory record locking:
F_SETLK(struct flock*) //設建議鎖
F_SETLKW(struct flock*) //設建議鎖,如果文件上有沖突的鎖,且在等待的時候捕獲了一個信號,則調用被打斷并在信號捕獲之后立即返回一個錯誤,如果等待期間沒有信號,則一直等待
F_GETLK(struct flock*) //嘗試放鎖,如果能放鎖,則不會放鎖,而是返回一個含有F_UNLCK而其他不變的l_type類型,如果不能放鎖,那么fcntl()會將新類型的鎖加在文件上,并把當前PID留在鎖上
Duplicating a file descriptor:
F_DUPFD (int) //找到>=arg的最小的可以使用的文件描述符,并把這個文件描述符用作fd的一個副本
F_DUPFD_CLOEXEC(int)//和F_DUPFD一樣,除了會在新的文件描述符上設置close-on-exec
F_GETFD (void) //讀取fd的flag,忽略arg的值
F_SETFD (int) //將fd的flags設置成arg的值.
F_GETFL (void) //讀取fd的Access Mode和其他的file status flags; 忽略arg
F_SETFL (long) //設置file status flags為arg
F_GETOWN(void) //返回fd上接受SIGIO和SIGURG的PID或進程組ID
F_SETOWN(int) //設置fd上接受SIGIO和SIGURG的PID或進程組ID為arg
F_GETOWN_EX(struct f_owner_ex*) //返回當前文件被之前的F_SETOWN_EX操作定義的文件描述符R
F_SETOWN_EX(struct f_owner_ex*) //和F_SETOWN類似,允許調用程序將fd的I/O信號處理權限直接交給一個線程,進程或進程組
F_GETSIG(void) //當文件的輸入輸出可用時返回一個信號
F_SETSIG(int) //當文件的輸入輸出可用時發送arg指定的信號
*//*…:
可選參素,是否需要得看cmd,如果是加鎖,這里應是struct flock*
struct flock {short l_type; //%d Type of lock: F_RDLCK(讀鎖), F_WRLCK(寫鎖), F_UNLCK(解鎖)short l_whence; //%d How to interpret l_start, 加鎖的位置參考標準:SEEK_SET, SEEK_CUR, SEEK_ENDoff_t l_start; //%ld Starting offset for lock, 加鎖的起始位置off_t l_len; //%ld Number of bytes to lock , 鎖定的字節數pid_t l_pid; // PID of process blocking our lock, (F_GETLK only)加鎖的進程號,,默認給-1
};
*/
- 文件描述符:文件描述符是指向操作系統對象的 “指針”——系統調用通過這個指針 (fd) 確定進程希望訪問操作系統中的哪個對象。
Filesystem Hierarchy Standard --FHS
- enables software and user to predict the location of installed files and directories: 例如 macOS 就不遵循 FHS
只要拷對了文件,操作系統就能正常執行啦!
- 創建 UEFI 分區,并復制正確的 Loader
- 創建文件系統
-
- mkfs (格式化)
- cp -ar 把文件正確復制 (保留權限)
-
- 注意 fstab 里的 UUID
- 就得到了一個可以正常啟動的系統盤!
- 運行時掛載必要的其他文件系統
-
- 磁盤上的 /dev, /proc, ... 都是空的
- mount -t proc proc /mount/point 可以 “創建” procfs
對于Linux制作系統盤的實驗前文有寫過,感興趣的可以找著看一下Linux 系統盤制作 | 引導加載器(GRUB 為例)| mount
操作系統給了我們很多API,可以創建各種各樣的對象
任何 “可讀寫” 的東西都可以是文件
真實的設備
- /dev/sda
- /dev/tty
虛擬的設備 (文件)
- /dev/urandom (隨機數), /dev/null (黑洞), ...
-
- 它們并沒有實際的 “文件”
- 操作系統為虛擬的設備 (文件)實現了特別的 read 和 write 操作
-
-
- /drivers/char/mem.c
- 發現的一些有意思的事情:甚至可以通過 /sys/class/backlight 控制屏幕亮度
-
- procfs 也是用類似的方式實現的
管道:一個特殊的 “文件” (流)
- 由讀者/寫者共享
-
- 讀口:支持 read
- 寫口:支持 write
匿名管道
(Unix得圖靈獎的一個重要原因🙂
- 返回兩個文件描述符
- 進程同時擁有讀口和寫口
-
- 看起來沒用?不,fork 一下就有用了 (testkit)
- 然后甚至還可以再結合dup,來實現對 0 1 2 指向替代也可以
- ls | wc -l (這就是一個管道的應用)
?理解管道的意義:
- UNIX 管道:UNIX 管道 (pipe) 是一種典型的進程間通信機制,允許數據在不同的進程之間單向流動。
- 管道可以被視為一種特殊的文件,其中一個進程將數據寫入管道的一端,而另一個進程從另一端讀取數據。
- 管道實現了父子進程當中,先后的同步。
?sum
進程管理
- fork, execve, waitpid, exit
內存管理
- mmap, munmap, mprotect, msync
文件管理
- open, close, read, write, lseek, dup
- mkfifo 命名管道,可以屬于某一個文件
- pipe 匿名管道。
- proc
- sys
- Everything is a File ...?
例如來和 ag 結合,cat readme.md | ag 就可以問AI 啦
一切皆文件的好處
一套 API 訪問所有對象
- 一切都可以 | grep
-
- Introducing
ag -g
- Introducing
同時,UNIX Shell 的語法廣受詬病
- 稍大一些的項目就應該用更好的語言 (Python, Rust!)
- 但是:We all love quick & dirty!
ls -l /proc/*/fd/* 2>/dev/null | awk '{print $(NF-2), $(NF-1), $NF}'
grep -s VmRSS /proc/*[0-9]/status | awk '{sum += $2} END {print sum " kB"}'
Prompt: 什么是 glob patterns? 有什么實際中常用的擴展?
Glob patterns 是一種用于匹配文件路徑名的模式,常用于文件搜索和操作。常見的通配符包括:
*
:匹配任意數量的字符(包括零個)?
:匹配單個字符[]
:匹配指定范圍內的字符
實際中常用的擴展包括:
**
:遞歸匹配任意層級的子目錄{}
:匹配多個模式,如{a,b,c}
匹配a
、b
或c
!(pattern)
:排除指定模式
這些擴展增強了 glob patterns 的靈活性和功能性。
字節流
- 順序讀/順序寫
-
- 沒有數據時等待
- 典型代表:管道
- 但是會存在操作系統對offset默默地移動...
字節序列
- 其實就有一點點不方便了
-
- 需要到處 lseek 再 read/write
-
-
- mmap 不香嗎?指針指哪打哪
- madvise, msync 提供了更精細的控制
-
lseek
用于重新定位文件偏移量(文件指針位置),支持三種定位模式:
SEEK_SET
:絕對定位(從文件頭開始偏移)SEEK_CUR
:相對定位(從當前位置偏移)SEEK_END
:從文件末尾偏移補充說明:該函數不會觸發任何物理 I/O 操作,僅修改內核中的文件偏移量記錄。
優點
- 優雅,文本接口,就是好用
缺點
- 和各種 API 緊密耦合
-
- A fork() in the road
- im ple men ta tion 實現
- al ter na tives 替代方案
- con flates 合并
如果我fork出的父子進程,同時寫Hello和world,它會是覆蓋呢還是實現延續呢?
- 進程獨立性
fork()
會創建子進程,父子進程的內存空間是獨立的,但文件描述符(如標準輸出)是共享的。因此,兩者的輸出會混合到同一個目標中,但不會直接覆蓋(每個write
操作是原子的) - 輸出順序不確定
父子進程的執行順序由操作系統調度決定。可能的結果包括:
-
- 父進程先輸出“Hello”,子進程后輸出“World” →
HelloWorld
- 子進程先輸出“World”,父進程后輸出“Hello” →
WorldHello
- 兩者交替執行,導致字符交錯(如
HWeolrllod
)
- 父進程先輸出“Hello”,子進程后輸出“World” →
- 對高速設備不夠友好(why)
-
- 額外的延遲和內存拷貝
- 單線程 I/O
Any problem in computer science can be solved with another level of indirection. (Butler Lampson)
- Windows NT: Win32 API → POSIX 子系統
-
- Windows Subsystem for Linux (WSL)
- macOS: Cocoa API → BSD 子系統
- Fuchsia: Zircon 微內核 → POSIX 兼容層
兼容當然沒法做到 100%
- sysfs, procfs 就是沒法兼容
- 優雅的 WSL1 已經暴斃
-
- “Windows Subsystem for Linux”
- “Linux Subsystem for Windows” (wine)
- 對硬件做抽象,給應用程序提供服務
拓展: OpenHarmony
對于 硬件和軟件
- “初學FPGA,突然頓悟何為“硬件的并行化思維”的美妙,那一瞬之后,就像打通了任督二脈,之后不管是看代碼還是寫代碼都變得十分順暢。更關鍵的是我的認知也得到了提升:那是我第一次認知到不同思維模式會對coding產生如此之大的區別。
- 其實反過來各種語言也在(強迫)塑造人的思維模式,比如cuda之類的并行編程要求程序員轉換思維模式。HDL也一樣。人發明工具,然后被工具改變。
- 以除法為例,軟件工程師代碼的除法: int val=3300/256 ; 硬件工程師:int val = 3300》8;“