Зачем писать на чистом C, если есть Arduino
Arduino-обёртки удобны, но скрывают главное — как на самом деле работает микроконтроллер. digitalWrite(13, HIGH) превращается в десятки машинных инструкций, тогда как запись бита в регистр PORTB — это одна инструкция sbi за один такт. Код на чистом C с avr-libc:
- работает в 10–50 раз быстрее Arduino-функций для GPIO;
- занимает в разы меньше Flash — мигающий светодиод укладывается в 150–200 байт вместо ~1 КБ;
- даёт полный контроль над таймерами, прерываниями и спящими режимами;
- переносится на любой AVR — от ATtiny13 до ATmega2560 — без переделок.
Все примеры ниже рассчитаны на ATmega328P (он же стоит в Arduino Uno), но почти без изменений работают на ATmega8/16/32 и с минимальной правкой имён регистров — на ATtiny85/2313.
Установка инструментария
На Debian/Ubuntu всё ставится одной командой:
sudo apt install gcc-avr avr-libc avrdude make
Проверяем версии:
avr-gcc --version
avrdude --version
На Windows проще всего поставить toolchain из состава Microchip Studio или MSYS2-пакет mingw-w64-avr-gcc. На macOS — brew install avr-gcc avrdude.
Три регистра GPIO: DDRx, PORTx, PINx
Каждый порт AVR (PORTB, PORTC, PORTD…) управляется тремя 8-битными регистрами. Каждый бит регистра соответствует одной ножке:
- DDRx (Data Direction Register) — направление: 1 = выход, 0 = вход. После сброса все ножки — входы.
- PORTx — для выхода: уровень на ножке (1 = высокий, 0 = низкий). Для входа: 1 = включён внутренний подтягивающий резистор pull-up, 0 = hi-Z.
- PINx — чтение фактического уровня на ножке. Запись 1 в бит PINx на современных AVR инвертирует соответствующий бит PORTx — удобно для toggle за одну инструкцию.
Мнемоника для ATmega328P: PB0..PB7 — порт B, PC0..PC6 — порт C, PD0..PD7 — порт D. Ножка Arduino «13» — это PB5.
Мигание светодиодом: минимальная прошивка
#define F_CPU 16000000UL
#include <avr/io.h>
#include <util/delay.h>
int main(void)
{
DDRB |= (1 << PB5); // PB5 (Arduino D13) — выход
while (1) {
PORTB |= (1 << PB5); // высокий уровень
_delay_ms(500);
PORTB &= ~(1 << PB5); // низкий уровень
_delay_ms(500);
}
}
F_CPU обязан быть определён до включения util/delay.h — от него считаются задержки. 1 << PB5 — маска бита 5; то же самое делает макрос _BV(PB5).
Инверсия через запись в PIN — ещё короче:
while (1) {
PINB = (1 << PB5); // toggle PB5 одной инструкцией
_delay_ms(500);
}
Битовые макросы: база всей работы с регистрами
Определите их один раз в заголовке проекта — код станет читаемее:
#define BIT(n) (1U << (n))
#define SET_BIT(reg, n) ((reg) |= BIT(n))
#define CLR_BIT(reg, n) ((reg) &= ~BIT(n))
#define TGL_BIT(reg, n) ((reg) ^= BIT(n))
#define READ_BIT(reg, n) (((reg) >> (n)) & 1U)
Использование:
SET_BIT(DDRB, PB5); // PB5 — выход
SET_BIT(PORTB, PB5); // зажечь
CLR_BIT(PORTB, PB5); // погасить
TGL_BIT(PORTB, PB5); // переключить
if (READ_BIT(PIND, PD2)) {
// на PD2 высокий уровень
}
Кнопка: вход с pull-up и дребезг
Кнопку подключают между ножкой и GND, включают внутреннюю подтяжку — внешний резистор не нужен:
DDRD &= ~(1 << PD2); // PD2 — вход
PORTD |= (1 << PD2); // включить pull-up
if (!(PIND & (1 << PD2))) {
// кнопка нажата (вход притянут к GND)
}
Логика инвертированная: нажата = 0. Механические контакты дребезжат 5–20 мс, поэтому программный антидребезг обязателен:
uint8_t button_pressed(void)
{
if (PIND & (1 << PD2))
return 0; // не нажата
_delay_ms(20); // ждём окончания дребезга
if (PIND & (1 << PD2))
return 0; // ложное срабатывание
while (!(PIND & (1 << PD2)))
; // ждём отпускания
_delay_ms(20);
return 1;
}
Компиляция и прошивка
Компилируем под конкретный чип и оптимизируем — -Os обязателен, без него _delay_ms считает неправильно и код раздувается:
avr-gcc -mmcu=atmega328p -DF_CPU=16000000UL -Os -o main.elf main.c
avr-objcopy -O ihex -R .eeprom main.elf main.hex
Прошиваем через USBasp:
avrdude -c usbasp -p m328p -U flash:w:main.hex:i
Или через Arduino Uno как ISP-программатор / через bootloader по UART:
avrdude -c arduino -p m328p -P /dev/ttyUSB0 -b 115200 -U flash:w:main.hex:i
Makefile для проекта
MCU = atmega328p
F_CPU = 16000000UL
PROG = usbasp
TARGET = main
CC = avr-gcc
CFLAGS = -mmcu=$(MCU) -DF_CPU=$(F_CPU) -Os -Wall -Wextra
all: $(TARGET).hex
$(TARGET).elf: $(TARGET).c
$(CC) $(CFLAGS) -o $@ $<
$(TARGET).hex: $(TARGET).elf
avr-objcopy -O ihex -R .eeprom $< $@
avr-size --format=avr --mcu=$(MCU) $<
flash: $(TARGET).hex
avrdude -c $(PROG) -p m328p -U flash:w:$<:i
clean:
rm -f *.elf *.hex
avr-size покажет, сколько Flash и SRAM заняла прошивка — приятно видеть «Program: 178 bytes» там, где Arduino дала бы килобайт.
Бегущий огонь на 8 светодиодах
Восемь светодиодов на PORTD (через резисторы 220–330 Ом на каждый!):
#define F_CPU 8000000UL
#include <avr/io.h>
#include <util/delay.h>
int main(void)
{
DDRD = 0xFF; // весь порт D — выходы
while (1) {
for (uint8_t i = 0; i < 8; i++) {
PORTD = (1 << i);
_delay_ms(80);
}
for (int8_t i = 6; i > 0; i--) {
PORTD = (1 << i);
_delay_ms(80);
}
}
}
Здесь пишем в PORTD целиком — за один такт обновляются все 8 ножек. В Arduino потребовалось бы 8 вызовов digitalWrite.
Чтение целого порта и маски
Настройка смешанного порта: PD0–PD3 выходы, PD4–PD7 входы с подтяжкой:
DDRD = 0b00001111; // младшие 4 — выходы
PORTD = 0b11110000; // на выходах 0, на входах pull-up
uint8_t high_nibble = PIND >> 4; // состояние PD4..PD7
Префикс 0b — расширение GCC, удобно для наглядности. Стандартный вариант — шестнадцатеричный 0x0F.
Частые ошибки новичков
- Забыли
-Os— delay-петли оптимизируются не так, код не влезает во Flash. - F_CPU не совпадает с реальной частотой — задержки и UART «уплывут» во столько же раз. Заводские чипы идут с внутренним RC 1 МГц (8 МГц / CKDIV8), а не 16 МГц.
- Нет конденсаторов по питанию — 100 нФ керамика на каждую пару VCC/GND обязательна, иначе чип будет сбрасываться.
- Ножка RESET подтянута «плавающе» — 10 кОм на VCC, иначе случайные ресеты.
- Светодиод без резистора — ножка AVR даёт максимум ~20 мА, без резистора сожжёте и пин, и диод.
- Запись
=вместо|=/&=—PORTB = 0x20заодно сбрасывает все остальные биты порта.
Что дальше
Освоив GPIO на регистрах, логично двигаться к таймерам (CTC, ШИМ — без них не сделать ни точных задержек в фоне, ни управление сервами), внешним прерываниям INT0/PCINT, UART для отладки и АЦП для аналоговых датчиков — по каждой теме у нас есть отдельная статья с примерами кода.