Bash por dentro: como o shell lê, expande e executa os seus comandos
Uma imersão no Bash. A história do shell do Unix ao GNU, o caminho de um comando da leitura à execução, as expansões na ordem certa, as aspas, os redirecionamentos, os pipelines e os subshells, o status de saída, as funções, os sinais, a portabilidade com o POSIX, os erros mais comuns e o jeito como a Gothor usa scripts no dia a dia.

Um servidor que precisa ser atualizado às três da manhã, um backup que roda todo dia sem ninguém olhar, um arquivo de vendas que chega por e-mail e precisa virar uma planilha, uma lista de contêineres que precisa ser reiniciada depois de uma queda de energia. Em quase toda empresa que tem um servidor Linux, essas tarefas acabam escritas do mesmo jeito: algumas linhas de comandos, guardadas num arquivo de texto, que o computador executa uma depois da outra. Quem lê e executa essas linhas, na maioria das vezes, é o Bash.
O Bash parece simples porque o uso do dia a dia é simples: digitar um comando, apertar Enter, ver o resultado. Mas ele é também uma linguagem de programação completa, com variáveis, funções, laços e tratamento de erros, e uma linguagem com regras próprias, algumas delas pouco intuitivas. Uma variável sem aspas que se divide em duas palavras, um set -e que não para o script quando deveria, um laço que perde o valor de uma variável depois de um pipe: os problemas mais comuns em scripts vêm de detalhes de como o shell lê e transforma cada linha antes de executá-la.
Este artigo é uma imersão no Bash, de dentro para fora. Começa pela história, do primeiro shell do Unix ao projeto GNU. Depois segue o caminho de um comando: como o Bash separa a linha em palavras, a ordem exata das expansões, o papel das aspas, os redirecionamentos e os descritores de arquivo, os pipelines e os subshells, o status de saída e o controle de fluxo. Em seguida vêm as funções, os sinais, os arquivos de inicialização e a relação com o padrão POSIX, as travas e os prazos, o desempenho, as ferramentas para depurar, os erros mais comuns e, no fim, os princípios que seguimos na Gothor.
O que é o Bash
O Bash é um shell: o programa que fica entre a pessoa e o sistema operacional, lê comandos e pede ao sistema que os execute. O nome vem de Bourne-Again SHell, um trocadilho com o nome de Stephen Bourne, autor do shell que o Bash substituiu, e com a expressão inglesa born again, "nascido de novo". É o shell do projeto GNU, escrito em C e distribuído como software livre, sob a licença GPL na versão 3 desde a série 4.0.
Duas palavras costumam se misturar, e vale separá-las:
- O terminal (ou emulador de terminal) é a janela onde se digita: o Windows Terminal, o GNOME Terminal, o Terminal do macOS, o PuTTY numa conexão SSH. Ele só mostra texto e repassa o que se digita.
- O shell é o programa que roda dentro do terminal, lê cada linha, interpreta e executa. O Bash é um shell; o zsh, o dash e o fish são outros.
O Bash trabalha de dois jeitos. No modo interativo, ele mostra um prompt, espera um comando, executa e volta ao prompt, com histórico, edição de linha e preenchimento automático com a tecla Tab. No modo não interativo, ele lê os comandos de um arquivo, o script, ou de uma string passada com bash -c, e executa tudo sem perguntar nada a ninguém. A linguagem é a mesma nos dois modos; o que muda são detalhes de comportamento, como os arquivos de configuração que ele lê ao iniciar e algumas opções que vêm ligadas ou desligadas.
Um script Bash é um arquivo de texto que costuma começar com uma linha especial, o shebang:
#!/usr/bin/env bash
echo "Olá do Bash $BASH_VERSION"
Os dois primeiros caracteres, #!, dizem ao sistema operacional qual programa deve interpretar o arquivo quando ele é executado diretamente (com ./script.sh, depois de receber a permissão de execução com chmod +x). O /usr/bin/env bash procura o Bash no PATH, o que funciona em mais sistemas do que um caminho fixo como /bin/bash. Para o Bash, a linha é só um comentário, porque começa com #.
Uma história curta
O primeiro shell do Unix foi escrito por Ken Thompson e acompanhou as primeiras versões do sistema, no começo dos anos 1970, nos laboratórios Bell. Ele já tinha os redirecionamentos com < e > e, a partir de 1973, o pipe |, a ideia de Douglas McIlroy de ligar a saída de um programa à entrada do outro. Mas era pouco mais do que um executor de comandos: não tinha variáveis de verdade nem estruturas de controle próprias.
Em 1979, a sétima versão do Unix trouxe o shell de Stephen Bourne, o sh. Com ele, o shell virou uma linguagem de programação: variáveis, laços for e while, if e case, o here-document, a substituição de comando com crases e o status de saída usado nas condições. A sintaxe herdou palavras do ALGOL 68, que Bourne conhecia bem, e é por isso que os blocos terminam com a palavra ao contrário: if fecha com fi, case com esac.
Em paralelo, a Universidade da Califórnia em Berkeley criou o C shell (csh), escrito por Bill Joy no fim dos anos 1970, com histórico de comandos, controle de jobs e uma sintaxe parecida com a da linguagem C. O csh foi bem recebido para o uso interativo, mas ficou com fama de ruim para scripts. Em 1983, David Korn, também nos laboratórios Bell, apresentou o Korn shell (ksh), que mantinha a compatibilidade com o shell de Bourne e trazia o melhor do csh, além de recursos novos como a aritmética com $((...)) e a substituição de comando com $(...).
O projeto GNU, que queria um sistema operacional inteiramente livre, precisava de um shell compatível com o de Bourne e sem as restrições de licença dos shells comerciais. Brian Fox, então funcionário da Free Software Foundation, escreveu o Bash e publicou a primeira versão de testes, a 0.99, em 8 de junho de 1989. Fox manteve o Bash até o começo dos anos 1990, quando a manutenção passou para Chet Ramey, da Case Western Reserve University, que já contribuía com o projeto e é o mantenedor desde então.
Na mesma época, o padrão POSIX definia como um shell deve se comportar. A parte de shell e utilitários, conhecida como POSIX.2 (IEEE 1003.2), foi aprovada em 1992, baseada principalmente no shell de Bourne do System V e com elementos do Korn shell. O Bash adotou o padrão e ganhou um modo POSIX para seguir as regras à risca quando necessário. Hoje, o shell e os utilitários fazem parte do volume único POSIX.1, cuja edição mais recente é a de 2024.
Com o Linux, que usava o kernel de Linus Torvalds e as ferramentas do GNU, o Bash se tornou o shell padrão de quase todas as distribuições. Algumas versões marcaram época: a 2.0, de 1996, trouxe os arrays; a 3.0, de 2004, o operador =~ para expressões regulares; a 4.0, de 2009, os arrays associativos, o ** recursivo nos nomes de arquivos, os coprocessos e o comando mapfile; a 5.0 saiu em 2019. Em setembro de 2014, uma falha grave na forma como o Bash importava funções do ambiente, apelidada de Shellshock, mostrou o quanto o shell estava espalhado por servidores, roteadores e aparelhos.

Dois movimentos recentes mudaram o lugar do Bash. Distribuições como o Debian e o Ubuntu passaram a usar o dash, um shell menor e mais rápido, como o /bin/sh do sistema, para os scripts de inicialização; o Bash continua como o shell interativo dos usuários. E a Apple, que distribuía o Bash no macOS, ficou presa à versão 3.2, a última sob a licença GPL na versão 2, e em 2019 trocou o shell padrão do Terminal pelo zsh. O Bash continua lá, mas na série 3.2, de 2006, o que importa para quem escreve scripts que precisam rodar também em Macs.
A série 5, até o Bash 5.3
Desde 2019, o Bash evolui em versões espaçadas, cada uma com poucos recursos novos e muito cuidado com a compatibilidade. A 5.0 saiu em janeiro de 2019, a 5.1 em dezembro de 2020, a 5.2 em setembro de 2022 e a 5.3 em julho de 2025. Alguns recursos dessas versões mudam a forma de escrever scripts:
- 5.0: as variáveis
EPOCHSECONDSeEPOCHREALTIME, que dão a hora atual em segundos desde 1970, a segunda com precisão de microssegundos, sem chamar o programadate. Servem para medir quanto uma etapa demorou sem criar processos. - 5.1: a variável
SRANDOM, um número aleatório de 32 bits tirado de uma fonte do sistema e que não pode ser semeado, ao contrário doRANDOM, cuja sequência é previsível; e a opçãowait -p variavel, que, junto com owait -n, diz qual dos processos em segundo plano acabou de terminar. - 5.2: a opção
globskipdots, ligada por padrão, que impede o.e o..de aparecerem no resultado de um glob; e apatsub_replacement, também ligada por padrão, que faz um&sem escape na troca${var//de/para}representar o trecho encontrado. Um script antigo que usava&literal na troca mudou de resultado com a atualização, e a correção é escapá-lo com barra:${texto//e/\&}. - 5.3: uma forma nova de substituição de comando,
${ comando; }, que captura a saída sem criar um processo filho, descrita mais adiante; a variávelGLOBSORT, que escolhe como ordenar o resultado de um glob, por nome, tamanho ou data de modificação, por exemplo; e a opçãosource -p, que procura o arquivo num caminho próprio em vez doPATH.
O ritmo lento tem um motivo: o Bash roda em scripts de inicialização, instaladores e imagens de contêiner do mundo inteiro, e uma mudança de comportamento, por menor que seja, quebra alguma coisa em algum lugar. Por isso quase todo recurso novo chega como opção, e as mudanças de padrão, como as da 5.2, aparecem documentadas uma a uma no arquivo de novidades de cada versão.
Como o Bash lê um comando
Quando se aperta Enter, o Bash não executa a linha de uma vez. Ele passa por uma sequência fixa de etapas, e conhecer essa sequência explica quase todos os comportamentos estranhos do shell.
- Leitura. O Bash lê a linha (de um arquivo, do terminal ou de uma string).
- Divisão em tokens. A linha é quebrada em palavras e operadores, respeitando as aspas. Operadores são sequências como
|,&&,;,>e(. - Aliases. Se a primeira palavra de um comando é um alias, ela é trocada pelo texto do alias.
- Análise. Os tokens são organizados em comandos simples, pipelines, listas e comandos compostos (
if,for,while,case,{ ... },( ... )). - Expansões. Cada palavra passa pelas expansões, numa ordem fixa, que transformam
$HOME,*.txt,$(date)e companhia no texto final. - Redirecionamentos. Os
<,>,2>e afins são aplicados, e as palavras que eram só redirecionamento saem da lista. - Execução. A primeira palavra que sobrou é o nome do comando; as outras são os argumentos. O Bash procura o comando e o executa, e guarda o status de saída em
$?.
Comandos simples, pipelines e listas
O bloco mais básico é o comando simples: atribuições opcionais, um nome e argumentos, como em LANG=C sort arquivo.txt. As atribuições que vêm antes do nome valem só para aquele comando.
Um pipeline é uma sequência de comandos ligados por |: a saída de cada um vira a entrada do seguinte. ps aux | grep nginx | wc -l é um pipeline de três comandos.
Uma lista junta pipelines com os operadores ;, &, && e ||:
a ; bexecutaa, espera terminar e executab, não importa o resultado;a & bcolocaaem segundo plano e executabem seguida, sem esperar;a && bsó executabseadeu certo (status zero);a || bsó executabseafalhou (status diferente de zero).
&& e || têm a mesma precedência e se associam da esquerda para a direita. Por isso a && b || c não é um "se, senão" de verdade: c também roda quando b falha, mesmo que a tenha dado certo. Para uma condição com duas saídas, o if é mais seguro.
Os comandos compostos agrupam outros comandos: as estruturas de controle, o agrupamento com chaves { ...; }, que roda os comandos no próprio shell, e o agrupamento com parênteses ( ... ), que os roda num subshell, uma cópia do shell em outro processo. A diferença entre os dois aparece mais adiante, quando falarmos de processos.
Onde o Bash procura um comando
Com a linha já expandida, a primeira palavra precisa virar alguma coisa executável. O Bash procura nesta ordem:
- Palavras reservadas, como
if,forewhile(essas, na verdade, são reconhecidas ainda na análise); - Funções definidas no shell;
- Comandos internos (builtins), como
cd,echo,read,exportetest, que são parte do próprio Bash; - Programas externos, procurados nos diretórios da variável
PATH, na ordem em que aparecem.
Os comandos internos existem por dois motivos. Alguns só fazem sentido dentro do shell: o cd precisa mudar o diretório do próprio shell, e um programa externo mudaria só o diretório dele mesmo. Outros são internos por desempenho: o echo, o printf e o test existem também como programas em /usr/bin, mas chamar um programa externo custa criar um processo.
Para os programas externos, o Bash guarda numa tabela o caminho onde achou cada um, para não varrer o PATH a cada chamada. O comando hash mostra essa tabela, e hash -r a limpa, o que resolve o caso de um programa instalado num lugar novo que o shell continua procurando no antigo. Para saber o que o Bash vai executar para um nome, o comando certo é o type:
$ type cd ls ll if
cd is a shell builtin
ls is /usr/bin/ls
ll is aliased to `ls -alF'
if is a shell keyword
Quando o comando é um programa externo, o Bash cria um processo filho com a chamada de sistema fork, que duplica o shell, e nesse filho chama exec, que troca o programa do processo pelo comando pedido. O pai espera o filho terminar e lê o status de saída. É por isso que um script cheio de chamadas a programas externos dentro de um laço fica lento: cada volta cria processos novos.
As expansões, na ordem certa
As expansões são o coração do Bash. Elas transformam o que foi digitado no que será de fato executado, e acontecem numa ordem fixa, descrita no manual do Bash:
- Expansão de chaves:
arquivo.{txt,bak}viraarquivo.txt arquivo.bak. - Num único passo, da esquerda para a direita: - expansão do til:
~vira o diretório do usuário; - expansão de parâmetros e variáveis:$HOME,${nome},${nome:-padrão}; - expansão aritmética:$((2 + 3))vira5; - substituição de comando:$(date +%F)vira a data de hoje; - substituição de processo:<(comando)vira um caminho de arquivo. - Divisão de palavras: o resultado das expansões de parâmetros, aritmética e substituição de comando, quando fora de aspas, é quebrado em várias palavras.
- Expansão de nomes de arquivos: palavras com
*,?ou[...]viram a lista de arquivos que combinam. - Remoção de aspas: as aspas e barras que não vieram de uma expansão são retiradas.

A ordem tem consequências práticas. Como a expansão de chaves vem antes de tudo, {1..$n} não funciona: quando as chaves são expandidas, $n ainda não virou número. Para uma sequência com variável, usa-se seq ou um for aritmético. Como a divisão de palavras e a expansão de nomes de arquivos vêm depois das variáveis, o conteúdo de uma variável sem aspas pode virar várias palavras e até uma lista de arquivos.
A expansão de parâmetros
A forma básica é $nome ou ${nome}; as chaves são necessárias quando o nome é seguido de letras (${arquivo}_backup) ou para os recursos extras. Os mais usados:
| Forma | O que faz |
|---|---|
${var:-padrão} | usa padrão se var estiver vazia ou não definida |
${var:=padrão} | como o anterior, e ainda atribui o padrão à variável |
${var:?mensagem} | encerra com erro e mostra a mensagem se var estiver vazia |
${var:+outro} | usa outro só se var tiver valor; senão, nada |
${#var} | o tamanho do valor, em caracteres |
${var#padrão} / ${var##padrão} | tira do começo o menor / o maior trecho que combina |
${var%padrão} / ${var%%padrão} | tira do fim o menor / o maior trecho que combina |
${var/de/para} / ${var//de/para} | troca a primeira / todas as ocorrências |
${var:inicio:tamanho} | um pedaço do valor |
${var^^} / ${var,,} | tudo em maiúsculas / minúsculas (Bash 4 ou mais novo) |
Sem os dois-pontos, as formas -, =, ? e + testam só se a variável existe, e aceitam uma variável definida com valor vazio. Com os dois-pontos, o valor vazio é tratado como ausente.
As remoções de padrão substituem muitos usos de programas externos. Para um caminho guardado em arquivo=/var/log/nginx/acesso.log.gz:
echo "${arquivo##*/}" # acesso.log.gz (como o basename)
echo "${arquivo%/*}" # /var/log/nginx (como o dirname)
echo "${arquivo%.gz}" # /var/log/nginx/acesso.log
echo "${arquivo%%.*}" # /var/log/nginx/acesso
Como tudo acontece dentro do shell, sem criar processos, é bem mais rápido num laço.
Substituição de comando e aritmética
A substituição de comando $(comando) executa o comando num subshell e põe a saída dele no lugar, sem as quebras de linha do fim. A forma antiga, com crases, ainda funciona, mas $(...) pode ser aninhada sem escapes e é mais legível:
hoje=$(date +%F)
versao=$(git -C "$repo" describe --tags --abbrev=0)
Como o comando roda num subshell, cada $(...) custa a criação de um processo, e nada do que ele altera volta para o shell. O Bash 5.3 trouxe duas formas que evitam as duas coisas. A ${ comando; }, com um espaço depois da chave e o ponto e vírgula antes do fecho, executa o comando no próprio shell e captura a saída, sem as quebras de linha do fim. A ${| comando; } não captura a saída: devolve o valor que o comando deixou na variável REPLY, que é local a essa execução.
linhas=${ wc -l < vendas.csv; } # sem criar processo para o subshell
proximo=${| REPLY=$((ultimo + 1)); } # o valor vem do REPLY, não da saída
Como tudo roda no shell atual, os efeitos colaterais ficam: uma variável alterada sem local dentro das chaves continua alterada depois, e um exit ali dentro encerra o script inteiro. São formas exclusivas do Bash 5.3 em diante, e um script que as usa precisa de uma versão recente instalada em todos os lugares em que roda.
Uma otimização mais antiga vale para um caso comum: $(< arquivo) lê o conteúdo de um arquivo sem chamar o cat, e o manual a descreve como equivalente a $(cat arquivo), só que mais rápida.
A expansão aritmética $((...)) faz contas com números inteiros. Dentro dela, as variáveis podem aparecer sem o $, e os operadores são os da linguagem C: +, -, *, /, %, ** para potência, comparações e operadores lógicos. Não há números com vírgula: $((7 / 2)) é 3. Para casas decimais, o caminho é chamar um programa como o bc ou o awk.
A divisão de palavras e a variável IFS
A divisão de palavras é a etapa que mais causa erros. Depois das expansões, o resultado que não está entre aspas é quebrado nos caracteres da variável IFS (internal field separator), que por padrão contém espaço, tabulação e quebra de linha. Veja o que acontece com um nome de arquivo que tem espaço:
arquivo="relatorio de vendas.csv"
rm $arquivo # o rm recebe três argumentos: relatorio, de, vendas.csv
rm "$arquivo" # o rm recebe um argumento: relatorio de vendas.csv
Sem aspas, a primeira linha tenta apagar três arquivos que provavelmente não existem e, se algum existir com aquele nome, apaga o arquivo errado. A regra prática que resolve a maior parte dos problemas é: sempre coloque aspas duplas em volta de expansões, a menos que você queira de propósito a divisão em palavras.
A IFS pode ser mudada para dividir texto por outro separador, de preferência só para um comando:
IFS=, read -r nome cidade valor <<< "Loja Centro,Cuiabá,1290.50"
A expansão de nomes de arquivos
Uma palavra com * (qualquer sequência), ? (um caractere) ou [...] (um dos caracteres) vira a lista dos arquivos que combinam, em ordem alfabética. É o globbing. Três detalhes importam:
- Se nenhum arquivo combina, o Bash, por padrão, deixa o padrão como está. Um
for f in *.csvnum diretório sem CSV executa uma vez comfvalendo literalmente*.csv. A opçãoshopt -s nullglobfaz o padrão sem resultado desaparecer. - Arquivos que começam com ponto não entram no
*, a menos que a opçãodotglobesteja ligada. - Com
shopt -s globstar, o**percorre subdiretórios:**/*.logacha os logs em qualquer nível.
O globbing também acontece no resultado de variáveis sem aspas. Uma variável que contém * vira a lista de arquivos do diretório atual: mais um motivo para as aspas.
Padrões estendidos e globs que falham alto
Com a opção shopt -s extglob, o Bash reconhece cinco operadores a mais, que recebem uma lista de padrões separados por |:
| Padrão | Combina com |
|---|---|
?(lista) | zero ou uma ocorrência dos padrões |
*(lista) | zero ou mais ocorrências |
+(lista) | uma ou mais ocorrências |
@(lista) | exatamente um dos padrões |
!(lista) | qualquer coisa, menos os padrões |
shopt -s extglob
rm -- !(*.conf|*.key) # apaga tudo no diretório, menos as configurações e as chaves
ls -- relatorio-@(jan|fev|mar).csv
[[ $codigo == +([0-9]) ]] && echo "só dígitos"
O !(...) é o mais útil e o mais perigoso: num rm, um padrão escrito errado inverte o que se queria proteger. Vale rodar o mesmo padrão com ls antes. Há também um detalhe de leitura: o extglob muda o próprio analisador, porque os parênteses normalmente são operadores. Por isso a opção precisa estar ligada antes de o Bash ler o trecho que usa os padrões; ligá-la dentro da mesma função que os usa não basta, e o lugar certo é o começo do script.
Duas outras opções mudam o que acontece quando nada combina. Com shopt -s failglob, um padrão sem resultado é um erro de expansão, e o comando nem chega a rodar, o que é mais seguro que o comportamento padrão de passar o *.csv literal adiante. A nullglob, vista acima, faz o padrão desaparecer, o que é ótimo num for, mas perigoso num comando que, sem argumentos, age sobre tudo: um ls sem argumentos lista o diretório inteiro. E, no Bash 5.3, a variável GLOBSORT escolhe a ordem do resultado: GLOBSORT=-mtime põe os arquivos modificados mais recentemente primeiro, o que substitui o ls -t em rotinas que guardam só os N arquivos mais novos.
Aspas: simples, duplas e a barra
As aspas são a forma de dizer ao Bash quais expansões não fazer.
- Aspas simples
'...'desligam tudo: dentro delas, nenhum caractere é especial, nem a barra. Não há como pôr uma aspa simples dentro de aspas simples. - Aspas duplas
"..."mantêm as expansões de$(variáveis, aritmética e substituição de comando) e a crase, e desligam a divisão de palavras e a expansão de nomes de arquivos sobre o resultado. A barra continua especial antes de$,`,",\e da quebra de linha. - A barra invertida
\fora de aspas protege o caractere seguinte. - A forma
$'...'interpreta sequências como\ne\t, útil para escrever uma quebra de linha num valor.
nome="Gothor"
echo 'Olá, $nome' # Olá, $nome
echo "Olá, $nome" # Olá, Gothor
echo "Custa \$10" # Custa $10
echo $'linha 1\nlinha 2'
"$@", "$*" e os argumentos
Os argumentos de um script ou de uma função ficam nos parâmetros posicionais $1, $2 e assim por diante (a partir do décimo, com chaves: ${10}). $# é a quantidade, e $0 é o nome do script. Para repassar todos os argumentos, há duas formas, e só uma é quase sempre a certa:
"$@"expande para cada argumento como uma palavra separada, preservando espaços dentro de cada um. É a forma de repassar argumentos sem estragá-los."$*"junta todos os argumentos numa palavra só, separados pelo primeiro caractere daIFS.
Sem aspas, $@ e $* se comportam igual e sofrem a divisão de palavras, o que quebra argumentos com espaço. O comando shift descarta o primeiro argumento e anda os outros uma posição, o jeito clássico de ler opções num laço.
Variáveis, ambiente e escopo
Uma atribuição no Bash não tem espaços em volta do sinal de igual: nome=valor. Com espaços, nome = valor é a chamada de um comando chamado nome com dois argumentos. Os valores são, em essência, texto; o Bash não tem tipos como as linguagens de programação, a não ser os atributos que se pode dar com o comando declare.
Variáveis do shell e variáveis de ambiente
Uma variável criada num script existe só no próprio shell. Para que os programas que o shell executa também a vejam, ela precisa ser exportada: export DATABASE_URL=.... O conjunto das variáveis exportadas é o ambiente, que o sistema operacional copia para cada processo filho. A cópia é de mão única: um processo filho pode mudar o próprio ambiente, mas nunca o do pai. É por isso que um script executado com ./script.sh não consegue mudar o diretório ou as variáveis do terminal que o chamou, e que, para isso, o script precisa ser lido no próprio shell, com source script.sh (ou o equivalente . script.sh).
Algumas variáveis são do próprio Bash e úteis em scripts:
| Variável | O que guarda |
|---|---|
$? | o status de saída do último comando |
$$ | o número do processo do shell |
$! | o número do último processo colocado em segundo plano |
$BASHPID | o número do processo atual, inclusive num subshell |
$PWD / $OLDPWD | o diretório atual e o anterior |
$RANDOM | um número pseudoaleatório entre 0 e 32767 |
$LINENO | a linha do script em execução |
$BASH_SOURCE / $FUNCNAME | o arquivo e a pilha de funções em execução |
$PIPESTATUS | um array com o status de cada comando do último pipeline |
O comando declare e os atributos
O declare (ou o equivalente typeset) dá atributos às variáveis:
declare -i ntrata a variável como inteiro, e as atribuições passam a ser avaliadas como aritmética;declare -rereadonlycriam constantes;declare -xexporta, como oexport;declare -aedeclare -Acriam arrays indexados e associativos;declare -ncria uma referência a outra variável pelo nome (Bash 4.3 ou mais novo), útil para passar arrays a funções.
Escopo: global por padrão
Toda variável é global no script, inclusive as criadas dentro de funções, a menos que seja declarada com local dentro da função. O escopo de local é dinâmico, não léxico: uma função chamada por outra enxerga as variáveis locais da que a chamou. O exemplo mostra a diferença:
contador=0
incrementa() {
local passo=1
contador=$((contador + passo)) # muda a variável global
}
incrementa; incrementa
echo "$contador" # 2
echo "${passo:-sem passo}" # sem passo: a local não existe fora da função
O hábito de declarar com local toda variável usada só dentro da função evita que duas funções apaguem os valores uma da outra sem ninguém perceber.
Arrays
O Bash tem dois tipos de array. O indexado usa números como índice, a partir de zero:
lojas=("Loja Centro" "Loja Norte" "Loja Sul")
lojas+=("Loja Oeste")
echo "${lojas[0]}" # Loja Centro
echo "${#lojas[@]}" # 4
for loja in "${lojas[@]}"; do
echo "Processando $loja"
done
As aspas em "${lojas[@]}" têm o mesmo papel que em "$@": cada elemento vira uma palavra, com os espaços preservados. Sem elas, "Loja Centro" viraria duas palavras. Os índices não precisam ser contínuos: ${!lojas[@]} lista os índices que existem.
O associativo, do Bash 4.0 em diante, usa texto como chave e precisa ser declarado:
declare -A estoque=([parafuso]=1200 [porca]=830)
estoque[arruela]=415
for item in "${!estoque[@]}"; do
printf '%-10s %6d\n' "$item" "${estoque[$item]}"
done
A ordem das chaves num array associativo não é garantida. Para ler as linhas de um arquivo num array, o comando mapfile (ou readarray) faz o trabalho de uma vez: mapfile -t linhas < arquivo.txt. Arrays são um dos recursos do Bash que não existem no shell POSIX; um script que precisa rodar no dash não pode usá-los.
Redirecionamentos e descritores de arquivo
Todo processo nasce com três canais abertos, numerados: o 0 é a entrada padrão (stdin), de onde o programa lê; o 1 é a saída padrão (stdout), para onde escreve o resultado; o 2 é a saída de erros (stderr), para onde vão as mensagens de erro e de diagnóstico. Esses números são os descritores de arquivo. No terminal, os três apontam para a própria janela; os redirecionamentos mudam para onde cada um aponta, antes de o comando começar.
| Forma | O que faz |
|---|---|
comando > arquivo | a saída padrão vai para o arquivo, apagando o conteúdo anterior |
comando >> arquivo | a saída padrão é acrescentada ao fim do arquivo |
comando < arquivo | a entrada padrão vem do arquivo |
comando 2> erros.log | os erros vão para o arquivo |
comando 2>&1 | os erros vão para onde a saída padrão aponta naquele momento |
comando &> arquivo | as duas saídas vão para o arquivo (forma do Bash) |
comando > /dev/null | a saída é descartada |
O detalhe mais importante é que os redirecionamentos são aplicados da esquerda para a direita, e 2>&1 copia o destino atual do 1, não um vínculo permanente. Por isso a ordem muda tudo:
comando > saida.log 2>&1 # as duas saídas vão para o arquivo
comando 2>&1 > saida.log # os erros vão para o terminal; só a saída vai para o arquivo
Na primeira linha, o 1 passa a apontar para o arquivo e, depois, o 2 copia esse destino. Na segunda, o 2 copia o destino do 1 quando ele ainda era o terminal, e só então o 1 é desviado.

O comando exec com redirecionamentos e sem nome de comando muda os descritores do próprio script, a partir daquele ponto. É assim que um script manda toda a saída dele para um log sem repetir o redirecionamento em cada linha:
exec >> /var/log/rotina.log 2>&1
echo "Início: $(date)" # já vai para o log
Here-documents e here-strings
O here-document passa um bloco de texto como entrada de um comando, até uma palavra que marca o fim:
cat > /etc/motd <<EOF
Servidor de produção da $(hostname)
Atualizado em $(date +%d/%m/%Y)
EOF
Se a palavra do fim estiver entre aspas na abertura (<<'EOF'), nenhuma expansão acontece dentro do bloco: o texto vai exatamente como está, o que é o certo para gerar outro script ou um arquivo de configuração cheio de $. Com <<-, as tabulações do começo de cada linha são removidas, para o bloco poder ser indentado dentro de uma função. O here-string <<< passa uma única string como entrada: grep -c erro <<< "$texto".
Substituição de processo
A forma <(comando) executa o comando e entrega, no lugar dela, um caminho de arquivo (como /dev/fd/63) de onde se lê a saída. Serve para usar a saída de um comando onde um programa espera um arquivo:
diff <(sort lista-ontem.txt) <(sort lista-hoje.txt)
A forma >(comando) faz o inverso: entrega um caminho onde o que for escrito vira a entrada do comando. A substituição de processo é do Bash e de outros shells derivados do Korn shell; o POSIX não a define.
Pipelines, subshells e processos
Num pipeline como cat log | grep erro | wc -l, o Bash cria um processo para cada comando e liga a saída de um à entrada do outro por um pipe, um canal na memória do sistema operacional. Os três comandos rodam ao mesmo tempo: o grep começa a filtrar as primeiras linhas enquanto o cat ainda está lendo o arquivo. Quando o canal enche, quem escreve espera; quando esvazia, quem lê espera.
Há uma consequência que surpreende quase todo mundo: cada comando de um pipeline roda num subshell, um processo separado, cópia do shell. Variáveis alteradas dentro dele não voltam para o shell principal:
total=0
cat vendas.txt | while read -r valor; do
total=$((total + valor))
done
echo "$total" # 0: o while rodou num subshell, e o total dele morreu junto
Há três saídas para esse problema. A mais simples é não usar o pipe e redirecionar o arquivo para o laço, que então roda no próprio shell:
total=0
while read -r valor; do
total=$((total + valor))
done < vendas.txt
echo "$total"
Quando a entrada precisa vir de um comando, a substituição de processo resolve: done < <(grep -v '^#' vendas.txt). A terceira é a opção shopt -s lastpipe, do Bash 4.2 em diante, que faz o último comando do pipeline rodar no shell atual, mas só quando o controle de jobs está desligado, como acontece nos scripts.

O mesmo vale para os parênteses e para a substituição de comando. ( cd /tmp; ls ) muda de diretório só dentro do subshell, e o shell principal continua onde estava, o que às vezes é exatamente o que se quer. Já { cd /tmp; ls; }, com chaves, roda no próprio shell e muda o diretório de verdade. A variável $$ continua mostrando o número do shell principal dentro de um subshell; para saber o número do processo atual, existe $BASHPID.
O status de um pipeline
O status de saída de um pipeline é, por padrão, o do último comando. grep erro log.txt | sort termina com sucesso mesmo se o arquivo não existir, porque o sort de uma entrada vazia dá certo. Com set -o pipefail, o status passa a ser o do último comando que falhou, ou zero se todos deram certo. Para saber o resultado de cada pedaço, o array PIPESTATUS guarda o status de todos os comandos do último pipeline: echo "${PIPESTATUS[@]}".
Controle de jobs
No modo interativo, o Bash gerencia os jobs, os pipelines em execução:
comando &coloca o pipeline em segundo plano e devolve o prompt; o número do processo fica em$!;jobslista os jobs do shell;Ctrl+Zsuspende o job que está em primeiro plano;bgcontinua um job suspenso em segundo plano, efgo traz para o primeiro plano;waitespera os jobs em segundo plano terminarem, ewait -nespera o primeiro que terminar (Bash 4.3 ou mais novo);disowndesliga um job do shell, para ele não receber o sinal de desligamento quando o terminal fechar, como faz onohupao iniciar um comando.
Em scripts, o par & e wait é a forma simples de rodar tarefas em paralelo:
for loja in centro norte sul; do
./exportar.sh "$loja" &
done
wait # só continua quando as três exportações terminarem
Status de saída e controle de fluxo
Todo comando termina com um status de saída, um número de 0 a 255. Zero significa sucesso, e qualquer outro número é algum tipo de falha. É o contrário do que muitas linguagens fazem com verdadeiro e falso, e é a base de todo o controle de fluxo do shell. Alguns valores têm significado combinado:
| Status | Significado comum |
|---|---|
| 0 | sucesso |
| 1 | erro genérico |
| 2 | uso incorreto de um comando interno |
| 126 | o comando existe, mas não pode ser executado (permissão) |
| 127 | comando não encontrado |
| 128 + n | o processo foi encerrado pelo sinal de número n (130 é o Ctrl+C) |
O if do Bash não testa uma expressão: ele executa um comando e olha o status. if grep -q erro log.txt; then ... entra no bloco se o grep achou alguma coisa. Os colchetes que aparecem em quase todo if são, eles também, um comando: [ é outro nome para o comando test, que recebe os argumentos e devolve 0 ou 1. Por isso os espaços em volta dos colchetes são obrigatórios: [ -f "$arquivo" ] são quatro palavras, e [-f "$arquivo"] é a chamada de um comando inexistente chamado [-f.
O Bash tem ainda o [[ ... ]], uma palavra reservada com regras melhores:
- dentro dele, não há divisão de palavras nem expansão de nomes de arquivos, então
[[ $nome == Loja* ]]funciona mesmo sem aspas; - o
==compara com um padrão de glob, e o=~com uma expressão regular, cujos grupos ficam no arrayBASH_REMATCH; &&e||funcionam dentro dele, sem o-ae o-odotest.
Para comparar números, o (( ... )) avalia uma expressão aritmética e devolve sucesso se o resultado for diferente de zero: if (( estoque < 100 )); then .... Dentro do [ ] e do [[ ]], os números se comparam com -lt, -gt, -eq e companhia; o < compara texto, em ordem alfabética, o que faz "10" ser menor que "9".
Os laços seguem o mesmo princípio:
for arquivo in /backups/*.dump; do ...; done # sobre uma lista de palavras
for ((i = 1; i <= 10; i++)); do ...; done # no estilo da linguagem C
while read -r linha; do ...; done < entrada.txt # enquanto o comando der certo
until ping -c1 -W1 "$host" > /dev/null; do sleep 5; done # até dar certo
O case compara um valor com padrões de glob e é a forma legível de tratar várias opções:
case "$1" in
start|iniciar) iniciar ;;
stop|parar) parar ;;
*.conf) validar "$1" ;;
*) echo "uso: $0 {iniciar|parar|arquivo.conf}" >&2; exit 2 ;;
esac
Ler arquivos e nomes de arquivo sem surpresas
O while read é a forma clássica de processar um arquivo linha a linha, e quase todos os detalhes dele importam. A versão robusta tem esta cara:
while IFS= read -r linha || [[ -n $linha ]]; do
printf '%s\n' "$linha"
done < clientes.txt
O IFS= vazio só para o read preserva os espaços do começo e do fim da linha; o -r impede que as barras invertidas sejam tratadas como escape. O || [[ -n $linha ]] cuida de um caso que pega muita gente: arquivos gerados por planilhas e outros sistemas às vezes terminam sem a quebra de linha final, e nesse caso o read encontra o fim do arquivo, guarda a última linha na variável e devolve falha. Sem o teste extra, o laço termina sem processar a última linha, e o erro só aparece quando alguém percebe que o total não fecha.
O read tem outras opções úteis em scripts: -p mostra uma pergunta antes de ler, só quando a entrada é um terminal; -s não ecoa o que se digita, para senhas; -t desiste depois de alguns segundos, com status maior que 128, e funciona com terminais e pipes, mas não com arquivos comuns; e -a guarda as palavras da linha num array.
Os nomes de arquivo são um caso à parte, porque podem conter espaços, tabulações e até quebras de linha. O único caractere que não pode aparecer num caminho é o NUL, o byte zero, e por isso ele é o separador seguro. O find produz a lista separada por NUL com -print0, e o Bash a lê com read -d '' ou, desde a versão 4.4, com mapfile -d '':
# um arquivo por vez
while IFS= read -r -d '' arquivo; do
gzip -- "$arquivo"
done < <(find /srv/exportacoes -name '*.csv' -mtime +7 -print0)
# ou todos de uma vez, num array
mapfile -d '' -t antigos < <(find /srv/exportacoes -name '*.csv' -mtime +7 -print0)
echo "${#antigos[@]} arquivos com mais de 7 dias"
Com -d '', o read e o mapfile terminam cada item no NUL em vez da quebra de linha. A substituição de processo < <(...) mantém o laço no shell atual, pelo motivo visto na seção sobre pipelines, e o -- protege nomes que começam com hífen.
set -euo pipefail: o que protege e o que não protege
Por padrão, o Bash continua executando um script mesmo quando um comando falha. Uma combinação de opções muito recomendada no começo dos scripts muda isso:
set -e(ouset -o errexit) encerra o script quando um comando falha;set -u(ouset -o nounset) trata como erro o uso de uma variável que não existe, em vez de expandi-la para vazio;set -o pipefailfaz um pipeline falhar quando qualquer comando dele falha.
O set -u evita um dos erros mais perigosos do shell. Com uma variável vazia por engano, rm -rf "$pasta/"* vira rm -rf /*. Com set -u, o script para antes, com a mensagem de que a variável não foi definida.
O set -e, porém, tem regras que pegam até quem usa o Bash há anos. Ele não encerra o script quando o comando que falhou:
- faz parte da condição de um
if,whileouuntil; - está à esquerda de um
&&ou de um||(só o último comando da lista conta); - é precedido de
!; - está num pipeline que não é o último comando, quando o
pipefailestá desligado.
E há casos em que o status do erro simplesmente se perde. O mais conhecido é a declaração com local (ou export, ou declare) junto da substituição de comando:
carregar() {
local dados=$(curl -fsS "$url") # o status é o do local, que dá certo; o erro do curl some
local dados2
dados2=$(curl -fsS "$url") # assim o erro do curl chega ao set -e
}
Também por padrão, uma substituição de comando não herda o set -e: um comando que falha dentro de $(...) não interrompe o subshell, a menos que a opção shopt -s inherit_errexit (Bash 4.4 ou mais novo) esteja ligada. E uma função chamada como condição de um if roda inteira com o set -e desligado, mesmo que tenha comandos que falham no meio.
A conclusão prática é usar o set -euo pipefail como rede de proteção, não como substituto do tratamento de erros. Os comandos cujo erro importa merecem um teste explícito (comando || { echo "falhou" >&2; exit 1; }), e os comandos que podem falhar sem problema merecem um || true visível, que deixa claro para quem lê que a falha foi prevista.
Funções
Uma função agrupa comandos sob um nome e é chamada como qualquer comando:
log() {
printf '%s %s\n' "$(date '+%F %T')" "$*" >&2
}
baixar() {
local url=$1 destino=$2
log "baixando $url"
curl -fsSL --retry 3 -o "$destino" "$url"
}
baixar "https://exemplo.com/tabela.csv" /tmp/tabela.csv || exit 1
Algumas regras definem como as funções se comportam:
- Argumentos chegam em
$1,$2,$@e$#, como num script;$0continua sendo o nome do script. - O status de saída de uma função é o do último comando executado, ou o valor passado ao
return, de 0 a 255. Oreturnencerra só a função; oexitencerra o script inteiro. - Para devolver um valor, a função escreve na saída padrão, e quem chama captura com a substituição de comando:
total=$(somar 2 3). Isso roda a função num subshell, então mudanças em variáveis globais feitas lá dentro se perdem. - As mensagens de diagnóstico vão para a saída de erros (
>&2), para não se misturarem com o valor que a função devolve pela saída padrão. - As duas formas de definir,
nome() { ... }efunction nome { ... }, são equivalentes no Bash, mas só a primeira é POSIX.
O Bash permite exportar funções para os processos filhos com export -f nome. O mecanismo coloca o corpo da função numa variável de ambiente especial, e foi justamente na leitura dessas variáveis que a falha Shellshock morava, como veremos adiante.
Sinais e o comando trap
Os sinais são avisos que o sistema operacional entrega a um processo: SIGINT quando alguém aperta Ctrl+C, SIGTERM quando o sistema ou um gerenciador de serviços pede para o processo terminar, SIGHUP quando o terminal fecha. O comando trap diz ao Bash o que fazer quando um sinal chega, e o uso mais importante é garantir a limpeza no fim do script, aconteça o que acontecer:
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
# ... trabalha com arquivos em "$tmp" ...
O EXIT não é um sinal de verdade: é um momento, o fim do shell, seja pelo fim normal do script, por um exit ou por um erro com set -e. Junto com o mktemp, que cria um arquivo ou diretório temporário com nome único, evita arquivos esquecidos e conflitos entre duas execuções simultâneas do mesmo script.
O Bash tem outras armadilhas especiais. A ERR é executada quando um comando falha nas mesmas condições em que o set -e encerraria o script, útil para registrar a linha do erro com $LINENO. A DEBUG é executada antes de cada comando, e a RETURN, quando uma função termina. Dois sinais não podem ser capturados de jeito nenhum: o SIGKILL (o kill -9) e o SIGSTOP. Um processo encerrado com kill -9 não executa o seu trap, e é por isso que esse sinal deve ser a última opção, não a primeira.
Execuções simultâneas, prazos e travas
Um script agendado para rodar a cada cinco minutos um dia demora seis. A próxima execução começa com a anterior ainda em andamento, e as duas passam a disputar os mesmos arquivos. O problema não aparece nos testes, só na operação, e por isso vale prevenir desde o começo.
A ferramenta mais usada no Linux é o flock, do pacote util-linux. Ele pega uma trava sobre um arquivo, e o sistema operacional a solta sozinho quando o processo termina, mesmo que termine por erro ou por kill -9. O uso típico abre o arquivo da trava num descritor do próprio script:
exec 9> /var/lock/exportar-vendas.lock
if ! flock -n 9; then
echo "outra execução em andamento; saindo" >&2
exit 0
fi
# daqui em diante, só uma cópia do script roda por vez
O -n faz o flock desistir na hora, com status 1, em vez de esperar; o -w 30 esperaria até 30 segundos. Sem o flock, há o recurso do próprio shell: com set -C (a opção noclobber), o redirecionamento > falha se o arquivo já existe, e criar um arquivo de trava vira uma operação de teste e criação num passo só. O ponto fraco dessa alternativa é que um script morto com kill -9 deixa o arquivo para trás, e a próxima execução precisa decidir se a trava é velha. Com noclobber ligado, o >| força a gravação quando ela é de fato desejada.
O outro lado do problema é o comando que nunca termina: uma conexão que fica pendurada, um disco de rede que não responde. O timeout, do GNU coreutils, dá um prazo a qualquer comando:
status=0
timeout 300 ./sincronizar.sh || status=$? # o || guarda o status sem acionar o set -e
if (( status == 124 )); then
echo "sincronização passou de 5 minutos" >&2
fi
(( status == 0 )) || exit "$status"
Quando o prazo vence, o timeout envia o sinal SIGTERM ao comando e termina com o status 124, o que permite distinguir "demorou demais" de "falhou". Com --kill-after, ele manda o SIGKILL se o comando ignorar o primeiro aviso. Entre a trava e o prazo, uma rotina agendada fica previsível: nunca roda em dobro e nunca fica presa para sempre.
Arquivos de inicialização e modos de execução
O Bash lê arquivos de configuração ao iniciar, e quais arquivos ele lê depende de como foi iniciado. É a origem do clássico "a variável funciona no terminal, mas não no cron".
- Um shell de login interativo (o que abre numa conexão SSH, por exemplo) lê o
/etc/profilee depois o primeiro que existir entre~/.bash_profile,~/.bash_logine~/.profile. Ao sair, lê o~/.bash_logout. - Um shell interativo que não é de login (uma nova aba do terminal na maioria dos sistemas) lê o
~/.bashrc. - Um shell não interativo (um script) não lê nenhum desses; se a variável
BASH_ENVestiver definida, lê o arquivo indicado nela.
Por isso, muitos sistemas trazem um ~/.bash_profile que simplesmente chama o ~/.bashrc, para as duas situações terem a mesma configuração. E por isso um script executado pelo cron, que roda com um ambiente mínimo e um PATH curto, não enxerga as variáveis e os aliases definidos no ~/.bashrc. O script que precisa de alguma coisa do ambiente deve defini-la ele mesmo, ou ler um arquivo de configuração explicitamente.
Quando é chamado com o nome sh, o Bash tenta imitar o shell de Bourne histórico ao iniciar e entra no modo POSIX depois de ler os arquivos de inicialização. O modo POSIX também pode ser ligado com a opção --posix ou com set -o posix. Nele, dezenas de comportamentos mudam para seguir o padrão; o manual do Bash traz a lista completa. Um exemplo: nos scripts, a expansão de aliases fica desligada por padrão, e só é ligada com shopt -s expand_aliases; no modo POSIX, ela vale sempre.
Bash, sh e o POSIX: portabilidade
O POSIX define uma linguagem de shell mínima que todo sistema compatível com Unix deve oferecer em /bin/sh. Um script que segue só o POSIX roda no Bash, no dash, no ksh, no shell do BusyBox e em sistemas Unix comerciais. O Bash segue o padrão e acrescenta muitos recursos, que circulam com o apelido de bashisms. Os mais comuns:
| Recurso do Bash | Alternativa POSIX |
|---|---|
[[ ... ]] | [ ... ] ou test, com aspas nas variáveis |
arrays e declare -A | os parâmetros posicionais (set -- a b c) ou um arquivo |
$'...' | printf com a sequência |
function nome { ... } | nome() { ... } |
local | não existe no padrão, embora o dash e outros o aceitem |
<(comando) | um arquivo temporário ou um pipe nomeado (mkfifo) |
{1..10} | seq 1 10 ou um laço com aritmética |
source arquivo | . arquivo |
== dentro de [ ] | = |
echo -e | printf |
&> | > arquivo 2>&1 |
pipefail | entrou no POSIX só na edição de 2024 |
O ponto prático é declarar com honestidade qual shell o script precisa. Um script que começa com #!/bin/sh e usa [[ funciona num sistema onde o /bin/sh é o Bash e quebra no Debian ou no Ubuntu, onde ele é o dash. Se o script usa recursos do Bash, o shebang deve chamar o Bash. Se ele precisa rodar em qualquer lugar, inclusive em imagens de contêiner mínimas que nem têm Bash, como as baseadas no Alpine, deve ficar no POSIX e ser testado com o dash.
Ainda sobre portabilidade, o echo é um caso à parte: o comportamento com opções como -n e -e e com barras invertidas varia entre shells e sistemas. Para qualquer saída que não seja um texto simples, o printf é o caminho portátil e previsível: printf '%s\n' "$texto".
O printf do Bash
O printf funciona como o da linguagem C: um formato com marcadores e os argumentos que os preenchem. Se houver mais argumentos que marcadores, o formato é reaplicado até acabarem, o que transforma uma linha numa tabela: printf '%-12s %8.2f\n' Centro 1290.5 Norte 830 imprime duas linhas alinhadas. Além do padrão, o comando interno do Bash tem três recursos que valem conhecer:
printf -v hoje '%(%Y-%m-%d)T' -1 # a data de hoje numa variável, sem chamar o date
printf -v linha '%s;%s' "$loja" "$total" # monta o texto direto na variável, sem subshell
printf '%q\n' "vendas de 'marco'.csv" # vendas\ de\ \'marco\'.csv
- **
-v variavel** grava o resultado na variável em vez de imprimir. Substitui ovar=$(printf ...)e evita o subshell. - **
%(formato)T**, do Bash 4.2, formata uma data com os mesmos códigos dodate. O argumento é o número de segundos desde 1970, e o valor-1significa "agora". - **
%q** escreve o argumento de um jeito que pode ser lido de volta pelo shell, com as aspas e os escapes necessários. É a forma segura de registrar num log o comando exato que será executado, ou de montar uma linha para outro shell, como a de umssh, sem que espaços e aspas do valor mudem o sentido do comando. Com letras acentuadas, o resultado depende da configuração regional: fora de uma localidade UTF-8, o Bash usa a forma$'...', com os bytes em octal.
Segurança: Shellshock, eval e injeção
Em 24 de setembro de 2014 foi divulgada a falha registrada como CVE-2014-6271, apelidada de Shellshock. O Bash permitia exportar funções pelo ambiente, e, ao ler uma variável de ambiente cujo valor começava como uma definição de função, o Bash executava também os comandos que viessem depois da função. Bastava que um programa colocasse um texto vindo da internet numa variável de ambiente e chamasse o Bash: servidores web com scripts CGI, por exemplo, fazem exatamente isso com os cabeçalhos HTTP. Vieram várias correções nas semanas seguintes, e as funções exportadas passaram a usar nomes de variável especiais, que não podem ser criados por acidente.
A lição vai além da falha: um shell interpreta texto como código, e todo dado que vem de fora, de um formulário, de um arquivo, do nome de um arquivo enviado por alguém, precisa ser tratado como dado. Os cuidados principais:
- Aspas em toda expansão. Um nome de arquivo pode conter espaços, quebras de linha, asteriscos e até começar com hífen, e ser lido como uma opção do comando. Use
--para encerrar as opções:rm -- "$arquivo". - **Evite o
eval.** Ele executa uma string como código, e qualquer pedaço dela vindo de fora vira um comando. Quase sempre há uma alternativa: arrays para montar listas de argumentos,declare -npara acessar uma variável pelo nome. - Monte comandos com arrays, não com strings.
args=(-v --porta "$porta"); programa "${args[@]}"preserva cada argumento; montar uma string e executá-la sem aspas reabre a divisão de palavras. - **Nunca confie no
PATHde quem chama** num script que roda com privilégios, e prefira caminhos absolutos ou umPATHdefinido no próprio script. - Não coloque segredos na linha de comando. Os argumentos de um processo aparecem para outros usuários da mesma máquina em comandos como o
ps. Senhas e tokens devem vir de variáveis de ambiente ou de arquivos com permissão restrita.
Desempenho: quando o Bash fica lento
O Bash é rápido para o que foi feito: coordenar programas. Fica lento quando passa a fazer, ele mesmo, o trabalho pesado, e a causa quase sempre é a mesma: a criação de processos. Cada programa externo, cada $(...) e cada pedaço de pipeline custa um fork, e o custo, pequeno numa linha, vira minutos dentro de um laço que roda cem mil vezes.
# lento: vários processos por linha do arquivo
while IFS= read -r linha; do
codigo=$(echo "$linha" | cut -d';' -f1)
total=$((total + $(echo "$linha" | cut -d';' -f3)))
done < vendas.csv
# rápido: o próprio read separa os campos, sem processo nenhum
while IFS=';' read -r codigo _ valor _; do
total=$((total + valor))
done < vendas.csv
# mais rápido ainda: um único processo faz o trabalho inteiro
awk -F';' '{ total += $3 } END { print total }' vendas.csv
As três versões somam a terceira coluna de um arquivo, e a diferença de tempo entre a primeira e a última cresce com o tamanho do arquivo, porque a primeira cria processos para cada linha, e a última cria um só. As regras práticas saem daí:
- Prefira as expansões aos programas.
${arquivo##*/}no lugar dobasename,${var//de/para}no lugar dosedpara uma troca simples,(( ))no lugar doexpr,printf -vno lugar de$(printf ...),$(< arquivo)no lugar de$(cat arquivo). - **Deixe o
readdividir os campos.** Com aIFScerta, ele separa as colunas semcutnemawkpor linha. - Um processo para o arquivo inteiro. Quando a transformação é por linha, um
awk, umsedou umsortsobre o arquivo todo trabalha em código compilado, e o Bash só monta o pipeline. - Meça. A palavra reservada
timemede um comando ou um pipeline inteiro, eEPOCHREALTIMEpermite medir etapas de dentro do script.
E há o momento de trocar de ferramenta. Quando o script passa a precisar de estruturas de dados aninhadas, números com casas decimais, JSON, tratamento de erros detalhado ou algumas centenas de linhas de lógica, uma linguagem como o Python costuma resultar num código menor, mais legível e mais fácil de testar. O Bash continua ótimo como cola em volta dela: preparar o ambiente, chamar o programa e tratar o resultado.
Ferramentas para escrever e depurar
Algumas ferramentas mudam a qualidade de um script:
- **
bash -n script.sh** verifica a sintaxe sem executar nada. - **
bash -x script.sh**, ouset -xdentro do script, mostra cada comando depois das expansões, antes de executá-lo, com um+na frente. É a forma mais rápida de ver por que uma variável virou o que virou. A variávelPS4controla o prefixo;PS4='+ ${BASH_SOURCE}:${LINENO}: 'mostra o arquivo e a linha de cada comando. - ShellCheck é um analisador estático que aponta variáveis sem aspas,
set -eque não vai funcionar, laços que perdem variáveis e dezenas de outros problemas, cada um com um código e uma explicação. Roda no terminal, em editores e em pipelines de integração contínua, e aceita--shell=shpara conferir a portabilidade.
Erros com contexto: onde e por quê
Um script que falha às três da manhã deixa, no melhor caso, uma linha de erro solta no log. Algumas variáveis e opções do Bash transformam essa linha num diagnóstico completo.
O rastreamento do set -x vai para a saída de erros e se mistura com as mensagens do próprio script. Desde a versão 4.1, a variável BASH_XTRACEFD manda o rastreamento para outro descritor, e com ele para um arquivo à parte:
exec 5>> /var/log/rotina.trace
BASH_XTRACEFD=5
PS4='+ ${BASH_SOURCE}:${LINENO}: '
set -x
Para os erros, a armadilha ERR registra onde a falha aconteceu. Há um detalhe: por padrão, ela não é herdada pelas funções, pelas substituições de comando e pelos subshells, e um erro dentro de uma função passa sem acionar a armadilha. A opção set -E (ou set -o errtrace) faz a herança. Com ela, as variáveis FUNCNAME, BASH_SOURCE e BASH_LINENO, que guardam a pilha de chamadas, permitem imprimir o caminho inteiro até o erro:
set -Eeuo pipefail
pilha() {
local status=$? i
echo "erro $status em ${BASH_SOURCE[1]}:${BASH_LINENO[0]}: $BASH_COMMAND" >&2
for ((i = 1; i < ${#FUNCNAME[@]}; i++)); do
echo " em ${FUNCNAME[i]} (${BASH_SOURCE[i]}:${BASH_LINENO[i-1]})" >&2
done
}
trap pilha ERR
A variável BASH_COMMAND guarda o comando que estava sendo executado, e o status do erro precisa ser lido na primeira linha da função, antes que outro comando o substitua. O comando interno caller faz um trabalho parecido: caller 0 devolve a linha, o nome da função e o arquivo de quem chamou a função atual, e números maiores sobem pela pilha. Com isso, o log de uma rotina agendada diz não só que ela falhou, mas em qual comando, em qual linha e por qual caminho de funções.
Um script comentado
O exemplo reúne as boas práticas deste artigo num formato que serve de ponto de partida:
#!/usr/bin/env bash
# Exporta as vendas de um dia por loja e guarda os últimos 14 arquivos.
set -euo pipefail
DESTINO="${DESTINO:-/srv/exportacoes}"
DIA="${1:-$(date -d yesterday +%F)}" # o dia vem do primeiro argumento ou é ontem
LOJAS=(centro norte sul)
log() { printf '%s %s\n' "$(date '+%F %T')" "$*" >&2; }
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
mkdir -p "$DESTINO"
for loja in "${LOJAS[@]}"; do
arquivo="$tmp/vendas-$loja-$DIA.csv"
if ! ./consultar-vendas.sh "$loja" "$DIA" > "$arquivo"; then
log "falhou a exportação da loja $loja; as outras seguem"
continue
fi
mv "$arquivo" "$DESTINO/"
log "loja $loja: $(wc -l < "$DESTINO/${arquivo##*/}") linhas"
done
# guarda os 14 arquivos mais recentes de cada loja
for loja in "${LOJAS[@]}"; do
ls -1t "$DESTINO"/vendas-"$loja"-*.csv 2>/dev/null | tail -n +15 | xargs -r rm -f --
done
Cada linha tem um motivo: o set -euo pipefail para o script em erros não previstos; o ${1:-...} dá um padrão; o mktemp com o trap garante que nada fique para trás; o if ! trata a falha de uma loja sem derrubar as outras; as aspas protegem cada expansão; o >&2 separa os avisos da saída; e o xargs -r não chama o rm quando a lista está vazia. A última linha assume nomes de arquivo sem quebras de linha, uma limitação aceitável aqui porque os nomes são gerados pelo próprio script.
Erros comuns
- Variáveis sem aspas. É a causa número um de bugs: divisão em palavras e expansão de nomes de arquivos em valores que deveriam ser um argumento só. Na dúvida, aspas duplas.
- Espaços na atribuição.
nome = valorchama um comando chamadonome. A atribuição é colada:nome=valor. - Esquecer os espaços dos colchetes.
[ "$a" = "$b" ]precisa de espaço depois do[e antes do], porque eles são argumentos de um comando. - Comparar números como texto. Em
[[ 10 < 9 ]], o resultado é verdadeiro, porque a comparação é alfabética. Para números,(( 10 < 9 ))ou-lt. - O laço que perde a variável.
comando | while read ...roda o laço num subshell. Use o redirecionamentodone < arquivooudone < <(comando). - **
readsem-r.** Sem a opção, as barras invertidas da entrada são interpretadas como escape e somem. E, para preservar espaços no começo e no fim da linha,IFS= read -r linha. - **Confiar demais no
set -e.** Ele não pega erros em condições, em listas com&&e||, nem numlocal var=$(...). Trate os erros importantes de forma explícita. - **Ler a saída do
lsem laços.**for f in $(ls)quebra nomes com espaço. O glob direto,for f in *, já devolve a lista certa, um arquivo por palavra. - Shebang errado.
#!/bin/shnum script com recursos do Bash funciona até o dia em que roda num sistema onde oshé o dash. - Quebras de linha do Windows. Um script salvo com o final de linha CRLF falha com mensagens estranhas, como
$'\r': command not found, porque o\rvira parte de cada comando. O editor e o Git devem gravar os scripts com o final de linha LF. - Diretórios relativos. Um script que faz
cdsem testar o resultado pode continuar no diretório errado e apagar o que não devia.cd "$pasta" || exit 1, ou oset -e. - **
cronsem ambiente.** O cron roda com umPATHmínimo e sem o~/.bashrc. Caminhos completos e variáveis definidas no próprio script evitam o "funciona na mão e não funciona no agendamento".
Como usamos o Bash na Gothor
Na Gothor, o Bash cuida de tarefas que precisam rodar sem ninguém olhando. As regras deste artigo são as nossas: set -euo pipefail no início, variáveis sempre entre aspas e nenhuma senha ou token escrito no código.
Resumo
- O Bash é o shell do projeto GNU, herdeiro do shell de Bourne, e é ao mesmo tempo a interface de comandos e uma linguagem de programação.
- Cada linha passa por etapas fixas: divisão em tokens, análise, expansões, redirecionamentos e execução. A ordem das expansões explica a maior parte dos comportamentos estranhos.
- A divisão de palavras e a expansão de nomes de arquivos agem sobre expansões sem aspas. Aspas duplas em volta de variáveis resolvem a maioria dos problemas, e
"$@"repassa argumentos sem estragá-los. - Os redirecionamentos mudam os descritores 0, 1 e 2 da esquerda para a direita:
> log 2>&1junta as duas saídas;2>&1 > log, não. - Cada comando de um pipeline roda num subshell, e variáveis alteradas lá dentro não voltam. O status do pipeline é o do último comando, a menos que o
pipefailesteja ligado. - Zero é sucesso. O
ifexecuta um comando e olha o status;[é um comando, e[[é a versão do Bash, mais segura. set -euo pipefailé uma boa rede de proteção, mas não pega erros em condições, em listas e em declarações comlocal.- Funções devolvem valores pela saída padrão e o status pelo
return; variáveis são globais a menos que declaradas comlocal. Otrap ... EXITgarante a limpeza. - Recursos do Bash como arrays e
[[não existem no shell POSIX: o shebang deve dizer qual shell o script precisa. - Nomes de arquivo se leem separados por NUL, com
find -print0eread -d ''; um laçowhile readrobusto usaIFS=,-re cuida da última linha sem quebra. - O custo do Bash está na criação de processos: expansões,
readcomIFSe um únicoawksobre o arquivo inteiro valem mais que programas chamados dentro de laços. flockevita execuções em dobro, etimeoutdá prazo a comandos que podem ficar presos.bash -xmostra o que o shell realmente executa,set -Ecom umtrap ... ERRregistra onde e por que o script falhou, e o ShellCheck aponta os erros clássicos antes de eles acontecerem.
Para ir além
- O manual de referência do Bash, mantido pelo projeto GNU, com a ordem das expansões, o modo POSIX e a lista completa de opções.
- A página do Bash de Chet Ramey, o mantenedor, com o histórico de versões, as perguntas frequentes e o arquivo de novidades de cada versão, da 2.0 à 5.3.
- As páginas de manual do flock, do util-linux, e do timeout, do GNU coreutils.
- A linguagem de comandos do shell no padrão POSIX, na edição de 2024, publicada pelo The Open Group.
- O Bash Guide e as Bash FAQ de Greg Wooledge, com a lista de armadilhas comuns, referência de boas práticas há muitos anos.
- O ShellCheck, com a explicação de cada aviso no wiki do projeto.
- A página do Debian sobre o dash como /bin/sh, com o porquê da troca e como escrever scripts portáteis.
- O registro da falha CVE-2014-6271, o Shellshock, no banco de vulnerabilidades do NIST.
Este post foi escrito com a ajuda da Juniper, a assistente de IA da Gothor, e revisado pela nossa equipe antes de ir ao ar.


