Een aangepaste shell bouwen in C is een uitstekende manier om uw begrip van besturingssystemen, procesbeheer en systeemprogrammering te verdiepen. Een shell is meer dan alleen een commandointerpreter; het maakt de primaire interface tussen de gebruiker en de kernel, het verstrekken van functies zoals taakbeheersing, piping, scripting en milieubeheer. Terwijl een minimale shell kan worden geïmplementeerd in een paar honderd lijnen, het toevoegen van geavanceerde mogelijkheden transformeert het in een productie-grade tool. In deze uitgebreide gids, zullen we onderzoeken de belangrijkste componenten en implementatiestrategieën voor het creëren van een robuuste, feature-rijke shell in C. Langs de weg, well cover parsing, uitvoering, pipering, omleiding, job control, scripting, en andere geavanceerde functies die maken een shell krachtig en gebruiksvriendelijk.

De basis van een Shell begrijpen

Een shell voert in zijn kern een eenvoudige lus uit: lees een invoerregel, ontleed het in commando's en argumenten, voer die commando's uit, en herhaal het vervolgens. Echter, een echte shell moet veel randgevallen behandelen en een consistente gebruikerservaring bieden. De basislus...vaak de .read-eval-print loop" (REPL) wordt het hart van elke interactieve shell genoemd. In een aangepaste implementatie moet je input beheren vanuit , speciale tekens (quoting, breaking) hanteren en onderscheid maken tussen ingebouwde commando's en externe programma's.

De shell werkt in twee primaire modi: interactive en non-interactief. In interactieve modus print het een prompt (bijv. ) en wacht op invoer van de gebruiker. Het moet functiecontrole, signalen en regelbewerking ondersteunen. In niet-interactieve modus (bij het lezen van een scriptbestand) leest de shell opdrachten sequentiële zonder te vragen. Beide modi vereisen een zorgvuldige omgang met exitcodes, foutmeldingen en resourcesopruiming.

Een historisch begrip van schelpen zoals de Bourne shell, Bash en Zsh biedt context voor de functies die we implementeren. Moderne schelpen erven tientallen jaren van ontwerpkeuzes .pipelining, procesgroepen, job control signalen ..die zijn gestandaardiseerd in POSIX. Door het bouwen van een shell vanaf nul, krijg je praktische inzicht in deze normen en de trade-offs achter hen.

Kerncomponenten van een aangepaste Shell

Elke schelp, hoe eenvoudig ook, moet deze fundamentele diensten leveren:

  • Input Parsing: Het verwerken van ruwe input in een commandostructuur (naam van de opdracht, argumenten, operators).
  • Command Execution: Het gebruik van systeemgesprekken zoals , en om externe programma's te starten.
  • Job Control: Het beheren van voorgrond- en achtergrondprocessen, procesgroepen en signaalverwerking.
  • Piping en redirection: De uitvoer van het ene proces verbinden met het andere, en stromen naar/van bestanden doorsturen.
  • Scripting Support: Meerdere commando's uitvoeren vanuit een bestand, met optionele controlestroom en foutafhandeling.
  • Ingebouwde commando's: Uitvoering van opdrachten zoals , , , , direct in het shellproces.
  • Milieubeheer: Erf- en wijzigingsvariabelen voor het milieu.

Elk van deze componenten interageert met de anderen. Zo is functiebeheersing afhankelijk van procesgroepen, die ook van invloed zijn op hoe leidingen werken. Het bouwen van een modulair ontwerp met duidelijke interfaces tussen ontleden, uitvoering en jobmanagement zal je later veel hoofdpijn besparen.

Invoerverwerking: Van ruwe regel naar commandostructuur

Lexical Analysis

Een token is een reeks tekens die een logische eenheid vormt: een commando, een argument, een operator (], , , ), of een geciteerde tekenreeks. Je kunt een eenvoudige tokenaar implementeren door over de regel te itereren, witruimte over te slaan en citaten te hanteren () en ]) en ontsnappingstekens (). Bijvoorbeeld, binnen enkele aanhalingen zijn alle tekens letterlijk; binnen dubbele aanhalingen, variabelen (zoals ) kunnen worden uitgebreid.

Een robuustere aanpak gebruikt een state machine om te volgen of de parser in een citaat, een escape sequentie of normale tekst zit. Dit voorkomt dat veel voorkomende bugs zoals het verkeerd verwerken van spaties binnen argumenten. Zodra de tokens zijn uitgepakt, worden ze samengevoegd tot een commandostructuur.

Commandostructuur

Definieer een structuur om een eenvoudig commando te vertegenwoordigen:

struct simple_cmd {
 char **args; // command name + arguments, NULL-terminated
 int argc; // number of arguments
 char **redirect_in; // input redirection file (if any)
 char **redirect_out; // output redirection file (if any)
 int append; // 1 if >>, 0 if >
 int background; // 1 if & is present
};

Een pijpleiding is dan een lijst van deze eenvoudige commando's, elk met zijn eigen omleidingen. De parser moet ook en (logical operators) en puntkomma's voor sequentiële uitvoering hanteren. Voor een productie shell zou je een volledige abstracte syntax boom (AST) bouwen die de gehele commandoregel vertegenwoordigt. Voor een leerproject volstaat een pijpleiding van eenvoudige commando's.

Commando-uitvoering: vork, exec en het pad

Het Fork-Exec patroon

Om een extern programma uit te voeren, wordt de shell eerst aangeroepen om een dochterproces aan te maken. Het kind roept vervolgens (of een variant) aan om de afbeelding te vervangen door het gewenste programma. De ouder moet wachten op het kind (als het een voorgrondtaak heeft) of het toevoegen aan de takenlijst (als achtergrond). Dit patroon is fundamenteel en verschijnt in alle Unix-shells.

Een nuance is dat mislukt als het programma niet kan worden gevonden of uitgevoerd. De shell moet fouten sierlijk rapporteren zonder te crashen. Ook moet de shell signaalverwerkers en procesgroepen voor en na de vork herstellen om te voorkomen dat de taakbeheersing wordt verstoord.

Ingebouwde commando's

Ingebouwde bestanden zoals wijzigen de eigen status van de shell (bv. de huidige map) en kunnen daarom niet worden uitgevoerd in een dochterproces. Ze moeten direct in het shellproces worden uitgevoerd. De makkelijkste manier om ingebouwde bestanden te verwerken is om de commandonaam na het paren en voor het vorken te controleren. Als het overeenkomt met een bekende ingebouwde functie, voer de overeenkomstige C-functie uit en sla het pad van de vork-exec over. De gemeenschappelijke ingebouwde elementen omvatten:

  • ..niet meer de schelp
  • ..drukwerkmap
  • ..lijst van achtergrondtaken
  • / ..breng taak naar de voorgrond/verzend naar de achtergrond

Milieuvariabelen

De shell erft de omgeving van de ouder en moet deze doorgeven aan de dochterprocessen. De functie gebruikt de huidige omgeving automatisch. Voor ingebouwde commando's als kunt u de globale variabele wijzigen of gebruiken . Het ondersteunen van variabele substitutie in commandoargumenten (bijv. ) vereist het uitbreiden van tokens voordat u deze uitvoert. Dit gebeurt door elk token te scannen voor en de waarde in de omgeving op te zoeken.

Padresolutie

Wanneer het commando geen ingebouwde opdracht is, moet de shell het uitvoerbare bestand lokaliseren met de omgevingsvariabele . De functie doet dit automatisch als het commando geen slash bevat. U kunt echter uw eigen padresolutie willen implementeren voor logging of veiligheidsredenen. Vergeet niet om de zaak te behandelen waarbij het commando een volledig pad is (beginnend met of ).

Piping en redirectie

Uitvoering van een enkele pijp

Pijpjes verbinden de standaarduitvoer van het ene proces met de standaardinvoer van het andere. De systeemaanroep creëert een paar bestandsdescriptoren: voor het lezen, voor het schrijven. Om een pijpleiding als te implementeren, vork je twee kinderen. De eerste kind .stdout wordt doorgestuurd naar ] met ], en de tweede kind .stdin wordt doorgestuurd van . De ouder moet beide uiteinden van de pijp sluiten na het instellen van de kinderen, anders de pijp won .. dicht als het schrijfproces voltooid.

Wees voorzichtig met bestandsdescriptorbeheer: je moet het ongebruikte einde in elk kind sluiten en alle pipe fds in de ouder sluiten na het forken van alle kinderen. Anders kunnen processen wachten op invoer die nooit aankomt.

Meerdere buizen hanteren

Voor langere pijpleidingen zoals ] heb je meerdere pijpen nodig. Een gemeenschappelijke aanpak is om een pijp te creëren voor elke tussenfase. Het kindproces voor schrijft naar de eerste pijp; leest uit die pijp en schrijft naar de volgende; en leest uit de laatste pijp. Je kunt dit iteratief implementeren: voor elk commando na de eerste, maak een nieuwe pijp, vork een kind, en stel zijn omleidingen op met behulp van de vorige pijp read end en de nieuwe pijp pijp three. De ouder moet bijhouden van open bestand descriptoren om ze te sluiten nadat alle kinderen zijn gemaakt.

I/O Omleiding

Redirection operators (, , ], ]) wijzigen de standaard bestandsdescriptoren voor de uitvoering. De implementatie is eenvoudig: voordat ] in het kind wordt aangeroepen, gebruik om stdin of stdout te vervangen door de juiste bestandsdescriptor die is verkregen van . Bijvoorbeeld, opent voor het schrijven (create or truncate) en dupliceert de bestandsdescriptor naar STDOUT FILENO. Voor de appende modus () gebruikt u de [ vlag. Input redirection uit een bestand in plaats van stdin.

U moet ook stderr redirection ([) en gecombineerde redirection ondersteunen (). Dit vereist het verwerken van het token om het bestandsdescriptornummer en de doelbestandsnaam te identificeren.

Taakbeheer

Procesgroepen

De functiecontrole wordt opgebouwd rond het concept van procesgroepen. Elke pijpleiding (taak) wordt in zijn eigen procesgroep geplaatst, met de groeps-ID gelijk aan het proces-ID van het eerste proces in de taak. De of ] systeemaanroep stelt de proces-groep van elk kind onmiddellijk na de vork in. De shell zelf behoort tot zijn eigen procesgroep (de voorgrondprocesgroep).

De voorgrondbewerkingsgroep van de terminal wordt beheerd door . Wanneer een taak op de voorgrond draait, moet de shell de terminal aan die procesgroep van de taak geven. Nadat de taak is voltooid (of is opgeschort), wordt de terminal opnieuw opgeëist. Dit is van cruciaal belang voor de juiste behandeling van Ctrl-C () en Ctrl-Z (), die worden geleverd aan de voorgrondprocesgroep.

Signalen

De shell moet signaalverwerkers installeren voor om beëindigde kindprocessen te herwinnen. De begeleider moet bellen met [] in een lus om alle kinderen die zijn gestopt te verzamelen. Dit voorkomt zombieprocessen en werkt de takenlijst bij.

Voor interactieve shells (van Ctrl-C) moet de huidige voorgrondtaak worden beëindigd, niet de shell zelf. U kunt instellen om in het shellproces genegeerd te worden en het mogelijk maken om het naar de voorgrond te brengen. Ook (Ctrl-\) en (Ctrl-Z) moeten zorgvuldig worden behandeld. De shell moet volgen of een taak is gestopt met een signaal en de status ervan rapporteren.

Voorgrond- en achtergrondtaken

Wanneer een taak op de voorgrond wordt gestart (geen trailing ), wacht de shell totdat deze voltooid is met zonder speciale vlaggen. Tijdens het wachten kan de shell geblokkeerd worden, maar het moet nog steeds signalen verwerken (bv. Ctrl-C onderbreken van het wachten). Een gebruikelijke techniek is een lus gebruiken die controleert op voltooiing en signalen parallel verwerkt.

Achtergrondtaken (trailing ) worden gestart zonder te wachten. De shell drukt de taak af en gaat verder met de volgende prompt. De takenlijst onderhoudt items met PID, taaknummer, commandostring en status (lopend, gestopt, gedaan). Ingebouwde commando's zoals , ], en manipuleren deze lijst. Bijvoorbeeld, brengt taak 1 naar de voorgrond door het verzenden van ] en wachten.

De structuur van de gegevens van het management van de functie

Implementeer een taaktabel (bijv. een reeks structuren) om te volgen:

  • Functie-ID (klein geheel getal toegewezen door shell)
  • Procesgroep-ID
  • Lijst van proces-PID's (één voor elk commando in pijplijn)
  • Status (lopend, gestopt, beëindigd)
  • Commando tekenreeks (voor weergave)

Bij elke , de status van de betreffende taak bijwerken. Wanneer een voorgrondtaak eindigt, verwijdert u deze uit de lijst. Voor achtergrondtaken, meld het asynchroon aan de gebruiker (druk een bericht zoals .[1]+ Gereed commando .).

Scripting en batchverwerking

Script-uitvoering

Om scriptbestanden te ondersteunen, voegt u een commandoregelvlag (bijv. ) of een ingebouwde opdracht toe. De shell opent het bestand, leest regels en verwerkt ze alsof ze interactief zijn getypt, behalve dat er geen prompt is afgedrukt en de taakcontrole kan worden vereenvoudigd (scripts draaien meestal op de voorgrond).

Shebang handling () is optioneel maar eenvoudig: als de eerste twee bytes van het scriptbestand zijn, zal de kernel de rest interpreteren als de tolk. Om je shell te laten werken als een scriptinterpreter, moet het de shebang-regel negeren bij het lezen van een bestand (sommige shells doen dat; andere behandelen het als een commentaar).

Controlestroom

Volledige ondersteuning voor het scripten vereist het verwerken en uitvoeren van controlestroomverklaringen: [, , , . Dit vergroot de complexiteit van de parser en uitvoerder dramatisch. Een minimale benadering is het geven van sequentiële uitvoering en eenvoudige commandoketen (met , , ). Voor geavanceerde scripting kunt u een recursieve-afstandsparer implementeren die een boom van commando's en controlestructuren bouwt.

Ondersteuning voor voorwaardelijke uitvoering op basis van exitcodes ( loopt minimaal alleen cmd2 als cmd1 slaagt) voegt enorme waarde toe. U kunt dit tijdens de uitvoering afhandelen: voer het eerste commando uit, controleer de exitstatus ervan en voer de volgende voorwaardelijk uit.

Fout bij het omgaan met scripts

Scripts hebben vaak robuuste foutafhandeling nodig. Implementeer de optie om het script te verlaten als een opdracht mislukt. Steun ook om signalen en fouten te vangen. Deze functies vereisen een wereldwijde staatmachine en een zorgvuldige opruiming (het vrijmaken van geheugen, het sluiten van bestanden).

Geavanceerde functies

Commandogeschiedenis

Een geschiedenismechanisme stelt gebruikers in staat om eerdere commando's terug te roepen, te bewerken en opnieuw uit te voeren. Implementeer een circulaire buffer die de laatste N-commando's (bijv. 1000) opslaat. Geef ingebouwde commando's en ] (of implementeer de Bash-navigatiesleutels: omhoog/omlaag pijlen). Gebruik of de ] bibliotheek om lijnbewerking en geschiedenisherroeping in interactieve modus te ondersteunen.

Tabbladaanvulling

Tabbladcompletie suggereert commando's, bestanden of variabelen. Voor bestanden kunt u de bibliotheek gebruiken om de inhoud van de directory te tonen die overeenkomt met het huidige prefix. Voor commando's kunt u de ] directories scannen. Voor variabelen kunt u de omgeving doorzoeken. Deze functie is een populaire toevoeging en leert u over directory traversal en string matching.

Variabele vervanging en uitbreiding

Voorbij simpel , steun brace-uitbreiding (), tilde-uitbreiding (), opdrachtsubstitutie ( of backticks), en rekenkundige expansie (]). Elk vereist een aparte parsingsfase. Command-substitutie is in het bijzonder complex omdat het een subshell uitvoert en de output ervan vastlegt. Je kunt een kindproces forken, zijn stdout pijpen en het resultaat in een string lezen.

Bijnamen

Aliassen laten gebruikers toe om shorthand commando's te definiëren (bijv. ). aliassen opslaan in een hash-tabel of gekoppelde lijst. Tijdens tokenization, als het eerste token overeenkomt met een alias, vervangt deze door de uitbreiding ervan. Wees voorzichtig met recursieve aliasuitbreiding (Bash ondersteunt het maar beperkt recursiediepte).

Conclusie

Het bouwen van een aangepaste shell in C met geavanceerde functies is een uitgebreid project dat raakt aan vele kern besturingssysteem concepten: procesbeheer, signalen, bestand descriptoren, en ontleden. Door het implementeren van taakcontrole, leidingen, redirectie, scripting, en extra gemakken zoals geschiedenis en tab-voltooid, creëer je een tool die zowel educatief als praktisch is. De code die u schrijft zal uw begrip van hoe Bash en Zsh werken onder de kap verdiepen, en je krijgt waardering voor de decennia van engineering die moderne schelpen zo veelzijdig en betrouwbaar maken.

Begin met een minimale lus en voeg incrementele functies toe. Test elke toevoeging grondig, vooral randgevallen met meerdere pijpen, achtergrondprocessen en signaalinteracties. Veel open-source shell implementaties (zoals GNU Bash en Zsh) zijn beschikbaar voor referentie, evenals POSIX specificaties voor shell gedrag. Met persistentie en zorgvuldige codering, kunt u een shell bouwen die rivaliseert professionele tools en leren een enorme hoeveelheid in het proces.