11 minutes
Programming a character device driver (PinePhone and Linux)
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:
- 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
and0x7
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 includingvolatile
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 ofPD_CFG2_REG_ADDRESS
is of typePD_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.