两个Arduino Nano如何用SPI“对话”?从寄存器到实战的完整拆解
你有没有遇到过这样的场景:一个Arduino Nano快被传感器和任务压垮了,而另一个却在旁边“摸鱼”?其实,它们完全可以分工协作——一个当“指挥官”(主机),另一个做“执行员”(从机)。关键就在于SPI通信。
别被这个名字吓到。虽然它听起来像高深莫测的底层技术,但只要搞懂ATmega328P这颗芯片是怎么玩转SPI的,你就能让两块Nano流畅地交换数据,甚至构建出分布式控制系统。
今天我们就抛开浮于表面的教程,深入到寄存器级别,一步步带你实现稳定可靠的SPI主从通信,并告诉你哪些坑必须绕开。
为什么是SPI?不是I²C或串口?
在嵌入式世界里,MCU之间要“说话”,常用的方式有UART、I²C和SPI。各有优劣:
- UART:简单,但半双工,没有地址机制,多设备时容易乱;
- I²C:两根线搞定多个设备,但速度慢(通常400kbps封顶),总线竞争复杂;
- SPI:四根线起步,但速度快(理论上可达8Mbps)、全双工、实时性强。
所以如果你需要的是高速点对点传输,比如主控读取从机采集的ADC数据流,或者下发大量控制指令,那SPI就是最优解。
更重要的是,ATmega328P自带硬件SPI模块,不需要靠软件模拟时序,既省CPU资源又保证时序精准。
SPI怎么工作?不只是MOSI/MISO那几根线
先来理清一个常见的误解:很多人以为SPI就是连好SCK、MOSI、MISO、SS四根线就完事了。其实真正决定通信能否成功的,是那些藏在芯片内部的控制逻辑。
核心机制:两个移位寄存器“手拉手”
想象一下,主机和从机各自有一个8位的移位寄存器。通信开始时:
- 主机把要发的数据写进自己的SPDR(SPI Data Register);
- SCK开始跳动,每跳一次,双方都把当前最高位推出去,同时从对方接收一位;
- 经过8个时钟周期后,双方的寄存器都被对方填满 —— 这就是一个字节的全双工交换。
注意关键词:“交换”。SPI不是单向发送,而是一边发一边收。哪怕你只想传数据,也得准备好接收从机可能回你的内容。
关键寄存器一览
这些才是操控SPI的核心开关:
| 寄存器 | 功能 |
|---|---|
| SPCR | 控制是否启用SPI、主/从模式、数据顺序、时钟极性等 |
| SPSR | 查看状态,比如是否完成一次传输(SPIF标志) |
| SPDR | 读写数据的地方 |
举个例子:
SPCR |= (1 << SPE); // 开启SPI功能 SPCR |= (1 << MSTR); // 设为主机模式就这么两句,就把ATmega328P变成了SPI主机。
两个Nano怎么接线?别忽略这个致命细节
硬件连接看似简单,但一个小疏忽就能让你调试三天。
标准引脚对应关系如下:
| 功能 | 引脚(Nano上的数字编号) | ATmega328P端口 |
|---|---|---|
| SCK | D13 | PB5 |
| MOSI | D11 | PB3 |
| MISO | D12 | PB4 |
| SS | D10 | PB2 |
重要提醒:
虽然D10是默认的SS引脚,但在从机上,必须把它配置为输入模式!否则即使你不碰它,内部电路也可能误判为片选有效。
推荐接法:
Master (Nano #1) Slave (Nano #2) D13 ------------------- D13 ← SCK D11 ------------------- D11 ← MOSI D12 ------------------- D12 ← MISO D10 ------------------- D10 ← SS (主输出,从输入) GND ------------------- GND ← 共地!不能少✅ 必须共地,否则信号参考电平不一致,通信必崩。
主机代码:别忘了“片选”才是启动键
很多初学者只调SPI.transfer(),结果发现没反应——因为他们忘了最关键的一步:拉低SS引脚。
#include <SPI.h> void setup() { pinMode(10, OUTPUT); digitalWrite(10, HIGH); // 初始不选中 SPI.begin(); // 自动设置D13/D11/D12为SCK/MOSI/MISO SPI.setClockDivider(SPI_CLOCK_DIV8); // 2MHz SCK,稳妥起见不用极限速度 SPI.setDataMode(SPI_MODE0); // 最常用模式:空闲低,上升沿采样 } void loop() { char sent = 'A'; char received; digitalWrite(10, LOW); // ⚠️ 必须先拉低SS! received = SPI.transfer(sent); // 发送同时接收 digitalWrite(10, HIGH); // 完成后释放 Serial.print("Sent: "); Serial.print(sent); Serial.print(", Got back: "); Serial.println(received); delay(1000); }这里有个隐藏知识点:SPI.transfer()是阻塞函数,它会一直等到8位全部移出才返回。也就是说,当你拿到received的时候,通信已经完成了。
从机怎么做响应?轮询 vs 中断,哪种更好?
从机不能主动发起通信,只能“听命行事”。当主机拉低SS并发送数据时,它必须及时回应。
方法一:轮询检查(适合简单应用)
#include <SPI.h> char recv = 0; void setup() { pinMode(10, INPUT); // SS作为输入 SPCR |= (1 << SPE); // 只开启SPI,不设为主机 → 自动成为从机 Serial.begin(9600); } void loop() { if (SPSR & (1 << SPIF)) { // 检查是否完成传输 recv = SPDR; // 读取收到的数据 SPDR = recv + 1; // 立刻准备回复(例如加1) Serial.print("Received: "); Serial.println((char)recv); } }这种方式简单直观,但有个问题:如果主机关心的是“我发出去有没有回音”,那你必须确保每次通信前SPDR已经被写入回复值。否则会返回上次残留的数据。
方法二:使用中断(更可靠,推荐)
更好的做法是用中断处理SPI事件:
volatile char response = 0; ISR(SPI_STC_vect) { char data = SPDR; // 读取主机发来的数据 SPDR = data + 1; // 回复 } void setup() { pinMode(10, INPUT); SPCR |= (1 << SPE) | (1 << SPIE); // 开启SPI + 中断使能 sei(); // 全局中断使能 Serial.begin(9600); } void loop() { // 不需要做任何事,中断自动处理 }这样,一旦主机完成传输,ISR立刻触发,响应更及时,也不会错过任何一帧。
实际项目中该怎么设计协议?
光通上还不够,你还得让它们“说同一种语言”。
假设你要做一个温湿度采集系统,主机定期问:“温度多少?”、“湿度多少?”。
可以定义简单的命令集:
| 命令 | 含义 |
|---|---|
| 0x01 | 请求温度 |
| 0x02 | 请求湿度 |
| 0xFF | 心跳测试 |
从机根据收到的命令返回对应数据:
ISR(SPI_STC_vect) { char cmd = SPDR; switch(cmd) { case 0x01: SPDR = readTemp(); break; case 0x02: SPDR = readHumidity(); break; case 0xFF: SPDR = 'O'; break; // OK default: SPDR = 0x00; } }主机侧则按流程操作:
digitalWrite(SS_SLAVE, LOW); SPI.transfer(0x01); // 问温度 temp = SPI.transfer(0); // 收回应答 digitalWrite(SS_SLAVE, HIGH);你看,整个过程就像打电话:拨号(选中)、提问(发命令)、听回答(收数据)、挂断(释放)。
调试翻车了怎么办?这几个坑你一定踩过
别急着怪代码,先看看是不是掉进了下面这些经典陷阱:
❌ 问题1:收到的数据全是0xFF或0x00
原因:MISO线悬空或未正确连接。
解决:检查从机MISO是否接到主机MISO;确认从机确实写了SPDR。
❌ 问题2:通信偶尔失败,有时正常
原因:时钟速率太高,从机跟不上。
建议:将分频改为SPI_CLOCK_DIV16(1MHz),尤其在供电不稳定或线路较长时。
❌ 问题3:从机完全没反应
原因:SS引脚浮空导致状态不确定。
秘籍:在从机的D10上加一个10kΩ上拉电阻到VCC,确保默认为高电平。
❌ 问题4:多从机互相干扰
原因:多个从机共用同一SS线。
正解:每个从机使用独立的GPIO控制其SS引脚,实现真正的片选隔离。
提升稳定性的小技巧
想让你的SPI系统跑得稳如老狗?试试这些工程经验:
- 电源去耦:每个Nano的VCC和GND之间焊一颗0.1μF陶瓷电容,滤除高频噪声;
- 布线尽量短:超过20cm就要考虑加缓冲器(如74HC125);
- 逻辑分析仪救场:用Saleae或低成本LA抓SCK/MOSI/MISO/SS波形,一眼看出时序问题;
- LED提示通信:在主机上用LED闪烁表示正在通信,便于肉眼判断流程;
- 避免串口干扰:从机打印调试信息时,不要在中断里调
Serial.print(),会影响SPI时序!
更进一步:我能用SPI做什么?
掌握了这项技能,你的系统架构将打开新维度:
- 主控+协处理器:主机负责UI和联网,从机专攻PID控制或FFT运算;
- 分布式传感器网络:多个从机分别采集不同位置的数据,统一上报;
- 扩展IO资源:用额外的Nano当作“远程GPIO板”;
- 固件更新通道:通过SPI给从机传送新程序片段(需配合引导区设计)。
甚至未来学习CAN、Ethernet等工业总线时,你会发现底层思维是一脉相承的:同步、帧结构、主从协调。
如果你正在做毕业设计、智能小车联动,或是想搭建一个小型工业监控节点,现在就可以动手试一试。找两块闲置的Arduino Nano,照着上面的步骤连起来,看着字符“A”变成“B”传回来那一刻,你会真正体会到什么叫“硬件之间的对话”。
而这,正是嵌入式系统的魅力所在。
有问题?欢迎留言讨论。你在实际项目中用SPI做过什么有趣的应用?一起来分享吧!