Unix Commands & Syscalls
Navigating the Unix environment — essential shell commands, file permissions, the Unix directory structure, and how your C programs talk directly to the operating system kernel via system calls.
The Unix environment — your new home
Think of Unix as a building with many floors. Each floor is a directory, and every room on each floor is a file. When you open a terminal, you are standing somewhere in that building. Commands like ls (look around), cd (go to a different floor), and pwd (ask "where am I?") are how you navigate. The kernel is the building manager — when you want to do something that requires authority (reading hardware, creating processes), you must ask the manager via a system call.
Unix was designed in the 1970s around three powerful ideas: (1) every resource is a file, (2) small tools that each do one thing well, (3) text as the universal interface. Windows introduced a graphical file manager — Unix gives you a command line that is far more powerful once learned.
When you type ls -la and press Enter, your shell (bash/zsh) parses the command, finds the ls binary, creates a process, and the kernel handles the rest. Everything you do in the terminal eventually becomes a system call — a request to the kernel to perform a privileged operation.
A library function (like printf) lives in user space — it is just C code from libc. A system call (like write) crosses into kernel space. Library functions often wrap system calls: printf eventually calls write(1, ...). You can see the system calls a program makes using strace ./myprogram.
The Unix Directory Structure
Everything starts at / (root). Key directories you must know:
| Directory | Purpose | Notable contents |
|---|---|---|
/ | Root of the filesystem | All other directories live here |
/bin | Essential user binaries | ls, cp, mv, cat, bash |
/usr/bin | Non-essential user programs | gcc, vim, grep |
/etc | System configuration files | passwd, hosts, fstab |
/dev | Device files | /dev/null, /dev/sda, /dev/tty |
/proc | Virtual filesystem — running processes | /proc/1234/status, /proc/cpuinfo |
/tmp | Temporary files (cleared on reboot) | Your temp files |
/home | User home directories | /home/alice, /home/bob |
/lib | Shared libraries | libc.so.6 |
The /proc Filesystem
/proc is a virtual filesystem — it is not stored on disk. The kernel creates it in memory to expose information about running processes. For process PID 1234, /proc/1234/ contains: status (memory, state), fd/ (open file descriptors), maps (memory map), cmdline (command line used to start it). This is how tools like ps and top work — they read from /proc.
File Permissions — rwx and Octal
Every file has three permission triplets: owner, group, others. Each triplet has read (r=4), write (w=2), execute (x=1).
$ ls -la myfile.c -rwxr-xr-- 1 alice devs 1234 Jun 10 file.c ^ ^ ^ ^ | | | └── others: r-- = 4 (read only) | | └───── group: r-x = 5 (read + execute) | └──────── owner: rwx = 7 (read + write + execute) └─────────── file type: - = regular, d = directory, l = symlink # Octal notation: r=4, w=2, x=1 # rwx = 4+2+1 = 7, r-x = 4+0+1 = 5, r-- = 4+0+0 = 4 # So -rwxr-xr-- = 754 in octal # chmod — change permissions chmod 755 script.sh # owner=rwx, group=r-x, others=r-x chmod 644 data.txt # owner=rw-, group=r--, others=r-- chmod +x program # add execute for all chmod u-w readonly.c # remove write from owner (u=user) # chown — change owner chown alice:devs file.c # set owner=alice, group=devs
r) AND execute (x). To list directory contents you need execute on the directory. Forgetting execute on a directory means you cannot cd into it.
Unix = everything is a file. The kernel is the gatekeeper. System calls are the door. Library functions are helpers that knock on the door for you. /proc, /dev, /etc are special directories you will use constantly in systems programming.
Essential commands and low-level syscall signatures
Core Unix Commands — Annotated
# Navigation pwd # Print Working Directory — where am I? cd /home/alice # Change Directory (absolute path) cd .. # Go up one directory cd ~ # Go to home directory ls -la # List All files, Long format (permissions, size) # File operations mkdir -p dir/sub/leaf # Make Directory, -p creates parents too rm -rf dir # Remove Forcibly + Recursively (dangerous!) cp -r src/ dest/ # Copy Recursively mv old.c new.c # Move (also used to rename) cat file.txt # conCATenate — print file to stdout head -n 20 file.txt # First 20 lines tail -n 20 file.txt # Last 20 lines wc -l file.txt # Word Count, -l = line count only # Searching grep -r "main" src/ # Search recursively for "main" pattern grep -n "TODO" *.c # Show line numbers, all .c files # Sorting and processing sort names.txt # Sort lines alphabetically sort -n numbers.txt # Sort numerically # Man pages — ALWAYS check these man ls # Manual for ls (shell commands) man 2 open # Manual section 2 = system calls man 3 printf # Manual section 3 = C library functions
man 2 read vs man 3 fread — different functions!
Low-Level I/O System Calls — C Signatures
/* Headers needed for all low-level I/O */ #include <fcntl.h> // open(), O_RDONLY, O_WRONLY, O_CREAT flags #include <unistd.h> // read(), write(), close(), STDIN_FILENO etc. #include <errno.h> // errno variable + error codes (EINTR, ENOENT...) /* open — opens a file, returns a file descriptor (int) */ int open(const char *pathname, int flags); ^ ^ file path O_RDONLY | O_WRONLY | O_RDWR | O_CREAT | O_TRUNC returns: fd >= 3 on success, -1 on error (errno is set) /* read — reads bytes from fd into buffer */ ssize_t read(int fd, void *buf, size_t count); ^ ^ ^ fd buffer max bytes to read returns: bytes read (0 = EOF), -1 on error /* write — writes bytes from buffer to fd */ ssize_t write(int fd, const void *buf, size_t count); returns: bytes written, -1 on error /* close — releases the file descriptor */ int close(int fd); returns: 0 on success, -1 on error /* Standard file descriptors — always open */ 0 = STDIN_FILENO // standard input (keyboard) 1 = STDOUT_FILENO // standard output (terminal) 2 = STDERR_FILENO // standard error (terminal)
FILE*. No buffering — every call goes straight to the kernel. Use errno to get detailed error info after a -1 return.
errno — Error Handling
#include <errno.h> #include <string.h> // for strerror() ssize_t n = read(fd, buf, 100); if (n < 0) { int err = errno; // save immediately — next call may change it if (err == EINTR) { /* interrupted by a signal — safe to retry */ } else if (err == ENOENT) { fprintf(stderr, "File not found: %s\n", strerror(err)); } } /* Common errno values */ // ENOENT = No such file or directory // EACCES = Permission denied // EINTR = Interrupted by signal (retry is safe) // EBADF = Bad file descriptor (fd not open) // EAGAIN = Try again (non-blocking I/O not ready)
errno into a local variable immediately after the failing call. Any subsequent function call (even printf) can overwrite errno. strerror(errno) converts the integer to a human-readable string.
strace — Spying on System Calls
# strace runs a program and prints every system call it makes strace ./myprogram strace -e trace=read,write ./myprogram # filter to specific calls strace -p 1234 # attach to running process PID 1234 # Example output of strace on "cat file.txt": open("file.txt", O_RDONLY) = 3 read(3, "hello\n", 4096) = 6 write(1, "hello\n", 6) = 6 read(3, "", 4096) = 0 <-- EOF close(3) = 0 # /proc/$PID/fd — see open file descriptors of a process ls -la /proc/$$/fd # $$ = current shell's PID cat /proc/$$/status # memory usage, state, signals
open), or you want to understand what a binary does. It is indispensable for debugging at the systems level.
Complete programs you can compile and run
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <stdio.h>
int main(int argc, char *argv[]) {
if (argc != 2) {
write(STDERR_FILENO, "Usage: readfile <filename>\n", 27);
return 1;
}
/* open() returns a file descriptor — an integer >= 3 */
/* O_RDONLY = open for reading only */
int fd = open(argv[1], O_RDONLY);
if (fd == -1) {
/* errno is set by open() on failure */
fprintf(stderr, "Cannot open %s: %s\n", argv[1], strerror(errno));
return 1;
}
char buf[4096];
ssize_t n;
/* read() returns bytes read, 0 at EOF, -1 on error */
while ((n = read(fd, buf, sizeof(buf))) > 0) {
/* write to stdout (fd=1) the exact bytes we read */
ssize_t written = write(STDOUT_FILENO, buf, n);
if (written != n) {
fprintf(stderr, "Write error: %s\n", strerror(errno));
close(fd);
return 1;
}
}
if (n == -1) {
fprintf(stderr, "Read error: %s\n", strerror(errno));
}
/* Always close file descriptors — they are a limited resource */
close(fd);
return 0;
}
$ echo "Hello syscalls!" > test.txt
$ ./readfile test.txt
Hello syscalls!
$ ./readfile nofile.txt
Cannot open nofile.txt: No such file or directory
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <sys/stat.h> /* for stat() and file permission bits */
int main(void) {
/* Create + write a file: O_CREAT|O_WRONLY|O_TRUNC with permissions 0644 */
/* 0644 octal = rw-r--r-- : owner read+write, group+others read only */
int fd = open("output.txt", O_CREAT | O_WRONLY | O_TRUNC, 0644);
if (fd == -1) {
fprintf(stderr, "open failed: %s\n", strerror(errno));
return 1;
}
const char *msg = "Hello from low-level write!\n";
write(fd, msg, strlen(msg));
close(fd);
/* Use stat() to read back file metadata */
struct stat st;
if (stat("output.txt", &st) == 0) {
printf("File size: %ld bytes\n", (long)st.st_size);
printf("Permissions (oct): %o\n", st.st_mode & 0777);
printf("inode number: %lu\n", (unsigned long)st.st_ino);
}
/* Show our own PID and check /proc */
pid_t pid = getpid();
printf("\nThis process PID: %d\n", pid);
printf("Try: cat /proc/%d/status\n", pid);
printf("Try: ls -la /proc/%d/fd\n", pid);
return 0;
}
Permissions (oct): 644
inode number: 2097353
This process PID: 18742
Try: cat /proc/18742/status
Try: ls -la /proc/18742/fd
#include <unistd.h>
#include <errno.h>
#include <stdio.h>
#include <string.h>
/* Robust read: retries on EINTR (signal interrupted the syscall) */
ssize_t robust_read(int fd, void *buf, size_t count) {
ssize_t result;
do {
result = read(fd, buf, count);
/* EINTR means a signal interrupted read() before it could start */
/* It is safe to retry in this case */
} while (result == -1 && errno == EINTR);
return result; /* caller checks for -1 (real error) or 0 (EOF) */
}
int main(void) {
char buf[256];
printf("Type something (Ctrl+C is handled, Ctrl+D to quit):\n");
ssize_t n = robust_read(STDIN_FILENO, buf, sizeof(buf) - 1);
if (n > 0) {
buf[n] = '\0'; /* null-terminate for printf */
printf("You typed: %s", buf);
} else if (n == 0) {
printf("EOF received.\n");
} else {
fprintf(stderr, "read() failed: %s\n", strerror(errno));
}
return 0;
}
This is NOT a real error — you should retry. Failing to handle EINTR
is a common systems programming bug.
Practice problems with solutions
For a file with permissions chmod 750 script.sh, write out the full rwx notation and describe what the owner, group, and others can do.
# 750 = 7 5 0
# 7 = 4+2+1 = rwx → owner can read, write, execute
# 5 = 4+0+1 = r-x → group can read and execute, NOT write
# 0 = 0+0+0 = --- → others have NO permissions at all
chmod 750 script.sh
# Result: -rwxr-x--- 1 alice devs script.sh
# Verify:
ls -la script.sh
# -rwxr-x--- 1 alice devs 42 Jun 10 script.sh
The code below has three bugs. Find and fix each one.
#include <fcntl.h>
#include <unistd.h>
int main(void) {
int fd = open("input.txt", O_RDONLY);
char buf[512];
ssize_t n = read(fd, buf, 512); /* Bug 1 */
write(1, buf, n); /* Bug 2 */
/* Bug 3 is missing entirely */
return 0;
}
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>
int main(void) {
int fd = open("input.txt", O_RDONLY);
if (fd == -1) { /* Fix 1: check open() return */
fprintf(stderr, "open: %s\n", strerror(errno));
return 1;
}
char buf[512];
ssize_t n;
while ((n = read(fd, buf, 512)) > 0) { /* Fix 2: loop until EOF */
write(STDOUT_FILENO, buf, n);
}
close(fd); /* Fix 3: always close fd */
return 0;
}
open() return value. If the file does not exist, fd = -1 and the subsequent read(-1, ...) will fail with EBADF.Bug 2: One
read() call only reads up to 512 bytes — a large file would be truncated. Must loop until read() returns 0 (EOF).Bug 3: Missing
close(fd). File descriptors are a limited per-process resource. Forgetting to close is a resource leak — in long-running programs this eventually causes "too many open files" errors.
Using only standard Unix commands, find all .c files in the current directory tree, show their permissions and sizes, then count how many there are. Use commands you know: ls, find, wc, grep.
# Find all .c files recursively
find . -name "*.c"
# Find .c files larger than 1KB and show details
find . -name "*.c" -size +1k -ls
# Count how many .c files exist
find . -name "*.c" | wc -l
# Show permissions of all .c files (ls long format)
ls -la *.c # current directory only
ls -laR **/*.c # recursive (bash globstar)
# Check if any .c files are world-writable (security check)
find . -name "*.c" -perm -o+w
# Search for all files containing "malloc"
grep -rn "malloc" . --include="*.c"
find for recursive searches with conditions (size, date, permissions). Use ls for quick listing of the current directory. find . -name "*.c" | wc -l is a classic Unix pipeline — find generates the list, wc -l counts the lines.
Compare fread() from <stdio.h> with read() from <unistd.h>. What are they each doing at the hardware/OS level? When would you choose one over the other?
/* fread — library function (stdio.h) */
/* Lives in user space, wraps read() internally */
/* Has an internal buffer (usually 4096 or 8192 bytes) */
/* Many small fread() calls → few actual kernel read() calls */
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
/* read — system call (unistd.h) */
/* Every call crosses user→kernel boundary (expensive context switch) */
/* No buffering — what you ask for is what the kernel does */
ssize_t read(int fd, void *buf, size_t count);
/*
* When to use fread (buffered):
* - Text processing, reading structured data
* - Lots of small reads (buffers accumulate them → fewer syscalls)
* - Portability required
*
* When to use read (unbuffered):
* - Need precise control over I/O (non-blocking, signals)
* - Reading/writing to devices, pipes, sockets
* - Implementing your own buffer strategy
* - Size of each operation matters (e.g. network protocols)
*/
read() call causes a context switch from user space to kernel space — this has overhead. fread() avoids most of these switches by buffering. However, buffering means data may not be written to disk/terminal immediately — fflush() forces the buffer. For interactive programs or network sockets, you often want unbuffered I/O.
Write a C program that opens two files and then prints a message. After running it under strace, what syscalls do you expect to see for the open and read operations? Also explain what ls -la /proc/$$/fd shows in the shell.
/* Program that opens two files */
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main(void) {
int fd1 = open("a.txt", O_RDONLY); /* gets fd = 3 */
int fd2 = open("b.txt", O_RDONLY); /* gets fd = 4 */
printf("fd1=%d fd2=%d\n", fd1, fd2);
/* At this point /proc/PID/fd has entries 0,1,2,3,4 */
pause(); /* hang so you can inspect /proc */
close(fd1); close(fd2);
return 0;
}
# strace output (abbreviated):
openat(AT_FDCWD, "a.txt", O_RDONLY) = 3
openat(AT_FDCWD, "b.txt", O_RDONLY) = 4
write(1, "fd1=3 fd2=4\n", 12) = 12
# ls -la /proc/PID/fd shows symlinks:
# lrwxrwxrwx 0 -> /dev/pts/0 (stdin = terminal)
# lrwxrwxrwx 1 -> /dev/pts/0 (stdout = terminal)
# lrwxrwxrwx 2 -> /dev/pts/0 (stderr = terminal)
# lrwxrwxrwx 3 -> /home/user/a.txt
# lrwxrwxrwx 4 -> /home/user/b.txt
ls -la /proc/$$/fd in bash shows your shell's open files. This is how you diagnose "too many open files" errors — you can see exactly which files a process has leaked.
Key concepts to memorize
Test your understanding
chmod 644 file.txt set the permissions to?LO2read() return when it reaches end-of-file?LO2int fd = open("data.txt", O_RDONLY);
char buf[100];
read(fd, buf, 100);
printf("%s\n", buf);