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

🧮 Математика: Подробнее о знаковой и беззнаковой арифметике и флагах процессора — в Шпаргалке по 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

💡 Совет: Как избежать этой ошибки?

Всегда смотрите в заголовочный файл и используйте типы с явным размером из <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.

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

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

❌ Неправильно: забыли 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 при делении в старших 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
✅ Решение: используем 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 - для всех переменных и буферов, которые при старте должны быть нулевыми

📊 Наглядный пример: Разница в размерах

Если в .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

❌ Неправильно: 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 должен содержать количество векторных регистров (XMM0-XMM7), используемых для аргументов с плавающей точкой. Если вы не передаёте float/double, ставьте xor rax, rax.


📐 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/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?
💜

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

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

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