By Derrick Klotz – Is it better to use assembly or the C programming language? This question has been debated for so long that I‘m surprised that it still gets asked. Everyone has their opinion, so I’m going to tell you mine. The primary benefit of using C is its flexibility. The biggest problem with using C is its flexibility.
One of the best features of C is that it is not tied to any particular hardware or system. This makes it easier to write programs that will run on practically any machine without requiring dramatic code changes. When you work with microcontrollers (MCUs), you spend a lot of time writing software. I estimate that at least 80 percent of the time required for MCU project development involves creating, testing and debugging the software. Few of us can afford to waste time rewriting the same software just to get it to run on a different MCU. Migrating field-proven, reliable algorithms to another MCU, commonly referred to as “porting code”, happens more often than you think, or can predict. It is usually a result of the evolving requirements of your projects while trying to reuse software that you know already works. Sometimes there are good reasons to stay with a particular MCU. But the inconvenience associated with code migration is not a good reason – given the costs of writing and testing software, it‘s a fact of programming life.
C is often referred to as a middle-level computer language as it combines some of the abstraction elements of other high-level languages with the functionality of assembly. C is a structured language that is relatively compact, has very loose data typing, and supports low-level bit-wise data manipulation. This last feature is absolutely critical when writing the software needed to manipulate the individual control bits for an MCU’s integrated peripherals.
I have been working with 8-bit MCUs for a very long time and I am very comfortable and proficient with assembly language. I have taught structured assembly language methods for MCUs and there are some techniques that can potentially create havoc with a C compiler. Executing code that resides on the stack or shifting bits into and out of the CPU’s Carry Flag are just two examples of situations where using assembly language can be easier than using C.
Assembly language gives you a lot of freedom and control, which can be a lot fun. But it is very difficult to create programs in assembly language that can be easily ported to a dissimilar MCU platform – in other words, from 8-bits to 32-bits. Programming in C won’t necessarily make this transparent, but it can make it much easier.
When compared to assembly language programming, code written in C can be:
Notice that I state “can be”. There is nothing magic about any programming language that you choose. Your programs will not suddenly become “better” just because you’re programming in C. I have seen programmers write horrible code in assembly language and horrendous code in C.
A C compiler is no more efficient than a good assembly language programmer. It is absolutely possible to create software using C that is about as efficient in memory utilization as equivalent code written in assembly language; you simply need to be aware of what your code is telling the compiler to do. I regularly use the compiler’s disassembly tool to visually check its assembly language output. This has taught me a great deal about how to write efficient software in C. I’ve even learned a few interesting assembly language tricks. It has taught me how to think like the compiler. When you get into the habit of thinking this way, your C source code becomes efficient from the beginning, instead of being an afterthought.
The C programming language retains the basic philosophy that programmers know what they are doing. C only requires that they state their intentions explicitly. Regardless of the programming language, your code should be clear, concise, correct, and commented.
There appears to be popular misconception that C is self-commenting. It is not. I have heard many arguments for not commenting code – none of them good. Like it or not, writing software requires discipline. Function and variable names written in C should be sufficiently descriptive such that their purpose is reasonably obvious. For example, the function name “call_me_a_taxi ()” conveys no information as to its purpose, while “toggle_GPIO_for_heater_elements()” does. For the most part, comments should not be used to explain what the code is doing. Comments should be used to explain why the code is doing what it is doing. It may require some extra time to create accurate comments, but in the long run it will save you time. Comments reflect your thought process when the code was created. They help you avoid wondering “what was I thinking?” when you review your code six months later to update a feature or apply a bug fix.
Stop using int!
When we are first taught to program in C, our code usually executes on a computer with virtually unlimited memory and disk space under the control of an operating system (OS) that has no concept of real time. The only thing that matters is getting the software to work – and submitting it on time so that the professor can grade it. The environment changes dramatically when you migrate from the desktop to embedded MCU programming.
To start with, in an MCU nothing is unlimited. The amount of memory available to your program is fixed and you had better know how much you have. The needs or your application will decide the internal peripherals and the number of I/O pins required to get the job done. Computational performance is determined by the system clock frequency and the size of your Central Processor Unit (CPU) – i.e., 8-bit, 16-bit or 32-bit. You will need to become very aware of real-time and how to implement it. In a future blog entry I will explain how interrupts are used to keep your software synchronized with the real world.
The C programming language has several native data types, the basic ones being char
, short
, int
, long
, float
, and double
. With the exception of char
, these data types default to being signed. The ANSI C99 standard also supports a Boolean data type, with a common declaration as bool
. Data declared as bool
can have one of two values – either “true” as one, or “false” as zero. Interestingly, ANSI C defines each data type as having a minimum size, but does not specify their maximum. Specifically, ANSI C states that:
sizeof(char) ≤ sizeof(short) ≤ sizeof(int) ≤ sizeof(long)
As a result, a C compiler for a 32-bit MCU can reserve a 32-bit memory location in RAM for a variable declared as type bool
. The reasoning for this is that C tries to support the “natural” register and memory size of the processor. That being said, I hope that we can all agree that using four bytes for a variable that can be either “true” or “false” is an incredible waste of RAM.
For most 8-bit and 16-bit MCUs, their respective C compilers will treat int
as a 16-bit data type. 32-bit compilers will normally regard int
data as being 32-bits in size. This ambiguity is not acceptable when we create software for MCUs where RAM is a limited resource. In addition, if the embedded software application makes heavy use of math, the different size for int
can create resolution errors in the algorithms. When I port a program from an 8-bit MCU to a 32-bit platform I expect to see some changes in memory utilization. But I don’t want to see a huge jump in RAM usage.
Consider the following code stub example:
int n;
for (n = 0; n != 100, n++)
{
…
}
This is a pretty normal software loop that most of us have seen numerous times. As simple as it is, this code is sloppy. For starters, as I described above, the size of n
is determined by the size of the target MCU. We don’t need to use a 32-bit variable to count up from 0 to 100. Secondly, by default int
is a signed data type. Signed variables are manipulated using signed arithmetic. We definitely don’t need to use a signed 32-bit variable to count up from 0 to 100. One of the first lessons in improving your code efficiency involves making an effort to use appropriate variable sizes.
Most of the data that we manipulate with MCU software tend to be unsigned. It has long become customary to use the following alternative data type definitions when we need to specify the size of unsigned data:
typedef unsigned char uint8_t;
typedef unsigned short int uint16_t;
typedef unsigned long int uint32_t;
Some compilers may subtly modify these type definitions, but the end result is the same. Data that is declared as being of type uint8_t
is an unsigned integer that occupies one byte of memory.
Here is my more efficient version of the software loop shown above:
uint8_t n;
for (n = 100; n != 0, n--)
{
…
}
Notice that we know that n
occupies exactly one byte, regardless of the target MCU, and that it is unsigned. I have also changed the control parameters to count down from 100 instead of the original which counted up from 0. I do this so that the test condition for my version is a check for when the value of n
is equal to zero. As any good assembly language programmer knows, all CPUs have a test for zero. That’s why they have a Zero Flag. No standard CPU has a test for 100, so testing for that value requires several instructions to perform a comparison operation.
The CPU in Freescale’s 8-bit S08 family of MCUs has an assembly language instruction intended specifically to make these common software loops as efficient as possible – DBNZ. The DBNZ opcode performs a “Decrement and Branch if Not Zero” operation. DBNZ will decrement an unsigned 8-bit variable and will perform a relative branch if the result is not zero, which is exactly what is needed by my modified software loop. Quite often, the variable n is a local variable, which means that it is located on the stack. DBNZ has an addressing mode that supports having the variable accessed relative to where the stack pointer is referencing. In a single, non-interruptible instruction, DBNZ performs the “n != 0, n--
” portion of my software loop. Now that’s efficient!
Obviously this is a very simple example. But inefficient methods have a bad habit of multiplying and eventually creating larger problems. Understanding the assembly language impact of your C source code will help improve its efficiency.
Understanding RAM
Knowing where variables are located in RAM is just as important as using the correct (i.e. smallest possible) size for each variable. In the C programming language variables have “storage” and “scope” and these can be either “global” or “local”. The term scope indicates how visible the variable is to the rest of the program. There are fundamentally four different types of variables (referred to as “storage classes”):
For global storage each variable has a permanent absolute address location in RAM. For local storage each variable resides on the stack and is referenced via an offset from the Stack Pointer. When you classify a variable as “register” you are asking the compiler to use one of the CPU’s internal registers to store the variable. However, if the compiler finds this to be not possible the variable will be implemented as an “auto”.
The stack is a place for temporary variable storage. By manipulating the Stack Pointer, each function creates this storage when it starts and releases it upon termination. Hence, local variables are temporary and only occupy memory for as long as the function that uses them is active.
A variable classified as “extern” is commonly referred to as a global variable. Global variables have global scope and are accessible by all software modules. A variable classified as “auto” is commonly referred to as a local variable. Local variables have local scope which is defined as everything surrounded within a complimentary set of curly brackets (i.e., { … }). Their local storage prevents other functions, those defined outside of the curly brackets, from accessing them. A variable defined within a set of curly brackets and without a class identification will default to “auto”, which explains why we rarely see the term “auto” in software listings.
Static variables seem to be underutilized by many programmers as they don’t seem to fully understand how to take advantage of them. Static variables have global storage which means that their address location in RAM is permanent. The compiler controls their local scope making them accessible only to the function or software module within which they are declared. In other words,
Notice that I use the phrase “directly accessible”. I have heard many times that the use of global variables is often unavoidable. This may be the case in some circumstances, however I find that programmers often overlook the powerful ability to manipulate and manage variable pointers within C. A static variable declared within a module can be accessed by an external function if a pointer to this variable has been passed to it by one of the internal functions. Judicious use of static variables will help prevent functions from corrupting each other’s data and increase overall code reliability.
I will follow up with these concepts as I demonstrate how to create some bare metal application code. I’ll be using C to create the code and occasionally take a look at the resulting assembly language in order to understand the details of how the MCU is being manipulated. It is much easier to write good code in C which can be converted to efficient assembly language than it is to write efficient assembly language code manually. Using the C programming language provides the opportunity to write code that is more portable across multiple hardware platforms. This is why I prefer to start my projects with C.
文章评论(0条评论)
登录后参与讨论