As an intermediate Linux enthusiast and aspiring embedded software engineer, I recently took on the challenge of writing my own device driver for the 1602A LCD on a Raspberry Pi 4B. This was my first time interfacing with low-level hardware via kernel development, and it was an exciting and educational project. In this blog post, I’ll share my experience, the challenges I faced, and how I eventually got my driver working.
The 1602A LCD is a popular character display module that uses the HD44780 controller. It operates via a set of control pins and either a 4-bit or 8-bit data interface, typically interfaced via GPIO or an I2C adapter. Prior to this project, I had experience with seven-segment displays and an SPI-based TFT LCD with PIC18F4321 micocontroller, but this was my first time working with the 1602A.
For this project, I chose to interface the LCD directly via GPIO on the Raspberry Pi 4B. This required timing control, proper pin configuration, and a thorough understanding of how to send commands to the LCD. My goal was to create a simple yet functional driver that allows users to initialize the LCD, write characters, clear the screen, move the cursor, and scroll text beyond the 16-character screen width.
Before writing the driver, I conducted research on using Raspberry Pi GPIO to interact with hardware. One example utilized procfs and the virtual address for BCM GPIO at 0x3F200000. This approach demonstrated how to perform bitwise operations to determine the function select index, bit position, and offset required to configure a pin as an output and set it to the desired value. Another example leveraged the label found under /sys/class/gpio/*chip name*/label as an offset, and the GPIO abstraction library <linux/gpio/consumer.h> to configure pin directions and values. These examples were invaluable for understanding how to control Raspberry Pi GPIO pins using the Linux GPIO API. They showcased key functions such as gpio_to_desc to request a GPIO, gpiod_direction_output to set it as output, and gpiod_set_value to modify its state.
One thing I realized early on is that I didn’t want to use procfs for this. While procfs is great for exposing kernel information, character devices felt like a more appropriate choice for interacting with hardware directly. They gave me better access control and allowed me to integrate easily with file operations like write(), read(), and ioctl()—exactly what I needed to make the LCD interactive.
I also made sure my environment was set up properly, installing the necessary kernel headers with:
sudo apt install -y raspberrypi-kernel-headers build-essential gpiod
Before I even started writing code for the kernel module, I wanted to make sure the LCD itself was working. I grabbed an Arduino UNO and ran a basic "Hello World" example to confirm the hardware was functional, which gave me confidence before jumping into kernel-level integration.
I started by implementing the module’s init and exit functions. Initially, I focused only on initializing the screen and displaying a pre-defined text message. I planned to add read, write, and ioctl functionality later.
To control the LCD, I needed to register GPIO pins for:
Register Select (RS) - Determines whether the LCD is receiving commands or data.
Enable (E) – Triggers the LCD to read the input.
Data lines (D4-D7) – Used for the 4-bit communication interface.
Since I was only writing to the LCD (not reading from it), I grounded the R/W pin. I used gpio_to_desc() and gpiod_direction_output() to configure the GPIO pins as outputs. The RS pin controls the mode: setting RS = 0 activates command mode, while RS = 1 enables character writing.
To modularize my code and avoid redundancy, I added three helper functions:
pulseEnable() – Generates the enable pulse to latch data into the LCD.
write4bits() – Writes a 4-bit nibble to the data pins.
lcd_byte(bits, mode) – Sends a command or data byte to the LCD using the above two functions. Mode was determined by RS.
When working with the HD44780 datasheet, I followed these best practices:
Timing Margins: I ensured my delays were long enough than the required minimum to accommodate variations in execution timing.
Command Structure: The instruction set can be confusing, so I verified bit patterns using multiple sources before implementing them in the driver. Some datasheets had limited information, one particular datasheet explained the operational modes and how addressing worked for setting the cursor. Another showed the timing flowchart.
Power-on Initialization: The LCD requires specific command sequences after power-on to function correctly. I double-checked the initialization flow to match the datasheet.
In this function, we first set the RS pin to data mode (rs = 1) to indicate that we will be writing data (as opposed to commands). Then, we loop through the characters in the provided string (string[]). Each character is passed to lcd_byte(), where it is broken into two 4-bit nibbles and sent to the LCD. If the string is shorter than 32 characters, the loop ends when the null character ('\0') is encountered. After the string is written, a small delay (msleep(10)) is introduced to ensure the LCD has time to process the written data.
I initially struggled with getting the correct characters to display. After debugging, I realized I was sending the lower nibble before the upper nibble. Correcting this allowed me to successfully display "Hello World" on the screen. A lucky behavior that the Power-on initialization worked despite sending the setup commands with the wrong nibble order.
For stable timing, I used at least msleep(5) for most delays, ensuring it exceeded all required minimum delays in the datasheet. I am aware the kernel module could be better fine tuned for proper real time applications.
Implementing Cursor Control and Scrolling
Character cursor addresses
Display shift instruction
To move the cursor, I used the command:
(row_bits | 0x80 | desired_position)
where:
Row 1 positions: 0x80 | 0x00 | position
Row 2 positions: 0x80 | 0x40 | position
As shown in the left figure above, the first row starts at 0x00 and second row starts at 0x40, which both require bit DB7 to be set or 0x80. I also set a buffer limit of 32 characters per row within my header file but could potentially be increased to 40 to align with the LCD’s 80-character DDRAM space. Scrolling was implemented using the display shift instruction with cursor following the shift. Initially, I did not have a delay, but from testing back to back scrolling, the second scrolling was ignored, so I added a delay to ensure the command was registered.
Implementing Character Device Interface
After setting up GPIO and LCD functions, I registered a major device number and connected it to a character device. This allowed userspace applications to interact with the driver using:
mknod /dev/lcd c <major> 0
chmod 777 /dev/lcd
In my LCD kernel module, I implemented the following key functions:
major = register_chrdev(0, "lcd_dev", &fops); // To register the the char device in init()
unregister_chrdev(major, "lcd_dev"); //To unregister in exit()
read(): returns the contents of the kernel buffer to the user buffer and displays the last written content on the LCD.
write(): saves the contents of the user buffer to the kernel buffer and updates the LCD display accordingly.
ioctl(): enabling users to control the LCD through simple commands.
The write and ioctl cases were initially tested within the init() function, so I needed to organize them into their respective file operation mappings. I verified their behavior using a test.c program. This program tested various commands and wrote data to the LCD, ensuring the module performed as expected.
Lastly, I enhanced the driver's configurability by replacing hardcoded GPIO pin definitions with a struct that can be passed via the ioctl() function. This modification allows dynamic configuration of the GPIO pins, making the driver more flexible and adaptable.
During testing, I encountered an issue with my software-implemented automatic row extension. In my implementation, the write() function would automatically move the cursor to the second row and continue writing when a string exceeded 16 characters. However, when I manually set the cursor to the second row and wrote to it, the text would briefly display before unexpectedly reverting, as if treated as a continuation of the first-row string.
To address this, I removed the automatic row extension and instead relied on explicit cursor control and scrolling to ensure proper positioning. I suspect the issue might be related to the LCD controller's handling of repeated instructions, as increasing the delay did not resolve the problem. Additionally, the brief flashing of the updated text before reverting suggested that the new write operation was being registered but subsequently overwritten.
In one section of my test.c program, I moved the cursor to the middle of the first row and successfully overwrote the existing contents with a new string. This confirmed that the write functionality was operational and further emphasized the importance of precise cursor management for achieving the desired behavior.
Conclusion
Writing a kernel module for the 1602A LCD was an fun learning experience. It deepened my understanding of Linux kernel development, GPIO handling on Raspberry Pi and understanding how the 1602A LCD functions. The project has helped give me practice in interpreting datasheets (especially how some are more detailed than others), the importance of stable timing, and how devices can have quirks—like the my row extension issue and unexpected nibble order behavior during initialization.