程序人生-Hello’s P2P

程序人生-Hello’s P2P

摘 要
本文通過hello程序從編寫源程序被編譯、彙編、鏈接、運行,從外部存儲設備,經過I/O橋,進入到內存,各級cache,最後在I/O中輸出,最後被回收的過程描述,詮釋了hello,簡單卻複雜的一生,描述了最簡單的程序,卻在生命週期中有着同樣複雜的經歷,從而揭開程序接近底層運行機制的過程。

關鍵詞:hello 編譯 彙編 鏈接 存儲 進程

第1章 概述
1.1 Hello簡介
根據Hello的自白,利用計算機系統的術語,簡述Hello的P2P,020的整個過程。
P2P:指從Program到process的過程,具體如下。

  1. 編寫源程序Program, hello.c源程序;
  2. 從.c的文本文件經過cpp預處理器預處理,根據#開頭的命令修改源程序,生成新的源程序.i文件;
  3. 再通過編譯器ccl,將.i文件編譯成.s的彙編程序;
  4. 經過編譯器as將.s文件翻譯成機器語言,生成可重定位目標程序,一個二進制文件;
  5. 鏈接:將標準C庫中的printf.o函數合併,得到二進制的可執行文件。
  6. 運行:輸入hello 117030**
  7. 創建子程序:通過fork創建子程序hello成爲進程的開始,也就是process的開始;
  8. 執行:通過execve加載器載入,建立虛擬內存映射,設置當前進程的上下文中的程序計數器,使之指向程序入口處。CPU爲其分配相應的時間分片,形成同其他程序併發執行的假想;
  9. 訪存:CPU上的內存管理單元MMU根據頁表將CPU生成的虛擬地址翻譯成物理地址,將相應的頁面調度;
  10. 動態內存申請:printf調用malloc進行動態內存分配,在堆中申請所需的內存;
  11. 接收信號:中途接受ctrl+z掛起,ctrl+c終止;
  12. 結束:程序返回後,內核向父進程發送SIGCHLD信號,此時終止的hello被父進程回收。

020:從原來OS存儲管理,MMU根據TLB將VA翻譯成PA,向cache發出請求,發生缺頁故障後,逐層申請,發生一系列的不命中後,通過頁面交換進入內存,就這樣,hello離開磁盤,通過I/O橋,一生的旅行就開始了。
再經過上面P2P的一系列過程之後,hello的一生以被父進程回收爲終點。生也OS,死也OS,form zero-O to zero-O.

1.2 環境與工具
1.2.1 硬件環境
X64 CPU;2.80GHz;8G RAM;256G SSD +1T HDD
1.2.2 軟件環境
Windows10 64位;Vmware 14.1.3;Ubuntu 16.04 LTS 64位;
1.2.3 開發工具
Edb, gdb, ccp, as, ld, readelf, gcc

1.3 中間結果
列出你爲編寫本論文,生成的中間結果文件的名字,文件的作用等。
文件名 文件作用
hello.i 預處理之後的源程序
hello.s 編譯之後的彙編程序
hello.o 彙編之後的可重定位目標程序
hello.elf hello.o的ELF格式
helloexe.elf hello的ELF格式
hello 可執行目標程序
hello2.s 反彙編後輸出的程序
Helloobj.txt Hello可執行程序的反彙編代碼
Temp.c 臨時數據存放

1.4 本章小結
本次實驗,我們基於Hello展開從Program到Process的過程,通過對其預處理、編譯、彙編、鏈接的過程,領會程序由.c源文件到可執行文件的細節及工作方式。
進一步體會程序從存儲管理中的源文件,通過IO加載到主存,再通過CPU執行,將Hello的內容顯示在屏幕上的具體過程原理。本次實驗將在Win10系統的VMWAare虛擬機上完成,具體工作過程見後面章節。

第2章 預處理
2.1 預處理的概念與作用
概念:預編譯又稱爲預處理,是做些代碼文本的替換工作。就是爲編譯做的預備工作的階段。是根據以字符#開頭的命令,修改源程序的過程,最後最後生成.i的文本文件。
作用:C語言預處理主要包括3個方面:1.宏定義;2.文件包含;3.條件編譯
預處理即將宏進行展開。
1.#define標識符 字符串
如上格式的宏定義中,預處理的過程中,將標識符用字符串替代,倘若含有參數,形如#define 宏名(參數表) 字符串,則還要做參數替換,但不進行語法檢查,不計算。
2.文件包含 #include <文件名>
根據#修改文件後,編譯時就以包含處理以後的文件爲編譯單位,被包含的文件將作爲源文件的一部分被編譯。
3.條件編譯
有些語句希望在條件滿足時才編譯,還有些語句當標識符已經定義時,才編譯。
使用條件編譯可以使目標程序變小,運行時間變短。
預編譯使問題或算法的解決方案增多,有助於我們選擇合適的解決方案。

2.2在Ubuntu下預處理的命令
cpp hello.c > hello.i
在這裏插入圖片描述
圖2.2.1 預處理過程圖
將預處理之後的文本文件輸出到hello.i文件中,後面是在gedit中打開的預處理後的文本文件
2.3 Hello的預處理結果解析
![圖2.3.1 預處理結果part1](https://img-blog.csdnimg.cn/20181231014945744.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2xsbF85MA==,size_16,color_FFFFFF,t_70)
圖2.3.1 預處理結果part1
上圖爲.c源程序文件名、命令行參數、環境變量

在這裏插入圖片描述
圖2.3.1 預處理結果part2
上圖是對包含的.h頭文件的預處理,用絕對路徑將其替代

在這裏插入圖片描述
圖2.3.1 預處理結果part3
上圖是標準C庫中一些數據類型的聲明

在這裏插入圖片描述
圖2.3.1 預處理結果part4
上圖是結構體的定義

在這裏插入圖片描述
圖2.3.1 預處理結果part5
上圖是對引用的外部函數的聲明

在這裏插入圖片描述
圖2.3.1 預處理結果part6
經過上述一系列的標識符替換、修改,環境、引用的外部函數聲明之後是原.c程序部分。因爲預處理只是對#開頭的命令進行標識符的替換,並不進行語法檢查等操作,所以原程序只進行了插入、修改替換操作,並沒有大的變化。
2.4 本章小結
預處理過程只是做些代碼文本的替換工作,是爲編譯做的預備工作的階段,對源程序並沒有進行語法檢查等操作,且程序沒有大的變化。這是我們從.c源程序到可執行文件的第一步。

第3章 編譯
3.1 編譯的概念與作用
概念:通過編譯器ccl,將文本文件.i編譯成文本文件.s,是將高級語言轉換成低級機器語言指令的過程。它包含了一個彙編語言程序。不同高級語言經過編譯器編譯後,都輸出爲同一彙編語言。
在此過程中,編譯器將會對程序進行優化、語法檢查等
注意:這兒的編譯是指從 .i 到 .s 即預處理後的文件到生成彙編語言程序
3.2 在Ubuntu下編譯的命令
Gcc -v -S hello.c -o hello.s (此處加了-v輸出詳細的編譯過程)
在這裏插入圖片描述
圖3.2.1 編譯.c程序生成.s文件

3.3 Hello的編譯結果解析

此部分是重點,說明編譯器是怎麼處理C語言的各個數據類型以及各類操作的。應分3.3.1~ 3.3.x等按照類型和操作進行分析,只要hello.s中出現的屬於大作業PPT中P4給出的參考C數據與操作,都應解析。

3.3.1
全局變量sleepsecs以被初始化爲2.5,被存放在堆的.data節中
局部變量存放在棧中,通過指向棧底的寄存器偏移量間接尋址獲得,存放在%rdi與%rsi當中
待打印的string類型串“Usage:Hello 117030 ,如圖*1
"存放在.string節中,argc, *argv參數局部變量存放在棧中,用寄存器存放地址,相應地指向該地址單元如圖2
3.3.2 賦值
用movx src,dec,由x控制字長(如圖3)
函數調用前,將用作返回值的寄存器初始化movl $0, %eax
3.3.3運算
加法addx 操作數1,操作數2
減法subx 操作數1,操作數2(如圖4)
3.3.4if條件語句判斷
Cmpx操作數1,操作數2 jxx條件跳轉語句實現,往下跳(如圖5)
3.3.5循環控制語句
Cmpx操作數1,操作數2 jxx條件跳轉語句實現,往上跳(如圖6)
3.3.6關係運算
Cmpx操作數1,操作數2
3.3.7函數返回
pushq %rbp,將上一個棧頂地址壓棧,爲函數返回時,出棧做準備
通過%rax 返回返回值(圖10)

3.3.8參數傳遞,通過棧底指針間接尋址,暫時存放在寄存器%rdi,%rsi中.(圖8)
3.3.9函數調用
call 函數名,在調用前,準備好參數,存放在寄存器%rdi, %rsi當中,並將返回值寄存器初始化movl $0, %eax(圖9)
3.3.10數組
通過偏移地址+基址尋址
movq -32(%rbp), %rax
addq $16, %rax
在這裏插入圖片描述
圖1
在這裏插入圖片描述
圖2
在這裏插入圖片描述
圖3

在這裏插入圖片描述
在這裏插入圖片描述
圖4

在這裏插入圖片描述
圖5

在這裏插入圖片描述
圖6
在這裏插入圖片描述
圖7
在這裏插入圖片描述
圖8

在這裏插入圖片描述
圖9
在這裏插入圖片描述
圖10
3.4 本章小結
本章通過編譯時,以hello爲例,講述了編譯器是怎麼處理C語言的各個數據類型以及各類操作的實際應用說明,像是數據:常量、變量(全局/局部/靜態)的存放,通過movx的賦值 = ,算術操作:+、 - 、++,關係操作: != 、<=,數組/指針的引用:A[i]、*p 控制轉移:if、for的使用,函數操作:參數傳遞(地址/值)、函數調用()、函數返回 return的實現等等,進一步深化,我們對C語言中的數據與操作的認識。

第4章 彙編
4.1 彙編的概念與作用

彙編器(as)將hello.s編譯成機器語言指令,把這些指令打包成一種叫可重定位目標程序的格式,並將結果保存在一個二進制的目標文件中的過程。

注意:這兒的彙編是指從 .s 到 .o 即編譯後的文件到生成機器語言二進制程序的過程。
4.2 在Ubuntu下彙編的命令
輸入:as hello.s -o hello.o, 產生彙編生成的二進制文件
用readelf -a hello.o > hello.elf, 命令,將二進制文件hello.o輸出.elf文件形式,再用cat hello.elf命令,查看.elf可重定位目標文件
結果如圖4.1
在這裏插入圖片描述
圖4.1

4.3 可重定位目標elf格式
分析hello.o的ELF格式,用readelf等列出其各節的基本信息,特別是重定位項目分析。
ELF頭以一個16字節序列開始,描述了生成該文件系統字的大小和字節順序。其餘的爲幫助鏈接器進行語法分析和解釋目標文件的信息,包括ELF文件的大小,節頭部的起始位置(此處爲1120),程序的入口地點,目標文件的類型,機器類型(此處爲小端機器),節頭部表的文件偏移,以及節頭部表中條目的大小與數量。如圖4.2

在這裏插入圖片描述
圖4.2

重定位.rel.text節,有偏移量,重定位類型,符號值等。當鏈接器將當前目標文件與其他文件組合時,需要修改這些位置,此處,修改的有puts(),exit(),printf(),sleepsecs,sleep,getchar等函數。而程序調用的本地函數指令地址屬於絕對地址,重定位類型爲R_X86_64_32,不需修改重定位後的地址信息。
在這裏插入圖片描述
圖4.3

重定位.symtab節,包含包含在程序中定義與引用的函數與全局變量信息。任何已初始化的全局變量地址或外部函數地址都需要被修改。
在這裏插入圖片描述
圖4.4

4.4 Hello.o的結果解析
(以下格式自行編排,編輯時刪除)
objdump -d -r hello.o 分析hello.o的反彙編,並請與第3章的 hello.s進行對照分析。
說明機器語言的構成,與彙編語言的映射關係。特別是機器語言中的操作數與彙編語言不一致,特別是分支轉移函數調用等。

  1. 分支轉移:在反彙編彙編語言中,分支轉移時的跳轉目標地址爲相對偏移量,而原來的.s文件中是.L2, .L3等註記符。因爲轉換成機器語言,在反彙編之後,註記符不復存在。(如圖4.5)
  2. 訪問全局變量時,.s文件中,使用的是註記符,而反彙編文件中是.rodata+偏移量(如圖4.6)
  3. 函數調用時,.s文件中用的是call 函數名,而反彙編得到的彙編代碼中,是當前PC+偏移量,來調用。(如圖4.7)
    在這裏插入圖片描述
    圖4.5

在這裏插入圖片描述
圖4.6

在這裏插入圖片描述
圖4.7
4.5 本章小結

本章通過彙編器(as)將hello.s編譯成機器語言指令,再通過readelf查看可重定位目標程序文件,通過對elf文件結構的分析,獲得相關數據的運行時地址,以及不同節的、條目的大小、偏移量等信息。同時,通過.s文本文件與由機器語言反彙編獲得的彙編代碼比較,易得.s文件中,通過註記符尋址和經反彙編後,重定位表示的地址信息差異。

第5章 鏈接
5.1 鏈接的概念與作用
通過鏈接器,將程序調用的外部函數(.o文件)與當前.o文件以某種方式合併,並得到./hello可執行目標文件的的過程成爲鏈接。且該二進制文件可被加載到內存,並由系統執行。
鏈接可以執行於編譯時,也就是在源代碼被編譯成機器代碼時;也可以執行於加載時,也就是在程序被加載器加載到內存並執行時;甚至於運行時,也就是由應用程序來執行。基於此特性的改進,以提高程序運行時的時間、空間利用效率。
鏈接是由叫做鏈接器的程序執行的。鏈接器使得分離編譯成爲可能。它將巨大的源文件分解成更小的模塊,易於管理。我麼可以通過獨立地修改或編譯這些模塊,並重新鏈接應用,不必再重新編譯其他文件。
注意:這兒的鏈接是指從 hello.o 到hello生成過程。
5.2 在Ubuntu下鏈接的命令
使用ld的鏈接命令,應截圖,展示彙編過程! 注意不只連接hello.o文件
輸入命令:ld /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/5/crtbeginT.o -L/usr/lib/gcc/x86_64-linux-gnu/5 hello.o -lc -lgcc -dynamic-linker /lib64/ld-linux-x86-64.so.2
/usr/lib/gcc/x86_64-linux-gnu/5/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -o hello
截圖如下:
在這裏插入圖片描述

5.3 可執行目標文件hello的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
與可重定位文件結構類似。ELF頭中給出了一個16字節序列,描述了生成該文件系統字的大小和字節順序。其餘的爲幫助鏈接器進行語法分析和解釋目標文件的信息,包括ELF文件的大小,節頭部的起始位置,程序的入口地點,目標文件的類型,機器類型,節頭部表的文件偏移,以及節頭部表中條目的大小與數量,如圖5.2。
節頭表中給出了各節的名稱、大小、偏移量、地址,如圖5.3
在這裏插入圖片描述

圖5.2 ELF頭

在這裏插入圖片描述
圖5.3 節頭表
5.4 hello的虛擬地址空間
使用edb加載hello,查看本進程的虛擬地址空間各段信息,並與5.3對照分析說明。
使用edb打開hello可執行程序,通過edb的Data Dump窗口查看加載到虛擬虛擬地址空間的hello程序。
在0x00400000-段中,程序被載入,即對應的.init, .text, .rodata, .data, .bss節
如圖5.4,查看ELF格式文件中的Program Headers, 程序頭表在執行時被使用,它告訴鏈接器加載的內容,並提供動態鏈接信息,每個表項提供了各段在虛擬地址空間的大小、偏移量,和物理空間的地址、權限標記、對齊長度。
由圖5.4知,程序包含8個段:
段名 功能
PHDR 保存程序頭表
INTERP 程序映射到內存後,調用的解釋器
LOAD 程序需要從二進制文件映射到虛擬地址空間的段,保存了常量數據、目標的空間代碼等
DYNAMIC 保存動態鏈接器使用的相關信息
NOTE 存儲輔助信息
GNU_STACK 權限標誌,標誌是否可執行
GNU_RELRO 指定重定位後的哪些區域只需要設置只讀

在這裏插入圖片描述
圖5.4 可執行文件hello的ELF格式文件 的Program Headers Table
5.5 鏈接的重定位過程分析
objdump -d -r hello 分析hello與hello.o的不同,說明鏈接的過程。
結合hello.o的重定位項目,分析hello中對其怎麼重定位的。
不同:
1.除了原來.text節外,增加了.init, .plt, .plt.got, .fini段,;
2.同時,在.txt段中,增加了_start,deregister_tm_clones,register_tm_clones,__do_global_dtors_aux,frame_dummy,__libc_csu_init,__libc_csu_fini,_fini等函數;
3.hello的反彙編代碼中函數調用時,call的地址爲運行時的絕對地址,而hello.o
的反彙編代碼中,是重定位條目信息,如圖5.5中exit函數重定位的例子。

鏈接過程:爲了構造可執行文件,鏈接器先後完成兩個主要任務:符號解析和重定位。
每個符號對應一個函數、全局變量、靜態變量,通過符號解析,將定義與引用關聯起來。鏈接器維護3個集合,可重定位目標文件的集合E(該集合中的文件將會被合併爲可執行文件),U引用了但尚未被定義的集合,D在前面輸入集合中已被定義的符號集合。初始時,3個集合均爲空。鏈接器會判斷命令行上的每一個輸入文件,f,若f爲目標文件,則鏈接器將f添加到E, 並修改U和D來反應f中的符號定義與引用;若f爲一個歸檔文件,則鏈接器嘗試匹配U中未解析的符號和f中定義的符號。直至U和D均不再變化,則將f丟棄。最後,若U爲空,則合併可重定位目標文件E爲可執行文件;否則,報錯。

重定位:下面以exit函數的重定位過程爲例,結合hello.o的重定位條目信息,分析hello中對其怎麼重定位的。
由下圖5.5,左側爲可執行文件的反彙編代碼,易知,exit函數運行時地址爲ADDR(s.symbol) = 0x4004a0,而引用的運行時地址爲ADDR(s)+ s.offset = 0x4005ee + 0x25 = 0x400613. 引用應當修改的偏移調整爲s.addend = - 0x4. 因此,可得應當更新該PC相對引用,使其在運行時指向exit函數,*refptr = 0x4004a0 – 0x400613 -0x4 = -0x177. = 0xfffffe89. 由hello的反彙編代碼(如圖5.6),可驗證正確。
在這裏插入圖片描述
圖5.5 可執行文件中運行時地址與可重定位目標文件中的可重定位條目信息對比

在這裏插入圖片描述
圖5.6 可執行目標文件PC相對引用信息驗證

5.6 hello的執行流程
(使用edb執行hello,說明從加載hello到_start,到call main,以及程序終止的所有過程。請列出其調用與跳轉的各個子程序名或程序地址。
程序名 程序地址
_dl_start 00007f36c1ed69b0
_dl_setup_hash 00007f36c1ee0a50
_dl_sysdep_start 00007f36c1eee210
brk 00007f36c1eef4b0
strlen 00007f36c1ef1fd0
sbrk 00007f36c1eef500
dl_main 00007f36c1ed71e0
dl_next_ld_env_entry 00007f36c1eeed40
dl_new_object 00007f36c1ee0b90
strlen 00007f36c1ef1fd0
calloc 00007f36c1eeef00
malloc 00007f36c1eeef00
memalign 00007f36c1eeee00
memcpy 00007f36c1ef2f60
dl_add_to_namespace 00007f36c1ee0b02
rtld_lock_default_lock_recursive 00007f36c1ed5c90
strcmp 00007f36c1ef0b40
dl_discover_osversion 00007f36c1eeeb81
dl_init_paths 00007f36c1edd4e0
dl_important_hwcaps 00007f36c1ee41c0
access 00007f36c1ef03c0
memset 00007f36c1ef2c40
dl_debug_initialized 00007f36c1ee6075
do_count_modid 00007f36c1ee8260
dl_map_object_deps 00007f36c1ee2f80
strchr 00007f36c1ef0920
dl_catch_error 00007f36c1ee54f0
dl_initial_error_catch_tsd 00007f36c1ed5c80
sigsetjmp 00007f36c1ef0610
openaux 00007f36c1ee2b70
dl_name_match_p 00007f36c1ee6980
dl_load_cache_lookup 00007f36c1eed3e9
read_whole_file 00007f36c1ee66f0
open64 00007f36c1ef0360
_fxstat 00007f36c1ef02e0
mmap64 00007f36c1ef0480
access 00007f36c1ef03c0
open_verify.consprop.7 00007f36c1eda710
read 00007f36c1ef0380
_dl_map_object_from_fd 00007f36c1edb330
_dl_receive_error 00007f36c1ee55c0
_dl_initial_error_catch_tsd 00007f36c1ed5c80
version_check_doit 00007f36c1ed6970
_dl_check_all_versions 00007f36c1ee72d0
_dl_check_map_versions 00007f36c1ee6e20
match_symble 00007f36c1ee6a70
_dl_relocate_object 00007f36c1ee1270
_init 400430
puts 400450
printf 400470
__libc_start_main 400480
getchar 400490
exit 4004a0
sleep 4004b0
.plt.got 4004c0
_start 4004d0
deregister_tm_clones 400500
register_tm_clones 400540
__do_global_dtors_aux 400580
frame_dummy 4005b0
__libc_csu_init 400670
__libc_csu_fini 4006e0
_fini 4006e4

因edb逐步運行實在太費時間,且棧空間的分佈是隨意的(除了相對位置不變外),於是後面半部分的地址來自gdb反彙編的.text節中的各函數地址

5.7 Hello的動態鏈接分析
分析hello程序的動態鏈接項目,通過edb調試,分析在dl_init前後,這些項目的內容變化。要截圖標識說明。

程序調用一個由共享庫定義的函數時,編譯器沒有辦法預測這個函數的運行時地址,因爲定義它的共享模塊在運行時可以加載到任意位置。GNU編譯系統使用延遲綁定的技術解決這個問題,將過程地址的延遲綁定推遲到第一次調用該過程時。
延遲綁定要用到全局偏移量表(GOT)和過程鏈接表(PLT)兩個數據結構。如果一個目標模塊調用定義在共享庫中的任何函數,那麼它就有自己的GOT和PLT。PLT是一個數組,其中每個條目是16字節代碼。PLT[0]是一個特殊條目,跳轉到動態鏈接器中。每個條目都負責調用一個具體的函數。PLT[[1]]調用系統啓動函數 (__libc_start_main)。從PLT[[2]]開始的條目調用用戶代碼調用的函數。GOT是一個數組,其中每個條目是8字節地址。和PLT聯合使用時,GOT[0]和GOT[[1]]包含動態鏈接器在解析函數地址時會使用的信息。GOT[[2]]是動態鏈接器在ld-linux.so模塊中的入口點。其餘的每個條目對應於一個被調用的函數,其地址需要在運行時被解析。
如圖5.,7找到GOT的起始位置
在這裏插入圖片描述
圖5.7

在這裏插入圖片描述
圖5. 8沒有調用dl_init之前的全局偏移量表.got.plt

在這裏插入圖片描述
圖5. 9調用dl_init之後的全局偏移量表.got.plt
5.8 本章小結
本章從ld鏈接器將hello.o的鏈接命令,到可執行文件ELF的查看,分析可執行文件的相關信息,hello的重定位過程,執行流程,hello的動態鏈接分析,進一步加深了對鏈接過程細節的理解。

第6章 hello進程管理
6.1 進程的概念與作用
進程即運行中的程序實例,系統中的每個進程均運行在進程的上下文中。上下文即程序需要正確運行所需要的狀態,它包括存放在內存中的程序代碼、數據,它的棧、通用目的寄存器的內容、程序計數器、環境變量以及打開文件描述符的集合。
進程向每個程序提供一個假象,好像它在單獨地使用着處理器,,獨佔地使用內存系統。
6.2 簡述殼Shell-bash的作用與處理流程
作用:作爲C編寫的程序,它是用戶使用Linux的橋樑。Shell是一種應用程序,它爲用戶訪問操作系統內核提供了一個交互界面。
處理流程:

  1. 讀入輸入的命令;
  2. 分割字符串,獲取命令;
  3. 若爲內置命令則執行,否則調用相應的程序爲其分配子進程執行;
  4. Shell可以異步接收來自I/O設備的信號,並對這些中斷信號進行處理。

6.3 Hello的fork進程創建過程
在終端輸入./hello,因爲./hello不是終端命令,而是當前目錄下的可執行文件,於是終端調用fork函數在當前進程中創建一個新的子進程,該進程與父進程幾乎完全相同。子進程得到與父進程用戶級虛擬地址空間相同的副本,包括代碼、數據、堆、共享庫和用戶棧。子進程還獲得與父進程任何打開文件描述符相同的副本,即子進程可讀寫當前父進程打開的任何文件。子進程與父進程最大的區別在於他們有不同的PID。
父進程與子進程是併發運行的獨立進程,內核能夠以任意方式交替執行他們的邏輯控制流中的指令。如圖6.1,爲fork進程圖的過程
在這裏插入圖片描述
圖6.1 hello進程圖
6.4 Hello的execve過程
父shell進程fork之後,生成一個子進程功能,它相當於父進程的一個副本,子進程再通過execve系統調用啓動加載器,加載器刪除子進程現有的虛擬內存段,並創建一組新的代碼、數據、堆和棧段。新的堆和棧段被初始化爲零。通過虛擬地址空間中的頁映射到可執行文件的頁大小的片。新代碼被初始化爲可執行文件的內容。最後加載器跳轉到__start地址,它最終會調用應用程序的main函數。但是,除了一些頭部信息,在加載過程中並沒有從磁盤到內存的數據複製,直到CPU引用一個被映射的虛擬頁時,才進行復制,此時,操作系統利用他的頁面調度機制自動將頁面從磁盤傳送到內存。Main函數在啓動時的棧結構如圖6.2.
在這裏插入圖片描述
圖6.2 Linux下32位用戶棧在程序開始時的典型組織結構
注:execve函數不同於fork函數,execve函數只有在找不到文件時纔會返回,否則調用一次,不返回。進程圖如圖6.3
在這裏插入圖片描述
圖6.3 execve加載並運行可執行文件hello的進程圖
6.5 Hello的進程執行
結合進程上下文信息、進程時間片,闡述進程調度的過程,用戶態與核心態轉換等等。
邏輯控制流:一系列程序計數器(PC)的值序列,若不發生搶佔,則順序執行,若發生搶佔,則當前進程被掛起,控制轉移至下一個進程。
時間分片:各個進程是併發執行的,每個進程輪流在處理器上執行,一個進程執行它控制流的一部分成爲時間分片。
上下文切換:1)保存當前進程的上下文,2)恢復某個先前被搶佔的進程保存的上下文,3)將控制傳遞給當前新恢復的進程。
操作系統內核使用上下文切換的異常控制流來實現上下文切換,過程圖6.4.
在這裏插入圖片描述
圖6.4. hello上下文切換剖析

6.6 hello的異常與信號處理
hello執行過程中會出現哪幾類異常,會產生哪些信號,又怎麼處理的。
程序運行過程中可以按鍵盤,如不停亂按,包括回車,Ctrl-Z,Ctrl-C等,Ctrl-z後可以運行ps jobs pstree fg kill 等命令,請分別給出各命令及運行結截屏,說明異常與信號的處理。
如圖6.5 正常運行,進程被sleep顯示休眠,按下enter後,程序繼續執行,終止後,被父進程回收。

如圖6.6 運行途中按下ctrl+z,內核向前臺進程發送一個SIGSTP信號,前臺進程被掛起,直到通知它繼續的信號到來,繼續執行。當按下fg 1 後,輸出命令行後,被掛起的進程從暫停處,繼續執行。

如圖6.7 運行途中按下ctrl+c,內核向前臺進程發送一個SIGINT信號,前臺進程終止,內核再向父進程發送一個SIGCHLD信號,通知父進程回收子進程,此時子進程不再存在

如圖6.6 運行途中亂按後,只是將亂按的內容輸出,程序繼續執行,而當程序執行至sleep,進程被顯示的請求休眠後,程序等待一個’\n’後,進程終止,父進程接收到一個SIGCHLD信號後,子進程被父進程回收。

如圖6.9 輸入jobs,打印進程狀態信息

如圖6.10 輸入fg 1,打印前臺進程組

圖6.11 輸入kill,終止前臺進程

如圖6.12 運行時輸入回車,’\n’會在最後sleep調用後,從緩衝區被讀入,程序繼續執行,至退出,終止後,被父進程回收。

在這裏插入圖片描述
圖6.5 正常運行

在這裏插入圖片描述
圖6.6 運行途中按下ctrl+z

在這裏插入圖片描述
圖6.7 運行途中按下ctrl+c

在這裏插入圖片描述
圖6.8 運行途中亂按
在這裏插入圖片描述
圖6.9 輸入ps打印前臺進程組
在這裏插入圖片描述

圖6.10 輸入pstree

在這裏插入圖片描述
圖6.11 輸入jobs

在這裏插入圖片描述
圖6.12 輸入fg 1,繼續執行前臺進程1

在這裏插入圖片描述
圖6.13 輸入kill
在這裏插入圖片描述
圖6.14 運行時輸入回車

6.7本章小結

本章從進程的概念開始,講述了shell的概念、作用,以圖文並茂的方式闡述了hello從被父進程的fork創建,再被execve加載,再到通過內核模式控制下的上下文切換,來實現hello進程以時間分片的形式併發執行的過程。最後通過hello執行過程中可能發生的異常,以及信號處理方式的實際操作實踐,感受了異常處理過程。

第7章 hello的存儲管理
7.1 hello的存儲器地址空間

結合hello說明邏輯地址、線性地址、虛擬地址、物理地址的概念。
邏輯地址:一個邏輯地址,是由一個段標識符加上一個指定段內相對地址的偏移量, 表示爲 [段標識符:段內偏移量]
線性地址:某地址空間中的地址是連續的非負整數時,該地址空間中的地址被稱爲線性地址。
虛擬地址:CPU在尋址的時候,是按照虛擬地址來尋址,然後通過MMU(內存管理單元)將虛擬地址轉換爲物理地址。
物理地址:計算機主存被組織成由M個連續字節大小的內存組成的數組,每個字節都有一個唯一的地址,該地址被稱爲物理地址。
三種地址的在尋址時的關係如圖7.1
在這裏插入圖片描述
圖7.1 三種地址在尋址時的關係
7.2 Intel邏輯地址到線性地址的變換-段式管理
一個段描述符由8個字節組成。它描述了段的特徵,可以分爲GDT和LDT兩類。通常來說系統只定義一個GDT,而每個進程如果需要放置一些自定義的段,就可以放在自己的LDT中。IA-32中引入了GDTR和LDTR兩個寄存器,就是用來存放當前正在使用的GDT和LDT的首地址。
在Linux系統中,每個CPU對應一個GDT。一個GDT中有18個段描述符和14個未使用或保留項(如圖7.2)。其中用戶和內核各有一個代碼段和數據段,然後還包含一個TSS任務段來保存寄存器的狀態。
在這裏插入圖片描述
圖7.2 Linux中不同段的描述符
在IA-32中,邏輯地址是16位的段選擇符+32位偏移地址,段寄存器不在保存段基址,而是保存段描述符的索引。
段地址轉換過程(如圖7.3所示):
1.IA-32首選確定要訪問的段(方式x86-16相同),然後決定使用的段寄存器。
2.根據段選擇符號的TI字段決定是訪問GDT還是LDT,他們的首地址則通過GTDR和LDTR來獲得。
3.將段選擇符的Index字段的值*8,然後加上GDT或LDT的首地址,就能得到當前段描述符的地址。(乘以8是因爲段描述符爲8字節)
4.得到段描述符的地址後,可以通過段描述符中BASE獲得段的首地址。
5.將邏輯地址中32位的偏移地址和段首地址相加就可以得到實際要訪問的物理地址

在這裏插入圖片描述
圖7.3 段地址轉換
7.3 Hello的線性地址到物理地址的變換-頁式管理
Linux下的虛擬地址VA即屬於上面提到的線性地址的一種。下面,我們講講如何從VA轉化到物理地址。
如圖7.4,一級頁表中的每個PTE負責映射虛擬地址空間中的一個4MB的片,這裏每個片都是由101124個連續的頁面組成,如PTE0映射第一片,PTE1映射第二片,以此類推。若地址空間爲4GB,則一級頁表需要1024個表項。最後以及PTE即爲對應的PPN,再結合原先VA中的偏移量VPO=PPO,將PPN與PPO結合,即爲所求的物理地址。
在這裏插入圖片描述
圖7.4
7.4 TLB與四級頁表支持下的VA到PA的變換
首先,CPU生成一個VA;
再到MMU的TLB中尋找相應的VPN,若命中,則讀取相應的PPN與原VA中的VPO結合即爲所求的PA;
倘若TLB不命中,則將虛擬地址劃分爲4個VPN和一個VPO,每個VPN i都是從一個i級頁表的索引,其中,1≤j≤4.當1≤j≤3時,第j級頁表的每個PTE均爲執行j+1級頁表的基址,第4級頁表的每個PTE爲物理地址的VPN,而原VA的VPO=PPO,將PTE與PPO結合之後,便是物理地址PA。
在這裏插入圖片描述
圖7.5 Core i7 地址翻譯部分過程
7.5 三級Cache支持下的物理內存訪問
(以下格式自行編排,編輯時刪除)
首先,將MMU生成的PA按照cache的相關參數進行劃分。
其次,在cache中尋址。如圖7.6,cache L1爲64組,於是CI共有6位,而每行的大小爲64B,於是由圖可知CT佔40位,CI佔6位,CO佔6位。若在cache中命中,則將結果返回;
再者,若cache不命中,則到依次到L2、L3、主存中尋址。L2、L3中的尋址方式與L1類似。
在這裏插入圖片描述
圖7.6 三級Cache支持下的物理內存訪問
7.6 hello進程fork時的內存映射
(以下格式自行編排,編輯時刪除)
當fork函數被當前進程調用時,內核爲新進程創建各種數據結構,並分配給它一個唯一的PID。爲了給這個新進程創建虛擬內存,它創建了當前進程的mm_struct、區域結構和頁表的原樣副本。它將兩個進程中的頁面標記爲只讀,並將兩個進程中的每個區域結構都標記爲私有的寫時複製。
當fork在新進程中返回時,,新進程現在的虛擬內存剛好和調用fork時存在的虛擬內存相同。當這兩個進程中的任一個後來進行寫操作時,寫時複製機制就會創建新頁面,因此,也就爲每個進程保持了私有地址空間的概念。
7.7 hello進程execve時的內存映射
在當前進程中的程序執行了execve(”a.out”,NULL, NULL)調用時,execve函數在當前程序中加載並運行包含在可執行文件a.out中的程序,用a.out代替了當前程序。加載並運行a.out主要分爲一下幾個步驟:
1.刪除已存在的用戶區域,刪除當前進程虛擬地址的用戶部分中的已存在的區域結構;
2.映射私有區域,爲新程序的代碼、數據、bss和棧區域創建新的區域結構,所有這些新的區域都是私有的、寫時複製的。代碼和數據區域被映射爲hello文件中的.text和.data區,bss區域是請求二進制零的,映射到匿名文件,其大小包含在hello中,棧和堆地址也是請求二進制零的,初始長度爲零;
3.映射共享區域, hello程序與共享對象libc.so鏈接,libc.so是動態鏈接到這個程序中的,然後再映射到用戶虛擬地址空間中的共享區域內;
4.設置程序計數器(PC),execve做的最後一件事情就是設置當前進程上下文的程序計數器,使之指向代碼區域的入口點。
在這裏插入圖片描述
圖7.7 加載器是如何映射用戶地址空間區域的
7.8 缺頁故障與缺頁中斷處理
缺頁故障:當指令引用一個虛擬內存地址,而該地址相對應的物理頁面不在內存中,則必須從內存取出,因此引發故障。
缺頁中斷處理:當缺頁異常發生時,處理器將控制傳遞給缺頁處理程序。缺頁處理程序從磁盤加載相應界面,然後將控制返回給引起缺頁故障的指令。當之前引起缺頁故障的指令再次執行時,相應的物理頁面已經駐留在內存中,指令就沒有故障地完成了。
在這裏插入圖片描述
圖7.8 故障處理流程
7.9動態存儲分配管理
(以下格式自行編排,編輯時刪除)
Printf會調用malloc,請簡述動態內存管理的基本方法與策略。
動態內存維護着進程的虛擬內存區,稱爲堆,分配器將堆視爲不同大小的塊的集合來維護。每個塊就是一個連續的虛擬內存片,要麼是已分配的,要麼是空閒的。已分配的塊顯式地保留爲供應用程序使用。空閒塊可用來分配。空閒塊保持空閒,直到它顯式地被應用所分配。一個已分配的塊保持已分配狀態,直到它被釋放,這種釋放要麼是應用程序顯式執行的,要麼是內存分配器自身隱式執行的。
分配器主要有兩種風格:顯示分配器,隱式分配器。
隱式分配器:要求分配器檢測一個分配塊何時不再被程序使用,就釋放這個塊。隱式分配器將塊大小、是否已分配信息嵌入塊頭部,來區分邊界以及標識是否已分配。這時,塊主要分爲反部分:頭部、有效載荷、填充部分。如圖7.9
在這裏插入圖片描述
圖7.9 使用邊界標記的堆塊格式
顯示分配器:要求應用顯示地釋放已分配的塊,C庫中提供了malloc來申請,free來釋放。將實現的數據結構指針存放在空閒塊主體中,如,堆可組織成一個雙向空閒鏈表,每個空閒塊照中包含一個pred前驅和一個succ後繼指針。(如圖7.10)使首次適配的分配時間從塊總數 的線性時間減少到空閒塊數量的線性時間。,釋放塊的時間也是線性的,有時也可能爲常數。
在這裏插入圖片描述
圖7.10 使用雙向鏈表的堆塊格式

根據不同要求,分配器採取不同的放置策略,常見的有首次適配、下次適配、最佳適配。至於選擇哪種放置策略,要根據實際對時間效率、空間效率的要求確定。
合併策略:爲解決假碎片問題,分配器必須合併相鄰的空閒塊,常採用的有立即合併,延遲合併等。
7.10本章小結
本章首先講述了虛擬地址、線性地址、物理地址的區別,通過虛擬地址到物理地址的轉換,進一步加深了對虛擬地址空間的理解運作及其強大作用,在fork、execve過程中扮演着重要的角色,使進程的私有地址空間變成了現實。同時,還體會了動態內存管理時,申請、分割、合併、回收等具體過程,加深了我們對動態內存管理過程的理解與認識。

第8章 hello的IO管理
8.1 Linux的IO設備管理方法
設備的模型化: 文件
設備管理:unix io接口
所有的I/O設備(例如網絡、磁盤和終端)都被模型化爲文件,而所有的輸入輸出都被當作相對應文件的讀和寫。這種將設備優雅地映射爲文件的方式,允許Linux內核引出一個簡單、低級的應用接口,稱爲Unix I/O。
8.2 簡述Unix I/O接口及其函數
輸入輸出都以統一的方式來執行:
1.打開文件。一個應用程序通過要求內核打開相應的文件,來宣告它想要訪間一個I/O 設備。內核返回一個小的非負整數,叫做描述符,它在後續對此文件的所有操作中標識這個文件。內核記錄有關這個打開文件的所有信息。應用程序只需記住這個描述符。

2.Linux shell 創建的每個進程開始時都有三個打開的文件:標準輸入(描述符爲0) 、標準輸出(描述符爲1) 和標準錯誤(描述符爲2) 。頭文件< unistd.h> 定義了常量STDIN_FILENO 、STOOUT_FILENO 和STDERR_FILENO, 它們可用來代替顯式的描述符值。

3.改變當前的文件位置。對於每個打開的文件,內核保持着一個文件位置k, 初始爲0。這個文件位置是從文件開頭起始的字節偏移量。應用程序能夠通過執行seek 操作,顯式地設置文件的當前位置爲K 。

4.讀寫文件。一個讀操作就是從文件複製n>0 個字節到內存,從當前文件位置k 開始,然後將k增加到k+n 。給定一個大小爲m 字節的文件,當k~m 時執行讀操作會觸發一個稱爲end-of-file(EOF) 的條件,應用程序能檢測到這個條件。在文件結尾處並沒有明確的“EOF 符號” 。類似地,寫操作就是從內存複製n>0 個字節到一個文件,從當前文件位置k開始,然後更新k 。

  1. 關閉文件。當應用完成了對文件的訪問之後,它就通知內核關閉這個文件。作爲響應,內核釋放文件打開時創建的數據結構,並將這個描述符恢復到可用的描述符池中。無論一個進程因爲何種原因終止時,內核都會關閉所有打開的文件並釋放它們的內存資源。
    Unix I/O函數
    1.進程是通過調用open 函數來打開一個已存在的文件或者創建一個新文件的:
    int open(char *filename, int flags, mode_t mode);
    open 函數將filename 轉換爲一個文件描述符,並且返回描述符數字。返回的描述符總是在進程中當前沒有打開的最小描述符。flags 參數指明瞭進程打算如何訪問這個文件,mode 參數指定了新文件的訪問權限位。
    返回:若成功則爲新文件描述符,若出錯爲-1。

2.進程通過調用close 函數關閉一個打開的文件。
int close(int fd);
返回:若成功則爲0, 若出錯則爲-1。

3.應用程序是通過分別調用read 和write 函數來執行輸入和輸出的。

ssize_t read(int fd, void *buf, size_t n);
read 函數從描述符爲fd 的當前文件位置複製最多n 個字節到內存位置buf 。返回值-1表示一個錯誤,而返回值0 表示EOF。否則,返回值表示的是實際傳送的字節數量。
ssize_t write(int fd, const void *buf, size_t n);
write 函數從內存位置buf 複製至多n 個字節到描述符fd 的當前文件位置。返回:若成功則爲寫的字節數,若出錯則爲-1。
8.3 printf的實現分析
從vsprintf生成顯示信息,到write系統函數,到陷阱-系統調用 int 0x80或syscall.
printf的實現:
typedef char *va_list;
int printf(const char fmt, …)
{
int i;
char buf[256];
va_list arg = (va_list)((char
)(&fmt) + 4);
i = vsprintf(buf, fmt, arg);
write(buf, i);
return i;
}
fmt是一個指針,這個指針指向第一個const參數(const char fmt)中的第一個元素。
由於棧是從高地址向低地址方向增長的,可知(char
)(&fmt) + 4) 表示的是第一個參數的地址。
下面來看看vsprint的實現:
int vsprintf(char *buf, const char fmt, va_list args)
{
char
p;
char tmp[256];
va_list p_next_arg = args;

for (p=buf;*fmt;fmt++) { 
  if (*fmt != '%') { 	//若不是格式符,則複製到buf所指向的單元
	*p++ = *fmt; 
	continue; 
  } 

fmt++; 

switch (*fmt) { 	//遇到格式符處理
case 'x': 
itoa(tmp, *((int*)p_next_arg)); 
strcpy(p, tmp); 
p_next_arg += 4; 	//指向參數的下一個字符
p += strlen(tmp); 
break; 
case 's': 
break; 
default: 
break; 
} 

}

return (p - buf); 

}
由此可知,vsprint的功能爲把指定的匹配的參數格式化,並返回字符串的長度。
下面我們反彙編追蹤一下write:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
這裏的int表示要調用中斷門了。通過中斷門,來實現特定的系統服務。
再看看INT_VECTOR_SYS_CALL的原型:
init_idt_desc(INT_VECTOR_SYS_CALL, DA_386IGate, sys_call, PRIVILEGE_USER);
即int INT_VECTOR_SYS_CALL表示要通過系統來調用sys_call這個函數。

再來看看sys_call的實現:
sys_call:
call save //保存中斷前進程的狀態
push dword [p_proc_ready]
sti
push ecx //ecx中是要打印出的元素個數
push ebx //ebx中的是要打印的buf字符數組中的第一個元素
call [sys_call_table + eax * 4] //不斷的打印出字符,直到遇到:’\0’
add esp, 4 * 3
mov [esi + EAXREG - P_STACKBASE], eax
//[gs:edi]對應的是0x80000h:0採用直接寫顯存的方法顯示字符串
cli
ret
字符顯示驅動子程序:從ASCII到字模庫到顯示vram(存儲每一個點的RGB顏色信息)
Sys_call將字符串中的字節“Hello 117030**** *”從寄存器中通過總線複製到顯卡的顯存中,此時字符以ASCII碼形式存儲。字符顯示驅動子程序將ASCII碼在自模庫中找到點陣信息將點陣信息存儲到vram中。最後,顯示芯片按照刷新頻率逐行讀取vram,並通過信號線向液晶顯示器傳輸每一個點(RGB分量)。
於是,“Hello 117030
***”就顯示在了屏幕上。

8.4 getchar的實現分析
異步異常-鍵盤中斷的處理:鍵盤中斷處理子程序。接受按鍵掃描碼轉成ascii碼,保存到系統的鍵盤緩衝區。
getchar等調用read系統函數,通過系統調用讀取按鍵ascii碼,直到接受到回車鍵才返回。
8.5本章小結
本章主要講述了Linux的IO設備管理方法,Unix I/O接口及其函數,以及printf函數實現的分析和getchar函數的實現。在此過程中,我們對系統I/O函數和Linux中將設備映射爲文件式來管理的方式有了進一步的認識。特別地,在printf函數的底層實現的分析過程中,將原來只是簡單的的打印函數一層層地展開,讓我們進一步認識了底層的工作過程。

結論
用計算機系統的語言,逐條總結hello所經歷的過程。
你對計算機系統的設計與實現的深切感悟,你的創新理念,如新的設計與實現方法。
Hello程序經歷的過程:

  1. 程序編寫;
  2. 預處理:將hello.c源程序的#開頭的指令進行符號替換,生成hello.i文件
  3. 編譯:將hello.i轉化爲彙編代碼,生成hello.s文件
  4. 彙編:將hello.s轉化爲二進制的機器代碼,生成hello.o的可重定位目標程序
  5. 鏈接:對hello.o中引用的外部函數、全局變量等進行符號解析,並重定位爲可執行文件hello。
  6. 運行:在終端輸入hello 117030****
  7. 創建子程序:通過fork創建子程序;
  8. 執行:通過execve加載器載入,建立虛擬內存映射,設置當前進程的上下文中的程序計數器,使之指向程序入口處。CPU爲其分配相應的時間分片,形成同其他程序併發執行的假想;
  9. 訪存:CPU上的內存管理單元MMU根據頁表將CPU生成的虛擬地址翻譯成物理地址,將相應的頁面調度;
  10. 動態內存申請:printf調用malloc進行動態內存分配,在堆中申請所需的內存;
  11. 接收信號:中途接受ctrl+z掛起,ctrl+c終止;
  12. 結束:程序返回後,內核向父進程發送SIGCHLD信號,此時終止的hello被父進程回收。

本次實驗將這學期以來的所有知識點貫穿起來,從數據的運算處理,到彙編、鏈接、信號異常的處理、內存管理、再到後面的I/O管理。但是最讓我震撼的是虛擬內存的實現,它將硬件異常、硬件翻譯、彙編、鏈接、加載、內存共享等等一系列難題都大大簡化了。將內存看作磁盤的高速緩存由它實現,從而使整個內存系統同時具備了高速度、大容量的特點;它爲每個進程提供了一致的私有空間,從而大大簡化了進程管理,同時它又保護了進程的地址空間不被破壞。雖然有時它會造成不易察覺的錯誤,但是無法否認,它是偉大又可愛的!因此,在硬件提高速度有限的同時,建立抽象模型,優化軟件,在提高計算機性能方面也是信息信息時代技術革命不容忽略的主題。

附件
列出所有的中間產物的文件名,並予以說明起作用。
(附件0分,缺失 -1分)
文件名 文件作用
hello.i 預處理之後的源程序
hello.s 編譯之後的彙編程序
hello.o 彙編之後的可重定位目標程序
hello.elf hello.o的ELF格式
helloexe.elf hello的ELF格式
hello 可執行目標程序
hello2.s 反彙編後輸出的程序
Helloobj.txt Hello可執行程序的反彙編代碼
Temp.c 臨時數據存放

參考文獻
爲完成本次大作業你翻閱的書籍與網站等
[1] https://www.cnblogs.com/zmrlinux/p/4921394.html
[2] https://blog.csdn.net/zsl091125/article/details/52556766
[3] https://blog.csdn.net/baidu_35679960/article/details/80463445
[4] https://blog.csdn.net/Six_666A/article/details/80635974
[5] https://www.cnblogs.com/pianist/p/3315801.html
[6] 深入理解計算機系統 第三版 蘭德爾E.布萊恩特 Randal E. Bryant 大衛 R. 奧哈拉倫DavidR. O’Hallaron [美]卡耐基梅隆大學著

發佈了23 篇原創文章 · 獲贊 5 · 訪問量 1萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章