Making SPI work on the ROCK64 (and similar Linux SBCs)

Recently I needed to connect a SPI device to a ROCK64 SBC running mainline Linux (5.19 at the time of writing) for use with a userspace SPI driver (spidev) as Linux doesn't have a device class nor a driver for it. I initially thought this would be quite simple, but it turns out there are a surprising amount of quirks, so I decided to document the process here. If you're just here for the device tree overlay, it is at the very end of the post.

The way Linux knows what devices are present and should be exposed on a given SBC is through a Device Tree, a piece of data which describes all hardware exposed on a given SBC. For a lot of the more popular SBCs (like the ROCK64) these are maintained by the Linux kernel developers in the main source tree. By convention there are usually at least two device tree source files which describe a board: One describing the peripherals the SoC (System on Chip) has and one describing which ones are wired up on a particular board and to what. Linux's device trees have a policy of not enabling peripherals if nothing is connected to them on a board, so if you wire up hardware to your SBC, you most likely need to extend the device tree to cover that extra hardware.

Looking at the ROCK64 Device Tree we can see that on it the spi0 controller being enabled and configured:

 &spi0 {
    // Enable the spi0 controller (remember the SoC device tree disables all peripherals just connected to the outside by convention)
	status = "okay";

    // Define a flash device on Chip Select 0 (the number after the @)
	flash@0 {
        // What driver to load for this device
		compatible = "jedec,spi-nor";
        // reg is the Chip Select again and must match the number after the @ of the definition.
		reg = <0>;
        
        // ...
	};
};

So this tells us that the ROCK64 board uses the spi0 controller from the SoC and has a SPI NOR flash fitted which occupies Chip Select 0. So to connect my device I either need to use a different SPI controller (spi1) or a different Chip Select on spi0. As it turns out spi1 is not available on the ROCK64 because the RK3328 doesn't have enough pins (there's also the 3368 and 3399 with more), so my only option is to use a second chip select on spi0.

In device tree form this looks like this:

&spi0 {
	status = "okay";
	// Tell the SPI controller that the second CS is used
	num-cs = 2;
    
	// Pins for the SPI controller, documented below
	pinctrl-0 = <&spi0m2_clk &spi0m2_tx &spi0m2_rx &spi0m2_cs0 &spi0_cs1>;
    
	flash@0 {
        // ...
	};
	spidev@1 {
		compatible = "linux,spidev";
		// Enable our spidev
		status = "okay";
		// Again should match the CS line (@1 in this case)
		reg = <1>; 
		// The maximum frequency the SPI slave supports
		// (and your wiring too, floating wires respond poorly to high frequencies).
		spi-max-frequency = <10000000>;
	};
};

I told the SPI controller that I now need two of its SPI chip select lines and defined my spidev on chip select 1. There is one complicated part about this, and that is pinctrl-0. This tells the SPI controller which pins to use and tells the pin controller to switch them to the correct mode. The Linux spi0 definition is actually the same, except that I added spi0_cs1, which is the additional chip select that I'm using for my device. Now here it gets weird. The RK3328 has three GPIO map modes (m0, m1 and m2) for the SPI peripheral. I need to run spi0 in m2 because otherwise the SPI flash doesn't work because it is wired to the pins as mapped in M2 mode. Also for both other modes not all pins are on the physical connector. But there is no spi0m2_cs1 on the chip. So there aren't actually two chip selects available on the board. So I'm stuck.

Or am I? Linux has this nice feature called cs-gpio, which is essentially just using GPIOs as chip selects. Instead of having the hardware SPI controller control the chip select line, Linux does it via GPIO. This is obviously slower, but the chip select line is very infrequently toggled so in practice this doesn't really slow things down. Now according to the documentation for this feature, the resulting device tree should look something like this:

&spi0 {
	pinctrl-0 = <&spi0m2_clk &spi0m2_tx &spi0m2_rx &spi0m2_cs0 &spi0_cs1>;
	// Use the native CS and as a second one GPIO3 pin 7 (GPIO3_A7) as as ACTIVE_LOW (1)
	cs-gpios = <0>, <&gpio3 7 1>;

	spidev@1 {
		compatible = "linux,spidev";
		status = "okay";
		reg = <1>;
		spi-max-frequency = <10000000>;
	};
}
// ...
&pinctrl {
	// ...
	spidev1 {
		spi0_cs1: spi0-cs1 {
        	// GPIO 3 Pin 7 Mode GPIO (0), pull up
 			rockchip,pins = <3 7 0 &pcfg_pull_up>;
 		};
 	};
};

One more thing I need to change is that I've been using the linux,spidev compatible on my spidev. This is however no longer supported on recent Linux versions due to a questionable design decision that each userspace device needs its own compatible string upstreamed into Linux. As this is a hobby project, I'm just going to hijack one already on the list, like cisco,spi-petra. If you're building something that's going to ship to millions you might want to actually get your own compatible string into Linux.

On most SPI controllers I'd now be done. But as it turns out the Rockchip SPI controller is kind of broken. It accepts this and happily gives me /dev/spidev0.1. But there is one big problem: If I'm talking to my spidev, both chip select lines go low. This is very bad and could damage hardware because the bus can be occupied by two devices at the same time. From looking at the code this is a hardware limitation of the Rockchip SPI controller. It needs to always toggle its native chip select, otherwise it doesn't make progress. So I'm stuck again.

Luckily there is an (admittedly kind of hacky) solution to this as well. Let's just use two GPIO chip selects and disconnect the native one. Then the SPI controller can happily toggle the native chip select, it is not connected to anything anymore.

&spi0 {
	cs-gpios = <&gpio3 8 1>, <&gpio3 7 1>;
};
&pinctrl {
	// ...
    spi0-2 {
		// ...
		// Override definition of spi0m2_cs0
		spi0m2_cs0: spi0m2-cs0 {
			// GPIO3 Pin 8/B0 Mode GPIO (0), pull up
			// Note the mode change from 4 to 0, this switches the pin from being controlled by the SPI controller to a GPIO
			rockchip,pins = <3 8 0 &pcfg_pull_up>;
		};
	};
	spidev1 {
		spi0_cs1: spi0-cs1 {
        	// GPIO 3 Pin 7 Mode GPIO (0), pull up
 			rockchip,pins = <3 7 0 &pcfg_pull_up>;
 		};
 	};
};

And with that, I can talk to both devices without issues!

Now for the last part, making this composable. Until now I've used excerpts of the full device tree to document what I did. This is however a relatively poor way of implementing something like this as I'd have to update my device tree for every change the Linux developers make. Let's instead use a device tree overlay where I just overlay my device tree changes on top of the normal device tree.

And this is it:

/dts-v1/;
/plugin/;
/ {
	compatible = "pine64,rock64", "rockchip,rk3328";
	fragment@0 {
		target = <&spi0>;
		__overlay__ {
			#address-cells = <0x1>;
			#size-cells = <0>;

			pinctrl-0 = <&spi0m2_clk &spi0m2_tx &spi0m2_rx &spi0m2_cs0 &spi0_cs1>;
			
			cs-gpios = <&gpio3 8 1>, <&gpio3 7 1>;

			spidev@1 {
				// Hijack random compatible as I have no desire to rebuild Linux to
				// include a custom device ID in spidev's compatible list.
				compatible = "cisco,spi-petra";
				status = "okay";
				reg = <1>;
				spi-max-frequency = <10000000>;
			};
		};
	};
	fragment@1 {
		target = <&pinctrl>;
		__overlay__ {
			spidev1 {
				spi0_cs1: spi0-cs1 {
					rockchip,pins = <3 7 0 &pcfg_pull_up>;
				};
			};
		};
	};
	fragment@2 {
		target = <&spi0m2_cs0>;
		__overlay__ {
			rockchip,pins = <3 8 0 &pcfg_pull_up>;
		};
	};
};

It looks very similar to the last device tree, except that the changes are encapsulated in fragments, a compatible is added to indicate for which boards this overlay is and cell geometry (#address-cells, #size-cells) is added because it cannot currently be inherited from the base device tree. Applying this overlay is highly bootloader- and distro-specific, so I'm leaving that part out of this post.