Russell Gallop | b9e15b2 | 2015-04-02 15:01:53 +0000 | [diff] [blame^] | 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) |