天天看點

萬字解讀鴻蒙輕核心實體記憶體子產品

摘要:本文首先了解了實體記憶體管理的結構體,接着閱讀了實體記憶體如何初始化,然後分析了實體記憶體的申請、釋放和查詢等操作接口的源代碼。

本文分享自華為雲社群《鴻蒙輕核心A核源碼分析系列三 實體記憶體》,作者: zhushy。

實體記憶體(Physical memory)是指通過實體記憶體條而獲得的記憶體空間,相對應的概念是虛拟記憶體(Virtual memory)。虛拟記憶體使得應用程序認為它擁有一個連續完整的記憶體位址空間,而通常是通過虛拟記憶體和實體記憶體的映射對應着多個實體記憶體頁。本文我們先來熟悉下OpenHarmony鴻蒙輕核心提供的實體記憶體(Physical memory)管理子產品。

本文中所涉及的源碼,以<code>OpenHarmony LiteOS-A</code>核心為例,均可以在開源站點https://gitee.com/openharmony/kernel_liteos_a 擷取。如果涉及開發闆,則預設以<code>hispark_taurus</code>為例。

我們首先了解了實體記憶體管理的結構體,接着閱讀了實體記憶體如何初始化,然後分析了實體記憶體的申請、釋放和查詢等操作接口的源代碼。

鴻蒙輕核心A核的實體記憶體采用了段頁式管理,每個實體記憶體段被分割為實體記憶體頁。在頭檔案kernel/base/include/los_vm_page.h中定義了實體記憶體頁結構體,以及記憶體頁數組g_vmPageArray及數組大小g_vmPageArraySize。實體記憶體頁結構體LosVmPage可以和實體記憶體頁一一對應,也可以對應多個連續的記憶體頁,此時使用nPages指定記憶體頁的數量。

在檔案kernel\base\include\los_vm_common.h中定義了記憶體頁的大小、掩碼和邏輯位移值,可以看出每個記憶體頁的大小為4KiB。

在檔案kernel/base/include/los_vm_phys.h中定義了實體記憶體段LosVmPhysSeg等幾個結構體。該檔案的部分代碼如下所示。⑴處的宏是實體記憶體夥伴算法中空閑記憶體頁節點連結清單數組的大小,VM_PHYS_SEG_MAX表示系統支援的實體記憶體段的數量。⑵處的結構體用于夥伴算法中空閑記憶體頁節點連結清單數組的元素類型,除了記錄雙向連結清單,還維護連結清單上節點數量。⑶就是我們要介紹的實體記憶體段,包含開始位址,大小,記憶體頁基位址,空閑記憶體頁節點連結清單數組,LRU連結清單數組等成員。

在kernel/base/vm/los_vm_phys.c檔案中定義了實體記憶體區數組g_physArea[],如下代碼所示,其中SYS_MEM_BASE為DDR_MEM_ADDR的宏名稱,DDR_MEM_ADDR和SYS_MEM_SIZE_DEFAULT定義在檔案./device/hisilicon/hispark_taurus/sdk_liteos/board/target_config.h中,表示開發闆相關的實體記憶體位址和大小。

看下實體記憶體區VmPhysArea和實體記憶體段的LosVmPhysSeg差別,前者資訊教少,主要記錄開始位址和大小,為一塊實體記憶體的最簡單描述;後者除了實體記憶體塊開始位址和大小,還維護實體頁開始位址,空閑實體頁夥伴連結清單,LRU連結清單,相應的自旋鎖等資訊。

上面提到了夥伴算法,先看下夥伴算法的示意圖,如下。每個實體記憶體段都分割為一個一個的記憶體頁,空閑的記憶體頁挂載在空閑記憶體頁節點連結清單上。共有9個空閑記憶體頁節點連結清單,這些連結清單組成連結清單數組。第一個連結清單上的記憶體頁節點大小為1個記憶體頁,第二個連結清單上的記憶體頁節點大小為2個記憶體頁,第三個連結清單上的記憶體頁節點大小為4個記憶體頁,依次下去,第9個連結清單上的記憶體頁節點大小為2^8個記憶體頁。申請記憶體、釋放記憶體時會操作這些空閑記憶體頁節點連結清單,後文詳細分析。

萬字解讀鴻蒙輕核心實體記憶體子產品

本節主要講解實體記憶體管理子產品是如何初始化的,核心函數是OsVmPageStartup()。在講解之前,會先看下實體記憶體初始化過程中的一些内部函數。

函數OsVmPhysSegCreate用于把指定的一個實體記憶體區VmPhysArea轉換為實體記憶體段LosVmPhysSeg。傳入的2個參數分别為實體記憶體區的開始記憶體位址和大小。⑴處表示系統支援的實體記憶體段的數量為32個,超過則轉換錯誤。⑵處從實體記憶體段全局數組g_vmPhysSeg中擷取一個可用的實體記憶體段。⑶處如果實體記憶體段seg為數組g_vmPhysSeg中的第一個元素,則跳過循環體直接執行⑸設定實體記憶體段的開始位址和大小。如果不為第一個元素,并且前一個實體記憶體段的開始位址在要轉換的實體記憶體段的結束位址之後,則執行⑷處代碼覆寫前一個實體記憶體段。在配置實體記憶體區的時候,需要注意這裡的影響。

函數OsVmPhysSegAdd調用上述函數OsVmPhysSegCreate依次把配置的多個實體記憶體區一一進行轉換,對于開發闆hispark_taurus隻配置了一塊實體記憶體區域。

函數OsVmPhysInit繼續初始化實體記憶體段資訊。⑴處循環實體記憶體段數組,這裡不是循環32次,而是多少個實體段就循環周遊多少次。周遊到每一個實體記憶體段,然後執行⑵設定目前實體記憶體段的第一個實體頁結構體的位址,每一個實體記憶體頁都有自己的結構體LosVmPage,這些結構體維護在通過malloc記憶體堆申請的g_vmPageArray數組裡,後文會詳細講述。⑶處seg-&gt;size &gt;&gt; PAGE_SHIFT計算目前記憶體段對于的記憶體頁數量,然後更新nPages,這是後續實體記憶體段第一個記憶體頁對應的的實體記憶體頁結構體在數組g_vmPageArray中索引。⑷處開始的函數OsVmPhysFreeListInit和OsVmPhysLruInit初始化夥伴雙向連結清單和LRU雙向連結清單,後續分析這2個函數。

每個實體記憶體段使用9個空閑實體記憶體頁節點連結清單來維護空閑實體記憶體頁。OsVmPhysFreeListInit函數用于初始化指定實體記憶體段的空閑實體記憶體頁節點連結清單。操作前後需要開啟、關閉空閑連結清單自旋鎖。⑴處周遊空閑實體記憶體頁節點連結清單數組,然後執行⑵初始化每個雙向連結清單。⑶處把每個連結清單中的空閑實體記憶體頁的數量初始化為0。

和上個函數類似,函數OsVmPhysLruInit初始化指定實體記憶體段的LRU連結清單數組中的LRU連結清單。LRU連結清單分五類,由枚舉類型enum OsLruList定義。代碼較簡單,讀者自行閱讀代碼即可。

函數OsVmPageInit用于初始化實體記憶體頁的初始值,該函數需要3個參數,分别是實體記憶體頁結構體位址,實體記憶體頁的開始位址,實體記憶體段編号。⑴處初始化記憶體頁的連結清單節點,這個連結清單節點通常會挂載在夥伴算法的空閑記憶體頁節點連結清單上。⑵處設定記憶體頁标記為空閑記憶體頁FILE_PAGE_FREE,該值由枚舉類型enum OsPageFlags定義。⑶處設定記憶體頁的引用計數為0。⑷處設定記憶體頁的開始位址。⑸處設定記憶體頁所在的實體記憶體段的編号。⑹處設定記憶體頁順序order初始值,此時不屬于任何空閑記憶體頁節點連結清單。⑺處設定記憶體頁的nPages數值為0。⑻處的宏VMPAGEINIT調用函數OsVmPageInit并自動增加記憶體頁結構體page位址和記憶體頁pa位址。

了解上述幾個内部函數後,我們正式開始閱讀實體記憶體頁初始化函數VOID OsVmPageStartup(VOID)。系統在啟動時,該函數用于初始化實體記憶體,把實體記憶體段劃分割為為實體記憶體頁。該函數被kernel/base/vm/los_vm_boot.c中的UINT32 OsSysMemInit(VOID)調用,進一步被檔案platform/los_config.c中的INT32 OsMain(VOID)函數調用。下面詳細分析下函數的代碼。

⑴處的g_vmBootMemBase初始值為(UINTPTR)&amp;__bss_end,表示系統可用記憶體在bss段之後;ROUNDUP用于記憶體向上對齊。函數OsVmPhysAreaSizeAdjust()用于調整實體區的開始位址和大小。⑵處的 OsVmPhysPageNumGet()計算實體記憶體段可以劃分多少實體記憶體頁,此行代碼重新計算實體記憶體頁數目,此時每個實體頁對應一個實體頁結構體,相應結構體也占用記憶體空間。 ⑶處計算實體頁結構體數組的大小,數組的每個元素對應每個實體頁結構體LosVmPage。接下來一行調用函數OsVmBootMemAlloc為實體頁結構體數組g_vmPageArray申請記憶體空間,申請的記憶體空間從位址g_vmBootMemBase截取指定的長度。⑷處再次調用函數OsVmPhysAreaSizeAdjust()用于調整實體記憶體區的開始位址和大小,確定基于記憶體頁對齊。⑸處調用函數OsVmPhysSegAdd()轉換為實體記憶體段,⑹處調用OsVmPhysInit函數初始化實體記憶體段的空閑實體記憶體頁節點連結清單和LRU連結清單。上文分析過這幾個内部函數。⑺處周遊每個實體記憶體段,擷取周遊到的實體記憶體段的總頁數nPage。⑻處為提升初始化實體記憶體頁的性能,把頁數分為8份,count為每份的記憶體頁的數目,left為等分為8份後剩餘的記憶體頁數。⑼處循環初始化實體記憶體頁,⑽處初始化剩餘的實體記憶體頁。⑾處的函數OsVmPageOrderListInit把實體記憶體頁插入到空閑記憶體頁節點連結清單,該函數進一步調用OsVmPhysPagesFreeContiguous函數,後續再分析該函數。初始化完成後,實體記憶體段上的記憶體頁都挂載到空閑記憶體頁節點連結清單上了。

學習過實體記憶體初始化後,接下來我們會分析實體記憶體管理子產品的接口函數,包含申請、釋放、查詢等功能接口。

申請實體記憶體頁的接口有3個,分别用于滿足不同的申請需求。LOS_PhysPagesAllocContiguous函數的傳入參數為要申請實體記憶體頁的數目,傳回值為申請到的實體記憶體頁對應的核心虛拟位址空間中的虛拟記憶體位址。⑴處調用函數OsVmPhysPagesGet申請指定數目的實體記憶體頁,然後⑵處調用函數OsVmPageToVaddr轉換為核心虛拟記憶體位址。函數LOS_PhysPageAlloc申請一個實體記憶體頁,傳回值為申請到的實體頁對應的實體頁結構體位址。代碼比較簡單,見⑶處,調用函數OsVmPageToVaddr傳入ONE_PAGE參數申請1個實體記憶體頁。函數LOS_PhysPagesAlloc用于申請nPages個實體記憶體頁,并挂在雙向連結清單list上,傳回值為實際申請到的實體頁數目。⑷處循環調用函數OsVmPhysPagesGet()申請一個實體記憶體頁,如果申請成功不為空,則插入到雙向連結清單,申請成功的實體頁的數目加1;如果申請失敗則跳出循環。⑹傳回實際申請到的實體頁的數目。

3個記憶體頁申請函數都調用了函數OsVmPhysPagesGet,下文會詳細分析申請實體記憶體頁内部接口實作。

函數OsVmPhysPagesGet用于申請指定數量的實體記憶體頁,傳回值為實體記憶體頁結構體位址。⑴處周遊實體記憶體段數組,對周遊到的實體記憶體段執行⑵處代碼,調用函數OsVmPhysPagesAlloc()從指定的記憶體段中申請指定數目的實體記憶體頁。如果申請成功,則執行⑶把記憶體頁的引用計數初始化為0,根據注釋,如果是連續的記憶體頁,則第一個記憶體頁持有引用計數數值。接下來以後更新記憶體頁的數量,并傳回申請到的記憶體頁的結構體位址;如果申請失敗則繼續循環申請或者傳回NULL。

從上文的介紹,我們知道實體記憶體段包含一個空閑記憶體頁節點連結清單數組,數組大小為9。數組中的每個連結清單上的記憶體頁節點的大小等于2的幂次方個記憶體頁,例如:第0個連結清單上挂載的空閑記憶體節點的大小為2的0次方個記憶體頁,即1個記憶體頁;第8個連結清單上挂載的記憶體頁節點的大小為2的8次方個記憶體頁,即256個記憶體頁。相同大小的記憶體塊挂在同一個連結清單上進行管理。

分析函數OsVmPhysPagesAlloc之前,先看下函數OsVmPagesToOrder,該函數根據指定的實體頁的數目計算屬于空閑記憶體頁節點連結清單數組中的第幾個雙向連結清單。當nPages為最小1時,order取值為0;當為2時,order取值1…等于取底為2的對數Log2(nPages)。

繼續分析下函數OsVmPhysPagesAlloc(),該函數基于傳入參數從指定的記憶體段申請指定數目的記憶體頁。⑴處調用的函數上文已經講述,根據記憶體頁數目計算對外連結表數組索引值。如果索引值小于連結清單最大索引值VM_LIST_ORDER_MAX,則執行⑵從小記憶體頁節點向大記憶體頁節點循環各個雙向連結清單。⑶處擷取雙向連結清單,如果空閑連結清單為空則繼續循環;如果不為空,則執行⑷擷取連結清單上的空閑記憶體頁結構體。

如果根據記憶體頁數計算出的數組索引值大于等于連結清單最大索引值VM_LIST_ORDER_MAX,說明空閑連結清單上并沒有這麼大塊的記憶體頁節點,需要從實體記憶體段上申請,需要執行⑸調用函數OsVmPhysLargeAlloc()申請大的記憶體頁。如果申請不到記憶體頁則申請失敗,傳回NULL;如果申請到合适的記憶體頁,則繼續執行後續DONE标簽代碼。這些代碼從空閑連結清單中删除,拆分,多餘的空閑記憶體頁插入空閑連結清單等,後文繼續分析調用的這些函數。先看下這些參數的實際傳入參數,order為要申請的記憶體頁對應的連結清單數組索引,newOrder為實際申請的記憶體頁對應的連結清單數組索引。⑹處的for循環條件中,&amp;page[nPages]為需要申請的記憶體頁結構體的結束位址,&amp;tmp[1 &lt;&lt; newOrder]表示夥伴算法中空閑記憶體頁節點連結清單上的記憶體塊的結束位址。這裡為啥使用for循環呢,上面申請記憶體時,應該申請了多個記憶體節點拼接起來了。看下⑺處的函數的傳入參數,&amp;page[nPages]為需要申請的記憶體頁結構體的結束位址,往後的部分被拆分放入空閑連結清單。(1 &lt;&lt; min(order, newOrder))表示實際申請的記憶體頁的數目。

當執行到這個函數時,說明空閑連結清單上的單個記憶體頁節點的大小已經不能滿足要求,超過了第9個連結清單上的記憶體頁節點的大小了。⑴處計算需要申請的記憶體大小。⑵從最大的連結清單上進行周遊每一個記憶體頁節點。⑶根據每個記憶體頁的開始記憶體位址,計算需要的記憶體的結束位址,如果超過記憶體段的大小,則繼續周遊下一個記憶體頁節點。

⑷處此時paStart表示目前記憶體頁的結束位址,接下來paStart &gt;= paEnd表示目前記憶體頁的大小滿足申請的需求;paStart &lt; seg-&gt;start和paStart &gt;= (seg-&gt;start + seg-&gt;size)發生溢出錯誤,記憶體頁結束位址不在記憶體段的位址範圍内。⑸處表示目前記憶體頁的下一個記憶體頁結構體,如果該結構體不在空閑連結清單上,則break跳出循環。如果在空閑連結清單上,表示連續的空閑記憶體頁會拼接起來,滿足大記憶體申請的需要。⑹表示一個或者多個連續的記憶體頁的大小滿足申請需求。

内部函數OsVmPhysFreeListDelUnsafe用于從空閑記憶體頁節點連結清單上删除一個記憶體頁節點,名稱中有Unsafe字樣,是因為函數體内并沒有對連結清單操作加自旋鎖,安全性由外部調用函數保證。⑴處進行校驗,確定記憶體段和空閑連結清單索引符合要求。⑵處擷取記憶體段和空閑連結清單,⑶處空閑連結清單上記憶體頁節點數目減1,并把記憶體塊從空閑連結清單上删除。⑷處設定記憶體頁的order索引值為最大值來标記非空閑記憶體頁。

和空閑連結清單上删除對應的函數是空閑連結清單上插入空閑記憶體頁節點函數OsVmPhysFreeListAddUnsafe。⑴處更新記憶體頁的要挂載的空閑連結清單的索引值,然後擷取記憶體頁所在的記憶體段seg,并擷取索引值對應的空閑連結清單。執行⑵把空閑記憶體頁節點插入到空閑連結清單并更新節點數目。

函數OsVmPhysPagesSpiltUnsafe用于分割記憶體塊,參數中oldOrder表示需要申請的記憶體頁節點對應的連結清單索引,newOrder表示實際申請的記憶體頁節點對應的連結清單索引。如果索引值相等,則不需要拆分,不會執行for循環塊的代碼。由于夥伴算法中的連結清單數組中元素的特點,即每個連結清單中的記憶體頁節點的大小等于2的幂次方個記憶體頁。在拆分時,依次從高索引newOrder往低索引oldOrder周遊,拆分一個記憶體頁節點作為空閑記憶體頁節點挂載到對應的空閑連結清單上。⑴處開始循環從高索引到低索引,索引值減1,然後執行⑵擷取夥伴記憶體頁節點,可以看出,申請的記憶體塊大于需求時,會把後半部分的高位址部分放入空閑連結清單,保留前半部分的低位址部分。⑶處的斷言確定夥伴記憶體頁節點索引值是最大值,表示屬于空閑記憶體頁節點。⑷處調用函數把記憶體頁節點放入空閑連結清單。

這裡有必要放這一張圖,直覺示範一下。假如我們需要申請8個記憶體頁大小的記憶體節點,但是隻有freeList[7]連結清單上才有空閑節點。申請成功後,超過了應用需要的大小,需要進行拆分。把2^7個記憶體頁分為2份大小為2^6個記憶體頁的節點,第一份繼續拆分,第二份挂載到freeList[6]連結清單上。然後把第一份2^6個記憶體頁拆分為2個2^5個記憶體頁節點,第一份繼續拆分,第二份挂載到freeList[5]連結清單上。依次進行下去,最後拆分為2份2^3個記憶體頁大小的記憶體頁節點,第一份作為實際申請的記憶體頁傳回,第二份挂載到freeList[3]連結清單上。如下圖紅色部分所示。

萬字解讀鴻蒙輕核心實體記憶體子產品

另外,函數OsVmRecycleExtraPages會調用OsVmPhysPagesFreeContiguous來回收申請的多餘的記憶體頁,後文再分析。

和申請實體記憶體頁接口相對應着,釋放實體記憶體頁的接口有3個,分别用于滿足不同的釋放記憶體頁需求。函數LOS_PhysPagesFreeContiguous的傳入參數為要釋放實體頁對應的核心虛拟位址空間中的虛拟記憶體位址和記憶體頁數目。⑴處調用函數OsVmVaddrToPage把虛拟記憶體位址轉換為實體記憶體頁結構體位址,然後⑵處把記憶體頁的連續記憶體頁數目設定為0。⑶處調用函數OsVmPhysPagesFreeContiguous()釋放實體記憶體頁。函數LOS_PhysPageFree用于釋放一個實體記憶體頁,傳入參數為要釋放的實體頁對應的實體頁結構體位址。⑷處對引用計數自減,當小于等于0,表示沒有其他引用時才進一步執行釋放操作。該函數同樣會調用函數OsVmPhysPagesFreeContiguous()釋放實體記憶體頁。函數LOS_PhysPagesFree用于釋放挂在雙向連結清單上的多個實體記憶體頁,傳回值為實際釋放的實體頁數目。⑸處周遊記憶體頁雙向連結清單,從連結清單上移除要釋放的記憶體頁節點。⑹處代碼和釋放一個記憶體頁的函數代碼相同。⑺處計算周遊的記憶體頁的數目,函數最後會傳回該值。

函數OsVmVaddrToPage把虛拟記憶體位址轉換為實體頁結構體位址。⑴處調用函數LOS_PaddrQuery()把虛拟位址轉為實體位址,該函數在虛實映射部分會詳細講述。⑵處周遊實體記憶體段,如果實體記憶體位址處于實體記憶體段的位址範圍,則可以傳回該實體位址對應的實體頁結構體位址。

函數OsVmPhysPagesFreeContiguous()用于釋放指定數量的連續實體記憶體頁。⑴處根據實體記憶體頁擷取對應的實體記憶體位址。⑵處根據實體記憶體位址擷取空閑記憶體頁連結清單數組索引數值(TODO為什麼實體記憶體位址和索引有對應關系?),⑶處擷取索引值對應的連結清單上的記憶體頁節點的記憶體頁數目。⑷處如果要釋放的記憶體頁數nPages小于目前連結清單上的記憶體頁節點的數目,則跳出循環執行⑹處代碼,去釋放到小索引的雙向連結清單上。⑸處調用函數OsVmPhysPagesFree()釋放指定連結清單上的記憶體頁,然後更新記憶體頁數量和記憶體頁結構體位址。

⑹處根據記憶體頁數量計算對應的連結清單索引,根據索引值計算連結清單上記憶體頁節點的大小。⑺處調用函數OsVmPhysPagesFree()釋放指定連結清單上的記憶體頁,然後更新記憶體頁數量和記憶體頁結構體位址。

函數OsVmPhysPagesFree()釋放記憶體頁到對應的空閑記憶體頁連結清單。⑴做傳入參數校驗。⑵處需要至少是倒數第二個連結清單,這樣記憶體頁節點可以和大索引連結清單上的節點合并。⑶處擷取記憶體頁對應的實體記憶體位址。⑷處的VM_ORDER_TO_PHYS(order)計算對外連結表索引值對應的實體位址,然後進行異或運算計算出夥伴頁的實體記憶體位址。⑸處實體位址轉換為記憶體頁結構體,進一步判斷如果記憶體頁不存在或者不在空閑連結清單上,則跳出循環while循環。否則執行⑹把夥伴頁從連結清單上移除,然後索引值加1。⑺處更新實體位址及其對齊的記憶體頁(TODO 沒有看懂)。當索引order為8,要插入到最後一個連結清單上時,則直接執行⑻插入記憶體頁到連結清單上。

函數LOS_VmPageGet用于根據實體記憶體位址參數計算對應的實體記憶體頁結構體位址。⑴處周遊實體記憶體段,調用函數OsVmPhysToPage根據實體記憶體位址和記憶體段編号計算實體記憶體頁結構體,該函數後文再分析。⑵處如果擷取的實體記憶體頁結構體不為空,則跳出循環,傳回實體記憶體頁結構體指針。

繼續看下函數OsVmPhysToPage的代碼。⑴處如果參數傳入的實體記憶體位址不在指定的實體記憶體段的位址範圍之内則傳回NULL。⑵處計算實體記憶體位址相對記憶體段開始位址的偏移值。⑶處根據偏移值計算出偏移的記憶體頁的數目,然後傳回實體記憶體位址對應的實體頁結構體的位址。

函數LOS_PaddrToKVaddr根據實體位址擷取其對應的核心虛拟位址。⑴處周遊實體記憶體段數組,然後在⑵處判斷如果實體位址處于周遊到的實體記憶體段的位址範圍内,則執行⑶,傳入的實體記憶體位址相對實體記憶體開始位址的偏移加上核心态虛拟位址空間的開始位址就是實體位址對應的核心虛拟位址。

函數OsPhysSharePageCopy用于複制共享記憶體頁。 ⑴處進行參數校驗, ⑵處擷取老記憶體頁, ⑶處擷取記憶體段。⑷處如果老記憶體頁引用計數為1,則把老實體記憶體位址直接指派給新實體記憶體位址。⑸處如果記憶體頁有多個引用,則先轉化為虛拟記憶體位址,然後執行⑹進行記憶體頁的内容複制。⑺重新整理新老記憶體頁的引用計數。

本文首先了解了實體記憶體管理的結構體,接着閱讀了實體記憶體如何初始化,然後分析了實體記憶體的申請、釋放和查詢等操作接口的源代碼。後續也會陸續推出更多的分享文章,敬請期待,有任何問題、建議,都可以留言給我。謝謝。

點選關注,第一時間了解華為雲新鮮技術~

繼續閱讀