29、位操作与算法:用位运算实现整数对数

说到对数,大家第一反应可能是数学库里的 log() 函数。但在嵌入式世界里,很多时候我们不需要浮点运算,也不方便调用数学库。我做过不少通信协议栈和传感器数据处理的项目,经常需要估算一个整数的量级——说白了,就是想知道这个数大概有多大,用二进制表示的话占多少位。

这个需求,其实就是整数对数。具体来说,就是求 floor(log2(n)),也就是以2为底的对数向下取整。比如 log2(8) = 3log2(10) = 3(因为 2³=8,2⁴=16,10 在中间)。

用位运算实现整数对数,核心思路只有一个:找到最高位的1所在的位置。你想想看,一个整数的二进制表示中,最高位的1在第几位,这个数就落在 2^(k) 到 2^(k+1)-1 之间,那它的整数对数就是 k。

方法一:逐位右移法

最直观的方法,就是不断右移,数一数要移多少次才能把数变成0。这个方法简单,但效率一般。

int int_log2_shift(uint32_t x) {
    int result = -1;
    while (x > 0) {
        x >>= 1;
        result++;
    }
    return result;
}

嗯,这里要注意:如果 x 是0,返回 -1 表示未定义。我在实际项目中一般会先判断一下输入是否为0。

这个方法的复杂度是 O(n),n 是整数的位数。对于32位整数,最多循环32次。说实话,在大多数场景下够用了。但我曾经在一个音频采样率转换的算法里用过这个,每次处理几千个样本,每个样本都要算一次对数,32次循环就变成了几万次操作——这时候就得优化了。

方法二:二分查找法

既然我们要找最高位的1,那完全可以用二分法来加速。思路是:先看高16位有没有1,如果有,结果至少是16;再看高8位……以此类推。

int int_log2_binary(uint32_t x) {
    int result = 0;
    if (x >= 0x10000) { result += 16; x >>= 16; }
    if (x >= 0x100)   { result += 8;  x >>= 8;  }
    if (x >= 0x10)    { result += 4;  x >>= 4;  }
    if (x >= 0x4)     { result += 2;  x >>= 2;  }
    if (x >= 0x2)     { result += 1;  x >>= 1;  }
    return result;
}

这个写法很经典,固定5次判断,不管输入是多少。我特别喜欢这种「确定性执行时间」的算法,在实时系统里特别有用。你想想看,如果每个中断处理的时间都不一样,调度起来多麻烦。

核心思想:二分查找法本质上是把「找最高位」的问题,拆成了「逐层缩小范围」的子问题。每一层判断当前数是否大于某个阈值,如果是,就把结果加上对应的偏移量,然后右移。

方法三:查表法

如果空间换时间,查表法是最快的。但直接查32位整数的表需要4GB内存,显然不现实。所以一般用「分段查表」:先通过二分法缩小到8位或4位,再查小表。

static const uint8_t log2_table[256] = {
    0,0,1,1,2,2,2,2,3,3,3,3,3,3,3,3,
    // ... 省略中间数据,完整表需要256个条目
    7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7
};

int int_log2_lut(uint32_t x) {
    int result = 0;
    if (x >= 0x10000) { result += 16; x >>= 16; }
    if (x >= 0x100)   { result += 8;  x >>= 8;  }
    return result + log2_table[x];
}

这个写法只需要256字节的表,加上两次判断,速度非常快。我曾经在一个电池管理系统的固件里用过这个,每次ADC采样后要快速计算电量百分比,查表法比逐位移法快了将近10倍。

方法四:内置指令法

如果编译器支持,可以用内置函数。GCC 和 Clang 都提供了 __builtin_clz()(Count Leading Zeros),直接返回前导零的个数。整数对数就是 31 - clz(对于32位整数)。

int int_log2_builtin(uint32_t x) {
    return 31 - __builtin_clz(x);
}

这个函数在底层会被编译成一条 CPU 指令(比如 ARM 的 CLZ 指令,x86 的 BSR 指令),效率最高。但要注意:__builtin_clz(0) 的行为是未定义的,需要先处理 x=0 的情况。

我曾经踩过的坑:在移植代码到不同平台时,__builtin_clz 的行为可能不一致。有的平台对0返回32,有的返回31,有的直接崩溃。所以跨平台代码里,我建议先判断 x==0,或者用 __builtin_clz(x | 1) 来避免0值。

方法对比

方法 速度 代码大小 适用场景
逐位右移 慢(O(n)) 极小 教学、偶尔调用
二分查找 快(固定5步) 通用、实时系统
查表法 极快 中等(256字节) 高频调用、资源充足
内置指令 最快(1条指令) 极小 平台确定、性能敏感

实际应用场景

整数对数在嵌入式里用得挺多的。我随便列几个:

  • 位图索引:用对数确定数据落在哪个桶里
  • FIFO 深度计算:根据数据量估算需要的缓冲区大小
  • 音频/图像量化:确定动态范围,做非线性映射
  • 协议解析:解析变长编码时,快速知道数据长度

我记得有一次做 LoRa 网关的固件,需要根据信号强度快速估算距离。信号强度是 dBm 值,但底层 ADC 读出来的是原始值。用整数对数把原始值映射到对数域,再查表转成距离,整个过程没有用一次浮点运算,跑得飞快。

SVG 知识结构图

整数对数 floor(log₂(n)) 实现方法 整数对数 逐位右移法 O(n) 循环右移计数 二分查找法 固定5步判断 查表法 256字节表 + 二分 内置指令法 __builtin_clz 核心思想:找到最高位的1所在位置 本质 = 前导零计数 → 整数对数

我的个人建议:如果项目对性能要求不高,用二分查找法最稳妥——代码简单,可读性好,执行时间固定。如果性能敏感且平台固定,用内置指令法。查表法适合那些「查一次表比算一次快」的场景,但要注意表放在 RAM 还是 Flash 里,有些 MCU 的 Flash 读取速度比 RAM 慢很多。

好了,整数对数这个看似简单的问题,用位运算实现起来其实有好几条路。关键是要理解它的本质——找最高位的1。理解了这一点,不管用哪种方法,你都能写出高效、可靠的代码。


公众号:蓝海资料掘金营,微信deep3321