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
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
| Aspect | MCU PWM → LED driver IC | Controller-generated PWM (DCS) |
|---|---|---|
| Signal source | MCU timer peripheral toggling a GPIO | Display controller’s internal PWM block, on a dedicated pin (CABC_PWM) |
| How you set brightness | Write a duty-cycle register directly | Send a display command (DCS 0x51) over the panel bus |
| Extra hardware | Needs an external LED driver IC between MCU pin and LED string | LED driver is usually already on the panel module |
| Timer/pin cost | Consumes one PWM channel + its GPIO permanently | Zero MCU timer channels |
| PWM frequency control | You 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 latency | Immediate — register write, same cycle | One bus transaction, and it must not race a frame flush on a shared bus |
| Portability | Same code on any panel — it is just a GPIO | Depends 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:
| Command | Name | Purpose |
|---|---|---|
0x51 | WRDISBV — Write Display Brightness Value | 1-byte brightness, 0x00–0xFF |
0x53 | WRCTRLD — Write CTRL Display | Enables the brightness block: bit 5 BCTRL, bit 3 DD (dimming), bit 2 BL (backlight on) |
0x55 | WRCABC — Write Content Adaptive Brightness Control | 0x00 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
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
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, ¶m, 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.xferOpsisNULLuntil display init completes, but the HMI active object’s initial transition can callBSP_BacklightSet()before that. The helper needs a defensive NULL check, with boot brightness applied later by the CABC-enable sequence.WRCABCis deliberately0x00(off), not0x01(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/LEDPWMpin 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
- 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”.
- 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_st7796sfor a FlexIO-based 8080 bus. - 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. - Read the DCS command table in the controller datasheet, specifically the extended command
set section — that is usually where
0x51/0x53/0x55and0x5E(CABC minimum brightness) live. - 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.
- 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.
- 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:
- 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.
- 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.
- 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.
Related
- i.MX RT1062 pin config: pull/keeper, drive strength and slew rate — pad settings for the display and driver signals
- A microSD active object with QP/C and USDHC1 — the same shared-bus discipline, applied to card detect
💬 Questions, corrections, or your own war story with backlight PWM? Leave a comment below — I read and answer all of them.
💬 Discussion
Questions, corrections, or war stories from your own board? Leave a comment below — I read and answer all of them. (Sign-in with GitHub.)