Adding an SPI ST7735R LCD to the IOC-M-2


Last Update: May 22, 2024 @ 10:13 Preliminary information, subject to change.

This is an Application Note discussing how a 160×128 LCD (no touch) can be interfaced to the IOC-M-2 using SPI.

The IOC-M-2 board supports a full Master-Slave SPI data bus on SPI2. Three CS (Chip Select) pins are provided, SPI2-NSS and CTRL_OUT_0 and CTRL_OUT_1. The firmware has been updated to enable the SPI2 port, and to read and write data buffers, via SDK commands USBSPIDevice, USBSPIRead and USBSPIWrite.

As a design exercise to test and verify the SPI functions, a 1.8″ 160×128 LCD (above) was acquired as a target device. In summary, the design requirements are:

  • Provide a way to initialize the ST7735R LCD controller chip
  • Support a subset of the USBLCD* command API set (see more below)
  • Provide all circuit details necessary for users to do the same.

Initializing the ST7735R controller

There’s two ways to do this. The first is creating a block of binary data containing all the necessary command/data to initialize the chip, and write this to the LCD via a sequence of USBSPIWrite commands in a user app. While this is doable, it requires significant hardware knowledge by the user, has a high risk of code errors, and is slow.

The second way is to add the LCD initialize code block directly into the IOC-M-2 firmware, and have the app simply send a command to enable the LCD which writes the block to the controller. This is clearly the better and faster solution, so the USBSPILCDinit command (i.e. USB OUT report) was created. This has the added benefit of hiding all the hardware code complexity away from the user.

What does the USBSPILCDinit command do?

There’s two modes: ENABLE and DISABLE. On power up, the LCD controller is not running, and the screen is dark. ENABLE causes the IOC-M-2 to write an internal data block to the LCD controller, starts the backlight PWM waveform, hence resulting in the LCD displaying a black screen. The IOC-M-2 app code then generates the desired user interface. Setting DISABLE mode turns off the LCD and stops the PWM signal to the backlight.

Creating a User Interface on the LCD

The USBLCD* graphics API commands number around 100. Given the low resolution of the LCD, a subset of USBLCD* only is supported. For example, drawing background colours, lines, rectangles, circles and ellipses and of course, text is supported. The utility command USBLCDSize will return (160,128) when the LCD is enabled.

What happens if the LCD is not connected or not enabled?

The USBLCD* commands are ignored. The USBLCDSize command returns (0,0).

Circuit Connections

Tests with the LCD show it draws around 40mA from the 3V3 supply. The LCD+ pin does not include a PWM drive circuit, and we can’t just connect a PWM signal from the MCU and expect it to safely source or sink 40mA. The commonly used circuit PWM_EN signal (below) drives the base of an NPN transistor that switches the Vcc (here, LED+) to vary the LCD brightness. Vcc is derived from the USB 5v via a voltage regulator. Here’s an example circuit.

The PWM signal causes Q1 to saturate i.e. the Collector-Emitter voltage goes to near zero, and completes the circuit to GND. Full brightness occurs when PWM_EN is set High. The backlight turns off when PWM_EN is set Low. Note here that the collector voltage will be around 0.7v. If LED+ is connected to 3.3v, the display will be rather dim, hence LED+ is connected to the 5v line. The LCD module 3.3v and 5v lines are separate from each other. If a PWM_EN signal is not used, LED+ and LED- are connected to 3.3v and GND respectively.

The above circuit can also use a PNP transistor, with the emitter connected to LED- and collector to GND. The control PWM waveforms are inverted.

The first goal of the App Note is to get the display initialized and displaying a user interface.

On the IOC-M-2 board, pin PA8 is available to be used to control the backlight, assuming a circuit like above is created. The simplest way is to configure PA8 as a GPIO_Output, default low, and adjust the firmware to set PA8 high when the LCD is ENABLED. In CubeMX, PA8 can also be defined as TIM1-CH1 PWM, which looks like the ideal solution.

In fact PA8 cannot be set as TIM1-CH1 PWM as it conflicts with USB-FS and some other GPIO pins. Even if the necessary firmware functions are set, the PWM does not start.

What is required is an adjustable PWM waveform to be output on PA8, without affecting the rest of the IOC-M-2 functionality. Here’s the way we did it. TIM4 with an interrupt handler is available and functional. The desired waveform frequency and duty cycle is created, and in the handler, PA8 (still a GPIO Output) is toggled as needed.

Waveform from TIM4, basic square wave, before PWM configuration.

In TIM4_IRQHandler() with PA8 toggled on each interrupt, we get a simple square wave signal on PA8. For variable LCD brightness, we require a PWM signal that can be adjusted by a user app.

PWM signal created in the interrupt handler.

TIM4 was initialized with a PreScalar to generate a 200kHz count frequency from the 72MHz clock, with a Period of approximately 500us. The TIM4_IRQHandler() refers to two variables, g_PulseWidthHi and g_PulseWidthLo which are set by the LCD_BacklightOn() API function (below). These Hi and Lo values define a total of (for now) 10 counts to decide when the PA8 is toggled. For example, Hi|Lo = 1|9 results in the PWM signal above.

 extern TIM_HandleTypeDef htim4;
 void LCD_BacklightOn(uint8_t level)
 {
 extern uint8_t g_LCD_PWM_Count; 
 extern uint8_t g_PulseWidthHi; 
 extern uint8_t g_PulseWidthLo; 
 extern void MX_TIM4_Init(uint16_t duration); 
 uint16_t duration = 3; // same as in main_IOC-M.c
 HAL_TIM_Base_DeInit(&htim4); 
 if (255 == level) 
 {
     HAL_GPIO_WritePin(PWM_OUT_GPIO_Port, PWM_OUT_Pin, GPIO_PIN_SET);
 } 
 else if (level >= 1) // i.e 1-254
 {   // Set the PWM Period as a level/254  of g_PulseWidthHi and g_PulseWidthLo. We need some
    // brightness variation only. Total pulse width is the sum of Hi and Lo. Examples:
     // Hi/Lo = 1/9     2/8    3/7    4/6   5/5
     HAL_GPIO_WritePin(PWM_OUT_GPIO_Port, PWM_OUT_Pin, GPIO_PIN_RESET);
     g_LCD_PWM_Count = 0;
     if (level < 50)
     {         g_PulseWidthHi =  1;
         g_PulseWidthLo =  9;
     }     else if (level < 100)
     {         g_PulseWidthHi =  2;
         g_PulseWidthLo =  8;
     }     else if (level < 150)
     {         g_PulseWidthHi =  3;
         g_PulseWidthLo =  7;
     }     else if (level < 200)
     {         g_PulseWidthHi =  4;
         g_PulseWidthLo =  6;
     }     else if (level < 255)
     {         g_PulseWidthHi =  5;
         g_PulseWidthLo =  5;
     }     MX_TIM4_Init(duration);
   }
 }

// Turn off the timer and reset PA8
 void LCD_BacklightOff(void)
 {
     HAL_TIM_Base_DeInit(&htim4);
     HAL_GPIO_WritePin(PWM_OUT_GPIO_Port, PWM_OUT_Pin, GPIO_PIN_RESET);
 }

Note how PA8 is set high for LCD brightness level 255 (i.e. maximum) and low for level 0 (i.e. LCD off).

Device Independent vs Dependent Code

All Decaf board firmware is intentionally segmented into device independent and device dependent code regions, with similar file names, functions, variables and algorithms. USB OUT and IN Reports have the same definitions and implementations, so an app can access any Decaf board with the same function call. LCD code is the most obvious example. The device dependent portion is in the LCD_Init() and pixel write functions that draw on to the LCD.

The 160×128 LCD is an SPI device, so at the dependent level, data is written using HAL_SPI_Transmit calls. For example, drawing a line is done via the independent function …

LCD_DrawLine(int16_t x1, int16_t y1, int16_t x2, int16_t y2);

In the dependent code area, the coordinates are converted to LCD GRAM pixel addresses, and SPI commands write the data.

Connecting the LCD to the IOC-M-2

The LCD requires 5V, 3V3 and GND, which are available on the IOC-M-2 GPIO headers. The LCD RESET pin (i.e. controller hardware reset) is active L, so CTRL_OUT_0 is used. The firmware needs only to write commands and data to the LCD via SPI2_MOSI (Master Out Slave In), plus the SPI2_SCK that clocks the data channel. Writing commands or data requires the LCD_CS input to be set Low, using the SPI2_NSS signal. Note that the app does not control LCD_CS, it’s done in the device dependent code.

The last pin is the AD, or Address | Data selector. Address (AD == Low) is the LCD controller register to which command codes are written, followed by setting Data (AD == Hi) to write command arguments as needed. The CTRL_OUT_1 signal is controlled by the firmware as needed. Note that while a user app can control the CTRL_OUT_* pins, it is not required to do so for this example.

Six wire connection from the IOC-M-2 to the LCD, plus power and ground. The LCD module includes an SPI SD card slot, which is not supported by this example.
The IOC-M-2 with SPI LCD built on to a R&D board, initialized and displaying the colour bar from the USBLCDDrawColourBar command, text and graphic objects.
The LCD test screen above was produced with this PiXCL 20 ISC app.

Other Application Notes

Custom USB HID using STM32CubeMX and TrueStudio.

Calibrating the Decaf Real Time Clock.

Interfacing LCD Modules: the LCD_Init problem. 

Comparing graphics speed: M4 vs. M3

Interrupt Vector Relocation : Cortex-M3

Timer4 PWM not working: STM32F103VE

Adding an SPI 10M Ethernet PHY

Questions or comments? Want the ST7735R LCD_Init code (.c and .h) for your project? Sure … Contact me.




Copyright © 2025 PiXCL Automation Tech Notes, Canada. All Rights Reserved.