Before reading this post, read Part 3: Hidden files, argc/argv,
-aflag first — that's whereargc/argvgot 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:
argvitself 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. achar *)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
-aflag 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
| Topic | What 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 check | Mandatory argc > 1 check before accessing an array index |
| Undefined behavior | Not crashing doesn't mean it's fine — births invisible bugs |
perror(path) | Informative error message with a custom prefix |
GDB x command | Inspect 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
| Part | Topic |
|---|---|
| Part 1 | opendir(), DIR structure, debugging with GDB |
| Part 2 | readdir(), dirent struct, closedir() |
| Part 3 | Hidden files, argc/argv, -a flag |
| Part 4 (this post) | char**, argv memory layout, path argument |
| Part 5 | stat() system call, file metadata |

