SAMD51 ARM Serial1 Read in an ISR - Better Approaches?

To summarize what I am looking for is basically a way for SAMD51 chipsets to be able to constantly listen in for TX serial / UART commands sent over to it by a user using the Arduino serial monitor (that will be sent through Serial1 then to RS485 to the receiving slave units) no matter where it is in the void loop() state and once a /n is finally received in the serial monitor, blast that entire command of perhaps a few dozen bytes or so downstream over Serial1 pins 0 and 1 to the RS485 shield then out to slave units. When it hears something back from the responding slave unit that the message was sent to, proceed to send that RX back to the serial monitor so you can see what it sent back.

The rest of the code should just run normally and loop awfully rapidly. This is really just basically an alternate method for the user to send serial commands out such that things are not waiting on the main script to finish up anything it might be doing or waiting on and therefore not be able to send commands out over serial at that time.

Hopefully that makes sense and helps to illustrate why the prior examples, while very helpful and very good practices did not seem to be terribly helpful for this specific use of serial on this particular ARM hardware although they are generally very good practices and very useful guides to follow.

The part for me that is the oddest here is that I cannot imagine that I am the first person in the 2 or so years that this chipset has been readily available over retail who is trying to use serial / UART in this way. Has nobody else come up with an explanation or code that handles this as an intact module or single driver? I have spent several days now looking around and have not come up with anything but if anyone knows of something, please let me know!

Your lucadavidian links are giving my virus scanner a migraine…

You’ve written a lot of stuff here and I wont bother to read it in detail but what I gather – and correct me if I’m wrong – is that you are giving a treatise on the basics of serial interrupts but aren’t maybe realizing that this functionality is already in the libraries. From the HardwareSerial.cpp library, for example:

void HardwareSerial::_tx_udr_empty_irq(void)
{
  // If interrupts are enabled, there must be more data in the output
  // buffer. Send the next byte
  unsigned char c = _tx_buffer[_tx_buffer_tail];
  _tx_buffer_tail = (_tx_buffer_tail + 1) % SERIAL_TX_BUFFER_SIZE;

  *_udr = c;

  // clear the TXC bit -- "can be cleared by writing a one to its bit
  // location". This makes sure flush() won't return until the bytes
  // actually got written. Other r/w bits are preserved, and zeroes
  // written to the rest.

#ifdef MPCM0
  *_ucsra = ((*_ucsra) & ((1 << U2X0) | (1 << MPCM0))) | (1 << TXC0);
#else
  *_ucsra = ((*_ucsra) & ((1 << U2X0) | (1 << TXC0)));
#endif

  if (_tx_buffer_head == _tx_buffer_tail) {
    // Buffer empty, so disable interrupts
    cbi(*_ucsrb, UDRIE0);
  }
}

The low-level interrupt already gather and transmit data to and from buffers. If you use “Serial.write(…”) to send a byte then, if the TX buffer is empty, it will be sent right away. If there’s data already in the buffer it will be queued and sent as part of the TX interrupt logic.

There is an RX and a TX buffer (~64 bytes each if memory serves, at least in an AVR) the allow the ISRs to buffer data ready for your app to come get them. When you do a Serial.available() call you’re really checking if the head and tail pointers to the RX buffer show no unread data (head == tail) or data available (head != tail.)

It seems like you want to go in and add all your serial message processing to the ISRs. This would be a bad idea.

Leave the serial ISRs alone; they’re stable, efficient and small. Read the provided buffers frequently and act on them in a timely fashion by writing the rest of your code so that the processor isn’t blocked or otherwise occupied for long periods of time doing other stuff.

The RX and a TX buffer on the SAMD51 isn’t 64 or even 128 bytes though. It’s 1 byte if I am understanding correctly. It just handles things markedly differently than AVR does.

It seems like you are looking at an AVR HardwareSerial.cpp file though?
https://github.com/arduino/ArduinoCore-avr/blob/master/cores/arduino/HardwareSerial.cpp

Here is the relevant part of the HardwareSerial.h file for the SAMD line here. There isn’t even a HardwareSerial.cpp file.

https://github.com/adafruit/ArduinoCore-samd/blob/master/cores/arduino/HardwareSerial.h
Which was forked from
https://github.com/arduino/ArduinoCore-samd/blob/master/cores/arduino/HardwareSerial.h

class HardwareSerial : public Stream
{
  public:
    virtual void begin(unsigned long) {}
    virtual void begin(unsigned long, uint16_t) {}
    virtual void end() {}
    virtual int available(void) = 0;
    virtual int peek(void) = 0;
    virtual int read(void) = 0;
    virtual void flush(void) = 0;
    virtual size_t write(uint8_t) = 0;
    using Print::write; // pull in write(str) and write(buf, size) from Print
    virtual operator bool() = 0;
};

extern void serialEventRun(void) __attribute__((weak));

I am unclear if the functionality I am looking for is actually in the SAMD51 libraries. Adafruit doesn’t seem to offer what seems exactly like a robust library for how to use this chipset or if they do, they sure don’t exactly document it well.

There is a RingBuffer.h and SERCOM.cpp / SERCOM.h file now though.
https://github.com/adafruit/ArduinoCore-samd/tree/master/cores/arduino

https://github.com/adafruit/ArduinoCore-samd/blob/master/cores/arduino/SERCOM.cpp in particular looks promising at least to better understand what is there now.

/* =========================
 * ===== Sercom UART
 * =========================
*/
void SERCOM::initUART(SercomUartMode mode, SercomUartSampleRate sampleRate, uint32_t baudrate)
{
  initClockNVIC();
  resetUART();

  //Setting the CTRLA register
  sercom->USART.CTRLA.reg = SERCOM_USART_CTRLA_MODE(mode) |
                            SERCOM_USART_CTRLA_SAMPR(sampleRate);

  //Setting the Interrupt register
  sercom->USART.INTENSET.reg = SERCOM_USART_INTENSET_RXC |  //Received complete
                               SERCOM_USART_INTENSET_ERROR; //All others errors

  if ( mode == UART_INT_CLOCK )
  {
    uint16_t sampleRateValue;

    if (sampleRate == SAMPLE_RATE_x16) {
      sampleRateValue = 16;
    } else {
      sampleRateValue = 8;
    }

    // Asynchronous fractional mode (Table 24-2 in datasheet)
    //   BAUD = fref / (sampleRateValue * fbaud)
    // (multiply by 8, to calculate fractional piece)
#if defined(__SAMD51__)
    uint32_t baudTimes8 = (SERCOM_FREQ_REF * 8) / (sampleRateValue * baudrate);
#else
    uint32_t baudTimes8 = (SystemCoreClock * 8) / (sampleRateValue * baudrate);
#endif

    sercom->USART.BAUD.FRAC.FP   = (baudTimes8 % 8);
    sercom->USART.BAUD.FRAC.BAUD = (baudTimes8 / 8);
  }
}
void SERCOM::initFrame(SercomUartCharSize charSize, SercomDataOrder dataOrder, SercomParityMode parityMode, SercomNumberStopBit nbStopBits)
{
  //Setting the CTRLA register
  sercom->USART.CTRLA.reg |=
    SERCOM_USART_CTRLA_FORM((parityMode == SERCOM_NO_PARITY ? 0 : 1) ) |
    dataOrder << SERCOM_USART_CTRLA_DORD_Pos;

  //Setting the CTRLB register
  sercom->USART.CTRLB.reg |= SERCOM_USART_CTRLB_CHSIZE(charSize) |
    nbStopBits << SERCOM_USART_CTRLB_SBMODE_Pos |
    (parityMode == SERCOM_NO_PARITY ? 0 : parityMode) <<
      SERCOM_USART_CTRLB_PMODE_Pos; //If no parity use default value
}

void SERCOM::initPads(SercomUartTXPad txPad, SercomRXPad rxPad)
{
  //Setting the CTRLA register
  sercom->USART.CTRLA.reg |= SERCOM_USART_CTRLA_TXPO(txPad) |
                             SERCOM_USART_CTRLA_RXPO(rxPad);

  // Enable Transceiver and Receiver
  sercom->USART.CTRLB.reg |= SERCOM_USART_CTRLB_TXEN | SERCOM_USART_CTRLB_RXEN ;
}

void SERCOM::resetUART()
{
  // Start the Software Reset
  sercom->USART.CTRLA.bit.SWRST = 1 ;

  while ( sercom->USART.CTRLA.bit.SWRST || sercom->USART.SYNCBUSY.bit.SWRST )
  {
    // Wait for both bits Software Reset from CTRLA and SYNCBUSY coming back to 0
  }
}

void SERCOM::enableUART()
{
  //Setting  the enable bit to 1
  sercom->USART.CTRLA.bit.ENABLE = 0x1u;

  //Wait for then enable bit from SYNCBUSY is equal to 0;
  while(sercom->USART.SYNCBUSY.bit.ENABLE);
}

void SERCOM::flushUART()
{
  // Skip checking transmission completion if data register is empty
  if(isDataRegisterEmptyUART())
    return;

  // Wait for transmission to complete
  while(!sercom->USART.INTFLAG.bit.TXC);
}

void SERCOM::clearStatusUART()
{
  //Reset (with 0) the STATUS register
  sercom->USART.STATUS.reg = SERCOM_USART_STATUS_RESETVALUE;
}

bool SERCOM::availableDataUART()
{
  //RXC : Receive Complete
  return sercom->USART.INTFLAG.bit.RXC;
}

bool SERCOM::isUARTError()
{
  return sercom->USART.INTFLAG.bit.ERROR;
}

void SERCOM::acknowledgeUARTError()
{
  sercom->USART.INTFLAG.bit.ERROR = 1;
}

bool SERCOM::isBufferOverflowErrorUART()
{
  //BUFOVF : Buffer Overflow
  return sercom->USART.STATUS.bit.BUFOVF;
}

bool SERCOM::isFrameErrorUART()
{
  //FERR : Frame Error
  return sercom->USART.STATUS.bit.FERR;
}

void SERCOM::clearFrameErrorUART()
{
  // clear FERR bit writing 1 status bit
  sercom->USART.STATUS.bit.FERR = 1;
}

bool SERCOM::isParityErrorUART()
{
  //PERR : Parity Error
  return sercom->USART.STATUS.bit.PERR;
}

bool SERCOM::isDataRegisterEmptyUART()
{
  //DRE : Data Register Empty
  return sercom->USART.INTFLAG.bit.DRE;
}

uint8_t SERCOM::readDataUART()
{
  return sercom->USART.DATA.bit.DATA;
}

int SERCOM::writeDataUART(uint8_t data)
{
  // Wait for data register to be empty
  while(!isDataRegisterEmptyUART());

  //Put data into DATA register
  sercom->USART.DATA.reg = (uint16_t)data;
  return 1;
}

void SERCOM::enableDataRegisterEmptyInterruptUART()
{
  sercom->USART.INTENSET.reg = SERCOM_USART_INTENSET_DRE;
}

void SERCOM::disableDataRegisterEmptyInterruptUART()
{
  sercom->USART.INTENCLR.reg = SERCOM_USART_INTENCLR_DRE;
}

It seems like you want to go in and add all your serial message processing to the ISRs. This would be a bad idea.

I completely agree. But what I am looking to figure out how to do is have it send and receive Serial1 commands basically at any point in time when typed into the Serial Monitor and pass them along to downstream RS485 devices, getting the response back and displaying that in the Serial Monitor. Even if in a delay or in a loop or otherwise not paying attention to Serial1. That’s really all I am trying to accomplish here specifically. I get that this is doable, probably in several different ways and it looks like with a number of built in tools that are already there. I just have yet to come across a good guide or good method of how to do that exactly is all.

One would think and hope that using Serial.write("…") to send a byte if the TX buffer is empty, it will be sent right away would work but it does not seem to do so if the main loop is in a while or delay or anything else, even in very simple testing. Maybe it has to do with how information is treated differently over the Serial Monitor? That is what I am trying to figure out because the goal is really just to pass the serial commands sent out such that they are sent out through RS485 as soon as they are entered into the Serial Monitor by the user.

I'm pretty sure the SAMD core does use a ring buffer (see RingBuffer.h) for UART comms. It's normal for the processor to have a single TX and RX register for each UART. It's up to the ISR to handle the RX/TX FIFO/ring buffers.

Can you define "as soon as"? What's the baud rate from the monitor? What's an acceptable latency from the time a complete message is received from the console to it going out as an RS485 message?

Remember you've got a 120MHz core there with a clock period of 8.3nS. What is the processor doing elsewhere that would take it so long to get back to pull received characters out of the buffer?

I believe you are correct. It does seem to use a ring buffer. Looking at 115,200 baud here with the serial monitor. The fastest that a user is going to be sending commands is a few dozen bytes every second or two. Not fast at all.

As soon as meaning within half a second or so of pressing the enter button for it to send the command over to the downstream device over RS485. An eternity in terms of the SAMD51. It does NOT need to be within a few nanoseconds but it has to actually send it over, not wait until the unit has someone press a start button for example or simply never send it at all because it times out or otherwise isn't sent at all.

The issue isn't that the unit is so busy. The issue is that it needs to actually send the serial commands out over Serial Monitor (or technically Serial1, pins 0 and 1 here out to RS485) once the user presses enter and then have it send back what it hears back as an interrupt also to the Serial Monitor then move back to doing other things. That's really all I am trying to do here.

arm_isr_serial:
I believe you are correct. It does seem to use a ring buffer. Looking at 115,200 baud here with the serial monitor. The fastest that a user is going to be sending commands is a few dozen bytes every second or two. Not fast at all.

As soon as meaning within half a second or so of pressing the enter button for it to send the command over to the downstream device over RS485. An eternity in terms of the SAMD51. It does NOT need to be within a few nanoseconds but it has to actually send it over, not wait until the unit has someone press a start button for example or simply never send it at all because it times out or otherwise isn't sent at all.

The issue isn't that the unit is so busy. The issue is that it needs to actually send the serial commands out over Serial Monitor (or technically Serial1, pins 0 and 1 here out to RS485) once the user presses enter and then have it send back what it hears back as an interrupt also to the Serial Monitor then move back to doing other things. That's really all I am trying to do here.

All very doable. Would be willing to offer help if you would post your code. Otherwise, thoughts and prayers.

Happy to post code. We sort of have a workaround, albeit a very, very crude one. It feels like it is about the worst way that you could solve this issue. It polls every 1 ms. It has to have you use MyDelay instead of Delay. It makes you put something to listen for it into every single while or if loop or anything else that pauses in any way.

It also technically sort of works.

What I would like to figure out though is what is the actual correct way of doing this? Clearly there are built in interrupts specifically designed only to operate for a few micro seconds while serial data is actually in the buffer and able to be sent.

#define CPU_HZ 48000000
int pinValue = 9;
int val1 = 1;

void MyDelay(int ms) {
   for(int i=0;i<ms;i++){
      if (Serial.available())
      {
        Serial.read();
      }
      delay(1);
   }
}

void setup(){

Serial.begin(115200);
 while (!Serial)
    delay(100);  // wait for USB Serial to enumerate

pinMode(pinValue, INPUT_PULLUP);
}

void loop(){ 

val1 = digitalRead(pinValue);

while (val1 == 1)
 {
  val1 = digitalRead(pinValue);
  delay(10);
  if(Serial.available()){
    Serial.read();
}
  // Do nothing
 }

MyDelay(500);

Any ideas or suggestions or things to consider? Surely there are ways to TX and RX serial commands like this (over Serial Monitor while connected through the PC), the moment they send commands in despite using any Arduino code, right?

Why use delay()? You’re wasting thousands or millions of CPU cycles by blocking the processor 1 or 10mS all the time.

Use millis timing. Consider the following:

In principle, it toggles an LED every 250mS while receiving any characters that pop in on the serial port virtually instantly. It doesn’t use delay to wait for LED toggles; instead it peeks in every loop for a few microseconds to see if 250mS has elapsed. If not, it returns to loop() and allows other things to happen, like checks of the serial port.

This compiles and runs on a Due (ATSAM3X8E) and should work on yours I think:

#define CPU_HZ          48000000

#define BUFF_SIZE       256         //serial RX buffer size

char
    rxBuffer[BUFF_SIZE];
    
const uint8_t pinValue = 9;
const uint8_t pinLED = LED_BUILTIN;

void setup()
{
    Serial.begin(115200);

    pinMode(pinValue, INPUT_PULLUP);
    pinMode( pinLED, OUTPUT );
}

void loop()
{
    Check250mSThing();
    CheckSerial();

}//loop

void Check250mSThing( void )
{
    static bool
        state = false;
    static uint32_t
        timeCheck;
    uint32_t
        timeNow = millis();

    if( (timeNow - timeCheck) < 250ul )
        return;

    timeCheck = timeNow;

    //do whatever task needs doing every 250mS
    //.
    //.
    //.   
    //for example, toggle an LED on and off 
    
    state ^= true;
    digitalWrite( pinLED, state );
    
}//CheckPin_SM

void CheckSerial( void )
{
    static uint16_t
        ptr = 0;

    //check every single pass of loop()
    if( Serial.available() > 0 )
    {
        //if any are waiting, grab them all
        while( Serial.available() > 0 )
        {
            //read the next available character
            char cRx = Serial.read();
            
            //if LF (EOM marker, for example)...
            if( cRx == '\n' )
            {                
                //...send buffer out as received
                Serial.write( rxBuffer, --ptr );
                //zero the pointer ready for the next RX message
                ptr = 0;
                
                //...
                
            }//if
            else
            {
                //receive as many bytes are in the RX buffer
                rxBuffer[ptr] = cRx;
                ptr++;
                if( ptr >= BUFF_SIZE - 1 )
                    ptr--;

            }//else

        }//while
        
    }//if
    
}//CheckSerial

Believe me, I don't want to use delay()! I agree that is the WRONG way of doing it. It technically works but it is a horrible way of doing things. Trying to figure out a better approach.

That sounds like a better way to go. I compiles. I will try it out and see, it does sound like a better approach (though again, probably anything is better than the current approach).

#define CPU_HZ          48000000

I assume that is for a 48 MHz unit? The Due is 84 MHz and the Grand Central is 120 MHz. So change it to

#define CPU_HZ          120000000

?

I changed it and it compiles (but it compiles either way).

arm_isr_serial:
Believe me, I don't want to use delay()! I agree that is the WRONG way of doing it. It technically works but it is a horrible way of doing things. Trying to figure out a better approach.

That sounds like a better way to go. I compiles. I will try it out and see, it does sound like a better approach (though again, probably anything is better than the current approach).

#define CPU_HZ          48000000

I assume that is for a 48 MHz unit? The Due is 84 MHz and the Grand Central is 120 MHz. So change it to

#define CPU_HZ          120000000

?

I changed it and it compiles (but it compiles either way).

Didn't actually notice it (it was in your original code.) I don't think it affects operation or actual clocking.

[/quote]

So to do this “correctly” seems to involve looking further into the USB side of things, since the serial monitor is through USB and isn’t traditional SERCOM.

\Arduino15\packages\adafruit\hardware\samd\1.6.4\libraries\USBHost\src

Things like Usb.cpp and so on.

We wound up basically coming up with a slightly less crude but still workable version that uses a partial derivation of some of Martin’s code. It is now using timers and interrupts and seems to work well enough. Now it does not require one to literally paste commands in to make it constantly check serial every 1 ms while you are in a while loop or otherwise “idle” from the user’s perspective. Even though it is now still checking every 1 ms :slight_smile:

It now allows one to send commands over serial monitor and have it send them downstream then hear back from the RS485 device that is replying, even if the main sketch is otherwise “paused”.

Timer4.cpp
#include "Timer4.h"


void (*func2)();
static inline void TC4_wait_for_sync() {
  while (TC4->COUNT16.SYNCBUSY.reg != 0) {}
}
void Timer4_::setPeriod_4(unsigned long period) {
  int prescaler;
  uint32_t TC_CTRLA_PRESCALER_DIVN;

  TC4->COUNT16.CTRLA.reg &= ~TC_CTRLA_ENABLE;
  TC4_wait_for_sync();
  TC4->COUNT16.CTRLA.reg &= ~TC_CTRLA_PRESCALER_DIV1024;
  TC4_wait_for_sync();
  TC4->COUNT16.CTRLA.reg &= ~TC_CTRLA_PRESCALER_DIV256;
  TC4_wait_for_sync();
  TC4->COUNT16.CTRLA.reg &= ~TC_CTRLA_PRESCALER_DIV64;
  TC4_wait_for_sync();
  TC4->COUNT16.CTRLA.reg &= ~TC_CTRLA_PRESCALER_DIV16;
  TC4_wait_for_sync();
  TC4->COUNT16.CTRLA.reg &= ~TC_CTRLA_PRESCALER_DIV4;
  TC4_wait_for_sync();
  TC4->COUNT16.CTRLA.reg &= ~TC_CTRLA_PRESCALER_DIV2;
  TC4_wait_for_sync();
  TC4->COUNT16.CTRLA.reg &= ~TC_CTRLA_PRESCALER_DIV1;
  TC4_wait_for_sync();

  if (period > 300000) {
    TC_CTRLA_PRESCALER_DIVN = TC_CTRLA_PRESCALER_DIV1024;
    prescaler = 1024;
  } else if (80000 < period && period <= 300000) {
    TC_CTRLA_PRESCALER_DIVN = TC_CTRLA_PRESCALER_DIV256;
    prescaler = 256;
  } else if (20000 < period && period <= 80000) {
    TC_CTRLA_PRESCALER_DIVN = TC_CTRLA_PRESCALER_DIV64;
    prescaler = 64;
  } else if (10000 < period && period <= 20000) {
    TC_CTRLA_PRESCALER_DIVN = TC_CTRLA_PRESCALER_DIV16;
    prescaler = 16;
  } else if (5000 < period && period <= 10000) {
    TC_CTRLA_PRESCALER_DIVN = TC_CTRLA_PRESCALER_DIV8;
    prescaler = 8;
  } else if (2500 < period && period <= 5000) {
    TC_CTRLA_PRESCALER_DIVN = TC_CTRLA_PRESCALER_DIV4;
    prescaler = 4;
  } else if (1000 < period && period <= 2500) {
    TC_CTRLA_PRESCALER_DIVN = TC_CTRLA_PRESCALER_DIV2;
    prescaler = 2;
  } else if (period <= 1000) {
    TC_CTRLA_PRESCALER_DIVN = TC_CTRLA_PRESCALER_DIV1;
    prescaler = 1;
  }
  TC4->COUNT16.CTRLA.reg |= TC_CTRLA_PRESCALER_DIVN;
  TC4_wait_for_sync();

  int compareValue = (int)(CPU_HZ / (prescaler/((float)period / 1000000))) - 1;

  // Make sure the count is in a proportional position to where it was
  // to prevent any jitter or disconnect when changing the compare value.
  TC4->COUNT16.COUNT.reg = map(TC4->COUNT16.COUNT.reg, 0,
                               TC4->COUNT16.CC[0].reg, 0, compareValue);
  TC4->COUNT16.CC[0].reg = compareValue;
  TC4_wait_for_sync();

  TC4->COUNT16.CTRLA.bit.ENABLE = 1;
  TC4_wait_for_sync();
 }

void Timer4_::startTimer_4(unsigned long period, void (*f)()) {
  // Enable the TC bus clock, use clock generator 0
  GCLK->PCHCTRL[TC4_GCLK_ID].reg = GCLK_PCHCTRL_GEN_GCLK1_Val |
                                   (1 << GCLK_PCHCTRL_CHEN_Pos);
  while (GCLK->SYNCBUSY.reg > 0);

  TC4->COUNT16.CTRLA.bit.ENABLE = 0;
  
  // Use match mode so that the timer counter resets when the count matches the
  // compare register
  TC4->COUNT16.WAVE.bit.WAVEGEN = TC_WAVE_WAVEGEN_MFRQ;
  TC4_wait_for_sync();
  
   // Enable the compare interrupt
  TC4->COUNT16.INTENSET.reg = 0;
  TC4->COUNT16.INTENSET.bit.MC0 = 1;

  // Enable IRQ
  NVIC_EnableIRQ(TC4_IRQn);

  func2 = f;
  setPeriod_4(period); 
}

 
 void TC4_Handler() {
  // If this interrupt is due to the compare register matching the timer count
  if (TC4->COUNT16.INTFLAG.bit.MC0 == 1) {
    TC4->COUNT16.INTFLAG.bit.MC0 = 1;
    (*func2)();
    
  }
}
Timer4.h
#include "Arduino.h"
#define CPU_HZ 120000000

class Timer4_{
  public:
    void setPeriod_4(unsigned long period);
    void startTimer_4(unsigned long period, void (*f)()); 
};

Posted this here in case it helps anyone else.

Put this above void setup()

#include "Timer4.h"

Put this in void setup()

Timer4_ T4;
T4.startTimer_4(1000 , handler);

With a samd51, if you don’t have the discipline to keep your loop non-blocking (I’m don’t mean to imply that this is always easy to do), you could run one of the RTOSes that are available. One task to read serial and write rs485 message, another task to run your loop() code.

Can you elaborate on that a bit more? That sounds interesting to explore further and at the very least know more about. First time I have heard of such a thing.

Seems to speak more about this. Interesting. Seems to respect real time requirements while still allowing for other tasks to run. Will have to look into it further. Was unaware that the SAMD51 supported it. Doesn't seem likely to be compatible with the Arduino IDE though?

I've never used it myself. I don't know how "transparent" it is to the normal Arduino library use.
Your description sounds like exactly the use case for an RTOS...