Cits2002 Compressed PDF
Cits2002 Compressed PDF
Cits2002 Compressed PDF
The unit explores the role of contemporary operating systems and their support for high-level programming languages, how they manage efficient access to computer hardware, and how a computer's resources may be accessed and controlled by the C programming language.
The unit will be presented by Chris McDonald - which is pronounced 'Chris', not 'Dr', not 'Professor'.
Understanding the relationship between a programming language and the contemporary operating systems on which it executes is central to developing many skills in Computer Science. This unit introduces the standard C programming language, on which many
other programming languages and systems are based, through a study of core operating system services including input and output, memory management and file systems.
The C language is introduced through discussions on basic topics like data types, variables, expressions, control structures, scoping rules, functions and parameter passing. More advanced topics like C's run-time environment, system calls, dynamic memory
allocation, and pointers are presented in the context of operating system services related to process execution, memory management and file systems. The importance of process scheduling, memory management and interprocess communication in modern
operating systems is discussed in the context of operating system support for multiprogramming. Laboratory and tutorial work place a strong focus on the practical application of fundamental programming concepts, with examples designed to compare and contrast
many key features of contemporary operating systems.
The UWA Handbook entry for this unit strongly recommends that you take one of three units of Advisable Prior Study before taking this unit - CITS1001, CITS1401, or CITS2401. Students who took this unit in recent years, and had chosen to not take one of the units of
Advisable Prior Study, found the material in this unit difficult. This unit is not suitable for first-time programmers.
Managing memory
Allocating physical memory to processes, sharing memory between multiple processes, allocating and managing memory in C programs.
By the end of this unit you'll have this knowledge - it just won't all be presented strictly in this order.
Why teach C?
Since its beginnings in the early 1970s, the C programming language has evolved to become one of the world's most popular, and widely deployed programming languages. The language has undergone extensive formal standardization to produce the ANSI-C standard in
1989, the ISO-C99 standard in 1999, ISO-C11 (revision) in Dec 2011, and ISO-C18 ("bugfix release", no new features) in June 2018.
C is the programming language of choice for most systems-level, engineering, and scientific programming:
most of the world's popular operating systems, Linux, Windows and macOS, their interfaces and file-systems, are written in C,
the infrastructure of the Internet, including most of its networking protocols, web servers, and email systems, are written in C,
software libraries providing graphical interfaces and tools, and efficient numerical, statistical, encryption, and compression algorithms, are written in C,
the software for most embedded devices, including those in cars, aircraft, robots, smart appliances, sensors, mobile phones, and game consoles, is written in C,
the software on the Mars Phoenix Lander is written in C,
much of the safety-critical software on the F-35 joint strike fighter, is written in C, but
C was not used on the Apollo-11 mission!
Though, of course, popularity is a poor measure of quality - otherwise, McDonald's Restaurants would receive Michelin stars.
So what is C?
“
A programming language that doesn't affect the way you think about
programming isn't worth knowing.
— Alan Perlis, 1st Turing Award winner
In one breath, C is often described as a good general purpose language, an excellent systems programming language, and just a glorified assembly language. So how can it be all three?
C is a procedural programming language, not an object-oriented language like Java, (parts of) Python, Objective-C, or C#.
well designed,
clearly written,
written for portability,
well documented,
use high level programming practices, and
well tested.
Of course, the above properties are independent of C, and are offered by many high level languages.
C has programming features provided by most procedural programming languages - strongly typed variables, constants, standard (or base) datatypes, enumerated types, user-defined types, aggregate structures, standard control flow, recursion, and program
modularization.
C does not offer tuples or sets, Java's concept of classes or objects, nested functions, subrange types, and has only recently added a Boolean datatype.
C does have, however, separate compilation, conditional compilation, bitwise operators, pointer arithmetic, and language independent input and output.
CITS2002 Systems Programming, Lecture 1, p4, 29th July 2019.
C also provides an excellent operating system interface through its well defined, hardware and operating system independent, standard library.
The C language began its development in 1972, as a programming language in which to re-write significant portions on the Unix operating system:
Unix was first written in assembly languages for PDP-7 and PDP-11 computers.
In 1973 Dennis Ritchie was working on a programming language for operating system development. Basing his ideas upon BCPL, he developed B and finally created one called C.
(Yes, there is a language named 'D', but it's not a descendant of C)
By the end of 1973, the UNIX kernel was 85% written in C which enabled it to be ported to other machines for which a C compiler could be fashioned.
This was a great step because it no longer tied the operating system to the PDP-7 as it would have been if it remained in assembly language. In 1976 Dennis Ritchie and Stephen Johnston ported Unix to an Interdata 8/32
machine. Since then, Unix and Linux have been ported to over 260 different processor architectures.
Today, well in excess of 95% of the Unix, Linux, macOS, and Windows operating system kernels and their standard library routines are all written in the C programming language - it's extremely difficult to find an operating system not written in either C or its descendants C++ or
Objective-C.
CITS2002 Systems Programming, Lecture 1, p5, 29th July 2019.
traditional large instruction set architectures, such as Intel x86, AMD, ARM, Motorola 680x0, Sun SPARCs, and DEC-Alpha,
newer reduced instruction set architectures (RISC), such as SGI MIPS, IBM/Motorola PowerPC,
mobile phones, home theatre equipment, routers and access-points, and
parallel and pipelined architectures.
CITS2002 Systems Programming, Lecture 1, p6, 29th July 2019.
But the obvious advantage of having the program coded in a readable, high level language, provides the overwhelming advantages of maintainability and portability.
Very little of an operating system, such as Windows, macOS, or Linux, is written in an assembly language - in most cases the majority is written in C.
Even an operating system's device drivers, often considered the most time-critical code in an operating system kernel, today contain assembly language numbered in only the hundreds of lines.
CITS2002 Systems Programming, Lecture 1, p7, 29th July 2019.
(in fact there's a humorous contest held each year, The International Obfuscated C Code Contest to design fully working but indecipherable code,
and the Underhanded C Contest whose goal is to write code that is as readable, clear, innocent and straightforward as possible, and yet it must fail to perform at its apparent function).
Perhaps C's biggest problem is that the language was designed by programmers who, folklore says, were not very proficient typists.
C makes extensive use of punctuation characters in the syntax of its operators and control flow. In fact, only the punctuation characters
@ ` and $
are not used in C's syntax! (and DEC-C once used the $ character, and Objective-C now uses the @).
It is not surprising, then, that if C programs are not formatted both consistently and with sufficient white space between operators, and if very short identifier names are used, a C program will be very difficult to read.
To partially address these problems, a number of text-editors, integrated development environments (IDEs), and beautification programs (such as indent) can automatically reformat our C code according to consistent specifications.
CITS2002 Systems Programming, Lecture 1, p8, 29th July 2019.
It is possible to cast an instance of some types into other types, even if the two instances have considerably different types.
A pointer to an instance of one type may be coerced into a pointer to an instance of another type, thereby permitting the item's contents to be interpreted differently.
Badly written C programs make incorrect assumptions about the size of items they are managing. Integers of 8-, 16-, and 32-bits can hold different ranges of values. Poor choices, or underspecification can easily lead to errors.
There is no exception handling mechanism, and errors such as division-by-zero and arithmetic overflow and underflow, are not caught and reported at run-time.
C offers no runtime checking of popular and powerful constructs like pointer variables and array indices.
Subject to constraints imposed by the operating system's memory management routines, a pointer may point almost anywhere in a process' address space and seemingly random addresses may be read or written to.
Although all array indices in C begin at 0, it is possible to access an array's elements with negative indices or indices beyond the declared end of the array.
There are occasions when each of these operations make sense, but they are rare.
We avoid all of these potential problems by learning the language well, and employing safe programming practices.
CITS2002 Systems Programming, Lecture 1, p9, 29th July 2019.
Through a sequence of units offered by Computer Science & Software Engineering you can become proficient in a wide variety of programming languages - procedural, object-oriented, functional, logic, set-based, and formal - and know the most appropriate one to select for
any project.
CITS2002 Systems Programming, Lecture 1, p10, 29th July 2019.
This is a very lengthy period of time when talking about a programming language's evolution.
The original C language was mostly designed by Dennis Ritchie and then described by Brian Kernighan and Dennis Ritchie in their imaginatively titled book The C Programming Language.
The language described in this seminal book, described as the "K&R" book, is now described as "K&R" or "old" C.
228 pages.
In the late 1980s, a number of standards forming bodies, and in particular the American National Standards Association X3J11 Committee, commenced work on rigorously defining both the C language and the commonly provided standard C
library routines. The results of their lengthy meetings are termed the ANSI-X3J11 standard, or informally as ANSI-C, C89, or C90.
The formal definition of ANSI-C introduced surprisingly few modifications to the old "K&R" language and only a few additions.
Most of the additions were the result of similar enhancements that were typically provided by different vendors of C compilers, and these had generally been considered as essential extensions to old C. The ANSI-C language is extremely similar to
old C. The committee only introduced a new base datatype, modified the syntax of function prototypes, added functionality to the preprocessor, and formalized the addition of constructs such as constants and enumerated types.
272 pages.
A new revision of the C language, named ANSI/ISO-C99 (known as C99), was completed in 1999.
Many features were "cleaned up", including the addition of Boolean and complex datatypes, single line comments, and variable length arrays, and the removal of many unsafe features, and ill-defined constructs.
753 pages.
A revision of C99, ISO/IEC 9899:2011 (known as C11), was completed in December 2011.
the mechanism by which C programs are transformed for use by a data-processing system;
the mechanism by which C programs are invoked for use by a data-processing system;
the mechanism by which input data are transformed for use by a C program;
the mechanism by which output data are transformed after being produced by a C program;
the size or complexity of a program and its data that will exceed the capacity of any specific data-processing system or the capacity of a particular processor;
all minimal requirements of a data-processing system that is capable of supporting a conforming implementation.
CITS2002 Systems Programming, Lecture 1, p14, 29th July 2019.
For example, C does not provide features for graphics, networking, cryptography, or multimedia.
Instead, C permits, enables, and encourages additional 3rd-party libraries (both open-source and commercial) to provide these facilities. The reason for these "omissions" is that C rigorously defines what it does provide, and rigorously defines how C must interact with external
libraries.
Here are some well-respected 3rd-party libraries, frequently employed in large C programs:
/* Compile this program with: Keywords, in bold, mean very specific things to the C compiler.
cc -std=c99 -Wall -Werror -pedantic -o rotate rotate.c
*/ Lines commencing with a '#' in blue are processed by a separate program, named the C preprocessor.
#define ROT 13 In practice, our program is provided as input to the preprocessor, and the preprocessor's output is given to the C compiler.
static char rotate(char c) Lines in green are comments. They are ignored by the C compiler, and may contain (almost) any characters.
{
..... C99 provides two types of comments -
return c;
} /* block comments */ and
// comments to the end of a line
int main(int argcount, char *argvalue[])
{
// check the number of arguments
if(argcount != 2) {
....
exit(EXIT_FAILURE);
}
else {
....
exit(EXIT_SUCCESS);
}
return 0;
}
#define ROT 13 Functions in C, may be thought of as a block of statements to which we give a name. In our example, we have two functions - rotate() and main().
static char rotate(char c) When our programs are run by the operating system, the operating system always starts our program from main(). Thus, every complete C program requires a main() function.
{
..... The operating system passes some special information to our main() function, command-line arguments, and main() needs a special syntax to receive these.
return c;
} Most C programs you read will name main()'s parameters as argc and argv.
int main(int argcount, char *argvalue[]) When our program finishes its execution, it returns some information to the operating system. Our example here exits by announcing either its failure or success.
{
// check the number of arguments
if(argcount != 2) {
....
exit(EXIT_FAILURE);
}
else {
....
exit(EXIT_SUCCESS);
}
return 0;
}
This makes them very easy to copy, read, and edit on different computers and different operating systems.
C is often described as being portable at the source-code level.
Before we can run (execute) our C programs, we must translate, or compile, their source-code files to files that the operating system can better manage.
A program known as a compiler translates (compiles) source-code files into object-code files.
Finally, we translate or link one or more object-code files to produce an executable program, often termed a 'binary', an 'executable', or an 'exe' file.
A program known as a linker performs this translation, also linking our object-code file(s) with standard libraries and (optionally) 3rd-party libraries.
Depending on how we invoke the compiler, sometimes we can 'move' straight from the source-code files to the executable program, all in one step.
In reality the compiler is 'silently' executing the linker program for us, and then removing any unwanted object-files.
CITS2002 Systems Programming, Lecture 2, p3, 2nd August 2019.
Variables
Variables are locations in a computer's memory. A typical desktop or laptop computer will have 4-16GB of memory, or four-sixteen billion addressable memory locations,
A typical C program will use 4 bytes to hold a single integer value, or 8 bytes to hold a single floating-point value.
Any variable can only hold a single value at any time - they do not maintain a history of past values they once had.
While variable names can be almost anything (but not the same as the keywords in C) there's a simple restriction on the permitted characters in a name -
they must commence with an alphabetic or the underscore character (_ A-Z a-z), and
be followed by zero or more alphabetic, underscore or digit characters (_ A-Z a-z 0-9).
While not required, it's preferred that variable names do not consist entirely of uppercase characters.
We'll consistently use uppercase-only names for constants provided by the C preprocessor, or user-defined type names:
MAXLENGTH, AVATAR, BUFSIZ, and ROT
Older C compilers limited variable names to, say, 8 unique characters. Thus, for them,
are the same variable! Keep this in mind if ever developing portable code for old environments.
CITS2002 Systems Programming, Lecture 2, p4, 2nd August 2019.
Basic datatypes
Variables are declared to be of a certain datatype, or just type.
We use different types to represent the permissible values that a program's variable has.
For example, if we're using a variable to just count things, we'll use an integer variable to hold the count; if performing trigonometry on angles expressed in radians, we'll use floating-point variables to hold values with both an integral and a fractional part.
C provides a number of standard, or base types to hold commonly required values, and later we'll see how we can also define our own user-defined types to meet our needs.
Some textbooks will (too quickly) focus on the actual storage size of these basic types, and emphasise the ranges of permissible values. When writing truly portable programs - that can execute consistently across different hardware architectures and operating systems - it's
important to be aware of, and avoid, their differences. We'll examine this issue later, but for now we'll focus on using these basic types in their most obvious ways.
From where does the bool datatype get its name? - the 19th century mathematician and philosopher, George Boole.
At a low level, different manufacturers employed different memory sizes for a basic character - some just 6 bits, some 8 bits, 9, and 10. The unfortunate outcome was the incompatability of computer programs and data storage formats.
The C programming language, developed in the early 1970s, addressed this issue by not defining the required size of its datatypes. Thus, C programs are portable at the level of their source code - porting a program to a different computer architecture is possible, provided that
the programs are compiled on (or for) each architecture. The only requirement was that:
Since the 1980s, fortunately, the industry has agreed on 8-bit characters or bytes. But (compiling and) running the C program on different architectures:
Why does this matter? Different sized integers can store different maximum values - the above datatypes are signed (supporting positive and negative values) so a 4-byte integer can only represent the values -2,147,483,648 to 2,147,483,647.
If employing integers for 'simple' counting, or looping over a known range of values, there's rarely a problem. But if using integers to count many (small) values, such as milli- or micro-seconds, it matters:
Fun fact: GPS uses 10 bits to store the week. That means it runs out... oh heck ? April 6, 2019
Airlines Have To Reboot Their Airbus A350 Planes After Every 149 Hours
To keep a Boeing Dreamliner flying, reboot once every 248 days
CITS2002 Systems Programming, Lecture 2, p6, 2nd August 2019.
global scope (sometimes termed file scope) in which variables are declared outside of all functions and statement blocks, and
block scope in which variables are declared within a function or statement block.
01 #include <stdio.h> It is defined on line 06, and may be used anywhere from line 06 until the end of the file (line 26).
02 #include <stdlib.h>
03 #include <string.h> The variable count is also preceded by the keyword static, which prevents it from being 'seen' (read or written) from outside of this file rot.c
04 #include <ctype.h>
05 The variable nfound has block scope.
06 static int count = 0;
07 It is defined on line 10, and may be used anywhere from line 10 until the end of the block in which it was defined (until line 26).
08 int main(int argcount, char *argvalue[])
09 { The variable nerrors has block scope.
10 int nfound = 0;
11 It is defined on line 14, and may be used anywhere from line 14 until line 18.
12 // check the number of arguments
13 if(argcount != 2) { The variable ntimes has block scope.
14 int nerrors = 1;
15 It is defined on line 20, and may be used anywhere from line 20 until line 24.
16 ....
17 exit(EXIT_FAILURE);
18 }
We could define a different variable named nerrors in the block of lines 20-24 - without problems.
19 else {
20 int ntimes = 100;
21 We could define a different variable named nfound in the block of lines 20-24 - but this would be a very bad practice!
22 ....
23 exit(EXIT_SUCCESS);
24 }
25 return 0;
26 }
C programs commence their execution at their main() function, execute their statements, and exit (return the flow of control) to the operating system.
It's fairly obvious that statements need to be executed in a well-defined order, as we expect programs to always behave the same way (unless some random data directs the execution path, as in computer games, simulations, and heuristic algorithms).
Programs that only execute from top-to-bottom are pretty boring, and we need to control their flow with a variety of conditional statements, loops, and function-calls.
CITS2002 Systems Programming, Lecture 3, p1, 5th August 2019.
Conditional execution
Conditional statements first evaluate a Boolean condition and then, based on whether it's true or false, execute other statements.
The most common form is: Sometimes, the else clause is omitted: Often, the else clause provides further if statements:
Note that in the examples, above, each block of statements to be executed has been written within curly-brackets.
The curly-brackets are not required (we could just write a single statement for either if or else clause). However, adding curly-brackets is considered a good practice. They provide a safeguard for when additional statements are added later.
Boolean values
Of significance, and a very common cause of errors in C programs, is that C standards, prior to ISO-C99, had no Boolean datatype.
Historically, an integer value of zero evaluated equivalent to a Boolean value of false; any non-zero integer value evaluated as true.
You may read some older C code: which may be badly and accidently coded as:
In the second example, the conditional test always evaluates to false, as the single equals character requests an assignment, not a comparison.
Whenever requiring the true and false constants (introduced in C99), we need to provide the line:
#include <stdbool.h>
CITS2002 Systems Programming, Lecture 3, p3, 5th August 2019.
Switch statements
When the same (integer) expression is compared against a number of distinct values, it's preferred to evaluate the expression once, and compare it with possible values:
Cascading if..else..if.. statements: The equivalent switch statement: Less-common features of the switch statement:
Typically the 'expression' is simply an identifier, but it may be arbitrarily complex - such as an arithmetic expression, or a function call.
The datatype of the 'expression' must be an integer (which includes characters, Booleans, and enumerated types), but it cannot be a real or floating-point datatype.
The break statement at the end of each case indicates that we have finished with the 'current' value, and control-flow leaves the switch statement.
Without a break statement, control-flow continues "downwards", flowing into the next case branch (even though the expression does not have that case's value!).
switch statements with 'dense' values (none, or few integer missing) provide good opportunities for optimised code.
CITS2002 Systems Programming, Lecture 3, p4, 5th August 2019.
(in fact, one of the motivating first uses of computers in the 1940s was to calculate trigonometric tables for the firing of artillery shells).
C provides its for control statement to loop through a sequence of statements, a block of statements, a known number of times:
The most common form appears below, in which we introduce a loop control variable, i, to count The loop control variable does not always have to be an integer:
how many times we go through the loop:
// here, variable i holds the values 1,2,...10 // here, variable ch holds each lowercase value
Notice that in both cases, above, we have introduced new variables, here i and ch, to specifically control the loop.
The variables may be used inside each loop, in the statement block, but then "disappear" once the block is finished (after its bottom curly bracket).
It's also possible to use any other variable as the loop control variable, even if defined outside of the for loop. In general, we'll try to avoid this practice - unless the value of the variable is required outside of the loop.
CITS2002 Systems Programming, Lecture 3, p5, 5th August 2019.
Such loops are termed bounded loops and, unless we've made an unseen coding error, always terminate after a fixed number of iterations.
There are also many occasions when we don't know, ahead of time, how many iterations may be required. Such occasions require unbounded loops.
int i = 1; int i = 1;
int n = 0; int n = 0;
..... .....
printf("loop was traversed %i times\n", n); printf("loop was traversed %i times\n", n);
Notice that in both cases we still use a variable, i, to control the number of iterations of each loop, and that the changing value of the variable is used to determine if the loop should "keep going".
However, the statements used to modify the control variable may appear almost anywhere in the loops. They provide flexibility, but can also be confusing when loops become severals tens or hundreds of lines long.
Notice also that while, and do....while loops cannot introduce new variables to control their iterations, and so we have to use existing variables from an outer lexical scope.
#define NROWS 6
#define NCOLS 4
for(int row = 1 ; row <= NROWS ; row = row+1) // the 'outer' loop
{
for(int col = 1 ; col <= NCOLS ; col = col+1) // the 'inner' loop
{
printf("(%i,%i) ", row, col); // print row and col as if "coordinates"
}
printf("\n"); // finish printing on this line
}
Notice that we have two distinct loop-control variables, row and col.
Each time that the inner loop (col's loop) starts, col's value is initialized to 1, and advances to 4 (NCOLS).
As programs become more complex, we will see the need for, and write, all combinations of:
Sometimes we need to leave a loop early, using the break statement, possibly skipping some iterations and some Sometimes we need to start the next iteration of a loop, even before executing all statements in the loop:
statements:
In the first example, we iterate through the loop at most 10 times, each time reading a line of input from the keyboard. If the user indicates they wish to quit, we break out of the bounded loop.
In the second example, we wish to perform some work for all lowercase characters, except 'm'.
We use continue to ignore the following statements, and to start the next loop (with ch == 'n').
To fully understand this, however, we need to accept (for now), that the three "pieces" of the for construct, are not always initialization, condition, modification.
More generally, the three pieces may be C expressions - for the moment we'll consider these as C statements which, if they produce a value, the value is often ignored.
In both cases, we're expecting expression2 to produce a Boolean value, either true or false, as we need that truth value to determine if our loops should "keep going".
You should think about these carefully, perhaps perform some experiments, to determine where control flow really goes when we introduce break and continue statements.
For example, the following loop initially sets i to 1, and increments it each iteration, but it doesn't have a "middle" conditional test to see if the loop has finished. The missing condition constantly evaluates to true:
Some loops don't even have a loop-control variable, and don't test for their termination. This loop will run forever, until we interrupt or terminate the operating system process running the C program.
We term these infinite loops :
#include <stdbool.h>
While we often see and write such loops, we don't usually want them to run forever!
We will typically use an enclosed condition and a break statement to terminate the loop, either based on some user input, or the state of some calculation.
CITS2002 Systems Programming, Lecture 3, p10, 5th August 2019.
CITS2002 Systems Programming
A piece of systems software that provides a convenient, efficient environment for the execution of user programs.
It's probably the largest and most complex program you'll ever run!
Traditionally, we would summarize an operating system's goals as making "the system" convenient to use and scheduling its resources efficiently and fairly.
We often (mistakenly) claim to understand, and like or dislike, an "operating system" based on its interface.
Whether or not a certain interface runs on a particular hardware or operating system platform is usually dictated by economics, marketing, and politics - not technology.
CITS2002 Systems Programming, Lecture 4, p2, 6th August 2019.
Better still, because the "controls" are often temporal or external to the operating system itself, let's consider the operating system as a resource manager.
Most of the time, the operating system relinquishes control to the user processes until the hardware again dispatches the control.
CITS2002 Systems Programming, Lecture 4, p3, 6th August 2019.
New hardware is constantly introduced - adding more memory presents little difficulty; new types of disks, video cards, etc, are more problematic.
New application programs, tools, and system services are added.
Fixes and patches are released to correct operating system deficiencies.
All of this suggests that the operating system, as a program, needs to be extensible - a modular design seems essential. Consider the following figure, taken from Stallings.
Of course, the above diagram provides a very simplified representation of an operating system. In practice, the relationships between the modular components become very complex - Linux and Windows-10.
CITS2002 Systems Programming, Lecture 4, p4, 6th August 2019.
Memory management:
divide and share physical memory among several processes.
Swapping:
move processes and their data between main memory and disk to present the illusion of a bigger machine.
File system:
organize mass storage (on disk) into files and directories.
CITS2002 Systems Programming, Lecture 4, p5, 6th August 2019.
A command interface:
textual or graphical, to enable interactive interrogation and manipulation of operating system features.
System calls:
allow constrained access to the interior of the running operating system (as a program).
Protection:
keep processes from interfering with each other, their data, and "the system", whilst permitting sharing when requested.
Communication:
allow users and processes to communicate within a single machine (inter-process communication), and across networks.
CITS2002 Systems Programming, Lecture 4, p6, 6th August 2019.
Advances in operating systems often accompanied advances in hardware, falling prices, and "exploding" capacities.
The first true digital computer was designed by English mathematician Charles Babbage (1792-1871).
Although Babbage spent most of his working life and fortune building his "analytical engine", its mechanical design and the wooden technology of the day could not provide the required precision.
Needless to say, the analytical engine did not have an operating system.
“
Everything that can be invented has been invented.
— Charles H. Duell, Commissioner, U.S. Office of Patents, 1899.
A single group of people designed, built, programmed, operated and maintained each machine. Although filling small warehouses, with tens of thousands of vacuum tubes, they were no match for today's cheapest home computers.
“
I think there is a world market for maybe five computers.
— Thomas Watson (1874-1956), Chairman of IBM, 1943.
Programs were loaded manually using console switches, or more likely by direct reconfiguration of wiring; indication of a program's execution and debugging returned through console lights.
Advantages:
Interactive, and user received immediate response.
Disadvantages:
Expensive machine was idle most of the time, because people's reactions (and thinking) were slow.
Programming and debugging were tedious; hardware was very unreliable.
Each program was self contained, including its own code for mathematical functions and I/O device support.
CITS2002 Systems Programming, Lecture 4, p8, 6th August 2019.
Given the high cost of computers, ways to increase their utility were quickly sought. The general solution was the batch system.
Similar/related programs, perhaps each requiring the FORTRAN (FORmula TRANslation) compiler, or a set of mathematical routines, were batched together so that the required routines need only be
physically loaded once.
Programs were first written on paper tape, in the emerging FORTRAN language or in assembly language, and then copied to punched cards. Such decks of cards included job control cards, the program itself, and often the program's data.
Jobs submitted by different users were sequenced automatically by the operating system's resident monitor. Early peripherals, such as large magnetic tape drives, were used to batch input (jobs and data) and spool (from Simultaneous Peripheral Operation OnLine) output.
CITS2002 Systems Programming, Lecture 4, p9, 6th August 2019.
Advantages:
Disadvantages:
The computer was no longer interactive. Jobs experienced a longer turnaround time.
The CPU was still idle much of the time for jobs. Other jobs remained queued for execution.
The significant operating system innovation at this time was the introduction of a command interpreter (a job control language - JCL) to describe, order, and commence execution of jobs.
The resident monitor was also protected from the user programs, and managed the automated loading of the programs after the monitor.
CITS2002 Systems Programming, Lecture 4, p10, 6th August 2019.
Incompatibility and a lack of an upgrade path were the problems of the day.
IBM attempted to address both problems with the release of their System/360, a family of software compatible machines differing only in capacity, price and performance. The machines had the same architecture and instruction set.
With heavy CPU-bound scientific calculations, I/O is infrequent, so the time spent (wasted) waiting was not significant. However, commercial processing programs in the emerging COBOL (Computer Oriented Business Organizational Language) often spent 80-90% of its time
waiting for I/O to complete.
The advent of separate I/O processors made simultaneous I/O and CPU execution possible.
The CPU was multiplexed (shared), or employed multiprogramming, amongst a number of jobs - while one job was waiting for I/O from comparatively slow I/O devices (such as a keyboard or tape), another job could use the CPU.
Jobs would run until their completion or until they made an I/O request.
Advantages:
Disadvantages:
The computer hardware and the operating system software became significantly more complex (and there has been no looking back since!).
CITS2002 Systems Programming, Lecture 4, p11, 6th August 2019.
The introduction of timesharing introduced pre-emptive scheduling. Jobs would execute for at most pre-defined time interval, after which it was another job's turn to use the CPU.
The first serious timesharing system (CTSS, from MIT 1962) lacked adequate memory protection.
Most (modern) operating system complexity was first introduced with the support of multiprogramming - scheduling algorithms, deadlock prevention, memory protection, and memory management.
The world's first commercially available time-sharing computer, the DEC PDP-6, was installed in UWA's Physics Building in 1965 - cf.
Cyberhistory, by Keith Falloon, UWA MSc thesis, 2001, and pdp6-serials.
CITS2002 Systems Programming, Lecture 4, p12, 6th August 2019.
There was little to protect and, if an errant program modified the executive program, then only the current user was affected. As more of a courtesy, the executive might clear memory segments and check itself before accepting the
next program.
As multi-tasking operating systems emerged, accountability of resource use became necessary, and operating system monitors oversaw the execution of programs.
Until system resources became more plentiful (and cheaper) attempts were made to maximize resource sharing - security was a consequent, not an initial, goal.
e.g. process scheduling policies were dominated by already running processes requesting resources (such as libraries and tapes) that were already in use.
At this level, computer security is more concerned with reliability and correctness, than about deliberate attacks on programs, data, and the system.
CITS2002 Systems Programming, Lecture 4, p13, 6th August 2019.
“
There are only two things to come out of Berkeley, Unix and LSD, and I
don't think this is a coincidence.
— Jeremy S. Anderson.
Another major development occurring in parallel was the phenomenal growth in minicomputers, starting with the DEC (Digital Equipment Corporation) PDP-1 (Programmed Data Processor) in 1961. The PDP-1, with 4K of 18-bit words cost only US$120,000 - 5% of the IBM
7094.
The trend was towards many small mid-range personal computers, rather than a single mainframe.
Early minicomputers and microcomputers were simple in their hardware architectures, and so there was some regression to earlier operating system ideas (single user, no pre-emption, no multiprogramming).
For example, MS-DOS on an IBM-PC (circa. 1975) was essentially introduced as a batch system, similar to those of the 1960s, with a few modern additions, such as a hierarchical file system.
With some notable exceptions, the trend quickly moved towards support of all modern operating system facilities on microcomputers.
“
There is no reason anyone would want a computer in their home.
— Ken Olsen, DEC Founder and Chairman, 1977.
Perhaps most significant has been the evolution, and importance, of operating systems' user interfaces.
In particular, the graphical desktop metaphor has remained for some time.
CITS2002 Systems Programming, Lecture 4, p14, 6th August 2019.
“
640K ought to be enough for anybody.
— Bill Gates (1955-), in 1981.
The decentralization of computing resources, now data and not the hardware, required more support for inter-operating system communication - both physical support and application program support.
As minicomputers shrunk in size, but exploded in capacity, the powerful computer workstation was born. Companies such as Sun Microsystems (SUN) and Silicon Graphics (SGI) rode this wave of success.
Local-area networks (primarily Ethernet and token-ring) connected workstations, while wide-area networks connected minicomputers.
Operating system developments included the development of fast and efficient network communication protocols, data encryption (of networks and file systems), security, reliability, and consistency of distributed data.
CITS2002 Systems Programming, Lecture 4, p15, 6th August 2019.
High speed, long distance communication links encourage graphical and audio communication (surpassing text).
The desktop metaphor for computer interfaces becomes only an instance of the wider web metaphor.
Computers again become idle; artificial uses (such as complex screensavers) keep them busy.
“
For years, we thought that a million monkeys sitting at a million
keyboards would produce the complete works of Shakespeare. Today,
thanks to the Internet, we know that's not true.
— Anon.
Introducing functions
C is a procedural programming language, meaning that its primary synchronous control flow mechanism is the procedure call.
C names its procedures functions (in contrast, Java has a different mechanism - methods).
In Mathematics, we apply a function, such as the trigonometric function cos, to one or more values.
The function performs an evaluation, and returns a result.
We've already seen the example of main() - the function that all C programs must have, which we might write in different ways:
int main(int argcount, char *argvalue[]) int main(int argcount, char *argvalue[])
{ {
// check the number of arguments int result;
if(argcount != 2)
{ // check the number of arguments
.... if(argcount != 2)
exit(EXIT_FAILURE); {
} ....
else result = EXIT_FAILURE;
{ }
.... else
exit(EXIT_SUCCESS); {
} ....
return 0; result = EXIT_SUCCESS;
} }
return result;
}
The operating system calls main(), passing to it some (command-line) arguments, main() executes some statements, and returns to the operating system a result - usually EXIT_SUCCESS or EXIT_FAILURE.
CITS2002 Systems Programming, Lecture 5, p1, 12th August 2019.
1. Functions allow us to group together statements that have a strongly related purpose - statements, in combination, performing a single task.
We prefer to keep such statements together, providing them with a name (as for variables), so that we may refer to the statements, and call them, collectively.
2. We often have sequences of statements that appear several times throughout larger programs.
The repeated sequences may be identical, or very similar (differing only in a very few statements). We group together these similar statement sequences, into a named function, so that we may call the function more than once and have it perform similarly for each call.
Historically, we'd identify and group similar statements into functions to minimize the total memory required to hold the (repeated) statements.
Today, we use functions not just to save memory, but to enhance the robustness and readability of our code (both good Software Engineering techniques).
3. From the Systems Programming perspective, the operating system kernel employs functions as well-defined entry points from user-written code into the kernel.
4. Functions provide a convenient mechanism to package and distribute code. We can distribute code that may be called by other people's code, without providing them with a complete program.
2. Soon, we'll write our own functions in other, multiple files, and call them from our main file.
The most prominent example, that we've already seen, is C's standard library - a collection of frequently required functions that must be provided by a standards' conforming C compiler.
In our programming, so far, we've already called library functions such as:
4. Similarly, there are many task-specific 3rd-party libraries. They are not required to come with your C compiler, but may be downloaded or purchased - from Lecture 1 -
report errors detected with command-line arguments, and then call exit(EXIT_FAILURE),
call functions from main(), typically passing information requested and provided by the command-line arguments, and
All 'normal' output printed to the stdout stream (if not to a requested file).
CITS2002 Systems Programming, Lecture 5, p4, 12th August 2019.
1. functions whose role is to just perform a task, and to then return control to the statement (pedantically, the expression) that called it.
Such functions often have side-effects, such as performing some output, or modifying a global variable so that other statements may access that modified value.
These functions don't return a specific value to the caller, are termed void functions, and we casually say that they "return void".
2. functions whose role is to calculate a value, and to return that value for use in the expressions that called them. The single value returned will have a type, such as int, char, bool, or float.
Note that this 2nd example uses the sqrt() function from the standard maths library.
We should thus compile it with: cc [EXTRAOPTIONS] -o program program.c -lm
CITS2002 Systems Programming, Lecture 5, p5, 12th August 2019.
each of these expressions has a datatype. In the above example, each of the expressions in an int.
when the function is called, the expressions are evaluated, and the value of each expression is assigned to the parameters of the function:
during the execution of the function, the parameters are local variables of the function.
They have been initialized with the calling values (x = 12 * 45 ...), and the variables exist while the function is executing.
They "disappear" when the function returns.
Quite often, functions require no parameters to execute correctly. We declare such functions with:
The order of evaluation of parameters is not defined in C. For example, in the code:
....
....
sum( square(3), square(4) );
or
Do not assume that function parameters are evaluated left-to-right. The compiler will probably choose the order of evaluation which produces the most efficient code, and this will vary on different processor architectures.
Some novice programmers think that the matching of names is how the arguments are evaluated, and how arguments are bound to parameters.
For example, consider the code:
....
int a, b, c;
a = 1;
b = 4;
c = 9;
printf("%i\n", sum3(c, a, b) );
Here, the arguments are not "shuffled" until the names match.
It is not the case that arguments must have the same names as the parameters they are bound to.
Similarly, the names of variables passed as arguments are not used to "match" arguments to parameters.
If you ever get confused by this, remember that arithmetic expressions, such as 2*3 + 1, do not have names, and yet they are still valid arguments to functions.
CITS2002 Systems Programming, Lecture 5, p8, 12th August 2019.
return x;
and sometimes:
return(x);
While both are correct, the parentheses in the 2nd example are unnecessary.
return is not a function call, it is a statement, and so does not need parentheses around the returned value.
However - at any point when writing code, use extra parentheses if they enhance the readability of your code.
We've carefully introduced the concepts that functions receive strongly typed parameters, that a fixed number of function arguments in the call are bound to the parameters, and that parameters are then considered as local variables.
#include <stdio.h>
int i = 238;
float x = 1.6;
In these cases, the first argument is always a string, but the number and datatype of the provided arguments keeps changing.
printf() is one of a small set of standard functions that permits this apparent inconsistency. It should be clear that the format specifiers of the first argument direct the expected type and number of the following arguments.
Fortunately, within the ISO-C99 specification, our cc compiler is permitted to check our format strings, and warn us (at compile time) if the specifiers and arguments don't "match".
Although the variety of computer system configurations is forever changing, as (new) component types employ different standards for their interconnection, it is still feasible to discuss a simple computer model, and to discuss components' roles in operating systems.
The Central Processing Unit, or CPU, undertakes arithmetic and logical computation, and directs most input and output services from memory and peripherals. There may be multiple processors in a system, each
executing the (same, single) operating system or user/application programs.
Main Memory, or RAM (Random Access Memory) is used to store both instructions and data. Processors read and write items of memory both at the direction of programs (for data), and as an artifact of running programs
(for instructions).
Secondary Storage and Peripheral Devices, (or input/output modules) and their I/O controllers, move data to and from the other components usually to provide longer-term, persistent storage of data (disks, tapes),
A communications bus, or system bus, connects the processor(s), main memory, and I/O devices together, providing a "highway" on which data may travel between the components. Typically only one component may
control the bus at once, and bus arbitration decides which that will be.
Depending on why the CPU requested the memory's value, it executes the contents as an instruction, or operates on the contents as data.
Similarly, the CPU locates data from, or for, distinct locations in the I/O devices using its I/O-AR (Address Register).
The OS attempts to attain maximum throughput of its computation and data transfer.
Processor scheduling attempts to keep the (expensive) processor busy at all times, by interleaving computation and communication.
While for a slow device to complete its I/O transfer, the CPU may be able to undertake other activities, such as performing some computation or managing faster I/O.
Processor Registers
As well as special logic to perform arithmetic and logic functions, the processor houses a small number of very fast memory locations, termed processor registers.
Data in registers can be read and written very rapidly (with a typical access time of 0.5-3ns). If the required data is available in registers, rather than main memory, program execution may proceed 10-500X faster.
Different types of processors have varying number of registers, For example, some processors have very few (3-16), some have many (32-100s).
The number of general-purpose CPU registers, and the width of each register (measured in bits, e.g. 64-bit registers), contribute to the power and speed of a CPU.
Processors place constraints on how some registers are used. Some processors expect certain types of data to reside in specific registers. For example, some registers may be expected to hold integer operands for integer arithmetic instructions, whereas some
registers may be reserved for holding floating-point data.
Data must first be copied into registers; the operation (e.g. addition) is then performed on the registers and the result left in a register, and that result (possibly) copied back to RAM.
Registers are also often used to hold a memory address, and the register's contents used to indicate which item from RAM to fetch.
The transfer of data to and from registers is completely transparent to users (even to programmers).
Generally, we only employ assembly language programs to manipulate registers directly. In compiled high-level languages, such as C, the compiler translates high-level operations into low-level operations that access registers.
CITS2002 Systems Programming, Lecture 6, p4, 13th August 2019.
Register types
Registers are generally of two types:
User-accessible registers -
are accessible to programs, and may usually be read from and written to under program control. Programs written in an assembly language, either by a human programmer or generated by a compiler for a high-level language, are able to read and write these registers
using specific instructions understood by the processor which usually accept the names of the registers as operands.
Data registers hold values before the execution of certain instructions, and hold the results after executing certain instructions.
Address registers hold the addresses (not contents) of memory locations used in the execution of a program, e.g.
the memory address register (MAR) holds the address of memory to be read or written;
the memory buffer register (MBR) holds the memory's data just read, or just about to be written;
index registers hold an integer offset from which of memory references are made; and
a stack pointer (SP) holds the address of a dedicated portion of memory holding temporary data and other memory addresses.
Special registers reflect the status of the processor. The processor status word (PSW) reflects whether or not the processor may be interrupted by I/O devices and whether privileged instructions may be executed, and it uses condition bits to reflect the status of recently
executed operations.
In order evaluate results, and to determine if branching should occur, the PSW may record -
We also expect memory to be able to provide the necessary data, as quickly as possible, when called upon. Unfortunately, there is a traditional trade-off between cost, capacity, and access time:
the faster the access time, the greater the cost per bit,
the greater the capacity, the smaller the cost per bit and, the greater the capacity, the slower the access time.
a modest amount of cache memory (1MB) to deliver data as quickly as possible to the processor,
a larger main memory (8GB) to store entire programs and less-frequently required data, and
long term, persistent storage in the form of a hard disk (1TB), or SSD (256GB).
CITS2002 Systems Programming, Lecture 6, p7, 13th August 2019.
Although processors provide instructions to set and compare single bits, it is rarely the most efficient method of manipulating data.
Bits are organised into larger groupings to store values encoded in binary bits. The most basic grouping is the byte: the smallest normally addressable quantum of main memory (which can be different from the minimum amount of memory fetched at one time).
In modern computers, a byte is almost always an 8-bit byte, but history has seen computers with 7-, 8-, 9-, 12-, and 16-bit bytes.
A word is the default data size for a processor. The word size is chosen by the processor's designer and reflects some basic hardware issues (such as the width of internal or external buses).
The most common word sizes are 16 and 32 bits; historically words have ranged from 16 to 60 bits.
Some processors require that data be aligned, that is, 2-byte quantities must start on byte addresses that are multiples of two; 4-byte quantities must start on byte addresses that are multiples of four; etc.
Some processors allow data to be unaligned, but this can result in slower execution as the processor may have to align the data itself.
CITS2002 Systems Programming, Lecture 6, p8, 13th August 2019.
However, it is important to realise that the processor can interpret a sequence of bits only in context: on its own, a sequence of bits means nothing.
4 ASCII characters,
a 32-bit integer,
2 x 16-bit integers,
1 floating point value,
the address of a memory location, or
an instruction to be executed.
No meaning is stored along with each bit pattern: it is up to the processor to apply some context to the sequence to ascribe it some meaning.
For example, a sequence of integers may form a sequence of valid processor instructions that could be meaningfully executed; a sequence of processor instructions can always be interpreted as a vector of, say, integers and can thus be added together.
Critical errors occur when a bit sequence is interpreted in the wrong context. If a processor attempts to execute a meaningless sequence of instructions, a processor fault will generally result: Linux announces this as a "bus error". Similar faults occur when instructions expect
data on aligned data boundaries, but are presented with unaligned addresses.
CITS2002 Systems Programming, Lecture 6, p9, 13th August 2019.
However, another program could read the bytes from /bin/ls and interpret them in other ways, e.g. as 32-bit integers:
prompt> od -i /bin/ls
0000000 1179403647 65793 0 0
0000020 196610 1 134518416 52
0000040 66628 0 2097204 2621447
0000060 1638426 6 52 134512692
0000100 134512692 224 224 5
0000120 4 3 276 134512916
0000140 134512916 19 19 4
0000160 1 1 0 134512640
.......
prompt> od -b /bin/ls
0000000 177 105 114 106 001 001 001 000 000 000 000 000 000 000 000 000
0000020 002 000 003 000 001 000 000 000 220 226 004 010 064 000 000 000
0000040 104 004 001 000 000 000 000 000 064 000 040 000 007 000 050 000
0000060 032 000 031 000 006 000 000 000 064 000 000 000 064 200 004 010
0000100 064 200 004 010 340 000 000 000 340 000 000 000 005 000 000 000
0000120 004 000 000 000 003 000 000 000 024 001 000 000 024 201 004 010
0000140 024 201 004 010 023 000 000 000 023 000 000 000 004 000 000 000
0000160 001 000 000 000 001 000 000 000 000 000 000 000 000 200 004 010
.......
or as ASCII characters:
prompt> od -c /bin/ls
0000000 177 E L F 001 001 001 \0 \0 \0 \0 \0 \0 \0 \0 \0
0000020 002 \0 003 \0 001 \0 \0 \0 220 226 004 \b 4 \0 \0 \0
0000040 D 004 001 \0 \0 \0 \0 \0 4 \0 \0 \a \0 ( \0
0000060 032 \0 031 \0 006 \0 \0 \0 4 \0 \0 \0 4 200 004 \b
0000100 4 200 004 \b 340 \0 \0 \0 340 \0 \0 \0 005 \0 \0 \0
0000120 004 \0 \0 \0 003 \0 \0 \0 024 001 \0 \0 024 201 004 \b
0000140 024 201 004 \b 023 \0 \0 \0 023 \0 \0 \0 004 \0 \0 \0
0000160 001 \0 \0 \0 001 \0 \0 \0 \0 \0 \0 \0 \0 200 004 \b
.......
Introducing arrays
As programs become more complex, we notice that they require more variables, and thus more variable names to hold all necessary values.
We could define:
int x1, x2, x3, x4, x5..... ;
but referring to them in our programs will quickly become unwieldy, and their actual names may be trying to tell us something.
In particular, our variables are often related to one another - they hold data having a physical significance, and the data value held in one variable is related to the data in another variable.
For example, consider a 2-dimensional field, where each square metre of the field may be identified by its rows and column coordinates. We may record each square's altitude, or temperature, or its number of ants:
Like most languages, C provides a simple data structure, termed an array, to store and access data where the data items themselves are closely related.
Depending on the context, different problem domains will describe different kinds of arrays with different names:
1-dimensional arrays
C provides support for 1-dimensional arrays by allowing us to identify the required data using a single index into the array.
Syntactically, we use square-brackets to identify that the variable is an array, and use an integer expression inside the array to identify which "part" of it we're requiring.
In all cases, an array is only a single variable, with one or more elements.
We declare our 1-dimensional arrays with square brackets, and indicate the maximum number of elements within those brackets.
#define N 20
A fixed, known value (here N, with the value 20) is used to specify the number of elements of the array.
int myarray[ N ]; We access elements of the array by providing the array's name, and an integer index into the array.
int evensum;
Elements of an array may be used in the same contexts as basic (scalar) variables. Here myarray is used on both the left-hand and right-hand sides of assignment statements.
evensum = 0;
for(int i=0 ; i < N ; ++i) We may also pass array elements as arguments to functions, and return their values from functions.
{
myarray[ i ] = i * 2; Array indicies start "counting" from 0 (not from 1).
evensum = evensum + myarray[ i ];
} Because our array consists of N integers, and indicies begin at zero, the highest valid index is actually N-1.
initialize the elements at run-time, by executing statements to assign values to the elements:
#define N 5
int myarray[ N ];
....
we may initialize the values at compile-time, by telling the compiler what values to initially store in the memory represented by the array. We use curly-brackets (braces) to provide the initial values:
#define N 5
int myarray[ N ] = { 0, 1, 2, 3, 4 };
we may initialize the values at compile-time, by telling the compiler what values to initially store in the memory represented by the array, and having the compiler determine the number of elements in the array(!).
int myarray[ ] = { 0, 1, 2, 3, 4 };
or, we may initialize just the first few values at compile-time, and have the compiler initialize the rest with zeroes:
Variable-length arrays
All of the examples of 1-dimensional arrays we've seen have the array's size defined at compile time:
#define N 5
int global_array[ N ];
....
As the compiler knows the exact amount of memory required, it may generate more efficient code (in both space and time) and more secure code.
More generally, an array's size may not be known until run-time. These arrays are termed variable-length arrays, or variable-sized arrays. However, once defined, their size cannot be changed.
In all cases, variable-length arrays may be defined in a function and passed to another. However, because the size is not known until run-time, the array's size must be passed as well. It is not possible to determine an array's size from its name.
void function1(void)
{
int size = read an integer from keyboard or a file;
function2(size, vla);
}
Variable-length arrays were first defined in the C99 standard, but then made optional in C11 - primarily because of their inefficient implementation on embedded devices. Modern Linux operating system kernels are now free of variable-length arrays.
CITS2002 Systems Programming, Lecture 7, p4, 19th August 2019.
However, C compilers provide some basic support for strings by considering strings to simply be arrays of characters.
We've already seen this support when calling the printf() function:
printf("I'm afraid I can't do that Dave\n");
The double quotation characters simply envelope the characters to be treated as a sequence of characters.
In addition, a standards' conforming C compiler is required to also provide a large number of string-handling functions in its standard C library. Examples include:
#include <string.h>
int strcmp( char str1[], char str2[] ); // to determine if two strings are equal
In reality these functions are not "really" managing strings as a basic datatype, but are just managing arrays of characters.
CITS2002 Systems Programming, Lecture 7, p5, 19th August 2019.
array[3] = '\0';
The null byte is used to indicate the end of a character sequence, and it exists at the end of all strings that are defined within double-quotes.
Inside the computer's memory we have: Of note, when dealing with strings:
Because the null byte has special significance, and because we may think of strings and character arrays as the same thing, we can manipulate the contents of strings by changing the array elements. Consider:
h e l l o w o r l d \0
array[5] = '\0';
the space between the two words is replaced by the null byte.
The result is that the array still occupies 12 bytes of storage, but if we tried to print it out, we would only get hello.
CITS2002 Systems Programming, Lecture 7, p7, 19th August 2019.
Copying strings
As strings are so important, the standard C library provides many functions to examine and manipulate strings.
However, C provides no basic string datatype, so we often need to treat strings as array of characters.
// DETERMINE THE STRING LENGTH, THEN USE A BOUNDED LOOP // DO NOT WRITE STRING-PROCESSING LOOPS THIS WAY
void my_strcpy(char destination[], char source[]) void my_strcpy(char destination[], char source[])
{ {
int length = strlen(source); int i;
// USE AN UNBOUNDED LOOP, COPYING UNTIL THE NULL-BYTE // USE AN UNBOUNDED LOOP, COPYING UNTIL THE NULL-BYTE
void my_strcpy(char destination[], char source[]) void my_strcpy(char destination[], char source[])
{ {
int i = 0; int i = 0;
while(source[i] != '\0') do
{ {
destination[i] = source[i]; destination[i] = source[i];
i = i+1; i = i+1;
} } while(source[i-1] != '\0');
destination[i] = '\0'; }
}
Fortunately, we need to learn very little - we now call standard function sprintf, rather than printf, to perform our formatting.
#include <stdio.h>
char chess_outcome[64];
if(winner == WHITE)
{
sprintf(chess_outcome, "WHITE with %i", nwhite_pieces);
}
else
{
sprintf(chess_outcome, "BLACK with %i", nblack_pieces);
}
printf("The winner: %s\n", chess_outcome);
We must be careful, now, not to exceed the maximum length of the array receiving the formatted printing.
Thus, we prefer functions which ensure that not too many characters are copied:
char chess_outcome[64];
To date, we've always employed the "traditional" mechanism of incrementing integer values, in both assignment statements and in for loops:
int value = 0;
....
value = value + 1;
....
for(int i=0 ; i < MAXVALUE ; ++i)
{
....
}
C provides a shorthand notation for incrementing and decrementing scalar variables, by one:
int value = 0;
char ch = 'z';
....
for(int i=0 ; i < MAXVALUE ; ++i)
{
....
}
The notation used above is always used to increment or decrement by one, and the 2 statements:
int x = 0;
int y = 0;
int what = 0;
what = ++x; // 1 1 0
what = y++; // 0 1 1
what = y++; // 1 1 2
what = ++y; // 3 1 3
Shorthand arithmetic
A similar notation may be used to perform any standard arithmetic operation on a variable. For example, assuming the correct declarations:
Processes
The fundamental activity of an operating system is the creation, management, and termination of processes.
More particularly, we consider how the operating system itself views a process:
It is a clear requirement of modern operating systems that they enable many processes to execute efficiently, by maximising their use of the processor, by supporting inter-process communication, and by maintaining reasonable response time.
This is an ongoing challenge: as hardware improves, it is "consumed" by larger, "hungrier" pieces of interlinked software.
CITS2002 Systems Programming, Lecture 8, p1, 20th August 2019.
Process States
We can view the process from two points of view: that of the processor, and that of the process itself.
The processor's view is simple: the processor's only role is to execute machine instructions from main memory. Over time, the processor continually executes the sequence of instructions indicated by the program counter (PC).
The processor is unaware that different sequences of instructions have a logical existence, that different sequences under execution are referred to as processes or tasks.
From the process's point of view, it is either being executed by the processor, or it is waiting to be executed (for simplicity here, we consider that a terminated process no longer exists).
We've identified two possible process states that a process may be in at any one time: Running and Ready.
Process Transitions
The operating system's role is to manage the execution of existing and newly created processes by moving them between the two states until they finish.
1. Newly created processes are created and marked as Ready, and are queued to run.
2. There is only ever a single process in the Running state. It will either:
be suspended (by itself or by the operating system), be marked as Ready, and be again queued to run.
Here the operating system has the role of a dispatcher - dispatching work for the processor according to some defined policy addressing a combination of fairness, priority, apparent "interactivity", ...
___________
For simplicity (of both understanding and implementation) modern operating systems support the idle process which is always ready to run, and never terminates.
When scheduling is discussed, we will introduce process priorities when deciding which Ready process should be the next to execute.
Process Creation
In supporting the creation of a new process, the operating system must allocate resources both for the process and the operating system itself.
The process (program under execution) will require a portion of the available memory to contain its (typically, read-only) instructions and initial data requirements. As the process executes, it will demand additional memory for its execution stack and its heap.
The operating system (as dispatcher) will need to maintain some internal control structures to support the migration of the process between states.
an "under-burdened" operating system may take new process requests from a batch queue.
a user logging on at a terminal usually creates an interactive control or encapsulating process (shell or command interpreter).
an existing process may request a new process,
the operating system itself may create a process after an indirect request for service (to support networking, printing, ...)
by requesting that an existing process be duplicated (ala fork() call in macOS and Linux),
by instantiating a process's image from a named location, typically the program's image from a disk file (ala the spawn() call in (old)DEC-VMS and the CreateProcess() call in Windows).
CITS2002 Systems Programming, Lecture 8, p5, 20th August 2019.
Process Termination
Stallings summarises typical reasons why a process will terminate:
normal termination,
execution time-limit exceeded,
a resource requested is unavailable,
an arithmetic error (division by zero),
a memory access violation,
an invalid request of memory or a held resource,
an operating system or parent process request, or
its parent process has terminated.
These and many other events may either terminate the process, or simply return an error indication to the running process. In all cases, the operating system will provide a default action which may or may not be process termination.
It is clear that process termination may be requested (or occur) when a process is either Running or Ready. The operating system (dispatcher) must handle both cases.
If a process is considered as a (mathematical) function, its return result, considered as a Boolean or integral result, is generally made available to (some) other processes.
CITS2002 Systems Programming, Lecture 8, p6, 20th August 2019.
Timer Interrupts
Why does a process move from Running to Ready?
The operating system must meet the two goals of fairness amongst processes and maximal use of resources (here, the processor and, soon, memory).
The first is easily met: enable each process to execute for a predetermined period before moving the Running process to the Ready queue.
A hardware timer will periodically generate an interrupt (say, every 10 milliseconds). Between the execution of any two instructions, the processor will "look for" interrupts. When the timer interrupt occurs, the processor will begin execution of the interrupt handler.
The handler will increment and examine the accumulated time of the currently executing process, and eventually move it from Running to Ready.
The maximum time a process is permitted to run is often termed the time quantum.
CITS2002 Systems Programming, Lecture 8, p7, 20th August 2019.
However, the existence of processes which continually execute to the end of their time quanta, often termed compute-bound processes, is rare.
More generally, a process will request some input or output (I/O) from a comparatively slow source (such as a disk drive, tape, keyboard, mouse, or clock). Even if the "reply" to the request is available immediately, a synchronous check of this will often exceed the remainder of
the process's time quantum. In general the process will have to wait for the request to be satisfied.
The process should no longer be Running, but it is not Ready either: at least not until its I/O request can be satisfied.
We now introduce a new state for the operating system to support, Blocked, to describe processes waiting for I/O requests to be satisfied. A process requesting I/O will, of course, request the operating system to undertake the I/O, but the operating system supports this as three
activities:
(Very simply) when the I/O completion interrupt occurs, the requesting process is moved from Blocked to Ready.
A degenerate case of blocking occurs when a process simply wishes to sleep for a given period. We consider such a request as "blocking until a timer interrupt", and have the operating system handle it the same way.
CITS2002 Systems Programming, Lecture 8, p8, 20th August 2019.
New for newly created processes which haven't yet been admitted to the Ready queue for resourcing reasons;
Exit for terminated processes whose return result or resources may be required by other processes, e.g. for post-process accounting.
Each of these states, except for Running, is likely to 'hold' more than one process (i.e. there may be more than one process in one of these states).
If a queue of processes is not always ordered in a first-come-first-served (FCFS) manner, then a priority or scheduling mechanism is necessary.
CITS2002 Systems Programming, Lecture 8, p9, 20th August 2019.
A typical example would have one queue for processes blocked on disk-drive-1, another blocked on disk-drive-2, another blocked on the keyboard ......
Then, when an interrupt occurs, it's quick to determine which process(es) should be unblocked, and moved to Ready.
CITS2002 Systems Programming, Lecture 8, p11, 20th August 2019.
The process's state is reflected by where it resides, although its state will also record much other information.
Suspension of Processes
Recall that the processor is much faster than I/O. As a consequence, it is quite possible for all processes to be blocked on I/O requests, when the processor will be idle most of the time while waiting for I/O interrupts.
Question: How to get more executing processes, given that resources such as memory are finite?
To enable more true work to be performed by the processor, we could provide more memory to support the requirements of more processes.
But aside from the expense, providing more memory tends to encourage larger processes, not (in general) better support for more processes.
CITS2002 Systems Programming, Lecture 8, p13, 20th August 2019.
Swapping of Processes
Another solution is swapping: moving (part of) a process's memory to disk when it is not needed.
When none of the processes in main memory is Ready, the operating system swaps the memory of some of the Blocked processes to disk to recover some memory space. Such processes are moved to a new state: the Suspend state, a queue of processes that have been
"kicked out" of main memory:
If desperate for even more memory, the operating system can similarly reclaim memory from Ready processes.
When memory becomes available, the operating system may now resume execution of a process from Suspend, or admit a process from New to Ready.
CITS2002 Systems Programming, Lecture 8, p14, 20th August 2019.
CITS2002 Systems Programming
At the lowest level, an operating system will only communicate using bytes, not with higher-level integers or floating-point values. C99 employs arrays of characters to hold the bytes in requests for raw input and output.
In combination, our C99 programs will use integer file descriptors and arrays of characters to request that the operating system performs input and output on behalf of the process - see man 2 open.
#include <stdio.h>
#include <fcntl.h>
#include <stdlib.h>
#include <unistd.h>
Note that the functions open, read, and close are not C99 functions but operating system system-calls, providing the interface between our user-level program and the operating system's implementation.
#include <stdio.h>
#include <fcntl.h>
#include <stdlib.h>
#include <unistd.h>
close(fd0); close(fd1);
return 0;
}
The returned value is a FILE pointer, that we use in all subsequent operations with that file.
#include <stdio.h>
....
// ATTEMPT TO OPEN THE FILE FOR READ-ACCESS
FILE *dict = fopen(DICTIONARY, "r");
If fopen() returns the special value NULL, it indicates that the file may not exist, or that the operating system is not giving us permission to access it as requested.
CITS2002 Systems Programming, Lecture 9, p3, 26th August 2019.
We pass different strings as the second parameter to fopen() to declare how we'll use the file:
All future operations to read and write an open file are checked against the initial file access mode. Future operations will fail if they do not match the initial declaration.
NOTE - File access mode flag "b" can optionally be specified to open a file in binary mode (described later). This flag has effect only on Windows systems, and is ignored on macOS and Linux. This flag has no effect when reading and writing text files.
The text data in the file is (unsurprisingly) also stored as a sequence of characters. We can use C's character arrays to store each line as we read it:
#include <stdio.h> we pass to fgets() our character array, into which each line will be read.
.... we indicate the maximum size of our array, so that fgets() doesn't try to read in too much.
FILE *dict;
char line[BUFSIZ]; We use the sizeof operator to indicate the maximum size of our character array.
Using sizeof here, rather than just repeating the value BUFSIZ, means that we can change the size of line at any time, by only changing the size at its definition.
dict = fopen( ..... );
.... We pass our file pointer, dict, to our file-based functions to indicate which file to read or write.
It's possible to have many files open at once.
// READ EACH LINE FROM THE FILE,
// CHECKING FOR END-OF-FILE OR AN ERROR
The fgets() functions returns the constant NULL when it "fails". When reading files, this indicates that the end-of-file has been reached, or some error detected (e.g. USB key removed!).
while( fgets(line, sizeof line, dict) != NULL )
{
....
Assuming that we've reached end-of-file, and that we only need to read the file once, we close the file pointer (and just assume that the closing has succeeded).
.... // process this line
....
}
// AT END-OF-FILE (OR AN ERROR), CLOSE THE FILE
fclose(dict);
a a r d v a r k \n \0
The character '\n', the familiar newline character often used in print(), is silently added to text lines by our text editors.
In fact on Windows' machines, text files also include a carriage-return character before the newline character.
W i n d o w s \r \n \0
As we know, we can simply turn this into a more manageable string by replacing the newline or carriage-return character by the null-byte.
CITS2002 Systems Programming, Lecture 9, p6, 26th August 2019.
It's very similar to functions like my_strlen() that we've written in laboratory work:
We note:
The file pointer passed to fputs() must previously have been opened for writing or appending.
#include <stdio.h>
#include <stdlib.h>
In the game of Scrabble, each letter tile has a certain value - the rarer a letter is in the English language, the higher the value of that letter ('E' is common and has the value
1, 'Q' is rare and has the value 10).
Another consideration (which we'll ignore) is that there are fixed tile frequencies - for example, there are
12 'E's but only 1 'Q'.
Letter Value
#include <stdbool.h> A 1
#include <string.h>
B 3
#include <ctype.h>
C 3
// ENSURE THAT A WORD CONTAINS ONLY LOWERCASE CHARACTERS D 2
bool valid_word(char word[])
{ E 1
int i = 0; F 4
// IF NOT A LOWERCASE CHARACTER, THE FUNCTION RETURNS false G 2
while(word[i] != '\0') { H 4
if( ! islower( word[i] )) { // if not islower ...
return false; I 1
} J 8
i = i+1;
} K 5
// WE'VE REACHED THE END OF THE WORD - IT'S ALL LOWERCASE L 1
return true;
} M 3
N 1
// CALCULATE THE SCRABBLE VALUE OF ANY WORD
int calc_value(char word[])
O 1
{ P 3
// AN ARRAY TO PROVIDE THE VALUE OF EACH LETTER, FROM 'a' TO 'z'
int values[] = { 1, 3, 3, 2, 1, 4, 2, 4, 1, 8, 5, 1, 3, 1,
Q 10
1, 3, 10, 1, 1, 1, 1, 4, 4, 8, 4, 10 }; R 1
int total = 0;
S 1
int i = 0;
T 1
// TRAVERSE THE WORD DETERMINING THE VALUE OF EACH LETTER
U 1
while(word[i] != '\0') {
total = total + values[ word[i] - 'a' ]; V 4
i = i+1; W 4
}
return total; X 8
} Y 4
Z 10
#include <stdio.h>
#include <stdbool.h>
#include <string.h>
#include <ctype.h>
This has provided a good introduction to file input/outpout (I/O) as textual data is easy to "see", and printing it to the screen helps us to verify our functions:
the standard fgets function manages the differing lengths of input lines by reading until the '\n' or '\r' character is found,
fgets terminates input lines by appending a null-byte to them, 'turning' them into C strings, and
the null-byte is significant when later managing (copying, printing, ...) strings.
CITS2002 Systems Programming, Lecture 9, p12, 26th August 2019.
#include <stdio.h>
#include <stdlib.h>
char buffer[BUFSIZ];
size_t got, wrote;
NOTE - The access mode flag "b" has been used in both calls to fopen() as we're anticipating opening binary files. This flag has effect only on Windows systems, and is ignored on macOS and Linux. This flag has no effect when reading and writing text files.
While we might request that fread() fetches a known number of bytes, fread() might not provide them all!
This mechanism enables our programs to read arbitrary sized data, by setting eachsize to one (a single byte), or to read a known number of data items each of the same size:
#include <stdio.h>
fclose(fp);
The result is that when binary data written via one architecture (such as an Intel Pentium) is read back on a different architecture (such as an IBM PowerPC), the "result" will be different!
#define N 10 #define N 10
The problems of reading and writing of binary data, to and from different architectures and across networks, are discussed and solved in later units, such as CITS3002 Networks and Security.
Jonathan Swift's Gulliver's Travels, published in 1726, provided the earliest literary reference to computers, in which a machine would write books. This early attempt at artificial intelligence was characteristically marked by its inventor's call for public funding and the employment of student operators. Gulliver's diagram of the machine actually contained errors, these being either an attempt to protect his
invention or the first computer hardware glitch.
The term endian is used because of an analogy with the story Gulliver's Travels, in which Swift imagined a never-ending fight between the kingdoms of the Big-Endians and the Little-Endians (whether you were Lilliputian or Brobdignagian), whose only difference is in where they crack open a hard-boiled egg.
the value returned by fork() in the parent process will be the process-indentifier, of process-ID, of the child process;
the value returned by fork() in the child process will be 0, indicating that it is the child, because 0 is not a valid process-ID.
Each successful invocation of fork() returns a new monotonically increasing process-ID (the kernel 'wraps' the value back to the first unused positive value when it reaches 100,000).
#include <stdio.h>
#include <unistd.h>
void function(void)
{
int pid; // some systems define a pid_t
produces:
Of note, calling sleep(1) may help to separate the outputs, and we fflush() in each process to force its output to appear.
CITS2002 Systems Programming, Lecture 10, p1, 27th August 2019.
init has the process-ID of 1. It is the ancestor process of all subsequent processes.
In addition, because the operating system strives to maintain a hierarchical relationship amongst all processes, a process whose parent terminates is 'adopted' by the init process.
the parent waits for the child's termination, calling the blocking function wait( &status ).
[optionally] the child process replaces details of its program (code) and data (variables) by calling the execve() function.
the child calls exit(value), with an integer value to represent its success or failure. By convention, zero (= EXIT_SUCCESS) indicates successful execution, non-zero otherwise.
the child's value given to exit() is written by the operating system to the parent's status.
A parent process calls the wait() system call to suspend its own execution, and to wait for any of its child processes to terminate.
The (new?) syntax &status permits the wait() system call (in the operating system kernel) to modify the calling function's variable. In this way, the parent process is able to receive information about how the child process terminated.
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
void function(void)
{
switch ( fork() ) {
case -1 :
printf("fork() failed\n"); // process creation failed
exit(EXIT_FAILURE);
break;
printf("parent waiting\n");
child = wait( &status );
}
}
Importantly, both the parent and child have their own copy of their program's memory (variables, stack, heap).
The parent naturally uses the memory that it had before it called fork(); the child receives its own copy of the same memory. The copy is made at the time of the fork().
As execution proceeds, each process may update its own memory without affecting the other process.
[ OK, I lied - on contemporary operating systems, the child process does not receive a full copy of its parent's memory at the time of the fork():
the child can share any read-only memory with its parent, as neither process can modify it.
the child's memory is only copied from the parent's memory if either the parent modies its (original) copy, or if the child attempts to write to its copy (that it hasn't yet received!)
this sequence is termed copy-on-write.
]
Under Unix/Linux, a new program may replace the currently running program. The new program runs as the same process (it has the same pid, confusing!), by overwriting the current process's memory (instructions and data) with the instructions and data of the new program.
The single system call execv() requests the execution of a new program as the current process:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
char *program_arguments[] = {
"ls",
"-l",
"-F",
NULL
};
....
execv( "/bin/ls", program_arguments );
// A SUCCESSFUL CALL TO exec() DOES NOT RETURN
The single system call is supported by a number of library functions (see man execl) which simplify the calling sequence.
Typically, the call to execv() (via one of its library interfaces) will be made in a child process, while the parent process continues its execution, and eventually waits for the child to terminate.
CITS2002 Systems Programming, Lecture 10, p6, 27th August 2019.
The operating system is able to use the exit status of a program to determine if it was successful.
Consider the following program which exits with the integer status provided as a command-line argument:
#include <stdio.h>
#include <stdlib.h>
if(argc > 1) {
status = atoi(argv[1]);
}
printf("exiting(%i)\n", status);
exit(status);
}
Shells are typically programmed using files of commands named shellscripts or command files and these will often have conditional constructs, such as if and while, just like C. It's thus important for our programs to work with the shells that invoke them.
We now compile our program, and invoke it with combinations of zero, and non-zero arguments:
It is considered desirable to minimise the number of system calls that an operating system provides, in the belief that doing so simplifies design and improves correctness. Unix 6th Edition (circa 1975) had 52 system calls, whereas the modern macOS system boasts 521 (see
/usr/include/sys/syscall.h).
We've recently seen an "explosion" in the number of system calls provided, as systems attempt to support legacy and current 32-bit system calls, while introducing new 64-bit and multi-processor calls.
Does the complexity of an OS's implementation matter? Linux, Windows (both about 2011).
Some material in this lecture is from two historic texts, both available in the Science Library:
Marc J. Rochkind,
Advanced Unix Programming, Prentice-Hall, 1985.
W. Richard Stevens,
Advanced Programming in the Unix Environment, Addison-Wesley, 1992.
CITS2002 Systems Programming, Lecture 11, p1, 2nd September 2019.
Interfaces to C
The system call interfaces of modern operating systems are presented as an API of C-language prototypes, regardless of the programmer's choice of application language (C++, Java, Visual-Basic). This is a clear improvement over earlier interfaces in assembly languages.
The technique used in most modern operating systems is to provide an identically-named interface function in a standard C library or system's library (for example /usr/lib/system/libsystem_kernel.dylib on macOS and /lib/libc.so.6 on Linux).
An application program, written in any programming language, may invoke the system calls provided that the language's run-time mechanisms support the operating system's standard calling convention.
In the case of a programming language employing a different calling convention, or requiring strong controls over programs (e.g. running them in a sandbox environment, as does Java), direct access to system calls may be limited.
As the context switch between application process and the kernel is relatively expensive, most error checking of arguments is performed within the library, avoiding a call of the kernel with incorrect parameters:
#include <syscall.h>
#include <unistd.h>
.....
But also, system calls need to be ''paranoid'' to protect the kernel from memory access violations! They will check their arguments, too.
CITS2002 Systems Programming, Lecture 11, p2, 2nd September 2019.
The kernel will use a consistent mechanism, such as using a processor register or the top of the run-time stack, to return a status indicator to a process. As this mechanism is usually of a fixed size, such as 32 bits, the value returned is nearly always an integer, occasionally a
pointer (an integral value interpreted as a memory address).
For this reason, globally accessible values such as errno, convey additional state, and values 'returned' via larger structures are passed to the kernel by reference (cf. getrusage() - discussed later).
The status interface employed by Unix/Linux and its C interface involves the globally accessible integer variable errno. From /usr/include/sys/errno.h:
Obvious exceptions are those system calls needing to return many possible correct values - such as open() and read(). Here we often see -1 as the return value indicating failure.
CITS2002 Systems Programming, Lecture 11, p3, 2nd September 2019.
As a convenience (not strictly part of the kernel interface), the array of strings sys_errlist[] may be indexed by errno to provide a better diagnostic:
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
...
if(chdir("/Users/someone") != 0)
{
printf("cannot change directory, why: %s\n", sys_errlist[errno]);
exit(EXIT_FAILURE);
}
...
or, alternatively, we may call the standard function perror() to provide consistent error reporting:
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
...
Note that a successful system call or function call will not set the value of errno to zero. The value will be unchanged.
CITS2002 Systems Programming, Lecture 11, p4, 2nd September 2019.
Ideally, all system call parameters are of the same length, such a 32-bit integers and 32-bit addresses.
It is very uncommon for an operating system to use floating point values, or accept them as arguments to system calls.
Depending on the architecture, the syscall() entry point will eventually invoke a TRAP or INT machine instruction - an 'illegal' instruction, or software interrupt, causing the hardware to jump to code which examines the required system call number and retrieves its arguments.
#define SYSCALL3(x) \
.globl NAME(x) ; \
NAME(x): \
push %ebx; \
mov 8(%esp), %ebx; \
mov 12(%esp), %ecx; \
mov 16(%esp), %edx; \
lea SYS_##x, %eax; \
int $0x80; \
pop %ebx; \
ret; \
END(x)
There is a clear separation of duties between system calls and their calling functions. For example, the memory allocation function malloc() calls sbrk() to extend a process's memory space by increasing the process's heap. malloc() and free() later manage this space.
CITS2002 Systems Programming, Lecture 11, p5, 2nd September 2019.
An additional function, linked at a known address, is often provided by the standard run-time libraries to initialise that environment.
For example, the C run-time library provides functions (such as) _init() to initialise (among other things) buffer space for the buffered standard I/O functions. (For example, /usr/include/sys/syslimits.h limits a process's arguments and environment to 256KB).
In particular, command-line arguments and environment variables are located at the beginning of each process's stack, and addresses to these are passed to main() and assigned to the global variable environ.
CITS2002 Systems Programming, Lecture 11, p6, 2nd September 2019.
Environment variables
As with command-line arguments, each process is invoked with a vector of environment variables (NULL-terminated character strings):
These are typically maintained by application programs, such as a command-interpreter (or shell), with calls to standard library functions such as putenv() and getenv().
#include <stdio.h>
#include <stdlib.h>
...
return 0;
}
A process's environment (along with many other attributes) is inherited by its child processes.
Interestingly, the user's environment variables are never used by the kernel itself.
CITS2002 Systems Programming, Lecture 11, p7, 2nd September 2019.
For example, the C library function execlp() may be called to commence execution of a new program:
execlp() - receives the name of the new program, and the arguments to provide to the program, however it does not know how to find the program.
execlp() - locates the value of the environment variable PATH, assuming it to be a colon-separated list of directory names to search,
e.g. PATH="/bin:/usr/bin:.:/usr/local/bin", and appends the program's name to each directory component.
execlp() - makes successive calls to the system call execve() until one of them succeeds in beginning execution of the required program.
This shows how main() may either call exit(), call return, or simply 'fall past its bottom curly bracket'.
CITS2002 Systems Programming, Lecture 11, p9, 2nd September 2019.
CITS2002 Systems Programming
cc is really a front-end program to a number of passes or phases of the whole activity of "converting" our C source files to executable programs:
2. all necessary object files (there may be more than one, and some may be standard C libraries, operating system-specific, or provided by a third-party), are presented to a program named the linker, to be "combined" together, and
cc determines which compilation phases to perform based on the command-line options and the file name extensions provided.
The compiler passes object files (with the filename suffix .o) and any unrecognized file names to the linker.
The linker then determines whether files are object files or library files (often with the filename suffix .a).
The linker combines all required symbols (e.g. your main() function from your .o file and the printf() function from C's standard library) to form the single executable program file.
each file (often containing multiple related functions) may perform (roughly) a single role,
large projects may be undertaken by multiple people each working on a subset of the files,
small changes to one source file do not require all other source files to be recompiled.
All object files are then linked to form a single executable program.
CITS2002 Systems Programming, Lecture 12, p3, 6th September 2019.
Each C file depends on a common header file, which we will name calcmarks.h.
CITS2002 Systems Programming, Lecture 12, p4, 6th September 2019.
The header file is used to announce their existence using the extern keyword.
The header file does not actually provide function implementations (code) or allocate any memory space for the variables.
#include <stdio.h>
#include <stdbool.h>
#include <math.h>
Notice that, although we have indicated that function readmarks() accepts one FILE * parameter, we have not needed to give it a name.
Similarly, we have declared the existence of arrays, but have not indicated/provided their sizes.
CITS2002 Systems Programming, Lecture 12, p5, 6th September 2019.
In particular, we now define the size of the projmarks and exammarks arrays, in a manner dependent on the preprocessor constants from calcmarks.h
This allows us to provide all configuration information in one (or more) header files. Other people modifying your programs, in years to come, will know to look in the header file(s) to adjust the constraints of your program.
0 (for ints),
'\0' (for chars),
0.0 (for floats and doubles),
false (for bools), and
zeroes (for pointers).
Note that we could have omitted the initialisation of verbose to false, but providing an explicit initialisation is much clearer.
CITS2002 Systems Programming, Lecture 12, p6, 6th September 2019.
Thus, our code may now call global functions, and access global variables, without (again) declaring their existence:
if(fp == NULL)
{
printf("Cannot open %s\n", argv[a]);
exit(EXIT_FAILURE);
}
nmarks += readmarks(fp);
// CLOSE THE FILE THAT WE OPENED
fclose(fp);
}
}
// IF WE RECEIVED SOME MARKS, REPORT THEIR CORRELATION
if(nmarks > 0)
{
correlation(nmarks);
}
return 0;
}
In the above function, we have used to a local variable, nmarks, to maintain a value (both receiving it from function calls, and passing it to other functions).
nmarks could have been another global variable but, generally, we strive to minimize the number of globals.
CITS2002 Systems Programming, Lecture 12, p7, 6th September 2019.
double thisproj;
double thisexam;
....
// READ A LINE FROM THE FILE, CHECKING FOR END-OF-FILE OR AN ERROR
while( fgets(line, sizeof line, fp) != NULL )
{
++nmarks;
This is particularly difficult to manage if multiple people are contributing to the same project, each editing different files.
Introducing make
The program make maintains up-to-date versions of programs that result from a sequence of actions on a set of files.
make reads specifications from a file typically named Makefile or makefile and performs the actions associated with rules if indicated files are "out of date".
make operates over rules and actions recursively and will abort its execution if it cannot create an up-to-date file on which another file depends.
Note that make can be used for many tasks other than just compiling C - such as compiling other code from programming languages, reformatting text and web documents, making backup copies of files that have recently changed, etc.
CITS2002 Systems Programming, Lecture 12, p10, 6th September 2019.
we are particularly interested in the dependencies between various files - certain files depend on others and, if one changes, it triggers the "rebuilding" of others:
The executable program prog is dependent on one or more object files (source1.o and source2.o).
Each object file is (typically) dependent on one C source file (suffix .c) and, often, on one or more header files (suffix .h).
So:
NOTE that the source code files (suffix .c) are not dependent on the header files (suffix .h).
Of note:
each target, at the beginning of lines, is followed by the dependencies (typically other files) on which it depends,
each target may also have one or more actions that are performed/executed if the target is out-of-date with respect to its dependencies,
each (line) is passed verbatim to a shell for execution - just as if you would type it by hand.
Very long lines may be split using the backslash character.
CITS2002 Systems Programming, Lecture 12, p12, 6th September 2019.
Although not a full programming language, make supports simple variable definitions and variable substitutions (and even functions!).
C99 = cc -std=c99
CFLAGS = -Wall -pedantic -Werror
Of note:
variables are usually defined near the top of the Makefile.
the variables are simply expanded in-line with $(VARNAME).
warning - the syntax of make's variable substitutions is slightly different to those of our standard shells.
CITS2002 Systems Programming, Lecture 12, p13, 6th September 2019.
PROJECT = calcmarks
HEADERS = $(PROJECT).h
OBJ = calcmarks.o globals.o readmarks.o correlation.o
C99 = cc -std=c99
CFLAGS = -Wall -pedantic -Werror
$(PROJECT) : $(OBJ)
$(C99) $(CFLAGS) -o $(PROJECT) $(OBJ) -lm
clean:
rm -f $(PROJECT) $(OBJ)
Of note:
we have introduced a new variable, PROJECT, to name our project,
the value of the new variable, HEADERS is defined by accessing the value of $(PROJECT),
we have introduced a new variable, OBJ, to collate all of our object files,
our project specifically depends on our object files,
we have a new target, named clean, to remove all unnecessary files. clean has no dependencies, and so will always be executed if requested.
CITS2002 Systems Programming, Lecture 12, p14, 6th September 2019.
make provides a (wide) variety of filename patterns and automatic variables to considerably simplify our actions:
PROJECT = calcmarks
HEADERS = $(PROJECT).h
OBJ = calcmarks.o globals.o readmarks.o correlation.o
C99 = cc -std=c99
CFLAGS = -Wall -pedantic -Werror
$(PROJECT) : $(OBJ)
$(C99) $(CFLAGS) -o $(PROJECT) $(OBJ) -lm
clean:
rm -f $(PROJECT) $(OBJ)
Of note:
the pattern %.o matches, in turn, each of the 4 object filenames to be considered,
the pattern %.c is "built" from the C file corresponding to the %.o file,
the automatic variable $< is "the reason we're here", and
the linker option -lm indicates that our project requires something from C's standard maths library (sqrt() ).
make supports many automatic variables, which it "keeps up to date" as its execution proceeds:
Fortunately, we rarely need to remember all of these patterns and variables, and generally just copy and modify existing Makefiles.
CITS2002 Systems Programming, Lecture 12, p15, 6th September 2019.
CITS2002 Systems Programming
We'll need to consider the role that memory plays from two (conflicting?) perspectives:
the operating system's perspective of how to allocate and manage all memory fairly and efficiently, and
the process's perspective of how to access the memory allocated to it, and how to obtain more.
An important goal of the operating system is to keep as many processes executable as possible, and it will achieve this only when processes have available the memory they require.
Processes requiring specific memory (holding either instructions or data) cannot execute, and are blocked until that memory is available (to them).
For simplicity, we'll initially assume that a process's image occupies a contiguous region of main memory.
CITS2002 Systems Programming, Lecture 13, p1, 9th September 2019.
Although processes in memory often occupy linear sequences of addresses, programs are seldom written this way.
Structured programming and, more recently, object-oriented techniques, encourage/enforce programming using modules which are developed and compiled independently.
Ideally, all references from one module to another are resolved at run-time, maintaining their independence (termed late binding).
Physical Organisation:
The relationship between primary memory (RAM) and secondary memory (disk) is straightforward, but not one that programmers wish to manage. Old techniques termed overlays permitted reuse of a process's memory, but (today) are unnecessarily complex.
Moreover, in a multi-programmed system, the programmer cannot predict the size nor location of a process's memory. The task of moving information between main and secondary memory is clearly the responsibility of the operating system.
CITS2002 Systems Programming, Lecture 13, p2, 9th September 2019.
Even with protection, there is also a need to allow processes to share memory. For example, multiple processes running the same program can share the (read+execute only) instructions for the program, and co-operating processes may wish to share and communicate via
memory containing data structures.
Relocation:
In a multi-programming system, the execution of a single process is often unrelated to others. When a process is first created, it is difficult (if not impossible) to know where its image will be placed when initially loaded into memory.
Similarly, when a process is swapped-out (Suspended), it is unlikely that the process will be swapped-in back to exactly the same memory location.
Memory management determines where both instructions and data are located, i.e. how a process's memory references (requests) translate into actual physical memory addresses.
Protection:
Each process must be protected from either accidental or deliberate "interference" from other processes. Although compilers for high-level programming languages offer some support (e.g. constrained control flow, static array bound references), most data references are
dynamic (array access and pointer following).
Memory references made by a process must be checked (at run-time) to ensure that they lie within the bounds of memory allocated by the operating system.
Checks are performed by hardware at run-time, and invalid references generate an access violation interrupt, trap, or exception, for the operating system software to handle.
The memory protection must be performed by the processor (hardware) rather than the operating system (software), because the operating system cannot anticipate all the memory references that a program will make. Even if this were possible, it would be prohibitively
time-consuming to screen each program in advance for possible memory violations.
CITS2002 Systems Programming, Lecture 13, p3, 9th September 2019.
The simplest technique is to consider main memory being in fixed-sized partitions, with two clear choices:
Any new process whose size is less than or equal to a partition's size may be loaded into that partition.
2. a small process still occupies a full partition. Such wastage of memory is termed internal memory
fragmentation.
The initial choice of partition - the placement algorithm - is, of course, trivial with equal-sized partitions.
Unequal sized partitions offer obvious advantages with respect to these problems, but they complicate the
placement algorithm. Either:
1. a process is placed in the largest (large-enough) partition, to minimise internal memory fragmentation, or
The initial placement algorithm is again simple, but also introduces excessive internal memory fragmentation.
When a process commences, it occupies a partition of exactly the required size, and no more.
As the above figure shows, dynamic partitioning introduces the problem of external memory fragmentation, where there is insufficient contiguous free memory to hold a new process, even though sufficient free memory exists in the system.
CITS2002 Systems Programming, Lecture 13, p6, 9th September 2019.
First-fit:
find the first unused block of memory that can contain the process, searching from Address 0,
Best-fit:
find the smallest unused block that can contain the process, or
Next-fit:
remember where the last process's memory was allocated (say Address k), and find the first unused block that can contain the process, searching from Address k.
CITS2002 Systems Programming, Lecture 13, p7, 9th September 2019.
This assumption actually complicates the memory management task, and contributes to memory fragmentation.
A logical address
is a reference to a memory location independent of any current assignment of data to main memory.
A relative address
is a logical address expressed relative to a fixed (logical) location, such as the beginning of the process's image.
We've previously (implicitly) assumed that when a process is initially loaded (from disk), its relative addresses are replaced by absolute addresses.
More realistically, we enable processes to be swapped-in to any feasible range of physical memory: and this location is unlikely to be the same as before.
CITS2002 Systems Programming, Lecture 13, p8, 9th September 2019.
While a process is executing, we employ a hardware base register to indicate the beginning of the process's partition, and a hardware bounds register to indicate the partition's extent.
Each process requires a pair of (hardware) base and bound registers, and the pair must be saved and restored as each process is swapped-out, and later swapped back in.
CITS2002 Systems Programming, Lecture 13, p9, 9th September 2019.
However, internal fragmentation is bounded by the maximum size of a partition, and so if we allocate to a process several small fixed-sized fragments, we'll see minimal internal fragmentation only within the last fragment, and no external fragmentation.
We term the small, equal-sized 'chunks' of a process's image pages, and place them in equal-sized 'chunks' of main memory, variously termed frames, or page frames.
We can now also remove the restriction (the assumption) that a process's sections of memory must be contiguous. Clearly a single base register is insufficient - we need a large number of base registers, one for each page, to identify the starting address of that page. (We do
not need multiple bounds registers; Why not?)
In the following figure, 6 bits from each 16-bit logical address indicate which page table entry to use. The remaining 10 bits of the logical address are appended to the contents of the page table entry to provide the actual physical address.
The Logical-to-Physical mapping can still be performed in hardware, provided that hardware knows how to access the page table of the current process.
CITS2002 Systems Programming, Lecture 13, p11, 9th September 2019.
CITS2002 Systems Programming
Except for infrequent branches and function/procedure invocation, program execution is sequential. The next instruction to be fetched usually follows the last one executed.
Programs generally operate at the same "depth" of function-invocation. References to instructions cluster within (and between) a small collection of functions.
Most iterative control flow (looping) is over short instruction sequences. Instructions from the same memory locations are fetched several times in succession.
Access to memory locations holding data is, too, constrained to a few frequently required data structures, or sequential steps through memory (e.g. when traversing arrays).
With reference to paging schemes, this locality of reference suggests that, within a process, the next memory reference will very likely be from the same page as the last memory reference.
This will impact heavily on our next enhancement to memory management: the use of virtual memory.
CITS2002 Systems Programming, Lecture 14, p1, 10th September 2019.
Paging vs Partitioning
When we compare paging with the much simpler technique of partitioning, we see two clear benefits:
As processes are swapped-out and then back in, they may occupy different regions of physical memory.
This is possible because hardware efficiently translates each logical address to a physical address, at run-time.
The operating system's memory management software manipulates the hardware (page table registers) to facilitate the translation.
A process is broken into pages and these need not be contiguous in physical memory.
In combination with the principle of program locality, we now have a significant breakthrough:
If the above two characteristics are present, then it is not necessary for all pages of a process to be in memory at any one time during its execution.
CITS2002 Systems Programming, Lecture 14, p2, 10th September 2019.
Advantages of Paging
Execution of any process can continue provided that the instruction it next wants to execute, or the data location it next wants to access, is in physical memory.
If not, the operating system must load the required memory from the swapping (or paging) space before execution can continue.
However, the swapping space is generally on a slow device (a disk), so the paging I/O request forces the process to be Blocked until the required page of memory is available. In the interim, another process may be able to execute.
Before we consider how we can achieve this, and introduce additional efficiency, consider what advantages are now introduced:
More (pieces of) processes may be maintained in main physical memory (either Ready or Running).
Most processes do not require all of their memory before they can execute: memory may be loaded on demand.
If the swapping space is larger than the physical memory, any single process may now demand more memory than the amount of physical memory installed.
This last aspect gives the technique its name: virtual memory.
CITS2002 Systems Programming, Lecture 14, p3, 10th September 2019.
We define a process's set of pages, or segments, in physical memory, as its resident (or working) memory set.
prompt> ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.1 1372 432 ? S Sep12 0:04 init
root 4 0.0 0.0 0 0 ? SW Sep12 0:04 [kswapd]
root 692 0.0 0.2 1576 604 ? S Sep12 0:00 crond
xfs 742 0.0 0.8 5212 2228 ? S Sep12 0:23 xfs -droppriv -da
root 749 0.0 0.1 1344 340 tty1 S Sep12 0:00 /sbin/mingetty tt
...
chris 3865 0.0 0.6 2924 1644 pts/1 S Sep15 0:01 -zsh
chris 25366 0.0 6.0 23816 15428 ? S 14:34 0:06 /usr/bin/firefox
chris 25388 0.0 1.4 17216 3660 ? S 14:34 0:00 (dns helper)
chris 26233 0.0 0.2 2604 688 pts/1 R 19:11 0:00 ps aux
In the steady state, the memory will be fully occupied by the working sets of the Ready and Running processes, but:
If the processes' working sets are permitted to be too large, fewer processes can ever be Ready.
If the processes' working sets are forced to be too small, then additional requests must be made of the swapping space to retrieve required pages or segments.
All modern operating systems employ virtual memory based on paging (Q: can you determine the macOS or Linux page size?). Windows systems also employ virtual memory based on segmentation.
CITS2002 Systems Programming, Lecture 14, p4, 10th September 2019.
Using virtual memory, the same approach is taken, but the contents of the page tables becomes more complex. Page table entries must include additional control information, indicating at least:
if the page has been modified since it was brought into physical memory (an M bit).
To make room for the required page, one or more existing pages must be "evicted" (to the swap space). Clearly, the working set of some process must be reduced.
However, if a page is evicted just before it is required (again), it'll just need to be paged back in! If this continues, the activity of page thrashing is observed.
We hope that the operating system can avoid thrashing with an intelligent choice of the page to discard.
CITS2002 Systems Programming, Lecture 14, p7, 10th September 2019.
A process initially requires the first page containing its starting address (and some initial data structures), but thereafter when should each page be allocated?
A VM system can employ demand paging in which a page is allocated only when a reference to it is made, or predictive pre-paging where pages are "intelligently" allocated before they are required.
Should we use policies such as first-fit, best-fit, or next-fit (which we saw when discussing basic memory partitioning)?
Does it matter?
CITS2002 Systems Programming, Lecture 14, p8, 10th September 2019.
To avoid thrashing, we wish to replace only pages unlikely to be required soon, but this must be balanced against how many frames can be allocated to a process, and if the Running process's pages should be displaced (a local policy) or if other processes' pages can
be displaced (a global policy).
A number of replacement algorithms exist (seemingly a preoccupation of 1970's OS research) which select pages to be replaced.
Fundamental algorithms include first-in, first-out (obvious, but disadvantages long-running programs with high locality) and least-recently-used (almost ideal, but requires time-expensive hardware to maintain time-stamps on page usage).
The degree of multi-programming permitted must balance processor utilisation (minimising idle time due to I/O blocking) against utility (many processes executing with small resident set sizes and possible thrashing).
If used correctly pointers can enable very fast, efficient, execution of C programs.
If misunderstood or used incorrectly, pointers can make your programs do very strange, incorrect things, and result in very hard to diagnose and debug programs.
The primary role of pointers - to allow a program (at run-time) to access its own memory - sounds like a useful feature, but is often described as a very dangerous feature.
There is much written about the power and expressiveness of C's pointers, with general agreement that C's pointers are a threshold concept in Computer Science.
(Recently much has been written about Java's lack of pointers. More precisely, Java does have pointers, termed references, but the references to Java's objects are so consistently and carefully constrained at both compile- and run-
times, that little information about the run-time environment is exposed to the running program, and little can go wrong). Pointers on C, Understanding
Kenneth Reek, and Using C
Addison-Wesley, Pointers,
636pp, 1998. Richard Reese,
O'Reilly Media,
226pp, 2013.
We understand that variables occupy memory locations (1 or more bytes) of a computer's memory.
Each variable requires enough bytes to store the values the variable will (ever) need to hold. For example, on our CSSE labs' computers, a simple C integer will require 4 bytes of memory.
Similarly, an array of 100 integers, will require 400 bytes of contiguous memory - there is no padding between elements.
Computers have a large amount of memory, e.g. our lab computers have 8 gigabytes of memory (8GB), or nearly 8.6 billion bytes.
Each of a computer's memory bytes is uniquely numbered, from 0 to some large value. Each such number is termed the byte's memory address.
We often refer to memory locations as just addresses and the action of identifying an address as addressing.
int total;
"the address of total", and if the integer variable total was located at memory address 10,000 then the value of &total would be 10,000.
int total;
int *p ;
p = &total ;
If the integer variable total was located at memory address 10,000 then the value of p would be 10,000.
If necessary (though rarely), we can print out the address of a variable, or the value of a pointer, by first casting it to something we can print, such as an unsigned integer, or to an "generic" pointer:
int total;
int *p = &total ;
Dereferencing a pointer
We now know that a pointer may point to memory locations holding variables' values.
It should also be obvious that if the variable's value (contents) changes, then the pointer will keep pointing to the same variable, (which now holds the new value).
We can use C's concept of dereferencing a pointer to determine the value the pointer points to:
int total;
int *p = &total ;
total = 3;
Even though the variable's value has changed (from 3 to 4), the pointer still points at the variable's location.
The pointer first pointed at an address containing 3, and the pointer kept pointing at the (same) address which now contains 4.
CITS2002 Systems Programming, Lecture 15, p4, 16th September 2019.
Now we'd like to change the value held in the address that the pointer points to.
int total;
int *p = &total ;
int bigger;
total = 8;
int total;
int *p = &total ;
However, when requiring the address of an array, we're really asking for the address of the first element of that array:
#define N 5
int totals[N];
As we frequently use a pointer to traverse the elements of an array (see the following slides on pointer arithmetic), we observe the following equivalence:
int *p = &totals[0] ;
// and:
int *p = totals ;
That is: "an array's name is synonymous with the address of the first element of that array".
CITS2002 Systems Programming, Lecture 15, p6, 16th September 2019.
Pointer Arithmetic
Another facility in C is the use of pointer arithmetic with which we may change a pointer's value so that it points to successive memory locations.
We specify pointer arithmetic in the same way that we specify numeric arithmetic, using the symbols ++ and - - to request pre- and post- increment and decrement operators.
Consider the following example, which initializes all elements of an integer array:
#define N 5
int totals[N];
int *p = totals; // p points to the first/leftmost element of totals
In our example:
totals[0], then to
totals[1], then to
totals[2] ...
&totals[0], then
&totals[1], then
&totals[2] ...
CITS2002 Systems Programming, Lecture 15, p8, 16th September 2019.
#define N 5
int totals[N];
int *p = totals ;
"set the contents of the location that the pointer p currently points to the value zero, and then increment the value of pointer p by the size of the variable that it points to"
Similarly we can employ pointer arithmetic in the control of for loops. Consider this excellent use of the preprocessor:
int array[N];
int n;
int *a;
FOREACH_ARRAY_ELEMENT
{
if(*a == 0)
{
.....
}
}
A pointer parameter will be initialized with an address when the function is called.
Consider two equivalent implementations of C's standard strlen function - the traditional approach is to employ a parameter that "looks like" an array; new approaches employ a pointer parameter:
int strlen_array( char array[] ) int strlen_pointer( char *strp ) int strlen_pointer( char *strp )
{ { {
int len = 0; int len = 0; char *s = strp;
During the execution of the function, any changes to the pointer will simply change what it points to.
In this example, strp traverses the null-byte terminated character array (a string) that was passed as an argument to the function.
We are not modifying the string that the pointer points to, we are simply accessing adjacent, contiguous, memory locations until we find the null-byte.
CITS2002 Systems Programming, Lecture 15, p10, 16th September 2019.
The C99 standards state that the strcpy function function must return a copy of its (original) destination parameter.
In both cases, we are returning a copy of the (original) dest parameter - that is, we are returning a pointer as the function's value.
Note:
in the array version, the function returns a pointer as its value. This further demonstrates the equivalence between array names (here, dest) and a pointer to the first element of that array.
in the pointer version, we move the dest parameter after we have copied each character, and thus we must first save and then return a copy of the parameter's original value.
if very careful, we could reduce the whole loop to the statement while((*dest++ = *src++));
But don't.
CITS2002 Systems Programming, Lecture 15, p12, 16th September 2019.
CITS2002 Systems Programming
#include <stdio.h>
temp = i;
i = j;
j = temp;
}
The "problem" occurs because we are not actually swapping the values contained in our variables a and b, but are (successfully) swapping copies of those values.
CITS2002 Systems Programming, Lecture 16, p1, 17th September 2019.
We need to give the swap() function "access" to the variables a and b, so that swap() may modify those variables:
#include <stdio.h>
return 0;
}
The function swap() is now dealing with the original variables, rather than new copies of their values.
A function may permit another function to modify its variables, by passing pointers to those variables.
The receiving function now modifies what those pointers point to.
CITS2002 Systems Programming, Lecture 16, p2, 17th September 2019.
Duplicating a string
We know that:
#include <string.h>
1. we'd never be able to know the largest array size required to copy the arbitrary string argument, and
2. we can't return the address of any local variable. Once function my_strdup() returns, variable bigarray no longer exists, and so we can't provide the caller with its address.
CITS2002 Systems Programming, Lecture 16, p3, 17th September 2019.
It is often the case that we do not know, until we execute our programs, how much memory we'll really need!
Instead of using a fixed sized array whose size may sometimes be too small, we must dynamically request some new memory at runtime to hold our desired result.
This is a fundamental (and initially confusing) concept of most programming languages - the ability to request from the operating system additional memory for our programs.
#include <stdlib.h>
For this reason, we use the generic pointer, pronounced "void star" or "void pointer".
It's a pointer to "something", and we only "know" what that is when we place an interpretation on the pointer.
malloc() needs to be informed of the amount of memory that it should allocate - the number of bytes we require.
We use the standard datatype size_t to hold an integer value that may be 0 or positive (we obviously can't request a negative amount of memory!).
We have used, but skipped over, the use of size_t before - it's the datatype of values returned by the sizeof operator, and the pedantically-correct type returned by the strlen() function.
CITS2002 Systems Programming, Lecture 16, p4, 17th September 2019.
Note that a machine's operating system will probably not allocate all memory to a single program, anyway. There's a lot going on on a standard computer, and those other activities all require memory, too.
For programs that perform more than a few allocations, or even some potentially large allocations, we need to check the value returned by malloc() to determined if it succeeded:
#include <stdlib.h>
Strictly speaking, we should check all allocation requests to both malloc() and calloc().
CITS2002 Systems Programming, Lecture 16, p5, 17th September 2019.
When duplicating a string, we need enough new bytes to hold every character of the string, including a null-byte to terminate the string.
This is 1 more than the value returned by strlen:
#include <stdlib.h>
#include <string.h>
if(new != NULL) {
strcpy(new, str); // ENSURES THAT DUPLICATE WILL BE NUL-TERMINATED
}
return new;
}
Of note:
we are not returning the address of a local variable from our function - we've solved both of our problems!
we're returning a pointer to some additional memory given to us by the operating system.
this memory does not "disappear" when the function returns, and so it's safe to provide this value (a pointer) to whoever called my_strdup2.
the new memory provided by the operating system resides in a reserved (large) memory region termed the heap. We never access the heap directly (we leave that to malloc()) and just use (correctly) the space returned by malloc().
CITS2002 Systems Programming, Lecture 16, p6, 17th September 2019.
#include <stdlib.h>
if(array != NULL) {
for(int i=0 ; i<wanted ; ++i) {
array[i] = rand() % 100;
}
}
return array;
}
Of note:
We know how many integers we want, wanted, and we know the space occupied by each of them, sizeof(int).
We thus just multiply these two to determine how many bytes we ask malloc() for.
CITS2002 Systems Programming, Lecture 16, p7, 17th September 2019.
#include <stdlib.h>
It's lost in C's history why malloc() and calloc() have different calling sequences.
To explain what is happening, here, we can even write our own version, if we are careful:
#include <stdlib.h>
#include <string.h>
if(result != NULL) {
memset( result, 0, nbytes ); // SETS ALL BYTES IN result TO THE VALUE 0
}
return result;
}
....
int *intarray = my_calloc(N, sizeof(int));
Think of it as requesting that some of the allocated heap memory be given back to the operating system for re-use.
#include <stdlib.h>
if(vector != NULL) {
// USE THE vector
......
}
free( vector );
Note, there is no need for your programs to completely deallocate all of their allocated memory before they exit - the operating system will do that for you.
CITS2002 Systems Programming, Lecture 16, p9, 17th September 2019.
Even then, depending on the input given to our program, or the execution of our program, we often need to allocate more than our initial "guess".
The C99 standard library provides a function, named realloc() to grow (or rarely shrink) our previously allocate memory:
We pass to realloc() a pointer than has previously been allocated by malloc(), calloc(), or (now) realloc().
Most programs wish to extend the initially allocated memory:
Of note:
if realloc() fails to allocate the revised size, it returns the NULL pointer.
if successful, realloc() copies any old data into the newly allocated memory, and then deallocates the old memory.
if the new request does not actually require new memory to be allocated, realloc() will usually return the same value of oldpointer.
a request to realloc() with an "initial" address of NULL, is identical to just calling malloc().
CITS2002 Systems Programming, Lecture 16, p10, 17th September 2019.
Writing a simple sorting function is easy (you may have developed one in our Labsheet 3). However, writing a very efficient sort function is difficult, and it's just the kind of thing we want standard libraries to provide for us.
#include <stdlib.h>
3. the 1st parameter is a pointer (think of it as an array), but qsort() doesn't care if it's an array of integers, an array of characters, or even an array of our own user-defined types.
For this reason, we use the generic pointer, pronounced "void star" or "void pointer". This means that qsort() receives a pointer (the beginning of an array), but qsort() doesn't care what type it is.
4. the 2nd parameter indicates how many elements are in the array (how many are to be sorted). Standard C's type size_t is used (think of it as an integer whose value must be 0 or positive).
5. the 3rd parameter indicates the size of each array element. As qsort() doesn't know the datatype it's sorting, it needs to know how "big" each element is.
We'll first focus on the main() function, which fills an array with some random integers, prints the array, sorts the array, and again prints the array.
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <time.h>
#define N 10
return 0;
}
Each time, qsort() passes the address of 2 elements in the array being sorted. As qsort() doesn't know the datatype of the array it's sorting, it can only pass (to us) void pointers.
3. We finally report (return) to qsort() the ordering of these two values. qsort() expects:
i. a negative value if the 1st value is less-than the 2nd,
ii. zero if the 1st value and 2nd values are equal, and
iii. a positive value if the 1st value is greater-than the 2nd.
We can support this simply by subtracting one value from the other.
Notice that compareints() does not modify the array's values being sorted, and so the use of const pointers is correct.
CITS2002 Systems Programming, Lecture 16, p14, 17th September 2019.
CITS2002 Systems Programming
The goal of the project was to manage the statistics of AFL teams throughout the season, calculating their positions on the premiership ladder at the end of each week.
It's clear that the variables are all strongly related, but that we're naming and accessing them as if they are independent.
CITS2002 Systems Programming, Lecture 17, p1, 23rd September 2019.
Defining structures
Instead of storing and identifying related data as independent variables, we prefer to "collect" it all into a single structure.
C provides a mechanism to bring related data together, structures, using the struct keyword.
We can now define and gather together our related data with:
We now have a single variable (named rgb_colour) that is a structure, and at its point of definition we have initialised each of its 4 fields.
struct {
char teamname[MAX_TEAMNAME_LEN+1]; // +1 for null-byte
Each element of the array has a number of fields, such as its teamname (a whole array of characters) and an integer number of points.
CITS2002 Systems Programming, Lecture 17, p3, 23rd September 2019.
We use a single dot ('.' or fullstop) to separate the variable name from the field name.
While it requires more typing(!), it's clear that the fields all belong to the same structure (and thus team).
Moreover, the names teamname, played, .... may now be used as "other" variables elsewhere.
CITS2002 Systems Programming, Lecture 17, p4, 23rd September 2019.
So that the information about the structures (the datatypes and names of the structure's fields) can be known by both the operating system and users' programs, these structures are defined in system-wide header files - typically in /usr/include and /usr/include/sys.
For example, consider how an operating system may represent time on a computer:
#include <stdio.h>
#include <sys/time.h>
// A value accurate to the nearest microsecond but also has a range of years
struct timeval {
int tv_sec; // Seconds
int tv_usec; // Microseconds
};
Note that the structure has now been given a name, and we can now define multiple variables having this named datatype (in our previous example, the structure would be described as anonymous).
We can now request information from the operating system, with the information returned to us in structures:
#include <stdio.h>
#include <sys/time.h>
....
perform_work();
....
Here we are passing the structure by address, with the & operator, so that the gettimeofday() function can modify the fields of our structure.
(we're not passing a meaningful pointer as the second parameter to gettimeofday(), as we're not interested in timezone information)
CITS2002 Systems Programming, Lecture 17, p5, 23rd September 2019.
We've seen "one side" of this situation, already - when we passed the address of a structure to a function:
Consider the following example, in which a pointer to a structure is returned from a function.
We now use the → operator (pronounced the 'arrow', or 'points-to' operator) to access the fields via the pointer:
#include <stdio.h>
#include <time.h>
void greeting(void)
{
time_t NOW = time(NULL);
struct tm *tm = localtime(&NOW);
We use the typedef keyword to define our new datatype in terms of an old (existing) datatype.
typedef struct {
char teamname[MAX_TEAMNAME_LEN+1]; // +1 for null-byte
....
int played;
....
} TEAM;
TEAM team[MAX_TEAMS];
As a convention (but not a C99 requirement), we'll define our user-defined types using uppercase names.
....
(After a post-project workshop) we later modified the 2-dimensional arrays to use dynamically-allocated memory:
int n_home_stops = 0;
int n_dest_stops = 0;
As we saw with time-based information, we can ask the operating system for information about files and directories, by calling some system-provided functions.
We employ another POSIX† function, stat(), and the system-provided structure struct stat, to determine the attributes of each file:
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>
char *progname;
†POSIX is an acronym for "Portable Operating System Interface", a family of standards specified by the IEEE for maintaining compatibility between operating systems. POSIX defines the application programming interface (API), along with command line shells and utility
interfaces, for software compatibility with variants of Unix (such as macOS and Linux) and other operating systems (e.g. Windows has a POSIX emulation layer).
CITS2002 Systems Programming, Lecture 17, p9, 23rd September 2019.
The formats used to store information in directories in different file-systems are different(!), and so when writing portable C programs, we prefer to use functions that work portably.
Consider the strong similarities between opening and reading a (text) file, and opening and reading a directory:
With directories, we're again discussing functions that are not part of the C99 standard, but are defined by POSIX standards.
CITS2002 Systems Programming, Lecture 17, p10, 23rd September 2019.
To answer those questions, we need to employ the POSIX function, stat(), to determine the attributes of the items we find in directories:
..... .....
while((dp = readdir(dirp)) != NULL) { while((dp = readdir(dirp)) != NULL) {
struct stat stat_buffer; struct stat stat_buffer;
struct stat *pointer = &stat_buffer;
The file system is used not only to store users' programs and data, but also to support and represent significant portions of the operating system itself.
fields
represent the smallest logical item of data "understood" by a file-system: examples including a single integer or string. Fields may be of a fixed length (recorded by the file-system as "part of" each file), or be of variable length, where either the length is stored with the field or
a sentinel byte signifies the extent.
records
consist of one or more fields and are treated as a single unit by application programs needing to read or write the record. Again, records may be of fixed or variable length.
files
are familiar collections of identically represented records, and are accessed by unique names. Deleting a file (by name) similarly affects its component records. Access control restricts the forms of operations that may be performed on files by various classes of users and
programs.
databases
consist of one or more files whose contents are strongly (logically) related. The on-disk representation of the data is considered optimal with respect to its datatypes and its expected methods of access.
CITS2002 Systems Programming, Lecture 18, p1, 24th September 2019.
The basic unit of access, the record from Stallings's taxonomy, is the byte, and access to these is simply keyed using the byte's numeric offset from the beginning of each file (the size of the variable holding this offset will clearly limit a file's extent).
The file-systems of modern operating systems, such as Linux's ext2, ext3, and ext4 systems and the Windows-NT File System (NTFS), support volumes with sizes up to 1 exbibyte (EiB) and files with sizes up to 16 tebibytes (TiB).
In commercial systems, such as for a large eCommerce system, a database management system (DBMS) many store and provide access to database information independent of an operating system's representation of files.
The DBMS manages a whole (physical) disk-drive, or a dedicated partition, and effectively assumes much of the operating system's role in managing access, security, and backup facilities.
CITS2002 Systems Programming, Lecture 18, p2, 24th September 2019.
Operating systems provide a layer of system-level software, using system-calls, to provide services relating to the provision of files.
The consistent use of system-calls for this task, obviates the need for each application program to manage its own disk (space) allocation and access.
Moreover, using system-calls to enter the kernel permits the OS to use file-system access as an opportunity to schedule processes. As a kernel-level responsibility, the file management system again provides a central role in resource (buffer) allocation and process scheduling.
From the "viewpoint" of the operating system kernel itself, the file management system has a number of goals:
User Requirements
A further critical requirement in a multi-tasking, multi-user system is that the file management system meet users' requirements in the presence of different users.
the recording of a primary owner of each file (for access controls and accounting),
each user should be able to create, delete, read, and modify files,
a file's owner should be able to modify the types of access that may be performed by others,
a secure logging and notification system if inappropriate access is attempted (or achieved).
CITS2002 Systems Programming, Lecture 18, p4, 24th September 2019.
The device drivers communicate directly with I/O hardware. Device drivers commence I/O requests and receive asynchronous notification of their completion (via DMA).
The basic file system exchanges fixed-sized pieces (blocks) of data with the device drivers, buffers those blocks in main memory, and maps blocks to their physical location.
The I/O supervisor manages the choice of device, its scheduling and status, and allocates I/O buffers to processes.
The logical I/O layer maps I/O requests to blocks, and maintains ownership and access information about files.
CITS2002 Systems Programming, Lecture 18, p5, 24th September 2019.
A directory itself is typically a special type of file storing information about the files (and hence directories) it contains.
Although a directory is "owned" by a user, the directory is truly owned by the operating system itself. The operating system must constrain access to important, and often hidden, information in the directory itself.
Although the "owner" of a directory may examine and (attempt to) modify it, true modification is only permitted through OS system-calls affecting the internal directory structure.
For example, deleting a file involves both deallocating any disk blocks used by that file, and removing that file's information from its container directory.
If direct modification of the directory were possible, the file may become "unlinked" in the hierarchical structure, and thereafter be inaccessible (as it could not be named and found by name).
In general, each directory is stored as a simple file, containing the names of files within it. The directory's "contents" may be read with simple user-level routines (introduced in Lecture 17).
CITS2002 Systems Programming, Lecture 18, p6, 24th September 2019.
As an example, Unix refers to these information structures as inodes, accessing them by their integral inode number on each device (themselves referred to by two integral device numbers).
Each information structure is accessed by a unique key (unique to the I/O device), and the role of the directory is now simply to provide <filename, inode> pairs.
Individual processes access open files though their file descriptor table;
this table indexes the kernel's global file table; and
that table indexes the device's inode table.
The simplest policy (akin to memory partitioning in process management) is the use of a fixed or contiguous allocation. This requires that a file's maximum (ever) size be determined when the file is created, and that the file cannot grow beyond this limit (as in the above figure).
The file allocation table stores, for each file, its starting block and length.
Like simple memory allocation schemes, this method suffers from both internal fragmentation (if the initial allocation is too large) and external fragmentation (as files are deleted over time).
Again, as with memory allocation, a compaction scheme is required to reduce fragmentation ("defragging" your disk).
CITS2002 Systems Programming, Lecture 18, p9, 24th September 2019.
Here, the blocks allocated to a file form a linked list (or chain) and, as a file's length is extended (by appending to the file), a new block is allocated and linked to the last block in the file:
A small "pointer" of typically 32 or 64 bits is allocated within each file data block to indicate the next block in the chain. Thus seeking within a file requires a read of each block to follow the pointers.
New blocks may be allocated from any free block on the disk. In particular, a file's blocks need no longer be contiguous.
CITS2002 Systems Programming, Lecture 18, p10, 24th September 2019.
This method was championed by the Multics operating system in 1966. The file-allocation table contains a multi-level index for each file - just as we have seen in the use of inodes, which contain direct pointers to data blocks, and pointers to indirection blocks (which point to
more data blocks).
Indirection blocks are introduced each time the total number of blocks "overflows" the previous index allocation.
Typically, the indices are neither stored with the file-allocation table nor with the file, and are retained in memory when the file is opened.
Pedantically, printf() actually sends its output to the stdout (pronounced standard-output) stream which, by default, is connected to the screen, but may be redirected to a file or even to another program (using operating system features, not C features).
Here, our program "doesn't care" where its output is going - to the default location (the screen), to a file, or through a pipe.
In our programs, we could choose to explicitly send to the stdout stream, instead of printf(), with:
stdout and stderr are two of the standard I/O streams that are created and initialized by the C runtime system when the execution of new C programs is commenced.
In general, we prefer to write "normal" output to stdout, and errors to stderr, and we'll adopt this practice in the rest of the unit.
if(fp == NULL) {
fprintf(stderr, "Cannot open results.data\n");
exit(EXIT_FAILURE);
}
else {
fprintf(fp, .....);
.....
}
The standard perror() function (from Lecture-11) also sends its output to stderr.
CITS2002 Systems Programming, Lecture 19, p1, 7th October 2019.
This is standard mechanism by which a single program receives its input via a named file, the contents of a redirected file, or through a pipe. We describe such a program as a filter [see The Art of Unix Programming, ch07]:
#include <stdio.h>
if(infp == NULL) {
fprintf(stderr, "%s: cannot open %s\n", argv[0], argv[a]);
exit(EXIT_FAILURE);
}
result = process(infp);
fclose(infp); // WE OPENED, SO WE CLOSE IT
}
}
return result;
}
Unix-based operating systems provide file descriptors, simple integer values, to identify 'communication channels' - such as files, interprocess-communication pipes, (some) devices, and network connections (sockets).
C99 defines the FILE * datatype (the file-pointer) as an abstraction over file descriptors to manage (simplify?) access to descriptors.
(As we know) when calling C99 standard functions we provide, or receive, a file-pointer. When calling OS system-calls we provide, or receive, a file-descriptor.
Buffered Input
When reading from a file, the code for using file pointers and file descriptors can be very similar.
However, the standard I/O streams perform input buffering - the data read from a file is actually read into a large memory buffer (owned by the user's process and 'remembered' by the file-pointer) and then 'dished out' to the requesting application:
Remember that the read() function is a system-call, and that system-calls can be expensive because it permits the operating system to reschedule the requesting process from the RUNNING state to the BLOCKED state if the data is not ready.
while((got = fread(buffer,1,sizeof buffer,fp)) > 0) { while((got = read(fd, buffer, sizeof buffer)) > 0) {
...... .....
} }
fclose(fp); close(fd);
} }
Consider what may happen in the above code if the value of MYSIZE is not always 10000, but is 1, 10, 1000, or 100000.
Buffered Output
Similarly, a big distinction between stdout and stderr is that the former is buffered (for efficiency), while the latter is unbuffered (to ensure output appears immediately).
A consequence of this is that, if all of your output is sent to stdout and your program crashes, you may not see all of your output.
CITS2002 Systems Programming, Lecture 19, p4, 7th October 2019.
We've noticed that the main() function receives command-line arguments from its calling environment (usually the operating system):
#include <stdio.h>
We know:
While we typically associate argv with strings, we remember that C doesn't innately support strings. It's only by convention or assumption that we may assume that each value of argv[i] is a pointer to something that we'll treat as a string.
In the previous example, we print "from" the pointer. Alternatively, we can print every character in the arguments:
#include <stdio.h>
The operating system actually makes argv much more usable, too:
each argument is guaranteed to be terminated by a null-byte (because they are strings), and
the argv array is guaranteed to be terminated by a NULL pointer.
CITS2002 Systems Programming, Lecture 19, p6, 7th October 2019.
For programs on Unix-derived systems (such as Apple's macOS and Linux), these are termed command switches, and their introductory character is a hyphen, or minus.
Keep in mind, too, that many utilities appear to accept their command switches in (almost) any order. For the common ls program to list files, each of these is equivalent:
ls -l -t -r files
ls -lt -r files
ls -ltr files
ls -rtl files
Of note, neither the operating system nor the shell know the switches of each program, so it's up to every program to detect them, and report any problems.
CITS2002 Systems Programming, Lecture 19, p7, 7th October 2019.
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
char *progname;
bool dflag = false;
--argc; ++argv;
In addition, command switches do not just indicate, or toggle, a Boolean attribute to the program, but often provide additional string values and numbers to further control the program.
To simplify the task of processing command switches in our programs, we'll use the function getopt().
getopt() is not a function in the Standard C library but, like the function strdup(), it is widely available and used. In fact, getopt() conforms to a different standard - an POSIX standard [2], which provides functions enabling operating system portability.
#include <stdio.h>
#include <stdbool.h>
#include <unistd.h>
#include <getopt.h>
.....
opterr = 0;
while((opt = getopt(argc, argv, OPTLIST)) != -1) {
if(opt == 'd')
dflag = !dflag;
else
argc = -1;
}
if(argc < 0) {
fprintf(stderr, "Usage: %s [-d] [filename]\n", progname);
exit(EXIT_FAILURE);
}
while(optind < argc) {
process( argv[optind] );
++optind;
}
.....
return 0;
}
#include <stdio.h>
#include <stdbool.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <getopt.h>
opterr = 0;
while((opt = getopt(argc, argv, OPTLIST)) != -1) {
// ACCEPT A BOOLEAN ARGUMENT
if(opt == 'd') {
dflag = !dflag;
}
// ACCEPT A STRING ARGUMENT
else if(opt == 'f') {
filenm = strdup(optarg);
}
// ACCEPT A INTEGER ARGUMENT
else if(opt == 'n') {
value = atoi(optarg);
}
// OOPS - AN UNKNOWN ARGUMENT
else {
argc = -1;
}
}
getopt sets the global pointer variable optarg to point to the actual value provided after -n - regardless of whether any spaces appear between the switch and the value.
CITS2002 Systems Programming, Lecture 19, p11, 7th October 2019.
CITS2002 Systems Programming
More recently, we've focused on arrays of values, whose required size was only known at run-time.
In the case of dynamic arrays we've used C99 functions such as:
An extension to this idea is the use of dynamic data structures - collections of data whose required size is not known until run-time. Again, we'll use C99's standard memory allocation functions whenever we require more memory.
However, unlike our use of realloc() to grow (or shrink) a single data structure (there, an array), we'll see two significant differences:
we'll manage a complete data structure by allocating and deallocating its "pieces", and
we'll keep all of the "pieces" linked together by including, in each piece, a "link" to other pieces.
To implement these ideas in C99, we'll develop data structures that contain pointers to other data structures.
All code examples in this lecture are available from here: examples.zip
CITS2002 Systems Programming, Lecture 20, p1, 8th October 2019.
Such a data structure is also termed a first-in-last-out data structure, a FILO, because the first item added to the stack is the last item removed from it (not the sort of sequence you want while queueing for a bank's ATM!).
typedef struct _s {
int value;
struct _s *next;
} STACKITEM;
Of note:
we haven't really defined a stack datatype, but a single item that will "go into" the stack.
the datatype STACKITEM contains a pointer field, named next, that will point to another item in the stack.
we've defined a new type, a structure named _s, so that the pointer field next can be of a type that already exists.
we've defined a single pointer variable, named stack, that will point to a stack of items.
CITS2002 Systems Programming, Lecture 20, p2, 8th October 2019.
The need to do this is not known until run-time, and data (perhaps read from file) will determine how large our stack eventually grows.
As its name suggests, when we add items to our stack, we'll speak of pushing new items on the stack, and popping existing items from the stack, when removing them.
....
new->value = newvalue;
new->next = stack;
stack = new;
}
The functions push_item and pop_item are quite simple, but in each case we must worry about the case when the stack is empty.
We use a NULL pointer to represent the condition of the stack being empty.
CITS2002 Systems Programming, Lecture 20, p3, 8th October 2019.
In this example, the data held in each STACKITEM is just a single integer, but it could involve several fields of data. In that case, we may need more complex functions to return all of the data (perhaps using a structure or pass-by-reference parameters to the pop_item function).
Again, we must ensure that we don't attempt to remove (pop) an item from an empty stack:
int pop_item(void)
{
STACKITEM *old;
int oldvalue;
if(stack == NULL) {
fprintf(stderr, "attempt to pop from an empty stack\n");
exit(EXIT_FAILURE);
}
oldvalue = stack->value;
old = stack;
stack = stack->next;
free(old);
return oldvalue;
}
Thus we'll write our own function, print_stack, to traverse the stack and successively print each item, using printf.
void print_stack(void)
{
STACKITEM *thisitem = stack;
while(thisitem != NULL) {
printf("%i", thisitem->value);
thisitem = thisitem->next;
if(thisitem != NULL)
printf(" -> ");
}
if(stack != NULL)
printf("\n");
}
Again, our stack is simple because each node only contains a single integer. If more complex, we may call a different function from within print_stack to perform the actual printing:
....
print_stack_item( thisitem );
Each integer read from lines of a file is pushed onto the stack, arithmetic operators pop 2 integers from the stack, perform some arithmetic, and push the result back onto the stack.
}
return pop_item();
}
In particular, the whole stack was represented by a single global pointer variable, and all functions accessed or modified that global variable.
Ideally we'd re-write all of our functions, push_item, push_item, and print_stack so that they received the required stack as a parameter, and used or manipulated that stack.
Techniques on how, and why, to design and implement robust data structures are a focus of the unit CITS2200 Data Structures & Algorithms.
CITS2002 Systems Programming, Lecture 20, p8, 8th October 2019.
We term such a data structure a list, and its datatype declaration is very similar to our stack:
typedef struct _l {
char *string;
struct _l *next;
} LISTITEM;
As with the stack, we'll need to support empty lists, and will again employ a NULL pointer to represent it.
This time, each data item to be stored in the list is string, and we'll often term such a structure as "a list of strings".
CITS2002 Systems Programming, Lecture 20, p9, 8th October 2019.
Notice how we needed to traverse the whole list to locate its end.
Such traversal can become expensive (in time) for very long lists.
CITS2002 Systems Programming, Lecture 20, p10, 8th October 2019.
Of course, we again need to be careful about the case of the empty list:
char *remove_item(void)
{
LISTITEM *old = list;
char *string;
if(old == NULL) {
fprintf(stderr, "cannot remove item from an empty list\n");
exit(EXIT_FAILURE);
}
list = list->next;
string = old->string;
free(old);
return string;
}
Notice that we return the string (data value) to the caller, and deallocate the old node that was at the head of the list.
We say that the caller now owns the storage required to hold the string - even though the caller did not initially allocate that storage.
We'll address all of these by developing a similar first-in-first-out (FIFO) data structure, which we'll name a queue.
CITS2002 Systems Programming, Lecture 20, p12, 8th October 2019.
We're hoping to address the main problems that were exhibited by the stack and list data structures:
typedef struct _e {
void *data;
size_t datalen;
struct _e *next;
} ELEMENT;
typedef struct {
ELEMENT *head;
ELEMENT *tail;
} QUEUE;
Of note:
We've introduced a new datatype, ELEMENT, to hold each individual item of data.
Because we don't require our functions to "understand" the data they're queueing, each element will just hold a void pointer to the data it's holding, and remember its length.
Our "traditional" datatype QUEUE now holds 2 pointers - one to the head of the list of items, one to the tail.
CITS2002 Systems Programming, Lecture 20, p13, 8th October 2019.
We thus need a function to allocate space for, and to initialize, a new queue:
return q;
}
....
QUEUE *people_queue = queue_new();
QUEUE *truck_queue = queue_new();
If we remember that:
the calloc function both allocates memory and sets all of its bytes to the zero-bit-pattern, and
that (most) C99 implementations represent the NULL pointer as the zero-bit-pattern,
this = q->head;
while( this != NULL ) {
save = this;
this = this->next;
free(save->data);
free(save);
}
free(q);
}
To quickly add items - we don't wish appending to a very long queue to be slow.
We achieve this by remembering where the tail of the queue is, and quickly adding to it without searching.
Writing a function to remove items from our queue, is left as a simple exercise.
CITS2002 Systems Programming, Lecture 20, p17, 8th October 2019.
More common is to store data in a structure that embeds the relative magnitude or priority of the data. Doing so requires insertions to keep the data-structure ordered, but this makes searching much quicker as well.
Let's consider the type definition and insertion of data into a binary tree in C99:
if(order > 0) {
t->left = tree_insert(t->left, value);
}
else if(order < 0) {
t->right = tree_insert(t->right, value);
}
return t;
}
Of note:
we've defined a data-structure containing two pointers to other instances of the data-structure.
the use of the struct _bt data type is temporary, and never used again.
here, each element of the data-structure, each node of the tree, holds a unique instance of a data value - here, a single integer - though it's very common to hold multiple data values.
we insert into the tree with:
tree_root = tree_insert(tree_root, new_value);
the (magnitude of the) integer data value embeds the order of the structure - elements with lesser integer values are stored 'below' and to the left of the current node, higher values to the right.
unlike some (more complicated) variants of the binary-tree, we've made no effort to keep the tree balanced. If we insert already sorted elements into the tree, the tree will degenerate into a list, with every node having either a NULL left or a NULL right pointer.
CITS2002 Systems Programming, Lecture 20, p18, 8th October 2019.
bool find_recursively(BINTREE *t, int wanted) bool find_iteratively(BINTREE *t, int wanted)
{ {
if(t != NULL) { while(t != NULL) {
int order = (t->value - wanted); int order = (t->value - wanted);
if(order == 0) { if(order == 0) {
return true; return true;
} }
else if(order > 0) { else if(order > 0) {
return find_recursively(t->left, wanted); t = t->left;
} }
else { else {
return find_recursively(t->right, wanted); t = t->right;
} }
} }
return false; return false;
} }
Of note:
we do not modify the tree when searching, we simply 'walk' over its elements, determining whether to go-left or go-right depending on the relative value of each element's data to the wanted value.
some (more complicated) variants of the binary-tree re-balance the tree by moving recently found values (their nodes) closer to the root of the tree in the hope that they'll be required again, soon.
if the required value if found, the searching functions return true; otherwise we keep walking the tree until we find the value or until we can no longer walk in the required direction (because either the left or the right pointer is NULL).
CITS2002 Systems Programming, Lecture 20, p19, 8th October 2019.
CITS2002 Systems Programming
Consider how these bits form bytes, and how integers are represented:
The constant NBBY is not a C99 symbol, but operating systems provide it to give the number of bits per byte.
CITS2002 Systems Programming, Lecture 21, p1, 14th October 2019.
Firstly, let's consider a few of C's operations on bits and bit patterns. When used in combination, these provide some powerful facilities not readily supported in some other programming languages.
Note: Don't confuse &&, the short-circuit logical-and, with &, which is the less common bitwise-and.
While the bitwise-and can also be used with Boolean operands, this is extremely rare and is almost always a programming error.
CITS2002 Systems Programming, Lecture 21, p2, 14th October 2019.
Consider the following example, using an unsigned 32-bit integer (uint32_t) to represent colours in an RGB format of 24 bits-per-pixel (24bpp).
....
RGBCOLOUR white = set_rgb(255, 255, 255);
RGBCOLOUR black = set_rgb( 0, 0, 0);
RGBCOLOUR skyblue = set_rgb(135, 206, 235);
RGBCOLOUR yellow = set_rgb(255, 255, 0);
Here, the left-shift operator is used to quickly multiply a small value by a constant power of two.
On most modern architectures, bit-shifting operations are considerably faster than the equivalent multiplications (use left-shifting) and divisions (use right-shifting).
www.rapidtables.com/web/color/RGB_Color.htm
www.w3schools.com/colors/colors_rgb.asp
CITS2002 Systems Programming, Lecture 21, p3, 14th October 2019.
Such code often mixes octal and hexadecimal values, and left- and right-shift operations. Fortunately, modern C compilers are able to generate the optimal machine-level code for their targeted architecture.
CITS2002 Systems Programming, Lecture 21, p4, 14th October 2019.
This simple program - greyscale.c - shows the idea (but doesn't use the RGBCOLOUR datatype, from above), converting a PPM-format colour image (as input) to a greyscale one (as output), with the command:
Like most modern systems, Unix arranges its file system in a hierarchical structure of files and directories. Directories may contain entries for files and other directories.
File entries contain the file's name, together with a pointer to a structure termed the inode (the information node?), which represents the details of the file.
Multiple file entries, from possibly different directories, may point to the same inode. Thus, it is possible to have a single file with multiple names - we say that the names are links to the same file.
Of note, one 32-bit integer field in each inode contains the file's permission mode bits.
From history, the permission mode bits appear in the same integer defining the file's type (regular, directory, block device, socket, ...) -
See man 2 stat for details.
CITS2002 Systems Programming, Lecture 21, p6, 14th October 2019.
Octal Modes
Of note, if the owner's permission bits of a file or directory are not set, then the owner cannot access the object by virtue of the 'group' or 'other' bits (can you think why?).
The inode structure also contains indication of the object's setuid and setgid status, together with a sticky bit having an overloaded meaning (historically, setting the sticky bit on an executable file requested that it not be swapped out of memory - requiring privilege to set the bit).
On different variants of Unix/Linux the permission mode bits, in combination, have some obscure meanings:
having execute access, but not read access, to a directory still permits an attacker to 'guess' filenames therein,
having the sticky bit set on a directory permits only the owner of a file, therein, to remove or modify the file,
having the setgid bit set on a directory means that files created in the directory receive the groupid of the directory, and not of their creator (owner).
A system administrator managing different operating systems (Unix/Linux, macOS, many flavours of Windows) needs be aware of these subtle differences.
CITS2002 Systems Programming, Lecture 21, p8, 14th October 2019.
Even more common is the setting and testing of individual bits in data, using each bit as if it were a complete Boolean value.
Consider the case of files in a file-system. We have already seen how we can determine if a directory entry is another directory or a file:
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
.....
struct stat stat_buffer;
if(stat(pathname, &stat_buffer) != 0)
{
perror( progname );
}
else if( S_ISDIR( stat_buffer.st_mode ))
{
printf( "%s is a directory\n", pathname );
}
else if( S_ISREG( stat_buffer.st_mode ))
{
printf( "%s is a file\n", pathname );
}
else
{
printf( "%s is unknown!\n", pathname );
}
Individual bits in this integer are then used to represent a variety of possible combinations of permissible values.
struct stat {
....
mode_t st_mode;
....
}
// File types
#define S_IFDIR 0040000 // DIRECTORY
....
#define S_IFREG 0100000 // REGULAR FILE
While the latter is preferable (because someone has to consciously issue chmod commands to make the files more accessible), most applications leave newly created files accessible by anyone.
Each Unix process also carries with it an integer attribute termed its umask, which is used by open() and creat() to set initial file permissions on newly-created files.
When new files or directories are created, their initial permissions are AND-ed with the Boolean-complement of the umask. Specifically, permissions set in the umask are turned off from the mode argument to open() or creat().
With an 'open' umask of 000, initial file and directory permissions are not modified on creation, and
With a umask of 022, write-permission is masked out for the 'group' and 'other' fields,
With a 'closed' umask of 077, all read, write, and execute permissions are disabled for the 'group' and 'other' fields.
It is important that a reasonable umask, ideally 077, is set upon login; the umask value is then inherited by all subprocesses spawned from the initial process (shell).
CITS2002 Systems Programming, Lecture 21, p11, 14th October 2019.
As an example, on Linux the following 'attribute bits', when set, have the following meanings:
Some of these attribute bits may only be set by the superuser, some may not be "compiled-in" to the kernel at all.
Unfortunately, their values (and meaning) are not honoured by most (standard) file-system utilities such as when being backed-up, are implemented differently across file-system versions, and care must be taken to restore their modes.
As these attributes are not as well known as the traditional protections (displayed using ls) they can provide additional protection against naive attackers - or greater challenges for naive administrators.
CITS2002 Systems Programming, Lecture 21, p12, 14th October 2019.
CITS2002 Systems Programming
System calls are (supposed to be) the only mechanism by which processes may interact with the operating system and the resources it is protecting and managing (i.e. a process may not randomly read nor write the memory of the kernel, or of another process, itself).
Unix has adopted the approach that processes are the only active entities on a system (i.e. devices are passive). Processes act on the behalf of users, while accessing resources in a controlled fashion.
If these two approaches have been successful, we can study the basic security aspects of an operating system by examining:
Operating systems support integral values for userids in preference to strings for their fixed length and their speed of comparison.
Although there may only be 216 possible values, even modern variants of Unix do not use the userid as an index into any kernel tables. Moreover, no userid is considered invalid.
The user-identifier is established when logging into a Unix system. A correct combination of user-name and password when logging in, or the validation of a network-based connection, set the user-identifier (uid) in the process control block of the user's login shell, or
command interpreter.
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
Unless modified, this user-identifier is inherited by all processes invoked from the initial login shell. Under certain conditions, the user-identifier may be changed and determined with the system calls setuid() and getuid().
CITS2002 Systems Programming, Lecture 22, p2, 15th October 2019.
The effective user-identifier is, by default, the same as the user-identifier, but may be temporarily changed to a different value to offer temporary privileges.
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
The successful invocation of set-user-id programs, such as passwd and login will, typically, set the effective user-identifier for the lifetime of that process.
Under certain conditions, the effective user-identifier may be changed and determined with the system calls seteuid() and geteuid().
CITS2002 Systems Programming, Lecture 22, p3, 15th October 2019.
In particular, because account- and user-management is not treated as an activity to the kernel, the kernel collapses users with the same userid to be a single user.
A frequently seen flaw is one where two (named) users have the same userid in the password file, or a user is incorrectly given a negative userid (such as a user named nobody with a userid of -2) on the assumption that all application software will consistently treat this as a
signed or unsigned integer.
Processes with these userids do not run with elevated privilege, but typically run in an environment where no human will notice them. These allocated userids do not invoke interactive sessions, (or are not supposed to!) and so no human operator is immediately notified if they do,
or if they try unsuccessfully several times.
Care must be taken to ensure that these 'users' do not have passwords, blank passwords, home directories, interactive login shells, etc. Typical approaches are to place an asterisk in their /etc/passwd password field, and set /sbin/nologin as their login shell.
Processes acting on behalf of the superuser can often access additional resources (often, incorrectly, stated as 'everything') because most system calls responsible for checking user permissions bypass their checks if being invoked with userid==0.
For example, because the superuser can access all files and directories on a local system, it is simpler to use the superuser account/privilege to backup data volumes. It is cumbersome to direct the backup programs to not backup certain items.
Although the superuser has greater access to otherwise protected resources, the Unix kernel will not permit the superuser to undermine the integrity of the operating system itself. For example:
although the superuser can create a new file in any directory through a call to the open() or creat() system calls, and that details of this new file are written to the directory by the kernel itself, the superuser cannot open and explicitly write to the directory.
the superuser does not have the ability to write a file to a read-only file system, but can unmount the file system, re-mount it with an elevate read-write status, and then write the file.
CITS2002 Systems Programming, Lecture 22, p6, 15th October 2019.
Strictly speaking, this is not an alternative to setting file-access permissions, but may be used in combination with the previous techniques.
On behalf of each process, the kernel manages a current working directory, relative to which all file accesses are made, and a root directory 'above' which the process may not change directory, or open files.
The chroot system call changes the root directory of a calling process to be that specified by the function's argument.
This directory will be used for path names beginning with / and constrains traversal with ../../.. etc. The root directory attribute is inherited by all children of the current process.
Under normal execution, a process wishing to constrain the access of a subprocess would:
Traditionally, only the super-user may change the root directory of the current process, and so the chroot application program exists with its setuid bit set to temporarily run as the superuser while making the system call.
CITS2002 Systems Programming, Lecture 22, p7, 15th October 2019.
groupids are similarly represented as 16-bit unsigned integral values. Each process maintains the primary groupid of its user (owner). The primary groupid for each user is initially located from the /etc/passwd file, typically when a user logs in.
chris:9HsKSemoJlxpA:333:110:Chris McDonald:/Users/chris:/bin/zsh
Under variants of Unix derived from the Berkeley (BSD) stream of Unix, each process also carries an array of 32 additional groupids, representing additional groups that the user is in. These are located from the /etc/group file, using the user-level library function
initgroups(), and set with the system call setgroups().
Variants of Unix derived from the AT&T System-V stream, support the newgrp command, which is similar to login in many respects but permits a process's primary group to be changed.
Additionally, the newgrp command permits an interactive process to change its primary group if it can provide the group's password (maintained in the /etc/group file).
Unix groups are used to constrain access to resources, such as files and some interprocess communication channels, to processes that must hold the required primary groupid, or (if BSD) hold it in its vector of groupids.
CITS2002 Systems Programming, Lecture 22, p8, 15th October 2019.
Thus, while the operating system kernel will maintain group information (integers and arrays of integers) about users and their executing processes, additional information about these integers requires library functions (outside of the kernel).
For example, getgroups() is a system-call (with its documentation in section-2 of the online manual) to request all group-IDs of which the calling user-ID is a member.
We can then use the library function getgrgid() (with its documentation in section-3 of the online manual) to find information about each group-ID.
#include <stdio.h>
#include <unistd.h>
#include <grp.h>
void print_my_groups(void)
{
gid_t groups[MY_MAXGROUPS];
if(gp != NULL) {
printf("gid: %-8i name: %s\n", groups[g], gp->gr_name);
printf("\tmembers:");
for(int m=0 ; gp->gr_mem[m] != NULL ; ++m) {
printf(" %s", gp->gr_mem[m]);
}
printf("\n\n");
}
}
}
However, there are occasions where a process must undertake an activity or temporarily have access to resources that it would not otherwise have. Examples include the passwd and newgrp programs already described. Each must have access to files containing passwords
(possibly shadowed versions of these), or require the ability to demonstrate that the activity is permitted.
In 1973, Dennis Ritchie, one of the original two designers of Unix filed a patent for the Unix set-uid mechanism:
US Patent 4,135,240: An improved arrangement for controlling access to data files by computer users. Access permission bits are used in the prior art to separately indicate permissions for the file owner and
nonowners to read, write and execute the file contents. An additional access control bit is added to each executable file. When this bit is set to one, the identification of the current user is changed to that of the owner
of the executable file. The program in the executable file then has access to all data files owned by the same owner. This change is temporary, the proper identification being restored when the program is
terminated.
In essence, program images are stored in files, and each file has an owner. Invoking a process, from a program file having its setuid bit set results in the (new) process executing with the privileges of the file's owner.
CITS2002 Systems Programming, Lecture 22, p10, 15th October 2019.
or
In effect, the owner of the (different) permissions is not explicitly changing the userid of a running process, but is permitting a (new) process to run with their permissions. Examples for its use include letting another user open a specific data file only by using your setuid program.
This facility takes on special significance when the owner of the program's file is the superuser -
any files then run as 'setuid root' run with the elevated permissions assigned to the superuser. (Hopefully) in all cases these are necessary:
setuid executable files, particularly if owned by root, are particular points of potential vulnerability on a Unix system, and regular checks with programs such as find should be made to locate (and justify) their existence.
A similarly extended execution state may be used with set-groupid permission mode bits on program files. Processes may execute as if the user running them were in the additional Unix group. This may provide access to resources that were otherwise constrained to the
particular group.
CITS2002 Systems Programming, Lecture 22, p11, 15th October 2019.
CITS2002 Systems Programming
C is a procedural programming language, not an object-oriented language like Java, Objective-C, or C#.
well designed,
clearly written and (hence) to read,
well documented,
use high level programming practices, and
well tested.
Of course, the above properties are independent of C, and are offered by many high level languages.
C has programming features provided by most procedural programming languages - strongly typed variables, constants, standard (or base) datatypes, enumerated types, user-defined types, aggregate structures, standard control flow, recursion, and program
modularization.
C does not offer tuples or sets, Java's concept of classes or objects, nested functions, subrange types, and has only recently added a Boolean datatype.
C does have, however, separate compilation, conditional compilation, bitwise operators, pointer arithmetic, and language independent input and output.
CITS2002 Systems Programming, Lecture 23, p1, 21st October 2019.
Why do we teach C?
small, imperative, procedural language
For example, we have seen that C does not provide features for graphics, networking, cryptography, or multimedia. Does this mean that C cannot be used to implement massive online multi-player games?
At one level, you probably appreciate that C doesn't have all these features (to learn). Instead, it is hoped that you have developed an appreciation that the "missing" features are provided by operating systems, other programs, 3rd-party libraries, and even bindings between C
and other programming languages.
C permits, enables, and encourages additional 3rd-party libraries (both open-source and commercial) to provide these facilities. The reason for these "omissions" is that C rigorously defines what it does provide, and rigorously defines how C must interact with external libraries.
We have previously listed some well-respected 3rd-party libraries, frequently employed in large C programs:
From this unit, it is hoped that you are now confident to investigate the use of these external libraries, be able to search for and read their documentation, and know how to incorporate their header files, functions, and libraries into your own projects.
CITS2002 Systems Programming, Lecture 23, p5, 21st October 2019.