Also available as PDF. Code at GitHub.
Introduction
I2C is a standard ubiquitous protocol for communication within embedded systems between a central processing unit and its external peripherals. Most embedded chip-sets incorporate I2C communication hardware and embedded vendors include driver-level software within their Hardware Abstraction Layer for efficiently handling low-level transfers over I2C. The protocol clocks bits over two wires \(SDA\) and \(SCL\), serial data and serial clock, respectively. Some embedded systems refer to the protocol as a “two-wire” interface for this reason.
But what about Linux? Though arguably not an embedded operating system,
it can be found in embedded scenarios with sufficiently powerful cores
equipped with sufficient amounts of memory. Embeddable distributions of
Linux typically provide kernel drivers that map the two-wire
I2C write-read protocol to standard Unix file descriptor
accessors. The developer opens a “character device” and uses standard
writes and reads to perform transfers. I2C devices appear at
/dev/i2c- followed by a number to identify the controller channel.
In object modelling terms, the “descriptor” model of input-output for Unix appears below, Figure 1. A “descriptor” acts as a connector between a user-land program and the kernel. Linux maps an I2C connection to a Unix descriptor. The input-output control method allows for arbitrary command requests. The Linux kernel driver uses this interface to configure the channel or to perform advanced transfers; user programs configure the I2C slave address using this generalised interface.
Figure 1: Unix descriptor “class” model. This is a conceptual depiction. The open method appears in the model as a static answering a descriptor instance. The C library answers the underlying file descriptor handle—in practice—and the instance methods receive the handle as the first argument.
It sounds simple. Is it? The following sections develop a simple wrapper library for SWI-Prolog, Rohner and Kjellerstrand (2023). Find the completed sources for the Prolog pack on GitHub and at the SWI-Prolog add-ons.
Prolog Pack
To test out the interface, a “foreign” library for Prolog will wrap the descriptor model. The pack will provide the following I2C predicates.
i2c_open(+Dev, -I2C)to open a device.i2c_funcs(+I2C, ?Funcs)to query functionality.i2c_slave(+I2C, +Addr)to configure the slave address.i2c_write(+I2C, ++Bytes, ?Actual)to write bytes.i2c_read(+I2C, +Expected, ?Bytes)to read bytes.
The predicate descriptions show the argument modes. Note the double plus when writing \(Bytes\), a list of integer terms. Also note that the read operation has partial ground \(Bytes\). Reading performs a complete read using a stack-based buffer and then unifies the resulting octets with the argument. The read succeeds when the buffer contents successfully unify with the argument. In other words, reading can check against some fully complete or partially complete expectations about the response.
Notice that the predicates exclude a close operation.
No Close
The pack does not provide an i2c_close predicate. That may seem
strange. The developer can open a device but not close it. It takes a
lazy approach instead. The open descriptor only closes when the garbage
collector releases the I2C blob. This carries with it one
disadvantage: holding the descriptor open longer than necessary.
Typical usage does not require eager closing, however. The typical embedded scenario keeps its I2C connection open indefinitely. The connection and its open file descriptor belong to a service that operates continuously. The open descriptor only needs to close when the service ends. The garbage collector releases the device blob on program termination. Linux itself will close the descriptor when its owner process dies.
Device Blob
In essence, the Prolog pack provides a “blob” for an I2C descriptor. The code extract below lists the C code that defines the blob and illustrates how it unifies a \(Term\) variable with a file descriptor \(fd\) integer. In Prolog’s world, the blob exists as an atom with some invisible, opaque state.
PL_blob_t i2c_dev_blob_type =
{ .magic = PL_BLOB_MAGIC,
.name = "i2c_dev",
.release = release_i2c_dev,
.write = write_i2c_dev,
};
int unify_i2c_dev(term_t Term, int fd)
{ struct linux_i2c_dev *blob = PL_malloc(sizeof(*blob));
(void)memset(blob, 0, sizeof(*blob));
blob->fd = fd;
return PL_unify_blob(Term, blob, sizeof(*blob), &i2c_dev_blob_type);
}
Opening an I2C device becomes a simple process: opening the device, turning a device number into a path and asking the kernel to open it for read-write access.
foreign_t i2c_open_2(term_t Dev, term_t I2C)
{ int dev;
char pathname[PATH_MAX];
int fd;
if (!PL_get_integer(Dev, &dev)) PL_fail;
Ssnprintf(pathname, sizeof(pathname), "/dev/i2c-%d", dev);
if (0 > (fd = open(pathname, O_RDWR))) return i2c_errno("open");
return unify_i2c_dev(I2C, fd);
}
The final return statement calls the blob unification function listed
previously.
Usage
It works well. The following section takes the pack for a ‘drive’ by talking to a PCA9685.
NXP Semiconductor’s PCA9685
What is the PCA9685? To paraphrase NXP Semiconductor,
“The PCA9685 is a 16-channel LED controller that operates via I2C-bus, specifically designed for Red/Green/Blue/Amber (RGBA) colour back-lighting applications. Each LED output features its own 12-bit resolution, equating to 4096 brightness levels, managed by a dedicated PWM (Pulse Width Modulation) controller. This controller can be programmed to operate at frequencies ranging from a typical 24 Hz to 1526 Hz, with the duty cycle adjustable between 0% and 100%, allowing for precise control over brightness levels. Notably, all outputs maintain the same PWM frequency, ensuring consistency in lighting performance.”
The following predicates define its entire eight-bit register file. The
file of registers primarily addresses sixteen individual pulse-width
modulator units each driven by 12-bit on-off counters. The led_adr/3
clauses associate the on-off and low-high register mappings for each LED
counter with its two-bit relative device address offset: on-low,
on-high, off-low, off-high is the relative order
\[ LedAdr_{i,j}=Base_i+LedAdr_j|i\in0..15\cup all,j\in0..3 \]
%! led_adr(?OnOff, ?LH, ?Adr0) is nondet.
%
% Adr0 is the PWM control's on-off low-high relative register address
% offset, between 0 and 3 inclusive.
led_adr(on, l, 0x00).
led_adr(on, h, 0x01).
led_adr(off, l, 0x02).
led_adr(off, h, 0x03).
%! reg_adr(?Reg, ?Adr) is nondet.
%
% Maps the entire PCA9685 register file.
reg_adr(mode(Mode), Adr) :-
between(1, 2, Mode),
Adr is 0x00 + Mode - 1.
reg_adr(subadr(SubAdr), Adr) :-
between(1, 3, SubAdr),
Adr is 0x02 + SubAdr - 1.
reg_adr(allcalladr, 0x05).
reg_adr(led(LED, OnOff, LH), Adr) :-
between(0, 15, LED),
led_adr(OnOff, LH, Adr0),
Adr is 0x06 + Adr0 + (0x04 * LED).
reg_adr(led(all, OnOff, LH), Adr) :-
led_adr(OnOff, LH, Adr0),
Adr is 0xfa + Adr0.
reg_adr(pre(scale), 0xfe).
reg_adr(test(mode), 0xff).
A write operation first sends the control register that defines the start address for subsequent bytes; the control register auto-increments if enabled; see below. Reading operations similarly start by writing the control register.
The following predicates used for PCA9685 testing describe how to run
I2C transfers by first writing to the Control Register. The
\(Adr\) goes out first on the bus following the slave address, immediately
followed by the bytes to write in the case of a wr/3 or immediately
preceding bytes to read for rd/3.
rd(I2C, Adr, Bytes) :- i2c_write(I2C, [Adr]), i2c_read(I2C, Bytes).
wr(I2C, Adr, Bytes) :- i2c_write(I2C, [Adr|Bytes]).
Reading the first mode register
This becomes a straightforward clause sequence: open the device \(Dev=1\) by number and configure the slave address \(Addr=40_{16}\).
i2c_dev(Dev),
i2c_open(Dev, I2C),
pca9685_addr(Addr),
i2c_slave(I2C, Addr),
ai(I2C),
% Write 00 to the Control Register.
% It determines access to the other registers.
i2c_write(I2C, [0x00]),
i2c_read(I2C, [Byte]).
Enabling the control register’s auto-increment feature
The PCA9685 turns off the control register’s auto-increment (AI) function on restart. It needs enabling. Enable AI idempotently by reading the \(Mode1\) register. If the register has zero in the fifth bit, perform a write-back with the fifth bit set. Retain all other bits.
ai(I2C) :-
reg_adr(mode(1), Adr),
rd(I2C, Adr, [Mode1]),
( Mode1 /\ 2'0010_0000 =\= 0x00
-> true
; Mode1_ is Mode1 \/ 2'0010_0000,
wr(I2C, Adr, [Mode1_])
).
Non-AI mode is less useful. Arguably, AI-enabled should be the default. If the Control Register retains its content, the device accesses the same register at every read operation without auto-incrementing.
That might be useful for some polling scenarios, perhaps, but that would be exceptional; register file access will be sequential for most of the time, e.g. when writing to a PWM unit’s counter on-off registers in a single transfer: on and off, low and high in a four-byte write operation. The same requirement applies to read operations.
System FS
Accessing the PCA9685 like this is only for demonstration purposes.
Ideally, the interface would reuse the kernel driver if such was
available for the target platform. On Raspberry Pi, for instance, the
developer only needs to overlay the device tree for the NXP PCA9685A
kernel driver by adding the following line to
boot/firmware/config.txt. The driver defaults to address \(40_{16}\) and
symbolically links the driver to /sys/class/pwm/pwmchip* if it finds
the LED controller by probing.
dtoverlay=i2c-pwm-pca9685a
Thereafter, the driver interface becomes much
easier,
amounting to simple pseudo-file operations. Write the channel number to
export within the exposed
sysfs interface
in order to access the period and duty cycle for the channel in
nanoseconds. Then write a 1 to the “enable” pseudo-file to start the
pulse-width modulation.
Conclusions
The Linux kernel driver makes I2C easy. The half-duplex
protocol fits neatly beneath the standard Unix file descriptor access
interface: open, ioctl, read, write. It also supports a more
sophisticated interface using message buffers—not covered here.
The pack presents the simplest connection to an I2C
controller. It does not use i2c_msg but instead leaves that step to
future iterations. That would eliminate task switching between transfer
segments, however. User-land would only resume at the end of all
transfer segments. The kernel handles the entire transfer in such a
case. The model based on Unix descriptors gives a simple level of access
but not one without limitations. It adds some latency between reads and
writes. Descriptor read-write is not an optimal implementation by any
means but proves adequate for most requirements.