0%

Makefileview raw
1
2
3
4
5
6
7
8
9
10
all: fake_dlfcn testlib main

fake_dlfcn:
g++ -std=c++17 -shared -fPIC fake_dlfcn.cpp -o libfake_dlfcn.so

testlib:
g++ -std=c++17 -fPIC -shared -o libtestlib.so testlib.cpp

main:
g++ -std=c++17 -o main main.cpp
fake_dlfcn.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
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
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
#include <elf.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>

#include <cstring>
#include <fstream>
#include <iostream>
#include <memory>
#include <sstream>
#include <vector>

#define TAG_NAME "test2:fake_dlfcn"

#ifdef __arm__
#include <android/log.h>
#define log_info(fmt, args...) __android_log_print(ANDROID_LOG_INFO, TAG_NAME, fmt, ##args)
#define log_err(fmt, args...) __android_log_print(ANDROID_LOG_ERROR, TAG_NAME, fmt, ##args)
#else
#define log_info(fmt, ...) std::cout << "[INFO] " << fmt << "\n", ##__VA_ARGS__
#define log_err(fmt, ...) std::cerr << "[ERROR] " << fmt << "\n", ##__VA_ARGS__
#endif

#ifdef LOG_DBG
#define log_dbg log_info
#else
#define log_dbg(...)
#endif

#ifdef __arm__
#define Elf_Ehdr Elf32_Ehdr
#define Elf_Shdr Elf32_Shdr
#define Elf_Sym Elf32_Sym
#elif defined(__aarch64__) || defined(__x86_64__)
#define Elf_Ehdr Elf64_Ehdr
#define Elf_Shdr Elf64_Shdr
#define Elf_Sym Elf64_Sym
#else
#error "Arch unknown, please port me"
#endif

struct Context {
void *load_addr = nullptr;
std::unique_ptr<char[]> dynstr;
std::unique_ptr<Elf_Sym[]> dynsym;
int nsyms = 0;
off_t bias = 0;

~Context() {
log_dbg("Context destroyed");
}
};

int fake_dlclose(void *handle) {
if (handle) {
delete static_cast<Context *>(handle);
}
return 0;
}

void *fake_dlopen(const char *libpath, int flags) {
std::ifstream maps("/proc/self/maps");
if (!maps.is_open()) {
log_err("Failed to open /proc/self/maps");
return nullptr;
}

std::string line;
std::string found_line;
while (std::getline(maps, line)) {
if (line.find("r-xp") != std::string::npos && line.find(libpath) != std::string::npos) {
found_line = line;
break;
}
}

if (found_line.empty()) {
log_err("%s not found in my userspace", libpath);
return nullptr;
}

off_t load_addr = 0;
std::istringstream iss(found_line);
iss >> std::hex >> load_addr;

log_info("%s loaded in Android at 0x%08lx", libpath, load_addr);

int fd = open(libpath, O_RDONLY);
if (fd < 0) {
log_err("Failed to open %s", libpath);
return nullptr;
}

off_t size = lseek(fd, 0, SEEK_END);
if (size <= 0) {
log_err("lseek() failed for %s", libpath);
close(fd);
return nullptr;
}

auto elf = static_cast<Elf_Ehdr *>(mmap(nullptr, size, PROT_READ, MAP_SHARED, fd, 0));
close(fd);

if (elf == MAP_FAILED) {
log_err("mmap() failed for %s", libpath);
return nullptr;
}

auto ctx = std::make_unique<Context>();
ctx->load_addr = reinterpret_cast<void *>(load_addr);

auto shoff = reinterpret_cast<char *>(elf) + elf->e_shoff;
for (int k = 0; k < elf->e_shnum; ++k, shoff += elf->e_shentsize) {
auto sh = reinterpret_cast<Elf_Shdr *>(shoff);
log_dbg("%s: k=%d shdr=%p type=%x", __func__, k, sh, sh->sh_type);

switch (sh->sh_type) {
case SHT_DYNSYM:
if (ctx->dynsym) {
log_err("%s: duplicate DYNSYM sections", libpath);
munmap(elf, size);
return nullptr;
}
ctx->dynsym = std::make_unique<Elf_Sym[]>(sh->sh_size / sizeof(Elf_Sym));
std::memcpy(ctx->dynsym.get(), reinterpret_cast<char *>(elf) + sh->sh_offset, sh->sh_size);
ctx->nsyms = sh->sh_size / sizeof(Elf_Sym);
break;

case SHT_STRTAB:
if (ctx->dynstr) break;
ctx->dynstr = std::make_unique<char[]>(sh->sh_size);
std::memcpy(ctx->dynstr.get(), reinterpret_cast<char *>(elf) + sh->sh_offset, sh->sh_size);
break;

case SHT_PROGBITS:
if (!ctx->dynstr || !ctx->dynsym) break;
ctx->bias = static_cast<off_t>(sh->sh_addr) - static_cast<off_t>(sh->sh_offset);
k = elf->e_shnum; // exit loop
break;
}
}

munmap(elf, size);

if (!ctx->dynstr || !ctx->dynsym) {
log_err("Dynamic sections not found in %s", libpath);
return nullptr;
}

log_dbg("%s: ok, dynsym = %p, dynstr = %p", libpath, ctx->dynsym.get(), ctx->dynstr.get());
return ctx.release();
}

void *fake_dlsym(void *handle, const char *name) {
auto ctx = static_cast<Context *>(handle);
auto sym = ctx->dynsym.get();
auto strings = ctx->dynstr.get();

for (int k = 0; k < ctx->nsyms; ++k, ++sym) {
if (std::strcmp(strings + sym->st_name, name) == 0) {
void *ret = static_cast<char *>(ctx->load_addr) + sym->st_value - ctx->bias;
log_info("%s found at %p", name, ret);
return ret;
}
}
return nullptr;
}
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
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
#include <iostream>

#include "fake_dlfcn.cpp" // 确保路径正确,或者将其改为头文件引用

// #include <dlfcn.h>
// #include <iostream>

// void preloadLibrary(const char *libraryPath) {
// void *handle = dlopen(libraryPath, RTLD_LAZY);
// if (!handle) {
// std::cerr << "Failed to preload library: " << libraryPath << std::endl;
// std::cerr << "Error: " << dlerror() << std::endl;
// return;
// }
// std::cout << "Library preloaded successfully: " << libraryPath << std::endl;

// // 注意:不要调用 dlclose(handle),否则会卸载库并从 /proc/self/maps 中移除
// }

#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>

#include <iostream>

void *mapLibrary(const char *libraryPath) {
int fd = open(libraryPath, O_RDONLY);
if (fd < 0) {
std::cerr << "Failed to open library: " << libraryPath << std::endl;
return nullptr;
}

off_t size = lseek(fd, 0, SEEK_END);
if (size <= 0) {
std::cerr << "Failed to get library size: " << libraryPath << std::endl;
close(fd);
return nullptr;
}

void *mapped = mmap(nullptr, size, PROT_READ | PROT_EXEC, MAP_PRIVATE, fd, 0);
close(fd);

if (mapped == MAP_FAILED) {
std::cerr << "Failed to map library: " << libraryPath << std::endl;
return nullptr;
}

std::cout << "Library mapped successfully: " << libraryPath << std::endl;
return mapped;
}

int main() {
const char *libraryPath = "./libtestlib.so"; // 替换为实际的共享库路径
const char *symbolName = "your_function_name"; // 替换为实际的符号名称

// 提前导入so到/proc中
void *mappedLibrary = mapLibrary(libraryPath);
if (!mappedLibrary) {
return 1; // 映射失败
}

// 加载共享库
void *handle = fake_dlopen(libraryPath, 0);
if (!handle) {
std::cerr << "Failed to load library: " << libraryPath << std::endl;
return 1;
}
std::cout << "Library loaded successfully: " << libraryPath << std::endl;

// 查找符号
void *symbol = fake_dlsym(handle, symbolName);
if (!symbol) {
std::cerr << "Failed to find symbol: " << symbolName << std::endl;
fake_dlclose(handle);
return 1;
}
std::cout << "Symbol found: " << symbolName << " at address " << symbol << std::endl;

// 调用符号(假设符号是一个函数)
using FunctionType = void (*)(); // 根据符号的实际类型修改
auto function = reinterpret_cast<FunctionType>(symbol);
function();

// 关闭共享库
if (fake_dlclose(handle) == 0) {
std::cout << "Library closed successfully." << std::endl;
} else {
std::cerr << "Failed to close library." << std::endl;
}

return 0;
}
testlib.cppview raw
1
2
3
4
5
6
7
8
9
10
#include <iostream>

extern "C" {

// 一个简单的函数,供 main.cpp 测试
void your_function_name() {
std::cout << "Hello from your_function_name in the shared library!" << std::endl;
}

}

定义

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

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

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

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

举个例子,假设我们有一个原子变量 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),是一种硬件或软件的同步机制,用于在并发系统中保持内存操作的顺序性123。这是多核和多线程环境中至关重要的,因为现代处理器会对指令进行重排序以提高执行效率123。

内存屏障的作用
防止指令重排序:

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

在多线程环境中,一个线程对内存的修改必须对其他线程可见12。内存屏障确保在屏障之前的所有写操作对其他线程可见12。
类型
加载屏障(Load Barrier):

确保在屏障之前的所有加载操作完成后,才开始执行屏障之后的加载操作12。
存储屏障(Store Barrier):

确保在屏障之前的所有存储操作完成后,才开始执行屏障之后的存储操作12。
全屏障(Full Barrier):

结合了加载屏障和存储屏障的功能,确保在屏障之前的所有加载和存储操作完成后,才开始执行屏障之后的加载和存储操作12。
代码示例
以下是一个简单的代码示例,展示了如何使用内存屏障:

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 引入了六种内存序,以便程序员在并发编程中根据需求选择合适的同步机制123。

六种内存序
memory_order_relaxed:

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

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

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

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

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

顺序一致性,确保所有线程看到的内存操作顺序一致12。这是最严格的内存序,适用于需要强一致性的场景12。
代码示例
以下是一个简单的代码示例,展示了如何使用不同的内存序:

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”​。

1. 参考

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

2. 数字IC设计流程

2.1. 三个主要流程:

  1. 前端设计: 用 Verilog 编写 RTL 设计文件(Register Transfer Level,RTL,寄存器传输层)。

  2. 综合(Synthesis):将 RTL 转换位实际电子元器件的连接。输出文件称为网表(netlist),也是纯文本文件。

  3. 后端布局布线(PR):将网表变为实际电路图,类似 PCB 图,称为版图(Layout)。版图是晶圆厂(Foundry)能识别的通用格式。

    版图的绘制:

    • 布局(Place):元器件如何摆放;
    • 布线(Route):元器件如何连接。

    数字版图的绘制大部分由计算机自动完成,称为自动布局布线(Automatic Place and Route,APR),很多时候也称为 PR 。

    流片(TapeOut):Foundry 根据版图制造芯片。

2.2. 验收和验证(SignOff):

对质量进行把控和最终验收。

前仿真:检查 RTL 设计文件;用测试平台(Testbench)文件对实际应用环境仿真。这是在版图成型之前,称为前仿。一旦发现错误,验证人员会反馈给设计者修改,如此反复迭代,直至交付到综合阶段的 RTL 文件都是合格的。

SignOff 检查:检查版图设计;对时序和功耗检查。如果检查不过,首先是数字后端工程师自己努力,重新绘制版图或微调元器件位置,若无法达到目的,再修改综合策略,重新综合。若仍无法达到目的,则反馈到前端 IC 设计,在 RTL 上调整。

后仿真:在 SignOff 合格后,需要将网表和延迟信息提交给验证人员,进行后仿。对成型版图的时序进行最后验证和验收。后仿就是版图成型之后的仿真。

2.3. 可测性设计(Design For Test,DFT)

一个附属工序,原理是在芯片中加入与主要功能无关的附属电路,用于在芯片的生产过程中使用 TestBench 快速判断芯片功能的完好行、内部结构的正常性。

如果芯片中包含 DFT 电路,则 TestBench 可以向芯片的某些引脚发送事先准备好的测试向量,在芯片的另一些引脚上采样芯片对测试向量的反应。将事先准备好的预期效果与实际采样到的结果进行对比,从而判断内部功能是否正常。

DFT 功能也常常被称为扫描(Scan),即用 TestBench 扫描芯片内部之意。DFT 的检查对象是生产差错造成的芯片损坏,它不能检查设计问题。设计问题应该交给验证工序。

DFT 对芯片来说不是必需的,一些低成本的芯片没有插入 DFT 电路,在设计流程中,直接从电路综合过渡到后端设计。

2.4. 前端设计 VS 后端设计

  • 前端设计:抽象电路,只描述功能,不是具体电路。
  • 后端设计:具体电路,不仅要知道哪些元器件,还要确认这些元器件的摆放位置。

综合是前端和后端的分界线,综合之前没有元器件而只有功能;综合之后,设计才有了具体化的元器件。

当然,前端工程师往往强调心中有电路,并非实际电路,而是心中有功能相似的概念性电路。这样可以认清设计架构,并避免潜在的设计隐患。

3. 模拟IC设计流程

  1. 绘制原理图(Schematic)

    手动绘制。

  2. 原理图仿真

    一般由原理图设计者亲自完成。原因是模拟电路的设计参数选择范围宽泛,需要验证的场景也复杂,仿真验收标准不十分明确。这就需要设计者搭建关键的应用环境和场景,对电路上产生的反应做出判断,随时调整电路结构和参数。

  3. 绘制版图(根据原理图)

    手动绘制。

    模拟版图工程师和模拟IC设计师一般是分开的两个职业。

    模拟IC设计师一般也都掌握一些绘制版图的方法,单熟练程度以及对一些物理问题的处理方面,需要求助专业的版图工程师。

    模拟版图工程师可以根据原理图及通用的绘图规则直接绘制。但是对诸如社评电路及高功率管之类有着特殊要求的设计,版图工程师需要与模拟IC设计师进行充分沟通,在模拟IC设计师的协助下进行绘制。

  4. 抽取寄生参数

  5. 版图后仿

  6. 设计规则检查(Design Rule Check,DRC)

  7. 版图和原理图的一致性检查(Layout Versus Schematics,LVS)

  8. 流片

模拟IC设计没有那么多自动化成分,需要手动绘制原理图、版图,电阻、电容等元器件需要人工确定。

模拟IC仿真,无法像数字IC那样通过仿真验证和FPGA验证两种方法来相互印证设计效果。模拟仿真速度慢、情况多,很难覆盖真是使用中可能遇到的全部情况,因此,模拟设计具有很高的不确定,芯片的实际效果与仿真结果存在明显差距是经常发生的。

但是模拟电路也有一些无法被数字电路取代的特性。

可以想象,数字电路只需要处理 0 和 1 两种信号,但是模拟电路的数值域是实数域,包括所有连续的整数、浮点数、正负数,数据有无穷个。因此,即使是很小的电路,元器件数量不多,也需要设计者和仿真者从数值域中挑选一部份值作为设计和仿真中用到的值,绝对不可能覆盖所有情况。

4. 芯片整体规划

版图布局规划(FloorPlan):将芯片整体规划,以及内部数字、模拟电路的位置、面积、形状等特征的规划。

FloorPlan示例
  • Pad: FloorPlan的周围是芯片引脚(Pad)。Pad实际指的是芯片引脚之外的一块金属。
  • IO: 包括Pad和内部逻辑在内的整个引脚设计。

一个完整的引脚设计如图:

20250116222905
  • 封装: 芯片外面的塑料壳子称为芯片的封装,大体分为两种:插针式(引脚如针)、表贴式(引脚扁平)。

5. IC设计工具

EDA 公司:电子设计自动化(Electronic Design Automation)公司,粗略来说,数字设计常用 Synopsys ,模拟设计常用 Cadence , Mentor 在一些细分领域有优势。

数字/模块 数字流程 常用软件 其他软件
数字 RTL 编写 Vim/Gvim 普通文本编辑器
数字 仿真 VCS (Synopsys) Incisive (Cadence)
数字 看波形 Verdi (Synopsys) DVE (Synopsys)
数字 设计检查 Spyglass (Synopsys) Simvision (Cadence)
数字 综合 DC (Synopsys) ModelSim (Mentor)
数字 时序仿真 PT (Synopsys) nLint (Cadence)
数字 自动布局布线 ICC2 (Synopsys) Genus (Cadence)
数字 设计版图形式验证 Formality (Synopsys) Tempus (Cadence)
数字 提取寄生参数 StarRC (Synopsys)
模拟 原理图/版图/仿真等 Virtuoso (Cadence) Calibre (Mentor)
模拟 寄生提取/DRC/LVS Calibre (Mentor)

仿真加速器: Palladium (Cadence) 、 ZeBu (Synopsys) 。

5.1. 数字IC设计工具

RTL 仿真工具:有 Synopsys 的 VCS ,Cadence 的 Incisive (也叫 irun )。这些工具可以胜任前仿、后仿、UVM 架构(Universal Verification Methodology,通用验证方法学)的仿真。Cadence 的 irun 与模拟设计工具 Virtuoso 工具中集成的 AMS 仿真工具相结合,支持数字模拟电路混合仿真。

波形查看工具:一般集成在仿真工具中。VCS 的波形软件叫 DVE , Incisive 的波形软件叫 SimVision 。但在这个领域,一家名为 Novas 的公司的软件,以其明快的界面、方便的功能、快捷的操作,异军突起,得到了广泛的认可,它就是 Verdi (以音乐家威尔第的名字命名,前身叫 Debussy ,以音乐家德彪西的名字命名)。 Verdi 现已被 Synopsys 收购。 Mentor 的仿真和看波形软件叫 ModelSim ,主要用于 FPGA 功能的仿真。

RTL 语法检查工具: Atrenta 公司的 Sypyglass ,可以检查语法、跨时钟域处理方案的可靠性,甚至可以在内部执行综合、功耗评估和简单的布局布线,使它能全方位地给出设计建议。现已被 Synopsys 收购。 Cadence 的对应检查工具是 nLint 。

综合工具:即将 RTL 转化为实际电路的工具,常用的的有 Synopsys 的 Design Compiler (DC) ,该工具内部还有一些 Synopsys 开发的库,能够帮助设计者减小面积,提高性能,例如加法器、乘法器等,这些设计好的子模块在 DC 中被称为 DesignWare (DW) 。可以是让工具自动从 RTL 中识别出可用 DW 替换的代码,也可以是设计者手动例化 DW 模块。 Cadence 相应的工具叫 Genus (原名叫 RTL Compiler )。

版图自动布局布线软件: Synopsys 有 ICC2 (旧版为 ICC ), Cadence 有 Innovus (原名 Encounter ),两个软件都被广泛使用。由于两个软件的操作命令不同,后端工程师往往只掌握其中一种。 Synopsys 为了增强客户的黏性,开发了一个银河( MilkyWay )流程,从前端到后端,通过专用的二进制文件 (db) 传输,占用空间小,处理效率高,但也有许多公司使用 DC 综合,再将网表导入 Innovus 进行布局布线。

SignOff 工具:即对整个设计的时序、功耗评估。 Synopsys 的 Prime Time (PT) , Cadence 的 Tempus 。目前, PT 已经称为业内 SignOff 的标准。实际上,时序分析在 DC 中也能做,但两者在分析方法、细节考虑全面度、分析速度等方面存在差异。在综合时使用 DC 检查,而在 SignOff 时,使用 PT 检查。

形式验证工具:也称为逻辑等效行检查( Logic Equivalece Check, LEC ),将 RTL 和网表进行一一对照。因为从 RTL 到综合网表,以及从综合网表到后端网表的过程,可能意外地改变原有功能和设计意图,所以需要进行检查。 Synopsys 的工具时 Formality , Candence 的工具是 Conformal 。

寄生参数的提取工具:进入设计版图阶段,可以确定走线的延迟。该值受信号负载、线路长短、粗细、周围线路等多重影响,需要用模型和查表进行计算,才能得到确切的值。该过程称为寄生参数的提取。一般使用 Synopsys 的 StarRC 。提取出来的信息可用于 PT 进行 SignOff 。

上述工具,最主要的控制语法是 TCL 语言, EDA 工具大多以该语言为基础,扩展出各种专用命令。

除了以上数字设计工具,还有一些更加细分的工具类型,比如仿真加速器,有 Cadence 的 Palladium 、 Synopsys 的 ZeBu 。

5.2. 模拟IC设计工具

主要是 Cadence 的 Virtuoso 。与数字设计中繁多的工具不同, Virtuoso 能满足大部分设计需求,例如绘制并仿真原理图、绘制并仿真版图、数模混合仿真等。实际上, Virtuoso 更像集成开发环境( Integrated Development Environment, IDE ),它包含很多独立的设计工具,如仿真工具 Spectre 等。

SignOff 工具:Mentor 的 Calibre 可以用来提取寄生参数、进行 DRC 和 LVS 检查。

5.3. 职位、分工与工具

大职位 细分职位 功能 常用工具
数字 数字 IC 设计 设计芯片中的数字电路 Vim, VCS, Verdi, Spyglass, DC 等
数字 数字 IC 验证 验证芯片中的数字电路功能 VCS, Verdi 等
数字 数字 IC 后端 将抽象电路转换为版图 ICC2, Innovus, Calibre, PT 等
数字 其他职位, 如 SignOff, DFT 等 负责在数字电路中插入 DFT、对最终的版图进行时序、面积、功耗的检查等 DC, PT, Formality 等
模拟 模拟 IC 设计 设计芯片中的模拟电路 Virtuoso 等
模拟 模拟版图 将电路原理图做成版图 Virtuoso, Calibre 等
软件 嵌入式软件工程师 (1) 参与芯片开发/验证 (2) 参与 SDK (3) 参与芯片应用方案 Keil, SourceInsight, Visual Studio 等
软件方案 测试工程师 (1) 对芯片设计性能的测试 (2) 芯片量产测试 兼用软件和硬件工具
硬方案 应用工程师 做电路板,为芯片找到合适的应用场景 Pads, Altium Designer, Cadence 等
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
管理
├── 研发
│ ├── 数字IC设计
│ ├── 数字IC验证
│ └── 数字后端
│ └── 模拟IC设计
│ └── 模拟版图
├── 测试
│ ├── 研发类测试
│ │ ├── 方案测试
│ │ └── 量产测试
│ └── 客户测试
├── 销售
└── 方案
├── 硬件方案
└── 软件方案

5.4. EDA 与 Foundry 的关系

普通软件公司无力开发 EDA 软件。因为 EDA 厂商需要和 Foundry 厂商紧密合作,才能获得有关的生产细节数据,从而帮助用户进行更加准确的仿真、寄生参数的提取、规则的检查。不同的 EDA 工具抽取的寄生参数可能不同,原因可能是不同的工具获得的工艺数据不同。

国内 EDA 厂商要想打破垄断,不仅要靠软件技术,还要与各大 Foundry 厂达成战略合作,共享工艺数据,才能做出有实用价值的 EDA 工具。

6. 概念

6.1. 寄存器:

register,这是对功能强调的名称,其物理实体很多,比如触发器 (Flip-Flop, FF)和锁存器(Latch)等等。在数字设计中,主要提倡触发器。

寄存器是受控于时钟沿的元器件, Foundry 会在标准单元库中提供多种寄存器。

触发器是由边沿信号而非电平信号导致数据存储的。触发源一般是时钟信号。

锁存器较少使用,它一般以非时钟信号为存储控制,靠电平触发。一般不连接时钟,只是连接一根普通的信号线。这种锁存器一般很少被纳入时钟计算中,因为它既不属于组合逻辑,又无法像触发器一样作为时序路径的起终点,所以时序上无法通过工具保证,只能通过工程师来保证。

本书说的寄存器,基本可以替换为触发器。在数字芯片 EDA 工具链中,两个概念也是混用的,一个 reg 既可以指寄存器,也可以指触发器。另外,所谓时序逻辑门电路,也基本等同于触发器。

6.2. 设计(Design)的边界

20250116211433
  • Pad:芯片(封装)之外的引脚。

  • 元器件:指芯片内的组合逻辑门电路及时序逻辑门电路。这些标准的元器件也被称为标准单元(Standard Cell)。元器件也称为逻辑门。

  • 标准单元库/工艺库:元器件的集合。

  • I/O: 如前“FloorPlan”对IO的解释,I/O是包括Pad和内部逻辑在内的整个引脚设计。在标准单元库中是一种标准单元,但是它比较特殊,可能与其他标准单元不放在同一个标准单元库中。驱动电平也可能不同。

  • 通用单元库: 在综合时,有一个中间步骤是将 RTL 抽象逻辑先映射为通用单元(Generic Boolean),然后映射到标准单元。通用单元与工艺无关,也不包含物理特性,只有功能属性,与 RTL 描述类似。而标准单元既有功能属性,又有延迟、电压等工艺属性,与工艺和 Foundry 相关。

  • lef文件: Library Exchange Format. 描述特定流片工艺下元器件的物理属性。

  • lib文件: Library. 描述特定流片工艺下元器件的功能属性。lib文件常常要被编译为二进制的db文件才能在某些EDA工具上使用。 lef 和 lib 文件都是技术库。

    数字设计只能在 Foundry 提供的已有的选项中挑选元器件。

  • def文件: Design Exchange Format. 设计除了 RTL 外,也可以带有物理信息,保存为 def 文件,基础内容是设计的形状和尺寸,扩展内容包括端口的分布位置、RAM和ROM等硬核的放置位置、内部元器件的放置和布线位置等。 RTL 和 def 统称为设计。

自己写的模块可以称为设备(Device)或模块(Module)。

用设备一词主要强调它具备相对独立的功能,在介绍SoC架构等上层逻辑时较为常用,设备也经常被替换为单元(Unit)一词,它指人为设计的单元,而非标准单元。一个设计可以包含很多个设备。

用模块一词,主要强调它在设计文件结构上的独立性,在介绍通用设计和底层逻辑时较为常用。

让 VMWare 的 Linux 虚拟机共享 Windows 主机的 VPN

方案 2:NAT 模式 + Windows 网络共享
另一种方式是保持 VMware 使用 NAT 模式,并通过 Windows 主机的网络共享功能,将 VPN 流量共享给虚拟机。

步骤:
在 Windows 上启用网络共享:

确保 Clash 在 Windows 主机上运行,并已连接到 VPN。
打开 控制面板 > 网络和共享中心 > 更改适配器设置。
右键点击你的 VPN 连接(通常是一个以 Ethernet 开头的适配器),选择 属性。
在 共享 标签页,勾选 允许其他网络用户通过此计算机的 Internet 连接连接。
选择虚拟机使用的网络适配器(例如 VMware 的 VMnet8 适配器),然后点击确定。
配置虚拟机网络为 NAT:

在 VMware 中,确保虚拟机的网络适配器设置为 NAT(这通常是默认设置)。
在 NAT 模式下,虚拟机会通过 Windows 主机访问网络,但流量会被路由到共享的网络适配器。
验证虚拟机是否可以通过 VPN 访问网络:

启动虚拟机,测试网络连接。
如果虚拟机连接成功并通过 VPN 访问互联网,那么配置就完成了。

线段树(Segment Tree)是一种将数组区间存储为树的数据结构 ^1

时间复杂度

查询连续子数组 或 查询范围内的最小元素:O(logn)

最简单形式的线段树

高阶版本的线段树

TODO

参考

OI wiki

参考源码

/usr/include/c++/11/bits/shared_ptr_base.h
/usr/include/c++/11/bits/shared_ptr.h

博客:https://zhiqiang.org/coding/std-shared-ptr.html

std::shared_ptr 的性质

  1. 复制构造、析构是线程安全的。

标准库使用原子操作实现无锁的线程安全性。

  1. 写操作(例如 reset 、 赋值 operator= )是线程不安全的。

写操作和复制构造、析构的主要区别是:

  • 复制构造、析构函数中,单个线程只处理一个对象,复制构造函数将其他对象复制过来之后,不会改动其他对象的资源(引用计数、所管理的内存)。
  • 但是写操作可能多个线程都在处理该 shared_ptr 。例如多个线程都对同一个 shared_ptr 进行赋值:
1
2
3
shared_ptr<int> sp1 = make_shared<int>(1);
sp1 = sp2; // 线程 1
sp1 = sp3; // 线程 2

对比源码分析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
     __shared_count&
operator=(const __shared_count& __r) noexcept
{
_Sp_counted_base<_Lp>* __tmp = __r._M_pi;
if (__tmp != _M_pi)
{
if (__tmp != nullptr)
__tmp->_M_add_ref_copy();
if (_M_pi != nullptr)
_M_pi->_M_release(); // 注意:线程 1 和线程 2 持有相同的 _M_pi!
_M_pi = __tmp; // 注意:线程 1 和线程 2 持有相同的 _M_pi!
}
return *this;
}

以上代码中两个“注意”可能同时在发生,例如:
线程 1 release 的时候,线程 2 在给 _M_pi 赋值;
或者两个线程同时在 release 或同时在给 _M_pi 赋值。

要点

  1. 管理的内存和引用计数都应该动态分配到堆( heap )上,这样多个 shared_ptr 对象才能更新同一份数据。
  2. 需要同时维护强引用计数和弱引用计数。
  3. 引用计数本身应该是一个控制块类,使用 delete this 来自动删除(析构)引用计数。

永远不要手动 delete use_count,因为其他线程可能此时正在使用该资源,例如解引用 *use_count

SharedPtr.hppview 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
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
template<_Lock_policy _Lp = __default_lock_policy>
class _Sp_counted_base
: public _Mutex_base<_Lp>
{
void
_M_add_ref_copy()
{ __gnu_cxx::__atomic_add_dispatch(&_M_use_count, 1); }

void
_M_release() noexcept
{
// Be race-detector-friendly. For more info see bits/c++config.
_GLIBCXX_SYNCHRONIZATION_HAPPENS_BEFORE(&_M_use_count);
if (__gnu_cxx::__exchange_and_add_dispatch(&_M_use_count, -1) == 1)
{
_GLIBCXX_SYNCHRONIZATION_HAPPENS_AFTER(&_M_use_count);
_M_dispose();
// There must be a memory barrier between dispose() and destroy()
// to ensure that the effects of dispose() are observed in the
// thread that runs destroy().
// See http://gcc.gnu.org/ml/libstdc++/2005-11/msg00136.html
if (_Mutex_base<_Lp>::_S_need_barriers)
{
__atomic_thread_fence (__ATOMIC_ACQ_REL);
}

// Be race-detector-friendly. For more info see bits/c++config.
_GLIBCXX_SYNCHRONIZATION_HAPPENS_BEFORE(&_M_weak_count);
if (__gnu_cxx::__exchange_and_add_dispatch(&_M_weak_count,
-1) == 1)
{
_GLIBCXX_SYNCHRONIZATION_HAPPENS_AFTER(&_M_weak_count);
_M_destroy();
}
}
}

private:
_Sp_counted_base(_Sp_counted_base const&) = delete;
_Sp_counted_base& operator=(_Sp_counted_base const&) = delete;

// using _Atomic_word = int;
_Atomic_word _M_use_count; // #shared
_Atomic_word _M_weak_count; // #weak + (#shared != 0)
};

template<_Lock_policy _Lp>
class __shared_count
{
_Sp_counted_base<_Lp>* _M_pi; // 引用计数的指针

__shared_count(const __shared_count& __r) noexcept
: _M_pi(__r._M_pi)
{
if (_M_pi != nullptr)
_M_pi->_M_add_ref_copy(); // TODO: 有没有可能此时的 _M_pi 所引对象已经被释放了呢
}

~__shared_count() noexcept
{
if (_M_pi != nullptr)
_M_pi->_M_release(); // 没有直接 delete _M_pi,
// 而是使用 _M_pi 的成员函数来释放它。
}

__shared_count&
operator=(const __shared_count& __r) noexcept
{
_Sp_counted_base<_Lp>* __tmp = __r._M_pi;
if (__tmp != _M_pi)
{
if (__tmp != nullptr)
__tmp->_M_add_ref_copy();
if (_M_pi != nullptr)
_M_pi->_M_release(); // 没有直接 delete _M_pi,
// 而是使用 _M_pi 的成员函数来释放它。
_M_pi = __tmp;
}
return *this;
}
};

参考

https://gcc.gnu.org/onlinedocs/libstdc++/manual/ext_concurrency.html

在测试leetcode的 815.公交路线 一题时,发现一个现象,当我使用vector<vector<bool>>作为图的邻接矩阵的类型时,运行速度比使用vector<vector<int>>慢了近1倍。使用bool运行速度为600ms左右,使用int时运行速度为300ms左右。

以下是ChatGPT的回答:

在C++中,bool类型并不一定比int类型慢,但在某些情况下,它们的性能表现可能有所不同。以下是一些与性能差异相关的考虑:

  1. 内存大小
    bool类型在标准中只需要表示 true(非0)和 false(0),通常占用1个字节(8位),而 int 通常占用4个字节(32位)。因此,bool类型可以节省内存,在某些情况下会带来缓存效率的提升,尤其是在处理大量布尔值时(例如布尔数组)。
  2. 对齐和内存访问
    在某些架构上,处理单字节的bool类型时,可能会有额外的开销,因为处理器通常优化对齐访问。如果内存没有正确对齐,处理器可能需要额外的操作来处理非对齐的字节,导致性能下降。相比之下,int的对齐通常与系统的内存总线对齐更好(如32位或64位系统上),因此读取和写入可能更高效。
  3. 指令集和操作
    现代处理器在处理布尔值时通常会将其扩展为完整的寄存器大小(例如32位或64位)。因此,虽然bool类型的存储可能更小,但在实际操作中,处理器可能将它提升为int或更大的类型来执行逻辑操作。处理布尔值可能涉及隐式类型转换,这在某些极端情况下可能带来开销。
  4. 寄存器使用
    int 类型可以直接映射到处理器寄存器中进行操作,而 bool 类型的操作有时可能会涉及额外的逻辑运算(如掩码操作)以确保布尔值的正确性。
  5. 编译器优化
    现代编译器在优化时会考虑到bool与int的差异,并尽量优化代码,因此在大多数应用场景下,你不会感受到显著的性能差异。但在一些特定的场合(如大规模数组、频繁内存访问等),bool类型的使用确实可能带来性能影响,尤其是在处理器需要对齐或执行额外的操作时。
    总结
    bool类型通常并不会比int慢,实际的性能差异取决于平台、内存对齐、编译器优化以及应用场景。在大多数情况下,差异是微不足道的,但在对性能非常敏感的程序中,这种差异可能会显现。

启用调试模式

  1. 命令行启用调试:
1
2
#使用 -x 和 -v 选项运行 main.sh 脚本。
bash -xv main.sh

注意:调试输出是通过 stderr ,在试图重定向时要注意。

  1. 在脚本中启用调试:
1
set -xv
Short notation Long notation Result
set -f set -o noglob Disable file name generation using metacharacters (globbing).
set -v set -o verbose Prints shell input lines as they are read.
set -x set -o xtrace Print command traces before executing command.

参考链接

其他选项:

  • set +e:忽略错误继续运行(bash)。
  • set -e:遇错停止执行(bash)。
  1. 环境变量 PS4
1
PS4='+ ${LINENO}: '

可以搭配使用 PS4 环境变量,以在调试输出中打印命令所在的文件行号。

PS4 是一个 Bash 环境变量,用于定义调试模式下的提示符格式。
当你启用调试模式(通过 set -x)时,Bash 会在每一行执行之前打印调试信息,而 PS4 决定了这些调试信息的格式。
$LINENO表示打印行号。

trap DEBUG

设置DEBUG陷阱,该陷阱在每一条“简单语句”执行完毕之后会被调用一次。

1
2
3
4
5
6
7
8
9
10
11
set -o functrace
track_var="my_var"
old_value=""
check_var_change() {
new_value="${!track_var}"
if [[ "$new_value" != "$old_value" ]]; then
echo "$track_var changed to \"$new_value\" at $BASH_LINENO"
old_value="$new_value"
fi
}
trap check_var_change DEBUG

说明:监控my_var变量的变化,如果变化,则打印新值。

注意:实际测试中,我们发现trap监控变量变化似乎会延迟,而set -x打印的信息是可靠的。

特殊变量

参考:https://www.gnu.org/software/bash/manual/html_node/Bash-Variables.html

  • BASH_LINENO: 这是一个数组变量,‌用于表示当前执行的脚本或函数中每一行的行号。‌它特别适用于调试,‌因为它可以追踪到函数调用栈中每一层的行号。‌通过访问BASH_LINENO数组的不同索引,‌可以获取到不同函数调用层级对应的行号信息‌。
  • LINENO: 获取当前脚本行号‌,‌而不涉及函数调用栈的追踪‌。
  • FUNCNAME: 函数名
  • BASH_SOURCE