0%

线程状态

  • New(新建):线程对象刚创建,还未开始执行。
  • Ready(就绪):线程准备好运行,等待调度器分配 CPU。
  • Running(运行):线程正在使用 CPU 执行指令。
  • Blocked(阻塞):线程在等待某个事件(如 I/O、锁、条件变量等),不能运行。
  • Terminated(终止):线程执行完毕或被强制结束。
  • Sleeping(休眠):线程主动暂停一段时间(如 sleep()),不能被调度。

线程状态转换图

当前状态 触发条件 转换后状态 说明
New 调用 start() 或 std::thread 构造函数 Ready 线程被创建,等待调度
Ready 被调度器选中 Running 获得 CPU,开始执行
Running 时间片耗尽 / yield() Ready 主动或被动让出 CPU,重新排队
Running 调用 sleep() / wait() / 等待锁 Blocked / Sleeping 线程不能继续执行,等待事件或时间
Blocked / Sleeping 条件满足 / 时间到 Ready 线程重新具备运行条件,等待调度
Running 执行结束或异常 Terminated 线程生命周期结束

性能指标

CPU时间

函数调用的 CPU 时间:

  • Inclusive time: total cpu time, include all functions it calls.
  • Exclusive time: only the time used by the function itself, exclusive all its children.

Refer to here.

  1. Wall time: total time the process used, containing IO time.

  2. CPU usage (CPU利用率) = CPU time / Wall time.

  3. real/user/system time

    • Real is wall clock time - time from start to finish of the call. This is all elapsed time including time slices used by other processes and time the process spends blocked (for example if it is waiting for I/O to complete).
    • User is the amount of CPU time spent in user-mode code (outside the kernel) within the process. This is only actual CPU time used in executing the process. Other processes and time the process spends blocked do not count towards this figure.
    • Sys is the amount of CPU time spent in the kernel within the process. This means executing CPU time spent in system calls within the kernel, as opposed to library code, which is still running in user-space. Like ‘user’, this is only CPU time used by the process. See below for a brief description of kernel mode (also known as ‘supervisor’ mode) and the system call mechanism.

    Refer to here.

  4. CPU 时间可能大于墙上时间:

    这是因为 CPU 时间是所有 CPU 核的运行时间的累加和,墙上时间则是实际的时间。此时 CPU 利用率大于 100%. (这是自己的理解)

  5. TODO: Is CPU time in flame graph sum of all the CPU time? Or is it the wall time when CPU works?

计时工具:timer

1
$ /usr/bin/time -p ls

Or,

1
$ time ls

其中(参考链接),

1
2
3
$ type -a time
time is a shell keyword
time is /usr/bin/time

分析工具

  • Performance Analyzer
  • Thread Analyzer
  • gprof
  • DDT/gdb

Performance Analyzer

Oracle Developer Studio 提供了多种工具:Performance Analyzer、Thread Analyzer。

Performance Analyzer 官方文档

1. 收集数据

使用 collect 命令收集数据(官方文档)。

1
collect collect-options program program-arguments

2. 开始性能分析

使用 analyzer 命令进行性能分析(官方文档)。

1
analyzer [control-options] [experiment | experiment-list]

例如:

1
analyzer -c test.1.er test.4.er

Thread Analyzer

Thread Analyzer 官方文档

gprof

gprof(GNU profiler)是GNU binutils工具集中的一个工具,linux系统当中会自带这个工具。它可以分析程序的性能,能给出函数调用时间、调用次数和调用关系,找出程序的瓶颈所在。在编译和链接选项中都加入-pg之后,gcc会在每个函数中插入代码片段,用于记录函数间的调用关系和调用次数,并采集函数的调用时间。

DDT

DDT

简介

“Hybrid Build”(混合构建)是指一种 兼顾调试能力与运行性能 的构建方式,常用于需要在 Release 环境中进行问题分析,但又不能完全使用 Debug 构建的场景。

🧩 Hybrid Build 的核心特征

特性 描述
优化等级 使用 -O2 或 -O3,保持接近 Release 的性能
调试信息 保留 -g,生成 DWARF 调试符号,便于分析 core dump 或使用 gdb
符号表 可选开启 -fno-omit-frame-pointer,便于栈回溯
日志/保护机制 可嵌入轻量级日志、ASAN-lite、canary 等机制,增强可观测性
部署策略 仅在内部或特定客户环境中使用,避免暴露完整源码结构或调试符号

✅ 编译示例(GCC/Clang)

1
g++ -O2 -g -fno-omit-frame-pointer -DNDEBUG -o your_program main.cpp
  • -O2:优化代码,接近 Release 性能
  • -g:保留调试信息
  • -fno-omit-frame-pointer:保留帧指针,便于栈追踪
  • -DNDEBUG:关闭断言(assert)

🎯 使用场景

场景 是否推荐使用 Hybrid Build 原因说明
客户现场复现崩溃 可通过 core dump + 符号表快速定位问题
内部性能测试 保持优化效果,同时可调试
正式发布给客户(大规模部署) 不建议,可能暴露符号信息,体积较大
与 ASAN/TSAN 联合使用 ⚠️(需关闭优化) ASAN/TSAN 更适合 Debug 构建,Hybrid 会影响检测精度

🛠️ 实践建议

  • 符号分离:使用 objcopy --only-keep-debug 分离调试符号,避免暴露给客户。
  • 符号服务器:内部维护符号服务器,支持通过 core 文件自动符号化。
  • 结合日志框架:Hybrid Build 可与轻量级日志框架结合,增强问题定位能力。

符号分离(Split Debug Info)

✅ 为什么要分离?

  • Release 版本:体积小,性能高,但缺少调试信息
  • 调试符号文件:保留完整符号信息,用于内部分析 core dump

🛠️ 分离流程(以 GCC/Clang 为例)

1
2
3
4
5
6
7
8
9
10
11
# 编译时保留调试信息
g++ -O2 -g -o your_program main.cpp

# 分离调试符号
objcopy --only-keep-debug your_program your_program.debug

# 去除主程序中的调试信息
strip --strip-debug --strip-unneeded your_program

# 添加调试符号关联信息
objcopy --add-gnu-debuglink=your_program.debug your_program

✅ 最终产物:

  • your_program:可交付给客户的 Release 可执行文件
  • your_program.debug:内部使用的调试符号文件

core dump 分析

1. 客户侧设置 core dump

1
ulimit -c unlimited

2. 客户运行程序并生成 core 文件

1
2
./your_program crash_input.txt
# 程序崩溃后生成 core 文件,如 core.12345

3. 客户提供 core 文件 + 可执行文件

  • core.12345
  • your_program(Release 版本)

4. 你在内部分析 core 文件

1
2
3
4
gdb your_program core.12345
# 加载调试符号
(gdb) symbol-file your_program.debug
(gdb) bt # 查看调用栈

工具

valgrind 是一个强大的内存调试和性能分析工具,它的不同子工具用于不同的分析目的。--memcheck--massif 是其中两个常用的工具。

ASAN(AddressSanitizer) 和 TSAN(ThreadSanitizer) 都是由现代编译器(如 Clang 和 GCC)提供的 运行时检测工具,用于帮助开发者在开发阶段发现内存错误和线程问题。

  • Clang:全面支持 ASAN 和 TSAN
  • GCC:也支持,但 TSAN 的支持略逊于 Clang

博客:How to get a core dump for a segfault on Linux

Memcheck:内存错误检查工具

官方文档:4. Memcheck: a memory error detector

  • 用途:检测内存相关的错误,如内存泄漏、越界访问、未初始化内存读取等。
  • 适用场景:调试程序中的内存错误,确保程序的内存使用是安全的。
  • 输出内容:
    • 哪些内存没有释放(内存泄漏)
    • 哪些内存被非法访问(越界、未初始化等)
    • 哪些内存访问是未定义行为
  • 常用命令:
1
valgrind --tool=memcheck ./your_program

Massif:堆内存使用分析工具

官方文档:9. Massif: a heap profiler

  • 用途:分析程序在运行过程中堆内存的使用情况,帮助优化内存占用。
  • 适用场景:性能分析,找出内存占用高的代码路径或数据结构。
  • 输出内容:
    • 堆内存使用随时间的变化(快照):需用 ms_printPostScript Viewer 查看
    • 哪些函数或调用路径分配了最多的内存
    • 常用命令:
1
2
valgrind --tool=massif ./your_program
ms_print massif.out.<pid>

🆚 Memcheck vs Massif 对比总结

特性 --memcheck --massif
工具类型 内存错误检查工具 堆内存使用分析工具
主要用途 检查内存泄漏、越界访问、未初始化读取等 分析堆内存使用趋势和峰值
检测内存泄漏 ❌(不直接检测)
分析内存增长趋势
输出格式 错误报告(文本) 内存快照(需用 ms_print 查看)
性能开销 高(运行速度变慢) 中等

ThreadSanitizer(TSAN)

  • 用途:检测多线程程序中的 数据竞争(data race) 和其他线程同步问题。
  • 适用场景:并发程序调试,尤其是当多个线程访问共享变量时。
  • 检测内容:
    • 数据竞争(两个线程同时访问同一变量,且至少一个是写操作)
    • 锁使用错误(死锁、双重解锁等)
  • 启用方式(Clang/GCC):
1
clang -fsanitize=thread -g your_program.c -o your_program

AddressSanitizer(ASAN)

AddressSanitizer 文档

  • 用途:检测内存访问错误。
  • 适用场景:单线程或多线程程序中调试内存问题。
  • 检测内容:
    • 越界访问(stack/heap/global)
    • use-after-free(释放后使用)
    • 内存泄漏(可选)
    • use-after-scope(作用域结束后使用局部变量)
  • 启用方式:
1
clang -fsanitize=address -g your_program.c -o your_program

🆚 TSAN vs ASAN 对比总结

特性 TSAN(ThreadSanitizer) ASAN(AddressSanitizer)
检测目标 多线程数据竞争 内存访问错误
是否支持多线程 ✅(专为多线程设计) ✅(支持,但不检测数据竞争)
性能开销 较高(10x~40x) 中等(2x~3x)
内存开销 中等 较高(增加约2倍内存使用)
是否检测内存泄漏 ❌(不检测) ✅(可选开启)
是否检测数据竞争

🆚 Memcheck vs Massif vs TSAN vs ASAN 对比总结

工具名称 类型 主要用途 检测内容 是否支持多线程 性能开销 内存开销 是否检测内存泄漏 是否检测数据竞争
Memcheck Valgrind 工具 内存错误检测 内存泄漏、越界访问、未初始化读取、非法释放等 ✅(有限支持) 高(10x+)
Massif Valgrind 工具 堆内存使用分析 堆内存分配趋势、内存峰值、调用路径分析 中等 中等
ASAN 编译器工具 内存访问错误检测 越界访问、use-after-free、use-after-scope、内存泄漏(可选) 中等(2x~3x) 高(约2倍) ✅(可选)
TSAN 编译器工具 多线程数据竞争检测 数据竞争、死锁、错误的锁使用等 ✅(强支持) 高(5x~40x) 中等

UBSan (Unifined Behavior Sanitizer)

Sanitizer 检测内容 Google 贡献
ASAN 内存访问错误 ✅ 是
UBSan 未定义行为 ✅ 是
TSAN 数据竞争 ✅ 是
MSAN 未初始化内存使用 ✅ 是

__attribute__((__visibility__("default")))

__attribute__((__visibility__("default"))) 是 GCC 编译器的一个属性,用于控制符号的可见性。它指定了一个符号(如函数或变量)在共享库中的可见性。

含义:

  • default 可见性
    • 符号可以被其他共享库或可执行文件访问。
    • 符号会被导出到共享库的符号表中。

用途:

  • 用于显式导出符号,使其在动态链接时可被其他模块使用。

  • 在共享库中,默认情况下符号是 default 可见的,但使用 -fvisibility=hidden 编译选项时,所有符号会被隐藏,只有显式标记为 default 的符号才会导出。

  • 编译器优化的影响:即使没有 -fvisibility=hidden,某些编译器优化(如 -flto)可能会影响符号的导出。
    显式使用 __attribute__((__visibility__("default"))) 可以避免这些问题。

示例:

1
2
3
4
__attribute__((__visibility__("default")))
void myFunc() {
// Function implementation
}

可以通过以下步骤确认 myFunc 是否在被链接时可见:

方法 1: 使用 nm 工具检查符号

nm 是一个工具,可以列出目标文件或共享库中的符号表。

  1. 检查 libchip.so 中是否导出了 myFunc

    1
    nm -D libchip.so | grep myFunc
    • 如果 myFunc 出现在输出中,说明它被导出并且可见。
    • 如果没有出现,可能是符号被隐藏(例如使用了 -fvisibility=hidden)。
  2. 如果 myFunc 没有导出:

    • 确认是否在代码中使用了 __attribute__((__visibility__("default")))
    • 确认编译时是否启用了 -fvisibility=hidden

方法 2: 使用 readelf 检查动态符号

readelf 可以显示共享库的动态符号表。

  1. 检查 libchip.so 的动态符号表:
    1
    readelf -Ws libchip.so | grep myFunc
    • 如果 myFunc 出现在输出中,说明它是动态可见的。
    • 如果没有出现,说明符号被隐藏。

方法 3: 检查链接依赖关系

  1. 检查 libdbg.so 是否声明了对 libchip.so 的依赖:

    1
    readelf -d libdbg.so | grep NEEDED
    • 如果 libchip.so 出现在 NEEDED 列表中,说明 libdbg.so依赖 libchip.so
  2. 如果 libchip.so 在依赖列表中,但链接时仍然报错:

    • 确认 myFunc 是否在 libchip.so 中导出(使用 nmreadelf 检查)。

方法 4: 检查链接器错误

如果链接器报错 undefined reference to myFunc

  • 确认链接命令是否显式包含 libchip.so
    1
    g++ -o executable main.o -L/path/to/libs -ldbg -lchip
  • 如果没有显式链接 libchip.so,动态链接器可能无法解析 myFunc

总结:

  • 使用 nmreadelf 检查 libchip.so 是否导出了 myFunc
  • 如果符号未导出,确保代码中使用了 __attribute__((__visibility__("default"))) 并检查编译选项。

📝 现象描述

当你运行一个 ELF 可执行文件(比如一个 32-bit 的程序),而它依赖的 loader 或共享库找不到 时,Shell 会提示:

1
bash: ./a.out: No such file or directory

但其实,这个“文件”确实存在,问题出在它 依赖的 loader 文件或库文件找不到,进而导致 execve() 系统调用失败。

我们从 内核执行路径 角度来解析这背后的原理。

🧠Linux 内部逻辑解析

  1. Shell 运行程序的过程
1
./my_app

Shell 实际做的是调用 execve() 系统调用:

1
execve("./my_app", argv, envp);

这是 Linux 加载并运行一个程序的唯一入口。

  1. execve() 做了什么?

内核执行 execve() 时,它会:

🔹 Step 1: 打开并读取 ELF 头(文件的前几个字节)
从而判断这是一个 ELF 可执行文件、脚本,还是其他格式。

🔹 Step 2: 检查 ELF 的架构位数、ABI、动态链接需求
对于动态链接程序,它会在 ELF 头中读取如下字段:

  • e_ident[EI_CLASS]: 32-bit 或 64-bit
  • e_interpreter: 这是最关键的!
  1. e_interpreter 是什么?

这是 ELF 文件里定义的 “程序解释器路径”(interpreter path)。即这个程序运行时,系统需要先加载谁来帮它加载剩下的动态库。

可以通过 readelf -l my_app 查看:

1
2
3
$ readelf -l ./my_app | grep interpreter

[Requesting program interpreter: /lib/ld-linux.so.2]

比如:/lib/ld-linux.so.2 是 32-bit 程序使用的 loader。

  1. 如果这个 loader 不存在,会发生什么?
  • execve() 尝试打开 ELF 指定的解释器(/lib/ld-linux.so.2)
  • 如果该文件不存在,open() 失败
  • 整个 execve() 调用失败
  • 错误代码是 ENOENT(2),代表:

“No such file or directory”

但这并不是说 你运行的那个文件 ./my_app 不存在,而是 它依赖的解释器不在系统中,导致整个执行失败。

🤯 为什么这么误导?

因为 shell(bash/zsh)调用 execve() 失败后,只看到了 errno = ENOENT,它默认解释为:

“你指定的那个文件路径不存在”

而不是更深层次的:

“文件存在,但它需要的 loader 不存在”

这是 shell 的 历史遗留行为,没有细分 errno 背后语义。

🧪 举个实际例子

1
2
3
4
5
6
7
8
9
10
11
$ file ./a.out
./a.out: ELF 32-bit LSB executable, Intel 80386, ...

$ readelf -l ./a.out | grep interpreter
[Requesting program interpreter: /lib/ld-linux.so.2]

$ ls /lib/ld-linux.so.2
ls: cannot access '/lib/ld-linux.so.2': No such file or directory

$ ./a.out
bash: ./a.out: No such file or directory ❌

实际上是 缺少解释器 /lib/ld-linux.so.2,而不是 a.out 本身。

✅ 总结

层级 行为
用户 输入 ./prog
Shell 调用 execve("./prog", ...)
内核 解析 ELF,发现 interpreter/lib/ld-linux.so.2
内核 找不到解释器,返回 ENOENT
Shell No such file or directory ❌(误导性)

🛠️ Bonus:编译 32-bit 程序

  1. 编写一个简单的 C 程序
1
2
3
4
5
6
7
// hello.c
#include <stdio.h>

int main() {
printf("Hello from 32-bit program!\n");
return 0;
}
  1. 编译成 32 bit 程序

需要先安装 32-bit 编译支持:

1
2
sudo apt update
sudo apt install gcc-multilib

然后编译:

1
gcc -m32 hello.c -o hello32
  1. 验证是否是 32-bit ELF 可执行文件
1
file hello32

输出应类似:

1
hello32: ELF 32-bit LSB executable, Intel 80386, ...

Makefileview raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
CXX = g++
CXXFLAGS = -fPIC -Wall -Wextra
LDFLAGS = -shared

all: main libhello.so

main: main.cpp
$(CXX) main.cpp -o main -ldl

libhello.so: hello.cpp
$(CXX) $(CXXFLAGS) $(LDFLAGS) hello.cpp -o libhello.so

clean:
rm -f main libhello.
main.cppview raw
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
#include <dlfcn.h>

#include <iostream>

typedef void (*HelloFunc)();

int main() {
// 打开共享库
void* handle = dlopen("./libhello.so", RTLD_LAZY);
if (!handle) {
std::cerr << "dlopen failed: " << dlerror() << std::endl;
return 1;
}

// 清除之前的错误
dlerror();

// 获取函数指针
HelloFunc hello = (HelloFunc)dlsym(handle, "hello");
const char* dlsym_error = dlerror();
if (dlsym_error) {
std::cerr << "dlsym failed: " << dlsym_error << std::endl;
dlclose(handle);
return 1;
}

// 打印函数地址
std::cout << "Address of hello function: " << reinterpret_cast<void*>(hello) << std::endl;

// 调用函数
hello();

// 关闭共享库
dlclose(handle);
return 0;
}

定义

当两个线程同时修改原子变量时,以下操作会发生:

原子性:原子变量的修改是原子的,即每次修改都是一个不可分割的操作。这意味着在任何时刻,只有一个线程能够成功地修改原子变量,而不会出现竞争条件。

同步机制:原子变量使用硬件级别的同步机制(如锁、比较并交换操作等)来确保修改的原子性。这些机制确保线程在修改原子变量时不会干扰其他线程的操作。

内存可见性:原子变量的修改会确保内存可见性,即一个线程对原子变量的修改会立即对其他线程可见。这意味着其他线程可以看到最新的修改结果,而不会读取到过时的数据。

举个例子,假设我们有一个原子变量 std::atomic<int> counter,两个线程同时执行 counter++ 操作:

atomic1.cppview raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <atomic>
#include <thread>
#include <iostream>

std::atomic<int> counter(0);

void increment() {
for (int i = 0; i < 1000; ++i) {
counter++;
}
}

int main() {
std::thread t1(increment);
std::thread t2(increment);

t1.join();
t2.join();

std::cout << "Final counter value: " << counter << std::endl;
return 0;
}

在这个例子中:

两个线程 t1 和 t2 同时执行 increment 函数,尝试修改原子变量 counter。
原子性 确保每次 counter++ 操作都是不可分割的,避免竞争条件。
同步机制 确保两个线程不会同时修改 counter,而是一个线程成功修改后,另一个线程才能继续修改。
内存可见性 确保 counter 的最新值对两个线程都是可见的。

实现机制

让我们深入探讨一下两个线程同时修改原子变量时的底层机制。

  1. 原子操作的硬件支持
    原子操作通常由硬件指令支持,这些指令确保操作的原子性。例如,x86架构提供了 LOCK 前缀,用于确保指令在多处理器环境中的原子性。常见的原子指令包括 LOCK XADD(原子加法)和 LOCK CMPXCHG(原子比较并交换)。

  2. 缓存一致性协议
    多核处理器使用缓存一致性协议(如MESI协议)来确保各个处理器缓存中的数据一致性。当一个处理器修改原子变量时,缓存一致性协议会确保其他处理器的缓存中对应的数据无效或更新。

  3. 内存屏障
    内存屏障(Memory Barrier)是一种指令,用于防止编译器和处理器对内存操作进行重排序。原子操作通常会使用内存屏障来确保操作的顺序性和可见性。例如,std::atomic 在实现时会使用内存屏障来确保操作的正确性。

  4. 比较并交换(CAS)
    比较并交换(Compare-And-Swap, CAS)是一种常用的原子操作,它通过比较变量的当前值和预期值,如果相等则更新变量的值。CAS操作是无锁编程的基础,用于实现许多并发数据结构。

  5. 伪代码示例
    以下是一个伪代码示例,展示了CAS操作的工作原理:

cas_mock.cppview raw
1
2
3
4
5
6
7
bool compare_and_swap(int* ptr, int old_value, int new_value) {
if (*ptr == old_value) {
*ptr = new_value;
return true;
}
return false;
}

在多线程环境中,CAS操作可以确保只有一个线程成功修改变量的值,而其他线程会失败并重试。

  1. 实际应用
    在实际应用中,原子变量的操作可能如下所示:
atomic2.cppview raw
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
#include <atomic>
#include <thread>
#include <iostream>

std::atomic<int> counter(0);

void increment() {
for (int i = 0; i < 1000; ++i) {
int expected = counter.load();
while (!counter.compare_exchange_weak(expected, expected + 1)) {
// 重试,直到成功
}
}
}

int main() {
std::thread t1(increment);
std::thread t2(increment);

t1.join();
t2.join();

std::cout << "Final counter value: " << counter << std::endl;
return 0;
}

在这个例子中,compare_exchange_weak 使用CAS操作来确保 counter 的原子性修改。每次修改失败时,线程会重试,直到成功。

缓存一致性协议

缓存一致性协议是确保多核处理器中各个缓存之间数据一致性的机制。最常见的缓存一致性协议是MESI协议。让我们详细解释一下。

MESI协议

MESI协议是由四种状态组成的缓存一致性协议,每个缓存行(Cache Line)可以处于以下四种状态之一:

  • Modified(修改):缓存行中的数据已被修改,与主内存中的数据不一致,且该数据仅存在于当前缓存中。
  • Exclusive(独占):缓存行中的数据与主内存中的数据一致,且该数据仅存在于当前缓存中。
  • Shared(共享):缓存行中的数据与主内存中的数据一致,且该数据可能存在于多个缓存中。
  • Invalid(无效):缓存行中的数据无效。

状态转换

缓存行的状态会根据不同的操作进行转换。以下是一些常见的状态转换:

从主内存读取数据:如果一个缓存行处于Invalid状态,当一个处理器读取该数据时,缓存行会变为Shared或Exclusive状态。
修改数据:如果一个缓存行处于Shared或Exclusive状态,当一个处理器修改该数据时,缓存行会变为Modified状态,并通知其他缓存将该缓存行置为Invalid状态。
写回数据:如果一个缓存行处于Modified状态,当该缓存行被替换时,数据会被写回主内存,缓存行变为Invalid状态。

工作原理

假设有三个处理器A、B和C,它们各自有自己的缓存。主内存中有一个变量x,初始值为0。

读取数据:

处理器A从主内存读取x,缓存行状态变为Exclusive。
处理器B从主内存读取x,处理器A检测到冲突,将缓存行状态变为Shared,处理器B的缓存行状态也变为Shared。
修改数据:

处理器A修改x,缓存行状态变为Modified,并通知处理器B将其缓存行状态变为Invalid。
处理器B尝试读取x时,检测到缓存行无效,从主内存重新读取数据。

写回数据:

处理器A将修改后的数据写回主内存,缓存行状态变为Invalid。

优化和问题

缓存一致性协议通过总线嗅探(Bus Snooping)和状态机机制来实现数据的一致性,但也会引入一些性能问题,如总线带宽压力和处理器等待时间。为了解决这些问题,现代处理器引入了存储缓冲区(Store Buffer)和写回策略(Write Back)等优化技术。

CAS

比较并交换(Compare-And-Swap, CAS)操作在并发编程中有许多好处:

  1. 无锁编程
    CAS操作是无锁编程的基础,它允许多个线程在不使用锁的情况下安全地修改共享数据。无锁编程可以减少锁的开销,避免死锁,提高系统的并发性能。

  2. 原子性
    CAS操作是原子的,即它确保比较和交换操作在硬件级别上是不可分割的。这意味着在任何时刻,只有一个线程能够成功地修改变量的值,避免竞争条件。

  3. 高效性
    CAS操作通常由硬件指令支持(如x86架构中的 LOCK CMPXCHG 指令),这些指令非常高效,能够快速完成比较和交换操作。相比于使用锁,CAS操作的开销更低。

  4. 可扩展性
    CAS操作允许多个线程同时尝试修改变量,而不会阻塞其他线程。这种特性使得CAS操作在高并发环境中具有良好的可扩展性,能够处理大量线程的并发访问。

  5. 实现复杂的并发数据结构
    CAS操作可以用于实现复杂的并发数据结构,如无锁队列、无锁栈等。这些数据结构能够在高并发环境中提供高效的操作,而不会引入锁的开销。

以下是一个使用CAS操作实现无锁计数器的示例:

cas_counter.cppview raw
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
#include <atomic>
#include <thread>
#include <iostream>

std::atomic<int> counter(0);

void increment() {
for (int i = 0; i < 1000; ++i) {
int expected = counter.load();
while (!counter.compare_exchange_weak(expected, expected + 1)) {
// 重试,直到成功
}
}
}

int main() {
std::thread t1(increment);
std::thread t2(increment);

t1.join();
t2.join();

std::cout << "Final counter value: " << counter << std::endl;
return 0;
}

在这个例子中,compare_exchange_weak 使用CAS操作来确保 counter 的原子性修改。每次修改失败时,线程会重试,直到成功。

compare_exchange_weak 和 fetch_add

compare_exchange_weak 和 fetch_add 是两种不同的原子操作,它们在功能和使用场景上有明显的区别。让我们详细解释一下它们的区别以及如何选择。

compare_exchange_weak

功能:

compare_exchange_weak 是一种CAS(Compare-And-Swap)操作,用于比较原子变量的当前值与预期值,如果相等,则将新值存储到变量中,并返回 true;如果不相等,则返回 false 并更新预期值。
该操作允许偶尔的失败(即使当前值与预期值相等,也可能返回 false),这在一些循环算法中是可以接受的。

使用场景:

适用于需要在循环中反复尝试更新变量的场景,例如实现无锁数据结构(如无锁队列、栈等)。
在高并发环境中,compare_exchange_weak 通常比 compare_exchange_strong 性能更高,因为它允许偶尔的失败,从而减少了总线锁定的开销.

fetch_add

功能:

fetch_add 是一种原子加法操作,它将给定的值加到原子变量的当前值中,并返回变量的旧值。
该操作是原子的,确保在多线程环境中不会出现竞争条件。

使用场景:

适用于简单的计数器或累加器场景,例如统计访问次数、累加总和等。
在需要对变量进行简单的加法操作时,fetch_add 是一个高效的选择2.

如何选择

选择 compare_exchange_weak 还是 fetch_add 取决于具体的使用场景和需求:

如果需要实现复杂的无锁数据结构(如无锁队列、栈等),并且需要反复尝试更新变量,选择 compare_exchange_weak。
如果需要对变量进行简单的加法操作(如计数器、累加器),选择 fetch_add。
示例代码
以下是使用 compare_exchange_weak 和 fetch_add 的示例代码:

compare_exchange_weak 示例:

compare_exchange_weak_eg.cppview raw
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
#include <atomic>
#include <thread>
#include <iostream>

std::atomic<int> counter(0);

void increment() {
for (int i = 0; i < 1000; ++i) {
int expected = counter.load();
while (!counter.compare_exchange_weak(expected, expected + 1)) {
// 重试,直到成功
}
}
}

int main() {
std::thread t1(increment);
std::thread t2(increment);

t1.join();
t2.join();

std::cout << "Final counter value: " << counter << std::endl;
return 0;
}

fetch_add 示例:

fetch_add_eg.cppview raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <atomic>
#include <thread>
#include <iostream>

std::atomic<int> counter(0);

void increment() {
for (int i = 0; i < 1000; ++i) {
counter.fetch_add(1);
}
}

int main() {
std::thread t1(increment);
std::thread t2(increment);

t1.join();
t2.join();

std::cout << "Final counter value: " << counter << std::endl;
return 0;
}

如果我们只需要简单地递增计数器,并且没有其他条件限制,fetch_add 确实是一个更直接和高效的选择。fetch_add 能够确保每次递增操作都是原子的,避免了竞争条件。

然而,在某些特定场景下,compare_exchange_weak 可能更为合适。例如,当我们需要基于特定条件进行更新时,compare_exchange_weak 可以确保只有在满足条件时才进行更新操作。

示例:基于条件的更新
假设我们有一个计数器,需要在满足特定条件时进行递增操作:

cas_case.cppview raw
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
#include <atomic>
#include <thread>
#include <iostream>

std::atomic<int> counter(0);

void conditional_increment() {
for (int i = 0; i < 1000; ++i) {
int expected = counter.load();
while (expected < 500 && !counter.compare_exchange_weak(expected, expected + 1)) {
// 重试,直到成功或条件不满足
}
}
}

int main() {
std::thread t1(conditional_increment);
std::thread t2(conditional_increment);

t1.join();
t2.join();

std::cout << "Final counter value: " << counter << std::endl;
return 0;
}

在这个例子中,只有当 counter 的值小于500时,才会进行递增操作。compare_exchange_weak 确保条件更新的原子性和安全性。

使用 fetch_add 的场景
如果我们不需要基于条件进行更新,只是简单地递增计数器,那么 fetch_add 是一个更好的选择:

fetch_add_case.cppview raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <atomic>
#include <thread>
#include <iostream>

std::atomic<int> counter(0);

void increment() {
for (int i = 0; i < 1000; ++i) {
counter.fetch_add(1);
}
}

int main() {
std::thread t1(increment);
std::thread t2(increment);

t1.join();
t2.join();

std::cout << "Final counter value: " << counter << std::endl;
return 0;
}

在这个例子中,fetch_add 确保每次递增操作都是原子的,避免了竞争条件。

总结来说,如果你的操作只是简单的递增,fetch_add 是更高效的选择;如果需要基于条件进行更新,compare_exchange_weak 则更为合适。

ABA

ABA问题在并发编程中是一个常见的问题,尤其是在使用CAS(Compare-And-Swap)操作时。ABA问题的本质是一个变量的值在两次比较之间发生了变化,但最终值又回到了原始值,导致CAS操作无法检测到这种变化。为了避免ABA问题,可以使用带有版本号或标记的原子引用,例如 AtomicStampedReference。

示例:使用 AtomicStampedReference 避免ABA问题
以下是一个使用 AtomicStampedReference 避免ABA问题的示例:

atomic_stamped_reference.cppview raw
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
#include <atomic>
#include <thread>
#include <iostream>
#include <vector>

std::atomic<int> counter(0);
std::atomic<int> stamp(0);

void conditional_increment() {
for (int i = 0; i < 1000; ++i) {
int expected = counter.load();
int expected_stamp = stamp.load();
while (expected < 500 && !counter.compare_exchange_weak(expected, expected + 1) && !stamp.compare_exchange_weak(expected_stamp, expected_stamp + 1)) {
// 重试,直到成功或条件不满足
}
}
}

int main() {
std::thread t1(conditional_increment);
std::thread t2(conditional_increment);

t1.join();
t2.join();

std::cout << "Final counter value: " << counter << std::endl;
std::cout << "Final stamp value: " << stamp << std::endl;
return 0;
}

在这个例子中,我们使用了一个额外的 stamp 变量来记录每次更新的版本号。每次更新 counter 时,同时更新 stamp。这样,即使 counter 的值回到了原始值,stamp 的值也会不同,从而避免了ABA问题。

解释
版本号:每次更新 counter 时,同时更新 stamp,确保每次更新都有一个唯一的版本号。
CAS操作:使用 compare_exchange_weak 确保 counter 和 stamp 的更新是原子的。
重试机制:如果CAS操作失败,线程会重试,直到成功或条件不满足。
这种方法通过引入版本号或标记,确保即使变量的值回到了原始值,版本号也会不同,从而避免了ABA问题。

ABA问题不仅存在于CAS操作中,还可能在其他并发编程场景中出现。以下是一些可能出现ABA问题的情景:

  1. 双重检查锁定(Double-Checked Locking)
    在双重检查锁定模式中,ABA问题可能会导致错误的判断。例如,在初始化单例对象时,如果两个线程同时检查对象是否为空,并且一个线程在检查后立即创建对象,而另一个线程在检查后发现对象已经被创建并且再次检查时发现对象为空,这可能会导致错误的初始化。

  2. 无锁队列和栈
    在无锁队列和栈的实现中,ABA问题可能会导致节点的插入和删除操作出现错误。例如,一个线程在删除节点时,另一个线程可能会插入和删除相同的节点,导致第一个线程无法正确判断节点的状态。

  3. 版本控制
    在版本控制系统中,ABA问题可能会导致错误的版本判断。例如,一个线程在检查版本号时,另一个线程可能会更新版本号并且再次更新回原始版本号,导致第一个线程无法正确判断版本是否被修改。

解决方案
为了避免ABA问题,可以使用以下解决方案:

版本号或标记:使用版本号或标记来记录每次更新的状态。例如,使用 AtomicStampedReference 来确保每次更新都有一个唯一的版本号。
时间戳:使用时间戳来记录每次更新的时间,确保每次更新都有一个唯一的时间标记。
逻辑时钟:使用逻辑时钟来记录每次更新的顺序,确保每次更新都有一个唯一的顺序标记。
示例:使用 AtomicStampedReference 避免ABA问题
以下是一个使用 AtomicStampedReference 避免ABA问题的示例:

atomic_stamped_reference.javaview raw
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
import java.util.concurrent.atomic.AtomicStampedReference;

public class ABAExample {
private static AtomicStampedReference<Integer> atomicStampedRef = new AtomicStampedReference<>(0, 0);

public static void main(String[] args) {
Thread t1 = new Thread(() -> {
int stamp = atomicStampedRef.getStamp();
Integer value = atomicStampedRef.getReference();
System.out.println("Thread 1 initial value: " + value + ", stamp: " + stamp);
atomicStampedRef.compareAndSet(value, value + 1, stamp, stamp + 1);
System.out.println("Thread 1 updated value: " + atomicStampedRef.getReference() + ", stamp: " + atomicStampedRef.getStamp());
});

Thread t2 = new Thread(() -> {
int stamp = atomicStampedRef.getStamp();
Integer value = atomicStampedRef.getReference();
System.out.println("Thread 2 initial value: " + value + ", stamp: " + stamp);
atomicStampedRef.compareAndSet(value, value + 1, stamp, stamp + 1);
System.out.println("Thread 2 updated value: " + atomicStampedRef.getReference() + ", stamp: " + atomicStampedRef.getStamp());
});

t1.start();
t2.start();
}
}

在这个例子中,AtomicStampedReference 使用版本号来避免ABA问题,确保每次更新都有一个唯一的版本号。

C++ 标准库中没有直接等同于 Java 的 AtomicStampedReference 的类,但你可以通过组合 std::atomic 和自定义结构体来实现类似的功能。以下是一个示例,展示如何在 C++ 中实现带有版本号的原子引用,以避免 ABA 问题:

示例:实现带有版本号的原子引用

cas_with_version.cppview raw
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
#include <atomic>
#include <iostream>
#include <thread>

struct StampedValue {
int value;
int stamp;
};

std::atomic<StampedValue> atomicStampedValue{StampedValue{0, 0}};

void increment() {
for (int i = 0; i < 1000; ++i) {
StampedValue expected = atomicStampedValue.load();
StampedValue newValue;
do {
newValue = {expected.value + 1, expected.stamp + 1};
} while (!atomicStampedValue.compare_exchange_weak(expected, newValue));
}
}

int main() {
std::thread t1(increment);
std::thread t2(increment);

t1.join();
t2.join();

StampedValue finalValue = atomicStampedValue.load();
std::cout << "Final value: " << finalValue.value << ", Final stamp: " << finalValue.stamp << std::endl;
return 0;
}

解释
StampedValue 结构体:包含一个值和一个版本号(或时间戳)。
std::atomic:使用 std::atomic 包装 StampedValue 结构体,以确保原子性操作。
compare_exchange_weak:在更新值和版本号时使用 CAS 操作,确保操作的原子性和避免 ABA 问题。
使用场景
这种方法适用于需要避免 ABA 问题的场景,例如无锁队列、无锁栈等复杂并发数据结构。

compare_exchange_weak 和 compare_exchange_strong

区别
可靠性:

compare_exchange_weak:允许偶尔失败,即使当前值与期望值相等,也可能返回 false。这种设计是为了提高性能,适用于循环中的自旋锁等场景。
compare_exchange_strong:保证操作成功,如果当前值与期望值相等,则一定返回 true。适用于需要确保操作成功的场景。
性能:

compare_exchange_weak:通常比 compare_exchange_strong 性能更高,因为它允许偶尔失败。
compare_exchange_strong:在某些平台上性能可能较低,因为它需要确保操作成功。
适用场景
compare_exchange_weak:

  • 适用于循环中的自旋锁。
  • 适用于高性能要求的场景。

compare_exchange_strong:

  • 适用于需要确保操作成功的场景。
  • 适用于线程同步。

compare_exchange_weak 允许虚假失败的底层实现基于 CAS(Compare-And-Swap) 操作和硬件指令的特性。以下是详细解释:

CAS操作
CAS 操作有三个参数:内存地址、预期值和新值。其基本原理是:

比较内存地址中的值是否与预期值相等。
如果相等,则将内存地址中的值更新为新值。
如果不相等,则不做任何操作,并返回当前内存地址中的值。
虚假失败的原因
compare_exchange_weak 允许虚假失败,即使当前值与预期值相等,也可能返回 false。这种情况通常发生在以下几种情况:

缓存一致性问题:

在多核处理器中,缓存一致性协议可能导致 CAS 操作失败,即使内存中的值没有改变。例如,在 ARM 架构中,使用 LDXR/STXR 指令时,其他线程对同一缓存行的访问可能导致 CAS 操作失败1。
硬件指令的实现:

在某些平台上,CAS 操作可能被实现为单次 LL/SC(Load-Linked/Store-Conditional)尝试。这种实现允许 CAS 操作偶尔失败,以提高性能

为什么compare_exchange_strong不被用于实现自旋锁?
Copilot said:
Copilot
compare_exchange_strong 通常不用于实现自旋锁,主要原因在于其性能和设计目的:

性能
compare_exchange_strong 保证操作成功,如果当前值与预期值相等,则一定返回 true1。这种保证需要更多的硬件资源和时间来完成操作,可能导致性能下降1。
compare_exchange_weak 允许偶尔失败,即使当前值与预期值相等,也可能返回 false1。这种设计减少了硬件资源的消耗,提高了性能1。
设计目的
自旋锁 的设计目的是在短时间内频繁尝试获取锁,而不是长时间等待。因此,自旋锁更适合使用 compare_exchange_weak,因为它可以在失败时快速重试1。
compare_exchange_strong 更适合需要确保操作成功的场景,如线程同步1。在这些场景中,操作的成功率比性能更重要。
代码示例
以下是一个使用 compare_exchange_weak 实现自旋锁的示例:

cas_spin_lock.cppview raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class SpinLock {
private:
std::atomic<bool> flag = {false};
public:
void lock() {
bool expected = false;
while (!flag.compare_exchange_weak(expected, true, std::memory_order_acquire)) {
expected = false;
}
}
void unlock() {
flag.store(false, std::memory_order_release);
}
};

在这个示例中,compare_exchange_weak 被用来实现自旋锁,因为它在失败时可以快速重试,从而提高性能

内存屏障

内存屏障(Memory Barrier),也被称为内存栅栏(Memory Fence)或内存围栏(Memory Fence),是一种硬件或软件的同步机制,用于在并发系统中保持内存操作的顺序性。这是多核和多线程环境中至关重要的,因为现代处理器会对指令进行重排序以提高执行效率。

内存屏障的作用

  • 防止指令重排序:

    编译器和处理器可能会对指令进行重排序,以优化性能。内存屏障确保特定的内存操作在屏障之前完成,而不会被重排序到屏障之后。

  • 保证内存可见性:

    在多线程环境中,一个线程对内存的修改必须对其他线程可见。内存屏障确保在屏障之前的所有写操作对其他线程可见。

类型

  • 加载屏障(Load Barrier):

    确保在屏障之前的所有加载操作完成后,才开始执行屏障之后的加载操作。

  • 存储屏障(Store Barrier):

    确保在屏障之前的所有存储操作完成后,才开始执行屏障之后的存储操作。

  • 全屏障(Full Barrier):

    结合了加载屏障和存储屏障的功能,确保在屏障之前的所有加载和存储操作完成后,才开始执行屏障之后的加载和存储操作。

代码示例

以下是一个简单的代码示例,展示了如何使用内存屏障:

memory_barrier.cppview raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <atomic>

std::atomic<int> a{0};
std::atomic<int> b{0};

void thread1() {
a.store(1, std::memory_order_relaxed);
std::atomic_thread_fence(std::memory_order_seq_cst); // 全屏障
b.store(1, std::memory_order_relaxed);
}

void thread2() {
while (b.load(std::memory_order_relaxed) == 0);
std::atomic_thread_fence(std::memory_order_seq_cst); // 全屏障
assert(a.load(std::memory_order_relaxed) == 1);
}

在这个示例中,std::atomic_thread_fence 用于插入内存屏障,确保在屏障之前的存储操作完成后,才开始执行屏障之后的存储操作

内存序

内存序(Memory Order)描述了在多线程环境中,内存操作的顺序和可见性。它决定了一个线程对内存的修改何时以及如何对其他线程可见。C++11 引入了六种内存序,以便程序员在并发编程中根据需求选择合适的同步机制。

六种内存序

  • memory_order_relaxed:

    只保证当前操作的原子性,不考虑线程间的同步。适用于不需要同步的场景,如计数器的增加。

  • memory_order_consume:

    确保当前操作依赖的所有写操作在此操作之前完成。主要用于依赖关系较强的场景。

  • memory_order_acquire:

    确保在此操作之后的所有读写操作不会被重排序到此操作之前。常用于获取锁的操作。

  • memory_order_release:

    确保在此操作之前的所有读写操作不会被重排序到此操作之后。常用于释放锁的操作。

  • memory_order_acq_rel:

    结合了 acquire 和 release 的语义,确保在此操作之前的写操作不会被重排序到此操作之后,同时在此操作之后的读操作不会被重排序到此操作之前。适用于读-修改-写操作。

  • memory_order_seq_cst:

    顺序一致性,确保所有线程看到的内存操作顺序一致。这是最严格的内存序,适用于需要强一致性的场景。

代码示例

以下是一个简单的代码示例,展示了如何使用不同的内存序:

memory_order.cppview raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <atomic>
#include <thread>
#include <cassert>

std::atomic<int> data{0};
std::atomic<bool> ready{false};

void producer() {
data.store(42, std::memory_order_relaxed);
ready.store(true, std::memory_order_release);
}

void consumer() {
while (!ready.load(std::memory_order_acquire));
assert(data.load(std::memory_order_relaxed) == 42);
}

int main() {
std::thread t1(producer);
std::thread t2(consumer);
t1.join();
t2.join();
return 0;
}

在这个示例中,producer 线程使用 memory_order_release 来发布数据,而 consumer 线程使用 memory_order_acquire 来确保读取到的数据是最新的

TODO

TCL_Main

Tcl_Main的简化流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void Tcl_Main(int argc, char *argv[], Tcl_AppInitProc *appInitProc) {
Tcl_Interp *interp;

// 创建一个新的 Tcl 解释器
interp = Tcl_CreateInterp();

// 调用应用程序初始化函数
if ((*appInitProc)(interp) != TCL_OK) {
fprintf(stderr, "Application initialization failed: %s\n", Tcl_GetStringResult(interp));
exit(1);
}

// 进入 Tcl 事件循环
Tcl_MainLoop();
}

花括号的用法

在Tcl中,花括号 {} 有多种用法,主要用于分组、延迟解析、创建列表和字典等。以下是花括号的主要用法及示例:

  1. 分组代码块
    花括号用于将一段代码分组,使其作为一个整体传递或执行。

    1
    2
    3
    4
    5
    if {[catch {myProc} msg]} {
    puts "An error occurred: $msg"
    } else {
    puts "myProc executed successfully"
    }
  2. 延迟解析
    花括号内的内容不会立即解析,直到需要时才会被解析。这在处理包含特殊字符的字符串时非常有用。

    1
    2
    set script {puts "Hello, World!"}
    eval $script ;# 输出 "Hello, World!"
    1
    2
    3
    if {[catch {myProc} msg]} {
    puts "An error occurred: $msg"
    }
  3. 创建列表
    花括号用于创建列表,列表中的元素可以包含空格或特殊字符。

    1
    2
    set myList {one two three}
    puts [lindex $myList 1] ;# 输出 "two"
  4. 创建字典
    花括号用于创建字典,字典中的键值对可以包含空格或特殊字符。

    1
    2
    set myDict {key1 value1 key2 value2}
    puts [dict get $myDict key1] ;# 输出 "value1"
  5. 多行字符串
    花括号用于创建多行字符串,字符串中的换行符和空格会被保留。

    1
    2
    3
    4
    5
    set multiLineString {
    This is a multi-line
    string in Tcl.
    }
    puts $multiLineString
  6. 保护特殊字符
    花括号用于保护特殊字符,使其不被解释为命令或变量。

    1
    2
    set specialChars {This is a {special} string with [brackets] and $dollar signs.}
    puts $specialChars
  7. 在控制结构中使用
    花括号用于控制结构(如 if、while、for 等)中的条件和代码块。

    1
    2
    3
    4
    5
    6
    set x 10
    if {$x > 5} {
    puts "x is greater than 5"
    } else {
    puts "x is 5 or less"
    }
  8. 定义过程
    花括号用于定义过程的参数和主体。

    1
    2
    3
    4
    proc greet {name} {
    puts "Hello, $name!"
    }
    greet "Tcl User" ;# 输出 "Hello, Tcl User!"

global 和 varaible

globalvariable 用于声明该变量来自全局还是当前命名空间。

注意:两者都是声明,不是定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
set i 20

namespace eval test {
;# variable i
;# i没有被事先声明为名字空间的变量,则会引用全局变量i
for { set i 1} { $i <=5} { incr i} {
puts -nonewline "i=$i; "
}
puts "\n"
;# 由于没有全局的j,所以j被默认视作命名空间内部变量,引用时必须加上命名空间为前缀
for {set j 1} { $j<=5} {incr j} {
puts -nonewline "j=$j; "
}
puts "\n"
}

puts $i
puts $test::j
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
;#注意i的定义处被注释了
#set i 20

namespace eval test {
;# 这里的 global 声明无效,因为没有在全局定义i
global i
;# 此处的i仍然是命令空间内部的
for { set i 1} { $i <=5} { incr i} {
puts -nonewline "i=$i; "
}
}

;# 错误!
puts $i

;# 正确
puts $test::i

1. 参考

白栎旸. 数字IC设计入门(微课视频版)[M]. 北京: 清华大学出版社,2023-09-01:第2章.

2. 数字器件与Verilog语法

虽然 Foundry 提供的可用元器件种类较多,但数字设计师并不特别在意这些元器件,在其头脑中,只存在 10 种简单元器件:

  • 与门、或门、非门、异或门、
  • 加法器、乘法器、移位器、
  • 选择器、比较器、
  • 触发器

注意,虽然加法器、乘法器、比较器等元器件可以由逻辑门生成,但是从Verilog常用的表达式来看,一般直接用“+”这个符号表示加法,而很少用门电路去直接搭建。因为现代芯片规模庞大,功能复杂,工程师应该将主要精力投入到重点难题的实现中,而对于如何实现加法器等最底层的问题,应该交给综合步骤自动完成。

基本元器件中不包括除法,因为除法的实现不同于乘法,它受到被除数、除数、商的数值范围限制,有时需要用到迭代等复杂方法实现,还有分母为 0 等异常情况需要报告,所以不属于Verilog中常用的直接运算方式。

务必注意,数字前端写的Verilog仅仅是代码,而非程序,其代码是代替电路图的一种文本语言描述。与或非加乘等指的是元器件。写Verilog时要有电路概貌和时序。

10种数字器件的符号表示及Verilog表示方法见表格:

10种数字逻辑器件和Verilog表示
20250117214703

真正的元器件库中有很多复杂的元器件,如图所示:

20250117214659
复杂元器件示例

但是这些元器件都可以看作以上10种基础元器件的组合,不会超出原有的功能范围。所以设计时,头脑中只需要有以上10种元器件。

数字IC设计又称为数字逻辑设计,因为其本身就是逻辑的,只有01两种逻辑。

  • 组合逻辑:电平输入和电平输出。元器件结构简单,但问题是如果输入含有毛刺,输出就有毛刺。
20250117215056
20250117214654
组合逻辑电路的毛刺
  • 时序逻辑:以时钟为驱动源。一个触发器,在时钟的驱动(边沿触发)下,将 D 输入端的信号送到 Q 端输出。
20250117214649

触发器也可以叫寄存器(register, reg),因为如果没有时钟驱动,那么Q端会保持原有状态不变,也就寄存了上一次触发时的D端信息。而组合逻辑,输出端是无法寄存信息的。

时序逻辑是数字电路的基础。10 种元器件中,只有触发器属于时序逻辑器件,所以触发器是整个数字电路的基础。从 RTL 的名称可以知晓,RTL 意为寄存器传输层,直译过来就是:从一个触发器的输出到另一个触发器的输入,通过触发器的层层传递,最终实现了一个功能完整的数字电路。

数字电路的时序分析,主要是分析两个触发器之间的路径延迟。

进行前仿时,看到的仿真波形会和本图一样,是理想的,而使用版图网表进行后仿时,仿真波形是带延迟的。

上图中触发器的符号表示,它共有4个引脚,除输入的D端和输出的Q端外,三角形位置表示时钟,下方的rst_n表示复位,其上的圆圈表示0电平有效,即rst_n等于0时,寄存器处于复位状态。此时,Q端保持0,即使时钟和D端有动作,Q端也不会变化,只有当rst_n等于1时,才解除复位状态,寄存器方能正常工作。

3. 可综合的Verilog设计语法

能变成电路的Verilog表达叫做可综合,在设计电路时,只能使用可综合的语法表述。而在仿真时,由于只在计算机上运行,不留片,可使用不能综合的高级语法,以增加语言表达的灵活度和复杂度。

可综合的电路表述只有两种:

  • assign
  • always

与门:

1
assign z = a & b;

触发器:

1
2
3
4
5
6
7
always @(posedge clk or negedge rst_n)
begin
if (!rst_n)
Q <= 0;
else
Q <= D;
end

符号说明:

  • <=: 非阻塞赋值,凡是时序逻辑,都用非阻塞赋值;
  • =: 阻塞赋值,凡是组合逻辑,都用阻塞赋值;
  • @(...): 括号中的列表叫敏感列表,意思是,always块输出的Q对列表中信号保持敏感,如果敏感信号动,则Q也会动。
  • posedge clk: 意思是时钟的上升沿;
  • negedge rst_n: 意思是时钟的下降沿。
  • begin / end: 相当于 C 语言中的 {} ,如果语句只有一条,可以不写 begin / end

always不仅可以表示时序逻辑,也可以表示组合逻辑。如下是与门的另一种表示:

1
2
3
4
always @(*)
begin
z = a & b;
end

其中,@(*)中的*是省略表述的敏感列表,综合器会自动在always块中寻找与输出z相关的输入信号,自动填入敏感列表中。本例中,会自动将ab作为输入填入。这种让工具自动填入的方式是可靠且推荐的。

Verilog的语法规律:

  1. 时序逻辑,必须使用always块,并同时使用<=非阻塞赋值。在其敏感列表中,必须出现时钟信号的边沿和复位信号的边沿。
  2. 组合逻辑,可以使用assign,也可以使用always块,但是它们的赋值是=阻塞赋值。若使用always块,则敏感列表中使用*。若遇到敏感列表中带有*,则可以直接判定为组合逻辑。

再次强调,Verilog的语法表达,描述的都是电路,因此例子中的 zabclkrst_nQD 都称为信号,在电路中都是实实在在的金属连线,切勿称为变量。

4. 对寄存器的深度解读

一般会使用时钟上升沿来驱动寄存器。对于同样的功能需求,双沿触发需要的时钟慢,但要求时钟是50%占空比,而单沿触发,对时钟的要求快一倍,但对时钟形状的要求降低很多。

复位信号rst_n,以0电平作为复位电平,1电平解复位,是通用标准,很少有反过来使用的。原因是,数字电路的复位信号是模拟电路给的,通常,模拟电路将其命名为POR(Power On Reset),即上电复位信号。芯片刚通电时,电压小,逐渐上升到要求的电压,例如1.8VPOR本质上是一个电压上升的标志,模拟电路放一个比较器,将输入电压与0.9V比较,电压小于0.9VPOR0,电压大于0.9VPOR1。因而复位信号上电时总是先01,数字寄存器需要在复位信号为0的阶段保持复位态,不能运行,因为此时芯片电压不足,不能保证正常运行,而复位信号变成1,说明上电完毕,电压充足,寄存器解除复位进行正常运行是安全的。

需要特别澄清的是语句negedge rst_n,但是经过仿真和与模拟工程师确认,复位信号对寄存器的作用不是通过信号沿来驱动的,而是通过电平来驱动,也就是 0 信号具有绝对控制权,只要 rst_n0 ,那么立即复位。

5. 非阻塞赋值和阻塞赋值的区别

非阻塞赋值的意思是该句表达不会阻塞后续表达的执行。如下例中,X <= 0的执行,不会阻碍到Y <= 0的执行,它们是同时发生的:

1
2
3
4
5
6
7
8
9
10
11
12
13
always @(posedge clk or negedge rst_n)
begin
if (!rst_n)
begin
X <= 0;
Y <= 0;
end
else
begin
X <= A;
Y <= B;
end
end

而阻塞赋值,意思是如果前一句不执行,后一句就无法执行,前一句会阻塞后一句。对于可综合的Verilog来讲,其实并不会阻塞。在下例中, always 块的目的是创造 zk 两个信号。 k = 3 * zz = a & b 是两个不同的电路, k = 3 * z 电路不会被 z = a & b 阻塞。

1
2
3
4
5
always @(*)
begin
z = a & b; // 与门
k = 3 * z; // 乘法器
end

本例对应的原理图如图所示:

20250117224729

可见,对于电路描述来讲,语法只是表示一种连接关系,并没有执行先后顺序的说法,但如果本例使用非阻塞赋值,语法检查会报错,因此,这是一种惯用方法。阻塞赋值在Verilog中真正体现阻塞,是在仿真使用的不可综合语法中,到第3章再做解释。

6. 组合逻辑的表达式

对于一个组合逻辑电路,应该在什么情况下用assign,在什么情况下用always呢?

比较简单的逻辑适合使用assign方式,较为复杂的逻辑应使用always块。下例给出了一个适合用always块的较复杂例子:

1
2
3
4
5
6
7
8
9
10
11
always @(*)
begin
if (s1)
a = 1;
else if (s2)
a = 2;
else if (s3)
a = 3;
else
a = 0;
end

同样的功能若改用assign,则为下例所示。很明显,用always块表达意思更加清晰。

1
assign a = s1 ? 1 : (s2 ? 2 : (s3 ? 3 : 0));

前面解释了敏感列表中的 * 在组合逻辑 always 块中的作用。如果读者使用过一些老IP,则可能还会看到下例所示的表达,这种表达已随着综合器的进步渐渐被淘汰了,不建议初学者使用。

1
2
3
4
5
6
7
8
9
10
11
always @(s1 or s2 or s3)
begin
if (s1)
a = 1;
else if (s2)
a = 2;
else if (s3)
a = 3;
else
a = 0;
end

7. 组合逻辑中的选择器

7.1. 二选一 MUX 如何表达?

20250117225844
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 第 1 种表达, assign 完整表达
assign z = (s == 1) ? b : a;

// 第 2 种表达, assign 简化表达
assign z = s ? b : a;

// 第 3 种表达, always 块表达
always @(*)
begin
if (s)
z = b;
else
z = a;
end

7.2. 多选一MUX,又该如何表示呢?

因为使用 assign 表示显然会过于复杂,所以需要用 always 块表示。表示方法有两种,注意两种表达综合出来的电路是不同的。

其一如下例所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
always @(*)
begin
if (s == 0)
z = a;
else if (s == 1)
z = b;
else if (s == 2)
z = c;
else if (s == 3)
z = d;
else if (s == 4)
z = e;
else if (s == 5)
z = f;
else
z = a; // 默认值
end

综合出来的电路如图所示:

20250117230859

可见,使用 if 表述的选择关系,综合的电路是一层一层逐渐展开的,写在 if 最前面的语句,掌握着最终的选择权,因而优先级最高,再往后优先级逐层下降,而使用 case 表述的MUX,每个选择都是并列的,优先级相同,见下文。

其二如下例所示。

1
2
3
4
5
6
7
8
9
10
11
always @(*)
begin
case (s)
0: z = a;
1: z = b;
2: z = c;
3: z = d;
4: z = e;
5: z = f;
default: z = a; // 默认值
end

所综合的电路如图所示:

20250117230221

使用 if 表述,有可能出现隐藏逻辑,即设计者没有考虑到,但实际会被综合出来的逻辑门。隐藏逻辑是设计的隐患,设计者在写代码时应该清楚其逻辑含义,尽量避免出现隐藏逻辑。为了避免设计中出现隐藏逻辑,在实际项目中往往会提倡使用 case 语句来表达。

下例中反映出 if 的优先级特征,条件 s < 5 包含 s == 4 的情况,因为 s < 5 优先,因而当 s==4 时, z 的赋值是 a 而不是 b 。如果设计意图是要在 s == 4 时使 z = b ,则应当将其写在 s < 5 之前。

1
2
3
4
5
6
7
8
9
always @(*)
begin
if (s < 5)
z = a;
else if (s == 4)
z = b;
else
z = c;
end

case有一种变体是 casez ,它可以拓展case的使用范围:

1
2
3
4
5
6
7
8
9
always @(*)
begin
casez(s)
16'b00011???????????: z = a;
16'b10111111????0000: z = b;
16'b1111?001000?????: z = c;
default: z = d;
endcase
end

其中,问号的意思是0或1都能匹配,类似计算机语言中的通配符。

虽然很多项目提倡使用 casecasez 来表述选择器,但究竟是使用 if 还是 case ,仍然取决于表达的需要。总体而言, case 便于判断是否相等的情况,而 if 适合判断大于或小于关系,不同情况用不同的表达,可以使Verilog逻辑更加清晰,也更便于维护。

注意组合逻辑中的 ifelse if ,最后必须跟一句 else ,使整体逻辑完整。若没有else,则该电路会综合出一个锁存器(Latch)。锁存器不属于10种基本元器件之一。在设计中,凡有寄存需求,应尽量使用触发器,避免使用锁存器,特别要避免不写 else 引起的隐藏逻辑。

8. Verilog中的for循环

关键字:

  • for
  • generate / endgenerate / genvar
  • integer: 是编译时的变量,不是信号,不会被综合成电路或金属连线。如果变量 ii 声明为 wirereg ,然后用 a[ii] 表示一个信号,则语法不能综合为电路。但是 ii 声明为 integer ,那么 a[ii] 将被综合成信号。

9. Verilog中的数值表示方法

数值一般不直接写,如果直接写,则工具会理解为十进制32比特数,但实际中的信号位宽多种多样,选用哪种进制表示数值也有多种选择,因此需要将这两方面予以规定。

进制制 符号号 举例 解释
二进制 b 4’b0110 4 比特数,用二进制表示为 0110
十进制 d 8’d3 8 比特数,用十进制表示为 3
十六进制 h 15’h1abc 15 比特数,用十六进制表示为 1abc

特殊的数值表示方法:

{5{1'b0}} 表示5个比特0,再如 {4{1'b1}} 表示4个比特1。这种表示一般不用,但是它有优点,上例中的比特数量5和4可以用参数替代,若Verilog中有一个参数 kkk ,则可以写为 {(kkk){1'b0}} ,甚至可以写成用计算式表示的位宽,如 {(kkk+2){1'b0}} 。注意,kkk是参数,不是信号,它不是电路,而是一种编译时使用的变量。

这种表示方法还可以写为 {常数{逻辑表达式}} 的形式,如

1
assign int_clr = {2{(apb_addr = = 4'd7) & wr_en}} & apb_wdat[1 : 0];

此例的目的是使用APB总线配置两个只写信号(write-only信号)​,这两个信号合称为 int_clr ,等式的右边,仅当地址 apb_addr 等于7,并且在APB总线上发生写操作时,APB写入的两比特数据 apb_wdat[1:0] 才会被配置到int_clr中,否则int_clr就是0。问题在于, (apb_addr==4'd7)&wr_en 是一比特,不能跟 apb_wdat[1:0] 按位求与,因而需要将一比特复制为两比特,即 {2{(apb_addr==4'd7)&wr_en}}

对于其他辅助变量,可以直接写数字,例如在for循环中讲述的 genvar ii ,ii只是个变量,没有对应的电路,那么for循环赋值就不需要带位宽 和进制了,可以像下面这样写

1
for(ii = 0;ii<100;ii = ii + 1)

注意Verilog中,如果单写a、b、c、d、e、f、x、z,表示的是信号名,不能作为数值,如果想表示十六进制的数值,则可写为4’ha等,1’bx表示未知态,1’bz表示高阻态。

10. 信号的状态类型

1
0 1 x z

其中,

  • 0和1是数字电路本身的状态,它的本源是零电平和VDD电平。

    VDD, Voltage Drain, 其中 Drain 表示漏极,是一个电源引脚,用来提供正电压给电路中的元件。由于晶体管的设计通常包含两个漏极(源极和漏极),所以在一些命名规则中,为了区分正电源和负电源,使用了两个 D,并且VDD 代表正电源电压,VSS(Voltage Source)代表地线或负电源。 整个芯片的电源常称为VCC,芯片的地常标注为VSS。

    不同工艺和元器件库需要的VDD不同,例如0.9V、1.8V、3.3V等,而同一个元器件库中的所有元器件,其需要的供电电压VDD一般相同的,只有I/O器件等少数元器件,其输入端和控制端是比较低的电压,而输出端口却是较高的电压。

    数字0和1对应的电平不会特别严格,而是有一个浮动范围,通常信号电平低于VDD的30%,就被认为是0,高于VDD的70%,就被认为是1。

  • z态是高阻态。

    如果一颗芯片不通电,则它所有的引脚就都是高阻态。可见,高阻态的实际意义就是不会干扰到其他信号传输的状态,例如某信号A是高阻态,某信号B不是高阻态,那么信号A叠加到信号B上(可以想象为两根信号线被拧在一起)​,结果仍然是B,而A没有任何效果。

    一般来讲,一个有着双向传输功能的引脚,如果设置为输入模式,就可以认为这个引脚处于高阻态,意思是它对电路板上与它相连的元器件没有任何影响,这些相连元器件如果要对本芯片输出0或1,就可以直接顺着该高阻态引脚输入,而不会被干扰或阻挡。

    在FPGA的Verilog表述中,可以很形象地将FPGA的引脚描述为如下语句,其中b是FPGA的引脚,所以声明为inout类型,oe是该引脚的方向选择,若oe为1,则b为输出模式,将信号a输出到FPGA外面,而当oe为0时,是高阻态,即输入模式,外面的信号从b引脚可以进来后与信号c形成了组合逻辑,代码如下:

    1
    2
    3
    4
    inout b;

    assign b = oe ? a : 1'bz
    assign d = b ^ c;

    注意上例中引用FPGA的语法只是为了说明z态的含义。IC设计中对引脚的设计不像FPGA这么简单,需要例化一个引脚模块,在代码中不会出现1’bz数值。

    博主注1:

    在集成电路(IC)中,高阻态(High Impedance State,简称 Hi-Z)是指一个电路输出引脚处于没有输出驱动信号的状态,即该引脚既不提供高电平(VDD)也不提供低电平(GND),而是处于一种“悬空”状态,几乎不消耗电流,像是断开了。高阻态通常用于多路复用(bus)或三态总线(tri-state bus)等应用中,允许多个设备共享同一条总线而不会相互干扰。

    高阻态的实现
    高阻态通常通过 三态门(Tri-state Gate) 来实现。三态门是一种特殊类型的逻辑门,它具有三个输出状态:

    高电平(逻辑 1)
    低电平(逻辑 0)
    高阻态(Hi-Z)
    具体来说,高阻态是通过在输出端增加一个高阻抗的电路来实现的,这个高阻抗值通常非常大,以至于该引脚对外部电路几乎没有影响。三态门的工作原理可以分为以下几种方式:

    开关型设计:

    P-Channel MOSFET(PMOS) 和 N-Channel MOSFET(NMOS) 组成的电路可以根据控制信号决定是否接通输出。如果三态门处于“高阻态”时,MOSFET 们并不会导通,从而使输出引脚处于高阻抗状态。
    控制信号:

    三态门的输出通常由一个控制信号来控制。当控制信号为“使能”状态时,输出引脚会驱动高或低电平;而当控制信号为“禁用”状态时,输出引脚进入高阻态,基本与外部电路断开连接。
    双向总线:

    在多路复用的场景中,多个设备可能需要共享同一条数据总线。为了避免多个设备同时驱动总线产生冲突,设备在不输出数据时会进入高阻态,不影响其他设备的信号传输。
    高阻态的作用
    多路复用:高阻态允许多个电路共享同一总线。只有一个电路在某个时间点输出数据,其他电路进入高阻态,防止冲突。

    总线冲突保护:通过将不参与通信的设备置于高阻态,避免了多个设备在同一时间尝试驱动总线的冲突。

    节省电力:通过在不需要输出信号时将输出置于高阻态,可以降低功耗,因为在高阻态下输出引脚几乎不消耗电流。

    举例说明
    一个常见的例子是在 Tri-state Bus(三态总线)中,多个设备通过控制信号来决定是否驱动总线。如果设备不需要发送数据,它会将输出置于高阻态,允许其他设备使用总线。

    总结来说,高阻态的实现依赖于三态门(Tri-state Gate),它通过控制MOSFET开关来使输出处于非驱动状态,从而让其他电路可以共享同一个引脚或总线。

    博主注2:

    在数字电路中,inout 引脚是指一个既可以作为输入端口,也可以作为输出端口的引脚。它通常用于多路复用(bus)或双向数据传输的场合,允许一个引脚在不同时间根据需要在输入和输出之间切换。为了在同一引脚上同时支持输入和输出,通常使用 三态逻辑(Tri-state logic)来控制数据的流向。

    inout 引脚的实现方式
    inout 引脚的实现依赖于几个关键的设计原则和组件

    三态缓冲器(Tri-state Buffer):

    在 inout 引脚上,通常使用 三态缓冲器(Tri-state buffer)来控制该引脚是处于输入、输出还是高阻态。三态缓冲器的作用是通过控制信号来决定该引脚的状态。
    当引脚作为 输出 时,缓冲器将数据传递到引脚。
    当引脚作为 输入 时,缓冲器将引脚上的电压信号传送到内部电路。
    当引脚处于 高阻态(Hi-Z)时,缓冲器断开与引脚的连接,使得该引脚对外部电路“透明”,即不影响电路。
    控制信号:

    inout 引脚的切换通常由控制信号(例如,方向控制信号)来决定,指示该引脚是作为输入使用还是作为输出使用。
    控制信号一般是由逻辑电路生成,当电路需要输出数据时,控制信号会使引脚成为输出;而当电路需要接收数据时,控制信号会使引脚成为输入。
    双向总线设计:

    inout 引脚常用于总线设计,其中多个设备共享同一条总线。每个设备可以在需要时向总线发送数据,而在不需要时通过将输出设为高阻态来避免与其他设备的冲突。
    例如,数据总线(Data Bus)可能由多个设备共享。当一个设备不在发送数据时,它会将其 inout 引脚设为高阻态,确保只有发送设备影响总线信号。

    工作原理

    输出模式:

    当inout 引脚作为输出时,相关的三态缓冲器连接到该引脚,并驱动电平(高或低)。此时,控制信号将引脚设置为输出模式,并且其他连接到该引脚的电路会处于断开状态。

    输入模式:

    当 inout 引脚作为输入时,缓冲器进入高阻态(Hi-Z),即引脚的状态不再由输出逻辑驱动,转而接收外部信号。此时,只有连接到引脚的其他设备会驱动该引脚的电平。
    高阻态:

    当引脚不需要输出数据时,三态缓冲器将进入 高阻态,使该引脚对其他电路没有影响。这样,多个设备就可以共享同一个引脚或总线而不会发生电平冲突。
    应用示例
    双向数据总线:

    假设有多个设备共享一条数据总线。每个设备的 inout 引脚可以作为数据的输入端或输出端。在某个时刻,只有一个设备向总线发送数据,其他设备则将其引脚设为高阻态,避免干扰。
    双向信号传输:

    在通信协议中,inout 引脚可用于双向信号传输。例如,I²C总线就使用 inout 引脚,其中一个设备在某个时刻发送信号,而另一个设备则接收信号。
    芯片间通信:

    在多芯片系统中,inout 引脚可用于芯片之间的数据交换,允许不同芯片在不同时刻控制同一引脚的输入和输出行为。
    总结
    inout 引脚通过三态缓冲器和控制信号的组合,实现了同一引脚可以在输入和输出之间切换的功能。通过将输出置于高阻态,可以使得该引脚不会干扰其他电路或设备,使其适用于总线系统或双向通信的设计中。

  • x态的含义是未知态。

    有4种情况会产生未知态:

    • 其一是芯片已上电但复位信号未进行复位的情况;
    • 其二是双向引脚信号冲突,因为没控制好,导致有一路信号通过引脚输入,另一路信号通过相同的引脚输出;
    • 其三是芯片中一个元器件的某个输入端为x态,于是输出就跟着变成了x态,这就是所谓x态的传播;
    • 第四是触发器的时序不满足,产生了亚稳态,从而表示为x态。

    上述4种情况在仿真中都能看到,但实际中,第1种情况基本不会出现,除非模拟电路设计有误,其他3种在数字设计有缺陷时会出现,实际在测量其电压时会出现不稳定或非预期的问题。在可综合的Verilog中,不会出现1’bx数值,因为没有一个设计会故意将一个错误引入RTL中,所有的错误都是意外发生的。该符号在仿真脚本和仿真波形中可能出现。

电平信号与脉冲信号

电平信号也叫Latch信号,即一个信号持续多个时钟周期都一直保持为1的信号。

脉冲信号就是只持续一个时钟周期的信号。

工程师在交流时,对于电平信号a常用的说法是“将a给Latch住”​,对于脉冲信号b常用的说法是“打一个脉冲b”​。