掃盲 Linux&UNIX 命令行——從“電傳打字機”聊到“shell 腳本編程”

掃盲 Linux&UNIX 命令行——從“電傳打字機”聊到“shell 腳本編程”

本文目標讀者

雖然本文的標題號稱是【掃盲】,但俺相信:即使是一些 POSIX 系統的命令行【老手】,對本文中介紹的某些概念,可能也會有【欠缺】。
  因此,這篇教程既適合於命令行的新手,也值得某些【老手】看一看。

由於本文介紹的是 POSIX 系統中【通用的】概念與知識。因此,包括 Linux、BSD 家族、macOS 等各種系統的用戶,應該都能從中受益。
  (注:POSIX 是某種操作系統的標準/規範。各種 Linux 發行版以及所有的 UNIX 變種,包括 macOS,都屬於“POSIX 系統”)

如果你是這方面的【菜鳥】,並且想要掌握這個領域。【不要】企圖只看一遍就完全理解本文的內容(可能需要看好幾遍)。俺的建議是:要一邊看,一邊拿命令行的環境【實踐】一下。

一切都從【電傳打字機】開始說起

(說完了“引子”與“目標讀者”,開始切入正題)
  可能有些讀者會納悶——“聊命令行的基本概念”,爲啥要扯到“電傳打字機”?是不是扯得太遠了?
  俺來解釋一下:
  IT 行業的很多基本概念都來自於【歷史遺蹟】。有時候你覺得某些東西很奇怪(並納悶“爲啥會設計成這樣”);而當你搞清楚歷史的演變過程之後,自然就明白其中的原因。

在那遙遠的【電報時代】

在計算機誕生之前(二戰前),【電報】屬於高科技的玩意兒——它能夠瞬間把信息傳送到另一個城市(甚至傳送到大洋彼岸)。
  當年的電報線路,是以【字符】爲單位發送信息。在線路兩端使用【電傳打字機】,就可以自動地把對方發過來的字符打印出來。


(上世紀40年代的電傳打字機——用於電報網)

“回車/換行”的來歷

稍微懂點 IT 的同學,應該都聽說過“回車/換行”,洋文分別稱之爲“carriage return”&“line feed”。在編程領域,這兩個字符簡稱爲 \r\n
  爲啥會有這麼兩個玩意兒捏?
  因爲在電傳打字機時代,當打印完一行之後,需要用一個控制命令把“打印頭”復位(移到打印紙的左邊),然後再用另一個控制命令把“打印頭”往下移動一行。很自然地,這倆動作就對應了兩個控制字符(CR & LF),也就是所謂的“回車 & 換行”。

其它控制字符

如果你去留意一下 ASCII 字符表的開頭部分,前面那32個字符都是控制字符,很多都源於遙遠的【電報時代】。
  在本文後續的介紹中,還會再聊到這些“控制字符”。

終端(terminal/TTY)

歷史演變

“終端”一詞,洋文稱之爲“terminal”。有時候又被稱作 TTY,而 TTY 這個簡寫就來自剛纔介紹的【電傳打字機】(teletype printer)。
  因爲早期的大型機,其“終端”就是【電傳打字機】。那時候的終端,也稱作【硬件終端】。

爲啥會有“終端”這個概念捏?你依然需要了解歷史的變遷。
  最早期的計算機(大型機)是【單任務】滴——也就是說,每次只能幹一件事情。
  到了60年代,出現了一個【革命性】的飛躍——發明了【多任務】系統,當時叫做“time-sharing”(分時系統)。有了“分時系統”,就可以讓多個人同時使用一臺大型機。而爲了讓多個人同時操作這臺大型機,就引入了【終端】的概念。每一臺大型機安裝多個終端,每個操作員都在各自的終端上進行操作,互不干擾。

(跑題)“約翰·麥卡錫”其人

聊到這裏,稍微跑題一下:
  最早的“分時系統”由 IT 超級大牛“約翰·麥卡錫”(John McCarthy)設計。此人不僅僅是“分時系統它爹”,還是“Lisp 語言它爹”,另外還參與設計了編程語言“ALGOL 60”。而這個“ALGOL 60”編程語言雖然知道的人不多,但該語言深刻影響了後續的 Ada、BCPL、C、Pascal…
  爲了讓你體會這隻大牛到底有多牛。俺引用另一個牛人保羅·格雷漢姆(《黑客與畫家》作者)的觀點——他認爲在所有編程語言中, Lisp 與 C 是兩座無法超越的高峯。而“約翰·麥卡錫”親自發明瞭 Lisp 語言,然後又深刻地影響了 C 語言。
  另外,麥卡錫這隻大牛還參與創立了“MIT 人工智能實驗室”與“斯坦福人工智能實驗室”。前者涌現出一大批早期的黑客,其中包括大名鼎鼎的 Richard Stallman(此人開創了:自由軟件運動、GNU 社區、GCC、GDB、GNU Emacs …)。


(超級大牛約翰·麥卡錫)

【遠程】終端

跑題結束,言歸正傳。
  “終端”的好處不光是“多任務”,而且還可以讓用戶在【遠程】進行操作。這種情況下,“終端”通過 modem(調制解調器)與“主機”相連。這種玩法很類似於——互聯網普及初期的撥號上網。示意圖如下:


(通過 modem 實現的【遠程】終端)

最早的“終端”,本質上就是“電傳打字機”——以“打字機”作爲輸入;以“打印紙”作爲輸出。這類終端,比較經典的是如下這款:


(Teletype Model 33 ASR)

到了上世紀70年初,終於有了帶【屏幕】的遠程終端。DEC 公司的 VT05 是第一款基於 CRT 顯示器的遠程終端。


(VT05 終端)

內部結構示意圖

下面這張是大型機時代,“終端”與“進程”通訊的示意圖。
  圖中的 UART 是洋文“Universal Asynchronous Receiver and Transmitter”的縮寫(相關維基百科鏈接在“這裏”)。LDISC 是洋文“line discipline”的簡寫(相關維基百科鏈接在“這裏”)。
  通俗地說,UART 用來處理物理線路的字符傳輸(比如:“錯誤校驗”、“流控”、等);LDISC 用來撮合底層的“硬件驅動”與上層的“系統調用”,並完成某些“控制字符”的處理與翻譯。


(TTY 示意圖1:使用【硬件終端】的大型機內部結構圖)

如今的含義

如今,“終端”一詞的含義已經擴大了——用來指:基於【文本】的輸入輸出機制。
  在本文後續的章節中, terminal 與 TTY 這兩個術語基本上是同義詞。

終端的3種【緩衝模式】——字符模式、行模式、屏模式

字符模式(character mode)

又要說回到【電傳打字機】。
  在本文開頭,已經聊過這個玩意兒,並且提到——它是基於【字符】傳輸滴。也就是說,操作員每次在“電傳打字機”上按鍵,對應的字符會立即通過線路發送給對方。這就是最傳統的【字符模式】
  通俗地說,“字符模式”也就是【無緩衝】的模式。

行模式(line mode)

不客氣地說,“字符模式”是非常傻逼滴!因爲如果你不小心按錯鍵,這個錯誤也會立即發送出去。
  比如說,你在輸入一串很長的命令,結果輸到半當中,敲錯一個按鍵,整個命令就廢了——要重新再輸入一遍。
  所以,當早期的程序員對“字符模式”實在忍無可忍之後,終於發明了【行模式】。
  【行模式】也叫做“行緩衝”。也就是說,終端會把你當前輸入的這行先緩衝在本地。只有當你最終按了【回車鍵】,纔會把這一整行發送出去。如果你不小心敲錯了一個字符,可以趕緊用“退格鍵”刪掉重輸這個字符。
  因此,這種模式稱之爲【行緩衝】。

順便說一下:
  早期的標準鍵盤,【沒有】方向鍵(“上下左右”這4個鍵)。不信的話,可以去看本文前面貼的那張“Teletype Model 33 ASR”的照片。
  因爲無論是“字符模式”還是“行模式”,都沒這個需求。

屏模式(screen mode/block mode)

“行模式”進一步的發展就是【屏模式】。這個玩意兒也叫“全屏緩衝”,顧名思義,終端會緩衝當前屏幕的內容。
  在這種模式下,用戶可以利用方向鍵,操縱光標(cursor)在屏幕上四處遊走。
  開發這種類型的軟件,比較複雜——程序員至少需要做如下工作:
\1. 保存整個屏幕的狀態
\2. 根據鍵盤輸入,操縱光標(cursor)移動
\3. 控制屏幕的哪些區域是光標可達,哪些是不可達;
\4. 對於光標可達的部分,控制哪些是“可編輯”,哪些是“只讀”;
\5. 根據“光標移動”以及某些“特定的按鍵”(比如“翻頁鍵”),重新繪製屏幕

  後來,爲了簡化”屏模式“的編程,專門搞了一個叫做 curses 的編程庫。如今的“ncurses 庫”就是從 curses 衍生出來滴(前面加了一個 n 表示 new)。


(“重編譯 Linux 內核”的配置界面,基於 ncurses 實現)

前面說了——早期的鍵盤【沒】方向鍵。有了這個【屏模式】之後,鍵盤上纔開始增加了“方向鍵”(所以“方向鍵”位於鍵盤的擴展區)

小結

上述這三種模式,第1種基本淘汰(僅限於極少數場景);第3種用得也不多。與本文關係比較密切的,其實是【第2種】——行模式。
  爲了加深你的印象,用 cat 命令來舉例(注:這個命令其實與“貓”【無關】,而是 concatenate 的簡寫)
  大部分情況下,都是用它來顯示某個文件的內容,比如說:cat 文件名 。但如果你運行 cat【沒】加任何參數,那麼它就會嘗試讀取你在終端的輸入,然後把讀到的文本再原樣輸出到終端。


(動畫:演示“行模式”的效果)

在上述動畫中,你的輸入並【沒有】直接傳遞給 cat 進程。要一直等到你按下【回車鍵】,cat 進程才收到你的輸入,並立即打印了輸出。

終端的【回顯】

“回顯”是啥?

在剛纔那個 gif 動畫中,當俺逐個輸入 test 的每個字母,這些字母也會逐個顯示在屏幕上。這種做法叫做【回顯】。

◇“回顯”的打開與關閉(啓用/禁用)

雖然“回顯”很人性化,但某些特殊的場合是【不想】“回顯”滴,比如當你輸入密碼/口令的時候。
  因此,終端提供了某種機制,使得程序能夠控制“回顯”的啓用/禁用。
  對於大多數終端,可以用【Ctrl + S】禁用“回顯”,然後用【Ctrl + Q】啓用“回顯”。
  如果你在禁用“回顯”的情況下輸入一些文本,當你重新啓用“回顯”的瞬間,這些文本會一起出現在屏幕上。

順便說一下:
  由於【Ctrl + S】在 Windows 上是很常見的組合鍵。某些菜鳥剛開始玩 Linux 命令行的時候,會習慣性地按這個組合鍵,結果就禁用了回顯。這時候,任何鍵盤輸入都沒有反應。菜鳥就以爲終端死掉了。

歷史演變

對於 Windows 用戶來說,【Ctrl + S】實在太常用了,很容易誤按。肯定有大量的用戶吐槽過 POSIX 終端的這個快捷鍵。
  那麼,爲啥要用這兩個快捷鍵來控制“回顯”捏?俺又要第 N 次說到【電傳打字機】了。
  由於這玩意兒的輸出是【打印紙】,其速率比較【慢】。一旦“對方發送字符的速率”高於“自己這邊的打印速率”,就需要向對方發一個控制信號,讓對方暫停發送;等到自己這邊打印完了,再發送另一個控制字符,通知對方繼續。
  (注:上述這種玩法,通信領域行話稱之爲“流量控制/流控”)
  當年用來表示“暫停發送”的控制字符,對應的就是【Ctrl + S】;用來“恢復發送”的控制字符,也正是【Ctrl + Q】。

(早期的)系統控制檯/物理控制檯(system console)

(前面說了)在【沒】發明“分時系統”之前,當時的計算機只能執行【單任務】。因此,那時候的大型機只有【一個】操作界面,稱之爲【控制檯】。
  話說那時的“控制檯”,真的是一個臺子(參見下圖)。


(上世紀50年代,IBM 公司 704 大型機的控制檯)

後來發明了“分時系統”。如剛纔所說——“分時系統”使得大型機可以具備多個終端。在這種情況下,你可以把“控制檯”通俗地理解爲“本地終端”,而【不】是“控制檯”的那些終端,稱之爲“遠程終端”。
  在那個年代,計算機屬於【非常非常稀缺】的資源。於是擁有大型機的公司,就可以【出租計算資源】,獲得一筆相當可觀的收入。他們把大型機的某個“遠程終端”租給外來人員使用,然後根據“時間/空間”收取費用。由於資源的稀缺性,當年的 CPU 是按【秒】計費,而內存是按【KB】計費。
  由於“遠程終端”可能會被【外人】使用,因此對“遠程終端”的【權限】要進行一些限制。如果要進行一些高級別的操作(比如“關閉整個系統”),就只能限制在【控制檯】(本地終端)進行。有些公司爲了安全起見,還會把“控制檯”單獨鎖在某個“secured room”裏面。


(上世紀60年代,DEC 公司 PDP-7 小型機的控制檯)

(如今的)虛擬控制檯(virtual console)

到了 PC 時代,傳統意義上的【控制檯】已經看不到了。但 console 這個術語保留了下來。

從“物理 console”到“虛擬 console”

早期大型機的 console 是【獨佔】硬件滴——“鍵盤/顯示器”固定用於某個 console 滴。
  【現代】的 POSIX 系統,衍生出“virtual console”的概念——可以讓幾個不同的 console【共用】一套硬件(鍵盤/顯示器)。“virtual”一詞就是這麼來滴。
  再重複嘮叨一下:不論是早期的“物理控制檯”還是後來的“虛擬控制檯”,都屬於廣義上的“終端”。

舉例:Linux 的 virtual console

假設你的 Linux 系統沒安裝圖形界面(或者默認不啓用圖形界面),當系統啓動完成之後,你會在屏幕上看到一個文本模式的登錄提示。這個界面就是 virtual console 的界面。
  在默認情況下,Linux 內置了【6個】virtual console 用於命令行操作,然後把第7個 virtual console 預留給圖形系統。你可以使用 Alt + FnCtrl + Alt + Fn 在這幾個 console 之間切換(注:上述所說的 Fn 指的是 F1、F2… 之類的功能鍵)。

虛擬控制檯的【內部結構】


(TTY 示意圖2:【虛擬控制檯】的內部結構圖)

終端模擬器(terminal emulator)

請注意上面那張示意圖,圖中出現了一個【終端模擬器】,這就是本章節要說的東東。
  如果你對比前面的【TTY 示意圖1】與【TTY 示意圖2】的變化,會發現——“UART & UART 驅動”沒了,然後多了這個【終端模擬器】。
  多出來的這個玩意兒相當於加了一個【抽象層】,模擬出早期硬件終端的效果,因此就【無需改動】系統內核中的其它部分,比如:LDISC(line discipline
  請注意,這個場景下的“終端模擬器”位於操作系統【內核】。換句話說,它屬於【內核態】的模擬器。正是因爲它處於這個地位,所以能夠在“驅動”&“LDISC”之間進行協調。

僞終端(PTY/pseudotty/pseudoterminal)

從“文本模式”到“圖形模式”

前面講的那些,都是【文本模式】(文本界面)。
  話說到了上世紀80年代,隨着【圖形界面】的興起,就出現某種需求——想在圖形界面下使用“【文本】終端”。於是就出現了“僞終端”的概念。
  通俗地說,“僞終端”就是用某個圖形界面的軟件來模擬傳統的“文本終端”的各種行爲。前面說了,TTY 這個縮寫相當於“終端”的同義詞;因此“pseudotty” 就衍生出 PTY 這個縮寫。

從“【內核態】終端模擬器”到“【用戶態】終端模擬器”

在上一個章節中,emulator 運行在系統內核中,因此是“內核態模擬器”;
  等到後來搞“僞終端”的時候,就直接把這個玩意兒從【內核態】轉到【用戶態】——讓它直接運行在【桌面環境】。如此一來,用戶就可以直接在桌面環境中使用“終端模擬器”。
  當“終端模擬器”變爲【用戶態】,它就【無法】直接與“鍵盤驅動 or 顯卡驅動”打交道。在這種情況下,由“GUI 系統”(比如:X11)負責與這些驅動打交道,然後再把用戶的輸入輸出轉交給“終端模擬器”。

下面這張示意圖是 xterm。別看它長得醜,它的出現也算是“里程碑”了。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-kui0EH6F-1582007010409)(https://lh5.googleusercontent.com/2GRHQZetZPor718nRQB0D4JPscPyssaw4c2ufUOa-EF73PWb-SmTFNJYjILwZ5znmpzX6qEdHrT5-1R83TmZAKUVzr4IhfdRMYmpZQmZC16qqIcaaJOESrJUX798-qtrtUSIE9lEUH4)]
(xterm——“圖形化終端模擬器”的祖師爺)

內部結構示意圖

很多人把“emulator”與“PTY”混爲一談。實際上兩者處於【不同】層次。
  在操作系統內部(內核),PTY 分爲兩部分實現,分別叫做“PTY master” & “PTY slave”。master 負責與“terminal emulator”打交道;而用戶通過 emulator 裏面的 shell 啓動的其它進程,則與 slave 打交道。
  在這個環節中,“PTY slave”又進一步縮寫爲“PTS”。如果你用 ps 命令查看系統中的所有進程,經常會看到 PTS 之類的字樣,指的就是這個玩意兒。對普通用戶而言,看到的是“終端模擬器”的界面,至於 PTY 內部的 master & slave,通常是感覺不到滴。

爲了讓大夥兒更加直觀,再放一張 PTY 的結構示意圖。


(TTY 示意圖3:【僞終端】的內部結構圖)

shell——命令行解釋器

費了好多口水,咱們終於聊到 shell 了。
  順便吐槽一下:
  掃盲命令行的教程,很少會像俺這樣,從最基本的概念說起。其導致的後果就是——很多人(甚至包括很多 Linux 程序員)都搞不清“shell、terminal、console、TTY、PTY、PTS”這些概念到底有啥區別。
  在《如何【系統性學習】——從“媒介形態”聊到“DIKW 模型”》一文中,俺特別強調了【基本概念/基礎知識】的重要性。這也就是俺爲啥前面要費這麼多口水的原因。

shell VS terminal

前面所說的“終端”(terminal),本質上是:基於【文本】的輸入輸出機制。它並【不】理解具體的命令及其語法。
  於是就需要引入 shell 這個玩意兒——shell 負責解釋你輸入的命令,並根據你輸入的命令,執行某些動作(包括:啓動其它進程)。

常見 shell 舉例

常見的 shell 包括如下這些(爲避免排名糾紛,按字母序列出):

bash
csh
fish
ksh
zsh

在維基百科的“這個頁面”,列出了各種各樣的 shell 及其功能特性的對照表。
  如今影響力最大的 shell 是 bash(沒有之一)。其名稱源自“Bourne-again shell”,是 GNU 社區對 Bourne shell 的重寫,使之符合自由軟件(GPL 協議)。
  本文後續章節對 shell 的舉例,如果沒有做特殊說明,均指 bash 這個 shell。

shell 的基本功能

顯示【命令行提示符】

當你打開一個 shell,會看閃爍的光標左側顯示一個東東,那個玩意兒就是【命令行提示符】(參見下圖)


(截圖中的“命令行提示符”包含了:用戶名、當前路徑、$分隔符)

很多 shell 的“命令行提示符”都會包含【當前路徑】。當你用 cd 命令切換目錄,提示符也會隨之改變。這有助於你搞清楚當前在哪個目錄下,可以有效避免誤操作
  下面這張圖演示了——“命令行提示符”隨着當前目錄的變化而變化。

大部分 shell 都可以讓你自定義這個【命令行提示符】,使之顯示更多的信息量。
  比如說,可以讓它顯示:當前的時間、主機名、上一個命令的退出碼…
  (注:如果你需要開多個【遠程】終端,去操作多個【不同】的系統,“主機名”就蠻有用)

解析用戶輸入的【命令行】

假設你想看一下 /home 這個目錄下有哪些子目錄,可以在 shell 中運行了如下命令:

ls /home

當你輸入這串命令並敲回車鍵,shell 會拿到這一行,然後它會分析出,空格前面的 ls 是一個外部命令,空格後面的 /home 是該命令的參數。
  然後 shell 會啓動這個外部命令對應的進程,並把上述參數作爲該進程的啓動參數。

◇內部命令 VS 外部命令

(剛纔提到了【外部命令】這個詞彙,順便解釋一下)
  通俗地說,“內部命令”就是內置在 shell 中的命令;而“外部命令”則對應了某個具體的【可執行文件】。
  當你在 shell 中執行“外部命令”,shell 會啓動對應的可執行文件,從而創建出一個“子進程”;而如果是“內部命令”,就【不】產生子進程。
  那麼,如何判斷某個命令是否爲“外部命令”捏?
  比較簡單的方法是——用如下方式來幫你查找。如果某個命令能找到對應的可執行文件,就是“外部命令”;反之則是“內部命令”。

whereis 命令名稱

翻譯【通配符】

玩過命令行的同學,應該都知道:“星號”(*)與“問號”(?)可以作爲通配符,用來模糊匹配文件名。
  當你在 shell 中執行的命令包含了上述兩個通配符,實際上是 shell 先把”通配符“翻譯成具體的文件名,然後再傳給相應命令。

翻譯某些【特殊符號】

比如說:在 POSIX 系統中,通常用 ~ 來表示當前用戶的【主目錄】(home 目錄)。
  如果你在 shell 中用到了 ~ 這個符號,shell 會先把該符號翻譯成“home 目錄的【全路徑】”,然後再傳給相應命令。

翻譯【別名】

很多 POSIX 的 shell 都支持用 alias 命令設置別名(把一個較長的命令串,用一個較短的別名來表示)。
  設置了別名之後,當你在 shell 中使用“別名”,由 shell 幫你翻譯成原先的命令串。

舉例:
  在《掃盲 netcat(網貓)的 N 種用法——從“網絡診斷”到“系統入侵”》一文中,俺使用如下命令創建了 nc-tor 這個別名。

alias nc-tor='nc -X 5 -x 127.0.0.1:9050'

設置完之後,當你在 shell 中執行了這個 nc-tor 命令,shell 會把它自動翻譯成 nc -X 5 -x 127.0.0.1:9050

歷史命令

大部分 shell 都會記錄歷史命令。你可以使用某些設定的快捷鍵(通常是【向上】的方向鍵),重新運行之前執行過的命令。

自動補全

很多 shell 都具備自動補全的功能。
  該功能不僅指“命令”本身的自動補全,還包括對“命令的參數”進行自動補全。

操作“環境變量”

關於這部分,在下面的“環境變量”章節單獨聊。

“管道”與“重定向”

關於這部分,在下面的“管道”章節單獨聊。

“進程控制”與“作業控制”

關於這部分,在下面的“進程控制”與“作業控制”章節單獨聊。

進程的啓動與退出

進程的【啓動】及其【父子關係】

一般來說,每個“進程”都是由另一個進程啓動滴。如果“進程A”創建了“進程B”,則 A 是【父進程】,B 是【子進程】(這個“父子關係”很好理解——因爲完全符合直覺)
  有些同學會問,那最早的【第一個】進程是誰啓動滴?
  一般來說,第一個進程由【操作系統內核】(kernel)親自操刀運行起來;而 kernel 又是由“引導扇區”中的“boot loader”加載。

進程樹

在 POSIX 系統(Linux & UNIX),所有的進程構成一個【單根樹】的層次關係。進程之間的“父子關係”,體現在“進程樹”就是樹上的【父子節點】。
  你可以使用如下命令,查看當前系統的“進程樹”。

pstree


(“進程樹”的效果圖。注:爲了避免暴露俺的系統信息,特意【不】用自己系統的截圖)

初始進程

一般情況下,POSIX 系統的“進程樹”的【根節點】就是系統開機之後【第一個】創建的進程,並且其進程編號(PID)通常是 1。這個進程稱之爲“初始進程”。
  (注:上述這句話並【不夠】嚴密——因爲某些 UNIX 衍生系統的“進程樹”,位於根節點的進程【不是】“初始化進程”。這種情況與本文的主題沒太大關係,俺不打算展開討論)
  對於“大部分 UNIX 衍生系統”以及“2010年之前的 Linux 發行版”,系統中的“初始進程”名叫 init
  如今越來越多的 Linux 發行版採用 systemd 來完成系統引導之後的初始化工作。在這些發行版中,“初始進程”名叫 systemd

你可以用如下命令顯示“進程樹”中每個節點的“進程編號”(PID),然後就能看到編號爲 1 的“初始進程”。

pstree -p

進程的三種死法

關於進程如何死亡,大致有如下三種情況:

自然死亡
  如果某個進程把它該乾的事情都幹完了,自然就會退出。
  這種是最常見的情況,也是最優雅的死法。俺習慣稱作【自然死亡】。

自殺
  如果某個進程的工作幹到半當中,突然收到某個通知,讓它立即退出。
  這時候,進程會趕緊處理一些善後工作,然後自行了斷——這就是【自殺】。

它殺
  比“自殺”更粗暴的方式稱之爲【它殺】。也就是讓“操作系統內核”直接把進程幹掉。
  在這種情況下,進程【不會】收到任何通知,因此也【不】可能進行任何善後事宜。

(注:上述三種死法純屬比喻,以加深大夥兒的印象;不必太較真。十年前俺剛開博客,寫過幾篇帖子談“C++ 對象之死”,也用過類似比喻)
  關於“自殺&它殺”的方式,會涉及到【信號】。在下一個章節,俺會單獨討論【進程控制】,並會詳細介紹“信號”的機制。

“孤兒進程”及其“領養”

如果某個進程死了(退出了),而它的子進程還【沒】死,那麼這些子進程就被形象地稱之爲“孤兒”,然後會被上述提到的【初始進程】“領養”——“初始進程”作爲“孤兒進程”的父進程。
  對應到“進程樹”——“孤兒進程”會被重新調整到“進程樹根節點”的【直接下級】。

“進程控制”與“信號”

用【Ctrl + C】殺進程

爲了演示這個效果,你可以執行如下命令:

ping 127.0.0.1

如果是 Windows 系統裏的 ping 命令,它只會進行4次“乒操作”,然後就自己退出了;
  但對於 POSIX 系統裏面的 ping 命令,它會永遠運行下去(直到被殺掉)。
  當 ping 在運行的時候,只要你按下 Ctrl + C 這個組合鍵,就可以立即終止這個 ping 進程。

“Ctrl + C”背後的原理——【信號】(signal)

當你按下了 Ctrl + C 這個組合鍵,當前正在執行的進程會收到一個叫做【SIGINT】的信號。
  如果進程內部定義了針對該信號的處理函數,那麼就會去執行這個函數,完成該函數定義的一些動作。一般而言,該函數會進行一些善後工作,然後進程退出。
  如果進程【沒有】定義相應的處理函數,則會執行一個【默認動作】。對於 SIGINT 這個信號而言,默認動作就是“進程退出”。
  上述這2種情況,都屬於前面所說的自殺。這2種屬於【常規情況】。

下面再來說【特殊情況】——有時候 Ctrl + C【無法】讓進程退出。爲啥會這樣捏?
  假如說,編寫某個進程的程序員,定義了該信號的處理函數,但在這個函數內部,並【沒有】執行“進程退出”這個動作。那麼當該進程收到 SIGINT 信號之後,自然就【不會】退出。這種情況稱之爲——信號被該進程【屏蔽】了

【誰】發出“Ctrl + C”對應的信號?

很多人(包括很多玩命令行的老手)都有一個【誤解】——他們誤以爲是 shell 發送了 SIGINT 信號給當前進程。其實不然!
  在上述 ping 的例子中,當 ping 進程在持續運行之時,你的鍵盤輸入是關聯到 ping 進程的“標準輸入”(stdin)。在這種情況下,shell 根本【無法】獲取你的按鍵信息。
  實際上,是【終端】獲取了你的 Ctrl + C 組合鍵信息,併發送了 SIGINT 信號。因爲【終端】處於更底層,它負責承載你所有的輸入輸出。因此,它當然可以截獲用戶的某個特殊的組合鍵(比如:Ctrl + C),並執行某些特定的動作。
  聊到這裏,大夥兒會發現——
如果沒有正確理解“終端”與“shell”這兩者的關係,就會犯很多錯誤(造成很多誤解)。

有的讀者可能會問:“終端”如何知道【當前進程】是哪一個?(能想到這點,通常是比較愛思考滴)
  俺來解答一下:
  當 shell 啓動了某個進程,它當然可以拿到這個進程的編號(pid),於是 shell 會調用某個系統 API(比如 tcsetpgrp)把“進程編號”與 shell 所屬的“終端”關聯起來。
  當“終端”需要發送 SIGINT 信號時,再調用另一個系統 API(比如 tcgetpgrp),就可以知道當前進程的編號。

對比殺進程的幾個信號:SIGINT、SIGTERM、SIGQUIT、SIGKILL

SIGINT
  在大部分 POSIX 系統的各種終端上,Ctrl + C 組合鍵觸發的就是這個信號。
  通常情況下,進程收到這個信號後,做完相關的善後工作,就自行了斷(自殺)。

SIGTERM
  這個信號基本類似於 SIGINT。
  它是 killkillall 這兩個命令【默認】使用的信號。
  也就是說,當你用這倆命令殺進程,並且【沒有】指定信號類型,那麼 killkillall 用的就是這個 SIGTERM 信號。

SIGQUIT
  這個信號類似於前兩個(SIGINT & SIGINT),差別在於——進程在退出前會執行“core dump”操作。
  一般而言,只有程序員纔會去關心“core dump”這個玩意兒,所以這裏就不細聊了。

SIGKILL
  在殺進程的幾個信號中,這個信號是是最牛逼的(也是最粗暴的)。
  前面三個信號都是【可屏蔽】滴,而這個信號是【不可屏蔽】滴。
  當某個進程收到了【SIGKILL】信號,該進程自己【完全沒有】處理信號的機會,而是由操作系統內核直接把這個進程幹掉。
  此種行爲可以形象地稱之爲“它殺”。
  當你用下列這些命令殺進程,本質上就是在發送這個信號進行【它殺】。【SIGKILL】這個信號的編號是 9,下列這些命令中的 -9 參數就是這麼來滴。

kill -9 進程號
kill -KILL 進程號

killall -9 進程名稱
killall -KILL 進程名稱
killall -SIGKILL 進程名稱

爲了方便對照上述這4種,俺放一個表格如下:

信號名稱編號能否屏蔽默認動作俗稱SIGINT2YES進程自己退出自殺SIGTERM15YES進程自己退出自殺SIGQUIT3YES執行 core dump
進程自己退出自殺SIGKILL9NO進程被內核幹掉它殺

【它殺】的危險性與副作用

請注意:**【它殺】是一種比較危險的做法,可能導致一些【副作用】。**只有當你用其它各種方式都無法幹掉某個進程,才考慮用這招。
  有讀者在評論區問到了“它殺的副作用”,俺簡單解釋一下:
  一方面,當操作系統用這種方式殺掉某個進程,雖然可以把很多內存相關的資源釋放掉,但【內存之外】的資源,內核就管不了啦;另一方面,由於進程遭遇“它殺”,無法完成某些善後工作。
  基於上述兩點,就【有可能】會產生副作用。另外,“副作用的嚴重程度”取決於不同類型的軟件。無法一概而論。

舉例1:
  某個進程正在保存文件。這時候遭遇“它殺”可能會導致文件損壞。
  (注:雖然某些操作系統能做到“寫操作的原子性”,但數據存儲可能會涉及多個寫操作。當進程在作【多個】關鍵性寫操作時,遭遇它殺。可能導致數據文件【邏輯上】的損壞)

舉例2:
  還有更復雜的情況,比如涉及跨主機的網絡通訊。某個進程可能向【遠程】的某個網絡服務分配了某個遠程的資源,當進程“自然死亡 or 自殺”,它會在“善後工作”釋放這個資源;而如果死於內核的“它殺”,這個遠程的資源就【沒】釋放。

kill VS killall

這兩個的差別在於——前者用“進程號”,後者用”進程名“(也就是可執行文件名)。
  對於新手而言,
如果用 kill 命令,你需要先用 ps 命令打印出當前進程清單,然後找到你要殺的進程的編號;而如果要用 killall 命令,就比較省事(比較傻瓜化)。但萬一碰到有多個【同名】進程在運行,而你只想幹掉其中一個,那麼就得老老實實用 kill 了。

進程退出碼

任何一個進程退出的時候,都對應某個【整數類型】的“退出碼”。
  按照 POSIX 系統(UNIX & Linux)的傳統慣例——
當“退出碼”爲【零】,表示“成功 or 正常狀態”;
當“退出碼”【非零】,表示“失敗 or 異常狀態”。

暫停進程

剛纔聊“殺進程”的時候提到了“自殺 VS 它殺”。前者比較“溫柔”;而後者比較“粗暴”。
  對於暫停進程,也有“溫柔 & 野蠻”兩種玩法。而且也是用 kill 命令發信號。

【溫柔】式暫停(SIGTSTP)

kill -TSTP 進程編號

這個【SIGTSTP】信號類似前面提及的【SIGINT】——
\1. 兩者默認都綁定到組合鍵(【SIGINT】默認綁定到組合鍵【Ctrl + C】;【SIGTSTP】默認綁定到組合鍵【Ctrl + Z】)
\2. 這兩個快捷鍵都是由【終端】截獲,併發出相應的信號(具體原理參見本章節的某個小節)
\3. 兩者都是【可】屏蔽的信號。也就是說,如果某個進程屏蔽了【SIGTSTP】信號,你就【無法】用該方式暫停它。這時候你就得改用【粗暴】的方式(如下)。

【粗暴】式暫停(SIGSTOP)

kill -STOP 進程編號

這個【SIGSTOP】信號與前面提及的【SIGKILL】有某種相同之處——這兩個信號都屬於【不可屏蔽】的信號。也就是說,收到【SIGSTOP】信號的進程【無法】抗拒被暫停(suspend)的命運。

與“殺進程”的風格類似——當你想要暫停某進程,應該先嚐試“溫柔”的方法,搞不定再用“粗暴”的方法(套用咱們天朝的老話叫“先禮後兵”)。

恢復進程

當你想要重新恢復(resume)被暫停的進程,就用如下命令(該命令發送信號【SIGCONT】)

kill -CONT 進程編號

引申閱讀

除了前面幾個小節提到的信號,POSIX 系統還支持其它一些信號,具體參見維基百科的“這個頁面”。

作業控制(job)

聊完了“進程控制”,再來聊“作業控制”。
  (注:這裏所說的“作業”是從洋文 job 翻譯過來滴)

啥是“作業”?

“作業”是 shell 相關的術語,用來表示【進程組】的概念(每個作業就是一組進程)。
  比如說,當你用“管道符”把若干命令串起來執行,這幾個命令對應的進程就被視作【一組】。
  (注:“管道符”的用法,後面某個章節會介紹)

同步執行(前臺執行) VS 異步執行(後臺執行)

大部分情況下,你在 shell 中執行的命令都是“同步執行”(或者叫“前臺執行”)。對於這種方式,只有當命令運行完畢,你纔會重新看到 shell 的“命令行提示符”。
  如果你以“異步執行”的方式啓動某個外部命令,在這個命令還沒有執行完的時候,你就可以重新看到“命令行提示符”。

請注意:
  對於【短】壽命的外部命令(耗時很短的外部命令),“同步/異步”兩種方式其實【沒】啥區別。比如 ls 命令通常很快就執行完畢,你就感覺不到上述兩種方式的差異。
  只有當你執行了某個【長】壽命的外部命令(其執行時間至少達到若干秒),上述這兩種方式纔會體現出差別。

到目前爲止,本文之前聊的命令執行方式,都屬於“同步執行”;如果想用【異步】,需要在整個命令的最末尾追加一個半角的 & 符號。

【同步】方式舉例
  下列命令以【同步】的方式啓動火狐瀏覽器,只有當你關閉了火狐,纔會重新看到 shell 的命令行提示符。

firefox

【異步】方式舉例
  下列命令以【異步】的方式啓動火狐瀏覽器。你剛敲完回車,就會重新看到 shell 的“命令行提示符”(此時火狐依然在運行)

firefox &

以“同步”方式啓動的進程,稱作“【前臺】進程”;反之,以“異步”方式啓動的進程,稱作“【後臺】進程”。

“前臺”切換到“後臺”

假設當前的 shell 正在執行某個長壽命的【前臺】進程,你可以按【Ctrl + Z】,就可以讓該進程變爲【後臺】進程——此時你立即可以看到“命令提示符”。
  只要你不是太健忘,應該記得前一個章節有提到過【Ctrl + Z】這個組合鍵——它用來實現”【溫柔】式暫停“,其原理是:向目標進程發送【SIGTSTP】信號。

“後臺”切換到“前臺”

假設當前 shell 正在執行某個後臺進程。由於該進程在【後臺】執行,此時有“命令提示符”,然後你在 shell 中執行 fg 命令,就可以把該後臺進程切換到【前臺】。

某些愛思考的同學會問了——如果同時啓動了【多個】“後臺進程”,fg 命令會切換哪一個捏?
  在這種情況下,fg 命令切換的是【最後啓動】的那個。

如果你有 N 個“後臺進程”,你想把其中的某個切換爲“前臺進程”,這時候就需要用到 jobs 命令。該命令與喬布斯同名 😃
  舉例:
  假設俺同時啓動了 vim 與 emacs 作爲後臺進程,先用 jobs 命令列出所有的後臺進程。假設該命令的輸出是如下這個樣子。

$ jobs
[1]  running    vim
[2]  running    emacs

在上述的終端窗口,中括號裏面的數字稱作“job id”。你可以用 fg 命令搭配“job id”,把某個後臺進程切換到前臺。
  (在本例中)如果你想切換 emacs 到前臺,就運行 fg %2,如果想切換 vim 就運行 fg %1(以此類推)

引申閱讀

想進一步瞭解“作業控制”,可以參考維基百科(這個鏈接)。

環境變量(environment variable)

“環境變量”是啥?

所謂的“環境變量”,你可以通俗理解爲某種【名值對】——每個“環境變量”都有自己的【名稱】和【值】。並且名稱必須是【唯一】滴。

如何添加並修改“環境變量”?

在 bash(或兼容 bash 的其它 shell),你可以用 export 設置環境變量。比如下面這個命令行設置了一個“環境變量”,其名稱是 abc,其值是 xyz

export abc=xyz

假如你要設置的【值】包含空格,記得用雙引號引用該值(示例如下)。

export abc="program think"

由於“環境變量”的名稱具有【唯一性】,當你設置【同名】的“環境變量”就等同於對它的【修改】。

如何查看“環境變量”?

設置完之後,你可以用 env 命令查看。該命令會列出【當前 shell】中的【全部】“環境變量”。

“環境變量”的【可見性】和【可繼承性】

某個進程設置的“環境變量”,其【可見性】僅限於該進程及其子進程(也就是“進程樹”中,該進程所在的那個枝節)。
  基於上述的【可見性】原則,你在某個 shell 中設置的“環境變量”,只在“該 shell 進程本身”,以及通過該 shell 進程啓動的“其它子進程”,才能看到。

另外,如果系統關機,所有進程都會退出,那麼你採用上一個小節(export 方式)設置的“環境變量”也就隨之消失了。
  爲了讓某個“環境變量”永久生效,需要把相應的 export 命令添加到該 shell 的初始化配置文件中。對於 bash 而言,也就是 ~/.bashrc 或者 ~/.profile
  估計有些同學會問:上述這兩個初始化配置文件,有啥差別捏?
  俺如果有空,會單獨寫一篇關於 bash 的定製教程,到時候再聊這個話題。

“環境變量”有啥用?

通俗地說,“環境變量”是某種比較簡單的“IPC 機制”(進程通訊機制),可以讓兩個進程共享某個簡單的文本信息。
  舉例:
  很多知名的軟件(比如:curl、emacs)都支持“以環境變量設置代理”。
  如果你按照它的約定,在 shell 中設置了約定名稱和格式的“環境變量”,然後在【同一個】shell 中啓動這個軟件,(由於環境變量的【可繼承性】)該軟件就會看到這個“環境變量”,並根據“環境變量”包含的信息,設置代理。

“標準流”(standard stream)與“重定向”(redirection)

進程的3個“標準流”

在 POSIX 系統(Linux & UNIX)中,每個進程都內置了三個“標準流”(standard stream),分別稱作:“標準輸入流”(stdin),“標準輸出流”(stdout),“標準錯誤輸出流”(stderr)。
  當進程啓動後,在默認情況下,stdin 對接到終端的【輸入】;stdout & stderr 對接到終端的【輸出】。示意圖如下:


(三個【標準流】的示意圖)

如果你是程序員,俺補充一下:
  當你在程序中打開某個文件,會得到一個“文件描述符”(洋文叫“file descriptor”,簡稱 fd)。fd 本身是個整數,程序員可以通過 fd 對該文件進行讀寫。
  而進程的三個【標準流】,就相當於是三個特殊的 fd。當進程啓動時,操作系統就已經把這三個 fd 準備好了。
  由於這三個玩意兒是預先備好滴,所以它們的數值分別是:0、1、2(參見上圖中 # 後面的數字)。

演示“標準流”的實際效果

在本文前面的某個章節,俺已經用 gif 動畫演示了終端的“行模式”。
  動畫中的 cat 命令同樣可以用來演示“標準輸入輸出”。俺把那個動畫再貼一次。


(動畫:“標準輸入輸出”的效果)

請注意,第1行 test 是針對 cat 進程的【輸入】,對應於【stdin】(你之所以能看到這行,是因爲前面所說的【終端回顯】)
  第2行 testcat 進程拿到輸入文本之後的原樣輸出,對應於【stdout】。

“標準流”的【重定向

所謂的【重定向】大體上分兩種:

1. 【輸入流】重定向
  把某個文件重定向爲 stdin;此時進程通過 stdin 讀取的是該文件的內容。
  這種玩法使用小於號(<

2. 【輸出流】重定向
  把 stdout 重定向到某個文件;此時進程寫入 stdout 的內容會【覆蓋 or 追加】到這個文件。
  這種玩法使用【單個】大於號(>)或【兩個】大於號(>>)。前者用於【覆蓋】文件內容,後者用於【追加】文件內容。

另外,有時候你會看到 2>&1 這種寫法。它表示:把 stderr 合併到 stdout。
  (注:前面俺提到過——stdout 是“數值爲 1 的文件描述符”;stderr 是“數值爲 2 的文件描述符”)

【重定向】舉例

cat 的例子
  下面這個命令把某個文件重定向到 cat 的 stdin。

cat < 文件名

很多菜鳥容易把上面的命令與下面的命令搞混淆。
  請注意:上面的命令用的是【輸入重定向】,而下面的命令用的是【命令行參數】。

cat 文件名

cat 命令還可以起到類似“文件複製”的效果。
  比如你已經有個 文件1,用下面這種玩法,會創建出一個內容完全相同的 文件2

cat < 文件1 > 文件2

某些同學可能會問了:既然能這麼玩,爲啥還需要用 cp 命令進行文件複製捏?
  原因在於:cat 的玩法,只保證內容一樣,其它的不管;而 cp 除了複製文件內容,還會確保“目標文件”與“源文件”具有相同的屬性(比如 mode)。

更多的例子
  在之前那篇《掃盲 netcat(網貓)的 N 種用法——從“網絡診斷”到“系統入侵”》,裏面介紹了十多種 nc 的玩法。很多都用到了【重定向】。

匿名管道(anonymous pipe)

“匿名管道”的【原理】

在大部分 shell 中,使用豎線符號(|)來表示【管道符】。用它來創建一個【匿名管道】,使得前一個命令(進程)的“標準輸出”關聯到後一個命令(進程)的“標準輸入”。

舉例

俺曾經在“這篇博文”中介紹過——如何用 netstat 查看當前系統的監聽端口。
  對於 Windows 系統,可以用如下命令:

netstat -an | find "LISTEN"

對於 POSIX 系統,可以用如下命令:

netstat -an | grep "LISTEN"

在上述兩個例子中,都用到了【管道符】。因爲 netstat -an 這個命令的輸出可能會很多,先把它的輸出通過【匿名管道】丟給某個專門負責過濾的命令(比如:POSIX 的 grep 或 Windows 的find)。當這個過濾命令拿到 netstat 的輸出內容,再根據你在命令行參數中指定的【關鍵字】(也就是上述例子中的 LISTEN),過濾出包含【關鍵字】的那些【行】。
  最終,你看到的是“過濾命令”(grep 或 find)的輸出。

【串聯的】匿名管道(chained pipeline)

前面的例子,可以用來列出當前系統中所有的監聽端口。
  現在,假設你運行了 Tor Browser,然後想看看它到底有沒有開啓 9150 這個監聽端口,那麼你就可以在上述命令中進行【二次過濾】(具體命令大致如下)。這就是所謂的【串聯】。

netstat -an | grep "LISTEN" | grep "9150"

“匿名管道”與“作業”(進程組)

用“匿名管道”串起來的多個進程,構成一個“作業”(這點前面提到了)。
  你可以嘗試執行某個長壽命的,帶管道符的命令行,然後用 Ctrl + Z 切到後臺,再執行 jobs 看一下,就能看出——該命令行對應的【多個】進程屬於同一個 job。

批處理(batch)

啥是“批處理”?

通俗地說就是:同時執行多個命令。
  爲了支持“批處理”,shell 需要提供若干語法規則。而且不同類型的 shell,用來搞“批處理”的語法規則也存在差異。
  在本章節中,俺以 bash 來舉例。

【無】條件的“批處理”

如果你把多個命令寫在同一行,並且命令之間用半角分號隔開,這種玩法就屬於【無條件】的批處理執行。
  舉例:
  假設當前目錄下有一個 abc.txt 文件,然後要在當前目錄下創建一個名爲 xxx 的子目錄,並把 abc.txt 移動到這個新創建的子目錄中。你可以用如下方式搞定(只用【一行】命令)

mkdir ./xxx/; mv abc.txt ./xxx/

爲啥這種方式叫做“【無條件】批處理”捏?因爲不管前一個“子命令”是否成功,都會繼續執行下一個“子命令”。

請注意:
  雖然俺上述舉例只使用了兩個“子命令”,但實際上這種玩法可以把 N 個“子命令”串起來。

【有】條件的“批處理”

與“無條件”相對應的,當然是“有條件”啦。
  這種玩法的意思是——後一個“子命令”是否執行,取決於【前一個】“子命令”的結果(成功 or 失敗)。
  (注:如何界定“成功/失敗”,請參見前面某個章節聊到的【進程退出碼】)
  【有】條件的批處理,常見的方式有兩種,分別是【邏輯與】、【邏輯或】。

邏輯與(語法:&&
  只要前面的某個“子命令”【失敗】了,就【不再】執行後續的“子命令”。
  舉例:
  還是拿前一個小節的例子。如下方式使用了“邏輯與”。如果創建子目錄失敗,就【不再】執行“移動文件”的操作

mkdir ./xxx/ && mv abc.txt ./xxx/

邏輯或(語法:||
  只要前面的某個“子命令”【成功】了,就【不再】執行後續的“子命令”。
  舉例:
  把上述例子進一步擴充,變爲如下:

mkdir ./xxx/ && mv abc.txt ./xxx/ || echo "FAILED!!!"

這個有點複雜,俺稍微解釋一下:
  你把前面兩句看作一個【整體】。其執行的邏輯參見前面所說的“邏輯與”。然後這個“整體”與後面的那句 echo 再組合成【邏輯或】的關係。
  也就是說,如果前面的“整體”成功了,那麼就【不】執行 echo(【不】打印錯誤信息);反之,如果前面的“整體”失敗了,就會打印錯誤信息。

shell 腳本

雖然前一個章節拿 bash 來舉例。但其實有很多其它類型的 shell 都支持類似的“批處理”機制。
  只要某個 shell 支持剛纔所說的【有條件批處理】的機制,它就已經很接近【編程語言】了。
  於是很自然地,那些 shell 的作者就會把 shell 逐步發展成某種【腳本語言】的解釋器。然後就有了如今的“shell script”(shell 腳本)和“shell 編程”。
  由於“shell 編程”這個話題比較大。哪怕俺只聊 bash 這一類 shell 的編程,也足夠寫上幾萬字的博文。考慮到本文已經很長了,這個話題就不再展開。
  對此感興趣的同學,可以參考俺分享的電子書。具體參見電子書清單的如下幾本(這幾本都位於【IT類 / 操作系統 / 使用教程】分類目錄下)
Shell 腳本學習指南》(Classic Shell Scripting)
Linux 與 UNIX Shell 編程指南》(Linux and UNIX Shell Programming)
高級 Bash 腳本編程指南》(Advanced Bash-Scripting Guide)
  上述這幾本,都屬於俺在《如何【系統性學習】——從“媒介形態”聊到“DIKW 模型”》中提到的【入門性讀物】。最後一本書的名稱中雖然有“高級”字樣,不過別怕——其內容的5個部分,有4部分都是在講基礎的東西,只有最後一部分才稍微有一點點深度。

★結尾

由於這篇涉及的內容比較雜,跨度也比較大。可能會有一些俺沒覆蓋到的地方。歡迎在博客留言中補充。
關係。
  也就是說,如果前面的“整體”成功了,那麼就【不】執行 echo(【不】打印錯誤信息);反之,如果前面的“整體”失敗了,就會打印錯誤信息。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章