半熟前端

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

IoT

用 Arduino 與 ESP32 打造空氣品質監測應用(2)- 資料溝通篇 UART

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

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

人跟人之間需要溝通,硬體與硬體之間當然也需要溝通。

在網路當中,最常見的應該就是 TCP 通訊協定了。為了保證資料傳輸的正確性,我們需要一連串的機制來確保資料無誤,並且雙方可接收訊息。在硬體當中也需要做類似的事情。

常見的硬體溝通協定有三種:

  • UART(Universal Asynchronous Receiver/Transmitter)
  • SPI
  • I2C

本篇文章只會介紹 UART。

傳遞資料

假設我們要傳送 10010 給另外一個硬體設備好了,一個簡單的方式是一次接上 5 條線,然後同時傳送 1 0 0 1 0,如下圖所示:

同步通訊

雖然看起來很直覺,不過這樣一來,如果有 16bit 就要 16 個腳位,如果要 32bit 就要 32 個腳位。在線路上,如果可以我們當然會希望接腳少一點,就跟大家都愛無線是類似的道理,因為相對簡單、製程也會變得比較容易。

那麼有沒有辦法可以減少腳位的使用?假設雙方協議好每隔 100ms 會送一個 bit 過來,這樣 1600ms 過後就可以收到 16bit 了,如下圖所示:

Serial

的確,這個方法有效解決了腳位過多的問題,不過也讓原本同步的資料傳送變成「非同步」,相對地會犧牲一些速度(總共要 1600ms 才能送完 16bit)。在硬體上,這種一個接一個的傳遞資料方式叫做「Serial」;而同步的資料傳遞叫做「Parallel」。

今天要介紹的 UART 就屬於一種 Serial Communication。

回到上圖,如果只有一條線並且只有約定好每隔多久接收一次資料似乎還不夠,因為在這個協定下我們不知道資料何時送來,何時結束因此我們也需要一個信號,讓雙方可以知道何時開始與結束傳遞資料。

UART 資料交換

UART 中定義了 start bit 以及 stop bit 讓雙方知道何時會開始送資料、何時停止。

UART - data packet

在閒置(IDLE)的狀態下會維持在高電位,而要開始傳送資料時則會轉為低電位當作 start bit,再開始傳送資料。每個資料幀(dataframe)為 8 ~ 9bit,最後一個 bit 為可選的奇偶校驗位。資料傳遞完之後拉到高電位表示 stop bit。

不像其他的通訊協定,UART 並沒有 clock 信號可供參考,所以雙方需要事先知道彼此的 baud rate,才知道雙方是以多快的速度傳送資料。為什麼這個數字那麼重要呢?可以參考下圖:

UART baud rate

紅色部分為正確的 baud rate,可以看到資料被正確讀取,但如果將 baud rate 提升到兩倍,則會出現同一個 bit 但卻重複讀取兩次的情形(咖啡色部分)。

要使用 UART 必須符合下列幾個條件:

  • 兩個硬體設備需要共用同一個 GND
  • Baud rate 必須相同

Arduino 中的 UART

在 Arduino 當中,內建都有一個 UART 的 Serial Port,通常會在 0, 1 這兩個接腳,標示為 tx 與 rx。tx 表示 transmit,傳送資料給另一個設備;rx 表示 read,從另一設備的 tx 讀取資料。RX 接腳會接到另一設備的 TX 接腳;而 TX 接腳會接到另一設備的 RX 接腳。

UART 是由獨立的 IC 提供或是寫在 MCU 裡頭的電路,不過 UART chip 在 Arduino 只有一個,如果有超過一個以上的設備也想要跟 Arduino 作 UART 通訊呢?或是想要將原生的 Serial Port 當作 Debug Console 方便除錯,不想給其他設備使用的話該怎麼辦?以我們的情境來說,我們想要透過 UART 跟 MH-Z14A 溝通;同時也想要透過 UART 傳送資料給 ESP32。

這時候就要派上 Arduino 內建的 Library — SoftwareSerial 了,Software Serial 可以讓 Arduino 的 digital pin 直接當作 UART 使用。

看到 Software 可以得知,這是利用程式來模擬 UART 的通訊,有點像是影片中常見的硬體編碼與軟體編碼的感覺。

我們這次使用的 MH-Z14A Co2 感測器當中,UART 的 baud rate 為 9600。原本我們打算自己實作 SoftwareSerial,也就是自幹一個符合 UART 的通訊,不過後來發現一些問題分享給大家:

  • 根據 datasheet 描述,這個 MH-Z14A 的感測器的傳輸率是 9600Hz。代表每個 bit 的傳送時間是 1/9600 = 1041.6666666us。首先 Arduino 的 delay 最多只能到 delayMicroSecond,所以後面的小數點必須捨棄(因為浮點數的關係,傳入 delay 的參數一定為整數),但這樣的誤差其實已經足以讓資料讀取不正確了。
  • CPU 的 instruction cycle 也要花時間,這個時間雖然短,但在 UART 的通訊過程中每個 bit 累積起來的誤差也不可忽略
  • 我們猜測單純使用 delay 的話,可能會被 Arduino 其他的中斷干擾導致時間不準確

下圖清楚描述了用 delay 這個函數會遇到的問題:

UART baud rate - 2

理想上 delay 函數應該要相當精準,但實際上 CPU 在運作的時候可能會因為 compile、中斷等原因而有咖啡色色塊當中的偏移。要嘛我們要瞎猜 CPU 耗費的時間,然後調整偏移值;要嘛就是想辦法提高時間的精度。後來我們去看了 SoftwareSerial 的原始碼...,他們還真的算出來了!

而且是直接算 CPU cycle 數!現在想想也很合理,直接算 cycle 是最準確的結果。不過這也意味著需要撰寫一些組合語言直接操作暫存器。

MH-Z14A 的 baud rate 為 9600,每個 bit 的時間應該為 1/9600 秒,對於 CPU 來說是幾個 cycle 呢?為了方便計算我們也直接將 CPU 頻率設定為 9600Hz 好了,這樣就剛好會是 1 cycle,計算方式為:CPUspeedbaudrate\frac{CPU speed}{baud-rate}

Software::begin 中可以看到實作

void SoftwareSerial::begin(long speed)
{
  // 略

  // Precalculate the various delays, in number of 4-cycle delays
  uint16_t bit_delay = (F_CPU / speed) / 4;

  // 12 (gcc 4.8.2) or 13 (gcc 4.3.2) cycles from start bit to first bit,
  // 15 (gcc 4.8.2) or 16 (gcc 4.3.2) cycles between bits,
  // 12 (gcc 4.8.2) or 14 (gcc 4.3.2) cycles from last bit to stop bit
  // These are all close enough to just use 15 cycles, since the inter-bit
  // timings are the most critical (deviations stack 8 times)
  _tx_delay = subtract_cap(bit_delay, 15 / 4);

  // Only setup rx when we have a valid PCINT for this pin
  if (digitalPinToPCICR((int8_t)_receivePin)) {
    #if GCC_VERSION > 40800
    // Timings counted from gcc 4.8.2 output. This works up to 115200 on
    // 16Mhz and 57600 on 8Mhz.
    //
    // When the start bit occurs, there are 3 or 4 cycles before the
    // interrupt flag is set, 4 cycles before the PC is set to the right
    // interrupt vector address and the old PC is pushed on the stack,
    // and then 75 cycles of instructions (including the RJMP in the
    // ISR vector table) until the first delay. After the delay, there
    // are 17 more cycles until the pin value is read (excluding the
    // delay in the loop).
    // We want to have a total delay of 1.5 bit time. Inside the loop,
    // we already wait for 1 bit time - 23 cycles, so here we wait for
    // 0.5 bit time - (71 + 18 - 22) cycles.
    _rx_delay_centering = subtract_cap(bit_delay / 2, (4 + 4 + 75 + 17 - 23) / 4);

    // There are 23 cycles in each loop iteration (excluding the delay)
    _rx_delay_intrabit = subtract_cap(bit_delay, 23 / 4);

    // There are 37 cycles from the last bit read to the start of
    // stopbit delay and 11 cycles from the delay until the interrupt
    // mask is enabled again (which _must_ happen during the stopbit).
    // This delay aims at 3/4 of a bit time, meaning the end of the
    // delay will be at 1/4th of the stopbit. This allows some extra
    // time for ISR cleanup, which makes 115200 baud at 16Mhz work more
    // reliably
    _rx_delay_stopbit = subtract_cap(bit_delay * 3 / 4, (37 + 11) / 4);
    #else // Timings counted from gcc 4.3.2 output
    // Note that this code is a _lot_ slower, mostly due to bad register
    // allocation choices of gcc. This works up to 57600 on 16Mhz and
    // 38400 on 8Mhz.
    _rx_delay_centering = subtract_cap(bit_delay / 2, (4 + 4 + 97 + 29 - 11) / 4);
    _rx_delay_intrabit = subtract_cap(bit_delay, 11 / 4);
    _rx_delay_stopbit = subtract_cap(bit_delay * 3 / 4, (44 + 17) / 4);
    #endif


    // Enable the PCINT for the entire port here, but never disable it
    // (others might also need it, so we disable the interrupt by using
    // the per-pin PCMSK register).
    *digitalPinToPCICR((int8_t)_receivePin) |= _BV(digitalPinToPCICRbit(_receivePin));
    // Precalculate the pcint mask register and value, so setRxIntMask
    // can be used inside the ISR without costing too much time.
    _pcint_maskreg = digitalPinToPCMSK(_receivePin);
    _pcint_maskvalue = _BV(digitalPinToPCMSKbit(_receivePin));

    tunedDelay(_tx_delay); // if we were low this establishes the end
  }
  ...
}

雖然程式碼不多,不過註釋當中寫得很清楚,他們計算了每個 gcc 版本會需要花上的 CPU cycle 數,扣掉之後才做 delay。

這邊的 tunedDelay 的實作也很有趣,直接用組合語言寫!

我對 AVR 的組語指令集不熟(當然對 Intel 也是啦 XD),但看起來應該就是一個比較精準的 delay 函數。使用 volatile 是告訴 compiler 不需要幫這段程式碼做優化,因為這個變數有可能會有變化,每次讀取時都直接從位址存取。

void tunedDelay(uint16_t __count)
{
	asm volatile (
		"1: sbiw %0,1" "\n\t"
		"brne 1b"
		: "=w" (__count)
		: "0" (__count)
	);
}

然後在 SoftwareSerial::write 當中

size_t SoftwareSerial::write(uint8_t b)
{
  volatile uint8_t *reg = _transmitPortRegister;
  uint8_t reg_mask = _transmitBitMask;
  uint8_t inv_mask = ~_transmitBitMask;
  uint8_t oldSREG = SREG;
  bool inv = _inverse_logic;
  uint16_t delay = _tx_delay;

  if (inv)
    b = ~b;

  cli();  // turn off interrupts for a clean txmit

  // Write the start bit
  if (inv)
    *reg |= reg_mask;
  else
    *reg &= inv_mask;

  tunedDelay(delay);

  // Write each of the 8 bits
  for (uint8_t i = 8; i > 0; --i)
  {
    if (b & 1) // choose bit
      *reg |= reg_mask; // send 1
    else
      *reg &= inv_mask; // send 0

    tunedDelay(delay);
    b >>= 1;
  }

  // restore pin to natural state
  if (inv)
    *reg &= inv_mask;
  else
    *reg |= reg_mask;

  SREG = oldSREG; // turn interrupts back on
  tunedDelay(_tx_delay);
  
  return 1;
}

這裡有幾個值得一提的程式碼片段:

  • *reg|= reg_mask 程式直接用操作暫存器的方式改變高低電位,而非一般的 digitalWrite() 的寫法。我猜是為了減少 compile 後的組語造成不必要的 instruction cycle?
  • cli():解除 Arduino 的中斷機制。要做一些原子操作或時間相關的操作時可以使用,不過這也代表在資料傳遞的過程中,其他程序會暫時無法運作?令我不解的是為什麼不用官方文件中的 noInterrupts() 而是 cli()
  • SREG = oldSREG:根據描述這樣可以啟動 Interrupt?為什麼不直接呼叫 interrupts() 就好?

其他的實作就不一一解釋,不過從觀察原始碼當中我們發現了幾件事:

  • 直接用 delay 精確度不夠
  • 為了達到精確的時間控制,需要直接計算 cycle 數
  • 在通訊的過程中中斷機制會無法使用

好了,現在我們知道 SoftwareSerial 的實作細節,用起來也比較心安理得了!

SoftwareSerial

SoftwareSerial 的 API 跟 HardwareSerial(也就是預先定義的 Serial)介面相同,根據上面的程式碼實作,也已經幫我們處理好 UART 的通信,所以直接使用 Serial.write 傳送指令碼就可以了。

唯一一點要注意的是在 Mega 以及 Mega 2560 上並不是每個接腳都可以當作 RX。

Not all pins on the Mega and Mega 2560 support change interrupts, so only the following can be used for RX: 10, 11, 12, 13, 14, 15, 50, 51, 52, 53, A8 (62), A9 (63), A10 (64), A11 (65), A12 (66), A13 (67), A14 (68), A15 (69).

使用 SoftwareSerial 向 MH-Z14A 發送指令

在上一篇中也有提到 MH-Z14A 的指令碼,我們再複習一次。

指令碼 功能(原文 / 中譯) 備註
0x86 Gas concentration / 感測二氧化碳濃度
0x87 Calibrate zero point value / 歸零校正 這個感測器有三個歸零校正方法,傳指令是其中一個
0x99 Calibrate span point value / 修改二氧化碳偵測範圍
0x79 Start/stop auto-calibration function of zero point value / 使用(或停止)自動校正功能 在出廠時,這個感測器預設會開啟自動校正

根據這張指令表,我們最感興趣的就是 0x86 指令碼,不過按照 datasheet 的描述,整個指令的傳送要符合下面的格式:

MH-Z14A datasheet

觀察一下,總共 9 bytes,第一個 byte 會是 0xff,第二個 byte 會是 0x01,第三個 byte 是指令碼,byte3 ~ byte7 為 0x00,最後一個值為 check value,datasheet 還很貼心附上附上計算方式的範例程式碼:

char getCheckSum(char *packet)
{
  char i, checksum;
  for( i = 1; i < 8; i++)
  {
    checksum += packet[i];
  }
  checksum = 0xff – checksum;
  checksum += 1;
  return checksum;
}

關於 checksum 的計算以及使用也是一門大學問,之後會在另外撰寫相關文章介紹!

搭配 SoftwareSerial 可以這樣寫:

#include <SoftwareSerial.h>
SoftwareSerial co2Serial(3, 4); // tx, rx 接腳,只要是 digital pin 都可以

void sendCommand(byte command)
{
  byte commands[9] = {0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
  commands[2] = command;
  commands[8] = getCheckSum();
  co2Serial.write(commands, 9);
}

這樣子就可以成功將指令碼送給 MH-Z14A 了!根據 datasheet 的 return value 表示,我們可以用 serial.readBytes 來拿到回應資料。

MH-Z14A datasheet

byte response[9]
byte high = response[2];
byte low = response[3];
byte ppm = high << 8 + low;
Serial.println(ppm);

根據 datasheet,ppm 的數值是將 high * 256 (就是將 high 左移 8 位的意思)再加上 low。也可以透過 byte8 的數字來檢查數字是否正確。不過我們這邊就偷懶不另外實作了。

如此一來就可以成功拿到二氧化碳的數字了!我們接下來看一下 MH-Z14A 的腳位圖:

MH-Z14A pin description

這個感測器提供很多同樣功能的腳位,我想是為了方便 debug 使用。像是接地就可以選擇 pin 2, 3, 12, 16, 22,多種組合任君挑選。在這邊我們將 GND 接到 Arduino 的 GND,將 RXD 接到 SoftwareSerial 的 tx(剛剛在 serial 定義的腳位而不是實際的 tx 腳位唷)、將 TXD 接到 SoftwareSerial 的 rx。

備註:MH-Z14A 感測器在購買時腳位並沒有焊接,因此沒辦法直接連到麵包板,我們剛開始直接用跳線插進去洞裡,後來發現超級不穩,請大家一定要記得焊接啊!

相關連結

總結

在這篇文章當中我們介紹了 UART,並且講解了 SoftwareSerial 背後的原理,最後再將指令碼送給 MH-Z14A 獲得二氧化碳濃度。下一篇文章,我們會分享在實作時遇到的地雷!請大家敬請期待。