Scripts & #!
Beginner
3 Programs
7 Questions
What is an Interpreter Script?
When you write a shell script, Python script, or awk script — these are just text files. The CPU can’t run text directly. So the Linux kernel uses the #! (shebang) line at the top of the file to figure out which program (the interpreter) should actually execute the text.
When you exec() a script file, the kernel reads the first line. If it starts with #!, it automatically re-runs the execution using the specified interpreter.
The #! Line Format
#! interpreter-path [ optional-arg ]
Examples:
#!/bin/sh # Use Bourne shell
#!/bin/bash # Use Bash
#!/usr/bin/python3 # Use Python 3
#!/usr/bin/awk -f # Use awk (with -f flag)
#!/usr/bin/perl # Use Perl
- Must be the very first two characters of the file:
#! - PATH is NOT used — you must give an absolute path to the interpreter
- Only ONE optional argument is allowed (treated as a single word, even with spaces)
- Linux limits the #! line to 127 characters
What the Kernel Does When It Sees #!
| Step | Action | Example |
|---|---|---|
| 1 | You call execve(“myscript.sh”, argv, envp) | argv = {“myscript.sh”, “arg1”, NULL} |
| 2 | Kernel reads first 2 bytes → sees “#!” | First line: #!/bin/bash |
| 3 | Kernel extracts interpreter path and optional arg | interpreter = /bin/bash, optional-arg = (none) |
| 4 | Kernel re-does exec with new argument list | /bin/bash myscript.sh arg1 |
| 5 | Interpreter starts and reads the script | bash reads and executes myscript.sh |
How Arguments Are Assembled for the Interpreter
| argv[0] | argv[1] | argv[2] | argv[3]… |
|---|---|---|---|
| interpreter-path /bin/awk |
optional-arg -f |
script-path myscript.awk |
remaining args from original execve() (excluding argv[0]) |
Example 1: exec() a Shell Script from C
/* example1_exec_script.c
* gcc -o ex1 example1_exec_script.c
*
* Step 1: Create the script file
* Step 2: Make it executable: chmod +x myscript.sh
* Step 3: Run: ./ex1
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <string.h>
#include <errno.h>
#include <fcntl.h>
/* Create a simple shell script file */
void create_script(const char *filename)
{
FILE *f = fopen(filename, "w");
if (!f) { perror("fopen"); exit(1); }
fprintf(f, "#!/bin/sh\n");
fprintf(f, "echo \"Script argv[0] = $0\"\n");
fprintf(f, "echo \"Script argv[1] = $1\"\n");
fprintf(f, "echo \"Script argv[2] = $2\"\n");
fprintf(f, "echo \"HOME = $HOME\"\n");
fclose(f);
/* Make it executable */
chmod(filename, 0755);
printf("Created script: %s\n", filename);
}
int main(void)
{
pid_t child;
int status;
const char *script = "/tmp/test_script.sh";
create_script(script);
child = fork();
if (child == -1) { perror("fork"); exit(1); }
if (child == 0) {
/* Child: exec the script */
char *argv[] = { (char *)script, "first_arg", "second_arg", NULL };
char *envp[] = { "HOME=/embedded", "PATH=/bin:/usr/bin", NULL };
printf("Child: execve(%s)\n", script);
execve(script, argv, envp);
fprintf(stderr, "execve failed: %s\n", strerror(errno));
exit(127);
}
waitpid(child, &status, 0);
if (WIFEXITED(status))
printf("Parent: script exited with %d\n", WEXITSTATUS(status));
return 0;
}
/* Expected output:
* Created script: /tmp/test_script.sh
* Child: execve(/tmp/test_script.sh)
* Script argv[0] = /tmp/test_script.sh
* Script argv[1] = first_arg
* Script argv[2] = second_arg
* HOME = /embedded
* Parent: script exited with 0
*/
Example 2: Using Optional Arg in #! Line (awk -f)
Without the -f option, awk treats its first argument as the script text. With -f it treats it as a script file. The #! optional-arg solves this.
/* example2_awk_script.c
* gcc -o ex2 example2_awk_script.c && ./ex2
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <string.h>
#include <errno.h>
void create_awk_script(const char *filename)
{
FILE *f = fopen(filename, "w");
if (!f) { perror("fopen"); exit(1); }
/* The -f flag in #! line tells awk: filename is a script file */
fprintf(f, "#!/usr/bin/awk -f\n");
fprintf(f, "{ print NR \": \" $0 } # print line number: line\n");
fprintf(f, "END { print \"Total lines:\", NR }\n");
fclose(f);
chmod(filename, 0755);
}
void create_input_file(const char *filename)
{
FILE *f = fopen(filename, "w");
if (!f) { perror("fopen"); exit(1); }
fprintf(f, "BLE Audio\nLC3 Codec\nIsochronous Streams\n");
fclose(f);
}
int main(void)
{
pid_t child;
int status;
const char *awk_script = "/tmp/count_lines.awk";
const char *input_file = "/tmp/topics.txt";
create_awk_script(awk_script);
create_input_file(input_file);
printf("Running awk script via execve...\n\n");
child = fork();
if (child == 0) {
/* Kernel sees #!/usr/bin/awk -f and transforms args:
* /usr/bin/awk -f /tmp/count_lines.awk /tmp/topics.txt */
char *argv[] = { awk_script, (char *)input_file, NULL };
char *envp[] = { NULL };
execve(awk_script, argv, envp);
fprintf(stderr, "execve: %s\n", strerror(errno));
exit(127);
}
waitpid(child, &status, 0);
return 0;
}
/* Expected output:
* Running awk script via execve...
*
* 1: BLE Audio
* 2: LC3 Codec
* 3: Isochronous Streams
* Total lines: 3
*/
Example 3: Scripts Without #! — execlp() Special Behavior
When a script has no #! line and you use execlp() or execvp(), they automatically try to run it with /bin/sh. Regular execve() would just fail with ENOEXEC.
/* example3_no_shebang.c
* gcc -o ex3 example3_no_shebang.c && ./ex3
*
* Demonstrates: execlp() handles missing #! by running /bin/sh
* execve() fails with ENOEXEC for same script
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <string.h>
#include <errno.h>
void create_plain_script(const char *path)
{
FILE *f = fopen(path, "w");
if (!f) { perror("fopen"); exit(1); }
/* No #! line! Just plain shell commands */
fprintf(f, "echo 'Running without shebang!'\n");
fprintf(f, "echo \"My PID is $$\"\n");
fclose(f);
chmod(path, 0755);
printf("Created plain script (no #!): %s\n\n", path);
}
void try_execve(const char *script)
{
pid_t child = fork();
if (child == 0) {
char *argv[] = { (char *)script, NULL };
char *envp[] = { "PATH=/bin:/usr/bin", NULL };
printf("Trying execve()...\n");
execve(script, argv, envp);
printf("execve FAILED: %s (errno %d = ENOEXEC expected)\n",
strerror(errno), errno);
exit(1);
}
int st; waitpid(child, &st, 0);
printf("\n");
}
void try_execlp(const char *script)
{
pid_t child = fork();
if (child == 0) {
printf("Trying execlp()...\n");
/* execlp: if file has execute bit but no #!, run via /bin/sh */
execlp(script, script, (char *)NULL);
printf("execlp FAILED: %s\n", strerror(errno));
exit(1);
}
int st; waitpid(child, &st, 0);
}
int main(void)
{
const char *script = "/tmp/plain_script.sh";
create_plain_script(script);
try_execve(script); /* Will fail */
try_execlp(script); /* Will succeed via /bin/sh */
return 0;
}
/* Expected output:
* Created plain script (no #!): /tmp/plain_script.sh
*
* Trying execve()...
* execve FAILED: Exec format error (errno 8 = ENOEXEC expected)
*
* Trying execlp()...
* Running without shebang!
* My PID is 12345
*/
❓ Interview Questions — Interpreter Scripts
Answer: The #! (shebang) line specifies which interpreter should run the script. It is processed by the Linux kernel during exec(), not by the shell. When execve() detects #! at the start of a file, it re-does the exec using the interpreter path from that line.
Answer: The kernel does NOT use the PATH environment variable when processing the #! line. It uses the path literally. If you write just “bash”, the kernel looks for a file literally named “bash” in the root directory and fails.
Answer: argv[0] is the interpreter path (e.g., /bin/bash). The script path becomes argv[1], any optional arg from the #! line becomes an argument too. The original argv[0] given to execve() is discarded.
Answer: execve() fails with ENOEXEC (“Exec format error”). The file has execute permission but the kernel cannot recognize its format. However, execlp() and execvp() handle this specially — they fall back to running the script with /bin/sh.
Answer: The kernel’s #! parser reads from the space after the interpreter path to the end of the line as one token. It does not split on spaces like a shell would. So “#!/usr/bin/awk -f -v FS=,” passes “-f -v FS=,” as a single argument, not three. This is Linux-specific behavior.
Answer: The path to python3 varies across systems (/usr/bin, /usr/local/bin, etc.). Using /usr/bin/env python3 works because env is almost always at /usr/bin/env, and env searches PATH for python3 — making the script portable across different Linux distributions.
Answer: Only one — the entire text after the interpreter path (and its leading space) to end-of-line is treated as a single argument string. You cannot pass multiple space-separated arguments on the #! line on Linux (unlike some older BSDs).
Next: File Descriptors and exec()
Learn how open files survive across exec() and how to control this
