Introduction

This page describes the process of writing a kernel module that can toggle an LED inside a PinePhone UBports edition.
Using the user manual of the phone’s processor (Allwinner A64), a kernel module will be written that can control the RGB-LED of the phone.

What is a kernel module?

A kernel is a piece of software that handles all the communications between hardware and software. The linux kernel uses modules for this. Only the kernel and it’s modules are allowed to read/write to/from hardware.

The goal

The goal of this little project is to create a linux module that turns the built-in RGB-LED of the PinePhone UBports edition on or off. The same approach can be used to control GPIO ports on other embedded systems, like Raspberry Pi’s. How cool would it be to understand the magic behind packages like wiringPi?

Reading schematics

The Pine64 website hosts a schematic, which tells how the PinePhone is wired on the inside. Page 11 says how the RGB-led is connected:

LED schematic

  • PD18-LED-R is connected to B+
  • PD19-LED-G is connected to R+
  • PD20-LED-B is connected to G+

Figuring out how to control the IO pins

The schematic provided enough information. It’s now time to start digging into the workings of the Allwinner A64 SoC. The A64 SoC has two lenghty documents;

  • The datasheet provides information about how to integrate the A64 SoC into a project and focuses on the electronic side of things.
  • The user manual contains information about how the SoC works and contains information about the different registers present in the SoC.

For this project, only the user manual will be used, as it contains all the required information.

Finding the right registers

Chapter 3.21 (page 376) of the user manual contains information about some of the registers in the A64 SoC. At the top of the page, the following text can be read:

The chip has 7 ports for multi-functional input/out pins. They are shown below:
- Port B(PB): 10 input/output port
- Port C(PC): 17 input/output port
- Port D(PD): 25 input/output port
- Port E(PE): 18 input/output port
- Port F(PF): 7 input/output port
- Port G(PG): 14 input/output port
- Port H(PH): 12 input/output port

Port D is the one that will be controlled (PD18, PD19 and PD20).

Further down the same page, some memory offsets are shown. This is because the RGB led is connected using MMIO (Memory Mapped IO). When a certain part of memory is altered, the corresponding IO port will turn on or off. There are a few configure registers (which set pin modes), a data register (which turns the IO port on or off), a multi-driving register (to control voltage levels?) and a fell pull registers (for pull-down and pull-up purposes).

For this project, only the configure register PD_CFG2 and the data register PD_DAT will be used. All the other registers can be set if so desired, but it is not needed.

Creating code to handle pin modes

Memory structure

In chapter 3.21.2.21 (page 387) of the user manual, the following information can be seen:

Offset: 0x74 Register Name: PD_CFG2_REG
Bit R/W Default/Hex Description
18:16 R/W 0x7 PD20_SELECT
000: Input
001: Output
15 / / /
14:12 R/W 0x7 PD19_SELECT
000: Input
001: Output
11 / / /
10:8 R/W 0x7 PD18_SELECT
000: Input
001: Output

A 3 bit value controls pin modes in register PD_CFG2_REG. Let’s set some pin modes using the C language

The C language has a datatype called struct, which can be used to “convert” the table into the following code:

typedef struct {
    uint32_t PD_16    : 3;
    uint32_t reserved : 1;
    uint32_t PD_17    : 3;
    uint32_t reserved : 1;
    uint32_t PD_18    : 3;
    uint32_t reserved : 1;
    uint32_t PD_19    : 3;
    uint32_t reserved : 1;
    uint32_t PD_20    : 3;
    uint32_t reserved : 1;
    uint32_t PD_21    : 3;
    uint32_t reserved : 1;
    uint32_t PD_22    : 3;
    uint32_t reserved : 1;
    uint32_t PD_23    : 3;
    uint32_t reserved : 1;
} PD_CFG2_REG_t;

The struct will ensure that the right bits are set in memory, without having to set individual bits.

Memory location

In the above table, an offset of 0x74 can be seen. The memory address of this MMIO register will be PIO (0x01C20800) + 0x74. In code, this could look as follows:

#define PIO 0x01C20800
#define PD_CFG2_REG_ADDRESS (PIO + 0x74)

Altering the memory

The code to alter a piece of memory looks a bit magical, but it works quite well:

#define MODE_OUTPUT   0x1
#define MODE_DISABLED 0x7 // default

((volatile PD_CFG2_REG_t*)PD_CFG2_REG_ADDRESS)->PD18 = MODE_OUTPUT;
((volatile PD_CFG2_REG_t*)PD_CFG2_REG_ADDRESS)->PD19 = MODE_OUTPUT;
((volatile PD_CFG2_REG_t*)PD_CFG2_REG_ADDRESS)->PD20 = MODE_OUTPUT;
  • The values 0x1 and 0x7 are copied from the user manual in hexadacimal form and set the right pin mode. The user manual includes many more pin modes, but those are not used here.
  • The volatile keyword tells C that it should trust the programmer and just set the bits, as requested. Not including volatile will result in unpredictable behaviour.
  • The PD_CFG2_REG_t* part is part of a cast. C is told that whatever resides at the memory location of PD_CFG2_REG_ADDRESS is of type PD_CFG2_REG_t*.
  • So, ((volatile PD_CFG2_REG_t*)PD_CFG2_REG_ADDRESS) can be seen as a pointer to a struct. Using this method, C can directly alter memory without the need for a variable.

Turning the IO pins on or off

The method that is used to set the pin modes will be used to turn the IO pins on or off as well. The required table is found in chapter 3.21.2.23 (page 388) of the user manual and the struct and address will be as follows:

#define PIO 0x01C20800                      // same PIO address as the configure code uses
#define PD_DATA_REG_ADDRESS (PIO + 0x7C)    // this time the offset is 0x7C

typedef struct {
    uint32_t  PD_0      : 1;
    uint32_t  PD_1      : 1;
    uint32_t  PD_2      : 1;
    uint32_t  PD_3      : 1;
    uint32_t  PD_4      : 1;
    uint32_t  PD_5      : 1;
    uint32_t  PD_6      : 1;
    uint32_t  PD_7      : 1;
    uint32_t  PD_8      : 1;
    uint32_t  PD_9      : 1;
    uint32_t  PD_10     : 1;
    uint32_t  PD_11     : 1;
    uint32_t  PD_12     : 1;
    uint32_t  PD_13     : 1;
    uint32_t  PD_14     : 1;
    uint32_t  PD_15     : 1;
    uint32_t  PD_16     : 1;
    uint32_t  PD_17     : 1;
    uint32_t  PD_18     : 1;
    uint32_t  PD_19     : 1;
    uint32_t  PD_20     : 1;
    uint32_t  PD_21     : 1;
    uint32_t  PD_22     : 1;
    uint32_t  PD_23     : 1;
    uint32_t  PD_24     : 1;
    uint32_t  reserved  : 7;
} PD_DATA_REG_t;

And to then turn the IO pins on or off, very similar pointer-magic will be used:

((volatile PD_DATA_REG_t*)PD_DATA_REG_ADDRESS)->PD18 = true; // or false
((volatile PD_DATA_REG_t*)PD_DATA_REG_ADDRESS)->PD19 = true; // or false
((volatile PD_DATA_REG_t*)PD_DATA_REG_ADDRESS)->PD20 = true; // or false;

Creating a linux kernel module

A bare bones linux kernel module

All the code that is required to set pinmodes is present, but if it’s ran in userspace (even as superuser), it will result in a segfault. This is because the Linux kernel does not allow memory to be altered directly. To get around this, a kernel module will be written.

A bare bones kernel module looks like this:

// filename: mymodule.c (do not name it 'module.c'!)

#include <linux/module.h>
#include <linux/kernel.h>

static int mymodule_init(void) {
    // do initializing here
    printk(KERN_INFO "mymodule: Hello from kernel module!");
    return 0;
}

static void mymodule_exit(void) {
    // do exit stuff here
    printk(KERN_INFO "mymodule: Goodbye from kernel module!");
    return;
}

module_init(mymodule_init);
module_exit(mymodule_exit);

Make sure to not name anything module. Use a custom name like mymodule, or it might not compile.

The following Makefile will compile the kernel module:

obj-m := mymodule.o

KERNEL_VERSION = $(shell uname -r)
KERNEL_DIR     = /usr/lib/modules/$(KERNEL_VERSION)/build/

all:
	# Create the kernel module
	$(MAKE) -C $(KERNEL_DIR) M=$(PWD) modules
	# Delete every other temporary file, leaving only the .ko file in place
	`rm mymodule.mo* mymodule.o modules.order Module.symvers ./.*.cmd`

clean:
	# Delete all build files
	$(MAKE) -C $(KERNEL_DIR) M=$(PWD) clean

To then add the kernel module to the kernel, the command

sudo insmod mymodule.ko

can be issued.

After that, when the command

sudo dmesg

can be executed to see the kernel logs. The new linux module should have logged a greeting. To see it happening in realtime, the -w option can de added to the dmesg command.

To remove the kernel module from the linux kernel, the command

sudo rmmod mymodule

can be used.

A kernel module that alters memory

To alter the MMIO registers of the PinePhone, a character device kernel module will be written. It will take an array of 3 characters and turn the right bits on and off using the values of the array. It will also be able to read the current state of the memory and return it in the form of a 3 character long array. All communication will happen through /dev/mymodule, a virtual device.

All the used memory locations and structures will be put in a separate file called mmio.h. The new kernel module looks like this:

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/semaphore.h>
#include <asm/io.h>
#include <asm/uaccess.h>
#include <stdbool.h>
#include <stdbool.h>

#include "mmio.h"   // contains all the structs and addresses

#define DEVICE_NAME "mymodule"
#define MAX_BUFFER_SIZE 3

MODULE_DESCRIPTION(DEVICE_NAME);
MODULE_AUTHOR("Tom Niesse");
MODULE_LICENSE("GPLv3");

// Create variables that have to do with registering a cdev object
struct cdev* mcdev;
int major_number;
int return_value;
dev_t dev_num;

// Create a structure for a virtual device
struct virtual_device {
	char data[MAX_BUFFER_SIZE];
	struct semaphore sem;
} virtual_device;

// Create file operations structure using define because it's position
// in the module is a bit un-intuitive (in-between functions)
#define FILE_OPERATIONS_CALLBACKS \
struct file_operations fops = {\
    .owner = THIS_MODULE,\
    .open = device_open,\
    .release = device_close,\
    .write = device_write,\
    .read = device_read\
};

// Create variables that have to do with MMIO
volatile PD_CFG2_REG_t* pd_cfg2_virtual_address = 0;
volatile PD_DATA_REG_t* pd_data_virtual_address = 0;

// A user wants to read to or write from /dev/mymodule
// and opens the file
int device_open(struct inode * inode, struct file *filp) {
	if(down_interruptible(&virtual_device.sem) != 0) {
		printk(KERN_INFO "mymodule: could not lock device for reading or writing.");
		return -1;
	}
	printk(KERN_INFO "mymodule: device \"/dev/%s\" is opened for reading or writing", DEVICE_NAME);
	return 0;
}

// A user is reading from /dev/mymodule
ssize_t device_read(struct file* filp, char* buffer, size_t buffer_size, loff_t* current_offset) {
	printk(KERN_INFO "mymodule: A user is reading from \"/dev/%s\", writing information...", DEVICE_NAME);

	// Convert the booleans to characters for all colors (red and green switched)
	if(pd_data_virtual_address->PD19_DATA) {
		virtual_device.data[0] = '1';
	} else {
		virtual_device.data[0] = '0';
	}

	if(pd_data_virtual_address->PD18_DATA) {
            virtual_device.data[1] = '1';
    } else {
            virtual_device.data[1] = '0';
    }

    if(pd_data_virtual_address->PD20_DATA) {
            virtual_device.data[2] = '1';
    } else {
            virtual_device.data[2] = '0';
    }

	return_value = copy_to_user(buffer, virtual_device.data, buffer_size);
	return return_value;
}

// A user is writing to /dev/mymodule
ssize_t device_write(struct file* filp, const char* buffer, size_t buffer_size, loff_t* current_offset) {
	int pos;

	// Make sure the buffer is exactly 3 in length
	if(buffer_size != 3) {
		return 1;
	}

	printk(KERN_INFO "mymodule: A user is writing to \"/dev/%s\", reading information...", DEVICE_NAME);
	return_value = copy_from_user(virtual_device.data, buffer, buffer_size);

	// Set color red
    if(virtual_device.data[0] == '1') {
        pd_data_virtual_address->PD19_DATA = 1;
    } else {
        pd_data_virtual_address->PD19_DATA = 0;
    }

	// Set color green
    if(virtual_device.data[1] == '1') {
        pd_data_virtual_address->PD18_DATA = 1;
    } else {
        pd_data_virtual_address->PD18_DATA = 0;
    }

	// Set color blue
    if(virtual_device.data[2] == '1') {
        pd_data_virtual_address->PD20_DATA = 1;
    } else {
        pd_data_virtual_address->PD20_DATA = 0;
    }

    // Log for debugging purposes
	for(pos = 0; pos < MAX_BUFFER_SIZE; pos++) {
        printk(KERN_INFO "Setting POS = %d to value %c", pos, virtual_device.data[pos]);
	}
	return return_value;
}

// A user is done with /dev/mymodule,
// the file can be closed
int device_close(struct inode* inode, struct file* filp) {
	up(&virtual_device.sem);
	printk(KERN_INFO "mymodule: closed device \"/dev/%s\".", DEVICE_NAME);
	return 0;
}

// Place file operations callback structure here
FILE_OPERATIONS_CALLBACKS

static int mymodule_init(void) {
	// Ask for virtual addresses to control MMIO
	pd_cfg2_virtual_address = ioremap((long long unsigned int)PD_CFG2_REG_ADDRESS, sizeof(PD_CFG2_REG_t));
	pd_data_virtual_address = ioremap((long long unsigned int)PD_DATA_REG_ADDRESS, sizeof(PD_DATA_REG_t));

	// Register the /dev/mymodule connection
	return_value = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
	if(return_value < 0) {
		printk(KERN_INFO "mymodule: failed to allocate a major number. virtual device will NOT work!");
		return return_value;
	}

	major_number = MAJOR(dev_num);
	printk(KERN_INFO "mymodule: major number is %d", major_number);
	printk(KERN_INFO "use \"mknod /dev/%s c %d 0\" to create a virtual device for this module.", DEVICE_NAME, major_number);

	mcdev = cdev_alloc();
	mcdev->ops = &fops;
	mcdev->owner = THIS_MODULE;

	return_value = cdev_add(mcdev, dev_num, 1);
	if(return_value < 0) {
		printk(KERN_INFO "mymodule: could not add cdev to kernel. virtual device will NOT work!");
		return return_value;
	}

	sema_init(&virtual_device.sem, 1);

	return 0;
}

static void mymodule_exit(void) {
	// Free the MMIO virtual addresses
	iounmap((void*)pd_cfg2_virtual_address);
	iounmap((void*)pd_data_virtual_address);

	// Unregister the module
	cdev_del(mcdev);
	unregister_chrdev_region(dev_num, 1);

	printk(KERN_INFO "mymodule: module has exited. make sure to delete \"/dev/%s\"", DEVICE_NAME);
}

module_init(mymodule_init);
module_exit(mymodule_exit);

When this module is loaded in using

sudo insmod mymodule.ko

it will log how to make a virtual device. After executing that command as superuser, a virtual device called /dev/mymodule will have been created.

Communicating with the kernel module

Communication can be done using a C or C++ program that runs in userland (as superuser):

// main.c

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>

#define MAX_BUFFER_SIZE 3
#define DEVICE          "/dev/mymodule"

char buffer[MAX_BUFFER_SIZE];

int main() {
    // Perform a read operation
    int fd = open(DEVICE, O_RDWR);
    read(fd, this->buffer, MAX_BUFFER_SIZE);
    close(fd);

    // Perform a write operation
    int fd = open(DEVICE, O_RDWR);
    write(fd, this->buffer, MAX_BUFFER_SIZE);
    close(fd);

    return 0;
}

Controlling other peripherals

The built-in motor, which makes the phone vibrate when a message arrives, is also directly controlled by a MMIO register of the A64 SoC. Another kernel module can be written to enable, disable and control that motor.