2、硬件接口与通信协议:I2C、SPI、UART、1-Wire协议原理与对比、硬件连接实战

各位同学,咱们今天聊点硬核的。传感器采集数据,说白了就是让MCU和传感器“说话”。怎么说话?靠的就是硬件接口和通信协议。

我这些年做项目,从温湿度传感器到九轴惯性测量单元,从几块钱的国产芯片到进口的高精度模数转换器,几乎每天都在跟这几种协议打交道。你想想看,如果连它们都搞不清楚,那传感器数据采集就是一句空话。

今天咱们就把I2C、SPI、UART、1-Wire这四种最常用的协议,掰开了揉碎了讲清楚。我会结合我踩过的坑,告诉你什么时候该用谁,怎么连线,怎么调通。

2.1 为什么需要这么多协议?

你可能会有疑问:为什么不能统一用一种协议?嗯,这个问题我当年也问过师傅。

答案其实很简单:没有银弹。不同的传感器对速度、功耗、引脚数量、传输距离的要求完全不同。比如一个数字温度传感器,每秒只传一次数据,你用高速SPI就是杀鸡用牛刀。反过来,一个高帧率的摄像头,你用I2C去传图像数据,那得等到猴年马月。

所以,搞嵌入式的人,必须把这四种协议都玩熟。它们各有各的脾气,各有各的绝活。

2.2 I2C协议:两线制,多设备,慢工出细活

I2C(Inter-Integrated Circuit),我习惯叫它“I方C”。这是飞利浦公司(现在的恩智浦)搞出来的。它最大的特点就是只用两根线:SCL(时钟线)和SDA(数据线)。

我在项目中遇到过最典型的场景:一个主控板挂载了七八个传感器,包括温度、湿度、气压、光照、距离等等。如果每个传感器都单独占用一个引脚,那MCU的引脚根本不够用。这时候I2C就派上大用场了——所有传感器都挂在同一条总线上,靠地址来区分。

2.2.1 工作原理

I2C是同步、半双工的通信方式。主设备(通常是MCU)产生时钟信号,从设备(传感器)响应。每次通信开始,主设备会发送一个起始条件,然后发送7位或10位的从设备地址,最后一位是读写位。

我建议你记住这个流程:起始 → 地址+读写位 → 应答 → 数据 → 应答 → ... → 停止。这个流程在逻辑分析仪上看得清清楚楚。

关键参数:

  • 标准模式:100 kHz
  • 快速模式:400 kHz
  • 高速模式:3.4 MHz
  • 最多可挂载:理论127个设备(7位地址)

2.2.2 硬件连接

连接方式非常简单:所有设备的SCL连在一起,SDA连在一起,然后分别通过上拉电阻接到VCC(通常是3.3V或5V)。上拉电阻的阻值很关键,我一般选4.7kΩ。如果总线太长或者设备太多,可以换成2.2kΩ。

// I2C读取传感器数据的伪代码示例
// 以读取温度传感器为例
void read_temperature() {
    i2c_start();                    // 发送起始条件
    i2c_write(0x90);               // 设备地址+写位 (0x48 << 1 | 0)
    i2c_wait_ack();                // 等待应答
    i2c_write(0x00);               // 发送寄存器地址
    i2c_wait_ack();
    i2c_stop();                    // 发送停止条件
    
    i2c_start();                    // 重新起始
    i2c_write(0x91);               // 设备地址+读位
    i2c_wait_ack();
    uint8_t msb = i2c_read();      // 读取高字节
    i2c_send_ack();                // 发送应答
    uint8_t lsb = i2c_read();      // 读取低字节
    i2c_send_nack();               // 发送非应答
    i2c_stop();
    
    temperature = (msb << 8) | lsb;
}

我的小技巧:调试I2C时,如果发现设备不响应,先检查上拉电阻焊了没有。我曾经有一次焊了板子发现SDA一直低电平,查了半天发现是上拉电阻虚焊了。另外,地址冲突也是常见问题,记得用示波器或者逻辑分析仪看看总线上有没有正确的地址帧。

2.3 SPI协议:四线制,高速,全双工

SPI(Serial Peripheral Interface),摩托罗拉发明的。如果说I2C是公交车,那SPI就是专车——速度快、效率高、一对一通信

SPI用了四根线:

  • SCLK:时钟线,由主设备产生
  • MOSI:主设备输出,从设备输入
  • MISO:主设备输入,从设备输出
  • SS/CS:片选线,低电平有效

你想想看,SPI是全双工的,也就是说发送和接收可以同时进行。这一点比I2C强太多了。我做过一个高速数据采集项目,ADC的采样率是1 Msps,用I2C根本扛不住,换成SPI就轻松搞定了。

2.3.1 四种模式

SPI有四种工作模式,由时钟极性(CPOL)和时钟相位(CPHA)决定。这个坑我踩过好几次——传感器手册上写着“Mode 3”,我默认用了Mode 0,结果读出来的数据全是乱的。

模式 CPOL CPHA 说明
Mode 0 0 0 空闲时时钟低电平,第一个边沿采样
Mode 1 0 1 空闲时时钟低电平,第二个边沿采样
Mode 2 1 0 空闲时时钟高电平,第一个边沿采样
Mode 3 1 1 空闲时时钟高电平,第二个边沿采样

注意:SPI的片选信号一定要处理好。我曾经遇到过一个问题:多个SPI设备共用总线,其中一个设备的片选信号没拉高,导致总线冲突,所有设备都通信异常。记住,同一时刻只能有一个片选有效

2.3.2 硬件连接实战

SPI的连接比I2C稍微复杂一点,因为每个从设备都需要独立的片选线。如果MCU的GPIO不够用,可以用译码器或者GPIO扩展芯片。

// SPI读取加速度计数据的示例
void spi_read_accel() {
    // 假设使用STM32的HAL库
    uint8_t tx_data[2];
    uint8_t rx_data[2];
    
    // 读取X轴加速度,寄存器地址0x28
    tx_data[0] = 0x28 | 0x80;  // 最高位置1表示读操作
    tx_data[1] = 0x00;         // 随便发,为了接收
    
    HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET);  // 拉低片选
    HAL_SPI_TransmitReceive(&hspi1, tx_data, rx_data, 2, 100);
    HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET);    // 拉高片选
    
    int16_t accel_x = (rx_data[0] << 8) | rx_data[1];
}

2.4 UART协议:异步,点对点,简单可靠

UART(Universal Asynchronous Receiver/Transmitter),也就是我们常说的串口。这是最古老、最通用的协议之一。我敢说,每个嵌入式工程师入门都是从点亮LED和串口打印“Hello World”开始的。

UART是异步、全双工的通信方式。它只需要两根线:TX(发送)和RX(接收)。没有时钟线,所以通信双方必须约定好相同的波特率。

2.4.1 数据帧格式

一个典型的UART数据帧包含:起始位(1位)、数据位(5~9位,常用8位)、校验位(可选)、停止位(1~2位)。

我建议你记住这个配置:9600-8-N-1。意思是波特率9600,8位数据位,无校验,1位停止位。这是最常用的配置,很多传感器和模块出厂默认就是这个。

常见波特率: 9600、19200、38400、57600、115200、230400、460800、921600

注意:波特率越高,传输距离越短,抗干扰能力越差。我在工业现场用过115200,线长超过2米就开始丢数据了。

2.4.2 硬件连接注意事项

UART连接时,一定要交叉连接:设备的TX接主控的RX,设备的RX接主控的TX。这个低级错误我见过太多新手犯了——把TX接TX,RX接RX,结果怎么调都调不通。

另外,电平转换也很重要。很多传感器是3.3V电平,但有些老模块是5V的。如果直接连接,可能会烧坏IO口。我一般用电平转换芯片,或者用电阻分压。

// UART接收传感器数据的示例
void uart_receive_data() {
    uint8_t buffer[64];
    uint8_t index = 0;
    
    while (1) {
        // 等待接收一个字节
        while (!(UCSR0A & (1 << RXC0)));
        uint8_t data = UDR0;
        
        // 假设传感器数据以0xAA开头,0x55结尾
        if (data == 0xAA) {
            index = 0;
        } else if (data == 0x55) {
            // 一帧数据接收完毕,处理
            process_sensor_data(buffer, index);
            break;
        } else {
            if (index < 64) {
                buffer[index++] = data;
            }
        }
    }
}

2.5 1-Wire协议:一根线,省引脚,但慢

1-Wire协议,达拉斯半导体(现属美信)的杰作。顾名思义,只用一根数据线,既传数据又传供电(寄生供电模式)。

最典型的应用就是DS18B20温度传感器。我做过一个项目,需要在一条线上挂载20个DS18B20,每个传感器都有唯一的64位ROM编码,通过搜索算法可以逐个访问。说实话,这个协议的速度确实慢,但胜在省引脚。

2.5.1 工作时序

1-Wire的时序要求非常严格,必须精确到微秒级。它有三种基本信号:

  • 复位脉冲:主机拉低480μs,然后释放
  • 存在脉冲:从设备拉低60~240μs作为应答
  • 写时隙:写0拉低60μs,写1拉低1~15μs后释放
  • 读时隙:主机拉低1~15μs后释放,从设备在15μs内将数据放到总线上

我的经验:1-Wire的时序对延时非常敏感。如果你用Arduino,直接用delayMicroseconds()可能不准,最好用定时器中断或者硬件1-Wire外设。我曾在STM32上用软件模拟1-Wire,折腾了两天才把时序调对。

2.5.2 硬件连接

1-Wire的连接更简单:数据线接一个4.7kΩ上拉电阻到VCC,然后所有设备的DQ引脚连在一起。如果使用寄生供电模式,VCC和GND可以不用接。

// 读取DS18B20温度的简化代码
float read_ds18b20() {
    uint8_t data[9];
    
    // 复位
    onewire_reset();
    // 跳过ROM(单设备时使用)
    onewire_write(0xCC);
    // 启动温度转换
    onewire_write(0x44);
    delay_ms(750);  // 等待转换完成
    
    // 复位
    onewire_reset();
    onewire_write(0xCC);
    // 读取暂存器
    onewire_write(0xBE);
    
    for (int i = 0; i < 9; i++) {
        data[i] = onewire_read();
    }
    
    // 计算温度
    int16_t raw = (data[1] << 8) | data[0];
    return raw * 0.0625;
}

2.6 四大协议对比总结

好了,四种协议都讲完了。我最后给你一张对比表,方便你以后选型时参考。

特性 I2C SPI UART 1-Wire
线数 2(SCL+SDA) 4(SCLK+MOSI+MISO+CS) 2(TX+RX) 1(DQ)
通信方式 同步、半双工 同步、全双工 异步、全双工 异步、半双工
速度 100kHz~3.4MHz 最高可达几十MHz 最高可达几Mbps 约15kbps
多设备支持 是(地址区分) 是(片选区分) 否(点对点) 是(ROM编码区分)
传输距离 短(<1m) 短(<1m) 中(可达10m+) 短(<10m)
典型应用 温湿度、气压、加速度计 ADC、DAC、显示屏、SD卡 GPS、蓝牙、WiFi模块 DS18B20、iButton

2.7 实战选型建议

最后,我根据我的项目经验,给你一些选型建议:

  • 引脚紧张、设备多、速度要求不高 → 选I2C。比如一个环境监测站,挂一堆温湿度、气压、光照传感器。
  • 需要高速、大数据量传输 → 选SPI。比如高速ADC、TFT屏幕、SD卡读写。
  • 点对点通信、距离较远、模块化设计 → 选UART。比如GPS模块、蓝牙模块、串口屏。
  • 极度省引脚、温度测量场景 → 选1-Wire。比如分布式温度监测系统。

嗯,这一章的内容就到这里。下一章咱们会深入讲I2C的时序细节和调试技巧,到时候我会带一个逻辑分析仪的实战案例。记得提前准备好你的开发板。