Assembler code generator library for AVR microcontrollers. Part 5

← Part 4. Programming peripherals and handling interrupts







Assembler Code Generator Library for AVR Microcontrollers



Part 5. Designing multi-threaded applications



In the previous parts of the article, we elaborated on the basics of programming using the library. In the previous part, we got acquainted with the implementation of interrupts and the restrictions that may arise when working with them. In this part of the post, we will dwell on one of the possible options for programming parallel processes using the Parallel class. The use of this class makes it possible to simplify the creation of applications in which data must be processed in several independent program flows.







All multi-tasking systems for single-core systems are similar to each other. Multithreading in them is implemented due to the work of the dispatcher, who allocates a time slot for each thread, and when it ends, it takes control and gives control to the next thread. The difference between the various implementations is only in the details, so we will dwell in more detail mainly on the specific features of this implementation.







The unit of process execution in the thread is the task. An unlimited number of tasks can exist in the system, but at any given moment only a certain number of them can be activated, limited by the number of workflows in the dispatcher. In this implementation, the number of workflows is set in the dispatcher constructor and cannot be subsequently changed. In the process, threads can do tasks or remain free. Unlike other solutions, Parallel Manager does not switch tasks. For the task to return control to the dispatcher, appropriate commands must be inserted in its code. Thus, the responsibility for the duration of the time slot in the task rests with the programmer, who must insert interrupt commands in certain places in the code if the task takes too long, and also determine the behavior of the thread upon completion of the task. The advantage of this approach is that the programmer controls the switching points between tasks, which allows you to significantly optimize the save / restore code when switching tasks, as well as get rid of most of the problems related to thread-safe data access.







To control the execution of running tasks, a special Signal class is used. The signal is a bit variable, the setting of which is used as an enable signal for starting a task in a stream. Signal values ​​can be set either manually or by an event associated with this signal.







The signal is reset when the task is activated by the dispatcher or can be performed programmatically.







Tasks in the system can be in the following states:







Deactivated - initial state for all tasks. The task does not occupy the thread and control for execution is not transferred. Return to this state for activated tasks occurs upon completion command.







Activated - the state in which the task is located after activation. The activation process associates a task with a thread of execution and an activation signal. The manager polls the threads and starts the task if the task signal is activated.







Blocked - when a task is activated, a signal can be assigned to it as a signal, which is already used to control another thread. In this case, in order to avoid the ambiguity of the program’s behavior, the activated task goes into the locked state. In this state, the task occupies the thread, but cannot receive control, even if its signal is activated. Upon completion of tasks or when changing the activation signal, the dispatcher checks and changes the status of tasks in the threads. If the threads have blocked tasks for which the signal matches the released one, the first one found is activated. If necessary, the programmer can independently lock and unlock tasks, based on the required logic of the program.







Waiting - the state the task is in after executing the Delay command. In this state, the task does not receive control until the required interval has elapsed. In the Parallel class, 16 ms WDT interrupts are used to control the delay, which allows not to occupy timers for system needs. In case you need more stability or resolution at small intervals, instead of Delay, you can use activation by timer signals. It should be borne in mind that the delay accuracy will still be low and will fluctuate in the range of “dispatcher response time” - “the maximum time slot in the system + dispatcher response time” . For tasks with exact time ranges, you should use the hybrid mode, in which the timer, which is not used in the Parallel class, works independently of the task flow and processes intervals in the pure interrupt mode.







Each task executed in a thread is an isolated process. This necessitates the definition of two types of data: local data of a stream, which should be visible and changed only within the framework of this stream, and global data for exchange between flows and access to shared resources. In the framework of this implementation, global data is created by previously considered commands at the device level. To create local task variables, they must be created using methods from the task class. The behavior of a local task variable is as follows: when a task is interrupted before transferring control to the dispatcher, all local register variables are stored in the stream's memory. When control is returned, the local register variables are restored before the next command is executed.

A class with the IHeap interface associated with the Heap property of the Parallel class is responsible for storing local data of the stream. The simplest implementation of this class is StaticHeap , which implements the static allocation of the same memory blocks for each thread. In case the tasks have a large spread on the demand for the amount of local data, you can use DynamicHeap , which allows you to determine the size of local memory individually for each task. Obviously, the overhead of working with stream memory in this case will be significantly higher.







Now let’s take a closer look at the class syntax using two streams as an example, each of which independently switches a separate port output.







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();
      
      





The top lines of the program are already familiar to you. In them, we determine the type of controller and assign the first and second bits of port B as the output. This is followed by the initialization of a variable of the Parallel class, where in the second parameter we determine the maximum number of execution threads. In the next line, we allocate memory to accommodate local variable flows. We have equal tasks, so we use StaticHeap . The next block of code is task definition. In it, we define two almost identical tasks. The only difference is the control port and the amount of delay. To work with local task objects, a pointer to the local task tsk is passed to the task code block . The task text itself is very simple:









The full list of task abort commands to transfer control to the dispatcher is as follows

AWAIT (signal) - the stream saves all the variables in the stream's memory and transfers control to the dispatcher. The next time the stream is activated, the variables are restored and execution continues, starting with the next instruction after AWAIT . The command is designed to divide the task into time slots and to implement the state machine according to the scheme Signal → Processing 1 → Signal → Processing 2 , etc.







The AWAIT command may have a signal as an optional parameter. If the parameter is empty, the activation signal is saved. If it is specified in the parameter, then all subsequent task calls will be made when the specified signal is activated, and communication with the previous signal is lost.







TaskContinue (label, signal) - the command terminates the stream and gives control to the dispatcher without saving variables. The next time the stream is activated, control is transferred to the label label . The optional Signal parameter allows you to override the stream activation signal for the next call. If not specified, the signal remains the same. A command without specifying a signal can be used to organize cycles within a single task, where each cycle is performed in a separate time slot. It can also be used to assign a new task to the current thread after completing the previous one. The advantage of this approach compared to the cycle Freeing up a thread → Highlighting a stream is a more efficient program. Using TaskContinue eliminates the need for the manager to search for a free thread in the pool and guarantees errors when trying to allocate threads in the absence of free threads.







TaskEnd () - clear the stream after the task is completed. The task ends, the thread is freed, and can be used to assign a new task with the Activate command.







Delay (ms) - the stream, as in the case of using AWAIT , saves all the variables in the stream's memory and transfers control to the dispatcher. In this case, the delay value in milliseconds is recorded in the stream header. In the dispatcher loop, in the case of a non-zero value in the delay field, the flow is not activated. Changing the values ​​in the delay field for all flows is performed by interrupting the WDT timer every 16 ms. When the zero value is reached, the execution ban is removed and the stream activation signal is set. Only a single-byte value for the delay is stored in the header, which gives a relatively narrow range of possible delays, therefore, to implement longer delays, Delay () creates an internal loop using local stream variables.

The activation of the commands in the example is performed using the ContinuousActivate and ActivateNext commands. This is a special kind of initial task activation at startup. At the initial activation stage, we are guaranteed not to have a single busy thread, so the activation process does not require a preliminary search for a free thread for a task and allows you to activate tasks in sequence. ContinuousActivate activates the task in the zero thread and returns a pointer to the header of the next thread, and the ActivateNext function uses this pointer to activate the following tasks in sequential threads.







As an activation signal, the example uses the AlwaysOn signal. This is one of the system signals. Its purpose means that the task will always be executed, as this is the only signal that is always activated and is not reset by use.







The example ends with a Loop call. This function starts the dispatcher’s cycle, so this command should be the last in the code.







Consider another example where using the library can greatly simplify the structure of the code. Let it be a conditional control device that registers an analog signal and sends it in the form of a HEX code to the terminal.







  var m = new Mega328(); var cData = m.WORD(); 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.ADC.Activate(); m.Usart.Activate(); var tasks = new Parallel(m, 4); tasks.Heap = new StaticHeap(tasks, 16); var adcSig = tasks.AddSignal(m.ADC.Handler, ()=> { m.ADC.Data(m.Temp); m.Temp.Store(cData); }); var TxS = tasks.AddSignal(m.Usart.TXC_Handler); var ConvS = tasks.AddLocker(); tasks.PrepareSignals(); var measurment = tasks.CreateTask((tsk) => { m.LOOP(m.TempL, (e, l) => m.GO(l), (e, l) => { m.ADC.ConvertAsync(); tsk.Delay(500); }); }); var conversion = tasks.CreateTask((tsk) => { var loop = AVRASM.NewLabel(); var romptr = m.ROMPTR(); void ConvertHigh(int ofs, int outp) { romptr.Load(chex); m.TempL.MLoad(cData, ofs); m.TempL >>= 4; romptr += m.TempL; romptr.MLoad(m.TempL); m.TempL.MStore(outDigit[outp]); } void ConvertLow(int ofs, int outp) { romptr.Load(chex); m.TempL.MLoad(cData, ofs); m.TempL &= 0x0F; romptr += m.TempL; romptr.MLoad(m.TempL); m.TempL.MStore(outDigit[outp]); } ConvertHigh(1, 0); ConvertLow (1, 1); ConvertHigh(0, 2); ConvertLow (0, 3); romptr.Dispose(); ConvS.Set(); tsk.TaskContinue(loop); }); var transmission = tasks.CreateTask((tsk) => { var loop = AVRASM.NewLabel(); void TransmitChar(char c) { m.TempL.Load(c); m.Usart.Transmit(m.TempL); tasks.AWAIT(); } void TransmitData(int i) { m.TempL.MLoad(outDigit[i]); m.Usart.Transmit(m.TempL); tasks.AWAIT(); } m.TempL.Load('0'); m.Usart.Transmit(m.TempL); tasks.AWAIT(TxS); TransmitChar('x'); TransmitData(0); TransmitData(1); TransmitData(2); TransmitData(3); TransmitChar(Convert.ToChar(13)); TransmitChar(Convert.ToChar(10)); m.Usart.Transmit(m.TempL); tsk.TaskContinue(loop, ConvS); }); var ptr = tasks.ContinuousActivate(tasks.AlwaysOn, measurment); tasks.ActivateNext(ptr, adcSig, conversion); tasks.ActivateNext(ptr, ConvS, transmission); m.EnableInterrupt(); tasks.Loop();
      
      





This is not to say that we saw a lot of new things here, but you can see something interesting in this code.







In this example, ADC (A / D Converter) is first mentioned. This peripheral device is designed to convert the voltage of the input signal into a digital code. The conversion cycle is started by the ConvertAsync function, which only starts the process without waiting for the result. When the conversion is complete, the ADC generates an interrupt that activates the adcSig signal. Pay attention to the definition of the adcSig signal. In addition to the interrupt pointer, it also contains a code block for storing values ​​from the ADC data register. All code that is preferably executed immediately after an interrupt occurs (for example, reading data from device registers) should be located in this place.

The conversion task is to convert a binary voltage code into a four-character HEX representation for our conditional terminal. Here we can note the use of functions for describing repeating fragments to reduce the size of the source code and the use of a constant string for data conversion.







The transmission problem is interesting from the point of view of the implementation of the formatted output of a string in which the output of static and dynamic data is combined. The mechanism itself cannot be considered ideal; rather, it is a demonstration of the possibilities for managing handlers. Here you can also pay attention to the redefinition of the activation signal during execution, which changes the activation signal from ConvS to TxS and vice versa.







For a better understanding, we will describe in words the algorithm of the program.







In the initial state, we have launched three tasks. Two of them have inactive signals, since the signal for the conversion task (adcSig) is activated at the end of the reading cycle of the analog signal, and ConvS for the transmission task is activated by a code that has not yet been executed. As a result, the first task to be launched after launch will always be measurment. The code for this task starts the ADC conversion cycle, after which the 500 ms task goes into the wait cycle. At the end of the conversion cycle, the adcSig flag is activated , which triggers the conversion task. In this task, a cycle of converting received data to a string is implemented. Before exiting the task, we activate the ConvS flag, making it clear that we have new data to send to the terminal. The exit command resets the return point to the beginning of the task and gives control to the dispatcher. The ConvS flag set allows transferring control to the transmission task. After transmitting the first byte of the sequence, the activation signal in the task changes to TxS . As a result of this, after the transfer of the byte is completed, the transmission task will be called again, which will lead to the transfer of the next byte. After transmitting the last byte of the sequence, the task returns the ConvS activation signal and resets the return point to the beginning of the task. The cycle is completed. The next cycle will begin when the measurment task completes the wait and activates the next measurement cycle.







In almost all multitasking systems, there is the concept of queues for the interaction between threads. We have already figured out that since switching between tasks in this system is a completely controlled process, using global variables to exchange data between tasks is quite possible. However, there are a number of tasks where the use of queues is justified. Therefore, we will not leave aside this topic and see how it is implemented in the library.







It is best to use the RingBuff class to implement a queue in a program. The class, as the name implies, implements a ring buffer with write and fetch commands. Reading and writing data is performed by the Read and Write commands. Read and write commands have no parameters. The buffer uses the register variable specified in the constructor as the data source / receiver. Access to this variable is made through the parameter IOReg class. The status of the buffer is determined by the two flags Ovf and Empty , which help determine the state of overflow when writing and overflow when reading. In addition, the class has the ability to determine the code that runs on overflow / overflow events. RingBuff has no dependencies on the Parallel class and can be used separately. The limitation when working with the class is the allowable capacity, which should be a multiple of the power of two (8.16.32, etc.) for reasons of code optimization.







An example of working with the class is given below.







  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 //    });
      
      





This part concludes the overview of library features. Unfortunately, there remained a number of aspects regarding the capabilities of the library, which were not even mentioned. In the future, in case of interest in the project, articles are planned on solving specific problems using the library and a more detailed description of complex issues that require a separate publication.








All Articles