第 7章
+---------------------------------------------------+
| 寫一個塊設備驅動 |
+---------------------------------------------------+
| 作者:趙磊 |
| email: [email protected] |
+---------------------------------------------------+
| 文章版權歸原作者所有。 |
| 大家可以自由轉載這篇文章,但原版權信息必須保留。 |
| 如需用於商業用途,請務必與原作者聯繫,若因未取得 |
| 授權而收起的版權爭議,由侵權者自行負責。 |
+---------------------------------------------------+
上一章中我們對驅動程序做了很大的修改,單獨分配每一頁的內存,然後使用基樹來進行管理
這使得驅動程序佔用的非線性映射區域大大減少,讓它看起來朝優秀的代碼 接近了一些
因爲優秀的代碼是相似的,糟 的代碼卻各有各的糟 之處
本章中我們將討論一些細枝末節的問題,算是對上一章中內容的鞏固,也是爲後面的章節作一些鋪墊
首先聊一聊低端內存、高端內存和非線性映射區域的問題:
在 i386結構中,由於任務使用 32位寄存器表示地址,這造成每個任務的最大尋址範圍是 4G
無論任務對應的是用戶程序還是內核代碼,都逃脫不了這個限制
讓問題更糟 的是,普通的 linux內核又將4G的地址劃分爲 2個部分,前3G讓用戶空間程序使用,後
1G由內核本身使用
這又將內核實際使用的空間壓縮了 4倍
不過 linux採用這樣的方案倒也不是由於開發者腦癱,因爲這樣一來,內核可以與用戶進程共用同一個
頁表,
因而在進行用戶態和內核態的切換時不必刷新頁表,提高了系統的效率
而帶來的麻煩就是內核只有 1G的地址範圍可用
其實也有一個相當出名的 4G+4G的 patch ,就是採用上述相反的方法,讓內核與用戶進程使用獨立的地
址空間,其優缺點也正好與現在的實現相反
但這畢竟不是標準內核的情況,對大多數系統而言,我們不得不接受內核只有 1G的地址範圍可用的現實
----------------------- Page 50-----------------------
然後我們再來看內核如何使用這 1G的地址範圍
作爲內核,當然需要有能力訪問到所有的物理內存,而在保護模式下,內存需要通過頁表映射到一個虛
擬地址上,再進行訪問
雖然內核可以在訪問任何物理內存時都採用映射->訪問->取消映射的方法,但這很可能將任意一臺機器
徹底變成 386的速度
因此,內核一般把儘可能多的物理內存事先映射到它的地址空間中去,這裏的“儘可能多”指的是 896M
原因是內核手頭只有 1G的地址空間,而其中的 128M還需要留作非線性映射空間
這樣一來,內核地址空間中的 3G~3G+896M便映射了 0~896M範圍的物理內存
這個映射關係在啓動系統時完成,並且在系統啓動後不會改變
物理內存中 0~896M的這段空間是幸運的,因爲它們在內核空間中有固定的住所,
這也使它們能夠方便、快速地被訪問。相對896M以上的物理內存,它們地址是比較低的,
正因爲此,我們通常把這部分內存區域叫做低端內存
但地址高於 896M的物理內存就沒這麼幸運了
由於它們沒有在啓動時被固定映射到內核空間的地址空間中,我們需要在訪問之前對它們進行映射
但映射到哪裏呢?幸好內核沒有把整個 1G的地址空間都用作映射上面所說的低端內存,好歹還留下
128M
其實這 128M還是全都能用,在其開頭和結尾處還有一些區域拿去幹別的事情了 (希望讀者去詳細瞭解一
下) ,
所以我們可以用這剩下的接近128M的區域來映射高於 896M的物理內存
明顯可以看出這時是僧多粥少,所以這部分區域最好應該節約使用
但希望讀者不要把訪問高於 896M的物理內存的問題想得過於嚴重,因爲一般來說,內核會傾向於把這部
分內存分配給用戶進程使用,而這是不需要佔用內核空間地址的
其實非線性映射區域還有另一個作用,就是用來作連續地址的映射
內核採用夥伴系統管理內存,這使得內核程序可以一次申請 2的 n次冪個頁面
但如果 n 比較大時,申請失敗的風險也會隨之增加 正如桑拿時遇到雙胞胎的機會很少、遇到三胞胎的
機會更少一樣,
獲得地址連續的空閒頁面的機會總是隨着連續地址長度的增加而減少
另外,即使能夠幸運地得到地址連續的空閒頁面,可能產生的浪費問題也是不能迴避的
比如我們需要申請地址連續513K的內存,從夥伴系統中申請時,由於只能選擇申請 2的 n次冪個頁面,
因此我們不得不去申請 1M內存
不過這兩個問題倒是都能夠通過使用非線性映射區域來解決
我們可以從夥伴系統中申請多個小段的內存,然後把它們映射到非線性映射區域中的連續區域中訪問
內核中與此相關的函數有 vmalloc、vmap等
其實8 前的作者很羨慕8 後和 9 後的新一代,不僅因爲可以在上中學時談戀愛,
還因爲隨着64位系統的流行,上面這些與 32位系統如影隨形的問題都將不復存在
關於 64位系統中的內存區域問題就留給有興趣的讀者去鑽研了
然後我們再談談linux中的夥伴系統
----------------------- Page 51-----------------------
夥伴系統總是分配出 2的 n次冪個連續頁面,並且首地址以其長度爲單位對齊
這增大了將回收的頁與其它空白頁合併的可能性,也就是減少了內存碎片
我們的塊設備驅動程序需要從夥伴系統中獲得所需的內存
目前的做法是每次獲得 1個頁面,也就是分配頁面時,把2的 n次冪中的 n指定爲
這樣做的好處是隻要系統中存在空閒的頁面,不管空閒的頁面是否連續,分配總是能成功
但壞處是增加了造就頁面碎片的機率
當系統中沒有單獨的空閒頁面時,夥伴系統就不得不把原先連續的空閒頁面拆開,再把其中的 1個頁面
返回給我們的程序
同時,在夥伴系統中需要使用額外的內存來管理每一組連續的空閒頁面,因此增大頁面碎片也意味着需
要更多的內存來管理這些碎片
這還不算,如果系統中的空閒頁面都以碎片方式存在,那麼真正到了需要分配連續頁面的時候,即使存
在空閒的內存,也會因爲這些內存不連續而導致分配失敗
除了對系統的影響以外,對我們的驅動程序本身而言,由於使用了基樹來管理每一段內存,將內存段定
義得越短,意味着需要管理更多的段數,也意味着更大的基樹結構和更慢的操作
因此我們打算增加單次從夥伴系統中獲得連續內存的長度,比如,每次分配2個、4個、或者 8個甚至
64個頁,來避免上述的問題
每次分配更大的連續頁面很明顯擁有不少優勢,但其劣勢也同樣明顯:
當系統中內存碎片較多時,喫虧的就是咱們的驅動程序了。原本分很多次一點一點去系統討要,最終可
以要到足夠的內存,但像現在這樣子獅子大開口,卻反而要不到了
還有就是如果系統中原先就存在不少碎片,原先的分配方式倒是可以把碎片都利用起來,而現在這種挑
肥撿瘦的分配會同樣無視那些更小的不連續頁面,反而可能企圖去拆散那些更大的連續頁面
折中的做法大概就是選擇每次分配一塊不大不小的連續的頁,暫且我們選擇每次分配連續的 4個頁
現在開始修改代碼:
爲簡單起見,我們了以下的4個宏:
#define SIMP_BLKDEV_DATASEGORDER (2)
#define SIMP_BLKDEV_DATASEGSHIFT (PAGE_SHIFT + SIMP_BLKDEV_DATASEGORDER)
#define SIMP_BLKDEV_DATASEGSIZE (PAGE_SIZE <<
SIMP_BLKDEV_DATASEGORDER)
#define SIMP_BLKDEV_DATASEGMASK (~(SIMP_BLKDEV_DATASEGSIZE-1))
SIMP_BLKDEV_DATASEGORDER表示我們從夥伴系統中申請內存時使用的 order值,把這個值設置爲 2
時,每次將從夥伴系統中申請連續的 4個頁面
我們暫且把這樣的連續頁面叫做內存段,這樣一來,在 i386結構中,每個內存段的大小爲 16K ,假設塊
設備大小還是 16M ,那麼經歷了本章的修改後,
驅動程序所使用的內存段數量將從原先的 4096個減少爲現在的 1024個
SIMP_BLKDEV_DATASEGSHIFT是在偏移量和內存段之間相互轉換時使用的移位值,類似於頁面處理中
的 PAGE_SHIFT。這裏就不做更詳細地介紹了,畢竟這不是 C語言教程
SIMP_BLKDEV_DATASEGSIZE是以字節爲單位的內存段的長度,在i386和
SIMP_BLKDEV_DATASEGORDER=2時它的值是 16384
SIMP_BLKDEV_DATASEGMASK是內存段的屏蔽位,類似於頁面處理中的 PAGE_MASK
----------------------- Page 52-----------------------
其實對於功能而言,我們只需要SIMP_BLKDEV_DATASEGORDER和 SIMP_BLKDEV_DATASEGSIZE就足夠
了,其它的宏用於快速的乘除和取模等計算
如果讀者對此感到有些迷茫的話,建議最好還是搞明白,因爲在linux內核的世界中這一類的位操作將
隨處可見
然後要改的是申請和釋放內存代碼
原先我們使用的是__get_free_page()和 free_page()函數,這一對函數用來申請和釋放一個頁面
這顯然不能滿足現在的要求,我們改用它們的大哥:__get_free_pages()和 free_pages()
它們的原型是:
unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order);
void free_pages(unsigned long addr, unsigned int order);
可以注意到與__get_free_page()和 free_page()函數相比,他們多了個 order參數,正是用於指定
返回 2的多少次冪個連續的頁
因此原先的 free_diskmem()和 alloc_diskmem()函數將改成以下這樣:
void free_diskmem(void)
{
int i;
void *p;
for (i = 0; i < (SIMP_BLKDEV_BYTES + SIMP_BLKDEV_DATASEGSIZE - 1)
>> SIMP_BLKDEV_DATASEGSHIFT; i++) {
p = radix_tree_lookup(&simp_blkdev_data, i);
radix_tree_delete(&simp_blkdev_data, i);
/* free NULL is safe */
free_pages((unsigned long)p, SIMP_BLKDEV_DATASEGORDER);
}
}
int alloc_diskmem(void)
{
int ret;
int i;
void *p;
INIT_RADIX_TREE(&simp_blkdev_data, GFP_KERNEL);
for (i = 0; i < (SIMP_BLKDEV_BYTES + SIMP_BLKDEV_DATASEGSIZE - 1)
>> SIMP_BLKDEV_DATASEGSHIFT; i++) {
p = (void *)__get_free_pages(GFP_KERNEL,
SIMP_BLKDEV_DATASEGORDER);
if (!p) {
ret = -ENOMEM;
----------------------- Page 53-----------------------
goto err_alloc;
}
ret = radix_tree_insert(&simp_blkdev_data, i, p);
if (IS_ERR_VALUE(ret))
goto err_radix_tree_insert;
}
return 0;
err_radix_tree_insert:
free_pages((unsigned long)p, SIMP_BLKDEV_DATASEGORDER);
err_alloc:
free_diskmem();
return ret;
}
除了用__get_free_pages()和 free_pages()代替了原先的__get_free_page()和 free_page()函
數以外,
還使用剛剛定義的那幾個宏代替了原先的 PAGE宏
這樣一來,所需內存段數的計算方法也完成了修改
剩下的就是使用內存段的 simp_blkdev_make_request()代碼
實際上,我們只要用剛纔定義的 SIMP_BLKDEV_DATASEGSIZE、SIMP_BLKDEV_DATASEGMASK和
SIMP_BLKDEV_DATASEGSHIFT替換原先代碼中的 PAGE_SIZE、PAGE_MASK和 PAGE_SHIFT就大功告成
了,
當然,這個結論是作者是經過充分檢查和實驗後才得出的,希望不要誤認爲編程時可以大大咧咧地隨心
所欲。作爲程序員,嚴謹的態度永遠都是需要的
現在,我們的 simp_blkdev_make_request()函數變成了這樣:
static int simp_blkdev_make_request(struct request_queue *q, struct bio *bio)
{
struct bio_vec *bvec;
int i;
unsigned long long dsk_offset;
if ((bio->bi_sector << 9) + bio->bi_size > SIMP_BLKDEV_BYTES) {
printk(KERN_ERR SIMP_BLKDEV_DISKNAME
": bad request: block=%llu, count=%u\n",
(unsigned long long)bio->bi_sector, bio->bi_size);
#if LINUX_VERSION_CODE < KERNEL_VERSION(2, 6, 24)
bio_endio(bio, 0, -EIO);
#else
bio_endio(bio, -EIO);
----------------------- Page 54-----------------------
#endif
return 0;
}
dsk_offset = bio->bi_sector << 9;
bio_for_each_segment(bvec, bio, i) {
unsigned int count_done, count_current;
void *iovec_mem;
void *dsk_mem;
iovec_mem = kmap(bvec->bv_page) + bvec->bv_offset;
count_done = 0;
while (count_done < bvec->bv_len) {
count_current = min(bvec->bv_len - count_done,
(unsigned int)(SIMP_BLKDEV_DATASEGSIZE
- ((dsk_offset + count_done) &
~SIMP_BLKDEV_DATASEGMASK)));
dsk_mem = radix_tree_lookup(&simp_blkdev_data,
(dsk_offset + count_done)
>> SIMP_BLKDEV_DATASEGSHIFT);
if (!dsk_mem) {
printk(KERN_ERR SIMP_BLKDEV_DISKNAME
": search memory failed: %llu\n",
(dsk_offset + count_done)
>> SIMP_BLKDEV_DATASEGSHIFT);
kunmap(bvec->bv_page);
#if LINUX_VERSION_CODE < KERNEL_VERSION(2, 6, 24)
bio_endio(bio, 0, -EIO);
#else
bio_endio(bio, -EIO);
#endif
return 0;
}
dsk_mem += (dsk_offset + count_done)
& ~SIMP_BLKDEV_DATASEGMASK;
switch (bio_rw(bio)) {
case READ:
case READA:
----------------------- Page 55-----------------------
memcpy(iovec_mem + count_done, dsk_mem,
count_current);
break;
case WRITE:
memcpy(dsk_mem, iovec_mem + count_done,
count_current);
break;
default:
printk(KERN_ERR SIMP_BLKDEV_DISKNAME
": unknown value of bio_rw: %lu\n",
bio_rw(bio));
kunmap(bvec->bv_page);
#if LINUX_VERSION_CODE < KERNEL_VERSION(2, 6, 24)
bio_endio(bio, 0, -EIO);
#else
bio_endio(bio, -EIO);
#endif
return 0;
}
count_done += count_current;
}
kunmap(bvec->bv_page);
dsk_offset += bvec->bv_len;
}
#if LINUX_VERSION_CODE < KERNEL_VERSION(2, 6, 24)
bio_endio(bio, bio->bi_size, 0);
#else
bio_endio(bio, 0);
#endif
return 0;
}
本章的到這裏就完成了,接下去我們還是打算試驗一下效果
其實這個實驗不太好做,因爲 linux本身也會隨時分配和釋放頁面,這會影響我們看到的結果
如果讀者看到的現象與預期不同,這也屬於預期
不過爲了降低試驗受到 linux自身活動影響的可能性,建議試驗開始之前儘可能關閉系統中的服務、不
要同時做其它的操作、不要在 xwindows中做
然後我們開始試驗:
----------------------- Page 56-----------------------
先編譯模塊:
# make
make -C /lib/modules/2.6.18-53.el5/build
SUBDIRS=/root/test/simp_blkdev/simp_blkdev_step07 modules
make[1]: Entering directory `/usr/src/kernels/2.6.18-53.el5-i686'
CC [M] /root/test/simp_blkdev/simp_blkdev_step07/simp_blkdev.o
Building modules, stage 2.
MODPOST
CC /root/test/simp_blkdev/simp_blkdev_step07/simp_blkdev.mod.o
LD [M] /root/test/simp_blkdev/simp_blkdev_step07/simp_blkdev.ko
make[1]: Leaving directory `/usr/src/kernels/2.6.18-53.el5-i686'
#
現在看看夥伴系統的情況:
# cat /proc/buddyinfo
Node 0, zone DMA 288 63 34 0 0 0 0 1
1 1
Node 0, zone Normal 9955 1605 24 1 0 1 1
0 0 1
Node 0, zone HighMem 2036 544 13 6 2 1 1
0 0
#
加載模塊後再看看夥伴系統的情況:
# insmod simp_blkdev.ko
# cat /proc/buddyinfo
Node 0, zone DMA 337 140 1 1 1 0 0
1 0
Node 0, zone Normal 27888 8859 18 0 0 1 0
1 0
Node 0, zone HighMem 1583 544 13 6 2 1 1
0 0
#
釋放模塊後再看看夥伴系統的情況:
# rmmod simp_blkdev
# cat /proc/buddyinfo
Node 0, zone DMA 337 140 35 0 0 0 0 1
1 1
Node 0, zone Normal 27888 8860 632 7 0 1 1
0 0 1
Node 0, zone HighMem 1583 544 13 6 2 1 1
0 0
----------------------- Page 57-----------------------
#
首先補充說明一下夥伴系統對每種類型的內存區域分別管理,這在夥伴系統中稱之爲 zone
在 i386中,常見的 zone有 DMA、Normal和 HighMem ,分別對應0~16M、16~896M和 896M以上的物
理內存
DMA zone的特點是老式ISA設備只能使用這段區域進行 DMA操作
Normal zone的特點它被固定映射在內核的地址空間中,我們可以直接使用指針訪問這段內存 (不難
看出,DMA zone也有這個性質)
HighMem zone的特點它沒有以上兩種 zone的特點
其實我們在上文中講述的低端內存區域是這裏的 DMA和 Normal zone ,而高端內存區域是這裏的
HighMem zone
/proc/buddyinfo用於顯示夥伴系統的各個 zone中剩餘的各個 order的內存段個數
我們的模塊目前使用低端內存來存儲數據,而一般情況下系統會盡可能保留 DMA zone的空域內存不被
分配出去,
因此我們主要關注/proc/buddyinfo 中的Normal行
行中的各列中的數字表示夥伴系統的這一區域中每個 order的剩餘內存數量
比如:
Node 0, zone Normal 9955 1605 24 1 0 1 1
0 0 1
這一行表示Normal zone 中剩餘9955個獨立的內存頁、1605個連續2個頁的內存、24連續4個頁的
內存等
由於我們現在每次申請 4個頁的內存,因此最關注的 Normal行的第 3列
首先看模塊加載前,Normal行的第 3列數字是 24 ,表示系統中剩餘24個連續4頁的內存區域
然後我們看模塊加載之後的情況,Normal行的第 3列從24變爲了 18 ,減少了 6個連續4頁的內存區域
這說明我們的程序只用掉了 6個連續4頁的內存區域------明顯不可能
因爲作爲模塊編者,我們很清楚程序需要使用 1024個連續4頁的內存區域
繼續看這一行的後面,原先處在最末尾的 1便成了
我們可以數出來最末尾的數字對應order爲 1 的連續頁面,也就是連續4M的頁面,原來是空閒的,而
現在被拆散用掉了
但即使它被用掉了,也不夠我們的的 16M空間,數字的分析變得越來越複雜,是堅持下去還是就此停止?
這一次我們決定停止,因爲真相是現在進行的模塊加載前後的剩餘內存對比確實產生不了什麼結論
詳細解釋一下,其實我們可以看出在模塊加載之前,Normal 區域中 order>=2的全部空閒內存加起來也
不夠這個模塊使用
甚至加上 DMA 區域中 order>=2的全部空閒內存也不夠
----------------------- Page 58-----------------------
雖然剩餘的 order<2的一大堆頁面湊起來倒是足夠,但誰讓我們的模塊挑食,只要order=2的頁面呢
因此這時候系統會試圖釋放出空閒內存。比如:釋放一些塊設備緩衝頁面,或者將用戶進程的內存轉移
到 swap中,以獲得更多的空閒內存
很幸運,系統通過釋放內存操作拿到了足夠的空閒內存使我們的模塊得以順利加載,
但同時由於額外增加出的空閒內存使我們對比模塊加載前後的內存差別失去了意義
其實細心一些的話,剛纔的對比中,我們還是能夠得到一些結論的,比如,
我們可以注意到模塊加載後 order爲 和 1的兩個數字的暴增,這就是系統釋放頁面的證明
詳細來說,系統釋放出的頁面既包含order<2的,也包含order>=2的,但由於其中 order>=2的頁面
多半被我們的程序拿走了,
這就造成模塊加載後的空閒頁面中大量出現order<2的頁面
既然我們沒有從模塊加載前後的空閒內存變化中拿到什麼有意義的結論,
我們不妨換條路走,去看看模塊釋放前後空閒內存的變化情況:
首先還是看 Normal 區域:
order爲 和 1的頁面數目基本沒有變化,這容易解釋,因爲我們釋放出的都是 order=2的連續頁面
order=2的連續頁面從18增加到 632 ,增加了 614個。這應該是模塊卸載時所釋放的內存的一部分
由於這個模塊在卸載時,會釋放1024個 order=2的連續頁面,那麼我們還要繼續找出模塊釋放的內存
中其他部分的行蹤
也就是 1024-614=41 個 order=2的連續頁到哪去了
回顧上文中的夥伴系統說明,夥伴系統會適時地合併連續頁面,那麼我們假設一部分模塊釋放出的頁面
被合併成更大 order的連續頁面了
讓我們計算一下 order>2的頁面的增加情況:
order=3的頁面增加了 7個,order=6的頁面增加了 1個,order=8的頁面減少了 1個,order=1 的
頁面增加了 1個
這分別相當於 order=2的頁面增加 14個、增加 16、減少64個、增加 256個,綜合起來就是增加 222
個
這就又找到了一部分,剩下的行蹤不明的頁面還有 410-222=188個
我們繼續追查,現在 DMA zone 區域
我們的程序所使用的是低端內存,其實也包含0~16M之間的 DMA zone
剛纔我們說過,系統會盡可能不把DMA 區域的內存分配出去,以保證真正到必須使用這部分內存時,能
夠拿得出來
但“儘可能”不代表“絕對不”,如果出現內存不足的情況,DMA zone的空閒內存也很難倖免
但剛纔我們的試驗中,已經遇到了 Normal 區域內存不足情況,這時把DMA zone中的公主們拿去充當
Normal zone的軍妓也是必然的了
因此我們繼續計算模塊釋放後 DMA 區域的內存變化。在DMA 區域:
order=2的頁面增加了 34個,order=3的頁面減少了 1個,order=4的頁面減少了 1個,order=7的
頁面增加了 1個,order=9的頁面增加了 1個
----------------------- Page 59-----------------------
這分別相當於 order=2的頁面增加 34個、減少2、減少4個、增加 32個,增加 128個,綜合起來就是
增加 188個
數字剛好吻合,我們就找到了模塊釋放出的全部頁面的行蹤
這也驗證了本章中改動的功能符合預期
然後我們再一次加載和卸載模塊,同時查看夥伴系統中空閒內存的變化:
# insmod simp_blkdev.ko
# cat /proc/buddyinfo
Node 0, zone DMA 336 141 0 0 0 1 1
1 0
Node 0, zone Normal 27781 8866 0 1 0 1 0
1 0
Node 0, zone HighMem 1459 544 13 6 2 1 1
0 0
#
# rmmod simp_blkdev
# cat /proc/buddyinfo
Node 0, zone DMA 336 141 35 0 0 0 0 1
1 1
Node 0, zone Normal 27781 8867 633 7 0 1 1
0 0 1
Node 0, zone HighMem 1459 544 13 6 2 1 1
0 0
#
我們可以發現這一次模塊加載前後的內存變化情況與上一輪有些不同,而分析工作就留給有興趣的讀者
了
本章對代碼的改動量不大,主要說明一下與我們程序中出現的 linux內存管理知識
其實上一章的改動中已經涉及到了這部分知識,只是因爲那時的重點不在這個方面,並且作者也不希望
在同一章中加入過多的內容,
因此在本章中做個補足
同時,本章中的說明也給後續章節中將要涉及到的內容做個準備,這樣讀者在將來也可以愜意一些
不過在開始寫這一章時,作者曾反覆考慮該不該這樣組織本章,
正如我們曾經說過的,希望讀者在遇到不明白的地方時主動去探索教程之外更多的知識,
而不是僅僅讀完這個教程本身
本教程的目的是牽引出通過實現一個塊設備驅動程序來牽引出相關的 linux的各個知識點,
讓讀者們以此爲契機,通過尋求疑問的答案、通過學習更細節的知識來提高自己的能力
因此教程中對於不少涉及到的知識點僅僅給出簡單的介紹,因爲讀者完全有能力通過 google瞭解更詳細
的內容,
這也是作者建議的看書方法
----------------------- Page 60-----------------------
不過本章是個例外,因爲作者最終認爲對這些知識的介紹對於這部教程的整體性是有幫助的
但這裏的介紹其實仍然只屬於皮毛,因此還是希望讀者進一步瞭解教程以外的更多知識
<未完,待續>