Arduino前處理器指令課程

arduino preprocessor

適用於ROHM感測器評估套件的羽量級Arduino程式館中,我介紹了RohmMultiSensor——幫您輕鬆連接ROHM感測器評估套件多個感測器的Arduino程式館。該程式館的核心特長之一就是透過僅編譯與所需感測器相關的程式館部分,顯著減小程式的大小。這意味著當您使用較少的感測器時,整體程式大小和記憶體使用量會減小。但是,這究竟是如何實現的呢?當您#include一個程式館然後按下“Upload”(上傳)按鈕之後,幕後究竟會發生什麼?

硬體

  • Arduino UNO

軟體

  • Arduino IDE

幾乎所有用過Arduino的人都使用過程式館。這就是Arduino程式設計對初學者來說如此簡單的原因之一——您無需深入瞭解感測器的工作原理;程式館會替您完成大部分工作。將代碼分成單獨的檔也是一種很好的程式設計習慣。組織、調試和維護單個檔要比處理一大堆代碼容易得多。

想必Arduino初學者都已經熟悉了將程式館添加到主程序中的#include命令。要瞭解這是如何實現的,我們首先應快速瞭解C/C++原始程式碼如何編譯成程式。別擔心,這聽起來比較複雜,其實很簡單。我們來看一下編譯的工作原理。

按“上傳”之後

我們先做一個快速實驗:啟動Arduino IDE,打開其中一個範例代碼(比如“Blink”),然後按“Verify”按鈕。假設程式中沒有語法錯誤,底部的控制台應該會列印出有關程式大小和記憶體的一些資訊。嗯,剛才我們成功地將C++原始程式碼編譯成了二進位檔案。在編譯過程中發生了以下幾件事:

  1. Arduino IDE執行了一種名為“語法檢查”的操作,以確保您編寫的程式是真正的C/C++原始程式碼。此時,如果發生函數拼寫錯誤或忘記分號,那麼編譯就會停止。
  2. 語法檢查之後,Arduino IDE會啟動另一個名為 preprocessor(前處理器)的程式。這是一個非常簡單的程式,如果檔是C/C++原始程式碼,它不會怎麼樣。我們稍後會詳細討論這一步驟。那麼現在我們假設結果是一個名為“擴展原始程式碼”的檔——一個文字檔。
  3. 然後,該擴展原始程式碼被移交給另一個名為 compiler(編譯器)的程式。該編譯器(在Arduino IDE中是avr-gcc)接收文本源,並生成彙編文件。彙編一種人類可讀的低級程式設計語言,但是更接近機器代碼——適用於特定處理器的指令。這裡就是您編寫程式之前必須選擇正確Arduino板的原因——不同的研發板具有不同的處理器,而處理器又具有不同的指令集。
  4. 處理您Arduino程式下一個的系統程式叫做 assembler(組合語言程式)。該程式會生成一個“目的檔案”。該檔主要是機器代碼,但也可以包含針對其他目的檔案物件的“引用”。這允許Arduino IDE“預編譯”一些編寫Arduino程式時會始終用到的程式館,從而使整個過程更快。
  5. 最後一個階段稱為連結,由另一個名為 linker(連結器,顯而易見)的程式完成。連結器獲取目的檔案並添加缺少的內容——主要是來自其他目的檔案的符號,以生產可執行檔。在此之後,程式完全轉換為機器代碼,並可以上傳到電路板。

arduino preprocessor

現在,我們對Arduino程式編譯有了一個基本的瞭解。但是在上述所有編譯階段中,我們將只關注第二個階段:前處理器。

 

前處理器基本知識

在上本中,我提到前處理器本質上非常簡單:接收文本輸入,搜索關鍵字,根據找到的內容進行一些操作,然後輸出不同的文本。它非常簡單,同時也非常強大,因為它允許你用普通C/C++語言完成一些本來會非常複雜的事情(如果可能)。

前處理器會搜索以井號(#)開頭且後面有文本的行。這種語句叫做前處理器指令,是前處理器的一種“命令”。前處理器指令的完整清單以及詳細文件的位址如下所示:

https://gcc.gnu.org/onlinedocs/cpp/Index-of-Directives.html#Index-of-Directives.

接下來,我將主要關注#include#define和條件指令,因為這是Arduino最有用的指令。如果您想瞭解一些更“奇異”的指令,比如#assert#pragma, 請參閱上述位址,以獲取官方資訊。

 

添加額外代碼:#include 指令

這可能是最著名的前處理器指令,不僅Arduino愛好者都知道,而且C/C++程式設計人員也都瞭解。原因很簡單:該指令的作用是包含程式館。但是,這究竟是如何實現的呢?確切的語法如下所示:

兩者的區別比較小,主要在於前處理器搜索file(檔)的確切位置。如果是第一句,前處理器僅搜索IDE指定的目錄。如果是第二句,前處理器首先查看包含原始檔案的資料夾,且僅當沒有在該目錄下找到file(檔)時, 它才會搜索第一句的搜索目錄。由於包含程式館的資料夾是在Arduino IDE中指定的,因此在包含程式館時兩者之間沒有重大區別。

當前處理器找到檔時,它只是將其內容複製粘貼到原始程式碼中,以替代程式中的#include指令。但是,如果在任何目錄中都找不到此檔,就會引發致命錯誤,編譯停止。

要記住,前處理器只處理文本——無法理解那些特殊字母和數位的含義。最重要的是,它對所包含的內容和包含次數絕對不會進行更高級別的檢查。讓我們來看一下使用編寫不正確的程式館會發生什麼。

這個Arduino程式中沒有多少內容。請注意我們包含了一個名為“ExampleLibrary.h”的檔,而且我們包含了兩次。

“ExampleLibrary.h”的內容如下所示。同樣,除了一個整數變數之外,沒有多少內容。那麼當我們編譯這個Arduino程式時會發生什麼呢?

arduino preprocessor

錯誤資訊顯示變數a聲明了兩次,這導致編譯失敗。這是前處理器完成後原始程式碼的樣子。

顯而易見,不應該多次包含程式館,但是如何在不依賴使用者的情況下實現這一目標?標準解決方案是將整個程式館包含在以下結構中:

現在,第一次包含程式館時,前處理器會檢查是否存在用“_EXAMPLE_LIBRARY_H”定義的內容。由於沒有類似的東西存在,前處理器繼續下一行並定義一個名為“_EXAMPLE_LIBRARY_H”的常量。然後,程式館代碼被複製到程式中。

當第二次包含程式館時,前處理器會再次檢查是否存在名為“_EXAMPLE_LIBRARY_H”的常量。這次,由於上一個#include命令已經定義了該常量,所以前處理器不會向程式中添加任何內容。於是,編譯成功完成。#ifdef#endif是條件指令,我們稍後將對此進行討論。

 

定義事物:#define 指令

在上一個例子中,我們用#define指令創建了一個常量,以決定是否包含一個程式館。在官方文件中,任何由#define指令定義的東西都被稱為macro(宏), 因此本文中我會一直沿用這個術語。該指令的語法如下:

 

大多數Arduino初學者可能會對宏感到困惑。如果我定義一個宏:

 

那麼這與以下變數聲明有什麼區別呢?

 

同樣,這一切都歸結為前處理器僅處理文本。遇到#define指令時,前處理器會搜索其餘的原始程式碼並將所有出現的“X”替換為“10”。這意味著與變數不同,巨集的值永遠不會改變。此外,您必須牢記前處理器只搜索以#define開頭的原始程式碼。讓我們看一下使用尚未定義的巨集會發生什麼情況。

 

編譯上述代碼會發生以下錯誤:

arduino preprocessor

預處理後的代碼如下所示:

 

第一行包含 X,它被看作一個變數。但是,該變數從未聲明,因此編譯停止。

儘管#define指令最常見的用途是創建帶名稱的常量,但是它可以做的遠不止這些。例如,假設您想知道兩個給定數位中哪一個較小。您可以編寫一個實現此功能的函數。

 

或者使用更簡單的三元運算子:

 

但是,這兩個函數都將被編譯並佔用寶貴的程式存儲空間。我們可以使用以下類似函數的巨集來實現相同效果,但是佔用的程式空間卻會變少。

 

現在,每個“MIN(A, B)”都會被替換為“(((A) < (B)) ? (A) : (B))”,其“A”和“B”可以是數位,也可以是變數。請注意,#define包含在相同的保護性結構中,以防止使用者重複定義宏。

創建巨集時,您必須記住,系統將巨集作為文本進行處理。這就是為什麼在上面的定義中,幾乎所有內容都包含在括弧中。請猜測以下運算的結果。

 

結果應該是6,因為2–0=2,然後2×3=6,對吧?如果我告訴你結果是2呢?實際編譯的內容如下:

 

由於乘法優先於減法,因此很明顯結果肯定是2,因為3×0=0,然後2-0=2。正確的版本如下所示:

 

 

條件編譯:#if 指令

在前面的例子中,我使用了#ifndef指令,於是我可以檢查是否已經包含了程式館。該指令可用於實現僅用C/C++語言不可能實現的內容:條件陳述式。這些指令的語法如下所示:

 

條件陳述式的常用功能是檢查一個宏是否已定義。為此,您可以使用幾個專門的指令:

 

我們已經熟悉了上述內容,因為我們之前使用此指令來檢查是否已包含程式館。您也可以使用這個條件:

 

以上語句只是#if defined的簡寫,可根據單個條件測試多個巨集。請注意,每個條件都必須用#endif 指令結束,從而指定代碼的哪些部分受條件影響,哪些部分不受條件影響。

我們來看一個實際的例子。假設您編寫了一個程式館,並且希望它在Arduino UNO和Arduino Mega上都能正常工作。這主意不錯,對吧?可攜式代碼總比為另一塊電路板修改程式館更容易。但是,如果您的程式館使用了SPI總線呢?該匯流排在Arduino UNO上用的是11-13引腳,但是在Mega上卻是50-52引腳。

那麼您如何告訴編譯器根據不同研發板使用相應的引腳呢?您猜對了——條件語法!根據您在Arduino IDE中選擇(“Tools”>“Board”功能表)的研發板,IDE將定義不同的宏,從而僅編譯與所選研發板相關的代碼部分!這非常強大,因為您可以實現以下功能:

 

怎麼樣,漂亮吧?僅用三行代碼,我們就製作了一個多平臺可攜式程式館!另外,這正是RohmMultiSensor程式館(適用於ROHM感測器評估套件的羽量級Arduino程式館)如何知道應該為所選感測器編譯哪些代碼。如果您看一下頭檔RohmMultiSensor.h裡面的內容,您只會看到幾個#ifdef和幾個#include指令。由於所有特定感測器代碼都存儲在單獨的.cpp檔中,因此將新感測器添加到程式館中很容易——只需創建另一個檔,然後創建與其他感測器相同的#ifdef – #include – #endif結構即可。完成!

 

提供回饋:#warning#error 指令

我們最後要介紹的指令是#warning#error。兩者但是不言自明,語法如下:

 

 

前處理器遇到這些指令時,它會將message列印到Arduino IDE控制台中。兩者之間的區別在於,發生#warning之後,編譯正常進行,而#error則會完全停止編譯。

我們可以在前文的例子中使用這兩個語句:

 

這樣,當用戶嘗試為其他Arduino研發板(比如Yún、LilyPad等)編譯該程式館時,編譯會失敗,與沒有定義SPI引腳沒有任何關係。

 

結論

在本文中,我們介紹了C/C++前處理器的相關知識。希望您看過本文之後,就不會再害怕編譯前處理器指令等術語了。我總結一下本文描述的最重要的幾點內容:

  1. 編寫程式館時,請務必將其放在#ifndef – #define – #endif結構中。這個結構我們已經見過多次了。這可能會為您省去一些麻煩。定義類似函數的巨集時同樣應該這樣做。
  2. 編寫代碼時,應確保程式易於移植到其他Arduino板上。相信我,未雨綢繆要比出現不相容問題之後再想法解決要容易得多。
  3. 分而治之!幾個較小的檔總比一個1000多行的大檔要好得多。