CS360 Lecture notes -- Introduction to System Calls (I/O System Calls)


System Calls

When a computer is turned on, the program that gets executed first is called the ``operating system.'' It controls pretty much all activity in the computer. This includes who logs in, how disks are used, how memory is used, how the CPU is used, and how you talk with other computers. The operating system we use is called "Unix".

The way that programs talk to the operating system is via ``system calls.'' A system call looks like a procedure call (see below), but it's different -- it is a request to the operating system to perform some activity.

System calls are expensive. While a procedure call can usually be performed in a few machine instructions, a system call requires the computer to save its state, let the operating system take control of the CPU, have the operating system perform some function, have the operating system save its state, and then have the operating system give control of the CPU back to you. This concept is important, and will be seen time and time again in this class.


System Calls for I/O

There are 5 basic system calls that Unix provides for file I/O.
        1.  int open(char *path, int flags [ , int mode ] );
        2.  int close(int fd);
        3.  ssize_t read(int fd, void *buf, size_t count);
        4.  ssize_t write(int fd, const void *buf, size_t count);
        5.  off_t lseek(int fd, off_t offset, int whence);

You'll note that they look like regular procedure calls. This is how you program with them -- like regular procedure calls. However, you should know that they are different: A system call makes a request to the operating system. A procedure call just jumps to a procedure defined elsewhere in your program. That procedure call may itself make a system call (for example, fopen() calls open()), but it is a different call.

The reason the operating system controls I/O is for safety -- the computer must ensure that if my program has a bug in it, then it doesn't crash the system, and it doesn't mess up other people's programs that may be running at the same time or later. Thus, whenever you do disk or screen or network I/O, you must go through the operating system and use system calls.

These five system calls are defined fully in their man pages (do 'man -s 2 open', 'man -s 2 close', etc). All those irritating types like ssize_t and off_t are ints and longs. They used to all be ints, but as machines and files have grown, so have they.


Open

Open makes a request to the operating system to use a file. The 'path' argument specifies what file you would like to use, and the 'flags' and 'mode' arguments specify how you would like to use it. If the operating system approves your request, it will return a ``file descriptor'' to you. This is a non-negative integer. If it returns -1, then you have been denied access, and you have to check the value of the variable "errno" to determine why. (That or use perror() -- see the lecture notes for Chapter 1 of the textbook -- which I know no one owns any more. However, go through those lecture notes on your own time, as they explain some definitions and Unix things that are good for you to know).

All actions that you will perform on files will be done through the operating system. Whenever you want to do file I/O directly with the operating system, you specify the file by its file descriptor. Thus, whenever you want to do file I/O on a specific file, you must first open that file to get a file descriptor.

Example: o1.c opens the file in1.txt for reading, and prints the value of the file descriptor. If you haven't copied over the file in1.txt, then it will print -1, since in1.txt does not exist. If in1.txt does exist, then it will print 3, meaning that the open() request has been granted (i.e. a non-negative integer was returned).

#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>

int main()
{
  int fd;

  fd = open("in1.txt", O_RDONLY);
  printf("%d\n", fd);
}

Note the value of 'flags' -- the man page for open() (or chapter 3 of the book) will give you a description of the flags and how they work. They are described in fcntl.h, which can be found in the directory /usr/include. (Note that fcntl.h merely includes /usr/include/sys/fcntl.h, so you'll have to look at that file to see what O_RDONLY and all really mean).

Here is a few examples of calling o1. Initially, I have a file called in1.txt in my directory, so the open() call is successful, returning 3. I then rename it to tmp.ctxt, and now the open() call fails, return -1. I rename it back, and the open() call succeeds again, returning 3:

UNIX> ls -l in1.txt
-rw-r--r--  1 plank  staff  22 Jan 31 12:50 in1.txt
UNIX> ./o1 
3                                       The open call succeeds here.
UNIX> mv in1.txt tmp.txt
UNIX> ./o1
-1                                      The open call fails here.
UNIX> mv tmp.txt in1.txt
UNIX> ./o1
3                                       The open call succeeds again.
UNIX> 
Example: o2.c tries to open the file "out1.txt" for writing. That fails because out1.txt does not exist already. Here's the code: You'll note that it uses perror() to print why the error occurred.

#include <fcntl.h>
#include <stdlib.h>
#include <stdio.h>

int main()
{
  int fd;

  fd = open("out1.txt", O_WRONLY);
  if (fd < 0) {
    perror("out1.txt");
    exit(1);
  }
}

When we run it, out1.txt doesn't exist, and it fails. We create out1.txt and it succeeds, but since we didn't do anything with it, nothing happens to the file. If we change its protection to be read-only, then the open() call fails due to file protection. Then We delete out1.txt, and o2 fails again:

UNIX> ls -l out*.txt
-rw-r--r--  1 plank  staff  0 Jan 31 12:50 out2.txt
UNIX> ./o2
out1.txt: No such file or directory
UNIX> echo "Hi" > out1.txt
UNIX> ls -l out*.txt
-rw-r--r--  1 plank  staff  3 Jan 31 13:13 out1.txt
-rw-r--r--  1 plank  staff  0 Jan 31 12:50 out2.txt
UNIX> ./o2
UNIX> cat out1.txt
Hi
UNIX> chmod 0400 out1.txt
UNIX> ./o2
out1.txt: Permission denied
UNIX> chmod 0644 out1.txt
UNIX> rm out1.txt
UNIX> ./o2
out1.txt: No such file or directory
UNIX> 
In order to open a new file for writing, you should open it with (O_WRONLY | O_CREAT | O_TRUNC) as the flags argument. The binary-or is how you aggregate these arguments (they are each integers with a different bit set, so the binary-or combines them all). See o3.c for an example:

#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>

int main()
{
  int fd;

  fd = open("out2.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
  if (fd < 0) {
    perror("o3");
    exit(1);
  }
}

Below, I run o3 in various situations -- you can see that if the file doesn't exist, it creates it. If the file does exist, then it truncates it:

UNIX> ls -l out2.txt
-rw-r--r--  1 plank  staff  0 Jan 31 12:50 out2.txt
UNIX> ./o3
UNIX> ls -l out2.txt
-rw-r--r--  1 plank  staff  0 Jan 31 13:15 out2.txt
UNIX> rm out2.txt
UNIX> ls -l out2.txt
ls: out2.txt: No such file or directory
UNIX> ./o3
UNIX> ls -l out2.txt
-rw-r--r--  1 plank  staff  0 Jan 31 13:16 out2.txt
UNIX> echo "Hi" > out2.txt
UNIX> ls -l out2.txt
-rw-r--r--  1 plank  staff  3 Jan 31 13:16 out2.txt
UNIX> ./o3
UNIX> ls -l out2.txt
-rw-r--r--  1 plank  staff  0 Jan 31 13:16 out2.txt
UNIX> echo "Hi Again" > out2.txt
UNIX> chmod 0400 out2.txt
UNIX> ls -l out2.txt
-r--------  1 plank  staff  9 Jan 31 13:16 out2.txt
UNIX> ./o3
o3: Permission denied
UNIX> ls -l out2.txt
-r--------  1 plank  staff  9 Jan 31 13:16 out2.txt
UNIX> chmod 0644 out2.txt
UNIX> ./o3
UNIX> ls -l out2.txt
-rw-r--r--  1 plank  staff  0 Jan 31 13:17 out2.txt
UNIX> 
Finally, the 'mode' argument should only be used if you are creating a new file. It specifies the protection mode of the new file. 0644 is the most typical value -- it says "I can read and write it; everyone else can only read it."

You can open the same file more than once. You will get a different fd each time. If you open the same file for writing more than once at a time, you may get bizarre results.


Close

Close() tells the operating system that you are done with a file descriptor. The OS can then reuse that file descriptor. The file c1.c shows some examples with opening and closing the file in1.txt. You should look at it carefully, as it opens the file multiple times without closing it, which is perfectly legal in Unix.

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>

int main()
{
  int fd1, fd2;

  fd1 = open("in1.txt", O_RDONLY);
  if (fd1 < 0) { perror("c1"); exit(1); }

  fd2 = open("in1.txt", O_RDONLY);
  if (fd2 < 0) { perror("c1"); exit(1); }

  printf("Opened the file in1.txt twice:  Fd's are %d and %d.\n", fd1, fd2);

  if (close(fd1) < 0) { perror("c1"); exit(1); }
  if (close(fd2) < 0) { perror("c1"); exit(1); }

  printf("Closed both fd's.\n");

  fd2 = open("in1.txt", O_RDONLY);
  if (fd2 < 0) { perror("c1"); exit(1); }
  
  printf("Reopened in1.txt into fd2: %d.\n", fd2);

  if (close(fd2) < 0) { perror("c1"); exit(1); }

  printf("Closed fd2.  Now, calling close(fd2) again.\n");
  printf("This should cause an error.\n\n");

  if (close(fd2) < 0) { perror("c1"); exit(1); }

}

UNIX> c1
Opened the file in1.txt twice:  Fd's are 3 and 4.
Closed both fd's.
Reopened in1.txt into fd2: 3.
Closed fd2.  Now, calling close(fd2) again.
This should cause an error.

c1: Bad file descriptor
UNIX> 

Read

Read() tells the operating system to read "size" bytes from the file opened in file descriptor "fd", and to put those bytes into the location pointed to by "buf". It returns how many bytes were actually read. Consider the code in r1.c.

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>

int main()
{
  char *c;
  int fd, sz;

  c = (char *) calloc(100, sizeof(char));

  fd = open("in1.txt", O_RDONLY);
  if (fd < 0) { perror("r1"); exit(1); }

  sz = read(fd, c, 10);
  printf("called read(%d, c, 10).  returned that %d bytes  were read.\n", fd, sz);
  c[sz] = '\0';
  printf("Those bytes are as follows: %s\n", c);

  sz = read(fd, c, 99);
  printf("called read(%d, c, 99).  returned that %d bytes  were read.\n", fd, sz);
  c[sz] = '\0';
  printf("Those bytes are as follows: %s\n", c);

  close(fd);
}

When executed, you get the following:

UNIX> r1
called read(3, c, 10).  returned that 10 bytes  were read.
Those bytes are as follows: Jim Plank

called read(3, c, 99).  returned that 12 bytes  were read.
Those bytes are as follows: Claxton 221

UNIX> 
There are a few things to note about this program. First, buf should point to valid memory. In r1.c, this is achieved by calloc()-ing space for c (read the man page on calloc() if you have not seen it before). Alternatively, I could have declared c to be a static array with 100 characters:
  char c[100];
Second, I null terminate c after the read() calls to ensure that printf() will understand it.

Third, when read() returns 0, then the end of file has been reached. When you are reading from a file, if read() returns fewer bytes than you requested, then you have reached the end of the file as well. This is what happens in the second read() in r1.c.

Fourth, note that the 10th character in the first read() call and the 12th character in the second are both newline characters. That is why you get two newlines in the printf() statement. One is in c, and the other is in the printf() statement.

Finally, the read call does not read a NULL character. It simply reads bytes from the file, and the file does not contain any NULL characters. This is why you have to put the NULL character explicitly into your string. Now, the first read() call didn't have to do this, because the NULL character has an integer value of 0, and the calloc() call made sure that all of the bytes are zero. The second read() call would have worked, too, because the string "Claxton 221" is bigger than "Jim Plank". However, if it had been smaller, then it would have been important for you to have put in that NULL character.


Write

Write() is just like read(), only it writes the bytes instead of reading them. It returns the number of bytes actually written, which is almost invariably "size".

w1.c writes the string "cs360\n" to the file out3.txt.

#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

int main()
{
  int fd, sz;

  fd = open("out3.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
  if (fd < 0) { perror("r1"); exit(1); }

  sz = write(fd, "cs360\n", strlen("cs360\n"));

  printf("called write(%d, \"cs360\\n\", %ld).  it returned %d\n",
         fd, strlen("cs360\n"), sz);

  close(fd);
}

UNIX> w1
called write(3, "cs360\n", 6).  it returned 6
UNIX> cat out3
cs360
UNIX> 
You should think about different combinations of O_CREAT and O_TRUNC, and their effect on write. In particular, take a look at w2.c. This lets you specify the combination of O_WRONLY, O_CREAT and O_TRUNC that you use in your open() call:

int main(int argc, char **argv)
{
  int fd, sz, flags, len;

  if (argc != 3) {
    fprintf(stderr, "usage: w2 w|wc|wt|wct  input-word\n");
    exit(1);
  }

  if (strcmp(argv[1], "w") == 0) {
    flags = O_WRONLY;
  } else if (strcmp(argv[1], "wc") == 0) {
    flags = (O_WRONLY | O_CREAT);
  } else if (strcmp(argv[1], "wt") == 0) {
    flags = (O_WRONLY | O_TRUNC);
  } else if (strcmp(argv[1], "wct") == 0) {
    flags = (O_WRONLY | O_CREAT | O_TRUNC);
  } else {
    fprintf(stderr, "Bad first argument.  Must be one of w, wc, wt, wct.\n");
    exit(1);
  }

  fd = open("out3.txt", flags, 0644);
  if (fd < 0) { perror("open"); exit(1); }

  len = strlen(argv[2]);
  sz = write(fd, argv[2], len);

  printf("called write(%d, \"%s\", %d).  it returned %d\n", fd, argv[2], len, sz);

  close(fd);
  exit(0);
}

Take a look at all of the following executions of the program. You should be able to explain them all. You should also notice that there is no newline in the write call, which is why the resulting file has no newline in it. There is also no NULL character being written to the file, because you are writing strlen() bytes, which does not include the NULL character:

UNIX> ls out*.txt
out2.txt
UNIX> w2 w Hi
open: No such file or directory
UNIX> ls out*.txt
out2.txt
UNIX> w2 wc ABCDEFG
called write(3, "ABCDEFG", 7).  it returned 7
UNIX> ls out*.txt
out2.txt	out3.txt
UNIX> ls -l out*.txt
-rw-r--r--  1 plank  staff  0 Jan 31 13:17 out2.txt
-rw-r--r--  1 plank  staff  7 Jan 31 13:50 out3.txt
UNIX> cat out3.txt
ABCDEFGUNIX> 
UNIX> w2 w XYZ
called write(3, "XYZ", 3).  it returned 3
UNIX> ls -l out*.txt
-rw-r--r--  1 plank  staff  0 Jan 31 13:17 out2.txt
-rw-r--r--  1 plank  staff  7 Jan 31 13:51 out3.txt
UNIX> cat out3.txt
XYZDEFGUNIX> w2 wc ---
called write(3, "---", 3).  it returned 3
UNIX> ls -l out*.txt
-rw-r--r--  1 plank  staff  0 Jan 31 13:17 out2.txt
-rw-r--r--  1 plank  staff  7 Jan 31 13:51 out3.txt
UNIX> cat out3.txt
---DEFGUNIX> w2 wt abcde
called write(3, "abcde", 5).  it returned 5
UNIX> ls -l out*.txt
-rw-r--r--  1 plank  staff  0 Jan 31 13:17 out2.txt
-rw-r--r--  1 plank  staff  5 Jan 31 13:52 out3.txt
UNIX> cat out3.txt
abcdeUNIX> rm out3.txt
UNIX> w2 wt efghi
open: No such file or directory
UNIX> ls -l out*.txt
-rw-r--r--  1 plank  staff  0 Jan 31 13:17 out2.txt
UNIX> 

Lseek

All open files have a "file pointer" associated with them. When the file is opened, the file pointer points to the beginning of the file. As the file is read or written, the file pointer moves. For example, in r1.c, after the first read, the file pointer points to the 11th byte in in1.txt. You can move the file pointer manually with lseek(). The 'whence' variable of lseek specifies how the seek is to be done -- from the beginning of the file, from the current value of the pointer, and from the end of the file. The return value is the offset of the pointer after the lseek. Look at l1.c It does a bunch of seeks on the file in1.txt. Trace it and make sure it all makes sense. How did I know to include sys/types.h and unistd.h? I typed "man -s 2 lseek".

Standard Input, Standard Output, and Standard Error

Now, every process in Unix starts out with three file descriptors predefined and open: Thus, when you write a program, you can read from standard input, using read(0, ...), and write to standard output using write(1, ...).

Armed with that information, we can write a very simple cat program (one that copies standard input to standard output) with one line: (this is in simpcat.c):

int main()
{
  char c;

  while (read(0, &c, 1) == 1) write(1, &c, 1);
}

UNIX> simpcat < in1.txt
Jim Plank
Claxton 221
UNIX>