简介面向STM32初学者与嵌入式进阶者的实操工程包围绕在STM32平台上使用C控制GM65二维码扫描模块并将扫码结果用于LED点灯联动帮助读者理解串口通信、模块初始化、C外设封装与GPIO控制之间的配合。压缩包共135个文件约1.47MB以62个.h头文件和28个.c源文件为主体另含2个.cpp文件、Keil工程配置.uvprojx/.uvoptx、编译产物.hex/.axf/.map及批处理与分散加载相关文件目录保留了完整工程结构便于直接打开、编译和对照修改。已有437人学习适合需要从单一外设驱动过渡到模块联动的读者参考也可作为课程设计、毕业设计前期验证的代码骨架。1. 扫码点灯只是表象STM32 上用 C 跑通 GM65 的真正门槛用 STM32 挂一颗 GM65 扫码模块扫到指定二维码就点亮一颗 LED硬件上三根线的事听上去像半天就能收工的活。真正上手才发现卡人的从来不是点灯——而 STM32 C 这套组合在裸机环境里的一堆隐形规矩C 的头文件被.cpp编译成 C 符号导致链接报错、中断服务函数被名字改编、全局对象在main之前就跑构造函数去摸还没使能的外设。这套可复用的最小工程骨架把 GM65 的串口协议、DMA 收包、LED 控制全部收进 C 类里主循环只剩一行轮询。适合两类人手上有 STM32 板子和 GM65 模块、想把扫码触发做成产品功能的从业者以及写了几年 C想让单片机代码干净一点却一直没敢下手的人。2. 把 C 工程改成 C四个动作让 Keil 从链接报错走到能跑从 CubeMX 生成一个纯 C 工程把main.c直接改名成main.cpp然后点编译多数人看到的不是恭喜编译通过而是一屏L6218E: Undefined symbol。这一章把这条路径上必踩的四个点按顺序拆开先是符号链接再是extern C的边界然后是编译开关的取舍。2.1 main.c 改名的第一小时undefined symbol 出现在哪几个函数上CubeMX 生成的main.h里会声明void SystemClock_Config(void);、void MX_GPIO_Init(void);、void MX_USART2_UART_Init(void);这一类函数而它们的实现躺在同目录的.c文件里。C 编译器给这些函数生成的符号名就是函数本名C 编译器则按名字改编规则生成_Z18SystemClock_Configv这样的符号。main.cpp里#include main.h时编译器按 C 规则解析声明于是链接阶段它去找_Z18SystemClock_Configv而目标文件里只有SystemClock_Config两边对不上。最省事的修法是在main.cpp的最前面给这些 C 头文件套一层extern C让编译器知道这里声明的东西按 C 的规矩生成符号// main.cpp —— 所有 CubeMX 生成的 C 头文件都包在 extern C 里 extern C { #include main.h } #include led.hpp #include gm65.hpp #include router.hpp int main(void) { HAL_Init(); // HAL 初始化必须在任何外设操作之前 SystemClock_Config(); // 72MHz 主频串口波特率误差依赖它 MX_GPIO_Init(); MX_USART2_UART_Init(); g_led.init(); // C 对象的初始化放在 HAL 之后 g_scanner.init(huart2); // 传入串口句柄内部只存指针不配置硬件 for (;;) { g_router.poll(); // 非阻塞内部判环形缓冲里有没有完整一行 } }这段main里有三个决定成败的细节。第一main本身不需要extern CC 标准规定main具有 C 链接启动文件的Reset_Handler调__main再跳main符号名保持为main加了反而多余。第二g_led.init()和g_scanner.init()之所以不写在构造函数里是因为全局对象的构造发生在__main内部的 C 库初始化阶段那时SystemInit也许跑完了但HAL_Init和 GPIO 时钟绝对没开——构造函数里调HAL_GPIO_Init会写到时钟没使能的寄存器写进去不生效且不报错。第三g_router.poll()必须是非阻塞的主循环被它卡住的话串口来的数据没人搬DMA 缓冲就覆盖了。extern C更彻底的解法是直接改main.h在文件头尾加条件编译这样任何.cpp引用它都安全/* main.h —— 加在文件最开始 */ #ifdef __cplusplus extern C { #endif /* ...原有的声明... */ #ifdef __cplusplus } #endif代价是每次 CubeMX 重新生成代码都会覆盖掉这段。习惯做法是不改生成文件把所有跨语言引用集中到一个自建的c_bridge.hpp里那个文件由自己维护CubeMX 怎么刷都不影响。2.2 extern C 该包在哪一层头文件、中断函数与 HAL 回调extern C不是加得越多越好加错位置反而制造新问题。判断标准只有一条这个符号有没有可能在 C 编译单元里被定义或被引用。满足这条就得用 C 链接不满足就老老实实按 C 走。具体到这套工程需要extern C的地方只有三类。第一类是 CubeMX 生成的 C 函数声明上面已经处理。第二类是中断服务函数比如void USART2_IRQHandler(void)它被启动文件的向量表用名字直接引用启动文件是汇编写死了USART2_IRQHandler这个符号.cpp里不加extern C就会被改编成_Z16USART2_IRQHandlerv链接器找不到向量表里落回默认的 weak 死循环。第三类是 HAL 的弱函数回调典型的就是HAL_UARTEx_RxEventCallback。// 中断服务函数启动文件按名字找必须 C 链接 extern C void USART2_IRQHandler(void) { HAL_UART_IRQHandler(huart2); // 全部交给 HAL 分发 } // HAL 弱回调同样按名字覆盖必须 C 链接 extern C void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef* huart, uint16_t Size) { if (huart-Instance ! USART2) return; // 只有空闲事件才代表一帧收完了半满事件不能当完整帧处理 if (huart-RxEventType HAL_UART_RXEVENT_IDLE) { g_scanner.onBytes(g_scanner.dmaBuf(), Size); } // 立刻重新武装否则下一次扫码收不到 HAL_UARTEx_ReceiveToIdle_DMA(huart2, g_scanner.dmaBuf(), g_scanner.dmaCap()); }反过来自己的 C 类成员函数、命名空间里的工具函数一个extern C都不要加。曾经见过把整个.cpp用extern C { }包起来的写法结果是所有成员函数都失去重载能力两个参数不同的同名函数直接报重定义改起来比重写还费劲。另一个常见误用是在.cpp里给extern C函数加默认参数C 链接函数不支持默认参数编译期就报错。还有一层容易忽略HAL_UARTEx_RxEventCallback这个回调在 HAL 里是__weak定义的你在.cpp里写了一个不带extern C的同名函数编译器不会报重复定义因为符号名不同链接器也不会报错因为 weak 符号允许被覆盖——最后的表现是回调压根没进你的函数串口数据静悄悄丢掉查半天查不出原因。这类编译链接全绿、运行时行为不对的坑只能靠对照 map 文件里的符号名确认。2.3 编译开关取舍异常、RTTI、静态构造与代码体积C 上单片机编译器默认打开的那一堆特性大部分用不上还会实打实吃 Flash 和 RAM。ARMCCKeil MDK 自带和 GCC 的默认值不一样配置项的写法也不一样下表是这套工程在两种工具链下的对照。特性ARMCC 写法GCC 写法这套工程的选择理由异常默认关闭开启需--exceptions默认开启需-fno-exceptions关闭异常表占 Flash裸机没有可靠的栈展开环境RTTI默认关闭开启需--rtti默认开启需-fno-rtti关闭不用dynamic_cast和typeid纯浪费静态对象线程安全无此选项-fno-threadsafe-statics关闭保护单线程裸机加锁代码纯属多余标准库不链libstdc不链libstdc不链std::string、std::vector一律不用优化等级-O1/-O2-O1/-O2调试用-O0发布-O2-O0下单步调试才准在 Keil 里这些选项走Options for Target → C/C → Misc Controls把--exceptions那一栏留空就是关闭不用额外写。GCC 工具链CubeIDE、CLion 配 arm-none-eabi则在MCU/MPU Settings或者 CMake 的target_compile_options里加# GCC 工具链下编译 C 源文件的标准开关 arm-none-eabi-g -mcpucortex-m3 -mthumb \ -fno-exceptions -fno-rtti -fno-threadsafe-statics \ -fno-use-cxa-atexit -Os \ -c main.cpp -o main.o-fno-use-cxa-atexit这一项容易被漏掉它的作用是让全局对象的析构函数不注册到atexit链表里。裸机程序永远不退出析构函数根本不会执行注册进去只是白占 RAM 和 Flash。加上它之后全局对象的析构函数会被直接丢弃。还有一个必须提前想清楚的问题operator new。默认实现会去调_sbrk从堆上分配堆大小由启动文件里的Heap_Size决定默认常常是 0x200。一旦代码里某处new了一个对象堆就用起来了而嵌入式堆的碎片化是不可控的。这套工程的做法是彻底禁用动态分配——不去定义operator new让链接器在用到时报错反而能第一时间发现问题。如果确实需要运行时创建对象就重载一个只从静态池里切的operator new// 固定大小的静态池杜绝堆碎片池满直接返回 nullptr static uint8_t s_pool[512]; static uint16_t s_used 0; void* operator new(size_t n) noexcept { n (n 3u) ~3u; // 4 字节对齐 if (s_used n sizeof(s_pool)) return nullptr; void* p s_pool[s_used]; s_used (uint16_t)n; return p; } void operator delete(void*) noexcept {} // 不回收池只增不减这段代码的取舍写得很明白operator delete故意做成空实现因为池不做回收回收反而会引入碎片和二次分配的正确性问题。s_pool的大小按你实际的对象数量估512 字节够放十来个中等大小的对象。如果编译时报operator new重复定义说明链接脚本里已经带了libstdc的实现需要在链接选项里去掉它。2.4 向量表里的 weak 陷阱中断进来直接 B .启动文件里每一个中断入口都写成这样DCD USART2_IRQHandler ; USART2 ; USART2_IRQHandler B . ENDPB .是一条死循环指令跳到自己。这是所有中断的默认实现用意是没写处理函数就别乱跳。它的危害在于它是合法的弱符号链接器不会因为你没有实现而报错。你的USART2_IRQHandler因为名字改编、因为写成了成员函数、因为文件没加进工程最终没有生成USART2_IRQHandler这个 C 符号链接器就安静地用默认的那个填进去。现象是串口一收到数据程序就卡死用调试器停下来看 PC 指针停在一个只有一条B .的地址上看调用栈什么也看不出来——这就是典型的黑匣子现场。确认方法很直接编译后打开.map文件搜USART2_IRQHandler看它被解析到哪个地址、哪个目标文件。如果显示的是startup_stm32f103xb.o那就说明你的实现根本没被链进去。另一条路是在调试器里看0x08000000 0x38 4*38这个位置向量表偏移按具体型号算读出来的地址如果落在启动文件那几十字节范围内同样是没接上。产业里比较稳的习惯是在工程里自己写一份irq_check.cpp把所有用到和没用到的中断处理函数都显式定义一遍用到的转发给 HAL没用到的写个带计数器的死循环。这样一旦哪个中断意外触发调试器停下来的位置就是明确的而不是一个地址都没有的汇编黑洞。3. GM65 的串口协议拆解一帧指令从拼包到校验的可复现实现硬件连好、工程能编译接下来面对的是协议。GM65 走的是标准 UART但它的指令帧不是发一串字节过去这么随意——帧头、长度、校验、帧尾四段缺一不可发错一个字节模块就静默不理你。这一章按帧长什么样、怎么拼、怎么收、怎么解四步把它拆干净。3.1 帧结构2 字节帧头、1 字节长度、1 字节校验的算法边界GM65 的串口指令帧是定长字段加变长数据的组合结构固定如下具体命令字和参数值以模块手册的指令表为准不同固件版本会有差异偏移字段长度说明0帧头2固定0x7E 0x00不能改2包长度 LEN1命令字 数据 校验位 三段的字节数之和3命令字 CMD1手册指令表里查决定这条指令干什么4数据 DATANN LEN - 2无数据时 N 为 04N校验 CHECK1从帧头第一个字节到 DATA 最后一字节逐字节异或5N帧尾2固定0x0D 0x0A三个容易算错的地方全在长度和校验上。第一LEN 不包含帧头和帧尾它的定义域是命令字开始到校验位结束所以最小合法值是 2只有命令字和校验位没有数据。很多人按整帧长度理解结果多发一个字节模块直接丢帧。第二校验是从帧头开始算的不是从命令字开始也不是从数据开始。异或的初值为 0把0x7E、0x00、LEN、CMD、DATA 全部异或进去结果就是 CHECK。第三整帧总字节数 LEN 5这个式子在做接收缓冲区和超时判断时天天要用。举个具体的发一条只有命令字、没有数据的指令LEN 2整帧长度 7字节序列是7E 00 02 CMD CHECK 0D 0A。发一条带 2 字节数据的指令LEN 4整帧长度 9序列是7E 00 04 CMD D0 D1 CHECK 0D 0A。把这个长度关系写进代码里比对着手册一个个数要靠谱得多。注意扫码成功后模块回传的是纯文本二维码内容不是这个帧格式。它和指令响应共用同一根 TX 线接收端必须靠内容特征区分——以0x7E开头的当帧解析其他的一律当扫码文本。两者混在一起是接收逻辑最常见的翻车点。3.2 拼包与校验把 0x7E 0x00 到 0x0D 0x0A 写成函数拼包函数只做一件事给定命令字和数据输出一段可以直接丢给HAL_UART_Transmit的字节流。校验和长度都在函数内部算调用方不用关心。// gm65.hpp 片段 class Gm65 { public: static const uint16_t kFrameMax 32; // 指令帧最长不超过 32 字节 // 返回整帧长度失败返回 0out 缓冲区至少 kFrameMax 字节 uint16_t buildFrame(uint8_t* out, uint8_t cmd, const uint8_t* data, uint8_t n); // 单字节异或校验从帧头算到 data 末尾 static uint8_t checksum(const uint8_t* p, uint16_t len); };// gm65.cpp 片段 uint8_t Gm65::checksum(const uint8_t* p, uint16_t len) { uint8_t x 0; for (uint16_t i 0; i len; i) { x ^ p[i]; } return x; } uint16_t Gm65::buildFrame(uint8_t* out, uint8_t cmd, const uint8_t* data, uint8_t n) { if (n (kFrameMax - 7)) return 0; // 留出帧头/长度/校验/帧尾共 5 字节 CMD uint16_t i 0; out[i] 0x7E; // 帧头高字节 out[i] 0x00; // 帧头低字节 out[i] (uint8_t)(n 2); // LEN CMD(1) DATA(n) CHECK(1) out[i] cmd; // 命令字 for (uint8_t k 0; k n; k) { out[i] data[k]; // 数据段 } out[i] checksum(out, i); // 校验帧头算到 DATA 末尾此时 i 正好是长度 i; out[i] 0x0D; // 帧尾 CR out[i] 0x0A; // 帧尾 LF return i; // 返回整帧长度等于 LEN 5 }checksum(out, i)这一行是整个函数的关键调用时i已经累加到DATA 末尾的下一个位置而checksum的参数是长度语义正好对上不需要任何-1之类的偏移调整。这种位置变量顺便当长度用的写法在拼包代码里很常见但一定要保证语义清晰否则后面改字段顺序时必然算错。发送时不要用阻塞式HAL_UART_Transmit死等用带超时的版本超时值按波特率算9600 波特率下每字节约 1.04ms一个 9 字节的帧约 9.4ms超时给 50ms 足够115200 下给 10ms。超时返回非HAL_OK就说明串口被占着或者硬件有问题这时候重试没有意义记一个错误计数让主循环去处理更稳妥。bool Gm65::send(uint8_t cmd, const uint8_t* data, uint8_t n) { uint8_t frame[kFrameMax]; uint16_t len buildFrame(frame, cmd, data, n); if (len 0) return false; // 超时按波特率估这里假设 960050ms 足够一帧发完 return HAL_UART_Transmit(huart_, frame, len, 50) HAL_OK; }3.3 不定长回包怎么收DMA 配空闲中断的完整配置二维码内容的长度是不定的短的十几个字节长的能到一两百字节。用固定长度接收就是自找麻烦收短了截断收长了死等。轮询接收更糟主循环被占住其他事都干不了。裸机上处理不定长串口数据成熟度最高的方案是 DMA 加空闲中断——DMA 负责把字节搬进缓冲串口总线空闲时硬件产生 IDLE 中断告诉你这一帧到头了。配置分三步。第一步是 CubeMX 里给 USART2 打开 DMA 接收通道模式选 Normal不用 Circular因为每次收完都要重新武装并处理数据Circular 会在你处理的时候继续往里写。第二步是在初始化时调用一次接收函数把 DMA 启动起来。第三步是写回调在空闲事件里把数据搬走并重新启动。// gm65.cpp —— 启动接收只调一次 bool Gm65::init(UART_HandleTypeDef* huart) { huart_ huart; if (huart_ nullptr) return false; // 关掉 DMA 半满中断避免半截数据被当成完整帧 __HAL_DMA_DISABLE_IT(huart_-hdmarx, DMA_IT_HT); return HAL_UARTEx_ReceiveToIdle_DMA(huart_, dmaBuf_, kRxCap) HAL_OK; } // 中断上下文调用只做搬运不做解析 void Gm65::onBytes(const uint8_t* p, uint16_t n) { if (n 0 || n kRxCap) return; ring_.push(p, n); // 环形缓冲见 3.4 overflow_ (ring_.pushOk() ? 0 : 1); }__HAL_DMA_DISABLE_IT(huart_-hdmarx, DMA_IT_HT)这一句不要省。DMA 默认会同时开传输完成TC和半传输完成HT两个中断HAL 把 HT 事件也转发到同一个HAL_UARTEx_RxEventCallback里只是Size参数的含义变了——TC/IDLE 时Size是本次收到的总字节数HT 时是缓冲的一半长度。如果不关 HT一个长一点的二维码会在收到一半的时候触发一次回调你把这半截当完整数据解析得到的永远是被截断的字符串。曾经为了这个现象查了两个晚上最后在回调里打点才发现一次扫码进了两次回调。缓冲区大小kRxCap按最坏情况给。GM65 单次扫码输出的字节数一般不超过 128取 128 或 256 都能覆盖。缓冲给大了不心疼STM32F103 的 RAM 有 20KB两个 256 字节的缓冲加环形缓冲也就 1KB 出头。3.4 环形缓冲解耦中断只负责搬主循环只负责判中断里不能做字符串比较更不能调用可能阻塞的函数。正确分工是中断把原始字节塞进环形缓冲就返回主循环在空闲时从缓冲里取出完整的一行去解析。这中间用环形缓冲当解耦层两边互不等待。// ring.hpp —— 单生产者单消费者中断写、主循环读不需要关中断 template uint16_t N class RingBuffer { public: bool push(const uint8_t* p, uint16_t n) { if (n free()) return false; for (uint16_t i 0; i n; i) { buf_[head_] p[i]; head_ (uint16_t)((head_ 1) % N); } return true; } // 取出一行以 \r 或 \n 结尾返回长度0 表示还没凑够一行 uint16_t popLine(char* out, uint16_t cap) { uint16_t t tail_; uint16_t len 0; while (t ! head_) { uint8_t c buf_[t]; if (c \r || c \n) { if (len 0) { // 连续结束符跳过 tail_ (uint16_t)((t 1) % N); t tail_; continue; } tail_ (uint16_t)((t 1) % N); // 吃掉结束符 return len; } if (len cap - 1) { // 行超长丢弃整行防溢出 tail_ (uint16_t)((t 1) % N); return 0; } out[len] (char)c; t (uint16_t)((t 1) % N); } return 0; // 没遇到结束符等下次 } uint16_t free() const { return (uint16_t)((N tail_ - head_ - 1) % N); } private: uint8_t buf_[N]; volatile uint16_t head_ 0; // 只由中断写 volatile uint16_t tail_ 0; // 只由主循环写 };这个实现能免锁靠的是单生产者单消费者模型head_只有中断在改tail_只有主循环在改两边读对方的变量都是单次 16 位读Cortex-M3 上对齐的 16 位读写是原子的不存在读到一半被打断的情况。volatile保证编译器每次都从内存重新读不会把head_缓存在寄存器里导致主循环永远看到旧值。popLine里那两个丢弃分支是血泪经验。第一条是连续的\r\n如果你只吃掉一个字符就返回下一次调用会立刻返回一个长度为 0 的行上层误判成有效数据。第二条是行超长——缓冲里进来一串不含结束符的垃圾比如波特率不匹配时收到的乱码如果不设上限len会一直涨到覆盖out缓冲区。设一个cap - 1的上限超了就整行丢弃让系统从错误状态恢复比死循环强。4. C 封装落地LED、串口、扫码结果各自成类前面三章解决的是能跑这一章解决写完半年后还看得懂。C 在这套工程里的价值不是炫技而是把某个外设的寄存器操作和业务逻辑隔开主循环里只有一行poll()扫码内容和动作的映射关系集中在一张表里改需求时不用翻中断服务函数。4.1 为什么不用 new静态对象与重载 operator new 的取舍单片机上的对象创建只有静态分配一种做法是安全的。全局对象、函数内的static对象、类成员对象这三类的存储位置在编译期就定了在.bss或.data段里不占堆、不会碎片化、地址固定可查。而new出来的对象落在堆上堆大小由启动文件里的Heap_Size控制用完不还就会一路涨上去。更要命的是失败路径。ARMCC 的默认operator new在分配失败时抛std::bad_alloc而你按 2.3 的配置关掉了异常抛出去没人接程序直接进std::terminate在裸机上就是一个死循环。有些人用new (std::nothrow)这要求链上完整版libstdcFlash 又得涨几 KB。所以这套工程的做法很干脆所有对象都是全局静态实例写在一个app.cpp里统一管理谁要用就extern声明。// app.cpp —— 所有全局对象的唯一定义点 #include app.hpp Led g_led(GPIOA, GPIO_PIN_5, true); // true 表示低电平点亮 Gm65 g_scanner; Router g_router;Led的构造参数(GPIOA, GPIO_PIN_5, true)三项分别表示端口、引脚、是否低电平有效。低电平有效是开发板上常见的接法LED 阳极接 3.3V阴极接 GPIO参数化之后同一份代码可以适配两种接法不用改实现。注意这里只是把三个值存进成员变量构造函数里一行硬件操作都没有原因在 4.4 展开。如果确实有运行时才确定的对象数量比如支持多个扫码模块也不用new改成固定大小的对象数组加一个计数static Gm65 s_scanners[2]; // 最多两个模块编译期确定 static uint8_t s_scannerCount 0;数组元素是静态分配的加一个对象就是把s_scannerCount加一用满了直接拒绝。这种编译期定上限的做法在嵌入式里是常态比动态分配的可预测性强得多。4.2 Led 类与 Gm65 类的接口设计两个类的接口都保持极窄只暴露调用方真正需要的动作内部实现细节一律private。判断标准是如果某个成员函数只在类内部被调用就不该出现在public区。// led.hpp #pragma once #include main.h class Led { public: Led(GPIO_TypeDef* port, uint16_t pin, bool activeLow) : port_(port), pin_(pin), activeLow_(activeLow), state_(false) {} void init(); // 配置 GPIO必须在 HAL_Init 之后调用 void on(); void off(); void toggle(); bool state() const { return state_; } private: GPIO_TypeDef* port_; uint16_t pin_; bool activeLow_; bool state_; };// led.cpp #include led.hpp void Led::init() { GPIO_InitTypeDef g {0}; g.Pin pin_; g.Mode GPIO_MODE_OUTPUT_PP; // 推挽输出驱动 LED 足够 g.Pull GPIO_NOPULL; g.Speed GPIO_SPEED_FREQ_LOW; // 点灯不需要高速翻转 HAL_GPIO_Init(port_, g); off(); // 上电默认熄灭避免闪一下 } void Led::on() { HAL_GPIO_WritePin(port_, pin_, activeLow_ ? GPIO_PIN_RESET : GPIO_PIN_SET); state_ true; } void Led::off() { HAL_GPIO_WritePin(port_, pin_, activeLow_ ? GPIO_PIN_SET : GPIO_PIN_RESET); state_ false; } void Led::toggle() { state_ ? off() : on(); }state_这个成员变量的存在容易被质疑为什么不直接读引脚电平。原因是读回引脚电平在输出模式下读到的是端口输出寄存器的值理论上一致但一旦引脚被复用或者外部电路拉偏读回来的就不可信了。自己维护一份软件状态toggle()判断用它逻辑不依赖硬件也方便以后加闪烁三次这类需要知道当前状态的功能。init()里最后一句off()不是可有可无的。GPIO 复位后的默认输出电平是低如果 LED 是低电平有效接法上电瞬间会亮一下再灭肉眼可见的闪烁。显式调一次off()把它拉到熄灭状态这段时间窗就消掉了。Gm65类的接口对应 3.2 和 3.3 的内容public区只有init、send、onBytes、fetch四个buildFrame和checksum因为要单独测试做成public static。4.3 扫码内容路由从字符串到动作的分发拿到二维码内容之后需要决定做什么。最直白的写法是if链三五个动作还能忍十几个动作就是灾难——加一个动作要改三处还容易漏。表驱动是把匹配和执行分开一张常量表存内容 → 函数指针查找逻辑写一次以后加动作只加一行表项。// router.hpp #pragma once #include gm65.hpp class Router { public: void init(Gm65* scanner) { scanner_ scanner; } void poll(); // 主循环调用非阻塞 private: static void actLedOn(); static void actLedOff(); static void actLedToggle(); Gm65* scanner_ nullptr; char line_[64]; // 单行最大长度够二维码用 };// router.cpp #include router.hpp #include app.hpp #include string.h namespace { struct Action { const char* code; // 二维码里的内容 void (*fn)(); // 匹配后执行的动作 }; // 动作表加新动作只在这里加一行 const Action kActions[] { { LED_ON, Router::actLedOn }, { LED_OFF, Router::actLedOff }, { LED_TOGGLE, Router::actLedToggle }, }; } // namespace void Router::poll() { uint16_t n scanner_-fetch(line_, sizeof(line_)); if (n 0) return; // 没有完整行直接返回 for (uint16_t i 0; i sizeof(kActions) / sizeof(kActions[0]); i) { uint16_t m (uint16_t)strlen(kActions[i].code); if (n m memcmp(line_, kActions[i].code, m) 0) { kActions[i].fn(); return; } } // 未匹配的内容不动作可以在这里记日志或点错误灯 } void Router::actLedOn() { g_led.on(); } void Router::actLedOff() { g_led.off(); } void Router::actLedToggle() { g_led.toggle(); }这段代码有三个细节值得说。第一比较用memcmp加长度判断不用strcmp。因为line_里的内容不保证以\0结尾fetch返回的是长度直接strcmp会越界读。长度相等再做内容比较两个条件缺一不可。第二kActions放在匿名命名空间里链接属性是内部链接别的编译单元看不到也就不会和别处的同名表冲突。第三sizeof(kActions) / sizeof(kActions[0])算表项数写成常量更好看但宏展开的写法在任何编译器上都成立先保证能跑。fetch是Gm65里对RingBuffer::popLine的一层薄包装作用是把环形缓冲这个实现细节挡在Router之外uint16_t Gm65::fetch(char* out, uint16_t cap) { return ring_.popLine(out, cap); }line_给 64 字节是个折中。二维码内容通常很短但如果是 URL 形式会超过 64。真遇到长内容popLine会整行丢弃并返回 0表现是扫这个码没反应。要支持长内容就把line_调大代价是每个Router实例多吃 RAM——静态对象不释放这个账要本文还有配套的精品资源点击获取