CH32V003F4P6 - Interrupts

In this article, we finally cover the interrupts. So far I briefly introduced the chip and then discussed the GPIOs, and then I showed some USART examples, too. Then I continued with timers and signal (PWM) generation and did some voltage measurements with the ADC and DMA of the CH32V003F4P6 microcontroller. Actually, all the so far introduced stuff could have benefited from interrupts. However, I felt more logical to introduce them without interrupts so then when I would arrive at the present article, I could pack everything together into a single lecture.

Introduction

 

I am not going to explain all the details about interrupts because they can be found on reputable websites and in the datasheet. I will focus more on CH32V003-specific things and things that are related to the already presented lectures. In a nutshell, from a certain aspect, interrupts allow us to do multiple things without the need of supervising (polling) events. This can be a button press, an USART transfer, a timer-related or an ADC conversion-related event. Instead, interrupts will let us know if the above things happened and we only need to look at the result when the result is already there.

GPIO interrupts

First, let’s see the interrupts applied to GPIOs. This is simple. Basically what we want here is that instead of constantly reading the GPIO pin that has a button connected to it, we use an interrupt which is fired when the state of the GPIO changes.

So, we need to initialize the interrupts for the GPIO. As usual, we select a GPIO port and a corresponding pin. I picked PD0. I set the pin as floating because I use a pullup resistor with the button, but input-pullup (IPU) mode is also fine if you directly connect it to a button.

I also introduced PC1 as an output. This GPIO will drive a LED which will be toggled when an encoder click is detected.

Then I set which port and pin will trigger the interrupt. I also selected the trigger as falling because the button is pulled up, so it only makes sense to listen to a falling event. Although rising event would also work because when a pulled up and pressed button is released then its status change is LOW → HIGH which is a rising event. It is just more conventional to register the event when the button is pressed and not when it is released. The rest of the things I did not explain are just the typical lines one can copy from the example codes provided for the microcontroller.


 
void EXTI0_INT_INIT(void)
{
    GPIO_InitTypeDef GPIO_InitStructure = {0};
    EXTI_InitTypeDef EXTI_InitStructure = {0};
    NVIC_InitTypeDef NVIC_InitStructure = {0};

    RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO | RCC_APB2Periph_GPIOD | RCC_APB2Periph_GPIOC, ENABLE);

    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_2;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
    GPIO_Init(GPIOD, &GPIO_InitStructure);

    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOC, &GPIO_InitStructure);

    GPIO_EXTILineConfig(GPIO_PortSourceGPIOD, GPIO_PinSource0);
    EXTI_InitStructure.EXTI_Line = EXTI_Line0;
    EXTI_InitStructure.EXTI_Mode = EXTI_Mode_Interrupt;
    EXTI_InitStructure.EXTI_Trigger = EXTI_Trigger_Falling;
    EXTI_InitStructure.EXTI_LineCmd = ENABLE;
    EXTI_Init(&EXTI_InitStructure);

    NVIC_InitStructure.NVIC_IRQChannel = EXTI7_0_IRQn;
    NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0;
    NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;
    NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
    NVIC_Init(&NVIC_InitStructure);
}
 

However, this is not everything. We now have the possibility to generate an interrupt by the PD0 pin, but there’s no way we can get any notification if anything happens to the pin. So let’s fix that.

First, we declare the EXTI7_0_IRQHandler() function. This is just an embedded C/C++ practice, particularly for interrupt handlers where we first declare the function before defining it. It tells the compiler that there should be a special function in the code.

Then the actual code first checks the interrupt flag and its status. If the flag is set ( != RESET), then the buttonPressed flag is set to 1, and the interrupt flag is cleared. This makes sure that the ISR won’t be called one more time and that the interrupt can be triggered again when the button is pressed. It is worth noting that the buttonPressed variable is declared as a volatile uint8_t. All variables that are used in interrupts need to be volatile.

So, how do we know about the change of the buttonPressed variable?

Well, it is a bit tricky. We actually resort to polling again but to a more efficient one. The thing is, we are not supposed to use the USART-related functions inside the ISR(). Or in general, nothing that could make the ISR() slow down and so on… ISR()s should be short and quick and since USART might contain blocking elements (like waiting for the transfer to complete), it can stall the execution of the ISR().

Therefore, I just set a flag and then in the main loop, I keep polling the flag. Polling a variable is more efficient than polling a GPIO pin because instead of accessing a bunch of registers, masking bits and whatnot, the microcontroller simply has to check a memory location in the SRAM which is much faster.

When the flag changes, the polling picks it up, then it resets the flag so a next change can be captured, and finally it sends a message to the serial port so we know that the button press was detected.

 

void EXTI7_0_IRQHandler(void) __attribute__((interrupt("WCH-Interrupt-fast")));

void EXTI7_0_IRQHandler(void)
{
    if(EXTI_GetITStatus(EXTI_Line0)!=RESET)
    {
        buttonPressed = 1; //Setting a flag instead of printing directly from the ISR()
        EXTI_ClearITPendingBit(EXTI_Line0); //Clearing ISR flag
    }
}
while(1)
{
    if(buttonPressed == 1)
    {
        buttonPressed = 0; 
        printf("The button was pressed\r\n");         
    }
}
 

Rotary encoder

Now we know how to handle a GPIO interrupt. Let’s implement the typical routine of handling a rotary encoder. What we need to do is to “listen” to one of the encoder pins via an interrupt and when an interrupt is fired, we should read the status of the other GPIO pin. Based on the relationship of the two pins, we can figure out if the encoder was turned clockwise or counterclockwise and then we can decide whether we increase or decrease the value of the variable associated with the number of encoder clicks.

So in the interrupt’s initialization, we need to add one more pin for the encoder. It might make sense to add the pin adjacent to PD0 which is PD1, but PD1 is also the SWIO pin that we use for programming. Therefore we jump one more and use PD2. I showed it already in the GPIO lecture how to define multiple pins at once (hint: logical OR operation), so I don’t show it here.

The interrupt is more interesting. I first defined two new volatile variables, encoderClicked and encoderClicks. The first is a status flag and the other is the counter for the number of clicks done by the encoder.

When the interrupt is triggered we first change the encoderClicked flag so the rest of the code knows that the interrupt was fired. Then, based on the direct reading of the PD2 pin the code decides whether the encoderClicks should be increased or decreased. For fun, I toggle an LED so we can directly see when a rotary encoder click is detected. It is quick visual feedback in case we don’t want to use the terminal.

Finally, the flag is cleared which makes the interrupt ready to receive a new event triggered by us rotating the rotary encoder.

We again poll the status flag used in the ISR in the main loop. When the flag becomes 1, we enter the if() and reset the flag. Then we simply print some message together with the value of the encoderClicks variable.

If you fancy a nice, debounced rotary encoder module, get my design via my PCBWay project site:

PCB from PCBWay
 
void EXTI7_0_IRQHandler(void)
{
    if (EXTI_GetITStatus(EXTI_Line0) != RESET)
    {
        encoderClicked = 1;

        if (GPIO_ReadInputDataBit(GPIOD, GPIO_Pin_2) == 1)
        {
            encoderClicks++;
        }
        else
        {
            encoderClicks--;
        }
        
        GPIO_WriteBit(GPIOC, GPIO_Pin_1, (BitAction)(1 - GPIO_ReadInputDataBit(GPIOC, GPIO_Pin_1)));
        EXTI_ClearITPendingBit(EXTI_Line0);
    }
}
while(1)
{
   if(encoderClicked == 1)
   {
       encoderClicked = 0; 
       printf("Encoder value: %d\r\n", encoderClicks);
   }
}
 

USART interrupts

So after we played around with the GPIO pins, let’s go back to the already existing USART initializing function and expand it so we can use interrupts on the USART. Actually, this is not too difficult, we just add the ITConfig() to the function and define the NVIC-related parameters in almost the same way as we did for the GPIOs.

Then, the more interesting part is inside the interrupt handler function, but in fact, this is not difficult either because it is almost identical to the polling function. Except now we don’t need to poll for the change of the RXNE flag, but the interrupt automatically updates it. And then of course we need to take care of the interrupt flag and clear it at the end of the interrupt.

So, as usual, we need to separately declare the interrupt handler function, otherwise, nothing will work. Then we create the function itself. As I said, it is very similar to the polling version.

The function checks the status of the RXNE flag and if it is set, then it proceeds further. It reads the data (character) from the USART1 and passes it to a variable. We must keep in mind that these variables could be potentially accessed by both the “normal code” and the interrupt, so we must define them as volatile, otherwise, things can go wrong.

Then the same algorithm is applied as in the past. If an endline character is received, the code finalizes the buffer and adds a null-terminate character to it. Then it resets the buffer index, so the next reception can start to fill up the buffer from the beginning and then it changes the usart_completed flag to 1, so the normal code can be aware that it can read out the buffer.

If the received character was not an endline character, then it is just added to the buffer.

Once these are done, the interrupt flag must be cleared so a new interrupt can be triggered.

The code below is then placed in the while(1). It first resets the flag, then prints the content of the buffer and finally, it erases its content. Although this is technically also polling because the usart_completed flag is constantly polled, it is still more efficient than polling the USART-related flags.

if(usart_completed == 1)
{
    usart_completed = 0;
    printf(receivedBuffer);
    memset(receivedBuffer, 0, 20); 
}
 
USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);
volatile char receivedCharacter = 0;
volatile char receivedBuffer[20];
volatile int16_t buffer_index = 0;
volatile int8_t usart_completed = 0;

void USART1_IRQHandler(void) __attribute__((interrupt("WCH-Interrupt-fast")));

void USART1_IRQHandler(void)
{
    if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET)
    {
        receivedCharacter = USART_ReceiveData(USART1);

        if (receivedCharacter == '\n')
        {
            receivedBuffer[buffer_index] = '\0';
            buffer_index = 0; 
            usart_completed = 1;
        }
        else
        {
            if (buffer_index < 20 - 1)
            {
                receivedBuffer[buffer_index++] = receivedCharacter; 
            }
        }
        USART_ClearITPendingBit(USART1, USART_IT_RXNE);
    }
}
 

Non-blocking blinky with timer interrupts

Now we arrived at this part finally where we can use interrupts with timers. I did not want to do this when I first introduced the timers because I knew that I was going to do it in this lecture.

In the initializer function, I combined initializing the timer and the GPIO pin. I chose PC1 to be the GPIO pin for the LED that the timer will toggle via an interrupt.

There is one very important and easy-to-overlook part in the code snippet that I want to highlight here separately:

RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE);

The CH32 has several buses and each bus is connected to various peripherals such as GPIO pins and timers. As you can see, APB1 (advanced peripheral bus 1) and APB2 (advanced peripheral bus 2) both show up in the code. What is important to note here is that coding this part requires a little hardware knowledge. One must know which bus contains which peripherals in order to be able to enable the correct clocks. The reference manual of the CH32V003 chip (link) contains this information in sections 3.4.7 and 3.4.8. While the GPIO can be found on the PB2 peripheral clock bus, the timer 2 is on the PB1.

So, here, we can not combine the two peripherals with the logical OR operation as I did in other examples where the different peripherals were on the same bus.

The rest of the code is similar to the previous examples, so they are not discussed further.

In the main(), before the while(1), I initialized the timer like this:

initializeTimer(47999, 999);

This line fires the interrupt every one second. So the LED will be ON for 1 second and OFF for 1 second.

Similarly to the button example, we need to first declare the TIM2_IRQHandler() function, otherwise the interrupt code would not work.

Then we implement the interrupt as well. As usual, the interrupt checks the corresponding flag and if its value is “SET”, then the rest of the code is performed. I borrowed my pin toggler line from the earlier examples and made it to toggle PC1. Afterwards, the interrupt bit (flag) is cleared so the code is ready for a new interrupt.

 
void initializeTimer(uint16_t prsc, uint16_t arr)
{
       TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure = {0};
       NVIC_InitTypeDef NVIC_InitStructure = {0};
       GPIO_InitTypeDef GPIO_InitStructure = {0};

       RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);
       RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE);

       GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1;
       GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; 
       GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
       GPIO_Init(GPIOC, &GPIO_InitStructure);

       TIM_TimeBaseStructure.TIM_Period = arr;    
       TIM_TimeBaseStructure.TIM_Prescaler = prsc; 
       TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1;
       TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up;
       TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure);

       TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE); 

       NVIC_InitStructure.NVIC_IRQChannel = TIM2_IRQn; 
       NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0;
       NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;
       NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
       NVIC_Init(&NVIC_InitStructure);

       TIM_Cmd(TIM2, ENABLE); 
}
void TIM2_IRQHandler(void) __attribute__((interrupt("WCH-Interrupt-fast")));

void TIM2_IRQHandler(void)
{
    if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET)
    {
        GPIO_WriteBit(GPIOC, GPIO_Pin_1, (GPIO_ReadOutputDataBit(GPIOC, GPIO_Pin_1) == Bit_SET) ? Bit_RESET : Bit_SET);
        TIM_ClearITPendingBit(TIM2, TIM_IT_Update);
    }
}
 

ADC conversion results via interrupts

The idea here is the same as before. Why should we wait (polling) for the result if we can do something else in the meantime and get a notification (interrupt) when a conversion is ready? However, since we already have a well-configured timer, why not use it to trigger an ADC reading instead of toggling a GPIO? Actually, for fun, I leave the GPIO toggling in the code. At least we see when a conversion is started. Also, to make it even more fun, I add another GPIO toggling to the ADC’s interrupt, so we see when the conversion is done.

The strategy is the following:

  1. The timer’s tick creates an interrupt inside which we start an ADC reading

  2. The ADC reading creates another interrupt inside which we get the result

The timer would create a new ADC reading every one second. The conversion is so fast that it could provide the result and throw an interrupt event so we can catch it before the new conversion is started.

The timer’s code needs just a little change, so I don’t copy-paste the whole code. The two lines below need to be modified/added to the previously implemented timer initialization code.

GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1 | GPIO_Pin_2;
TIM_SelectOutputTrigger(TIM2, TIM_TRGOSource_Update);

The ADC has to be initialized from scratch, so we borrowed the previous lesson’s code and modified it to match our requirements. One of the most important things here now is that the ADCContinuousConvMode is disabled. The timer’s trigger event will drive the conversion and not the ADC’s sampling speed. We also need to tell the ADC that it is going to receive an external trigger by setting the ADC_ExternalTrigConv to ADC_ExternalTrigConv_T2TRGO. This basically tells the ADC that the external trigger source is the TIM2’s trigger output (TRGO). Then I associate the input with channel 2 (A2) which is located on PC4 pin. Since we use interrupts, the NVIC must be set up for the ADC as well. It is similar to the timer’s NVIC, except the NVIC_IRQChannel is now set to ADC_IRQn. Then, the interrupt must be enabled in the ADC_ITConfig().



Finally, similarly to the timer’s interrupt, we define everything in the same manner. As I mentioned, I toggle another LED when the ADC conversion is ready. The LED is located on the PC2 pin. I also save the adcValue in a volatile uint16_t variable which is later accessed in the while(1) and printed on the serial terminal. The printing is managed by another volatile uint8_t variable, adcAvailable, which is used as a flag to let the code print the conversion result on the serial terminal when there’s a new value.

The printing is the same as for the button press or the encoder click:

if(adcAvailable == 1)
{
    adcAvailable = 0; 
    printf("ADC: %d\r\n", adcValue);
}
 
void ADC1_Init(void)
{
    ADC_InitTypeDef ADC_InitStructure = {0};
    NVIC_InitTypeDef NVIC_InitStructure = {0};

    RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE); 

    ADC_InitStructure.ADC_Mode = ADC_Mode_Independent;
    ADC_InitStructure.ADC_ScanConvMode = DISABLE;
    ADC_InitStructure.ADC_ContinuousConvMode = DISABLE; 
    ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_T2_TRGO; 
    ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right;
    ADC_InitStructure.ADC_NbrOfChannel = 1;
    ADC_Init(ADC1, &ADC_InitStructure);

    ADC_RegularChannelConfig(ADC1, ADC_Channel_2, 1, ADC_SampleTime_57Cycles); 

    NVIC_InitStructure.NVIC_IRQChannel = ADC_IRQn;            
    NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0;   
    NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;          
    NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;            
    NVIC_Init(&NVIC_InitStructure);

    ADC_ITConfig(ADC1, ADC_IT_EOC, ENABLE); 

    ADC_Cmd(ADC1, ENABLE); 

    ADC_ResetCalibration(ADC1);
    while(ADC_GetResetCalibrationStatus(ADC1));
    ADC_StartCalibration(ADC1);
    while(ADC_GetCalibrationStatus(ADC1));
}
void ADC1_IRQHandler(void) __attribute__((interrupt("WCH-Interrupt-fast")));

void ADC1_IRQHandler(void)
{
    if (ADC_GetITStatus(ADC1, ADC_IT_EOC) != RESET)
    {
        adcValue = ADC_GetConversionValue(ADC1); 
        adcAvailable = 1;
        GPIO_WriteBit(GPIOC, GPIO_Pin_2, (GPIO_ReadOutputDataBit(GPIOC, GPIO_Pin_2) == Bit_SET) ? Bit_RESET : Bit_SET);
        ADC_ClearITPendingBit(ADC1, ADC_IT_EOC); // Clear interrupt flag
    }
}
 

However, we can actually implement the printing by using the USART’s interrupt function. We can just simply add an extra bit of code to the already existing interrupt that we implemented for reading using the RXNE flag.

In this case, we check the TXE flag. If it is SET, then the code proceeds further. It checks if the head and tail are equal. These are the parameters which keep track of the data’s position inside the buffer. If the data which is written in the buffer (txHead index) is ahead of the data read out from the buffer (txTail index), the code enters the if() block because if the two parameters are not equal, it means that there is data to be sent to the USART port.

Then the code sends one byte out from the most recent position which is tracked by the txTail variable.

When a byte is sent out, the value of the txTail is incremented by one. To make sure that the buffer is treated as a circular buffer, a modulo operation is applied. So when (txTail + 1) becomes 64, which is the buffer size I arbitrarily chose, the result of the (txTail + 1 ) % 64 operation becomes 0. So the txTail wraps around and starts the counting from 0 again.

But we need to fill in the buffer somehow as well. For this demonstration, I borrow the already implemented interrupt for the ADC and I tweak it a bit.

After the EOC (end of conversion) interrupt triggered the code to enter the if() block, we fetch the most recent conversion value and pass it to the adcValue variable.

Then, we fill up the buffer. Since the ADC reading is a 10-bit number stored on 16-bits and the buffer is made of 8-bit variables (char), the conversion must be divided into two parts: a high-byte and a low-byte.

First the high-byte is passed to the buffer at the txHead location and it is shifted to right by 8 bits and the higher bits are masked out (can be omitted). The masking with 0xFF isolates the last 8 bits.

1011 0101 0111 1010 >> 8  
-----------------------
0000 0000 1011 0101   

Then the txHead is incremented by 1 using the same strategy we used for txTail. Afterwards, the masked low-byte is passed to the buffer and the txHead is incremented again.

  1011 0101 0111 1010  
& 0000 0000 1111 1111
-----------------------
  0000 0000 0111 1010   

Then finally, the TXE interrupt is enabled which will generate an interrupt request when the TXE flag is set (the transmit data register is empty).

 

Get this programmer using my affiliate link!

 

Get this board using my affiliate link!

 
if (USART_GetITStatus(USART1, USART_IT_TXE) != RESET)
{
    if (txHead != txTail) 
    {
        USART_SendData(USART1, txBuffer[txTail]);
        txTail = (txTail + 1) % 64;
    }
    else
    {           
        USART_ITConfig(USART1, USART_IT_TXE, DISABLE);
    }
}
void ADC1_IRQHandler(void)
{
    if (ADC_GetITStatus(ADC1, ADC_IT_EOC) != RESET)
    {
        adcValue = ADC_GetConversionValue(ADC1); 
        txBuffer[txHead] = (adcValue >> 8) & 0xFF;  
        txHead = (txHead + 1) % 64; 
        txBuffer[txHead] = adcValue & 0xFF;
        txHead = (txHead + 1) % 64;
        USART_ITConfig(USART1, USART_IT_TXE, ENABLE);
        GPIO_WriteBit(GPIOC, GPIO_Pin_2, (GPIO_ReadOutputDataBit(GPIOC, GPIO_Pin_2) == Bit_SET) ? Bit_RESET : Bit_SET);        
        ADC_ClearITPendingBit(ADC1, ADC_IT_EOC); 
    }
}
 
 
 

If you found this content useful, please consider joining my YouTube channel’s membership or leaving a donation.

Also, please consider using my affiliate links when buying relevant gadgets.

 
Previous
Previous

Reflow hot plate update

Next
Next

CH32V003F4P6 - ADC basics