本篇主要讲例子,至于关于FFI的知识点,可以参考以下文档:
官网文档FFI介绍入口 http://php.p2hp.com/manual/zh/class.ffi.php
或 https://www.kancloud.cn/a173512/php_note/1690512
鸟哥关于FFI的文章 https://www.laruence.com/2020/03/11/5475.html
cpp命令 https://blog.csdn.net/leibris/article/details/120195897
cpp命令 https://blog.csdn.net/he3913/article/details/2031050
首先检查 ffi 扩展是否已经安装 (配置文件是否存在,动态库是否存在)。ffi包含在 php8.0-common 软件包中,因此一般都是安装了的,毕竟它是语言基本特性。
sjg@sjg-PC:~/garbage$ php -r "phpinfo();" | grep ffi
/etc/php/8.0/cli/conf.d/20-ffi.ini,
ffi.enable => preload => preload
ffi.preload => no value => no value
sjg@sjg-PC:~/garbage$ php -r "phpinfo();" | grep extension_dir
extension_dir => /usr/lib/php/20200930 => /usr/lib/php/20200930
sqlite3.extension_dir => no value => no value
sjg@sjg-PC:~/garbage$ ll /usr/lib/php/20200930/ | grep ffi
-rw-r--r-- 1 root root 165992 2月 15 02:29 ffi.so
我们来写一个动态库,它实现加盐的 SHA1算法(其实就是把RFC3174抄写一遍稍加改写),如果没有指定盐,就使用默认盐zime87772676。打开Clion,创建项目类型为 C Library,然后头文件library.h 和实现文件 library.c 如下:
#ifndef MYSHA1SO_LIBRARY_H
#define MYSHA1SO_LIBRARY_H
#include <stdint-gcc.h>
enum {
shaSuccess = 0,
shaNull, /* Null pointer parameter */
shaInputTooLong, /* input data too long */
shaStateError /* called Input after Result */
};
#define SHA1HashSize 20 // sha1 结果为 20字节 长度,常表示成 40个 16进制数字
/*
* 消息长度指的是 bit数, bit数 是8的倍数就可以用 16进制数字 表达
* SHA1要求消息长度为512的倍数,即 512*n bit,不是512的倍数就需要后续“补白操作”
* 补白操作步骤:设原始消息长度为l,则在后面先补白1个bit “1”,然后是一堆 “0”,再
* 补上64bit来表示原始消息长度l,具体中间补多少个 “0”,是要保证最后补上的64bit(8字节)
* 凑齐总共 512*n bit 或者说 16*n 个 word(32-bit),亦或 64*n 个字节
* 原始消息长度l存放时是Big-Endian的(左边字节是高位)
*/
typedef struct SHA1Context {
uint32_t Intermediate_Hash[SHA1HashSize / 4]; /* Message Digest 中间摘要或最终摘要 */
uint32_t Length_Low; /* Message length in bits */
uint32_t Length_High; /* Message length in bits */
/* Index into message block array block缓冲区 为 512-bit,即 64 字节 */
int_least16_t Message_Block_Index;
uint8_t Message_Block[64]; /* 512-bit message blocks */
int Computed; /* Is the digest computed? */
int Corrupted; /* Is the message digest corrupted? */
} SHA1Context;
int SHA1Reset(SHA1Context *);
int SHA1Input(SHA1Context *, const uint8_t *, unsigned int);
int SHA1Result(SHA1Context *, uint8_t Message_Digest[SHA1HashSize]);
void SHA1ProcessMessageBlock(SHA1Context *context);
void SHA1PadMessage(SHA1Context *context);
uint32_t mySHA1(const uint8_t *in, uint8_t *out, const char *salt);
#endif
#include "library.h"
#include <stdio.h>
#include <string.h>
// 参考 https://www.ietf.org/rfc/rfc3174.txt
// 一个 word 为 32-bit,可以表示 0 到 2^32-1 的整数,以下操作为 32-bit word的循环左移操作
#define SHA1CircularShift(bits, word) \
(((word) << (bits)) | ((word) >> (32-(bits))))
//void SHA1PadMessage(SHA1Context *);
//
//void SHA1ProcessMessageBlock(SHA1Context *);
int SHA1Reset(SHA1Context *context) { // 初始化 context
if (!context) {
return shaNull;
}
context->Length_Low = 0;
context->Length_High = 0;
context->Message_Block_Index = 0;
context->Intermediate_Hash[0] = 0x67452301; // H0
context->Intermediate_Hash[1] = 0xEFCDAB89; // H1
context->Intermediate_Hash[2] = 0x98BADCFE; // H2
context->Intermediate_Hash[3] = 0x10325476; // H3
context->Intermediate_Hash[4] = 0xC3D2E1F0; // H4
context->Computed = 0;
context->Corrupted = 0;
return shaSuccess;
}
int SHA1Result(SHA1Context *context, uint8_t Message_Digest[SHA1HashSize]) {
int i;
if (!context || !Message_Digest) {
return shaNull;
}
if (context->Corrupted) {
return context->Corrupted;
}
if (!context->Computed) { // 未计算过
SHA1PadMessage(context); // 可能最终长度不满足要求,补白(包含了摘要计算)
for (i = 0; i < 64; ++i) {
/* message may be sensitive, clear it out 计算完清除原始消息块 */
context->Message_Block[i] = 0;
}
context->Length_Low = 0; /* and clear length */
context->Length_High = 0;
context->Computed = 1; // 标记“已经计算”
}
// H0 >> 24, H0 >> 16, H0 >> 8, H0 >> 0, H1 >> 24, H1 >> 16, H1 >> 8 ...
for (i = 0; i < SHA1HashSize; ++i) {
Message_Digest[i] = context->Intermediate_Hash[i >> 2]
>> 8 * (3 - (i & 0x03));
}
return shaSuccess;
}
int SHA1Input(SHA1Context *context, const uint8_t *message_array, unsigned length) {
if (!length) {
return shaSuccess;
}
if (!context || !message_array) {
return shaNull;
}
if (context->Computed) {
context->Corrupted = shaStateError;
return shaStateError;
}
if (context->Corrupted) {
return context->Corrupted;
}
while (length-- && !context->Corrupted) {
context->Message_Block[context->Message_Block_Index++] =
(*message_array & 0xFF);
context->Length_Low += 8;
if (context->Length_Low == 0) {
context->Length_High++;
if (context->Length_High == 0) {
/* Message is too long */
context->Corrupted = 1;
}
}
if (context->Message_Block_Index == 64) { // 缓冲区满,先计算摘要
SHA1ProcessMessageBlock(context);
}
message_array++; // 下一字节
}
return shaSuccess;
}
void SHA1ProcessMessageBlock(SHA1Context *context) {
const uint32_t K[] = { /* Constants defined in SHA-1, K(t)=K[t/20] */
0x5A827999, // for t in [0, 19]
0x6ED9EBA1, // for t in [20, 39]
0x8F1BBCDC, // for t in [40, 59]
0xCA62C1D6 // for t in [60, 79]
};
int t; /* Loop counter */
uint32_t temp; /* Temporary word value */
uint32_t W[80]; /* Word sequence */
uint32_t A, B, C, D, E; /* Word buffers */
/*
* Initialize the first 16 words in the array W
*/
for (t = 0; t < 16; t++) { // W[0]...W[15] 相当于从块缓冲读入 16个 32-bit 整数(左边高位)
W[t] = context->Message_Block[t * 4] << 24;
W[t] |= context->Message_Block[t * 4 + 1] << 16;
W[t] |= context->Message_Block[t * 4 + 2] << 8;
W[t] |= context->Message_Block[t * 4 + 3];
}
for (t = 16; t < 80; t++) { // W[16]...W[79] 是 0x1 循环左移 x位 得到,x依赖前面16个整数
W[t] = SHA1CircularShift(1, W[t - 3] ^ W[t - 8] ^ W[t - 14] ^ W[t - 16]);
}
A = context->Intermediate_Hash[0];
B = context->Intermediate_Hash[1];
C = context->Intermediate_Hash[2];
D = context->Intermediate_Hash[3];
E = context->Intermediate_Hash[4];
for (t = 0; t < 20; t++) {
temp = SHA1CircularShift(5, A) +
((B & C) | ((~B) & D)) + E + W[t] + K[0]; // f(t;B,C,D), where t in [0,19]
E = D;
D = C;
C = SHA1CircularShift(30, B);
B = A;
A = temp;
}
for (t = 20; t < 40; t++) {
temp = SHA1CircularShift(5, A) +
(B ^ C ^ D) + E + W[t] + K[1]; // f(t;B,C,D), where t in [20,39]
E = D;
D = C;
C = SHA1CircularShift(30, B);
B = A;
A = temp;
}
for (t = 40; t < 60; t++) {
temp = SHA1CircularShift(5, A) +
((B & C) | (B & D) | (C & D)) + E + W[t] + K[2];// f(t;B,C,D), where t in [40,59]
E = D;
D = C;
C = SHA1CircularShift(30, B);
B = A;
A = temp;
}
for (t = 60; t < 80; t++) {
temp = SHA1CircularShift(5, A) +
(B ^ C ^ D) + E + W[t] + K[3]; // f(t;B,C,D), where t in [60,79]
E = D;
D = C;
C = SHA1CircularShift(30, B);
B = A;
A = temp;
}
context->Intermediate_Hash[0] += A; // H0 ~ H4 是计算后的消息摘要
context->Intermediate_Hash[1] += B;
context->Intermediate_Hash[2] += C;
context->Intermediate_Hash[3] += D;
context->Intermediate_Hash[4] += E;
context->Message_Block_Index = 0; // 摘要计算完总是重置消息块索引为 0
}
void SHA1PadMessage(SHA1Context *context) { // 补白中包含了摘要计算!!
/*
* Check to see if the current message block is too small to hold
* the initial padding bits and length. If so, we will pad the
* block, process it, and then continue padding into a second
* block.
*/
if (context->Message_Block_Index > 55) { // 块缓冲比较满,不能常规补白,先计算部分摘要
context->Message_Block[context->Message_Block_Index++] = 0x80;
while (context->Message_Block_Index < 64) {
context->Message_Block[context->Message_Block_Index++] = 0;
}
SHA1ProcessMessageBlock(context);
while (context->Message_Block_Index < 56) {
context->Message_Block[context->Message_Block_Index++] = 0;
}
} else {
context->Message_Block[context->Message_Block_Index++] = 0x80;
while (context->Message_Block_Index < 56) {
context->Message_Block[context->Message_Block_Index++] = 0;
}
}
/*
* Store the message length as the last 8 octets
*/
context->Message_Block[56] = context->Length_High >> 24;
context->Message_Block[57] = context->Length_High >> 16;
context->Message_Block[58] = context->Length_High >> 8;
context->Message_Block[59] = context->Length_High;
context->Message_Block[60] = context->Length_Low >> 24;
context->Message_Block[61] = context->Length_Low >> 16;
context->Message_Block[62] = context->Length_Low >> 8;
context->Message_Block[63] = context->Length_Low;
SHA1ProcessMessageBlock(context);
}
const char defaultSalt[] = "zime87772676";
uint32_t mySHA1(const uint8_t *in, uint8_t *out, const char *salt) {
char mySalt[64] = {0};
uint8_t buf[4096];
unsigned int len = 0;
strcpy(mySalt, *salt != '\0' ? salt : defaultSalt); // 使用指定的salt或默认salt (传入空串用默认串)
uint8_t *p = buf, *q = (uint8_t *)in;
while (*q) { // 将 in[] 装入 buf[]
*p = *q;
++p, ++q, ++len;
}
q = (uint8_t *)mySalt;
while (*q) { // 将 mySalt[] 装入 buf[]
*p = *q;
++p, ++q, ++len;
}
SHA1Context context;
uint32_t myLastError = SHA1Reset(&context); // 初始化
if (myLastError) return myLastError;
myLastError = SHA1Input(&context, buf, len); // 从buf输入,包含了摘要计算
if (myLastError) return myLastError + 100;
myLastError = SHA1Result(&context, out); // 输出结果到out,包含了可能的补白和摘要计算
if (myLastError) return myLastError + 200;
return myLastError;
}
把项目 build 生成 libmySHA1so.so (会自动在项目名前加lib,然后后缀.so)。当然,直接终端下用gcc生成.so也不会太麻烦(gcc -shared -fPIC library.c -o libmySHA1so.so),不是我们的重点,忽略。
我们先写一个C程序来大概测试一下 (library.h头文件和.so动态库拷贝到和以下文件同一目录下)
#include "library.h"
#include <stdio.h>
int main()
{
uint8_t in[1024] = "sjg";
uint8_t out[200];
int i;
const char salt[] = "beta";
if (0 == mySHA1(in, out, salt)) {
for (i = 0; i < 20; i++) {
printf("%02x", out[i]);
}
printf("\n");
} else {
printf("error cacl %s%s\n", in, salt);
}
if (0 == mySHA1(in, out, "")) {
for (i = 0; i < 20; i++) {
printf("%02x", out[i]);
}
printf("\n");
} else {
printf("error cacl %s%s\n", in, "");
}
return 0;
}
sjg@sjg-PC:~/garbage$ gcc test1.c -L. -lmySHA1so -o test1
sjg@sjg-PC:~/garbage$ ./test1
./test1: error while loading shared libraries: libmySHA1so.so: cannot open shared object file: No such file or directory
注意:-L编译时库搜索路径 -l库名 (库名不包含前缀lib和后缀.so)。运行时,提示找不到动态库,我们要把.so文件所在路径添加到环境变量LD_LIBRARY_PATH中(动态库路径细节可以参考 https://www.bbsmax.com/A/ZOJP4lZeJv/)
sjg@sjg-PC:~/garbage$ export LD_LIBRARY_PATH=/home/sjg/garbage:$LD_LIBRARY_PATH
sjg@sjg-PC:~/garbage$ ./test1
1662f332c84db3fc0590bf39eca2a3912c9bfc27
94576415f34481c90986912998f0e3e5ba79cc4e
我们写一个php来看如何动态加载这个so里面的函数并使用。主要步骤包含创建FFI对象(相当于访问代理),创建一些C数据结构用于参数的传入传出,用FFI对象调用有关函数。下面的例子中,FFI对象“包裹”了.so文件中的 mySHA1 函数,$a 是传入参数FFI\CData对象,$b和$c是传出参数FFI\CData对象。用mySHA1分别计算 密码sjg + 用户设定盐beta 和 密码sjg + 无用户设定盐(使用默认盐zime87772676),同时和PHP自身的sha1函数结果对比。
<?php
$libPath = __DIR__.'/libmySHA1so.so'; // .so库文件路径
$ffi = \FFI::cdef(<<<EOF
uint32_t mySHA1(const uint8_t *in, uint8_t *out, const char *salt);
EOF, $libPath);
$passwordText = 'sjg';
$salt = 'beta';
$a = FFI::new("uint8_t[1024]");
$b = FFI::new("uint8_t[100]");
$c = FFI::new("uint8_t[100]");
for ($i = 0; $i < strlen($passwordText); $i++)
$a[$i] = ord($passwordText[$i]);
$error = $ffi->mySHA1($a, $b, $salt);
echo $error.PHP_EOL;
$hash = '';
for ($i = 0; $i < 20; $i++)
$hash .= $b[$i] > 0xf ? dechex($b[$i]) : '0'.dechex($b[$i]);
echo $hash.PHP_EOL;
echo sha1($passwordText . $salt).PHP_EOL;
$error = $ffi->mySHA1($a, $c, "");
echo $error.PHP_EOL;
$hash = '';
for ($i = 0; $i < 20; $i++)
$hash .= $c[$i] > 0xf ? dechex($c[$i]) : '0'.dechex($c[$i]);
echo $hash.PHP_EOL;
$salt = 'zime87772676';
echo sha1($passwordText.$salt).PHP_EOL;
运行结果如下:
sjg@sjg-PC:~/garbage$ php testFFI.php
0
1662f332c84db3fc0590bf39eca2a3912c9bfc27
1662f332c84db3fc0590bf39eca2a3912c9bfc27
0
94576415f34481c90986912998f0e3e5ba79cc4e
94576415f34481c90986912998f0e3e5ba79cc4e
在已经有.h头文件的情况下,自己手工去“包裹”需要用到的函数后结构,不够方便,而且当函数比较多的时候更是如此(包裹的内容并非都可以从.h拷贝就可以完事的)。我们可以利用已有的.h头文件,生成一个“中介”.h头文件,然后利用该“中介”头文件来加载创建FFI对象。
先创建“中介”头文件mySHA1.h,里面添加以下内容 (FFI::load()用于从C头文件加载声明,宏定义FFI_LIB指明哪个.so应当被加载,尽管似乎没有什么用,但似乎此种方式要求宏定义FFI_SCOPE存在)
#define FFI_SCOPE "MYSHA1"
#define FFI_LIB "/home/sjg/garbage/libmySHA1so.so"
执行命令 cpp -P library.h >> mySHA1.h 将library.h头文件进行文件包含和宏替换预处理后,将预处理结果追加到mySHA1.h中,这样可以避免手动添加类型定义和函数声明。
最后,使用动态库中函数的例子开头部分改写成如下(后面部分代码一样)。(说明:这里的做法类似PHP8源码中测试文件 ext/ffi/tests/301.phpt 这个例子)
<?php
$ffi = FFI::load(__DIR__.'/mySHA1.h');
$passwordText = 'sjg';
$salt = 'beta';
无论是官网关于FFI的说明还是鸟哥文章,都提到FFI的性能问题:每次请求都加载动态库并使用里面的函数和数据结构是开销很高的,减轻影响的办法是使用“预加载(preload)”。
“预加载(preload)”有2种做法:一种是预加载“中介”.h头文件;另一种是使用额外的“中介”预加载php文件,用此“中介”php加载“中介”.h头文件,同时将此php缓存到opcache。
先来看第一种。修改php配置文件php.ini (可以php -r "phpinfo();" | more 查看Loaded Configuration File)差不多最后部分(事实上,我是看配置中注释部分含义来推断应该直接写.h头文件的):
[ffi]
; FFI API restriction. Possible values:
; "preload" - enabled in CLI scripts and preloaded files (default)
; "false" - always disabled
; "true" - always enabled
ffi.enable=true
; List of headers files to preload, wildcard patterns allowed.
ffi.preload="/home/sjg/garbage/mySHA1.h"
使用的部分只需要把创建FFI对象实例的部分改一下就行(用FFI::scope()创建),其余不变:
<?php
$ffi = FFI::scope('MYSHA1');
$passwordText = 'sjg';
$salt = 'beta';
再来看第二种。参照PHP8源码中测试文件 ext/ffi/tests/300.phpt,修改php配置文件php.ini,在[opcache]小节,设置如下 (在 opcache 预加载 ffi_preload.php文件)
[opcache]
opcache.enable=1
opcache.enable_cli=1
opcache.optimization_level=-1
opcache.preload="/home/sjg/garbage/ffi_preload.php"
opcache.file_cache_only=0
而[ffi]小节只启用ffi,修改设置如下 (这里启用ffi为preload,即默认值,此时,对于CLI模式,ffi总是启用的,而非CLI模式,就是只有opcache预加载的文件才能使用ffi了,这就是鸟哥文章中说的考虑“安全”的情况了)
[ffi]
; FFI API restriction. Possible values:
; "preload" - enabled in CLI scripts and preloaded files (default)
; "false" - always disabled
; "true" - always enabled
ffi.enable=preload
; List of headers files to preload, wildcard patterns allowed.
;ffi.preload="/home/sjg/garbage/mySHA1.h"
ffi_preload.php文件比较简单,就是用FFI::load()加载“中介”.h文件(mySHA1.h同前)
<?php
$ffi = FFI::load(__DIR__.'/mySHA1.h');
使用的方式和前面是一样的(故代码就是前面的代码),即 $ffi = FFI::scope('MYSHA1'); 创建 FFI实例对象。
鸟哥的文章中“安全”的做法,指的是预加载的php文件中,把需要的功能打包好,然后避免FFI有关功能被其他PHP用到(除了调用我们打包的函数以外)