ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从零手搓BPSK调制器:C语言实现与避坑指南

从零手搓BPSK调制器:C语言实现与避坑指南

简介:这份资源面向通信原理学习者与C语言开发者,聚焦BPSK调制解调技术的完整实现,帮助读者在AWGN信道下理解数字通信链路并评估误码率性能。压缩包共8个文件,以6个cpp源文件为主,另有1个c文件与1个bgi图形驱动文件,整体约8KB,涵盖调制解调核心算法、噪声生成、误差函数计算及数据签名等辅助模块,结构紧凑便于逐文件研读。已有726人学习下载,说明其在同类基础实验中具有一定参考价值。读者可从中获得BPSK相位映射、高斯白噪声模拟、误码率统计等关键环节的C语言实现思路,并借助误差函数与图形驱动模块完成性能曲线绘制,适合作为课程设计、通信仿真入门或算法移植的参考素材。

1. 从零手搓 BPSK:为什么我劝你先用 C 语言把调制器写一遍

如果你正在做数字通信的课设、准备电赛的数据链模块,或者单纯想搞明白“一个比特到底是怎么变成一段正弦波”的,那这份基于 C 语言的 BPSK 实现资源,大概率能帮你省下不少翻教材的时间。BPSK(二进制相移键控)是所有数字调制里最朴素的一种:一个比特对应一个相位,0 和 1 分别映射到 0 度和 180 度。听起来简单到不像话,但真让你用 C 语言从比特流一路写到采样点输出,中间关于相位连续性、符号速率、载波频率、采样率这几件事怎么摆平,才是真正卡人的地方。这份资源给的不是一个调库调参的 Demo,而是一套能编译、能跑、能拿示波器或者音频接口看波形的完整链路,适合刚学完《通信原理》但没动过手的学生,也适合想快速验证解调算法、需要一个干净基线的工程师。我拿到之后第一件事就是把它拆开,看它到底怎么处理相位跳变和定时关系——这两处要是含糊,后面加噪声、加频偏全是玄学。

2. 拆开 BPSK 调制器:比特映射、载波生成与采样率怎么定

2.1 比特到相位的映射逻辑与差分编码的取舍

BPSK 最核心的一步就是把 0/1 映射成相位。常见做法有两种:一种直接映射,比特 0 对应相位 0,比特 1 对应相位 π;另一种先做差分编码,用“相位是否变化”来携带信息。这份资源用的是直接映射,代码里一个if就写完了,但这里有个容易被忽略的坑:直接映射在接收端需要知道绝对相位参考,一旦信道引入固定相偏,解调就会整体翻转。差分编码能绕开这个问题,代价是误码会成对出现。我一般建议课设阶段先用直接映射把链路跑通,等要加信道仿真了再换成差分,改动量其实很小,就在映射表那里动几行。

/* 直接映射:bit 为 0 输出 +1,为 1 输出 -1 */ float bpsk_map(int bit) { return (bit == 0) ? 1.0f : -1.0f; } /* 差分编码版本:用当前比特与前一比特的异或决定相位变化 */ int diff_encode(int bit, int *prev) { int out = bit ^ (*prev); *prev = bit; return out; }

上面两个函数是整条链路的起点。bpsk_map返回的是归一化幅度,后面乘上载波就是实际波形。diff_encode里*prev需要调用方维护,初始值一般设 0。注意差分编码后仍然要过bpsk_map,别直接把异或结果当相位用。

2.2 载波频率、符号速率与采样率的三角关系

这三个参数定不好,后面全是血泪。符号速率 Rs 决定一个比特占多长时间,载波频率 fc 决定正弦波多密,采样率 fs 决定你一个符号能采多少个点。工程上一般要求 fs 至少是 fc 的 4 倍以上,否则波形看起来像锯齿,解调时积分判决会偏。这份资源里默认给了一组参数:Rs = 1000 bps,fc = 10 kHz,fs = 100 kHz,也就是一个符号 100 个采样点,一个载波周期 10 个采样点。这个比例比较舒服,既不会让数组太大,也能看清相位跳变。

#define SYMBOL_RATE 1000.0f /* 符号速率,单位 bps */ #define CARRIER_FREQ 10000.0f /* 载波频率,单位 Hz */ #define SAMPLE_RATE 100000.0f /* 采样率,单位 Hz */ #define SAMPLES_PER_SYMBOL (int)(SAMPLE_RATE / SYMBOL_RATE) /* 生成一个符号内的 BPSK 波形,phase 为当前符号对应的相位 */ void gen_symbol(float *buf, int symbol_val, float *phase) { float w = 2.0f * M_PI * CARRIER_FREQ / SAMPLE_RATE; for (int i = 0; i < SAMPLES_PER_SYMBOL; i++) { buf[i] = symbol_val * cosf(*phase); *phase += w; if (*phase > 2.0f * M_PI) *phase -= 2.0f * M_PI; } }

gen_symbol里用*phase累加而不是每次重新算cos(2πfct),是为了保证符号之间相位连续。如果每个符号都从 0 开始算,符号边界会出现人为的相位跳变,频谱会鼓包。symbol_val就是bpsk_map的输出,+1 或 -1。SAMPLES_PER_SYMBOL必须是整数,所以 fs 要选成 Rs 的整数倍,否则会累积定时误差。

2.3 用文件做比特源和波形输出的最小闭环

资源里没有搞复杂的实时采集,而是用文本文件读比特、用二进制文件写浮点采样点,这样在 PC 上就能跑完整流程。输入文件一行一个 0 或 1,输出文件是 float32 的 IQ 或者实信号。我一般会再加一个 Python 脚本把输出文件画出来,确认眼图张开、相位跳变干净。

/* 从文本文件读比特,生成 BPSK 采样点写入二进制文件 */ int main(void) { FILE *fin = fopen("bits.txt", "r"); FILE *fout = fopen("bpsk.bin", "wb"); if (!fin || !fout) return -1; float phase = 0.0f; float buf[SAMPLES_PER_SYMBOL]; int bit; while (fscanf(fin, "%d", &bit) == 1) { int sym = bpsk_map(bit); gen_symbol(buf, sym, &phase); fwrite(buf, sizeof(float), SAMPLES_PER_SYMBOL, fout); } fclose(fin); fclose(fout); return 0; }

这段main就是最小可跑闭环。bits.txt里随便写几十个 0/1,编译后运行,bpsk.bin就是基带实信号。用 Python 的numpy.fromfile读出来,matplotlib一画,符号边界和载波周期一目了然。注意fscanf的返回值判断,别用while(!feof(fin)),那个会多读一次。

3. 解调端怎么接:相干积分、判决门限与定时同步的简化处理

3.1 相干解调的核心:乘同频载波再积分

调制是把比特搬上载波,解调就是把它搬下来。BPSK 相干解调的标准动作是:接收信号乘以同频同相的载波,然后在一个符号周期内积分,积分结果大于 0 判 0,小于 0 判 1。这份资源里解调部分和调制共用同一组参数,载波相位从 0 开始,相当于理想同步。实际工程里载波恢复和定时恢复是两大块独立模块,但课设阶段先把这个理想模型跑通,再逐步加频偏和相偏,思路会清晰很多。

/* 对一个符号周期的采样点做相干积分并判决 */ int bpsk_demod(const float *buf, float *phase) { float w = 2.0f * M_PI * CARRIER_FREQ / SAMPLE_RATE; float acc = 0.0f; for (int i = 0; i < SAMPLES_PER_SYMBOL; i++) { acc += buf[i] * cosf(*phase); *phase += w; if (*phase > 2.0f * M_PI) *phase -= 2.0f * M_PI; } return (acc > 0.0f) ? 0 : 1; }

acc就是积分结果,这里用累加代替连续积分。phase的推进必须和调制端完全一致,否则积分结果会随相偏余弦衰减。判决门限设 0 是因为 BPSK 两个星座点对称,如果信道有直流偏置,门限要相应调整。

3.2 定时误差从哪来:符号起始点对不齐会怎样

上面解调代码默认每个符号的起始采样点就是数组第 0 个,但实际接收时你根本不知道符号边界在哪。如果起始点偏了哪怕几个采样点,积分区间就跨到了相邻符号,判决结果会随机出错。这份资源里为了简化,假设定时完美,但我在复现时故意把接收数组偏移了 5 个点,误码率直接从 0 跳到 0.3 以上。所以如果你要拿它做信道仿真,定时同步这一环不能省,常见做法是用早迟门或者平方环先估计符号边界,再对齐积分区间。

/* 简单演示:接收数组偏移 offset 个采样点后再解调 */ int demod_with_offset(const float *rx, int offset, int nbits) { float phase = 0.0f; int err = 0; for (int k = 0; k < nbits; k++) { int bit = bpsk_demod(rx + offset + k * SAMPLES_PER_SYMBOL, &phase); /* 这里假设已知发送比特,实际用误码统计 */ if (bit != tx_bits[k]) err++; } return err; }

offset就是人为引入的定时偏差。你可以写个循环从 0 扫到SAMPLES_PER_SYMBOL-1,看误码数怎么随偏移变化,一般会在符号边界附近出现一个平坦区,那就是最佳采样时刻。这个扫描过程能帮你直观理解为什么定时同步模块要输出一个“误差信号”去调整采样相位。

3.3 加高斯白噪声:Eb/N0 怎么折算成采样点上的方差

要验证解调器性能,得加噪声。BPSK 在 AWGN 下的理论误码率是Q(sqrt(2Eb/N0)),但代码里加的是采样点级别的高斯噪声,方差要按采样率折算。这份资源里给了一个add_noise函数,思路是先算符号能量,再根据目标 Eb/N0 算噪声功率。我见过不少人直接把 Eb/N0 当 SNR 用,结果曲线和理论对不上,翻车就翻在这里。

/* 向采样点添加高斯白噪声,target_ebn0 单位 dB */ void add_noise(float *buf, int n, float target_ebn0) { float ebn0 = powf(10.0f, target_ebn0 / 10.0f); float eb = 1.0f; /* 每个符号能量,幅度为 1 时 */ float n0 = eb / ebn0; float sigma = sqrtf(n0 * SAMPLE_RATE / (2.0f * SYMBOL_RATE)); for (int i = 0; i < n; i++) { float u1 = (float)rand() / RAND_MAX; float u2 = (float)rand() / RAND_MAX; float g = sqrtf(-2.0f * logf(u1)) * cosf(2.0f * M_PI * u2); buf[i] += sigma * g; } }

sigma的表达式是关键:n0 * fs / (2 * Rs)再开根号。因为一个符号内有fs/Rs个采样点,噪声功率要按带宽比例分配。u1不能为 0,实际用的时候加个极小值保护。跑完加噪再解调,统计误码,和erfc算出来的理论值对比,如果差得远,先检查sigma对不对。

4. 避坑与排查:BPSK 从仿真到波形输出的五个常见翻车点

4.1 波形看起来像调幅而不是调相

现象:用音频软件播放bpsk.bin,听到的是忽大忽小的声音,示波器上看包络起伏明显。原因:符号映射后没有做相位连续累加,每个符号都从cos(0)重新开始,符号边界出现幅度跳变。解决:像gen_symbol那样维护一个全局phase变量,跨符号累加,保证相位连续。如果用的是查表法,表索引也要跨符号递增。

4.2 解调误码率始终在 0.5 附近

现象:不管加不加噪声,解出来的比特和发送比特一半对一半错。原因:解调载波相位和调制载波相位差了 π,导致积分结果整体反号。解决:检查两边phase初始值是否一致,或者把判决门限反过来。如果是差分编码,检查prev初始值是否两端一致。

4.3 采样率不是符号速率整数倍导致累积偏移

现象:跑长比特流时,前面解调正确,后面误码越来越多。原因:SAMPLES_PER_SYMBOL用整数除法截断,每个符号少采了零点几个点,累积几千个符号后定时偏出一个符号。解决:把SAMPLE_RATE改成SYMBOL_RATE的整数倍,或者用浮点累加器记录每个符号的实际采样区间。

4.4 噪声方差算错导致 Eb/N0 曲线整体平移

现象:误码率曲线形状对,但和理论值差好几个 dB。原因:把 Eb/N0 直接当 SNR 用,忽略了采样率与符号速率的比值。解决:按sigma = sqrt(N0 * fs / (2 * Rs))折算,其中N0 = Eb / ebn0。验证方法:不加噪声时误码为 0,加噪声后曲线斜率应与理论一致。

4.5 用feof控制读文件循环多读一个比特

现象:bits.txt里明明只有 100 个比特,输出波形却有 101 个符号。原因:while(!feof(fin))在最后一次读取后 feof 还没置位,循环体多执行一次,bit保持上一次的值。解决:用while(fscanf(fin, "%d", &bit) == 1),让读取函数的返回值控制循环。

5. 进阶玩法:把 BPSK 链路改成可配置的仿真框架

跑通基础版之后,我习惯把它改成一个带命令行参数的仿真框架,这样换参数不用重新编译。用getopt解析-r符号速率、-f载波频率、-s采样率、-eEb/N0,然后动态分配缓冲区。这样一套代码能同时跑课设演示和批量误码率扫描。

/* 简易命令行参数解析,编译后运行 ./bpsk -r 1000 -f 10000 -s 100000 -e 8 */ int main(int argc, char **argv) { float rs = 1000.0f, fc = 10000.0f, fs = 100000.0f, ebn0 = 8.0f; int opt; while ((opt = getopt(argc, argv, "r:f:s:e:")) != -1) { switch (opt) { case 'r': rs = atof(optarg); break; case 'f': fc = atof(optarg); break; case 's': fs = atof(optarg); break; case 'e': ebn0 = atof(optarg); break; } } int sps = (int)(fs / rs); /* 后续用 sps 替代宏,动态分配 buf */ return 0; }

改完之后,sps不再是编译期常量,gen_symbol和bpsk_demod都要改成接收sps参数。动态分配用malloc(sps * sizeof(float)),记得在程序退出前free。这样你可以写个 shell 循环,把 Eb/N0 从 0 扫到 12,每个点跑一万个比特,统计误码率,最后用 Python 画出来和erfc理论曲线叠在一起。我每次改完参数都会先跑一个 Eb/N0 = 10 dB 的点,看误码率是不是在 1e-3 量级,如果差太多,先查sigma和sps的整数性。从那以后我每次动采样率相关参数,都强制走一遍这个单点验证,省得后面批量跑完才发现曲线整体歪了。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进