← Parte 4. Programación de periféricos y manejo de interrupciones
Biblioteca de generador de código ensamblador para microcontroladores AVR
Parte 5. Diseño de aplicaciones de subprocesos múltiples
En las partes anteriores del artículo, elaboramos los conceptos básicos de la programación utilizando la biblioteca. En la parte anterior, nos familiarizamos con la implementación de interrupciones y las restricciones que pueden surgir al trabajar con ellas. En esta parte de la publicación, nos detendremos en una de las posibles opciones para programar procesos paralelos utilizando la clase Paralelo . El uso de esta clase hace posible simplificar la creación de aplicaciones en las que los datos deben procesarse en varias secuencias de programas independientes.
Todos los sistemas multitarea para sistemas de un solo núcleo son similares entre sí. El subprocesamiento múltiple se implementa a través del trabajo del despachador, que asigna un intervalo de tiempo para cada subproceso, y cuando finaliza, toma el control y da el control al siguiente subproceso. La diferencia entre las diversas implementaciones está solo en los detalles, por lo que nos detendremos en más detalles principalmente en las características específicas de esta implementación.
La unidad de ejecución del proceso en el hilo es la tarea. Puede existir un número ilimitado de tareas en el sistema, pero en un momento dado solo se puede activar un cierto número de ellas, limitado por el número de flujos de trabajo en el despachador. En esta implementación, el número de flujos de trabajo se especifica en el constructor del administrador y no se puede cambiar posteriormente. En el proceso, los hilos pueden realizar tareas o permanecer libres. A diferencia de otras soluciones, Parallel Manager no cambia las tareas. Para que la tarea devuelva el control al despachador, se deben insertar comandos apropiados en su código. Por lo tanto, la responsabilidad de la duración del intervalo de tiempo en la tarea recae en el programador, que debe insertar comandos de interrupción en ciertos lugares del código si la tarea lleva demasiado tiempo, así como determinar el comportamiento del hilo al completar la tarea. La ventaja de este enfoque es que el programador controla los puntos de conmutación entre tareas, lo que le permite optimizar significativamente el código de guardar / restaurar al cambiar de tarea, así como deshacerse de la mayoría de los problemas relacionados con el acceso a datos seguro para subprocesos.
Para controlar la ejecución de las tareas en ejecución, se utiliza una clase especial de señal . La señal es una variable de bit, cuya configuración se utiliza como señal de habilitación para iniciar una tarea en una secuencia. Los valores de la señal pueden establecerse manualmente o mediante un evento asociado con esta señal.
La señal se restablece cuando el despachador activa la tarea o se puede realizar mediante programación.
Las tareas en el sistema pueden estar en los siguientes estados:
Desactivado : estado inicial para todas las tareas. La tarea no ocupa el flujo y el control de ejecución no se transfiere. El retorno a este estado para las tareas activadas se produce al completar el comando.
Activado : el estado en el que se encuentra la tarea después de la activación. El proceso de activación asocia una tarea con un hilo de ejecución y una señal de activación. El administrador sondea los hilos y comienza la tarea si la señal de la tarea está activada.
Bloqueado : cuando se activa una tarea, se le puede asignar una señal como señal, que ya se utiliza para controlar otro subproceso. En este caso, para evitar la ambigüedad del comportamiento del programa, la tarea activada pasa al estado bloqueado. En este estado, la tarea ocupa el hilo, pero no puede recibir el control, incluso si su señal está activada. Al finalizar las tareas o al cambiar la señal de activación, el despachador verifica y cambia el estado de las tareas en los hilos. Si los hilos han bloqueado tareas para las cuales la señal coincide con la liberada, se activa el primero encontrado. Si es necesario, el programador puede bloquear y desbloquear tareas independientemente, en función de la lógica requerida del programa.
En espera : el estado en el que se encuentra la tarea después de ejecutar el comando Delay . En este estado, la tarea no recibe control hasta que haya transcurrido el intervalo requerido. En la clase paralela , se utilizan interrupciones WDT de 16 ms para controlar el retraso, lo que permite no ocupar temporizadores para las necesidades del sistema. En caso de que necesite más estabilidad o resolución en pequeños intervalos, en lugar de Retardo, puede usar la activación por señales de temporizador. Debe tenerse en cuenta que la precisión del retraso seguirá siendo baja y fluctuará en el rango de "tiempo de respuesta del despachador" - "duración máxima de intervalo de tiempo en el sistema + tiempo de respuesta del despachador" . Para tareas con rangos de tiempo exactos, se debe usar un modo híbrido, en el que el temporizador que no se usa en la clase Paralela funciona independientemente del flujo de la tarea y procesa los intervalos en el modo de interrupción pura.
Cada tarea ejecutada en un hilo es un proceso aislado. Esto requiere la definición de dos tipos de datos: datos locales de una secuencia, que deberían ser visibles y cambiados solo dentro del marco de esta secuencia, y datos globales para el intercambio entre flujos y acceso a recursos compartidos. En el marco de esta implementación, los datos globales se crean mediante comandos previamente considerados en el nivel del dispositivo. Para crear variables de tareas locales, deben crearse utilizando métodos de la clase de tarea. El comportamiento de la variable de tarea local es el siguiente: cuando la tarea se interrumpe antes de transferir el control al despachador, todas las variables de registro local se almacenan en la memoria de la secuencia. Cuando se devuelve el control, las variables de registro local se restauran antes de ejecutar el siguiente comando.
Una clase con la interfaz IHeap asociada con la propiedad Heap de la clase Parallel es responsable de almacenar los datos locales de la secuencia. La implementación más simple de esta clase es StaticHeap , que implementa la asignación estática de los mismos bloques de memoria para cada subproceso. En caso de que las tareas tengan una gran extensión de acuerdo con la demanda de la cantidad de datos locales, puede usar DynamicHeap , que le permite determinar el tamaño de la memoria local individualmente para cada tarea. Obviamente, la sobrecarga de trabajar con memoria de flujo en este caso será significativamente mayor.
Ahora echemos un vistazo más de cerca a la sintaxis de la clase usando dos flujos como ejemplo, cada uno de los cuales cambia independientemente una salida de puerto 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();
Las líneas superiores del programa ya te son familiares. En ellos, determinamos el tipo de controlador y asignamos el primer y segundo bits del puerto B como salida. Luego viene la inicialización de una variable de la clase Paralelo , donde en el segundo parámetro determinamos el número máximo de hilos de ejecución. En la siguiente línea, asignamos memoria para acomodar flujos de variables locales. Tenemos tareas iguales, por lo que utilizamos StaticHeap . El siguiente bloque de código es la definición de tarea. En él, definimos dos tareas casi idénticas. La única diferencia es el puerto de control y la cantidad de retraso. Para trabajar con objetos de tareas locales, se pasa un puntero a la tarea local tsk al bloque de código de la tarea. El texto de la tarea en sí es muy simple:
- Se crea una etiqueta local para organizar un ciclo de cambio infinito
- el estado del puerto se invierte
- el control se devuelve al despachador y la tarea pasa al estado de espera durante el número especificado de milisegundos
- El puntero de retorno se establece en el bloque de inicio del bloque y el control se devuelve al despachador.
Obviamente, en un ejemplo concreto, el último comando podría reemplazarse por un comando normal para ir al comienzo del bloque y proporcionarse en el ejemplo solo con el propósito de demostrarlo. Si lo desea, el ejemplo se puede ampliar fácilmente para controlar una gran cantidad de conclusiones, copiando tareas y aumentando el número de hilos.
Una lista completa de comandos de aborto de tareas para transferir el control al despachador es la siguiente
ESPERA (señal) : el flujo guarda todas las variables en la memoria del flujo y transfiere el control al despachador. La próxima vez que se active la secuencia, las variables se restauran y la ejecución continúa, comenzando con la siguiente instrucción después de AWAIT . El comando está diseñado para dividir la tarea en intervalos de tiempo e implementar la máquina de estado de acuerdo con el esquema Señal → Procesamiento 1 → Señal → Procesamiento 2 , etc.
El comando AWAIT puede tener una señal como parámetro opcional. Si el parámetro está vacío, se guarda la señal de activación. Si se especifica en el parámetro, todas las llamadas de tareas posteriores se realizarán cuando se active la señal especificada y se pierda la comunicación con la señal anterior.
TaskContinue (etiqueta, señal) : el comando finaliza la secuencia y le da el control al despachador sin guardar variables. La próxima vez que se active la transmisión, el control se transfiere a la etiqueta . El parámetro de señal opcional le permite anular la señal de activación de flujo para la próxima llamada. Si no se especifica, la señal permanece igual. Un comando sin especificar una señal se puede usar para organizar ciclos dentro de una sola tarea, donde cada ciclo se realiza en un intervalo de tiempo separado. También se puede usar para asignar una nueva tarea al hilo actual después de completar el anterior. La ventaja de este enfoque en comparación con el ciclo Liberar un hilo → Destacar un flujo es un programa más eficiente. El uso de TaskContinue elimina la necesidad de que el administrador busque un subproceso libre en el grupo y garantiza errores al intentar asignar subprocesos en ausencia de subprocesos libres.
TaskEnd () : borra la secuencia una vez que se completa la tarea. La tarea finaliza, el hilo se libera y se puede utilizar para asignar una nueva tarea con el comando Activar .
Retraso (ms) : el flujo, como en el caso de usar AWAIT , guarda todas las variables en la memoria del flujo y transfiere el control al despachador. En este caso, el valor de retraso en milisegundos se registra en el encabezado de la secuencia. En el bucle despachador, en el caso de un valor distinto de cero en el campo de retraso, el flujo no se activa. El cambio de los valores en el campo de retraso para todos los flujos se realiza interrumpiendo el temporizador WDT cada 16 ms. Cuando se alcanza el valor cero, se elimina la prohibición de ejecución y se establece la señal de activación del flujo. Solo se almacena un valor de un solo byte para el retraso en el encabezado, lo que proporciona un rango relativamente estrecho de posibles retrasos, por lo tanto, para implementar retrasos más largos, Delay () crea un bucle interno utilizando variables de flujo local.
La activación de los comandos en el ejemplo se realiza utilizando los comandos ContinuousActivate y ActivateNext . Este es un tipo especial de activación de tarea inicial al inicio. En la etapa de activación inicial, tenemos la garantía de no tener un solo subproceso ocupado, por lo que el proceso de activación no requiere una búsqueda preliminar de un subproceso libre para una tarea y le permite activar tareas en secuencia. ContinuousActivate activa la tarea en el subproceso cero y devuelve un puntero al encabezado del siguiente subproceso, y la función ActivateNext usa este puntero para activar las siguientes tareas en subprocesos secuenciales.
Como señal de activación, el ejemplo usa la señal AlwaysOn . Esta es una de las señales del sistema. Su propósito significa que la tarea siempre se ejecutará, ya que esta es la única señal que siempre se activa y no se restablece con el uso.
El ejemplo termina con una llamada de bucle . Esta función inicia el ciclo del despachador, por lo que este comando debería ser el último en el código.
Considere otro ejemplo donde el uso de la biblioteca puede simplificar significativamente la estructura del código. Sea un dispositivo de control condicional que registre una señal analógica y la envíe en forma de código HEX al 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();
Esto no quiere decir que vimos muchas cosas nuevas aquí, pero puedes ver algo interesante en este código.
En este ejemplo, ADC (convertidor analógico a digital) se menciona por primera vez. Este dispositivo periférico está diseñado para convertir el voltaje de la señal de entrada en un código digital. La función ConvertAsync inicia el ciclo de conversión, que solo inicia el proceso sin esperar el resultado. Cuando se completa la conversión, el ADC genera una interrupción que activa la señal adcSig . Preste atención a la definición de la señal adcSig . Además del puntero de interrupción, también contiene un bloque de código para almacenar valores del registro de datos ADC. Todo el código que se ejecute preferiblemente inmediatamente después de que ocurra una interrupción (por ejemplo, leer datos de registros de dispositivos) debe ubicarse en este lugar.
La tarea de conversión es convertir un código de voltaje binario en una representación HEX de cuatro caracteres para nuestro terminal condicional. Aquí podemos observar el uso de funciones para describir fragmentos repetidos para reducir el tamaño del código fuente y el uso de una cadena constante para la conversión de datos.
El problema de transmisión es interesante desde el punto de vista de implementar la salida formateada de una cadena en la que se combina la salida de datos estáticos y dinámicos. El mecanismo en sí no puede considerarse ideal; más bien, es una demostración de las posibilidades para administrar manejadores. Aquí también puede prestar atención a la redefinición de la señal de activación durante la ejecución, que cambia la señal de activación de ConvS a TxS y viceversa.
Para una mejor comprensión, describimos en palabras el algoritmo del programa.
En el estado inicial, hemos lanzado tres tareas. Dos de ellos tienen señales inactivas, ya que la señal para la tarea de conversión (adcSig) se activa al final del ciclo de lectura de la señal analógica, y ConvS para la tarea de transmisión se activa mediante un código que aún no se ha ejecutado. Como resultado, la primera tarea que se lanzará después del lanzamiento siempre será la medición. El código para esta tarea inicia el ciclo de conversión de ADC, después del cual la tarea de 500 ms pasa al ciclo de espera. Al final del ciclo de conversión, se activa el indicador adcSig , que desencadena la tarea de conversión . En esta tarea, se implementa un ciclo de conversión de los datos recibidos en una cadena. Antes de salir de la tarea, activamos el indicador ConvS , dejando en claro que tenemos nuevos datos para enviar al terminal. El comando de salida restablece el punto de retorno al comienzo de la tarea y le da control al despachador. El conjunto de banderas ConvS permite transferir el control a la tarea de transmisión . Después de transmitir el primer byte de la secuencia, la señal de activación en la tarea cambia a TxS . Como resultado de esto, una vez completada la transferencia del byte, se volverá a llamar a la tarea de transmisión, lo que conducirá a la transferencia del siguiente byte. Después de que se transmite el último byte de la secuencia, la tarea devuelve la señal de activación de ConvS y restablece el punto de retorno al comienzo de la tarea. El ciclo se completa. El siguiente ciclo comenzará cuando la tarea de medición complete la espera y active el siguiente ciclo de medición.
En casi todos los sistemas multitarea, existe el concepto de colas para la interacción entre hilos. Ya hemos descubierto que dado que cambiar entre tareas en este sistema es un proceso completamente controlado, es muy posible usar variables globales para intercambiar datos entre tareas. Sin embargo, hay una serie de tareas en las que se justifica el uso de colas. Por lo tanto, no dejaremos de lado este tema y veremos cómo se implementa en la biblioteca.
Para implementar una cola en un programa, es mejor usar la clase RingBuff . La clase, como su nombre lo indica, implementa un buffer de anillo con comandos de escritura y recuperación. La lectura y escritura de datos se realiza mediante los comandos de lectura y escritura . Los comandos de lectura y escritura no tienen parámetros. El búfer utiliza la variable de registro especificada en el constructor como fuente / receptor de datos. El acceso a esta variable se realiza a través del parámetro IOReg class. El estado del búfer está determinado por los dos indicadores Ovf y Empty , que ayudan a determinar el estado de desbordamiento durante la escritura y el desbordamiento durante la lectura. Además, la clase tiene la capacidad de determinar el código que se ejecuta en eventos de desbordamiento / desbordamiento. RingBuff no tiene dependencias en la clase Paralelo y se puede usar por separado. La limitación cuando se trabaja con la clase es la capacidad permitida, que debería ser un múltiplo de la potencia de dos (8.16.32, etc.) por razones de optimización del código.
A continuación se muestra un ejemplo de trabajo con la clase.
var m = new Mega328(); var io = m.REG();
Esta parte concluye la descripción general de las características de la biblioteca. Desafortunadamente, quedaban varios aspectos relacionados con las capacidades de la biblioteca, que ni siquiera se mencionaron. En el futuro, en caso de interés en el proyecto, se planean artículos dedicados a resolver problemas específicos utilizando la biblioteca y una descripción más detallada de problemas complejos que requieren una publicación por separado.