Kinetis KL I2C driver

cancel
Showing results for 
Show  only  | Search instead for 
Did you mean: 

Kinetis KL I2C driver

2,427 Views
marco_palestro
Contributor II

Hi,

I'm trying to write a simple interrupt driven I2C driver on Kinetis KL05; I've spent days browsing for examples but I couldn't come up with working code; while it seems to be transmitting and generating the interrupt, it always fails loosing arbitration (while there is only one other device on the I2C bus).

Is there anybody willing to post barebone code for driving I2C device?

I've seen many references to a kinetis_I2C.h written by Mark Butcher for "uTasker", but it's more than 1000 lines (!!!) and so much convoluted that I couldn't find a clue about it.

I've found another driver written by Jan Rychter, and since it's a lot less convoluted than the other, I could copy the operations used, but there is still something wrong.

Here is my code (also in attachment):

#include "i2c.h"
#include "IO_Map.h"


#define I2C_AVAILABLE             0
#define I2C_BUSY                 1
#define I2C_ERROR1                 2
#define I2C_ERROR2                 3
#define I2C_ERROR3                 4

#define I2C_WRITING             0
#define I2C_READING             1

struct
{
    uint8_t status;
    uint8_t tx_length;
    uint8_t txrx;
    uint8_t index;
    uint16_t* rx_data;
    uint8_t buffer[I2C_MAX_MSG_SIZE];
} channel;

static void enable_irq(uint32_t irq)
{
    NVIC_ISER |= (1 << irq);
}


// -----------------------------------------------------------------------------
// i2c_init
//
// -----------------------------------------------------------------------------
void i2c_init()
{
    // turn on i2c module
    SIM_SCGC4 |= SIM_SCGC4_I2C0_MASK;
    SIM_SCGC5 |= SIM_SCGC5_PORTA_MASK;

    // set ports function to i2c
    PORTA_PCR3 = (uint32_t) (PORT_PCR_MUX(0x02));                                            // SCL
    PORTA_PCR4 = (uint32_t) (PORT_PCR_MUX(0x02) | PORT_PCR_PE_MASK | PORT_PCR_PS_MASK);        // SDA

    // enable interrupt for i2c (INT_I2C0 = 0x18 = 24)
      enable_irq(INT_I2C0 - 16);

    I2C0_C1 = 0;
    I2C0_C1 |= I2C_C1_IICEN_MASK;    // enable module operation

    uint8_t mult = 0x00;    // 0x00=1,0x01=2,0x10=4

    // see 36.4.1.10 I2C divider and hold values
    // icr=0x20 scl divider=160
    // I2C baud rate = bus speed (Hz)/(mul × SCL divider)
    // baud = bus / 320
    uint8_t icr = 0x20;
    I2C0_F &= ~0xf;
    I2C0_F |= ((mult << I2C_F_MULT_SHIFT) | icr);

    channel.status = I2C_AVAILABLE;

    I2C0_S |= I2C_S_IICIF_MASK;                             // clear interrupt service flag
    I2C0_C1 = (I2C_C1_IICEN_MASK | I2C_C1_IICIE_MASK);        // enable module, enable interrupt
    I2C0_C1 |= (I2C_C1_MST_MASK | I2C_C1_TX_MASK);            // set as master, start tx
}

// -----------------------------------------------------------------------------
// i2c_message
//
// -----------------------------------------------------------------------------
bool i2c_message(const uint8_t* msg, uint8_t length, uint16_t* rx_data)
{
    // - send i2c message using interrupts
    // - if msg requires read, copy received data (2 bytes) on rd_data if specified
    // - return false if i2c busy

    if (channel.status != I2C_AVAILABLE)
    {
        return false;
    }

    if (length > I2C_MAX_MSG_SIZE)
    {
        return false;
    }

    channel.status    = I2C_BUSY;
    channel.txrx      = I2C_WRITING;
    channel.tx_length = length;
    channel.index     = 0;
    channel.rx_data   = rx_data;

    // copy message to local buffer
    uint8_t i;
    for (i = 0; i < length; i++)
    {
        channel.buffer[i] = msg[i];
    }

    I2C0_S |= I2C_S_IICIF_MASK;                             // clear interrupt service flag
    I2C0_C1 = (I2C_C1_IICEN_MASK | I2C_C1_IICIE_MASK);        // enable module, enable interrupt
    I2C0_C1 |= (I2C_C1_MST_MASK | I2C_C1_TX_MASK);            // set as master, start tx

    uint8_t status = I2C0_S;                                // check status register
    if (status & I2C_S_ARBL_MASK)                            // lost arbitration
    {
        // stop i2c
        I2C0_S |= I2C_S_ARBL_MASK;                             // clear arbl (by setting it)
        I2C0_C1 &= ~(I2C_C1_IICIE_MASK | I2C_C1_MST_MASK | I2C_C1_TX_MASK);
        channel.status = I2C_ERROR1;
        return false;
    }

    // tx first byte
    I2C0_D = channel.buffer[channel.index];
    channel.index++;

    return true;
}

// -----------------------------------------------------------------------------
// isr_i2c_chiam
//
// -----------------------------------------------------------------------------
void isr_i2c_chiam()
{
    uint8_t status = I2C0_S;                                // check status register

    if (!(status & I2C_S_IICIF_MASK))
    {
        // check that i2c isf flag is set
        return;
    }

    I2C0_S |= I2C_S_IICIF_MASK;                             // clear interrupt service flag

    if (status & I2C_S_ARBL_MASK)                            // lost arbitration
    {
        // stop i2c
        I2C0_S |= I2C_S_ARBL_MASK;                             // clear arbl (by setting it)
        I2C0_C1 &= ~(I2C_C1_IICIE_MASK | I2C_C1_MST_MASK | I2C_C1_TX_MASK);
        channel.status = I2C_ERROR2;
        return;
    }

      if (channel.txrx == I2C_WRITING)
      {
        if (channel.index == channel.tx_length)
        {
            // end of tx: check if read must be performed
            if (channel.rx_data != 0)
            {
                // switch to reading
                channel.txrx = I2C_READING;
                channel.index = 0;
                I2C0_C1 &= ~I2C_C1_TX_MASK;         // switch to RX mode
                I2C0_C1 &= ~(I2C_C1_TXAK_MASK);      // ACK all but the final read

                // dummy read
                *(channel.rx_data) = I2C0_D;
            }
            else
            {
                // Generate STOP (set MST=0), switch to RX mode, and disable further interrupts
                I2C0_C1 &= ~(I2C_C1_MST_MASK | I2C_C1_IICIE_MASK | I2C_C1_TXAK_MASK);
                channel.status = I2C_AVAILABLE;
            }
            return;
        }

        if (status & I2C_S_RXAK_MASK)
        {
              // NACK received: generate a STOP condition and abort
            I2C0_C1 &= ~(I2C_C1_MST_MASK | I2C_C1_IICIE_MASK);     // generate STOP and disable further interrupts
            channel.status = I2C_ERROR3;
            return;
        }

        // write byte
        I2C0_D = channel.buffer[channel.index];
        channel.index++;
    }
    else
    {
        if (channel.index == 0)
        {
            // first read
            I2C0_C1 |= I2C_C1_TXAK_MASK;         // do not ACK the final read

            *(channel.rx_data) = I2C0_D;        // low byte first?
            channel.index++;
        }
        else
        {
            // second and last read

            // All the reads in the sequence have been processed (but note that the final data register
            // read still needs to be done below!) Now the next thing is the end of a sequence; we need
            // to switch to TX mode to avoid triggering another I2C read when reading the contents of
            // the data register
            I2C0_C1 |= I2C_C1_TX_MASK;

            // Perform the final data register read now that it's safe to do so
            uint8_t rx = I2C0_D;
            *(channel.rx_data) |= (rx << 8);    // high byte second?

            // Generate STOP (set MST=0), switch to RX mode, and disable further interrupts
            I2C0_C1 &= ~(I2C_C1_MST_MASK | I2C_C1_IICIE_MASK | I2C_C1_TXAK_MASK);
            channel.status = I2C_AVAILABLE;
        }
    }
}

0 Kudos
Reply
10 Replies

2,172 Views
marco_palestro
Contributor II

Hi everybody,
thanks to all your answers I've been able to fix the errors; there are still a few questions bothering me:

- about open-drain mode for KL05, I've searched for the option but the reference manual for KL05 regarding I2C reports: "When the package pins associated with IIC have their mux select configured for IIC operation, the pins (SCL and SDA) are driven in a pseudo open drain configuration."

In port control and interrupt summary, regarding open drain enable control, it says "NO" (not available) neither for PORTA nor for PORTB; a note about it writes:

  1. UART signals can be configured for open-drain using SIM_SOPT5 register. IIC signals are automatically enabled for open

drain when selected.

so I guess the pins are already correctly configured?

- I'd also like to ask if it's necessary to configure pull up (PE and PS bits in PORTx_PCRn) for SDA, since it's used as input when receiving;

- KL05 doesn't have double buffered I2C so I didn't have to add the fix (which is instead present in the driver written by Jan Rychter)

- I'm still confused as how to correctly 'write 1 to clear' the required bits like ARBL or IICIF; Jan Rychter driver and NXP bare metal driver use |= ; Mark Butcher uses a macro: "WRITE_ONE_TO_CLEAR(I2C0_S, I2C_IIF);"; the project I'm working on is developed using Code Warrior and I can't say if a similar macro has been provided

I'm going to add the resulting code to the answer as reference as soon as I finish cleaning the mess.

Thanks again to everybody for the answers!

0 Kudos
Reply

2,172 Views
bobpaddock
Senior Contributor III

"...Jan Rychter driver and NXP bare metal driver use |= ..."

Yes they do.  Lots of that code out there.

At best it wastes CPU cycles and code space with unnecessary reads,

at worse it is dangerous depending on how the interrupt service routine is structured

from unintentionally clearing a status bit at the wrong time.

0 Kudos
Reply

2,172 Views
mjbcswitzerland
Specialist V

Marco

#define WRITE_ONE_TO_CLEAR(reg, flag)    reg = (flag)

The macro makes the code clearer but is just writing a '1' to the defined bit(s).

The other sources using |= will work but they can also reset the arbitration lost bit (either intentionally or by error depending on the intention behind it and it should be commented to be able to know).

If one wants to clear other flags too could should expressly do it in code to avoid uncertainty:
WRITE_ONE_TO_CLEAR(I2C0_S, (I2C_IIF | I2C_IAL));
The macro method is also more efficient since it avoids a potentially superfluous read.

If you are doing anything more that a class-room demonstration or hobby work you also need to consider being able to remover from a blocked slave otherwise failure will require a power cycle to recover: https://community.nxp.com/thread/322977 

Regards

Mark
[uTasker project developer for Kinetis and i.MX RT]

0 Kudos
Reply

2,172 Views
marco_palestro
Contributor II

Thanx Mark,

   That clears out any doubt about 'w1c' question.
I've also read the article you cited and found it useful; I agree that, when polling devices through serial communication, error recovery is fundamental to avoid leaving the device in a locked state.
Due to the simplicity of the current project, I'm leaving error management to the upper level; since periodic read are performed, if the last communication failed an error is signaled and the slave device is reset. I expect anyway to have to do more accurate error management when moving from the test board to the real device.
Also I think my actual code is useful as a minimal reference for driving I2C write, read and restart through interrupt, but it's left at its minimum for memory usage. At a later stage it's expected to add one more slave device, so the current code and error management will have to be updated accordingly (I still don't know if a message queue is necessary or not).

Here is my code (which is basically a stripped version of Jan Rychter code), please let me know if you find anything wrong.

Best regards,

Marco

i2c.h

#ifndef __I2C_DRIVER_H
#define __I2C_DRIVER_H

#include <stdint.h>

typedef uint8_t bool;
#define true 1
#define false 0


#define I2C_MAX_MSG_SIZE    10  // max size of writable data

// initialize i2c device
void i2c_init();

// write tx_data to I2C 'slave' device; if rx_data != 0, also read rx_length bytes to rx_data
// - tx_data are copied to internal buffer (at most I2C_MAX_MSG_SIZE bytes)
// - i2c_message() returns immediately; data read are available at end of communication
bool i2c_message(uint8_t slave, const uint8_t* tx_data, uint8_t tx_length, uint8_t* rx_data, uint8_t rx_length);


// I2C interrupt service routine
void isr_i2c();

// I2C peripheral working data model
typedef struct
{
 uint8_t slave;       // slave address used for current communication
 uint8_t tx_length;      // number of bytes to write
 uint8_t rx_length;      // number of bytes to read
 uint8_t index;       // index of current array (write/read)
 uint8_t state;       // current operation
 uint8_t* rx_data;      // pointer to read data (at least rx_length allocated bytes)
 uint8_t tx_data[I2C_MAX_MSG_SIZE];  // buffer of data to write

} I2C_Channel;

// I2C_Channel.state values
#define I2C_AVAILABLE    0   // peripheral free
#define I2C_TX     1   // writing
#define I2C_RX_START   2   // start read
#define I2C_RX     3   // reading
#define I2C_ERROR    255   // error

// I2C peripheral working data
extern volatile I2C_Channel i2c_channel;

#endif

i2c.c:

#include "i2c.h"
#include "IO_Map.h"  // KL05 register defines

// pins used:
// PIN:  use  ALT1 ALT2
// 9  SCL  PTA3 I2C0_SCL
// 10  SDA  PTA4 I2C0_SDA


volatile I2C_Channel i2c_channel;

// -----------------------------------------------------------------------------
// i2c_init
//
// -----------------------------------------------------------------------------
void i2c_init()
{
 // turn on i2c module
 SIM_SCGC4 |= SIM_SCGC4_I2C0_MASK;
 SIM_SCGC5 |= SIM_SCGC5_PORTA_MASK;

 // set ports function to i2c
 PORTA_PCR3 = (uint32_t) (PORT_PCR_MUX(0x02));          // SCL
 // which one is correct ?
 PORTA_PCR4 = (uint32_t) (PORT_PCR_MUX(0x02) | PORT_PCR_PE_MASK | PORT_PCR_PS_MASK); // SDA
 //PORTA_PCR4 = (uint32_t) (PORT_PCR_MUX(0x02));          // SDA

 // enable interrupt for i2c
 // this macro needs definition:
 //set_irq_priority(8, 15); // set minimum priority

   //enable_irq(INT_I2C0 - 16);
 NVIC_ISER |= (1 << (INT_I2C0 - 16));

 uint8_t mult = 0x00; // 0x00=1,0x01=2,0x10=4
 uint8_t icr = 0x00; // 0x20;
 I2C0_F = ((mult << I2C_F_MULT_SHIFT) | icr);

 // enable I2C, enable interrupt, master mode
 I2C0_C1 = (I2C_C1_IICEN_MASK);
 I2C0_C1 = (I2C_C1_IICEN_MASK | I2C_C1_IICIE_MASK | I2C_C1_TX_MASK);

 i2c_channel.state = I2C_AVAILABLE;
}

// -----------------------------------------------------------------------------
// i2c_message2
//
// -----------------------------------------------------------------------------
bool i2c_message(uint8_t slave, const uint8_t* tx_data, uint8_t tx_length, uint8_t* rx_data, uint8_t rx_length)
{
 uint8_t i;

 // - queue the message only if I2C is available
 // - check against maximum tx_data size
 if (i2c_channel.state != I2C_AVAILABLE ||
     tx_length > I2C_MAX_MSG_SIZE)
 {
  return false;
 }

 // prepare working data
 i2c_channel.slave     = slave;
 i2c_channel.tx_length = tx_length;
 i2c_channel.rx_length = rx_length;
 i2c_channel.index     = 0;
 i2c_channel.state     = I2C_TX;
 i2c_channel.rx_data   = rx_data;
 for (i = 0; i < tx_length; i++)
 {
  i2c_channel.tx_data[i] = tx_data[i];
 }

 // send I2C start
 I2C0_C1 = (I2C_C1_IICEN_MASK | I2C_C1_IICIE_MASK | I2C_C1_MST_MASK | I2C_C1_TX_MASK);
 // send first byte (slave address)
 I2C0_D = slave;

 // the rest of communication will be handled by ISR
 return true;
}

// -----------------------------------------------------------------------------
// isr_i2c
//
// -----------------------------------------------------------------------------
void isr_i2c()
{
 // check status register
   uint8_t status = I2C0_S;

 // clear interrupt service flag
 I2C0_S = I2C_S_IICIF_MASK;

    if (status & I2C_S_ARBL_MASK)
    {
  // lost arbitration (should not happen): stop i2c
        I2C0_S = I2C_S_ARBL_MASK;       // clear arbl (by setting it)
        I2C0_C1 = 0x00;
        i2c_channel.state = I2C_ERROR;
    }
 else
   if (i2c_channel.state == I2C_TX)
   {
  if (status & I2C_S_RXAK_MASK)
  {
   // ACK not received
   // Generate STOP (set MST=0), stay in tx mode, and disable further interrupts
   I2C0_C1 = (I2C_C1_IICEN_MASK | I2C_C1_TX_MASK);
   i2c_channel.state = I2C_ERROR;
  }
  else
  if (i2c_channel.index < i2c_channel.tx_length)
  {
   // write byte
   I2C0_D = i2c_channel.tx_data[i2c_channel.index];
   i2c_channel.index++;
  }
  else
  {
   // end of tx: check if read must be performed
   if (i2c_channel.rx_length > 0)
   {
    // Generate restart
    I2C0_C1 |= (I2C_C1_RSTA_MASK | I2C_C1_TX_MASK);

    // send slave address and read command
    i2c_channel.state = I2C_RX_START;
    I2C0_D = i2c_channel.slave | 0x01;
   }
   else
   {
    // Generate STOP (set MST=0), stay in tx mode, and disable further interrupts
    I2C0_C1 = (I2C_C1_IICEN_MASK | I2C_C1_TX_MASK);
    i2c_channel.state = I2C_AVAILABLE;
   }
  }
 }
 else
   if (i2c_channel.state == I2C_RX_START)
   {
  // switch to reading
  i2c_channel.state = I2C_RX;
  i2c_channel.index = 0;

  I2C0_C1 &= ~I2C_C1_TX_MASK;   // switch to RX mode
  if (i2c_channel.rx_length > 1)
  {
   I2C0_C1 &= ~I2C_C1_TXAK_MASK;   // ack next byte read
  }
  else
  {
   I2C0_C1 |= I2C_C1_TXAK_MASK;  // do not ACK the final read
  }

  // dummy read to start communication
  *(i2c_channel.rx_data) = I2C0_D;
 }
 else
   if (i2c_channel.state == I2C_RX)
   {
  if (i2c_channel.index == i2c_channel.rx_length - 1)
  {
   // last read

   // All the reads in the sequence have been processed (but note that the final data register
   // read still needs to be done below!) Now the next thing is the end of a sequence; we need
   // to switch to TX mode to avoid triggering another I2C read when reading the contents of
   // the data register
   I2C0_C1 |= I2C_C1_TX_MASK;

   // Perform the final data register read now that it's safe to do so
   *(i2c_channel.rx_data + i2c_channel.index) = I2C0_D;

   // Generate STOP (set MST=0), switch to RX mode, and disable further interrupts
   I2C0_C1 = (I2C_C1_IICEN_MASK | I2C_C1_TX_MASK);

   i2c_channel.state = I2C_AVAILABLE;
  }
  else
  {
   // read byte
   *(i2c_channel.rx_data + i2c_channel.index) = I2C0_D;
   i2c_channel.index++;

   if (i2c_channel.index == i2c_channel.rx_length - 1)
   {
    I2C0_C1 |= I2C_C1_TXAK_MASK;  // do not ACK the final read
   }
   else
   {
    I2C0_C1 &= ~I2C_C1_TXAK_MASK;   // ack next byte read
   }
  }
 }
}
0 Kudos
Reply

2,172 Views
diego_charles
NXP TechSupport
NXP TechSupport

Regarding the pull enable for SDA and SCL pins in your driver. 

The correct muxing is the following

PORTA_PCR4 = (uint32_t) (PORT_PCR_MUX(0x02));

Since the pins are a pseudo open drain in this configuration, the will require only an external pull up resistor.

0 Kudos
Reply

2,172 Views
bobpaddock
Senior Contributor III

uint8_t icr = 0x20;
I2C0_F &= ~0xf;
I2C0_F |= ((mult << I2C_F_MULT_SHIFT) | icr);

Also check the erratas for your device.
Some device masks will not issue a Repeated Start if the upper nibble of F is non-zero.

ERRATA_1N96F_ /* Issue 6070: I2C: Repeated Start cannot be generated if the I2Cx_F[MULT] field is set to a non-zero value */

The code posted above appears to be based on the I2C flow chart in most of the Kinetis data sheets.
They neglect to explain when a Repeated Start needs issued on devices that both transmit and receive.
Such as writing the address to an EEPROM then reading the data from said address.

https://www.nxp.com/docs/en/user-guide/UM10204.pdf is the I2C specification document that explains the requirement.

RSTA is the bit that needs fiddled with to generate the required Repeated Start.


0 Kudos
Reply

2,172 Views
bobpaddock
Senior Contributor III

"I2C0_S |= I2C_S_ARBL_MASK;                             // clear arbl (by setting it)"

The flags are cleared by 'Writing a one to clear' (W1C).

Using '|=' can have unintentional side effects from the read in some processors.
An assignment (=) is sufficient to clear a flag in the status register for W1C flags.

0 Kudos
Reply

2,172 Views
bobpaddock
Senior Contributor III

I do not know the specifics of the KL05, this may not apply, it is not one of the devices I use.


Any device that has 'Double Buffered' I2C has a broken Repeated Start system.
A Repeated Start issued at the wrong time is what results in the arbitration  error.

Mark describes the problem in Appendix A of this document:

https://www.utasker.com/docs/uTasker/uTasker_I2C.pdf 


His code shows how to use the STARTF/STOPF interrupt to work around the problem.

Search the forms for issues related to the KL43/KL27 I2C.

The only official statement from NXP/Freescale about this issue is mentioned in an obscure migration guide:

https://www.nxp.com/docs/en/application-note/AN4997.pdf 

Never in the data sheet or an errata.  Their official position seems to be "Our IDE works".
Which it does by inserting undocumented random busy loops for undocumented duration to deal with the internal timing race caused by double buffering.


0 Kudos
Reply

2,172 Views
diego_charles
NXP TechSupport
NXP TechSupport

Hello,

Additionationally to the Mark suggestions. 

You could check this bare-metal implementation for KL05 drivers including I2C.

https://cache.nxp.com/files/32bit/software/KL05-SC.zip

To find the peripheral drivers follow this path. KL05-SC\klxx-sc-baremetal\src\drivers

 

Since the   KL05 is not supported by MCUXpresso SDK  I recommend refer to  KL03 or KL02 SDK drivers.

To download them, go to MCUxpresso SDK builder and click Select  Development board. After logging in, please build the SDK for KL03 or KL02.

To find the SDK peripheral drivers follow this path \devices\MKL03Z4\drivers.

Best regards, Diego. 

0 Kudos
Reply

2,172 Views
mjbcswitzerland
Specialist V

Hi Marco

I have extracted the part of code to send data via the KL05's I2C0 interface so that you can easily understand it:

Initialisation:

void i2c_init()
{
    POWER_UP_ATOMIC(4, I2C0);                                        // enable clock to module
    fnEnterInterrupt(irq_I2C0_ID, PRIORITY_I2C0, _I2C_Interrupt_0);  // enter I2C0 interrupt handler
    _CONFIG_PERIPHERAL(A, 4,  (PA_4_I2C0_SDA | PORT_ODE | PORT_PS_UP_ENABLE)); // I2C0_SDA on PA4 (alt. function 2)
    _CONFIG_PERIPHERAL(A, 3,  (PA_3_I2C0_SCL | PORT_ODE | PORT_PS_UP_ENABLE)); // I2C0_SCL on PA3 (alt. function 2)
    I2C0_F = 0x28;                                                   // 100kHz
    I2C0_C1 = (I2C_IEN);                                             // enable I2C controller
    I2C0_C1 = (I2C_IEN | I2C_IIEN | I2C_MTX);                        // set transmit mode with interrupt enabled
}
‍‍‍‍‍‍‍‍‍‍‍

Here you can see that it is effectively the same as your code apart from the fact that you haven't configured the pins for open-drain mode; this may be a cause of difficulty.

Starting a transmission:

    I2C0_D = slave_address;
    I2C0_C1 = (I2C_IEN | I2C_IIEN | I2C_MSTA | I2C_MTX);             // set master mode to cause start condition to be sent

Handling the Tx interrupt:

    WRITE_ONE_TO_CLEAR(I2C0_S, I2C_IIF);        // clear the interrupt flag (write '1' to clear)
    if (more data to send) {
        I2C0_D = next_byte;                     // send next byte
    }
    else {
        I2C0_C1 = (I2C_IEN | I2C_MTX);          // send stop condition and disable interrupts
    }

That is all that is needed for master transmission.

Regards

Mark

[uTasker project developer for Kinetis and i.MX RT]

0 Kudos
Reply