分类 汽车 下的文章

小 RAM MCU 的现实约束

STM32F103C8T6 是很多项目的入门选择,但它的 20KB RAM 和 64KB Flash 在实际开发中往往捉襟见肘。更现实的情况是 LoRa 远传节点——在 STM32L4 的 32KB RAM 里,LoRaWAN 协议栈要吃掉 8KB,传感器驱动 2KB,数据缓冲队列 4KB,OTA 预留 6KB,留给业务逻辑的只有 12KB。

在这种资源约束下写代码,和在 256KB RAM 的 STM32F4 上完全是两种思路。不是功能能不能实现的问题,而是每分配一个缓冲区都要先算一遍还剩多少堆、栈还有多少空间。

小 RAM 系统的核心痛点有三个:栈溢出、堆碎片化、缓冲区大小妥协。每一个都可能导致 HardFault,而且排查起来非常耗时——因为崩溃现场往往已经被破坏了。

栈溢出:最隐蔽的杀手

栈溢出在开发阶段往往不暴露,因为测试时中断嵌套少、函数调用浅。到了量产环境,中断密集时一层层栈帧叠上去,突然就溢出了。

STM32 的栈是从高地址向低地址生长的。如果栈顶越过了 BSS 段的起始地址,就会破坏全局变量,表现出来的症状是某个全局变量莫名其妙被改了。

解决思路是给栈加"水位标记"。在启动时把栈空间填满一个魔术值(比如 0xA5),运行一段时间后检查有多少魔术值没被覆盖,就知道栈的高水位线在哪里:

// 实测于 STM32F103C8T6, GCC 12.2, -O2
extern unsigned int _estack;      // 链接脚本定义的栈顶
extern unsigned int _Min_Stack_Size;

#define STACK_CANARY 0xA5A5A5A5

void Stack_InitCanary(void) {
    uint32_t *stack_start = (uint32_t *)((uint32_t)&_estack - 4096);
    uint32_t *stack_end = (uint32_t *)&_estack;
    while (stack_start < stack_end - 16) {
        *stack_start++ = STACK_CANARY;
    }
}

uint32_t Stack_GetWatermark(void) {
    uint32_t *p = (uint32_t *)((uint32_t)&_estack - 4096);
    uint32_t used = 0;
    while (*p == STACK_CANARY && (uint32_t)p < (uint32_t)&_estack) {
        p++;
        used++;
    }
    return 4096 - used * 4;  // 返回栈使用了多少字节
}

实测记录

在 LoRa 节点上运行 24 小时后检查,栈高水位线在 2.3KB 处。开发时以为只用 1KB 就够了,实际多出来一倍,主要是中断嵌套时 LoRaWAN 的 MAC 层函数调用链很深,每层都申请了局部数组。

偏差分析:测试中断嵌套场景时发现 watermark 比单次中断高出 800 字节,怀疑是 SysTick 中断和 LoRa CAD 检测中断同时触发时的栈叠加。后续调高了栈大小到 4KB 才稳定。

堆碎片化:用静态分配替代 malloc

在小 RAM 系统上用 malloc/free 是灾难性的。堆碎片化会导致即使总剩余空间足够,也无法分配出一块连续的大块。

我的策略是:根本不用堆。所有缓冲区改成静态分配,在编译阶段就固定大小和位置。

// 不用 malloc,用静态数组 + 编译期大小
typedef struct {
    uint8_t buf[256];
    uint16_t len;
} Packet;

// 编译期分配 8 个 Packet 池
static Packet packet_pool[8];
static uint8_t pool_used[8];

Packet* Packet_Alloc(void) {
    for (int i = 0; i < 8; i++) {
        if (!pool_used[i]) {
            pool_used[i] = 1;
            return &packet_pool[i];
        }
    }
    return NULL;
}

void Packet_Free(Packet* p) {
    uint32_t idx = p - packet_pool;
    if (idx < 8) pool_used[idx] = 0;
}

代价是池子大小必须编译期确定,不能动态扩展。但好处是不会碎片化、回收可预测、调试时能看到完整的占用情况。

缓冲区大小:用时间换空间

20KB RAM 不够让你同时缓存 N 路 CAN 报文和传感器数据。妥协的方式是用"时间换空间"——加大抽样间隔,减小单帧缓冲。

比如原来缓存 100 帧 CAN 数据等一起发送,现在改成 32 帧就发。延迟从 100ms 降到 32ms,但内存占用从 1.5KB 降到 480 字节。

实际项目里另一个有效手段是复用缓冲区。同一个 256 字节的缓冲区,在采集阶段装传感器数据,发送阶段装 LoRa 报文。只要两个阶段不重叠,就能安全复用。需要的状态机为了避免覆盖,要用一个 buf_state 标志位严格管控:

enum {
    BUF_FREE = 0,
    BUF_COLLECTING,
    BUF_TX_PENDING,
    BUF_TXING,
};

static uint8_t shared_buf[256];
static uint8_t buf_state = BUF_FREE;

定位 HardFault 的技巧

小 RAM 系统崩溃了,光看 lrpc 往往不够。把崩溃时的栈内容 dump 出来,用 addr2line 反查到源码行号,是最可靠的定位方法:

// 实测于 STM32F103C8T6, GCC arm-none-eabi
void HardFault_Handler(void) {
    uint32_t *stack;
    __asm volatile("tst lr, #4 \n"
                   "ite eq \n"
                   "mrseq r0, msp \n"
                   "mrsne r0, psp \n"
                   "mov %0, r0" : "=r"(stack));

    // 写到备用寄存器里,调试器抓取
    volatile uint32_t saved_r0 = stack[0];
    volatile uint32_t saved_r1 = stack[1];
    volatile uint32_t saved_lr = stack[5];
    volatile uint32_t saved_pc = stack[6];
    volatile uint32_t saved_xpsr = stack[7];

    while (1);  // 在这里断下,用 arm-none-eabi-addr2line 反查 pc
}

适用边界

这套策略适合:
- STM32F1 系列 64KB Flash / 20KB RAM 级别
- 中低数据吞吐的 LoRa / NB-IoT 节点
- 对实时性要求高、不允许动态调度的场合

如果是 STM32F4 级别(192KB RAM),就没必要这么折磨自己了——直接按教科书来,malloc 该用就用。

遗留问题

  • 任务切换时栈切换会不会影响水位检测的准确性?目前只在 FreeRTOS 之外验证过,静态调度场景下 OK,多任务未测。
  • 共享缓冲区方案在协程(protothread)场景下的状态机复杂度会指数增长,有没有更优雅的复用模式,待调研。

关联笔记

关联笔记:DMA 双缓冲与事件驱动:STM32L4 传感器数据采集的功耗优化 中关于 DMA 缓冲区大小与唤醒频率的权衡讨论

传感器采集的功耗困境

在电池供电的传感器节点中,MCU 大部分时间应该处于睡眠状态,只在数据就绪时被唤醒处理。但很多实现中,MCU 要么轮询 ADC,要么用中断频繁唤醒,导致实际睡眠时间远小于预期。DMA 双缓冲加事件驱动,是解决这个问题的经典思路。

DMA 双缓冲的核心价值是:CPU 不参与数据搬运,且两块缓冲区交替使用——一块在被外设计数填充时,另一块正在被 CPU 处理。这避免了单缓冲方案中"等 DMA 传输完才能处理"的空窗期。

STM32L4 系列的 Stop2 模式是个好东西。静态功耗 1.4μA,SRAM 内容保持,DMA 可以继续工作,唤醒延迟 35μs。这意味着传感器采集可以在 CPU 睡眠时进行,只在缓冲区满时才唤醒 CPU。

双缓冲的工作逻辑

双缓冲的关键是"乒乓切换"。DMA 配置两块缓冲区,用 Circular 模式让 DMA 在两块之间循环。每次半传输完成(HT)和传输完成(TC)中断都会触发 CPU 唤醒,处理对应的半块数据。

设计代价是:内存占用翻倍,且需要同步机制防止 DMA 和 CPU 访问同一块缓冲区。简单说,HT 中断处理前半块,TC 中断处理后半块,两块交替使用,天然避免了竞争。

缓冲区布局:
[Buffer A (512B)] [Buffer B (512B)]
       ↑                ↑
   HT 中断处理       TC 中断处理
   (DMA 正在填 B)    (DMA 正在填 A)

实操配置

以下配置基于 STM32L432KC,用 CubeMX 生成 HAL 框架代码,手动补充双缓冲逻辑:

// 实测于 STM32L432KC, STM32Cube HAL 1.17.0
// ADC1 通道 5(PB1),DMA 双缓冲,12 位分辨率
#define BUF_SIZE 512
static uint16_t adc_buf[2][BUF_SIZE];  // 双缓冲

void ADC_DMA_Init(void) {
    // ADC 配置:72MHz/6 = 12MHz,采样时间 47.5 周期
    // 单次转换约 4μs,512 次约 2ms
    hadc1.Init.ContinuousConvMode = ENABLE;
    hadc1.Init.DMAContinuousRequests = ENABLE;

    // DMA 配置:Circular 模式,半字传输
    hdma_adc1.Init.Mode = DMA_NORMAL;  // 用 Normal + 手动重启实现双缓冲切换
    // 注意:HAL 库的 DMA_CIRCULAR 模式下 HT/TC 中断稳定
    // 但实测发现双缓冲切换用 DMA_NORMAL + 手动重启更可靠
    hdma_adc1.Init.Mode = DMA_CIRCULAR;

    HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buf, BUF_SIZE * 2);

    // 注册 HT 和 TC 回调
    HAL_ADC_RegisterCallback(&hadc1, HAL_ADC_CONV_HALF_CB_ID, ADC_HT_Callback);
    HAL_ADC_RegisterCallback(&hadc1, HAL_ADC_CONV_CPLT_CB_ID, ADC_TC_Callback);
}

void ADC_HT_Callback(ADC_HandleTypeDef* hadc) {
    // DMA 正在填 Buffer B,处理 Buffer A
    ProcessData(adc_buf[0], BUF_SIZE);
}

void ADC_TC_Callback(ADC_HandleTypeDef* hadc) {
    // DMA 正在填 Buffer A,处理 Buffer B
    ProcessData(adc_buf[1], BUF_SIZE);
}

进入 Stop2 模式前的配置很关键。必须先挂起所有外设时钟,只保留 ADC 和 DMA 时钟,否则 Stop2 进不去:

void EnterStop2(void) {
    // 关闭不需要的外设时钟
    __HAL_RCC_GPIOA_CLK_SLEEP_DISABLE();
    // 保留 ADC 和 DMA 时钟
    __HAL_RCC_ADC_CLK_SLEEP_ENABLE();
    __HAL_RCC_DMA1_CLK_SLEEP_ENABLE();

    // 进入 Stop2
    HAL_PWREx_ControlLowPowerRunMode(PWR_LOWPOWERRUNMODE_ENABLE);
    HAL_PWREx_EnterSTOP2Mode(PWR_STOPENTRY_WFI);

    // 唤醒后恢复时钟
    SystemClock_Config();
}

实测记录

理论预期:MCU 在 Stop2 模式下被 DMA 中断唤醒处理数据,整体平均功耗应接近 Stop2 的 1.4μA 加上短暂的唤醒处理时间。

实测结果(用万用表 μA 档测 VDD 电流):
- 纯 Stop2 模式:1.6μA(手册标称 1.4μA,偏差 0.2μA)
- 双缓冲运行时(512 点采集 + 处理):平均 18μA
- 同样的轮询方案(CPU 不睡眠):平均 2.8mA

偏差分析:Stop2 静态功耗略高于手册值,怀疑是板上 LDO 漏电流导致。双缓冲运行时的 18μA 远高于静态,说明唤醒处理的开销不可忽略——每次唤醒要恢复时钟、处理数据、再进入 Stop2,这个流程约 50μs。

优化思路:增大缓冲区。从 512 点改到 2048 点后,唤醒频率降低 4 倍,平均功耗降到 8μA。代价是数据延迟从 2ms 涨到 8ms,对低频传感器(温度、压力)可以接受。

(这里我一开始想用 DMA 双缓冲的硬件切换功能——GD32 有专门的 MDA M0/M1 寄存器,但 STM32L4 的 HAL 库支持不完整,手动管理反而更可控。)

适用边界

这套方案的前提:
- 传感器采样率低(kHz 级以下)。如果是音频这种高采样率场景,Stop2 的唤醒开销反而比一直跑更划算。
- 数据处理可以批量进行。如果每点都要实时处理,双缓冲没有意义。
- VDD 稳定。Stop2 模式下 VDD 波动会导致 SRAM 数据丢失,电池供电要有稳压。

遗留问题

  • DMA 在 Stop2 模式下能否继续工作?手册写的是"SRAM 保持,DMA 可工作",但实测中如果 DMA 源是 ADC,需要确认 ADC 时钟是否在 Stop2 下保持开启。这部分手册描述模糊,待用逻辑分析仪抓时序验证。
  • 多 ADC 通道扫描时,DMA 双缓冲的 HT/TC 中断会变得复杂。如果采样频率不一致,需要按最慢的通道计算唤醒间隔,否则会丢数据。

关联笔记

关联笔记:STM32 ADC 采样时间计算:从手册到实测 中关于 ADC 采样时间与信号源阻抗的讨论

MCU 的两种重启姿态

在嵌入式系统中,MCU 的重启不是简单的"断电再来"。冷启动和热启动是两种完全不同的机制,它们对系统状态的影响、恢复速度、以及适用场景都有本质区别。搞混这两种重启,轻则系统行为异常,重则丢失关键数据。

冷启动(Cold Boot)是 MCU 从完全断电状态上电,所有寄存器、SRAM、外设状态都被复位到默认值。热启动(Warm Boot)则是 MCU 在保持供电的情况下触发复位,部分寄存器和 SRAM 内容可能被保留。

从设计意图看,冷启动是"从零开始",热启动是"带着记忆重启"。两者的核心差异在于:冷启动必须完整执行所有初始化流程,而热启动可以跳过部分初始化,快速恢复到工作状态。

硬件层面的差异

STM32 的复位源决定了启动类型。通过查看 RCC_CSR 寄存器的复位标志位,可以判断上次重启的原因:

// 实测于 STM32F103C8T6, STM32Cube HAL 1.17.0
void CheckResetSource(void) {
    if (RCC_CSR & RCC_CSR_PINRSTF) {
        // NRST 引脚复位 → 冷启动
        printf("Reset by NRST pin\n");
    }
    if (RCC_CSR & RCC_CSR_PORRSTF) {
        // 上电/掉电复位 → 冷启动
        printf("Reset by POR/PDR\n");
    }
    if (RCC_CSR & RCC_CSR_SFTRSTF) {
        // 软件复位 → 热启动
        printf("Reset by software\n");
    }
    if (RCC_CSR & RCC_CSR_IWDGRSTF) {
        // 独立看门狗复位 → 热启动
        printf("Reset by IWDG\n");
    }
    if (RCC_CSR & RCC_CSR_WWDGRSTF) {
        // 窗口看门狗复位 → 热启动
        printf("Reset by WWDG\n");
    }

    // 清除复位标志
    RCC_CSR |= RCC_CSR_RMVF;
}

关键点:NRST 引脚复位和上电复位一定是冷启动,而软件复位和看门狗复位通常是热启动。但这个"通常"有例外——如果看门狗复位时 VDD 掉电又恢复,实际效果等同于冷启动。

SRAM 保留机制

热启动最核心的价值在于 SRAM 数据保留。STM32F1 系列在热启动时,SRAM 内容会被保留(前提是 VDD 保持在有效范围内)。这意味着你可以在 SRAM 中保存关键状态,重启后恢复。

但这里有个坑:SRAM 保留不是无条件的。手册规定 VDD 必须保持在 1.8V 以上(STM32F103 的最低工作电压),否则 SRAM 内容会丢失。在实际产品中,电源跌落的时序很难精确控制,所以不能 100% 依赖 SRAM 保留。

// 实测于 STM32F103C8T6
// 在 SRAM 末尾预留 1KB 用于状态保存
#define SRAM_PERSIST_ADDR  ((uint32_t)0x20003C00)  // SRAM 末尾 1KB
#define SRAM_PERSIST_SIZE  1024

typedef struct {
    uint32_t boot_count;
    uint32_t last_error;
    uint8_t  config[64];
} SystemState;

SystemState* pState = (SystemState*)SRAM_PERSIST_ADDR;

void SaveState(void) {
    pState->boot_count++;
    // 注意:这里没有写入 Flash,因为 Flash 写入速度慢且有寿命限制
}

void RestoreState(void) {
    if (pState->boot_count > 0) {
        // 热启动,恢复状态
        printf("Warm boot, count: %lu\n", pState->boot_count);
    } else {
        // 冷启动,初始化状态
        printf("Cold boot, initializing\n");
        memset(pState, 0, SRAM_PERSIST_SIZE);
        pState->boot_count = 1;
    }
}

实测记录

理论预期:热启动后 SRAM 内容保留,boot_count 递增。

实际结果:
- 正常热启动(软件复位):SRAM 保留,boot_count 正确递增
- 快速断电恢复(< 10ms):SRAM 部分保留,boot_count 有时递增有时归零
- 慢速断电(> 100ms):SRAM 丢失,等同于冷启动

偏差分析:快速断电时 SRAM 保留不稳定,怀疑是电源跌落过程中 VDD 在临界值附近波动,导致部分 SRAM 单元数据丢失。

AUTOSAR 中的应用

在 AUTOSAR 架构中,冷启动和热启动的处理有明确规范。ECU 状态管理器(EcuM)负责管理启动流程:

  • 冷启动:执行完整的 ECU 初始化,包括所有 BSW 模块的初始化
  • 热启动:跳过部分初始化,直接恢复到上次运行状态

实际项目中,热启动通常用于看门狗复位后的快速恢复。比如一个正在处理 CAN 报文的 ECU,如果看门狗触发复位,热启动可以在几十毫秒内恢复通信,而冷启动可能需要几百毫秒。

但热启动有个风险:如果上次运行状态本身就有问题(比如内存泄漏、状态机卡死),热启动会带着这些问题重启,可能再次触发看门狗。所以 AUTOSAR 规定,连续热启动超过 N 次(通常 3 次)后,必须执行冷启动。

选型建议

场景 推荐启动类型 理由
首次上电 冷启动 无历史状态可恢复
看门狗复位 热启动 快速恢复通信
软件异常复位 热启动 保留调试信息
电源跌落后恢复 冷启动 SRAM 可能已丢失
量产测试 冷启动 确保每次测试条件一致

遗留问题

  • STM32L4 系列支持 SRAM 保留模式(SRAM2 在低功耗模式下可保持),但 STM32F1 不支持。不同系列的 SRAM 保留机制差异较大,后续需要对比验证。
  • 热启动时外设状态如何恢复?手册说 GPIO 会复位到默认状态,但 DMA 和定时器的行为未明确说明,待实测确认。

关联笔记

关联笔记:STM32 ADC 采样时间计算:从手册到实测 中关于 ADC 初始化时序的讨论

ADC 采样时间的本质

在嵌入式系统中,ADC(模数转换器)是连接模拟世界和数字世界的桥梁。而采样时间,决定了这座桥梁的"宽度"——它直接影响测量精度和转换速度之间的权衡。

很多工程师在配置 STM32 ADC 时,只是照着例程改个采样周期值,却很少去算这个值到底合不合适。结果就是:低阻抗信号源用 1.5 个周期也能跑,高阻抗信号源用 239 个周期还是测不准。

采样时间的本质是:ADC 内部采样保持电路需要多长时间来"抓住"输入电压。这个时间取决于两个因素:信号源内阻和 ADC 内部采样电容。信号源内阻越大,需要的采样时间越长。

STM32F1 系列的 ADC 采样时间可配置为 1.5、7.5、13.5、28.5、41.5、55.5、71.5、239.5 个时钟周期。注意,这些数字不是随便给的,每个值对应不同的采样电容充电时间。

计算公式与手册依据

根据 STM32F1 参考手册(RM0008, Rev 21, Page 245),采样时间的计算公式为:

T_S ≥ (R_AIN + R_ADC) × C_ADC × ln(2^(N+2))

其中:
- T_S:采样时间
- R_AIN:信号源内阻(需要外部计算或测量)
- R_ADC:ADC 内部开关电阻(约 1kΩ,手册给出)
- C_ADC:ADC 内部采样电容(约 8pF,手册给出)
- N:ADC 分辨率(12 位)

简化后:对于 12 位 ADC,ln(2^14) ≈ 9.7,所以:

T_S ≥ (R_AIN + 1kΩ) × 8pF × 9.7

举个例子:如果信号源内阻是 10kΩ,那么:

T_S ≥ (10kΩ + 1kΩ) × 8pF × 9.7 ≈ 853ns

而 STM32F1 的 ADC 时钟最大为 14MHz(72MHz/6),每个周期约 71.4ns。所以需要的采样周期数为:

853ns / 71.4ns ≈ 12 个周期

这时候选 13.5 个周期就刚好够用。如果选 1.5 个周期(约 107ns),采样时间只有需求的 1/8,测量结果肯定会偏低。

实测验证

我用 STM32F103C8T6 做了一个简单测试。硬件配置:

  • ADC1,通道 0(PA0)
  • 外部接一个 10kΩ 电阻到 3.3V(模拟高阻抗信号源)
  • 参考电压 3.3V
// 实测于 STM32F103C8T6, STM32Cube HAL 1.17.0
void ADC_Config(void) {
    ADC_InitTypeDef ADC_InitStruct = {0};

    RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE);

    ADC_InitStruct.ADC_Mode = ADC_Mode_Independent;
    ADC_InitStruct.ADC_ScanConvMode = DISABLE;
    ADC_InitStruct.ADC_ContinuousConvMode = DISABLE;
    ADC_InitStruct.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None;
    ADC_InitStruct.ADC_DataAlign = ADC_DataAlign_Right;
    ADC_InitStruct.ADC_NbrOfChannel = 1;
    ADC_Init(ADC1, &ADC_InitStruct);

    // 配置采样时间:239.5 个周期
    ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_239Cycles5);

    ADC_Cmd(ADC1, ENABLE);

    // 校准
    ADC_ResetCalibration(ADC1);
    while(ADC_GetResetCalibrationStatus(ADC1));
    ADC_StartCalibration(ADC1);
    while(ADC_GetCalibrationStatus(ADC1));
}

实测记录

理论预期:10kΩ 信号源接 3.3V,ADC 应该读到 4095(12 位满量程)。

实际结果:
- 采样时间 1.5 周期:读数约 3800-3900(偏低 5-7%)
- 采样时间 13.5 周期:读数约 4050-4090(基本准确)
- 采样时间 239.5 周期:读数稳定在 4095

偏差分析:1.5 周期时读数偏低,符合预期——采样电容没充满就开始转换了。239.5 周期时读数稳定,但转换时间变长(约 17μs vs 1.5 周期的 0.1μs)。

(这里我一开始以为只要选最大采样时间就行,后来发现采样时间太长会影响转换速率,多通道扫描时尤其明显。)

选型建议

根据信号源阻抗选择采样时间:

信号源内阻 推荐采样时间 转换时间(单次)
< 1kΩ 1.5 周期 1.5μs
1kΩ - 10kΩ 13.5 周期 2.1μs
10kΩ - 50kΩ 55.5 周期 5.4μs
> 50kΩ 239.5 周期 18.1μs

如果需要高速采样(> 100kHz),信号源内阻必须控制在 1kΩ 以内。否则要么加运放缓冲,要么接受精度损失。

遗留问题

  • 多通道扫描时,不同通道的采样时间能否独立配置?手册说可以,但 HAL 库似乎不支持,待验证。
  • 温度漂移对采样电容的影响有多大?Datasheet 给出了范围但没给典型值,量产时需要实测。

关联笔记

暂未找到强关联笔记

做工业项目时,STM32 需要和 PLC 通信。Modbus RTU 是最常见的工业协议,libmodbus 是最流行的开源实现。但 libmodbus 是为 Linux 设计的,移植到 STM32 需要替换底层硬件操作。

为什么选 libmodbus

  • 成熟稳定,社区活跃
  • 支持 Modbus RTU/TCP
  • API 设计清晰,易于使用
  • 有丰富的示例代码

坑在于:libmodbus 依赖 POSIX API(openreadwrite),STM32 裸机环境下没有这些。

移植思路

libmodbus 的架构分两层:

  • 上层:Modbus 协议处理(CRC 计算、报文组装/解析)
  • 底层:串口收发、定时器、延时

移植的关键是保留上层,替换底层。

第一步:简化源码

# 下载 libmodbus
git clone https://github.com/stephane/libmodbus.git

# 只保留核心文件
src/
├── modbus.c          # Modbus 协议核心
├── modbus.h          # 公共头文件
├── modbus-private.h  # 私有头文件
├── modbus-rtu.c      # RTU 模式实现
├── modbus-rtu.h      # RTU 模式头文件
└── config.h          # 配置文件

删除 TCP 相关文件(modbus-tcp.cmodbus-tcp.h),只保留 RTU 模式。

第二步:配置 config.h

// config.h
#define HAVE_STRLCPY 1
#define HAVE_STRERROR 1
#define HAVE_ARPA_INET_H 0  // STM32 没有
#define HAVE_NETINET_IN_H 0
#define HAVE_SYS_SOCKET_H 0
#define HAVE_WINSOCK2_H 0

坑在于:很多宏默认开启,STM32 环境下要手动关闭。

第三步:替换串口收发

libmodbus 的底层操作在 modbus-rtu.c_step 函数里:

// 原始代码(Linux)
static ssize_t _step(modbus_t *ctx, uint8_t *msg, int msg_length)
{
    return write(ctx->s, msg, msg_length);
}

// 替换为 STM32 HAL
static ssize_t _step(modbus_t *ctx, uint8_t *msg, int msg_length)
{
    modbus_rtu_t *ctx_rtu = ctx->backend_data;
    HAL_StatusTypeDef status;

    // 发送
    status = HAL_UART_Transmit(&huart1, msg, msg_length, 1000);
    if (status != HAL_OK) {
        return -1;
    }

    // 接收
    status = HAL_UART_Receive(&huart1, msg, msg_length, 1000);
    if (status != HAL_OK) {
        return -1;
    }

    return msg_length;
}

第四步:替换延时函数

// 原始代码(Linux)
static void usleep_ms(int ms)
{
    usleep(ms * 1000);
}

// 替换为 STM32 HAL
static void usleep_ms(int ms)
{
    HAL_Delay(ms);
}

第五步:替换互斥锁(可选)

如果是单线程环境(裸机或 FreeRTOS 单任务),可以简化互斥锁:

// 简化为无操作
#define LOCK(ctx)
#define UNLOCK(ctx)

第六步:初始化和使用

#include "modbus.h"

int main(void)
{
    // 初始化硬件
    HAL_Init();
    SystemClock_Config();
    MX_USART1_UART_Init();

    // 创建 Modbus RTU 上下文
    modbus_t *ctx = modbus_new_rtu("/dev/ttyS0", 9600, 'N', 8, 1);
    if (ctx == NULL) {
        printf("无法创建 Modbus 上下文\n");
        return -1;
    }

    // 设置从站地址
    modbus_set_slave(ctx, 1);

    // 连接(RTU 模式不需要,但要调用)
    modbus_connect(ctx);

    // 读取保持寄存器(功能码 0x03)
    uint16_t tab_reg[10];
    int rc = modbus_read_registers(ctx, 0, 10, tab_reg);
    if (rc == 10) {
        printf("读取成功:");
        for (int i = 0; i < 10; i++) {
            printf("%d ", tab_reg[i]);
        }
        printf("\n");
    } else {
        printf("读取失败,错误码:%d\n", rc);
    }

    // 写入单个寄存器(功能码 0x06)
    rc = modbus_write_register(ctx, 0, 12345);
    if (rc == 1) {
        printf("写入成功\n");
    }

    // 关闭连接
    modbus_close(ctx);
    modbus_free(ctx);

    return 0;
}

常见问题

1. CRC 校验失败

// 检查波特率配置
modbus_t *ctx = modbus_new_rtu("/dev/ttyS0", 9600, 'N', 8, 1);
//                                    波特率    校验 数据位 停止位

坑在于:PLC 和 STM32 的波特率、校验位、数据位、停止位必须一致。

2. 超时问题

// 设置超时时间
struct timeval timeout;
timeout.tv_sec = 0;
timeout.tv_usec = 100000;  // 100ms
modbus_set_response_timeout(ctx, &timeout);

3. 从站地址不对

// 设置从站地址(要和 PLC 配置一致)
modbus_set_slave(ctx, 1);  // 从站地址 1

踩坑总结

  1. libmodbus 依赖 POSIX API,STM32 需要替换底层操作
  2. 核心替换:串口收发、延时函数、互斥锁
  3. 保留上层协议处理(CRC、报文组装/解析)
  4. 波特率、校验位、数据位、停止位必须和 PLC 一致
  5. 从站地址要和 PLC 配置一致

从"libmodbus 不能用"到"STM32 和 PLC 通信成功",只需要替换底层操作。坑在于:很多人被 POSIX API 吓退了,其实替换量不大。