The backlight on this product worked fine for a year. FlexPWM2, submodule 3, channel A, 16 kHz into a TPS61158 LED driver. Nobody thought about it.

Then a customer-facing PWM input-capture feature landed on the same FlexPWM submodule, and the two could not coexist. A FlexPWM submodule has one shared counter across its channels and its capture logic — the modulo (VAL1) and prescaler are shared hardware, not per-channel. The backlight forced that counter to wrap every 62.5 µs. Capturing an external 1–20 kHz signal needs it free-running at a completely different time scale.

One counter, two mutually exclusive jobs, and no frequency at which both work.

In this article I show why the fix was to stop generating the PWM in the MCU at all — the panel controller already had a backlight PWM output and a standardised command to drive it — plus the two non-obvious things that nearly broke the migration.

flowchart LR
    subgraph MCU["i.MX RT1062"]
        PWM["FlexPWM2 SM3 ch.A<br>16 kHz"]
        BUS["FlexIO 8080/DBI bus"]
    end
    subgraph PANEL["ST7796S panel module"]
        CTRL["ST7796S controller<br>internal PWM block"]
        CABC["CABC_PWM pin"]
    end
    DRV["TPS61158<br>LED driver (CTRL pin)"]
    LED["LED string"]

    PWM -. "old path: DISP_BL_CTL<br>(0R link option)" .-> DRV
    BUS -->|"DCS 0x51 WRDISBV"| CTRL
    CTRL --> CABC
    CABC ==>|"new path<br>(0R link option)"| DRV
    DRV --> LED

Both paths land on the same LED driver CTRL pin. On this board the selection is a pair of 0 Ω link options, so the change was a reroute rather than a respin.

TL;DR

A backlight PWM can come from an MCU timer or from the display controller’s own PWM block. Most TFT controllers (ST7796S, ILI9341, ILI9488, ST7789, GC9A01…) implement MIPI DCS, so 0x51 / 0x53 / 0x55 set brightness over the bus you already have — and give you back a whole timer channel. Which one you use is dictated by your timer budget, not by taste.

The two architectures

AspectMCU PWM → LED driver ICController-generated PWM (DCS)
Signal sourceMCU timer peripheral toggling a GPIODisplay controller’s internal PWM block, on a dedicated pin (CABC_PWM)
How you set brightnessWrite a duty-cycle register directlySend a display command (DCS 0x51) over the panel bus
Extra hardwareNeeds an external LED driver IC between MCU pin and LED stringLED driver is usually already on the panel module
Timer/pin costConsumes one PWM channel + its GPIO permanentlyZero MCU timer channels
PWM frequency controlYou choose it (>200 Hz–20 kHz to avoid flicker or audible whine)Fixed by the panel controller — check the datasheet, you do not get to choose
Update latencyImmediate — register write, same cycleOne bus transaction, and it must not race a frame flush on a shared bus
PortabilitySame code on any panel — it is just a GPIODepends on the controller implementing DCS brightness registers

The standard that matters: MIPI DCS

If you take one thing from this post: look for the MIPI DCS spec, not the panel-specific chapters, when you are hunting for backlight registers.

Almost every TFT controller used in embedded products implements the MIPI Display Command Set, a standardised command layer sitting on top of whatever physical bus the panel uses — SPI, parallel 8080/DBI, or MIPI DSI on higher-end parts. Because it is standardised, three commands are portable across nearly every controller you will touch:

CommandNamePurpose
0x51WRDISBV — Write Display Brightness Value1-byte brightness, 0x000xFF
0x53WRCTRLD — Write CTRL DisplayEnables the brightness block: bit 5 BCTRL, bit 3 DD (dimming), bit 2 BL (backlight on)
0x55WRCABC — Write Content Adaptive Brightness Control0x00 off (manual, deterministic), 0x01 UI, 0x02 still image, 0x03 moving image

This is exactly why these registers can be hard to find in a vendor datasheet. Vendors document them tersely, or just point at “MIPI DCS” rather than re-explaining a command they did not invent. The panel-specific chapters — gamma tables, VCOM, power sequencing, timing — are spelled out per vendor, because those genuinely differ chip to chip. WRDISBV, WRCTRLD and WRCABC do not.

Tip

If a datasheet’s command table has a “DCS” column or references the MIPI Alliance Display Command Set, that is your signal these commands will transfer directly to the next panel you use, even from a different vendor.

Before: MCU FlexPWM into a TPS61158

The LED string was driven by an external TPS61158 boost/LED driver whose CTRL pin was fed from FlexPWM2 submodule 3 channel A at 16 kHz:

/* BL_CTL is on PWM2 submodule 3, channel A — fixed by silicon pad assignment */
#define BL_CTL_PWM_BASE         PWM2
#define BL_CTL_PWM_SUBMODULE    kPWM_Module_3
#define BL_CTL_PWM_CHANNEL      kPWM_PwmA
#define BL_CTL_PWM_FREQ_HZ      16000U

void BSP_Hmi_init(void)
{
    pwm_config_t pwmConfig;
    PWM_GetDefaultConfig(&pwmConfig);
    pwmConfig.clockSource   = kPWM_BusClock;
    pwmConfig.prescale      = kPWM_Prescale_Divide_1;
    pwmConfig.pairOperation = kPWM_Independent;
    PWM_Init(BL_CTL_PWM_BASE, BL_CTL_PWM_SUBMODULE, &pwmConfig);

    pwm_signal_param_t pwmSignal = {
        .pwmChannel       = BL_CTL_PWM_CHANNEL,
        .level            = kPWM_HighTrue,
        .dutyCyclePercent = 0U,
        .pwmchannelenable = true,
    };
    PWM_SetupPwm(BL_CTL_PWM_BASE, BL_CTL_PWM_SUBMODULE, &pwmSignal, 1U,
                 kPWM_SignedCenterAligned, BL_CTL_PWM_FREQ_HZ, BL_CTL_PWM_SRC_CLK_HZ);

    PWM_SetPwmLdok(BL_CTL_PWM_BASE, kPWM_Control_Module_3, true);
    PWM_StartTimer(BL_CTL_PWM_BASE, kPWM_Control_Module_3);
}

void BSP_BacklightSet(uint8_t percent)
{
    PWM_UpdatePwmDutycycle(BL_CTL_PWM_BASE, BL_CTL_PWM_SUBMODULE,
                           BL_CTL_PWM_CHANNEL, kPWM_SignedCenterAligned, percent);
    PWM_SetPwmLdok(BL_CTL_PWM_BASE, kPWM_Control_Module_3, true);
}

One counter, two jobs

Driving the backlight forced the shared submodule counter to wrap every 62.5 µs. That is incompatible with using the same counter as a time base for capturing an external 1–20 kHz signal — capture needs the counter free-running at its time scale, not the backlight’s.

Two alternatives were considered and rejected:

  • Move input capture to another timer (QTMR/GPT). Doable, but a bigger change: new pin mux, new driver, more validation.
  • Compromise on one shared frequency. Impossible. Capture needs a slow free-running wrap; the backlight needs a fast one. They cannot coexist on one counter at any frequency.

After: ST7796S CABC via MIPI DCS

The ST7796S already exposes a CABC_PWM output that drives the LED string directly from the controller’s internal PWM generator. The hardware prerequisite was rerouting the LED driver’s control input from the old MCU PWM net to the panel’s CABC_PWM — on this board a pair of 0 Ω link options, so no respin. After that, the software became a bus command instead of a timer peripheral:

/* Send a MIPI-DCS command with a single 8-bit parameter over the ST7796S DBI bus.
 * writeCommand + writeData, both blocking — matching how fsl_st7796s.c itself
 * issues commands. */
static status_t prv_st7796s_write_reg8(uint8_t cmd, uint8_t param)
{
    if (s_st7796sHandle.xferOps == NULL)
    {
        return kStatus_Fail;   /* command bus not initialized yet */
    }

    status_t status = s_st7796sHandle.xferOps->writeCommand(
                          s_st7796sHandle.xferOpsData, (uint32_t)cmd);
    if (status == kStatus_Success)
    {
        status = s_st7796sHandle.xferOps->writeData(
                     s_st7796sHandle.xferOpsData, &param, 1U);
    }
    return status;
}

void BSP_BacklightSet(uint8_t percent)
{
    if (percent > 100U) { percent = 100U; }

    /* Map 0..100% to 8-bit DBV 0..255; drives CABC_PWM on the panel side. */
    uint8_t dbv = (uint8_t)(((uint32_t)percent * 255U) / 100U);
    (void)prv_st7796s_write_reg8(0x51U, dbv);   /* WRDISBV */
}

/* Once, during display init, after the panel is enabled: */
(void)prv_st7796s_write_reg8(0x53U, 0x2CU);  /* WRCTRLD: BCTRL | DD | BL          */
(void)prv_st7796s_write_reg8(0x55U, 0x00U);  /* WRCABC:  off (manual, determinate) */
(void)prv_st7796s_write_reg8(0x51U, 0xFFU);  /* WRDISBV: full brightness at boot   */

FlexPWM2 SM3 is now fully free for input capture, BSP_BacklightSet()’s public signature did not change so no caller needed updating anywhere in the application, and brightness now costs one bus transaction instead of a timer peripheral.

The four things that nearly bit

Check your bus adapter actually implements the convenience API

writeCommandData looked like the obvious one-call API. The legacy FlexIO-eDMA transfer-ops table used on this bus does not implement it — calling it branches through a NULL pointer. Falling back to two-step writeCommand + writeData, exactly what the vendor’s own ST7796S driver does internally, avoids the trap. API surface described in a header does not guarantee every transfer-ops implementation behind it supports every entry point.
  • xferOps is NULL until display init completes, but the HMI active object’s initial transition can call BSP_BacklightSet() before that. The helper needs a defensive NULL check, with boot brightness applied later by the CABC-enable sequence.
  • WRCABC is deliberately 0x00 (off), not 0x01 (UI adaptive). Content-adaptive brightness sounds appealing, but it means the panel silently changes brightness based on what is on screen — wrong for a deterministic industrial HMI where an operator-set level must stay where the operator put it.
  • Brightness commands are only issued from the HMI active object’s task context, the same context that owns the LVGL flush. Both share the physical DBI bus; interleaving a command with an in-flight frame DMA corrupts the display.

Choosing this during display selection

Run this checklist before committing to a panel and backlight architecture — retrofitting it later means a hardware reroute:

  • Does the panel module break out a CABC_PWM / BL_PWM / LEDPWM pin on the connector?
  • Does the controller datasheet list DCS 0x51 / 0x53 / 0x55 (or equivalent CABC registers)? This confirms brightness-over-bus is actually implemented, not just present in a generic DCS list the vendor copy-pasted.
  • What is the controller’s fixed backlight PWM frequency, and is it acceptable? You do not get to choose it. If your product has an EMC or flicker spec, this needs sign-off, not assumption.
  • How many timer channels does the rest of the design actually need? Count PWM outputs, input capture, quadrature decode, motor control. If you are tight, controller-generated backlight gives one back.
  • Is the backlight command issued from a context that cannot race the frame-flush DMA?
  • Do you need gamma-correct perceptual dimming? DCS DBV is linear — apply a lookup table before writing it if you need roughly logarithmic response. Same requirement applies to MCU PWM duty cycle.

Bring-up order for a new panel

  1. Identify the controller IC, not just the module — the DCS command set lives at the controller level (ST7796S), not at “the LCD-PAR-S035 module”.
  2. Confirm the physical bus: SPI (4-wire/3-wire), parallel 8080 8/16-bit DBI, or MIPI DSI. This decides which vendor adapter layer you need — e.g. fsl_dbi_flexio_edma + fsl_st7796s for a FlexIO-based 8080 bus.
  3. Check the reference driver exposes raw command write (writeCommand/writeData). You need it directly for brightness even if the SDK’s high-level display API does not wrap DCS.
  4. Read the DCS command table in the controller datasheet, specifically the extended command set section — that is usually where 0x51/0x53/0x55 and 0x5E (CABC minimum brightness) live.
  5. Decide the CABC mode deliberately. Off for deterministic brightness — most industrial and medical cases. UI/Still/Moving only if content-adaptive dimming is a real product requirement.
  6. Verify on hardware at 0%, 50% and 100%, checking for visible flicker and audible coil or driver whine. The controller’s internal PWM frequency is not something you chose, so confirm it is acceptable for your LED driver and enclosure.
  7. Serialise backlight commands with frame flush if they share a bus.

Summary

The migration freed a full FlexPWM submodule, unblocked the input-capture feature, and changed zero call sites — BSP_BacklightSet(uint8_t percent) kept its signature while everything underneath it changed. That stable API is what made it a low-risk change rather than a refactor.

Three things worth carrying forward:

  1. The backlight PWM does not have to come from the MCU. If the panel controller supports DCS brightness commands, drive it over the bus you already have and keep the timer.
  2. Shared hardware timers are a classic “it worked until we added feature #2”. A FlexPWM submodule’s counter is shared across all its channels and its capture logic. Plan the timer budget before committing a channel to something as replaceable as a backlight.
  3. Verify the reference driver implements the convenience API you are about to call. A header is a promise about the interface, not about every implementation behind it.

💬 Questions, corrections, or your own war story with backlight PWM? Leave a comment below — I read and answer all of them.