C 语言指针与输入输出:地址、原反补码与格式符
指针是 C 语言的魂,输入输出是和人交互的入口。这篇文章把"变量在内存里到底长什么样"讲透,接着用代码验证原码/反码/补码为什么非用补码不可,再拿进制转换、数组、printf/scanf 的格式符收尾。所有输出都是本机 gcc 真实跑出来的。
一、变量的"三段式"存储模型
C 语言里,一个普通变量在内存中可以理解为"三段式"结构:
-
变量名(给人看的代号)
-
地址(这块数据在内存里的门牌号,由系统分配)
-
值(真正存进去的二进制数据)
而指针变量 特殊在:它自己也是一段内存,但它里面存的不是普通数据,而是另一个变量的地址。于是普通变量是"货物本身",指针是"快递单号"------单号上写的是货物所在的货架号。
读写一个变量,CPU 先拿地址,再去那块内存取数据。记住这个模型,后面所有指针操作都不会乱。
二、& 与 *:取地址与解引用
两个最常用运算符:
-
&取地址符 :&a拿到变量a在内存里的地址。 -
*解引用符 :放在指针前,*p表示"访问 p 指向的那块内存里的值"。
下面的代码把这两个符号和"指针存的是地址"这件事直接打印出来:
int a = 10;
int b = a; /* 深拷贝:把值复制一份,b 与 a 互不影响 */
int *p = &a; /* &a 取 a 的地址,p 是指针,存的是地址 */
printf("a=%d, b=%d, 通过指针 *p=%d\n", a, b, *p);
printf("&a=%p, p=%p(指针 p 里存的正是 a 的地址)\n", (void *)&a, (void *)p);
本机真实输出:
a=10, b=10, 通过指针 *p=10
&a=000000000062FE08, p=000000000062FE08(指针 p 里存的正是 a 的地址)
改 b=11 后:a=10, b=11(a 不变,b 是独立副本)
再改 *p=100 后:a=100(指针穿透改到了 a 本体)
重点看最后两行:改 b 只动了副本,原变量 a 纹丝不动;但通过 *p 改,a 本体被穿透修改。 这就是"值"和"地址"的本质区别。
三、浅拷贝与深拷贝(共享地址 vs 拷贝值)
-
深拷贝(值拷贝) :
int d = c;把c的值完整复制一份给d,之后d是独立个体,改d不影响c。 -
浅拷贝(地址共享) :
int *q = &c;两个指针指向同一块内存,通过任意一个改值,另一个"看到"的也跟着变。
本机实测:
通过 *q=50 改后:c=50, *q=50, d=5(d 不受影响,是深拷贝)
工程上的坑:浅拷贝让多个指针共享同一块内存,改一处处处变,好处是省内存,坏处是容易"误伤"。什么时候该深、什么时候该浅,是 C 里反复要权衡的事。
四、整数在计算机里怎么存:原码/反码/补码
计算机只认二进制,那负数怎么表示?经历了三代方案:
4.1 原码的问题
原码 = 符号位(0 正 1 负)+ 数值的二进制。比如 -10 原码写作 1,000000000001010。
问题:原码做加法会错。理论上 +10 + (-10) 应该等于 0,但符号位直接参与运算后,结果是负的 10100,明显不对。也就是说,原码没法让 CPU 用同一套加法电路同时算正数和负数。
4.2 反码与 ±0 矛盾
反码规则:正数不变;负数符号位不变,其余位按位取反 。但反码留下一个致命矛盾------+0 是 000...0,-0 是 111...1,数轴上 0 明明只有一个点,却出现了两个编码。
4.3 补码:统一加减法
补码规则:正数同原码;负数 = 反码 + 1。它一举解决上面两个问题:
-
统一用加法算加减,
-10 + 10直接相加,最高位溢出被自动丢弃,正好得 0。 -
0 只有一种表示(全 0),没有 ±0 冲突。
所以现代计算机一律用补码存整数 ,包括正数。下面把 short(16 位)下 +10、-10 的真实补码位模式打出来:
short pos = 10, neg = -10;
printf("+10 的补码位模式:"); print_bits((unsigned short)pos, 16); printf("\n");
printf("-10 的补码位模式:"); print_bits((unsigned short)neg, 16); printf("\n");
short r = (short)((unsigned short)pos + (unsigned short)neg);
printf("-10 + 10 用补码相加:"); print_bits((unsigned short)r, 16);
printf("= %d(最高位溢出自动丢弃,结果正确为 0)\n", r);
本机真实输出:
+10 的补码位模式:0000 0000 0000 1010
-10 的补码位模式:1111 1111 1111 0110
-10 + 10 用补码相加:0000 0000 0000 0000 = 0(最高位溢出自动丢弃,结果正确为 0)
五、进制转换:二/八/十/十六
计算机内部只有二进制,但人写起来太长,于是引入八进制、十六进制做"缩写":
-
八进制 ↔ 二进制:每 3 位二进制对应 1 位八进制(因为 2³=8)。
-
十六进制 ↔ 二进制 :每 4 位二进制对应 1 位十六进制(
0~9,A~F表示 10~15,因为 2⁴=16)。 -
十六进制常以
0x前缀标记,如0xDB。
C 里用 printf 的 %o(八进制)、%x/%X(十六进制)直接打印。以十进制 219 为例验证:
int v = 219;
printf("八进制 %%o -> %o\n", v);
printf("十六进制 %%x -> %x ,大写 %%X -> %X\n", v, v);
printf("二进制(32 位):"); print_bits((unsigned int)v, 32); printf("\n");
本机真实输出:
十进制 219:
八进制 %o -> 333
十六进制 %x -> db ,大写 %X -> DB
二进制(32 位补码):0000 0000 0000 0000 0000 0000 1101 1011
验证:219 = 128+64+16+8+2+1 = 11011011(二进制) = 333(八进制) = DB(十六)
219 = 11011011 二进制,从右每 3 位分组 011 011 011 = 3 3 3 八进制,每 4 位分组 1101 1011 = D B 十六进制,完全对得上。
六、数组:连续、同类型、下标从 0
数组是"相同类型数据的集合",三个铁律:
-
连续存储:元素在内存里一个挨一个,地址等差递增。
-
同类型 :一旦是
int数组,每个元素都固定 4 字节,CPU 才能用"首地址 + 下标 × 4"精准定位。混类型会破坏这种规律。 -
下标从 0 :第 n 个元素实际偏移是
(n-1)个单位长度,从 0 开始算偏移最整齐。
6.1 下标为什么从 0
因为"第一个元素"的偏移就是 0(地址 = 首地址 + 0),不需要额外减一。若从 1 开始,每次访问都要多一次减一,纯属浪费。
6.2 数组不能整体赋值、不能动态扩容
-
arr2 = arr1;在 C 里直接编译报错------数组名是地址常量,不能当左值整体赋值。 -
数组长度创建时定死。想"加长"只能:申请更大的新空间 → 把旧数据拷过去 → 释放旧的。原地往后接是不允许的,因为后面可能已被别人占用。
6.3 malloc 动态开辟数组
int *dyn = (int *)malloc(5 * sizeof(int));
malloc 返回的是"原始内存"(无类型),必须用 (int*) 强制类型转换给它贴上"这是 int 数组"的标签,之后才能按 int 存取。free 用完要还回去。
6.4 用 sizeof 求数组长度
C 不会主动告诉你数组有几个元素,但可以用"总大小 ÷ 单个元素大小"算:
int arr[5] = {1, 2, 3, 4, 5};
printf("数组长度 = sizeof(arr)/sizeof(arr[0]) = %lu\n",
(unsigned long)(sizeof(arr) / sizeof(arr[0])));
本机真实输出:
arr=000000000062FE20,&arr[0]=000000000062FE20(数组名就是首元素地址)
arr[0] @ 000000000062FE20 = 1
arr[1] @ 000000000062FE24 = 2
arr[2] @ 000000000062FE28 = 3
arr[3] @ 000000000062FE2C = 4
arr[4] @ 000000000062FE30 = 5
数组长度 = sizeof(arr)/sizeof(arr[0]) = 5
malloc 动态数组((int*)强制转换):10 20 30 40 50
注意每个元素地址相差正好 4 字节(一个 int),这就是"连续 + 同类型"的直接证据。
七、printf 格式符全家福
printf 靠格式符决定"把内存里的二进制按什么规则解释出来"。常用一览:
| 格式符 | 含义 | 示例 |
|---|---|---|
%d |
有符号十进制整数 | 42 |
%f |
浮点数(默认 6 位小数) | 3.141590 |
%.2f |
浮点,保留 2 位小数 | 2.72 |
%c |
单个字符 | Z |
%s |
字符串(双引号包裹) | CSDN |
%p |
指针/地址 | 000000000062FE0C |
%o / %x |
八进制 / 十六进制整数 | 377 / ff |
%% |
输出一个百分号 % |
100% |
本机真实输出(把上面这些格式符一次打出来):
%d 整数 : 42
%f 浮点 : 3.141590
%.2f 两位小数 : 2.72
%c 字符 : Z
%s 字符串 : CSDN
%p 地址 : 000000000062FE0C
%% 百分号 : 正确率 100%
%o/%x 进制 : 255 的八进制=377 十六进制=ff
要点:%f 默认打 6 位小数,%.Nf 控制小数位;想输出字面 % 必须写 %%,否则会被当成格式符开头。
八、scanf 的坑:为什么必须加 &
scanf 是把键盘输入写进变量地址。%d 读 int 时,必须加 & 取地址,否则会把变量当前的值误当成目标地址去写,直接内存报错崩溃。
int age;
float score;
char name[20];
scanf("%d %f %s", &age, &score, name);
注意三个参数:
-
&age、&score:普通变量,得取地址才能让scanf把值写进去。 -
name:字符串本来就是char数组,数组名本身就是首元素地址 ,所以不用加&。
本机实测(输入 20 99.5 Alice):
请输入:年龄 成绩 姓名(空格分隔,回车结束)
读到了 -> 年龄=20, 成绩=99.50, 姓名=Alice
注意:如果写成 scanf("%d", age) 漏掉 &,会把变量的值当地址去写,直接崩。
九、转义字符 \n \t 与 %%
输出排版离不开转义字符:
-
\n换行:不加的话所有内容挤成一行。 -
\t制表符:一个"长空格",用来对齐。 -
%%:输出百分号本身(见第七节)。
这些在 printf 的双引号字符串里才生效,单引号字符里只表示一个普通字符。
附:速查表
| 名称 | 作用 | 备注 |
|---|---|---|
& |
取地址 | &a 得到 a 的地址 |
* |
解引用 / 声明指针 | int *p 声明;*p 取值 |
| 浅拷贝 | 复制地址(共享内存) | 一处改,处处变 |
| 深拷贝 | 复制值(独立副本) | 互不影响 |
| 原码/反码/补码 | 负数表示方案 | 现代统一用补码 |
%o / %x |
八/十六进制输出 | 4 位二进制 = 1 位十六进制 |
malloc / free |
动态内存 | 返回 void*,需强转 |
sizeof(arr)/sizeof(arr[0]) |
数组长度 | 仅在数组本名处有效 |
scanf 的 & |
读入普通变量必须加 | 字符串 %s 不用加 |
\n / \t |
换行 / 制表符 | 转义字符 |
附:实测代码
完整源码见 ../demos/818-pointers-io/pointers_io.c 与 scanf_demo.c,编译运行:
gcc pointers_io.c -o pointers_io
./pointers_io
gcc scanf_demo.c -o scanf_demo
./scanf_demo # 运行时输入:20 99.5 Alice
实测环境:Windows + MinGW-w64 (tdm64-1) gcc 4.9.2,64 位程序。文中所有地址、补码位模式、进制与格式符输出均为该程序真实运行结果。