Skip to main content

Introducció

Fins ara has escrit ordres una a una a la terminal. Però tot el que fas a mà es pot guardar en un fitxer i executar quan vulguis: això és un script. I darrere de cada script hi ha el mateix mecanisme que fa servir la teva shell: llançar processos, esperar que acabin i mirar com han acabat.

Com sap una shell si una ordre ha anat bé o ha fallat? I com ho podem fer servir en el nostre propi codi?

Aquesta sessió construeix la resposta peça a peça:

shell → script → permisos → codi de retorn → test → arguments → errors → instal·lació → read → perfil.sh
Programari Necessari
  • bash (sistema de referència: Debian bookworm, amb bash 5.2) i un editor de text (nano, vim, code…).
  • Eines de diagnòstic: ps, env, type i man (si no tens les pàgines del manual: sudo apt install man-db).

Part A · Quina shell tinc i el meu primer script

A.1 — Quina és la meva shell?

Exercici

Prediu què imprimiran aquestes ordres i comprova-ho:

echo $SHELL
cat /etc/shells
echo $0
ps -p $$

Ara entra a una altra shell escrivint-ne el nom (sh, dash, zsh… la que tinguis) i torna a executar echo $SHELL i echo $0. Escriu exit per tornar.

  1. Quina diferència hi ha entre $SHELL i $0?
  2. Què és $$?
Veure el raonament
Solució
  1. $SHELL és una variable d’entorn amb la shell de login configurada al teu usuari (/etc/passwd). No és la shell que s’està executant ara mateix: si entres a dash escrivint-ne el nom, $SHELL continua mostrant /bin/bash. $0 i ps -p $$ mostren la shell real (a la terminal, $0 pot sortir com bash o -bash; el guió inicial indica que és una shell de login).
  2. $$ és el PID de la shell actual: una shell és un procés més, i cada shell nova que obres és un procés fill de l’anterior (echo $$ $PPID ho mostra).

Per canviar la shell de login (afecta sessions futures, no l’actual) existeix chsh -s /bin/zsh.

A.2 — El primer script

Crea hola.sh:

#!/bin/bash
echo "Hola, $USER! Estàs utilitzant la shell: $SHELL"

Executa’l amb:

bash hola.sh
Exercici
  1. Executa env i localitza USER i SHELL. D’on surten aquestes variables?
  2. Canvia les cometes dobles per cometes simples: echo 'Hola, $USER!'. Què passa? Per què?
  3. La primera línia (#!/bin/bash) sembla un comentari. Què fa realment?
Veure el raonament
Solució
  1. Són variables d’entorn: les hereta el procés de la seva shell pare, que les va rebre del procés de login. (En alguns contenidors USER pot no estar definida.)
  2. Amb cometes simples la shell no expandeix res i imprimeix el text literal Hola, $USER!. Amb dobles, expandeix les variables. Aquesta distinció tornarà a ser clau a la Part B.
  3. Es diu shebang. Quan el kernel executa un fitxer de text, llegeix aquesta línia per saber quin intèrpret ha de llançar. Encara no la fem servir (hem escrit bash hola.sh), però la necessitarem a A.3.

Un detall: el missatge diu “estàs utilitzant la shell: $SHELL”, però ja hem vist a A.1 que $SHELL és la shell de login, no la que executa l’script. En aquest cas l’script s’executa amb bash perquè l’hem cridat així (o pel shebang).

A.3 — Fer-lo executable

Exercici

Prediu què passarà i comprova-ho:

./hola.sh
ls -l hola.sh
chmod +x hola.sh
ls -l hola.sh
./hola.sh
hola.sh
  1. Per què falla el primer ./hola.sh?
  2. Per què bash hola.sh funcionava sense permís d’execució?
  3. Per què l’últim intent (hola.sh sol) falla, si ara el fitxer és executable? (Pista: echo $PATH.)
Veure el raonament
Solució
  1. Sense el bit x el kernel es nega a executar el fitxer: Permission denied. Després de chmod +x els permisos passen a ser semblants a -rwxr-xr-x (depèn de la umask).
  2. bash hola.sh no executa el fitxer: executa bash i li passa l’script com a dada d’entrada. Només cal permís de lectura.
  3. Quan escrius una ordre sense ruta, la shell la busca als directoris de $PATH, i el directori actual normalment no hi és (per seguretat). Per això cal ./, que indica la ruta explícitament. Ho recuperarem a C.3.
Exercici

Ampliació (concepte de SO). Afegeix echo "PID=$$ PARE=$PPID" a hola.sh i compara aquestes dues maneres d’executar-lo:

echo "shell: $$"
./hola.sh
source hola.sh

Quin PID mostra cadascuna? Què en dedueixes?

Veure el raonament
Solució

./hola.sh mostra un PID diferent: la shell fa fork i exec d’un procés fill, i el seu pare (PARE) és la teva shell. source hola.sh (o . hola.sh) mostra el mateix PID que la shell: les ordres s’executen dins del mateix procés.

Per això un script executat normalment no pot canviar el directori ni les variables de la shell que el crida: viuen en un procés fill que desapareix en acabar. (Prova-ho: afegeix cd /tmp a l’script i compara pwd després de ./hola.sh i després de source hola.sh.) És el mateix model fork + exec + wait que implementes al Pralab 05.


Part B · Codis de retorn i test

B.1 — El codi de retorn $?

Tot procés acaba amb un codi de sortida: 0 vol dir èxit i qualsevol altre valor (1–255) indica error. La shell el guarda a $?.

Exercici

Prediu què imprimiran i comprova-ho:

ls
echo $?
ls noexisteix
echo $?
echo $?
  1. Quins valors surten? Per què el tercer echo $? mostra 0?
  2. Si vull conservar el codi de ls noexisteix per fer-lo servir més tard, què he de fer?
Veure el raonament
Solució
  1. Surten 0, 2 i 0. $? conté només el codi de l’última ordre executada, i el segon echo (que va anar bé) ha sobreescrit el 2. Qualsevol ordre, fins i tot echo, canvia $?.
  2. Guardar-lo immediatament en una variable:
ls noexisteix
rc=$?
echo "ls ha retornat $rc"

Les variables es creen amb nom=valor sense espais (rc = $? falla perquè la shell interpreta rc com una ordre) i es llegeixen amb $nom.

Exercici

Crea test_retorn.sh que executi una ordre correcta i una d’incorrecta i mostri els dos codis de retorn.

Veure el raonament
Solució
#!/bin/bash
ls
rc1=$?
ls noexisteix
rc2=$?
echo "Codi de retorn de ls: $rc1"
echo "Codi de retorn de ls noexisteix: $rc2"

Si féssim echo "...: $?" just després de cada ls també funcionaria, però guardar-lo en una variable és la pràctica segura.

B.2 — test i [ ]

Per decidir qüestions com “existeix aquest fitxer?” la shell té test, que no imprimeix res: només retorna 0 (cert) o 1 (fals).

Exercici

Llegeix man test (seccions DESCRIPTION i EXIT STATUS) i comprova:

test -f /etc/passwd ; echo $?
[ -d /etc/passwd ] ; echo $?
[ -e /noexisteix ] ; echo $?
  1. Quina diferència hi ha entre -f, -d i -e?
  2. Què imprimeix type [? Què implica això sobre els espais a [ -f fitxer ]?
Veure el raonament
Solució

Els valors són 0, 1 i 1.

  1. -f: existeix i és un fitxer regular. -d: existeix i és un directori. -e: existeix, sigui del tipus que sigui.
  2. type [ mostra que [ és una ordre (un builtin, equivalent a test) i ] n’és l’últim argument. Per això calen espais: [ -f x ] funciona i [-f x] dona command not found.

B.3 — && i ||

Aquests operadors usen el codi de retorn de l’ordre anterior:

  • A && B executa B només si A retorna 0.
  • A || B executa B només si A retorna ≠ 0.
Exercici

Prediu abans de provar. Què imprimeix cadascuna d’aquestes línies?

true  && echo "A"
false && echo "B"
false || echo "C"
true  && false || echo "D"

L’última és la important: A && B || C, és un if/else?

Veure el raonament
Solució

Imprimeix A, C i D (la B no surt mai).

L’última línia mostra la trampa: true && false || echo "D" executa D encara que la condició era certa, perquè el B (aquí false) ha fallat, i llavors s’activa el ||. Per tant A && B || C no és un if/else: és “si A va bé, fes B; si qualsevol dels dos falla, fes C”. Serà segur mentre B no pugui fallar (com un echo), però convé saber-ho abans de veure l’if.

B.4 — existeix.sh

Exercici

Crea existeix.sh: rep un nom de fitxer com a argument i mostra un missatge indicant si existeix o no. Encara no podem usar if: fes servir [ ] amb && i ||.

Després prova-ho amb un fitxer que tingui un espai al nom:

touch "el meu fitxer"
./existeix.sh "el meu fitxer"

I repeteix-ho treient les cometes de "$1" dins de l’script. Què passa? Per què?

Veure el raonament
Solució
#!/bin/bash
[ -f "$1" ] && echo "El fitxer $1 existeix." || echo "El fitxer $1 no existeix."

Sense cometes, la shell divideix $1 en paraules en expandir-lo: [ -f el meu fitxer ] són quatre arguments i [ es queixa (binary operator expected o too many arguments). Amb cometes, "$1" és un únic argument, tingui els espais que tingui. Regla pràctica: cita sempre les variables ("$1", "$nom").

Si $1 està buit, [ -f ] sense cometes també canvia de significat (només té un argument, i [ el tracta com un text no buit: és cert!). Amb "$1", [ -f "" ] és simplement fals.

B.5 — Encadenar-ho tot: fitxer, directori o una altra cosa

Ara volem distingir tres casos més un d’error: fitxer regular, directori, un altre tipus d’element (un dispositiu, un socket…) o que no existeixi.

Exercici

Crea tipus.sh amb aquest primer intent. Les línies acaben en ||, així que la shell continua llegint la línia següent com si fos una de sola.

#!/bin/bash
[ -f "$1" ] && echo "El fitxer $1 existeix." ||
[ -d "$1" ] && echo "El directori $1 existeix." ||
[ -e "$1" ] && echo "L'element $1 existeix, però no és ni un fitxer regular ni un directori." ||
echo "L'element $1 no existeix."

Prediu què imprimirà amb cadascun d’aquests arguments i després comprova-ho:

./tipus.sh /etc/passwd
./tipus.sh /etc
./tipus.sh /dev/null
./tipus.sh /noexisteix

Funciona sempre? Per què?

Veure el raonament
Solució

Només funciona bé per a /dev/null i /noexisteix. Amb /etc/passwd imprimeix tres línies (fitxer, directori i element) i amb /etc en imprimeix dues (directori i element).

El motiu és que && i || tenen la mateixa prioritat i s’avaluen d’esquerra a dreta (això és diferent de C, on && va abans que ||). La shell ho llegeix així:

((((T1 && M1) || T2) && M2) || T3) && M3) || M4

Amb /etc/passwd: T1 && M1 dona 0, el || T2 se salta, però el resultat segueix sent 0, de manera que el && M2 s’executa igualment, i el mateix passa amb M3. El codi de retorn “arrossega” cap a la dreta.

Solució: agrupar. Volem que el || salti tot el bloc següent, no només la propera ordre. Cal imbricar amb claus:

#!/bin/bash
[ -f "$1" ] && echo "El fitxer $1 existeix." || {
  [ -d "$1" ] && echo "El directori $1 existeix." || {
    [ -e "$1" ] && echo "L'element $1 existeix, però no és ni un fitxer regular ni un directori." ||
    echo "L'element $1 no existeix."
  }
}

Ara, si el primer cas es compleix, el || salta el bloc sencer i no s’avalua res més. Recorda les regles de les claus: espai després de { i ; o salt de línia abans de }.

La imbricació té un cost evident: costa de llegir. Aquest és el motiu pel qual existeix if.

Concepte — Claus `{ }` o parèntesis `( )`?

Les dues formes agrupen ordres, però no són equivalents:

  • { ordres; } s’executa dins de la shell actual.
  • ( ordres ) s’executa en un subshell (un procés fill, com a A.3): els canvis de variables, de directori i els exit hi moren.
( x=1 ); echo "x=$x"     # x=   (el subshell ha desaparegut amb la variable)
{ x=1; }; echo "x=$x"    # x=1

Per això a C.2 fem servir { ...; exit 1; }: amb parèntesis, l’exit només sortiria del subshell i l’script continuaria. Per a un simple agrupament dins d’un script, fes servir claus.

Concepte — Avançament: la mateixa lògica amb `if`

El comportament que hem buscat amb &&, || i claus s’escriu molt més net amb if, i no té el problema de l’ordre. Encara no l’expliquem (és el tema de la sessió següent), però pots llegir-lo i comprovar que fa el mateix:

#!/bin/bash
if [ -f "$1" ]; then
    echo "El fitxer $1 existeix."
elif [ -d "$1" ]; then
    echo "El directori $1 existeix."
elif [ -e "$1" ]; then
    echo "L'element $1 existeix, però no és ni un fitxer regular ni un directori."
else
    echo "L'element $1 no existeix."
fi

Fixa’t que cada branca és exclusiva per construcció: això és exactament el que calia forçar a mà amb les claus.


Part C · Arguments i gestió d’errors

C.1 — Arguments posicionals

Quan crides ./script.sh a b c, la shell guarda els valors en variables especials:

Variable Significat
$0 nom de l’script
$1, $2… primer, segon… argument
$# nombre d’arguments
$@ tots els arguments
Exercici

Crea arguments.sh que mostri el primer argument, el nombre total i tots els arguments. Prediu què imprimirà cadascuna d’aquestes crides i comprova-ho:

./arguments.sh hola mon
./arguments.sh "hola mon"
./arguments.sh

Quina diferència veus entre la primera i la segona? Què passa a la tercera?

Veure el raonament
Solució
#!/bin/bash
echo "Primer argument: $1"
echo "Total arguments: $#"
echo "Tots: $@"
  • hola mon → primer: hola, total: 2.
  • "hola mon" → primer: hola mon, total: 1. Les cometes agrupen en un sol argument (el mateix motiu pel qual a B.4 calia "$1").
  • Sense arguments → $1 és buit i $# val 0. L’script no es queixa de res, però això en un script real seria un problema: és el que arreglem a C.2.

Quan hagis de passar tots els arguments a una altra ordre, fes servir "$@" (amb cometes): conserva cada argument com a element separat, fins i tot si conté espais.

C.2 — Gestionar l’error: exit i stderr

Fins ara els nostres scripts acaben sempre amb codi 0, encara que hi hagi hagut un error. Un altre programa que el cridés no s’adonaria que ha fallat.

Exercici

Modifica arguments.sh perquè:

  • Si no hi ha arguments, mostri un missatge d’error i acabi amb un codi diferent de 0.
  • Si n’hi ha, mostri el primer i el total.

Comprova el resultat amb:

./arguments.sh ; echo $?
./arguments.sh a b ; echo $?
./arguments.sh > sortida.txt
cat sortida.txt

Pistes: man bash, secció SHELL BUILTIN COMMANDS, exit. Compara $# amb 0 usant [ ] i -eq. Busca què fa >&2.

Veure el raonament
Solució
#!/bin/bash
[ $# -eq 0 ] && { echo "Error: no s'han passat arguments." >&2; exit 1; }
echo "Primer argument: $1, Total arguments: $#"
  • exit 1 acaba l’script amb codi 1: aquest és el $? que veurà qui l’hagi cridat. Tanquem el cercle amb B.1: ara som nosaltres qui defineix codis de retorn. La convenció és 0 èxit i 1–255 errors. Sense exit, l’script retorna el codi de l’última ordre executada.
  • >&2 redirigeix la sortida al descriptor 2 (stderr). Els errors han d’anar a stderr perquè es puguin separar de la sortida normal: ./arguments.sh > sortida.txt mostra l’error per pantalla en lloc d’amagar-lo dins del fitxer, i sortida.txt queda buit.
  • { ...; } agrupa ordres sense crear un subshell (vegeu el requadre de B.5). Calen espais després de { i un ; abans de }.
  • Resultats: el primer cas retorna 1, el segon 0.
Reflexió

La versió que només feia [ $# -eq 0 ] && echo "Error..." || echo "..." també mostrava el missatge, però acabava amb 0. Un missatge d’error no és el mateix que un error: el segon ho ha de dir el codi de retorn.

C.3 — Un script que crea scripts, i instal·lar-lo

Fins ara hem hagut d’escriure el shebang a mà a cada script. Automatitzem-ho, i de passada tanquem el cercle amb el $PATH de A.3.

Exercici

Crea crea_script.sh: rep un nom com a argument, crea un fitxer amb aquest nom que només conté la línia #!/bin/bash i el fa executable.

  1. Ha de comprovar que se li passa exactament un argument (error a stderr i codi 1 si no).
  2. Ha de negar-se a sobreescriure un fitxer que ja existeix (error i codi 2).
  3. Prova-ho: ./crea_script.sh nou.sh, mira’n el contingut amb cat i executa’l.

Després instal·la’l i compara:

sudo install -m 0755 crea_script.sh /usr/local/bin/crea_script
# Equivalent (dos passos):
#   sudo mv crea_script.sh /usr/local/bin/crea_script
#   sudo chmod 0755 /usr/local/bin/crea_script
cd /tmp
crea_script prova.sh
crea_script.sh prova2.sh
  • Quina de les dues ordres de /tmp funciona? Per què?
  • Què hauries hagut de fer sense instal·lar-lo per cridar-lo des de /tmp?
Veure el raonament
Solució
#!/bin/bash
[ $# -eq 1 ] || { echo "Ús: $0 nom_script" >&2; exit 1; }
[ -e "$1" ] && { echo "Error: $1 ja existeix." >&2; exit 2; }
echo '#!/bin/bash' > "$1"
chmod +x "$1"
echo "Script $1 creat."
  • Cometes simples a '#!/bin/bash': a la terminal interactiva, amb cometes dobles bash intentaria expandir ! com a història d’ordres (event not found). Dins d’un script no passaria, però és un bon hàbit.
  • > sobreescriu: per això comprovem [ -e "$1" ] abans, o perdríem el contingut d’un script existent (tornarem a veure > vs >> a D.2).
  • [ $# -eq 1 ] || { ...; } és el mateix patró que C.2 invertit: “o bé hi ha un argument, o bé error”.

Instal·lat vs no instal·lat:

  • crea_script prova.sh funciona des de qualsevol directori: /usr/local/bin és al $PATH (comprova-ho amb echo $PATH), i la shell la troba allà. type crea_script mostra la ruta.
  • crea_script.sh prova2.sh falla amb command not found: no hi ha cap fitxer amb aquest nom a cap directori del $PATH.
  • Sense instal·lar-lo, cal donar la ruta (~/pralab04/crea_script.sh, o ./crea_script.sh si hi ets al costat), o afegir el seu directori al $PATH.

/usr/local/bin és el lloc pensat per al programari instal·lat a mà per l’administrador (no gestionat per apt), i només root hi pot escriure: per això cal sudo. Si no vols o no pots fer servir sudo, una alternativa és ~/.local/bin, que Debian afegeix al $PATH en iniciar sessió si el directori existeix (cal tancar i tornar a obrir sessió, o source ~/.profile).


Part D · Interacció i tasca final

D.1 — read

read llegeix una línia de l’entrada estàndard i la desa en una variable.

Exercici

Crea saluda.sh que pregunti el nom de l’usuari, el llegeixi i mostri una benvinguda personalitzada. Després:

  1. Executa’l normalment.
  2. Executa’l amb echo Anna | ./saluda.sh. Què passa? Per què?
  3. Ampliació: simplifica’l usant read -p (help read).
Veure el raonament
Solució
#!/bin/bash
echo "Quin és el teu nom?"
read -r nom
echo "Hola, $nom! Benvingut/da!"

Variant amb -p:

#!/bin/bash
read -r -p "Quin és el teu nom? " nom
echo "Hola, $nom! Benvingut/da!"

Amb echo Anna | ./saluda.sh, read llegeix d’stdin, que ara és el pipe, no el teclat: no espera ningú i funciona igualment. read no és interactiu per definició, sinó que depèn d’on vingui l’entrada. Un detall: amb read -p, quan stdin no és un terminal, bash no mostra la pregunta. Això serà important a D.3.

-r evita que read interpreti les barres invertides (\) com a caràcters d’escapada; és el costum recomanat. read -s amaga el que s’escriu (útil per contrasenyes).

D.2 — Tasca final: perfil.sh

Exercici

Crea perfil.sh (al teu directori personal, ~) que:

  1. Mostri un missatge de benvinguda.
  2. Demani nom i cognom.
  3. Mostri el directori actual (pwd) i la shell ($SHELL).
  4. Guardi tota aquesta informació a ~/perfil.txt.

Executa’l dues vegades i observa perfil.txt. Després canvia > per >> i torna-ho a provar: quina diferència hi ha?

Veure el raonament
Solució
#!/bin/bash
echo "Benvingut/da al sistema!"
read -r -p "Nom: " nom
read -r -p "Cognom: " cognom

{
  echo "Nom: $nom $cognom"
  echo "Directori: $(pwd)"
  echo "Shell: $SHELL"
} > ~/perfil.txt

echo "Perfil desat a ~/perfil.txt"

Recorda chmod +x perfil.sh (A.3).

  • > sobreescriu el fitxer a cada execució; >> hi afegeix al final (s’acumula). Amb >> pots afegir date per tenir un registre.
  • El bloc { ...; } > fitxer redirigeix la sortida de totes les ordres alhora.
  • $(pwd) és substitució d’ordres: executa pwd i n’insereix la sortida.

D.3 — Executar-lo en iniciar sessió

Bona Pràctica

Prova primer a mà. Abans de tocar res de configuració, assegura’t que ./perfil.sh funciona perfectament. I fes aquest apartat en una VM o amb una segona sessió oberta: un error a ~/.profile pot fer incòmode iniciar sessió, i la segona sessió et permet desfer-ho.

Afegeix al final de ~/.profile la línia:

~/perfil.sh

Tanca la sessió (exit) i torna a entrar.

Exercici
  1. Si fas la prova en un terminal gràfic i no passa res, per què? Com pots provar-ho sense tancar la sessió? (Pista: man bash, secció INVOCATION, i bash -l.)
  2. Què creus que passarà si .profile s’executa en una sessió de login sense terminal (per exemple, bash -l < /dev/null) i l’script fa un read?
  3. Com ho desfaries si alguna cosa falla?
Veure el raonament
Solució
  1. ~/.profile només el llegeix una shell de login. Hi entres per TTY (Ctrl+Alt+F3), per SSH o amb bash -l; els terminals gràfics nous normalment no són shells de login, i exit només tanca la finestra. Per provar-ho sense tancar la sessió: bash -l o ssh localhost. A més, si existeix ~/.bash_profile, bash el llegeix en lloc de .profile; i amb zsh el fitxer és ~/.zprofile.
  2. El read no té teclat del qual llegir: rep final d’entrada (EOF) o es queda bloquejat esperant, i l’script desa un perfil.txt buit o incomplet. Una versió segura executa l’script només si l’entrada és un terminal:
[ -t 0 ] && ~/perfil.sh

(-t 0 és cert si el descriptor 0, stdin, és un terminal. Fixa’t que torna a ser [ ] + &&.) 3. Des de l’altra sessió, comenta la línia (#) o elimina-la amb un editor. En una VM, sempre queda el mode de recuperació.

Aquest és un bon exemple de com el procés de login (login o sshd) llança la shell, que llegeix /etc/profile i després ~/.profile: així es configura l’entorn d’un procés.