FPGA 记录

煮波之前学了很多东西,比如小程序,网页三件套,esp32 等等,由于这些东西的学习没有持续很久,三四个星期到两三个月不等,而且没有像C 语言这种类型的技能一样一直有在接触和使用,所以都忘得差不多了。写这个记录的主要目的就是给自己学过的内容开个证明,其次是等我要回看这部分内容时,先看这个记录效率会来得高一点。


FPGA 背景

FPGA 的主流开发厂商有Xilinx 和Altera (以及被Intel 收购),其对应的开发软件是Vivado 和 Quartus。

FPGA 是与单片机同一个层次的概念,两者在功能的完备性方面来说可以互相替代。也就是说用单片机的地方,用FPGA 也可以,换过来同样成立。区别在哪里呢?
单片采用的是冯洛伊曼架构,跟电脑主机相似, 其内部有CPU ,内存(RAM),硬盘(FLASH 等)。其工作的过程是有时序特点的,CPU 反复从硬盘中读取命令来对内存中的数据进行操作,通俗的来说,代码命令是一句句执行的。
FPGA 是一块儿电路,这个电路是可编辑的。 当你需要一块儿ROM 你可以用代码从底层去描述这个ROM 的构成,从逻辑门开始,一步步构成。编写完成ROM 的描述后,软件会自动将其转化为硬件架构,烧录到芯片后,这个ROM 就实打实的生成到了芯片中。除了ROM,UART,IIC 这些熟悉的外设外,其他任何外设,只要你能想得到,都能通过硬件描述语言描述,在FPFA 内部生成。其运行的时候,所有模块的电路都是同时运行的,只是在不同的时间里面,同一个模块的动作不同并且可能啥都不做罢了。

语言区别。 单片机的编程语言都是类似C 这种指令,最后都要转化成机器码让CPU 一句句执行,它们描述的是指令,是动作。FPGA 的编程语言是对硬件结构的描述,就像建筑的图纸,每张图纸都对应着一个建筑,它们描述的是硬件结构。

单片机的硬件资源是固定了的,一块儿芯片上有的外设数量和种类在出厂时已经确定了,而FPGA 则是在总资源有限的情况下,外设数量种类都是自定义的。FPGA 的优势就在于它的灵活性。

当时学计算机组成原理的时候,不太理解这东西,是因为没见过FPGA ,脑子里没有与其同层次的概念。

开发环境与流程

主流语言有Verilog HDL 和VHDL 前者更简单,后者更系统。这十几天我在海云捷迅那边用的是Altera 的cyclone IV(芯片),在Quartus 平台上用Verilog HDL进行开发。

开发流程的话,大概是这样。先确定要实现什么功能,如果方便的话可以画出输入,中间变量,输出的时序图,在软件上编写代码描述实现这个输入输出功能硬件。然后编译检查语法,根据编译好的结果将输入输出与芯片引脚绑定。最后就是烧录进芯片,然后调试。

开发(编码)

模块化。 编写代码之前先将整个模块划分成多个子模块,理清楚每个模块的输入输出是什么,以及模块与模块之间的联系。可以画图(没画完,懒):

基本语法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
module tube_ctl (
input wire clk,
input wire rst_n,
input wire [47:0] num_display,

output reg [5:0] SEL,
output reg [7:0] DIG
);

// 解包
wire [7:0] numbers[5:0];
assign numbers[0] = num_display[7:0];
assign numbers[1] = num_display[15:8];
assign numbers[2] = num_display[23:16];
assign numbers[3] = num_display[31:24];
assign numbers[4] = num_display[39:32];
assign numbers[5] = num_display[47:40];
/********************************状态机写法*******************************/
parameter [5:0] s1 = 6'b000_001;
parameter [5:0] s2 = 6'b000_010;
parameter [5:0] s3 = 6'b000_100;
parameter [5:0] s4 = 6'b001_000;
parameter [5:0] s5 = 6'b010_000;
parameter [5:0] s6 = 6'b100_000;
reg [5:0] cur_state;
reg [5:0] next_state;

// 第一段
always @(posedge clk or negedge rst_n) begin
if(!rst_n)begin
cur_state <= s1;
end
else begin
cur_state <= next_state;
end
end
// 第二段
// 计时
reg [15:0] cnt;
wire [15:0] reach_1ms;
assign reach_1ms = (16'd50_000 - 16'b1 == cnt);
always @(posedge clk or negedge rst_n) begin
if(!rst_n)begin
cnt <= 16'b0;
end
else if(reach_1ms)begin
cnt <= 16'b0;
end
else begin
cnt <= cnt + 16'b1;
end
end
// 状态转移
always @(*) begin
if(!rst_n)begin
next_state <= s1;
end
else begin
case (cur_state)
s1:begin
if(reach_1ms)
next_state <= s2;
else
next_state <= cur_state;
end
s2:begin
if(reach_1ms)
next_state <= s3;
else
next_state <= cur_state;
end
s3:begin
if(reach_1ms)
next_state <= s4;
else
next_state <= cur_state;
end
s4:begin
if(reach_1ms)
next_state <= s5;
else
next_state <= cur_state;
end
s5:begin
if(reach_1ms)
next_state <= s6;
else
next_state <= cur_state;
end
s6:begin
if(reach_1ms)
next_state <= s1;
else
next_state <= cur_state;
end
default:next_state <= s1;
endcase
end
end
// 第三段
always @(*) begin
if(!rst_n)begin
SEL <= 6'b111_111;
DIG <= 8'h0;
end
else begin
case (cur_state)
s1:begin
SEL <= 6'b011111;
DIG <= numbers[0];
end
s2:begin
SEL <= 6'b101111;
DIG <= numbers[1];
end
s3:begin
SEL <= 6'b110111;
DIG <= numbers[2];
end
s4:begin
SEL <= 6'b111011;
DIG <= numbers[3];
end
s5:begin
SEL <= 6'b111101;
DIG <= numbers[4];
end
s6:begin
SEL <= 6'b111110;
DIG <= numbers[5];
end
endcase
end
end

endmodule

时序逻辑与组合逻辑。 每个模块都有自己的时钟。假如某个变量在本周期内的某个时刻发生了变化,采用时序逻辑编写的模块需要等到下个周期的上升沿才能获取到这个变化,而组合逻辑则在变化的一瞬间就已经获取。体现在硬件上可以理解为,时序逻辑的信息获取由触发器控制,只在上升沿的一瞬间才会触发,组合逻辑则是直接连线。它们在代码上也有区别。
组合逻辑:

1
2
3
4
5
6
7
8
9
10
always @(*) begin
if(!rst_n)begin

end
else begin

end

wire [15:0] reach_1ms;
assign reach_1ms = (16'd50_000 - 16'b1 == cnt);

时序逻辑:

1
2
3
4
5
6
7
8
always @(posedge clk or negedge rst_n) begin
if(!rst_n)begin

end
else begin

end
end

状态机。 由于FPGA 中所有的模块都是同时运行的:各自都跟着自己的时钟独立运行,模块之间没有时间顺序,区别于单片机的“执行完行代码的某个函数,得到了某个中间变量,再将这个中间变量输入给下一行代码的下个函数,最后得到结果”,FPGA 想要达到模块与模块之间的时序运行需要状态,每个时刻下系统的状态只有一个,而每个模块根据目前的状态来决定自己干不干事,干什么事。这就是状态机编程思想。FPGA 中所有的逻辑都可以用状态机实现,当然,也可以按照每个变量的时序图用always 块儿和assign 块儿分别对其进行赋值(FPGA 编码的本质就是编写出一套硬件结构,让输入与输出按照某种规定的时序变化),前者适用稍微复杂一点的情况,后者适用在简单情况。
要按照状态机的思想去编码,首先要画出状态转移图:

画图主要是为了定义出所有的状态,有的状态是为了判断然后分支,有的状态是为了计数,有的状态是为了改变输出。尽可能将所有涉及到的变量画在图里面。
编码一般分为三个always 语句,称之为三段式编码。第一段固定,第二段根据转移条件描述状态的跳变,第三段根据不同的状态对所有变量进行赋值。下面我用串口的rx 模块进行举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
// 波特率可更换,理论上比9600 大的都行,目前只测试了9600 和 115200
// 模块监听RX 数据线,如果有数据则串行收集,最后并行发出
// done_rx 为一个持续一个时钟周期的高电平脉冲,高电平结束后,data_rx 会被置0
module rx(
input wire clk,
input wire rst_n,
input wire rx_M2,

output reg [7:0] data_rx,
output reg done_rx
);

parameter [4:0] IDLE = 5'b00001;
parameter [4:0] START = 5'b00010;
parameter [4:0] DATA = 5'b00100;
parameter [4:0] STOP = 5'b01000;
parameter [4:0] WAIT = 5'b10000;

parameter sys_clk = 50_000_000;
parameter bps = 115200;
parameter delay = sys_clk / bps;

(* preserve *)reg [4:0] cur_state;
(* preserve *)reg [4:0] next_state;
// 接受到的bit 数
reg [3:0] rx_num;
reg [15:0] cnt;

reg [1:0] rx_buffer;
// 缓存两位rx
always @(posedge clk or negedge rst_n) begin
if(!rst_n)
rx_buffer <= 2'b11;
else
rx_buffer <= {rx_M2, rx_buffer[1]};
end

// 第一段
always @(posedge clk or negedge rst_n) begin
if(!rst_n)
cur_state <= IDLE;
else
cur_state <= next_state;
end
// 第二段
always @(*) begin
if(!rst_n)begin
next_state = IDLE;
end
else begin
case (cur_state)
IDLE :begin
if(rx_buffer == 2'b01)
next_state = WAIT;
else
next_state = cur_state;
end
WAIT :begin
if(cnt == delay - 1'b1)
next_state = START;
else
next_state = cur_state;
end
START :begin
if(rx_num == 4'd8)
next_state = STOP;
else
next_state = DATA;
end
DATA :begin
if(cnt == delay - 1'b1)
next_state = START;
else
next_state = DATA;
end
STOP :begin
next_state = IDLE;
end
default: next_state = IDLE;
endcase
end
end
// 第三段
always @(posedge clk or negedge rst_n) begin
if(!rst_n)begin
data_rx <= 1'b0;
done_rx <= 1'b0;
rx_num <= 1'b0;
cnt <= 1'b0;
end
else begin
case (cur_state)
IDLE :begin
// data_rx <= 1'b0;
done_rx <= 1'b0;
rx_num <= 1'b0;
cnt <= 1'b0;
end
WAIT :begin
data_rx <= 1'b0;
done_rx <= 1'b0;
rx_num <= 1'b0;
if(cnt == delay - 1'b1)
cnt <= 1'b0;
else
cnt <= cnt + 1'b1;
end
START :begin
done_rx <= 1'b0;
cnt <= 1'b0;
rx_num <= rx_num + 1'b1;
end
DATA :begin
done_rx <= 1'b0;
// 计时
if(cnt == delay - 1'b1)
cnt <= 1'b0;
else
cnt <= cnt + 1'b1;
// 输出
if(cnt == delay/2-1)
data_rx[rx_num-1] <= rx_buffer[1];
// data_rx <= data_rx + 1;
end
STOP :begin
rx_num <= 1'b0;
cnt <= 1'b0;
done_rx <= 1'b1;
end
default:begin
data_rx <= 1'b0;
done_rx <= 1'b0;
rx_num <= 1'b0;
cnt <= 1'b0;
end
endcase
end
end

endmodule

调试

在线调试。


这些界面的使用就不详细说了,大概阐述一下里面的逻辑。在线调试就是在你的代码之外额外给你添加了一部分代码(你看不到),这部分代码花费了FPGA 的一部分资源构建了一部分硬件,这些硬件按照你选择的时钟去抓取你选择的变量,并通过下载线传到软件进行显示。所以采样深度越高,耗费资源越多,能看到触发点前后的变量的值就越多。
仿真。 要编写一个.vt 文件,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 前者是编码时间单位(#), 后者是仿真的步骤分辨率
`timescale 1 ns/ 1 ps

// 用于测试的模块, 这里的变量可以被可视化
// 变量不写在() 内部,而且没有用output input 声明, 说明这个模块不能被其他模块例化
module "module_name"_vlg_tst();
reg clk;
reg rst_n;

// 需要测试的模块的例化, 相当于变量声明
"module_name" i1 (
.clk(clk),
.rst_n(rst_n)
);

// 仿真专用写法
always begin
#10
clk = ~clk;
end

// 仿真专用写法
initial begin
$display("-------------------------------------Running testbench-------------------------------------------");
clk = 1'b0;
rst_n = 1'b0;

#100;
rst_n = 1'b1;

$display("-------------------------------------testbench end-------------------------------------------");
$stop;
end

endmodule

这个文件将你的工程的顶层模块例化了,并且新建了一个测试模块儿。测试模块只有输入,这些输入会被仿真窗口显示。大致逻辑就是,这个文件模拟了输入信号,仿真出接收了这些信号后的测试模块儿中变量的值。具体配置的话(quartus 内部)就不写了。

其他

IP 核。 就是厂商帮你写好了一些外设,你直接调用就完事儿了。这些天一共用了这几个:

  • FIFO:一块儿数据缓存区
  • PLL:时钟的分倍频率
  • rom:储存,在配置IP 的时候传入一个.mif 文件,这个文件就是rom 储存的内容

Avalon 接口。 本身也是个IP 核,但比较特殊。它是一种总线协议,也有对应的硬件。将很多使用不同通讯协议的设备统一。只需要把你的设备连接到Avalon 引脚,你就可以通过Avalon 的统一的时序去与这个设备通讯。

VGA 协议。 VGA 是一种与HDMI 和DP 同层次的协议,但其只能传输视频流不能传输音频流。具体内容,我竟然讲不出来,看来这部分学的一般啊。

通用工程

我将常用的功能写成代码模块儿,装在一个project 里面,虽然这些模块只能在固定的开发板上使用,但代码逻辑是不变的,以后可以借鉴。
general_project.7z

正弦波从采样到梅尔频谱图

本文内容为正弦波的采样, 加窗, 傅里叶变换和梅尔滤波, 将波形定为简单的正弦波是为了简化例子, 专注于每个变换所带来的数值上的变化, 以更好的理解整个过程, 为音频处理做铺垫.

注意: 文中FFT 部分只是列出了傅里叶变换的输出, 并没有解释为什么是这样的输出, 比如用torch.fft.fft()得到的是复数, 需要求模长, 并除以样本的一半, 才是正确的幅值等.这些都涉及到傅里叶变换的原理, 本系列文章暂时不涉及, 插个在这里, 万一以后有契机补充这部分内容也说不定.

所有代码的import

1
2
3
4
import torch
import torchaudio
import matplotlib.pyplot as plt
import numpy as np

采样

对频率为500 Hz 幅值为100 的正弦波进行5 个周期的采样, 分别设置采样率为1-6 倍原频率.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 正弦波的频率
f1 = 500
# 周期
T = 1 / f1
# 采样范围
TT = 5*T

fig, axes = plt.subplots(2, 3, figsize=(20, 10))

for i, ax in enumerate(axes.flatten()):
# 采样率
fs = f1 * (i + 1)
# 采样点数
N = int(fs * TT)
print(f"f1 = {f1}, T = {T}, TT = {TT}, fs = {fs}, N = {N}")

x = torch.linspace(0, TT, N)
y = 100 * torch.sin( (2*torch.pi)*f1*x)

# 原波形
ax.plot(x, y)
ax.set_xlabel("time")
ax.set_ylabel("amplitude")
ax.set_title(f"sample wave (fs = {fs})")

alt text
可以明显看到, 采样率越高越还原波形, 最少需要两倍原频率.

FFT

为了展示出FFT 的作用, 这里用了5 个不同频率和幅值的正弦波以及一个随机噪声, 采样率设定的比较高, 让波形看起来比较丝滑.后续处理都是基于这个波形, 前面采样部分只是起到一个演示作用.

下面直接画出FFT 前后的波形.需要注意的是, 在FFT 前我先把波形都归一化到了[-1, 1].至于为什么可能要看完本篇文章才能看得懂: 对于单个音频来说, 同个语句由于响度不一样大, 波形幅值会不一样, 但大致形状相同.到文章的最后输入模型的分贝值就会产生偏移, 这会给模型带来数值大小的困扰, 如果归一化就不会有这个问题.对于不同的语句音频, 归一化就是去除响度的干扰因素, 默认所有语句, 都一个响度.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# 第一个正弦波
# 正弦波的频率
f1 = 1000
# 周期
T = 1 / f1
# 采样范围
TT = 10*T
# 采样率
fs = 20000

N = int(fs * TT)

# 第二个正弦波
f2 = 300

# 第三个
f3 = 700

# 第四个
f4 = 7000

# 第五个
f5 = 5000

x = torch.linspace(0, TT, N)
# 直接相加
y1 = 70 * torch.sin( (2*torch.pi)*f1*x)
y2 = 50 * torch.sin( (2*torch.pi)*f2*x)
y3 = 20 * torch.sin( (2*torch.pi)*f3*x)
y4 = 60 * torch.sin( (2*torch.pi)*f4*x)
y5 = 80 * torch.sin( (2*torch.pi)*f5*x)
# 噪声
noise = torch.randn(N) * 100

y = y1 + y2 + y3 + y4 + y5 + noise
y = y1 + y2 + y3 + y4 + y5
# 缩放到[-1, 1]
max_val = torch.max(torch.abs(y)) # 找到绝对值最大值
if max_val > 0:
y = y / max_val
print(f"max_val: {max_val}")

fig, axes = plt.subplots(1, 2, figsize=(20, 10))
axes = [axes]
# fig, axes = plt.subplots(2, 2, figsize=(20, 10))
# 原波形
axes[0][0].plot(x, y)
axes[0][0].set_xlabel("time")
axes[0][0].set_ylabel("amplitude")
axes[0][0].set_title("original wave")

# FFT
y_fft = torch.fft.fft(y)
y_fft = torch.abs(y_fft) / (N / 2)
x_fft = torch.linspace(0, fs, N)

axes[0][1].plot(x_fft, y_fft)
axes[0][1].set_xlabel("frequency")
axes[0][1].set_ylabel("amplitude")
axes[0][1].set_title("after FFT")

alt text

注意torch.fft.fft() 计算得到的是复数, 需要勾股定理求模长, 然后要除以一半的样本数, 这样得到的幅值才是符合原本的度量.

1
2
y_fft = torch.fft.fft(y)
y_fft = torch.abs(y_fft) / (N / 2)

原始波形的横坐标是时间, 纵坐标是幅值.
FFT 后的波形叫做幅值图, 它的纵坐标也是幅值, 但横坐标却是频率.点(x, y)表示, 在原始波形中, 频率为x 的正弦波分量的幅值是y .图(左右对称)左边有5个凸起, 分别对应5 个正弦分量.其中中频率为7000 Hz, 与5000 hz 的值为0.35 和0.225, 这如果没有进行归一化这两个值应该在60, 80 左右, 正好对应初始正弦分量的幅值.归一化就是线性除以了一个系数, 乘上这个系数,就可以变回60 和 80.

但是要画出幅值图可不像画出原始采样波形那样简单, 需要你知道FFT 的输出究竟是什么.

  • 横坐标的个数
    • 原始采样波形有多少个点(N), 那么个数就为N
  • 横坐标的单位与数值
    • 单位就是Hz, 因为其表示的是频率
    • 数值间隔, 也就是分辨率(interval 简称 I), 为: 采样频率(fs) / (横坐标个数(N) - 1)
    • 数值范围为0 到 采样频率(fs)
  • 纵坐标: 与原始采样波形一样, 表示幅值大小, 只是等比缩放了

一般只取幅值图的左半边, 因为它左右对称:

  • 横坐标的个数变为: (N // 2 + 1)
  • 横坐标的范围变为: (0 Hz 到 (N//2*I) Hz )
1
2
3
4
5
6
7
8
9
10
# 取一半
interval = fs / (N-1)
x_fft = torch.linspace(0, (N//2)*interval, N//2+1)
y_fft = y_fft[0:N//2+1]

fig, axes = plt.subplots(figsize=(20, 10))
axes.plot(x_fft, y_fft)
axes.set_xlabel("frequency")
axes.set_ylabel("amplitude")
axes.set_title("Keep half")

alt text
总的来看FFT 只能分析一半采样频率以内的频率分量, 而且采样点数越多, 分析的频率分量就越多频率间隔就越小.

功率谱

幅值谱的纵坐标表示幅值, 对于声波来说就是声压.将幅值平方即可转化为功率.
功率谱如下:

1
2
3
4
5
6
7
8
# 功率谱
y_pow = y_fft ** 2

fig, axes = plt.subplots(figsize=(20, 10))
axes.plot(x_fft, y_pow)
axes.set_xlabel("frequency")
axes.set_ylabel("power")
axes.set_title("power spectrum")

alt text
这个图的点(x, y) 表示频率为x 的正弦分量的功率为y.

梅尔滤波

这一步会将功率谱转化为梅尔频谱图, 将功率转化为能量.
如何进行转化呢? 就是将原功率谱与n 个等长的梅尔滤波器相乘再相加,得到长度为n 的梅尔频谱图.

得到梅尔滤波器

人耳对频率的感受是非线性的, 比如你会感觉1000Hz 与2000Hz 差别巨大, 但6000Hz 与 7000Hz 差距并没有这么大.梅尔频率与普通频率基本相同, 只是单位不一样, 前者为mel 后者为Hz.将普通频率转化为梅尔频率后, 1000ml 与2000ml 和6000ml 与7000ml 在听觉上的差距就一致了.
两者存在定量的转换公式:
alt text
计算梅尔滤波器的大致步骤:先在hz 单位下, 取一个最大频率和最小频率, 将它们都转化为mel 频率, 再根据mel 单位下的数值等距划分出中间多个数值.这样就得到了一个单位为mel 的等差数列频率, 再将这个等差数列转回hz 单位, 自然就不等差了, 但是符合人耳对频率差距的感知.
得到这些hz 数列后, 每三个数据, 创造一个三角滤波器, 每个三角滤波器可以对原功率谱进行滤波.
下面是计算梅尔滤波器的函数, 默认计算26 个梅尔滤波器(26 是前人总结的经验).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
"""
输入:
n_fft: 窗口大小
fs: 采样率
输出:
x: FFT 后折半保留的单位为频率的轴
mel_fliters: (num_fliters(26), fliter_size)
"""
def get_mel_fliters(n_fft, fs):
# 计算mel 滤波器组
def mel_to_hz(mel):
return 700 * (np.exp(mel / 1125) - 1)
def hz_to_mel(hz):
return 1125 * np.log(1 + hz / 700)

# 原幅值图X 轴范围
# 分辨率
interval = fs / (n_fft-1)
# 保留点数: 总的FFT 点数除以二加一
n_fft_reserve = n_fft // 2 + 1
# 幅值图X 轴
x = torch.linspace(0, interval*(n_fft_reserve-1), n_fft_reserve)

# print("x:")
# print(x)

# 计算mel 频率桶
# 低于300 Hz 人耳感受不到,不需要计算这部分的能量
hz_min = 300
# 保留的幅值图中最大的频率
hz_max = x[-1]
mel_min = hz_to_mel(hz_min)
mel_max = hz_to_mel(hz_max)
# 26 个滤波器需要28 个点
mel_bins = torch.linspace(mel_min, mel_max, 28)
# 来回转换有误差, 保证转回Hz 时不超过范围
hz_bins = torch.clamp(mel_to_hz(mel_bins), max=hz_max, min=hz_min)

# print("hz_bins:")
# print(hz_bins)

# 计算原x 轴与频率桶最相近的频率索引
hz_indexs = []
for hz_bin in hz_bins:
temp_abs = torch.abs(x - hz_bin)
temp_min = temp_abs.min()
indexs = torch.where(temp_abs == temp_min)
hz_indexs.append(indexs[0].item())
# print("hz_index:")
# print(hz_indexs)

# 判断频率桶是否重复, 如果重复则无法正常创造梅尔滤波器
hz_index_set = set(hz_indexs)
if len(hz_indexs) > len(hz_index_set):
print("hz bin 有重复")

# 创建mel 滤波器
mel_fliters = torch.zeros((26, len(x)))

for index, fliter in enumerate(mel_fliters):
left = hz_indexs[index]
center = hz_indexs[index + 1]
right = hz_indexs[index + 2]

# 线性上升部分
for i in range(left, center):
fliter[i] = (i - left) / (center - left)

# 线性下降部分
for i in range(center, right):
fliter[i] = (right - i) / (right - center)

return x, mel_fliters

可视化滤波前后:
注意这由于杂波每次生成的都不一样, 所以画图的纵坐标需要稍微调整一下.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
fig, axes = plt.subplots(3, 2, figsize=(20, 10))
ax = axes.flatten()


# 26 个梅尔滤波器叠加
x, mel_fliters = get_mel_fliters(N, fs)
for fliter in mel_fliters:
ax[0].plot(x, fliter)
ax[0].set_xlabel("Hz")

# 原波形
ax[1].plot(y_pow)
ax[1].set_ylim(0, 0.15)

# 第2个滤波器
ax[2].plot(mel_fliters[1])

# 被第2 个滤波器滤波后的原波形
ax[3].plot(mel_fliters[1] * y_pow)
ax[3].set_ylim(0, 0.15)

# 第6个滤波器
ax[4].plot(mel_fliters[5])

# 被第6 个滤波器滤波后的原波形
ax[5].plot(mel_fliters[5] * y_pow)
ax[5].set_ylim(0, 0.15)

alt text

  • 左边三张图分别是
    • 26 个梅尔滤波器叠加
    • 第2 个滤波器
    • 第6 个滤波器
  • 右边
    • 原波形
    • 被第2 个滤波器滤波后的原波形
    • 被第6 个滤波器滤波后的原波形

说是滤波, 其实就是直接相乘.
滤波后的图像看起来就是三角形, 这是因为我们的原始波形太简单了,虽然加了噪声, 但是依旧很纯净.再下一篇的语音处理中, 就会复杂得多.

梅尔频谱图

用26 个滤波器对功率谱进行滤波会得到26 个向量, 向量中值代表的是功率, 如果将每个向量的值加起来,得到26 个值, 那么这些值代表的是什么呢, 能量.

1
2
3
4
5
6
7
8
9
10
11
12
fig, axes = plt.subplots(2, 1, figsize=(20, 10))

# 得到梅尔频谱图
mel_data = []
for mel_fliter in mel_fliters:
mel_data.append((mel_fliter * y_pow).sum() )
axes[0].plot(mel_data)

# 将能量对数化, 转化成分贝
mel_data = torch.tensor(mel_data)
mel_data = 20*torch.log10(mel_data + 1e-10)
axes[1].plot(mel_data)

alt text

前面将hz 频率转化为mel 频率目的就让滤波的每个频率段落差值符合人耳听觉规律.这里将上图取对数得到下图也是同理.人耳对能量(响度)的感知也不是线性的, 人耳对响度(音量)的感知并不是线性的,而是更接近对数关系,即声音的主观响度随物理能量的变化呈对数增长。因此,对能量取对数可以使信号的表示更加符合人耳的听觉特性.
对数后的纵坐标从能量变成了加了对数并线性变换后的比例,分母是1, 所有的能量都在跟1 比, 如果其小于1 比例值最后就是负数.这个比例的单位就是分贝(dB).
所以我们得到了最终的梅尔频谱图, 它的横坐标是[0, 25], 代表原音频的26 个频率部分.它的纵坐标是对数比例, 单位是分贝, 代表各个部分能量与1 的比例.

但是我们通常不单独画一段音频的梅尔频谱图, 而是将时间连续的音频分别计算梅尔频谱图, 再画到同一张图上.
这里举一个最简单的例子, 将刚刚计算得到的频谱图的分贝值全部减10, 假设这就是原波形的下一等长时间的波形, 我们将这两张图画在一起.变成一个三维的图像.

1
2
3
4
5
6
fig, axes = plt.subplots(figsize=(20, 10))
mel_data_double = torch.stack((mel_data, mel_data-10))

im = axes.imshow(mel_data_double.T, aspect="auto", origin="lower", cmap="hot", extent=[1, 2, 0, 25])
cbar = fig.colorbar(im, ax=axes)
cbar.set_label("dB")

alt text
看不懂图先别慌, 横坐标[1, 2], 代表我们画了两个时刻的频谱图, 每一列代表一个时刻(也就是一帧), 纵坐标代表26 的频率部分, 而颜色代表对数比例的数值, 颜色越亮数值越大.


** 必要内容结束 **


加窗

首先窗函数长这样, 它的横坐标长度就是FFT 前原始采样波形的长度. 加窗就是将窗函数乘上采样波形, 对采样波形进行缩放.
目的就是减少频谱泄露:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 正弦波的频率
f1 = 500
# 周期
T = 1 / f1
# 采样范围
TT = 5*T
# 采样率
fs = f1 * 10
# 采样点数
N = int(fs * TT)

x = torch.linspace(0, TT, N)
y = 100 * torch.sin( (2*torch.pi)*f1*x)

fig, axes = plt.subplots(3, 1, figsize=(20, 5))

# 原波形
axes[0].plot(y)

# 窗函数
hann_window = torch.windows.hann(len(y))
axes[1].plot(hann_window)

# 相乘(加窗)
axes[2].plot(hann_window * y)

alt text

从空气震动到梅尔频谱图

导库:

1
2
3
4
5
import torch
import torchaudio
import matplotlib.pyplot as plt
import os
import numpy as np

空气振动 ===> 时域波形

声波的采样

声音通过空气传播,被麦克风采集,存储为音频文件,其中一种格式叫做.wav。
声波是模拟量,是连续的,要将其转化为数字量就需要采样。并且这个转化一定会有损失存在。
一共涉及到两个重要参数,一个是采样率,另一个是采样深度。采样率越高,单位时间采集的点就越多,采样深度越大,点的大小刻度就越细。一般想要还原一段音频,采样率必须高于这段音频最大频率分量的2倍。麦克风硬件完成模拟量到数字量的转换。

用torchaudio 读取.wav 文件可以得到:

1
2
wave_path = "./bed_0b56bcfe_nohash_0.wav"
print(torchaudio.info(wave_path))
1
AudioMetaData(sample_rate=16000, num_frames=16000, num_channels=1, bits_per_sample=16, encoding=PCM_S)

表示这个文件保存的音频数据的采样率为16000Hz, 总共有16000 个点,所以时长为1s.那么里面的音频数据具体是怎样的呢?数据位数为16,那数据类型呢,可以是有符号整数也可以是无符号。encoding=PCM_S, 中的S 就表示signed.所以每个数据点的范围为[-32768, 32767].

读取wav 文件并可视化

有了点的个数和点的值就能画出这个wav 文件所代表的时域波形。

先利用torchaudio 解析wav 文件,转化为tensor:

1
2
3
4
wave = torchaudio.load(wave_path, normalize=False)
print(wave)
wave = torchaudio.load(wave_path, normalize=True)
print(wave)
1
2
(tensor([[ 6, 11, 15,  ...,  7, 12, 11]], dtype=torch.int16), 16000)
(tensor([[0.0002, 0.0003, 0.0005, ..., 0.0002, 0.0004, 0.0003]]), 16000)

这里参数normalize 默认为True, 会将样本点的数据类型转化到torch.float32 并且缩放到[-1, 1]
画图:

1
2
3
4
5
6
7
8
9
wave = []
wave.append(torchaudio.load(wave_path, normalize=False))
wave.append(torchaudio.load(wave_path, normalize=True))

fig, axes = plt.subplots(1, 2, figsize=(12, 7))
x = torch.arange(1, 16000+1, 1)
for index, ax in enumerate(axes):
y = wave[index][0][0]
ax.plot(x, y)

alt text
左边是没有缩放和标准化的,右边是是标准化后的。由于在同一个词语,在不同响度的情况下,波形幅度大小不同,但它们都是同一个词语,所以我们一般采用标准化后的波形。

时域波形 ===> 频谱图

与上文同思路,找出画出频谱图的必须条件,比如:横纵坐标以及数据点的具体值,就能画出频谱图,画出来了,也就理解了。
傅里叶变换具体原理,我个人还不太理解,先插个眼在这里,如果以后有契机再补全吧。傅里叶变换原理
频谱图表示一段音频中各个频率的分量的大小。横坐标就是频率,范围就是0hz 到采样频率(FS),分辨率就是采样率(FS)除以样本点个数(N)。纵坐标分两种,功率谱时为功率,能量谱时为能量。
这里没有明白为什么对时域信号进行傅里叶变换后,得到的横坐标长度为采样频率,以及纵坐标为功率和能量,估计是因为没有去具体学习傅里叶变换。
对上文的wave 进行傅里叶变换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
specgram = []
for i in wave:
specgram.append(torch.fft.fft(i[0][0]))
for i in specgram:
print(len(i))
print(torch.abs(i))

fig, axes = plt.subplots(2, 2, figsize=(15, 8))
x = torch.arange(1, 16000+1, 1)
for index, ax in enumerate(axes[0]):
y = specgram[index]
ax.plot(x, y)

x = torch.arange(1, 16000//2+1, 1)
for index, ax in enumerate(axes[1]):
y = specgram[index][0:16000//2]
ax.plot(x, y)
1
2
3
4
5
16000
tensor([114381.0000, 113926.7969, 111315.3984, ..., 107403.2188,
111315.3984, 113926.7969])
16000
tensor([3.4906, 3.4768, 3.3971, ..., 3.2777, 3.3971, 3.4768])

alt text
首先看横坐标,傅里叶变换后的横坐标个数就等于变换前样本点的个数,只不过数据左右对称,所以一般只取一半。横坐标的最小间距是采样率(FS)/样本点个数,只是这里恰好等于1而已。
然后就是纵坐标,这里的纵坐标主要跟变换前的时域信号相关,没有标准化的纵坐标非常大,标准化后的相对较小,一般取标准化后的。
这里我会有直觉上的疑惑:假如我对一个幅值为10 的正弦波进行傅里叶变换,那么变换后的频谱图(振幅图)中对应的尖刺峰值应该为10 才对。但是经过实验发现,峰值为几百。查询资料发现,这个峰值包含了多个样本点的能量.处理方法如下:
直流分量(0 Hz):得到的振幅需要除以N,N 为样本点个数
非直流分量:得到的振幅需要除以(N/2),N 为样本点个数

频谱图 ===> 时频图

对于两个读音完全不相同的词语来说,他们的时域图肯定不相同,但是频谱图可能相同(极端情况)。虽然完全相同的情况不多,但相似的情况绝对不少。对于关键词分类来说,频谱图的信息诚然比时域图的多,但这种相似情况却让其不能作为分类模型的输入。所以时频图诞生了,它既保留了频域信号有包含时域信号,是折中的选择。
大致流程是,规定一个长度,从时域图上等距取出此长度的数据(这些数据可以重合),每个此长度的数据简称为窗口。每个窗口都包含一部分时域数据,窗口与窗口之间有重合部分。对这些窗口分别进行傅里叶变换,得到这一小段时间内的频谱图,再将所有的二维频谱图合成一张三维图片,这张图片就是时频图。

1
2
3
4
5
6
7
8
# 这里是直接从时域图到时频图,一步到位
print(f"时域信号:{wave[1][0][0]}")
n_fft = 600
hop_length = 300
window_fn = torch.hann_window
spectrogram = torchaudio.transforms.Spectrogram(n_fft=n_fft, hop_length=hop_length, window_fn=window_fn, power=1, center=False)
one_specgram = spectrogram(wave[1][0][0])
print(f"频域信号:{one_specgram.shape}")
1
2
时域信号:tensor([0.0002, 0.0003, 0.0005,  ..., 0.0002, 0.0004, 0.0003])
频域信号:torch.Size([301, 52])

窗口大小为600,所以单个傅里叶变换的样本个数就为600,所以频率个数就为301,而频率最小间隔就为:16000/600 = 26.66 Hz.窗口大小为600,窗口滑动步长为300,可以计算得到窗口个数为52(剩下的数据不够一个窗口,舍去)。
画图:

1
2
3
4
5
fig, axes = plt.subplots(1, 1, figsize=(20, 5))
axes.imshow(one_specgram, aspect="auto", origin="lower", cmap="hot", extent=[0, one_specgram.shape[1], 0, int(one_specgram.shape[0]*(16000/600))])
axes.set_title("Spectrogram Visualization")
axes.set_xlabel("Time Frames")
axes.set_ylabel("Frequency")

alt text
这里的三维图片的具体数据是由标准化到[-1, 1]的时域数据傅里叶变换而来,这些数据差距非常大(有的分量的值非常大,有的非常小),这就导致较小的值之间的差距很小所以全为黑色。下面通过对数变换一下,将差距减小,以便于分辨这些细小的差距。

1
2
3
4
5
fig, axes = plt.subplots(1, 1, figsize=(20, 5))
axes.imshow(10*torch.log10(one_specgram), aspect="auto", origin="lower", cmap="hot", extent=[0, one_specgram.shape[1], 0, int(one_specgram.shape[0]*(16000/600))])
axes.set_title("Spectrogram Visualization")
axes.set_xlabel("Time Frames")
axes.set_ylabel("Frequency")

alt text

梅尔频谱图

。。。。。。。

深度学习环境配置

本文主要介绍深度学习框架的配置,并不是手把手截图配置,不会这么详细,但会讲清楚概念和原理

在个人系统中分为全局环境和虚拟环境,深度学习框架一般安装在虚拟环境中。深度学习框架一般需要GPU 支持,而GPU 本是用来渲染画面的,而不是用来训练神经网络的所以需要安装cuda 来使能后者。

关于显卡的一些概念:

  • 显卡驱动程序
    • 任何硬件都需要驱动程序,这样系统才能调用这个硬件
  • cuda
    • 一个软件库,让显卡可以用来训练神经网络
  • cuda toolkit
    • cuda 的运行环境,包含了cuda 的很多依赖和工具
  • cuDNN
    • 专门为深度学习加速的库,它是基于 CUDA 运行的

一般情况下,cuda 与 cuda toolkit 是同时安装的。

所以要安装深度学习框架(GPU)版本,就需要显卡的支持,自然就需要显卡驱动程序,cuda, cuda toolkit 和cuDNN 。

安装之前

更新自己的显卡驱动(为了增加支持CUDA 版本上线): 左边为显卡驱动版本,右边为最高支持CUDA版本。
使用命令:nvidia-smi
更新前:
alt text
更新后:
alt text
可以看到更新驱动后支持cuda 版本更高了,也就代表这我们能安装更高版本的深度学习框架。

注意: 这里安装的驱动在全局环境,全局环境里面可以不安装cuda,如果需要请看在全局环境中安装cuda.

创建虚拟环境

为什么非要在虚拟环境中安装呢? 因为上面的四个工具版本与框架版本是有对应关系的,如果在全局环境中安装的话,就几乎不可能同时存在不同版本的pytorch 或者同时存在pytorch 和tensorflow。

用conda 创建环境,而不用Python 自带的虚拟环境工具venv: 因为conda 环境内既可以用conda 下载包也可以用Pip, 后者只能用pip 。所有的深度学习框架都需要cuda,cuda toolkit cuDNN, 而只有conda 才能在虚拟环境中安装非python 库,pip 则不行。

安装tensorflow

Tensorflow-GPU 目前最新版本已经不支持windows 了,如果要下载只能用老版本。所以windows 推荐pytorch。
安装前需要对照版本:Build from source on Windows | TensorFlow
官网教程:
alt text
这里安装的cudatoolkit 里面包含了cuda 。

安装pytorch

官网教程:
alt text
安装前需要对照python 版本。这句命令会自动在虚拟环境中安装cuda,cuda toolkit,cuDNN 所以一条命令就可以实现安装torch 环境,很方便。唯一要注意的是,这里选择的cuda 版本务必要小于等于显卡驱动所支持的最大版本。

这里有问题:这里安装只用了pip 没有用conda 安装非python 库?与只能用conda 冲突了?


** 必要内容结束 **


全局环境安装cuda

先使用命令:nvcc --version
如果没有此命令,也不存在目录(C:\Program Files\INVIDIA GPU Computing Toolkit)说明没安装。
正常安装后输出是这样的:
alt text

python 对象赋值原则


本文所用的可嵌套对象和不可嵌套对象,官方称之为可变对象和不可变对象。我只是觉得前者更便于理解。

首先有两个基本知识:

  • Python 中的所有变量皆为对象
  • Python 函数参数传递的是对象的引用(跟C 语言的指针差不多)

不可嵌套对象赋值常量

不可嵌套对象赋于之前不同的值时,会重新分配内存,并将引用指向新的内存。下例将2 这个整型对象的引用赋值给a

1
2
3
4
5
6
7
a = 1
print(a, id(a))
a = 2
print(a, id(a))

1 140709099219384
2 140709099219416

不可嵌套对象赋值不可嵌套对象

1
2
3
4
5
6
7
8
9
10
11
12
a = 1
b = a
print(a, b)
print(id(a), id(b))
b = 2
print(a, b)
print(id(a), id(b))

1 1
140709083031992 140709083031992
1 2
140709083031992 140709083032024

不可嵌套对象赋值可嵌套对象

1
2
3
4
5
6
7
a = 1
print(a, id(a))
a = [1, 2, 3]
print(a, id(a))

1 140709083031992
[1, 2, 3] 1588587600064

这里a变成了整个列表对象的引用

不可嵌套对象作为函数参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def test(param1):
print(param1, id(param1))

param1 = 2
print(param1, id(param1))
return param1
a = 1
print(a, id(a))
a = test(a)
print(a, id(a))

1 140711413885368
1 140711413885368
2 140711413885400
2 140711413885400

可以看到a 作为函数参数或是返回值时,传递的始终时a 的引用。由于a 为不可嵌套对象,所以在函数内部赋值的时候引用发生了改变。

不可嵌套对象作为全局变量时被函数内部访问

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
a = 1

def test():
print(a, id(a))
a = 2
print(a, id(a))

if __name__ == '__main__':
test()
print(a, id(a))

Traceback (most recent call last):
File "c:\Users\Xe-131\Desktop\test.py", line 45, in <module>
test()
File "c:\Users\Xe-131\Desktop\test.py", line 40, in test
print(a, id(a))
^
UnboundLocalError: cannot access local variable 'a' where it is not associated with a value
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
a = 1

def test():
print(a, id(a))
print(a.__class__)
print(a.__add__(2))
print(a, id(a))

if __name__ == '__main__':
test()
print(a, id(a))

1 140710631975352
<class 'int'>
3
1 140710631975352
1 140710631975352

可以看到在函数内部对a进行赋值会报错,但使用a内部的属性或者方法时却没问题。如果函数内部有赋值操作,python会将a理解为局部变量,从而第一句print(a, id(a))就会报错,因为在这句话之前a并没有初始化。

如果需要对a 进行赋值,有两种对待方式:

  • 当成全局变量:在函数内部用关键词声明
    • 这里全程都是用的时全局变量a,函数执行结束后,全局变量a的值确实改变了
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      a = 1

      def test():
      global a
      print(a, id(a))
      a = 2
      print(a, id(a))

      if __name__ == '__main__':
      print(a, id(a))

      test()

      print(a, id(a))

      1 140711413885368
      1 140711413885368
      2 140711413885400
      2 140711413885400
  • 当成局部变量:在用到之前先初始化声明一下
    • 函数内部的a跟外部的a是两个东西
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      a = 1

      def test():
      a = 2
      print(a, id(a))

      if __name__ == '__main__':
      print(a, id(a))

      test()

      print(a, id(a))

      1 140710631975352
      2 140710631975384
      1 140710631975352


可嵌套对象赋值常量(不可嵌套对象)

1
2
3
4
5
6
7
8
a = [1, 2, 3]
print(a, id(a))

a = 1
print(a, id(a))

[1, 2, 3] 2023166382400
1 140710631975352

可嵌套对象元素赋值常量(不可嵌套对象)

1
2
3
4
5
6
7
8
a = [1, 2, 3]
print(a, id(a), a[0], id(a[0]))

a[0] = 4
print(a, id(a), a[0], id(a[0]))

[1, 2, 3] 1281264521536 1 140710631975352
[4, 2, 3] 1281264521536 4 140710631975448


a的元素赋值并不会改变a本身的指向,而是改变其元素的指向

可嵌套对象赋值可嵌套对象

1
2
3
4
5
6
7
8
9
a = [1, 2, 3]
b = a
print(a, id(a), b, id(b))

b[0] = 4
print(a, id(a), b, id(b))

[1, 2, 3] 2346644148544 [1, 2, 3] 2346644148544
[4, 2, 3] 2346644148544 [4, 2, 3] 2346644148544

这里我们改变b的内部元素,就连a内部元素也一起改变了。是因为a b都指向一个列表,b[0] = 4 操作是将列表中的引用改变,无论改变与否它都属于这个列表。

想要获得两个互不相干的列表,可以去学习深拷贝和浅拷贝的内容。

可嵌套对象作为函数参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def test(param):
print(param, id(param))

param[0] = 4
print(param, id(param))

a = [1, 2, 3]
print(a, id(a))

test(a)
print(a, id(a))

[1, 2, 3] 2395825574208
[1, 2, 3] 2395825574208
[4, 2, 3] 2395825574208
[4, 2, 3] 2395825574208

函数传递的是列表的引用,所以列表内部元素的改变会被传递到函数外部

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def test(param):
print(param, id(param))

param = [4, 5, 6]
print(param, id(param))

a = [1, 2, 3]
print(a, id(a))

test(a)
print(a, id(a))

[1, 2, 3] 2408679280960
[1, 2, 3] 2408679280960
[4, 5, 6] 2408681482880
[1, 2, 3] 2408679280960

这里对传入的引用改变方向,指向了另一个列表

通过实验,我们可以知道,将某个对象传入函数时,实际上是创建了一个新的指向原对象的引用

可嵌套对象作为全局变量被函数访问

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def test():
print(a, id(a))

a = [4, 5, 6]
print(a, id(a))
a = [1, 2 ,3]
if __name__ == '__main__':
print(a, id(a))
test()
print(a, id(a))

[1, 2, 3] 1967735312704
Traceback (most recent call last):
File "c:\Users\Xe-131\Desktop\test.py", line 122, in <module>
test()
File "c:\Users\Xe-131\Desktop\test.py", line 111, in test
print(a, id(a))
^
UnboundLocalError: cannot access local variable 'a' where it is not associated with a value
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def test():
print(a, id(a))

a[0] = 4
print(a, id(a))

a = [1, 2 ,3]

if __name__ == '__main__':
print(a, id(a))

test()
print(a, id(a))

[1, 2, 3] 1741292507456
[1, 2, 3] 1741292507456
[4, 2, 3] 1741292507456
[4, 2, 3] 1741292507456

全局变量的作用就是,这个变量的引用时全局可见的。因为并不是通过参数传递给函数的,所以函数内部使用的引用就是原来的引用。

原理与不可嵌套函数作为全局变量被函数访问一样,当进行a = [1, 2 ,3]时,python 就会混淆,它不知道你是想新建一个与a 同名的局部变量还是将全局变量a 的指向改变。解决方法与不可嵌套变量一样。

但是如果只改变其内部属性或者元素的话,并不会引起混淆,所以不会报错。