4 行 × 8 字 LED 广告屏控制卡(HC32F460KCTA + FreeRTOS)
做这块控制卡的出发点很直接:我想完整走一遍“点阵显示”这条链路—— 从字库芯片取模、显存组织、行选扫描,到显示页调度和上位机通信。 于是我用 HC32F460KCTA 做了一块控制卡,跑 FreeRTOS, 对外用自定义 RS485 帧收发 JSON 命令,内部管理最多 10 个显示页, 用一块字库芯片取汉字点阵,用 SPI Flash 存页面内容、EEPROM 存小参数。 结论是:功能都实现并跑通了(软件版本 231102),双缓冲显存和页面调度这两块是我最满意的部分; 但字库缓存的内存预算、接收缓冲的上界检查、参数区没有校验这几处, 我是靠“限制使用规格”绕过去的,而不是靠代码约束,这一点在最后一节会老实写清楚。
一、这块控制卡要做什么
需求可以压成一句话:把上位机发来的文字,稳定地显示在现场的 LED 单元板上,并按时间切换内容。 拆开来看是四件事:
- 显示:4 行,每行最多 8 个汉字(视口上限就是 8 个汉字宽), 支持 16/24/32 三档字号、静态显示、左右平移和跑马灯;
- 内容管理:最多 10 个显示页,每页有自己的内容、字体、显示模式、生效周期、 起止时间、显示次数和保持时间;
- 通信:通过 RS485 接收上位机的 JSON 命令(查询、控制、工厂测试三类), 并返回统一格式的应答;
- 自管理:开机自检、字库初始化重试、RTC 定时、自动亮度、硬件看门狗、设备授权校验。
为什么值得做?因为“点阵显示”这件事把嵌入式里几件典型难题凑到了一起: 实时性(扫描不能断)、内存(点阵数据非常大)、 状态管理(多个显示页抢同一块屏)、可靠性(现场断电、单元板接触不良)。 这块板子上的每一个坑,几乎都能对应到其中一类。
对外报告的信息里,我把软件版本号和设备站号放在最先读的两个—— 它们在代码里各是一个常量,也正好是我排查现场问题时最先关心的两件事: 版本号能确认烧的是哪一版,站号能确认这台设备是不是我以为的那台。 站号的具体取值本文不写:它是现场按总线分配的寻址参数, 写出来对读者没有帮助,对项目本身也没有好处。
二、硬件构成:主控、字库芯片、单元板接口、存储、时钟、通信
整块卡的外设清单如下(型号只作技术说明,接口编号是我板子上的实际分配):
| 器件 / 接口 | 连接方式 | 用途 |
|---|---|---|
| HC32F460KCTA | Cortex-M4 + FPU,512 KB Flash / 192 KB SRAM | 主控;Keil MDK、ARMCC V5.06、-O2 优化 |
| GT5SL24K4W 字库芯片 | 硬件 SPI3(SCK PB03 / NSS PB06 / MOSI PB04 / MISO PB05),另配一个定时器中断 | 汉字与 ASCII 点阵取模;另开一块取模缓冲 |
| 74HC595 × N | 直接寄存器操作(GPIO 寄存器基址 + 偏移) | P10(HUB12)接口的行列驱动;宏封装成 CLK、DR1~DR4、A/B/C/D 行选 |
| HUB08 接口 | GPIO(DR1 PA09 / DR2 PA08 / LAT1 PC08 / CLK1 PC09 / OE1 PA10 / DR3 PC06 / DR4 PC07 / C PA11 / D PB15) | 另一种单元板接口,与 HUB12 二选一 |
| GD25Qxx SPI Flash | SPI,页大小 0x100 | 显示页内容、字库缓存等大块数据 |
| 24C 系列 EEPROM | 软件 I²C | 行数/列数、当前页、亮度、开关屏、屏幕类型、自动亮度时间、授权校验字 |
| PCF8563 | 软件 I²C | RTC,供按周/按日期/按时间的显示页使用 |
| RS485 收发 | USART2,115200,方向脚 PA05 | 与上位机通信(帧头字节由本站站号充当) |
| 调试串口 | USART1,921600 | 日志打印 |
| 按键 / 遥控 | EXTI(PA03 调速、PH02 遥控) | 现场调速与遥控 |
| 内置看门狗 WDT | PCLK3 / 2048 分频,计数 65536,100% 刷新窗口 | 异常复位;主循环与 500 ms 任务里喂狗 |
74HC595 那一栏我要特别说明:我没有用库函数去点这些脚,而是直接读写 GPIO 寄存器。 这套芯片的端口寄存器是“基址 + 端口偏移 + 组内偏移”的组织方式: 先用一个基址定位到端口寄存器块,再按端口序号偏移到目标端口, 同一个端口内部又分为置位、复位、数据输出等几个偏移。 这些偏移在芯片手册里都是公开信息,按自己板子上用到的端口算清楚一次就够; 下面只保留写法的形状,不把具体数值抄进来:
/* 直接操作 GPIO 寄存器:地址 = 该 MCU 的 GPIO 寄存器基址
+ 端口偏移 + 组内置位 / 复位偏移(具体数值查手册,不写死在这里) */
#define LED_PORT_SET (*(volatile uint32_t *)(GPIO_BASE + PORT_OFFSET + SET_OFFSET))
#define LED_PORT_RST (*(volatile uint32_t *)(GPIO_BASE + PORT_OFFSET + RST_OFFSET))
/* HUB08 的行数据 D 落在某个端口的一位上:置位 / 复位都写这一位的掩码 */
#define HUB08_D_ON() (LED_PORT_SET = D_MASK)
#define HUB08_D_OFF() (LED_PORT_RST = D_MASK)这样写的好处是扫描循环里每个动作都是一条存储指令,没有函数调用开销, 行选和数据移位的时间可以压得很紧;坏处是可读性差、换一颗芯片就得全部重写, 而且和 DDL 库的写法混在一起,别人接手时要先看懂这套偏移。 我现在更倾向于把这类操作包成一层薄宏、并在宏旁边写清楚偏移是怎么算出来的—— 这块板子上我当时只做到了前半步。
三、通信协议:帧格式逐字节说明 + JSON 命令分派
上位机和我这块卡之间跑的是自定义 RS485 帧,帧头两字节、长度两字节(小端)、 payload 是一段 JSON 文本(ASCII)、最后两字节是 CRC16(小端)。 帧总长 = payload 长度 + 6:
| 偏移 | 长度 | 内容 | 说明 |
|---|---|---|---|
Byte[0] | 1 B | 帧头字节(兼作站号) | 取值就是本站的设备站号;与本机不符就直接丢弃 |
Byte[1] | 1 B | 固定标识字节 | 第二道帧头确认;不匹配就清零重新找帧头 |
Byte[2..3] | 2 B | LEN(小端) | payload 长度;帧总长 = LEN + 6 |
Byte[4..LEN+3] | LEN B | JSON 文本 | 命令体,交给 cJSON 解析 |
Byte[LEN+4] | 1 B | CRC16 低字节 | 小端;校验范围覆盖帧头 + 长度 + payload |
Byte[LEN+5] | 1 B | CRC16 高字节 | 高字节在后 |
上面这张表是协议契约,我把它的字段、长度和语义都留着; 但“接收侧怎么实现”这一段我只给状态说明,不给收发代码—— 逐字节状态机属于这个项目的核心实现,把每个字节怎么比较、怎么搬运照抄出来, 对读者来说是拿来即用,对我来说则等于把实现细节公开,两边都没有必要。 下面这张表说明的是“在什么状态下、收到什么、做什么、转到哪个状态”:
| 当前状态 | 收到什么 | 动作 | 下一状态 |
|---|---|---|---|
| 找帧头 | 与本站站号不符的字节 | 忽略,不缓存 | 找帧头 |
| 找帧头 | 与本站站号相符的字节 | 存入缓冲,已收长度记为 1 | 等固定标识 |
| 等固定标识 | 约定的固定标识字节 | 存入缓冲,已收长度记为 2 | 收数据 |
| 等固定标识 | 任何其它字节 | 丢弃已缓存内容,长度清零 | 找帧头 |
| 收数据 | 任意字节,且长度未到缓冲上限 | 存入缓冲,长度加一 | 收数据 |
| 收数据 | 任意字节,但长度已到缓冲上限 | 丢弃整帧,长度清零 | 找帧头 |
| 收数据 | 总线空闲超过阈值 | 认为一帧收完,交解析:校验长度与 CRC | 找帧头 |
这张表上有两点要顺着说清楚。第一,帧尾不是靠长度字段闭合的,而是靠总线空闲: 1 ms 的节拍中断里累计“这一拍没有新字节”的次数,通信任务看到计数达到阈值就认为一帧收完。 长度字段在这里退化成一个“再校验”的量——长度不符就回一条错误应答, 而不是拿它去数字节。第二,缓冲上限是必须的: 长度字段会被干扰,上位机也可能发错,到上限就丢帧重来, 比把下标夹在缓冲末端安全得多——后者会把一帧已经错位的数据当成好数据继续往下送。
JSON 部分我用的是 cJSON(1.7.15)这个公开的轻量解析库。顶层用三个关键字段做三选一, 分别落到查询、控制、工厂测试三类处理上。这里我只给命令分派表, 不给分派代码:分派本身是一小段控制流,谁写都是那个形状; 而“有哪些命令、每条命令做什么”才是这份协议真正的对外承诺, 把它写成表比写成代码更不容易看漏。 子命令的具体编码值我不逐个列出来——编码是两端约定好的常量, 记错一个比暂时不写更麻烦:
| 顶层类别 | 子命令 | 作用 |
|---|---|---|
"query"(查询) | 查板卡版本 | 读回固件版本与板卡基本信息 |
"query"(查询) | 查页面数据 | 读回指定显示页的全部参数与内容 |
"ctrl"(控制) | 设屏幕类型 | 在两种单元板接口之间切换 |
"ctrl"(控制) | 删页面数据 | 清掉指定的显示页 |
"ctrl"(控制) | 设板卡时间 | 校准 RTC,供按时间生效的显示页使用 |
"ctrl"(控制) | 设页面数据 | 下发一页的完整参数与文字内容,字段最多的一条 |
"ctrl"(控制) | 设自动亮度 | 打开或关闭自动亮度 |
"factory"(工厂测试) | 三组工厂测试命令 | 产线与调试阶段的自检,具体项目按现场约定执行 |
查询版本的应答里,我把现场最需要的信息一次全带上了:
version / device_type / row / column / light / light_sw / page / default / now / mode0..3 / auto_light。
设页面数据那条命令的字段最多,一页的完整参数是:
num / used / default / row / size / shift / s_speed / mar / m_speed / activ / hold / mode /
weeks / start_date / stop_date / dis_times / hold_time / start_time / stop_time / content。
这些字段我第一次定的时候只写了七八个,后来越加越多——这也是为什么我现在坚持
把帧格式和字段表写成一张表放在文档里:
字段名本身就是两端之间的契约,漏一个、改一个,上位机就会发出一条我这边解析不了的命令。
应答格式是统一的:{"code":"...","msg":"...","data":...},
用三个错误码区分结果——CODE_RIGHT=1000(成功)、
CODE_ERR=1001(失败)、CODE_WARING=1002(警告)。
统一形状的好处是上位机只需要写一套解析;坏处是如果 data 里的内容
和 code 表达的状态不一致,排查时容易只盯着 code 看。
关于帧同步(长度字段 / 空闲超时 / 帧头搜索 / 空闲中断这几种做法的取舍), 我在《串口通信的帧同步:四种“怎么知道一帧收完了”的做法》 里做了完整对比,这里不再展开。
四、显存与显示驱动:双缓冲、视口、扫描
点阵显示最怕的不是画不出来,而是“正在刷屏的时候被改了内容”, 屏上就会出现撕裂或者半截的汉字。我的做法是双缓冲: 显存按 4 行组织,每行 128 字节,也就是每组 4 × 128 = 512 字节; 四组缓冲(a / b / c / d)各有一份备份组,一共 8 组、约 4 KB。 写入方只写“后台组”,扫描方只读“前台组”,靠一组指针和状态位切换:
pline_bufa~pline_bufd:四个指向“当前写入组”的指针;save_buf_name/read_buf_name:记录当前哪一组在写、哪一组在读;buf_busy_state1/buf_busy_state2:缓冲区占用标志;timer_idle_state:扫描空闲状态,用来决定“什么时候可以换组”。
视口是另一组状态:currentline(当前在哪一行)、
max_viewport_column(视口列上限,对应“最多显示 8 个汉字”)、
viewport_shift_count(平移计数)和 canvas_used_column(画布已用列数)。
我在 viewport_shift_count 上留过一句注释——“1 个屏移位 16 次,n 个屏移位 n × 16”,
我理解它的意思是:一个汉字宽 16 点,左移一个汉字宽需要走 16 次移位;
级联 n 块单元板,次数就乘 n。写平移和跑马灯的时候,
这个计数就是“内容走了多远”的唯一依据,算错了表现就是字走到一半突然跳回开头。
扫描这一层分两套接口,靠设备类型区分,二选一:
| 接口 | 驱动方式 | 设备类型常量 | 状态 |
|---|---|---|---|
| HUB12(P10 单元板) | 74HC595 级联,直接寄存器操作;CLK 移位、LAT 锁存、OE 使能、A/B/C/D 行选 | P10_DEVICE_TYPE = 0xEE | 主用,实际验证走的是这条路 |
| HUB08 | GPIO 位操作(DR1~DR4、CLK1、LAT1、OE1、C、D) | P475_DEVICE_TYPE = 0x99 | 代码保留,但后来被我强制改写成 P10,见第九节 |
行选(A/B/C/D)和列数据的先后顺序是这类扫描最容易出错的地方: 必须先把列数据移完、锁存好,再切行选,最后开 OE; 顺序反了会看到“上一行的残影出现在下一行”。我在逻辑分析仪上对着 CLK、LAT、OE 和 A/B/C/D 一路看过去,才把时序固定下来—— 这一步用示波器比用串口打印有用得多。
五、显示模式与页面调度
显示部分不是一个 while(1) 里的 switch,而是两个独立任务 + 一个调度器:
文字效果任务(display.c)负责“当前这一页怎么显示”,
跑马灯任务(marquee.c)负责“移动类特效”,
页面调度任务(time_event.c)负责“下一时刻该显示哪一页”。
每个部分都有自己的状态机,命名风格一致:
- 显示任务:
DISPLAY_WAIT(0) → DISPLAY_INIT(1) → DISPLAY_RUN(2) → DISPLAY_EXIT(3); - 跑马灯任务:
MARQUEE_WAIT / INIT / RUN / EXIT,控制结构里带type / mov_state / speed; - 文字预加载任务:
LOAD_WAIT / INIT / RUN / EXIT,配text_message_page(待缓存页) 与text_cache_page(点阵缓存)两套结构; - 页面调度:
EVENT_WAIT(0) / EVENT_INIT(1) / EVENT_ENABLE(2) / EVENT_RUN(3) / EVENT_EXIT(4)。
一页内容按什么策略轮播,用显示类型区分,一共五种:
| 显示类型 | 含义 | 依赖的页参数 |
|---|---|---|
DIS_ALL = 0 | 普通轮播:到时间就换页 | 保持时间、显示次数 |
DIS_WEEKS = 1 | 按星期生效 | weeks[7] 七位星期掩码 |
DIS_DATE = 2 | 按日期区间生效 | 起始日期 / 结束日期 |
DIS_HOLD_TIME = 3 | 按时间段保持 | 开始时间 / 结束时间 |
DIS_TIMES = 4 | 按次数显示 | 显示次数 |
调度器里维护着“最近要显示的几页”(TABLE_MAX_NUM = 3)和多组页面队列:
基础队列、按周队列(还有一个跨页标志数组)、保持队列、次数队列,每组 10 项——
这也是“最多 10 个显示页”这个规格的来源。另外还有默认页和“只显示这一页”的特殊状态,
以及一个用软件定时器(ID 是 666)驱动的预缓存节拍,
提前把下一段的点阵算好,避免切页瞬间出现空白。
自动亮度是同一套调度里的分支:靠 RTC 时间判断是否落在设定的开启/关闭区间内, 再结合一个可开关的自动亮度标志和亮度值去改屏的亮度等级, 这样白天和夜里就不用手工调。把这部分放进页面调度任务里, 代价是任务稍微臃肿了一点,好处是“时间相关的事”只有一个地方在看表。
六、字库取模与内存预算(简述)
汉字点阵来自一块 SPI 接口的字库芯片,驱动层给了一套字号宏:
MIN_8X16_16X16=1 / MED_16X24_24X24=2 / MAX_24X32_32X32=3 /
PLUS_32X48_48X48=4 / ULTRA_48X64_64X64=5,
ASCII 字号有 26 档,字体族里还区分方体、宋体、黑体、不等宽的 Times New Roman 和时钟体。
取模时的地址计算是分三步算的——代码注释写着“担心 8 位单片机有乘法溢出问题,
所以分三步取地址”,这是从一个 8 位平台移植过来的痕迹。
真正的坑不在取模,而在缓存。字库缓存数组是按“两个页面同时缓存”开的, 容量是按字号与页数算出来的一个单元数; 而当字号拉到 64×64、一页 128 个汉字时,一页的点阵数据要 64 KB 空间,两页就是 128 KB—— 这颗芯片只有 192 KB SRAM,还要留 16 KB 给 FreeRTOS 堆。 结果就是数组根本装不下,超出之后会溢出、显示错乱。 这里我不写缓存数组的具体单元数:把“一页多少字 × 每个字占多少位”按自己的规格算一遍, 各人得到的答案本来就不一样,照抄我的数字反而容易对不上。
我当时的选择是把产品规格压下来而不是把数组改大:最大字体限制为 48×48、 每页 128 字;如果用 64×64,一页最多只允许 96 个字。 这笔内存账(按字号与页数算出来的单元数到底能装多少字、双页缓存要占多少空间) 我在另一篇笔记里单独算了一遍, 这里只留结论:这是“用使用规格绕开内存不足”,代码层当时并没有加边界检查。
七、芯片 UID 授权校验(简述)
这块卡上还做了一层“授权校验”:读芯片的唯一 ID,算三个校验字,写进 EEPROM, 每次开机重算比对,不一致就在屏上报加密错误(系统错误码 2)。 具体做法分四步,这里只讲步骤、不给算式:
- 从芯片的只读唯一 ID 寄存器区读回完整的 CPU 唯一 ID,它一共 96 位、分三段;
- 把这三段 ID 和一个固定常量按约定的交叉顺序做异或与乘法,得到三个 32 位校验字;
- 把这三个校验字写进 EEPROM 的固定位置,随参数一起保存;
- 每次开机读回同样的三段 ID,按同样的顺序重算一遍,三个都对上才继续跑业务, 对不上就在屏上报加密错误。
这里我不写出实际的运算表达式,也不写参与运算的那个固定常量—— 这套算式就是授权机制本身,连同常量一起写出来,等于把“怎么绕过去”也一并公开了; 而上面这四步已经足够说明它是怎么被设计出来的。
代码里还有一个细节值得一提:读 ID 的时候,基址是用一个变量存着传进去的, 注释写的是“防止编译优化把 ID 地址合成常量”。这类“和编译器较劲”的写法我以前不太理解, 后来才明白:地址一旦被优化成常量,读出数据的路径就可能和预期不一样。
八、任务划分与看门狗
FreeRTOS 这边我用的是最保守的一套配置:1 ms 节拍、16 个优先级、16 KB 堆、 最小任务栈 130 字、栈溢出检测开到第 2 级,所有任务全部静态创建—— 因为我不希望在运行期因为堆碎片而创建失败。任务划分如下:
| 任务 | 优先级 | 栈(字) | 职责 |
|---|---|---|---|
timer_500ms_task | 7 | 256 | 工作指示灯 + 系统信息打印 + 喂狗 |
screen_display_Task | 6 | 256 | 显存组织与送显 |
marquee_effects_Task | 5 | 256 | 跑马灯特效 |
Display_Task | 5 | 256 | 文字效果 |
load_txt_Task | 3 | 512 | 文字点阵预缓存 |
message_storage_Task | 2 | 512 | 通信帧解析(30 ms 轮询一次) |
time_event_task | 1 | 512 | 页面调度事件管理 |
event_timer(软件定时器) | — | — | 页面调度时基、预缓存节拍 |
start_task | 2 | 128 | 启动任务,创建完其它任务后自删 |
优先级是这样想的:喂狗和打印放在最高(7), 因为它是唯一“不能被我拖住”的事情;显示与送显在 6,保证屏不会因为通信忙而闪; 通信解析只有 2,而且用 30 ms 轮询而不是常驻等消息—— 既然帧尾判定本来就是“总线空闲若干毫秒”,30 ms 的轮询周期完全够用,还省了上下文切换。 栈给得不一样也是有意的:通信解析和字库预缓存要碰大缓冲和 cJSON 解析,我给到 512 字, 显示、跑马灯这类以位运算和小结构体为主的任务给 256 字。
看门狗用的是内置 WDT:时钟取自 PCLK3 再 2048 分频、计数 65536、 刷新窗口按 100% 配置、超时后发复位请求。 我在主循环和 500 ms 任务里各喂一次。 这么做的逻辑是:只要这两个地方还在跑,说明调度器没死、任务没被彻底饿死; 反过来,如果显示任务卡在某个循环里出不来,500 ms 任务仍然能喂狗, 所以看门狗在这块板子上防的主要是“系统级死机”,而不是“单个任务卡死”。 要防后者,得让每个关键任务各自参与喂狗(比如用一个位集合统计),这一点我后来才想明白。
九、问题与解决(现象 → 原因 → 办法)
下面这几条是我做这块卡的过程中真正花过时间的,按“现象 / 原因 / 办法”记下来:
1. 大字体时显示错乱
现象:把字号设到 64×64、一页放满汉字时,屏上出现乱码和不完整的字。 原因:字库点阵缓存数组太小,一页 64×64、128 字的点阵要 64 KB,双页要 128 KB, 而 SRAM 只有 192 KB 且还要留 FreeRTOS 堆;缓存数组的单元数是按双页规格算出来的一个固定值, 不会跟着字号走。 办法:把字体规格限制成 48×48 / 128 字,64×64 时最多 96 字, 并在参数下发时就按这个上限约束。这一条我在第六节展开过,也是这块卡上最有价值的一个教训。
2. 上电后第一次读字库数据是错的
现象:初始化完成后的第一次取模,返回的点阵不对,第二次开始就正常。 原因:字库芯片初始化完之后需要一个额外的读周期才能给出正确数据。 办法:初始化结束后先做一次“模拟读取”把结果丢掉。 这个处理我不是自己想到的——同族的另一颗字库驱动里就是这么写的, 注释只有一句“初始化完毕后测试模拟读取数据,解决第一次读取数据错误”, 我照着做了之后问题消失。
3. 取模地址计算在 8 位平台上会溢出
现象:在大字号下取出来的点阵是别的字,或者一段完全无意义的点阵。 原因:地址计算公式里有乘法,中间结果在小位宽平台上会溢出—— 这段代码是从 8 位平台移植过来的,原作者的注释就写着“担心 8 位单片机有乘法溢出问题, 所以分三步取地址”。办法:沿用三步计算的写法,每一步的中间结果都控制在小位宽内。 我现在对这类“从更小的平台移植过来的代码”会多看一眼:它的写法看起来啰嗦, 但每一处啰嗦背后往往都有一个具体的溢出或时序理由。
4. 协议文档不在工程里
现象:过了一段时间再改协议,发现自己不知道上位机那条命令的字段是怎么排的。 原因:代码里只留了一句“通讯数据格式请查看协作文档”的注释, 而那份文档并不在工程里,帧格式只能靠抓包反推。 办法:把帧格式表(也就是第三节那张表)写进注释和文档各一份。 这一条是我在这块卡上最不技术、但代价最大的教训。
5. 屏幕类型被强制改写成 P10
现象:配置成另一种单元板类型之后,显示不正常。 原因:我当时的判断是那种单元板在驱动时序上兼容性不够, 调试成本高于收益,于是最终只支持 P10(HUB12)。 办法:首次上电和收到旧配置时,统一把设备类型改写成 P10, 并在代码里留下注释说明“这里被强制改写”。 这条我现在会换个做法:要么把不支持的型号明确回一条错误应答, 要么干脆不留那个类型常量——静默地改写用户配置, 在现场排查时是很让人困惑的一件事。
6. 收发方向切换和帧尾判定耦合在一起
现象:极少数情况下,一帧命令像是“没被收到”。 原因:解析函数一进来就把 RS485 方向脚切到发送态(等于关掉接收), 整帧处理完才切回来;如果这一帧的处理时间偏长,紧接着到来的下一帧就会丢。 办法:把协议做成问答式——上位机收到应答才发下一条, 这样“处理期间收不到”就永远不会发生。 这是一个把问题用协议约定绕开而不是用代码解决的例子, 好处是简单,坏处是它限制死了上位机的行为。关于方向脚和帧同步的耦合, 我在帧同步那篇笔记里单独写了一节。
十、局限与还没做完的部分
功能虽然跑通了,但这块卡上我知道有问题、当时没有解决的地方还有不少,按重要性列出来:
- 参数区没有校验、没有双备份。EEPROM 里就是一个个裸的字节、半字、字, 写进去什么样读出来什么样,既没有 CRC 也没有备份区; 唯一的“校验”是那三个授权校验字。掉电写坏的参数没有任何机制能发现。
- 字库缓存没有边界检查。超限的后果是溢出和显示错乱, 我是靠限制字号和字数绕开的,代码层没有拦住。 现在我会在下发页数据时先算一遍需要的点阵单元数,超了就回一条错误应答。
- 接收缓冲的上限处理不干净。原来的写法是把下标夹在缓冲末端, 多余的字节会一直覆写同一个位置;正确做法是丢帧重来—— 这一条我在第三节的状态表里已经把它写成了必须动作。 这里也顺便说明:第三节那张表里没有出现收发代码, 是因为这一层属于项目的核心实现,我只公开“什么状态做什么”。
- HUB08 那条路没有充分验证。GPIO 定义和驱动还在代码里, 但我实际验证的是 P10(HUB12)这一路;另一种单元板最终被强制改写成了 P10。
- 授权校验的强度有限。三个校验字是固定公式算出来的, 只能算“防抄板的最低成本做法”;而且我在同一份代码上留了两个编译配置 (其中一个打开了 Flash 加密位配置),这两者在保护级别上的实际差异我一直没有系统验证过。
- 看门狗只覆盖了“系统级死机”。主循环和 500 ms 任务喂狗, 单个任务卡死不一定能被发现;更细的做法是让关键任务各自上报心跳。
- 还有一路配了没用起来的串口(USART3,256000)和一组按键/遥控输入, 按键调速用过,遥控那一路的完整功能我一直没有定下来。
- 没有升级通道。这块卡当时只能靠调试器重新烧录, 没有做 Bootloader 和参数区双备份;同样是 RS485 通道的升级方案, 我后来在别的项目里才完整做过一遍(见升级与跳转那篇笔记)。
如果让我重做一遍,我会先做三件事:把帧格式和字段表写成文档、 给参数区加 CRC 和备份、把“限制使用规格”的地方全部改成代码里的边界检查。 这三件事都不难,难的是当时觉得“先跑起来再说”。现在回头看, 那些被绕过去的地方,后来都变成了我要在页面上老实交代的部分。
参考资料与说明
- HC32F460 系列用户手册中关于 GPIO、USART、SPI、WDT 与 CPU 唯一 ID 寄存器的说明,属厂商公开文档。
- FreeRTOS 官方文档中关于任务优先级、静态创建、栈溢出检测(
configCHECK_FOR_STACK_OVERFLOW)与软件定时器的说明。 - cJSON 项目文档(MIT 许可的轻量 JSON 解析库),本文的 JSON 解析均指该库的公开用法。
- 74HC595 数据手册(移位寄存器 / 锁存输出的时序与级联方式),属公开器件手册。
- 点阵字库芯片的公开数据手册中关于取模地址计算与字库编码(GBK / 点阵 / 矢量)的说明。
- 本文涉及的行列数、地址表、任务参数与代码片段均来自个人学习项目, 示例代码为按个人理解重写的最小片段,不代表任何产品或交付代码;寄存器偏移与参数值已做通用化处理。
- 出于对个人项目实现细节的保护,本文只公开方法与思路;涉及核心实现的部分以步骤与字段说明代替代码,示例代码为按个人理解重写的通用片段。
- 文中出现的芯片型号仅用于说明技术方案,与相关厂商无隶属或授权关系。