云南省网站建设_网站建设公司_Spring_seo优化
2026/1/19 12:18:46 网站建设 项目流程

Intro

  你的解法被允许包含任何可以通过编译的代码, 包括但不限于内联汇编 (不过 puzzle 设计时并不会考虑这种解法), 未指明行为或未定义行为, 但请确保自己知道自己解法的正确性从何而来. 当你的解法依赖 RSI/ABI/编译器特性时, 请以如下配置为参考 (这个配置下的编译结果应该和 NOI Linux 2 是一致的, 如果你发现不一致的情况可以告诉我 qwq):

Target: x86_64-linux-gnu
gcc version 11.4.0 (Ubuntu 11.4.0-1ubuntu1~22.04.2)

  全文编译命令如下:

g++ puz_N.cpp ans_N.cpp -Wall -Wextra -Wshadow -Wconversion -Werror -Og -fno-inline -fno-stack-protector -masm=att -o test

其中:

  • -Wall -Wextra -Wshadow -Wconversion -Werror 要求你在写 UB 的时候伪装一下, 不能被编译器发现 (大嘘).
  • -Og 保证你的代码几乎被逐句翻译为可读性较高的汇编指令而不引入过多优化.
  • -fno-inline 显式阻止了函数内联 (这在 -Og 优化级别下仍然可能发生), 以方便你更好地利用过程调用相关的 ABI 约定.
  • -fno-stack-protector 关闭了汇编指令层次的栈保护措施, 我们大概不会做这方面的 puzzle, 关掉这个单纯是为了让函数序言不那么冗长.
  • -masm=att 指定汇编格式. 别问为什么用 AT&T, 因为 ICS 也用这个.

  测试用的头文件如下 (注意它可能会不断更新, 但总是兼容旧版; 你可以在 ans_N.cpp 中直接 include 它).

#pragma once#include <cstdio>
#include <random>
#include <cstdlib>
#include <type_traits>typedef u_int8_t u8;
typedef u_int32_t u32;
typedef __float80 f80; // long double on x86-64 is 80-bit extended precision#define Success() \do { \fprintf(stderr, "Puzzle solved.\n"); \exit(0); \} while (false)#define Require(cond, msg, ...) \do { \if (!(cond)) { \fprintf(stderr, "Failed: " msg "\n", ##__VA_ARGS__); \_Exit(1); \} \} while (false)#define ReqEq_u32(x, y) Require(u32(x) == u32(y), "%u != %u", u32(x), u32(y))

  我们认为你的答案正确, 当且仅当它能通过编译并且使得 test 正常运行并最终返回 0.

  为了方便你测试, 提供一个编译脚本:

#!/bin/bash
id=$1
puzzle=puz_$id.cpp
answer=ans_$id.cpp
FLAGS="-Wall -Wextra -Wshadow -Wconversion -Werror -Og -fno-inline -fno-stack-protector -masm=att"
g++ $FLAGS $puzzle $answer -o test && ./test

你可以将 judge.h, puz_N.cpp, ans_N.cpprun.sh 保存在同一目录下, 然后使用命令

linux> chmod +x run.sh

为脚本添加可执行权限, 此后就能使用

linux> ./run.sh N

来编译并运行第 \(N\) 个 puzzle 的程序.

Puzzle #0

// puz_0.cpp#include "judge.h"u32 guess(void);u32 sample() {return std::random_device()();
}int main() {u32 x = sample();u32 y = guess();ReqEq_u32(x, y);Success();
}

  请在 ans_0.cpp 中实现 u32 guess(void) 函数.

  • 冷知识: 在函数声明时, u32 guess(void);u32 guess(); 并不等价. 前者告诉编译器 "这是一个无参数的函数", 后者告诉编译器 "这是一个函数, 但我不知道它的参数类型".

Discussion

  @zero4338 提供了一种解答 (因为 ta 提供答案时编译命令中的 -Wx 选项没有这么多, 我略修改了代码使得它可以通过编译):

#include "judge.h"u32 guess(void) {int z = 0xdeadbeef; // useless value to escape compiler checkint y = *((u32*)(&z) + 12);return y;
}

然而这个解答并不正确: 除了 guess::z 因为被取地址必须分配在栈上外, 其他三个局部变量 (main::x, main::y, guess::y) 并不一定被编译器安排在栈空间上, 而更有可能被安排为寄存器变量, 这时基于栈偏移的做法就不奏效了.

  不过, 我们可以弱化一下问题, 让基于栈偏移的做法有前途. 我们将所有局部变量加上 volatile 修饰:

  • volatile 是一种类型修饰符, 告诉编译器受修饰变量可能被除当前线程以外的线程, 程序等修改. 从效果上, volatile 强制编译器每次读变量时, 都从内存中读取 (因此它也必须存储在内存中), 而不把它缓存在寄存器中.
    • volatile int x;x++++x-std=c++2a (C++ 20) 起被遗弃 (deprecated, by -Wvolatile), 因为它们隐含地违背了上述原则.
// puz_0.cpp#include "judge.h"u32 guess(void);u32 sample() {return std::random_device()();
}int main() {volatile u32 x = sample();volatile u32 y = guess();ReqEq_u32(x, y);return 0;
}
// ans_0.cpp#include "judge.h"u32 guess(void) {volatile u32 z = 0xdeadbeef; // useless value to escape compiler checkvolatile u32 y = *((u32*)(&z) + 12);return y;
}

这样, 编译后使用命令

linux> objdump -d test
  • objdump 命令用于展示目标文件 (你可以理解作由编译器生成的具有特定组织格式的一类二进制文件) 的信息.
    • -d/--disassemble 指定展示可执行节 (executable section) 的反汇编信息.

并观察 mainguess 的汇编:

main:endbr64 sub    $0x18,%rsp       ; 将栈指针 (总是 rsp) 减去 0x18;   相当于为当前函数分配 0x18 字节的栈帧call   1349             ; call <sample>, 这个函数名 objdump 会帮你标注;   汇编里的数字是 PC 相对寻址的偏移量, 不重要mov    %eax,0xc(%rsp)   ; eax 是 sample() 的 32 位返回值;   所以 main::x 在 rsp+12call   15a7             ; call <guess>;   此指令执行后 rsp<-rsp+8 (压入返回地址),;   紧接着从 guess 标签的下一条指令开始执行...mov    %eax,0x8(%rsp)mov    0xc(%rsp),%edxmov    0x8(%rsp),%eaxcmp    %eax,%edx; ......; ......guess:endbr64 movl   $0xdeadbeef,-0x4(%rsp)  ; guess::z 在 rsp-4mov    0x2c(%rsp),%eax         ; 求出 guess::y 的值. 我们需要调整 0x2c 这个偏移量mov    %eax,-0x8(%rsp)mov    -0x8(%rsp),%eaxret

根据汇编, 正确的 u32 指针偏移量应该是 \((12+8+4)/4=6\), 即 y = *((u32*)(&z) + 6). 此时编译运行成功.

  • (相信大家比较熟悉了.) C/C++ 中, 对指针的加减行为 由指针类型决定. 笼统地说, type* pp + x 意味着 p 偏移 x * sizeof(type). 特别地, 在 C++ 中, 对 void* 的加法是未定义行为, 并且可能无法通过编译. 但 C 的 GNU 扩展中规定 void* 的加法步长为 \(1\), 相当于将它视作 char* 来运算.

  但是, 即使加上 volatile 标记, 这种解法也不尽优美: "main::x%rsp+12 仍然依赖于编译器的规划." 再例如, 我们知道 x86-64 Linux 约定, 在 call 指令之前, %rsp 必须 \(16\) 字节对齐. 因此, 进入 main 函数时, $rsp % 16 == 8, main 函数内部申请了 \(4+4=8\) 字节栈空间, 所以理论上编译器可以只给 main 分配 \(8\) 字节内存 (即代码块第三行改为 sub $0x8,%rsp), 这样也满足栈对齐需求. 选择 \(24\) 字节而非 \(8\) 字节仍然依赖于编译器甚至优化选项.

Solution

TBC...

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询