Como funciona uma falha de segmentação oculta?

266

Não consigo encontrar nenhuma informação sobre isso além de "o MMU da CPU envia um sinal" e "o kernel o direciona para o programa incorreto, encerrando-o".

Eu assumi que provavelmente ele envia o sinal para o shell, e o shell lida com ele encerrando o processo e a impressão incorretos "Segmentation fault". Então, testei essa suposição escrevendo um shell extremamente mínimo que chamo de crsh (casca de merda). Esse shell não faz nada, exceto pegar a entrada do usuário e alimentá-la com o system()método

#include <stdio.h>
#include <stdlib.h>

int main(){
    char cmdbuf[1000];
    while (1){
        printf("Crap Shell> ");
        fgets(cmdbuf, 1000, stdin);
        system(cmdbuf);
    }
}

Então, eu executei esse shell em um terminal vazio (sem bashcorrer por baixo). Então eu comecei a executar um programa que produz um segfault. Se minhas suposições estivessem corretas, isso poderia a) travar crsh, fechar o xterm, b) não imprimir "Segmentation fault", ou c) ambos.

braden@system ~/code/crsh/ $ xterm -e ./crsh
Crap Shell> ./segfault
Segmentation fault
Crap Shell> [still running]

De volta à estaca zero, eu acho. Acabei de demonstrar que não é o shell que faz isso, mas o sistema abaixo. Como é impressa a "falha de segmentação"? "Quem" está fazendo isso? O kernel? Algo mais? Como o sinal e todos os seus efeitos colaterais se propagam do hardware para a finalização do programa?

Braden Best
fonte
43
crshé uma ótima idéia para esse tipo de experimentação. Obrigado por nos informar sobre a idéia e por trás dela.
precisa saber é o seguinte
30
Quando vi pela primeira vez crsh, pensei que seria pronunciado "acidente". Não tenho certeza se esse é um nome igualmente adequado.
jpmc26
56
Esta é uma boa experiência ... mas você deve saber o que system()faz sob o capô. Acontece que isso system()gerará um processo de shell! Portanto, seu processo de shell gera outro processo de shell e esse processo de shell (provavelmente /bin/shou algo parecido) é o que executa o programa. A maneira /bin/shou bashfunciona é usando fork()e exec()(ou outra função na execve()família).
Dietrich Epp
4
@BradenBest: Exatamente. Leia a página do manual man 2 wait, ela incluirá as macros WIFSIGNALED()e WTERMSIG().
Dietrich Epp
4
@DietrichEpp Assim como você disse! Tentei adicionar um cheque para (WIFSIGNALED(status) && WTERMSIG(status) == 11)que ele imprima algo pateta ( "YOU DUN GOOFED AND TRIGGERED A SEGFAULT"). Quando executei o segfaultprograma de dentro crsh, ele imprimiu exatamente isso. Enquanto isso, os comandos que saem normalmente não produzem a mensagem de erro.
Braden Best

Respostas:

248

Todas as CPUs modernas têm capacidade para interromper as instruções da máquina em execução no momento. Eles economizam estado suficiente (geralmente, mas nem sempre, na pilha) para tornar possível retomar a execução mais tarde, como se nada tivesse acontecido (a instrução interrompida será reiniciada do zero, geralmente). Então eles começam a executar um manipulador de interrupção , que é apenas mais código de máquina, mas colocado em um local especial para que a CPU saiba onde está com antecedência. Manipuladores de interrupção sempre fazem parte do kernel do sistema operacional: o componente que é executado com o maior privilégio e é responsável por supervisionar a execução de todos os outros componentes. 1,2

As interrupções podem ser síncronas , o que significa que elas são acionadas pela própria CPU como uma resposta direta a algo que a instrução atualmente em execução fez, ou assíncronas , o que significa que elas ocorrem em um momento imprevisível por causa de um evento externo, como dados chegando na rede porta. Algumas pessoas reservam o termo "interrupção" para interrupções assíncronas e chamam interrupções síncronas de "traps", "falhas" ou "exceções", mas todas essas palavras têm outros significados, por isso vou usar a "interrupção síncrona".

Agora, os sistemas operacionais mais modernos têm uma noção de processos . No mais básico, esse é um mecanismo pelo qual o computador pode executar mais de um programa ao mesmo tempo, mas também é um aspecto essencial de como os sistemas operacionais configuram a proteção de memória , que é um recurso da maioria (mas, infelizmente, ainda não todos ) CPUs modernas. Combina com a memória virtual, que é a capacidade de alterar o mapeamento entre endereços de memória e locais reais na RAM. A proteção de memória permite que o sistema operacional forneça a cada processo seu próprio pedaço privado de RAM, que somente ele pode acessar. Ele também permite que o sistema operacional (agindo em nome de algum processo) designe regiões da RAM como somente leitura, executável, compartilhada entre um grupo de processos cooperativos etc. Também haverá um pedaço de memória acessível apenas pelo núcleo. 3

Enquanto cada processo acessa a memória apenas da maneira que a CPU está configurada para permitir, a proteção da memória é invisível. Quando um processo quebra as regras, a CPU gera uma interrupção síncrona, pedindo ao kernel para resolver as coisas. Ocorre regularmente que o processo realmente não violou as regras, apenas o kernel precisa fazer algum trabalho antes que o processo possa continuar. Por exemplo, se uma página da memória de um processo precisar ser "despejada" no arquivo de permuta para liberar espaço na RAM para outra coisa, o kernel marcará essa página como inacessível. Na próxima vez que o processo tentar usá-lo, a CPU gerará uma interrupção na proteção de memória; o kernel recuperará a página do swap, colocará de volta onde estava, marcará como acessível novamente e retomará a execução.

Mas suponha que o processo realmente tenha infringido as regras. Ele tentou acessar uma página que nunca teve nenhuma RAM mapeada para ele ou tentou executar uma página marcada como não contendo código de máquina ou qualquer outra coisa. A família de sistemas operacionais geralmente conhecida como "Unix" usa sinais para lidar com essa situação. 4 Os sinais são semelhantes às interrupções, mas são gerados pelo kernel e colocados em campo por processos, em vez de serem gerados pelo hardware e em campo pelo kernel. Processos podem definir manipuladores de sinalem seu próprio código e diga ao kernel onde eles estão. Esses manipuladores de sinal serão executados, interrompendo o fluxo normal de controle, quando necessário. Todos os sinais têm um número e dois nomes, um dos quais é um acrônimo enigmático e o outro uma frase um pouco menos enigmática. O sinal gerado quando um processo quebra as regras de proteção de memória é (por convenção) o número 11 e seus nomes são SIGSEGV"Falha na segmentação". 5,6

Uma diferença importante entre sinais e interrupções é que existe um comportamento padrão para cada sinal. Se o sistema operacional falhar na definição de manipuladores para todas as interrupções, isso é um bug no sistema operacional e o computador inteiro falhará quando a CPU tentar invocar um manipulador ausente. Mas os processos não têm obrigação de definir manipuladores de sinais para todos os sinais. Se o kernel gerar um sinal para um processo, e esse sinal tiver sido deixado em seu comportamento padrão, o kernel apenas seguirá em frente e fará o que for o padrão e não incomodará o processo. O comportamento padrão da maioria dos sinais é "não fazer nada" ou "encerrar esse processo e talvez também produzir um dump principal". SIGSEGVé um dos últimos.

Então, para recapitular, temos um processo que violou as regras de proteção de memória. A CPU suspendeu o processo e gerou uma interrupção síncrona. O kernel colocou em campo essa interrupção e gerou um SIGSEGVsinal para o processo. Vamos supor que o processo não configurou um manipulador de sinal para SIGSEGV, portanto, o kernel executa o comportamento padrão, que é finalizar o processo. Isso tem os mesmos efeitos que a _exitchamada do sistema: os arquivos abertos são fechados, a memória é desalocada, etc.

Até esse momento, nada imprimia nenhuma mensagem que um humano pudesse ver, e o shell (ou, de maneira mais geral, o processo pai do processo que acabou de terminar) não estava envolvido. SIGSEGVvai para o processo que violou as regras, não seu pai. A próxima etapa da sequência, no entanto, é notificar o processo pai de que seu filho foi encerrado. Isso pode acontecer de várias maneiras diferentes, das quais a mais simples é quando o pai já está esperando por esta notificação, usando uma das waitchamadas de sistema ( wait, waitpid, wait4, etc). Nesse caso, o kernel fará com que a chamada do sistema retorne e forneça ao processo pai um número de código chamado status de saída. 7 O status de saída informa ao pai por que o processo filho foi encerrado; nesse caso, aprenderá que a criança foi encerrada devido ao comportamento padrão de um SIGSEGVsinal.

O processo pai pode então relatar o evento a um humano imprimindo uma mensagem; programas shell quase sempre fazem isso. Você crshnão inclui código para fazer isso, mas acontece assim mesmo, porque a rotina da biblioteca C systemexecuta um shell com todos os recursos /bin/sh, "sob o capô". crshé o avô nesse cenário; a notificação do processo pai é preenchida por /bin/sh, que imprime sua mensagem usual. Em seguida, /bin/shele sai, pois não tem mais nada a fazer, e a implementação da biblioteca C systemrecebe essa notificação de saída. Você pode ver essa notificação de saída em seu código, inspecionando o valor de retorno desystem; mas não lhe dirá que o processo do neto morreu em um segfault, porque foi consumido pelo processo intermediário do shell.


Notas de rodapé

  1. Alguns sistemas operacionais não implementam drivers de dispositivo como parte do kernel; no entanto, todos os manipuladores de interrupção ainda precisam fazer parte do kernel, assim como o código que configura a proteção de memória, porque o hardware não permite nada além do kernel fazer essas coisas.

  2. Pode haver um programa chamado "hypervisor" ou "gerenciador de máquina virtual" que seja ainda mais privilegiado que o kernel, mas, para fins desta resposta, pode ser considerado parte do hardware .

  3. O kernel é um programa , mas é não um processo; é mais como uma biblioteca. Todos os processos executam partes do código do kernel, de tempos em tempos, além de seu próprio código. Pode haver vários "threads do kernel" que executam apenas o código do kernel, mas eles não nos interessam aqui.

  4. O único sistema operacional com o qual você provavelmente precisará lidar mais que não pode ser considerado uma implementação do Unix é, obviamente, o Windows. Não usa sinais nesta situação. (Na verdade, ele não possui sinais; no Windows, a <signal.h>interface é completamente falsificada pela biblioteca C.) Ela usa algo chamado " manipulação de exceção estruturada ".

  5. Algumas violações de proteção de memória são geradas SIGBUS("Erro de barramento") em vez de SIGSEGV. A linha entre os dois é subespecificada e varia de sistema para sistema. Se você escreveu um programa que define um manipulador SIGSEGV, provavelmente é uma boa ideia definir o mesmo manipulador para SIGBUS.

  6. "Falha de segmentação" foi o nome da interrupção gerada por violações da proteção de memória por um dos computadores que executaram o Unix original , provavelmente o PDP-11 . " Segmentação " é um tipo de proteção de memória, mas atualmente o termo " falha de segmentação " refere-se genericamente a qualquer tipo de violação de proteção de memória.

  7. Todas as outras maneiras pelas quais o processo pai pode ser notificado sobre a conclusão de um filho terminam com o pai chamando waite recebendo um status de saída. É que algo mais acontece primeiro.

zwol
fonte
@ zvol: ad 2) Eu não acho certo dizer que a CPU sabe alguma coisa sobre processos. Você deve dizer que ele chama um manipulador de interrupção, que transfere o controle.
user323094
9
@ user323094 As modernas CPUs multicore realmente conhecem bastante sobre processos; o suficiente para que, nessa situação, eles possam suspender apenas o encadeamento de execução que acionou a falha de proteção de memória. Além disso, eu estava tentando não entrar em detalhes de baixo nível. Do ponto de vista do programador do espaço do usuário, a coisa mais importante a entender sobre a etapa 2 é que é o hardware que detecta violações da proteção de memória; menos ainda a divisão precisa do trabalho entre o hardware, o firmware e o sistema operacional quando se trata de identificar o "processo incorreto".
Zwol 29/01
Outra sutileza que pode confundir um leitor ingênuo é "O kernel envia ao processo ofensor um sinal SIGSEGV". que usa o jargão usual, mas na verdade significa que o kernel diz a si mesmo para lidar com o sinal foo na barra de processos (ou seja, o código da terra do usuário não se envolve, a menos que haja um manipulador de sinais instalado, uma pergunta que é resolvida pelo kernel). Às vezes prefiro "gera um sinal SIGSEGV no processo" por esse motivo.
dmckee
2
A diferença significativa entre SIGBUS (erro de barramento) e SIGSEGV (falha de segmentação) é a seguinte: SIGSEGV ocorre quando a CPU sabe que você não deve acessar um endereço (e, portanto, não faz nenhuma solicitação de barramento de memória externa). O SIGBUS ocorre quando a CPU apenas descobre o problema de endereçamento depois de colocar sua solicitação no barramento de endereço externo. Por exemplo, solicitando um endereço físico ao qual nada no barramento responde ou solicitando a leitura de dados em um limite desalinhado (o que exigiria duas solicitações físicas para obter em vez de uma)
Stuart Caie
2
@StuartCaie Você está descrevendo o comportamento das interrupções ; de fato, muitas CPUs fazem a distinção que você descreve (embora algumas não façam, e a linha entre as duas varia). Os sinais SIGSEGV e SIGBUS, no entanto, não são mapeados de maneira confiável para essas duas condições no nível da CPU. A única condição em que o POSIX requer SIGBUS em vez de SIGSEGV é quando você coloca mmapum arquivo em uma região de memória maior que o arquivo e acessa "páginas inteiras" além do final do arquivo. (POSIX é de outro modo muito vago sobre quando SIGSEGV / SIGBUS / SIGILL / etc acontecer.)
Zwol
42

O shell realmente tem algo a ver com essa mensagem e crshindiretamente chama um shell, o que provavelmente é bash.

Eu escrevi um pequeno programa C que sempre seg falhas:

#include <stdio.h>

int
main(int ac, char **av)
{
        int *i = NULL;

        *i = 12;

        return 0;
}

Quando o executo do meu shell padrão zsh, recebo o seguinte:

4 % ./segv
zsh: 13512 segmentation fault  ./segv

Quando o executo bash, recebo o que você anotou na sua pergunta:

bediger@flq123:csrc % ./segv
Segmentation fault

Eu ia escrever um manipulador de sinal no meu código, então percebi que a system()chamada de biblioteca usada pelo crshexec é um shell, de /bin/shacordo com man 3 system. Isso /bin/shquase certamente está imprimindo "Falha na segmentação", pois crshcertamente não está.

Se você reescrever crshpara usar a execve()chamada do sistema para executar o programa, não verá a sequência "Falha na segmentação". Vem do shell invocado por system().

Bruce Ediger
fonte
5
Eu estava discutindo isso com Dietrich Epp. Eu cortei juntos uma versão do CRSH que usa execvpe fez o teste novamente para descobrir que enquanto o shell ainda não falhar (o que significa SIGSEGV nunca é enviado para o shell), ele não imprimir "falha de segmentação". Nada é impresso. Isso parece indicar que o shell detecta quando seus processos filhos são mortos e é responsável pela impressão de "Falha de segmentação" (ou alguma variante do mesmo).
Braden Best
2
@ BradenBest - Eu fiz a mesma coisa, meu código é mais desleixado que o seu código. Não recebi nenhuma mensagem, e meu invólucro ainda mais ruim não imprime nada. Eu usei waitpid()em cada fork / exec e ele retorna um valor diferente para processos com falha de segmentação do que para processos que saem com o status 0.
Bruce Ediger
21

Não consigo encontrar nenhuma informação sobre isso além de "o MMU da CPU envia um sinal" e "o kernel o direciona para o programa incorreto, encerrando-o".

Este é um resumo um pouco distorcido. O mecanismo de sinal Unix é totalmente diferente dos eventos específicos da CPU que iniciam o processo.

Em geral, quando um endereço incorreto é acessado (ou gravado em uma área somente leitura, tenta executar uma seção não executável, etc.), a CPU gera um evento específico da CPU (nas arquiteturas tradicionais não VM, isso foi chamado de violação de segmentação, já que cada "segmento" (tradicionalmente, o "texto executável somente leitura", os "dados" graváveis ​​e de comprimento variável e a pilha tradicionalmente na extremidade oposta da memória) tinha um intervalo fixo de endereços - em uma arquitetura moderna, é mais provável que seja uma falha de página [para memória não mapeada] ou uma violação de acesso [para problemas de permissão de leitura, gravação e execução], e vou me concentrar nisso no restante da resposta).

Agora, neste ponto, o kernel pode fazer várias coisas. As falhas de página também são geradas para a memória que é válida, mas não carregada (por exemplo, trocada ou em um arquivo mmapped etc.), e nesse caso o kernel mapeará a memória e reiniciará o programa do usuário a partir da instrução que causou o erro. erro. Caso contrário, ele envia um sinal. Isso não exatamente "direciona [o evento original] para o programa incorreto", pois o processo de instalação de um manipulador de sinal é diferente e, principalmente, independente da arquitetura, em comparação com o programa que simula a instalação de um manipulador de interrupção.

Se o programa do usuário tiver um manipulador de sinais instalado, isso significa criar um quadro de pilha e definir a posição de execução do programa do usuário para o manipulador de sinais. O mesmo é feito para todos os sinais, mas, no caso de uma violação de segmentação, as coisas geralmente são organizadas para que, se o manipulador de sinais retornar, reinicie a instrução que causou o erro. O programa do usuário pode ter corrigido o erro, por exemplo, mapeando a memória para o endereço incorreto - depende da arquitetura se isso é possível). O manipulador de sinal também pode pular para um local diferente no programa (normalmente via longjmp ou lançando uma exceção), para abortar qualquer operação que tenha causado o acesso ruim à memória.

Se o programa do usuário não tiver um manipulador de sinal instalado, ele simplesmente será encerrado. Em algumas arquiteturas, se o sinal for ignorado, ele poderá reiniciar a instrução repetidamente, causando um loop infinito.

Random832
fonte
+1, única resposta que adiciona algo ao aceito. Boa descrição do histórico de "segmentação". Curiosidade: o x86 ainda possui limites de segmento no modo protegido de 32 bits (com ou sem paginação (memória virtual) ativada), portanto, as instruções que acessam a memória podem gerar #PF(fault-code)(falha de página) ou #GP(0)("Se um endereço efetivo de operando de memória estiver fora do CS, Limite de segmento DS, ES, FS ou GS. "). O modo de 64 bits descarta as verificações de limite de segmento, já que os sistemas operacionais apenas usavam paginação e um modelo de memória plana para o espaço do usuário.
Peter Cordes
Na verdade, acredito que a maioria dos sistemas operacionais no x86 usa paginação segmentada: vários segmentos grandes dentro de um espaço de endereço paginado e plano. É assim que você protege e mapeia a memória do kernel em cada espaço de endereço: anéis (níveis de proteção) são vinculados a segmentos, não a páginas
Lorenzo Dematté
Além disso, no NT (mas eu adoraria saber se na maioria dos Unixes é o mesmo!) "Falha de segmentação" pode ocorrer com bastante frequência: há um segmento protegido de 64k no início do espaço do usuário, portanto, desferir um ponteiro NULL gera um falha de segmentação (adequada?)
Lorenzo Dematté 28/01
1
@ LorenzoDematté Sim, todos ou quase todos os Unixes modernos deixarão um pedaço de endereços permanentemente não mapeados no início do espaço de endereço para capturar desreferências NULL. Pode ser bastante grande - em sistemas de 64 bits, na verdade, pode ser de quatro gigabytes , para que o truncamento acidental de ponteiros para 32 bits seja capturado rapidamente. No entanto, a segmentação no sentido estrito x86 mal é usada; existe um segmento plano para o espaço do usuário e outro para o kernel, e talvez alguns para truques especiais, como usar o FS e o GS.
Zwol 28/01
1
@ LorenzoDematté NT usa exceções em vez de sinais; neste caso STATUS_ACCESS_VIOLATION.
precisa saber é o seguinte
18

Uma falha de segmentação é um acesso a um endereço de memória que não é permitido (não faz parte do processo, ou tenta gravar dados somente leitura ou executar dados não executáveis, ...). Isso é detectado pela MMU (Unidade de Gerenciamento de Memória, hoje parte da CPU), causando uma interrupção. A interrupção é tratada pelo kernel, que envia um SIGSEGFAULTsinal (veja, signal(2)por exemplo) para o processo incorreto. O manipulador padrão para esse sinal despeja o núcleo (consulte core(5)) e finaliza o processo.

A concha não tem absolutamente nenhuma mão nisso.

vonbrand
fonte
3
Então sua biblioteca C, como glibc em uma área de trabalho, define a string?
precisa saber é o seguinte
7
Também é importante notar que o SIGSEGV pode ser tratado / ignorado. Portanto, é possível escrever um programa que não seja finalizado por ele. A máquina virtual Java é um exemplo notável que usa SIGSEGV internamente para diferentes fins, como mencionado aqui: stackoverflow.com/questions/3731784/...
Karol Nowak
2
Da mesma forma, no Windows, o .NET não se incomoda em adicionar verificações de ponteiro nulo na maioria dos casos - apenas captura violações de acesso (equivalentes a segfaults).
immibis