计算机C语言中的指针是什么?用实例理解指针的用法

影视资讯1小时前发布 jdkjadmin
0 00
计算机C语言中的指针是什么?用实例理解指针的用法

计算机C语言中的指针是什么?用实例理解指针的用法

指针是 C 语言中理解数组、函数参数和动态内存的重要基础。它常被描述为“存放地址的变量”,这个说法有助于入门,但还不够完整:指针具有类型,指针运算受对象边界限制;指针指向的对象还必须处于有效生命周期内。只记住“地址”而忽略这些规则,很容易写出越界访问、悬空指针等错误。

本文从基本概念开始,通过可独立编译运行的示例,说明指针的声明、取地址、解引用、数组关系、函数参数、动态内存和 const 用法,并解释指针的关键边界与安全注意事项。示例使用 C11,可用 GCC 或 Clang 编译,例如 gcc -std=c11 -Wall -Wextra -pedantic demo.c -o demo。

一、指针是什么

程序运行时,变量通常对应一块存储空间。指针变量保存一个对象的地址;指针的类型说明它所指向的对象类型,以及解引用时应如何访问该对象。例如,int * 是指向 int 的指针,char * 是指向 char 的指针。

指针并不是普通整数。C 语言不保证指针能安全地转换成整数,也不保证指针的内部表示、大小或不同类型指针的表示完全相同。日常程序应使用正确的指针类型和标准提供的操作,而不是把指针当作可随意加减的数字。

二、取地址与解引用

运算符 & 用来取得对象的地址;一元运算符 * 用来访问指针所指向的对象,这称为解引用。

#include <stdio.h>

int main(void)
{
    int number = 42;
    int *ptr = &number;

    printf("number = %d\n", number);
    printf("通过指针读取 = %d\n", *ptr);

    *ptr = 100;
    printf("修改后的 number = %d\n", number);

    return 0;
}

ptr 保存 number 的地址,因此 *ptr 指向同一个 int 对象。通过 *ptr = 100 修改的不是指针本身,而是指针指向的对象。此处 ptr 的类型是“指向 int 的指针”,而 *ptr 表示一个 int 左值。

声明时星号属于声明符的一部分。下面两种写法含义相同:

int *a;
int* b;

但在一行声明多个变量时,星号只作用于紧邻的声明符:

int *p, value;  /* p 是指针,value 是 int */

初学者可以每行只声明一个变量,减少误读。没有指向有效对象时,应显式初始化为空指针:

int *ptr = NULL;

NULL 是空指针常量,表示它不指向任何有效对象。不能对空指针解引用;调用 *ptr 前必须确认它确实指向仍然存活且类型匹配的对象。

三、指针适合解决什么问题

1. 让函数修改调用者的数据

C 函数的参数按值传递:函数收到的是实参值的副本。如果希望函数修改调用者持有的变量,可以传入该变量的地址。

#include <stdio.h>

void set_to_zero(int *value)
{
    if (value != NULL) {
        *value = 0;
    }
}

int main(void)
{
    int score = 95;

    set_to_zero(&score);
    printf("%d\n", score);

    return 0;
}

调用 set_to_zero(&score) 时,函数得到一个指向 score 的指针副本;通过它修改的仍是原来的 score。如果参数允许为空,函数可以检查空指针。但检查只能处理“调用者传入空指针”这一种情况,无法证明任意非空指针都有效。

2. 处理数组和连续数据

数组元素在内存中连续排列。在大多数表达式中,数组表达式会转换为指向首元素的指针。因此,函数常通过“指针加元素数量”接收数组数据。元素数量需要单独传递,因为指针本身通常不携带数组长度。

#include <stdio.h>

void print_values(const int *values, size_t count)
{
    for (size_t i = 0; i < count; ++i) {
        printf("%d\n", values[i]);
    }
}

int main(void)
{
    int data[] = { 3, 6, 9, 12 };
    size_t count = sizeof data / sizeof data[0];

    print_values(data, count);
    return 0;
}

这段代码使用了 size_t,应包含 <stddef.h>,或像这里一样包含通常会声明它的标准库头文件;为了明确起见,实际代码可以在开头增加 #include <stddef.h>。函数中的 const int * 表示函数承诺不通过这个指针修改元素;它不代表底层数据在程序的所有位置都不可修改。

函数参数声明中的数组形式,例如 void f(int values[]),会调整为指针参数,效果相当于 void f(int *values)。数组长度不会随参数自动传入。因此应像示例那样另传长度,或通过其他明确的接口约定管理长度。

3. 遍历数组

对同一数组中的元素,可以使用下标,也可以使用指针逐项访问。以下代码展示指针递增的用法:

#include <stdio.h>

int main(void)
{
    int values[] = { 10, 20, 30 };
    size_t count = sizeof values / sizeof values[0];

    for (int *p = values; p < values + count; ++p) {
        printf("%d\n", *p);
    }

    return 0;
}

对于指向数组元素的指针,p + 1 指向下一个元素,而不是把地址简单地增加一个字节。编译器会按所指向类型的大小进行缩放。这个便利只适用于符合语言规则的数组范围内运算,不能据此在任意内存区域中移动指针。

四、指针运算的边界

指针运算有严格边界。对于一个包含 n 个元素的数组,指针可以指向某个实际元素,也可以指向最后一个元素之后的位置。后一种位置称为“尾后指针”,可用于循环终止判断,但不能解引用。

对数组指针加减整数,结果必须仍指向该数组中的元素,或恰好指向尾后位置;否则行为未定义。两个指针相减,必须都指向同一个数组(或尾后位置),结果类型为 ptrdiff_t。比较不相关对象的指针也不应被当作通用排序手段。

下面是合法的边界写法:

int values[] = { 4, 8, 12 };
int *begin = values;
int *end = values + 3;  /* 尾后指针,可以比较,不能解引用 */

for (int *p = begin; p != end; ++p) {
    /* 此处 p 指向有效元素 */
}

即使某个地址看起来位于可访问内存中,越过数组边界进行指针运算或访问,仍可能违反 C 语言规则。不要依靠一次运行“没有崩溃”来判断代码正确。

五、指针与字符串

C 字符串是以空字符 '\0' 结尾的字符序列。字符串字面量具有数组类型,程序不应修改它。把字符串字面量赋给 char * 在语法上可能通过编译,但通过该指针写入字面量的存储区域会产生未定义行为。需要修改时,应使用可写数组。

#include <stdio.h>
#include <string.h>

int main(void)
{
    const char *message = "hello";
    char editable[] = "world";

    printf("%s, %zu\n", message, strlen(message));
    editable[0] = 'W';
    printf("%s\n", editable);

    return 0;
}

strlen 会一直查找结尾的 '\0',所以传入的指针必须指向有效、以空字符结束的字符串;如果没有终止符,函数可能越界读取。处理外部输入或非字符串缓冲区时,应优先使用带长度的接口,并确认长度不超过缓冲区实际容量。

六、指针作为输出参数与返回值

指针参数常用于让函数返回多个结果。例如,函数可以通过返回值报告成功与否,再通过输出参数写回结果。调用者负责提供有效、可写且大小足够的对象。

#include <stdbool.h>
#include <stddef.h>

bool divide_int(int numerator, int denominator, double *result)
{
    if (result == NULL || denominator == 0) {
        return false;
    }

    *result = (double)numerator / denominator;
    return true;
}

这里检查了空指针和除数为零,但函数仍无法验证一个非空 result 是否指向可写的 double。这是 C 接口的重要边界:指针通常不携带对象大小、生命周期或访问权限的运行时证明,接口文档和调用者必须共同遵守约定。

不要返回局部自动变量的地址。函数返回后,局部变量的生命周期已经结束,所得指针成为悬空指针:

int *bad_function(void)
{
    int local = 10;
    return &local; /* 错误:返回后 local 已不再存活 */
}

需要让数据在函数返回后继续存在,应由调用者提供存储空间、使用静态存储期对象,或按明确的所有权约定动态分配内存。静态变量虽然生命周期足够长,但会带来共享状态问题,不应为了避开指针生命周期而随意使用。

七、动态内存:malloc 与 free

当对象大小要在运行时决定,或需要跨越当前函数调用存在时,可以使用动态内存分配。malloc 在成功时返回一块未初始化存储的指针,失败时返回空指针。调用者需要检查失败,并在不再使用后释放内存。

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    size_t count = 5;
    int *items = malloc(count * sizeof *items);

    if (items == NULL) {
        fprintf(stderr, "内存分配失败\n");
        return 1;
    }

    for (size_t i = 0; i < count; ++i) {
        items[i] = (int)(i * 10);
    }

    for (size_t i = 0; i < count; ++i) {
        printf("%d\n", items[i]);
    }

    free(items);
    items = NULL;

    return 0;
}

在 C 中,malloc 返回的 void * 可以隐式转换为对象指针,因此不需要写强制类型转换。sizeof *items 按元素类型计算大小,修改指针所指类型时比重复写类型名更不容易出错。

分配数组时还要考虑乘法溢出。若 count * sizeof *items 超出 size_t 能表示的范围,计算结果可能变小,导致分配空间不足。对来自外部的数据,应先验证数量,例如:

#include <stdint.h>
#include <stddef.h>
#include <stdlib.h>

if (count > SIZE_MAX / sizeof *items) {
    /* 数量过大,不能安全计算所需字节数 */
} else {
    items = malloc(count * sizeof *items);
}

释放规则同样重要:

  • 只能把动态分配函数返回、且尚未释放的分配块起始指针传给 free;不能释放栈变量地址、数组内部地址或同一块内存的第二次副本。
  • 每块动态内存应恰好释放一次。遗漏释放会造成内存泄漏;重复释放或释放无效指针会导致未定义行为。
  • 释放后,所有指向该块内存的指针都不再可用。把其中一个变量设为 NULL,不会自动清除其他别名。
  • 只有由兼容的分配函数取得的内存,才按对应规则释放。不要把 malloc 得到的地址交给不匹配的释放机制。

八、const 与指针的几种组合

const 放在指针声明的不同位置,约束对象不同。读声明时,可以先从变量名向外理解:

const int *p;        /* 指向 const int 的指针:不能通过 p 修改所指对象 */
int *const q = NULL; /* const 指针:q 不能改指向,所指 int 可修改 */
const int *const r = NULL; /* 指针和所指对象都不能通过该声明修改 */

第一种形式常用于只读输入参数,例如 void print_values(const int *values, size_t count)。它限制的是通过 p 进行的修改,不一定意味着对象本身在别处也不可修改。第二种形式要求初始化时确定指向位置,此后不能给 q 赋另一个地址。

const 不能替代边界检查,也不会自动保证指针有效。一个指向 const int 的悬空指针仍然是悬空指针。

九、void 指针与函数指针

void * 是通用对象指针类型,可用于保存对象指针并在需要时转换回正确的对象指针类型。例如,标准库 malloc 就返回 void *。但 void * 没有具体对象类型,不能直接解引用,也不能对它进行标准 C 的指针算术;应先转换为适当的对象指针类型。

函数指针则指向函数,不是对象指针。它可用于回调、排序比较函数或运行时选择操作,但不能与 void * 混为一谈。ISO C 不保证对象指针与函数指针可以互相转换。下面是一个简单的函数指针示例:

#include <stdio.h>

int add(int a, int b)
{
    return a + b;
}

int main(void)
{
    int (*operation)(int, int) = add;

    printf("%d\n", operation(2, 3));
    return 0;
}

十、常见错误与排查办法

1. 未初始化就解引用

未初始化的自动指针可能含有不确定值。它不一定是空指针,直接解引用可能访问任意位置。声明时应初始化为有效对象地址或 NULL,并在使用前建立有效性条件。

2. 对空指针解引用

NULL 表示不指向对象,不能用 *ptr 读取或写入。检查指针是否为空并不能证明非空指针有效,但它能避免这一类明显错误。

3. 返回局部变量地址或保留过期地址

对象生命周期结束后,指向它的指针不再可用。局部变量、已释放内存、已经结束生命周期的复合字面量等都需要关注生命周期。指针变量本身还在,并不代表它指向的对象还在。

4. 越界访问

数组下标 i 只有在 0 <= i < count 时才可用于访问该数组元素。尾后指针只能用于形成边界、比较或循环终止,不能解引用。应把长度作为接口的一部分,并在每次访问前保证下标范围有效。

5. 忘记字符串终止符或缓冲区容量

写入字符串时不仅要计算字符数,还要为结尾的 '\0' 留空间。使用长度受限函数也不能自动保证正确:调用者仍需提供真实缓冲区容量,并正确处理截断和返回值。

6. 误以为修改指针参数会修改调用者的指针

指针参数仍然是按值传递。函数中给形参 p 重新赋值,只会改变形参副本,不会自动改变调用者的指针变量。若函数要修改调用者的指针,通常需要传入指针的地址,即使用二级指针,例如 int **;也可以改用返回值传回新指针。

7. 混淆指针与指针所指对象

p = other 改变指针指向;*p = value 修改所指对象。遇到复杂声明时,可以拆成两步理解:先看指针类型,再看解引用表达式的类型。

十一、指针与安全:能做什么,不能做什么

指针让 C 能直接操作对象、组织动态数据结构,并把数组或缓冲区交给函数处理;同时,它也把许多责任留给程序员。C 语言不会在每次指针访问时自动验证对象是否存在、数组有多长、指针是否已释放,很多越界访问、悬空访问和类型使用错误会造成未定义行为。编译器警告、运行时检查和测试有帮助,但不能替代正确的接口设计。

安全使用指针时,可以遵循以下做法:

  • 让接口明确表达长度、容量、所有权和可空性。指针与长度通常应成对传递,并写清楚函数是否会保留该指针。
  • 检查外部输入和长度计算,特别注意加法、乘法溢出,以及分配失败。
  • 明确谁负责释放内存;释放后避免继续使用旧指针,避免多个所有者各自释放同一对象。
  • 优先使用已知长度的操作,避免对不确定是否以空字符结束的数据调用字符串函数。
  • 启用编译器警告,结合 AddressSanitizer、UndefinedBehaviorSanitizer 等工具发现部分越界、释放后使用等问题。工具能发现一部分错误,不代表未报告的代码就必然正确。

指针本身不是安全边界或权限控制机制。它不加密数据、不验证访问者身份,也不会让任意地址访问变得安全。把指针设为 NULL、隐藏地址或使用 const,都不能单独构成可靠的安全防护。安全性应建立在边界检查、生命周期管理、接口约束和适当的系统权限设计之上。

十二、总结

理解指针可以先抓住四件事:指针有类型;& 取得对象地址;* 访问指向的对象;指针使用必须满足对象生命周期和边界规则。数组传参时长度需要明确传递,动态内存需要检查分配结果并配对释放,函数返回的指针必须指向仍然有效的对象。

写指针代码时,不妨逐项自查:它指向什么类型的对象?对象是否仍在生命周期内?当前访问是否落在有效范围?这块内存由谁负责释放?这些问题有明确答案,指针代码通常就更容易理解、测试和维护。

© 版权声明

相关文章