萬字整理 systemd 學習筆記

作者簡介:

偉林,中年碼農,從事過電信、手機、安全、芯片等行業,目前依舊從事 Linux 方向開發工作,個人愛好 Linux 相關知識分享。

sysvinit

Linux 在內核態啓動完成後,調用用戶態的 “init” 程序開始佈置整個用戶態的應用環境,init 在隨後根據配置文件調用文件系統中的初始化腳本。在這裏,唯一可以肯定的是任何 linux 發行版本第一個應用程序都是會去調用 init 程序,且 init 程序解析配置文件的方法都是一致的。而關於啓動腳本的組織形式和風格,在多個發行版本之間是各不相同、多種多樣的。

所以如果需要修改啓動腳本以用來加入一些自定義的模塊,需要先理解該 linux 文件系統的腳本架構。

Linux 腳本的啓動順序大概如下圖所示:

/etc/inittab

Linux 下爲什麼會要有個 init?用過 windows 9.x 的人應該知道有個批處理文件 autoexec.bat,用過 windows NT/2000 系統的人應該在控制面板中見過 system service 工具,它們的目的是相同的。只是比較起來 windows 下的這些東西功能太弱(當然用法也更簡單)。

init 是 Linux 啓動的最後一步,它幫助用戶完成每次啓動系統都必須完成的一些重複性任務,如加載文件系統、各類網絡服務等等程序;它還有一個重要用途,讓用戶自定義系統運行環境,只啓動需要的進程,關閉不用的進程,釋放內存和處理器資源,讓系統運行得更快更穩。

常見的 init 用戶程序有兩種:一種完整版的 init 程序 sysvinit,sysvinit 軟件包提供了一系列開關機的命令,常見的有:hutdown、reboot、halt、poweroff、telinit、init。它們都可以達到關機或重啓的目的,但是每個命令的工作流程並不一樣。

另一種是 busybox 提供的精簡版 init :

init 會按任務表執行我們下的命令,這個任務表就是 / etc/inittab 文件。下面查看一個 inittab 文件的例子:

“/etc/inittab” 文件每一行定義一個指令,其基本格式爲:id:runlevels:action:command。各字段的詳解如下:

id:是任意一個名稱(具體是什麼並不重要);

runlevels:是一個數字串(代表運行等級);
一條命令可以是一個等級下執行,也可以是多個等級下執行,例如:“1:2345:respawn:/sbin/getty 38400 tty1 “,該命令在等級2345下都會被執行。
根據Linux的定義,Init 可以啓動到8個不同的運行級別上:0-6 和 S 或 s。
運行級別可以由超級用戶通過 telinit 命令來轉換,此命令可以將轉換信號傳遞給 init,告訴它切換到哪個運行級別。
運行級別 0,1,和 6爲系統保留的專用運行級別。
運行級別 0 用來關機,運行級別 6 用來重啓,運行級別 1 用來使計算機進入單用戶模式。
運行級別 S 不是給我們直接使用的,更多是爲進入運行級別 1 時運行某些可執行腳本時被調用。下面是幾個運行級的簡單介紹:
# 0 - 關機(千萬不要把initdefault 設置爲0 )
# 1 - 單用戶模式
# 2 - 多用戶,但是沒有 NFS
# 3 - 完全多用戶模式
# 4 - 沒有用到
# 5 - X11
# 6 - 重啓(千萬不要把initdefault 設置爲6 )

action:描述何時執行命令;
action,告訴init執行的動作,即如何處理process字段指定的進程。action字段允許的值及對應的動作分別爲:
1)respawn:如果process字段指定的進程沒有運行,則啓動該進程,init不等待處理結束,而是繼續掃描inittab文件中的後續進程,當這樣的進程終止時,init會重新啓動它,如果這樣的進程已經運行,則什麼也不做。
2)wait:啓動process字段指定的進程,並等到處理結束纔去處理inittab中的下一記錄項。
3)once:啓動process字段指定的進程,不等待處理結束就去處理下一記錄項。當這樣的進程終止時,也不再重新啓動它,在進入新的運行級別時,如果這樣的進程仍在運行,init也不重新啓動它。
4)boot:只有在系統啓動時,init才處理這樣的記錄項,啓動相應進程,並不等待處理結束就去處理下一個記錄項。當這樣的進程終止時,系統也不重啓它。
5)bootwait:系統啓動後,當第一次從單用戶模式進入多用戶模式時處理這樣的記錄項,init啓動這樣的進程,並且等待它的處理結束,然後再進行下一個記錄項的處理,當這樣的進程終止時,系統也不重啓它。
6)powerfail:當init接到斷電的信號(SIGPWR)時,處理指定的進程。
7)powerwait:當init接到斷電的信號(SIGPWR)時,處理指定的進程,並且等到處理結束纔去檢查其他的記錄項。
8)off:如果指定的進程正在運行,init就給它發SIGTERM警告信號,在向它發出信號SIGKILL強制其結束之前等待5秒,如果這樣的進程不存在,則忽略這一項。
9)ondemand:功能通respawn,不同的是,與具體的運行級別無關,只用於runlevel字段是a、b、c的那些記錄項。
10)sysinit:指定的進程在訪問控制檯之前執行,這樣的記錄項僅用於對某些設備的初始化,目的是爲了使init在這樣的設備上向用戶提問有關運行級別的問題,init需要等待進程運行結束後才繼續。
11)initdefault:指定一個默認的運行級別,只有當init一開始被調用時才掃描這一項,如果rstate字段指定了多個運行級別,其中最大的數字是默認的運行級別,如果runlevel字段是空的,init認爲字段是0123456,於是進入級別6,這樣便陷入了一個循環,如果inittab文件中沒有包含initdefault的記錄項,則在系統啓動時請求用戶爲它指定一個初始運行級別。

command:指定執行的實際命令。該字段中進程可以是任意的守候進程、可執行腳本或程序,後面可以帶參數。

Inittab 是所有啓動腳本的總入口,各種版本 linux 的啓動腳本組織風格雖然不同,從這裏都能找到它的入口。

/etc/init.d/rc 和 /etc/rc.d

雖然不同 linux 版本啓動腳本的組織風格是不一樣,你也可以自定義出一套風格來,但是普遍來說 / etc/rc.d/rcN.d 是一種最常見的風格。如在 / etc/inittab 文件中,N 運行級別調用 / etc/rc.d/rc N 的命令:

以運行級別 5 爲例,init 將執行配置文件 inittab 中的以下這行:l5:5:wait:/etc/rc.d/rc 5 這一行表示以 5 爲參數運行 / etc/rc.d/rc,/etc/rc.d/rc 是一個 Shell 腳本,它接受 5 作爲參數,去執行 / etc/rc.d /rc5.d / 目錄下的所有的 rc 啓動腳本,/etc/rc.d/rc5.d / 目錄中的這些啓動腳本實際上都是一些鏈接文件,而不是真正的 rc 啓動腳本,真正的 rc 啓動腳本實際上都是放在 / etc/rc.d/init.d / 目錄下。而這些 rc 啓動腳本有着類似的用法,它們一般能接受 start、stop、 restart、status 等參數。

/etc/rc.d/rc5.d / 中的 rc 啓動腳本通常是 K 或 S 開頭的鏈接文件,對於以以 S 開頭的啓動腳本,將以 start 參數來運行。而如果發現存在相應的腳本也存在 K 打頭的鏈接,而且已經處於運行態了 (以 / var/lock/subsys / 下的文件作爲標誌),則將首先以 stop 爲參數停止這些已經啓動了的守護進程,然後再重新運行。這樣做是爲了保證是當 init 改變運行級別時,所有相關的守護進程都將重啓。

root@am57xx-evm:~# ls /etc/rc  
rc0.d/ rc1.d/ rc2.d/ rc3.d/ rc4.d/ rc5.d/ rc6.d/ rcS.d/ 
root@am57xx-evm:~# ls /etc/rc3.d/  
S01networking          S08rc.pvr              S10tiipclad-daemon.sh  S19nfscommon           S20thttpd              S30rng-tools           S97matrix-gui-2.0      S99rmnologin.sh
S02dbus-1              S10dropbear            S12rpcbind             S20hwclock.sh          S21avahi-daemon        S70lighttpd            S98thermal-zone-init
S03uim-sysfs           S10telnetd             S15mountnfs.sh         S20syslog              S22ofono               S95gdbserverproxy      S99gplv3-notice
root@am57xx-evm:~# ls -l /etc/rc3.d/
lrwxrwxrwx    1 root     root            20 Oct  3 21:07 S01networking -> ../init.d/networking
lrwxrwxrwx    1 root     root            16 Oct  3 21:07 S02dbus-1 -> ../init.d/dbus-1
lrwxrwxrwx    1 root     root            19 Oct  3 21:07 S03uim-sysfs -> ../init.d/uim-sysfs
lrwxrwxrwx    1 root     root            16 Oct  3 21:07 S08rc.pvr -> ../init.d/rc.pvr
lrwxrwxrwx    1 root     root            18 Oct  3 21:07 S10dropbear -> ../init.d/dropbear
lrwxrwxrwx    1 root     root            17 Oct  3 21:07 S10telnetd -> ../init.d/telnetd
lrwxrwxrwx    1 root     root            28 Oct  3 21:07 S10tiipclad-daemon.sh -> ../init.d/tiipclad-daemon.sh
lrwxrwxrwx    1 root     root            17 Oct  3 21:07 S12rpcbind -> ../init.d/rpcbind
lrwxrwxrwx    1 root     root            21 Oct  3 21:07 S15mountnfs.sh -> ../init.d/mountnfs.sh
lrwxrwxrwx    1 root     root            19 Oct  3 21:07 S19nfscommon -> ../init.d/nfscommon
lrwxrwxrwx    1 root     root            20 Oct  3 21:07 S20hwclock.sh -> ../init.d/hwclock.sh
lrwxrwxrwx    1 root     root            16 Oct  3 21:07 S20syslog -> ../init.d/syslog
lrwxrwxrwx    1 root     root            16 Oct  3 21:07 S20thttpd -> ../init.d/thttpd
lrwxrwxrwx    1 root     root            22 Oct  3 21:07 S21avahi-daemon -> ../init.d/avahi-daemon
lrwxrwxrwx    1 root     root            15 Oct  3 21:07 S22ofono -> ../init.d/ofono
lrwxrwxrwx    1 root     root            19 Oct  3 21:07 S30rng-tools -> ../init.d/rng-tools
lrwxrwxrwx    1 root     root            18 Oct  3 21:07 S70lighttpd -> ../init.d/lighttpd
lrwxrwxrwx    1 root     root            24 Oct  3 21:07 S95gdbserverproxy -> ../init.d/gdbserverproxy
lrwxrwxrwx    1 root     root            24 Oct  3 21:07 S97matrix-gui-2.0 -> ../init.d/matrix-gui-2.0
lrwxrwxrwx    1 root     root            27 Oct  3 21:07 S98thermal-zone-init -> ../init.d/thermal-zone-init
lrwxrwxrwx    1 root     root            22 Oct  3 21:07 S99gplv3-notice -> ../init.d/gplv3-notice
lrwxrwxrwx    1 root     root            22 Oct  3 21:07 S99rmnologin.sh -> ../init.d/rmnologin.sh

example 1

example 2

example 3

sysvinit 缺點

sysvinit 就是 System V 風格的 init 系統,顧名思義,它源於 System V 系列的 UNIX。最初的 linux 發行版幾乎都是採用 sysvinit 作爲 init 系統。sysvinit 用術語 runlevel 來定義 “預訂的運行模式”。比如 runlevel 3 是命令行模式,runlevel 5 是圖形界面模式,runlevel 0 是關機,runlevel 6 是重啓。sysvinit 會按照下面的順序按部就班的初始化系統:

激活 udev 和 selinux
設置定義在 /etc/sysctl.conf 中的內核參數
設置系統時鐘
加載 keymaps
啓用交換分區
設置主機名(hostname)
根分區檢查和 remount
激活 RAID 和 LVM 設備
開啓磁盤配額
檢查並掛載所有文件系統
清除過期的 locks 和 PID 文件
最後找到指定 runlevel 下的腳本並執行,其實就是啓動服務。

除了負責初始化系 統,sysvinit 還要負責關閉系統,主要是在系統關閉是爲了保證數據的一致性,需要小心地按照順序進行任務的結束和清理工作。另外,sysvinit 還提供了很多管理和控制系統的命令,比如 halt、init、mesg、shutdown、reboot 等等。

sysvinit 的優點是概念簡單。特別是服務 (service) 的配置,只需要把啓動 / 停止服務的腳本鏈接接到合適的目錄就可以了。sysvinit 的另一個重要優點是確定的執行順序,腳本嚴格按照順序執行(sysvinit 靠腳本來初始化系統),一個執行完畢再執行下一個,這非常有益於錯誤排查。

同時,完全順序執行任務也是 sysvinit 最致命的缺陷。如果 linux 系統只用於服務器系統,那麼漫長的啓動過程可能並不是什麼問題,畢竟我們是不會經常重啓服務器的。但是現在 linux 被越來越多的用在了桌面系統中,漫長的啓動過程對桌面用戶來說是不能接受的。除了啓動慢,sysvinit 還有一些其它的缺陷,比如不能很好的處理即插即用的設備,對網絡共享磁盤的掛載也存在一定的問題,於是 init 系統開始了它的進化之旅。

SystemD

systemd 的主要優點:

systemd 提供了和 sysvinit 兼容的特性。系統中已經存在的服務和進程無需修改。這降低了系統向 systemd 遷移的成本,使得 systemd 替換現有初始化系統成爲可能。

root@am57xx-evm:~# ls -l /sbin/init 
lrwxrwxrwx    1 root     root            20 Oct  3 21:08 /sbin/init -> /lib/systemd/systemd
root@am57xx-evm:~#

爲了與傳統的 SysV 兼容,如果將 systemd 以 init 名稱啓動,並且 "PID≠1",那麼它將執行 telinit 命令並將所有命令行參數原封不動的傳遞過去。這樣對於普通的登錄會話來說,無論是調用 init 還是調用 telinit 都是等價的。

當作爲系統實例運行時,systemd 將會按照 system.conf 配置文件以及 system.conf.d 配置目錄中的指令工作;當作爲用戶實例運行時,systemd 將會按照 user.conf 配置文件 以及 user.conf.d 配置目錄中的指令工作。

systemd 提供了比 upstart 更激進的並行啓動能力,採用了 socket / D-Bus activation 等技術啓動服務。一個顯而易見的結果就是:更快的啓動速度。

爲了減少系統啓動時間,systemd 的目標是:儘可能啓動更少的進程 儘可能將更多進程並行啓動

upstart 的並行方式:

upstart 增加了系統啓動的並行性,從而提高了系統啓動速度。但是在 upstart 中,有依賴關係的服務還是必須先後啓動。比如任務 A,B,(C,D) 因爲存在依賴關係,所以在這個局部,還是串行執行。

systemd 的並行方式:

systemd 能夠更進一步提高併發性,即便對於那些 upstart 認爲存在相互依賴而必須串行的服務,比如 Avahi 和 D-Bus 也可以併發啓動。在 systemd 中,所有的任務都同時併發執行,總的啓動時間被進一步降低爲 T1。可見 systemd 比 upstart 更進一步提高了並行啓動能力,極大地加速了系統啓動時間。

當 sysvinit 系統初始化的時候,它會將所有可能用到的後臺服務進程全部啓動運行。並且系統必須等待所有的服務都啓動就緒之後,才允許用戶登錄。這種做法有兩個缺點:首先是啓動時間過長,其次是系統資源浪費。

某些服務很可能在 很長一段時間內,甚至整個服務器運行期間都沒有被使用過。比如 CUPS,打印服務在多數服務器上很少被真正使用到。您可能沒有想到,在很多服務器上 SSHD 也是很少被真正訪問到的。花費在啓動這些服務上的時間是不必要的;同樣,花費在這些服務上的系統資源也是一種浪費。

systemd 可以提供按需啓動的能力,只有在某個服務被真正請求的時候才啓動它。當該服務結束,systemd 可以關閉它,等待下次需要時再次啓動它。這有點類似於以前系統中的 inetd,並且有很多文章介紹如何把過去 inetd 管理的服務遷移到 systemd。

systemd 利用了 Linux 內核的特性即 cgroups 來完成跟蹤的任務。當停止服務時,通過查詢 cgroups,systemd 可以確保找到所有的相關進程,從而乾淨地停止服務。

cgroups 已經出現了很久,它主要用來實現系統資源配額管理。cgroups 提供了類似文件系統的接口,使用方便。當進程創建子進程時,子進程會繼承父進程的 cgroups 。因此無論服務如何啓動新的子進程,所有的這些相關進程都會屬於同一個 cgroups ,systemd 只需要簡單地遍歷指定的 cgroups 即可正確地找到所有的相關進程,將它們一一停止即可。

啓動掛載點:

傳統的 linux 系統中,用戶可以用 /etc/fstab 文件來維護固定的文件系統掛載點。這些掛載點在系統啓動過程中被自動掛載,一旦啓動過程結束,這些掛載點就會確保存在。這些掛載點都是對系統運行至關重要 的文件系統,比如 HOME 目錄。和 sysvinit 一樣,Systemd 管理這些掛載點,以便能夠在系統啓動時自動掛載它們。systemd 還兼容 /etc/fstab 文件,您可以繼續使用該文件管理掛載點。

自動掛載:

有時候用戶還需要動態掛載點,比如打算訪問 DVD 或者 NFS 共享的內容時,才臨時執行掛載以便訪問其中的內容,而不訪問光盤時該掛載點被取消 (umount),以便節約資源。傳統地,人們依賴 autofs 服務來實現這種功能。systemd 內建了自動掛載服務,無需另外安裝 autofs 服務,可以直接使用 systemd 提供的自動掛載管理能力來實現 autofs 的功能。

系統啓動過程是由 很多的獨立工作共同組成的,這些工作之間可能存在依賴關係,比如掛載一個 NFS 文件系統必須依賴網絡能夠正常工作。systemd 雖然能夠最大限度地併發執行很多有依賴關係的工作,但是類似 "掛載 NFS" 和 "啓動網絡" 這樣的工作還是存在天生的先後依賴關係,無法併發執行。對於這些任務,systemd 維護一個 "事務一致性" 的概念,保證所有相關的服務都可以正常啓動而不會出現互相依賴,以至於死鎖的情況。

units

systemd 把初始化過程中需要需要做的每件事都抽象成了配置單元,即 unit。

系統初始化需要做的事情非常多。需要啓動後臺服務,比如啓動 ssh 服務;需要做配置工作,比如掛載文件系統。這個過程中的每一步都被 systemd 抽象爲一個配置單元,即 unit。可 以認爲一個服務是一個配置單元,一個掛載點是一個配置單元,一個交換分區的配置是一個配置單元等等。systemd 將配置單元歸納爲以下一些不同的類型。然而,systemd 正在快速發展,新功能不斷增加。所以配置單元類型可能在不久的將來繼續增加。

下面是一些常見的 unit 類型:

service :代表一個後臺服務進程,比如 mysqld。這是最常用的一類。
socket :此類配置單元封裝系統和互聯網中的一個套接字 。當下,systemd 支持流式、數據報和連續包的 AF_INET、AF_INET6、AF_UNIX socket 。
        每一個套接字配置單元都有一個相應的服務配置單元 。
        相應的服務在第一個"連接"進入套接字時就會啓動(例如:nscd.socket 在有新連接後便啓動 nscd.service)。
device :此類配置單元封裝一個存在於 Linux 設備樹中的設備。
        每一個使用 udev 規則標記的設備都將會在 systemd 中作爲一個設備配置單元出現。
mount :此類配置單元封裝文件系統結構層次中的一個掛載點。Systemd 將對這個掛載點進行監控和管理。
        比如可以在啓動時自動將其掛載;可以在某些條件下自動卸載。
        Systemd 會將 /etc/fstab 中的條目都轉換爲掛載點,並在開機時處理。
automount :此類配置單元封裝系統結構層次中的一個自掛載點。
        每一個自掛載配置單元對應一個掛載配置單元 ,當該自動掛載點被訪問時,systemd 執行掛載點中定義的掛載行爲。
swap:和掛載配置單元類似,交換配置單元用來管理交換分區。
        用戶可以用交換配置單元來定義系統中的交換分區,可以讓這些交換分區在啓動時被激活。
target :此類配置單元爲其他配置單元進行邏輯分組。它們本身實際上並不做什麼,只是引用其他配置單元而已。
        這樣便可以對配置單元做一個統一的控制。這樣就可以實 現大家都已經非常熟悉的運行級別概念。
        比如想讓系統進入圖形化模式,需要運行許多服務和配置命令,這些操作都由一個個的配置單元表示,將所有這些配置單元 組合爲一個目標(target),就表示需要將這些配置單元全部執行一遍以便進入目標所代表的系統運行狀態。 
        (例如:multi-user.target 相當於在傳統使用 SysV 的系統中運行級別 5)
timer:定時器配置單元用來定時觸發用戶定義的操作,這類配置單元取代了 atd、crond 等傳統的定時服務。
snapshot :與 target 配置單元相似,快照是一組配置單元。它保存了系統當前的運行狀態。
path:文件系統中的一個文件或目錄。
scope:用於 cgroups,表示從 systemd 外部創建的進程。
slice:用於 cgroups,表示一組按層級排列的單位。slice 並不包含進程,但會組建一個層級,並將 scope 和 service 都放置其中。

每個配置單元都有一個對應的配置文件,系統管理員的任務就是編寫和維護這些不同的配置文件,比如一個 MySQL 服務對應一個 mysql.service 文件。這種配置文件的語法非常簡單,用戶不需要再編寫和維護複雜的系統腳本了。

unit 的配置文件一般在 / lib/systemd/system/、/usr/lib/systemd/user / 和 / run/systemd/generator/:

root@am57xx-evm:~# ls /lib/systemd/system/         
-.slice                                 emergency.target                        nfs-statd.service                       runlevel2.target                        systemd-backlight@.service              systemd-remount-fs.service
alsa-restore.service                    exit.target                             nss-lookup.target                       runlevel2.target.wants                  systemd-bootchart.service               systemd-resolved.service
alsa-state.service                      final.target                            nss-user-lookup.target                  runlevel3.target                        systemd-exit.service                    systemd-rfkill.service
root@am57xx-evm:~# 
root@am57xx-evm:~# ls /usr/lib/systemd/user/
basic.target          busnames.target       exit.target           printer.target        pulseaudio.socket     smartcard.target      sound.target          timers.target
bluetooth.target      default.target        paths.target          pulseaudio.service    shutdown.target       sockets.target        systemd-exit.service

可以通過下面的指令查詢 systemd 配置目錄:

root@am57xx-evm:~# pkg-config systemd --print-variables              
binfmtdir
catalogdir
modulesloaddir
pcfiledir
prefix
sysctldir
systemdshutdowndir
systemdsleepdir
systemdsystemconfdir
systemdsystemgeneratordir
systemdsystempresetdir
systemdsystemunitdir
systemdsystemunitpath
systemduserconfdir
systemdusergeneratordir
systemduserpresetdir
systemduserunitdir
systemduserunitpath
systemdutildir
systemgidmax
systemuidmax
sysusersdir
tmpfilesdir
root@am57xx-evm:~# pkg-config systemd --variable=systemdsystemunitdir   // unit dir
/lib/systemd/system
root@am57xx-evm:~# pkg-config systemd --variable=systemdsystemconfdir   // config dir
/etc/systemd/system
root@am57xx-evm:~# pkg-config systemd --variable=systemdsystemgeneratordir
/lib/systemd/system

systemctl cat 命令可以用來查看配置文件,那麼我們來看一下 sshd 配置文件的內容,分析下它每項配置的含義是什麼。

systemctl cat sshd.service 查看的 service 配置文件:

[Unit]
Description=OpenSSH server daemon   # 當前服務的簡單描述
Documentation=man:sshd(8) man:sshd_config(5)  # sshd是啓動腳本,sshd_config是配置文件
After=network.target sshd-keygen.service  # 啓動ssh服務之前會先啓動這兩個Unit
Wants=sshd-keygen.service  # 此Unit啓動成功與否不影響ssh服務的正常啓動

[Service]
Type=notify  # ssh服務啓動成功後會通知systemd,再啓動其他依賴服務
EnvironmentFile=/etc/sysconfig/sshd  # 指定ssh服務的環境參數配置文件
ExecStart=/usr/sbin/sshd -D $OPTIONS  # 啓動ssh服務執行的命令
ExecReload=/bin/kill -HUP $MAINPID  # 重啓ssh服務執行的命令
KillMode=process  # process表示只停止主進程,不停止子進程
Restart=on-failure  # 進程非正常退出時,包括信號終止和超時,會重啓服務
RestartSec=42s  # 上面Restart重啓之前需要等待42秒再重啓

[Install]
WantedBy=multi-user.target   # ssh服務所在的系統運行模式
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/5C2PbirEBxCGaoR0DgPmuw