第二题:QPSK仿真——从理论到代码的完整实现

各位同学,今天我们来聊聊QPSK仿真。说实话,QPSK(正交相移键控)是我个人非常喜欢的一种调制方式。为什么?因为它简单、高效,而且在工程中特别实用。我记得刚入行那会儿,第一个独立负责的项目就是基于QPSK的卫星通信系统。那时候踩了不少坑,今天我把这些经验都揉进代码里,咱们一起走一遍。

一、QPSK调制原理——先别急着写代码

QPSK本质上就是把二进制比特流映射到四个相位点上。每个符号携带2个比特,所以传输效率比BPSK翻了一倍。你想想看,同样的带宽,数据速率直接翻倍,这买卖划算吧?

具体来说,QPSK的四个星座点分别对应:

  • 00 → 相位 45°(π/4)
  • 01 → 相位 135°(3π/4)
  • 11 → 相位 225°(5π/4)
  • 10 → 相位 315°(7π/4)

这里有个细节要注意:映射顺序不是随便定的。格雷编码(相邻星座点只差1个比特)能有效降低误码率。我在项目中吃过这个亏——第一次做QPSK时用了自然编码,结果误码率比理论值高了0.5dB。后来改成格雷编码,立马就对了。

核心公式:

QPSK信号可以表示为:s(t) = I(t)·cos(2πfct) - Q(t)·sin(2πfct)

其中I(t)是同相分量,Q(t)是正交分量。说白了,就是把两个BPSK信号叠加在一起,一个在cos上,一个在sin上。

二、Python实现QPSK调制——动手吧

好,理论讲完了,咱们直接上代码。我个人习惯用NumPy处理数组,用Matplotlib画图。先写一个QPSK调制函数:

import numpy as np
import matplotlib.pyplot as plt

def qpsk_modulate(bits):
    """
    QPSK调制函数
    bits: 输入比特流,长度必须为偶数
    返回: 复数形式的QPSK符号
    """
    # 确保比特数为偶数
    if len(bits) % 2 != 0:
        raise ValueError("比特数必须为偶数")
    
    # 将比特流分组,每2个比特映射为一个符号
    symbols = []
    for i in range(0, len(bits), 2):
        bit_pair = bits[i:i+2]
        # 格雷编码映射
        if bit_pair == [0, 0]:
            symbols.append(complex(1/np.sqrt(2), 1/np.sqrt(2)))   # 45°
        elif bit_pair == [0, 1]:
            symbols.append(complex(-1/np.sqrt(2), 1/np.sqrt(2)))  # 135°
        elif bit_pair == [1, 1]:
            symbols.append(complex(-1/np.sqrt(2), -1/np.sqrt(2))) # 225°
        elif bit_pair == [1, 0]:
            symbols.append(complex(1/np.sqrt(2), -1/np.sqrt(2)))  # 315°
    
    return np.array(symbols)

嗯,这里要注意:我用了1/√2作为幅度归一化。为什么?因为每个符号的能量要归一化到1。如果不做归一化,后面加噪声时信噪比计算会出问题。我曾经见过有人直接用(1,1)作为星座点,结果误码率曲线怎么都对不上理论值——就是归一化没做好。

三、添加AWGN噪声——模拟真实信道

信号发出去,总要经过信道。AWGN(加性高斯白噪声)是最基本的信道模型。说白了,就是在信号上叠加一个高斯分布的随机噪声。

这里有个关键点:噪声功率怎么算?根据信噪比SNR来算。SNR = 10·log10(信号功率/噪声功率)。既然我们信号功率归一化为1,那么噪声功率就是10(-SNR/10)

def add_awgn(symbols, snr_db):
    """
    添加AWGN噪声
    symbols: 输入符号(复数)
    snr_db: 信噪比,单位dB
    返回: 加噪后的符号
    """
    # 计算信号功率
    signal_power = np.mean(np.abs(symbols)**2)
    
    # 根据SNR计算噪声功率
    snr_linear = 10**(snr_db/10)
    noise_power = signal_power / snr_linear
    
    # 生成复高斯噪声
    noise = np.sqrt(noise_power/2) * (np.random.randn(len(symbols)) + 
                                      1j * np.random.randn(len(symbols)))
    
    return symbols + noise

避坑指南:我曾经犯过一个错误——直接用np.random.randn生成噪声,忘了除以2。结果噪声功率翻倍,误码率曲线整体右移了3dB。找了半天才发现问题。记住:复噪声的实部和虚部各占一半功率。

四、QPSK解调——把信号变回比特

解调就是调制的逆过程。收到信号后,我们要判断每个符号属于哪个象限,然后映射回比特对。这里用的是最小欧氏距离判决法——说白了,就是看接收到的符号离哪个星座点最近。

def qpsk_demodulate(received_symbols):
    """
    QPSK解调
    received_symbols: 接收到的符号(复数)
    返回: 解调后的比特流
    """
    # 定义四个星座点
    constellation = [
        complex(1/np.sqrt(2), 1/np.sqrt(2)),   # 00
        complex(-1/np.sqrt(2), 1/np.sqrt(2)),  # 01
        complex(-1/np.sqrt(2), -1/np.sqrt(2)), # 11
        complex(1/np.sqrt(2), -1/np.sqrt(2))   # 10
    ]
    
    # 对应的比特映射
    bit_mapping = [[0,0], [0,1], [1,1], [1,0]]
    
    bits = []
    for symbol in received_symbols:
        # 计算到四个星座点的距离
        distances = [np.abs(symbol - ref) for ref in constellation]
        # 找到最近的点
        idx = np.argmin(distances)
        # 映射回比特
        bits.extend(bit_mapping[idx])
    
    return np.array(bits)

五、误码率计算——验证你的系统

误码率(BER)是衡量通信系统性能的核心指标。计算很简单:统计错误比特数除以总比特数。但要注意,一定要对比发送的原始比特和解调后的比特,不能自己跟自己比。

def calculate_ber(original_bits, demodulated_bits):
    """
    计算误码率
    """
    if len(original_bits) != len(demodulated_bits):
        raise ValueError("比特长度不一致")
    
    errors = np.sum(original_bits != demodulated_bits)
    ber = errors / len(original_bits)
    return ber

我建议你跑一个完整的仿真链路:生成随机比特 → QPSK调制 → 加噪声 → QPSK解调 → 计算BER。然后改变SNR,画出BER曲线。你会发现,当SNR从0dB增加到10dB时,BER会从大约0.1降到10-4以下。

六、星座图可视化——一眼看出问题

星座图是调试通信系统最直观的工具。没有之一。我每次做实验,第一件事就是画星座图。它能告诉你:噪声大不大?有没有相位偏移?有没有IQ不平衡?

def plot_constellation(symbols, title="QPSK Constellation"):
    """
    绘制星座图
    """
    plt.figure(figsize=(8, 8))
    plt.scatter(symbols.real, symbols.imag, alpha=0.6, s=20)
    plt.grid(True, alpha=0.3)
    plt.xlabel("In-phase (I)")
    plt.ylabel("Quadrature (Q)")
    plt.title(title)
    plt.axis('equal')
    plt.xlim(-2, 2)
    plt.ylim(-2, 2)
    plt.show()

重要提醒:画星座图时一定要用plt.axis('equal')。如果不加这行,I轴和Q轴的比例可能不一样,星座点看起来会是椭圆形的,容易误判。我见过有人因为这个设置,把正常的QPSK信号误判成IQ不平衡。

七、完整仿真流程——串起来跑一遍

好了,所有模块都写好了。咱们把它们串起来,跑一个完整的仿真:

# 参数设置
num_bits = 100000  # 发送10万个比特
snr_db_range = np.arange(0, 15, 2)  # SNR从0到14dB,步进2dB

# 存储结果
ber_results = []

for snr_db in snr_db_range:
    # 1. 生成随机比特
    tx_bits = np.random.randint(0, 2, num_bits)
    
    # 2. QPSK调制
    tx_symbols = qpsk_modulate(tx_bits)
    
    # 3. 添加AWGN噪声
    rx_symbols = add_awgn(tx_symbols, snr_db)
    
    # 4. QPSK解调
    rx_bits = qpsk_demodulate(rx_symbols)
    
    # 5. 计算误码率
    ber = calculate_ber(tx_bits, rx_bits)
    ber_results.append(ber)
    
    print(f"SNR = {snr_db} dB, BER = {ber:.6f}")

# 绘制BER曲线
plt.figure(figsize=(10, 6))
plt.semilogy(snr_db_range, ber_results, 'bo-', label='Simulated QPSK')
plt.grid(True, alpha=0.3)
plt.xlabel('SNR (dB)')
plt.ylabel('Bit Error Rate (BER)')
plt.title('QPSK BER Performance in AWGN Channel')
plt.legend()
plt.show()

跑完这段代码,你会得到一条漂亮的BER曲线。把它和理论值对比一下——如果吻合得很好,说明你的实现没问题。如果不吻合,嗯,那就得回头检查了。我建议你先从高SNR开始测试,比如15dB,这时候BER应该非常低(接近0)。如果高SNR下BER还很高,那肯定是代码有bug。

八、知识体系总览

为了让你对整个QPSK仿真流程有个全局认识,我画了一张流程图:

QPSK仿真完整流程 生成随机比特 QPSK调制 添加AWGN噪声 QPSK解调 计算误码率(BER) 星座图可视化 图1:QPSK仿真完整流程(从比特生成到误码率计算)

这张图把整个流程串起来了。你写代码的时候,就按这个顺序来。每一步的输出都是下一步的输入,逻辑非常清晰。

九、总结与经验分享

做QPSK仿真,说白了就是三件事:调得好、噪得准、解得对。调得好指星座点映射要正确;噪得准指噪声功率计算要精确;解得对指判决门限要合理。

我个人建议你多做几组对比实验:

  • 对比格雷编码和自然编码的BER差异
  • 对比不同SNR下的星座图变化
  • 对比理论BER曲线和仿真BER曲线

这些对比做下来,你对QPSK的理解会深入很多。我记得当年做这些实验时,光是星座图就画了几十张。每张图都记录下SNR和BER,最后总结出一张完整的性能表。那种从数据中看到规律的感觉,真的很爽。

好,QPSK仿真就讲到这里。代码都给你了,赶紧动手试试吧。有问题随时交流。


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