База знаний · Язык скетчей
#include в Arduino: свои файлы .h и .cpp
Строка #include просто вставляет файл целиком. Отсюда и страж включения, и multiple definition, и то, почему .cpp не видит функции из вкладки.
Строчка #include есть в каждом втором скетче, и почти никто не знает, что она делает. «Подключает библиотеку» — объяснение красивое, но неверное, и именно из-за него потом не собираются проекты из двух файлов. На самом деле всё гораздо проще и грубее, и если один раз это увидеть, половина непонятных ошибок сборки перестаёт быть загадкой.
Что на самом деле делает #include
До того как компилятор увидит вашу программу, её просматривает препроцессор — очень простая программа, которая не понимает ни функций, ни переменных. Она умеет одно: там, где написано #include, взять указанный файл и вставить его содержимое целиком вместо этой строки. Как если бы вы открыли файл, выделили всё, скопировали и вставили руками.
Из этого одного факта следует всё остальное. Вставили файл дважды — получили две копии всего, что в нём написано, и компилятор скажет, что вы дважды объявили одно и то же. Положили в файл тело функции и подключили его из двух мест — функция окажется в программе дважды. Никакой хитрости в правилах ниже нет, это просто следствия копирования.
Кавычки или угловые скобки
Общее правило звучит так: <Servo.h> — библиотека, "pins.h" — свой файл рядом со скетчем. Это верный совет, но не по той причине, по которой обычно объясняют.
Мы проверили: #include <melody.h> для файла, лежащего в папке проекта, собирается совершенно спокойно. Разница между двумя формами не в том, что «одна ищет только библиотеки», а в порядке поиска: кавычки означают «сначала посмотри рядом со мной, потом везде», угловые скобки — «смотри там, где библиотеки». Поскольку папка скетча тоже попадает в список мест для поиска, работают обе формы.
Пользоваться этим не стоит. Кавычки для своих файлов — договорённость, по которой любой человек с первого взгляда отличает ваш файл от чужой библиотеки. Плюс если ваш файл вдруг назван так же, как файл библиотеки, кавычки гарантируют, что возьмут именно ваш.
Файл .ino — не совсем программа на C++
Прежде чем звать компилятор, среда собирает из ваших вкладок один файл и дописывает в него три вещи, которых вы не писали. Это видно на второй и третьей картинке выше, а по пунктам так:
- в самое начало добавляется
#include <Arduino.h>— поэтому в скетче работаютdigitalWriteиdelay, хотя вы ничего не подключали; - добавляются объявления ваших функций — поэтому в скетче можно вызвать функцию, написанную ниже по тексту, что в обычном C++ невозможно;
- расставляются метки
#line— поэтому компилятор ругается на строку 12 вашего файла, а не на строку 47 какого-то файла, которого вы в глаза не видели.
Все вкладки .ino при этом склеиваются в один файл: главная идёт первой, остальные — по алфавиту. Вот почему вкладки видят функции друг друга без всяких #include: для компилятора это давно один файл. И вот почему две вкладки не могут завести функцию с одинаковым именем — будет redefinition of 'void helper()'.
А файл .cpp в эту склейку не попадает. Он компилируется сам по себе и о вкладках ничего не знает: попытка позвать оттуда функцию из скетча даёт 'helper' was not declared in this scope, а забытый #include <Arduino.h> — 'digitalWrite' was not declared in this scope. Это не поломка, это ровно то, о чём картинка выше.
Знаменитая ловушка с собственными типами. Объявления функций надо куда-то вставить, и классическая сборка Arduino ставит их в самое начало файла. Если вы объявили в скетче свою структуру и написали функцию, которая её возвращает, объявление оказывается выше самой структуры — и сборка падает с загадочным 'Point' does not name a type в строке, которой вы не писали. Мы проверили это отдельным проектом: компилятор ещё и предлагает «did you mean 'Print'?», окончательно сбивая с толку. В Trema IDE этот случай разобран по обращениям пользователей: объявления вставляются не в начало файла, а перед первой вашей функцией, так что объявленные выше типы к этому месту уже видны. Если собираете где-то ещё и наткнулись на такое — вынесите структуру в отдельный заголовок, это лечит везде.
Разложить программу по файлам: как это выглядит
Возьмём маленькую программу-маячок. Сначала она живёт в одном файле — так пишут все и правильно делают, пока код помещается на экран.
// Маячок: мигает три раза, потом пишет в монитор порта, сколько раз
// это уже случилось. Вся программа — в одном файле.
const int PIN_LED = 13;
const int PAUSE = 250;
int cycles = 0;
void blinkTimes(int times) {
for (int i = 0; i < times; i++) {
digitalWrite(PIN_LED, HIGH);
delay(PAUSE);
digitalWrite(PIN_LED, LOW);
delay(PAUSE);
}
}
void setup() {
pinMode(PIN_LED, OUTPUT);
Serial.begin(9600);
}
void loop() {
blinkTimes(3);
cycles++;
Serial.println("Циклов: " + String(cycles));
delay(1000);
}
Теперь разложим её на четыре файла. Главная вкладка становится оглавлением:
// Тот же маячок, разложенный по файлам. Главный файл теперь похож на
// оглавление: setup, loop и ничего лишнего.
#include "pins.h"
#include "blink.h"
int cycles = 0;
void setup() {
pinMode(PIN_LED, OUTPUT);
Serial.begin(9600);
}
void loop() {
blinkTimes(3);
cycles++;
Serial.println("Циклов: " + String(cycles));
delay(1000);
}
Числа, которые крутят при сборке устройства, уезжают в отдельный заголовок. Обратите внимание на первые строки — это страж включения, о нём сразу после примера:
// pins.h — только числа, которые крутят при сборке устройства.
// Первые три строки — страж: если файл вставят второй раз, он окажется
// пустым, и компилятор не увидит повторных объявлений.
#ifndef PINS_H
#define PINS_H
const int PIN_LED = 13;
const int PAUSE = 250;
#endif
Заголовок с обещанием: такая функция есть, зовут её так, ей нужно одно число:
// blink.h — обещание: такая функция есть, зовут её так, и ей нужно
// одно число. Тела функции здесь нет — оно живёт в blink.cpp.
#ifndef BLINK_H
#define BLINK_H
void blinkTimes(int times);
#endif
И сама работа — в обычном файле с кодом:
// blink.cpp — сама работа. Обычный файл, в отличие от вкладки .ino,
// не получает Arduino.h сам по себе: без первой строки компилятор
// скажет, что не знает ни digitalWrite, ни delay, ни HIGH.
#include <Arduino.h>
#include "pins.h"
#include "blink.h"
void blinkTimes(int times) {
for (int i = 0; i < times; i++) {
digitalWrite(PIN_LED, HIGH);
delay(PAUSE);
digitalWrite(PIN_LED, LOW);
delay(PAUSE);
}
}
Главный вопрос, который задают в этом месте: сколько это стоит по памяти? Мы собрали оба варианта — тот, что в одном файле, и тот, что в четырёх. Не «примерно столько же», а побайтно одна и та же прошивка: 214 байт ОЗУ, 3 636 байт памяти программ, одинаковая контрольная сумма файла прошивки. Компилятору всё равно, из скольких файлов вы собрали программу: он всё равно склеивает их обратно. Делить проект на файлы бесплатно.
Страж включения: три строки, без которых всё сыплется
Как только файлов становится больше двух, обязательно случается вот что: главный файл подключает melody.h и tools.h, а tools.h тоже подключает melody.h. Препроцессор честно вставляет содержимое дважды.
Лечится это тремя строками в самом заголовке: #ifndef и #define в начале, #endif в конце. Читается так: «если метки ещё нет — поставь её и вставь содержимое, а если есть — пропусти всё до конца файла». Современный короткий вариант — #pragma once первой строкой; мы проверили, работает так же. Классические три строки надёжнее только тем, что их понимает вообще любой компилятор.
Своё имя метки должно быть у каждого файла. Скопировать заголовок, переименовать файл и забыть переименовать метку — отличный способ получить пустой файл вместо содержимого и потом долго не понимать, куда делись константы.
Если создавать файл прямо в Trema IDE, первая строка уже будет на месте: новый заголовок открывается со строкой #pragma once, а новый .cpp — со строкой #include <Arduino.h>. Это ровно те две строки, которые чаще всего забывают.
Что кладут в .h, а что в .cpp
Правило, которого хватает на всю жизнь: в заголовке — что есть, в файле с кодом — как оно устроено. Заголовок читают все файлы, которым нужна ваша функция, а тело компилируется ровно один раз.
Стоит написать тело функции прямо в заголовке и подключить его из двух файлов — и линковщик, последний этап сборки, скажет multiple definition. Страж включения здесь не помогает, и это сбивает с толку: он следит за одним файлом, а копии попали в разные. То же самое с переменными.
Переменную, общую для нескольких файлов, объявляют в заголовке со словом extern — это значит «такая переменная где-то есть» — а создают ровно один раз в файле с кодом:
// counter.h — объявления. Слово extern означает «переменная где-то есть,
// в каком именно файле — разберётся сборщик». Без него каждый файл завёл
// бы свою собственную переменную с тем же именем.
#ifndef COUNTER_H
#define COUNTER_H
extern int pressCount;
void addPress();
#endif
// counter.cpp — здесь переменная создаётся по-настоящему. Ровно один раз
// на всю программу: это и есть главное правило многофайлового проекта.
#include <Arduino.h>
#include "counter.h"
int pressCount = 0;
void addPress() {
pressCount++;
}
После этого её видит и главный файл, и любой другой, кто подключит заголовок:
// Общая переменная на два файла: счётчик нажатий заведён в counter.cpp,
// а пользуются им и главный файл, и соседний.
#include "counter.h"
const int PIN_BUTTON = 2;
void setup() {
pinMode(PIN_BUTTON, INPUT_PULLUP);
Serial.begin(9600);
}
void loop() {
if (digitalRead(PIN_BUTTON) == LOW) {
addPress();
Serial.println("Нажатий: " + String(pressCount));
delay(200);
}
}
Заголовок должен подключать то, чем сам пользуется
Ещё одна ловушка, которая выглядит как чертовщина. Заголовок player.h объявляет функцию, которая принимает тип Melody из соседнего melody.h, но сам его не подключает. Пока в главном файле сначала идёт melody.h, а потом player.h, всё собирается. Поменяйте две строки местами — и сборка развалится с variable or field 'play' declared void. Код не менялся, порядок подключения — да.
Правильное лечение не «расставить include в нужном порядке», а сделать каждый заголовок самодостаточным: пусть он сам подключает то, чем пользуется. Тогда порядок перестаёт что-либо значить, а страж включения не даст ничему вставиться дважды. Проверено обоими способами: с подключением внутри заголовка любой порядок собирается.
Куда класть файлы и что среда вообще собирает
Среда просматривает папку проекта целиком, вместе с подпапками, и берёт в сборку файлы с такими расширениями:
| Расширение | Что это | Как участвует в сборке |
|---|---|---|
.ino | вкладка скетча | склеивается с другими вкладками в один файл |
.pde | старое имя вкладки | то же самое, осталось от древних версий |
.h .hh .hpp | заголовок | вставляется туда, где его подключили |
.cpp .cc | файл с кодом | компилируется отдельно и сам по себе |
.c | файл на языке C | тоже компилируется отдельно |
.ipp .tpp | вставки для шаблонов | подключаются как заголовки |
.S | ассемблер | нужен редко, но собирается |
Подпапки работают: файл motors/motors.cpp соберётся, а подключать его заголовок нужно вместе с папкой — #include "motors/motors.h". Служебные папки среда пропускает, так что .git и папка данных скетча в сборку не попадут. Склеиваются в один файл только вкладки .ino, лежащие в корне проекта, — файл с таким расширением в подпапке уже живёт отдельной жизнью.
Десять сообщений, из-за которых обычно и приходят
Все эти строки мы получили специально: собрали по маленькому проекту на каждый случай и записали, что ответил компилятор. Ищите свою строку по началу — дальше в ней будет ваше имя файла или функции.
| Что написано | Что случилось | Что делать |
|---|---|---|
fatal error: melody.h: No such file or directory | файла с таким именем рядом нет | проверьте имя буква в букву и что файл лежит в папке проекта |
error: redefinition of 'struct Melody' | заголовок вставился дважды | добавьте в него страж включения |
multiple definition of 'blinkTimes(int)' | тело функции лежит в заголовке | перенесите тело в файл .cpp, в заголовке оставьте строку с точкой с запятой |
multiple definition of 'pressCount' | переменная создана прямо в заголовке | в заголовке — extern, создание переменной — в одном .cpp |
undefined reference to 'blinkTimes(int)' | функцию объявили, но нигде не написали | напишите тело или проверьте, что файл .cpp лежит в проекте |
'digitalWrite' was not declared in this scope | в файле .cpp нет строки #include <Arduino.h> | добавьте её первой строкой: вкладки .ino получают её сами, обычные файлы — нет |
'helper' was not declared in this scope | файл .cpp зовёт функцию, написанную во вкладке .ino | перенесите функцию в .cpp и объявите её в заголовке |
variable or field 'play' declared void | заголовок пользуется типом, которого не подключил | подключите нужный заголовок внутри самого заголовка |
error: redefinition of 'void helper()' | две вкладки .ino написали функцию с одним именем | переименуйте одну: все вкладки — это один файл |
'Point' does not name a type | объявление функции оказалось выше вашего типа | перенесите свой struct в отдельный заголовок и подключите его первой строкой |
Разница между двумя видами сообщений полезнее, чем кажется. Если в строке есть слово error и номер строки — ругается компилятор, и он смотрит на один файл. Если написано multiple definition или undefined reference — это уже линковщик, который складывает все файлы вместе, и проблема не в одном файле, а в том, как они сходятся.
Когда делить, а когда не стоит
Программу на сто строк делить не надо: искать по одному экрану проще, чем по четырём вкладкам. Смысл появляется, когда у проекта есть узлы — моторы, датчики, экран — и когда одну и ту же функцию хочется взять в следующий проект. Хороший ориентир: файл должен отвечать на один вопрос, а его имя — подсказывать, на какой именно.
Начинать удобнее всего с самого простого шага: вынести в отдельный заголовок номера выводов и настройки. Это ничего не ломает, сразу окупается — все числа устройства собраны в одном месте — и заодно приучает к стражу включения.
Частые вопросы
Чем отличается #include в кавычках от #include в угловых скобках?
Только порядком поиска: кавычки означают «сначала посмотри рядом со мной, потом там же, где библиотеки», угловые скобки — «смотри среди библиотек». Папка скетча тоже входит в список мест поиска, поэтому свой файл находится и в угловых скобках — мы это проверили сборкой. Но кавычки для своих файлов остаются правильной договорённостью: по ним сразу видно, где ваш файл, а где чужая библиотека.
Почему в файле .cpp не работает digitalWrite?
Потому что строку #include <Arduino.h> среда дописывает только во вкладки скетча, а обычный файл .cpp компилируется сам по себе. Без неё компилятор отвечает «'digitalWrite' was not declared in this scope», а заодно не знает ни delay, ни HIGH. Лечится одной строкой в начале файла.
Что означает ошибка multiple definition?
Что одна и та же функция или переменная попала в программу дважды. Почти всегда причина одна: тело функции или создание переменной написано прямо в заголовке, а заголовок подключили из двух файлов. Страж включения тут не спасает — он следит за одним файлом, а копии попали в разные. Тело функции переносят в .cpp, а переменную объявляют в заголовке со словом extern и создают ровно один раз.
Почему функция из вкладки скетча не видна в файле .cpp?
Потому что все вкладки .ino склеиваются в один файл, а .cpp в эту склейку не входит и компилируется отдельно. Он о вкладках ничего не знает, поэтому получает «'helper' was not declared in this scope». Правильный путь — перенести функцию в .cpp и объявить её в заголовке, который подключат оба файла.
Зачем в начале заголовка пишут #ifndef и #define?
Это страж включения. Он нужен, потому что рано или поздно два пути подключения сходятся на одном файле: главный файл подключает melody.h напрямую, а заодно подключает tools.h, который тоже подключает melody.h. Без стража содержимое вставится дважды и компилятор скажет «redefinition of 'struct Melody'». Короткий современный вариант того же самого — строка #pragma once.
Разделение программы на файлы занимает лишнюю память?
Нет, ни одного байта. Мы собрали одну и ту же программу двумя способами — в одном файле и разложенную по четырём — и получили побайтно одинаковую прошивку: 214 байт ОЗУ, 3 636 байт памяти программ, одна и та же контрольная сумма. Компилятор всё равно склеивает файлы обратно, так что делить проект бесплатно.
Что значит ошибка 'Point' does not name a type?
Что объявление функции оказалось в файле выше, чем объявлен ваш собственный тип. Так бывает, когда сборка вставляет объявления функций в самое начало файла, а вы завели в скетче свою структуру и написали функцию, которая её возвращает. В Trema IDE этот случай специально разобран: объявления вставляются не в начало файла, а перед первой вашей функцией, поэтому типы к этому месту уже видны. Универсальное лечение — вынести структуру в отдельный заголовок и подключить его первой строкой.
Читайте также
Обновлено: 25 августа 2026
