Адреса загрузки DLL
Когда приложение завершается аварийно, то большую помощь программисту оказывают некие вехи (т. е. указатели), позволяющие ему не заблудиться в отладчике.
Первым важным указателем для аварийных сбоев является базовый адрес ваших динамических библиотек (DLL) и элементов управления ActiveX (OCX), который указывает, с какой ячейки памяти начинается отведенное им адресное пространство. Когда заказчик сообщает адрес аварийного завершения, необходимо быстро сузить его до первых двух или трех цифр адреса DLL, из которого он пришел. Конечно, трудно запомнить адреса всех системных DLL, но надо знать, по крайней мере, базовые адреса DLL своего проекта.
Если все ваши DLL загружены в уникальные адреса, то имеется несколько хороших указателей, помогающих вести поиск аварийных остановов. Но что, вы думаете, случилось бы, если бы все DLL имели один и тот же адрес загрузки? Очевидно, что операционная система не отображает все DLL в одно и то же место памяти. Она должна перемещать любую входящую DLL, которая хочет занять уже заполненную память, в другое место. Тогда возникают проблемы, связанные с попытками вычислить, где какая DLL загружена. К сожалению, нет никакого способа узнать, что операционная система будет делать на различных машинах. Следовательно, программист понятия не имеет, откуда пришел аварийный останов, и ему придется потратить уйму времени на его поиск через отладчик.
По умолчанию, для проектов, созданных с помощью соответствующего мастера, Visual Basic загружает DLL-библиотеки по адресу 0x11000000, a Visual C++ — по адресу 0x10000000. Держу пари, что сегодня по крайней мере половина DLL-библиотек в мире пытается загрузиться по одному из этих адресов. Изменение базового адреса для DLL называется перебазированием (rebasing), и это — простая операция, в которой указывается адрес загрузки, отличающийся от умалчиваемого.
Прежде чем перейти к перебазированию, рассмотрим более легкий способ выяснить, имеются ли конфликты загрузки в ваших DLL. Получив следующее уведомление в окне Output отладчика Visual C++, следует немедленно остановиться и исправить адреса загрузки конфликтующих DLL.
Удостоверьтесь, что вы исправляете адреса загрузки как для выпускных (финальных), так и для отладочных конфигураций.
LDR: Dll xxx base 10000000 relocated due to collision with yyy
(LDR: Dll xxx база 10000000 перемещена из-за конфликта с yyy)
xxx и yyy в этом утверждении — имена DLL-библиотек, которые находятся в конфликте друг с другом.
В дополнение к трудностям в поиске аварийного останова, когда операционная система должна перемещать DLL, ваше приложение еще и замедляет свое выполнение. При перемещении операционная система должна прочитать всю информацию перемещения для DLL, найти каждое место в коде, которое имеет доступ к адресу в DLL, и изменить этот адрес, потому что DLL больше не находится на своем, предписанном ему, месте в памяти. Если в приложении имеется хотя бы пара конфликтов загрузочных адресов, то ( продолжительность его запуска иногда увеличивается более чем вдвое!
Существует два способа перебазирования DLL-библиотек в приложении. Первый метод использует утилиту REBASE.EXE, которая поставляется с набором разработчика Platform SDK. Утилита REBASE.EXE имеет много различных возможностей (опций), но лучший выбор состоит в ее вызове через командную строку с ключом /b, со стартовым базовым адресом и указанием в командной строке имен соответствующих DLL-файлов.
Данные, представленные в табл. 2.1, взяты из документации Platform SDK и могут быть применены для перебазирования пользовательских DLL. Как видите, рекомендованный формат достаточно прост. Динамические библиотеки операционной системы загружаются в адреса от 0x70000000 до 0x78000000, поэтому следование рекомендациям табл 2.1 предохранит вас от конфликта с операционной системой.
Таблица 2.1. Схема перебазирования DLL
Первая буква имени DLL-файла |
Стартовый адрес |
А-С |
0x60000000 |
D-F |
0x61000000 |
G-I |
0x62000000 |
J-L |
0x63000000 |
М-О |
0x64000000 |
P-R |
0x65000000 |
S-U |
0x66000000 |
V-X |
0x67000000 |
Y-Z |
0x68000000 |
Следующие три команды показывают, как нужно запускать REBASE.EXE с этими DLL:
REBASE /b 0x60000000 APPLE.DLL
REBASE /b 0x61000000 DUMPLING.DLL
REBASE /b 0x62000000 GINGER.DLL GOOSEBERRIES.DLL
Если несколько DLL-файлов передаются в REBASE.EXE в командной строке, как здесь показано для файлов GINGER.DLL и GOOSEBERRIES.DLL, то REBASE.EXE перебазирует их так, чтобы они были загружены друг за другом, начиная с указанного стартового адреса.
Другой метод перебазирования DLL состоит в спецификации адреса загрузки при компоновке DLL. В IDE Visual Basic установите адрес в поле DLL Base Address на вкладке Compile диалогового окна Project Properties. В Visual C++ укажите адрес в редактируемое поле Base Address, перейдя на вкладку Link диалогового окна Project Settings и выбрав там элемент Output в комбинированном списке Category. Visual C++ транслирует адрес, который вы вводите в поле Base Address в ключ /BASE компоновщика LINK.EXE.
С помощью утилиты REBASE.EXE можно автоматически обрабатывать одновременную установку множественных адресов загрузки DLL. Однако при установке адреса загрузки во время компоновки следует быть немного осторожнее. Если адреса загрузки нескольких DLL-файлов установлены слишком близко друг к другу, то в окне Ouput появляется сообщение перераспределения загрузчика. Фокус в том, чтобы установить загрузочные адреса достаточно далеко друг от друга (чтобы не беспокоиться о них после того, как вы их установили).
Для тех же DLL-файлов, что и в примере с REBASE.EXE, загрузочные адреса устанавливаются так:
APPLE.DLL 0x60000000
DUMPLING.DLL. 0x61000000
GINGER.DLL 0x62000000
GOOSEBERRIES.DLL 0x62100000
Два файла — GINGER.DLL и GOOSEBERRIES.DLL - интересны потому, что их имена начинаются с одного и того же знака. Когда это случается, адреса загрузки дифференцируются по третьей старшей цифре.
В случае добавления еще одного DLL-файла, имя которого начиналось бы с символа "G", его адрес загрузки был бы 0x62200000.
Для того чтобы увидеть проект, в котором адреса загрузки установлены вручную, посмотрите на проект WDBG в разделе "WDBG: реальный отладчик" главы 4. Ключ /BASE позволяет также указать текстовый файл, содержащий адреса загрузки для каждого DLL в приложении (как это сделано в проекте WDBG).
Хотя перебазировать DLL- и OCX-файлы может как метод, использующий REBASE.EXE, так и ручное перебазирование, но лучше следовать второму способу и перебазировать DLL-файлы вручную. Именно вручную были перебазированы DLL-файлы всех примеров на сопровождающем компакт-диске данной книги. Главное достоинство этого метода заключается в том, что устанавливаемый специфический адрес будет содержаться в МАР- файле. МАР- файл — это текстовый файл, который указывает, куда компоновщик помещает все символы и исходные строки программы. В выпускной конфигурации всегда следует создавать МАР- файлы, потому что они — единственное прямое текстовое представление символов, которое можно получить.
МАР- файл содержит карту распределения глобальных символических имен (символов) конкретного приложения в памяти компьютера. Файл необязательный, он создается компоновщиком конкретной системы программирования (по запросу разработчика, через специальные ключи /MAP компоновщика) и имеет расширение .MAP. Описание состава, структуры и методики использования МАР-файлов для отладки приложений приводятся в главе 8. — Пер
МАР- файлы окажутся особенно удобны в будущем, когда потребуется найти положение точки аварийного останова, а текущая версия отладчика не сможет прочитать старые символы. Если вместо ручного перебазирования DLL используется REBASE.EXE, то МАР -файл, созданный компоновщиком, будет содержать первоначальный базовый адрес, и нужно будет сделать некоторые арифметические вычисления, чтобы преобразовать адрес в МАР- файле в перебазированный адрес. В главе 8 МАР- файлы рассматриваются более подробно.
Очень серьезным является вопрос о том, какие именно файлы надо перебазировать. Практическое правило довольно простое: если код написали вы или кто-то из вашей команды, то перебазируйте его. Если используются компоненты независимых поставщиков, то все двоичные файлы нужно будет установить вокруг них.
Общий вопрос отладки
Какие дополнительные параметры компилятора и компоновщика помогут мне с упреждающей отладкой?
Параметры (ключи) компилятора и компоновщика помогают управлять выполнением и облегчают отладку приложений. Установки по умолчанию, которые обеспечивают мастера проектов Visual C++ для компилятора и компоновщика, годятся не на все случаи жизни. Поэтому некоторые из них приходится изменять.
Параметры (ключи) компилятора CL.EXE
Все ключи компилятора можно ввести с клавиатуры непосредственно в поле редактирования Project Options в нижней части вкладки C/C++ диалогового окна Project Settings.
IP (препроцессорная обработка файла)
Если у вас неприятности с макросами, ключ /р будет предварительно обрабатывать ваш исходный файл, расширяя все макросы, включая все include-файлы и посылая вывод в файл с тем же именем, но с расширением .1. Чтобы увидеть, как расширен ваш макрос, вы можете заглянуть в М-файл. Удостоверьтесь, что на диске имеете достаточно места, потому что М-файлы могут иметь объем в несколько мегабайт каждый. Если они слишком велики, то можно использовать ключ /ЕР (совместно с /р), чтобы подавить директивы #line, выводимые препроцессором. Директивы #line используются препроцессором для координации номеров строк и имен исходных файлов в файле препроцессора, так что компилятор может сообщать о расположении ошибок компиляции.
/X (игнорировать стандартные пути)
Получение правильной конфигурации приложения может иногда быть затруднено, если на машине разработчика установлено несколько компиляторов и SDK. Если этот ключ не указывается, то компилятор при вызове из МАК-файла будет использовать переменную среды INCLUDE. Для того чтобы точно управлять включением конкретных файлов заголовков, применяется ключ /х, заставляющий компилятор игнорировать переменную среды INCLUDE и искать файлы заголовков только в тех местах, которые явно указаны в ключе /I.
/Zp (выравнивать члены структур)
Разработчик не должен использовать этот флаг. Вместо того чтобы указывать в командной строке, как члены структуры должны быть выровнены в памяти, это следует сделать при помощи директивы ttpragma pack внутри конкретного файла заголовка. Источником ошибок является то, что команда разработчиков выполняла построение приложений, изначально установив ключ /Zp. Требуется много времени, чтобы найти такие ошибки.
/GZ (отлавливать ошибки конфигурации версии в отладочной конфигурации)
В Visual C++ 6 введено выдающееся отладочное свойство, при включении которого компилятор после вызовов функций автоматически инициализирует их локальные переменные и проверяет стек вызовов. Этот флаг включен по умолчанию для отладочных конфигураций, но можно также использовать его в конфигурациях версии. Если возникают неприятности с чтением неинициализированной памяти (wild reads), записью неинициализированной памяти (wild writes) или перезаписью памяти, создайте новую проектную конфигурацию, которая основана на конфигурации версии и добавьте данный ключ к параметрам компиляции. Просматривая локальные переменные, заполненные во время их создания значениями ОхСС, можно попытаться понять, что изменило их исходные значения в неподходящий момент.
Кроме того, ключ /GZ будет генерировать код, который сохраняет текущий указатель стека перед косвенным вызовом функции (таким как вызов DLL-функции) и подтверждает, что указатель стека остается неизменным после вызова. Подтверждение правильности указателя стека предохраняет от одной из наиболее коварных ошибок описания, противоречащего соглашениям о вызовах. Эта ошибка происходит, когда вызываемая функция, специфицированная как _stdcall, неправильно объявлена со спецификатором _cdecl. Эти два спецификатора по-разному чистят стек, что позже приводит программу к аварийному сбою, если программист нарушает данное соглашение о вызовах.
/О1 (минимизировать размер)
По умолчанию проект, созданный с помощью мастера AppWizard библиотеки классов Microsoft Foundation Class (MFC), использует ключ /02 (максимизировать скорость) для построения конфигураций версии.
Однако Microsoft строит все свои коммерческие приложения с ключом /01, который и следует указывать. В Microsoft нашли, что после выбора наилучшего алгоритма и записи плотного кода уход от страничных ошибок1может помочь значительно ускорить выполнение приложения.
Страничные ошибки происходят, когда выполняющийся код переходит с одной страницы памяти (4 Кбайт для процессоров Intel x86) на следующую. Чтобы исправить страничную ошибку, операционная система должна прекратить выполнение вашей программы и разместить новую страницу в CPU. Если страничная ошибка — "мягкая" (т. е. страница уже находится в памяти), то издержки не слишком ужасны, но это, тем не менее, дополнительные издержки.
Страничная ошибка (page fault) — ошибка из-за отсутствия страницы (ошибка, которая возникает в случае, когда процесс указывает на страницу виртуальной памяти, отсутствующую в рабочем наборе в главной памяти). — Пер.
Если же страничная ошибка "жесткая", то операционная система должна отправиться на диск и перенести страницу в память. Несложно сообразить, что эта "небольшое" путешествие заставит выполнить сотни тысяч инструкций, замедляя приложение. Минимизировав размер двоичного кода, вы уменьшаете общее количество страниц, используемых вашим приложением, сокращая, таким образом, число страничных ошибок. Предоставленные операционной системой загрузчики и кэш-менеджеры весьма хороши, но они почему-то дают много страничных ошибок.
В дополнение к использованию ключа /01, следует обратить внимание на применение утилиты Working Set Tuner (WST) из Platform SDK. Утилита WST поможет упорядочить наиболее часто вызываемые функции в начале двоичного файла так, чтобы минимизировать рабочий набор (число страниц, хранящихся в памяти). С общими функциями в начале операционная система сможет выполнять свопинг ненужных страниц. Таким образом, приложение будет выполняться быстрее. Подробнее об использовании WST, см. февральскую колонку "Bugslayer" (1999) в Microsoft Systems Journal на MSDN.
Ключи (параметры) компоновщика LINK.EXE
Эти ключи можно ввести с клавиатуры прямо в поле редактирования Project
Options в нижней части вкладки Link диалогового окна Project Settings.
/MAP (генерировать МАР-файл)
/MAPINFO:LINES (включать строчную информацию в МАР-файл)
/MAPINFO:EXPORTS (включать экспортную информацию в МАР-файл)
Эти ключи строят МАР-файл для связанного изображения. Следует всегда создавать МАР-файл, потому что это — единственный способ получить текстовую символическую информацию. Используйте все три ключа, чтобы гарантировать, что МАР-файл содержит наиболее полезную информацию.
/NODEFAULTLIB (ignore libraries)
Многие системные файлы заголовков включают записи #pragma comment (lib#, xxx), указывающие, с каким библиотечным файлом они связаны, где ххх — имя библиотеки. Ключ /NODEFAULTLIB сообщает, что компоновщик игнорирует директивы pragma. Этот ключ позволяет указать, с какими библиотеками надо держать связь и в каком порядке. Чтобы приложение имело связь с библиотеками, нужно указать каждую необходимую библиотеку в командной строке компоновщика, но по крайней мере нужно точно знать, какие библиотеки вы получаете и в каком порядке. Управление порядком, в котором связаны библиотеки, может быть достаточно важным, если один и тот же символ включен более чем в одну библиотеку, что может привести к ошибкам, которые очень трудно найти.
/ORDER (разместить функции по порядку)
После того как выполнена утилита WST, ключ /ORDER позволяет указать файл, который содержит упорядоченный список функций. Ключ /ORDER отменит инкре-ментную компоновку, так что указывайте его только в конфигурациях версии.
/PDBTYPE:CON (Объедините PDB-файлы)
Всегда указывайте ключ /PDBTYPEICON для всех конфигураций (как для выпускных, так и для отладочных). Для проектов Visual C++ этот параметр по
умолчанию не включен. Ключ /PDBTYPE:CON объединяет всю отладочную информацию модуля в единый PDB-файл. Наличие единственного PDB-файла существенно облегчает отладку одного и того же двоичного кода несколькими пользователями, а также упрощает архивирование отладочной информации.
/VERBOSE (печатать сообщения о ходе процесса)
/VERBOSE:LIB (печатать только сообщения о найденных библиотеках)
Если возникают затруднения с компоновкой, то эти сообщения могут показать, какие символы компоновщик ищет и где он их находит. Вывод может оказаться довольно громоздким, но покажет, где имеются проблемы, связанные с построением приложения. Я использовал ключи /VERBOSE и /VERBOSE : LIB, получив случайный сбой из-за того, что вызываемая функция выглядела (на уровне языка ассемблера) как-то не так, как должна была выглядеть, по моему представлению. Оказалось, что я имел две функции с идентичными сигнатурами, но различными реализациями в двух разных библиотеках, и компоновщик находил не ту, которая была нужна.
/WARN:3
Вообще-то этот ключ не нужен все время, но пару раз в течение жизни проекта программисту надо посмотреть, на какие библиотеки он фактически ссылается. Включив параметр /WARN:3, вы будете получать сообщения о том, имеются ли ссылки на библиотеки, переданные компоновщику LINK.EXE. Лично мне нравится точно знать, с какими библиотеками я связан, и я удаляю из списка компоновщика те библиотеки, на которые нет ссылок.