ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SPI+DMA高效读取传感器数据:GD32F103实战优化CPU占用

SPI+DMA高效读取传感器数据:GD32F103实战优化CPU占用 1. 为什么把SPI交给DMA这步棋到底省在哪1.1 从一次CPU占用报警说起做伺服电机位置环的时候我遇到一个很现实的问题主控芯片要同时跑电流环、速度环、CANopen通信还要定期读编码器角度。当时用的是GD32F103SPI读取一个14位磁编码器MT6701的角度信息一开始图省事直接用查询方式收发数据。结果某次用调试器测量CPU负载发现主循环里最耗时的就是那段SPI读取代码。当时SPI时钟配在9MHz读取一次角度需要先发一个字节命令再收两个字节数据单个读取周期纯数据时间是16bit / 9MHz约1.8微秒。看上去不长但加上查询标志位的循环判断、函数调用、局部变量处理实际CPU被占住的时间往往接近10微秒。一旦控制频率跑到1kHz加上SPI Flash擦写、OLED刷新、其他传感器采集CPU的空闲时间被压得非常小。这时候我下定决心把SPI通信改成DMA搬运。这个方案的本质是用DMA接管“把数据从SPI数据寄存器搬到内存缓冲区”的过程CPU在传输期间不需要干等。对GD32F103这种Cortex-M3内核的芯片来说DMA外设是独立于CPU运行的硬件引擎配置好后它可以自己完成搬运工作传输完成再通过中断告诉CPU取数。对于持续、重复、数据量明确的SPI通信这几乎是降CPU占用最直接的手段。1.2 DMA到底在搬什么不经过CPU的“专职搬运工”很多初学者把DMA想得很玄其实它就是一个独立的数据搬运电路。普通SPI收发模式下CPU要反复执行“写数据寄存器”“等待发送缓冲区空”“读取数据寄存器”这样的指令每次读写都要经过CPU核心。DMA模式则完全不同外设的请求信号会直接触发DMA控制器由DMA把外设寄存器里的数据搬运到指定内存地址或者把内存里的数据喂给外设寄存器全程不经过CPU的ALU和寄存器堆。用生活里的例子类比普通模式等于老板亲自去仓库取货、送货每送一趟都要签字、确认DMA模式等于老板雇了个搬运工只需要告诉他“货在哪个仓库、送到哪个位置、搬多少件”然后老板就可以去开别的会了搬运工搬完会按个铃铛通知老板。对SPI来说搬运工搬的东西非常具体发送方向是“从内存缓冲区搬一个字节到SPI_DATA寄存器”接收方向是“从SPI_DATA寄存器搬一个字节到内存缓冲区”。每当SPI外设的发送缓冲区空TXE或者接收缓冲区非空RXNE它就会给DMA发出一个请求DMA响应请求后搬运一个数据单元。MCU主频72MHz或108MHz下DMA搬运一个字节只需要若干个HCLK周期而SPI通信本身受SCK时钟限制速度再快也有上限DMA完全跟得上。这个设计替我解决了一个很关键的问题CPU不需要关心SPI总线上的每个字节是怎么进去、怎么出来的只需要在传输开始前配置好参数传输结束后处理结果。尤其当你要连续读取几十甚至上百字节时查询方式和DMA方式的CPU占用差距会非常明显。1.3 什么时候值得用DMA什么时候是杀鸡用牛刀并不是所有SPI场景都需要上DMA。这句话我每次带人或写笔记都会强调。判断标准很简单数据传输是否频繁、数据量是否足够大、CPU是否需要同时处理其他高优先级任务。如果只是偶尔读一下温度传感器每秒钟读一次数据量就两个字节查询方式一个函数就搞定了代码也容易排查。这种场景上DMA反而增加了配置复杂度中断处理、DMA通道管理、缓冲区生命周期都要考虑投入产出比很低。但如果你的系统里有1kHz的编码器采集、有SPI Flash多页读写、有显示刷新或者有多个SPI从机设备需要轮询那DMA带来的收益就非常明显。我甚至见过一个项目把SPI读取PDK传感器数组、CAN通信、内存拷贝全部挂在DMA上主循环只做协议栈状态机CPU占用率从70%降到了20%出头。所以这个方案不是炫技是真实需求驱动。2. GD32F103 SPI与DMA的底层逻辑2.1 SPI四根线背后的约定SPI这种总线接法不复杂主机和从机之间就四根线SCK时钟、MOSI主机输出从机输入、MISO主机输入从机输出、CS片选。难点在于通信双方的约定也就是CPOL时钟极性和CPHA时钟相位的选择。CPOL决定SCK空闲时是高电平还是低电平CPHA决定数据在第一个边沿采样还是第二个边沿采样。用生活化的方式理解两个人约好在某个固定时间点说话CPOL是约定“不说话的时候是安静还是举个牌子”CPHA是约定“听到第一个节拍还是第二个节拍再开口”。两边必须完全匹配否则数据就会错位。在GD32F103的标准库里时钟极性和相位是合并配置的一个成员变量clock_polarity_phase就能完成。常用的组合有SPI_CK_PL_LOW_PH_1EDGE空闲低电平、第一个边沿采样对应SPI Mode 0SPI_CK_PL_LOW_PH_2EDGE空闲低电平、第二个边沿采样对应SPI Mode 1SPI_CK_PL_HIGH_PH_1EDGE空闲高电平、第一个边沿采样对应SPI Mode 2SPI_CK_PL_HIGH_PH_2EDGE空闲高电平、第二个边沿采样对应SPI Mode 3以MT6701这类磁性角度传感器为例通常工作在Mode 0或Mode 1具体以数据手册时序图为准。我遇到过不止一次“SPI通信不生效”的情况最后拿逻辑分析仪一抓波形发现就是CPOL或CPHA配反了从机接收到的数据每一位都是错位的看起来像乱码。排查这类问题最好的工具就是逻辑分析仪不要靠猜。2.2 DMA请求映射不是随便哪个通道都能接GD32F103和STM32F103在DMA映射上有一定相似性但也有自己的特点。GD32F103内部有DMA0和DMA1两个DMA控制器每个控制器下都有7个通道。每个外设的DMA请求信号是固定连接到某个控制器的某个通道上的不是说你随便配置一个通道就能工作。拿GD32F103C8T6来说SPI0也就是很多人习惯称呼的SPI1的接收请求对应DMA0的通道2发送请求对应DMA0的通道3SPI1的接收请求对应DMA0的通道4发送请求对应DMA0的通道5。使用的时候一定要对照数据手册里的DMA请求映射表确认不同封装、不同系列可能有一点点差异。网上很多代码是STM32F103的从命名习惯上会把DMA0叫DMA1通道号不变但还是要细心核对。这里有个实用技巧配置DMA之前先把外设时钟和GPIO的问题解决掉。如果SCK上没有波形MISO上全是高电平那问题大概率不在DMA而在SPI本身没跑起来。DMA只是搬运工前提是SPI这个“货物存放点”先正常运转。2.3 普通模式还是循环模式先看清楚数据形式GD32的DMA支持两种模式普通模式和循环模式。普通模式下DMA计数器减到0就停止每启动一次传输传输指定数量的数据后关闭通道循环模式下计数器减到0后自动恢复初值DMA一直在幕后干活。选择哪种模式取决于你的从机设备是“一次性读多少就完事”还是“持续不断往外吐数据”。我项目里读MT6701角度是主机发命令、从机回数据的交互式读取数据量固定用普通模式就够了。每次读取完成后DMA停止主循环或者中断里处理数据下一次读取时重新配置传输数量并启动DMA逻辑很清晰。但如果你做的是SPI ADC连续采样从机在不断转换并更新数据寄存器主机需要一直发时钟才能采到每一个样品这时候循环模式就非常合适。DMA自动回绕CPU只需要在缓冲区半满、全满时来取数据。当然循环模式也有代价缓冲区会被持续覆盖处理不及时数据就丢了需要配合半传输中断、传输完成中断做双缓冲或者乒乓缓冲。3. 从零配置SPIDMA代码级的实操过程3.1 先把时钟、GPIO和SPI外设点亮配置SPIDMA的第一步不是写DMA代码而是保证SPI本身工作正常。GD32F103的SPI0引脚一般是PA4CS、PA5SCK、PA6MISO、PA7MOSI。先用标准库初始化时钟和GPIO。rcu_periph_clock_enable(RCU_GPIOA); rcu_periph_clock_enable(RCU_SPI0); rcu_periph_clock_enable(RCU_DMA0); gpio_init(GPIOA, GPIO_MODE_AF_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_5 | GPIO_PIN_7); gpio_init(GPIOA, GPIO_MODE_IN_FLOATING, GPIO_OSPEED_50MHZ, GPIO_PIN_6);注意MISO引脚不要配成复用推挽它本身是输入方向配成浮空输入或带上拉的输入。如果从机MISO是开漏输出可能还需要外部上拉电阻。SPI外设参数配置里有几个点要特别留意。prescale决定SPI时钟分频GD32F103的SPI挂在APB2总线上我通常选择8分频或者16分频让SPI时钟保持在9MHz左右这样绝大多数SPI从机都支持速度不算慢稳定性也有保障。frame_size一般用8位数据帧除非你的从机明确要求16位帧。nss一定要用软件管理也就是SPI_NSS_SOFT因为硬件自动NSS容易干扰你手动控制CS时序。spi_parameter_struct spi_init_struct; spi_struct_para_init(spi_init_struct); spi_init_struct.trans_mode SPI_TRANSMODE_FULLDUPLEX; spi_init_struct.device_mode SPI_MASTER; spi_init_struct.frame_size SPI_FRAMESIZE_8BIT; spi_init_struct.clock_polarity_phase SPI_CK_PL_LOW_PH_1EDGE; spi_init_struct.nss SPI_NSS_SOFT; spi_init_struct.prescale SPI_PSC_8; spi_init_struct.endian SPI_ENDIAN_MSB; spi_init(SPI0, spi_init_struct); spi_enable(SPI0);3.2 打开SPI向DMA发出的“请求信号”SPI外设和DMA之间的“牵线搭桥”是通过SPI控制寄存器里的DMA请求使能位完成的。GD32标准库里对应两个函数spi_dma_enable(SPI0, SPI_DMA_TRANSMIT)和spi_dma_enable(SPI0, SPI_DMA_RECEIVE)。一个容易被忽略的细节如果你的目的是“从从机读取数据”接收DMA当然要开但发送DMA也建议同时开。原因是SPI主机必须提供SCK时钟而SCK时钟只有在主机发送数据时才会产生。如果你只开接收DMA而不发送任何数据SCK根本不翻转从机也就不会把数据送出来。实际操作中我会定义一个用于发送的缓冲区里面填充0x00或0xFF配合DMA发送同时打开接收DMA一收一发并行进行。这一点很像现场总线里“你问了别人才答”的机制主机先给出足够的时钟沿从机才能把数据放在MISO上。很多时候初学者只关心接收忘记发送结果读回来的数据全是0xFF就是因为没有时钟驱动从机输出。3.3 DMA通道初始化像传送带一样配置DMA的初始化参数中最核心的几个是外设地址、内存地址、传输方向、传输数量、地址增量模式、数据宽度、优先级、循环模式。dma_parameter_struct dma_init_struct; dma_struct_para_init(dma_init_struct); dma_init_struct.periph_addr (uint32_t)(SPI0-DATA); dma_init_struct.periph_inc DMA_PERIPH_INCREASE_DISABLE; dma_init_struct.memory_addr (uint32_t)rx_buffer; dma_init_struct.memory_inc DMA_MEMORY_INCREASE_ENABLE; dma_init_struct.direction DMA_PERIPHERAL_TO_MEMORY; dma_init_struct.number 16; dma_init_struct.periph_memory_width DMA_PERIPH_DATA_WIDTH_8BIT; dma_init_struct.priority DMA_PRIORITY_HIGH; dma_init_struct.circular_mode DMA_CIRCULAR_MODE_DISABLE; dma_init(DMA0, DMA_CH2, dma_init_struct);在这个结构体里periph_addr固定填SPI0的数据寄存器地址而且地址增量必须禁用因为SPI数据寄存器永远在同一个位置memory_addr填我们的接收缓冲区首地址地址增量使能这样DMA每接收一个字节就会自动写到下一个内存单元。数据宽度方面SPI数据寄存器是8位有效配成DMA_PERIPH_DATA_WIDTH_8BIT内存也配8位避免半字、字搬移时出现多余数据交换。发送方向的DMA配置方向反过来外设地址还是SPI数据寄存器内存地址换成发送缓冲区地址方向选择DMA_MEMORY_TO_PERIPHERAL。关于优先级我的习惯是接收DMA优先级设高一点发送DMA可以稍微低一点。实际项目中SPI通常不是唯一的外设DMA0上可能还有其他通道优先级配置会影响响应速度。如果系统里同时有串口DMA和SPI DMA而你的SPI从机要求严格的时序把SPI的DMA通道优先级调高会稳很多。3.4 启动传输的正确顺序与完成判断启动顺序是个容易踩坑的细节。我建议的顺序是先配置好两路DMA的传输参数再使能DMA通道然后拉低CS片选最后打开SPI的DMA请求。这样的好处是DMA通道已经准备好接收SPI外设发来的事件请求CS拉低后从机已经进入通信状态SPI一旦开始工作数据流就能无缝衔接。void spi0_dma_exchange(uint8_t* tx_buf, uint8_t* rx_buf, uint16_t len) { dma_memory_address_config(DMA0, DMA_CH3, (uint32_t)tx_buf); dma_transfer_number_config(DMA0, DMA_CH3, len); dma_channel_enable(DMA0, DMA_CH3); dma_memory_address_config(DMA0, DMA_CH2, (uint32_t)rx_buf); dma_transfer_number_config(DMA0, DMA_CH2, len); dma_channel_enable(DMA0, DMA_CH2); gpio_bit_reset(GPIOA, GPIO_PIN_4); spi_dma_enable(SPI0, SPI_DMA_TRANSMIT); spi_dma_enable(SPI0, SPI_DMA_RECEIVE); }完成判断方面接收DMA的传输完成中断是比较可靠的判定点。当接收端DMA把最后一个字节从SPI数据寄存器搬到内存缓冲区时说明从机数据已经全部进入内存了。在这个中断里我们可以清标志、拉高CS、置一个全局标志告诉主循环数据已就绪。需要提醒的是不要在中断里做太重的运算。SPIDMA方案的目的本来是省CPU如果中断里做大量数据处理那跟查询方式没有本质区别。正确做法是中断里只置标志主循环或任务里再解析数据。3.5 实战通过DMA读取MT6701角度MT6701是一款磁性角度编码器芯片支持SPI接口常用于电机转子位置检测。它的通信模式就是典型的主机发时钟、从机回数据主机发一个16位的读命令帧从机在MISO线上返回包含角度信息的16位数。要注意的是具体每一帧各位的定义要严格对照芯片手册这里讲的是DMA使用的完整流程。volatile uint8_t spi_dma_done 0; uint16_t mt6701_read_angle_dma(void) { static uint8_t tx[2] {0x00, 0x00}; static uint8_t rx[2] {0, 0}; uint16_t angle; spi_dma_done 0; spi0_dma_exchange(tx, rx, 2); while(!spi_dma_done); angle ((rx[0] 8) | rx[1]) 0x3FFF; return angle; }这段代码先清空完成标志接着启动DMA传输然后等待完成。虽然这个函数整体还是阻塞式的但阻塞等待的是DMA完成事件而不是逐字节等待SPI标志位CPU在等待期间如果系统支持可以调度其他任务。如果想要完全非阻塞把读取函数改成“启动传输后立即返回”在DMA完成中断里置标志主循环再查询标志取数效果会更好。一个值得注意的小细节rx[0]和rx[1]的接收顺序。DMA是从SPI数据寄存器按字节顺序搬运的第一个SCK周期收到的数据会先落在rx[0]第二个落在rx[1]。如果发现角度数据前后字节颠倒了多半是时序位移了一个字节需要检查CPOL/CPHA或者发送的首字节内容。4. 实测中踩过的坑与排查思路实录4.1 第一个字节总是0xFF数据从第二个字节才开始对齐这是SPIDMA接收时最经典的问题。现象是明明设置了15个字节的接收长度但从机回的数据在缓冲区里整体偏移了第一个字节是垃圾值0xFF或0x00后面14个才是有效数据。原因通常有两个。第一个是接收DMA使能前SPI数据寄存器里已经残留了一个历史数据DMA一使能就把这个残留值搬到了缓冲区的第0位。第二个是主机开始发送第一个数据时从机还没来得及把有效数据放到MISO上SPI的RXNE事件就已经生成了于是DMA把这个无效数据搬走了。解决办法是在拉低CS和启动接收DMA之前先做一次伪读把SPI数据寄存器里的残留数据清掉或者干脆在使能接收DMA之前先让SPI的RXNE标志清零。更稳妥的做法是每次传输完成后把接收缓冲区清一遍避免上一次残留影响下一次传输的判断。4.2 DMA完成但SPI的BSY还拉着片选不敢拉高DMA接收完成中断触发后并不意味着SPI总线已经完全静止。SPI外设有一个BSY标志表示SPI还在工作状态可能在处理最后一位数据。如果一看到DMA完成就立刻拉高CS可能会截断最后一个字节的接收导致最后一位数据采样错误。我在项目里完全没遇到这个问题直到某次把SPI时钟从18MHz降到9MHz时发现接收数据概率性地出现最后一比特错误。后来在DMA完成中断里加了BSY等待问题就消失了。while (spi_i2s_flag_get(SPI0, SPI_FLAG_BSY) SET); gpio_bit_set(GPIOA, GPIO_PIN_4);这个等待一般很快只有几个时钟周期不会影响实时性。类似地如果使用硬件自动CS可能不存在这个问题但硬件CS的时序不一定符合每个从机的要求。我更推荐软件CS加BSY等待的组合。4.3 连续读取时偶尔跳变多半是缓冲区被覆盖用循环模式连续采集数据时一个容易忽略的问题是缓冲区覆盖。DMA在后台不断搬运数据如果你在主循环里取数的速度跟不上DMA的搬运速度那么上一帧数据还没处理完下一帧数据就已经写进来了读到的数据自然会出现跳变。解决思路有两种。第一种是加大缓冲区给CPU更多处理时间第二种是使用双缓冲机制。GD32F103的DMA支持双缓冲模式DMA会在缓冲区A和缓冲区B之间自动切换当DMA正在往缓冲区B写数据时CPU可以放心处理缓冲区A的数据两边互不干扰。如果没有双缓冲也可以用半传输中断和传输完成中断配合。比如一个256字节的循环缓冲区DMA搬满128字节触发半传输中断这时处理缓冲区前半段搬满256字节触发传输完成中断这时处理缓冲区后半段。这样每个缓冲区都有至少半帧数据的处理时间窗口。4.4 其他高频问题速查表现象可能原因排查方法SCK无波形GPIO复用没配好或SPI外设时钟没开用逻辑分析仪量SCK引脚确认GPIO初始化和RCU时钟MISO一直是高电平从机没被CS选中或模式配置错误检查CS时序核对CPOL/CPHADMA不触发中断DMA通道映射错误或传输数量配置为0核对DMA请求映射表检查通道使能数据错位CPOL/CPHA不对或首字节无效用逻辑分析仪对照时序图逐位检查连续传输卡死循环模式下未处理半传输中断使用双缓冲或加大缓冲区低速率正常高速率出错SPI时钟超过从机上限降低PSC分频值查看从机最大SPI频率5. 稳定传输的几个习惯与进一步扩展5.1 我在项目里固定下来的SPI习惯经过这些调试我总结出几个固定的操作习惯写在这里给大家参考。第一每次改完SPI配置先别急着写DMA代码用一个最简单的循环发送方式看一眼波形。SCK是否正常翻转、CS低电平期间是否稳定、MISO的数据位是否清晰可见。确认SPI底层OK了再接DMA问题会清晰很多。第二接收缓冲区一定要加volatile修饰。否则编译器优化时可能认为缓冲区内容不会被修改导致数据搬运完成后主循环读到的还是旧值。这属于那种“代码看起来没问题但一开O2优化就出错”的隐蔽问题。第三DMA完成中断里只做轻量操作。置标志位、发信号量、拉高CS这些事可以做但不要在中断里解析协议、计算角度、操作结构体链表。把这些挪到主循环或任务里一方面减少中断响应时间另一方面避免临界区资源竞争。第四无论是软件CS还是硬件CS都要确认从机的片选时序要求。有些从机要求CS低电平要保持几个时钟周期有些要求最后一个SCK边沿之后CS再等一段时间才能拉高。这些时序细节不是DMA能替你解决的只能靠软件控制。5.2 这些场景其实不需要上DMADMA虽好但不是万能药。数据量小、频率低、实时性要求宽松的场景查询方式依然是简单可靠的选择。我个人的判断标准是平均每秒SPI收发次数超过100次或者单次收发数据量超过4字节或者CPU负载已经超过50%三个条件满足任意一个才值得认真考虑DMA。如果只是低频、小数据量的控制命令交互查询式SPI代码简单、调试方便出问题的概率反而低。5.3 在FreeRTOS里用信号量处理DMA完成事件如果你的系统跑FreeRTOS可以在DMA完成中断里释放一个二进制信号量任务中等待这个信号量收到后再处理数据。这种方式让任务在等待期间进入阻塞态完全不占用CPU比轮询标志位更高效。需要注意FreeRTOS的中断优先级约束中断级API只能在高优先级中断里调用DMA中断优先级不能超过configMAX_SYSCALL_INTERRUPT_PRIORITY的数值范围否则信号量操作可能无效。实际使用中遇到“DMA完成但任务收不到信号量”的情况多半是中断优先级设置超过了FreeRTOS提醒阈值或者中断忘了调用portYIELD_FROM_ISR触发调度。关于SPI中断在FreeRTOS中的等级设置我的经验是DMA中断优先级要尽量高于其他可屏蔽中断但必须低于FreeRTOS允许调用API的最高优先级。否则可能会因为中断嵌套导致SPI时序被拉得参差不齐出现偶发性错位。这套方案不只是MT6701可以用SPI Flash、ADC采集、显示屏刷屏、传感器轮询本质上都是同一套思路硬件负责搬CPU负责算。希望这篇记录能帮你少走几趟弯路。
返回列表