← Part 3. Indirect addressing and flow control
Part 5. Designing multi-threaded applications. →
In this part of the post, we, as promised, will deal with one of the most popular aspects of microcontroller programming - namely, working with peripheral devices. There are two most common approaches to peripheral programming. First, the programming system does not know anything about peripheral devices and provides only means of access to device control ports. This approach is practically no different from working with devices at the assembler level and requires a thorough study of the purpose of all ports associated with the operation of a specific peripheral device. To facilitate the work of programmers, there are special programs, but their help, as a rule, ends with the generation of a sequence of initial device initialization. The advantage of this approach is full access to all peripheral capabilities, and the disadvantage is the complexity of programming and the large amount of program code.
The second - work with peripheral devices is carried out at the level of virtual devices. The main advantage of this approach is the simplicity of device management and the ability to work with them without delving into the particular hardware implementation. The disadvantage of this approach is the limitation of the capabilities of peripheral devices with the purpose and functions of the emulated virtual device.
The NanoRTOS library implements a third approach. Each peripheral device is described by a specialized class, the purpose of which is to simplify the setup and operation of the device while maintaining its full functionality. It is better to demonstrate the features of this approach using examples, so let's get started.
Let's start with the simplest and most common peripheral device - a digital input / output port. This port combines up to 8 channels, each of which can be configured independently for input or output. Clarification to 8 means that the controller architecture implies the possibility of assigning alternative functions for individual port bits, which excludes their use as water / output ports, thereby reducing the number of bits available. Configuration and further work can be carried out both at the level of a single bit, and at the port level as a whole (writing and reading all 8 bits with one command). The Mega328 controller used in the examples has 3 ports: B, C, and D. In the initial state, from the library point of view, the discharges of all ports are neutral. This means that for their activation it is necessary to indicate the mode of their use. In case of an attempt to access an unactivated port, the program will generate a compilation error. This is done in order to eliminate possible conflicts when assigning alternative functions. To switch ports to the input / output mode, use the Mode commands to set the single-bit mode, and Direction to set the mode of all port bits with one command. From a programming point of view, all ports are the same and their behavior is described by one class.
var m = new Mega328(); m.PortB[0].Mode = ePinMode.OUT;// 0 B m.PortC.Direction(0xFF);// m.PortB.Activate(); // m.PortC.Activate(); // C // m.PortB[0].Set(); // 0 B 1 m.PortB[0].Clear();// 0 B 0 m.PortB[0].Toggle();// 0 B m.PortC.Write(0b11000000);// 6 7 var rr = m.REG(); // rr.Load(0xC0); m.PortC.Write(rr);// rr var t = AVRASM.Text(m);
The example above demonstrates how data output through ports can be organized. Work with port B here is carried out at the level of one category, and with port C at the level of the port, as a whole. Pay attention to the Activate () activation command. Its purpose is to generate in the output code a sequence of device initialization commands in accordance with the previously set properties. Thus, the Activate () command always uses the set of set parameters that is current at the time of execution. Consider an example of reading data from a port.
m.PortB.Activate(); // B m.PortC.Activate(); // C Bit dd = m.BIT(); // Register rr = m.REG(); // m.PortB[0].Read(dd); // 0 B m.PortC.Read(rr);// rr var t = AVRASM.Text(m);
In this example, a new Bit data type has appeared. The closest analogue of this type in high-level languages is the bool type. The Bit data type is used to store only one bit of information and allows its value to be used as a condition in branching operations. In order to save memory, bit variables during storage are combined into blocks in such a way that one RON register is used to store 8 variables of type Bit . In addition to the described type, the library contains two more bit data types: Pin , which has the same functionality as Bit, but uses IO and Mbit registers for storing bit variables in RAM memory. Let's see how you can use bit variables to organize branches
m.IF(m.PortB[0], () => AVRASM.Comment(", = 1")); var b = m.BIT(); b.Set(); m.IF(b, () => AVRASM.Comment(", b "));
The first line checks the status of the input port, and if at input 1, the code of the conditional block is executed. The last line contains an example where a variable of type Bit is used as a branching condition.
The next common and often used peripheral device can be considered a hardware counter / timer. In AVR microcontrollers, this device has a wide range of functions and, depending on the setting, can be used to generate a delay, generate a meander with a programmable frequency, measure the frequency of an external signal, and also as a multimode PWM modulator. Unlike I / O ports, each of the Mega328 timers has a unique set of features. Therefore, each timer is described by a separate class.
Let's consider them in more detail. As the signal source of each timer, both an external signal and the internal clock of the processor can be used. The hardware settings of the microcontroller allow you to configure the use of either the full frequency for peripheral devices, or turn on the single splitter for all peripheral devices by 8. Since the microcontroller allows operation in a wide frequency range, the correct calculation of the timer divider values for the required delay during internal clocking requires specifying the processor frequency and prescaler mode. Thus, the timer settings section takes the following form
var m = new Mega328(); m.FCLK = 16000000; // m.CKDIV8 = false; // // Timer1 m.Timer1.Clock = eTimerClockSource.CLK256; // m.Timer1.OCRA = (ushort)((0.5 * m.FCLK) / 256); // A m.Timer1.Mode = eWaveFormMode.CTC_OCRA; // m.Timer1.Activate(); // Timer1
Obviously, setting the timer requires studying the manufacturer’s documentation to select the correct mode and understanding the purpose of the various settings, but using the library makes working with the device easier and more understandable, while retaining the ability to use all device modes.
Now I suggest a little distraction from the description of the use of specific devices and before continuing, discuss the problem of asynchronous operation. The main advantage of peripheral devices is that they are able to perform certain functions without using CPU resources. Complexity can arise in the organization of the interaction between the program and the device, since the events that occur during the operation of the peripheral device are asynchronous with respect to the code execution flow in the CPU. Synchronous interaction methods, in which the program contains cycles for waiting for the required device status, nullify almost all the advantages of peripherals as independent devices. More efficient and preferred is the interrupt mode. In this mode, the processor continuously executes the code of the main thread, and at the time the event occurs, switches the execution thread to its handler. At the end of processing, control returns to the main thread. The merits of this approach are obvious, but its use may be complicated by the complexity of the setup. In assembler, to use an interrupt, you must:
To simplify the programming of work through interrupts, the library peripheral device description classes contain the properties of an event handler. Moreover, to organize work with a peripheral device through interrupts, you only need to describe the code for processing the required event, and the library will perform all other settings on its own. Let us return to the timer setting and supplement it with the definition of a code that must be executed when the thresholds for comparing timer comparison channels are reached. Suppose we want that when the thresholds of the comparison channels are triggered, certain bits of the I / O ports are reset when the overflow occurs. In other words, we want to implement a timer function to generate a PWM signal on selected arbitrary ports with a duty cycle determined by the OCRA values for the first and OCRB for the second channel. Let's see how the code will look in this case.
var m = new Mega328(); m.FCLK = 16000000; m.CKDIV8 = false; var bit1 = m.PortB[0]; bit1.Mode = ePinMode.OUT; var bit2 = m.PortB[1]; bit2.Mode = ePinMode.OUT; m.PortB.Activate(); // 0 1 B // m.Timer0.Clock = eTimerClockSource.CLK; m.Timer0.OCRA = 50; m.Timer0.OCRB = 170; m.Timer0.Mode = eWaveFormMode.PWMPC_TOP8; // m.Timer0.OnCompareA = () => bit1.Set(); m.Timer0.OnCompareB = () =>bit2.Set(); m.Timer0.OnOverflow = () => m.PortB.Write(0); m.Timer0.Activate(); m.EnableInterrupt(); // // m.LOOP(m.TempH, (r, l) => m.GO(l), (r) => { });
The part concerning the setting of timer modes has been considered earlier, so let's move on to the interrupt handlers right away. In the example, three handlers are used to implement two PWM channels using one timer. The code of the handlers is quite obvious, but the question may arise how the previously mentioned state saving is implemented so that the interrupt call does not affect the logic of the main thread. The solution option, in which all registers and flags are saved, looks obviously redundant, therefore the library analyzes the use of resources in the interrupt and saves only the necessary minimum. The empty main loop confirms the idea that the task of continuously generating several PWM signals works without the participation of the main program.
It should be noted that the library implements a unified approach to working with interrupts for all peripheral device description classes. This simplifies programming and reduces errors.
We continue to study the work with interrupts and consider a situation in which clicking on the buttons connected to the input ports should cause certain actions on the part of the program. In the processor we are considering, there are two ways to generate interrupts when the state of the input ports changes. The most advanced is the use of external interrupt mode. In this case, we are able to generate separate interrupts for each of the conclusions and configure the reaction only to a specific event (front, recession, level). Unfortunately, there are only two of them in our crystal. Another method allows you to control by means of interrupts any of the bits of the input port, but the processing is more complicated due to the fact that the event occurs at the port level when the input signal of any of the configured bits changes, and further clarification of the cause of the interrupt should be performed at the algorithm level by software .
As an illustration, we will try to solve the problem of controlling the state of the port output using two buttons. One of them should set the value of the port indicated by us to 1, and the other reset. Since there are only two buttons, we will use the opportunity to use external interrupts.
var m = new Mega328(); m.PortD[0].Mode = ePinMode.OUT; m.PortD.Write(0x0C); // pull-up m.INT0.Mode = eExtIntMode.Falling; // INT0 . m.INT0.OnChange = () => m.PortD[0].Set(); // 1 m.INT1.Mode = eExtIntMode.Falling; // INT1 . m.INT1.OnChange = () => m.PortD[0].Clear(); // // m.INT0.Activate(); m.INT1.Activate(); m.PortD.Activate(); m.EnableInterrupt(); // // m.LOOP(m.TempL, (r, l) => m.GO(l), (r, l) => { });
Using external interrupts allowed us to solve our problem as simple and clear as possible.
Managing external ports programmatically is not the only possible way. In particular, timers have a setting that allows them to control the output of the microcontroller directly. To do this, in the timer setting, you must specify the output control mode
m.Timer0.CompareModeA = eCompareMatchMode.Set;
After activating the timer, the 6th bit of port D will receive an alternative function and will be controlled by the timer. Thus, we are able to generate a PWM signal at the processor output purely at the hardware level, using software only to set the signal parameters. At the same time, if we try using the library tools to turn to the busy port as an input / output port, we will get an error at the compilation level.
The last device that we will cover in this part of the article will be the USART serial port. The functionality of this device is very wide, but so far we will touch on only one of the most common use cases for this device.
The most popular use case for this port is connecting a serial terminal to input / output text information. The part of the code regarding the port settings in this case may look as follows
m.FCLK = 16000000; // m.CKDIV8 = false; // m.Usart.Mode = eUartMode.UART; // UART m.Usart.Baudrate = 9600; // 9600 m.Usart.FrameFormat = eUartFrame.U8N1; // 8N1
The specified settings coincide with the default settings of the USART in the library, therefore, they can be partially or completely skipped in the program text.
Consider a small example in which we output static text to the terminal. In order not to inflate the code, we restrict ourselves to outputting to the terminal the classic “Hello world!” When the program starts.
var m = new Mega328(); var ptr = m.ROMPTR(); // m.CKDIV8 = false; m.FCLK = 16000000; // m.Usart.Mode = eUartMode.UART; m.Usart.Baudrate = 9600; m.Usart.FrameFormat = eUartFrame.U8N1; // m.Usart.OnTransmitComplete = () => { ptr.MLoadInc(m.TempL); m.IF(m.TempL!=0,()=>m.Usart.Transmit(m.TempL)); }; m.Usart.Activate(); m.EnableInterrupt(); // var str = Const.String("Hello world!"); // ptr.Load(str); // ptr.MloadInc(m.TempL); // m.Usart.Transmit(m.TempL); // . m.LOOP(m.TempL, (r, l) => m.GO(l), (r,l) => { });
In this program, from the new, the declaration of the constant string str . The library places all constant variables in the program memory, therefore, to work with them, you must use the ROMPtr pointer. Data output to the terminal begins with the output of the first character of the string sequence, after which control immediately goes to the main loop, without waiting for the end of the output. The completion of the byte transfer process causes an interrupt, in the handler of which the next character of the line is read. If the character is not 0 (the library uses the zero-terminated format for storing strings), this character is sent to the serial interface port. If we reach the end of the line, the character is not sent to the port and the send cycle ends.
The disadvantage of this approach is the fixed interrupt processing algorithm. It will not allow the serial port to be used in any other way than to output static strings. Another drawback of this implementation is the lack of a mechanism for monitoring port occupancy. If you try to send several lines sequentially, there may be a situation where the transmission of previous lines will be interrupted or the lines will be mixed.
More effective methods for solving this and other problems, as well as working with other peripheral devices, we will see in the next part of the post. In it, we will take a closer look at programming using the special Parallel task management class.