[back to index]


Picoro is a small (hacky) cooperative multi-tasking library for the Raspberry Pi Pico.
The name is a mash-up of "Pico Coroutine".

Cooperative Multi-tasking

(To be written)

Async I2C

I2C is usually very slow. From the CPU's point of view, it takes forever to send a bunch of bytes across the wire. Luckily, I2C is usually used for small amounts of data, e.g. some sensor readings. Still, you might want to push the data out asynchronously wrt what the CPU does; e.g. using those freed-up cycles for some other computation. That's pretty much the general idea with async-io (in pretty much any programming language). Picoro gives you the async part. It also includes a driver that makes using that "async" easier.

Take a look at i2c.h and i2c.cpp.
Here's an example:

// reading voltage measurement from the INA219 current sensor

// there are only 4 values we can read: bus voltage, shunt voltage, current, power.
uint16_t    cmds[] = {
    I2C_START   | I2C_WRITE(0x1)  /* INA219_REG_SHUNTVOLTAGE */,   // (I2C_START does nothing, it's here only for symmetry with I2C_STOP)
    I2C_RESTART | I2C_READ(0x1),
    I2C_RESTART | I2C_WRITE(0x2)  /* INA219_REG_BUSVOLTAGE */,     // chip register to read, we need to write this to the chip.
    I2C_RESTART | I2C_READ(0x2),                                   // we want to read, so we need to write a dummy "read" command.
                  I2C_READ(0x2),                                   // registers are 16 bits, so we need two byte transfers (across the wire).
    I2C_RESTART | I2C_WRITE(0x3)  /* INA219_REG_POWER */,
    I2C_RESTART | I2C_READ(0x3),                                   // (I2C_RESTART is optional, the RP2040 will auto-generate it on read/write direction change)
                  I2C_READ(0x3),                                   // (the value for I2C_READ is ignored, you can use it to remind yourself which register you are reading)
    I2C_RESTART | I2C_WRITE(0x4)  /* INA219_REG_CURRENT */,
    I2C_RESTART | I2C_READ(0x4),
                  I2C_READ(0x4) | I2C_STOP                         // last command needs this stop bit to let go of the bus.

bool    success = false;
Waitable* transfer = queue_cmds(
                         0x43  /* INA219_ADDRESS */, 
                         sizeof(cmds) / sizeof(cmds[0]),     // how many commands
                         8,                                  // how many bytes we expect to read: 4 x int16
                         (uint8_t*) &buffer[0],

// do other stuff, maybe...


// now we can do something with the bytes in buffer.

First, there is an important API difference vs the SDK's i2c_read_blocking: you need to supply READ commands. Reading from another device in I2C requires us to send READ commands to the target, "I want to read 1 byte!", which the target then responds to. The SDK's i2c_read_blocking does that for you behind the scenes (the I2C_IC_DATA_CMD_CMD_BITS, not an obvious name for that constant, tbh).

Second, you need to tell the driver how many READ commands you have issued, so it can wait for those to arrive. If you give it the wrong number you are likely going to end up waiting forever.

So how does it work?
The driver has a ringbuffer of requests, so all the other Coros running can queue up stuff.
For each request, the driver configures the target address.
Then it sets up a write DMA channel, and if you told it you'll be expecting to read data, a read DMA channel.
Starting the write channel will set things in motion. The DMA controller will fill the I2C transmit FIFO, at whatever pace it permits. Note that the read channel runs in parallel! We do not need to synchronise (DMA's) access to the command register of the I2C controller. Whenever the target device responds with data, the read channel will transfer it to our buffer.

Now here comes the critical part: when do we know we are finished?
We have a DMA completion handler. But that will only tell us when DMA has finished copying stuff into the transmit FIFO. It does NOT tell us that the transfer has completed!
For that we need to wait for I2C's TX_EMPTY interrupt. Once that fires we know we are done. And only then can we start another request!
What about READ requests? Our DMA completion handler waits for both the WRITEs to finish as well as the number of expected READs.

Picoro's API gives you a waitable object. If you need to wait for the result to come back you can wait-and-yield on this object. That will put your current Coro back in the scheduler queue and allow another one to run, until the driver signals that waitable object, waking you up.