CppCon 2018: James McNellis “Unwinding the Stack: Exploring How C++ Exceptions Work on Windows”

Video Statistics and Information

Video
Captions Word Cloud
Reddit Comments
Captions
- So welcome everyone, my name is James McNellis. I'm an engineer at Microsoft, where I work on our time travel debugging toolkit. I'm not actually not going to be talking at all about that today, so if you're interested in more information about that, I'd highly recommend you check out our talk from last year's CppCon, where we announced and released the first preview of those tools. Today, though, I'll be talking about C++ Exceptions. Specifically, how C++ Exceptions work on Windows in the Visual C++ implementation. I will not be discussing how C++ Exceptions work on other platforms, whether you should use exceptions in C++, how best to use exceptions, or how to write exceptions save C++. Those are all very interesting subjects. We've had talks on many of those at previous CppCons. I'd love to see talks on others of those at future CppCons, but they're out of scope for today's talk. We're going to focus solely on how C++ Exceptions work on Windows. So C++ Exceptions, this being CppCon I'm sure that most of us are at least somewhat familiar with C++ Exceptions. We can write throw expressions that throw exceptions, and we can write try catch blocks to handle them. But, today, C++ Exceptions will be at the end of our journey. So we're going to start at the bottom of the stack and work our way towards them. If you've done much programming on Windows, you may be familiar with structured exceptions as well. And you may have written these try except blocks to handle them. We will meet these on our way, and we will meet these language extensions on our journey, but this is also not where our journey begins. We're going to start all the way at the very beginning. What happens when something goes wrong, like really wrong? So I'm gonna tell you a story. Once upon a time, many years ago, I was working on a program on my trusty 32-bit x86 PC. Those details are actually important. In this program I had a global constant named, conveniently enough, ConstantZero, and it's value was zero. But I had a problem. At the beginning of my program, the value of this global variable was zero, and that was fine. But later in my program, I really needed this global variable to have a different value. So I did what any programmer would do, and I just tried to change it. Unfortunately, the C++ compiler was in one of it's grumpy moods and it told me "no, you cannot assign to a variable that is const." My teammates told me I should just remove the const keyword, but as a professional C++ progammer, I had a better idea. I added a const_cast, and to that the compiler gave me a big thumbs up. So I copied the program onto my trusty floppy disk, and ran it. Unfortunately, I was very surprised by the result. So it printed the first message, but where did my second message go? So then I checked to see if something went wrong and I discovered that my program returned this big, scary negative number. I'm much more used to my programs returning zero. So, what happened? When I declared this global constant variable in my program, what I was actually doing is telling the compiler to tell the linker to tell the operating system to tell the CPU, "don't let this thing get modified." So the CPU is executing the instructions in my program, and it gets to this part where the program tried to write to that global variable. And the CPU remembers how we told it not to let anyone do that. So it metaphorically throws up it's hands and asks, "what are you doing?" Though the CPU doesn't actually speak English so, what it really does, is it signals a general protection exception, or fault. This is not a C++ Exception, this is a hardware exception actually signaled from the CPU. This causes control to transfer from your program back to the operating system via the interrupt table, and the operating system now is responsible for fixing the problem. So the OS is going to do two things here. The first thing the OS is going to do, is it's going to record the current state of the CPU so that it knows the state the program was in when the exception occurred. So the state of the CPU is stored in a context structure, and consists of the values of all the CPU registers. Since CPU registers are architecture-specific, the definitely of this context structure is different on different architectures. We're discussing 32-bit x86 here, so this is the 32-bit x86 context. You can see, for example, that it has the integer registers like Eax and Ebx. And it has the Control Registers like the instruction pointer Eip, and the stack pointer Esp. For our purposes, the actual contents don't matter so much. The important thing is that the OS records the state of the CPU. The second thing the OS is going to do, is record information about what went wrong by creating an exception record. So, whereas the context is architecture-specific, the exception record contains an architecture-neutral description of the failure. So let's look at the exception record that the OS will create for this general protection exception that the CPU signaled. The first and most important member of the exception record is the exception code, which indicates the type of the problem. For our error, the OS uses the special status code status_access_violation, which is the architecture-neutral equivalent of the x86-specific general protection exception. Next, the exception record can have flags and a nested exception. No flags are needed here, and we don't have a nested exception, so these are just left as their default values. The exception record contains the address at which the exception occurred, so this is generally the address of the instruction that caused the exception. In this case, it's the address of what would be a move instruction that tried to modify the global variable. Finally, the exception record can carry exception-specific payloads, so up to 15 parameters can be included. For an access violation, only two parameters are used. The first parameter contains a flag indicating the kind of access violation. So, here it was a write fault, but it also could have been a read or an execute fault. The second parameter contains the address of the access that faulted. So, whereas the exception address member contains the address of the instruction that caused the fault, this address parameter contains the address of the memory location that the instruction tried to access. These parameter values can be found in the documentation for exception record if you go look on MSDN. So the OS has now saved the CPU context at the point where the problem occurred, and it's put together an exception record containing a generalized description of the problem. Now what? So it has a few options. If it wanted to be overly dramatic, it could shut down the machine or give us one of those famous blue craft screens. If the error happened inside of the kernel, this might actually be a reasonable thing to do. But this error happened inside of our program, not the kernel, so killing whole machine would be overly drastic. A more reasonable option would be for the OS just to kill our program. We did something wrong, it wouldn't be entirely unreasonable for the OS to punish us. But, no, the OS really wants things to work out between us, so it's going to give us an opportunity to make things right. The OS is going to tell our program about the failure, and it's going to ask us if we'd like to fix things, or if we'd have some way that we'd like to handle the error. How is it going to do this? It's going to raise what's called a structured exception inside of our program. So structured exception handling, or SEH, is the generalized error propagation mechanism used on Windows for this kind of asynchronous error. It's part of the Windows ABI, so the binary contract between your program and the operating system. It is language-neutral, though our focus today will be on C and C++, it's also usable in other languages and environments on Windows. So our program needs some way to tell the OS whether and how we would like to handle this structured exception. In a Windows program, every thread has an associated data structure called thread environment block, or TEB. Most of the contents of this data structure are internal implementation details of the operating system, so your program should not touch them. But the first few members of the TEB are documented for our program to use, and these form what is called the thread information block, or TIB. The first member of this data structure is the exception list, and it is rather poorly named. It would be better named if it were called the exception handler list. This exception list member points to a linked list of exception handler functions for the thread. So each handler is specified using an exception registration record, which is allocated on the stack of the thread. Here, for example, we have an exception registration record that points to the FooHandler function. Then the next handler registration in the list is for the BarHandler function. Then we have one for the BazHandler. And then the end of the list is indicated by a pointer with all bits set. So when the OS wants to notify us of an exception, it will get the exception list for the thread and then iterate over the handlers, asking each handler if it would like to handle the exception. Here the OS would first call the FooHandler and then, if it does not handle the exception, it would move on to the BarHandler and so on until it reaches the end of the list. If we look back at our program, it does not register any exception handlers. So when the OS tries to notify us of the exception that occurred, it will not find a handler. The default behavior is for the OS just to terminate the program. And we can actually find a little hint about what went wrong if we look back at the output of our program. So let's take a closer look at that return code, the big ugly negative number that our program returned. If we convert that to a 32-bit hex value, we'll find that it's actually this value, which it turns out is the value of status access violation. So our program crashed due to an unhandled structured exception, and the OS reported this by using the exception code as the program's return value. So let's write an exception handler for our program. So, here we have our program again. And I've just added a little white space to make room for the code we're going to add. The first thing we need to do is get the TEB, so that we can access the handler list for our thread. There's a helper function in the Windows STK called NtCurrentTeb, so we'll just call that and cast it to right type that we need. The next thing that we need to do is create our exception registration record, which will initialize with the address of the MyExceptionHandler function, which we'll define on the next slide. Then we have to push our exception registration record onto the front of the handler list, so that it will be the first handler that the OS will call. Now our exception handler is registered, so we can proceed with the rest of our function. Before we return, however, we also need to pop our handler registration from the list. So that's it. Next, let's define the MyExceptionHandler function. An exception handler takes four parameters. Only two of these are important at the moment. So we can see that this function receives an exception record and a context record. These are, as you might guess, pointers to the exception record and context that the OS created for the exception earlier. We'll start with a very simple exception handler function. For our first handler we'll just print a message with the exception address and exception code, so that we know the error happened and what the error was, and we know that the OS actually called our handler. We'll then return ExceptionContinueSearch, which tells the OS that this handler does not want to handle the exception, and it should continue searching for a handler. So if we re-run our program, then we'll observe that it still prints the first message. But now it also prints this message from our handler, telling us that an exception occurred. So we know that the OS called our handler, and we can verify that the exception code is indeed the value that we expect for our access violation. We can also see that our program did not print out our second message with the new value of the global variable. And, if we look at the return code, we can see that the program still crashed. This is expected, because our handler returned ExceptionContinueSearch, which told the OS we did not want to handle the exception. The OS continued it's search but, since no other handlers were registered, it failed to find a handler and so it terminated the program. Let's see if we can actually fix the problem inside of our handler. So I'm saying fix in quotes, this is not something you should actually do in real code, but it makes for a fun demo. So we'll define our handler function as before. And first we'll check to see if the exception is actually the exception we're expecting. So if the exception is something other than an access violation, or if the failure was something other than a write failure, then we'll opt not to handle the exception. Next we'll print a message like we did before, just so that we can verify that our handler was called. We'll then look in the exception record to get the address to which we failed to write. And then since we failed to write, and we'd really like to be able to change that value, we'll call this VirtualProtect function. So this is a Windows API that changes the protection of a region of memory, so we'll call it the change the protection of the page containing our variable to make it writable. Again, please don't ever do this sort of thing. Finally, we'll return Exception_Continue_Execution. So before, when we returned ExceptionContinueSearch, we told the OS that we do not want to handle the exception, so it should continue searching for a handler. ExceptionContinueExecution tells the OS, "we've fixed the problem, please go and resume execution where the failure occurred." So that, what the OS will do, is it will attempt to restart execution of our program, starting with the instruction that caused the fault. So now if we re-run our program we can see that we still get our first message, and we still get the message from our handler, but now we can also see that we got our second message and it indicates that we successfully changed the value of the global variable. If we print out the return code of our program, we can also see that the program returned zero, indicating that it successfully exited. So to recap what we've seen so far, structured exception handling is the Windows operating system facility. So it's part of Windows, not the Visual C++ runtime library or anything else on top of Windows, that makes it possible for the OS to report errors to our program, and to give us an opportunity to try to resolve them. Our program can register our list of handlers, which the OS will call in sequence, to see if each would like to handle the exception. And each handler can choose to handle, or not to handle, the exception as it sees fit. What we've seen so far has been quite tedious, though. We've had to write code to manually register and unregister our handlers. We've had to write a substantial amount of code within each handler. It would be most unfortunate if we had to do all of this work ourselves. Oh yeah, our program didn't crash. Wouldn't it be nice if we could get the compiler to help us to do all of this? So the Visual C++ compiler, good news, provides a pair of compiler extensions that make structured exception handling much simpler in both C and C++ code. The first is the __try/__except statement, so note that that's double underscore try and double underscore except. So these are extensions, not the standard C++ try statement. And this statement consists of a try block, which is a region of code whose execution is guarded by an exception filter, which can be an arbitrary C++ expression. So the filter acts kind of like an exception handler, and we'll see how they're related in a little bit. If an exception happens inside of the Guarded Region, then the exception handler will evaluate the filter expression to see what it should do. So, for example, the exception filter can evaluate to Exception_Continue_Search, which means that we choose not to handle the exception here. In this particular case, since the exception filter unconditionally evaluates to Exception_Continue_Search, this try accept statement is effectively a no op because we never actually tried to handle the exception. Alternatively, if the exception filter evaluates to Exception_Continue_Execution, this will cause execution to continue at the point where the exception occurred. Both of these values, the all-capitals EXCEPTION_CONTINUE_SEARCH and EXCEPTION_CONTINUE_EXECUTION, are similar to the constants that we used with our exception handler in the previous section. These are just the values for the Visual C++ support library, whereas the other ones were the raw values from the underlying Windows structured exception handling implementation. So the exception filter can be an arbitrary C++ expression, and it's evaluated in the context of the enclosing function. So, for example, it can refer to local variables. Here, we have the value of the filters determined by looking at the value of a function parameter named ShouldContinue. In order for the exception filter to be useful for real world exception handling, it really needs to be able to query information about the exception being processed so that it can take action for some exceptions but not for others. And the compiler provides a pair of intrinsics to support this. So the first intrinsic is GetExceptionInformation, which returns a pointer to an exception pointer structure, and this structure just provides access to the exception record and the context for the exception that is being processed. The second intrinsic is GetExceptionCode, which returns the exception code. So this is provided for convenience. The exception code can also be obtained from the exception record in the exception pointers. These intrinsics are only valid for use inside of the filter expression where we're trying to determine if we should handle the exception. If you try and use the somewhere else in your code the compiler will give you an error. So, a typical filter in real world code will actually just call a function. It'll pass any exception pointers to that function, and then that function will return what the filter should do. So we now have enough information, or enough compiler support, that we can rewrite our original example using it. Here, for example, is our program using a try except block instead of manually registering a handler. And we'll also need to rewrite our exception handler function for use in the filter expression. So here is out exception handler, which I've rewritten. And there aren't many differences, I've just highlighted them here. Basically we've renamed the function to MyExceptionFilter because we're now using it as a filter instead of a handler. We've changed the function to just take an exception pointer structure instead of having the whole signature of an exception handler. We now access the exception record through the exception pointers that we get. And then we return the Visual C++ constants, the ones that are shouty and in all-caps, instead of the underlying OS constants. There's one more feature to discuss here. If we look closely, or actually we don't have to look too closely at all, we can see that the except is actually a block statement. So it has braces and you can put arbitrary code inside of it. What is that? So we've seen two filter values so far. If the filter evaluates to EXCEPTION_CONTINUE_EXECUTION, then execution will resume at the instruction where the exception occurred. If the filter evaluates to EXCEPTION_CONTINUE_SEARCH, that means we don't want to handle the exception, and the search continues. Finally, the filter can also evaluate to EXCEPTION_EXECUTE_HANDLER. If it evaluates to the value, then the stack is unwound to this point and execution resumes inside of the except statement. For example, here we attempt to modify constant zero inside of a try block, our filter expression evaluates to EXCEPTION_EXECUTE_HANDLER, and when the exception occurs we will handle the exception and execution will resume inside of the except block. This case is more interesting if, instead of the exception occurring directly inside of the try block, it occurs inside of some nested frame. So here, inside of the try block we call Foo, which then calls Bar, which then calls Baz, and the exception occurs inside of Baz. In this case, the only exception handler that's been registered is the one for our top-level function, so it will handle the exception and execution will resume inside of the except block after the stack has been unwound. So the nested callframes of Foo, Bar, and Baz are unwound. In this case, unwinding just means that we reset the stack pointer and reclaimed the stack space that these functions were using. So unwinding can have some interesting results. For example, let's consider a slightly different program. So here we have a similar try except statement, and in the try we call ModifyConstantZero under lock. This function acquires a lock, then calls ModifyConstantZero and will release the lock when that function returns. ModifyConstantZero, however, will attempt the modification and it will fail. So we handled the exception here, causing the stack to be unwound, and causing execution to resume inside of our except clause. The problem here is that when the stack is unwound we never actually release the lock that we acquired because this LeaveCriticalSection call gets skipped. It's after the ModifyConstantZero that did not return. So, the second structured exception handling language extension is the __try/__finally block. And with a __try/__finally block we can customize the actions that take place during unwind. So the body of the finally block is executed both when control leaves the try block normally, so if ModifyConstantZero were to return, then in that case we would execute the finally block, or when the stack is unwound through the try block. So here, for example, we move the call to LeaveCriticalSection into the finally block, so that we will release the lock even if an exception occurs inside of ModifyConstantZero. So we've now seen how we can use these two Visual C++ language extensions, __try/__except and __try/__finally, to make structured exception handling easier. But how do these work under the hood? How do these language extensions map to the raw structured exception handling code that we wrote at the beginning of this talk? So let's look and see. Before I begin, though, I do want to note that much of the code that follows is based on actual code in the runtime library. But I've simplified and cleaned up much of it to help us focus on the essentials and not get bogged down in details that are not important for this overview. For example, some of this is written in assembly, and I've turned it into the equivalent C code just to make it easier for us to understand. And for now we'll keep looking at how things work only on x86. We'll look at how things work on other architectures toward the end of the talk. So the naive approach the compiler could take, would be to register a handler on entry into each try block, and unregister the handler when control leaves the try block. But this is not so great, because a function can have more than one try block. Here, for example, we have a function with multiple sequential try blocks. And here we have a function with nested try blocks. But for discussion purposes, let's consider a function that has a mixture of both. So, what is the compiler going to do with this function? The ideal things is, is we don't wanna have to do all that work to register a handler and then unregister it, and then register another handler and then unregister it as we execute through the function. So instead what the compiler is going to do, is it's going to divide this function up into states or scope levels, where each state indicates which try blocks we are inside of. So we start with the base state, or State -1. And this state contains the entire body of the function. In this state we are not inside of any try block so, if an exception occurs in this state, we don't take any action for this function. Next, we define State 0 for the first outer try block. So execution of this code is guarded by the associated except so, if an exception occurs here, the filter for that except will need to be called to see if the exception should be handled. Note that State 0 includes only try block, and not the associated except block, because the except block does not guard execution of itself. So if an exception occurs inside of the except block, like, the except block can't handle that exception on it's own. Next, we define State 1 for the inner try block, and then we'll define State 2 for the last try block in the function. So now that the compiler has divided the function up into states, it will build a ScopeTable to represent them. So a ScopeTable is an array of entries, each entry represents a state. It specifies the enclosing state, or level, the address of the filter function to be called, and the address of the handler. So for our function here, we need three states in our ScopeTable. We don't need an entry for -1, because no action needs to be taken when we are in State -1. So first we'll set the enclosing level for each state. The enclosing level of States 0 and 2 is the base state, or State -1. And the enclosing level for State 1 is State 0. And we can just see that by seeing what the block is that each state is inside of. Second, we'll set the filter pointer. So for try except blocks, this is a pointer to the address of the exception filter, so that expression inside of the except statement. For try finally blocks, so like the first state, this is set to null. Finally we'll set the handler pointer, which is the address of either the except block or the finally block, depending on what kind of statement this is. So this table makes it so that, instead of having to register a separate exception handler for each try block, we can register one handler for the entire function and then just track the state that we are in within the function. If an exception occurs and the handler is called, it can look up what it needs to do based on the current state. Moving on, then. So we've already seen the EXCEPTION_REGISTRATION_RECORD, which is used to register an exception handler. The compiler uses a type C_EXCEPTION_REGISTRATION_RECORD, to hold the exception registration record and then some associated metadata that it's going to use at runtime. So the first thing to note is that it does contain a registration exception record, and it's going to need that in order to register the handler for the function. The stack pointer member will be used to store the current stack pointer value any time we change it within this function so that, if we handle an exception, we know where to reset the stack pointer to. The exception member will contain a pointer to the exception pointers for the exception, so that we can access it from the filter. The ScopeTable member will contain a pointer to the ScopeTable for the function which we just built, and then the try level contains the current state. So we wanna use just one handler for everything, and have it dynamically act using the state we just defined. But, how do we do that? So we can take one more look at the exception handler declaration. We've already introduced two of the parameters, the ExceptionRecord and the ContextRecord. And now we'll look at the third, the EstablisherFrame. So the EstablisherFrame actually is past the pointer to the exception registration record for which the handler is being called. So we can think of this function as actually being declared, something like this. So if we store our auxiliary state, like the current state number in the pointer to the ScopeTable, alongside our exception registration record, then we can just use pointer arithmetic to access our auxiliary state inside of the handler. We can just add or subtract the appropriate number of bytes from the EstablisherFrame pointer that we're given. Let's look at the code that the compiler has to generate now to make all of this work. So first on entry into the function the compiler needs to initialize a C_EXCEPTION REGISTRATION_RECORD, and push the handler onto the front of the list. So we'll start by declaring the registration record, which we'll name RN for registration node. This is the term used internally within the runtime code. Next we'll leave the first two members, the StackPointer and Exception as TBD because we don't have values for those. We'll update those later. We'll initialize the handler registrations, there are currently four versions of the C structured exception handler. I'll be showing Version three, Internals. Version four adds a bunch of security features, which are super important, but they're just sort of extra information that is not essential for our stuff here today. Then we'll initialize the ScopeTable to point to the ScopeTable for our function and we'll initialize the try level to -1, because we'll be in the base state to begin with. And then we'll just link our handler into the list. So then, before any return from the function, the compiler will have to unregister the handler by popping it from the list just like we did in our manual code. Then, inside of the function, the compiler needs to add logic to update the try level as we transition from one state to the next. So here, for example, I actually just copied this from the debugger in Visual Studio. We can see the code that the compiler generated. So when we enter the first try, it updates the TryLevel to 0. When we enter the second try, it updates the TryLevel to 1. And then when it exits that second try and re-enters the first try, it updates the TryLevel back to 0. So that's all the code that the compiler has to generate for each function. Then the last piece of the puzzle is what does except_handler3 do? So except_handler3 is an exception handler function, so it has the same parameters as our previous handlers that we've looked at. So first it'll take the EstablisherFrame, and it'll do that appropriate pointer arithmetic that we mentioned to get the pointer to the full registration node. Next, it'll update the exception pointers members so that any exception filters can access it. We'll then loop through the currently active states. So we start in the current state. Each iteration through the loop we move to the enclosing state, as it's declared inside of the ScopeTable. And then we end when we reach State -1. If the filter pointer for a state is null then that means it's a finally block, not an except block. So we skip it for now and we move on to the next state. We're only interested in except blocks right now, because we're trying to find someone to handle the exception. Otherwise we call the filter, and then we decide what to do based on the value of the returns. If it returns EXCEPTION_CONTINUE_SEARCH, then it does not want to handle the exception and we move on to the next state. If it returns EXCEPTION_CONTINUE_EXECUTION, then it's fixed things up, and we immediately return to tell the operating system that we want to continue execution. We don't have to do anymore work here. If it returns EXCEPTION_EXECUTE_HANDLER, well, this is where things get complicated. We need to unwind the stack, and transfer control to the except block. And we'll look at the mechanics of this next. But finally, if no one in this function wants to handle the exception, that's the point at which we return EXCEPTION_CONTINUE_SEARCH to tell the operating system that we do not want to handle the exception and to let it continue trying to find a handler. So, unwinding is the process of cleaning up nested stack frames. At a minimum, this includes reclaiming the stack space used by nested functions for structured exception handling. Unwinding also involves calling any finally blocks in nested frames. Unwinding is split into two parts. So there's global unwinding, which unwinds across all of the nested frames. And then local unwinding, which unwinds within a single frame. Global unwinding is performed by the Windows API function RtlUnwind, which is part of the Windows STK. It receives a few parameters. The parameters of interest to us here are the exception records. This is the, again, the record for the exception that was thrown. And then the TargetFrame. So the TargetFrame is the pointer to the EXCEPTION_REGISTRATION_RECORD, where it should stop unwinding. So how is this function going to unwind the stack? It's going to basically just call the registered exception handlers again, in order, to let them know that they're being unwound. So the first thing it's going to do is inside of the exception record, it's going to set this flag, EXCEPTION_UNWINDING. This flag can then be used within the exception handler functions to know whether the handler is being called to handle an exception, or because the stack is being unwound. Next, it'll get the TIB so that it can get the handler list from it. It'll then iterate over the exception list until the target frame is reached. For each handler it will call the handler function, and then it will pop the handler from the list. So, as this moves along, it removes frames from this list. Within each handler we then need to handle this unwind operation appropriately, by doing a local unwind. So inside of the C structured exception handling implementation, the local unwind function is used to perform a local unwind. It takes those parameters a pointer to the registration node for the function being unwound, and the state number at which unwinding should stop. So inside of this function we iterate over the states until we reach the desired target state. So we get the ScopeTable entry for each state. If the filter for the entry is null, then it's a finally block. So we call the handler, which is the body of the finally block. If the filter is non-null, then this is an except block, and so we take no action during the unwind because we're only trying to do cleanup here. Finally, after we have called the finally block, we update the TryLevel to the EnclosingLevel. So the last thing we need to do is patch things up inside of _except_handler3 so that it can handle this extra unwinding case. So what we need to do is indent the actions that we took during the search for a handler, and then add a check so that we only do that if we're not unwinding. If we are unwinding, then we call local unwind to unwind back to the base state for the function, which is State -1. Now we can come back to what we have to do when the filter expression evaluates to EXCEPTION_EXECUTE_HANDLER. First we have to call RtlUnwind, unwind any nested stack frames, and we stop at this frame so that the OS won't unwind this frame. Then we call local unwind so that we unwind all of the nested states underneath us. So if, for example, if there's a try finally inside of our try except, we need to execute that finally block. Then we transfer control into the except block by calling the handler. So this resumes execution inside of that except block, and that will not return. Okay, so that was a lot of information, and this is actually probably something that's a little easier to show with a picture, so let's walk through an end-to-end example. So here we have a stack, and someone calls our function named F. So the first thing we're going to do is build a registration node for F on the stack. So this is the first function that we have, so there's no next handler to set the pointer to. We set the pointer to the handler in the runtime library, so except_handler3. We set the ScopeTable to the appropriate ScopeTable for F, and then we initialize the TryLevel to the base state, which is -1. We then allocate a call frame for F to hold any local variables that are in here. I've just removed all those from this so that we can focus on the important pieces. And then we store that stack pointer inside of our registration node for future use. Then we enter the body of the function. We enter the Outer__try and we update the TryLevel to State 0. And then we enter the Inner__try and we enter State 1. And then we call G. So inside of G the first thing we do again, is we allocate a registration node. It's much the same as for F, except the ScopeTable is set to the ScopeTable for G. And then the next pointer it points to the registration of F, because there was a prior registration on the stack. We then allocate a call frame for G, and we store the current stack pointer, again, inside of the registration node. We then enter the Outer__try block in G and we transition to State 0. Then enter the Inner__try block and transition to State 1, and then we call H. So here is H. The first thing we do, again, is we allocate a registration node for the function. The next pointer points to the registration node for G. And we allocate a frame for H, and we store the stack pointer as we did before. We then enter the try block and update the try level to level 0. Then we execute this statement which attempts to de-reference a null pointer. Oh no we should not do that, definitely not. But this is where the CPU will signal a general protection exception, causing the OS to raise a structured exception for the access violation. The OS dispatches the exception, so it's going to construct that context and the exception record and pass it back into our program. It's going to inject the exception dispatcher onto our thread. The dispatcher starts by getting the most recent handler registration from the TEB, so this is the registration node for H. It then calls the handler for H. The handler looks at the current TryLevel, it observes that this is for a finally block. The enclosing state is the base state, so there are no except statements in this function. And so the handler returns ExceptionContinueSearch. The dispatcher then follows the link to the next handler. It gets the registration node for G. It then calls the handler for G. The handler looks at the current TryLevel and it sees that it is for an except block, so it calls the filter which then returns EXCEPTION_CONTINUE_SEARCH, indicating it does not want to handle the exception. The handler then advances to the enclosing scope in State 0. This is a finally block, so no action is required. The next enclosing scope is the base state, where we don't take any action. So the handler, again, returns ExceptionContinueSearch. The dispatcher then follows the link to the next handler to get the registration node for F. The dispatcher then calls the handler for F. The handler looks at the current state, sees that it's an except block, calls the filter, and the filter returns EXCEPTION_EXECUTE_HANDLER indicating that here's where we want to handle the exception and unwind the stack. So, for this case, the handler is not going to return control back to the dispatcher. Instead, the handler needs to unwind the stack using those three steps that we had before. So first we call RtlUnwind to do the global unwind, and unwind any nested frames. So we call RtlUnwind, it starts just like the dispatcher did, by getting the first exception handler registration from the TEB. So this is the registration node for H. Inside of our handler we look at the current state, which indicates that we are in a finally block. It indicates that the state is for a finally block. It runs the finally block, which we'll return, and then we update the state. We've now reached the base state again so our handler can return. RtlUnwind will now pop the handler H from the exception list and this frame has actually been unwound, so it's not actually in the list anymore. RtlUnwind will then follow the link to the next handler, which is the handler for G. RtlUnwind calls the handler for G. Inside of that handler we need to unwind back to the base state, so we start by looking at the current state, which is for an except block. We're only interested in finally blocks during the unwind, so we update the state to the enclosing state, which is State 0. State 0 is a finally block, so the handler calls the finally block which will execute and then return. The handler will then update the TryLevel to the enclosing state, which is the base state, and the handler will return. Now, just as before, RtlUnwind will pop G's handler from the list of handlers, unregistering it. So G is now fully unwound, and it will then follow the link to the next handler. It sees that the next handler is the handler for F, and that's where we told it to stop, so RtlUnwind now returns back to our handler. So, back inside the handler for F, we've now completed the global unwind. So we've successfully unwound all of the nested frames. We now have to call local unwind to unwind any nested states in our frame. So inside of local unwind, we observe that the current state is 1, which is the same as our target state. So that is, it's the same as the state of the except block that we're processing. So this means that we have no work to do, but if there'd been a nested finally block we would have run it. So local unwind now returns and, back in the handler, we've now completed the local unwind. We can now transfer control to the __except block. So the __except block will do several things. First it will update the TryLevel to the enclosing scope, because we're no longer inside of the Inner__try, so execution is no longer guarded by that except filter. Next it will update the stack pointer to the value that it had before G was called. This reclaims all the stack space that was used by nested frames. And, last but not least, it then begins execution again inside of the except block. So that is, end-to-end, how structured exception handling and stack unwinding works. There's one more essential thing to discuss before we proceed. All the exceptions that we've seen so far have been generated by the OS. What if we want to create our own exception? Well, we can. There's a function RaiseException which, well, it raises an exception. So it takes a few parameters, which should look fairly familiar. Earlier we saw the exception record structure, and the parameters of RaiseException are just used to construct an exception record, which is then dispatched as an exception. So for example, we can define our own exception code, STATUS_COFFEE_SHORTAGE, which is something that the conference could have used this morning during the break, with an appropriate value. And then call RaiseException to raise an exception with this code. Then we can write a program that handles this exception and, in our filter, we can check to see if it's the coffee shortage exception and handle it appropriately. Okay. (audience murmuring) So I expect some of you may be wondering, I'm now 40 minutes and over 150 slides into my talk on C++ exceptions, and we have yet to meet a C++ exception. Why have I spent so much time on structured exceptions? And the answer is, C++ exceptions are implemented as structured exceptions. So it's much easier and simpler to look at this base case of how structured exception handling works, and then look just at the C++ specific pieces that sit on top of that. So when we have a throw expression in our code, the compiler actually transforms that into a call to a runtime library function that calls RaiseException. Try catch blocks are implemented using an exception handler, similar to how __try/__except is implemented. Local variable destruction is done in a very similar way to how try finally blocks are handled during an unwind. And then we have the _except_handler3 function used by structured exception handling compiler support. And there's a similar CxxFrameHandler used for C++ exception handling. So I spent most of this talk, again, talking about structured exception handling because it's simpler, and now we can focus on the C++ details that sit on top of that. So, first things first. What happens when you throw a C++ exception? Let's say, for example, that I want to throw an exception of type MyBeautifulException. So there's a runtime library function called CxxThrowException, which is responsible for throwing C++ exceptions. Fancy that, it's actually well-named. It's takes a pointer to the object to the thrown, and then a pointer to some metadata describing the object's type. For when we have a throw expression in our code, the compiler first constructs the object to be thrown locally on the stack. Now this is somewhat incorrect because it actually constructs a copy of the object that was passed to the throw expression. And then it calls CxxThrowException with that, and with the appropriate metadata based on it's type. The throw info consists of a few things. So it has a member that contains some flags, called attributes, here. It has a pointer to the Destructor for the thrown type, so that the object can be destroyed at the right time. And then it has a CatchableTypeArray which, as it's name indicates, is an array of catchable type objects where each catchable type object describes one of the types via which the thrown object can be caught. So this includes some flags, a pointer to a type info object representing the type. So these are the same structures used by RTTI. If you don't have RTTI enabled in your build, but you do use exception handling the compiler will still generate RTTI data, but only for the few types needed for exception handling. It also contains displacement information, which is used for multiple inheritance cases. So that if you catch a derived class object via one of it's bases, we know how to find the base class. And then it's got a copy function, so it knows how to make a copy of the exception object. So let's consider an example. Here we have a base exception type that stores some data. It's ThrowInfo has a null destructor pointer because this type is trivially destructible. It's CatchableType array contains a single element, which describes the base exception type. We can define the derived exception type, which derives from base exception and carries a string as it's payload. This also has it's own throw info, in this case the unwind member function points to the DerivedException destructor, because it's destructor needs to destroy this to the string. And then it has two associated CatchableTypes. So first it has a CatchableType for the DerivedException type itself, since DerivedException is not trivially copyable. It also has a pointer to the copy constructor there. And then it also has as a CatchableType the CatchableType for the BaseException because, again, you can catch an exception of a derived type by a catch for one of it's base classes. So let's implement CxxThrowException, this one's really easy to do. So first it's going to construct an EXCEPTION_RECORD, and it'll set the exception code to the Visual C++ ExceptionCode. Here's a little piece of trivia, it's this value which what you get if you do a logical or of the multi-character literal msc with a few extra bits at the top of the number. Next, it'll set the exception flag. So we haven't seen many of the flags here but we set one flag, EXCEPTION_NONCONTINUABLE, and this prevents structured exception handlers from attempting to continue execution at the site where the exception was raised. So if a structured exception handler tried to handle this, it would not be able to return Exception Continue Execution, the OS would prevent that. C++ exceptions are not resumable in that way. It will then initialize the exception parameters, so we use three parameters inside of here. Parameter 0 is set to a magic number that, it's kind of used to know what version of exception this is. Parameter 1 is then just set to the pointer of the exception object. And Parameter 2 is set to the throw info described in the ExceptionObject. And then it just calls RaiseException, with all of the data that it just filled in. Note that, because C++ exceptions are raised as structured exceptions, you can actually use a try except block to handle C++ exceptions. So here, for example, we throw a C++ exception inside of a structured exception handling try block. And then in our exception filter we check for the Visual C++ exception code, and we handle the exception if it matches. This is not usually something that we do, but it's possible. Now we're gonna look at the other side of things. How are exceptions caught, and how is the stack unwound? So with the compiler structured exception handling support we had this C_EXCEPTION_REGISTRATION_RECORD which contains some auxiliary state. There is something similar for the C++ exception handling support called the EHRegistrationNode. Like the structured exception handling type we can see that we still have a member to store the stack pointer, and we still have the handler registration. We also have a state which is used to store the state index, which is the moral equivalent of the structured exception handling TryLevel. The EHRegistrationNode does not have storage for the exception pointers like we had in the C version. This is because that information is not accessible from any user code. It's used entirely within the C++ handler support. Additionally, it does not have a pointer to a ScopeTable. So for the ScopeTable, the compiler uses a little trick. We still use a single common exception handler for all functions that use C++ exception handling. But the compiler actually generates custom thunks, one for each function, that basically pass the function metadata to the handler as a hidden parameter. So for example, if we have a function named F, then the compiler will generate a little two-instruction thunk that just puts the pointer to the appropriate metadata, which I've called FuncInfoForF, into a register and then it'll transfer control to the common handler code. So then, in the handler we can just refer to that data. So this approach, it's a little cheaper to enter a function that uses C++ exception handling because we don't have to copy this pointer onto the stack every time we enter the function. We only have to copy it if we actually need to when an exception has actually been thrown. So, just like with structured exception handling, the compiler will split our function into a set of states. With structured exception handling the compiler generated states for each structured exception handling TryBlock in the function. With C++ exception handling the compiler generates states for each C++ TryBlock, and also for each local variable destructor that might need to be called during unwind. And then here we can see it also generates the code to update the state as we execute through the function. For structured exception handling we had a ScopeTable. For C++ exception handling we have something similar called a FuncInfo, and it contains two things. The first is an UnwindMap, which maps all those state indexes to the sets of destructors that needs to be called during an unwind. And the second is the TryBlockMap, which contains information about all of the TryBlocks in the function. So we're going to focus on the TryBlock map. For each TryBlock, it contains information to map which set of states are covered by the TryBlock because you can have multiple local variables declared inside of each of those blocks. And then it contains a list of catch blocks associated with the try. There's effectively one handler type object for each catch statement. It then, each contains the type info describing the handler type, and then a pointer to the handler function. So if we look at the exception handler function, so for structured exception handling we showed except_handler3, for C++ we use __CxxFrameHandler. The first thing that this function does is it ignores non-C++ exceptions, at least in the usual mode. Second, it looks at the FuncInfo and the current state, and it uses that to compute the range of try blocks whose catch blocks should be considered. Third, for each try block, from the innermost to the outermost, it enumerates the associated catch blocks. Then for each catch block, it checks to see if the type is a match for the type of the thrown object. So each handler has a std::type_info describing the type of object it can catch. And the throw info has an array of catchable types, each of which describes, contains a type info describing a type by which the exception can be caught. So basically we just have to compare all of those, and then pick whichever catch block we find that matches first. If a catch block matches, then it initializes a catch object. So if the catch block catches by reference, this this will just be reference to the thrown object. Otherwise we have to make a copy of the thrown object, or a copy of some part of it, if it might be a base class. It then performs a global unwind to unwind all of the nested frames. This will destroy local variables that have been constructed in those nested functions. It'll then perform a local unwind to unwind any local frames. So, for example, this will destroy any local variables that were declared and that had been constructed inside of the try block. And then it calls the catch block. There's two ways that a catch block can exit. So first, control can reach the end of the catch block and then, after any local variables that were declared in the catch block are destroyed, it will return control back to the handler. In this case the thrown object is then destroyed, and the stack pointer is reset, and execution resumes with the first statement that follows the catch block. Alternatively, the catch block can rethrow the exception via a throw with no operand. In that case, the original exception is re-raised, but from the context of the catch block. Finally, if no catch block matches, ExceptionContinueSearch is returned to let the OS continue the search for a handler. I'll note a few interesting tidbits. First, we use a different handler for structured exception handling, except_handler3. Then for C++ exception handling, where we use CxxFrameHandler. So this means that you can't mix and match C++ try blocks with SEH try blocks in a single function. They have to be in separate functions. In the structured exception handler we reset the stack pointer before we enter the except block and, basically after we enter the except block, we never return back to the handler. In the C++ exception handler, we execute the catch block, and then let it return control back to the handler, where we then reset the stack pointer and then resume execution after that. This is because the C++ exception object is allocated on the stack inside of the function where it was thrown, so we can't reclaim that stack space until after control leaves the catch block. There's a few other interesting things worth noting. First, what happens there's no matching catch block? In this case, it's required that std::terminate should be called. So during program startup, there's a function in the runtime library that will call this Windows API SetUnhandledExceptionFilter to register an exception filter that the OS will call if it fails to find a handler the normal way. And then inside of that helper we just check if it's a C++ exception, then it'll call std::terminate. C++ used to support dynamic exception specifications, which would let you restrict the set of exceptions that a function could throw. Visual C++ did not implement this feature, but this feature was completely removed from the language in C++ 17. So the way I see it, Visual C++ was just a couple decades ahead of everyone else here. (audience laughing) - There's noexcept. So a function can be declared as noexcept, in which case no C++ exception are allowed to unwind through it. And the implementation here is actually pretty straightforward, at least at runtime. And that is, inside of CxxFrameHandler, if it fails to find a catch block to handle an exception, then just before it returns ExceptionContinueSearch indicating that it does not wanna handle the exception, it checks to see if the function is noexcept via a flag that's stored in the FuncInfo. If the function is noexcept, then it calls terminate immediately without unwinding the stack. So the implementations are given the option of either unwinding the stack to the point of the noexcept or not unwinding, and Visual C++ makes the choice not to. Finally, there's an interesting issue of how C++ exceptions and non-C++ structured exceptions should interact with each other. This is configurable at compile time via the EH switch. I'm not going to go into details here. The documentation is actually pretty thorough about how these things work. So if you're interested in that, I would refer you to MSDN. Alright, what about other architectures? So, so far everything I've discussed is how things work on 32-bit x86. The x86 implementation is, well, not great, because it imposes non-negligible overhead even in the case where no exceptions are thrown. So, even if no exceptions are thrown, the compiler still has to put code into each function to initialize and register the exception handler at the beginning of the function, unregister when control exits the function, and then it also has to update the state as objects are constructed and destroyed, and as try blocks are entered and left. Is that a lot of overhead? Well that really depends on the application, and what each function does. But, ideally, C++ exceptions should impose no runtime overhead until an exception is actually thrown. So you shouldn't have to pay for the feature if doesn't get used, you know, the usual C++ thing. The x86 implementation is also problematic for another reason, and that is that the handler registration records that we've seen, they contain function pointers and they're allocated on the stack. So they're ripe for attack if there's a buffer overrun on the stack. The implementation was improved in 2005 with a new feature called SAFESEH, and that mitigates some of those vulnerabilities. That's why I didn't show the Version 4. The exception handler basically just adds all that security support that wasn't particularly relevant to what we're talking about here. I do want to note that the implementation, it's not that the implementation was bad at the time when it was designed. Structured exception handling actually predates the addition of exceptions to C++. So when this feature was first implemented, it was kind of rare to have functions that actually handled exceptions. The overhead basically just wasn't a big deal. Additionally, the security issues weren't really known at that time in the late 1980s, very early 1990s. Unfortunately, we can't change how things work on 32-bit x86. Again, the exception handling mechanics are part of the fundamental ABI. But as Windows is imported to other architectures, we have an opportunity to make ABI improvements. So on x64 ARM, and then now 64-bit ARM, we use a different implementation which resolves both the performance issue and most of those potential security issues. So all of these architectures use very similar implementations. There's a few differences, but the fundamentals are the same. So, for today, I'll show what's done on x64. We start with an observation, and that is that each function that registers an exception handler always registers the same handler. This is probably not true in 100% of cases, but it's true in practically all cases. And in the few cases where it wasn't true we can transform the code to make it true, right? Every one of those C++ functions is registering CxxFrameHandler as it's handler function. That means that, instead of maintaining a dynamic handler registrations at runtime, we can store a static table of handler registrations in the binary. So the OS will need some way to find this table and to facilitate that, we store in the special part of our xcr.dll called the .pdata section. The OS will also need some way to know what functions are currently on the stack, so that it knows what entries in the table to look up. On x86, walking the stack is hard. Due to the long legacy of 32-bit x86, the ABI is very loosely defined. There are numerous calling conventions, and then each function generally has free reign to do as it pleases, as long as it cleans up before it returns control back to it's caller. But if we're building all of this from the ground up for a new architecture we can define the ABI very precisely, and in such a way that stack walking is easy. So on x64 we define the .pdata section that's containing an array of these Runtime_Function objects. Each runtime function specifies the begin and end address of the function, and then a pointer to the metadata describing the function. This array is sorted so the OS can basically just do a binary search, using whatever the current instruction pointer is to find the right function entry. The metadata then includes information that the OS needs to walk the stack. It includes the address of the exception handler function, and then it includes language-specific metadata. So the C++ compiler uses the language-specific metadata to store either the ScopeTable for structured exception handling, or the FuncInfo for C++ exception handling. So, from this metadata the OS can find out what functions are on the stack, and it can find the handler for each function. The C runtime library can also use the instruction pointer to determine the current state that each function is in. Everything else works more or less the same. The algorithms are the same. There's one file the C runtime library that actually implements CxxFrameHandler and the bulk of the logic. You know, there's a few architecture-dependent pieces, but they're not part of the core algorithm of how we search for exceptions. The one other thing worth noting here, is that we don't store pointers in any of the data structures in this new implementation. So x64 is a 64-bit architecture, so pointers are 64 bits in size. However, a single binary can't be larger than four gigabytes. I'm sure the practical limit might be much lower, but four gigabytes is the absolute maximum permitted by the file format. So it would be quite wasteful for us to store pointers, as our data would be twice as large. So instead, what we do is we actually store pointers as 32-bit RVAs, which are just offsets from the beginning of the DLL or EXE. If you wanna learn more about that, I'll refer you to my talk from last year's CppCon, where I discussed all the details of how DLLs work on Windows. Finally, just a little fun thing to end the talk with. At the beginning of the talk, I showed how structured exceptions are resumable. So, when you handle an exception, you can tell the OS that you want execution to resume at the point where the exception occurred. C++ exceptions are not resumable, but what if they were? So as part of my research for this talk I was reviewing some old specifications for the Visual C++ exception handling implementation. And a former manager, when he left the Visual C++ team, he dumped this pile of documents on my desk. And in it there was this specification of how exception handling worked, and a bunch of papers. So I came across this paper, numbered here, that Microsoft submitted to the C++ committee back, I believe, in 1990 or 1991, where we proposed support for optionally resumable exceptions in C++. So here was one example of the proposed syntax showing how you could specify the throw site. Or at the throw site, whether an exception should be resumable or not. Then you could explicitly catch resumable exceptions, and then control whether the exception was resumable if you rethrew it. And then you could catch non-resumable exceptions, and then attempting to resume a non-resumable exception would result in a compilation error. Anyway, obviously this wasn't adopted into C++, but I just found it interesting history and wanted to share it. In the Visual C++ EH implementation today, there's actually still reserved flags for this. You know, if we ever decide to add this to C++ in the future. Though, to the best of my knowledge, there are no plans to resume work on this feature. (laughing) - Okay, and with that, that's the end. If you're interested in exploring more, I will note that we shipped most of the runtime library sources with the Visual C++ STK, so if you have Visual Studio installed, you can actually go and take a look at the source code. It's not buildable, but you can debug through it if you're using the debug libraries which is pretty great. And with that, I'm actually done 50 seconds ahead of schedule, so are there any questions? (audience applauding) (Audience member asking question) - Yeah, so the question is how do vectored exception handlers fit in with this? I did not talk about vectored exception handlers because I only wanted to cover the parts of structured exception handling that fit in with C++ exceptions. So I'd be happy to chat about that after the talk, yeah. (audience member asking question) - Destructors that throw. Yeah I didn't talk about that but, basically, inside of the frame handler, when it's doing the unwind operation, when it's actually calling the destructors, it basically just has a try except around it or maybe a try catch, even. But it has a try except and then, if an exception is thrown out of that while it's unwinding, then it calls to terminate in that case. So basically it just guards the execution of those handlers, or of those destructors. Yes? (Audience member asking question) - How does it work with fibers? That is a good question, and I don't immediately know the answer to that. But I suspect that, since multiple fibers would share the same TEB, depending on which thread is currently scheduled, they would also share the same exception list. But I don't actually know that, yeah. Yes? (Audience member asking question) - Yeah, do I have numbers on x86 performance implications even if you don't catch anything. I do not, no. Yes? (Audience member asking question) - So the question was, yeah, the question was how do things happen in the case of a stack overrun. So, in that case, you do get a stack overflow structured exception. When I gave the dry run of this talk on Friday, I was asked that question. I don't actually know how that's handled internally, and I did not look it up because I was not expecting to get the question here. So that will teach me to not listen to the feedback from my colleagues. (audience laughing) - But, that's something I do wanna look up because I don't actually know what happens in that case. - [Man In Audience] Can I do follow up with you on something else? - Sure yeah, absolutely, after the talk. - [Man In Audience] Thank you. - Yes? (Audience member asking question) - So on x86 it's not zero cost, obviously, because you've got all that work that you have to do. With the new implementation used on x64, bascially, the two pieces of information that you need to track as you're running, is you have to track what is the ScopeTable for the current function, and then what is the current state that you're in within that. And it turns out that both of those can be derived from the instruction pointer. So basically, instead of updating some state manually if an exception is raised, we then go and we look up what the state is using the instruction pointer. So we just look at where we are in the function, and then that'll tell us what the state is and what information to use. Yes? (Audience member asking question) - Yes. No, so the question was when you throw an object we have to make a copy, so that's actually required by the C++ standard, as whatever expression you're passing we end up making a copy of that object. You know, that copy could throw an exception. So, for example, a vic could throw a bad_alloc or something like that. In that case, the original exception has not yet been raised. So in that case, it's that new exception is the one that's going to get thrown. And then you may handle that somewhere, but then the original exception is not going to get raised. Yes? (Audience member asking question) - Can you get a back trace from, (audience member continuing question) - You can, there's actually a Windows API that can help you get a stack trace, and I can refer you that. And it will use, for example on x64, it will use the information stored in the binary that the OS uses to do the stack walk. Yes? (Audience member asking question) - Yes, you could catch C++ exceptions in a language that is not C++. Now there's some danger there because, for example, if you catch and handle the exception in a language that's not C++, the C++ exception object may not get destroyed correctly because we rely on the handler actually doing that destruction work. Now, in my example that I showed where we caught the C++ exception using a structured exception handling try accept, in that case the runtime library actually will do the destruction correctly, but it may not do unwind correctly, yep. Yes? (Audience member asking question) - If you have two separate, So the question is, with respect to optimizations, how does that work with the state table, but (Audience member continuing question) - No, no. But if you have two separate functions that are similar? (Audience member continuing question) - But the compiler in that case still knows when the destructors need to be called, right? No matter what optimizations the compiler does to the code, it still has to be able to call the destructors in the right place, right? So the compiler does have to track that. And with that I think we'll end, but I'm happy to take more questions afterwards and my colleagues from Microsoft can heckle me and so excellent, yeah, thank you for coming. (audience applauding)
Info
Channel: CppCon
Views: 9,694
Rating: undefined out of 5
Keywords: James McNellis, CppCon 2018, Computer Science (Field), + C (Programming Language), Bash Films, conference video recording services, conference recording services, nationwide conference recording services, conference videography services, conference video recording, conference filming services, conference services, conference recording, conference live streaming, event videographers, capture presentation slides, record presentation slides, event video recording
Id: COEv2kq_Ht8
Channel Id: undefined
Length: 66min 26sec (3986 seconds)
Published: Tue Oct 30 2018
Related Videos
Note
Please note that this website is currently a work in progress! Lots of interesting data and statistics to come.