Вы написали код, он собрался без ошибок, запустился, но работает некорректно. Программа не «падает», однако результат далек от ожидаемого. Это одна из главных трудностей низкоуровневой разработки — логические ошибки.
«Тихие» баги коварнее, чем очевидные вылеты программы. Их причина проста: компьютер делает именно то, что вы ему приказали, даже если вы имели в виду совсем другое. В этой статье мы рассмотрим самые распространённые ловушки новичков: ошибки в арифметике, путаницу с регистрами и проблемы с памятью. Инструменты и методики обнаружения подобных ошибок подробно описаны в руководстве «Отладка 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
🔍 Подробнее: Почему movsx даёт отрицательное число?
Инструкция movsx проверяет старший бит исходного значения. Если он равен 1 (как в случае 200 = 11001000b), то это означает отрицательное число в системе дополнительного кода. movsx расширяет его как отрицательное, заполняя все старшие биты единицами.
Для беззнаковых чисел такое поведение катастрофично: 200 превращается в -56. Всегда используйте movzx для беззнаковых данных.
🧮 Математика: Подробнее о знаковой и беззнаковой арифметике и флагах процессора — в Шпаргалке по 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>- типы с явным размером (int32_tвместоint) -
Создавайте комментарии - указывайте размер рядом с
extern:extern my_var ; int64_t (8 байт) -
Тестируйте с GDB - проверяйте значения переменных после каждой операции. Принципы адресации и работы с памятью детально рассмотрены в статье «Массивы в 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(Greater): проверяетZF=0 AND SF=OF- для знаковыхja(Above): проверяетCF=0 AND ZF=0- для беззнаковых
Флаги SF (Sign Flag) и OF (Overflow Flag) имеют смысл только для знаковых чисел. Для беззнаковых используется CF (Carry Flag).
⚠️ 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, регистр RDX будет содержать случайный мусор, и процессор попытается разделить гигантское число (мусор << 64) + ваше_значение, что приведет к переполнению или неверному результату.
Пример:
- Вы хотите
-100 / 3 - Без
cqo: делится(случайный_RDX << 64) - 100- совершенно не то! - С
cqo: делится-100(правильно расширенное в 128 бит)
🤯 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- для всех переменных и буферов, которые при старте должны быть нулевыми
📊 Наглядный пример: Разница в размерах
section .data
const_string db "Hello", 0 ; 6 байт В ФАЙЛЕ
init_array dd 1, 2, 3, 4, 5 ; 20 байт В ФАЙЛЕ
section .bss
big_buffer resb 1000000 ; 0 байт в файле (!)
var_counter resd 1 ; 0 байт в файле
var_flags resq 10 ; 0 байт в файлеИтого в файле: 6 + 20 = 26 байт
Итого в памяти: 26 + 1000000 + 4 + 80 = 1000110 байт
Экономия места на диске: ~1 МБ!
📞 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 (младший байт RAX) должен содержать количество векторных регистров (XMM0-XMM7), используемых для передачи аргументов с плавающей точкой.
Для printf:
- Если вы НЕ передаёте
float/double- ставьтеxor rax, rax - Если передаёте 2 аргумента типа
double- ставьтеmov al, 2
Пример с float:
extern printf
section .data
fmt db "Float: %.2f", 10, 0
section .text
global main
main:
mov rdi, fmt
movsd xmm0, [float_value] ; Передаём double в XMM0
mov al, 1 ; Говорим: используется 1 векторный регистр
call printf
ret
📐 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 (Streaming SIMD Extensions) инструкции, которые работают с 128-битными (16-байтными) данными. Многие из этих инструкций требуют, чтобы данные были выровнены по 16-байтной границе для максимальной производительности (а некоторые просто не работают без выравнивания).
Стек — это место, где часто размещаются локальные переменные, и если он не выровнен, SSE инструкции могут:
- Работать медленнее (невыровненный доступ)
- Вызвать исключение (на некоторых платформах)
Правило просто: всегда резервируйте на стеке кратное 16 байтам.
Удобная формула:
; Если нужно N байт, резервируйте:
sub rsp, ((N + 15) / 16) * 16 ; Округление вверх до 16Примеры:
- Нужно 8 байт → резервируем 16
- Нужно 20 байт → резервируем 32
- Нужно 64 байта → резервируем 64 (уже кратно 16)
✅ Заключение
Большинство «тихих» ошибок в ассемблере возникает по одной причине: процессор выполняет ваши команды буквально, а не так, как вы задумали. Например, вы хотели работать с беззнаковым числом, а использовали команду для знакового. Или выделили два байта памяти, а инструкция записала четыре. Чтобы избегать таких проблем, нужно давать процессору предельно точные команды, иначе результат будет неверным.
Чек-лист: проверка перед запуском
Перед тем как запустить свою программу, пройдитесь по этому списку:
- Расширение данных: Используете
movzxдля беззнаковых иmovsxдля знаковых? - Размеры переменных: Указатели размера (
byte/word/dword/qword) соответствуют реальным размерам в.hфайле? - Условные переходы: Используете
ja/jbдля беззнаковых иjg/jlдля знаковых? - Деление: Не забыли
cqo/cdqпередidiv? - 64-битные регистры: Очищаете старшие биты через 32-битные операции?
- Секции: Большие буферы определены в
.bss, а не в.data? - Передача аргументов: Используете регистры (
RDI,RSI…), а неpush? - Выравнивание стека: Резервируете кратное 16 байтам перед
call?