Skip to content

A Practical Guide to Zephyr Device Driver Development

This is a guide on how to write a production grade device driver in Zephyr. This includes driver code, testing, CI and many more. Do not consider this document as a tutorial to Zephyr or device drivers, I'll just showcase my workflow and the environment I use to develop device drivers in Zephyr. Some of this may not be suited for your workflow, but I'll try to keep each section on it's own so you can skip the parts you don't need or don't want to use.

I'll not talk about some details and assume you have at least heard about Zephyr and device drivers. If you don't, or want to refresh your memory check out the official documentation. More of the talk will assume you were already developing on embedded projects and want to try something new, or you are already developing on Zephyr and want to see how others do it.

Note

Zephyr still makes big changes to the framework, so some of the information here might be outdated or not the best practice anymore. I'll try to keep this up to date as much as possible, and give version information anywhere I can. Also I'll run inside a development container for you readers to easily follow along.

Introduction

I've known about Zephyr for a while now but only had the chance to work with it by the beginning of this year when I listened the this Amp Hour podcast. I've quickly come to realize the enormous benefits of using Zephyr for any kind of embedded project. From full blown RTOS running on x86 with network stacks and PCIe drivers to a microcontroller with 32KB of flash, Zephyr is a great choice for any project. It was surreal to edit devicetree files to configure a 50-cent microcontroller.

Zephyr gives you a nice framework for everything embedded. You have device trees, device drivers, networking stacks, filesystems, a testing framework, a nice RTOS, a nice build system and anything else you can name. It abstracts low level hardware so nicely that you only need to write what your application ultimately wants to do in a hardware-agnostic way and have it run on anything without touching a single line of code.

I especially liked the Zephyr because I was working on a range of similar products with an unorganized and distributed codebase, we needed to run our business logic on slightly different hardware platforms, so the device driver model and device trees was the perfect solution in this case. We initially got concerned about the size of the firmware as we were on very tight budget, we could only get 32KB and 64KB flash microcontrollers, and Zephyr seemed overkill for this, but it was not. It seems like the Zephyr team has a lot of experience in the embedded world and Linux in general, so they've made the right choice of making the entire thing customizable down to the detail. If you strip down everything you can get down to a ~3KB binary, which is amazing. But most of the time you want to use OS services and other features so you end up with a ~7KB-10KB binary for a minimal application.

Most of the drivers are already in the Zephyr upstream, but you eventually have to write your own device drivers for your custom hardware. That's what I ended up doing, writing some device drivers, generating my custom board configurations and device trees, and wrote my application logic, and here I'll show you how I did it.

Prerequisites and Setup

First of all we need an environment to work in. In this environment we'll have all the tools we need to develop, test and debug our device driver. I usually prefer working in docker containers for reproducibility and ease of deployment. Therefore you need some tools installed in your host to get started.

Note

I'm running on a x86 Windows machine, so I can use things like USBIP to debug my hardware connected to my host but this feature is not yet available in macOS, but we will also discuss about remote debugging in later sections.

I assume most of you already have those installed, so lets quickly get started.

Setup

If you are using VSCode, you can use devcontainers feature to get your development environment up and running in no time. But you can also use any other editor or a terminal to do everything I'll show you here.

If you want to work on your host machine, you can check out the official Zephyr documentation to setup your environment.

The development container is based on official zephyr-build container in Zephyr's docker-image repository. I've added extra configurations to really integrate it into VSCode with extensions and tools. But basically it has:

  • Zephyr SDK (0.16.8)
  • All the system packages needed for Zephyr
  • Python3 and pip packages (including west)
  • Oh My Zsh
  • VSCode Extensions

Check out the devcontainer.json if you want to see the exact setup. To keep it compatible with non-vscode users, I kept the configuration minimal. I highly encourage you to modify the file to your needs. There are tons of customizations you can do with devcontainers.

Using the container

The fastest way to start is to pull and run the image I prepared from my GitHub Container Registry with the following command:

docker run -it --name zephyr-dev -v zephyr-volume:/workdir ghcr.io/arifbalik/zephyr-dev:latest

This should drop you into a shell with the Zephyr SDK and all the tools installed.

VSCode Remote Containers

Easiest method to work in a container is to use the Remote - Containers extension in VSCode. You can open the project folder in VSCode and click on the blue icon in the bottom left corner or with ctrl + shift + p and select Attach to Running Container... and select the container you just started.

Building the container (optional)

The building requires additional tools like node, npm and devcontainers package:

First install the package;

npm install -g @devcontainers/cli

Then build the container with the following command:

devcontainer up --config /path/to/devcontainer.json --workspace-folder /path/to/your/workspace

Getting Started

Now that we have a system we can build on. We'll use the west tool to get the zephyr repository and start implementing.

West Workspaces

Assuming we want to contribute to the open-source project, we will develop inside the Zephyr source, so the T1 topology is what will be used here, but personally I use free standing application workspaces for professional work, T3 topology to be more specific.

Zephyr calls its workspaces west workspaces. And there are 3 main workspace types:

  • Zephyr source as a workspace (T1)
  • Zephyr application as a workspace (T2)
  • Custom workspace with multiple Zephyr applications (you guessed it..)

More information on them can be found here.

Inside the container, we can start by initializing a workspace with the following command:

west init

This will create a .west directory and clone zephyr repository into the /workdir which is mounted as volume and its persistent. What also is required is the Zephyr modules and tools, update the workspace with the following command:

west update

This will fetch all source code that is outside of the Zephyr repository but needed to build an application, like the hal_stm32 and mcuboot repositories.

Warning

Zephyr version is v3.7.0 at the time of writing this document and it has a a bug that has been patched by me but is not in the v3.7.0, therefore if you want to be where I was when this document is written, check out to the 10fa1eab50ac54e41bf75ab0ad01daa4bd25ba13 hash of the zephyr repository, inside zephyr/west.yml file, or apply the patch yourself.

This may take some time and it will load a ton of modules we won't need, therefore I highly recommend using a T2 or T3 topology for your projects. Once the update is done, a similar file structure should appear in the workspace;

.
├── bootloader
├── modules
├── tools
├── .west
└── zephyr

Device Driver Development

STM32U083C Development Kit

Recently I had my hands on a devkit for STMicroelectronic's U0 series microcontrollers, and one peripheral I was interested was the TSC peripheral in this MCU. This is a peripheral used for capacitive touch sensing applications, buttons, sliders and things like that can be implemented with this peripheral (Notice the touch surface in the bottom right corner, it is a bare electrode and a 1mm insulating surface on top) You can implement a touch sensing application by using a PWM and an ADC maybe with comparator but this peripheral is very simple and does all that and a little bit more and offloads the CPU.

Conveniently Zephyr does not have a driver for this peripheral. So I will write one here.

Understanding the Hardware

This section goes a little bit into the details of the hardware we are working with. If you are not interested in this part or you already know the terminology you can skip to the next section.

If you haven't worked with a capacitive touch sensing application before, allow me to tell you that it is not as easy as it seems when integrating touch sensing into a product end-to-end. Especially if you have stricter requirements for your application, like electrically noisy environments, or any industrial spec really immediately starts to complicate things and EMC becomes a big issue.

A capacitive touch point is usually just a piece of conductive material, like a PCB pad. This piece of pad is leveraged to create a capacitive coupling with the human body, so you have to make a sandwich of these two conductive surfaces, (often a plastic is always in between the finger and the plate) forming a capacitor.

Then this means we can charge the capacitor and do funky things with it, for example we can say that the capacitance changes when a finger is present than when it is not, and this can be used to detect a touch.

Of course this is just one very specific setup with a single ended electrode, there are many setups and ways to do this, what we will specifically talk here is called Surface Charge Transfer (SCT) method, which is what this peripheral does.

Surface Charge Transfer

Electrode

The dotted capacitor in the figure above represents the capacitor of the electrode we just made with a finder and a conductive patch with an insulating layer in between.

This method does not continuously check the voltage of this electrode, instead an external capacitor (in ranges from 1pf to couple hundreds of pf) called sampling capactior is used to transfer some charge in the electrode to this new capacitor (Cs in the figure above), this makes the readings much more repeatable and reliable.

ST TSC in particular calls this combo a group and gives each group two extra channels for more custom applications. Each group has to have 1 sampling capacitor channel and at least one IO channel to function properly. These IO channels do not produce a value each, instead each channel is used to charge the same sampling capacitor, so a group will produce only one count value no matter the IO channel count. ST gives the example of a crude proximity sensor by placing two or more electrodes near each other.

SCT Method

This charge transfer duration is pretty small (and of course programmable), so it does not fill the sampling capacitor right away, the peripheral needs to perform many of these charge transfer cycles and count it. Counting is stopped when this count reaches a certain threshold, which the peripheral then throws a max count error interrupt, or the voltage on this charged sample capacitor is reached a voltage level (again, programmable) before the max count is reached, peripheral then throws an end of acquisition interrupt which than you can read and find out how many cycles it took to reach this voltage level. And this will directly be correlated to the capacitance of the electrode, which is directly correlated to the presence of a finger.

STM32 TSC SCT Cycle

As I mentioned above touch sensing get really messy in noisy environments, so running these cycles and very fast at a fixed interval would not be very wise because the signal would pick up harmonics of the noise and you would get false positives and whatnot. Therefore the peripheral has a feature called spread spectrum which adds variation to charge transfer cycles.

This is a very smart and robust way of detecting a touch. Above you can see the full cycle. I got this image and most of all information about this hardware from the reference manual RM0503 of the STM32U0 series microcontrollers.

Registers and Configurations

After we understand the Surface Charge Transfer method we can start to see what can we configure. Functional diagrams is a good way to start.

STM32 TSC Functional Diagram

We can see that we have two different sections which can be clocked at different speeds, one is for generating the charge pulses and other is for spread spectrum which generates a value that feeds into the pulse generator. Something we did not talked about yet is SYNC signal, which is an external signal which triggers pulse generator to start acquisition in addition to the software trigger the driver will use.

Pulse generator commands the IO, setting their modes and function while the acquisition is running and it will store the group results in separate 14-bit values in IOGXCR (IO Group X Counter Register).

Reset and Clock Configuration

This is a memory mapped peripheral like most all peripherals in STM32 microcontrollers. And often the first thing to do is to reset the peripheral (optional) and enable it. For STM32 MCU's the RCC (Reset and Clock Controller) peripheral is able to do this, so first peripheral to work with is the RCC.

The TSC peripheral gets it's clock from the AHB clock, we expect this clock to be initialized by other drivers, what this driver has to do is to set some bits to perform reset and enable operations. Reference manual gives all the information we would need.

RCC AHBRSTR (RCC AHB Reset Register Bits 24)

Bit 31:25 24 23:0
Name - TSCRST -
Desc. - Touch sensing controller reset -
Values - 0: No effect
1: Reset TSC
-

RCC AHBENR (RCC AHB Enable Register Bits 24)

Bit 31:25 24 23:0
Name - TSCEN -
Desc. - Touch sensing controller clock enable -
Values - 0: TSC clock disabled
1: TSC clock enabled
-

As you can see ST conveniently named the registers and put the relevant bits in the same position for both registers (24th bit). So this means whenever we write 1 to the 24th bit of the RCC register base address + register_offset (0x28 for reset and 0x48 for enable) we can reset and enable the peripheral.

GPIO Configuration

This is the second peripheral before we get to the TSC peripheral. The pins we will use for the TSC should be configured through the GPIO peripheral. Thankfully Zephyr provides a good subsystem and abstraction for this which we will see later.

There is also some registers in the TSC peripheral that we need to configure. First of all we have to specify which channels are used for what, and which groups are enabled. We will use different registers for each of these settings.

When no acquisition is ongoing, the state of the IO channels are in default mode. And TSC peripheral offers an option to set the default mode of the IO channels. This can be floating, push-pull-low. The register for this is CR (Control Register) and the bit position is 4;

TSC CR (TSC Control Register Bit 4)

Bit 31:5 4 3:0
Name - IODEF -
Desc. - IO default mode -
Values - 0: Push-pull-low
1: Floating
-

We use IOCCR (IO Channel Control Register) and IOSCR (IO Sampling Control Register) registers for electrodes and sampling capacitors respectively.

TSC IOCCR (TSC IO Channel Control Register)

Bit 31:28 27:0
Name - Gx_IOy
Desc. - IO channel y of group x
Values - 0: Disabled
1: IO used as channel mode

TSC IOSCR (TSC IO Sampling Control Register)

Bit 31:28 27:0
Name - Gx_IOy
Desc. - IO channel y of group x
Values - 0: Disabled
1: IO sampling mode

And finally IOGCSR (IO Group Control Status Register) to enable the group.

TSC IOGCSR (TSC IO Group Control Status Register)

Bit 31:7 6:0
Name - GxS
Desc. - Analog I/O group x enable
Values - 0: Disabled
1: Enabled

TSC Clock Prescaler Configuration

The clock enters the TSC IP from the AHB clock, this clock goes to many other peripherals so it is not a good idea to change the AHB clock rate for the TSC peripheral as the first and only thing. Instead we have a clock pre-scaler value to divide clock further before it enters the TSC. User manuals tells us the location of this register that controls this.

TSC CR (TSC Control Register Bits 15 and 14:12)

Bit 15 14:12
Name SSPSC PGPSC[2:0]
Desc. Spread spectrum prescaler Pulse generator prescaler
Values 0: fHCLK
1: fHCLK /2
000: fHCLK
...
111: fHCLK /128

There are two clock settings

  • PGPSC (Pulse Generator Prescaler) and it is a 3-bit value, and each represents a different division factor.adjust the clock rate for the TSC peripheral.
  • SSPSC (Spread Spectrum Prescaler) and it is a 1-bit value. 0: AHB/1 and 1: AHB/2.

Defining the clock finally gives us the full cycle duration for one charge transfer cycle. In U0 series this can be from 500ns to 2us.

TSC Pulse Generator Configuration

This section is the heart of the peripheral configuration and there are a few settings we can adjust;

  • Charging duration
  • Transfer Duration
  • Max Cycle Count

TSC CR (TSC Control Register Bits 31:28, 27:24 and 7:5)

Bit 31:28 27:24 7:5
Name CTPH[3:0] CTPL[3:0] MCV[2:0]
Desc. Charge transfer pulse high Charge transfer pulse low Max count value
Values 0000: 1x tPGCLK
...
1111: 16x tPGCLK
0000: 1x tPGCLK
...
1111: 16x tPGCLK
000: 255
001: 511
...
110: 16383

Charging and transfer durations are denoted as CTPH (Charge Transfer Pulse High) and CTPL (Charge Transfer Pulse Low) respectively. These are 4-bit values and can be adjusted from 1 to 16 clock cycles. These values are used to charge the external capacitor and transfer the charge to the sample capacitor.

Max cycle count is a 3-bit value and each represents a different count value. This is a safety feature where TSC can not fill the capacitor enough to reach the voltage threshold, so it stops the cycle and throws an interrupt.

TSC Spread Spectrum Configuration

TSC CR (TSC Control Register Bits 23:17 and 16)

Bit 23:17 16
Name SSD[6:0] SSE
Desc. Spread spectrum deviation Spread spectrum enable
Values 0000000: 1x tSSCLK
...
1111111: 128x tSSCLK
0: Disabled
1: Enabled

Spread spectrum has 9-bits for configuration in the CR register. The pre-scaler bit is already set, so rest if a 1-bit enable and 7-bit deviation value.The deviation value represents the maximum deviation of the charge transfer pulse in spread spectrum clock cycles.

TSC SYNC Configuration

There are only two bits related to this setting, one is for selecting the polarity and the other is for enabling the SYNC signal.

TSC CR (TSC Control Register Bits 4 and 3)

Bit 3 2
Name SYNCPOL SYNC Enable
Desc. Synchronization pin polarity Synchronization enable
Values 0: Falling edge only
1: Rising edge and high level
0: Disabled
1: Enabled

TSC Interrupts

There are only 2 interrupts, EOAF (End of Acquisition Flag) and MCEF (Max Count Error Flag). These are set when the voltage threshold is reached or the max cycle count is reached respectively. They are enabled through a different register called IER (Interrupt Enable Register).

TSC IER (TSC Interrupt Enable Register Bits 1 and 0)

Bit 31:2 1 0
Name - MCEIE EOAIE
Desc. - Max count error interrupt enable End of acquisition interrupt enable
Values - 0: Disabled
1: Enabled
0: Disabled
1: Enabled

Note

There is also a setting to adjust the voltage threshold for the end of acquisition, but this is done using a different comparator peripheral, for the sake of simplicity we will not use this feature.

How to Read the Count Value

Before we start reading the START bit in the CR register should be set to start the acquisition. After the acquisition is done, the peripheral will throw an interrupt or we can poll the EOAF bit in the ISR register to check if the acquisition is done. Then we can read the status of each group with IOGSR and see if that group has finished the acquisition. If it has we can read the count value from the IOGXCR register.

TSC CR (TSC Control Register Bits 0)

Bit 31:1 0
Name - START
Desc. - Start acquisition
Values - 0: No effect
1: Start acquisition

TSC ISR (TSC Interrupt Status Register Bits 1 and 0)

Bit 31:2 1 0
Name - MCEF EOAF
Desc. - Max count error flag End of acquisition flag
Values - 0: No error
1: Error
0: No end of acquisition
1: End of acquisition

TSC IOGCSR (TSC IO Group Control Status Register)

31:23 22:16 15:0
Name - GxS -
Desc. - Analog I/O group x status -
Values - 0: Ongoing
1: Complete
-

TSC IOGXCR (TSC IO Group X Counter Register)

Bit 31:14 13:0
Name - CT[13:0]
Desc. - Counter value

There are some other minor settings which I will not go into detail because it does not concern the driver implementation. But here is the complete map of the TSC peripheral registers.

STM32U0 TSC Peripheral Registers

STM32U0 TSC Registers

Hardware Configuration

STM32 TSC SCH

The development kit schematic shows the implementation is for one electrode (TKEY) and one sampling capacitor (TKEY_CS), so we will stick with that.

Notice how they actually used an extra group for another electrode and called it shield and connected to the end of the electrode to the ground. This is a trick often used to further reduce EMI and noise. Only difference this has over an electrode is this is not selected for acqusition. This is not related to the driver implementation so I will not go into detail.

The pins and their alternate functions are as follows:

GPIO Pin GPIO Alternate Function TSC Group TSC IO Number Function
PD10 AF9 6 1 TKey CS
PD11 AF9 6 2 TKey
PB12 AF9 1 1 Shield Electrode
PB13 AF9 1 2 Shield Electrode Sampling Capacitor

I talk more about this in the next section, but this is the hardware we will work with.

Device Tree Nodes and Bindings

One of the first things to do is to create a device tree binding to describe our hardware before the implementation starts. The concept of device trees are not new and is used in Linux for many years, so it may be familiar to some of you, but for those who are unfamiliar, it is a JSON-like file that describes a hardware node in a system called device-tree, this allows for decoupling the hardware from the source code so that software may run independent of the hardware it is running on, meaning the API won't change if you change the hardware.

To know more about device trees check out the official documentation.

To sketch a design lets thinker about a node for the hardware above. This is an attempt to create a definition that will contain all hardware related information in a single node for this peripheral.

tsc : tsc@40024000 {
  compatible = "st,stm32-tsc";
  reg = <0x40024000 0x400>;
  clocks = <&rcc STM32_CLOCK_BUS_AHB1 0x01000000>;
  resets = <&rctl STM32_RESET(AHB1, 24U)>;
  interrupts = <21 0>;
};

This simple node seems pretty self-explanatory, it has the most generic properties common to almost all ST -or ARM really- compatible peripherals, a register space, clock, reset and interrupt. The node describes a TSC node with the label tsc and at the address 0x40024000 which corresponds to the beginning of address space of the TSC module in STM32U0x series microcontrollers, this is the most fundamental information we should have about a memory-mapped io. We can see this information is encoded twice (both in definition with @ symbol and with reg property). We can also see the entire memory mapped region is 1KB (0x400), it's clock is connected to the AHB1 (24th bit 0x01000000 of the AHB control register), also it has a reset line again at the bit position 24 in the corresponding reset controller control register (created with STM32_RESET macro). In the vector table the position offset of its interrupt is 21 and has 0 priority. Another very important property is the compatible property, this is a string that describes the driver that will handle this node, in other words, this node information will be passed to a driver that declares itself as st,stm32-tsc compatible. Very neat!

A node binding is any property of a node, described in a file, a yaml file in our case, so to make our st,stm32-tsc node work we need to create a binding file for it, so that we can enforce out properties and rules about those properties. And there are strict rules to follow about the location of these files and the naming conventions. The binding files must be in the dts/bindings directory of workspace or application root, board directories or in modules. We will create our own in the workspace root.

The naming convention for the binding files are usually vendor,driver.yaml (vendor is optional for generic drivers) and usually they reside in subfolders for better organization. So we will create ours in misc folder and put the binding there.

And create the st,stm32-tsc.yaml file with the following content:

# zephyr/dts/bindings/misc/st,stm32-tsc.yaml

description: STM32 Tocuh Sensing Controller (TSC) driver

compatible: "st,stm32-tsc"

include: [base.yaml, reset-device.yaml]

properties:
  reg:
    required: true
  clocks:
    required: true
  resets:
    required: true
  interrupts:
    required: true

as you can tell from the file, all the properties are included from other files Zephyr provides for us, this file only makes some of them required. Their definition can be seen in one of these included files; base.yaml

# zephyr/dts/bindings/base/base.yaml
properties:
  compatible:
    type: string-array
    required: true
    description: compatible strings

  reg:
    type: array
    description: register space

  interrupts:
    type: array
    description: interrupts for device

  ...

This binding file will make sure we have the properties we need, and they are parsed correctly for the relevant hardware node with the matching compatible property.

Again these documents are quite verbose and easy to read and maintain, so that's good. They can also get pretty messy if you don't do it right, so please follow the official documentation for more information.

Of course up to this point we only included and set up bindings for the information to enable the peripheral (reg, clocks, resets and interrupts), but we also have to configure it, which requires additional bindings. We went into detail in the section Understanding the Hardware so I will not go into detail here, but basically we will need the following information to set up a TSC peripheral:

  • IO pins for the electrodes and sampling capacitors
  • Pulse generator prescaler value
  • Spread spectrum prescaler, deviation and enable
  • Charge transfer pulse high and low values
  • Max count value
  • SYNC signal polarity and enable

Pin Control

We discussed how configuring the physical pins is a job of another peripheral, pintcrl-device.yaml provides some bindings to allow generic nodes to provide pin configuration information. It is called pinctrl-X where X is the number of the pin group. Take a look at the property;

# zephyr/dts/bindings/pinctrl-device.yaml
pinctrl-0:
    type: phandles
    description: |
      Pin configuration/s for the first state. Content is specific to the
      selected pin controller driver implementation.

...

pinctrl-x:
    type: phandles
    description: |
      Pin configuration/s for the x-th state. Content is specific to the
      selected pin controller driver implementation.
pinctrl-names:
    type: string-array
    description: |
      Names for the provided states. The number of names needs to match the
      number of states.

A pin configuration can change based on whether the device is running or in sleep mode to preserve power. So we can have multiple states for the pin configuration, separated by the pinctrl-names property. It usually is default and sleep but can be anything. The phandles property is just a reference to another node, so we can point to other nodes which will include the pin information, defined somewhere else.

To have this binding required in our st,stm32-tsc.yaml file we just need to add the following lines:

properties:
  ...
  pinctrl-0:
    required: true
  pinctrl-names:
    required: true

The use of this binding in the driver implementation will be discussed in the next sections. But the following snippet can give you a glimpse of how it can be used in a device tree node.;

&node {
  pinctrl-0 = <&pin_d12_af9>;
  pinctrl-names = "default";
};

&pinctrl {
  pin_d12_af9: pinconfig1 {
    pinmux = <STM32_PINMUX('D', 12, AF9)>;
    drive-open-drain;
  };
};

The & symbol works very similar to a pointer in C, in this example we are referencing already existing nodes (through labels; node-label: node-name) and altering them (node and pinctrl) in the device tree. STM32_PINMUX is just a C macro and literally coming from an included header file. We can supply the pin, its function and additional properties like push-pull or open-drain. And passing the definition of these pinctrl nodes to the node node will make sure the pins are configured correctly when the driver is loaded.

So based on this let's define the pins and their functions for this development kit. Schematic tells us there is two pins used in this board, one is a shield electrode and the other is the touch electrode itself, of course each with their own sampling capacitor pins. 4 in total. These pins are also belong to a group, ST choose group 1 and group 6 for these pins and they nicely provide a table for the pins in the UM3292 user manual. I also talked about them in the Hardware Configuration section.

Also In the reference manual we can get the information about which alternate function is used for the TSC group configuration for that specific pin, conveniently all of them are AF9 for these pins. So we can define them in the device tree as follows;

&pinctrl {
  tsc_shield_pb12: tsc_shield_pb12 {
    pinmux = <STM32_PINMUX('D', 12, AF9)>;
    drive-open-drain;
  };
  tsc_shield_cs_pb13: tsc_shield_cs_pb13 {
    pinmux = <STM32_PINMUX('D', 13, AF9)>;
  };
  tsc_g6_io1_pd10: tsc_g6_io1_pd10 {
    pinmux = <STM32_PINMUX('D', 10, AF9)>; // TKey CS pin
    drive-open-drain;
  };
  tsc_g6_io2_pd11: tsc_g6_io2_pd11 {
    pinmux = <STM32_PINMUX('D', 11, AF9)>; // TKey Pin
  };
};

And later we will provide these pins to the TSC node in the device tree;

&tsc {
  pinctrl-0 = <&tsc_shield_pb12 &tsc_shield_cs_pb13 &tsc_g6_io1_pd10 &tsc_g6_io2_pd11>;
  pinctrl-names = "default";
};

and use this information to set up the GPIO pins in the driver implementation.

It is important to define these pins inside the pinctrl parent node so we can use the bindings like drive-open-drain and pinmux in the child nodes. This node is provided by your vendor in the soc device tree file, this file and many other will be included in the final device tree file, so many overlays can be used to configure the hardware, SOC DTS files > Board DTS files > Application DTS files > Final DTS file.

Other Bindings

Now we only deal with bindings with basic types, like int, enum, string and boolean.

Pulse Generator Prescaler

The pulse generator prescaler is a 3-bit value as discussed in the Registers and Configurations section, so it can be defined as an enum in the binding file.

pulse-generator-prescaler:
    type: int
    default: 2
    description: |
      Prescaler for the pulse generator clock (t_pgclk=f_hclk/prescaler).
      The prescaler is used to generate the charge transfer pulse.
      0: f_hclk/1
      1: f_hclk/2
      2: f_hclk/4
      3: f_hclk/8
      4: f_hclk/16
      5: f_hclk/32
      6: f_hclk/64
      7: f_hclk/128
    enum: [0, 1, 2, 3, 4, 5, 6, 7]
Charge Transfer Pulse High and Low

These are 4-bit values, this too can be defined as integer enum.

  ctph:
    type: int
    default: 2
    description: |
      Number of cycles for the high state of the 
      charge transfer pulse (1 to 16 cycles of t_pgclk).
    enum: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]

  ctpl:
    type: int
    default: 2
    description: |
      Number of cycles for the low state of the 
      charge transfer pulse (1 to 16 cycles of t_pgclk).
    enum: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
Max Count Value

This is a 3-bit value, we can define it as an integer enum.

max-count-value:
    type: int
    default: 5
    description: |
      Max number of charge transfer pulses before max count error is generated.
      0: 255
      1: 511
      2: 1023
      3: 2047
      4: 4095
      5: 8191
      6: 16383
      7: 32767
    enum: [0, 1, 2, 3, 4, 5, 6, 7]
Spread Spectrum

The spread spectrum prescaler is a 1-bit value, it should be easy to define.

spread-spectrum-prescaler:
    type: int
    description: Spread spectrum clock prescaler (t_ssclk)
    enum:
      - 1
      - 2

and to enable it we can define a boolean property.

  spread-spectrum:
    type: boolean
    description: Spread spectrum enable

These boolean properties are easy to use;

&tsc {
  spread-spectrum;
  spread-spectrum-prescaler = <2>;
};

We also need to define the deviation value for the spread spectrum, it can have a value from 0 to 127. But for convenience we will start from 1 and offset it in the driver implementation.

spread-spectrum-deviation:
    type: int
    default: 1
    description: Spread spectrum deviation (1 to 128 cycles of t_ssclk)
SYNC Signal

We have two properties for the SYNC signal, one is for the polarity and the other is for the enable.

synced-acquisition:
    type: boolean
    description: |
      Synchronized acquisition enable. Acquisition starts when START bit and signal on sync pin.
      You have to provide a pinctrl for the sync pin.

  syncpol-rising:
    type: boolean
    description: Rising synchronization pin polarity, instead of falling

I choose to use a boolean property for the polarity it could also be a string enum, but I think this is more readable.

IO Default Mode

IO mode is 1-bit value, it can be either floating or driven low (default).

  iodef-float:
    type: boolean
    description: |
      I/Os are floating when not used for acquisition.
      If not set, I/Os are driven low.

Child Nodes and Bindings

We talked about the group logic in the Understanding the Hardware section. Once we enable the TSC peripheral, we should also enable groups and channels within those groups for acquisition. Device tree offers a way to describe these as child nodes of the TSC node. Lets say we can define a group node as follows;

&tsc {
  group1 {
    group = <1>;
    use-as-shield;****
    channel-ios = <2>;
    sampling-io = <1>;
  };

  group6 {
    group = <6>;
    channel-ios = <2>;
    sampling-io = <1>;
  };
};

So this nodes inside the tsc node are called child nodes, and in our case they give us information about the group index, channel and sampling io pin bits and one more property to use the group as shield (basically configure the group but do not acquire a value from it). We can define these nodes in the binding file as follows;

# zephyr/dts/bindings/misc/st,stm32-tsc.yaml
properties:
  ...

child-binding:
  description: STM32 TSC group configuration
  properties:
    group:
      type: int
      required: true
      description: Group number (0 to 7)
      enum: [0, 1, 2, 3, 4, 5, 6, 7]

    channel-ios:
      type: int
      description: Channel I/Os to be enabled
      enum: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]

    sampling-io:
      type: int
      required: true
      description: Channel to be selected for sampling
      enum: [1, 2, 4, 8]

    use-as-shield:
      type: boolean
      description: |
        Use channel as shield. This configures group but 
        does not enable it for acqusition. channel-io is used 
        as shield pin and can only have values 1, 2, 4 or 8.

This information unfortunately can not be extracted from the pin information, so child nodes like these are required to configure and acquire data from the TSC peripheral.

Final Binding File and Device Tree Nodes

For the device tree, we will have two definitions in two different files. The first definition is the core information about the peripheral, and is the same for all U0 series microcontrollers. Therefore it has to be inside the main stm32u0.dtsi file which all stm32u0 based boards include (device tree files can be nested and included in each other).

This file already exists in upstream Zephyr, under dts/arm/st/u0/stm32u0.dtsi, we just need to modify it.

# zephyr/dts/arm/st/u0/stm32u0.dtsi

soc {
  tsc: tsc@40024000 {
    compatible = "st,stm32-tsc";
    reg = <0x40024000 0x400>;
    clocks = <&rcc STM32_CLOCK_BUS_AHB1 0x01000000>;
    resets = <&rctl STM32_RESET(AHB1, 24U)>;
    interrupts = <21 0>;
    interrupt-names = "global";
    status = "disabled";
    };
};

We define the node and let it be disabled by default so our driver wont be compiled into the application until status is set to okay.

The second definition is in the development kit specific file, stm32u083c-dk.dts in our case, this file again is defined in the upstream Zephyr, under boards/st/stm32u083c_dk/stm32u083c_dk.dts. This file will contain the information about the pins and the groups we will use in the TSC peripheral along with the pin configuration.

Note

You can also define these in the device tree overlay file in your application, and avoid modifying files inside Zephyr source, but since we already have a demo board definition we will use that.

If you want to use an overlay it would look something like this;

include "...";
&soc {
  tsc: tsc@40024000 {
    status = "okay";
    clocks = <&rcc STM32_CLOCK_BUS_AHB1 0x01000000>;
    resets = <&rctl STM32_RESET(AHB1, 24U)>;
    interrupts = <21 0>;
    pinctrl-0 = <&tsc_shield_pb12 &tsc_shield_cs_pb13 &tsc_g6_io1_pd10 &tsc_g6_io2_pd11>;
    pinctrl-names = "default";
    spread-spectrum;
    spread-spectrum-prescaler = <2>;
    spread-spectrum-deviation = <100>;
    g1 {
      group = <1>;
      use-as-shield;
      channel-ios = <2>;
      sampling-io = <1>;
    };
    g6 {
      group = <6>;
      channel-ios = <2>;
      sampling-io = <1>;
    };
  };
};

&pinctrl {
   tsc_shield_pb12: tsc_shield_pb12 {
    pinmux = <STM32_PINMUX('D', 12, AF9)>;
    drive-open-drain;
  };
   tsc_shield_cs_pb13: tsc_shield_cs_pb13 {
    pinmux = <STM32_PINMUX('D', 13, AF9)>;
  };
   tsc_g6_io1_pd10: tsc_g6_io1_pd10 {
    pinmux = <STM32_PINMUX('D', 10, AF9)>; // TKey CS pin
    drive-open-drain;
  };
   tsc_g6_io2_pd11: tsc_g6_io2_pd11 {
    pinmux = <STM32_PINMUX('D', 11, AF9)>; // TKey Pin
  };
};
# zephyr/boards/st/stm32u083c_dk/stm32u083c_dk.dts

#include <zephyr/dt-bindings/misc/stm32-tsc-defines.h> 

&tsc {
  status = "disabled";
  pinctrl-0 = <&tsc_shield_pb12 &tsc_shield_cs_pb13 &tsc_g6_io1_pd10 &tsc_g6_io2_pd11>;
  pinctrl-names = "default";
  spread-spectrum;
  spread-spectrum-prescaler = <2>;
  spread-spectrum-deviation = <100>;

  g1 {
    group = <1>;
    use-as-shield;
    channel-ios = <2>;
    sampling-io = <1>;
  };

  g6 {
    group = <6>;
    channel-ios = <2>;
    sampling-io = <1>;
  };
};

&pinctrl {
   tsc_shield_pb12: tsc_shield_pb12 {
    pinmux = <STM32_PINMUX('D', 12, AF9)>;
    drive-open-drain;
  };
   tsc_shield_cs_pb13: tsc_shield_cs_pb13 {
    pinmux = <STM32_PINMUX('D', 13, AF9)>;
  };
   tsc_g6_io1_pd10: tsc_g6_io1_pd10 {
    pinmux = <STM32_PINMUX('D', 10, AF9)>; // TKey CS pin
    drive-open-drain;
  };
   tsc_g6_io2_pd11: tsc_g6_io2_pd11 {
    pinmux = <STM32_PINMUX('D', 11, AF9)>; // TKey Pin
  };
};

The peripheral and the groups are defined as child nodes of the TSC node. Pins are supplied and spread spectrum feature is enabled and configured. The status property is set to disabled by default, users will be able to enable this in application overlay file.

Notice how this information could only be given in the development kit specific file instead of the soc definition, because the pins are different in every board. And other bindings are also application specific.

When device tree compiler compiles all the device tree files into one final device tree, we get the following node;

/ {
  soc {
    pinctrl {
      ...
      tsc_shield_pb12: tsc_shield_pb12 {
        pinmux = < 0x789 >;
        drive-open-drain;
        phandle = < 0x10 >;
      };
      tsc_shield_cs_pb13: tsc_shield_cs_pb13 {
        pinmux = < 0x7a9 >;
        phandle = < 0x11 >;
      };
      tsc_g6_io1_pd10: tsc_g6_io1_pd10 {
        pinmux = < 0x749 >;
        drive-open-drain;
        phandle = < 0x12 >;
      };
      tsc_g6_io2_pd11: tsc_g6_io2_pd11 {
        pinmux = < 0x769 >;
        phandle = < 0x13 >;
      };
    };
    tsc: tsc@40024000 {
      compatible = "st,stm32-tsc";
      reg = < 0x40024000 0x400 >;
      clocks = < &rcc 0x48 0x1000000 >;
      resets = < &rctl 0x518 >;
      interrupts = < 0x15 0x0 >;
      status = "disabled";
      pinctrl-0 = < &tsc_shield_pb12 &tsc_shield_cs_pb13 &tsc_g6_io1_pd10 &tsc_g6_io2_pd11 >;
      pinctrl-names = "default";
      spread-spectrum;
      spread-spectrum-prescaler = < 0x2 >;
      spread-spectrum-deviation = < 0x64 >;
      g1 {
        group = < 0x1 >;
        use-as-shield;
        channel-ios = < 0x2 >;
        sampling-io = < 0x1 >;
      };
      g6 {
        group = < 0x6 >;
        channel-ios = < 0x2 >;
        sampling-io = < 0x1 >;
      };
    };
  }
}

All files included and all macros expanded, this is the final device tree node that will be converted to a header file (instead of a binary blob used in Linux) containing all this information as C defines, so they will take up no space if not used. This generated header file can not be used directly as it is pretty verbose and hard to read;

// devicetree_generated.h

...

/*
 * Devicetree node: /soc/tsc@40024000
 *
 * Node identifier: DT_N_S_soc_S_tsc_40024000
 *
 * Binding (compatible = st,stm32-tsc):
 *   /workdir/zephyr/dts/bindings/misc/st,stm32-tsc.yaml
 *
 * (Descriptions have moved to the Devicetree Bindings Index
 * in the documentation.)
 */

/* Node's full path: */
#define DT_N_S_soc_S_tsc_40024000_PATH "/soc/tsc@40024000"

/* Node's name with unit-address: */
#define DT_N_S_soc_S_tsc_40024000_FULL_NAME "tsc@40024000"

/* Node parent (/soc) identifier: */
#define DT_N_S_soc_S_tsc_40024000_PARENT DT_N_S_soc

/* Macros for properties that are special in the specification: */
#define DT_N_S_soc_S_tsc_40024000_REG_NUM 1
#define DT_N_S_soc_S_tsc_40024000_REG_IDX_0_EXISTS 1
#define DT_N_S_soc_S_tsc_40024000_REG_IDX_0_VAL_ADDRESS 1073889280 /* 0x40024000 */
#define DT_N_S_soc_S_tsc_40024000_REG_IDX_0_VAL_SIZE 1024 /* 0x400 */
#define DT_N_S_soc_S_tsc_40024000_RANGES_NUM 0
#define DT_N_S_soc_S_tsc_40024000_FOREACH_RANGE(fn) 
#define DT_N_S_soc_S_tsc_40024000_IRQ_NUM 1
#define DT_N_S_soc_S_tsc_40024000_IRQ_IDX_0_EXISTS 1
#define DT_N_S_soc_S_tsc_40024000_IRQ_IDX_0_VAL_irq 21
#define DT_N_S_soc_S_tsc_40024000_IRQ_IDX_0_VAL_irq_EXISTS 1
#define DT_N_S_soc_S_tsc_40024000_IRQ_IDX_0_EXISTS 1
#define DT_N_S_soc_S_tsc_40024000_IRQ_IDX_0_VAL_priority 0
#define DT_N_S_soc_S_tsc_40024000_IRQ_IDX_0_VAL_priority_EXISTS 1
#define DT_N_S_soc_S_tsc_40024000_IRQ_IDX_0_CONTROLLER DT_N_S_soc_S_interrupt_controller_e000e100
#define DT_N_S_soc_S_tsc_40024000_IRQ_LEVEL 1
#define DT_N_S_soc_S_tsc_40024000_IRQ_NAME_global_VAL_irq DT_N_S_soc_S_tsc_40024000_IRQ_IDX_0_VAL_irq
#define DT_N_S_soc_S_tsc_40024000_IRQ_NAME_global_VAL_irq_EXISTS 1
#define DT_N_S_soc_S_tsc_40024000_IRQ_NAME_global_VAL_priority DT_N_S_soc_S_tsc_40024000_IRQ_IDX_0_VAL_priority
#define DT_N_S_soc_S_tsc_40024000_IRQ_NAME_global_VAL_priority_EXISTS 1
#define DT_N_S_soc_S_tsc_40024000_IRQ_NAME_global_CONTROLLER DT_N_S_soc_S_tsc_40024000_IRQ_IDX_0_CONTROLLER
#define DT_N_S_soc_S_tsc_40024000_COMPAT_MATCHES_st_stm32_tsc 1
#define DT_N_S_soc_S_tsc_40024000_COMPAT_VENDOR_IDX_0_EXISTS 1
#define DT_N_S_soc_S_tsc_40024000_COMPAT_VENDOR_IDX_0 "STMicroelectronics"
#define DT_N_S_soc_S_tsc_40024000_COMPAT_MODEL_IDX_0_EXISTS 1
#define DT_N_S_soc_S_tsc_40024000_COMPAT_MODEL_IDX_0 "stm32-tsc"
#define DT_N_S_soc_S_tsc_40024000_STATUS_okay 1
#define DT_N_S_soc_S_tsc_40024000_P_resets_LEN 1
#define DT_N_S_soc_S_tsc_40024000_P_resets_EXISTS 1
#define DT_N_S_soc_S_tsc_40024000_P_pulse_generator_prescaler 2
#define DT_N_S_soc_S_tsc_40024000_P_pulse_generator_prescaler_ENUM_IDX 2
#define DT_N_S_soc_S_tsc_40024000_P_pulse_generator_prescaler_ENUM_VAL_2_EXISTS 1
#define DT_N_S_soc_S_tsc_40024000_P_pulse_generator_prescaler_EXISTS 1
#define DT_N_S_soc_S_tsc_40024000_P_ctph 2
#define DT_N_S_soc_S_tsc_40024000_P_ctph_ENUM_IDX 1
#define DT_N_S_soc_S_tsc_40024000_P_ctph_ENUM_VAL_2_EXISTS 1
#define DT_N_S_soc_S_tsc_40024000_P_ctph_EXISTS 1
#define DT_N_S_soc_S_tsc_40024000_P_ctpl 2
#define DT_N_S_soc_S_tsc_40024000_P_ctpl_ENUM_IDX 1
#define DT_N_S_soc_S_tsc_40024000_P_ctpl_ENUM_VAL_2_EXISTS 1
#define DT_N_S_soc_S_tsc_40024000_P_ctpl_EXISTS 1
#define DT_N_S_soc_S_tsc_40024000_P_spread_spectrum 0
#define DT_N_S_soc_S_tsc_40024000_P_spread_spectrum_EXISTS 1
#define DT_N_S_soc_S_tsc_40024000_P_spread_spectrum_deviation 1
#define DT_N_S_soc_S_tsc_40024000_P_spread_spectrum_deviation_EXISTS 1
#define DT_N_S_soc_S_tsc_40024000_P_max_count_value 5
#define DT_N_S_soc_S_tsc_40024000_P_max_count_value_ENUM_IDX 5
#define DT_N_S_soc_S_tsc_40024000_P_max_count_value_ENUM_VAL_5_EXISTS 1
#define DT_N_S_soc_S_tsc_40024000_P_max_count_value_EXISTS 1
#define DT_N_S_soc_S_tsc_40024000_P_iodef_float 0
#define DT_N_S_soc_S_tsc_40024000_P_iodef_float_EXISTS 1
#define DT_N_S_soc_S_tsc_40024000_P_synced_acquisition 0
#define DT_N_S_soc_S_tsc_40024000_P_synced_acquisition_EXISTS 1
#define DT_N_S_soc_S_tsc_40024000_P_syncpol_rising 0
#define DT_N_S_soc_S_tsc_40024000_P_syncpol_rising_EXISTS 1

...

The values are not meant to be accessed directly by us, instead a macro library is used to get the values programmatically. In my opinion this part needs a rework because it is very hard to trace macros when something is not right and you want to debug, but I think Zephyr is already working on a revised solution.

An example of how to access these values can be seen below;

#include <zephyr/devicetree.h>

DT_PROP(DT_NODELABEL(tsc), pulse_generator_prescaler); // 2

Plumbing Device Tree to Driver Code

We have a binding file that defines the information the driver would need to work with a general STM32 TSC peripheral, and we have a device tree node that describes a specific instance of this peripheral. Now we need to write a driver that will use this information to configure and use the peripheral. From now on the idea is that this hardware node can be inside any ARM Cortex-M based STM32 microcontroller, and we will be able to use the same driver for all of them. Also our code wont only run on a specific hardware node, but instead may work on any hardware node that has the same properties (same compatible property in the device tree), so multiple TSC hardware can be used in the same application even though ST has no MCU with such feature, it's best practice to write drivers this way in case ST releases a new MCU with multiple TSC peripherals.

We therefore need to store all these information in a struct inside our MCU, preferably in flash memory so it won't take up any RAM.

Device Tree Macros

Zephyr provides us with a set of macros to access the device tree properties in the generated header file. These are usually harder to debug when something goes wrong but it makes everything compile-time. To use it we need to include the API from zephyr/include/zephyr/devicetree.h.;

#include <zephyr/devicetree.h>

Register Base Address

The memory address is the first thing we care about. it will be a 32-bit pointer to the base address of the TSC peripheral and can be casted to a pointer to a struct that represents the TSC peripheral memory layout.

#define TSC_BASE(node) ((TSC_TypeDef *)DT_INST_REG_ADDR(node))

So TSC_BASE macro takes a node and calls Zephyr macro DT_INST_REG_ADDR to get the reg property of the node, which is the base address of the peripheral. This is a pointer to the memory mapped registers of the peripheral, we than cast it to a pointer to a struct that represents the TSC peripheral memory layout and we have the peripheral ready to use.

Note

Since we are using a multi-instance compatible driver, we use instances, therefore we use the macro DT_INST_REG_ADDR instead of DT_REG_ADDR which is used for single instance drivers. when using _INST macros we need to provide DT_DRV_COMPAT in the beggining of the driver source file later when we create the driver.

TSC_TypeDef is provided by ST and is defined as a simple struct;

typedef struct
{
  volatile uint32_t CR;            /*!< TSC control register,                                     Address offset: 0x00 */
  volatile uint32_t IER;           /*!< TSC interrupt enable register,                            Address offset: 0x04 */
  volatile uint32_t ICR;           /*!< TSC interrupt clear register,                             Address offset: 0x08 */
  volatile uint32_t ISR;           /*!< TSC interrupt status register,                            Address offset: 0x0C */
  volatile uint32_t IOHCR;         /*!< TSC I/O hysteresis control register,                      Address offset: 0x10 */
  uint32_t      RESERVED1;     /*!< Reserved,                                                 Address offset: 0x14 */
  volatile uint32_t IOASCR;        /*!< TSC I/O analog switch control register,                   Address offset: 0x18 */
  uint32_t      RESERVED2;     /*!< Reserved,                                                 Address offset: 0x1C */
  volatile uint32_t IOSCR;         /*!< TSC I/O sampling control register,                        Address offset: 0x20 */
  uint32_t      RESERVED3;     /*!< Reserved,                                                 Address offset: 0x24 */
  volatile uint32_t IOCCR;         /*!< TSC I/O channel control register,                         Address offset: 0x28 */
  uint32_t      RESERVED4;     /*!< Reserved,                                                 Address offset: 0x2C */
  volatile uint32_t IOGCSR;        /*!< TSC I/O group control status register,                    Address offset: 0x30 */
  volatile uint32_t IOGXCR[8];     /*!< TSC I/O group x counter register,                         Address offset: 0x34-50 */
} TSC_TypeDef;

Pretty simple and efficient so far. This macro will expand into a simple pointer to the memory mapped registers of the TSC peripheral after preprocessor does its job.

((TSC_TypeDef *)DT_N_S_soc_S_tsc_40024000_REG_IDX_0_VAL_ADDRESS) // 0x40024000

Clock and Reset Control

We now need the clocks property to get the device responsible for clock control in the system, we already passed the clock controller RCC as a reference to the TSC device node.

Clock and reset control is common to ARM based MCUs so Zephyr provides a Clock Control Peripheral API to vendors to develop their own clock and reset control drivers. We can use this API to enable the clock for the TSC peripheral.

Here is the API to enable the clock for a subsystem (TSC in our case);

/* zephyr/drivers/clock_control.h */

static int clock_control_on ( const struct device *dev, clock_control_subsys_t  sys );

This means we can just pass a generic device that has been defined in the device tree clocks property to the generic clock_control_on with the peripheral and bus information and it will do the job for us. The sys argument is the opaque data to the Clock Control Peripheral API (void pointer) and will be passed to the API implementer that will cast and use the data to enable the clock for the TSC peripheral, this corresponds to the STM32_CLOCK_BUS_AHB1 (0x48) and 0x01000000 value in the device tree node. We can extract these values on our own but ST provides a nice macro to do this for us.

#include <zephyr/drivers/clock_control/stm32_clock_control.h>

#define TSC_CLOCK(node) (struct stm32_pclken) STM32_CLOCK_INFO(0, node);

This macro will expand into more device tree macros and will give us the clock information for the TSC peripheral.

/* zepryr/include/drivers/clock_control/stm32_clock_control.h */

#define STM32_CLOCK_INFO(clk_index, node_id)    \
 {        \
 .enr = DT_CLOCKS_CELL_BY_IDX(node_id, clk_index, bits),  \
 .bus = DT_CLOCKS_CELL_BY_IDX(node_id, clk_index, bus)  \
 }

Note

We are only using one clock for this peripheral but multiple can be defined, in that case a different macro STM32_DT_INST_CLOCKS is used instead.

Which will expand into;

{
  .enr = DT_N_S_soc_S_tsc_40024000_P_clocks_IDX_0_VAL_bus // 0x48
  .bus = DT_N_S_soc_S_tsc_40024000_P_clocks_IDX_0_VAL_bits // 0x1000000
}

Reset control is even simpler, we again use the resets property to get the device responsible for reset control in the system, and we can use the same API to reset the TSC peripheral. We will use the Reset Controller Peripheral API to reset the TSC peripheral.

#include <zephyr/reset.h>

#define TSC_RESET(node) (const struct reset_dt_spec *) RESET_DT_SPEC_INST_GET_OR(node, NULL)

This macro will get the resets property if it exists, NULL otherwise. This is a pointer to the reset controller device that will be used to reset the TSC peripheral. And reset_dt_spec is a struct that contains the reset controller device and the reset line number, 24 in our case.

struct reset_dt_spec {
  const struct device *dev;
  uint32_t line_num;
};

So this will expand into

(const struct reset_dt_spec *) { .dev = DT_N_S_soc_S_tsc_40024000_P_resets, .line_num = 24 }

With this struct we can use variety of reset control APIs to reset the TSC peripheral.

/* zephyr/include/drivers/reset.h */
int reset_status_dt(const struct reset_dt_spec *spec, uint8_t *status);
int reset_line_assert_dt(const struct reset_dt_spec *spec);
int reset_line_deassert_dt(const struct reset_dt_spec *spec);
int reset_line_toggle_dt(const struct reset_dt_spec *spec)

Pin Controller

This is another peripheral we have to deal with, again Zephyr has a Pin Controller Peripheral API to handle the pin configuration. We can use this API to configure the pins for the TSC peripheral.

This is a little tricky because the pin controller configs needs to be defined, but there is a macro for it too.

#include <zephyr/drivers/pinctrl.h>

#define TSC_PINCTRL_DEFINE(node) PINCTRL_DT_INST_DEFINE(node)
#define TSC_PINCTRL(node) ((const struct pinctrl_dev_config *) PINCTRL_DT_INST_DEV_CONFIG_GET(node))

We can then use this struct to apply all the pin states at runtime.

int pinctrl_apply_state(const struct pinctrl_dev_config *config, uint8_t id)

id is the pin control name property, default (0) in our case.

Group Configurations

From now on we only have basic types and need no additional subsystem or peripheral API.

A driver instance can have many groups, so lets define a struct to hold the group configurations.

struct stm32_tsc_group_config
{
  uint8_t group;
  uint8_t channel_ios;
  uint8_t sampling_io;
  bool use_as_shield;
};

To fill this struct we need to access the child nodes of the TSC node in the device tree.

#define STM32_TSC_GROUP(node) 
  { 
    .group = DT_PROP(node, group), 
    .channel_ios = DT_PROP(node, channel_ios), 
    .sampling_io = DT_PROP(node, sampling_io), 
    .use_as_shield = DT_PROP(node, use_as_shield), 
  },

This macro will expand into the following code;

{
  .group = DT_N_S_soc_S_tsc_40024000_g1_group,
  .channel_ios = DT_N_S_soc_S_tsc_40024000_g1_channel_ios,
  .sampling_io = DT_N_S_soc_S_tsc_40024000_g1_sampling_io,
  .use_as_shield = DT_N_S_soc_S_tsc_40024000_g1_use_as_shield,
},

To define multiple of these, Zephyr provides a macro DT_INST_FOREACH_CHILD_STATUS_OKAY that will iterate over all the child nodes of a parent node and call a function for each child node that has a status of okay.

#define STM32_TSC_GROUPS(node) (const struct stm32_tsc_group_config[]) { 
  DT_INST_FOREACH_CHILD_STATUS_OKAY(node, STM32_TSC_GROUP) 
}

This will eventually expand into;

(const struct stm32_tsc_group_config[]) {
  {
    .group = DT_N_S_soc_S_tsc_40024000_g1_group,
    .channel_ios = DT_N_S_soc_S_tsc_40024000_g1_channel_ios,
    .sampling_io = DT_N_S_soc_S_tsc_40024000_g1_sampling_io,
    .use_as_shield = DT_N_S_soc_S_tsc_40024000_g1_use_as_shield,
  },
  {
    .group = DT_N_S_soc_S_tsc_40024000_g6_group,
    .channel_ios = DT_N_S_soc_S_tsc_40024000_g6_channel_ios,
    .sampling_io = DT_N_S_soc_S_tsc_40024000_g6_sampling_io,
    .use_as_shield = DT_N_S_soc_S_tsc_40024000_g6_use_as_shield,
  }
}

TSC Configuration

Finally we need a struct to hold all the information about the TSC peripheral, including the base address, clock and reset control, pin controller, group configurations and other properties.

struct stm32_tsc_config
{
  TSC_TypeDef *tsc;
  const struct stm32_pclken *pclken;
  const struct reset_dt_spec *reset;
  const struct pinctrl_dev_config *pcfg;
  const struct stm32_tsc_group_config *groups;
  uint8_t groups_size;
  uint8_t pgpsc;
  uint8_t ctph;
  uint8_t ctpl;
  uint8_t sscpsc;
  uint8_t ssd;
  uint8_t max_count;
  bool spread_spectrum;
  bool sync_acq;
  bool sync_pol;
  bool iodef_float;

  void (*irq_func)(void);
};

Putting it all together

Once we put it all together we get a big initialization macro (backlashes are omitted for readability);

#define TSC_GROUP(node) 
  { 
    .group = DT_PROP(node, group), 
    .channel_ios = DT_PROP(node, channel_ios), 
    .sampling_io = DT_PROP(node, sampling_io), 
    .use_as_shield = DT_PROP(node, use_as_shield), 
  },

#define TSC_GROUPS(node) 
  (const struct stm32_tsc_group_config[]) { 
    DT_INST_FOREACH_CHILD_STATUS_OKAY(node, STM32_TSC_GROUP) 
  }

#define TSC_BASE(node) ((TSC_TypeDef *)DT_INST_REG_ADDR(node))
#define TSC_CLOCK(node) (struct stm32_pclken) STM32_CLOCK_INFO(0, node)
#define TSC_RESET(node) (const struct reset_dt_spec) RESET_DT_SPEC_INST_GET_OR(node, NULL)
#define TSC_PINCTRL_DEFINE(node) PINCTRL_DT_INST_DEFINE(node)
#define TSC_PINCTRL(node) ((const struct pinctrl_dev_config *) PINCTRL_DT_INST_DEV_CONFIG_GET(node))

#define STM32_TSC_INIT(node)

  static void stm32_tsc_irq_init_##node(void){
    IRQ_CONNECT(DT_INST_IRQN(node), DT_INST_IRQ(node, priority), stm32_tsc_isr, DEVICE_DT_INST_GET(node), 0);
    irq_enable(DT_INST_IRQN(node));
  };

  TSC_PINCTRL_DEFINE(node);

  static const struct stm32_tsc_group_config groups[] = TSC_GROUPS(node);

  static const struct stm32_tsc_config tsc_config##node = 
  {
    .tsc = TSC_BASE(node),
    .pclken = TSC_CLOCK(node),
    .reset = TSC_RESET(node),
    .pcfg = TSC_PINCTRL(node),
    .groups = groups,
    .groups_size = DT_INST_CHILD_NUM_STATUS_OKAY(groups),
    .pgpsc = DT_INST_PROP_OR(node, pulse_generator_prescaler, 2),
    .ctph = DT_INST_PROP_OR(node, ctph, 2),
    .ctpl = DT_INST_PROP_OR(node, ctpl, 2),
    .sscpsc = DT_INST_PROP_OR(node, spread_spectrum_prescaler, 1),
    .ssd = DT_INST_PROP_OR(node, spread_spectrum_deviation, 1),
    .max_count = DT_INST_PROP_OR(node, max_count_value, 5),
    .spread_spectrum = DT_INST_PROP_OR(node, spread_spectrum, false),
    .sync_acq = DT_INST_PROP_OR(node, synced_acquisition, false),
    .sync_pol = DT_INST_PROP_OR(node, syncpol_rising, false),
    .iodef_float = DT_INST_PROP_OR(node, iodef_float, false),
    .irq_func = stm32_tsc_irq_init_##node,
  };

  DEVICE_DT_INST_DEFINE(node, stm32_tsc_init, NULL, NULL, &stm32_tsc_cfg_##node, POST_KERNEL, CONFIG_KERNEL_INIT_PRIORITY_DEFAULT, NULL);

When Zephyr starts booting, it will call all drivers' initialization functions, so when we enter main() we will have everything ready for our application. To enable Zephyr to pick up our driver and call it before main DEVICE_DT_INST_DEFINE is used. This macro has parameters node_id, initialization function, power management function (NULL for us), a pointer to device data in RAM (NULL for us), a pointer to device data in FLASH (cast to void pointer as we will see in Peripheral API section), initialization level, initialization priority, and a pointer to a device API structure (NULL for us).

What all the device tree was is to be able to fill this struct. We have defined the binding file, written the node and generated the devicetree_generated.h file and got all the data we need.

For this specific node if we call STM32_TSC_INIT with our node, the generated code will look like this;

static void stm32_tsc_irq_init_0(void){
  IRQ_CONNECT(21, 0, stm32_tsc_isr, DEVICE_DT_INST_GET(0), 0);
  irq_enable(21);
};

static const struct stm32_tsc_group_config groups[] = 
{
  {
    .group = DT_N_S_soc_S_tsc_40024000_g1_group,
    .channel_ios = DT_N_S_soc_S_tsc_40024000_g1_channel_ios,
    .sampling_io = DT_N_S_soc_S_tsc_40024000_g1_sampling_io,
    .use_as_shield = DT_N_S_soc_S_tsc_40024000_g1_use_as_shield,
  },
  {
    .group = DT_N_S_soc_S_tsc_40024000_g6_group,
    .channel_ios = DT_N_S_soc_S_tsc_40024000_g6_channel_ios,
    .sampling_io = DT_N_S_soc_S_tsc_40024000_g6_sampling_io,
    .use_as_shield = DT_N_S_soc_S_tsc_40024000_g6_use_as_shield,
  }
};

static const struct stm32_tsc_config tsc_config = 
{
  .tsc = (TSC_TypeDef *)DT_N_S_soc_S_tsc_40024000_REG_IDX_0_VAL_ADDRESS,
  .pclken = &DT_N_S_soc_S_tsc_40024000_P_clocks,
  .reset = DT_N_S_soc_S_tsc_40024000_P_resets,
  .groups = groups,
  .irq_func = tsc_isr,
  .groups_size = 2,
  .pgpsc = DT_N_S_soc_S_tsc_40024000_P_pulse_generator_prescaler,
  .ctph = DT_N_S_soc_S_tsc_40024000_P_ctph,
  .ctpl = DT_N_S_soc_S_tsc_40024000_P_ctpl,
  .sscpsc = DT_N_S_soc_S_tsc_40024000_P_spread_spectrum_prescaler,
  .ssd = DT_N_S_soc_S_tsc_40024000_P_spread_spectrum_deviation,
  .max_count = DT_N_S_soc_S_tsc_40024000_P_max_count_value,
  .spread_spectrum = DT_N_S_soc_S_tsc_40024000_P_spread_spectrum,
  .sync_acq = DT_N_S_soc_S_tsc_40024000_P_synced_acquisition,
  .sync_pol = DT_N_S_soc_S_tsc_40024000_P_syncpol_rising,
  .iodef_float = DT_N_S_soc_S_tsc_40024000_P_iodef_float,
  .irq_func = stm32_tsc_irq_init_0,
};

/* device defininition code */

This is very roughly what ends up happening after preprocessor does its job.

Interrupts

One important thing not mentioned here is interrupts. We defined in the node that the interrupts are at position 21 and the priority is 0. Zephyr has a nice API to connect interrupts to ISRs and enable them. In this implementation all interrupts are connected to stm32_tsc_isr function, which is a generic interrupt service routine, and it will take a parameter of type const struct device * (provided by passing DEVICE_DT_INST_GET to ISR_CONNECT ) so we will be able to serve multiple instances of the TSC peripheral with the same ISR. So if we call stm32_tsc_irq_init_0 function, it will connect the interrupt to the ISR and enable it.

I plan to be able to enable and disable interrupts from the Kconfig files which what Zephyr uses to configure the kernel at compile time.

Device Driver Implementation

Now that we have our device tree node and binding ready, data plumbed to the driver, and the device defined, we can start writing the driver code.

We will again start by creating a directory for our driver in the workspace in the zephyr/drivers/misc folder for a cohesive folder structure. These are not mandatory but it is good practice to keep things organized.

We can start writing the code right away. I'll just create stm32_tsc.c file in the drivers/misc/stm32_tsc directory.

I like to start with the DT_DRV_COMPAT macro to define the compatible string for the driver, this is the same string we used in the binding file but special characters are replaced with underscores. And include the plumbing code so we can have the data ready.

/* zephyr/drivers/misc/stm32_tsc/stm32_tsc.c */

#define DT_DRV_COMPAT st_stm32_tsc

#include <zephyr/irq.h>
#include <zephyr/devicetree.h>
#include <zephyr/drivers/reset.h>
#include <zephyr/drivers/pinctrl.h>
#include <zephyr/drivers/clock_control/stm32_clock_control.h>

struct stm32_tsc_group_config {
 const uint8_t  group;
 const uint8_t  channel_ios;
 const uint8_t  sampling_io;
 const bool     use_as_shield;
 const uint32_t zephyr_code;
};

struct stm32_tsc_config {
 TSC_TypeDef       *tsc;
 const struct stm32_prcc_deven     pclken;
 const struct reset_dt_spec      reset;
 const struct pinctrl_dev_config     *pcfg;
 const struct stm32_tsc_group_config *groups;
 const uint8_t        groups_size;

 const uint8_t        pgpsc;
 const uint8_t        ctph;
 const uint8_t        ctpl;
 const bool        spread_spectrum;
 const uint8_t        sscpsc;
 const uint8_t        ssd;
 const uint16_t        max_count;
 const bool        iodef;
 const bool        sync_acq;
 const bool        sync_pol;
 void (*irq_func)(void);
};

#define STM32_TSC_GROUP(node)                                 \
  {                                                           \
    .group = DT_PROP(node, group),                            \
    .channel_ios = DT_PROP(node, channel_ios),                \
    .sampling_io = DT_PROP(node, sampling_io),                \
    .use_as_shield = DT_PROP(node, use_as_shield),            \
  },

#define STM32_TSC_GROUPS(node)                                \
  (const struct stm32_tsc_group_config[]) {                   \
    DT_INST_FOREACH_CHILD_STATUS_OKAY(node, STM32_TSC_GROUP)  \
  }

#define STM32_TSC_BASE(node) ((TSC_TypeDef *)DT_INST_REG_ADDR(node))
#define STM32_TSC_CLOCK(node) (const struct stm32_pclken) STM32_CLOCK_INFO(0, DT_DRV_INST(node))
#define STM32_TSC_RESET(node) (const struct reset_dt_spec) RESET_DT_SPEC_INST_GET_OR(node, NULL)
#define STM32_TSC_PINCTRL_DEFINE(node) PINCTRL_DT_INST_DEFINE(node)
#define STM32_TSC_PINCTRL(node) ((const struct pinctrl_dev_config *) PINCTRL_DT_INST_DEV_CONFIG_GET(node))

#define STM32_TSC_INIT(node)                                                                                        \
                                                                                                                    \
  static void stm32_tsc_irq_init_##node(void) {                                                                     \
    IRQ_CONNECT(DT_INST_IRQN(node), DT_INST_IRQ(node, priority), stm32_tsc_isr, DEVICE_DT_INST_GET(node), 0);       \
    irq_enable(DT_INST_IRQN(node));                                                                                 \
  };                                                                                                                \
                                                                                                                    \
  STM32_TSC_PINCTRL_DEFINE(node);                                                                                   \
                                                                                                                    \
  static const struct stm32_tsc_group_config groups[] = STM32_TSC_GROUPS(node);                                     \
                                                                                                                    \
  static const struct stm32_tsc_config tsc_config##node =                                                           \
  {                                                                                                                 \
  .tsc = STM32_TSC_BASE(node),                                                                                      \
  .pclken = STM32_TSC_CLOCK(node),                                                                                  \
  .reset = STM32_TSC_RESET(node),                                                                                   \
  .pcfg = STM32_TSC_PINCTRL(node),                                                                                  \
  .groups = groups,                                                                                                 \
  .groups_size = DT_INST_CHILD_NUM_STATUS_OKAY(node),                                                               \
  .pgpsc = DT_INST_PROP_OR(node, pulse_generator_prescaler, 2),                                                     \
  .ctph = DT_INST_PROP_OR(node, ctph, 2),                                                                           \
  .ctpl = DT_INST_PROP_OR(node, ctpl, 2),                                                                           \
  .spread_spectrum = DT_INST_PROP_OR(node, spread_spectrum, false),                                                 \
  .sscpsc = DT_INST_PROP_OR(node, spread_spectrum_prescaler, 1),                                                    \
  .ssd = DT_INST_PROP_OR(node, spread_spectrum_deviation, 1),                                                       \
  .max_count = DT_INST_PROP_OR(node, max_count_value, 5),                                                           \
  .iodef = DT_INST_PROP_OR(node, iodef_float, false),                                                               \
  .sync_acq = DT_INST_PROP_OR(node, synced_acquisition, false),                                                     \
  .sync_pol = DT_INST_PROP_OR(node, syncpol_rising, false),                                                         \
  };                                                                                                                \
                                                                                                                    \
  DEVICE_DT_INST_DEFINE(node, stm32_tsc_init, NULL, NULL, &tsc_config##node, POST_KERNEL, CONFIG_KERNEL_INIT_PRIORITY_DEFAULT, NULL);

DT_INST_FOREACH_STATUS_OKAY(STM32_TSC_INIT)

DT_INST_FOREACH_STATUS_OKAY is another macro which will iterate over all the instances of the driver in the device tree and call the function for each instance that has a status of okay.

We already defined in some parts of the code that we will use functions named stm32_tsc_isr and stm32_tsc_init so we need to define them. But before doing that a little info about peripheral and subsystem APIs so we can initialize TSC related configurations using other devices like RCC, GPIO, NVIC, etc.

Peripheral and Subsystem APIs

Peripheral APIs are generic Zephyr APIs that defines a common interface for general "peripherals", like GPIO, PWM, I2C, SPI, etc. These APIs are implemented by the SoC specific HALs so we don't have to change our application code, or driver code, if we change the hardware. We will be only dealing with the Zephyr APIs and it will do the plumbing for us.

These peripheral APIs are usually pretty nice and complete, for example check out the comprehensive GPIO Peripheral API documentation.

To use any GPIO in Zephyr we just need to include the header file and use the API.

#include <zepryr/drivers/gpio.h>

void main(void)
{
    struct device *dev = NULL;
    int ret = 0;

    dev = device_get_binding("GPIO_0");
    if (!dev) {
        k_panic("Could not get GPIO device\n");
        return;
    }

    ret = gpio_pin_configure(dev, 0, GPIO_INPUT | GPIO_ACTIVE_HIGH);
    if (ret) {
        k_panic("Could not configure GPIO pin 0\n");
        return;
    }

    ...
}

Once we are able to get a device struct, we can then pass it to its respective API. Notice how struct device is just a generic struct, in fact if we look at the definition of it we see a really abstract structure:

/**
 * @brief Runtime device structure (in ROM) per driver instance
 */
struct device {
 /** Name of the device instance */
 const char *name;
 /** Address of device instance config information */
 const void *config;
 /** Address of the API structure exposed by the device instance */
 const void *api;
 /** Address of the common device state */
 struct device_state *state;
 /** Address of the device instance private data */
 void *data;

  ...
};

config, api and data are all adapters (void pointers) to whatever any "device" wants. And if we look at the gpio_pin_configure function you see after some checking it calls the actual driver that was described in the device tree of the hardware we are using, which was connected to this adapter at boot time, by the device tree macros. We will see this in detail in the next section.

int gpio_pin_configure(const struct device *port,
     gpio_pin_t pin,
     gpio_flags_t flags)
{
  const struct gpio_driver_api *api =
    (const struct gpio_driver_api *)port->api;

  ...

 return api->pin_configure(port, pin, flags);
}

So the void *api was assigned to the device structure by the actual vendor driver at boot time and again dereferenced here by the API to get the address of the actual driver function which does the actual configuration. So probably the vendor driver will look into the config field or the data field to get the actual hardware registers and do the configuration.

Strong opinion time; C allowed this kind of abstraction all along, but nobody dared to implement it. I think this was the framework industry needed for a long time, and Zephyr did it right. So hats off to the Zephyr team for this.

The peripheral APIs this driver will use are;

There are also subsystems, they are pretty similar to peripherals in terms of their use but the difference is that they do not rely on hardware. Things like console or file systems. One subsystem this driver can use is the Input Subsystem. This subsystem is used for anything that is an input, usually touch screens, buttons, etc. We can use this subsystem to get the touch input from the TSC peripheral.

Reset API

Since we will be using another device we should check if it was initialized before using it. Zephyr has a function for this

#include <zephyr/device.h>

void stm32_tsc_init(const struct device *dev)
{
  const struct stm32_tsc_config *config = dev->config;
  const struct device *const  rcc_dev = DEVICE_DT_GET(STM32_CLOCK_CONTROL_NODE);

  if (!device_is_ready(rcc_dev))
  {
    LOG_ERR("TSC@%p: clock and reset controller device not ready", config->tsc);
    return -ENODEV;
  }

}

First thing to do is to get the devices and configs we will use. In this case this device's config point to a memory location that holds the struct stm32_tsc_config we defined earlier, so we will cast the dev->config, which was a void pointer to this struct and use it. Another one is the rcc_dev which is the clock and reset controller device we will use to reset the TSC peripheral. STM32_CLOCK_CONTROL_NODE is defined in stm32_clock_control.h and points to the device tree node that holds the clock and reset controller information.

After this we will use reset_line_toggle_dt from the reset API to reset the TSC peripheral, so the peripheral values are at their defaults.

Note

Also a subsystem called Log is used here, but since it is irrelevant, I will not discuss it. Please find more information about it in the Zephyr Documentation.

#include <zephyr/drivers/reset.h>

void stm32_tsc_init(const struct device *dev)
{
  const struct stm32_tsc_config *config = dev->config;
  const struct device *const  rcc_dev = DEVICE_DT_GET(STM32_CLOCK_CONTROL_NODE);

  ...

  int ret = reset_line_toggle_dt(config->reset);
  if (ret < 0)
  {
    LOG_ERR("Failed to reset TSC@%p (%d)", config->tsc, ret);
    return ret;
  }
}

Clock Control API

Then we will use the clock control API to enable the clock for the TSC peripheral.

#include <zephyr/drivers/clock_control.h>

void stm32_tsc_init(const struct device *dev)
{
  const struct stm32_tsc_config *config = dev->config;
  const struct device *const  rcc_dev = DEVICE_DT_GET(STM32_CLOCK_CONTROL_NODE);

  ...

  ret = clock_control_on(rcc_dev, (clock_control_subsys_t)&config->pclken);
  if (ret < 0)
  {
    LOG_ERR("Failed to enable clock for TSC@%p (%d)", config->tsc, ret);
    return ret;
  }
}

Pin Controller API

And finally we will use the pin controller API to configure the pins for the TSC peripheral.

#include <zephyr/drivers/pinctrl.h>

void stm32_tsc_init(const struct device *dev)
{
  const struct stm32_tsc_config *config = dev->config;
  const struct device *const  rcc_dev = DEVICE_DT_GET(STM32_CLOCK_CONTROL_NODE);

  ...

  ret = pinctrl_apply_state(config->pcfg, PINCTRL_STATE_DEFAULT);
  if (ret < 0)
  {
    LOG_ERR("Failed to configure TSC@%p pins (%d)", config->tsc, ret);
    return ret;
  }
}

This will be all for now for the peripheral APIs, the rest of it is just memory writes and reads to/from the TSC peripheral registers and finally calling the interrupt enable function we defined earlier in STM32_TSC_INIT, which makes this final code for the stm32_tsc_init function.

stm32_tsc_init

#include <soc.h>

static int stm32_tsc_init(const struct device *dev)
{
 const struct stm32_tsc_config *config = dev->config;
 const struct device *const     rcc_dev = DEVICE_DT_GET(STM32_CLOCK_CONTROL_NODE);

 int          ret;

 if (!device_is_ready(rcc_dev))
 {
  LOG_ERR("TSC@%p: clock and reset controller device not ready", config->tsc);
  return -ENODEV;
 }

 /* reset TSC values to default */
 ret = reset_line_toggle_dt(&config->reset);
 if (ret < 0)
 {
  LOG_ERR("Failed to reset TSC@%p (%d)", config->tsc, ret);
  return ret;
 }

 ret = clock_control_on(rcc_dev, (clock_control_subsys_t)&config->pclken);
 if (ret < 0)
 {
  LOG_ERR("Failed to enable clock for TSC@%p (%d)", config->tsc, ret);
  return ret;
 }

 ret = pinctrl_apply_state(config->pcfg, PINCTRL_STATE_DEFAULT);
 if (ret < 0)
 {
  LOG_ERR("Failed to configure TSC@%p pins (%d)", config->tsc, ret);
  return ret;
 }

 /* set ctph (bits 31:28) and ctpl (bits 27:24) */
 sys_set_bits((mem_addr_t)&config->tsc->CR, (((config->ctph - 1) << 4) | (config->ctpl - 1)) << TSC_CR_CTPL_Pos);

 /* set spread spectrum deviation (bits 23:17) */
 sys_set_bits((mem_addr_t)&config->tsc->CR, config->ssd << TSC_CR_SSD_Pos);

 /* set pulse generator prescaler (bits 14:12) */
 sys_set_bits((mem_addr_t)&config->tsc->CR, config->pgpsc << TSC_CR_PGPSC_Pos);

 /* set max count value (bits 7:5) */
 sys_set_bits((mem_addr_t)&config->tsc->CR, config->max_count << TSC_CR_MCV_Pos);

 /* set spread spectrum prescaler (bit 15) */
 if (config->sscpsc == 2)
  sys_set_bit((mem_addr_t)&config->tsc->CR, TSC_CR_SSPSC_Pos);

 /* set sync bit polarity */
 if (config->sync_pol)
  sys_set_bit((mem_addr_t)&config->tsc->CR, TSC_CR_SYNCPOL_Pos);

 /* set sync acquisition */
 if (config->sync_acq)
  sys_set_bit((mem_addr_t)&config->tsc->CR, TSC_CR_AM_Pos);

 /* set I/O default mode */
 if (config->iodef)
  sys_set_bit((mem_addr_t)&config->tsc->CR, TSC_CR_IODEF_Pos);

 /* set spread spectrum */
 if (config->spread_spectrum)
  sys_set_bit((mem_addr_t)&config->tsc->CR, TSC_CR_SSE_Pos);

 /* group configuration */
 for (int i = 0; i < config->groups_size; i++)
 {
  const struct stm32_tsc_group_config *group = &config->groups[i];

  if (group->channel_ios & group->sampling_io)
  {
   LOG_ERR("TSC@%p: group %d has the same channel and sampling I/O", config->tsc, group->group);
   return -EINVAL;
  }

  /* if use_as_shield is true, the channel I/Os are used as shield, and can only have values 1,2,4,8 */
  if (group->use_as_shield &&
      group->channel_ios != 1 &&
      group->channel_ios != 2 &&
      group->channel_ios != 4 &&
      group->channel_ios != 8)
  {
   LOG_ERR("TSC@%p: group %d is used as shield, but has invalid channel I/Os. Can only have one", config->tsc, group->group);
   return -EINVAL;
  }

  /* each group only has 4 configurable I/O */
#define GET_GROUP_BITS(val) (uint32_t)(((val) & 0x0f) << ((group->group - 1) * 4))

  /* clear schmitt trigger hysteresis for enabled I/Os */
  sys_clear_bits((mem_addr_t)&config->tsc->IOHCR, GET_GROUP_BITS(group->channel_ios | group->sampling_io));

  /* set channel I/Os */
  sys_set_bits((mem_addr_t)&config->tsc->IOCCR, GET_GROUP_BITS(group->channel_ios));

  /* set sampling I/O */
  sys_set_bits((mem_addr_t)&config->tsc->IOSCR, GET_GROUP_BITS(group->sampling_io));

  /* enable group */
  if (!group->use_as_shield)
   sys_set_bit((mem_addr_t)&config->tsc->IOGCSR, group->group - 1);
 }

 /* disable interrupts */
 sys_clear_bits((mem_addr_t)&config->tsc->IER, TSC_IER_EOAIE | TSC_IER_MCEIE);

 /* clear interrupts */
 sys_set_bits((mem_addr_t)&config->tsc->ICR, TSC_ICR_EOAIC | TSC_ICR_MCEIC);

 /* enable peripheral */
 sys_set_bit((mem_addr_t)&config->tsc->CR, TSC_CR_TSCE_Pos);

#if IS_ENABLED(CONFIG_STM32_TSC_INTERRUPT)
 config->irq_func();
#endif

 return 0;
}

The functions like sys_set_bits and sys_clear_bits are just memory writes to the peripheral registers and definitions like TSC_IER_EOAIE or TSC_CR_IODEF_Pos are taken from the SOC specific header file, which gets included with soc.h which corresponds to the stm32u083xx.h file in this case.

If you noticed the interrupt is only enabled when there is a definition called CONFIG_STM32_TSC_INTERRUPT. We will not define it in this file instead it will be defined globally by the Zephyr build system when parsing Kconfig files which we will see in the next section.

TSC Peripheral API

This is not quite a set-and-forget type of a driver. Application implementer should start the acquisition. Therefore the driver should expose some functions to interact with the TSC peripheral.

Header files are resided in include directory of the Zephyr source, therefore this driver will be in zephyr/include/drivers/misc/stm32_tsc.h.

/* zephyr/include/drivers/misc/stm32_tsc.h */

#ifndef ZEPHYR_DRIVERS_MISC_STM32_TSC_H_
#define ZEPHYR_DRIVERS_MISC_STM32_TSC_H_

#include <zephyr/types.h>

void stm32_tsc_start(const struct device *dev);
int  stm32_tsc_poll_for_acquisition(const struct device *dev, k_timeout_t timeout);
int  stm32_tsc_read(const struct device *dev, uint8_t group, uint32_t *value);

#endif /* ZEPHYR_DRIVERS_MISC_STM32_TSC_H_ */

This API is as simple as it can get. All functions require a device to work. stm32_tsc_start will start the acquisition, and if interrupt is enabled it should put the acquisition value to the input subsystem, or user can poll and read manually.

TSC Peripheral API Implementation

stm32_tsc_start
void stm32_tsc_start(const struct device *dev)
{
 const struct stm32_tsc_config *config = dev->config;

 /* clear interrupts */
 sys_set_bits((mem_addr_t)&config->tsc->ICR, TSC_ICR_EOAIC | TSC_ICR_MCEIC);

#if IS_ENABLED(CONFIG_STM32_TSC_INTERRUPT)
 /* enable end of acquisition and max count error interrupts */
 sys_set_bits((mem_addr_t)&config->tsc->IER, TSC_IER_EOAIE | TSC_IER_MCEIE);
#endif

 /* start acquisition */
 sys_set_bit((mem_addr_t)&config->tsc->CR, TSC_CR_START_Pos);
}

This is discussed in the previous sections, we clear and enable interrupts if enabled, only only set a bit to start the acquisition.

stm32_tsc_poll_for_acquisition

When error or acquisition occurs the ISR register bits are set. Driver will check those and return if the bits are set (clearing them before returning) or a timeout occurs.

int stm32_tsc_poll_for_acquisition(const struct device *dev, k_timeout_t timeout)
{
 const struct stm32_tsc_config *config = dev->config;
 uint32_t         status;
 uint32_t         start_tick = sys_clock_tick_get_32();

 do
 {
  status = sys_read32((mem_addr_t)&config->tsc->ISR);

  if (sys_clock_tick_get_32() - start_tick > timeout.ticks)
  {
   LOG_ERR("TSC@%p: timeout", config->tsc);
   return -ETIMEDOUT;
  }

 } while (!(status & TSC_ISR_EOAF) && !(status & TSC_ISR_MCEF));

 if (status & TSC_ISR_MCEF)
 {
  sys_set_bit((mem_addr_t)&config->tsc->ICR, TSC_ICR_MCEIC_Pos);
  LOG_ERR("TSC@%p: max count error", config->tsc);
  return -EIO;
 }

 sys_set_bit((mem_addr_t)&config->tsc->ICR, TSC_ICR_EOAIC_Pos);

 return 0;
}
stm32_tsc_read

Reading is also straightforward, we just read the data register of the TSC group.

int stm32_tsc_read(const struct device *dev, uint8_t group, uint32_t *value)
{
 const struct stm32_tsc_config *config = dev->config;

 /* make sure group starts with 1 and can go max to IOGXCR count */
 if (value == NULL || group < 1 || group > 8)
  return -EINVAL;

 *value = sys_read32((mem_addr_t)&config->tsc->IOGXCR[group - 1]);

 return 0;
}
stm32_tsc_isr

This function just disables the interrupts and checks for errors just like in the polling function, and if there is no error it reads the values and reports them to the input subsystem.

static void stm32_tsc_isr(const struct device *dev)
{
 const struct stm32_tsc_config *config = dev->config;

 /* disable interrupts */
 sys_clear_bits((mem_addr_t)&config->tsc->IER, TSC_IER_EOAIE | TSC_IER_MCEIE);

 if (sys_test_bit((mem_addr_t)&config->tsc->ISR, TSC_ISR_MCEF_Pos))
 {
  /* clear max count error flag */
  sys_set_bit((mem_addr_t)&config->tsc->ICR, TSC_ICR_MCEIC_Pos);
  LOG_ERR("TSC@%p: max count error", config->tsc);
  LOG_HEXDUMP_DBG(config->tsc, sizeof(TSC_TypeDef), "TSC Registers");
  return -EIO;
 }

 if (sys_test_bit((mem_addr_t)&config->tsc->ISR, TSC_ISR_EOAF_Pos))
 {
  /* clear end of acquisition flag */
  sys_set_bit((mem_addr_t)&config->tsc->ICR, TSC_ICR_EOAIC_Pos);

  /* read values */
  for (int i = 0; i < config->groups_size; i++)
  {
   const struct stm32_tsc_group_config *group = &config->groups[i];
   uint32_t        group_bit = BIT(group->group - 1) << 16;

   if (config->tsc->IOGCSR & group_bit)
   {
    int32_t count_value = sys_read32((mem_addr_t)&config->tsc->IOGXCR[group->group - 1]);
    input_report(dev, INPUT_EV_DEVICE, group->zephyr_code, count_value, true, K_FOREVER);
   }
  }
 }
}

Integration into the Build System

Zephyr uses Kconfig and CMake to decide if it will compile and link our source code. So this driver needs to integrate itself into this build system.

In total 2 new files need to be created and 2 more needs editing.

  • zephyr/drivers/misc/stm32_tsc/CMakeLists.txt for the driver source
  • zephyr/drivers/misc/stm32_tsc/Kconfig for the driver configuration
  • zephyr/drivers/misc/CMakeLists.txt for the driver inclusion
  • zephyr/drivers/misc/Kconfig for the driver inclusion

First thing this driver needs is to have a CMakeLists.txt and Kconfig file where the driver source is;

Source CMakeLists.txt

# zephyr/drivers/misc/stm32_tsc/CMakeLists.txt
zephyr_library()
zephyr_library_sources(stm32_tsc.c)

This will add this as a Zephyr library and include the stm32_tsc.c file in the build system.

Source Kconfig

For the Kconfig file, we will create a Kconfig file in the same directory as the CMakeLists.txt file.

# zephyr/drivers/misc/stm32_tsc/Kconfig
config STM32_TSC
 bool "STM32 TSC driver"
 depends on DT_HAS_ST_STM32_TSC_ENABLED
 help
   Enable driver for STM32 TSC peripheral.

if STM32_TSC

config STM32_TSC_INTERRUPT
 bool "Use interrupt for STM32 TSC"
 default y
 help
   Use interrupt for STM32 TSC peripheral.

endif

Kconfig files are easy to read, and in this one we have only two configurations. The first one is to enable the driver, and the second one is to enable the interrupt. The first one depends on DT_HAS_ST_STM32_TSC_ENABLED which requires many more configs to make sure we run on STM32 and in the system we have a TSC peripheral.

Zephyr CMakeLists.txt

But the driver did not used STM32_TSC anywhere, so how does Zephyr know to include this driver in the build? This is done in the zephyr/drivers/misc/CMakeLists.txt file. We strategically placed these in subfolders, and these subfolders have their own Kconfig and CMakeLists.txt files. So that subdirs are accumulated as a chain. Therefore this drivers needs to be made aware to the parent directory.

# zephyr/drivers/misc/CMakeLists.txt
add_subdirectory_ifdef(CONFIG_STM32_TSC stm32_tsc)

This is where the definition in the Kconfig file is used. If CONFIG_STM32_TSC is defined, then the stm32_tsc directory will be included in the build. The CONFIG_ prefix is attached to every configuration in the Kconfig file.

Zephyr Kconfig

Same has to be done with Kconfig but without the conditional inclusion.

# zephyr/drivers/misc/Kconfig
menu "Miscellaneous Drivers"
...
source "drivers/misc/stm32_tsc/Kconfig"
endmenu

How Do We Test This?

Zephyr has an extensive testing and emulation framework, allowing us to do all kinds of tricks to confidently say our code works. And without even running it on a real hardware. Of course for this memory mapped peripheral driver, we would have to emulate the entire TSC peripheral, which is outside of the scope of this document, but I plan to write on emulation frameworks in the future.

But first, lets test it on the real hardware.

Building

The driver is written but there also needs to be an application to test it, we could create a new application but lets use an existing one. The Input Dump example is a good candidate for this. It is a simple application that reads every input event and prints it to the console.

One change we need to edit in this application is to start the acquisition of the TSC peripheral, since it needed to be manually started.

/* zephyr/samples/subsys/input/input_dump/src/main.c */

#include <stdio.h>
#include <zephyr/kernel.h>
#include <zephyr/drivers/misc/stm32_tsc/stm32_tsc.h>

int main(void)
{
 printf("Input sample started\n");

 const struct device *tsc_dev = DEVICE_DT_GET(DT_NODELABEL(tsc));

 if (!device_is_ready(tsc_dev))
 {
  printf("TSC device is not ready\n");
  return -ENODEV;
 }

 while (1)
 {
  /* This will start the acqusition and print the readings to input subsystem */
  stm32_tsc_start(tsc_dev);

    /* You can alternatively disable interrupts and
   * poll for acquisition completion. */
#if !IS_ENABLED(CONFIG_STM32_TSC_INTERRUPT)
  int ret = stm32_tsc_poll_for_acquisition(tsc_dev, K_MSEC(100));
  if (ret < 0)
  {
   printf("Failed to poll for acquisition\n");
   return ret;
  }

  uint32_t value;
  ret = stm32_tsc_read(tsc_dev, 6, &value);
  if (ret < 0)
  {
   printf("Failed to read TSC\n");
   return ret;
  }

  printf("TSC value: %u\n", value);
#endif

  k_msleep(100);
 }

 return 0;
}

If you remember we kept the status of TSC at disabled in the device tree, so we need to change it to okay in the application overlay to be able to use it in the application.

/* zephyr/samples/subsys/input/input_dump/boards/stm32u083c_dk.overlay */

&tsc {
 status = "okay";
};

Now all its left is to enable the driver in the config file of the application.

# zephyr/samples/subsys/input/input_dump/prj.conf
CONFIG_STM32_TSC=y

after all these changes, west is ready to build the application.

west build -b stm32u083c_dk zephyr/samples/subsys/input/input_dump

Flashing

Linux

If your host is Linux, you can use USB pass-through to flash the device. For this the devcontainer should be started with host's USB bus.

There are several ways to do this but one way is to add it in devcontainer.json

"mounts": ["type=bind,source=/dev/bus/usb,target=/dev/bus/usb"]

Windows

If your host is Windows, USB pass-through is not possible. But you can use USB/IP to share the USB device with the container. This is a bit more complicated but it is possible. Please read the Microsoft's documentation on how to do this.

OSX

I am not sure if USB pass-through is possible, but USB/IP is currently unavailable on OSX. In this case you are on your own. I recommend pulling the files from the volume and flashing on the host with vendor tools, or opening an openOCD remote server and connecting to it inside the container.

After starting the container with a mount or USB/IP, you can flash the device with the following command.

west flash

and if you connect to the USB Serial port with a terminal emulator (115200bps), we should see the input events printed on the console.

*** Booting Zephyr OS build 5466949aefaf ***
Input sample started
I: input event: dev=tsc@40024000     SYN type=ef code=  2 value=1813
I: input event: dev=tsc@40024000     SYN type=ef code=  2 value=1803
I: input event: dev=tsc@40024000     SYN type=ef code=  2 value=1801
I: input event: dev=tsc@40024000     SYN type=ef code=  2 value=1811
I: input event: dev=tsc@40024000     SYN type=ef code=  2 value=1820
I: input event: dev=tsc@40024000     SYN type=ef code=  2 value=1815

How do we know which group's value is this? From the schematic it is connected to the group 6, but as you can see the code field for this input event is 2, and that is the zephyr,code field in the device tree. So we can check the zephyr,code field of the group 6 in the device tree and see it is 2.

Integration Testing

This step can be done at the beginning of the driver implementation to make sure the driver works as expected. This is called TDD (Test Driven Development) and it is a good practice to write tests before writing the actual code. But for the sake of this document I'll write the tests here. These tests can also be used to test the further modifications to the driver to make sure there is no regression in the driver when a change is made.

What to Test?

There are two main options that can be tested in this driver. One is to test the driver API and its behavior, and the other is to test the actual register states for this peripheral, and see if it matches the device tree configuration.

Our initial tests kept interrupt enabled, but in the integration tests we should test with and without this feature; CONFIG_STM32_TSC_INTERRUPT.

Test Plan

Here I outline the simple test plan for this driver. This might seem not enough to some, but should cover most cases.

  • Test MMIO
  • Check if the registers are set correctly
  • Test the driver API (interrupt)
  • Start the acquisition
  • Test if input events are reported
  • Test the driver API (polling)
  • Start the acquisition
  • Poll for acquisition
  • Read the values

Test Application

A test application (or a test suite as Zephyr calls it) is just another application that can either run on the host or on the target. Only difference is the inclusion of the ztest library and the test functions with an additional testcase.yml file for the test runner (twister) to pick up the application as a test.

I'll create a new application in the tests/drivers/misc/stm32_tsc directory and add my files there.

CMakeLists.txt

This is just a generic cmake file that used in any other application cmake file.

# zephyr/tests/drivers/misc/stm32_tsc/CMakeLists.txt
cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(stm32_tsc)

FILE(GLOB app_sources src/*.c)
target_sources(app PRIVATE ${app_sources})
prj.conf

Since we do not have any emulator for TSC peripheral, we will run all the tests on the hardware.

# tests/drivers/misc/stm32_tsc/prj.conf
CONFIG_STM32_TSC=y
CONFIG_ZTEST=y
CONFIG_INPUT=y
CONFIG_INPUT_MODE_SYNCHRONOUS=y

This enables the driver and the test framework, and also the input subsystem to be able to test input events.

Device tree overlays

This devicetree modification is to make sure we use different values for the configuration than the reset values of the TSC peripheral, so that we can hit the parts of our code that sets the registers.

This includes using synced acquisition as discussed in the Hardware Configuration section. This requires two different additional pin configurations, one for the sync input pin and the other one is to trigger the sync pin. In the following example I've used generic zephyr,user node to define the trigger pin, which I arbitrarily chose to be GPIOA 10 which is exposed in my development kit.

And for the sync pin, I've used PD2 but this is not an arbitrary pin and it has been chosen with the help of the MCU datasheet. Luckily this pin is also exposed in my development kit.

Since we are overwriting pinctrl-0 property, we have to re-supply the previous channel pins, we do not have to re define them as they will be defined in the original .dts file.

/* zephyr/tests/drivers/misc/stm32_tsc/boards/stm32u083c_dk.overlay */
#include <zephyr/dt-bindings/misc/stm32-tsc-defines.h>

/ {
    zephyr,user {
        /* This will be used to trigger the SYNC pin high and low programmatically. They have to be connected with a jumper cable */
        signal-gpios = <&gpioa 10 GPIO_ACTIVE_HIGH>;
    };
};

&tsc {
    status = "okay";

    pinctrl-0 = <&tsc_shield_pb12 &tsc_shield_cs_pb13 &tsc_g6_io1_pd10 &tsc_g6_io2_pd11 &syncpin>;
    pinctrl-names = "default";

    pulse-generator-prescaler = <TSC_PG_PRESC_DIV128>;

    spread-spectrum;
    spread-spectrum-prescaler = <2>;
    spread-spectrum-deviation = <10>;

    max-count-value = <TSC_MCV_16383>; 

    synced-acquisition;
    syncpol-rising;
};

&pinctrl {
  syncpin: syncpin_pd2 {
    pinmux = <STM32_PINMUX('D', 2, AF9)>;
    drive-open-drain;
  };
};

This devicetree modification will make sure the driver will be utilized to its full extent.

testcase.yml

This file is searched when the test runner twister is run. There is a pretty comprehensive documentation on how to write this file.

Here we will use a pretty simple one.

# zephyr/tests/drivers/misc/stm32_tsc/testcase.yml
tests:
  drivers.misc.stm32_tsc:
    tags: drivers misc stm32 tsc
    filter: dt_compat_enabled("st,stm32-tsc")
  drivers.misc.stm32_tsc_poll:
    tags: drivers misc stm32 tsc poll
    filter: dt_compat_enabled("st,stm32-tsc")
    extra_args: CONFIG_STM32_TSC_INTERRUPT=n

We can define multiple tests in a single file and each will be built, flashed and run separately. In this case I've defined two tests with two different configurations. These tests are called drivers.misc.stm32_tsc and drivers.misc.stm32_tsc_poll. The first one will test the driver with interrupt enabled and the second one will test the driver with polling enabled. There are also properties like tags which twister uses to scope and run multiple tests and filter which is used to figure out if the test should be run or not. In this case the test will only run if the device tree node st,stm32-tsc is enabled.

Test Cases

Test Setup

We want to run a setup function before each test, and define out test suite first.

/* zephyr/tests/drivers/misc/stm32_tsc/src/main.c */
#include <zephyr/device.h>
#include <zephyr/drivers/gpio.h>
#include <zephyr/drivers/misc/stm32_tsc/stm32_tsc.h>
#include <zephyr/kernel.h>
#include <zephyr/ztest.h>

#if DT_HAS_COMPAT_STATUS_OKAY(st_stm32_tsc)
  #define TSC_NODE DT_INST(0, st_stm32_tsc)
#else
  #error "Define a TSC device"
#endif

#define ZEPHYR_USER_NODE DT_PATH(zephyr_user)

const struct gpio_dt_spec signal =
  GPIO_DT_SPEC_GET(ZEPHYR_USER_NODE, signal_gpios);

static void *stm32_tsc_setup(void)
{
  zassert_true(device_is_ready(DEVICE_DT_GET(TSC_NODE)), "STM32 TSC device is not ready");
  zexpect_ok(gpio_pin_configure_dt(&signal, GPIO_OUTPUT_INACTIVE), "Failed to configure signal pin");
  return NULL;
}

ZTEST_SUITE(stm32_tsc, NULL, stm32_tsc_setup, NULL, NULL, NULL);

You can return a special data struct to be used in your tests by returning it in the setup function. Also you can define a teardown function to run after each test. I kept them NULL for now.

Please find more information about the ztest library in the Ztest documentation.

First Test Case

A simple test case has the following structure;

ZTEST(test_suite_name, test_testname)
{
  /* Test code */
}

By this definition lets keep the first test simple and just check if the device is ready (stm32_tsc_init function returns 0).

/* zephyr/tests/drivers/misc/stm32_tsc/src/main.c */
ZTEST(stm32_tsc, test_1_device_ready)
{
  zassert_true(device_is_ready(DEVICE_DT_GET(TSC_NODE)), "STM32 TSC device is not ready");
}
Test MMIO

The first thing we will test is the CR register of the TSC peripheral, this register is where most of the configuration is done except for the group configurations, which we will test in the next test.

/* zephyr/tests/drivers/misc/stm32_tsc/src/main.c */
#include <soc.h>
ZTEST(stm32_tsc, test_2_cr_reg)
{
 TSC_TypeDef  *tsc = (TSC_TypeDef *)DT_REG_ADDR(TSC_NODE);
 volatile const uint32_t *tsc_cr = &tsc->CR;
 const uint8_t   pgpsc = DT_PROP(TSC_NODE, pulse_generator_prescaler);
 const uint8_t   ctph = DT_PROP(TSC_NODE, ctph);
 const uint8_t   ctpl = DT_PROP(TSC_NODE, ctpl);
 const uint8_t   ssd = DT_PROP_OR(TSC_NODE, spread_spectrum_deviation, 0);
 const bool   spread_spectrum = DT_PROP_OR(TSC_NODE, spread_spectrum, false);
 const uint8_t   sscpsc = DT_PROP_OR(TSC_NODE, spread_spectrum_prescaler, 0);
 const uint8_t   max_count = DT_PROP_OR(TSC_NODE, max_count_value, 0);
 const bool   iodef = DT_PROP_OR(TSC_NODE, iodef_float, false);
 const bool   sync_pol = DT_PROP_OR(TSC_NODE, syncpol_rising, false);
 const bool   sync_acq = DT_PROP_OR(TSC_NODE, synced_acquisition, false);

 /* check charge transfer pulse high value (bits 31:28) */
 zassert_equal((*tsc_cr & TSC_CR_CTPL_Msk) >> TSC_CR_CTPL_Pos, ctph - 1, "CTPH value is not correct, expected %d, got %d", ctph - 1, (*tsc_cr & TSC_CR_CTPL_Msk) >> TSC_CR_CTPL_Pos);

 /* check charge transfer pulse low value (bits 27:24) */
 zassert_equal((*tsc_cr & TSC_CR_CTPL_Msk) >> TSC_CR_CTPL_Pos, ctpl - 1, "CTPL value is not correct, expected %d, got %d", ctpl - 1, (*tsc_cr & TSC_CR_CTPL_Msk) >> TSC_CR_CTPL_Pos);

 /* check spread spectrum deviation value (bits 23:17) */
 zassert_equal((*tsc_cr & TSC_CR_SSD_Msk) >> TSC_CR_SSD_Pos, ssd, "SSD value is not correct, expected %d, got %d", ssd, (*tsc_cr & TSC_CR_SSD_Msk) >> TSC_CR_SSD_Pos);

 /* check spread spectrum enable bit (bit 16) */
 if (spread_spectrum)
  zexpect_true(*tsc_cr & TSC_CR_SSE_Msk);
 else
  zexpect_false(*tsc_cr & TSC_CR_SSE_Msk);

 /* check spread spectrum prescaler value (bits 15) */
 if (sscpsc == 2)
  zexpect_true(*tsc_cr & TSC_CR_SSPSC_Msk);
 else
  zexpect_false(*tsc_cr & TSC_CR_SSPSC_Msk);

 /* check pulse generator prescaler value (bits 14:12) */
 zassert_equal((*tsc_cr & TSC_CR_PGPSC_Msk) >> TSC_CR_PGPSC_Pos, pgpsc, "PGPSC value is not correct, expected %d, got %d", pgpsc, (*tsc_cr & TSC_CR_PGPSC_Msk) >> TSC_CR_PGPSC_Pos);

 /* check max count value (bits 7:5) */
 zassert_equal((*tsc_cr & TSC_CR_MCV_Msk) >> TSC_CR_MCV_Pos, max_count, "MCV value is not correct, expected %d, got %d", max_count, (*tsc_cr & TSC_CR_MCV_Msk) >> TSC_CR_MCV_Pos);

 /* check I/O default mode bit (bit 4) */
 if (iodef)
  zexpect_true(*tsc_cr & TSC_CR_IODEF_Msk);
 else
  zexpect_false(*tsc_cr & TSC_CR_IODEF_Msk);

 /* check sync polarity bit (bit 3) */
 if (sync_pol)
  zexpect_true(*tsc_cr & TSC_CR_SYNCPOL_Msk);
 else
  zexpect_false(*tsc_cr & TSC_CR_SYNCPOL_Msk);

 /* check sync acquisition bit (bit 2) */
 if (sync_acq)
  zexpect_true(*tsc_cr & TSC_CR_AM_Msk);
 else
  zexpect_false(*tsc_cr & TSC_CR_AM_Msk);

 /* check start bit (bit 1) */
 zexpect_false(*tsc_cr & TSC_CR_START_Msk);

 /* check TSC enable bit (bit 0) */
 zexpect_true(*tsc_cr & TSC_CR_TSCE_Msk);
}

This is a lenghty test, but it is just checking if the values in the device tree are set correctly in the CR register of the TSC peripheral.

To test the group configurations, we need to test the registers like IOCCR, IOSCR and IOGCSR of the TSC peripheral.

/* zephyr/tests/drivers/misc/stm32_tsc/src/main.c */
ZTEST(stm32_tsc, test_3_group_registers)
{
 TSC_TypeDef  *tsc = (TSC_TypeDef *)DT_REG_ADDR(TSC_NODE);
 volatile const uint32_t *tsc_iohcr = &tsc->IOHCR;
 volatile const uint32_t *tsc_ioscr = &tsc->IOSCR;
 volatile const uint32_t *tsc_ioccr = &tsc->IOCCR;
 volatile const uint32_t *tsc_iogcsr = &tsc->IOGCSR;

#define GET_GROUP_BITS(val) (uint32_t)(((val) & 0x0f) << ((group - 1) * 4))

#define STM32_TSC_GROUP_TEST(node)                                                                                                                                                                                                         \
 do                                                                                                                                                                                                                                 \
 {                                                                                                                                                                                                                                  \
  const uint8_t group = DT_PROP(node, group);                                                                                                                                                                                \
  const uint8_t channel_ios = DT_PROP(node, channel_ios);                                                                                                                                                                    \
  const uint8_t sampling_io = DT_PROP(node, sampling_io);                                                                                                                                                                    \
  const bool    use_as_shield = DT_PROP(node, use_as_shield);                                                                                                                                                                \
                                                                                                                                                                                                                                           \
  /* check schmitt trigger hysteresis for enabled I/Os */                                                                                                                                                                    \
  zassert_equal((*tsc_iohcr & GET_GROUP_BITS(channel_ios | sampling_io)), 0, "Schmitt trigger hysteresis not disabled, expected %d, got %d", 0, (*tsc_iohcr & GET_GROUP_BITS(channel_ios | sampling_io)));                   \
                                                                                                                                                                                                                                           \
  /* check channel I/Os */                                                                                                                                                                                                   \
  zassert_equal((*tsc_ioccr & GET_GROUP_BITS(channel_ios)), GET_GROUP_BITS(channel_ios), "Channel I/Os value is not correct, expected %d, got %d", GET_GROUP_BITS(channel_ios), (*tsc_ioccr & GET_GROUP_BITS(channel_ios))); \
                                                                                                                                                                                                                                           \
  /* check sampling I/O */                                                                                                                                                                                                   \
  zassert_equal((*tsc_ioscr & GET_GROUP_BITS(sampling_io)), GET_GROUP_BITS(sampling_io), "Sampling I/O value is not correct, expected %d, got %d", GET_GROUP_BITS(sampling_io), (*tsc_ioscr & GET_GROUP_BITS(sampling_io))); \
                                                                                                                                                                                                                                           \
  /* check enabled groups */                                                                                                                                                                                                 \
  if (use_as_shield)                                                                                                                                                                                                         \
   zassert_not_equal((*tsc_iogcsr & BIT(group - 1)), BIT(group - 1), "Group %d is a shield group and should not be enabled", group);                                                                                  \
  else                                                                                                                                                                                                                       \
   zassert_equal((*tsc_iogcsr & BIT(group - 1)), BIT(group - 1), "Group %d is not enabled", group);                                                                                                                   \
 } while (0);

 DT_FOREACH_CHILD_STATUS_OKAY(TSC_NODE, STM32_TSC_GROUP_TEST);
}
API Tests

The first test will be to test the polling function of the driver. For this the setting CONFIG_STM32_TSC_INTERRUPT needs to be set to n before building the application which is what we did in the testcase.yml file.

But this test case will run regardless of any Kconfig settings, so we need to skip the test if the interrupt is enabled.

/* zephyr/tests/drivers/misc/stm32_tsc/src/main.c */
ZTEST(stm32_tsc, test_4_acquisition_polling)
{
 Z_TEST_SKIP_IFDEF(CONFIG_STM32_TSC_INTERRUPT);

 const struct device *tsc_dev = DEVICE_DT_GET(TSC_NODE);
 TSC_TypeDef  *tsc = (TSC_TypeDef *)DT_REG_ADDR(TSC_NODE);
 volatile const uint32_t *tsc_cr = &tsc->CR;
 volatile const uint32_t *tsc_ier = &tsc->IER;
 volatile const uint32_t *tsc_isr = &tsc->ISR;

 stm32_tsc_start(tsc_dev);

 /* this should timeout because sync pin is not triggered */
 zexpect_not_ok(stm32_tsc_poll_for_acquisition(tsc_dev, K_MSEC(100)));

 stm32_tsc_start(tsc_dev);

 zexpect_ok(gpio_pin_toggle_dt(&signal));

 /* check if interrupts are disabled */
 zexpect_false(*tsc_ier & TSC_IER_EOAIE_Msk);
 zexpect_false(*tsc_ier & TSC_IER_MCEIE_Msk);

 /* test CR register start bit */
 zexpect_true((*tsc_cr & TSC_CR_START_Msk) >> TSC_CR_START_Pos);

 zexpect_ok(stm32_tsc_poll_for_acquisition(tsc_dev, K_MSEC(100)));

 /* test for max count error flag */
 zexpect_false((*tsc_isr & TSC_ISR_MCEF_Msk) >> TSC_ISR_MCEF_Pos);

 uint32_t value;
 zexpect_ok(stm32_tsc_read(tsc_dev, 6, &value));
}

The next test will be to test the interrupt driven acquisition. This test will run only if the CONFIG_STM32_TSC_INTERRUPT is enabled.

static volatile bool tsc_input_received;

static void      tsc_input_callback(struct input_event *evt, void *user_data)
{
 ARG_UNUSED(evt);
 ARG_UNUSED(user_data);

 tsc_input_received = true;
}
INPUT_CALLBACK_DEFINE(DEVICE_DT_GET(TSC_NODE), tsc_input_callback, NULL);

ZTEST(stm32_tsc, test_5_acquisition_interrupt)
{
 Z_TEST_SKIP_IFNDEF(CONFIG_STM32_TSC_INTERRUPT);

 TSC_TypeDef  *tsc = (TSC_TypeDef *)DT_REG_ADDR(TSC_NODE);
 volatile const uint32_t *tsc_cr = &tsc->CR;
 volatile const uint32_t *tsc_ier = &tsc->IER;
 volatile const uint32_t *tsc_isr = &tsc->ISR;

 tsc_input_received = false;

 stm32_tsc_start(DEVICE_DT_GET(TSC_NODE));

 /* test CR register start bit */
 zexpect_true((*tsc_cr & TSC_CR_START_Msk) >> TSC_CR_START_Pos);

 /* check if interrupts are enabled */
 zexpect_true(*tsc_ier & TSC_IER_EOAIE_Msk);
 zexpect_true(*tsc_ier & TSC_IER_MCEIE_Msk);

 k_sleep(K_MSEC(100));

 /* test ISR register max count error flag */
 zexpect_false((*tsc_isr & TSC_ISR_MCEF_Msk) >> TSC_ISR_MCEF_Pos);

 /* this should fail because of the sync pin */
 zexpect_false(tsc_input_received);

 zexpect_ok(gpio_pin_toggle_dt(&signal));

 k_sleep(K_MSEC(100));

 zexpect_true(tsc_input_received);
}

Here in this test a 100ms window is given to allow TSC peripheral to start acqusition and put the value to the input subsystem. The tsc_input_callback function is defined to set a flag when an input event is received.

Notice how we use signal and gpio_pin_toggle_dt to mock and test the presence and absence of an external trigger for the TSC peripheral.

Running the Tests

We will use the west extension commands to use twistter and run the tests.

west twister -T tests/drivers/misc/stm32_tsc --device-testing --device-serial COM4 -p stm32u083c_dk

In this command we first select a specific test suite with -T flag, then we enable the device testing with --device-testing flag, and then we select the serial port with --device-serial flag and finally we select the board with -p flag, which stands for platform.

Note

A physical connection with a jumper between PA10 and PD2 should be made before running the tests.

Twister will upload this and read the output from the serial port and let us know how it went. But for the fun of it, we can connect to the serial port with a terminal emulator and see the output.

---- Opened the serial port COM4 ----
*** Booting Zephyr OS build 6aae5648ff4d ***
Running TESTSUITE stm32_tsc
===================================================================
START - test_1_device_ready
 PASS - test_1_device_ready in 0.001 seconds
===================================================================
START - test_2_cr_reg
 PASS - test_2_cr_reg in 0.001 seconds
===================================================================
START - test_3_group_registers
 PASS - test_3_group_registers in 0.001 seconds
===================================================================
START - test_4_acquisition_polling
 SKIP - test_4_acquisition_polling in 0.001 seconds
===================================================================
START - test_5_acquisition_interrupt
 PASS - test_5_acquisition_interrupt in 0.201 seconds
===================================================================
TESTSUITE stm32_tsc succeeded

------ TESTSUITE SUMMARY START ------

SUITE PASS - 100.00% [stm32_tsc]: pass = 4, fail = 0, skip = 1, total = 5 duration = 0.205 seconds
 - PASS - [stm32_tsc.test_1_device_ready] duration = 0.001 seconds
 - PASS - [stm32_tsc.test_2_cr_reg] duration = 0.001 seconds
 - PASS - [stm32_tsc.test_3_group_registers] duration = 0.001 seconds
 - SKIP - [stm32_tsc.test_4_acquisition_polling] duration = 0.001 seconds
 - PASS - [stm32_tsc.test_5_acquisition_interrupt] duration = 0.201 seconds

------ TESTSUITE SUMMARY END ------

===================================================================
RunID: 54191c69b895b72ac69c072ecf65e1d0
PROJECT EXECUTION SUCCESSFUL

Looks like everything is working just fine. Perfect!

Wrapping Up

We've implemented the driver and tested it on the hardware. This is now complete as a driver but still not usable as is, in a future post I'll implement a library that uses this driver to generate input events that are usable (like actual press detection), instead of using the capture value and putting it in the input subsystem, which is not the ideal use of this subsystem at the moment.

Comments