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:

  1. . and .. are showing — they shouldn't by default
  2. Hidden files (any dot file) are showing — they shouldn't without the -a flag

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 always 1.
  • 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_hidden is false (meaning -a wasn'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         │
└────────────────┴────────────────┴─────────────────┘
  • ls and ./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 = 2
  • argv[0] = "./myls" — the program's name
  • argv[1] = "-a" — our flag
  • argv itself 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

TopicWhat I Learned
Hidden filesA Unix convention — starts with a dot means hidden. Born from an accidental bug.
argcThe count of command-line arguments. The program itself counts too.
argvThe array of arguments. argv[0] = program name.
strcmp()The correct way to compare strings. You can't compare strings with == in C.
continueSkips the current iteration of a loop and moves to the next.
Boolean flag patternint 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

PartTopic
Part 1opendir(), DIR structure, debugging with GDB
Part 2readdir(), dirent struct, closedir()
Part 3 (this post)Hidden files, argc/argv, -a flag
Part 4argv deep dive, char**, path argument