Orange Pi Zero 3 - exploring GPIO

GPIO (General-Purpose Input/Output, or General-Purpose Pins) is a fundamental concept that came into the world of powerful SoCs (System-on-a-Chip) directly from the realm of microcontrollers.

It is the main bridge for your single-board computer to interact with simple external devices, in addition to more complex hardware interfaces (such as I2C, SPI, UART, or I2S).

While specialized protocols transmit data streams, GPIO allows you to work at the lowest level -controlling individual digital signals. With each pin you can:

  • Read the state (input): For example, check if a button is pressed, or if the signal is “1” or “0”.

  • Control the state (output): For example, turn on an LED, activate a relay, or send a signal to another component.

In this article, I want to provide a quick overview of the methods for working with GPIO on the Orange Pi Zero 3 (which is based on the Allwinner H618 SoC). Of course, these methods are not unique and can be applied to other single-board computers as well (especially those based on Allwinner chips), but the focus will be on the Zero 3.

Attention: Important Warning! The logic level on the Orange Pi Zero 3 GPIO pins is 3.3V. You must not apply a higher voltage (such as 5V) to any pin; this will 100% damage the processor. If this is truly necessary, you must use logic level shifters for external 5V devices.

The Orange Pi Zero 3 has several connectors for accessing GPIO:

  1. 26-Pin Connector (DIP26): This is the main connector. Its pinout is partially compatible with the Raspberry Pi standard.

  2. 13-Pin Connector (SIP13): An additional connector that breaks out a few more GPIO lines and specialized interfaces (like a USB/Audio/Composite video).

  3. 3-Pin UART Connector: A separate connector for the serial console.

DIP26 Connector Pinout (2x13)

SIP13 Connector Pinout (1x13)

For working with GPIO in Linux, there are three main mechanisms:

  • Direct Register Access: This is the “lowest,” hardware level. This method requires superuser (root) privileges, and most importantly, it is completely specific to each SoC. You need to know the exact addresses of the hardware registers, which makes this approach non-portable and complex.

  • GPIO Sysfs: The classic way of interaction through the filesystem, typically located at /sys/class/gpio/. Although it may still be enabled by default on some distributions (such as Armbian), this interface is officially considered deprecated. Its support is gradually being removed from new versions of the Linux kernel, so it is not advisable to rely on it. Link to kernel documentation.

  • GPIO Character Device: This is the new standard mechanism that works via character devices located in /dev/ (for example, /dev/gpiochip0, /dev/gpiochip1, etc.). The libgpiod library is used for convenient interaction with these devices from user space.

Let’s examine each of these methods in detail to evaluate their convenience and practicality in real-world tasks.

Direct Register Access

Let’s try to “blink” an LED on pin PC5 without any libraries, by communicating directly with the hardware.

Warning: This method is extremely dangerous. Direct writing to /dev/mem gives you complete control over all physical memory in the system. Any error in a single address or value can cause the system to freeze or even damage the file system. Therefore, it is best to have a separate SD card without important data and experiment using that.

To begin, let’s install the necessary tools—a C++ compiler and CMake for configuring the build:

sudo apt install g++ cmake

We will need the technical documentation (datasheet) for the SoC. The Orange Pi Zero 3 uses the H618, but for our GPIO experiments, it is practically identical to the Allwinner H616. The documentation for the H616 can be found at: H616 User Manual V1.0.

I must note that the H618 uses the MMIO (Memory-Mapped Input/Output) mechanism—meaning input/output is mapped into memory. This signifies that all hardware control registers do not have a separate space but are located directly within the general physical memory address space. To enable a pin, we don’t need special instructions like in/out on x86. We simply need to write the correct value to the correct physical memory address.

This is why, for direct hardware access, we will use the mmap() system call on the device file /dev/mem. This pair of tools allows us to map the CPU’s real physical address space into our program’s virtual address space.

This allows us to read from and write to physical registers just as if they were standard variables in our code.

According to the documentation (Section 9.6.4. Register List), two main blocks are dedicated to GPIO registers:

  • For ports C, G, H, I — the block starting at address 0x0300B000.

  • For port L — the block starting at address 0x07022000.

We will experiment with ports from the first block. The base address of this block (0x0300B000) happens to be precisely aligned to the 4KB memory page boundary. This is crucial because the mmap() system call requires the offset to be a multiple of the page size. Fortunately, we can pass this address directly without any additional calculations.

Our C++ code for “mapping” this physical address into our program’s virtual space will look like this:

constexpr off_t PIO_BASE_ADDR = 0x300B000;
constexpr size_t PAGE_SIZE = 4096;

int mem_fd = open("/dev/mem", O_RDWR | O_SYNC);
if (mem_fd < 0) {
    std::cerr << "Can't open /dev/mem: " << strerror(errno) << std::endl;
    return 1;
}

void* map_base = mmap(
    nullptr,
    PAGE_SIZE,
    PROT_READ | PROT_WRITE,  // we need read and write access
    MAP_SHARED,
    mem_fd,                  // file descriptor of the /dev/mem file
    PIO_BASE_ADDR            // physical address (4k aligned)
);
close(mem_fd); // file descriptor not needed anymore, after mmap call

if (map_base == MAP_FAILED) {
    std::cerr << "mmap error: " << strerror(errno) << std::endl;
    return 1;
}

If the code executed without any errors, the map_base variable is now a pointer to the start of the virtual memory that, actually maps to our physical registers.

I recall from the days of 8-bit AVRs that simply blinking an LED required doing two things:

  • Configure the pin direction (input/output) in the corresponding bit of the direction register (DDRx).

  • Write a one or a zero to the bit of the data register (PORTx).

If you needed to read the state of a button, you could additionally enable the internal pull-up to VCC.

This fundamental principle has not changed. Of course, on the H618, each pin has many more options (multiplexing for I2C/SPI/UART, drive strength selection, etc.), but basic digital I/O works very similarly.

We will need two types of registers: configuration registers (analogous to DDR) and data registers (analogous to PORT).

According to the documentation, they have the following offsets relative to the port base address (PIO_BASE_ADDR + n * 0x24):

Register Offset Description
Pn_CFG0 n*0x0024+0x00 Port n Configure Register 0 (Піни 0-7)
Pn_CFG1 n*0x0024+0x04 Port n Configure Register 1 (Піни 8-15)
Pn_CFG2 n*0x0024+0x08 Port n Configure Register 2 (Піни 16-23)
Pn_CFG3 n*0x0024+0x0С Port n Configure Register 3 (Піни 24-31)
Pn_DAT n*0x0024+0x10 Port n Data Register

where n is the port index.

As we have already seen, we are interested in ports C, F, G, H, I. For programmatic access, they are identified by numeric indices corresponding to their ordinal number in the alphabet (i.e. A=0, B=1, C=2, D=3, E=4, F=5, etc.).

For convenience in the code, these indices can be defined in a C++ enum:

enum class GPIOPort: uint8_t {
    PC = 2,
    PF = 5,
    PG = 6,
    PH = 7,
    PI = 8
};

The most important difference from AVR is that each pin requires 4 bits instead of 1, to configure its function (Input, Output, NAND, SDIO, etc.). Therefore, a single 32-bit Pn_CFG register can configure only 8 pins.

  • Pn_CFG0 controls pins 0-7

  • Pn_CFG1 controls pins 8-15

  • and so on…

Fortunately, the data register (Pn_DAT) is structured more simply: a single 32-bit register holds the data for all 32 port pins (1 bit per pin), which resembles the familiar PORT register in AVR architecture.

Let’s take our pin, PC5, as an example. In addition to input and output, it can perform additional functions (multiplexing) related to NAND flash or an SD card. Since PC5 is the 5th pin of Port C (0-indexed), its configuration is controlled by 4 bits in the PC_CFG0 register, starting at bit position 5 * 4 = 20. That means we are interested in bits [23:20].

According to the documentation, these bits (PC5_SELECT) signify:

  • 000: Input

  • 001: Output

  • 010: NAND_RE

  • 011: SDC2_CLK

  • 100: Reserved

  • 101: BOOT_SEL

Therefore, the task is to write 001 there to configure the pin as an Output.

constexpr uint8_t PIN_ID = 5; // pin number
constexpr PIOPort PIN_PORT = PIOPort::PC; // our port

// volatile ensures that the compiler does not optimize our memory accesses.
volatile uint32_t* reg32_ptr = (volatile uint32_t*)map_base;
uint32_t pin_port = static_cast(PIN_PORT);
uint32_t cfg0_off = pin_port * 0x24 + 0x00; // Pn_CFG0 offset
uint32_t dat_off = pin_port * 0x24 + 0x10; // Pn_DAT offset

// we should write 001 - Output
uint32_t current_value = *(reg32_ptr + cfg0_off/4); // read Pn_CFG0
// resets all bits to 0000
current_value &= ~(0xF << PIN_ID * 4);
current_value |= (0x1 << PIN_ID * 4);  // ant then write 1 at the beggining
*(reg32_ptr + cfg0_off/4) = current_value;

for(auto i = 0; i < 10; i++) { // let's change state 10 times
    current_value = *(reg32_ptr + dat_off/4);
    if (!(current_value & (1 << PIN_ID))) {
        std::cout << "Pin is LOW, setting it HIGH" << std::endl;
        current_value |= (1 << PIN_ID);  // set bit to 1
    } else {
        std::cout << "Pin is HIGH, set LOW" << std::endl;
        current_value &= ~(1 << PIN_ID);  // clear bit
    }
    *(reg32_ptr + dat_off/4) = current_value;
    std::this_thread::sleep_for(500ms);
}
if (munmap(map_base, PAGE_SIZE) == -1) {
    std::cerr << "munmap error: " << strerror(errno) << std::endl;
}

The full code for this example can be found on GitHub.

This method is the “closest to the hardware”approach, which comes with both unique advantages and very serious drawbacks.

Advantages:

  • Maximum Speed: This is indisputably the fastest way to control a pin from user-space. You bypass all kernel abstractions, additional system calls (aside from the initial mmap), and file parsing.

  • Zero Dependencies: The code requires no third-party libraries (like libgpiod) or specific kernel modules (like sysfs). All you need is access to /dev/mem.

  • Educational Value: This approach forces you to open the documentation and understand how the processor actually controls hardware at the bit and register level.

  • Atomicity: You can switch multiple pins within the same port simultaneously.

Disadvantages:

  • Root Privileges Required: Accessing /dev/mem is restricted to the superuser, which poses a huge security risk and is considered bad practice for standard applications.

  • Extreme Danger: An error in a single address or value can instantly freeze the system or corrupt the filesystem.

  • Zero Portability: This code will only work on the Allwinner H616/H618 and possibly other Allwinner SoCs with a similar memory map. It is completely useless on any other platform (Raspberry Pi, Rockchip, etc.).

  • No Interrupt Handling: Since we are operating in user-space, we cannot handle hardware interrupts (e.g., “notify me when a button is pressed”). The only available method is constant polling of the pin in a loop, which wastes valuable CPU resources.

Accessing Pins via GPIO Sysfs

Now let’s examine the “civilized,” but deprecated, method — control via GPIO Sysfs.

Additional information about this method can be found at the following links:

GPIO sysfs requires no additional libraries or dependencies. All interaction occurs via reading from and writing to files in the /sys/class/gpio directory.

To start working with a pin, you must first “export” it so that it appears in sysfs. To do this, write the global pin index to the special file /sys/class/gpio/export.

How do you calculate this index? The formula is simple:

pin_index = port_index * 32 + pin_number

We determined the port indices earlier (PC = 2, PF = 5, etc.). So, for our test pin, PC5:

  • Port C has index 2.

  • The pin number is 5.

pin_index= (2 * 32) + 5 = 69

By the way, if you are too lazy to do the math, I already included these global indices in the pinout diagram at the beginning of the article.

Here is an example of C++ code to export our target pin, PC5:

constexpr uint8_t PIN_ID = 5; // required pin
constexpr PIOPort PIN_PORT = PIOPort::PC; // required port

uint16_t pinId = static_cast(PIN_PORT) * 32 + PIN_ID;
std::string pinStr = std::to_string(pinId);

// export pin
int exportFd = open("/sys/class/gpio/export", O_WRONLY);
if (exportFd < 0) {
    std::cerr << "Can't open /sys/class/gpio/export: " << strerror(errno)
        << std::endl;
    return 1;
}
write(exportFd, pinStr.c_str(), pinStr.size());
close(exportFd);

After executing this code, a new directory will appear: /sys/class/gpio/gpio69/. Let’s see what’s inside:

# ls -l /sys/class/gpio/gpio69/
total 0
-rw-r--r-- 1 root root 4096 Nov  7 10:15 active_low
lrwxrwxrwx 1 root root    0 Nov  7 10:15 device -> ../../../gpiochip1
-rw-r--r-- 1 root root 4096 Nov  7 10:15 direction
-rw-r--r-- 1 root root 4096 Nov  7 10:15 edge
drwxr-xr-x 2 root root    0 Nov  7 10:15 power
lrwxrwxrwx 1 root root    0 Nov  7 10:15 subsystem -> ../../../../../../../class/gpio
-rw-r--r-- 1 root root 4096 Nov  7 10:15 uevent
-rw-r--r-- 1 root root 4096 Nov  7 10:15 value

For simple blinking, we only need two files:

  • direction - Direction. Accepts “in” or “out”. We need to write “out” to control the LED. By the way, writing “out” sets the pin to LOW by default.

  • value - State. This file can be both read from and written to. We will write ‘1’ or ‘0’.

  • other files – These are intended for configuring interrupts or inverting the signal. We don’t need them for our current experiment.

C++ Code:

// configure pin direction
std::string gpioCtrlPath = "/sys/class/gpio/gpio" + pinStr;
std::string directionPath = gpioCtrlPath + "/direction";
int directionFd = open(directionPath.c_str(), O_WRONLY);

if (directionFd < 0) {
    std::cerr << "Can't open " << directionPath << ": " << strerror(errno) 
    return 1;
}

write(directionFd, "out", 3);
close(directionFd);

// open pin file
std::string valuePath = gpioCtrlPath + "/value";
int valueFd = open(valuePath.c_str(), O_RDWR);

if (valueFd < 0) {
    std::cerr << "Can't open " << valuePath << ": " << strerror(errno) << std::endl;
    return 1;
}

// toggle pin value
char valueBuf;
for(auto i = 0; i < 10; i++) {
    valueBuf = (i % 2) ? '1' : '0';

    std::cout << "Setting pin to " << (i % 2 ? "HIGH" : "LOW") << std::endl;

    if (write(valueFd, &valueBuf, 1) != 1) {
        std::cerr << "Error writing value: " << strerror(errno) << std::endl;
    close(valueFd);
        return 1;
    }
    std::this_thread::sleep_for(500ms);
}
close(valueFd);

And finally, you need to clean up after yourself — specifically, “unexport” the pin.

// unexport pin
int unexportFd = open("/sys/class/gpio/unexport", O_WRONLY);
if (unexportFd < 0) {
    std::cerr << "Can't open /sys/class/gpio/unexport: " << strerror(errno) << std::endl;
    return 1;
}
write(unexportFd, pinStr.c_str(), pinStr.size());
close(unexportFd);

The full code for this example can be found on GitHub.

Advantages compared to direct register access:

  • Safety: Most importantly, it is safe. You cannot crash (“hang”) the system by writing the wrong value. You are working within a “sandbox” provided by the kernel.

  • No Root Required: Although /sys files belong to root by default, you can easily configure udev rules to grant your user access to the gpio group. This is standard practice.

  • Abstraction: The code is no longer tied to the H618. It doesn’t care about addresses like 0x0300B000. It only needs the pin number (69). This exact code (with a different pin number) will work on a Raspberry Pi, Rockchip, or any other Linux device where sysfs is enabled.

  • Interrupt Support: Deep within the Linux kernel, GPIO Sysfs handles hardware interrupts and allows you to subscribe to pin state changes via poll() or select() calls. Consequently, there is no need for busy-wait loops to check the pin, which reduces CPU load.

  • Simplicity: GPIO Sysfs can even be used in shell scripts.

Disadvantages:

  • Deprecated: The Linux kernel developer community officially recommends against using this interface for new projects and suggests switching to libgpiod.

  • Slow: Every write to a file involves a system call, a context switch (user-space -> kernel-space), and string parsing (“out”, “1”, “0”) inside the driver. This is very slow. You cannot generate fast PWM signals or control high-speed protocols (e.g., emulating SPI) using this method.

  • Non-atomic: You cannot change the state of multiple pins simultaneously. If you need PC5 and PC6 to turn on at the exact same time, sysfs offers no guarantees. You write to one file, then to the other — there will be a delay between these actions.

The libgpiod Library

We have covered GPIO sysfs and concluded that while it is safe, it is slow, non-atomic, and—most importantly—officially deprecated. So, what is the modern and recommended approach in Linux?

The answer is the GPIO Character Device interface. Instead of using “files-in-a-directory” for each individual pin, the kernel now provides a unified interface via device files located at /dev/gpiochip[n].

The libgpiod library was created to facilitate interaction with these devices from user-space. Additionally, a suite of utilities is available for shell scripts: gpiodetect, gpioinfo, gpioset, and gpioget.

Let’s install libgpiod and adapt our LED “blinking” example to use it. The latest version of the library (2.x.x) is available on Armbian via backports, so I am installing the dependencies as follows:

sudo apt install gpiod libgpiod-dev -t oldstable-backports

The API documentation for the library’s C interface can be found here - libgpiod Documentation.

But for now, let’s start experimenting directly in the console. Let’s see which GPIO controllers are available in the system using the gpiodetect command:

$ sudo gpiodetect
gpiochip0 [7022000.pinctrl] (32 lines)
gpiochip1 [300b000.pinctrl] (288 lines)

You can also view details for each of the GPIO controllers:

$ sudo gpioinfo gpiochip0
gpiochip0 - 32 lines:
        line   0:      unnamed       kernel   input  active-high [used]
        line   1:      unnamed       kernel   input  active-high [used]
        line   2:      unnamed       unused   input  active-high
        ...

On a Raspberry Pi, lines often have meaningful names, such as “GPIO16” or “ID_SDA”. Unfortunately, on my Armbian setup, the pin names are missing.

However, we can deduce which chip contains the PC5 pin by analyzing the chip names; they contain the physical register addresses we saw in the first section:

  • gpiochip0 [7022000.pinctrl] - 7022000 is the register address for Port L, so gpiochip0 corresponds to this port.

  • gpiochip1 [300b000.pinctrl] - 300b000 is, accordingly, the register address for Ports C, G, H, and I. This is the one we need.

In libgpiod terminology, pins are called “lines.” We calculate the line_index the same way as in the previous method: port_index * 32 + pin_number (for PC5, this is 2 * 32 + 5 = 69).

The configuration steps in libgpiod (v2) are as follows:

  • Create a gpiod_line_settings object and specify the desired parameters within it (direction, initial state, etc.).

  • Create a gpiod_line_config object and add our settings to it, along with the line numbers to which they should apply.

  • Create a gpiod_request_config object and specify the “name” of our application (the consumer field).

  • Request lines from the chip by calling gpiod_chip_request_lines(), passing it both the gpiod_line_config and gpiod_request_config structures.

And after all, we receive a gpiod_line_request object — this is our “key” for controlling the lines.

The simplified code for initialization and blinking is as follows:

std::string chipId = "gpiochip1"; // corresponds to 0x0300B000
    unsigned int pinId = static_cast(PIN_PORT) * 32 + PIN_ID;

    gpiod_chip *chip = gpiod_chip_open(("/dev/" + chipId).c_str());
    gpiod_line_settings* line_settings = gpiod_line_settings_new();
    gpiod_line_settings_set_direction(line_settings, GPIOD_LINE_DIRECTION_OUTPUT);

    gpiod_line_config* line_config = gpiod_line_config_new();
    gpiod_line_config_add_line_settings(line_config, &pinId, 1, line_settings);

    gpiod_request_config *req_config = gpiod_request_config_new();
    gpiod_request_config_set_consumer(req_config, "my-blink-app");

    gpiod_line_request *request = gpiod_chip_request_lines(
        chip,
        req_config,
        line_config);

    // Let's blink 10 times
    gpiod_line_value value;
    for (int i = 0; i < 10; i++)
    {
        value = (i % 2) ? GPIOD_LINE_VALUE_ACTIVE : GPIOD_LINE_VALUE_INACTIVE;
        gpiod_line_request_set_value(request, pinId, value);
        std::this_thread::sleep_for(std::chrono::milliseconds(500));
    }

    // we should free the resources
    gpiod_line_request_release(request);
    gpiod_line_config_free(line_config);
    gpiod_line_settings_free(line_settings);
    gpiod_chip_close(chip);

The full code for this example can be found on GitHub.

Advantages:

  • Recommended Method: This is the standard interface supported by the Linux kernel.

  • Higher Speed: Interaction occurs via ioctl() system calls rather than through slow file parsing (as in sysfs). This allows for much faster pin control.

  • Atomicity: libgpiod allows you to claim and change the state of multiple pins simultaneously (within a single request). This is critical for implementing software protocols (like SPI).

  • Reliability: An application can “claim” (request) a line. This prevents conflicts where two programs attempt to control the exact same pin simultaneously.

  • Safety: Like sysfs, this method does not require root privileges. You can configure udev rules for /dev/gpiochipN devices.

Disadvantages:

  • Greater API Complexity: To simply “blink” an LED, you need to write significantly more code than with GPIO sysfs.

  • External Dependency: It requires installing the libgpiod-dev library for compilation and libgpiod for runtime execution.

  • API Confusion: There is both a C API (gpiod.h) and a C++ API (gpiod.hpp). Furthermore, API v2 differs significantly from v1, which causes confusion when searching for code samples.

How to Configure UDEV for Libgpiod

For the final step, we will configure UDEV so that a standard user can access GPIO.

Here is a step-by-step guide:

Creating the gpio group

If it doesn’t already exist in the system, we will create a dedicated gpio group:

sudo groupadd --system gpio

Adding the user to the group

Add the current user to this group:

sudo usermod -a -G gpio ${USER}

Creating a UDEV rule

Create a new udev rules file that will automatically change the group and access permissions for gpiochip devices.

/etc/udev/rules.d/99-gpio.rules:

SUBSYSTEM=="gpio", KERNEL=="gpiochip*", GROUP="gpio", MODE="0660"

Reloading UDEV rules

To apply the new rule without waiting for a reboot, run:

sudo udevadm control --reload-rules
sudo udevadm trigger

Verifying permissions

Now, let’s check the access permissions for the device files. We need to verify that the gpio group is now the owner of the gpiochip files:

$ ls -l /dev/gpiochip*
crw-rw---- 1 root gpio 254, 0 Nov  8 16:07 /dev/gpiochip0
crw-rw---- 1 root gpio 254, 1 Nov  8 16:07 /dev/gpiochip1

The permissions (crw-rw—-) and the group (gpio) are set correctly.

Finally, you must log out and log back in; this is mandatory for the group changes to take effect for the user.

After this, you can run the compiled application without sudo, and it will successfully blink the LED.

Comments

Popular posts from this blog

How to use SPIFFS for ESP32 with Platform.IO

Configuring LED Indicators on Orange Pi Zero 3

ESP8266 module with OLED screen (HW-364A)