/asm/thread №48

Home | Return to board | Help


05/09/2023 20:08 Thread №48 [Return to board] [Delete]

Изучение языка ассемблера

Хочу поделиться своими соображениями и ответами на некоторые неочевидные вопросы, которые могут возникнуть при изучении данного языка программирования, что может помочь в его понимании и сэкономить время.

Для разбора примеров буду использовать актуальную версию nasm для архитектуры x86-64 и ОС Linux.
05/09/2023 21:07 Post №228 [Delete]
Начну со способов определения данных в тексте кода и их особенностей.

Обычно, для определения данных используются так называемые псевдоинструкции вида: db, dw, dd, dq. В случае, когда требуется резервировние неинициализированных блоков данных, используются инструкции вида: resb, resw, resd и т.п. Помимо этих двух способов для определения констант возможно использовать псевдоинструкцию equ и директиву препроцессора %define.

При этом на свойства размещаемых данных будет влияет место, где они будут размещены. Если некоторые данные размешеются в секции .data, то они будут изменяемыми - их значения, заданные во время инициализации могут быть перезаписаны другими занчениями во время выполнения программы.


section .data
  var  dq 10

section .text
  global  _start

_start:
  mov rax, [var] ; можно прочитать значение области даннных
                 ; идентифицируемой меткой var
  inc rax
  mov [var], rax ; можно записать новое значение в область данных var


Но, если разместить var в секции .rodata


section .rodata
  var  dq 10


то var станет константой и при попытке изменинить ее значение программа завершится ошибкой: segmentation fault.
05/09/2023 21:47 Post №229 [Delete]
Спешу сразу пояснить: что представляет собой идентификатор, сопоставленный с некой областью памяти. К примеру, выражение:


  var dq 10


cопоставляет идентификатор var с адресом области пямяти размером в 8 байт, которая будет располагаться внутри другой области памяти, соответствующей секции статических данных, созданной во время загрузки программы на выполнение.
Надо помнить, что var - не значение перменной, как это очевидно воспринимается в ЯП высокого уровня, а адрес первого байта области памяти, используемой для размешения значения переменной. Для получения значения необходимо использовать синтаксис [var].


  mov rax, var ; получаем адрес
  mov rax, [var] ; получаем значение


Адреса, сопоставленные с символьными идетнтификаторами можно увидить используя утилиты предназначенные для просмотра исполнимых и объектных файлов: nm, objdump, readelf.

Например, после трансляции и линковки в полученном исполнимом файле при помощи nm можно увидеть строку:


0000000000403010 d var


Первое число будет указывать адрес по которому будут размещены данные, определяемые индентификатором var.
05/09/2023 22:30 Post №230 [Delete]
Помимо псевдоинструкций, используемых для определения данных (db, dw ...) существует инструкция equ которая отличается тем, что создет константу - имеющую значение, но не имеющую адреса.
Выражение:


  a equ 10


Создает запись в служебном сегменте объектного файла .strtab, которую можно увидить при использовании nm в виде:

000000000000000a a a

Здесь первое число будет не адрес, а само занчение константы, которое напрямую будет использовано при обращенни к идентификатору.


  mov rax, a


Загрузит значение 0xa (или 10) в регистр, а не адрес, которого не может существовать для такого типа данных. Это порождает путанницу, в случае непонимания различия между equ и прочими способами определения данных.

Но что бы "окончательно всех запутать" для определения значений констант возможно использовать еще один способ - комманду препроцессора %define


%define MY_CONST 10


Это значение будет подставляться во время трансляции текста программы в объектный код и попадет в объектный файл в виде подстановки - прямой замены имени на значение с ним соспоставленное. При этом оно не попадет в таблицу идентификаторов (символов) объектного и исполнимого файла. Что означет, невозможность использования таких определений вне текста программы, например во время линковки объектного файла.
08/09/2023 22:48 Post №237 [Delete]
В продолжение темы работы с данными расскажу, как использовать массив указателей.


section .rodata
  str1     db  "str 1", 0xa
  str2     db  "str 2", 0xa
  str3     db  "str 3", 0xa

  s_arr    dq  str1, str2, str3, 0 ; последний 0 - признак завершения массива


Здесь в секции .rodata размещены три символьные строки, в которых 0хa - символ завершения стоки '\n' для удобства вывода на терминал и массив указателей на строки - адреса их первого символа. Обратите внимание, что для 64-биной ОС для задания размера указателей использована псевдоинструкция dq -  так как указатели имеют рамер 8 байт или 64 бита.

Как именно данные будут размещены в памяти можно увидеть из листинга (опция -l <имя_файла_листинга> nasm) данного кода:


     4 00000000 73747220310A              str1     db  "str 1", 0xa
     5 00000006 73747220320A              str2     db  "str 2", 0xa
     6 0000000C 73747220330A              str3     db  "str 3", 0xa
     7
     8 00000012 [0000000000000000]-       s_arr    dq  str1, str2, str3, 0
     8 0000001A [0600000000000000]-
     8 00000022 [0C00000000000000]-
     8 0000002A 0000000000000000


Строки размещены как последовательность байт с определенных адресов. Массив указателей представляет собой последовательность адресов начала каждой из строк, кроме последнего - нулевого элемента, который является константой.
08/09/2023 23:22 Post №238 [Delete]
Код, который демонстрирует работу с массивом строк, определенном в предыдущем посте:


section .text
  global  _start

_start:
  mov  rdx, 6      ; длина строки
  mov  rdi, 1      ; запись в stdout
  xor  r12, r12    ; r12 = 0 первый элемент массива находится по нулевому смещению
  mov  r13, s_arr

print_line:
  mov   rsi, [s_arr + r12 * 8] ; доступ по индексу массива
  test rsi, rsi    ; выход в случае нулевого указателя
  jz exit          ; как признака конца массива

  mov   rax, 1     ; write
  syscall

  mov  rsi, [r13]  ; доступ по адресу элемента массива
  mov  rax, 1      ; write
  syscall

  inc  r12         ; увеличить индекс на 1
  add  r13, 8      ; перейти к адресу следующего элемента
  jmp  print_line

exit:


Здесь показано два способа обращения к элементам массива: по индексу и по смещению. Для выбода на теримнал используется syscall write. Системный вызов после выполнеия возвращает в rax количество записанных символов, поэтому его приходится устанавливать в 1 заново после каждого вызова.

Самый важный момент для понимания: сам массив имеет адрес, начиная с которого в памяти расположены его элементы. Поэтому для доступа к его элементам необходимо вычислять их адреса, как смещение относительно адреса массива, используя этот адрес в качестве базы и зная размер каждого элемента - 8 байт.  
21/09/2023 21:48 Post №296 [Delete]
Теперь, продолжая тему работы с данными, перейду к обсуждению основ использования стека. Про стек следует помнить четыре основных момента:

1. В отличии от других, зараннее определяемых секций данных, до момента выполнения программы, размер секции стека постоянно изменяется во время работы программы. Текущий размер определяет адрес, хранящийся в регистре rsp. Причем, размер стека (для x86 архитектуры) изменяется в сторону младших адресов. Данные стека занимают максимальные адреса виртуального пространства выполняемого процесса - от адреса 0x7fffffffffff и ниже.

2. Регистр rsp содержит адрес памяти откуда будут считаны данные размером (64 бит или 8 байт) при вызове команды pop.
При записи в стек командой push сначала адрес в регистре rsp уменьшается на 8, а только затем производится запись по новому адресу.

3. Изменяется размер стека кратно размеру регистра - 8 байт, то есть стек всегда выровнен относительно адресов, заканчивающихся на 0x0 и 0x8. Это всегда следует помнить при "ручном" выделении пространства в стеке, уменьшая значения регистра rsp на кратные 0x10 значения.


  sub  rsp, 0x40      ; зарезервировать в стеке 64 байта

21/09/2023 22:34 Post №297 [Delete]
Пример типичного дампа стека исполняемой программы в точке входа _start:


0x00007fffffffe420 : 00 00 00 00 00 00 00 00 - 00 00 00 00 00 00 00 00 ...............
0x00007fffffffe430 : 00 00 00 00 00 00 00 00 - 00 00 00 00 00 00 00 00 ...............
0x00007fffffffe440 : 01 00 00 00 00 00 00 00 - c3 e6 ff ff ff 7f 00 00 ...............
0x00007fffffffe450 : 00 00 00 00 00 00 00 00 - ea e6 ff ff ff 7f 00 00 ...............
0x00007fffffffe460 : fa e6 ff ff ff 7f 00 00 - 0c e7 ff ff ff 7f 00 00 ...............
0x00007fffffffe470 : 14 e7 ff ff ff 7f 00 00 - 2a e7 ff ff ff 7f 00 00 ........*......


При этом регистр указателя стека rsp содержит значение 0x00007fffffffe440. Как видно, по этому адресу записано значение 01 00 00 00 00 00 00 00, что в последовательности байт little endian, означает 0x0000000000000001 или просто 1. В данном случае это количество аргументов коммандной строки, переданных программе в момент ее запуска.

4. Так как заначение регистра rsp постоянно изменятся при использовании инструкций push  и pop использовать его для досупа к данным стека неудобно. Для упрощения работы со стеком используется другой специальный регистр: rbp в котором хранится постоянное значение относительно которого можно производить адресацию. Обычно в него копируется значение rsp на момент входа в вызываемую функцию или при передачи управления коду после метки входа в программу _start. При этом, предыдущее значение rbp, если оно используется вызывающим функцию кодом, следует сохранить и не забыть восстановить при выходе из функции:


  push rbp        ; сохранить предыдущее знание (при необходимости)
  mov  rbp, rsp   ; скопировать значение до использования стека

  ...

  mov  rsp, rbp   ; если производилось ручное изменение rsp, то необходимо
                  ; восстановить его начальное значение при входе
  pop  rbp        ; восстановить предыдущее значение rbp

21/09/2023 23:20 Post №298 [Delete]
А теперь рассмотрим на предыдущем примере дампа, как выглядит резервирование и использование данных в стеке.
Допустим, необходимо создать две автоматические (следуя терминологии языка С) переменные: целочисленную, размером в 8 байт и строку символов из 16 байт. Для этого следует уменьшить значение rsp на 32 или 0x20 для резервирования блока памяти:


  mov  rbp, rsp
  sub  rsp, 0x20


После этого


0x00007fffffffe420 : 00 00 00 00 00 00 00 00 - 00 00 00 00 00 00 00 00 ...............
0x00007fffffffe430 : 00 00 00 00 00 00 00 00 - 00 00 00 00 00 00 00 00 ...............
0x00007fffffffe440 : 01 00 00 00 00 00 00 00 - c3 e6 ff ff ff 7f 00 00 ...............


rbp будет содержать адрес: 0xe440, а rsp 0xe420. Что означает: область в 32 байта (заполненная нулями) начиная с 0xe420 и завершая 0xe43f может быть использована программой. При этом к области возможно обратися используя отрицательное смещение относительно занчения rbp. Например:


  mov  qword [bp - 0x8], 1 ; записать 1 по адресу 0xe440 - 0х8 = 0xe438
  mov  byte [bp - 0x20], `a` ; записать символ "а" по адресу 0xe440 - 0x20 = 0xe420


Здесь смещение bp - 0x8 соответствует целочисленной переменной размером 8 байт, а 0xe420 - началу строки. При этом, что бы обратиться ко второму символу строки надо использовать дополнительное смещение от ее начала: bp - 0x20 + 1 и т.д. При этом может быть использован регистр, для обращения ко всем адресам символов строки при последовательном увеличении его значения.

Следует обратить внимание, что область памяти с нулевым смещением [bp] нельзя использовать, потому что при этом будут затерты уже существующие данные, на которые должен указывать rsp при восстановлении его предыдущего значения.