1. 从“小身材”到“大能耐”为什么ATtiny85的USI值得深挖在嵌入式开发的广阔世界里我们常常被那些功能强大的32位MCU所吸引它们外设丰富、性能强劲仿佛无所不能。然而在很多场景下比如一个简单的传感器数据采集节点、一个智能纽扣、一个低成本的小玩具或者仅仅是给一个现有系统增加一个简单的逻辑控制功能使用这些“大家伙”就显得杀鸡用牛刀了。这时像ATtiny85这样的8位AVR微控制器就闪亮登场了。它只有8个引脚价格低廉功耗极低但麻雀虽小五脏俱全。而它内部最核心、也最容易被开发者低估的通信外设就是通用串行接口——USI。USI全称Universal Serial Interface是ATtiny85这类小尺寸AVR芯片的“通信多面手”。它不像STM32的SPI、I2C那样是独立且功能固定的硬件模块USI更像是一个高度可配置的通信“内核”。通过软件配置它可以模拟出SPI三线或四线、I2CTWI以及半双工UART等多种通信协议。这种灵活性使得ATtiny85在有限的硬件资源下依然能够与丰富的传感器、存储器、显示模块等外设对话极大地扩展了其应用边界。我最初接触ATtiny85的USI时也走过一些弯路。官方数据手册对USI的描述比较底层直接操作寄存器时时序和状态机的控制需要格外小心。网上很多示例代码要么过于简单只演示了主模式SPI要么在TWII2C从机实现上存在中断响应不及时、数据丢失的问题。特别是当项目需要ATtiny85同时作为SPI从机接收命令又作为I2C主机去读取传感器时如何安全、高效地切换USI的工作模式就成了一个必须啃下来的硬骨头。这篇文章我就结合自己多次踩坑和实战的经验为你彻底拆解ATtiny85 USI模块实现SPI和TWI通信的原理并提供稳定、可直接复用的代码实现。无论你是想用ATtiny85驱动一个OLED屏幕SPI还是读取一个温湿度传感器I2C亦或是设计一个双向通信的智能模块这里的内容都将为你提供清晰的路径。2. USI模块的硬件架构与核心寄存器剖析要驾驭USI必须先理解它的硬件设计思想。你可以把USI想象成一个精简而高效的“串行数据加工车间”。这个车间核心的“流水线”是一个8位的USI数据寄存器USIDR。所有要发送或接收的数据都暂存在这里。数据是如何一位一位搬进搬出这个寄存器的呢这就依赖于一个独立的USI数据位移寄存器它受时钟驱动负责完成实际的位移动作。控制这个车间的“总控台”是以下几个关键寄存器USI控制寄存器USICR这是最重要的配置寄存器。它决定了车间的工作模式。USIWM[1:0]Wire Mode这两位是模式选择开关。00代表禁用USI或用于三线模式01选择I2CTWI模式10选择SPI从机模式11选择SPI主机模式。你的代码里绝大部分关于USI的初始化都是从正确设置这两位开始的。USICS[1:0]Clock Source Select这两位选择驱动位移寄存器的时钟源。在SPI主机模式下你可以选择内部时钟并设置分频在SPI从机或I2C模式下通常选择外部时钟即来自SCK或SCL引脚的变化。USICLK这是一个软件触发的时钟脉冲位。写入1会产生一个时钟脉冲推动数据位移一位。在软件模拟某些时序或测试时非常有用。USITC这是用来翻转时钟线SCK/SCL状态的位。在I2C模式下生成起始、停止条件或者在某些特殊SPI时序中都需要操作它。USI状态寄存器USISR这个寄存器像车间的“状态指示灯”和“计数器”。低4位USICNT[3:0]这是一个4位的计数器。它记录了自上次清零以来已经发生了多少次时钟事件即位移了多少位。当它计满达到特定值如8或溢出时可以触发中断。这是我们判断一次8位数据传输是否完成的核心依据。USIDCData Output Collision、USIPFStop Condition Flag、USIOIFCounter Overflow Interrupt Flag、USISIFStart Condition Interrupt Flag这些是各种事件标志位。例如在I2C模式下USISIF会在检测到起始条件时置位USIPF会在检测到停止条件时置位。USI数据寄存器USIDR前面提到的8位数据暂存区。写入USIDR的数据会在下一个时钟周期被加载到位移寄存器准备发送接收到的数据在传输完成后可以从USIDR中读取。理解这些寄存器如何协同工作是关键。例如在SPI主机模式下你设置USIWM11USICS选择内部时钟分频。当你向USIDR写入数据后USI硬件会自动在设定的时钟频率下从USIDO数据输出引脚将数据一位位移出同时从USIDI数据输入引脚将数据一位位移入USIDR。计数器USICNT随着每个时钟周期递增计满8后USIOIF标志置位表示一次传输结束。而在I2C模式下情况更复杂一些。设置USIWM01后USI模块会开始监视SCL和SDA引脚。检测到起始条件SDA在SCL高时变低会置位USISIF检测到停止条件SDA在SCL高时变高会置位USIPF。数据的收发则需要在SCL为低时由软件或通过时钟拉伸来读取或设置SDA的状态并利用USITC位产生SCL脉冲。USICNT计数器则用来追踪当前是第几位数据或地址。注意ATtiny85的USI在I2C模式下其硬件对SDA和SCL线的控制是有限的特别是输出驱动能力。它采用了一种“开漏输出加输入采样”的机制。这意味着在软件控制下我们只能将SDA线拉低输出0或释放输出1实际上是通过外部上拉电阻变为高电平。读取SDA状态时是读取其输入引脚的电平。这一点与具有真正硬件I2C模块的MCU不同编程时需要时刻牢记。3. 实战SPI通信主机与从机的代码实现SPI是USI最常实现的功能之一因其协议简单、速率高。ATtiny85的USI可以配置为SPI主机或从机。需要注意的是USI实现的SPI是“3线”或“4线”模式但不直接支持复杂的多主模式或NSS从机选择信号的自动管理。NSS信号通常需要一个额外的GPIO来手动控制。3.1 SPI主机模式实现作为主机ATtiny85负责产生时钟SCK并控制数据传输。假设我们使用经典的4线SPIMOSI, MISO, SCK, SS连接一个SPI Flash芯片如W25Q16。第一步硬件连接与初始化首先需要根据数据手册确定USI引脚与ATtiny85物理引脚的映射关系。对于ATtiny85PB2(Pin 7): 通常用作USCK(SPI SCK)PB1(Pin 6): 通常用作DO/MOSI(Master Out Slave In)PB0(Pin 5): 通常用作DI/MISO(Master In Slave Out)PB3(Pin 2): 我们可以将其配置为普通的GPIO用作从机选择SS。初始化代码的核心是配置USICR寄存器并设置好对应的引脚方向。#include avr/io.h #include util/delay.h #define SPI_SS_PIN PB3 #define SPI_SS_PORT PORTB #define SPI_SS_DDR DDRB void USI_SPI_MasterInit(void) { // 1. 配置SPI引脚方向 // PB1 (MOSI) 和 PB2 (SCK) 设置为输出 DDRB | (1 PB1) | (1 PB2); // PB0 (MISO) 设置为输入 DDRB ~(1 PB0); // 可选使能内部上拉防止MISO浮空 PORTB | (1 PB0); // SS引脚作为普通GPIO输出并初始化为高电平不选中从机 SPI_SS_DDR | (1 SPI_SS_PIN); SPI_SS_PORT | (1 SPI_SS_PIN); // 2. 配置USI控制寄存器USICR // USIWM1:0 11 (SPI主机模式) // USICS1:0 00 (使用软件时钟 strobe配合USICLK位) 或 // 01 (使用定时器0比较匹配作为时钟) 或 // 10 (使用外部正边沿) 这里我们选择外部正边沿但作为主机我们实际使用内部时钟。 // 更常见的做法是使用USICS00然后在传输函数中手动控制USICLK位来产生时钟软件SPI。 // 若要使用硬件时钟需设置USICS10并配置时钟分频。 // 本例展示硬件时钟方式 USICR (1 USIWM1) | (1 USIWM0) | // SPI主机模式 (1 USICS1) | (0 USICS0) | // 时钟源软件时钟下降沿 / 外部正边沿 (取决于USICLK) (0 USICLK); // 不产生时钟脉冲 // 设置USI时钟预分频器通过USISR的低4位和USICR的USICS1:0组合 // 例如设置时钟为系统时钟的16分频 USISR (1 USIOIF); // 先清除溢出标志 // 注意USI的时钟分频设置较为隐蔽通常通过设置USISR的USICNT[3:0]初始值来实现。 // 更简单可靠的方式是使用软件时钟Bit-banging或定时器。硬件分频器功能有限。 }实际上由于USI作为SPI主机时其内置的时钟分频器选项不多且不易用很多开发者更喜欢在主机模式下使用“软件时钟”方式即设置USICS00然后在数据传输函数中通过循环和操作USICLK位来产生精确的时钟脉冲。这样可以获得更灵活的时钟速度控制。第二步实现数据传输函数下面是一个使用软件控制时钟的SPI主机发送/接收函数uint8_t USI_SPI_MasterTransfer(uint8_t data) { // 1. 将待发送数据加载到USIDR USIDR data; // 2. 清除计数器溢出标志并设置计数器为0准备计数16个时钟边沿这里需要小心 // 在SPI模式下一个时钟周期包含两个边沿。传输8位数据需要16个时钟边沿。 // USICNT是一个4位计数器每计数满会产生溢出。我们可以设置其初始值让它计满16次后溢出。 // 设置USISR 0xF0; 即USICNT[3:0] 0 且USIOIF0 USISIF0 USIPF0... // 更常见的做法是先清除溢出标志然后等待溢出。 USISR (1 USIOIF); // 写入1清除溢出中断标志同时USICNT被清零 // 3. 循环直到8位数据发送/接收完成USIOIF标志置位 while ( !(USISR (1 USIOIF)) ) { // 产生一个时钟脉冲通过设置USICLK位在USICS00时该位写入1会产生一个时钟边沿 // 但注意根据数据手册在USICS00时操作USICLK位会触发时钟。 // 然而更直接的方式是使用USITC位来翻转时钟线。 // 这里演示使用USITC的方法更通用 USICR | (1 USITC); // 切换USCK/SCK引脚的电平产生一个边沿 _delay_us(1); // 简单的延时控制SPI速度。实际应用中可用__builtin_avr_delay_cycles实现精确延时。 USICR | (1 USITC); // 再次切换产生另一个边沿完成一个时钟周期 _delay_us(1); // 注意上述方法会产生完整的时钟方波。但USI硬件在外部时钟模式下会自动计数。 // 对于软件模拟我们也可以直接控制PORTB的SCK引脚但使用USITC是更“硬件”的方式。 } // 4. 传输完成从USIDR读取接收到的数据 return USIDR; }这个函数是一个基础的框架。在实际项目中你需要根据外设的时序要求精确调整_delay_us()的延时或者使用定时器来产生更精确的时钟。同时别忘了在传输前后控制SS引脚拉低选中拉高释放。3.2 SPI从机模式实现ATtiny85作为SPI从机时时钟SCK由外部主机提供。USI硬件会自动检测时钟边沿并移位数据。从机的实现相对主机更简单因为它不需要产生时钟。初始化代码void USI_SPI_SlaveInit(void) { // 配置引脚方向 // PB0 (DI/MOSI) 输入用于接收主机数据 // PB1 (DO/MISO) 输出用于向主机发送数据 // PB2 (USCK/SCK) 输入用于接收主机时钟 DDRB ~((1 PB0) | (1 PB2)); // DI, SCK 输入 DDRB | (1 PB1); // DO 输出 // 可选使能内部上拉防止输入浮空 PORTB | (1 PB0) | (1 PB2); // 配置USI为SPI从机模式 // USIWM1:0 10 (SPI从机模式) // USICS1:0 1x (使用外部时钟具体边沿取决于USICLK和USICS0) // 通常设置为在SCK的上升沿采样数据模式0这需要根据主机模式调整。 // 假设主机是SPI模式0 (CPOL0, CPHA0): 时钟空闲低电平数据在SCK上升沿采样。 // 对于从机需要设置在SCK的上升沿采样数据。 // USICS1:0 11 表示使用外部时钟在上升沿增加计数器与模式0对应。 USICR (1 USIWM1) | (0 USIWM0) | // SPI从机模式 (1 USICS1) | (1 USICS0) | // 外部时钟上升沿触发 (0 USICLK); // 无关 // 清除任何可能的中断标志 USISR (1 USIOIF) | (1 USIPF) | (1 USISIF); }数据接收与发送从机模式下数据传输由主机发起。从机需要检测何时一次传输完成8位数据收/发完毕。这通常通过查询USIOIF标志或使能溢出中断来实现。// 查询方式检查是否收到数据 uint8_t USI_SPI_SlaveReceive(void) { if (USISR (1 USIOIF)) { // 检查计数器溢出标志 // 传输完成 uint8_t receivedData USIDR; // 读取接收到的数据 // 准备下一次传输将待发送数据写入USIDR // USIDR dataToSend; // 清除溢出标志复位计数器准备下一次传输 USISR (1 USIOIF); return receivedData; } return 0xFF; // 未收到数据 } // 在中断服务程序中处理更高效 ISR (USI_OVF_vect) { uint8_t receivedData USIDR; // 处理接收到的数据... // 准备下一个要发送的数据 USIDR nextDataToSend; // 清除中断标志通过写入1 USISR (1 USIOIF); // 同时复位计数器 }从机模式下最大的挑战是响应速度。如果主机时钟很快从机必须在下一个字节开始前完成对上一个字节的处理并准备好下一个要发送的字节。使用中断是确保实时性的关键。同时要确保SS引脚如果使用的连接正确通常从机的SS由主机控制用于帧同步。4. 深入TWII2C通信从机实现的难点与技巧TWITwo-Wire Interface就是我们所熟悉的I2C。ATtiny85的USI实现TWI功能尤其是作为从机是相对复杂的因为它需要严格遵循I2C的时序协议包括起始条件、停止条件、地址匹配、应答ACK等。USI硬件提供了一些辅助如起始/停止条件检测标志但大部分协议层需要软件实现。4.1 TWI从机初始化与地址匹配首先我们需要将USI配置为TWI模式并使能起始条件中断以便在总线上检测到起始条件后能够及时响应。#define TWI_SLAVE_ADDRESS 0x50 // 假设我们的从机地址是0x50 (7位地址) void USI_TWI_SlaveInit(void) { // 配置引脚PB0 (SDA), PB2 (SCL) 设置为输入并启用内部上拉电阻 // I2C总线需要上拉电阻内部上拉通常足够用于低速通信100kHz DDRB ~((1 PB0) | (1 PB2)); PORTB | (1 PB0) | (1 PB2); // 使能内部上拉 // 配置USI控制寄存器 // USIWM1:0 01 (TWI模式) // USICS1:0 00 (使用软件时钟便于精确控制时序) // USICLK 0 // 使能起始条件中断USISIE和溢出中断USIOIE USICR (1 USIWM1) | (0 USIWM0) | // TWI模式 (0 USICS1) | (0 USICS0) | // 软件时钟 (0 USICLK) | (1 USISIE) | // 使能起始条件中断 (1 USIOIE); // 使能溢出中断 // 清除所有USI状态标志 USISR (1 USISIF) | (1 USIOIF) | (1 USIPF) | (1 USIDC); // 使能全局中断 sei(); }地址匹配逻辑当主设备发送起始条件后紧接着会发送一个8位的字节其中高7位是从机地址最低位是读写方向位0-写1-读。我们的从机需要在中断服务程序中检查这个地址是否与自身地址匹配。4.2 TWI从机中断服务程序ISR框架这是整个TWI从机实现的核心逻辑较为复杂。下面是一个简化的框架展示了如何处理起始条件、地址匹配、数据接收和发送。// 全局状态变量 volatile uint8_t twi_slaveStatus TWI_IDLE; volatile uint8_t twi_rxBuffer[32]; volatile uint8_t twi_rxIndex 0; volatile uint8_t twi_txBuffer[32]; volatile uint8_t twi_txIndex 0; volatile uint8_t twi_txLength 0; #define TWI_IDLE 0 #define TWI_ADDRESSED 1 // 已寻址等待R/W位 #define TWI_RX_MODE 2 // 主机要写数据到从机 #define TWI_TX_MODE 3 // 主机要从从机读数据 ISR (USI_START_vect) { // 起始条件检测中断 // 清除起始条件标志 USISR | (1 USISIF); // 复位USI准备接收地址字节 twi_slaveStatus TWI_IDLE; // 设置USI计数器准备接收8位数据地址 R/W // 设置USICNT[3:0]为0b1000 (8)当收到8个时钟后USIOIF会置位 // 但注意在TWI模式下计数器计的是SCL的边沿。起始条件后的第一个字节是8位数据1位ACK共9个时钟边沿。 // 更常见的做法是设置计数器为0x0E (14)这样在收到ACK位后溢出。这里简化处理。 USISR (0x0E USICNT0); // 设置计数器初始值使其在收到ACK位后溢出 } ISR (USI_OVF_vect) { // USI计数器溢出中断一个字节传输完成包括ACK uint8_t data USIDR; // 读取刚刚接收到的数据可能是地址或数据 if (twi_slaveStatus TWI_IDLE) { // 第一个字节是地址字节 uint8_t address data 1; // 提取7位地址 uint8_t rw_bit data 0x01; // 读写标志 if (address TWI_SLAVE_ADDRESS) { // 地址匹配成功 twi_slaveStatus TWI_ADDRESSED; // 准备发送ACK // 在SCL低电平期间将SDA线拉低 // 通过操作USIDR和USITC来模拟 // 首先确保SDA为输出模式拉低 DDRB | (1 PB0); // SDA设置为输出 PORTB ~(1 PB0); // 输出低电平ACK // 然后产生一个SCL脉冲高-低-高让主机看到ACK // 这需要精确的时序控制通常直接操作USITC位 // 这里是一个简化示例实际需要更严谨的时序 USICR | (1 USITC); // 产生一个SCL边沿高低取决于当前状态 // ... 需要更多代码来确保正确的ACK时序 // 根据R/W位设置下一步状态 if (rw_bit 0) { twi_slaveStatus TWI_RX_MODE; // 主机要写数据 // 准备接收下一个数据字节 USISR (0x0E USICNT0); // 重置计数器 } else { twi_slaveStatus TWI_TX_MODE; // 主机要读数据 // 准备发送第一个数据字节 // 将待发送数据加载到USIDR USIDR twi_txBuffer[0]; twi_txIndex 1; // 设置SDA为输出并输出数据的最高位通过USIDR // USI硬件会在SCL时钟下自动移位输出 // 需要设置计数器并在溢出中断中处理ACK和下一个字节 USISR (0x0E USICNT0); } } else { // 地址不匹配不响应保持SDA高电平即NACK // 释放SDA线输入由上拉电阻拉高 DDRB ~(1 PB0); // 可能需要等待停止条件或重复起始条件 twi_slaveStatus TWI_IDLE; } } else if (twi_slaveStatus TWI_RX_MODE) { // 接收数据模式 twi_rxBuffer[twi_rxIndex] data; // 发送ACK DDRB | (1 PB0); PORTB ~(1 PB0); // ... 产生SCL脉冲确认ACK // 重置计数器准备接收下一个字节 USISR (0x0E USICNT0); // 检查是否接收了足够的数据例如根据协议 if (twi_rxIndex sizeof(twi_rxBuffer)) { // 缓冲区满发送NACK // 处理逻辑... } } else if (twi_slaveStatus TWI_TX_MODE) { // 发送数据模式 // 上一个字节已发送这里处理主机的ACK/NACK // 读取SDA线状态在SCL高时来判断是ACK(0)还是NACK(1) // 由于ACK位是在第9个时钟周期我们需要在溢出中断中检查之前的状态。 // 这需要更精细的状态机通常结合USISR的标志位。 // 简化处理假设主机发送了ACK // 准备下一个要发送的字节 if (twi_txIndex twi_txLength) { USIDR twi_txBuffer[twi_txIndex]; USISR (0x0E USICNT0); // 重置计数器发送下一个字节 } else { // 没有更多数据要发送后续主机可能会发送NACK或停止条件 // 可以发送一个默认值如0xFF或保持SDA高 USIDR 0xFF; // 状态可能需要改变 } } // 清除溢出中断标志通过写入1到USIOIF位 // 注意写入USISR会同时设置计数器初始值。我们已经在上面设置过了。 // 所以通常这样清除标志USISR | (1 USIOIF); }重要提示上面的ISR代码是一个高度简化的概念框架不能直接复制使用。它省略了最关键的时序控制细节比如在SCL低电平时改变SDA数据在SCL高电平时读取数据以及精确产生ACK/NACK信号。完整的、可工作的TWI从机代码需要仔细研读AVR数据手册中关于USI TWI的时序图并可能涉及直接操作PORTB和PINB寄存器来读取SDA线状态。网络上一些成熟的库如TinyWireS已经妥善处理了这些细节在实际项目中我强烈建议先使用这些经过验证的库理解其源码后再进行定制。4.3 TWI主机模式简述ATtiny85作为I2C主机相对从机简单因为发起通信的时序完全由自己控制。实现主机功能通常采用“Bit-banging”方式即完全用软件控制SDA和SCL两个GPIO引脚模拟起始、停止、发送数据位、接收应答等时序。虽然USI可以用于主机模式但其在TWI主机方面的辅助有限不如直接用软件模拟直观和灵活。因此很多ATtiny85的I2C主机驱动库如TinyWireM都选择使用纯软件实现。5. 调试经验、常见问题与进阶应用在实际项目中调试USI通信特别是TWI可能会遇到各种奇怪的问题。以下是我总结的一些常见坑点和调试技巧时钟速度与上拉电阻I2C总线的速度受限于上拉电阻的阻值和总线电容。ATtiny85的内部上拉电阻约20-50kΩ通常只适用于100kHz以下的低速通信。如果通信不稳定如ACK丢失、数据错误首先尝试降低时钟速度在主机模式下其次考虑在SDA和SCL线上增加外部上拉电阻例如4.7kΩ到10kΩ。中断竞争与状态机在TWI从机中断服务程序中状态机的设计必须严谨。确保在任何情况下twi_slaveStatus变量都能被正确设置和清除。避免在中断服务程序中执行耗时操作如长循环、复杂计算这可能导致错过下一个时钟边沿。如果需要处理大量数据可以在中断中只进行数据搬运设置标志位在主循环中处理业务逻辑。USI计数器初始值的玄机USISR寄存器的低4位USICNT[3:0]的初始值设置非常关键。它决定了在多少个时钟边沿后触发溢出中断。对于I2C一个完整的字节传输8位数据 1位ACK/NACK需要18个时钟边沿实际上从起始条件后的第一个时钟边沿开始计数。通常设置为0x0E14或0x00具体需要根据数据手册的示例和你的中断处理逻辑来调整。设置不当会导致中断触发时机错误数据错位。同时使用SPI和TWIATtiny85只有一个USI模块不能同时工作在两种模式。如果你的应用需要与SPI设备和I2C设备通信必须在运行时动态切换USI的模式。切换时务必先禁用USIUSICR 0重新配置引脚方向特别是输入输出模式然后重新初始化USICR和USISR。切换过程要确保不会在总线上产生毛刺或冲突。使用逻辑分析仪这是调试串行通信无可替代的工具。一个几十块钱的USB逻辑分析仪配合PulseView或Saleae软件可以清晰地抓取SCK、MOSI、MISO、SDA、SCL等信号波形。你可以直观地看到起始/停止条件、数据位、ACK位从而快速定位是时序问题、数据问题还是应答问题。进阶应用模拟UART除了SPI和TWIUSI还可以通过软件模拟半双工UART串口。原理是利用USI的移位功能在固定的时间间隔根据波特率计算内通过操作USITC或USICLK位来产生发送数据的时钟或者检测接收数据的起始位和采样数据位。这对于需要串口调试而又没有硬件UART的ATtiny85来说是一个很有用的技巧但它会占用大量CPU时间进行位操作通常只适用于低波特率如9600且对实时性要求不高的场景。最后拥抱社区资源。像TinyWireSI2C从机、TinyWireMI2C主机、SoftSerial软件串口这些为ATtiny85编写的库都是经过大量项目验证的。从使用这些库开始在理解其工作原理的基础上进行修改和优化是快速上手并规避底层陷阱的最佳路径。毕竟我们的目标是让项目跑起来而不是重新发明轮子。当你真正需要极致优化或应对特殊场景时再深入到底层寄存器操作你会更有方向感。
ATtiny85 USI模块深度解析:SPI与I2C通信实战指南
1. 从“小身材”到“大能耐”为什么ATtiny85的USI值得深挖在嵌入式开发的广阔世界里我们常常被那些功能强大的32位MCU所吸引它们外设丰富、性能强劲仿佛无所不能。然而在很多场景下比如一个简单的传感器数据采集节点、一个智能纽扣、一个低成本的小玩具或者仅仅是给一个现有系统增加一个简单的逻辑控制功能使用这些“大家伙”就显得杀鸡用牛刀了。这时像ATtiny85这样的8位AVR微控制器就闪亮登场了。它只有8个引脚价格低廉功耗极低但麻雀虽小五脏俱全。而它内部最核心、也最容易被开发者低估的通信外设就是通用串行接口——USI。USI全称Universal Serial Interface是ATtiny85这类小尺寸AVR芯片的“通信多面手”。它不像STM32的SPI、I2C那样是独立且功能固定的硬件模块USI更像是一个高度可配置的通信“内核”。通过软件配置它可以模拟出SPI三线或四线、I2CTWI以及半双工UART等多种通信协议。这种灵活性使得ATtiny85在有限的硬件资源下依然能够与丰富的传感器、存储器、显示模块等外设对话极大地扩展了其应用边界。我最初接触ATtiny85的USI时也走过一些弯路。官方数据手册对USI的描述比较底层直接操作寄存器时时序和状态机的控制需要格外小心。网上很多示例代码要么过于简单只演示了主模式SPI要么在TWII2C从机实现上存在中断响应不及时、数据丢失的问题。特别是当项目需要ATtiny85同时作为SPI从机接收命令又作为I2C主机去读取传感器时如何安全、高效地切换USI的工作模式就成了一个必须啃下来的硬骨头。这篇文章我就结合自己多次踩坑和实战的经验为你彻底拆解ATtiny85 USI模块实现SPI和TWI通信的原理并提供稳定、可直接复用的代码实现。无论你是想用ATtiny85驱动一个OLED屏幕SPI还是读取一个温湿度传感器I2C亦或是设计一个双向通信的智能模块这里的内容都将为你提供清晰的路径。2. USI模块的硬件架构与核心寄存器剖析要驾驭USI必须先理解它的硬件设计思想。你可以把USI想象成一个精简而高效的“串行数据加工车间”。这个车间核心的“流水线”是一个8位的USI数据寄存器USIDR。所有要发送或接收的数据都暂存在这里。数据是如何一位一位搬进搬出这个寄存器的呢这就依赖于一个独立的USI数据位移寄存器它受时钟驱动负责完成实际的位移动作。控制这个车间的“总控台”是以下几个关键寄存器USI控制寄存器USICR这是最重要的配置寄存器。它决定了车间的工作模式。USIWM[1:0]Wire Mode这两位是模式选择开关。00代表禁用USI或用于三线模式01选择I2CTWI模式10选择SPI从机模式11选择SPI主机模式。你的代码里绝大部分关于USI的初始化都是从正确设置这两位开始的。USICS[1:0]Clock Source Select这两位选择驱动位移寄存器的时钟源。在SPI主机模式下你可以选择内部时钟并设置分频在SPI从机或I2C模式下通常选择外部时钟即来自SCK或SCL引脚的变化。USICLK这是一个软件触发的时钟脉冲位。写入1会产生一个时钟脉冲推动数据位移一位。在软件模拟某些时序或测试时非常有用。USITC这是用来翻转时钟线SCK/SCL状态的位。在I2C模式下生成起始、停止条件或者在某些特殊SPI时序中都需要操作它。USI状态寄存器USISR这个寄存器像车间的“状态指示灯”和“计数器”。低4位USICNT[3:0]这是一个4位的计数器。它记录了自上次清零以来已经发生了多少次时钟事件即位移了多少位。当它计满达到特定值如8或溢出时可以触发中断。这是我们判断一次8位数据传输是否完成的核心依据。USIDCData Output Collision、USIPFStop Condition Flag、USIOIFCounter Overflow Interrupt Flag、USISIFStart Condition Interrupt Flag这些是各种事件标志位。例如在I2C模式下USISIF会在检测到起始条件时置位USIPF会在检测到停止条件时置位。USI数据寄存器USIDR前面提到的8位数据暂存区。写入USIDR的数据会在下一个时钟周期被加载到位移寄存器准备发送接收到的数据在传输完成后可以从USIDR中读取。理解这些寄存器如何协同工作是关键。例如在SPI主机模式下你设置USIWM11USICS选择内部时钟分频。当你向USIDR写入数据后USI硬件会自动在设定的时钟频率下从USIDO数据输出引脚将数据一位位移出同时从USIDI数据输入引脚将数据一位位移入USIDR。计数器USICNT随着每个时钟周期递增计满8后USIOIF标志置位表示一次传输结束。而在I2C模式下情况更复杂一些。设置USIWM01后USI模块会开始监视SCL和SDA引脚。检测到起始条件SDA在SCL高时变低会置位USISIF检测到停止条件SDA在SCL高时变高会置位USIPF。数据的收发则需要在SCL为低时由软件或通过时钟拉伸来读取或设置SDA的状态并利用USITC位产生SCL脉冲。USICNT计数器则用来追踪当前是第几位数据或地址。注意ATtiny85的USI在I2C模式下其硬件对SDA和SCL线的控制是有限的特别是输出驱动能力。它采用了一种“开漏输出加输入采样”的机制。这意味着在软件控制下我们只能将SDA线拉低输出0或释放输出1实际上是通过外部上拉电阻变为高电平。读取SDA状态时是读取其输入引脚的电平。这一点与具有真正硬件I2C模块的MCU不同编程时需要时刻牢记。3. 实战SPI通信主机与从机的代码实现SPI是USI最常实现的功能之一因其协议简单、速率高。ATtiny85的USI可以配置为SPI主机或从机。需要注意的是USI实现的SPI是“3线”或“4线”模式但不直接支持复杂的多主模式或NSS从机选择信号的自动管理。NSS信号通常需要一个额外的GPIO来手动控制。3.1 SPI主机模式实现作为主机ATtiny85负责产生时钟SCK并控制数据传输。假设我们使用经典的4线SPIMOSI, MISO, SCK, SS连接一个SPI Flash芯片如W25Q16。第一步硬件连接与初始化首先需要根据数据手册确定USI引脚与ATtiny85物理引脚的映射关系。对于ATtiny85PB2(Pin 7): 通常用作USCK(SPI SCK)PB1(Pin 6): 通常用作DO/MOSI(Master Out Slave In)PB0(Pin 5): 通常用作DI/MISO(Master In Slave Out)PB3(Pin 2): 我们可以将其配置为普通的GPIO用作从机选择SS。初始化代码的核心是配置USICR寄存器并设置好对应的引脚方向。#include avr/io.h #include util/delay.h #define SPI_SS_PIN PB3 #define SPI_SS_PORT PORTB #define SPI_SS_DDR DDRB void USI_SPI_MasterInit(void) { // 1. 配置SPI引脚方向 // PB1 (MOSI) 和 PB2 (SCK) 设置为输出 DDRB | (1 PB1) | (1 PB2); // PB0 (MISO) 设置为输入 DDRB ~(1 PB0); // 可选使能内部上拉防止MISO浮空 PORTB | (1 PB0); // SS引脚作为普通GPIO输出并初始化为高电平不选中从机 SPI_SS_DDR | (1 SPI_SS_PIN); SPI_SS_PORT | (1 SPI_SS_PIN); // 2. 配置USI控制寄存器USICR // USIWM1:0 11 (SPI主机模式) // USICS1:0 00 (使用软件时钟 strobe配合USICLK位) 或 // 01 (使用定时器0比较匹配作为时钟) 或 // 10 (使用外部正边沿) 这里我们选择外部正边沿但作为主机我们实际使用内部时钟。 // 更常见的做法是使用USICS00然后在传输函数中手动控制USICLK位来产生时钟软件SPI。 // 若要使用硬件时钟需设置USICS10并配置时钟分频。 // 本例展示硬件时钟方式 USICR (1 USIWM1) | (1 USIWM0) | // SPI主机模式 (1 USICS1) | (0 USICS0) | // 时钟源软件时钟下降沿 / 外部正边沿 (取决于USICLK) (0 USICLK); // 不产生时钟脉冲 // 设置USI时钟预分频器通过USISR的低4位和USICR的USICS1:0组合 // 例如设置时钟为系统时钟的16分频 USISR (1 USIOIF); // 先清除溢出标志 // 注意USI的时钟分频设置较为隐蔽通常通过设置USISR的USICNT[3:0]初始值来实现。 // 更简单可靠的方式是使用软件时钟Bit-banging或定时器。硬件分频器功能有限。 }实际上由于USI作为SPI主机时其内置的时钟分频器选项不多且不易用很多开发者更喜欢在主机模式下使用“软件时钟”方式即设置USICS00然后在数据传输函数中通过循环和操作USICLK位来产生精确的时钟脉冲。这样可以获得更灵活的时钟速度控制。第二步实现数据传输函数下面是一个使用软件控制时钟的SPI主机发送/接收函数uint8_t USI_SPI_MasterTransfer(uint8_t data) { // 1. 将待发送数据加载到USIDR USIDR data; // 2. 清除计数器溢出标志并设置计数器为0准备计数16个时钟边沿这里需要小心 // 在SPI模式下一个时钟周期包含两个边沿。传输8位数据需要16个时钟边沿。 // USICNT是一个4位计数器每计数满会产生溢出。我们可以设置其初始值让它计满16次后溢出。 // 设置USISR 0xF0; 即USICNT[3:0] 0 且USIOIF0 USISIF0 USIPF0... // 更常见的做法是先清除溢出标志然后等待溢出。 USISR (1 USIOIF); // 写入1清除溢出中断标志同时USICNT被清零 // 3. 循环直到8位数据发送/接收完成USIOIF标志置位 while ( !(USISR (1 USIOIF)) ) { // 产生一个时钟脉冲通过设置USICLK位在USICS00时该位写入1会产生一个时钟边沿 // 但注意根据数据手册在USICS00时操作USICLK位会触发时钟。 // 然而更直接的方式是使用USITC位来翻转时钟线。 // 这里演示使用USITC的方法更通用 USICR | (1 USITC); // 切换USCK/SCK引脚的电平产生一个边沿 _delay_us(1); // 简单的延时控制SPI速度。实际应用中可用__builtin_avr_delay_cycles实现精确延时。 USICR | (1 USITC); // 再次切换产生另一个边沿完成一个时钟周期 _delay_us(1); // 注意上述方法会产生完整的时钟方波。但USI硬件在外部时钟模式下会自动计数。 // 对于软件模拟我们也可以直接控制PORTB的SCK引脚但使用USITC是更“硬件”的方式。 } // 4. 传输完成从USIDR读取接收到的数据 return USIDR; }这个函数是一个基础的框架。在实际项目中你需要根据外设的时序要求精确调整_delay_us()的延时或者使用定时器来产生更精确的时钟。同时别忘了在传输前后控制SS引脚拉低选中拉高释放。3.2 SPI从机模式实现ATtiny85作为SPI从机时时钟SCK由外部主机提供。USI硬件会自动检测时钟边沿并移位数据。从机的实现相对主机更简单因为它不需要产生时钟。初始化代码void USI_SPI_SlaveInit(void) { // 配置引脚方向 // PB0 (DI/MOSI) 输入用于接收主机数据 // PB1 (DO/MISO) 输出用于向主机发送数据 // PB2 (USCK/SCK) 输入用于接收主机时钟 DDRB ~((1 PB0) | (1 PB2)); // DI, SCK 输入 DDRB | (1 PB1); // DO 输出 // 可选使能内部上拉防止输入浮空 PORTB | (1 PB0) | (1 PB2); // 配置USI为SPI从机模式 // USIWM1:0 10 (SPI从机模式) // USICS1:0 1x (使用外部时钟具体边沿取决于USICLK和USICS0) // 通常设置为在SCK的上升沿采样数据模式0这需要根据主机模式调整。 // 假设主机是SPI模式0 (CPOL0, CPHA0): 时钟空闲低电平数据在SCK上升沿采样。 // 对于从机需要设置在SCK的上升沿采样数据。 // USICS1:0 11 表示使用外部时钟在上升沿增加计数器与模式0对应。 USICR (1 USIWM1) | (0 USIWM0) | // SPI从机模式 (1 USICS1) | (1 USICS0) | // 外部时钟上升沿触发 (0 USICLK); // 无关 // 清除任何可能的中断标志 USISR (1 USIOIF) | (1 USIPF) | (1 USISIF); }数据接收与发送从机模式下数据传输由主机发起。从机需要检测何时一次传输完成8位数据收/发完毕。这通常通过查询USIOIF标志或使能溢出中断来实现。// 查询方式检查是否收到数据 uint8_t USI_SPI_SlaveReceive(void) { if (USISR (1 USIOIF)) { // 检查计数器溢出标志 // 传输完成 uint8_t receivedData USIDR; // 读取接收到的数据 // 准备下一次传输将待发送数据写入USIDR // USIDR dataToSend; // 清除溢出标志复位计数器准备下一次传输 USISR (1 USIOIF); return receivedData; } return 0xFF; // 未收到数据 } // 在中断服务程序中处理更高效 ISR (USI_OVF_vect) { uint8_t receivedData USIDR; // 处理接收到的数据... // 准备下一个要发送的数据 USIDR nextDataToSend; // 清除中断标志通过写入1 USISR (1 USIOIF); // 同时复位计数器 }从机模式下最大的挑战是响应速度。如果主机时钟很快从机必须在下一个字节开始前完成对上一个字节的处理并准备好下一个要发送的字节。使用中断是确保实时性的关键。同时要确保SS引脚如果使用的连接正确通常从机的SS由主机控制用于帧同步。4. 深入TWII2C通信从机实现的难点与技巧TWITwo-Wire Interface就是我们所熟悉的I2C。ATtiny85的USI实现TWI功能尤其是作为从机是相对复杂的因为它需要严格遵循I2C的时序协议包括起始条件、停止条件、地址匹配、应答ACK等。USI硬件提供了一些辅助如起始/停止条件检测标志但大部分协议层需要软件实现。4.1 TWI从机初始化与地址匹配首先我们需要将USI配置为TWI模式并使能起始条件中断以便在总线上检测到起始条件后能够及时响应。#define TWI_SLAVE_ADDRESS 0x50 // 假设我们的从机地址是0x50 (7位地址) void USI_TWI_SlaveInit(void) { // 配置引脚PB0 (SDA), PB2 (SCL) 设置为输入并启用内部上拉电阻 // I2C总线需要上拉电阻内部上拉通常足够用于低速通信100kHz DDRB ~((1 PB0) | (1 PB2)); PORTB | (1 PB0) | (1 PB2); // 使能内部上拉 // 配置USI控制寄存器 // USIWM1:0 01 (TWI模式) // USICS1:0 00 (使用软件时钟便于精确控制时序) // USICLK 0 // 使能起始条件中断USISIE和溢出中断USIOIE USICR (1 USIWM1) | (0 USIWM0) | // TWI模式 (0 USICS1) | (0 USICS0) | // 软件时钟 (0 USICLK) | (1 USISIE) | // 使能起始条件中断 (1 USIOIE); // 使能溢出中断 // 清除所有USI状态标志 USISR (1 USISIF) | (1 USIOIF) | (1 USIPF) | (1 USIDC); // 使能全局中断 sei(); }地址匹配逻辑当主设备发送起始条件后紧接着会发送一个8位的字节其中高7位是从机地址最低位是读写方向位0-写1-读。我们的从机需要在中断服务程序中检查这个地址是否与自身地址匹配。4.2 TWI从机中断服务程序ISR框架这是整个TWI从机实现的核心逻辑较为复杂。下面是一个简化的框架展示了如何处理起始条件、地址匹配、数据接收和发送。// 全局状态变量 volatile uint8_t twi_slaveStatus TWI_IDLE; volatile uint8_t twi_rxBuffer[32]; volatile uint8_t twi_rxIndex 0; volatile uint8_t twi_txBuffer[32]; volatile uint8_t twi_txIndex 0; volatile uint8_t twi_txLength 0; #define TWI_IDLE 0 #define TWI_ADDRESSED 1 // 已寻址等待R/W位 #define TWI_RX_MODE 2 // 主机要写数据到从机 #define TWI_TX_MODE 3 // 主机要从从机读数据 ISR (USI_START_vect) { // 起始条件检测中断 // 清除起始条件标志 USISR | (1 USISIF); // 复位USI准备接收地址字节 twi_slaveStatus TWI_IDLE; // 设置USI计数器准备接收8位数据地址 R/W // 设置USICNT[3:0]为0b1000 (8)当收到8个时钟后USIOIF会置位 // 但注意在TWI模式下计数器计的是SCL的边沿。起始条件后的第一个字节是8位数据1位ACK共9个时钟边沿。 // 更常见的做法是设置计数器为0x0E (14)这样在收到ACK位后溢出。这里简化处理。 USISR (0x0E USICNT0); // 设置计数器初始值使其在收到ACK位后溢出 } ISR (USI_OVF_vect) { // USI计数器溢出中断一个字节传输完成包括ACK uint8_t data USIDR; // 读取刚刚接收到的数据可能是地址或数据 if (twi_slaveStatus TWI_IDLE) { // 第一个字节是地址字节 uint8_t address data 1; // 提取7位地址 uint8_t rw_bit data 0x01; // 读写标志 if (address TWI_SLAVE_ADDRESS) { // 地址匹配成功 twi_slaveStatus TWI_ADDRESSED; // 准备发送ACK // 在SCL低电平期间将SDA线拉低 // 通过操作USIDR和USITC来模拟 // 首先确保SDA为输出模式拉低 DDRB | (1 PB0); // SDA设置为输出 PORTB ~(1 PB0); // 输出低电平ACK // 然后产生一个SCL脉冲高-低-高让主机看到ACK // 这需要精确的时序控制通常直接操作USITC位 // 这里是一个简化示例实际需要更严谨的时序 USICR | (1 USITC); // 产生一个SCL边沿高低取决于当前状态 // ... 需要更多代码来确保正确的ACK时序 // 根据R/W位设置下一步状态 if (rw_bit 0) { twi_slaveStatus TWI_RX_MODE; // 主机要写数据 // 准备接收下一个数据字节 USISR (0x0E USICNT0); // 重置计数器 } else { twi_slaveStatus TWI_TX_MODE; // 主机要读数据 // 准备发送第一个数据字节 // 将待发送数据加载到USIDR USIDR twi_txBuffer[0]; twi_txIndex 1; // 设置SDA为输出并输出数据的最高位通过USIDR // USI硬件会在SCL时钟下自动移位输出 // 需要设置计数器并在溢出中断中处理ACK和下一个字节 USISR (0x0E USICNT0); } } else { // 地址不匹配不响应保持SDA高电平即NACK // 释放SDA线输入由上拉电阻拉高 DDRB ~(1 PB0); // 可能需要等待停止条件或重复起始条件 twi_slaveStatus TWI_IDLE; } } else if (twi_slaveStatus TWI_RX_MODE) { // 接收数据模式 twi_rxBuffer[twi_rxIndex] data; // 发送ACK DDRB | (1 PB0); PORTB ~(1 PB0); // ... 产生SCL脉冲确认ACK // 重置计数器准备接收下一个字节 USISR (0x0E USICNT0); // 检查是否接收了足够的数据例如根据协议 if (twi_rxIndex sizeof(twi_rxBuffer)) { // 缓冲区满发送NACK // 处理逻辑... } } else if (twi_slaveStatus TWI_TX_MODE) { // 发送数据模式 // 上一个字节已发送这里处理主机的ACK/NACK // 读取SDA线状态在SCL高时来判断是ACK(0)还是NACK(1) // 由于ACK位是在第9个时钟周期我们需要在溢出中断中检查之前的状态。 // 这需要更精细的状态机通常结合USISR的标志位。 // 简化处理假设主机发送了ACK // 准备下一个要发送的字节 if (twi_txIndex twi_txLength) { USIDR twi_txBuffer[twi_txIndex]; USISR (0x0E USICNT0); // 重置计数器发送下一个字节 } else { // 没有更多数据要发送后续主机可能会发送NACK或停止条件 // 可以发送一个默认值如0xFF或保持SDA高 USIDR 0xFF; // 状态可能需要改变 } } // 清除溢出中断标志通过写入1到USIOIF位 // 注意写入USISR会同时设置计数器初始值。我们已经在上面设置过了。 // 所以通常这样清除标志USISR | (1 USIOIF); }重要提示上面的ISR代码是一个高度简化的概念框架不能直接复制使用。它省略了最关键的时序控制细节比如在SCL低电平时改变SDA数据在SCL高电平时读取数据以及精确产生ACK/NACK信号。完整的、可工作的TWI从机代码需要仔细研读AVR数据手册中关于USI TWI的时序图并可能涉及直接操作PORTB和PINB寄存器来读取SDA线状态。网络上一些成熟的库如TinyWireS已经妥善处理了这些细节在实际项目中我强烈建议先使用这些经过验证的库理解其源码后再进行定制。4.3 TWI主机模式简述ATtiny85作为I2C主机相对从机简单因为发起通信的时序完全由自己控制。实现主机功能通常采用“Bit-banging”方式即完全用软件控制SDA和SCL两个GPIO引脚模拟起始、停止、发送数据位、接收应答等时序。虽然USI可以用于主机模式但其在TWI主机方面的辅助有限不如直接用软件模拟直观和灵活。因此很多ATtiny85的I2C主机驱动库如TinyWireM都选择使用纯软件实现。5. 调试经验、常见问题与进阶应用在实际项目中调试USI通信特别是TWI可能会遇到各种奇怪的问题。以下是我总结的一些常见坑点和调试技巧时钟速度与上拉电阻I2C总线的速度受限于上拉电阻的阻值和总线电容。ATtiny85的内部上拉电阻约20-50kΩ通常只适用于100kHz以下的低速通信。如果通信不稳定如ACK丢失、数据错误首先尝试降低时钟速度在主机模式下其次考虑在SDA和SCL线上增加外部上拉电阻例如4.7kΩ到10kΩ。中断竞争与状态机在TWI从机中断服务程序中状态机的设计必须严谨。确保在任何情况下twi_slaveStatus变量都能被正确设置和清除。避免在中断服务程序中执行耗时操作如长循环、复杂计算这可能导致错过下一个时钟边沿。如果需要处理大量数据可以在中断中只进行数据搬运设置标志位在主循环中处理业务逻辑。USI计数器初始值的玄机USISR寄存器的低4位USICNT[3:0]的初始值设置非常关键。它决定了在多少个时钟边沿后触发溢出中断。对于I2C一个完整的字节传输8位数据 1位ACK/NACK需要18个时钟边沿实际上从起始条件后的第一个时钟边沿开始计数。通常设置为0x0E14或0x00具体需要根据数据手册的示例和你的中断处理逻辑来调整。设置不当会导致中断触发时机错误数据错位。同时使用SPI和TWIATtiny85只有一个USI模块不能同时工作在两种模式。如果你的应用需要与SPI设备和I2C设备通信必须在运行时动态切换USI的模式。切换时务必先禁用USIUSICR 0重新配置引脚方向特别是输入输出模式然后重新初始化USICR和USISR。切换过程要确保不会在总线上产生毛刺或冲突。使用逻辑分析仪这是调试串行通信无可替代的工具。一个几十块钱的USB逻辑分析仪配合PulseView或Saleae软件可以清晰地抓取SCK、MOSI、MISO、SDA、SCL等信号波形。你可以直观地看到起始/停止条件、数据位、ACK位从而快速定位是时序问题、数据问题还是应答问题。进阶应用模拟UART除了SPI和TWIUSI还可以通过软件模拟半双工UART串口。原理是利用USI的移位功能在固定的时间间隔根据波特率计算内通过操作USITC或USICLK位来产生发送数据的时钟或者检测接收数据的起始位和采样数据位。这对于需要串口调试而又没有硬件UART的ATtiny85来说是一个很有用的技巧但它会占用大量CPU时间进行位操作通常只适用于低波特率如9600且对实时性要求不高的场景。最后拥抱社区资源。像TinyWireSI2C从机、TinyWireMI2C主机、SoftSerial软件串口这些为ATtiny85编写的库都是经过大量项目验证的。从使用这些库开始在理解其工作原理的基础上进行修改和优化是快速上手并规避底层陷阱的最佳路径。毕竟我们的目标是让项目跑起来而不是重新发明轮子。当你真正需要极致优化或应对特殊场景时再深入到底层寄存器操作你会更有方向感。