2025-05-25 12:28:10 -03:00
#+title : 6 – Vetor de argumentos de linha de comando
#+author : Blau Araujo
#+email : cursos@blauaraujo.com
#+options : toc:3
* Objetivos
- Compreender a passagem de argumentos de linha de comando para processos.
- Descobrir como o kernel Linux estrutura o vetor de argumentos.
- Acessar a quantidade e os elementos do vetor de argumentos.
- Criar programas em Assembly que trabalhem com argumentos de linha de comando.
- Implementar parsing manual de argumentos no Assembly.
* O quadro inicial da pilha do processo
Ao iniciar a pilha de um processo, o kernel inclui diversos conjuntos de dados
relativos ao ambiente de execução do programa e outras informações auxiliares
para uso do /runtime/ da linguagem C (=glibc= ) e do carregados dinâmico do sistema
(=ld-linux= ). Na arquitetura Linux x86_64, dos endereços mais altos para os mais
baixos, nós temos:
- Strings dos argumentos de linha de comando;
- Strings das definições de variáveis exportadas para o ambiente do processo;
- Semente aleatória (=AT_RANDOM= );
- Nome do executável (=AT_EXECFN= );
- Vetor auxiliar (=auxv= );
- Vetor dos endereços dos dados de ambiente (=envp= );
- Vetor dos endereços dos argumentos (=argv= );
- Contador de argumentos (=argc= ).
O diagrama abaixo é uma representação simplificada do estado inicial da pilha,
segundo especificado pela /System V Application Binary Interface/ (ABI) para a
arquitetura AMD64:
#+begin_example
┌────────────────────────────────┐
│ NÃO ESPECIFICADO │ ENDEREÇOS MAIS ALTOS
├────────────────────────────────┤ ─┐
│ STRINGS DE ARGUMENTOS │ │
├ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─┤ │
│ STRINGS DE AMBIENTE │ ├─ BLOCO DE INFORMAÇÕES
├ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─┤ │ (SEM ORDEM ESPECÍFICA)
│ DADOS BINÁRIOS AUXILIARES │ │
├────────────────────────────────┤ ─┘
│ NÃO ESPECIFICADO │
├────────────────────────────────┤
│ ENTRADA NULA DO VETOR AUXILIAR │ QWORD (0x00)
├────────────────────────────────┤
│ ENTRADAS DO VETOR AUXILIAR │ 2 QWORDS POR ENTRADA
├────────────────────────────────┤
│ SEPARADOR NULO │ QWORD (0X00)
├────────────────────────────────┤
│ VETOR AMBIENTE (ENVP) │ 1 QWORD POR ENDEREÇO
├────────────────────────────────┤
RSP + (8 * ARGC) + 8 │ SEPARADOR NULO │ QWORD (0X00)
├────────────────────────────────┤
RSP + 8 (ARGV[0]) │ VETOR DE ARGUMENTOS (ARGV) │ 1 QWORD POR ENDEREÇO
├────────────────────────────────┤
RSP │ ENDEREÇO DO VALOR DE ARGC │ 1 QWORD
├────────────────────────────────┤
│ NÃO DEFINIDO │ ENDEREÇO MAIS BAIXO
└────────────────────────────────┘
QWORD = 8 BYTES (64 BITS)
#+end_example
Embora a ABI diga que o conteúdo do bloco de informações não precisa seguir uma
ordem específica, o Linux padronizou uma ordem em sua implementação da carga de
programas (em [[https://github.com/torvalds/linux/blob/94305e83eccb3120c921cd3a015cd74731140bac/fs/binfmt_elf.c#L824 ][kernel.org ]]), dos endereços mais altos para os mais baixos:
- Outros eventuais dados auxiliares
- Strings das definições das variáveis exportadas
- Strings de argumentos
- Semente aleatória (=AT_RANDOM= , ponteiro para 16 bytes aleatórios)
- Endereço da string da arquitetura (=AT_PLATAFORM= )
- Endereço da string do caminho do executável (=AT_EXECFN= )
No contexto deste e do próximo tópico dos nossos estudos, interessam apenas os
dados do quadro inicial da pilha que, de algum modo, propiciam alguma forma de
interação do usuário com os nossos programas: os argumentos passados pela linha
de comandos e as variáveis exportadas para o processo do programa.
* Inspecionando argumentos e variáveis exportadas
Para investigar o estado do quadro inicial da pilha, nós vamos utilizar o
programa [[exemplos/06/exit42.asm ][exit42.asm ]]:
#+begin_src asm
; Retorna 42 como estado de término
section .text
global _start
_start:
mov rax, 60 ; syscall: exit
mov rdi, 42 ; código de saída
syscall
#+end_src
*Montagem e link-edição:*
#+begin_example
:~$ nasm -f elf64 -g exit42.asm
:~$ ld -o exit42 exit42.o
#+end_example
Agora, vamos abrir o executável no GDB, definir =_start= como ponto de parada
e iniciar sua execução:
#+begin_example
:~$ gdb exit42
Reading symbols from exit42...
(gdb) break _start
Breakpoint 1 at 0x401000: file exit42.asm, line 7.
(gdb) run
Starting program: /home/blau/git/pbn/curso/exemplos/06/exit42
Breakpoint 1, _start () at exit42.asm:7
7 mov rax, 60 ; syscall: exit
(gdb)
#+end_example
** Contador de argumentos
Primeiro, vamos examinar o conteúdo do registrador =rsp= (/stack pointer/ ):
#+begin_example
(gdb) info registers rsp
rsp 0x7fffffffdfd0 0x7fffffffdfd0
#+end_example
Como resultado, nós temos um endereço que, segundo o diagrama anterior, deve
nos levar à quantidade de argumentos de linha de comando escritos para invocar
o programa (=argc= ). Como o programa não foi iniciado com argumentos, o valor de
=argc= deve ser =1= (escrito em 8 bytes), correspondendo ao caminho do nosso
executável. Sendo assim, vamos utilizar o comando =x= para exibir uma /giant-word/
em hexa (=/1gx= ) a partir do endereço em =rsp= (=$rsp= ):
#+begin_example
(gdb) x /1gx $rsp
0x7fffffffdfd0: 0x0000000000000001
#+end_example
E lá está o valor =1= escrito em 8 bytes.
#+begin_quote
*Nota:* É por conta dessa ambiguidade do termo /argumentos/ , utilizado tanto no
sentido de /"argumentos passados para um programa"/ quanto de /"todas as palavras
utilizadas para invocar o programa"/, que o vetor de argumentos também é
chamado de /vetor de parâmetros/ , bem mais adequado para expressar o que nós
encontramos no quadro inicial da pilha (todas as palavras utilizadas para
invocar o programa). Mas, feita a observação, vamos nos manter na terminologia
da documentação da ABI e do kernel.
#+end_quote
** Primeiro elemento do vetor de argumentos
Lembrando que o registrador =rsp= sempre contém o dado que está no topo da
pilha (ou seja, em seu endereço mais baixo) e sabendo que o dado anterior
está 8 bytes acima, nós podemos obter o endereço do primeiro elemento do
vetor de argumentos (=argv= ) desta forma:
#+begin_example
(gdb) x /1gx $rsp+8
0x7fffffffdfd8: 0x00007fffffffe2ec
#+end_example
Se o endereço estiver correto, ele nos levará à string do caminho completo
do nosso executável. Para confirmar, vamos examinar com o formato =/1s= , de
/"uma string"/ :
#+begin_example
(gdb) x /1s 0x00007fffffffe2ec
0x7fffffffe2ec: "/home/blau/git/pbn/curso/exemplos/06/exit42"
#+end_example
Confirmado!
** Separador nulo do vetor de argumentos
Ainda segundo o diagrama do quadro inicial da pilha, tendo apenas um argumento,
a posição seguinte na pilha (=rsp+16= ) deve conter um endereço que nos leva a
uma cadeia de 8 bytes zerados (/separador nulo/ ). Então, vejamos o que mostra
o GDB:
#+begin_example
(gdb) x /1gx $rsp+16
0x7fffffffdfe0: 0x0000000000000000
#+end_example
Não é o que estamos vendo... Na verdade, o que esperávamos resulta de uma
interpretação equivocada bastante comum das especificações da ABI. Os dados
no bloco de informações são compactados, ou seja, escritos sem separação de
forma contígua. Sendo assim, a delimitação dos vetores (os /separadores nulos/ )
é feita pelos valores dos elementos mais acima na pilha (nos endereços mais
baixos), como vimos no GDB. A posição do separador nulo de =argv= pode ser
calculado com:
#+begin_example
rsp + (argc * 8) + 8
#+end_example
Como o valor em =argc= é =1= :
#+begin_example
rsp + (1 * 8) + 8 = rsp + 8 + 8 = rsp + 16
#+end_example
Ou seja, nós teríamos que encontrar o valor =0= escrito com 8 bytes na posição
da pilha resultante de =rsp + 16= -- e encontramos.
** Primeiro elemento do vetor ambiente
A posição imediatamente abaixo da pilha (=rsp + 24= ) deve conter o endereço da
primeira string com a definição de uma variável exportada para o processo do
programa -- ou seja, a string em =envp[0]= . Então, vamos examinar novamente:
#+begin_example
(gdb) x /1gx $rsp+24
0x7fffffffdfe8: 0x00007fffffffe318
(gdb) x /1s 0x00007fffffffe318
0x7fffffffe318: "SHELL=/bin/bash"
#+end_example
O quadro inicial da pilha não tem uma informação sobre a quantidade de
variáveis exportadas para o ambiente do processo, então é difícil saber
onde ela termina sem continuar a percorrer a pilha até encontrar o separador
nulo de =envp= . No meu sistema, eu testei manualmente e descobri que o endereço
do último elemento de =envp= está em =rsp + 368= :
#+begin_example
(gdb) x /1gx $rsp+368
0x7fffffffe140: 0x00007fffffffefa3
(gdb) x /1s 0x00007fffffffefa3
0x7fffffffefa3: "OLDPWD=/home/blau/git/pbn/curso/exemplos"
#+end_example
Portanto, em =rsp + 376= eu devo encontrar o separador nulo:
#+begin_example
(gdb) x /1gx $rsp+376
0x7fffffffe148: 0x0000000000000000
#+end_example
E lá está ele!
** Resultados com mais de um argumento
Também é interessante investigar o que acontece com o quadro inicial da
pilha quando nós executamos o programa com outros argumentos de linha de
comando além, é claro, de seu próprio caminho. Para isso, vamos iniciar
novamente o GDB, mas vamos rodar o programa com argumentos:
#+begin_example
$ gdb exit42
Reading symbols from exit42...
(gdb) b _start
Breakpoint 1 at 0x401000: file exit42.asm, line 7.
(gdb) run banana laranja
Starting program: /home/blau/git/pbn/curso/exemplos/06/exit42 banana laranja
Breakpoint 1, _start () at exit42.asm:7
7 mov rax, 60 ; syscall: exit
#+end_example
*Conferindo =argc=:*
#+begin_example
(gdb) info registers rsp
rsp 0x7fffffffdfb0 0x7fffffffdfb0
(gdb) x /1gx 0x7fffffffdfb0
0x7fffffffdfb0: 0x0000000000000003
#+end_example
*Valor em =argv[0]=:*
#+begin_example
(gdb) x /1gx $rsp + 8
0x7fffffffdfb8: 0x00007fffffffe2dd
(gdb) x /1s 0x00007fffffffe2dd
0x7fffffffe2dd: "/home/blau/git/pbn/curso/exemplos/06/exit42"
#+end_example
*Valor em =argv[1]=:*
#+begin_example
(gdb) x /1gx $rsp + 16
0x7fffffffdfc0: 0x00007fffffffe309
(gdb) x /1s 0x00007fffffffe309
0x7fffffffe309: "banana"
#+end_example
*Valor em =argv[2]=:*
#+begin_example
(gdb) x /1gx $rsp + 24
0x7fffffffdfc8: 0x00007fffffffe310
(gdb) x /1s 0x00007fffffffe310
0x7fffffffe310: "laranja"
#+end_example
*Separador nulo de =argv=:*
#+begin_example
(gdb) x /1gx $rsp + 32
0x7fffffffdfd0: 0x0000000000000000
#+end_example
* Acesso aos argumentos em baixo nível
Em Assembly, as duas técnicas mais comuns para acessar os argumentos de linha
de comando são a leitura da pilha após o ponto de entrada (=_start= ) e, no caso
de programas híbridos que seguem as convenções da linguagem C, exportando uma
função =main= , deixando que a =glibc= cuide da pilha e das convenções de chamada.
** Acesso direto à pilha
Basta salvar o endereço em =rsp= e utilizá-lo para obter =argc= e os demais
elementos em =argv= :
#+begin_src asm
section .bss
argc: resq 1 ; reservar 8 bytes para receber o valor de argc.
argv: resq 1 ; reservar 8 bytes para receber o endereço de argv[0].
section .text
global _start
_start:
; ----------------------------------------------------
; Inicialização de argc e argv...
; ----------------------------------------------------
mov rbx, rsp ; endereço de argc
mov rax, [rbx] ; valor de argc
lea rcx, [rbx + 8] ; endereço de argv[0]
mov [argc], rax
mov [argv], rcx
; Agora podemos usar [argc] e [argv] em qualquer parte do código.
; ...
#+end_src
#+begin_quote
*Nota:* a instrução =lea= (/Load Effective Address/ ) calcula um endereço e carrega
o resultado em um registrador de destino.
#+end_quote
2025-05-26 08:04:43 -03:00
** Acesso em Assembly exportando main
2025-05-25 12:28:10 -03:00
Exportando a rotina principal do programa com o nome =main= , nós podemos gerar
o executável com o =gcc= que, utilizando o objeto de inicialização da linguagem
C (=crt0.o= ), executará todas as tarefas de preparação para o acesso aos dados
de argumentos e ambiente, o que inclui:
- Carregar =argc= em =rdi= ;
- Carregar o endereço de =argv[0]= em =rsi= ;
- Carregar o endereço de =envp[0]= em =rdx= ;
- Configurar variáveis globais para a =glibc= (se usada);
- Chamar =main= , esperando que retorne um =int= em =eax= ;
- Terminar o programa chamando a função =exit= (=glibc= ).
O exemplo abaixo imprime o nome do programa (string =argv[0]= ) utilizando a
função =printf= , da glibc:
#+begin_src asm :tangle exemplos/06/prog.asm
section .data
fmt db "Programa: %s", 10, 0
global main ; símbolo chamado por _start de crt0.o
extern printf ; (opcional) usar funções da libc
section .text
main:
; argc → rdi
; argv → rsi
; envp → rdx (não obrigatório)
; Exemplo: imprimir argv[0]
mov rdi, fmt
mov rsi, [rsi] ; argv[0]
xor rax, rax ; terminação de argumentos (printf é variádica)
call printf
mov eax, 0 ; retorno de 'main' = (int)0
ret
#+end_src
Para montar e compilar:
#+begin_example
:~$ nasm -g -f elf64 prog.asm
:~$ gcc -no-pie -o prog prog.o
#+end_example
Atenção para a opção =-no-pie= no =gcc= : Isso é necessário porque o exemplo não
foi escrito nem montado prevendo suporte a endereçamento relativo (PIE).
Executando:
#+begin_example
:~$ ./prog
Programa: ./prog
#+end_example
Mas note que, ao preparar a chamada de =printf= , nós tivemos que seguir as
convenções da ABI e passar o primeiro argumento em =rdi= e o segundo em =rsi= :
que são justamente os registradores onde as informações de argumentos foram
carregadas. Portanto, se quisermos reutilizar esses dados, nós devemos
salvá-los em outro lugar, como fizemos antes:
#+begin_src asm
section .bss
argc: resq 1 ; reservar 8 bytes para receber o valor de argc.
argv: resq 1 ; reservar 8 bytes para receber o endereço de argv[0].
section .data
fmt db "Programa: %s", 10, 0
global main ; símbolo chamado por _start de crt0.o
extern printf ; (opcional) usar funções da libc
section .text
main:
; argc → rdi
; argv → rsi
; envp → rdx (não obrigatório)
mov [argc], rdi ; salvar argc
mov [argv], rsi ; salvar endereço de argv[0]
; Exemplo: imprimir argv[0]
mov rdi, fmt
mov rsi, [argv] ; argv[0]
xor rax, rax ; terminação de argumentos (printf é variádica)
call printf
mov eax, 0 ; retorno de 'main' = (int)0
ret
#+end_src
* Listando todos os dados de argumentos
Para encerrar este tópico, vamos criar dois programas que acessam dos dados de
argumentos na pilha e lista seus valores no terminal: um utilizando o /runtime/
da linguagem C (=crt0.o= ) e outro totalmente em Assembly, mas com um pequeno
módulo de sub-rotinas para conversão e impressão que só será estudada nos
próximos tópicos.
2025-05-26 08:04:43 -03:00
** Exemplo em Assembly com main e gcc
2025-05-25 12:28:10 -03:00
Arquivo: [[exemplos/06/cargs.asm ][cargs.asm ]]
#+begin_src asm :tangle exemplos/06/cargs.asm
section .bss
argc: resq 1 ; 8 bytes para armazenar argc
argv: resq 1 ; 8 bytes para armazenar ponteiro para argv[0]
section .data
fmt_argc db "argc : %ld", 10, 0
fmt_argv db "argv[%d]: %s", 10, 0
global main
extern printf
section .text
main:
; rdi = argc
; rsi = argv
mov [argc], rdi ; salvar argc
mov [argv], rsi ; salvar argv[0]
; Imprimir com: printf("argc : %ld", argc);
mov rdi, fmt_argc ; primeiro argumento: formato
mov rsi, [argc] ; segundo argumento: argc (qword = long)
xor rax, rax ; terminação de argumentos
call printf
; preparar o laço: i = 0
xor rcx, rcx ; i = 0
mov rbx, [argv] ; rbx = argv base
mov r8, [argc] ; r8 = argc
.loop:
cmp rcx, r8 ; compara i com argc
jge .done ; se i >= argc, fim
; Imprimir com: printf("argv[%d]: %s\n", i, argv[i]);
mov rdi, fmt_argv ; primeiro argumento: formato
mov rsi, rcx ; segundo argumento: i
mov rdx, [rbx + rcx*8] ; terceiro argumento: argv[i]
xor rax, rax ; terminação de argumentos
push rcx ; salvar rcx (caller saved) na pilha
push r8 ; salvar r8 (caller saved) na pilha
call printf
pop r8 ; restaurar rcx
pop rcx ; restaurar rcx
inc rcx
jmp .loop
.done:
xor eax, eax ; retorno de main: (int)0
ret
#+end_src
Compilando e executando:
#+begin_example
:~$ nasm -g -felf64 cargs.asm
:~$ gcc -no-pie -o cargs cargs.o
:~$ ./cargs a b c
argc : 4
argv[0]: ./cargs
argv[1]: a
argv[2]: b
argv[3]: c
#+end_example
*** Nota sobre registradores /"caller saved"/
#+begin_src asm
push rcx ; salvar rcx (caller saved) na pilha
push r8 ; salvar r8 (caller saved) na pilha
call printf
pop r8 ; restaurar rcx
pop rcx ; restaurar rcx
#+end_src
Nesse trecho, nós salvamos na pilha os registradores =rcx= e =r8= , usados como
referências do nosso /loop/ , antes de chamar a função =printf= . Isso foi
necessário porque, segundo a ABI, eles estão entre os registradores /"caller
saved"/, ou seja: registradores que podem ser alterados pela chamada de uma
função com a instrução =call= e, portanto, terão que ser salvos pela função
chamadora, caso estejam em uso para outros propósitos.
São registradores /caller-saved/ segundo a ABI (System V AMD64):
- =rax= : usado para retorno de funções;
- =rcx= : usado como argumento 4 em chamadas de funções;
- =rdx= : usado como argumento 3 em chamadas de funções;
- =rsi= : usado como argumento 2 em chamadas de funções;
- =rdi= : usado como argumento 1 em chamadas de funções;
- =r8= e =r9= : usados como argumentos de 5 e 6 em chamadas de funções;
- =r10= e =r11= : uso interno em funções.
A volatilidade desses registradores não está relacionada com seu uso antes
da chamada da função (=call= ), mas com o fato da ABI não obrigar a função
chamada a salvar e restaurar seus conteúdos.
2025-05-26 08:04:43 -03:00
** Exemplo com acesso direto à pilha
2025-05-25 12:28:10 -03:00
- Arquivo: [[exemplos/06/args.asm ][args.asm ]]
- Módulo: [[exemplos/06/print_utils.asm ][print_utils.asm ]]
Neste exemplo, não há necessidade de salvar =argc= e =argv= na memória.
#+begin_src asm
; ----------------------------------------------------------
; Montar módulo com : nasm -f elf64 print_utils.asm
; Montar programa com : nasm -f elf64 args.asm
; Gerar executável com: ls -o args args.o print_utils.o
; ----------------------------------------------------------
section .rodata
pref_argc db "argc : ", 0 ; Prefixo da impressão de argc
pref_argv db "argv[", 0 ; Prefixo da impressão de argv
posf_argv db "]: ", 0 ; Sufixo da impressão de argv
; sub-rotinas do módulo print_utils.o
extern print_int
extern print_str
extern print_char
section .text
global _start
_start:
; ----------------------------------------------------------
; Impressão de argc
; ----------------------------------------------------------
mov rdi, pref_argc ; endereço de pref_argc
call print_str ; imprime pref_argc
mov rax, [rsp] ; rax = argc
call print_int ; imprime argc
mov rdi, 10 ; rdi = \n
call print_char ; imprime \n
; ----------------------------------------------------------
; Impressão de argv
; ----------------------------------------------------------
mov rcx, 0 ; índice i = 0
lea rbx, [rsp + 8] ; rbx = endereço de argv[0]
.print_argv_loop:
cmp rcx, [rsp] ; enquanto i < argc
jge .done_argv
mov rdi, pref_argv ; imprime prefixo "argv["
call print_str
mov rax, rcx ; rax = i
call print_int ; imprime índice
mov rdi, posf_argv ; imprime sufixo "]: "
call print_str
mov rdi, [rbx + rcx*8] ; rdi = argv[i]
call print_str
mov rdi, 10 ; imprime \n
call print_char
inc rcx
jmp .print_argv_loop
.done_argv:
; ----------------------------------------------------------
; Termina o programa com exit(0)
; ----------------------------------------------------------
mov rax, 60 ; syscall: exit
xor rdi, rdi ; status = 0
syscall
#+end_src
Montagem, link-edição e execução:
#+begin_example
:~$ nasm -f elf64 print_utils.asm
:~$ nasm -f elf64 args.asm
:~$ ld -o args args.o print_utils.o
:~$ ./args a b c d
argc : 5
argv[0]: ./args
argv[1]: a
argv[2]: b
argv[3]: c
argv[4]: d
#+end_example
* Exercícios propostos
1. Crie um programa em Assembly que receba seu nome como argumento e imprima =Salve, NOME!= .
2025-05-26 08:04:43 -03:00
2. Crie uma versão desse mesmo programa em Assembly chamando funções da linguagem C.
2025-05-25 12:28:10 -03:00
3. Utilizando o GDB e o exemplo =cargs.asm= , analise o comportamento dos registradores voláteis nas chamadas de funções da =glibc= .
4. Com base nos exemplos do tópico, crie um programa que liste as variáveis no vetor de ambiente.
* Referências
- [[https://refspecs.linuxfoundation.org/ ][System V Application Binary Interface – AMD64 Architecture Processor Supplement ]]
- [[https://github.com/torvalds/linux/blob/master/fs/binfmt_elf.c ][Linux kernel: fs/binfmt_elf.c ]]
- =man 2 execve=
- =man 3 environ=
- =man 7 exec=