Protótipo Arduino Dual Core com ATMega328P - Embarcados
SDK Arduino Dual Core
O ATMega328P, microcontrolador do Arduino, é single-core e não suporta execução de tarefas paralelas. Quando necessário rodar "múltiplas tarefas" podemos utilizar soluções como timer interrupts ou Protothreads, aonde se define um intervalo de tempo para execução das tarefas e podemos trocar de contexto entre elas. Dessa forma temos "múltiplas tarefas" sendo executadas, mas ainda uma de cada vez em seus intervalos. Nesse artigo apresento uma prova de conceito para um Arduino Dual Core com processamento paralelo, em um protótipo utilizando dois ATMega328P e um SDK para controle das tarefas e intercomunicação entre os núcleos, de forma que o programador tenha a sensação de estar programando um único dispositivo.
O protótipo segue o modelo padrão de um Arduino standalone, mas com dois núcleos, dois ATMega328P. Note que estamos utilizando dois microcontroladores, mas eles compartilham da mesma fonte de energia e do mesmo cristal de clock:
Outros dois pontos importantes a serem destacados no protótipo são a conexão I2C entre os núcleos:
E a linha de interrupção entre o núcleo 2, porta digital 8 como output, e o núcleo 1, porta digital 3 como entrada de interrupção:
Vamos falar mais sobre a intercomunicação entre os núcleos utilizando I2C e IRQ mais a frente nesse artigo.
Com o protótipo do hardware montado agora o desafio é escrever um firmware que possa executar tarefas de forma paralela nos dois microcontroladores. Para isso temos que definir uma forma de intercomunicação entre os núcleos. A ideia foi realizar o upload do mesmo programa nos dois microcontroladores, assim eles vão compartilhar das mesmas funções e ficará por conta da intercomunicação qual núcleo vai rodar o que. Para facilitar a programação criei uma classe para deixar isso da forma mais transparente o possível.
Para a intercomunicação entre os núcleos resolvi utilizar I2C. Sendo assim primeiramente temos que definir qual núcleo vai ser o Master e qual vai ser o Slave. A solução que implementei foi escrever na EEPROM do núcleo 1 durante o upload do firmware. Assim o software lê a EEPROM e configura a conexão I2C como Master no núcleo 1. Essa definição também foi importante para a configuração de filas de tarefas. Na programação podemos montar filas de tarefas para cada núcleo, essas tarefas serão inicializadas durante o "boot", setup, sem necessidade de intercomunicação. Como os dois núcleos compartilham do mesmo código o software tem que saber quem é quem, para executar apenas a fila do núcleo em que está rodando. Vamos entender melhor isso no exemplo.
E a linha de interrupção entre os núcleos? O núcleo 1, master, pode pedir para que o núcleo 2 execute uma certa tarefa e necessite de uma resposta, valor de retorno por exemplo, da execução dessa tarefa. O núcleo 1 vai continuar executando suas tarefas após a requisição e quando o núcleo 2 tiver uma resposta, retorno de função, ele ativará a interrupção. Assim o núcleo 1 é "avisado", para a execução de suas tarefas e recebe do núcleo 2, via I2C, o valor de retorno da tarefa, e volta de onde parou.
Para fins de demonstração vamos supor que precisamos de uma aplicação para piscar três LEDs, mas em paralelo e em velocidades diferentes. Um LED amarelo deve piscar de 100 em 100 ms, em paralelo a um LED azul que deve piscar de 1 em 1 s apenas 4x. Ao término da quarta piscada do LED azul, deve-se começar a piscar um LED branco, que irá piscar 10x, e o LED amarelo deve trocar sua velocidade para 1 s. Quando o LED amarelo parar de piscar o LED azul deve voltar a piscar 4x, enquanto o LED amarelo muda sua velocidade para 500 ms. Ao término das novas 4x que o LED azul irá piscar, o LED amarelo deve voltar a piscar de 1 em 1 s.
Para esse exemplo vamos colocar cada piscar de LEDs em uma tarefa/função. Note que sempre que cada limite de vezes que um LED deve piscar é alcançado a velocidade do LED amarelo muda. Então podemos escrever esse valor de mudança como retorno da tarefa do piscar de cada LED. Veja a implementação do exemplo:
Tarefa do LED amarelo começa a piscar de 100 em 100 ms definido pelo valor de CORE.core2Return. Essa propriedade de CORE que recebe o valor retornado de uma tarefa executada pelo núcleo 2. Utilizar essa propriedade aqui vai ser importante para pegar o valor de retorno das outras tarefas que serão executadas no núcleo 2, e obviamente iremos rodar essa função no núcleo 1. Lembrando que o LED amarelo vai ter sua velocidade trocada quando o LED azul e branco pararem de piscar.
LED Azul pisca 4x e retorna 1000. Esperamos piscar o LED azul em paralelo ao LED amarelo, então ele deve ser executado no núcleo 2. Quando terminar ele irá retornar o valor da troca de velocidade para o LED amarelo, 1000 ms que é igual a 1 s.
LED branco pisca dependendo do argumento. Essa tarefa estará na fila de tarefas do núcleo 2 e irá ser executada quando o LED azul para de piscar. Vamos ver a fila mais a frente.
Até aqui tudo bem, funções em linguagem C normais. Com as tarefas definidas podemos escrever o setup dos nossos núcleos, é aqui que vamos utilizar o objeto CORE do SDK para administrar as filas de execução:
Nesse método se verifica a EEPROM e o firmware descobre se está rodando no núcleo 1 ou no núcleo 2. Também registra as interrupções e a conexão I2C entre os núcleos.
CORE.setPinMode(pin, mode);
Podemos utilizar pinos dos dois núcleos. Por isso ao invés de utilizar a função pinMode padrão da biblioteca do Arduino, foi implementado esse método. Por de trás dos panos ele converte a numeração "dual core" para a numeração real do pino. Outro ponto importante é que o pinMode tem de ser executado no core real aonde o pino está, então esse método também inclui o pinMode à fila de tarefas do núcleo do pino. Veja a imagem abaixo do protótipo com a numeração "dual core":
Por exemplo ao configurar o pino 4 com CORE.pinMode(4, OUTPUT), o método irá adicionar a função pinMode(12, OUTPUT) à fila de tarefas do núcleo 2, e retornará a numeração do pino real 12. Importante guardar o retorno desse método em uma variável do tipo inteiro para ser utilizado no digitalWrite e etc …
CORE.addCommand("function", function);
Esse método registra o ponteiro da função e o string do seu nome a um hashmap interno ao SDK. Assim o núcleo 1 pode, por exemplo, enviar o nome de uma função, em string, via I2C para que seja executada pelo núcleo 2, que irá buscar o ponteiro da função no seu hashmap pelo nome. É nesse ponto que há a vantagem de termos o mesmo firmware nos dois microcontroladores. A programação dos núcleos fica transparentes pois os dois microcontroladores tem as mesmas funções. O usuário do classe fica com a sensação de estar programando para um dispositivo único.
CORE.addExecTask(function, core, argument);
Esse método adiciona o ponteiro de uma função a fila de tarefas de um determinado core, passado como parâmetro, CORE1 ou CORE2. O método espera que passemos uma função com a assinatura void name(int argName) por isso ainda podemos passar um último argumento, do tipo inteiro, para esse método que será o argumento passado quando a tarefa sair da fila e ser executada. Importante: utilize esse método apenas dentro de setup() pois ele não faz intercomunicação entre os núcleos. Dentro de setup() ele é útil para que possamos já incluir tarefas nas filas de execução dos núcleos durante o "boot".
CORE.onCore2Return
Essa propriedade espera receber um ponteiro de função, no caso do exemplo estamos usando uma expressão lambda, que irá ser executado quando o núcleo 2 tiver retornado, acionado a interrupção do núcleo 1, de alguma tarefa/função com valor de return diferente de -1.
CORE.execOn(function, core)
Esse método adiciona uma função a fila de tarefas de um determinado core, passado como parâmetro, CORE1 ou CORE2. A diferença aqui, relacionado ao CORE.addExecTask, é que esse método deve ser utilizado fora do escopo do setup() pois ele irá usar da intercomunicação entre os núcleos. Note que estamos usando esse método dentro do CORE.onCore2Return que será executado na interrupção de retorno do núcleo 2, ele foi definido, implementado, dentro de setup() mas irá ser executado fora do escopo de setup().
E por fim, e não menos importante, o CORE.exec(): :
O CORE.exec(); que executa a fila de tarefas de cada núcleo em que ele estará executando. Sempre deixe esse método dentro do loop(); pois quando o núcleo estiver livre, ou seja tiver executado todas as suas tarefas, ele estará sempre verificando se há novas tarefas enviadas à fila.
Veja esse vídeo do exemplo sendo executado no protótipo:
Resumindo: o piscar do LED amarelo está sendo executado no núcleo 1, o piscar dos LED azul e branco estão sendo executados no núcleo 2, em paralelo e em velocidades diferentes. Quando o LED azul desliga a quarta vez ele aciona a interrupção para avisar o núcleo 1 que a tarefa do núcleo 2 terminou e temos um retorno. Para o vídeo coloquei um LED vermelho na linha de interrupção para ficar mais visual esse aviso entre os núcleos. Cada vez que o LED vermelho muda de estado quer dizer que núcleo 2 está avisando o núcleo 1 que uma tarefa terminou, assim o núcleo 1 pode verificar o valor de retorno e utiliza-lo em suas tarefas, ou até mesmo utilizar esses valores de retorno para tomar decisões e colocar novas tarefas na fila de execução do núcleo 2.
É muito bacana ver como a solução de deixar os dois microcontroladores com o mesmo firmware, utilizando o SDK, facilita a programação do dispositivo como se ele fosse um só. Provamos o conceito de que é possível fazer um Arduino Dual Core de certa forma transparente. A comunicação entre os núcleos poderia ser feita através de conexão serial, mas perderíamos esses pinos para aplicações posteriores, então a comunicação I2C caiu muito bem pois ainda podemos utilizar outros dispositivos usando as mesmas linhas de I2C sem tirar a funcionalidade de intercomunicação entre os núcleos.
Este ainda é um protótipo, quem sabe no futuro não possamos colocar essa ideia em uma PCB e integrar à IDE do Arduino. O projeto é totalmente livre e está disponível no meu Github:
Arquivo Fritzing:
https://github.com/microhobby/miduecore/blob/master/fritzing/miduecore.fzz
Source do SDK: https://github.com/microhobby/miduecore
Home » Arduino » Protótipo Arduino Dual Core
Interessante, muito didático. Porém tenho pontos a colocar: acredito que pela ideia de simular um dual core, a comunicação entre os MCUs deveria ser a mais rápida possível, visando ser o mais próximo de um SoC, coisa que o I2C não te oferece, devido a sua baixa taxa de transferência (geralmente na casa do kHz, raramente chegando em kHz dependendo do chip). Talvez um SPI seria melhor. Para "paralelizar" tarefas em sistemas embarcados geralmente usa-se RTOS. Sugiro uma implementação de um micro kernel usando soft times e sua abordagem de Arduino Dual core, creio que seria interessante.