单片机C++实战:类封装与模板泛型重构温湿度采集系统
写这一篇的时候我心里其实挺感慨的。上一篇讲C在单片机上的基础用法评论区里吵得很凶有人说“单片机就那点资源用C不是吃饱了撑的”也有人说“C面向对象写驱动是真的爽回不去C了”。两边都有道理但真实项目里怎么选、怎么落地比站队重要得多。这篇我不会再去辩论C和C谁好而是直接把我最近用C重构的一套温湿度采集显示项目完整拆开从类封装、模板泛型到工具链配置、踩坑排查一次性讲透。适合那些已经会用C写单片机程序、想试试C但又不知道从哪下手的人也适合刚入行、想建立工程化思维的同学。1. C上单片机从C思维到C思维的关键转变1.1 为什么要在单片机上用C先回答那个最常被问的问题单片机资源这么紧张C那一堆特性真的有必要吗我的回答是C在单片机上真正值钱的不是语法糖而是封装和分层。C语言写驱动典型状态是函数散落、全局变量满天飞。我见过太多工程是这样的一个lcd1602.c里有十几个函数lcd1602.h里暴露了一堆接口全局变量uchar x, y, temp在好几个文件里被改来改去改一个功能牵一发动全身。这还是在一个人维护的前提下如果项目过手两三个人后期维护成本直接起飞。C解决的是代码组织和复用的问题。同样是LCD1602驱动用类封装之后引脚配置、初始化时序、数据读写、光标控制这些操作全部收敛在类内部对外只暴露几个语义清晰的接口。换引脚改一下构造参数就行。换芯片平台只要接口不变上层应用代码一行都不用动。我拿实际项目举例。之前用C写的DHT11温湿度读取函数大概长这样uchar dht11_read_data(uchar* temp, uchar* humi) { // 200多行里面全是全局标志位和延时 }函数确实能跑但问题是如果同一条总线上挂两个DHT11或者要接一个DHT22这函数立刻就不够用了——所有状态都耦合在一个全局变量上。用类封装之后每个传感器实例都有自己的状态互不干扰。这就是从“面向流程”到“面向对象”的第一步也是最重要的思维转变。1.2 裸机环境下C能用什么、不能用什么先把话说清楚C在裸机环境下不是所有特性都适合用。我自己在工程里的取舍是放心用的类、结构体、枚举类enum class命名空间namespace模板在合理范围内constexpr编译期计算引用和重载函数对象、std::function的轻量替代谨慎用的虚函数。不是说绝对不能用但要先确认目标编译器的实现。AVR、STM32这类平台C编译器的虚函数表vtable一般都能正常工作但51这种只有几百字节RAM的片子一个虚表指针就占2个字节一个类再虚一下几个实例下来内存就告急了。能用模板就用模板虚函数留给真正需要多态的场景。运算符重载。自己做数学库或者封装固件寄存器的时候挺顺手但滥用会让代码变得很难读。直接绕开的new/delete。裸机环境下没有操作系统管理堆内存频繁动态分配会产生碎片最后程序跑着跑着就炸了。需要内存就静态分配或者用内存池。异常try/catch。C异常在裸机上的实现依赖栈展开stack unwinding对资源要求极高而且异常表会占用Flash空间。用错误码状态机去处理错误这才是单片机的做法。STL容器。vector、map这些底层全是指针动态内存直接用在单片机上等于自杀。需要固定大小的数据结构就自己写一个。1.3 寄存器封装从#define到constexprvolatileC语言单片机编程里最常见的寄存器定义是这样的#define P1_0 (*((volatile unsigned char*)0x90))宏定义在C时代是唯一选择但问题很明显没有类型检查写错一个位操作很难发现。而且宏是纯文本替换一旦定义错了排查起来非常痛苦。C里我更喜欢用constexpr 引用或者类来封装寄存器// 封装一个GPIO引脚 struct Pin { volatile uint8_t* ddr; // 方向寄存器地址 volatile uint8_t* port; // 端口寄存器地址 uint8_t bit; // 位序号 void output() const { *ddr | (1 bit); } void high() const { *port | (1 bit); } void low() const { *port ~(1 bit); } };这里有个关键点volatile绝对不能丢。单片机寄存器会被硬件修改编译器如果把它优化掉了程序逻辑直接就坏了。我见过好几个人用C写外设驱动时觉得volatile是C语言的老古董不加也没关系结果调试一整天最后发现就是这个问题——寄存器被编译器优化成了普通变量每次读到的都是缓存值。还有一个C特有的优势可以用constexpr做编译期计算。比如LCD1602的命令字、DHT11的时序延时参数可以在编译期就算好运行时零开销。这一点后面讲模板的时候会再展开。2. 实战核心用类封装DHT11温湿度传感器2.1 接口设计对象思维的第一步很多从C转C的人最大的困惑是知道要封装但不知道怎么设计接口。我建议从“这个传感器对外提供什么能力”开始思考而不是先想内部实现。DHT11对外其实就是三件事初始化设置引脚读取温湿度判断读取是否成功所以我的类是这么设计的class DHT11 { public: // 构造函数传入引脚但不在这里初始化 DHT11(volatile uint8_t* ddr, volatile uint8_t* port, uint8_t bit); // 初始化配置引脚方向 void init(); // 读取一次数据成功返回true失败返回false bool read(float temperature, float humidity); };注意我故意把构造函数和init()分开。原因是在嵌入式环境里对象可能是全局变量构造函数在main()之前就执行了当时MCU时钟、引脚状态可能还没准备好所以把真正需要硬件的操作放到init()里由程序在合适时机调用。2.2 DHT11时序实现40bit数据怎么收DHT11的通信协议是单总线时序要求非常严格。串行数据是40位8位湿度整数部分 8位湿度小数部分 8位温度整数部分 8位温度小数部分 8位校验和校验和等于前四个字节之和的低8位。时序流程是这样的主机拉低总线至少18ms然后释放并延时20~40us这是起始信号DHT11响应拉低80us再拉高80us然后开始传输40位数据。每一位数据的开始都是50us的低电平之后是拉高拉高持续26~28us表示逻辑0拉高持续70us表示逻辑1用C实现的时候最常见的问题是延时不准。很多人的做法是在循环里读引脚电平然后等电平变化但DHT11要求对信号宽度做判断单纯“等变化”很容易误判。我的做法是把每一位的读取封装成一个独立函数bool DHT11::readBit(uint8_t bit) { // 等待低电平结束 uint32_t timeout 0; while ((*port (1 p_bit)) 0) { if (timeout 1000) return false; // 超时报错 delayMicroseconds(1); } // 测量高电平持续时间 timeout 0; while ((*port (1 p_bit)) ! 0) { if (timeout 1000) return false; delayMicroseconds(1); } // 高电平时间约70us为1约26us为0 bit (timeout 50) ? 1 : 0; return true; }这里的关键是timeout同时充当了延时和计时器。为什么不用官方推荐的delayMicroseconds精确测量因为在实际项目里中断会打断延时的执行如果延时期间来了一个中断整个时序就全乱了。用循环计数器的方式就算中断打断了一小会儿也不至于彻底丢位顶多多计几个数在70us vs 26us这个量级上仍然能区分开。踩过的坑说一下DHT11上电之后不能立刻读至少要等1秒让传感器稳定。我第一次实测的时候上电就立刻调用read()结果读十次失败九次一度怀疑是硬件问题。后来查资料才发现DHT11在刚上电时会有一个自检过程这时候IO状态是不确定的。所以我在init()里加了一个1秒的等待逻辑void DHT11::init() { *ddr | (1 p_bit); // 配置为输出 *port | (1 p_bit); // 默认拉高 // 等待传感器稳定 for (uint16_t i 0; i 1000; i) { delayMicroseconds(1000); } }2.3 把LCD1602也封装成类接口设计的套路DHT11封装完之后LCD1602的封装就顺理成章了。LCD1602本质是一个只能“写”的外设它的操作分两类写命令和写数据。底层时序都是差不多的拉低E引脚、在RS引脚设置模式、把数据放到8根数据线或4根数据线上4线模式、拉高E触发锁存。用类封装我前后设计了三个版本最终留下来的是这个class LCD1602 { public: LCD1602(volatile uint8_t* dataPort, uint8_t rs, uint8_t e); void init(); // 初始化设置引脚、清屏、进入4线模式 void clear(); // 清屏 void setCursor(uint8_t col, uint8_t row); // 设置光标位置 void print(const char* str); // 输出字符串 void print(char ch); // 输出单个字符 private: volatile uint8_t* _dataPort; uint8_t _rs; uint8_t _e; void writeCommand(uint8_t cmd); void writeData(uint8_t data); void writeByte(uint8_t data, uint8_t mode); // mode0命令mode1数据 };这里有个细节值得展开为什么构造函数只存引脚参数不做事。LCD1602初始化需要延时第一次写命令前要等40ms以上这些操作放在构造函数里如果对象是全局的构造函数在main()之前就跑了这时候系统时钟可能还没配置好延时函数可能不准。所以所有硬件初始化都放到init()里这是嵌入式C的通用实践。LCD1602实际的初始化时序里有个很反直觉的地方进入4线模式的时候要先向8线模式发送三次0x30然后再切到4线模式。有些文档直接写“发送0x28初始化”新手照着做LCD1602死活不亮其实就是没理解这个从8线到4线的切换过程。这个我后面在排查章节会再讲。3. 用模板改造驱动层从面向对象走向泛型3.1 为什么模板在单片机上有天然优势很多做C的人一听到模板就想起STL那些庞然大物第一反应是“单片机资源那么小用模板不是找死”。但恰恰相反模板用在单片机上只要用得克制反而能比虚函数省下非常多的资源。原理是这样的模板是在编译期实例化的编译完之后每个模板实例就是一坨具体的、硬编码的代码没有任何间接跳转没有虚表没有运行时类型信息。虚函数虽然写起来方便但每次调用都要通过虚表指针间接跳转在时钟频率低的单片机上这个跳转开销是实打实的。模板适合在什么场景下用答案是参数在一个编译期就能确定的地方。比如我们前面封装的Pin结构体引脚编号不可能是运行时才知道的那直接用模板参数传进去让编译器把整个操作内联优化掉templateuint8_t DDR_ADDR, uint8_t PORT_ADDR, uint8_t PIN_NUM class MyPin { public: static void output() { (*reinterpret_castvolatile uint8_t*(DDR_ADDR)) | (1 PIN_NUM); } static void high() { (*reinterpret_castvolatile uint8_t*(PORT_ADDR)) | (1 PIN_NUM); } static void low() { (*reinterpret_castvolatile uint8_t*(PORT_ADDR)) ~(1 PIN_NUM); } };注意这里定义的都是static函数所以这个类不需要任何实例调用就是MyPin0x3A, 0x3B, 4::high();。编译之后这条调用直接就变成一条C语言的位操作指令开销为零。3.2 static_assert与编译期约束把错误掐死在编译期用了模板之后最爽的体验其实是编译期的错误检查。以前用C写代码引脚编号写错编译不会报错下载到板子上之后才发现某个引脚不响应这时候只能一根一根线去量电压非常痛苦。C给我提供了static_assert可以在编译期做断言。比如我做一个I2C的模板类支持的最低地址是0x08那我就可以这么写templateuint8_t ADDR class I2CDevice { static_assert(ADDR 0x08, I2C地址最低是0x08ADDR太小了); // ... };如果我代码里写了I2CDevice0x02编译器直接报错连下载都不用错误在写代码的时候就暴露了。这种“把错误拦截在编译期”的思路是C相对C最大的工程价值。3.3 模板实例与Flash空间的取舍但模板也不是没有代价。每个模板实例都是一份独立的代码拷贝如果一个模板被实例化10次代码就是10份。这对Flash空间小的芯片非常不友好。我的经验是两个原则模板只封装高频使用、函数体很小的操作比如引脚控制、寄存器读写、位运算如果函数体很长比如液晶初始化就算用了模板也要把它提取成普通成员函数避免每个实例都复制一遍大段代码举个例子我前面写的MyPin里low()函数只有一行就算实例化100个也不占多少Flash。但如果我把整个LCD1602的初始化代码全塞进模板类里那每个实例都会把那几百行初始化代码复制一份Flash瞬间爆炸。所以模板使用必须克制。3.4 结合模板与类DHT11的泛型化改造DHT11的时序操作其实非常适合用模板来定义引脚。我把上面的Pin类作为模板参数传给DHT11这样就完全解耦了传感器逻辑和具体引脚操作templatetypename PIN_T class DHT11T { public: DHT11T() {} bool read(float temp, float humi) { // 使用PIN_T::output()、PIN_T::low()、PIN_T::high() // ... } }; // 具体引脚定义 using DHT_Pin MyPin0x3A, 0x3B, 4; DHT11TDHT_Pin dht;这样做的好处是如果哪天我把DHT11从PB4换到PB5只需要改一行using声明驱动类代码一行不用动其他所有调用代码也全部兼容。而且因为所有操作都是内联的性能跟手写寄存器操作完全一样。4. 工具链与工作流VSCodePlatformIO实战4.1 为什么我放弃了Keil选了PlatformIO这个话题可能争议比较大但我还是得说Keil用了那多年这两年切到PlatformIO之后真的回不去了。不是说Keil不好而是Keil的问题在于它太老了和现代工程工作流严重脱节。做个简单对比对比项KeilSTM32CubeIDEPlatformIO平台兼容仅WindowsWindows/macOS/LinuxWindows/macOS/Linux编译器ArmCC/AC5/AC6arm-none-eabi-gccgcc/clang全系C标准支持C03到C17要看版本C11到C20C11到C20代码补全/跳转一般一般优秀基于clangd/IntelliSense包管理手动手动自带库管理器调试集成一般优秀良好原生支持OpenOCD对我来说最关键的是VSCode的编辑体验。用Keil的时候跳转定义、查看引用、重命名符号这些操作全都很难用在C这种强调类型抽象的编程语言面前非常痛苦。PlatformIO依托VSCode的clangd插件代码补全和纠错都很好用写类模板的时候尤其明显——写错类型、忘了加const编辑器直接标红不用等编译。4.2 项目结构驱动与业务分离C工程和C工程还有一个明显区别就是对目录结构的要求更高。C语言一个文件一个功能混乱一点也能忍。C类一多目录不规划好工程很快就变成垃圾场。我现在常用的裸机C项目结构是project/ ├── include/ # 对外头文件、公共接口 ├── src/ # 业务代码、main.cpp ├── lib/ # 独立驱动库 │ ├── dht11/ │ │ ├── dht11.h │ │ └── dht11.cpp │ ├── lcd1602/ │ │ ├── lcd1602.h │ │ └── lcd1602.cpp │ └── mypin/ # 模板类不拆分文件直接头文件实现 │ └── mypin.h ├── test/ # 本机测试可选 └── platformio.ini注意一个细节纯模板类如MyPin是不需要.cpp文件的全部实现在.h里。因为模板在编译期要实例化编译器必须在实例化点看到模板的完整定义。如果把模板实现放在.cpp文件里连接的时候就会报“未定义引用”的错误。这个新手特别容易踩。platformio.ini 的配置这样写[env:stc15w4k32s4] platform ststm32 ; 根据实际平台调整 board genericSTM32F103C8 framework arduino build_flags -stdgnu17 -fno-exceptions ; 禁用异常节省Flash -fno-rtti ; 禁用运行时类型信息节省RAM ; 根据你的芯片改平台配置-fno-exceptions和-fno-rtti是裸机C的两个非常关键的编译选项。禁用异常和RTTI可以省下几百字节到几KB不等的Flash/RAM对资源受限的单片机来说差距很大。如果不禁用一旦代码里用了typeid或者dynamic_cast编译出来的体积立刻膨胀。4.3 C与C混编extern C是绕不过去的坎很多单片机外设库是纯C写的比如STM32的标准外设库、STC的官方例程。在C工程里直接#include这些头文件大部分情况能过但只要涉及到链接就会遇到一个经典问题C编译器会做名字修饰name mangling而C编译器不会导致符号对不上报undefined reference错误。比如C库里有函数void lcd_write(uint8_t data);经过C编译器编译符号名会被修饰成类似_Z10lcd_writeh的东西链接器找不到C库里的lcd_write就报错了。解决办法是给C头文件加extern C块#ifdef __cplusplus extern C { #endif #include stc15.h #include lcd1602_c_lib.h #ifdef __cplusplus } #endif不过更规范的做法是在C头文件自己内部加这种兼容宏这样C和C工程都能直接包含// lcd1602_c_lib.h #ifdef __cplusplus extern C { #endif void lcd_write(uint8_t data); #ifdef __cplusplus } #endif我在一个项目里把官方C库包了一层C类用类管理引脚和操作内部调用extern C的C函数这样既保留了官方库的稳定性又能享受C的封装。如果遇到access violation c0000005这种运行时崩溃绝大部分情况都是指针地址写错了比如给函数传了一个非法的地址或者空指针。在单片机上的表现通常是程序跑飞或者进HardFault排查思路跟这个类似先检查所有指针、所有寄存器地址再用调试器看是哪条指令触发异常的。4.4 中断服务函数怎么用C写单片机项目离不开中断。中断服务函数ISR在C里有一个特殊约束ISR不能是类的成员函数除非是静态成员。因为中断发生时硬件只会把PC指针跳转到固定地址没有this指针可用非静态成员函数无法被调用。正确做法是把ISR写成普通C函数或静态成员函数然后在里面调用类的公有方法// 假设这是一个定时器中断 volatile uint32_t system_ticks 0; extern C void TIM0_ISR(void) { system_ticks; // 如果需要通知传感器读取可以置标志位 dht_read_requested true; }中断函数里还要注意一个点尽量少做耗时操作。比如DHT11的读取时序需要毫秒级延时这个绝对不能放在中断里否则整个MCU都会被卡住。一般做法是中断里只置标志位主循环里检测到标志位后再做实际的数据读取。这也是状态机思维在外设编程中的具体体现。5. 踩坑记录常见问题的排查思路与解决5.1 LCD1602接上后显示不出字符的排查清单这个话题在网上问的人特别多几乎每届新生都会踩一遍这个坑。我自己也折腾过很久总结下来优先级最高的排查顺序是这样的第一查对比度电位器。LCD1602的V0引脚需要一个负压偏置才能显示这个电压太强或太弱都看不见字。很多人一上电发现白屏第一反应是代码问题结果不是是V0没接电位器导致对比度为零字根本显示不出来。解决办法是V0接一个10k电位器中间抽头调整慢慢拧到能看见字。这是我见过的最常见的“代码没问题但屏幕不亮”的原因。第二查初始化时序。LCD1602对初始化时序要求非常严格4线模式必须在8线模式下先发三次0x30再切4线模式而且每步之间必须有足够的延时。具体来说上电等15ms以上写0x30等5ms写0x30等160us写0x30等160us切4线模式写0x28写0x08关显示写0x01清屏写0x0F开显示很多人的初始化代码里延时不够尤其是不等160us就直接发后续指令屏幕直接就卡死了。这里建议把所有延时都写长一点用5ms级别的延时函数宁可慢一点也要保证可靠。第三查引脚映射。如果用4线模式只用到DB4-DB7四根数据线DB0-DB3要悬空或者接地不能悬空否则可能干扰。RS、RW、E三根控制线注意RW如果不需要读操作可以直接接地省一根IO。如果RW接地了但代码里还在控制它可能导致显示异常。5.2 51单片机TMOD0x20的定时器配置细节51单片机相关的搜索词里TMOD 0x20出现频率特别高。这个赋值的作用是定时器1工作在方式2即8位自动重装模式。0x20二进制是0010 0000高四位控制定时器1M11、M00所以是方式2C/T0表示工作在定时器模式GATE0表示不受外部引脚控制。方式2的特点是TH1保存初值TL1做计数TL1计满溢出后自动把TH1的值重新装入TL1不需要软件重装所以定时很准。这个模式做波特率发生器特别方便经典51常用它配合串口1工作。波特率计算公式是波特率 (晶振频率 / 12) / (256 - TH1) / 32当串口工作在方式1或方式3时波特率直接由定时器1的溢出率除以32决定。比如常用的9600波特率、12MHz晶振9600 (12000000 / 12) / (256 - TH1) / 32 256 - TH1 (1000000 * 32) / 9600 ≈ 3333算出来是负的说明12MHz晶振配9600波特率误差很大。这时候要么换11.0592MHz晶振要么换波特率。这也是为什么很多51开发板都用11.0592MHz晶振——它就是为了让串口波特率是整数才选的频率。很多初学者不知道这点拿着12MHz晶振死磕9600波特率结果串口收到的全是乱码。5.3 烧录失败的典型场景搜索词里“单片机下载失败”也是高频词结合我用STC单片机下载的经验绝大多数情况不是代码问题而是环境问题。第一个坑是驱动没装好。STC下载用的是USB转串口芯片常见的有CH340、CP2102这些。Windows10以上一般能自动识别但如果你用的是老开发板装的驱动版本太旧就可能导致设备管理里出现黄色感叹号。我的建议是去芯片官网直接下载最新驱动别用驱动精灵那些第三方工具那些工具装的驱动经常带一堆莫名其妙的后台程序。第二个坑是下载软件占用了串口。STC的下载工具STC-ISP打开时如果别的软件比如串口调试助手还占着那个串口下载必然失败。这个看起来简单但经常被忽略。第三个坑是复位时序。STC单片机下载时需要先冷启动断电再上电才能进入ISP模式。如果你点了“下载”按钮但没有给板子断电程序就一直在等待状态最后提示超时。解决办法是先点下载再给板子重新上电等软件提示“正在检测目标单片机”这时候才上电。第四个坑是电平不匹配。有些板子的USB转串口芯片输出3.3V电平但单片机是5V供电如果不做电平转换下载成功率极低。这在老51板子上尤其常见。检查办法是用万用表测一下串口输出的高电平是不是接近5V如果差得太多中间就得加电平转换电路。5.4 C写单片机的几个独特大坑最后集中说说C语言层面在单片机上容易踩的坑这些坑我在C语言时代从来没遇到过所以特别值得记下来。坑一全局对象的构造时机问题。C全局对象在main()之前构造。但单片机的启动文件和操作系统不一样有些MCU平台的启动文件根本不调用全局对象构造函数这意味着你的全局对象可能没有被正确初始化就被使用了。这个问题不一定会立刻报错有时候表现出来就是某个变量初始值不对、某个引脚状态异常。解决方法是要么在main()里手动调用构造函数用placement new要么干脆不做全局对象都在main()里通过局部变量创建。坑二把volatile给忘了。前面说过寄存器地址和中断里共享的变量必须加volatile。在C里有个更隐蔽的情况你写了一个类成员变量本身是volatile但你把this指针转成了普通指针或者通过非volatile的引用来操作它编译器照样会优化掉。这个排查起来非常费劲因为代码看一眼总觉得没问题。坑三new的隐式调用。有些STL头文件、字符串类内部会调用new即使你自己没用new代码里也可能在偷偷分配堆内存。比如很多版本的std::string在字符串超过小字符串优化SSO阈值时会堆分配。裸机项目里如果用了这些程序跑一段时间就会出现内存耗尽。所以裸机C建议直接加编译参数-fno-exceptions的同时也可以限制堆空间让编译器在尝试隐式new的时候直接爆错逼你找出问题。坑四类成员函数访问寄存器时的const安全。如果你的成员函数被声明为const函数内部就不能修改类的成员变量。但寄存器地址本来就是指针常量它指向的值是可变的编译器可能会认为“这个寄存器地址是常量所以不会变”从而把读操作优化掉。解决办法是寄存器指针成员一律声明为volatile指针或者用mutable修饰相关成员变量。我在实际项目中踩过最惨的坑是第一个一个全局的DHT11对象构造函数里已经把引脚方向配置好了但在我的STM32上电后第一次进入main()时寄存器还是复位状态初始化全白做了。后来在main()开头又调用了一次init()才恢复正常。查了两三个小时最后靠看启动文件源码才明白——短小的启动文件里压根没有__libc_init_array这种C全局构造器调用。最后再说两句这些项目做完之后我最大的感触是C在单片机上的价值不是让你写出“更高端的代码”而是让工程变得更可控、更可维护。DHT11这个项目如果只用C写几十行也能搞定但一旦后续要加多个传感器、加液晶菜单、加按键交互代码量膨胀速度是惊人的。有了类封装和模板抽象之后每个模块都是独立的、可替换的调试哪一块就看哪一块心里有数多了。另外一个小建议刚开始从C转C的时候别一上来就追求“完美面向对象设计”什么抽象基类、工厂模式往单片机里套大概率会把简单事情搞复杂。先从把一个传感器封装成一个类开始用起来顺手、代码更清晰了再慢慢引入模板、换工具链。单片机C的核心是驾驭它不是炫技。哪怕只是把之前的C工程里最乱的那个模块用类重写一遍你都会对“嵌入式C”这件事有全新的理解。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →