摘要:總結上面的過程中,我們已經將源程序經過預處理編譯匯編階段變成了二進制代碼,這三個過程我們都是用兩種方法完成的,一種是參數的方法,另一種是使用系統默認的預處理器,編譯器,匯編器。
目錄
在ANSIC的任何一種實現中,存在兩個不同的環境。
第1種是翻譯環境,在這個環境中源代碼被轉換為可執行的機器指令。
第2種是執行環境,它用于實際執行代碼。
?組成一個程序的每個源文件通過編譯過程分別轉換成目標代碼(object code)。
每個目標文件由鏈接器(linker)捆綁在一起,形成一個單一而完整的可執行程序。
鏈接器同時也會引入標準C函數庫中任何被該程序所用到的函數,而且它可以搜索程序員個人的程序庫,將其需要的函數也鏈接到程序中。
? 編譯一個 C程序可以分為四階段:預處理階段?--->?生成匯編代碼階段?--->?匯編階段?--->?鏈接階段。?
gcc 指令的一般格式為:?
gcc [選項] 要編譯的文件 [選項] [目標文件] 其中,目標文件可缺省,gcc默認生成可執行的文件名為:a.out gcc main.c 直接生成可執行文件 a.out gcc -E main.c -o hello.i 生成預處理后的代碼(還是文本文件) gcc –S main.c -o hello.s 生成匯編代碼 gcc –c main.c -o hello.o 生成目標代碼
C程序?目標文件和可執行文件?結構
目標文件和可執行文件可以有幾種不同的格式,有ELF(Excutable and linking Format,可執行文件和鏈接)格式,也有COFF(Common Object-File Format,普通目標文件格式)。
雖然格式不一樣,但具有一個共同的概念,那就是?段(segments),這里段指二進制格式文件中的一塊區域。
linux下的可執行文件有三個段:(?可用?nm?命令查看目標文件的符號清單?)
預編譯:主要處理那些源代碼文件中的以?#?開始的預編譯指令,如?#include、#define、#if,同時并刪除注釋行,還會添加行號和文件名標識,以便于編譯時編譯器產生調試用的行號信息,及用于編譯時產生編譯錯誤或警告時能夠顯示行號。
經過預編譯的 .i 文件不包含任何宏定義,因為所有的宏已經被展開并且包含的文件也已經被插入到 .i 文件中。
所以當我們無法判斷?宏定義是否正確?或?頭文件包含是否正確?時,可以查看已編譯后的文件來確認問題。比如:hello.c 中第一行的 #include
用法:#gcc -E main.c -o main.i作用:將main.c預處理輸出main.i文件[user:test] lsmain.c[user:test] gcc -E main.c -o main.i[user:test] lsmain.c main.i
使用 gcc?-E 參數完成。
預處理會干什么事情:
?處理完成之后看看我們的 Hello.i,發現原來8行代碼現在變成了接近700行,因為將
使用系統默認的預處理器 cpp 完成。
預處理除了使用 GCC -E 參數完成之外,我們還可以使用系統默認的預處理器 cpp 完成。如下所示
我們看看Hello.ii的代碼:
雖然 Hello.i 和 Hello.ii 的代碼對應的行數不同,但是內容卻是一模一樣的,只是中間空行的數量不同而已。
OK ,接下來,繼續向編譯出發。
gcc -S
編譯是將?源文件?轉換成?匯編代碼?的過程,具體的步驟主要有:詞法分析 ---> 語法分析 ---> 語義分析及相關的優化 ---> 中間代碼生成 ---> 目標代碼生成(匯編文件.s)。
具體生成過程可以參考《編譯原理》。在這個階段中,gcc 首先要檢查代碼的規范性、是否有語法錯誤等,以確定代碼的實際要做的工作,在檢查無誤后,gcc 把代碼翻譯成匯編語言。
用戶可以使用?-S 選項來進行查看,該選項只進行編譯而不進行匯編,生成匯編代碼。
選項 -S用法:[user]# gcc –S main.i –o main.s作用:將預處理輸出文件main.i匯編成main.s文件。[user:test] lsmain.c main.i[user:test] gcc -S main.i -o main.s[user:test] lsmain.c main.i main.s
注意:gcc 命令只是一個后臺程序的包裝,會根據不同的參數要求去調用預編譯編譯程序cc1(c)、匯編器 as、連接器 ld。
使用 gcc?-S 參數完成。
查看 Hello.s 發現已經是匯編代碼了。
使用系統默認的編譯器 cc1 完成這個過程。
前面的預處理命令?cpp?
可能大家的系統上都有,我們輸入cp
,然后?Tab?
兩下(Linux系統上表示提示補全命令),系統提示如下:?
倒數第二個命令就是?cpp?
了。但是我們?cc?
同樣的過程的時候卻發現:?
并沒有?cc1?
這個命令,但是?cc1?
確實是?Linux?
系統上默認的編譯器呀,我們在系統上找找看:?
看上圖第二條,/usr/libexec/gcc/x86_64-redhat-linux/4.8.2/cc1
,嘗試著去看下:?
有可執行權限,那為何不試試能不能用來編譯?Hello.ii?
呢??
好像沒有什么報錯,迫不及待的看看?Hello.ss?
的內容:
發現和?Hello.s?
的是一樣的。編譯成功。
匯編階段是把編譯階段生成的 ”.s” 文件轉成二進制目標代碼。匯編器(as)將 hello.s 翻譯成機器語言指令,把這些指令打包成一種叫做可重定位目標程序的格式,并將結果保存在目標文件hello.o中。hello.o文件是一個二進制文件,它的字節編碼是機器語言指令而不是字符。如果我們在文本編譯器中打開 hello.o 文件,看到的將是一堆亂碼。
選項 -c用法:[user]# gcc -c main.s -o main.o作用:將匯編輸出文件main.s編譯輸出main.o文件。[user:test] lsmain.c main.i main.s[user:test] gcc -c main.s -o main.o[user:test] lsmain.c main.i main.o main.s
使用 gcc?-c 參數完成。
其實也可以查看下 Hello.o 的內容:
只是亂碼罷了。要是想看,我們可以使用 hexedit, readelf 和 objdump 這三個工具。
hexedit 只是個將二進制文件用十六進制打開的工具,我們執行:
$ sudo yum install hexedit$ hexedit Hello.o
可以看到:
最右邊是源文件被翻譯成可見字符,點.表示的都是不可見字符。這樣看當然沒有多大實際意義,但是一些輸出的字符串 Hello World,包括整個文件的類型 ELF 都是可以看到的。
readelf 和 objdump 我們后面再說。
使用系統默認的匯編器as完成。
hexedit 看看 :
使用 cmp 命令比較 Hello.oo 和 Hello.o
只有極少數字符不同。可能也是格式問題。
總結:上面的過程中,我們已經將 Hello.c 源程序經過預處理、編譯、匯編階段變成了二進制代碼,這三個過程我們都是用兩種方法完成的,一種是 GCC + 參數的方法,另一種是使用系統默認的預處理器,編譯器,匯編器。這兩種方法都達到了我們的目的,最后給它加上x權限。然后運行
chmod a+x a.out./a.out
這階段就是把匯編后的機器指令集變成可以直接運行的文件,而對目標文件進行鏈接主要是因為在目標文件中可能用到了在其他文件當中定義的字段(或者函數),通過鏈接來把多個不同目標文件關聯到一起。
比如:有2個目標文件 a 和 b,在 b 中定義了一個函數 "method",而在文件 a 中則使用到了b文件中的函數 "method",通過鏈接文件a才能調用到函數"method",不然文件a根本就不知道到函數 "method" 底做了些什么操作。
hello 程序調用了一個 printf 函數,它是每個 C 編譯器都會提供的標準C庫中的一個函數,printf 函數存在于一個名為 printf.o 的多帶帶預編譯好了的標準文件中,而這個文件必須以某種方式合并到我們的 hello.o 程序中,鏈接器(ld)就負責處理這種合并,結果就得到 hello 文件,他是一個可執行目標文件(簡稱:可執行文件),可以被加載到內存中,有系統執行。
gcc的無選項的編譯就是鏈接用法:[user]# gcc main.o -o main.elf作用:將編譯輸出文件main.o鏈接成最終可執行文件main.elf[user:test] lsmain.c main.i main.o main.s[user:test] gcc main.o -o main.elf[user:test] lsmain.c main.elf* main.i main.o main.s
模塊之間的通信有兩種方式:一種是模塊間的函數調用,另一種是模塊間的變量訪問。函數訪問需知道目標函數的地址,變量訪問也需要知道目標變量的地址,所以這兩種方式都可以歸結為一種方式,那就是模塊間符號的引用。模塊間依靠符號來通信類似于拼圖版,定義符號的模塊多出一塊區域,引用該符號的模塊剛好少了那一塊區域,兩者一拼接剛好完美組合。這個模塊的拼接過程就是“鏈接”。
在鏈接中,函數和變量統稱為符號(symbol),函數名或變量名就是符號名(symbol name)。可以將符號看做是鏈接中的粘合劑,整個鏈接過程正是基于符號才能夠正確完成。鏈接過程中很關鍵的一部分就是符號的管理,每一個目標文件都會有一個相應的符號表(symbol table),這個表里面記錄了目標文件中所用到的所有符號。每個定義的符號有一個對應的值,叫做符號值(symbol value),對于變量和函數來說,符號值就是它們的地址。符號表中所有的符號分類:
鏈接過程主要包括了地址和空間分配、符號決議和重定位。符號決議有時候也叫做符號綁定、名稱綁定、名稱決議,甚至還有叫做地址綁定、指令綁定,大體上它們的意思都一樣,但從細節角度來區分,它們之間還存在一定區別,比如“決議”更傾向于靜態鏈接,而“綁定”更傾向于動態鏈接,即它們所使用的范圍不一樣。
每個目標文件都可能定義一些符號,也可能引用到定義咋其他目標文件的符號。重定位的過程中,每個重定位的入口都是對一個符號的引用,那么當鏈接器須要對某個符號的引用重定位時,它就是要確定這個符號的目標地址。這時候鏈接器就會去查找由所有輸入目標文件的符號表組成的全局符號表,找到相應的符號后進行重定位。
看代碼:
sum.c
int g_val = 2016;void print(const char *str){printf("%s/n", str);}
test.c
#include int main(){extern void print(char *str);extern int g_val;printf("%d/n", g_val);print("hello bit./n");return 0;}
?如何查看編譯期間的每一步發生了什么呢?
test.c
#include int main(){int i = 0;for(i=0; i<10; i++){printf("%d ", i);}return 0;}
1. 預處理 選項 gcc -E test.c -o test.i
預處理完成之后就停下來,預處理之后產生的結果都放在test.i文件中。
2. 編譯 選項 gcc -S test.c
編譯完成之后就停下來,結果保存在test.s中。
3. 匯編 gcc -c test.c
匯編完成之后就停下來,結果保存在test.o中。
程序執行的過程:
1. 程序必須載入內存中。在有操作系統的環境中:一般這個由操作系統完成。在獨立的環境中,程序
的載入必須由手工安排,也可能是通過可執行代碼置入只讀內存來完成。
2. 程序的執行便開始。接著便調用main函數。
3. 開始執行程序代碼。這個時候程序將使用一個運行時堆棧(stack),存儲函數的局部變量和返回
地址。程序同時也可以使用靜態(static)內存,存儲于靜態內存中的變量在程序的整個執行過程
一直保留他們的值。
4. 終止程序。正常終止main函數;也有可能是意外終止。
__FILE__ //進行編譯的源文件__LINE__ //文件當前的行號__DATE__ //文件被編譯的日期__TIME__ //文件被編譯的時間__STDC__ //如果編譯器遵循ANSI C,其值為1,否則未定義
這些預定義符號都是語言內置的。
舉個栗子:
printf("file:%s line:%d/n", __FILE__, __LINE__);
語法:#define name stuff
??舉個栗子:
#define MAX 1000#define reg register //為 register這個關鍵字,創建一個簡短的名字#define do_forever for(;;) //用更形象的符號來替換一種實現#define CASE break;case //在寫case語句的時候自動把 break寫上。// 如果定義的 stuff過長,可以分成幾行寫,除了最后一行外,每行的后面都加一個反斜杠(續行符)。#define DEBUG_PRINT printf("file:%s/tline:%d/t /date:%s/ttime:%s/n" ,/__FILE__,__LINE__ , /__DATE__,__TIME__ )
在define定義標識符的時候,要不要在最后加上 ; ?
比如:
#define MAX 1000;#define MAX 1000
建議不要加上 ; ,這樣容易導致問題。
比如下面的場景:
if(condition)max = MAX;elsemax = 0;
這里會出現語法錯誤。
?
#define 機制包括了一個規定,允許把參數替換到文本中,這種實現通常稱為宏(macro)或定
義宏(define macro)。
下面是宏的申明方式:
#define name( parament-list ) stuff
?其中的 parament-list 是一個由逗號隔開的符號表,它們可能出現在stuff中。
注意:
參數列表的左括號必須與name緊鄰。
如果兩者之間有任何空白存在,參數列表就會被解釋為stuff的一部分。
如:
?
#define SQUARE( x ) x * x
這個宏接收一個參數 x .
如果在上述聲明之后,你把
SQUARE( 5 );
置于程序中,預處理器就會用下面這個表達式替換上面的表達式:
5 * 5
警告:
這個宏存在一個問題:
觀察下面的代碼段:
int a = 5;printf("%d/n" ,SQUARE( a + 1) );
乍一看,你可能覺得這段代碼將打印36這個值。
事實上,它將打印11.
為什么?
替換文本時,參數x被替換成a + 1,所以這條語句實際上變成了:printf ("%d/n",a + 1 * a + 1 );
這樣就比較清晰了,由替換產生的表達式并沒有按照預想的次序進行求值。
在宏定義上加上兩個括號,這個問題便輕松的解決了:
?
#define SQUARE(x) (x) * (x)
這樣預處理之后就產生了預期的效果:
printf ("%d/n",(a + 1) * (a + 1) );
這里還有一個宏定義:
#define DOUBLE(x) (x) + (x)
定義中我們使用了括號,想避免之前的問題,但是這個宏可能會出現新的錯誤。
int a = 5;printf("%d/n" ,10 * DOUBLE(a));
這將打印什么值呢?
warning:
看上去,好像打印100,但事實上打印的是55.
我們發現替換之后:
printf ("%d/n",10 * (5) + (5));
乘法運算先于宏定義的加法,所以出現了? ? 55
這個問題,的解決辦法是在宏定義表達式兩邊加上一對括號就可以了。
#define DOUBLE( x) ( ( x ) + ( x ) )
提示:
所以用于對數值表達式進行求值的宏定義都應該用這種方式加上括號,避免在使用宏時由于參數
中的操作符或鄰近操作符之間不可預料的相互作用。
?
在程序中擴展#define定義符號和宏時,需要涉及幾個步驟。
1. 在調用宏時,首先對參數進行檢查,看看是否包含任何由#define定義的符號。如果是,它們首先
被替換。
2. 替換文本隨后被插入到程序中原來文本的位置。對于宏,參數名被他們的值替換。
3. 最后,再次對結果文件進行掃描,看看它是否包含任何由#define定義的符號。如果是,就重復上
述處理過程。
注意:
1. 宏參數和#define 定義中可以出現其他#define定義的變量。但是對于宏,不能出現遞歸。
2. 當預處理器搜索#define定義的符號的時候,字符串常量的內容并不被搜索。
如何把參數插入到字符串中?
首先我們看看這樣的代碼:
char* p = "hello ""bit/n";printf("hello"," bit/n");printf("%s", p);
這里輸出的是不是? ? ?hello bit? ??
答案是確定的:是。
我們發現字符串是有自動連接的特點的。
1. 那我們是不是可以寫這樣的代碼?:
#define PRINT(FORMAT, VALUE)/printf("the value is "FORMAT"/n", VALUE);...PRINT("%d", 10);
這里只有當字符串作為宏參數的時候才可以把字符串放在字符串中。
1. 另外一個技巧是:
使用 # ,把一個宏參數變成對應的字符串。
比如:
int i = 10;#define PRINT(FORMAT, VALUE)/printf("the value of " #VALUE "is "FORMAT "/n", VALUE);...PRINT("%d", i+3);//產生了什么效果?
代碼中的 #VALUE 會預處理器處理為:
"VALUE" .
最終的輸出的結果應該是:
the value of i+3 is 13
## 的作用
##可以把位于它兩邊的符號合成一個符號。
它允許宏定義從分離的文本片段創建標識符
#define ADD_TO_SUM(num, value) /sum##num += value;...ADD_TO_SUM(5, 10);//作用是:給sum5增加10.
注:
這樣的連接必須產生一個合法的標識符。否則其結果就是未定義的。
當宏參數在宏的定義中出現超過一次的時候,如果參數帶有副作用,那么你在使用這個宏的時候就可能
出現危險,導致不可預測的后果。副作用就是表達式求值的時候出現的永久性效果。
例如:
x+1;//不帶副作用x++;//帶有副作用
MAX宏可以證明具有副作用的參數所引起的問題。
#define MAX(a, b) ( (a) > (b) ? (a) : (b) )...x = 5;y = 8;z = MAX(x++, y++);printf("x=%d y=%d z=%d/n", x, y, z);//輸出的結果是什么?
這里我們得知道預處理器處理之后的結果是什么:
z = ( (x++) > (y++) ? (x++) : (y++));
所以輸出的結果是:
x=6 y=10 z=9
?宏通常被應用于執行簡單的運算。比如在兩個數中找出較大的一個。
?
#define MAX(a, b) ((a)>(b)?(a):(b))
那為什么不用函數來完成這個任務?
原因有二:
1. 用于調用函數和從函數返回的代碼可能比實際執行這個小型計算工作所需要的時間更多。所以宏比
函數在程序的規模和速度方面更勝一籌。
2. 更為重要的是函數的參數必須聲明為特定的類型。所以函數只能在類型合適的表達式上使用。反之
這個宏怎可以適用于整形、長整型、浮點型等可以用于>來比較的類型。宏是類型無關的。
當然和宏相比函數也有劣勢的地方:
1. 每次使用宏的時候,一份宏定義的代碼將插入到程序中。除非宏比較短,否則可能大幅度增加程序
的長度。
2. 宏是沒法調試的。
3. 宏由于類型無關,也就不夠嚴謹。
4. 宏可能會帶來運算符優先級的問題,導致程容易出現錯。
宏有時候可以做函數做不到的事情。比如:宏的參數可以出現類型,但是函數做不到。
#define MALLOC(num, type)/(type *)malloc(num * sizeof(type))...//使用MALLOC(10, int);//類型作為參數//預處理器替換之后:(int *)malloc(10 * sizeof(int));
宏和函數的一個對比
屬 性 | #define定義宏 | 函數 |
代 碼 長 度 | 每次使用時,宏代碼都會被插入到程序中。除了非 常小的宏之外,程序的長度會大幅度增長 | 函數代碼只出現于一個地方;每 次使用這個函數時,都調用那個 地方的同一份代碼 |
執 行 速 度 | 更快 | 存在函數的調用和返回的額外開 銷,所以相對慢一些 |
操 作 符 優 先 級 | 宏參數的求值是在所有周圍表達式的上下文環境 里,除非加上括號,否則鄰近操作符的優先級可能 會產生不可預料的后果,所以建議宏在書寫的時候 多些括號。 | 函數參數只在函數調用的時候求 值一次,它的結果值傳遞給函 數。表達式的求值結果更容易預 測。 |
帶 有 副 作 用 的 參 數 | 參數可能被替換到宏體中的多個位置,所以帶有副 作用的參數求值可能會產生不可預料的結果。 | 函數參數只在傳參的時候求值一 次,結果更容易控制。 |
參 數 類 型 | 宏的參數與類型無關,只要對參數的操作是合法 的,它就可以使用于任何參數類型。 | 函數的參數是與類型有關的,如 果參數的類型不同,就需要不同 的函數,即使他們執行的任務是 不同的。 |
調 試 | 宏是不方便調試的 | 函數是可以逐語句調試的 |
遞 歸 | 宏是不能遞歸的 | 函數是可以遞歸的 |
命名約定
一般來講函數的宏的使用語法很相似。所以語言本身沒法幫我們區分二者。
那我們平時的一個習慣是:
把宏名全部大寫
函數名不要全部大寫
這條指令用于移除一個宏定義。
#undef NAME//如果現存的一個名字需要被重新定義,那么它的舊名字首先要被移除。
許多C 的編譯器提供了一種能力,允許在命令行中定義符號。用于啟動編譯過程。
例如:當我們根據同一個源文件要編譯出不同的一個程序的不同版本的時候,這個特性有點用處。(假
定某個程序中聲明了一個某個長度的數組,如果機器內存有限,我們需要一個很小的數組,但是另外一
個機器內存大寫,我們需要一個數組能夠大寫。)
#include int main(){int array [ARRAY_SIZE];int i = 0;for(i = 0; i< ARRAY_SIZE; i ++){array[i] = i;}for(i = 0; i< ARRAY_SIZE; i ++){printf("%d " ,array[i]);}printf("/n" );return 0;}
編譯指令:
gcc -D ARRAY_SIZE=10 programe.c
在編譯一個程序的時候我們如果要將一條語句(一組語句)編譯或者放棄是很方便的。因為我們有條件
編譯指令。
比如說:
調試性的代碼,刪除可惜,保留又礙事,所以我們可以選擇性的編譯。
#include #define __DEBUG__int main(){int i = 0;int arr[10] = {0};for(i=0; i<10; i++){arr[i] = i;#ifdef __DEBUG__printf("%d/n", arr[i]);//為了觀察數組是否賦值成功。#endif //__DEBUG__}return 0;}
常見的條件編譯指令
?
1.#if 常量表達式//...#endif//常量表達式由預處理器求值。如:#define __DEBUG__ 1#if __DEBUG__//..#endif2.多個分支的條件編譯#if 常量表達式//...#elif 常量表達式//...#else//...#endif3.判斷是否被定義#if defined(symbol)#ifdef symbol#if !defined(symbol)#ifndef symbol4.嵌套指令#if defined(OS_UNIX)#ifdef OPTION1unix_version_option1();#endif#ifdef OPTION2unix_version_option2();#endif#elif defined(OS_MSDOS)#ifdef OPTION2msdos_version_option2();#endif#endif
我們已經知道, #include 指令可以使另外一個文件被編譯。就像它實際出現于 #include 指令的地方
一樣。
這種替換的方式很簡單:
預處理器先刪除這條指令,并用包含文件的內容替換。
這樣一個源文件被包含10次,那就實際被編譯10次。
本地文件包含
#include "filename"
查找策略:先在源文件所在目錄下查找,如果該頭文件未找到,編譯器就像查找庫函數頭文件一樣在標
準位置查找頭文件。
如果找不到就提示編譯錯誤。
Linux環境的標準頭文件的路徑:
/usr/include
VS環境的標準頭文件的路徑:
C:/Program Files (x86)/Microsoft Visual Studio 12.0/VC/include
注意按照自己的安裝路徑去找。
庫文件包含
#include
查找頭文件直接去標準路徑下去查找,如果找不到就提示編譯錯誤。
這樣是不是可以說,對于庫文件也可以使用 “” 的形式包含?
答案是肯定的,可以。
但是這樣做查找的效率就低些,當然這樣也不容易區分是庫文件還是本地文件了。
如果出現這樣的場景:
comm.h和comm.c是公共模塊。
test1.h和test1.c使用了公共模塊。
test2.h和test2.c使用了公共模塊。
test.h和test.c使用了test1模塊和test2模塊。
這樣最終程序中就會出現兩份comm.h的內容。這樣就造成了文件內容的重復。
如何解決這個問題?
答案:條件編譯。
每個頭文件的開頭寫:
#ifndef __TEST_H__#define __TEST_H__//頭文件的內容#endif //__TEST_H__
或者:
#pragma once
就可以避免頭文件的重復引入。
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://m.specialneedsforspecialkids.com/yun/122325.html
摘要:程序預處理本章節研究的是,源代碼文件是如何一步步得到一個可執行程序的。如的語句被稱為預處理指令,還有注釋文本的刪除,都在此階段完成替換。目的是能夠將所有文件中的代碼組合到一起成一個完整的程序。終止程序可以正常也可以意外終止程序。 ...
摘要:如的語句被稱為預處理指令,還有注釋文本的刪除,都在此階段完成替換。故宏在程序規模和執行速度方面更勝一籌。宏替換發生在預編譯期間,故無法調試。宏可能由于運算符優先級的問題,會導致程序出錯。 ...
閱讀 3267·2023-04-26 02:10
閱讀 2888·2021-10-12 10:12
閱讀 4586·2021-09-27 13:35
閱讀 1528·2019-08-30 15:55
閱讀 1069·2019-08-29 18:37
閱讀 3433·2019-08-28 17:51
閱讀 1966·2019-08-26 13:30
閱讀 1202·2019-08-26 12:09