Nikita Mandrykin

Практикуюсь в программировании. C • Python • Assembly • Linux

Назад

Топ ошибок в NASM: Почему падает Segfault и неверные расчёты

📚 Содержание

Вы написали код, он собрался без ошибок, запустился, но работает некорректно. Программа не «падает», однако результат далек от ожидаемого. Это одна из главных трудностей низкоуровневой разработки — логические ошибки.

«Тихие» баги коварнее, чем очевидные вылеты программы. Их причина проста: компьютер делает именно то, что вы ему приказали, даже если вы имели в виду совсем другое. В этой статье мы рассмотрим самые распространённые ловушки новичков: ошибки в арифметике, путаницу с регистрами и проблемы с памятью. Инструменты и методики обнаружения подобных ошибок подробно описаны в руководстве «Отладка 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). ❌ Неправильно.

Пример: обработка беззнакового числа

❌ Неправильно: movsx для беззнакового
section .data
    unsigned_byte db 200  ; 0xC8 (беззнаковое)

section .text
    global main

main:
    movsx rax, byte [unsigned_byte]
    ; RAX = 0xFFFFFFFFFFFFFFC8 = -56
    ; Ожидали 200, получили -56!
    ret
✅ Правильно: movzx для беззнакового
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).

Пример: чтение переменной неправильного размера

❌ Неправильно: читаем 2 байта из 4-байтовой переменной
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
✅ Правильно: читаем все 4 байта
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
💡 Совет: Как избежать этой ошибки?
  1. Всегда смотрите в заголовочный файл - проверяйте тип переменной

  2. Используйте <stdint.h> - типы с явным размером (int32_t вместо int)

  3. Создавайте комментарии - указывайте размер рядом с extern:

    extern my_var  ; int64_t (8 байт)

  4. Тестируйте с 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.

Пример: сравнение беззнаковых чисел

❌ Неправильно: jg для беззнаковых
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
✅ Правильно: ja для беззнаковых
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-битных чисел

❌ Неправильно: забыли cqo
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
✅ Правильно: используем cqo
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
✅ Решение: используем 32-битную запись
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

❌ Неправильно: 32-битный стиль
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)

Как это работает

  1. При входе в main: ОС выравнивает стек так, что RSP % 16 == 8 (из-за адреса возврата)
  2. После push rbp: RSP % 16 == 0 (выровнен!)
  3. Ваша задача: сохранить это выравнивание перед каждым 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 инструкции могут:

  1. Работать медленнее (невыровненный доступ)
  2. Вызвать исключение (на некоторых платформах)

Правило просто: всегда резервируйте на стеке кратное 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?
💜

Полезный материал?

Если статья помогла разобраться в теме, вы можете поддержать автора любой комфортной суммой.

Поддержать через CloudTips