新闻详情

新闻详情

首页 / 资讯中心 / 详情

STM32F103实战:SPI、I2C与DMA通信机制详解与避坑指南

发布时间:2026/9/20 6:51:19来源:尧图网络
STM32F103实战:SPI、I2C与DMA通信机制详解与避坑指南
嵌入式系统这个领域有个很有意思的现象很多人能照着教程把STM32的流水灯跑通能调通串口打印但一旦项目里同时出现SPI、I2C、DMA这几个东西就开始手忙脚乱。更典型的是有人用CubeMX生成了SPIDMA的代码编译下载后却发现接收缓冲区里全是0或者数据只进来一次就再也不更新了。这类问题我在实际项目中遇到过太多次根源往往不在代码本身而在于对底层机制的理解不够透彻。这篇内容面向的是已经能写基础外设驱动、但想真正搞明白为什么这样配置的嵌入式开发者。我会从第一性原理出发把SPI、I2C、DMA这三个嵌入式系统中最核心的通信与传输机制拆开讲清楚然后落到STM32F103的具体工程实践上包括CubeMX配置、代码实现、常见问题排查。不管你是做数据采集、电机控制还是电源管理这些知识都是绕不过去的基本功。1. 为什么SPI和I2C不是随便选一个就行很多初学者觉得SPI和I2C都是串行通信协议功能差不多选哪个看心情。这种想法在简单场景下可能不出问题但一旦系统复杂度上来选错了协议会导致整个方案推倒重来。要理解这一点得从两种协议的物理层设计哲学说起。1.1 从引脚数量和拓扑结构看本质差异SPI的全称是Serial Peripheral Interface它的设计思路非常直接用四根线搞定全双工通信。SCK负责时钟MOSI是主机输出从机输入MISO是主机输入从机输出CS是片选。每个从机独占一根CS线主机通过拉低对应的CS来决定跟谁通信。这种设计的代价是引脚数量随从机数量线性增长。一个主机挂四个SPI从机就需要448根线SCK、MOSI、MISO共用CS各一根。如果挂八个从机CS线就要八根。在引脚资源紧张的MCU上这是个不小的负担。I2C则走了另一条路。它只用两根线SCL时钟线和SDA数据线所有设备都挂在这两条线上通过7位地址来区分。理论上一条I2C总线可以挂128个设备实际受总线电容限制通常不超过十几个。这种设计极大地节省了引脚代价是通信速率较低且协议本身更复杂。我个人的经验是高速数据传输选SPI多设备低速控制选I2C。比如SPI Flash、TFT显示屏、高速ADC这些需要几MHz甚至几十MHz时钟的场合SPI是唯一选择。而EEPROM、温湿度传感器、RTC芯片这类低速外设I2C更合适。1.2 开漏输出与上拉电阻I2C最容易被忽略的硬件细节I2C的SDA和SCL必须配置为开漏输出Open-Drain外接上拉电阻。这不是可选项而是协议正常工作的前提。原因在于I2C支持多主机仲裁和时钟同步需要实现线与逻辑——任何一个设备拉低总线总线就是低电平只有所有设备都释放总线上拉电阻才能把电平拉高。很多人在调试I2C时遇到通信失败第一反应是查代码但实际上问题往往出在上拉电阻上。上拉电阻的取值需要根据总线电容和通信速率来计算。阻值太大上升沿变缓高速通信时波形还没到高电平就被拉低了阻值太小灌电流过大可能超过器件的驱动能力。一个实用的经验公式是Rp(max) tr / (0.8473 × Cb)其中tr是允许的最大上升时间标准模式1000ns快速模式300nsCb是总线电容。以快速模式400kHz、总线电容200pF为例Rp(max) ≈ 300ns / (0.8473 × 200pF) ≈ 1.77kΩ。实际选用时通常取1.8kΩ到4.7kΩ之间。注意如果你在调试I2C时发现偶尔能通、偶尔不通或者示波器上看波形上升沿明显变缓先检查上拉电阻不要急着改代码。1.3 SPI的四种模式CPOL和CPHA组合的坑SPI有四种工作模式由CPOL时钟极性和CPHA时钟相位组合决定。CPOL0表示空闲时SCK为低电平CPOL1表示空闲时SCK为高电平。CPHA0表示在第一个时钟边沿采样数据CPHA1表示在第二个时钟边沿采样。模式CPOLCPHA空闲电平采样边沿Mode 000低上升沿Mode 101低下降沿Mode 210高下降沿Mode 311高上升沿最常见的坑是主机和从机的模式不匹配。比如STM32配置为Mode 0但从机芯片要求Mode 3结果就是读回来的数据全是0xFF或者0x00。更隐蔽的情况是某些芯片在低速时两种模式都能凑合工作但速率一提高就出错。我的建议是拿到一颗SPI从机芯片第一件事就是翻数据手册确认它支持哪种模式然后在CubeMX里严格对应配置。不要凭经验猜不同厂商的芯片默认模式可能完全不同。2. DMA到底解决了什么问题从CPU搬运工到数据高速公路DMA是Direct Memory Access的缩写直译就是直接存储器访问。很多人知道DMA能不占用CPU搬运数据但具体怎么理解这个不占用以及什么时候该用DMA、什么时候不该用往往说不清楚。2.1 没有DMA时CPU是怎么被拖垮的假设你用STM32的SPI接口读取一颗传感器的数据每次读2个字节每秒读1000次。不用DMA的话流程是这样的SPI接收到一个字节触发中断CPU进入中断服务函数从SPI数据寄存器读出数据存到内存清除中断标志退出中断。第二个字节再来一遍。每次中断的进出开销大约几十个时钟周期加上中断服务函数本身的执行时间保守估计每次中断消耗100个时钟周期。每秒2000次中断就是20万个时钟周期。如果MCU跑在72MHz这相当于CPU有0.28%的时间花在搬运数据上。看起来不多但如果传感器数据率提高到每秒10万次采样这个比例就飙升到14%CPU有七分之一的时间在做搬运工。DMA的思路是给外设和内存之间修一条专用通道数据直接从外设寄存器流到内存或者从内存流到外设寄存器全程不需要CPU参与。CPU只需要在传输开始前配置好源地址、目标地址、传输长度然后该干嘛干嘛去。传输完成后DMA控制器产生一个中断通知CPU活干完了。2.2 STM32F103的DMA资源分配与通道映射STM32F103系列有2个DMA控制器DMA1有7个通道DMA2有5个通道大容量型号。每个通道可以配置不同的外设请求源但通道和外设的对应关系是固定的不能随意映射。DMA控制器通道常见外设请求DMA1Channel 1ADC1、TIM2_CH3、TIM4_CH1DMA1Channel 2SPI1_RX、USART3_TX、TIM1_CH1DMA1Channel 3SPI1_TX、USART3_RX、TIM1_CH2DMA1Channel 4SPI2/I2S2_RX、USART1_TX、I2C2_TXDMA1Channel 5SPI2/I2S2_TX、USART1_RX、I2C2_RXDMA1Channel 6USART2_RX、I2C1_TX、TIM3_CH1DMA1Channel 7USART2_TX、I2C1_RX、TIM2_CH2这张表建议保存下来。我在实际项目中遇到过有人想把SPI1_RX配到DMA1 Channel 5结果怎么都不工作查了半天才发现通道映射搞错了。CubeMX虽然会自动分配但如果你手动改代码或者用标准库这个映射关系必须记清楚。2.3 DMA的三种工作模式单次、循环与双缓冲DMA传输模式的选择直接决定了数据流的连续性。单次模式Normal下DMA传输完指定数量的数据后就停止需要CPU重新使能才能开始下一次传输。适合一次性大批量数据传输比如SPI Flash读取一页数据。循环模式Circular下DMA传输完指定数量后自动回到起始地址继续传输形成一个环形缓冲区。适合连续数据流场景比如ADC连续采样、串口持续接收。但循环模式有个问题CPU怎么知道当前数据写到了缓冲区的哪个位置如果CPU读取速度跟不上DMA写入速度就会发生数据覆盖。双缓冲模式Double Buffer解决了这个问题。DMA在两个缓冲区之间交替写入当写满缓冲区A时自动切换到缓冲区B同时产生中断通知CPU处理缓冲区A的数据。这样CPU和DMA各用各的缓冲区互不干扰。STM32F103的DMA支持双缓冲模式通过设置DMA_InitStructure.DMA_MemoryBaseAddr和DMA_Memory1BaseAddr两个地址来实现。提示双缓冲模式在处理高速数据流时非常有用但要注意两个缓冲区的大小必须相同且中断处理函数中要及时处理数据否则DMA切换回缓冲区A时如果CPU还没处理完数据仍然会被覆盖。3. STM32F103 SPIDMA读取芯片数据的完整实现这一部分进入实战。我以STM32F103通过SPI接口配合DMA读取一颗外部芯片数据为例把CubeMX配置、代码实现、调试过程完整走一遍。这个场景在工业数据采集、传感器读取、存储器访问中非常典型。3.1 CubeMX配置的关键参数与容易配错的地方打开CubeMX选择STM32F103C8T6或其他F103型号先配置时钟树。F103最高主频72MHzAPB2总线SPI1挂在这条总线上也是72MHzAPB1总线SPI2挂在这条总线上最高36MHz。这个差异直接影响SPI的最高通信速率。SPI1的配置界面里几个关键参数需要特别注意Mode选择Full-Duplex Master全双工主机还是Receive Only Master只接收主机。如果你只需要读取从机数据不需要发送选Receive Only可以节省一根MOSI线。但大多数SPI从机芯片需要先发送命令字才能读取数据所以通常还是选Full-Duplex Master。Data Size8位还是16位。大部分SPI Flash和传感器是8位但有些ADC芯片是16位。Clock Polarity和Clock Phase根据从机芯片手册选择前面已经讲过。NSS Signal Type硬件片选还是软件片选。硬件片选由SPI外设自动控制CS引脚软件片选需要手动拉低拉高GPIO。我一般推荐软件片选因为更灵活特别是在多从机场景下。DMA配置部分在SPI1的DMA Settings里添加两个请求SPI1_RX和SPI1_TX。RX配置为Circular或Normal取决于你的应用场景TX通常配置为Normal。优先级都设为Medium或High数据宽度都选Byte与SPI数据宽度一致。这里有个容易忽略的点DMA的Memory数据宽度和Peripheral数据宽度必须匹配SPI的数据宽度。如果SPI配置为8位DMA的Peripheral和Memory数据宽度都应该是Byte。如果配成了Half Word数据会错位。3.2 代码实现从HAL库函数到寄存器层面的理解CubeMX生成的代码框架里SPI和DMA的初始化已经完成了。核心的收发函数需要自己写。HAL库提供了HAL_SPI_TransmitReceive_DMA()函数可以同时进行发送和接收。// 定义缓冲区 #define SPI_BUFFER_SIZE 16 uint8_t spi_tx_buffer[SPI_BUFFER_SIZE]; uint8_t spi_rx_buffer[SPI_BUFFER_SIZE]; // SPIDMA收发函数 void SPI_DMA_ReadWrite(uint8_t *tx_data, uint8_t *rx_data, uint16_t size) { HAL_SPI_TransmitReceive_DMA(hspi1, tx_data, rx_data, size); } // DMA传输完成回调函数 void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi-Instance SPI1) { // 数据接收完成处理rx_buffer中的数据 ProcessReceivedData(spi_rx_buffer, SPI_BUFFER_SIZE); } }这段代码看起来简单但有几个细节值得展开。HAL_SPI_TransmitReceive_DMA()内部会分别启动TX和RX的DMA通道然后使能SPI的收发。TX DMA把tx_buffer里的数据逐个搬到SPI数据寄存器RX DMA把SPI数据寄存器里的数据搬到rx_buffer。两个DMA通道并行工作SPI全双工的特性保证了发送和接收同时进行。回调函数HAL_SPI_TxRxCpltCallback在TX和RX都完成后被调用。注意HAL库的这个回调是在RX DMA完成中断里触发的TX完成中断默认不触发回调。如果你只关心接收可以只用HAL_SPI_Receive_DMA()。3.3 软件片选的控制时机一个时序问题如果使用软件片选CS引脚的控制时机非常关键。正确的流程是拉低CS - 启动SPIDMA传输 - 等待传输完成 - 拉高CS。void ReadSensorData(void) { // 拉低片选 HAL_GPIO_WritePin(SPI_CS_GPIO_Port, SPI_CS_Pin, GPIO_PIN_RESET); // 准备发送数据命令字 spi_tx_buffer[0] 0x03; // 读取命令 spi_tx_buffer[1] 0x00; // 地址高字节 spi_tx_buffer[2] 0x00; // 地址低字节 // 启动DMA传输 HAL_SPI_TransmitReceive_DMA(hspi1, spi_tx_buffer, spi_rx_buffer, SPI_BUFFER_SIZE); } // 在回调函数中拉高片选 void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi-Instance SPI1) { HAL_GPIO_WritePin(SPI_CS_GPIO_Port, SPI_CS_Pin, GPIO_PIN_SET); ProcessReceivedData(spi_rx_buffer, SPI_BUFFER_SIZE); } }这里有个坑如果在启动DMA传输后立即拉高CS传输还没完成CS就失效了从机会停止响应。必须等DMA传输完成回调触发后再拉高CS。但也不能在回调里做太多事情否则会影响下一次传输的启动时机。注意有些SPI从机芯片对CS的建立时间和保持时间有要求比如CS拉低后需要等待至少100ns才能开始时钟。STM32的GPIO翻转速度很快通常不需要额外延时但如果通信不稳定可以在CS拉低后加一个微秒级延时试试。4. I2C通信的深层机制与调试实战I2C看起来比SPI简单——只有两根线但协议本身的复杂度反而更高。时序要求严格、总线仲裁机制、时钟拉伸、上拉电阻计算每一个环节出问题都会导致通信失败。4.1 I2C时序图背后的状态机逻辑I2C的完整通信过程可以分解为几个阶段起始条件、地址帧、应答位、数据帧、应答位、停止条件。每个阶段都有严格的时序要求。起始条件START的定义是SCL为高电平时SDA从高电平跳变到低电平。停止条件STOP是SCL为高电平时SDA从低电平跳变到高电平。这两个条件必须由主机产生。地址帧是7位地址加1位读写位共8位。发送完地址后从机如果存在且地址匹配会在第9个时钟周期拉低SDA作为应答ACK。如果从机不存在或地址不匹配SDA保持高电平这就是非应答NACK。数据帧也是8位每发送完一个字节接收方都要给出ACK或NACK。主机读取数据时在最后一个字节后发送NACK告诉从机我不要了然后发送STOP条件结束通信。理解这个状态机之后用示波器或逻辑分析仪抓I2C波形时就能快速定位问题。比如看到START条件后地址帧没有ACK说明从机地址不对或者从机没上电。看到数据帧中间出现NACK可能是从机内部处理不过来需要降低通信速率。4.2 用GPIO模拟I2C什么时候需要怎么做STM32的硬件I2C外设在某些型号上存在已知的稳定性问题特别是在总线受到干扰或者从机时钟拉伸时容易卡死。很多有经验的工程师会选择用GPIO模拟I2C也就是软件I2C。软件I2C的优势是完全可控不受硬件外设状态机限制出问题时容易恢复。代价是占用CPU时间通信速率较低通常100kHz到400kHz。实现软件I2C的核心是精确控制SCL和SDA的时序。以下是一个基本的起始条件生成函数void I2C_Start(void) { SDA_HIGH(); SCL_HIGH(); delay_us(4); SDA_LOW(); delay_us(4); SCL_LOW(); delay_us(4); } void I2C_Stop(void) { SDA_LOW(); SCL_HIGH(); delay_us(4); SDA_HIGH(); delay_us(4); } void 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(); delay_us(2); SCL_HIGH(); delay_us(4); // 读取SDA电平判断ACK/NACK uint8_t ack SDA_READ(); SCL_LOW(); SDA_OUTPUT(); }延时函数的具体数值需要根据从机支持的通信速率调整。标准模式100kHz下每个时钟周期10微秒高电平时间和低电平时间各5微秒左右。快速模式400kHz下周期2.5微秒高低电平各1.25微秒。实际写的时候留一些余量因为GPIO操作本身也有开销。4.3 I2C读写EEPROM的完整代码与页写入边界问题以AT24C02为例这是一颗2Kbit256字节的I2C EEPROM在很多嵌入式系统中用来保存配置参数。它的设备地址是1010xxx其中xxx由A2/A1/A0引脚决定。字节写入的流程是START - 发送设备地址写 - 等待ACK - 发送内存地址 - 等待ACK - 发送数据 - 等待ACK - STOP。然后EEPROM内部需要大约5ms的写入周期期间不会响应任何请求。页写入是AT24C02的一个特性它内部有8字节的页缓冲区一次连续写入不能跨页。比如从地址0x07开始写8个字节会写到0x07然后回卷到0x00覆盖掉原来的数据。这个坑我在实际项目中踩过当时保存了一组配置参数读回来发现前面几个字节被后面的数据覆盖了。// 安全的EEPROM写入函数处理页边界 void EEPROM_WriteBuffer(uint8_t mem_addr, uint8_t *data, uint16_t len) { uint16_t written 0; while (written len) { // 计算当前页剩余空间 uint8_t page_remain 8 - (mem_addr % 8); uint8_t to_write (len - written page_remain) ? (len - written) : page_remain; // 执行页写入 I2C_Start(); I2C_SendByte(EEPROM_ADDR | 0x00); // 写操作 I2C_WaitAck(); I2C_SendByte(mem_addr); I2C_WaitAck(); for (uint8_t i 0; i to_write; i) { I2C_SendByte(data[written i]); I2C_WaitAck(); } I2C_Stop(); // 等待EEPROM内部写入完成 HAL_Delay(6); written to_write; mem_addr to_write; } }这个函数的核心逻辑是每次写入不超过当前页的剩余空间写完后等待EEPROM完成内部写入再继续下一页。虽然比一次性写入慢一些但保证了数据的正确性。5. DMA与中断的配合空闲中断、双缓冲与实战踩坑DMA和中断的配合是嵌入式系统中比较高级的话题。用好了能大幅提升系统效率用不好会出现数据丢失、缓冲区溢出、中断嵌套等问题。5.1 串口DMA加空闲中断接收不定长数据的标准方案串口接收不定长数据是一个经典问题。用传统的中断方式每收到一个字节就进一次中断数据量大时CPU负担很重。用DMA接收解决了CPU负担问题但DMA是定长的怎么知道一帧数据什么时候结束答案是串口空闲中断IDLE Interrupt。当串口总线在一个字节传输时间后没有新数据硬件会产生空闲中断。结合DMA就可以实现不定长数据的高效接收。配置步骤串口配置为DMA接收模式开启串口空闲中断。在空闲中断回调中计算DMA当前剩余传输数量用总长度减去剩余数量就是本次接收到的数据长度。#define RX_BUFFER_SIZE 256 uint8_t rx_buffer[RX_BUFFER_SIZE]; volatile uint16_t rx_len 0; // 启动DMA接收 void UART_StartReceive(void) { HAL_UART_Receive_DMA(huart1, rx_buffer, RX_BUFFER_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); // 停止DMA计算接收长度 HAL_UART_DMAStop(huart1); rx_len RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); // 处理数据 ProcessUARTData(rx_buffer, rx_len); // 重新启动接收 UART_StartReceive(); } HAL_UART_IRQHandler(huart1); }这个方案的关键点在于空闲中断触发后要先停止DMA再计算长度否则DMA可能还在写入计算出的长度不准确。处理完数据后重新启动DMA接收准备下一帧。提示__HAL_DMA_GET_COUNTER返回的是DMA剩余未传输的数量不是已传输的数量。总长度减去剩余数量才是实际接收到的字节数。这个逻辑我见过不少人搞反。5.2 DMA双缓冲在高速数据采集中的应用前面提到过DMA双缓冲模式这里展开讲一个实际应用场景用ADCDMA双缓冲采集音频信号。音频采样率通常44.1kHz或48kHz每个采样点16位数据流是连续的不能有断点。单缓冲模式下DMA写满缓冲区后产生中断CPU处理数据然后重新启动DMA。在CPU处理数据期间DMA是停止的这会导致采样数据丢失。双缓冲模式下DMA写缓冲区A时CPU处理缓冲区B写满A后自动切换到B并产生中断CPU处理A如此交替数据流不会中断。#define BUFFER_SIZE 512 uint16_t adc_buffer_a[BUFFER_SIZE]; uint16_t adc_buffer_b[BUFFER_SIZE]; void ADC_DMA_Init(void) { // 配置DMA双缓冲 hdma_adc.Instance DMA1_Channel1; hdma_adc.Init.Direction DMA_PERIPH_TO_MEMORY; hdma_adc.Init.PeriphInc DMA_PINC_DISABLE; hdma_adc.Init.MemInc DMA_MINC_ENABLE; hdma_adc.Init.PeriphDataAlignment DMA_PDATAALIGN_HALFWORD; hdma_adc.Init.MemDataAlignment DMA_MDATAALIGN_HALFWORD; hdma_adc.Init.Mode DMA_CIRCULAR; hdma_adc.Init.Priority DMA_PRIORITY_HIGH; HAL_DMA_Init(hdma_adc); // 配置双缓冲地址 HAL_DMAEx_MultiBufferStart(hdma_adc, (uint32_t)ADC1-DR, (uint32_t)adc_buffer_a, (uint32_t)adc_buffer_b, BUFFER_SIZE); } // DMA传输完成回调半传输和全传输都会触发 void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { // 缓冲区A写满处理A的数据 ProcessAudioData(adc_buffer_a, BUFFER_SIZE); } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { // 缓冲区B写满处理B的数据 ProcessAudioData(adc_buffer_b, BUFFER_SIZE); }双缓冲的精髓在于CPU处理数据的时间必须小于DMA写满一个缓冲区的时间。如果CPU处理太慢DMA已经切换回这个缓冲区开始写了数据还是会被覆盖。所以缓冲区大小的选择需要根据CPU处理能力和数据速率来权衡。5.3 那些年我踩过的DMA配置坑说几个实际项目中遇到的DMA问题都是文档里不会写但实际会遇到的。第一个坑DMA传输完成中断标志没有清除。STM32的DMA中断标志需要手动清除如果忘了清除中断会反复触发程序卡死在中断里。HAL库的HAL_DMA_IRQHandler会自动处理标志清除但如果你自己写中断服务函数必须手动清除对应的标志位。第二个坑DMA和CPU同时访问同一块内存。这个问题在双缓冲模式下容易出现。如果CPU正在读取缓冲区A的数据DMA突然开始写缓冲区A因为缓冲区B已经写满了数据就会出错。解决办法是使用内存屏障或者禁用DMA中断来保护临界区。第三个坑DMA传输长度设置为0。有些情况下传输长度是动态计算的如果计算结果为0DMA不会传输任何数据但也不会报错。程序看起来在运行但数据永远不更新。建议在启动DMA前加一个长度检查。第四个坑SPIDMA模式下TX和RX的DMA通道优先级设置不当。如果TX优先级高于RX可能出现TX已经发完了但RX还没接收完的情况。通常建议RX优先级不低于TX保证接收数据不会丢失。6. 从原理到工程嵌入式系统设计的几个关键决策前面讲了SPI、I2C、DMA的具体技术细节这一部分回到工程层面聊聊在实际项目中怎么做技术选型和架构设计。6.1 通信协议选型的决策框架面对一个具体的通信需求怎么决定用SPI还是I2C我通常从以下几个维度评估评估维度SPII2C最高速率几十MHz几MHz受总线电容限制引脚数量4每从机1根CS2根多设备支持每个设备独立CS地址寻址一条总线挂多个协议复杂度简单较复杂硬件成本较高引脚多较低抗干扰能力较强推挽输出较弱开漏上拉典型应用Flash、显示屏、高速ADCEEPROM、传感器、RTC如果数据速率要求高、从机数量少选SPI。如果从机数量多、速率要求不高、引脚紧张选I2C。如果两者都能满足考虑开发难度和调试便利性——SPI协议简单调试更容易。6.2 中断优先级与实时性保障在一个同时使用SPI、I2C、DMA、串口的系统中中断优先级的分配直接影响系统的实时性。STM32F103的中断优先级分为抢占优先级和响应优先级数值越小优先级越高。我的分配原则是数据流中断优先级高于控制流中断硬件错误中断优先级最高。比如DMA传输完成中断、串口接收中断的优先级要高于定时器中断、按键中断。因为数据流中断如果被延迟可能导致数据丢失而控制流中断延迟几毫秒通常不影响功能。具体到STM32F103NVIC的分组设置为Group 22位抢占优先级2位响应优先级比较常用。抢占优先级0到3响应优先级0到3。DMA中断设为抢占优先级1串口中断设为抢占优先级2定时器中断设为抢占优先级3。6.3 代码分层与可移植性设计嵌入式项目做到一定规模代码分层就变得很重要。我通常把代码分为三层硬件抽象层HAL、驱动层、应用层。硬件抽象层直接操作寄存器或调用HAL库函数封装GPIO读写、SPI收发、I2C读写等基本操作。驱动层基于硬件抽象层实现具体芯片的驱动比如AT24C02的读写、W25Q64的扇区擦除。应用层调用驱动层提供的接口实现业务逻辑。这样分层的好处是换MCU型号时只需要改硬件抽象层驱动层和应用层基本不动。换外设芯片时只需要改驱动层。我在实际项目中多次受益于这种分层设计特别是在项目后期需要更换物料时改动量非常小。7. 调试工具与方法示波器、逻辑分析仪和软件断点嵌入式调试不能只靠printf。SPI和I2C这类时序敏感的通信必须用示波器或逻辑分析仪观察波形。逻辑分析仪是调试串行通信的利器。它可以把SPI的SCK、MOSI、MISO、CS四根线的波形同时抓下来自动解析出每个字节的数据。I2C的SCL和SDA波形也能自动解析出地址、数据、ACK/NACK。我用的是一款八通道的逻辑分析仪配合开源软件抓SPI和I2C波形非常方便。示波器则更适合看信号质量。比如I2C的上升沿是否足够陡峭、SPI的时钟频率是否准确、CS的建立保持时间是否满足要求。这些参数逻辑分析仪看不出来必须用示波器。软件断点方面STM32支持硬件断点可以在不停止外设运行的情况下暂停CPU。调试DMA传输时可以在DMA完成中断的回调函数里设断点查看缓冲区的数据是否正确。但要注意断点暂停CPU时DMA可能还在运行如果DMA配置为循环模式缓冲区数据可能已经被覆盖了。提示调试DMA相关问题时建议先用单次模式确认数据传输正确再切换到循环模式或双缓冲模式。这样可以把问题范围缩小避免多个变量同时变化。8. 几个真实项目中的问题排查记录最后分享几个我在实际项目中遇到的问题和排查过程这些经验比任何教程都宝贵。案例一SPI读取数据偶尔出错。一个项目用STM32F103通过SPI读取外部ADC数据大部分时候正常但偶尔会出现数据跳变。用逻辑分析仪抓波形发现出错时SCK的时钟频率比正常时高了一倍。排查后发现是时钟树配置问题系统时钟切换时SPI的时钟源没有正确更新导致SPI时钟短暂异常。解决办法是在时钟切换后重新初始化SPI。案例二I2C总线死锁。一个系统在运行几个小时后I2C通信卡死SCL被从机拉低不放。这是I2C的经典问题从机在发送数据时被复位或干扰状态机卡在某个状态一直拉低SCL。解决办法是在I2C初始化时发送9个时钟脉冲强制从机释放总线。具体做法是把SCL配置为GPIO输出手动翻转9次然后重新初始化I2C外设。案例三DMA传输导致系统HardFault。一个项目在启用DMA后偶尔进入HardFault。排查发现是DMA的目标地址指向了一个局部变量函数返回后栈空间被回收DMA还在往那个地址写数据破坏了其他变量的值。解决办法是把DMA缓冲区定义为全局变量或静态变量确保生命周期覆盖整个DMA传输过程。案例四串口DMA接收数据错位。用串口DMA空闲中断接收数据时发现接收到的数据偶尔会多一个字节或少一个字节。排查后发现是空闲中断的清除时机问题在计算接收长度之前清除了空闲标志导致DMA计数器还没更新就读取了。正确的顺序是先读DMA计数器再清除空闲标志。这些问题的共同点是都不是代码逻辑错误而是对硬件机制理解不够深入导致的。解决它们的过程也是我对嵌入式系统理解不断加深的过程。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

FT232R驱动安装全攻略:USB转UART串口调试与常见问题排查 2026/9/20 7:33:25

FT232R驱动安装全攻略:USB转UART串口调试与常见问题排查

做嵌入式或者电子开发的朋友,十有八九都跟FT232R打过照面。这颗FTDI出品的USB转UART桥接芯片,几乎成了各种开发板、调试器、设备固件升级工具的标配——电脑上插个USB口,串口终端里立刻多出一个COM口,底层干活的其实就是它。我最早…

阅读更多 →
蜂鸟工具集Colibri实测:轻量便携的Windows效率工具箱 2026/9/20 7:33:25

蜂鸟工具集Colibri实测:轻量便携的Windows效率工具箱

如果你经常逛小众软件分享群,或者喜欢折腾 Windows 效率工具,最近应该没少看到“colibri”这个名字。它确实是个有点特别的项目——一个以蜂鸟命名的轻量级 Windows 工具集,把日常高频操作(截图、取色、文件搜索、快速启动、二维码…

阅读更多 →
实时监控GPU状态:nvidia-smi与显存/功耗/温度排查实战 2026/9/20 7:33:25

实时监控GPU状态:nvidia-smi与显存/功耗/温度排查实战

1. 实时监控GPU状态:先从一张图看懂这套体系做深度学习、跑大模型微调、维护GPU服务器的人,几乎每天都在跟显存和功耗较劲。模型跑着跑着OOM了,训练速度突然掉下来,或者显卡风扇狂转但利用率只有个位数,这些场景遇到一…

阅读更多 →
嵌入式低功耗设计:时钟门控、电源门控、DVFS的收益与风险平衡 2026/9/20 7:33:25

嵌入式低功耗设计:时钟门控、电源门控、DVFS的收益与风险平衡

1. 低功耗设计的核心矛盾:收益与风险从来不是单选题做嵌入式这行十几年,我经手的低功耗项目少说也有几十个,从早期的8位机到现在的Cortex-M4、M33,从简单的电池供电传感器到复杂的低功耗语音唤醒设备,踩过的坑比写过的…

阅读更多 →
OpenClaw:Python实现PPT自动化处理的高效工具 2026/9/20 7:33:25

OpenClaw:Python实现PPT自动化处理的高效工具

1. 项目概述:OpenClaw与PPT自动化在办公自动化领域,PPT文档的批量处理一直是个高频需求。传统手动操作不仅效率低下,还容易出错。最近我在一个企业级文档处理项目中,深度使用了OpenClaw这个开源库来实现PPT的自动化读写&#xff0…

阅读更多 →
使用 GitKraken 完成第一次开源贡献:first-contributions 项目的 fork → clone → edit → PR 实战指南 2026/9/20 7:30:25

使用 GitKraken 完成第一次开源贡献:first-contributions 项目的 fork → clone → edit → PR 实战指南

使用 GitKraken 完成第一次开源贡献:first-contributions 项目的 fork → clone → edit → PR 实战指南 【免费下载链接】first-contributions 🚀✨ Help beginners to contribute to open source projects 项目地址: https://gitcode.com/gh_mirrors…

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞