Основы отладки микроконтроллерных систем
Разбираем инструменты и техники отладки встраиваемых систем на базе Arduino: от трассировки кода до осциллографов и SDR-анализаторов спектра.
Встраиваемые системы — это микропроцессорные или микроконтроллерные устройства с конкретной функциональной ролью. В отличие от настольных компьютеров, ноутбуков или игровых консолей, которые собираются из отдельных компонентов, встраиваемые системы объединяют всё необходимое железо и программное обеспечение в одном устройстве. Сегодня они повсюду: автомобили, камеры, бытовая техника, мобильные устройства — всё это примеры встраиваемых систем.
Разработка встраиваемых систем — задача непростая, потому что она одновременно затрагивает аппаратную часть, прошивку и программное обеспечение. Чтобы получить качественный результат, без отладки не обойтись. Отладка — это процесс последовательной проверки каждого допущения, которое мы делаем о работе кода. Когда одно из этих допущений оказывается неверным — мы нашли «баг».
Слово «баг» используется уже очень давно; даже Thomas Alva Edison употреблял его в своё время. Исторически «bug» означало «монстр» — как гремлины в механизмах, баги вредят и мешают.
В этом уроке рассмотрим основные инструменты и техники отладки микроконтроллерных систем, особенно построенных на платформе Arduino® hardware.
Инструменты и техники отладки
Существует несколько базовых инструментов и подходов для проверки корректности кода:
Компилятор и синтаксические ошибки. Классические техники: трассировочный код и GPIOs. Удалённые отладчики. Симуляторы. Внутрисхемные эмуляторы и внутрисхемные отладчики. Аппаратные инструменты: мультиметры, логические анализаторы, осциллографы и программно-определяемые радиосистемы.
Разберём каждый из них подробнее.
Компилятор и синтаксические ошибки
Компиляция — это преобразование высокоуровневого кода в машинный язык, понятный процессору или микроконтроллеру. В ходе этого процесса компилятор также выявляет синтаксические ошибки. Синтаксическая ошибка означает, что в программе что-то написано неправильно с точки зрения языка. Классический пример — пропущенная точка с запятой в конце оператора: компилятор сразу же сообщит об ошибке.

Работа с ошибками компилятора бывает непростой. Вот две типичные ситуации:
Компилятор показывает 100 ошибок: это почти никогда не означает, что ошибок действительно сто. Обнаружив первую ошибку, компилятор теряет ориентацию и начинает сообщать о «ложных» проблемах. Надёжна только первая ошибка; исправьте её и скомпилируйте снова. Непонятные сообщения об ошибках: компилятор пишет кратко и технически, но в каждом сообщении есть ценная информация. Читайте внимательно — там всегда указано, где именно в коде возникла проблема.
Классические техники: трассировочный код и GPIO
Добавление трассировочного кода — наверное, самый простой и распространённый способ отладки встраиваемых систем. Суть в том, чтобы вставить в программу вывод сообщений или значений переменных (например, через функцию Serial.print()) прямо во время выполнения. Например, чтобы выяснить, не зависает ли определённая функция, можно сделать так:
// Вывести сообщение, если выполнение дошло до этой точки
Serial.println("Code got here");
// Попытаться выполнить myFunction1()
myFunction1();
// Вывести сообщение, если выполнение дошло до этой точки
Serial.println("Code got here, myFunction1 executed");
// Попытаться выполнить myFunction2()
myFunction2();
// Вывести сообщение, если выполнение дошло до этой точки
Serial.println("Code got here, myFunction2 executed");
Трассировочный код хорошо работает на ранних этапах разработки. Однако у него есть существенный минус: вывод данных занимает процессорное время и ресурсы, что может нарушить критичные по времени задачи. Кроме того, если UART уже используется для других целей, добавить трассировку будет затруднительно.
Чтобы не расходовать оперативную память на строки, передавайте их в Serial.print() через макрос F(). Например, Serial.println(F("Code got here")) хранит строку во флеш-памяти, а не в RAM.
Ещё одна техника трассировки — сброс данных в массив во время выполнения (dump into array). Вместо немедленного вывода мы записываем нужные значения в буфер, а смотрим на результаты позже — например, когда программа завершится. Предположим, что good и bad — две переменные, которые нас интересуют. Сначала объявляем буфер в RAM:
#define DUMP_BUFFER_SIZE 32
unsigned char goodBuffer[DUMP_BUFFER_SIZE];
unsigned char badBuffer[DUMP_BUFFER_SIZE];
unsigned long count = 0;
Переменная count служит индексом в буфере — её нужно обнулить перед началом отладки. Сам сброс данных из переменных good и bad выглядит так:
void Save_Debug_Buffer(void) {
if (count < DUMP_BUFFER_SIZE) {
goodBuffer[count] = good;
badBuffer[count] = bad;
count++;
}
}
Пины общего назначения (GPIO) — ещё один удобный инструмент отладки, особенно когда UART занят или трассировочный вывод неприменим. Например, можно вставить инструкцию digitalWrite(LED_BUILTIN, HIGH) до или после подозрительного участка кода. Если встроенный светодиод загорится — значит, соответствующая строка выполнилась:
// Вывести сообщение, если выполнение дошло до этой точки
Serial.println("Code got here");
// Попытаться выполнить myFunction1()
myFunction1();
// Включить встроенный светодиод на одну секунду, чтобы обозначить выполнение myFunction1
digitalWrite(LED_BUILTIN, HIGH);
delay(1000);
digitalWrite(LED_BUILTIN, LOW);
// Попытаться выполнить myFunction2()
myFunction2();
// Включить встроенный светодиод на одну секунду, чтобы обозначить выполнение myFunction2
digitalWrite(LED_BUILTIN, HIGH);
delay(1000);
digitalWrite(LED_BUILTIN, LOW);
Практические замечания
- Не забывайте удалять трассировочный код из финальной версии прошивки — он занимает место и замедляет работу.
- Если вы используете несколько точек трассировки, давайте им различимые метки, чтобы не запутаться в выводе.
- Метод dump into array особенно ценен, когда нельзя вывести данные в реальном времени (например, в прерываниях).
Удалённые отладчики
Удалённая отладка — распространённый подход, при котором встраиваемая система подключается к хост-компьютеру, а программное обеспечение на компьютере управляет процессом отладки. Это особенно полезно, когда среда разработки и целевая система работают на разных архитектурах — например, вы пишете код на Windows-компьютере для микроконтроллера на ARM.
Удалённый отладчик состоит из двух частей:
Фронтенд-отладчик — пользовательский интерфейс (графический или командная строка), через который разработчик управляет выполнением кода на целевом устройстве. Бэкенд-отладчик (debug monitor) — часть, специфичная для конкретной архитектуры процессора. Он запускается при сбросе процессора и обеспечивает связь между фронтендом и аппаратной частью встраиваемой системы.
Отладчик — относительно новая и пока не очень известная функция Arduino IDE 2. Посмотрите this tutorial, где показано, как пользоваться отладчиком Arduino® IDE 2 на поддерживаемых платах.
Симуляторы
Симуляторы воспроизводят поведение и систему команд целевого процессора в программной среде. Как правило, они моделируют только сам процессор, но не его окружение и внешние компоненты. Симуляторы особенно полезны на ранних стадиях разработки, когда программный код уже есть, а железо ещё не готово.
Tinkercad Circuits — отличный симулятор для начинающих в экосистеме Arduino®. Он умеет симулировать Arduino® UNO и многие электронные компоненты: резисторы, LEDs, моторы, LCDs и некоторые датчики.

Внутрисхемные эмуляторы и внутрисхемные отладчики
Внутрисхемный эмулятор (ICE, In-Circuit Emulator) — специализированный инструмент, позволяющий наблюдать состояние процессора прямо во время выполнения программы. ICEs — это, по сути, самостоятельные встраиваемые системы: они содержат полную копию целевого процессора и его памяти (RAM и ROM), что обеспечивает ненавязчивую отладку. Исторически ICEs были главным инструментом разработчиков встраиваемых систем, однако с ростом сложности и тактовых частот процессоров ICEs стали дорогими и труднодоступными.

Внутрисхемный отладчик (ICD, In-Circuit Debugger) — более доступный инструмент, который подключается между хост-компьютером и процессором. Он использует часть памяти и GPIO-пинов целевого микроконтроллера. Через интерфейс (например, JTAG) разработчик получает доступ к встроенному отладочному модулю CPU: загружает программу, запускает, останавливает и выполняет код пошагово.

Ключевое различие между ICE и ICD — в источнике ресурсов для управления отладочным процессом. В ICEs ресурсы предоставляет аппаратура эмулятора; в ICDs — сам целевой процессор.
Поддержка ICD в платах Arduino®
Платы Arduino® с микроконтроллером SAMD поддерживают ICD-отладку:
Zero Nano 33 IoT MKR Zero MKR WiFi 1010 MKR WAN 1300 MKR WAN 1310 MKR FOX 1200 MKR NB 1500 MKR GSM 1400 MKR Vidor 4000
Плата Arduino® Zero оснащена встроенным отладчиком Atmel® Embedded Debugger (EDGB). Помимо программирования и отладки, EDGB поддерживает потоковую передачу данных между хост-компьютером и целевым процессором. Подробнее о возможностях отладки Arduino® Zero читайте в this tutorial — там описана работа с Arduino IDE 2.

Платы Arduino® на базе SAMD поддерживают встроенную отладку через интерфейсы JTAG или SWD. CMSIS-DAP совместимые отладочные пробники работают с Arduino IDE 2 «из коробки» без дополнительной настройки; нестандартные пробники требуют специальной конфигурации. Подробности — в следующих руководствах:
Debugging with the SEGGER J-Link Debugging with the Atmel-ICE
Платы Arduino® Portenta H7, H7 Lite и H7 Lite Connected из семейства Pro family также поддерживают ICD-отладку через отладчик TRACE32 от Lauterbach. Инструмент TRACE32 позволяет тестировать аппаратуру и программное обеспечение через встроенный интерфейс отладки процессора. Руководство по работе с TRACE32 для плат семейства Portenta:
* Lauterbach TRACE32 GDB Front-End Debugger for Portenta H7

Аппаратные инструменты
Разработчики встраиваемых систем, в отличие от «чистых» программистов, работают значительно ближе к железу. Для понимания того, что происходит на аппаратном уровне, используются специальные инструменты: мультиметры, логические анализаторы, осциллографы и программно-определяемые радиосистемы (SDRs).
Мультиметры, логические анализаторы, осциллографы и SDR помогают отлаживать взаимодействие процессора с другими компонентами схемы. Они не управляют потоком выполнения кода.
Мультиметры
Цифровой мультиметр (DMM) измеряет электрические величины — обычно напряжение (в вольтах), ток (в амперах) и сопротивление (в омах). DMMs — один из базовых инструментов в арсенале разработчика встраиваемых систем: они незаменимы при поиске электрических неисправностей.

Логические анализаторы
Логический анализатор предназначен для захвата, отображения и измерения цифровых сигналов в схеме. Он имеет несколько входных каналов, каждый из которых определяет логический уровень сигнала (1 или 0). Логические анализаторы показывают временны́е соотношения между сигналами и нередко умеют декодировать цифровые протоколы связи (например, SPI).

Осциллографы
Осциллограф отображает электрические сигналы в виде графика и показывает, как они меняются во времени. Сигнал подаётся через щуп-датчик.

Программно-определяемые радиосистемы
Программно-определяемая радиосистема (SDR) — это система радиосвязи, в которой модуляция и демодуляция сигналов реализованы программно. В традиционных радиосистемах эти функции выполняет аппаратура, что существенно ограничивает гибкость перенастройки. SDRs гораздо гибче: их конфигурацию можно изменить программно.

Отладка с помощью аппаратных инструментов
Простейший аппаратный способ отладки — использовать светодиод как индикатор прохождения кода. Вставляя команды включения/выключения LED в разные точки программы, можно визуально отслеживать, до какого места доходит выполнение. Метод не даёт детальной информации о регистрах или передаче данных, зато работает быстро и не требует никакого дополнительного оборудования — особенно когда отладчик недоступен.
Если LEDs в системе нет или они недоступны для визуального контроля, на помощь придёт осциллограф. Он позволяет наблюдать состояние GPIO-пинов в реальном времени: достаточно управлять нужным пином из кода и смотреть на форму сигнала. DMM справится с той же задачей для статических состояний.
Осциллограф особенно ценен для измерения производительности — определения электрических и временны́х характеристик сигналов. Например, с его помощью можно обнаружить лишние задержки в коде:
void myFunction() {
digitalWrite(LED_BUILTIN, HIGH);
Serial.println("Code got here");
count++;
digitalWrite(LED_BUILTIN, LOW);
}
Длительность выполнения myFunction() можно измерить, подняв GPIO-пин в HIGH в начале функции и опустив в LOW в конце. Осциллограф покажет точное время выполнения, а также любые отклонения от ожидаемого поведения. Аналогично можно проверить myFunction().
Теперь поговорим о беспроводной связи. Беспроводные коммуникации — ключевая особенность современных IoT-устройств (IoT). Как отлаживать беспроводной обмен между устройствами?
Распространённый приём — использование флагов подтверждения (acknowledge flags). Они помогают понять поведение устройств при установке соединения, показывая их текущий статус. Этот принцип лежит в основе многих физических протоколов, таких как I2C и SPI. Самый простой способ убедиться в успешном обмене данными — проверить журнал данных на каждом из устройств. Для более глубокого анализа подключайте аппаратные инструменты: DMMs, осциллографы и логические анализаторы.
Однако не всё взаимодействие происходит на физическом уровне — часть работы ведётся на абстрактном уровне электромагнитных волн. Иногда нужно убедиться, что конфигурация беспроводного передатчика корректна — например, правильно ли выставлена мощность передачи. Здесь на помощь приходят SDRs: SDRs можно использовать как дешёвые анализаторы спектра. Анализатор спектра измеряет мощность сигнала в зависимости от частоты. Использование SDRs в роли анализатора спектра — опциональный, но полезный приём для обеспечения надёжной беспроводной связи.
С SDRs работает несколько популярных программ: GQRX — одна из самых известных; GQRX — открытый и кроссплатформенный (Linux и macOS). AirSpy и CubicSDR — другие популярные варианты с поддержкой SDRs и кроссплатформенностью (Windows, Linux и macOS).
Визуализация сигнала в SDR-программе позволяет проверить мощность передачи, количество переданных байт и рабочую частоту. Так можно отладить параметры беспроводной конфигурации и добиться максимальной эффективности связи.

Пример применения техник отладки
Рассмотрим практический пример, который покажет, как описанные техники работают в реальном проекте Arduino®. Мы используем плату Arduino® Nano 33 BLE Sense с её встроенным инерциальным измерительным блоком (IMU). Пример одновременно считывает данные акселерометра, гироскопа и магнетометра:
/*
Программа:
- Debugging_techniques_example.ino
Описание:
- Этот пример объединяет данные акселерометра, гироскопа и магнетометра LSM9DS1 в одном коде. Кроме того,
он помогает понять различные методы и техники отладки, когда структура кода включает несколько задач.
Схема:
- Arduino Nano 33 BLE Sense.
Код основан на примерах, созданных Riccardo Rizzo, Jose García и Benjamin Dannegård.
Изменён Taddy Ho Chung и José Bagur (16/02/22).
*/
#include <Arduino_LSM9DS1.h>
#define DUMP_BUFFER_SIZE 32
unsigned char GoodBuffer[DUMP_BUFFER_SIZE];
unsigned char BadBuffer[DUMP_BUFFER_SIZE];
unsigned long count = 0;
uint8_t good, bad = 0;
float x, y, z, ledvalue;
int degreesX = 0, degreesY = 0;
int plusThreshold = 30, minusThreshold = -30;
void setup() {
Serial.begin(9600);
while (!Serial);
Serial.println("- Started");
if (!IMU.begin()) {
Serial.println("- Failed to initialize IMU!");
bad++;
save_debug_buffer();
disp_debug_buffer();
debug_stop();
}
accelermeter_setup();
gyroscope_setup();
}
void loop() {
for (int i = 0; i < 5; i++) {
accelerometer_task();
gyroscope_task();
magnetometer_task();
}
save_debug_buffer();
debug_stop();
}
// Настройка акселерометра
void accelermeter_setup() {
Serial.print(F("- Accelerometer sample rate: "));
Serial.print(IMU.accelerationSampleRate());
Serial.println(F(" Hz"));
}
// Задача чтения данных акселерометра по всем трём осям
void accelerometer_task() {
if (IMU.accelerationAvailable()) {
Serial.println(F("- Accelerometer data ready"));
IMU.readAcceleration(x, y, z);
good++;
} else {
Serial.println(F("- Accelerometer data not ready"));
bad++;
}
if (x > 0.1) {
x = 100 * x;
degreesX = map(x, 0, 97, 0, 90);
Serial.print(F("- Tilting up "));
Serial.print(degreesX);
Serial.println(F(" degrees"));
}
if (x < -0.1) {
x = 100 * x;
degreesX = map(x, 0, -100, 0, 90);
Serial.print(F("- Tilting down "));
Serial.print(degreesX);
Serial.println(F(" degrees"));
}
if (y > 0.1) {
y = 100 * y;
degreesY = map(y, 0, 97, 0, 90);
Serial.print(F("- Tilting left "));
Serial.print(degreesY);
Serial.println(F(" degrees"));
}
if (y < -0.1) {
y = 100 * y;
degreesY = map(y, 0, -100, 0, 90);
Serial.print(F("- Tilting right "));
Serial.print(degreesY);
Serial.println(F(" degrees"));
}
delay(1000);
}
// Настройка гироскопа
void gyroscope_setup() {
Serial.print(F("- Gyroscope sample rate = "));
Serial.print(IMU.gyroscopeSampleRate());
Serial.println(F(" Hz"));
Serial.println();
Serial.println(F("- Gyroscope in degrees/second"));
}
// Задача чтения данных гироскопа по всем трём осям
void gyroscope_task( ) {
if (IMU.gyroscopeAvailable()) {
IMU.readGyroscope(x, y, z);
Serial.println(F("- Gyroscope data ready"));
good++;
} else {
Serial.println(F("- Gyroscope data not ready"));
bad++;
}
if(y > plusThreshold) {
Serial.println(F("- Collision front"));
delay(500);
}
if(y < minusThreshold) {
Serial.println(F("- Collision back"));
delay(500);
}
if(x < minusThreshold) {
Serial.println(F("- Collision right"));
delay(500);
}
if(x > plusThreshold) {
Serial.println(F("- Collision left"));
delay(500);
}
}
// Задача чтения данных магнетометра по всем трём осям
void magnetometer_task(){
IMU.readMagneticField(x, y, z);
if(x < 0) {
ledvalue = -(x);
}
else {
ledvalue = x;
}
analogWrite(LED_BUILTIN, ledvalue);
delay(500);
}
// Для целей отладки
void save_debug_buffer(void) {
if (count < DUMP_BUFFER_SIZE) {
GoodBuffer[count] = good;
BadBuffer[count] = bad;
disp_debug_buffer();
count++;
}
}
// Буфер массива для отладки
void disp_debug_buffer() {
Serial.println(F("\n Debugging array buffer result >>"));
Serial.print(F("- Good marks: "));
Serial.println(GoodBuffer[count]);
Serial.print(F("- Bad marks: "));
Serial.println(BadBuffer[count]);
}
void debug_stop() {
Serial.flush();
exit(1);
}
Код объединяет работу с тремя модулями IMU в единую структуру, разнесённую по отдельным функциям для удобства. В коде применяется техника dump into array: метки good и bad расставлены в ключевых точках и записывают данные в массивы, которые выводятся в конце работы программы.
Важно правильно выбрать момент остановки для анализа. При остановке после первого цикла выполнения в Arduino Serial Monitor можно увидеть следующее:

Видно одну 1 хорошую метку и одну 1 плохую. Хорошая метка — от гироскопа: его данные были готовы. Плохая — от акселерометра: данные не успели подготовиться до момента считывания. Значит, акселерометру не хватает времени для инициализации в первом цикле. Запустим несколько итераций перед сбросом в массив:

Акселерометр отработал нормально во всех случаях, кроме первого запуска: 9 хороших меток и 1 плохая метка — именно из-за этого поведения. Инструкция Serial.println(F()) при настройке модулей и выполнении задач показывает, что код проходит операции без ошибок. Вывод: структура кода работает корректно, но при первом запуске акселерометру требуется больше времени для готовности данных.
Дополнительно можно добавить инструкцию digitalWrite(12, HIGH) перед вызовом задач и digitalWrite(12, LOW) после их завершения, чтобы измерить время выполнения с помощью GPIO 12 и осциллографа. Это также поможет оценить потребление тока в данном участке кода:
void loop() {
for (int i = 0; i < 5; i++) {
digitalWrite(12, LOW);
accelerometer_task();
gyroscope_task();
magnetometer_task();
digitalWrite(12, HIGH);
}
save_debug_buffer();
debug_stop();
}

Итоги: о чём важно помнить при отладке
Отладка — обязательный этап разработки надёжного встраиваемого программного обеспечения. Завершим урок четырьмя ключевыми фазами отладки, которые сформулировал Robin Knoke в своей статье article, опубликованной в журнале Embedded Systems Programming:
Тестирование: проверка работы программы на широком диапазоне входных значений и в различных условиях. Стабилизация: попытка воспроизвести условия, при которых возникает конкретный баг. Локализация: последовательное сужение круга подозреваемых участков кода до конкретного места. Исправление: устранение бага из программы.
Зная типичные причины ошибок, можно выстраивать стратегии их предотвращения. Разные проекты требуют разных инструментов: простые программы часто обходятся без внешних отладчиков, но когда появляются масштабируемость и сложные требования — без серьёзных инструментов не обойтись. Отладочные техники и внешние отладчики поддерживают этот процесс, позволяя создавать более качественное программное обеспечение: предсказуемое по поведению, эффективное по производительности и экономичное по расходу памяти.
Отладка нередко остаётся в тени, хотя является одним из самых важных инструментов в разработке встраиваемых систем. Если цель — создавать надёжные и качественные устройства, отладка должна быть неотъемлемой частью рабочего процесса.
Дополнительные материалы
Если хотите глубже разобраться в теме отладки — вот полезные ресурсы:
- Хотите прокачать навыки отладки и инженерного мышления? Настоятельно рекомендуем книгу Debugging: The 9 Indispensable Rules for Finding Even the Most Elusive Software and Hardware Problems Дэвида Дж. Эйгенса.
- Хотите узнать больше о цифровых мультиметрах? Читайте статью this от Fluke®.
- Хотите разобраться с осциллографами? Смотрите статью this от Tektronix®.
- Хотите понять, как работают логические анализаторы? Читайте статью this от Saleae®.
- Хотите узнать об анализаторах спектра? Смотрите статью this от Tektronix®.
- Хотите изучить SDRs? Посмотрите серию видео Great Scott Gadgets video series о SDRs. Это полноценный курс по SDRs: основы цифровой обработки сигналов (DSP) и создание гибких SDR-приложений на GNU Radio.
Список литературы
[1] P. Koopman, Better Embedded System Software. S.L.: Drumnadrochit Press, 2010.
[2] D. J. Agans, Debugging: The Nine Indispensable Rules for Finding Even the Most Elusive Software and Hardware Problems. New York: Amacom, 2002.
[3] M. Barr and A. Massa, Programming Embedded Systems: with C and GNU Development Tools. Johanneshov: MTM, 2013.
[4] J. W. Valvano, Embedded Systems: Introduction to ARM® Cortex™-M Microcontrollers. United States: Self-published, 2015.