Android linux 开源 google wordpress nginx 云计算 php Python 程序员 shell apache Firefox java Ubuntu Windows 编程 微软 centos mysql

《嵌入式Linux基礎教程(第2版)》——2.3 存儲

2.3 存儲

嵌入式Linux開發的一大挑戰性源自大多數嵌入式系統的物理資源非常有限。雖然你的臺式電腦會擁有酷睿2雙核處理器和500 GB大小的硬盤,但很難找到擁有如此巨大硬盤容量的嵌入式系統。多數情況下,硬盤通常被更小和更便宜的非易失性存儲設備所取代。硬盤不僅笨重,包含旋轉部件,對物理震動敏感,並且要求提供多種供電電壓,因此並不適合用在許多嵌入式系統中。

2.3.1 閃存
幾乎所有人都對消費電子設備,比如數碼相機和PDA(這兩者都是很好的嵌入式系統的例子)中廣泛使用的Compact Flash卡和SD卡很熟悉。這些基於閃存技術的模塊可以看做是固態硬盤,它們能夠在很小的空間內存儲許多兆甚至幾吉字節的數據。它們內部沒有活動的部件,相對堅固,只需一種供電電壓。

生產閃存的廠家有好幾家。閃存的類型多種多樣,電氣規格、物理封裝形式以及容量各有不同。只擁有小到4 MB或8 MB非易失性存儲容量的嵌入式系統並不罕見。嵌入式Linux系統對存儲容量的典型需求是16 MB~256 MB。越來越多的嵌入式Linux系統擁有數吉字節的非易失性存儲空間。

閃存可以在軟件的控制下寫入和擦除數據。采用旋轉硬盤驅動器技術的普通硬盤仍然是最快的可寫入存儲媒介。雖然和普通硬盤相比,閃存的寫入和擦除仍然相當慢,但與以前相比,其寫入和擦除速度已經有了顯著提高。了解硬盤驅動器和閃存技術的根本區別才能正確地使用相應技術。

閃存的存儲空間被分割成相對較大的可擦除單元,稱為擦除塊(erase block)。閃存的一個顯著特征就是閃存中的數據寫入和擦除的方式。在典型的NOR型[7]閃存芯片中,數據可以在軟件的控制下,使用直接向某個存儲單元地址寫入的簡單方法將其從二進制1改為二進制0。然而,要將數據從0改回1,則要擦除整個擦除塊,在擦除時需要向閃存芯片寫入一串特別的控制指令序列。

典型的NOR型閃存包含多個擦除塊。例如,一個4 MB容量的閃存芯片可能包含64個擦除塊,每塊大小為64 KB。市面上也有擦除塊大小不一致的閃存,以便靈活存放數據。這種閃存常常被稱為引導塊(boot block)或引導扇區(boot sector)閃存。通常,引導加載程序存儲在較小的塊中,內核和其他必要的數據則存放在更大的塊中。圖2-3說明了一個典型的頂部引導(top boot)閃存芯片的塊大小布局。

screenshot

為了修改存儲在閃存陣列中的數據,必須完全擦除待修改數據所在的塊。即使只修改某個塊中的一個字節,都必須擦除並重新寫入整個塊[8]。相比於傳統硬盤的扇區,閃存的塊大小相對較大。相對而言,一個典型的高性能硬盤的可寫扇區大小為512 B或1024 B。結果顯而易見:更新閃存中的數據所耗費的寫入時間會是硬盤驅動器的很多倍,部分原因就是每次更新數據時都有相對大量的數據需要被擦除和寫回。在最壞的情況下,一個寫周期會耗費幾秒鐘的時間。

關於閃存,另一個需要考慮的限制是存儲單元的寫壽命(write lifetime)。NOR型閃存存儲單元的可寫入次數是有限制的,超出次數限制後寫入就會失敗。雖然這個次數的數值比較大(典型的寫入次數限制是每塊100 000次),但不難想象一個設計很差的閃存存儲算法(甚至是一個軟件故障)會迅速毀壞閃存設備。顯然,應該避免配置你的系統日誌輸出到閃存。

2.3.2 NAND型閃存
NAND型閃存使用一種相對較新的閃存技術。當NAND型閃存投放市場時,前一節介紹的傳統閃存就被稱為了NOR型閃存。它們之間的區別與閃存存儲單元的內部架構有關。NAND型閃存設備通過提供更小的塊尺寸改進了傳統(NOR型)閃存的一些限制,它可以更快更有效地進行寫操作,同時大大提高了閃存陣列的使用效率。

NOR型閃存為微處理器提供的接口方式與很多微處理器外圍設備的做法類似。也就是說,它們有並行的數據和地址總線,直接[9]連接到微處理器的數據/地址總線上。閃存陣列的每個字節或字(word)可以隨機尋址。相反,NAND型閃存設備是通過復雜的接口串行訪問的,而且這些接口因廠商而異。NAND型閃存設備的操作模式更類似於傳統的硬盤驅動器加上附帶的控制器。數據是以串行突發(burst)方式訪問的,每次突發訪問的數據量遠遠小於NAND型閃存的塊大小。相比於NOR型閃存,雖然NAND型閃存的寫入時間要少很多,但其寫壽命卻比NOR型閃存高出一個數量級。

總的來說,NOR型閃存可以被微處理器直接訪問,甚至於代碼可以直接在NOR型閃存中執行。(然而,因為性能方面的原因,很少有人這樣做,除非系統資源極其匱乏。)實際上,很多處理器都不能像對待DRAM一樣緩存(cache)訪問閃存的指令。這進一步降低了代碼的執行速度。相反,NAND型閃存更適合於以文件系統的格式大容量存儲數據,而不是撇開文件系統,直接存儲二進制可執行代碼和數據。

2.3.3 閃存的用途
有多種閃存布局和使用方法可供嵌入式系統的設計者選擇。在最簡單的系統中,資源沒有過度受限,可以將原始的二進制數據(可能是壓縮過的)存儲在閃存設備中。系統引導時,存儲在閃存中的文件系統鏡像被讀入Linux內存磁盤(ramdisk)塊設備中。這個塊設備由Linux掛載為一個文件系統,並且只能從內存中訪問。當閃存中的數據幾乎不需要更新時,這種方式通常是很好的選擇。相比於內存磁盤的容量,需要更新的數據量是很少的。但是,當系統重啟或斷電時,對內存磁盤中文件的修改會丟失,務必牢記這一點。

圖2-4說明了一個簡單嵌入式系統中的典型閃存組織結構。在這個系統中,動態數據對非易失性存儲的需求很少且更新不頻繁。

screenshot

引導加載程序通常存放在閃存陣列的頂部或底部。引導加載程序之後的存儲空間被分配給Linux內核和內存磁盤文件系統鏡像[10],這個鏡像中包含了根文件系統。一般來說,Linux內核和ramdisk文件系統鏡像都被壓縮過,並由引導加載程序在系統引導時解壓。

可以在閃存中專門開辟一小塊區域,或者使用其他類型的非易失性存儲設備[11]來存放那些重啟或掉電後仍需保留的動態數據。對於需要保存配置數據的嵌入式系統,這種方式很常見。例如,針對消費者市場的無線接入點設備可能采用這種方式。

2.3.4 閃存文件系統
剛才描述的簡單閃存布局策略有局限性,但可以通過使用閃存文件系統來克服。閃存文件系統以類似於硬盤驅動器組織數據的方式來管理閃存設備中的數據。早期針對閃存設備的文件系統包含簡單的塊設備層,這個塊設備層模擬了普通硬盤驅動器的扇區布局,扇區大小為512 B。這些簡單的模擬層允許以文件格式而不是無格式的大容量存儲方式來訪問數據,但是它們有一些性能上的局限。

對閃存文件系統的一個主要改進就是引入了耗損均衡(wear leveling)算法。如前所述,閃存塊的寫壽命是有限的。耗損均衡算法用來將寫操作均勻分布到閃存的各個物理擦除塊上,以延長閃存芯片的壽命。

閃存架構帶來的另一個限制是系統掉電或意外關機後存在數據丟失的風險。閃存的塊尺寸相對較大,而寫入的文件的平均大小相對於塊尺寸通常小很多。從前面的內容我們知道閃存塊必須一次寫入一整塊。因此,為了寫入一個8 KB的小文件,必須擦除和重寫整個閃存塊,而這個塊的大小可能是64 KB或128 KB;在最壞的情況下,這個寫入會花費幾秒鐘才能完成。這極大增加了系統掉電後丟失數據的風險。

目前比較受歡迎的一種閃存文件系統是JFFS2,或稱為第二代日誌閃存文件系統(Journaling Flash file System 2)。這個文件系統有很多重要特性,旨在提升整體性能、延長閃存壽命並降低系統掉電時數據丟失的風險。最新的JFFS2文件系統的最重要改進包括完善耗損均衡、壓縮和解壓縮(將更多的數據擠進有限的閃存空間),以及對Linux硬連接(hard link)的支持。相關主題將在第9章和第10章詳細講述。在第10章中,我們會討論內存技術設備(Memory Technology Device, MTD)子系統。

2.3.5 內存空間
老式嵌入式操作系統通常將系統內存看做一大塊線性地址空間,並進行管理。也就說,微處理器的地址空間的下限是0,上限是其物理地址範圍的頂部。舉例來說,如果一個微處理器有24條物理地址線,其內存範圍的上限就是16 MB。因此,其地址範圍可以用十六進制表示為從0x00000000到0x00ffffff。硬件設計常常將DRAM放置在這個地址範圍的底部,並將閃存放置在頂部。位於DRAM頂部和閃存底部之間的那些未使用的地址範圍常常被分配給板上的各種外圍設備芯片,用於對它們進行尋址。這種設計方法一般是由所選擇的微處理器決定的。圖2-5顯示了一個簡單嵌入式系統中的典型內存布局。

screenshot

在基於老式操作系統的嵌入式設備中,操作系統和所有的任務[12]具有相同的權限,能夠訪問系統的所有資源。某個進程中的一個故障可能會改寫系統中任意一塊內存的內容,這塊內存可能屬於這個進程本身、操作系統、其他任務,甚至是地址空間中的一個硬件寄存器。雖然這種內存管理方式有個最大的優點:簡單,但它會導致一些很難診斷的故障。

高性能的微處理器中都包含一個復雜的硬件引擎,稱為內存管理單元(Memory Management Unit,MMU)。MMU的作用是使操作系統能夠在很大程度上管理和控制地址空間,包括操作系統自身的地址空間和分配給進程的地址空間。這種控制主要體現為兩種形式:訪問權限控制(access right)和內存地址轉換(memory translation)。訪問權限控制允許操作系統將特定的內存訪問權分配給特定的進程。內存地址轉換允許操作系統將其地址空間虛擬化,從而帶來很多好處。

Linux內核利用這些硬件MMU實現了一個虛擬內存操作系統。虛擬內存所帶來的最大的一個好處是,它可以讓系統的內存看起來比實際的物理內存多,這樣能夠更加有效地利用物理內存。其他的好處是,內核在為任務或進程分配系統內存時,可以指定這塊內存的訪問權限,從而防止某個進程錯誤地訪問屬於另一個進程或內核自身的內存或其他資源。

下一節將更詳細地討論MMU的工作原理。復雜的虛擬內存系統的內容超出了本書的範圍[13]。實際上,我們會從嵌入式系統開發者的角度來考查虛擬內存系統。

2.3.6 執行上下文
系統引導時,Linux最先要完成一項瑣碎工作,即配置處理器中的硬件MMU以及相應的數據結構,並使之能夠進行地址轉換。這一步完成後,內核運行於自己的虛擬內存空間中,這個空間稱為內核空間。在當前的Linux內核版本中,這個虛擬內存空間的起始地址是由內核開發者選擇的,其默認值為0xC0000000[14]。對於大多數硬件架構,這是個可以配置的參數[15]。在內核符號表中,可以看到內核符號的鏈接地址都是以0xC0xxxxxx開頭的。所以,當內核在內核空間中執行代碼時,處理器的指令指針(程序計數器)所包含的值都在這個範圍之內。

Linux中有兩個明顯分隔開的運行上下文,由線程[16]的執行環境所決定。那些完全在內核中執行的線程被認為運行在內核上下文中,而應用程序運行在用戶空間上下文中。用戶空間進程只能訪問它自己擁有的內存,如果它要訪問文件或設備I/O等特權資源,則必須使用內核系統調用。下面舉個例子,讓你更好地理解這一點。

假設一個應用程序打開一個文件並讀取其中的內容,如圖2-6所示。對讀函數的調用是從用戶空間開始的,由應用程序調用C庫中的read()函數。接著,C庫向內核發起一個讀請求。這個讀請求造成一次上下文的切換,從用戶程序切換到內核,以服務這個請求並讀取文件中的數據。在內核中,這個讀請求最終轉變成對硬盤驅動器的訪問,從包含文件內容的扇區中讀取相應數據。

screenshot

通常,這個對硬盤驅動器的讀請求是以異步的形式發往硬件自身的。也就是說,處理器將這個請求發給硬件,並不會等待其完成請求。硬件收到請求後讀取數據,當數據準備好的時候,通過中斷的方式來告知處理器讀請求已經完成了。等待數據的應用程序會阻塞在一個等待隊列中,直到有數據可用。當硬盤準備好數據時,它將向處理器發送一個硬件中斷(這裏只是描述了一個簡化的過程)。當內核接收到這個硬件中斷時,它會掛起正在執行中的任何進程,並從硬盤驅動器中讀取應用程序所等待的數據。

下面對我們的討論做一個概括,我們學習了兩個通用的執行上下文——用戶空間和內核空間。當應用程序執行系統調用,造成上下文的切換而進入內核時,內核會代表這個進程執行內核代碼。你會經常聽到,這種情況稱為內核運行於進程上下文中。相反,處理IDE驅動器的中斷處理程序(ISR)也是內核代碼,但在運行時並不代表任何特定的進程。這種情況通常被稱為內核運行於中斷上下文中。

內核運行於中斷上下文中時會受到一些限制,包括中斷處理程序不能夠阻塞(睡眠)或調用任何可能造成阻塞的內核函數。如果想要更多地了解這些概念,請閱讀本章末尾的參考文獻。

2.3.7 進程虛擬內存
一個進程產生時——例如,當用戶在Linux的命令提示符後面輸入ls的時候——內核就會為這個進程分配內存及相應的虛擬內存地址範圍。這些地址與內核中的地址或其他正在運行的進程的地址沒有固定的關系。此外,這些進程所看到的虛擬地址跟目標板上的物理內存的地址也沒有直接的關系。實際上,由於系統中存在分頁(paging)和交換(swapping)機制,一個進程在其生命周期中常常會占用內存的多個不同的物理地址。

代碼清單2-4是程序員所熟知的“Hello World”程序,這裏做了點修改來說明剛剛討論的一些概念。這個例子的目的是解釋說明內核分配給進程的地址空間。這段代碼編譯後,在一個擁有256 MB DRAM內存的嵌入式系統上運行。

代碼清單2-4嵌入式風格的Hello World

screenshot
screenshot

代碼清單2-5顯示了運行編譯後的程序hello時,控制臺輸出的信息。註意,hello進程認為它的運行地址位於高地址內存的某個地方,剛好超過256 MB的邊界(0x10000418)。還需註意,棧的地址大概處於32位地址空間一半的地方,遠遠超過了內存的大小256 MB(0x7ff8ebb0)。怎麽會這樣呢?在這種系統中,DRAM通常是一塊連續的內存。乍一看,我們幾乎有將近2 GB的DRAM可以使用。這些虛擬地址是由內核分配的,並且有嵌入式目標板上的256 MB的物理內存在背後支持。

代碼清單2-5 Hello的輸出

screenshot

虛擬內存系統的一個特點是當可用的物理內存的數量低於某個指定的閾值時,內核可以將內存頁面交換到大容量存儲媒介中,通常是硬盤驅動器。內核檢查正在使用中的內存區域,並判斷哪些區域最近使用得最少,然後將這些內存區域交換到磁盤中,並釋放這些內存區域給當前進程使用。嵌入式系統的開發者常常會因為性能原因或資源限制而禁用嵌入式系統中的交換功能。多數情況下,使用慢速且寫壽命有限的閃存設備作為交換設備是很不明智的。如果沒有交換設備可用,就必須仔細地設計應用程序,使其能夠運行在有限的物理內存中。

2.3.8 交叉開發環境
開發嵌入式系統應用和設備驅動之前,需要一套工具(編譯器、實用工具等)來生成適合目標系統的二進制可執行文件。考慮一個在桌面PC上編寫的簡單應用,比如傳統的“Hello World”。你在電腦上編寫好代碼後,會使用電腦操作系統自帶的編譯器(通常是GNU的gcc編譯器)來編譯代碼,以生成一個可執行的二進制鏡像文件。這個可執行文件的格式與編譯代碼的電腦兼容,可以在該電腦上運行。這被稱為本地(native)編譯。也就是說,使用本機系統中的編譯器生成可以在本機上運行的程序。

需要註意的是,本地編譯並不意味著我們就能知道用於編譯和運行程序的系統架構。其實,如果你有一個可以在目標板上運行的工具鏈,就可以在目標板上本地編譯生成適合此目標板架構的應用程序。實際上,要對一個新的嵌入式內核和定制單板進行壓力測試,一個好辦法就是在上面反復編譯Linux內核。

在交叉開發環境中開發軟件要求編譯器運行於開發主機上,但生成的二進制可執行文件的格式與開發主機不兼容,不能在上面運行。這類工具存在的主要原因是,在資源(一般指內存大小和CPU性能)受限的嵌入式系統上本地開發和編譯代碼常常是不現實或不可能的。

這種開發方式隱藏著很多陷阱,嵌入式開發的新手稍不留神就會中招。當編譯一個程序時,編譯器一般都知道怎樣找到所需要的頭文件和正確編譯代碼必需的程序庫。為了說明這些概念,我們再看一下“Hello World”。代碼清單2-4中的示例代碼是使用下面的命令行進行編譯的:

screenshot

在代碼清單2-4中,我們看到這個程序代碼包含了一個頭文件stdio.h。這個文件和我們在gcc命令行中指定的文件hello.c不在同一個目錄中。那麽,編譯器是如何找到它的呢?另外,函數printf()也不是在文件hello.c中定義的。因此,編譯hello.c後,它會包含一個對此符號的未解析的引用(unresolved reference)。鏈接器在鏈接時是怎樣解析這個引用的呢?

編譯器使用一些默認的搜索路徑來定位頭文件。在代碼中引用某個頭文件時,編譯器在默認的幾個搜索路徑中查找這個文件。類似地,鏈接器也是以這種方式來解析對外部符號printf()的引用。鏈接器知道默認在C庫(libc-*)中搜索未解析的引用,並且知道在系統中的哪些位置可以找到這些程序庫。再說明一下,這種默認行為是內置於工具鏈中的。

現在假設你為某個采用Power架構的嵌入式系統編寫應用程序。顯然,你需要一個交叉編譯器,用於生成兼容Power架構處理器的二進制可執行文件。如果你使用交叉編譯器,並采用類似的編譯命令來編譯前面的hello.c程序,在解析對外部符號printf()的引用時,鏈接器很可能會意外地將二進制可執行文件鏈接到一個x86版本的C庫。當然,由於生成的可執行程序混合了Power架構和x86二進制指令,如果運行這個錯誤的混合體[17],其結果是可以預見的,那就是系統崩潰!

擺脫這個困境的方法是指引交叉編譯器在非標準路徑中進行查找,以使用針對目標架構的頭文件和程序庫。我們將在第12章中詳細討論這個主題。這個例子旨在說明兩種開發環境的區別,即本地開發環境和嵌入式系統所需的交叉編譯開發環境。這只是交叉開發環境復雜性的一個方面。

延伸阅读

评论