radix-tree算法淺析--從不懂到裝懂【轉】

轉自:https://blog.csdn.net/C_JLMPC2009/article/details/103936336

前言 本文爲原創,可能會存在一些知識點或理解上的問題,歡迎切磋和交流  ^_^

1. 爲什麼要研究radix-tree算法?(腦殘嗎)
存儲的核心就是I/O流,不懂I/O流就不要說自己會存儲;而最近一直研究和分析文件系統I/O流,說到I/O瓶頸和提升性能,緩存讀寫顯然是重中之重。在分析ceph文件系統寫I/O流程中,比如調用ceph_write_iter接口,如果過緩存,必調用generic_perform_write(),這個接口的作用是啥呢,就是通過索引在頁緩存中返回一個頁(不管你是在頁緩存中找到的一個頁,還是因沒有找到,自己重新申請並加到頁緩存結構中的,這個後面詳細說),把用戶態緩存區的數據拷貝到這個頁中,然後返回給用戶態說,哥們,我寫完了(當然,此時的數據只是在內存中,需要內核線程定時下刷數據落盤,纔是真正的寫數據到磁盤介質)。

那麼,我的問題是,這一個頁又是存放在什麼數據結構裏呢,實現的算法又是怎樣的呢?

帶着這些疑問,繼續查看調用到的接口,最後發現所有有關頁操作(創建、刪除、查找)有一個專門的文件lib/radix-tree.c用於管理和維護,這套算法已經屏蔽了上層具體業務(頁緩存機制、還是網絡路由機制等等),是完完全全的底層核心算法,這勾起了我極大的好奇心,這一份誘惑難以拒絕,抱着對金庸“九陰真經”的癡迷,亦或是對“淡眉如秋水,玉肌伴輕風”的眷戀,所以想一窺她的芳容。

Radix-tree樹算法是對字典樹算法的壓縮變種,前面分析字典樹算法的目的就在這裏,不過字典樹算法原理很好理解,接口代碼不過200行,但實現radix-tree算法的內容就太多了。感覺自己的腦摺疊度不夠。。。

Radix-tree基數樹用於存儲與查找鍵-值(key-value)這種關聯類型的數據結構。當然,鍵(key)可以是字符串,也可以是長整型數據類型的路由(比如id),利用raidx-tree可以快速完成其對應的value值。所以linux內核網絡路由查找就是用基數樹算法實現,內存管理頁緩存機制也用到了該數據結構。

Linux內核使用基數樹管理與地址空間相關的所有頁,頁緩存讀寫獲取的頁都存放到這個樹結構中,主要目的是加大搜索和管理緩存頁效率,提高IO緩存讀寫性能。Radix-tree並不對應一個普通的二叉或三叉樹,而是一個多叉樹,同時它又是不平衡的,即樹的每個節點,高度差可能是任意值,樹本身由兩種數據結構組成,即radix_tree_root和radix_tree_node,因爲頁緩存葉子節點存放的是一個頁,所以struct page頁是基數樹的一個實例。

2.radix-tree相關結構體
從面嚮對象語言的設計原則看,結構體就是一個類,尤其內核中用到的結構體,裏面即有公共成員變量,又有自己私有成員變量,同時封裝了函數操作集,用於操作本類對象的方法。所以搞明白結構體中成員的作用,對於理解函數接口是幹嘛的,起到畫龍點睛的作用。但是,有些結構體,比如說struct inode、struct mount等等,動輒十幾、幾十個成員,如果不是負責開發和維護內核模塊,我們在平時使用中,知道其中幾個主要的,就可以了。以下針對radix-tree算法主要涉及的兩個結構體進行分析。

注意:以下結構體和相關源碼均基於內核源碼版本kernel-4.14.28-201.el7。

2.1 struct radix_tree_root(include/linux/radix-tree.h)
struct radix_tree_root {
gfp_t gfp_mask;
struct radix_tree_node __rcu *rnode;
};
如何理解struct radix_tree_root結構體以及成員?

針對這部分結構體的認識,鑑於相關資料(《深入linux內核架構》、《基於2.6.x的存儲技術原理分析》以及網上資料等)。

radix_tree_root是每個基數樹的根節點,radix-tree樹開始於這個根節點的建立,我認爲可以把它解釋爲一個頭節點更容易理解。gfp_mask查看相關資料,給出的結論是一個標記,用於區分內存區域,具體如何用,我沒有具體看,可留一個遺留問題,待續。rnode結構體指針指向基數樹的第一個節點。

2.2 struct radix_tree_node(include/linux/radix-tree.h)
#define RADIX_TREE_MAX_TAGS 3

#ifndef RADIX_TREE_MAP_SHIFT
#define RADIX_TREE_MAP_SHIFT (CONFIG_BASE_SMALL ? 4 : 6)
#endif

#define RADIX_TREE_MAP_SIZE (1UL << RADIX_TREE_MAP_SHIFT)
#define RADIX_TREE_MAP_MASK (RADIX_TREE_MAP_SIZE-1)

struct radix_tree_node {
unsigned char shift; /* Bits remaining in each slot */
unsigned char offset; /* Slot offset in parent */
unsigned char count; /* Total entry count */
unsigned char exceptional; /* Exceptional entry count */
struct radix_tree_node *parent; /* Used when ascending tree */
struct radix_tree_root *root; /* The tree we belong to */
union {
struct list_head private_list; /* For tree user */
struct rcu_head rcu_head; /* Used when freeing node */
};
void __rcu *slots[RADIX_TREE_MAP_SIZE];
unsigned long tags[RADIX_TREE_MAX_TAGS][RADIX_TREE_TAG_LONGS];
};
基數樹使用struct radix_tree_node結構體定義了中間節點,先介紹一下slots指針數組,數組個數是由宏RADIX_TREE_MAP_SIZE來定義,RADIX_TREE_MAP_SIZE宏定義爲(1UL << RADIX_TREE_MAP_SHIFT), RADIX_TREE_MAP_SHIFT按照內核配置參數CONFIG_BASE_SMALL是否設置,被定義爲4或者6,即如果CONFIG_BASE_SMALL被設置,RADIX_TREE_MAP_SIZE爲16,否則爲64。數組的每一個元素存儲的是下一個radix_tree_node節點或葉子,而葉子在頁緩存存儲的就是一個頁page的地址;count計數表示的是這個節點中slot數組元素被使用的數目;基數樹中每一個節點slot數組又可以指向16或64個節點或葉子,每個葉子表示一個頁,但是此時內核不能區分出這個頁是髒頁還是乾淨頁,爲進一步提高對髒頁和乾淨頁操作,引入了tags二維數組。shift表示當前查詢或插入節點所在整顆樹的層數;offset表示存儲當前節點在父節點中的偏移。

遺留問題:

tag二維數組不是很理解其發揮的作用,RADIX_TREE_MAX_TAGS 源碼中定義爲3,即內核支持三種形式的tag,RADIX_TREE_TAG_LONGS被定義如下

#define RADIX_TREE_TAG_LONGS  ((RADIX_TREE_MAP_SIZE + BITS_PER_LONG - 1) / BITS_PER_LONG),BITS_PER_LONG被定義爲32或64,假設BITS_PER_LONG爲64,RADIX_TREE_MAP_SIZE值爲64,RADIX_TREE_TAG_LONGS值爲(64+64-1)/64=1,那麼tag二維數組爲tag[3][1],即爲一維數組,總共3個元素,如何存的下整個slots 64個葉子的標記?

3.radix-tree插入函數分析
radix-tree算法源碼文件在kernel/lib/radix-tree.c文件中,這裏的源碼分析目前只是看代碼流程,函數實現等方式進行理論分析,其中結構體有些成員賦值仍不知所以然其意義。採用熱探測方式進行打樁調試,因爲該算法摒棄上層具體業務,所以內核會無時無刻將查到的條目信息打印出來,無法通過添加調試信息方式進行動態分析,其他調試方式暫時沒有想到,如果有好的方式,可以討論溝通一下。以下主要從代碼層面講解插入一個條目信息的流程,其中有些公共接口會放到前面簡單講解一下。

注意:以下結構體和相關源碼均基於內核源碼版本kernel-4.14.28-201.el7。

3.1 static unsigned radix_tree_load_root(const struct radix_tree_root *root, struct radix_tree_node **nodep, unsigned long *maxindex)
static unsigned radix_tree_load_root(const struct radix_tree_root *root,
struct radix_tree_node **nodep, unsigned long *maxindex)
{
struct radix_tree_node *node = rcu_dereference_raw(root->rnode);

*nodep = node;

if (likely(radix_tree_is_internal_node(node))) {
node = entry_to_node(node);
*maxindex = node_maxindex(node);
return node->shift + RADIX_TREE_MAP_SHIFT;
}

*maxindex = 0;
return 0;
}
分析一個函數先從函數返回值、函數入參出參說起,函數參數一共有3個,struct radix_tree_root *root、radix_tree_node *child、unsigned long maxindex,很明顯root是入參,所有radix-tree函數入參一定要獲取根節點root,child和maxindex是出參,所以該函數用於獲取根節點,先通過rcu_dereference_raw獲取root指向的rnode成員並返回給一個局部變量node,通過radix_tree_is_internal_node(node))判斷節點node是否是內部節點,如果是內部節點,再通過entry_to_node獲取rnode對應radix-tree的指針並再賦值給node,通過node_maxindex獲取對應當前節點的最大索引範圍,比如當前是第一個radix_tree_node節點,即第一層,所以maxindex=64,因爲該節點slot數組共64個元素,可以指向64個葉子,此處賦值給maxindex,函數返回值爲當前節點對應的shift值,此處shift大小爲0+64=64。如果判斷髮現該節點不是內部節點,則maxindex=0,返回shift=0。

3.2 static inline struct radix_tree_node *entry_to_node(void *ptr)
static inline struct radix_tree_node *entry_to_node(void *ptr)
{
return (void *)((unsigned long)ptr & ~RADIX_TREE_INTERNAL_NODE);
}
該函數用於獲取函數參數對應的radix-tree的節點,我的理解是通過傳入參是否含有標誌RADIX_TREE_INTERNAL_NODE是否是一個內部節點還是葉子節點,此處是ptr & ~RADIX_TREE_INTERNAL_NODE,很明顯是要把該標誌清除,所以該函數只是想將傳入參的身份,即內部節點重新清除一下。目的是便於後面流程以該節點爲起始節點進行操作。

3.3 static inline void *node_to_entry(void *ptr)
static inline void *node_to_entry(void *ptr)
{
return (void *)((unsigned long)ptr | RADIX_TREE_INTERNAL_NODE);
}
node_to_entry函數和entry_to_node函數作用剛好相反,從函數名稱上也可以看出來,該函數作用是將傳入值指向節點的指針的低位或上RADIX_TREE_INTERNAL_NODE,用於告訴大家這個節點已經是一個內部節點了,即標識了該節點的身份。

3.4 int __radix_tree_insert(struct radix_tree_root *root, unsigned long index, unsigned order, void *item)
int __radix_tree_insert(struct radix_tree_root *root, unsigned long index,
unsigned order, void *item)
{
struct radix_tree_node *node;
void __rcu **slot;
int error;

BUG_ON(radix_tree_is_internal_node(item));

error = __radix_tree_create(root, index, order, &node, &slot);
if (error)
return error;

error = insert_entries(node, slot, item, order, false);
if (error < 0)
return error;

if (node) {
unsigned offset = get_slot_offset(node, slot);
BUG_ON(tag_get(node, 0, offset));
BUG_ON(tag_get(node, 1, offset));
BUG_ON(tag_get(node, 2, offset));
} else {
BUG_ON(root_tags_get(root));
}

return 0;
}
對該函數的理解,先從參數說起,其中root是基數樹根節點,index是一個索引值,是上層業務傳下來的,比如頁緩存寫流程中,當執行echo 123 > file命令時,此時先到頁緩存中查找頁是否命令,這個index值的作用是用戶態寫入字節流的偏移被轉換到頁緩存中一個頁在緩存中的索引;order上層函數傳下來的是一個0,不用管;item是要存入的條目信息。首先,radix-tree中節點類型有兩種:中間節點和葉子節點。BUG_ON(radix_tree_is_internal_node(item))用於判斷要插入的條目信息是否是內部節點,通過BUG_ON和radix_tree_is_internal_node插入的條目信息不能是內部節點,如果是,則直接報錯,即插入的必須是一個葉子節點。__radix_tree_create()會返回獲取新索引對應的父節點指針和slot指針。 insert_entries()則是將最終要寫到葉子節點上的item條目信息賦值給slot指針指向的節點。

3.5 static inline bool radix_tree_is_internal_node(void *ptr)
#define RADIX_TREE_ENTRY_MASK 3UL
#define RADIX_TREE_INTERNAL_NODE 1UL
static inline bool radix_tree_is_internal_node(void *ptr)
{
return ((unsigned long)ptr & RADIX_TREE_ENTRY_MASK) ==
RADIX_TREE_INTERNAL_NODE;
}
其中,有關slot最低兩個bit位的意義如下所示(源於include/linux/radix-tree.h),如下圖所示:

The bottom two bits of the slot determine how the remaining bits in the
* slot are interpreted:
*
* 00 - data pointer
* 01 - internal entry
* 10 - exceptional entry
* 11 - this bit combination is currently unused/reserved
所以,slot最低兩個bit用於決定slot保留的bit位數據類型,所以radix_tree_is_internal_node()中先獲取最低bit位,&上RADIX_TREE_INTERNAL_NODE,如果爲1,則說明這是一個內部節點,如果爲0,則不是一個內部節點。

3.6 int __radix_tree_create(struct radix_tree_root *root, unsigned long index, unsigned order, struct radix_tree_node **nodep, void __rcu ***slotp)
int __radix_tree_create(struct radix_tree_root *root, unsigned long index,
unsigned order, struct radix_tree_node **nodep,
void __rcu ***slotp)
{
struct radix_tree_node *node = NULL, *child;
void __rcu **slot = (void __rcu **)&root->rnode;
unsigned long maxindex;
unsigned int shift, offset = 0;
unsigned long max = index | ((1UL << order) - 1);
gfp_t gfp = root_gfp_mask(root);

shift = radix_tree_load_root(root, &child, &maxindex);

/* Make sure the tree is high enough. */
if (order > 0 && max == ((1UL << order) - 1))
max++;
if (max > maxindex) {
int error = radix_tree_extend(root, gfp, max, shift);
if (error < 0)
return error;
shift = error;
child = rcu_dereference_raw(root->rnode);
}

while (shift > order) {
shift -= RADIX_TREE_MAP_SHIFT;
if (child == NULL) {
/* Have to add a child node. */
child = radix_tree_node_alloc(gfp, node, root, shift,
offset, 0, 0);
if (!child)
return -ENOMEM;
rcu_assign_pointer(*slot, node_to_entry(child));
if (node)
node->count++;
} else if (!radix_tree_is_internal_node(child))
break;

/* Go a level down */
node = entry_to_node(child);
offset = radix_tree_descend(node, &child, index);
slot = &node->slots[offset];
}
if (nodep)
*nodep = node;
if (slotp)
*slotp = slot;
return 0;
}
到了__radix_tree_create函數,個人認爲,這個函數可以說是整個__radix_tree_index函數的核心,也是最不好理解的函數。因爲其他的函數作用都很簡單,通過看函數名稱就可以知道函數做了什麼,層次比較清晰,但是create函數,看完也只是知道大概做了什麼,這棵樹是如何生長的,還是不清楚,另外這個函數涉及內容很多,它的作用是在樹中找到我想要找的節點node和該node對應的slot指針,便於把傳進來的條目信息寫到slot指針指向的一塊內存區域。

這個函數幹了這麼幾件事:

(1) 先找到root對應rnode節點賦值給child、當前節點的maxindex最大索引值,並返回當前節點對應的shift。索引值index怎麼理解?Shift怎麼理解?比如第一層節點,應該是如下圖所示:

 

該層是第一層,即只有一個節點,那麼slot數組一共可以存放64個葉子,所以,maxindex=64,shift值表示slot指向的每一個元素指針所在整顆樹的第幾層,比如這64個元素均在第0層,所以shift=0。

(2) 注意max局部變量,unsigned long max = index | ((1UL << order) - 1),通過計算可知,max就是上層透傳下來的index,可以理解成max=index,通過判斷max和maxindex,當前樹層深maxindex是否滿足要插入元素的索引值,如果滿足,則直接跳過if處理邏輯,如果不滿足,則需要擴展樹深度,我們假設這裏滿足,先不擴展。

(3) While(shift > order)循環語句體,order透傳下來值=0,shift=64,child指向第一個radix_tree_node節點,進入循環語句內容,shift先減64,變爲0,然後判斷child是否爲NULL,顯然不爲NULL,再判斷child是否內部節點,也顯然是,所以兩個if語句跳過。

(4) 執行entry_to_node函數,清除內部節點標誌RADIX_TREE_INTERNAL_NODE,獲取該指針對應radix_tree的節點。

(5) 通過radix_tree_descend()函數找到node節點中slot數組偏移index個元素的指針,並返回這個指針相對該node的偏移offset,將該值賦值給slot指針。

(6) 然後執行第二遍循環,此時shift=0,shift>order條件不成立,退出循環體。

(7) 此時就找到了想要插入條目信息的元素,並賦值給出參nodep和slotp。

以上步驟是查找要插入內存獲取對應指針的一般流程,如果是一層,即shift=0,則while循環語句每一次都只循環一次即可,插入64個元素,剛好把64個slot元素全部填滿。如果要插入索引值大於maxindex,則需要擴展樹高度,另外,查找節點需要循環多次,該流程後面會以流程框架圖方式呈現出來。

3.7 static inline unsigned long node_maxindex(const struct radix_tree_node *node)
static inline unsigned long node_maxindex(const struct radix_tree_node *node)
{
return shift_maxindex(node->shift);
}
其中shift_maxindex函數實現如下
static inline unsigned long shift_maxindex(unsigned int shift)
{
return (RADIX_TREE_MAP_SIZE << shift) - 1;
}
該函數作用是獲取當前節點所在該層能夠支持的最大索引值,前面已經舉例,當只有一個節點時,該層支持最大索引值爲64,如果是兩層,支持最大索引值爲多少,如下圖所示:

 

第2層支持maxindex爲64<<6-1個元素。

疑問:這裏其實有個疑問,shift值是如何知道的?Shift值在源碼中確實沒有體現出來,我是根據代碼邏輯推算出來的,發現剛好符合以上規律。

3.8 static inline unsigned long shift_maxindex(unsigned int shift)
static inline unsigned long shift_maxindex(unsigned int shift)
{
return (RADIX_TREE_MAP_SIZE << shift) - 1;
}
該函數作用是返回當前節點所在層的最大索引值。

3.9 static int radix_tree_extend(struct radix_tree_root *root, gfp_t gfp, unsigned long index, unsigned int shift)
/*
* Extend a radix tree so it can store key @index.
*/
static int radix_tree_extend(struct radix_tree_root *root, gfp_t gfp,
unsigned long index, unsigned int shift)
{
void *entry;
unsigned int maxshift;
int tag;

/* Figure out what the shift should be. */
maxshift = shift;
while (index > shift_maxindex(maxshift))
maxshift += RADIX_TREE_MAP_SHIFT;

entry = rcu_dereference_raw(root->rnode);
if (!entry && (!is_idr(root) || root_tag_get(root, IDR_FREE)))
goto out;

do {
struct radix_tree_node *node = radix_tree_node_alloc(gfp, NULL,
root, shift, 0, 1, 0);
if (!node)
return -ENOMEM;

if (is_idr(root)) {
all_tag_set(node, IDR_FREE);
if (!root_tag_get(root, IDR_FREE)) {
tag_clear(node, IDR_FREE, 0);
root_tag_set(root, IDR_FREE);
}
} else {
/* Propagate the aggregated tag info to the new child */
for (tag = 0; tag < RADIX_TREE_MAX_TAGS; tag++) {
if (root_tag_get(root, tag))
tag_set(node, tag, 0);
}
}

BUG_ON(shift > BITS_PER_LONG);
if (radix_tree_is_internal_node(entry)) {
entry_to_node(entry)->parent = node;
} else if (radix_tree_exceptional_entry(entry)) {
/* Moving an exceptional root->rnode to a node */
node->exceptional = 1;
}
/*
* entry was already in the radix tree, so we do not need
* rcu_assign_pointer here
*/
node->slots[0] = (void __rcu *)entry;
entry = node_to_entry(node);
rcu_assign_pointer(root->rnode, entry);
shift += RADIX_TREE_MAP_SHIFT;
} while (shift <= maxshift);
out:
return maxshift + RADIX_TREE_MAP_SHIFT;
}
如果要插入64個元素以內,則該函數不會被調用,但是如果插入元素索引>64,則需要擴展樹高度,以滿足插入操作。擴展基數樹之前一直沒有搞懂,是因爲懶得搞懂,因爲要存儲64個元素直接申請內存用於存儲葉子節點即可,幹嘛費勁研究這個,如果要存的條目索引值超過64,比如index=65,再放到樹裏,因爲一層樹深不夠,一定是要擴展樹的高度,那麼研究一下該函數還是有必要的,這個函數和下面radix_tree_descend遙相呼應,一個用來擴展,一個用來遍歷。擴展節點和索引節點的關鍵也是這兩個函數,搞懂了就會了。直接上函數流程圖如下所示:

 

擴展樹的高度如果不仔細看,以爲和普通二叉樹一樣,是一顆倒着長的樹,往下繼續添加節點就可以了。但是如下兩行代碼是這樣寫的:

當entry爲內部節點是,entry_to_node(entry)->parent = node;

node->slots[0] = (void __rcu *)entry;

entry = node_to_entry(node);

rcu_assign_pointer(root->rnode, entry);
如果是一顆正常倒着生長的樹,這幾行代碼就很怪,不符合邏輯嘛,所以簡單畫一下,就知道這棵樹是如何生長的了,如下圖所示。

如果當前只有一層,即一個radix_tree_node節點,基數樹如下所示:

 

此時該基數樹一共可以存放64個索引對應條目信息;如果此時再插入一個index=65對應的條目信息,發現index>maxindex,就要擴展樹的高度。擴展後樹的樣子如下圖所示:

 

擴展後樹的樣子如果還是不夠清晰,不妨繼續修改一下,即如下圖所示:

 

這樣擴展後,基數樹的樣子就一目瞭然了。因爲創建流程裏還有一個while循環要找到對應index的節點,所以在要插入條目元素索引值index=65操作中,create流程while循環找對應index的節點如下圖所示:

 

4.0 static unsigned int radix_tree_descend(const struct radix_tree_node *parent, struct radix_tree_node **nodep, unsigned long index)
static unsigned int radix_tree_descend(const struct radix_tree_node *parent,
struct radix_tree_node **nodep, unsigned long index)
{
unsigned int offset = (index >> parent->shift) & RADIX_TREE_MAP_MASK;
void __rcu **entry = rcu_dereference_raw(parent->slots[offset]);

#ifdef CONFIG_RADIX_TREE_MULTIORDER
if (radix_tree_is_internal_node(entry)) {
if (is_sibling_entry(parent, entry)) {
void __rcu **sibentry;
sibentry = (void __rcu **) entry_to_node(entry);
offset = get_slot_offset(parent, sibentry);
entry = rcu_dereference_raw(*sibentry);
}
}
#endif

*nodep = (void *)entry;
return offset;
}
該函數邏輯比較簡單,核心代碼就一行:unsigned int offset = (index >> parent->shift) & RADIX_TREE_MAP_MASK,就是計算出偏移然後返回該節點slot所在偏移的指針即可。

4. radix-tree API用戶態實例
demo源碼如下:

1 #include <linux/init.h>
2 #include <linux/module.h>
3 #include <linux/kernel.h>
4
5 #include <linux/radix-tree.h>
6
7 char *test[] = {"aaa", "bbb", "ccc"};
8 struct radix_tree_root root;
9
10 static int __init my_radix_tree_init(void)
11 {
12 int i = 0;
13 int num = ARRAY_SIZE(test);
14 RADIX_TREE(root, GFP_ATOMIC);
15 printk("num : %d\n", num);
16 for(i = 0; i < num; i++)
17 {
18 radix_tree_insert(&root, i, test[i]);
19 }
20 for(i = 0; i < num; i++)
21 {
22 printk("---[%d]---%s\n", i, (char*)radix_tree_lookup(&root, i));
23 }
24
25 return 0;
26 }
27
28 static void __exit my_radix_tree_exit(void)
29 {
30 printk("my_radix_tree_exit start\n");
31 }
32
33
34 module_init(my_radix_tree_init);
35 module_exit(my_radix_tree_exit);
36 MODULE_LICENSE("GPL");
執行結果如下:

 

5. 參考文章
https://biscuitos.github.io/blog/RADIX-TREE/

http://blog.chinaunix.net/uid-20718037-id-5728709.html

https://ivanzz1001.github.io/records/post/data-structure/2018/11/18/ds-radix-tree
————————————————
版權聲明:本文爲CSDN博主「清水濁酒」的原創文章,遵循CC 4.0 BY-SA版權協議,轉載請附上原文出處鏈接及本聲明。
原文鏈接:https://blog.csdn.net/C_JLMPC2009/article/details/103936336

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