1. 从“听”到“码”PCM编码到底在做什么如果你用过手机打电话或者听过MP3音乐那你其实已经和PCM编码打过无数次交道了。简单来说PCM脉冲编码调制就是把我们耳朵听到的、连续变化的模拟声音信号变成一串计算机能看懂和处理的“0”和“1”的数字密码。这个过程就像是给一段连续起伏的波浪线拍一系列快照然后把每一张照片的“高度”用一个数字记录下来。这个“拍照”的过程叫采样而“记录高度”的过程就是量化和编码。为什么要把声音变成数字呢因为数字信号抗干扰能力强方便存储、复制和通过网络传输。我们今天要聊的“逐次比较型编码器”就是PCM编码里一个非常经典、也特别适合用代码来实现的量化编码算法。它的核心思想就像我们玩“猜数字”游戏我心里想一个0到100之间的数你每次猜一个数我只告诉你“大了”还是“小了”你用最少的次数猜中它。逐次比较型编码器对待一个输入的电平值用的就是这种“步步逼近”的聪明策略。对于嵌入式或者通信领域的开发者来说理解这个算法原理是一回事但能亲手用C语言把它写出来让它在单片机或者DSP上跑起来那感觉是完全不一样的。这不仅能帮你把《通信原理》课本上那些抽象的流程图和公式真正“吃透”更能让你获得一种“我能造轮子”的扎实成就感。接下来我就带你一起把这个经典的算法从理论图纸变成一行行可以运行、可以调试的C语言代码。2. 动手前的理论准备理解“13折线”与“逐次比较”在开始敲代码之前我们得先搞清楚游戏规则。我们实现的这个编码器遵循的是通信领域一个非常著名的非均匀量化标准——A律13折线实际上常用的是A律但原理类似我们讨论其量化特性。为什么叫“13折线”你可以想象一下我们用13段首尾相连的直线段去近似一条对数曲线。这条对数曲线非常关键它保证了我们对声音信号进行量化时对小信号比如轻声细语的量化间隔很精细而对大信号比如高声呼喊的量化间隔可以粗一些。这样就能用较少的二进制位数获得较好的整体听觉效果提高了编码效率。在我们的编码器里输入信号的动态范围被设定在-2048到2047之间总共4096个可能的离散电平值。经过非均匀量化后我们最终要输出一个8位的二进制码字。这8位码字结构分明第1位 (a1)极性码。非常简单正数为1负数为0。这就是编码流程的第一步判断样值极性。第2到4位 (a2, a3, a4)段落码。这3位用来确定输入信号绝对值落在13折线8个大段中的哪一段。你可以把这8段想象成一把不均匀的尺子从第1段到第8段每一段的“长度”电平范围和“最小刻度”量化间隔都不同而且是越来越大的。确定段落的过程就是“逐次比较”的第一次大显身手。第5到8位 (a5, a6, a7, a8)段内码。在确定了属于哪一段之后这4位用来进一步确定信号在该段16个均匀量化级中的具体位置。因为段内是均匀量化的所以这一步的本质是一个在固定段落内的“猜数字”游戏。那么“逐次比较”究竟是如何工作的呢我们以确定段落码为例。假设输入信号的绝对值是|X|。编码器内部预设了一系列的判决电平就像游戏里预设的几个关键分界线。首先它会拿|X|和第5段的起始电平128比较。如果|X| 128那么a2就置为1说明信号在后四段5,6,7,8段否则a20信号在前四段1,2,3,4段。确定了大范围后再在选定的四段内进行下一次比较以此类推。通过三次这样的二分判决就能唯一确定8段中的一段。这个过程是不是像极了二分查找算法没错它的核心思想就是二分法每次比较都将不确定范围缩小一半效率非常高。为了写代码我们必须明确两个核心表格的数据这些数据是算法的“灵魂”8个段落的起始电平和量化间隔表段落序号段落码 (a2 a3 a4)起始电平量化间隔 (Δ)10 0 00120 0 116130 1 032240 1 164451 0 0128861 0 12561671 1 05123281 1 1102464这个表告诉我们两件事1每一段从哪个电平值开始2在这一段里每两个相邻的量化级之间相差多少即量化间隔。例如第1段从0开始量化间隔是1那么它的16个量化级就是0, 1, 2, ..., 15。而第8段从1024开始量化间隔是64它的量化级就是1024, 1088, 1152, ..., 1984。3. 核心算法拆解把流程图翻译成C语言逻辑理论懂了现在我们来“翻译”算法。编码器的完整工作流程可以清晰地对应到C语言的三种基本控制结构顺序结构、选择结构if-else和循环结构。虽然原始流程图中没有显式循环但“逐次”这个过程我们用一系列嵌套的if-else判断来实现其本质是循环思想的展开。让我们一步步拆解并思考如何用变量和数组来模拟编码器的状态。第一步极性判断与绝对值处理这是最简单的部分对应一个if-else判断。我们用一个整型变量比如int sample来存放输入的抽样值。然后int encoder[8] {0}; // 初始化8位编码结果数组所有位为0 if (sample 0) { encoder[0] 1; // 正数极性码为1 } else { encoder[0] 0; // 负数极性码为0 } int abs_sample abs(sample); // 取绝对值用于后续段落和段内判断注意我们使用了一个数组encoder[8]来存储最终的8位编码。数组下标0到7正好对应a1到a8这比用8个独立的变量要清晰和方便得多。第二步确定段落码 (a2, a3, a4)这是“逐次比较”的精华所在。我们根据流程图用嵌套的if-else来实现三次二分判决。这里的关键是比较电平是各段的起始电平。我们根据abs_sample的值一步步缩小范围。// 第一次比较判断前4段还是后4段 if (abs_sample 128) { // 与第5段起始电平比较 encoder[1] 1; // a2 1落在5,6,7,8段 // 第二次比较判断是(5,6)段还是(7,8)段 if (abs_sample 512) { // 与第7段起始电平比较 encoder[2] 1; // a3 1落在7,8段 // 第三次比较判断是第7段还是第8段 if (abs_sample 1024) { // 与第8段起始电平比较 encoder[3] 1; // a4 1第8段 } else { encoder[3] 0; // a4 0第7段 } } else { encoder[2] 0; // a3 0落在5,6段 // 第三次比较判断是第5段还是第6段 if (abs_sample 256) { // 与第6段起始电平比较 encoder[3] 1; // a4 1第6段 } else { encoder[3] 0; // a4 0第5段 } } } else { // 类似地处理前4段(1,2,3,4)的情况 encoder[1] 0; // a2 0 if (abs_sample 32) { // 与第3段起始电平比较 encoder[2] 1; // a3 1落在3,4段 if (abs_sample 64) { // 与第4段起始电平比较 encoder[3] 1; // a4 1第4段 } else { encoder[3] 0; // a4 0第3段 } } else { encoder[2] 0; // a3 0落在1,2段 if (abs_sample 16) { // 与第2段起始电平比较 encoder[3] 1; // a4 1第2段 } else { encoder[3] 0; // a4 0第1段 } } }写完这段代码段落码encoder[1]、encoder[2]、encoder[3]就被确定了。同时我们也知道了输入信号具体属于哪一段。我们可以用一个变量segment来记录段号1-8方便后续使用。segment encoder[1]*4 encoder[2]*2 encoder[3]这个计算结果是0-7对应第1到第8段代码中通常用0-7表示对应数组下标。第三步确定段内码 (a5, a6, a7, a8)知道在哪一段后我们就在这一段内进行4次“逐次比较”来确定16个量化级中的哪一个。这个过程更像标准的二分搜索了。每一段的起始电平start_level[segment]和量化间隔delta[segment]我们已经从表格中知道了。思路是我们先计算该段的中间判决电平。以第segment段为例段内范围是从start_level[segment]到start_level[segment] 15 * delta[segment]。第一次比较我们看abs_sample是否大于等于start_level[segment] 8 * delta[segment]即后8个量化级的起始点。如果是则a51否则a50。确定a5后我们在选定的8个量化级范围内再次与其中间值比较确定a6以此类推。int seg_index encoder[1]*4 encoder[2]*2 encoder[3]; // 段号索引 0-7 int start start_level[seg_index]; // 本段起始电平 int delta quantization_space[seg_index]; // 本段量化间隔 // 确定 a5 int threshold start 8 * delta; if (abs_sample threshold) { encoder[4] 1; } else { encoder[4] 0; } // 确定 a6在a5确定的基础上更新比较阈值 threshold start encoder[4] * 8 * delta 4 * delta; if (abs_sample threshold) { encoder[5] 1; } else { encoder[5] 0; } // 确定 a7 threshold start encoder[4] * 8 * delta encoder[5] * 4 * delta 2 * delta; if (abs_sample threshold) { encoder[6] 1; } else { encoder[6] 0; } // 确定 a8 threshold start encoder[4] * 8 * delta encoder[5] * 4 * delta encoder[6] * 2 * delta 1 * delta; if (abs_sample threshold) { encoder[7] 1; } else { encoder[7] 0; }至此完整的8位PCM编码encoder[0]到encoder[7]就全部生成了。这个过程清晰地展示了如何用C语言的条件判断精确地模拟硬件编码器中比较器的工作流程。4. 代码优化与工程化思考上面给出的代码虽然逻辑正确但直接嵌套的if-else在确定段落码时显得有些冗长和重复。在实际工程中我们追求代码的清晰、可维护和高效。这里分享几个我踩过坑之后总结的优化思路。优化一使用查找表与循环简化段内码编码段内码的生成逻辑非常规整四次比较的模式完全一致只是每次参与比较的“权重”8, 4, 2, 1不同。这非常适合用循环来实现。int weights[4] {8, 4, 2, 1}; // 四次比较的权重 int temp_remainder abs_sample - start; // 信号相对于段起始电平的差值 int accumulated 0; // 用于累积已判决的电平 for (int i 0; i 4; i) { // 计算当前判决阈值起始电平 已累积电平 当前权重*量化间隔 int current_threshold weights[i] * delta; if (temp_remainder accumulated current_threshold) { encoder[4 i] 1; // a5, a6, a7, a8 accumulated current_threshold; } else { encoder[4 i] 0; } }这样写代码一下子缩短了很多逻辑也更清晰。如果要改变编码位数比如改成7位段内码只需要修改循环次数和权重数组即可扩展性更好。优化二将段落判决电平定义为常量数组在确定段落码的嵌套if-else中那些比较的数值128, 512, 1024, 32, 64, 16其实是根据13折线特性计算出来的。我们可以把它们明确定义成常量数组增加代码的可读性也便于修改。const int decision_levels[3] {128, 512, 1024}; // 用于后四段判决的关键电平 const int decision_levels_front[3] {32, 64, 16}; // 用于前四段判决的关键电平 // 在实际比较时引用这些常量更好的做法是将整个段落判决过程也抽象成一个函数输入是abs_sample输出是段落索引seg_index。函数内部可以用一个更通用的二分查找逻辑虽然对于只有8段的情况收益不大但这是一种良好的编程思维训练。优化三模块化与函数封装一个完整的编码器不应该把所有代码都堆在main()函数里。我们可以将其拆分成几个功能明确的函数int determine_polarity(int sample): 确定极性码。int determine_segment(int abs_sample): 确定段落索引。void determine_internal_code(int abs_sample, int seg_index, int* internal_code): 确定段内码4位。void encode_pcm(int sample, int* pcm_code): 主编码函数调用以上函数填充8位编码数组。这样做的好处是每个函数职责单一易于单独测试和调试。当你想把这个编码算法移植到另一个嵌入式平台或者集成到一个更大的通信系统中时模块化的代码会省去你很多麻烦。关于量化误差的计算编码完成后我们通常关心量化误差即原始抽样值与编码后重建值之间的差值。重建值可以根据编码结果反推出来reconstructed_value start_level[seg_index] (encoder[4]*8 encoder[5]*4 encoder[6]*2 encoder[7]*1) * delta (delta / 2)注意最后加的(delta / 2)是重建电平的偏移通常取量化间隔的一半代表该量化级的中间值。量化误差就是abs_sample - reconstructed_value。在代码中输出这个误差可以帮助我们验证编码算法的正确性。5. 进阶理解7/11与7/12转换在原始文章的代码里最后还输出了“7/11转换”和“7/12转换”的结果。这又是什么呢这其实是通信系统中为了兼容不同设备而进行的码型变换。7位非线性码就是我们编码结果中除去极性位a1后的7位码a2-a8。它代表了信号幅度经过非均匀量化13折线后的信息。11位线性码可以理解为将信号幅度在0-2048范围内进行均匀量化后所需要的编码位数。均匀量化时量化间隔是固定的比如为1那么表示2048个电平就需要11位二进制码2^112048。7/11转换就是把7位非线性码转换成等效的11位线性码。为什么是“等效”因为前面理论部分提到过在保证小信号量化间隔相同的条件下这种7位非线性编码和11位线性编码的效果是等价的。转换的规则是根据段落码a2,a3,a4确定线性码中从低位开始的第几位是“1”这个“1”代表了该段的起始电平在均匀量化中的位置然后将段内码a5-a8填充到其后。具体算法在原始代码中已经体现核心是根据段落索引计算出线性码中“1”的位置偏移。7/12转换与7/11类似但输出的是12位线性码。多出来的一位通常是最低有效位LSB之后的填充位在一些特定的数字接口标准中会用到。在C语言实现上这两个转换其实就是一些按规则的位填充操作。我们可以用两个新的数组linear_11[11]和linear_12[12]来存储结果。关键是根据段落索引seg_index计算出线性码中那个标志性的“1”应该放在哪里。原始代码中的计算公式7 - seg_index就是这个偏移量的计算因为段落索引0对应第1段其起始电平在均匀量化中对应较高的位置。理解这个转换能让你更深刻地把握非均匀量化与均匀量化之间的数学映射关系。6. 完整的可运行代码示例与测试把前面所有的部分组合起来并加入详细的注释和用户交互我们就得到了一个完整的、可编译运行的C程序。这里我提供一个优化和整理后的版本它更清晰也更容易理解。/** * PCM逐次比较型编码器 - C语言实现 * 遵循A律13折线近似量化规则 * 输入范围-2048 ~ 2047 的整数 * 输出8位PCM码7/11转换码7/12转换码 */ #include stdio.h #include stdlib.h // 用于abs()函数 // 常量定义8段落的起始电平和量化间隔 const int START_LEVEL[8] {0, 16, 32, 64, 128, 256, 512, 1024}; const int QUANT_STEP[8] {1, 1, 2, 4, 8, 16, 32, 64}; // 函数声明 int get_polarity_bit(int sample); int determine_segment(int abs_value); void encode_segment_internal(int abs_value, int seg_idx, int internal_code[4]); void convert_7_to_11(int seg_idx, int internal_code[4], int linear_11[11]); void convert_7_to_12(int seg_idx, int internal_code[4], int linear_12[12]); int main() { int input_sample; int pcm_code[8] {0}; // 8位PCM编码 int linear_11_code[11] {0}; // 11位线性码 int linear_12_code[12] {0}; // 12位线性码 printf( PCM逐次比较型编码器仿真 \n); printf(请输入信号抽样值整数范围-2048 ~ 2047: ); if (scanf(%d, input_sample) ! 1) { printf(输入错误\n); return -1; } // 1. 获取极性码 (a1) pcm_code[0] get_polarity_bit(input_sample); // 2. 处理绝对值用于幅度编码 int abs_sample abs(input_sample); // 3. 确定段落索引 (0~7) 和段落码 (a2, a3, a4) int segment_index determine_segment(abs_sample); // 将段落索引0-7转换为3位二进制存入pcm_code[1]-[3] pcm_code[1] (segment_index 2) 0x01; // 最高位 - a2 pcm_code[2] (segment_index 1) 0x01; // 次高位 - a3 pcm_code[3] segment_index 0x01; // 最低位 - a4 // 4. 确定段内码 (a5, a6, a7, a8) int internal_code[4] {0}; encode_segment_internal(abs_sample, segment_index, internal_code); for (int i 0; i 4; i) { pcm_code[4 i] internal_code[i]; } // 5. 进行码型转换 convert_7_to_11(segment_index, internal_code, linear_11_code); convert_7_to_12(segment_index, internal_code, linear_12_code); // 6. 计算并输出结果 int seg_num segment_index 1; // 转换为1~8的段号 int step QUANT_STEP[segment_index]; int start START_LEVEL[segment_index]; // 计算段内电平值0~15 int level_in_seg internal_code[0]*8 internal_code[1]*4 internal_code[2]*2 internal_code[3]; // 计算重建值取量化级中点 int reconstructed start level_in_seg * step step / 2; int quant_error abs_sample - reconstructed; printf(\n 编码结果 \n); printf(输入抽样值: %d\n, input_sample); printf(绝对值: %d\n, abs_sample); printf(位于第 %d 段\n, seg_num); printf(本段起始电平: %d\n, start); printf(本段量化间隔: %d\n, step); printf(段内电平索引: %d\n, level_in_seg); printf(重建电平值: %d\n, reconstructed); printf(量化误差: %d\n, quant_error); printf(-----------------------------\n); printf(8位PCM编码结果: ); for (int i 0; i 8; i) printf(%d, pcm_code[i]); printf(\n); printf(7位非线性码(a2-a8): ); for (int i 1; i 8; i) printf(%d, pcm_code[i]); printf(\n); printf(7/11转换线性码: ); for (int i 0; i 11; i) printf(%d, linear_11_code[i]); printf(\n); printf(7/12转换线性码: ); for (int i 0; i 12; i) printf(%d, linear_12_code[i]); printf(\n\n); return 0; } // 获取极性位正数为1负数为0 int get_polarity_bit(int sample) { return (sample 0) ? 1 : 0; } // 确定段落索引 (0~7) int determine_segment(int abs_value) { // 简单的if-else链实现清晰对应硬件判决流程 if (abs_value START_LEVEL[4]) { // 128 后4段(4,5,6,7索引) if (abs_value START_LEVEL[6]) { // 512, 第7或8段 if (abs_value START_LEVEL[7]) { // 1024 return 7; // 第8段 } else { return 6; // 第7段 } } else { // 在128和512之间第5或6段 if (abs_value START_LEVEL[5]) { // 256 return 5; // 第6段 } else { return 4; // 第5段 } } } else { // 128 前4段(0,1,2,3索引) if (abs_value START_LEVEL[2]) { // 32, 第3或4段 if (abs_value START_LEVEL[3]) { // 64 return 3; // 第4段 } else { return 2; // 第3段 } } else { // 32 第1或2段 if (abs_value START_LEVEL[1]) { // 16 return 1; // 第2段 } else { return 0; // 第1段 } } } } // 对指定段内的信号进行4次逐次比较生成4位段内码 void encode_segment_internal(int abs_value, int seg_idx, int internal_code[4]) { int start START_LEVEL[seg_idx]; int delta QUANT_STEP[seg_idx]; int remainder abs_value - start; // 段内偏移量 int weight[4] {8, 4, 2, 1}; // 四次比较的权重 int accumulated 0; for (int i 0; i 4; i) { if (remainder accumulated weight[i] * delta) { internal_code[i] 1; accumulated weight[i] * delta; } else { internal_code[i] 0; } } } // 7位非线性码转11位线性码 void convert_7_to_11(int seg_idx, int internal_code[4], int linear_11[11]) { // 先将11位数组清零 for (int i 0; i 11; i) linear_11[i] 0; if (seg_idx 0) { // 第1段特殊处理段内码直接放在低4位 for (int i 0; i 4; i) { linear_11[7 i] internal_code[i]; } } else { // 其他段在特定位置置1然后紧跟段内码 int pos 7 - seg_idx; // 计算标志位“1”的位置 linear_11[pos] 1; for (int i 0; i 4; i) { linear_11[pos 1 i] internal_code[i]; } } } // 7位非线性码转12位线性码 void convert_7_to_12(int seg_idx, int internal_code[4], int linear_12[12]) { // 先将12位数组清零 for (int i 0; i 12; i) linear_12[i] 0; if (seg_idx 0) { // 第1段特殊处理 for (int i 0; i 4; i) { linear_12[7 i] internal_code[i]; } linear_12[11] 1; // 多出一位置1 } else { int pos 7 - seg_idx; linear_12[pos] 1; for (int i 0; i 4; i) { linear_12[pos 1 i] internal_code[i]; } linear_12[pos 5] 1; // 多出一位置1 } }你可以将这段代码复制到任何C语言编译器中如GCC, Visual Studio, Code::Blocks等进行编译和运行。测试时可以输入一些边界值和典型值比如-2048, -1025, -512, -100, -16, 0, 16, 100, 500, 1024, 2047。观察输出结果是否符合13折线的量化规律。例如输入500它应该落在第7段段落码110因为512500256。通过手动计算和程序输出对比你能更牢固地掌握整个编码过程。7. 从仿真到实战在嵌入式场景中的应用思考当你成功在电脑上运行了这个编码器并理解了每一行代码的含义后你可能已经在想这玩意儿怎么用到实际项目里我最初把这个算法移植到一块STM32单片机上时就遇到过几个很实际的问题。第一性能与优化。在PC上跑我们不太关心时钟周期。但在资源受限的嵌入式MCU上特别是如果需要对音频进行实时编码比如8kHz采样率每125微秒就要处理一个样点效率就很重要了。原始的嵌套if-else判断虽然直观但分支较多。一个优化思路是使用查找表。我们可以预先计算好所有2048个可能的输入绝对值0-2047所对应的7位幅度码a2-a8并将其存储在一个大小为2048的常量数组中。这样编码过程就变成了判断极性a1。取绝对值作为索引直接从表中读出7位幅度码。 这种方法用空间换时间编码速度是O(1)的极其快速非常适合对实时性要求高的场景。缺点是占用了大约2KB的ROM空间。对于现在的单片机来说这点空间通常可以接受。第二定点数与精度。我们的示例输入输出都是整数。但在实际系统中从ADC模数转换器采样来的值可能范围不同或者需要处理浮点数。这时就需要进行标定和缩放。例如ADC是12位的输出0-4095而我们的编码器期望输入是-2048~2047。那么就需要一个线性映射pcm_input (adc_value - 2048)。如果信号是浮点范围的[-1.0, 1.0]则需要先乘以2048再取整。这里会引入舍入误差需要在系统设计时考虑。第三集成与调试。在一个完整的语音编解码或通信系统中PCM编码器只是一个模块。它前面连接着抗混叠滤波器、采样保持电路和ADC后面可能连接着并/串转换器、调制器或数字接口。在软件上你需要提供一个清晰的API比如pcm_encode(int16_t sample, uint8_t *code)让它易于被上层调用。调试时除了看最终的编码输出更应该关注量化信噪比。你可以输入一个正弦波序列将编码后再解码解码过程是编码的逆过程的信号与原始信号比较计算均方误差来衡量编码器的性能是否符合理论预期。第四理解“非均匀”的意义。最后别忘了我们实现的是一个非均匀量化编码器。它的精髓就在于那张QUANT_STEP表。为什么第1段的间隔是1而第8段是64这是为了在编码位数有限8位的情况下尽可能提升小信号时的信噪比使其听感上更接近均匀量化。你可以做个实验修改QUANT_STEP表让所有段的量化间隔都变成16模拟均匀量化然后用同样的正弦波测试。你会发现大信号的信噪比可能变化不大但小信号的信噪比会急剧恶化从而理解13折线律设计的巧妙之处。亲手实现这个算法就像搭积木一样把通信原理课本上的方块图、流程图变成了有生命力的代码。它不再是一堆需要死记硬背的公式而是一个你可以任意修改、测试、观察其行为的工具。下次当你再听到“PCM编码”这个词时你脑海里浮现的将不再是一个黑盒而是一串清晰的if-else判断、一个精心设计的查找表以及那8位由0和1组成的、承载着声音信息的数字密码。这种从理论到实践再从代码回归对理论更深层次理解的过程正是嵌入式开发和技术学习的乐趣所在。