尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

单片机C++实战:从类封装到内存优化的嵌入式开发指南

单片机C++实战:从类封装到内存优化的嵌入式开发指南 上一期我把C和单片机这门“姻缘”的大框架讲了一遍很多人私下问我最多的就是“既然能用为什么我写了第一个类编译完Flash直接多出好几KB”“new能不能用”以及“中断里能不能调用成员函数”。说实话这些问题是真实开发里最磨人的细节也是教科书几乎不会告诉你的部分。这一期我就纯粹从实际干活的角度把C在单片机上真正落地时用到的工程配置、类封装思路、内存取舍、编译优化和问题排查全部走一遍尽量用我自己的项目经历来还原少讲空话多讲操作。先说结论C在单片机上能不能用从来不是语言先进性的问题而是你的工具链、项目规模和内存预算合不合适的问题。这一篇我按“判断-搭建-落地-优化-实战-排查”的线索展开全文会围绕一套我实际做过的温控器项目讲涉及ARM Cortex-M平台以STM32F103C8T6为例和GCC工具链顺带提Keil AC6因为这两类组合基本覆盖了绝大多数“C上单片机”的真实场景。1. 为什么要在单片机上用C先做一道判断题1.1 别把C当成万能锤三种不建议硬上的场景我见过不少帖子把C吹得天花乱坠好像把工程改成C马上就能提升一个档次。但实际做久了会发现C并不是所谓“更高级”的C它是一套封装、抽象和复用机制背后需要编译器、链接器、运行时库三方面配合。所以先泼一盆冷水下面三种情况我真心不建议硬上。第一种是芯片资源太小的场景。比如STC89C52这种8051内核Flash通常8KB以内、RAM只有256字节左右。这里首要问题是编译器本身对C的支持就不完整Keil C51编译器对C特性支持很弱硬件资源也不够承载类和虚函数带来的开销。硬上C很容易出现一个HelloWorld占掉一半Flash的尴尬。反过来在STM32F103C8T6这类Cortex-M3上64KB Flash、20KB RAM用C就完全在预算之内。所以第一步永远是看芯片。第二种是团队协作边界不清晰的场景。如果团队里老一辈工程师全都是纯C出身而且单位代码规范要求“任何人都能接手任何模块”那引入C就要掂量一下。我自己见过一个本来用C写得好好的模块后来交接给不熟悉C的同事他直接删掉析构函数导致GPIO没有释放整个产品间歇性失灵。最后查了三天才发现问题。不是说C不好而是说这种“抽象”是需要整个团队理解和遵守的不是一个人爽了就行。第三种是代码逻辑极简、生命周期只有几个月的项目。比如一些比赛用的临时demo、毕设验证板总共几百行代码全是读写IO和延时。这种项目用C和C差别不大但C的编译配置还要多花半小时。为了表示而表示毫无意义。1.2 换C后“真香”的项目赢在哪里什么情况下C能发挥真正价值我的经验是这样的当你的项目逻辑复杂度开始超过“点灯、读按键、延时”这个级别并且模块之间存在明显的“状态”和“行为”绑定C的优势立刻显现。举例我做过一个温控器需求是按键调节目标温度、OLED实时显示、PID算法计算加热棒占空比、断电保存参数、带一个简单的状态机在上电自检、运行、待机、报警之间切换。用C写的话每个模块的状态变量、回调函数、数据结构全都要靠文件级全局变量和函数指针硬撑着。代码写得多了之后一个全局变量被谁改了根本查不出来。用C之后每个模块就是一个类状态是类的成员变量行为是成员函数外部根本碰不到内部状态。按键模块只管上报“短按”“长按”“双击”PID模块只管算输出显示模块只负责渲染。模块之间拿着接口互调用可读性完全是两个档次。关键是这种抽象带来的维护成本下降比那几KB Flash更重要。因为单片机产品的开发和维护大头一直不在编译大小而在排查bug的时间差。所以我的判断标准很简单代码量超过几千行或者有3个以上独立模块或者需要稳定的接口约定就值得上C。小于这个规模用C反而更省心。1.3 编译器选型新手踩得最狠的坑确定了上C下一个坎就是编译工具链。很多新手拿着Keil MDK直接写一个.cpp文件结果链接报错一堆然后得出“C不兼容单片机”的结论。问题不在语言在于很多老教程用的Keil AC5armcc这个老编译器对C11以上的支持相当有限而且错误信息晦涩。如果你用Keil从AC5切换到AC6armclang是必须的动作。AC6底层是Clang对C标准的支持很完整用起来比AC5顺滑得多。切换方法也不复杂在“Options for Target - Target”里把Compiler version改成Arm Compiler 6然后重新编译大部分代码无需改动就能过。如果你喜欢开源路线我强烈建议用ARM GCC工具链也就是arm-none-eabi-gcc包编译C文件时用arm-none-eabi-g命令。STM32CubeIDE集成了全套工具链或者你用VS Code配好cross-compile环境也完全可行。GCC工具链的好处是对C标准的支持最好优化选项透明链接map文件清晰出了问题你能一路查到手册。缺点是配置工程比Keil麻烦一点但磨合好之后效率很高。2. 工程搭建与基础配置把编译链路跑通2.1 三种常见建工程的方式Makefile、PlatformIO、KeilC工程和C工程最大的区别其实是“你用哪个编译器处理.cpp文件”。C工程里编译器默认对.c做C编译遇到.cpp需要明确告诉构建系统用C规则。我推荐三种方式任选其一。第一种是Makefile手动管理。这是最直接、最推荐学习的方式因为你能看到所有细节。核心就是把编译命令从arm-none-eabi-gcc换成arm-none-eabi-gCC arm-none-eabi-gcc CXX arm-none-eabi-g OBJCOPY arm-none-eabi-objcopy SIZE arm-none-eabi-size CFLAGS -mcpucortex-m3 -mthumb -Wall -Os -ffunction-sections -fdata-sections CXXFLAGS $(CFLAGS) -fno-exceptions -fno-rtti -stdc14 LDFLAGS -Tstm32f103c8t6.ld -Wl,--gc-sections SRCS_C $(wildcard Core/*.c Drivers/*.c) SRCS_CPP $(wildcard App/*.cpp Drivers/*.cpp) OBJS $(SRCS_C:.c.o) $(SRCS_CPP:.cpp.o) all: firmware.elf %.o: %.c $(CC) $(CFLAGS) -c $ -o $ %.o: %.cpp $(CXX) $(CXXFLAGS) -c $ -o $ firmware.elf: $(OBJS) $(CXX) $(LDFLAGS) $^ -o $ $(OBJCOPY) -O ihex $ firmware.hex $(SIZE) $关键点就两个编译C文件时用$(CXX)链接整个工程时也用$(CXX)而不是gcc。很多人的链接错误就出在这里——用gcc做链接C运行时库没有自动加进去于是报一堆“undefined reference to __cxa_pure_virtual”这类符号找不到的错误。第二种是PlatformIO。它初始支持Arduino框架但也可以做纯裸机工程。在platformio.ini里指定board、framework、monitor端口它会自动调GCC工具链并且底层用C编译。如果新项目不想从零写构建脚本用PlatformIO最省事。唯一要注意的是它默认会加入Arduino的框架代码如果你是标准库裸机开发要在配置里写明build_flags和lib_ldf_mode否则编译出来的固件会多一堆你没用到的内容。第三种是Keil AC6。上面说了把编译器切换到Arm Compiler 6后工程里只要加入.cpp文件Keil会自动识别并启用C编译。但有个小坑Keil默认会把C的异常和运行时类型信息RTTI关掉这其实挺好但如果你在全局定义静态对象需要确认Startup文件里有没有执行“C全局构造”的初始化。AC6默认在Reset_Handler里调用__main而__main会处理C静态对象的构造所以大多数情况下没问题比GCC手写启动文件省心一些。2.2 启动文件与堆栈配置别让静态对象变成哑弹在GCC工具链下C静态对象、全局对象的构造函数必须被调用这个动作发生在main之前由_startup或Reset_Handler里的__libc_init_array()函数完成。如果你直接复刻一个极简启动文件只做了“复制向量表、调用main”那么你的全局对象构造函数永远不执行所有成员变量还是垃圾值。这个问题极其隐蔽因为编译链接都不会报错只有运行到一半才觉得哪哪不对。标准做法是确保启动文件里有类似这样的片段void Reset_Handler(void) { /* 复制 .data、清零 .bss */ ... SystemInit(); __libc_init_array(); /* 关键执行C全局/静态对象的构造函数 */ main(); while (1); }如果你用的是CubeMX生成的工程__libc_init_array()默认已经调用了但如果你从旧模板或极简启动文件起步就要手动确认。堆栈配置也一样重要。C里用堆的地方是new/delete用栈的地方是函数调用和局部对象。启动文件里的Stack_Size和Heap_Size是预分配内存建议Stack_Size0x400到0x1000之间Heap_Size至少0x400。如果局部对象里有容器、std::string、动态分配Heap要更大。这块没法一口气给答案我用下来是RAM 20K的STM32F103Stack开0x800Heap开0x400运行温控器项目很稳。2.3 目录结构与头文件组织建议C工程和C工程在目录结构上最大的区别是建议按“驱动库-中间层-应用层”分层而不是全按硬件外设堆砌。下面是我常用的目录project/ ├── Core/ # 启动文件、系统时钟、中断向量 ├── Drivers/ # 芯片外设驱动GPIO、TIM、UART、I2C │ ├── inc/ │ └── src/ ├── App/ # 应用逻辑层Button、PID、OLED、StateMachine │ ├── inc/ │ └── src/ ├── Middlewares/ # 算法库、协议栈、第三方组件 ├── build/ # 编译输出 └── Makefile头文件组织有一个容易被忽视的问题C头文件里如果包含C语言库声明需要用extern C包起来否则链接阶段会出现符号重名或找不到。最稳妥的做法是#ifdef __cplusplus extern C { #endif #include stm32f1xx_hal.h #ifdef __cplusplus } #endif这个问题我踩过一次HAL头文件在C和C下都能编译但只要你在.cpp里include它所有HAL函数都被C编译器按“名字装饰name mangling”处理于是和C文件里编译出来的符号对不上最后就是一堆链接错误。3. C核心特性在单片机里的落地姿势3.1 类封装寄存器让脏乱的#define下岗裸机C代码里最常见的是这种写法#define LED_PIN GPIO_PIN_5 #define LED_PORT GPIOC void led_init(void) { __HAL_RCC_GPIOC_CLK_ENABLE(); GPIO_InitTypeDef gpio {0}; gpio.Pin LED_PIN; gpio.Mode GPIO_MODE_OUTPUT_PP; gpio.Speed GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(LED_PORT, gpio); } void led_on(void) { HAL_GPIO_WritePin(LED_PORT, LED_PIN, GPIO_PIN_SET); }看两遍还行等外设一多几十个define散落你根本不知道谁被谁用。用C可以这样封装class Led { public: Led(GPIO_TypeDef* port, uint16_t pin, bool active_high true) : port_(port), pin_(pin), active_high_(active_high) { GPIO_InitTypeDef gpio {0}; gpio.Pin pin_; gpio.Mode GPIO_MODE_OUTPUT_PP; gpio.Speed GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(port_, gpio); } void On() { Write(active_high_ ? GPIO_PIN_SET : GPIO_PIN_RESET); } void Off() { Write(active_high_ ? GPIO_PIN_RESET : GPIO_PIN_SET); } void Toggle() { Write(HAL_GPIO_ReadPin(port_, pin_) GPIO_PIN_SET ? GPIO_PIN_RESET : GPIO_PIN_SET); } private: void Write(GPIO_PinState state) { HAL_GPIO_WritePin(port_, pin_, state); } GPIO_TypeDef* port_; uint16_t pin_; bool active_high_; };然后使用起来Led status_led(GPIOC, GPIO_PIN_5, false); // 低电平点亮 status_led.On();好处一眼就能看见新加一个LED只是几行代码引脚号、电平极性都在构造函数里一次性定死外部调用不需要关心寄存器细节。而且析构函数里可以做安全复位避免程序崩溃后外设一直开着——这一点在工业设备里尤其有用。3.2 运算符重载与模板把CPU开销交给编译器C在嵌入式里被诟病最多的就是“抽象带来性能损耗”。其实只要用好模板很多所谓的开销在编译期就被优化掉了CPU反而更轻。拿寄存器位操作举例。传统的HAL接口是函数调用会带参数压栈、出栈优化不好时开销不小。用模板可以在编译期把端口和引脚号直接变成立即数template uint32_t PORT_ADDR, uint16_t PIN class Pin { public: static void SetHigh() { reinterpret_castGPIO_TypeDef*(PORT_ADDR)-BSRR (uint32_t)PIN; } static void SetLow() { reinterpret_castGPIO_TypeDef*(PORT_ADDR)-BSRR ((uint32_t)PIN) 16; } static bool Read() { return (reinterpret_castGPIO_TypeDef*(PORT_ADDR)-IDR PIN) ! 0; } };用的时候using LedPin PinGPIOC_BASE, GPIO_PIN_5; using KeyPin PinGPIOA_BASE, GPIO_PIN_0; LedPin::SetHigh(); if (KeyPin::Read()) { ... }因为所有信息都是编译期常量编译器优化后生成的机器码几乎等同于直接写寄存器甚至比HAL库函数还小。这就是模板的价值把成本从运行期挪到编译期。运算符重载我用的场景不算多但有一个特别好用——在寄存器数组地址映射上。比如SPI或UART的寄存器通常就是一块连续地址你可以写一个轻量寄存器数组类重载[]class RegisterArray { public: RegisterArray(uint32_t base) : base_(base) {} volatile uint32_t operator[](int index) { return *(volatile uint32_t*)(base_ index * 4); } private: uint32_t base_; };然后RegisterArray usart1(USART1_BASE); usart1[0] 0x2000; // 写USART_CR1这种写法在驱动复杂外设DMA描述符、定时器比较通道时特别顺手读代码的人一眼就知道在操作哪个寄存器。3.3 回调机制的三种实现函数指针、std::function、虚函数单片机上经常需要“事件回调”按钮按下触发某函数数据接收完毕触发某函数定时器匹配触发某函数。C语言传统做法是函数指针typedef void (*button_callback_t)(uint8_t key); static button_callback_t callback NULL; void Button_RegisterCallback(button_callback_t cb) { callback cb; }这个办法简单高效但函数指针绑定的只能是普通函数没法绑定对象成员。于是C程序里麻烦来了——成员函数指针和普通函数指针不是一回事不能直接赋值需要一个std::function或者一个包装器。三种方式我全部在项目里用过说下实际感受函数指针效率最高代码最透明但带不了上下文。适合那种“全局只有一个按钮”的简单场景。虚函数最方便表达“多态回调”比如协议解析器有多个派生类每个派生类自己实现OnPacketReceived。缺点是一个虚函数对应一个vtable指针通常4字节如果类数量很多、继承层次复杂Flash和RAM都会吃点亏。std::function最灵活可以绑定lambda、成员函数、仿函数但代价最大。在STM32上一个std::function对象本身就要占24到32字节RAM内部可能触发堆分配还容易让代码体积涨个好几KB。所以我的建议是如果对大小敏感别用std::function而是自己写一个轻量的“回调槽”template typename T class SimpleCallback { public: SimpleCallback() : obj_(nullptr), method_(nullptr) {} template typename U void Bind(U* obj, void (U::*method)(T)) { obj_ static_castvoid*(obj); method_ reinterpret_castMethodPtr(method); } void Invoke(T value) { if (obj_ method_) { (reinterpret_castT*(obj_)-*reinterpret_castvoid (T::*)(T)(method_))(value); } } private: using MethodPtr void (*)(); void* obj_; MethodPtr method_; };虽然这种写法涉及一些reinterpret_cast不优雅但在资源受限的环境里它能在内存占用和灵活性之间取得平衡。一些成熟的嵌入式库比如EventLoop、uSched就广泛使用类似技术。4. 运行时取舍与性能优化4.1 new/delete用还是不用内存池的思路对这个问题的简短回答是能不用就不用一定要用就要有对策。ARM GCC提供的默认operator new最终会落到malloc而malloc需要维护堆元数据在只有十几KB RAM的单片机上频繁动态分配会产生碎片长时间运行后malloc失败的概率越来越高。更好的做法是给自己划一个内存池。比如温控器项目里需要动态创建一批临时字符串但最大数量是已知的就可以用一个固定大小的对象池template typename T, uint16_t POOL_SIZE class ObjectPool { public: ObjectPool() : free_list_(nullptr) { for (uint16_t i 0; i POOL_SIZE; i) { Node* n reinterpret_castNode*(storage_[i * sizeof(T)]); n-next free_list_; free_list_ n; } } T* Allocate() { if (!free_list_) return nullptr; Node* n free_list_; free_list_ n-next; return reinterpret_castT*(n); } void Deallocate(T* p) { Node* n reinterpret_castNode*(p); n-next free_list_; free_list_ n; } private: struct Node { Node* next; }; alignas(T) uint8_t storage_[POOL_SIZE * sizeof(T)]; Node* free_list_; };需要对象时从池里取用完还回池里。因为分配回收都是O(1)不会产生碎片速度还比malloc快。这类代码在很多RTOS内核里也能看到类似设计它的实质是既然你知道资源上限就别让运行时去猜。4.2 异常处理单片机项目里的正确归宿很多从桌面端过来的C程序员习惯用try/catch。在单片机裸机上我几乎不用异常。原因很现实启用异常后编译器要加入异常处理运行时Flash占用显著增加一旦在中断上下文里抛出异常栈展开stack unwind机制根本不可靠程序很容易直接进HardFault。我的做法和很多嵌入式C项目一致编译时直接关掉异常用错误码或返回对象来处理错误-fno-exceptions对应地设计接口时用“返回值表示是否成功”的约定enum class Result { Ok, Timeout, InvalidParam, HardwareError }; Result PidController::Update(float setpoint, float current, float dt) { if (dt 0.0f) return Result::InvalidParam; // ... return Result::Ok; }如果你特别想要“期望式”APIC23的std::expected很合适但在老工具链上支持不好。也可以自己写一个极简Expected模板不过我大部分项目里用错误码就够了。4.3 constexpr编译期计算与优化等级选择C相对于C在“计算能力”上的另一个优势是可以把复杂的算法在编译期就算完。比如查表法做正弦波传统C做法是启动时算一个数组填到RAM里而C可以constexpr让编译器直接生成常量表放进Flashconstexpr int kSamples 256; constexpr float SineTable(int index) { return 0.5f 0.5f * __builtin_sinf(2.0f * 3.14159265f * index / kSamples); } constexpr std::arrayfloat, kSamples GenerateSineTable() { std::arrayfloat, kSamples table{}; for (int i 0; i kSamples; i) { table[i] SineTable(i); } return table; } constexpr auto g_sine_table GenerateSineTable();编译后g_sine_table直接躺在Flash里RAM零占用运行速度就是数组访问。这在做波形发生、PWM调光、电机控制时非常实用。优化级别的选择我开发阶段通常用-Og因为调试信息保留得最好断点、单步也更准确。发布版本用-Os优先减小代码体积。除非是DSP算法或主频不够我很少用-O3因为-O3在紧凑循环里经常提高代码体积而且嵌入式设备代码大不等于跑得快。这条经验是我踩过坑换来的有次一个PID控制循环-O3比-Os编译出来反而多了几十条指令因为编译器内联了很多不该内联的函数。4.4 内存与启动文件再补一点和内存强相关的细节C工程中全局对象占用的RAM属于静态存储区它们在main之前完成构造生命周期直到关机。所以要用C写低功耗睡眠一定要确认全局对象里有没有定时器外设占用、有没有独立看门狗这类“不允许断电”的资源否则进入睡眠模式后状态会被破坏。5. 实战记录温控器状态机的C改造5.1 改造目标与类设计下面还原一个真实项目温控器。硬件是STM32F103C8T6板载按键x3、I2C OLED 128x64、NTC温度采样、固态继电器控制加热棒、掉电保存EEPROM。最初是C语言写的大约1800行主循环已经变得很难扩展。改造目标很明确按键扫描、消抖和事件识别独立成类PID算法独立成模板类方便以后换参数或增加限幅OLED显示逻辑从“到处是绘制代码”收敛成一个类主状态机用有限状态机类承载避免散落的if-else。5.2 核心代码实现与说明按键类重点是把“短按”、“长按”、“双击”识别从主循环里剥离出来enum class KeyEvent { None, ShortPress, LongPress, DoubleClick }; class KeyScanner { public: KeyScanner(GPIO_TypeDef* port, uint16_t pin) : port_(port), pin_(pin), last_state_(true), press_time_(0), last_event_time_(0) {} void Update(uint32_t now_ms) { bool level HAL_GPIO_ReadPin(port_, pin_) GPIO_PIN_RESET; bool pushed (level ! last_state_); last_state_ level; if (pushed) { if (now_ms - last_event_time_ 300) { event_ KeyEvent::DoubleClick; last_event_time_ 0; } else { press_time_ now_ms; event_ KeyEvent::ShortPress; last_event_time_ now_ms; } } } KeyEvent GetEvent() { KeyEvent e event_; event_ KeyEvent::None; return e; } private: GPIO_TypeDef* port_; uint16_t pin_; bool last_state_; uint32_t press_time_; uint32_t last_event_time_; KeyEvent event_ KeyEvent::None; };PID类我之前单独写了一篇笔记。核心是纯数学计算最好做成内联函数方便编译器优化class PidController { public: PidController(float kp, float ki, float kd, float dt) : kp_(kp), ki_(ki), kd_(kd), dt_(dt), integral_(0.0f), last_error_(0.0f) {} float Compute(float setpoint, float measured) { float error setpoint - measured; integral_ error * dt_; float derivative (error - last_error_) / dt_; last_error_ error; float output kp_ * error ki_ * integral_ kd_ * derivative; if (output 100.0f) output 100.0f; if (output 0.0f) output 0.0f; return output; } void Reset() { integral_ 0.0f; last_error_ 0.0f; } private: float kp_, ki_, kd_, dt_; float integral_; float last_error_; };状态机是这次改造的重点。C的原版写了三个switch-case新增状态时要改好几处C版本用一个枚举驱动每个状态有进入、执行、退出这三个可覆写钩子enum class AppState { PowerOn, Idle, Heating, Alarm }; class HeaterStateMachine { public: void SetState(AppState new_state) { Exit(); state_ new_state; Enter(); } void Run(uint32_t now_ms) { switch (state_) { case AppState::PowerOn: RunPowerOn(now_ms); break; case AppState::Idle: RunIdle(now_ms); break; case AppState::Heating: RunHeating(now_ms); break; case AppState::Alarm: RunAlarm(now_ms); break; } } private: void Enter() { /* 根据state_初始化 */ } void Exit() { /* 根据state_做清理 */ } void RunPowerOn(uint32_t now_ms) { if (now_ms 500) SetState(AppState::Idle); } void RunIdle(uint32_t now_ms) { /* 按键监听 */ } void RunHeating(uint32_t now_ms) { /* 调PID、控制继电器 */ } void RunAlarm(uint32_t now_ms) { /* 报警复位等 */ } AppState state_ AppState::PowerOn; };这样新增加一种状态只需要加枚举项、加一个Run函数、在Enter/Exit里各加一个分支不会影响到其他状态的逻辑。5.3 改造前后的实测对比我把改造前后的编译结果和代码量做了对比编译用arm-none-eabi-g 10.3-Os关闭异常和RTTI对比项C版本C版本代码总行数约1850行约1350行源文件数8个.c9个.cpp/ .hFlash占用31.6KB33.2KBRAM占用4.8KB5.1KB模块间全局变量12个3个手动状态枚举维护点4处1处C版本重写后Flash小幅增加1.6KB换来的是状态维护点减少、模块耦合明显降低。RAM只多了0.3KB主要来自Log Buffer的预留。这个代价对于绝大多数产品形态是完全可接受的。如果你对Flash大小极为敏感还可以用-ffunction-sections和--gc-sections做裁剪把未使用函数全部丢掉实测还能再省1KB以上。6. 常见问题与排查技巧实录6.1 编译链接期那些C运行时符号去哪了最典型的链接报错就是undefined reference to __cxa_pure_virtual undefined reference to __gxx_personality_v0 undefined reference to operator new(unsigned int)这类问题的根源都是链接时没有找到C运行时库。解决办法有两个方向。如果你用的是GCC工具链链接命令必须用arm-none-eabi-g同时确保没有使用-fno-exceptions时链接了libstdc如果不需要异常明确加-fno-exceptions把异常相关符号全部移除。如果你确实需要异常链接时就要带-lgcc、-lstdc、-lnosys等库。但正如我上文所说单片机裸机我基本不开异常。另外第一个符号__cxa_pure_virtual通常是纯虚函数被调用了。用-fno-rtti再配合检查代码一般能解决。我给纯C工程加.cpp文件时最常犯的一个错误就是忘了链接g后来在Makefile里固定用$(CXX)做LD变量再没出现过。6.2 运行期静态对象/全局对象为什么没执行构造函数症状是代码里new了一个全局对象并传了正确的引脚号但运行后引脚电平不对或者某个全局Logger对象的缓冲一直是空的。检查步骤按顺序来确认启动文件里有没有调用__libc_init_array()如果没有手动加上确认这个全局对象定义在哪个编译单元是否被--gc-sections误裁掉因为某些工具链对“没有直接引用”的全局对象会视为死代码通过map文件确认对象是否被分配在.bss段而不是.data段如果分配在错误段初期构造调用也会出问题。这里有个很坑的细节GCC的--gc-sections虽然好用但偶尔会把只被静态构造函数引用、没有显式外部引用的对象裁掉。解决方法是给Makefile加编译器保留属性或者在代码里对关键外部对象做一次“假引用”。6.3 Flash/内存疯涨用map文件抓“凶手”当你发现编译出的固件比预期大了好几KB别慌用map文件定位。GCC在链接时候加-Wl,-Mapoutput.map然后打开map文件搜索可疑函数和段。我经常搜的是.text段里占用最大的那一页明细看看是不是某些C标准库函数被无意拉了进来。比如std::string、std::ostream这类模板一旦你用错一个函数整个stream库可能都被留下。我遇到过最夸张的一次是在一个本来只有20KB的固件里因为我include了一个 并顺手push_back了几个数据Flash直接多了7KB。换用固定长度数组后立降6KB。如果只想快速裁剪代码体积记得加-ffunction-sections -fdata-sections --gc-sections这套组合拳做完再配合arm-none-eabi-size --targetbinary firmware.hex看最终大小。6.4 中断与C对象的共存法则最后一个高频问题中断里能不能调用成员函数普通成员函数理论上当然可以调用比如一个全局对象的成员函数只要这个函数内没有使用不可重入的资源就可以在中断上下文执行。但真实工程里我并不推荐直接在中断函数里调用复杂C逻辑。原因在于中断上下文要尽量短如果里面执行了模板容器扩容、动态分配、加锁等操作一旦被更高优先级中断打断系统实时性会受影响某些C库函数不是可重入的可能破坏内部状态调试麻烦中断里程序崩溃现场恢复比主循环麻烦得多。我的习惯模式是“中断置标志主循环处理”volatile bool adc_flag false; void ADC_IRQHandler(void) { if (ADC_GetITStatus(...) ! RESET) { adc_flag true; ADC_ClearITPendingBit(...); } } int main() { while (1) { if (adc_flag) { adc_flag false; // 在主循环上下文处理数据调用任何C对象都没有问题 } } }6.5 调试技巧在PC上先把C模块测试一遍最后分享一个我自己最受用的方法。单片机上的C类很多是纯逻辑、不涉及硬件的比如PID、状态机、按键消抖逻辑。别急着烧录到板子上调试先扔到PC上配合单元测试框架我最常用的是简单的主函数断言用g直接编译运行g -stdc14 test_pid.cpp pid.cpp -o test_pid ./test_pid发现问题改完再交叉编译。这样能把编译速度和调试速度提升一个量级尤其是状态机这种逻辑型代码在PC上模拟各种时间序列比上板子用串口打印强太多。等到PC上逻辑全绿再搬到STM32上做硬件联调剩下来要处理的就只剩时序和配置问题。我在这套流程里省下的时间保守估计每次大改都能省出半天到一天。这也是为什么我坚持推荐大家在单片机上用C因为类、模板、命名空间这些东西能把“逻辑”和“硬件”切得更干净从而让大量代码可以在PC上得到验证。这个收益在开发进度紧张的时候比任何一条技术花活都实在。
返回列表