perl.gg / hidden-gems

<!-- category: hidden-gems -->

Exit Code Bitshift

2026-04-17

You run a command with system(). It fails. You check $? and get the number 256.

256 is not an exit code. The process exited with 1.

system("false"); say $?; # 256 say $? >> 8; # 1
Perl gives you the wait status, not the exit code. The actual exit code is packed into the upper 8 bits. You have to shift right by 8 to get it out.

This catches everyone exactly once. Some people learn about it in five minutes. Others spend five hours staring at "256" wondering why their process returned a value that no program in history has ever set as an exit code.

Part 1: WHAT $? ACTUALLY CONTAINS

When a child process dies, Unix packs three things into a single 16-bit integer:
BITS WHAT ------ ---------------------------------- 15-8 Exit code (0-255) 7 Core dump flag (0 or 1) 6-0 Signal number (0-127)
Laid out as bits:
+--------+---+-------+ | exit | C | signal| | code | D | number| +--------+---+-------+ 15 8 7 6 0
So when a process exits normally with code 1, you get:
exit code = 1 (binary: 00000001) core dump = 0 signal = 0 Packed: 00000001_0_0000000 = 256 decimal
That is where 256 comes from. The exit code 1 is sitting in bits 15 through 8. The lower 8 bits are all zeros because the process exited cleanly (no signal, no core dump).

Part 2: THE >> 8 PATTERN

The bit shift $? >> 8 slides everything 8 positions to the right, dropping the signal and core dump bits entirely:
system("grep", "-q", "needle", "haystack.txt"); my $exit_code = $? >> 8; if ($exit_code == 0) { say "Found it"; } elsif ($exit_code == 1) { say "Not found"; } else { say "Error occurred (exit code: $exit_code)"; }
This is the standard pattern. You will see $? >> 8 in virtually every Perl program that manages child processes. It is idiomatic. It is expected. It is non-negotiable.

Without the shift, you are comparing against packed wait status values. Exit code 1 becomes 256. Exit code 2 becomes 512. Exit code 127 becomes 32512. None of these numbers mean anything to a human reading your code.

Part 3: CHECKING FOR SIGNALS

If the lower 7 bits are non-zero, the process was killed by a signal rather than exiting on its own:
system("long_running_command"); my $signal = $? & 127; # mask off the lower 7 bits if ($signal) { say "Process killed by signal $signal"; } else { my $exit_code = $? >> 8; say "Process exited with code $exit_code"; }
The & 127 (which is & 0x7F) masks out just the signal bits. Common signals you might see:
SIGNAL NUMBER MEANING ------ ------ ------------------------- SIGHUP 1 Hangup (terminal closed) SIGINT 2 Interrupt (Ctrl+C) SIGKILL 9 Kill (cannot be caught) SIGSEGV 11 Segmentation fault SIGTERM 15 Termination request SIGPIPE 13 Broken pipe
When a process dies by signal, the exit code bits (upper 8) are meaningless. Check for signals first, then check exit code. Order matters.

Part 4: THE CORE DUMP BIT

Bit 7 tells you whether a core dump was produced:
system("./buggy_program"); my $signal = $? & 127; my $core_dump = $? & 128; # bit 7 if ($signal) { say "Killed by signal $signal"; say "Core dump generated" if $core_dump; }
The & 128 (which is & 0x80) checks just bit 7. If the system has core dumps enabled and the signal caused one, this bit is set.

In practice, you rarely need to check this. But when you are debugging a segfault in a C extension or an XS module and need to know whether the core file exists, this bit tells you without guessing.

Part 5: THE COMPLETE STATUS CHECK

Here is the full pattern, covering every case:
sub run_and_check { my (@cmd) = @_; system(@cmd); if ($? == -1) { # system() itself failed (couldn't exec) die "Failed to execute '@cmd': $!\n"; } elsif ($? & 127) { # killed by signal my $signal = $? & 127; my $core = $? & 128 ? ' (core dumped)' : ''; die "Command '@cmd' died with signal $signal$core\n"; } else { # normal exit my $exit_code = $? >> 8; if ($exit_code != 0) { warn "Command '@cmd' exited with code $exit_code\n"; } return $exit_code; } }
Three cases. The special -1 check handles the situation where system() could not even start the command (file not found, permission denied, etc.). In that case $! has the error reason.

Part 6: BACKTICKS AND QX

Backticks and qx set $? the same way system() does:
my $output = `ls -la /nonexistent 2>&1`; my $exit_code = $? >> 8; if ($exit_code != 0) { say "ls failed with code $exit_code"; say "Output was: $output"; }
The difference is that backticks capture STDOUT while system() lets it pass through. But $? works identically in both cases. Same packed wait status. Same >> 8 pattern.

Same thing with qx:

my $date = qx~date +%Y-%m-%d~; chomp $date; die "date command failed\n" if $? >> 8;
And with open in pipe mode:
open my $pipe, '-|', 'find', '/tmp', '-name', '*.log' or die "Can't open pipe: $!\n"; while (<$pipe>) { chomp; say "Found: $_"; } close $pipe; # $? is set after closing the pipe if ($? >> 8) { warn "find exited with code " . ($? >> 8) . "\n"; }
Note that $? is set when you close the pipe handle, not when you open it.

Part 7: THE POSIX MODULE

If bit-shifting feels too low-level, the POSIX module provides named macros:
use POSIX qw(:sys_wait_h); system("some_command"); if (WIFEXITED($?)) { my $code = WEXITSTATUS($?); say "Exited normally with code $code"; } elsif (WIFSIGNALED($?)) { my $sig = WTERMSIG($?); say "Killed by signal $sig"; if (WCOREDUMP($?)) { say "Core dump produced"; } }
These are the same C macros that every Unix programmer knows. They do exactly what >> 8, & 127, and & 128 do, but with names that explain the intent.
MACRO EQUIVALENT MEANING ----------------- ---------------- ---------------------- WIFEXITED($?) ($? & 127) == 0 Did it exit normally? WEXITSTATUS($?) $? >> 8 What was the exit code? WIFSIGNALED($?) ($? & 127) != 0 Killed by signal? WTERMSIG($?) $? & 127 Which signal? WCOREDUMP($?) $? & 128 Core dump?
Use whichever you prefer. The bitshift version is more common in Perl code. The POSIX version is more common in code written by people who grew up on C.

Part 8: PRACTICAL PROCESS MANAGEMENT

A wrapper that runs commands, retries on failure, and gives clean diagnostics:
#!/usr/bin/env perl use strict; use warnings; use feature 'say'; sub run_with_retry { my (%opts) = @_; my @cmd = @{ $opts{cmd} }; my $retries = $opts{retries} // 3; my $delay = $opts{delay} // 5; for my $attempt (1 .. $retries) { say "Attempt $attempt: @cmd"; system(@cmd); if ($? == -1) { die "Cannot execute '@cmd': $!\n"; } if ($? & 127) { my $sig = $? & 127; die "Command killed by signal $sig, not retrying\n"; } my $exit_code = $? >> 8; return 0 if $exit_code == 0; # success warn "Exit code $exit_code, attempt $attempt of $retries\n"; sleep $delay if $attempt < $retries; } return $? >> 8; # return last exit code on failure } # usage my $result = run_with_retry( cmd => [qw(curl -sf http://example.com/api/health)], retries => 5, delay => 10, ); if ($result) { die "Health check failed after retries (exit code $result)\n"; } say "Service is up";
Notice how signal deaths are never retried. If something got SIGKILLed, retrying is pointless. But a non-zero exit code might be transient (network timeout, busy server), so those get retried.

Part 9: THE $? == -1 TRAP

When system() returns -1, it means Perl could not even fork and exec the command. This is different from the command running and failing:
system("nonexistent_command_xyz"); if ($? == -1) { # The command was never executed say "Error: $!"; # No such file or directory }
Some people skip this check and go straight to $? >> 8. That works most of the time. But when it fails, the error is mysterious. -1 >> 8 gives you a bunch of 1-bits (because Perl uses arithmetic shift on signed integers), and you end up with an exit code that makes no sense.

Always check for -1 first. Then check signals. Then check exit code. That order handles every case cleanly.

system(@cmd) | +------+------+ | | | $?=-1 $?&127 $?>>8 | | | exec signal exit failed killed code

Part 10: THE CHILD_ERROR_NATIVE

For the truly curious, there is also ${^CHILD_ERROR_NATIVE}. This gives you the raw wait status as the operating system reported it, before Perl does any munging.
system("some_command"); say "Perl \$?: $?"; say "Native: ${^CHILD_ERROR_NATIVE}";
On Unix, these are usually identical. But on Windows, they can differ because Windows does not use the same wait status format. If you are writing cross-platform code, ${^CHILD_ERROR_NATIVE} with the POSIX macros is the safest approach.

For Unix-only code, $? >> 8 is all you need. It has been the right answer since Perl 1. It will be the right answer in Perl

  1. Some patterns earn their permanence.

$? = wait status | +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ |15 |14 |13 |12 |11 |10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 | +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ | exit code | C | signal number | +---------------------------+---+-------------------------------+ ^ | core dump bit >> 8 = exit code & 127 = signal & 128 = core dump .--. |o_o | "Shift right by 8. |:_/ | That's the whole trick." // \ \ (| | ) /'\_ _/`\ \___)=(___/
The wait status is a 40-year-old Unix convention that Perl faithfully exposes through $?. It is not a Perl quirk. It is a Unix quirk that Perl refuses to hide from you.

Other languages wrap this in objects and method calls. Perl gives you the raw integer and trusts you to know what to do with it. Now you do.

perl.gg