当前位置:首页 > 嵌入式 > 嵌入式分享
[导读]实时控制、ADC采样、电机电流环里若每个周期都通过基类指针调虚函数,代价不只是“一次间接跳转”:先取对象vptr、再查vtable槽、再间接call,且编译器因不知道最终类型而不敢内联。Cortex-M0/M3没有分支预测,间接调用流水线损失更明显;M4/M7虽有预测,但热路径仍应尽量直调。优化原则不是“全面禁虚函数”,而是先定位热点,再用去虚拟化、CRTP、variant/函数表把vtable查找消掉。


实时控制、ADC采样、电机电流环里若每个周期都通过基类指针调虚函数,代价不只是“一次间接跳转”:先取对象vptr、再查vtable槽、再间接call,且编译器因不知道最终类型而不敢内联。Cortex-M0/M3没有分支预测,间接调用流水线损失更明显;M4/M7虽有预测,但热路径仍应尽量直调。优化原则不是“全面禁虚函数”,而是先定位热点,再用去虚拟化、CRTP、variant/函数表把vtable查找消掉。

一、先让编译器去虚拟化

很多虚调用在编译期类型可知时会被自动改成直接调用。栈对象、static对象、final类/函数、LTO跨文件可见,都能提高去虚概率:

class Sensor {

public:

   virtual int16_t read() = 0;

   virtual ~Sensor() = default;

};

class TempSensor final : public Sensor {

public:

   int16_t read() override { return i2c_read_raw(); }

};


void poll(){

   TempSensor t;          // 类型确定,可直接调用/内联

   t.read();

}

final告诉编译器无更派生类,配合-O2/-O3、-flto,GCC/Clang常把虚调用转直接调用甚至内联;但是否真去虚要看反汇编,虚调用通常是call [vtable+offset],去虚后是call TempSensor::read。 多文件把实现放头文件或开LTO,跨翻译单元才能看到具体类型。

二、热路径改CRTP,彻底无vtable

编译期就知道具体传感器、执行器、通信后端时,用奇异递归模板把分发移到编译期:

template<typename D>

class SensorBase {

public:

   int16_t read() { return static_cast<D*>(this)->read_impl(); }

   void sleep()  { static_cast<D*>(this)->sleep_impl(); }

};


class PressSensor : public SensorBase<PressSensor> {

public:

   int16_t read_impl() { return spi_read(0x1F); }

   void sleep_impl()    { spi_write(0x1F, 0x00); }

};


class TempSensor2 : public SensorBase<TempSensor2> {

public:

   int16_t read_impl() { return i2c_read(0x48); }

   void sleep_impl()    { i2c_write(0x48, 0x01); }

};

Base::read通过static_cast直调派生实现,无vptr、无vtable、可被完全内联。Cortex-M4简单调用基准中,CRTP相对虚函数可快数倍,紧密循环内联后差距更大;代价是每个派生类实例化一套基类代码,ROM会涨,派生类型多且基类很大时要权衡。

C++20可用concept约束派生接口,避免CRTP写错实现名:

template<typename D>

concept SensorC = requires(D d){ { d.read_impl() } -> std::convertible_to<int16_t>; d.sleep_impl(); };


template<typename D> requires SensorC<D>

class SafeSensorBase {

public:

   int16_t read(){ return static_cast<D*>(this)->read_impl(); }

};

三、有限类型用variant/函数表,替代继承

若运行期要在几种固定算法间切换,但不在乎“继承体系”,std::variant比虚函数更轻:

struct PidCtrl { int16_t step(int16_t e){ return pid(e); } };

struct BangBang { int16_t step(int16_t e){ return e>0?1000:-1000; } };

using Controller = std::variant<PidCtrl, BangBang>;


int16_t control(Controller& c, int16_t err){

   return std::visit([err](auto& x){ return x.step(err); }, c);

}

类型数少、且禁止堆的项目可改函数指针表,ISR和周期任务零vtable:

struct CtrlOps {

   int16_t (*step)(int16_t, void* ctx);

   void* ctx;

};

int16_t call(const CtrlOps* o, int16_t e){ return o->step(e, o->ctx); }

相比继承,variant/函数表没有每对象vptr,dispatch是visit或一次指针调用,分支更可预测。

四、必须保留虚函数时的裁剪

插件式、运行期动态加载、通过统一基类管理异构外设,仍可用虚函数,但热路径要做减法:

纯接口只留1~3个虚函数,其余公共逻辑写非虚NVI;

不需要基类指针delete派生对象就不加虚析构,普通析构更省;

单继承、避免虚继承;大量小对象避免每人带vptr(32位核4字节、64位8字节);

实时循环内不通过基类引用调虚函数,改由具体类型对象调用,或把“选类型”放到循环外只做一次,循环内走具体实现;

开-Os/-O2、-flto、-ffunction-sections,链接--gc-sections删未用vtable。

class Device {

public:

   int run() { pre(); do_run(); post(); }   // NVI,只有do_run虚

protected:

   virtual void do_run() = 0;

};

五、测量与落地

别凭感觉改:在目标MCU用DWT CYCCNT或GPIO+逻辑仪测“调用N次耗时”;用objdump/arm-none-eabi-objdump看热函数是否仍是call [vtable+x]。M0/M3对间接跳转最敏感,ISR、控制环优先CRTP或具体类型;M7带Cache且调用点单态,final+LTO往往已够。若用CRTP后ROM涨太多,把大段公共代码提到非模板自由函数,模板基类只做薄分发。

按“先测热点→能去虚就final/LTO→编译期定类型用CRTP→有限运行期类型用variant/函数表→真异构才留虚但做NVI”五步收口,实时路径可把vtable查找从关键链路彻底拿掉,又保留C++接口复用。



本站声明: 本文章由作者或相关机构授权发布,目的在于传递更多信息,并不代表本站赞同其观点,本站亦不保证或承诺内容真实性等。需要转载请联系该专栏作者,如若文章内容侵犯您的权益,请及时联系本站删除( 邮箱:macysun@21ic.com )。
关闭