AVR USI模块SPI通信配置详解:从寄存器操作到实战调试
1. 项目缘起为什么还要折腾AVR的USI SPI最近在整理一个老项目的维护文档又翻出了几块ATtiny85和ATtiny13的开发板。这些小家伙现在看起来性能平平无奇但在一些对成本、功耗和体积极其敏感的场合比如简单的传感器节点、一次性消费电子产品或者作为大系统中的协处理器它们依然是极具性价比的选择。在调试一个基于ATtiny85的无线模块通信时我再次用到了它的USIUniversal Serial Interface模块来实现SPI通信。说实话比起STM32、ESP32这些现代MCU丰富的外设库AVR的USI配置起来确实需要多花点心思寄存器直接操作的感觉很“复古”但也正因为如此一旦吃透你对SPI时序的理解会深刻得多。网上关于AVR USI SPI的资料要么是年代久远的代码片段语焉不详要么是直接给个“标准配置”却不说为什么。新手照着配通信不通是常态然后就开始怀疑人生。其实USI模块本身设计得很灵活但它的灵活性也带来了配置的复杂性。SPI通信的核心在于主从设备间时钟和数据线的严格同步而USI模块需要你手动“组装”出这个同步逻辑。这就像给你一套乐高零件移位寄存器、时钟逻辑告诉你它能拼出一辆车SPI但具体怎么拼轮子装哪得你自己看说明书数据手册并动手试。所以这篇内容不是简单的代码搬运而是结合我多次调试的经验带你从USI模块的底层逻辑出发一步步拆解如何将它配置成SPI主机或从机。我们会搞清楚每个寄存器位的作用弄明白时钟极性和相位CPOL/CPHA到底在配置什么并给出经过实测的驱动代码框架。无论你是正在学习AVR还是在维护老项目希望这篇“踩坑总结”能帮你少走弯路。2. USI模块的本质一个灵活的串行接口“积木盒”在深入SPI配置之前我们必须先理解USI到底是什么。它不是ATmega或ATtiny系列里那种独立的、功能固定的SPI外设像ATmega16的SPI模块。USI顾名思义是一个通用串行接口。你可以把它想象成一个高度可配置的数字积木盒里面核心包含了一个8位移位寄存器、一个时钟发生器/选择器和一些控制逻辑。它的“通用”性体现在通过不同的配置这个模块可以模拟出多种串行协议三线模式USIWM[1:0] 0b01 可以用于自定义的、简单的双向数据线通信。I2C模式USIWM[1:0] 0b10 需要配合外部上拉电阻实现I2C主从机功能。SPI模式USIWM[1:0] 0b11 或 0b00这里有个关键点 这就是我们今天的重点。但请注意数据手册上SPI模式的设置值可能因型号略有差异需要查证。对于SPI通信USI模块主要提供了以下硬件支持移位寄存器USIDR 这是数据进出的核心。当你写入数据到USIDR在时钟驱动下数据会从DO或DI取决于模式引脚一位一位地移出同时对方发送的数据也会一位一位地从DI引脚移入到USIDR中。时钟单元 这是最容易出错的地方。USI的时钟可以来自三个地方软件触发USICLK 你写1到USICLK位来产生一个时钟脉冲。这在低速或需要精确控制时序时有用。外部时钟USCK/SCK引脚 当配置为从机时时钟由外部主机提供。定时器/计数器0比较匹配 可以产生一个固定频率的时钟用于主机模式。 你需要通过USICS[1:0]和USICLK等位来选择合适的时钟源和边沿。控制逻辑 包括计数器USICNT[3:0]用于计数移位的位数SPI通常是8位以及一些状态标志位如移位完成标志USIOIF。关键理解 USI模块不自动处理SPI通信中的“片选SS信号”。片选需要你手动控制一个普通的GPIO引脚。这是与全功能SPI外设的一个重要区别。同时它通常只支持全双工或半双工的SPI模式即同时收发像单线双向模式需要更复杂的软件模拟。注意 不同AVR型号的USI可能略有差异。例如ATtiny85的USI功能比ATtiny13更完整。务必以你手头芯片的官方数据手册Datasheet为准本文以ATtiny85/45/25系列为主要参考。3. 核心配置详解从寄存器位到SPI时序配置USI为SPI模式本质上是告诉它“请把你的移位寄存器和时钟逻辑按照SPI协议的规则来运作”。这主要通过USICRUSI控制寄存器和USISRUSI状态寄存器来完成。我们结合SPI的四个关键参数来理解SPI模式时钟极性CPOL与时钟相位CPHA 这是SPI配置的基石决定了时钟空闲状态和数据的采样时刻。CPOL0 时钟线SCK空闲时为低电平。CPOL1 时钟线空闲时为高电平。CPHA0 数据在时钟的第一个边沿如果CPOL0就是上升沿CPOL1就是下降沿被采样。CPHA1 数据在时钟的第二个边沿被采样。对于USI你需要通过USICLK控制是否产生时钟脉冲和USICS[1:0]/USIWM[1:0]的配合来模拟这些边沿。一个常见的对应关系是以主机模式为例模式0 (CPOL0, CPHA0) 空闲低电平数据在上升沿采样。配置USI在时钟上升沿移位可能需要设置USICSx选择外部时钟上升沿触发或配合软件控制。模式3 (CPOL1, CPHA1) 空闲高电平数据在下降沿采样。配置USI在时钟下降沿移位。具体配置步骤拆解3.1 确定主从模式与引脚映射首先通过USIWM[1:0]位设置SPI模式。对于ATtiny85设置USIWM11, USIWM01通常用于SPI主机或从机具体行为还需结合时钟源设置。然后查看数据手册的“引脚配置”章节找到USI功能对应的物理引脚。以ATtiny85为例PB0(XCK/TO/DI) 通常作为数据输入DI。PB1(T1/DO) 通常作为数据输出DO。PB2(AIN1/SCK) 通常作为时钟线SCK。片选SS你需要自己指定一个GPIO比如PB3。你需要将这些引脚配置为正确的方向输入或输出。主机模式下DO和SCK应设置为输出DI为输入。从机模式下DO为输出DI和SCK为输入。// ATtiny85 SPI主机引脚初始化示例 #define SPI_DDR DDRB #define SPI_PORT PORTB #define SPI_PIN PINB #define USI_DI PB0 // 输入 #define USI_DO PB1 // 输出 #define USI_SCK PB2 // 输出 #define SPI_SS PB3 // 片选普通GPIO输出 void spi_master_init(void) { // 配置DO, SCK, SS 为输出 SPI_DDR | (1 USI_DO) | (1 USI_SCK) | (1 SPI_SS); // 配置DI为输入通常内部上拉可开可不开 SPI_DDR ~(1 USI_DI); // 初始时片选拉高无效时钟线设置为空闲状态取决于CPOL SPI_PORT | (1 SPI_SS); // 如果CPOL0SCK初始低CPOL1SCK初始高。这里以模式0为例 SPI_PORT ~(1 USI_SCK); // 空闲低电平 }3.2 配置时钟源与边沿实现CPOL/CPHA这是最核心的一步通过USICR寄存器配置。我们以实现主机模式、SPI模式0为例void usi_spi_master_mode0_init(void) { // 设置USI为三线模式SPI的一种实现方式实际上ATtiny85的SPI模式可能对应特定的USIWM值 // 根据数据手册对于SPI主机通常设置 USIWM11, USIWM00 或 其他组合务必查表 // 假设我们查表得知USIWM[1:0]0b00 为SPI主机使用软件时钟或定时器 // USICS[1:0]0b00 表示时钟源来自软件触发 // USICLK 位用于在软件中产生时钟边沿 // 但我们更常用的是使用定时器作为时钟源以产生稳定的时钟。 // 例如设置 USICS11, USICS00 表示时钟源来自定时器0比较匹配。 // 同时设置 USICLK0不立即产生时钟USIOIE0禁用溢出中断。 // 以下是一个常见的、经过简化的主机模式0初始化代码框架 USICR 0; // 先清零 // 设置SPI模式USIWM11, USIWM00 (示例需核对) // 设置时钟源USICS11, USICS00 (Timer0 Compare Match) // 不使能时钟输出和中断 USICR (1 USIWM1) | (1 USIWM0) | (1 USICS1); // 再次强调此值为示例 // 同时需要配置Timer0来产生合适的比较匹配频率以决定SPI的时钟速度。 // 例如设置CTC模式设置OCR0A的值。 TCCR0A (1 WGM01); // CTC模式 TCCR0B (1 CS01); // 预分频8 OCR0A 255; // 比较值决定频率 F_SPI F_CPU / (2 * prescaler * (1 OCR0A)) // 注意USI会在每次Timer0比较匹配时自动产生一个时钟脉冲其边沿由USI的配置决定。 }关键点USICS[1:0]和USICLK位的组合共同决定了移位寄存器在哪个时钟边沿动作。对于模式0CPOL0 CPHA0我们需要数据在上升沿被从机采样同时主机也在上升沿采样输入数据对于全双工。这意味着USI应该在时钟的某个边沿可能是上升沿触发一次移位操作。你需要仔细阅读数据手册中关于“时钟输出”和“移位寄存器时钟”的时序图来确定如何设置才能产生符合SPI模式要求的边沿序列。实操心得很多时候通信失败就是因为这个边沿没设对。一个笨但有效的方法是用逻辑分析仪或示波器抓取SCK、DO、DI的波形然后对照SPI时序图看数据采样点是否正确。如果发现数据错位尝试调整USICSx的设置或者改变你手动产生时钟如果使用软件时钟的顺序。3.3 编写数据收发函数配置好硬件后数据收发就围绕着USIDR寄存器和计数器USICNT展开了。基本流程如下将待发送的数据写入USIDR。清除计数器USICNT[3:0]0或设置计数器为需要移位的位数对于8位数据可以写入0因为计数器溢出值是0移位8次后溢出或者直接设置USICNT[3:0]0b1000但不同芯片处理方式不同。启动传输。如果是软件时钟则需要循环产生时钟脉冲写USICLK位如果使用定时器时钟则使能定时器或等待传输完成。等待传输完成标志USIOIF置位。从USIDR中读取接收到的数据。// 一个使用软件时钟手动翻转SCK的SPI主机发送/接收函数示例模式0 uint8_t usi_spi_transfer(uint8_t data) { USIDR data; // 加载要发送的数据 USISR (1 USIOIF); // 清除溢出中断标志同时将计数器清零通过写1来清除标志 // 手动产生8个时钟脉冲软件时钟模式 // 这种模式下我们需要自己控制SCK引脚的高低变化来满足CPOL和CPHA // 以下代码模拟模式0空闲低电平数据在上升沿采样 for (uint8_t i 0; i 8; i) { // 先设置数据位DO稳定 // 然后产生上升沿SCK从低到高 SPI_PORT | (1 USI_SCK); // SCK拉高产生上升沿 _delay_us(1); // 短暂延时确保建立时间 // 在这里从机将在上升沿采样数据位 // 主机也需要在上升沿采样输入数据但USI硬件可能自动在某个边沿锁存DI // 实际上对于软件模拟我们通常在时钟边沿后读取数据 // 更准确的做法是依赖USI硬件移位我们只触发时钟 // USICR | (1 USICLK); // 产生一个时钟脉冲如果配置为软件时钟触发 // while (!(USISR (1 USIOIF))); // 等待一次移位完成 SPI_PORT ~(1 USI_SCK); // SCK拉低产生下降沿为下一个上升沿准备 _delay_us(1); } // 传输完成后USIDR中已经是接收到的数据 // 但更标准的做法是等待USIOIF标志然后读取USIDR // while (!(USISR (1 USIOIF))); // 等待8次移位完成 return USIDR; }重要提示上面的for循环示例是纯软件模拟SPI时序并没有充分利用USI的硬件移位功能仅用于理解时序。在实际使用USI硬件时我们更倾向于配置好时钟源后启动传输然后等待USIOIF标志。下面的代码更接近硬件辅助的写法uint8_t usi_spi_hw_transfer(uint8_t data) { USIDR data; // 数据放入移位寄存器 USISR (1 USIOIF); // 写1清除溢出标志并复位计数器为0 // 启动传输如果是软件时钟模式循环触发USICLK如果是定时器时钟使能定时器或等待。 // 假设我们配置为使用定时器0比较匹配作为时钟源自动产生时钟 // 那么只需要等待传输完成即可。 // 但为了通用性这里展示一种常见的“等待循环触发”方式适用于多种时钟源配置 // 通过一个do-while循环等待计数器溢出USIOIF置位 // 在循环内如果配置为软件时钟可能需要手动触发USICLK。 // 以下是一个简化的模式 do { // 如果时钟源是软件触发则需要在此执行USICR | (1 USICLK); // 对于已配置为外部或定时器时钟的情况这行代码可能不需要。 // 我们使用一个空语句或极短延时让硬件自动工作。 _delay_us(0.1); } while (!(USISR (1 USIOIF))); // 等待8位数据移位完成 // 传输完成USIDR中现在是从设备读回的数据 return USIDR; }4. 从机模式配置要点与常见问题排查将USI配置为SPI从机逻辑上更简单因为时钟SCK完全由外部主机控制。但也有一些需要特别注意的地方。4.1 从机模式初始化引脚配置DI和SCK设置为输入DO设置为输出。特别注意DO引脚通常需要在USIDR被写入数据后才能输出有效数据。有些配置下需要设置USIWM和USICS为从机模式使得DO引脚在移位过程中自动输出。寄存器配置 设置USIWM[1:0]为SPI从机模式查数据手册确定值。设置USICS[1:0]选择外部SCK引脚作为时钟源并配置在正确的边沿与主机模式匹配。例如对于模式0从机需要在SCK的上升沿采样数据所以USI应该配置为在SCK上升沿触发移位。片选SS处理 从机必须有一个片选引脚通常是一个普通GPIO配置为输入。当SS被主机拉低时从机开始准备或响应通信。在一些严格的SPI实现中SS引脚还用于复位从机的内部状态如移位计数器。在USI中你可以将SS引脚的变化与外部中断或引脚变化中断结合来初始化USI或准备数据。// ATtiny85 SPI从机初始化示例模式0 void spi_slave_init(void) { // 配置DI, SCK为输入DO为输出 DDRB ~((1 USI_DI) | (1 USI_SCK)); // DI, SCK 输入 DDRB | (1 USI_DO); // DO 输出 // 可选使能内部上拉防止悬空 PORTB | (1 USI_DI) | (1 USI_SCK); // 配置USI为SPI从机模式使用外部SCK时钟在上升沿触发 // 假设查表得USIWM[1:0]0b11 为SPI从机USICS[1:0]0b01 为外部时钟正边沿 USICR (1 USIWM1) | (1 USIWM0) | (1 USICS0); // 清除标志和计数器 USISR (1 USIOIF); }4.2 从机数据收发从机的数据收发通常由中断驱动。你可以使能USI溢出中断USIOIE当8位数据移位完成计数器溢出时进入中断服务程序ISR。在ISR中读取USIDR得到主机发来的数据同时将需要回复给主机的下一个数据写入USIDR。// 全局变量用于数据交换 volatile uint8_t spi_received_data 0; volatile uint8_t spi_data_to_send 0xFF; // 默认发送0xFF // USI溢出中断服务程序 ISR(USI_OVF_vect) { spi_received_data USIDR; // 读取接收到的数据 USIDR spi_data_to_send; // 装入要发送的数据为下一次传输准备 USISR | (1 USIOIF); // 清除溢出标志通过写1并复位计数器 // 注意清除标志后USI会等待下一个SCK时钟边沿继续移位。 } void spi_slave_enable(void) { USICR | (1 USIOIE); // 使能USI溢出中断 sei(); // 开启全局中断 // 预先加载第一个要发送的数据 USIDR spi_data_to_send; USISR (1 USIOIF); // 清除标志准备开始 }4.3 通信失败排查清单主机/从机通用当你按照上述步骤配置后如果SPI通信仍然失败可以按照以下清单逐步排查物理连接检查VCC和GND是否连接正确、牢固。检查SCK,DO,DI三条线是否交叉连接主机的DO接从机的DI主机的DI接从机的DO。片选SS线是否连接并正确控制主机在通信前拉低对应从机的SS通信后拉高。线路是否过长是否有干扰对于高速SPI需要考虑信号完整性。电源与电平主从设备是否共地这是必须的。双方IO电平是否兼容如果主机是5V从机是3.3V可能需要电平转换。软件配置CPOL和CPHA是否匹配这是最常见的问题。用逻辑分析仪抓取SCK和DO波形对照SPI模式图检查。主机和从机的模式必须完全一致。时钟频率是否过高尤其是使用软件模拟时钟或低速MCU时降低时钟频率增加_delay_us或调整定时器分频试试。USI寄存器配置是否正确反复核对USICR和USISR的每一位特别是USIWM[1:0]、USICS[1:0]和USICLK。最可靠的方法是在数据手册中找到SPI通信的示例代码或时序图对照着配置。引脚方向DDRx设置是否正确主机SCK、DO输出DI输入从机反之。是否在传输开始前正确加载了要发送的数据到USIDR是否清除了USIOIF标志并复位了计数器在每次传输开始前通常需要写USISR来清除标志和复位计数器。是否等待传输完成在读取USIDR之前必须确保USIOIF标志置位或使用中断。工具辅助逻辑分析仪是你的最佳朋友。一个几十块钱的简易逻辑分析仪配合Sigrok/PulseView软件就能清晰地显示SCK、DO、DI、SS四条线上的时序一眼就能看出数据在哪一位、时钟边沿是否正确。没有逻辑分析仪可以用两个LED分别接在DO和SCK上通过LED的闪烁情况粗略判断是否有数据在传输。5. 进阶话题性能优化与特殊场景处理在基本通信调通后你可能会考虑以下问题5.1 提高SPI通信速度USI的SPI速度受限于几个因素CPU主频 这是上限。时钟源 使用定时器比较匹配产生的时钟比软件翻转SCK引脚要快得多也稳定得多。代码效率 传输函数的循环、延时、标志检查都会消耗时间。尽量使用中断和DMA如果支持来解放CPU。对于主机可以尝试在while等待USIOIF时并行处理其他不冲突的任务。对于从机中断服务程序ISR应尽可能短小精悍只做必要的数据搬运。计算SPI时钟频率 如果使用定时器0比较匹配作为时钟源USICS11, USICS00则SPI时钟频率为F_SPI F_CPU / (2 * N * (1 OCR0A))其中N是定时器预分频系数1, 8, 64, 256, 1024。例如F_CPU8MHz预分频N8OCR0A0则F_SPI 8MHz / (2*8*1) 500kHz。设置OCR0A1则F_SPI 8MHz / (2*8*2) 250kHz。5.2 处理多从机与大数据量传输多从机 每个从机都需要独立的SS片选线。主机在通信前只拉低目标从机的SS其他保持高电平。USI模块本身不管理SS这需要你在软件中精确控制GPIO。大数据量传输 连续发送多个字节时需要注意SS信号的控制。通常在一次“事务”中比如读写一个传感器的多个寄存器SS应始终保持低电平。在字节与字节之间你需要确保USI已经完成前一个字节的传输USIOIF置位然后立即写入下一个字节到USIDR并清除标志启动下一次传输。避免在字节间产生不必要的SCK空闲周期除非协议要求。5.3 USI与其他功能引脚的冲突在ATtiny等小引脚芯片上USI引脚DI,DO,SCK可能与ADC输入、外部中断、PWM输出等功能复用。如果你同时需要使用这些功能必须在初始化时规划好引脚功能。通过DDRx、PORTx寄存器以及相关功能模块如ADC、定时器的使能位来协调。通常一个引脚在同一时刻只能用于一种主要功能。6. 一个完整的、可移植的USI SPI驱动框架示例下面提供一个针对ATtiny85/45/25的、相对完整的SPI主机驱动框架采用定时器时钟源模式0。请注意其中的寄存器位定义需要根据你使用的具体芯片和编译器如AVR-GCC进行调整。/** * USI SPI Master Driver for ATtiny85/45/25 * Mode: 0 (CPOL0, CPHA0) * Clock Source: Timer0 Compare Match */ #include avr/io.h #include util/delay.h // 引脚定义 #define SPI_DDR DDRB #define SPI_PORT PORTB #define SPI_PIN PINB #define USI_DI_PIN PB0 #define USI_DO_PIN PB1 #define USI_SCK_PIN PB2 #define SPI_SS_PIN PB3 // 用户自定义片选 // USI寄存器位定义ATtiny85示例请核对你的芯片头文件 #ifndef USIWM1 #define USIWM1 USIWM0 // 有时位定义名称不同需要适配 #endif // ... 其他位定义 USIWM0, USICS1, USICS0, USICLK, USIOIE等 /** * brief 初始化USI为SPI主机模式0 * param clock_divider 时钟分频因子影响SPI速度。值越大速度越慢。 * 实际计算需参考数据手册和Timer0配置。 */ void usi_spi_master_init(uint8_t clock_divider) { // 1. 配置引脚方向 SPI_DDR | (1 USI_DO_PIN) | (1 USI_SCK_PIN) | (1 SPI_SS_PIN); SPI_DDR ~(1 USI_DI_PIN); // DI 输入 // 2. 设置初始电平SS高无效SCK低模式0空闲低 SPI_PORT | (1 SPI_SS_PIN); SPI_PORT ~(1 USI_SCK_PIN); // 3. 配置Timer0用于产生SPI时钟 // 使用CTC模式比较匹配时触发USI时钟 TCCR0A (1 WGM01); // CTC模式 // 设置预分频和比较值。这里简化处理clock_divider用于设置OCR0A // 更精细的控制需要根据F_CPU计算 TCCR0B (1 CS01); // 预分频8 OCR0A clock_divider; // 比较值控制频率 // 4. 配置USI控制寄存器 // 设置SPI主机模式使用Timer0比较匹配作为时钟源 USICR (1 USIWM1) | (1 USIWM0) // SPI Master模式 (请核对!) | (1 USICS1); // Clock Source Timer0 Compare Match // 5. 清除状态标志和计数器 USISR (1 USIOIF); } /** * brief 选择从设备拉低片选 */ void spi_select_slave(void) { SPI_PORT ~(1 SPI_SS_PIN); _delay_us(1); // 短暂延时确保从设备识别到片选变化 } /** * brief 取消选择从设备拉高片选 */ void spi_deselect_slave(void) { _delay_us(1); // 短暂延时确保最后一位数据被处理 SPI_PORT | (1 SPI_SS_PIN); } /** * brief 交换一个字节数据发送并接收 * param data 要发送的字节 * return 接收到的字节 */ uint8_t usi_spi_transfer_byte(uint8_t data) { USIDR data; // 加载发送数据 USISR (1 USIOIF); // 清除溢出标志复位计数器 // 等待传输完成8个时钟脉冲由Timer0自动产生 // 这里用while循环等待标志位也可以考虑用中断提高效率 while (!(USISR (1 USIOIF))) { // 空循环等待。如果长时间等不到应考虑超时处理。 } return USIDR; // 返回接收到的数据 } /** * brief 发送/接收多个字节数据块 * param tx_buf 发送数据缓冲区指针如果为NULL则发送0xFF * param rx_buf 接收数据缓冲区指针如果为NULL则忽略接收的数据 * param len 数据长度 */ void usi_spi_transfer_block(const uint8_t *tx_buf, uint8_t *rx_buf, uint16_t len) { for (uint16_t i 0; i len; i) { uint8_t tx_data (tx_buf ! NULL) ? tx_buf[i] : 0xFF; uint8_t rx_data usi_spi_transfer_byte(tx_data); if (rx_buf ! NULL) { rx_buf[i] rx_data; } } } // 示例主函数中读取一个SPI设备例如一个SPI Flash的ID int main(void) { usi_spi_master_init(255); // 初始化SPI设置较低的时钟速度 uint8_t cmd 0x9F; // 读ID命令 uint8_t id_buf[3] {0}; spi_select_slave(); usi_spi_transfer_byte(cmd); // 发送命令 usi_spi_transfer_block(NULL, id_buf, 3); // 读取3字节ID发送dummy数据(0xFF) spi_deselect_slave(); // 此时id_buf中包含了设备ID // ... 其他操作 while (1) { // 主循环 } return 0; }这个框架提供了初始化和基础的单字节/多字节传输函数。在实际项目中你需要根据连接的从设备的具体协议如命令格式、等待时间、CRC校验等在这个基础上进行封装。最后调试USI SPI是一个需要耐心和细致观察的过程。从理解寄存器每一位开始用逻辑分析仪验证每一个时序遇到问题就对照数据手册和排查清单。一旦跑通你会发现这个看似简单的“积木盒”其实非常强大可靠足以应对许多嵌入式场景中的串行通信需求。

相关新闻