需求
假如有这样的一个需求,有个日期,想要截取获得其年份。我们用 php 可以使用explode
,也可以使用strtok
$a = "2019-09-10 00:00:00";
echo strtok($a,"-"); // 2019
可能大家对strtok
不太熟悉,它的作用是用-
来分割$a
获取子串,循环调用可以达到和explode
差不多的效果。具体可以看下官方手册里面的 demo https://www.php.net/manual/zh/function.strtok.php
实验
实验1
我之所以用strtok
呢,是因为C 语言里也有这个函数,这个函数比较“怪”,每一次调用,是将字符串中找到的-
替换为\0
,然后返回标记字符串的首地址。
#include <stdio.h>
#include <string.h>
int main(int argc, char *argv[]) {
char date[] = "2019-09-10";
char *tmp = strtok(date, "-");
printf("%s,%p\n", tmp, (void *) tmp); // 2019,0x7ffe8741bdd0
printf("%s,%p\n", date, (void *) date); // 2019,0x7ffe8741bdd0
printf("%d,%c\n", date[4], date[4]); // 0,
return 0;
}
实验2
当我们使用char
指针来作为字符串的初始化时,又会是怎样呢?
#include <stdio.h>
#include <string.h>
int main(int argc, char *argv[]) {
char *date = "2019-09-10";
char *tmp = strtok(date, "-");
printf("%s,%p\n", tmp, (void *) tmp); // 2019,0x7ffe8741bdd0
printf("%s,%p\n", date, (void *) date); // 2019,0x7ffe8741bdd0
printf("%d,%c\n", date[4], date[4]); // 0,
return 0;
}
运行的结果却是
Segmentation fault
原理
当我们使用指针变量作为左值
,双引号字符串作为右值
时,背后双引号的逻辑是:
- 在只读区申请内存,存放字符串
- 在字符串尾加上了'/0'
- 返回字符串的首地址
所以char * date
就在栈上存放里双引号字符串返回的首地址。当使用strtok
的时候,通过实验1
可以看到strtok
实际是找到的字符串替换为\0
,也就是说需要修改原字符串的。而该字符串是在只读区,不不能修改,所以运行出现了段错误。
反过来思考,我们 char date[]
数组通过双引号初始化的时候又是什么原理,是不是也是双引号返回了常量字符串首地址,然后再通过循环一个个赋值到char
数组里呢?
实验3
猜想归猜想。我们通过实验来证明下。
#include <stdio.h>
int main(int argc, char const *argv[])
{
char *str1 = "123";
char str2[] = {'1','2','3'};
char str3[] = {"123"};
char str4[] = "123";
return 0;
}
通过objdump 反汇编可以看到
$ gcc a.c
$ objdump -D a.out
00000000004004ed <main>:
4004ed: 55 push %rbp
4004ee: 48 89 e5 mov %rsp,%rbp
4004f1: 89 7d cc mov %edi,-0x34(%rbp)
4004f4: 48 89 75 c0 mov %rsi,-0x40(%rbp)
4004f8: 48 c7 45 f8 c0 05 40 movq $0x4005c0,-0x8(%rbp)
4004ff: 00
400500: c6 45 f0 31 movb $0x31,-0x10(%rbp)
400504: c6 45 f1 32 movb $0x32,-0xf(%rbp)
400508: c6 45 f2 33 movb $0x33,-0xe(%rbp)
40050c: c7 45 e0 31 32 33 00 movl $0x333231,-0x20(%rbp)
400513: c7 45 d0 31 32 33 00 movl $0x333231,-0x30(%rbp)
40051a: b8 00 00 00 00 mov $0x0,%eax
40051f: 5d pop %rbp
400520: c3 retq
400521: 66 2e 0f 1f 84 00 00 nopw %cs:0x0(%rax,%rax,1)
400528: 00 00 00
40052b: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)
$objdump -j .rodata -d 3.out
a.out: file format elf64-x86-64
Disassembly of section .rodata:
00000000004005b0 <_IO_stdin_used>:
4005b0: 01 00 02 00 00 00 00 00 ........
00000000004005b8 <__dso_handle>:
...
4005c0: 31 32 33 00 123.
实验结论
可以看到
第一个变量(黄色框)初始化是传入了一个地址,而这个地址4005c0
正是下面只读数据段
里面的,我们可以看到下面4005c0
储存数据31323300
十六进制对应的ascii
码里面的就是123\0
。
第二个变量(红色框)是通过三次mov
操作放到了栈上(movb
表示按字节移动)。
第三个变量和第四个变量的方式一样,都是直接把字符串传递到了栈上,而不是像第一个变量那样,传递的是一个地址。
所以,用指针初始化的字符串在只读取,不能被改写;用 char 数组形式初始化的字符串,即使使用了双引号来初始化,也是在栈上,后面程序是可以改写的。
扩展
C 语言也太坑爹了,这样每个函数怎么用,我们怎么知道传入的字符串在函数内部会不会做变更呢?
其实在函数手册可以看到一些细节,比如下面的函数
char *strchr(const char *s, int c);
char *strtok(char *str, const char *delim);
char *strcat(char *dest, const char *src);
当形参为const char *
的时候,说明函数不会对该段内存里的数据做变更,传入栈上、堆上、只读区的地址都行;反之,如果形参为char *
就要小心了,可以认为它的意思是数组,会改变传入的“字符串”。
思考
根据我们上面分析的
#include <stdio.h>
#include <string.h>
int main(int argc, char *argv[]) {
char *date = "2019";
strcat(date, "-09-10");
printf("%s,%p\n", date, (void *) date);
return 0;
}
运行时肯定是Segmentation fault
了,因为“2019”是存在了只读取。
如果换成下面的代码,又会怎样呢?
#include <stdio.h>
#include <string.h>
int main(int argc, char *argv[]) {
char date[] = "2019";
strcat(date, "-09-10");
printf("%s,%p\n", date, (void *) date);
return 0;
}
linux gcc 编译可运行,但是实际是有问题的,比如我改成
#include <stdio.h>
#include <string.h>
int main(int argc, char *argv[]) {
char date[] = "2019";
strcat(date, "-09-1000000000000000000");
printf("%s,%p\n", date, (void *) date);
return 0;
}
就会出现段错误,也许在你的服务器编译运行又不报错,如果不报错请增加追加字符串的长度然后尝试。(C 程序就是这么神奇,能运行不一定表示没问题。)
因为date
初始化分配的内存不足以存放连接之后的字符串。我们改写为
#include <stdio.h>
#include <string.h>
int main(int argc, char *argv[]) {
char date[11] = "2019";
strcat(date, "-09-10");
printf("%s,%p\n", date, (void *) date);
return 0;
}
这样就可以正常运行了。坑爹啊,C 语言也太麻烦了,一不小心就写错,怪不得 PHP 是世界上最好的语言。