STM32从零到量产开发:四路继电器工业控制模块开发 -上位机主窗口设计说明

STM32从零到量产开发:四路继电器工业控制模块开发 -上位机主窗口设计说明

1. 模块概述

MainForm 是 P02HostPC 命名空间下的核心上位机主窗口模块,专为 P02 4通道继电器控制器设计。该模块面向工业控制场景,提供了完整的设备交互、状态监控与参数配置能力。

核心能力列表

  • 继电器控制:支持4路继电器独立 ON/OFF 切换、全开/全关批量操作,以及基于位掩码(0-15)的批量控制。
  • 状态监控:实时显示4路数字量输入(DI)LED状态,并支持只读查询 INFO、PARAM、STATS、FS(故障安全)等系统信息。
  • 参数配置:支持修改从机地址(ADDR)、波特率(BAUD)、故障安全使能(FS_EN)及超时时间(FS_SEC),并支持恢复出厂设置。
  • 通信保障:内置定时轮询机制自动刷新面板,集成连接看门狗与自动重连机制,确保工业现场通信稳定性。
  • 调试与测试 :提供带时间戳的 TX/RX 日志终端、命令行透传功能,以及支持无头环境(Headless)的自助联调模式(--selftest)。

2. 架构设计

数据流与线程模型

本模块采用异步事件驱动架构,严格分离 UI 线程与后台通信线程,确保界面响应流畅。

数据流路径:

复制代码
按钮点击 → _comm.Enqueue(cmd, OnXxx) → 后台线程收响应 → OnXxx解析(Protocol.TryParse*) → SafeUI更新控件 + 日志

线程模型:

  • UI 线程:负责所有控件的创建、布局与展示更新
  • 后台读线程:SerialComm 内部线程负责串口数据接收,触发响应回调
  • SafeUI 机制 :所有回调中的 UI 更新经 Invoke 切回 UI 线程执行,杜绝跨线程异常

3. UI布局设计

界面采用纯代码构建(DesignerCategory("Code")),非 WinForms Designer 生成,布局结构清晰,按功能划分为以下区域:

区域名称 包含控件 功能说明
顶部连接栏 端口下拉框、波特率下拉框、连接/断开按钮、看门狗状态标签 串口连接管理及通信状态指示
继电器控制区 CH1~CH4 按钮、全开/全断按钮、位掩码输入框+发送按钮 继电器状态翻转与批量控制
DI指示区 DI1~DI4 指示灯(Label)、状态文本(UPTIME/RESETS) 数字量输入状态可视化及系统运行状态
只读信息区 INFO/PARAM/STATS/FS 查询按钮、只读多行文本框 设备固件、参数、统计及故障安全信息查询
配置写Flash区 ADDR/BAUD/FS_EN/FS_SEC 输入控件、应用/恢复出厂/PING按钮 设备参数持久化配置及连通性测试
日志终端区 只读多行文本框 (Consolas 9pt) 实时显示带时间戳的收发报文
底部命令行/轮询 命令行文本框+发送按钮、轮询间隔数字框、自动轮询复选框 自定义指令透传及 STATUS 自动刷新配置

窗口规格: 760×640 像素,最小尺寸 780×680,固定对话框样式,居中显示。


4. 字段与状态管理

字段名称 类型 说明
_comm SerialComm 串口通信管理器,负责底层收发与命令队列
_relayBtns Button[4] 4路继电器 UI 按钮引用(索引 0...3 对应 CH1...CH4)
_diLeds Label[4] 4路 DI 指示灯 UI 引用
_relayState bool[4] 继电器本地状态缓存,采用乐观更新,由 STATUS 轮询校正
_pollTimer Timer 定时轮询 STATUS 命令的定时器(UI 线程触发)
_watchdog Timer 连接看门狗定时器,检测超时与触发重连
_lastResponse DateTime 最后收到有效响应的时间戳
_lastPort string 上次连接的端口名(用于自动重连)
_lastBaud int 上次连接的波特率
_watchdogRecovering bool 防重连风暴标志,避免高频重连尝试

5. 核心功能设计

5.1 继电器控制

单路控制(OnRelayToggle):

  1. 检查连接状态,未连接则直接返回
  2. 翻转本地态 _relayState[idx]
  3. 构建命令 Protocol.BuildRelayOn(ch)Protocol.BuildRelayOff(ch)
  4. _comm.Enqueue(cmd, callback) 入队发送
  5. 回调中解析响应,成功则更新按钮 UI(文本+配色),失败则记录错误日志

全开/全关(BtnAllOn / BtnAllOff):

  • 逐路(CH1~CH4)独立发送命令,每路独立回调更新本地态+按钮
  • 确保 CH4 受控(最后一个发送,回调最后执行)

位掩码批量控制(BtnMaskSend_Click):

  • 支持十进制(0-15)和十六进制(0x0-0xF)输入
  • TryParseMask 解析并校验范围,非法输入弹出警告

5.2 DI指示与状态显示

  • UpdateDiLed 动态切换 Label 背景色:高电平=浅绿(LightGreen),低电平=系统色(灰色)
  • _lblStatus 文本格式:状态:UPTIME {秒}s RESETS {次数},由 OnStatus 回调刷新

5.3 只读信息查询

按钮 命令 解析回调 显示内容
INFO Protocol.BuildInfo() OnInfo 固件版本、MCU型号、运行秒数、复位次数、帧数、CRC错误数、Flash魔术字、Flash CRC
PARAM Protocol.BuildParam() OnParam 从机地址、波特率索引、波特率文本、通信使能
STATS Protocol.BuildStats() OnStats 帧数、CRC错误、异常数、各继电器动作次数、寿命告警掩码、运行秒、运行小时
FS Protocol.BuildFailSafeQuery() OnFailSafe 故障安全使能、超时时间、激活状态

5.4 Flash配置

参数应用(BtnApply_Click):

分批发送独立命令,各参数互不影响:

参数 范围 命令构建
ADDR 1~247 Protocol.BuildAddr(addr)
BAUD 0~4 Protocol.BuildBaud(idx)
FS_EN 0/1 Protocol.BuildFailSafeEn(en)
FS_SEC 1~254 Protocol.BuildFailSafeSec(sec)

恢复出厂(BtnFactory_Click):

  • 弹出二次确认 MessageBox(不可逆操作警告)
  • 用户确认后才发送 Protocol.BuildFactory() 命令

5.5 日志终端

  • Log 方法写入带完整时间戳(yyyy-MM-dd HH:mm:ss.fff)的报文
  • TX 日志前缀 TX> ,RX 日志前缀 RX<
  • 自动滚动到底部(ScrollToCaret
  • 日志框使用 Consolas 9pt 等宽字体

5.6 命令行透传

  • _txtCmd 文本框支持输入任意命令,点击发送或按 Enter 键发送
  • 通过 Protocol.BuildRaw(cmd) 构建原始命令透传给下位机

5.7 定时轮询

  • PollTimer_Tick:连接且 _chkPoll.Checked 为真时,按设定间隔(1~10秒,默认1秒)周期性发送 STATUS 命令
  • 轮询间隔通过 _numPoll 数字框实时调整(ValueChanged 事件)

6. 通信与线程模型

6.1 串口通信流程

复制代码
UI线程                          后台读线程
  │                                │
  ├─ 按钮点击 ──────────────────► │ _comm.Enqueue(cmd, callback)
  │                                │
  │                                ├─ 串口接收数据
  │                                ├─ Protocol.TryParse* 解析
  │                                ├─ 触发对应回调 OnXxx
  │                                │
  │ ◄──── SafeUI(Invoke) ─────────┤ UI更新 + 日志写入

6.2 SafeUI 线程切换机制

csharp 复制代码
private void SafeUI(Action action)
{
    if (SelfTestHeadless) { action(); return; }  // 无头模式同步直执
    if (InvokeRequired)
    {
        Invoke(action);  // 跨线程安全调用,切回UI线程
    }
    else
    {
        action();  // 已在UI线程,直接执行
    }
}

设计要点:

  • 所有响应回调(OnStatus、OnInfo 等)运行在后台读线程,UI 更新必须经 SafeUI
  • 日志写入 Log() 内部也包裹 SafeUI,确保线程安全
  • SelfTestHeadless 模式下跳过 Invoke,同步直执,避免无消息泵环境下的死锁

6.3 事件订阅

SubscribeComm() 统一订阅串口通信三层事件:

事件 处理逻辑
CommandSent 日志输出 TX 报文(前缀 TX>
ResponseReceived 更新时间戳 _lastResponse、清除恢复标志、更新看门狗UI、日志输出 RX 报文
ConnectionLost 切换断开UI状态、更新看门狗UI、日志记录断线原因

7. 看门狗与自动重连

7.1 超时检测逻辑

Watchdog_Tick 每秒执行一次,检测流程:

  1. 未连接则直接返回
  2. 计算距上次响应的时间差 elapsed
  3. 超时阈值 = max(3.0, 轮询周期 × 2.0) ------ 自适应轮询周期,避免慢轮询时误判
  4. 超时则标红看门狗标签,触发重连流程

7.2 重连策略

复制代码
超时检测 → 设置 _watchdogRecovering = true → 断开连接 → 800ms延迟 → 尝试重连 → 成功则恢复轮询 / 失败则记录日志 → 释放Timer

防重连风暴设计:

  • _watchdogRecovering 标志位:重连进行中禁止再次触发重连
  • 800ms 延迟:给串口释放和底层驱动清理留出时间
  • 重连成功后重置 _watchdogRecovering = false_lastResponse

7.3 状态指示

状态 标签文字 颜色
正常 ● 正常 绿色
超时 ● 超时 红色
未连接 ● 未连接 灰色

8. 自助联调模式

通过启动参数 --selftest 激活,支持 CI/沙箱等无桌面环境自动化测试。

8.1 模式开关

属性 说明
SelfTestMode 开关,隐藏窗口、不显示在任务栏
SelfTestPort 目标串口(默认 COM3)
SelfTestHeadless 无桌面环境标志,跳过 Application.RunSafeUI 同步直执

8.2 测试流程与断言设计

步骤 操作 断言条件
1. 连接板子 自动连接指定串口 @ 115200 IsConnected 为真
2. STATUS握手 发送 STATUS 查询 _lblStatus 匹配正则 UPTIME\s+\d+s(排除初始 --s 占位)
3. PING握手 发送 PING 命令 响应包含 PONG
4. INFO握手 发送 INFO 查询 _txtInfo.Text 包含 FW:
5. RELAY写握手 翻转 CH1 _relayState[0] 与初始态不同
6. RELAY还原 再次翻转 CH1 _relayState[0] 恢复初始态
7. STATUS复查 发送 STATUS 查询 板子回报的 CH1 与界面本地态一致(写-回读闭环)

8.3 StWait 轮询等待

csharp 复制代码
private async Task<bool> StWait(Func<bool> cond, int ms)
{
    int w = 0;
    while (!cond() && w < ms)
    {
        await Task.Delay(50).ConfigureAwait(false);  // 关键:ConfigureAwait(false) 防挂起
        w += 50;
    }
    return cond();
}

设计要点: 每 50ms 检查一次条件,最多等待指定毫秒。使用 ConfigureAwait(false) 确保续体投递到线程池而非 WinForms 同步上下文(无头环境下后者会导致永久挂起)。

8.4 报告输出

测试完成后输出 PASS/FAIL 汇总,写入 selftest_P02_时间戳.txt 报告文件到应用程序目录。


9. 对外依赖

依赖类/模块 说明
SerialComm 串口通信管理器,提供 Connect/Disconnect/Enqueue/ListPorts 及事件订阅
Protocol 协议解析器,提供 BuildRelayOn/BuildRelayOff/BuildStatus/BuildInfo 等命令构建方法及 TryParseStatus/TryParseInfo 等解析方法
System.Windows.Forms WinForms UI 框架(Form/Control/Timer/Button/Label/ComboBox/TextBox 等)
System.IO.Ports 串口枚举(SerialPort.GetPortNames)与底层通信
System.Threading.Tasks 异步/await 支持(自助联调模式)

10. 设计总结

设计维度 具体措施
纯代码布局 摆脱 Designer 依赖,便于版本控制与动态调整,DesignerCategory("Code") 标记
乐观更新+轮询校正 继电器点击即时响应(本地态乐观更新),后台 STATUS 轮询保证状态最终一致性
防重连风暴机制 标志位(_watchdogRecovering)+ 800ms 延迟双重保护,避免工业现场通信抖动导致的资源耗尽
自适应超时阈值 超时阈值 = max(3s, 轮询周期×2),兼容 1s~10s 轮询间隔,不误判
无头自助联调 支持 CI/CD 集成,ConfigureAwait(false) 避免无消息泵死锁,写-回读闭环断言确保协议栈可靠性
线程安全封装 SafeUI 统一处理跨线程调用,兼顾 GUI 模式与 Headless 测试模式
参数范围钳位 ADDR(1-247)、BAUD(0-4)、FS_SEC(1-254) 均做范围校验,非法值拒绝写入
恢复出厂保护 二次确认 MessageBox,防止误操作导致配置丢失
csharp 复制代码
// ------------------------------------------------------------------------------
//  MainForm.cs
//  功能:P02 上位机 WinForms 主窗口 ------ 继电器控制 / DI 指示 / 只读信息 /
//        配置写 Flash / 日志终端 / 定时轮询 STATUS。
//  数据流:按钮 -> _comm.Enqueue(cmd, OnXxx) -> 后台线程收响应 ->
//          OnXxx 解析(Protocol.TryParse*) -> SafeUI 更新控件 + 日志。
//  线程:响应回调运行在后台读线程,所有 UI 更新经 SafeUI(Invoke) 切回 UI 线程。
// ------------------------------------------------------------------------------

using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace P02HostPC
{
    /// <summary>P02 上位机主窗口(手写布局,非设计器生成)。</summary>
    [System.ComponentModel.DesignerCategory("Code")]
    public partial class MainForm : Form
    {
        // ============================ 字段 ============================

        #region 字段

        /// <summary>串口通信管理器。</summary>
        private readonly SerialComm _comm = new SerialComm();

        /// <summary>4 路继电器按钮(索引 0..3 对应 CH1..CH4)。</summary>
        private readonly Button[] _relayBtns = new Button[Protocol.CHANNEL_COUNT];

        /// <summary>4 路 DI 指示灯(Label,亮=绿,灭=灰)。</summary>
        private readonly Label[] _diLeds = new Label[Protocol.CHANNEL_COUNT];

        /// <summary>继电器本地态(点击乐观更新,STATUS 轮询校正)。</summary>
        private readonly bool[] _relayState = new bool[Protocol.CHANNEL_COUNT];

        // 顶部连接栏
        private ComboBox _cboPort;
        private ComboBox _cboBaud;
        private Button _btnConnect;
        private Label _lblWatchdog;

        // 继电器区
        private Button _btnAllOn;
        private Button _btnAllOff;
        private TextBox _txtMask;
        private Button _btnMaskSend;

        // DI / 状态区
        private Label _lblStatus;

        // 只读信息区
        private Button _btnInfo;
        private Button _btnParam;
        private Button _btnStats;
        private Button _btnFs;
        private TextBox _txtInfo;

        // 配置区
        private TextBox _txtAddr;
        private ComboBox _cboBaudIdx;
        private ComboBox _cboFsEn;
        private TextBox _txtFsSec;
        private Button _btnApply;
        private Button _btnFactory;
        private Button _btnPing;

        // 日志与命令行
        private TextBox _txtLog;
        private TextBox _txtCmd;
        private Button _btnSendCmd;
        private NumericUpDown _numPoll;
        private CheckBox _chkPoll;

        /// <summary>定时轮询 STATUS 的定时器(UI 线程触发)。</summary>
        private readonly Timer _pollTimer = new Timer();

        /// <summary>连接看门狗 Timer(检测响应超时)。</summary>
        private readonly Timer _watchdog = new Timer();

        /// <summary>最后一次收到响应的时间戳。</summary>
        private DateTime _lastResponse = DateTime.MinValue;

        /// <summary>上次连接的端口名(用于自动重连)。</summary>
        private string _lastPort = string.Empty;

        /// <summary>上次连接的波特率。</summary>
        private int _lastBaud;

        /// <summary>看门狗恢复中标志(防重连风暴)。</summary>
        private bool _watchdogRecovering;

        #endregion

        // ============================ 构造函数 ============================

        #region 构造函数

        /// <summary>初始化窗口、控件、事件订阅。</summary>
        public MainForm()
        {
            BuildLayout();
            BindEvents();
            SubscribeComm();

            // 初始填充端口列表与默认波特
            RefreshPorts();
            _cboBaud.SelectedItem = "115200";

            _pollTimer.Interval = 1000;
            _pollTimer.Tick += PollTimer_Tick;

            _watchdog.Interval = 1000;
            _watchdog.Tick += Watchdog_Tick;
            _watchdog.Start();

            if (SelfTestMode)
            {
                this.Opacity = 0;
                this.ShowInTaskbar = false;
                this.Load += async (s, e) => { await RunSelfTest(); };
            }
        }

        #endregion

        // ============================ 控件初始化 ============================

        #region 控件初始化

        /// <summary>手动创建并布局所有 WinForms 控件(等效于 Designer)。</summary>
        private void BuildLayout()
        {
            this.Text = "P02 4-CH Relay Controller --- 上位机";
            this.ClientSize = new Size(760, 640);
            this.MinimumSize = new Size(780, 680);
            this.FormBorderStyle = FormBorderStyle.FixedDialog;
            this.MaximizeBox = false;
            this.StartPosition = FormStartPosition.CenterScreen;

            // --- 顶部连接栏 ---
            Label lblPort = new Label { Text = "端口", Left = 12, Top = 16, Width = 36 };
            _cboPort = new ComboBox { Left = 50, Top = 12, Width = 80, DropDownStyle = ComboBoxStyle.DropDownList };
            Label lblBaud = new Label { Text = "波特", Left = 140, Top = 16, Width = 36 };
            _cboBaud = new ComboBox { Left = 180, Top = 12, Width = 80, DropDownStyle = ComboBoxStyle.DropDownList };
            _cboBaud.Items.AddRange(new object[] { "9600", "19200", "38400", "57600", "115200" });
            _btnConnect = new Button { Text = "连接", Left = 270, Top = 11, Width = 80, Height = 26 };

            _lblWatchdog = new Label { Text = "● 未连接", Left = 360, Top = 14, Width = 120,
                ForeColor = Color.Gray, AutoSize = true };

            // --- 继电器控制 ---
            GroupBox gbRelay = new GroupBox { Text = "继电器控制", Left = 12, Top = 48, Width = 360, Height = 150 };
            for (int i = 0; i < Protocol.CHANNEL_COUNT; i++)
            {
                int idx = i;
                _relayBtns[i] = new Button
                {
                    Text = $"CH{i + 1} OFF",
                    Left = 20 + i * 82,
                    Top = 22,
                    Width = 76,
                    Height = 34,
                    BackColor = SystemColors.Control,
                    Tag = idx
                };
                gbRelay.Controls.Add(_relayBtns[i]);
            }

            _btnAllOn = new Button { Text = "全开", Left = 20, Top = 70, Width = 80, Height = 28 };
            _btnAllOff = new Button { Text = "全断", Left = 110, Top = 70, Width = 80, Height = 28 };
            Label lblMask = new Label { Text = "位掩码", Left = 20, Top = 108, Width = 48 };
            _txtMask = new TextBox { Left = 72, Top = 104, Width = 60, Height = 23 };
            _btnMaskSend = new Button { Text = "发送", Left = 140, Top = 103, Width = 80, Height = 26 };
            gbRelay.Controls.AddRange(new Control[] { _btnAllOn, _btnAllOff, lblMask, _txtMask, _btnMaskSend });

            // --- DI / 状态 ---
            GroupBox gbDi = new GroupBox { Text = "数字量输入 DI", Left = 384, Top = 48, Width = 360, Height = 150 };
            for (int i = 0; i < Protocol.CHANNEL_COUNT; i++)
            {
                _diLeds[i] = new Label
                {
                    Text = $"DI{i + 1}",
                    Left = 20 + i * 82,
                    Top = 24,
                    Width = 70,
                    Height = 40,
                    BackColor = SystemColors.Control,
                    TextAlign = ContentAlignment.MiddleCenter,
                    BorderStyle = BorderStyle.FixedSingle
                };
                gbDi.Controls.Add(_diLeds[i]);
            }

            _lblStatus = new Label { Text = "状态:UPTIME --s  RESETS --", Left = 20, Top = 110, Width = 320 };
            gbDi.Controls.Add(_lblStatus);

            // --- 只读信息 ---
            GroupBox gbInfo = new GroupBox { Text = "只读信息", Left = 12, Top = 206, Width = 360, Height = 180 };
            _btnInfo = new Button { Text = "INFO", Left = 20, Top = 22, Width = 76, Height = 28 };
            _btnParam = new Button { Text = "PARAM", Left = 102, Top = 22, Width = 76, Height = 28 };
            _btnStats = new Button { Text = "STATS", Left = 184, Top = 22, Width = 76, Height = 28 };
            _btnFs = new Button { Text = "FS", Left = 266, Top = 22, Width = 76, Height = 28 };
            _txtInfo = new TextBox
            {
                Left = 20,
                Top = 58,
                Width = 322,
                Height = 110,
                Multiline = true,
                ReadOnly = true,
                ScrollBars = ScrollBars.Vertical
            };
            gbInfo.Controls.AddRange(new Control[] { _btnInfo, _btnParam, _btnStats, _btnFs, _txtInfo });

            // --- 配置(写 Flash) ---
            GroupBox gbConfig = new GroupBox { Text = "配置(写 Flash)", Left = 384, Top = 206, Width = 360, Height = 180 };
            Label lblAddr = new Label { Text = "ADDR", Left = 20, Top = 26, Width = 44 };
            _txtAddr = new TextBox { Left = 64, Top = 22, Width = 46, Height = 23 };
            Label lblBaud2 = new Label { Text = "BAUD", Left = 120, Top = 26, Width = 44 };
            _cboBaudIdx = new ComboBox { Left = 164, Top = 22, Width = 70, DropDownStyle = ComboBoxStyle.DropDownList };
            _cboBaudIdx.Items.AddRange(new object[] { "0(9600)", "1(19200)", "2(38400)", "3(57600)", "4(115200)" });
            _btnApply = new Button { Text = "应用", Left = 244, Top = 21, Width = 80, Height = 26 };
            Label lblFsEn = new Label { Text = "FS_EN", Left = 20, Top = 60, Width = 48 };
            _cboFsEn = new ComboBox { Left = 70, Top = 56, Width = 50, DropDownStyle = ComboBoxStyle.DropDownList };
            _cboFsEn.Items.AddRange(new object[] { "0", "1" });
            Label lblFsSec = new Label { Text = "FS_SEC", Left = 130, Top = 60, Width = 52 };
            _txtFsSec = new TextBox { Left = 184, Top = 56, Width = 46, Height = 23 };
            _btnFactory = new Button { Text = "恢复出厂", Left = 244, Top = 55, Width = 80, Height = 26 };
            _btnPing = new Button { Text = "PING", Left = 20, Top = 92, Width = 80, Height = 28 };
            gbConfig.Controls.AddRange(new Control[]
            {
                lblAddr, _txtAddr, lblBaud2, _cboBaudIdx, _btnApply,
                lblFsEn, _cboFsEn, lblFsSec, _txtFsSec, _btnFactory, _btnPing
            });

            // --- 日志终端 ---
            GroupBox gbLog = new GroupBox { Text = "日志终端", Left = 12, Top = 392, Width = 732, Height = 170 };
            _txtLog = new TextBox
            {
                Left = 16,
                Top = 20,
                Width = 700,
                Height = 138,
                Multiline = true,
                ReadOnly = true,
                ScrollBars = ScrollBars.Vertical,
                Font = new Font("Consolas", 9f)
            };
            gbLog.Controls.Add(_txtLog);

            // --- 底部命令行 / 轮询 ---
            Label lblCmd = new Label { Text = "命令行", Left = 12, Top = 578, Width = 48 };
            _txtCmd = new TextBox { Left = 64, Top = 574, Width = 380, Height = 23 };
            _btnSendCmd = new Button { Text = "发送", Left = 452, Top = 573, Width = 70, Height = 26 };
            Label lblPoll = new Label { Text = "轮询", Left = 540, Top = 578, Width = 36 };
            _numPoll = new NumericUpDown { Left = 580, Top = 575, Width = 46, Minimum = 1, Maximum = 10, Value = 1 };
            _chkPoll = new CheckBox { Text = "自动", Left = 634, Top = 575, Width = 80, Checked = true };

            // --- 汇总加入窗体 ---
            this.Controls.AddRange(new Control[]
            {
                lblPort, _cboPort, lblBaud, _cboBaud, _btnConnect,
                _lblWatchdog,
                gbRelay, gbDi, gbInfo, gbConfig, gbLog,
                lblCmd, _txtCmd, _btnSendCmd, lblPoll, _numPoll, _chkPoll
            });
        }

        #endregion

        // ============================ 事件绑定 ============================

        #region 事件绑定

        /// <summary>绑定所有按钮/控件事件。</summary>
        private void BindEvents()
        {
            _btnConnect.Click += BtnConnect_Click;

            for (int i = 0; i < Protocol.CHANNEL_COUNT; i++)
            {
                int ch = i + 1;
                _relayBtns[i].Click += (s, e) => OnRelayToggle(ch);
            }

            _btnAllOn.Click += (s, e) =>
            {
                if (!_comm.IsConnected) return;
                // 逐路发送 RELAY n ON(含回调握手更新本地态+按钮),确保 CH4 受控
                for (int ch = 1; ch <= Protocol.CHANNEL_COUNT; ch++)
                {
                    int idx = ch - 1;
                    _comm.Enqueue(Protocol.BuildRelayOn(ch), (raw) =>
                    {
                        if (Protocol.TryParseCommandResult(raw, out var r) && r.Success)
                        {
                            _relayState[idx] = true;
                            SafeUI(() => UpdateRelayButton(idx));
                        }
                    });
                }
            };
            _btnAllOff.Click += (s, e) =>
            {
                if (!_comm.IsConnected) return;
                // 逐路发送 RELAY n OFF(含回调握手更新本地态+按钮),确保 CH4 受控
                for (int ch = 1; ch <= Protocol.CHANNEL_COUNT; ch++)
                {
                    int idx = ch - 1;
                    _comm.Enqueue(Protocol.BuildRelayOff(ch), (raw) =>
                    {
                        if (Protocol.TryParseCommandResult(raw, out var r) && r.Success)
                        {
                            _relayState[idx] = false;
                            SafeUI(() => UpdateRelayButton(idx));
                        }
                    });
                }
            };
            _btnMaskSend.Click += BtnMaskSend_Click;

            _btnInfo.Click += (s, e) => _comm.Enqueue(Protocol.BuildInfo(), OnInfo);
            _btnParam.Click += (s, e) => _comm.Enqueue(Protocol.BuildParam(), OnParam);
            _btnStats.Click += (s, e) => _comm.Enqueue(Protocol.BuildStats(), OnStats);
            _btnFs.Click += (s, e) => _comm.Enqueue(Protocol.BuildFailSafeQuery(), OnFailSafe);

            _btnApply.Click += BtnApply_Click;
            _btnFactory.Click += BtnFactory_Click;
            _btnPing.Click += (s, e) => _comm.Enqueue(Protocol.BuildPing(), OnPing);

            _btnSendCmd.Click += (s, e) => BtnSendCmd_Click();
            _txtCmd.KeyDown += (s, e) =>
            {
                if (e.KeyCode == Keys.Enter)
                {
                    BtnSendCmd_Click();
                    e.SuppressKeyPress = true;
                }
            };

            _numPoll.ValueChanged += (s, e) => _pollTimer.Interval = (int)(_numPoll.Value * 1000);
        }

        /// <summary>订阅串口通信事件(日志 + 断线处理)。</summary>
        private void SubscribeComm()
        {
            _comm.CommandSent += (cmd) => Log("TX> " + cmd);
            _comm.ResponseReceived += (raw) =>
            {
                _lastResponse = DateTime.Now;
                _watchdogRecovering = false;
                SafeUI(() => UpdateWatchdogUi(true));
                Log("RX< " + raw.Replace("\r\n", " | ").Replace("\n", " | "));
            };
            _comm.ConnectionLost += (msg) =>
            {
                SafeUI(() =>
                {
                    SetConnectedUi(false);
                    UpdateWatchdogUi(false);
                    Log("[!] 连接丢失:" + msg);
                });
            };
        }

        #endregion

        // ============================ 连接 ============================

        #region 连接

        /// <summary>连接/断开按钮。</summary>
        private void BtnConnect_Click(object sender, EventArgs e)
        {
            if (_comm.IsConnected)
            {
                _comm.Disconnect();
                SetConnectedUi(false);
                UpdateWatchdogUi(false);
                Log("[*] 已断开");
                return;
            }

            string port = _cboPort.Text;
            if (string.IsNullOrEmpty(port))
            {
                MessageBox.Show("请先选择串口", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information);
                return;
            }

            int baud = int.Parse(_cboBaud.Text, System.Globalization.CultureInfo.InvariantCulture);
            bool ok = _comm.Connect(port, baud);
            if (!ok)
            {
                MessageBox.Show($"无法打开 {port}(可能被占用或不存在)", "连接失败", MessageBoxButtons.OK, MessageBoxIcon.Error);
                return;
            }

            SetConnectedUi(true);
            _lastPort = port;
            _lastBaud = baud;
            _lastResponse = DateTime.Now;
            UpdateWatchdogUi(true);
            Log($"[*] 已连接 {port} @ {baud}");
            // 连上先查一次状态
            _comm.Enqueue(Protocol.BuildStatus(), OnStatus);
        }

        /// <summary>根据连接状态切换 UI(启用/禁用控件、按钮文案)。</summary>
        private void SetConnectedUi(bool connected)
        {
            _btnConnect.Text = connected ? "断开" : "连接";
            // 连接相关控件仅在连接后可用
            foreach (Control c in new Control[]
            {
                _btnAllOn, _btnAllOff, _btnMaskSend, _btnInfo, _btnParam,
                _btnStats, _btnFs, _btnApply, _btnFactory, _btnPing, _btnSendCmd
            })
            {
                c.Enabled = connected;
            }

            for (int i = 0; i < Protocol.CHANNEL_COUNT; i++)
            {
                _relayBtns[i].Enabled = connected;
            }

            if (connected)
            {
                _pollTimer.Start();
            }
            else
            {
                _pollTimer.Stop();
            }
        }

        /// <summary>刷新可用串口下拉列表。</summary>
        private void RefreshPorts()
        {
            _cboPort.Items.Clear();
            foreach (string p in SerialComm.ListPorts())
            {
                _cboPort.Items.Add(p);
            }

            if (_cboPort.Items.Count > 0)
            {
                _cboPort.SelectedIndex = 0;
            }
        }

        #endregion

        // ============================ 继电器控制 ============================

        #region 继电器控制

        /// <summary>单路继电器点击:翻转本地态并发送 ON/OFF。</summary>
        private void OnRelayToggle(int ch)
        {
            if (!_comm.IsConnected)
            {
                return;
            }

            int idx = ch - 1;
            bool turnOn = !_relayState[idx];
            string cmd = turnOn ? Protocol.BuildRelayOn(ch) : Protocol.BuildRelayOff(ch);
            int localIdx = idx;
            _comm.Enqueue(cmd, (raw) =>
            {
                if (Protocol.TryParseCommandResult(raw, out var r) && r.Success)
                {
                    _relayState[localIdx] = turnOn;
                    SafeUI(() => UpdateRelayButton(localIdx));
                }
                else
                {
                    Log($"[ERR] CH{ch}: {r.Message}");
                }
            });
        }

        /// <summary>位掩码批量发送:本地校验 0-15 / 0x 前缀。</summary>
        private void BtnMaskSend_Click(object sender, EventArgs e)
        {
            if (!_comm.IsConnected)
            {
                return;
            }

            string txt = _txtMask.Text.Trim();
            if (!TryParseMask(txt, out int mask))
            {
                MessageBox.Show("位掩码须为 0-15 或 0x0-0xF", "输入错误", MessageBoxButtons.OK, MessageBoxIcon.Warning);
                return;
            }

            _comm.Enqueue(Protocol.BuildRelayAll(mask));
        }

        /// <summary>解析位掩码(支持十进制与 0x 十六进制)。</summary>
        private static bool TryParseMask(string txt, out int mask)
        {
            mask = 0;
            if (string.IsNullOrEmpty(txt))
            {
                return false;
            }

            string s = txt;
            int @base = 10;
            if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
            {
                @base = 16;
                s = s.Substring(2);
            }

            if (!int.TryParse(s, @base == 16 ? System.Globalization.NumberStyles.HexNumber : System.Globalization.NumberStyles.Integer,
                System.Globalization.CultureInfo.InvariantCulture, out mask))
            {
                return false;
            }

            return mask >= 0 && mask <= 0x0F;
        }

        #endregion

        // ============================ 配置写 Flash ============================

        #region 配置写 Flash

        /// <summary>应用 ADDR / BAUD / FS_EN / FS_SEC 配置(分批发送)。</summary>
        private void BtnApply_Click(object sender, EventArgs e)
        {
            if (!_comm.IsConnected)
            {
                return;
            }

            if (!string.IsNullOrWhiteSpace(_txtAddr.Text))
            {
                if (int.TryParse(_txtAddr.Text.Trim(), out int addr) && addr >= 1 && addr <= 247)
                {
                    _comm.Enqueue(Protocol.BuildAddr(addr), OnSimpleResult);
                }
                else
                {
                    Log("[ERR] ADDR 范围 1-247");
                }
            }

            if (_cboBaudIdx.SelectedItem != null)
            {
                int idx = int.Parse(_cboBaudIdx.SelectedItem.ToString().Substring(0, 1), System.Globalization.CultureInfo.InvariantCulture);
                _comm.Enqueue(Protocol.BuildBaud(idx), OnSimpleResult);
            }

            if (_cboFsEn.SelectedItem != null)
            {
                int en = int.Parse(_cboFsEn.SelectedItem.ToString(), System.Globalization.CultureInfo.InvariantCulture);
                _comm.Enqueue(Protocol.BuildFailSafeEn(en), OnSimpleResult);
            }

            if (!string.IsNullOrWhiteSpace(_txtFsSec.Text))
            {
                if (int.TryParse(_txtFsSec.Text.Trim(), out int sec) && sec >= 1 && sec <= 254)
                {
                    _comm.Enqueue(Protocol.BuildFailSafeSec(sec), OnSimpleResult);
                }
                else
                {
                    Log("[ERR] FS_SEC 范围 1-254");
                }
            }
        }

        /// <summary>恢复出厂:二次确认(不可逆)。</summary>
        private void BtnFactory_Click(object sender, EventArgs e)
        {
            if (!_comm.IsConnected)
            {
                return;
            }

            DialogResult dr = MessageBox.Show(
                "将恢复出厂参数:Modbus 地址 / 波特 / 故障安全将被重置。\n确定继续?",
                "恢复出厂确认",
                MessageBoxButtons.YesNo,
                MessageBoxIcon.Warning);
            if (dr == DialogResult.Yes)
            {
                _comm.Enqueue(Protocol.BuildFactory(), OnSimpleResult);
            }
        }

        /// <summary>命令行透传发送。</summary>
        private void BtnSendCmd_Click()
        {
            if (!_comm.IsConnected)
            {
                return;
            }

            string cmd = _txtCmd.Text.Trim();
            if (string.IsNullOrEmpty(cmd))
            {
                return;
            }

            _comm.Enqueue(Protocol.BuildRaw(cmd));
            _txtCmd.Clear();
        }

        #endregion

        // ============================ 响应回调 ============================

        #region 响应回调(后台线程执行,UI 更新须 SafeUI)

        /// <summary>STATUS 响应:刷新继电器按钮 + DI 指示灯 + 状态文本。</summary>
        private void OnStatus(string raw)
        {
            if (!Protocol.TryParseStatus(raw, out var d))
            {
                return;
            }

            SafeUI(() =>
            {
                for (int i = 0; i < Protocol.CHANNEL_COUNT; i++)
                {
                    _relayState[i] = d.Relays[i];
                    UpdateRelayButton(i);
                    UpdateDiLed(i, d.Di[i]);
                }

                _lblStatus.Text = $"状态:UPTIME {d.UptimeSec}s  RESETS {d.Resets}";
            });
        }

        /// <summary>INFO 响应:填充只读信息框。</summary>
        private void OnInfo(string raw)
        {
            if (Protocol.TryParseInfo(raw, out var d))
            {
                SafeUI(() => _txtInfo.Text =
                    $"FW: {d.Fw}  MCU: {d.Mcu}\r\n" +
                    $"Uptime: {d.UptimeSec}s  Resets: {d.Resets}\r\n" +
                    $"Frames: {d.Frames}  CRC_Err: {d.CrcErr}\r\n" +
                    $"Flash magic=0x{d.FlashMagic} crc=0x{d.FlashCrc}");
            }
        }

        /// <summary>PARAM 响应:填充只读信息框。</summary>
        private void OnParam(string raw)
        {
            if (Protocol.TryParseParam(raw, out var d))
            {
                SafeUI(() => _txtInfo.Text =
                    $"SlaveAddr={d.SlaveAddr}\r\nBaudIndex={d.BaudIndex}({d.BaudText})\r\nLinkEnable={d.LinkEnable}");
            }
        }

        /// <summary>STATS 响应:填充只读信息框。</summary>
        private void OnStats(string raw)
        {
            if (Protocol.TryParseStats(raw, out var d))
            {
                SafeUI(() =>
                {
                    var sb = new System.Text.StringBuilder();
                    sb.AppendLine($"Frames={d.Frames}  CRC_Err={d.CrcErr}  Excep={d.Excep}");
                    sb.Append("RelayAct: ");
                    for (int i = 0; i < Protocol.CHANNEL_COUNT; i++)
                    {
                        sb.Append($"R{i + 1}={d.RelayAct[i]} ");
                    }

                    sb.AppendLine();
                    sb.AppendLine($"LifeAlarmMask=0x{d.LifeAlarmMask:X2}");
                    sb.Append($"RunSec={d.RunSec}  RunHours={d.RunHours}");
                    _txtInfo.Text = sb.ToString();
                });
            }
        }

        /// <summary>FS 响应:填充只读信息框。</summary>
        private void OnFailSafe(string raw)
        {
            if (Protocol.TryParseFailSafe(raw, out var d))
            {
                SafeUI(() => _txtInfo.Text =
                    $"FailSafe en={d.En}  timeout={d.TimeoutSec}s  active={d.Active}");
            }
        }

        /// <summary>PING 响应:校验 PONG。</summary>
        private void OnPing(string raw)
        {
            bool pong = raw.Contains("PONG");
            Log(pong ? "[OK] PING -> PONG (链路正常)" : "[!] PING 未收到 PONG");
        }

        /// <summary>通用写类命令结果。</summary>
        private void OnSimpleResult(string raw)
        {
            if (Protocol.TryParseCommandResult(raw, out var r))
            {
                Log(r.Success ? $"[OK] {r.Message}" : $"[ERR] {r.Message}");
            }
        }

        #endregion

        // ============================ 定时轮询 ============================

        #region 定时轮询

        /// <summary>定时器:自动发 STATUS 刷新面板。</summary>
        private void PollTimer_Tick(object sender, EventArgs e)
        {
            if (_comm.IsConnected && _chkPoll.Checked)
            {
                _comm.Enqueue(Protocol.BuildStatus(), OnStatus);
            }
        }

        #endregion

        // ============================ UI 辅助 ============================

        #region UI 辅助

        /// <summary>线程安全更新 UI(后台线程回调统一经此切回 UI 线程)。</summary>
        private void SafeUI(Action action)
        {
            if (SelfTestHeadless) { action(); return; }
            if (InvokeRequired)
            {
                Invoke(action);
            }
            else
            {
                action();
            }
        }

        /// <summary>更新单路继电器按钮文本与配色。</summary>
        private void UpdateRelayButton(int idx)
        {
            Button b = _relayBtns[idx];
            if (_relayState[idx])
            {
                b.Text = $"CH{idx + 1} ON";
                b.BackColor = Color.LightGreen;
            }
            else
            {
                b.Text = $"CH{idx + 1} OFF";
                b.BackColor = SystemColors.Control;
            }
        }

        /// <summary>更新单路 DI 指示灯配色(亮=绿,灭=灰)。</summary>
        private void UpdateDiLed(int idx, bool high)
        {
            _diLeds[idx].BackColor = high ? Color.LightGreen : SystemColors.Control;
        }

        /// <summary>写日志(带完整时间戳 yyyy-MM-dd HH:mm:ss.fff,自动滚到底部)。</summary>
        private void Log(string line)
        {
            SafeUI(() =>
            {
                _txtLog.AppendText($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] {line}\r\n");
                _txtLog.ScrollToCaret();
            });
        }

        #endregion

        #region 看门狗(连接状态指示)

        /// <summary>每秒检查响应是否超时(>3s 无响应标红并尝试重连一次)。</summary>
        private void Watchdog_Tick(object sender, EventArgs e)
        {
            if (!_comm.IsConnected)
            {
                return;
            }

            // 超时阈值自适应:≥ 轮询周期 × 2(最少 3s),
            // 避免慢轮询(如 5s、10s)周期内被自家看门狗误判超时。
            double elapsed = (DateTime.Now - _lastResponse).TotalSeconds;
            double timeoutSec = Math.Max(3.0, _pollTimer.Interval / 1000.0 * 2.0);
            if (elapsed > timeoutSec)
            {
                SafeUI(() => UpdateWatchdogUi(false));

                // 尝试自动重连一次(防重连风暴)
                if (!_watchdogRecovering && _lastPort.Length > 0)
                {
                    _watchdogRecovering = true;
                    Log("[WARN] 响应超时,尝试重连...");
                    _comm.Disconnect();
                    SetConnectedUi(false);
                    // 延迟后重连(给串口释放时间)
                    System.Threading.Timer t = null;
                    t = new System.Threading.Timer(_ =>
                    {
                        if (_comm.Connect(_lastPort, _lastBaud))
                        {
                            _lastResponse = DateTime.Now;
                            SafeUI(() =>
                            {
                                SetConnectedUi(true);
                                UpdateWatchdogUi(true);
                                Log("[INFO] 自动重连成功");
                                _pollTimer.Start();
                            });
                        }
                        else
                        {
                            SafeUI(() => Log("[ERROR] 自动重连失败"));
                        }
                        t.Dispose();
                    }, null, 800, System.Threading.Timeout.Infinite);
                }
            }
        }

        /// <summary>更新看门狗指示标签。</summary>
        private void UpdateWatchdogUi(bool healthy)
        {
            _lblWatchdog.ForeColor = healthy ? Color.Green : Color.Red;
            _lblWatchdog.Text = healthy ? "● 正常" : "● 超时";
        }

        #endregion

        #region 自助联调 (--selftest)

        /// <summary>自助联调模式开关(由 Program 解析 --selftest 设置)。</summary>
        public bool SelfTestMode { get; set; } = false;
        /// <summary>自助联调目标串口(由 Program 解析 --selftest [port] 设置,默认 COM3)。</summary>
        public string SelfTestPort { get; set; } = "COM3";

        /// <summary>无桌面环境(CI/沙箱)自助联调:跳过 Application.Run 与窗口句柄,使 SafeUI 同步直执、绕过 BeginInvoke。</summary>
        public bool SelfTestHeadless { get; set; } = false;

        private readonly List<string> _stLines = new List<string>();
        private int _stPass, _stFail;

        private void StLog(string s) => _stLines.Add(s);
        /// <summary>状态栏是否已被板子真实响应刷新(要求 "UPTIME &lt;数字&gt;s",排除初始占位 "--s")。</summary>
        private bool StatusHasRealUptime()
        {
            return System.Text.RegularExpressions.Regex.IsMatch(_lblStatus.Text, @"UPTIME\s+\d+s");
        }

        /// <summary>把多行文本压成单行,便于写入报告。</summary>
        private static string OneLine(string s)
        {
            return (s ?? string.Empty).Replace("\r\n", " / ").Replace("\n", " / ").Trim();
        }

        private void StCheck(string name, bool ok, string detail = "")
        {
            if (ok) _stPass++; else _stFail++;
            StLog($"{(ok ? "[PASS]" : "[FAIL]")} {name}" + (detail.Length > 0 ? " :: " + detail : ""));
        }
        /// <summary>
        /// 轮询等待条件成立(每 50ms 检查一次)。
        /// 【关联指引】headless 下主线程被 GetAwaiter().GetResult() 阻塞,若 await 续体被投递回
        /// WinForms 同步上下文(此时无消息泵)将永久挂起,故必须 ConfigureAwait(false) 走线程池。
        /// </summary>
        private async Task<bool> StWait(Func<bool> cond, int ms)
        {
            int w = 0;
            while (!cond() && w < ms) { await Task.Delay(50).ConfigureAwait(false); w += 50; }
            return cond();
        }

        /// <summary>自助联调主流程:连真板、跑 STATUS 查询与继电器写回读、断言界面由板子真实响应刷新。</summary>
        public async Task RunSelfTest()
        {
            try
            {
                StLog($"=== P02 GUI SelfTest @ {DateTime.Now:HH:mm:ss} ===");
                if (SelfTestHeadless) System.Threading.SynchronizationContext.SetSynchronizationContext(null);
                if (!_cboPort.Items.Contains(SelfTestPort)) _cboPort.Items.Add(SelfTestPort);
                _cboPort.SelectedItem = SelfTestPort;
                string port = SelfTestPort;
                StLog($"Port={port} Baud=115200");

                bool ok = _comm.Connect(port, 115200);
                StCheck("Connect to board", ok, $"IsConnected={_comm.IsConnected}");
                if (!ok) return;

                await StWait(() => _comm.IsConnected, 2000).ConfigureAwait(false);

                // STATUS 全查询:OnStatus 刷新继电器按钮 / DI 指示灯 / 状态文本(握手驱动)
                // 断言必须要求出现真实数字(初始占位文本为 "UPTIME --s",只查 Contains 会假 PASS)
                _comm.Enqueue(Protocol.BuildStatus(), OnStatus);
                await StWait(() => StatusHasRealUptime(), 3000).ConfigureAwait(false);
                StCheck("STATUS handshake fills GUI (real uptime digits)", StatusHasRealUptime(),
                    $"lbl=\"{_lblStatus.Text}\"");

                // PING 链路握手:断言收到板子 PONG
                bool pong = false;
                _comm.Enqueue(Protocol.BuildPing(), (raw) => { if (raw.Contains("PONG")) pong = true; });
                await StWait(() => pong, 3000).ConfigureAwait(false);
                StCheck("PING handshake (board replies PONG)", pong);

                // INFO 查询握手:断言只读信息框被板子响应填充
                _comm.Enqueue(Protocol.BuildInfo(), OnInfo);
                await StWait(() => _txtInfo.Text.Contains("FW:"), 3000).ConfigureAwait(false);
                StCheck("INFO handshake fills info box", _txtInfo.Text.Contains("FW:"),
                    $"info=\"{OneLine(_txtInfo.Text)}\"");

                // 继电器写握手:读当前态 -> 翻转 -> 断言 GUI 被板子响应刷新 -> 还原
                bool s0 = _relayState[0];
                OnRelayToggle(1);
                await StWait(() => _relayState[0] != s0, 3000).ConfigureAwait(false);
                StCheck("RELAY write handshake (CH1 flips GUI)", _relayState[0] != s0,
                    $"btn=\"{_relayBtns[0].Text}\"");
                OnRelayToggle(1); // 还原到初始态
                await StWait(() => _relayState[0] == s0, 3000).ConfigureAwait(false);
                StCheck("RELAY restore to original", _relayState[0] == s0,
                    $"btn=\"{_relayBtns[0].Text}\"");

                // STATUS 复查:断言板子回报的 CH1 与界面本地态一致(写-回读闭环)
                bool[] before = (bool[])_relayState.Clone();
                _comm.Enqueue(Protocol.BuildStatus(), OnStatus);
                await StWait(() => false, 800).ConfigureAwait(false); // 固定等待一帧回来
                StCheck("STATUS re-read matches GUI relay state", _relayState[0] == before[0],
                    $"gui={_relayState[0]} board={before[0]}");
            }
            catch (Exception ex)
            {
                StLog($"[EXCEPTION] {ex}");
                _stFail++;
            }
            finally
            {
                FinishSelfTest();
            }
        }

        private void FinishSelfTest()
        {
            StLog($"=== SUMMARY PASS={_stPass} FAIL={_stFail} ===");
            string ts = DateTime.Now.ToString("HHmmss");
            string path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, $"selftest_P02_{ts}.txt");
            try { File.WriteAllLines(path, _stLines); StLog("REPORT=" + path); }
            catch (Exception ex) { StLog("[WARN] report write failed: " + ex.Message); }
            if (!SelfTestHeadless) { try { this.Close(); } catch { } }
        }

        #endregion

    }
}
相关推荐
wuyk5552 小时前
第1章:无刷电机核心原理与运行机制
c语言·开发语言·stm32·单片机·嵌入式硬件·机器学习
科芯创展2 小时前
XZ9164,16V,0.8A稳压LDO芯片
单片机·嵌入式硬件
WWJA王文举2 小时前
HAL库、标准库、LL库和寄存器开发有什么区别?STM32四种开发方式一次讲透
stm32·单片机·嵌入式硬件
坚持编程的菜鸟2 小时前
模拟实现strncat
c语言·算法·模拟实现strncat
Hhy_11072 小时前
【从零开始学数据结构 ⑥】:二叉树之堆——打破规则的优先队列
c语言·开发语言·数据结构·学习·visual studio
0x3F(小茶)2 小时前
FreeRTOS 系统配置
c语言·单片机·mcu
wengqidaifeng2 小时前
2026 年电赛(TI 杯)H 题:完整测评流程、失败案例复盘、比赛策略与开源交付
c语言·单片机·嵌入式硬件
ysu_03143 小时前
【创作128天纪念】从新手村踏上征途
c语言
0x3F(小茶)3 小时前
FreeRTOS 任务相关 API 函数
c语言·arm开发·单片机