十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

STM32硬件I2C+DMA驱动JY901S与OLED显示实战

STM32硬件I2C+DMA驱动JY901S与OLED显示实战 1. 从JY901S的数据手册到STM32的I2C外设先搞清楚你在跟谁打交道JY901S这颗模块在姿态测量圈子里出镜率很高三轴加速度、三轴角速度、三轴磁力计加上气压计输出姿态角、四元数、原始传感器数据串口和I2C两种接口都支持。很多人第一次上手直接拿串口跑通就收工了等到项目里串口资源紧张、或者需要挂多个传感器的时候才回头考虑I2C。这时候问题就来了I2C的时序比串口挑剔得多STM32的硬件I2C又素有难调的名声再叠加上DMA三个变量搅在一起调试起来容易让人怀疑人生。这篇内容就是把这套组合拳拆开讲清楚。目标读者是已经会用CubeMX建工程、能点灯、能跑串口的STM32开发者对I2C协议有概念但没深挖过时序细节对DMA停留在听说过能减轻CPU负担的层面。读完之后你应该能做到用硬件I2C加DMA把JY901S的数据稳定读进来解析出姿态角再通过OLED实时显示整个链路跑通且不占用CPU轮询时间。先明确一个前提JY901S的I2C从机地址是0x507位地址这是模块出厂默认值可以通过上位机软件修改但绝大多数场景下不需要动。它的寄存器是16位地址宽度每个数据占2个字节高字节在前。这一点和常见的EEPROM8位寄存器地址不一样写代码的时候如果照搬EEPROM的读写逻辑地址会错位读出来的全是垃圾数据。我在第一次调试时就栽在这里示波器上看波形完全正常ACK也都有但数据就是不对后来翻手册才发现寄存器地址是16位的。硬件连接上JY901S的SDA和SCL需要接上拉电阻典型值4.7kΩ到10kΩ。模块内部其实已经带了上拉但如果你用的杜邦线比较长、或者总线上挂了多个设备建议额外并一个4.7kΩ上去。我遇到过一根20cm杜邦线导致通信不稳定的情况换了短接线加外部上拉之后问题消失。I2C总线的电容负载是有上限的线越长、挂的设备越多上升沿越缓到了400kHz速率下就容易出错。STM32这边我选的是STM32F103C8T6也就是最常见的蓝板。它的I2C1挂在PB6SCL和PB7SDA上复用开漏输出模式。这里有个细节CubeMX里配置I2C引脚时GPIO的模式会自动设为Alternate Function Open Drain不需要你手动改。但如果你用的是软件I2C那就得自己把引脚设成开漏输出否则总线会被推挽输出拉死通信直接挂掉。开漏加外部上拉是I2C的电气基础原因是I2C支持多主多从任何设备都可以把总线拉低但不能主动拉高拉高靠上拉电阻完成。这个机制保证了不会出现两个设备一个输出高一个输出低的短路情况。DMA的角色在这里是搬运工。I2C的接收寄存器DR每收到一个字节就会触发一次中断或者DMA请求如果不用DMA你就得在中断里一个字节一个字节地读或者轮询等待。JY901S一次姿态角读取需要读6个字节Roll、Pitch、Yaw各2字节如果再加上加速度和角速度一次读几十个字节很常见。用DMA把这些字节自动搬到内存数组里CPU只需要在DMA传输完成中断里处理数据就行中间不用管。这就是硬件I2C加DMA的核心价值把CPU从字节级的搬运工作中解放出来。2. CubeMX里的I2C与DMA配置每个参数背后都有原因打开CubeMX选好芯片型号之后第一步是配置时钟。STM32F103的I2C1挂在APB1总线上最高36MHz。I2C的时钟源就是APB1CubeMX会自动根据你设的I2C速率计算分频值。标准模式100kHz快速模式400kHz。JY901S支持400kHz我一般直接用快速模式读数据快一倍。但如果你总线上的上拉电阻偏大比如10kΩ400kHz下波形上升沿可能不够陡那就降到100kHz先用着等硬件调好了再提速。I2C的参数配置里Clock Speed设400000HzDuty Cycle选2:1这是快速模式下的标准占空比。Analog Filter和Digital Filter都使能模拟滤波器抑制毛刺数字滤波器可以设一个合适的值我一般设0或者1设太大反而会把正常的窄脉冲滤掉。Own Address不用管主机模式用不到。DMA的配置在DMA Settings标签页里。点Add选I2C1_RX模式选Normal还是Circular这取决于你的读取策略。如果是一次性读固定长度然后处理用Normal每次读之前重新配置DMA。如果是持续不断地读用CircularDMA会自动循环填充缓冲区。我倾向于用Normal加手动触发因为JY901S的数据读取有明确的寄存器地址和长度不是流式数据用Normal更可控。DMA的优先级设Medium或者High都行I2C的DMA请求不像SPI那么密集Medium足够。Memory和Peripheral的数据宽度都设Byte因为I2C的数据寄存器是8位的。Memory地址自增使能Peripheral地址不自增因为I2C的DR寄存器地址是固定的。这些设置看起来琐碎但错一个就可能导致DMA搬错数据或者搬不完。NVIC里要开DMA1_Channel6_IRQnI2C1_RX对应的DMA通道和I2C1_EV_IRQn。DMA中断用来通知传输完成I2C事件中断用来处理错误和状态变化。有些人只开DMA中断不开I2C中断跑起来好像也没问题但一旦出现总线错误比如从机没应答没有I2C中断你就不知道发生了什么程序会卡死在等待循环里。生成代码之后先别急着写业务逻辑把I2C的初始化代码翻出来看一眼。HAL库的HAL_I2C_Init里会调用I2C_Init里面根据你设的Clock Speed计算CCR和TRISE寄存器的值。TRISE这个参数很多人忽略它跟总线的最大上升时间有关标准模式是1000ns快速模式是300ns。CubeMX会根据你选的模式自动填但如果你手动改了时钟树导致APB1频率变了TRISE可能就不对了。我一般会在生成代码后确认一下hi2c1.Init结构体里的值确保和预期一致。还有一个容易踩的坑I2C的GPIO速度。CubeMX默认给I2C引脚设的GPIO Speed是Low在100kHz下没问题但400kHz下建议改成High。GPIO速度影响的是引脚输出驱动器的翻转速率速度不够会导致波形边沿变缓等效于增加了上升时间。这个细节在CubeMX的GPIO配置页面里改找到PB6和PB7把Maximum output speed改成High。3. JY901S的寄存器读写逻辑16位地址是第一个拦路虎JY901S的I2C读写协议和标准I2C存储器操作类似但寄存器地址是16位的。写操作时主机先发从机地址加写位0x501 | 0收到ACK后发寄存器地址高字节再发低字节然后是要写入的数据。读操作稍微绕一点先发从机地址加写位发寄存器地址高字节和低字节然后发重复起始条件Repeated Start再发从机地址加读位之后从机开始返回数据。HAL库提供了HAL_I2C_Mem_Read和HAL_I2C_Mem_Write两个函数专门用来读写带寄存器地址的I2C设备。关键参数是MemAddressSizeJY901S要设成I2C_MEMADD_SIZE_16BIT。如果你设成8BITHAL库只会发一个字节的寄存器地址JY901S收到的地址就错了返回的数据自然不对。这个参数在函数调用时指定不是初始化时设的所以每次调用都要写对。// 读取JY901S的姿态角数据寄存器地址0x3D开始共6字节 uint8_t jy901s_rx_buf[6]; HAL_I2C_Mem_Read_DMA(hi2c1, (0x50 1), // 从机地址左移一位加读写位 0x3D, // 寄存器地址 I2C_MEMADD_SIZE_16BIT, // 16位地址 jy901s_rx_buf, // 接收缓冲区 6); // 读取长度上面这段代码用的是DMA方式函数名带_DMA后缀。调用之后函数立刻返回DMA在后台搬运数据搬完之后触发DMA传输完成中断。你需要在中断回调里处理数据。HAL库的DMA完成回调是HAL_I2C_MemRxCpltCallback注意这个回调是弱定义的你要在自己的代码里重写它。void HAL_I2C_MemRxCpltCallback(I2C_HandleTypeDef *hi2c) { if (hi2c-Instance I2C1) { // 解析jy901s_rx_buf里的数据 int16_t roll (int16_t)((jy901s_rx_buf[1] 8) | jy901s_rx_buf[0]); int16_t pitch (int16_t)((jy901s_rx_buf[3] 8) | jy901s_rx_buf[2]); int16_t yaw (int16_t)((jy901s_rx_buf[5] 8) | jy901s_rx_buf[4]); // 实际角度 原始值 / 32768 * 180 float roll_deg roll / 32768.0f * 180.0f; float pitch_deg pitch / 32768.0f * 180.0f; float yaw_deg yaw / 32768.0f * 180.0f; // 后续处理... } }这里有个数据格式的细节JY901S返回的角度值是int16范围-32768到32767对应-180度到180度。转换公式是原始值 / 32768 * 180。加速度和角速度的转换系数不一样加速度是原始值 / 32768 * 16g角速度是原始值 / 32768 * 2000度/秒。这些系数在手册里都有用之前确认一下量程设置因为JY901S的量程是可配置的如果你改过配置系数要跟着变。还有一个坑DMA传输完成中断里不能做太耗时的事情。我见过有人在回调里直接跑OLED刷新结果DMA中断响应变慢下一次I2C读取的时序受影响。正确的做法是在回调里只做数据解析和标志位设置主循环里根据标志位去刷新OLED。中断里要快进快出这是嵌入式的铁律。4. OLED显示部分的I2C资源分配别让两个设备打架OLED用0.96寸的SSD1306模块I2C接口地址通常是0x788位地址或者0x3C7位地址。问题来了JY901S和OLED都挂在I2C上STM32F103只有一个I2C1I2C2在PB10/PB11但很多最小系统板没引出来。两个设备共用一条总线地址不同理论上可以共存。但实际调试时会遇到几个问题。第一个问题是总线速率。OLED的SSD1306在400kHz下工作没问题但有些便宜的模块在400kHz下会丢数据尤其是刷新整屏的时候。JY901S在400kHz下很稳。如果两个设备速率要求不一致取低的那个或者分时切换速率。我一般统一用400kHz如果OLED不稳定就降到200kHzJY901S也能接受。第二个问题是DMA通道冲突。I2C1_RX用的是DMA1_Channel7不同型号可能不同以CubeMX为准I2C1_TX用的是DMA1_Channel6。如果你给OLED也配了DMA要确保通道不冲突。但OLED刷新其实不太需要DMA因为SSD1306的显存写入是单向的用阻塞方式写也没问题刷新一屏1024字节在400kHz下大概20ms可以接受。我建议OLED用阻塞方式把DMA留给JY901S的读取这样资源分配清晰调试也简单。第三个问题是上拉电阻的负载。两个设备加上STM32的内部上拉如果有的话总线上拉等效阻值会变小。如果每个设备都带4.7kΩ上拉两个并联就是2.35kΩ加上STM32的内部上拉通常40kΩ左右总等效阻值大概2.2kΩ。这个值偏小会导致总线电流增大低电平时的灌电流可能超过器件的额定值。标准做法是整条总线只留一组上拉电阻通常是4.7kΩ。如果你发现通信不稳定先检查是不是上拉电阻并太多了。OLED的驱动代码网上很多HAL库的I2C阻塞写显存大概长这样void OLED_WriteCmd(uint8_t cmd) { HAL_I2C_Mem_Write(hi2c1, OLED_ADDR, 0x00, I2C_MEMADD_SIZE_8BIT, cmd, 1, 100); } void OLED_WriteData(uint8_t *data, uint16_t len) { HAL_I2C_Mem_Write(hi2c1, OLED_ADDR, 0x40, I2C_MEMADD_SIZE_8BIT, data, len, 100); }注意OLED的寄存器地址是8位的控制字节0x00表示命令0x40表示数据。这和JY901S的16位地址不一样调用HAL_I2C_Mem_Write时MemAddressSize参数要设对。我见过有人把两个设备的地址宽度搞混结果OLED显示花屏查了半天以为是显存初始化问题其实是地址宽度设错了。显示内容上我一般把Roll、Pitch、Yaw三个角度显示在屏幕前三行用sprintf格式化字符串然后调用OLED的字符串显示函数。刷新率控制在10Hz左右太快了人眼看不清也浪费CPU时间。10Hz意味着每100ms读一次JY901S并刷新OLED这个节奏对姿态显示来说足够了。5. 调试过程中最容易卡住的三个地方第一个卡点是DMA传输完成但数据不对。现象是回调进了但缓冲区里的数据全是0或者全是0xFF。原因通常是DMA的Memory地址自增没开或者Peripheral地址自增误开了。检查CubeMX里DMA配置的Increment Address设置Memory要打勾Peripheral不能打勾。另一个可能是I2C的DMA请求没使能在HAL_I2C_Mem_Read_DMA调用之前HAL库会检查DMA是否配置好如果没配置会返回HAL_ERROR。可以在调用后检查返回值如果是HAL_ERROR说明DMA初始化有问题。第二个卡点是I2C总线锁死。现象是程序跑着跑着就不动了I2C的SR2寄存器里BUSY位一直是1。这通常是因为上一次传输过程中出现了错误比如从机没应答但错误标志没清除总线状态机卡住了。解决办法是在初始化之后加一个总线恢复函数把SCL引脚临时设成GPIO输出手动发9个时钟脉冲然后发一个停止条件。HAL库没有现成的恢复函数需要自己写。我一般会在HAL_I2C_Init之后调用一个自定义的I2C_Bus_Recover函数确保总线处于空闲状态。void I2C_Bus_Recover(void) { GPIO_InitTypeDef gpio {0}; // 把SCL和SDA临时设为开漏输出 gpio.Pin GPIO_PIN_6 | GPIO_PIN_7; gpio.Mode GPIO_MODE_OUTPUT_OD; gpio.Pull GPIO_NOPULL; gpio.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOB, gpio); // 发9个时钟脉冲 for (int 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.Mode GPIO_MODE_AF_OD; HAL_GPIO_Init(GPIOB, gpio); }第三个卡点是OLED和JY901S互相干扰。现象是单独跑JY901S没问题单独跑OLED也没问题两个一起跑就偶尔出错。原因可能是OLED刷新时占用了I2C总线JY901S的DMA读取请求被延迟导致I2C的时序超时。解决办法是给两个设备的访问加互斥锁或者把OLED刷新放在JY901S读取的间隙里。我的做法是用一个状态机先触发JY901S的DMA读取等DMA完成回调里设置标志位主循环检测到标志位后再刷新OLED刷新完之后再触发下一次JY901S读取。这样两个操作串行化不会打架。6. 从能跑到跑得稳几个提升可靠性的实操技巧第一个技巧是给I2C读取加超时和重试。HAL库的DMA方式没有超时参数如果从机没应答DMA传输完成回调可能永远不进。我一般会在主循环里加一个软件超时触发读取后记下时间戳如果超过50ms还没进回调就认为这次读取失败调用HAL_I2C_DeInit和HAL_I2C_Init重新初始化I2C然后重试。重试次数设3次3次都失败就报错。这个机制在实际项目中救过我很多次尤其是电机干扰大的场合。第二个技巧是DMA缓冲区的对齐。STM32的DMA对内存地址的对齐有要求虽然字节传输不强制4字节对齐但对齐之后效率更高。我一般把接收缓冲区定义成__attribute__((aligned(4)))或者直接用一个uint32_t数组来装字节数据。这个细节在F4和F7系列上更明显F1系列要求没那么严但养成习惯没坏处。第三个技巧是JY901S的配置保存。JY901S的量程、输出速率、I2C地址这些参数可以通过上位机软件配置配置完之后要发送保存命令否则断电就丢了。保存命令是往寄存器0x00写0x00然后往寄存器0x01写0x00再往0x00写0x00。这个流程手册里有但很容易漏掉最后一步。我见过有人配置完直接断电下次上电发现又回到默认值以为是模块坏了。第四个技巧是OLED的显存刷新策略。SSD1306的显存是1024字节如果每次刷新都全写一遍400kHz下要20ms左右。如果只刷新变化的部分可以省很多时间。我的做法是维护一个本地显存数组每次要显示新内容时先比较哪些页变了只写变化的页。对于姿态显示这种只有数字变化的场景通常只有两三页需要刷新时间可以降到5ms以内。问题现象可能原因排查方法DMA回调不进DMA通道没使能或I2C错误检查HAL_I2C_Mem_Read_DMA返回值查I2C的SR1寄存器错误标志数据全0或全FF地址宽度设错或DMA自增配置错确认MemAddressSize为16BITMemory自增开Peripheral自增关总线BUSY位卡1上次传输错误未清除调用总线恢复函数发9个时钟加停止条件OLED花屏地址宽度或控制字节错误确认OLED地址为8位模式控制字节0x00/0x40正确两个设备互相干扰I2C访问冲突串行化访问加状态机或互斥锁7. 把姿态数据用起来从显示到控制的延伸数据读进来、显示出来只是第一步。JY901S的姿态角可以拿来做很多事平衡车的姿态反馈、云台的稳定控制、机械臂的关节角度监测。如果你要做闭环控制注意JY901S的输出速率和I2C读取速率的匹配。JY901S默认输出速率是10Hz100ms一次如果你读得比它快会读到重复数据读得比它慢会丢数据。我一般把JY901S的输出速率设成50HzI2C读取也设成50Hz这样数据新鲜度够CPU负担也不重。另一个延伸方向是多传感器融合。JY901S自带姿态解算输出的是融合后的角度直接用就行。但如果你需要更高精度或者更快的响应可以只读它的原始加速度和角速度数据自己在STM32上跑卡尔曼滤波或者互补滤波。这时候I2C读取的数据量会大很多原始数据有9个轴加气压共20字节DMA的优势就更明显了。OLED显示方面除了数字还可以画简单的波形图。把最近几十次的Roll值存到一个环形缓冲区里然后在OLED上画折线。SSD1306支持页寻址模式画波形就是往对应的页写位图数据。这个功能在调试PID参数的时候特别有用能直观看到超调和振荡。最后说一个实际项目里的经验I2C总线的物理布局很重要。如果JY901S和OLED离STM32比较远建议用双绞线或者屏蔽线SDA和SCL尽量靠近GND要粗。我做过一个项目传感器装在机械臂末端线长30cm一开始用普通杜邦线I2C频繁出错换成屏蔽线之后稳定了。如果线实在长考虑用I2C缓冲器或者转成差分信号但那是另一个话题了。代码层面我习惯把JY901S的读取封装成一个独立模块对外只暴露JY901S_Init、JY901S_ReadAngle、JY901S_GetAngle三个函数。DMA和中断都在模块内部处理主循环只管调用和取数据。这样换平台或者换传感器的时候只需要改模块内部上层逻辑不动。这个习惯让我在多个项目之间切换时省了很多重复劳动。
返回列表