1. 为什么工业现场偏爱“DMA+485”这对黄金搭档?

如果你在工厂车间里待过,或者捣鼓过PLC、传感器这些工业设备,那你肯定对“变送器”和“485总线”这两个词不陌生。简单来说,变送器就是个“翻译官”,它把现场的压力、温度、流量这些物理信号,转换成标准的电信号(比如4-20mA电流或者数字信号),然后通过总线传出去。而485总线,你可以把它想象成一条“工业高速公路”,它最大的特点就是抗干扰能力强、传输距离远(上千米没问题)、还能一条线上挂好多个设备,特别适合工厂这种环境复杂、设备分散的场合。

那么,我们的主角STM32单片机怎么去这条“高速公路”上收数据呢?最直接的想法就是用串口(USART)去收。但这里有个坑:工业现场的数据可能来得又快又密,比如几十个温度传感器同时上报数据。如果你的单片机程序还傻傻地用HAL_UART_Receive()这种轮询方式,或者每收到一个字节就进一次中断,那CPU基本就别干别的了,光忙着收数据就能被“累死”,主程序卡得动弹不得。

这时候,DMA(直接存储器访问) 就该登场了。它好比是给CPU配了一个专职的“数据搬运工”。你只需要告诉DMA:数据从串口接收寄存器搬到内存的哪个数组,搬多少。之后,串口收到数据,DMA就默默地在后台完成搬运,完全不需要CPU插手。CPU解放出来,可以安心地去执行控制逻辑、算法计算这些更重要的任务。所以,“DMA+485”这个组合,就成了实现稳定、高效、实时工业数据采集的经典方案,它能确保你的系统即使在数据洪流中也能气定神闲。

而STM32CubeMX这个工具,就是帮我们快速搭建这个系统框架的“神器”。以前配置一个带DMA的串口,要翻几百页的参考手册,查寄存器,写一堆初始化代码,没半天功夫下不来。现在用CubeMX,鼠标点一点,图形化配置一下,代码框架直接就生成了,我们只需要关注最核心的应用逻辑就行。这效率提升,谁用谁知道。

2. 硬件连接与CubeMX工程创建:从零搭建采集框架

动手之前,咱们先得把“舞台”搭好。硬件上,你需要一块STM32开发板(这里以STM32F407为例),一个485电平转换芯片(比如常用的SP3485或MAX485),以及一个或多个485接口的变送器。

硬件连线是关键的第一步,接错了啥都白搭:

  1. MCU与485芯片:将STM32的某个USART的TX引脚(如PA2)接到485芯片的DI(数据输入)脚,RX引脚(如PA3)接到RO(数据输出)脚。别忘了,还需要一个普通的GPIO口(如PG8)连接到485芯片的RE(接收使能)和DE(发送使能)脚,这两个脚通常短接在一起。这个GPIO就是控制收发切换的关键:拉高时,芯片处于发送模式;拉低时,处于接收模式。
  2. 485总线:将485芯片的A线(正端)和B线(负端)分别连接到变送器总线端口的A和B。记住:485是差分信号,必须A接A,B接B,绝对不能接反或交叉。 总线的两端,最好还各接一个120欧姆的终端电阻,用来消除信号反射,保证长距离通信的稳定性。

硬件搞定,打开STM32CubeMX,开始“画图”编程。

### 2.1 芯片选择与引脚分配

新建工程,选择你的具体芯片型号(例如STM32F407ZGTx)。在引脚分配视图里,我们需要做三件事:

  • 配置USART2为异步通信模式:找到USART2,设置为“Asynchronous”。这时,PA2和PA3会自动被配置为USART2_TX和USART2_RX。波特率先不管,后面统一设。
  • 配置一个GPIO控制收发方向:找到PG8,将其设置为“GPIO_Output”。这个引脚就是我们控制485芯片收发状态的开关。
  • 配置USART1用于调试打印:通常我们会用另一个串口(如USART1,PA9/PA10)连接电脑的串口助手,用来打印调试信息,监控程序运行状态。同样将其设为“Asynchronous”,波特率可以设为常用的115200。

### 2.2 核心:DMA与中断的图形化配置

这是CubeMX最省事的地方。点开USART2的配置页面,找到“DMA Settings”选项卡。

  1. 添加DMA接收通道:点击“Add”,选择“USART2_RX”。方向(Direction)自动是“Peripheral To Memory”(外设到内存)。模式(Mode)这里有个小选择:如果你每次接收的数据长度固定,就用“Normal”(普通模式);如果数据长度不定,想用“串口空闲中断+ DMA”来检测一帧数据结束,那么模式要选“Circular”(循环模式)。我们为了演示通用性,先选“Normal”。
  2. 配置DMA参数:优先级(Priority)设为“Medium”或“High”都可以。数据宽度(Data Width)都选“Byte”(字节)。最关键的是“Increment Address”(地址自增)选项:外设地址(Periph)不自增,因为数据始终来自USART2的数据寄存器;内存地址(Memory)一定要自增,这样数据才能依次存到数组里。
  3. 使能串口全局中断和DMA中断:在USART2的“NVIC Settings”中,勾选“USART2 global interrupt”使能串口全局中断。同时,在“DMA Settings”页面,你添加的DMA流(Stream)对应的中断(如DMA1 Stream5 global interrupt)也会自动被使能。这两个中断是后续我们实现灵活数据接收的保障。

### 2.3 时钟树与工程生成

点击“Clock Configuration”标签,配置系统时钟。对于STM32F407,通常使用外部8MHz晶振(HSE),然后通过PLL倍频到168MHz作为系统主频(HCLK)。这一步CubeMX可以一键优化,点击“HCLK”输入框,输入168,然后回车,软件会自动计算并配置好PLL参数,非常方便。

最后,在“Project Manager”里给工程起个名字,选择你用的IDE(比如Keil MDK或STM32CubeIDE),在“Code Generator”里务必勾选“Generate peripheral initialization as a pair of ‘.c/.h’ files per peripheral”,这样每个外设的代码都会独立成对的文件,结构清晰,方便管理。

点击“GENERATE CODE”,一个完整的、包含DMA和USART初始化的工程框架就生成了。接下来,我们要往这个框架里注入“灵魂”。

3. 代码实战:编写高效可靠的数据收发逻辑

打开生成的工程,你会发现main.cusart.cdma.c等文件都已就绪,外设初始化函数MX_USART2_UART_Init()MX_DMA_Init()也都在main()里调用了。我们现在要做的,就是编写用户代码,实现“发送请求->接收数据->解析处理”的完整流程。

### 3.1 变量定义与数据帧解析准备

main.c文件的/* USER CODE BEGIN PV *//* USER CODE END PV */之间,定义我们需要的全局变量:

/* Private variables ---------------------------------------------------------*/
/* USER CODE BEGIN PV */
uint8_t RxBuffer[64]; // DMA接收缓冲区,大小根据你的数据帧长度定
uint8_t RxData[64];   // 实际存储一帧完整数据的地方
volatile uint8_t uart2_rx_flag = 0; // 接收完成标志位, volatile很重要!
uint8_t pressure_cmd[] = {0x01, 0x03, 0x00, 0x04, 0x00, 0x01, 0xC5, 0xCB}; // 读取压力的Modbus RTU命令示例
/* USER CODE END PV */

这里解释一下:RxBuffer是DMA搬运数据的目的地。uart2_rx_flag是一个标志,当一帧数据接收完成后,由中断服务程序将其置1,主循环检测到这个标志就知道该处理数据了。volatile关键字告诉编译器,这个变量可能被中断程序修改,不要做优化,每次都必须从内存中读取它的值。

### 3.2 开启DMA接收与空闲中断

main()函数的/* USER CODE BEGIN 2 */区域,进行关键的初始化操作:

/* USER CODE BEGIN 2 */
// 使能USART2的空闲中断(Idle Interrupt)
__HAL_UART_ENABLE_IT(&huart2, UART_IT_IDLE);
// 启动DMA接收,指定接收缓冲区和长度
HAL_UART_Receive_DMA(&huart2, RxBuffer, sizeof(RxBuffer));
// 将485控制引脚默认设置为接收模式(根据你的硬件,拉低或拉高)
HAL_GPIO_WritePin(GPIOG, GPIO_PIN_8, GPIO_PIN_RESET);
/* USER CODE END 2 */

HAL_UART_Receive_DMA这个函数一旦执行,DMA就开始在后台待命,只要串口收到数据,DMA就自动往RxBuffer里搬,完全不用CPU管。而__HAL_UART_ENABLE_IT(&huart2, UART_IT_IDLE)这行代码,是实现不定长数据接收的精髓。它使能了串口的“空闲中断”。什么意思呢?当串口总线上持续一段时间没有新的数据时,硬件就会产生一个“空闲”事件中断。利用这个特性,我们就可以判断一帧数据已经发送完毕了。

### 3.3 中断回调函数:精准捕获一帧数据

CubeMX生成的HAL库,中断处理逻辑是:硬件中断发生后,会进入标准的中断服务函数(在stm32f4xx_it.c里),然后HAL库的中断处理函数HAL_UART_IRQHandler会被调用,这个函数内部会根据中断类型,去调用对应的回调函数。我们需要重写这个回调函数。

main.c/* USER CODE BEGIN 4 */区域,编写我们的中断回调函数:

/* USER CODE BEGIN 4 */
// 串口接收完成回调函数(在中断中调用)
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    // 判断是否是USART2的中断
    if(huart->Instance == USART2)
    {
        // 判断是否是空闲中断(IDLE)
        if(__HAL_UART_GET_FLAG(huart, UART_FLAG_IDLE) != RESET)
        {
            // 1. 清除空闲中断标志(必须操作!)
            __HAL_UART_CLEAR_IDLEFLAG(huart);

            // 2. 暂停DMA,防止数据被覆盖
            HAL_UART_DMAStop(huart);

            // 3. 计算本次接收到的数据长度
            // DMA配置的接收总长度 减去 DMA寄存器中剩余的未传输数据量 = 已接收的数据长度
            uint16_t rx_len = sizeof(RxBuffer) - __HAL_DMA_GET_COUNTER(huart->hdmarx);

            // 4. 将数据从DMA缓冲区拷贝到安全的数据处理区
            if(rx_len > 0 && rx_len <= sizeof(RxBuffer))
            {
                memcpy(RxData, RxBuffer, rx_len);
                uart2_rx_flag = 1; // 置位接收完成标志
            }

            // 5. 重新设置DMA接收缓冲区地址和长度,为下一次接收做准备
            // 这里需要先手动设置一下DMA的存储器地址和计数器,HAL库没有直接提供重置函数
            huart->hdmarx->Instance->M0AR = (uint32_t)RxBuffer;
            huart->hdmarx->Instance->NDTR = sizeof(RxBuffer);

            // 6. 重新使能DMA和空闲中断,开始下一轮监听
            __HAL_UART_ENABLE_IT(huart, UART_IT_IDLE);
            HAL_UART_Receive_DMA(huart, RxBuffer, sizeof(RxBuffer));
        }
    }
}
/* USER CODE END 4 */

这段代码是核心中的核心。它的工作流程是:当一帧数据发送完毕,总线空闲,触发空闲中断。在中断里,我们立刻停止DMA,然后通过一个巧妙的计算(总长度 - 剩余长度)得到本次实际接收的字节数。接着把数据从DMA缓冲区RxBuffer拷贝到RxData,并置位标志。最后,重置DMA参数,重新开启接收。整个过程在中断里完成,快速且高效。

### 3.4 主循环:发送请求与处理数据

现在,我们回到main()函数的while(1)主循环:

/* Infinite loop */
/* USER CODE BEGIN WHILE */
while (1)
{
    /* USER CODE END WHILE */

    /* USER CODE BEGIN 3 */
    // 每隔2秒发送一次数据请求
    HAL_Delay(2000);

    // 1. 切换485为发送模式
    HAL_GPIO_WritePin(GPIOG, GPIO_PIN_8, GPIO_PIN_SET);
    // 2. 通过DMA发送请求指令(非阻塞,发送完立即返回)
    HAL_UART_Transmit_DMA(&huart2, pressure_cmd, sizeof(pressure_cmd));
    // 3. 短暂延时,确保数据发送完毕(这里可以优化为等待DMA发送完成回调)
    HAL_Delay(1);
    // 4. 立即切换回接收模式,准备接收变送器回复
    HAL_GPIO_WritePin(GPIOG, GPIO_PIN_8, GPIO_PIN_RESET);

    // 5. 检查是否收到一帧完整数据
    if(uart2_rx_flag == 1)
    {
        // 清除标志
        uart2_rx_flag = 0;

        // 6. 简单的数据解析示例(假设回复格式为Modbus RTU)
        // 例如:01 03 02 01 2F CRC
        // 其中第4、5字节(0x01, 0x2F)是压力值数据
        if(RxData[0] == 0x01 && RxData[1] == 0x03) // 核对地址和功能码
        {
            uint16_t raw_pressure = (RxData[3] << 8) | RxData[4]; // 合并两个字节
            float real_pressure = (float)raw_pressure / 10.0; // 假设变送器数据单位为0.1kPa

            // 通过串口1打印到电脑调试助手
            printf("Device 01 Pressure: %.1f kPa\r\n", real_pressure);
        }

        // 7. 清空数据缓冲区(可选,因为下次拷贝会覆盖)
        memset(RxData, 0, sizeof(RxData));
    }
}
/* USER CODE END 3 */

主循环的逻辑很清晰:定时发送查询指令 -> 等待接收完成标志 -> 解析数据。这里发送指令也用了HAL_UART_Transmit_DMA,这是一个非阻塞函数,调用后立即返回,DMA会在后台完成发送,不占用CPU时间。注意收发切换的时机:必须在发送切到发送模式,发送立刻切回接收模式。这个延时HAL_Delay(1)是给485芯片一个稳定的切换时间,1ms通常足够。更严谨的做法是使用HAL_UART_Transmit_DMA的回调函数HAL_UART_TxCpltCallback,在DMA发送完成回调里再切换回接收模式,这样时间掐得最准。

4. 避坑指南与高级优化技巧

照着上面的步骤做,一个基础的采集系统就能跑起来了。但想在实际工业环境中稳定运行,还得注意下面这些我踩过的“坑”。

### 4.1 硬件设计上的“雷区”

  • 终端电阻与偏置电阻:当通信距离超过100米,或者波特率较高(比如115200以上)时,务必在总线最远端的两个节点上,各接一个120欧姆的终端电阻,用来匹配阻抗,消除信号反射。如果总线上设备不多,或者环境干扰大,还可以在A线对地、B线对地之间各接一个偏置电阻(例如4.7kΩ),给总线一个稳定的空闲电平,防止因线路浮空产生误码。
  • 电源与接地:485芯片的电源一定要干净,最好用磁珠或电感隔离一下。所有节点的“地”要尽量共在一起,如果实在无法共地,可以考虑使用带隔离的485模块(光耦隔离),这是解决地环路干扰和共模电压最彻底的办法。
  • 布线:485通信线最好用双绞线,抗干扰能力更强。避免与电机、变频器等强电线路平行走线,如果无法避免,距离保持30厘米以上。

### 4.2 软件层面的稳定性打磨

  • 超时与重发机制:工业网络不是理想的,丢包、延迟时有发生。你不能发了指令就干等。一定要在主循环里加入超时判断。比如,发送指令后,启动一个定时器,如果500ms内没收到回复,就认为本次通信超时,进行错误计数,超过一定次数后尝试重发,或者上报通信故障。
  • 数据校验与帧完整性:例子中我们只简单判断了地址和功能码,这远远不够。CRC校验是必须的。Modbus RTU协议帧的最后两个字节就是CRC16校验码。收到数据后,必须对整帧数据(除了CRC部分)重新计算CRC,并与接收到的CRC比对,一致才认为帧是完整的、正确的。网上有很多CRC16的C语言查表法代码,直接拿来用就行。
  • DMA缓冲区管理与溢出:我们的RxBuffer大小是64字节。如果变送器回复的数据帧超过64字节,DMA会继续往后写,导致缓冲区溢出,数据覆盖。解决办法是:根据协议最大帧长来合理设置缓冲区大小,并留有余量。更保险的做法是,在中断回调函数里,如果计算出的rx_len等于我们设定的缓冲区长度,就要警惕了,这很可能意味着帧过长或发生了溢出,应该丢弃这一帧数据并报告错误。
  • 中断嵌套与优先级:如果你的系统还有其他中断(比如定时器中断、外部中断),需要合理配置NVIC中断优先级。串口DMA接收中断和空闲中断的优先级不宜设得太低,否则可能被其他中断打断,导致数据接收不及时。通常设置为“中等偏上”的优先级比较合适。

### 4.3 应对多设备轮询的架构思考

一条485总线上通常不止一个变送器。如何高效、有序地和所有设备对话?这就是轮询策略。你可以在程序中维护一个设备地址列表,用一个状态机来控制轮询流程:

  1. 发送当前地址设备的查询指令。
  2. 等待回复或超时。
  3. 如果收到正确回复,解析并存储数据,然后延时一段时间(防止总线冲突),切换到下一个设备地址。
  4. 如果超时,记录该设备通信失败,也切换到下一个地址。
  5. 所有设备轮询一遍后,暂停一段时间(如1秒),再开始新一轮轮询。

这个状态机可以放在主循环里,也可以用一个定时器中断来驱动,让轮询的时序更加精确。处理好轮询间隔和超时时间,是多设备通信稳定的关键。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐