UART is a point-to-point serial protocol for sending bytes between two devices using TX and RX lines.
UART allows the microcontroller to exchange data with modules and other equipment using a simple connection.
The Serial protocol (UART) has been the de facto standard in the electronics industry for decades. Even though your computer no longer has that giant DB9 connector, internally the world still runs on TX and RX.
In this post, we will learn to use the System.IO.Ports library to establish bidirectional communications. We’ll use it for two fundamental purposes:
- M2M Communication: Connect modules like GPS (NMEA), GSM/4G, or barcode readers.
- PC Communication: Create a custom command console to control our device.
For this tutorial, you need to install the NuGet package: nanoFramework.System.IO.Ports
Basic Concepts: TX and RX
The most important idea of UART is crossover wiring. UART is asynchronous communication (no clock wire) that uses two data lines:
- TX (Transmit): Where data goes out.
- RX (Receive): Where data comes in.
Therefore, when connecting two devices (A and B), you must always cross the lines:
- TX of A goes to the RX of B.
- RX of A goes to the TX of B.
- GND: Both must share the same ground reference.
Configuring the Serial Port
In nanoFramework, the star class is SerialPort.
The first thing we need to know is which COM port corresponds to which pins on our board.
- ESP32:
COM1: Generally reserved for Log/Debug and flashing (UART0).COM2: Usually UART1 or UART2 (depending on pin assignment).COM3: Another available port.
On ESP32, we can reassign pin functions using nanoFramework.Hardware.Esp32.Configuration. Always check the mapping of the target you have flashed, because the port name alone does not determine the physical GPIOs.
Initialization
using System.IO.Ports;
// 1. Instantiate the port
// Parameters: Port name, BaudRate (Speed)
SerialPort mySerial = new SerialPort("COM2", 9600);
// 2. Optional (but recommended) configuration
mySerial.Parity = Parity.None;
mySerial.StopBits = StopBits.One;
mySerial.Handshake = Handshake.None;
mySerial.DataBits = 8;
// 3. Open the connection
mySerial.Open();The speed (BaudRate) must be identical on both ends. Typical values are 9600 (older GPS) or 115200 (Modern modules). If you see strange characters (“garbage”), the speed is wrong.
Sending Data (TX)
Sending information is trivial. We have methods to send text or raw bytes.
// Send simple text
mySerial.Write("Hello World");
// Send a line (automatically adds \r\n at the end)
mySerial.WriteLine("AT Command");
// Send bytes (for binary protocols)
byte[] buffer = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF };
mySerial.Write(buffer, 0, buffer.Length);Receiving Data via Events (RX)
We could use an infinite loop and check if (mySerial.BytesToRead > 0), but that is going back to Polling, which we already agreed to abandon.
The SerialPort class provides us with the DataReceived event. This event fires when data arrives at the input buffer.
Full Example: Serial Echo
Let’s create a program that listens to everything sent to it and returns it converted to uppercase. (To test this, you will need a USB-Serial adapter connected to the TX/RX pins of your ESP32, or bridge the TX with the RX to create a “loopback”.)
using System;
using System.IO.Ports;
using System.Text;
using System.Threading;
using System.Diagnostics;
namespace UartExample
{
public class Program
{
private static SerialPort _serial;
public static void Main()
{
// Configure the port according to your board (COM2 is usually safe on ESP32 for extra pins)
// Make sure you know which physical pins are COM2 on your device
_serial = new SerialPort("COM2", 115200);
// Subscribe to the event
_serial.DataReceived += Serial_DataReceived;
try
{
_serial.Open();
Debug.WriteLine("Port open. Waiting for data...");
_serial.WriteLine("System Started. Type something:");
}
catch (Exception ex)
{
Debug.WriteLine($"Error opening port: {ex.Message}");
}
Thread.Sleep(Timeout.Infinite);
}
private static void Serial_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
SerialPort sp = (SerialPort)sender;
// Option A: Read everything as a string (if we know it is text)
string receivedText = sp.ReadExisting();
Debug.WriteLine($"RX: {receivedText}");
// Process and Respond (Echo in uppercase)
if (receivedText.Length > 0)
{
sp.WriteLine($"RECEIVED: {receivedText.ToUpper()}");
}
// Option B: Read bytes (if it is a binary protocol)
/*
int bytesToRead = sp.BytesToRead;
byte[] buffer = new byte[bytesToRead];
sp.Read(buffer, 0, bytesToRead);
*/
}
}
}Use Case: Reading a GPS
One of the most common uses is reading a GPS module (like the NEO-6M). These modules continuously output lines of text using the NMEA standard.
A typical sentence looks like this:
$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47
To process this in nanoFramework:
- Listen to the
DataReceivedevent. - Accumulate characters in a
StringBuilderuntil a line feed (\n) is found. - When we have the complete line, parse it.
private static StringBuilder _buffer = new StringBuilder();
private static void Serial_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
SerialPort sp = (SerialPort)sender;
string incoming = sp.ReadExisting();
foreach (char c in incoming)
{
if (c == '\n') // End of NMEA line
{
ProcessGPSLine(_buffer.ToString());
_buffer.Clear();
}
else
{
_buffer.Append(c);
}
}
}
private static void ProcessGPSLine(string line)
{
if (line.StartsWith("$GPGGA"))
{
// Split by commas
string[] parts = line.Split(',');
// parts[2] is Latitude, parts[4] is Longitude...
Debug.WriteLine($"GPS Location detected: {line}");
}
}Data Fragmentation
Keep in mind that the DataReceived event does not guarantee that you will receive the complete line at once. It can fire with half a message. That is why we must always use an intermediate buffer and look for the delimiter character (like \n) before processing.