字符指針與字符數組真正的區別
問題緣起
先看一個示例
示例1
#include <stdio.h>
int main() {
char *p = "hello";
char q[] = "hello";
printf ("p: %s\n", p);
printf ("q: %s\n", q);
return 0;
}
上面的例子會給出這樣的輸出
p: hello
q: hello
這樣看,char *p
和 char
q[]
好像沒什麼區別, 那麼我們再看一個例子
示例2
#include <stdio.h>
int main() {
char *p = "hello";
char q[] = "hello";
p[0] = 's';
q[0] = 's';
return 0;
}
如果你在Linux下,運行時可能得到這樣的結果
Segmentation fault (core dumped)
這時候你看到了區別,出現了段錯誤, 你一定想明白,到底發生了什麼, 經過幾個小實驗,你可能會發現使用 char
*p
定義時,p指向的數據是無法改變的。 然後你Google, 別人可能會告訴你
- char 指針指向的數據不能修改
- char 指針指向的數據沒有分配
- ...
你聽了還是一頭霧水,不能修改是什麼意思,沒有分配?沒有分配爲什麼能輸出?
作爲一個好奇心很重的人,你是絕對不能容忍這些問題的困擾的,且聽我慢慢道來
深入理解
首先,你的程序代碼和程序數據都在內存裏放着,也就是說 p 指向的 hello
和
q 指向的 hello
, 都是內存中的數據。從兩個示例的比較中,你發現,同樣是內存中的數據,都可以讀,但是有的可以寫,
有的不能寫。從這可以看出,問題的關鍵在於,內存是如何組織的。
寫好的程序代碼放在硬盤上。程序代碼的運行需要CPU一條指令一條指令地執行。
在硬盤上讀東西是慢的,CPU是快的,所以有了內存。
因此程序代碼如果要運行,需要先載入內存。這時候,問題又出現了,系統中同時有許多程序要運行, 你想要這塊內存,我也想要這塊內存,那這塊內存給誰呢? 何況,寫程序的時候,我是不知道哪塊內存 被佔用,哪塊沒有被佔用的。總不能每次我想放數據,都檢查一下吧。那程序員的負擔也太大了。
一件事如果大家都需要,肯定會出現專門做這件事的人。
於是,操作系統接管了內存。程序A說,我要12號內存單元。程序B說,我要12號內存單元。 操作系統表示很爲難。不能都給,要不就衝突了,也不能不給,內存還有好大地方呢。
操作系統是聰明的,聰明人是會抽象的。
所謂抽象,就是看不到具體的東西了,只能看到上層的東西。當程序A和程序B都請求12號內存單元時, 操作系統把3號內存單元給了A,5號內存單元給了B。但是爲了讓程序中對內存的訪問保持一致性, 並不讓程序知道給他們的不是12號內存單元,否則程序中凡是和12號內存單元相關的,都要作修改, 又變成了程序自己維護內存。操作系統爲每個程序維護一個映射表。在映射表中, 對於程序A來說,12號內存單元對應3號內存單元,對於程序B來說12號內存單元對應5號內存單元。 這時候程序看到的12號內存單元和操作系統實際給出的3,5號內存單元,就變成了兩種不同的事物。 12號內存單元只是程序看到的,3,5號是真實的內存單元。我們把前者稱爲虛擬內存單元,後者指爲物理 內存單元。
有了虛擬內存的概念後,程序就無法無天了,全部的內存我都可以用,想訪問哪塊訪問哪塊,至於 實際上真正訪問的是內存哪個位置,我可不關心,那是操作系統的事,我只要把一個虛擬內存號告訴 操作系統就可以了。所以,從程序看來,他擁有整個內存空間。
嚴格來說,程序
這個詞是不準確的, 程序
一般就是指的代碼本身。但是代碼一旦運行起來,
和這段代碼相關的東西就太多了,比如指令,數據,映射表,用到的內存。另一方面, 系統中有多個程序在執行,有時候程序A執行,有時候程序B執行,操作系統從A切換到B時, 肯定要記下來A執行到哪裏了,這也和程序相關。所以這時候,我們又抽象出一個概念,叫進程
。
這時候,程序
就表示硬盤上那塊代碼,進程
表示正在運行的程序,進程
不僅包含代碼,
還包含一些運行時相關的東西。
現在,當你啓動一個程序時,操作系統會先創建一個進程,爲這個進程建立一個私有的虛擬的內存空間, 把程序代碼加載進來。進程代表一個運行中的程序,程序在運行時要使用內存,並且使用內存的方式多種多樣, 程序有有些數據放在內存中是不變的,有些是一開始就分配好的,還有一些會根據需要分配。所以, 我們需要對進程的虛擬內存空間進行良好的組織,以便操作系統和程序配合,高效地完成任務。
下圖是一個Linux進程的虛擬內存空間
所有的Linux進程的虛擬內存空間都是以這種方式組織的,只不過不同進程因爲映射表不同,所以 同一虛擬地址對應不同的物理地址。如果進程需要共享一塊內存區,只需要在映射表中把同一虛擬內存 地址映射到相同物理地址就可以了,比如上圖中的Kernel virutal memory區域,這個區域是操作系統 內核的代碼,所有進程都需要共享,所以操作系統就可以把所有進程的這一區域映射到相同物理地址處。
上圖的下半部分是Process virtual memory,代碼進程使用的虛擬內存空間,可以看出他們被分成了 幾個塊,這些塊代表了程序使用內存的不同方式。我們先來看一段代碼,並結合上圖說明一下程序使用 內存的不同方式。
示例3
#include <stdio.h>
#include <stdlib.h>
int main() {
char *p = "hello";
char q[] = "world";
char *r = (char *)malloc(sizeof(char)*6);
p[0] = 's';
q[0] = 's';
r[0] = 's';
printf ("p is:%s",p);
printf ("q is:%s",q);
printf ("r is:%s",r);
return 0;
}
我們先用gcc將這段代碼編程成彙編語言
gcc -S tchar.c -o tchar.s
示例3彙編版本(含註釋, 只含關鍵代碼)
.file "tcharp.c"
.section .rodata
.LC0:
.string "hello"
.LC1:
.string "p is:%s"
.LC2:
.string "q is:%s"
.LC3:
.string "r is:%s"
.text
.globl main
.type main, @function
main:
# char *p = "hello"
movl $.LC0, 28(%esp)
# char q[] = "world"
movl $1819438967, 38(%esp)
movw $100, 42(%esp)
# char *r = (char *)malloc(sizeof(char)*6)
movl $6, (%esp)
call malloc
movl %eax, 32(%esp)
# p[0] = 's'
movl 28(%esp), %eax
movb $115, (%eax)
# q[0] = 's'
movb $115, 38(%esp)
# r[0] = 's'
movl 32(%esp), %eax
movb $115, (%eax)
movl $.LC1, %eax
movl 28(%esp), %edx # save p
movl %edx, 4(%esp)
movl %eax, (%esp)
call printf
movl $.LC2, %eax
leal 38(%esp), %edx # save q
movl %edx, 4(%esp)
movl %eax, (%esp)
call printf
movl $.LC3, %eax
movl 32(%esp), %edx # save r
movl %edx, 4(%esp)
movl %eax, (%esp)
call printf
從上述彙編代碼可以看出p,q,r三種使用內存的方式。從初始化上看, p指向的"hello",初始化時,直接指向了一個固定的位置,這意味着代碼執行的時候, 這個位置已經有數據了。q指向的"world",初始化是由代碼完成的,你把"world"經ASCII碼轉化成數字形式, 對比一下就會發現,那兩個數字,1819438967,100,對應的就是"world"。而r的初始化,是調用malloc得到的。
從這段彙編代碼。我們從直覺上會感覺到這三種使用內存方式的不同,接下來,我們再來看一下Linux運行時存儲器映像。
.text 段放着已經編譯的程序機器代碼。
.rodata 段放着只讀數據,printf
函數參數中的字符串,p指向的"hello",
都在這存着。正因爲這個段是隻讀的,所以不能修改,代碼
p[0] = 's'
執行時就會出現段錯誤。
.data 段放着已經初始化的全局變量,.bss 段變着沒有初始化的全局變量。
再往上是 Run-time heap, 我們用malloc分配的內存空間都在這一段。
接着是User Stack,程序中的局部變量都在這一段,我們q指向的"world"就存儲在這裏。 從圖中也可以看到,%esp
指向棧頂,再回頭看一下彙編代碼,你可能就明白之前相對於(%esp)地址所做的操作意味着什麼。
這裏特別要區分 地址與數據 。
p,q,r是局部變量,它們的值都是地址,這個地址作爲局部變量的值,在User Stack裏存儲。
p表示的地址指向數據"hello",這是不可變量,在.rodata段中存儲。q表示的地址指向的數據"world",作爲局部變量的數據,在User Stack段存儲。 r表示的地址指向的數據,在Run-time heap中存儲。
爲了驗證我們的想法,我們做一個實驗,把p,q,r三者地址打印出來, 再把三者指向的數據的地址打印出來。 然後查看內存分配。
示例4
#include <stdio.h>
#include <stdlib.h>
int main() {
char *p = "hello";
char q[] = "world";
char *r = (char *)malloc(sizeof(char)*6);
int n;
printf ("addr of p:%p\n", &p);
printf ("addr of q:%p\n", &q);
printf ("addr of r:%p\n", &r);
printf ("addr of p's data:%p\n", p);
printf ("addr of q's data:%p\n", q);
printf ("addr of r's data:%p\n", r);
scanf ("%d\n", &n);
return 0;
}
爲了便於觀察,我們引入scanf,同時放在後臺運行,這樣只要我們不輸入數據, 進程就不會終止,我們就可以觀察它。運行它,
$ ./tcharp &
[2] 3461
addr of p:0xbfaa91d8
addr of q:0xbfaa91e6
addr of r:0xbfaa91dc
addr of p's data:0x8048670
addr of q's data:0xbfaa91e6
addr of r's data:0x87ad008
從這裏,我們可以看出:p,q,r本身的值,以及q指向的數據,存儲的位置離的很近,我們猜測, 所以0xbfXXXXXX這一塊應該是User Stack區域,0x8048XXX這一塊是.rodata區域, 0x87XXXXX這一塊是Run-time heap區域。
接下來,我們使用 readelf
命令,得到各個區域的實際位置,進一步明確我們的猜想。
$ readelf -a tcharp > tcharp_elf.txt
從tcharp_elf.txt中截取關鍵數據, 得到
[11] .init PROGBITS 08048318 000318 00002e 00 AX 0 0 4
[12] .plt PROGBITS 08048350 000350 000060 04 AX 0 0 16
[13] .text PROGBITS 080483b0 0003b0 00023c 00 AX 0 0 16
[14] .fini PROGBITS 080485ec 0005ec 00001a 00 AX 0 0 4
[15] .rodata PROGBITS 08048608 000608 000077 00 A 0 0 4
[16] .eh_frame_hdr PROGBITS 08048680 000680 000034 00 A 0 0 4
[17] .eh_frame PROGBITS 080486b4 0006b4 0000c4 00 A 0 0 4
[18] .ctors PROGBITS 08049f14 000f14 000008 00 WA 0 0 4
[19] .dtors PROGBITS 08049f1c 000f1c 000008 00 WA 0 0 4
[20] .jcr PROGBITS 08049f24 000f24 000004 00 WA 0 0 4
[21] .dynamic DYNAMIC 08049f28 000f28 0000c8 08 WA 6 0 4
[22] .got PROGBITS 08049ff0 000ff0 000004 04 WA 0 0 4
[23] .got.plt PROGBITS 08049ff4 000ff4 000020 04 WA 0 0 4
[24] .data PROGBITS 0804a014 001014 000008 00 WA 0 0 4
[25] .bss NOBITS 0804a01c 00101c 000008 00 WA 0 0 4
[26] .comment PROGBITS 00000000 00101c 00002a 01 MS 0 0 1
[27] .shstrtab STRTAB 00000000 001046 0000fc 00 0 0 1
[28] .symtab SYMTAB 00000000 0015f4 000430 10 29 45 4
[29] .strtab STRTAB 00000000 001a24 00022c 00 0 0 1
從這裏,我們可以驗證對 .rodata 段的猜測,p指向的 "hello", 確實是存儲在這一段。
然後,我們查看其它段的位置
$ cat /proc/3461/maps
08048000-08049000 r-xp 00000000 08:0a 4981409 /home/zhaoxk/test/tcharp
08049000-0804a000 r--p 00000000 08:0a 4981409 /home/zhaoxk/test/tcharp
0804a000-0804b000 rw-p 00001000 08:0a 4981409 /home/zhaoxk/test/tcharp
087ad000-087ce000 rw-p 00000000 00:00 0 [heap]
b75fc000-b75fd000 rw-p 00000000 00:00 0
b75fd000-b77a1000 r-xp 00000000 08:07 412678 /lib/i386-linux-gnu/libc-2.15.so
b77a1000-b77a3000 r--p 001a4000 08:07 412678 /lib/i386-linux-gnu/libc-2.15.so
b77a3000-b77a4000 rw-p 001a6000 08:07 412678 /lib/i386-linux-gnu/libc-2.15.so
b77a4000-b77a7000 rw-p 00000000 00:00 0
b77c1000-b77c5000 rw-p 00000000 00:00 0
b77c5000-b77c6000 r-xp 00000000 00:00 0 [vdso]
b77c6000-b77e6000 r-xp 00000000 08:07 403838 /lib/i386-linux-gnu/ld-2.15.so
b77e6000-b77e7000 r--p 0001f000 08:07 403838 /lib/i386-linux-gnu/ld-2.15.so
b77e7000-b77e8000 rw-p 00020000 08:07 403838 /lib/i386-linux-gnu/ld-2.15.so
bfa8b000-bfaac000 rw-p 00000000 00:00 0 [stack]
看到stack和heap段的位置了吧,再一次印證了我們的想法。
好了,我們的探索到這裏就結束了。
文後的話
從上面的過程可以看出,要想真正理解C語言,你需要了解彙編,需要了解操作系統, 而Linux提供了一系列工具,方便你探索整個系統的運行機制。如果你也想了解它, 請開始使用它。
還是那句話。
既然看起來不錯,爲什麼不試試呢?
文中圖片均來自《深入理解計算機系統》一書。