HD44780 LCD with OCaml


Tags: electronics, ocaml

Some time ago I've purchased an Adafruit HD44780 LCD as a fancy accessory for playing around with Raspberry Pi. It was lying around in my office for some time until recently. I finally got around to putting it together (many thanks to my office mate Carlo for helping me solder and wire the thing).

It came with a Python library for controlling it via a high-level API. However, I don't really like Python, and I really don't like the design of CircuitPython. So I decided to write an interface to the LCD in OCaml, that I originally wanted to use on a Raspberry Pi (using the bindings to the WiringPi library).

The code can be found in my fork of the ocaml-wiringpi repository under the lcd_lwt branch. It mainly consists of two parts: the low-level interface for writing data to the display and invoking some operations, and a high-level interface powered by a "cursor" module.

The basics

The first is a low-level API that controls the display using 4 data pins (D4-D7) and two special "control" pins. The first control pin is EN ("enable pin") which is used to signal the start of data read/write operations. The RS ("register select pin") is used as a flag for selecting which registers to address. The low value denotes the instruction register, and the high value denotes the data register.

For example, to clear the display (instruction 0x01 = 0b00000001) one would send the following signals to the LCD:

  1. send 0 to RS
  2. send 0, 0, 0, 0 to D4, D5, D6, D7, resp -- the four upper bits in reverse order
  3. send 0, 1, 0 to EN -- signal that the first four bits have been sent
  4. send 1, 0, 0, 0 to D4, D5, D6, D7, resp -- the four lower bits in reverse order
  5. send 0, 1, 0 to EN -- signal that the last four bits have been sent

Internally, this sequence of signals stores 0b00000001 in the instruction register IR.

Instead of sending the instructions to the LCD, we can instead send data, which ends up in the display data RAM (DDRAM). Suppose we want to display a character 'a' (ascii 97 = 0b1100001). We then do the following sequence of operations:

  1. send 1 to RS -- address the data register
  2. send 0, 0, 1, 1 to D4, D5, D6, D7, resp -- the four upper bits in reverse order
  3. send 0, 1, 0 to EN -- signal that the first four bits have been sent
  4. send 0, 0, 0, 1 to D4, D5, D6, D7, resp -- the four lower bits in reverse order
  5. send 0, 1, 0 to EN -- signal that the last four bits have been sent

We implement this operation of writing 8 bits to either the instruction register or the data register as the following function:

write8 : Lcd.t -> ?char_mode:bool -> char -> unit

where char_mode determines whether we write the data to the data register (true) or to the instruction register (false).

To write out a string to be displayed we can use Bytes.iter:

let write_bytes lcd bts =
  Bytes.iter (fun c -> write8 lcd c ~char_mode:true) bts

The location in which we store the data is determined by the address counter, which is automatically incremented after each write. This means that we can just send a sequence of characters sequentially, without touching the address counter ourselves.

Operations with arguments

The way that operations with arguments are typically represented is by using e.g. 4 bits to denote the operation and 4 bits to denote the arguments. For instance, to activate the 2 line mode, we invoke the "function set" operation which requires the upper 4 bits to be 0010, with the argument "2 line mode" which requires the lower 4 bits to be 1000. To obtain the code for the whole operation we just take the bitwise OR:

0b0000 1000 -- 2 line mode
0b0010 0000 -- function set
0b0010 1000

This translates to the following mini program in OCaml:

let _lcd_2line               = (0x08) in
let _lcd_functionset         = (0x20) in
write8_unsafe lcd (_lcd_2line lor _lcd_functionset);

Let us a consider a simple example: shifting the characters. By default, the data that is actually displayed on the LCD is taken from the addresses 0x0..0x7 and 0x40..0x47 for the first and the second rows, resp. If we want to display further characters we can use this shift operation. (See FIGURE 5 in the docs).

To do this we invoke the "cursor/display shift" operation settin the appropriate bits for moving the display and the move direction:

let _lcd_cursorshift         = (0x10)
(* 00010000 *)
let _lcd_displaymove         = (0x08)
(* 00001000 *)
let _lcd_moveright           = (0x04)
(* 00000100 *)
(* -------- *)
(* 00011100 *)

let shift_right lcd =
  write8_unsafe lcd (_lcd_cursorshift lor _lcd_displaymove lor _lcd_moveright)

Higher-level interface

We can provide a slightly higher-level interface by keeping track of the cursor (and some other settings) in the program.

  type Cursor.t = { x: int; y: int; visible: bool; blink: bool; _lcd: mono_lcd }

A /cursor/ is a record that combines the underlying mono_lcd type (which stores the pin layout), the current position of the cursor x, y and some settings (whether the cursor should be visible and blinking). Then all the operation on the cursor are just functions Cursor.t -> Cursor.t. For example, a function that write out a string takes in a cursor, writes the underlying bytes using write_bytes and updates the cursor position. A function that sets the blinking flag writes out the desired bytes (as descirbed in the "operations with arguments") and updates the boolean flag. Combination of those operations are just function composition:

let (|>) (m : Cursor.t) (f : Cursor.t -> 'b) = f m
let display_lines lcd l1 l2 =
  let col_shift = 3 in
  clear lcd;
  let cur = Cursor.of_lcd lcd in
  let open Cursor in
  |> set_visible false
  |> set_blink false
  |> set_position col_shift 0 
  |> write_string l1 ~wrap:false
  |> set_position col_shift 1
  |> write_string l2 ~wrap:false


You can find more usage examples in the lcd_lwt.ml file. Overall, I thought that OCaml was a good fit for this kind of programming, and the type system helps out a bit!