A Família exec() de Chamadas de Sistema

Nos dois artigos anteriores, discutiu-se a utilização dos Parâmetros argc, argv e envp e também a Criação de Processos em Linux. Se você já está familiarizado com estes assuntos, sinta-se à vontade para prosseguir com a leitura deste texto, que é destinado à família de chamadas de sistema exec(). Este conjunto de chamadas de sistema é um complemento para a criação de processos utilizando a chamada de sistema fork().

Introdução

Como dito no artigo sobre Criação de Processos em Linux, manter todo o código do processo filho no mesmo arquivo fonte do processo pai nem sempre é uma boa abordagem. Por este motivo, as chamadas de sistema exec() são utilizadas em conjunto com as chamadas de sistema fork(). Um processo que é criado através da chamada fork() contém, basicamente, uma chamada exec(), a qual irá substituir a imagem do novo processo por outro executável. Desta forma, é possível manter os códigos em arquivos distintos, facilitando a organização em casos de projetos grandes e de maior complexidade.

Outra abordagem na qual é necessário utilizar chamadas exec() é quando necessita-se executar programas que já encontram-se compilados no sistema, como por exemplo o comando ls, find, mkdir, etc. Como, neste caso, o programador não dispõe do código fonte para incorporá-los ao seu código, a única saída é criar um novo processo e substituir sua imagem pela do executável que já encontra-se compilado no sistema.

Descrição

A família de chamadas de sistema exec() possui a capacidade de substituir completamente a imagem de um processo vigente por uma nova imagem. A nova imagem deve ser construída a partir de um arquivo executável regular disponível no disco. Este arquivo executável pode ser tanto um arquivo binário quanto um script. Em caso de sucesso, não há retorno desta chamada de sistema devido à substituição da imagem do processo.

Abaixo, são apresentados os seis diferentes protótipos das chamadas do tipo exec():

Em sua essência, as seis chamadas de sistema possuem a mesma funcionalidade e diferem apenas na quantidade e forma como os argumentos são passados para o novo programa. Então, para que não haja confusão entre as letras finais de cada chamada, estão listados abaixo os seus respectivos significados. Assim, fica mais fácil assimilar a funcionalidade.

  • L: lista de argumentos [execl(), execle() e execlp()]. Os argumentos que serão recebidos em argv são listados um a um como parâmetros da função em forma de string.
  • V: vetor de argumentos [execv(), execve() e execvp()]. Os argumentos que serão recebidos em argv são passados em um vetor do tipo char* que já contém todas as strings previamente carregadas.
  • E: variáveis de ambiente [execle() e execve()]. O último parâmetro destas funções é um vetor para strings (char *) que será recebido pelo novo programa no argumento envp contendo variáveis de ambiente pertinentes para sua execução. Para as versões sem a letra “e“, o ambiente é adquirido a partir de uma variável externa (extern char **environ) já declarada na biblioteca unistd.h.
  • P: utilização da variável de ambiente PATH [execlp() e execvp()]. Esta função irá buscar a nova imagem do processo nos diretórios contidos na variável PATH. Para as versões sem a letra “p“, deverá ser passada uma string contendo o caminho completo para o arquivo executável.

Quando um novo programa em linguagem C é executado a partir de uma destas chamadas, a sua função main() deverá ser declarada da seguinte forma:

Como já é sabido, os argumentos argv e envp são vetores de ponteiros para strings e devem sempre ser terminados por um ponteiro nulo (NULL). Devido à natureza destes vetores, deve-se utilizar o cast (char *) para o ponteiro nulo. É bom relembrar também que o primeiro argumento de argv sempre deve apontar para uma string contendo o nome associado ao programa que será executado.

É importante observar que alguns atributos do processo não serão preservados após uma chamada exec(). A disposição para qualquer sinal será resetada; o mapeamento de memória bem como o trancamento de memória (mlock) não serão preservados; todas as threads, com exceção da thread que executa a chamada, serão destruídas; mutex, variáveis condicionais e outros objetos da biblioteca pthread também não serão preservados. Em contrapartida, outros atributos serão herdados, tais como PID, PPID, ID do grupo, ID do usuário, diretório atual de trabalho, diretório root, etc. Para mais informação, consulte a página exec(3) do Manual do Programador Linux.

Valores de Retorno

Como dito anteriormente, em caso de sucesso, este tipo de chamada de sistema não irá retornar. Nos casos de erro, a função retorna -1 e a variável global errno (contida na biblioteca errno.h) é sinalizada de acordo com a causa do erro. Algumas causas possíveis são listadas abaixo e a lista completa de causas pode ser encontrada na página execve(2) do Manual do Programador Linux.

  • EACCES: permissão negada;
  • ENFILE: muitos arquivos abertos;
  • ENOENT: caminho ou arquivo não encontrado;
  • ENOEXEC: erro no formato de execução;
  • ENOMEM: sem memória suficiente.

Para auxiliar no tratamento de erro, é interessante, porém não obrigatório, adicionar a função perror() logo após uma chamada exec(). Caso a chamada exec() retorne, indicando um erro, a função perror() fica incumbida de imprimir uma mensagem correspondente na saída padrão de erro. Por motivo similar, é obrigatório o uso da função _exit() após uma chamada exec() para que o processo seja terminado. As funções _exit(2)exit(3) são similares, porém a primeira não causa limpeza dos buffers de I/O nem remove arquivos temporários. Essas ações podem interferir no funcionamento do processo pai.

Aplicações

As aplicações para as chamadas de sistema exec() são diversas, mas, basicamente, resumem-se à necessidade de invocar um novo programa. A escolha de uma dentre as seis chamadas desta família será atribuição do programador, que deve identificar qual delas atende melhor ao seu propósito. Então, para demonstrar com maior cautela o uso destas chamadas, mostrarei a seguir 4 exemplos que misturam todas as possibilidades de uso destas funções. Você pode baixar os códigos fonte deste artigo clicando AQUI ou digitando os seguintes comandos no seu terminal:

Você também pode visualizar e baixar os código desejados a partir do meu repositório no GitHub.

-> Lista de Argumentos com Variável PATH

O primeiro exemplo, tão simples quanto possa ser, utiliza a chamada de sistema execlp() para invocar o programa ls e passa alguns parâmetros de formatação para ele. Pelas letras “l” e “p” da chamada de sistema, podemos inferir que esta função necessita que os argumentos sejam passados em forma de lista e que o programa a ser executado será buscado nas pastas contidas na variável de ambiente PATH. Para saber quais pastas estão adicionadas à sua variável PATH, digite o comando abaixo:

O resultado mostra as pastas separadas por dois pontos. Provavelmente a pasta /bin já estará adicionada e é nela que a chamada de sistema irá encontrar o programa ls. Caso você tenha interesse de executar um programa o qual encontra-se em outra pasta, você pode adicionar manualmente esta pasta à variável PATH com o seguinte comando:

Esta solução é apenas temporária, pois quando a sessão for reiniciada novamente (ou o próprio computador), a variável PATH voltará ao seu valor original. Caso você deseje que a variável PATH contenha o caminho para outra pasta de forma permanente, deve-se adicionar o comando export mostrado acima no final do arquivo ~/.profile. Para fazer isto, utilize qualquer editor de texto e.g. gedit ou nano.

Indo adiante, o programa execlp_ls.c cria um novo processo com a chamada fork() e apenas aguarda seu filho finalizar a execução. Já o processo filho invoca o programa ls através da chamada execlp() substituindo toda sua imagem de programa. O resultado da listagem de pastas e arquivos irá aparecer no terminal normalmente, como se você tivesse executado o comando no terminal.

Para enfatizar, note que nos parâmetros da função execlp() o primeiro “ls” corresponde ao nome do executável que será buscado nas pastas da variável PATH e que o segundo “ls” corresponde ao nome associado ao programa que será executado (normalmente o mesmo nome do executável) e será recebido na primeira posição do vetor de argumentos argv da função main(). Os parâmetros -l e -a são passados logo adiante e finalizados pelo vetor NULL que indica o fim da lista de argumentos. Veja abaixo como compilar o código, executá-lo e qual a saída a ser esperada para este programa:

A única real diferença entre rodar este programa e executar o comando ls diretamente do terminal é que as letras não sairão coloridas, diferenciando arquivos comuns, pastas e executáveis. No mais, tudo será igual.

-> Lista de Argumentos sem Variável PATH

Quanto à passagem de argumentos em forma de lista, creio que tudo tenha ficado bem esclarecido no exemplo anterior. No exemplo execl_image.c, a única mudança é que a chamada de sistema execl() não dispõe da variável de ambiente PATH para buscar a imagem do executável. Com isto, deve-se passar o caminho absoluto ou relativo do programa o qual deseja-se executar. Veja a seguir apenas o trecho do processo filho que faz a chamada de sistema passando o caminho do executável e dois números quaisquer que serão somados pelo novo programa.

Compile os dois códigos contidos na pasta e execute o programa principal no terminal.

Observe que nada o impede de executar o programa new_image diretamente do terminal. Você apenas precisa passar os argumentos na linha de comando que ele irá recebê-los em argv.

Caso você não passe a quantidade de argumentos corretamente, o programa irá avisá-lo com uma mensagem de erro.

-> Vetor de Argumentos com Execução de Script

Neste próximo exemplo, tomaremos duas novas abordagens: passaremos os argumentos para o novo programa através de um vetor e executaremos um script no lugar de um binário. Esta tarefa será executada com a chamada de sistema execv() que, como pode-se observar, não dispõe da variável PATH para buscar o novo programa. Logo, faz-se necessário passar o caminho completo para o novo arquivo executável.

O vetor de argumentos a ser passado para o novo executável nada mais é que um vetor que aponta para strings e segue a mesma regra da lista de argumentos: o primeiro item é sempre um nome relacionado ao nome do executável e o último item é sempre o ponteiro NULL. Este artifício muitas vezes é utilizado quando a quantidade de argumentos a serem passados é grande ou quando você simplesmente quer repassar os argumentos já recebidos em argv da função main().

Por sua vez, um script é um arquivo de texto ao qual lhe foi atribuída função executável e no qual a primeira linha começa da seguinte forma:

O interpretador necessita ser um caminho completo para um executável, e.g. /bin/bash. E [argumentos-opcionais] são argumentos passados para o interpretador como, por exemplo, a flag -c. Se o arquivo executável informado na chamada execv() especificar um script, então o interpretador será invocado com os seguintes argumentos:

Onde arquivo é o caminho para o script desejado e arg representa uma série de palavras apontadas pelo vetor de argumentos passado através da chamada execv().

Parece meio complexo, não é? Mas ficará muito claro no exemplo prático. Observe abaixo o trecho do programa execv_script1.c. Ele executa um bash script através da chamada execv() e isto somente se torna possível dando permissão de execução para o script da seguinte maneira:

O intuito deste programa é rodar um script que irá imprimir no terminal as informações recebidas como parâmetro. Note que o vetor args é previamente preenchido com os valores desejados e depois é repassado na função execv(). Já o caminho para o script executável informa que o arquivo encontra-se na mesma pasta do programa principal (processo pai).

E o bash script é muito simples, pois os argumentos passados por args na função execv() são obtidos na mesma ordem utilizando $0, $1 e $2:

Por conseguinte, reiterando o que foi dito anteriormente, o interpretador /bin/bash irá executar o script1.sh com os argumentos “print_terminal”, “Hello ” e “world!” sem nenhum argumento opcional.

Observação: É imprescindível que a primeira linha do script sempre comece informando qual interpretador será utilizado. Por este motivo, o cabeçalho dos meus scripts começa depois desta declaração, caso contrário, haveria a ocorrência de falha no programa principal bem na chamada execv().

Para se aprofundar um pouco mais no tema e ver um script um pouco mais elaborado, estude o código da pasta script2 do pacote de arquivos fornecido para este artigo.

-> Vetor de Argumentos com Variáveis de Ambiente

Para finalizar esta sequência de quatro exemplos, veremos agora o comportamento de um programa quando inserido em um novo contexto de variáveis de ambiente. Sabe-se que as variáveis são recebidas no argumento *envp[] da função main() de um programa. Quando passamos um novo ambiente para um programa, ele irá funcionar segundo os respectivos valores das variáveis que foram recebidas. Desta forma, é possível setar temporariamente novos valores para variáveis de ambiente as quais deseja-se utilizar, independentemente do propósito. Os novos valores atribuídos serão enxergados somente no contexto daquele programa.

Observando o código do exemplo execve_env_date.c, é possível notar que agora se necessário preencher dois vetores que serão passados como argumento. O programa /bin/sh será executado com os argumentos “-c env; date”. O que, basicamente, roda uma nova instância do shell que invoca os programas env e date. O interessante aqui é saber que os programas rodando nesta nova instância do shell serão diretamente influenciados pelo novo ambiente estabelecido no momento da chamada de sistema. Assim, o programa env somente irá imprimir no terminal as variáveis de ambiente que ele enxerga naquele contexto, i.e. as variáveis que foram passadas pelo vetor env_vars[]. De forma similar, o programa date irá mostrar a data e a hora de acordo com a time zone estabelecida na nova variável de ambiente TZ.

Para visualizar bem o efeito destas mudanças, primeiro execute diretamente no terminal os comandos env e date. Após isto, compile e execute o programa exemplo e compare os resultados. A saída será similar a esta:

E então? Notou alguma diferença significativa? Note também que mesmo eu não tendo setado uma variável PWD, o próprio shell a incorporou no ambiente. Dependendo da sua distribuição ou versão do Linux, outras variáveis podem ser adicionadas compulsoriamente pelo sistema.

Conclusão

Por fim, é pertinente dizer que a criação e manipulação de processos em Linux é de suma importância. O cerne de um sistema operacional está em seus processos e todas as funcionalidades disponíveis são implementadas através dos mesmo. Assim sendo, caro leitor, não deixe o estudo parar por aqui. Em um futuro próximo irei abordar o tema de threads e IPC (comunicação inter-processos) que são artifícios importantes e poderosos para a criação de aplicações robustas e eficientes em ambiente Linux.

Um abraço e até a próxima!

Sobre

Jair Junior é Bacharel em Engenharia Eletrônica pela Universidade de Brasília [2014] com ênfase em microeletrônica. Suas especialidades na área são microcontroladores, sistemas embarcados e projeto de hardware. Também possui conhecimentos aprofundados em aplicações web e processamento digital de imagens. Atualmente, é aluno de pós-graduação lato sensu da PUC Minas no curso de Desenvolvimento de Aplicações Web. Ademais, tem como hobbies viajar, praticar esportes na natureza, apreciar cervejas artesanais e escutar um bom e velho rock 'n roll. Para mais detalhes, acesso a página sobre o autor.

Ver todos os posts de

Deixe uma resposta

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *