简介:基于STM32F103VET6单片机实现的国产三菱FX3U全套设计资料,以硬件原理图、PCB版图与软件源码为核心,面向PLC开发工程师、嵌入式学习者及相关专业毕业生,适合用作物联网设备、工装夹具或小型自动化项目中的控制器原型参考,也可作为理解国产PLC软硬件协同设计的入门范本。整个zip压缩包共含532个文件,大小约24.16MB,文件内容涵盖原理图与PCB设计源文件、C程序源码与头文件、Keil工程配置、编译过程中间文件以及多个历史版本备份目录;其中schdoc、pcbdoc可完整还原硬件设计,c/h文件则对应固件逻辑。源码程序展示了输入输出端口初始化、RTC时钟配置、断电数据保持与恢复、串口和485通信等PLC典型功能模块,能够直观看到从底层寄存器配置到应用层逻辑的完整实现路径。硬件设计部分通过原理图和PCB呈现板级走线、元器件布局和接口定义,方便对照软件进行联调排查。目前已有一千二百八十四人学习浏览,适合在开发调试时作为高风险项目的设计参考或工程模板。
1. 把三菱 FX3U 复刻到 STM32F103VET6 上:一套可学习也可改造的国产 PLC 方案
车间里 FX3U 老设备出故障,多半不是 IO 坏了就是通信口被浪涌打掉。原厂板子价格高、周期长,直接换新机型又要重写梯形图、重新调参数。基于 STM32F103VET6 的国产 FX3U 复刻方案,解决的正是这个维护痛点:用一颗 512KB Flash 的 Cortex-M3 芯片,把 FX3U 的输入输出映像、D/M 软元件、485 编程口协议这些核心行为重新实现一遍,外部接线和用户梯形图不改,就能把旧程序接力跑起来。这套 V8.0 资料包含完整硬件原理图、PCB 文件和 Keil 工程源码,适合自动化设备改造工程师拿来评估替代方案,也适合嵌入式开发者研究 PLC 的扫描执行模型和 Modbus 通信实现。全文就是围绕这两条主线,把硬件选型、固件启动、通信与掉电保持、烧录验证逐个拆开讲透。
2. 硬件层拆解:电源轨、IO 光耦、PCB 走线里的 EMC 设计细节
拿到 V8.0 资料先不要急着打开源码。硬件原理图和 PCB 决定了这套方案能跑在什么样的工业环境里,也决定了后期改板时你会不会掉进坑。STM32F103VET6 本身不是 PLC,电源、隔离、接口保护才是 PLC 与开发板的真正区别。下面按“电源→IO→PCB”三个层次把板子过一遍,这几个位置对排查上电异常帮助最直接。
2.1 电源轨设计:24V 输入怎么变成 3.3V 给 MCU
FX3U 兼容板的外部供电绝大多数是 24V 直流,而 MCU 内核和片上外设需要 3.3V,中间至少隔两级转换。原理图第一眼应该先看电源树,而不是看 CPU 引脚。
| 电源轨 | 典型来源 | 主要负载 | 设计注意点 |
|---|---|---|---|
| 24V_IN | 外部开关电源 | DCDC、继电器线圈、输入光耦 | 入口并 TVS(如 SMBJ24A)和防反接二极管 |
| 5V | DCDC(如 MP1584、LM2596) | 485 收发器、光耦输出侧、继电器驱动 | 与 3.3V 之间用单点接地连接 |
| 3.3V | LDO(如 AMS1117、RT9013) | STM32F103VET6、RTC、晶振电路 | 靠近 VDDA 引脚加磁珠和 0.1uF 去耦 |
STM32F103VET6 的工作电压是 2.0~3.6V,板上 3.3V 由 5V 经 LDO 再降一级得到。这套“DCDC+LDO”的组合在工控板上很常见:DCDC 负责把 24V 高效降到 5V 并承受输入波动,LDO 再给 MCU 提供低纹波的 3.3V,避免 DC-DC 开关噪声直接串进 ADC 和复位电路。如果原理图里只看到一颗 LDO 从 24V 直接降到 3.3V,就要注意压差发热,24V 输入时线性稳压器会消耗掉大部分功率。
电源走线在原理图阶段就要圈出来:24V、5V、3.3V、GND 和 PGND 这五类网络是重点。GND 和 PGND 通常在继电器或光耦附近用 0 欧电阻或磁珠做单点连接。我一般会先在原理图上把这五类网络标上颜色,再生成 BOM 和网络表,检查隔离两侧地线是否被错误短接,避免后期 PCB 改板时带着问题投产。
2.2 IO 通道电路:输入分压、光耦 CTR 和输出续流
输入侧,FX3U 的数字量输入是 24V 漏型/源型两用的。复刻板上常见做法是 24V 信号先经过几个电阻分压限流,再驱动 PC817 这类三极管输出光耦,光耦输出侧直接接 STM32 的 GPIO 并配上拉电阻。光耦的电流传输比(CTR)决定上拉电阻取值,PC817 在 1mA 输入下 CTR 只有 50% 左右,设计输入电流 5~10mA、上拉电阻留 2~3 倍裕量比较稳妥,留小了低温下容易导通不稳定。
输出侧,继电器输出比晶体管输出更容易被接受,因为负载回路和 MCU 完全隔离。驱动继电器线圈要反向并联续流二极管,否则断电瞬间的感生电动势会倒灌击穿驱动管。如果采用 ULN2803 这类达林顿阵列,内部已经集成续流二极管,但外部加一颗 TVS 到电源轨仍然必要,尤其是带接触器、电磁阀这类感性负载时,触点断开的瞬态会直接作用在输出端子上。
2.3 PCB 布局与走线:从网表里先抓出弱信号
PCB 层数方面,2 层板能省成本,但 485、晶振、电源这些信号挤在同一层,对铺地和回流控制要求比较高;这类兼容 PLC 的板子如果条件允许做 4 层,把第二层整层作为 GND 平面,走线和 EMC 调试都会轻松很多。RS-485 差分线要成对走、保持间距一致,收发器尽量靠近端子,TVS 管放在连接器入口处,让浪涌在进板前就被吸收,而不是进板后再绕一圈。
拿到 gerber 或 PCB 源文件后,我会先做一轮最小线宽和间距的自动筛查,而不是只看视觉效果。从 Altium 或嘉立创导出的网表里带上走线宽度字段,用简单脚本就能过滤出风险项:
# 最小线宽与电源线宽度检查,输入为原理图导出的网表 CSV import csv MIN_SIGNAL_WIDTH = 0.2 # 信号线最小线宽,单位 mm MIN_POWER_WIDTH = 0.6 # 电源网络最小线宽 PWR_NETS = {"24V", "5V", "3V3", "GND", "PGND"} with open("net_widths.csv", newline="", encoding="utf-8") as fp: rows = list(csv.DictReader(fp)) bad = [] for row in rows: width = float(row["Width_mm"]) net = row["Net"] if net in PWR_NETS and width < MIN_POWER_WIDTH: bad.append(f"{net}: {width}mm < 电源线 {MIN_POWER_WIDTH}mm") if net not in PWR_NETS and width < MIN_SIGNAL_WIDTH: bad.append(f"{net}: {width}mm < 信号线 {MIN_SIGNAL_WIDTH}mm") print("\n".join(bad) if bad else "线宽检查通过")Width_mm是 PCB 布线数据里的实际铜皮宽度,不是封装引脚宽度;PWR_NETS里的网络名要和原理图实际标签一致。脚本只做数值报警,真正判断还要回到板上看回流路径,例如 485 收发器下方不要打一排信号过孔,光耦正下方避免铺铜形成寄生电容。
gerber 导出后还要检查丝印层。丝印字符压到焊盘会造成贴片不良,字符最小高度建议 0.8mm 以上,板边 3mm 内不要放丝印和过孔。钻孔孔径与板厚比、最小孔环这些参数,交给板厂前至少用在线 DFM 工具跑一遍,出问题的是“看起来连上了、实际回流焊后虚焊”这一类批量良率问题,原理图阶段看不出来。
3. 固件启动流程:main() 初始化、D8000 软元件区与扫描执行模型
V8.0 的源码工程在 Keil MDK 下打开,目标芯片 STM32F103VET6,外设库基于标准外设库(stm32f10x_tim.c、stm32f10x_rcc.c 这一套)。源码目录里 CRJ_FX3U_V4.0.3.201610、CRJ_FX3U_V6.0.3.180921.0 这些文件夹是历次固件版本归档,对比不同版本 main() 的启动顺序,能看出外设配置的演进。固件整体框架并不复杂,核心思路是让 IO 和时钟先稳定,再恢复用户数据,最后才打开通信。
3.1 main() 里谁先谁后:IO 配置、RTC、数据恢复、串口 485
拿到源码第一件事是读 main(),因为它把 PLC 的上电顺序讲得最清楚:
int main(void) { // rstFlg = RCC->CSR; // 复位标志,后面掉电判断会用到 PLC_IO_config(); // PLC 输入输出初始化,先锁定引脚方向 #if RTC_FUNC RTC_Init(); // RTC 时钟配置,掉电后时间戳保持 #endif data_init(); // D8000~D8126 系统软元件初始化 PLC_ReadKeepData(); // 从 Flash/BKP 恢复保持型数据 USART1_Configuration(); // 串口初始化:编程口对应 USART1 bsp_Init(); // 485 收发方向控制、定时器、状态指示灯 HS_MOD_LOCAL_ADDR = 1; // 本机 Modbus 从站地址,默认 1 // 后续主循环:读输入映像 -> 执行梯形图 -> 刷新输出 -> 处理通信帧 while (1) { plc_scan_loop(); } }PLC_IO_config()放在最前面不是习惯而是必须:后续 RTC、串口初始化的过程中若有引脚电平跳变,没有配置的 GPIO 处于浮空输入,外部接线一旦给到高电平,可能误触发还没配置好的外设。data_init()和PLC_ReadKeepData()的顺序也值得注意,先初始化系统软元件区,再从非易失介质回读用户数据,两者不能反过来。
被注释掉的RCC->CSR值得解释。RCC->CSR是复位状态寄存器,可以区分上电复位、外部复位、看门狗复位和欠压复位。在掉电保持方案里,判断“这次启动是不是掉电后的恢复”就是读这个寄存器,典型写法是:
uint32_t rst_flags = RCC->CSR; if (rst_flags & RCC_CSR_PORRSTF) { // 上电复位,保持数据区从 Flash 整块恢复 } else if (rst_flags & RCC_CSR_PINRSTF) { // 外部手动复位,保持数据维持 RAM 缓存,不重读 Flash }HS_MOD_LOCAL_ADDR是通信从站地址,默认 1,对应 FX3U 编程口在 Modbus 场景下的站号。这个变量放在 RAM 里,如果要断电保持,应该在PLC_ReadKeepData()阶段从保持区回读并覆盖默认值。另外,RTC 功能依赖板上的 32.768kHz 晶振,如果 LSE 没焊或虚焊,RTC_Init()可能一直停在同步等待上,表现为主程序卡在初始化阶段,串口没有任何输出,这是上电“假死”的第一排查点。
3.2 stm32f10x_tim.c 与扫描周期:定时器中断如何驱动 PLC 任务
PLC 的执行模型和 FX3U 一样:循环扫描。一次扫描周期依次完成输入采样、用户程序执行、输出刷新、通信服务四个阶段。梯形图在固件里的表达方式,通行做法是先把每个网络编译成中间指令表,ladder.c 在扫描时逐条解释执行;这种做法的执行速度比逐节点递归遍历快,又比 ARM 原生代码方式容易移植到其他型号。
ladder.c 内部维护一个软元件映像区,X 输入、Y 输出、M 中间继电器、D 数据寄存器都映射到 RAM 数组。梯形图执行时只读写这些数组,不直接操作 GPIO,这样输出刷新可以统一做一次,也避免用户程序高频翻转单个 IO 导致外部负载抖动。扫描周期由定时器保障,源码里用到 stm32f10x_tim.c 的 TIM2 作为时基中断:
// TIM2 1ms 中断,作为 PLC 扫描节拍与看门狗时基 void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); ms_tick++; if ((ms_tick & 0x03) == 0) // 每 4ms 递减一次扫描看门狗 scan_watchdog--; if (scan_watchdog == 0) // 用户程序死循环或跑飞 system_fault_handler(); } }TIM_TimeBaseInitTypeDef 里的预分频和自动重载值决定中断周期,72MHz 主频下产生 1ms 中断,一般取prescaler=71, period=999。如果应用有高速计数需求,TIM2 可能被复用为计数通道,这时扫描时基要换到 TIM3 或 TIM4,改板时容易漏。主循环里的plc_scan_loop()只是按状态机顺序调用各阶段函数,真正的时间精度由定时器中断保证,梯形图中的延时定时器指令、脉冲输出指令都以ms_tick为基准。
3.3 D8000~D8126 系统软元件区:为什么不能当普通 D 寄存器用
D8000 开始的系统软元件区是 FX3U 体系里的系统状态字,里面存放扫描时间、错误代码、版本信息等运行状态。data_init()先把它写到默认值,再由通信任务和扫描任务在运行中持续刷新。用户程序读写这一区间要谨慎,例如扫描周期统计值由内核按实际扫描时间周期写入,用户往里面写数据,下一轮扫描就会被覆盖,等于白写。开发应用时,临时变量和数据缓存放在普通 D 区,D8000 起的一段只读用于诊断。
| 范围 | 内容 | 用户程序可写 |
|---|---|---|
| D0~D511 | 保持型数据寄存器,掉电由 PLC_ReadKeepData() 恢复 | 可读写 |
| D512~D7999 | 非保持型数据寄存器,上电清零 | 可读写 |
| D8000~D8003 | 系统信息、版本、错误代码 | data_init 初始化后建议只读 |
| D8100~D8126 | 扩展与外设参数 | 可在配置阶段写入 |
保持区的具体范围在系统参数里可配,V8.0 默认按 D0~D511 处理。调试时如果发现某个 D 寄存器断电不保存,先确认地址是否落在保持范围内,再查PLC_ReadKeepData()的实现,而不是急着改硬件。
4. 485 通信与掉电保持:Modbus RTU 帧解析、CRC16 和保存区策略
FX3U 原机编程口是 422,复刻板改为 485,这是硬件上的取舍,因为 485 是工业现场更普遍的物理层。上位机把编程口协议封装成 Modbus RTU 帧,通过 UART 发送到板子。V8.0 固件里USART1_Configuration()负责打开串口,bsp_Init()把 485 方向控制引脚、收发切换准备好。这章把通信和掉电保持放在一起讲,因为它们的共同点是都要处理异步、时序和极端条件。
4.1 Modbus RTU 帧的接收:地址过滤、超时断帧和 CRC 校验
485 是半双工,一条总线上多个从站共享线路,靠地址区分。HS_MOD_LOCAL_ADDR=1决定了本机只处理地址为 1 的帧。从站在收到非本机地址帧时直接丢弃,不清空接收缓冲,这样不会抢占总线处理时间,也不会因为总线上其他站的数据导致误判。
Modbus RTU 帧格式固定为:
| 字段 | 长度 | 说明 |
|---|---|---|
| 从站地址 | 1 字节 | 0x01~0xF7,0x00 为广播地址 |
| 功能码 | 1 字节 | 0x01 读线圈、0x03 读寄存器、0x06 写单个寄存器 |
| 数据区 | N 字节 | 寄存器起始地址、数量或写值 |
| CRC16 | 2 字节 | 低字节在前,对地址到数据结尾做 CRC 校验 |
帧结束判定有两条路:一种靠串口空闲中断(IDLE,接收线空闲一个字符时间产生),另一种靠定时器超时。标准库里没有现成的 IDLE 中断,常见做法是开一个 3~5 个字符时间的定时器窗口来断帧,下面这个精简实现可以跑在任意 F103 工程里:
// 485 帧接收:地址过滤 + 长度保护 + 超时断帧 #define RX_BUF_MAX 64 volatile uint8_t rx_buf[RX_BUF_MAX]; volatile uint16_t rx_len; volatile uint8_t rx_timeout; void USART1_IRQHandler(void) { uint8_t ch; if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { ch = USART_ReceiveData(USART1); rx_timeout = 30; // 收到一个字节就喂超时计数器 if (rx_len == 0 && ch != HS_MOD_LOCAL_ADDR) { return; // 第一字节地址不匹配,整帧丢弃 } if (rx_len < RX_BUF_MAX) rx_buf[rx_len++] = ch; } } // 在 1ms 定时器中断里递减 rx_timeout,归零表示 30ms 无新数据,判定帧结束rx_timeout是帧间隙超时,9600bps 下 30ms 足够宽松,也兼容一些上位机逐字节发送的方式。地址不匹配后不调用rx_len = 0,保持缓冲为空,让下一条地址匹配的帧从头开始存。广播地址 0x00 任何从站都要响应,处理方式是在地址判断里单独加一个分支,不能直接丢帧。
CRC16 校验使用 Modbus 标准多项式 0xA001,校验范围从地址字节开始到数据区结束,不含 CRC 本身:
uint16_t modbus_crc16(const uint8_t *data, uint16_t len) { uint16_t crc = 0xFFFF; for (uint16_t i = 0; i < len; i++) { crc ^= data[i]; for (uint8_t bit = 0; bit < 8; bit++) { crc = (crc & 0x0001) ? (crc >> 1) ^ 0xA001 : crc >> 1; } } return crc; // 返回值为低字节在前的两个字节 }调用时把rx_buf和rx_len - 2传进去,再和帧尾两个字节比对,不匹配就丢弃。CRC 初值固定 0xFFFF,0xA001 是 Modbus 标准对多项式 0x8005 的反射形式,不能用查表法里的其他初值替换,否则和上位机对不上。串口参数方面,FX3U 编程口默认是 9600bps、8 数据位、无校验、1 停止位,改波特率要同时改USART1_Configuration()里的 BRR 分频值和上位机组态软件的通信参数。
4.2 掉电数据恢复:BKP 备份寄存器、Flash 写入和复位标志配合
FX3U 的 D 寄存器有保持区和非保持区,保持区数据断电后要能恢复。PLC_ReadKeepData()在这里是恢复入口,它的保存介质有两个来源:内部 Flash 划出的扇区,或 RTC 域里的备份数据寄存器(BKP)。两种介质的可靠性模型完全不同:
| 保存介质 | 容量 | 写寿命 | 典型用途 |
|---|---|---|---|
| STM32 内部 Flash 扇区 | 1KB 到 128KB 不等 | 约 1 万次擦写 | 掉电瞬间批量保存 |
| RTC 备份寄存器(BKP) | 42×16 位 | 无写次数限制 | 频繁更新的小批量数据 |
| 外挂铁电 FRAM | KB 到 MB 级 | 百亿次 | 量产方案更合适 |
内部 Flash 寿命只有一万次擦写,绝不能每次 D 寄存器变化都写。合理做法是数据在 RAM 里累积,软件检测到 24V 跌落或收到保存指令时,一次性写入 Flash 扇区,上电PLC_ReadKeepData()只读一次。如果数据修改频繁且量不大,优先用 BKP 寄存器,它没有写寿命问题,但需要 VBAT 引脚接电池或大电容,否则断电后内容同样丢失。
BKP 写入在标准库里的用法是:
PWR_BackupAccessCmd(ENABLE); // 打开备份域写访问 BKP_WriteBackupRegister(RTC_BKP_DR1, d0); // 把 D0 的值写进备份寄存器 1 BKP_WriteBackupRegister(RTC_BKP_DR2, d1); // D1 -> 备份寄存器 2 BKP_WriteBackupRegister(RTC_BKP_DR3, d2); // D2 -> 备份寄存器 3PWR_BackupAccessCmd必须在写 BKP 之前调用,它使能备份域的写保护;读取 BKP 不需要这个指令,但上电后要等备份域时钟稳定。备份寄存器数量有限(F103 系列 42 个 16 位寄存器),能装下 42 个保持 D 寄存器或 21 个 32 位变量,适合保存通信参数、设备地址这类关键数据,大批量配方数据仍要落到 Flash。
V8.0 里rstFlg = RCC->CSR被注释掉,其实是另一个配合点:掉电恢复时用复位标志区分“上电冷启动”还是“从掉电模式唤醒”,决定是否重新从 Flash 读取保持数据。如果每次上电都无条件重读,Flash 里恰好是旧数据,就可能覆盖掉电前 BKP 里的最新值。上电先读复位标志,发现欠压复位(BOR)才执行整块恢复,可以避免这种错误覆盖。
5. 上电验证三步走:分散加载文件、485 回环和掉电数据检查
资料包根目录里有CRJ_FX3U_V6_0_3_180921_sct.Bak这个文件,.Bak后缀说明工程调试阶段改过分散加载文件。分散加载文件(.sct)决定代码、只读数据、RW 数据落到 Flash 的哪些地址,改它往往是为了把保持数据区固定到一个独立区域,避免与固件升级互相覆盖。这个文件值得在烧录前看一眼。
5.1 用分散加载文件把保持数据隔离到独立 Flash 区域
STM32F103VET6 的 Flash 是 512KB,扇区大小并不一致,前 4 个扇区是 16KB,后面有大扇区。要隔离保持数据,不如直接把代码区限制在前 384KB,把最后一个 128KB 大扇区整个划出去,这样擦写数据区时不会碰到代码:
LR_IROM1 0x08000000 0x00060000 { ; 384KB 代码区 ER_IROM1 0x08000000 0x00060000 { *.o (RESET, +First) *(InRoot$$Sections) .ANY (+RO) } RW_IRAM1 0x20000000 0x00010000 { .ANY (+RW +ZI) } } LR_KEEP 0x08060000 0x00020000 { ; 最后 128KB 扇区,独立数据区 ER_KEEP 0x08060000 0x00020000 { keep.o (+RW +ZI) } }LR_KEEP建立独立的加载域,keep.o对应编译单元被指定进去,用户程序升级或擦写其他区域时,不会误伤保持数据。要注意的是擦除粒度:F103 的大扇区最小擦除单位就是 128KB,如果你把保持数据放在代码区中间,一擦就全没了;把代码区上限缩到 0x00060000,最后一个扇区整块留给数据,才算真正隔离。
改完 .sct 要验证一次地址映射:编译后在 Keil 的 MAP 文件里搜keep,确认数据段落在 0x08060000 这个区间。注意,在工程配置里必须关闭Use Memory Layout from Target Dialog,否则你的 .sct 文件会被忽略,保持数据又回到默认布局里。
5.2 485 回环验证与掉电恢复确认
烧录后第一次上电,按顺序验证三件事:IO 是否受控、RTC 是否走时、485 是否响应。485 验证不需要上位机组态软件,用 USB-485 转换器直连 A/B,串口助手发一帧 Modbus 读保持寄存器命令:
发送:01 03 00 00 00 02 C4 0B 返回:01 03 04 xx xx xx xx CRC_LO CRC_HI01是设备地址,对应代码里HS_MOD_LOCAL_ADDR=1;03是读保持寄存器功能码;00 00代表从 D0 开始读;00 02读两个寄存器。返回段里 4 个xx就是 D0 和 D1 的内容,可以和硬件侧写入的状态对应。如果返回的是01 83 02 C0 F1这类异常帧,83是异常功能码,02表示非法数据地址,先核对固件里启用了哪些 Modbus 功能码,再确认 D0 起始地址没超出保持区范围。
掉电恢复的验证方法:用可调电源给板子供 24V,先把 D0 写成一个非零值(比如 0x5A5A),断开 24V 等待 5 秒,重新上电立刻读 D0,确认返回值还是 0x5A5A。如果读回 0,先查PLC_ReadKeepData()有没有被条件编译关掉,再看保持数据用的是 Flash 还是 BKP——BKP 方案要求 VBAT 一直有电,断开 24V 的同时把电池也断开,数据必然丢失。实际现场测试不能直接把 24V 拉掉,拆线前先断负载,避免继电器触点拉弧反灌到主板,损坏 IO 通道。
本文还有配套的精品资源,点击获取