Консольная программа для Windows на языке ассемблера

В предыдущих заметках я начал писать программы, которые стартуют на ПК без операционной системы, будучи загруженными с дискеты. Программы эти я компилировал ассемблером flat assembler (FASM). В настоящей заметке мне захотелось сделать небольшое отступление и написать о том, как при помощи FASM можно писать программы пользовательского режима для Windows. Знание того, как это делается позволяет изучать программирование на ассемблере как таковое, не заморачиваясь написанием своей собственной операционной системы.

Я преподаю студентам программирование на ассемблере Intel x86 с использованием компилятора Microsoft Macro Assembler (MASM). Чтобы изучать ассемблер, нам нужно писать на нем программы, а чтобы мы могли вводить в эти программы данные и видеть какие-то результаты на экране, нам нужны какие-то средства ввода-вывода. В языках C/C++ для ввода-вывода мы привычно используем стандартные библиотеки этих языков. А в ассемблере? Есть разные варианты.

Вариант 1-й. Использовать функции WinAPI, например WriteConsole, ReadConsole. Согласитесь, не самое приятное средство ввода-вывода — мы все-таки ассемблер хотим изучать, а не WinAPI.

Вариант 2-й. Использовать привычную стандартную библиотеку языка C (CRT — C Runtime Library) — т. е. такие простые и понятные функции, как printf и scanf. Но вызвать их из кода на ассемблере, как выяснилось, не такое простое дело.

Вызываем printf из программы на MASM в Visual Studio 2015+

Прежде всего, оказывается, что в версиях Visual Studio начиная с 2015 функция printf и еще какие-то стандартные функции уже не экспортируются Microsoft’овской реализацией CRT, а являются встроенными (inline) и определены прямо в заголовочных файлах (например, stdio.h). Поэтому если вы напишите на ассемблере код, который вызывает printf и попробуете скомпоновать его с msvcrt.lib, то получите ошибку unresolved external symbol printf. Однако Microsoft оставила нам лазейку в виде ряда статически компонуемых библиотек (см. код ниже), которые экспортируют те функции, которые Microsoft сделала встроенными. Ниже приведен код, который вы можете скомпилировать компилятором ml.exe, который вы можете запустить например из командной строки Developer Command Prompt for VS, если у вас установлена Visual Studio (или можете создать и настроить проект в Visual Studio для программирования на MASM).

; MASM version of HelloWorld program using printf() function

; We have to link to these libraries since Visual Studio 2015
includelib libcmt.lib
includelib libucrt.lib
includelib libvcruntime.lib
includelib legacy_stdio_definitions.lib

.686P ; Pentium Pro or later
.MODEL flat, stdcall
.STACK 4096

EXTERN printf:NEAR

.data
    mytext BYTE "Hello World!", 0Dh, 0Ah, 0

.code

; "PROC" directive is mandatory here, we can't write just "main:" (don't know why)
main PROC C
    push offset mytext
    call printf
    add esp, 4
    ret
main ENDP

; it's important that we use just "END" istead of "END main"
; as we don't want to set main as the entry point.
END

В процессе отладки вышеприведенного кода я наткнулся не только на ошибки компоновщика unresolved external symbol, но и на ошибки времени выполнения. Например, стоило мне написать в конце файла END main, а не просто END, как во время выполнения программы возникала ошибка Exception thrown at 0x77621DCA (ntdll.dll) in AsmProject.exe: 0xC0000005: Access violation writing location 0x00000014. Дело в том, что функция main в данном случае не должна быть точкой входа (директива END задает точку входа), ею должна быть функция mainCRTStartup(), которая инициализирует стандартную библиотеку языка C. Функция mainCRTStartup() в конце концов сама вызовет нашу функцию main.
Если же я пытался не использовать директиву PROC рядом с меткой main, т. е. писал вот так:

main:
    push offset mytext
    call printf
    add esp, 4
    ret

… то получал ошибку компоновки unresolved external symbol _main referenced in function "int __cdecl __scrt_common_main_seh(void)" (?__scrt_common_main_seh@@YAHXZ).

Вот еще несколько ресурсов по теме линковки программ со стандартной библиотекой языка C:

Вызываем printf из программы на flat assembler (FASM)

flat assembler, в отличие от MASM, практически не делает ничего, что программист не написал явно в исходном коде. В ОС Windows исполняемые файлы и библиотеки имеют формат Portable Executable (PE). Если вы создаете при помощи FASM программу, которая будет выполняться в ОС Windows, то вы должны сами описать в файле исходного кода некоторые части исполняемого файла Portable Executable (чего в MASM вам делать не пришлось бы). Например, вы должны сами описать секции данных, кода, импорта, экспорта, ресурсов и пр. Для программы HelloWorld нужны три секции: кода, данных и импорта.
С секциями кода и данных всё очевидно. В секцию импорта помещается информация о функциях, которые наша программа вызывает из различных библиотек. Нам нужно поместить в секцию импорта данные о функциях стандартной библиотеки языка C (msvcrt.dll), которые мы используем в программе. В программе, показанной ниже, я использую две функции: printf и exit. Но каков формат данных в секции импорта? К счастью, мы можем об этом не думать — в заголовочном файле /INCLUDE/MACRO/IMPORT32.INC находятся макросы library и import, которые генерируют данные для секции импорта; программисту нужно лишь указать этим макросам имя файла библиотеки и названия функций. Об этих макросах читайте раздел 3.1.2 документа flat assembler Programmer’s Manual. Пример описания секции импорта без использования макросов library и import находится в файле /EXAMPLES/PEDEMO/PEDEMO.ASM.
Достигнуть некоторого просветления в понимании формата Portable Executable мне помогли следующие ресурсы:

Ниже приведен код программы HelloWorld на FASM:

; FASM version of HelloWorld program using printf() function
format PE console
entry main
use32

; ========== CODE SECTION ==========
section '.text' code readable executable

printf: jmp [imp_printf]
exit:   jmp [imp_exit]

main:
    push message
    call printf
    add esp, 4
   
    push 0
    call exit

; ========== DATA SECTION ==========
section '.data' data readable writeable
    message db "Hello, World!", 0

; ========== IMPORT SECTION ==========
section '.idata' data import readable

; The header included below contains "library" and "import" macros that
; generate import data which must be placed in the import section of PE file
include "C:\FASM\INCLUDE\macro\import32.inc"

library msvcrt, "MSVCRT.DLL"

import msvcrt,\
    imp_printf ,'printf',\
    imp_exit   ,'exit'

Пояснения к программе HelloWorld на FASM

format PE console определяет формат двоичного файла, который должен сгенерировать компилятор; формат — Portable Executable, подсистема — console.

entry main устанавливает точку входа в программу — функцию main.

use32 заставляет компилятор генерировать 32-разрядный машинный код.

section '.text' code readable executable описывает секцию кода. ‘.text’ — это имя секции (имена секций, насколько я понимаю, не несут никакой функциональной нагрузки и могут быть любыми, но не длиннее 8 символов). code readable executable — атрибуты (флаги), которые характеризуют секцию как секцию кода, содержимое которой можно читать и исполнять.

section '.data' data readable writeable секция данных, для которой разрешены чтение и запись.

section '.idata' data import readable секция импорта, в которой описывается, какие функции из каких библиотек использует наша программа. О том, как устроена секция импорта в файле PE хорошо написано в книге П. В. Румянцева «Работа с файлами в win32 API» в разделе Импорт функций и механизм импорта. Раздел импорта состоит, грубо говоря, из двух массивов. Массив структур IMAGE_IMPORT_DESCRIPTOR (определения структур см. в заголовочном файле WinNT.h) содержит информацию об используемых нашей программой библиотеках. Количество элементов в массиве равно количеству библиотек. В каждом элементе этого массива содержится имя библиотеки, из которой импортируются функции, и указатель FirstThunk, который указывает на массив структур IMAGE_THUNK_DATA. Каждый элемент массива структур IMAGE_THUNK_DATA содержит информацию об одной импортируемой функции — это либо ее порядковый номер (ordinal), либо указатель на структуру IMAGE_IMPORT_BY_NAME, которая содержит имя функции.

printf: jmp [imp_printf]
Команда вида jmp [metka] выполняет прыжок по адресу, который берется из участка памяти, на который указывает метка metka. Не путайте эту команду с командой jmp metka, которая выполняет прыжок по адресу, на который указывает метка metka. В приведенной выше команде imp_printf — это метка, которая указывает на структуру IMAGE_THUNK_DATA. При загрузке исполняемого файла в память загрузчик помещает в место в памяти, на которое указывает imp_printf адрес функции printf в библиотеке msvcrt.dll. Так что команда printf: jmp [imp_printf] прыгает на функцию printf.

Ну и наконец, я хотел бы показать, как выглядит наша программа без макросов library и import:

; FASM version of HelloWorld program using printf() function
format PE console
entry main
use32

; ========== CODE SECTION ==========
section '.text' code readable executable

main:
    push message
    call [printf]
    add esp, 4
   
    push 0
    call [exit]

; ========== DATA SECTION ==========
section '.data' data readable writeable
    message db "Hello, World!", 0

; ========== IMPORT SECTION ==========
section '.idata' data import readable

; --- array of IMAGE_IMPORT_DESCRIPTOR structures ---
dd 0, 0, 0, RVA msvcrt_name, RVA msvcrt_table
dd 0, 0, 0, 0, 0
; ---

; --- array of IMAGE_THUNK_DATA structures ---
msvcrt_table:
    printf dd RVA _printf
    exit   dd RVA _exit
    dd 0
; ---

msvcrt_name db 'MSVCRT.DLL', 0

; IMAGE_IMPORT_BY_NAME structure
_printf:
    dw 0
    db 'printf', 0

; IMAGE_IMPORT_BY_NAME structure
_exit:
    dw 0
    db 'exit', 0

В приведенном коде директива RVA означает Relative Virtual Address — адрес относительно начала файла на диске, а не адрес оперативной памяти. Следует понимать разницу между этими двумя адресами. Файл на диске состоит из байтов (можно еще сказать, является потоком байтов). Байты файла можно условно пронумеровать от 0 до <размер файла в байтах — 1>. Такой номер байта — это и есть RVA. При запуске программы содержимое файла копируется (точнее — проецируется, но не будем об этом сейчас) в оперативную память по определенному адресу, который обычно равен 0x00400000 (этот адрес указывается в заголовке файла Portable Executable — см. поле ImageBase в структуре IMAGE_OPTIONAL_HEADER в файле WinNT.h). Поэтому, условно говоря, байт в файле, у которого RVA = X, будет находиться в оперативной памяти по адресу X + 0x00400000.

Отладка (Debugging)

Отладка — это исследования работы скомпилированной программы во время ее выполнения. Отладка выполняется при помощи специальных программ, называемых отладчиками (debuggers). Отладчики позволяют пользователю

  • пошагово выполнять целевую программу
  • отображать и, при необходимости, изменять содержимое оперативной памяти и регистров процессора
  • ставить в коде так называемые точки останова (breakpoints): дойдя до команды, на которую установлена точка останова, программа остановится, после чего пользователь может просмотреть содержимое памяти и регистров процессора, продолжить выполнение пошагово и т. д.
  • изменять порядок выполнения команд в программе

Как работает отладчик? Как я понимаю, для работы отладчику необходима поддержка со стороны операционной системы. Поэтому такие ОС, как Windows и Linux предоставляют API для выполнения отладки (см. например The Debugging Application Programming Interface). ОС может перевести процессор в такой режим, когда после выполнения каждой машинной команды, он будет генерировать прерывание — это и позволяет, как я понимаю, выполнять программу пошагово.
Где взять отладчик? Для Windows стандартным является отладчик от фирмы Microsoft под названием WinDbg, он поставляется в составе Windows SDK (см. Debugging Tools for Windows). О том, как пользоваться отладчиком WinDbg см. статью Getting Started with WinDbg (User-Mode).
Другой вариант — отладчик с открытым исходным кодом OllyDbg. Пользоваться им довольно просто: вы нажимаете на кнопку «Открыть» и выбираете исполняемый файл (exe) программы, которую хотите отлаживать. Программа тут же запускается и происходит останов на ее точке входа (функции main). В окне программы вы видите четыре окошка: дизассемблированный код, регистры процессора, шестнадцатеричный дамп памяти, стек (stack). Интерфейс программы интуитивно понятен, а в составе дистрибутива есть руководство пользователя.

Заключение

Если выбирать между MASM и FASM с точки зрения обучения языку ассемблера, то я пожалуй выбираю FASM. Его можно легко установить (надо просто скачать дистрибутив с сайта). Он позволяет создавать любые двоичные файлы и помогает программисту создавать двоичные файлы форматов PE, ELF и COFF. С MASM мне пришлось помучиться, чтобы разобраться с вызовом функций из стандартной библиотеки языка C, с FASM всё оказалось куда проще.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *