换种方法学操作系统,轻松入门Linux内核
對于軟件工程師,理解操作系統的工作原理和關鍵機制是設計高質量應用程序的前提,但要做到這一點是十分困難的。
一方面,操作系統設計涉及計算機科學與工程學科的方方面面,包括數據結構與算法、計算機組成與系統結構、計算機網絡,甚至程序設計語言與編譯系統等核心知識,以及并發、同步和通信等核心概念。
另一方面,作為一個復雜龐大的軟件產品,理解操作系統更需要理論與實踐深度結合。
操作系統的相關學習資料十分豐富。有闡述基本原理者,有剖析典型系統者,還有構造示例系統者;有面向專業理論者,亦有面向應用實踐者。角度多種多樣,內容簡繁不一。
?本書的最大特點在于作者結合其多年的Linux操作系統實際教學經驗編撰而成。作為一位經驗豐富的高級軟件工程師和專業教師,本書作者基于自己學習和研究Linux的心得,創新性地以一個mykernel和MenuOS為基礎實驗平臺進行教學和實驗組織,實現了理論學習與工程實踐的自然融合,達到了事半功倍的效果。
同時,書中設計了豐富的單元測試題和實驗,引導讀者循序漸進地掌握所學知識,并有效地促進讀者深入思考和實踐所學內容。
作者基于本書開設的操作系統課程,其教學形式涉及面對面的課堂教學和在線慕課教學,選課對象既包括軟件工程碩士,又包括一般工程實踐者,學習人數已數以萬計。本書的出版體現了作者認真吸收大量的學員反饋,不斷優化課程的教學內容和過程組織的成果。
本文重點介紹計算機的工作原理,具體涉及存儲程序計算機工作模型、基本的匯編語言,以及C語言程序匯編出來的匯編代碼如何在存儲程序計算機工作模型上一步步地執行。其中重點分析了函數調用堆棧相關匯編指令,如call/ret和pushl/popl。
存儲程序計算機工作模型
存儲程序計算機的概念雖然簡單,但在計算機發展史上具有革命性的意義,至今為止仍是計算機發展史上非常有意義的發明。一臺硬件有限的計算機或智能手機能安裝各種各樣的軟件,執行各種各樣的程序,這在人們看起來都理所當然,其實背后是存儲程序計算機的功勞。
存儲程序計算機的主要思想是將程序存放在計算機存儲器中,然后按存儲器中的存儲程序的首地址執行程序的第一條指令,以后就按照該程序中編寫好的指令執行,直至程序執行結束。
相信很多人特別是學習計算機專業的人都聽說過圖靈機和馮·諾依曼機。圖靈機關注計算的哲學定義,是一種虛擬的抽象機器,是對現代計算機的首次描述。只要提供合適的程序,圖靈機就可以做任何運算。基于圖靈機建造的計算機都是在存儲器中存儲數據,程序的邏輯都是嵌入在硬件中的。
與圖靈機不同,馮·諾依曼機是一個實際的體系結構,我們稱作馮·諾依曼體系結構,它至今仍是幾乎所有計算機平臺的基礎。我們都知道“庖丁解牛”這個成語,比喻經過反復實踐,掌握了事物的客觀規律,做事得心應手,運用自如。馮·諾依曼體系結構就是各種計算機體系結構需要遵從的一個“客觀規律”,了解它對于理解計算機和操作系統非常重要。下面,我們就來看看什么是馮·諾依曼體系結構。
在1944~1945年期間,馮·諾依曼指出程序和數據在邏輯上是相同的,程序也可以存儲在存儲器中。馮·諾依曼體系結構的要點包括:
馮·諾依曼體系結構如圖1-1所示,其中運算器、存儲器、控制器、輸入設備和輸出設備5大基本類型部件組成了計算機硬件;
圖1-1??馮·諾依曼體系結構
計算機內部采用二進制來表示指令和數據;?
將編寫好的程序和數據先存入存儲器中,然后啟動計算機工作,這就是存儲程序的基本含義。
計算機硬件的基礎是CPU,它與內存和輸入/輸出(I/O)設備進行交互,從輸入設備接收數據,向輸出設備發送數據。
CPU由運算器(算術邏輯單元ALU)、控制器和一些寄存器組成。有一個非常重要的寄存器稱為程序計數器,在IA32(x86-32)中是EIP,指示將要執行的下一條指令在存儲器中的地址。
C/C++程序員可以將EIP看作一個指針,因為它總是指向某一條指令的地址。CPU就是從EIP指向的那個地址取過來一條指令執行,執行完后EIP會自動加一,執行下一條指令,然后再取下一條指令執行,CPU像“貪吃蛇”一樣總是在內存里“吃”指令。
CPU、內存和I/O設備通過總線連接。內存中存放指令和數據。
“計算機內部采用二進制來表示指令和數據”表明,指令和數據的功能和處理是不同的,但都可以用二進制的方式存儲在內存中。
上述第3個要點指出了馮·諾依曼體系結構的核心是存儲程序計算機。
我們用程序員的思維來對存儲程序計算機進行抽象,如圖1-2所示。?
圖1-2??存儲程序計算機工作原理示意圖
我們可以把CPU抽象成一個for循環,因為它總是在執行next instruction(下一條指令),然后從內存里取下一條指令來執行。從這個角度來看,內存保存指令和數據,CPU負責解釋和執行這些指令,它們通過總線連接起來。這里揭示了計算機可以自動化執行程序的原理。
這里存在一個問題,CPU能識別什么樣的指令,我們這里需要有一個定義。學過編程的讀者基本都知道API,也就是應用程序編程接口。
而對于程序員來講,還有一個稱為ABI的接口,它主要是一些指令的編碼。在指令編碼方面,我們不會涉及那么具體的細節,而只會涉及和匯編相關的內容。
至于這些指令是如何編碼成二進制機器指令的,我們不必關心,有興趣的讀者可以查找指令編碼的相關資料。此外,這些指令會涉及一些寄存器,這些寄存器有些約定,我們約定什么樣的指令該用什么寄存器。
同時,我們也需要了解寄存器的布局。還有,大多數指令可以直接訪問內存,對于x86-32計算機指令集來講,這也是一個重要的概念。
對于x86-32計算機,有一個EIP寄存器指向內存的某一條指令,EIP是自動加一的(不是一個字節,也不是32位,而是加一條指令),雖然x86-32中每條指令占的存儲空間不一樣,但是它能智能地自動加到下一條指令,它還可以被其他指令修改,如call、ret、jmp等,這些指令對應C語言中的函數調用、return和if else語句。
現在絕大多數具有計算功能的設備,小到智能手機,大到超級計算機,基本的核心部分可以用馮·諾依曼體系結構(存儲程序計算機)來描述。因此,存儲程序計算機是一個非常基本的概念,是我們理解計算機系統工作原理的基礎。
x86-32匯編基礎
Intel處理器系列也稱為x86,經過不斷的發展,體系結構經歷了16位、32位和64位幾個關鍵階段。32位的體系結構稱為IA32,64位體系結構稱為x86-64,但為了明確區分兩者,本書中把32位體系結構稱作x86-32。本書與Linux內核采用的匯編格式保持一致,采用AT&T匯編格式。
1.x86-32 CPU的寄存器
為了便于讀者理解,下面先來介紹16位的8086 CPU的寄存器。8086 CPU中總共有14個16位的寄存器:AX、BX、CX、DX、SP、BP、SI、DI、IP、FLAG、CS、DS、SS和ES。這14個寄存器分為通用寄存器、控制寄存器和段寄存器3種類型。
通用寄存器又分為數據寄存器、指針寄存器和變址寄存器。
AX、BX、CX和DX統稱為數據寄存器。
qAX(Accumulator):累加寄存器,也稱為累加器。
qBX(Base):基地址寄存器。
qCX(Count):計數器寄存器。
qDX(Data):數據寄存器。
SP和BP 統稱為指針寄存器。
qSP(Stack Pointer):堆棧指針寄存器。
qBP(Base Pointer):基指針寄存器。
SI和DI統稱為變址寄存器。
qSI(Source Index):源變址寄存器。
qDI(Destination Index):目的變址寄存器。
控制寄存器主要分為指令指針寄存器和標志寄存器。
qIP(Instruction Pointer):指令指針寄存器。
qFLAG:標志寄存器。
段寄存器主要有代碼段寄存器、數據段寄存器、堆棧段寄存器和附加段寄存器。
qCS(Code?Segment):代碼段寄存器。
qDS(Data Segment):數據段寄存器。
qSS(Stack Segment):堆棧段寄存器。
qES(Extra Segment):附加段寄存器。
以上數據寄存器AX、BX、CX和DX都可以當作兩個單獨的8位寄存器來使用,如圖1-3所示,以AX寄存器為例。
圖1-3??AX 寄存器示意圖
qAX寄存器可以分為兩個獨立的8位的AH和AL寄存器。
qBX寄存器可以分為兩個獨立的8位的BH和BL寄存器。
qCX寄存器可以分為兩個獨立的8位的CH和CL寄存器。
qDX寄存器可以分為兩個獨立的8位的DH和DL寄存器。
除了上面4個數據寄存器以外,其他寄存器均不可以分為兩個獨立的8位寄存器。注意,每個分開的寄存器都有自己的名稱,可以獨立存取。程序員可以利用數據寄存器的這種“可分可合”的特性,靈活地處理字/字節的信息。
了解了16位的8086 CPU的寄存器之后,我們再來看32位的寄存器。
IA32所含有的寄存器包括:
q4個數據寄存器(EAX、EBX、ECX和EDX)。
q2個變址和指針寄存器(ESI和EDI)。
q2個指針寄存器(ESP和EBP)。
q6個段寄存器(ES、CS、SS、DS、FS和GS)。
q1個指令指針寄存器(EIP)。
q1個標志寄存器(EFlags)。
32位寄存器只是把對應的16位寄存器擴展到了32位,如圖1-4所示為EAX寄存器示意圖,它增加了一個E。所有開頭為E的寄存器,一般是32位的。
EAX累加寄存器、EBX基址寄存器、ECX計數寄存器和EDX數據寄存器都是通用寄存器,程序員在寫匯編碼時可以自己定義如何使用它們。EBP是堆棧基址指針,比較重要;ESI、EDI是變址寄存器;ESP也比較重要,它是堆棧棧頂寄存器。
這里可能會涉及堆棧的概念,學過數據結構課程的讀者應該知道堆棧的概念,本書后面會具體講到push指令壓棧和pop指令出棧,它是向一個堆棧里面壓一個數據和從堆棧里面彈出一個數據。這些都是32位的通用寄存器。
圖1-4??EAX寄存器示意圖
值得注意的是在16位CPU中,AX、BX、CX和DX不能作為基址和變址寄存器來存放存儲單元的地址,但在32位CPU中,32位寄存器EAX、EBX、ECX和EDX不僅可以傳送數據、暫存數據保存算術邏輯運算結果,還可以作為指針寄存器,因此這些32位寄存器更具通用性。
除了通用寄存器外,還有一些段寄存器。雖然段寄存器在本書中用得比較少,但還是要了解一下。除了CS、DS、ES和SS外,還有其他附加段寄存器FS和GS。
常用的是CS寄存器和SS寄存器。我們的指令都存儲在代碼段,在定位一個指令時,使用CS:EIP來準確指明它的地址。
也就是說,首先需要知道代碼在哪一個代碼段里,然后需要知道指令在代碼段內的相對偏移地址EIP,一般用CS:EIP準確地標明一個指令的內存地址。還有堆棧段,每一個進程都有自己的堆棧段(在Linux系統里,每個進程都有一個內核態堆棧和一個用戶態堆棧)。
標志寄存器的功能細節比較復雜煩瑣,本書就不仔細介紹了,讀者知道標志寄存器可以保存當前的一些狀態就可以了。
現在主流的計算機大多都是采用64位的CPU,那么我們也需要簡單了解一下x86-64的寄存器。實際上,64位和32位的寄存器差別也不大,它只是從32位擴展到了64位。前面帶個“R”的都是指64位寄存器,如RAX、RBX、RCX、RDX、RBP、RSI、RSP,還有Flags改為了RFLAGS,EIP改為了RIP。
另外,還增加了更多的通用寄存器,如R8、R9等,這些增加的通用寄存器和其他通用寄存器只是名稱不一樣,在使用中都是遵循調用者使用規則,簡單說就是隨便用。
2.數據格式
在Intel的術語規范中,字表示16位數據類型;在IA32中,32位數稱為雙字;在x86-64中,64位數稱為四字。圖1-5所示為C語言中基本類型的IA32表示,其中列出的匯編代碼后綴在匯編代碼中會經常看到。?
圖1-5??C語言中基本類型的IA32表示
3.尋址方式和常用匯編指令
匯編指令包含操作碼和操作數,其中操作數分為以下3種:
(1)立即數即常數,如$8,用$開頭后面跟一個數值;
(2)寄存器數,表示某個寄存器中保存的值,如%eax;而對字節操作而言,是8個單字節寄存器中的一個,如%al(EAX寄存器中的低8位);
(3)存儲器引用,根據計算出的有效地址來訪問存儲器的某個位置。
還有一些常見的匯編指令,我們來看它們是如何工作的。最常見的匯編指令是mov指令,movl中的l是指32位,movb中的b是指8位,movw中的w是指16位,movq中的q是指64位。我們以32位為主進行介紹。
首先介紹寄存器尋址。所謂寄存器尋址就是操作的是寄存器,不和內存打交道,如%eax,其中%開頭后面跟一個寄存器名稱。
movl?%eax,%edx
上述代碼把寄存器%eax的內容放到%edx中。如果把寄存器名當作C語言代碼中的變量名,它就相當于:
edx?=?eax;
立即尋址(immediate)是用一個$開頭后面跟一個數值。例如:
movl?$0x123,?%edx
就是把0x123這個十六進制的數值直接放到EDX寄存器中。如果把寄存器名當作C語言代碼中的變量名,它就相當于:
edx?=?0x123;
立即尋址也和內存沒有關系。
直接尋址(direct)是直接用一個數值,開頭沒有$符號。開頭有$符號的數值表示這是一個立即數;沒有$符號表示這是一個地址。例如:
movl?0x123,?%edx
就是把十六進制的0x123內存地址所指向的那塊內存里存儲的數據放到EDX寄存器里,這相當于C語言代碼:
edx?=?*(int*)0x123;
把0x123這個數值強制轉化為一個32位的int型變量的指針,再用一個*取它指向的值,然后放到EDX寄存器中,這就稱為直接尋址。
換句話說,就是用內存地址直接訪問內存中的數據。
間接尋址就是寄存器加個小括號。舉例說明,%ebx這個寄存器中存的值是一個內存地址,加個小括號表示這個內存地址所存儲的數據,我們把它放到EDX寄存器中:
move?(%ebx),?%edx
就相當于:
edx?=?*(int*)ebx;
把這個EBX寄存器中存儲的數值強制轉化為一個32位的int型變量的指針,再用一個*取它指向的值,然后放到EDX寄存器中,這稱為間接尋址。
變址尋址比間接尋址稍微復雜一點。例如:
movl?4(%ebx),?%edx
讀者會發現代碼中“(%ebx)”前面出現了一個4,也就是在間接尋址的基礎上,在原地址上加上一個立即數4,相當于:?
edx?=?*(int*)(ebx+4)
把這個EBX寄存器存儲的數值加4,然后強制轉化為一個32位的int類型的指針,再用一個*取它指向的值,然后放到EDX寄存器中,這稱為變址尋址。
如上所述的CPU對寄存器和內存的操作方法,都是比較基礎的知識,需要牢固掌握。
x86-32中的大多數指令都能直接訪問內存,但還有一些指令能直接對內存操作,如push/pop。它們根據ESP寄存器指向的內存位置進行壓棧和出棧操作,注意這是指令執行過程中默認使用了特定的寄存器。
還需要特別說明的是,本書中使用的是AT&T匯編格式,這也是Linux內核使用的匯編格式,與Intel匯編格式略有不同。
我們在搜索資料時可能會遇到Intel匯編代碼,一般來說,全是大寫字母的一般是Intel匯編,全是小寫字母的一般是AT&T匯編。
本書中的代碼用到的寄存器名稱都遵守AT&T匯編格式采用全小寫的方式,而正文中需要使用寄存器名稱一般使用大寫,因為它們是首字母縮寫。
還有幾個重要的指令:pushl/popl和call/ret。pushl表示32位的push,如:
pushl?%eax
就是把EAX寄存器的值壓到堆棧棧頂。它實際上做了這樣兩個動作,其中第一個動作為:
subl?$4,?%esp
把堆棧的棧頂ESP寄存器的值減4。因為堆棧是向下增長的,所以用減指令subl,也就是在棧頂預留出一個存儲單元。第二個動作為:
movl?%eax,?(%esp)
把ESP寄存器加一個小括號(間接尋址),就是把EAX寄存器的值放到ESP寄存器所指向的地方,這時ESP寄存器已經指向預留出的存儲單元了。
接下來介紹popl指令,如:
popl?%eax
就是從堆棧的棧頂取一個存儲單元(32位數值),從堆棧棧頂的位置放到EAX寄存器里,這稱為出棧。出棧同樣對應兩個操作:
movl?(%esp),?%eax
addl?$4,?%esp
第一步是把棧頂的數值放到EAX寄存器里,然后用指令addl把棧頂加4,相當于棧向上回退了一個存儲單元的位置,也就是棧在收縮。每次執行指令pushl棧都在增長,執行指令popl棧都在收縮。
call指令是函數調用,調用一個地址。例如:
call?0x12345
上述代碼實際上做了兩個動作,如下兩條偽指令,注意,這兩個動作并不存在實際對應的指令,我們用“(*)”來特別標記一下,這兩個動作是由硬件一次性完成的。出于安全方面的原因,EIP寄存器不能被直接使用和修改。
pushl?%eip?(*)
movl?$0x12345,?%eip?(*)
上述偽指令先是把當前的EIP寄存器壓棧,把0x12345這個立即數放到EIP寄存器里,該寄存器是用來告訴CPU下一條指令的存儲地址的。
把當前的EIP寄存器的值壓棧就是把下一條指令的地址保存起來,然后給EIP寄存器又賦了一個新值0x12345,也就是CPU執行的下一條指令就是從0x12345位置取得的。
再看與call指令對應的指令ret,ret指令是函數返回,例如:
ret
上述代碼實際上做了一個動作,如下一條偽指令,注意,這個動作并不存在實際對應的指令,我們用“(*)”來特別標記一下,這個動作是由硬件一次性完成的。出于安全方面的原因,EIP寄存器不能被直接使用和修改。
popl?%eip(*)
也就是把當前堆棧棧頂的一個存儲單元(一般是由call指令壓棧的內容)放到EIP寄存器里。上述pushl/popl和call/ret匯編指令對應執行的動作匯總如圖1-6所示。
圖1-6??pushl/popl和call/ret匯編指令
總結一下,call指令對應了C語言里我們調用一個函數,也就是call一個函數的起始地址。ret指令是把調用函數時壓棧的EIP寄存器的值(即call指令的下一條指令的地址)還原到EIP寄存器里,ret指令之后的下一條指令也就回到函數調用位置的下一條指令。
換句話說就是函數調用結束了,繼續執行函數調用之后的下一條指令,這和C語言中的函數調用過程是嚴格對應的。但是需要注意的是,帶個“(*)”的指令表示這些指令都是不能被程序員直接使用的,是偽指令。
因為EIP寄存器不能被程序員直接修改,只能通過專用指令(如call、ret和jmp等)間接修改。
若程序員可以直接修改EIP寄存器,那么會有嚴重的安全隱患。讀者可以思考一下為什么?我們就不展開討論了。
4.匯編代碼范例解析
我們已經對指令和寄存器有了大致的了解,下面做一個練習來驗證我們的理解。在堆棧為空棧的情況下,執行如下匯編代碼片段之后,堆棧和寄存器都發生了哪些變化?
1????push???$8
2????movl????%esp,?%ebp
3????subl????$4,?%esp
4????movl????$8,?(%esp)
我們分析這段匯編代碼每一步都做了什么動作。首先在堆棧為空棧的情況下,EBP和ESP寄存器都指向棧底。?
第1行語句是將立即數8壓棧(即先把ESP寄存器的值減4,然后把立即數8放入當前堆棧棧頂位置)。
第2行語句是把ESP寄存器的值放到EBP寄存器里,就是把ESP寄存器存儲的內容放到EBP寄存器中,把EBP寄存器也指向當前ESP寄存器所指向的位置。
換句話說,在堆棧中又新建了一個邏輯上的空棧,這一點理解起來并不容易,讀者暫時理解不了也沒有關系。本書后面會將C語言程序匯編成匯編代碼來分析函數調用是如何實現的,其中會涉及函數調用堆棧框架。
第3行語句中的指令是subl,是把ESP寄存器存儲的數值減4,也就是說,棧頂指針ESP寄存器向下移了一個存儲單元(4個字節)。
最后一行語句是把立即數8放到ESP寄存器所指向的內存地址,也就是把立即數8通過間接尋址放到堆棧棧頂。?
本例是關于棧和寄存器的一些操作的,我們可以對照上述文字說明一步一步跟蹤堆棧和寄存器的變化過程,以便更加準確地理解指令的作用。
再來看一段匯編代碼,同樣在堆棧為空棧的情況下,執行如下匯編代碼片段之后,堆棧和寄存器都發生了哪些變化?
1??pushl??$8
2??movl???%esp,?%ebp
3??pushl??$8
同樣我們也分析一下這段匯編代碼每一步都做了什么動作。首先在堆棧為空棧的情況下EBP和ESP寄存器都指向棧底。
第1行語句是將立即數8壓棧,即堆棧多了一個存儲單元并存了一個立即數8,同時也改變了ESP寄存器。
第2行語句把ESP寄存器的值放到EBP寄存器里,堆棧空間沒有變化,但EBP寄存器發生了變化。
第3行語句將立即數8壓棧,即堆棧多了一個存儲單元并存了一個立即數8。
讀者會發現,這個例子和上一個例子的實際效果是完全一樣的。
小試牛刀之后,再看下面這段更加復雜一點的匯編代碼:
1??pushl??$8
2??movl???%esp,?%ebp
3??pushl??%esp
4??pushl??$8
5??addl???$4,?%esp
6??popl???%esp
這段匯編代碼同樣首先在堆棧為空棧的情況下EBP和ESP寄存器都指向棧底。
第1行語句是將立即數8壓棧,即堆棧多了一個存儲單元并保存立即數8,同時也改變了ESP寄存器。
第2行語句是把ESP寄存器的值放到EBP寄存器里,堆棧空間沒有變化,但EBP寄存器發生了變化。
第3行語句是把ESP寄存器的內容壓棧到堆棧棧頂的存儲單元里。需要注意的是,pushl指令本身會改變ESP寄存器。“pushl %esp”語句相當于如下兩條指令:
subl?$4,?%esp
movl?%esp,?(%esp)
顯然,在保存ESP寄存器的值到堆棧中之前改變了ESP寄存器,保存到棧頂的數據應該是當前ESP寄存器的值減4。ESP寄存器的值發生了變化,同時棧空間多了一個存儲單元保存變化后的ESP寄存器的值。
第4行語句是將立即數8壓棧,即堆棧多了一個存儲單元保存立即數8,同時也改變了ESP寄存器。
第5行語句是把ESP寄存器的值加4,這相當于堆棧空間收縮了一個存儲單元。?
最后一條語句相當于如下兩條指令:
movl?(%esp),?%esp
addl?$4,?%esp
也就是把當前棧頂的數據放到ESP寄存器中,然后又將ESP寄存器加4。這一段代碼比較復雜,因為ESP寄存器既作為操作數,又被pushl/popl指令在執行過程中使用和修改。
讀者需要仔細分析和思考這段匯編代碼以理解整個執行過程,本書后續內容會結合C代碼的函數調用和函數返回,來進一步理解這段匯編代碼中涉及的建立一個函數調用堆棧和拆除一個函數調用堆棧。
《庖丁解牛Linux內核分析》
孟寧? 婁嘉鵬 ?劉宇棟 著
本書從理解計算機硬件的核心工作機制(存儲程序計算機和函數調用堆棧)和用戶態程序如何通過系統調用陷入內核(中斷異常)入手,通過上下兩個方向雙向夾擊的策略,并利用實際可運行程序的反匯編代碼從實踐的角度理解操作系統內核,然后開始分析Linux內核源代碼,從系統調用陷入內核,進程調度與進程切換,最后返回到用戶態進程。
長按二維碼,可以關注我們喲
每天與你分享IT好文。
在“異步圖書”后臺回復“關注”,即可免費獲得2000門在線視頻課程
異步圖書福利送不停
邀請10名好友關注10天直接獲取異步圖書一本(點擊文字獲取活動詳情哦)
點擊閱讀原文,購買異步圖書
閱讀原文?????????
總結
以上是生活随笔為你收集整理的换种方法学操作系统,轻松入门Linux内核的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Oracle 12c 安装(内附软件包)
- 下一篇: c语言解决函数变参数问题 va_list