Print This Post MASM32: Немного основ ассемблера

Суббота, 21. Август 2010
Раздел: Assembler, Windows, Для новичков, автор:

Эта статья идет прямиком в дополнение к предыдущей. Я не рассказал про самые основы ассемблера. Хотя в интернете полно материала на эту тему, я все равно решил ее немного затронуть. Что же нужно знать для начала, чтобы понимать и писать несложные программы или ассемблерные вставки?

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! Попробуйте сделать это сами. Не забудьте написать прототип функции в начало файла после подключения различных библиотек.

Также рекомендую почитать

 Обсудить на форуме


Получать обновления на почту:     

Метки: , , , , , , , .

Комментариев: 3 к “MASM32: Немного основ ассемблера”

  1. Привет!

    Сделай у себя на блоге "версию для печати",
    было бы реально полезно.

    [Ответить]

    Kaimi:

    Сделал

    [Ответить]


  2. Chrome~ :

    Спасибо за цикл новых статей. Очень полезно.

    [Ответить]


Оставьте ваш комментарий