
简介一份STM32F407与ADS8688结合的完整源码工程面向嵌入式开发者特别是需要掌握高精度ADC采集与RT-Thread实时操作系统应用的工程师。资源以RT-Thread为运行平台实现了STM32F407对ADS8688的I2C驱动与数据采集涵盖ADC初始化、中断服务、数据滤波与错误处理等关键环节可作为工业采集、物联网传感节点等场景的参考设计。包内共4182个文件压缩后29.01MB以c源文件、h头文件、s汇编文件及sconscript构建脚本为主同时包含RT-Thread内核相关配置与文档目录结构完整便于直接导入KEIL或IAR工程学习。目前已有3005人下载学习是网上少有的STM32F407ADS8688RT-Thread完整驱动实例。通过该工程读者可深入理解I2C总线时序与多字节读写实现掌握ADS8688寄存器配置与采样率设定方法并学习如何在RT-Thread中创建任务、利用信号量或中断同步完成数据采集。项目还包含错误检测机制可帮助提升驱动代码的健壮性适合有一定STM32基础、希望进阶RTOS与高精度ADC开发的读者。1. 双 ADC 高速采集工程ADS8688 在 STM32F407 上的 Rt-Thread 落地实践拿到一个 STM32F407 ADS8688 的完整源码工程时我第一反应不是看原理图而是先翻线程文件和 SPI 初始化代码。原因很简单ADS8688 这颗 16 位 8 通道 ADC 本身不复杂真正决定采样质量的是时序和 DMA 的配合方式。这个工程解决的核心问题是在 Rt-Thread 实时操作系统上打通 SPI DMA 的数据链路让 8 通道模拟量采集在中断驱动下稳定运行而不是靠轮询硬等。项目的亮点在于它把裸机思维换成了 RTOS 思维——采样任务、数据处理、对外通信各占一个线程通过信号量握手这套架构拿到其他传感器项目里也能直接复用。适合正在做数据采集、工业控制或者便携仪表的嵌入式开发者尤其是想看看 Rt-Thread 下多线程采集怎么设计的人。2. 技术选型解析为什么是 STM32F407、ADS8688 和 Rt-Thread 的组合2.1 STM32F407 的资源定位STM32F407 在这套系统里承担的是主控和搬运工的角色。这颗芯片主频能跑到 168MHz带 FPU处理 ADS8688 送进来的 16 位采样值绰绰有余。工程选它还有个重要原因它有三路 SPI 控制器其中 SPI1 和 SPI4 可以直接映射 DMA 请求这对高速连续采样非常关键。我用过的很多 F1 系列板子SPI 的 DMA 映射关系没有 F4 这么灵活做双缓冲会多一些限制。从资源分配上看这个工程把 SPI1 用作 ADS8688 的通信总线DMA2 的 Stream 0 负责接收数据Stream 3 负责发送命令。剩下的 USART 资源用来做调试信息输出和上位机通信。整个资源划分属于比较标准的“采集 处理 上报”三段式没有为了炫技把外设功能堆叠得很复杂。这种克制是我喜欢的地方——嵌入式工程最怕的就是“什么都能干出了问题什么都不敢动”。2.2 ADS8688 的特性与工作模式ADS8688 是 TI 家的 16 位、8 通道、SAR 型 ADC单芯片支持 0 到 5V 或 0 到 10V 的输入范围不需要外部运放做电平搬移。片上自带基准源常温下精度够用。它最方便的一点是内部有通道选择寄存器和自动扫描模式可以配置成“写完寄存器之后自动把 8 个通道扫一遍”也可以“手动指定采某一个通道”。在这个工程里用的方式比较有意思它配置成了手动模式但并不是真的“手动”——每次采样前由主控通过 SPI 写入通道选择命令然后等待 DRDY 引脚拉低表示转换完成再去读数据。这种方式看起来多了一步操作实际上给 DMA 和线程调度留出了可控的节拍比自动扫描模式更好排查问题。自动扫描模式下如果通道配置错了你看到的是一堆错位的数据手动模式下错误只会出现在某一个通道定位成本低很多。2.3 Rt-Thread 在这个项目中承担的角色Rt-Thread 在这个工程里不是一个摆设而是整个数据流向的调度核心。ADS8688 每次转换完成会通过 DRDY 引脚触发一次中断中断服务函数里释放一个信号量ADC 采样线程等着拿这个信号量。拿到之后去 DMA 缓冲区把数据拷贝出来做必要的滤波或格式转换再放到消息队列里交给发送线程。这种设计的价值在于采样线程不会被 SPI 通信阻塞。如果丢开 RTOS裸机开发时你需要在一个大循环里不断查询 DRDY 引脚状态或者靠中断嵌套来保证采样连续性代码写起来非常容易乱。而在 Rt-Thread 里每个功能模块各占一个线程优先级从高到低分别是 DRDY 中断、采样线程、发送线程。中断负责“通知”采样线程负责“处理”发送线程负责“输出”。每个线程的栈大小也经过调整采样线程给了 2048 字节发送线程 1024 字节剩下系统默认的 512 字节给空闲线程。这套划分在资源紧张的单片机上还挺合理的既不至于栈溢出也不浪费内存。3. 源码工程目录结构与初始化流程3.1 工程目录的功能划分从源码包的目录结构能看出作者对代码管理的思路顶层分为applications、drivers、bsp和components四块。这不是随口说的分层每一层的职责边界很清晰。applications里放的是和业务逻辑直接相关的文件比如adc_app.c、data_process.c、uart_report.cdrivers里放的是板级外设驱动ads8688.c和ads8688.h就在这里bsp是板级支持包负责时钟初始化、引脚复用配置components挂的是 Rt-Thread 的官方组件比如 FinSH 命令行工具。project_root/ |-- applications/ | |-- main.c | |-- adc_app.c | |-- data_process.c | -- uart_report.c |-- drivers/ | |-- ads8688.c | |-- ads8688.h | |-- spi_dma.c | -- gpio_config.c |-- bsp/ | |-- board.c | |-- clock_config.c | -- rtconfig.h |-- components/ | -- finsh/ -- rt-thread/ |-- src/ -- include/这种组织方式对我这种习惯在多个平台间移植代码的人来说非常友好。换一块不同型号的 STM32 芯片时一般只需要改bsp层和驱动层的少量引脚配置applications层的业务代码基本不用动。对比我之前见过的一些“全部塞进 main.c”的项目这个工程至少省出了一半的排查时间。3.2 系统时钟初始化链路系统上电后先跑的是clock_config.c里的system_clock_init()函数。它做的事情可以概括为把外部 8MHz 晶振信号通过 PLL 倍频到 168MHz 作为系统主频然后分频给 AHB、APB1、APB2 总线最后单独给 SPI1 和 DMA2 的时钟使能。ADS8688 的通信速率上限有 1MHz 的 SPI 时钟限制所以 SPI1 的预分频器配的是 4 分频实际 SPI 时钟为 168MHz / 4 42MHz。这个分频值猛一看可能会觉得超过了 ADS8688 的上限但仔细看 SPI 控制寄存器会发现工程里设置的是 SPI 时钟的“位率预分频”最终 SCLK 频率 PCLK2 / 4 42MHz / 4 10.5MHz对 ADS8688 来说还有余量。调试时如果 SPI 通信不稳定可以把预分频从 4 改成 8SCLK 降到 5.25MHz一般就能排除时序问题。3.3 GPIO 复用映射配置GPIO 配置这一块是最容易翻车的地方因为 ADS8688 的 DRDY 引脚必须接在支持外部中断的引脚上而每个引脚的 EXTI 线是固定的。工程里把 DRDY 接到了 PG6对应 EXTI6 中断线。SPI1 的四个引脚用了 PA5SCK、PA6MISO、PA7MOSI、PA4NSS四者全部设置为复用功能推挽输出或输入速度等级设为高速。有个细节值得注意ADS8688 的 NSS 引脚虽然接了 PA4但工程里没有启用 STM32 的硬件 NSS 管理而是通过软件控制 PA4 的电平来产生片选信号。这样做的好处是 SPI 时序完全由软件掌控适合 ADS8688 这种“先发命令再读数据”的时序协议。缺点是每次通信前要多写一行HAL_GPIO_WritePin来控制片选性能上有微小开销但对这个工程来说完全够用。4. 核心代码拆解SPI-DMA 链路与采样线程的完整实现4.1 底层 SPI 读写接口封装ADS8688 的底层驱动不复杂核心就是通过 SPI 发一个命令字节然后读回转换结果。工程里把这步封装成了两个函数一个是读取单通道数据一个是写配置寄存器。先看读数据的实现static uint16_t ads8688_read_channel(uint8_t ch) { uint8_t cmd (ch 4) | 0x01; // 通道号编码到 D7:D4末尾固定位为 1 uint8_t tx_data[3] {cmd, 0x00, 0x00}; uint8_t rx_data[3] {0, 0, 0}; uint8_t *tx_ptr tx_data; uint8_t *rx_ptr rx_data; ADS8688_NSS_LOW(); // 拉低片选启动 SPI 帧 // 使用 DMA 发送和接收一次传输完整 24 位(3 字节)命令加数据 spi_dma_transfer(tx_ptr, rx_ptr, 3); ADS8688_NSS_HIGH(); // 片选拉高结束本次通信 // ADS8688 输出的 16 位数据在 rx_data[1] 和 rx_data[2] 中 return (rx_data[1] 8) | rx_data[2]; }这段代码在逻辑上是好理解的ADS8688 的 SPI 协议规定一次完整的通信是 24 个 SCLK 周期前 8 位是命令字后 16 位是转换结果。命令字的高四位是通道地址D3 位用来区分读配置还是触发转换。(ch 4) | 0x01这个写法里的0x01是控制字里的一位标志位告诉芯片执行一次单通道转换并读取结果。代码里最需要注意的地方是spi_dma_transfer(tx_ptr, rx_ptr, 3)这个调用它不能简单地用阻塞式 SPI 接收替换。因为 ADS8688 的 SDO 在发送命令阶段同时会把上一次转换结果移出来你发完命令之后紧接着的 16 个时钟周期才是本次有效数据。如果中间插入任何 delay 或者 CPU 干预时序就错位了。DMA 在这里的作用就是把发送和接收两个动作锁在一个连续的时间窗内完成避免CPU 调度带来的不确定性。4.2 DMA 双缓冲与环形队列的配合单纯用 DMA 接收还不够工程里还引入了双缓冲机制这是整个采集链路里我认为设计得最漂亮的一环。#define ADC_DMA_BUF_SIZE 256 static uint16_t dma_rx_buffer[2][ADC_DMA_BUF_SIZE]; static volatile uint8_t dma_active_buf 0; static volatile uint8_t dma_buf_full 0; void spi_dma_init(void) { // 配置 DMA2_Stream0方向为外设到内存数据宽度 16 位 // 内存地址递增外设地址固定为 SPI1-DR // 开启传输完成中断和半传输完成中断 } void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi-Instance SPI1) { dma_buf_full 1; rt_sem_release(adc_sem); // 唤醒采样线程 } }双缓冲的思想是DMA 在往缓冲区 A 写数据的同时CPU 可以处理缓冲区 B 里的旧数据。ADC 采样线程拿到信号量之后先用dma_active_buf判断当前哪个缓冲区满了然后拷贝缓冲区内容到线程栈上的局部数组里后续做滤波和格式转换。注意这里的dma_buf_full是一个易失变量用来防止重复处理同一个缓冲区但这个标志位必须在主循环里尽快清除否则会漏掉下一批数据。4.3 采样线程的核心循环逻辑采样线程的逻辑直接体现了 RTOS 的思路等待信号量、取数据、处理数据、发送数据。线程函数本身是一个 while 循环代码不长但每一行都有作用static void adc_sample_thread_entry(void *parameter) { uint16_t raw_value 0; uint8_t channel 0; while (1) { // 等待 DMA 完成中断释放的信号量超时时间设为永久等待 rt_sem_take(adc_sem, RT_WAITING_FOREVER); // 从当前活跃的 DMA 缓冲区取数据 raw_value dma_rx_buffer[dma_active_buf][channel]; dma_buf_full 0; // 清标志允许 DMA 继续写 // 简单中值滤波连续采 5 次去掉最大最小值 filtered_value median_filter(raw_value); // 将处理后的数据放入消息队列交给发送线程 rt_mq_send(data_mq, filtered_value, sizeof(filtered_value)); } }这个逻辑里信号量是生产者和消费者之间的桥梁DMA 中断是生产者采样线程是消费者。rt_sem_take是阻塞等待意味着没有数据的时候线程会挂起不占用 CPU 时间。这跟裸机里的“轮询检测标志位”有本质区别——裸机里你需要在一个死循环里不停去查标志位CPU 空转着也在跑现在线程挂起了CPU 可以去做别的事情比如 FinSH 命令的处理。4.4 初始化顺序与参数的配置细节最后看初始化部分的代码工程里的ads8688_hw_init()函数把整个外设的启动顺序写得很清晰int ads8688_hw_init(void) { // 1. 初始化 GPIO 引脚复用 gpio_config_ads8688_pins(); // 2. 初始化 SPI 外设 spi1_init(); // 设置为主模式clock 10.5MHzCPOL0, CPHA1 // 3. 初始化 DMA 通道 spi_dma_init(); // 4. 配置 ADS8688 内部寄存器选择输入范围为 0~5V ads8688_write_reg(ADS8688_REG_INPUT_RANGE, 0x12); // 5. 创建线程和信号量 rt_thread_init(adc_thread, adc_sample, adc_sample_thread_entry, RT_NULL, adc_thread_stack[0], sizeof(adc_thread_stack), 15, 5); // 优先级 15时间片 5 rt_sem_init(adc_sem, adc_sem, 0, RT_IPC_FLAG_FIFO); rt_mq_init(data_mq, data_mq, mq_pool[0], sizeof(uint16_t), 16, RT_IPC_FLAG_FIFO); return 0; }可以看到步骤 1 到 3 是 STM32 外设初始化步骤 4 是 ADS8688 配置步骤 5 是 RTOS 资源创建。这个顺序不能乱如果先创建线程再初始化硬件线程可能在信号量还没有就绪的情况下就去访问寄存器导致未定义行为。另外ADS8688 输入范围寄存器配置成了0x12这个值是“通道 0 到 7 全部选择 0 到 5V 范围”的意思如果板子的前端调理电路输出的是 0 到 10V这个寄存器要改成0x33才行否则采样值会提前饱和。5. 避坑记录SPI 时序、DMA 配置和 Rt-Thread 资源竞争三类问题5.1 DRDY 引脚电平抖动导致误触发中断现象初始化完成后采样线程频繁被唤醒串口打印的数据毫无规律像是采样到了噪声而不是真实信号。原因ADS8688 上电后 DRDY 引脚默认处于高电平但主控在初始化 SPI 引脚时如果把 DRDY 所在引脚配置成了浮空输入外部干扰或者内部上拉电阻的差异会让 DRDY 出现毛刺。这些毛刺落在 EXTI6 中断线上就会触发无效的信号量释放。解决在 GPIO 配置里将 DRDY 引脚设为上拉输入然后调试时先用示波器确认引脚的静态电平是否符合预期。另外在 ADS8688 写入通道范围配置之前先对 DRDY 做一次软件清零——写入配置寄存器后延时 10 微秒再读取 DRDY 状态确认其为高电平后才使能 EXTI 中断。5.2 DMA 传输半完成中断与完成中断的优先级冲突现象采样数据出现间歇性跳变间隔大概几百毫秒出现一次。直接在 SPI 接收中断里读取数据寄存器时正常换成 DMA 后偶尔丢数据。原因DMA2 的半传输中断和传输完成中断如果配置了同等优先级两个中断同时在挂起队列里等待处理时CPU 可能先处理半传输中断此时数据传输还在继续但 DMA 的缓冲区指针已经切到了下一个缓冲区开头。如果采样线程在此时取走了还没写完的缓冲区数据拿到的就是半新半旧的内容。解决给传输完成中断设置更高的抢占优先级并且在采样线程里加一个简单的校验——读取dma_active_buf时顺带读一下 DMA 当前剩余的传输计数值hdma-Instance-NDTR只有当 NDTR 等于 0 或者等于缓冲区大小时才执行拷贝。5.3 Rt-Thread 信号量初始化顺序不当导致启动时丢数据现象系统启动的前几次采样数据全部是 0或者命令字没有被正确写入 ADS8688。原因rt_sem_init的初始计数值 0信号量初始化放在 DMA 初始化之后。但 SPI 外设已经开启了 DMA 接收就意味着外设随时可能产生完成中断。如果 DMA 在信号量创建之前就完成了第一次传输中断里调用rt_sem_release而信号量对象还不存在这个中断服务函数里就会产生非法内存访问。解决初始化顺序调整为先创建 Rt-Thread 的资源信号量、消息队列再使能 DMA 传输。这个工程里我把rt_sem_init和rt_mq_init提前到了gpio_config_ads8688_pins()之后、spi_dma_init()之前之后连续上电 100 次没有复现丢数据的问题。5.4 SPI 时钟极性和相位不匹配导致读取的数总是 0xFFFF现象写入命令后读回来的数据始终是满量程值换成读取寄存器也不对。原因ADS8688 的 SPI 协议要求 CPHA1也就是数据在 SCLK 的第二个边沿采样。很多 STM32 初始化代码默认配置的是 CPHA0这时主控发出的命令字没问题但读回来的数据位对齐错误导致 16 位全部是 1。解决设置 SPI 的时钟极性为低电平CPOL0相位为第二个边沿采样CPHA1对应 HAL 库里就是SPI_PHASE_2EDGE。这个细节如果不看芯片手册真的很难一眼发现我当初也栽在这一步上排查了整整一天。5.5 缓冲区下标越界导致 RT-Thread 报错现象系统跑一段时间后突然停止调度FinSH 打印内存访问违例的信息。原因DMA 持有的是缓冲区 A 的地址如果 RTOS 代码里不小心把队列容量设置得比 DMA 缓冲区还大导致拷贝数据时越界写到 DMA 正在写的区域就会出现内存错乱。解决把消息队列容量设置为 16 条记录每条记录 2 字节总占用 32 字节远小于 DMA 缓冲区的 512 字节。同时在线程入口处加一个断言检查dma_active_buf的值是否只可能是 0 或 1防止异常情况直接跳过缓冲区边界。6. 二次开发指南把单通道采集改造成多通道自动扫描系统6.1 改造思路从手动选通道到通道轮询原工程的单通道采集模式一次仅采一个通道任务简单但效率有限。要改成多通道自动采集最简单的做法不是动底层驱动而是利用 Rt-Thread 的定时器机制在采样线程里加一个通道轮询变量。每轮采样结束后把通道号加一超过 7 就回绕到 0。static uint8_t current_channel 0; static void adc_sample_thread_entry(void *parameter) { uint16_t sample_value 0; while (1) { rt_sem_take(adc_sem, RT_WAITING_FOREVER); // 轮询切换通道 ads8688_set_channel(current_channel); sample_value ads8688_read_convert_result(); // 把通道号和采样值打包成 32 位数据发送 uint32_t packed_data ((uint32_t)current_channel 16) | sample_value; rt_mq_send(data_mq, packed_data, sizeof(packed_data)); current_channel; if (current_channel 7) { current_channel 0; } } }注意这段代码和原工程的差别原工程是在 DMA 数据就绪后直接读固定通道这里需要先调用ads8688_set_channel来切换 ADS8688 的输入多路开关。ADS8688 的通道切换命令和读取数据命令可以合并在同一个 SPI 帧中但为了代码可读性工程里通常拆成两步。实测在 16 位分辨率下通道切换后需要一个稳定时间所以ads8688_read_convert_result()内部要等 DRDY 引脚拉低后再读这个依赖底层时序改上层逻辑时不要动它。6.2 数据打包与上位机通信格式扩展多通道采样带来的一个问题是单通道 16 位的数据格式不够用了。原来的rt_mq_send每次发 2 字节现在要带通道信息需要至少 3 字节。工程里的packed_data用了 4 字节高 8 位放通道号低 16 位放采样值这样上位机解析时不需要额外同步字节直接把 4 字节当成一个记录处理。如果你觉得从字节流里解析太费劲可以改成发送结构体效果更清晰typedef struct { uint8_t channel; uint16_t value; } adc_data_packet_t;用结构体发送的好处是代码可读性更高而且 Fort 或 C# 上位机里可以用预设的字节序来直接映射结构体前提是结构体加了__packed修饰否则编译器可能插入填充字节造成通信双方字节不一致。这个坑我在项目里也踩过32 位对齐下sizeof(adc_data_packet_t)可能变成 6 而不是 4所以上机联调前先打印一下 sizeof 的结果。6.3 验证方法用手头信号源做通道区分测试改完代码后怎么做验证我的习惯是先接一个稳定的直流电压到通道 2 的输入端其余通道悬空。跑起来之后观察串口输出的数据如果通道 2 的数据明显大于其他通道说明通道切换逻辑基本正确。再把直流电压从 0V 慢慢加到 3V观察采样值是否线性变化同时每 0.1V 记录一次数据做一条两点间的斜率计算斜率应当在 65535 / 5.0 附近即大约每伏 13107 LSB。更严格一点可以在上位机里做频率分析输入一个 1Vpp、100Hz 的正弦波用工程里现在的轮询方式去采每个通道的采样率只有原来的 1/8。如果发现波形严重失真可以把 ADS8688 配置成自动扫描模式让芯片内部自己选通道MCU 只需要在转换完成后读取对应通道的数据即可。这种改动不需要动 STM32 的 SPI 和 DMA 代码只需要改驱动层的控制命令字节。不过自动扫描模式和手动模式在寄存器配置上有区别改前一定先对着数据手册确认命令字格式。6.4 一个值得保留的小习惯采样率估算公式每次改完采集程序我都会在注释里保留一段采样率估算公式防止过了一段时间自己都忘了这个配置的极限在哪。当前配置下单通道采样率的理论值是SPI 时钟 10.5MHz / 24 个时钟周期约 437kSPS。ADS8688 的满量程吞吐率是 500kSPS所以 SPI 时钟是瓶颈。当切换成 8 通道轮询后每个通道的实际采样率降到约 54kSPS对于环境监测、电力数据采集这类应用来说完全够用。但如果把 SPI 预分频改成 2SCLK 提升到 21MHz单通道理论采样率就能到 875kSPS接近芯片极限这时就要关注转换时间与 SPI 传输的时间竞争说不定又要多踩几个坑。从这段时间拆这个工程的经验看嵌入式采集类项目最值得学习的不是某个具体芯片的寄存器而是把一个外设接进 RTOS 时的架构思路。从那以后我每次新建采集项目都会先画一下中断、信号量、线程之间的数据流图再开始写代码虽然前期多花了半小时后面调试省的时间通常是十倍以上。希望这篇拆解能帮你把这个工程跑起来也给你自己的采集系统设计带来一点参考。本文还有配套的精品资源点击获取