Вы написали код, он собрался без ошибок, запустился, но работает некорректно. Программа не «падает», однако результат далек от ожидаемого. Это одна из главных трудностей низкоуровневой разработки — логические ошибки.
«Тихие» баги коварнее, чем очевидные вылеты программы. Их причина проста: компьютер делает именно то, что вы ему приказали, даже если вы имели в виду совсем другое. В этой статье мы рассмотрим самые распространённые ловушки новичков: ошибки в арифметике, путаницу с регистрами и проблемы с памятью. Инструменты и методики обнаружения подобных ошибок подробно описаны в руководстве «Отладка ASM в VS Code: Настройка GDB и визуальный интерфейс».
📈 1. Неправильное расширение данных: movsx для беззнаковых чисел
Одна из самых частых ошибок — путать, когда использовать movsx (для знаковых) и movzx (для беззнаковых). Полный перечень инструкций для работы с расширением знака приведён в Шпаргалке по NASM, в разделе «Манипуляции с регистрами».
Справочная таблица расширения
| Инструкция | Предназначение | Поведение | Пример (AL = 150, или 10011110b) |
|---|---|---|---|
movzx rax, al |
Для беззнаковых чисел | Заполняет старшие биты нулями (0). |
RAX станет 0x...0096 (150). ✅ Правильно. |
movsx rax, al |
Для знаковых чисел | Копирует знаковый бит (1) в старшие биты. |
RAX станет 0x...FF96 (-106). ❌ Неправильно. |
Пример: обработка беззнакового числа
section .data
unsigned_byte db 200 ; 0xC8 (беззнаковое)
section .text
global main
main:
movsx rax, byte [unsigned_byte]
; RAX = 0xFFFFFFFFFFFFFFC8 = -56
; Ожидали 200, получили -56!
ret
section .data
unsigned_byte db 200 ; 0xC8 (беззнаковое)
section .text
global main
main:
movzx rax, byte [unsigned_byte]
; RAX = 0x00000000000000C8 = 200
; Получили то, что ожидали!
ret
🧮 Математика: Подробнее о знаковой и беззнаковой арифметике и флагах процессора — в Шпаргалке по NASM, в разделе «Суффиксы размера данных».
📏 2. Ошибка несоответствия размеров: нарушение “контракта” с памятью
Это самая опасная ошибка, так как она приводит к тихому повреждению данных. Суть ошибки: размер, который вы указываете в ассемблерной инструкции (byte, word…), не совпадает с реальным размером переменной в памяти. Для сверки суффиксов и их размеров обратитесь к таблице «Суффиксы размера данных» в Памятке.
Справочная таблица: Типы в C/C++ и их эквиваленты в NASM
Тип в C/C++ (<stdint.h>) |
Размер (байты) | Директива в NASM | Указатель размера |
|---|---|---|---|
int8_t / char |
1 | db |
byte |
int16_t / short |
2 | dw |
word |
int32_t / int |
4 | dd |
dword |
int64_t / long long |
8 | dq |
qword |
⚠️ КРИТИЧНО: При использовании
externассемблер «не видит» типа переменной, он видит лишь метку в памяти. Вы обязаны точно знать размер данных (byte, int, long), который там хранится. Единственный надёжный источник этой информации — исходный код на C или заголовочные файлы (.h).
Пример: чтение переменной неправильного размера
extern c_variable ; int32_t (4 байта)
section .text
global asm_function
asm_function:
; Переменная c_variable занимает 4 байта
movzx rax, word [c_variable]
; Прочитали только младшие 2 байта!
; Если c_variable = 0x12345678,
; то RAX = 0x5678 (потеряли 0x1234)
ret
extern c_variable ; int32_t (4 байта)
section .text
global asm_function
asm_function:
; Читаем все 4 байта
mov eax, dword [c_variable]
; Если c_variable = 0x12345678,
; то EAX = 0x12345678 (всё корректно)
ret
💡 Совет: Как избежать этой ошибки?
Всегда смотрите в заголовочный файл и используйте типы с явным размером из
<stdint.h>. Оставляйте комментарии рядом сexternс указанием размера переменной. Принципы работы с памятью детально рассмотрены в статье «Массивы в NASM».
🔀 3. Путаница в условных переходах: jg вместо ja
Инструкция cmp просто вычитает операнды и выставляет флаги. Ваша задача — выбрать инструкцию перехода, которая правильно эти флаги прочитает. Сводная таблица всех условных переходов для знаковых и беззнаковых чисел доступна в Шпаргалке (раздел «Условные переходы»).
Справочная таблица условных переходов
| Условие | Беззнаковый переход | Знаковый переход | Универсальный переход |
|---|---|---|---|
| Равно | - | - | je / jz |
| Не равно | - | - | jne / jnz |
| > | ja (Above) |
jg (Greater) |
- |
| >= | jae (Above or Equal) |
jge (Greater or Equal) |
- |
| < | jb (Below) |
jl (Less) |
- |
| <= | jbe (Below or Equal) |
jle (Less or Equal) |
- |
🎯 Мнемоника:
- Above/Below (Выше/Ниже) — это про физическое расположение (как полки в шкафу или Адреса в памяти). Адреса не бывают отрицательными → Unsigned.
- Greater/Less (Больше/Меньше) — это чисто математические термины для величин. В математике есть отрицательные числа → Signed.
Пример: сравнение беззнаковых чисел
section .data
num1 dq 255 ; 0xFF (беззнаковое)
num2 dq -1 ; 0xFFFFFFFFFFFFFFFF
section .text
global main
main:
mov rax, [num1] ; RAX = 0xFF
mov rbx, [num2] ; RBX = 0xFFFF...FFFF
cmp rax, rbx
jg greater ; Проверяет ЗНАКОВОЕ сравнение
; 255 > -1? Нет! (как знаковое)
; Переход НЕ произойдет
greater:
; Не выполнится!
ret
section .data
num1 dq 255 ; 0xFF (беззнаковое)
num2 dq -1 ; 0xFFFFFFFFFFFFFFFF
section .text
global main
main:
mov rax, [num1] ; RAX = 0xFF
mov rbx, [num2] ; RBX = 0xFFFF...FFFF
cmp rax, rbx
ja above ; Проверяет БЕЗЗНАКОВОЕ сравнение
; 255 > 18446744073709551615? Нет!
; Переход НЕ произойдет (и это ПРАВИЛЬНО)
above:
; Корректная логика
ret
🔬 Техническая деталь:
jgпроверяетZF=0 AND SF=OF(знаковые флаги).jaпроверяетCF=0 AND ZF=0(для беззнаковых используется флаг переносаCF).
⚠️ 4. Забытая подготовка к знаковому делению (idiv)
Инструкция idiv требует, чтобы делимое было в два раза больше делителя. Для этого существуют специальные инструкции, расширяющие аккумулятор. Требования к подготовке регистров перед делением описаны в Шпаргалке в разделе «Деление (div, idiv)».
Справочная таблица расширения для деления
| Инструкция | Преобразует | В пару регистров | Для деления… |
|---|---|---|---|
cbw |
8-бит AL в 16-бит AX |
AX |
8-битного делителя |
cwd |
16-бит AX в 32-бит DX:AX |
DX:AX |
16-битного делителя |
cdq |
32-бит EAX в 64-бит EDX:EAX |
EDX:EAX |
32-битного делителя |
cqo |
64-бит RAX в 128-бит RDX:RAX |
RDX:RAX |
64-битного делителя |
Пример: знаковое деление 64-битных чисел
section .data
dividend dq -100
divisor dq 3
section .text
global main
main:
mov rax, [dividend] ; RAX = -100
; Забыли расширить RAX в RDX:RAX!
; RDX содержит случайный мусор
mov rbx, [divisor]
idiv rbx ; СБОЙ или неверный результат!
; idiv ожидает делимое в RDX:RAX
ret
section .data
dividend dq -100
divisor dq 3
section .text
global main
main:
mov rax, [dividend] ; RAX = -100
cqo ; Расширяем RAX в RDX:RAX
; RDX:RAX = -100 (с знаковым расширением)
mov rbx, [divisor]
idiv rbx ; RAX = -33, RDX = -1
ret
⚙️ Как работает расширение для деления: Инструкция
idivделит 128-битное числоRDX:RAXи помещает частное вRAX, а остаток вRDX. Поэтому безcqoпри делении в старших 64 битах будет случайный мусор, что приведёт к переполнению (overflow exception).
🤯 5. Тонкости работы с 64-битными регистрами
Запись в 32-битную часть регистра очищает его старшую половину, а запись в 16- или 8-битную — нет. Для понимания вложенности регистров (AX внутри EAX и т.д.) рекомендуется изучить схему в разделе «Анатомия и иерархия регистров» Памятки.
| Операция | Цель | Эффект на старшие 32 бита RAX | Результат в RAX (если он был 0xAAAAAAAAAAAAAAAA) |
|---|---|---|---|
mov al, 0x12 |
Изменить младшие 8 бит | Не затрагиваются | 0xAAAAAAAAAAAAAAAA12 ⚠️ |
mov ax, 0x1234 |
Изменить младшие 16 бит | Не затрагиваются | 0xAAAAAAAAAAAA1234 ⚠️ |
mov eax, 0x12345678 |
Изменить младшие 32 бита | Обнуляются! | 0x0000000012345678 ✅ |
Пример: неожиданное сохранение старших битов
section .text
global main
main:
mov rax, 0xAAAAAAAAAAAAAAAA ; Заполняем RAX
; Пытаемся записать новое 16-битное значение
mov ax, 0x1234
; RAX = 0xAAAAAAAAAAAA1234
; Старшие биты НЕ очистились!
; Если потом используем RAX как 64-битное число:
add rax, 1
; RAX = 0xAAAAAAAAAAAA1235
; Это НЕ то, что мы ожидали!
ret
section .text
global main
main:
mov rax, 0xAAAAAAAAAAAAAAAA ; Заполняем RAX
; Правильный способ очистить старшие биты
movzx rax, word 0x1234
; или
mov eax, 0x1234 ; Это очистит старшие 32 бита
; RAX = 0x0000000000001234
; Теперь безопасно использовать RAX
add rax, 1
; RAX = 0x0000000000001235 - правильно!
ret
💡 Правило большого пальца: Если работаете с 64-битными регистрами, всегда используйте 32-битные операции для очистки (запись в
eaxвместоax), или явно применяйтеmovzx.
💾 6. Раздувание файла: db 0 в .data вместо resb в .bss
Эта ошибка не сломает программу, но сделает ваш исполняемый файл неоправданно большим, из-за непонимания разницы между секциями .data и .bss.
Сравнение секций .data и .bss
| Секция | Назначение | Как работает | Размер файла |
|---|---|---|---|
.data |
Инициализированные данные | Всё, что вы определяете здесь (db, dw…), включается в исполняемый файл. |
Увеличивается на размер данных |
.bss |
Неинициализированные данные | Ассемблер лишь сообщает ОС, сколько места нужно. Сами байты в файл не записываются. | Не увеличивается |
Пример: буфер на 1 МБ
section .data
buffer times 1048576 db 0
section .text
global main
main:
lea rax, [buffer]
ret
Результат:
$ ls -lh program
-rwxr-xr-x 1 user user 1.0M program # ОГРОМНЫЙ ФАЙЛ!
section .bss
buffer resb 1048576
section .text
global main
main:
lea rax, [buffer]
ret
Результат:
$ ls -lh program
-rwxr-xr-x 1 user user 8.2K program # Компактный!
🎯 Золотое правило:
.data- только для констант и предзаполненных массивов.bss- для всех переменных и буферов, которые при старте должны быть нулевыми
📊 Наглядный пример: Разница в размерах
Если в
.dataобъявитьbig_buffer resb 1000000, но черезdb 0, то нули запишутся в бинарник, увеличив его размер на 1 МБ. В.bssдирективаresb 1000000лишь сообщает ОС о необходимости выделить память при старте, не занимая место в самом файле.
📞 7. Нарушение контракта вызова: аргументы в стеке вместо регистров
Многие новички, особенно те, кто пришел из 32-битной эры, пытаются передавать аргументы в функции через стек. В 64-битном Linux (соглашение System V ABI) это неправильно и приводит к тихим, трудноуловимым ошибкам. Список регистров, их назначение и правила сохранения (callee-saved/caller-saved) приведены в Шпаргалке в таблице «Роли регистров (System V ABI)».
Порядок передачи аргументов в System V ABI (Linux x86-64)
| Номер аргумента (целочисленный/указатель) | Регистр для передачи |
|---|---|
| 1-й | RDI |
| 2-й | RSI |
| 3-й | RDX |
| 4-й | RCX |
| 5-й | R8 |
| 6-й | R9 |
| 7-й и далее | Через стек (в обратном порядке) |
Пример: вызов printf
extern printf
section .data
fmt db "Number: %d", 10, 0
num dq 42
section .text
global main
main:
; ОШИБКА: Пытаемся передать через стек
push qword [num] ; "Передаём" второй аргумент
push fmt ; "Передаём" первый аргумент
call printf
add rsp, 16 ; Очищаем стек
xor rax, rax
ret
Результат:
printf игнорирует ваши push и читает мусор из регистров RDI и RSI. Выведет случайные данные или упадет.
extern printf
section .data
fmt db "Number: %d", 10, 0
num dq 42
section .text
global main
main:
; ПРАВИЛЬНО: Используем регистры
mov rdi, fmt ; 1-й аргумент: формат-строка
mov rsi, [num] ; 2-й аргумент: число
xor rax, rax ; Кол-во векторных регистров (для printf)
call printf
xor rax, rax
ret
Результат:
Number: 42 - работает как ожидалось!
🔍 Детали: Почему RAX обнуляется перед printf?
Согласно System V ABI, регистр
ALдолжен содержать количество векторных регистров (XMM0-XMM7), используемых для аргументов с плавающей точкой. Если вы не передаётеfloat/double, ставьтеxor rax, rax.
📐 8. Неверное выравнивание стека
Это второй аспект соглашения о вызовах: стек должен быть выровнен по 16-байтной границе перед выполнением инструкции call.
Правило выравнивания
Перед каждым
call: значениеRSPдолжно быть кратно 16 (т.е.RSP % 16 == 0)
Как это работает
- При входе в
main: ОС выравнивает стек так, чтоRSP % 16 == 8(из-за адреса возврата) - После
push rbp:RSP % 16 == 0(выровнен!) - Ваша задача: сохранить это выравнивание перед каждым
call
Пример: правильное выравнивание
section .text
global main
extern printf
main:
push rbp ; RSP % 16 == 0 (выровнен)
mov rbp, rsp
sub rsp, 8 ; Резервируем 8 байт
; Теперь RSP % 16 == 8 (НЕ выровнен!)
call printf ; ОШИБКА: стек не выровнен!
; Может привести к сбою в SSE инструкциях
mov rsp, rbp
pop rbp
ret
Последствия:
- Сбой при использовании SSE/AVX инструкций в вызываемой функции
- Непредсказуемое поведение на некоторых системах
section .text
global main
extern printf
main:
push rbp ; RSP % 16 == 0 (выровнен)
mov rbp, rsp
sub rsp, 16 ; Резервируем 16 байт (кратно 16!)
; RSP % 16 == 0 (всё ещё выровнен)
call printf ; Отлично: стек выровнен!
mov rsp, rbp
pop rbp
ret
ИЛИ ПРОЩЕ:
section .text
global main
extern printf
main:
push rbp
mov rbp, rsp
; НЕ резервируем ничего - стек УЖЕ выровнен
; RSP % 16 == 0
call printf ; ✅ Стек выровнен!
pop rbp
ret
Важно: Работает только если вы ничего не push’ите после push rbp!
⚙️ Почему именно 16 байт: Современные процессоры используют инструкции SSE/AVX для оптимизации (в том числе внутри
printf), которые требуют выравнивания данных в памяти по 16-байтной границе. Невыровненный стек может привести к Segmentation Fault глубоко внутри системных библиотек. Удобная формула выравнивания для N байт:sub rsp, ((N + 15) / 16) * 16.
Чек-лист: проверка перед запуском
Перед тем как запустить свою программу, пройдитесь по этому списку:
- Расширение данных: Используете
movzxдля беззнаковых иmovsxдля знаковых? - Размеры переменных: Указатели размера (
byte/word/dword/qword) соответствуют реальным размерам в.hфайле? - Условные переходы: Используете
ja/jbдля беззнаковых иjg/jlдля знаковых? - Деление: Не забыли
cqo/cdqпередidiv? - 64-битные регистры: Очищаете старшие биты через 32-битные операции?
- Секции: Большие буферы определены в
.bss, а не в.data? - Передача аргументов: Используете регистры (
RDI,RSI…), а неpush? - Выравнивание стека: Резервируете кратное 16 байтам перед
call?