Building an Air Quality Monitoring App with Arduino and ESP32 (Part 2) - Data Communication via UART
This article is the second in the series:
- Sensor Introduction - DHT11 and MH-Z14A
- Data Communication - UART (Since the implementation uses UART, this article will focus exclusively on it)
- Arduino Pitfalls and Troubleshooting
- (Upcoming) WiFi: To save debugging time, I bought an ESP32 development board, which comes with built-in Wi-Fi and Bluetooth
- (Upcoming) MQTT: Using MQTT, a lightweight messaging protocol, to transmit data to other devices
- (Upcoming) Grafana / Web: Once data is stored in a database, why not display it in a fancy way? Here, Grafana + Prometheus and Svelte are used to visualize the data.
Just as people need to communicate with one another, hardware components naturally need to communicate with each other as well.
In networking, TCP is probably the most familiar communication protocol. To guarantee transmission accuracy, a series of mechanisms are required to ensure data integrity and reliable reception between both sides. A similar process is necessary in hardware as well.
There are three common hardware communication protocols:
- UART (Universal Asynchronous Receiver/Transmitter)
- SPI
- I2C
This article will focus exclusively on UART.
Data Transmission
Suppose we want to send 10010 to another hardware device. A straightforward way is to connect 5 wires at once and transmit 1 0 0 1 0 simultaneously, as shown below:

While intuitive, this approach means that if you have 16 bits, you need 16 pins; if you have 32 bits, you need 32 pins. On a circuit board, we naturally prefer fewer pins whenever possible—much like why everyone loves wireless tech—because it simplifies both circuit design and manufacturing.
So is there a way to reduce pin usage? Suppose both sides agree to send one bit every 100ms. After 1600ms, all 16 bits will be received, as illustrated below:

Indeed, this method effectively solves the problem of using too many pins. However, it turns what was originally synchronous data transmission into “asynchronous,” sacrificing some speed in return (taking a total of 1600ms to transmit 16 bits). In hardware, this method of transmitting data bit by bit sequentially is called “Serial,” whereas simultaneous data transmission across multiple lines is called “Parallel.”
The UART protocol introduced today is a type of Serial Communication.
Returning to the diagram above: having just one line and an agreed-upon interval for receiving data is still not enough. Under this simple agreement, we do not know when data transmission begins or ends. Therefore, we need a signal that lets both sides know when data transfer starts and finishes.
UART Data Exchange
UART defines a start bit and a stop bit to inform both sides when transmission begins and ends.

In the idle (IDLE) state, the line is kept at a high voltage level. When data transmission begins, it pulls down to a low level as the start bit before sending the data payload. Each dataframe consists of 8 to 9 bits, with the last bit being an optional parity bit. After the data has finished transmitting, the line is pulled high to signal the stop bit.
Unlike other communication protocols, UART does not carry a clock signal for synchronization. Therefore, both parties must know each other’s baud rate in advance to agree on how fast the data is being transmitted. Why is this number so crucial? Take a look at the diagram below:

The red portion represents the correct baud rate, showing that the data is read properly. However, if the baud rate is doubled, the exact same bit will be sampled twice (shown in brown).
To use UART, the following conditions must be met:
- Both hardware devices must share a common GND (ground)
- The baud rate must match on both sides
UART in Arduino
In Arduino, there is a built-in hardware UART serial port, typically located on pins 0 and 1, labeled TX and RX. TX stands for transmit (sending data to another device), while RX stands for receive (reading data from the other device’s TX pin). The RX pin connects to the other device’s TX pin, and the TX pin connects to the other device’s RX pin.
UART is either provided by a dedicated IC or implemented within the MCU’s circuitry. However, an Arduino only has one built-in hardware UART chip. What if multiple devices need to communicate with the Arduino via UART? Or what if you want to keep the native serial port free as a debug console rather than dedicating it to other peripherals? In our scenario, we want to communicate with the MH-Z14A via UART, while also transmitting data to the ESP32 via UART.
This is where Arduino’s built-in library, SoftwareSerial, comes into play. SoftwareSerial allows regular Arduino digital pins to function as UART ports.
As the name implies, it uses software to simulate UART communication—somewhat analogous to hardware encoding versus software encoding in video processing.
In the MH-Z14A CO2 sensor we used, the UART baud rate is 9600. Initially, we thought about implementing SoftwareSerial ourselves—essentially rolling our own UART-compliant communication. However, we ran into a few issues that are worth sharing:
- According to the datasheet, the MH-Z14A’s baud rate is 9600 bps. This means each bit duration is 1 / 9600 = 104.1666666 us (microseconds). Arduino’s finest delay function is delayMicroseconds, which means the fractional part must be dropped (arguments passed to delay functions must be integers). This rounding error alone is enough to corrupt data reception over time.
- CPU instruction cycles take time. Although small, the cumulative timing error for each bit across UART transmission cannot be ignored.
- We suspected that relying purely on delay loops would make timing prone to drift due to other Arduino interrupts.
The diagram below clearly illustrates the problem encountered when using simple delay functions:

Ideally, the delay function should be extremely precise. In practice, however, factors like compiler output and interrupts introduce offset shifts (as shown in the brown blocks). We would either have to blindly guess the CPU overhead and adjust the offset values, or find a way to dramatically increase timing precision. When we later looked into the source code of SoftwareSerial… they actually calculated it!
And they calculated it down to the exact number of CPU cycles! Looking back, this makes total sense—counting cycles directly yields the most accurate results. However, it also means writing inline assembly to manipulate registers directly.
For an MH-Z14A with a baud rate of 9600, each bit lasts 1/9600 seconds. How many cycles does that represent to the CPU? For simplicity of explanation, if the CPU frequency were 9600 Hz, that would be exactly 1 cycle, calculated as: .
In SoftwareSerial::begin, you can see the implementation:
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
}
...
}
Although the code is concise, the comments explain it thoroughly: they calculated the exact number of CPU cycles consumed across different GCC versions and deducted those cycles before delaying.
The implementation of tunedDelay is also interesting—it is written directly in inline assembly!
I am not very familiar with the AVR assembly instruction set (or Intel’s for that matter, haha), but it looks like a highly accurate delay routine. Using volatile tells the compiler not to optimize this block of code away, ensuring that because the variable might change, every read accesses the address directly.
void tunedDelay(uint16_t __count)
{
asm volatile (
"1: sbiw %0,1" "\n\t"
"brne 1b"
: "=w" (__count)
: "0" (__count)
);
}
And in 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;
}
A few code snippets here are worth highlighting:
*reg |= reg_mask: The code toggles pin states via direct register manipulation rather than calling standarddigitalWrite(). Presumably, this avoids unnecessary instruction cycles introduced by the compiled assembly.cli(): Disables Arduino’s interrupt mechanism. This is used when performing atomic operations or timing-critical routines, though it also means other tasks are temporarily halted during transmission. Curiously, it usescli()directly instead of the documentednoInterrupts().SREG = oldSREG: According to descriptions, restoring the status register reenables interrupts if they were previously enabled. Why not simply callinterrupts()?
We won’t go through every detail of the rest of the implementation, but examining the source code reveals a few takeaways:
- Using plain
delayfunctions lacks sufficient precision - Achieving precise timing control requires counting cycles directly
- Interrupts must be disabled during transmission routines
Now that we understand the internal mechanics of SoftwareSerial, using it feels much more reassuring!
SoftwareSerial
The SoftwareSerial API shares the same interface as HardwareSerial (the predefined Serial object). Given that the implementation above handles all low-level UART communication for us, we can simply call Serial.write to send command bytes.
One important caveat: on the Mega and Mega 2560, not every pin can be used as 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).
Sending Commands to the MH-Z14A Using SoftwareSerial
In the previous article, we introduced the command codes for the MH-Z14A. Let’s review them briefly:
| Command | Function (Original / Description) | Notes |
|---|---|---|
| 0x86 | Gas concentration / Read CO2 concentration | |
| 0x87 | Calibrate zero point value / Zero-point calibration | The sensor offers three calibration methods; sending commands is one of them |
| 0x99 | Calibrate span point value / Adjust CO2 detection range | |
| 0x79 | Start/stop auto-calibration function of zero point value / Enable (or disable) auto-calibration | By default, this sensor has auto-calibration enabled from the factory |
Looking at this command table, the one we are most interested in is 0x86. However, according to the datasheet, the command packet must follow this format:

Observing the structure: there are 9 bytes in total. The first byte is 0xff, the second byte is 0x01, the third byte is the command code, bytes 3 to 7 are 0x00, and the last byte is the check value. The datasheet helpfully provides example code for computing the checksum:
char getCheckSum(char *packet)
{
char i, checksum;
for( i = 1; i < 8; i++)
{
checksum += packet[i];
}
checksum = 0xff – checksum;
checksum += 1;
return checksum;
}
Calculating and validating checksums is a substantial topic in itself, which I will cover in a future post!
Using SoftwareSerial, we can write the following:
#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);
}
With this, we can successfully send commands to the MH-Z14A! According to the datasheet’s return value specification, we can use serial.readBytes to retrieve the response data.

byte response[9]
byte high = response[2];
byte low = response[3];
byte ppm = high << 8 + low;
Serial.println(ppm);
According to the datasheet, the PPM value is calculated by taking high * 256 (which is equivalent to bit-shifting high left by 8 bits) and adding low. You can also verify the byte 8 checksum to confirm data integrity, though we took a shortcut and skipped that verification here.
And just like that, we can read the CO2 value! Next, let’s take a look at the pinout diagram of the MH-Z14A:

This sensor provides several redundant pins for the same functions, likely to make prototyping and debugging more convenient. For instance, you can choose pins 2, 3, 12, 16, or 22 for Ground—plenty of options to suit your wiring setup. Here, we connect GND to Arduino GND, RXD to the SoftwareSerial TX pin (the pin assigned in code, not the Arduino’s hardware TX pin), and TXD to the SoftwareSerial RX pin.
Note: When purchased, the MH-Z14A sensor does not come with header pins soldered, meaning it cannot be plugged directly into a breadboard. At first, we tried sticking jumper wires straight into the through-holes, but it proved extremely unstable. Be sure to solder headers properly!
Related Links
Conclusion
In this article, we introduced UART, explored the principles behind SoftwareSerial, and showed how to send commands to the MH-Z14A to read CO2 concentrations. In the next post, we will share the pitfalls and gotchas encountered during implementation! Stay tuned.
Related Posts
- When a Measure Becomes a Target: From the Window Tax to Pull Request Counts I once wrote a script to tally how many PRs I contributed in a quarter, how many reviews I left, and how many tickets I closed, hoping to use numbers to prove my output to my manager. My manager simply remarked that performance isn't just about output. Years later, I finally understood—when a measure becomes a target, it ceases to be a good measure. From the British window tax and the Hanoi rat bounty to evaluating developers by PR counts today, the underlying mechanism is exactly the same.
- Using Cloudflare Images for Image Storage and Transformation Putting an image on a webpage is the simplest task in frontend development. But doing it properly—including resizing, generating multiple formats, and withstanding heavy traffic—is actually an entire end-to-end solution. Eventually, I offloaded everything to Cloudflare Images, keeping only a single original image.
- Stop Using AWS Access Keys Access Keys are an easily overlooked security risk in AWS. By pairing OIDC with IAM Roles, GitHub Actions can securely operate AWS resources without storing any secrets.
- Database Primary Keys: AUTO_INCREMENT, UUID, and UUIDv7 Backend developers often face the choice of primary keys: should you use auto-increment or UUID? What about collisions? How does UUIDv7 compare to created_at + index in performance? Here are the design decisions and benchmark results from testing 20 million rows.