Functions

Terminology


    swap(&int1, &int2);   /* arguments are addresses of 2 integers   */
                          /* this is a call; this code is identified */
                          /* as the caller or parent                 */


/*  function swap                               */
/*    interchanges two integer values           */
/*  return value:  none                         */
/*  parameters:                                 */
/*     *px         pointer to an one integer    */
/*     *py         pointer to the other integer */
void swap(int *px, int *py)  /* when called or invoked, this code is identified */
                             /* as the  callee or child                         */
{
    int temp;

    temp = *px;
    *px = *py;
    *py = temp;
}

Arguments and Parameters

C uses call by value for all parameters. This implies that the function receives copies of all parameters. These parameters are local variables within the function's block.

Therefore, the following code may not do what you expect.



#include <stdio.h>

void increment(int a);

main()
{
    int x;
    int y;
    int larger;

    x = 1;
    printf("before call, x = %d\n", x);
    increment(x);
    printf("after  call, x = %d\n", x);
    return (0);
}

void increment(int a){
    a++;
}

The output of this program will be:


before call, x = 1
after  call, x = 1

To change the value of the variable, pass a pointer to it.


#include <stdio.h>

void increment(int *a);

main()
{
    int x;
    int y;
    int larger;

    x = 1;
    printf("before call, x = %d\n", x);
    increment(&x);
    printf("after  call, x = %d\n", x);
    return (0);
}

void increment(int *a){
    (*a)++;
}

Now, the output will be:


before call, x = 1
after  call, x = 2

The address of the variable allows the function to modify the variable pointed to. The language only implements call by value, but we get call by reference functionality.

Function Return Values

Any and every function returns a value. The type of this value must be declared in the definition of the function. Here is an example of the type declaration.


int dosomething()
{
    /* code here to do something */
}

This function returns an integer to its parent. Within the function definition, the return value must be set and/or identified. This is accomplished with a return statement. Here are three examples that all accomplish the same thing.


int dosomething()
{
    /* code here to do something */

    return 6;
}



int dosomething()
{
    int returnvalue;
    /* code here to do something */
    returnvalue = 6;

    return returnvalue;
}



int dosomething()
{
    /* code here to do something */

    return (2 + 4);
}

Plenty of functions have no value to return to the parent. This case is called a procedure by many high level languages. The return type is void in this case, and there will be no return statement.

"return" from main

As a program is executed, consider what occurs at a less abstract level. The operating system (OS) is running and knows that it is to execute a specific program. The OS does administrative things that work towards running the program.

All this implies that the program to be executed is treated as a function that is called by the OS. As a function, it returns a value. The return value ought to be explictly set by the program. In C, this may be done by the function exit() or by using return.

By convention, a return value of 0 means that the program is exiting without error. Other return values are used to identify error conditions.

Style guidelines say that all programs should explictly use a return or exit() from main.

Here are two simple examples showing this.


#include <stdio.h>

#define MAX 100

main()
{
    int x;

    x = 1;
    while (x <= MAX) {
        printf("%d\n", x);
        x++;
    }
    exit(0);
}



#include <stdio.h>

#define MAX 100

main()
{
    int x;

    x = 1;
    while (x <= MAX) {
        printf("%d\n", x);
        x++;
    }
    return 0;
}

Error Codes and Functions

A topic difficult for (Java) programmers who only have a vague notion of Java exceptions and their proper use deals with what functions/programs should do when they encounter conditions that indicate an error.

NOTE: The use of the term exception within the Java language is unfortunate for computer architects. It has a definite, and somewhat different meaning when discussed in terms of computer architecture (this class!). Therefore, this short section of notes talks of error conditions, as opposed to exceptions, to attempt to avoid later confusion.

In general, a program should be written such that every possible error condition is covered. Examples of error conditions:

A common implementation of C programs utilizes the return value to return an indication of whether an error was encountered, and if so, what the error was. At an appropriate function, the return value becomes an error code that is used to print an error message, or cause the program to repeat a request for user input, or set variable values in a default way, or possibly exit the program. The appropriate program response to the error condition depends on the program.

Stack

The stack is a totally cool data structure (or abstract data type) that is extremely useful to make programs work. A data structure is a way of organizing data. The two major things that are done with a data structure are putting data into it (often called insert), and getting data back out of it (often called delete or remove). A stack is an organization that gets the data back out in the reverse order that the data is put into it. The code that uses the stack data structure (the client) does not have the ability to order the data. The stack implementation enforces the ordering of the data.

Putting data into a stack is an operation called a push. Getting data out of a stack is an operation called a pop.

Here is a little example that represents the data with letters. Assume the following operations are done in the given order:

  1. push a
  2. push b
  3. push c
  4. push d

After these 4 push operations, there will be 4 items in the stack, a, b, c, and d. If the next operation is a pop, item d is retrieved, and it is taken out of the stack. Yet another pop retrieves item c. Push and pop operations can be done in any order; the application that uses a stack determines this order. Theoretically, a stack can never be full, meaning that a new item can always be pushed onto the stack. A stack can be empty, and an implementation will need to do something specific (like returning an error code), when a pop operation is requested on an empty stack.

When implemented, a stack utilizes memory for the items pushed onto the stack, and an extra variable that identifies the top of stack. It is also commonly called the stack pointer, usually abbreviated sp. Both push and pop operations work on the item at the top of the stack.

Sometimes, a stack is called a LIFO, where the acronym stands for Last In First Out.

The reason for introducing the stack when discussing the C programming language has to do with the implementation (at the assembly/machine language level) of functions.

As viewed from a high-level language perspective, function calls, returns, parameter passing, and returning values just works. From the assembly language perspective, there is a lot of code to make it work. Nothing is automatically done; it is all explicit. Understanding what must be done and why is important, to be able to implement it in assembly language.

Function Call and Return


    rtn_value1 = fcn1(int x);
    a = b + c;
    .
    .
    .

    rtn_value1 = fcn1(int x);
    y = x + z;
    .
    .
    .

    int fcn1 (int local_int) {

        k = fcn2(local_int * 2);
        return (k - 3);
    }

    int fcn2 (int local_int2) {
        return (local_int2 - 85);
    }

This is a sizeable example that does not do much. It could easily be done in a single expression evaluation. The point of this example it to notice the call and return calculations. Here are the steps that occur, in the order that they occur.

  1. fcn1 is called. Upon the return from fcn1, the code must continue with the assignment of fcn1's return value, followed by the statement a = b + c.
  2. fcn1 is begins execution.
  3. fcn1 calls fcn2. Upon the return from fcn1, the code must continue with the assignment to k of fcn2's return value.
  4. fcn2 is begins execution.
  5. fcn2 completes, returning a value.
  6. The program returns, continuing execution within fcn1.
  7. fcn1 completes, returning a value.
  8. The program returns, continuing execution within the caller (parent) of fcn1.
  9. All these steps repeat again, with the second call to fcn1, but with a different return address; upon the return from fcn1, the code must continue with the assignment of fcn1's return value, followed by the statement y = x + z.

An important aspect to the call and return deals with return addresses. At the level of a computer executing a program, a call requires two things:

  1. Save and remember the return address for later use when returning.
  2. Branch (jump) to the first instruction within the called function.

These 2 steps may also be expressed as (and will make more sense once we incorporate the use of a stack into the discussion)

  1. Remember the return address while
  2. Branching (jumping) to the first instruction within the called function.

The return from a function requires using the saved and remembered return address as the target of a branch (jump).

A key point here is that the return addresses are used (for returning) in the reverse order that they are saved/remembered. The implication is that a stack is the perfect data structure to use for the saving/use of return addresses. A call pushes the return address onto a stack, then branches to the first instruction within the function. A return pops the top of stack, obtaining the (correct) return address. It is then used as the target of a branch.

Knowing about how calls and returns are implemented at the assembly language level makes the understanding of recursion the same as understanding nested function calls.

Local Variables and Parameter Passing

A variable that is local to a function is one that resides within the scope of the function. When code not within the function is executing, the local variable does not exist. While the function executes, the local variable does exist, and can be set or used by the function.

Like local variables, (call by value) parameters exist and can be set and used only while the function is executing.

To compiler writers, as well as computer architects, local variables, parameters, and return addresses are all utilized in the same way. Calls and returns order the usage of these things, and they can all be grouped together and used as a set. This set is a single entity called an activation record (AR) or stack frame. An AR consists of the set of variables that may be accessed and modified while a program is executing within the scope of a function.

A stack is the correct data structure to use to hold ARs, where the top element (an AR) within the stack belongs to the currently executing invocation of a function. The use of the stack is slightly more complex than the simplified push and pop operations as described.

When executing, a function call, execution, and return becomes

  1. Remember the return address while
  2. Branching (jumping) to the first instruction within the called function.
  3. push an AR's worth of memory space for the function onto the stack
  4. save (place) the remembered return address into the AR
  5. execute the function, which may involve looking at (reading) items from within the AR, and even setting (writing) items from within the AR
  6. retrieve the return address from the AR
  7. pop the AR's worth of space from the stack
  8. branch, where the target is the retrieved return address

Please note that I have simplified the steps and ordering a little bit to abstract away some difficulties of the explanation.

If a function contains another call to a function (a nested call), then the nested function would occur during step 5. When the nested invocation returns, step 5 continues. The remainder of the steps are administrative work that allows programs to work.

These notes have now referred to "the stack." Making nested calls (and recursion) work requires the use of a stack. Since our program paradigm presumes (and assumes) that we can nest function calls, every executing program needs access to a stack that can be used for ARs. Therefore, every computer system is set up such that the operating system allocates memory for use by each program of a stack. The use of the stack is such a common operation by executing programs, that many architectures provide hardware support for making stack operations more efficient (fast).


  v = a(i);
      .
      .

  int a (int r){
      .
      .
      w = b(j);
      .
      .
  }

  int b (int s){
      .
      .
      x = c(k);
      .
      .
  }

  int c (int t){
      .
      .
      y = d(l);
      .
      .
  }

  int d (int u){
      .
      .
  }

In this code fragment, a calls b, b calls c, and c calls d. There are several levels of nested calls. After c calls d, the stack will appear something similar to


  |                   |
  |-------------------|
  |  RA within C      |  <----                 <------- Top of Stack
  |-------------------|      |----  AR for d
  |  parameter u      |  -----
  |-------------------|
  |  RA within B      |  -----
  |-------------------|      |
  |  parameter t      |      |----  AR for c
  |-------------------|      |
  | local variable y  |      |
  |-------------------|      |
  | local variable l  |  -----
  |-------------------|
  |  RA within A      |  -----
  |-------------------|      |
  |  parameter s      |      |----  AR for b
  |-------------------|      |
  | local variable x  |      |
  |-------------------|      |
  | local variable k  |  -----
  |-------------------|
  |  RA within main   |  -----
  |-------------------|      |
  |  parameter r      |      |----  AR for a
  |-------------------|      |
  | local variable w  |      |
  |-------------------|      |
  | local variable j  |  -----
  |-------------------|
  |                   |
  |-------------------|



Recursion

Once we understand how call and return work for a function, as well as for nested function calls, recursion is just a specialized case of a nested function call. A function contains a call to itself.

When the recursive function is executing, it places data into and uses data from its AR. Each new call (a recursive call) does exactly the steps as a nested call would, such as remembering the return address, and creating (pushing) a new AR to be used by the recursive function invocation.

Heap

These notes have discussed the use of the stack. It becomes an excellent way of doing one type of dynamic memory allocation. Memory for various items (such as return addresses, local variables, parameters) is required to be allocated as the program is executing. This must be accomplished while the program is executing because there is no way to know how much memory will be required before that time. As evidence of this, consider a simple recursive function. Each invocation (call) of the function requires a new AR to be allocated. If the number of recursive calls is not known until the program is executing, then the space required cannot be determined until that time.

Dynamic memory allocation can be contrasted with static memory allocation. Static allocation is well defined, and can be done at the time when a program is compiled. The memory space for global variables can be statically allocated.

User-allocated variables (those created with new in Java) are another example that require dynamic allocation of memory space. Examples of this are lists, trees, and queues. The amount of memory space required will not be known until the program is executing. For these dynamic memory requirements, a heap is utilized. It is a large portion of memory set aside for a program to use. The allocation and deallocation of chunks of this portion of memory are handled (in C) by library routines (malloc and free).

The Big Picture of Memory Allocation

A program is compiled and assembled. This results in data (probably a file kept somewhere on a disk) that represents all information about what memory must look like for the program (sometimes called a memory image). This contains

  1. Code. This is the machine code portion of compiled and then assembled program. Note that there may be other code that is utilized by the program, but is not part of the memory image. An example of this may be a library function called when the program executes. Another example may be the code within a function that the operating system performs on behalf of a program.

    A result of not having these functions as part of the memory image produced by the assembler is that the location (address) of the function is not known. Therefore, the machine code for the branch instruction that implements the call is incomplete. The further step of linking and loading has the task of completing the code for these addresses unknown at compile time.
  2. Static Variables. These are the global variables. They exist for the duration of program execution. A separate section of memory is utilized for these global variables.
  3. Symbol Table. The symbol table is a list of labels and the addresses assigned for the lables. Labels are utilized for variables names as well as the targets of branch (or jump) instructions. It will also may be a place to identify those labels that cannot be assigned an address at assembly time.
When the program is to be executed, the operating system copies the memory image into memory in just the locations that the assembler has assigned. As this step (called linking and loading) occurs, any unresolved addresses are assigned or known. The code is completed. Memory space for the stack and heap are made available (allocated) for the program, and the stack pointer is set. All that remains is for the operating system code to branch (or jump) to the address identified as containing the first instruction within the program.


Copyright © Karen Miller, 2006