From 02d77731e35fc5eb18a75a78a69c01a6508cf357 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Thu, 18 Jun 2026 21:05:35 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=B2=97=E4=BD=93?= =?UTF-8?q?=E6=9C=AA=E6=B8=B2=E6=9F=93,=E4=B8=AD=E6=96=87=E6=A0=87?= =?UTF-8?q?=E7=82=B9=E7=A7=BB=E5=87=BA=20**=20=E9=81=BF=E5=85=8D=E7=B2=97?= =?UTF-8?q?=E4=BD=93=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 36 篇文章把 `**加粗**` 紧贴的中文标点(。!?()等)移到 ** 外侧,解决 markdown 解析器在 CJK 文本中粗体不渲染;新增 scripts/check_bold_rendering.ts 自动检测此类问题。 --- .../01-compilation-and-linking-overview.md | 8 +- .../compilation/04-dynamic-libraries-1.md | 2 +- .../compilation/05-dynamic-library-design.md | 2 +- .../07-symbol-missing-and-runtime-loading.md | 2 +- .../compilation/08-library-search-logic.md | 4 +- .../02-c-language-crash-course.md | 2 +- ...3B-cpp98-function-overload-default-args.md | 6 +- .../03C-cpp98-classes-and-objects.md | 2 +- .../03D-cpp98-inheritance-polymorphism.md | 4 +- .../03E-cpp98-operator-overloading.md | 2 +- .../03F-cpp98-casts-memory-exceptions.md | 2 +- .../02-cache-and-memory-hierarchy.md | 63 +++++++---- .../ch03/03-overloading-default.md | 2 +- .../ch04/02-pointer-arithmetic.md | 2 +- .../ch04/04-smart-ptr-preview.md | 2 +- .../01-type-safety-and-number-concept.md | 2 +- .../04-stl-and-generic-programming.md | 2 +- .../01-from-loops-to-iterators.md | 2 +- ...02-stl-algorithms-and-iterator-pitfalls.md | 2 +- .../03-ranges-views-and-composition.md | 2 +- .../03-move-ops-stdmove-and-elision.md | 4 +- .../04-compile-time-practice.md | 2 +- .../01-udl-basics.md | 2 +- .../vol4-advanced/01-coroutine-basics.md | 4 +- .../03-cpu-cache-and-os-threads.md | 6 +- .../01-std-thread.md | 2 +- .../03-condition-variable.md | 4 +- .../05-latch-barrier-semaphore.md | 4 +- .../01-debugging-concurrency.md | 6 +- .../02-distributed-primitives.md | 6 +- .../02-file-copier-core-implementation.md | 10 +- .../msvc-debugging-internals.md | 2 +- .../embedded/01-led/04-hal-gpio-clock.md | 8 +- .../embedded/03-circular-buffer.md | 2 +- .../04-crtp-vs-runtime-polymorphism.md | 2 +- scripts/check_bold_rendering.ts | 103 ++++++++++++++++++ 36 files changed, 203 insertions(+), 79 deletions(-) create mode 100644 scripts/check_bold_rendering.ts diff --git a/documents/compilation/01-compilation-and-linking-overview.md b/documents/compilation/01-compilation-and-linking-overview.md index cb6df0368..ed5b13f73 100644 --- a/documents/compilation/01-compilation-and-linking-overview.md +++ b/documents/compilation/01-compilation-and-linking-overview.md @@ -351,7 +351,7 @@ extern_var |0000000000000000| D | OBJECT|0000000000000004 ​ 您可以看到,demo_extern解决的是extern_var的定义,但是`extern_func`的定义没找到,咱们又只给了这两个文件,自然连接器不知道上哪找到你的`extern_func`,自然也就会爆这个错误。 -​ 我们现在知晓了链接器的重要功能——解决最小可执行文件(为什么是最小的呢?我们之后继续讨论)的符号未定义问题。任何那些**你没提供对应信息告知定义的具体内容(那些用了的函数的源代码漏写)**的链接都会失败!最后当链接器搜寻一圈后,只要存在未定义符号(也就是nm或者dumpbin中Class是U的符号),链接器就会拉起报错:告诉你所有那些没有定义的符号。**这个时候你的解决方案非常简单——找到这些符号的可重定位文件(一般构建系统的源代码文件名和可重定位文件名相同,只有后缀不同),然后链接的时候提供!**这是所有无动态库的编译场景下解决`undefined reference`的**唯一办法**。 +​ 我们现在知晓了链接器的重要功能——解决最小可执行文件(为什么是最小的呢?我们之后继续讨论)的符号未定义问题。任何那些**你没提供对应信息告知定义的具体内容**(那些用了的函数的源代码漏写)的链接都会失败!最后当链接器搜寻一圈后,只要存在未定义符号(也就是nm或者dumpbin中Class是U的符号),链接器就会拉起报错:告诉你所有那些没有定义的符号。**这个时候你的解决方案非常简单——找到这些符号的可重定位文件(一般构建系统的源代码文件名和可重定位文件名相同,只有后缀不同),然后链接的时候提供**!这是所有无动态库的编译场景下解决`undefined reference`的**唯一办法**。 ​ 现在我们看了nm的输出,就可以回答整个问题了: @@ -409,7 +409,7 @@ collect2: error: ld returned 1 exit status ``` -​ 您注意到了,还是一样,因为编译器相信**链接器可以正确的处理任何符号的关系**(他只能一分一分的编译文件!他管不了全局其他的源文件!**整个结果单元(包含可执行文件,动态库和静态库)的符号裁决由链接器决定!**这是笔者要再强调一次的!) +​ 您注意到了,还是一样,因为编译器相信**链接器可以正确的处理任何符号的关系**(他只能一分一分的编译文件!他管不了全局其他的源文件!**整个结果单元(包含可执行文件,动态库和静态库)的符号裁决由链接器决定**!这是笔者要再强调一次的!) ​ 所以,链接的时候,链接器发现两个文件中居然存在一模一样的符号定义。自然,定义是不一样,就像您即说A是1,又说A是2,唯一性被打破,贸然决定只会让程序变得不可控。所以,链接器自然一巴掌闪回来,不予通过!至少在今天的GNU工具链的默认行为下,您这样做智慧得到一个`multiple definition`。 @@ -417,7 +417,7 @@ collect2: error: ld returned 1 exit status ​ 我都这样问了,怎么可能就这样是不是?不知道您看到我反复强调这句话的时候,您有没有感受: -- 为什么是:**C/C++编译型语言允许你在编译的时候只出现声明而不用出现实现!**为什么不要求立马知道呢?好麻烦啊。 +- 为什么是:**C/C++编译型语言允许你在编译的时候只出现声明而不用出现实现**!为什么不要求立马知道呢?好麻烦啊。 ​ 您冷静想一下,举个例子。我让您去邮局送一个邮件,您显然不会打断我:"闭嘴伙计,您先把邮局扛过来我看到邮件了我在帮你送",比起来,您更加会在脑子里绘制出假象的邮局,"嗯很,我需要去一个叫邮局的地方帮忙送一个邮件"。您自然回去其他地方寻找邮件。这就是一样的道理。我们空余出来悬而未决的符号,我们自己管理和承诺他们都会出现在对应的地方——**这是您的责任而不是编译器的责任**。那好,我们现在就可以继续我们的疑问: @@ -450,7 +450,7 @@ collect2: error: ld returned 1 exit status > 快速的说说细节: > > - 在 **UNIX** 系统上,用于生成静态库的命令通常是 **`ar`**,生成出的库文件通常带有 **`.a`** 扩展名。这些库文件通常还以 **"lib"** 作为前缀,并在传递给链接器时使用 **`"-l"`** 选项,后接库的名称(不带前缀和扩展名)。例如,**`"-lfred"`** 就会选择 **`libfred.a`** 文件。(历史上,静态库还需要一个名为 **`ranlib`** 的程序来在库的开头构建一个符号索引。如今,**`ar`** 工具通常会自行完成这项工作。) -> - 在 **Windows** 系统上,静态库具有 **`.LIB`** 扩展名,并由 **`LIB`** 工具生成。但这可能会引起混淆,因为**"导入库"(import library)**也使用相同的扩展名,导入库仅包含一个 DLL 中可用内容的列表 +> - 在 **Windows** 系统上,静态库具有 **`.LIB`** 扩展名,并由 **`LIB`** 工具生成。但这可能会引起混淆,因为"**导入库**"(import library)也使用相同的扩展名,导入库仅包含一个 DLL 中可用内容的列表 ​ 对于链接阶段,当我们提供给链接器一个静态库,整个时候,我们的链接器会持有一个尚未裁决的符号表格,沉浸到静态库中,把这些符号一个一个找出来(举个例子,A符号丢失,他在Obj1.o中,这个时候我们就会把Obj1.o全部链接进来),直到我们解决了所有的符号未定义的问题。 diff --git a/documents/compilation/04-dynamic-libraries-1.md b/documents/compilation/04-dynamic-libraries-1.md index 5105d5d27..c91510d9c 100644 --- a/documents/compilation/04-dynamic-libraries-1.md +++ b/documents/compilation/04-dynamic-libraries-1.md @@ -40,7 +40,7 @@ description: '' #### 什么是`-fPIC`? -`-fPIC` 的含义是**`Position-Independent Code`**(生成位置无关代码),换而言之, 编译出来的机器指令 **不依赖固定的加载地址**,在运行时可以被加载到任意内存位置而无需修改代码本身。这很符合我们对动态库功能的感知。我们最后,总是要将一个动态库的符号导出出来给其他第三方的应用程序或者是其他的库进行使用,因此,显然我们不能安排一个绝对的映射地址给这些动态库符号,而是在复用的时候,动态的给予一个偏移地址映射到使用者的进程地址上,这样我们才能实现符号的复用。按照步骤的说: +`-fPIC` 的含义是`Position-Independent Code`(生成位置无关代码),换而言之, 编译出来的机器指令 **不依赖固定的加载地址**,在运行时可以被加载到任意内存位置而无需修改代码本身。这很符合我们对动态库功能的感知。我们最后,总是要将一个动态库的符号导出出来给其他第三方的应用程序或者是其他的库进行使用,因此,显然我们不能安排一个绝对的映射地址给这些动态库符号,而是在复用的时候,动态的给予一个偏移地址映射到使用者的进程地址上,这样我们才能实现符号的复用。按照步骤的说: - -fPIC将会对符号采用 **相对地址** 而不是绝对地址进行映射 - 全局变量通过 **GOT(Global Offset Table)** 间接访问 diff --git a/documents/compilation/05-dynamic-library-design.md b/documents/compilation/05-dynamic-library-design.md index c3e19312e..c0bd07226 100644 --- a/documents/compilation/05-dynamic-library-design.md +++ b/documents/compilation/05-dynamic-library-design.md @@ -116,7 +116,7 @@ extern "C"{ #### 提供完整ABI声明的头文件 -这里**"提供完整ABI声明的头文件"** 指的是一个头文件(`.h`),它包含了所有必要的声明,使得编译器能够**完全理解**一个库或模块的接口,从而能够: +这里"**提供完整ABI声明的头文件**" 指的是一个头文件(`.h`),它包含了所有必要的声明,使得编译器能够**完全理解**一个库或模块的接口,从而能够: 1. **正确编译** 调用该库的代码。 2. **正确生成** 与库中函数交互的机器码。 diff --git a/documents/compilation/07-symbol-missing-and-runtime-loading.md b/documents/compilation/07-symbol-missing-and-runtime-loading.md index 30cae5d25..aa343fd91 100644 --- a/documents/compilation/07-symbol-missing-and-runtime-loading.md +++ b/documents/compilation/07-symbol-missing-and-runtime-loading.md @@ -25,7 +25,7 @@ description: '' ## 运行时动态加载是什么? -官方的说,运行时动态链接(dynamic loading)指程序**在运行时**按需加载一个共享库(shared object / dynamic library / DLL),并查找需要的符号(函数、变量)后调用。笔者认为,**这是插件系统的一个重要的实现机制。**因为现在: +官方的说,运行时动态链接(dynamic loading)指程序**在运行时**按需加载一个共享库(shared object / dynamic library / DLL),并查找需要的符号(函数、变量)后调用。笔者认为,**这是插件系统的一个重要的实现机制**。因为现在: - 我们可以动态的加载进入插件,在运行时根据配置加载不同功能模块(国际化、渲染后端、驱动等)。 - 上述特性允许我们可以按照需求加载我们需要的依赖,节约一部分空间 diff --git a/documents/compilation/08-library-search-logic.md b/documents/compilation/08-library-search-logic.md index 166ccb6ee..80658a61a 100644 --- a/documents/compilation/08-library-search-logic.md +++ b/documents/compilation/08-library-search-logic.md @@ -113,9 +113,9 @@ Windows 的可执行/装载器与 API(`LoadLibrary` / `LoadLibraryEx` / 自动 一般而言,Windows的方式有两种:隐式(导入表)与显式(运行时 API) -**隐式加载(implicit)**指的是可执行文件的导入表(Import Table)在进程启动或模块加载时由系统装载器解析,系统会为每个 `DLL` 尝试找到并映射到进程地址空间。开发者在链接阶段指定依赖(例如 `kernel32.dll`、`mydll.dll`),加载由系统在进程启动时期自动完成。 +**隐式加载**(implicit)指的是可执行文件的导入表(Import Table)在进程启动或模块加载时由系统装载器解析,系统会为每个 `DLL` 尝试找到并映射到进程地址空间。开发者在链接阶段指定依赖(例如 `kernel32.dll`、`mydll.dll`),加载由系统在进程启动时期自动完成。 -**显式加载(explicit)**指的是代码在运行时使用 `LoadLibrary` / `LoadLibraryEx` 等 API 手工加载 DLL,然后用 `GetProcAddress` 取得函数指针。显式加载能通过参数控制搜索行为(例如使用 `LOAD_LIBRARY_SEARCH_USER_DIRS` 等标志)。 +**显式加载**(explicit)指的是代码在运行时使用 `LoadLibrary` / `LoadLibraryEx` 等 API 手工加载 DLL,然后用 `GetProcAddress` 取得函数指针。显式加载能通过参数控制搜索行为(例如使用 `LOAD_LIBRARY_SEARCH_USER_DIRS` 等标志)。 #### 默认搜索顺序(概念化顺序) diff --git a/documents/vol1-fundamentals/02-c-language-crash-course.md b/documents/vol1-fundamentals/02-c-language-crash-course.md index 4cbd36edf..799d8de16 100644 --- a/documents/vol1-fundamentals/02-c-language-crash-course.md +++ b/documents/vol1-fundamentals/02-c-language-crash-course.md @@ -59,7 +59,7 @@ uint32_t u32 = 4294967295U;// 精确32位无符号整数 ``` -那问题来了,什么时候用多大的呢?嗯,这个事情可以不必要如此的死板,不过有一个事情必须注意——**你的数据范围得够用**。那问题来了:**N 位的数据到底能存多大?**对于 **无符号整数**,N 位一共可以表示 **2ⁿ 个数**,取值范围是 **0 ~ 2ⁿ − 1**。那如果是 **有符号整数** 呢?最高位要拿来当符号位了,采用补码表示的话,范围就是 **−2ⁿ⁻¹ ~ 2ⁿ⁻¹ − 1**。大家都是嵌入式程序员,这点二进制应该都能算得过来。 +那问题来了,什么时候用多大的呢?嗯,这个事情可以不必要如此的死板,不过有一个事情必须注意——**你的数据范围得够用**。那问题来了:**N 位的数据到底能存多大**?对于 **无符号整数**,N 位一共可以表示 **2ⁿ 个数**,取值范围是 **0 ~ 2ⁿ − 1**。那如果是 **有符号整数** 呢?最高位要拿来当符号位了,采用补码表示的话,范围就是 **−2ⁿ⁻¹ ~ 2ⁿ⁻¹ − 1**。大家都是嵌入式程序员,这点二进制应该都能算得过来。 ### 1.2 浮点类型 diff --git a/documents/vol1-fundamentals/03B-cpp98-function-overload-default-args.md b/documents/vol1-fundamentals/03B-cpp98-function-overload-default-args.md index 7a10f7a26..6f4dbb10e 100644 --- a/documents/vol1-fundamentals/03B-cpp98-function-overload-default-args.md +++ b/documents/vol1-fundamentals/03B-cpp98-function-overload-default-args.md @@ -80,7 +80,7 @@ void init_uart(int baudrate, int databits, int stopbits) { 表面上看,调用一个重载函数只是"写个名字、传个参数"这么简单的事。但实际上,编译器在背后执行了一套非常严格的决策流程——这套流程被称为**重载解析 (Overload Resolution)**。 -每当你调用一个存在多个重载版本的函数时,编译器都会先收集所有名字匹配、参数数量一致的候选函数,然后逐一评估,试图回答一个问题:**哪一个是"最合适"的?**需要强调的是,编译器并不会理解你的业务语义,它只会机械地按照语言规则打分,最终选出匹配度最高的版本。 +每当你调用一个存在多个重载版本的函数时,编译器都会先收集所有名字匹配、参数数量一致的候选函数,然后逐一评估,试图回答一个问题:**哪一个是"最合适"的**?需要强调的是,编译器并不会理解你的业务语义,它只会机械地按照语言规则打分,最终选出匹配度最高的版本。 在涉及模板和可变参数之前,编译器的判断标准可以理解为一条由强到弱的"匹配优先级链"。首先是**精确匹配**——实参与形参类型完全一致;如果不存在精确匹配,才会考虑**类型提升**,比如 `char` 提升为 `int`;再往后是**标准类型转换**,例如 `int` 转换为 `double`;最后才轮到用户自定义的类型转换。这个顺序非常关键,因为只要某一层级已经能找到可行的匹配,后面的规则就完全不会被考虑,哪怕它们在你看来更"合理"。 @@ -181,7 +181,7 @@ configure_uart(115200, 8, 2, 'E'); // 全部自定义 默认参数的语法看似简单,但它的规则其实非常严格,踩坑的人不在少数。 -**规则一:默认参数必须从右向左连续出现。**编译器在处理函数调用时,只能通过"省略尾部参数"的方式来判断哪些值使用默认值。换句话说,你不能跳过中间的参数——如果要给第三个参数传值,前面的所有参数都必须显式给出。这也就意味着,如果你试图在某个有默认值的参数后面再放一个没有默认值的参数,编译器会直接拒绝。 +**规则一:默认参数必须从右向左连续出现**。编译器在处理函数调用时,只能通过"省略尾部参数"的方式来判断哪些值使用默认值。换句话说,你不能跳过中间的参数——如果要给第三个参数传值,前面的所有参数都必须显式给出。这也就意味着,如果你试图在某个有默认值的参数后面再放一个没有默认值的参数,编译器会直接拒绝。 ```cpp // 正确:默认参数从右向左连续 @@ -193,7 +193,7 @@ void init_spi(int freq, int mode = 0, int bits = 8); 所以,在设计函数签名时,参数的排列顺序非常重要。一个实用的原则是:**把最常需要自定义的参数放在最左边,把几乎不会变的参数放在最右边**。 -**规则二:默认参数只能被指定一次,而且应该放在声明处。**这一点在头文件与源文件分离的工程中尤为重要。默认值是接口的一部分,而不是实现细节——如果你在 `.cpp` 里又写了一遍默认参数,编译器会认为你在试图重新定义规则,直接报错。 +**规则二:默认参数只能被指定一次,而且应该放在声明处**。这一点在头文件与源文件分离的工程中尤为重要。默认值是接口的一部分,而不是实现细节——如果你在 `.cpp` 里又写了一遍默认参数,编译器会认为你在试图重新定义规则,直接报错。 ```cpp // uart.h —— 声明时指定默认参数 diff --git a/documents/vol1-fundamentals/03C-cpp98-classes-and-objects.md b/documents/vol1-fundamentals/03C-cpp98-classes-and-objects.md index f2eebe5e0..1fcdb74fd 100644 --- a/documents/vol1-fundamentals/03C-cpp98-classes-and-objects.md +++ b/documents/vol1-fundamentals/03C-cpp98-classes-and-objects.md @@ -685,7 +685,7 @@ private: 但 `mutable` 也容易被滥用。如果你发现自己在 `const` 函数里频繁修改 `mutable` 成员,而且这些修改会影响到对象的"可观测行为",那大概率是你的 `const` 设计有问题——要么这个函数不应该是 `const` 的,要么这些成员不应该是 `mutable` 的。 -一个简单的判断标准是:**如果去掉 `mutable` 标记和相关的修改代码,函数对外部行为是否完全一样?**如果答案为"是",那 `mutable` 就是合理的;如果为"否",那就需要重新审视设计了。 +一个简单的判断标准是:**如果去掉 `mutable` 标记和相关的修改代码,函数对外部行为是否完全一样**?如果答案为"是",那 `mutable` 就是合理的;如果为"否",那就需要重新审视设计了。 ## 在线运行 diff --git a/documents/vol1-fundamentals/03D-cpp98-inheritance-polymorphism.md b/documents/vol1-fundamentals/03D-cpp98-inheritance-polymorphism.md index c6045e3a5..6cafe374a 100644 --- a/documents/vol1-fundamentals/03D-cpp98-inheritance-polymorphism.md +++ b/documents/vol1-fundamentals/03D-cpp98-inheritance-polymorphism.md @@ -37,7 +37,7 @@ title: C++98面向对象:继承与多态 继承的核心是表达一种非常明确的关系:**派生类 is-a 基类**。例如,一个温度传感器"是一种传感器",UART "是一种通信接口"。在这种语义成立的前提下,继承才是自然的。 -我要强调一些事情:特别是在比较关键的设计场景下——**使用正确的语义总是比为了图省事强!使用正确的语义总是比为了图省事强!使用正确的语义总是比为了图省事强!**你也不想给未来的你和你的同事加班擦屁股吧。 +我要强调一些事情:特别是在比较关键的设计场景下——**使用正确的语义总是比为了图省事强!使用正确的语义总是比为了图省事强!使用正确的语义总是比为了图省事强**!你也不想给未来的你和你的同事加班擦屁股吧。 我们来看一个完整的传感器层次结构示例: @@ -431,7 +431,7 @@ send_command(spi, cmd, sizeof(cmd)); // 通过 SPI 发送 虚析构函数是多态中一个极其容易被忽视、却又极其致命的细节。 -**只要你打算通过基类指针来管理派生类对象的生命周期,那么基类的析构函数就必须是虚的。**否则,在 `delete` 基类指针时,只会调用基类的析构函数,派生类中持有的资源将完全得不到释放。 +**只要你打算通过基类指针来管理派生类对象的生命周期,那么基类的析构函数就必须是虚的**。否则,在 `delete` 基类指针时,只会调用基类的析构函数,派生类中持有的资源将完全得不到释放。 ```cpp class BadBase { diff --git a/documents/vol1-fundamentals/03E-cpp98-operator-overloading.md b/documents/vol1-fundamentals/03E-cpp98-operator-overloading.md index 466434bcd..6c0669b8a 100644 --- a/documents/vol1-fundamentals/03E-cpp98-operator-overloading.md +++ b/documents/vol1-fundamentals/03E-cpp98-operator-overloading.md @@ -29,7 +29,7 @@ title: C++98运算符重载 运算符重载是 C++ 最具争议但也最有魅力的特性之一。它允许**自定义类型像内置类型一样参与表达式计算**,从而显著提升代码的可读性与表达力。你是喜欢看两个向量塞到一个叫做特别别扭的 `VectorAdd` 方法里(这里内涵下 Java(逃)),还是直接使用 `a + b` 的方式更可读呢?相信各位自有答案。 -不过,运算符重载是一个需要克制的特性。笔者就建议一个准则:**当你"自然地"会用某个运算符来读这段代码时,才值得重载它。**比如说自然的处理非内置的向量数学运算、物理量运算、时间日期、容器处理等等。如果你的运算符重载让人看完之后一头雾水——比如用 `+` 来表示"从容器中删除元素"——那不如老老实实写一个名为 `remove` 的函数。 +不过,运算符重载是一个需要克制的特性。笔者就建议一个准则:**当你"自然地"会用某个运算符来读这段代码时,才值得重载它**。比如说自然的处理非内置的向量数学运算、物理量运算、时间日期、容器处理等等。如果你的运算符重载让人看完之后一头雾水——比如用 `+` 来表示"从容器中删除元素"——那不如老老实实写一个名为 `remove` 的函数。 ## 1. 算术运算符重载 diff --git a/documents/vol1-fundamentals/03F-cpp98-casts-memory-exceptions.md b/documents/vol1-fundamentals/03F-cpp98-casts-memory-exceptions.md index cfb3fc09c..63c33e7b4 100644 --- a/documents/vol1-fundamentals/03F-cpp98-casts-memory-exceptions.md +++ b/documents/vol1-fundamentals/03F-cpp98-casts-memory-exceptions.md @@ -166,7 +166,7 @@ int* modifiable = const_cast(&const_value); 首先问自己:需要移除 `const` 或 `volatile` 吗?如果需要,用 `const_cast`。其次,需要做底层的内存重新解释(如整数地址到指针、不相关指针类型之间)吗?如果需要,用 `reinterpret_cast`——但要格外小心。再次,需要在有虚函数的继承层次中做运行时类型检查吗?如果需要,用 `dynamic_cast`——但要注意 RTTI 开销。如果以上都不是,那就用 `static_cast`——它覆盖了绝大部分日常的类型转换需求。 -**一个实用的原则是:优先用 `static_cast`,只有在明确知道为什么需要其他三种的时候才使用它们。**如果你发现自己在大量使用 `reinterpret_cast` 或 `const_cast`,那可能说明你的设计存在问题,值得重新审视。 +**一个实用的原则是:优先用 `static_cast`,只有在明确知道为什么需要其他三种的时候才使用它们**。如果你发现自己在大量使用 `reinterpret_cast` 或 `const_cast`,那可能说明你的设计存在问题,值得重新审视。 ## 2. 动态内存管理 diff --git a/documents/vol1-fundamentals/c_tutorials/advanced_feature/02-cache-and-memory-hierarchy.md b/documents/vol1-fundamentals/c_tutorials/advanced_feature/02-cache-and-memory-hierarchy.md index 6d988e610..130c2f51d 100644 --- a/documents/vol1-fundamentals/c_tutorials/advanced_feature/02-cache-and-memory-hierarchy.md +++ b/documents/vol1-fundamentals/c_tutorials/advanced_feature/02-cache-and-memory-hierarchy.md @@ -73,6 +73,7 @@ Python 和 Java 这类语言把内存管理彻底抽象掉了,程序员基本 我们可以写一段简单的 C 代码来直观感受缓存行的存在。这个程序以不同的步长遍历同一个数组,观察耗时变化: ```c +#define _POSIX_C_SOURCE 199309L // 启用 clock_gettime / CLOCK_MONOTONIC #include #include #include @@ -88,16 +89,23 @@ int main(void) } // 以不同步长遍历,只做读操作 + volatile int sink = 0; // 防止 sum 被"死代码消除"优化掉 for (int stride = 1; stride <= 4096; stride *= 2) { - clock_t start = clock(); + struct timespec t0, t1; + clock_gettime(CLOCK_MONOTONIC, &t0); // 记录墙上时间起点 int sum = 0; for (int i = 0; i < kArraySize; i += stride) { sum += arr[i]; } - clock_t end = clock(); - printf("stride=%5d time=%.3f ms\n", - stride, - (double)(end - start) / CLOCKS_PER_SEC * 1000); + clock_gettime(CLOCK_MONOTONIC, &t1); // 记录墙上时间终点 + sink = sum; // 强制编译器真的去算 sum + + double total_ms = (t1.tv_sec - t0.tv_sec) * 1000.0 + + (t1.tv_nsec - t0.tv_nsec) / 1e6; + long accesses = kArraySize / stride; // 注意:步长翻倍,访问次数减半 + double ns_per_access = total_ms * 1e6 / accesses; + printf("stride=%5d accesses=%9ld total=%7.3f ms per_access=%6.2f ns\n", + stride, accesses, total_ms, ns_per_access); } free(arr); @@ -109,25 +117,38 @@ int main(void) ```text $ gcc -O2 -std=c11 stride_test.c -o stride_test && ./stride_test -stride= 1 time=68.245 ms -stride= 2 time=68.891 ms -stride= 4 time=69.012 ms -stride= 8 time=69.453 ms -stride= 16 time=70.102 ms -stride= 32 time=132.567 ms -stride= 64 time=201.345 ms -stride= 128 time=215.789 ms -stride= 256 time=218.901 ms -stride= 512 time=220.134 ms -stride= 1024 time=221.567 ms -stride= 2048 time=222.890 ms -stride= 4096 time=223.456 ms +stride= 1 accesses= 67108864 total= 20.518 ms per_access= 0.31 ns +stride= 2 accesses= 33554432 total= 13.900 ms per_access= 0.41 ns +stride= 4 accesses= 16777216 total= 12.080 ms per_access= 0.72 ns +stride= 8 accesses= 8388608 total= 9.663 ms per_access= 1.15 ns +stride= 16 accesses= 4194304 total= 10.263 ms per_access= 2.45 ns +stride= 32 accesses= 2097152 total= 8.678 ms per_access= 4.14 ns +stride= 64 accesses= 1048576 total= 4.679 ms per_access= 4.46 ns +stride= 128 accesses= 524288 total= 2.733 ms per_access= 5.21 ns +stride= 256 accesses= 262144 total= 1.409 ms per_access= 5.38 ns +stride= 512 accesses= 131072 total= 0.866 ms per_access= 6.61 ns +stride= 1024 accesses= 65536 total= 0.672 ms per_access= 10.25 ns +stride= 2048 accesses= 32768 total= 0.304 ms per_access= 9.27 ns +stride= 4096 accesses= 16384 total= 0.115 ms per_access= 7.00 ns ``` -当步长从 1 增长到 16(16 个 int = 64 字节,正好一条缓存行)的过程中,耗时几乎不怎么变化——因为无论你是逐个访问还是每隔几个访问,反正一条缓存行被拉上来之后里面的所有数据都已经在 Cache 里了。但步长一旦超过 16(跨越缓存行边界),每次访问都会触发新的 Cache Line 加载,耗时就会明显上升。这个小实验非常好地展示了缓存行作为最小搬运单位的效果。 +先别急着看 `total` 那一列——它是"把整个数组从头到尾扫一遍的总耗时",而我们的循环是 `i += stride`,步长翻倍访问次数就直接减半:stride=1 要访问 6700 万次,stride=4096 只访问 1.6 万次,差了四千多倍。所以 `total` 这一列被"访问次数"这个量主导着一路往下掉(20ms 掉到 0.1ms),它根本反映不出 Cache 的存在——换台机器、把数组改大改小,绝对值都会跟着抖,没有可比性。 + +真正该盯的是 `per_access`——**每次访存平均摊到多少纳秒**。它把"访问次数"这个干扰量给除掉了,剩下的才是单次访存的纯开销,这才看得见 Cache 的影子。你会发现这条曲线有三个明显的段: + +- **stride 1 → 16**:`per_access` 从 0.31ns 慢慢爬到 2.45ns。16 个 `int` 正好 64 字节、一条缓存行,所以这一段里相邻几次访问还窝在同一条缓存行里——缓存行一旦被拉上来,行内的数据全在 Cache 里白送,再加上硬件预取在背后偷偷提前搬,单次开销就被压到了亚纳秒级。 +- **stride 超过 16**:开始跨缓存行边界了,`per_access` 明显加速往上抬,到 stride=512 已经 6.6ns。这时候每跳一步,基本都得等一条新的缓存行从 L2/L3 甚至主存搬上来,预取也追不上这么大的步子。 +- **stride 到 1024 往上**:步长已经 ≥ 4KB,连页都跨了,访问又稀疏到 Cache 根本兜不住,`per_access` 攀到 7~10ns,基本就是每次都冷访问、逼近一次 DRAM 访问的延迟量级。 + +这就是缓存行作为最小搬运单位的效果——**只要访问还窝在一条 64 字节的缓存行里,单次访存就便宜到亚纳秒;一旦跳出这条行,每一步都得付出整条缓存行搬运的代价。** > **踩坑预警** -> 做步长实验时一定要加上 `-O2` 编译选项。用 `-O0` 的话,循环本身的开销会掩盖 Cache 带来的差异;而 `-O3` 有时又会激进到把整个循环优化成一个常数表达式,导致你什么都测不出来。如果发现所有步长的耗时都一样,很可能是编译器把你的循环吃掉了,可以尝试用 `volatile` 修饰 `sum` 或者在循环体内插入一个编译器屏障(`__asm__ volatile("" ::: "memory")`)。 +> +> 这个实验有几个特别容易翻车的点,挨个说一下: +> +> - **一定要看每次访问的平均耗时,别被总耗时骗了。** 如果你直接拿"扫完整个数组的总时间"来比,步长越大访问次数越少,总时间当然越来越短——但这跟 Cache 没半点关系,纯粹是"活儿干少了"。所以代码里专门算了 `per_access = 总时间 ÷ 访问次数`,把访问次数这个干扰量除掉,才能看到 Cache 命中率的变化。(这也是本教程早先版本踩过的坑,感谢读者在 issue 里指出。) +> - **别让编译器把循环优化没了。** `-O0` 会让循环本身的开销盖过 Cache 差异,`-O3` 又可能激进到把整个循环折叠成常数表达式。代码里的 `volatile int sink = sum;` 就是干这个的——`sum` 算完没人用,编译器会判定它是"死代码"直接删掉,我们用一个 `volatile` 汇聚点逼它老老实实算完。 +> - **计时要用墙上时间,别用 `clock()`。** `clock()` 量的是进程占用的 CPU 时间,不是真实流逝的墙上时间;访存 benchmark 该用 `clock_gettime(CLOCK_MONOTONIC, ...)`。它需要 `#define _POSIX_C_SOURCE 199309L`(或直接 `-std=gnu11` 编译),否则在严格的 `-std=c11` 下会报"隐式声明"。 ## 第三步——搞明白一条缓存行被放到了哪里 @@ -339,7 +360,7 @@ C++ 标准库里的容器在设计时也考虑了缓存因素。`std::vector` ## 练习 -1. **步长实验验证**:修改本文的步长测试代码,将数组大小改为 4MB(恰好塞进大部分 CPU 的 L3),观察步长从 1 到 32 时耗时的变化曲线。思考:为什么步长超过 16 之后耗时又开始趋于平缓? +1. **步长实验验证**:修改本文的步长测试代码,把数组缩小到 4MB(基本能塞进大部分 CPU 的 L3,避免主存延迟的干扰),重点盯 `per_access` 那一列。观察步长从 1 涨到 32 时单次访问耗时的变化——思考:为什么步长突破 16(一条缓存行边界)之后,`per_access` 才开始明显往上抬?这个拐点对应的字节数,能反推出你机器的缓存行大小吗? 2. **伪共享复现**:写一个多线程程序(使用 pthread 或 C++ ``),创建两个线程各自累加一个共享结构体里的不同字段到一亿次。先不加对齐地跑一次,然后用 `alignas(64)` 把两个字段分别对齐到不同缓存行再跑一次,对比耗时。 diff --git a/documents/vol1-fundamentals/ch03/03-overloading-default.md b/documents/vol1-fundamentals/ch03/03-overloading-default.md index 7a8c3deeb..de354acdd 100644 --- a/documents/vol1-fundamentals/ch03/03-overloading-default.md +++ b/documents/vol1-fundamentals/ch03/03-overloading-default.md @@ -77,7 +77,7 @@ void init_uart(int baudrate, int databits, int stopbits, char parity) ## 第二步——理解重载决议 -表面上看,调用一个重载函数只是"写个名字、传个参数"这么简单的事。但实际上,编译器在背后执行了一套非常严格的决策流程——**重载决议 (Overload Resolution)**。每当调用一个存在多个重载版本的函数时,编译器会收集所有名字匹配的候选函数,然后逐一评估:**哪一个是"最合适"的?**需要强调的是,编译器不会理解你的业务语义,它只会机械地按照语言规则打分,选出匹配度最高的版本。 +表面上看,调用一个重载函数只是"写个名字、传个参数"这么简单的事。但实际上,编译器在背后执行了一套非常严格的决策流程——**重载决议 (Overload Resolution)**。每当调用一个存在多个重载版本的函数时,编译器会收集所有名字匹配的候选函数,然后逐一评估:**哪一个是"最合适"的**?需要强调的是,编译器不会理解你的业务语义,它只会机械地按照语言规则打分,选出匹配度最高的版本。 在不涉及模板的情况下,编译器的判断标准可以理解为一条由强到弱的"匹配优先级链"。最顶层是**精确匹配**——实参与形参类型完全一致;如果找不到精确匹配,才会考虑**类型提升**,比如 `char` 提升为 `int`、`float` 提升为 `double`;再往后是**标准类型转换**,例如 `int` 转换为 `double`;最后才轮到用户自定义的类型转换。这个顺序非常关键——只要某一层级已经能找到可行的匹配,后面的规则就完全不会被考虑。 diff --git a/documents/vol1-fundamentals/ch04/02-pointer-arithmetic.md b/documents/vol1-fundamentals/ch04/02-pointer-arithmetic.md index 84147e346..6224fa86d 100644 --- a/documents/vol1-fundamentals/ch04/02-pointer-arithmetic.md +++ b/documents/vol1-fundamentals/ch04/02-pointer-arithmetic.md @@ -146,7 +146,7 @@ std::cout << "p2 - p1 = " << (p2 - p1) << "\n"; // 3 `p2 - p1` 的结果是 3,因为从 `arr[1]` 到 `arr[4]` 中间隔了 3 个元素。这个特性在很多算法里都非常有用——比如计算某个元素在数组中的下标,你只需要 `ptr - arr` 就能得到。 -> 指针减法只能对**指向同一个数组(或同一块连续内存)**的两个指针进行。如果你拿两个毫无关系的指针做减法,结果是未定义行为,编译器连警告都不一定会给。 +> 指针减法只能对**指向同一个数组**(或同一块连续内存)的两个指针进行。如果你拿两个毫无关系的指针做减法,结果是未定义行为,编译器连警告都不一定会给。 ## 用指针遍历数组 diff --git a/documents/vol1-fundamentals/ch04/04-smart-ptr-preview.md b/documents/vol1-fundamentals/ch04/04-smart-ptr-preview.md index 1ace27d93..c0fa81d11 100644 --- a/documents/vol1-fundamentals/ch04/04-smart-ptr-preview.md +++ b/documents/vol1-fundamentals/ch04/04-smart-ptr-preview.md @@ -59,7 +59,7 @@ void process_data() ## RAII——一把钥匙开一把锁 -三种问题的根源一样:**资源的获取和释放被分散在了代码的不同位置**。解决它的核心思想叫 **RAII(Resource Acquisition Is Initialization)**——在构造函数里获取资源,在析构函数里释放资源。C++ 保证对象离开作用域时析构函数**一定会被调用**,不管正常退出还是异常退出,这个保证由**栈展开(stack unwinding)**机制提供。 +三种问题的根源一样:**资源的获取和释放被分散在了代码的不同位置**。解决它的核心思想叫 **RAII(Resource Acquisition Is Initialization)**——在构造函数里获取资源,在析构函数里释放资源。C++ 保证对象离开作用域时析构函数**一定会被调用**,不管正常退出还是异常退出,这个保证由**栈展开**(stack unwinding)机制提供。 你可以把它想象成一把自动归还的钥匙:拿到钥匙(构造时获取),走出房间(离开作用域),钥匙自动归还(析构时释放)。 diff --git a/documents/vol10-open-lecture-notes/cppcon/2025/01-concept-based-generic-programming/01-type-safety-and-number-concept.md b/documents/vol10-open-lecture-notes/cppcon/2025/01-concept-based-generic-programming/01-type-safety-and-number-concept.md index d60ce7c73..08ab4c9e3 100644 --- a/documents/vol10-open-lecture-notes/cppcon/2025/01-concept-based-generic-programming/01-type-safety-and-number-concept.md +++ b/documents/vol10-open-lecture-notes/cppcon/2025/01-concept-based-generic-programming/01-type-safety-and-number-concept.md @@ -704,7 +704,7 @@ struct safe_span { }; ``` -关键点在于成员变量 `size_` 的类型是 `safe_int` 而不是裸的 `std::size_t`。这意味着任何对这个尺寸的运算——减法、比较、赋值——都会经过我们的安全检查。如果有人写了 `50 - 500`,safe_int 会在运算的那一刻就报错,而不是让一个巨大的数悄悄流进 subspan 里。**我们不需要在 span 的边界检查里去补救,我们需要从源头——整数运算本身——就杜绝错误值的产生。**回头看看,其实思路很简单:把不安全的内置整数替换成安全的包装类型,让错误在发生的那一刻就被抓住,而不是等它传播到某个边界检查里才被发现。换而言之——让真正应该负责处理的类处理对应的错误,而不是让其他组件给你兜底。 +关键点在于成员变量 `size_` 的类型是 `safe_int` 而不是裸的 `std::size_t`。这意味着任何对这个尺寸的运算——减法、比较、赋值——都会经过我们的安全检查。如果有人写了 `50 - 500`,safe_int 会在运算的那一刻就报错,而不是让一个巨大的数悄悄流进 subspan 里。**我们不需要在 span 的边界检查里去补救,我们需要从源头——整数运算本身——就杜绝错误值的产生**。回头看看,其实思路很简单:把不安全的内置整数替换成安全的包装类型,让错误在发生的那一刻就被抓住,而不是等它传播到某个边界检查里才被发现。换而言之——让真正应该负责处理的类处理对应的错误,而不是让其他组件给你兜底。 --- diff --git a/documents/vol10-open-lecture-notes/cppcon/2025/02-some-assembly-required/04-stl-and-generic-programming.md b/documents/vol10-open-lecture-notes/cppcon/2025/02-some-assembly-required/04-stl-and-generic-programming.md index 6ee379fe2..72336673b 100644 --- a/documents/vol10-open-lecture-notes/cppcon/2025/02-some-assembly-required/04-stl-and-generic-programming.md +++ b/documents/vol10-open-lecture-notes/cppcon/2025/02-some-assembly-required/04-stl-and-generic-programming.md @@ -184,7 +184,7 @@ int main() { 关键在于 STL 的核心设计理念:算法不应该绑定在特定的类型上,它应该绑定在"迭代器所满足的概念"上。`accumulate` 不关心你累加的是 `int` 还是 `double` 还是某种自定义的 `BigNum`,它只关心迭代器能解引用、值类型能做 `+` 和 `=`。 -如果用显式实例化,你每想支持一种新类型,就得回去加一行显式实例化声明。这意味着算法的作者必须提前知道所有可能的类型——**但这恰恰违背了泛型编程的初衷啊!**泛型编程的意义就在于"我写一次,你拿去用,不管你是什么类型,只要满足我的要求就行"。泛型编程对于实现程序本身是后验的,编译器认为需要什么,实例化什么样的代码;显式声明却又在这里退一步了! +如果用显式实例化,你每想支持一种新类型,就得回去加一行显式实例化声明。这意味着算法的作者必须提前知道所有可能的类型——**但这恰恰违背了泛型编程的初衷啊**!泛型编程的意义就在于"我写一次,你拿去用,不管你是什么类型,只要满足我的要求就行"。泛型编程对于实现程序本身是后验的,编译器认为需要什么,实例化什么样的代码;显式声明却又在这里退一步了! 隐式实例化让这变成了现实:算法作者写模板,类型作者写类型,两边完全解耦,编译器在中间做桥梁。没有这个机制,STL 那种"算法 + 迭代器 + 类型"的三层解耦架构根本搭不起来。 diff --git a/documents/vol10-open-lecture-notes/cppcon/2025/03-back-to-basics-ranges/01-from-loops-to-iterators.md b/documents/vol10-open-lecture-notes/cppcon/2025/03-back-to-basics-ranges/01-from-loops-to-iterators.md index 7f2b9405c..79ac6b232 100644 --- a/documents/vol10-open-lecture-notes/cppcon/2025/03-back-to-basics-ranges/01-from-loops-to-iterators.md +++ b/documents/vol10-open-lecture-notes/cppcon/2025/03-back-to-basics-ranges/01-from-loops-to-iterators.md @@ -260,7 +260,7 @@ for (char c : message) { 这就解释了一个常见的疑惑:**range-based for 是怎么知道去调 `begin`/`end` 的?** 答案是编译器在背后帮你插了这两句。它先拿 `__range`,再取首尾迭代器,然后就是普通迭代器循环。所以 range-based for 对迭代器类别没有任何额外要求——只要你的类型能提供 `begin`/`end`(成员或自由函数都行),它就能用。这也是为什么后面我们自定义类型只要实现这两个函数,就能直接塞进 range-based for。 -如果遍历的是 `std::map` 这种键值对容器,C++17 的**结构化绑定(structured binding)**配合 range-based for 会非常顺手: +如果遍历的是 `std::map` 这种键值对容器,C++17 的**结构化绑定**(structured binding)配合 range-based for 会非常顺手: ```cpp const std::map scores{ diff --git a/documents/vol10-open-lecture-notes/cppcon/2025/03-back-to-basics-ranges/02-stl-algorithms-and-iterator-pitfalls.md b/documents/vol10-open-lecture-notes/cppcon/2025/03-back-to-basics-ranges/02-stl-algorithms-and-iterator-pitfalls.md index 72b2c0354..bb9472776 100644 --- a/documents/vol10-open-lecture-notes/cppcon/2025/03-back-to-basics-ranges/02-stl-algorithms-and-iterator-pitfalls.md +++ b/documents/vol10-open-lecture-notes/cppcon/2025/03-back-to-basics-ranges/02-stl-algorithms-and-iterator-pitfalls.md @@ -33,7 +33,7 @@ video_youtube: https://www.youtube.com/watch?v=Q434UHWRzI0 ## STL 的三大支柱 -标准模板库(STL)的设计哲学,是把三样东西解耦开:**容器(containers)**负责存数据,**迭代器(iterators)**负责遍历数据,**算法(algorithms)**负责处理数据。三者通过迭代器这个「胶水」连接起来——算法不直接认识任何具体容器,它只认迭代器;容器只要能吐出符合要求的迭代器,就能被所有算法复用。这个解耦是 STL 能用一个 `std::sort` 通吃 `vector`、`array`、`deque` 的根本原因。 +标准模板库(STL)的设计哲学,是把三样东西解耦开:**容器**(containers)负责存数据,**迭代器**(iterators)负责遍历数据,**算法**(algorithms)负责处理数据。三者通过迭代器这个「胶水」连接起来——算法不直接认识任何具体容器,它只认迭代器;容器只要能吐出符合要求的迭代器,就能被所有算法复用。这个解耦是 STL 能用一个 `std::sort` 通吃 `vector`、`array`、`deque` 的根本原因。 那算法到底在哪几个头文件里? diff --git a/documents/vol10-open-lecture-notes/cppcon/2025/03-back-to-basics-ranges/03-ranges-views-and-composition.md b/documents/vol10-open-lecture-notes/cppcon/2025/03-back-to-basics-ranges/03-ranges-views-and-composition.md index 65bd8ee47..dc6ba295d 100644 --- a/documents/vol10-open-lecture-notes/cppcon/2025/03-back-to-basics-ranges/03-ranges-views-and-composition.md +++ b/documents/vol10-open-lecture-notes/cppcon/2025/03-back-to-basics-ranges/03-ranges-views-and-composition.md @@ -75,7 +75,7 @@ std::ranges::find_if(v, [](int i) { return i > 4; }); ## views:惰性求值,Ranges 的灵魂 -受约束算法只是开胃菜,Ranges 真正的杀手锏是 **views(视图)**。一个 view 是一种**惰性(lazy)**访问 range 的方式——它不拷贝数据、不预先计算结果,而是在你遍历它的时候,**一次处理一个元素**。 +受约束算法只是开胃菜,Ranges 真正的杀手锏是 **views(视图)**。一个 view 是一种**惰性**(lazy)访问 range 的方式——它不拷贝数据、不预先计算结果,而是在你遍历它的时候,**一次处理一个元素**。 对比一下两种风格。`std::ranges::sort(v)` 是**急切求值(eager)**——它立刻、当场把整个区间排好序,跑完才返回。而 `std::views::filter(...)` 是**惰性求值(lazy)**——它只是搭好一个「过滤管道」,什么计算都不做,直到你真正去遍历它,每遍历到符合条件的一个元素,才把它交给你。 diff --git a/documents/vol10-open-lecture-notes/cppcon/2025/04-back-to-basics-move-semantics/03-move-ops-stdmove-and-elision.md b/documents/vol10-open-lecture-notes/cppcon/2025/04-back-to-basics-move-semantics/03-move-ops-stdmove-and-elision.md index ec5d283a1..5bafa2731 100644 --- a/documents/vol10-open-lecture-notes/cppcon/2025/04-back-to-basics-move-semantics/03-move-ops-stdmove-and-elision.md +++ b/documents/vol10-open-lecture-notes/cppcon/2025/04-back-to-basics-move-semantics/03-move-ops-stdmove-and-elision.md @@ -36,7 +36,7 @@ video_youtube: https://www.youtube.com/watch?v=szU5b972F7E ## 移动构造函数:偷指针的艺术 -上一篇我们已经有了完整的 `MyString` 拷贝操作。现在给它加上移动构造函数。这个函数做的事情,用 Ben Saks 的话说,是一种**"破坏性拷贝"(destructive copy)**——我们把源对象的数据"偷"过来,然后让源对象进入一种无害的状态。 +上一篇我们已经有了完整的 `MyString` 拷贝操作。现在给它加上移动构造函数。这个函数做的事情,用 Ben Saks 的话说,是一种"**破坏性拷贝**"(destructive copy)——我们把源对象的数据"偷"过来,然后让源对象进入一种无害的状态。 ```cpp class MyString @@ -310,7 +310,7 @@ $ g++ -std=c++20 -O2 -fno-elide-constructors test.cpp && ./a.out ## moved-from 状态:有效但不可知 -移动操作完成之后,源对象处于一种标准称为**"有效但未指定的状态"(valid but unspecified state)**的状态。这几个字值得逐个拆解。 +移动操作完成之后,源对象处于一种标准称为"**有效但未指定的状态**"(valid but unspecified state)的状态。这几个字值得逐个拆解。 "有效"意味着:不会内存泄漏、不会资源泄漏、不会触发未定义行为。你可以安全地让这个对象析构——它的析构函数会正常执行,不会 double free,不会 crash。对于我们的 `MyString` 来说,移动后 `actual_str_` 被置成了 `nullptr`,`stored_length_` 变成了 0,所以析构时 `delete[] nullptr` 什么也不做。 diff --git a/documents/vol2-modern-features/ch02-constexpr/04-compile-time-practice.md b/documents/vol2-modern-features/ch02-constexpr/04-compile-time-practice.md index 76d510e82..51e15d83f 100644 --- a/documents/vol2-modern-features/ch02-constexpr/04-compile-time-practice.md +++ b/documents/vol2-modern-features/ch02-constexpr/04-compile-time-practice.md @@ -520,7 +520,7 @@ static_assert(kDebugUart.is_acceptable(), "Baud rate error too large"); 这一章我们从实战角度综合运用了前面学到的所有编译期计算技术。查表生成(CRC、三角函数、多项式)展示了 `constexpr` 在数据预处理方面的威力;字符串哈希和编译期状态机展示了 `constexpr` 在代码结构设计方面的价值;嵌入式寄存器地址计算和配置校验展示了它在实际工程中的安全保障能力。 -核心思路是:**如果某个计算在编译期就能完成,而且它的结果在运行时不变,那就应该考虑把它移到编译期。**这不是为了炫技,而是为了让运行时代码更简单、更快、更安全。编译器是你的同事,让它多干点活,你的 MCU 就能少干点活。 +核心思路是:**如果某个计算在编译期就能完成,而且它的结果在运行时不变,那就应该考虑把它移到编译期**。这不是为了炫技,而是为了让运行时代码更简单、更快、更安全。编译器是你的同事,让它多干点活,你的 MCU 就能少干点活。 ## 参考资源 diff --git a/documents/vol2-modern-features/ch11-user-defined-literals/01-udl-basics.md b/documents/vol2-modern-features/ch11-user-defined-literals/01-udl-basics.md index cd8088a83..d0f5965d1 100644 --- a/documents/vol2-modern-features/ch11-user-defined-literals/01-udl-basics.md +++ b/documents/vol2-modern-features/ch11-user-defined-literals/01-udl-basics.md @@ -24,7 +24,7 @@ title: 用户自定义字面量基础 笔者在写嵌入式代码的时候,经常遇到这种让人难受的场景:`TIM1->ARR = (1000 - 1)` 里面的 1000 是毫秒还是微秒?`USART1->BRR = 0x271` 到底是 9600 还是 115200?`#define BUFFER_SIZE 1024` 是字节还是字?这些"魔数"不仅难以理解,还容易出错——更糟糕的是,不同单位之间的转换完全依赖程序员手动计算,稍有不慎就会出问题。 -C++11 引入的**用户自定义字面量(User-Defined Literals,UDL)**就是为了解决这个问题。它允许我们定义自己的字面量后缀,比如 `100_ms`、`72_MHz`、`4_KiB`,让代码更直观、更安全,而且所有转换都可以在编译期完成,零运行时开销。 +C++11 引入的**用户自定义字面量**(User-Defined Literals,UDL)就是为了解决这个问题。它允许我们定义自己的字面量后缀,比如 `100_ms`、`72_MHz`、`4_KiB`,让代码更直观、更安全,而且所有转换都可以在编译期完成,零运行时开销。 ------ diff --git a/documents/vol4-advanced/01-coroutine-basics.md b/documents/vol4-advanced/01-coroutine-basics.md index 124eb9499..e3afdaabc 100644 --- a/documents/vol4-advanced/01-coroutine-basics.md +++ b/documents/vol4-advanced/01-coroutine-basics.md @@ -17,11 +17,11 @@ description: '' ​ 首先,引出协程,我们跑不了提到函数的运行时栈:调用一个函数时,运行时会为该函数分配一个**栈帧**,在栈帧中保存参数、返回地址以及函数中声明的局部变量——这就是函数的运行时环境。 -​ 协程的核心思想是:**函数可以在执行到一半时挂起(suspend),把执行权让出`(yield)`;当条件满足时再恢复(`resume`)并从原处继续执行。**这使得我们可以在用户态实现轻量级的协作式调度:不同任务按程序控制有序切换,而不是依赖操作系统线程的抢占式调度。 +​ 协程的核心思想是:**函数可以在执行到一半时挂起(suspend),把执行权让出`(yield)`;当条件满足时再恢复(`resume`)并从原处继续执行**。这使得我们可以在用户态实现轻量级的协作式调度:不同任务按程序控制有序切换,而不是依赖操作系统线程的抢占式调度。 ​ 当然,我们需要说明是——按照实现方式, -​ 协程有两类实现思路:**有栈协程(stackful)**会切换完整的执行栈;而**C++20 的协程属于"无栈(`stackless`)"范式**——编译器会把在挂起点需要保留的局部变量和状态封装到一个 **协程帧(coroutine frame)**中。挂起时保存该协程帧并返回,恢复时从帧里恢复状态继续执行。因为不需要切换操作系统栈,也通常不需要频繁进入内核态,对于极端的并发场景,这玩意显然比进程/线程的切换要强的太多太多。 +​ 协程有两类实现思路:**有栈协程**(stackful)会切换完整的执行栈;而**C++20 的协程属于"无栈(`stackless`)"范式**——编译器会把在挂起点需要保留的局部变量和状态封装到一个 **协程帧**(coroutine frame)中。挂起时保存该协程帧并返回,恢复时从帧里恢复状态继续执行。因为不需要切换操作系统栈,也通常不需要频繁进入内核态,对于极端的并发场景,这玩意显然比进程/线程的切换要强的太多太多。 我们使用协程通常有三大理由: diff --git a/documents/vol5-concurrency/ch00-concurrency-fundamentals/03-cpu-cache-and-os-threads.md b/documents/vol5-concurrency/ch00-concurrency-fundamentals/03-cpu-cache-and-os-threads.md index 782fa926d..ac49be7da 100644 --- a/documents/vol5-concurrency/ch00-concurrency-fundamentals/03-cpu-cache-and-os-threads.md +++ b/documents/vol5-concurrency/ch00-concurrency-fundamentals/03-cpu-cache-and-os-threads.md @@ -47,7 +47,7 @@ L3 cache 是所有核心共享的最后一道防线。典型大小从几 MB 到 ### 缓存行:缓存的最小单位 -cache 并不是一个字节一个字节地跟主存交换数据的。它以**缓存行(cache line)**为单位进行操作,在几乎所有现代处理器上,一行是 64 字节。这意味着当你访问内存中的某个地址时,整条 64 字节的缓存行都会被加载到 cache 里,即使你只读了其中一个字节。 +cache 并不是一个字节一个字节地跟主存交换数据的。它以**缓存行**(cache line)为单位进行操作,在几乎所有现代处理器上,一行是 64 字节。这意味着当你访问内存中的某个地址时,整条 64 字节的缓存行都会被加载到 cache 里,即使你只读了其中一个字节。 这个设计背后的逻辑是**空间局部性(spatial locality)**:如果你访问了地址 A,大概率你很快也会访问 A 附近的地址。数组遍历就是一个典型的受益场景——第一个元素被加载时,后面的 15 个 `int` 也一起进了 cache,后续的访问就是 cache hit,几乎零延迟。(注意,1个int是4字节大小,这就是为什么实际加载了15 + 1 = 16个`int`)。 @@ -57,7 +57,7 @@ cache 并不是一个字节一个字节地跟主存交换数据的。它以**缓 在单核时代,缓存很简单——只有一个核心用,数据只存在于一个地方,读写都不存在歧义。但多核处理器打破了这一点:每个核心有自己的 L1 和 L2,同一个内存地址的数据可能同时存在于多个核心的 cache 中。如果核心 A 修改了自己 cache 里的某个值,核心 B 的 cache 里还存着旧值,它怎么知道数据已经过期了? -这就是**缓存一致性(cache coherence)**要解决的问题。现代 x86 和 ARM 处理器普遍使用 **MESI 协议**(Modified / Exclusive / Shared / Invalid)来维护多核之间的缓存一致性。MESI 给每条缓存行赋予了四种状态之一: +这就是**缓存一致性**(cache coherence)要解决的问题。现代 x86 和 ARM 处理器普遍使用 **MESI 协议**(Modified / Exclusive / Shared / Invalid)来维护多核之间的缓存一致性。MESI 给每条缓存行赋予了四种状态之一: **Modified(M)**:这条缓存行被当前核心修改过了,跟主存中的值不一致。当前核心是唯一持有这条数据的有效副本的——其他核心的 cache 里如果有同一地址的数据,状态必须是 Invalid。当这条缓存行被驱逐(evict)时,必须写回主存。 @@ -200,7 +200,7 @@ struct PaddedCounter { 在操作系统的视角里,线程是 CPU 调度的基本单位,进程是资源分配的基本单位。一个进程可以包含多个线程,这些线程共享同一个地址空间、文件描述符表、信号处理函数等资源,但每个线程有自己独立的栈、寄存器状态和程序计数器。这种"共享大部分资源但各自独立执行"的设计,让线程成为实现并发的天然载体。 -线程之所以能"同时"运行,是因为操作系统实现了一套**上下文切换(context switch)**机制:把当前线程的寄存器状态保存到内存中(具体来说,是保存到这个线程对应的线程控制块 TCB 中),然后恢复下一个线程的寄存器状态,跳转到它上次暂停的地方继续执行。这一切都发生在内核空间里——线程的创建、调度、切换都是内核在管。 +线程之所以能"同时"运行,是因为操作系统实现了一套**上下文切换**(context switch)机制:把当前线程的寄存器状态保存到内存中(具体来说,是保存到这个线程对应的线程控制块 TCB 中),然后恢复下一个线程的寄存器状态,跳转到它上次暂停的地方继续执行。这一切都发生在内核空间里——线程的创建、调度、切换都是内核在管。 操作系统为每个线程维护一个**线程控制块(Thread Control Block, TCB)**,里面存储了这个线程的完整状态:寄存器快照、栈指针、程序计数器、调度优先级、信号掩码、以及各种调度相关的元数据。TCB 本身就占几百字节到几 KB 不等,加上每个线程默认的栈空间(Linux 默认 8 MB),一个线程的基础开销并不小。这也是为什么你不能随便开几万个线程——光是栈空间就要吃掉几十 GB 内存。 diff --git a/documents/vol5-concurrency/ch01-thread-lifecycle-raii/01-std-thread.md b/documents/vol5-concurrency/ch01-thread-lifecycle-raii/01-std-thread.md index e10364fdd..6fb45064b 100644 --- a/documents/vol5-concurrency/ch01-thread-lifecycle-raii/01-std-thread.md +++ b/documents/vol5-concurrency/ch01-thread-lifecycle-raii/01-std-thread.md @@ -29,7 +29,7 @@ title: std::thread 基础 ## 从三种方式构造 std::thread -`std::thread` 的构造函数接受一个**可调用对象(callable)**以及可选的参数列表。C++ 为我们提供了好几种表达"可调用"的方式,我们一个一个来看。 +`std::thread` 的构造函数接受一个**可调用对象**(callable)以及可选的参数列表。C++ 为我们提供了好几种表达"可调用"的方式,我们一个一个来看。 ### 函数指针 diff --git a/documents/vol5-concurrency/ch02-mutex-condition-sync/03-condition-variable.md b/documents/vol5-concurrency/ch02-mutex-condition-sync/03-condition-variable.md index 66b1bc3b9..72ad217b0 100644 --- a/documents/vol5-concurrency/ch02-mutex-condition-sync/03-condition-variable.md +++ b/documents/vol5-concurrency/ch02-mutex-condition-sync/03-condition-variable.md @@ -81,7 +81,7 @@ int main() ## 虚假唤醒:为什么 wait 必须配合谓词使用 -**虚假唤醒(spurious wakeup)**是指线程在没有收到 `notify_one()` 或 `notify_all()` 调用的情况下,从 `wait` 中返回。这不是 bug,不是实现质量问题——POSIX 标准和 C++ 标准都明确允许这种行为。为什么?原因在于条件变量的底层实现。 +**虚假唤醒**(spurious wakeup)是指线程在没有收到 `notify_one()` 或 `notify_all()` 调用的情况下,从 `wait` 中返回。这不是 bug,不是实现质量问题——POSIX 标准和 C++ 标准都明确允许这种行为。为什么?原因在于条件变量的底层实现。 在 Linux 上,`std::condition_variable` 基于 `futex`(fast user-space mutex)系统调用实现。条件变量的内部状态通常用一个原子计数器来跟踪等待者和通知者的数量。为了高效实现 `wait` 和 `notify`,条件变量的实现采用了"分散-聚集"策略:`notify` 只需要递增计数器并唤醒一个等待的 futex,而 `wait` 需要原子地递减计数器并检查是否有未处理的通知。在某些边界条件下——比如一个 `notify_all` 刚刚唤醒了一批线程,而这些线程还没来得及重新检查内部状态——内核可能会多唤醒一些线程。POSIX 标准委员会在权衡了实现效率和语义严格性之后,选择了允许虚假唤醒——这样条件变量可以用更轻量的内核原语实现,而不需要为每次通知都做精确的一对一映射。 @@ -121,7 +121,7 @@ void worker() ## 丢失唤醒:先通知后等待的灾难 -虚假唤醒说的是"没通知就醒了",而**丢失唤醒(lost wakeup)**恰好相反——"通知了但没人收到"。发生的原因是通知在 `wait` 之前就发出了。 +虚假唤醒说的是"没通知就醒了",而**丢失唤醒**(lost wakeup)恰好相反——"通知了但没人收到"。发生的原因是通知在 `wait` 之前就发出了。 我们来构造一个丢失唤醒的场景: diff --git a/documents/vol5-concurrency/ch02-mutex-condition-sync/05-latch-barrier-semaphore.md b/documents/vol5-concurrency/ch02-mutex-condition-sync/05-latch-barrier-semaphore.md index 97ba99c8d..544c5a265 100644 --- a/documents/vol5-concurrency/ch02-mutex-condition-sync/05-latch-barrier-semaphore.md +++ b/documents/vol5-concurrency/ch02-mutex-condition-sync/05-latch-barrier-semaphore.md @@ -21,7 +21,7 @@ title: latch、barrier 与 semaphore --- # latch、barrier 与 semaphore -上一篇我们深入拆解了 `condition_variable` 的等待-通知机制——虚假唤醒、丢失唤醒、带谓词的 `wait`。有了这些基础,我们现在可以面对一个更实际的问题:很多时候我们并不需要"某个条件满足才继续"这种通用的等待语义,而是只需要"等到大家都到齐了再继续"或者"限制同时访问资源的线程数量"。这两种需求分别对应**屏障(barrier)**和**信号量(semaphore)**两种同步模式,而 C++20 终于把这两个概念以 `std::latch`、`std::barrier` 和 `std::counting_semaphore` 的形式纳入了标准库。 +上一篇我们深入拆解了 `condition_variable` 的等待-通知机制——虚假唤醒、丢失唤醒、带谓词的 `wait`。有了这些基础,我们现在可以面对一个更实际的问题:很多时候我们并不需要"某个条件满足才继续"这种通用的等待语义,而是只需要"等到大家都到齐了再继续"或者"限制同时访问资源的线程数量"。这两种需求分别对应**屏障**(barrier)和**信号量**(semaphore)两种同步模式,而 C++20 终于把这两个概念以 `std::latch`、`std::barrier` 和 `std::counting_semaphore` 的形式纳入了标准库。 说实话,在此之前,我们只能用 mutex + condition_variable + 一个手动计数器来模拟这些模式——代码冗长、容易出错、而且每次都要重新写一遍。C++20 这三个原语的引入,本质上是把这些高频模式标准化了。但要用好它们,我们需要搞清楚每个原语的语义边界和适用场景,而不是拿着 hammer 把所有钉子都敲一遍。 @@ -422,7 +422,7 @@ private: }; ``` -barrier 的模拟比 latch 复杂的地方在于"可重用"。我们不能简单地在计数归零时重置——因为可能有上一轮的线程还没从 `wait` 中返回,新一轮的线程已经开始 `arrive_and_wait` 了。解决方案是引入一个**代数(generation)**计数器:每次屏障重置时递增 generation,等待线程检查的是"我这一代的 generation 是否已经变化"——如果变了,说明屏障已经打开,可以继续了。 +barrier 的模拟比 latch 复杂的地方在于"可重用"。我们不能简单地在计数归零时重置——因为可能有上一轮的线程还没从 `wait` 中返回,新一轮的线程已经开始 `arrive_and_wait` 了。解决方案是引入一个**代数**(generation)计数器:每次屏障重置时递增 generation,等待线程检查的是"我这一代的 generation 是否已经变化"——如果变了,说明屏障已经打开,可以继续了。 这个 generation 技巧是实现可重用屏障的核心手法,也是 C++20 `std::barrier` 内部使用的机制。理解了这个技巧,你在阅读标准库实现或者第三方并发库时就不会对 generation 计数器感到陌生了。 diff --git a/documents/vol5-concurrency/ch08-debug-testing-perf/01-debugging-concurrency.md b/documents/vol5-concurrency/ch08-debug-testing-perf/01-debugging-concurrency.md index 954a2c6f3..9234b7007 100644 --- a/documents/vol5-concurrency/ch08-debug-testing-perf/01-debugging-concurrency.md +++ b/documents/vol5-concurrency/ch08-debug-testing-perf/01-debugging-concurrency.md @@ -37,11 +37,11 @@ title: 并发程序调试技巧 在开始用工具之前,我们需要先搞清楚并发 bug 大致分为哪几类,因为不同类型的 bug 对应的诊断策略完全不同。 -**数据竞争(data race)**是最常见也最阴险的一类。它的定义很严格:两个或以上的线程同时访问同一个内存位置,其中至少一个是写入操作,而且它们之间没有任何同步关系(没有 mutex、没有 atomic、没有 happens-before)。C++ 标准明确规定数据竞争是未定义行为——不是"可能出错",是"什么都可能发生",包括但不限于读到垃圾值、程序崩溃、甚至看起来"正常工作"然后突然在某一天爆炸。数据竞争之所以难以追踪,是因为它取决于线程的调度顺序,而这个顺序在你调试的时候和在生产环境里可能完全不同。你加一个 `printf` 调试,打印本身就改变了时序,bug 就消失了——这就是经典的"Heisenbug"。 +**数据竞争**(data race)是最常见也最阴险的一类。它的定义很严格:两个或以上的线程同时访问同一个内存位置,其中至少一个是写入操作,而且它们之间没有任何同步关系(没有 mutex、没有 atomic、没有 happens-before)。C++ 标准明确规定数据竞争是未定义行为——不是"可能出错",是"什么都可能发生",包括但不限于读到垃圾值、程序崩溃、甚至看起来"正常工作"然后突然在某一天爆炸。数据竞争之所以难以追踪,是因为它取决于线程的调度顺序,而这个顺序在你调试的时候和在生产环境里可能完全不同。你加一个 `printf` 调试,打印本身就改变了时序,bug 就消失了——这就是经典的"Heisenbug"。 -**死锁(deadlock)**是另一大类。两个或多个线程互相等待对方持有的资源,谁也不让步,程序就彻底卡死了。死锁的确定性其实比数据竞争高——只要触发了特定的锁获取顺序,它必定发生。但问题在于,触发条件可能非常复杂,涉及多个线程的特定执行路径组合。而且死锁往往在正常负载下不出现,只在某些特定的并发模式下才暴露。 +**死锁**(deadlock)是另一大类。两个或多个线程互相等待对方持有的资源,谁也不让步,程序就彻底卡死了。死锁的确定性其实比数据竞争高——只要触发了特定的锁获取顺序,它必定发生。但问题在于,触发条件可能非常复杂,涉及多个线程的特定执行路径组合。而且死锁往往在正常负载下不出现,只在某些特定的并发模式下才暴露。 -**活锁(livelock)**比死锁更隐蔽。线程们没有卡死——CPU 占用率可能是 100%——但没有任何有意义的进展。一个经典的例子是两个线程都在礼貌地让出资源给对方,结果谁也没拿到。活锁的表象是程序变慢而不是卡死,很容易被误判为性能问题。 +**活锁**(livelock)比死锁更隐蔽。线程们没有卡死——CPU 占用率可能是 100%——但没有任何有意义的进展。一个经典的例子是两个线程都在礼貌地让出资源给对方,结果谁也没拿到。活锁的表象是程序变慢而不是卡死,很容易被误判为性能问题。 最后是**悬挂引用(dangling reference)**。线程通过引用或指针访问了一个已经超出生命周期的对象——这在异步编程中尤其常见。比如你启动一个线程,传了一个局部变量的引用进去,然后函数返回了,局部变量被销毁,线程还在用那个引用。这种 bug 的表现取决于那块内存被重新分配给了什么——可能读到一个"看起来正常但其实是错的"值,也可能直接 segfault。 diff --git a/documents/vol5-concurrency/ch09-distributed-bridge/02-distributed-primitives.md b/documents/vol5-concurrency/ch09-distributed-bridge/02-distributed-primitives.md index fa3040b65..05329accf 100644 --- a/documents/vol5-concurrency/ch09-distributed-bridge/02-distributed-primitives.md +++ b/documents/vol5-concurrency/ch09-distributed-bridge/02-distributed-primitives.md @@ -58,7 +58,7 @@ title: 分布式一致性原语初探 如果我们再进一步放松约束,不要求所有操作的全序一致,只要求**因果相关**的操作被所有进程以相同的顺序看到,而因果无关的操作可以以不同的顺序被看到——这就是因果一致性。 -什么叫做因果相关?简单说,如果操作 B 读取了操作 A 写入的值,那么 A 和 B 就有因果关系——A "导致"了 B。或者如果操作 C 发生在操作 B 之后(同一个进程内),而 B 因果依赖于 A,那么 C 也因果依赖于 A。除了这些直接和间接的依赖关系之外,两个操作就是**并发(concurrent)**的——它们之间没有因果关系。 +什么叫做因果相关?简单说,如果操作 B 读取了操作 A 写入的值,那么 A 和 B 就有因果关系——A "导致"了 B。或者如果操作 C 发生在操作 B 之后(同一个进程内),而 B 因果依赖于 A,那么 C 也因果依赖于 A。除了这些直接和间接的依赖关系之外,两个操作就是**并发**(concurrent)的——它们之间没有因果关系。 用一个社交媒体的场景来解释。用户 Alice 发了一条帖子:"今天天气真好!"(操作 A)。用户 Bob 看到了 Alice 的帖子,回复说:"确实不错!"(操作 B)。操作 B 因果依赖于操作 A——因为 Bob 是看了 Alice 的帖子才回复的。在因果一致性下,任何用户都一定先看到 Alice 的帖子,然后才看到 Bob 的回复——不可能看到 Bob 的回复但看不到 Alice 的帖子,那在语义上就说不通了。 @@ -110,7 +110,7 @@ flowchart TD 在 Raft 中,任何时候集群中最多只有一个 Leader——所有的写请求都由 Leader 处理,所有的日志都由 Leader 向 Follower 复制。这个"强 Leader"的设计比 Paxos 的"多 Proposer"模型更容易理解和实现。 -Leader 选举通过**任期(term)**和**心跳(heartbeat)**来驱动。每个任期是一个单调递增的整数,每个任期最多有一个 Leader。正常情况下,Leader 定期向所有 Follower 发送心跳(AppendEntries RPC,即使没有日志要复制也发空的心跳)。如果 Follower 在一段超时时间(election timeout)内没有收到心跳,它就认为 Leader 挂了,开始新一轮选举。 +Leader 选举通过**任期**(term)和**心跳**(heartbeat)来驱动。每个任期是一个单调递增的整数,每个任期最多有一个 Leader。正常情况下,Leader 定期向所有 Follower 发送心跳(AppendEntries RPC,即使没有日志要复制也发空的心跳)。如果 Follower 在一段超时时间(election timeout)内没有收到心跳,它就认为 Leader 挂了,开始新一轮选举。 选举的过程用通俗的话说就是"一群人投票选一个领导":Follower 自增当前任期,变成 Candidate,先给自己投一票,然后向其他所有节点发送 RequestVote RPC。其他节点的投票规则是:每个任期最多投一票,先到先得(但有一个限制:Candidate 的日志必须至少跟投票者一样新)。如果一个 Candidate 收到了多数派的投票,它就变成了新的 Leader,立刻开始发送心跳来阻止其他人继续发起选举。 @@ -118,7 +118,7 @@ Leader 选举通过**任期(term)**和**心跳(heartbeat)**来驱动。 ### 日志复制:Leader 发话,Follower 跟着做 -Leader 选出来之后,日志复制就比较直白了——整个流程的核心就是"Leader 说一句,Follower 跟一句"。客户端发送写请求给 Leader,Leader 把操作追加到自己的日志里,然后把这条日志复制给所有 Follower(通过 AppendEntries RPC)。当 Leader 确认这条日志已经被多数派(包括自己)接受后,它就**提交(commit)**这条日志并应用到状态机,然后给客户端返回成功。 +Leader 选出来之后,日志复制就比较直白了——整个流程的核心就是"Leader 说一句,Follower 跟一句"。客户端发送写请求给 Leader,Leader 把操作追加到自己的日志里,然后把这条日志复制给所有 Follower(通过 AppendEntries RPC)。当 Leader 确认这条日志已经被多数派(包括自己)接受后,它就**提交**(commit)这条日志并应用到状态机,然后给客户端返回成功。 关键的安全性保证是:已被提交的日志永远不会被覆盖。Raft 通过一个简单的约束来实现这一点——Leader 在发送 AppendEntries 时会携带前一条日志的索引和任期,Follower 收到后会检查自己的日志里对应位置是否匹配。如果不匹配,Follower 拒绝接受这条日志,Leader 会回退并重试,直到找到双方一致的位置然后从那里开始覆盖。 diff --git a/documents/vol7-engineering/02-file-copier-core-implementation.md b/documents/vol7-engineering/02-file-copier-core-implementation.md index e3bd0edc0..a83ca90ef 100644 --- a/documents/vol7-engineering/02-file-copier-core-implementation.md +++ b/documents/vol7-engineering/02-file-copier-core-implementation.md @@ -484,15 +484,15 @@ echo -e "\n=== All tests completed ===" 虽然这个拷贝器已经挺实用了,但如果要继续优化,可以考虑: -**多线程:**可以一个线程读,一个线程写,用队列传递缓冲区,理论上能提升性能。但要注意同步开销,不一定总是更快。 +**多线程**:可以一个线程读,一个线程写,用队列传递缓冲区,理论上能提升性能。但要注意同步开销,不一定总是更快。 -**内存映射:**用`mmap`(或Windows的等价API)把文件映射到内存,让操作系统来优化读写。不过这对超大文件可能有问题,而且跨平台性不如`fstream`。 +**内存映射**:用`mmap`(或Windows的等价API)把文件映射到内存,让操作系统来优化读写。不过这对超大文件可能有问题,而且跨平台性不如`fstream`。 -**校验和:**计算MD5/SHA-256确保数据完整性。可以在读写的同时进行计算,不会增加太多时间。 +**校验和**:计算MD5/SHA-256确保数据完整性。可以在读写的同时进行计算,不会增加太多时间。 -**断点续传:**记录已拷贝的位置,如果中断可以从断点继续。对超大文件很有用,但实现比较复杂。 +**断点续传**:记录已拷贝的位置,如果中断可以从断点继续。对超大文件很有用,但实现比较复杂。 -**批量拷贝:**支持一次拷贝多个文件,或者整个目录树。这就需要递归遍历目录,创建对应的目录结构。 +**批量拷贝**:支持一次拷贝多个文件,或者整个目录树。这就需要递归遍历目录,创建对应的目录结构。 不过对于一个教学示例,我们现在的实现已经足够了。它简洁、健壮、性能合理,代码量也不大,正适合理解文件IO和现代C++特性。 diff --git a/documents/vol7-engineering/msvc-debugging-internals.md b/documents/vol7-engineering/msvc-debugging-internals.md index d34b5e4ef..1c808f5ac 100644 --- a/documents/vol7-engineering/msvc-debugging-internals.md +++ b/documents/vol7-engineering/msvc-debugging-internals.md @@ -99,7 +99,7 @@ flowchart TD 如果说二进制文件是迷宫,那么 **PDB (Program Database)** 就是这份迷宫的地图。它并不是简单的辅助文件,而是一个复杂的数据库,记录了机器码地址与源代码行号、变量名、类型定义以及栈回溯所需的 FPO 数据。 -当程序在地址 `0x00401000` 崩溃时,调试器并不知道这里发生了什么。它会迅速检索 PDB 文件,通过映射表发现该地址对应的是 `main.cpp` 第 15 行。正是通过这种**符号化(Symbolication)**过程,调试器才能将原始的寄存器状态转化为开发者能理解的代码上下文。 +当程序在地址 `0x00401000` 崩溃时,调试器并不知道这里发生了什么。它会迅速检索 PDB 文件,通过映射表发现该地址对应的是 `main.cpp` 第 15 行。正是通过这种**符号化**(Symbolication)过程,调试器才能将原始的寄存器状态转化为开发者能理解的代码上下文。 为了确保这张地图的准确性,**编译选项**至关重要: diff --git a/documents/vol8-domains/embedded/01-led/04-hal-gpio-clock.md b/documents/vol8-domains/embedded/01-led/04-hal-gpio-clock.md index 62ff2e716..6b0bf84e6 100644 --- a/documents/vol8-domains/embedded/01-led/04-hal-gpio-clock.md +++ b/documents/vol8-domains/embedded/01-led/04-hal-gpio-clock.md @@ -29,11 +29,11 @@ description: '' 要理解时钟使能,首先要理解STM32的设计哲学——省电。这颗芯片的设计目标之一就是能在各种低功耗场景下工作,从电池供电的传感器节点到手持设备,功耗控制都是核心考量。STM32F103C8T6是一颗Cortex-M3内核的微控制器,它的设计者面对一个现实问题:芯片上集成了几十个外设——GPIO有五个端口(A到E),通用定时器有好几个(TIM2、TIM3、TIM4),高级定时器有TIM1,串口有USART1、USART2、USART3,SPI有SPI1、SPI2、SPI3,I2C有I2C1、I2C2,ADC有两个,还有DMA控制器、USB、CAN等等。如果这些外设全部同时接收时钟信号、全部处于活跃状态,哪怕你只用了其中一个GPIO端口去点一个LED,芯片的待机电流也会非常高——那些你没用到但依然在运转的外设,每一个都在消耗电能。 -想象一下你家有二十个房间,但你只在其中一个房间里看书。如果你把所有房间的灯都打开、空调都开着、电视都开着,电费账单会让你哭出来。合理的做法是什么?你进哪个房间,就开哪个房间的灯和空调;离开的时候关掉。STM32就是这么做的——这就是**时钟门控(Clock Gating)**机制。 +想象一下你家有二十个房间,但你只在其中一个房间里看书。如果你把所有房间的灯都打开、空调都开着、电视都开着,电费账单会让你哭出来。合理的做法是什么?你进哪个房间,就开哪个房间的灯和空调;离开的时候关掉。STM32就是这么做的——这就是**时钟门控**(Clock Gating)机制。 时钟门控的核心思想很简单:每个外设都有独立的时钟开关。你需要用哪个外设,就手动打开它的时钟;不用的外设,时钟默认关闭,它就处于"断电"状态,几乎不消耗电能。这个开关不是物理上的电源开关,而是时钟信号的门控——时钟信号到达外设之前要经过一个"闸门",这个闸门由软件控制,打开就放行时钟信号,关闭就阻断。外设没有时钟信号输入,内部的时序逻辑电路就无法工作,寄存器的写入操作会被硬件直接忽略。 -那么谁来管理这些闸门呢?答案是**RCC(Reset and Clock Control)**模块。RCC是STM32内部一个非常重要的模块,它负责三件事:第一,管理时钟源的选择和配置(用内部振荡器还是外部晶振?要不要倍频?);第二,管理时钟的分频和分配(CPU跑多少MHz?各个总线跑多少MHz?);第三,管理每个外设的时钟使能(哪个外设开、哪个外设关)。RCC本身就是一颗芯片内部的"电力调度中心",我们在代码中对时钟做的一切操作,最终都是通过配置RCC模块内部的寄存器来实现的。 +那么谁来管理这些闸门呢?答案是**RCC**(Reset and Clock Control)模块。RCC是STM32内部一个非常重要的模块,它负责三件事:第一,管理时钟源的选择和配置(用内部振荡器还是外部晶振?要不要倍频?);第二,管理时钟的分频和分配(CPU跑多少MHz?各个总线跑多少MHz?);第三,管理每个外设的时钟使能(哪个外设开、哪个外设关)。RCC本身就是一颗芯片内部的"电力调度中心",我们在代码中对时钟做的一切操作,最终都是通过配置RCC模块内部的寄存器来实现的。 在我们的项目代码中,`clock.cpp`文件里的`ClockConfig::setup_system_clock()`方法就是用来配置RCC模块的,它设定了系统时钟源和各级分频参数。而GPIO外设的时钟使能,则是在`gpio.hpp`中的`GPIOClock::enable_target_clock()`方法里完成的。两者分工明确:前者配置整棵时钟树,后者打开特定外设的时钟闸门。下面我们先来看时钟树,搞清楚GPIO的时钟到底从哪里来。 @@ -64,7 +64,7 @@ osc.HSICalibrationValue = RCC_HSICALIBRATION_DEFAULT; **第二层:PLL倍频——从8MHz到64MHz** -HSI的8MHz对于一颗Cortex-M3来说太慢了。STM32F103C8T6的最高主频是72MHz(在数据手册中有明确标注),但我们这里的配置选择了64MHz——这是一个安全且稳定的频率。要把8MHz提升到64MHz,中间要经过一个叫**PLL(Phase Locked Loop,锁相环)**的模块。PLL本质上是一个倍频器:你给它一个输入频率,它输出一个更高的频率。 +HSI的8MHz对于一颗Cortex-M3来说太慢了。STM32F103C8T6的最高主频是72MHz(在数据手册中有明确标注),但我们这里的配置选择了64MHz——这是一个安全且稳定的频率。要把8MHz提升到64MHz,中间要经过一个叫**PLL**(Phase Locked Loop,锁相环)的模块。PLL本质上是一个倍频器:你给它一个输入频率,它输出一个更高的频率。 倍频的过程分两步:先分频,再倍频。HSI的8MHz先经过2分频变成4MHz,然后4MHz经过16倍频变成64MHz。数学上就是:8 / 2 × 16 = 64MHz。这个配置在我们的代码中一目了然: @@ -79,7 +79,7 @@ osc.PLL.PLLMUL = RCC_PLL_MUL16; // 4MHz × 16 = 64MHz **第三层:AHB和APB总线分频** -SYSCLK的64MHz并不是直接给所有模块用的。它先经过**AHB(Advanced High-performance Bus)**分频器得到HCLK,这是CPU本身运行的时钟频率,也是整个总线矩阵的核心时钟。在我们的配置中,AHB分频系数是1,所以HCLK = SYSCLK = 64MHz: +SYSCLK的64MHz并不是直接给所有模块用的。它先经过**AHB**(Advanced High-performance Bus)分频器得到HCLK,这是CPU本身运行的时钟频率,也是整个总线矩阵的核心时钟。在我们的配置中,AHB分频系数是1,所以HCLK = SYSCLK = 64MHz: ```cpp clk.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK; // SYSCLK = PLL输出 diff --git a/documents/vol8-domains/embedded/03-circular-buffer.md b/documents/vol8-domains/embedded/03-circular-buffer.md index d11e8f2ed..4af5ec1d3 100644 --- a/documents/vol8-domains/embedded/03-circular-buffer.md +++ b/documents/vol8-domains/embedded/03-circular-buffer.md @@ -20,7 +20,7 @@ title: 循环缓冲区实现 --- # 嵌入式C++教程——循环缓冲区 -在嵌入式世界里,有一类问题反复出现:**数据源不停地产生数据,消费者慢慢地处理数据,中间还不想 malloc。**于是,一个古老但永不过时的数据结构登场了——**循环缓冲区(Circular Buffer / Ring Buffer)**。 +在嵌入式世界里,有一类问题反复出现:**数据源不停地产生数据,消费者慢慢地处理数据,中间还不想 malloc**。于是,一个古老但永不过时的数据结构登场了——**循环缓冲区(Circular Buffer / Ring Buffer)**。 你可以把它理解为一个仓库,只有固定大小,装满了就从头再来。没有扩容、没有碎片、没有"new 失败",非常适合 MCU、驱动、中断、DMA、串口、音频流等场景。 diff --git a/documents/vol8-domains/embedded/04-crtp-vs-runtime-polymorphism.md b/documents/vol8-domains/embedded/04-crtp-vs-runtime-polymorphism.md index 1ac6a2d4b..97db77743 100644 --- a/documents/vol8-domains/embedded/04-crtp-vs-runtime-polymorphism.md +++ b/documents/vol8-domains/embedded/04-crtp-vs-runtime-polymorphism.md @@ -30,7 +30,7 @@ title: CRTP vs 运行时多态 所以您就能看到,运行时的多态,有函数转发操作。 -**编译期多态(static polymorphism)**则是通过模板、重载、`constexpr`、CRTP(Curiously Recurring Template Pattern)以及代数数据类型(`std::variant`/`std::visit`)等,在编译阶段就把不同实现分派、内联、优化掉。函数调用在编译期能被决定并展开为直接调用或内联,从而消除了运行时间接调用的代价。 +**编译期多态**(static polymorphism)则是通过模板、重载、`constexpr`、CRTP(Curiously Recurring Template Pattern)以及代数数据类型(`std::variant`/`std::visit`)等,在编译阶段就把不同实现分派、内联、优化掉。函数调用在编译期能被决定并展开为直接调用或内联,从而消除了运行时间接调用的代价。 从实现角度看,运行时多态会产生一张或多张 vtable、每个对象携带 vptr(占用 RAM),每次虚函数调用是一次间接跳转(可能影响分支预测),而编译期多态通常会生成多个具体函数实例(模板实例化),这些可以被内联与优化,调用开销可接近普通函数调用,甚至为零开销抽象。 diff --git a/scripts/check_bold_rendering.ts b/scripts/check_bold_rendering.ts new file mode 100644 index 000000000..272637389 --- /dev/null +++ b/scripts/check_bold_rendering.ts @@ -0,0 +1,103 @@ +#!/usr/bin/env tsx +/** + * check_bold_rendering.ts — 检测 markdown 粗体/强调(**)渲染失败。 + * + * 背景:VitePress 用 markdown-it 渲染。按 CommonMark 的 flanking 规则,当 `**` 的 + * 一侧紧贴 Unicode 标点(全角 `)。、!、:` 或半角 `)` 等)、另一侧紧贴普通字 + * (中文/英文/数字)时,`**` 不构成 emphasis delimiter,会以字面 `**` 残留在页面 + * (加粗失效)。最典型:`**术语(english)**` —— 结尾全角 `)` 致闭合 `**` 失效。 + * + * 原理:用 VitePress 同源 markdown-it 逐行 renderInline,剥掉 inline code 后,若 + * 仍含字面 `**` → 该行有渲染失败。这是与线上完全一致的「金标准」检测。 + * + * 用法: + * tsx scripts/check_bold_rendering.ts # 只查中文 documents/(排除 en/) + * tsx scripts/check_bold_rendering.ts --all # 同时查英文 + * + * 退出码:0 通过,1 有失败,2 环境异常(pre-commit/CI 友好)。 + */ +import { createRequire } from 'node:module' +import { dirname, join, relative } from 'node:path' +import { readFileSync, readdirSync } from 'node:fs' + +const require = createRequire(import.meta.url) + +// 复用 VitePress 自带的 markdown-it:版本随 vitepress 自动一致、渲染零偏差, +// 且无需在 package.json 声明额外依赖。 +let MarkdownIt: any +try { + const vpPkg = require.resolve('vitepress/package.json') + MarkdownIt = require(require.resolve('markdown-it', { paths: [dirname(vpPkg)] })) +} catch { + console.error('✗ 找不到 vitepress / markdown-it,请先 `pnpm install`。') + process.exit(2) +} + +// 复刻 VitePress 默认 markdown 配置(html:true, typographer:false)。 +// emphasis 行为不受 linkify / 项目自定义插件(cppTemplateEscape / kbd / mermaid)影响。 +const md = new MarkdownIt({ html: true, typographer: false }) + +const ROOT = join(process.cwd(), 'documents') +const INCLUDE_EN = process.argv.includes('--all') + +interface Hit { rel: string; line: number; text: string } + +function walk(dir: string, acc: string[] = []): string[] { + for (const e of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, e.name) + if (e.isDirectory()) { + if (e.name === 'en' && !INCLUDE_EN) continue + walk(p, acc) + } else if (e.name.endsWith('.md')) { + acc.push(p) + } + } + return acc +} + +function scanFile(file: string): Hit[] { + const lines = readFileSync(file, 'utf8').split('\n') + const hits: Hit[] = [] + const rel = relative(ROOT, file) + let inFence = false, fenceCh: string | null = null, yaml = false, yd = false + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + // 跳过 YAML frontmatter(文件首部 --- ... ---) + if (!yd) { + if (i === 0 && line.trim() === '---') { yaml = true; continue } + if (yaml) { if (line.trim() === '---') { yaml = false; yd = true } continue } + else yd = true + } + // 跳过代码围栏 ``` / ~~~ + const fm = line.match(/^\s*(`{3,}|~{3,})/) + if (fm) { + const c = fm[1][0] + if (!inFence) { inFence = true; fenceCh = c } + else if (c === fenceCh) { inFence = false; fenceCh = null } + continue + } + if (inFence) continue + if (/^\t/.test(line)) continue // 缩进代码块 + // 整行渲染 → 剥 inline code → 若残留字面 ** 即失败 + const rendered = md.renderInline(line).replace(/]*>[\s\S]*?<\/code>/g, '') + if (rendered.includes('**')) hits.push({ rel, line: i + 1, text: line.slice(0, 120) }) + } + return hits +} + +const hits = walk(ROOT).sort().flatMap(scanFile) + +if (hits.length === 0) { + console.log('✓ 粗体渲染检查通过:无 `**` 残留失败。') + process.exit(0) +} + +console.error(`✗ 发现 ${hits.length} 行粗体渲染失败(\`**\` 因标点边界未渲染为 ,字面残留):\n`) +for (const h of hits) console.error(` ${h.rel}:L${h.line} ${h.text}`) +console.error(` +修法:让 \`**\` 边界落在文字字符上,把标点移到 \`**\` 外侧。例如: + **术语(english)** → **术语**(english) (英文对照移出加粗) + **…句末标点!** → **…**! (句末标点移出加粗) + **"导入库"(import)** → "**导入库**"(import) (包裹引号移出加粗) +详见 scripts/check_bold_rendering.ts 顶部说明。`) +process.exit(1) From d465189d352560eec0a323deeac65716ead02ad8 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Thu, 18 Jun 2026 21:06:28 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AD=A3?= =?UTF-8?q?=E6=96=87=E5=AD=97=E5=8F=B7=E4=BA=94=E6=A1=A3=E5=88=87=E6=8D=A2?= =?UTF-8?q?(=E8=B6=85=E5=B0=8F/=E5=B0=8F/=E6=AD=A3=E5=B8=B8/=E5=A4=A7/?= =?UTF-8?q?=E8=B6=85=E5=A4=A7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A-/A+ 步进切换器挂 nav-bar-content-after 插槽;用 CSS zoom 整页缩放(rem 与所有 px 字号一并跟随,等同浏览器 Ctrl+/-,避免只有正文跳组件不动的割裂);config head 内联脚本首屏从 localStorage 应用字号防刷新闪烁;默认「正常」,选择持久化。 --- site/.vitepress/config/index.ts | 26 +- site/.vitepress/config/shared.ts | 44 ++- .../theme/components/FontSizeSwitcher.vue | 113 ++++++++ site/.vitepress/theme/custom.css | 250 ++++++++++++++---- site/.vitepress/theme/index.ts | 3 + 5 files changed, 352 insertions(+), 84 deletions(-) create mode 100644 site/.vitepress/theme/components/FontSizeSwitcher.vue diff --git a/site/.vitepress/config/index.ts b/site/.vitepress/config/index.ts index b3c9e4833..abe38149f 100644 --- a/site/.vitepress/config/index.ts +++ b/site/.vitepress/config/index.ts @@ -2,10 +2,7 @@ import { defineConfig } from 'vitepress' import withDrawio from '@dhlx/vitepress-plugin-drawio' import { navEn } from './nav' import { buildSidebar } from './sidebar' -import { sharedThemeConfig } from './shared' -import { kbdPlugin } from '../plugins/kbd-plugin' -import { cppTemplateEscapePlugin } from '../plugins/escape-cpp-templates' -import { mermaidPlugin } from '../plugins/mermaid-plugin' +import { sharedThemeConfig, sharedMarkdown } from './shared' import { createReadStream, existsSync } from 'node:fs' import { join, normalize } from 'node:path' import { fileURLToPath } from 'node:url' @@ -85,21 +82,16 @@ export default withDrawio(defineConfig({ head: [ ['link', { rel: 'icon', href: '/Tutorial_AwesomeModernCPP/favicon.ico' }], + // 首屏立即应用字号档(从 localStorage 读,默认 medium),防刷新闪烁。 + // 与 FontSizeSwitcher.vue 的 STORAGE_KEY('vp-font-size')保持一致。 + [ + 'script', + {}, + `(function(){try{var s=localStorage.getItem('vp-font-size')||'normal';if(s!=='xxsmall'&&s!=='small'&&s!=='normal'&&s!=='large'&&s!=='xxlarge'){s='normal';}document.documentElement.dataset.fontSize=s;}catch(e){}})()`, + ], ], - markdown: { - lineNumbers: true, - math: true, - theme: { - light: 'github-light', - dark: 'github-dark', - }, - config(md) { - cppTemplateEscapePlugin(md) - md.use(kbdPlugin) - md.use(mermaidPlugin) - }, - }, + markdown: sharedMarkdown, themeConfig: { ...sharedThemeConfig(), diff --git a/site/.vitepress/config/shared.ts b/site/.vitepress/config/shared.ts index a6c72e6b0..14984fe22 100644 --- a/site/.vitepress/config/shared.ts +++ b/site/.vitepress/config/shared.ts @@ -8,6 +8,29 @@ import { getBuildInfo } from './build-info' // 模块加载时算一次,两个 themeConfig 函数共用;同一构建进程内一致。 const buildInfo = getBuildInfo() +// 单一 markdown 配置来源:index.ts(dev/单体 build) 和 scripts/build.ts 分卷构建共用。 +// 改 markdown 只改这一处,避免两份重复配置漏改——languageAlias 曾因此只改了 index.ts、 +// 漏掉 build.ts 走的 sharedBase,导致分卷构建仍刷 Shiki 告警。 +export const sharedMarkdown = { + lineNumbers: true, + math: true, + // ld(GNU linker script)、nasm(NASM 汇编)不在 Shiki 默认 bundle, + // 映射到近似语言,避免 "language not loaded, falling back to txt" 告警刷屏。 + languageAlias: { + ld: 'c', + nasm: 'asm', + }, + theme: { + light: 'github-light', + dark: 'github-dark', + }, + config(md) { + cppTemplateEscapePlugin(md) + md.use(kbdPlugin) + md.use(mermaidPlugin) + }, +} + export const sharedBase = { base: '/Tutorial_AwesomeModernCPP/', cleanUrls: true, @@ -30,21 +53,16 @@ export const sharedBase = { head: [ ['link', { rel: 'icon', href: '/Tutorial_AwesomeModernCPP/favicon.ico' }], + // 首屏立即应用字号档(从 localStorage 读,默认 medium),防刷新闪烁。 + // 与 FontSizeSwitcher.vue 的 STORAGE_KEY('vp-font-size')保持一致。 + [ + 'script', + {}, + `(function(){try{var s=localStorage.getItem('vp-font-size')||'normal';if(s!=='xxsmall'&&s!=='small'&&s!=='normal'&&s!=='large'&&s!=='xxlarge'){s='normal';}document.documentElement.dataset.fontSize=s;}catch(e){}})()`, + ], ], - markdown: { - lineNumbers: true, - math: true, - theme: { - light: 'github-light', - dark: 'github-dark', - }, - config(md) { - cppTemplateEscapePlugin(md) - md.use(kbdPlugin) - md.use(mermaidPlugin) - }, - }, + markdown: sharedMarkdown, } export function sharedThemeConfig(): DefaultTheme.Config { diff --git a/site/.vitepress/theme/components/FontSizeSwitcher.vue b/site/.vitepress/theme/components/FontSizeSwitcher.vue new file mode 100644 index 000000000..809075293 --- /dev/null +++ b/site/.vitepress/theme/components/FontSizeSwitcher.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/site/.vitepress/theme/custom.css b/site/.vitepress/theme/custom.css index e26c0ea0b..90f4f5faa 100644 --- a/site/.vitepress/theme/custom.css +++ b/site/.vitepress/theme/custom.css @@ -1,35 +1,128 @@ +/* ================================================================ + 正文字号五档(超小/小/正常/大/超大)—— 由 FontSizeSwitcher 切 documentElement 的 data-font-size。 + 默认「正常」(zoom 1);往下为小/超小,往上为大/超大。 + 用 CSS zoom 整页缩放:rem + 所有 px 字号(自定义组件 + VitePress 默认)一并跟随, + 等同浏览器 Ctrl+/- 原生缩放,避免「只有正文跳动、组件不动」的割裂感。 + 首屏由 config head 内联脚本提前设 data-font-size(默认 normal),避免刷新闪烁。 + 注:zoom 连尺寸/间距一起按比例缩放(2023 起全主流浏览器支持,含 Firefox 109+)。 + ================================================================ */ +html[data-font-size='xxsmall'] { + zoom: 0.85; +} + +html[data-font-size='small'] { + zoom: 0.92; +} + +html[data-font-size='normal'] { + zoom: 1; +} + +html[data-font-size='large'] { + zoom: 1.08; +} + +html[data-font-size='xxlarge'] { + zoom: 1.16; +} + /* Chinese Typography */ .vp-doc { line-height: 1.85; - font-size: 0.82rem; + font-size: 1.05rem; + /* 正文 0.82rem≈13px 偏小 → 0.95rem≈15px,1080p+ 屏阅读更舒适 */ +} + +.vp-doc h1 { + line-height: 1.4; +} + +.vp-doc h2 { + line-height: 1.45; } -.vp-doc h1 { line-height: 1.4; } -.vp-doc h2 { line-height: 1.45; } -.vp-doc h3 { line-height: 1.5; } -.vp-doc h4 { line-height: 1.55; } +.vp-doc h3 { + line-height: 1.5; +} + +.vp-doc h4 { + line-height: 1.55; +} .vp-doc p { margin-bottom: 1.2em; } -/* Content Width */ +/* Content Width —— 整体布局宽度,它决定文档正文区的最终宽度。 + VitePress 用它算 VPContent 的左右 padding,正文区天花板 ≈ + layout − 272(目录栏) − 256(大纲栏) − 128(各层 padding)。 + 取 1560 → 天花板 ≈ 904px(中文 ~53 字/行,舒适),正文撑满即无缝; + 1920 屏两侧留白 ~180px(仍小于 VitePress 默认 1440 的留白)。 + 想让正文更宽就调大此值,代价是正文行变长。 */ :root { - --vp-layout-max-width: 90rem; + --vp-layout-max-width: 1560px; +} + +/* 文档正文列:让它撑满父级 content 区,从而无缝。 + 父级天花板 ≈ layout(1560) − 272(目录栏) − 256(大纲栏) − 128(padding) ≈ 904px, + 所以 max-width 只要 ≥ 904 就会撑满;实际正文宽度由父级链决定,这里给 1380 留足余量。 + (之前设 1300 / 1700 看着一样,正是因为都 ≥ 天花板,渲染都卡在父级宽度上。) + 三类选择器叠加特异性,稳压 VitePress 默认的 has-aside / 无 aside 规则。 */ +.VPDoc .content-container, +.VPDoc.has-aside .content-container, +.VPDoc:not(.has-aside) .content-container { + max-width: 1380px; +} + +/* ================================================================ + 侧栏 / 大纲 / 首页卡片 —— 字号随正文整体放大 + 正文 1.05rem(≈16.8px);侧栏、大纲、卡片按层级略小,避免全局偏小。 + ================================================================ */ + +/* 左侧目录栏:略小于正文,作为导航层级(默认 14px 偏小) */ +.VPSidebar .link { + font-size: 1.2rem; +} + +/* 右侧大纲:辅助导航,再小一档(默认 14px) */ +.VPDocAsideOutline .outline-link { + font-size: 1.1rem; +} + +/* 首页 feature 卡片:标题比正文醒目,描述对齐正文档(默认 16/14px 偏小) */ +.VPFeature .title { + font-size: 1.125rem; +} + +.VPFeature .details { + font-size: 0.95rem; +} + +/* ================================================================ + 移动端代码块:取消 VitePress 默认 margin:16px -24px 的出血 + (<640px 时代码块左右各扩 24px 顶到视口边缘,长代码右侧被遮)。 + 改为不出血 + 恢复四角圆角,长行靠 overflow-x:auto 横滚。 + ================================================================ */ +@media (max-width: 639px) { + .vp-doc div[class*='language-'] { + margin: 16px 0; + border-radius: 8px; + } } /* Code Blocks */ .vp-doc div[class*='language-'] { border-radius: 8px; - font-size: 0.75rem; + font-size: 0.96rem; + /* 代码块 0.75rem→0.86rem≈13.8px,随正文等比放大 */ line-height: 1.6; } .vp-doc div[class*='language-'] code { - font-size: 0.75rem; + font-size: 0.96rem; } -.vp-doc :not(pre) > code { +.vp-doc :not(pre)>code { font-size: 0.78em; padding: 0.15em 0.35em; border-radius: 4px; @@ -37,7 +130,8 @@ /* Tables */ .vp-doc table { - font-size: 0.78rem; + font-size: 0.88rem; + /* 表格 0.78rem→0.88rem≈14px,随正文等比放大 */ line-height: 1.6; border-radius: 8px; overflow: hidden; @@ -541,23 +635,19 @@ html.dark .online-compiler-demo__source-highlight .shiki span { position: absolute; inset: 0; z-index: -1; - background: linear-gradient( - 135deg, - var(--vp-c-brand-soft) 0%, - transparent 50%, - var(--vp-c-indigo-soft) 100% - ); + background: linear-gradient(135deg, + var(--vp-c-brand-soft) 0%, + transparent 50%, + var(--vp-c-indigo-soft) 100%); opacity: 0.5; pointer-events: none; } .VPHero .name.clip { - background: linear-gradient( - 135deg, - var(--vp-c-brand-1) 0%, - var(--vp-c-indigo-1) 50%, - var(--vp-c-purple-1) 100% - ); + background: linear-gradient(135deg, + var(--vp-c-brand-1) 0%, + var(--vp-c-indigo-1) 50%, + var(--vp-c-purple-1) 100%); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; @@ -602,6 +692,7 @@ html.dark .online-compiler-demo__source-highlight .shiki span { } @media (min-width: 960px) { + /* Desktop two-column hero: image column fills remaining width, card centered. */ .VPHero .image { flex-grow: 1; @@ -622,12 +713,26 @@ html.dark .online-compiler-demo__source-highlight .shiki span { /* ── Proof 条:移动端(<960,hero 堆叠)夹在标题与终端之间;桌面端(≥960)仍在 hero 下方 ── 两个实例按视口切换显隐。移动端那条放在 home-hero-actions-after(标题块末尾、终端之前), 底边距归零——终端的 margin-top 提供 proof→终端 的间隙。 */ -.proof-on-mobile { display: none; } -.proof-on-desktop { display: block; } -.proof-on-mobile .proof-strip { margin-bottom: 0 !important; } +.proof-on-mobile { + display: none; +} + +.proof-on-desktop { + display: block; +} + +.proof-on-mobile .proof-strip { + margin-bottom: 0 !important; +} + @media (max-width: 959px) { - .proof-on-mobile { display: block; } - .proof-on-desktop { display: none; } + .proof-on-mobile { + display: block; + } + + .proof-on-desktop { + display: none; + } } /* ── Feature Card Entrance Animation ────────────────────────── */ @@ -637,25 +742,64 @@ html.dark .online-compiler-demo__source-highlight .shiki span { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } } -.VPFeatures .item { animation: feature-fade-up 0.65s cubic-bezier(0.25, 0.46, 0.45, 0.94) both; } -.VPFeatures .item:nth-child(1) { animation-delay: 0ms; } -.VPFeatures .item:nth-child(2) { animation-delay: 78ms; } -.VPFeatures .item:nth-child(3) { animation-delay: 156ms; } -.VPFeatures .item:nth-child(4) { animation-delay: 234ms; } -.VPFeatures .item:nth-child(5) { animation-delay: 312ms; } -.VPFeatures .item:nth-child(6) { animation-delay: 390ms; } -.VPFeatures .item:nth-child(7) { animation-delay: 468ms; } -.VPFeatures .item:nth-child(8) { animation-delay: 546ms; } -.VPFeatures .item:nth-child(9) { animation-delay: 624ms; } -.VPFeatures .item:nth-child(10) { animation-delay: 702ms; } -.VPFeatures .item:nth-child(11) { animation-delay: 780ms; } -.VPFeatures .item:nth-child(12) { animation-delay: 858ms; } +.VPFeatures .item { + animation: feature-fade-up 0.65s cubic-bezier(0.25, 0.46, 0.45, 0.94) both; +} + +.VPFeatures .item:nth-child(1) { + animation-delay: 0ms; +} + +.VPFeatures .item:nth-child(2) { + animation-delay: 78ms; +} + +.VPFeatures .item:nth-child(3) { + animation-delay: 156ms; +} + +.VPFeatures .item:nth-child(4) { + animation-delay: 234ms; +} + +.VPFeatures .item:nth-child(5) { + animation-delay: 312ms; +} + +.VPFeatures .item:nth-child(6) { + animation-delay: 390ms; +} + +.VPFeatures .item:nth-child(7) { + animation-delay: 468ms; +} + +.VPFeatures .item:nth-child(8) { + animation-delay: 546ms; +} + +.VPFeatures .item:nth-child(9) { + animation-delay: 624ms; +} + +.VPFeatures .item:nth-child(10) { + animation-delay: 702ms; +} + +.VPFeatures .item:nth-child(11) { + animation-delay: 780ms; +} + +.VPFeatures .item:nth-child(12) { + animation-delay: 858ms; +} @media (prefers-reduced-motion: reduce) { .VPFeatures .item { @@ -677,16 +821,16 @@ html.dark .online-compiler-demo__source-highlight .shiki span { border-radius: 14px !important; background-color: var(--vp-c-bg) !important; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), - 0 1px 2px rgba(0, 0, 0, 0.06); + 0 1px 2px rgba(0, 0, 0, 0.06); transition: border-color 0.39s ease, - box-shadow 0.39s ease, - transform 0.39s ease; + box-shadow 0.39s ease, + transform 0.39s ease; } .VPFeature.link:hover { border-color: var(--vp-c-brand-1) !important; box-shadow: 0 12px 32px rgba(0, 0, 0, 0.1), - 0 4px 8px rgba(0, 0, 0, 0.06); + 0 4px 8px rgba(0, 0, 0, 0.06); transform: translateY(-4px); } @@ -700,16 +844,14 @@ html.dark .online-compiler-demo__source-highlight .shiki span { width: 48px !important; height: 48px !important; border-radius: 12px !important; - background: linear-gradient( - 135deg, - var(--vp-c-brand-soft) 0%, - var(--vp-c-indigo-soft) 100% - ) !important; + background: linear-gradient(135deg, + var(--vp-c-brand-soft) 0%, + var(--vp-c-indigo-soft) 100%) !important; color: var(--vp-c-brand-1) !important; font-size: 22px !important; transition: background 0.39s ease, - color 0.39s ease, - transform 0.39s ease; + color 0.39s ease, + transform 0.39s ease; } .VPFeature.link:hover .icon { @@ -807,12 +949,12 @@ html.dark .online-compiler-demo__source-highlight .shiki span { background-color: var(--vp-c-bg-elv) !important; border-color: var(--vp-c-border) !important; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2), - 0 1px 2px rgba(0, 0, 0, 0.15); + 0 1px 2px rgba(0, 0, 0, 0.15); } .dark .VPFeature.link:hover { box-shadow: 0 12px 32px rgba(0, 0, 0, 0.3), - 0 4px 8px rgba(0, 0, 0, 0.2); + 0 4px 8px rgba(0, 0, 0, 0.2); } /* ── Responsive ──────────────────────────────────────────────── */ diff --git a/site/.vitepress/theme/index.ts b/site/.vitepress/theme/index.ts index c493cd067..c3658a2ef 100644 --- a/site/.vitepress/theme/index.ts +++ b/site/.vitepress/theme/index.ts @@ -13,6 +13,7 @@ import OnlineCompilerDemo from './components/OnlineCompilerDemo.vue' import HomeHeroVisual from './components/HomeHeroVisual.vue' import ProofStrip from './components/ProofStrip.vue' import HomeRoadmap from './components/HomeRoadmap.vue' +import FontSizeSwitcher from './components/FontSizeSwitcher.vue' import { setupMermaid } from './mermaid-client' import './custom.css' @@ -26,6 +27,8 @@ export default { 'home-features-before': () => h('div', { class: 'home-pre-features' }, [h(ScreenshotCarousel), h(HomeTipBanner)]), 'home-features-after': () => h(HomeRoadmap), + 'nav-bar-content-after': () => h(FontSizeSwitcher), + 'nav-screen-content-after': () => h(FontSizeSwitcher), }) }, setup() {