Skip to content
  • 指针是 C 语言中最重要的概念之一,也是最难以理解的概念之一。
  • 指针是 C 语言的精髓,要想掌握 C 语言就需要深入地了解指针。

第一章:颇具争议的指针

1.1 概述

  • 目前而言,操作系统几乎都是通过 C 语言来编写和维护的;而 C 语言提供了指针的用法,其能直接操作内存地址,是个非常强大灵活的工具;但是,需要开发者小心谨慎的使用,以确保程序的稳定性和安全性。

NOTE

之所以指针在 C 语言中颇具争议,是因为一方面其功能强大,直接操作内存地址;另一方面,又很危险,不正确的使用指针的方式,非常容易导致程序崩溃。

  • 如果没有能很好的使用指针,就会带来一系列的问题,如:

    • 空指针引用(Null Pointer Dereference):当一个指针没有正确初始化或者被赋予了空(NULL)值时,如果程序尝试访问该指针所指向的内存,会导致运行时错误,甚至导致程序崩溃。
    • 野指针(Dangling Pointers):指针指向的内存地址曾经分配给某个变量或对象,但后来该变量或对象被释放或者移动,导致指针仍指向已经无效的内存位置。对野指针进行操作可能会导致未定义的行为或程序崩溃。
    • 指针算术错误:在进行指针运算时,如果没有正确管理指针的偏移量或者超出了数组的边界,可能会导致指针指向错误的内存位置,从而影响程序的正确性和安全性。
    • 内存泄漏:如果动态分配的内存通过指针分配,但在不再需要时没有正确释放,会导致内存泄漏,长时间运行的程序可能会耗尽系统资源。
  • 为了减少指针带来的风险,开发人员可以采取以下的措施:

    • 良好的编程实践:确保指针的初始化和使用是安全的,避免空指针引用和野指针问题。
    • 边界检查:在进行指针运算时,始终确保不会超出数组或内存分配的边界。
    • 使用指针和引用的适当性:在可能的情况下,可以考虑使用更安全的语言特性,如:引用(在 C++ 等编程语言中)或者更高级别的数据结构来代替裸指针,从而减少指针使用时的潜在风险。

IMPORTANT

  • ① 既然指针很危险,那么通过一系列的手段将指针包装或屏蔽,以达到程序安全的目的(这是现代化的高级编程语言解决的思路,如:Java、Go、Rust 等)。
  • ② 之所以,指针还需要学习,是因为在嵌入式等领域,其机器的资源(CPU、内存等)非常有限;而现代化的高级编程语言虽然安全,但是需要的系统资源也庞大。
  • ③ 我们知道,编译型的程序不管编译过程如何复杂,至少需要两步:编译和运行。通常,我们也将这两步称为编译期和运行期。C 语言中的指针之所以危险就在于程序要在运行的时候才会发现问题(后知后觉);而现代化的高级编程语言中的编译器在程序编译的时候就会发现问题(提前发现问题)。
  • ④ C 语言的编译器之所以这么设计的原因,就在于当时的内存和 CPU 是非常有限(PDP-7 早期小型计算机,CPU:18 bit 的电子管逻辑,内存:4kb )和昂贵(72,000 $),如果加入安全限制的功能,会远远超过整个系统的资源。

1.2 现代化高级编程语言是如何解决指针危险的?

  • C++采用了如下的策略和机制,来解决指针危险操作的:

    • 智能指针: C++ 引入了智能指针(如std::shared_ptrstd::unique_ptr),这些指针提供了自动资源管理和所有权的语义。std::unique_ptr确保只有一个指针可以访问给定的资源,从而避免了传统指针的悬空引用和内存泄漏问题。std::shared_ptr允许多个指针共享一个资源,并在所有引用释放后自动释放。
    • 引用: C++ 中的引用(如:&符号)提供了更安全的间接访问方法,与指针相比,引用不能重新绑定到不同的对象,从而减少了意外的指针错误。
  • Go采用了如下的策略和机制,来解决指针危险操作的:

    • 内存管理和垃圾回收: Go 语言通过自动垃圾回收器管理内存,减少了手动内存管理所带来的指针操作错误。Go 的垃圾回收器定期扫描并释放不再使用的内存,避免了内存泄漏和悬空指针问题。
    • 指针的安全性: Go 语言的指针是受限的,不支持指针运算,从而减少了指针操作可能带来的风险。
  • Rust采用了如下的策略和机制,来解决指针危险操作的:

    • 所有权和借用: Rust 引入了所有权和借用的概念,编译器在编译时静态分析所有权转移和引用的生命周期。这种机制避免了数据竞争和空指针解引用等运行时错误,使得在编译时就能够保证内存安全。
    • 生命周期: Rust 的生命周期系统确保引用的有效性和安全性,防止了悬空引用和指针乱用。
  • Java采用了如下的策略和机制,来解决指针危险操作的:

    • 引用类型和自动内存管理: Java 中所有的对象引用都是通过引用来访问的,而不是直接的指针。Java 的自动垃圾回收器负责管理内存,从而避免了手动内存管理可能导致的指针错误,如:内存泄漏和悬空指针。
    • 强类型系统和异常处理: Java 的强类型系统和异常处理机制减少了指针操作带来的风险,如:空指针解引用异常(NullPointerException)。编译器在编译时能够捕获许多潜在的类型错误,进一步增强了程序的安全性和可靠性。

IMPORTANT

总而言之,各种编程语言通过引入不同的语义和机制,如:智能指针、垃圾回收器、所有权和借用,以及强类型系统,有效地减少了指针操作所带来的各种安全性和可靠性问题,提升了程序的稳定性和开发效率。

第二章:回顾知识

2.1 变量

  • 变量就是保存程序运行过程中临时产生的值,其语法如下:
c
数据类型 变量名 = 值 ;

IMPORTANT

变量名(标识符)需要符合命名规则和命名规范!!!

  • 强制规范:
    • ① 只能由小写大写英文字母0-9_ 组成。
    • ② 不能以数字开头。
    • ③ 不可以是关键字
    • ④ 标识符具有长度限制,不同编译器和平台会有所不同,一般限制在 63 个字符内。
    • ⑤ 严格区分大小写字母,如:Hello、hello 是不同的标识符。
  • 建议规范:
    • ① 为了提高阅读性,使用有意义的单词,见名知意,如:sum,name,max,year 等。
    • ② 使用下划线连接多个单词组成的标识符,如:max_classes_per_student 等。
    • ③ 多个单词组成的标识符,除了使用下划线连接,也可以使用小驼峰命名法,除第一个单词外,后续单词的首字母大写,如: studentId、student_name 等。
    • ④ 不要出现仅靠大小写区分不同的标识符,如:name、Name 容易混淆。
    • ⑤ 系统内部使用了一些下划线开头的标识符,如:C99 标准添加的类型 _Bool,为防止冲突,建议开发者尽量避免使用下划线开头的标识符。
  • 变量名作用,如下所示:
    • ① 当我们编写代码的时候,使用变量名关联某块内存的地址
    • ② 当 CPU 执行的时候,会将变量名替换为具体的地址,再进行具体的操作。

2.2 普通变量和指针变量的区别

  • 根据变量存储不同,我们可以将变量分为两类:
    • 普通变量:变量所对应的内存中存储的是普通值
    • 指针变量:变量所对应的内存中存储的是另一个变量的地址
  • 如下图所示:

img

  • 普通变量和指针变量的相同点,如下所示:
    • ① 普通变量有内存空间,指针变量也有内存空间。
    • ② 普通变量有内存地址,指针变量也有内存地址。
    • ③ 普通变量所对应的内存空间中有值,指针变量所对应的内存空间中也有值。
  • 普通变量和指针变量的不同点:
    • 普通变量所对应的内存空间存储的是普通的值,如:整数、小数、字符等;指针变量所对应的内存空间存储的是另外一个变量的地址
    • 普通变量有普通变量的运算方式,而指针变量有指针变量的运算方式(后续讲解)。

2.3 运算符

2.3.1 概述

  • 运算符是一种特殊的符号,用于数据的运算、赋值和比较等。
  • 表达式指的是一组运算数、运算符的组合,表达式一定具有值,一个变量或一个常量可以是表达式,变量、常量和运算符也可以组成表达式,如:

img

  • 操作数指的是参与运算或者对象,如:

  • 根据操作数个数,可以将运算符分为:
    • 一元运算符(一目运算符)。
    • 二元运算符(二目运算符)。
    • 三元运算符(三目运算符)。
  • 根据功能,可以将运算符分为:
    • 算术运算符。
    • 关系运算符(比较运算符)。
    • 逻辑运算符。
    • 赋值运算符。
    • 逻辑运算符。
    • 位运算符。
    • 三元运算符。

NOTE

掌握一个运算符,需要关注以下几个方面:

  • ① 运算符的含义。
  • ② 运算符操作数的个数。
  • ③ 运算符所组成的表达式。
  • ④ 运算符有无副作用,即:运算后是否会修改操作数的值。

IMPORTANT

普通变量支持上述的所有运算符;而指针变量并非支持上述的所有运算符,且支持运算符的含义和普通变量相差较大!!!

2.3.2 运算符的优先级

  • C 语言中运算符的优先级,如下所示:
优先级运算符名称或含义结合方向
1[]数组下标➡️(从左到右)
()圆括号
.成员选择(对象)
->成员选择(指针)
2-负号运算符⬅️(从右到左)
(类型)强制类型转换
++自增运算符
--自减运算符
*取值运算符
&取地址运算符
!逻辑非运算符
~按位取反运算符
sizeof长度运算符
3/➡️(从左到右)
*
%余数(取模)
4+➡️(从左到右)
-
5<<左移➡️(从左到右)
>>右移
6>大于➡️(从左到右)
>=大于等于
<小于
<=小于等于
7==等于➡️(从左到右)
!=不等于
8&按位与➡️(从左到右)
9^按位异或➡️(从左到右)
10|按位或➡️(从左到右)
11&&逻辑与➡️(从左到右)
12||逻辑或➡️(从左到右)
13?:条件运算符⬅️(从右到左)
14=赋值运算符⬅️(从右到左)
/=除后赋值
*=乘后赋值
%=取模后赋值
+=加后赋值
-=减后赋值
<<=左移后赋值
>>=右移后赋值
&=按位与后赋值
^=按位异或后赋值
|=按位或后赋值
15,逗号运算符➡️(从左到右)

WARNING

  • ① 不要过多的依赖运算符的优先级来控制表达式的执行顺序,这样可读性太差,尽量使用小括号来控制表达式的执行顺序。
  • ② 不要把一个表达式写得过于复杂,如果一个表达式过于复杂,则把它分成几步来完成。
  • ③ 运算符优先级不用刻意地去记忆,总体上:一元运算符 > 算术运算符 > 关系运算符 > 逻辑运算符 > 三元运算符 > 赋值运算符。

IMPORTANT

  • ① 取值运算符 * 和取地址运算符 & 的优先级相同,并且运算方向都是从右向左!!!
  • ② 逗号运算符 , 的优先级最低,并且运算方向是从左向右!!!

第三章:环境切换为 WSL(⭐)

3.1 概述

  • 前文说过,C 语言中的指针是否使用是个颇具争议的话题,现代化的高级编程语言通过各种策略和机制,在编译期就能解决指针危险的问题。但是,遗憾的是,C 语言的指针很大程度上,在运行期才会暴露问题。

  • 幸运的是,我们可以使用 Valgrind 项目来进行内存泄露检测性能分析,而 Valgrind 只支持 Linux 。

NOTE

win 11 中的 WSL2 就是个 Linux 环境,我们可以在上面跑各种 Linux 工具,这样我们就不需要再安装虚拟机软件了,如:VMware Workstation(它会完整的模拟一个硬件系统,并在上面跑各种 Linux ,实在是太笨重了)。

3.2 使用步骤

  • 在 WSL2 上安装 Valgrind :
shell
dnf -y upgrade && dnf -y install valgrind # AlmaLinux
shell
apt -y update && apt -y upgrade && apt -y install valgrind # Ubuntu

  • 查看 valgrind 可执行文件的安装位置:
shell
which valgrind

  • CLion 中将工具链设置为 WSL2 :

  • CLion 中配置 valgrind 的路径:

  • 查看 WSL2 中 cmake 的版本:
shell
cmake --version

  • 修改项目中 CMakeLists.txt 中 cmake 的版本:
cmake_minimum_required(VERSION 3.26.5) # 3.26.5

# 项目名称和版本号
project(c-study VERSION 1.0 LANGUAGES C)

# 设置 C 标准
set(CMAKE_C_STANDARD 23)
set(CMAKE_C_STANDARD_REQUIRED True)

# 辅助函数,用于递归查找所有源文件
function(collect_sources result dir)
    file(GLOB_RECURSE new_sources "${dir}/*.c")
    set(${result} ${${result}} ${new_sources} PARENT_SCOPE)
endfunction()

# 查找顶层 include 目录(如果存在)
if (EXISTS "${CMAKE_SOURCE_DIR}/include")
    include_directories(${CMAKE_SOURCE_DIR}/include)
endif ()

# 查找所有源文件
set(SOURCES)
collect_sources(SOURCES ${CMAKE_SOURCE_DIR})

# 用于存储已经处理过的可执行文件名,防止重复
set(EXECUTABLE_NAMES)

# 创建可执行文件
foreach (SOURCE ${SOURCES})
    # 获取文件的相对路径
    file(RELATIVE_PATH REL_PATH ${CMAKE_SOURCE_DIR} ${SOURCE})
    # 将路径中的斜杠替换为下划线,生成唯一的可执行文件名
    string(REPLACE "/" "_" EXECUTABLE_NAME ${REL_PATH})
    string(REPLACE "\\" "_" EXECUTABLE_NAME ${EXECUTABLE_NAME})
    string(REPLACE "." "_" EXECUTABLE_NAME ${EXECUTABLE_NAME})

    # 处理与 CMakeLists.txt 文件同名的问题
    if (${EXECUTABLE_NAME} STREQUAL "CMakeLists_txt")
        set(EXECUTABLE_NAME "${EXECUTABLE_NAME}_exec")
    endif ()

    # 检查是否已经创建过同名的可执行文件
    if (NOT EXECUTABLE_NAME IN_LIST EXECUTABLE_NAMES)
        list(APPEND EXECUTABLE_NAMES ${EXECUTABLE_NAME})

        # 创建可执行文件
        add_executable(${EXECUTABLE_NAME} ${SOURCE})

        # 查找源文件所在的目录,并添加为包含目录(头文件可能在同一目录下)
        get_filename_component(DIR ${SOURCE} DIRECTORY)
        target_include_directories(${EXECUTABLE_NAME} PRIVATE ${DIR})

        # 检查并添加子目录中的 include 目录(如果存在)
        if (EXISTS "${DIR}/include")
            target_include_directories(${EXECUTABLE_NAME} PRIVATE ${DIR}/include)
        endif ()

        # 检查并添加 module 目录中的所有 C 文件(如果存在)
        if (EXISTS "${DIR}/module")
            file(GLOB_RECURSE MODULE_SOURCES "${DIR}/module/*.c")
            target_sources(${EXECUTABLE_NAME} PRIVATE ${MODULE_SOURCES})
        endif ()
    endif ()
endforeach ()
  • 在 CLion 中正常运行代码:

  • 在 CLion 中通过 valgrind 运行代码:

第四章:指针的理解和定义(⭐)

4.1 概述

第五章:指针的运算(⭐)

5.1 概述

5.2 总结

  • 在 C 语言中,普通变量是直接存储数据变量。对于普通变量,支持的操作包括:

    • 赋值操作:给变量赋值,如:int a = 5
    • 算术运算:可以对数值类型的普通变量进行加、减、乘、除等运算,如:a + ba - ba * ba / b
    • 关系运算:可以进行比较运算(大于、小于、等于等),如: a > ba == b
    • 逻辑运算:对布尔类型的值进行与、或、非运算,如: a && ba || b!a
    • 位运算:对整数类型的值进行位操作(与、或、异或、取反、左移、右移等),如: a & ba | ba ^ b~aa << 2a >> 2
    • 自增自减运算a++--a 等。
  • 在 C 语言中,指针变量存储的是另一个变量地址。对于指针变量,支持的操作包括:

    • 赋值操作:可以将一个地址赋值给指针,如: int *p = &a;
    • 解引用操作:通过指针访问它指向的变量,如: *p = 10; 修改指向变量的值。
    • 指针运算
      • 指针和整数值的加减运算:指针可以进行整数的加减运算,用于访问数组或结构体成员,如: p + 1
      • 指针的自增和自减运算:指的是内存地址的向前或向后移动,如:p++p--
      • 指针间的相减运算:两个指向同一数组的指针相减可以得到它们之间的元素个数,如: ptr1 - ptr2
      • 指针间的比较运算:可以比较两个指针,如 p1 == p2p1 != p2
    • 数组访问:指针可以用于访问数组中的元素,通过 *(p + i) 访问第 i 个元素。
    • 指向指针的指针:可以声明指向指针的指针,即多级指针,如:int **pp = &p;

WARNING

在使用指针时,务必小心避免野指针和内存泄漏等问题。

在C语言中,同类指针相减的结果是一个整数,它表示两个指针之间相隔多少个指向的对象单位,而不是它们在内存中的字节偏移量。这种对象单位是指针所指向的具体类型的大小。

举个例子来说,如果你有两个指向整数数组元素的指针 pq,那么 p - q 的结果将是 p 指向的数组元素的索引与 q 指向的数组元素索引之间的差值。这个差值代表了在数组中相隔多少个整数元素,而不是它们在内存中的字节偏移量。

这种设计的优势在于,它使得指针运算更加直观和便于理解,特别是在处理数组和其他连续存储的数据结构时。因为指针运算结果的单位是根据指针所指向的具体类型来计算的,这样可以确保不同平台上的程序行为是一致的,不会受到底层硬件架构或者字节对齐规则的影响。

Released under the MIT License.