ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++在单片机开发中的工程价值:类封装、继承与模板实战

C++在单片机开发中的工程价值:类封装、继承与模板实战 上一篇文章写完基础用法之后有不少朋友在后台问我C写单片机到底是图个啥源文件改成.cpp、变量名换成驼峰然后呢说句实话我最开始也是这么干的把Keil工程里的main.c改成main.cpp结果除了编译多几个警告之外没有任何区别。后来踩了不少坑才算慢慢摸清楚——C在单片机上的价值根本不在语法层面而在工程组织层面。如果你已经在用51、STC或者STM32做项目正纠结要不要从C迁移到C或者已经迁了但总觉得写得像“C版的C”那这篇文章值得看完。这一篇我会重点讲几个真正能体现C优势的场景外设驱动的类封装、多传感器场景下的继承与虚函数、模板在资源受限环境下的裁剪用法以及8位机到底值不值得上C。所有代码都是我在实际项目里跑过的编译环境以Keil和arm-none-eabi-gcc为主。1. 单片机上的C和桌面C特性取舍与编译器现实1.1 先搞清楚你的编译器到底支持什么很多人一听说“单片机用C”第一反应是“那能跑得动吗”实际上这个问题要分两层看你的芯片跑不跑得动和你的编译器支不支持其实是两码事。STM32这类Cortex-M内核的芯片用Keil MDK的ARMCC/AC6编译器或者arm-none-eabi-gcc对C的支持相当完整。C11大部分特性都能用C14、C17也有不错的覆盖率。就拿我用的AC6来说类继承、虚函数、模板、lambda表达式都工作正常连constexpr都能用这在写外设驱动的时候特别方便。但事情没那么简单。51系列走的Keil C51编译器对C的支持几乎可以忽略很多人说能编译.cpp文件但其实是把C语法糖剥掉之后当C来编译真要写类继承、虚函数那一套不是报错就是行为诡异。如果你想在51上体验C比较靠谱的路线是用SDCC它对C的支持要比Keil C51好很多但也不是完整标准很多现代特性仍然不支持。还有一类是STC8系列这类增强型8051核芯片厂商提供的SDK基本都是C语言但你要是用SDCC编译一样能上C。不过要有个心理准备8位机的RAM和Flash就那几十KBC带来的代码膨胀可能让256字节的内部RAM瞬间捉襟见肘。所以第一件事先去看自己的编译器文档确认支持哪些C特性再决定怎么写代码。别跟我当初一样兴致勃勃写完一个大项目结果换了编译器之后编译通过率低得让人想砸电脑。1.2 能用的和必须禁用的C特性清单根据我这些年的实测可以给你一个比较通用的特性取舍表尤其是用ARM核芯片的场景特性是否推荐原因类与封装强烈推荐把外设驱动和数据模型绑定可读性大幅提升继承推荐适合传感器抽象、设备抽象但注意层级不要过深虚函数谨慎使用每个对象多4字节的虚表指针间接调用有开销重载推荐显示函数、数学函数重载非常实用模板推荐但需控制避免实例化过多导致代码膨胀异常处理禁止开启后代码体积暴增单片机根本玩不动RTTI禁止运行时类型识别开销巨大基本用不上动态内存new/delete尽量避免内存碎片问题在裸机上调试起来很痛苦STL容器禁止内存分配不确定实时性没保证这张表不是我拍脑袋写的是拿STM32F103跑实际项目验证过的。开异常的时候Flash占用能多个十几KB对一个128KB Flash的芯片来说直接砍掉15%代价实在太大。1.3 为什么“内联”是单片机C性能的关键C在单片机上的性能瓶颈不在函数调用本身而在你是否理解编译器的内联决策。类封装如果写得好大量短小的方法会直接被内联展开编译后的机器码跟手写寄存器操作几乎没有区别。比如下面这个GPIO控制类的写法class GpioPin { private: uint32_t volatile *port; uint16_t pin; public: GpioPin(uint32_t volatile *base, uint16_t pinNum) : port(base), pin(pinNum) {} void high() { *port | pin; } void low() { *port ~pin; } void toggle() { *port ^ pin; } };这个类里的三个方法声明在类定义内部编译器默认把它们视为inline。如果你把高电平、低电平、翻转这三个操作频繁用在某个主循环里最终生成的代码和直接用位运算控制寄存器没有任何区别但代码的语义清晰了不止一个量级。我见过很多朋友写类的时候喜欢把方法定义放到.cpp文件里这在桌面开发没问题但在单片机上就要注意了不是所有编译器都会做跨编译单元的内联。我的建议是短方法放头文件里定义长方法才放.cpp别让编译器替你做那些它不一定愿意做的优化。2. 用类封装DHT11和LCD1602驱动从寄存器到对象的重构思路2.1 为什么普通结构体封装不够用讨论C优势的时候很多人会说“C语言也可以用结构体封装外设呀”。没错C语言确实可以用结构体函数指针模拟出面向对象的效果但有个很别扭的地方——你必须手动维护每个实例的上下文。以DHT11温湿度传感器为例C语言驱动的常态写法是typedef struct { uint8_t pin; uint8_t temp; uint8_t humi; uint32_t lastReadTime; } Dht11; void Dht11_Read(Dht11 *dht) { // 操作逻辑 }每次调用都要传指针而且没有访问控制工程里谁都能直接修改dht.pin。C的类则可以把引脚、时序细节、数据缓存都封装成private成员外部代码只能通过公开接口去读温度湿度底层逻辑完全隔离。这种封装在项目规模小的时候感觉不出差别一旦你的系统里有二三十个模块每个模块都裸奔着全局变量设计变更的时候会有一种“改一行坏三处”的窒息感。2.2 DHT11驱动类的完整设计流程DHT11这个传感器很有意思它走的是单总线协议对时序要求很严格而且数据是从低位开始一位一位读出来的。如果每次读取都重新写一遍时序逻辑代码会非常冗余而且很容易在多个调用点之间出现细节不一致。我重构之后的DHT11类是这样设计的class Dht11 { private: uint8_t pin; float temp; float humi; uint32_t lastTick; bool reset_bus(); uint8_t read_byte(); bool read_frame(); public: Dht11(uint8_t pinNum); bool update(); // 读取一次数据成功返回true float getTemperature() { return temp; } float getHumidity() { return humi; } };关键设计思路在接口粒度update()一次性完成整帧读取内部自己处理时序和校验外部调用者不用关心DHT11协议的任何细节。这在主循环里调用起来特别清爽Dht11 dht(P2_0); while (1) { if (dht.update()) { printf(temp%.1f humi%.1f\r\n, dht.getTemperature(), dht.getHumidity()); } else { printf(read failed\r\n); } }这里要说一个踩坑经验DHT11的最大采样周期是500毫秒如果你更新频率太高传感器会直接不理你。所以update()里最好做一个时间门槛判断我就在里面存了一个lastTick成员更新时间不足300毫秒就返回上一次的数据避免误操作。还有一个值得注意的点DHT11的时序是微秒级的在51上用软件延时能勉强控制但换成STM32之后如果你的延时函数没有适配主频很容易读出来的全是0xFF。我的做法是把微秒延时函数作为弱函数实现然后在项目里根据主频单独重新实现一遍。2.3 LCD1602类封装把可视化输出变成简单的对象调用LCD1602是那个时代绕不开的显示器件它有几个让人觉得繁琐的地方初始化要发一串命令、显示字符要先设地址、清屏要等待一段时间。用面向对象的方式封装这些能让你在业务逻辑里直接写“第0行第0列显示温度”这种直白的话。一个简洁的LCD1602类设计class Lcd1602 { private: void write_cmd(uint8_t cmd); void write_data(uint8_t data); void busy_wait(); public: Lcd1602(); // 构造函数里完成初始化 void clear(); void setCursor(uint8_t row, uint8_t col); void print(const char *str); void print(int num); void print(float num); };print函数做成重载版本以后顶层逻辑能写成这样Lcd1602 lcd; lcd.clear(); lcd.setCursor(0, 0); lcd.print(Temp:); lcd.print(dht.getTemperature());看着是不是有点像Arduino的写法了这其实就是面向对象封装带来的直接好处——业务层的代码只描述“我要干什么”不描述“底层怎么干”。setCursor和print的重载还有个好处你不需要在业务层反复用sprintf去格式化字符串类内部自己处理整数转字符串和浮点转字符串的细节减少出错的机会。这里必须提醒一个容易踩的大坑LCD1602初始化之后有些屏要等一段时间才能接收第一条指令通常是15到30毫秒。如果你的构造函数里不等待初始化命令就会被丢掉结果就是屏幕亮着但是不显字。这个坑在51上特别隐蔽因为很多51开发板主频慢指令执行本身耗时长碰巧就躲过去了换到STM32以后主频上来了问题立刻暴露。另外LCD1602在8线模式下数据线和控制线要占用11个GPIO引脚对引脚紧张的项目可以用4线模式类里只要打包一个构造函数参数区分模式就行。我一直建议用4线模式省下来的7个引脚可以做别的事。3. 继承与虚函数多传感器轮询和事件状态机的实现代价3.1 用一个抽象基类统一管理不同的传感器单个传感器的类封装只是入门C真正厉害的是在多传感器场景下的抽象能力。比如项目里同时用到DHT11、DS18B20、SHT30三个温度传感器每个传感器的接口协议完全不同但它们对上层提供的能力是一样的读温度。在C语言里你通常要写三个独立的驱动头文件三个不同的函数命名上层的业务代码里写一堆if分支去调用不同传感器。而在C里可以定义一个抽象基类class TemperatureSensor { public: virtual bool read(float temp) 0; virtual const char *name() 0; };然后DHT11、DS18B20、SHT30各自的类都继承这个基类并实现自己的read()函数class Dht11Sensor : public TemperatureSensor { Dht11 dht; public: Dht11Sensor(uint8_t pin) : dht(pin) {} bool read(float temp) override { if (dht.update()) { temp dht.getTemperature(); return true; } return false; } const char *name() override { return DHT11; } };这时候上层调度代码可以写成一个统一的轮询管理器TemperatureSensor *sensors[] { new Dht11Sensor(P2_0), new Ds18b20Sensor(P3_5), new Sht30Sensor(I2C_ADDR) }; for (auto sensor : sensors) { float temp; if (sensor-read(temp)) { printf(%s: %.1f\r\n, sensor-name(), temp); } }以后要增加新的传感器类型只需要写一个新的派生类塞进sensors数组上层逻辑完全不用动。这就是“开闭原则”的实际收益——对扩展开放对修改关闭。项目维护到后期这种特性带来的省心程度只有自己经历过才能体会。3.2 虚函数表的代价到底有多大有朋友会担心虚函数性能问题这个担心部分是对的。每个包含虚函数的类实例化之后会自动携带一个隐藏的vptr指针虚表指针指向类的虚函数表。在32位MCU上这个指针占4字节虚函数表本身存在Flash里每个虚函数占一个表项通常也是4字节。计算一下假设有两个派生类每个类有2个虚函数那么虚函数表大概要占16字节Flash两个对象各占4字节RAM的vptr。对STM32来说这连零头都算不上。再算上调用虚函数时是间接跳转比直接调用慢大概几条指令对温度采集这种本身就是毫秒级操作的任务来说毫无影响。但你要是在51这种1K Flash都抠抠搜搜的老平台上玩这套那确实得掂量掂量所以我一直强调虚函数是ARM核单片机的舒适区不是8位机的游乐场。我实际项目里的经验是虚函数最适合的地方是“横跨多个设备的统一管理”而不是每一层的细碎函数都搞虚函数。能用普通函数解决的问题就老老实实用普通函数抽象太多也是一种技术债。3.3 虚函数驱动的按键状态机告别一团乱麻的switch-case按键扫描基本上是每个单片机项目的标配功能。C语言的经典写法是switch-case在case里判断当前状态和按键动作然后跳转到下一个状态。三四层嵌套还能忍等你要支持短按、长按、双击、连发这些花样功能之后switch-case就变成了意大利面条。用虚函数写状态机思路完全不同。把每个按键状态定义成一个类共有接口就是handleEvent()返回下一个状态对象指针class KeyState { public: virtual KeyState *handleEvent(KeyEvent evt, uint16_t duration) 0; }; class IdleState : public KeyState { public: KeyState *handleEvent(KeyEvent evt, uint16_t duration) override; }; class PressedState : public KeyState { public: KeyState *handleEvent(KeyEvent evt, uint16_t duration) override; };每个状态类里面只处理自己状态下的事件逻辑不需要关心别的状态内部是怎么实现的。这样代码的局部性非常好往后扩展新状态只需要再加一个类改main里的状态初始化即可。不过我真心建议按键扫描这种简单逻辑别急着上状态机除非你的项目确实有复杂交互需求。我见过有人写个单按键点灯都要用状态模式那纯属杀鸡用牛刀。判断标准很简单——如果这个逻辑用switch-case不超过100行那就用switch-case超过100行并且状态之间的跳转关系复杂再用面向对象的状态机重构。4. 模板在资源受限环境的设计准则以滤波和环形缓冲为例4.1 模板函数做数学工具限幅、映射、滑动平均模板在桌面开发里是重武器在单片机上玩模板需要更小心但并非完全不能用。一些纯数学工具函数用模板写能兼顾类型安全和极小的代码开销。比如限幅函数需求是对任意数值类型做边界裁剪。用宏写容易有副作用问题用普通函数要写好几个重载版本而模板只需要一份代码templatetypename T T clamp(T value, T minVal, T maxVal) { if (value minVal) return minVal; if (value maxVal) return maxVal; return value; }调用的时候想用int用int想用float用floatuint16_t duty clampuint16_t(setDuty, 0, 1000); float temp clampfloat(value, -20.0f, 80.0f);因为模板是编译期展开的编译器实际上会为每种类型生成一份独立代码但每份代码都是最优化的直接比较和跳转运行效率跟手写C没有区别。还有一个经典工具模板是滑动平均滤波。我项目里常用的是“去极值平均滤波”——去掉最大最小值后取平均模板写法如下templatetypename T, uint8_t N T smoothFilter(T data) { static T buf[N]; static uint8_t idx 0; static bool full false; buf[idx] data; idx (idx 1) % N; if (idx 0) full true; if (!full idx 0) return data; T sum 0, minVal buf[0], maxVal buf[0]; for (uint8_t i 0; i (full ? N : idx); i) { sum buf[i]; if (buf[i] minVal) minVal buf[i]; if (buf[i] maxVal) maxVal buf[i]; } uint8_t count (full ? N : idx); if (count 2) return data; return (sum - minVal - maxVal) / (T)(count - 2); }N作为模板参数传入意味着每一组滤波缓冲区的大小在编译期就固定好了不需要动态分配内存运行期也没有循环边界检查的额外开销。这里要注意static局部变量在类的不同实例之间是共享的所以如果两个传感器各自需要一个独立的滤波缓冲区别用这种静态局部变量写法应该把buf作为类的成员变量。4.2 模板类实现环形缓冲区编译期确定大小是关键环形缓冲区在串口接收、ADC采样缓存场景下是绝对的主力数据结构。C模板类可以完美适配这种“缓冲区大小编译期已知”的需求templatetypename T, uint16_t SIZE class RingBuffer { public: bool push(T item) { if ((head 1) % SIZE tail) return false; // 满了 data[head] item; head (head 1) % SIZE; return true; } bool pop(T item) { if (head tail) return false; // 空了 item data[tail]; tail (tail 1) % SIZE; return true; } bool empty() { return head tail; } private: T data[SIZE]; volatile uint16_t head; volatile uint16_t tail; };这里的SIZE是模板参数在编译器就被固化成具体数值比如RingBufferuint8_t, 256。它的好处是不同的缓冲区实例拥有完全独立的存储空间不会出现static变量共享的问题每个实例的大小在编译期就能算清楚。需要注意一个细节在中断服务函数和主循环之间共享这个缓冲区时head和tail记得加volatile修饰。push操作写入headpop操作读取tail如果中断里push主循环里pop不加volatile会出现一个极端情况——主循环优化后反复读取寄存器里的旧tail值导致数据错乱。这个坑我曾经定位了整整一天最后发现是优化选项级别提高之后编译器做了激进的缓存volatile一加上就正常了。模板也有个非常现实的副作用——代码膨胀。每当你用不同的模板参数实例化一次编译器就会生成一份完整的代码副本。比如RingBufferuint8_t, 128和RingBufferuint8_t, 256是两个完全不同的类型会生成两份几乎相同的代码。所以在单片机上模板参数最好控制在少数几组固定的数值不要今天16明天32后天64地换来换去。5. 8位机要不要用C51/STC与ARM架构的真实取舍5.1 51单片机写C值不值得这个问题我被人问过太多次每次我的回答都差不多要看你的具体场景。如果只是做一个简单的入侵报警器MCU就负责读几个IO、控制一个蜂鸣器代码量总共300行那老老实实用C语言别折腾C。工具选型要考虑工程总成本小项目里的代码可维护性压力根本不存在C的抽象反而显得多余。但如果你的51单片机项目因为逻辑复杂已经开始失控了比如做一个带菜单导航、参数保存、多级密码校验的温控器代码量直逼两千行那C确实能帮你理清思路。用类把菜单项、按键事件、数据存储各自封装起来每个模块之间的接口变得清晰而不是一坨main.c里一万个全局变量纠缠不清。具体到51环境的技术前提是用SDCC而不是Keil C51。SDCC对C的支持虽然只到标准的某个子集但类、继承、成员函数这些都有对绝大多数外设驱动封装场景已经够用了。Keil C51对C的处理方式更像一个语法层面的“模拟”正式项目里我不推荐踩这个坑。5.2 从代码密度角度聊聊C是否会导致Flash不够用这也是高频问题。C代码是否比C代码占用更多Flash答案是“不一定但大概率会多一些”。类封装和普通结构体函数调用相比正常情况下也就是多一点点函数名修饰开销虚函数表会占一定Flash模板会在每种参数组合上复制代码增加Flash使用。但有个容易被忽略的反方向面向对象设计之后很多重复的代码被收拢到公共类中整体代码量反而下降了。我有个实际案例温度采集显示报警的项目C实现约6.8KB FlashC重构后约7.4KB Flash多了不到10%。但业务层代码从500行降到了250行维护成本大幅降低。所以Flash增加不可怕可怕的是业务复杂度带来的时间成本。如果你用的是STM32F103C8T6这种64KB Flash的芯片这点增加根本不叫事。用51的话就要精打细算了毕竟Flash按KB计算的时代多10%有时候真会超容量。5.3 新唐、GD32这些ARM核芯片C其实是舒适区如果你的芯片列表里有新唐的M0系列或者GD32、AT32等国产ARM核那C的适用性要再上一个档次。这些芯片的Flash普遍在32KB以上RAM也有8KB起步运行一个完整C工程完全没问题。在这个环境下我甚至推荐更激进的用法把外设配置信息也写成对象在类初始化时直接配置好寄存器构造一个外设对象就等于完成了一次初始化省去单独写一堆初始化函数。比如SPI接口可以这样设计class SpiMaster { private: SPI_TypeDef *spi; uint32_t baudrate; public: SpiMaster(SPI_TypeDef *reg, uint32_t baud, uint8_t polarity, uint8_t phase); void init(); uint8_t transfer(uint8_t byte); void select_ss(bool level); };构造对象时传参init里面把寄存器配置逻辑完全隔离。在实际项目中这种写法让板级初始化代码变得非常直观新人接手也更容易上手。5.4 开发环境配置VS Code里怎么优雅地编译单片机C工程开发环境也是个绕不开的话题。Keil MDK当然可以直接编译C但它的代码编辑器实在谈不上好用。我现在的习惯是用VS Code写代码Keil或者arm-none-eabi-gcc做编译调试。VS Code里配置C项目的核心是c_cpp_properties.json和tasks.json。一个基础的配置长这样{ configurations: [ { name: ARM-MCU, includePath: [ ${workspaceFolder}/Core/Inc, ${workspaceFolder}/Drivers/STM32F1xx_HAL_Driver/Inc ], defines: [ STM32F103xB, USE_HAL_DRIVER ], compilerPath: C:/arm-gcc/bin/arm-none-eabi-g.exe, cStandard: c11, cppStandard: c14, intelliSenseMode: gcc-arm } ], version: 4 }注意这里把编译器路径指向了arm-none-eabi-g.exe而不是gcc.exe这样VS Code的IntelliSense才能正确解析C语法。如果是Keil工程compilerPath里填AC6的编译器路径也可以比如armclang.exe。对新手朋友我的建议是先把Keil跑通再折腾VS Code别一开始就叠加环境复杂度。等你在Keil里理解了整个工程的编译、下载、调试流程之后再迁到VS Code会发现非常顺滑。6. C工程落地的经典坑从构造顺序到中断安全的细节6.1 全局对象构造顺序为什么你的类成员没有初始化这是我见过最阴的坑之一而且检索资料特别困难。在桌面C里全局对象的构造函数会在main()之前自动执行这是运行时库的事。但在单片机裸机环境中启动文件startup_xxx.s或者运行时库不一定帮你做了“全局对象构造”这步工作。具体来说C编译器会生成一个名为__libc_init_array或者__init_array之类的初始化段需要额外代码去遍历这些段调用全局对象的构造函数。如果启动文件没有包含这段逻辑那全局对象的构造函数根本不会被调用。症状非常诡异你在全局定义了一个类对象构造函数里初始化了一个成员变量结果运行时成员变量的值完全是随机的。解决方案我总结三条路线查启动链接脚本和启动文件确认有调用__libc_init_array。ARM GCC工具链的默认启动文件里有这个但某些剪裁版可能省略了。尽量不用全局对象 构造函数依赖的模式。把初始化逻辑放到显式调用init()函数里而不是依赖构造函数。如果非要依赖全局对象可以在main()最开始手动调用构造函数用placement new的方式// 分配一块静态存储区 uint8_t mem[sizeof(MyClass)] __attribute__((aligned(4))); MyClass *obj new (mem) MyClass();这段代码的工作原理很直白在静态区划一块内存用placement new在指定地址构造对象。由于地址是我们自己控制的构造时机也是我们自己控制的不再依赖运行时库的行为。6.2 中断服务函数里调用类方法有什么注意事项单片机项目绕不开中断。用C写ISR中断服务函数有一个很舒服的点ISR可以是一个静态成员函数或者游离的类成员代码组织更清晰。但有几个雷区必须避开。先说一个最基本的规则不要在中断里new和delete。中断里做动态内存分配万一内存碎片导致分配失败你连报错都无从谈起。中断的职责应该是把数据快速搬移到缓冲区真正处理数据的逻辑放到主循环里。第二个规则中断里调用类方法时确保该方法内部没有阻塞操作。比如你的UART类里有一个发送字符串的函数内部用for循环等待发送完成这个函数如果在中断里被调用等待时间会被拉得很长严重影响中断响应。我项目里的做法是中断里只做能立刻返回的操作——往寄存器写数据、设置标志位、往RingBuffer里压数据其他都推迟到主循环。第三个规则不要在主循环和中断中同时调用同一个类的非线程安全方法。比如你的显示类有print函数主循环每100ms调用一次同时串口中断也要更新显示两个地方同时操作类的内部缓存会出现数据错乱。需要做临界区保护或者干脆用一个队列把显示请求串行化。6.3 字符串处理std::string别想用那用什么替代桌面C里玩转std::string是基本功但单片机裸机上std::string是个彻头彻尾的祸害。因为它默认要动态分配内存底层使用堆在中断环境或内存碎片场景下非常危险。替代方案其实很成熟用固定大小的字符数组。C的std::arraychar, N和C风格char[N]都行但配套要写一些裁剪过的工具函数。我常用的是一套轻量字符串工具templateuint8_t N void strCopy(char (dest)[N], const char *src) { uint8_t i 0; while (src[i] i N - 1) { dest[i] src[i]; i; } dest[i] 0; } templateuint8_t N void strAppend(char (dest)[N], const char *src) { uint8_t i 0; while (dest[i] i N - 1) i; uint8_t j 0; while (src[j] i N - 1) { dest[i] src[j]; i; j; } dest[i] 0; }基本原理就是目标数组容量是模板参数编译期能确定数组边界运行期的越界风险被模板参数隐式消除。我用这套工具在STM32上拼装串口协议包从未出过缓冲区溢出问题。6.4 上位机联合调试C#调用C的access violation问题排查单片机项目做到一半你大概率要写个上位机或者用现成的调试工具来通信。热搜词里那个“c#调用c出现access violation c0000005”其实是好多人都会踩的坑我根据自己的经历说说。表面现象你写了一个C的DLL解析单片机串口发来的数据包C#上位机调用这个DLL接口某一刻程序直接崩掉报access violation。排查思路要简单清晰第一步把C#侧的结构体定义和C侧的结构体定义逐字段对照重点检查字节对齐。C默认是8字节对齐C#默认也是8字节对齐但一旦一个用了#pragma pack()另一个没用数据错位就开始了最终解析出一个野指针访问崩溃。第二步检查数据包长度。单片机串口数据包长度定义不正确发过来的数据比你C结构体预期的少几个字节读到了越界的地址百分百要崩。第三步检查字符串处理方式。如果你的C#传进来的是string类型DLL的接口参数却是char*你会经常看到指针错乱的崩溃。更稳的方式是C#先转成byte[]再传到C里转成char*。排查路径理清楚之后实际问题往往半小时就能定位。这里想强调的是上位机和单片机之间的通信协议永远要在设计阶段就明确每个字段的类型、长度和对齐规则别等到联调阶段在崩溃日志里猜来猜去。6.5 关于new/delete的正确替代方案虽然前面说了避免动态内存分配但完全不用new也有点极端。现实的做法是在系统启动阶段统一做一次性的内存分配之后运行期间不再释放。比如你要管理多个传感器对象可以这样TemperatureSensor *sensors[3]; void init_sensors() { sensors[0] new Dht11Sensor(P2_0); sensors[1] new Ds18b20Sensor(P3_5); sensors[2] new Sht30Sensor(I2C_ADDR); }这套做法在启动时一口气把内存分配完之后整个运行周期内没有分配释放操作就不会有碎片问题。而且这些对象是长期存在的不存在泄漏。这种“启动即定案”的模式是我在裸机项目里最推荐的动态内存使用方式。如果你确实需要运行期反复申请释放那就得自己写内存池了。这也是模板的一个用武之地实现一个固定容量的内存池对象在启动时从静态区拿一块空间运行期只在这个池子里分配避免直接跟堆打交道。写在最后我从C语言转C写单片机最初纯粹是被“变量的作用域”“类的抽象”这些概念吸引。真正跑完两三个完整项目之后才意识到C在单片机上的价值不是花哨语法而是让中大型裸机项目变得可控。如果项目代码只有几百行优先用C如果项目已经复杂到你自己都常常忘了某个全局变量在哪改的那考虑C它虽然不能解决所有问题但至少能让代码的组织方式清晰一些。最后分享一个小技巧如果你决定在ARM核芯片上认真用C强烈建议在编译选项里打开“-fno-exceptions -fno-rtti”这两个开关。它们能直接砍掉一大块无用代码还能让编译器在遇到异常相关语法时报错提醒你防止误用。实际运行效果会非常干净。下一个阶段我计划聊聊“C在单片机的应用三”主题大概率是事件驱动框架和任务调度——当项目里同时有多个传感器、显示、通信任务需要协同工作时怎么在裸机上做一个轻量级的状态机调度器。到时候欢迎继续来讨论。
返回列表