使用 eBPF 實現基於 DWARF 的堆棧遍歷

原文爲 https://www.parca.dev/[1] 的 Javier Honduvilla Coto[2],文章地址 DWARF-based Stack Walking Using eBPF[3],副標題Deep dive into the new DWARF-based stack unwinder using eBPF,即深度解析使用eBPF基於DWARF的棧展開。文章爲機翻,以及人工矯正。譯者水平有限,有疑問請看原文。

parca 項目介紹

parca 是一款基於 eBPF 技術實現的 CPU、內存觀測產品。可以持續分析 CPU 和內存使用情況產品,細化到行號和整個時間。節省基礎架構成本、提高性能並提高可靠性。GitHub 地址:https://github.com/parca-dev/parca

原文:

採樣 CPU 分析器週期性地獲取目標進程的堆棧,比如用 C、C++、Rust 等語言編寫的進程,可能比大家想象的要複雜一些。最普遍的問題是因爲缺少frame pointer幀指針

我們已經開發了一個改進的堆棧遍歷器 :Parca[4] continuous profiling  Agent[5],即使在省略了幀指針的情況下也能工作。

x86_64 中的堆棧

x86_64架構除了描述它的指令集和其他一些重要特徵外,還定義了數據在其應用二進制接口或簡稱 ABI[6] 中應如何佈局的規則。該規範顯示了該架構下的堆棧應該如何設置。

當這段代碼被執行時,不同的call指令將把返回地址壓入堆棧中。一旦函數返回,CPU 將讀取返回地址並跳轉到該地址,繼續執行該函數的調用點。

由於沒有額外的信息,不可能可靠地生成堆棧跟蹤。可能有其他的值,比如函數的本地數據,被存儲在堆棧中,可能看起來像函數地址。這就是幀指針要解決的問題。

Can I have a (frame) pointer?

如下僞代碼爲例,假設沒有編譯器優化

int top(void) {
    for(;;) { }
}


int c1(void) {
    top();
}


int b1(void) {
    c1();
}


int a1(void) {
    b1();
}


int main(void) {
  a1();
}

使用這方法來遍歷堆棧,我們需要保留指向上一幀的指針。在 x86 體系結構中,這通常在幀指針$rbp中。由於函數可能調用其他函數,因此必須在函數進入時存儲該寄存器,並在函數退出時恢復該寄存器。

這是通過所謂的function prologue函數序言 [7] 來實現的,在函數入口處,它可能看起來像這樣

push $rbp # saves the stack frame pointer
mov $rbp, $rsp # sets the current stack pointer to the frame pointer

function epilogue函數序言在函數返回處

pop $rbp # restores the function's frame pointer
ret # pops the saved return address and jumps to it

如果我們用幀指針編譯並運行上面的 C 代碼,堆棧將具有遍歷堆棧所需的所有信息。有效地調用不同的函數會創建一個需要遍歷的鏈表。

反彙編使用幀指針編譯的代碼

# compiled with `gcc sample.c -o sample_with_frame_pointers -fno-omit-frame-pointer`
$ objdump -d ./sample_with_frame_pointers
0000000000401106 <top>:
  401106:       55                      push   %rbp
  401107:       48 89 e5                mov    %rsp,%rbp
  40110a:       eb fe                   jmp    40110a <top+0x4>

000000000040110c <c1>:
  40110c:       55                      push   %rbp
  40110d:       48 89 e5                mov    %rsp,%rbp
  401110:       e8 f1 ff ff ff          call   401106 <top>
  401115:       90                      nop
  401116:       5d                      pop    %rbp
  401117:       c3                      ret

0000000000401118 <b1>:
  401118:       55                      push   %rbp
  401119:       48 89 e5                mov    %rsp,%rbp
  40111c:       e8 eb ff ff ff          call   40110c <c1>
  401121:       90                      nop
  401122:       5d                      pop    %rbp
  401123:       c3                      ret

0000000000401124 <a1>:
  401124:       55                      push   %rbp
  401125:       48 89 e5                mov    %rsp,%rbp
  401128:       e8 eb ff ff ff          call   401118 <b1>
  40112d:       90                      nop
  40112e:       5d                      pop    %rbp
  40112f:       c3                      ret

0000000000401130 <main>:
  401130:       55                      push   %rbp
  401131:       48 89 e5                mov    %rsp,%rbp
  401134:       e8 eb ff ff ff          call   401124 <a1>
  401139:       b8 00 00 00 00          mov    $0x0,%eax
  40113e:       5d                      pop    %rbp
  40113f:       c3                      ret

上面的例子代碼中的 native stack 的內容是在頂部函數運行時用幀指針編譯的

要遍歷堆棧,我們必須遵循上面生成的鏈表,讀取每個保存的$rbp之前壓入的值,這將使我們的堆棧幀,直到$rbp爲零,意味着已經到達了堆棧的末尾。

這很好,因爲它允許我們以很低的成本計算出堆棧跟蹤。對於編譯器實現者來說,添加它也相對容易,而且一般來說,只需要相當少量的周邊基礎設施就可以使它工作。

儘管有這些優點,但我們所依賴的許多代碼並不是用幀指針編譯的。我們中的許多人依賴於我們的 Linux 發行版應用程序和庫,其中絕大多數選擇省略幀指針。即使你使用框架指標編譯程序,動態或靜態鏈接你的發行版本所提供的任何庫,也可能會讓你無法僅使用幀指針來正確展開堆棧。

我們不會深入探討在某些環境中禁用幀指針的原因以及與之相關的細微差別,但我們認爲必須逐個應用地對它們的開銷進行基準測試。禁用幀指針帶來的成本也應該考慮在內。

反彙編不使用幀指針編譯的代碼

# compiled with `gcc sample.c -o sample_without_frame_pointers -fomit-frame-pointer`
$ objdump -d ./sample_without_frame_pointers
[...]
0000000000401106 <top>:
  401106:       eb fe                   jmp    401106 <top>

0000000000401108 <c1>:
  401108:       e8 f9 ff ff ff          call   401106 <top>
  40110d:       90                      nop
  40110e:       c3                      ret

000000000040110f <b1>:
  40110f:       e8 f4 ff ff ff          call   401108 <c1>
  401114:       90                      nop
  401115:       c3                      ret

0000000000401116 <a1>:
  401116:       e8 f4 ff ff ff          call   40110f <b1>
  40111b:       90                      nop
  40111c:       c3                      ret

000000000040111d <main>:
  40111d:       e8 f4 ff ff ff          call   401116 <a1>
  401122:       b8 00 00 00 00          mov    $0x0,%eax
  401127:       c3                      ret
[...]

二者差異

top:
-       push   %rbp
-       mov    %rsp,%rbp
        jmp    40110a <top+0x4>
c1:
-       push   %rbp
-       mov    %rsp,%rbp
        call   401106 <top>
        nop
-       pop    %rbp
        ret
b1:
-       push   %rbp
-       mov    %rsp,%rbp
        call   40110c <c1>
        nop
-       pop    %rbp
        ret
a1:
-       push   %rbp
-       mov    %rsp,%rbp
        call   401118 <b1>
        nop
-       pop    %rbp
        ret
main:
-       push   %rbp
-       mov    %rsp,%rbp
        call   401124 <a1>
        mov    $0x0,%eax
-       pop    %rbp
        ret

假設我們在分析 c1 的執行過程,堆棧可能如下所示:

top 運行時上述代碼的 native stack 的內容

我們需要一些其他信息或硬件支持,以便能夠可靠地展開堆棧。

硬件方法

我們可以使用一些硬件設施來展開堆棧,例如 Intel 的 Last Branch Record (LBR)[8].LBR 產生起始地址和目的地址對FROM_IPTO_IP。我們可以使用它們來建立堆棧跟蹤。他的缺點就是他能記錄的深度有限。

雖然 LBR 用途廣泛且功能強大,但我們決定不將其用於 CPU 分析,因爲並非每個虛擬化環境都提供此功能,而且它是英特爾特有的。這些缺點延伸到其他感興趣的供應商特定的處理器特徵,例如 Intel Processor Trace (PT)[9]

一場特殊的邂逅

你可能會想,我怎麼可能編譯沒有幀指針的 C++ 應用程序,並且異常仍然工作得很好?那麼 Rust 呢?在 Rust 中,默認情況下幀指針是禁用的,但是調用panic() 會顯示完整且正確的堆棧跟蹤。

爲了使 C++ 異常無論二進制代碼是如何編譯的都能工作,以及添加一些其他必要的工具來使它們發揮作用,編譯器可以發出一些元數據來指示如何展開堆棧。該信息提供程序計數器到關於如何恢復所有寄存器的指令的映射。

更多詳情參見 DWARF debugging information format[10] 和 x86_64 ABI[11] 兩篇文章。

DWARF’s Call Frame Information (CFI)

CFI 的主要目標是提供如何在我們代碼執行的任何部分恢復前一幀的每個寄存器的答案。直接存儲一個包含每個程序計數器和所有寄存器及其位置(例如它們是否已被壓入堆棧)的表,將生成巨大的展開表。

因此,此格式儘量緊湊,僅包含所需的信息。它使用各種技術來達到此效果,例如

回溯表以 CFI 格式編碼,採用我們需要計算的操作碼的形式。它有兩個主要的。第一層是在 VM 中編碼的狀態機。這有助於壓縮性能良好的重複模式,並允許更緊湊地表示某些數據,因爲在某些情況下,有一個專門的操作碼佔用 1、2 或 4 個字節,而不是一直使用 4 個字節。未壓入堆棧的寄存器可能不會出現在表中。

我稱之爲第二層的是一個特殊的操作碼,它包含另一組操作碼,包含我們需要計算的任意表達式。這兩個層次之間的主要區別是,對於第一層次,我們只需要一個堆棧來記憶和恢復寄存器(分別爲DW_CFA_remember_stateDW_CFA_restore_state),對於第二層,我們需要計算任意圖靈完整表達式。因此,我們需要一個成熟的 VM 來計算任何表達式。

在 BPF 中實現 VM 並不太實際,因此我們決定採用一種實用的方法,首先對 2 個表達式進行硬編碼,這 2 個表達式 [13] 在我們評估的大多數二進制文件中出現的次數超過 50%。我們有一些關於如何進一步改進表達式支持的想法,但是這篇博客文章已經太長了。

使用 DWARF CFI 遍歷堆棧

要使用此方法遍歷給定程序計數器(PC)的堆棧,我們需要找到其相應的展開信息。但是,我們所說的展開信息到底是什麼意思呢?

我們需要恢復:

前一幀的堆棧指針的值,就在我們當前函數被調用之前,在 DWARF 的 CFI 術語中,稱爲規範幀地址或 CFA。正如我們之前看到的,在 x86_64 中,保存的返回地址總是在 CFA 之前 8 個字節(一個字)。

展開算法看起來像這樣

  1. 讀取初始寄存器

  2. 指令指針 $rip。需要在展開表中查找行。

  3. 堆棧指針$rsp和幀指針$rbp,用於計算前一幀的堆棧指針值 (CFA)。我們可以在 CFA 的偏移量處找到壓入堆棧的返回地址和其他寄存器。

  4. While 循環 unwind_frame_count <= MAX_STACK_DEPTH:

  5. 如果沒有記錄並且 $rbp 爲零,我們就到達了堆棧的底部。

  6. 找到 i 滿足的 PC 的展開錶行$unwind_table[i].PC <= $target_PC <= $unwind_table[i+1].PC

  7. 將指令指針添加到堆棧。

  8. 計算前一幀的棧指針。這可以基於當前幀的 $rsp$rbp,如果它不是表達式或直接註冊。

  9. 用前一幀的計算值更新寄存器。

  10. 繼續下一幀。轉到 2。

Note: 爲簡單起見,我們省略了我們的展開器實現的一些重要細節.

爲此,我們需要讀取回溯操作碼,對它們求值,然後生成表。這個過程可能會非常昂貴,但它是由 C++ 中的異常處理基礎結構完成的。因爲異常應該是,呃,異常,所以這些代碼路徑不應該經常使用,開銷也不會太高。

對於調試器(如 GDB)也是如此,在調試器中,用戶可能希望瞭解各處的堆棧跟蹤,以瞭解他們在執行中的位置。這些用例有時被歸類爲離線展開。

分析器有點不同,因爲它們通常每秒對堆棧進行數十次或數百次採樣。必須讀取、解析和評估展開信息的開銷可能非常高。雖然堆棧展開器可能會做一些緩存,但整個過程仍然非常昂貴。

在我們的案例中,一個關鍵的觀察結果是,我們不需要恢復每個寄存器,我們只需要這 2 個寄存器和保存的返回地址。這種洞察力使我們能夠生成一種更適合在線展開用例的表示。

Possible implementations 可能的實現

我們開發的分析器並不是第一個使用這種技術的分析器。Perf 是一個古老的 Linux 分析器,它支持基於 DWARF 的堆棧展開已經有一段時間了。通過利用 Linux 3.4 中的perf_event_open系統調用中引入的PERF_SAMPLE_REGS_USERPERF_SAMPLE_STACK_USER,它可以接收所分析進程的寄存器以及每個樣本的堆棧副本。

雖然這種方法已經被證明是可行的,並且我們對實現我們的分析器進行了類似的評估,但它有一些我們希望避免的缺點:

雖然 300 KB/s 的數據量看起來並不多,但我們認爲,對於運行 CPU 密集型應用程序的繁忙機器來說,這個數字可能會高得多。我們希望通過在分析器運行時減少測量的影響,減少專用於分析器的資源,否則應用程序可能會使用這些資源。

另一個突然出現在我們腦海中的想法是複製 BPF 程序中的堆棧,但這仍然有我們希望避免的缺點,我們必須重新實現內核已經擁有的功能,而且它被證明工作得很好!

這就引出了我們最終採用的方法,仍然利用 BPF!

Why BPF?

我們是 BPF 的忠實信徒,有很多原因。從廣義上講,它允許 Linux 內核以更高的安全保證和更低的學習門檻進行編程。

在 BPF 中開發分析器非常有意義,因爲一旦實現了堆棧遍歷機制,我們就可以利用 perf 子系統來獲取有關 CPU 週期、指令、L3 緩存未命中或我們機器中可用的任何其他性能計數器的樣本。它還有助於開發其他工具,如分配跟蹤器、off-CPU 分析器等。

你可能會想,爲什麼要在 BPF 中討論堆棧展開?使用bpf_get_stackid(ctx,& map,BPF_F_USER_STACK) helper,我們可以獲取用戶堆棧!事實上,這個助手使用幀指針遍歷堆棧,而一個功能齊全的 DWARF 展開器不太可能登陸內核 [15]

展開表的 BPF 友好表示形式

大多數離線堆棧展開器不會處理大部分 DWARF CFI 信息,因爲它們只針對很少的程序計數器。另一方面,分析器可能會產生更高的程序計數器基數。出於這個原因,事實上我們只需要數據的一個子集來遍歷堆棧,因爲我們不需要知道如何恢復每個寄存器,也不需要生成一些表示來最小化 BPF 的工作 unwinder 必須做,我們決定預先承擔 unwind 表生成成本。

在用戶空間中,我們首先解析、計算和生成回溯表。到目前爲止,我們只支持存儲在.eh_frame ELF 段中的信息。生成的表是由以下行類型構建的幾個數組:

typedef struct {
  u64 pc;
  u16 _reserved_do_not_use;
  u8 cfa_type;
  u8 rbp_type;
  s16 cfa_offset;
  s16 rbp_offset;
} stack_unwind_row_t;

我們使用一個完整的字作爲程序計數器,然後有幾個字段幫助我們計算 CFA。例如,它可以與當前的$rsp$rbp有一定的偏移量。我們還將瞭解如何恢復$rbp

使用上述算法,我們通過恢復前一幀的寄存器來遍歷幀。該表按程序計數器排序,以便能夠在 BPF 程序中對該表進行二進制搜索。

當我們找不到給定 PC 的回溯信息,且當前的$rbp爲零時,我們就完成了對堆棧的遍歷。然後,我們在內核空間的 BPF map 中聚合堆棧,以最大限度地提高效率,我們每分鐘從用戶空間收集兩次數據,在用戶空間生成配置文件並將其發送到 Parca Server 兼容的服務器。

開發

在我們開始這個項目的早期,我們就意識到有很多變量會影響它的成功。據我們所知,沒有其他功能完整的、開源的、基於 DWARF 的 BPF 展開器,所以我們不確定它的可行性。因此,爲了最大限度地提高我們的成功機會,我們試圖大幅減少問題空間,同時仍給我們儘可能多的信號。

在 Polar Signals,我們爲每一個我們想要討論或獲得反饋的大功能或主題創建了一個徵求意見(RFC)。對於這項工作,我們從一個文檔開始,列出了我們希望在第一次迭代中實現的目標,包括目標,更重要的是,非目標。

經過幾個星期的工作,我們得到了第一個版本 [16],它專注於正確性。我們繼續進行後續工作 [17],以放寬最低內核要求(kernel ~4.10),並使展開錶行更加緊湊。

在 BPF 中構建這是一個有趣的挑戰。內核必須確保任何加載的程序都不會使其崩潰,它使用 BPF 驗證器 [18] 靜態分析代碼,驗證器將拒絕或接受程序。在寫這篇文章的時候,一些當前的規則是,沒有動態分配,終止必須是可證明的,以及許多其他的,所以我們必須有創造性地讓驗證器接受我們的程序。這篇文章太長了,所以這將是另一個故事。

Testing 測試

爲了讓展開器工作,展開表和 BPF 中實現的展開算法都必須運行良好。確保 table 正確是該項目開發的重中之重。

在這種情況下,我們很早就決定以非常簡單的形式使用快照測試。我們在一個單獨的 git 倉庫 [19] 中有一些測試二進制文件和預期的回溯表。作爲 Agent 中測試套件的一部分,我們重新生成表並確保沒有任何更改。

這種技術允許我們快速迭代 DWARF 展開信息解析器,幫助我們找到大量的 bug,並節省了大量的時間,否則我們將花費大量的時間來試圖理解爲什麼我們在遍歷堆棧時失敗。

未來工作

我們正在修復、研發很多功能,將很快與您分享!

我們幾天前才發佈了第一個版本,其中包括基於 DWARF 的堆棧展開。但是我們已經做了一些更多的改進,以確保分析器在內存受限的機器上運行良好,改進了體系結構,更好地支持 JIT 代碼,等等。

近期,我們將重點轉向可靠性、性能和更廣泛的支持。DWARF 的回溯信息的解析、求值和處理尚未優化。我們還希望確保我們的分析器具有詳細的性能指標。最後,我們希望對 Clang 和 Rust 編譯器工具鏈生成的表進行更詳盡的測試。

這個項目的最終目標是在默認情況下爲我們的所有用戶啓用這個分析器,而不會導致明顯更高的資源使用率。

試用

如上所述,這個新特性是在一個特性標誌後面,但是我們將在下一個版本中默認啓用它,一旦我們獲得了一些改進,你可下載 parca-agent[20] 當前的版本來體驗。

$ ./parca-agent [...] --experimental-enable-dwarf-unwinding \
  --debug-process-names="(postgres|mysql|redpanda)"

Working with the community 與社區合作

我們認爲,幀指針的普遍缺乏對於應用程序開發人員以及分析器、調試器和編譯器的開發人員來說是一個大問題。

幸運的是,這個問題空間正在被更廣泛的工程社區的許多成員積極地研究,比如在 Fedora 中默認啓用幀指針 [21] 的提議,或者 the .ctf_frame work[22],這是一種DWARF展開的代替格式,專門爲分析器和其他工具需要的在線、異步(意味着可以解旋任何程序計數器,而不僅僅是從特定部分)用例定製。

開源和與其他社區合作是我們公司精神的重要組成部分。這就是爲什麼我們很早就開始談論這個項目,從去年 9 月的 Linux Plumbers talk[23] 開始,在那裏我們宣佈了這項工作。

我們的 unwinder[24] 是根據 GPL 許可證獲得許可的。它開放供檢查和貢獻,我們很樂意與面臨類似問題的其他項目合作。不要猶豫,伸出手!如果您希望在我們的 Discord [25] 或 GitHub discussion[26] 中看到任何反饋或功能,請告訴我們。

致謝

如果沒有許多人的努力,這項工作是不可能完成的。感謝如下項目。

雜談

  1. 雖然術語stack walking堆棧遍歷在分析器的上下文中更爲正確,而stack unwinding堆棧展開通常在運行時處理異常時使用,但我們可以互換使用這兩個術語。

  2. 我們的表格式使用了我們可以使用的最小數據類型,它對偏移量的最小值和最大值等設置了一些限制。您可以在本設計文檔 [30] 中查看它們,其中還包括一些unwinder的要求。我們已經在努力消除上面提到的限制

  3. Reliable and fast DWARF-based stack unwonding一文中的一個非常有趣的想法是,當展開表由於某種原因不存在或不完整時,從目標代碼合成展開表。這是我們將來可能會考慮的事情。

  4. 爲了增加對複雜性影響的一些見解,僅就代碼大小而言,之前基於幀指針的展開器可以在不到 50 行的 BPF 中重新實現,而這個基於 dwarf 的展開器超過 500 行。這排除了用戶空間和測試中所有必要的支持代碼。

  5. 最後但並非最不重要的一點是,不要把這項工作當作刪除幀指針的道歉!如果我們可以改變計算行業中的一些技術和低級別的東西,它可能會默認啓用幀指針。這是 Facebook 和谷歌等超大規模企業已經在做的事情,儘管可能會產生額外的計算成本,因爲在故障排除的每一分鐘都需要花費大量金錢的情況下,它們可以節省他們的頭痛和時間。話雖如此,我們知道即使每個人都同意啓用幀指針,也需要數年時間才能使我們的所有用戶都獲得好處。

旁註:C++ 異常機制非常複雜,必須完成本文中描述的大量工作。一些有趣的事情需要考慮:展開表在內存中與不在內存中時的成本是多少?這可能是您的應用程序的問題嗎?這些路徑是如何行使的?

參考資料

[1]

https://www.parca.dev/: https://www.parca.dev/

[2]

Javier Honduvilla Coto: https://hondu.co/

[3]

DWARF-based Stack Walking Using eBPF: https://www.polarsignals.com/blog/posts/2022/11/29/profiling-without-frame-pointers/

[4]

Parca: https://www.parca.dev/

[5]

Agent: https://github.com/parca-dev/parca-agent/

[6]

ABI: https://en.wikipedia.org/wiki/Application_binary_interface

[7]

函數序言: https://www.intel.com/content/www/us/en/docs/programmable/683836/current/function-prologues.html

[8]

Last Branch Record (LBR): https://lwn.net/Articles/680985/

[9]

Intel Processor Trace (PT): https://lwn.net/Articles/648154/

[10]

DWARF debugging information format: https://dwarfstd.org/doc/DWARF5.pdf

[11]

x86_64 ABI: https://refspecs.linuxbase.org/elf/x86_64-abi-0.99.pdf

[12]

LEB128: https://en.wikipedia.org/wiki/LEB128

[13]

2 個表達式: https://github.com/parca-dev/parca-agent/pull/1058/commits/b7d873d7ce7cdd19edb52fad1659b8fd34b5fd34

[14]

當前正在使用的用戶堆棧: https://github.com/torvalds/linux/blob/3d7cb6b0/kernel/events/core.c#L6582-L6629

[15]

一個功能齊全的 DWARF 展開器不太可能登陸內核: https://lkml.org/lkml/2012/2/10/129

[16]

第一個版本: https://github.com/parca-dev/parca-agent/pull/948

[17]

後續工作: https://github.com/parca-dev/parca-agent/pull/978

[18]

BPF 驗證器: https://docs.kernel.org/bpf/verifier.html

[19]

一個單獨的 git 倉庫: https://github.com/parca-dev/testdata

[20]

parca-agent: https://github.com/parca-dev/parca-agent/releases/tag/v0.10.0

[21]

Fedora 中默認啓用幀指針: https://fedoraproject.org/wiki/Changes/fno-omit-frame-pointer

[22]

the .ctf_frame work: https://www.youtube.com/watch?v=XiH12D5ZN2A

[23]

Linux Plumbers talk: https://youtu.be/Gr1rrSzvqfg

[24]

我們的 unwinder: https://github.com/parca-dev/parca-agent/tree/main/bpf/cpu

[25]

Discord : https://discord.com/invite/ZgUpYgpzXy

[26]

GitHub discussion: https://github.com/parca-dev/parca-agent/discussions/1055

[27]

關於 .eh_frame 的博客系列: https://www.airs.com/blog/archives/460

[28]

MaskRay 的博客: https://maskray.me/

[29]

可靠且快速的基於 DWARF 的堆棧展開論文: https://dl.acm.org/doi/10.1145/3360572

[30]

本設計文檔: https://github.com/parca-dev/parca-agent/blob/c28f6482924e760faaa844973d690defd74bba4c/docs/native-stack-walking/design.md

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/j9ic1Bm68P8CGArn_q6WdQ