8 Essential C Language Tips for Embedded Engineers

As an engineer working with MCUs, we rarely encounter pure upper-level software development, nor can we fully engage in hardware circuit design. Our most common work mode is: A cigarette in the left hand, a soldering iron in the right, and both hands on the keyboard typing code.

To create a good design, we must overcome countless difficulties and obstacles in the collaboration between hardware and software. No MCU enthusiast wants to face troubles caused by language and tools. Although developing C language on resource-constrained platforms like MCUs may not seem glamorous in the software world, C language remains the mainstream for MCU development. To better control and debug our hardware, we must strive to avoid some C language programming pitfalls and prevent high-level languages or architectures from interfering with the overall progress and reliability of our products.

Here are some tips to avoid pitfalls in C language.

Pitfall 1: Avoid using “GOTO” statements

The GOTO statement originated from assembly language jumps. Many years ago, when computer programming was still in its infancy, C language was designed following the assembly thought process, leaving behind the GOTO statement that allows programmers to jump freely between code. Example of using GOTO statement:

#include <stdio.h>
int main() {    int i = 0;
    // Simple example using goto    goto start;
loop:    printf("Inside loop: %d\n", i);    i++;
start:    if (i < 5)        goto loop;
    printf("Loop finished.\n");
    return 0;}

This GOTO statement is simple to use, but if the program jumps back and forth, it becomes very difficult to read and quite convoluted. Additionally, GOTO statements have the following issues:

  1. Poor readability: Code using <span>goto</span> statements often becomes difficult to understand because it allows jumps to different label locations in the program. This makes the code flow unclear and increases the difficulty of understanding the code.
  2. Difficult to maintain: When the code contains many <span>goto</span> statements, it can lead to maintenance difficulties. When modifying code or adding new features, careful consideration must be given to the impact of <span>goto</span> statements to prevent introducing errors.
  3. Incorrect usage may cause problems: If a wrong label is used accidentally or if <span>goto</span> is used inappropriately, it may lead to incorrect program behavior. Such errors can be hard to trace and fix.
  4. Hinders structured programming: Using <span>goto</span> statements may violate the principles of structured programming, making it difficult to organize code in a clear structure. Structured programming emphasizes using sequential, selection, and iteration structures to build clear, readable, and maintainable code.
  5. Hinders debugging: During debugging, jump statements complicate the execution path of the program, increasing debugging difficulty. Jumps in the code can make step-by-step debugging difficult, hindering the process of finding and fixing errors.

Pitfall 2: Use complete conditional statements

When using conditional statements, we must pay particular attention to the completeness of the conditions. Many engineers are familiar with simple if-else statements, but some engineers may overlook that different writing styles can waste processor time. For example:

if(value == 1U){
}
if(value == 0U){
}
if(value == 1U){
}else {
}

In the first writing style, the processor checks twice and then branches based on the results. However, if we write it in the second style, the processor only needs to check once. Especially when such checks are inside a large loop, this can waste a lot of processor time.

Additionally, to ensure clearer readability, we should make if-else pairs appear together, and separate the program with {} to avoid errors during debugging that could arise from copy-pasting, which would affect our debugging and problem-solving progress.

#include <stdio.h>
int main() {    int choice;
    // Prompt user for input    printf("Enter a number (1-3): ");    scanf("%d", &choice);
    // Use switch statement to execute different operations based on user input    switch (choice) {        case 1:            printf("You chose option 1.\n");            // Execute code for operation 1            break;
        case 2:            printf("You chose option 2.\n");            // Execute code for operation 2            break;
        case 3:            printf("You chose option 3.\n");            // Execute code for operation 3            break;
        default:            printf("Invalid choice. Please enter a number between 1 and 3.\n");            // Handle invalid choice            break;    }
    return 0;
}

If there are many branches to check, it is best to use switch-case statements instead of if-else. The reasoning is the same; always ensure completeness and separate the program segments with {}. Also, if we have some foresight about the hit rate of branches, we should place the branches with higher hit rates first.

For cases with many branches, some compilers will optimize automatically, so we don’t need to worry about hit rates in such cases.

Pitfall 3: Use FOR(;;) or While(1)?

In MCU development, we mostly use front and back-end systems. Even when running some real-time operating systems, we cannot avoid using infinite loops.

There are currently two ways to write infinite loops. I often see some junior engineers using while(1), while in some operating system source codes, for(;;) is more common.

If we use for loops under C99, it looks more compact.

// while loop initialization
int i = 0;while (i < 5) {    // ...    i++;}
// for loop initialization
for (int i = 0; i < 5; i++) {    // ...}

Additionally, over a decade ago, I developed on Cypress microcontrollers, where flash space was limited, requiring extreme code optimization for space compression. I chose the for loop style to save an extra byte, but now many compilers have been updated over the years, and at least on mainstream ARM platforms, their assembly code is the same.

Pitfall 4: Avoid using inline assembly language as much as possible

The natural language of microprocessors is assembly language instructions. Programming in low-level machine language may provide more efficient code for processors. However, humans are not inherently fluent in this language, and experience shows that writing assembly language can lead to misunderstandings. Misunderstandings can lead to improper maintenance, and worse, can leave the system riddled with bugs. It is generally advised to avoid using assembly language.

8 Essential C Language Tips for Embedded Engineers

My first job was in the subway broadcasting system. I heard that the digital transformation of Beijing Subway Line 1 was done using a microcontroller control system. The old engineer in Tianjin wrote several pages of assembly code, which had to be compiled in Beijing, and it was almost compiled successfully on the first attempt.

This may sound mythical, but in today’s world of rich online debugging tools, we no longer need to develop such skills.

In fact, most compilers today can compile highly efficient code. Developing in high-level languages like C or C++ allows for a more organized structure, making the code easier to understand and maintain, resulting in a better overall effect.

Pitfall 5: Write layered code instead of messy code

In the early stages of MCU development, we often charge in like a bull, writing code in one go without regard for structure, and then complain about changing requirements or the hassle of adding small features.

8 Essential C Language Tips for Embedded Engineers

To avoid these troubles, we need to cultivate architectural thinking, plan the code in a layered manner, and implement one module at a time. It may be slower initially, but it will lead to greater progress in the long run.

If the code is written chaotically like instant noodles, it can easily result in confusion. In the end, you may lose the confidence to refactor.

Pitfall 6: Have a modular mindset

MCU development often faces many different platforms, from early 8051 to the now popular ARM Cortex series. Regardless of the platform, we essentially operate on their peripherals, and we can abstract many codes related to peripherals into modules, such as FIFO for serial transmission and reception.

For example, when performing some digital signal processing algorithms, such as finding maximum and minimum values, first-order low-pass filter algorithms, etc.

We can abstract these small algorithms into a module for direct use across various platforms and projects.

C language programming allows engineers to divide code into independent functional modules, simplifying code navigation while enabling the use of encapsulation and other object-oriented techniques. Code can be organized into logical modules, which is meaningful. Although it may take some time upfront (a few minutes), in the long run, it will save many long nights and debugging struggles!

Pitfall 7: Establish a variable naming convention for yourself

It’s easy to conquer a territory but hard to maintain it. If you write code hastily, maintaining it later will be painful. Often, we can’t even understand our own comments, so it is crucial to make the code itself meaningful.

8 Essential C Language Tips for Embedded Engineers

For example, we can prefix global and local variables with g and m respectively.

unsigned char g_bValue = 0;
int main(){    short m_wCnt = 0;}

For variable definitions, we can add identifiers based on the type in front of the variable: b (byte), w (word), dw (double word).

Moreover, the meaning of the variable names themselves is important. We can use complete English for naming, and if in a team, everyone can agree on effective naming, or we can use some brief, creative names, provided there is a team naming manual.

int Freq      //Frequency
int Btn       //Button
int MotorSta   //MotorState
int Spd       //Speed

Pitfall 8: Use #pragma statements sparingly

In C language, there is a special #pragma statement. These statements usually handle non-standard syntax and features and should be avoided as much as possible because they are non-standard and cannot be ported from one processor to another. Some compilers may require these statements to complete certain tasks, such as defining an interrupt service routine. In such cases, we should write our interrupt service functions separately and let the compiler-required function call our functions to decouple our program from the compiler’s feature requirements.

// Using typedef to define keywords
typedef unsigned char U8;
typedef unsigned short U16;
typedef unsigned int U32;

Additionally, we can define some portable data types in our code, so that when porting our code, we only need to #include our configuration file.

Author: Yixuan, Source: Xuan Ge Talks on Chips
Disclaimer: This article is reproduced with permission from the “Xuan Ge Talks on Chips” WeChat official account. The reproduction is for learning reference only and does not represent the views of this account. This account does not bear any infringement responsibility for its content, text, or images.
END
Interesting articles
1、What is DMA? How fast is DMA?
2The Journey of an Ordinary Second-Tier Electronic Engineer

3、Remember these 3 details, and avoid EMC hazards!

4、Understand the relationship between frequency domain and time domain with one picture

Leave a Comment