ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

LEDShow 上位机与单片机串口通信:点阵屏协议、VC++ 环境与帧调度实战

LEDShow 上位机与单片机串口通信:点阵屏协议、VC++ 环境与帧调度实战

简介:面向单片机开发与Visual C++上位机编程的LED显示控制项目源码包,适用于嵌入式入门和MFC界面开发学习者,演示了上位机如何通过串行通信驱动LED设备。资源共69个文件,压缩包约10.14MB,包含C++源程序、MFC头文件、动态链接库(LEDSender.dll、Interface.dll)、工程配置、调试日志与图标资源等,能从中看到对话框界面搭建、指令发送模块封装以及用户接口与硬件交互层的完整实现。核心源码围绕LED显示调试展开,涉及基于MFC的窗口程序编写、DLL模块划分、SPI、I2C、UART等串行通信接口控制LED亮灭与颜色变化,以及点阵扫描、亮度调节等原理。已有133人学习下载,适合希望系统走一遍LED显示项目全流程,并想借助真实工程快速理解串口通信、DLL封装与上位机协作机制的开发者参考。

1. 拿到 LEDShow.rar:Visual C++ 上位机到底在解决什么问题

在网上搜「LEDShow.rar 单片机开发 Visual C++」的人,多半是刚点亮 8×8 或 16×16 点阵屏的新手,下一步要给 LED 加花样:箭头滚动、爱心闪烁、跑马灯。花样写死在单片机里不是不行,但每改一版就要重新烧录,调试周期被拉得很长。

LEDShow 这类 Visual C++ 6.0 时代的串口上位机,把问题变成在电脑上画一画、点一点:上位机把花样帧转成串口字节流,下位机收到帧就去点亮点阵,全程不碰烧录器。它适合刚学会定时器和串口中断的开发者,也适合想把手头点阵屏做成桌面摆件的爱好者。

真正的难点不在点灯,而在两边协议怎么对齐、Visual C++ 程序换到新系统后跑不跑得起来。下文按我拿到这种下载包的顺序讲:拆模块、对协议、处理闪退和串口打不开的坑,最后调速度亮度和波特率。

2. 拆 LEDShow 的三个模块:画点阵、帧缓冲、串口线程

解压 rar 后,典型结构是一个 VC++ 6.0 对话框工程,类名通常是CLedShowDlg。先别急着点编译,把程序按职责拆开看。这类上位机无论界面做成什么样子,核心都跑不出三个模块:画点阵的界面线程、存花样的帧缓冲、发串口的通信线程。搞清楚它们之间的数据流,后面所有调试都好办。

2.1 三个角色的分工与数据流

模块职责常见实现方式
界面线程显示当前帧、编辑花样OnPaint+ GDI 矩形绘制
帧缓冲保存多帧 LED 位图BYTE m_frames[N][8]
串口线程按节拍把当前帧发到下位机CreateFile+WriteFile

数据流是单向的:界面编辑成果写进帧缓冲,串口线程定时从缓冲取一帧发出。很多人第一次改这种工程就翻车,原因是不管三七二十一在OnPaint里直接调WriteFile,结果窗口刷新一次串口就发一次,展示效果乱成一团。正确做法是把「显示」和「发送」彻底分开。

帧缓冲里最常见的数据结构是行位图,每帧 8 字节,一字节对应一行 LED。下面这个结构在 LEDShow 类工程里很典型:

typedef struct { BYTE rows[8]; // 每一字节对应点阵一行,bit7 是最左边那盏灯 DWORD dwell_ms; // 这一帧停留多少毫秒,0 表示用全局速度 } LedFrame;

帧缓冲用一个固定数组LedFrame m_frames[MAX_FRAMES]就够用。VC++ 6.0 的内存管理不适合做动态容器,std::vector在 1998 年的编译器上兼容性也一般,固定数组配合帧计数器是最省事的选择。这个结构的位序约定要写进注释里:bit7 对最左灯、bit0 对最右灯。下位机如果按相反方向移位,所有花样都会左右镜像,这是最隐蔽的坑。

2.2 点阵显示区:CStatic 画 8×8 灯矩阵的方法与位序约定

界面部分要做的事很单纯:把帧缓冲里的一帧画到窗口上。常见做法是用一个CStatic控件当画布,在它的OnPaint里按格子填色。核心绘制函数写成这样:

void CLedShowDlg::DrawMatrix(CDC* pDC, int x0, int y0, const BYTE frame[8]) { const int cell = 24; // 每格 24 像素,8×8 正好 192 宽 CBrush brOn(RGB(0, 255, 0)); // 亮灯颜色 CBrush brOff(RGB(24, 24, 24)); // 灭灯底色 for (int row = 0; row < 8; row++) { for (int col = 0; col < 8; col++) { CRect rc(x0 + col * cell, y0 + row * cell, x0 + (col + 1) * cell, y0 + (row + 1) * cell); // 高位对应左灯,所以用 0x80 >> col 逐位取 if (frame[row] & (0x80 >> col)) pDC->FillRect(&rc, &brOn); else pDC->FillRect(&rc, &brOff); } } }

这段代码的两个参数值得解释。cell决定显示区大小,24 像素一格在 800×600 的对话框里比较协调;如果做 16×16 点阵,记得把格数从 8 扩展成 16,坐标循环也要跟着改。位序0x80 >> col是从左往右扫,这必须和下位机扫描方向一致,否则画出来是镜像,后面第 3 章会看到下位机怎么配合。

窗口的OnPaint里不要直接读帧缓冲,因为串口线程可能正在写。标准做法是加一个临界区,取快照再绘制:

void CLedShowDlg::OnPaint() { CPaintDC dc(this); BYTE snapshot[8]; EnterCriticalSection(&m_cs); memcpy(snapshot, m_frame, sizeof(snapshot)); LeaveCriticalSection(&m_cs); DrawMatrix(&dc, 40, 40, snapshot); }

m_cs是对话框类的成员CRITICAL_SECTION,在OnInitDialog里初始化。取快照比占用临界区画一整屏要合理,因为画 64 个矩形耗时远高于 memcpy,锁太久串口线程会被拖住。这个写法在下载包里不一定有,但加上它之后,界面刷新和串口发送才互不打架。

2.3 串口选择:CreateFile + DCB 的写法,以及 MSComm 的取舍

老工程里常看到 MSComm ActiveX 控件,拖进对话框就能用,但 exe 分发时要带mscomm32.ocx还得注册,新系统上注册失败率很高,表现为程序能开、串口控件没反应。我更建议直接用 Windows API,代码不多,也不依赖 ocx。打开串口和设置参数是固定的套路:

char portName[32] = "\\\\.\\COM3"; // 超过 COM9 必须加 \\.\ 前缀 HANDLE hCom = CreateFileA(portName, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL); if (hCom == INVALID_HANDLE_VALUE) { AfxMessageBox("串口打开失败,检查 COM 号是否被占用"); return; } DCB dcb; GetCommState(hCom, &dcb); dcb.BaudRate = 9600; // 与下位机保持一致,后面第 5 章细说 dcb.ByteSize = 8; dcb.Parity = NOPARITY; dcb.StopBits = ONESTOPBIT; SetCommState(hCom, &dcb); COMMTIMEOUTS to = {0}; to.ReadIntervalTimeout = 50; to.ReadTotalTimeoutConstant = 50; SetCommTimeouts(hCom, &to);

\\\\.\\COM3这个写法是给 C 字符串转义后的结果,实际传给系统的是\\.\COM3。如果不加前缀,COM10 以上的端口在 Windows 里会打开失败,这是新手必踩的一脚。波特率、数据位、停止位要和单片机端完全一致,任何一项不匹配,下位机收到的就是乱码。COMMTIMEOUTS设置 50ms 超时,避免ReadFile在串口没数据时把线程永远卡住。

串口线程的逻辑很简单:一个工作线程循环里Sleep帧间隔,然后从帧缓冲取当前帧,调WriteFile发出去。界面线程改花样时只改动帧缓冲,线程自然会把新花样发下去。这样的结构即使串口写阻塞,界面也不会假死。

3. 与单片机对表:LEDShow 串口帧协议与最小 C 工程复现

上位机画得再漂亮,协议和下位机对不上就是白干。很多下载包里带一份协议说明,但通常写得含糊。我一般把协议固定成下面这个 13 字节帧格式,收发两端都按同一张表实现。

3.1 帧协议表与发送端实现

偏移字段长度取值说明
0帧头 110xAA
1帧头 210x55
2命令字10x01 表示写整帧
3数据长度1固定 0x08
4-11帧数据88 行位图,bit7 在最左
12校验字节1前面 12 字节异或

帧头选AA 55而不是00 FF,是为了避开串口空闲电平。UART 空闲时是高电平,FF容易被当成空闲,AA有均匀的跳变沿,单片机端识别更可靠。校验用单字节异或,室内短距离足够用;如果你的环境有电机或者继电器干扰,建议升级成 CRC8,实现也就多十几行。

上位机发送端按这个协议打包:

void SendFrame(HANDLE hCom, const BYTE frame[8]) { BYTE pkt[13]; pkt[0] = 0xAA; pkt[1] = 0x55; pkt[2] = 0x01; // 命令:写整帧 pkt[3] = 0x08; // 数据长度 BYTE sum = 0xAA ^ 0x55 ^ 0x01 ^ 0x08; for (int i = 0; i < 8; i++) { pkt[4 + i] = frame[i]; sum ^= frame[i]; } pkt[12] = sum; DWORD written = 0; WriteFile(hCom, pkt, 13, &written, NULL); }

打包顺序和协议表完全对应。校验字节由前 12 字节异或得到,注意帧头也参与异或,不要漏掉。如果后期在协议里加命令,比如「清屏」「亮度调节」,只需改pkt[2]并扩充数据区,下位机状态机里加两个分支即可,别乱动帧头定义,否则老设备全部不兼容。

3.2 下位机接收端状态机:从 SBUF 字节流里切出整帧

单片机端不能用「先收完再解析」的思路,因为 51 的 SBUF 只有一个字节缓冲,不来中断就丢数据。正确做法是逐字节状态机:每收到一个字节就推进状态,凑满 13 字节后校验。以下是最小可用的 Keil C51 写法:

#define FRAME_HEAD1 0xAA #define FRAME_HEAD2 0x55 #define CMD_WRITE 0x01 static uint8_t rx_state = 0; static uint8_t rx_len = 0; static uint8_t rx_sum = 0; static uint8_t rx_data[8]; void UART_ISR(void) __interrupt 4 { uint8_t b; if (!RI) return; RI = 0; b = SBUF; switch (rx_state) { case 0: if (b == FRAME_HEAD1) rx_state = 1; break; case 1: if (b == FRAME_HEAD2) { rx_state = 2; rx_len = 0; rx_sum = FRAME_HEAD1 ^ FRAME_HEAD2; } else { rx_state = 0; } break; case 2: rx_sum ^= b; if (b == CMD_WRITE) rx_state = 3; else rx_state = 0; break; case 3: rx_sum ^= b; if (b == 8) rx_state = 4; else rx_state = 0; break; case 4: rx_sum ^= b; rx_data[rx_len++] = b; if (rx_len >= 8) rx_state = 5; break; case 5: if (b == rx_sum) { update_matrix(rx_data); // 整帧有效,拿去刷点阵 } rx_state = 0; rx_len = 0; rx_sum = 0; break; } }

状态机的关键是第 2、3 两个 case 把命令字和长度也异或进rx_sum,到 case 5 收到的校验字节不再异或。如果校验不通过,直接回到 state 0 重新找帧头,不要刷新点阵。这样的好处是即使中间丢一两个字节,下一帧的AA 55还能让状态机重新对齐。update_matrix里做的事就是把rx_data按行位移到 P 口驱动点阵,这和第 2 章上位机的位序约定必须一一对应。

注意:这里__interrupt 4是 Keil C51 的串口中断写法,用 STM32 的读者改成 HAL_UART_RxCpltCallback 即可,状态机逻辑完全不用变。

3.3 最小联调的三个步骤:回环、单字节、整帧

不要直接把上位机和单片机接上就开跑,那样出了问题都不知道是哪一端的问题。我习惯按下面三步走:

  1. 串口回环自测。把 USB 转串口的 TX 和 RX 短接,用任意串口助手发AA 55 01 08加 8 个数据字节加校验,看能不能原样收回。这一步验证波特率、线序和驱动是否正常。
  2. 上位机单独发一字节。把上位机和单片机接上,程序里先固定发0xAA,单片机收到后在 LED 上显示一个标记。这一步验证连线方向。
  3. 整帧循环发送。上位机按 200ms 间隔循环发整帧,单片机每收到一帧就翻转一次 LED 亮灭状态。如果 LED 变化节奏和上位机一致,联调就算通了。

第二步常被人跳过,结果整帧不通时,没人说得清是单片机没收到,还是收到了但解析错了。把串口这类黑匣子拆成最小可验证的步骤,比对着逻辑猜效率高得多。

4. 闪退、缺运行库、串口打不开:Visual C++ 环境避坑记录

这类从网上下载的老工程,九成的问题不在代码逻辑,而在运行环境。下载包是 Visual C++ 6.0 编的,系统却是 Win10/Win11,运行库、位数、控件注册全是坑。这些坑表面上看起来像玄学,实际每条都有明确原因和固定解法。

4.1 现象:双击 EXE 闪退,任务管理器里一闪而过

现象是双击 exe 没窗口,任务管理器里进程出现一下就消失;有时还弹窗说缺少mfc42.dll或msvcp60.dll。原因是 VC++ 6.0 默认动态链接 MFC 和 CRT,系统里没有这些旧 DLL,程序起不来。

解决方法是把工程改成静态链接:在 VC6 里打开 Project Settings,General 标签页,把 Microsoft Foundation Classes 从Use MFC in a Shared DLL改成Use MFC in a Static Library,重新编译 Release。这样mfc42.dll会被直接编进 exe,新系统上无需额外装运行库。如果下载包没有源码只有 exe,可以把对应 DLL 放到 exe 同目录,但这不是长久之计,改静态链接一劳永逸。

4.2 现象:提示缺少 VC++ 2010 x86 Redistributable

有些下载包是 VS2010 编译的,运行时弹窗要求装 Microsoft Visual C++ 2010 x86 Redistributable。很多人去官网搜 visual c++ redistributable,装了 2015-2022 x64 版,问题依旧,因为 32 位程序需要的是 x86 版运行库,x64 版不起作用。

解决方法是区分 x86/x64:到微软官网下载vc_redist.x86.exe并安装,或者用 VS2010 打开工程,把配置属性里 C/C++ 的代码生成运行库改成/MT,即静态链接。如果同时混用了不同年份的 Visual C++ 运行库,最省事的方案是把 2010 到 2022 的 x86 版都装上,程序优先匹配自己需要的年份,装齐后这类弹窗基本消失。

4.3 现象:串口号空白或 CreateFile 打不开

界面里的串口下拉列表是空的,或者代码里CreateFile返回INVALID_HANDLE_VALUE。原因有两类:驱动没识别设备,或者程序用了老式COM1写法导致高口号端口打不开。

解决:先在设备管理器里看端口号,CH340/CP2102 等常见芯片装好驱动后会在「端口」下面显示具体 COM 号。程序侧把端口名改成\\\\.\\COMx,尤其是 COM10 以上。还有一种隐蔽情况:串口助手占用了端口,关掉所有占用程序再试。每改一次驱动或占用情况,都要重新枚举端口列表,VC6 程序里的枚举代码如果只扫 COM1-COM4,会把 COM5 以上的设备漏掉。

4.4 现象:点阵闪烁残影,扫描顺序写反

整帧能收到,但 LED 有残影,或者点亮顺序是乱的。原因很典型:扫描程序先开行选再送列数据,上一行残留的数据会在新行亮一下,形成拖影。解决方法是把扫描顺序改成「先关行、再送数据、最后开行」:

void scan_next_row(void) { P0 = 0x00; // 先关所有行,消隐 P2 = rx_data[scan_row]; // 再送当前行的灯数据 P0 = 1 << scan_row; // 最后打开当前行 }

单片机端P0选行、P2送数据的接法很常见。关键在P0 = 0x00那一步,等于把所有行拉低,让 LED 全灭一瞬间,再更新数据。如果顺序反了,帧切换时会有肉眼可见的残影,频率越高越明显。残影问题和上位机无关,纯下位机时序问题,但很多人会误判成刷新率不够。

4.5 现象:GDI 对象数持续增长,界面越跑越卡

程序跑一段时间后,窗口拖动卡顿,任务管理器里 GDI 对象数只增不减。原因是绘制代码里new了画笔或画刷却没有DeleteObject,句柄泄漏。Windows 对 GDI 对象有数量上限,泄漏到一定量界面就失去响应。

解决方法是改用栈对象,或者显式释放。第 2 章代码里CBrush brOn(RGB(...))是栈对象,函数结束自动析构,不会泄漏;但如果写成CBrush* br = new CBrush(...),用完必须br->DeleteObject()再delete br。排查时用任务管理器看 GDI 对象数,每刷一帧涨几个,基本就是哪个绘制函数在泄漏。这类问题在界面有动画时会非常快地暴露,静态程序反而看不出。

5. 必调的三组参数:花样速度、LED 亮度、串口波特率怎么匹配

模块跑通、坑也填完之后,剩下就是手感问题:花样换得太快像抽风,太慢像死机;亮度要么刺眼要么看不清;波特率不合适时偶尔花屏。这三组参数是相互牵扯的,下面按调试顺序逐一说明。

5.1 花样速度:上位机帧间隔与下位机 tick 计数怎么换算

上位机的帧间隔单位是毫秒,下位机用定时器产生时基。常用做法是 1ms tick 计数器,代码里这样组织:

#define TIMER_TICK_MS 1 static volatile uint16_t tick_count = 0; static volatile uint16_t frame_delay = 200; // 默认 200ms 换一帧 void Timer0_ISR(void) __interrupt 1 { // 12MHz 晶振、12T 模式,1ms 定时重装值 TH0 = 0xFC; TL0 = 0x18; if (++tick_count >= frame_delay) { tick_count = 0; apply_next_frame(); // 从帧表取下一帧刷新 } }

12MHz 下0xFC18对应 1000 个机器周期,正好 1ms。如果你用的是 11.0592MHz 晶振,初值要重算成 0xFC66,照抄会不准。frame_delay建议做成全局变量,上位机通过扩展命令字下发改值,这样不用重新烧录就能调速度。可调范围放在 50ms 到 2000ms 之间,低于 50ms 人眼已经看不出花样,只看到一片快闪;高于 2000ms 就接近静态图了。

5.2 亮度:PWM 占空比在逐行扫描里的插入位置

LED 点阵亮度不是靠加大电流,而是靠 PWM 占空比控制每行点亮时间。逐行扫描时,每一行在一个扫描周期里被点亮一次,亮的时间占总行周期的比例就是亮度。占空比越大越亮,但行周期是固定的,占空比过大会导致扫描频率下降。

以 8×8 点阵为例,每行分配约 200μs 的行周期,一个完整扫描周期是 1.6ms,对应 625Hz 刷新率,肉眼看不出闪烁。如果想让亮度降到 70%,就在行选打开后延时约 140μs,然后提前关行。这个延时放在消隐之后、下一次送数之前,不影响残影表现。16×16 点阵行数翻倍,每行时间要压缩到 100μs 以内,否则整体扫描频率会掉到 300Hz 以下,开始出现肉眼闪烁。

5.3 刷新率与波特率匹配:一张 9600 到 115200 的经验表

上位机发一帧要 13 字节,波特率决定了最短发送耗时。如果帧间隔短于发送耗时,数据会在驱动缓冲区里积压,效果是下位机收到的帧节奏不稳,动画明显「一顿一顿」。

波特率一帧 13 字节最短耗时建议最小帧间隔
960013.5ms30ms
192006.8ms15ms
1152001.1ms5ms

9600 波特率最常用,但不建议把帧间隔压到 20ms 以下,留出至少两倍的余量。115200 波特率下间隔可以压到 5ms,此时花样能跑到人眼极限。下位机用状态机逐字节接收,理论上不需要接收缓冲,只要保证每两个字节间隔内中断能取走当前字节,115200 下 1 字节约 87μs,对 12MHz 的 51 来说绰绰有余。三组参数里,波特率决定下限,帧间隔决定上限,占空比只改变亮度不改节奏,调的时候按这个顺序来就不会乱。

6. 把 LEDShow 升级成帧序列编辑器:导出一份可直接烧录的头文件

下载包里常见的 LEDShow 只能手工填 8 个字节,看一帧存一帧,做动画效率太低。值得花一小时改造成帧序列编辑器:上位机里维护一组帧,支持填帧、复制帧、删除帧和预览循环,最后一键导出成 C 头文件,直接 include 进单片机工程。这一步能让「电脑编辑花样」变成真正闭环的工具。

数据结构沿用第 2 章的LedFrame,再加一个总数,就能支持基本编辑操作。每改一帧,预览窗口立即刷新,点击「导出」时生成一个二维数组:

void ExportFrames(const LedFrame frames[], int count) { FILE* fp = fopen("frames.h", "w"); if (!fp) return; fprintf(fp, "// generated by LEDShow editor\n"); fprintf(fp, "const unsigned char pattern[%d][8] = {\n", count); for (int i = 0; i < count; i++) { fprintf(fp, " {0x%02X, 0x%02X, 0x%02X, 0x%02X, 0x%02X, 0x%02X, 0x%02X, 0x%02X},\n", frames[i].rows[0], frames[i].rows[1], frames[i].rows[2], frames[i].rows[3], frames[i].rows[4], frames[i].rows[5], frames[i].rows[6], frames[i].rows[7]); } fprintf(fp, "};\n"); fclose(fp); }

导出的frames.h在单片机工程里直接#include,配合一个帧索引就能循环显示。注意帧数较多时数组会占用可观的内存,51 单片机的 RAM 有限,建议加code关键字放到程序存储区;STM32 用户可以放到常量区,整个编辑过程完全不占 RAM。如果下位机想去掉上位机独立运行,把这个头文件烧进去就是最轻量的离线模式。

我做第一个 8×8 点阵工程时,没有约定位序,LEDShow 画出的箭头到了板子上变成镜像,来回改上位机代码折腾了一晚上。后来学乖了,协议和位序注释永远写在代码头部,收发两端各留一行说明,联调时一次只改一端。这个方向值得投入:一次搭建,以后所有点阵花样都变成「画图、导出、烧录」三分钟搞定。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进