You're right in that normally, to redirect a command, all the shell has to do is fork a child process, do the redirection in the child only (the parent file descriptors are not affected) and then execute the command in there (while the parent just waits for the child).
For builtin commands (or compound commands, or functions...), there is no fork nor exec. Yet after a redirected builtin command terminates, the file descriptors are back to how they were before.
To do that, the shell just saves a copy of the redirected file descriptor to another file descriptor before doing the redirection, marks that file descriptor with the O_CLOEXEC flag (in case the builtin ends up executing a command like eval
or command
would), and when the builtin returns, the shell restores the file descriptor.
You can see that if you run for instance:
strace sh -c 'echo test > /dev/null; :'
You'll see (only relevant entries included):
open("/dev/null", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 3
: opens the file to redirect to.
fcntl(1, F_DUPFD, 10) = 10
: duplicate the original stdout to the first available fd >= 10 (here 10).
fcntl(10, F_SETFD, FD_CLOEXEC) = 0
: set the O_CLOEXEC flag on it
dup2(3, 1) = 1
: make the redirected file stdout
close(3) = 0
: no longer needed
write(1, "test\n", 5) = 5
: echo runs and writes "test\n" on its stdout (now redirected to /dev/null).
dup2(10, 1) = 1
: restore stdout
close(10) = 0
: close fd 10 no longer needed.
Note that in the Bourne shell, redirecting a compound command did cause a fork (as in a=0; { a=1; echo "$a"; } >&2; echo "$a"
would give you 1
then 0
). To work around that, you actually had to do the above by hand:
Instead of
while cmd1; do cmd2; i=`expr "$i" + 1`; done > file
You had to do:
exec 3>&1 > file
while cmd1 3>&-; do cmd2 3>&-; i=`expr "$i" + 1 3>&-`; done
exec >&3 3>&-
(with a 3>&-
for each command as that fd doesn't have the O_CLOEXEC flag).
And in early versions of the Bourne shell, you could not redirect builtins. On a Unix V7 on an emulated pdp11:
$ eval a=1 > /tmp/x
illegal io
$ read a < /etc/passwd
illegal io
cd
. If it executed in a child process it would have no effect. Indeed very early in the piece it did execute in a child process, leading to much bewilderment among the UNIX developers ... until they twigged that some commands had to be built-in and executed in the current shell process.