半熟前端

軟體工程師 / 台灣人 / 在前端的路上一邊探索其他領域的可能性

IoT

用 Arduino 與 ESP32 打造空氣品質監測應用(3)- Arduino 踩雷篇

本篇文章為系列文第三篇:

  1. 感測器介紹篇 - DHT11 與 MH-Z14A
  2. 資料溝通篇 - UART
  3. Arduino 踩雷篇
  4. (未上映)WiFi 篇:為了省下 Debug 的時間,我額外購買了 ESP32 開發版,本身已經含有 WiFi 跟藍芽功能
  5. (未上映)MQTT 篇:為了把資料傳給其他設備,使用了 MQTT 這個輕薄短小的通訊協定
  6. (未上映)Grafana / Web 篇:資料存在資料庫當然要用炫炮的方式顯示出來啊!這裡使用了 Grafana + Promethus 以及 Svelte 來顯示資料。

這篇會聊聊在 Arduino 以及語言上不熟悉而踩到的一些地雷。

CPU 讀取資料順序不同(Big Endian 與 Little Endian)

因為 CPU 指令集架構不同,所以讀資料的順序也會不一樣,中文稱作位元組順序(Endianness)。從高位元組開始讀取的話叫做 Big-Endian,從低位元組開始讀取的話則叫做 Little Endian,透過下面的圖解釋應該會更清楚:

big-endian and little-endian difference

Arduino 是以 Little Endian 作為讀取順序,在這裡可以找到解答。除了上面提到的記憶體之外,只要是讀取資料的順序不同,都可以用 Big Endian 跟 Little Endian 來描述。

記憶體由於方便管理的關係,OS 會將記憶體的空間切成固定的大小,所以如果儲存連續資料時空間不夠的話就會將資料放到下個空間裡,要取用值的時候再把它們加起來。

之所以會遇到這個問題,是因為當初耍小聰明直接將 ppm 設為一個 unsigned short 指標,這樣一來應該就會自動幫我們計算出正確的數字才對,不用在自己手動寫 bit shift,範例像下面這樣子:

struct Co2Result {
  byte startByte;
  byte command;
  byte high;
  byte low;
  unsigned short ppm;
};
Co2Result *result = malloc(sizeof(Co2Result));
result->startByte = response[0];
result->command = response[1];
result->high = response[2];
result->low = response[3];
result->ppm = &result->high;

因為 short 的大小為 2byte,所以將指標指向 result->high 的話向後讀取 2byte,效果應該等同於 result->high<<8 + result->low 才對。

short 指標測試結果

測試後發現結果會是一個非常奇怪的數字,我想是因為位元讀取順序的不同而導致的。乖乖用 bit shift 之後就可以拿到正確的數字了:

result->ppm = (result->high << 8) + result->low;

C 的資料結構會依平台不同

舉例來說,像是 int 就可能會是 2 bytes 或 4 bytes,一般的 long 有可能是 8 bytes 也有可能是 4 bytes。在像是 Java 等語言中不會出現資料型別在其他平台上有不同的結果,因為資料型別的定義是來自於程式語言實作本身。所以在 C/C++ 當中會出現像 uint8_t 這種型別,讓平台可根據需要定義不同大小的資料型別。

雖然對習慣寫 C 的人來說應該是家常便飯,不過在求算資料結構的大小時,應該使用 sizeof 來計算而不應該假設每個平台當中的資料型別大小都是固定的。

善用 byte 資料型別

在 Arduino 當中提供了一個資料型別叫做 byte。背後的定義跟 unsigned char 一樣,只是時常搞不懂 uint8_t char int 的差別,在這邊一次釐清。

  1. byteunsigned char 是一樣的,背後都是指派 8bit 的記憶體給變數。唯一的差別只是 unsigned char 的含意給使用者的感覺比較廣泛,而 byte 更清楚表達了這個數字是在 0 ~ 255 之間(unsigned), arduino 推薦使用 byte 作為 consistency。
  2. uint8_tbyte 還有 unsigned char 其實都是差不多的概念,有點像 alias。在 C/C++ 當中,不同平台可能會將 int 定義為 8bit 或 16bit,所以通常會定義 uint8_t 來表達更精準的含意。

善用標頭檔拆分函式

在做 Arduino 專案的時候程式碼的架構相對小很多,把全部的功能塞在 loopsetup 當中也不會讓程式碼太難讀,不過一旦電路變得更複雜一些,把全部實作塞在同一個檔案就有點雜亂了。

雖然 Arduino IDE 的副檔名叫做 .ino,本質上背後還是 C++,因此支援 C++ 的語法,只是沒辦法使用標準函式庫。要撰寫模組,並且使用 Arduino 的函式的話,只要加入 Arduino.h 就可以了。

#include "Arduino.h"
#include <string.h>

另外如果是用 vscode 撰寫的話,會發現 Arduino 的內建函式庫沒辦法被 include,直接引入的話雖然編譯會過,不過編輯器上會出現紅線,這時候可以加入 .vscode/c_cpp_properties.json 讓 vscode 可以找到正確的標頭檔。 如下所示:(macOS 版本為例,Linux 或 Windows 可能要另外找一下 Arduino 的安裝位置在哪裡)

{
  "configurations": [
    {
      "name": "Mac",
      "includePath": [
        "${workspaceFolder}/**",
        "/Applications/Arduino.app/Contents/Java/hardware/**",
        "/Applications/Arduino.app/Contents/Java/hardware/arduino/**"
      ],
      "defines": [],
      "macFrameworkPath": [
        "/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/System/Library/Frameworks"
      ],
      "compilerPath": "/usr/bin/clang",
      "cStandard": "c11",
      "cppStandard": "c++17",
      "intelliSenseMode": "clang-x64"
    }
  ],
  "version": 4
}

其他

  • noInterrupet()cli() 是一樣的。在 Arduino.h 當中是這樣定義的
#define interrupts() sei()
#define noInterrupts() cli()
  • Arduino 直接用 std:string 會有問題,std 在 Arduino 上避免使用會比較好,所有那些美好的 C++ 功能在 Arduino 上統統用不了。可以參考論壇中的內文,社群上似乎有人嘗試著讓 std library 也可以在 Arduino 上運作。不知道進度如何。

這一趟實驗結果下來,我發現透過 Arduino 來學習各種比較底層的知識是個不錯的方法,既有內建的 Serial Port 可供 debug,USB 接起來 IDE 安裝一下馬上就可以上傳程式碼省掉各種麻煩,同時背後的實作也可以在 Github 上找到程式碼。同時也讓我想要找時間補足一下對於 C/C++ 的知識了(只有在大學課程時有碰過)

另外本篇文章由於對 Arduino 的不熟悉,雖然已經盡力找資料完善論述,但難免會有不完整或是出錯的地方,還請大家不吝指教。