STM32 C++扫码模块串口实战:DMA空闲中断+状态机点灯闭环
发布时间:2026/11/5 4:14:12 锦皓数字建站

简介面向 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 CCubeMX 只认.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 用 AC6armclang时.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去掉函数内静态变量的加锁保护单核裸机不需要。三项加起来通常能省 815 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就按应答走是可打印 ASCII0x200x7E就按结果走其余控制字符直接丢弃。这条规则能覆盖九成场景剩下的边缘情况——比如二维码内容本身以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两边各自volatileclass 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 ms9600 波特率下 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_InitDMA 缓冲区一律定义成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模块自己复位了程序这边只看到数据断流。从那以后我每次做这类串口外设的板子都强制先用示波器看一眼供电纹波再动代码。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。