Prerequisite Modules
Mastering the development/debug environment (integrated development environment or “IDE”) is essential and you also need to have a good understanding of the C programming language to read and write code for this course and your own projects. This module will demonstrate the basics of working with IAR, go over fundamental C programming syntax, and show many powerful features of the IAR debugger. This particular project is configured to run in simulator mode, so no hardware connection is required.
IAR Introduction
IAR uses Projects inside Workspaces to organize code. Follow these steps to open the project:
- Make the “cprimer_simulator” code the active branch in Git
- Launch IAR.
- File > Open > Workspace… and navigate to ..\razor_sam3u2\firmware_simulator\iar_9_40_1\eie_cprimer_simulator.eww
A file tree of the project is on the left, and two panes are open that have code files. The basic project structure is the same as the code you will use for the rest of the modules so you can start to get familiar with it.
All source files included in the project are compiled / assembled when the code is built. Header files and other files can be put into the project tree for reference, but the compiler only looks for header files that are #included by the source files. We use a single master header file called configuration.h that is included by all source files. The configuration.h file then includes all the other headers needed in the program. There are arguments for and against this approach.
Open configuration.h by double clicking the file name in the list. Make sure the #define SOLUTION line is commented out as shown. Yes, the full solution to this exercise is included right inside the project, but if you cannot do this exercise on your own, you will have a lot of trouble with the rest of EiE.
IAR Debugger
The debugger runs code and gives you access to the memory contents of the processor (or simulated processor in this case). Press Ctrl-D or click the green “Play” button – this rebuilds the current code, flashes it to the processor, and starts the debugger. The code is NOT running yet — the “program counter” is at the first line of code that will be executed next but the program is “halted” or paused. The program counter is always shown by the green arrow in the C code window or the green bar in the disassembly window. The program counter is a special memory location that holds the address of the next line of code to execute. Note that the “Disassembly” window shows you the actual assembly instructions that the source code was compiled in to.
The project you are working with is already set up with the all the recommended debug view windows opened. You can open and close other windows that might have information you want — look under the “View” menu for all of the options and feel free to explore. Right now the main goal is to get familiar with the basics of running the debugger.
- Press the “Play” button or press F5 to run the code full speed.
- Once the code is running, press the “Pause” button to halt the debugger. This stops the processor at whatever instruction it is processing and automatically downloads all of the processor’s current information into the debug environment.
Notice that the available debugging tools change depending on the state of the debugger (running or halted):
You can hover over each button to get a description of the functions. When debugging, avoid pressing the green circles or the red X button – this shuts down the debugger and returns to regular editing mode. No problem if you do this, although if you are in a long debugging session and you accidentally kill the session you will have to repeat your work. The “Reset” button can cause similar grief, so be careful. There are definitely times when you want to press these buttons, though. In fact, go ahead and try them a few times to see what happens.
The buttons most often used during debugging are “Go” (F5), “Step Over” (F10), and “Step Into” (F11). It is useful to remember the function key shortcuts.
You should notice that the program just sits in a while loop right now. Find the “CYCLECOUNTER” value at the bottom of the the “Registers 1” window. This is a special simulator counter that shows how many instruction cycles have passed since you last halted the code. Watch this value as you run and halt the code a few times and also use F10 and F11 to step. Even though the code does not appear to be doing anything, it is in fact executing an instruction that simply branches to itself. CYCLECOUNTER should increase by 3 each time you single step. This is profoundly and fundamentally important to an embedded system, even if you do not yet know why.
By default, the simulator runs at about 10MHz — 10 million instruction cycles per second. Although that is kind of cool in itself, we point this out mostly to highlight a debugger feature: in any active debugging windows (i.e. any debugging windows you can see), values of registers / variables that have changed since the last time the code was halted will be red. This is very important to understand and make use of. “Halted” includes both running and then stopping the code with “Play” and “Pause” but also any time a Step Over or Step Into operation takes place. Both Step functions start the code simulating until a certain amount of code executes. More on that later. For now, Step Over and Step Into look like they do the same thing because the only thing they can do is execute the instructions for the while(1).
We are not going to discuss assembly language in the EiE Firmware program – if you’d like to, there are lots of resources including the EiE textbook. If you ever want to see the related assembly code for the C code that you write, activate the “Disassembly” window. The window shows that “while (1)” requires a single “branch” instruction which simply changes the execution address back to the label “main.” This happens to take 3 instruction cycles, as indicated by CYCLECOUNTER each time you single step.
For now, close the debugger and have main.c open in the IDE.
C Programming
The C programming language is a staple to embedded systems. C is well known as a “low level” language even though it is technically a high level language. C is compiled into assembly code just like C++ or Java or Python or whatever. C and C++ share many similarities, though it’s safe to say C does not have the complexity of C++. Today, C++ and object-oriented concepts are often used in C programming. The amount of syntax to learn for writing embedded firmware in C is fairly small. The examples below go over the critical aspects of C. They will work together to create a simple restaurant application with servers carrying drinks.
Basic Types and Naming Conventions
C supports 8-bit, 16-bit, and 32-bit integers both signed an unsigned. Since ARM processors are 32-bit, it is easy for the memory architecture to natively support all these integer types. C also supports floating-point numbers but in many cases embedded systems do not, so we will focus on integers.
There are quite a few different conventions for type names these days, the following list shows just a few for unsigned integer values that you might come across:
- unsigned char | UCHAR | uint8_t | u8
- unsigned short | USHORT | uint16_t | u16
- unsigned long | ULONG | uint32_t | u32
Type definitions are used to equate these to be the same — take a moment to look at them in typedefs.h. EiE uses the u8/u16/u32 style. There are also conventions for naming variables. EiE will use Hungarian notation which means the variable type is included in the variable name. Variable names should clearly indicate what the variable does.
Add an example variable in main():
Variables can be initialized when created or later on before they are used. Do not use an uninitialized variable, especially a pointer.
The scope of a variable is important to understand. The variables in functions live on the stack and will not be visible outside of the function call — the compiler enforces this. If you need information from functions in other parts of a program, you either have to pass it through function parameters, the function return value, or use global variables. Global variables are often avoided but are sometimes a solution to certain problems. We use some specific conventions for global variables that are meant to have scope visible to all other applications in the project, and “local globals” whose scope is only global within the task where it is defined. The difference between the fully global and locally global variables is accomplished by declaring the local globals as “static.” This is a different use of “static” than when using it to make local function variables persist between calls. You will see special sections in our source code for these globals.
Create an 8-bit integer in Main’s global variable section (around line 25) following the EiE naming convention for a “local global.” Be sure to use the static keyword here to limit the scope to main.
Preprocessor #define statements in header files are also used often. These are just symbols that get replaced by the value they hold when code is compiled. By convention, symbols are all UPPERCASE and use an underscore between MULTIPLE_WORDS_LIKE_THIS. We also always type cast them (i.e. put the intended type in parenthesis before the number) to set their size limits and proper usage.
Add a definition in main.h for the maximum number of drinks a server can carry on a tray:
Bit-wise operations
The smallest data type that C handles natively is a byte. A byte has 8 bits. Working with bits is very common in embedded systems. You have to use “bit-wise logic” using two arguments (the value you care about, and a value with the bits of interest) to access bits within a byte. Bit-wise operators are:
- AND: &
- OR: |
- XOR: ^
- Invert bits: ~
“Bit-wise” means the logic operation is performed at each bit position. Don’t forget that for an 8-bit digit, the lowest bit is called bit 0 and is known as the “least significant bit” or LSB. The highest bit in an 8-bit number is bit 7 and is called the “most significant bit” or MSB. A bit-wise “OR” would perform the logical operation between two numbers starting with the LSB all the way up to the MSB.
A good example is a “flag” register, which is a variable where the programmer assigns meaning to individual bits to keep track of settings or events in the program. The Server example code does not need any flag registers, so this is just an example. Feel free to try this in the code and delete it after.
Define a flag register and assign three bits to track some conditions in a system (arbitrarily we choose bits 0, 1 and 7). By convention, bit names are _ALL_CAPS with a leading underscore.
At some point in the code, the _FLAG_ERROR bit might need to get set which can be done by a bit-wise OR operation:
Later, the bit might be cleared which is done by a bit-wise AND with the bit-wise inversion operator:
A bit (or multiple bits) can be toggled with XOR. For the purpose of the example, let’s start with u8FlagRegister set to 0xF0 and then toggle two flags at the same time.
We can also check if a bit is set:
In this case, the bit-wise operation evaluates to 1 (true) if the bit at position _FLAG_BUTTON_WAS_PRESSED (bit 0) is set in the u8FlagRegister variable. Would this code run the /* Do something */ code?
Enumerated types
Enumerated types (“enum” for short) are important to help programmers use compiler-enforced rules to manage data. They also help to improve clarity and self documentation of code. Enum variables can be created directly, but we tend to declare enum types and then declare variables of that type. This helps to ensure the enum is carefully thought out and reusable across an application.
When defining an enumerated type, a list of values set the allowed names within the type. By default, the first name is assigned a value 0, the second is 1, and so on. Values can also be assigned explicitly in the list so they are not sequential. By convention we put the word “Type” at the end of the type name and use CAPITAL LETTERS for the values to imply the names themselves are constants. Add the following type definition to main.h:
Note that IAR implements a Boolean type using an enum with uppercase TRUE and FALSE. The actual data type in which this is stored is unknown, though likely an 8-bit unsigned integer.
Arrays and Strings
Arrays are extremely useful containers for data. An array is a continuous block in memory of the data type specified for the array. Arrays can be single dimension or multi-dimensions. When an array is defined, its size must be known. You can explicitly state it, or use an initialization list and the compiler will size the array to fit all of the init values. A classic one-dimensional array is defined like this:
Add these example arrays at the top of main:
You might have heard that “arrays and pointers are the same thing.” This is not true. There is some syntax where arrays and pointers are used interchangeably, but if you need an array, use appropriate array syntax and do the same for pointers.
Build and run the code. You will get “variable was declared but never referenced” warnings which you can ignore.
Once the debugger is loaded, press F5 to start the code and then halt it. Find the “Locals” debugger window and stretch it out so you can what is shown here:
Take time to understand everything you see and know how it got there:
- au32BigArray has been initialized to the values 5 thru 1.
- The first two elements of aeDrinkArray have been initialized but the last one is EMPTY.
- The labels BEER, SHOOTER and EMPTY are from the DrinkType typedef
- The Useless variable is not initialized but still shows up
The debugger allows you to change values in memory locations just by typing (the code must be halted). For example, you can change “5” to “5000” by clicking and typing. Or you if you prefer an array of BEER, you can change that entering either “BEER” or “1” into those locations. You cannot change the Useless variable because it was not initialized and never used, so it actually doesn’t exist (notice it does not have a “Location” which means there is no memory assigned to it).
Strings in C are simply arrays of u8 (CHAR) that by definition contain a NULL character at the end. NULL is used because it is a non-printable control character (see www.asciitable.com to learn more).
This results in:
Since a simple string in C is just an array, we can describe it like any other array. Some general things to know about arrays are:
- The first element of an array is index 0.
- The values inside an array are accessed (indexed) using square parenthesis (e.g. aString[1] would access the element with ‘h’).
- Use the “sizeof()” operator to get the size of any array e.g. sizeof(aMyArray) — it is returned in bytes. This is a “compile-time” operation which means the number is calculated and subbed in when the code is compiled.
- If an array type is something other than a single-byte storage type, then the number of elements is NOT equal to the size. The number of elements is sizeof(aMyArray) / sizeof(array_type).
- The last element of an array is [the number of elements – 1].
- Memory addresses allocated for an array are always sequential.
- If you include string.h in your code, then you have access to standard C string functions.
Pointers
A pointer is a variable that stores an address. Every byte in an ARM microcontroller has an address. If you know the address of a value that you want to look at, you load a pointer with the address of interest and then “dereference” the pointer (i.e. go look at that address) to get the value. Programming embedded systems at a low level is a great way to get a better understand of pointers. The pointer itself is a 32-bit value holding a 32-bit address (at least on our 32-bit ARM processor). When defining a pointer, you must tell the compiler what variable type the pointer will be pointing to which can be any other variable type like a single byte, a struct, or another pointer. Here is what you should know:
- Declaring a pointer is done with the * operator, e.g. u8* pu8Pointer.
- Setting a pointer to a variable is done by assigning an address to the pointer, e.g. pu8Pointer = &u8SomeVariable where the ‘&’ is the “address” operator in this case.
- When you increment a pointer, the value it holds (i.e. the address it is currently holding) will increment by the size of the variable type it is pointing to.
Try the following code at the top of main():
Start the debugger and use single step (F11) to run the first 4 lines of code (up to but not including *pu8Example++). Try to predict what each line of code is doing as it runs.
The variables are initialized to 0xA5 and 0xffff. The two pointers are given the addresses of variables they point to. Examine the Locals debug window and find the following:
- The Location (address) of u8Test is 0x20081f98; the Value is 0xA5 per the initialization.
- The Location (address) of 32Test is 0x20081f9c; the Value is 0xffff..
- The Value of pu8Example is 0x20081f98 which is the address of u8Test; you can expand pu8Example in the window to see the value “0xa5” at the pointer target location.
- The Value of pu32Example is 0x20081f9c which is the address of u32Test.
- The two pointer variables are in processor “registers” (R0 and R1) which are special memory locations on the microcontroller.
Single-step twice to increment the test values using their pointers – watch the variables increase to 0xA6 and 0x10000.
Single-step two more times to advance the pointers. Notice that pu8Example increments by one byte address (0x20081f98 to 0x20081f99), but pu32Example increments by 4 bytes (0x20081f9c to 0x20081fa0). Also notice that pu32Example has a “*” operator so while you might expect the line of code to increment u32Test, C’s operator precedence assigns ‘*” and “++” the same precedence and thus works right to left so pu32Example is incremented and the “*” operation does not actually do anything. It would be much clearer to simply write pu32Example++ instead.
What are these pointers pointing at now? pu8Example is pointing at address 0x20091f99 which isn’t a location that holds meaningful data. pu32Example is pointing at 0x20081fa0 which happens to be the start of the aeDrinkArray.
In a few lines of code, we have captured the essence of pointers and also the essence of hacking and/or potentially wicked pointer-related bugs caused by pointing to memory locations that they should not be looking at. The development environment is completely open and you have full access to anything you want! But remember: with great power comes great responsibility! You do not need to understand everything that is happening here. Just remember this exercise and the steps taken to show how pointers are loaded with addresses, dereferenced, and adjusted, but most importantly how to use the debugger window to observe what is happening.
Structs
Structs are used to group related variables together. They also make for very efficient passing of parameters into functions, since a pointer to a struct is a single 32-bit address to pass even though the information at the end of that pointer could be quite sizeable. Structs improve readability and imply relationships between variables which also improves self-documentation.
Like enums, structs can be declared explicitly or defined as struct types from which variables of that type are created. If a struct typedef is declared and one of the member variables needs to be the same struct type (e.g. to allow a member of the struct to point to another struct of that type), then it has to be declared “void” or else the compiler will give an error.
We will be creating a linked list in this exercise, so define a struct for a drink server which will make up members of our linked list. Since it is a typedef it goes in the main.h header file:
If a struct is declared in the local scope, the dot operator is used to access the struct’s member variables. If a struct pointer is used, the arrow operator accesses the struct’s member variables.
Code the following in main before the while(1) loop for a quick example:
Restart the debugger to build and reload the code. Try setting a breakpoint at the psServerParser = &sServer1; line by left clicking the margin next to the code – you should see a red dot and highlight appear. Alternatively, left-click the line to set the cursor there, then right-click the line and select “Run to Cursor.”
Now do the following:
- Activate the “Watch 1” window in the top right debugging space.
- Double-click the “sServer1” variable name to select it and either drag it up into the Watch 1 window, or copy and paste it.
- Repeat to add psServerParser and u8CurrentServer to the Watch1 window. Even though you can see these values in the Locals window, that window is already starting to get crowded
- Expand sServer1 variable to see the struct members; further expand the struct members asServingTray and psNextServer. You now have full few into the variables!
- Try to predict what will happen when you step through the 3 lines of code. Test your prediction and understand what is happening.
Functions
A function (or sub-routine) is a chunk of code that performs a specific task when it is called. In some cases it will return a result, in other cases it just makes something happen. The basic syntax is:
If a function does not return anything, then return_type is “void” and you do not need an explicit “return” statement at the end. If a function does not take parameters, then arguments should be “void.”
You can spend a lot of time learning how functions are actually implemented in C and how that implementation will use processor resources. To keep things simple, we will recommend that you minimize the number of arguments passed, and try to make functions very purpose-built so they do only one thing. There are also lots of different styles to set up and document functions. We use a “function prototype” in the header file, and code the function itself in the associated source file. We document our functions with a brief description and also have a “Requires / Promises” notes. “Requires” defines the system conditions assumed when the function is called and details the incoming function arguments. “Promises” indicates what system conditions are true when the function exits and what (if anything) is returned.
Add the following function to the “Function definitions” section of main.c to initialize a new server. Read the function documentation to see if you understand what is supposed to happen, and then read the code to confirm it does what you expect (it’s not complete yet)
Copy the function definition line and paste it into main.h with a semi-colon at the end.
This is called a “function prototype” which some developers like and some don’t. We use them in EiE as it’s nice to have a summary of all a source file’s functions in one place in the header file. There is also an option set in IAR that requires them to be present, though this can be disabled.
Remember that functions can return a value or be “void” if they don’t return anything. The function arguments can be anything you want, but in an embedded system the number of arguments should be limited. EiE uses a postfixed underscore for function parameter names to help distinguish them from local function variables. You definitely do not want to be passing huge structs or dozens of variables. Keep functions tight and concise. Pass pointers to any large datasets the function needs. In this case, psServer_ points to the main data structure in the program, and you should note that the “local global” Main_u8Servers will be used per the information in the header.
Loops and conditional execution
The processor will sequentially execute code from the first line in flash unless it is told to do something else. There are various ways to change the program flow including looping and if/else structures which are fundamentals in any programming language. C has “for” loops, “while” loops and “do/while” loops. It also uses “if/else” and “switch/case” structures.
For loops have the structure:
“While” loops have the structure:
“Do/while” loops have this structure:
Both “for” loops and “while” loops might run 0 times or run continuously until the exit condition occurs, but do/while loops always run at least once. All loops are potentially infinite, so it is a best practice to have at least one logical condition that will terminate the loop (like a timeout) if it depends on an event, external input, or anything else not deterministic.
If / else if / else structures are conditional statements that tell the processor to evaluate a condition and make a decision. The structure is:
Add the missing code to InitializeServer() to initialize all the values on the serving tray:
The Heap
The Heap is an allocation of RAM (the size of which is under control of the programmer) separate from the stack. Two common uses of the heap are for variables that need to persist and be accessible outside of function calls and also for dynamically allocated memory using malloc(). C has some notoriety when it comes to malloc() and memory leaks. A memory leak occurs when memory on the heap is allocated using malloc() but the program (or programmer) does not free (release) it. Since microcontroller-based embedded systems are often very resource-limited, using the heap must be done very carefully. In fact, due to the relatively limited amount of memory space available, it is usually better (and much safer) to use static memory locations instead. Discussion of why is beyond the scope of this part of EiE, but it is discussed at length in the EiE textbook.
For the sake of example, dynamic allocation will be used in this exercise. For now, you don’t have to type anything. Just try to follow the discussion.
To use malloc, you need a pointer to the object type you are trying to create. Call malloc with the size of memory you need. If malloc fails, it returns NULL so this is what must be checked.
When you are done with the memory, it must be freed back to the heap. This can get difficult if the memory is allocated in a function and then passed to other parts of the code. In the case of our server objects, the new memory contains a pointer to other memory, so if the object is removed, you must also preserve the pointers to the other locations. It helps to draw a picture.
If Object 2 is to be removed, set a temporary pointer to it, connect Object 1 to Object 3, then delete Object 2. The edge cases are special cases that must be handled correctly as well. The code for our solution to the exercise for removing a server node looks like this:
When learning how pointers work and even when you have more experience and are writing new pointer operations, drawing pictures and carefully stepping through each operation can really help. When you code this exercise and test it, you can use the debugger to see exactly what is happening with all the memory locations and pointers. If you have done some C programming before and had trouble with pointers, using the debugger like this might help a lot to clear things up.
Printf and Scanf
There is no built-in printf and scanf functionality in an embedded system. Without a terminal, keyboard and monitor, how could there be? If you have programmed on a PC, you have likely used printf and scanf and a Windows terminal to read and write characters. What might surprise you is the complexity of implementing printf() and scanf(). IAR includes the “front-end” of these functions that will operate on strings, but it is up to the developer to write a driver to read and write these strings at the hardware level. The easiest and most common place is to a UART peripheral that goes to an RS-232 connection and then to a Windows terminal. The USB-to-Serial adapter that connects to the development boards is the hardware to accomplish this, but without supporting firmware nothing will happen. You will explore this in the Debug module, and you can read how the driver was developed in the EiE textbook. Until this functionality is coded, the debugger is the saving grace.
Exercise
Now it’s time to put everything together from this module to build a simulated restaurant where you can order drinks. The servers will be ServerType objects in a linked list. There are a limited number of servers (MAX_SERVERS), and each can carry a limited number of drinks (MAX_DRINKS). A boolean variable will be used to “order drinks” since we do not have any physical input to the system. If you are thirsty, halt the code and set this to 1. There will also be an output string location to post messages — a very crude printf() workaround. A breakpoint will be set where the message is updated so the code will halt and you can read the message.
The following details should be implemented:
- The main loop will run infinitely but after each iteration a pause is inserted to simulate 1ms of processor sleep time. The variable u32LoopCounter will track how many loops have run and function as our system timer.
- The variable bOrderDrink is set to TRUE through the debugger to request a drink. The type of drink is selected based on ((u32LoopCounter % 4) + 1) to randomly select one of the 4 drinks defined in DrinkType. Drinks will be added to the first server in the list until the server’s tray is full. The message “Drink ordered” is printed.
- If there is no space for drinks, a new server is added up to MAX_SERVERS. The message “New server added” will be printed when a new server is added. A function “CreateServer” should be written to do this with a pointer-to-pointer argument back to the main server list just like InitializeServer(). If no more servers are available, the message “No free server” will be printed.
- A drink is automatically removed from a tray about every 3 seconds (every 3000 loop iterations)
- If a server’s tray becomes empty, the server is removed from the list. The message “Server removed” is printed.
The function header and comments from CreateServer() are shown here:
ry to write this function based on the comments. Use the debugger to verify the server is added correctly.
Next, tackle the main code. Like all embedded programs, the main code exists inside an infinite while(1) loop so it is always running. There are only two main things that happen:
- The boolean variable bOrderDrink gets set (by the user halting the code and manually changing it. This means that the drink needs to be added to an available server tray. If all the serving trays are full, then a new server had to be added. If there are no more available servers, the drink ordered is cleared. In all these cases, a message should be queued to show the result of the order.
- After a certain period of time, a drink should be removed from one of the server’s trays. To make it sort of interesting, find a way to randomly select a server and drink. In the solution provided, the server chosen is based on the last drink chosen. Be careful to avoid moving outside the linked list. When a drink is removed and the server’s tray is empty, the server should also be removed. Once again, a message should be queued and flagged to indicate what took place.
For the first part, start by handling the case where a drink was ordered and it fits on the current tray. The skeleton code might look like this:
If there was no space available on the current server’s trays, see if there is another server available. If not, the order can be ignored (perhaps the angry customer leaves).
For the second main task of clearing drinks, start by selecting (aka putting a pointer on) the server from which a drink will be removed. We are relying on the rest of the code to function properly to ensure that any server in the list does not have an empty tray. The full code is shown here so you can review, understand, and then copy it (if you want to):
Now remove the first drink you find on the tray. If you’re really into this, you might suggest that’s dangerous because the person who has the third drink on the tray might never get their drink if the server is very busy. We won’t worry about that, but it is an excellent example of the type of problem that can arise from code that functions perfectly technically but does not perform well in real life.
Once the drink has been removed, the server needs to be removed if there are no more drinks on the tray. If the server is removed, it must be done carefully to deallocate the memory and not break the linked list. Draw pictures and don’t loose pointers!
As a final little step at the end of the main loop, check if a new message has been queued. We will use the debugger to read this message from memory, so set a breakpoint that will trigger every time a new message is waiting.Y ou can see the loop delay time as well — if the program is running too quickly, the value can be increased.
There is no doubt it is difficult to plan and write a program. It is probably more difficult to write one following the framework of someone else’s code. Write small sections of code and then test each as thoroughly as possible. Most syntax errors will be caught by the compiler and pointed out, but logical errors will not. Since this is a pointer (and pointer to pointer) exercise, addressing errors are likely to be the worst issue. But also check that your overall logic is headed in the right way.
Once your program is working, run and test as you follow along with the debugger. Use three breakpoints: one when a drink is ordered, one when a drink is served, and one when a new message is queued. Run to these breakpoints and then single-step to verify that the operation works as it should. Remember to order a new drink when you get a message that the last order has been processed. Test to get at least two active servers (which takes 4 drinks) and make sure the drink trays fill up correctly and the linked list grows as expected. Then let the code run until drinks are served and one of the servers is removed to check that the list node is removed properly without leaking.
However you approach this, the debug environment should be carefully set up so that all of the server information and variables needed are visible. Compare your solution to the one provided. What’s better? What’s worse? There’s at least one bug in the solution relating to the “Server number” that gets assigned. Are there more bugs? What happens if the code runs a long time with hundreds of drinks ordered and served?
From a C syntax perspective, was there anything you did not understand and need help with? That is something that is relatively “easy” to help you with. If you had trouble from a logic perspective, this is something that will mostly be up to you to work through and figure out how you can visualize a programming problem and break it down into manageable, codable, and testable pieces. Only experience will make you better at this!
[LAST UPDATE: 2023-SEP-19]