2

我正在为嵌入式系统构建代码,并尝试尽可能多地节省二进制空间。

该代码用于解析协议(MQTT 的价值),其中有许多数据包类型,它们都是不同的,但有一些共同的部分。

目前,为了简化代码的编写,我使用这种模式:

  template <PacketType type>
  struct ControlPacket
  {
      FixedHeader<type>    type;
      VariableHeader<type> header;
      Properties<type>     props;
      ... and so on...
  };   

  // Specialize for each type
  template <>
  struct FixedHeader<CONNECT>
  {
     uint8_t typeAndFlags;
     PacketType getType() const { return static_cast<PacketType>(typeAndFlags >> 4); }
     uint8 getFlags() const { return 0; }
     bool parseType(const uint8_t * buffer, int len) 
     { 
         if (len < 1) return false; 
         typeAndFlags = buffer[0]; 
         return true; 
     }
     ...  
  };

  template <>
  struct FixedHeader<PUBLISH>
  {
     uint8_t typeAndFlags;
     PacketType getType() const { return static_cast<PacketType>(typeAndFlags >> 4); }
     uint8 getFlags() const { return typeAndFlags & 0xF; }
     bool parseType(const uint8_t * buffer, int len) 
     { 
         if (len < 1) return false; 
         typeAndFlags = buffer[0];
         if (typeAndFlags & 0x1) return false;  // Example of per packet specific check to perform
         return true; 
     }
     ...  
  };

  ... For all packet types ...

这是有效的,我现在正试图减少所有这些模板专业化的二进制影响(否则代码几乎重复了 16 次)

所以,我想出了这个范式:

   // Store the most common implementation in a base class
   struct FixedHeaderBase
   {
       uint8_t typeAndFlags;
       virtual PacketType getType() { return static_cast<PacketType(typeAndFlags >> 4); }
       virtual uint8 getFlags() { return 0; } // Most common code here
       virtual bool parseType(const uint8_t * buffer, int len) 
       { 
         if (len < 1) return false; 
         typeAndFlags = buffer[0]; 
         return true; 
       }

       virtual ~FixedHeaderBase() {}
   };

   // So that most class ends up empty
   template <>
   struct FixedHeader<CONNECT> final : public FixedHeaderBase
   {
   };

   // And specialize only the specific classes
   template <>
   struct FixedHeader<PUBLISH> final : public FixedHeaderBase
   {
       uint8 getFlags() const { return typeAndFlags & 0xF; }
       bool parseType(const uint8_t * buffer, int len) 
       { 
         if (!FixedHeaderBase::parseType(buffer, len)) return false; 
         if (typeAndFlags & 0x1) return false;  // Example of per packet specific check to perform
         return true; 
       }
   };

  // Most of the code is shared here
  struct ControlPacketBase
  {
     FixedHeaderBase & type;
     ...etc ...
     virtual bool parsePacket(const uint8_t * packet, int packetLen)
     {
        if (!type.parseType(packet, packetLen)) return false;
        ...etc ...
     }

     ControlPacketBase(FixedHeaderBase & type, etc...) : type(type) {} 
     virtual ~ControlPacketBase() {}
  };

  // This is only there to tell which specific version to use for the generic code
  template <PacketType type>
  struct ControlPacket final : public ControlPacketBase
  {
      FixedHeader<type>    type;
      VariableHeader<type> header;
      Properties<type>     props;
      ... and so on...

      ControlPacket() : ControlPacketBase(type, header, props, etc...) {}
  };   

这工作得很好,可以减少大量使用的二进制代码空间。顺便说一句,我在final这里使用,因此编译器可以去虚拟化,并且我在没有 RTTI 的情况下进行编译(显然也使用 -Os 并且每个函数都在其自己的部分中被垃圾收集)。

但是,当我检查符号表大小时,我发现析构函数上有很多重复(所有模板实例都在实现一个明显相同(相同二进制大小)或空的析构函数)。

通常,我理解ControlPacket<CONNECT>需要调用~FixedHeader<CONNECT>()并且ControlPacket<PUBLISH>需要调用~FixedHeader<PUBLISH>()破坏。

然而,由于所有的析构函数都是虚拟的,有没有一种方法ControlPacket可以避免它们的析构函数的专业化,而是必须ControlPacketBase虚拟地破坏它们,这样我就不会得到 16 个无用的析构函数,而只有一个?

4

1 回答 1

1

值得指出的是,这与称为“相同 COMDAT 折叠”或 ICF 的优化有关。这是一个链接器功能,其中相同的函数(即空函数)全部合并为一个。

不是每个链接器都支持这个,也不是每个链接器都愿意这样做(因为语言说不同的功能需要不同的地址),但你的工具链可以有这个。这将是快速和容易的。


我将假设您的问题已通过这个玩具示例重现:

#include <iostream>
#include <memory>
#include <variant>

extern unsigned nondet();

struct Base {
    virtual const char* what() const = 0;

    virtual ~Base() = default;
};

struct A final : Base {
    const char* what() const override {
        return "a";
    }
};

struct B final : Base {
    const char* what() const override {
        return "b";
    }
};

std::unique_ptr<Base> parse(unsigned v) {
    if (v == 0) {
        return std::make_unique<A>();
    } else if (v == 1) {
        return std::make_unique<B>();
    } else {
        __builtin_unreachable();
    }
}

const char* what(const Base& b) {
    return b.what();  // virtual dispatch
}

const char* what(const std::unique_ptr<Base>& b) {
    return what(*b);
}

int main() {
    unsigned v = nondet();
    auto packet = parse(v);

    std::cout << what(packet) << std::endl;
}

反汇编显示两者A::~A都有B::~B(多个)列表,即使它们是空的且相同的。这是= defaultfinal

如果删除virtual,那么这些空洞的定义就会消失,我们就实现了目标 - 但是现在当 unique_ptr 删除对象时,我们会调用未定义的行为。

我们有三种选择可以让析构函数保持非虚拟,同时保持定义明确的行为,其中两种有用,一种无用。


无用:第一个选项是使用shared_ptr. 这是有效的,因为shared_ptr实际上类型擦除了它的删除器功能(参见这个问题),所以它绝不会通过基础删除。换句话说,当你shared_ptr<T>(u)为一些u派生的从创建一个时Tshared_ptr存储一个函数指针U::~U直接指向。

然而,这种类型的擦除只是重新引入了问题并生成了更多空的虚拟析构函数。请参阅修改后的玩具示例进行比较。我提到这一点是为了完整性,以防您碰巧已经将它们放入 shared_ptr's 中。


有用:替代方法是避免虚拟调度以进行生命周期管理,并使用variant. 做出这样一个笼统的声明并不合适,但通常您可以通过标签调度实现更小的代码甚至一些加速,因为您避免指定 vtables 和动态分配。

这需要对代码进行最大的更改,因为代表数据包的对象必须以不同的方式进行交互(它不再是 is-a 关系):

#include <iostream>

#include <boost/variant.hpp>

extern unsigned nondet();

struct Base {
    ~Base() = default;
};

struct A final : Base {
    const char* what() const {
        return "a";
    }
};

struct B final : Base {
    const char* what() const {
        return "b";
    }
};

typedef boost::variant<A, B> packet_t;

packet_t parse(unsigned v) {
    if (v == 0) {
        return A();
    } else if (v == 1) {
        return B();
    } else {
        __builtin_unreachable();
    }
}

const char* what(const packet_t& p) {
    return boost::apply_visitor([](const auto& v){
        return v.what();
    }, p);
}

int main() {
    unsigned v = nondet();
    auto packet = parse(v);

    std::cout << what(packet) << std::endl;
}

我使用了 Boost.Variant,因为它产生的代码最小。令人讨厌的是,std::variant坚持要生成一些较小但存在的 vtables 来实现自身 - 我觉得这有点违背了目的,尽管即使使用变体 vtables 代码总体上仍然小得多。

我想指出现代优化编译器的一个很好的结果。注意结果实现what

what(boost::variant<A, B> const&):
        mov     eax, DWORD PTR [rdi]
        cdq
        cmp     eax, edx
        mov     edx, OFFSET FLAT:.LC1
        mov     eax, OFFSET FLAT:.LC0
        cmove   rax, rdx
        ret

编译器理解变体中的封闭选项集,lambda 鸭类型证明每个选项确实有一个...::what成员函数,因此它实际上只是根据变体值挑选要返回的字符串文字。

与变体的权衡是您必须拥有一组封闭的选项,并且您不再拥有强制存在某些功能的虚拟接口。作为回报,你会得到更小的代码,编译器通常可以看穿调度“墙”。

但是,如果我们为每个“预期的”成员函数定义这些简单的访问者帮助函数,它就会充当接口检查器——而且您已经有了帮助类模板来保持一致。


最后,作为上述内容的扩展:您始终可以自由地在基类中维护一些虚函数。如果您可以接受 vtables 的成本,这可以提供两全其美的效果:

#include <iostream>

#include <boost/variant.hpp>

extern unsigned nondet();

struct Base {
    virtual const char* what() const = 0;

    ~Base() = default;
};

struct A final : Base {
    const char* what() const override {
        return "a";
    }
};

struct B final : Base {
    const char* what() const override {
        return "b";
    }
};

typedef boost::variant<A, B> packet_t;

packet_t parse(unsigned v) {
    if (v == 0) {
        return A();
    } else if (v == 1) {
        return B();
    } else {
        __builtin_unreachable();
    }
}

const Base& to_base(const packet_t& p) {
    return *boost::apply_visitor([](const auto& v){
        return static_cast<const Base*>(&v);
    }, p);
}

const char* what(const Base& b) {
    return b.what();  // virtual dispatch
}

const char* what(const packet_t& p) {
    return what(to_base(p));
}

int main() {
    unsigned v = nondet();
    auto packet = parse(v);

    std::cout << what(packet) << std::endl;
}

这会产生相当紧凑的代码

我们这里有一个虚拟基类(但没有虚拟析构函数,因为它不是必需的),以及一个to_base可以接受变体并为您返回公共基接口的函数。(在像你这样的层次结构中,每种基础都可以有几个。)

在公共基础上,您可以自由地执行虚拟调度。根据工作负载,这有时更容易管理和更快,并且额外的自由只需要一些 vtables。在这个例子中,我实现what了先转换为基类,然后再对what成员函数进行虚拟分派。

再次,我想指出访问的定义,这次是to_base

to_base(boost::variant<A, B> const&):
        lea     rax, [rdi+8]
        ret

编译器理解所有继承自的封闭类集Base,因此根本不需要实际检查任何变体类型标记。


在上面我使用了 Boost.Variant。不是每个人都可以或想要使用 Boost,但答案的原则仍然适用:存储对象并跟踪存储在整数中的对象类型。当需要做某事时,查看整数并跳转到代码中的正确位置。

实现一个变体是一个完全不同的问题。:)

于 2020-03-19T00:52:40.497 回答