Parte 2. Introdução →
Biblioteca do gerador de código do assembler para microcontroladores AVR
Parte 1. Primeiro conhecido
Boa tarde, queridos Khabrovitas. Quero chamar sua atenção para o próximo (dos muitos disponíveis) projetos de programação dos microcontroladores populares da série AVR.
Seria possível gastar muito texto para explicar por que isso era necessário; em vez disso, basta ver exemplos de como isso difere de outras soluções. E todas as explicações e comparações com os sistemas de programação existentes estarão, conforme necessário, no processo de análise de exemplos. A biblioteca está agora em processo de finalização, portanto a implementação de algumas funções pode não parecer ótima. Além disso, algumas das tarefas atribuídas ao programador nesta versão devem ser otimizadas ou automatizadas.
Então, vamos começar. Quero esclarecer imediatamente que o material apresentado não deve ser considerado como uma descrição completa, mas apenas como uma demonstração de alguns dos recursos da biblioteca desenvolvida, a fim de ajudar a entender o quão interessante essa abordagem pode ser para os leitores.
Não nos desviaremos da prática estabelecida e começaremos com um exemplo clássico, uma espécie de "Olá, mundo" para microcontroladores. Ou seja, pisca o LED conectado a uma das pernas do processador. Vamos abrir o VisualStudio da Microsoft (qualquer versão serve) e criar um aplicativo de console para C #. Para quem não conhece, o Community Edition, suficiente para o trabalho, é totalmente gratuito.
Na verdade, o próprio texto é o seguinte:
Exemplo 1 do código-fonteusing NanoRTOSLib; using System; namespace ConsoleApp { class Program { static void Main(string[] args) { var m = new Mega328(); m.PortB[0].Mode = ePinMode.OUT; m.PortB.Activate(); m.LOOP(m.TempL, (r, l) => m.GO(l), (r) => { m.PortB[0].Toggle();}); Console.WriteLine(AVRASM.Text(m)); } } }
Claro, para que tudo funcione e você precisa da própria biblioteca que eu represento.
Após compilar e executar o programa, na saída do console, veremos o seguinte resultado deste programa.
Resultado da compilação do exemplo 1 #include “common.inc” RESET: ldi r16, high(RAMEND) out SPH,r16 ldi r16, low(RAMEND) out SPL,r16 outi DDRB,0x1 L0000: in TempL,PORTB ldi TempH,1 eor TempL,TempH out PORTB,TempL xjmp L0000 .DSEG
Se você copiar o resultado para qualquer ambiente que saiba trabalhar com o montador do AVR e conectar a biblioteca de macro Common.inc (a biblioteca de macro também é um dos componentes do sistema de programação apresentado e funciona em conjunto com o NanoRTOSLib ), esse programa poderá ser compilado e verificado em um emulador ou em um chip real e verifique se tudo funciona.
Considere o código fonte do programa em mais detalhes. Primeiramente, atribuímos à variável m o tipo de cristal usado. Em seguida, defina o modo de saída digital para o bit zero da porta B do cristal e ative a porta. A próxima linha parece um pouco estranha, mas seu significado é bastante simples. Nele, dizemos que queremos organizar um loop infinito, no corpo do qual alteramos o valor do bit zero da porta B para o oposto. A última linha do programa realmente visualiza o resultado de tudo anteriormente escrito na forma de código assembler. Tudo é extremamente simples e compacto. E o resultado é praticamente diferente do que se poderia escrever em assembler. Pode haver apenas duas perguntas no código de saída: a primeira - por que inicializar a pilha se ainda não a usamos e que tipo de xjmp ? A resposta para a primeira pergunta e, ao mesmo tempo, uma explicação do motivo pelo qual o assembler é exibido, em vez do HEX finalizado, será o seguinte: o resultado na forma de assembler permite analisar e otimizar ainda mais o programa, permitindo que o programador selecione e modifique fragmentos de código que ele não gosta. E a inicialização da pilha foi deixada pelo menos por esses motivos: sem o uso da pilha, você pode criar muitos programas. No entanto, se você não gostar, sinta-se à vontade para limpá-lo. A saída para o montador é para essa finalidade. Quanto ao xjmp , este é um exemplo de uso de macros para aumentar a legibilidade do assembler de saída. Especificamente, xjmp é um substituto para jmp e rjmp com a substituição correta, dependendo da duração da transição.
Se você preencher o programa com um chip, é claro que não veremos o piscar do diodo, apesar do estado do pino mudar. Acontece rápido demais para ver através dos olhos. Portanto, consideramos o seguinte programa, no qual continuamos a piscar com um diodo, mas para que ele possa ser visto. Por exemplo, um atraso de 0,5 segundos é bastante adequado: nem muito rápido nem muito lento. Seria possível fazer muitos loops aninhados com NOPs para formar um atraso, mas pularemos esta etapa como não adicionando nada à descrição dos recursos da biblioteca e aproveitaremos imediatamente a oportunidade de usar o hardware disponível. Mudamos nossa aplicação da seguinte maneira.
Exemplo 2 do código-fonte using System; namespace ConsoleApp { class Program { static void Main(string[] args) { var m = new Mega328(); m.PortB[0].Mode = ePinMode.OUT; m.PortB.Activate(); m.WDT.Clock = eWDTClock.WDT500ms; m.WDT.OnTimeout = () => m.PortB[0].Toggle(); m.WDT.Activate(); m.EnableInterrupt(); var loop = AVRASM.newLabel(); m.GO(loop); Console.WriteLine(AVRASM.Text(m)); } } }
Obviamente, o programa é semelhante ao anterior, portanto, consideraremos apenas o que mudou. Primeiro, neste exemplo, usamos o WDT (watchdog timer). Para trabalhar com grandes atrasos que não exigem extrema precisão, esta é a melhor opção. Tudo o que é necessário para usá-lo é definir a frequência necessária, definindo o divisor através da propriedade WDT.Clock e determinar as ações que devem ser executadas no momento em que o evento é disparado, definindo o código através da propriedade WDT.OnTimeout. Como precisamos de interrupções para funcionar, elas devem ser ativadas com o comando EnableInterrupt. Mas o ciclo principal pode ser substituído por um boneco. Nele, ainda não planejamos fazer nada. Portanto, declararemos e definiremos um rótulo e faremos uma transição incondicional para organizar um ciclo vazio. Se você gosta mais do LOOP - por favor. O resultado disso não será alterado.
Bem, na final, vejamos o código resultante.
Resultado da compilação do exemplo 2 #include “common.inc” jmp RESET reti ; IRQ0 Handler nop reti ;IRQ1 Handler nop reti ;PC_INT0 Handler nop reti ;PC_INT1 Handler nop reti ;PC_INT2 Handler nop jmp WDT ;Watchdog Timer Handler RESET: ldi r16, high(RAMEND) out SPH,r16 ldi r16, low(RAMEND) out SPL,r16 outi DDRB,0x1 ldi TempL, (1<<WDCE) | (1<<WDE) sts WDTCSR,TempL ldi TempL, 0x42 sts WDTCSR,TempL sei L0000: xjmp L0000 WDT: push r17 push r16 in r16,SREG push r16 in TempL,PORTB ldi TempH,1 eor TempL,TempH out PORTB,TempL pop r16 out SREG,r16 pop r16 pop r17 reti .DSEG
Aqueles que estão familiarizados com esse processador, sem dúvida, terão uma pergunta para onde foram vários outros vetores de interrupção. Aqui usamos a seguinte lógica - se o código não for usado - o código não é necessário. Portanto, a tabela de interrupção termina no último vetor usado.
Apesar de o programa lidar perfeitamente com a tarefa, o mais exigente pode não gostar do fato de que o conjunto de possíveis atrasos é limitado e a etapa é muito difícil. Portanto, consideraremos outra maneira e, ao mesmo tempo, veremos como o trabalho com temporizadores é organizado na biblioteca. No cristal Mega328, que é retirado como amostra, existem até três deles. 2 de 8 bits e um de 16 bits. Os arquitetos tentaram arduamente investir o maior número possível de recursos nesses timers, portanto, sua configuração é bastante volumosa.
Primeiro, calculamos qual contador deve ser usado para o nosso atraso de 0,5 segundos. Se considerarmos a frequência do relógio de cristal de 16 MHz, mesmo com o divisor periférico máximo, é impossível manter o contador de 8 bits. Portanto, não complicaremos e usaremos o único contador Timer1 de 16 bits disponível para nós.
Como resultado, o programa assume o seguinte formato:
Exemplo de código-fonte 3 using NanoRTOSLib; using System; namespace ConsoleApp { class Program { static void Main(string[] args) {var m = new Mega328(); m.FCLK = 16000000; m.CKDIV8 = false; var bit1 = m.PortB[0]; bit1.Mode = ePinMode.OUT; m.PortB.Activate(); m.Timer1.Mode = eWaveFormMode.CTC_OCRA; m.Timer1.Clock = eTimerClockSource.CLK256; m.Timer1.OCRA = (ushort)((0.5 * m.FCLK) / 256); m.Timer1.OnCompareA = () => bit1.Toggle(); m.Timer1.Activate(); m.EnableInterrupt(); m.LOOP(m.TempH, (r, l) => m.GO(l), (r) => { }); Console.WriteLine(AVRASM.Text(m)); } } }
Como usamos o gerador principal como fonte de clock para o nosso timer, para o cálculo correto do atraso, você deve especificar a freqüência do clock do processador, a configuração do divisor e o fusível do clock periférico. O texto principal do programa está ajustando o timer no modo desejado. Aqui, um deliberador de 256 e não um máximo é escolhido deliberadamente para o relógio, pois quando você seleciona um divisor de 1024 para a freqüência de relógio necessária de 500ms, que queremos obter, um número fracionário é obtido.
O código assembler resultante do nosso programa terá a seguinte aparência:
Resultado da Compilação do Exemplo 3 #include “common.inc” jmp RESET reti ; IRQ0 Handler nop reti ;IRQ1 Handler nop reti ;PC_INT0 Handler nop reti ;PC_INT1 Handler nop reti ;PC_INT2 Handler nop reti ;Watchdog Timer Handler nop reti ;Timer2 Compare A Handler nop reti ;Timer2 Compare B Handler nop reti ;Timer2 Overflow Handler nop reti ;Timer1 Capture Handler nop jmp TIM1_COMPA ;Timer1 Compare A Handler RESET: ldi r16, high(RAMEND) out SPH,r16 ldi r16, low(RAMEND) out SPL,r16 outi DDRB,0x1 outiw OCR1A,0x7A12 outi TCCR1A,0 outi TCCR1B,0xC outi TCCR1C,0x0 outi TIMSK1,0x2 outi DDRB,0x1 sei L0000: xjmp L0000 TIM1_COMPA: push r17 push r16 in r16,SREG push r16 in TempL,PORTB ldi TempH,1 eor TempL,TempH out PORTB,TempL pop r16 out SREG,r16 pop r16 pop r17 reti .DSEG
Já não parece haver mais nada a comentar. Inicializamos os dispositivos, configuramos interrupções e aproveitamos o programa.
Trabalhar com interrupções é a maneira mais fácil de criar programas para trabalhar em tempo real. Infelizmente, nem sempre é possível alternar entre tarefas paralelas usando apenas manipuladores de interrupção para executar essas tarefas. A restrição é a proibição de manipulação de interrupções aninhadas, o que leva ao fato de que, até que o processador saia, o processador não responde a todas as outras interrupções, o que pode levar à perda de eventos se o processador executar por muito tempo.
Uma solução é separar o código de registro de eventos e seu processamento. O núcleo de processamento Parallel multithread da biblioteca é organizado de forma que, quando um evento ocorre, o manipulador de interrupção registra apenas o evento especificado e, se necessário, executa as operações mínimas necessárias de captura de dados e todo o processamento é executado no fluxo principal. O kernel verifica sequencialmente a presença de sinalizadores não processados e, se encontrado, prossegue para a tarefa correspondente.
O uso dessa abordagem simplifica o design de sistemas com várias tarefas assíncronas, permitindo que você considere cada um deles isoladamente, sem focar nos problemas de alternar recursos entre tarefas. Como exemplo, considere a implementação de duas tarefas independentes, cada uma das quais alterna sua saída com um certo atraso.
Exemplo de código-fonte 4 using NanoRTOSLib; using System; namespace ConsoleApp { class Program { static void Main(string[] args) { var m = new Mega328(); m.FCLK = 16000000; m.CKDIV8 = false; m.PortB.Direction(0x07); var bit1 = m.PortB[1]; var bit2 = m.PortB[2]; m.PortB.Activate(); var tasks = new Parallel(m, 4); tasks.Heap = new StaticHeap(tasks, 64); 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(); Console.WriteLine(AVRASM.Text(m)); } } }
Nesta tarefa, configuramos as zero e as primeiras saídas da porta B para saída e alteramos o valor de 0 para 1 e vice-versa com um período de 32 ms para zero e 48 ms para a primeira saída. Uma tarefa separada é responsável pelo gerenciamento de cada porta. A primeira coisa a observar é a definição de uma instância de Parallel. Esta classe é o núcleo do gerenciamento de tarefas. Em seu construtor, determinamos o número máximo permitido de threads em execução simultaneamente. A seguir, é apresentada uma alocação de memória para armazenar fluxos de dados. A classe StaticHeap usada no exemplo aloca um número fixo de bytes para cada fluxo. Para resolver nosso problema, isso é aceitável, e o uso de uma alocação de memória fixa comparada à dinâmica simplifica os algoritmos e torna o código mais compacto e mais rápido. Além disso, descrevemos um conjunto de tarefas projetadas para serem executadas sob o controle do kernel. Você deve prestar atenção à função assíncrona Delay, que usamos para formar um atraso. Sua peculiaridade é que, quando essa função é chamada, o atraso necessário é definido nas configurações do fluxo e o controle é transferido para o kernel. Após o intervalo definido, o kernel retorna o controle para a tarefa a partir do comando após o comando Delay. Outro recurso da tarefa é programar o comportamento do fluxo de tarefas após a conclusão no último comando da tarefa. No nosso caso, as duas tarefas são configuradas para serem executadas em um loop infinito com o controle retornando ao kernel no final de cada ciclo. Se necessário, a conclusão de uma tarefa pode liberar o encadeamento ou transmiti-lo para executar outra tarefa.
O motivo para chamar a tarefa é ativar o sinal atribuído ao fluxo de tarefas. O sinal pode ser ativado de forma programática e de hardware por interrupções de dispositivos periféricos. Uma chamada de tarefa redefine o sinal. Uma exceção é o sinal predefinido AlwaysOn, que está sempre no estado ativo. Isso torna possível criar tarefas que receberão controle em cada ciclo de pesquisa. A função LOOP é necessária para chamar o loop de execução principal. Infelizmente, o tamanho do código de saída ao usar o Parallel já está se tornando significativamente maior do que nos exemplos anteriores (aproximadamente 600 comandos) e não pode ser totalmente citado no artigo.
E para doce - algo mais como um projeto ao vivo, ou seja, um termômetro digital. Tudo é como sempre simples. Um sensor digital com uma interface SPI, um indicador de 7 segmentos e 4 dígitos e várias threads de processamento para manter a calma. Em um, dirigimos um ciclo para indicação dinâmica; em outro, eventos pelos quais um ciclo de leitura de temperatura é iniciado; no terceiro, lemos os valores recebidos do sensor e o convertemos de um código binário para BCD e, em seguida, em um código de segmento para um buffer de indicação dinâmica.
O programa em si é o seguinte.
Exemplo de código-fonte 5 using NanoRTOSLib; using System; namespace ConsoleApp { class Program { static void Main(string[] args) { var m = new Mega328(); m.FCLK = 16000000; m.CKDIV8 = false; var led7s = new Led_7(); led7s.SegPort = m.PortC; led7s.Activate(); m.PortD.Direction(0xFF); m.PortD.Activate(); m.PortB[0].Mode = ePinMode.OUT; var tc77 = new TC77(); tc77.CS = m.PortB[0]; tc77.Port = m.SPI; m.Timer0.Clock = eTimerClockSource.CLK64; m.Timer0.Mode = eWaveFormMode.Normal; var reader = m.DREG("Temperature"); var bcdRes = m.DREG("digits"); var tmp = m.BYTE(); var bcd = new BCD(reader, bcdRes); m.subroutines.Add(bcd); var os = new Parallel(m, 4); os.Heap = new StaticHeap(os, 64); var tmrSig = os.AddSignal(m.Timer0.OVF_Handler); var spiSig = os.AddSignal(m.SPI.Handler, () => { m.SPI.Read(m.TempL); m.TempL.MStore(tmp); }); var actuator = os.CreateTask((tsk) => { var loop = AVRASM.NewLabel(); tc77.ReadTemperatureAsync(); tsk.Delay(16); tsk.TaskContinue(loop); }, "actuator"); var treader = os.CreateTask((tsk) => { var loop = AVRASM.NewLabel(); tc77.ReadTemperatureCallback(os, reader, tmp); reader >>= 7; m.CALL(bcd); tsk.TaskContinue(loop); }, "reader"); var display = os.CreateTask((tsk) => { var loop = AVRASM.NewLabel(); m.PortD.Write(0xFE); m.TempQL.Load(bcdRes.Low); m.TempQL &= 0x0F; led7s.Show(m.TempQL); os.AWAIT(); m.PortD.Write(0xFD); m.TempQL.Load(bcdRes.Low); m.TempQL >>= 4; led7s.Show(m.TempQL); os.AWAIT(); m.PortD.Write(0xFB); m.TempQL.Load(bcdRes.High); m.TempQL &= 0x0F; led7s.Show(m.TempQL); os.AWAIT(); m.PortD.Write(0xF7); m.TempQL.Load(bcdRes.High); m.TempQL >>= 4; led7s.Show(m.TempQL); os.AWAIT(); tsk.TaskContinue(loop); }, "display"); var ct = os.ContinuousActivate(os.AlwaysOn, actuator); os.ActivateNext(ct, spiSig, treader); os.ActivateNext(ct, tmrSig, display); tc77.Activate(); m.Timer0.Activate(); m.EnableInterrupt(); os.Loop(); Console.WriteLine(AVRASM.Text(m)); } } }
É claro que este não é um rascunho de trabalho, mas apenas uma demonstração tecnológica projetada para demonstrar os recursos da biblioteca NanoRTOS. Mas, em qualquer caso, menos de 100 linhas de origem e menos de 1kb de código de saída são um bom resultado para um aplicativo viável.
Nos artigos a seguir, planejo, em caso de interesse neste projeto, abordar mais detalhadamente os princípios e recursos da programação usando esta biblioteca.