Biblioteca de gerador de código Assembler para microcontroladores AVR. Parte 5

← Parte 4. Programação de periféricos e manuseio de interrupções


Biblioteca do gerador de código do assembler para microcontroladores AVR


Parte 5. Projetando aplicativos multithread


Nas partes anteriores do artigo, elaboramos os conceitos básicos de programação usando a biblioteca. Na parte anterior, nos familiarizamos com a implementação de interrupções e as restrições que podem surgir ao trabalhar com elas. Nesta parte do post, abordaremos uma das opções possíveis para programar processos paralelos usando a classe Parallel . O uso dessa classe possibilita simplificar a criação de aplicativos nos quais os dados devem ser processados ​​em vários fluxos de programas independentes.


Todos os sistemas multitarefa para sistemas de núcleo único são semelhantes entre si. O multithreading é implementado através do trabalho do expedidor, que aloca um intervalo de tempo para cada encadeamento e, quando termina, assume o controle e concede controle ao próximo encadeamento. A diferença entre as várias implementações está apenas nos detalhes, portanto, vamos nos aprofundar mais nos principais recursos específicos dessa implementação.


A unidade de execução do processo no encadeamento é a tarefa. Um número ilimitado de tarefas pode existir no sistema, mas a qualquer momento, apenas um certo número delas pode ser ativado, limitado pelo número de fluxos de trabalho no expedidor. Nesta implementação, o número de fluxos de trabalho é especificado no construtor do gerenciador e não pode ser alterado posteriormente. No processo, os threads podem executar tarefas ou permanecer livres. Diferentemente de outras soluções, o Parallel Manager não alterna tarefas. Para que a tarefa retorne o controle ao expedidor, os comandos apropriados devem ser inseridos em seu código. Portanto, a responsabilidade pela duração do intervalo de tempo na tarefa é do programador, que deve inserir comandos de interrupção em determinados locais do código se a tarefa demorar muito e também determinar o comportamento do encadeamento após a conclusão da tarefa. A vantagem dessa abordagem é que o programador controla os pontos de alternância entre tarefas, o que permite otimizar significativamente o código de salvar / restaurar ao alternar tarefas, além de livrar-se da maioria dos problemas relacionados ao acesso a dados seguros para threads.


Para controlar a execução de tarefas em execução, uma classe Signal especial é usada. O sinal é um pouco variável, cuja configuração é usada como sinal de ativação para iniciar uma tarefa em um fluxo. Os valores do sinal podem ser definidos manualmente ou por um evento associado a este sinal.


O sinal é redefinido quando a tarefa é ativada pelo expedidor ou pode ser executada programaticamente.


As tarefas no sistema podem estar nos seguintes estados:


Desativado - estado inicial para todas as tarefas. A tarefa não ocupa o fluxo e o controle de execução não é transferido. O retorno a esse estado para tarefas ativadas ocorre após o comando de conclusão.


Ativado - o estado em que a tarefa está localizada após a ativação. O processo de ativação associa uma tarefa a um encadeamento de execução e um sinal de ativação. O gerente consulta os encadeamentos e inicia a tarefa se o sinal da tarefa estiver ativado.


Bloqueado - quando uma tarefa é ativada, um sinal já pode ser atribuído a ela como um sinal, que já é usado para controlar outro encadeamento. Nesse caso, para evitar a ambiguidade do comportamento do programa, a tarefa ativada entra no estado bloqueado. Nesse estado, a tarefa ocupa o encadeamento, mas não pode receber controle, mesmo que seu sinal esteja ativado. Após a conclusão das tarefas ou ao alterar o sinal de ativação, o expedidor verifica e altera o status das tarefas nos encadeamentos. Se os threads bloquearem tarefas para as quais o sinal corresponde ao liberado, o primeiro encontrado é ativado. Se necessário, o programador pode bloquear e desbloquear tarefas independentemente, com base na lógica necessária do programa.


Aguardando - o estado em que a tarefa está após a execução do comando Delay . Nesse estado, a tarefa não recebe controle até que o intervalo necessário tenha decorrido. Na classe Paralela , interrupções WDT de 16 ms são usadas para controlar o atraso, o que permite não ocupar temporizadores para as necessidades do sistema. Caso você precise de mais estabilidade ou resolução em pequenos intervalos, em vez de Atraso, você pode usar a ativação por sinais de timer. Deve-se ter em mente que a precisão do atraso ainda será baixa e variará no “tempo de resposta do despachante” - “duração máxima do intervalo de tempo no sistema + tempo de resposta do despachante” . Para tarefas com intervalos de tempo exatos, você deve usar o modo híbrido, no qual o timer, que não é usado na classe Parallel , funciona independentemente do fluxo de tarefas e processa intervalos no modo de interrupção pura.


Cada tarefa executada em um encadeamento é um processo isolado. Isso requer a definição de dois tipos de dados: dados locais de um fluxo, que devem ser visíveis e alterados apenas dentro da estrutura desse fluxo, e dados globais para troca entre fluxos e acesso a recursos compartilhados. Na estrutura desta implementação, os dados globais são criados por comandos anteriormente considerados no nível do dispositivo. Para criar variáveis ​​de tarefas locais, elas devem ser criadas usando métodos da classe de tarefas. O comportamento da variável de tarefa local é o seguinte: quando a tarefa é interrompida antes de transferir o controle para o distribuidor, todas as variáveis ​​de registro local são armazenadas na memória do fluxo. Quando o controle é retornado, as variáveis ​​de registro local são restauradas antes que o próximo comando seja executado.
Uma classe com a interface IHeap associada à propriedade Heap da classe Parallel é responsável por armazenar dados locais do fluxo. A implementação mais simples dessa classe é StaticHeap , que implementa a alocação estática dos mesmos blocos de memória para cada encadeamento. Caso as tarefas tenham uma grande dispersão de acordo com o requisito para a quantidade de dados locais, você poderá usar o DynamicHeap , que permite determinar o tamanho da memória local individualmente para cada tarefa. Obviamente, a sobrecarga de trabalhar com a memória de fluxo nesse caso será significativamente maior.


Agora, vamos dar uma olhada na sintaxe da classe usando dois fluxos como exemplo, cada um dos quais alterna independentemente uma saída de porta separada.


var m = new Mega328 { FCLK = 16000000, CKDIV8 = false }; m.PortB.Direction(0x07); var bit1 = m.PortB[1]; var bit2 = m.PortB[2]; m.PortB.Activate(); var tasks = new Parallel(m, 2); tasks.Heap = new StaticHeap(tasks, 16); var t1 = tasks.CreateTask((tsk) => { var loop = AVRASM.NewLabel(); bit1.Toggle(); tsk.Delay(32); tsk.TaskContinue(loop); },"Task1"); var t2 = tasks.CreateTask((tsk) => { var loop = AVRASM.NewLabel(); bit2.Toggle(); tsk.Delay(48); tsk.TaskContinue(loop); }, "Task2"); var ca = tasks.ContinuousActivate(tasks.AlwaysOn, t1); tasks.ActivateNext(ca, tasks.AlwaysOn, t2); ca.Dispose(); m.EnableInterrupt(); tasks.Loop(); 

As principais linhas do programa já lhe são familiares. Neles, determinamos o tipo de controlador e atribuímos o primeiro e o segundo bits da porta B como saída. A seguir, vem a inicialização de uma variável da classe Parallel , onde no segundo parâmetro determinamos o número máximo de threads de execução. Na próxima linha, alocamos memória para acomodar fluxos variáveis ​​locais. Como temos tarefas iguais, usamos o StaticHeap . O próximo bloco de código é a definição de tarefa. Nele, definimos duas tarefas quase idênticas. A única diferença é a porta de controle e a quantidade de atraso. Para trabalhar com objetos de tarefas locais, um ponteiro para a tarefa local tsk é passado para o bloco de código da tarefa. O texto da tarefa em si é muito simples:


  • um rótulo local é criado para organizar um ciclo de comutação infinito
  • o status da porta é revertido
  • o controle é retornado ao expedidor e a tarefa entra no status de espera pelo número especificado de milissegundos
  • O ponteiro de retorno é definido como o bloco inicial do bloco e o controle é retornado ao expedidor.
    Obviamente, em um exemplo concreto, o último comando pode ser substituído por um comando normal para ir para o início do bloco e fornecido no exemplo apenas com o objetivo de demonstrá-lo. Se desejado, o exemplo pode ser facilmente expandido para controlar um grande número de conclusões, copiando tarefas e aumentando o número de threads.

Uma lista completa dos comandos de interrupção de tarefas para transferir o controle ao expedidor é a seguinte
AWAIT (sinal) - o fluxo salva todas as variáveis ​​na memória do fluxo e transfere o controle para o despachante. Na próxima vez que o fluxo for ativado, as variáveis ​​serão restauradas e a execução continuará, iniciando com a próxima instrução após AWAIT . O comando foi projetado para dividir a tarefa em intervalos de tempo e implementar a máquina de estado de acordo com o esquema Sinal → Processamento 1 → Sinal → Processamento 2 , etc.


O comando AWAIT pode ter um sinal como parâmetro opcional. Se o parâmetro estiver vazio, o sinal de ativação é salvo. Se for especificado no parâmetro, todas as chamadas de tarefas subseqüentes serão feitas quando o sinal especificado for ativado e a comunicação com o sinal anterior for perdida.


TaskContinue (etiqueta, sinal) - o comando finaliza o fluxo e dá controle ao expedidor sem salvar variáveis. Na próxima vez que o fluxo for ativado, o controle é transferido para o rótulo . O parâmetro opcional Signal permite substituir o sinal de ativação do fluxo para a próxima chamada. Se não especificado, o sinal permanece o mesmo. Um comando sem especificar um sinal pode ser usado para organizar ciclos em uma única tarefa, em que cada ciclo é executado em um intervalo de tempo separado. Também pode ser usado para atribuir uma nova tarefa ao segmento atual depois de concluir o anterior. A vantagem dessa abordagem em comparação com o ciclo Liberando um encadeamento → Destacar um fluxo é um programa mais eficiente. O uso do TaskContinue elimina a necessidade de o gerente procurar um encadeamento livre no pool e garante erros ao tentar alocar encadeamentos na ausência de encadeamentos livres.


TaskEnd () - limpe o fluxo após a conclusão da tarefa. A tarefa termina, o encadeamento é liberado e pode ser usado para atribuir uma nova tarefa com o comando Ativar .


Atraso (ms) - o fluxo, como no caso de usar AWAIT , salva todas as variáveis ​​na memória do fluxo e transfere o controle para o expedidor. Nesse caso, o valor do atraso em milissegundos é registrado no cabeçalho do fluxo. No loop do despachante, no caso de um valor diferente de zero no campo de atraso, o fluxo não é ativado. A alteração dos valores no campo de atraso para todos os fluxos é realizada interrompendo o temporizador WDT a cada 16 ms. Quando o valor zero é atingido, a proibição de execução é removida e o sinal de ativação do fluxo é definido. Somente um valor de byte único para o atraso é armazenado no cabeçalho, o que fornece uma faixa relativamente estreita de possíveis atrasos; portanto, para implementar atrasos maiores, Delay () cria um loop interno usando variáveis ​​de fluxo locais.
A ativação dos comandos no exemplo é realizada usando os comandos ContinuousActivate e ActivateNext . Esse é um tipo especial de ativação de tarefa inicial na inicialização. No estágio de ativação inicial, garantimos que não temos um único encadeamento ocupado, portanto, o processo de ativação não requer uma pesquisa preliminar de um encadeamento livre para uma tarefa e permite que você ative as tarefas em sequência. ContinuousActivate ativa a tarefa no segmento zero e retorna um ponteiro para o cabeçalho do próximo segmento, e a função ActivateNext usa esse ponteiro para ativar as seguintes tarefas nos segmentos sequenciais.


Como sinal de ativação, o exemplo usa o sinal AlwaysOn . Este é um dos sinais do sistema. Sua finalidade significa que a tarefa sempre será executada, pois esse é o único sinal que é sempre ativado e não é redefinido pelo uso.


O exemplo termina com uma chamada de loop . Essa função inicia o ciclo do expedidor, portanto, este comando deve ser o último no código.


Considere outro exemplo em que o uso da biblioteca pode simplificar significativamente a estrutura do código. Que seja um dispositivo de controle condicional que registra um sinal analógico e o envia na forma de um código HEX para o terminal.


  var m = new Mega328(); m.FCLK = 16000000; m.CKDIV8 = false; var cData = m.DREG(); var outDigit = m.ARRAY(4); var chex = Const.String("0123456789ABCDEF"); m.ADC.Clock = eADCPrescaler.S64; m.ADC.ADCReserved = 0x01; m.ADC.Source = eASource.ADC0; m.Usart.Baudrate = 9600; m.Usart.FrameFormat = eUartFrame.U8N1; var os = new Parallel(m, 4); os.Heap = new StaticHeap(os, 8); var ADS = os.AddSignal(m.ADC.Handler, () => m.ADC.Data(cData)); var trm = os.AddSignal(m.Usart.TXC_Handler); var starts = os.AddLocker(); os.PrepareSignals(); var t0 = os.CreateTask((tsk) => { m.LOOP(m.TempL, (r, l) => m.GO(l), (r, l) => { m.ADC.ConvertAsync(); tsk.Delay(500); }); }, "activate"); var t1 = os.CreateTask((tsk) => { var loop = AVRASM.NewLabel(); var mref = m.ROMPTR(); mref.Load(chex); m.TempL.Load(cData.High); m.TempL >>= 4; mref += m.TempL; mref.MLoad(m.TempL); m.TempL.MStore(outDigit[0]); mref.Load(chex); m.TempL.Load(cData.High); m.TempL &= 0x0F; mref += m.TempL; mref.MLoad(m.TempL); m.TempL.MStore(outDigit[1]); mref.Load(chex); m.TempL.Load(cData.Low); m.TempL >>= 4; mref += m.TempL; mref.MLoad(m.TempL); m.TempL.MStore(outDigit[2]); mref.Load(chex); m.TempL.Load(cData.Low); m.TempL &= 0x0F; mref += m.TempL; mref.MLoad(m.TempL); m.TempL.MStore(outDigit[3]); starts.Set(); tsk.TaskContinue(loop); }); var t2 = os.CreateTask((tsk) => { var loop = AVRASM.NewLabel(); trm.Clear(); m.TempL.Load('0'); m.Usart.Transmit(m.TempL); tsk.AWAIT(trm); m.TempL.Load('x'); m.Usart.Transmit(m.TempL); tsk.AWAIT(); m.TempL.MLoad(outDigit[0]); m.Usart.Transmit(m.TempL); tsk.AWAIT(); m.TempL.MLoad(outDigit[1]); m.Usart.Transmit(m.TempL); tsk.AWAIT(); m.TempL.MLoad(outDigit[2]); m.Usart.Transmit(m.TempL); tsk.AWAIT(); m.TempL.MLoad(outDigit[3]); m.Usart.Transmit(m.TempL); tsk.AWAIT(); m.TempL.Load(13); m.Usart.Transmit(m.TempL); tsk.AWAIT(); m.TempL.Load(10); m.Usart.Transmit(m.TempL); tsk.TaskContinue(loop, starts); }); var p = os.ContinuousActivate(os.AlwaysOn, t0); os.ActivateNext(p, ADS, t1); os.ActivateNext(p, starts, t2); m.ADC.Activate(); m.Usart.Activate(); m.EnableInterrupt(); os.Loop(); 

Isso não quer dizer que vimos muitas coisas novas aqui, mas você pode ver algo interessante neste código.


Neste exemplo, o ADC (conversor analógico-digital) é mencionado pela primeira vez. Este dispositivo periférico foi projetado para converter a tensão do sinal de entrada em um código digital. O ciclo de conversão é iniciado pela função ConvertAsync , que inicia o processo apenas sem aguardar o resultado. Quando a conversão estiver concluída, o ADC gera uma interrupção que ativa o sinal adcSig . Preste atenção na definição do sinal adcSig . Além do ponteiro de interrupção, ele também contém um bloco de código para armazenar valores do registro de dados ADC. Todo o código que é executado preferencialmente imediatamente após a interrupção (por exemplo, leitura de dados dos registros do dispositivo) deve estar localizado neste local.
A tarefa de conversão é converter um código de tensão binário em uma representação HEX de quatro caracteres para o nosso terminal condicional. Aqui podemos observar o uso de funções para descrever fragmentos repetidos para reduzir o tamanho do código-fonte e o uso de uma string constante para conversão de dados.


O problema de transmissão é interessante do ponto de vista da implementação da saída formatada de uma string na qual a saída de dados estáticos e dinâmicos é combinada. O mecanismo em si não pode ser considerado ideal; é uma demonstração das possibilidades de gerenciamento de manipuladores. Aqui você também pode prestar atenção à redefinição do sinal de ativação durante a execução, que altera o sinal de ativação de ConvS para TxS e vice-versa.


Para uma melhor compreensão, descrevemos em palavras o algoritmo do programa.


No estado inicial, lançamos três tarefas. Dois deles têm sinais inativos, uma vez que o sinal para a tarefa de conversão (adcSig) é ativado no final do ciclo de leitura do sinal analógico, e o ConvS para a tarefa de transmissão é ativado por um código que ainda não foi executado. Como resultado, a primeira tarefa a ser lançada após o lançamento será sempre a medição. O código para esta tarefa inicia o ciclo de conversão do ADC, após o qual a tarefa de 500 ms entra no ciclo de espera. No final do ciclo de conversão, o sinalizador adcSig é ativado , o que aciona a tarefa de conversão . Nesta tarefa, um ciclo de conversão dos dados recebidos em uma sequência é implementado. Antes de sair da tarefa, ativamos o sinalizador ConvS , deixando claro que temos novos dados para enviar ao terminal. O comando exit redefine o ponto de retorno para o início da tarefa e dá controle ao expedidor. O conjunto de sinalizadores ConvS permite transferir o controle para a tarefa de transmissão . Após transmitir o primeiro byte da sequência, o sinal de ativação na tarefa muda para TxS . Como resultado, após a transferência do byte ser concluída, a tarefa de transmissão será chamada novamente, o que levará à transferência do próximo byte. Após o último byte da sequência ser transmitido, a tarefa retorna o sinal de ativação do ConvS e redefine o ponto de retorno para o início da tarefa. O ciclo está completo. O próximo ciclo começará quando a tarefa de medição concluir a espera e ativar o próximo ciclo de medição.


Em quase todos os sistemas multitarefa, existe o conceito de filas para a interação entre threads. Já descobrimos que, como alternar entre tarefas neste sistema é um processo completamente controlado, é possível usar variáveis ​​globais para trocar dados entre tarefas. No entanto, há várias tarefas em que o uso de filas é justificado. Portanto, não deixaremos de lado este tópico e veremos como ele é implementado na biblioteca.


Para implementar uma fila em um programa, é melhor usar a classe RingBuff . A classe, como o nome indica, implementa um buffer de anel com comandos de gravação e busca. A leitura e gravação de dados são realizadas pelos comandos de leitura e gravação . Os comandos de leitura e gravação não têm parâmetros. O buffer usa a variável de registro especificada no construtor como a fonte / receptor de dados. O acesso a essa variável é feito através do parâmetro IOReg class. O status do buffer é determinado pelos dois sinalizadores Ovf e Empty , que ajudam a determinar o estado de estouro durante a gravação e estouro durante a leitura. Além disso, a classe tem a capacidade de determinar o código que é executado nos eventos de estouro / estouro. RingBuff não tem dependências na classe Parallel e pode ser usado separadamente. A limitação ao trabalhar com a classe é a capacidade permitida, que deve ser um múltiplo da potência de dois (8.16.32 etc.) por motivos de otimização de código.


Um exemplo de trabalho com a classe é dado abaixo.


  var m = new Mega328(); var io = m.REG(); //     16     io. var bf = new RingBuff(m, 16, io) { //    OnOverflow = () => { AVRASM.Comment("   "); }, OnEmpty = () => { AVRASM.Comment("   "); } }; var cntr = m.REG(); cntr.Load(16); //       m.LOOP(cntr, (r, l) => { cntr--; m.IFNOTEMPTY(l); },(r)=> { //         //m.IF(bf.Ovf,()=>{AVRASM.Comment("”)}; bf.IOReg.Load(cntr); //      bf.Write(); //    }); //     m.LOOP(cntr, (r, l) => { m.GO(l); }, (r) => { //         //m.IF(bf.Ovf,()=>{AVRASM.Comment(" ”)}; bf.Read(); //       IOReg //    }); 

Esta parte conclui a visão geral dos recursos da biblioteca. Infelizmente, ainda havia vários aspectos relacionados às capacidades da biblioteca, que nem sequer foram mencionados. No futuro, no caso de interesse no projeto, os artigos estão planejados para resolver problemas específicos usando a biblioteca e uma descrição mais detalhada de problemas complexos que requerem uma publicação separada.

Source: https://habr.com/ru/post/pt464651/


All Articles