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:

Watch on YouTube

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 (0x3 three times, then 0x2, 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 pinPi pinJob
GND6Ground
VCC1 (3.3 V)Power
SDA3 (GPIO2)I2C data
SCL5 (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:

BitValueLCD pinJob
00x01RS0 = command, 1 = character
10x02RWAlways 0: we only write
20x04EA pulse tells the LCD "read the data now"
30x08Backlight1 = on
4–70xF0D4–D7The 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.

BlockJobBus address (datasheet)ARM physical (Pi 3)
GPIOConnect GPIO2 and GPIO3 to BSC10x7E2000000x3F200000
BSC1Talk I2C0x7E8040000x3F804000

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:

RegisterOffsetWhat it does
C (control)0x00Turn it on, empty the queue, start a transfer
S (status)0x04Finished? Any error?
DLEN0x08How many bytes to send
A0x0CThe address of the chip we're talking to: 0x27
FIFO0x10The queue of bytes to send, up to 16
DIV0x14Clock 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:

StepSendThen waitWhy
1β€”50 msThe LCD needs time after power-up
2nibble 0x34.5 ms"Use 8-bit mode", attempt 1
3nibble 0x3150 Β΅sAttempt 2
4nibble 0x3150 Β΅sAttempt 3
5nibble 0x2150 Β΅sNow switch to 4-bit mode
6byte 0x28β€”4-bit, 2 lines, 5Γ—8 dots
7byte 0x08β€”Display off
8byte 0x012 msClear: the only slow command, 1.52 ms
9byte 0x06β€”Cursor moves right after each character
10byte 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:

  1. 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.
  2. 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

ProblemWhat it meansFix
/dev/mem: Permission deniedNot rootsudo ./proxalarm, or run it as the service
LCD not answering at 0x27No ACKWrong 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 blocksThe PCF8574 answers, but the wake-up isn't reaching the LCDCheck the timing in lcdInit and that LCD_E is 0x04
Backlight on, no text, no blocksContrastTurn the pot slowly; text often wants a different setting than blocks
Garbage charactersNibbles out of step: a loose wire or a killed programRe-seat the jumpers and restart: the wake-up routine fixes it
Text only on line 1The LCD is in 1-line modeCheck LCD_CMD_2LINES is 0x28
Backlight goes off when text is writtenA byte is missing the backlight bitCheck LCD_BACKLIGHT is ORed in lcdNibble
Screen flickersClearing the screen on every updateDon't clear: write over it with padded lines
Random errors after some timeBus too fast, or long wiresKeep BSC_CLK_DIV at 4000 and use short jumpers
Errors, or the screen stops, with the I2C driver onThe kernel and our code share BSC1Remove 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 numberThe program crashed or was killed before cleaning upThe 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

  1. 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.
  2. 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.
  3. The HD44780 wake-up routine works from any state: 0x3 three times, then 0x2 for 4-bit mode, then the setup commands. Respect the wait times; only clear is slow in normal use.
  4. No flicker: never clear the screen. Write padded lines over the old ones, and only when the text changed.
  5. 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,3 should show ip before 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