MASM32: Немного основ ассемблера
Суббота, 21. Август 2010
Раздел: Assembler, Windows, Для новичков, автор: dx
Эта статья идет прямиком в дополнение к предыдущей. Я не рассказал про самые основы ассемблера. Хотя в интернете полно материала на эту тему, я все равно решил ее немного затронуть. Что же нужно знать для начала, чтобы понимать и писать несложные программы или ассемблерные вставки?
1. У процессора есть определенный набор регистров.
Представьте, что регистр - это переменная, которая всегда объявлена, и вы можете ее свободно использовать, только переменная эта хранится не в оперативной памяти, а прямиком в процессоре, поэтому работа с регистрами очень быстрая. Существует 8 регистров общего назначения, каждый из них может хранить 4 байта. Чаще всего мы будем использовать следующие регистры: eax, ebx, ecx, edx. Каждый из этих регистров делится еще на несколько следующим образом:

Если поделить EAX на две части по 16 бит (по 2 байта), то младшая часть - это регистр AX. Если AX поделить на две части по 8 бит (по 1 байту), то младшая его часть - AL, а старшая - AH. То же самое справедливо и для остальных перечисленных регистров.
Существуют еще регистры esi и edi (в отличие от предыдущих, у них есть только младшая 16-битная часть si и di). Они используются для выполнения различных операций пересылки данных.
Регистр esp используется для того, чтобы адресовать стек, а ebp - чтобы обращаться к локальным переменным в стеке. Над этими можно пока не задумываться, потому что MASM32 позволяет обойтись без их использования.
В программах мы будем использовать чаще всего только шесть вышеперечисленных регистров. Советую немного прочитать про двоичные формы представления чисел (про перевод десятичных чисел в двоичные и наоборот, а также про дополнительный и прямой код, это пригодится в будущем. Если не всё поняли, опять-таки ничего страшного, про арифметику я еще буду упоминать в дальнейшем.
2. Регистр флагов. Об этом регистре поговорим отдельно. В нем хранятся различные биты, позволяющие узнать информацию о текущем состоянии процессора. Напрямую с этим регистром обычно не работают, существует много команд ассемблера, позволяющих работать с отдельными его битами.
Что ж, небольшой пример, в котором вы уже будете способны разобраться. Предположим, мы хотим сложить два числа и проверить, не равен ли результат нулю.
mov eax, 123 ;здесь мы записали число 123 в регистр eax. ;Эта операция эквивалентна eax = 123 mov ebx, -123 ;а здесь пишем в ebx число -123. Оно будет представлено в дополнительном коде (см. выше) add eax, ebx; складываем. Эта запись эквивалентна eax = eax + ebx ;В eax у нас будет результат 0. Как же мы можем это проверить? test eax, eax ;Эта команда проверит, есть ли хотя бы одна единичка ;в регистре eax (в бинарном представлении числа) ;если есть, то в регистре флагов будет сброшен флаг ZF (zero flag) ;то есть, его значение будет установлено равным нулю ;можно было проверить и так: ;cmp eax, 0 ;то есть, напрямую сравнить eax и ноль jz zero ;осуществляем переход на метку zero, ;если флаг ZF установлен. Именно это действие производит ;данная команда ;Если в eax будет не 0, то выполнение продолжится дальше invoke MessageBox, hWin, chr$("В eax почему-то не 0!"), chr$("Info"), 0 jmp lexit ;переходим на метку exit (GOTO exit) zero: invoke MessageBox, hWin, chr$("В eax 0!"), chr$("Info"), 0 lexit:
Изучите этот простой пример и вставьте его в предыдущий исходник в тело обработчика нажатия на кнопку "Test" (сразу после case TEST_BTN). Вы увидите сообщение о том, что в регистре eax число ноль при нажатии кнопки "test".
3. Стек и куча
Теперь немного о стеке и куче. Куча, как вы конечно же сразу подумали, это область памяти, в которой хранится вся программа и ее данные. Все глобальные переменные, которые мы создавали в секции .data и .data? в предыдущем примере будут размещены именно там.
Теперь немного о стеке. Стек - это удобное место для хранения информации. Чаще всего он используется при вызове функций. Стек использует принцип "первым вошел - последним вышел". Приведу пример:
mov eax, 100 ;загружаем в eax число 100 mov ecx, 200 ;а в ecx - 200 push eax ;сохраняем в стеке регистр eax push ecx ;сохраняем в стеке регистр ecx ;сейчас мы сохранили оба регистра в стеке, причем первым в стек ;попал eax, а последним - ecx ;здесь мы можем делсть любые действия с этими регистрами mov eax, 555 ;например, такое add ecx, eax ;или такое ;а теперь мы просто восстановим значение из стека pop ecx pop eax ;восстанавливаем в обратном порядке! ;потому что стек организован по принципу "первый вошел - последний вышел", ;как я уже говорил ;выведем значения регистров .data? buffer db 128 dup(?) ;создаем буфер ;в секции неинициализированных данных ;размером 128 байт ;директива dup говорит о том, что все байты будут неопределены ;если бы мы написали dup(123) ;то все байты приняли бы значение 123 ;но у нас - секция НЕинициализированных данных ;и мы не можем так сделать, да и не нужно .code ;опять вернемся к коду ;и вызовем функцию, эквивалентную функции ;printf в языках C/C++ invoke wsprintf, offset buffer, chr$("EAX = %u, ECX = %u"), eax, ecx ;она запишет нам строку EAX = [число], ECX = [число] ;в буфер buf invoke MessageBox, hWin, offset buffer, chr$("Info"), 0 ;а теперь мы просто выведем эту строку
Вставьте этот пример в место обработчика нажатия на TEST_BTN, как и предыдущий, и посмотрите, что произойдет при нажатии на кнопку "Test".
А теперь вернемся к вызову функций. Что происходит, когда мы пишем такую строку?
invoke MessageBox, hWin, offset buffer, chr$("Info"), 0
Ведь invoke - это всего лишь встроенный макрос для упрощения кода, и при компиляции всё это преобразуется в ассемблерные команды.
Этот код эквивалентен следующему:
push 0 push chr$("Info") push offset buffer push hWin call MessageBox
Замените макрос на эту последовательность команд и убедитесь, что так оно и есть!
В предыдущей статье вы сможете еще раз прочитать, что MessageBox - это WinAPI-функция из библиотеки user32.dll, а все WinAPI-функции созданы по соглашению stdcall, то есть, передача аргументов в них производится через стек в обратном порядке. Возвращают эти функции значение в регистре eax. Ну, возвращаемое значение MessageBox нас не сильно интересует, но если бы оно нам было необходимо, то сразу после вызова мы могли бы как-то работать с регистром eax, например, сохранить его. Скажем, мы хотим узнать полную командную строку приложения (с какими аргументами его вызвали). Напишем так:
call GetCommandLine push 0 push chr$("Command Line") push eax ;вот оно - в eax будет указатель на строку! ;который нам вернула функция GetCommandLine push hWin call MessageBox
Выполнив этот код, мы увидим полный путь к приложению и переданные ему аргументы, если такие имеются.
Разумеется, код выше можно упростить с помощью макросов MASM32:
invoke MessageBox, hWin, FUNC(GetCommandLine), chr$("Command line"), 0
Но знать, что в итоге получается, все равно нужно, поэтому я всё и пояснил.
Со стеком можно работать и напрямую, как с кучей, используя адреса и произвольный доступ.
Что еще нужно знать о соглашении stdcall? То, что все такие функции сохраняют регистры esi, edi и ebx и могут менять по своему усмотрению регистры ecx, edx. То есть, если мы что-то храним в регистре ecx, потом вызываем какую-то WinAPI-функцию, то после ее вызова уже нельзя использовать ecx - в нем будет храниться совершенно случайное значение. Если же мы все-таки хотим что-то там сохранить, чтобы вызов функции не изменил значения, то следует просто воспользоваться командами push ecx до вызова и pop ecx после - и значение ecx будет восстановлено.
4. Адреса
Теперь я поговорю немного об адресах и указателях. Указатель - это непосредственно адрес какой-то переменной в памяти, будь она в куче или в стеке. Если вы знакомы с указателями, то можете даже не читать этот кусок текста.
Многие функции (например, все WinAPI функции) используют для передачи больших переменных не сами переменные, а указатели на них. Если вы были внимательны, вы могли заметить, что выше я писал
invoke MessageBox, hWin, offset buffer, chr$("Info"), 0
И я использовал не просто buf, а offset buf - адрес буфера. Представьте, что было бы, если бы мы весь 128-байтный буфер решили загрузить в стек (а передача значений в функции, как я уже говорил, производится через стек). Нам бы пришлось заталкивать каким-либо образом в стек 128 байт. Это очень долго и неоптимально, ведь функции MessageBox пришлось бы потом еще и убирать эту строку оттуда. А мы передаем просто адрес этой строки, указатель на нее, который занимает всего 4 байта, и функция MessageBox по этому указателю находит строку в памяти.
Предположим, у нас есть строка
.data stroka db "Hello, world", 0
Что во-первых следует знать? То что все строки в Windows, которые используются функциями WinAPI, должны заканчиваться нулевым байтом. По этой причине мы написали 0 в конце инициализации строки. Этот нулевой байт говорит о том, что это последний байт строки, и дальше ничего нет. Unicode-строки (двухбайтовые) должны заканчиваться соответственно двумя нулевыми байтами. Во-вторых, сравните записи:
mov al, stroka mov eax, offset stroka
В первом случае мы загружаем в al первый байт из строки stroka. А во втором случае мы загружаем указатель на строку stroka в регистр eax, и можем работать с самой строкой через указатель. Например, если мы хотим заменить вторую букву строки с "e" на "X", то достаточно просто написать так:
mov byte ptr [eax + 1], 'X'
На первый взгляд, это сложная строка, но эта запись по сути эквивалентна stroka[1] = 'X'. Byte ptr говорит о том, что мы записываем один байт по адресу строки + 1. Строка выглядит как массив байтов, и по адресу stroka + 0 лежит первая ее буква, по адресу stroka + 1 - вторая, и так далее.
Теперь приведу пример посложнее и закончу на этом. Допустим, мы хотим посчитать длину строки. Мы знаем, что конец строки указывается нулевым байтом. Что же, этого вполне достаточно. Пример я приведу, используя только те команды ассемблера, которые я успел разобрать.
.data stroka db "Hello, world!", 0 buffer db 128 dup(?) ;буфер, в который мы запишем онформацию о строке .code mov eax, offset stroka mov ecx, 0; это будет наш счетчик длины строки next_sym: mov bl, byte ptr [eax + ecx] ;загружаем очередной символ из строки в регистр bl test bl, bl ;проверяем, а не ноль ли он? jz end_count ;если ноль, идем на вывод длины ;если нет - считаем дальше inc ecx ;прибавляем счетчик на 1 ;(эта команда эквивалентна add ecx, 1) jmp next_sym end_count: ;выводим посчитанную длину: invoke wsprintf, offset buffer, chr$("Длина строки - %u символов"), ecx invoke MessageBox, hWin, offset buffer, chr$("Info"), 0
Вставьте этот код в код примера из предыдущей статьи после строки case TEST_BTN, как всегда, и посмотрите, как он работает.
Вы уже испугались, думая, что каждый раз придется ТАК считать длину строк? Не пугайтесь, всё уже сделано за нас! Предыдущий исходник эквивалентен такому:
.data stroka db "Hello, world!", 0 buffer db 128 dup(?) ;буфер, в который мы запишем онформацию о строке .code ;выводим посчитанную длину: invoke wsprintf, offset buffer, chr$("Длина строки - %u символов"), FUNC(lstrlen, offset stroka) invoke MessageBox, hWin, offset buffer, chr$("Info"), 0
Мы просто воспользовались WinAPI-функцией lstrlen!
Теперь немного дополнительного материала для интересующихся - а что, если мы хотим оставить свой личный просчет длины строки и вынести его в отдельную процедуру? Тогда напишем так:
STRING_LENGHT PROC pointer_to_string :DWORD push esi ;сохраним в стеке значения регистров esi, edi и ebx push edi ;прямо как настоящая stdcall-функция push ebx mov eax, pointer_to_string mov ecx, 0; это будет наш счетчик длины строки next_sym: mov bl, byte ptr [eax + ecx] ;загружаем очередной символ из строки в регистр bl test bl, bl ;проверяем, а не ноль ли он? jz end_count ;если ноль, идем на вывод длины ;если нет - считаем дальше inc ecx ;прибавляем счетчик на 1 ;(эта команда эквивалентна add ecx, 1) jmp next_sym end_count: mov eax, ecx ;помним, что все stdcall-функции возвращают значение в eax! ;мы ведь пишем, используя этот стандарт pop ebx pop edi ;восстановим значения регистров из стека pop esi ;в обратном порядке (помним принцип работы стека) ret ;выходим из процедуры STRING_LENGHT ENDP
MASM32 сильно упрощает написание функций. Я даже не буду приводить код, который получается в итоге, чтобы не запутать вас, да это и не нужно.
Перед использованием функции необходимо написать ее прототип прототип (о них я говорил в предыдущих статьях).
Пишем:
STRING_LENGHT PROTO :DWORD
Этот прототип говорит компилятору о том, что функция STRING_LENGHT принимает единственный аргумент типа "двойное слово", т.е. 4 байта - указатель на нашу строку.
Теперь у нас есть своя функция для просчета длины строки. Мы можем вставить ее в конец кода перед строкой "end start" и вызывать ее вместо lstrlen! Попробуйте сделать это сами. Не забудьте написать прототип функции в начало файла после подключения различных библиотек.

qosys :
Привет!
Сделай у себя на блоге "версию для печати",
было бы реально полезно.
[Ответить]
Kaimi:
Август 22nd, 2010 at 22:32
Сделал
[Ответить]
Chrome~ :
Спасибо за цикл новых статей. Очень полезно.
[Ответить]