前几篇 ZZZ-23-零点校准、ZZZ-24-满量程校准 已经把分析仪的“心脏”(采集与校准)做好了,现在我们要给它装上“嘴巴”(Modbus RTU 通讯能力),让它能和工业控制系统(PLC/DCS)交流
硬件接线
- [电脑] $\xrightarrow{USB}$ [USB 转 RS485 模块] $\xrightarrow{A/B \text{ 双绞线}}$ [TTL 转 RS485 模块] $\xrightarrow{UART}$ [STM32]
- 我们之前的串口实验中,已经将 PA9 (TX) 和 PA10 (RX) 给了 USART1 用于 VOFA+ 调试;那么现在 485 模块需要换个引脚连接。在工业开发中,我们通常采用“双串口方案”:
- USART1 (调试口):负责
printf 输出,接 VOFA+ 看波形、打日志。
- USART2 (通讯口):专门负责 Modbus RTU 协议,连接 485 模块。
| TTL 转 485 模块引脚 |
STM32F103 引脚 |
说明 |
| VCC |
3.3V |
保证电平一致 |
| GND |
GND |
共地 |
| TXD |
PA3 (RX) |
模块发,单片机收 |
| RXD |
PA2 (TX) |
单片机发,模块收 |
| A / B |
接 USB 转 485 模块 |
A 接 A,B 接 B |
USB 转 485、485 转 TTL、USB 转 TTL 模块上通常有 RX、TX 和电源指示灯。通过 RX 和 TX 灯(例如通过串口不断的发送数据,查看灯是否闪烁)可判断是否配置正常。
IOC 硬件配置 (USART2)
- 引脚选择:在左侧引脚树中找到 USART2。
- 将 Mode 设置为 Asynchronous (异步)。
- 你会看到引脚自动变绿:PA2 (TX) 和 PA3 (RX)。这就是你 485 模块要接的地方。
- 参数设置 (Parameter Settings):115200,8N1
- 开启中断 (NVIC Settings):
- 勾选 USART2 global interrupt 的 Enabled。这是为了实现非阻塞接收。
- 配置 DMA (重要):
- 在 DMA Settings 选项卡点击 Add。
- 添加 USART2_RX 和 USART2_TX。
- 将 Mode 设置为 Normal。这样可以极大地减轻 CPU 负担。
编写代码
1、修改 /* USER CODE BEGIN PV */ (增加变量定义)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
/* USER CODE BEGIN PV */
// ... 你之前的 ADC 和标定变量保留 ...
// --- Modbus RTU 配置 ---
#define SLAVE_ID 0x01 // 从机地址
uint16_t Modbus_Regs[10]; // 逻辑寄存器数组 (对应地址 0x0001, 0x0002...)
// --- 通讯缓冲区 ---
#define RX_BUF_SIZE 64
uint8_t rx_buffer[RX_BUF_SIZE]; // 接收缓冲区
uint8_t tx_buffer[RX_BUF_SIZE]; // 发送缓冲区
volatile uint8_t modbus_rx_flag = 0; // 收到完整帧标志
volatile uint16_t modbus_rx_len = 0; // 接收长度
/* USER CODE END PV */
|
2、修改 /* USER CODE BEGIN 0 */ (增加 CRC 与解析逻辑)
将这部分代码放在 main 函数之前。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
|
/* USER CODE BEGIN 0 */
// 1. 标准 Modbus CRC16 计算函数
uint16_t Modbus_CRC16(uint8_t *ptr, uint16_t len) {
uint16_t crc = 0xFFFF;
while(len--) {
crc ^= *ptr++;
for(int i = 0; i < 8; i++) {
if(crc & 0x01) {
crc >>= 1;
crc ^= 0xA001;
} else {
crc >>= 1;
}
}
}
return crc;
}
// 2. Modbus 报文解析逻辑 (核心:只处理 03 功能码)
void Modbus_Handle(void) {
if (modbus_rx_flag == 0) return;
// A. 基础校验:长度不足或站号不对则丢弃
if (modbus_rx_len < 8 || rx_buffer[0] != SLAVE_ID) {
goto reset;
}
// B. CRC 校验
uint16_t crc_calc = Modbus_CRC16(rx_buffer, modbus_rx_len - 2);
uint16_t crc_recv = rx_buffer[modbus_rx_len - 2] | (rx_buffer[modbus_rx_len - 1] << 8);
if (crc_calc != crc_recv) {
goto reset;
}
// C. 功能码处理 (03: 读取寄存器)
if (rx_buffer[1] == 0x03) {
uint16_t start_addr = (rx_buffer[2] << 8) | rx_buffer[3];
uint16_t reg_num = (rx_buffer[4] << 8) | rx_buffer[5];
// 构造回复报文
tx_buffer[0] = SLAVE_ID;
tx_buffer[1] = 0x03;
tx_buffer[2] = reg_num * 2; // 字节数
for (int i = 0; i < reg_num; i++) {
// 将数组中的 uint16 拆分为高低字节 (大端序)
tx_buffer[3 + i * 2] = (Modbus_Regs[start_addr + i] >> 8) & 0xFF;
tx_buffer[4 + i * 2] = Modbus_Regs[start_addr + i] & 0xFF;
}
uint16_t crc_send = Modbus_CRC16(tx_buffer, 3 + reg_num * 2);
tx_buffer[3 + reg_num * 2] = crc_send & 0xFF;
tx_buffer[4 + reg_num * 2] = (crc_send >> 8) & 0xFF;
// 通过 USART2 发送回复
HAL_UART_Transmit(&huart2, tx_buffer, 5 + reg_num * 2, 100);
}
reset:
modbus_rx_flag = 0;
modbus_rx_len = 0;
// 重新开启 DMA 接收(针对 F1 系列 IDLE 中断方案)
HAL_UARTEx_ReceiveToIdle_DMA(&huart2, rx_buffer, RX_BUF_SIZE);
}
// 3. 串口接收完成回调函数 (处理 IDLE 中断)
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) {
if (huart->Instance == USART2) {
modbus_rx_len = Size;
modbus_rx_flag = 1;
}
}
/* USER CODE END 0 */
|
3、修改 /* USER CODE BEGIN 2 */ (启动通讯)
在 main 函数的初始化部分开启接收。
1
2
3
4
5
6
7
8
|
/* USER CODE BEGIN 2 */
HAL_ADCEx_Calibration_Start(&hadc1);
HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buffer, SAMPLES * CHANNELS);
Load_Params_From_Flash(&zero_offset_volt, &slope_k);
// --- 核心:启动 USART2 的 DMA 接收,监听空闲中断 ---
HAL_UARTEx_ReceiveToIdle_DMA(&huart2, rx_buffer, RX_BUF_SIZE);
/* USER CODE END 2 */
|
4、修改 while(1) (同步数据与解析)
将采集到的 float 物理量“同步”到 Modbus 寄存器中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
while (1)
{
// --- 1-4. 你的采集、滤波、标定、换算逻辑 (保持不变) ---
// ... (此处省略你原有的代码) ...
// --- 5. 同步数据到 Modbus 仓库 ---
// 地址 0x0000: 电流 (mA * 100)
Modbus_Regs[0] = (uint16_t)(current_mA * 100);
// 地址 0x0001: 压力 (MPa * 1000)
Modbus_Regs[1] = (uint16_t)(calibrated_pressure * 1000);
// 地址 0x0002: 零点电压 (V * 1000)
Modbus_Regs[2] = (uint16_t)(zero_offset_volt * 1000);
// 地址 0x0003: 斜率 K (K * 1000)
Modbus_Regs[3] = (uint16_t)(slope_k * 1000);
// --- 6. 执行 Modbus 解析 ---
Modbus_Handle();
// --- 7. VOFA+ 调试输出 (USART1) ---
printf("%.2f,%.3f,%.3f,%.4f\n", current_mA, calibrated_pressure, zero_offset_volt, slope_k);
HAL_Delay(50); // 稍微提高刷新率
}
|
报文解析
![[Pasted image 20251224195543.png]]
1
2
|
发送数据:01 03 00 00 00 02 C4 0B
收到数据:01 03 04 02 88 00 AE FA 1D
|
01:从机地址(就是你的 STM32)。
03:功能码(读取保持寄存器)。
04:字节计数(后面跟着 4 个字节的数据)。
02 88:第一个寄存器值(电流)。
- 十六进制
0x0288 = 十进制 648。
- 按照你的代码逻辑(放大 100 倍),当前的电流是 6.48 mA。
00 AE:第二个寄存器值(压力)。
- 十六进制
0x00AE = 十进制 174。
- 按照你的代码逻辑(放大 1000 倍),当前的压力是 0.174 MPa。
FA 1D:CRC16 校验码。
485 返回的数据解析出来后,和串口 1 中 VOFA 显示的数据一致!
反控
既然“读”已经通了,下一步就是实现心心念念的“反控”——让上位机发一条指令,单片机自动去跑校准函数。我们要实现 功能码 06(写单个寄存器)。这样可以通过电脑直接下令:“现在开始零点校准!”
修改 Modbus_Handle 函数(增加 06 支持)
在的 Modbus_Handle 函数中,在 if (rx_buffer[1] == 0x03) { … } 后面增加下面这段代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
else if (rx_buffer[1] == 0x06) {
uint16_t addr = (rx_buffer[2] << 8) | rx_buffer[3];
uint16_t val = (rx_buffer[4] << 8) | rx_buffer[5];
printf("[Match] Function 06! Addr:0x%04X, Val:0x%04X\r\n", addr, val);
if (addr == 0x0006 && val == 0x55AA) {
printf("[Action] Calibrating Zero...\r\n");
zero_offset_volt = filter_volt_A;
HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13);
Save_Params_To_Flash(zero_offset_volt, slope_k);
}
HAL_UART_Transmit(&huart2, rx_buffer, 8, 100);
}
|
发送指令:
1
2
3
|
发送数据:01 06 00 06 55 AA ED 20
2025-12-24更新:注意这里正确的指令应该是:01 06 00 06 55 AA D6 E4
|
01:从机地址(你的单片机)。
06:功能码(写单个寄存器)。
00 06:寄存器地址(我们约定的命令触发地址)。
55 AA:魔法数(钥匙)。只有收到这个特定的数,单片机才认为这是一个合法的校准请求。
ED 20:CRC16 校验码(这是针对这串指令计算出来的“指纹”)。
坑:串口助手的“隐形尾巴”
发送了数据 01 06 00 06 55 AA ED 20 进行反控,发现怎么也实现不了功能;然后在 VOFO 中发送同样的命令,结果 VOFO 显示发送的数据后面多了 0A,这个多出来的 0A,在 ASCII 码中代表 Line Feed(换行符 \n)。
注意这里正确的指令应该是 01 06 00 06 55 AA D6 E4,上面指令的 CRC 算错了。不过这里确实得强调发送指令时的一些注意事项
因为命令的后面,带有一个追加的选项。并且注意这里数据引擎的选择
反控成功
注意,下图中左侧 VOFA 窗口,CRC 校验失败后依然执行了校零程序,这是有问题的(这里是调试阶段)。在附录的完整代码中,已设置好 CRC 的功能,如果校验不通过,发送的该行命令就作废
这里 Gemini 坑了我一把:
它给我反控的命令 01 06 00 06 55 AA ED 20,我怎么发送,都无法成功反控,在 CRC 校验这一步就被卡死了。正确的命令是 01 06 00 06 55 AA D6 E4
附录 - 完整代码
1
2
3
4
|
/* USER CODE BEGIN Includes */
#include <stdio.h>
#include <string.h>
/* USER CODE END Includes */
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
/* USER CODE BEGIN PV */
#define SAMPLES 10 // 每个通道采样 10 次
#define CHANNELS 2 // 共 2 个通道
uint16_t adc_buffer[SAMPLES * CHANNELS]; // 长度为 20 的数组
// --- 核心标定参数 ---
const float P_NOMINAL = 1.6f; // 满量程标定时的标准压力值 (MPa)
const float R_SAMPLE = 150.0f; // 采样电阻 150 欧
const float alpha = 0.15f; // 一阶滞后滤波系数
// --- 标定状态变量 ---
float zero_offset_volt = 0.60f; // 默认零点电压 (4mA 对应 0.6V)
float slope_k = 1.6f / (3.0f - 0.60f); // 默认斜率
float filter_volt_A = 0.60f; // 滤波后的电压变量
uint8_t calibration_flag = 0; // 标定标志位
// --- Modbus RTU 配置 ---
#define SLAVE_ID 0x01 // 从机地址
uint16_t Modbus_Regs[10]; // 逻辑寄存器数组 (对应地址 0x0001, 0x0002...)
// --- 通讯缓冲区 ---
#define RX_BUF_SIZE 64
uint8_t rx_buffer[RX_BUF_SIZE]; // 接收缓冲区
uint8_t tx_buffer[RX_BUF_SIZE]; // 发送缓冲区
volatile uint8_t modbus_rx_flag = 0; // 收到完整帧标志
volatile uint16_t modbus_rx_len = 0; // 接收长度
/* USER CODE END PV */
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
|
/* USER CODE BEGIN 0 */
int __io_putchar(int ch)
{
HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, HAL_MAX_DELAY);
return ch;
}
// 写入函数(保存参数)
#define FLASH_USER_START_ADDR 0x0800FC00 // �?后一页地�?
void Save_Params_To_Flash(float zero, float slope) {
uint32_t data_to_save[2];
// 使用指针强转,把 float 变成 uint32_t 存入数组
data_to_save[0] = *(uint32_t*)&zero;
data_to_save[1] = *(uint32_t*)&slope;
HAL_FLASH_Unlock(); // 解锁
// 1. 擦除�?后一�?
FLASH_EraseInitTypeDef EraseInitStruct;
uint32_t PageError = 0;
EraseInitStruct.TypeErase = FLASH_TYPEERASE_PAGES;
EraseInitStruct.PageAddress = FLASH_USER_START_ADDR;
EraseInitStruct.NbPages = 1;
HAL_FLASHEx_Erase(&EraseInitStruct, &PageError);
// 2. 写入数据
HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, FLASH_USER_START_ADDR, data_to_save[0]);
HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, FLASH_USER_START_ADDR + 4, data_to_save[1]);
HAL_FLASH_Lock(); // 上锁
}
// 读取函数(开机加载)
void Load_Params_From_Flash(float *zero, float *slope) {
uint32_t raw_zero = *(__IO uint32_t*)(FLASH_USER_START_ADDR);
uint32_t raw_slope = *(__IO uint32_t*)(FLASH_USER_START_ADDR + 4);
// 如果 FLASH 是空的(0xFFFFFFFF),则不加载,维持默认�??
if (raw_zero != 0xFFFFFFFF) {
*zero = *(float*)&raw_zero;
*slope = *(float*)&raw_slope;
}
}
// Modbus CRC16 计算函数
// 1. 标准 Modbus CRC16 计算函数
uint16_t Modbus_CRC16(uint8_t *ptr, uint16_t len) {
uint16_t crc = 0xFFFF;
while(len--) {
crc ^= *ptr++;
for(int i = 0; i < 8; i++) {
if(crc & 0x01) {
crc >>= 1;
crc ^= 0xA001;
} else {
crc >>= 1;
}
}
}
return crc;
}
/* * 提示:请确保你的 Modbus_Regs 数组长度足够(如 uint16_t Modbus_Regs[10])
* 且已经在前面定义了 SLAVE_ID 为 0x01
*/
void Modbus_Handle(void) {
// 如果没有标志位,说明根本没触发串口接收
if (modbus_rx_flag == 0) return;
// --- 雷达探测:只要串口 2 动了,串口 1 必须说话 ---
// 这一段放在所有 if 校验的最前面!
printf("\r\n[Radar] New Packet! Len: %d\r\n", modbus_rx_len);
printf("[Radar] Data: ");
for(int i = 0; i < modbus_rx_len; i++) {
printf("%02X ", rx_buffer[i]);
}
printf("\r\n");
// --- 下面是原有的逻辑,但我们加了错误诊断 ---
if (modbus_rx_len < 8) {
printf("[Error] Length < 8 bytes!\r\n");
goto reset;
}
if (rx_buffer[0] != SLAVE_ID) {
printf("[Error] ID Mismatch! Recv:%02X\r\n", rx_buffer[0]);
goto reset;
}
uint16_t crc_calc = Modbus_CRC16(rx_buffer, 6); // 针对 06 指令固定前 6 位
uint16_t crc_recv = rx_buffer[6] | (rx_buffer[7] << 8);
if (crc_calc != crc_recv) {
printf("[Error] CRC Fail! Calc:%04X, Recv:%04X\r\n", crc_calc, crc_recv);
goto reset; // 如果CRC校验不成功,不执行任何操作
}
// 真正的逻辑处理
// --- 处理 03 读取 ---
if (rx_buffer[1] == 0x03) {
uint16_t start_addr = (rx_buffer[2] << 8) | rx_buffer[3];
uint16_t reg_num = (rx_buffer[4] << 8) | rx_buffer[5];
// 构造回复报文
tx_buffer[0] = SLAVE_ID;
tx_buffer[1] = 0x03;
tx_buffer[2] = reg_num * 2; // 字节数
for (int i = 0; i < reg_num; i++) {
// 将数组中的 uint16 拆分为高低字节 (大端序)
tx_buffer[3 + i * 2] = (Modbus_Regs[start_addr + i] >> 8) & 0xFF;
tx_buffer[4 + i * 2] = Modbus_Regs[start_addr + i] & 0xFF;
}
uint16_t crc_send = Modbus_CRC16(tx_buffer, 3 + reg_num * 2);
tx_buffer[3 + reg_num * 2] = crc_send & 0xFF;
tx_buffer[4 + reg_num * 2] = (crc_send >> 8) & 0xFF;
// 通过 USART2 发送回复
HAL_UART_Transmit(&huart2, tx_buffer, 5 + reg_num * 2, 100);
}
else if (rx_buffer[1] == 0x06) {
uint16_t addr = (rx_buffer[2] << 8) | rx_buffer[3];
uint16_t val = (rx_buffer[4] << 8) | rx_buffer[5];
printf("[Match] Function 06! Addr:0x%04X, Val:0x%04X\r\n", addr, val);
if (addr == 0x0006 && val == 0x55AA) {
printf("[Action] Calibrating Zero...\r\n");
zero_offset_volt = filter_volt_A;
HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13);
Save_Params_To_Flash(zero_offset_volt, slope_k);
}
HAL_UART_Transmit(&huart2, rx_buffer, 8, 100);
}
reset:
modbus_rx_flag = 0;
modbus_rx_len = 0;
memset(rx_buffer, 0, RX_BUF_SIZE);
HAL_UARTEx_ReceiveToIdle_DMA(&huart2, rx_buffer, RX_BUF_SIZE);
}
// 3. 串口接收完成回调函数 (处理 IDLE 中断)
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) {
if (huart->Instance == USART2) {
modbus_rx_len = Size;
modbus_rx_flag = 1;
}
}
/* USER CODE END 0 */
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
/* USER CODE BEGIN 2 */
// 为了精准,建议先进行 ADC 校准(F1系列特有�?
HAL_ADCEx_Calibration_Start(&hadc1);
// 核心命令:启�? ADC + DMA
// 参数:ADC句柄,存储的目标地址,要搬运的数据个�?
HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buffer, SAMPLES * CHANNELS);
// 开机加载校准数据
Load_Params_From_Flash(&zero_offset_volt, &slope_k);
HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buffer, SAMPLES * CHANNELS);
// --- 核心:启动 USART2 的 DMA 接收,监听空闲中断 ---
HAL_UARTEx_ReceiveToIdle_DMA(&huart2, rx_buffer, RX_BUF_SIZE);
/* USER CODE END 2 */
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
|
while (1)
{
// --- 1. 信号采集与双重滤波 ---
uint32_t sum_A = 0;
for(int i = 0; i < SAMPLES; i++) {
sum_A += adc_buffer[i * CHANNELS];
}
// 原始电压计算 (基于 3.3V 理论值,若需更准可换回 VREFINT 公式)
float raw_volt_A = (float)sum_A / SAMPLES * 3.3f / 4095.0f;
// 一阶滞后滤波
filter_volt_A = alpha * raw_volt_A + (1.0f - alpha) * filter_volt_A;
float volt = filter_volt_A;
// --- 2. 零点校准逻辑 (PB0) ---
if (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_0) == GPIO_PIN_RESET)
{
HAL_Delay(20); // 初步去抖
if (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_0) == GPIO_PIN_RESET)
{
zero_offset_volt = volt; // 记录当前电压为新零点
// 亮灯反馈
HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_RESET);
HAL_Delay(500);
HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_SET);
// 在零点校准成功后的位置
zero_offset_volt = volt;
Save_Params_To_Flash(zero_offset_volt, slope_k); // 同步保存到 FLASH
while(HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_0) == GPIO_PIN_RESET); // 等待松开
}
}
// --- 3. 满量程校准逻辑 (PB1) ---
if (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_1) == GPIO_PIN_RESET)
{
HAL_Delay(20);
if (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_1) == GPIO_PIN_RESET)
{
// 核心逻辑:斜率 k = 标准压力 / (当前满量程电压 - 零点电压)
float v_diff = volt - zero_offset_volt;
if (v_diff > 0.1f) // 安全检查:确保满位电压确实大于零点电压
{
slope_k = P_NOMINAL / v_diff;
// 在满量程校准成功后的位置
slope_k = P_NOMINAL / v_diff;
Save_Params_To_Flash(zero_offset_volt, slope_k); // 同步保存到 FLASH
// 成功后快闪两次灯
for(int i=0; i<4; i++) {
HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13);
HAL_Delay(100);
}
}
while(HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_1) == GPIO_PIN_RESET);
}
}
// --- 4. 最终工业参数换算 ---
// 计算电流 (mA)
float current_mA = (volt / R_SAMPLE) * 1000.0f;
// 计算校准后的压力
float calibrated_pressure = (volt - zero_offset_volt) * slope_k;
// 负数截断保护
if (calibrated_pressure < 0.0f) calibrated_pressure = 0.0f;
// --- 5. VOFA+ 数据输出 ---
// 通道说明:1.电流(mA), 2.校准后压力(MPa), 3.零点偏置电压(V), 4.当前斜率k
// printf("%.2f,%.3f,%.3f,%.4f\n",
// current_mA,
// calibrated_pressure,
// zero_offset_volt,
// slope_k);
// --- 5. 同步数据到 Modbus 仓库 ---
// 地址 0x0000: 电流 (mA * 100)
Modbus_Regs[0] = (uint16_t)(current_mA * 100);
// 地址 0x0001: 压力 (MPa * 1000)
Modbus_Regs[1] = (uint16_t)(calibrated_pressure * 1000);
// 地址 0x0002: 零点电压 (V * 1000)
Modbus_Regs[2] = (uint16_t)(zero_offset_volt * 1000);
// 地址 0x0003: 斜率 K (K * 1000)
Modbus_Regs[3] = (uint16_t)(slope_k * 1000);
// --- 6. 执行 Modbus 解析 ---
Modbus_Handle();
// --- 7. VOFA+ 调试输出 (USART1) ---
printf("%.2f,%.3f,%.3f,%.4f\n", current_mA, calibrated_pressure, zero_offset_volt, slope_k);
HAL_Delay(50); // 稍微提高刷新率
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
}
|
硬件架构:双串口解耦
为了解决“生产”与“调试”的冲突,我们采用了双串口方案。核心逻辑是:把人看的调试信息和机器看的通讯协议彻底分开。
graph TD
subgraph "STM32F103 Node"
CPU[STM32 CPU]
ADC[ADC Peripheral]
Flash[Flash Storage]
USART1[USART1 Debug Port]
USART2[USART2 Modbus Port]
end
Sensor((Pressure Sensor)) --> ADC
ADC --> CPU
CPU <--> Flash
USART1 -- "printf (ASCII)" --> VOFA[VOFA+ / PC Terminal]
USART2 -- "Modbus RTU (HEX)" --> RS485[RS485 to USB]
RS485 --> Master[PC / PLC Master]
style CPU fill:#f96,stroke:#333
style USART2 fill:#bbf,stroke:#333
为什么要用双串口
如果只有一个串口,会面临以下“打架”的情况:
- 数据污染:你正通过串口发 Modbus 的十六进制数据(如
01 03…),突然程序运行报错,触发了一行 printf("Error: Sensor Timeout")。上位机(PLC)收到这串字符会立刻解析失败,导致通讯断开。
- 带宽抢占:高频的波形数据(VOFA+)非常占带宽。如果此时 Modbus 进来一个紧急指令,单片机可能因为正在忙着吐
printf 的字符串而漏掉中断,导致响应超时。
在本次 STM32F103 开发中,典型的双串口硬件布局如下:
| 串口编号 |
硬件连接 |
逻辑角色 |
传输内容 |
| USART1 (PA9/PA10) |
USB 转 TTL 线 -> 电脑 |
调试/监控口 |
文本格式(ASCII)、VOFA+ 波形数据、报错日志。 |
| USART2 (PA2/PA3) |
TTL 转 485 模块 -> 总线 |
工业通讯口 |
二进制格式(Hex)、Modbus RTU 协议数据、PLC 指令。 |
软件架构实现
- 重定向(Redirect):让
printf 函数默认“走” USART1
- 协议栈(Protocol Stack):让 USART2 保持“纯净”,只跑 Modbus。
双串口方案的优势
1、波形与数据同步调试
你可以一边在串口助手里给 USART2 发送反控指令,一边在 VOFA+(连接 USART1)上实时观察零点电压 $V_{zero}$ 是如何跳变的。这种“上帝视角”能让你快速定位算法问题。
2、性能隔离
即使 USART1 打印了一万行日志导致缓冲区溢出,它也完全不会影响 USART2 接收 PLC 的指令。在工业现场,这种物理层面的隔离是系统鲁棒性(Robustness)的最高保障。
3、现场维护便利性
当设备安装在烟囱高处的监测柜里时:
- USART2 永远接在总线上,给中控室传数据。
- USART1 引出一根调试线。技术人员爬上去接上笔记本电脑,就能直接看到设备内部的运行状态,而不需要中断正常的通讯。
扩展资料
软件逻辑流程
sequenceDiagram
participant M as Master (PC)
participant D as DMA/USART2
participant H as Modbus_Handle
participant A as Application/Flash
M->>D: 发送报文 (含噪音/换行符)
D-->>D: DMA 搬运数据到缓冲区
Note right of D: 串口检测到空闲 (IDLE)
D->>H: 触发接收中断, 置标志位
H->>H: 校验 1: 长度是否 >= 8 字节
H->>H: 校验 2: 站号 (Slave ID)
H->>H: 校验 3: CRC16 (强制取前 8 字节)
alt 校验通过
H->>M: 【关键】先回传响应报文
H->>A: 执行校准/写入逻辑 (耗时操作)
A->>A: Save_Params_To_Flash
H->>H: 打印调试信息到 USART1
else 校验失败
H->>H: 打印错误日志, 丢弃数据
end
H->>D: Memset 清空缓存, 重启 DMA
为什么校验时,强制取前 8 字节
“强制取前 8 字节”并不是不遵守协议,而是一种针对现实复杂环境的防御性编程策略。
1、协议的“固定性”:03/06 指令的标准定义:在 Modbus RTU 标准协议中,无论是读取请求(03)还是单次写入请求(06),它们的报文结构在物理上是绝对固定为 8 个字节的:
| 字节序号 |
定义 |
说明 |
| 第 1 字节 |
从机地址 (Slave ID) |
1 Byte |
| 第 2 字节 |
功能码 (Function Code) |
1 Byte (0x03 或 0x06) |
| 第 3-4 字节 |
寄存器地址 |
2 Bytes |
| 第 5-6 字节 |
寄存器数量 / 写入数据 |
2 Bytes |
| 第 7-8 字节 |
CRC 校验码 |
2 Bytes (这就是最后 2 位) |
2、解决“隐形尾巴” (Garbage Bytes)
这是我上面亲身经历的坑。很多串口调试助手、PLC 甚至劣质的 485 转换器,在发送完标准的 8 字节后,会因为设置问题或硬件干扰,顺便带出一个或多个“脏字节”:
- 例如:
\n (0x0A), \r (0x0D), 或者线路干扰产生的 0x00。
如果你不“强制”取 8 字节:
- 单片机通过 DMA 接收到了 9 个字节(第 9 位是
0A)。
- 如果你用
modbus_rx_len - 2 找校验码,代码会去读第 8、9 位作为 CRC。
- 结果: 本该是
rx_buffer[6] 和 rx_buffer[7] 的 CRC,变成了 rx_buffer[7] 和 rx_buffer[8]。位置一偏,校验必败。
注意,如果 Modbus 报文长度超过了 8 字节(如 16 功能码),就不能强制取 8 字节了,此时需根据报文中的“字节计数值”字段动态计算 CRC 位置。
本案例流程总结
graph TD
Start((开始项目)) --> Phase1[第一阶段:硬件解耦
双串口方案规划]
Phase1 --> Phase2[第二阶段:CubeMX 配置
DMA + IDLE 中断]
Phase2 --> Phase3[第三阶段:核心算法
CRC16 与 寄存器映射]
Phase3 --> Phase4[第四阶段:逻辑解析
三道门卫 校验逻辑]
Phase4 --> Phase5[第五阶段:工业加固
响应优先 与 缓冲区自洁]
Phase5 --> End((稳定运行))
subgraph "关键细节"
Phase1 -.-> P1_1[USART1: 调试/VOFA+
USART2: 485/Modbus]
Phase2 -.-> P2_1[DMA: Circular 模式
NVIC: 开启全局中断]
Phase4 -.-> P4_1[1.长度校验
2.站号校验
3.CRC 强校验]
Phase5 -.-> P5_1[先发回传, 后写 Flash
memset 彻底清理缓存]
end
style Start fill:#f9f,stroke:#333
style End fill:#bbf,stroke:#333
style Phase4 fill:#f96,stroke:#333
五步实战流程详解
1、硬件架构(物理层隔离)
- USART1 (调试口):接 USB-TTL,用于
printf 文本和 VOFA+ 绘图。它相当于仪表的“黑匣子”,让你看到内部逻辑。
- USART2 (通讯口):接 TTL-485 模块。它是仪表的“耳朵和嘴巴”,只跑二进制协议,确保不受调试信息的干扰。
2、第二步:底层配置(DMA+ 空闲中断)
- DMA (Direct Memory Access):数据不需要经过 CPU,直接搬运到数组,效率极高。
- IDLE (空闲中断):这是 Modbus 的灵魂。它能自动判定一帧数据的结束(当总线超过 1.5 个字符时间没信号时触发),完美解决变长报文接收问题。
3、第三步:数据建模(寄存器映射)
- 建立仓库:用一个数组
Modbus_Regs[10] 模拟寄存器。
- 同步数据:在
while(1) 中将传感器实时值(如压力、电流)放大 100/1000 倍后存入数组,等待主机读取。
4、第四步:解析逻辑(三道门卫)
- 长度检查:对于 03/06,小于 8 字节的一律视为垃圾。
- 站号过滤:不是发给我的(SLAVE_ID)一律不回。
- CRC 强校验:这是最重要的安全锁。通过
Modbus_CRC16 函数计算后与末尾两字节比对,确保数据在传输过程中没有因为电磁干扰而变质。
5、第五步:工业级加固(稳定性策略)
- 防御性编程:强制截取前 8 字节校验,无视末尾多余的
\n 或空格。
- 响应优先原则:先回传响应报文,再执行 Flash 写入或电机控制等耗时动作,防止上位机通讯超时。
- 自洁功能:处理完每一帧后,必须
memset 缓冲区并重启 DMA,确保下一帧从第 0 位开始。