I2C

Previously, I looked into porting Inferno to the SparkFun Artemis module, which uses the Apollo3 microcontroller. It's a bit more convenient to use than some of the other MCUs in the MicroMod series of processor boards because it exposes one of its two UARTs via the standard USB connection that the MicroMod carrier boards have, making it fairly straightforward to turn it into a plug-and-play development board.

Peripherals

With the status LED and UARTs functioning, there are plenty of other tasks that could be started right now. The port doesn't support floating point operations fully, there's no functioning just-in-time (JIT) compiler, and the Thumb-2 compiler that is used to build it could use some tidying up. But it would be nice to explore a bit more before tackling those issues.

Since I had transferred the processor board to a Data Logging Carrier Board, there are two input/output peripherals that are just waiting to be supported: I2C and SPI. The Apollo3 MCU handles both of these together, and it looked like I2C might be easier to start with, so that's where things went next.

Back to bare metal

Once you get past a certain point with porting Inferno, it becomes fairly comfortable to write drivers to support peripherals. Still, I like to return to my bare metal development environment because it's quicker to create and flash small programs onto a development board, and also because I might want to write small standalone programs that run directly on the hardware.

Like the UARTs, setting up I2C involved a certain amount of trial and error, decoding what the datasheet was trying to tell me about FIFOs, commands, clocks, DMA, with the steps needed scattered around in the chapter about the IOM (I2C/SPI Master Module). There is also a certain amount of basic information mixed in about I2C which is potentially useful for anyone unfamiliar with it, but distracting otherwise.

Ultimately, it all boils down to the usual dance of enabling power to the peripheral, configuring pads and pins, then configuring the clock used for the peripheral. Additionally, the appropriate submodule within the IOM peripheral needs to be enabled. After that, it was a case of writing tests and example code to verify that things worked at a basic level, using a couple of I2C devices.

The Inferno i2c device

Inferno already has a file server for accessing I2C devices, providing an interface that programs can use to interact with them. The file server presents a standard way to access devices and performs a lot of the housekeeping needed to manage file operations, particularly those that configure how the interface works.

Underneath the file server, drivers for the specific hardware peripherals in use need to be written to interact with devices, and this means that a common API needs to be implemented.

void i2csetup(int polling) performs setup of the hardware peripheral used to access I2C devices. If polling is 1, the setup will involve configuring interrupts. In my implementation, I just ignored this. The i2c device appears to send 0 to this function, in any case.

long i2crecv(I2Cdev *d, void *buf, long len, ulong offset) handles reads from the data file exposed for a device.

long i2csend(I2Cdev *d, void *buf, long len, ulong offset) handles writes to the data file exposed for a device.

Once these are implemented, it becomes possible to use the i2c device to bind an I2C device at a particular address to a target directory:

apollo3$ bind -a '#J38' /dev
apollo3$ ls /dev | grep i2c
i2c.38.data
i2c.38.ctl

There is some subtlety involved when using the files the i2c device exposes to communicate with the I2C device itself. When you need to send data to a particular device register, you first need to enable subaddressing by sending a subaddress command to the control file, specifying the size of the subaddress address space:

apollo3$ echo 'subaddress 1' > /dev/i2c.38.ctl

Now, it should be possible to seek to a particular offset in the data file and perform a read or write operation. The offset will be translated to a register address, or device subaddress, depending on your terminology.

I2C from Limbo

Although you can get quite far using the device files from the shell, it's easier to use Limbo to interact with the data device file. The following two functions show how file offsets are used to specify the register/subaddress to access.

read_data(dfd: ref sys->FD, offset, length: int): array of byte
{
    b := array[length] of byte;
    sys->seek(dfd, big offset, sys->SEEKSTART);
    sys->readn(dfd, b, length);
    return b;
}

write_data(dfd: ref sys->FD, offset: int, b: array of byte)
{
    sys->seek(dfd, big offset, sys->SEEKSTART);
    sys->write(dfd, b, len b);
}

It's possible to write simple tools to read from devices and write to others. The first example I have to hand involves a Limbo program called aht which reads from an AHT20 temperature and humidity sensor, writing the temperature to standard output. The second program, ledsegment, reads a number from standard input and converts it to data that it sends to a LED brick. The use of standard input and output means that we can connect these programs with pipes to display the current temperature on the LED display:

apollo3$ aht | ledsegment

This might not all be as robust as it could be. Error handling and resource management will no doubt need to be improved, but it's a proof of concept, and another step toward more ambitious goals.

Code for the port can be found in the apollo3 branch of this repository. Look in the os/apollo3/appl directory for the programs mentioned above.


David Boddie
29 August 2023