Как работает ошибка сегментации под капотом?

266

Похоже, я не могу найти какую-либо информацию по этому поводу, кроме того, что «MMU ЦП посылает сигнал» и «ядро направляет его в вызывающую программу, завершая ее».

Я предположил, что он, вероятно, отправляет сигнал в оболочку, а оболочка обрабатывает его, завершая ошибочный процесс и печать "Segmentation fault". Поэтому я проверил это предположение, написав чрезвычайно минимальную оболочку, которую я называю crsh (crap shell). Эта оболочка не делает ничего, кроме как принимает пользовательский ввод и передает его system()методу.

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

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

Поэтому я запустил эту оболочку в пустом терминале (без bashзапуска под ним). Затем я приступил к запуску программы, которая производит segfault. Если бы мои предположения были верны, это было бы либо a) сбой crsh, закрытие xterm, b) не печать "Segmentation fault", либо c) оба.

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

Думаю, вернемся к исходной точке. Я только что продемонстрировал, что это делает не оболочка, а система под ней. Как «Ошибка сегментации» вообще печатается? «Кто» это делает? Ядро? Что-то другое? Как сигнал и все его побочные эффекты распространяются от аппаратного обеспечения до возможного завершения программы?

Брэден Бест
источник
43
crshотличная идея для такого рода экспериментов. Спасибо, что сообщили нам всем об этом и идее.
Брюс Эдигер
30
Когда я впервые увидел crsh, я подумал, что это будет произносится как «крах». Я не уверен, что это одинаково подходящее имя.
jpmc26
56
Это хороший эксперимент ... но вы должны знать, что system()делает под капотом. Оказывается, system()будет порожден процесс оболочки! Таким образом, ваш процесс оболочки порождает другой процесс оболочки, и этот процесс оболочки (возможно, /bin/shили что-то в этом роде) является тем, который запускает программу. Способ /bin/shили bashработа заключается в использовании fork()и exec()(или другой функции в execve()семье).
Дитрих Эпп
4
@BradenBest: Точно. Прочитайте страницу руководства man 2 wait, она будет включать макросы WIFSIGNALED()и WTERMSIG().
Дитрих Эпп
4
@DietrichEpp Как вы и сказали! Я попытался добавить проверку, (WIFSIGNALED(status) && WTERMSIG(status) == 11)чтобы он напечатал что-то глупое ( "YOU DUN GOOFED AND TRIGGERED A SEGFAULT"). Когда я запустил segfaultпрограмму изнутри crsh, она напечатала именно это. Между тем команды, которые обычно выходят, не выдают сообщение об ошибке.
Брэден Бест

Ответы:

248

Все современные процессоры способны прерывать выполняющуюся в данный момент машинную инструкцию. Они сохраняют достаточно состояния (обычно, но не всегда, в стеке), чтобы впоследствии можно было возобновить выполнение, как будто ничего не произошло (прерванная инструкция будет перезапущена с нуля, как правило). Затем они начинают выполнять обработчик прерываний , который представляет собой просто машинный код, но размещается в специальном месте, чтобы процессор знал заранее, где он находится. Обработчики прерываний всегда являются частью ядра операционной системы: компонент, который работает с наибольшей привилегией и отвечает за контроль выполнения всех других компонентов. 1,2

Прерывания могут быть синхронными , что означает, что они запускаются самим ЦП как прямой ответ на то, что выполняла текущая инструкция, или асинхронными , что означает, что они происходят в непредсказуемое время из-за внешнего события, такого как данные, поступающие в сеть порт. Некоторые люди резервируют термин «прерывание» для асинхронных прерываний и вместо этого называют синхронные прерывания «ловушками», «ошибками» или «исключениями», но все эти слова имеют другие значения, поэтому я буду придерживаться «синхронного прерывания».

В настоящее время большинство современных операционных систем имеют представление о процессах . По своей сути, это механизм, с помощью которого компьютер может запускать более одной программы одновременно, но это также является ключевым аспектом того, как операционные системы конфигурируют защиту памяти , что является особенностью большинства (но, увы, еще не все ) современные процессоры. Это идет вместе с виртуальной памятью, которая является способностью изменять отображение между адресами памяти и фактическими местоположениями в RAM. Защита памяти позволяет операционной системе предоставлять каждому процессу свой частный фрагмент оперативной памяти, к которому имеет доступ только он. Это также позволяет операционной системе (действующей от имени какого-либо процесса) определять области ОЗУ как доступные только для чтения, исполняемые, совместно используемые группой взаимодействующих процессов и т. Д. Также будет фрагмент памяти, доступный только для ядро. 3

Пока каждый процесс обращается к памяти только так, как это разрешено центральным процессором, защита памяти невидима. Когда процесс нарушает правила, процессор сгенерирует синхронное прерывание, попросив ядро ​​разобраться. Регулярно случается, что процесс на самом деле не нарушает правила, только ядру нужно проделать некоторую работу, прежде чем процесс можно будет продолжить. Например, если страницу памяти процесса необходимо «выселить» в файл подкачки, чтобы освободить место в ОЗУ для чего-то другого, ядро ​​пометит эту страницу как недоступную. В следующий раз, когда процесс попытается его использовать, процессор сгенерирует прерывание защиты памяти; ядро извлечет страницу из раздела подкачки, вернет ее на прежнее место, снова отметит ее как доступную и возобновит выполнение.

Но предположим, что процесс действительно нарушил правила. Он пытался получить доступ к странице, на которой никогда не было отображено ОЗУ, или пытался выполнить страницу, которая помечена как не содержащая машинный код, или что-то еще. Семейство операционных систем, обычно известных как «Unix», использует сигналы для решения этой ситуации. 4 Сигналы похожи на прерывания, но они генерируются ядром и отправляются процессами, а не генерируются оборудованием и отправляются ядром. Процессы могут определять обработчики сигналовв своем собственном коде и сообщить ядру, где они находятся. Затем эти обработчики сигналов будут выполняться, прерывая нормальный поток управления, когда это необходимо. Все сигналы имеют номер и два имени, одно из которых является загадочным сокращением, а другое - несколько менее загадочной фразой. Сигнал, который генерируется, когда процесс нарушает правила защиты памяти, - это (по соглашению) номер 11, а его имена SIGSEGVи «Ошибка сегментации». 5,6

Важное различие между сигналами и прерываниями заключается в том, что для каждого сигнала существует поведение по умолчанию . Если операционная система не может определить обработчики для всех прерываний, это ошибка в ОС, и весь компьютер рухнет, когда процессор попытается вызвать отсутствующий обработчик. Но процессы не обязаны определять обработчики сигналов для всех сигналов. Если ядро ​​генерирует сигнал для процесса, и этот сигнал был оставлен в своем поведении по умолчанию, ядро ​​просто пойдет дальше и сделает то, что по умолчанию, и не будет беспокоить процесс. Поведение большинства сигналов по умолчанию - «ничего не делать» или «завершить этот процесс и, возможно, также создать дамп ядра». SIGSEGVявляется одним из последних.

Итак, подведем итог: у нас есть процесс, который нарушает правила защиты памяти. Процессор приостановил процесс и сгенерировал синхронное прерывание. Ядро выставило это прерывание и сгенерировало SIGSEGVсигнал для процесса. Давайте предположим, что процесс не настроил обработчик сигнала SIGSEGV, поэтому ядро ​​выполняет поведение по умолчанию, которое заключается в прекращении процесса. Это имеет тот же эффект, что и _exitсистемный вызов: открытые файлы закрываются, память освобождается и т. Д.

До этого момента ничто не распечатывало никаких сообщений, которые может видеть человек, и оболочка (или, в более общем случае, родительский процесс только что завершившегося процесса) вообще не была задействована. SIGSEGVидет к процессу, который нарушил правила, а не к его родителю. Следующий шаг в последовательности, однако, является то, чтобы уведомить об этом родительском процессе , что его ребенок был прекращен. Это может произойти несколько различных способов, простейшие из которых являются , когда родитель уже ждет этого уведомления, используя один из waitсистемных вызовов ( wait, waitpid, wait4и т.д.). В этом случае ядро ​​просто вызовет возврат этого системного вызова и предоставит родительскому процессу кодовый номер, называемый состоянием выхода., 7 Статус выхода информирует родителя, почему дочерний процесс был прерван; в этом случае он узнает, что дочерний процесс был прекращен из-за поведения SIGSEGVсигнала по умолчанию .

Затем родительский процесс может сообщить о событии человеку, напечатав сообщение; программы оболочки почти всегда делают это. Ваш crshкод для этого не включен, но в любом случае это происходит, потому что подпрограмма библиотеки C systemзапускает полнофункциональную оболочку /bin/sh«под капотом». crshявляется прародителем в этом сценарии; отправляется уведомление родительского процесса /bin/sh, которое печатает свое обычное сообщение. Затем /bin/shсам завершает работу, так как ему больше нечего делать, и реализация библиотеки C systemполучает это уведомление о выходе. Вы можете увидеть это уведомление о выходе в своем коде, проверив возвращаемое значениеsystem; но это не скажет вам, что процесс внука умер в результате segfault, потому что он был поглощен промежуточным процессом оболочки.


Сноски

  1. Некоторые операционные системы не реализуют драйверы устройств как часть ядра; однако все обработчики прерываний по-прежнему должны быть частью ядра, как и код, который настраивает защиту памяти, потому что аппаратное обеспечение не позволяет ничего, кроме ядра, делать эти вещи.

  2. Может существовать программа под названием «гипервизор» или «менеджер виртуальной машины», которая даже более привилегирована, чем ядро, но для целей этого ответа ее можно считать частью аппаратного обеспечения .

  3. Ядро - это программа , но это не процесс; это больше похоже на библиотеку. Все процессы время от времени выполняют части кода ядра в дополнение к своему собственному коду. Может быть несколько «потоков ядра», которые только исполняют код ядра, но они нас здесь не касаются.

  4. Единственная ОС, с которой вам, скорее всего, придется иметь дело, которая не может считаться реализацией Unix, - это, конечно, Windows. Он не использует сигналы в этой ситуации. (Действительно, он не имеет сигналов; в Windows <signal.h>интерфейс полностью подделан библиотекой C.) Вместо этого он использует то, что называется « структурной обработкой исключений ».

  5. Некоторые нарушения защиты памяти генерируют SIGBUS(«Ошибка шины») вместо SIGSEGV. Граница между ними не указана и варьируется от системы к системе. Если вы написали программу, для которой определен обработчик SIGSEGV, вероятно, будет хорошей идеей определить тот же обработчик SIGBUS.

  6. «Ошибка сегментации» - это имя прерывания, сгенерированного для нарушений защиты памяти одним из компьютеров, на которых работал исходный Unix , возможно, PDP-11 . « Сегментация » - это тип защиты памяти, но в настоящее время термин « ошибка сегментации » в общем относится к любому виду нарушения защиты памяти.

  7. Все остальные способы, которыми родительский процесс может быть уведомлен о завершении дочернего процесса, заканчиваются вызовом родительского процесса waitи получением статуса выхода. Просто сначала что-то происходит.

zwol
источник
@zvol: объявление 2) Я не думаю, что правильно сказать, что процессор знает что-нибудь о процессах. Вы должны сказать, что он вызывает обработчик прерываний, который передает управление.
user323094
9
@ user323094 Современные многоядерные процессоры действительно немного знают о процессах; достаточно, чтобы в этой ситуации они могли приостановить только поток выполнения, вызвавший ошибку защиты памяти. Также я старался не вдаваться в детали низкого уровня. С точки зрения программиста пользовательского пространства, наиболее важная вещь, которую нужно понять в шаге 2, - это то, что именно аппаратное обеспечение обнаруживает нарушения защиты памяти; менее важно точное разделение труда между аппаратным обеспечением, встроенным программным обеспечением и операционной системой, когда дело доходит до выявления «процесса нарушения».
zwol
Другая тонкость, которая может сбить с толку наивного читателя: «Ядро отправляет нарушающему процессу сигнал SIGSEGV». который использует обычный жаргон, но на самом деле означает, что ядро ​​говорит себе, что имеет дело с сигналом foo на панели процессов (т. е. код пользовательского пространства не участвует, если не установлен обработчик сигнала, вопрос, который решается ядром). По этой причине я иногда предпочитаю «подавать сигнал SIGSEGV на процесс» .
dmckee
2
Существенное различие между SIGBUS (ошибка шины) и SIGSEGV (ошибка сегментации) заключается в следующем: SIGSEGV возникает, когда ЦП знает, что вам не нужно обращаться к адресу (и поэтому он не делает никакого запроса внешней шины памяти). SIGBUS возникает, когда ЦП узнает о проблеме адресации только после того, как он поместил ваш запрос на свою внешнюю адресную шину. Например, запрос физического адреса, на который ничего на шине не отвечает, или запрос чтения данных на неправильно выровненной границе (что потребовало бы двух физических запросов, чтобы получить вместо одного)
Стюарт Кей
2
@StuartCaie Вы описываете поведение прерываний ; действительно, многие процессоры проводят различие, которое вы выделяете (хотя некоторые этого не делают, и грань между ними различается). Сигналы SIGSEGV и SIGBUS, однако, не достоверно отображены на этих двух условий процессора уровня. Единственное условие, когда POSIX требует SIGBUS, а не SIGSEGV, - это когда вы помещаете mmapфайл в область памяти, которая больше, чем файл, и затем получаете доступ к «целым страницам» за концом файла. (POSIX в остальном довольно расплывчато, когда случается SIGSEGV / SIGBUS / SIGILL / и т. Д.)
zwol
42

Оболочка действительно имеет отношение к этому сообщению и crshкосвенно вызывает оболочку, что, вероятно, и есть bash.

Я написал небольшую программу на C, которая всегда вызывает ошибки:

#include <stdio.h>

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

        *i = 12;

        return 0;
}

Когда я запускаю его из моей оболочки по умолчанию zsh, я получаю это:

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

Когда я запускаю его bash, я получаю то, что вы отметили в своем вопросе:

bediger@flq123:csrc % ./segv
Segmentation fault

Я собирался написать обработчик сигнала в моем коде, я понял , что system()вызов библиотеки используется crshExec это оболочка, в /bin/shсоответствии с man 3 system. Это /bin/shпочти наверняка распечатка "Ошибка сегментации", поскольку, crshконечно, нет.

Если вы переписываете crshиспользовать execve()системный вызов для запуска программы, вы не увидите «ошибка» Сегментации строки. Это происходит из оболочки, вызванной system().

Брюс Эдигер
источник
5
Я только что обсуждал это с Дитрихом Эппом. Я взломал версию crsh, которая использует execvpи провел тест снова, чтобы обнаружить, что, хотя оболочка все еще не падает (то есть SIGSEGV никогда не отправляется в оболочку), она не печатает «Ошибка сегментации». Ничего не печатается вообще. Кажется, это указывает на то, что оболочка обнаруживает, когда ее дочерние процессы уничтожаются, и отвечает за печать «Ошибка сегментации» (или некоторый ее вариант).
Брэден Бест
2
@BradenBest - я сделал то же самое, мой код медленнее, чем ваш код. Я вообще не получил сообщения, и моя даже более хрупкая оболочка ничего не печатает. Я использовал waitpid()на каждом форке / exec, и он возвращает другое значение для процессов, которые имеют ошибку сегментации, чем процессы, которые выходят с 0 статусом.
Брюс Эдигер
21

Похоже, я не могу найти какую-либо информацию по этому поводу, кроме того, что «MMU ЦП посылает сигнал» и «ядро направляет его в вызывающую программу, завершая ее».

Это немного искаженное резюме. Сигнальный механизм Unix полностью отличается от специфичных для процессора событий, запускающих процесс.

Как правило, при обращении к неверному адресу (или при записи в область только для чтения, попытке выполнить неисполняемый раздел и т. Д.) ЦП будет генерировать какое-то специфичное для ЦП событие (в традиционных архитектурах, отличных от ВМ, это было называется нарушением сегментации, поскольку каждый «сегмент» (традиционно исполняемый «текст» только для чтения, «данные» с возможностью записи и переменной длины и стек традиционно на противоположном конце памяти) имеет фиксированный диапазон адресов - в современной архитектуре это скорее будет ошибка страницы [для не отображенной памяти] или нарушение прав доступа [для проблем с правами чтения, записи и выполнения], и я сосредоточусь на этом до конца ответа).

Теперь на этом этапе ядро ​​может делать несколько вещей. Ошибки страницы также генерируются для памяти, которая является действительной, но не загружена (например, выгружена, или в файле mmapped, и т. Д.), И в этом случае ядро ​​отобразит память и затем перезапустит пользовательскую программу из инструкции, которая вызвала ошибка. В противном случае он отправляет сигнал. Это не совсем «направляет [исходное событие] к программе-нарушителю», поскольку процесс установки обработчика сигналов отличается и в основном не зависит от архитектуры, в отличие от того, если программа должна была имитировать установку обработчика прерываний.

Если в пользовательской программе установлен обработчик сигналов, это означает создание стекового фрейма и установку позиции выполнения пользовательской программы в обработчике сигналов. То же самое делается для всех сигналов, но в случае нарушения сегментации, как правило, все устроено так, что, если обработчик сигнала возвращается, он перезапустит инструкцию, вызвавшую ошибку. Пользовательская программа могла исправить ошибку, например, путем сопоставления памяти с адресом-нарушителем - это зависит от архитектуры, возможно ли это). Обработчик сигнала также может перейти в другое место в программе (обычно через longjmp или с помощью исключения), чтобы прервать любую операцию, вызвавшую плохой доступ к памяти.

Если в пользовательской программе не установлен обработчик сигнала, он просто завершается. На некоторых архитектурах, если сигнал игнорируется, он может перезапускать инструкцию снова и снова, вызывая бесконечный цикл.

Random832
источник
+1, только ответ, который добавляет что-либо к принятому. Хорошее описание истории «сегментации». Интересный факт: x86 на самом деле все еще имеет ограничения сегментов в 32-битном защищенном режиме (с включенным или не включенным подкачкой (виртуальная память)), поэтому инструкции, которые могут генерировать доступ к памяти #PF(fault-code)(сбой страницы) или #GP(0)(«Если эффективный адрес операнда памяти находится за пределами CS, Предел сегмента DS, ES, FS или GS. "). В 64-битном режиме пропускаются проверки предельных значений сегментов, поскольку операционные системы просто используют подкачку, а модель плоской памяти для пользовательского пространства.
Питер Кордес
На самом деле, я полагаю, что большинство ОС на x86 используют сегментированную нумерацию страниц: кучу больших сегментов в плоском страничном адресном пространстве. Вот как вы защищаете и отображаете память ядра в каждое адресное пространство: кольца (уровни защиты) связаны с сегментами, а не страницами
Lorenzo Dematté
Кроме того, в NT (но я хотел бы знать, является ли в большинстве Unix-систем одинаковым!) «Ошибка сегментации» может происходить довольно часто: в начале пользовательского пространства имеется защищенный сегмент размером 64 КБ, поэтому разыменование указателя NULL вызывает (правильно?) ошибка сегментации
Lorenzo Dematté
1
@ LorenzoDematté Да, все современные или почти все Unix-системы оставят часть постоянно не отображенных адресов в начале адресного пространства, чтобы перехватывать пустые разыменования. Он может быть довольно большим - в 64-битных системах, фактически, это может быть четыре гигабайта , так что случайное сокращение указателей до 32 бит будет обнаружено быстро. Однако сегментация в строгом смысле x86 практически не используется; есть один плоский сегмент для пользовательского пространства и один для ядра, и, возможно, пара для специальных приемов, таких как использование FS и GS.
zwol
1
@ LorenzoDematté NT использует исключения, а не сигналы; в этом случае STATUS_ACCESS_VIOLATION.
Random832
18

Ошибка сегментации - это доступ к адресу памяти, который не разрешен (не является частью процесса, или пытается записать данные только для чтения, или выполнить неисполняемые данные, ...). Это перехватывается MMU (блок управления памятью, сегодня часть ЦП), вызывая прерывание. Прерывание обрабатывается ядром, которое отправляет SIGSEGFAULTсигнал (см., signal(2)Например) процессу-нарушителю. Обработчик по умолчанию для этого сигнала сбрасывает ядро ​​(см. core(5)) И завершает процесс.

Оболочка не имеет к этому никакого отношения.

vonbrand
источник
3
Итак, ваша библиотека C, как и glibc на рабочем столе, определяет строку?
Дрюбенн
7
Также стоит отметить, что SIGSEGV может обрабатываться / игнорироваться. Таким образом, можно написать программу, которая не завершается этим. Виртуальная машина Java - один известный пример, который использует SIGSEGV внутренне для различных целей, как упомянуто здесь: stackoverflow.com/questions/3731784/…
Karol Nowak
2
Аналогично, в Windows .NET не беспокоится о добавлении проверок нулевого указателя в большинстве случаев - он просто отслеживает нарушения доступа (эквивалентно ошибкам сегмента).
immibis