这段时间在搞一块 STM32C5 的开发板,顺手把 LSM6DSVE 这颗六轴 IMU 的驱动打通了。第一步要做的,就是用最基础的轮询方式读取陀螺仪数据,把 I2C 通信、寄存器配置、原始数据和角速度换算这套完整链路跑通。这篇文章就把整个思路和代码记录下来,包含器件选型的考量、关键寄存器配置、状态轮询的写法、单位换算方法,还有调试时踩到的几个坑。适合手上正好有 STM32C5 和 LSM6DSVE、或者准备从零折腾 MEMS 传感器的朋友参考。之所以先把“轮询”当作第一篇,是因为它是所有读取方式里逻辑最简单、最容易排查问题的一条路径。等轮询跑通之后,后续再上中断、FIFO、DMA 甚至 MLC 机器学习核,都有了可靠的通信基础。
1. 整体设计思路:为什么是 STM32C5、为什么用轮询
1.1 任务拆解:这个项目到底在做什么
“STM32C5 开发 LSM6DSVE(1)——轮询获取陀螺仪数据”这个标题,拆开来看其实是三件事:第一,让 MCU 能和 LSM6DSVE 通信,也就是把 I2C 或者 SPI 总线调通;第二,把传感器初始化到正确的工作状态,让陀螺仪按设定的输出数据率(ODR)持续采样;第三,MCU 通过查询状态位的方式,按节奏把陀螺仪三个轴的原始数据读出来,并换算成有物理意义的角速度值。
看起来步骤不多,但实际动手时有一堆细节。比如 I2C 地址到底是多少,取决于 ADO/SA0 引脚电平;WHO_AM_I 寄存器能不能读到正确值,决定了接线和通信有没有毛病;CTRL2_G 寄存器里 ODR 和量程的位怎么编排,直接关系到输出频率和灵敏度;状态寄存器里 DRDY_G 位什么时候置 1,什么时候被硬件清零,这些都会影响轮询逻辑。这些细节单独看都不难,但串在一起就容易翻车。
我选 LSM6DSVE 做这颗传感器,主要是看中它在功耗、噪声和功能集成度上的平衡。它内置三轴加速度计和三轴陀螺仪,支持从低功耗到高性能的多种模式,还带了 FIFO、有限状态机(FSM)和机器学习核(MLC)。对于一篇入门级的轮询教程来说,这些高级功能用不上,但后续做姿态解算、运动识别时都是很好的扩展空间。更重要的是,它和 LSM6DSO、LSM6DSV 系列在寄存器架构上非常接近,学会这一颗,其他兄弟型号基本能无缝迁移。
1.2 为什么选择轮询而不是中断或者 DMA
先把结论放前面:对于“第一次把传感器调通”这个目标,轮询是最不容易出错、最容易调试的方案。因为它的逻辑完全线性,不用管中断回调时机,不用考虑环形缓冲,也不依赖定时器触发 DMA 传输。你只要在主循环里不断问传感器“数据好没?”,好了就拿走,仅此而已。但有些朋友一上来就想用最高级的方式,结果 I2C 还没调通就开始折腾 FIFO,这样一旦出问题,很难分清是底层通信的锅还是上层逻辑的锅。
轮询的本质,其实和王道“轮询仲裁器”的思路一样。你看 Verilog 里的轮询仲裁器,就是仲裁逻辑按固定顺序去检查每个请求信号,谁请求就响应谁;MCU 的轮询就是不断去读 STATUS_REG 里的引脚状态位,看陀螺仪有没有产生新数据。这种“主动查状态”的方式虽然 CPU 占用率不低,但在数据率不高、主循环任务不重的场景下完全够用,而且代码好读好维护。
当然轮询也有明显短板:如果传感器 ODR 很高,比如 6.66kHz,主循环如果把时间浪费在别的任务上,就可能漏掉中间的数据;反过来,如果主循环空转频率非常高,也可能出现同一份数据被反复读取的情况。这个我在第 5 节会详细展开。做嵌入式就是个权衡的过程,搞清楚轮询的边界之后,你才会理解为什么后面要引入中断和 FIFO。
2. 硬件连接与验证通信
2.1 接线与 I2C 地址确认
STM32C5 和 LSM6DSVE 之间,我优先选了 I2C 接口。原因无它,I2C 只要两根线,接线简单,调试方便。LSM6DSVE 的 I2C 地址是 7 位地址,由 SA0(有的手册也叫 ADO)引脚决定:SA0 接 GND 时地址是 0x6B,接 VDD 时是 0x6A。这里有个容易搞混的点:CubeMX 生成的 HAL 库函数里,HAL_I2C_Mem_Read的地址参数需要传入 8 位地址,也就是在 7 位地址的基础上左移一位,最后一位用于表示读/写方向。所以如果传感器地址是 0x6B,代码里要写0x6B << 1。
接线方面,我用的开发板传感器子板已经把 I2C 上拉电阻做上去了,所以 SCL、SDA 直接连到 MCU 的 I2C 引脚即可。如果你用的是裸片或者自己画的板子,一定要记得在 SCL 和 SDA 上各加一颗 4.7kΩ 左右的上拉电阻。I2C 是开漏结构,没有上拉电阻,总线根本拉不高,通信会时好时坏。这是新手最常见的问题之一,不是代码的问题,是硬件的问题。
2.2 用 WHO_AM_I 验证通信是否正常
连接好硬件之后,第一步不要急着配寄存器,先把 WHO_AM_I 寄存器读出来。WHO_AM_I 相当于传感器的“身份证号”,地址是 0x0F。如果接线、供电、地址都对,读出来应该是一个固定的值,我手上这颗 LSM6DSVE 读出来是 0x6E。按照 ST 的习惯,同一系列的芯片 WHO_AM_I 基本固定,如果你读到的值和数据手册对不上,大概率是地址配置错误、引脚接反,或者芯片没正常上电。
读 WHO_AM_I 的代码非常简单,直接用 HAL 库的库函数就行:
#define LSM6DSVE_I2C_ADDR (0x6B << 1) uint8_t who_am_i = 0; HAL_I2C_Mem_Read(&hi2c1, LSM6DSVE_I2C_ADDR, 0x0F, I2C_MEMADD_SIZE_8BIT, &who_am_i, 1, 100); if (who_am_i == 0x6E) { // 通信正常,可以继续 } else { // 检查接线、地址和电源 }提示:如果 HAL_I2C_Mem_Read 返回 HAL_ERROR 或者 HAL_BUSY,先检查 I2C 引脚有没有接反,再检查地址是否写成了 7 位。很多问题不是出现在代码逻辑,而是出现在地址换算上。
2.3 CubeMX 里快速初始化 I2C
我用 STM32CubeMX 生成的工程框架,版本比较新的环境下已经能直接选到 STM32C5 系列芯片。在 Pinout 视图里把 I2C1 配上,SCL 和 SDA 指定到实际连接的引脚,然后在 Configuration 里确认 I2C 的速度模式。对于 LSM6DSVE,我一般配置成 Standard Mode 100kHz 先验证通信,因为低速模式容错性更好;等确认没问题,再改成 Fast Mode 400kHz 提升效率。
CubeMX 生成的代码会初始化好 I2C 外设的句柄。在 main.c 里,初始化顺序是先MX_I2C1_Init(),然后在用户代码区调用我们自己的传感器初始化函数。这里有个习惯想提醒一下:不要把传感器初始化写在 MX 生成的初始化函数里,尽量单独建一个文件或者单独的函数,方便后续移植。HAL 库的 I2C 默认是有超时的,读取函数传入的 Timeout 参数建议给个合理值,比如 100ms。如果传感器没接好,超时能帮你快速暴露问题,不至于卡死在死循环里。
3. 关键寄存器与状态轮询逻辑设计
3.1 初始化前需要搞懂的寄存器
LSM6DSVE 的寄存器映射不算复杂,但有几个关键的必须在初始化阶段配置好。我把重点列出来,方便大家对照数据手册看。
第一个是 CTRL1_XL,地址 0x10,控制加速度计。虽然我们要读的是陀螺仪,但实际操作中如果某个版本固件要求双轴传感器协同工作,建议还是把加速度计设置到一个合理的 ODR,避免传感器进入奇怪的状态。CTRL1_XL 的高四位是 ODR_XL,低两位主要控制加速度量程 FS_XL。
第二个是 CTRL2_G,地址 0x11,这是陀螺仪的主控寄存器。高四位 ODR_G 决定陀螺仪输出频率,比如 0000 是掉电模式,0001 是 12.5Hz,0010 是 26Hz,0011 是 52Hz,0100 是 104Hz,0101 是 208Hz,后面还有更高频率。低四位里,FS_G 位决定量程。我建议入门先用 104Hz 的 ODR,量程先选 ±2000dps,因为量程大不容易出现数值溢出,等后面需要精细测量再降量程。
第三个是 CTRL3_C,地址 0x12。这里最值得关注的是 IF_INC 位,也就是地址自动递增。把 IF_INC 置 1 之后,连续读取从 OUTX_L_G 开始的多个数据寄存器时,I2C 设备会自动把寄存器地址往上递增,这样我们就能一次读出 6 个字节的 XYZ 原始数据,而不需要发 6 次读命令。这一点对轮询效率的提升非常明显。
第四个是 STATUS_REG,地址 0x1E。这是轮询的核心寄存器。bit0 是 XLDA,表示加速度计数据是否就绪;bit1 是 DRDY_G,表示陀螺仪数据是否就绪。轮询时主要盯 DRDY_G 这一位。
第五个是陀螺仪数据寄存器组,从 0x22 到 0x27,分别对应 X 轴低字节、Y 轴、Z 轴。读取顺序是 0x22 开始连续读 6 个字节,拼出三个 int16_t 类型的原始值。
下面用一张表整理一下这些重点寄存器:
| 寄存器 | 地址 | 作用 | 关键位 |
|---|---|---|---|
| WHO_AM_I | 0x0F | 芯片标识 | 固定值校验 |
| CTRL1_XL | 0x10 | 加速度计配置 | ODR_XL、FS_XL |
| CTRL2_G | 0x11 | 陀螺仪配置 | ODR_G、FS_G |
| CTRL3_C | 0x12 | 接口与自动递增 | IF_INC |
| STATUS_REG | 0x1E | 数据就绪状态 | bit1=DRDY_G |
| OUTX_L_G | 0x22 | X 轴低字节 | 陀螺仪数据开始 |
3.2 状态轮询的本质:一个软件仲裁器
轮询这个词听起来可能有点“低级”,但它在嵌入式里无处不在。它的核心思想很简单:对外设或者模块的状态进行周期性检查,一旦满足条件就执行对应的读取操作。这就像一个软件仲裁器,MCU 在多个任务里轮流查看“哪个设备准备好了”,优先级高、准备就绪的先处理。
针对 LSM6DSVE 的陀螺仪轮询,流程是固定的:先读 STATUS_REG,检查 DRDY_G 位是否为 1;如果为 1,说明陀螺仪已经更新了采样结果,这时再从 0x22 开始读取 6 个字节的原始值;如果为 0,说明还没准备好,跳过本次读取。循环往复,就形成了稳定的数据流。
这里需要注意一个顺序问题:一定要先读状态寄存器,再读数据寄存器。因为数据手册里写了,读取输出寄存器会自动清除 DRDY 状态位。如果你先读数据再读状态,可能每次看到的 DRDY_G 都是 0,导致轮询永远判断“未就绪”。刚开始我就在这个顺序上栽过跟头,后面会细说。
“状态轮询”这个方法不仅在 I2C 传感器上有用。你写 PLC 的 Modbus 轮询程序、写 Verilog 的仲裁器、做按键扫描,本质上都是同样的思路——不断查询事件是否发生,做条件分支。所以把这个套路理解透,以后遇到类似场景都会很顺手。
4. 完整代码实现与单位换算
4.1 传感器初始化函数
在 main.c 中,我用一个自定义函数完成 LSM6DSVE 的初始化。函数的逻辑分三步:软件复位、配置加速度计与陀螺仪、开启地址自动递增。先看一下代码:
#define LSM6DSVE_CTRL1_XL 0x10U #define LSM6DSVE_CTRL2_G 0x11U #define LSM6DSVE_CTRL3_C 0x12U uint8_t LSM6DSVE_Init(void) { uint8_t ret = 0; uint8_t ctrl_val = 0; // 第一步:软件复位 ctrl_val = 0x01; ret = HAL_I2C_Mem_Write(&hi2c1, LSM6DSVE_I2C_ADDR, LSM6DSVE_CTRL3_C, I2C_MEMADD_SIZE_8BIT, &ctrl_val, 1, 100); if (ret != HAL_OK) return ret; HAL_Delay(10); // 第二步:配置加速度计 ODR=104Hz,量程 +-4g ctrl_val = 0x40 | 0x08; ret = HAL_I2C_Mem_Write(&hi2c1, LSM6DSVE_I2C_ADDR, LSM6DSVE_CTRL1_XL, I2C_MEMADD_SIZE_8BIT, &ctrl_val, 1, 100); if (ret != HAL_OK) return ret; // 第三步:配置陀螺仪 ODR=104Hz,量程 +-2000dps ctrl_val = 0x40 | 0x08; // ODR_G=0100, FS_G=11 ret = HAL_I2C_Mem_Write(&hi2c1, LSM6DSVE_I2C_ADDR, LSM6DSVE_CTRL2_G, I2C_MEMADD_SIZE_8BIT, &ctrl_val, 1, 100); if (ret != HAL_OK) return ret; // 第四步:开启 IF_INC 地址自动递增 ctrl_val = 0x04; ret = HAL_I2C_Mem_Write(&hi2c1, LSM6DSVE_I2C_ADDR, LSM6DSVE_CTRL3_C, I2C_MEMADD_SIZE_8BIT, &ctrl_val, 1, 100); if (ret != HAL_OK) return ret; return HAL_OK; }有几点解释一下。软件复位那一步,往 CTRL3_C 的 bit0 写 1,传感器会自己复位,复位完成后这个位会自动清 0。我在复位后加了一个 10ms 延时,确保传感器有充足时间完成内部重启。如果不加延时,紧接着马上写寄存器,有可能第一笔写在复位过程中被吞掉。
关于 CTRL2_G 的值,0x40 恰好对应 ODR_G = 0100,也就是 104Hz;低位的 0x08 对应 FS_G = 11,表示 ±2000dps。如果你追求更低噪声,可以把量程降到 ±250dps,此时灵敏度会高很多,但对应代码里的换算系数也要改。这个我们要在下一节讲清楚。
4.2 轮询读取陀螺仪数据
初始化完成之后,写一个轮询读取函数。这个函数的核心就是“先查状态位,再取数据”。函数入参是一个结构体指针,把三个轴的原始值带出来。如果数据未就绪,函数返回 0;如果正常读取到新数据,返回 1。
typedef struct { int16_t gyro_x; int16_t gyro_y; int16_t gyro_z; } lsm6dsve_gyro_t; uint8_t LSM6DSVE_ReadGyro(lsm6dsve_gyro_t *gyro) { uint8_t status = 0; uint8_t data[6] = {0}; HAL_StatusTypeDef ret; // 1. 读取状态寄存器的 DRDY_G 位 ret = HAL_I2C_Mem_Read(&hi2c1, LSM6DSVE_I2C_ADDR, 0x1E, I2C_MEMADD_SIZE_8BIT, &status, 1, 100); if (ret != HAL_OK) return 0; if ((status & 0x02) == 0) { return 0; // 陀螺仪数据未就绪 } // 2. 从 OUTX_L_G 地址连续读取 6 个字节 ret = HAL_I2C_Mem_Read(&hi2c1, LSM6DSVE_I2C_ADDR, 0x22, I2C_MEMADD_SIZE_8BIT, data, 6, 100); if (ret != HAL_OK) return 0; // 3. 拼合 XYZ 原始值 gyro->gyro_x = (int16_t)(data[0] | (data[1] << 8)); gyro->gyro_y = (int16_t)(data[2] | (data[3] << 8)); gyro->gyro_z = (int16_t)(data[4] | (data[5] << 8)); return 1; }主循环里的调用就很清爽了:
lsm6dsve_gyro_t gyro = {0}; while (1) { if (LSM6DSVE_ReadGyro(&gyro) == 1) { float gx = gyro.gyro_x * 0.070f; float gy = gyro.gyro_y * 0.070f; float gz = gyro.gyro_z * 0.070f; printf("GX: %.2f dps, GY: %.2f dps, GZ: %.2f dps\r\n", gx, gy, gz); } }这里注意一下,因为前面设置量程是 ±2000dps,在这种量程下 LSM6DSVE 的灵敏度通常是 70 mdps/LSB。也就是说,原始值每变化 1 个 LSB,代表角速度变化 70 mdps,即 0.070 度/秒。所以代码直接用原始值乘 0.070f,得到以 dps 为单位的角速度。如果不确定自己这颗芯片的具体值,最稳妥的方法是去数据手册里查灵敏度表。
为了照顾喜欢用查表方式的朋友,我把常见量程和对应灵敏度整理如下:
| 陀螺仪量程 | 灵敏度(mdps/LSB) | 换算成 dps/LSB |
|---|---|---|
| ±125 dps | 4.375 | 0.004375 |
| ±250 dps | 8.75 | 0.00875 |
| ±500 dps | 17.5 | 0.0175 |
| ±1000 dps | 35 | 0.035 |
| ±2000 dps | 70 | 0.07 |
4.3 数据的单位与精度验证
读完原始值并换算成 dps 之后,怎么验证数据是合理的呢?最简单的办法是拿起板子,静止放在桌上,观察打印出来的三个轴数据。静止状态下,理论上三个轴的角速度都应该是 0 左右,但实际情况会有一些零点偏移,比如我的板子上静止时 X 轴可能稳定在 0.1 到 0.5 dps 之间,这不是错误,而是 MEMS 陀螺仪本身的零偏特性。真正需要担心的是数据完全不变化,或者出现跳变到几千的大数值,这种情况多半是配置出错或者总线干扰。
如果你用 104Hz 的 ODR,主循环又有串口打印占时间,实际打印频率可能会略小于 104Hz。这是正常的,只要现象是“有节奏地刷新”,说明轮询逻辑没问题。我建议在调试阶段可以在打印语句前面加一个计数器,例如每秒统计一次读到的数据次数,用来核对实际输出数据率是否接近配置的 ODR。如果差太多,说明主循环太忙,或者状态位判断出现了问题。
关于单位换算,还有一个容易被忽略的点:原始值是有符号整数,拼合的时候必须用 int16_t 强转。有人直接拿 uint16_t 拼,结果负值变成很大的正数,换算出来的角速度完全不对。正确写法是用 int16_t。如果读到负的原始值,换算出来自然是负角度,表示转动方向,这很好理解。
4.4 调试时怎么把数据可视化
除了用 printf 打印 CSV 格式数据,也可以直接在调试器里观察变量。比如使用 STM32CubeIDE 的 Live Expressions 窗口,给 gyro 结构体加一个观测表达式,然后在 run 模式下就能实时看到三个 int16_t 原始值变化。这个方式对排查“数据是不是全 0”特别有效,因为它不依赖串口。
另外一个小技巧:如果你想把波形画出来,可以用串口把数据发到上位机,用 Serial Plot 之类的工具直接绘图。这样把板子翻来翻去,能直观看到三个轴的数据波形和转动方向,验证数据合理性比看数字快很多。我调试时会把原始值和 dps 两种数据都输出,原始值用于验证寄存器读取,dps 用于业务逻辑,两边对得上,基本就能确定链路没问题。
5. 调试实录与常见问题排查
5.1 读不到 WHO_AM_I 怎么办
这个问题十有八九出现在 I2C 地址或者接线上。先检查 SA0 引脚的电平,确定地址到底是 0x6A 还是 0x6B,然后看代码里的地址有没有左移一位。HAL 库函数要求的是 8 位地址,所以0x6B << 1是必须的。如果你直接传 0x6B 进去,在大多数情况下通信会失败。
排查硬件时可以拿示波器或者逻辑分析仪抓 I2C 波形。启动条件、地址字节、寄存器地址、应答位都能直接看到。如果没有示波器,先把 I2C 速率降到 100kHz,再用万用表量一下 SCL、SDA 对地电压,正常空闲状态应该被上拉到 3.3V,如果有引脚被拉低,说明可能没有接上拉电阻,或者芯片没上电导致倒灌电流。
如果 WHO_AM_I 读出来是 0xFF 或者 0x00,还要检查是不是把传感器接到了 SPI 模式。LSM6DSVE 支持 I2C 和 SPI,如果 CS 引脚被拉死,I2C 可能失效。我在早期调试时就遇到过这样的坑,板子的 CS 引脚没有留出来,直接接了地,结果 I2C 死活不通,最后用飞线把 CS 拉高才解决。
注意:更换传感器子板或者重新焊接后,最好先重新确认一次 WHO_AM_I,很多“代码没问题但数据不对”的案例,最后都发现是芯片换过批次,地址和 ID 有微妙差别。
5.2 数据一直是 0,或者读出来的数值不变
先说数值不变的情况。如果你在主循环里只用一次初始化,然后不断调用读取函数,但三个轴的数据始终是同一个固定值,最可能的原因是你根本没有等到新的 DRDY 信号,读到的都是上一次缓存的数据。解决办法是确认轮询函数里有状态检查,并且状态检查要在读数据之前。
再说全是 0 的情况。排除接线和地址问题之后,大概率是陀螺仪没有被真正开启。CTRL2_G 寄存器必须是掉电模式以外的 ODR 配置,如果 ODR_G 保持 0000,陀螺仪不工作,状态位永远不会置位,读出来的数据自然全是 0。另外,某些 LSM 系列传感器要求写给 CTRL1_XL 和 CTRL2_G 的数据不能全为 0,否则内部可能进入低功耗状态。保险起见,把加速度计和陀螺仪都配置成同样的 ODR,同时打开。
还有一种隐藏原因是 I2C 通信有偶发性错误。HAL 库的 I2C 在总线异常时会返回 HAL_BUSY,而你的读取函数如果只判断了 HAL_OK 而没有针对 HAL_BUSY 做恢复处理,可能导致后面的读取全部基于脏数据。建议在轮询代码里加一个简单的超时统计,如果连续多次读取失败,就调用HAL_I2C_DeInit和MX_I2C1_Init重新初始化一次总线,这种笨办法能解决不少瞬时毛刺问题。
5.3 轮询频率和数据覆盖问题
前面提到过,轮询方式最怕两件事:太快和太慢。当你把传感器 ODR 配成 6.66kHz 这样非常高的频率时,主循环如果还要处理显示、通信、控制逻辑,很可能在某段时间内没来得及读取数据,新数据就会覆盖旧数据。这个道理和一些人写 PLC Modbus 轮询时遇到的情况很像,读取频率如果超过从站更新速度,或者主站循环太慢,会出现数据覆盖或者读到半新半旧的数据。MCU 访问传感器也是一样,所以在选择 ODR 时,一定要评估主循环的耗时。
一个可行的做法是把 ODR 先设低一点,比如 26Hz 或者 52Hz,让主循环运行周期远小于数据周期。等实际确认主循环开销之后,再逐步提高 ODR。对于 104Hz 的陀螺仪数据,只要主循环里没有特别重的运算,轮询完全跟得上。如果你发现实际读取频率远低于理论 ODR,那就说明主循环太忙,要么优化任务,要么升级成中断加 FIFO 的方案。
还有一个小细节,主循环里不要再主动加HAL_Delay(100)这种长延时,除非你有意降频。否则本来就 104Hz 的数据,被你硬生生延迟到 10Hz 采样,虽然数值本身没变,但系统实时性大打折扣。
5.4 个人踩坑后的几点心得
这一路折腾下来,最大的体会是:单片机驱动的难点往往不在代码本身,而在“查手册”和“看波形”。LSM6DSVE 的寄存器其实不多,但每个位都有明确含义,只要把 ODR、FS、IF_INC、DRDY_G 这几个关键点搞清楚,轮询读取基本不会走弯路。不要凭记忆写寄存器值,ST 的数据手册随芯片版本更新会有差异,哪怕是同一系列,量程位和老型号也可能不一样。
具体到代码习惯上,我建议把寄存器地址和关键位定义成宏,不要直接在函数里写一堆裸数字。比如 0x22 这个地址,写成LSM6DSVE_REG_OUTX_L_G,一看就明白。后续要适配其他传感器,或者发布代码给别人看,都会省很多事。
最后再分享一个小技巧:如果轮询时发现 DRDY_G 一直为 1,导致同一组数据被连续读了很多次,可以在读取数据之后主动再读一次 STATUS_REG,确认状态位已经被硬件清零。正常逻辑下,读输出寄存器会自动清除 DRDY,但加这一步确认能让你的代码更稳,尤其在调试初期,它能帮助你确认我前面说的“读取输出寄存器会清除状态位”这个行为是否真的生效。
轮询打通之后,下一步我准备用 LSM6DSVE 的 FIFO 和中断组合,把陀螺仪数据读取从主循环里解放出来,这样主循环就能专注于算法任务。到那一步,再回来写第二篇对比一下两种方案的 CPU 占用差异,会更有说服力。