简介:围绕JY901传感器模块与STM32F1X微控制器UART串口通信的完整工程代码包,面向嵌入式初学者及需要调试姿态与传感器数据采集的工程师,可解决硬件连接、串口参数配置、收发流程及协议解析等实际问题。压缩包共123个文件,以C源码和H头文件为主,同时包含Keil MDK工程文件,以及hex、axf、map、lst等编译调试产物,整体约1.71MB;目录结构保留备份与调试记录,便于打开工程后对照UART初始化、中断处理、数据读写和报文解析模块进行学习,也可直接烧录或二次开发。目前已有457人学习下载。内容除基础串口通信代码外,还覆盖JY901数据接收处理与姿态解算相关逻辑,适合作为STM32F1X串口外设编程和九轴传感器应用的综合参考。
1. 为什么 JY901 与 STM32F1X 的 UART 连接比 I2C 更省心
UART 连接 STM32 看上去是四根线,实际要跑通 JY901 串口通信,靠的是 11 字节一帧的 0x55 协议和一台能看 HEX 的串口调试助手。JY901 模块把三轴加速度、三轴角速度和融合后的姿态角都以固定帧格式发出来,STM32F1X 只要配好 USART、在中断里收字节、在 main 循环里拼包,就能拿到物理量。这个项目包里能看到 IMU_AHRS.uvproj.bak、stm32f10x_rcc.c、stm32f10x_tim.c 和 stm32f10x_adc.c,说明作者是用标准外设库把 RCC 时钟、定时器与 ADC 都一起编译进去了。用 STM32F103C8T6 这类小封装芯片时,USART1 默认在 PA9/PA10,三根线就能完成数据交换,比 I2C 少折腾应答和寄存器配置;即使对串口协议不熟,也可以先抓帧头再慢慢适配。下面直接按工程里最可能出现的写法拆开讲。
2. 硬件接线与 RCC/GPIO 配置:从三根线到 USART 中断
2.1 JY901 引脚定义与 STM32F1X 的交叉接线
JY901 模块背面通常引出 VCC、GND、TX、RX 四根信号,部分版本还会带 SCL、SDA 用于 I2C 模式,但这个项目只在 UART 下工作,所以 I2C 引脚不用理会。UART 是异步串口,A 设备发送脚必须接 B 设备接收脚,A 设备接收脚接 B 设备发送脚。我焊过几次板子后总结了一个口诀:看“信号去向”,不要只看“TX 对 TX”。
| JY901 引脚 | 功能 | STM32F103 引脚 | 信号方向 |
|---|---|---|---|
| VCC | 电源输入,3.3V~5V 均可 | 3.3V 或 5V 电源 | 供电 |
| GND | 电源地 | GND | 共地 |
| TX | 模块串口发送 | PA10 / USART1_RX | JY901 → STM32 |
| RX | 模块串口接收 | PA9 / USART1_TX | STM32 → JY901 |
这里要注意的是电平类型。JY901 输出的是 TTL 电平,不是 RS232,所以不能拿 STM32 直接去接电脑 DB9 串口,也不能把 JY901 接到 MAX3232 之后的 RS232 电平上。调试时常见的 FT232R、CH340 USB 转 TTL 模块本身输出的是 TTL,可以和 JY901 直接对话。如果给 JY901 供 5V,它的 TX 高电平会接近 5V,STM32F103 的 PA10 虽然具备 5V 容忍能力,我一般还是会在中间串一个 1kΩ 电阻,或者直接改用 3.3V 供电,尤其是板子用电池给 stm32f103c8t6 供电时,这样能避免灌电流在引脚保护二极管上发热。
2.2 打开 RCC 时钟并配置 PA9/PA10 复用功能
标准外设库工程里,时钟使能代码一般写在stm32f10x_rcc.c的调用点中。USART1 挂在 APB2 总线上,PA9/PA10 属于 GPIOA,因此需要同时打开 GPIOA 和 USART1 的时钟。先配置 GPIO 再配置 USART,代码顺序上更符合手册里外设初始化流程:
void JY901_UART_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; // USART1 和 GPIOA 都挂在 APB2 上,两个时钟都要开 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE); // PA9 作为 USART1_TX,必须配置成复用推挽输出 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_Init(GPIOA, &GPIO_InitStructure); // PA10 作为 USART1_RX,用浮空输入或上拉输入 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); }PA9 不能配置成普通推挽输出GPIO_Mode_Out_PP,因为普通输出时引脚状态由 ODR 寄存器控制,而 UART 发送需要由外设把数据移位到引脚上,只能使用复用推挽。PA10 的模式,手册推荐浮空输入,但我实际调试时发现改成GPIO_Mode_IPU抗干扰更好。原因是 RX 引脚如果悬空,内部电平不确定,附近电机或继电器动作时容易产生毛刺触发 RXNE 中断;JY901 的 TX 是推挽输出,本来就具备驱动能力,上拉不会把高电平拉垮。
2.3 硬件接线最容易犯的三个错误
先看共地。STM32 和 JY901 如果不共地,TX 和 RX 之间没有参考电平,读到的数据要么全 FF,要么全是乱码。很多从 Arduino 转过来的同学会拿两个独立电源分别供电,然后发现通信失败,十有八九是忘了把两边 GND 连在一起。
再看 TX/RX 是否接反。JY901 的 TX 必须接 STM32 的 RX,JY901 的 RX 必须接 STM32 的 TX。面包板上如果图省事把同名端子插在一起,数据就进入自己的发送脚,自然收不到。这种错误用万用表量通断很难发现,最好在代码里做一层回环测试,后面第 5 章会提到。
最后是信号电平。JY901 是 TTL,PC 串口是 RS232,如果中间没有 USB 转 TTL 模块,只靠一个 RS232 转接头,FT232R 驱动装得再正确也看不到数据。项目工程里出现 stm32f10x_flash.c,说明开发者可能把参数存进单片机内部 Flash,调试时如果 Flash 擦写频繁,也会让中断延迟变大,这部分放到第 3 章一起处理。
3. UART 初始化与中断收数据:波特率、环形缓冲和 RXNE 中断
3.1 初始化 USART1:为什么用 8 位数据位加 1 位停止位
JY901 的默认串口配置里没有校验位,数据位 8 位,停止位 1 位,也就是常说的 8N1。初始化代码使用 USART_InitTypeDef 结构体,主频由 stm32f10x_rcc.c 配置好之后,波特率由 USART1 的外设时钟直接分频得到,不需要手动算 USARTDIV:
void JY901_UART_Init(uint32_t baud) { USART_InitTypeDef USART_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; USART_InitStructure.USART_BaudRate = baud; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_RX | USART_Mode_TX; USART_Init(USART1, &USART_InitStructure); USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); USART_Cmd(USART1, ENABLE); NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); }波特率参数由外部传入,调用时写JY901_UART_Init(9600)或JY901_UART_Init(115200)。JY901 出厂值在不同批次上不太一样,常见是 9600 或 115200;如果手上这台模块被改过配置,先用 USB 转 TTL 接电脑,在串口调试助手里分别测试这两个波特率,能读到以 55 开头的稳定字节流,就说明波特率匹配。波特率过低时,数据帧会非常密集,甚至背靠背发送,这时中断压力会明显加大:
| 波特率 | 11 字节完整帧耗时 | 按 100Hz 帧间隔 10ms 计算 | STM32 中断压力 |
|---|---|---|---|
| 9600 | 约 11.46ms | 超过 100%,必然积压 | 每个字节约 1.04ms 一次中断 |
| 115200 | 约 0.95ms | 约 10%,很宽裕 | RXNE 约 86us 一次,中等 |
| 921600 | 约 0.12ms | 约 1% | 接近 DMA 使用场景 |
我在这个项目里一般先用 115200。9600 虽然更慢、更稳定,但 JY901 最高频率输出时,串口总线本身就成为瓶颈,任何一个 Flash 写操作卡住几百微秒,就可能丢一个字节。
3.2 用 RXNE 中断加环形缓冲接收数据
JY901 的 UART 接收数据是“零零散散、按字节到达”的,而协议解析需要一次拿 11 个字节,因此中间必须有一层缓冲。我用环形缓冲,中断只负责写入,主循环负责读出,这样解析逻辑再慢也不会阻塞串口接收:
#define JY901_RX_BUF_SIZE 256 static uint8_t jy901_rx_buf[JY901_RX_BUF_SIZE]; static volatile uint16_t jy901_rx_wr = 0; static volatile uint16_t jy901_rx_rd = 0; void USART1_IRQHandler(void) { uint8_t ch; if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { // 读取数据寄存器会自动清除 RXNE 标志 ch = (uint8_t)USART_ReceiveData(USART1); jy901_rx_buf[jy901_rx_wr] = ch; jy901_rx_wr = (jy901_rx_wr + 1) % JY901_RX_BUF_SIZE; } } uint8_t JY901_RingRead(uint8_t *ch) { if (jy901_rx_rd == jy901_rx_wr) return 0; *ch = jy901_rx_buf[jy901_rx_rd]; jy901_rx_rd = (jy901_rx_rd + 1) % JY901_RX_BUF_SIZE; return 1; }代码里的USART_ReceiveData返回的是 32 位寄存器值,但 USART1 的 DR 寄存器只有低 9 位有效,8 位数据模式下高字节全是 0,所以强转成uint8_t没有问题。如果不转,直接在ch里保存 32 位值,后面写进 uint8_t 数组时也可能出现,但那样语义不清晰,我习惯在入口就收敛成单一字节。
环形缓冲写满之后会覆盖旧数据,不会触发阻塞。这样设计的原因很简单:主循环如果正在做姿态换算,或者正在读stm32f10x_adc.c里的采样值,不可能每次都刚好赶在 RXNE 时刻去读串口;缓冲能扛住几个帧的数据积压。只要JY901_RingRead被高频调用,比如放在 while(1) 主循环入口,就不会丢数据。
3.3 ORE 溢出和乱码的排错思路
串口通信遇到乱码,最常见的两个原因是波特率不匹配和接线错误。波特率不匹配时,数据位的采样点错位,串口调试助手里看到的是一堆随机字符;如果 HEX 显示,则能看到 55 后面跟着一串无规律字节,而且校验和几乎总是不通过。
另一个隐蔽问题是 ORE,即 Overrun Error。STM32F1 的 USART 只有一个数据寄存器,如果 RXNE 置位后 CPU 没有及时读取,下一个字节到达会把 DR 里的数据覆盖,硬件置起 ORE 标志。这个标志不清除,后续 RXNE 中断会停止工作。标准外设库工程里可以在中断函数开头加一段保护:
if (USART_GetFlagStatus(USART1, USART_FLAG_ORE) != RESET) { (void)USART_ReceiveData(USART1); }这个操作把 DR 读掉一次,ORE 被硬件清除,然后再进入正常 RXNE 处理。出现 ORE,说明主循环确实有较长阻塞,或者波特率偏高、中断优先级没调好。更彻底的方案是换用 DMA 接收,把字节搬运交给 DMA 控制器,这样即使 CPU 正在写 Flash,串口数据也会先落进内存缓冲区。第 5 章会给一个 DMA 空闲中断的改造要点。
4. 解析 JY901 的 0x55 协议:数据类型、校验和与加速度换算
4.1 一帧 11 字节的链路层布局
JY901 的串口协议不是 Modbus,每个数据包固定 11 字节。第 1 个字节是帧头 0x55,第 2 个字节是数据类型,第 3 到第 10 个字节是数据区,第 11 个字节是校验和。这里的难点在于数据类型不同,数据区含义也不同:
| 字节偏移 | 内容 | 0x51 加速度帧 | 0x52 角速度帧 | 0x53 角度帧 |
|---|---|---|---|---|
| 0 | 帧头 | 0x55 | 0x55 | 0x55 |
| 1 | 数据类型 | 0x51 | 0x52 | 0x53 |
| 2~3 | 通道 0 | AX 轴原始值 | X 轴角速度 | 横滚角 |
| 4~5 | 通道 1 | AY 轴原始值 | Y 轴角速度 | 俯仰角 |
| 6~7 | 通道 2 | AZ 轴原始值 | Z 轴角速度 | 偏航角 |
| 8~9 | 通道 3 | 温度/保留 | 温度/保留 | 温度/保留 |
| 10 | 校验和 | 前 10 字节累加和的低 8 位 | 同左 | 同左 |
所有 16 位数据都是低字节在前,也就是小端序。STM32F103 和 JY901 内部都是小端,所以拼接时不需要额外字节序转换,直接取低字节 $ data[2] 和高字节 $ data[3] 组合即可。
4.2 用逐字节状态机把帧从字节流里切出来
从环形缓冲读出来的是一串连续字节,无法保证第一字节恰好是帧头,所以解析函数最好做成状态机。下面这份代码每次喂一个字节,攒满 11 字节后校验,校验通过再按类型分发:
#define JY901_FRAME_LEN 11 #define TYPE_ACC 0x51 #define TYPE_GYRO 0x52 #define TYPE_ANGLE 0x53 static uint8_t frame_buf[JY901_FRAME_LEN]; static uint8_t frame_cnt = 0; int JY901_ParseByte(uint8_t ch) { if (frame_cnt == 0) { // 未起始前只关心帧头 0x55 if (ch == 0x55) frame_buf[frame_cnt++] = ch; return 0; } frame_buf[frame_cnt++] = ch; if (frame_cnt < JY901_FRAME_LEN) return 0; frame_cnt = 0; // 校验和前 10 字节:从帧头到最后一个数据字节 uint8_t sum = 0; for (uint8_t i = 0; i < 10; i++) sum += frame_buf[i]; if (sum != frame_buf[10]) return -1; switch (frame_buf[1]) { case TYPE_ACC: JY901_HandleAcc(&frame_buf[2]); break; case TYPE_GYRO: JY901_HandleGyro(&frame_buf[2]); break; case TYPE_ANGLE: JY901_HandleAngle(&frame_buf[2]); break; default: break; } return 1; }校验和的逻辑是把第 0 到第 9 字节累加,结果只取低 8 位,和帧尾比较。这里要注意,sum 必须声明成uint8_t,这样累加自然会保留低 8 位;如果声明成 uint16_t 再和 frame_buf[10] 比较,低位溢出逻辑虽然一样,但可读性差得多。
还有一种更简单的做法是把收到的字节先攒满一个不定长数组,再用 memcmp 找 0x55。应付 JY901 这种固定 11 字节帧没有问题,但状态机的好处是不需要额外搬移内存,也不会因为一帧数据跨两次读取而切错。当前实现中,如果帧尾校验失败直接 frame_cnt 归零,理论上会丢掉一个恰好是 0x55 的字节,但 JY901 每帧之间都有间隔,实际影响很小。
4.3 原始数据到物理量的换算关系
JY901 输出的原始值是 16 位有符号整数,要换成加速度、角速度和角度,需要按量程缩放。模块内部把满量程映射到 32768,所以换算公式是“原始值除以 32768 再乘以量程”,不要写成除以 32767。具体到代码里,先写一个把小端字节拼成有符号短整型的函数,再分别处理三类数据:
typedef struct { float ax, ay, az; float wx, wy, wz; float roll, pitch, yaw; } jy901_t; static int16_t JY901_Int16(const uint8_t *p) { return (int16_t)((uint16_t)p[0] | ((uint16_t)p[1] << 8)); } static void JY901_HandleAcc(const uint8_t *p) { jy901.ax = JY901_Int16(p) / 32768.0f * 16.0f; jy901.ay = JY901_Int16(p + 2) / 32768.0f * 16.0f; jy901.az = JY901_Int16(p + 4) / 32768.0f * 16.0f; } static void JY901_HandleGyro(const uint8_t *p) { jy901.wx = JY901_Int16(p) / 32768.0f * 2000.0f; jy901.wy = JY901_Int16(p + 2) / 32768.0f * 2000.0f; jy901.wz = JY901_Int16(p + 4) / 32768.0f * 2000.0f; } static void JY901_HandleAngle(const uint8_t *p) { jy901.roll = JY901_Int16(p) / 32768.0f * 180.0f; jy901.pitch = JY901_Int16(p + 2) / 32768.0f * 180.0f; jy901.yaw = JY901_Int16(p + 4) / 32768.0f * 180.0f; }拼接时先转成uint16_t再转int16_t很重要。如果直接写(int16_t)(p[1] << 8 | p[0]),在标准 C 中 p[1] 是 uint8_t,会被提升为 int,左移 8 位后如果高字节最高位为 1,结果再转 int16_t 才会得到正确符号;但不同编译器的告警处理不一致,我习惯用强制类型把每一步写清楚。
加速度的结果单位是 g,角速度单位是度每秒,角度单位是度。比如 raw 值为 16384 的加速度帧,计算结果就是 16384 / 32768 * 16 = 8g,正好是满量程一半。这个结果可以直接给姿态控制用,不需要再额外乘 9.8。项目里既然有 stm32f10x_tim.c,通常会定时采集 JY901 解算后的角度,用 TIM 做采样周期控制;stm32f10x_adc.c 则用来监控供电电压,避免 JY901 进入低电压状态后角度漂移。这些模块和 JY901 代码之间通过全局结构体 jy901 共享数据,互不阻塞。
5. UART 连接 STM32 后的三招调试技巧
5.1 串口回环测试把问题定位到 STM32 自身
新板子第一次跑 JY901,先不要接模块,用一根杜邦线把 PA9 和 PA10 短接。然后在 USART1_IRQHandler 里把收到的字节原样发送:
if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t ch = (uint8_t)USART_ReceiveData(USART1); while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); USART_SendData(USART1, ch); }串口助手上发送 0x55 如果能收到 0x55,说明 PA9/PA10 复用配置、USART 中断、NVIC 优先级都没问题。这时再断开短接线接上 JY901,如果收不到数据,问题就基本锁定在硬件接线或模块配置上,而不是 STM32 代码。这比拿示波器到处点引脚快得多。
5.2 统计解析成功帧数,而不是只看串口有没有数据
JY901 以固定频率持续发出 0x55 帧,比如设定 100Hz,那么 1 秒内应当有约 100 次解析成功。用另一个串口每秒打印Jy901_packet_count,数值稳定在 100 附近才说明数据链路完整。如果帧数只有一半,先怀疑中断是否被stm32f10x_flash.c的长时间写操作打断;再查是不是波特率偏低导致总线占用率过高。只看逻辑分析仪上有波形不算数,波形存在但不代表每帧校验都通过。
5.3 进一步用 DMA 空闲中断接收,把 CPU 释放出来
对 stm32f103c8t6 这种小封装芯片,如果还要跑 ADC 采样和控制算法,RXNE 中断频繁进入会消耗不少 CPU。改造思路是让 DMA 把 USART1_DR 里的数据自动搬运到内存数组,再用串口 IDLE 空闲中断判断一帧结束。DMA1 Channel5 对应 USART1_RX,配置成循环模式,缓存大小 256 字节:
DMA_DeInit(DMA1_Channel5); DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&USART1->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)jy901_dma_buf; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize = 256; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel5, &DMA_InitStructure); USART_DMACmd(USART1, USART_DMAReq_RX, ENABLE); DMA_Cmd(DMA1_Channel5, ENABLE);然后在 USART1 中断里开启 IDLE 中断,检测到总线空闲时,通过DMA_GetCurrDataCounter计算当前接收长度,再把数据交给 JY901 的协议解析函数。IDLE 中断不会像 RXNE 那样逐字节触发,只有在串口线上停顿时触发一次,比较适合 JY901 这类按帧发送、帧间有明显间隔的传感器。如果项目已经调通,这个改动不会妨碍原有逻辑,只把接收层从字节流换成整帧缓冲,后续再接其他 UART 传感器时也可以照同一套思路扩展。
本文还有配套的精品资源,点击获取