1. 1. 系列导读
    1. 1.1. 同系列其它文章
  2. 2. 1. 核心问题:Arduino 到底有没有 Makefile?
  3. 3. 2. 整体架构
  4. 4. 3. Phase 1:源码发现——{includes} 是如何填满的
    1. 4.1. 3.1 扫描路径(4 个来源)
    2. 4.2. 3.2 .ino 文件合并(Sketch 预处理)
    3. 4.3. 3.3 头文件路径收集——{includes} 的填充
    4. 4.4. 3.4 依赖文件生成(-MMD)
  5. 5. 4. Phase 2:变量替换——从 platform.txt 到真实命令
    1. 5.1. 4.1 build 变量的来源与优先级
    2. 5.2. 4.2 UNO R4 Minima 的关键变量展开
    3. 5.3. 4.3 命令逐步展开实例
      1. 5.3.1. Step 0:原始 recipe 模板(platform.txt 第 74 行)
      2. 5.3.2. Step 1:替换编译器路径和命令名
      3. 5.3.3. Step 2:替换 {compiler.cpp.flags}(展开 ~15 个编译选项)
      4. 5.3.4. Step 3:替换 FSP 相关变量
      5. 5.3.5. Step 4:替换 {includes}(约 50 个 -I 参数)
      6. 5.3.6. Step 5:替换源文件和输出路径
      7. 5.3.7. 最终生成的真实命令
  6. 6. 5. Phase 3:编译执行流程(6 条 recipe 的串/并行)
    1. 6.1. 5.1 六个 recipe 总览
    2. 6.2. 5.2 并行编译的细节
    3. 6.3. 5.3 各 recipe 详解
      1. 6.3.1. Recipe 1 & 2:编译(platform.txt 第 71/74 行)
      2. 6.3.2. Recipe 3:打包 core.a(platform.txt 第 80 行)
      3. 6.3.3. Recipe 4:链接(最关键一步,platform.txt 第 83 行)
      4. 6.3.4. Recipe 5 & 6:生成 .bin
  7. 7. 6. 完整的参数替换链路图
  8. 8. 7. 如何查看 Arduino 实际生成的完整编译命令
    1. 8.1. 7.1 方法 1:arduino-cli dry-run(推荐)
    2. 8.2. 7.2 方法 2:查看 build 目录
    3. 8.3. 7.3 方法 3:Arduino IDE 详细输出
    4. 8.4. 7.4 方法 4:用 PowerShell 过滤感兴趣的命令
  9. 9. 8. 源码分析:arduino-builder 内部做了什么
    1. 9.1. 8.1 源码发现逻辑
    2. 9.2. 8.2 增量编译逻辑
  10. 10. 9. 常见误解澄清
    1. 10.1. 误解 1:”Arduino 不用 Makefile 所以很快”
    2. 10.2. 误解 2:”Arduino 编译的是 .ino 文件”
    3. 10.3. 误解 3:”platform.txt 决定了所有编译行为”
    4. 10.4. 误解 4:”库只在第一次编译时扫描”
  11. 11. 10. 总结
    1. 11.1. 10.1 三层抽象对照
    2. 11.2. 10.2 关键文件的作用
    3. 11.3. 10.3 核心流程

Arduino IDE 编译内幕:没有 Makefile 如何调用 GCC(以 UNO R4 + 10BASE-T1S 为例)

系列导读

本文深入剖析 Arduino IDE 如何在不使用 Makefile 的前提下,完成从源码到 .bin 的完整编译链路。以 Arduino UNO R4 Minima + Microchip LAN8650/LAN8651 + Arduino_10BASE_T1S 库为具体实例,追踪 arduino-builder / arduino-cli 如何自动扫描源文件、解析 #include、填充 platform.txt recipe 中的变量,最终调用真实的 arm-none-eabi-gcc 编译器。

同系列其它文章

# 标题
- 架构概览
- OA TC6 协议栈深度分析
- 移植实战指南
- Arduino IDE 编译架构深度解析
- 代码接口深度解析(〇):系列索引
1 代码接口(1):应用层 UDP Socket API
2 代码接口(2):PHY Interface 与 HAL 层
3 代码接口(3):libtc6 协议核心与 lwIP 集成
4 代码接口(4):辅助类与 lwIP 配置

1. 核心问题:Arduino 到底有没有 Makefile?

简短回答:Arduino 底层并不是”没有 Makefile”,而是有一个自动生成的隐式 Makefile,它的逻辑被硬编码在 arduino-builder(Java)或 arduino-cli(Go)的源码中。Makefile 的”规则”变成了 platform.txt 中的 recipe,Makefile 的”变量”变成了 boards.txt 中的 build.xxx

类比

传统 Makefile 世界 Arduino 世界
Makefile platform.txt (recipe 模板)
Makefile 中的 SRCS := *.c arduino-builder 递归扫描 src/
Makefile 中的 CFLAGS := -D... boards.txt 中的 minima.build.defines=...
make 执行规则 arduino-builder fork+exec GCC
.d 依赖文件 GCC -MMD 生成的 .d 文件
make -j8 并行编译 arduino-builder 并行执行无依赖的 .c/.cpp 编译

2. 整体架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
┌────────────────────────────────────────────────────────────────────────┐
│ Arduino IDE 2.x (前端 UI) │
│ 点击 "验证" / "上传" │
└─────────────────────────────┬──────────────────────────────────────────┘
│ JSON-RPC / 内部调用

┌────────────────────────────────────────────────────────────────────────┐
│ arduino-builder (Java) 或 arduino-cli (Go) │
│ 真正的构建引擎 │
│ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ Phase 1: 源码发现 │ │
│ │ • 递归扫描 .c/.cpp/.S 文件(~150 个) │ │
│ │ • 解析 #include,收集所有头文件路径 │ │
│ │ • 读取 library.properties 识别库 │ │
│ │ • 合并同目录 .ino 文件为一个 .cpp │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ Phase 2: 变量替换 │ │
│ │ • 从 boards.txt 读取所有 {build.xxx} 变量 │ │
│ │ • 从 platform.txt 读取所有 {compiler.xxx} 变量 │ │
│ │ • 从 variant/*.txt 读取 FSP 编译参数 │ │
│ │ • 替换 recipe 中的所有 {xxx} 占位符 │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ Phase 3: 命令执行 │ │
│ │ • recipe.c.o.pattern → arm-none-eabi-gcc × N (并行) │ │
│ │ • recipe.cpp.o.pattern → arm-none-eabi-g++ × N (并行) │ │
│ │ • recipe.ar.pattern → arm-none-eabi-ar (core.a) │ │
│ │ • recipe.c.combine → arm-none-eabi-g++ (链接 .elf) │ │
│ │ • recipe.objcopy.bin → arm-none-eabi-objcopy (.bin) │ │
│ │ • recipe.size.pattern → arm-none-eabi-size (报告大小) │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ 增量编译支持 │ │
│ │ • GCC -MMD 为每个 .o 生成 .d 依赖文件 │ │
│ │ • 下次编译跳过未变化的 .cpp 及其依赖的 .h │ │
│ └──────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────┬──────────────────────────────────────────┘

┌────────────────────────────────────────────────────────────────────────┐
│ GNU Arm Embedded Toolchain (真实编译器) │
│ arm-none-eabi-gcc arm-none-eabi-g++ arm-none-eabi-ar objcopy │
└────────────────────────────────────────────────────────────────────────┘

3. Phase 1:源码发现——{includes} 是如何填满的

3.1 扫描路径(4 个来源)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
需要扫描的目录(按优先级排序):

1. Sketch 所在目录
└── UDP_Client.ino ← 用户编辑的入口
├── .ino 本身(Arduino 会合并为 UDP_Client.cpp)
└── 同一目录所有 .cpp/.c/.S

2. 库目录 (libraries/Arduino_10BASE_T1S/src/)
└── 递归扫描所有 .c/.cpp/.S
├── Arduino_10BASE_T1S_UDP.cpp
├── MacAddress.cpp
├── T1SPlcaSettings.cpp
├── T1SMacSettings.cpp
├── microchip/TC6_Arduino_10BASE_T1S.cpp
├── microchip/TC6_Io.cpp
├── microchip/lib/libtc6/src/tc6.cpp
├── microchip/lib/libtc6/src/tc6-regs.cpp
├── lib/liblwip/core/udp.c
├── lib/liblwip/core/ipv4/ip4.c
├── lib/liblwip/core/netif/ethernet.c
└── ... (~80 个 lwIP .c 文件)

3. Arduino 核心目录
%LOCALAPPDATA%\Arduino15\packages\arduino\hardware\
renesas_uno\1.6.0\cores\arduino\
├── main.cpp
├── wiring.c
├── HardwareSerial.cpp
├── Print.cpp
└── ... (~30 个)

4. Variant 目录(板级特定源码)
%LOCALAPPDATA%\...\variants\MINIMA\
└── includes/ ← 仅头文件路径,不含 .c/.cpp

3.2 .ino 文件合并(Sketch 预处理)

Arduino 在编译前会把同一目录下所有 .ino 文件按文件名排序后首尾拼接为一个 .cpp,并在其中插入必要的声明。以下是实际发生过程的伪代码表示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Arduino 生成的临时文件: build/UDP_Client.cpp
// === 来自 UDP_Client.ino (第一部分) ===
#include <Arduino_10BASE_T1S.h>

// === 来自 UDP_Server.ino (第二部分,如果有的话) ===
// #include <...>

// === 来自 UDP_Client.ino 的用户代码 ===
void setup() {
Serial.begin(115200);
// ...
}

void loop() {
// ...
}

合并规则:

  • 多个 .ino 按文件名字母顺序拼接(_01_*.ino_02_*.ino 之前)
  • Arduino 在拼接后的文件顶部自动插入 #include <Arduino.h>
  • 这就是为什么 sketch 中不需要 #include <Arduino.h> 也能使用 SerialpinMode

3.3 头文件路径收集——{includes} 的填充

这是整个机制最精妙的部分。Arduino 不仅扫描 .c/.cpp,还会:

  1. 解析每个源文件的 #include 语句(递归解析所有层级)
  2. 将路径转换为 -I 编译器参数,组装成 {includes} 变量

Arduino_10BASE_T1S 库中的关键文件为例:

1
2
3
4
5
// TC6_Arduino_10BASE_T1S.cpp 中的 #include
#include "Arduino_10BASE_T1S_PHY_Interface.h"
#include "TC6_Io.h"
#include "../../lib/liblwip/include/lwip/netif.h"
#include "lib/liblwip/include/lwip/ip_addr.h"
1
2
3
4
// libtc6/tc6.cpp 中的 #include
#include "tc6.h"
#include "tc6-conf.h"
#include <string.h>

会生成对应的 -I 参数:

1
2
3
4
5
6
-I"D:/Documents/Arduino/libraries/Arduino_10BASE_T1S/src/"
-I"D:/Documents/Arduino/libraries/Arduino_10BASE_T1S/src/microchip/"
-I"D:/Documents/Arduino/libraries/Arduino_10BASE_T1S/src/microchip/lib/libtc6/inc/"
-I"D:/Documents/Arduino/libraries/Arduino_10BASE_T1S/src/microchip/lib/liblwip/include/"
-I"D:/Documents/Arduino/libraries/Arduino_10BASE_T1S/src/lib/liblwip/include/"
-I"%LOCALAPPDATA%/.../cores/arduino/"

最终,{includes} 的典型展开包含 约 50 个 -I 参数,覆盖 sketch 层、库层、核心层和 variant FSP 层。

3.4 依赖文件生成(-MMD

注意 platform.txt 中的编译 flags:

1
2
compiler.cpp.flags=... -MMD ...
compiler.c.flags=... -MMD ...

-MMD 告诉 GCC 生成 .d 依赖文件.d = dependency)。编译 Arduino_10BASE_T1S_UDP.cpp 时会同时生成 Arduino_10BASE_T1S_UDP.d

1
2
3
4
5
6
7
8
9
10
11
12
# build/Arduino_10BASE_T1S_UDP.d
# (由 arm-none-eabi-g++ -MMD 自动生成)

Arduino_10BASE_T1S_UDP.cpp.o: \
Arduino_10BASE_T1S_UDP.cpp \
Arduino_10BASE_T1S_UDP.h \
../lib/liblwip/include/lwip/netif.h \
../lib/liblwip/include/lwip/udp.h \
../lib/liblwip/include/lwip/pbuf.h \
../../../cores/arduino/Arduino.h \
../../../cores/arduino/Print.h \
...

Arduino-builder 读取所有 .d 文件,用于增量编译:如果某个 .h 文件没有变化,则不重新编译依赖它的 .cpp


4. Phase 2:变量替换——从 platform.txt 到真实命令

4.1 build 变量的来源与优先级

Arduino 按以下优先级合并变量(后面的覆盖前面的同名变量):

1
2
3
4
5
6
7
platform.txt          (基础值,所有板子共享)

boards.txt (minima.xxx 覆盖同名 xxx)

platform.local.txt (用户本地覆盖,不受 IDE 升级影响)

Sketch 的 boards.local.txt

4.2 UNO R4 Minima 的关键变量展开

以 UNO R4 Minima 为例,关键 {build.xxx}{compiler.xxx} 变量展开如下:

占位符 展开值 来源
{build.mcu} cortex-m4 boards.txt
{build.float-abi} -mfloat-abi=hard boards.txt
{build.fpu} -mfpu=fpv4-sp-d16 boards.txt
{build.crossprefix} arm-none-eabi- boards.txt
{build.compiler_path} %LOCALAPPDATA%...\arm-none-eabi-gcc\7-2017q4\bin\ boards.txt
{build.variant.path} %LOCALAPPDATA%...\variants\MINIMA\ boards.txt
{build.core.path} %LOCALAPPDATA%...\cores\arduino\ boards.txt
{build.defines} -DF_CPU=48000000 -DARDUINO_UNOR4_MINIMA boards.txt
{build.board} MINIMA boards.txt
{compiler.fsp} %LOCALAPPDATA%...\variants\MINIMA\libs\libfsp.a boards.txt
{compiler.fsp.cxxflags} -mthumb "@{compiler.fsp.defines}" boards.txt
{compiler.fsp.includes} @{build.variant.path}/includes.txt boards.txt
{compiler.tinyusb.cxxflags} -DCFG_TUSB_MCU=OPT_MCU_RAXXX boards.txt
{build.variant.path}/defines.txt -D_RA_CORE=CM4 -D_RENESAS_RA_ MINIMA/defines.txt
{build.variant.path}/includes.txt 14 行 -iwithprefixbefore/... MINIMA/includes.txt

4.3 命令逐步展开实例

以编译 Arduino_10BASE_T1S_UDP.cpp 为例,展示从 recipe 模板到真实 GCC 命令的每一步展开。

Step 0:原始 recipe 模板(platform.txt 第 74 行)

1
2
3
4
5
6
7
8
{compiler.path}{compiler.cpp.cmd} {compiler.cpp.flags} -DARDUINO={runtime.ide.version}
"-DPROJECT_NAME="{build.path}/{build.project_name}"" -DARDUINO_{build.board}
-DARDUINO_ARCH_{build.arch} -DARDUINO_ARCH_RENESAS -DARDUINO_FSP -D_XOPEN_SOURCE=700
{compiler.fsp.cxxflags} {compiler.tinyusb.cxxflags} {compiler.cpp.extra_flags}
{build.extra_flags} {tinyusb.includes}
"-I{build.core.path}/api/deprecated" "-I{build.core.path}/api/deprecated-avr-comp"
{includes} "-iprefix{runtime.platform.path}" "@{compiler.fsp.includes}"
"{source_file}" -o "{object_file}"

Step 1:替换编译器路径和命令名

1
2
3
"C:\...\arm-none-eabi-gcc\7-2017q4\bin\arm-none-eabi-g++" {compiler.cpp.flags}
-DARDUINO=... -DARDUINO_MINIMA -DARDUINO_ARCH_RENESAS ...
{includes} "{source_file}" -o "{object_file}"

Step 2:替换 {compiler.cpp.flags}(展开 ~15 个编译选项)

1
2
3
4
5
6
7
8
-w -Os -g3
-fno-use-cxa-atexit -fno-rtti -fno-exceptions
-MMD -nostdlib
-DF_CPU=48000000 -DARDUINO_UNOR4_MINIMA
-std=gnu++17
-mcpu=cortex-m4 -mfloat-abi=hard -mfpu=fpv4-sp-d16
-fsigned-char -ffunction-sections -fdata-sections
-fmessage-length=0 -fno-builtin

Step 3:替换 FSP 相关变量

1
-mthumb "@{compiler.fsp.defines}"

↓ 读取 MINIMA/defines.txt 内容 -D_RA_CORE=CM4 -D_RENESAS_RA_,展开为:

1
-mthumb -D_RA_CORE=CM4 -D_RENESAS_RA_

Step 4:替换 {includes}(约 50 个 -I 参数)

1
2
3
4
5
6
7
8
-I"%LOCALAPPDATA%/.../cores/arduino/api/deprecated"
-I"%LOCALAPPDATA%/.../cores/arduino/api/deprecated-avr-comp"
-ID:/Documents/Arduino/libraries/Arduino_10BASE_T1S/src
-I%LOCALAPPDATA%/.../variants/MINIMA/includes/ra/fsp/inc
-I%LOCALAPPDATA%/.../variants/MINIMA/includes/ra/fsp/inc/api
...
-iprefix%LOCALAPPDATA%/.../renesas_uno/1.6.0/
@%LOCALAPPDATA%/.../variants/MINIMA/includes.txt

Step 5:替换源文件和输出路径

1
2
"D:/.../Arduino_10BASE_T1S/src/Arduino_10BASE_T1S_UDP.cpp"
-o "D:/.../build/Arduino_10BASE_T1S_UDP.cpp.o"

最终生成的真实命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
"C:\Users\cursorhu\AppData\Local\Arduino15\packages\arduino\tools\
arm-none-eabi-gcc\7-2017q4\bin\arm-none-eabi-g++" \
-c \
-w -Os -g3 \
-fno-use-cxa-atexit \
-fno-rtti \
-fno-exceptions \
-MMD \
-nostdlib \
-DF_CPU=48000000 \
-DARDUINO_UNOR4_MINIMA \
-std=gnu++17 \
-mcpu=cortex-m4 \
-mfloat-abi=hard \
-mfpu=fpv4-sp-d16 \
-fsigned-char \
-ffunction-sections \
-fdata-sections \
-fmessage-length=0 \
-fno-builtin \
-mthumb \
-D_RA_CORE=CM4 -D_RENESAS_RA_ \
-DCFG_TUSB_MCU=OPT_MCU_RAXXX \
-DARDUINO=20300 \
-DARDUINO_MINIMA \
-DARDUINO_ARCH_RENESAS \
-DARDUINO_ARCH_RENESAS \
-DARDUINO_FSP \
-D_XOPEN_SOURCE=700 \
-I"%LOCALAPPDATA%/.../cores/arduino/api/deprecated" \
-I"%LOCALAPPDATA%/.../cores/arduino/api/deprecated-avr-comp" \
-I"D:/Documents/Arduino/libraries/Arduino_10BASE_T1S/src" \
-I"%LOCALAPPDATA%/.../variants/MINIMA/includes/ra/fsp/inc" \
-I"%LOCALAPPDATA%/.../variants/MINIMA/includes/ra/fsp/inc/api" \
-I"%LOCALAPPDATA%/.../variants/MINIMA/includes/ra/fsp/inc/instances" \
-I"%LOCALAPPDATA%/.../variants/MINIMA/includes/ra_gen" \
... \
-iprefix"%LOCALAPPDATA%/.../renesas_uno/1.6.0/" \
@"%LOCALAPPDATA%/.../variants/MINIMA/includes.txt" \
"D:/Documents/Arduino/libraries/Arduino_10BASE_T1S/src/Arduino_10BASE_T1S_UDP.cpp" \
-o "D:/.../AppData/Local/Temp/arduino/sketches/.../build/Arduino_10BASE_T1S_UDP.cpp.o"

这就是开启”编译时显示详细输出”时在 Arduino IDE 控制台看到的实际命令。每一行背后都是一个 ProcessBuilder.exec()(Java)或 exec.Command()(Go),对应一个真实的 GCC 进程。


5. Phase 3:编译执行流程(6 条 recipe 的串/并行)

5.1 六个 recipe 总览

顺序 recipe 名称 执行内容 并行性
1 recipe.c.o.pattern gcc -c *.c → *.o 并行(~30 个 .c)
2 recipe.cpp.o.pattern g++ -c *.cpp → *.o 并行(~120 个 .cpp)
3 recipe.S.o.pattern g++ -c *.S → *.o 串行(汇编文件很少)
4 recipe.ar.pattern ar rcs core.a *.o 串行(依赖步骤 1-3)
5 recipe.c.combine.pattern g++ -o *.elf 串行(依赖步骤 4)
6 recipe.objcopy.bin.pattern objcopy *.elf → *.bin 串行(依赖步骤 5)

5.2 并行编译的细节

arduino-builder 使用线程池并行执行无依赖关系的编译任务:

1
2
3
4
5
6
7
CPU 核心 1: arm-none-eabi-g++ → Arduino_10BASE_T1S_UDP.cpp.o
CPU 核心 2: arm-none-eabi-g++ → TC6_Arduino_10BASE_T1S.cpp.o
CPU 核心 3: arm-none-eabi-g++ → TC6_Io.cpp.o
CPU 核心 4: arm-none-eabi-g++ → libtc6/tc6.cpp.o
CPU 核心 5: arm-none-eabi-g++ → liblwip/core/udp.c.o
CPU 核心 6: arm-none-eabi-g++ → liblwip/core/ipv4/ip4.c.o
... (同时运行 4-8 个进程,取决于 CPU 核心数)

5.3 各 recipe 详解

Recipe 1 & 2:编译(platform.txt 第 71/74 行)

1
2
3
4
5
# .c 文件
arm-none-eabi-gcc [flags] -o xxx.o xxx.c

# .cpp 文件
arm-none-eabi-g++ [flags] -o xxx.o xxx.cpp

注意 -MMD:每个编译任务同时生成一个 .d 依赖文件。

Recipe 3:打包 core.a(platform.txt 第 80 行)

1
2
3
4
5
6
7
arm-none-eabi-ar rcs build/core.a \
main.cpp.o \
wiring.c.o \
HardwareSerial.cpp.o \
Print.cpp.o \
Stream.cpp.o \
... (所有 Arduino 核心层的 .o)

Recipe 4:链接(最关键一步,platform.txt 第 83 行)

这是所有 .o + core.a + libfsp.a 最终合并为 .elf 的步骤:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
arm-none-eabi-g++ \
-Wl,--gc-sections \
--specs=nosys.specs \
-mcpu=cortex-m4 \
-mfloat-abi=hard \
-mfpu=fpv4-sp-d16 \
-o "build/UDP_Client.ino.elf" \
-L"build" \
-L"%LOCALAPPDATA%/.../variants/MINIMA/" \
-T"%LOCALAPPDATA%/.../variants/MINIMA/fsp.ld" \
build/*.o \
-Wl,--whole-archive \
"%LOCALAPPDATA%/.../variants/MINIMA/libs/libfsp.a" \
build/core.a \
-Wl,--no-whole-archive \
--specs=nano.specs \
-lstdc++ -lsupc++ -lm -lc -lgcc -lnosys \
-Wl,--end-group \
-Wl,-Map,build/UDP_Client.ino.map

关键参数解读:

参数 作用
-Wl,--gc-sections 丢弃未引用的函数和数据段(需要 -ffunction-sections 配合)
--specs=nosys.specs 不链接系统调用(嵌入式无 OS)
-T fsp.ld 指定链接脚本(定义 Flash/RAM 布局)
-Wl,--whole-archive ... -Wl,--no-whole-archive 强制链接 libfsp.a 和 core.a 中所有符号(包括未引用的)
--specs=nano.specs 使用 newlib-nano(精简版 C 库)
-Wl,-Map,*.map 生成符号映射文件

Recipe 5 & 6:生成 .bin

1
2
3
4
5
arm-none-eabi-objcopy -O binary -j .text -j .data \
UDP_Client.ino.elf \
UDP_Client.ino.bin

arm-none-eabi-size -A UDP_Client.ino.elf

6. 完整的参数替换链路图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
boards.txt
minima.build.mcu=cortex-m4
minima.build.float-abi=-mfloat-abi=hard
minima.build.fpu=-mfpu=fpv4-sp-d16
minima.build.defines=-DF_CPU=48000000 -DARDUINO_UNOR4_MINIMA
minima.compiler.fsp.cxxflags=-mthumb "@{compiler.fsp.defines}"
minima.compiler.fsp={build.variant.path}/libs/libfsp.a
minima.compiler.fsp.includes={build.variant.path}/includes.txt

│ platform.txt
│ compiler.cpp.cmd={build.crossprefix}g++
│ compiler.cpp.flags=... -mcpu={build.mcu} {build.float-abi} {build.fpu} ...
│ recipe.cpp.o.pattern="{compiler.path}{compiler.cpp.cmd}" {compiler.cpp.flags} ...
│ │
│ │ MINIMA/defines.txt
│ │ -D_RA_CORE=CM4
│ │ -D_RENESAS_RA_
│ │ │
│ │ │ arduino-builder 源码发现
│ │ │ {includes} = -I... -I... -I... (50+ 个)
│ │ │ {source_file} = Arduino_10BASE_T1S_UDP.cpp
│ │ │ {object_file} = Arduino_10BASE_T1S_UDP.cpp.o
│ │ │ │
│ │ │ ▼
│ │ │ "C:\...\arm-none-eabi-g++" \
│ │ │ -c -Os -mcpu=cortex-m4 \
│ │ │ -DF_CPU=48000000 \
│ │ │ -DARDUINO_UNOR4_MINIMA \
│ │ │ -D_RA_CORE=CM4 \
│ │ │ -mthumb \
│ │ │ -I"D:/.../Arduino_10BASE_T1S/src" \
│ │ │ -I"%LOCALAPPDATA%/.../variants/MINIMA/includes/ra/fsp/inc" \
│ │ │ ... \
│ │ │ "Arduino_10BASE_T1S_UDP.cpp" \
│ │ │ -o "Arduino_10BASE_T1S_UDP.cpp.o"
│ │ │
│ │ ▼
│ │ fork+exec → arm-none-eabi-g++ 进程 ← 真实编译器
│ │ │
│ │ ▼
│ │ build/Arduino_10BASE_T1S_UDP.cpp.o (150 个 .o 之一)
│ │
│ ▼
│ build/core.a (ar 打包所有核心 .o)
│ │
│ ▼
│ arm-none-eabi-g++ (链接)
│ + libfsp.a + core.a + 所有 .o
│ │
│ ▼
│ build/UDP_Client.ino.elf
│ │
│ ▼
│ arm-none-eabi-objcopy
│ │
│ ▼
│ build/UDP_Client.ino.bin ← 最终烧录文件

7. 如何查看 Arduino 实际生成的完整编译命令

7.1 方法 1:arduino-cli dry-run(推荐)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 模拟编译但不实际执行,生成 compile_commands.json
arduino-cli compile `
--fqbn arduino:renesas_uno:minima `
--verbose `
--only-compilation-database `
--build-path ./build_verbose `
./examples/UDP_Client/UDP_Client.ino

# 查看每一条编译命令
Get-Content ./build_verbose/compile_commands.json | ConvertFrom-Json |
Select-Object -First 3 | ForEach-Object {
Write-Host "=== $($_.file) ===" -ForegroundColor Cyan
Write-Host $_.command
}

compile_commands.json 是 JSON 格式的编译数据库,每一项包含完整的编译器命令,可直接用于 Clangd / VSCode C++ Intellisense 或分析工具。

7.2 方法 2:查看 build 目录

1
2
3
4
5
6
7
8
9
10
# 编译后检查生成物
ls build/*.o | Measure-Object # 目标文件数量
ls build/*.d | Measure-Object # 依赖文件数量
ls build/*.a # 静态库

# 查看某个 .d 依赖文件
Get-Content build/Arduino_10BASE_T1S_UDP.cpp.d

# 查看 core.a 的构成
arm-none-eabi-ar -t build/core.a | Select-Object -First 20

7.3 方法 3:Arduino IDE 详细输出

1
2
3
文件 → 首选项
☑ 编译时显示详细输出
☑ 上传时显示详细输出

控制台会显示每一条 arm-none-eabi-g++ 命令的全部参数。

7.4 方法 4:用 PowerShell 过滤感兴趣的命令

1
2
3
4
5
6
7
8
9
# 只想看 lwIP 相关的编译
arduino-cli compile --verbose ... 2>&1 | Select-String "lwip"

# 只想看 libtc6 相关的编译
arduino-cli compile --verbose ... 2>&1 | Select-String "libtc6"

# 只想看链接命令(最长的一条)
arduino-cli compile --verbose ... 2>&1 | Select-String "arm-none-eabi-g\+\+" |
Where-Object { $_.ToString().Length -gt 500 }

8. 源码分析:arduino-builder 内部做了什么

如果你想深入到代码层面,arduino-builder 的核心逻辑在以下文件中(从 github.com/arduino/arduino-builder 获取):

8.1 源码发现逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// arduino-builder/compilers/Builder.go
// 关键函数: RunHardwareTasks()

// 1. 加载 platform.txt 和 boards.txt
props := loader.LoadHardware(...)
boardProps := props.SubTree("minima.")

// 2. 合并所有定义
allFlags := Merge(platformFlags, boardFlags, variantFlags)

// 3. 发现源文件(核心方法)
sketch := loadSketch(sketchPath)
libraries := findLibraries(sketch)
sourceFiles := discoverSourceFiles(sketch, libraries, core)

// 4. 收集 include 路径
includes := collectIncludes(sourceFiles)

// 5. 对每个源文件执行 recipe
for _, src := range sourceFiles {
cmd := buildCommand(recipe, {
"{source_file}": src.Path,
"{object_file}": objPath(src),
"{includes}": includes,
"{build.xxx}": allFlags["build.xxx"],
// ... 所有其他变量
})
exec.Command(cmd) // fork+exec
}

// 6. 归档 core.a
exec.Command("ar", "rcs", "core.a", coreObjects...)

// 7. 链接 .elf
exec.Command("g++", "-o", "*.elf", allObjects, "core.a", "libfsp.a", ldScript, ...)

8.2 增量编译逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 如果启用了增量编译(默认启用)
if incrementalBuildEnabled {
for _, src := range sourceFiles {
obj := objPath(src)
dep := depPath(src) // *.d 文件

if !needsRebuild(src, dep, obj) {
continue // 跳过未变化的文件
}
compile(src, obj)
}
}

// needsRebuild() 的实现:
// 读取 .d 文件,检查所有依赖(.h 文件)的 mtime
// 如果 .h 的 mtime 比 .o 新,才重新编译

9. 常见误解澄清

误解 1:”Arduino 不用 Makefile 所以很快”

事实:Arduino 的构建速度往往比纯 Makefile 更慢,因为:

  • 每次编译都要重新扫描所有源文件(没有预先计算的 compile_commands.json
  • 没有精细的增量依赖跟踪(仅靠 .d 文件,不追踪更复杂的 include 依赖链)
  • 并行度受限于线程池,不及 make -j$(nproc) 灵活

误解 2:”Arduino 编译的是 .ino 文件”

事实.ino 只是 sketch 的入口,真正的编译单元是 Arduino 合并后的 .cpp + 所有 src/*.cpp + cores/*.c.ino 本身不直接被 GCC 处理。

误解 3:”platform.txt 决定了所有编译行为”

事实platform.txt 只定义 recipe 模板(如何调用 GCC),而 build 变量(MCU 型号、FPU 参数、链接脚本路径)全部来自 boards.txt。两者缺一不可。

误解 4:”库只在第一次编译时扫描”

事实:每次编译 Arduino 都会重新扫描 src/ 目录。如果库文件变化,或 sketch 中 #include 了新的库,下次编译会自动包含。Arduino IDE 会缓存扫描结果,但缓存失效时仍需重新扫描。


10. 总结

10.1 三层抽象对照

层级 机制 负责什么
用户层 #include + src/ 自动扫描 用户无需声明源文件
模板层 platform.txt recipe 模式 + boards.txt 变量 一个 recipe 通用于所有板子
执行层 arduino-builder / arduino-cli 填充变量 → fork GCC → 并行执行

10.2 关键文件的作用

文件 位置 作用
platform.txt hardware/renesas_uno/1.6.0/ 定义 recipe 模板和编译器参数
boards.txt hardware/renesas_uno/1.6.0/ 定义板级变量(MCU、Flash、RAM、链接脚本)
fsp.ld variants/MINIMA/ 链接脚本(Flash/RAM 布局、段定义)
defines.txt variants/MINIMA/ FSP 专用宏定义
includes.txt variants/MINIMA/ FSP 头文件搜索路径
library.properties libraries/Arduino_10BASE_T1S/ 库元数据(版本、支持的架构)

10.3 核心流程

1
2
3
4
5
6
7
8
9
10
arduino-builder 启动

├── 1. 递归扫描 src/ + sketch/ + cores/ → 150 个源文件
├── 2. 解析所有 #include → 50 个 -I 参数
├── 3. 读取 boards.txt + platform.txt → 替换 {build.xxx} 变量
├── 4. 对每个 .c/.cpp 执行: arm-none-eabi-gcc/g++ -c (并行)
├── 5. ar rcs core.a (打包核心 .o)
├── 6. arm-none-eabi-g++ 链接 → .elf
├── 7. objcopy → .bin
└── 8. size 报告 Flash/RAM 使用

本质:Arduino 用 recipe 模板代替 Makefile 的规则,用 自动扫描代替 Makefile 的 SRCS := *.c,用 arduino-builder 代替 make。Makefile 的角色被这个 Java/Go 程序接管了,但它生成的命令仍然是标准 GCC,输出的仍然是 .elf + .bin,链接脚本仍然是 .ld。对于习惯 Makefile 的工程师来说,Arduino 的构建系统本质上是一个带自动依赖管理的 GCC 调用器


下一步阅读

实用命令arduino-cli compile --only-compilation-database --verbose --build-path ./build ... 生成的 compile_commands.json 是理解编译过程的最佳起点。