Two Programming Philosophies for Microcontrollers

Layered Thinking

Layered thinking is not a mysterious concept; in fact, many engineers working on projects are already using it. I’ve seen many posts that do not mention this concept, yet a layered structure is indeed very useful, and once understood, it can lead to a moment of clarity. If I don’t understand how to drive an LCD, that’s manageable; I can look at the datasheet and reference others’ programs to quickly get it working. However, if I don’t understand programming design principles, I will face many confusions during my project work.
I have referenced various embedded books available in the market, including MCS-51, AVR, ARM, etc., but I haven’t found any that introduce design philosophies. Even if there are some, they are rare. Writing programs is not difficult, but writing them well and quickly requires some experience accumulation. The principles of structured and modular programming are fundamental requirements. Related articles: Programming philosophies in C for embedded development.
But how do we apply this abstract concept in engineering practice? It requires enduring hardships during project work, summarizing some experiences, and abstracting them into theories, which greatly benefits both experience accumulation and knowledge dissemination. So, I humbly present some summaries.
From my personal experience, there are two design philosophies that are very important.
One is the “time-slice round-robin design philosophy,” which is very useful for solving multitasking issues in practice. This is usually a good indicator of whether someone is a microcontroller learner or a microcontroller engineer. This must be mastered (to be introduced below). The second is the “layered masking design philosophy,” which is the layered concept. Below, I will use a keyboard scanning program as an introduction to today’s discussion.
Problem Statement
Microcontroller learning boards are generally designed for simplicity, with buttons well allocated; for example, an entire 4*4 keyboard matrix mapped to port P1, with 8 control lines, just right. This makes the program easy to write. You only need something simple:
KEY_DAT = P1;
The port data is read in.
Indeed, reality is not so convenient. In actual project applications, the multiplexing of microcontroller pins is quite extensive, which differs greatly from those so-called microcontroller learning boards. Another reason is that the general design process is “software cooperating with hardware”; simply put, first determine the hardware schematic and wiring, and only then develop the software, as hardware modifications are relatively troublesome, while software modifications are easier. This reflects the traditional Chinese philosophy of yin and yang balance. Hardware design and software design are inherently at odds; if hardware design is made easier, it may cause significant trouble for software writing.
Conversely, if software design is made easier, hardware design may become quite troublesome. If both hardware and software designs are made easy, there are only two possibilities: either the design scheme is very simple, or the designer has reached a very high level. For now, let’s not consider too many scenarios and simply look at the problem from the perspective of common practical applications. To facilitate wiring, hardware often allocates I/O ports to different ports; for instance, the aforementioned 4*4 keyboard may have its 8 lines distributed across P0, P1, P2, and P3. Thus, the scanning keyboard programs for development boards become irrelevant. How do we scan the keys? I recall when I first started learning, I wrote three very similar programs for scanning keys, one by one… Perhaps some are unwilling to accept that “those things I spent a long time learning and using well can just be dismissed?” While it may sound harsh, I still want to say, “Brother, accept reality; it is cruel…” However, the difference between humans and lower animals is that humans can create and think of solutions when faced with difficulties. Thus, we begin to ponder… Finally, we introduce the concept of “mapping” learned in middle school mathematics to solve the problem. The basic idea is to map keys from different ports to the same port. How to Divide the Key Scanning Program into Three Layers
The bottom layer is the hardware layer, responsible for port scanning, 20ms debounce delay, and mapping port data to a KEY_DAT register, which serves as an interface to the upper driver layer. The middle layer is the driver layer, which only operates on the value of the KEY_DAT register. In simple terms, regardless of how the underlying hardware is wired, the driver layer does not need to care; it only needs to focus on the value of the KEY_DAT register. The indirect effect of this is that it “masks the differences in underlying hardware,” allowing the programs written in the driver layer to be universally applicable. Another function of the driver layer is to provide message interfaces for the upper layer. We use a concept similar to window program messages here. Some key messages can be provided, such as: pressed messages, released messages, long-press messages, step messages during long presses, etc. The application layer is the topmost program, where key function programs are written according to different projects. It uses the message interface provided by the driver layer. The programming thought at the application layer is that I do not care how the lower layer works; I only focus on key messages. When key messages come, I execute the function; when there are no messages, I do nothing. Below, I will illustrate the application of this design philosophy with a simple and common example. When adjusting the time of a stopwatch, it requires holding down a certain key while the time continuously increases. This is very practical and widely used in actual household appliances. Before looking at the following content, everyone can think about whether this is difficult. I believe everyone will answer loudly, “Not difficult!!” However, I will ask again: “Is this troublesome?” I believe many will say, “Very troublesome!!” This reminds me of the chaotic structure of the programs I wrote when I first learned microcontrollers. If you do not believe it, you can try writing one on the 51; this will further help you appreciate the advantages of the layered structure discussed in this article.
Project Requirements:
Two keys are allocated to P10 and P20, respectively, for “increase” and “decrease” functions, requiring continuous increase and decrease when the key is pressed.
Practical Application:
Assuming the keys are pulled up, the high level is present when no key is pressed, and low level when a key is pressed. Additionally, to highlight the problem, I have not included the debounce delay program here; it should be added in actual projects. There are various ways to pass parameters in C language functions; here, as an example, I used the simplest global variable to pass parameters. Of course, you could also use unsigned char ReadPort(void) to return a read key result, or void ReadPort(unsigned char* pt) to use a pointer variable to pass the address, achieving the purpose of directly modifying the variable. There are many methods, and it depends on each person’s programming style.
1) Start writing the hardware layer program to complete the mapping
#define KEY_MIN  0X01#define KEY_PLUS  0X01unsigned char KeyDat;void ReadPort(void){if (P1 & KEY_PLUS == 0 )  {    KeyDat |= 0x01 ;  }if (P2 & KEY_MIN  == 0 )  {    KeyDat |= 0x02 ;  }}
C language should be easy to understand, right? If KEY_PLUS is pressed, reading low level at port P10 means P1 & KEY_PLUS results in 0 (xxxx xxx0 & 0000 0001), meeting the if condition, entering KeyDat |= 0x01 sets bit0 of KeyDat to one, meaning KEY_PLUS is mapped to bit0 of KeyDat.
KEY_MIN is mapped to bit1 of KeyDat in the same way. If bit0 of KeyDat is 1, it indicates KEY_PLUS is pressed, and vice versa. There is no need to overthink it; mapping is just that simple. If there are other keys, use the same method to map them all to KeyDat. 2) Write the driver layer program
If we consider KeyDat as port P1, then isn’t this the same as the standard scanning program for learning boards? Correct, that is the purpose of the underlying mapping. 3) Write the application layer program
According to the message, the hardware layer must be separated; however, the requirements for the driver layer and application layer are not so strict. In fact, for some simple projects, it is unnecessary to separate these two layers; just respond flexibly according to practical applications.
In fact, writing programs this way is quite portable; by slightly modifying the hardware layer’s ReadPort function according to different boards, the driver layer and application layer can often use much of the code without modification, greatly improving development efficiency. Of course, this key program will have certain issues, especially when dealing with scenarios involving both normally closed keys and momentary keys. This is left for everyone to ponder; after all, problems always have solutions, even if the methods vary in quality.
Time-Slice Round-Robin Design Philosophy
Let’s start with a small example to introduce today’s theme. Imagine a basic appliance control board, which will certainly include at least three parts: LED or digital tube display, keys, and relay or thyristor output. The digital tube needs a dynamic scan of 10ms to 20ms, and keys also require about 20ms debounce delay. Have you realized that these times are actually running simultaneously?
Recall how our textbooks taught us about key debounce delays? That’s right, a dead loop, absolutely a stationary dead loop, using instructions to time. This naturally raises a question: what happens to other tasks while the microcontroller is stuck in a dead loop?
Only after scanning the keys can other tasks proceed, which results in the digital tube flickering due to prolonged scan time. Reducing the debounce time for keys does not solve the issue; imagine if we had many other tasks to run simultaneously? One solution is today’s theme: the time-slice scanning philosophy. This is certainly not the only method, but I have been using it and find it to be an excellent philosophy that can solve many practical problems. Boldly stating, the time-slice scanning philosophy is also one of the core ideas in microcontroller programming; whether you believe it is up to you.
Core Philosophy Implementation Process
First, use RTC interrupts to keep time; I prefer a short RTC interrupt period of about 125us, which is necessary for decoding infrared remote control codes. RTC timing is quite accurate, so we should make the most of it. Second, place three (quantity can be adjusted) timers in the RTC interrupt service routine (essentially counters). My habit is to use 2ms, 5ms, and 500ms as benchmark times for the entire system to call upon, so they must be accurate; adjusting them with an oscilloscope works well. Third, place a dedicated time-handling subroutine in the main program loop. (Note: microcontrollers do not stop; they continually run in a loop, which differs from what we learned in school; I was asked about this in an interview…). All time handling is done in the time-handling subroutine, which is very convenient. A microcontroller system needs to handle at least 10 to 20 different times and requires 10 to 20 timers, many of which must work simultaneously and asynchronously. If each were handled separately, it would be quite troublesome. Fourth, “programs run to wait, not stand still to wait”; this seems a bit abstract. An engineer I worked with once mentioned this concept, and I believe it is a fairly important idea in a time-slice system, hence the name. Fifth, let’s let the program speak; comments should be as detailed as possible; one can understand the code just by reading the comments.
Interrupt Service Routine Part Interrupt every 125us to generate several benchmark times.
Two Programming Philosophies for Microcontrollers
(1) The ref_2ms register continuously decrements by 1 every interrupt; it decreases 16 times, so the elapsed time is 125us × 16 = 2ms, which is what we call a timer/counter. Thus, we can use the system’s RTC interrupt to implement many required timing intervals. (2) Set the 2ms timing completion flag, which is provided for the time-handling program; this is a timer framework, and the 5ms timing is identical.
This program also uses a block framework, which is quite convenient, although it is unrelated to today’s topic; I will write about that later when I feel like it. The program above is the timer in the interrupt service routine, timing 2ms, 5ms, and 500ms; upon timing completion, the overflow is recorded by the flag_time flag, and the program can read this flag to determine if the timing has reached the specified interval.
Now Let’s Look at the Unified Time Service Subroutine
Two Programming Philosophies for Microcontrollers
The above uses a 20ms debounce timer for the key as an example; once understood, you can see that we can completely mimic that timer and place many more timers below, each counting simultaneously every 5ms. Whoever finishes counting first will turn off itself and set the corresponding flag for other programs to call, without affecting other timers! Thus, we can place many timers here; generally, having ten to twenty is no problem and fully meets the timing needs of a microcontroller system. The structure of a single timer is quite simple; first, check whether the timing flag is set to allow timing, then a dedicated register increments or decrements by 1. After adding or subtracting the corresponding value, when the appropriate time is reached, the timer turns off and sets the corresponding flags needed. At this point, we are almost done; we can obtain all the required timing intervals conveniently. Now, let’s see what the microcontroller is doing during this time. Only when the interrupt timing reaches 5ms or 500ms will the overflow flag be valid, allowing entry into the above timing program; at other times, it is performing other tasks. Furthermore, when entering the above timer, it is clear that it is not in a dead loop; it simply adds or subtracts a register and exits, consuming very little time—about 5us to 20us—without affecting the execution of the main program.
Next, Let’s Look at How to Call This
We previously discussed the debounce time handling for keys; now let’s see how to solve this issue using the methods introduced above.
Two Programming Philosophies for Microcontrollers
Essentially, it works like this: determine when a key is pressed; if not, exit; if pressed, start the debounce timing. On the second entry, control the checking based on the flag to see if the time is sufficient. Here, we are waiting, but as mentioned earlier, we are running to wait, not standing still to wait. Compared to dead loop timing, during the time before reaching 20ms, what is the microcontroller doing? In a dead loop, it would be waiting in place, doing nothing. However, looking at the above program, it merely checks whether the timing is sufficient; the specific timing is handled in the unified time subroutine. If the time is not yet reached, it exits and continues running other programs. Until the timing is reached, the microcontroller checks whether the flags flag_delay and key_flow meet the conditions and starts the key handling program. During this period, the microcontroller is free to run other tasks; it simply checks once in the main loop.
Two Programming Philosophies for Microcontrollers

This is the loop body used; all functions are written as subroutine forms, making it convenient to attach them as needed. Thus, in a general loop body, the microcontroller continuously executes this loop. If the entire program adopts the aforementioned time-slice scanning philosophy, the time to loop back is quite short, and it is somewhat similar to the idea of a computer, isn’t it?

No matter how fast a computer is, it does not process multiple tasks simultaneously; rather, it processes one at a time, cycling through very quickly, giving us the impression that it is handling multiple programs at once. I believe the idea I ultimately want to express is just this. With this philosophy supporting it, programming for microcontrollers becomes relatively easy; the remaining task is simply to focus on using code to realize our ideas. Of course, this is just one feasible method, not the only one.

Writing programs is an art; it is easy to write but challenging to write elegantly.

Leave a Comment