# project_open **Repository Path**: cui-jiahao666/project_open ## Basic Information - **Project Name**: project_open - **Description**: 基于FreeRTOS的多传感器数据采集与TCP/MQTT通信系统开发 - **Primary Language**: Unknown - **License**: GPL-2.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 3 - **Forks**: 0 - **Created**: 2026-05-12 - **Last Updated**: 2026-09-16 ## Categories & Tags **Categories**: Uncategorized **Tags**: Cpp, stm32h743, FreeRTOS, LWIP ## README # 基于 STM32H743 + FreeRTOS + LwIP + MQTT 的多传感器数据采集系统 基于 STM32H743 的多传感器数据采集系统,使用 FreeRTOS 多任务框架,实现了多种 MEMS 传感器的数据读取,以及 UART / TCP Socket / MQTT 三条数据通路的稳定上传。 本项目参考 [eetree 大佬的开源项目](https://www.eetree.cn/project/3089),在此基础上完善。 系统通过:UART 串口输出调试信息 / TCP Socket 发送数据 / MQTT 协议上传到云端,重点解决了 FreeRTOS 多线程环境下 MQTT 与 Socket 并发访问 lwIP 导致的数据竞争问题。 ## 硬件平台 - 主控:NUCLEO-H743ZI2(STM32H743ZI,Cortex-M7) - 传感器扩展板:IKS4A1 - 运动传感器:LIS2MDL(磁力计)、LSM6DSV16X(加速度 + 陀螺仪)、LIS2DUXS12(加速度计)、LSM6DSO16IS(加速度 + 陀螺仪,含 ISPU) - 环境传感器:STTS22H(温度)、LPS22DF(温度 + 气压)、SHT40AD1B(温度 + 湿度) ## 功能特性 - 7 路 MEMS 传感器数据采集(I2C 总线) - 三种数据通路:UART 串口调试输出 / TCP Socket 发送 / MQTT 协议上传云端 - FreeRTOS 多任务环境下的线程安全数据上传(互斥锁保护临界区) - LwIP 协议栈网络通信 ## 目录结构 ``` ├── App/ # 应用层:lwip 配置与网络任务 │ ├── lwip.c / lwip.h ├── Src/ # 主要源码 │ ├── main.c # 入口 │ ├── freertos.c # FreeRTOS 任务创建 │ ├── mems_control.c # 传感器读取控制 │ ├── app_mems.c # 传感器应用层 │ ├── data_pack.c # 数据打包 │ ├── chry_ringbuffer.c # 串口环形缓冲区(CherryRB) │ ├── usart.c / gpio.c / usb_otg.c ... ├── Inc/ # 头文件 ├── Target/ # lwipopts.h、ethernetif(以太网接口) ├── Middlewares/ # FreeRTOS 等第三方中间件 ├── H743_SENSOR_DEMO.uvprojx # Keil MDK 工程文件 └── startup_stm32h743xx.s # 启动文件 ``` ## 构建与烧录 1. 使用 Keil MDK-ARM 打开 `H743_SENSOR_DEMO.uvprojx` 2. 需安装 STM32H7xx_DFP 设备支持包(Keil Pack Installer) 3. 使用 ST-Link 烧录至 NUCLEO-H743ZI2 ## 核心问题与解决方案 ### 1. I2C 通信的互斥保护 在读取传感器数值时,有 MQTT 和 TCP 两个任务需要读取,但 I2C 通信是串行总线,不支持并发的访问,所以加入了互斥锁进行保护。互斥锁解决的是临界区问题,即保证多个任务对临界资源的独占式访问(同一时刻只能被一个任务安全访问的资源)。 **互斥锁与二进制信号量的区别(FreeRTOS)** **互斥锁的优先级继承** 假设系统中有 3 个任务,优先级从高到低为:Task_H(高)、Task_M(中)、Task_L(低),核心过程如下: 1. Task_L 先获取互斥锁,开始访问临界资源。 2. Task_H 就绪并尝试获取同一互斥锁,因锁被 Task_L 持有,Task_H 进入阻塞态。 3. 内核触发优先级继承:将 Task_L 的优先级临时提升至与 Task_H 相同的优先级(即最高优先级)。 4. 此时 Task_M 就绪,因 Task_L 的优先级已被提升至最高,Task_M 无法抢占 Task_L,Task_L 可继续执行并快速释放互斥锁。 5. Task_L 释放互斥锁后,其优先级自动恢复至原始优先级,内核唤醒 Task_H,Task_H 获取互斥锁并执行。 **递归互斥锁** 普通互斥锁存在同一任务不能多次获取同一互斥锁的问题,否则会导致任务自身阻塞(死锁);而递归互斥锁可以解决,它允许同一任务多次获取互斥锁,底层通过"递归计数器"实现: - 任务第一次获取递归互斥锁时,计数器设为 1,任务持有互斥锁。 - 同一任务再次获取该互斥锁时,计数器加 1,任务仍持有互斥锁(不阻塞)。 - 任务每释放一次互斥锁,计数器减 1,直到计数器减为 0 时,互斥锁才真正被释放,其他任务可获取。 我们在获取数据长度的时候加入了互斥锁,如果其他任务没有释放锁,那么是不能读取传感器的数据的。在每个任务里面调用安全函数 `get_one_sensor_data_safe`,这样其他没有释放锁的任务就会阻塞在这里: ```c uint8_t get_one_sensor_data_safe(Sensor_Type type, uint8_t *data, sensor_channel_status ch_status) { uint8_t length = 0; if (xSemaphoreTake(g_sensor_mutex, pdMS_TO_TICKS(500)) == pdTRUE){ //xSemaphoreTake是获取锁的,并且最多等待500ms length = get_one_sensor_data(type, data, ch_status);//获取传感器数据长度 xSemaphoreGive(g_sensor_mutex);//释放锁 }else{ printf("sensor mutex timeout!\r\n"); } return length; } ``` ### 2. LwIP 内部的 API 线程安全 **LwIP 的三种 API 模式** **RAW API**:内核回调型的 API。当新建了一个 TCP/UDP 连接,将接收到数据时需要进行处理,把处理该数据的操作封装为一个函数,如果收到了数据,内核会调用注册的函数,这个过程称为回调,注册函数称为回调函数。这个回调函数就是应用程序(可以在函数内做任何事情:处理数据/发送数据),这样使用 RAW API 就以回调函数的形式成为了内核代码的一部分,用户应用程序和内核程序处于同一线程中,省去了任务通信和切换任务开销。 但缺点也是很明显的: - 在利用 RAW API 开发复杂业务逻辑时,会很麻烦。 - 在操作系统中,应用程序代码和内核代码处于同一线程,会节省任务通信和切换任务开销,但是应用程序的执行会制约内核程序的执行,在应用程序执行的过程中,内核程序将不可能得到运行,会降低数据包的处理效率,导致丢包。 **NETCONN API**:基于操作系统的 IPC 机制(信号量与邮箱机制),将 LwIP 的内核代码与应用程序代码分离成了独立的线程,内核线程只负责数据包的 TCP/IP 封装和拆封。操作系统中,内核会被实现为一个独立的线程,为 tcpip_thread,可以根据任务的重要性,分配不同的优先级给这些线程。 NETCONN 的优缺点: - 相较于 RAW API 简化了编程工作,操作网络连接更加方便。但是内核程序和网络应用程序之间的数据包传递,需要依靠操作系统的信号量和邮箱机制完成,消耗了更多的时间和内存。 - 相较于 Socket API 避免了内核程序和网络应用程序之间的数据拷贝,提升了数据传输的效率。 **Socket API**:套接字。 **问题**:在多线程环境下直接调用 mqtt_publish 和 socket 发送接口,会导致 lwIP 内部资源竞争,从而出现数据丢失或连接异常。MQTT 自带了 mqtt_publish 函数用于发布数据,但如果在线程中直接调用这个函数,而且 socket 也同时进行连接,就会导致多个线程调用 lwIP 导致数据的竞争。 **原因**:lwIP 的 RAW API(如 mqtt_publish)不是线程安全的,必须在 tcpip_thread 中执行。而 socket API 虽然可以在任意线程调用,但其内部通过 netconn API 和 tcpip_callback,最终仍然在 tcpip_thread 中串行执行。 **解决方法**: - 对于 MQTT(RAW API):将 mqtt_publish 函数写入 tcpip_callback 中(投递消息),进入 tcpip_thread 线程中,并进入到 do_mqtt_publish_in_tcpip(),执行 mqtt_publish 函数。(使用 tcpip_callback 将 mqtt_publish 投递到 tcpip_thread 中执行,保证线程安全) - 对于 Socket API:lwIP 的 socket 编程中的发送函数是映射为 lwip_send(),并调用 netconn_write_partly(),可以在 TCP 中稳定的发送数据,在内部调用 netconn_write_partly() 函数,并在内部将函数映射到 tcpip_callback 中,在 tcpip_thread 中排队。(直接调用 lwip_send 即可,其内部会自动切换到 tcpip_thread 执行) 统一将网络操作串行化到 tcpip_thread 中执行,避免多线程竞争 lwIP 内部资源,提高系统稳定性。 ### 3. TCP Server 和 MQTT 争抢 tcpip 线程 **问题**:之前 TCP 线程采用的是 NETCONN 通信,netconn_write 阻塞等待 tcpip 线程,同时 MQTT 的 tcp_write 也需要 tcpip 线程,两者互相阻塞。 - TCP 调用的 netconn_write 的工作流程是:TCPServerTask 调用 netconn_write → 把"发送请求"放入 tcpip 线程的消息队列 → TCPServerTask 阻塞等待,等 tcpip 线程处理完回复 → tcpip 线程处理发送请求,发完后回复 → TCPServerTask 收到回复,继续运行。 - 而 MQTT 通过 tcpip_callback 投递函数的工作流程是:MQTTPublishTask 调用 tcpip_callback → 把"mqtt_publish 函数"放入 tcpip 线程的消息队列 → MQTTPublishTask 不等待,立即返回继续运行 → tcpip 线程空闲时执行 mqtt_publish → mqtt_publish 写入 ringbuffer,调用 tcp_write → 等待 TCP ACK 回来,触发 sent_cb。 **原因**:就会导致在某一时刻在 tcpip 线程中,网络 ACK 来了,但线程还在处理 TCP Server 的发送流程,MQTT 的 mqtt_publish 还在队列中等待;当缓冲区 ringbuffer 绕回发送时,mqtt_output_send 需要分成两段写,第一段写入 tcp_sndbuf,需要 ACK 来推进 get 指针进入下次轮回(get 是已经发送数据的地址指针,put 是下次写入数据的地址指针),但此时 TCPServerTask 的 send 也在等待 ACK,就会导致两个连接的 ACK 处理互相排队。 当处理 TCP Server 的 ACK 时,TCPServerTask 的 send 写入 TCP 发送窗口 tcp_sndbuf,返回并开始 osDelay,此时 TCPServerTask 的 send 处理完成,tcpip 线程进入空闲状态,并开始处理 tcpip 线程积压的 ACK 并推进 get 指针,但是对于 MQTT 来说第二段的 tcp_write 可能已经被其他数据填满(TCP send),就会返回 ERR_MEM 内存不足的情况。 MQTT 连接用 tcp_sndbuf,第一段写入 tcp_sndbuf,并等待 ACK;TCPServerTask 连接也用 tcp_sndbuf,写入 tcp_sndbuf,并等待 ACK;导致 tcp_sndbuf 剩余空间减少,MQTT 第二段数据尝试写入,发现内存不足(清空发送数据的条件是接收方返回一个接收 ACK 信号才会清空,tcp_sndbuf 承载了两个连接的数据,ACK 还没回来时,里面堆积了两条连接的待确认数据,导致空间不足,新数据写不进去)。 **解决方法**:为什么把 NETCONN API 换成 Socket API 就可以解决了?因为 netconn_write 将消息发送给 tcpip 线程,阻塞等待回复;但是 Socket send 将数据写入缓冲区并立即返回,在 tcpip 线程空闲时,自行取出缓冲区数据发送。与 netconn_write 不同的是,send 函数调用的 netconn_write_partly() 存在超时等待,当等不到 ACK 时,就放弃,让出调度给其他任务,这样 TCP 就不会阻塞 MQTT 的任务。 修改方法:将 TCP Server 改为 Socket API,Socket API 有独立的同步机制,不会和 tcpip_callback 产生死锁。netconn API 内部也向 tcpip 线程发消息等回复,混用必然死锁。 ### 4. MQTT 和 TCP 启动时序冲突 **问题**:在最初时,初始化阶段首先启动 tcp_server_init(),然后再调用 mqtt_init(),但这样会存在一个问题,TCPServerTask 调用 netconn_accept(),并向 tcpip 线程注册连接请求,并阻塞等待。问题在于,在 netconn_accept 等待期间,tcpip 线程需要持续轮询端口是否有新连接,这个轮询操作会和 MQTT 握手的收包处理争抢 tcpip 线程的执行时间。 **原因**:如果二者同时启动,每隔很短的时间,netconn_accept 就会向 tcpip 线程发送一次"检测连接"的消息,如果轮询堆积过多就会导致 MQTT 的连接超时,继而导致连接失败。 **解决方法**:我们先启动 MQTT,当 MQTT 连接稳定时,再启动 TCPServerTask;MQTT 连接建立后,已经进入稳定的心跳维持阶段,这时候 TCP Server 的轮询对 MQTT 的影响就微乎其微了。 ```c if (netif_is_link_up(&gnetif)) { if (mqtt_started == 0) { mqtt_started = 1; bsp_mqtt_init(); } if (tcp_started == 0 && bsp_mqtt_is_connected()) { tcp_started = 1; osThreadDef(TCPServerTask, TCPServerTask, osPriorityBelowNormal, 0, 4096); osThreadCreate(osThread(TCPServerTask), NULL); } } ``` ## 参考 - [eetree 开源项目](https://www.eetree.cn/project/3089) - [CherryRB 环形缓冲区](https://github.com/cherry-embedded/CherryRB)(串口接收) ## 许可证 [GPL-2.0](LICENSE)