1. 程式人生 > >虛幻4藍圖虛擬機器剖析

虛幻4藍圖虛擬機器剖析

2016-11-13 23:22 by 風戀殘雪, 2223 閱讀, 1 評論, 收藏編輯

前言

這裡,我們打算對虛幻4 中藍圖虛擬機器的實現做一個大概的講解,如果對其它的指令碼語言的實現有比較清楚的認識,理解起來會容易很多,我們先會對相關術語進行一個簡單的介紹,然後會對藍圖虛擬機器的實現做一個講解。

術語

程式語言一般分為編譯語言和解釋型語言。

編譯型語言

程式在執行之前需要一個專門的編譯過程,把程式編譯成 為機器語言的檔案,執行時不需要重新翻譯,直接使用編譯的結果就行了。程式執行效率高,依賴編譯器,跨平臺性差些。如C、C++、Delphi等.

解釋性語言

編寫的程式不進行預先編譯,以文字方式儲存程式程式碼。在釋出程式時,看起來省了道編譯工序。但是,在執行程式的時候,解釋性語言必須先解釋再執行。

然而關於Java、C#等是否為解釋型語言存在爭議,因為它們主流的實現並不是直接解釋執行的,而是也編譯成位元組碼,然後再執行在jvm等虛擬機器上的。

UE4中藍圖的實現更像是lua的實現方式,它並不能獨立執行,而是作為一種嵌入宿主語言的一種擴充套件指令碼,lua可以直接解釋執行,也可以編譯成位元組碼並儲存到磁碟上,下次呼叫可以直接載入編譯好的位元組碼執行。

什麼是虛擬機器

虛擬機器最初由波佩克[a]與戈德堡定義為有效的、獨立的真實機器的副本。當前包括跟任何真實機器無關的虛擬機器。虛擬機器根據它們的運用和與直接機器的相關性分為兩大類。系統虛擬機器(如VirtualBox)提供一個可以執行完整作業系統的完整系統平臺。相反的,程式虛擬機器(如Java JVM)為執行單個計算機程式設計,這意謂它支援單個程序。虛擬機器的一個本質特點是執行在虛擬機器上的軟體被侷限在虛擬機器提供的資源裡——它不能超出虛擬世界。

而這裡我們主要關心的是程式虛擬機器,VM既然被稱為"機器",一般認為輸入是滿足某種指令集架構(instruction set architecture,ISA)的指令序列,中間轉換為目標ISA的指令序列並加以執行,輸出為程式的執行結果的,就是VM。源與目標ISA可以是同一種,這是所謂same-ISA VM。

分類

虛擬機器實現分為基於暫存器的虛擬機器和基於棧的虛擬機器。

三地址指令

a = b + c;

如果把它變成這種形式:

add a, b, c

那看起來就更像機器指令了,對吧?這種就是所謂"三地址指令"(3-address instruction),一般形式為:

op dest, src1, src2

許多操作都是二元運算+賦值。三地址指令正好可以指定兩個源和一個目標,能非常靈活的支援二元操作與賦值的組合。ARM處理器的主要指令集就是三地址形式的。

二地址指令

a += b;

變成:

add a, b

這就是所謂"二地址指令",一般形式為:

op dest, src

它要支援二元操作,就只能把其中一個源同時也作為目標。上面的add a, b在執行過後,就會破壞a原有的值,而b的值保持不變。x86系列的處理器就是二地址形式的。

一地址指令

顯然,指令集可以是任意"n地址"的,n屬於自然數。那麼一地址形式的指令集是怎樣的呢?

想像一下這樣一組指令序列:

add 5

sub 3

這隻指定了操作的源,那目標是什麼?一般來說,這種運算的目標是被稱為"累加器"(accumulator)的專用暫存器,所有運算都靠更新累加器的狀態來完成。那麼上面兩條指令用C來寫就類似:

C程式碼 收藏程式碼

acc += 5;

acc -= 3;

只不過acc是"隱藏"的目標。基於累加器的架構近來比較少見了,在很老的機器上繁榮過一段時間。

零地址指令

那"n地址"的n如果是0的話呢?

看這樣一段Java位元組碼:

Java bytecode程式碼 收藏程式碼

iconst_1

iconst_2

iadd

istore_0

注意那個iadd(表示整型加法)指令並沒有任何引數。連源都無法指定了,零地址指令有什麼用??

零地址意味著源與目標都是隱含引數,其實現依賴於一種常見的資料結構——沒錯,就是棧。上面的iconst_1、iconst_2兩條指令,分別向一個叫做"求值棧"(evaluation stack,也叫做operand stack"運算元棧"或者expression stack"表示式棧")的地方壓入整型常量1、2。iadd指令則從求值棧頂彈出2個值,將值相加,然後把結果壓回到棧頂。istore_0指令從求值棧頂彈出一個值,並將值儲存到區域性變數區的第一個位置(slot 0)。

零地址形式的指令集一般就是通過"基於棧的架構"來實現的。請一定要注意,這個棧是指"求值棧",而不是與系統呼叫棧(system call stack,或者就叫system stack)。千萬別弄混了。有些虛擬機器把求值棧實現在系統呼叫棧上,但兩者概念上不是一個東西。

由於指令的源與目標都是隱含的,零地址指令的"密度"可以非常高——可以用更少空間放下更多條指令。因此在空間緊缺的環境中,零地址指令是種可取的設計。但零地址指令要完成一件事情,一般會比二地址或者三地址指令許多更多條指令。上面Java位元組碼做的加法,如果用x86指令兩條就能完成了:

mov eax, 1

add eax, 2

基於棧與基於暫存器結構的區別

  1. 儲存臨時值的位置不同
  • 基於棧:將臨時值儲存在求值棧上。
  • 基於暫存器:將臨時值儲存在暫存器中。
  1. 程式碼所佔體積不同
  • 基於棧:程式碼緊湊,體積小,但所需要的程式碼條件多
  • 基於暫存器:程式碼相對大些,但所需要的程式碼條件少

基於棧中的"棧"指的是"求值棧",JVM中"求值棧"被稱為"運算元棧"。

棧幀

棧幀也叫過程活動記錄,是編譯器用來實現過程/函式呼叫的一種資料結構。從邏輯上講,棧幀就是一個函式執行的環境:函式引數、函式的區域性變數、函式執行完後返回到哪裡等等。

藍圖虛擬機器的實現

前面我們已經簡單得介紹了虛擬機器的相關術語,接下來我們來具體講解下虛幻4中藍圖虛擬機器的實現。

位元組碼

虛擬機器的位元組碼在Script.h檔案中,這裡我們把它全部列出來,因為是專用的指令碼語言,所以它裡面會有一些特殊的位元組碼,如代理相關的程式碼(EX_BindDelegate、EX_AddMulticastDelegate),當然常用的語句也是有的,比如賦值、無條件跳轉指令、條件跳轉指令、switch等。

複製程式碼

  1 //
  2 
  3 // Evaluatable expression item types.
  4 
  5 //
  6 
  7 enum EExprToken
  8 
  9 {
 10 
 11     // Variable references.
 12 
 13     EX_LocalVariable        = 0x00,    // A local variable.
 14 
 15     EX_InstanceVariable        = 0x01,    // An object variable.
 16 
 17     EX_DefaultVariable        = 0x02, // Default variable for a class context.
 18 
 19     //                        = 0x03,
 20 
 21     EX_Return                = 0x04,    // Return from function.
 22 
 23     //                        = 0x05,
 24 
 25     EX_Jump                    = 0x06,    // Goto a local address in code.
 26 
 27     EX_JumpIfNot            = 0x07,    // Goto if not expression.
 28 
 29     //                        = 0x08,
 30 
 31     EX_Assert                = 0x09,    // Assertion.
 32 
 33     //                        = 0x0A,
 34 
 35     EX_Nothing                = 0x0B,    // No operation.
 36 
 37     //                        = 0x0C,
 38 
 39     //                        = 0x0D,
 40 
 41     //                        = 0x0E,
 42 
 43     EX_Let                    = 0x0F,    // Assign an arbitrary size value to a variable.
 44 
 45     //                        = 0x10,
 46 
 47     //                        = 0x11,
 48 
 49     EX_ClassContext            = 0x12,    // Class default object context.
 50 
 51     EX_MetaCast = 0x13, // Metaclass cast.
 52 
 53     EX_LetBool                = 0x14, // Let boolean variable.
 54 
 55     EX_EndParmValue            = 0x15,    // end of default value for optional function parameter
 56 
 57     EX_EndFunctionParms        = 0x16,    // End of function call parameters.
 58 
 59     EX_Self                    = 0x17,    // Self object.
 60 
 61     EX_Skip                    = 0x18,    // Skippable expression.
 62 
 63     EX_Context                = 0x19,    // Call a function through an object context.
 64 
 65     EX_Context_FailSilent    = 0x1A, // Call a function through an object context (can fail silently if the context is NULL; only generated for functions that don't have output or return values).
 66 
 67     EX_VirtualFunction        = 0x1B,    // A function call with parameters.
 68 
 69     EX_FinalFunction        = 0x1C,    // A prebound function call with parameters.
 70 
 71     EX_IntConst                = 0x1D,    // Int constant.
 72 
 73     EX_FloatConst            = 0x1E,    // Floating point constant.
 74 
 75     EX_StringConst            = 0x1F,    // String constant.
 76 
 77     EX_ObjectConst         = 0x20,    // An object constant.
 78 
 79     EX_NameConst            = 0x21,    // A name constant.
 80 
 81     EX_RotationConst        = 0x22,    // A rotation constant.
 82 
 83     EX_VectorConst            = 0x23,    // A vector constant.
 84 
 85     EX_ByteConst            = 0x24,    // A byte constant.
 86 
 87     EX_IntZero                = 0x25,    // Zero.
 88 
 89     EX_IntOne                = 0x26,    // One.
 90 
 91     EX_True                    = 0x27,    // Bool True.
 92 
 93     EX_False                = 0x28,    // Bool False.
 94 
 95     EX_TextConst            = 0x29, // FText constant
 96 
 97     EX_NoObject                = 0x2A,    // NoObject.
 98 
 99     EX_TransformConst        = 0x2B, // A transform constant
100 
101     EX_IntConstByte            = 0x2C,    // Int constant that requires 1 byte.
102 
103     EX_NoInterface            = 0x2D, // A null interface (similar to EX_NoObject, but for interfaces)
104 
105     EX_DynamicCast            = 0x2E,    // Safe dynamic class casting.
106 
107     EX_StructConst            = 0x2F, // An arbitrary UStruct constant
108 
109     EX_EndStructConst        = 0x30, // End of UStruct constant
110 
111     EX_SetArray                = 0x31, // Set the value of arbitrary array
112 
113     EX_EndArray                = 0x32,
114 
115     //                        = 0x33,
116 
117     EX_UnicodeStringConst = 0x34, // Unicode string constant.
118 
119     EX_Int64Const            = 0x35,    // 64-bit integer constant.
120 
121     EX_UInt64Const            = 0x36,    // 64-bit unsigned integer constant.
122 
123     //                        = 0x37,
124 
125     EX_PrimitiveCast        = 0x38,    // A casting operator for primitives which reads the type as the subsequent byte
126 
127     //                        = 0x39,
128 
129     //                        = 0x3A,
130 
131     //                        = 0x3B,
132 
133     //                        = 0x3C,
134 
135     //                        = 0x3D,
136 
137     //                        = 0x3E,
138 
139     //                        = 0x3F,
140 
141     //                        = 0x40,
142 
143     //                        = 0x41,
144 
145     EX_StructMemberContext    = 0x42, // Context expression to address a property within a struct
146 
147     EX_LetMulticastDelegate    = 0x43, // Assignment to a multi-cast delegate
148 
149     EX_LetDelegate            = 0x44, // Assignment to a delegate
150 
151     //                        = 0x45,
152 
153     //                        = 0x46, // CST_ObjectToInterface
154 
155     //                        = 0x47, // CST_ObjectToBool
156 
157     EX_LocalOutVariable        = 0x48, // local out (pass by reference) function parameter
158 
159     //                        = 0x49, // CST_InterfaceToBool
160 
161     EX_DeprecatedOp4A        = 0x4A,
162 
163     EX_InstanceDelegate        = 0x4B,    // const reference to a delegate or normal function object
164 
165     EX_PushExecutionFlow    = 0x4C, // push an address on to the execution flow stack for future execution when a EX_PopExecutionFlow is executed. Execution continues on normally and doesn't change to the pushed address.
166 
167     EX_PopExecutionFlow        = 0x4D, // continue execution at the last address previously pushed onto the execution flow stack.
168 
169     EX_ComputedJump            = 0x4E,    // Goto a local address in code, specified by an integer value.
170 
171     EX_PopExecutionFlowIfNot = 0x4F, // continue execution at the last address previously pushed onto the execution flow stack, if the condition is not true.
172 
173     EX_Breakpoint            = 0x50, // Breakpoint. Only observed in the editor, otherwise it behaves like EX_Nothing.
174 
175     EX_InterfaceContext        = 0x51,    // Call a function through a native interface variable
176 
177     EX_ObjToInterfaceCast = 0x52,    // Converting an object reference to native interface variable
178 
179     EX_EndOfScript            = 0x53, // Last byte in script code
180 
181     EX_CrossInterfaceCast    = 0x54, // Converting an interface variable reference to native interface variable
182 
183     EX_InterfaceToObjCast = 0x55, // Converting an interface variable reference to an object
184 
185     //                        = 0x56,
186 
187     //                        = 0x57,
188 
189     //                        = 0x58,
190 
191     //                        = 0x59,
192 
193     EX_WireTracepoint        = 0x5A, // Trace point. Only observed in the editor, otherwise it behaves like EX_Nothing.
194 
195     EX_SkipOffsetConst        = 0x5B, // A CodeSizeSkipOffset constant
196 
197     EX_AddMulticastDelegate = 0x5C, // Adds a delegate to a multicast delegate's targets
198 
199     EX_ClearMulticastDelegate = 0x5D, // Clears all delegates in a multicast target
200 
201     EX_Tracepoint            = 0x5E, // Trace point. Only observed in the editor, otherwise it behaves like EX_Nothing.
202 
203     EX_LetObj                = 0x5F,    // assign to any object ref pointer
204 
205     EX_LetWeakObjPtr        = 0x60, // assign to a weak object pointer
206 
207     EX_BindDelegate            = 0x61, // bind object and name to delegate
208 
209     EX_RemoveMulticastDelegate = 0x62, // Remove a delegate from a multicast delegate's targets
210 
211     EX_CallMulticastDelegate = 0x63, // Call multicast delegate
212 
213     EX_LetValueOnPersistentFrame = 0x64,
214 
215     EX_ArrayConst            = 0x65,
216 
217     EX_EndArrayConst        = 0x66,
218 
219     EX_AssetConst            = 0x67,
220 
221     EX_CallMath                = 0x68, // static pure function from on local call space
222 
223     EX_SwitchValue            = 0x69,
224 
225     EX_InstrumentationEvent    = 0x6A, // Instrumentation event
226 
227     EX_ArrayGetByRef        = 0x6B,
228 
229     EX_Max                    = 0x100,
230 
231 };

複製程式碼

棧幀

在Stack.h中我們可以找到FFrame的定義,雖然它定義的是一個結構體,但是執行當前程式碼的邏輯是封裝在這裡面的。下面讓我們看一下它的資料成員:

複製程式碼

 1   // Variables.
 2 
 3     UFunction* Node;
 4 
 5     UObject* Object;
 6 
 7     uint8* Code;
 8 
 9     uint8* Locals;
10 
11  
12 
13     UProperty* MostRecentProperty;
14 
15     uint8* MostRecentPropertyAddress;
16 
17  
18 
19     /** The execution flow stack for compiled Kismet code */
20 
21     FlowStackType FlowStack;
22 
23  
24 
25     /** Previous frame on the stack */
26 
27     FFrame* PreviousFrame;
28 
29  
30 
31     /** contains information on any out parameters */
32 
33     FOutParmRec* OutParms;
34 
35  
36 
37     /** If a class is compiled in then this is set to the property chain for compiled-in functions. In that case, we follow the links to setup the args instead of executing by code. */
38 
39     UField* PropertyChainForCompiledIn;
40 
41  
42 
43     /** Currently executed native function */
44 
45     UFunction* CurrentNativeFunction;
46 
47  
48 
49     bool bArrayContextFailed;

複製程式碼

我們可以看到,它裡面儲存了當前執行的指令碼函式,執行該指令碼的UObject,當前程式碼的執行位置,區域性變數,上一個棧幀,呼叫返回的引數(不是返回值),當前執行的原生函式等。而呼叫函式的返回值是放在了函式呼叫之前儲存,呼叫結束後再恢復。大致如下所示:

複製程式碼

1 uint8 * SaveCode = Stack.Code;
2 
3 // Call function
4 
5 ….
6 
7 Stack.Code = SaveCode

複製程式碼

下面我們列出FFrame中跟執行相關的重要函式:

複製程式碼

  1     // Functions.
  2 
  3     COREUOBJECT_API void Step( UObject* Context, RESULT_DECL );
  4 
  5  
  6 
  7     /** Replacement for Step that uses an explicitly specified property to unpack arguments **/
  8 
  9     COREUOBJECT_API void StepExplicitProperty(void*const Result, UProperty* Property);
 10 
 11  
 12 
 13     /** Replacement for Step that checks the for byte code, and if none exists, then PropertyChainForCompiledIn is used. Also, makes an effort to verify that the params are in the correct order and the types are compatible. **/
 14 
 15     template<class TProperty>
 16 
 17     FORCEINLINE_DEBUGGABLE void StepCompiledIn(void*const Result);
 18 
 19  
 20 
 21     /** Replacement for Step that checks the for byte code, and if none exists, then PropertyChainForCompiledIn is used. Also, makes an effort to verify that the params are in the correct order and the types are compatible. **/
 22 
 23     template<class TProperty, typename TNativeType>
 24 
 25     FORCEINLINE_DEBUGGABLE TNativeType& StepCompiledInRef(void*const TemporaryBuffer);
 26 
 27  
 28 
 29     COREUOBJECT_API virtual void Serialize( const TCHAR* V, ELogVerbosity::Type Verbosity, const class FName& Category ) override;
 30 
 31     
 32 
 33     COREUOBJECT_API static void KismetExecutionMessage(const TCHAR* Message, ELogVerbosity::Type Verbosity, FName WarningId = FName());
 34 
 35  
 36 
 37     /** Returns the current script op code */
 38 
 39     const uint8 PeekCode() const { return *Code; }
 40 
 41  
 42 
 43     /** Skips over the number of op codes specified by NumOps */
 44 
 45     void SkipCode(const int32 NumOps) { Code += NumOps; }
 46 
 47  
 48 
 49     template<typename TNumericType>
 50 
 51     TNumericType ReadInt();
 52 
 53     float ReadFloat();
 54 
 55     FName ReadName();
 56 
 57     UObject* ReadObject();
 58 
 59     int32 ReadWord();
 60 
 61     UProperty* ReadProperty();
 62 
 63  
 64 
 65     /** May return null */
 66 
 67     UProperty* ReadPropertyUnchecked();
 68 
 69  
 70 
 71     /**
 72 
 73      * Reads a value from the bytestream, which represents the number of bytes to advance
 74 
 75      * the code pointer for certain expressions.
 76 
 77      *
 78 
 79      * @param    ExpressionField        receives a pointer to the field representing the expression; used by various execs
 80 
 81      *                                to drive VM logic
 82 
 83      */
 84 
 85     CodeSkipSizeType ReadCodeSkipCount();
 86 
 87  
 88 
 89     /**
 90 
 91      * Reads a value from the bytestream which represents the number of bytes that should be zero'd out if a NULL context
 92 
 93      * is encountered
 94 
 95      *
 96 
 97      * @param    ExpressionField        receives a pointer to the field representing the expression; used by various execs
 98 
 99      *                                to drive VM logic
100 
101      */
102 
103     VariableSizeType ReadVariableSize(UProperty** ExpressionField);

複製程式碼

像ReadInt()、ReadFloat()、ReadObject()等這些函式,我們看到它的名字就知道它是做什麼的,就是從程式碼中讀取相應的int、float、UObject等。這裡我們主要說下Step()函式,它的程式碼如下所示:

複製程式碼

1 void FFrame::Step(UObject *Context, RESULT_DECL)
2 
3 {
4 
5     int32 B = *Code++;
6 
7     (Context->*GNatives[B])(*this,RESULT_PARAM);
8 
9 }

複製程式碼

可以看到,它的主要作用就是取出指令,然後在原生函式陣列中找到對應的函式去執行。

位元組碼對應函式

前面我們列出了所有的虛擬機器的所有位元組碼,那麼對應每個位元組碼具體執行部分的程式碼在哪裡呢,具體可以到ScriptCore.cpp中查詢定義,我們可以看到每個位元組碼對應的原生函式都在GNatives和GCasts裡面:

它們的宣告如下:

複製程式碼

1 /** The type of a native function callable by script */
2 
3 typedef void (UObject::*Native)( FFrame& TheStack, RESULT_DECL );
4 
5 Native GCasts[];
6 
7 Native GNatives[EX_Max];

複製程式碼

這樣它都會對每一個原生函式呼叫一下注冊方法,通過IMPLEMENT_VM_FUNCTION和IMPLEMENT_CAST_FUNCTION巨集實現。

具體程式碼如下圖所示:

複製程式碼

 1 #define IMPLEMENT_FUNCTION(cls,func) \
 2 
 3     static FNativeFunctionRegistrar cls##func##Registar(cls::StaticClass(),#func,(Native)&cls::func);
 4 
 5  
 6 
 7 #define IMPLEMENT_CAST_FUNCTION(cls, CastIndex, func) \
 8 
 9     IMPLEMENT_FUNCTION(cls, func); \
10 
11     static uint8 cls##func##CastTemp = GRegisterCast( CastIndex, (Native)&cls::func );
12 
13  
14 
15 #define IMPLEMENT_VM_FUNCTION(BytecodeIndex, func) \
16 
17     IMPLEMENT_FUNCTION(UObject, func) \
18 
19     static uint8 UObject##func##BytecodeTemp = GRegisterNative( BytecodeIndex, (Native)&UObject::func );

複製程式碼

可以看到,它是定義了一個全域性靜態物件,這樣就會在程式的main函式執行前就已經把函式放在陣列中對應的位置了,這樣在虛擬機器執行時就可以直接呼叫到對應的原生函數了。

執行流程

我們前面講藍圖的時候講過藍圖如何跟C++互動,包括藍圖呼叫C++程式碼,以及從C++程式碼呼叫到藍圖裡面去。

C++呼叫藍圖函式

複製程式碼

 1 UFUNCTION(BlueprintImplementableEvent, Category = "AReflectionStudyGameMode")
 2 
 3 void ImplementableFuncTest();
 4 
 5  
 6 
 7 void AReflectionStudyGameMode::ImplementableFuncTest()
 8 
 9 {
10 
11 ProcessEvent(FindFunctionChecked(REFLECTIONSTUDY_ImplementableFuncTest),NULL);
12 
13 }

複製程式碼

因為我們這個函式沒有引數,所有ProcessEvent中傳了一個NULL,如果是有引數和返回值等,那麼UHT會自動生成一個結構體用於儲存引數和返回值等,這樣當在C++裡面呼叫函式時,就會去找REFLECTIONSTUDY_ImplementableFuncTest這個名字對應的藍圖UFunction,如果找到那麼就會呼叫ProcessEvent來做進一步的處理。

ProcessEvent流程

藍圖呼叫C++函式

複製程式碼

 1 UFUNCTION(BlueprintCallable, Category = "AReflectionStudyGameMode")
 2 
 3 void CallableFuncTest();
 4 
 5  
 6 
 7 DECLARE_FUNCTION(execCallableFuncTest) \
 8 
 9 { \
10 
11 P_FINISH; \
12 
13 P_NATIVE_BEGIN; \
14 
15 this->CallableFuncTest(); \
16 
17 P_NATIVE_END; \
18 
19 }

複製程式碼

如果是通過藍圖呼叫的C++函式,那麼UHT會生成如上的程式碼,並且如果有引數的話,會呼叫P_GET_UBOOL等來獲取對應的引數,如果有返回值的話也會將返回值賦值。

總結

至此,加上前面我們對藍圖編譯的剖析,加上藍圖虛擬機器的講解,我們已經對藍圖的實現原理有一個比較深入的瞭解,本文並沒有對藍圖的前身unrealscript進行詳細的講解。有了這個比較深入的認識後(如果想要有深刻的認識,必須自己去看程式碼),相信大家在設計藍圖時會更遊刃有餘。當然如果有錯誤的地方也請大家指正,歡迎大家踴躍討論。接下來可能會把重心放到虛幻4渲染相關的模組上,包括渲染API跨平臺相關,多執行緒渲染,渲染流程,以及渲染演算法上面,可能中間也會穿插一些其他的模組(比如動畫、AI等),歡迎大家持續關注,如果你有想提前瞭解的章節,也歡迎在下面留言,我可能會根據大家的留言來做優先順序調整。

參考文章

作者: 風戀殘雪