OK01 課程講解了樹莓派如何入門,以及在樹莓派上如何啟用靠近 RCA 和 USB 埠的 OK 或 ACT 的 LED 指示燈。這個指示燈最初是為了指示 OK 狀態的,但它在第二版的樹莓派上被改名為 ACT。
我們假設你已經存取了下載頁面,並且已經獲得了必需的 GNU 工具鏈。也下載了一個稱為作業系統模板的檔案。請下載這個檔案並在一個新目錄中解開它。
現在,你已經展開了這個模板檔案,在 source
目錄中建立一個名為 main.s
的檔案。這個檔案包含了這個作業系統的程式碼。具體來看,這個資料夾的結構應該像下面這樣:
build/ (empty)source/ main.skernel.ldLICENSEMakefile
用文字編輯器開啟 main.s
檔案,這樣我們就可以輸入組合程式碼了。樹莓派使用了稱為 ARMv6 的組合程式碼變體,這就是我們即將要寫的組合程式碼型別。
擴充套件名為
.s
的檔案一般是組合程式碼,需要記住的是,在這裡它是 ARMv6 的組合程式碼。
首先,我們複製下面的這些命令。
.section .init.globl _start_start:
實際上,上面這些指令並沒有在樹莓派上做任何事情,它們是提供給組合器的指令。組合器是一個轉換程式,它將我們能夠理解的組合程式碼轉換成樹莓派能夠理解的機器程式碼。在組合程式碼中,每個行都是一個新的命令。上面的第一行告訴組合器 1 在哪裡放我們的程式碼。我們提供的模板中將它放到一個名為 .init
的節中的原因是,它是輸出的起始點。這很重要,因為我們希望確保我們能夠控制哪個程式碼首先執行。如果不這樣做,首先執行的程式碼將是按字母順序排在前面的程式碼!.section
命令簡單地告訴組合器,哪個節中放置程式碼,從這個點開始,直到下一個 .section
或檔案結束為止。
在組合程式碼中,你可以跳行、在命令前或後放置空格去提升可讀性。
接下來兩行是停止一個警告訊息,它們並不重要。2
現在,我們正式開始寫程式碼。計算機執行組合程式碼時,是簡單地一行一行按順序執行每個指令,除非明確告訴它不這樣做。每個指令都是開始於一個新行。
複製下列指令。
ldr r0,=0x20200000
ldr reg,=val
將數位val
載入到名為reg
的暫存器中。
那是我們的第一個命令。它告訴處理器將數位 0x20200000
儲存到暫存器 r0
中。在這裡我需要去回答兩個問題,暫存器是什麼?0x20200000
是一個什麼樣的數位?
暫存器在處理器中就是一個極小的記憶體塊,它是處理器儲存正在處理的數位的地方。處理器中有很多暫存器,很多都有專門的用途,我們在後面會一一接觸到它們。最重要的有十三個(命名為 r0
、r1
、r2
、…、r9
、r10
、r11
、r12
),它們被稱為通用暫存器,你可以使用它們做任何計算。由於是寫我們的第一行程式碼,我們在範例中使用了 r0
,當然你可以使用它們中的任何一個。只要後面始終如一就沒有問題。
樹莓派上的一個單獨的暫存器能夠儲存任何介於
0
到4,294,967,295
(含)之間的任意整數,它可能看起來像一個很大的記憶體,實際上它僅有 32 個二進位制位元。
0x20200000
確實是一個數位。只不過它是以十六進位制表示的。下面的內容詳細解釋了十六進位制的相關資訊:
延伸閱讀:十六進位制解釋
十六進位制是另一種表示數位的方式。你或許只知道十進位制的數位表示方法,十進位制共有十個數位:
0
、1
、2
、3
、4
、5
、6
、7
、8
和9
。十六進位制共有十六個數位:0
、1
、2
、3
、4
、5
、6
、7
、8
、9
、a
、b
、c
、d
、e
和f
。你可能還記得十進位制是如何用位制來表示的。即最右側的數位是個位,緊接著的左邊一位是十位,再接著的左邊一位是百位,依此類推。也就是說,它的值是 100 × 百位的數位,再加上 10 × 十位的數位,再加上 1 × 個位的數位。
從數學的角度來看,我們可以發現規律,最右側的數位是 100 = 1s,緊接著的左邊一位是 101 = 10s,再接著是 102 = 100s,依此類推。我們設定在系統中,0 是最低位,緊接著是 1,依此類推。但如果我們使用一個不同於 10 的數位為冪底會是什麼樣呢?我們在系統中使用的十六進位制就是這樣的一個數位。
上面的數學等式表明,十進位制的數位 567 等於十六進位制的數位 237。通常我們需要在系統中明確它們,我們使用下標 10 表示它是十進位制數位,用下標 16 表示它是十六進位制數位。由於在組合程式碼中寫上下標的小數位很困難,因此我們使用 0x 來表示它是一個十六進位制的數位,因此 0x237 的意思就是 23716 。
那麼,後面的
a
、b
、c
、d
、e
和f
又是什麼呢?好問題!在十六進位制中為了能夠寫每個數位,我們就需要額外的東西。例如 916 = 9×160 = 910 ,但是 1016 = 1×161 + 1×160 = 1610 。因此,如果我們只使用 0、1、2、3、4、5、6、7、8 和 9,我們就無法寫出 1010 、1110 、1210 、1310 、1410 、1510 。因此我們引入了 6 個新的數位,這樣 a16 = 1010 、b16 = 1110 、c16 = 1210 、d16 = 1310 、e16 = 1410 、f16 = 1510 。所以,我們就有了另一種寫數位的方式。但是我們為什麼要這麼麻煩呢?好問題!由於計算機總是工作在二進位制中,事實證明,十六進位制是非常有用的,因為每個十六進位制數位正好是四個二進位制數位的長度。這種方法還有另外一個好處,那就是許多計算機的數位都是十六進位制的整數倍,而不是十進位制的整數倍。比如,我在上面的組合程式碼中使用的一個數位 2020000016 。如果我們用十進位制來寫,它就是一個不太好記住的數位 53896806410 。
我們可以用下面的簡單方法將十進位制轉換成十六進位制:
- 我們以十進位制數位 567 為例來說明。
- 將十進位制數位 567 除以 16 並計算其餘數。例如 567 ÷ 16 = 35 餘數為 7。
- 在十六進位制中餘數就是答案中的最後一位數位,在我們的例子中它是 7。
- 重複第 2 步和第 3 步,直到除法結果的整數部分為 0。例如 35 ÷ 16 = 2 餘數為 3,因此 3 就是答案中的下一位。2 ÷ 16 = 0 餘數為 2,因此 2 就是答案的接下來一位。
- 一旦除法結果的整數部分為 0 就結束了。答案就是反序的餘數,因此 56710 = 23716。
轉換十六進位制數位為十進位制,也很容易,將數位展開即可,因此 23716 = 2×162 + 3×161 +7 ×160 = 2×256 + 3×16 + 7×1 = 512 + 48 + 7 = 567。
因此,我們所寫的第一個組合命令是將數位 2020000016 載入到暫存器 r0
中。那個命令看起來似乎沒有什麼用,但事實並非如此。在計算機中,有大量的記憶體塊和裝置。為了能夠存取它們,我們給每個記憶體塊和裝置指定了一個地址。就像郵政地址或網站地址一樣,它用於標識我們想去存取的記憶體塊或裝置的位置。計算機中的地址就是一串數位,因此上面的數位 2020000016 就是 GPIO 控制器的地址。這個地址是由製造商的設計所決定的,他們也可以使用其它地址(只要不與其它的衝突即可)。我之所以知道這個地址是 GPIO 控制器的地址是因為我看了它的手冊,3 地址的使用沒有專門的規範(除了它們都是以十六進位制表示的大數以外)。
閱讀了手冊可以得知,我們需要給 GPIO 控制器傳送兩個訊息。我們必須用它的語言告訴它,如果我們這樣做了,它將非常樂意實現我們的意圖,去開啟 OK 的 LED 指示燈。幸運的是,它是一個非常簡單的晶片,為了讓它能夠理解我們要做什麼,只需要給它設定幾個數位即可。
mov r1,#1lsl r1,#18str r1,[r0,#4]
mov reg,#val
將數位val
放到名為reg
的暫存器中。
lsl reg,#val
將暫存器reg
中的二進位制運算元左移val
位。
str reg,[dest,#val]
將暫存器reg
中的數位儲存到地址dest + val
上。
這些命令的作用是在 GPIO 的第 16 號插針上啟用輸出。首先我們在暫存器 r1
中獲取一個必需的值,接著將這個值傳送到 GPIO 控制器。因此,前兩個命令是嘗試取值到暫存器 r1
中,我們可以像前面一樣使用另一個命令 ldr
來實現,但 lsl
命令對我們後面能夠設定任何給定的 GPIO 針比較有用,因此從一個公式中推匯出值要比直接寫入來好一些。表示 OK 的 LED 燈是直接連線到 GPIO 的第 16 號針腳上的,因此我們需要傳送一個命令去啟用第 16 號針腳。
暫存器 r1
中的值是啟用 LED 針所需要的。第一行命令將數位 110 放到 r1
中。在這個操作中 mov
命令要比 ldr
命令快很多,因為它不需要與記憶體互動,而 ldr
命令是將需要的值從記憶體中載入到暫存器中。儘管如此,mov
命令僅能用於載入某些值。4 在 ARM 組合程式碼中,基本上每個指令都使用一個三字母程式碼表示。它們被稱為助記詞,用於表示操作的用途。mov
是 “move” 的簡寫,而 ldr
是 “load register” 的簡寫。mov
是將第二個引數 #1
移動到前面的 r1
暫存器中。一般情況下,#
肯定是表示一個數位,但我們已經看到了不符合這種情況的一個反例。
第二個指令是 lsl
(邏輯左移)。它的意思是將第一個引數的二進位制運算元向左移第二個引數所表示的位數。在這個案例中,將 110 (即 12 )向左移 18 位(將它變成 10000000000000000002=26214410 )。
如果你不熟悉二進位制表示法,可以看下面的內容:
延伸閱讀: 二進位制解釋
與十六進位制一樣,二進位制是寫數位的另一種方法。在二進位制中只有兩個數位,即
0
和1
。它在計算機中非常有用,因為我們可以用電路來實現它,即電流能夠通過電路表示為1
,而電流不能通過電路表示為0
。這就是計算機能夠完成真實工作和做數學運算的原理。儘管二進位制只有兩個數位,但它卻能夠表示任何一個數位,只是寫起來有點長而已。這個圖片展示了 56710 的二進位制表示是 10001101112 。我們使用下標 2 來表示這個數位是用二進位制寫的。
我們在組合程式碼中大量使用二進位制的其中一個巧合之處是,數位可以很容易地被
2
的冪(即1
、2
、4
、8
、16
)乘或除。通常乘法和除法都是非常難的,而在某些特殊情況下卻變得非常容易,所以二進位制非常重要。將一個二進位制數位左移
n
位就相當於將這個數位乘以 2n。因此,如果我們想將一個數乘以 4,我們只需要將這個數位左移 2 位。如果我們想將它乘以 256,我們只需要將它左移 8 位。如果我們想將一個數乘以 12 這樣的數位,我們可以有一個替代做法,就是先將這個數乘以 8,然後再將那個數乘以 4,最後將兩次相乘的結果相加即可得到最終結果(N × 12 = N × (8 + 4) = N × 8 + N × 4)。右移一個二進位制數
n
位就相當於這個數除以 2n 。在右移操作中,除法的餘數位將被丟棄。不幸的是,如果對一個不能被 2 的冪次方除盡的二進位制數位做除法是非常難的,這將在 課程 9 Screen04 中講到。這個圖展示了二進位制常用的術語。一個位元就是一個單獨的二進位制位。一個“半位元組“ 是 4 個二進位制位。一個位元組是 2 個半位元組,也就是 8 個位元。半字是指一個字長度的一半,這裡是 2 個位元組。字是指處理器上暫存器的大小,因此,樹莓派的字長是 4 位元組。按慣例,將一個字最高有效位標識為 31,而將最低有效位標識為 0。頂部或最高位表示最高有效位,而底部或最低位表示最低有效位。一個 kilobyte(KB)就是 1000 位元組,一個 megabyte 就是 1000 KB。這樣表示會導致一些困惑,到底應該是 1000 還是 1024(二進位制中的整數)。鑑於這種情況,新的國際標準規定,一個 KB 等於 1000 位元組,而一個 Kibibyte(KiB)是 1024 位元組。一個 Kb 是 1000 位元,而一個 Kib 是 1024 位元。
樹莓派預設採用小端法,也就是說,從你剛才寫的地址上載入一個位元組時,是從一個字的低位位元組開始載入的。
再強調一次,我們只有去閱讀手冊才能知道我們所需要的值。手冊上說,GPIO 控制器中有一個 24 位元組的集合,由它來決定 GPIO 針腳的設定。第一個 4 位元組與前 10 個 GPIO 針腳有關,第二個 4 位元組與接下來的 10 個針腳有關,依此類推。總共有 54 個 GPIO 針腳,因此,我們需要 6 個 4 位元組的一個集合,總共是 24 個位元組。在每個 4 位元組中,每 3 個位元與一個特定的 GPIO 針腳有關。我們想去啟用的是第 16 號 GPIO 針腳,因此我們需要去設定第二組 4 位元組,因為第二組的 4 位元組用於處理 GPIO 針腳的第 10-19 號,而我們需要第 6 組 3 位元,它在上面的程式碼中的編號是 18(6×3)。
最後的 str
(“store register”)命令去儲存第一個引數中的值,將暫存器 r1
中的值儲存到後面的表示式計算出來的地址上。這個表示式可以是一個暫存器,在上面的例子中是 r0
,我們知道 r0
中儲存了 GPIO 控制器的地址,而另一個值是加到它上面的,在這個例子中是 #4
。它的意思是將 GPIO 控制器地址加上 4
得到一個新的地址,並將暫存器 r1
中的值寫到那個地址上。那個地址就是我們前面提到的第二組 4 位元組的位置,因此,我們傳送我們的第一個訊息到 GPIO 控制器上,告訴它準備啟用 GPIO 第 16 號針腳的輸出。
現在,LED 已經做好了開啟準備,我們還需要實際去開啟它。意味著需要給 GPIO 控制器傳送一個訊息去關閉 16 號針腳。是的,你沒有看錯,就是要傳送一個關閉的訊息。晶片製造商認為,在 GPIO 針腳關閉時開啟 LED 更有意義。5 硬體工程師經常做這種反常理的決策,似乎是為了讓作業系統開發者保持警覺。可以認為是給自己的一個警告。
mov r1,#1lsl r1,#16str r1,[r0,#40]
希望你能夠認識上面全部的命令,先不要管它的值。第一個命令和前面一樣,是將值 1
推入到暫存器 r1
中。第二個命令是將二進位制的 1
左移 16 位。由於我們是希望關閉 GPIO 的 16 號針腳,我們需要在下一個訊息中將第 16 位元設定為 1(想設定其它針腳只需要改變相應的位元位即可)。最後,我們寫這個值到 GPIO 控制器地址加上 4010 的地址上,這將使那個針腳關閉(加上 28 將開啟針腳)。
似乎我們現在就可以結束了,但不幸的是,處理器並不知道我們做了什麼。事實上,處理器只要通電,它就永不停止地運轉。因此,我們需要給它一個任務,讓它一直運轉下去,否則,樹莓派將進入休眠(本範例中不會,LED 燈會一直亮著)。
loop$:b loop$
name:
下一行的名字。
b label
下一行將去標籤label
處執行。
第一行不是一個命令,而是一個標籤。它給下一行命名為 loop$
,這意味著我們能夠通過名字來指向到該行。這就稱為一個標籤。當程式碼被轉換成二進位制後,標籤將被丟棄,但這對我們通過名字而不是數位(地址)找到行比較有用。按慣例,我們使用一個 ?$
表示這個標籤只對這個程式碼塊中的程式碼起作用,讓其它人知道,它不對整個程式起作用。b
(“branch”)命令將去執行指定的標籤中的命令,而不是去執行它後面的下一個命令。因此,下一行將再次去執行這個 b
命令,這將導致永遠迴圈下去。因此處理器將進入一個無限迴圈中,直到它安全關閉為止。
程式碼塊結尾的一個空行是有意這樣寫的。GNU 工具鏈要求所有的組合程式碼檔案都是以空行結束的,因此,這就可以你確實是要結束了,並且檔案沒有被截斷。如果你不這樣處理,在組合器執行時,你將收到煩人的警告。
由於我們已經寫完了程式碼,現在,我們可以將它上傳到樹莓派中了。在你的計算機上開啟一個終端,改變當前工作目錄為 source
資料夾的父級目錄。輸入 make
然後回車。如果報錯,請參考排錯章節。如果沒有報錯,你將生成三個檔案。 kernel.img
是你的編譯後的作業系統映象。kernel.list
是你寫的組合程式碼的一個清單,它實際上是生成的。這在將來檢查程式是否正確時非常有用。kernel.map
檔案包含所有標籤結束位置的一個對映,這對於跟蹤值非常有用。
為安裝你的作業系統,需要先有一個已經安裝了樹莓派作業系統的 SD 卡。如果你瀏覽 SD 卡中的檔案,你應該能看到一個名為 kernel.img
的檔案。將這個檔案重新命名為其它名字,比如 kernel_linux.img
。然後,複製你編譯的 kernel.img
檔案到 SD 卡中原來的位置,這將用你的作業系統映象檔案替換現在的樹莓派作業系統映象。想切換回來時,只需要簡單地刪除你自己的 kernel.img
檔案,然後將前面重新命名的檔案改回 kernel.img
即可。我發現,保留一個原始的樹莓派作業系統的備份是非常有用的,萬一你要用到它呢。
將這個 SD 卡插入到樹莓派,並開啟它的電源。這個 OK 的 LED 燈將亮起來。如果不是這樣,請檢視故障排除頁面。如果一切如願,恭喜你,你已經寫出了你的第一個作業系統。課程 2 OK02 將指導你讓 LED 燈閃爍和關閉閃爍。
是的,我說錯了,它告訴的是連結器,它是另一個程式,用於將組合器轉換過的幾個程式碼檔案連結到一起。直接說是組合器也沒有大問題。 ?
其實它們對你很重要。由於 GNU 工具鏈主要用於開發作業系統,它要求入口點必須是名為 _start
的地方。由於我們是開發一個作業系統,無論什麼時候,它總是從 _start
開時的,而我們可以使用 .section .init
命令去設定它。因此,如果我們沒有告訴它入口點在哪裡,就會使工具鏈困惑而產生警告訊息。所以,我們先定義一個名為 _start
的符號,它是所有人可見的(全域性的),緊接著在下一行生成符號 _start
的地址。我們很快就講到這個地址了。 ?
本教學的設計減少了你閱讀樹莓派開發手冊的難度,但是,如果你必須要閱讀它,你可以在這裡 SoC-Peripherals.pdf 找到它。由於新增了混淆,手冊中 GPIO 使用了不同的地址系統。我們的作業系統中的地址 0x20200000 對應到手冊中是 0x7E200000。 ?
mov
能夠載入的值只有前 8 位是 1
的二進位制表示的值。換句話說就是一個 0 後面緊跟著 8 個 1
或 0
。 ?
一個很友好的硬體工程師是這樣向我解釋這個問題的: ?
原因是現在的晶片都是用一種稱為 CMOS 的技術來製成的,它是互補金氧半導體的簡稱。互補的意思是每個信號都連線到兩個電晶體上,一個是使用 N 型半導體的材料製成,它用於將電壓拉低,而另一個使用 P 型半導體材料製成,它用於將電壓升高。在任何時刻,僅有一個半導體是開啟的,否則將會短路。P 型材料的導電效能不如 N 型材料。這意味著三倍大的 P 型半導體材料才能提供與 N 型半導體材料相同的電流。這就是為什麼 LED 總是通過降低為低電壓來開啟它,因為 N 型半導體拉低電壓比 P 型半導體拉高電壓的效能更強。
還有一個原因。早在上世紀七十年代,晶片完全是由 N 型材料製成的(NMOS),P 型材料部分使用了一個電阻來代替。這意味著當信號為低電壓時,即便它什麼事都沒有做,晶片仍然在消耗能量(並行熱)。你的電話裝在口袋裡什麼事都不做,它仍然會發熱並消耗你的電池電量,這不是好的設計。因此,信號設計成 “活動時低”,而不活動時為高電壓,這樣就不會消耗能源了。雖然我們現在已經不使用 NMOS 了,但由於 N 型材料的低電壓信號比 P 型材料的高電壓信號要快,所以仍然使用了這種設計。通常在一個 “活動時低” 信號名字上方會有一個條型標記,或者寫作 SIGNAL_n
或 /SIGNAL
。但是即便這樣,仍然很讓人困惑,那怕是硬體工程師,也不可避免這種困惑!