
1. 从第一性原理重新理解嵌入式系统1.1 为什么“会用”和“懂”之间隔着一道鸿沟很多人学嵌入式是从“点灯”开始的。打开CubeMX配置一个GPIO输出生成代码在while循环里翻转电平LED闪烁成就感拉满。然后学UART学I2C学SPI每个外设都跑一遍例程看起来什么都会了。但一旦项目需求稍微偏离例程——比如要求SPI用DMA接收不定长数据、I2C总线上挂多个同地址设备、DMA传输一半要动态切换缓冲区——立刻就卡住了。这个现象背后的原因很直接大多数教程教的是“怎么配置”而不是“为什么这样配置”。配置步骤是死的但工程问题是活的。你背下了I2C需要开漏输出加上拉电阻但不知道为什么所以当上拉电阻从4.7k换成10k导致通信失败时你只能靠猜。你照抄了SPI的DMA配置代码但不知道DMA的传输完成中断和空闲中断有什么区别所以面对不定长数据帧时无从下手。第一性原理的思路就是回到协议本身要解决什么问题回到硬件层面信号是怎么走的回到寄存器层面每个bit控制了什么。理解了这些配置代码只是表达你理解的一种方式而不是需要死记硬背的咒语。1.2 嵌入式系统的本质在约束中做权衡嵌入式系统最核心的特征不是“小”而是“约束”。CPU主频有限、RAM有限、Flash有限、功耗有限、成本有限。所有的技术选型本质上都是在这些约束下做权衡。举个例子为什么STM32F103的SPI最高只能到18MHzAPB2时钟72MHz的4分频而有些专用芯片能到50MHz以上因为通用MCU的SPI外设要兼顾灵活性内部逻辑更复杂信号路径更长时序余量更小。而专用芯片只需要做好一件事可以把电路优化到极致。再比如为什么I2C要用开漏输出加上拉电阻而不是像SPI那样推挽输出因为I2C是总线结构允许多个设备挂在同一组信号线上。推挽输出意味着每个设备都强行把线拉高或拉低如果两个设备同时输出不同电平就会短路。开漏输出只能拉低或者释放高阻态释放时由上拉电阻把线拉高这样就不会有冲突。代价是上升沿变缓因为上拉电阻给总线电容充电需要时间。这些“为什么”才是嵌入式系统核心知识的骨架。具体的外设配置、寄存器操作、代码实现都是在这个骨架上长出来的血肉。1.3 这份讲义适合谁能解决什么问题这份内容面向的是已经跑过例程、但想真正搞懂底层原理的开发者。如果你还在纠结怎么安装Keil、怎么新建工程那可能需要先补一补基础操作。但如果你已经能用STM32完成一些基本功能却在以下场景中感到吃力那这份讲义就是为你准备的SPI通信时好时坏示波器上看波形总觉得哪里不对但又说不清楚I2C总线上挂了三四个设备偶尔某个设备不响应重启又好了用DMA传输数据不知道什么时候该用普通模式、什么时候该用循环模式看到别人的代码里DMA加空闲中断处理串口数据想搞懂原理但找不到系统讲解想从STM32F103过渡到更复杂的项目但感觉底层知识不够扎实接下来的内容会从协议原理讲到硬件电路从寄存器操作讲到HAL库封装从单个外设讲到多外设协同。每个环节都会解释“为什么这样做”并给出实际项目中验证过的配置和代码。2. 通信协议的第一性原理SPI与I2C到底在解决什么问题2.1 同步串行通信的基本模型SPI和I2C都属于同步串行通信。拆开来看同步意味着通信双方共享一个时钟信号数据在时钟的节拍下逐位传输串行意味着数据一位一位地走而不是像并行总线那样多位同时传输。为什么嵌入式系统偏爱串行通信因为引脚资源宝贵。一个8位并行总线需要8根数据线加若干控制线而SPI只需要4根SCK、MOSI、MISO、CSI2C只需要2根SCL、SDA。引脚少意味着PCB布线简单、芯片封装可以更小、成本更低。代价是传输速率——同样时钟频率下串行比并行慢。但在大多数嵌入式场景中传感器数据量不大串行完全够用。同步通信和异步通信比如UART的核心区别在于同步通信有时钟线接收方知道每一位数据的准确时刻异步通信没有时钟线接收方靠波特率约定来采样对时钟精度的要求更高。SPI和I2C有时钟线所以对双方时钟精度的容忍度更高可以实现更高的速率。2.2 SPI简单粗暴的高速通道SPI的设计哲学是“简单”。一个主机、一个或多个从机主机产生时钟数据在时钟边沿移入移出。没有地址、没有应答、没有仲裁就是纯粹的移位寄存器对移位寄存器。SPI有四种模式由CPOL时钟极性和CPHA时钟相位组合决定。CPOL决定空闲时SCK是高还是低CPHA决定数据在第一个边沿还是第二个边沿采样。很多初学者在这里卡住其实用一个简单的方法就能记住CPHA0时数据在第一个边沿采样第二个边沿移出CPHA1时数据在第一个边沿移出第二个边沿采样。具体用哪种模式取决于从机芯片的数据手册没有商量余地。SPI的片选信号CS有两种实现方式硬件片选和软件片选。硬件片选由SPI外设自动控制传输时拉低传输完拉高时序精准。软件片选就是普通GPIO代码里手动拉低拉高。硬件片选的问题是STM32的SPI外设通常只有一到两个硬件片选引脚挂多个从机时不够用。软件片选更灵活但要注意在拉低CS和开始传输之间不能有太长的延迟否则某些从机会超时复位。实操心得用软件片选时拉低CS之后最好插入几个NOP或者短暂延时再开始SPI传输。我遇到过从机芯片要求CS建立时间至少50ns而STM32的GPIO翻转太快直接传输导致第一个bit丢失。后来在CS拉低后加了一个__NOP()就稳定了。SPI的速率可以很高。STM32F103的SPI1挂在APB2上最高72MHz经过2分频后36MHz但实际能否跑到36MHz取决于从机芯片和PCB走线质量。一般传感器芯片SPI速率在1MHz到10MHz之间SPI Flash可以到50MHz以上。速率越高对PCB走线的要求越高——线要短、要等长、要远离干扰源。2.3 I2C两根线上的多设备舞蹈I2C的设计哲学是“用最少的线连接最多的设备”。两根线SCL和SDA可以挂128个设备7位地址。每个设备有唯一的地址主机通过地址来选择通信对象。I2C的时序比SPI复杂得多。起始条件、地址帧、应答位、数据帧、停止条件每个环节都有严格的时序要求。起始条件是SCL高时SDA从高变低停止条件是SCL高时SDA从低变高。数据在SCL低时变化在SCL高时采样。应答位是接收方在第9个时钟周期把SDA拉低。为什么I2C要用开漏输出加上拉电阻前面已经解释过核心原因是总线仲裁和防止冲突。但这里还有一个实际问题上拉电阻的阻值怎么选阻值太小功耗大且可能超过设备的灌电流能力阻值太大上升沿太慢高速通信时波形还没恢复到高电平就被下一个时钟拉低了。计算上拉电阻的公式是R tr / (0.8473 × C)其中tr是上升时间要求C是总线电容。标准模式100kHz要求上升时间小于1000ns快速模式400kHz要求小于300ns。假设总线电容200pF快速模式下R 300ns / (0.8473 × 200pF) ≈ 1.77kΩ。所以快速模式常用1.8k到2.2k的上拉电阻标准模式常用4.7k到10k。注意I2C总线上挂的设备越多总线电容越大上拉电阻就要越小。但总电容不能超过400pF否则需要加I2C缓冲器或中继器。我见过一个项目挂了8个I2C设备总线电容超过600pF400kHz通信时波形完全变形降到100kHz才能勉强工作。后来加了I2C多路复用器才解决问题。I2C还有一个容易踩的坑地址冲突。有些芯片的I2C地址是固定的比如某些EEPROM的地址是0xA0如果挂两个同型号的EEPROM就会冲突。解决办法是用I2C多路复用器如TCA9548A或者选择地址可配置的芯片。另外有些芯片的地址引脚可以配置但配置方式不同——有的接高电平表示1有的接低电平表示1看数据手册时要仔细。2.4 SPI与I2C的选型逻辑什么时候用SPI什么时候用I2C这不是一个非此即彼的问题而是根据需求权衡。对比维度SPII2C引脚数4根多从机加CS2根最高速率几十MHz几MHz受总线电容限制多设备支持每个设备一根CS地址寻址两根线挂多个协议开销无纯数据移位起始、地址、应答、停止硬件复杂度简单需要开漏和上拉抗干扰能力推挽输出较强开漏输出较弱典型应用Flash、显示屏、高速ADC传感器、EEPROM、RTC选型的核心逻辑是如果数据量大、速率要求高、设备数量少选SPI如果设备多、引脚紧张、速率要求不高选I2C。有些场景两者都可以比如驱动一个OLED屏SPI和I2C版本都有SPI刷新率更高但占引脚多I2C省引脚但刷新率低。还有一个实际考虑PCB布线。SPI的时钟线是高速信号走线要短、要远离模拟信号。I2C的两根线虽然速率低但因为是开漏输出上升沿缓容易受干扰走线也要注意。如果板上同时有SPI和I2C尽量让它们走不同的层或者保持足够间距。3. STM32外设配置的底层逻辑从寄存器到HAL库3.1 GPIO的八种模式与选择依据STM32的GPIO有八种模式输入浮空、输入上拉、输入下拉、模拟输入、开漏输出、推挽输出、开漏复用、推挽复用。每种模式对应不同的硬件结构适用于不同的场景。输入浮空引脚悬空时电平不确定适合外部已经有上拉或下拉的场合。比如I2C的SDA和SCL外部有上拉电阻MCU端配置为复用开漏即可不需要内部上拉。输入上拉/下拉内部有30k到50k的弱上拉或下拉适合按键输入等简单场景。但内部上拉电阻较大驱动能力弱如果外部有强干扰还是需要外部上拉。模拟输入引脚直接连到ADC或DAC不经过施密特触发器减少数字电路对模拟信号的干扰。配置ADC引脚时必须用这个模式。推挽输出可以输出高电平和低电平驱动能力强适合驱动LED、控制使能引脚等。但两个推挽输出不能直接连在一起否则会短路。开漏输出只能拉低或释放释放时靠外部上拉电阻拉高。适合I2C、多设备共享信号线、电平转换等场景。复用推挽/开漏当引脚被SPI、UART、I2C等外设使用时需要配置为复用模式。SPI的SCK、MOSI用复用推挽I2C的SCL、SDA用复用开漏。实操心得配置GPIO时最容易犯的错误是忘记开时钟。STM32的每个外设都有独立的时钟门控不使能时钟寄存器写不进去引脚也不工作。我见过有人调试了半天I2C不通信最后发现是GPIOB的时钟没开。现在养成的习惯是任何外设初始化之前先确认对应的RCC时钟已经使能。3.2 SPI外设的配置细节STM32的SPI外设配置涉及几个关键参数波特率预分频、时钟极性、时钟相位、数据帧格式、片选管理。波特率预分频决定了SPI的时钟频率。SPI1挂在APB2上SPI2和SPI3挂在APB1上。APB2最高72MHzAPB1最高36MHz。预分频系数可以是2、4、8、16、32、64、128、256。实际SPI时钟 APB时钟 / 预分频系数。比如SPI1用8分频就是72/89MHz。时钟极性和相位要和从机匹配。大多数SPI Flash用模式0CPOL0CPHA0或模式3CPOL1CPHA1。传感器芯片的模式各不相同必须查数据手册。数据帧格式通常是8位但有些芯片支持16位。STM32的SPI支持8位和16位通过DFF位配置。如果用DMA传输数据宽度要和DMA配置匹配。片选管理有两种硬件NSS和软件NSS。硬件NSS由SPI外设自动控制但STM32的硬件NSS有时会有问题——比如在多主机场景下NSS引脚被拉低时SPI会自动进入从模式。所以大多数项目用软件NSS即普通GPIO手动控制。// SPI1初始化示例软件NSS模式08位数据9MHz void SPI1_Init(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; SPI_HandleTypeDef hspi1 {0}; // 使能时钟 __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_SPI1_CLK_ENABLE(); // PA5SCK, PA6MISO, PA7MOSI GPIO_InitStruct.Pin GPIO_PIN_5 | GPIO_PIN_6 | GPIO_PIN_7; GPIO_InitStruct.Mode GPIO_MODE_AF_PP; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, GPIO_InitStruct); // PA4CS普通推挽输出 GPIO_InitStruct.Pin GPIO_PIN_4; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull GPIO_NOPULL; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, GPIO_InitStruct); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET); // CS默认高 hspi1.Instance SPI1; hspi1.Init.Mode SPI_MODE_MASTER; hspi1.Init.Direction SPI_DIRECTION_2LINES; hspi1.Init.DataSize SPI_DATASIZE_8BIT; hspi1.Init.CLKPolarity SPI_POLARITY_LOW; hspi1.Init.CLKPhase SPI_PHASE_1EDGE; hspi1.Init.NSS SPI_NSS_SOFT; hspi1.Init.BaudRatePrescaler SPI_BAUDRATEPRESCALER_8; hspi1.Init.FirstBit SPI_FIRSTBIT_MSB; hspi1.Init.TIMode SPI_TIMODE_DISABLE; hspi1.Init.CRCCalculation SPI_CRCCALCULATION_DISABLE; HAL_SPI_Init(hspi1); }这段代码里有个细节GPIO速度配置为HIGH。SPI时钟9MHz时GPIO翻转速度要足够快否则波形会变形。STM32的GPIO速度等级有LOW、MEDIUM、HIGH、VERY_HIGH对应不同的压摆率。SPI的SCK和MOSI建议用HIGH或VERY_HIGH。3.3 I2C外设的配置与常见问题STM32的I2C外设配置比SPI复杂因为I2C协议本身有更多状态。关键参数包括时钟频率、占空比、自身地址、应答使能、地址模式。时钟频率配置为100kHz或400kHz对应标准模式和快速模式。占空比在快速模式下可以配置为2:1或16:9影响SCL高电平和低电平的时间比例。STM32F103的I2C外设有一个著名的问题在特定条件下会锁死。具体表现是I2C通信中途卡住SCL被拉低不放总线死锁。原因是STM32的I2C状态机在某些错误条件下无法自动恢复。解决办法是在检测到总线错误时手动切换SCL和SDA为普通GPIO模拟几个时钟脉冲把从机状态复位然后重新初始化I2C。// I2C总线恢复函数 void I2C_Bus_Recovery(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; uint8_t i; // 切换SCL和SDA为普通开漏输出 GPIO_InitStruct.Pin GPIO_PIN_6 | GPIO_PIN_7; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_OD; GPIO_InitStruct.Pull GPIO_NOPULL; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOB, GPIO_InitStruct); // SDA拉高SCL发9个脉冲 HAL_GPIO_WritePin(GPIOB, GPIO_PIN_7, GPIO_PIN_SET); for(i 0; i 9; i) { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_RESET); HAL_Delay(1); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET); HAL_Delay(1); } // 发送停止条件SCL高时SDA从低变高 HAL_GPIO_WritePin(GPIOB, GPIO_PIN_7, GPIO_PIN_RESET); HAL_Delay(1); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET); HAL_Delay(1); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_7, GPIO_PIN_SET); HAL_Delay(1); // 重新初始化为I2C复用开漏 GPIO_InitStruct.Mode GPIO_MODE_AF_OD; HAL_GPIO_Init(GPIOB, GPIO_InitStruct); }注意这个恢复函数要在I2C初始化之前调用或者在检测到通信超时后调用。我一般会在I2C读写函数里加超时检测超时后调用恢复函数然后重试一次。实测下来90%以上的I2C死锁都能恢复。I2C的另一个常见问题是上拉电阻。前面算过快速模式400kHz时如果总线电容200pF上拉电阻要小于1.8k。但很多开发板用的是4.7k或10k跑400kHz时波形上升沿太缓通信不稳定。如果遇到I2C通信时好时坏先用示波器看SCL和SDA的上升沿如果上升时间超过300ns就要减小上拉电阻。3.4 DMA让数据搬运不占用CPUDMA是嵌入式系统里提升效率的关键外设。没有DMA时串口每收一个字节都要进中断CPU频繁被打断效率低。有了DMA数据可以在外设和内存之间自动搬运CPU只需要在传输完成后处理一次。STM32的DMA有多个通道每个通道可以配置源地址、目标地址、传输方向、数据宽度、传输模式。传输模式有普通模式和循环模式。普通模式传输完设定数量后停止循环模式传输完后自动重新开始。DMA的传输完成中断和半传输中断是两个重要概念。传输完成中断在全部数据传输完后触发半传输中断在传输了一半时触发。半传输中断常用于双缓冲——一半数据在传输时CPU处理另一半。串口DMA接收不定长数据是一个经典场景。串口数据长度不固定DMA配置为循环模式配合串口空闲中断IDLE当总线空闲时触发中断此时DMA已经接收了若干字节CPU读取当前DMA剩余计数就能算出接收了多少数据。// 串口DMA加空闲中断接收不定长数据 #define RX_BUFFER_SIZE 256 uint8_t rx_buffer[RX_BUFFER_SIZE]; volatile uint16_t rx_len 0; volatile uint8_t rx_flag 0; void UART_DMA_Init(void) { // 串口和DMA初始化省略关键是DMA配置为循环模式 // 并使能串口空闲中断 __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE); HAL_UART_Receive_DMA(huart1, rx_buffer, RX_BUFFER_SIZE); } // 串口中断处理 void USART1_IRQHandler(void) { if(__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); HAL_UART_DMAStop(huart1); rx_len RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx); rx_flag 1; HAL_UART_Receive_DMA(huart1, rx_buffer, RX_BUFFER_SIZE); } HAL_UART_IRQHandler(huart1); }这段代码的核心逻辑是DMA循环接收空闲中断触发时计算已接收字节数置标志位重新启动DMA。主循环检测到标志位后处理数据。实操心得DMA加空闲中断处理串口数据时有一个坑是DMA的循环模式和普通模式选择。如果用普通模式每次接收完要重新配置DMA比较麻烦。用循环模式DMA自动回绕配合空闲中断读取数据效率最高。但要注意如果数据量超过缓冲区大小循环模式会覆盖旧数据。所以缓冲区要足够大或者用双缓冲。SPI通过DMA读取芯片数据也是类似思路。配置SPI的DMA接收通道启动DMA传输传输完成后中断处理。STM32F103的SPI1有专用的DMA通道DMA1通道2和3配置时要注意通道映射。// SPI1 DMA接收配置 void SPI1_DMA_Init(void) { // SPI1_RX DMA1_Channel2, SPI1_TX DMA1_Channel3 __HAL_RCC_DMA1_CLK_ENABLE(); hdma_spi1_rx.Instance DMA1_Channel2; hdma_spi1_rx.Init.Direction DMA_PERIPH_TO_MEMORY; hdma_spi1_rx.Init.PeriphInc DMA_PINC_DISABLE; hdma_spi1_rx.Init.MemInc DMA_MINC_ENABLE; hdma_spi1_rx.Init.PeriphDataAlignment DMA_PDATAALIGN_BYTE; hdma_spi1_rx.Init.MemDataAlignment DMA_MDATAALIGN_BYTE; hdma_spi1_rx.Init.Mode DMA_NORMAL; hdma_spi1_rx.Init.Priority DMA_PRIORITY_HIGH; HAL_DMA_Init(hdma_spi1_rx); __HAL_LINKDMA(hspi1, hdmarx, hdma_spi1_rx); // 使能DMA传输完成中断 HAL_NVIC_SetPriority(DMA1_Channel2_IRQn, 1, 0); HAL_NVIC_EnableIRQ(DMA1_Channel2_IRQn); }SPI DMA接收时要注意SPI是全双工发送和接收同时进行。如果只想接收需要发送 dummy 数据来产生时钟。用HAL库的HAL_SPI_Receive_DMA函数时库会自动处理发送 dummy 数据。但如果用寄存器操作要手动往DR寄存器写数据来产生时钟。4. 工程实践从原理图到代码的完整链路4.1 硬件设计中的信号完整性考量嵌入式项目从原理图开始。原理图阶段的一个小疏忽可能在PCB阶段变成大问题在调试阶段变成噩梦。SPI信号线要尽量短尤其是SCK。SCK是时钟信号频率高走线长了会产生反射和振铃。如果SCK走线超过5cm建议在源端串一个22Ω到33Ω的电阻做阻抗匹配。MISO和MOSI相对不那么敏感但也要尽量短。I2C的上拉电阻位置有讲究。上拉电阻应该放在总线的一端而不是每个设备旁边都放。如果每个设备都放上拉电阻并联后阻值变小功耗增大且可能超过设备的灌电流能力。一般整条总线只放一组上拉电阻放在靠近主机的位置。电源去耦是另一个关键点。每个芯片的电源引脚旁边都要放一个100nF的陶瓷电容越近越好。如果芯片有多个电源引脚每个都要放。大容量电容10uF到100uF放在电源入口处用于滤低频噪声。我见过一个项目因为STM32的VDDA引脚没放去耦电容ADC采样值跳动很大加了电容后立刻稳定。注意STM32F103的VDDA和VSSA是模拟电源引脚即使不用ADC也要接。VDDA通过一个磁珠或0Ω电阻连接到VDDVSSA直接连到GND。VDDA旁边要放1个100nF和1个1uF的电容。4.2 从CubeMX到Keil的工程搭建CubeMX是ST官方的图形化配置工具可以生成初始化代码。但CubeMX生成的代码只是起点实际项目要在上面做大量修改。CubeMX配置的步骤选择芯片型号、配置时钟树、配置外设、配置中断优先级、生成代码。时钟树配置是关键STM32F103最高72MHz外部晶振一般是8MHz经过PLL倍频到72MHz。如果外部晶振是12MHzPLL配置不同。时钟树配错了串口波特率会不对SPI速率也会不对。中断优先级配置要合理。NVIC的优先级分组有5种常用的是分组22位抢占优先级2位响应优先级。抢占优先级高的中断可以打断抢占优先级低的中断。DMA传输完成中断、串口空闲中断、定时器中断的优先级要根据实时性要求排列。一般串口和DMA中断优先级较高定时器中断较低。Keil5安装时要注意芯片包。STM32F1系列的芯片包是Keil.STM32F1xx_DFP安装后才能在Keil里选择STM32F103芯片。如果同时用C51和STM32Keil5可以兼容但要注意安装路径不要有中文和空格。# Keil5芯片包安装路径 # 默认在 C:\Keil_v5\ARM\PACK\Keil\STM32F1xx_DFP # 如果芯片包没装Keil里找不到STM32F1034.3 调试工具与手段调试嵌入式系统光靠printf是不够的。示波器、逻辑分析仪、调试器是三大法宝。示波器看模拟波形比如I2C的上升沿、SPI的时钟质量、电源纹波。逻辑分析仪看数字协议比如SPI的时序、I2C的地址帧、UART的数据帧。调试器ST-Link、J-Link用于单步调试、查看寄存器、设置断点。逻辑分析仪配合协议解码软件如Saleae Logic、PulseView可以自动解析SPI和I2C协议直接看到地址、数据、应答位。调试I2C时如果设备不响应先用逻辑分析仪抓波形看地址帧发出去后有没有应答。没有应答可能是地址错了、设备没供电、上拉电阻不对。ST-Link调试时可以查看外设寄存器的值。比如SPI不工作时查看SPI_CR1、SPI_SR寄存器看使能位有没有置位、TXE和RXNE标志有没有变化。这比盲目改代码高效得多。实操心得我习惯在项目初期就引出调试串口用printf输出关键信息。但printf会占用CPU时间在高速通信时可能影响时序。所以调试完成后要把printf去掉或者改成条件编译。另外SWD调试接口的SWCLK和SWDIO要引出排针方便连接调试器。4.4 一个完整的SPI DMA读取案例假设有一个SPI接口的传感器芯片需要周期性读取数据。传感器数据通过SPI输出数据长度不固定以某个字节作为帧尾。硬件连接STM32F103的SPI1PA5SCKPA6MISOPA7MOSIPA4作为CS传感器还有一个数据就绪引脚接PA0外部中断。软件流程传感器数据就绪时触发外部中断在中断中拉低CS启动SPI DMA接收DMA接收完成后拉高CS处理数据。// 外部中断处理 void EXTI0_IRQHandler(void) { if(__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_0) ! RESET) { __HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_0); // 拉低CS HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET); // 启动SPI DMA接收 HAL_SPI_Receive_DMA(hspi1, spi_rx_buffer, SPI_RX_SIZE); } } // DMA传输完成回调 void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi) { if(hspi-Instance SPI1) { // 拉高CS HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET); // 置标志位主循环处理数据 spi_data_ready 1; } }这个案例的关键点CS的拉低和拉高时机。CS拉低后要尽快启动SPI传输否则传感器可能超时。CS拉高要在DMA传输完成后不能在数据还没收完就拉高。用DMA传输完成回调来拉高CS是最稳妥的。如果传感器数据长度不固定可以用DMA加空闲检测。但SPI没有空闲中断所以要么用固定长度接收要么用定时器配合——启动DMA后开一个定时器定时器超时表示一帧数据接收完毕。5. 常见问题与排查技巧实录5.1 SPI通信失败排查清单SPI通信失败是最常见的问题之一。排查时按以下顺序检查排查项检查方法常见问题时钟使能查看RCC寄存器GPIO或SPI时钟没开GPIO模式查看GPIOx_CRL/CRHSCK/MOSI没配复用推挽片选信号示波器看CS引脚CS没拉低或拉低太晚时钟极性相位对比从机数据手册CPOL/CPHA配错波特率示波器测SCK频率分频系数太大或太小数据顺序查看从机要求MSB/LSB配错硬件连接万用表测通断虚焊或接错线我遇到最多的是CPOL/CPHA配错。有一次调试一个SPI Flash读ID一直是0xFF查了半天发现是模式配成了模式0而Flash要求模式3。改成模式3后立刻正常。所以调试SPI的第一步应该是读从机的ID寄存器确认基本通信正常再读写数据。5.2 I2C死锁与恢复I2C死锁的表现是SCL被从机拉低不放主机无法产生时钟。原因通常是从机在发送数据时被复位状态机停在某个中间状态一直等待时钟。恢复方法前面已经给了代码。核心思路是把SCL和SDA切换为普通GPIO主机发送9个时钟脉冲让从机把剩余的数据位发完然后发送停止条件从机状态机复位。预防I2C死锁的措施在I2C读写函数里加超时检测超时后调用恢复函数。另外I2C的时钟频率不要超过从机支持的最高频率。有些从机标称支持400kHz但实际在400kHz下容易出错降到100kHz就稳定。注意I2C总线上如果有一个设备一直拉低SDA整个总线就瘫痪了。所以I2C设备最好有独立的电源控制引脚出问题时可以断电重启。或者用I2C多路复用器把设备分组一组出问题不影响其他组。5.3 DMA传输的常见坑DMA传输有几个容易踩的坑第一个坑是数据宽度不匹配。源地址和目的地址的数据宽度要一致否则传输的数据会错位。比如SPI数据寄存器是8位DMA内存地址也应该是8位。如果配成16位传输的数据会变成两个字节合并。第二个坑是DMA通道映射错误。STM32F103的DMA1有7个通道每个通道对应固定的外设请求。SPI1_RX是DMA1_Channel2SPI1_TX是DMA1_Channel3USART1_RX是DMA1_Channel5USART1_TX是DMA1_Channel4。配错通道DMA不工作。第三个坑是DMA传输完成中断标志没清除。在中断处理函数里要清除对应的标志位否则中断会反复触发。HAL库的HAL_DMA_IRQHandler会自动清除标志但如果用寄存器操作要手动清除。第四个坑是DMA和CPU同时访问同一块内存。DMA在传输时CPU如果修改了源数据或读取了目标数据可能导致数据不一致。解决办法是用双缓冲或者DMA传输完成后再让CPU访问。5.4 嵌入式系统设计师的实战建议如果你在准备嵌入式系统设计师考试或者想系统提升嵌入式能力以下几点建议来自实际项目经验第一不要只跑例程。例程是别人嚼过的馍跑通了不代表你懂了。试着改参数、改配置看现象怎么变化。比如把SPI波特率预分频从8改成2看通信是否还正常用示波器看波形变化。第二学会看数据手册和参考手册。数据手册Datasheet讲引脚定义、电气特性、封装信息。参考手册Reference Manual讲外设的寄存器、工作模式、时序参数。中文资料虽然方便但翻译可能有误关键参数要以英文原版为准。第三积累调试经验。每次遇到问题记录现象、排查过程、解决方法。时间长了你会形成自己的排查套路。比如SPI不通信先查时钟、再查GPIO、再查片选、再查模式按顺序来不遗漏。第四理解比记忆重要。STM32的寄存器有几百个不可能全记住。但理解了外设的工作原理需要时查手册就能找到对应的寄存器。比如知道了SPI的传输流程是“写DR启动传输读DR获取数据”就能在手册里找到DR寄存器的地址和位定义。第五动手做完整项目。从原理图设计、PCB布线、焊接、调试到代码编写完整走一遍。哪怕是一个简单的温湿度计也能让你学到很多——电源怎么设计、I2C上拉电阻怎么选、PCB怎么布局、代码怎么分层。我在实际项目中体会最深的一点是嵌入式系统的核心能力不是写代码而是定位问题。代码写错了可以改但问题定位不准改代码就是瞎猜。而定位问题的能力来自于对底层原理的理解和对调试工具的熟练使用。示波器、逻辑分析仪、调试器这三个工具用好了80%的问题都能快速定位。剩下的20%靠经验和直觉。