- Aprender
- Informática
- Programación
- Programación en C
- Depuración y código seguro en C
Depuración y código seguro en C: comportamiento indefinido
PrincipianteUnidad 8 de 8 del curso Curso de Programación en C
Lo que aprenderás
- Activa las advertencias del compilador, lee sus mensajes de diagnóstico y elige opciones de construcción que hagan visibles los defectos de programación desde el principio, antes de ejecutar el programa.
- Activar las advertencias y leer los mensajes del compiladorCompila con un conjunto estricto de opciones de advertencia, lee cada mensaje de diagnóstico línea por línea y corrige la causa en lugar de silenciar el mensaje.
- Elegir las opciones de construcción para depuración, pruebas y distribuciónUtiliza versiones de depuración y de distribución separadas, comprende qué cambian -g y los niveles de optimización, y añade análisis estático y opciones de refuerzo de la seguridad donde corresponda.
- Activar las advertencias y leer los mensajes del compilador
- Rastrea los defectos de programación de forma metódica: reproduce y aísla el fallo y, después, utiliza gdb o lldb para pausar el programa, recorrerlo paso a paso, inspeccionar valores y encontrar el verdadero origen de un cierre inesperado o de un valor incorrecto.
- Depurar de forma metódica: reproducir el fallo, aislarlo y poner a prueba una hipótesisConvierte un fallo impreciso en uno concreto, interpreta lo que indica el mensaje de cierre inesperado y acota la causa mediante pequeños experimentos controlados.
- Ejecutar, pausar y recorrer paso a paso un programa en gdb o lldbInicia un programa bajo el control de gdb o lldb, establece puntos de interrupción, avanza línea por línea e inspecciona variables y arrays mientras el programa está pausado.
- Diagnosticar cierres inesperados y valores imprevistosUtiliza la pila de llamadas después de un cierre inesperado, desplázate entre marcos de pila y utiliza puntos de vigilancia, puntos de interrupción condicionales y volcados de memoria del proceso para encontrar el origen de un valor incorrecto.
- Depurar de forma metódica: reproducir el fallo, aislarlo y poner a prueba una hipótesis
- Explica qué es el comportamiento indefinido y por qué hace impredecibles los programas en C, y reconoce sus fuentes más comunes en operaciones aritméticas, expresiones, punteros y memoria.
- Comprender qué significa el comportamiento indefinidoDistingue el comportamiento indefinido, el definido por la implementación y el no especificado, y explica por qué el código con comportamiento indefinido puede parecer que funciona y después fallar.
- Identificar el comportamiento indefinido en operaciones aritméticas y expresionesReconoce los desbordamientos de enteros con signo, las divisiones y los desplazamientos de bits incorrectos, las conversiones fuera de rango, los efectos secundarios no secuenciados y las lecturas de memoria sin inicializar, y escribe las comprobaciones que los evitan.
- Identificar el comportamiento indefinido en el uso de punteros, arrays y memoriaReconoce los accesos fuera de los límites, las cadenas incorrectas, los punteros nulos y colgantes, el uso después de la liberación y los tamaños de reserva incorrectos, y conoce el patrón seguro para cada caso.
- Comprender qué significa el comportamiento indefinido
- Haz que un programa compruebe sus propios supuestos mientras se ejecuta: exprésalos mediante aserciones y construye versiones de prueba con sanitizadores que se detengan ante errores de memoria y comportamiento indefinido e informen exactamente de dónde ocurrieron.
- Expresar supuestos mediante asercionesUtiliza assert para condiciones que solo un defecto de programación puede incumplir, comprende qué hace NDEBUG y separa las aserciones de la gestión de errores esperados.
- Detectar errores de memoria con AddressSanitizerConstruye una versión de prueba con AddressSanitizer, lee sus informes de errores y conoce qué detecta, qué no detecta y qué coste tiene.
- Detectar el comportamiento indefinido con UBSan y establecer una rutina de comprobaciónUtiliza UndefinedBehaviorSanitizer y conoce los demás sanitizadores; después combina advertencias, sanitizadores y pruebas en una rutina que detecte errores pronto.
- Expresar supuestos mediante aserciones
- Aplica reglas de programación segura, como las de SEI CERT C, a cadenas, búferes, entradas y errores, y evalúa el coste de la falta de seguridad de memoria en C frente a lo que ofrece.
- Utilizar un estándar de programación seguraExplica cómo se organiza el estándar de programación segura en C de SEI CERT, lee una regla con sus ejemplos y conoce las reglas fundamentales que previenen los defectos de programación más comunes en C.
- Manejar cadenas y búferes de forma seguraConoce siempre el tamaño de un búfer, lee y copia cadenas con funciones que respeten ese tamaño y detecta el truncamiento.
- Validar la entrada y gestionar los erroresTrata todos los datos externos como no confiables, convierte texto a números con comprobaciones completas de errores y comprueba el resultado de cada llamada de biblioteca que pueda fallar.
- Evaluar las ventajas y los inconvenientes de las decisiones sobre seguridad de memoriaExplica qué es la seguridad de memoria, por qué C carece de ella, qué coste tiene y qué ofrece C a cambio, y cómo las capas de defensa y los lenguajes con seguridad de memoria reducen el riesgo.
- Utilizar un estándar de programación segura
Guía de estudio de Depuración y código seguro en C
1Dejar que el compilador detecte problemas por ti
El compilador puede señalar muchos problemas antes de que ejecutes un programa. Aprende a leer sus mensajes como indicios sobre tu código y, después, elige opciones que revelen más de lo que importa. La información de depuración, la optimización, el análisis estático y el refuerzo de la seguridad para distribución cumplen funciones distintas. Mantener claras esas funciones te ayuda a construir un programa que puedes inspeccionar durante el desarrollo y volver a probar en la forma en que lo ejecutarán tus usuarios.
1.1 Activar las advertencias y leer los mensajes del compilador
Las advertencias te dan la oportunidad de corregir un defecto antes de que una entrada lo desencadene. Un error impide que la construcción termine correctamente. Una advertencia permite que la construcción continúe, pero informa de algo que necesitas entender: código válido pero sospechoso, posible comportamiento indefinido o una infracción de una regla del lenguaje que el compilador acepta.
Elige comprobaciones útiles
Inicia GCC o Clang con una versión explícita del lenguaje y un conjunto amplio de advertencias:
clang -std=c17 -Wall -Wextra -Wpedantic -g program.c -o program
-Wall activa muchas comprobaciones comunes, no todas las advertencias. -Wextra añade otras, incluidas las comparaciones entre valores con signo y sin signo en C. -Wpedantic informa de muchas desviaciones del estándar ISO elegido; no es una prueba completa de conformidad. /W4 de MSVC cumple una función similar en Windows.
Añade comprobaciones que correspondan a los riesgos de tu código:
| Opción | Qué ayuda a revelar |
|---|---|
-Wconversion | Conversiones implícitas que pueden cambiar un valor |
-Wshadow | Una declaración local que oculta otra variable |
-Wformat=2 | Comprobaciones adicionales de cadenas de formato |
-Werror | Hace que las advertencias activadas provoquen el fallo de la construcción |
Por ejemplo, double price = 3.7; int whole = price; descarta la parte fraccionaria. -Wconversion hace visible esa conversión. Si se pretende truncar y el valor cabe, (int)price expresa esa elección. Si la parte fraccionaria importa, conserva un tipo de coma flotante. Para redondear al entero más cercano, lround de <math.h> cumple una función distinta de una conversión explícita.
Lee el primer mensaje útil
Un mensaje de diagnóstico normalmente indica una ubicación en el código fuente, un mensaje y la opción de advertencia. Un signo circunflejo marca la expresión pertinente; las notas que siguen pueden identificar una declaración o sugerir una corrección. Lee el grupo completo. Cuando un error de sintaxis provoca muchos mensajes posteriores, corrige el primero y vuelve a compilar antes de investigar el resto.
Supón que un programa contiene estas tres líneas, con average declarado como double:
if (count = 0) { /* report no data */ }
printf("%d\n", average);
int unused;
La asignación en la condición establece count en cero, lo que hace que la condición sea falsa. Si la intención era hacer una comparación, usa count == 0. Añadir paréntesis alrededor de la asignación puede silenciar una advertencia y dejar el defecto intacto.
El formato %d espera un int, pero el argumento es un double. Utiliza %f; una incompatibilidad de formato es comportamiento indefinido, no una petición de convertir el valor. Elimina la declaración no utilizada si no cumple ninguna función. Son tres causas distintas, por lo que cada una necesita su propia corrección.
Sigue las pistas
Presta especial atención a los valores sin inicializar, los retornos ausentes, las direcciones de variables locales que se devuelven y sizeof aplicado a parámetros de tipo array. La ausencia de una cabecera puede dejar una función sin declarar; las versiones recientes de GCC y Clang rechazan esto de forma predeterminada. No añadas una conversión explícita solo para hacer desaparecer un mensaje de diagnóstico. Explica primero los tipos y valores previstos y después haz que el código exprese esa intención.
Un programa sin advertencias aún puede tener una condición de bucle incorrecta o un índice erróneo procedente de la entrada. Algunas advertencias también dependen de la optimización y del análisis del flujo de datos, y distintos compiladores detectan casos distintos. Mantén activado el conjunto de advertencias en las construcciones habituales y después investiga el comportamiento en tiempo de ejecución con el depurador y las herramientas de prueba.
Puntos clave
- Elige la versión de C explícitamente.
-Wallno activa todas las advertencias.- Corrige la causa de un mensaje de diagnóstico antes de suprimirlo.
- Una conversión de formato debe corresponder al tipo del argumento.
- Una construcción sin advertencias sigue necesitando pruebas en tiempo de ejecución.
1.2 Elegir las opciones de construcción para depuración, pruebas y distribución
El mismo código fuente puede construirse para facilitar la inspección, realizar pruebas intensivas o distribuir una versión eficiente. Las opciones de construcción determinan qué información puedes ver y qué comprobaciones adicionales se ejecutan. Da a cada construcción un propósito claro.
Conserva la información del código fuente
La opción -g añade nombres, tipos y correspondencias entre instrucciones de máquina y líneas de código fuente. Permite que un depurador muestre tu código y ayuda a los informes de los sanitizadores a identificar ubicaciones. No activa comprobaciones en tiempo de ejecución ni desactiva la optimización. Sin información de depuración, algunos nombres de funciones pueden seguir disponibles, pero las líneas de código fuente y las variables locales son mucho más difíciles de recuperar.
La optimización cambia hasta qué punto la ejecución sigue el orden del código fuente:
| Opción | Uso habitual | Qué puedes observar |
|---|---|---|
-O0 | Depuración sencilla | El orden del código fuente suele ser más fácil de seguir |
-Og | Depuración con optimización ligera | Se mantienen algunas transformaciones |
-O2 | Versión de distribución eficiente | Operaciones reordenadas y variables eliminadas |
Una sesión de depuración optimizada puede saltarse líneas o mostrar una variable como «». El compilador ha conservado el comportamiento del código definido, pero puede que ya no mantenga todos los valores intermedios del código fuente. -O0 no traduce literalmente cada línea de C en una sola instrucción de máquina.
Construye según el propósito
Un comando sencillo de depuración es:
gcc -std=c17 -Wall -Wextra -Wpedantic -g -O0 program.c -o program-debug
Una versión de prueba añade sanitizadores. Una versión de distribución suele usar -O2 y puede definir NDEBUG para eliminar las aserciones. La validación ordinaria de entradas y la gestión de errores deben permanecer activas cuando desaparezcan las aserciones. Prueba también la configuración de distribución: un problema visible solo con optimización es una razón sólida para investigar el comportamiento indefinido, incluidas las lecturas de memoria sin inicializar y el desbordamiento de enteros con signo.
Mantén las opciones compartidas en un Makefile u otra configuración de construcción. Los nombres de salida separados evitan ejecutar accidentalmente un binario de distribución antiguo mientras inspeccionas código fuente recién editado. Después de cada cambio en el código fuente, vuelve a construir el ejecutable que pretendes ejecutar.
Añade análisis y refuerzo de la seguridad
El análisis estático sigue posibles rutas a través del código fuente sin ejecutar el programa. Prueba gcc -fanalyzer o clang --analyze; scan-build, clang-tidy y cppcheck ofrecen otros flujos de análisis. Un analizador puede detectar que una rama de gestión de errores libera un puntero dos veces aunque tus pruebas nunca entren en esa rama. Revisa su razonamiento: el análisis de rutas posibles puede producir falsas alarmas.
El análisis dinámico comprueba la ejecución real. Los sanitizadores y Valgrind solo ven las rutas que ejecutas, por lo que su cobertura depende de tus entradas. Combina ambos tipos de análisis.
El refuerzo de la seguridad para distribución limita los daños causados por los defectos restantes. -fstack-protector-strong coloca comprobaciones en determinados marcos de pila para detectar muchas sobrescrituras. En los sistemas compatibles, _FORTIFY_SOURCE añade comprobaciones a las llamadas de biblioteca cuando se pueden determinar los tamaños de los objetos. Requiere optimización; el nivel 2 es habitual, mientras que el nivel 3 requiere compatibilidad adecuada del compilador y de la biblioteca de C. Son funciones de la plataforma, no garantías de ISO C.
Un programa reforzado puede detenerse cuando detecta corrupción. La operación no válida sigue necesitando una corrección en el código fuente; cambiar las opciones de distribución no puede corregir un bucle que accede fuera de los límites.
Puntos clave
- Utiliza
-gpara obtener información de depuración del código fuente. - Depura con
-O0o-Ogy prueba también la versión de distribución. - Mantén activas las comprobaciones de entrada cuando
NDEBUGelimine las aserciones. - El análisis estático y el análisis en tiempo de ejecución encuentran rutas y defectos distintos.
- Mantén las opciones de construcción en una configuración reproducible.
- El refuerzo de la seguridad reduce el daño de los defectos que quedan.
2Detectar y corregir defectos de programación con un depurador
Un cierre inesperado indica dónde se detuvo la ejecución; el error puede haber ocurrido mucho antes. Empieza con un fallo reproducible y una entrada pequeña y después utiliza un depurador para inspeccionar el programa en momentos elegidos. Los puntos de interrupción y la ejecución paso a paso muestran cómo evolucionan los valores. Los marcos de pila, los puntos de vigilancia y los puntos de interrupción condicionales ayudan a rastrear un valor incorrecto hasta el código que lo creó, de modo que cada experimento responda a una pregunta concreta.
2.1 Depurar de forma metódica: reproducir el fallo, aislarlo y poner a prueba una hipótesis
Un fallo es el comportamiento que observas: un cierre inesperado, un total incorrecto o una salida que nunca aparece. Un defecto de programación es el error que lo causó. Mantenerlos separados evita que cambies la línea del cierre antes de averiguar cómo llegó a ella el programa.
Haz que el fallo sea reproducible
Registra el comando, la entrada, las opciones de construcción, el resultado esperado y el resultado real. Mantén sin cambios la entrada que falla mientras experimentas. Si un programa falla solo con un archivo, guarda ese archivo antes de editarlo. La reproducción te permite juzgar si una corrección propuesta funciona.
Después, reduce el caso conservando el fallo. Elimina registros o código que no estén relacionados y vuelve a ejecutar. Supón que un registro entre 1000 desencadena un cierre inesperado por sí solo. Probar mitades puede localizarlo en unas 10 rondas. Si ninguna mitad falla por sí sola, el fallo puede requerir una secuencia de registros o un estado acumulado anteriormente. Conserva esa secuencia y elimina partes más pequeñas a su alrededor. Dividir por la mitad solo es útil cuando la entrada conservada sigue activando la causa.
Plantea una pregunta comprobable
Un campo vacío en un archivo reducido sugiere una hipótesis: «El analizador utiliza el campo antes de comprobar si existe». Ponla a prueba con una entrada pequeña que contenga un campo vacío. Inspecciona el puntero devuelto o añade una comprobación específica. Cambia una cosa cada vez; si modificas el analizador, el tamaño del búfer y la entrada al mismo tiempo, un resultado satisfactorio no te dirá qué cambio influyó.
Corrige la regla incumplida, vuelve a ejecutar el caso reducido y después vuelve a ejecutar la entrada original y las pruebas existentes. Guarda el desencadenante como una prueba de regresión para que una modificación posterior no pueda restablecer silenciosamente el mismo fallo.
Interpreta la detención
«Segmentation fault» suele indicar SIGSEGV: el sistema detuvo un acceso no válido a la memoria. Un puntero nulo, un puntero liberado o una dirección fuera de rango son causas comunes. Un acceso no válido también puede no provocar un cierre inmediato, por lo que la ausencia de mensajes por sí sola dice poco.
«Aborted» suele indicar SIGABRT, generada por abort(). Puede causarla una aserción fallida o una comprobación de biblioteca que detecta corrupción del montículo. En bash, los estados de salida habituales por señales son 139 para SIGSEGV y 134 para SIGABRT; estos números son convenciones del intérprete, no valores de retorno de C que tu programa deba usar.
Observa sin perder la salida
Un breve mensaje de diagnóstico puede revelar el primer valor incorrecto:
fprintf(stderr, "index=%zu total=%d\n", index, total);
Las etiquetas hacen que la salida sea legible cuando la misma línea se ejecuta muchas veces. stderr es útil para mensajes de diagnóstico porque no utiliza un búfer completo; stdout redirigido a un archivo o una tubería normalmente utiliza un búfer completo, por lo que un cierre inesperado puede hacer que se pierda la salida pendiente. Elimina las impresiones temporales después de la corrección.
Explica en voz alta una función sospechosa línea por línea. Este método del «pato de goma» te obliga a comprobar que cada operación corresponda al comportamiento previsto. Si un defecto cambia cuando añades impresiones o cambias la optimización, investiga el comportamiento indefinido con sanitizadores. Ampliar un array hasta que desaparezca un cierre inesperado puede ocultar un índice no válido sin corregirlo.
Puntos clave
- Guarda una reproducción exacta antes de cambiar el código.
- Reduce la entrada conservando el fallo.
- Pon a prueba una hipótesis con un cambio o una observación controlados.
- Rastrea un cierre inesperado hasta su causa, que puede estar antes.
- Vuelve a ejecutar el caso original y conserva una prueba de regresión.
2.2 Ejecutar, pausar y recorrer paso a paso un programa en gdb o lldb
Un depurador te permite pausar un programa en ejecución e inspeccionar los valores que explican su comportamiento. Utiliza GDB en muchos sistemas Linux o LLDB en macOS. Los botones de depuración de los IDE utilizan las mismas ideas: detener, ejecutar, inspeccionar y avanzar paso a paso.
Empieza con el ejecutable correcto
Compila con -g -O0 y después abre el resultado con gdb ./program o lldb ./program. En la consola del depurador, run records.txt 3 lo inicia con dos argumentos. En GDB, run < input.txt redirige la entrada estándar; en LLDB, utiliza process launch -i input.txt. Usa quit para salir.
Establece un punto de interrupción antes de ejecutar. break main en GDB y b main en LLDB detienen el programa al comienzo de main. Ambos aceptan una ubicación de archivo y línea como b program.c:12. Los comandos completos de LLDB incluyen breakpoint set --name main y breakpoint set --file program.c --line 12.
Un punto de interrupción normalmente detiene el programa antes de que se ejecute la sentencia indicada del código fuente. Si la línea seleccionada asigna un valor, muéstralo después de avanzar sobre ella para inspeccionar el nuevo valor. Vuelve a construir después de editar el código fuente: el depurador ejecuta código máquina, no el texto abierto en tu editor.
Elige cómo avanzar
| Comando | Propósito en GDB y LLDB |
|---|---|
next o n | Ejecutar la línea actual, normalmente pasando por encima de las llamadas |
step o s | Entrar en una función llamada cuando la información de depuración lo permite |
finish | Ejecutar hasta que la función seleccionada termine |
continue o c | Reanudar hasta otra detención o el final del programa |
Otros puntos de interrupción pueden interrumpir next o finish. Utiliza next para una llamada de biblioteca que no necesites inspeccionar. Si entras en código que no deseas examinar, finish devuelve el control a la función llamadora.
Inspecciona un bucle una pasada cada vez
Considera un total inicializado a cero y este array:
int a[5] = {4, 8, 15, 16, 23};
for (int i = 0; i <= 5; ++i) {
sum += a[i];
}
Detente en el cuerpo y muestra i y sum antes de la suma. Después de las primeras cinco sumas, los totales acumulados son 4, 12, 27, 43 y 66. En la siguiente detención, i == 5, pero los índices válidos terminan en 4. Has encontrado la iteración adicional sin necesidad de leer el elemento no válido. Cambia la condición a i < 5, vuelve a construir y ejecuta de nuevo.
print expr o p expr evalúa una expresión en cualquiera de los dos depuradores. Empieza con observaciones seguras como p i y p sum; una expresión del depurador que desreferencia un puntero no válido puede intentar una lectura no válida. display i de GDB repite la impresión en cada detención. info locals en GDB y frame variable en LLDB muestran las variables locales. Para cinco elementos válidos a través de ptr, utiliza p *ptr@5 en GDB o parray 5 ptr en LLDB.
Puedes probar una hipótesis cambiando un valor mientras el programa está pausado: GDB acepta set var i = 4, mientras que LLDB acepta expr i = 4. Esto cambia una ejecución, no el código fuente. Para gestionar los puntos de interrupción, utiliza info breakpoints y delete 1 en GDB, o breakpoint list y breakpoint delete 1 en LLDB.
Puntos clave
- Construye y vuelve a construir el ejecutable con información de depuración.
- La sentencia del código fuente en la que se detiene el programa normalmente aún no se ha ejecutado.
- Utiliza
next,step,finishycontinuepara avanzar de distintas maneras. - Inspecciona los índices y los límites antes de evaluar expresiones que acceden a memoria.
2.3 Diagnosticar cierres inesperados y valores imprevistos
Un cierre inesperado dentro de una biblioteca suele comenzar con un argumento no válido de tu propio código. La pila de llamadas muestra las llamadas a funciones activas. Úsala para pasar de la ubicación de la detención a la función llamadora cuyos valores puedes explicar.
Lee los marcos desde la llamada actual hacia fuera
Escribe bt en cualquiera de los dos depuradores. El marco #0 es la llamada activa más interna; los números de marco mayores corresponden a sus funciones llamadoras. Un marco proporciona el contexto de ejecución de la función, incluidos los argumentos y las variables locales disponibles. La optimización puede eliminar parte de esa información.
Supón que find_name(2) no encuentra ninguna coincidencia y que la función llamadora hace esto:
const char *name = find_name(2);
printf("%zu\n", strlen(name));
La detención puede producirse dentro de strlen. En bt, busca el primer marco que pertenezca a tu código. Selecciónalo con frame 1 en GDB o frame select 1 en LLDB cuando sea el marco 1. Muestra name. Un valor nulo explica por qué falló la operación con la cadena. La función llamadora debe gestionar la ausencia del nombre antes de llamar a strlen; editar la biblioteca no puede corregir el argumento.
Utiliza up y down para desplazarte entre funciones llamadoras y llamadas. Seleccionar un marco cambia lo que muestra el depurador y las variables locales a las que se refiere una expresión. No reanuda la ejecución ni deshace una llamada.
Detente donde cambia un valor
Un punto de interrupción pregunta: «¿Qué ocurre en esta ubicación?». Un punto de vigilancia pregunta: «¿Qué operación cambia este valor?». Si un número de elementos almacenado se vuelve incorrecto mucho antes de un cierre inesperado, pausa el programa mientras ese número esté dentro de su ámbito y utiliza:
GDB: watch count
LLDB: watchpoint set variable count
Continúa hasta que el depurador se detenga ante un cambio. Inspecciona los valores anterior y nuevo y la ubicación en el código fuente de la instrucción que escribió el valor. Un cambio puede ser legítimo, así que compáralo con la regla de la estructura de datos. El hardware ofrece solo un pequeño número de puntos de vigilancia; cuatro es habitual en x86-64. El almacenamiento de una variable local solo tiene sentido mientras su función está activa.
Para una iteración tardía conocida, utiliza un punto de interrupción condicional. break 20 if i == 999 de GDB pausa el programa en la línea 20 solo cuando se cumple la condición. Su equivalente en LLDB es breakpoint set --line 20 --condition 'i == 999'. Esto ahorra cientos de pasos manuales.
Inspecciona un cierre inesperado guardado
Un volcado de memoria del proceso registra el estado de un proceso que se ha cerrado inesperadamente. Donde estén habilitados los volcados, ulimit -c unlimited los permite para los programas iniciados desde ese intérprete. Abre un volcado guardado con gdb ./program core o lldb ./program -c core, utilizando el ejecutable que lo produjo. Las políticas del sistema controlan si se guarda un volcado y dónde; los sistemas Linux basados en systemd pueden gestionarlo mediante coredumpctl.
Ejecuta bt, selecciona tu marco e inspecciona los valores como en un cierre inesperado en directo. El volcado no puede continuar el programa. Si malloc o free detecta corrupción, la escritura incorrecta original puede no aparecer en esta pila. Reproduce el fallo con AddressSanitizer para detenerte más cerca de esa escritura. Si un valor local parece arbitrario, comprueba si la ejecución ha llegado a su inicializador antes de culpar al depurador.
Puntos clave
- Utiliza
btpara seguir las llamadas activas. - Selecciona un marco de tu código e inspecciona los argumentos pasados a la biblioteca.
- Los puntos de vigilancia se detienen ante cambios de valor; los puntos de interrupción condicionales, en estados elegidos.
- Un volcado de memoria del proceso permite inspeccionar el estado después de un cierre inesperado.
- La corrupción puede ser anterior a la pila visible en el cierre final.
3Reconocer y evitar el comportamiento indefinido
Algunas operaciones de C no tienen un resultado garantizado por el lenguaje. Pueden parecer que funcionan en una construcción y fallar en otra, lo que hace que las pruebas por ensayo y error resulten engañosas. Aprende a distinguir el comportamiento indefinido de las decisiones documentadas de la plataforma y de las variaciones permitidas. Después, examina las operaciones aritméticas, el orden de las expresiones, los límites de los arrays y los tiempos de vida de la memoria, y coloca comprobaciones antes de que una operación cruce uno de esos límites.
3.1 Comprender qué significa el comportamiento indefinido
El comportamiento indefinido, o UB, significa que el estándar de C no impone requisitos sobre lo que ocurre después de una operación no válida. No promete una excepción, un mensaje de error útil ni un valor predecible obtenido por reducción modular. Un programa puede cerrarse inesperadamente, corromper datos, mostrar una respuesta plausible o parecer que funciona.
Distingue tres tipos de comportamiento
| Tipo | Qué permite el lenguaje | Ejemplo en C17 |
|---|---|---|
| Indefinido | No se exige ningún comportamiento | Desbordamiento de enteros con signo |
| Definido por la implementación | Una elección documentada de la implementación | Si char sin modificador tiene signo |
| No especificado | Una elección permitida que no necesita documentarse | Orden de evaluación de argumentos independientes de una función |
El comportamiento definido por la implementación puede utilizarse deliberadamente cuando la plataforma documentada forma parte del diseño de un programa. Para que el código sea portable, evita suponer que todas las implementaciones hacen la misma elección. El comportamiento no especificado también es válido: f(a(), b()) puede evaluar primero cualquiera de las dos llamadas independientes. Escribe código cuya corrección se mantenga ante esa variación permitida. El ancho de int y, en C17, el desplazamiento a la derecha de un entero con signo negativo son otras elecciones que dependen de la plataforma.
Por qué la optimización revela defectos
C deja al programador las comprobaciones de los límites de los arrays y de las sumas con signo. El compilador puede optimizar suponiendo que el programa cumple las reglas del lenguaje. Un intento de detectar un desbordamiento puede infringir la misma regla que intenta comprobar:
int will_overflow(int x) {
return x + 1 < x;
}
Para cada x en la que la suma con signo está definida, x + 1 es mayor que x. Por tanto, el compilador puede sustituir el resultado por cero. Con INT_MAX, realizar la suma es comportamiento indefinido, por lo que el compilador no tiene obligación de proporcionar a la función llamadora un resultado reducido modularmente. Ver una respuesta distinta en una construcción sin optimizar no convierte esto en una prueba válida.
La prueba segura se realiza antes de la operación:
if (x == INT_MAX) {
/* handle overflow */
} else {
++x;
}
Utiliza <limits.h> para los límites de la implementación. Un máximo escrito directamente supone un ancho de tipo concreto; la macro indica el límite real.
Comprueba antes del primer uso
Este orden también es esencial para los punteros:
if (p == NULL) {
return -1;
}
int v = *p;
Invertir estas sentencias permite que se desreferencie un puntero nulo antes de la protección. Una vez que el código desreferencia p, un optimizador puede razonar que la ruta definida ya requiere que p no sea nulo y eliminar una comprobación posterior de nulidad.
Cambiar las opciones, añadir una impresión o mover una variable puede alterar el síntoma visible del comportamiento indefinido. Una ejecución satisfactoria solo establece lo que hizo esa construcción para esa entrada. Para corregir el código, identifica qué operación carece de un significado definido y qué condición debe cumplirse antes de ella. Las advertencias pueden identificar algunos casos; el análisis estático puede explorar rutas posibles; ASan y UBSan comprueban muchas operaciones ejecutadas. Las reglas de programación proporcionan los patrones correspondientes en el código fuente, como comprobar un índice o un límite aritmético antes de usarlo.
Puntos clave
- El comportamiento indefinido no garantiza ni un cierre inesperado ni un resultado estable.
- Las elecciones definidas por la implementación deben documentarse.
- Las elecciones no especificadas permanecen dentro de las reglas del lenguaje.
- Los optimizadores pueden basarse en que el desbordamiento de enteros con signo nunca ocurre en código definido.
- Valida un puntero o un límite aritmético antes de la operación de riesgo.
3.2 Identificar el comportamiento indefinido en operaciones aritméticas y expresiones
Una operación aritmética puede fallar antes de que tengas oportunidad de inspeccionar su resultado. Comprueba los operandos mientras sus valores siguen siendo válidos y conoce el tipo que realiza cada operación.
Límites con signo y sin signo
El desbordamiento de enteros con signo es indefinido. Con un int de 32 bits, INT_MAX es 2 147 483 647; sumar uno no es una forma portable de obtener un número negativo. INT_MIN / -1, -INT_MIN y las multiplicaciones demasiado grandes también fallan cuando el resultado matemático no cabe.
La aritmética sin signo se reduce modularmente dentro del rango de su tipo. Para un unsigned int de 32 bits, UINT_MAX + 1u es cero y 0u - 1u es 4 294 967 295. Esto está definido, pero aun así puede estropear un cálculo de tamaño. Un bucle inverso sin signo que use i >= 0 nunca alcanza un valor negativo para detenerse.
Para una suma general con signo, comprueba antes de sumar:
if ((b > 0 && a > INT_MAX - b) ||
(b < 0 && a < INT_MIN - b)) {
/* report overflow */
} else {
result = a + b;
}
Las restas utilizadas en estas comprobaciones permanecen dentro de rango porque cada una está protegida por la condición sobre el signo de b. Calcular primero la suma y después compararla es demasiado tarde. Un tipo de mayor rango solo ayuda cuando puede contener todos los resultados intermedios: convierte un operando antes de la operación. C23 proporciona ckd_add en <stdckdint.h>; GCC y Clang ofrecen __builtin_add_overflow.
División, desplazamientos de bits y conversiones
Rechaza el cero antes de una división entera o de calcular el resto. Rechaza también el mínimo con signo dividido por menos uno cuando su resultado no quepa. Los sistemas de coma flotante IEEE 754 suelen producir infinito o NaN al dividir por cero, lo que permite que los resultados numéricos no válidos se propaguen sin una interrupción como la de los enteros.
Para las operaciones con bits, utiliza operandos sin signo. La cantidad de desplazamiento debe ser no negativa y menor que el ancho del operando izquierdo después de las promociones. Con operandos de 32 bits, desplazar 32 posiciones es indefinido. En C17, desplazar a la izquierda un valor negativo con signo es indefinido; 1 << 31 también es indefinido para un int de 32 bits, mientras que 1u << 31 es válido para un unsigned int de 32 bits.
Una conversión de coma flotante a entero requiere que el valor truncado quepa, incluso cuando el destino es sin signo. (unsigned)-1.0 es indefinido. En cambio, la conversión de entero a un tipo sin signo reduce el valor al rango del destino: (unsigned)-1 se convierte en UINT_MAX. La conversión de un entero fuera de rango a un tipo con signo está definida por la implementación en C17. Comprueba los rangos antes de convertir a un tipo de menor rango; -Wconversion ayuda a revelar los cambios implícitos.
Haz que el orden de evaluación no cause problemas
printf("%d %d\n", i, ++i); lee y cambia i sin el orden requerido. i = i++ + 1; y a[i] = i++; también contienen operaciones no secuenciadas. Divide el trabajo en sentencias separadas con un orden explícito que refleje tu intención.
Inicializa las variables automáticas antes de leerlas: int sum = 0; hace que sum += x; tenga sentido. Las variables globales y estáticas empiezan en cero. El requisito de C23 de representación en complemento a dos no define el desbordamiento de enteros con signo; el código portable sigue necesitando las comprobaciones.
Puntos clave
- Comprueba las operaciones aritméticas con signo antes de calcular su resultado.
- La reducción modular de enteros sin signo está definida, pero puede producir tamaños y bucles incorrectos.
- Comprueba los operandos de la división y las cantidades de desplazamiento de bits.
- La conversión de coma flotante a un tipo sin signo difiere de la conversión de entero a un tipo sin signo.
- Secuencia los cambios explícitamente e inicializa los valores antes de usarlos.
- C23 sigue dejando indefinido el desbordamiento de enteros con signo.
3.3 Identificar el comportamiento indefinido en el uso de punteros, arrays y memoria
Un puntero necesita algo más que una dirección plausible. El objeto debe existir, el acceso debe permanecer dentro de sus límites y la operación debe estar permitida para ese objeto. Estas comprobaciones explican tanto los cierres inesperados inmediatos como la corrupción detectada mucho después.
Permanece dentro del objeto
Un array de n elementos tiene índices desde cero hasta n - 1. Un puntero a la posición inmediatamente posterior al último elemento puede marcar el final de un bucle, pero no debes desreferenciarlo. i <= n visita un elemento de más. CERT ARR30-C trata estos errores de límites.
Una escritura fuera de los límites puede llegar a memoria accesible que pertenece a otro objeto. El programa puede continuar hasta que se utilice un número de elementos, un puntero o un registro del gestor de memoria dañado. Un cierre posterior en free no identifica la escritura original. ASan puede detenerse en la escritura cuando sus comprobaciones detectan la infracción del límite.
Convierte los caracteres en una cadena válida
Una cadena de C termina con '\0'. Su almacenamiento necesita un byte más que su número de caracteres. C acepta char s[5] = "hello";, pero los cinco bytes contienen letras, sin dejar espacio para un terminador. Pasarlo a strlen o a %s puede provocar una lectura más allá del array. Usa espacio suficiente y asegúrate de que el terminador esté realmente presente. STR31-C trata la capacidad; STR32-C trata el paso de una cadena con terminador.
Un literal de cadena no se puede modificar. Después de char *p = "hi";, p[0] = 'H'; es indefinido. Utiliza char p[] = "hi"; para una copia modificable, o const char *p para que el compilador detecte los intentos de cambiar el literal. Esto corresponde a STR30-C.
Respeta el tiempo de vida y la propiedad de la memoria
Comprueba NULL antes de desreferenciar un resultado de una reserva de memoria, apertura de archivo o búsqueda. Cuando se libera un bloque, todos los punteros a su interior pierden el acceso a un objeto vivo. No lo leas, no escribas en él ni lo liberes de nuevo. Libera solo el puntero apropiado devuelto por una reserva dinámica, nunca un array local ni un puntero interior. free(NULL) es inocuo, pero asignar el valor nulo a un puntero después de liberar la memoria no corrige los demás alias.
Una función que devuelve la dirección de un array local devuelve un puntero colgante:
char *greeting(void) {
char text[16] = "Welcome";
return text;
}
El tiempo de vida del array termina cuando la función retorna. Proporciona un búfer y su capacidad para que la función llamadora los pase y la función los rellene, o reserva un resultado cuya propiedad pase a la función llamadora. Una impresión correcta inmediatamente después del retorno no puede prolongar el tiempo de vida del array anterior.
Reserva bytes para cada elemento
malloc(n) solicita bytes, no n objetos del tipo apuntado. La expresión habitual basada en elementos es n * sizeof *a. Primero impide que ese producto se reduzca modularmente:
if (n > SIZE_MAX / sizeof *a) {
/* reject the requested size */
}
Incluye <stdint.h> para SIZE_MAX y <stdlib.h> para las funciones de reserva de memoria. Una vez que el tamaño sea válido, reserva la memoria, comprueba el puntero devuelto y escribe cada valor antes de leerlo. malloc deja la memoria sin inicializar. calloc(n, sizeof *a) rellena su almacenamiento con ceros y las bibliotecas más comunes rechazan los productos que se desbordan. Una comprobación de tamaño, una comprobación del resultado de la reserva y un índice válido resuelven problemas distintos; los tres importan.
Puntos clave
- Desreferencia solo punteros dentro de los límites de un objeto cuyo tiempo de vida esté activo.
- Reserva espacio para el terminador de la cadena y almacénalo.
- No modifiques literales de cadena.
- Liberar un bloque reservado invalida el acceso mediante todos sus alias.
- Devuelve almacenamiento con un tiempo de vida que la función llamadora pueda utilizar.
- Comprueba la multiplicación del número de elementos y el resultado de la reserva de memoria.
4Detectar errores en tiempo de ejecución con aserciones y sanitizadores
Puedes hacer que una versión de prueba compruebe más que su salida final. Las aserciones comprueban los supuestos que tu propio código promete mantener, mientras que los sanitizadores comprueban operaciones como los accesos a memoria y las operaciones aritméticas mientras ocurren. Aprende a leer cada informe, elige una herramienta para el error que sospechas y ejecuta entradas que alcancen las rutas de riesgo. Una rutina de comprobación útil conecta un error detectado con una corrección y una prueba duradera.
4.1 Expresar supuestos mediante aserciones
Una aserción expresa algo que debería ser verdadero porque tu propio código es correcto. Convierte un supuesto incumplido y oculto en una detención cerca del lugar donde importa ese supuesto.
Comprueba un contrato de programación
Incluye <assert.h> y escribe assert(condition);. Una condición verdadera permite que la ejecución continúe. Una condición falsa muestra información de diagnóstico en stderr y llama a abort(). El mensaje identifica la expresión, la ubicación en el código fuente y la función; su redacción exacta depende de la plataforma.
Para una función auxiliar interna que calcula promedios y cuya función llamadora promete argumentos válidos:
double average(const int *a, size_t n) {
assert(a != NULL);
assert(n > 0);
/* compute the average */
}
Una llamada accidental con cero elementos falla en la promesa incumplida. Bajo un depurador, bt muestra el código de la aserción y de la interrupción, seguido de las llamadas de tu código. Selecciona el marco de la función llamadora para inspeccionar cómo proporcionó cero.
Los contratos aparecen en varios puntos:
| Tipo | Qué expresa | Ejemplo |
|---|---|---|
| Precondición | Se requiere al entrar | Un array de entrada no vacío |
| Poscondición | Se garantiza después del trabajo | Un array está ordenado |
| Invariante | Se mantiene a lo largo de los pasos | Un número de elementos almacenado coincide con la estructura |
Una aserción es documentación ejecutable: nombra la propiedad y la comprueba durante una ejecución de prueba. Elige expresiones que observen el estado sin cambiarlo.
Mantén el trabajo necesario fuera de las aserciones
Definir NDEBUG antes de incluir <assert.h>, normalmente con -DNDEBUG, desactiva assert. Su expresión no se evalúa. Esto crea un defecto grave si la expresión realiza trabajo necesario:
assert(++count <= MAX);
El incremento desaparece en una construcción con NDEBUG. Escribe la acción por separado:
++count;
assert(count <= MAX);
La misma regla excluye abrir archivos, reservar memoria o cerrar archivos dentro de una aserción. Esas operaciones deben ocurrir en todas las configuraciones. Sus fallos necesitan una gestión ordinaria de errores.
Valida los fallos esperados de la forma habitual
Un archivo de entrada inexistente, texto de usuario no válido y memoria agotada pueden ocurrir durante el uso correcto del programa. Compruébalos con if, informa del error y retorna o libera los recursos. Una función auxiliar privada puede comprobar un rango validado mediante una aserción, pero el punto público de entrada de datos debe hacer cumplir ese rango con comprobaciones que permanezcan en las versiones de distribución.
Por ejemplo, comprueba siempre fclose(f) == EOF en el flujo de control ordinario cuando cierres un archivo de salida. La salida almacenada en búfer puede fallar durante el cierre; eliminar la llamada o su comprobación puede ocultar la pérdida de datos.
Comprueba hechos fijos en tiempo de compilación
Una aserción estática comprueba una expresión constante al compilar. En C17, <assert.h> proporciona static_assert como macro para _Static_assert de C11:
static_assert(sizeof(int) == 4, "requires a 4-byte int");
Una condición falsa hace que falle la construcción. No puede comprobar un número introducido por el usuario que solo se conoce en tiempo de ejecución. C23 convierte static_assert en una palabra clave y permite omitir el mensaje. Utiliza esta distinción para colocar cada comprobación donde esté disponible la información que necesita.
Puntos clave
- Las aserciones comprueban promesas internas de programación.
- Una aserción fallida en tiempo de ejecución informa de su ubicación y termina el programa.
- Mantén los efectos secundarios fuera de las expresiones de aserción.
- Utiliza la gestión ordinaria de errores para entradas no válidas y fallos de recursos.
NDEBUGdesactiva las aserciones en tiempo de ejecución.- Las aserciones estáticas comprueban expresiones constantes durante la compilación.
4.2 Detectar errores de memoria con AddressSanitizer
AddressSanitizer, normalmente llamado ASan, añade comprobaciones a las operaciones de memoria compiladas. Puede informar de un acceso incorrecto en el momento en que ocurre, antes de que los datos corrompidos provoquen un cierre inesperado en otro lugar.
Construye con las comprobaciones enlazadas
Utiliza GCC o Clang para compilar y enlazar con ASan:
clang -std=c17 -g -fsanitize=address -fno-omit-frame-pointer program.c -o program-asan
-g proporciona ubicaciones del código fuente. Conservar los punteros de marco ayuda a obtener trazas de la pila de llamadas. Para un proyecto de varios archivos, instrumenta los archivos fuente pertinentes y utiliza también la opción del sanitizador en el paso final de enlace. MSVC proporciona /fsanitize=address en las construcciones compatibles de Windows.
ASan detecta muchos desbordamientos de bloques del montículo, arrays de la pila y arrays globales. También detecta usos después de la liberación, dobles liberaciones, liberaciones no válidas y los errores de uso después de que termine el ámbito que admite. En Linux, el entorno de ejecución habitual de ASan incluye la comprobación de fugas al terminar. El Clang incluido por Apple en macOS no proporciona esa detección de fugas; las herramientas de LLVM y la compatibilidad de las plataformas varían.
Sigue un informe hasta la causa
Considera un array reservado correctamente, con un int de 4 bytes:
int *a = malloc(10 * sizeof *a);
for (int i = 0; i <= 10; ++i) {
a[i] = i;
}
La reserva contiene 40 bytes. Los índices de cero a nueve utilizan esos bytes. El índice diez comienza inmediatamente después de ellos. ASan puede informar «heap-buffer-overflow», «WRITE of size 4» y una ubicación a cero bytes después de una región de 40 bytes. La traza de escritura identifica la asignación; la traza de reserva identifica el malloc que estableció los límites del bloque.
Lee los informes en este orden:
- Identifica la categoría del error y si el acceso fue una lectura o una escritura.
- Busca el primer marco de tu propio código en la traza del acceso.
- Compara el acceso con la región reservada y su tamaño de elemento.
- Para un error de tiempo de vida, busca también las trazas de reserva y liberación.
Aquí la corrección es i < 10. Reservar otro elemento y conservar la condición de parada incorrecta oculta la discrepancia entre el número previsto y el bucle. ASan se detiene de forma predeterminada en el primer error detectado. Corrígelo, vuelve a construir y ejecuta de nuevo.
Comprende los límites de la herramienta
ASan sigue la memoria permitida mediante memoria sombra. Las zonas rojas prohibidas rodean las reservas y los arrays admitidos. Los bloques liberados pasan un tiempo en cuarentena antes de reutilizarse, lo que ayuda a revelar accesos a memoria que ya no es válida. Una escritura que sobrepasa un miembro de tipo array y llega a otro miembro de la misma estructura puede permanecer dentro de una región válida mayor y pasar sin detectarse.
ASan no comprueba las lecturas ordinarias de valores sin inicializar. Memcheck de Valgrind puede encontrar esos usos y muchos defectos del montículo sin volver a compilar, pero suele ejecutarse entre 20 y 30 veces más despacio. La ralentización típica de ASan es de unas dos veces, con un uso adicional de memoria. No combines una versión con ASan con Valgrind, MemorySanitizer ni ThreadSanitizer. Ejecuta deliberadamente entradas límite: un error en una ruta no ejecutada no puede aparecer en el informe.
Puntos clave
- Compila y enlaza con
-fsanitize=addresse información de depuración. - Lee conjuntamente la traza del acceso y las trazas de reserva o liberación.
- Corrige la primera causa indicada, vuelve a construir y ejecuta de nuevo.
- ASan no detecta lecturas ordinarias de valores sin inicializar.
- Prueba entradas límite para alcanzar accesos a memoria de riesgo.
4.3 Detectar el comportamiento indefinido con UBSan y establecer una rutina de comprobación
Una herramienta de comprobación de memoria no cubre todas las operaciones no válidas de C. UndefinedBehaviorSanitizer, o UBSan, instrumenta muchas operaciones aritméticas, de punteros y de arrays. Combínalo con ASan para que una ejecución de prueba pueda detectar más de una clase de defecto.
Haz visibles los fallos aritméticos
Con un int de 32 bits, sumar dos valores de 2 000 000 000 requiere un total de 4 000 000 000. Ese resultado supera INT_MAX. Un informe de UBSan identifica el desbordamiento de enteros con signo en la suma e indica los operandos. Mostrar una respuesta negativa que parezca obtenida por reducción modular sin la herramienta de comprobación no haría válida la operación.
Utiliza un tipo para el total que pueda representar el rango aceptado, o rechaza una suma antes de que se desborde. Convertir el resultado ya desbordado a long long es demasiado tarde; la propia operación debe usar un tipo adecuado.
Un comando habitual de prueba es:
clang -std=c17 -Wall -Wextra -g -fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=undefined program.c -o program-check
UBSan normalmente permite continuar después de muchos informes, por lo que el código posterior puede seguir ejecutándose. -fno-sanitize-recover=undefined hace que esos fallos detengan la prueba con un estado de error. Esto ayuda a las comprobaciones automatizadas a detectar un defecto aunque la salida final mostrada parezca correcta.
Elige la herramienta según el defecto
| Herramienta | Propósito principal | Limitación importante |
|---|---|---|
| UBSan | Muchas operaciones indefinidas de aritmética, desplazamiento de bits, punteros nulos, alineación y arrays | No detecta todos los tipos de comportamiento indefinido |
| ASan | Muchos errores de límites y de tiempo de vida | No sigue los valores ordinarios sin inicializar |
| MemorySanitizer | Usos de memoria sin inicializar | Necesita dependencias instrumentadas para obtener resultados fiables |
| ThreadSanitizer | Carreras de datos | Requiere una construcción separada de ASan |
La reducción modular de enteros sin signo está definida, por lo que el grupo undefined no informa de ella. Clang tiene una comprobación independiente, -fsanitize=unsigned-integer-overflow, para código en el que esa reducción indique un defecto de programación. No deduzcas que todo resultado entero no deseado es comportamiento indefinido.
MemorySanitizer es una herramienta de Clang utilizada principalmente en Linux; las bibliotecas sin instrumentar pueden confundir su seguimiento. ThreadSanitizer detecta accesos incompatibles de hilos sin sincronización, con al menos una escritura. Los accesos no tienen que ocurrir en el mismo instante exacto. Ninguna de las dos herramientas se combina con ASan en una sola construcción.
Convierte la comprobación en una rutina
Mantén activadas las advertencias y resuélvelas. Construye con ASan y UBSan y después ejecuta entradas vacías, entradas de un elemento, búferes llenos, texto no válido y valores cercanos a los límites aceptados. Ejecuta análisis estático para inspeccionar rutas que las pruebas pueden pasar por alto y repite las comprobaciones automáticamente cuando cambie el código. Ejecuta también la configuración de distribución, donde la optimización y las opciones de aserción son distintas.
Las pruebas con entradas generadas o mutadas automáticamente proporcionan muchas entradas generadas o modificadas. libFuzzer y AFL++ pueden revelar rutas poco habituales del analizador; los sanitizadores convierten una operación no válida desencadenada en un informe útil. Guarda la entrada que detecte un defecto, redúcela sin perder el fallo, corrige la causa y consérvala como prueba de regresión. Una ejecución sin informes es una prueba sobre las entradas ejecutadas, no una garantía para todas las ejecuciones posibles.
Puntos clave
- Utiliza UBSan para muchos errores aritméticos y de expresiones.
- Haz que los fallos de UBSan detengan las pruebas automatizadas cuando continuar pudiera ocultarlos.
- La reducción modular de enteros sin signo necesita una comprobación independiente cuando no se desea.
- Utiliza construcciones separadas para MemorySanitizer o ThreadSanitizer.
- Combina pruebas límite, pruebas con entradas generadas o mutadas automáticamente, análisis estático y entradas de regresión.
- La cobertura de los sanitizadores depende de la ejecución.
5Escribir código C más seguro
Un C más seguro comienza donde los datos entran en un programa y continúa en cada cálculo de tamaño, copia, reserva de memoria y ruta de gestión de errores. Un estándar de programación convierte los defectos recurrentes en comprobaciones concretas que puedes aplicar mientras escribes código. Aprende a conservar los tamaños de los búferes, validar entradas numéricas completas y mantener una propiedad clara de los recursos. Después, evalúa estas responsabilidades frente al control de C sobre la memoria y la protección que ofrecen los lenguajes con seguridad de memoria.
5.1 Utilizar un estándar de programación segura
Un estándar de programación segura reúne reglas concretas que previenen defectos recurrentes. Proporciona a quien revisa el código preguntas más útiles que «¿Parece seguro?». Cada regla conecta una operación de riesgo con la condición o el diseño necesarios para hacerla segura.
Lee una regla para entender su motivo
El estándar de programación segura en C de SEI CERT procede del Instituto de Ingeniería de Software de la Universidad Carnegie Mellon. La edición en libro de 2016 va acompañada de una versión en línea que sigue evolucionando. Distingue entre reglas, cuyo incumplimiento probablemente cause defectos, y recomendaciones que mejoran la fiabilidad y la seguridad.
Los identificadores combinan una categoría, un número y -C. Las reglas usan los números 30-99; las recomendaciones usan 00-29. Por ejemplo, INT32-C se refiere al desbordamiento de enteros con signo, mientras que DCL00-C es una recomendación sobre declaraciones. Las categorías incluyen declaraciones (DCL), expresiones (EXP), enteros (INT), coma flotante (FLP), arrays (ARR), cadenas (STR), memoria (MEM), entrada y salida (FIO), entorno (ENV), errores (ERR) y asuntos varios (MSC).
La página de una regla normalmente explica el defecto, muestra código no conforme, presenta soluciones conformes y evalúa el riesgo. Lee los supuestos de cada solución. Una función de sustitución solo es útil cuando su comportamiento cumple realmente el requisito.
Aplica una regla a una ruta de datos
FIO30-C se refiere a las cadenas de formato. Este código trata los datos externos como un programa de formato:
printf(user_name);
Un nombre de usuario que contenga conversiones de formato hace que printf busque argumentos que no se proporcionaron; %n puede provocar una escritura en memoria. Mantén el formato bajo control del programa:
printf("%s", user_name);
Como alternativa, fputs(user_name, stdout) imprime el texto sin procesar formatos. -Wformat=2 incluye comprobaciones que ayudan a encontrar formatos inseguros. La corrección cambia cómo se interpretan los datos, no solo la longitud que puede tener la cadena.
Crea una lista de comprobación útil
Para la memoria, pregunta si los índices permanecen dentro de los objetos (ARR30-C), si el almacenamiento de cadenas incluye el terminador (STR31-C), si nunca se accede al almacenamiento liberado (MEM30-C), si se liberan los bloques reservados cuya propiedad se tiene (MEM31-C), si solo se liberan reservas válidas (MEM34-C) y si se reservan suficientes bytes (MEM35-C).
Para las expresiones, comprueba la reducción modular involuntaria de enteros sin signo (INT30-C), el desbordamiento de enteros con signo (INT32-C), la división no válida (INT33-C), las lecturas de memoria sin inicializar (EXP33-C) y las desreferenciaciones de punteros nulos (EXP34-C). En los límites de la biblioteca, gestiona los fallos indicados (ERR33-C) y los errores de conversión (ERR34-C). Evita system() como indica ENV33-C, porque un intérprete de comandos interpreta su entrada.
Las tablas de riesgo valoran la gravedad, la probabilidad y el coste de corrección del 1 al 3; una puntuación de coste mayor significa una corrección más barata. Su producto es la prioridad, de 1 a 27. Los niveles agrupan las prioridades en L1 (12-27), L2 (6-9) y L3 (1-4). Esto ayuda a ordenar las correcciones sin considerar inocuos los defectos de menor prioridad.
Utiliza las reglas durante la revisión de código y emplea los resultados de los analizadores vinculados a sus identificadores. MISRA C es otro estándar utilizado en trabajos con sistemas embebidos de seguridad crítica. Las reglas ayudan a prevenir defectos, mientras que las advertencias, las pruebas y los sanitizadores comprueban si la implementación realmente las sigue. Memorizar los identificadores por sí solo no puede detectar un terminador ausente o una rama de gestión de errores omitida.
Puntos clave
- Lee conjuntamente el mecanismo del fallo y la solución conforme.
- Los identificadores CERT distinguen categorías, reglas y recomendaciones.
- Mantén los datos externos fuera de las cadenas de formato.
- Aplica comprobaciones concretas a los límites, el tiempo de vida, las operaciones aritméticas y los errores.
- Utiliza las valoraciones de riesgo para ordenar el trabajo y después verifica el código con herramientas.
5.2 Manejar cadenas y búferes de forma segura
El manejo seguro de cadenas requiere un origen válido, una capacidad de destino conocida y una decisión sobre el texto que no quepa. Una función limitada por tamaño evita algunas sobrescrituras, pero aún debes comprobar si toda la operación se completó correctamente.
Lleva la capacidad junto con el puntero
sizeof mide el tipo de su operando. Para un char buf[20] local, da 20 bytes. En void fill(char buf[]), el parámetro se ajusta a un puntero; sizeof buf mide ese puntero, no el array de la función llamadora. Pasa un parámetro de capacidad, como en fill(buf, sizeof buf), mientras la función llamadora aún tiene el tipo array.
Una cadena que contiene cinco caracteres necesita seis bytes, incluido '\0'. La capacidad cuenta cada byte en el que se puede escribir; la longitud de la cadena excluye el terminador. Por eso malloc(strlen(src)) reserva un byte de menos para una copia.
Lee una línea con límite de tamaño
Utiliza fgets(buf, sizeof buf, stdin) y comprueba su valor de retorno antes de usar el contenido. Almacena como máximo la capacidad menos un carácter y después un terminador. Conserva un salto de línea cuando este cabe. Después de una lectura correcta, buf[strcspn(buf, "\n")] = '\0'; elimina un salto de línea almacenado.
Si una línea larga llena el búfer, los caracteres no leídos permanecen para la siguiente llamada. Decide si rechazas y descartas el resto, lees más o aceptas el truncamiento. Eliminar un salto de línea no demuestra que hayas leído la línea completa. gets no puede recibir una capacidad y se eliminó en C11; nunca lo uses.
Para una sola palabra en char name[20], %19s en scanf limita los caracteres almacenados y deja espacio para el terminador. Comprueba que se haya realizado correctamente el número esperado de conversiones. Para texto numérico no confiable, prefiere una línea completa seguida de una conversión comprobada con strtol; %d tiene comportamiento indefinido cuando el valor convertido no se puede representar, aunque inspecciones el número de asignaciones devuelto por scanf.
Detecta una copia acortada
strcpy y strcat no conocen la capacidad del destino. strncpy puede dejar el resultado sin terminador. Con una capacidad positiva, snprintf termina el texto almacenado e informa de cuántos caracteres necesitaba la salida completa:
char dst[8];
int r = snprintf(dst, sizeof dst, "%s", "debugging");
if (r < 0) {
/* report encoding failure */
} else if ((size_t)r >= sizeof dst) {
/* report truncation */
}
La cadena almacenada es «debuggi» y r es 9. La salida completa necesita diez bytes, incluido su terminador. Comprueba si el resultado es negativo antes de convertirlo a size_t. Para que el resultado quepa, r debe ser menor que la capacidad.
Adapta la operación al almacenamiento
Utiliza memmove cuando el origen y el destino se solapen; memcpy con regiones solapadas es indefinido. strlcpy está disponible en sistemas BSD, macOS y glibc a partir de la versión 2.38, pero no forma parte de ISO C. Las funciones del anexo K son opcionales y no están disponibles en muchas bibliotecas.
Mantén los datos del usuario fuera del propio formato. Haz que cada conversión corresponda a su argumento: %d para int, %ld para long, %zu para size_t, %f para double y %s para una cadena con terminador. %p requiere un void *, así que pasa una conversión adecuada del puntero. Un límite de ancho no puede corregir un tipo de argumento incompatible.
Puntos clave
- Pasa la capacidad del destino junto con el puntero.
- Reserva un byte para el terminador de la cadena.
- Gestiona explícitamente las líneas de entrada incompletas.
- Comprueba si
snprintfha fallado o ha truncado el resultado. - Utiliza
memmovepara regiones de memoria solapadas. - Haz que cada conversión de formato corresponda al tipo de su argumento.
5.3 Validar la entrada y gestionar los errores
Los datos externos pueden ser sintácticamente válidos y, aun así, no ser seguros para la operación que pretendes realizar. Valida su longitud, su formato completo y el rango útil antes de que se conviertan en un índice, un tamaño de reserva o un número de iteraciones.
Analiza el número completo
atoi no ofrece una forma fiable de distinguir un cero válido de texto no válido, y un resultado que no se puede representar tiene comportamiento indefinido. strtol proporciona un puntero al final de la conversión y un error de rango. Después de leer una línea completa y eliminar su salto de línea, un analizador decimal estricto puede usar:
errno = 0;
char *end;
long v = strtol(text, &end, 10);
if (end == text || *end != '\0' || errno == ERANGE) {
/* reject the numeric text */
} else if (v < 0 || v > 150) {
/* reject this application's range */
} else {
int age = (int)v;
/* use age */
}
Incluye <errno.h> y <stdlib.h>. La primera comprobación distingue tres fallos: ausencia de dígitos, texto al final y un valor fuera del rango de long. La comprobación del rango de la aplicación se realiza antes de convertir a int. strtol acepta espacios iniciales y un signo opcional; decide si la gramática de la aplicación los permite.
Para «42abc», la conversión puede devolver 42, pero end apunta a las letras restantes. Para «abc», no se consume ningún dígito. Para un entero decimal demasiado grande, errno toma el valor ERANGE. Un número devuelto distinto de cero no demuestra por sí solo que la conversión esté completa ni que el rango sea seguro.
Conserva la propiedad de los recursos ante un fallo
Toda operación que pueda fallar necesita una respuesta. Comprueba los resultados de las reservas de memoria, fopen, fgets, los números de asignaciones y la finalización de la salida. Que fgets devuelva NULL puede significar el fin de la entrada o un error de lectura. fclose puede informar de un fallo de escritura de datos almacenados en búfer. Utiliza la información de error apropiada para cada función; muchas llamadas de biblioteca establecen errno, que perror o strerror pueden convertir en un mensaje de diagnóstico legible.
Para cambiar a un tamaño positivo, protege el cálculo del tamaño y la reserva original:
if (new_n == 0) {
free(a);
a = NULL;
} else if (new_n > SIZE_MAX / sizeof *a) {
/* reject the size */
} else {
int *tmp = realloc(a, new_n * sizeof *a);
if (tmp == NULL) {
/* handle failure; a still owns its old block */
} else {
a = tmp;
}
}
Asignar realloc directamente al único puntero propietario hace que se pierda la dirección del bloque anterior si la operación falla. Gestiona el cero por separado: C17 define el comportamiento de realloc con tamaño cero según la implementación, mientras que C23 lo deja indefinido. Después de cambiar el tamaño correctamente, utiliza el puntero devuelto y descarta los alias que ya no sean válidos.
Deja una ruta de gestión de errores clara
Una única sección de liberación puede liberar los recursos adquiridos en orden inverso. Inicializa los punteros propietarios a NULL, cierra solo los archivos abiertos y devuelve un estado de fallo después de informar de la causa. Un goto cleanup puede facilitar la revisión de estas rutas.
Evita system() para tareas que impliquen texto externo: el intérprete de comandos puede interpretar ese texto como comandos. Prefiere una operación de biblioteca o una interfaz adecuada para iniciar programas con los argumentos pasados por separado. Trata los argumentos de la línea de comandos, los valores del entorno, el contenido de archivos y los datos de red como entradas que requieren el mismo cuidado que el texto escrito por el usuario.
Puntos clave
- Valida la conversión completa y el rango de la aplicación antes de utilizar un número.
- Restablece
errnoantes destrtole inspecciona su puntero al final de la conversión. - Comprueba los fallos de biblioteca y conserva los recursos en todas las rutas.
- Utiliza un puntero temporal para
realloccon tamaño positivo. - Gestiona los tamaños cero y el desbordamiento de la multiplicación antes de cambiar el tamaño.
- Libera solo los recursos que se hayan adquirido realmente.
- Mantén el texto externo fuera de las cadenas de comandos del intérprete.
5.4 Evaluar las ventajas y los inconvenientes de las decisiones sobre seguridad de memoria
La seguridad de memoria significa que los accesos permanecen dentro del almacenamiento que un programa tiene derecho a utilizar. C proporciona control directo de la memoria y deja al programador las comprobaciones de límites y de tiempo de vida.
Distingue los límites del tiempo de vida
La seguridad espacial mantiene un acceso dentro de los límites de su objeto. Escribir el elemento diez de un array de diez elementos la infringe. La seguridad temporal mantiene el acceso dentro del tiempo de vida del objeto. Leer un bloque reservado después de free, liberarlo dos veces o utilizar la dirección devuelta de una variable local infringe las reglas de tiempo de vida.
Las consecuencias pueden ir más allá de un cierre inesperado. Una lectura puede revelar datos confidenciales. Una escritura puede corromper un archivo, cambiar datos de control o crear una vulnerabilidad de seguridad cuando una entrada externa controla la operación. Un programa que continúa después de la corrupción puede producir resultados convincentes pero falsos.
Esta carga también afecta a equipos experimentados. Microsoft informó en 2019 de que cerca del 70 % de las vulnerabilidades a las que había asignado identificadores CVE, año tras año durante más de una década, se referían a la seguridad de memoria. En diciembre de 2023, CISA y organismos colaboradores instaron a los fabricantes de software a planificar la transición hacia lenguajes con seguridad de memoria. Son observaciones históricas concretas, no un porcentaje medido para todos los programas en C.
Comprende los motivos para elegir C
C ofrece control sobre la distribución y la reserva de memoria y el acceso al hardware, sin requerir un recolector de basura. Su pequeño entorno de ejecución es adecuado para firmware con recursos limitados. Cuenta con amplia compatibilidad de compiladores, grandes bases de código existentes e interfaces a las que pueden llamar muchos otros lenguajes. Estas ventajas explican su uso en núcleos de sistemas operativos, software embebido, entornos de ejecución y bibliotecas.
No hacen que todos los programas en C sean predeciblemente rápidos: los algoritmos, las reservas de memoria, las cachés y las llamadas al sistema siguen importando. La ventaja relevante es el control sobre los costes y los requisitos del entorno de ejecución.
| Enfoque | Protección de memoria | Coste o responsabilidad |
|---|---|---|
| C | Límites y tiempos de vida controlados por el programador | Razonamiento, comprobaciones y pruebas manuales |
| Rust seguro | Propiedad, préstamo y comprobaciones de límites | Aprender sus reglas de propiedad |
| Go, Java, C#, Python | Tiempos de vida gestionados y comprobaciones de límites | Los costes de ejecución y memoria varían |
| Swift | Recuento de referencias y accesos comprobados | Los costes de propiedad y ejecución varían |
Las operaciones inseguras y las interfaces con C necesitan especial cuidado en sistemas que por lo demás tienen seguridad de memoria. La seguridad de memoria también deja pendientes los errores lógicos y la inyección mediante entradas.
Elige capas para el riesgo restante
Prevén defectos con reglas de programación, advertencias y revisión. Detecta defectos con análisis estático, pruebas límite, sanitizadores y pruebas con entradas generadas o mutadas automáticamente. Limita el daño restante con protectores de pila, comprobaciones _FORTIFY_SOURCE compatibles, aleatorización de la distribución del espacio de direcciones (ASLR) y memoria no ejecutable. Una medida de mitigación puede detener un ataque o convertir la corrupción en un cierre inesperado, aunque el defecto siga presente en el código fuente.
Los costes orientan su uso. ASan suele duplicar el tiempo de ejecución y aumentar el uso de memoria; Valgrind suele ralentizar la ejecución entre 20 y 30 veces. Son más adecuados para las pruebas que para el uso ordinario en versiones de distribución. El refuerzo de la seguridad de menor coste suele permanecer en las versiones de distribución.
Para un nuevo analizador de archivos de internet sin restricciones que exijan C, un lenguaje con seguridad de memoria reduce un riesgo importante. Para firmware existente en C en un microcontrolador con 32 KB de memoria, la compatibilidad del lenguaje y los límites de recursos pueden favorecer C con comprobaciones rigurosas. Basa la elección en las restricciones, la exposición a entradas y la facilidad de mantenimiento; sustituir gradualmente un analizador de riesgo también puede ser práctico.
Puntos clave
- La seguridad espacial se refiere a los límites; la seguridad temporal, a los tiempos de vida.
- Los defectos de memoria pueden revelar datos y corromper resultados.
- C ofrece control directo y amplia compatibilidad, con la carga de garantizar la seguridad manualmente.
- Los lenguajes con seguridad de memoria eliminan muchos defectos de memoria, pero dejan otras clases de defectos.
- Combina prevención, detección y medidas de mitigación para distribución.
- Elige un lenguaje según las restricciones reales de recursos e integración.
Ejemplos de Depuración y código seguro en C
1Construir un programa que puedas inspeccionar
9 pasos
- 1IntroducciónEmpieza antes de la primera ejecución
Un programa puede compilarse y aun así contener un defecto que el compilador sabe señalar. Tu primera tarea es solicitar esos mensajes, como advertencias, e interpretarlos.
Inspeccionarás una asignación accidental y una incompatibilidad de formato, y después elegirás opciones para una sesión de depuración (una compilación de depuración) y una versión de distribución. Estas decisiones preparan las herramientas posteriores para mostrar pruebas útiles en lugar de direcciones sin explicación o valores locales ausentes.
- 2ExplicaciónElige el conjunto de advertencias
Empieza con
-std=c17 -Wall -Wextra -Wpedantic. La primera opción fija la versión del lenguaje; las demás activan advertencias comunes, advertencias adicionales y mensajes de diagnóstico para muchas desviaciones de ese estándar.-Wallno significa todas las advertencias. Añade-Wconversionpara las conversiones implícitas que cambian valores y-Wformat=2para comprobaciones de formato más estrictas.-Werrorconvierte las advertencias activadas en errores de construcción. - 3EjemploLee la condición
El programador quiere informar de que una colección está vacía:
if (items = 0) { puts("No items"); }Una advertencia del compilador señala una asignación en una condición. Pregunta primero qué hace la expresión: almacena cero en
itemsy después la condición comprueba ese cero. El cuerpo nunca se ejecuta. Sustituye=por==para comprobar el número de elementos existente; añadir paréntesis alrededor de la asignación conservaría la operación incorrecta. - 4DesafíoCorrige el tipo de salida
double ratio = 2.5;se imprime conprintf("%d\n", ratio);. ¿Qué debería cambiar y por qué?---reveal---
Utiliza
printf("%f\n", ratio);.%despera unint, mientras que%fespera undoubleenprintf. Un formato incompatible tiene comportamiento indefinido. Convertir explícitamente aintcambiaría el número impreso al descartar su parte fraccionaria, por lo que no cumpliría el objetivo de mostrar la proporción. - 5ConsejoLee los mensajes en orden
Un mensaje de diagnóstico suele indicar la ubicación en el código fuente, el problema y la opción responsable. Lee también las notas relacionadas; pueden identificar la declaración en conflicto.
Corrige el primer error del compilador y después vuelve a construir. La ausencia de un punto y coma puede confundir el análisis posterior y crear una cascada de mensajes. Una vez eliminados los errores, inspecciona la causa de cada advertencia. Una construcción sin advertencias sigue necesitando pruebas de lógica y de errores que dependen de la entrada.
- 6ExplicaciónConserva un estado útil
Para una construcción sencilla de depuración, añade
-g -O0. La información de depuración relaciona la ejecución con los nombres y las líneas del código fuente; una optimización baja mantiene disponible más estado intermedio para su inspección.-gno desactiva la optimización. Con-O2, el código aún puede depurarse, pero puede parecer que las líneas saltan y las variables pueden eliminarse mediante la optimización.-Oges otra opción de desarrollo que conserva una optimización ligera. - 7ConexiónElige comprobaciones para distintas tareas
El análisis estático sigue posibles rutas del código fuente sin ejecutar el programa. Un sanitizador comprueba operaciones en las rutas que las pruebas realmente ejecutan. Utiliza ambos para ampliar la cobertura.
El refuerzo de la seguridad para distribución cumple otro propósito: los protectores de pila y las comprobaciones de fortificación compatibles pueden detectar o limitar parte de la corrupción restante. La fortificación requiere optimización y compatibilidad adecuada de la biblioteca. Una sobrescritura detectada sigue necesitando una corrección en el código fuente.
- 8DesafíoMantén la validación en la versión de distribución
Una versión de depuración utiliza advertencias estrictas y aserciones. Una versión de distribución añade
-O2 -DNDEBUG. El programador propone utilizarassert(valid_input)como única comprobación de entrada. ¿Qué debe cambiar antes de la distribución?---reveal---
Valida la entrada con el flujo de control ordinario y gestiona el fallo.
NDEBUGelimina la evaluación de las aserciones, por lo que la comprobación propuesta desaparece. Mantén activadas las advertencias y prueba tanto la configuración de distribución como la versión de depuración; las distintas opciones pueden revelar defectos ocultos. - 9RepasoRelaciona las opciones de construcción
Utiliza los mensajes del compilador para identificar las operaciones que necesitan corrección. Haz corresponder los formatos con los tipos, investiga las conversiones y corrige los primeros errores antes de seguir los mensajes que provocan.
Para la siguiente sesión de depuración, construye el ejecutable correcto con información del código fuente y un nivel de optimización adecuado. Mantén las configuraciones reproducibles, añade análisis y pruebas con sanitizadores, y conserva las comprobaciones de entrada cuando las opciones de distribución eliminen las aserciones.
2Rastrear un total incorrecto y un cierre inesperado
12 pasos
Guarda esta unidad para seguir cada Lección Interactiva paso a paso.
- 1IntroducciónConvierte un síntoma en una pregunta
- 2ExplicaciónConserva el caso que falla
- 3DesafíoElige un experimento controlado
- 4EjemploPausa en el cuerpo del bucle
- 5EjemploSigue las pasadas válidas
- 6DesafíoCorrige y verifica la pasada adicional
- 7ConsejoEntra solo en código útil
- 8EjemploVe a la función llamadora
- 9ExplicaciónDetecta un cambio cuando ocurre
- 10DesafíoInvestiga un cierre inesperado en el gestor de memoria
- 11ConexiónUtiliza el estado guardado de un cierre inesperado
- 12RepasoElige la vista que responde a la pregunta
3Comprobar la operación antes de que deje de ser válida
12 pasos
Guarda esta unidad para seguir cada Lección Interactiva paso a paso.
- 1IntroducciónPregunta qué garantiza C
- 2ExplicaciónDistingue la variación de un defecto
- 3EjemploUna prueba de desbordamiento que llega demasiado tarde
- 4DesafíoColoca la comprobación del puntero
- 5EjemploProtege una suma con signo
- 6Idea claveUna reducción modular definida también puede estropear el programa
- 7DesafíoConvierte a un tipo de mayor rango antes de multiplicar
- 8ConsejoComprueba varios límites de las expresiones
- 9EjemploDistingue un marcador de final de un elemento
- 10EjemploSigue cada alias después de la liberación
- 11DesafíoCorrige un total y su tiempo de vida
- 12RepasoComprueba el tipo, el rango, los límites y el tiempo de vida
4Interpretar las comprobaciones como pruebas de un defecto concreto
11 pasos
Guarda esta unidad para seguir cada Lección Interactiva paso a paso.
- 1IntroducciónComprueba más que la salida final
- 2ExplicaciónComprueba una promesa interna con una aserción
- 3DesafíoMantén la operación en todas las construcciones
- 4ExplicaciónConstruye un ejecutable instrumentado
- 5EjemploInterpreta la región indicada
- 6DesafíoCorrige el bucle correspondiente
- 7Idea claveLa ausencia de un informe tiene varias explicaciones
- 8EjemploHaz que un fallo aritmético detenga la prueba
- 9ExplicaciónElige otras herramientas de comprobación de forma deliberada
- 10DesafíoMejora una rutina de comprobación incompleta
- 11RepasoConvierte los informes en comprobaciones reproducibles
5Mantener explícitos la entrada, los tamaños y la propiedad de los recursos
15 pasos
Guarda esta unidad para seguir cada Lección Interactiva paso a paso.
- 1IntroducciónSigue los datos por el programa
- 2ExplicaciónUtiliza la regla como pregunta
- 3DesafíoMantén la entrada como datos
- 4EjemploPasa la capacidad desde la función llamadora
- 5EjemploDistingue un prefijo seguro de una operación completa
- 6DesafíoGestiona una línea incompleta
- 7EjemploInspecciona el final de la conversión
- 8EjemploAplica el rango del programa
- 9EjemploConserva el puntero propietario al cambiar el tamaño
- 10DesafíoPlanifica la ruta de fallo
- 11ExplicaciónIdentifica el límite de seguridad
- 12ConexiónElige capas con funciones distintas
- 13DesafíoElige un lenguaje
- 14DesafíoRevisa la ruta completa de entrada
- 15RepasoMantén visible cada límite
Ejercicios de Depuración y código seguro en C
Set 1: Tipos, rangos y objetos vivos10 Preguntas de Práctica
Pregunta 1 de 10
merge(left(), right()). Se permite cualquiera de los dos órdenes de evaluación y ningún estado compartido crea un conflicto. ¿Cómo se clasifica ese orden en C?Mostrar respuestaOcultar respuesta
Pregunta 2 de 10
unsigned int de 32 bits. ¿Qué valor almacena unsigned int slots = 0u - 1u;?Mostrar respuestaOcultar respuesta
slots se utiliza después como número de elementos para una reserva o como límite de un array.Pregunta 3 de 10
unsigned int de 32 bits. Una entrada proporciona shift = 32. ¿Cuál es el estado de 1u << shift?Mostrar respuestaOcultar respuesta
Pregunta 4 de 10
Mostrar respuestaOcultar respuesta
Pregunta 5 de 10
local después de declarar char local[24] = "ready";. ¿Por qué no es válido leer el resultado en la función llamadora?Mostrar respuestaOcultar respuesta
Pregunta 6 de 10
int a con signo y int b positivo, ¿qué condición detecta un desbordamiento de a + b sin evaluar esa suma?Mostrar respuestaOcultar respuesta
b positiva, restarla de INT_MAX produce el mayor valor seguro para a. Comparar antes de la suma evita el desbordamiento durante la comprobación. a + b > INT_MAX intenta primero la operación potencialmente indefinida, por lo que no puede servir como protección válida frente al desbordamiento de enteros con signo.Pregunta 7 de 10
int old = *p; antes de if (p == NULL) return -1;. Una prueba con entrada nula parece alcanzar el retorno de error con poca optimización, pero provoca un cierre inesperado en la versión de distribución. ¿Qué corrección establece una ruta definida para la entrada nula?Mostrar respuestaOcultar respuesta
Pregunta 8 de 10
int de 32 bits y long long de 64 bits, ambos factores son int: units = 70000, rate = 40000. ¿Qué expresión calcula correctamente su producto en long long?Mostrar respuestaOcultar respuesta
long long, que puede representar 2 800 000 000. Convertir explícitamente el producto int ya calculado ocurre después de que se haya producido el desbordamiento de enteros con signo. El tipo de mayor rango de la variable de destino por sí solo no cambia los tipos de los operandos utilizados en la multiplicación.Pregunta 9 de 10
p; alias apunta a su interior. Una llamada a realloc con tamaño positivo tiene éxito y el bloque se mueve. La función asigna el resultado a p, escribe mediante alias y después libera tanto p como alias. ¿Qué corrección de la propiedad de la memoria se necesita?Mostrar respuestaOcultar respuesta
Pregunta 10 de 10
-fsanitize=undefined. Una multiplicación de tipo size_t se reduce modularmente, pero no produce ningún informe. ¿Por qué puede ocurrir esto?Mostrar respuestaOcultar respuesta
size_t es un tipo entero sin signo y su reducción modular es un comportamiento definido de C, aunque el tamaño resultante sea incorrecto. Por tanto, el grupo undefined no incluye la comprobación de desbordamiento sin signo. Añade comprobaciones explícitas de tamaño; Clang también ofrece una comprobación independiente para la reducción modular involuntaria de enteros sin signo.Set 2: Pistas del compilador y decisiones con el depurador10 Preguntas de Práctica
Guarda esta unidad para ver todas las respuestas y practicar cada set.
Pregunta 1 de 10
strlen y el marco dos en tu función render. Quieres inspeccionar el puntero caption de render en GDB. ¿Qué debes hacer?Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Pregunta 2 de 10
-Wall sin advertencias convierte silenciosamente un número de elementos de double a int. ¿Qué opción adicional de advertencia detecta este cambio implícito de valor?Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Pregunta 3 de 10
double reading = 7.25; y llama a printf("%d\n", reading);. ¿Qué corrección conserva ese objetivo?Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Pregunta 4 de 10
Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Pregunta 5 de 10
Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Pregunta 6 de 10
Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Pregunta 7 de 10
result = compute();. Al mostrar result aparece su valor anterior y en esta detención aún no se ha ejecutado ninguna llamada. ¿Cómo debes inspeccionar el resultado calculado?Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Pregunta 8 de 10
Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Pregunta 9 de 10
-O0 y -O2. Con -O2, una variable intermedia tampoco está disponible en el depurador. ¿Qué investigación aborda ambas observaciones?Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Pregunta 10 de 10
free después de leer muchos registros. Ampliar un búfer de nombre detiene el cierre visible, pero la entrada guardada sigue haciendo que ASan informe de una escritura más allá de ese búfer. ¿Qué acción aborda la causa?Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Set 3: Aserciones e informes de sanitizadores10 Preguntas de Práctica
Guarda esta unidad para ver todas las respuestas y practicar cada set.
Pregunta 1 de 10
Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Pregunta 2 de 10
assert(user_count > 0); como único mecanismo para rechazar números de elementos no válidos introducidos por el usuario. Las versiones de distribución definen NDEBUG. ¿Qué debería sustituir esa protección?Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Pregunta 3 de 10
Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Pregunta 4 de 10
Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Pregunta 5 de 10
Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Pregunta 6 de 10
-fsanitize=undefined no emiten informes, pero el valor vecino y el tamaño de reserva son incorrectos. ¿Qué investigación aborda ambos defectos?Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Pregunta 7 de 10
Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Pregunta 8 de 10
Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Pregunta 9 de 10
assert(++used < capacity); antes de almacenar en el índice used. Las pruebas pasan, pero con NDEBUG sobrescribe repetidamente una misma posición. ¿Qué cambio en el código fuente conserva la actualización de estado prevista en ambas construcciones?Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Pregunta 10 de 10
size_t, reserva un búfer cuyo tamaño depende del número de elementos y lo rellena. No comprueba que la conversión esté completa ni el rango de la aplicación. ¿Qué orden de corrección aborda la ruta desde la entrada hasta la memoria?Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Set 4: Cadenas, entrada numérica y propiedad de los recursos10 Preguntas de Práctica
Guarda esta unidad para ver todas las respuestas y practicar cada set.
Pregunta 1 de 10
Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Pregunta 2 de 10
char line[48] a read_line(char line[]) en un sistema con punteros de ocho bytes. ¿Qué mide sizeof line dentro de la función?Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Pregunta 3 de 10
snprintf con una capacidad de destino de 11 y devuelve 11. ¿Qué demuestra esto?Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Pregunta 4 de 10
errno y convierte «64kg» con strtol. La conversión devuelve 64 y errno permanece en cero. ¿Qué debe provocar el rechazo?Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Pregunta 5 de 10
strtol, comprueba solo errno, convierte el resultado explícitamente a size_t y lo multiplica por el tamaño del elemento para reservar memoria. ¿Qué comprobaciones deben preceder a la reserva?Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Pregunta 6 de 10
a. Después de leer en new_n un número positivo de elementos, el código hace a = realloc(a, new_n * sizeof *a); a[new_n - 1] = 0;. Debe gestionar cada número válido y conservar el array anterior si falla el cambio de tamaño. ¿Qué secuencia se requiere?Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Pregunta 7 de 10
fgets, sin almacenar ningún salto de línea. La aplicación requiere un registro completo por línea. ¿Qué gestión evita tratar un posible resto como un registro nuevo?Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Pregunta 8 de 10
strncpy(dst, src, sizeof dst) y después imprime dst con %s. Supón que los punteros ocupan ocho bytes. ¿Qué rediseño cumple el requisito?Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Pregunta 9 de 10
size_t count de un archivo e inmediatamente ejecuta a = realloc(a, count * sizeof *a);. Se permiten arrays vacíos y una solicitud fallida debe conservar el array anterior. ¿Qué rediseño cumple estos requisitos para cualquier número de elementos?Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Pregunta 10 de 10
Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Set 5: Corregir defectos que interactúan10 Preguntas de Práctica
Guarda esta unidad para ver todas las respuestas y practicar cada set.
Pregunta 1 de 10
Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Pregunta 2 de 10
int i = 4;, un programador escribe printf("%d %d\n", i, ++i);. ¿Qué corrección hace que la salida prevista «valor anterior y después valor nuevo» esté bien definida?Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Pregunta 3 de 10
Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Pregunta 4 de 10
Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Pregunta 5 de 10
word, calcula int bytes = strlen(word);, reserva esa cantidad de bytes, copia la cadena completa, libera el bloque nuevo y devuelve su dirección. Las entradas pueden ser demasiado largas para int. ¿Qué diseño aborda el tamaño y el tiempo de vida?Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Pregunta 6 de 10
strtol y comprueba solo errno != ERANGE. Después convierte explícitamente el resultado a int para usarlo como índice de un array. ¿Qué comprobaciones faltan y pueden permitir todavía una entrada no válida?Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Pregunta 7 de 10
Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Pregunta 8 de 10
Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Pregunta 9 de 10
Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
Pregunta 10 de 10
Mostrar respuestaOcultar respuesta
Guarda esta unidad para ver esta respuesta.
¿Te atascas con una pregunta tuya? Ayuda con Tareas
Términos Clave
A
Acceso fuera de los límites
AddressSanitizer
Advertencia del compilador
Análisis estático
Aritmética con comprobación de desbordamiento
ckd_add de C23 informa de si una suma cabe para que el programa pueda gestionar el fallo explícitamente.Aserción
Aserción estática
static_assert de C17 puede rechazar una plataforma donde no se cumple un supuesto necesario sobre el tamaño de int.ASLR
C
Cadena de formato
printf usa %zu para size_t; el texto del usuario pertenece a un argumento de datos separado.Capacidad del búfer
Carrera de datos
Caso mínimo que reproduce el fallo
Código no conforme
printf(input) permite que los datos externos proporcionen conversiones de formato e infringe FIO30-C.Comportamiento definido por la implementación
char sin modificador tiene o no tiene signo.Comportamiento indefinido
Comportamiento no especificado
Conversión implícita
double a un int descarta la parte fraccionaria cuando el valor truncado cabe en el tipo.Cuarentena
D
Defecto de programación
Defensa en profundidad
Depurador
Desbordamiento de enteros con signo
int de 32 bits, sumar uno a INT_MAX no puede producir un siguiente valor portable y definido.Doble liberación
E
Efectos secundarios no secuenciados
i como ++i a una llamada mezcla una lectura y un cambio sin un orden seguro.Ejecución paso a paso
next para pasar por encima de una llamada y step cuando necesites entrar en el código de la función.Entrada no confiable
Error de compilación
Error de rango
errno, un error de rango de strtol establece ERANGE aunque se devuelva un valor límite.Estándar de programación segura
Estándar de programación segura en C de SEI CERT
Evaluación de expresiones
count observa un escalar; evaluar *p intenta un acceso a través de ese puntero.F
Fallo
free puede ser el fallo visible causado por una escritura mucho más anterior en la ejecución.H
Hipótesis
I
Información de depuración
-g ayuda a un depurador a identificar la línea de código fuente y las variables locales en un punto de interrupción ordinario.Instrumentación
Invariante
Inyección de comandos del intérprete
system() a partir de un argumento sin comprobar permite que un intérprete de comandos interprete la sintaxis proporcionada.L
Lenguaje con seguridad de memoria
Longitud de una cadena
strlen("safe") es cuatro, mientras que almacenar la cadena completa requiere cinco bytes.M
Marco de pila
name, seleccionar su marco después de un cierre inesperado permite inspeccionar ese puntero.Memoria sombra
MemorySanitizer
Mensaje de diagnóstico del compilador
%d e identificar el argumento double que no corresponde a él.O
Ocultación de variables
count de un bucle interno puede recibir actualizaciones mientras el count externo permanece sin cambios.Optimización
-O2, un depurador puede no mostrar un valor separado para una variable local que el compilador eliminó.P
Pila de llamadas
main.Portabilidad
INT_MAX evita suponer que todas las implementaciones dan a int el mismo valor máximo.Poscondición
Precondición
Prioridad del riesgo
Promoción de enteros
int o unsigned int antes de muchas operaciones aritméticas y de desplazamiento de bits.Propiedad de la memoria
realloc con tamaño positivo.Prueba de regresión
Pruebas con entradas generadas o mutadas automáticamente
Puntero al final de la conversión
strtol identifica el primer carácter no convertido o el inicio de la entrada si no se consumió ningún dígito.Puntero colgante
Punto de interrupción
sum += a[i] te permite inspeccionar i sin evaluar el acceso al array.Punto de interrupción condicional
i == 650 evita recorrer manualmente las iteraciones anteriores del bucle.Punto de vigilancia
R
Reducción modular de enteros sin signo
Refuerzo de la seguridad
Regiones de memoria solapadas
memmove porque memcpy prohíbe el solapamiento.Ruta de liberación de recursos
S
Seguridad espacial de la memoria
Seguridad temporal de la memoria
free infringe la seguridad del tiempo de vida aunque su dirección siga pareciendo conocida.Solución conforme
%s mantiene el texto proporcionado en el argumento de datos de una llamada a printf.T
Terminador nulo
ThreadSanitizer
Tiempo de vida de un objeto
Traza de la pila de llamadas
bt te ayuda a localizar el primer marco del código fuente que pertenece a tu propio código.Truncamiento
snprintf al menos igual a la capacidad indica que el texto completo no cabía.U
UndefinedBehaviorSanitizer
Uso después de la liberación
V
Valor indeterminado
int sum; e inmediatamente usar sum += x; lee un valor que nunca se inicializó.Versión de depuración
-g -O0 facilita seguir un bucle defectuoso una sentencia del código fuente cada vez.Versión de distribución
NDEBUG elimine sus aserciones.Volcado de memoria del proceso
Z
Zona roja
Sobre esta unidad
Crea tus propios Materiales de Estudio
Convierte tus notas, diapositivas y documentos en Guías de Estudio, Preguntas de Práctica, Flashcards y 6 herramientas más.