Было ли повреждение памяти распространенной проблемой в больших программах, написанных на языке ассемблера?
Ошибки, связанные с повреждением памяти, всегда были распространенной проблемой в больших программах и проектах на C. В то время это было проблемой в 4.3BSD, и это все еще проблема сегодня. Независимо от того, насколько тщательно написана программа, если она достаточно велика, часто можно обнаружить в коде еще одну ошибку чтения или записи, выходящую за рамки.
Но было время, когда большие программы, включая операционные системы, писались на ассемблере, а не на C. Были ли ошибки повреждения памяти распространенной проблемой в больших программах на ассемблере? И как это сравнивать с программами на C?
Ответы
Кодирование в сборке жесткое.
Указатели мошенников
Языки ассемблера еще больше полагаются на указатели (через регистры адресов), поэтому вы даже не можете полагаться на компилятор или инструменты статического анализа, чтобы предупредить вас о таких повреждениях памяти / переполнении буфера, в отличие от C.
Например, в C хороший компилятор может выдать предупреждение:
char x[10];
x[20] = 'c';
Это ограничено. Как только массив распадается на указатель, такие проверки проводить нельзя, но это начало.
В сборке без надлежащих исполняемых файлов или формальных исполняемых бинарных инструментов вы не можете обнаружить такие ошибки.
Мошеннические (в основном адресные) регистры
Еще одним отягчающим фактором для сборки является то, что соглашение о сохранении регистров и вызове подпрограмм не является стандартным / гарантированным.
Если процедура вызывается и не сохраняет по ошибке конкретный регистр, она возвращается к вызывающей стороне с измененным регистром (рядом с «временными» регистрами, которые, как известно, удаляются при выходе), а вызывающий не ожидает это, что приводит к чтению / записи по неверному адресу. Например, в коде 68k:
move.b d0,(a3)+
bsr a_routine
move.b d0,(a3)+ ; memory corruption, a3 has changed unexpectedly
...
a_routine:
movem.l a0-a2,-(a7)
; do stuff
lea some_table(pc),a3 ; change a3 if some condition is met
movem.l (a7)+,a0-a2 ; the routine forgot to save a3 !
rts
Использование подпрограммы, написанной кем-то другим, которая не использует те же соглашения о сохранении регистров, может привести к той же проблеме. Я обычно сохраняю все регистры перед тем, как использовать чужую процедуру.
С другой стороны, компилятор использует передачу параметров стека или стандартного регистра, обрабатывает локальные переменные с помощью стека / другого устройства, сохраняет регистры, если это необходимо, и все это согласовано во всей программе, что гарантируется компилятором (если нет ошибок, из курс)
Режимы несанкционированной адресации
Я исправил множество нарушений памяти в старых играх на Amiga. Запуск их в виртуальной среде с активированным MMU иногда вызывает ошибки чтения / записи в полных фиктивных адресах. В большинстве случаев эти операции чтения / записи не имеют никакого эффекта, потому что операции чтения возвращают 0, а записи уходят в лес, но в зависимости от конфигурации памяти это может иметь неприятные последствия.
Также были случаи ошибок адресации. Я видел такие вещи, как:
move.l $40000,a0
вместо немедленного
move.l #$40000,a0
в этом случае регистр адресов содержит содержимое $40000
(возможно, мусор), а не $40000
адрес. В некоторых случаях это приводит к катастрофическому повреждению памяти. Игра обычно завершает действие, которое не сработало где-то еще, без исправления этого, поэтому большую часть времени игра работает правильно. Но бывают случаи, когда игры нужно было должным образом исправить, чтобы восстановить нормальное поведение.
В C ввод значения указателя в заблуждение приводит к предупреждению.
(Мы отказались от такой игры, как "Wicked", в которой все больше и больше графических искажений по мере того, как вы продвигались по уровням, но также в зависимости от того, как вы проходили уровни и их порядок ...)
Размеры мошеннических данных
В сборке нет типов. Это означает, что если я это сделаю
move.w #$4000,d0 ; copy only 16 bits
move.l #1,(a0,d0.l) ; indexed write on d1, long
d0
регистр получает только половину данных изменились. Может быть то, что я хотел, а может и нет. Затем, если он d0
содержит ноль в наиболее значимых 32–16 битах, код выполняет то, что ожидалось, в противном случае он добавляет a0
и d0
(полный диапазон), и результирующая запись находится «в лесу». Исправление:
move.l #1,(a0,d0.w) ; indexed write on d1, long
Но тогда, если d0
> $7FFF
это тоже что-то не так, потому что тогда d0
считается отрицательным (не в случае с d0.l
). Значит d0
нужно расширение знака или маскировка ...
Эти ошибки размера можно увидеть в коде C, например, при назначении short
переменной (которая усекает результат), но даже тогда вы большую часть времени просто получаете неправильный результат, а не фатальные проблемы, как указано выше (то есть: если вы не не лгать компилятору, заставляя неправильное приведение типов)
Ассемблеры не имеют типов, но хорошие ассемблеры позволяют использовать структуры ( STRUCT
ключевое слово), которые позволяют немного поднять код, автоматически вычисляя смещения структур. Но чтение неверного размера может быть катастрофическим, независимо от того, используете вы структуры / определенные смещения или нет.
move.w the_offset(a0),d0
вместо
move.l the_offset(a0),d0
не проверяется и дает неверные данные в d0
. Убедитесь, что вы пьете достаточно кофе во время кодирования, или вместо этого просто напишите документацию ...
Несанкционированное согласование данных
Ассемблер обычно предупреждает о невыровненном коде, но не о невыровненных указателях (потому что указатели не имеют типа), который может вызвать ошибки шины.
Языки высокого уровня используют типы и избегают большинства этих ошибок, выполняя выравнивание / заполнение (если, опять же, не солгали).
Однако вы можете успешно писать программы сборки. Используя строгую методологию для передачи параметров / сохранения регистров и пытаясь покрыть 100% вашего кода тестами и отладчиком (символическим или нет, это все еще код, который вы написали). Это не устранит все потенциальные ошибки, особенно те, которые вызваны неправильными входными данными, но поможет.
Большую часть своей карьеры я писал ассемблера, соло, для небольших и больших команд (Cray, SGI, Sun, Oracle). Я работал над встраиваемыми системами, ОС, виртуальными машинами и загрузчиками начальной загрузки. Повреждение памяти редко, если вообще когда-либо было проблемой. Мы наняли умных людей, а тех, кто потерпел неудачу, направили на другую работу, более соответствующую их навыкам.
Мы также фанатично тестировали - как на уровне модулей, так и на уровне системы. У нас было автоматизированное тестирование, которое постоянно проводилось как на симуляторах, так и на реальном оборудовании.
Ближе к концу моей карьеры я провел собеседование в компании и спросил, как они проводят автоматическое тестирование. Их ответ "Что?!?" все, что мне нужно было услышать, я закончил интервью.
При сборке изобилует простыми идиотскими ошибками, как бы осторожны вы ни были. Оказывается, даже глупые компиляторы для плохо определенных языков высокого уровня (например, C) ограничивают огромный диапазон возможных ошибок как семантически или синтаксически недопустимыми. Ошибка с одним лишним или забытым нажатием клавиши с гораздо большей вероятностью приведет к отказу от компиляции, чем к сборке. Конструкции, которые вы можете корректно выразить в ассемблере, которые просто не имеют никакого смысла, потому что вы все делаете неправильно, с меньшей вероятностью будут переведены во что-то, что считается допустимым C. И поскольку вы работаете на более высоком уровне, вы скорее всего прищуриться и сказать "а?" и перепишите только что написанного монстра.
Так что разработка и отладка сборок действительно мучительно неумолимы. Но большинство таких ошибок сильно ломают и обнаруживаются при разработке и отладке. Я бы рискнул обоснованно предположить, что, если разработчики следуют одной и той же базовой архитектуре и тем же передовым методам разработки, конечный продукт должен быть примерно таким же надежным. Типы ошибок, которые улавливает компилятор, могут быть обнаружены с помощью передовых методов разработки, а ошибки, которые компиляторы не улавливают, могут быть или не могут быть обнаружены с помощью таких методов. Однако, чтобы достичь того же уровня, потребуется гораздо больше времени.
Я написал оригинальный сборщик мусора для MDL, языка, подобного Lisp, еще в 1971-72 годах. Тогда для меня это было непросто. Он был написан на MIDAS, ассемблере для PDP-10, работающего под управлением ITS.
Главной задачей этого проекта было предотвращение повреждения памяти. Вся команда боялась, что успешная демонстрация выйдет из строя и загорится при вызове сборщика мусора. И у меня не было действительно хорошего плана отладки для этого кода. Я проверил стол больше, чем когда-либо до или после. Такие вещи, как проверка отсутствия ошибок в столбах забора. Убедившись, что при перемещении группы векторов цель не содержала никакого мусора. Снова и снова проверяю свои предположения.
Я никогда не обнаруживал ошибок в этом коде, кроме тех, которые были обнаружены при проверке на рабочем месте. После того, как мы вышли в эфир, во время моих наблюдений никто не появлялся.
Я просто не такой умный, как пятьдесят лет назад. Сегодня я не мог сделать ничего подобного. А современные системы в тысячи раз больше, чем были MDL.
Ошибки повреждения памяти всегда были распространенной проблемой в больших программах на C [...] Но было время, когда большие программы, включая операционные системы, писались на ассемблере, а не на C.
Вы знаете, что есть и другие языки, которые уже были довольно распространены? Нравится COBOL, FORTRAN или PL / 1?
Были ли ошибки, связанные с повреждением памяти, распространенной проблемой в больших программах на ассемблере?
Это, конечно, зависит от нескольких факторов, таких как
- Используется Ассемблер, поскольку разные программы на ассемблере предлагают разный уровень поддержки программирования.
- структура программы, так как особенно большие программы придерживаются проверяемой структуры
- модульность и понятные интерфейсы
- тип написанной программы, так как не каждая задача требует манипуляции с указателем
- лучший стиль практики
Хороший ассемблер не только следит за выравниванием данных, но также предлагает инструменты для абстрактной обработки сложных типов данных, структур и тому подобного, уменьшая необходимость «вручную» вычислять указатели.
Ассемблер, используемый для любого серьезного проекта, как всегда, является макроассемблером (* 1), таким образом, способным заключать примитивные операции в макрокоманды более высокого уровня, позволяя программировать более ориентированное на приложения, избегая при этом многих ловушек, связанных с обработкой указателей (* 2).
Типы программ также имеют большое влияние. Приложения обычно состоят из различных модулей, многие из них могут быть написаны почти или полностью без (или только контролируемого) использования указателя. Опять же, использование инструментов, предоставляемых ассемблером, является ключом к уменьшению ошибочного кода.
Следующим шагом будет лучшая практика, которая идет рука об руку со многими из предыдущих. Просто не пишите программы / модули, которым требуется несколько базовых регистров, которые передают большие куски памяти вместо выделенных структур запросов и так далее ...
Но передовая практика начинается уже на раннем этапе и с, казалось бы, простых вещей. Просто возьмите пример примитивного (извините) ЦП, такого как 6502, который, возможно, имеет набор таблиц, все настроены на границы страницы для повышения производительности. При загрузке адреса одной из этих таблиц в указатель нулевой страницы для индексированного доступа использование инструментов ассемблера будет означать
LDA #<Table
STA Pointer
Некоторые программы, которые я видел, скорее идут
LDA #0
STA Pointer
(или хуже, если на 65C02)
STZ Pointer
Обычная аргументация - «Но все равно согласовано». Это? Можно ли это гарантировать для всех будущих итераций? Как насчет того дня, когда адресное пространство станет ограниченным и их нужно будет переместить на невыровненные адреса? Можно ожидать множество серьезных (которые трудно найти) ошибок.
Итак, передовая практика снова возвращает нас к использованию Ассемблера и всех инструментов, которые он предлагает.
Не пытайтесь играть в Assembler вместо Assembler - позвольте ему делать свою работу за вас.
А еще есть среда выполнения, которая применима ко всем языкам, но о ней часто забывают. Помимо таких вещей, как проверка стека или проверка границ параметров, одним из наиболее эффективных способов отлова ошибок указателя является простая блокировка первой и последней страниц памяти от записи и чтения (* 3). Он не только улавливает всеми любимую ошибку нулевого указателя, но также и все низкие положительные или отрицательные числа, которые часто являются результатом неправильного предварительного индексирования. Конечно, Runtime - это всегда последнее средство, но это простое средство.
Прежде всего, возможно, наиболее актуальной причиной является
- ISA машины
в снижении вероятности повреждения памяти за счет уменьшения необходимости обрабатывать указатели вообще.
Некоторые структуры ЦП просто требуют меньше (прямых) операций с указателями, чем другие. Существует огромный разрыв между архитектурами, которые включают операции с памятью в память, и теми, кто этого не делает, например, архитектурами загрузки / сохранения на основе аккумуляторов. По сути, требует обработки указателя для чего-либо большего, чем один элемент (байт / слово).
Например, для передачи поля, скажем, имени клиента из памяти, / 360 использует одну операцию MVC с адресами и длиной передачи, сгенерированными ассемблером из определения данных, в то время как архитектура загрузки / сохранения, предназначенная для обработки каждого байта отдельный, должен установить указатели и длину в регистрах и цикл вокруг движущихся отдельных элементов.
Поскольку такие операции довольно распространены, вероятность возникновения ошибок также является обычным явлением. Или, в более общем смысле, можно сказать, что:
Программы для процессоров CISC обычно менее подвержены ошибкам, чем программы, написанные для RISC-машин.
Конечно и как обычно, из-за плохого программирования все может быть напортачено.
И как это сравнивать с программами на C?
Во многом то же самое - или лучше, C - это HLL-эквивалент самого примитивного CPU ISA, поэтому все, что предлагает инструкции более высокого уровня, будет лучше.
C по своей сути является RISCy языком. Предоставляемые операции сведены к минимуму, что сопровождается минимальной возможностью проверки против непреднамеренных операций. Использование непроверенных указателей не только стандартно, но и необходимо для многих операций, открывая множество возможностей для повреждения памяти.
Возьмем для сравнения HLL, подобную ADA, здесь почти невозможно создать хаос указателя - если он не задуман и явно не объявлен как опция. Хорошая его часть (как и в предыдущем случае с ISA) связана с более высокими типами данных и их безопасным обращением.
Что касается опыта, то я большую часть своей профессиональной жизни (> 30 лет) занимался проектами сборки, например, 80% мэйнфреймов (/ 370) 20% микроконтроллеров (в основном 8080 / x86) - плюс еще много личного :) более 2 миллионов LOC (только инструкции), в то время как микропроекты держат около 10-20 тысяч LOC.
* 1 - Нет, то, что предлагает замену текстовых фрагментов готовым текстом, - это в лучшем случае какой-нибудь текстовый препроцессор, но не макроассемблер. Макроассемблер - это мета-инструмент для создания языка, необходимого для проекта. Он предлагает инструменты для извлечения информации, которую ассемблер собирает об источнике (размер поля, тип поля и многое другое), а также управляющие структуры для формулирования обработки, используемой для генерации соответствующего кода.
* 2 - Легко пожаловаться на то, что C не соответствовал каким-либо серьезным возможностям макросов, он не только устранил бы необходимость во многих непонятных конструкциях, но также позволил бы значительно продвинуться за счет расширения языка без необходимости писать новый.
* 3 - Лично я предпочитаю сделать страницу 0 только защищенной от записи и заполнить первые 256 байтов двоичным нулем. Таким образом, все записи с нулевым (или низким) указателем по-прежнему приводят к машинной ошибке, но чтение из нулевого указателя возвращает, в зависимости от типа, байт / полуслово / слово / дуплекс, содержащий ноль - ну или нулевую строку :) Я знаю, это лениво, но это делает жизнь намного лучше, если проще не взаимодействовать с кодом других людей. Также оставшуюся страницу можно использовать для удобных значений констант, таких как указатели на различные глобальные источники, строки идентификаторов, содержимое полей констант и таблицы перевода.
Я написал модификации ОС в сборке на CDC G-21, Univac 1108, DECSystem-10, DECSystem-20, все 36-битные системы, плюс 2 ассемблера IBM 1401.
«Повреждение памяти» существовало, в основном, как запись в списке «Не делать».
На Univac 1108 я обнаружил аппаратную ошибку, когда выборка первого полуслова (адрес обработчика прерывания) после аппаратного прерывания возвращала бы все единицы вместо содержимого адреса. Прочь в зарослях, с отключенными прерываниями, без защиты памяти. Он идет кругом, где он останавливается, никто не знает.
Вы сравниваете яблоки и груши. Языки высокого уровня были изобретены, потому что программы достигли размера, который был неуправляемым с помощью ассемблера. Пример: «У V1 было 4501 строка ассемблерного кода для ядра, инициализации и оболочки. Из них 3976 приходится на ядро, а 374 - на оболочку». (Из этого ответа .)
Файл. V1. Оболочка. Имел. 347. Линии. Из. Код.
Сегодняшний bash содержит около 100 000 строк кода (wc над репо дает 170 КБ), не считая центральных библиотек, таких как строка чтения и локализация. Языки высокого уровня используются отчасти для обеспечения переносимости, но также и потому, что практически невозможно писать программы сегодняшнего размера на ассемблере. Это не просто больше подвержено ошибкам - это почти невозможно.
Я не думаю, что повреждение памяти обычно является более серьезной проблемой на языке ассемблера, чем на любом другом языке, который использует непроверенные операции индексирования массивов при сравнении программ, выполняющих аналогичные задачи. Хотя для написания правильного ассемблерного кода может потребоваться внимание к деталям, помимо тех, которые были бы актуальны для такого языка, как C, некоторые аспекты языка ассемблера на самом деле безопаснее, чем C. На языке ассемблера, если код выполняет последовательность загрузок и сохранений, ассемблер будет производите инструкции по загрузке и хранению в указанном порядке, не задавая вопросов, все ли они необходимы. В C, напротив, если умный компилятор, такой как clang, вызывается с любым параметром оптимизации, кроме -O0
и с чем- то вроде:
extern char x[],y[];
int test(int index)
{
y[0] = 1;
if (x+2 == y+index)
y[index] = 2;
return y[0];
}
он может определить, что значение y[0]
момента, когда return
выполняется инструкция, всегда будет 1, и, следовательно, нет необходимости перезагружать ее значение после записи в y[index]
, даже если единственное определенное обстоятельство, при котором может произойти запись в индекс, будет, если x[]
будет два байта, y[]
происходит чтобы сразу следовать за ним, и index
равен нулю, что означает, y[0]
что на самом деле останется с номером 2.
Ассемблер требует более глубоких знаний об используемом вами оборудовании, чем другие языки, такие как C или Java. Правда в том, что ассемблер использовался практически во всем, от первых компьютеризированных автомобилей, ранних систем видеоигр и вплоть до 1990-х годов до устройств Интернета вещей, которые мы используем сегодня.
Хотя C предлагал безопасность типов, он по-прежнему не предлагал других мер безопасности, таких как проверка пустых указателей или ограниченные массивы (по крайней мере, не без дополнительного кода). Было довольно легко написать программу, которая вылетела бы и сгорела так же, как и любая программа на ассемблере.
Десятки тысяч видеоигр были написаны на ассемблере, вменяемый писать небольшие , но впечатляющие демки в всего лишь несколько килобайт кода / данных на протяжении десятилетий, тысячи машин до сих пор используют некоторые формы ассемблере сегодня, а также несколько менее известных операционные системы (например, MenuetOS ). У вас могут быть десятки или даже сотни вещей, запрограммированных на ассемблере, о которых вы даже не подозреваете.
Основная проблема программирования на ассемблере заключается в том, что вам нужно планировать более энергично, чем на таком языке, как C. Вполне возможно написать программу даже с 100 тыс. Строк кода на ассемблере без единой ошибки, а также можно написать программа с 20 строками кода, содержащая 5 ошибок.
Проблема не в инструменте, а в программисте. Я бы сказал, что повреждение памяти было распространенной проблемой в раннем программировании в целом. Это не ограничивалось ассемблером, но также C (который был печально известен утечкой памяти и доступом к недопустимым диапазонам памяти), C ++ и другими языками, где вы могли напрямую обращаться к памяти, даже BASIC (который имел возможность читать / писать определенные I / O портов на ЦП).
Даже с современными языками, в которых есть средства защиты, мы будем видеть ошибки программирования, которые приводят к сбою в играх. Почему? Потому что при разработке приложения не уделяется должного внимания. Управление памятью никуда не делось, оно было загнано в угол, где его труднее визуализировать, что привело к разного рода хаосу в современном коде.
Практически каждый язык подвержен различным видам повреждения памяти при неправильном использовании. Сегодня наиболее распространенной проблемой являются утечки памяти, которые проще, чем когда-либо, случайно вызвать из-за замыканий и абстракций.
Несправедливо утверждать, что ассемблер по своей сути более или менее повреждает память, чем другие языки, он просто получил плохую репутацию из-за того, насколько сложно было написать правильный код.
Это была очень распространенная проблема. У компилятора IBM FORTRAN для 1130 было довольно много: в тех, что я помню, были случаи неправильного синтаксиса, которые не были обнаружены. Переход к языкам более высокого уровня, близким к машине, явно не помог: ранние системы Multics, написанные на PL / I, часто давали сбой. Я думаю, что культура и техника программирования были больше связаны с улучшением этой ситуации, чем язык.
Я несколько лет занимался программированием на ассемблере, а затем десятилетиями занимался программированием на C. Ассемблер, похоже, не имел больше ошибок с плохими указателями, чем C, но важной причиной этого было то, что программирование на ассемблере - сравнительно медленная работа.
Команды, в которых я работал, хотели тестировать свою работу каждый раз, когда они писали приращение функциональности, которое обычно составляло каждые 10-20 инструкций ассемблера. На языках более высокого уровня вы обычно тестируете после аналогичного количества строк кода, которые обладают гораздо большей функциональностью. Это идет вразрез с безопасностью HLL.
Ассемблер перестал использоваться для крупномасштабных задач программирования, потому что он давал более низкую производительность и потому, что он обычно не переносился на другие типы компьютеров. За последние 25 лет я написал около 8 строк ассемблера, и он должен был генерировать условия ошибки для тестирования обработчика ошибок.
Не тогда, когда я работал с компьютерами. У нас было много проблем, но я никогда не сталкивался с проблемами повреждения памяти.
Сейчас я работал на нескольких машинах IBM 7090, 360, 370, s / 3, s / 7, а также микроконтроллерах на базе 8080 и Z80. У других компьютеров вполне могли быть проблемы с памятью.