简介:面向 STM32 嵌入式学习者与电子设计实践者的一套可编译工程,围绕在 STM32 平台上用 C++ 驱动 GM65 二维码扫描模块、由识别结果触发 LED 点灯这一完整链路展开,适合已具备 C 语言基础、希望过渡到 C++ 嵌入式写法并熟悉串口通信与外设驱动的开发者参考。压缩包共 135 个文件、约 1.47MB,以 62 个头文件与 28 个 C 源文件为主,涵盖 STM32F10x 标准外设库的定时器、串口、RCC、ADC 等模块,另有 2 个 cpp 文件承载 C++ 封装逻辑,并附 Keil 工程 uvprojx/uvoptx、axf、hex、map、sct 与批处理脚本,可直接编译下载验证。已有 437 人学习。读者可从中获得 C++ 与标准库混合编程的工程组织方式、GM65 串口数据读取与解析思路,以及从扫码识别到点灯动作的联动实现,便于快速搭建同类扫码控制项目。
1. 扫码枪在响,灯不亮:这套组合到底卡在哪一环
上周帮人看一块板子:STM32 主控、GM65 扫码模块、一颗贴片 LED,工程用 C++ 写,现象是串口助手能打出二维码里的字符串,板载灯纹丝不动。翻代码发现两处:一是工程把main.c直接改成了.cpp,重新用 CubeMX 生成一次就全被覆盖;二是模块靠红外感应自动出光,主循环里根本没发过任何一条启动解码命令,状态机一直停在“等结果”。这类问题的共同点是——单看每一段都成立,拼在一起就断链。这份工程要解决的就是把断链接上:让 STM32 用 C++ 把一个串口扫码模块的输出、一条命令应答、一次点灯动作串成可观测的状态机;读者对象是刚上手 stm32 c++ 混合工程、手里有现成模块但一直调不通的开发者,以及需要把扫码枪接进产测工装的工程师。灯不亮只是表象,真正要立住的是“收包—解析—判定—执行”四段闭环。
2. C++ 改造清单:从 C 工程切过去要动哪五处
把 C 工程改成 C++,最容易翻车的不是语法,是链接和生成流程。我一般不会去改 CubeMX 生成的main.c,因为下一次点“生成代码”就白改了。稳妥做法是留一个 C 入口文件,把所有业务逻辑放在 C++ 侧,中间用一层extern "C"汇合。
2.1 保留 main.c 做外壳,业务入口走 extern "C"
CubeMX 只认.c后缀的main.c,直接重命名成.cpp,再生成时会同时冒出两个文件,一个进编译一个报重复定义。所以我固定这么分层:main.c只做 HAL 初始化与死循环调用,app.cpp承接一切。
/* main.c —— 保持 C 后缀,CubeMX 重新生成时不会被覆盖 */ #include "hal_bridge.h" int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_USART1_UART_Init(); App_Init(); /* C++ 侧入口:对象构造、串口启动、状态机复位 */ while (1) { App_Loop(); /* 非阻塞轮询,所有延时交给 tick 计数 */ } }App_Init和App_Loop声明在hal_bridge.h里,用extern "C"包住,这样 C 编译器看到的是普通 C 函数名,C++ 侧实现时不会产生名字修饰:
/* hal_bridge.h */ #ifndef HAL_BRIDGE_H #define HAL_BRIDGE_H #ifdef __cplusplus extern "C" { #endif #include "main.h" #include "stm32f1xx_hal.h" /* 按实际系列换成 f4xx / g0xx 等 */ void App_Init(void); void App_Loop(void); #ifdef __cplusplus } #endif #endif逻辑说明:头文件里包含 HAL 头的那几行也必须落在extern "C"内部,否则HAL_UART_Transmit这类函数在 C++ 里会被当成 C++ 符号去找,链接阶段直接报undefined symbol。参数上唯一要注意的是芯片系列头文件名,换系列时只改这一行,别的地方不用动。
2.2 编译选项:关掉异常与 RTTI,别把 Flash 吃光
Keil MDK 用 AC6(armclang)时,.cpp会自动按 C++ 编译,但默认打开异常和 RTTI,一个空工程也能多出十几 KB。我一般在Options for Target → C/C++ → Misc Controls里补三个开关:
-fno-exceptions -fno-rtti -fno-threadsafe-statics-fno-exceptions关掉异常表,裸机上没人 catch,留着只占空间;-fno-rtti关掉运行时类型信息,用不上多态识别;-fno-threadsafe-statics去掉函数内静态变量的加锁保护,单核裸机不需要。三项加起来通常能省 8~15 KB,对小容量型号是实打实的余量。
配套还要确认两点:启动文件里__main会调用 C 库的初始化流程,全局对象的构造函数由__libc_init_array遍历.init_array段执行,Keil 的启动文件默认已经包含,不用手写;但全局对象的构造函数里绝对不能调用HAL_Delay或printf,因为此时SystemClock_Config还没跑、SysTick 没配、串口没初始化,HAL_Delay会卡在uwTick永远不增的死等里,表现为上电就停在Reset_Handler之后一动不动。我的习惯是构造函数只做赋初值,真正的初始化全放App_Init。
2.3 三个类的职责边界:谁碰硬件,谁管协议,谁定策略
C++ 在这个场景里的价值不是语法糖,是把“换一个扫码模块只改一个文件”这件事变便宜。我按三层切:
| 类 | 职责 | 不该做的事 |
|---|---|---|
ScannerPort | 持有 UART 句柄、DMA 缓冲、环形队列,只管字节进出 | 不解析内容、不判断成败 |
Gm65Link | 组命令帧、发命令、匹配应答、维护重试计数 | 不碰 LED、不做业务判定 |
ScannerService | 状态机、去重、白名单、驱动 LED | 不直接读写寄存器 |
ScannerPort用静态实例,不用new。裸机上开堆是给自己找事,碎片化之后跑几小时崩一次,排查成本极高。写成static ScannerPort& instance() { static ScannerPort s; return s; }这种 Meyers 单例,构造时机确定,也不会重复构造。三个类之间用引用传递,ScannerService持有另外两个的引用,构造顺序在App_Init里显式写死。
这么切的直接好处是:将来模块从 GM65 换成别家的串口扫码头,只需重写Gm65Link的组帧和应答匹配,ScannerPort和ScannerService一行不动。反过来如果全塞在一个main.cpp里,换模块等于重写。
3. GM65 的串口行为:两种报文混在一条线上
很多人第一次接这个模块会发现“串口助手里有数据,但程序里解不出来”,原因是没有区分它往串口上吐的两类东西。GM65 在正常工作模式下,识读到码之后直接把码内容以 ASCII 明文发出来,可能带\r\n后缀;而当你给它发命令时,它回的是以0x02开头的短应答帧。同一个 RX 引脚上,两种格式交替出现,解析器必须自己判断。
3.1 明文结果与 0x02 应答帧的区分依据
出厂默认下,扫码结果就是裸文本,比如二维码里存的是LED:ON,串口上收到的就是4C 45 44 3A 4F 4E,后面可能跟0D 0A,也可能什么都不跟,取决于模块的“后缀”设置。命令应答则是二进制短帧,首字节固定0x02。
所以解析的第一条规则很朴素:首字节是0x02就按应答走,是可打印 ASCII(0x20~0x7E)就按结果走,其余控制字符直接丢弃。这条规则能覆盖九成场景,剩下的边缘情况——比如二维码内容本身以0x02开头——在实际业务里几乎不会出现,因为二维码里存的是给人看的字符串。
关于应答帧的确切长度和校验位置,不同固件版本不完全一致,我不建议照抄网上某一段硬编码。正确做法是先用 USB 转 TTL 把模块接到电脑,串口助手设 9600、8N1,发一次启动解码命令,把返回的字节流截图存下来,再按实抓结果固化长度常量。这是血泪经验:曾经按某个帖子的“4 字节应答”写死,结果换了一批模块,应答是 6 字节,多出来的两字节被当成扫码内容,灯乱闪。
3.2 DMA 加空闲中断:不定长数据的标准接法
扫码结果长度不定,用HAL_UART_Receive一问一答会漏数据,用逐字节中断又太吵。常见做法是 DMA 循环接收加 IDLE 空闲中断,一帧结束时硬件产生中断,软件把这一段的长度取走。
/* ScannerPort 内部:启动接收 */ void ScannerPort::start() { /* DMA 配置为 Circular 模式,接收长度 = 缓冲区大小 */ HAL_UARTEx_ReceiveToIdle_DMA(&huart_, dma_buf_, sizeof(dma_buf_)); /* 关掉半传输中断,否则一帧会被拆成两次回调 */ __HAL_DMA_DISABLE_IT(huart_.hdmarx, DMA_IT_HT); } /* 空闲事件回调,HAL 会在 IDLE 或缓冲区满时进来 */ extern "C" void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef* huart, uint16_t size) { if (huart == ScannerPort::instance().handle()) { ScannerPort::instance().on_rx_event(size); /* 把 size 字节搬进环形队列 */ } }回调必须放在extern "C"里,因为 HAL 的弱定义是 C 链接的,不加就会出现“回调函数写了但永远不进”的经典现象——编译器把 C++ 版本当成另一个函数,链接器保留弱定义,你的实现变成死代码。
参数说明:dma_buf_我给到 128 字节足够,二维码内容一般不超过 100 字节;HAL_UARTEx_ReceiveToIdle_DMA在非循环模式下 IDLE 触发后会停止 DMA,需要重新调用,配成 Circular 就能一直收。on_rx_event里不做解析,只做搬运,把dma_buf_的前size字节压进环形队列后立刻返回,中断里停留时间控制在几微秒。
环形队列用单生产者单消费者模型,中断里写head,主循环里读tail,两边各自volatile:
class RxRingBuffer { public: void push_from_isr(const uint8_t* data, size_t len) { for (size_t i = 0; i < len; ++i) { size_t next = (head_ + 1) % N; if (next == tail_) { overflow_ = true; return; } /* 满了丢新数据 */ buf_[head_] = data[i]; head_ = next; } } bool pop(uint8_t& b) { if (tail_ == head_) return false; b = buf_[tail_]; tail_ = (tail_ + 1) % N; return true; } private: static constexpr size_t N = 512; volatile uint8_t buf_[N]; volatile size_t head_ = 0; volatile size_t tail_ = 0; volatile bool overflow_ = false; };注意缓冲区满时的策略:丢新数据而不是覆盖旧数据。扫码结果是短帧,丢一条新帧比把正在解析的半截帧搞乱要好排查得多。overflow_标志留给主循环打日志用,真出现持续溢出,说明波特率配错或者主循环被什么东西堵住了。
3.3 逐字节状态机:把两种报文分流成两种事件
队列里出来的是字节流,解析器是个三状态的小机器,喂一个字节返回一个事件。写成feed()而不是parse(buf, len),好处是不用假设一帧一定完整落在一个缓冲里。
enum class RxEvent { NONE, BARCODE, ACK }; RxEvent FrameParser::feed(uint8_t b) { switch (state_) { case State::IDLE: if (b == 0x02) { /* 应答帧起始 */ state_ = State::ACK; idx_ = 0; ack_[idx_++] = b; } else if (b >= 0x20 && b < 0x7F) { /* 可打印字符,扫码内容 */ state_ = State::TEXT; idx_ = 0; text_[idx_++] = b; } break; case State::TEXT: if (b == '\r' || b == '\n') { /* 常见后缀,作为帧尾 */ text_[idx_] = '\0'; state_ = State::IDLE; return RxEvent::BARCODE; } if (idx_ < sizeof(text_) - 1) text_[idx_++] = b; else state_ = State::IDLE; /* 超长直接丢弃,防越界 */ break; case State::ACK: ack_[idx_++] = b; if (idx_ >= ACK_LEN) { /* ACK_LEN 按实抓结果改成常量 */ state_ = State::IDLE; return RxEvent::ACK; } break; } return RxEvent::NONE; }逻辑说明:TEXT状态依赖后缀字符收尾。如果你的模块被设置成“无后缀”,这条路径永远不返回BARCODE,表现为队列里数据一直在但业务没反应。解决办法有两个——在模块配置里加回\r\n后缀,或者在状态机里加一个“空闲超时”兜底:连续 20 ms 没有新字节进来就把当前text_当成完整帧。我更倾向后者,因为后缀是可以被上位机工具改掉的,而超时兜底不依赖模块配置。
ACK_LEN不要写魔法数字,定义成具名常量并在注释里写明“实测值”,换固件时只改一处。
4. 命令、应答与点灯闭环:让状态机决定何时亮
前面两章解决的是“数据能进来、能分开”,这一章解决“什么时候发命令、收到什么算成功、成功之后灯怎么亮”。没有状态机的写法通常是:上电发一次启动命令,主循环里等结果,收到就点灯。这套逻辑在实验室能跑,接到工装上就会暴露三个问题——命令丢了没人重发、同一条码连扫五次灯闪五次、模块死机之后整个流程静默卡死。
4.1 命令帧组装与校验位
启动解码、停止解码、切换触发模式都是靠短命令帧下发的。帧结构大致是“帧头 + 长度 + 命令类型 + 参数 + 校验/帧尾”,我这里给出的是一份可运行的样板,具体字节以模块手册和你实抓的返回为准:
/* 组一帧命令,返回帧长度 */ size_t Gm65Link::build_cmd(uint8_t* out, uint8_t cmd, uint8_t param) { out[0] = 0x7E; /* 帧头 */ out[1] = 0x00; /* 长度高字节 */ out[2] = 0x08; /* 长度低字节,按手册核对 */ out[3] = 0x01; /* 命令类型 */ out[4] = 0x00; out[5] = cmd; /* 0x01 启动解码,0x00 停止解码 */ out[6] = param; out[7] = 0xAB; /* 校验/帧尾,若手册要求异或校验则替换 */ out[8] = 0xCD; return 9; } bool Gm65Link::send_start_decode() { uint8_t frame[9]; size_t n = build_cmd(frame, 0x01, 0x01); return HAL_UART_Transmit(&huart_, frame, n, 20) == HAL_OK; }参数说明:超时给 20 ms,9600 波特率下 9 字节约 9.4 ms,留一倍余量。如果手册要求的是异或校验,把out[7]换成前面字节逐字节异或的结果,out[8]保留固定帧尾。这一步不要凭记忆写,拿手册对一遍,校验错的表现是模块完全没反应,看起来像“命令没发出去”,实际是发出去了被丢弃。
还有一点容易忽略:模块正在解码出光的时候,串口命令可能不被响应。所以我在状态机里把“停止解码”放在“启动解码”之前,先确保它处于空闲态再下发新命令,成功率明显提高。
4.2 发完命令必须等应答,不能靠延时猜
“发完延时 100 ms 再继续”是新手最常见的写法,问题在于 100 ms 是猜的,模块忙的时候不够,闲的时候白等。正确做法是发完进入WAIT_ACK,收到RxEvent::ACK才推进,超时才重试。
void ScannerService::tick(uint32_t now_ms) { switch (state_) { case State::BOOT_WAIT: if (now_ms - t0_ >= 500) { /* 上电等模块自检完成 */ link_.send_start_decode(); state_ = State::WAIT_ACK; t0_ = now_ms; retry_ = 0; } break; case State::WAIT_ACK: if (ack_ok_) { /* 由 ACK 事件置位 */ ack_ok_ = false; led_.blink(2, 80); /* 两声短闪,表示链路就绪 */ state_ = State::SCANNING; t0_ = now_ms; } else if (now_ms - t0_ > 300) { /* 300 ms 没应答就重发 */ if (++retry_ >= 3) { led_.error_pattern(); state_ = State::FAULT; } else { link_.send_start_decode(); t0_ = now_ms; } } break; /* SCANNING / COOLDOWN 见 4.3 */ } }逻辑说明:BOOT_WAIT那 500 ms 是给模块上电自检留的窗口,太短会把命令发进“模块还没醒”的黑洞里。WAIT_ACK的重试上限设 3 次,超过就进FAULT并用灯效报错——工装上没人盯串口,靠灯说话最直接。所有时间判断都用HAL_GetTick()的差值,不用绝对时间,避免 49 天回绕时出玄学问题。
4.3 去重、白名单与灯效分层
扫码模块有个特性:同一条码在视野里,它可能连续上传多次。不去重的话,灯会闪成一串。去重逻辑不能只比对字符串,还要带时间窗:
bool ScannerService::accept(const char* code, uint32_t now_ms) { if (strncmp(code, "LED:ON", 6) != 0) return false; /* 白名单:只认这一条 */ uint32_t h = fnv1a(code); if (h == last_hash_ && (now_ms - last_ms_) < 2000) return false; /* 2 s 内同码忽略 */ last_hash_ = h; last_ms_ = now_ms; return true; }白名单用前缀匹配,是为了防止随便一张二维码都能点灯,工装上这一点很重要——现场地上可能躺着别的码。时间窗 2 s 是经验值:人的操作节奏不会在 2 s 内重复扫同一条码,但模块的重复上报往往集中在 200 ms 内,2 s 足够挡掉。
灯效我分成三种语义,全部非阻塞实现,靠tick里比较时间戳翻转 GPIO:常亮 500 ms 表示扫码成功,双短闪表示链路就绪,1 Hz 慢闪表示故障等待复位。不要在主循环里写HAL_Delay(500)点灯,那 500 ms 里串口 DMA 还在收数据,队列填满就开始丢帧,表现就是“点了灯之后扫码就不灵了”。
5. 五个坑:从乱码到 HardFault 的排查路径
这一章是我在几块板子上反复踩过的记录,按“现象 → 原因 → 解决”写。排查顺序建议从最外层往里走:先确认电平与供电,再看波特率,再看编译链接,最后才怀疑代码逻辑。倒过来查,往往在软件里绕半天,实际是杜邦线没共地。
5.1 串口助手有数据,程序里一条都收不到
现象:模块用 USB 转 TTL 接电脑能正常出码,接到板子上,程序里的环形队列始终为空,示波器量 RX 有波形。
原因:多发于电平与共地。模块的 TTL 电平与主控不匹配时会处于临界状态,波形看着有,实际高低门限不对;另一种是两块板各自供电、地没连一起,共模电压把信号顶出识别范围。还有一种是 USART 的 RX 引脚被 CubeMX 配成了别的复用功能,或者 GPIO 模式不是上拉输入。
解决:先拿万用表量两块板的地是否导通,再用示波器看 RX 上的高电平是不是稳定在供电电压附近。模块供电建议单独给足,不要从主控的 3.3V LDO 上分——扫码出光瞬间电流会跳,LDO 掉压会导致模块复位。这一步确认完再动代码。
5.2 收到的全是乱码,或者只收到半条
现象:能进回调,但内容像乱码;或者LED:ON只收到LED,后面没了。
原因:波特率不匹配最常见。模块出厂可能是 9600,被人用配置码改成 115200 之后没记,程序里还按 9600 收,就会得到规律性乱码。只收到半条则多半是解析器把中间某个字节当成了帧尾,或者 DMA 缓冲太小、一帧被截断。
解决:先用串口助手在 9600 和 115200 两档各试一次,确认实际波特率;再看HAL_UARTEx_RxEventCallback里拿到的size,如果每次都是缓冲区的最大长度,说明没开 IDLE 中断或者 DMA 不是循环模式。size打印出来一看就清楚,比猜快得多。
5.3 编译过了,回调函数死活不进
现象:HAL_UART_RxCpltCallback或HAL_UARTEx_RxEventCallback写了实现,断点打上去从不命中,程序跑得很正常。
原因:C++ 的名字修饰。HAL 里这些回调是__weak的 C 函数,你在.cpp里定义同名函数,编译器会生成_Z25HAL_UARTEx_RxEventCallbackP18UART_HandleTypeDefh这样的符号,和 C 版本不是同一个名字,链接器保留弱定义,你的实现成了没人调用的死代码。
解决:所有 HAL 回调一律用extern "C"包裹,或者放进.c文件里再转调 C++ 单例。判断方法也简单,fromelf --text -s或者看 map 文件里符号名,带_Z前缀就是被修饰了。这个坑我踩过两次,第二次是因为新加的.cpp文件忘了加包裹。
5.4 上电就死在启动阶段,或者跑几秒进 HardFault
现象:烧录后程序不跑,调试器停的位置在启动文件附近;或者正常跑几秒后跳 HardFault。
原因:第一种多与全局对象构造有关——构造函数里调了HAL_Delay、printf或者访问了还没初始化的外设,此时时钟没配好,死等。第二种常见于 DMA 缓冲区定义在局部作用域,函数返回后栈被复用,DMA 还在往已经释放的内存写,写穿相邻变量。
解决:构造函数只赋初值,初始化统一挪到App_Init;DMA 缓冲区一律定义成static或者类的成员,生命周期和程序一致,不要用局部数组。HardFault 出现后用调试器看LR寄存器判断是主栈还是中断栈出错,再结合CFSR的IMPRECISERR位定位是不是 DMA 写越界,这个顺序比盲改代码快。
5.5 一次扫码灯闪好几次,或者命令发出去没反应
现象一:扫一次码,灯连着闪三四下。现象二:启动解码命令发出去,模块毫无反应。
原因一:模块重复上报同一内容,加上去重的时间窗设得太短或者根本没做。原因二:模块正在解码状态时不接收串口命令,或者命令帧校验位算错被静默丢弃;还有一种少见情况是发送时被更高优先级中断打断,字节间出现长间隔,模块把帧判为不完整。
解决:去重加上 2 s 时间窗并做哈希比对;发命令前先发停止解码,确保模块处于空闲;发送函数外面加临界区或者确认没有其他中断长时间占用串口。排查命令是否真的到模块,最直接的办法是把逻辑分析仪夹在 TX 线上,看波形有没有出去、字节对不对,比在代码里加十行打印有用。
6. 验证与进阶:把不确定性压到可测量的范围内
调这种“串口 + 状态机 + 灯”的组合,我现在的顺序固定成三步,不再直接从业务代码开始改。第一步,用 USB 转 TTL 把模块单独接到电脑,串口助手发启动解码命令,把应答字节逐个抄下来,确认波特率、应答长度、校验位置三个常量。第二步,把板子的 TX/RX 与电脑对接,写一个只有收发的空壳程序,确认主控这侧的发送和接收都对。第三步才把业务状态机接上去。跳过前两步直接调业务,出问题时分不清是模块、是线路、还是自己代码。
下面这张表是我常用的参数对照,换模块或换板子时照着核一遍,能省掉大半返工:
| 项目 | 常用值 | 验证方式 | 出错时的表现 |
|---|---|---|---|
| 波特率 | 9600 / 115200 | 串口助手两档各试 | 规律性乱码 |
| 数据格式 | 8 数据位、1 停止位、无校验 | 串口助手配置项 | 偶发丢字节 |
| 命令应答超时 | 300 ms | 抓包看实际回包间隔 | 误判失败、频繁重试 |
| 重复码抑制窗口 | 2 s | 连扫同码观察灯 | 灯连续闪 |
| DMA 接收缓冲 | 128 字节 | 打印每次size | 长内容被截断 |
如果手上没有逻辑分析仪,有个便宜办法:在固件里加一个“回环自测”模式,用宏开关控制,上电后把串口收到的原始字节以十六进制转发到调试串口,同时接受电脑下发的模拟扫码结果。这样在没有实物模块的情况下也能把解析状态机和业务逻辑跑通,等模块到货只验证物理层。自测模式我一般会保留在工程里,后面改去重策略、改灯效时序,都靠它回归。
进阶方向有两个。一是把ScannerPort的 DMA 收发改成双缓冲,主循环处理一块、DMA 填另一块,适合高速连续扫码;二是把状态机的每个状态迁移打上时间戳存进一个 32 条的小环形日志,故障时通过调试口一次性 dump 出来,比现场盯着灯猜快得多。日志环我用的是定长结构体数组,不动态分配,占 400 字节左右,小容量型号也放得下。
血泪教训是有一批工装板在现场偶发“扫三次亮一次”,折腾了两天才发现是模块供电和主控共用一路,出光瞬间电压跌到 2.9V,模块自己复位了,程序这边只看到数据断流。从那以后我每次做这类串口外设的板子,都强制先用示波器看一眼供电纹波再动代码。希望帮到你。
本文还有配套的精品资源,点击获取