GPIO pins on the Raspberry Pi can be controlled using the sysfs interface, which is a virtual filesystem that the Linux kernel provides. In this guide, we will write a basic C# class to control available pins on the Pi through sysfs.
Understanding the sysfs interface
sysfs provides access to the GPIO pins at the path /sys/class/gpio
. You can cd into this path and ls to list files in the directory. There are two special files here which are export and unexport. You write to the export
file to activate a particular pin, while writing to unexport
deactivates the pin. The following example activates GPIO pin 18.
| echo 18 > /sys/class/gpio/export |
You can verify that the pin is activated by listing the files in the /sys/class/gpio
directory. You should see a gpio18
folder in the directory listing. After the pin has been activated, you should specify whether the pin should be an input or output pin before you can read or write values. You do this for input like so:
| echo in > /sys/class/gpio/gpio18/direction |
Or for output:
| echo out > /sys/class/gpio/gpio18/direction |
If the pin is specified as an output pin, you can write a value of either 0 (low) or 1 (high) for the pin. If a LED is connected to the pin for this example, a value of 0 will turn the LED off, while a value of 1 will turn the LED on. To specify the pin value, you can do this:
| echo 1 > /sys/class/gpio/gpio18/value echo 0 > /sys/class/gpio/gpio18/value |
Once you are done with the pin, you can deactivate it using:
| echo 18 > /sys/class/gpio/unexport |
Writing the C# class
Now that we have an idea of how sysfs works, we can create a class to implement the necessary steps. The sysfs approach basically requires writing values to the file, so we can use simple file I/O operations to achieve the desired result. The full listing for the GPIO class can be found at https://gitlab.com/akinwale/NaniteIo/blob/master/Nanite/IO/GPIO.cs.
The first thing we’ll do is add the using statements for the namespaces. System.IO is required for FileStream, StreamReader and StreamWriter which are used for file I/O. System.Threading is required for the Thread class, while Nanite.Exceptions contains the custom exceptions defined for our project. We’ll also define enumerations for the GPIO direction and value, and a few constants for strings like the GPIO path and other special files. The class will be defined as static, because we do not need to create an instance of the class.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | using System.IO; using System.Threading; using Nanite.Exceptions; namespace Nanite.IO { public static class GPIO { public enum Direction { Input = 0, Output = 1 }; public enum Value { Low = 0, High = 1 }; private const string GPIOPath = "/sys/class/gpio"; private const string GPIODirection = "direction"; private const string GPIOExport = "export"; private const string GPIOUnexport = "unexport"; private const string GPIOValue = "value"; |
Pretty straightforward so far. The first method we’re going to define is the PinMode method, which will take the pin number and direction as parameters. This method will activate the pin and then set the direction to either in
or out
depending on the specified parameter value.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | public static void PinMode(int pin, Direction direction) { ClosePin(pin); string pinPath = Path.Combine(GPIOPath, string.Format("gpio{0}", pin)); if (!Directory.Exists(pinPath)) { try { using (StreamWriter writer = new StreamWriter(new FileStream( Path.Combine(GPIOPath, GPIOExport), FileMode.Open, FileAccess.Write, FileShare.ReadWrite))) { writer.Write(pin); } } catch (IOException ex) { throw new GPIOException("Unable to export the pin.", ex); } } do { // Wait until the pin has been initialised properly before setting the direction Thread.Sleep(500); } while (!Directory.Exists(pinPath)); try { using (StreamWriter writer = new StreamWriter(new FileStream( Path.Combine(pinPath, GPIODirection), FileMode.Truncate, FileAccess.Write, FileShare.ReadWrite))) { writer.Write(direction == Direction.Input ? "in" : "out"); } } catch (IOException ex) { throw new GPIOException("Unable to set the pin direction.", ex); } } |
We build the pinPath
string making use of Path.Combine(GPIOPath, string.Format("gpio{0}", pin));
. If the value specified for the pin parameter is 18, pinPath
will contain the string, "/sys/class/gpio/gpio18"
. The ClosePin
method call is optional, but the idea behind this is that the pin should be deactivated first before activating. We also check if the gpio pin directory exists using if (!Directory.Exists(pinPath))
before activating to make sure we are not activating a pin that has already been activated.
After the request for pin activation, there may be a small delay which is why we have a while loop which waits until the corresponding gpio pin directory has been created before we set the pin direction. Thread.Sleep(500)
makes the program wait 500 milliseconds before proceeding to the next statement. Note that this while loop is completely optional, but it acts as a safeguard against setting the pin direction before the gpio pin directory has been created by the system. One thing to take note of is if the gpio pin directory never gets created (for instance, if the pin is invalid), the loop may end up running forever. To fix this, we can set a maximum number of times the loop should run before ending the loop.
| int loopCount = 1; do { if (loopCount > 5) { // Exit the loop after it has been run 5 times (waited for 2.5s) break; } // Wait until the pin has been initialised properly before setting the direction Thread.Sleep(500); loopCount++; } while (!Directory.Exists(pinPath)); |
The next method is the ClosePin
method which takes the pin number as a parameter. This method checks if the pin directory exists before it writes the pin number to the /sys/class/gpio/unexport
file.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | public static void ClosePin(int pin) { string pinPath = Path.Combine(GPIOPath, string.Format("gpio{0}", pin)); if (Directory.Exists(pinPath)) { try { using (StreamWriter writer = new StreamWriter(new FileStream( Path.Combine(GPIOPath, GPIOUnexport), FileMode.Open, FileAccess.Write, FileShare.ReadWrite))) { writer.Write(pin); } } catch (IOException ex) { throw new GPIOException("Unable to close the pin.", ex); } } } |
We create the Write
method to write a value to a pin. It takes two parameters, the pin number and the value which is of the Value enumerator type with possible values Value.Low
or Value.High
. In this method, we make use Path.Combine to create the full path to the value
file in the gpio pin directory. For pin 18, this will be "/sys/class/gpio/gpio18/value"
. If value for the value parameter is Value.Low
, we write 0
to the file, otherwise if it’s Value.High
, we write 1
to the file.
| public static void Write(int pin, Value value) { try { string pinPath = Path.Combine(GPIOPath, string.Format("gpio{0}", pin)); using (StreamWriter writer = new StreamWriter(new FileStream( Path.Combine(pinPath, GPIOValue), FileMode.Truncate, FileAccess.Write, FileShare.ReadWrite))) { writer.Write(value == Value.Low ? 0 : 1); } } catch (IOException ex) { throw new GPIOException("Unable to write the pin value.", ex); } } |
Finally, we have our Read
method to read a value from a pin. It will return either Value.Low or Value.High depending on what the pin has been set to. The question mark at the end of the method return type indicates that we can return null for the method if the value retrieved is invalid.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | public static Value? Read(int pin) { int pinValue = -1; try { string pinPath = Path.Combine(GPIOPath, string.Format("gpio{0}", pin)); using (StreamReader reader = new StreamReader(new FileStream( Path.Combine(pinPath, GPIOValue), FileMode.Open, FileAccess.Read, FileShare.ReadWrite))) { int.TryParse(reader.ReadToEnd(), out pinValue); } } catch (IOException ex) { throw new GPIOException("Unable to read the pin value.", ex); } if (pinValue != 0 && pinValue != 1) { return null; } return (Value) pinValue; } |
To determine if the retrieved value is valid, we add a couple of checks in the method. The first is the int.TryParse
method, which returns false if the retrieved value is not a valid integer. Then verify that the value is either 0 or 1 using if (pinValue != 0 && pinValue != 1)
. If it’s neither 0 nor 1, null is returned. Otherwise, the corresponding enumeration value is returned by casting the integer to GPIO.Value
.
Finally, we can put this all together in a sample program. If a LED is connected to pin 18, the LED will light up when the value is set to High and turn off when the value is set to Low.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | using System; using System.Threading; using Nanite.IO; using Nanite.Exceptions; namespace NaniteIoSample { class Program { public static void Main(string[] args) { // Activate the pin and set the direction as Output GPIO.PinMode(18, GPIO.Direction.Output); // Blink a LED connected to pin 18 up to 50 times for (int i = 0; i < 50; i++) { GPIO.Write(18, GPIO.Value.High); Thread.Sleep(200); GPIO.Write(18, GPIO.Value.Low); Thread.Sleep(200); } // Close the pin after we're done GPIO.ClosePin(18); } } } |
Source Code
The full code listing for the GPIO class can be obtained from https://gitlab.com/akinwale/NaniteIo/blob/master/Nanite/IO/GPIO.cs.