Why I Started Using This Tool
My proximity alarm could measure distance and beep like a parking sensor, but to read the number I still needed a laptop. In the last part I'd wired up an LCD1602 and blinked its backlight from the console, but it hadn't shown a single letter. Put it on a table at a STEM event and nobody knows what it's doing.
There are plenty of LCD libraries, and the kernel has a perfectly good I2C driver. I went the other way. The rest of
this project talks to the chip through /dev/mem: GPIO for the sensor, PWM for the buzzer. I wanted to see
the whole path from a C string to a letter on a screen, so I drove the Pi's I2C controller directly through its
registers. By the end, you move your hand toward the sensor and watch the number count down on the screen, with no
laptop in sight.
Missed the wiring? Start with Part 12: Giving My Raspberry Pi a Face: Wiring an LCD1602 Over I2C With Four Wires (video).
Here's the video walkthrough of this post, if you'd rather watch it step by step:
What It Does
The LCD task is one new file, lcd.c, plus one setter in the shared frame. It has three layers:
- I2C (BSC1 at
0x3F804000): set the address, the length and the bytes, start the transfer, then wait for DONE with a timeout and check for an error. The clock divider is 4000, so the bus stays at or below the PCF8574's 100 kHz limit even when the core clock runs at 400 MHz. - PCF8574: each LCD nibble is 3 I2C bytes: data with E low, E high, E low. The backlight bit goes in every one of them.
- HD44780: the datasheet's wake-up routine (
0x3three times, then0x2, then the setup commands), then cursor commands and characters.
Every 200 ms the task copies the frame, formats two lines (Distance 52 cm and
Beep 71% 370ms), stores them back in the frame so the console shows them too, and redraws only a line
that changed.
If an I2C write fails, the task marks the screen as not ready and runs the wake-up routine again on the next tick. The sensor and the buzzer never notice. And on exit, it clears the screen, turns off the backlight and puts both pins back to inputs.
Step by Step
1. Recap: what we already have
From Part 12, the hardware:
| Backpack pin | Pi pin | Job |
|---|---|---|
| GND | 6 | Ground |
| VCC | 1 (3.3 V) | Power |
| SDA | 3 (GPIO2) | I2C data |
| SCL | 5 (GPIO3) | I2C clock |
We also learned that the backpack's PCF8574 chip answers at 0x27 (or 0x3F, whichever your scan showed), and that it turns each byte it receives into 8 output pins:
| Bit | Value | LCD pin | Job |
|---|---|---|---|
| 0 | 0x01 | RS | 0 = command, 1 = character |
| 1 | 0x02 | RW | Always 0: we only write |
| 2 | 0x04 | E | A pulse tells the LCD "read the data now" |
| 3 | 0x08 | Backlight | 1 = on |
| 4β7 | 0xF0 | D4βD7 | The 4 data lines |
From Part 6, two frame fields have been waiting all this time:
CHAR lcdLine1[FRAME_LCD_COLS + 1u]; /* +1 for the '\0' */
CHAR lcdLine2[FRAME_LCD_COLS + 1u];
One thing to check first: the kernel's I2C driver has to be off. We rebooted at the end of Part 12 to clear it, and this confirms it:
pinctrl get 2,3
# 2: ip pu | hi // GPIO2 = input
# 3: ip pu | hi // GPIO3 = input
ip means nobody owns the pins. If you see a0, the driver is on, and it would fight our code
for the same hardware.
2. There are three layers
Getting one letter onto the screen takes three layers, and each one only talks to the one below it:
"Distance 52 cm" our text
β
HD44780 layer commands, characters, the wake-up routine
β
PCF8574 layer one LCD nibble = 3 bytes with RS, E and backlight
β
I2C layer (BSC1) START, address, bytes, STOP
β
GPIO2 / GPIO3 the two wires
We'll build it from the bottom up.
3. The I2C controller: BSC1
In Part 12's test, the kernel driver did the I2C work for us. Now we do it ourselves. Inside the BCM2837 there's a circuit called the BSC ("Broadcom Serial Controller", which is Broadcom's name for I2C). It generates the START, shifts out the bits, reads the ACK and sends the STOP. We only hand it bytes.
The Pi has several of these, and BSC1 is the one wired to GPIO2 and GPIO3.
| Block | Job | Bus address (datasheet) | ARM physical (Pi 3) |
|---|---|---|---|
| GPIO | Connect GPIO2 and GPIO3 to BSC1 | 0x7E200000 | 0x3F200000 |
| BSC1 | Talk I2C | 0x7E804000 | 0x3F804000 |
It's the same rule as the last few parts: 0x7E in the datasheet is 0x3F for us. One
mmap per block.
BSC1 has only six registers that matter to us:
| Register | Offset | What it does |
|---|---|---|
| C (control) | 0x00 | Turn it on, empty the queue, start a transfer |
| S (status) | 0x04 | Finished? Any error? |
| DLEN | 0x08 | How many bytes to send |
| A | 0x0C | The address of the chip we're talking to: 0x27 |
| FIFO | 0x10 | The queue of bytes to send, up to 16 |
| DIV | 0x14 | Clock divider: sets the bus speed |
FIFO stands for "first in, first out". It's a small queue inside the chip: you write bytes into it, and the controller sends them in the same order.
4. How fast should the bus run?
The bus speed is the chip's core clock divided by DIV:
SCL = core clock / DIV
Here's the trap. On a Pi 3, the core clock isn't one fixed number: it can be 250 MHz or 400 MHz, depending on the configuration and the load. And the PCF8574's limit is 100 kHz. If we pick DIV for 250 MHz, the bus could run too fast at 400 MHz.
So we choose for the worst case:
DIV = 4000
400 MHz / 4000 = 100 kHz right at the limit
250 MHz / 4000 = 62.5 kHz slower, and that's fine
Remember: I2C has no minimum speed. Too slow costs nothing. Too fast is the only way to get this wrong.
5. One I2C write, in registers
Here's the bottom layer, i2cWrite. It's the only function in the whole file that sends anything:
bsc[BSC_C] = BSC_C_I2CEN | BSC_C_CLEAR; /* empty the FIFO */
bsc[BSC_S] = BSC_S_CLKT | BSC_S_ERR | BSC_S_DONE; /* write 1 to clear old flags */
bsc[BSC_DLEN] = len;
for (i = 0u; i < len; i++)
{
bsc[BSC_FIFO] = data[i];
}
bsc[BSC_C] = BSC_C_I2CEN | BSC_C_ST; /* go! */
Then we wait for the DONE flag in the status register, and check two things:
- ERR: the chip didn't ACK. This is what a loose wire or a wrong address looks like.
- CLKT: a chip held the clock low for too long.
One detail looks odd at first: to clear a flag, you write a 1 to it. That's normal for status registers, because it lets you clear one flag without touching the others.
And our rule from Part 10 applies here too: never wait forever for hardware. The wait gives up after about 50 ms, checking every 50 Β΅s and sleeping in between, so it doesn't burn the CPU.
6. One nibble: 3 bytes through the PCF8574
The LCD runs in 4-bit mode, so a byte is sent as two half-bytes, or nibbles. The LCD reads a nibble when the E line falls from high to low. We can only reach E through the PCF8574, so each nibble takes three I2C bytes:
data + RS + backlight
byte 1: E low set the data, and let it settle
byte 2: E high "get ready"
byte 3: E low falling edge: the LCD reads the data now
In code:
UINT8 out = (UINT8)((value & 0xF0u) | rs | LCD_BACKLIGHT);
data[0] = out;
data[1] = (UINT8)(out | LCD_E);
data[2] = out;
A small correction from Part 12. There I described this as two bytes: "nibble + E high", then "nibble + E low". That's what a lot of example code does, and it usually works. But the HD44780 datasheet wants RS to be stable a moment before E rises, and with two bytes they change at the same instant. One extra byte fixes that, so we send three.
Notice that LCD_BACKLIGHT is in every byte. The PCF8574 has no memory of what you
"meant": each byte sets all 8 pins. Forget that bit once and the backlight goes out.
A whole byte is then just two nibbles, high half first:
INT32 rc = lcdNibble(value, rs);
rc |= lcdNibble((UINT8)(value << 4), rs);
Why rc |=? Success is 0 and failure is -1, so OR-ing the results gives -1 if any of the steps
failed.
7. Waking up the HD44780
This is the strange part. Before the screen does anything useful, it needs a specific routine straight from the datasheet:
| Step | Send | Then wait | Why |
|---|---|---|---|
| 1 | β | 50 ms | The LCD needs time after power-up |
| 2 | nibble 0x3 | 4.5 ms | "Use 8-bit mode", attempt 1 |
| 3 | nibble 0x3 | 150 Β΅s | Attempt 2 |
| 4 | nibble 0x3 | 150 Β΅s | Attempt 3 |
| 5 | nibble 0x2 | 150 Β΅s | Now switch to 4-bit mode |
| 6 | byte 0x28 | β | 4-bit, 2 lines, 5Γ8 dots |
| 7 | byte 0x08 | β | Display off |
| 8 | byte 0x01 | 2 ms | Clear: the only slow command, 1.52 ms |
| 9 | byte 0x06 | β | Cursor moves right after each character |
| 10 | byte 0x0C | β | Display on, no cursor |
Why say the same thing three times? Because we don't know what state the LCD is in. The screen keeps its power when our program restarts. It might be fresh from power-up in 8-bit mode, or in 4-bit mode, or even waiting for the second half of a byte from a program that was killed mid-write.
Three 0x3 nibbles land it in 8-bit mode from every one of those states. Follow one case: if it was
waiting for a second nibble, the first 0x3 completes that old byte, and the next two are read as 8-bit
commands. It's a clever bit of 1980s design. From there, 0x2 takes us to 4-bit mode, and now both sides
agree on how to read every nibble after it.
Do we need to wait between normal commands? Most HD44780 commands take about 40 Β΅s. One of our nibbles takes at least 360 Β΅s on the bus (4 bytes of 9 clocks each, counting the address, at 100 kHz). So the I2C itself is our delay. Only the clear command needs a real sleep.
8. Putting text on the screen
The screen's memory has positions, and each character goes into one. Line 1 starts at position 0x00, and
line 2 starts at 0x40. The "set cursor" command is 0x80 plus the position. After that,
characters simply land one after another, sent with RS = 1:
command 0x80 | 0x40 = 0xC0 cursor to the start of line 2
character 'B', 'e', 'e', 'p' ...
Our screen shows two lines, built by a small pure function:
ββββββββββββββββββ ββββββββββββββββββ
βDistance 52 cm β βDistance --- cm β
βBeep 71% 370msβ βBeep off β
ββββββββββββββββββ ββββββββββββββββββ
something close nothing there
Let's count, because the screen has exactly 16 columns: Beep %3u%% %4ums is 5 + 3 + 1 + 1 + 4 + 2 =
16. A tight fit, and exactly right for the longest case, Beep 100% 1000ms. And
snprintf could never run past the buffer anyway.
Two tricks stop the flicker:
- We never clear the screen. Clearing and rewriting shows a blank for a moment. Instead we always write all 16 characters, padded with spaces, so old text simply gets overwritten.
- We only redraw a line when its text changed. We keep a copy of what's currently showing and compare it. Same text? No I2C at all.
How often? Every 200 ms. LCD crystals are slow, and digits changing any faster are just a blur. Writing a full line takes about 25 ms, which leaves plenty of room.
9. When the screen goes missing
A jumper wire pops off in the middle of a demo. What should happen?
The alarm must keep working. The sensor and the buzzer don't need the screen. So a failed I2C write doesn't stop anything: the task just marks the screen as "not ready", and on its next tick it tries the wake-up routine again.
every 200 ms:
read the frame, format both lines, store them in the frame
if the screen isn't ready: run the wake-up routine
if the screen is ready: redraw any line that changed
any failure: not ready, try again next tick
Push the wire back on and the screen comes back by itself. The log gets one line when the screen disappears, not five lines per second forever.
This is why the wake-up routine lives inside the task loop and not in Lcd_start. It also means that
starting with no screen attached isn't an error. So Lcd_start only fails if /dev/mem itself
fails.
10. The frame gets another writer: Frame_setLcd
The LCD task owns lcdLine1 and lcdLine2. It writes them to the frame, so the
console shows exactly what the screen should show. That's handy: if the console and the screen disagree, you know the
bug is in the I2C part. In frame.h:
void Frame_setLcd(const CHAR *line1, const CHAR *line2);
In frame.c:
void Frame_setLcd(const CHAR *line1, const CHAR *line2)
{
pthread_mutex_lock(&frameLock);
strncpy(frame.lcdLine1, line1, FRAME_LCD_COLS); /* the last byte stays '\0' */
strncpy(frame.lcdLine2, line2, FRAME_LCD_COLS);
pthread_mutex_unlock(&frameLock);
}
The buffers are 17 bytes, and strncpy only ever touches the first 16. The last byte was zeroed by
Frame_init and stays zero, so the string is always terminated.
And notice what we don't do: hold the lock while talking to the screen. A full line takes 25 ms, and the buzzer task checks the frame every 10 ms. We copy the frame, unlock, and only then do the slow work.
The whole pipeline, with no task calling another:
ultrasonic task ββFrame_setDistanceβββΆ FRAME ββFrame_getβββΆ buzzer task βββΆ PWM registers
β² β² β
β βββFrame_setBuzzerββββ
β
βββFrame_getβββΆ LCD task βββΆ BSC1 registers
βββFrame_setLcdββββ
β
sw alive task prints it all
11. Keeping a promise: gpio.c
In Part 11, I said about the gpioMode function: "When a third task needs GPIO, that's the moment to move
them into their own gpio.c." The LCD is that third task. So here it is, and it's small.
gpio.h:
#ifndef GPIO_H
#define GPIO_H
/************************************************************************/
/* GPIO function select, shared by every task that uses GPIO */
/* registers. Each task maps the GPIO block itself and passes the */
/* mapping in. Call it from start/stop code, not from running tasks: */
/* it reads, changes and writes a register that holds 10 pins. */
/************************************************************************/
#include "ctypes.h"
#define GPIO_INPUT (0u) /* 000 */
#define GPIO_OUTPUT (1u) /* 001 */
#define GPIO_ALT0 (4u) /* 100: GPIO2 = SDA1, GPIO3 = SCL1 */
#define GPIO_ALT5 (2u) /* 010: GPIO18 = PWM0 */
void Gpio_mode(volatile UINT32 *regs, UINT32 pin, UINT32 mode);
#endif
gpio.c:
#include "gpio.h"
#define GPFSEL0 (0x00u / 4u) /* function select: 3 bits per pin, 10 pins per register */
void Gpio_mode(volatile UINT32 *regs, UINT32 pin, UINT32 mode)
{
UINT32 reg = GPFSEL0 + (pin / 10u);
UINT32 shift = (pin % 10u) * 3u;
regs[reg] = (regs[reg] & ~(7u << shift)) | (mode << shift);
}
Each task still maps the GPIO block itself, so the function takes that mapping as its first argument. In
ultrasonic.c and buzzer.c, we delete the old copy and its #defines, add
#include "gpio.h", and change the calls:
gpioMode(BUZZER_GPIO, GPIO_ALT5); /* before */
Gpio_mode(gpio, BUZZER_GPIO, GPIO_ALT5); /* after */
The behaviour doesn't change at all. And see the ALT0 there: code 100 connects GPIO2 to
SDA1 and GPIO3 to SCL1, which is how the pins reach BSC1. Last time we used ALT5 for the PWM; same idea, different
code.
One warning in that header comment: Gpio_mode reads a register, changes it and writes it back, and that
register holds 10 pins. Two threads doing that at once could overwrite each other. We're safe, because
main calls every start and stop one after another, and the tasks never change a
pin's mode while running.
12. lcd.h: the public menu
#ifndef LCD_H
#define LCD_H
/************************************************************************/
/* LCD task: shows the distance and the buzzer state on the LCD1602. */
/* The BCM2837 I2C controller (BSC1) talks to the PCF8574 backpack, */
/* which drives the HD44780 screen in 4-bit mode. */
/* Registers are accessed through /dev/mem. Needs root, and the */
/* kernel's I2C driver must be off: BSC1 can only have one owner. */
/************************************************************************/
#include "ctypes.h"
#define LCD_PERIOD_MS (200u) /* update the screen 5 times a second */
#define LCD_I2C_ADDR (0x27u) /* the address you found in video 12 (or 0x3F) */
#define LCD_SDA_GPIO (2u) /* BCM GPIO2, physical pin 3, ALT0 = SDA1 */
#define LCD_SCL_GPIO (3u) /* BCM GPIO3, physical pin 5, ALT0 = SCL1 */
INT32 Lcd_start(void);
void Lcd_stop(void);
#endif
Change LCD_I2C_ADDR if your scan in Part 12 found 0x3F. Everything else can stay.
13. lcd.c: the LCD thread
Here's the whole file. Then we'll walk through it.
#include <fcntl.h>
#include <pthread.h>
#include <stdio.h>
#include <string.h>
#include <sys/mman.h>
#include <time.h>
#include <unistd.h>
#include "frame.h"
#include "gpio.h"
#include "tick.h"
#include "lcd.h"
/* BCM2837 (Pi 3) physical addresses. The datasheet lists the bus addresses
0x7E200000 and 0x7E804000; the ARM sees 0x7E.. at 0x3F.. */
#define PERI_BASE (0x3F000000u)
#define GPIO_PHYS_BASE (PERI_BASE + 0x200000u) /* GPIO pins */
#define BSC1_PHYS_BASE (PERI_BASE + 0x804000u) /* I2C controller BSC1 */
#define REGS_MAP_SIZE (4096u) /* one page per block */
/* BSC registers, as 32-bit word offsets */
#define BSC_C (0x00u / 4u) /* control */
#define BSC_S (0x04u / 4u) /* status */
#define BSC_DLEN (0x08u / 4u) /* how many bytes to send */
#define BSC_A (0x0Cu / 4u) /* address of the target */
#define BSC_FIFO (0x10u / 4u) /* the bytes to send */
#define BSC_DIV (0x14u / 4u) /* clock divider */
#define BSC_C_I2CEN (1u << 15) /* controller on */
#define BSC_C_ST (1u << 7) /* start the transfer */
#define BSC_C_CLEAR (1u << 4) /* empty the FIFO */
#define BSC_S_CLKT (1u << 9) /* clock stretch timeout */
#define BSC_S_ERR (1u << 8) /* the target didn't ACK */
#define BSC_S_DONE (1u << 1) /* transfer finished */
/* SCL = core clock / DIV. The Pi 3 core clock can be 250 or 400 MHz, so
4000 gives 62.5..100 kHz, never above the PCF8574's 100 kHz limit. */
#define BSC_CLK_DIV (4000u)
#define BSC_POLL_US (50u) /* time between DONE checks */
#define BSC_DONE_TRIES (1000u) /* x 50 us = give up after 50 ms */
/* What each bit of the PCF8574 does on the LCD backpack */
#define LCD_RS (0x01u) /* 0 = command, 1 = character */
#define LCD_E (0x04u) /* high pulse: LCD reads the data */
#define LCD_BACKLIGHT (0x08u) /* backlight on */
/* HD44780 commands */
#define LCD_CMD_CLEAR (0x01u) /* clear the screen, takes 1.52 ms */
#define LCD_CMD_ENTRY (0x06u) /* cursor moves right after each character */
#define LCD_CMD_OFF (0x08u) /* display off */
#define LCD_CMD_ON (0x0Cu) /* display on, no cursor */
#define LCD_CMD_2LINES (0x28u) /* 4-bit mode, 2 lines, 5x8 dots */
#define LCD_CMD_CURSOR (0x80u) /* set the cursor position */
#define LCD_LINE2_ADDR (0x40u) /* line 2 starts at position 0x40 */
static pthread_t lcdThread;
static volatile BOOLEAN lcdRunning = FALSE;
static volatile UINT32 *gpio = NULL; /* volatile: every access really hits the hardware */
static volatile UINT32 *bsc = NULL;
static volatile UINT32 *mapRegs(INT32 fd, UINT32 phys)
{
void *map = mmap(NULL, REGS_MAP_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, phys);
if (map == MAP_FAILED)
{
perror("mmap /dev/mem");
return NULL;
}
return (volatile UINT32 *)map;
}
static void unmapRegs(volatile UINT32 **regs)
{
if (*regs != NULL)
{
munmap((void *)*regs, REGS_MAP_SIZE);
*regs = NULL;
}
}
/* One complete I2C write: START, address, bytes, STOP. len must fit the
16-byte FIFO. Returns -1 if nobody ACKs or the transfer never finishes. */
static INT32 i2cWrite(const UINT8 *data, UINT32 len)
{
UINT32 i;
UINT32 tries = 0u;
bsc[BSC_C] = BSC_C_I2CEN | BSC_C_CLEAR; /* empty the FIFO */
bsc[BSC_S] = BSC_S_CLKT | BSC_S_ERR | BSC_S_DONE; /* write 1 to clear old flags */
bsc[BSC_DLEN] = len;
for (i = 0u; i < len; i++)
{
bsc[BSC_FIFO] = data[i];
}
bsc[BSC_C] = BSC_C_I2CEN | BSC_C_ST; /* go! */
while (!(bsc[BSC_S] & BSC_S_DONE))
{
if (++tries > BSC_DONE_TRIES)
{
bsc[BSC_C] = 0u; /* stop the controller */
return -1;
}
usleep(BSC_POLL_US);
}
return (bsc[BSC_S] & (BSC_S_ERR | BSC_S_CLKT)) ? -1 : 0;
}
/* Send the top 4 bits of value to the LCD. Three bytes: set the data and
RS with E low so they settle, raise E, then lower E. The LCD reads the
data when E falls. */
static INT32 lcdNibble(UINT8 value, UINT8 rs)
{
UINT8 out = (UINT8)((value & 0xF0u) | rs | LCD_BACKLIGHT);
UINT8 data[3];
data[0] = out;
data[1] = (UINT8)(out | LCD_E);
data[2] = out;
return i2cWrite(data, 3u);
}
/* In 4-bit mode one byte is two nibbles, high half first */
static INT32 lcdByte(UINT8 value, UINT8 rs)
{
INT32 rc = lcdNibble(value, rs);
rc |= lcdNibble((UINT8)(value << 4), rs);
return rc;
}
/* The HD44780 wake-up sequence. The LCD keeps its power when our program
restarts, so it could be in any state, even halfway through a byte.
Three 0x3 nibbles bring it to a known state from anywhere. */
static INT32 lcdInit(void)
{
INT32 rc;
usleep(50000); /* after power-up: at least 40 ms */
rc = lcdNibble(0x30u, 0u);
usleep(4500); /* at least 4.1 ms */
rc |= lcdNibble(0x30u, 0u);
usleep(150); /* at least 100 us */
rc |= lcdNibble(0x30u, 0u);
usleep(150);
rc |= lcdNibble(0x20u, 0u); /* now switch to 4-bit mode */
usleep(150);
rc |= lcdByte(LCD_CMD_2LINES, 0u);
rc |= lcdByte(LCD_CMD_OFF, 0u);
rc |= lcdByte(LCD_CMD_CLEAR, 0u);
usleep(2000); /* clear is slow: 1.52 ms */
rc |= lcdByte(LCD_CMD_ENTRY, 0u);
rc |= lcdByte(LCD_CMD_ON, 0u);
return rc;
}
/* Write one whole line, padded with spaces so old characters disappear */
static INT32 lcdPrint(UINT8 row, const CHAR *text)
{
UINT8 pos = (row == 0u) ? 0u : LCD_LINE2_ADDR;
INT32 rc = lcdByte((UINT8)(LCD_CMD_CURSOR | pos), 0u);
UINT32 i;
for (i = 0u; i < FRAME_LCD_COLS; i++)
{
UINT8 ch = (*text != '\0') ? (UINT8)*text++ : (UINT8)' ';
rc |= lcdByte(ch, LCD_RS);
}
return rc;
}
/* Only talk to the screen if the text changed: no flicker, no wasted I2C */
static INT32 lcdUpdate(UINT8 row, const CHAR *text, CHAR *shown)
{
if (strcmp(text, shown) == 0)
{
return 0;
}
if (lcdPrint(row, text) != 0)
{
return -1;
}
strcpy(shown, text);
return 0;
}
/* Frame in, two lines of text out. Each line fits in 16 characters. */
static void frameToLines(const FRAME *f, CHAR *line1, CHAR *line2, size_t len)
{
if (f->distanceValid)
{
snprintf(line1, len, "Distance %3u cm", (unsigned)f->distanceCm);
}
else
{
snprintf(line1, len, "Distance --- cm");
}
if (f->buzzerPeriodMs != 0u)
{
snprintf(line2, len, "Beep %3u%% %4ums",
(unsigned)f->buzzerVolume, (unsigned)f->buzzerPeriodMs);
}
else
{
snprintf(line2, len, "Beep off");
}
}
/* Dark and empty, the controller off, the pins back to inputs */
static void hwOff(void)
{
UINT8 allOff = 0u;
lcdByte(LCD_CMD_CLEAR, 0u);
usleep(2000);
i2cWrite(&allOff, 1u); /* backlight off */
bsc[BSC_C] = 0u;
Gpio_mode(gpio, LCD_SDA_GPIO, GPIO_INPUT);
Gpio_mode(gpio, LCD_SCL_GPIO, GPIO_INPUT);
}
static void *lcd_task(void *arg)
{
struct timespec next;
FRAME snapshot;
CHAR line1[FRAME_LCD_COLS + 1u];
CHAR line2[FRAME_LCD_COLS + 1u];
CHAR shown1[FRAME_LCD_COLS + 1u];
CHAR shown2[FRAME_LCD_COLS + 1u];
BOOLEAN ready = FALSE; /* screen woken up and working */
BOOLEAN reported = FALSE; /* error already in the log */
(void)arg;
clock_gettime(CLOCK_MONOTONIC, &next);
while (lcdRunning)
{
Frame_get(&snapshot); /* lock, copy, unlock */
frameToLines(&snapshot, line1, line2, sizeof(line1));
Frame_setLcd(line1, line2);
if (!ready) /* first time, or lost the screen */
{
if (lcdInit() == 0)
{
ready = TRUE;
reported = FALSE;
shown1[0] = '\0'; /* forces a full redraw */
shown2[0] = '\0';
}
else if (!reported)
{
fprintf(stderr, "LCD not answering at 0x%02X, will keep trying\n", LCD_I2C_ADDR);
reported = TRUE;
}
}
if (ready && ((lcdUpdate(0u, line1, shown1) != 0) || (lcdUpdate(1u, line2, shown2) != 0)))
{
ready = FALSE; /* try the wake-up again next tick */
}
Tick_addMs(&next, LCD_PERIOD_MS);
Tick_sleepUntil(&next);
}
return NULL;
}
INT32 Lcd_start(void)
{
INT32 fd;
fd = open("/dev/mem", O_RDWR | O_SYNC); /* root only: this is all of physical memory */
if (fd < 0)
{
perror("/dev/mem");
return -1;
}
gpio = mapRegs(fd, GPIO_PHYS_BASE);
bsc = mapRegs(fd, BSC1_PHYS_BASE);
close(fd); /* the mappings stay valid */
if ((gpio == NULL) || (bsc == NULL))
{
unmapRegs(&gpio);
unmapRegs(&bsc);
return -1;
}
bsc[BSC_C] = 0u; /* controller off while we set it up */
bsc[BSC_DIV] = BSC_CLK_DIV;
bsc[BSC_A] = LCD_I2C_ADDR;
Gpio_mode(gpio, LCD_SDA_GPIO, GPIO_ALT0); /* connect the pins last */
Gpio_mode(gpio, LCD_SCL_GPIO, GPIO_ALT0);
lcdRunning = TRUE;
if (pthread_create(&lcdThread, NULL, lcd_task, NULL) != 0)
{
lcdRunning = FALSE;
hwOff();
unmapRegs(&gpio);
unmapRegs(&bsc);
return -1;
}
return 0;
}
void Lcd_stop(void)
{
if (lcdRunning)
{
lcdRunning = FALSE;
pthread_join(lcdThread, NULL); /* wait for the last tick */
hwOff(); /* leave the bus idle and the screen dark */
unmapRegs(&gpio);
unmapRegs(&bsc);
Frame_setLcd("LCD stopped", "");
}
}
Let's walk through it.
The top of the file is the addresses and bits from sections 3 to 8. On a Pi 4 you'd change
PERI_BASE, and the core clock is different there too, so check DIV.
i2cWrite is section 5. It's the only function that touches BSC1 during normal running.
If it times out, it switches the controller off, and the next call switches it back on.
lcdNibble and lcdByte are section 6: three bytes per nibble, two nibbles
per byte. So one character costs 6 I2C bytes plus 2 addresses.
lcdInit is the table from section 7, line by line. It doesn't stop at the first error,
because a failed attempt only costs about 57 ms, and the code stays much shorter.
lcdPrint walks along the text and sends spaces once the text runs out.
*text++ only moves forward while there's text left, so it never reads past the '\0'.
lcdUpdate is the "only if it changed" check. shown is updated only after a
successful write. So after a failure it still holds the old text, and we'll try again.
frameToLines is another pure function. Frame in, text out, no hardware and no locks.
Give it 52 cm and it should return Distance 52 cm.
The task loop is section 9. Look at the first branch: after a successful wake-up, the screen is
blank, so we empty shown1 and shown2. An empty string never matches our text, so the next
lines redraw everything.
Lcd_start: set up first, connect last. The same order as the buzzer: controller off, set
DIV and the address, and only then connect the pins with ALT0. We don't need a line for "enable", because
i2cWrite sets I2CEN every time.
hwOff and Lcd_stop. Clear the screen, write 0x00 so the
backlight goes out, switch off BSC1, set both pins back to inputs. The pull-up resistors hold the bus idle. A dark
screen now tells you something true: the program isn't running. That's a useful signal, the same idea
as the ACT LED.
14. main.c: not fatal
#include "lcd.h"
if (Lcd_start() != 0)
{
/* no screen is not fatal: the alarm must keep working without it */
fprintf(stderr, "LCD not available, running without LCD task\n");
}
Lcd_stop(); /* stop in reverse start order */
Buzzer_stop();
Ultrasonic_stop();
HWAlive_stop();
SWAlive_stop();
We had this decision twice before. No sensor or no buzzer: fatal, because that's not an alarm any more. No LED: fine, keep going. A screen is like the LED: nice to have, but no reason to stop the alarm from protecting anything.
15. CMake
add_executable(proxalarm
src/main.c
src/frame.c
src/tick.c
src/gpio.c
src/swalive.c
src/hwalive.c
src/ultrasonic.c
src/buzzer.c
src/lcd.c)
Two more .c files, no new libraries.
16. Build, deploy, watch
cmake --preset pi-debug
cmake --build --preset deploy
ssh au@au.local
sudo ./proxalarm
Move your hand slowly toward the sensor:
Main running. Press Ctrl+C to stop.
[alive 1] dist=---cm buz=0%/0ms led=0 lcd="Distance --- cm|Beep off"
[alive 2] dist=120cm buz=25%/807ms led=1 lcd="Distance 120 cm|Beep 25% 807ms"
[alive 3] dist=52cm buz=71%/370ms led=1 lcd="Distance 52 cm|Beep 71% 370ms"
[alive 4] dist=24cm buz=90%/190ms led=0 lcd="Distance 24 cm|Beep 90% 190ms"
[alive 5] dist=9cm buz=100%/100ms led=0 lcd="Distance 9 cm|Beep 100% 100ms"
And there it is on the blue screen: the same text, counting down as your hand gets closer. No laptop needed.
If the backlight is on but you can't see any letters, turn the contrast pot again. Part 12 set it for solid blocks, and normal text often wants a slightly different setting.
While it runs, open a second SSH window and look at the pins:
pinctrl get 2,3
# 2: a0 pu | hi // GPIO2 = SDA1
# 3: a0 pu | hi // GPIO3 = SCL1
a0: connected to BSC1. (The exact text depends on your pinctrl version.) Now the fun test:
pull the SDA wire off. The buzzer keeps going, the distance keeps updating in the console, and the log
shows one line:
LCD not answering at 0x27, will keep trying
Push it back on, and within a moment the screen is alive again. Press Ctrl+C in the first window. The screen goes
dark, and the pins are back to ip.
Then the real way, with no SSH window at all:
sudo systemctl restart proxalarm
sudo reboot
Wait for the boot. The backlight comes on with the 16 blocks you know from Part 12, and then the program takes over and they turn into the live distance. That's the demo: plug it in, and it works.
17. When things go wrong
| Problem | What it means | Fix |
|---|---|---|
/dev/mem: Permission denied | Not root | sudo ./proxalarm, or run it as the service |
LCD not answering at 0x27 | No ACK | Wrong address (try 0x3F in lcd.h), SDA/SCL swapped, or a loose wire. Go back to Part 12's tests |
Console lcd= text is right, screen shows the 16 solid blocks | The PCF8574 answers, but the wake-up isn't reaching the LCD | Check the timing in lcdInit and that LCD_E is 0x04 |
| Backlight on, no text, no blocks | Contrast | Turn the pot slowly; text often wants a different setting than blocks |
| Garbage characters | Nibbles out of step: a loose wire or a killed program | Re-seat the jumpers and restart: the wake-up routine fixes it |
| Text only on line 1 | The LCD is in 1-line mode | Check LCD_CMD_2LINES is 0x28 |
| Backlight goes off when text is written | A byte is missing the backlight bit | Check LCD_BACKLIGHT is ORed in lcdNibble |
| Screen flickers | Clearing the screen on every update | Don't clear: write over it with padded lines |
| Random errors after some time | Bus too fast, or long wires | Keep BSC_CLK_DIV at 4000 and use short jumpers |
| Errors, or the screen stops, with the I2C driver on | The kernel and our code share BSC1 | Remove dtparam=i2c_arm=on from /boot/firmware/config.txt and reboot. pinctrl get 2,3 must show ip before our program starts |
| Screen frozen with an old number | The program crashed or was killed before cleaning up | The screen holds what it was last given, so check systemctl status proxalarm |
18. What's next
Look at the project plan. Every box is done: it boots by itself, one thread per task, one mutex-protected frame, CMake presets for build, deploy and debug. It measures, it screams, it blinks, and now it shows you the number.
This project started with an empty SD card, and it ends with a real embedded Linux gadget, built from registers up.
Next time you see a library call like lcd.print("hello"), you'll know what's underneath it: a command, a
nibble, a falling edge, an ACK.
Key Takeaways
- Three layers put one letter on the screen: the BSC1 controller sends I2C bytes, the PCF8574 turns each byte into pins, and the HD44780 reads a nibble when E falls. So one nibble is 3 I2C bytes, and the backlight bit goes in all of them.
- BSC1 needs only six registers: A = who, DLEN = how many, FIFO = what, C = go, S = done or error, DIV = speed. Pick DIV = 4000, so the bus stays at or below 100 kHz even if the core clock is 400 MHz.
- The HD44780 wake-up routine works from any state:
0x3three times, then0x2for 4-bit mode, then the setup commands. Respect the wait times; only clear is slow in normal use. - No flicker: never clear the screen. Write padded lines over the old ones, and only when the text changed.
- A screen is not worth stopping an alarm for. I2C errors just mean "not ready, try the wake-up again next tick". Hold the frame lock only for the copy, never for the slow I2C.
Final Verdict
Writing this taught me more about I2C than reading about it ever did. The strange-looking wake-up routine finally makes sense when you realize the screen keeps its power and can be in any state at all when your program starts.
Would I do it this way in a product? No. The kernel's I2C driver and /dev/i2c-1 handle sharing the bus,
and they survive a move to a newer Pi, while my addresses and my clock divider are specific to the Pi 3. For a real
product, use the driver. For learning what the driver does for you, this is hard to beat.
Gotchas, so you don't have to find them yourself:
- The kernel's I2C driver must be off. Two owners for BSC1 is a fight.
pinctrl get 2,3should showipbefore the program starts. - The core clock isn't a fixed number. On a Pi 3 it can be 250 or 400 MHz. Choose the divider for the fastest case.
- Every PCF8574 byte sets all 8 pins. Leave out the backlight bit once and the light goes off.
- Let RS settle before E rises. Many examples send two bytes per nibble and get away with it. I send three.
- The contrast that shows blocks may not show text. A lit screen with no letters is usually just the pot.
- Don't clear the screen on every update. Write padded lines over the old ones, and only when the text changed.
- Never hold the frame lock during I2C. A full line takes about 25 ms, and the buzzer checks the frame every 10 ms.
- Don't let a screen stop your alarm. A loose wire should cost you a display, not the whole program.
That's the last task in the project: the alarm now boots by itself, measures, screams, blinks and shows the distance, all from one frame and a handful of threads.
Related Videos
- Raspberry Pi Finally Has a FACE! Drive an LCD1602 in C With NO Libraries (I2C Registers + /dev/mem)
- 16 Wires? NO! Add a Screen to Your Raspberry Pi With Just FOUR (LCD1602 I2C Wiring, Zero Code)
- Make the buzzer scream with hardware PWM in C
- Measure distance to the microsecond in C (HC-SR04 + GPIO registers)
- Build the heartbeat thread and shared frame
- Build, deploy and debug on the Pi with one command (CMake presets + GDB)