Before reading this post, read Part 3: Hidden files, argc/argv, -a flag first — that's where argc/argv got introduced.

In Part 3 I made a quick acquaintance with argc and argv — just enough to detect the -a flag, no more. But real ls takes more than flags; it takes a path too:

$ ls /tmp
$ ls /etc
$ ls -la /var/log

Our myls is still hardcoded — it works only on the current directory (.). I want this: ./myls /tmp should show the contents of /tmp, and ./myls -a /etc should show /etc including hidden files.

To do this I need to understand the real structure of argv. In Part 3 we accessed argv[0], argv[1] — but what does this array actually look like in memory? Why does the char ** type feel scary? What happens if you access argv[100]?

This part goes into that depth.

char ** — Breaking the Type Down

main's signature:

int main(int argc, char **argv)

Read char **argv out loud — "pointer to pointer to char." First time you see it, your head spins. But step by step it's easy.

Step 1 — Single string:

In C a string is an array of characters. And the name of an array means the address of its first element. So:

char *name = "nazrul";

name is a pointer — it holds the address of the 'n' character. In memory:

name ──▶ ['n']['a']['z']['r']['u']['l']['\0']

Step 2 — Multiple strings:

What if you want to hold several strings?

char *names[] = { "nazrul", "islam", "dhaka" };

names is an array, each element a char *. The name of the array means the address of its first element — which here is a pointer to char *. That is, char **.

names ──▶ ┌─────────┐
          │ char *  │───▶ "nazrul\0"
          ├─────────┤
          │ char *  │───▶ "islam\0"
          ├─────────┤
          │ char *  │───▶ "dhaka\0"
          └─────────┘

argv is exactly this — an array of the strings passed to the program.

The Full Picture of argv in Memory

Say we ran:

./myls -a /tmp

Here's what's happening in memory at that moment:

  Stack frame (main):         Process memory (args area):
  ┌──────────┐
  │ argc = 3 │
  ├──────────┤
  │  argv    │──────┐
  └──────────┘      │
                    ▼
              ┌─────────┐
              │  ptr 0  │─────▶  "./myls\0"
              ├─────────┤
              │  ptr 1  │─────▶  "-a\0"
              ├─────────┤
              │  ptr 2  │─────▶  "/tmp\0"
              ├─────────┤
              │  NULL   │   ◀── sentinel
              └─────────┘

Key points:

  • argv itself is a pointer — it points to an array.
  • Each element of that array is again a pointer — pointing to a null-terminated string.
  • At the end of the array is NULL — this is a guarantee of the C standard (C99 section 5.1.2.2.1).

The fun part — with that NULL you can iterate even without argc:

for (char **p = argv; *p != NULL; p++) {
    printf("%s\n", *p);
}

This is the idiomatic C pattern. But we'll use argc instead — more beginner-friendly.

Inspecting argv with GDB

Now let's look at the actual memory. Get into the Docker container and write a tiny inspection program:

// argv_inspect.c
#include <stdio.h>

int main(int argc, char **argv) {
    printf("inspect me with gdb\n");
    return 0;
}

Compile and debug:

gcc -Wall -g argv_inspect.c -o argv_inspect
gdb ./argv_inspect

GDB session:

(gdb) b main
Breakpoint 1 at 0x1149: file argv_inspect.c, line 4.

(gdb) run -a /tmp hello

Breakpoint 1, main (argc=4, argv=0x7fffffffe328) at argv_inspect.c:4

(gdb) p argc
$1 = 4

(gdb) p argv
$2 = (char **) 0x7fffffffe328

(gdb) p argv[0]
$3 = 0x7fffffffe5a0 "./argv_inspect"

(gdb) p argv[1]
$4 = 0x7fffffffe5b0 "-a"

(gdb) p argv[2]
$5 = 0x7fffffffe5b3 "/tmp"

(gdb) p argv[3]
$6 = 0x7fffffffe5b8 "hello"

(gdb) p argv[4]
$7 = 0x0

A few things worth noticing:

1. argv[4] is 0x0 — meaning NULL. That sentinel from the C standard really is there. See it?

2. The strings sit in consecutive memory: 0x...e5a0, ...e5b0, ...e5b3, ...e5b8 — they're sitting right next to each other. Because when the shell launches the program, it copies all the arguments into a single block.

3. argv itself is an address (0x7fffffffe328) — where the pointer array sits.

If you want to go deeper, you can inspect raw memory with the x command:

(gdb) x/5xg argv
0x7fffffffe328: 0x00007fffffffe5a0  0x00007fffffffe5b0
0x7fffffffe338: 0x00007fffffffe5b3  0x00007fffffffe5b8
0x7fffffffe348: 0x0000000000000000

x/5xg means — "examine 5 giant (8-byte) words in hex." It shows 5 pointers in a row, the last one NULL (0x0000...).

This one command puts the entire memory layout of the argv array right in front of your eyes.

argv[i][0] — Why Does This Syntax Work?

To detect a path we have to exclude flags (which start with -). That means we check the first character of each argument.

if (argv[i][0] != '-') { ... }

This double indexing is confusing the first time. But break it down and it's simple. In C, p[i] is really syntactic sugar for *(p + i). So:

  • argv[i] = *(argv + i) = the i-th pointer (i.e. a char *)
  • argv[i][0] = *(argv[i] + 0) = the first character of that string

Pointer arithmetic in two steps:

  argv[i][0]
   │    │  │
   │    │  └─▶ the 0-th char of the string
   │    └────▶ the i-th pointer (start of the string)
   └─────────▶ the start of the pointer array

Don't be scared of the double indexing — underneath it's just pointer arithmetic.

Taking a Path Argument

Now let's upgrade our myls. The logic is simple:

  • If the user gives a non-flag argument, take it as the path.
  • If not, default to . (current directory).
char *path = ".";  // default
for (int i = 1; i < argc; i++) {
    if (argv[i][0] != '-') {
        path = argv[i];
        break;
    }
}

We start at i = 1 — argv[0] = program name, skip it. The first non-flag argument we find becomes the path, and we break out of the loop.

Safe Access — Why a Bounds Check Is Mandatory

Dangerous code:

char *path = argv[1];  // ❌ terrifying if argc = 1

If the user just types ./myls — argc = 1, so argv[1] is that NULL sentinel. Then accessing argv[1][0] is a NULL pointer dereference — segmentation fault.

Correct code:

if (argc > 1 && argv[1][0] != '-') {
    path = argv[1];
}

This is the classic case of undefined behavior. In C, undefined behavior means the compiler gives you no guarantee about what will happen:

  • Sometimes it crashes (lucky)
  • Sometimes you get garbage data (unlucky)
  • Sometimes it "seems to run fine," but later there's a weird bug somewhere else — and that's the most terrifying of all

Lesson: Always bounds-check before accessing an array index.

Updated myls.c

Putting it all together:

// myls.c

#include <dirent.h>
#include <stdio.h>
#include <string.h>

int main(int argc, char **argv) {
  int show_hidden = 0;
  char *path = ".";  // default: current directory

  // parse arguments — handle flag and path together
  for (int i = 1; i < argc; i++) {
    if (strcmp(argv[i], "-a") == 0) {
      show_hidden = 1;
    } else if (argv[i][0] != '-') {
      path = argv[i];
    }
  }

  DIR *dir_stream = opendir(path);
  if (dir_stream == NULL) {
    perror(path);  // error with the path name
    return 1;
  }

  struct dirent *entry;
  while ((entry = readdir(dir_stream)) != NULL) {
    if (!show_hidden && entry->d_name[0] == '.') continue;
    printf("%s\n", entry->d_name);
  }

  if (closedir(dir_stream) == -1) {
    perror("closedir");
    return 1;
  }

  return 0;
}

What Changed in the Code

Compared to Part 3, there are two main changes:

1. Path variable:

char *path = ".";

Default . — now a variable instead of Part 3's hardcoded opendir(".").

2. An else if in the argument loop:

} else if (argv[i][0] != '-') {
    path = argv[i];
}

Not a flag means it's the path. If it starts with - it's a flag (more flags will come later — -l, -R).

3. perror(path): On error, a message with the path name — informative. "/nonexistent: No such file or directory" is much better than "Error opening directory."

Let's Test

$ gcc -Wall -Werror -g myls.c -o myls

Let's look at four cases together:

┌─────────────────────────────────┬───────────────────────────┐
│  Command                        │  Behavior                 │
├─────────────────────────────────┼───────────────────────────┤
│  $ ./myls                       │  current dir, no hidden   │
│  $ ./myls /tmp                  │  /tmp, no hidden          │
│  $ ./myls -a /tmp               │  /tmp, with hidden        │
│  $ ./myls /nonexistent          │  error with path name     │
└─────────────────────────────────┴───────────────────────────┘

Actual output:

$ ./myls /tmp
com.apple.launchd.xxxx
powerlog
tmp.xxxxx

$ ./myls -a /tmp
.
..
.DS_Store
com.apple.launchd.xxxx
tmp.xxxxx

$ ./myls /nonexistent
/nonexistent: No such file or directory

See how perror(path) builds a message with the path name in the error case? It gives context — the user knows exactly which path failed.

Real ls Has a Lot More — Things We Still Don't Have

Real ls does much more complex parsing:

$ ls /tmp /etc              # multiple paths at once
$ ls -la /tmp               # combined flags (-l and -a)
$ ls -l -a /tmp             # separate flags, same meaning
$ ls /tmp -a                # flag even after the argument

Our myls now handles:

  • ✅ a single path argument
  • ✅ the -a flag in any position
  • ❌ multiple paths at once
  • ❌ combined flags (-la)

These are future work. The foundation right now is solid — we can build more on top.

What I Learned Today

TopicWhat I learned
char **Pointer to pointer — the standard pattern for an array of strings
argv[argc]Always NULL — a C standard guarantee (sentinel)
argv[i][0]Double indexing — the 0-th char of the i-th element of the string
What p[i] means*(p + i) — syntactic sugar for pointer arithmetic
Bounds checkMandatory argc > 1 check before accessing an array index
Undefined behaviorNot crashing doesn't mean it's fine — births invisible bugs
perror(path)Informative error message with a custom prefix
GDB x commandInspect raw memory — x/5xg (5 giant hex words)

Conclusion

For the longest time, when I saw argv I'd think — "that's magic, it just exists in main." But today I saw it — when the shell launches the program it really does build a pointer array, sends the pointer to that array into our main, and marks the end with NULL. A simple mechanism, but once you understand it, argument parsing in C stops feeling unfamiliar.

Now a change is coming. In the next part we'll step into a new world — the stat() system call. So far we've only used library functions (opendir, readdir, closedir). This time we'll talk directly to the kernel — we'll get a file's size, permissions, owner, modification time. This is the first step toward building the ls -l flag.

A system call and a library function — both look like a function call from the outside, but inside they're entirely different worlds. Kernel mode, context switch, syscall table — all of it will come into view.

Stay with me.


The Complete Series

PartTopic
Part 1opendir(), DIR structure, debugging with GDB
Part 2readdir(), dirent struct, closedir()
Part 3Hidden files, argc/argv, -a flag
Part 4 (this post)char**, argv memory layout, path argument
Part 5stat() system call, file metadata