2017年8月21日 星期一

Value Type (實值型別) vs. Reference Type (參考型別)


[Ref] http://xingulin.tumblr.com/post/48493582986/ref-type-vs-val-type


在 MSDN 上,將 .Net 下所有的類別,分為以下三類︰
  • Value Type (實值型別)︰常見的型別是 struct、int、char、double 等
  • Reference Type (參考型別)︰典型的例子就是使用 class 關鍵字定義的型別
  • Pointer Type (指標型別)
其中 Pointer Type,主要是像 C/C++ 語言,可以用來對記憶體直接操作。但因為使用到它的機會比較特別,我自己使用到它的機會也沒有太多,所以本文暫時先不針對此型別做討論。
而 Value Type 與 Reference Type 兩者在 .Net 下的型別階層體系的差別上,最明顯的差別與區別方式在於,Value Type 皆繼承自 System.ValueType,因此如果不是繼承自 System.ValueType 的型別,都不是 Value Type。
針對 Value Type 與 Reference Type 的差異,我們可以先以以下兩句 ,簡單做個說明︰
  • 在 Value Type 變數中,儲存的值是「實值」(Value),像是整數、浮點數、布林、字元等
  • 在 Reference Type 變數中,儲存的值是「參考」(Reference),也就是記憶體的位址 ,指向儲存真實內容的記憶體區塊的開始位置。
也就是說,Value Type 與 Reference Type 最顯著的差別,是他們在記憶體中,儲存其變數值的方式。再往下繼續探討前,我覺得我們可以先建立以下的基礎觀念︰
  • C# (或說大部份的程式語言) 會將記憶體分為兩大用途︰Stack 與 Heap。
  • C# 中所有的區域變數 (不管是 Value Type 或是 Reference Type),其內容 (變數名稱、型別與與值) 都是儲存在 Stack 中。Value Type 變數儲存的內容是「實值」,Reference Type 變數儲存的內容是「參考」。
  • 使用 new 關鍵字實體化類別的物件,其物件內容,是儲存在 Heap 中。Reference Type 變數中所儲存的參考,其實是指向 Heap 中的記憶體位址。
墳墓大大之前有寫了一系列關於 C/Java 在記憶體管理上,非常好的系列文章,大家可以閱讀第一、二篇 (其實全系列 8 篇都很推薦閱讀),以建立上述觀念︰
為了測試兩者在記憶體儲存上的差別,我建立測試用 Reference Type 型別與 Value Type 型別如下︰
?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
///
/// 測試用 Reference Type 型別
///
public class DemoReferenceType
{
    int _field;
    public int Field
    {
        get
        {
            return this._field;
        }
 
        set
        {
            this._field = value;
        }
    }
 
    public DemoReferenceType(int val)
    {
        this._field = val;
    }
 
    public override string ToString()
    {
        return "Field=" + this._field.ToString();
    }
}
另外建立測試用 Value Type 如下︰
?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/// 測試用 Value Type 型別
///
public struct DemoValueType
{
    int _field;
    public int Field
    {
        get
        {
            return this._field;
        }
         
        set
        {
            this._field = value;
        }
    }
 
    public DemoValueType(int val)
    {
        this._field = val;
    }
 
    public override string ToString()
    {
        return "Field=" + this._field.ToString();
    }
}
我們以下列程式進行測試︰
?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 建立 Reference Type 與 Value Type 的測試物件
DemoReferenceType refVar1 = new DemoReferenceType(1);
DemoValueType valVar1 = new DemoValueType(1);
 
// 將變數 1 的值,指派給變數 2
DemoReferenceType refVar2 = refVar1;
DemoValueType valVar2 = valVar1;
 
// 變更變數 2 的欄位值
refVar1.Field = 2;
valVar2.Field = 2;
 
// 印出︰
// refVar1 Field=2
// refVar2 Field=2
// valVar1 Field=1
// valVar2 Field=2
Console.WriteLine("refVar1 " + refVar1);
Console.WriteLine("refVar2 " + refVar2);
Console.WriteLine("valVar1 " + valVar1);
Console.WriteLine("valVar2 " + valVar2);
可以發現,在 Reference Type 與 Value Type 下,我們都是將變數一 (refVar1、valVar1) 的值指派給變數二 (refVar2、valVar2),並修改變數二的欄位值。但最後的結果卻不相同。
直覺上我們會變得 Reference Type 的情形不符直覺,明明我已經變數一(refVar1) 「指派」(assign) 給變數二 (refVar2) 了,為什麼我修改變數二的欄位值,看起來好像也影響到變數一的欄位值了呢?
這是因為兩者間所交換儲存的值不同,在「指派」這件事上,其實兩種型別做的事都是相同的,即「將變數一儲存的值,建立一個複本並儲存到變數二中」,只是 Value Type 建立的複本是資料本身,而 Reference Type 的複本是參考。
我們以圖例來進行說明,下圖是我們建立變數一時,記憶體中的配置方式︰
image
可以看到,所有的區域變數,都是儲存於 Stack 中,Reference Type 中儲存的是參考,內含是 Heap 中的記憶體位址。
而在建立變數二,並將變數一 (refVar1、valVar1) 的值指派給變數二 (refVar2、valVar2)後,記憶體中的配置如下︰
image
如同先前所說,「指派」這件事,不管在 Reference Type 或是 Value Type 都是一樣的,都是將變數一的儲存的值,複製一份到變數二。
而因為 Reference Type 中儲存的是參考,因此變數二 (refVar2) 會儲存與變數一 (refVar2) 相同的記憶體位址。也就是其實 refVar2 與 refVar1 指向的是在 Heap 上,相同的物件內容。
最後我們可以看看,同樣針對變數二的欄位做變更,在記憶體上的儲存情形︰
image
在此我們可以看到,由於 Reference Type 中的 refVar1 與 refVar2 中儲存的參考值相同,也就是兩者背後指向的是相同的物件內容。因此使用 refVar2 變數修改 Field 欄位內容,再取出 refVar1.Field 欄位值,兩者數值是相等的也就是完全合理的了。
最後整理一下重點如下︰
  • 在 C# 中,記憶體用途分為 Stack 與 Heap 兩種,所有的區域變數 (不管是 Value Type 或是 Reference Type) 都儲存於 Stack 下,使用 new 關鍵字實體化的類別實體,則儲存於 Heap 中
  • Value Type 儲存的是內容 (實值),Reference 儲存的是位址 (參考)
  • 由於 Value Type 與 Reference Type 在記憶體儲存值上的差異,在使用上若不理解,有時會造成意料外的問題

2017年8月3日 星期四

系統架構設計實例分析(I)





菜鳥:『你花了好多時間在說明系統架構的重要性,我大部分都能理解,但心裡還是有點虛虛的;因為實際上每個專案的特性與要求都不一樣,我實在沒有把握當自己碰到新的應用或硬體配置時,也能設計出正確的系統架構!』

PM:『我們作產品的並沒有所謂的”正確的”或”最好的”系統架構,一來業界並沒有評估系統架構優劣的標準,而且執行電子產品開發追求的應該是種”足夠好”的水準;所以實作上系統架構的設計總是循序漸進的,資深如我也沒把握一次就把系統架構考慮周全,畢竟CPU的運算能力、記憶體的size、應用的複雜程度、可投入專案的資源、公司之前是否有相關技術累積…等都可能會影響到系統架構。

好吧,今天我花點時間講講兩個我們之前作過的案子,這兩個案子相當具有代表性,在設計系統架構時都經歷了一番討論與取捨。』

Case study (I) – 8bit MCU + NAND 的系統架構

PM:『首先我們來說一個computing power與memory size嚴重受限的例子,其硬體架構如下:

-  CPU:8bit MCU (e.g. 8051相容),定址空間最大為64KB
-  DSP:因為CPU計算能力實在太弱,所以需要一顆DSP負責影音資料的解碼
-  Memory:40KB(程式執行與runtime資料所需的空間都在這40KB內,甚至比CPU位址空間的極限64KB還要小)
-  NAND Flash:GB等級
-  Boot ROM inside CPU:boot-ROM會自NAND Flash的特定位置載入一小段程式到SRAM裡執行

因為memory size實在太小,最後真的是寸土必爭,而且都是以byte為單位;再加上每新增一組的API就會多出一些函數,勢必要付出code size增加的代價,因此所有工程師都認為在這個狀況下還要去作模組化是不現實的,所以當時的PM就決定以土法煉鋼的方式硬幹下去了。後來在同仁們的努力下,這個專案確實也拼出來了。

但真正的困難出現在接續的專案上,沒考慮模組化的code本來就較難以maintain,而且幾乎每個功能都曾專門為code size的問題作過調整,所以導致新專案想要重複使用前專案的研發成果並不容易;再經過幾個同性質的專案作下來,面對新功能導致的code size問題只能挖東牆補西牆,最後整個系統簡直是到處貼滿了狗皮膏藥,新進工程師的學習曲線相當陡峭,這種案子變成PM間的燙手山芋。

當我還是菜鳥PM時,這個燙手山芋丟到了我的手上;我研究了一陣子,終於決定斷尾求生,開始組織同仁重新進行系統架構設計。如果是由你負責這個案子,你會想怎麼設計系統架構?試著在紙上把方塊圖畫出來。』

菜鳥:『以前聽你說過這個案子,似乎曾把大家搞得灰頭土臉,幸好我們現在的專案用的是32bit CPU,記憶體是MB等級的,不用跟一個個BYTE奮戰。

以硬體配置來看,我認為首先要解決的問題是:程式的footprint(包含code 與data size)遠大於SRAM的size(40KB)。既然NAND Flash的空間那麼大,我們可以把所有的產品功能切分為一個個獨立的程式並儲存在NAND flash內,而這些程式的footprint必須控制在40KB內,需要時可自NAND Flash載入SRAM執行;透過不斷的切換,應該就可以完成最終產品所需的功能,我的系統架構如下圖,我將其稱之為Program-Switch Arch.。』
 

菜鳥:『首先SRAM中必須有一塊永遠不會被置換出去的程式,我稱之為Basic System,負責作Program之間的切換;而當我們把所有程式燒入NAND Flash後,必須將這些program的size與在NAND Flash內的位置紀錄在一張表格(稱之program index table),並寫入NAND Flash中,則basic system在runtime時才知道去哪裡載入program。

如何,這個架構應該可行吧?』

PM:『不錯,這個架構不但可行,還實際用在了幾個專案。』

菜鳥:『難道你剛剛說造成最後很難maintain的架構就是長成這樣?』

PM:『是的,當初的分析結果也是認為code size是最大的問題,最直覺的作法的確就是如你所設計的架構。用這個架構來完成第一個專案確實也沒什麼大問題,但後續專案要沿用此系統就會碰到一個個麻煩。

舉例來說,當碰到code size問題時,只要把當前program切成兩個program即可;而這兩個program可能都需要用到同一個模組,但可用memory size實在太少,無法在每個program中link完整的模組,亦即模組提供的功能多寡必須依使用者(caller)而異。

我們作系統時總是禀持著一個基本原則:底層的系統必須盡量可以支援各種不同應用,所以系統的性質是比較一般化的,它不該與”使用者”有太深的關係,否則一旦換了使用者,系統的運作就可能會出問題。所以上面的作法,導致系統與應用程式的耦合度太高,破壞了模組化的基本精神;所以之後的maintain與擴充都會非常麻煩!』

菜鳥:『有聽沒有懂,可以舉個例子嗎?』

PM:『我們有個功能是MP3播放,播放時有兩個主要的功能,一個是顯示歌詞或頻譜,另一個是顯示檔案列表,讓使用者可以瀏覽檔案或換曲;顯然40KB作這些功能是遠遠不足的,所以我們使用兩個program來實現這個應用,會用到的系統模組有:1. drivers、2. Play Engine(負責播放MP3)、3. File System(負責檔案的存取)、4. UI Common (提供簡單的視窗系統功能)。

請參考下圖,兩個program都無法把這些完整的模組塞入40KB中。因為第一個program播MP3時還要作歌詞同步,但同一時間只需要access一個檔案,所以它需要”較多”的Play Engine,與較少的File System;第二個program恰恰相反,它需要”較少”的Play Engine,與較多的File System。』




 
PM:『為了滿足這樣的需求,於是所有系統模組的程式就會變成像下面的程式一樣,就一個字 – “亂”!原本應該功能單純的模組,為了滿足不同program之code size的需求,裡面加了一堆#ifdef…#else..#endif;這還是一個專案的狀況,如果用同一個系統run了多個專案後,你能想像系統程式會亂成什麼樣子嗎?

程式毫無可讀性可言,且難以維護,但這還不是最糟糕的狀況!』



菜鳥:『這個程式真的很難一眼看出某個程式段到底會不會被執行,對不熟的工程師來說,確實很容易因此犯錯,但你說這還不是最糟的?』

Ref: Link

NOP 的使用時機



菜鳥:『我有個問題,剛剛我們看了一些時序圖,控制訊號之間都有時間的關係,我想知道我們的程式如何精準地控制時間?我知道有些操作要求的timing很嚴格,舉例來說,假設CPU透過兩根PIN腳與某個IC連接,一根是CS(chip select),另一根是輸入PIN – P#1,data sheet裡說明如何從IC取得資料的時序圖如下。其中規定當CPU把CS由High被拉到Low時,該IC會開始動作,經過T1的時間後,IC會把資料放在P#1上(這裡所謂的“資料”不是High就是Low,或者說不是0就是1),維持的時間為T2。這種狀況下,我們程式如何在正確的時間裡到P#1上抓取正確的資料?』


韌體老鳥:『剛剛已經講解過,要把某根輸出PIN做High/Low變化,以及要從某根輸入PIN讀出狀態都可以透過CPU的暫存器,這個基本的控制應該沒問題吧。這個例子還算簡單的,從時序圖中我們可以看出程式該做的事情是:

a.       把CS PIN設定為Low
b.      等待T1的時間
c.       立即從P#1讀出狀態

通常執行步驟c的時間不會長於T2,所以上述的步驟中省略了T2;假設T1 = 10m s,先不要管操作CPU PIN腳的細節,我們可以將上面的步驟寫成下面這個簡單的程式: 』

/**********************************************************
       Function: drv_read_XXXIC_status
  return 0 if P#1 is Low, return 1 if P#1 is High
**********************************************************/
int drv_read_XXXIC_status(void)
{
 int i ;
 int P1_status ;

 // 將CS PIN設為Low
 //
 drv_set_XXXIC_CS_Low() ;

 //等待10ms
 //
 for(i=0;i<10;i++)
      drv_wait_1ms() ;

 // 讀出P#1 PIN的狀態
 //
 P1_status = drv_get_P1_status() ;

 // 依照時序圖, 將CS PIN恢復為High
 //
 drv_set_XXXIC_CS_High() ;

 return P1_status ;
}

菜鳥:『這個程式很簡單,但我的問題就是drv_wait_ 1m s()要怎麼implement?難道是用timer嗎?』

韌體老鳥:『用timer也是一個方法,但設定timer、執行timer ISR都需要時間執行吧,假設我們要等待的這個T1很短,使用timer會造成一定的誤差。而且要驅動timer也需要一個driver,在開發時期盡量不需要把一件工作加入太多的變數。我們在開發驅動程式時,最常用,也是最簡單的方法就是寫一個什麼事情都不做的迴圈,例如: 』

#define COUNTER_PER_1MS  22

void drv_wait_1ms(void)
{
 int counter ;

 // 執行這個function的期間, 最好確認中斷不會產生
 // 否則執行ISR的時間會影響這個函數的準確度

 for(counter = 0 ; counter < COUNTER_PER_1MS ; counter++)
 {
       // busy waiting - no nothing
       //
       asm("nop") ;
 }

}

菜鳥:『這個程式就是讓CPU執行指定次數之無意義的指令,而執行這些指令所需的時間剛好就是我們想要等待的時間。這個程式很簡單,但我有兩個問題,第一個,我們怎麼得到“COUNTER_PER_ 1M S”的值?第二個,“nop”這個CPU的指令代表什麼意思?』

韌體老鳥:『先回答你第二個問題,“nop”是no operation的意思,CPU執行這行指令時什麼都不作,就是很單純的耗掉一個或多個clock的時間;應該是所有的CPU都會提供這樣的指令,程式可以用這樣的指令讓CPU空轉一段時間,而且不會影響任何記憶體或一般暫存器的值(“nop”也是CPU的指令,CPU執行指令時,PC(program, counter)暫存器一定會跟著改變,CPU才知道要到哪裡抓取下一個命令來執行。)。在回答你第一個問題之前,你先看一下這張表,每一個CPU都會提供這樣的表格,它說明了CPU執行不同的指令需要多少個clock,你會算每一個clock的時間是多長嗎? 』

菜鳥:『你之前教過我啊,如果CPU運作的頻率是8Mhz(這個頻率和震盪器輸入的頻率不一定相同,CPU內部可以升頻或降頻),也就是說一秒鐘有8M個clock,所以一個clock的時間就是1秒除以(8 x 1024 x 1024),等於7.6 micro-second (7.6 x 10-6秒)。』

韌體老鳥:『理論上CPU執行指令的時間是很精確的,如果你把上面那個迴圈的程式轉為組合語言,你可以知道這個迴圈執行了哪些指令,每一個指令需要多少個clock,你就可以算出這個迴圈要轉幾次才會用掉你想要的時間。 』

菜鳥:『大概知道原理了,你可不可以舉個實例?』

韌體老鳥:『OK,假設那個迴圈對應的CPU指令為: 』

;
; 不同的CPU有不同的組合語言指令,
; 以下程式旨在表達流程, 並非任何CPU的組合語言語法,
;
       R0 = COUNTER_PER_1MS                 ; 2 clock cycles
loop_start:
       nop                                                          ; 1 clock cycles
       R0 = R0 -1                                              ; 1 clock cycles
       jump to "loop_start" if R0 ≠0              ; 4 clock cycles

菜鳥:『所以一次loop會用掉6個clock的時間,所以我們可以很容易算出R0的初值等於多少時,執行這個迴圈的時間剛好是1ms。』

韌體老鳥:『沒錯,就是這麼簡單。這個function的誤差很小,此後在開發其他驅動程式,甚至系統程式與應用程式時都可以使用。 』


ref: Link