Before reading this post, go through Part 2: readdir(), dirent, closedir() — it covers listing files from a directory.
At the end of Part 2 I noticed something — running our myls showed . and .. in the output, but the real ls doesn't. I said back then, "I'll fix this later." This is the part where I do exactly that.
But I won't just fix it — I'll dig a little deeper. Why this behavior? Is it intentional or accidental? And there's a fun bit of Unix history hiding here.
Hidden Files — The Birth of an Accidental Feature
In the early days of Unix, the ls program showed . (current directory) and .. (parent directory). These were annoying to look at because they appeared in every directory listing. So someone — believed to be Ken Thompson or Dennis Ritchie — wrote a quick fix to skip these two entries:
if (name[0] == '.') continue;
It should have been written like this:
if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) continue;
But instead of that, the shorter version got written — "if the first character of the name is ., skip it." A bug! With this, not just . and .. but any file starting with a dot got skipped.
But nobody thought of it as a bug at the time — instead they started using it as a feature. .bashrc, .gitignore, .ssh — this whole convention was born out of that one lazy shortcut.
Today a hidden file in Unix/Linux just means it starts with a dot — it's not any official specification, just an accidental convention.
Our Problem
Let's look at the problem in the output of Part 2's myls:
$ ./myls
. 32 bytes
.. 32 bytes
myls 32 bytes
myls.c 32 bytes
Running the real ls:
$ ls
myls myls.c
Two differences:
.and..are showing — they shouldn't by default- Hidden files (any dot file) are showing — they shouldn't without the
-aflag
The fix is simple:
if (entry->d_name[0] == '.') continue;
But we want behavior like the real ls — running ./myls -a shows everything, and without it they stay hidden. For that we need to handle command-line arguments.
argc and argv — How a Program Receives Arguments
I've been ignoring the signature of the main function all this time:
int main(int argc, char **argv)
A program receives its command-line arguments through these two parameters.
argc(argument count): how many arguments there are. The program's own name counts too, so the minimum value is always1.argv(argument values): the array of all arguments.argv[0]is always the program's name.
Example — running ./myls -a /tmp:
argc = 3
argv[0] = "./myls"
argv[1] = "-a"
argv[2] = "/tmp"
What does argv look like in memory?
argv is really a pointer to a pointer — char **. Each element is the starting address of a string. At the end there's a NULL as a sentinel.
argv
│
▼
┌─────────┐
│ ptr 0 │───────▶ "./myls\0"
├─────────┤
│ ptr 1 │───────▶ "-a\0"
├─────────┤
│ ptr 2 │───────▶ "/tmp\0"
├─────────┤
│ NULL │
└─────────┘
Live experiment
If you want to see it for yourself, write this little program and run it:
// argc_test.c
#include <stdio.h>
int main(int argc, char **argv) {
printf("argc = %d\n", argc);
for (int i = 0; i < argc; i++) {
printf("argv[%d] = %s\n", i, argv[i]);
}
return 0;
}
Compile and run it:
$ gcc -o argc_test argc_test.c
$ ./argc_test hello world -a
argc = 4
argv[0] = ./argc_test
argv[1] = hello
argv[2] = world
argv[3] = -a
Notice — the program's name automatically showed up in argv[0], even though we didn't type it. That's the shell's doing — when it executes a program, it passes the name as an argument too.
String Compare — Why == Doesn't Work
When checking a flag, you feel like writing argv[1] == "-a". But this won't work in C.
In C a string is a pointer to a character array. Comparing two pointers with == checks whether the two are the same memory address — not whether the content of the two strings is the same.
That's why you have to use strcmp():
#include <string.h>
strcmp("hello", "hello") // returns 0 (equal)
strcmp("abc", "xyz") // returns negative (abc comes before xyz)
strcmp("xyz", "abc") // returns positive
0 means equal. So to check the flag:
if (strcmp(argv[i], "-a") == 0) {
show_hidden = 1;
}
Updated myls.c
Now let's put it all together and write the updated code:
// myls.c
#include <dirent.h>
#include <stdio.h>
#include <string.h>
int main(int argc, char **argv) {
// check whether the -a flag is present
int show_hidden = 0;
for (int i = 1; i < argc; i++) {
if (strcmp(argv[i], "-a") == 0) {
show_hidden = 1;
}
}
DIR *dir_stream;
struct dirent *dir_read;
dir_stream = opendir(".");
if (dir_stream == NULL) {
perror("Error while trying to open directory");
return 1;
}
while ((dir_read = readdir(dir_stream)) != NULL) {
// skip dot files if show_hidden isn't set
if (!show_hidden && dir_read->d_name[0] == '.') {
continue;
}
printf("%s\n", dir_read->d_name);
}
if (closedir(dir_stream) == -1) {
perror("Can't close the dir");
return -1;
}
return 0;
}
What the Code Does Step by Step
1. Flag scan:
int show_hidden = 0;
for (int i = 1; i < argc; i++) {
if (strcmp(argv[i], "-a") == 0) show_hidden = 1;
}
Start from i = 1 — argv[0] is the program's name, so we skip it. We check every argument, and set show_hidden = 1 if we find -a.
2. Hidden file filter:
if (!show_hidden && dir_read->d_name[0] == '.') {
continue;
}
Two conditions together:
show_hiddenis false (meaning-awasn't given)- the first character of the name is
.
When both are true, continue — skip this entry and move to the next.
Output Comparison
Compile and test:
gcc -Wall -Werror -g myls.c -o myls
Let's see three versions side by side — the real ls, our ./myls, and ./myls -a:
┌────────────────┬────────────────┬─────────────────┐
│ $ ls │ $ ./myls │ $ ./myls -a │
├────────────────┼────────────────┼─────────────────┤
│ myls │ myls │ . │
│ myls.c │ myls.c │ .. │
│ │ │ myls │
│ │ │ myls.c │
└────────────────┴────────────────┴─────────────────┘
lsand./myls— identical. Hidden files are being hidden../myls -a— shows everything, including.and...
Behavior just like the real ls!
Looking at argv with GDB
I can't hold back my curiosity — let's use GDB to see what argv actually contains in memory:
The GDB session looks like this:
$ gdb ./myls
(gdb) b main
Breakpoint 1 at 0x11a9: file myls.c, line 6.
(gdb) run -a
Starting program: /work/myls -a
Breakpoint 1, main (argc=2, argv=0x7fffffffe328) at myls.c:6
(gdb) p argc
$1 = 2
(gdb) p argv[0]
$2 = 0x7fffffffe5e4 "./myls"
(gdb) p argv[1]
$3 = 0x7fffffffe5eb "-a"
(gdb) p argv
$4 = (char **) 0x7fffffffe328
(gdb) p *argv
$5 = 0x7fffffffe5e4 "./myls"
In the output you'll see:
argc = 2argv[0] = "./myls"— the program's nameargv[1] = "-a"— our flagargvitself is a memory address — because it's a pointer
One interesting thing: p argv shows an address, while p *argv shows argv[0]. That's because argv is a char** — a pointer to a pointer.
What I Learned Today
| Topic | What I Learned |
|---|---|
| Hidden files | A Unix convention — starts with a dot means hidden. Born from an accidental bug. |
argc | The count of command-line arguments. The program itself counts too. |
argv | The array of arguments. argv[0] = program name. |
strcmp() | The correct way to compare strings. You can't compare strings with == in C. |
continue | Skips the current iteration of a loop and moves to the next. |
| Boolean flag pattern | int show_hidden = 0 → argument loop → if (!show_hidden && ...) |
Conclusion
A tiny if check and argc/argv — that's all it took for our myls to now handle hidden files like the real ls. And the story of that accidental bug — to me it's a reminder that a lot of "design decisions" are really just because someone was lazy one day.
In the next part I'll go deeper into argv — taking a path argument, so any directory can be listed like ./myls /tmp.
The Full Series
| Part | Topic |
|---|---|
| Part 1 | opendir(), DIR structure, debugging with GDB |
| Part 2 | readdir(), dirent struct, closedir() |
| Part 3 (this post) | Hidden files, argc/argv, -a flag |
| Part 4 | argv deep dive, char**, path argument |

