Немного о «мертвом коде» +14


А вдоль дороги мертвые с косами стоят

Термин «мертвый код» - это, скорее, жаргонное, чем научное название участков программы, на которые не может попасть управление и, таким образом, они никогда не выполняются. Разумеется, в нормальных программах таких участков быть не должно. Но поскольку языки программирования становятся все сложнее и сложнее (а программисты все тупее и тупее, шутка!) в кодах программ может быть все, что угодно.

Поэтому в компиляторах одна из обычных задач на этапе оптимизации – это выявление и выбрасывание невыполняемых участков программы.

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

В своей работе я использую очень маленький компилятор, который сам же и сопровождаю, и, по мере сил, совершенствую. Оптимизатор в этом компиляторе работает на самом низком уровне – практически на уровне команд x86-64. У такой локальной или, как я ее называю, «тактической» оптимизации возможности скромнее, чем, например, у оптимизаторов кода LLVM, но зато и некоторые локальные задачи оптимизации, в том числе выбрасывание «мертвого кода», становятся тривиальными.

В моем компиляторе на этапе обработки внутреннего представления программы каждая операция этого внутреннего представления вызывает сразу же формирование одной или нескольких команд x86. Но первоначально эти команды представляют собой лишь внутренние структуры компилятора, организованные в двусторонний связанный список. И поля будущего двоичного кода x86 в этих структурах пока пустые, а длина кода – нулевая.

Поэтому такие заготовки команд при оптимизации легко переставлять/исключать/заменять. И после этапа генерации двоичного кода эти команды все еще можно исключать, даже не выбрасывая из связанного списка, а просто обнуляя поле длины команды (т.е. делая их опять «пустыми»).

Помнится, как-то задавали вопрос: сколько команд процессора используют компиляторы? В моем случае (не считая команд FPU) – 90 штук, и не все они на самом деле являются командами x86. Например, в это число входят и команды «метка», которые, конечно, никакого кода не имеют, но имеют адрес в коде, как и обычные команды x86. Меток-«команд» есть две разновидности: метка компилятора, которую тот ставит, например, при генерации кодов условного оператора, и метка программиста, которую программист имеет право сам поставить почти в любом месте исходного текста. Разумеется, имена описываемых в исходном тексте подпрограмм и функций – это тоже команды-«метки».

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

Таким образом, одна-единственная проверка в компиляторе, выполняемая к тому же попутно при подготовке к формированию кода x86, позволяет без громоздкого анализа исходного текста программы выявить и сразу удалить недостижимые участки кода.

Правда, дело на этом не заканчивается. Компилятору теперь еще требуется определить, а кто эту недостижимость создал, он сам или программист в исходном тексте?

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

Простейший пример. Вся программа состоит из одного «бесконечного» цикла чтения и обработки файла. Перед циклом записан обработчик конца файла.

test:proc main;
dcl f file; 
on endfile(f) stop;
do repeat;
// здесь читаем и обрабатываем содержимое файла
end repeat;
end test;

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

Но если поместить какой-либо непомеченный оператор перед строкой "end test;" – этот оператор будет удален уже с предупреждением.

Правда, если разместить там помеченный оператор, но на эту метку нигде не переходить в тексте программы, что должен делать компилятор? Удалять или не удалять?

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

А в некоторых случаях «мертвый код» может даже принести пользу. В приведенном выше примере неявный return в конце подпрограммы не мог привести к ошибке, независимо от того, выкинул его компилятор или нет. Но, например, в языке PL/1 (компилятор с которого я и описываю) есть потенциальная опасность неприятных ошибок, связанных с описанием функций.

В обычной подпрограмме (т.е. в процедуре в терминах PL/1 или в функции, возвращающей void, в терминах Си) выход происходит или по явному return или по достижению конца «тела» подпрограммы, куда компилятором всегда подставляется неявный return. А вот для функции обязательно нужен явный оператор return со значением. И в PL/1 описание процедур и функций отличается друг от друга только заголовком и видом операторов return, которые можно размещать где угодно и как угодно, причем в исходном тексте процедур return может не быть вообще, а в тексте функций обязательно они должны быть. Вот тут-то и появляется опасность ошибки.

Конечно, компилятор проверяет, что в исходном тексте функции есть хотя бы один return, но ошибка может быть тоньше.

Например, в случае, если текст какой-нибудь функции f оканчивается выражением вроде:

… if x>0 then return(1); else return(-1); end f;

то все в порядке, всегда будет выход со значением функции.

Но, если я, сморозив глупость, написал что-нибудь вроде:

… if x>0 then return(1); if x<0 then return(-1); end f;

то в случае x=0 становится возможным достижение конца исходного текста функции без вычисления какого-либо ее значения, хотя return и имеются. И независимо от того, стоит ли в конце еще и неявный return или нет, ничего хорошего из этого не выйдет.

Для обнаружения таких неприятных ошибок без громоздкого анализа исходного текста компилятор и использует «мертвый код». А именно, в конце каждой процедуры-функции сначала обязательно ставится псевдокоманда func, которая тоже входит в пресловутые 90 команд. Она имеет двоичный код останова по контрольной точке (байт 0CCH) и на следующих этапах компиляции должна быть именно как «мертвый код» и выброшена. Если же она сохранилась в связанном списке будущих команд x86, значит этот код не «мертвый» и потенциально возможно попадание управления в эту точку. Следовательно, можно выдавать предупреждение, что из такой-то функции возможен выход без значения. А ошибку в этом примере можно было бы исправить как-нибудь так:

… if x>0 then return(1); if x<0 then return(-1); return(0); end f;

и тогда и предупреждение и код 0CCH исчезают.

Если же не обращать внимания на предупреждение, и все-таки запускать программу с такой потенциальной ошибкой, то в случае, если она действительно произойдет, из-за оставшегося кода 0CCH программа вылетит по исключению «контрольная точка» (или выйдет в интерактивный отладчик), что гораздо лучше молчаливой поломки в непредсказуемом месте.

Таким образом, «мертвый код» - это суровые реалии программирования. Он может возникнуть как в результате работы компилятора (особенно при оптимизации), так и в результате ошибок в программе. В некоторых случаях невыполняемый участок программы может быть даже сделан намеренно, например, при отладке в исходный текст может быть специально вставлен оператор перехода/возврата, чтобы пока не выполнялась бы какая-то часть программы.

В любом случае от компиляторов обычно требуется такие участки кода обнаруживать и, если они возникают по вине программиста, то необходимо предупреждать его об этом. С точки зрения размера программы целесообразно, конечно же, удалять весь «мертвый код». Хотя его наличие само по себе не должно влиять на работу программы.




Комментарии (17):

  1. alan008
    /#24491586 / +2

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

    2) Если это наш код и он не нужен временно, его конечно можно удалить, но потом будет сложно искать его в истории контроля версий, т. к. для этого нужно помнить, где он был..

    Мне тоже не нравится мертвый код, но приведенные выше соображения мешают избавляться от него "в безусловном порядке".

    • berez
      /#24492018 / +2

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

      Ну вообще тут возможны варианты. Если библиотека статическая, то из нее будут выброшены объектные файлы, на содержимое которых нет ссылок. А если компилятор и линкер поддерживают IPO (interprocedural optimization), то выкидывание неиспользуемого кода будет жощще и из библиотеки будут выкинуты вообще все функции, на которые нет ссылок.

      2) Если это наш код и он не нужен временно, его конечно можно удалить, но потом будет сложно искать его в истории контроля версий, т. к. для этого нужно помнить, где он был…

      Дык /* многострочные комменты */ же ж!
      Или блоки #if 0 / #endif, если речь о C или С++.

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

      Нормально все.
      Гораздо хуже, когда библиотека или фреймворк настолько суровые, что внутри себя используют огромную кучу всякого. В результате использование даже маленькой части функционала приводит к раздуванию кода.
      Например, если в программе, использующей фреймворк Qt и собранной статически, просто открыть файл (QFile), то код моментально раздуется на несколько сот килобайт. А все потому, что в коде есть проверки на то, что имя файла — это не URL-адрес. А проверка на URL использует зашитый в программу список «известных сайтов». И хотя формально это не мертвый код, вряд ли он когда-либо понадобится среднестатистической программе.

      • alan008
        /#24495060

        Причем тут линковка, в статье речь о выкидывании мертвого кода ИЗ ИСХОДНИКОВ. Я понимаю, что он НЕ слинкуется в exe-шник, даже если он и НЕ закомментарен, но нигде не вызывается. Но статья о том, что в исходниках не должно быть такого кода.

    • ioncorpse
      /#24494238

      Если это наш код и он мертвый - значит что-то у нас не так. Кроме нюансов, но тогда он саспендится и там подробный коммент.

      • alan008
        /#24495040

        Согласен, как-то так и делаем.

  2. Gumanoid
    /#24491612 / +3

    Тут описан только недостижимый код, который является подмножеством мёртвого кода. В общем случае, мёртвый код — это любой код, который не имеет видимых результатов (он может быть как достижимом, так и не достижимым). Например, запись в регистр без последующего чтения из него.

    • lex899
      /#24494242

      Вас послушать дак любой benchmark (алгоритма или железа) или wipe data является мёртвым кодом.

      • Gumanoid
        /#24494366

        Если benchmark плохо написан, то да. Надо или возвращать результат вычислений из программы или говорить компилятору не делать оптимизаций. С этим сталкивался, наверное, автор любого бенчмарка, вот например AnTuTu.

        А насчёт wipe data, зависит от того каких данных — если на диске, то это видимый результат, а если в памяти, то такой код тоже может быть удалён.

        • lex899
          /#24494650

          Надо или возвращать результат вычислений из программы

          И получить overhead на обман компилятора и работать это будет пока компилятор не поумнеет и не поймет что вы возвращаете результат в никуда.

          или говорить компилятору не делать оптимизаций

          не делать всех оптимизаций? тогда мы получаем benchmark совсем другого неоптимизированного кода. КМК это повод для warning но никак не для слепой оптимизации.

          если на диске, то это видимый результат, а если в памяти, то такой код тоже может быть удалён.

          Дак в статье и написано что нужно "обманывать" компилятор чтобы программа работала как положено. Стирание памяти это не только пароли, но и часть защиты от отладки\взлома например. Что касается записи на диск - видимым будет результат последней записи, значит предыдущие 10 проходов можно оптимизировать, а если следом DeleteFileA дергается то и последний проход не нужен.

          Я понимаю когда undefined behavior, тут я не совсем согласен с логикой, но да, мы имеем то что имеем.

    • Keeper13
      /#24494340

      Что если это будет управляющий регистр, или порт вывода?

      • Gumanoid
        /#24494386

        Скорее всего такой код будет написан или в виде ассемблерной вставки или обложен volatile.

  3. dyadyaSerezha
    /#24492672

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

    • IvanSTV
      /#24494588

      хз, такой код может иметь влияние при отладке или для расширения внешними приложениями.

  4. torinbds
    /#24495600

    В том случае, когда код действительно "мертвый" - согласен с автором. Но есть случаи, когда данные встраиваются в код (ну или код в данные). Например объявляем функцию/процедуру на ассемблере (псевдоязык - нам главное в сегмент кода поместить массив из байт)

    void MyDeathCode() {

    asm db AA,BB,CC,DD,0A,0B,0C,OD

    }

    Этот "код", хотя прямых вызовов его и нет - может быть использован и как хранилище для какого-то набора данных, так и быть вызван путем взятия указателя на начало процедуры + смещение на нужный нам участок от начала (указатель + смещение) и ничто не мешает в набор db поместить исполняемый код.

  5. Sergey_zx
    /#24496914

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

    Так что достоверно определить мертвый это код, или нет нереально.

  6. ulovka22
    /#24497166

    Что это, перевод какой-то статьи из 80-х годов прошлого века?