1. 从第一性原理重新理解嵌入式系统
1.1 为什么“第一性原理”对嵌入式开发如此重要
很多做嵌入式开发的朋友,包括我自己早期也一样,拿到一个需求的第一反应是“找个例程改改”。比如要做SPI读取传感器数据,立刻去搜“STM32 SPI DMA 读取例程”,找到一份能跑的代码,改改引脚定义,烧进去,数据出来了,收工。这种方式在项目初期确实高效,但它有一个致命问题:一旦通信不稳定、数据错位、DMA卡死,你就完全不知道从哪里下手了。
我踩过最典型的一个坑:用STM32F103的SPI通过DMA方式读取一颗传感器芯片的数据,例程跑得好好的,换了一块板子,DMA就是收不到数据。查了半天,最后发现是SPI的时钟极性CPOL和时钟相位CPHA配置跟新芯片不匹配。如果当初我理解了SPI协议的本质——数据在哪个时钟边沿采样、哪个边沿移出——这个问题五分钟就能定位。
所谓“第一性原理”,在嵌入式领域就是:回到信号本身、回到时序本身、回到硬件本身去理解问题。不是背寄存器配置,而是理解为什么这个寄存器要这么配。不是记住I2C要加上拉电阻,而是理解为什么开漏输出必须配上拉。不是照抄DMA初始化代码,而是理解DMA的传输触发源、数据流映射、优先级仲裁到底是怎么回事。
这篇内容适合所有嵌入式方向的从业者和学习者——无论你是刚入门的单片机爱好者,还是已经做过几个项目但总觉得“知其然不知其所以然”的工程师,甚至是准备嵌入式系统设计师考试的朋友。我会从SPI、I2C、DMA这三条主线出发,把协议原理、硬件设计、软件配置、调试技巧串起来讲透,让你看完之后不只是“会抄代码”,而是真正“能设计、能调试、能定位问题”。
1.2 嵌入式系统的知识分层模型
我习惯把嵌入式系统的知识分成四层,从下往上依次是:
- 物理层:电平标准、引脚特性、PCB走线、电源完整性。这一层决定了信号能不能可靠传输。
- 协议层:SPI、I2C、UART、CAN等通信协议的时序规则、帧格式、错误处理机制。这一层决定了数据怎么组织、怎么同步。
- 外设层:MCU内部的SPI控制器、I2C控制器、DMA控制器、GPIO等外设的寄存器模型和工作模式。这一层决定了你怎么用软件去操控硬件。
- 应用层:具体的业务逻辑,比如读取传感器、驱动屏幕、控制电机。这一层是最终交付的功能。
大部分教程直接从第三层开始讲,告诉你“配置SPI的CR1寄存器,设置MSTR位为1”。但真正让你在调试中活下来的,是第一层和第二层的功底。一个SPI通信失败,可能是PCB走线太长导致信号反射,可能是片选信号建立时间不够,可能是时钟频率超过了从设备支持的上限——这些问题在寄存器配置里是找不到答案的。
所以接下来的内容,我会按照“协议原理→硬件设计→软件实现→调试验证”这个链路来展开,确保每一层都讲到位。
2. SPI通信协议:从时序图到DMA数据搬运
2.1 SPI协议的核心机制与时序本质
SPI(Serial Peripheral Interface)是全双工同步串行通信协议,由Motorola提出。它的核心思想非常简单:一个主设备、一个或多个从设备,通过四根线完成通信——SCLK(时钟)、MOSI(主出从入)、MISO(主入从出)、CS(片选)。
SPI的本质是移位寄存器交换。主设备和从设备各有一个移位寄存器,在时钟的驱动下,主设备的数据从MOSI移出、从MISO移入,从设备反之。每来一个时钟脉冲,双方各移出一位、移入一位。8个时钟脉冲之后,两个寄存器的内容就完成了交换。理解了这一点,你就能理解为什么SPI是全双工的——发送和接收是同一个移位过程的两个方向。
但这里有几个关键参数必须搞清楚:
CPOL(Clock Polarity):时钟极性,决定空闲状态下SCLK是高电平还是低电平。CPOL=0表示空闲时SCLK为低,CPOL=1表示空闲时SCLK为高。
CPHA(Clock Phase):时钟相位,决定数据在哪个边沿采样。CPHA=0表示在第一个边沿(即从空闲状态跳变的那个边沿)采样,CPHA=1表示在第二个边沿采样。
这两个参数组合出四种模式:
| 模式 | CPOL | CPHA | 空闲电平 | 采样边沿 | 移出边沿 |
|---|---|---|---|---|---|
| Mode 0 | 0 | 0 | 低 | 上升沿 | 下降沿 |
| Mode 1 | 0 | 1 | 低 | 下降沿 | 上升沿 |
| Mode 2 | 1 | 0 | 高 | 下降沿 | 上升沿 |
| Mode 3 | 1 | 1 | 高 | 上升沿 | 下降沿 |
我实测下来,大部分传感器和Flash芯片用的是Mode 0或Mode 3。比如常见的W25Q系列SPI Flash用Mode 0和Mode 3都支持,但有些芯片只支持其中一种。如果你发现SPI读出来的数据总是移位或者全是0xFF,第一个要检查的就是CPOL和CPHA是否跟从设备手册一致。
注意:CPHA=0时,第一个时钟边沿就采样数据,这意味着从设备必须在片选有效之前就把第一位数据准备好放到MISO上。如果你的从设备需要一定的准备时间,CPHA=0可能会读到错误的第一位。这时候要么降低SPI时钟频率,要么改用CPHA=1。
2.2 SPI硬件片选与软件片选的取舍
片选信号CS是SPI通信中容易被忽视但非常关键的一环。它的作用是告诉从设备“现在主设备在跟你说话”。CS的有效电平通常是低电平,也就是说主设备把CS拉低表示选中该从设备。
硬件片选是指使用MCU的SPI外设自带的NSS(Negative Slave Select)引脚,由硬件自动管理CS的拉高拉低。软件片选是指用普通GPIO手动控制CS引脚,在通信前后自己拉低和拉高。
我个人的经验是:多从设备场景下,几乎一定要用软件片选。原因很简单——硬件NSS在多主或多从场景下管理起来非常麻烦,而且STM32的硬件NSS在某些模式下还会产生意想不到的行为(比如在NSS脉冲模式下会自动拉高)。软件片选虽然多写两行代码,但控制灵活、逻辑清晰。
用软件片选时,时序上要注意几点:
- CS拉低之后,要等一小段时间(通常几百纳秒到几微秒)再开始发送时钟,给从设备反应时间。
- 通信结束后,要等最后一个时钟边沿完成、数据稳定后,再拉高CS。
- 连续读写同一个从设备时,CS可以保持低电平不拉高,但要注意从设备的时序要求。
// 软件片选示例 #define SPI_CS_LOW() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET) #define SPI_CS_HIGH() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET) void SPI_ReadData(uint8_t *buf, uint16_t len) { SPI_CS_LOW(); HAL_SPI_Receive(&hspi1, buf, len, HAL_MAX_DELAY); SPI_CS_HIGH(); }2.3 STM32F103 SPI通过DMA读取芯片数据的完整实现
这是热词里出现频率最高的一个需求,我详细讲一下完整实现过程。以STM32F103 + CubeMX + HAL库为例,目标是通过SPI+DMA方式高效读取外部芯片数据。
第一步:CubeMX配置
在CubeMX中配置SPI1为全双工主模式,参数如下:
- Clock Polarity: Low(CPOL=0)
- Clock Phase: 1 Edge(CPHA=0)
- Data Size: 8 bits
- First Bit: MSB First
- Prescaler: 根据从设备手册选择,一般先设大一点(如256分频),调通后再提高
- NSS: Software
然后配置DMA:
- 添加SPI1_RX的DMA请求,模式选Normal或Circular
- 优先级设为Medium或High
- 数据宽度:Peripheral和Memory都设为Byte
第二步:代码实现
uint8_t rxBuf[64]; void SPI_DMA_Read(uint8_t *txBuf, uint8_t *rxBuf, uint16_t len) { SPI_CS_LOW(); HAL_SPI_TransmitReceive_DMA(&hspi1, txBuf, rxBuf, len); } // DMA传输完成回调 void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI1) { SPI_CS_HIGH(); // 数据处理 ProcessData(rxBuf, 64); } }第三步:关键细节说明
为什么用HAL_SPI_TransmitReceive_DMA而不是HAL_SPI_Receive_DMA?因为SPI是全双工的,主设备在产生时钟的同时必须提供MOSI数据。即使你只想接收,也需要发送一些dummy数据(通常是0x00或0xFF)来产生时钟。用TransmitReceive可以同时管理发送和接收,避免时钟中断。
DMA的传输完成中断里要记得拉高CS。如果你在HAL_SPI_TransmitReceive_DMA之后立刻拉高CS,DMA还没传完呢,CS就失效了,从设备会中止通信。
实操心得:STM32F103的SPI1 TX和RX分别使用DMA1的Channel3和Channel2。如果你同时用了其他外设的DMA,注意通道冲突。我遇到过一次SPI1_RX和USART1_TX抢DMA1_Channel5的情况,结果串口发不出数据,查了半天才发现是DMA通道分配问题。
2.4 SPI时钟频率与信号完整性的工程权衡
SPI时钟频率不是越高越好。理论上STM32F103的SPI1最高可以跑到18MHz(APB2=72MHz,分频系数最小为2),但实际能用多高取决于:
- 从设备的最高SPI时钟支持
- PCB走线长度和质量
- 上拉电阻和线路电容
我做过一个测试:用STM32F103通过SPI读取W25Q64 Flash,PCB走线约5cm,不加任何匹配电阻。分频系数设为2(18MHz)时,读取数据偶发错误;设为4(9MHz)时完全稳定;设为8(4.5MHz)也没问题但速度没必要那么低。
如果你需要高速SPI通信,几个建议:
- 尽量缩短SPI走线,尤其是SCLK
- SCLK走线旁边包地,减少串扰
- 如果走线超过10cm,考虑在SCLK上串一个22Ω~33Ω的电阻做阻抗匹配
- MISO线上如果有多个从设备,注意总线电容不要太大
3. I2C通信协议:开漏输出、上拉电阻与实战调试
3.1 I2C为什么必须用开漏输出加上拉电阻
这是I2C面试和实际调试中出现频率极高的问题。答案的核心在于I2C的多主多从总线仲裁机制。
I2C总线只有两根线:SDA(数据)和SCL(时钟)。总线上可以挂多个主设备和多个从设备。问题来了:如果两个设备同时驱动SDA线,一个想输出高电平、一个想输出低电平,会发生什么?如果是推挽输出,高电平驱动器和低电平驱动器直接对接,会形成大电流通路,烧毁引脚。
开漏输出的妙处在于:它只能主动拉低,不能主动拉高。高电平是靠外部上拉电阻把线拉上去的。这样即使多个设备同时驱动SDA,只要有一个拉低,线就是低电平,不会出现电源到地的短路。这就是I2C的“线与”逻辑——所有设备都能拉低,但没人能强行拉高。
上拉电阻的取值是一个经典的工程权衡:
- 电阻太小:上升沿快,但功耗大,低电平时灌电流大,可能超过引脚的灌电流能力(STM32的GPIO灌电流一般不超过20mA)
- 电阻太大:功耗小,但上升沿慢,高速通信时信号还没拉高就被下一个时钟沿采样了,导致通信失败
计算公式:上升时间 t_r ≈ 0.847 × R × C,其中C是总线电容(包括PCB走线电容和引脚电容)。I2C标准模式(100kHz)要求上升时间不超过1000ns,快速模式(400kHz)要求不超过300ns。
假设总线电容为100pF,快速模式下:R ≤ 300ns / (0.847 × 100pF) ≈ 3.5kΩ。所以快速模式常用2.2kΩ~4.7kΩ,标准模式常用4.7kΩ~10kΩ。
我踩过的坑:有一次用10kΩ上拉电阻跑400kHz的I2C,读EEPROM时好时坏。用示波器一看,SCL上升沿明显变缓,从低到高花了将近800ns。换成2.2kΩ之后立刻稳定。所以“i2c上拉电阻小了不通信”这个说法其实要反过来理解——通常是上拉电阻太大了不通信,太小了会烧引脚。
3.2 I2C时序图详解与软件模拟实现
I2C的时序规则不复杂,但细节很多。核心的几种信号:
起始条件(START):SCL为高电平时,SDA从高变低。
停止条件(STOP):SCL为高电平时,SDA从低变高。
数据位传输:SCL为低时SDA可以变化,SCL为高时SDA必须稳定。每个数据位对应一个SCL时钟脉冲。
应答(ACK/NACK):发送完8位数据后,接收方在第9个时钟周期拉低SDA表示ACK,保持高电平表示NACK。
用GPIO模拟I2C(软件I2C)时,关键操作是:
// 起始条件 void I2C_Start(void) { SDA_HIGH(); SCL_HIGH(); delay_us(4); SDA_LOW(); delay_us(4); SCL_LOW(); delay_us(4); } // 发送一个字节 uint8_t I2C_SendByte(uint8_t byte) { for (int i = 0; i < 8; i++) { if (byte & 0x80) SDA_HIGH(); else SDA_LOW(); byte <<= 1; delay_us(2); SCL_HIGH(); delay_us(4); SCL_LOW(); delay_us(2); } // 读取ACK SDA_INPUT(); // 切换为输入模式 SCL_HIGH(); delay_us(4); uint8_t ack = SDA_READ(); SCL_LOW(); delay_us(4); SDA_OUTPUT(); return ack; }软件I2C的优势是引脚灵活、不受硬件I2C外设的限制,缺点是速度慢、占CPU。硬件I2C的优势是速度快、不占CPU,缺点是STM32早期的硬件I2C外设确实有一些已知的缺陷(比如F103的I2C死锁问题),需要额外处理。
3.3 I2C读写EEPROM的完整代码与注意事项
以AT24C02为例,写一个字节的流程是:
- 发送START
- 发送设备地址+写方向(0xA0)
- 等待ACK
- 发送内存地址(要写入的EEPROM地址)
- 等待ACK
- 发送数据字节
- 等待ACK
- 发送STOP
- 等待写周期完成(约5ms)
读一个字节的流程是:
- 发送START
- 发送设备地址+写方向(0xA0)
- 等待ACK
- 发送内存地址
- 等待ACK
- 发送START(重复起始条件)
- 发送设备地址+读方向(0xA1)
- 等待ACK
- 读取数据字节
- 发送NACK(告诉从设备不再需要更多数据)
- 发送STOP
void AT24C02_WriteByte(uint8_t addr, uint8_t data) { I2C_Start(); I2C_SendByte(0xA0); I2C_WaitAck(); I2C_SendByte(addr); I2C_WaitAck(); I2C_SendByte(data); I2C_WaitAck(); I2C_Stop(); HAL_Delay(5); // 等待EEPROM内部写周期 } uint8_t AT24C02_ReadByte(uint8_t addr) { uint8_t data; I2C_Start(); I2C_SendByte(0xA0); I2C_WaitAck(); I2C_SendByte(addr); I2C_WaitAck(); I2C_Start(); // 重复起始 I2C_SendByte(0xA1); I2C_WaitAck(); data = I2C_ReadByte(); I2C_SendNack(); // 最后一个字节发NACK I2C_Stop(); return data; }注意:EEPROM的写周期是必须等待的。AT24C02的典型写周期是5ms,如果你写完立刻读,读到的可能是旧数据。我见过有人在循环里连续写EEPROM不延时,结果只有第一个字节写进去了,后面的全丢了。
3.4 I2C常见故障排查速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 完全无应答 | 设备地址错误 | 用示波器看SDA在第9个时钟是否被拉低 |
| 偶发NACK | 上拉电阻过大 | 测量上升时间,换小电阻 |
| SCL一直被拉低 | 从设备时钟拉伸 | 检查从设备是否支持时钟拉伸,降低速率 |
| 总线死锁 | 主设备复位时从设备还在驱动SDA | 发送9个时钟脉冲释放总线 |
| 读数据全0xFF | 从设备未选中或地址错误 | 确认设备地址和片选 |
| 读数据全0x00 | SDA被短路到地 | 检查PCB焊接 |
4. DMA控制器:从数据搬运到双缓冲与空闲中断
4.1 DMA的工作原理与STM32F103的DMA架构
DMA(Direct Memory Access)的本质是一个独立于CPU的数据搬运工。没有DMA的时候,CPU要一个字节一个字节地从外设读数据写到内存,或者从内存读数据写到外设。有了DMA,CPU只需要告诉DMA“从哪搬到哪、搬多少”,然后就可以去干别的事了,DMA搬完了再通知CPU。
STM32F103有两个DMA控制器:DMA1有7个通道,DMA2有5个通道(只有大容量型号才有DMA2)。每个通道可以配置:
- 外设地址和内存地址
- 传输方向(外设到内存、内存到外设、内存到内存)
- 传输数据宽度(字节、半字、字)
- 传输数量
- 优先级
- 循环模式(Normal/Circular)
- 中断使能
DMA的触发源是外设的DMA请求。比如SPI1_RX产生DMA请求时,DMA就从SPI1的DR寄存器读一个字节写到内存。USART1_TX产生DMA请求时,DMA就从内存读一个字节写到USART1的DR寄存器。
4.2 DMA加空闲中断接收不定长数据的经典方案
串口接收不定长数据是嵌入式开发中的高频需求。传统方案是每收到一个字节进一次中断,CPU开销大。DMA+空闲中断的方案完美解决了这个问题。
原理是:DMA以Circular模式持续接收串口数据到缓冲区,同时使能串口的IDLE中断。当串口总线空闲(一个字节时间内没有新数据)时,触发IDLE中断。在IDLE中断里,计算DMA当前剩余传输数量,就能算出收到了多少字节。
#define RX_BUF_SIZE 256 uint8_t rxBuf[RX_BUF_SIZE]; volatile uint16_t rxLen = 0; void UART_DMA_Init(void) { HAL_UART_Receive_DMA(&huart1, rxBuf, RX_BUF_SIZE); __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); } void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); HAL_UART_DMAStop(&huart1); rxLen = RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx); // 处理rxBuf中的rxLen个字节 ProcessFrame(rxBuf, rxLen); HAL_UART_Receive_DMA(&huart1, rxBuf, RX_BUF_SIZE); } HAL_UART_IRQHandler(&huart1); }这个方案的精髓在于:DMA在后台默默搬运数据,CPU完全不用管;只有在一帧数据接收完毕(总线空闲)时,CPU才被中断一次去处理数据。效率极高。
实操心得:IDLE中断标志的清除方式比较特殊,必须先读SR寄存器再读DR寄存器,或者直接调用
__HAL_UART_CLEAR_IDLEFLAG。我早期自己写的时候只清了SR标志没读DR,结果IDLE中断反复触发,CPU直接跑飞。
4.3 DMA双缓冲技术在高速数据采集中的应用
DMA双缓冲(Double Buffer)模式是STM32一个非常实用的特性。它使用两块内存区域,DMA在填充缓冲区A的时候,CPU可以处理缓冲区B的数据;A填满了自动切换到B,同时通知CPU处理A。如此循环,实现无缝的数据流处理。
配置双缓冲需要用到DMA的M0AR和M1AR寄存器(在HAL库中通过HAL_DMAEx_MultiBufferStart配置)。以ADC高速采集为例:
uint16_t adcBuf0[256]; uint16_t adcBuf1[256]; void ADC_DMA_DoubleBuffer_Init(void) { HAL_DMAEx_MultiBufferStart(&hdma_adc1, (uint32_t)&ADC1->DR, (uint32_t)adcBuf0, (uint32_t)adcBuf1, 256); __HAL_DMA_ENABLE_IT(&hdma_adc1, DMA_IT_TC); } // 在DMA传输完成中断中处理 void DMA1_Channel1_IRQHandler(void) { if (__HAL_DMA_GET_FLAG(&hdma_adc1, DMA_FLAG_TC1)) { __HAL_DMA_CLEAR_FLAG(&hdma_adc1, DMA_FLAG_TC1); // 判断当前DMA在填充哪个缓冲区,处理另一个 if (hdma_adc1.Instance->CR & DMA_CR_CT) { // 当前在填充buf1,处理buf0 ProcessADCData(adcBuf0, 256); } else { ProcessADCData(adcBuf1, 256); } } }双缓冲的核心价值在于零等待。单缓冲模式下,DMA填满缓冲区后必须停止,等CPU处理完再重启,这中间会丢失数据。双缓冲模式下,DMA永远在搬运,CPU永远在处理已经搬完的那一块,两者互不干扰。
4.4 DMA测速与性能评估方法
DMA到底能跑多快?这个问题需要用数据回答。我做过一组实测,在STM32F103(72MHz)上:
| 传输类型 | 数据宽度 | 传输量 | 耗时 | 速率 |
|---|---|---|---|---|
| 内存到内存 | 字节 | 1024 | 约45us | 约22MB/s |
| 内存到内存 | 字 | 1024 | 约15us | 约273MB/s |
| SPI到内存 | 字节 | 1024 | 取决于SPI时钟 | 等于SPI速率 |
| ADC到内存 | 半字 | 1024 | 取决于采样率 | 等于采样率 |
内存到内存的传输速率差异巨大,原因是字节传输每次搬1个字节,字传输每次搬4个字节,而DMA的总线周期开销是固定的。所以能用字传输就不要用字节传输。
测速方法很简单:在DMA传输前后各翻转一个GPIO,用示波器测量高电平持续时间。或者用定时器计数,在DMA完成中断里读取定时器值。
5. 嵌入式系统板级电路装配与调试实战
5.1 最小系统板的关键电路设计要点
STM32F103最小系统板看起来简单,但有几个地方如果设计不当,会带来莫名其妙的故障:
电源部分:STM32F103的VDD范围是2.0V~3.6V,典型3.3V。每个VDD引脚旁边必须放一个100nF的去耦电容,越近越好。VDDA(模拟电源)要额外加一个1uF~10uF的电容,并且通过磁珠或0Ω电阻与VDD隔离。我见过有人VDDA直接跟VDD短接,ADC采集出来的数据跳得厉害。
复位电路:NRST引脚内部有上拉电阻,但建议外部再加一个10kΩ上拉和一个100nF电容到地,形成RC复位电路。如果复位引脚走线太长,容易引入干扰导致随机复位。
晶振电路:8MHz主晶振要尽量靠近MCU,负载电容根据晶振手册选择(通常10pF~22pF)。32.768kHz晶振用于RTC,对走线更敏感,建议包地处理。
BOOT引脚:BOOT0和BOOT1决定启动模式。正常从Flash启动时,BOOT0接10kΩ下拉到地。如果要做串口下载,BOOT0接高电平。建议BOOT0用跳线帽或拨码开关,方便切换。
5.2 焊接与装配的实操经验
QFP封装的STM32芯片焊接是很多人的噩梦。我的经验是:
- 先用助焊剂涂满焊盘
- 对准引脚放好芯片,注意方向(圆点标记对应1脚)
- 用刀头烙铁加锡拖焊,让锡自动流向引脚
- 如果有连锡,用吸锡线清理
- 最后用放大镜检查每个引脚
注意:焊接温度不要超过350°C,每个引脚停留时间不要超过3秒。STM32的引脚很脆弱,过热会导致引脚脱落或芯片内部损坏。
对于0402、0603封装的电阻电容,我建议用镊子夹住元件,先焊一端固定,再焊另一端。不要试图同时焊两端,那样元件会跑偏。
5.3 上电调试的标准化流程
板子焊好之后,不要急着烧程序。按照以下流程逐步验证:
- 目视检查:用放大镜看有没有连锡、虚焊、元件方向错误
- 电源短路检测:用万用表测VDD和GND之间的电阻,正常应该在几百欧姆以上。如果接近0Ω,说明有短路,必须排除后再上电
- 上电测电压:用万用表测3.3V、5V等各路电源是否正常
- 测晶振:用示波器看晶振引脚是否有波形。注意示波器探头电容会影响晶振起振,最好用×10档
- 连接调试器:用ST-Link或J-Link连接SWD接口,看能否识别到芯片
- 烧录测试程序:烧一个最简单的GPIO翻转程序,测LED是否闪烁
这个流程看起来繁琐,但能帮你避免90%的上电烧板事故。我亲眼见过有人焊完板子直接上电,结果电源和地短路,芯片瞬间冒烟。
6. 常见问题与排查技巧实录
6.1 SPI通信故障排查
问题一:SPI读出来全是0xFF
排查思路:0xFF意味着MISO线一直是高电平。可能原因:从设备没有正确响应(片选没拉低、从设备供电异常)、MISO线断路、从设备根本没有输出数据。先用示波器看MISO线上有没有波形,如果没有,检查从设备供电和片选信号。
问题二:SPI读出来全是0x00
排查思路:0x00意味着MISO线一直是低电平。可能原因:MISO线短路到地、从设备处于复位状态、从设备配置错误。检查MISO线是否跟GND短路。
问题三:数据偶尔错位
排查思路:通常是CPOL/CPHA配置不匹配,或者时钟频率过高导致建立/保持时间不够。先用低速率测试,如果低速正常高速出错,就是信号完整性问题。
6.2 I2C通信故障排查
问题一:STM32硬件I2C死锁
这是STM32F103的经典问题。现象是I2C总线卡死,SCL或SDA被一直拉低。解决方法:在I2C初始化时配置为“No Clock Stretch”模式,或者在检测到死锁后手动发送9个时钟脉冲释放总线。
问题二:多设备挂载时地址冲突
I2C设备地址是7位的,理论上可以挂128个设备。但实际中很多传感器的地址是固定的,或者只有1~2个可选地址。如果两个设备地址相同,就需要用I2C多路复用器(如TCA9548A)来扩展。
6.3 DMA传输异常排查
问题一:DMA传输完成中断不触发
检查DMA的TC中断是否使能、NVIC中对应的中断是否使能、DMA通道是否跟其他外设冲突。STM32F103的DMA1通道是固定的,不能重映射。
问题二:DMA传输数据错位
检查外设和内存的数据宽度是否匹配。比如SPI配置为8位数据,DMA内存宽度也必须是字节。如果内存宽度设为半字,数据会错位。
问题三:DMA传输一半就停了
检查DMA的传输数量是否设置正确。另外,如果DMA模式是Normal,传输完成后会自动停止,需要重新使能。如果需要连续传输,要配置为Circular模式。
6.4 常见问题速查表
| 问题分类 | 典型现象 | 首选排查手段 |
|---|---|---|
| SPI | 数据全0xFF或0x00 | 示波器看MISO波形 |
| SPI | 数据偶发错误 | 降低时钟频率测试 |
| I2C | 无应答 | 检查上拉电阻和地址 |
| I2C | 总线死锁 | 发送9个时钟脉冲 |
| DMA | 中断不触发 | 检查NVIC和DMA使能 |
| DMA | 数据错位 | 检查数据宽度配置 |
| GPIO | 输出电平不对 | 检查模式配置(推挽/开漏) |
| 电源 | 芯片发热 | 立即断电,检查短路 |
7. 从知识到项目:嵌入式系统设计的进阶路径
7.1 典型项目案例拆解
热词里提到了几个有意思的项目方向,我简单拆解一下技术要点:
基于STM32的数字温湿度计与报警器:核心是传感器驱动(DHT11或SHT30)、LCD显示驱动、蜂鸣器控制。DHT11是单总线协议,对时序要求严格,需要关中断操作。SHT30是I2C接口,相对简单。报警逻辑可以用定时器中断做周期检测。
基于STM32的四开关Buck-Boost双向升降压数字电源:这是电力电子+嵌入式的综合项目。核心是PWM生成(用定时器)、ADC采样(用DMA+定时器触发)、PID控制算法。四开关Buck-Boost的拓扑决定了它可以实现输入输出双向能量流动,适合电池充放电管理场景。
STM32鱼缸控制器:典型的物联网应用。核心是温度采集(DS18B20或NTC)、继电器控制(加热棒、水泵)、WiFi模块通信(ESP8266)、定时喂食。ESP8266通过AT指令或SPI接口与STM32通信,把数据上传到云平台。
7.2 嵌入式系统设计师考试的知识体系
如果你在准备嵌入式系统设计师考试,核心知识模块包括:
- 硬件基础:数字电路、模拟电路、PCB设计
- 处理器架构:ARM Cortex-M系列、流水线、中断系统
- 通信协议:UART、SPI、I2C、CAN、USB、以太网
- 操作系统:RTOS任务调度、信号量、消息队列
- 软件工程:C语言、数据结构、算法、设计模式
- 系统设计:低功耗设计、可靠性设计、EMC
考试中经常出现的题型包括:给定时序图分析通信协议、计算DMA传输时间、分析中断嵌套优先级、设计状态机等。这些内容跟实际工程是高度重合的,所以认真做项目本身就是最好的备考。
7.3 持续学习的实操建议
嵌入式这个方向,光看书是学不会的。我的建议是:
- 买一块开发板,从点灯开始。不要小看点灯,它涵盖了GPIO配置、时钟使能、下载调试的完整流程。
- 每学一个外设,就写一个完整的驱动。不要只用HAL库的现成函数,试着自己操作寄存器。
- 用示波器和逻辑分析仪。这两个工具能让你看到信号的真实样子,比看一百遍时序图都管用。
- 读芯片手册和数据手册。英文的也要硬着头皮读,中文翻译经常有歧义。
- 参与开源项目。GitHub上有大量STM32项目,看别人怎么组织代码、怎么处理异常。
最后分享一个我自己的习惯:每次调试完一个通信问题,我都会在笔记本上画一遍时序图,标注出问题的位置和原因。积累多了之后,再遇到类似问题,脑子里会自动浮现出时序图,排查速度会快很多。嵌入式开发没有捷径,但每一步踩过的坑都会变成你的经验壁垒。