Debugging Techniques for Microcontrollers Without Serial Ports

Outputting debugging information is an essential debugging tool in embedded development. One characteristic of embedded development is that often there is no operating system or file system, making conventional methods of printing logs to files generally unsuitable.

The most common method is to output UART logs through a serial port. For example, with the 51 microcontroller, as long as the serial port driver is implemented, outputting through the serial port is sufficient.

Using a serial port is simple to implement, as most embedded chips have serial port functionality. However, this straightforward method can sometimes be less than ideal. For instance:
  • How can we print logs for a newly acquired chip that lacks a serial port driver?
  • In some applications where timing is critical, what should we do if serial port log output takes too long? For example, during USB enumeration.
  • What if certain bugs occur during normal operation but do not reproduce when the serial port log is enabled?
  • How can we output logs when some packages do not have a serial port, or the serial port is already used for other purposes?

This article introduces how to print debugging information when the microcontroller lacks a serial port.

1. Output Log Information to SRAM

To be precise, we are not outputting logs here, but rather using a method to view logs without a serial port. During chip development, we can connect to a debugger for debugging and use breakpoints, but some operations cannot be interrupted, making breakpoint debugging impossible.In such cases, we can consider printing logs to SRAM. After the entire operation is completed, we can check the log buffer in SRAM through the debugger, achieving indirect log output.

The test platform used in this article is the STM32F407 Discovery, based on USB host experimental code. The principles apply to other embedded platforms as well.

First, define a structure for printing logs as follows:

typedef struct {
   volatile u8     type;
   u8*             buffer;        /* log buffer pointer*/
   volatile u32    write_idx;     /* log write position*/
   volatile u32    read_idx;      /* log read position*/
}log_dev;

Define a segment of SRAM space as the log buffer.

static u8 log_buffer[LOG_MAX_LEN];

The log buffer is a circular buffer, allowing for infinite log printing in a small buffer. However, the drawback is apparent; if logs are not output in time, they will be overwritten by new logs. The buffer size is allocated based on the SRAM size, and here we use 1kB.

To facilitate output formatting, we use the printf function, which requires the following configuration.

Debugging Techniques for Microcontrollers Without Serial Ports

Include the header file #include <stdio.h>, and implement the function fputc() in the code.

//redirect fputc
int fputc(int ch, FILE *f)
{
    print_ch((u8)ch);
    return ch;
}

Writing data to SRAM:

/* write log to buffer or I/O */
void print_ch(u8 ch)
{
    log_dev_ptr->buffer[log_dev_ptr->write_idx++] = ch;
    if(log_dev_ptr->write_idx >= LOG_MAX_LEN){
        log_dev_ptr->write_idx = 0;
    }
}

To facilitate control over log printing formats, we add a custom printing function in the header file:

#ifdef  DEBUG_LOG_EN
#define DEBUG(...)    printf("usb_printer:"__VA_ARGS__)
#else
#define DEBUG(...)
#endif
To print logs where needed, simply call DEBUG(), and the final effect is as follows, with logs visible from the Memory window:

Debugging Techniques for Microcontrollers Without Serial Ports

2. Output Log via SWO
Using the method of printing logs to SRAM allows us to see the logs, but when there is a large amount of data, we might not have time to view them before they are overwritten.To solve this problem, we can use the SWO log output from the ST-Link, eliminating concerns about logs being overwritten.

Debugging Techniques for Microcontrollers Without Serial Ports

Add SWO operation function sets in the log structure:

typedef struct{
    u8 (*init)(void* arg);
    u8 (*print)(u8 ch);
    u8 (*print_dma)(u8* buffer, u32 len);
}log_func;

typedef struct {
    volatile u8    type;
    u8*            buffer;
    volatile u32   write_idx;
    volatile u32   read_idx;
    //SWO
    log_func*      swo_log_func;
}log_dev;

SWO only requires the print operation function, implemented as follows:

u8 swo_print_ch(u8 ch)
{
    ITM_SendChar(ch);
    return 0;
}

Using SWO to output logs first outputs to the log buffer, and then outputs when the system is idle; of course, it can also output directly. Delaying log output can affect the real-time nature of logs, while direct output can affect time-sensitive code execution, so the choice depends on the scenario requiring log output. By calling the output_ch() function within a while loop, we can achieve log output when the system is idle.

/*output log buffer to I/O*/
void output_ch(void)
{   
    u8 ch;
    volatile u32 tmp_write,tmp_read;
    tmp_write = log_dev_ptr->write_idx;
    tmp_read = log_dev_ptr->read_idx;

    if(tmp_write != tmp_read){
        ch = log_dev_ptr->buffer[tmp_read++];
        //swo
        if(log_dev_ptr->swo_log_func)
            log_dev_ptr->swo_log_func->print(ch);
        if(tmp_read >= LOG_MAX_LEN){
            log_dev_ptr->read_idx = 0;
        }else{
            log_dev_ptr->read_idx = tmp_read;
        }
    }
}

1. Output via IDE

Using the SWO output function in the IDE requires the following configuration (Keil):

Debugging Techniques for Microcontrollers Without Serial Ports

Logs can be seen in the output window:

Debugging Techniques for Microcontrollers Without Serial Ports

2. Output via STM32 ST-LINK Utility

Using the STM32 ST-LINK Utility does not require special settings; simply open the Printf via SWO viewer under the ST-LINK menu and press start:

Debugging Techniques for Microcontrollers Without Serial Ports

3. Output Log via Serial Port

All the above methods are suitable when serial port logs are temporarily unavailable or used just for a short time. For long-term use, outputting logs via the serial port is still necessary, as most of the time, it is not possible to connect to the debugger.

To add serial port log output, we only need to add the operation function set for the serial port:

typedef struct {
    volatile u8    type;
    u8*            buffer;
    volatile u32   write_idx;
    volatile u32   read_idx;
    volatile u32   dma_read_idx;
    // uart
    log_func*      uart_log_func;
    // SWO
    log_func*      swo_log_func;
}log_dev;

Implement the serial port driver function:

log_func uart_log_func = {
    uart_log_init,
    uart_print_ch,
    0,
};

Adding serial port log output is similar to the SWO process, and will not be elaborated further. However, the issue to discuss is that the serial port’s low rate and the time required for data output can severely affect system operation. Although printing to SRAM first and then delaying output can mitigate the impact, if the system interrupts frequently or requires time-consuming calculations, logs may be lost. To solve this problem, we need to address the simultaneous operation of the CPU and outputting data to the serial port, and embedded engineers can immediately think of DMA as a good solution.

Using DMA to transfer log data to the serial port output does not affect CPU operation, thus solving the problem of time-consuming serial port log output affecting the system: Why use DMA for serial communication in STM32? The initialization functions for the serial port and DMA are as follows:

u8 uart_log_init(void* arg)
{
    DMA_InitTypeDef DMA_InitStructure;
    u32* bound = (u32*)arg;
    // GPIO port settings
    GPIO_InitTypeDef GPIO_InitStructure;
    USART_InitTypeDef USART_InitStructure;

    RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOA,ENABLE);  // Enable GPIOA clock
    RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2,ENABLE); // Enable USART2 clock
    // Serial port 2 corresponding pin multiplexing
    GPIO_PinAFConfig(GPIOA,GPIO_PinSource2,GPIO_AF_USART2);
    // USART2 port configuration
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_2;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF; // Multiplex function
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;  // Speed 50MHz
    GPIO_InitStructure.GPIO_OType = GPIO_OType_PP;  // Push-pull multiplex output
    GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP;  // Pull-up
    GPIO_Init(GPIOA,&amp;GPIO_InitStructure);
    // USART2 initialization settings
    USART_InitStructure.USART_BaudRate = *bound; // Baud rate settings
    USART_InitStructure.USART_WordLength = USART_WordLength_8b; // Data format with 8-bit length
    USART_InitStructure.USART_StopBits = USART_StopBits_1; // One stop bit
    USART_InitStructure.USART_Parity = USART_Parity_No; // No parity bit
    USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;// No hardware flow control
    USART_InitStructure.USART_Mode = USART_Mode_Tx;  // Transmit mode
    USART_Init(USART2, &amp;USART_InitStructure);  // Initialize serial port 1
#ifdef LOG_UART_DMA_EN  
    USART_DMACmd(USART2,USART_DMAReq_Tx,ENABLE);
#endif  
    USART_Cmd(USART2, ENABLE);  // Enable serial port 1 
    USART_ClearFlag(USART2, USART_FLAG_TC);
    while (USART_GetFlagStatus(USART2, USART_FLAG_TC) == RESET);
#ifdef LOG_UART_DMA_EN
    RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_DMA1, ENABLE);
    // Config DMA channel, uart2 TX usb DMA1 Stream6 Channel
    DMA_DeInit(DMA1_Stream6);
    DMA_InitStructure.DMA_Channel = DMA_Channel_4;
    DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)(&amp;USART2-&gt;DR);
    DMA_InitStructure.DMA_DIR = DMA_DIR_MemoryToPeripheral;
    DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable;
    DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable;
    DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte;
    DMA_InitStructure.DMA_MemoryDataSize = DMA_PeripheralDataSize_Byte;
    DMA_InitStructure.DMA_Mode = DMA_Mode_Normal;
    DMA_InitStructure.DMA_Priority = DMA_Priority_High;
    DMA_InitStructure.DMA_FIFOMode = DMA_FIFOMode_Disable; 
    DMA_InitStructure.DMA_MemoryBurst = DMA_MemoryBurst_Single;
    DMA_InitStructure.DMA_PeripheralBurst = DMA_PeripheralBurst_Single;
    DMA_Init(DMA1_Stream6, &amp;DMA_InitStructure);
    RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_DMA1, ENABLE);
#endif
    return 0;
}

The function for outputting data via DMA to the serial port is as follows:

u8 uart_print_dma(u8* buffer, u32 len)
{
    if((DMA1_Stream6->CR &amp; DMA_SxCR_EN) != RESET){
        // dma not ready
        return 1;
    }
    if(DMA_GetFlagStatus(DMA1_Stream6,DMA_IT_TCIF6) != RESET){
        DMA_ClearFlag(DMA1_Stream6,DMA_FLAG_TCIF6);
        DMA_Cmd(DMA1_Stream6,DISABLE);
    }
    DMA_SetCurrDataCounter(DMA1_Stream6,len);
    DMA_MemoryTargetConfig(DMA1_Stream6, (u32)buffer, DMA_Memory_0);
    DMA_Cmd(DMA1_Stream6,ENABLE);
    return 0;
}

Here, for convenience, we directly use the polling method for the DMA status register. If needed, it can be modified to use DMA interrupt mode. The datasheet can be referenced to find that serial port 2 uses DMA1 channel 4 stream 6:

Debugging Techniques for Microcontrollers Without Serial Ports

Finally, the log output can be seen on the PC’s serial assistant:

Debugging Techniques for Microcontrollers Without Serial Ports

Using DMA to transfer data from the log buffer to the serial port allows the CPU to handle other tasks simultaneously, minimizing system impact while providing timely log output. This method is the most commonly used in practical applications.Moreover, this method can be used not only with serial ports but also with other interfaces that can utilize DMA operations (such as SPI, USB) for log printing.

4. Using IO to Simulate Serial Port Log Output

Finally, we discuss how to output logs when there is no serial port in certain packages, or when the serial port has been used for other purposes. In such cases, we can find a free general IO to simulate UART protocol and output logs to the serial port tool on the host computer.

The commonly used UART protocol is as follows:

Debugging Techniques for Microcontrollers Without Serial Ports

As long as we output high and low levels on the IO at determined times, we can simulate waveforms. This determined timing corresponds to the serial port’s baud rate.

To achieve precise timing, we use the TIM4 timer to generate a 1us delay. Note: The timer cannot be reused; in the test project, TIM2 and TIM3 have already been used. Reusing them would cause confusion.

The initialization function is as follows:

u8 simu_log_init(void* arg)
{
    TIM_TimeBaseInitTypeDef TIM_InitStructure;  
    u32* bound = (u32*)arg;
    // GPIO port settings
    GPIO_InitTypeDef GPIO_InitStructure;
    RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOA,ENABLE);  // Enable GPIOA clock
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_2;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_OUT;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;   // Speed 50MHz
    GPIO_InitStructure.GPIO_OType = GPIO_OType_PP;  // Push-pull multiplex output
    GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP;  // Pull-up
    GPIO_Init(GPIOA,&amp;GPIO_InitStructure);
    GPIO_SetBits(GPIOA, GPIO_Pin_2);
    // Config TIM
    RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM4,ENABLE);  // Enable TIM4 clock
    TIM_DeInit(TIM4);
    TIM_InitStructure.TIM_Prescaler = 1;        // 2 prescaler
    TIM_InitStructure.TIM_CounterMode = TIM_CounterMode_Up;
    TIM_InitStructure.TIM_Period = 41;          // 1us timer
    TIM_InitStructure.TIM_ClockDivision = TIM_CKD_DIV1;
    TIM_TimeBaseInit(TIM4, &amp;TIM_InitStructure);
    TIM_ClearFlag(TIM4, TIM_FLAG_Update);
    baud_delay = 1000000/(*bound);          // Calculate delay per bit based on baud rate
    return 0;
}

The delay function using the timer is as follows:

void simu_delay(u32 us)
{
    volatile u32 tmp_us = us;
    TIM_SetCounter(TIM4, 0);
    TIM_Cmd(TIM4, ENABLE);
    while(tmp_us--){
        while(TIM_GetFlagStatus(TIM4, TIM_FLAG_Update) == RESET);
        TIM_ClearFlag(TIM4, TIM_FLAG_Update);
    }   
    TIM_Cmd(TIM4, DISABLE);
}

The final simulation output function must ensure interrupts are disabled before outputting; after outputting one byte, interrupts can be re-enabled; otherwise, garbled output may occur:

u8 simu_print_ch(u8 ch)
{
   volatile u8 i=8;
   __asm("cpsid i");
   //start bit
   GPIO_ResetBits(GPIOA, GPIO_Pin_2);
   simu_delay(baud_delay);
   while(i--){
        if(ch &amp; 0x01)
            GPIO_SetBits(GPIOA, GPIO_Pin_2);
        else
            GPIO_ResetBits(GPIOA, GPIO_Pin_2);
        ch >>= 1;
        simu_delay(baud_delay);
    }
    // stop bit
    GPIO_SetBits(GPIOA, GPIO_Pin_2);
    simu_delay(baud_delay);
    simu_delay(baud_delay);
     __asm("cpsie i");
    return 0;
}

This article introduced several methods used for printing debugging information during development. Methods are always fixed, but the key lies in flexible usage; by printing effective debugging information, we can help solve issues encountered in development and later maintenance, avoiding unnecessary detours.

END

Comprehensive Examples of Microcontroller Programming

Collection of 50 Books on Microcontroller Interfaces

Learn Microcontroller in Ten Days – Complete Version

Microcontroller Peripheral Circuit Design

100 Examples of Microcontroller Programming Techniques

👇 Scan to Join the Microcontroller WeChat Group and Download Materials for Free 👇

Debugging Techniques for Microcontrollers Without Serial Ports

Leave a Comment