前端學習C語言

2023-06-25 18:01:07

初級指標

本篇主要介紹:指標和變數的關係、指標型別、指標的運運算元、空指標和野指標、指標和陣列指標和字串、const 和指標、以及gdb 偵錯段錯誤

基礎概念

指標是一種特殊的變數。存放地址的變數就是指標。

int num = 1; 會申請4個位元組的記憶體來存放數位1,每次存取 num 就是存取這4個位元組。

存取記憶體中的這4個位元組,不僅可以通過名稱(例如 num),還可以通過地址

Tip& 不僅是位運運算元,還是取地址操作符。例如 int* ptr = #,就是取變數 num 的地址並將其儲存到指標變數 ptr 中

請看範例:

#include <stdio.h>

int main() {
    int num = 10;

    // num 的地址:0x7fff4dbf01d8
    printf("num 的地址:%p\n", &num);
    // num 的地址加1 :0x7fff4dbf01dc。
    printf("num 的地址加1 :%p\n", &num + 1);

    // j 存放連續記憶體的第一個位元組地址
    int *j = &num;

    // 10。通過地址存取
    printf("%d", *j);

    return 0;
}

&num&num + 1 相差4個位元組,說明 &num 表示整數。

普通變數存放值,而指標用於存放地址。

通過 int *j = &num 將變數num的首地址給到指標 j(j的型別是 int *),最後通過地址(*j) 存取整數1。

int *j 是一個int型別的指標,還有 char、float等指標型別。指標型別必須匹配,比如將 j 的指標型別換成 char,則會警告。就像這樣:

- int *j = &num;
+ char *j = &num;

執行:

/workspace/CProject-test/main.c:12:11: warning: incompatible pointer types initializing 'char *' with an expression of type 'int *' [-Wincompatible-pointer-types]
    char *j = &num;
          ^   ~~~~
1 warning generated.
num 的地址:0x7ffddcfe5328
num 的地址加1 :0x7ffddcfe532c
10

Tip: 指標 j 也有地址,也就是指標的指標。現在不研究

練習

題目:請問輸出什麼?

#include <stdio.h>

int main() {
    int num = 10;
    int *p = &num;
    printf("用指標存取資料 num :%d\n", *p);

    *p = 11;
    printf("用過指標修改 num 資料:%d\n", num);

    return 0;
}

提示:資料可以通過變數存取,也能使用地址(指標)存取。就像通知同學去嵌入式實驗室上課,或者是 303 上課。其中*p = 11; 等價於 num = 11;

輸出:

用指標存取資料 num :10
用過指標修改 num 資料:11

星號的作用

指標 * 有兩個主要作用(根據* 前面有無型別做區分):

  • 指標型別宣告
  • 取值(又稱解除參照操作符)。例如,*ptr 表示獲取指標變數 ptr 所指向記憶體地址上的值。

請看範例:

#include <stdio.h>

int main() {
    int num = 10;
    // 指標型別宣告
    int *p = &num;

    // 取值
    printf("%d\n", *p); // 10

    // 取值
    *p = 11;
    printf("%d\n", num); // 11

    return 0;
}

指標型別

所佔位元組

在32位元系統上,指標通常佔用4個位元組;而在64位元系統上,指標通常佔用8個位元組。請看範例:

#include <stdio.h>

int main() {
    printf("char型別指標所佔位元組數為:%zu\n", sizeof(char*));
    printf("short型別指標所佔位元組數為:%zu\n", sizeof(short*));
    printf("int型別指標所佔位元組數為:%zu\n", sizeof(int*));
    printf("long型別指標所佔位元組數為:%zu\n", sizeof(long*));
    printf("float型別指標所佔位元組數為:%zu\n", sizeof(float*));
    printf("double型別指標所佔位元組數為:%zu\n", sizeof(double*));
    printf("long long型別指標所佔位元組數為:%zu\n", sizeof(long long*));
    return 0;
}

輸出:

char型別指標所佔位元組數為:8
short型別指標所佔位元組數為:8
int型別指標所佔位元組數為:8
long型別指標所佔位元組數為:8
float型別指標所佔位元組數為:8
double型別指標所佔位元組數為:8
long long型別指標所佔位元組數為:8

練習

題目:請問整數型別的指標和字元型別的指標加1分別是幾個位元組?

#include <stdio.h>

int main() {
    int num = 10;

    printf("num 的地址:%p\n", &num);
    printf("num 的地址加1 :%p\n", &num + 1);

    char ch = 'a';

    printf("ch 的地址:%p\n", &ch);
    printf("ch 的地址加1 :%p\n", &ch + 1);
    return 0;
}

輸出:

num 的地址:0x7fffe8244288
num 的地址加1 :0x7fffe824428c
ch 的地址:0x7fffe8244287
ch 的地址加1 :0x7fffe8244288

答案int * 加1是4個位元組;char * 加1是1個位元組。&num 和 &ch 分別代表該變數的全部位元組。

指標交換資料

比如這段程式碼是不能實現 a、b 兩數交換。請看範例:

#include <stdio.h>

void swap(x, y){
    int tmp = x;
    x = y;
    y = tmp;
}
int main() {
    int a = 1;
    int b = 2;
    swap(a, b);
    printf("a:%d\n", a);
    printf("b:%d\n", b);
    return 0;
}
a:1
b:2

分析:呼叫 swap(a, b) 這裡是一個值傳遞,找到函數入口地址,對引數 x、y 申請空間和賦值,通過 tmp 變數完成了 x和y的交換,最後回收區域性變數 x、y和tmp,釋放空間。而 a,b資料沒有變化。

可以通過指標來實現兩數的交換。請看範例:

#include <stdio.h>

void swap(int* x, int* y){
    int tmp = *x;
    *x = *y;
    *y = tmp;
}
int main() {
    int a = 1;
    int b = 2;
    swap(&a, &b);
    printf("a:%d\n", a);
    printf("b:%d\n", b);
    return 0;
}
a:2
b:1

分析:通過 swap(&a, &b) 將 a b 的地址傳給 x 和 y,通過 x 和 y 指標對 a 和 b 進行交換,雖然最後會銷燬swap中的區域性變數,但 a 和 b的值已經完成了交換。

指標的運運算元

指標和變數的關係

練習1

題目:輸出什麼?

#include <stdio.h>

int main() {
    int a = 10, *pa = &a, *pb;
    printf("%d\n", *pa);
    pb = pa;
    printf("%d\n", *pb);
    return 0;
}

輸出:10 10

分析:


int a = 10, 
// pa 指向變數 a
*pa = &a, 
// 定義一個整數型的指標 pb
*pb;
printf("%d\n", *pa);

// pb 也指向變數 a
pb = pa;
printf("%d\n", *pb);
return 0;

練習2

題目:輸出什麼?

#include <stdio.h>

int main() {
    int x = 3, y = 0, *px = &x;

    y = *px + 5;
    printf("%d\n", y);

    y= ++*px;
    printf("%d\n", y);

    printf("%p\n", px);

    y = *px++; 
    printf("%p\n", px);
    printf("%d\n", y);

    return 0;
}

輸出:

8
4
0x7ffc48b9be38
0x7ffc48b9be3c
4

分析:

  • y= ++*px; 等效 ++(*px)。如果是 ++* 是不對的
類似 y = ++i,等於先執行 ++,在執行 y = i,
這裡先對 (*px) 執行 ++,在返回  *px 的值
  • y = *px++;
先執行 y = *px,然後是 px++。px是整數型別的地址,加1就是加4個位元組。

練習3

題目:輸出什麼?

#include <stdio.h>

int main() {
    int x = 3, y = 0, *px = &x;
    printf("%p\n", px);
    y = (*px)++; 
    printf("%p\n", px);
    printf("%d\n", x);

    return 0;
}

輸出:

0x7ffef1dc4d58
0x7ffef1dc4d58
4

分析:*px++ 表示指標加1,(*px)++ 表示值加1。

指標初始化

指標初始化有兩種方法:已經存在的空間和自己申請空間。

已經存在的空間,例如:

#include <stdio.h>
#include <stdlib.h>
int main() {
    int num;
    int* p = &num;
    *p = 10;

    char *str = "abc";
    printf("%s\n", str); // abc。把字串的地址賦值給指標變數
    return 0;
}

自己申請空間可以使用 malloc 函數。申請的是 void 型別指標,也稱為通用型別指標。請看範例:

#include <stdio.h>
// malloc 需要引入 <stdlib.h>
#include <stdlib.h>
int main() {
    // 申請16個位元組
    int* q = malloc(sizeof(int) * 4); // 在堆裡申請了16個位元組
    // int* q = (int *)malloc(sizeof(int) * 4); // 推薦
    
    *q = 10;
    // 釋放申請的16個位元組
    free(q);
    return 0;
}

申請空間,使用完需要使用 free() 釋放。

Tip:根據 C99 標準以及更高版本的標準,顯式的型別轉換是建議的做法,以確保型別的安全性和可讀性。

空指標和野指標

下面這段程式碼 p 就是一個野指標,執行報錯:段錯誤 (核心已轉儲)

#include <stdio.h>

int main() {
    int* p;
    *p = 1;
    return 0;
}

這裡宣告一個指標 p,裡面是一個亂數,例如 0x7ffe71df3f40,接著往指向的記憶體放1,由於這塊記憶體不知道是否存在,即使存在也不能存取,於是報段錯誤

直接手寫一個地址也不可以。就像這樣:

#include <stdio.h>

int main() {
    
    // warning: incompatible integer to pointer conversion initializing 'int *' with an expression of type 'long' [-Wint-conversion]
    // 這個警告是因為你正在將一個 long 型別的表示式賦值給一個 int* 型別的指標變數,導致型別不匹配。
    // int* p = 0x7ffe71df3f40;
    int* p = (int *)0x7ffe71df3f40;
    *p = 100;
    return 0;
}
// 分段錯誤 (核心已轉儲)"
Segmentation fault (core dumped)

空指標也不能使用:

int* p = NULL;
*p = 100;

// 輸出:`Segmentation fault (core dumped)`

但空指標會讓你可控。就像這樣:

int* p = NULL;

if (p != NULL) {
    printf("p is not NULL\n");
}else{
    printf("p is NULL\n");
}

// 輸出:p is NULL

指標和陣列

指標當陣列用

遍歷一個陣列,可以這樣:

#include <stdio.h>

int main() {
    int arr[] = {1, 2, 3, 4, 5};
    int length = sizeof(arr) / sizeof(arr[0]);  // 計算陣列的長度
    // 1 2 3 4 5 
    for (int i = 0; i < length; ++i) {
        printf("%d ", arr[i]);
    }

    return 0;
}

使用指標遍歷陣列有兩種方式(效果相同)。請看範例:

#include <stdio.h>

int main() {
    int arr[] = {1, 2, 3, 4, 5};
    int length = sizeof(arr) / sizeof(arr[0]);  // 計算陣列的長度

    // 指標遍歷方式1
    /*
    int* pArr = arr;
    for (int i = 0; i < length; ++i) {
        printf("%d ", *(pArr + i));
    }
    */

    // 指標遍歷方式2
    int* pArr = arr;
    for (int i = 0; i < length; ++i) {
        printf("%d ", pArr[i]);
    }

    return 0;
}

Tip:在陣列一文中我們知道陣列名錶示首元素地址,這裡*(pArr + i)會依次遍歷陣列或許是因為指標是int型別吧!

總結pArr[i] 等於 *(pArr + i)。在這裡[]不再是取某個索引,而是表示取值。

指標和字元陣列

題目:分析 char a[] = "Hello";char *b = "World";

  • 都可以用for遍歷元素。例如:
#include <stdio.h>

int main() {
    char a[] = "Hello";
    char *b = "World";

    // Iterating over 'a'
    printf("Characters in 'a':\n");
    for (int i = 0; a[i] != '\0'; i++) {
        printf("%c\n", a[i]);
    }

    // Iterating over 'b'
    printf("\nCharacters in 'b':\n");
    for (int i = 0; b[i] != '\0'; i++) {
        printf("%c\n", b[i]);
    }

    return 0;
}

輸出:

開始執行...

Characters in 'a':
H
e
l
l
o

Characters in 'b':
W
o
r
l
d

執行結束。
  • 為什麼指標也可以通過索引存取特定字元?
    比如 char *b = "World";,可以將字串視為字元陣列,使用指標來指向該陣列的首地址,指標可以通過偏移來存取特定位置的元素,包括字串中的字元。

練習

題目:下面程式碼中 p1[0]p2[0]p3[0]的值分別是多少?

// 申請4*4個位元組,每個位元組地址假如是:0x100(存放1) 0x104(存放2) 0x108 0x10c
int a[] = {1,2,3,4};

int *p1 = (int*)(&a + 1);

int *p2 = (int*)((int)a + 1);

int *p3 = (int*)(a + 1);

分析:

  • (int*)(&a + 1) - &a 表示整個陣列,加1則到下一個陣列,然後將陣列指標強轉成整數指標,指向第5個元素,其實已經越界了。
  • (int*)((int)a + 1) - a 表示陣列首元素地址,(int)a 將地址轉為整數,以前是加1個元素,現在就是加1,然後又將整數轉為整數指標,亂了(就好比存取 0x101 0x102 0x103 0x104
  • (int*)(a + 1) - a 表示陣列首元素地址,加1則是第二個元素地址 0x104,不強轉也可以。

結論:只有p3[0](等價於 *(p3 + 0))是一個正常的元素,也就是2.

指標和字串

題目:用陣列和指標定義字串有什麼區別?

#include <stdio.h>

int main() {
    char str[] = "HelloWorld";
    // HelloWorld
    printf("%s\n", str);

    char* s = "HelloWorld";
    // HelloWorld
    printf("%s\n", s);

    return 0;
}

Tip: 字串的輸出都是首地址,比如這裡的 str 是陣列的首地址,s 指標指向的也是首地址。

分析:
char str[] = "HelloWorld"; 在棧中定義一個陣列,用11個位元組儲存HelloWorld(還有一個 \0)。請看範例:

#include <stdio.h>

int main() {
    char str[] = "HelloWorld";
    str[0]++; 
    // IelloWorld
    printf("%s\n", str); 
    // error: cannot increment value of type 'char[11]'
    str++;
    // printf("%s\n", str);
    return 0;
}

陣列名(str++)不可以修改,str 就是陣列首元素地址,已經固定了,可認為它是常數。但陣列內容可以修改。

char* s = "helloWorld";helloWorld 放在唯讀資料區,s 是區域性變數,放在棧中,佔8個位元組。請看範例:

#include <stdio.h>

int main() {
    char* s = "helloWorld";
    
    s++;
    // elloWorld
    printf("%s\n", s);

    // 報錯:Segmentation fault (core dumped)
    s[0]++;

    return 0;
}

指標可以加加,但指標指向的內容不能修改。

str 只是個名字,不佔空間,如果一定要說佔多少,那就是它執行的陣列佔11個位元組。而 s 是8個位元組,指向一個唯讀區,佔 11 個位元組。

練習

題目:分析以下範例。

#include <stdio.h>

int main() {
    char str[20];
    str = "HelloWorld";

    char* s;
    s = "HelloWorld";
    // HelloWorld
    printf("%s\n", s);

    return 0;
}

分析:

// 分配20個位元組的記憶體,並把首地址給 str
char str[20];
// str 是唯讀的,不能再賦值。報錯:`error: array type 'char[20]' is not assignable`
str = "HelloWorld";

// 定義一個指標 s
char* s;
// 將 HelloWorld 的首地址給 s
s = "HelloWorld";

擴充套件

自定義strcpy()函數

題目:實現原生字串拷貝方法strcpy。strcpy 其用法如下:

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

int main() {
    char source[] = "Hello";
    char destination[10]; // 目標字串需要足夠的空間來容納 source 字串

    strcpy(destination, source);

    printf("Source string: %s\n", source);
    printf("Destination string: %s\n", destination);

    return 0;
}

實現:

#include <stdio.h>

char* strcpy_custom(char* destination, const char* source) {
    // 字串陣列末尾有一個特殊的空字元 '\0' 來表示字串的結束。逐個複製字元,直到遇到源字串的結束標誌 '\0'
    while (*source != '\0') {
        *destination = *source;
        destination++;
        source++;
    }

    *destination = '\0'; // 在目標字串末尾新增結束標誌 '\0'

    return destination;
}

int main() {
    // 定義兩個字元陣列
    char source[] = "Hello";
    char destination[10]; // 目標字串需要足夠的空間來容納 source 字串

    // 陣列名。表示首元素的地址,加 1 是加一個元素(比如這裡1個位元組)
    strcpy_custom(destination, source);

    printf("Source string: %s\n", source);
    printf("Destination string: %s\n", destination);

    return 0;
}

Tipconst char* source 中 const 的作用請看const 和指標

輸出:

開始執行...

Source string: Hello
Destination string: Hello

執行結束。

將 while 替換成下面一行程式碼效果也相同:

char* strcpy_custom(char* destination, const char* source) {
    /*
    while (*source != '\0') {
        *destination = *source;
        destination++;
        source++;
    }

    *destination = '\0'; 
    */

    // 替換成
    while((*destination++ = *source++) != '\0');
    return destination;
}

分析:(*destination++ = *source++) != '\0':

之前的是首先判斷,在賦值。`*source != '\0'`、`*destination = '\0';`,這裡是先賦值

後置++會放在表示式最後,所以等於:

(*destination = *source) != '\0';
destination++;
source++;

const 和指標

首先補充下(int*)的作用。之前說到 const 定義的變數可以被修改,我們寫了如下程式碼:

#include <stdio.h>

int main() {
    const int val =5;

    int *ptr= (int*)&val;
    *ptr=10;

    printf("val = %d\n",val);
    printf("*ptr = %d\n", *ptr);

    return 0;
}

其中 int *ptr= (int*)&val; 是將一個 const int 型別的變數 val 地址強制轉換為 int* 型別的指標,並將指標儲存在 ptr 中。這種型別轉換是不安全的,因為它丟失了 val 的常數性質。

const char* source 宣告一個常數指標,以下程式碼僅做示意:

#include <stdio.h>

int main() {
    const char* source = "Hello";
    char* mutableSource = "World";

    printf("%c\n", source[0]);
    printf("%c\n", mutableSource[0]);

    // 以下操作是非法的,會導致編譯錯誤
    // source[0] = 'h'; // 不能修改字元資料

    // 合法
    // 儘管mutableSource是一個非常數指標,看起來可以進行修改,但修改字串常數是不被允許的,並且這可能導致未定義行為。
    mutableSource[4] = 'w'; // 可以修改字元資料
    return 0;
}

執行:

開始執行...

H
W
Segmentation fault (core dumped)

執行結束。
就近原則

const 有個就近原則

  • 比如:const int* p1 = &num;,const 修飾的是 *,所以 *p1 不能修改, p1 可以修改
  • 比如:int* const p2 = &num;,const 修飾 p2,所以 p2 不能修改,*p2 可以修改

請看範例:

#include <stdio.h>

int main() {
    int num = 1;
    const int* p1 = &num; // const 修飾的是 *,所以 *p1 不能修改, p1 可以修改

    p1++;
    // (*p1)++;

    int* const p2 = &num; // const 修飾 p2,所以 p2 不能修改,*p2 可以修改

    // p2++;
    (*p2)++;

    const int* const p3 = &num; // 兩個都不能修改
    // p3++;
    // (*p3)++;

    return 0;
}

gdb 偵錯段錯誤

GDB(GNU Debugger)是一款強大的偵錯程式,用於幫助開發者查詢和解決程式中的錯誤。通過與原始碼互動,並提供諸如斷點設定、變數觀察、記憶體檢查等功能,GDB允許開發者逐行執行程式並分析其執行狀態。
除了上文使用的 run,還有如下操作

  • run:執行程式。
  • break <line_number>:在指定行設定斷點。
  • break <function_name>:在指定函數設定斷點。
  • continue:繼續執行程式直到下一個斷點或程式結束。
  • next:逐過程地執行程式。
  • step:逐語句地執行程式。
  • print <variable>:列印變數的值。
  • backtrace:顯示函數呼叫的堆疊跟蹤資訊。
  • quit:退出GDB偵錯對談。

使用 gdb 偵錯段錯誤的過程如下:

編寫程式碼:

pjl@pjl-pc:~/pjl$ cat demo-3.c
#include <stdio.h>

int main() {
    int* p;
    *p = 1;

    return 0;
}

編譯執行發現段錯誤:

pjl@pjl-pc:~/pjl$ gcc demo-3.c -o demo-3
pjl@pjl-pc:~/pjl$ ./demo-3
段錯誤 (核心已轉儲)

將程式碼編譯為可偵錯的可執行檔案。在gcc或g++編譯時,新增"-g"選項可以生成包含偵錯資訊的可執行檔案。

// 增加 -g
pjl@pjl-pc:~/pjl$ gcc demo-3.c -o demo-3 -g

// 啟動GDB並載入可執行檔案
pjl@pjl-pc:~/pjl$ gdb demo-3
GNU gdb (Ubuntu 9.1-0kylin1) 9.1
Copyright (C) 2020 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Type "show copying" and "show warranty" for details.
This GDB was configured as "x86_64-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
    <http://www.gnu.org/software/gdb/documentation/>.

For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from demo-3...

(gdb) 

輸入 run(還有其他操作) 找到是第5行程式碼報錯:

...
// run:執行程式。
(gdb) run
Starting program: /home/pjl/pjl/demo-3

Program received signal SIGSEGV, Segmentation fault.
0x0000555555555135 in main () at demo-3.c:5
5           *p = 1;
(gdb)

高階指標

提前透露:指標遇上陣列

題目:以下程式碼輸出什麼?

#include <stdio.h>

int main() {
    char * string[] = {"Hello", "World" };
    printf("%s\n", string);
    return 0;
}

分析:
我們知道定義字串有以下兩種方法:

char str[] = "HelloWorld";
char* s = "HelloWorld";

Tip: string 在 C 中不是關鍵字,也不是保留字,就是一個普通變數名。

[] 的優先順序是非常高的,這裡首先是定義一個陣列(string[]),其次就是指標,合起來就是一個指標陣列。

首先在唯讀區分配兩塊記憶體分別存放 Hello(地址比如是 0x100) 和 World(地址比如是 0x200),指標陣列是16個位元組,本質就是陣列,只不過裡面放的是指標,比如前8個位元組的地址是0x1000,那麼 string 就是 0x1000,因為陣列名就是陣列首元素地址。

所以要輸出這兩個字串,可以這麼寫:

#include <stdio.h>

int main() {
    char * string[] = {"Hello", "World" };

    // Hello
    printf("%s\n", string[0]);
    // World
    printf("%s\n", string[1]);
    return 0;
}