Linux

NAND Flash on STM32MP135

Published 13 Mar 2026. By Jakob Kastelic.

This is Part 10 in the series: Linux on STM32MP135. See other articles.

Getting the NAND flash peripheral to work on the STM32MP135 appears tricky since the evaluation board does not include it. In this article, we’ll check my connections; we’ll extract the relevant parameters from the datasheet, and try to use the HAL drivers to access the memory chip on my custom STM32MP135 board. Then, we’ll set things up so we can boot the kernel from it.

Connections

I am using the MX30LF4G28AD-TI NAND flash (512 MB) chip, connecting to the STM32MP135FAE SoC, as follows:

NAND pinNAND signalSoC signalSoC pinNotes
9CE#PG9/FMC_NCEE110k to VDD_NAND
16ALEPD12/FMC_ALEC6
17CLEPD11/FMC_CLEE2
8RE#PD4/FMC_NOEE7
18WE#PD5/FMC_NWEA6
7R/B#PA9/FMC_NWAITA210k to VDD_NAND
29I/O0PD14/FMC_D0B3
30I/O1PD15/FMC_D1C7
31I/O2PD0/FMC_D2E4
32I/O3PD1/FMC_D3D5
41I/O4PE7/FMC_D4A5
42I/O5PE8/FMC_D5A7
43I/O6PE9/FMC_D6B6
44I/O7PE10/FMC_D7B8
19WP#PWR_ONP14via 10k
38PT10k to GND

VDD_NAND is derived from +3.3V, switched on the NAND_WP# signal: when NAND_WP# is low, VDD_NAND is floating, and with NAND_WP# is high, VDD_NAND is powered from +3.3V.

Furthermore, a 1N5819WS diode allows the system RESET# to pull NAND_WP# low when asserted, to assert write protect when system is under reset. When system is not under reset (i.e., RESET# is high), the diode prevents opposite current flow. A 10k resistor is connected between PWR_ON and NAND_WP# to prevent shorting PWR_ON to ground when reset is asserted (low).

Voltages and power switching

The power supply and related voltages read as follows:

NodeOperation [V]Reset [V]
+3.3V3.3003.306
RESET#3.2950.000
VDD_NAND0.0000.001
PWR_ON3.3013.303
NAND_WP#3.2020.169

The problem immediately jumps out at us: the power switch does not work. Regardless of the state of NAND_WP#, the VDD_NAND node stays around zero.

The power switch is an NCP380, more precisely the C145185 from the JLCPCB parts library in the UDFN6 package (NCP380HMUAJAATBG). The active enable level is “High”, which is correct, but the over current limit is “Adj.” To fix this, we have to solder a resistor (anything from 10k to 33k would do) between pin 2 of the switch and ground.

With this fix, VDD_NAND reads 3.300V in normal operation, and in reset, 0.5V slowly decaying towards zero.

NAND parameters

The NAND datasheet is 93 pages long and includes a lot of numbers, but not so many as the DDR chip. The STM32MP135 bare-metal BSP package (STM32CubeMP13) includes the FMC NAND driver, and a code example, and this will be our starting point. The following parameters can be easily read off the NAND datasheet:

Let’s leave the following parameters as in the ST example for now:

/* hnand Init */
hnand.Instance  = FMC_NAND_DEVICE;
hnand.Init.NandBank        = FMC_NAND_BANK3; /* Bank 3 is the only available with STM32MP135 */
hnand.Init.Waitfeature     = FMC_NAND_WAIT_FEATURE_ENABLE; /* Waiting enabled when communicating with the NAND */
hnand.Init.MemoryDataWidth = FMC_NAND_MEM_BUS_WIDTH_8; /* An 8-bit NAND is used */
hnand.Init.EccComputation  = FMC_NAND_ECC_DISABLE; /* The HAL enable ECC computation when needed, keep it disabled at initialization */
hnand.Init.EccAlgorithm    = FMC_NAND_ECC_ALGO_BCH; /* Hamming or BCH algorithm */
hnand.Init.BCHMode         = FMC_NAND_BCH_8BIT; /* BCH4 or BCH8 if BCH algorithm is used */
hnand.Init.EccSectorSize   = FMC_NAND_ECC_SECTOR_SIZE_512BYTE; /* BCH works only with 512-byte sectors */
hnand.Init.TCLRSetupTime   = 2;
hnand.Init.TARSetupTime    = 2;

/* ComSpaceTiming */
FMC_NAND_PCC_TimingTypeDef ComSpaceTiming = {0};
ComSpaceTiming.SetupTime = 0x1;
ComSpaceTiming.WaitSetupTime = 0x7;
ComSpaceTiming.HoldSetupTime = 0x2;
ComSpaceTiming.HiZSetupTime = 0x1;

/* AttSpaceTiming */
FMC_NAND_PCC_TimingTypeDef AttSpaceTiming = {0};
AttSpaceTiming.SetupTime = 0x1A;
AttSpaceTiming.WaitSetupTime = 0x7;
AttSpaceTiming.HoldSetupTime = 0x6A;
AttSpaceTiming.HiZSetupTime = 0x1;

The following numbers we can easily read off the datasheet:

hnand.Config.PageSize = 4096;     // bytes
hnand.Config.SpareAreaSize = 256; // bytes
hnand.Config.BlockSize = 64;      // pages
hnand.Config.BlockNbr = 4096;     // blocks
hnand.Config.PlaneSize = 1024;    // blocks
hnand.Config.PlaneNbr = 2;        // planes

Initialization

We enable the FMC clock and the relevant GPIOs and then configure pin muxing (same as the ST example code):

/* Common GPIO configuration */
GPIO_InitTypeDef GPIO_Init_Structure;
GPIO_Init_Structure.Mode      = GPIO_MODE_AF_PP;
GPIO_Init_Structure.Pull      = GPIO_PULLUP;
GPIO_Init_Structure.Speed     = GPIO_SPEED_FREQ_VERY_HIGH;

/* STM32MP135 pins: */
GPIO_Init_Structure.Alternate = GPIO_AF10_FMC;
SetupGPIO(GPIOA, &GPIO_Init_Structure, GPIO_PIN_9); /* FMC_NWAIT: PA9 */
GPIO_Init_Structure.Alternate = GPIO_AF12_FMC;
SetupGPIO(GPIOG, &GPIO_Init_Structure, GPIO_PIN_9); /* FMC_NCE: PG9 */
SetupGPIO(GPIOD, &GPIO_Init_Structure, GPIO_PIN_4); /* FMC_NOE: PD4 */
SetupGPIO(GPIOD, &GPIO_Init_Structure, GPIO_PIN_5); /* FMC_NWE: PD5 */
SetupGPIO(GPIOD, &GPIO_Init_Structure, GPIO_PIN_12); /* FMC_ALE: PD12 */
SetupGPIO(GPIOD, &GPIO_Init_Structure, GPIO_PIN_11); /* FMC_CLE: PD11 */
SetupGPIO(GPIOD, &GPIO_Init_Structure, GPIO_PIN_14); /* FMC_D0: PD14 */
SetupGPIO(GPIOD, &GPIO_Init_Structure, GPIO_PIN_15); /* FMC_D1: PD15 */
SetupGPIO(GPIOD, &GPIO_Init_Structure, GPIO_PIN_0); /* FMC_D2: PD0 */
SetupGPIO(GPIOD, &GPIO_Init_Structure, GPIO_PIN_1); /* FMC_D3: PD1 */
SetupGPIO(GPIOE, &GPIO_Init_Structure, GPIO_PIN_7); /* FMC_D4: PE7 */
SetupGPIO(GPIOE, &GPIO_Init_Structure, GPIO_PIN_8); /* FMC_D5: PE8 */
SetupGPIO(GPIOE, &GPIO_Init_Structure, GPIO_PIN_9); /* FMC_D6: PE9 */
SetupGPIO(GPIOE, &GPIO_Init_Structure, GPIO_PIN_10); /* FMC_D7: PE10 */

I verified that the alternate functions for all the NAND-related pins are exactly as given in the STM32MP135 datasheet.

The firmware can now call HAL_NAND_Init(). It succeeds, but then HAL_NAND_Reset() fails. It writes NAND_CMD_STATUS (0x70), but reads back 0xff rather than NAND_READY (0x40).

On the scope, we can see that the CE# signal goes low for about 50ns.

Comparing the connection table shown above to the NAND datasheet, we notice that unfortunately ALE and CLE have been swapped. The correct pin assignment would be CLE on pin 16 and ALE on pin 17, opposite to the PCB wiring.

Swapping ALE / CLE

I thought I could swap the wires by soldering to raw PCB traces, but I gave up on that plan and made a Rev B PCB. Then, the fmc_init() function just works, and I learned that my chip reports the following information (defines so that the bootloader can verify the chip at boot):

#define FMC_MAKER             0xC2U
#define FMC_DEV               0xDCU
#define FMC_3RD               0x90U
#define FMC_4TH               0xA2U

With that, we can ask AI to cook up some code to read and write to the NAND Flash and use it as the storage backend for the USB MSC code instead of the SD card. We’re now running out of SRAM space, so we can use conditional compilation flags to disable SD card when NAND is used and vice versa.

NAND initialization

First the FMC needs to be initialized, which we’ll do in fmc_ini(). This function uses the ST HAL to do the following tasks:

Now the NAND chip is presumed ready to use.

Bad blocks

Unlike SD cards, NAND chips do not attempt any automatic error correction. Up to about 2% of the blocks are bad, which seems a shockingly high percentage for anyone not used to manual error correction.

Thus, we first need to scan the NAND chip for bad blocks (before erasing it!) as recommended in the MX30LF4G28AD-TI datasheet:

The bad blocks are included in the device while it gets shipped. During the time of using the device, the additional bad blocks might be increasing; therefore, it is recommended to check the bad block marks and avoid using the bad blocks. Furthermore, please read out the bad block information before any erase operation since it may be cleared by any erase operation.

While the device is shipped, the value of all data bytes of the good blocks are FFh. The 1st byte of the 1st and 2nd page in the spare area for bad block will be 00h. The erase operation at the bad blocks is not recommended.

The fmc_scan() function just iterates over all blocks, using the following function to check if the block is good or bad:

static int is_bad_oob(uint32_t blk)
{
   uint8_t oob[FMC_OOB_SIZE_BYTES];
   NAND_AddressTypeDef a = page_addr(blk, 0);
   if (HAL_NAND_Read_SpareArea_8b(&hnand, &a, oob, 1) != HAL_OK)
      return 1;
   if (oob[0] != 0xFFU)
      return 1;
   a = page_addr(blk, 1);
   if (HAL_NAND_Read_SpareArea_8b(&hnand, &a, oob, 1) != HAL_OK)
      return 1;
   return oob[0] != 0xFFU;
}

The NAND datasheet further recommends keeping a table of the bad blocks in the application:

Although the initial bad blocks are marked by the flash vendor, they could be inadvertently erased and destroyed by a user that does not pay attention to them. To prevent this from occurring, it is necessary to always know where any bad blocks are located. Continually checking for bad block markers during normal use would be very time consuming, so it is highly recommended to initially locate all bad blocks and build a bad block table and reference it during normal NAND flash use. This will prevent having the initial bad block markers erased by an unexpected program or erase operation. Failure to keep track of bad blocks can be fatal for the application. For example, if boot code is programmed into a bad block, a boot up failure may occur.

In the bootloader code, we keep the bad blocks tables as a simple file-global static array:

static uint8_t bad[FMC_PLANE_NBR * FMC_PLANE_SIZE_BLOCKS];

With that implemented, the code prints on boot the number of bad blocks found, and we can manually rescan:

FMC: 3 bad block(s) found
> fmc_scan
bad: blk 1699
bad: blk 1735
bad: blk 1761
scan done: 3 bad / 2048 total

Basic tests

Now that we know where all the bad blocks are, we can erase the device, either just a specified number of blocks, or the whole:

> fmc_erase 10
FMC: erasing 10 blocks
done: 0 pre-marked bad, 0 newly bad, 0 s, avg 96.1 MB/s

> fmc_erase 100
FMC: erasing 100 blocks
done: 0 pre-marked bad, 0 newly bad, 0 s, avg 105.0 MB/s

> fmc_erase
FMC: erasing 2048 blocks
skip 1699 (pre-marked bad) 0 new-bad)  104.9 MB/s
skip 1735 (pre-marked bad)
skip 1761 (pre-marked bad)
done: 3 pre-marked bad, 0 newly bad, 4 s, avg 104.5 MB/s

We can directly test the write and the read:

> fmc_test_write
FMC write: 2048 blocks
blk 2043/2048  4.9 MB/s  (0 errs)
done: 0 errs, 102 s, avg 4.9 MB/s

> fmc_test_read
FMC read: 2048 blocks
blk 2040/2048  3.7 MB/s  (1002651436 bit errs)
done: 0 rd errs, 1002651436 bit errs (post-ECC), 137 s, avg 3.7 MB/s

Next, we test the USB interface. To simplify matters, the USB interface will present itself as a Mass Storage Class (flash drive) to the host, but will write all data directly to a 256MB block set aside in the DDR memory, and also read it from there. Later, when the data is transferred from the host to the target, we can commit it to flash with a separate command, and also read it from there.

To test the write, we will create a big file full of random data:

dd if=/dev/urandom of=file_256M.dat bs=1M count=256

The write from USB to DDR worked at 17.6 MB/s, which is respectable. A prior USB test showed a ~7 MB/s limit; I presume we now have a more efficient implementation. Immediate USB read for verification showed 19.3 MB/s and all data was received correctly. With the data transferred onto the DDR RAM of the target, we can now commit it to the flash memory:

> fmc_flush
FMC flush: 1024 blocks
blk 1007/1024  2.2 MB/s  (1007 written, 0 skipped)
done: 1024 written, 0 skipped, 0 new-bad, 111 s, avg 2.2 MB/s

If we trigger another flush immediately afterwards, it works much faster since instead of erasing and re-writing, we just read and skip if the block is the same as what’s there already:

> fmc_flush
FMC flush: 1024 blocks
blk 1014/1024  4.8 MB/s  (0 written, 1014 skipped)
done: 0 written, 1024 skipped, 0 new-bad, 52 s, avg 4.8 MB/s

The data is now written on the flash. We can reset the device, disconnect it from power, and when it starts up again, perhaps a year later, it should still retain the data. In this case, I did a reset and then used the load operation to return the data from NAND flash to the DDR memory:

> fmc_load
FMC load: 1024 blocks
blk 1008/1024  5.1 MB/s  (0 rd errs)
done: 0 rd errs, 49 s, avg 5.1 MB/s

Now we can read the data from the USB interface. Again it reads at about 19.5 MB/s and all data has been received correctly. Great, we now have a working flash drive!

Boot from NAND flash

Instead of flashing 256M of random bits, we can now load an image that contains the bootloader, kernel, etc. Again we copy it over the USB MSC interface to DDR RAM, and then call fmc_flush to commit it to the NAND flash memory. Then, we can run a function to check that the first two blocks contain the bootloader with a valid STM32 header:

> fmc_test_boot
boot check: block 0
  version 2.0  image_len 129284  entry 0x2ffe0000  load 0x2ffe0000
  ext header: OK
  checksum OK (0x00c9f3b8)
boot check: block 1
  version 2.0  image_len 129284  entry 0x2ffe0000  load 0x2ffe0000
  ext header: OK
  checksum OK (0x00c9f3b8)
partition table: block 2
  checksum OK  total_blocks 138  5 partition(s)
  [0] bootloader        block 0  len 2
  [1] dtb               block 3  len 1
  [2] kernel            block 4  len 34
  [3] rootfs            block 38  len 100
  [4] ptable            block 2  len 1
DTB: block 3
  FDT magic OK  totalsize 53981 bytes

With that checked, we can set the boot pins to 011 to force boot from NAND, hit reboot, and watch in marvel as the system boots up just fine from NAND. No more need for the expensive SD cards and their sockets!

Enabling NAND in Buildroot and kernel

To make it work with Linux, we enable the Linux support for the UBI file system by enabling the following flags (in my case they were enabled already, so no change):

CONFIG_MTD_UBI
CONFIG_MTD_NAND_STM32_FMC2
CONFIG_UBIFS_FS

Next, we need to tell Buildroot about our flash drive. Define the following additional keys (either in the defconfig directly, or open menuconfig and find them there—I did the latter):

BR2_TARGET_ROOTFS_UBI=y
BR2_TARGET_ROOTFS_UBI_PEBSIZE=0x40000
BR2_TARGET_ROOTFS_UBI_SUBSIZE=4096
BR2_TARGET_ROOTFS_UBIFS_LEBSIZE=0x3e000
BR2_TARGET_ROOTFS_UBIFS_MINIOSIZE=0x1000
BR2_TARGET_ROOTFS_UBIFS_MAXLEBCNT=1800

To determine MAXLEBCNT, we figure as follows:

To be safe, let’s round down to 1800.

When we rebuild Buildroot, we notice new images appear under buildroot/output/images: rootfs.ubi and rootfs.ubifs. Use rootfs.ubi—it’s the complete UBI image ready to write to flash. rootfs.ubifs is just the inner filesystem; it still needs to be wrapped in a UBI volume, which is what rootfs.ubi already is.

Configure the DTS

The same kernel will work both with the SD card and the NAND flash; that’s the point of the DTS. But this means we need to enable NAND support in the DTS, as follows:

  1. Add an FMC NAND pinctrl group inside the pinctrl@50002000 node (matching

the exact pins the bootloader configures):

fmc_nand_pins: fmc-nand-0 {
	pins1 {
		pinmux = <STM32_PINMUX('D', 14, AF12)>,  /* FMC_D0  */
			 <STM32_PINMUX('D', 15, AF12)>,  /* FMC_D1  */
			 <STM32_PINMUX('D',  0, AF12)>,  /* FMC_D2  */
			 <STM32_PINMUX('D',  1, AF12)>,  /* FMC_D3  */
			 <STM32_PINMUX('E',  7, AF12)>,  /* FMC_D4  */
			 <STM32_PINMUX('E',  8, AF12)>,  /* FMC_D5  */
			 <STM32_PINMUX('E',  9, AF12)>,  /* FMC_D6  */
			 <STM32_PINMUX('E', 10, AF12)>,  /* FMC_D7  */
			 <STM32_PINMUX('D',  4, AF12)>,  /* FMC_NOE */
			 <STM32_PINMUX('D',  5, AF12)>,  /* FMC_NWE */
			 <STM32_PINMUX('D', 11, AF12)>,  /* FMC_CLE */
			 <STM32_PINMUX('D', 12, AF12)>,  /* FMC_ALE */
			 <STM32_PINMUX('G',  9, AF12)>;  /* FMC_NCE */
	 	bias-disable;
	 	drive-push-pull;
	 	slew-rate = <3>;
	};
	pins2 {
		pinmux = <STM32_PINMUX('A', 9, AF10)>;   /* FMC_NWAIT */
		bias-disable;                              /* external 10k pull-up */
	};
};
  1. Enable the FMC and NAND nodes and add the nand@0 device with partitions:

fmc: memory-controller@58002000 {
	compatible = "st,stm32mp1-fmc2-ebi";
	reg = <0x58002000 0x1000>;
	ranges = <0 0 0x60000000 0x04000000>, /* EBI CS 1 */
		 <1 0 0x64000000 0x04000000>, /* EBI CS 2 */
		 <2 0 0x68000000 0x04000000>, /* EBI CS 3 */
		 <3 0 0x6c000000 0x04000000>, /* EBI CS 4 */
		 <4 0 0x80000000 0x10000000>; /* NAND */
	#address-cells = <2>;
	#size-cells = <1>;
	clocks = <&rcc FMC_K>;
	resets = <&rcc FMC_R>;
	feature-domains = <&etzpc STM32MP1_ETZPC_FMC_ID>;
	pinctrl-names = "default";
	pinctrl-0 = <&fmc_nand_pins>;
	status = "okay";

	nand-controller@4,0 {
		compatible = "st,stm32mp1-fmc2-nfc";
		reg = <4 0x00000000 0x1000>,
		      <4 0x08010000 0x1000>,
		      <4 0x08020000 0x1000>,
		      <4 0x01000000 0x1000>,
		      <4 0x09010000 0x1000>,
		      <4 0x09020000 0x1000>;
		#address-cells = <1>;
		#size-cells = <0>;
		interrupts = <GIC_SPI 49 IRQ_TYPE_LEVEL_HIGH>;
		dmas = <&mdma 24 0x2 0x12000a02 0x0 0x0>,
		       <&mdma 24 0x2 0x12000a08 0x0 0x0>,
		       <&mdma 25 0x2 0x12000a0a 0x0 0x0>;
		dma-names = "tx", "rx", "ecc";
		status = "okay";

		nand@0 {
			reg = <0>;
			nand-on-flash-bbt;
			nand-ecc-algo = "bch";
			nand-ecc-strength = <8>;
			nand-ecc-step-size = <512>;
			#address-cells = <1>;
			#size-cells = <1>;

			partitions {
				compatible = "fixed-partitions";
				#address-cells = <1>;
				#size-cells = <1>;

				partition@0 {
				    label = "bootloader";
				    reg = <0x00000000 0x00080000>; /* blocks 0-1 */
				    read-only;
				};
				partition@80000 {
				    label = "ptable";
				    reg = <0x00080000 0x00040000>; /* block 2 */
				    read-only;
				};
				partition@c0000 {
				    label = "dtb";
				    reg = <0x000c0000 0x00040000>; /* block 3 */
				};
				partition@100000 {
				    label = "kernel";
				    reg = <0x00100000 0x01000000>; /* blocks 4-67, 16 MB */
				};
				partition@1100000 {
				    label = "rootfs";
				    reg = <0x01100000 0x1ef00000>; /* block 68 to end, ~495 MB */
				};
			};
		};
	};
};
  1. Change bootargs in the chosen node:

bootargs = "ubi.mtd=rootfs root=ubi0:rootfs rootfstype=ubifs clk_ignore_unused";

The ubi.mtd=rootfs matches the partition label, so the kernel finds it by name regardless of MTD index.

Make the final NAND image

Package everything into a single NAND image ready for flashing:

python3 bootloader/scripts/nandimage.py \
   buildroot/output/images/nand.img \
   --boot bootloader/build/main.stm32 \
   --dtb linux/arch/arm/boot/dts/$(DTS).dtb \
   --kernel linux/arch/arm/boot/zImage \
   --rootfs
   buildroot/output/images/rootfs.ubi

Write this image to the bootloader RAM via the USB “flash drive” interface, flush it to the NAND, and just to be sure, restart the devies and verify that it was written right:

> fmc_flush
FMC flush: 115 blocks
blk 99/115  3.0 MB/s  (61 written, 38 skipped)
done: 77 written, 38 skipped, 0 new-bad, 9 s, avg 2.9 MB/s

> r
System reset requested...
bad: blk 1699
bad: blk 1735
bad: blk 1761
scan done: 3 bad / 2048 total

> Press any key to stop autoload ..

> fmc_test_boot
boot check: block 0
  version 2.0  image_len 129284  entry 0x2ffe0000  load 0x2ffe0000
  ext header: OK
  checksum OK (0x00cbcb26)
boot check: block 1
  version 2.0  image_len 129284  entry 0x2ffe0000  load 0x2ffe0000
  ext header: OK
  checksum OK (0x00cbcb26)
partition table: block 2
  checksum OK  total_blocks 115  5 partition(s)
  [0] bootloader        block 0  len 2
  [1] dtb               block 3  len 1
  [2] kernel            block 4  len 34
  [3] rootfs            block 68  len 47
  [4] ptable            block 2  len 1
DTB: block 3
  FDT magic OK  totalsize 54934 bytes

Now we’re ready to boot the system: load kernel and DTB into memory, and start it up!

> fmc_bload
bload: DTB  blk 3+1 -> 0xc4000000
bload: kernel blk 4+34 -> 0xc2000000
bload: done

> j
Jumping to address 0xC2000000...
[    0.000000] Booting Linux on physical CPU 0x0
...
[    2.581401] ubi0: attaching mtd4
[    3.352526] ubi0: scanning is finished
[    3.376597] ubi0: volume 0 ("rootfs") re-sized from 45 to 1936 LEBs
[    3.382515] ubi0: attached mtd4 (name "rootfs", size 495 MiB)
[    3.387286] ubi0: PEB size: 262144 bytes (256 KiB), LEB size: 253952 bytes
[    3.394064] ubi0: min./max. I/O unit sizes: 4096/4096, sub-page size 4096
[    3.400865] ubi0: VID header offset: 4096 (aligned 4096), data offset: 8192
[    3.407754] ubi0: good PEBs: 1973, bad PEBs: 7, corrupted PEBs: 0
[    3.413821] ubi0: user volume: 1, internal volumes: 1, max. volumes count: 128
[    3.421132] ubi0: max/mean erase counter: 1/0, WL threshold: 4096, image sequence number: 776612721
[    3.430341] ubi0: available PEBs: 0, total reserved PEBs: 1973, PEBs reserved for bad PEB handling: 33
[    3.439406] ubi0: background thread "ubi_bgt0d" started, PID 69
[    3.446064] clk: Not disabling unused clocks
[    3.453791] UBIFS (ubi0:0): Mounting in unauthenticated mode
[    3.585956] UBIFS (ubi0:0): UBIFS: mounted UBI device 0, volume 0, name "rootfs", R/O mode
[    3.593117] UBIFS (ubi0:0): LEB size: 253952 bytes (248 KiB), min./max. I/O unit sizes: 4096 bytes/4096 bytes
[    3.602971] UBIFS (ubi0:0): FS size: 454574080 bytes (433 MiB, 1790 LEBs), max 1800 LEBs, journal size 9404416 bytes (8 MiB, 38 LEBs)
[    3.614882] UBIFS (ubi0:0): reserved for root: 0 bytes (0 KiB)
[    3.620754] UBIFS (ubi0:0): media format: w4/r0 (latest is w5/r0), UUID D9C4AE58-EAE8-4A96-B306-979CE84378B9, small LPT model
[    3.643062] VFS: Mounted root (ubifs filesystem) readonly on device 0:17.
[    3.657604] devtmpfs: mounted
[    3.662754] Freeing unused kernel image (initmem) memory: 1024K
[    3.668681] Run /sbin/init as init process
[    3.959212] UBIFS (ubi0:0): background thread "ubifs_bgt0_0" started, PID 72

Welcome to Buildroot
buildroot login:

Success!

All Articles in This Series

Productivity

How We Turn Play Into Work

Published 26 Feb 2026. By Jakob Kastelic.

Most of the things we abandon were once exciting—they began as play. Then, almost without noticing, we turned them into work. Track them, schedule them, tie them to progress and identity, and tell yourself you have to improve. The fun drained out; we change how we frame the activity. The same mind that resists ten forced minutes can happily work all day when something feels optional, rich with reward, and free of burden. In other words, we don’t lose interest, we just load weight onto it until we drown in it.

Work, Play, Meditation: Three Modes of Engagement

Some definitions:

The mistake is thinking these are fixed categories. They aren’t. We slide activities from one column to another without noticing. There can be substantial overlap between the three groups. To make an activity more pleasant, palatable, or likely to happen, one needs to add or increase the amount and/or variety of rewards. Conversely, to decrease or stop an activity, remove rewards and reframe it as the pursuit of money, power, or a mere checking off of boxes.

In other words, make it something you have to do, introduce an accountability partner, tie your survival and social status with it, and you will have succeeded in making it a thoroughly unpleasant activity that will happen only if there’s really no other option. But often our intentions or desires are quite the contrary: the activity is to be encouraged and made a habit.

Optionality and the Power of Emptiness

Add rewards but reframe it as 100% optional. It never has to be done, there’s no time allocated for it, no one will get angry if you don’t do it. There’s a certain sense of “emptiness” associated with it: the activity doesn’t consume space in your mind, it’s just out there if you want to do it in a given moment. There’s no conscious tracking or keeping score (although an external reward mechanism could do the tracking, such as the elaborate programs that make social media so compelling). There’s no sense of sacrifice or dedication. With all these negatives, it may seem surprising anything would happen at all. But the underlying truth is that things arise out of nothingness: when the mind is empty, you can take on a new project, but not if you’re already juggling ten of them.

When Novelty Fades

So what happened with me with a fun hobby activity: it started with the promise of novelty, as an escape from other work-like things I was doing at the time. When the novelty wore off, there was no other reward and I was left with a sense of “I have to do it”, as if I have to practice to get somewhere with it. But no one was measuring or noticing my progress, if there ever was any. Add to it the setup cost (having to clear the table to setup the tools for the activity every time) and now I don’t do it at all.

Or to take instrument practice: you may have to do it because there’s a lesson every two weeks or so, but the subconscious mind makes sure to do all in its power to absolutely minimize the time spent on the activity to exactly and only the amount you force yourself through various spreadsheets and plans and goals: about ten minutes a day, after which you’re convinced that you’ve totally spent your “practice credits” and that my brain would be unable to continue at all until they get refilled overnight. But that’s not how the brain works: one can sustain an intellectual activity for eight hours a day or even more, given sufficient reward quantity and, crucially, type.

Hobby Programming and the Novelty Trap

To take another example, hobby programming. I started working on an app and there was freshness and quick progress, and then the thing became much more difficult, requiring to keep in mind way too much of the program structure. The initial excitement wore off and progress stopped, and the brain thought it cannot touch the code ever again. Recently I split it into tiny programs and it feels fun again, though time will tell whether that’s not just the thrill of something new again. Having said that, on hobby projects, if they’re short enough, it’s actually entirely okay to be motivated by curiosity and the excitement of exploration. Surely that’s a part of what hobbies are for: find out what’s worthwhile, and then choose one or two things and make it your primary work for a deep dive into the topic.

You can only keep exploring new things if they’re short enough so they can be completed before the sense of newness wears out. What instead happens to me is that initial enthusiasm introduces me to big projects which then get left in a half-finished (or barely-started) state when a new one comes along. Then before I know it there’s a list of 150 things to do in my pipeline, none of which give any satisfaction since they go nowhere, but they still exert a nagging sense of “I should do this”, “I should work on this”.

The Growing Queue and the Hedonic Wheel

I have recently restricted myself in such a way that new novelty projects go at the bottom of the 150-item list, while I only work on the things on the top of this pipeline, so a queue data structure. The queue length is growing, but being just a text file on a 2TB SSD, it’ll be a little while before I max out the capacity. It’s been just a few days or weeks, but already I’m feeling the futility of starting new things to escape the present. The new things become the present! The hedonic wheel spins faster and faster, while everything else stays the same, until I fall over in exhaustion, stop the wheel, dump my to-do list, and start from scratch.

Shopping, Spending, and the Illusion of Ownership

Much the same happens when shopping: an exciting new thing is bought, placed on shelf, integrated into the background noise, another new thing is bought, and the endless loop of wasting money is joined. I’d do well to append the things to buy as a list in a text file rather than stuff taking up shelf space in the apartment. In fact, so far this year I have dramatically cut down on online shopping, with happy effect on the bank balance but no observable downside. The thought I got in Sedona was the right one: when I get the urge to spend money, I can go spend it on a cause I believe in. Give it to Wikipedia, or my favorite religious organization (if I had any); donate to a school or university; plan a trip with my love.

Then there’s the futility of ownership: if I have something inside “my” apartment, does that mean I own it? Just because others are barred from using it, doesn’t really make it particularly “mine” in any significant sense. Ownership in this sense makes sense for things that I use all the time or would be unwise to share with others: my watch, some clothes, toothbrush. On the other hand, I don’t gain much by keeping my books to myself, or the GPU that I use once a month. If only there was a good way of sharing these things!

The internet is magical as a way of sharing things without yourself losing any of it. Quite the contrary: the people who are best at sharing their work openly gain tremendously: others contribute to the work and they grow in respect etc.

Linux

PWM, LCD, and CTP from Kernel on STM32MP135

Published 20 Feb 2026. By Jakob Kastelic.

This is Part 10 in the series: Linux on STM32MP135. See other articles.

Backlight with GPIO

The display backlight is controlled via a single GPIO:

panel_backlight: panel-backlight {
	compatible = "gpio-backlight";
	gpios = <&gpiob 15 GPIO_ACTIVE_HIGH>;
	default-on;
	default-brightness-level = <1>;
	status = "okay";
};

With that, the backlight can be turned on and off as follows:

# echo 1 > /sys/class/backlight/panel-backlight/brightness
# echo 0 > /sys/class/backlight/panel-backlight/brightness

There’s no tricks to this, so long as we make sure that the GPIO mentioned in the DTS matches the one on the circuit schematic.

Backlight and PWM

To control the brightness, we need to enable the PWM corresponding to this GPIO pin and make sure the panel backlight is controlled by this PWM.

We have already implemented this previously in bare-metal using the HAL driver. We can check the TIM1, GPIOB, and GPIOC registers to see what is the effect of our configuration. In the following table, “Before” and “After” are on bare metal before and after configuring the PWM output.

RegisterAddressBeforeAfter
TIM1_CR10x440000000x000000000x00000001
TIM1_SR0x440000100x000000000x0003001f
TIM1_CCMR20x4400001c0x000000000x00000068
TIM1_CCER0x440000200x000000000x00000400
TIM1_CNT0x440000240x000000000x000001eb
TIM1_PSC0x440000280x000000000x00000063
TIM1_ARR0x4400002c0x000000000x000003e7
TIM1_CCR30x4400003c0x000000000x000001f4
TIM1_BDTR0x440000440x000000000x00008000
TIM1_DMAR0x4400004c0x000000000x00000001
TIM1_AF10x440000600x000000000x00000001
TIM1_AF20x440000640x000000000x00000001
TIM1_VERR0x440003f40x000000000x00000035
TIM1_IPIDR0x440003f80x000000000x00120002
TIM1_SIDR0x440003fc0x000000000xa3c5dd01
GPIOB_MODER0x500030000xffbfffff0xbfbfffff
GPIOB_AFR[1]0x500030240x0000b0000x1000b000
GPIOC_IDR0x500040100x00001f000x00000f00
RCC_MP_APB2ENSETR0x500007080x000000000x00000001
RCC_MP_APB2ENCLRR0x5000070c0x000000000x00000001

The bare-metal bootloader configures the PWM for 3.0489 kHz operating with 50% duty cycle.

Now let’s configure the PWM in the DTS as follows:

		timers1: timer@44000000 {
			#address-cells = <1>;
			#size-cells = <0>;
			compatible = "st,stm32-timers";
			reg = <0x44000000 0x400>;
			interrupts = <GIC_SPI 25 IRQ_TYPE_LEVEL_HIGH>,
				     <GIC_SPI 26 IRQ_TYPE_LEVEL_HIGH>,
				     <GIC_SPI 27 IRQ_TYPE_LEVEL_HIGH>,
				     <GIC_SPI 28 IRQ_TYPE_LEVEL_HIGH>;
			interrupt-names = "brk", "up", "trg-com", "cc";
			clocks = <&rcc TIM1_K>;
			clock-names = "int";
			status = "okay";

			pwm1: pwm {
				compatible = "st,stm32-pwm";
				#pwm-cells = <3>;
				status = "okay";
				pinctrl-0 = <&pwm1_pins>;
				pinctrl-names = "default";
			};

When the PWM driver module is inserted, we can control the screen brightness as follows:

modprobe pwm-stm32
cd /sys/class/pwm/pwmchip0
echo 2 > export
echo 1000000 > pwm2/period
echo 500000 > pwm2/duty_cycle
echo 1 > pwm2/enable

After that, the registers read:

RegisterValue
TIM1_CR10x00000081
TIM1_SR0x0003001f
TIM1_CCMR20x00000068
TIM1_CCER0x00000500
TIM1_CNT0x00008a35
TIM1_PSC0x00000004
TIM1_ARR0x0000ee33
TIM1_CCR30x0000771a
TIM1_BDTR0x00008000
TIM1_DMAR0x00000081
TIM1_AF10x00000001
TIM1_AF20x00000001
TIM1_VERR0x00000035
TIM1_IPIDR0x00120002
TIM1_SIDR0xa3c5dd01
GPIOB_MODER0xbfbf7fff
GPIOB_AFR[1]0x1000b000
GPIOC_IDR0x00001f00
RCC_MP_APB2ENSETR0x00000001
RCC_MP_APB2ENCLRR0x00000001

Finally, we need to make the panel backlight driver automatically talk to the PWM driver so that the backlight can be adjusted as brightness and not as PWM. We add the following to the DTS:

panel_backlight: panel-backlight {
	compatible = "pwm-backlight";
	pwms = <&pwm1 2 1000000 0>;
	brightness-levels = <0 16 32 64 128 255>;
	default-brightness-level = <4>;
	power-supply = <&v3v3_ao>;
	default-on;
	status = "okay";
};

The panel_backlight is referenced from panel_rgb, but still does not turn on automatically. Apparently the pwm-backlight driver does not have a DTS-controlled “power on” option, so we have to turn it on manually:

echo 0 > /sys/class/backlight/panel-backlight/bl_power

With that, the backlight brightness and power control work as they should via the /sys/class/backlight/ controls.

Display image on LCD

Let’s add two lines to the Buildroot configuration to make the modetest command available:

BR2_PACKAGE_LIBDRM=y
BR2_PACKAGE_LIBDRM_INSTALL_TESTS=y

I am using the Rocktech RK050HR01-CT LCD display and the following DTS fragment:

panel_rgb: panel-rgb {
	compatible = "rocktech,rk050hr18-ctg", "panel-dpi";
	enable-gpios = <&gpiog 7 GPIO_ACTIVE_HIGH>;
	backlight = <&panel_backlight>;
	power-supply = <&v3v3_ao>;
	data-mapping = "rgb565";
	status = "okay";

	width-mm = <108>;
	height-mm = <65>;

	port {
		panel_in_rgb: endpoint {
			remote-endpoint = <&ltdc_out_rgb>;
		};
	};

	panel-timing {
		clock-frequency = <24000000>;
		hactive = <800>;
		vactive = <480>;
		hsync-len = <4>;
		hfront-porch = <8>;
		hback-porch = <8>;
		vsync-len = <4>;
		vfront-porch = <16>;
		vback-porch = <16>;
		hsync-active = <0>;
		vsync-active = <0>;
		de-active = <1>;
		pixelclk-active = <1>;
	};
};

Run just modetest to determine the numbers (CRTC 41, connector 32) of the display:

# modetest | head
trying to open device '/dev/dri/card0'... done
opened device `STMicroelectronics SoC DRM` on driver `stm` (version 1.0.0 at 20170330)
Encoders:
id      crtc    type    possible crtcs  possible clones
31      41      DPI     0x00000001      0x00000001

Connectors:
id      encoder status          name            size (mm)       modes   encoders
32      31      connected       DPI-1           108x65          1       31

Modetest is now able to display a test pattern on the LCD with the following command:

# modetest -M stm -s 32@41:800x480
opened device `STMicroelectronics SoC DRM` on driver `stm` (version 1.0.0 at 20170330)
setting mode 800x480-56.72Hz on connectors 32, crtc 41

Capacitive touchpad (CTP)

Adjust the pin configuration in the DTS according to the way the PCB is wired. For completeness, here’s mine:

i2c5: i2c@4c006000 {
	compatible = "st,stm32mp13-i2c";
	reg = <0x4c006000 0x400>;
	interrupt-names = "event", "error";
	interrupts = <GIC_SPI 114 IRQ_TYPE_LEVEL_HIGH>,
		     <GIC_SPI 115 IRQ_TYPE_LEVEL_HIGH>;
	clocks = <&rcc I2C5_K>;
	resets = <&rcc I2C5_R>;
	#address-cells = <1>;
	#size-cells = <0>;
	st,syscfg-fmp = <&syscfg 0x4 0x10>;
	i2c-analog-filter;
	feature-domains = <&etzpc STM32MP1_ETZPC_I2C5_ID>;
	pinctrl-names = "default", "sleep";
	pinctrl-0 = <&i2c5_pins_a>;
	pinctrl-1 = <&i2c5_sleep_pins_a>;
	i2c-scl-rising-time-ns = <170>;
	i2c-scl-falling-time-ns = <5>;
	clock-frequency = <400000>;
	status = "okay";

	goodix: goodix-ts@5d {
		compatible = "goodix,gt911";
		reg = <0x5d>;
		pinctrl-names = "default";
		pinctrl-0 = <&goodix_pins_a>;
		interrupt-parent = <&gpioh>;
		interrupts = <12 IRQ_TYPE_EDGE_FALLING>;
		reset-gpios = <&gpiob 7 GPIO_ACTIVE_LOW>;
		AVDD28-supply = <&v3v3_ao>;
		VDDIO-supply = <&v3v3_ao>;
		touchscreen-size-x = <800>;
		touchscreen-size-y = <480>;
		status = "okay" ;
	};
};

The I2C5 pins are set as follows:

i2c5_pins_a: i2c5-0 {
	pins {
		pinmux = <STM32_PINMUX('H', 13, AF4)>, /* I2C5_SCL */
			 <STM32_PINMUX('F', 3, AF4)>; /* I2C5_SDA */
		bias-disable;
		drive-open-drain;
		slew-rate = <0>;
	};
};

Now verify that the CTP driver has been initialized correctly:

# dmesg | grep -i good
[    1.071181] Goodix-TS 0-005d: ID 911, version: 1060
[    1.074418] input: Goodix Capacitive TouchScreen as /devices/platform/soc/5c007000.etzpc/4c006000.i2c/i2c-0/0-005d/input/input0

Let’s use evtest (add BR2_PACKAGE_EVTEST=y in Buildroot) to verify that we can receive touchpad events. With evtest running, we can touch the touchpad and see that the events are streaming in:

# evtest
Event: time 54.972748, -------------- SYN_REPORT ------------
Event: time 54.976560, type 3 (EV_ABS), code 53 (ABS_MT_POSITION_X), value 575
Event: time 54.976560, type 3 (EV_ABS), code 54 (ABS_MT_POSITION_Y), value 236
Event: time 54.976560, type 3 (EV_ABS), code 0 (ABS_X), value 575
Event: time 54.976560, type 3 (EV_ABS), code 1 (ABS_Y), value 236

All Articles in This Series

Linux

Debugging the Late Boot of STM32MP135

Published 19 Feb 2026. By Jakob Kastelic.

This is Part 9 in the series: Linux on STM32MP135. See other articles.

In this article we will take the custom STM32MP135 board through the end of the Linux boot process. In a previous article we saw the “Booting Linux” message for the first time; now it’s time to run our own programs under the OS and configure some of the devices.

Device Tree Source (DTS)

Having fixed the DDR issues (swizzling gone wrong) in a new revision of the board, wrote the 50 lines of Makefile needed to build the bootloader and kernel and the root file system, it only remains to fix up the device tree source.

The STMicroelectronics kernel repository contains a comprehensive DTS that works on the evaluation board, together with the pin assignment file. It’s an excellent starting point since only a few lines need to be adjusted to accommodate the slightly different pinout used on the larger BGA package (0.8mm) used on my custom board. See the DTS files here for the final result.

The board started to boot but did not find the root filesystem on the SD card, even though the bootloader successfully copied the kernel from the card into DDR. As it turns out, the board miswired the “card detect” pin, so we have to ignore it in the DTS:


&sdmmc1 {
	...
	broken-cd;
	...
}

Init and BusyBox

With the fixed DTS, the boot process finishes with this:

[    1.332380] Run /sbin/init as init process
Hello, world!
$ hello
hello: command not found

Naturally the hello command does not exist, nor any other. Mind you, my init (PID = 1) program is essentially just “Hello, world!” and entirely vibecoded at that.

The next step is to have a more useful set of tools installed on the target, such as BusyBox. We can easily enough build it as follows:

git clone git://busybox.net/busybox.git
cd busybox
make menuconfig # select static build
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf-

But when we copy the resulting binary as init, we discover that it would really like to have a proper inittab, and a couple device files. With the “Hello, world!” init, we created the root filesystem as follows:

root:
	mkdir -p build/rootfs.dir/sbin
	cp build/init build/rootfs.dir/sbin/init
	dd if=/dev/zero of=build/rootfs bs=1M count=10
	mke2fs -t ext4 -F -d build/rootfs.dir build/rootfs

But to add the device files we need to work as root or do something more clever.

Buildroot

It’s reasonable to yield to the temptation of build automation. (Why the hesitation? Package management and build systems inevitably result in a massive increase in complexity of a system, because it becomes easy to add more stuff into a build.)

Obtain Buildroot:

git clone https://gitlab.com/buildroot.org/buildroot.git

Create a very minimal buildroot/.config such as the following:

BR2_arm=y
BR2_cortex_a7=y
BR2_TOOLCHAIN_BUILDROOT_UCLIBC=y
BR2_PACKAGE_HOST_LINUX_HEADERS_CUSTOM_6_1=y
BR2_CCACHE=y
BR2_CCACHE_DIR="/tmp/buildroot-ccache"
BR2_ENABLE_DEBUG=y
BR2_SHARED_STATIC_LIBS=y
BR2_ROOTFS_DEVICE_CREATION_STATIC=y
BR2_TARGET_GENERIC_ROOT_PASSWD="root"
BR2_ROOTFS_OVERLAY="board/stmicroelectronics/stm32mp135-test/overlay"
BR2_PACKAGE_DROPBEAR=y
BR2_PACKAGE_IPERF3=y
BR2_TARGET_ROOTFS_EXT2=y
BR2_TARGET_ROOTFS_EXT2_4=y
BR2_TARGET_ROOTFS_EXT2_SIZE="5M"

Note the overlay directory: it can be empty, or you can use it to copy any additional files onto the target filesystem. Run make and a long time later (enough to get and build the toolchain and the two selected packages) the root file system image will appear under buildroot/output/images.

The advantage of this approach is that we can quickly and directly rebuild the kernel and the DTB without invoking Buildroot, while being able to quickly get all the packages compiled from Buildroot and included in the target system.

Copying DTB over ssh

So far, installing a new DTB required rebuilding the SD image and writing it to the target using our bootloader. Since DTB is a tiny file, it’s much faster if we could just change the DTB and not the whole SD card image. Let’s do it over ssh so as to easily automate it from the “50 line Makefile”.

First we need to make sure the target has an SSH server. This is provided by the Dropbear package that we have added to the Buildroot configuration. By default, ssh will ask for a password each time which is tedious and insecure. Instead, let’s copy our public key to the overlay directory:

cp ~/.ssh/id_ed25519.pub overlay/root/.ssh/authorized_keys

Moreover, each time Dropbear is started from a “clean” Buildroot-generated rootfs, it will generate its own host key which we’ll have to manually accept. Instead, let’s pre-seed the target key by copying the generated key from the target one time (replace the IP address with that of your target):

mkdir -p overlay/etc/dropbear
scp root@172.25.0.132:/etc/dropbear/dropbear_ed25519_host_key overlay/etc/dropbear

Finally, to install the new DTB over ssh, copy it over and then write it to the second SD card partition:

ssh root@172.25.0.132 "dd of=/dev/mmcblk0p2" < linux/arch/arm/boot/dts/custom.dtb

Restart handler

After installing the new DTB we need to reboot the system. This can be done by power cycling the board, but that quickly get tiresome. In the default configuration, the PSCI interface communicates with the “secure” OS or bootloader (TF-A), but to my mind that’s complexity we can do without.

Thus, we need to implement a “restart handler” so that the reboot command will be able to reboot the system. It will be very simple: it needs to flip one bit in the GRSTCSETR register. As we can read in the RM0475 reference manual for this SoC, that register has only one bit, called MPSYSRST, where writing ‘1’ generates a system reset.

Notice that the device tree already defines a driver to talk to the RCC unit:

rcc: rcc@50000000 {
	compatible = "st,stm32mp13-rcc", "syscon";
	reg = <0x50000000 0x1000>;
	#clock-cells = <1>;
	#reset-cells = <1>;
	clock-names = "hse", "hsi", "csi", "ck_lse", "lsi";
	clocks = <&clk_hse>,
		 <&clk_hsi>,
		 <&clk_csi>,
		 <&clk_lse>,
		 <&clk_lsi>;
};

This st,stm32mp13-rcc driver is to be found under drivers/clk/stm32/clk-stm32mp13.c. First we define the register and bit needed to execute the reset:

#define RCC_MP_GRSTCSETR                0x114
#define RCC_MP_GRSTCSETR_MPSYSRST       BIT(0)

Then the reset handler is very simple:

static int stm32mp1_restart(struct sys_off_data *data)
{
	void __iomem *rcc_base = data->cb_data;

	pr_info("System reset requested...\n");
	dsb(sy);
	writel(RCC_MP_GRSTCSETR_MPSYSRST, rcc_base + RCC_MP_GRSTCSETR);

	while (1)
		wfe();

	return NOTIFY_DONE;
}

Finally, we need to register this reset handler. A good place is at the bottom of stm32mp1_rcc_init():

devm_register_sys_off_handler(dev, SYS_OFF_MODE_RESTART,
	SYS_OFF_PRIO_HIGH, stm32mp1_restart, rcc_base);

All Articles in This Series

Incoherent Thoughts

One Move at a Time

Published 14 Feb 2026. By Jakob Kastelic.

Complex problems can be broken down into steps that are small and easy, or, at worst, small and tedious. A big chess game unfolds move by move. In life too, you can only make one move at a time, and then it’s the universe’s turn.

The only question is, what is my move right now? But few ask it. Be aware!