CSCI 2330
Foundations of Computer Systems

Bowdoin College
Spring 2024
Instructor: Sean Barker

Lab 2 - Program Pointers

Release Date:Friday, February 16.
Acceptance Deadline:Monday, February 19, 11:59 pm.
Due Date:Tuesday, February 27, 11:59 pm.
Collaboration Policy:Level 1
Group Policy:Pair-optional (you may work in a group of 2 if you wish; review the group work guidelines)

pointers

The main objective of this lab is to make you familiar with the memory model used by computers, primarily using the concept of pointers. Your task is to write a command-line parser in C. Your program will implement one part of a shell program: splitting a command line into its meaningful parts. While conceptually simple, this lab will also demonstrate some of the "under the hood" complexity that is involved in managing strings.

A secondary objective of this lab is to give you experience programming to a specification. A program specification (such as this writeup) says exactly how a program is supposed to behave. Read the specification closely and literally! You may think that your program is "done" when, in fact, it may fail many of my tests due to some aspect of the specification that seemed insignificant. Assume that all aspects of the specification are important and be sure that your program follows them precisely! Read, reread, and test your code against the complete specification; don't fall into the trap of running a few basic tests and then deciding you're done.

Lab Overview

The shell is the program that reads and interprets the commands you type in a command-line environment. In this lab, you will implement one component of a shell: the command parser. The parser takes a command line comprised of a program name and its command-line arguments (such as 'gcc -Wall file1.c file2.c') and converts it to a command array, which is an array of the strings comprising the command line (such as ['gcc', '-Wall', 'file1.c', 'file2.c']). This process is similar to the behavior of the split function available in many programming languages. However, you won't be allowed to just use a library function like split to do the heavy lifting, and must implement the core parsing functionality yourself. In doing so, you will confront how strings are represented and manipulated in memory (and will also gain an appreciation for how much complexity higher-level programming languages are hiding from you)!

Below are formal definitions (i.e., specifications) for command lines and command arrays. Following these definitions are discussions of the lab files and tasks to be completed.

Command Line Specification

A command line is defined to be a null-terminated string containing zero or more words separated by one or more spaces, with an optional ampersand ('&') after the final word. This ampersand is used to denote a background command. In particular:

Note that the rules above are slightly more restrictive than in a real shell program (just for simplicity's sake).

Command Line Examples

Here are two typical command lines:

Since the definitions allow for any spacing between words, the following lines are all valid variations of the first example above (containing the same three words):

Similarly, the following are all valid background command lines:

However, the following are all examples of invalid command lines:

Command Array Specification

Your parser will convert a valid command line into a command array (an ordered array of strings representing the words of the command line) terminated by a NULL address. Recall that a string in C is not a special type; it is just an array of char terminated by a null character '\0' (the character with ASCII code zero). A command array is thus an array of pointers to arrays of characters, and therefore has the type char**. All arrays in this structure must be null-terminated. Note that there are two different notions of null here: the null character '\0' used to terminate a string, and the special value NULL representing a null pointer that is used to terminate the words array.

warningIMPORTANT: The null character '\0' is not the same as the null pointer NULL. The former is a char, and is therefore one byte in size. The latter is an address (i.e., pointer), and is therefore one machine word in size. Confusion often arises because both have a numeric value of zero, but remember that they are different types with different sizes. Adding to the confusion is that we say that strings are null-terminated, but this only refers to the null character, not the null pointer.

Also remember the difference between literal characters and literal strings. Characters in C are specified in single quotes ('a') and have type char. Literal strings are specified in double quotes ("a") and have type char*. As such, 'a' is not the same as "a". The former is a one-byte char with ASCII value 97, while the latter is a pointer to the start of a null-terminated char array of length 2 (one byte for 'a' and one byte for '\0').

Here is an example command array for the command line string "ls -l fcs-labs":

Command Array:             Null-Terminated Strings
Index   Contents           (stored elsewhere in memory)
      +----------+
    0 |  ptr  *----------> "ls"
      +----------+
    1 |  ptr  *----------> "-l"
      +----------+
    2 |  ptr  *----------> "fcs-labs"
      +----------+
    3 |   NULL   |
      +----------+

Here is the same array drawn another way and showing how the array is arranged in memory, with each element's offset from the base address of the array. Addresses grow left to right, and are assumed to be 64 bits (8 bytes), so indices are related to offsets by a factor of 8.

Index:        0       1       2       3 
Offset:   +0      +8      +16     +24     +32
          +-------+-------+-------+-------+
Contents: |   *   |   *   |   *   | NULL  |
          +---|---+---|---+---|---+-------+
              |       |       |
              V       V       V
              "ls"    "-l"    "fcs-labs"

Note that although we draw "strings" in the above pictures, this is an abstraction. Each string is actually represented by a null-terminated array of 1-byte characters in memory, as shown below for the first word. Since each element is one byte, the addresses of adjacent characters differ by 1 and the offset is identical to the index.

Index:      0     1     2
Offset:  +0    +1    +2    +3
         +-----+-----+-----+
         | 'l' | 's' |'\0' |
         +-----+-----+-----+

warningIMPORTANT: While the null characters at the end of strings and the NULL pointers at the end of command arrays are fulfilling the same basic purpose (indicating the end of the array), they exist for different reasons. Strings in C are always null-terminated, but regular (non-string) arrays are *not*. The only reason command arrays (which are not strings) have NULL pointers on the end is because that's the way we've defined command arrays.

Lab Files

The provided files for the lab consist of the following:

Summarizing the above, your command parser is essentially a software library: it provides functionality for other programs rather than being a standalone program itself. As a result, your command.c file does not have a main function and cannot be executed directly. Instead, the executable you will run is derived from command_test.c, which does contain a main function that uses the parsing functions implemented in command.c. Running make will build the test executable, which can then be executed by running ./command_test.

Your tasks will be (1) completing the parsing functions in command.c, and (2) writing additional tests within command_test.c to exercise the parser (which you should do in tandem with implementing the parser).

Function Specifications

You must write four functions in command.c (plus any necessary helper functions), which must match the function definitions provided in command.h. Summaries of each function are provided below:

In writing your functions, you are required to follow certain coding rules, which are specified in the next section.

Coding Rules and C

This lab may be the first time you have coded a real program in C. Syntactically, C is very similar to Java, so you should have minimal difficulty with the basic building blocks (functions, conditionals, loops, etc). Conceptually, the primary challenge of this lab is grappling with pointers.

For general advice about proper program design and style, consult the CSCI 2330 Coding Design & Style Guide. You should review this guide before beginning to code and consult it as a general reference.

In addition to following good design and style principles, you are required to follow the following coding rules for this assignment:

  1. You may not use any standard C string processing functions. Using printf is fine, but anything declared in string.h is not. Instead of using such functions, you must implement all string processing yourself.
  2. You may not use any array notation in command.c, and should have no expressions of the form *(a + i) (which is effectively equivalent to writing a[i]). Instead, you should use idiomatic pointer style for working with your strings, as described in the next section. Practically speaking, this restriction is just notational as opposed to functional, but it will force you to think about your structures explicitly in terms of pointers. Note that it is fine (and probably advisable) to use array notation in your test code within command_test.c.
  3. Don't use the bool datatype. In a normal C program, you can include the stdbool.h file, which allows you to use the quasi-boolean datatype bool (even though this is still just a number internally). Don't use this type; stick to the regular C convention of using int when you need a boolean, with zero meaning false and nonzero meaning true.
  4. Your code must follow standard memory safety rules:

    • No use of uninitialized memory contents.
    • No out-of-bounds accesses.
    • free only called on blocks previously returned by malloc, and only once per block.

    Note that violating one of these rules does not necessarily mean that your program will crash (e.g., if you access uninitialized memory), so the absence of crashes does not necessarily mean that you are following all the rules. Use the valgrind tool to check for many kinds of memory errors.

  5. You should abide by these usage conventions between the command library itself (the functions in command.c) and any code that calls on these functions (such as command_test.c), otherwise known as clients of the command library:

    • Clients are considered to own and manage the memory that contains the command line strings. This means that command library functions must never free nor modify command line strings (i.e., don't change any string passed to one of your functions), with the notable exception of command_free (which should, by definition, free those strings).
    • Command array structures must be allocated dynamically using malloc within command_parse and returned to the client. Clients should not modify command array structures returned from the library and you may assume that they will not do so.
    • Your library functions should not allocate any memory except that which is returned as part of the command array structure. In other words, no extraneous calls to malloc.
    • You may assume that clients will call command_free at most once on a given command array structure.
    • Assuming that if command_free is eventually called on every command array structure, then all memory allocated by the command library functions should be freed. In other words, your code should not leak memory.
  6. Don't make any assumptions about data type sizes in your program. For example, remember that while an int is usually 32 bits, this isn't required by C and may vary across machines. Instead, use the C sizeof operator whenever you need the number of bytes in a particular data type. For instance, you should write sizeof(int) instead of 4, and sizeof(char) instead of 1, and so forth.
  7. Lastly, treat compiler warnings like errors. Your code should not output any warnings when compiled, even if these warnings do not prevent the program from working. To make this easier, the compiler flags specified in the provided Makefile will cause the compiler to reject your program if there are any warnings.

Idiomatic Pointer Style

One of the coding rules listed above is a prohibition on using array notation (i.e., square brackets) in your parsing library. Instead, your code should use only pointers and pointer arithmetic. In general, choosing pointer arithmetic over array indexing will not always result in the clearest code, but here it will teach you about how arrays work at a lower level.

A trivial way to work with arrays but avoid array notation is to write *(a + i) wherever you would otherwise write a[i]. Don't do this! This kind of simple transformation results in an awkward, non-idiomatic way of using pointers (plus, it is banned by the coding rules). Instead, use pointers in an idiomatic style as discussed below.

A typical array loop with array indexing normally uses an integer index variable incremented on each iteration, such as in the following:

    // replace all characters in a string with 'Z'
    for (int i = 0; str[i] != '\0'; i++) {
        str[i] = 'Z';
    }

While you could rewrite the above without array indexing by just applying the pointer substitution mentioned previously, a more idiomatic style uses a pointer like a cursor that is incremented to point to the next element on each iteration, as in the following:

    // replace all characters in a string with 'Z'
    for (char* p = str; *p != '\0'; p++) {
        *p = 'Z';
    }

Importantly, note that the loop variable has changed from a regular integer index to the pointer itself (this is the 'cursor'), which traverses the string as the loop executes.

You can simplify the above code even further by noting that '\0' has a numeric value of zero:

    // replace all characters in a string with 'Z'
    for (char* p = str; *p; p++) {
        *p = 'Z';
    }

To reiterate the prohibition on array indexing: your final command.c code should contain no array[index] operations nor operations of the form *(p + i) for some pointer p and index i. If you find yourself writing (or wanting to write) the latter type of expression frequently, that is a good sign that you are still thinking in terms of arrays rather than pointers and should review this section again.

Lastly, a note on pointer syntax: you will often come across multiple types of spacing when declaring pointer types, e.g., int* p versus int *p versus int * p. All of these forms are functionally equivalent, but you may be inclined to read them differently. For example, int* p is most naturally read as "the type of p is a pointer to an int", while int *p is more naturally read as "the type of data pointed to by p is an int". Personally, I find the int* p form the most intuitive and always use it in my own code, but you will often come across the int *p form instead. You can use whichever form you find clearer (but be consistent).

Suggested Workflow

Since there are several components of the library, here is a suggested plan of action for tackling them:

  1. First, review the existing code in command_test.c so that you understand what is happening when you compile and run the command_test executable. Ask questions if you aren't sure!
  2. Add several more hard-coded command array test cases to command_test.c These are the lines of the form:
    static char* NAME[] = {"str1", "str2", ..., NULL};
    Three example command arrays are already included in command_test.c. The purpose of writing hard-coded command arrays here is to (1) remind you of the structure of a command array, and (2) let you test command_print and command_show without needing to implement command_parse first. Note that in addition to adding new hardcoded command arrays themselves, you will need to add each new array to the COMMAND_ARRAYS array defined below the command arrays.
  3. Within command.c, implement the command_show and command_print functions (which are similar). These functions should be only a few lines of code each. Test them on the constant, statically allocated command arrays in command_test.c by compiling and then running ./command_test. If you don't understand the output you're seeing, review command_test.c again. Note that the second set of tests (which exercises command_parse) will still be failing at this point by marking every command line as invalid.
  4. Within command_test.c, add some test command lines (not command arrays) to the COMMAND_LINES array. You should include both invalid and valid lines as well as background/foreground commands, etc.
  5. Now you are ready to actually implement parsing. Within command.c, implement and test command_parse in stages, testing each stage on several inputs and committing a working version before continuing. Here is a suggested logical flow for the command_parse function:

    1. Count the number of words in line and detect use of &, returning NULL for invalid commands and marking the foreground/background status for valid commands.
    2. Allocate the top-level command array.
    3. For each each word in line, allocate properly sized space to hold the word as a null-terminated string, copy the word into this space, and save it in the command array.
    As you are implementing the parsing function, it's a good idea to regularly run Valgrind to catch and fix memory errors early. If you implement the entire parsing function before beginning to debug, it is likely to be more difficult to fix any memory-related bugs (due to the tendency of such bugs to cascade into other issues as the program continues to run).
  6. Implement and test command_free.
  7. Rigorously test your library by adding many more test strings to the COMMAND_LINES array in command_test.c. This step is important! The only way you can be confident that your parser is actually following the specification fully and doesn't have bugs is if you have tests that exercise every part of the specification. If you have only a few test command lines, then succeeding on them will not tell you very much about how robust your program is. Add tests that target every part of the specification (e.g., invalid/valid command lines, whitespace, ampersands, etc.). If you have less than (say) 10-20 tests, it is unlikely that you are getting good coverage; of course, adding many blindly-designed tests that just test the same things over and over again will not help. As a point of reference, my own test suite that will be used to exercise your parser has over 100 command lines in it (though you probably don't need to create that many). As a final step, you should always run Valgrind (again), even if you believe everything to be working. If Valgrind reports any warnings, your debugging and/or testing isn't done yet!

Tools and Debugging Tips

Programming in C can be finicky and error-prone, even for experts. Make use of the tools available to aid in debugging whenever possible:

Make liberal use of assertions. Asserting a condition (e.g., assert(x == 5);) means that the condition is supposed to be true at that point in the code, and if it isn't, that means something is wrong (likely a bug) and the program should terminate with an error. Assertions are "executable documentation": they document rules about how code should be used or how data should be structured, but they also make it easier to detect violations of these rules (a.k.a. bugs!). Use the assert(...) statement in C by including assert.h and asserting expected properties. For example, the provided code already includes code that asserts that the arguments to command_ functions are not NULL. Thus, if a NULL argument is ever passed to these functions, an error message will be printed and execution will halt immediately. Detecting errors early like this (vs. crashing or corrupting data later wherever the code depends on this assumption) saves a lot of time. Add assertions to make the "rules" of your code clear wherever you make assumptions.

Your primary runtime debugging tools should be Valgrind and GDB. You will gain basic proficiency with these tools in the (upcoming) Debugging Mini-Lab. Keep the GDB Reference Sheet readily accessible to help you in using the debugger effectively.

Minimize print-based debugging (e.g., printf) in favor of the other tools mentioned above. If you do use printf, remember that you need to explicitly include ending newline characters (unlike, for example, System.out.println in Java or print in Python). However, be sure to disable all extraneous print commands in command.c in your final submitted version. Your program should not produce any output that the specification does not include!

C Function Declarations and Header Files

In C, a function is allowed to be used only after (that is, later in the file than) its declaration. This behavior differs from Java, which allows you to refer to later methods from earlier methods. When declaring helper functions, you can do one of a few things to deal with this restriction:

  1. Just declare your helper function before the functions that use it (this is the easiest option).
  2. Write a function header earlier in the file, then the actual definition later in the file. The function header just describes the name and type of the function, much like an interface method in Java. For example:
            // A function header declares that such a function exists,
            // and will be implemented elsewhere.
            int helper(int x, int y);
    
            // Parameter names are optional in headers.
            int helper2(char*);
    
            void needsHelp() {
                // OK, because header precedes this point in the file
                helper(7, 8);
                helper2("hello");
            }
    
            // even though the implementation comes later
            int helper(int x, int y) {
                return x + y;
            }
    
            int helper2(char* str) {
                return 7;
            }
  3. If the functions would likely get used elsewhere, then put the header in a header file, which is a file ending in .h that contains only function headers (for related functions) and data type declarations. For example, if you added another general function (not just a helper function) for manipulating commands, it would be best to place a function header for it with the other function headers in command.h so that users of your command library can call it. Header files are widely used in most C programs, and are included (essentially programmatically copy-pasted) by the #include directive you often see at the top of C source files.

Logistics

As for Lab 1, starter code will be distributed via GitHub. If you are working in a group, your group will share one repository in which both members can push and pull changes.

Collaborative development within a group will likely require more extensive use of git than in the past. If you haven't done so previously, it is a good idea to go through Part 3 of the Git tutorial, which covers some specific topics applicable to collaboration (most significant of which is handling merge conflicts). You should also review the course policies on group work.

Remember that you should be working on hopper, not on your local machine. While it is possible that things work correctly on your local machine, if there are problems, I will not be able to effectively help you!

Before submitting, disable any diagnostic printing in command.c. Only command_print and command_show should print, as specified. Any output produced by your parse function (or by your free function) will cause your code to fail tests.

As usual, make sure that your final code has been both committed to your local repository and then pushed to GitHub.

Evaluation

Your lab will be evaluated using the following criteria:

warningIMPORTANT: Remember to get rid of any extraneous print statements in your final code. In particular, your command_parse function should never call printf!

You should consult the Coding Design & Style Guide for tips on design and style issues.

Your library will be tested using a private suite of test inputs in addition to the test inputs provided with the starter code. As discussed previously, you should extend command_test.c with your own large suite of test inputs to check that your code meets the specification and also run under valgrind to make sure that your program is free of memory safety violations. Your tests themselves will not be graded, but preparing and using them will help you ensure that your code is correct and complete.

Acknowledgement

This lab was derived from an assignment originally designed by Ben Wood.