|
| 1 | +#!/usr/bin/env python2.7 |
| 2 | + |
| 3 | +"""Check CFC - Check Compile Flow Consistency |
| 4 | +
|
| 5 | +This is a compiler wrapper for testing that code generation is consistent with |
| 6 | +different compilation processes. It checks that code is not unduly affected by |
| 7 | +compiler options or other changes which should not have side effects. |
| 8 | +
|
| 9 | +To use: |
| 10 | +-Ensure that the compiler under test (i.e. clang, clang++) is on the PATH |
| 11 | +-On Linux copy this script to the name of the compiler |
| 12 | + e.g. cp check_cfc.py clang && cp check_cfc.py clang++ |
| 13 | +-On Windows use setup.py to generate check_cfc.exe and copy that to clang.exe |
| 14 | + and clang++.exe |
| 15 | +-Enable the desired checks in check_cfc.cfg (in the same directory as the |
| 16 | + wrapper) |
| 17 | + e.g. |
| 18 | +[Checks] |
| 19 | +dash_g_no_change = true |
| 20 | +dash_s_no_change = false |
| 21 | +
|
| 22 | +-The wrapper can be run using its absolute path or added to PATH before the |
| 23 | + compiler under test |
| 24 | + e.g. export PATH=<path to check_cfc>:$PATH |
| 25 | +-Compile as normal. The wrapper intercepts normal -c compiles and will return |
| 26 | + non-zero if the check fails. |
| 27 | + e.g. |
| 28 | +$ clang -c test.cpp |
| 29 | +Code difference detected with -g |
| 30 | +--- /tmp/tmp5nv893.o |
| 31 | ++++ /tmp/tmp6Vwjnc.o |
| 32 | +@@ -1 +1 @@ |
| 33 | +- 0: 48 8b 05 51 0b 20 00 mov 0x200b51(%rip),%rax |
| 34 | ++ 0: 48 39 3d 51 0b 20 00 cmp %rdi,0x200b51(%rip) |
| 35 | +
|
| 36 | +-To run LNT with Check CFC specify the absolute path to the wrapper to the --cc |
| 37 | + and --cxx options |
| 38 | + e.g. |
| 39 | + lnt runtest nt --cc <path to check_cfc>/clang \\ |
| 40 | + --cxx <path to check_cfc>/clang++ ... |
| 41 | +
|
| 42 | +To add a new check: |
| 43 | +-Create a new subclass of WrapperCheck |
| 44 | +-Implement the perform_check() method. This should perform the alternate compile |
| 45 | + and do the comparison. |
| 46 | +-Add the new check to check_cfc.cfg. The check has the same name as the |
| 47 | + subclass. |
| 48 | +""" |
| 49 | + |
| 50 | +from __future__ import print_function |
| 51 | + |
| 52 | +import imp |
| 53 | +import os |
| 54 | +import platform |
| 55 | +import shutil |
| 56 | +import subprocess |
| 57 | +import sys |
| 58 | +import tempfile |
| 59 | +import ConfigParser |
| 60 | +import io |
| 61 | + |
| 62 | +import obj_diff |
| 63 | + |
| 64 | +def is_windows(): |
| 65 | + """Returns True if running on Windows.""" |
| 66 | + return platform.system() == 'Windows' |
| 67 | + |
| 68 | +class WrapperStepException(Exception): |
| 69 | + """Exception type to be used when a step other than the original compile |
| 70 | + fails.""" |
| 71 | + def __init__(self, msg, stdout, stderr): |
| 72 | + self.msg = msg |
| 73 | + self.stdout = stdout |
| 74 | + self.stderr = stderr |
| 75 | + |
| 76 | +class WrapperCheckException(Exception): |
| 77 | + """Exception type to be used when a comparison check fails.""" |
| 78 | + def __init__(self, msg): |
| 79 | + self.msg = msg |
| 80 | + |
| 81 | +def main_is_frozen(): |
| 82 | + """Returns True when running as a py2exe executable.""" |
| 83 | + return (hasattr(sys, "frozen") or # new py2exe |
| 84 | + hasattr(sys, "importers") or # old py2exe |
| 85 | + imp.is_frozen("__main__")) # tools/freeze |
| 86 | + |
| 87 | +def get_main_dir(): |
| 88 | + """Get the directory that the script or executable is located in.""" |
| 89 | + if main_is_frozen(): |
| 90 | + return os.path.dirname(sys.executable) |
| 91 | + return os.path.dirname(sys.argv[0]) |
| 92 | + |
| 93 | +def remove_dir_from_path(path_var, directory): |
| 94 | + """Remove the specified directory from path_var, a string representing |
| 95 | + PATH""" |
| 96 | + pathlist = path_var.split(os.pathsep) |
| 97 | + norm_directory = os.path.normpath(os.path.normcase(directory)) |
| 98 | + pathlist = filter(lambda x: os.path.normpath( |
| 99 | + os.path.normcase(x)) != norm_directory, pathlist) |
| 100 | + return os.pathsep.join(pathlist) |
| 101 | + |
| 102 | +def path_without_wrapper(): |
| 103 | + """Returns the PATH variable modified to remove the path to this program.""" |
| 104 | + scriptdir = get_main_dir() |
| 105 | + path = os.environ['PATH'] |
| 106 | + return remove_dir_from_path(path, scriptdir) |
| 107 | + |
| 108 | +def flip_dash_g(args): |
| 109 | + """Search for -g in args. If it exists then return args without. If not then |
| 110 | + add it.""" |
| 111 | + if '-g' in args: |
| 112 | + # Return args without any -g |
| 113 | + return [x for x in args if x != '-g'] |
| 114 | + else: |
| 115 | + # No -g, add one |
| 116 | + return args + ['-g'] |
| 117 | + |
| 118 | +def derive_output_file(args): |
| 119 | + """Derive output file from the input file (if just one) or None |
| 120 | + otherwise.""" |
| 121 | + infile = get_input_file(args) |
| 122 | + if infile is None: |
| 123 | + return None |
| 124 | + else: |
| 125 | + return '{}.o'.format(os.path.splitext(infile)[0]) |
| 126 | + |
| 127 | +def get_output_file(args): |
| 128 | + """Return the output file specified by this command or None if not |
| 129 | + specified.""" |
| 130 | + grabnext = False |
| 131 | + for arg in args: |
| 132 | + if grabnext: |
| 133 | + return arg |
| 134 | + if arg == '-o': |
| 135 | + # Specified as a separate arg |
| 136 | + grabnext = True |
| 137 | + elif arg.startswith('-o'): |
| 138 | + # Specified conjoined with -o |
| 139 | + return arg[2:] |
| 140 | + assert grabnext == False |
| 141 | + |
| 142 | + return None |
| 143 | + |
| 144 | +def is_output_specified(args): |
| 145 | + """Return true is output file is specified in args.""" |
| 146 | + return get_output_file(args) is not None |
| 147 | + |
| 148 | +def replace_output_file(args, new_name): |
| 149 | + """Replaces the specified name of an output file with the specified name. |
| 150 | + Assumes that the output file name is specified in the command line args.""" |
| 151 | + replaceidx = None |
| 152 | + attached = False |
| 153 | + for idx, val in enumerate(args): |
| 154 | + if val == '-o': |
| 155 | + replaceidx = idx + 1 |
| 156 | + attached = False |
| 157 | + elif val.startswith('-o'): |
| 158 | + replaceidx = idx |
| 159 | + attached = True |
| 160 | + |
| 161 | + if replaceidx is None: |
| 162 | + raise Exception |
| 163 | + replacement = new_name |
| 164 | + if attached == True: |
| 165 | + replacement = '-o' + new_name |
| 166 | + args[replaceidx] = replacement |
| 167 | + return args |
| 168 | + |
| 169 | +def add_output_file(args, output_file): |
| 170 | + """Append an output file to args, presuming not already specified.""" |
| 171 | + return args + ['-o', output_file] |
| 172 | + |
| 173 | +def set_output_file(args, output_file): |
| 174 | + """Set the output file within the arguments. Appends or replaces as |
| 175 | + appropriate.""" |
| 176 | + if is_output_specified(args): |
| 177 | + args = replace_output_file(args, output_file) |
| 178 | + else: |
| 179 | + args = add_output_file(args, output_file) |
| 180 | + return args |
| 181 | + |
| 182 | +gSrcFileSuffixes = ('.c', '.cpp', '.cxx', '.c++', '.cp', '.cc') |
| 183 | + |
| 184 | +def get_input_file(args): |
| 185 | + """Return the input file string if it can be found (and there is only |
| 186 | + one).""" |
| 187 | + inputFiles = list() |
| 188 | + for arg in args: |
| 189 | + testarg = arg |
| 190 | + quotes = ('"', "'") |
| 191 | + while testarg.endswith(quotes): |
| 192 | + testarg = testarg[:-1] |
| 193 | + testarg = os.path.normcase(testarg) |
| 194 | + |
| 195 | + # Test if it is a source file |
| 196 | + if testarg.endswith(gSrcFileSuffixes): |
| 197 | + inputFiles.append(arg) |
| 198 | + if len(inputFiles) == 1: |
| 199 | + return inputFiles[0] |
| 200 | + else: |
| 201 | + return None |
| 202 | + |
| 203 | +def set_input_file(args, input_file): |
| 204 | + """Replaces the input file with that specified.""" |
| 205 | + infile = get_input_file(args) |
| 206 | + if infile: |
| 207 | + infile_idx = args.index(infile) |
| 208 | + args[infile_idx] = input_file |
| 209 | + return args |
| 210 | + else: |
| 211 | + # Could not find input file |
| 212 | + assert False |
| 213 | + |
| 214 | +def is_normal_compile(args): |
| 215 | + """Check if this is a normal compile which will output an object file rather |
| 216 | + than a preprocess or link.""" |
| 217 | + compile_step = '-c' in args |
| 218 | + # Bitcode cannot be disassembled in the same way |
| 219 | + bitcode = '-flto' in args or '-emit-llvm' in args |
| 220 | + # Version and help are queries of the compiler and override -c if specified |
| 221 | + query = '--version' in args or '--help' in args |
| 222 | + # Check if the input is recognised as a source file (this may be too |
| 223 | + # strong a restriction) |
| 224 | + input_is_valid = bool(get_input_file(args)) |
| 225 | + return compile_step and not bitcode and not query and input_is_valid |
| 226 | + |
| 227 | +def run_step(command, my_env, error_on_failure): |
| 228 | + """Runs a step of the compilation. Reports failure as exception.""" |
| 229 | + # Need to use shell=True on Windows as Popen won't use PATH otherwise. |
| 230 | + p = subprocess.Popen(command, stdout=subprocess.PIPE, |
| 231 | + stderr=subprocess.PIPE, env=my_env, shell=is_windows()) |
| 232 | + (stdout, stderr) = p.communicate() |
| 233 | + if p.returncode != 0: |
| 234 | + raise WrapperStepException(error_on_failure, stdout, stderr) |
| 235 | + |
| 236 | +def get_temp_file_name(suffix): |
| 237 | + """Get a temporary file name with a particular suffix. Let the caller be |
| 238 | + reponsible for deleting it.""" |
| 239 | + tf = tempfile.NamedTemporaryFile(suffix=suffix, delete=False) |
| 240 | + tf.close() |
| 241 | + return tf.name |
| 242 | + |
| 243 | +class WrapperCheck(object): |
| 244 | + """Base class for a check. Subclass this to add a check.""" |
| 245 | + def __init__(self, output_file_a): |
| 246 | + """Record the base output file that will be compared against.""" |
| 247 | + self._output_file_a = output_file_a |
| 248 | + |
| 249 | + def perform_check(self, arguments, my_env): |
| 250 | + """Override this to perform the modified compilation and required |
| 251 | + checks.""" |
| 252 | + raise NotImplementedError("Please Implement this method") |
| 253 | + |
| 254 | +class dash_g_no_change(WrapperCheck): |
| 255 | + def perform_check(self, arguments, my_env): |
| 256 | + """Check if different code is generated with/without the -g flag.""" |
| 257 | + output_file_b = get_temp_file_name('.o') |
| 258 | + |
| 259 | + alternate_command = list(arguments) |
| 260 | + alternate_command = flip_dash_g(alternate_command) |
| 261 | + alternate_command = set_output_file(alternate_command, output_file_b) |
| 262 | + run_step(alternate_command, my_env, "Error compiling with -g") |
| 263 | + |
| 264 | + # Compare disassembly (returns first diff if differs) |
| 265 | + difference = obj_diff.compare_object_files(self._output_file_a, |
| 266 | + output_file_b) |
| 267 | + if difference: |
| 268 | + raise WrapperCheckException( |
| 269 | + "Code difference detected with -g\n{}".format(difference)) |
| 270 | + |
| 271 | + # Clean up temp file if comparison okay |
| 272 | + os.remove(output_file_b) |
| 273 | + |
| 274 | +class dash_s_no_change(WrapperCheck): |
| 275 | + def perform_check(self, arguments, my_env): |
| 276 | + """Check if compiling to asm then assembling in separate steps results |
| 277 | + in different code than compiling to object directly.""" |
| 278 | + output_file_b = get_temp_file_name('.o') |
| 279 | + |
| 280 | + alternate_command = arguments + ['-via-file-asm'] |
| 281 | + alternate_command = set_output_file(alternate_command, output_file_b) |
| 282 | + run_step(alternate_command, my_env, |
| 283 | + "Error compiling with -via-file-asm") |
| 284 | + |
| 285 | + # Compare disassembly (returns first diff if differs) |
| 286 | + difference = obj_diff.compare_object_files(self._output_file_a, |
| 287 | + output_file_b) |
| 288 | + if difference: |
| 289 | + raise WrapperCheckException( |
| 290 | + "Code difference detected with -S\n{}".format(difference)) |
| 291 | + |
| 292 | + # Clean up temp file if comparison okay |
| 293 | + os.remove(output_file_b) |
| 294 | + |
| 295 | +if __name__ == '__main__': |
| 296 | + # Create configuration defaults from list of checks |
| 297 | + default_config = """ |
| 298 | +[Checks] |
| 299 | +""" |
| 300 | + |
| 301 | + # Find all subclasses of WrapperCheck |
| 302 | + checks = [cls.__name__ for cls in vars()['WrapperCheck'].__subclasses__()] |
| 303 | + |
| 304 | + for c in checks: |
| 305 | + default_config += "{} = false\n".format(c) |
| 306 | + |
| 307 | + config = ConfigParser.RawConfigParser() |
| 308 | + config.readfp(io.BytesIO(default_config)) |
| 309 | + scriptdir = get_main_dir() |
| 310 | + config_path = os.path.join(scriptdir, 'check_cfc.cfg') |
| 311 | + try: |
| 312 | + config.read(os.path.join(config_path)) |
| 313 | + except: |
| 314 | + print("Could not read config from {}, " |
| 315 | + "using defaults.".format(config_path)) |
| 316 | + |
| 317 | + my_env = os.environ.copy() |
| 318 | + my_env['PATH'] = path_without_wrapper() |
| 319 | + |
| 320 | + arguments_a = list(sys.argv) |
| 321 | + |
| 322 | + # Prevent infinite loop if called with absolute path. |
| 323 | + arguments_a[0] = os.path.basename(arguments_a[0]) |
| 324 | + |
| 325 | + # Sanity check |
| 326 | + enabled_checks = [check_name |
| 327 | + for check_name in checks |
| 328 | + if config.getboolean('Checks', check_name)] |
| 329 | + checks_comma_separated = ', '.join(enabled_checks) |
| 330 | + print("Check CFC, checking: {}".format(checks_comma_separated)) |
| 331 | + |
| 332 | + # A - original compilation |
| 333 | + output_file_orig = get_output_file(arguments_a) |
| 334 | + if output_file_orig is None: |
| 335 | + output_file_orig = derive_output_file(arguments_a) |
| 336 | + |
| 337 | + p = subprocess.Popen(arguments_a, env=my_env, shell=is_windows()) |
| 338 | + p.communicate() |
| 339 | + if p.returncode != 0: |
| 340 | + sys.exit(p.returncode) |
| 341 | + |
| 342 | + if not is_normal_compile(arguments_a) or output_file_orig is None: |
| 343 | + # Bail out here if we can't apply checks in this case. |
| 344 | + # Does not indicate an error. |
| 345 | + # Maybe not straight compilation (e.g. -S or --version or -flto) |
| 346 | + # or maybe > 1 input files. |
| 347 | + sys.exit(0) |
| 348 | + |
| 349 | + # Sometimes we generate files which have very long names which can't be |
| 350 | + # read/disassembled. This will exit early if we can't find the file we |
| 351 | + # expected to be output. |
| 352 | + if not os.path.isfile(output_file_orig): |
| 353 | + sys.exit(0) |
| 354 | + |
| 355 | + # Copy output file to a temp file |
| 356 | + temp_output_file_orig = get_temp_file_name('.o') |
| 357 | + shutil.copyfile(output_file_orig, temp_output_file_orig) |
| 358 | + |
| 359 | + # Run checks, if they are enabled in config and if they are appropriate for |
| 360 | + # this command line. |
| 361 | + current_module = sys.modules[__name__] |
| 362 | + for check_name in checks: |
| 363 | + if config.getboolean('Checks', check_name): |
| 364 | + class_ = getattr(current_module, check_name) |
| 365 | + checker = class_(temp_output_file_orig) |
| 366 | + try: |
| 367 | + checker.perform_check(arguments_a, my_env) |
| 368 | + except WrapperCheckException as e: |
| 369 | + # Check failure |
| 370 | + print(e.msg, file=sys.stderr) |
| 371 | + |
| 372 | + # Remove file to comply with build system expectations (no |
| 373 | + # output file if failed) |
| 374 | + os.remove(output_file_orig) |
| 375 | + sys.exit(1) |
| 376 | + |
| 377 | + except WrapperStepException as e: |
| 378 | + # Compile step failure |
| 379 | + print(e.msg, file=sys.stderr) |
| 380 | + print("*** stdout ***", file=sys.stderr) |
| 381 | + print(e.stdout, file=sys.stderr) |
| 382 | + print("*** stderr ***", file=sys.stderr) |
| 383 | + print(e.stderr, file=sys.stderr) |
| 384 | + |
| 385 | + # Remove file to comply with build system expectations (no |
| 386 | + # output file if failed) |
| 387 | + os.remove(output_file_orig) |
| 388 | + sys.exit(1) |
0 commit comments